Files
Mangalord/frontend/src/lib/components/SegmentedControl.svelte
MechaCat02 194adf1339 fix(ui): meet 44px touch-target floor on shared mobile primitives
The desktop/mobile consistency review found primary mobile controls
rendering below the ~44px comfortable touch floor. Address the genuinely
shared primitives in one pass:

- Add a --tap-min: 44px token (documented anchor for the floor).
- tokens.css: text inputs, selects, and submit buttons grow to --tap-min
  under the 640px breakpoint (fixes the 36px auth/login form controls).
  Scoped to form controls + type=submit so dense icon buttons aren't
  inflated.
- SegmentedControl: .seg options reach the floor on mobile (it doubles as
  the catalog sort toggle and the Library tab switcher).
- Chip: expand .chip-remove's tappable area to --tap-min via a centered
  pseudo-element so the 16px glyph stays compact and tag rows don't grow.

Pure-CSS responsive change — jsdom can't evaluate @media or layout, so the
test pins the stylesheet contract (each shared primitive bumps to --tap-min
inside the mobile breakpoint), guarding against the bump being dropped.

The per-route .icon-btn (copy-pasted across seven files) is left for a
separate refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:27 +02:00

139 lines
4.2 KiB
Svelte

<script lang="ts" generics="T extends string">
interface Option {
label: string;
value: T;
}
let {
options,
value,
onchange,
ariaLabel,
testid
}: {
options: Option[];
value: T;
onchange: (next: T) => void;
ariaLabel: string;
testid?: string;
} = $props();
// Roving-tabindex refs so arrow keys can move focus across the group.
let buttons = $state<HTMLButtonElement[]>([]);
function select(index: number) {
const opt = options[index];
if (!opt) return;
if (opt.value !== value) onchange(opt.value);
// Selection follows focus, per the WAI-ARIA radiogroup pattern.
buttons[index]?.focus();
}
// ArrowLeft/Up and ArrowRight/Down move (and wrap) through the options;
// Home/End jump to the ends. Without this a control announced as a
// `radiogroup` would not respond to the keys AT users expect.
function onkeydown(event: KeyboardEvent) {
// Navigate relative to the focused button, not the selected `value`:
// this control is parent-controlled, so `value` only updates after a
// re-render — anchoring on focus keeps rapid keypresses from sticking.
// Falls back to `value` when focus is elsewhere (e.g. event delegated
// from outside a child, as in unit tests).
const focused = buttons.findIndex((b) => b === document.activeElement);
const current = focused >= 0 ? focused : options.findIndex((o) => o.value === value);
switch (event.key) {
case 'ArrowRight':
case 'ArrowDown':
event.preventDefault();
select((current + 1) % options.length);
break;
case 'ArrowLeft':
case 'ArrowUp':
event.preventDefault();
select((current - 1 + options.length) % options.length);
break;
case 'Home':
event.preventDefault();
select(0);
break;
case 'End':
event.preventDefault();
select(options.length - 1);
break;
}
}
</script>
<!-- Keydown is delegated here; focus lives on the child radios via roving
tabindex, so the group itself is intentionally not a focus target. -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div
class="segmented"
role="radiogroup"
aria-label={ariaLabel}
data-testid={testid}
{onkeydown}
>
{#each options as opt, i (opt.value)}
<button
bind:this={buttons[i]}
type="button"
role="radio"
aria-checked={opt.value === value}
tabindex={opt.value === value ? 0 : -1}
class="seg"
class:active={opt.value === value}
onclick={() => {
if (opt.value !== value) onchange(opt.value);
}}
data-testid={testid ? `${testid}-${opt.value}` : undefined}
>
{opt.label}
</button>
{/each}
</div>
<style>
.segmented {
display: inline-flex;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 2px;
gap: 2px;
}
.seg {
height: 32px;
padding: 0 var(--space-3);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
font-size: var(--font-sm);
font-weight: var(--weight-medium);
cursor: pointer;
transition:
background var(--transition),
color var(--transition);
}
.seg:hover:not(.active) {
color: var(--text);
}
.seg.active {
background: var(--surface-elevated);
color: var(--text);
box-shadow: var(--shadow-sm);
}
/* This control doubles as the primary sort toggle (catalog) and the
Library tab switcher on mobile, so its options must meet the touch
floor on phones. */
@media (max-width: 640px) {
.seg {
min-height: var(--tap-min);
}
}
</style>