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>
139 lines
4.2 KiB
Svelte
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>
|