Files
core-cooldown/src/lib/components/Stepper.svelte
T
lashman 81b0775b57 a11y: Tasks 2-6 -- App shell, Titlebar, ToggleSwitch, Stepper, animate.ts
- Add skip-to-content link and dynamic document title (App.svelte)
- Wrap titlebar in header landmark, enlarge traffic lights to 44px (Titlebar.svelte)
- Enlarge toggle switch to 52x28, improve OFF knob contrast (ToggleSwitch.svelte)
- Enlarge stepper buttons to 36px, add keyboard hold-to-repeat (Stepper.svelte)
- Add keyboard feedback to pressable, focus glow to glowHover (animate.ts)
2026-02-18 19:18:15 +02:00

110 lines
2.9 KiB
Svelte

<script lang="ts">
interface Props {
value: number;
min?: number;
max?: number;
step?: number;
formatValue?: (v: number) => string;
onchange?: (value: number) => void;
label?: string;
}
let {
value = $bindable(),
min = 0,
max = 100,
step = 1,
formatValue = (v: number) => String(v),
onchange,
label = "Value",
}: Props = $props();
let holdTimer: ReturnType<typeof setTimeout> | null = null;
let holdInterval: ReturnType<typeof setInterval> | null = null;
function decrement() {
if (value > min) {
value = Math.max(min, value - step);
onchange?.(value);
}
}
function increment() {
if (value < max) {
value = Math.min(max, value + step);
onchange?.(value);
}
}
function startHold(fn: () => void) {
fn();
// Initial delay before repeating
holdTimer = setTimeout(() => {
// Start repeating, accelerate over time
let delay = 150;
function tick() {
fn();
delay = Math.max(40, delay * 0.85);
holdInterval = setTimeout(tick, delay) as unknown as ReturnType<typeof setInterval>;
}
tick();
}, 400);
}
function stopHold() {
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
if (holdInterval) { clearTimeout(holdInterval as unknown as ReturnType<typeof setTimeout>); holdInterval = null; }
}
function handleKeydown(fn: () => void, e: KeyboardEvent) {
if (["Enter", " "].includes(e.key)) {
e.preventDefault();
startHold(fn);
}
}
function handleKeyup(e: KeyboardEvent) {
if (["Enter", " "].includes(e.key)) {
stopHold();
}
}
</script>
<div class="flex items-center gap-1.5" role="group" aria-label={label}>
<button
type="button"
aria-label="Decrease"
class="flex h-9 w-9 min-h-[44px] min-w-[44px] items-center justify-center rounded-lg
border border-[#3a3a3a] bg-[#1a1a1a] text-text-sec transition-colors
hover:bg-[#1c1c1c] hover:text-white
disabled:opacity-20"
onmousedown={() => startHold(decrement)}
onmouseup={stopHold}
onmouseleave={stopHold}
onkeydown={(e) => handleKeydown(decrement, e)}
onkeyup={handleKeyup}
disabled={value <= min}
>
&minus;
</button>
<span class="min-w-[38px] text-center text-[13px] tabular-nums text-white" aria-live="polite" aria-atomic="true">
{formatValue(value)}
</span>
<button
type="button"
aria-label="Increase"
class="flex h-9 w-9 min-h-[44px] min-w-[44px] items-center justify-center rounded-lg
border border-[#3a3a3a] bg-[#1a1a1a] text-text-sec transition-colors
hover:bg-[#1c1c1c] hover:text-white
disabled:opacity-20"
onmousedown={() => startHold(increment)}
onmouseup={stopHold}
onmouseleave={stopHold}
onkeydown={(e) => handleKeydown(increment, e)}
onkeyup={handleKeyup}
disabled={value >= max}
>
+
</button>
</div>