diff --git a/docs/specs/sort-inline-final-spec.html b/docs/specs/sort-inline-final-spec.html new file mode 100644 index 00000000..fdf2f00c --- /dev/null +++ b/docs/specs/sort-inline-final-spec.html @@ -0,0 +1,1052 @@ + + +
+ + +Sort dropdown sits inline in the search bar row, between the text input and the Filter button. Always visible, always labelled. Selected from 4 exploration variants (see sort-integration-spec.html). Addresses Issue #180 Problem 1.
?sort=date&dir=desc — omitted when default| Element | Tailwind classes | Real size | Notes |
|---|---|---|---|
| Search card container | +bg-surface border border-line rounded-sm p-4 shadow-sm mb-2 |
+ p 16px | +Existing SearchFilterBar.svelte outer wrapper — no change needed |
+
| Row 1 flex container | +flex items-center gap-3 |
+ — | +Existing — insert sort button between SINPUT and filter button |
+
| Search input | +flex-1 h-10 flex items-center gap-2 px-3 border border-line rounded-sm text-base placeholder-ink-3 focus-visible:ring-2 focus-visible:ring-focus-ring focus:outline-none |
+ h 40px, text 16px | +Placeholder: "Titel, Personen, Tags durchsuchen …". Addresses #180 Problem 2 discoverability. | +
| Sort trigger button — default | +h-10 shrink-0 flex items-center gap-1.5 px-3 border border-line rounded-sm bg-muted text-sm font-bold text-ink-2 hover:bg-surface hover:text-ink transition whitespace-nowrap cursor-pointer |
+ h 40px, px 12px, text 14px / 700 | +Shows "Sortieren: Datum ↓". "Sortieren:" prefix: text-xs font-normal text-ink-3 mr-0.5 |
+
| Sort trigger button — active | +Same + override: border-brand-navy text-brand-navy bg-surface |
+ — | +Active when sort ≠ default (date/desc). "Sortieren:" prefix color: text-brand-navy/50 |
+
| Sort trigger button — focus | +Add: focus-visible:ring-2 focus-visible:ring-focus-ring focus:outline-none |
+ — | +Never remove focus ring. Tab order: input → sort → filter → reset | +
| Filter button (unchanged) | +h-10 shrink-0 flex items-center gap-2 px-3 border border-line rounded-sm bg-muted text-sm font-bold text-ink-2 uppercase tracking-wide hover:text-ink transition |
+ h 40px | +Existing — no change | +
| Reset button (unchanged) | +h-10 shrink-0 flex items-center justify-center px-2 border border-transparent text-ink-3 hover:text-red-500 transition |
+ h 40px, min-w 40px | +Existing — no change | +
| Result count line | +text-sm font-medium text-ink-2 mb-3 — <strong class="text-brand-navy font-bold"> |
+ 14px / 500 | +aria-live="polite". Only rendered when !isDashboard. |
+
| Element | Tailwind classes | Real size | Notes |
|---|---|---|---|
| Dropdown container | +absolute top-[calc(100%+4px)] left-0 z-50 min-w-[200px] bg-surface border border-brand-navy rounded-sm shadow-[0_6px_20px_rgba(0,0,0,.12)] |
+ min-w 200px | +Use Svelte's use:clickOutside action to close. Also close on Escape. Position: position: relative on .SDROP-WRAP. |
+
| Dropdown header | +text-xs font-bold uppercase tracking-widest text-ink-3 px-3 py-2 border-b border-line |
+ 12px / 700 | +"SORTIEREN NACH" — orientation label for screen readers + seniors | +
| Option row — default | +flex items-center px-3 py-2.5 gap-4 border-b border-line last:border-b-0 cursor-pointer hover:bg-muted |
+ h ~44px, py 10px | +Touch target exactly 44px. Most commonly undersized element — do not reduce py. | +
| Option row — active | +Same + bg-blue-50 |
+ — | +aria-selected="true" on the active row. Role: role="option" on each row, role="listbox" on dropdown. |
+
| Option label | +flex-1 text-sm font-semibold text-ink |
+ 14px / 600 | +Active row: text-brand-navy font-bold |
+
| Direction toggle group | +flex gap-1 shrink-0 |
+ — | +role="group" with aria-label="Richtung" |
+
| Direction button — inactive | +text-xs font-bold px-2 py-1 rounded-sm border border-line text-ink-3 hover:border-ink-2 hover:text-ink-2 transition min-w-[28px] text-center |
+ 28px min-width, 24px h | +Labels: "↑" + aria-label="Aufsteigend" / "↓" + aria-label="Absteigend" |
+
| Direction button — active | +Same + bg-brand-navy text-white border-brand-navy |
+ — | +aria-pressed="true" |
+
| Keyboard navigation | +— | +— | +↑/↓ to move between rows. Enter / Space to select. Escape to close without change. Tab moves through direction arrows within a row. Focus returns to trigger on close. | +
| Svelte state | +— | +— | +let sort = $state('date'), let dir = $state('desc'). Add both to triggerSearch() URL params. Bind to new sort and dir props on SearchFilterBar. |
+
| Element | Tailwind classes | Real size | Notes |
|---|---|---|---|
| Search card row 1 (mobile) | +flex items-center gap-2 mb-2 |
+ — | +Input + reset only. Sort and filter move to row 2. Breakpoint: sm:hidden on row 2 wrapper, show on mobile only. |
+
| Search card row 2 (mobile) | +flex items-center gap-2 sm:hidden |
+ — | +Sort + filter as two equal-width buttons. Desktop keeps sort in row 1 between input and filter. | +
| Sort button label — mobile | +— | +— | +Omit "Sortieren:" prefix (hidden with hidden sm:inline). Show field name + direction only: "Absender ↑" |
+
| Sort button width — mobile | +flex-1 justify-center min-h-[44px] |
+ h 44px min, flex-1 | +Equal width with filter button. Most commonly undersized on mobile. | +
| Bottom sheet wrapper | +fixed inset-x-0 bottom-0 z-50 bg-surface border-t-2 border-brand-navy rounded-t-2xl shadow-[0_-4px_24px_rgba(0,0,0,.15)] sm:hidden |
+ — | +Only on <768px. Desktop: absolute dropdown. Backdrop: fixed inset-0 z-40 bg-black/20 behind sheet. |
+
| Bottom sheet drag handle | +mx-auto mt-2 mb-1 w-10 h-1 rounded-full bg-line |
+ 40px × 4px | +Decorative — does not need to be draggable. Tap backdrop or select option to close. | +
| Bottom sheet option rows | +flex items-center px-4 py-3.5 gap-4 border-b border-line last:border-b-0 |
+ py 14px → row h ~52px | +Taller than desktop rows for thumb comfort. 52px exceeds 44px minimum — intentional for seniors. | +
| Direction buttons — mobile | +text-sm font-bold min-w-[40px] min-h-[40px] flex items-center justify-center rounded-sm border border-line |
+ 40px × 40px min | +Larger touch target than desktop. Active: bg-brand-navy text-white border-brand-navy |
+
| Element | Tailwind classes | Real size | Notes |
|---|---|---|---|
| Spinner (inside input) | +w-4 h-4 rounded-full border-2 border-line border-t-brand-navy shrink-0 animate-spin |
+ 16px × 16px | +Replaces search icon during isLoading state. aria-label="Suche läuft" on a visually hidden span. Add let isLoading = $state(false) to +page.svelte; set true on goto(), false when data reactive updates. |
+
| Skeleton row | +bg-surface border border-line rounded-sm px-4 py-3 mb-2 flex flex-col gap-2 |
+ py 12px | +Two <div class="h-3 rounded bg-muted animate-pulse"> lines (80% and 50% width). 3 skeleton rows sufficient. |
+
| Sort button — no results | +Add opacity-40 pointer-events-none when result count is 0 |
+ — | +Dimmed to signal no effect. Do not disable entirely — allow user to change sort to try different result order. | +
| Empty state container | +bg-surface border border-line rounded-sm py-16 px-6 flex flex-col items-center text-center |
+ py 64px | +Only rendered when !isLoading && documents.length === 0 && !isDashboard |
+
| Empty state icon | +w-14 h-14 rounded-full bg-muted flex items-center justify-center text-2xl mb-4 |
+ 56px × 56px | +Use a search/magnifier SVG, not an emoji, in production | +
| Empty state headline | +font-serif text-xl font-bold text-ink mb-2 |
+ 20px / 700 | +"Keine Dokumente gefunden" — most commonly undersized on empty states | +
| Empty state subtext | +text-sm text-ink-3 max-w-xs leading-relaxed mb-5 |
+ 14px, leading 1.625 | +Quote the search term: „{q}". Paraglide key: docs_empty_state_text |
+
| Empty state CTA | +text-sm font-bold text-brand-navy border border-brand-navy rounded-sm px-4 py-2 hover:bg-brand-navy hover:text-white transition |
+ h ~40px | +<a href="/"> — no JS needed. Paraglide key: docs_btn_reset_title |
+
| Layer | What to change | Detail | Notes |
|---|---|---|---|
| Frontend state | +Add sort + dir to +page.svelte |
+ let sort = $state(untrack(() => data.filters?.sort || 'date'))let dir = $state(untrack(() => data.filters?.dir || 'desc')) |
+ Default: date/desc. Omit from URL when default to keep URLs clean. | +
| Frontend search trigger | +Add sort + dir to triggerSearch() |
+ if (sort !== 'date' || dir !== 'desc') { params.set('sort', sort); params.set('dir', dir); } |
+ Only append if non-default — avoids polluting the URL for most users. | +
| Frontend server load | +Read sort + dir in +page.server.ts |
+ const sort = url.searchParams.get('sort') || 'date';const dir = url.searchParams.get('dir') || 'desc'; |
+ Pass to /api/documents/search query params. Add to the returned filters object for sync. |
+
| Frontend sync effect | +Sync sort + dir in the $effect that syncs filter state from data |
+ sort = data.filters?.sort || 'date';dir = data.filters?.dir || 'desc'; |
+ Keeps sort state consistent on browser back/forward navigation. | +
| SearchFilterBar props | +Add sort + dir as bindable props |
+ sort = $bindable('date'), dir = $bindable('desc') |
+ Pass bind:sort + bind:dir from +page.svelte. |
+
| Backend endpoint | +Extend GET /api/documents/search |
+ Add query params: sort (string, default "date") and dir (string, default "desc") |
+ Map values: date→documentDate, title→title, sender→sender.lastName, receiver→receivers[0].lastName, tag→tags[0].name, uploaded→createdAt |
+
| Backend sort mapping | +In DocumentService.search(), add Sort parameter |
+ Build Sort sort = Sort.by(direction, field). Pass to repository.findAll(spec, pageable) or equivalent query method. |
+ Sender/receiver sort requires a JOIN — use @Query with explicit ORDER BY or a Specification with Join. Tag sort: sort by first tag name alphabetically. |
+
| API type regeneration | +Run npm run generate:api after backend changes |
+ Requires backend running with --spring.profiles.active=dev |
+ The new sort + dir params must appear in the OpenAPI spec for the typed client to pick them up. |
+
| i18n keys needed | +Add to messages/de.json, en.json, es.json |
+ docs_sort_label, docs_sort_date, docs_sort_title, docs_sort_sender, docs_sort_receiver, docs_sort_tag, docs_sort_uploaded, docs_sort_dir_asc, docs_sort_dir_desc, docs_empty_state_text |
+ Also update docs_search_placeholder to "Titel, Personen, Tags durchsuchen …" to address #180 Problem 2. |
+
Four distinct approaches for adding sort controls to the document search. Issue #180 (Problem 1). Each variant has a different placement philosophy — choose based on the user model you want to reinforce. Not a final spec; pick one and lock it down.
+?sort=date&dir=desc| Element | Tailwind classes | Real size | Notes |
|---|---|---|---|
| Sort trigger button | +h-10 flex items-center gap-2 border border-line px-3 text-sm font-bold text-ink-2 bg-muted hover:bg-surface transition whitespace-nowrap rounded-sm |
+ h 40px, px 12px | +Active state: border-brand-navy text-brand-navy bg-surface |
+
| Sort prefix label | +text-xs font-normal text-ink-3 mr-1 |
+ 12px / 400 | +"Sortieren:" prefix — hide on mobile with hidden sm:inline |
+
| Sort dropdown container | +absolute top-full left-0 z-50 min-w-[180px] bg-surface border border-brand-navy border-t-0 shadow-lg rounded-b-sm |
+ min-w 180px | +Opens below button; close on outside click via onpointerdown listener |
+
| Dropdown option row | +flex items-center px-3 py-2.5 gap-3 border-b border-line cursor-pointer hover:bg-muted last:border-b-0 |
+ h ~40px, py 10px | +Touch target ≥ 44px — most commonly undersized element | +
| Dropdown option label | +flex-1 text-sm font-semibold text-ink |
+ 14px / 600 | +Active row: text-brand-navy + row background bg-blue-50 |
+
| Direction toggle (↑/↓) | +flex gap-1 — each button: text-xs font-bold px-1.5 py-0.5 rounded-sm border border-transparent text-ink-3 |
+ 24px touch area min | +Active direction: bg-brand-navy text-white border-brand-navy |
+
| Mobile: sort + filter row | +flex gap-2 mt-2 — each button adds flex-1 justify-center |
+ h 40px, flex-1 | +Wraps to second line inside .SCARD below sm. Both buttons equal width. |
+
| New URL params | +sort + dir |
+ — | +Values: sort=date|title|sender|receiver|tag|uploaded, dir=asc|desc. Add to triggerSearch() in +page.svelte and to /api/documents/search query params. |
+
-webkit-mask-image: linear-gradient(to right, #000 80%, transparent)) to the pill container to signal overflow.
+ | Element | Tailwind classes | Real size | Notes |
|---|---|---|---|
| Pill strip container | +flex items-center gap-2 overflow-x-auto bg-surface border border-line rounded-sm px-4 py-3 shadow-sm scroll-smooth |
+ h ~48px, py 12px | +Add scrollbar-hide (Tailwind plugin) or ::-webkit-scrollbar { display: none }. Right-fade mask on mobile. |
+
| "Sortieren:" label | +text-xs font-bold uppercase tracking-widest text-ink-3 shrink-0 mr-1 |
+ 12px / 700 | +Hide on mobile: hidden sm:block |
+
| Inactive pill | +h-8 shrink-0 flex items-center gap-1 px-3 rounded-full border border-line text-sm font-semibold text-ink-2 cursor-pointer hover:border-brand-navy hover:text-brand-navy transition whitespace-nowrap |
+ h 32px, px 12px | +Touch target: wrap in min-h-[44px] flex items-center on mobile. Most commonly undersized element. |
+
| Active pill | +Inactive classes + bg-brand-navy text-white border-brand-navy |
+ h 32px | +Shows direction suffix: "Absender ↑". Screen reader: aria-pressed="true" |
+
| Direction toggles (↑/↓) | +shrink-0 h-8 flex items-center gap-1 px-3 border border-line text-sm font-bold text-ink-2 rounded-sm cursor-pointer hover:border-brand-navy transition |
+ h 32px, px 12px | +Active: border-brand-navy text-brand-navy. aria-label="Aufsteigend" / "Absteigend" |
+
| Separator | +w-px h-5 bg-line shrink-0 mx-1 |
+ 1px × 20px | +Divides pills from direction buttons. role="separator" aria-hidden="true" |
+
| Result count confirmation | +text-sm font-medium text-ink-2 mb-2 |
+ 14px | +Text: "12 Dokumente, sortiert nach Absender ↑". Live region: aria-live="polite" |
+
position: sticky; top: 0 on mobile so it stays in view while scrolling the document list. Sort dropdown opens upward (bottom: 100%) to avoid viewport overflow.| Element | Tailwind classes | Real size | Notes |
|---|---|---|---|
| Result header bar | +flex items-center gap-3 px-4 py-3 bg-surface border border-line rounded-sm shadow-sm sticky top-0 z-20 |
+ h ~48px, py 12px | +Sticky on mobile. Only rendered when !isDashboard — wrap in {#if !data.isDashboard} in +page.svelte |
+
| Result count text | +flex-1 text-sm font-medium text-ink-2 |
+ 14px / 500 | +<strong class="text-brand-navy">12</strong> Dokumente gefunden. aria-live="polite" |
+
| Sort trigger (result header) | +h-9 flex items-center gap-2 border border-line px-3 text-sm font-bold text-ink-2 bg-surface rounded-sm hover:border-brand-navy transition whitespace-nowrap |
+ h 36px, px 12px | +Active: border-brand-navy text-brand-navy. Touch target min 44px: add min-h-[44px] on mobile. |
+
| Sort dropdown (result header) | +absolute bottom-full right-0 mb-1 z-50 min-w-[180px] bg-surface border border-brand-navy shadow-lg rounded-sm |
+ min-w 180px | +Opens upward (bottom-full) on mobile to stay in viewport. Same option rows as Variant A. |
+
| Dropdown option row | +flex items-center px-3 py-2.5 gap-3 border-b border-line last:border-b-0 cursor-pointer hover:bg-muted |
+ h ~40px | +Touch target ≥ 44px — most commonly undersized | +
| Direction toggle | +Same as Variant A direction toggle | +— | +Reuse same component | +
| Svelte placement | +— | +— | +Add ResultHeader.svelte component. Render between SearchFilterBar and DocumentList in +page.svelte, only when !data.isDashboard. |
+
| Element | Tailwind classes | Real size | Notes |
|---|---|---|---|
| Sort section in panel | +border-b border-line pb-5 mb-5 |
+ — | +First child inside the transition:slide panel in SearchFilterBar.svelte |
+
| Sort section label | +text-xs font-bold uppercase tracking-widest text-ink-2 mb-3 block |
+ 12px / 700 | +"SORTIEREN NACH" | +
| Sort option pills row | +flex flex-wrap gap-2 mb-3 |
+ — | +Wraps on mobile. 6 pills total. | +
| Inactive sort pill | +h-9 flex items-center px-4 rounded-full border border-line text-sm font-semibold text-ink-2 cursor-pointer hover:border-brand-navy transition whitespace-nowrap |
+ h 36px, px 16px | +Touch target: min-h-[44px] on mobile. Most commonly undersized. |
+
| Active sort pill | +Same + bg-brand-navy text-white border-brand-navy |
+ — | +aria-pressed="true" |
+
| Direction row | +flex gap-2 |
+ — | +Two buttons: "↑ Aufsteigend" / "↓ Absteigend" | +
| Direction button | +h-9 flex items-center gap-1.5 px-4 border border-line text-sm font-bold text-ink-2 rounded-sm cursor-pointer hover:border-brand-navy transition whitespace-nowrap |
+ h 36px | +Active: bg-brand-navy text-white border-brand-navy |
+
| Filter button badge count | +Inline "(N)" text suffix or: absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-brand-navy text-white text-[10px] font-bold flex items-center justify-center border-2 border-surface |
+ 16px badge | +Count = number of active modifiers: active sort (if non-default) + active filter fields. Computed in +page.svelte. |
+
| Criterion | +A — Inline | +B — Pill strip | +C — Result header | +D — In filter panel | +
|---|---|---|---|---|
| Discoverability | +Excellent — always in view | +Good — visible strip | +Medium — below search | +Low — hidden by default | +
| Senior usability | +Good — labeled button | +Medium — scroll may confuse | +Medium — must scroll to find | +Low — requires two taps to find | +
| Mobile footprint | +Good — wraps to row 2 | +Medium — extra full row | +Excellent — no extra row in search card | +Excellent — zero chrome when closed | +
| Search bar clarity | +Medium — adds sort button to row 1 | +Good — search bar unchanged | +Excellent — search bar 100% clean | +Excellent — search bar 100% clean | +
| Active state visibility | +Excellent — button label changes | +Excellent — active pill always visible | +Good — result header shows it | +Low — only via badge + result count | +
| Implementation complexity | +Medium — dropdown with dir toggle | +Low — pills + 2 buttons, no dropdown | +Medium — new ResultHeader component | +Low — extends existing filter panel | +
| Keyboard / screen reader | +Medium — dropdown needs focus trap | +Excellent — no dropdown, simple buttons | +Medium — same dropdown focus trap | +Excellent — inside existing panel flow | +
| Recommended for this project | +★ Primary recommendation | +Strong alternative if row 1 feels crowded | +Suitable only if sort is secondary | +Not recommended — discoverability gap | +