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"
|
autocomplete="off"
|
||||||
aria-label={placeholder}
|
aria-label={placeholder}
|
||||||
aria-expanded={picker.isOpen}
|
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-autocomplete="list"
|
||||||
aria-activedescendant={activeOptionId}
|
aria-activedescendant={activeOptionId}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|||||||
@@ -39,9 +39,7 @@ const authorName = $derived(formatAuthorName(geschichte.author));
|
|||||||
{#if isJourney}
|
{#if isJourney}
|
||||||
<span
|
<span
|
||||||
data-testid="journey-badge"
|
data-testid="journey-badge"
|
||||||
aria-label="Lesereise"
|
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{m.journey_badge_list()}
|
{m.journey_badge_list()}
|
||||||
</span>
|
</span>
|
||||||
@@ -52,13 +50,12 @@ const authorName = $derived(formatAuthorName(geschichte.author));
|
|||||||
<div class="min-w-0 flex-1 p-3 sm:px-4">
|
<div class="min-w-0 flex-1 p-3 sm:px-4">
|
||||||
<!-- Compact meta line (mobile only) -->
|
<!-- Compact meta line (mobile only) -->
|
||||||
<div class="mb-1 flex items-center gap-1.5 sm:hidden">
|
<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
|
<span
|
||||||
aria-hidden="true"
|
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)}"
|
style="background-color: {personAvatarColor(authorName)}"
|
||||||
>
|
></span>
|
||||||
{getInitials(authorName)}
|
|
||||||
</span>
|
|
||||||
<span class="font-sans text-sm font-semibold text-ink">{authorName}</span>
|
<span class="font-sans text-sm font-semibold text-ink">{authorName}</span>
|
||||||
{#if publishedAt}
|
{#if publishedAt}
|
||||||
<span class="ml-auto font-sans text-sm text-ink-3">{publishedAt}</span>
|
<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}
|
{#if isJourney}
|
||||||
<span
|
<span
|
||||||
data-testid="journey-badge-mobile"
|
data-testid="journey-badge-mobile"
|
||||||
aria-hidden="true"
|
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{m.journey_badge_list()}
|
{m.journey_badge_list()}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -79,12 +79,12 @@ describe('GeschichteListRow', () => {
|
|||||||
expect(badge?.tagName.toLowerCase()).toBe('span');
|
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' }) } });
|
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
|
||||||
const badge = document.querySelector('[data-testid="journey-badge"]');
|
const badge = document.querySelector('[data-testid="journey-badge"]');
|
||||||
const fontSize = parseFloat(window.getComputedStyle(badge!).fontSize);
|
expect(badge!.className).toContain('text-xs');
|
||||||
expect(fontSize).toBeGreaterThan(0);
|
// 10px was below the house floor for the 60+ audience (round-3 review)
|
||||||
expect(fontSize).toBeLessThanOrEqual(14); // label badge must not exceed body text size
|
expect(badge!.className).not.toContain('text-[10px]');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders author name in meta line', async () => {
|
it('renders author name in meta line', async () => {
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ const isDraft = $derived(status === 'DRAFT');
|
|||||||
{m.geschichte_sidebar_status()}
|
{m.geschichte_sidebar_status()}
|
||||||
</summary>
|
</summary>
|
||||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
<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()}
|
{m.geschichte_sidebar_status()}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mb-3">
|
<p class="mb-3">
|
||||||
@@ -50,7 +53,9 @@ const isDraft = $derived(status === 'DRAFT');
|
|||||||
{m.geschichte_editor_personen_heading()}
|
{m.geschichte_editor_personen_heading()}
|
||||||
</summary>
|
</summary>
|
||||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
<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()}
|
{m.geschichte_editor_personen_heading()}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
|
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
|
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
|
||||||
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
||||||
@@ -14,9 +15,29 @@ let { alreadyAddedIds = new Set(), onAddDocument, onAddInterlude }: Props = $pro
|
|||||||
let showPicker = $state(false);
|
let showPicker = $state(false);
|
||||||
let showInterludeForm = $state(false);
|
let showInterludeForm = $state(false);
|
||||||
let interludeDraft = $state('');
|
let interludeDraft = $state('');
|
||||||
|
let rootEl: HTMLElement | null = $state(null);
|
||||||
|
|
||||||
const canConfirmInterlude = $derived(interludeDraft.trim().length > 0);
|
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) {
|
function handleDocumentSelect(doc: DocumentOption) {
|
||||||
showPicker = false;
|
showPicker = false;
|
||||||
onAddDocument(doc);
|
onAddDocument(doc);
|
||||||
@@ -36,25 +57,23 @@ function handleInterludeCancel() {
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-add-document
|
data-add-document
|
||||||
onclick={() => {
|
onclick={togglePicker}
|
||||||
showPicker = !showPicker;
|
aria-expanded={showPicker}
|
||||||
showInterludeForm = false;
|
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"
|
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()}
|
+ {m.journey_add_document()}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => {
|
onclick={toggleInterludeForm}
|
||||||
showInterludeForm = !showInterludeForm;
|
aria-expanded={showInterludeForm}
|
||||||
showPicker = false;
|
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"
|
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()}
|
+ {m.journey_add_interlude()}
|
||||||
@@ -62,15 +81,17 @@ function handleInterludeCancel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showPicker}
|
{#if showPicker}
|
||||||
<DocumentPickerDropdown
|
<div id="journey-add-picker">
|
||||||
alreadyAddedIds={alreadyAddedIds}
|
<DocumentPickerDropdown
|
||||||
onSelect={handleDocumentSelect}
|
alreadyAddedIds={alreadyAddedIds}
|
||||||
placeholder={m.journey_add_document()}
|
onSelect={handleDocumentSelect}
|
||||||
/>
|
placeholder={m.journey_add_document()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showInterludeForm}
|
{#if showInterludeForm}
|
||||||
<div class="flex flex-col gap-2">
|
<div id="journey-add-interlude" class="flex flex-col gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={interludeDraft}
|
bind:value={interludeDraft}
|
||||||
placeholder={m.journey_interlude_placeholder()}
|
placeholder={m.journey_interlude_placeholder()}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const hasNote = $derived(item.note != null && item.note.trim().length > 0);
|
|||||||
<a
|
<a
|
||||||
href="/documents/{doc.id}"
|
href="/documents/{doc.id}"
|
||||||
aria-label={openAriaLabel}
|
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">
|
<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" />
|
<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');
|
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 () => {
|
it('"Brief öffnen" link has dated aria-label when documentDate is present', async () => {
|
||||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const validItems = $derived(
|
|||||||
{#if introText}
|
{#if introText}
|
||||||
<!-- plaintext — do NOT use {@html} here -->
|
<!-- plaintext — do NOT use {@html} here -->
|
||||||
<p
|
<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}
|
{introText}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ async function handleDelete() {
|
|||||||
<header class="mb-6">
|
<header class="mb-6">
|
||||||
{#if isJourney}
|
{#if isJourney}
|
||||||
<span
|
<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()}
|
{m.journey_badge_detail()}
|
||||||
</span>
|
</span>
|
||||||
@@ -74,7 +74,7 @@ async function handleDelete() {
|
|||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
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"
|
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)}
|
{getInitials(authorName)}
|
||||||
</span>
|
</span>
|
||||||
@@ -97,14 +97,14 @@ async function handleDelete() {
|
|||||||
<div class="ml-auto flex items-center gap-3">
|
<div class="ml-auto flex items-center gap-3">
|
||||||
<a
|
<a
|
||||||
href="/geschichten/{g.id}/edit"
|
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()}
|
{m.btn_edit()}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={handleDelete}
|
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()}
|
{m.btn_delete()}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ async function handleSubmit(e: SubmitEvent) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={title}
|
bind:value={title}
|
||||||
|
maxlength="255"
|
||||||
onblur={() => (titleTouched = true)}
|
onblur={() => (titleTouched = true)}
|
||||||
placeholder={m.geschichte_editor_title_placeholder()}
|
placeholder={m.geschichte_editor_title_placeholder()}
|
||||||
aria-label={m.journey_title_aria_label()}
|
aria-label={m.journey_title_aria_label()}
|
||||||
@@ -78,7 +79,7 @@ async function handleSubmit(e: SubmitEvent) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
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()}
|
{m.journey_create_submit()}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user