refactor(geschichten): migrate TypeSelector onto SegmentedControl primitive

Refs #857
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-16 19:02:17 +02:00
parent 533196dabb
commit a3343f898f
2 changed files with 20 additions and 43 deletions

View File

@@ -1,11 +1,9 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { radioGroupNav } from '$lib/shared/actions/radioGroupNav';
import SegmentedControl from '$lib/shared/primitives/SegmentedControl.svelte';
type GeschichteType = 'STORY' | 'JOURNEY';
const TYPES: GeschichteType[] = ['STORY', 'JOURNEY'];
interface Props {
onweiter: (type: GeschichteType) => void;
}
@@ -15,10 +13,6 @@ let { onweiter }: Props = $props();
let selected = $state<GeschichteType | null>(null);
let announcement = $state('');
// Roving-tabindex holder: falls back to the first card so keyboard nav can start
// even when nothing is selected (all cards at tabindex=-1 would be a keyboard dead-spot).
const rovingFocusType = $derived(selected ?? TYPES[0]);
function select(type: GeschichteType) {
selected = type;
announcement = '';
@@ -32,15 +26,10 @@ function handleWeiter() {
onweiter(selected);
}
const titles: Record<GeschichteType, () => string> = {
STORY: m.journey_selector_story_title,
JOURNEY: m.journey_selector_journey_title
};
const descs: Record<GeschichteType, () => string> = {
STORY: m.journey_selector_story_desc,
JOURNEY: m.journey_selector_journey_desc
};
const typeOptions = [
{ value: 'STORY', label: m.journey_selector_story_title() },
{ value: 'JOURNEY', label: m.journey_selector_journey_title() }
];
</script>
<div>
@@ -48,31 +37,14 @@ const descs: Record<GeschichteType, () => string> = {
{m.journey_selector_question()}
</p>
<div
role="radiogroup"
aria-labelledby="type-selector-label"
class="grid grid-cols-1 gap-4 sm:grid-cols-2"
use:radioGroupNav={(v) => {
if (TYPES.includes(v as GeschichteType)) select(v as GeschichteType);
<SegmentedControl
options={typeOptions}
value={selected ?? ''}
onChange={(v) => {
if (v === 'STORY' || v === 'JOURNEY') select(v);
}}
>
{#each TYPES as type (type)}
<button
type="button"
role="radio"
value={type}
aria-checked={selected === type}
tabindex={type === rovingFocusType ? 0 : -1}
onclick={() => select(type)}
class="min-h-[64px] cursor-pointer rounded border px-4 py-3 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {selected === type
? 'border-primary bg-primary text-primary-fg'
: 'border-line bg-surface text-ink hover:border-primary/50'}"
>
<span class="block font-sans text-sm font-bold">{titles[type]()}</span>
<span class="mt-1 block font-sans text-xs text-current opacity-70">{descs[type]()}</span>
</button>
{/each}
</div>
label={m.journey_selector_question()}
/>
<div aria-live="polite" aria-atomic="true" class="sr-only">{announcement}</div>

View File

@@ -18,13 +18,18 @@ describe('TypeSelector', () => {
await expect.element(page.getByRole('radio', { name: /Lesereise/i })).toBeVisible();
});
it('radiogroup is correctly labelled', async () => {
it('radiogroup is correctly labelled (via aria-label or aria-labelledby)', async () => {
render(TypeSelector, { props: { onweiter: vi.fn() } });
const group = document.querySelector('[role="radiogroup"]');
const labelledBy = group?.getAttribute('aria-labelledby');
// SegmentedControl uses aria-label; the old TypeSelector used aria-labelledby.
// Accept either as long as the accessible name is non-empty.
const ariaLabel = group?.getAttribute('aria-label') ?? '';
const labelledBy = group?.getAttribute('aria-labelledby') ?? '';
const labelEl = labelledBy ? document.getElementById(labelledBy) : null;
expect(labelEl?.textContent?.trim().length).toBeGreaterThan(0);
const hasAccessibleName =
ariaLabel.trim().length > 0 || (labelEl?.textContent?.trim().length ?? 0) > 0;
expect(hasAccessibleName).toBe(true);
});
it('Weiter button has aria-disabled=true when nothing is selected', async () => {