Add WCAG 2.1 Level AA accessibility across all components
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
import { timer, currentView, formatTime, type TimerSnapshot } from "../stores/timer";
|
||||
import { config } from "../stores/config";
|
||||
import TimerRing from "./TimerRing.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { scaleIn, fadeIn, pressable } from "../utils/animate";
|
||||
import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities";
|
||||
|
||||
@@ -70,14 +71,36 @@
|
||||
);
|
||||
|
||||
const isModal = $derived(!$config.fullscreen_mode && !standalone);
|
||||
|
||||
// Focus trap: keep Tab cycling within break screen
|
||||
let breakContainer = $state<HTMLElement>(undefined!);
|
||||
$effect(() => {
|
||||
if (!breakContainer) return;
|
||||
function trapFocus(e: KeyboardEvent) {
|
||||
if (e.key !== "Tab") return;
|
||||
const focusable = breakContainer.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
} else {
|
||||
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
}
|
||||
breakContainer.addEventListener("keydown", trapFocus);
|
||||
return () => breakContainer.removeEventListener("keydown", trapFocus);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if standalone}
|
||||
<!-- ── Standalone break window: horizontal card, transparent background ── -->
|
||||
<div class="standalone-card" use:scaleIn={{ duration: 0.5 }}>
|
||||
<div class="standalone-card" use:scaleIn={{ duration: 0.5 }} bind:this={breakContainer}>
|
||||
<!-- Ripples emanate from the ring, visible outside the card -->
|
||||
<div class="standalone-ring-area">
|
||||
<div class="ripple-container">
|
||||
<div class="ripple-container" aria-hidden="true">
|
||||
<div class="break-ripple ripple-1" style="--ripple-color: {$config.break_color}"></div>
|
||||
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}"></div>
|
||||
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}"></div>
|
||||
@@ -88,6 +111,8 @@
|
||||
size={140}
|
||||
strokeWidth={5}
|
||||
accentColor={$config.break_color}
|
||||
label="Break timer"
|
||||
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
|
||||
>
|
||||
<div class="break-breathe-counter">
|
||||
<span
|
||||
@@ -103,19 +128,19 @@
|
||||
|
||||
<!-- Right side: text + buttons -->
|
||||
<div class="standalone-content">
|
||||
<h2 class="text-[17px] font-medium text-white mb-1.5">
|
||||
<h2 class="text-[17px] font-medium text-white mb-1.5" tabindex="-1">
|
||||
{$timer.breakTitle}
|
||||
</h2>
|
||||
<p class="text-[12px] leading-relaxed text-[#666] mb-4 max-w-[240px]">
|
||||
<p class="text-[12px] leading-relaxed text-[#8a8a8a] mb-4 max-w-[240px]">
|
||||
{$timer.breakMessage}
|
||||
</p>
|
||||
|
||||
{#if $config.show_break_activities}
|
||||
<div class="rounded-xl border border-[#ffffff08] bg-[#ffffff06] px-4 py-2.5 mb-4 max-w-[260px]">
|
||||
<div class="text-[9px] font-medium tracking-[0.15em] text-[#555] uppercase mb-1">
|
||||
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase mb-1">
|
||||
{getCategoryLabel(currentActivity.category)}
|
||||
</div>
|
||||
<p class="text-[12px] leading-relaxed text-[#999]">
|
||||
<p class="text-[12px] leading-relaxed text-[#999]" aria-live="polite">
|
||||
{currentActivity.text}
|
||||
</p>
|
||||
</div>
|
||||
@@ -126,9 +151,9 @@
|
||||
<button
|
||||
use:pressable
|
||||
class="rounded-full border border-[#333] px-5 py-2 text-[11px]
|
||||
tracking-wider text-[#666] uppercase
|
||||
tracking-wider text-[#8a8a8a] uppercase
|
||||
transition-colors duration-200
|
||||
hover:border-[#444] hover:text-[#aaa]"
|
||||
hover:border-[#444] hover:text-[#ccc]"
|
||||
onclick={cancelBreak}
|
||||
>
|
||||
{cancelBtnText}
|
||||
@@ -147,7 +172,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if $config.snooze_limit > 0}
|
||||
<p class="mt-2 text-[9px] text-[#333]">
|
||||
<p class="mt-2 text-[9px] text-[#8a8a8a]">
|
||||
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
|
||||
</p>
|
||||
{/if}
|
||||
@@ -155,7 +180,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Bottom progress bar with clip-path -->
|
||||
<div class="standalone-progress-container">
|
||||
<div class="standalone-progress-container" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
|
||||
<div class="standalone-progress-track">
|
||||
<div
|
||||
class="h-full transition-[width] duration-1000 ease-linear"
|
||||
@@ -170,6 +195,7 @@
|
||||
<div
|
||||
class="relative h-full flex items-center justify-center"
|
||||
style="background: #000;"
|
||||
bind:this={breakContainer}
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-col items-center"
|
||||
@@ -177,7 +203,7 @@
|
||||
>
|
||||
<!-- Break ring with breathing pulse + ripples -->
|
||||
<div class="mb-6 relative" use:scaleIn={{ duration: 0.6, delay: 0.1 }}>
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none" aria-hidden="true">
|
||||
<div class="break-ripple ripple-1" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
|
||||
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
|
||||
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
|
||||
@@ -189,6 +215,8 @@
|
||||
size={isModal ? 160 : 200}
|
||||
strokeWidth={isModal ? 5 : 6}
|
||||
accentColor={$config.break_color}
|
||||
label="Break timer"
|
||||
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
|
||||
>
|
||||
<div class="break-breathe-counter">
|
||||
<span
|
||||
@@ -204,12 +232,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-2 text-lg font-medium text-white" use:fadeIn={{ delay: 0.25, y: 10 }}>
|
||||
<h2 class="mb-2 text-lg font-medium text-white" tabindex="-1" use:fadeIn={{ delay: 0.25, y: 10 }}>
|
||||
{$timer.breakTitle}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#555]"
|
||||
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#8a8a8a]"
|
||||
class:mb-4={$config.show_break_activities}
|
||||
class:mb-8={!$config.show_break_activities}
|
||||
use:fadeIn={{ delay: 0.35, y: 10 }}
|
||||
@@ -222,10 +250,10 @@
|
||||
class="mb-8 mx-auto max-w-[320px] rounded-2xl border border-[#1a1a1a] bg-[#0a0a0a] px-5 py-3.5 text-center"
|
||||
use:fadeIn={{ delay: 0.4, y: 10 }}
|
||||
>
|
||||
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#444] uppercase">
|
||||
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
|
||||
{getCategoryLabel(currentActivity.category)}
|
||||
</div>
|
||||
<p class="text-[13px] leading-relaxed text-[#999]">
|
||||
<p class="text-[13px] leading-relaxed text-[#999]" aria-live="polite">
|
||||
{currentActivity.text}
|
||||
</p>
|
||||
</div>
|
||||
@@ -236,9 +264,9 @@
|
||||
<button
|
||||
use:pressable
|
||||
class="rounded-full border border-[#222] px-6 py-2.5 text-[12px]
|
||||
tracking-wider text-[#555] uppercase
|
||||
tracking-wider text-[#8a8a8a] uppercase
|
||||
transition-colors duration-200
|
||||
hover:border-[#333] hover:text-[#999]"
|
||||
hover:border-[#333] hover:text-[#ccc]"
|
||||
onclick={cancelBreak}
|
||||
>
|
||||
{cancelBtnText}
|
||||
@@ -257,7 +285,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if $config.snooze_limit > 0}
|
||||
<p class="mt-3 text-[10px] text-[#2a2a2a]">
|
||||
<p class="mt-3 text-[10px] text-[#8a8a8a]">
|
||||
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
|
||||
</p>
|
||||
{/if}
|
||||
@@ -265,7 +293,7 @@
|
||||
|
||||
<!-- Bottom progress bar for modal -->
|
||||
{#if isModal}
|
||||
<div class="break-modal-progress-container">
|
||||
<div class="break-modal-progress-container" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
|
||||
<div class="break-modal-progress-track">
|
||||
<div
|
||||
class="h-full transition-[width] duration-1000 ease-linear"
|
||||
@@ -278,7 +306,7 @@
|
||||
|
||||
<!-- Fullscreen progress bar - anchored to bottom of screen -->
|
||||
{#if !isModal}
|
||||
<div class="absolute bottom-8 left-12 right-12 h-[3px] rounded-full overflow-hidden">
|
||||
<div class="absolute bottom-8 left-12 right-12 h-[3px] rounded-full overflow-hidden" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round($timer.breakProgress * 100)} aria-label="Break progress">
|
||||
<div class="w-full h-full" style="background: rgba(255,255,255,0.03);">
|
||||
<div
|
||||
class="h-full transition-[width] duration-1000 ease-linear"
|
||||
|
||||
Reference in New Issue
Block a user