fix(geschichte-ui): a11y and visual round-3 batch
- JourneyItemCard: 'Brief öffnen' back to a >=44px touch target with the height regression spec restored - GeschichteListRow: REISE badges text-[10px] -> text-xs; drop the hardcoded aria-label and the mobile badge's aria-hidden so phone screen readers learn a row is a Lesereise; mobile avatar initials -> color dot - detail page: badge text-xs, metabar Edit/Delete h-9 -> h-11, avatar color keyed by name to match the list - JourneyReader: dead border-subtle class -> border-line-2 - DocumentPickerDropdown: aria-controls only while the listbox exists - JourneyAddBar: aria-expanded/aria-controls on both toggles + focus hand-off into the revealed picker input / interlude textarea - GeschichteSidebar: section h2s hidden below sm (summary already shows the label there) - JourneyCreate: bg-brand-navy -> semantic bg-primary/text-primary-fg; title maxlength=255 - JourneyItemRow interlude: neutral frame border + left accent only, token utilities instead of arbitrary var() syntax and inline style Review round 3: Leonie (1-8 + round-1 leftovers), Elicit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -84,7 +84,9 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
autocomplete="off"
|
||||
aria-label={placeholder}
|
||||
aria-expanded={picker.isOpen}
|
||||
aria-controls={listboxId}
|
||||
aria-controls={picker.isOpen && !picker.loading && !picker.error && picker.results.length > 0
|
||||
? listboxId
|
||||
: undefined}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeOptionId}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -39,9 +39,7 @@ const authorName = $derived(formatAuthorName(geschichte.author));
|
||||
{#if isJourney}
|
||||
<span
|
||||
data-testid="journey-badge"
|
||||
aria-label="Lesereise"
|
||||
style="font-size: 10px"
|
||||
class="inline-flex items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-[10px] font-bold tracking-wide text-journey uppercase"
|
||||
class="inline-flex items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-xs font-bold tracking-wide text-journey uppercase"
|
||||
>
|
||||
{m.journey_badge_list()}
|
||||
</span>
|
||||
@@ -52,13 +50,12 @@ const authorName = $derived(formatAuthorName(geschichte.author));
|
||||
<div class="min-w-0 flex-1 p-3 sm:px-4">
|
||||
<!-- Compact meta line (mobile only) -->
|
||||
<div class="mb-1 flex items-center gap-1.5 sm:hidden">
|
||||
<!-- 7px initials render as smudge at this size — a plain color dot reads better -->
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full font-sans text-[7px] font-bold text-white"
|
||||
class="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style="background-color: {personAvatarColor(authorName)}"
|
||||
>
|
||||
{getInitials(authorName)}
|
||||
</span>
|
||||
></span>
|
||||
<span class="font-sans text-sm font-semibold text-ink">{authorName}</span>
|
||||
{#if publishedAt}
|
||||
<span class="ml-auto font-sans text-sm text-ink-3">{publishedAt}</span>
|
||||
@@ -72,9 +69,7 @@ const authorName = $derived(formatAuthorName(geschichte.author));
|
||||
{#if isJourney}
|
||||
<span
|
||||
data-testid="journey-badge-mobile"
|
||||
aria-hidden="true"
|
||||
style="font-size: 10px"
|
||||
class="inline-flex shrink-0 items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-[10px] font-bold tracking-wide text-journey uppercase sm:hidden"
|
||||
class="inline-flex shrink-0 items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-xs font-bold tracking-wide text-journey uppercase sm:hidden"
|
||||
>
|
||||
{m.journey_badge_list()}
|
||||
</span>
|
||||
|
||||
@@ -79,12 +79,12 @@ describe('GeschichteListRow', () => {
|
||||
expect(badge?.tagName.toLowerCase()).toBe('span');
|
||||
});
|
||||
|
||||
it('badge has small font size appropriate for a label', async () => {
|
||||
it('badge uses the 12px label size — text-xs is the visible-text floor', async () => {
|
||||
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
|
||||
const badge = document.querySelector('[data-testid="journey-badge"]');
|
||||
const fontSize = parseFloat(window.getComputedStyle(badge!).fontSize);
|
||||
expect(fontSize).toBeGreaterThan(0);
|
||||
expect(fontSize).toBeLessThanOrEqual(14); // label badge must not exceed body text size
|
||||
expect(badge!.className).toContain('text-xs');
|
||||
// 10px was below the house floor for the 60+ audience (round-3 review)
|
||||
expect(badge!.className).not.toContain('text-[10px]');
|
||||
});
|
||||
|
||||
it('renders author name in meta line', async () => {
|
||||
|
||||
@@ -22,7 +22,10 @@ const isDraft = $derived(status === 'DRAFT');
|
||||
{m.geschichte_sidebar_status()}
|
||||
</summary>
|
||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
<!-- hidden below sm: the <summary> already shows this label there -->
|
||||
<h2
|
||||
class="mb-1 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
|
||||
>
|
||||
{m.geschichte_sidebar_status()}
|
||||
</h2>
|
||||
<p class="mb-3">
|
||||
@@ -50,7 +53,9 @@ const isDraft = $derived(status === 'DRAFT');
|
||||
{m.geschichte_editor_personen_heading()}
|
||||
</summary>
|
||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
<h2
|
||||
class="mb-2 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
|
||||
>
|
||||
{m.geschichte_editor_personen_heading()}
|
||||
</h2>
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
|
||||
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
||||
@@ -14,9 +15,29 @@ let { alreadyAddedIds = new Set(), onAddDocument, onAddInterlude }: Props = $pro
|
||||
let showPicker = $state(false);
|
||||
let showInterludeForm = $state(false);
|
||||
let interludeDraft = $state('');
|
||||
let rootEl: HTMLElement | null = $state(null);
|
||||
|
||||
const canConfirmInterlude = $derived(interludeDraft.trim().length > 0);
|
||||
|
||||
async function togglePicker() {
|
||||
showPicker = !showPicker;
|
||||
showInterludeForm = false;
|
||||
if (showPicker) {
|
||||
// Keyboard users need a perceivable result of activating the toggle.
|
||||
await tick();
|
||||
rootEl?.querySelector<HTMLInputElement>('#journey-add-picker input')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleInterludeForm() {
|
||||
showInterludeForm = !showInterludeForm;
|
||||
showPicker = false;
|
||||
if (showInterludeForm) {
|
||||
await tick();
|
||||
rootEl?.querySelector<HTMLTextAreaElement>('#journey-add-interlude textarea')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDocumentSelect(doc: DocumentOption) {
|
||||
showPicker = false;
|
||||
onAddDocument(doc);
|
||||
@@ -36,25 +57,23 @@ function handleInterludeCancel() {
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div bind:this={rootEl} class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-add-document
|
||||
onclick={() => {
|
||||
showPicker = !showPicker;
|
||||
showInterludeForm = false;
|
||||
}}
|
||||
onclick={togglePicker}
|
||||
aria-expanded={showPicker}
|
||||
aria-controls={showPicker ? 'journey-add-picker' : undefined}
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
+ {m.journey_add_document()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showInterludeForm = !showInterludeForm;
|
||||
showPicker = false;
|
||||
}}
|
||||
onclick={toggleInterludeForm}
|
||||
aria-expanded={showInterludeForm}
|
||||
aria-controls={showInterludeForm ? 'journey-add-interlude' : undefined}
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
+ {m.journey_add_interlude()}
|
||||
@@ -62,15 +81,17 @@ function handleInterludeCancel() {
|
||||
</div>
|
||||
|
||||
{#if showPicker}
|
||||
<DocumentPickerDropdown
|
||||
alreadyAddedIds={alreadyAddedIds}
|
||||
onSelect={handleDocumentSelect}
|
||||
placeholder={m.journey_add_document()}
|
||||
/>
|
||||
<div id="journey-add-picker">
|
||||
<DocumentPickerDropdown
|
||||
alreadyAddedIds={alreadyAddedIds}
|
||||
onSelect={handleDocumentSelect}
|
||||
placeholder={m.journey_add_document()}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showInterludeForm}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div id="journey-add-interlude" class="flex flex-col gap-2">
|
||||
<textarea
|
||||
bind:value={interludeDraft}
|
||||
placeholder={m.journey_interlude_placeholder()}
|
||||
|
||||
@@ -34,7 +34,7 @@ const hasNote = $derived(item.note != null && item.note.trim().length > 0);
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
aria-label={openAriaLabel}
|
||||
class="inline-flex items-center gap-1 text-sm font-semibold text-ink hover:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
class="-my-2 inline-flex min-h-[44px] items-center gap-1 text-sm font-semibold text-ink hover:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<svg class="h-3 w-3 shrink-0" viewBox="0 0 10 12" fill="none">
|
||||
<rect x="1" y="1" width="8" height="10" rx="1" stroke="currentColor" stroke-width="1" />
|
||||
|
||||
@@ -51,6 +51,16 @@ describe('JourneyItemCard', () => {
|
||||
expect(el.getAttribute('href')).toContain('/documents/d1');
|
||||
});
|
||||
|
||||
it('"Brief öffnen" link meets the 44px touch-target floor', async () => {
|
||||
// Primary tap action of the phone read path — WCAG 2.5.5 / house rule.
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
const link = page.getByRole('link', { name: /öffnen/i });
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
const height = link.element().getBoundingClientRect().height;
|
||||
expect(height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
|
||||
it('"Brief öffnen" link has dated aria-label when documentDate is present', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ const validItems = $derived(
|
||||
{#if introText}
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<p
|
||||
class="border-subtle mb-6 border-b border-dashed pb-4 font-serif text-lg leading-relaxed text-ink-2 italic"
|
||||
class="mb-6 border-b border-dashed border-line-2 pb-4 font-serif text-lg leading-relaxed text-ink-2 italic"
|
||||
>
|
||||
{introText}
|
||||
</p>
|
||||
|
||||
@@ -61,7 +61,7 @@ async function handleDelete() {
|
||||
<header class="mb-6">
|
||||
{#if isJourney}
|
||||
<span
|
||||
class="mb-2 inline-flex rounded-sm border border-journey-border bg-journey-tint px-2 py-px text-[10px] font-bold tracking-widest text-journey uppercase"
|
||||
class="mb-2 inline-flex rounded-sm border border-journey-border bg-journey-tint px-2 py-px text-xs font-bold tracking-widest text-journey uppercase"
|
||||
>
|
||||
{m.journey_badge_detail()}
|
||||
</span>
|
||||
@@ -74,7 +74,7 @@ async function handleDelete() {
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-sans text-xs font-bold text-white"
|
||||
style="background-color: {personAvatarColor(g.author?.id ?? authorName)}"
|
||||
style="background-color: {personAvatarColor(authorName)}"
|
||||
>
|
||||
{getInitials(authorName)}
|
||||
</span>
|
||||
@@ -97,14 +97,14 @@ async function handleDelete() {
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<a
|
||||
href="/geschichten/{g.id}/edit"
|
||||
class="inline-flex h-9 items-center rounded border border-line bg-surface px-3 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-3 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDelete}
|
||||
class="inline-flex h-9 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.btn_delete()}
|
||||
</button>
|
||||
|
||||
@@ -61,6 +61,7 @@ async function handleSubmit(e: SubmitEvent) {
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title}
|
||||
maxlength="255"
|
||||
onblur={() => (titleTouched = true)}
|
||||
placeholder={m.geschichte_editor_title_placeholder()}
|
||||
aria-label={m.journey_title_aria_label()}
|
||||
@@ -78,7 +79,7 @@ async function handleSubmit(e: SubmitEvent) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="inline-flex h-11 items-center rounded bg-brand-navy px-4 font-sans text-sm font-medium text-white hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-50"
|
||||
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-50"
|
||||
>
|
||||
{m.journey_create_submit()}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user