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. |