Sort — Inline Variant · Final Design Spec

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.

Final · Ready for implementation
Issue
#180 — Sort & Search UX
Sort options (6)
Datum · Titel · Absender · Empfänger · Tag · Hochgeladen
Default
Datum ↓ (newest first) — no URL param needed for default
New URL params
?sort=date&dir=desc — omitted when default
📐 Mockup scale notice — all font-size, height, and padding values in the mockup CSS are scaled to ~55% of actual implementation values. Do not copy sizes from mockup CSS. Use the ⚙ Implementation Reference tables after each section.
1 Anatomy at rest — desktop
Default — no active search, default sort
localhost:3000/
Dokumente Personen
Titel, Personen, Tags durchsuchen …
Sortieren: Datum ↓
⊞ Filter
Dashboard widgets appear here when no search is active
On the dashboard (no active search) the sort button is still visible and operable. Sorting here has no effect until a search is triggered — the button communicates the "ready" state.
Active — search running, custom sort set
localhost:3000/?q=raddatz&sort=sender&dir=asc
Dokumente Personen
raddatz
Sortieren: Absender ↑
⊞ Filter
8 Dokumente gefunden
PDF
Brief an Tante Klara, Weihnachten 1954
Ernst Raddatz·15. Dez 1954·Briefe
PDF
Urlaubspostkarte Schwarzwald 1962
Hildegard Raddatz·8. Aug 1962·Karten
Active sort changes the button border and text color to navy. The label updates to the selected field + direction arrow. Reset button (✕) clears both search and sort, returning to the dashboard.
Implementation Reference — §1 Search bar anatomy Real values · mockup above is ~55% scale · do not copy mockup CSS
ElementTailwind classesReal sizeNotes
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.
2 Sort dropdown — open states & interaction anatomy
Open — default sort (Datum ↓)
Sortieren: Datum ↓
Sortieren nach
Datum
Titel
Absender
Empfänger
Tag
Hochgeladen
Active option row has light blue tint. The currently selected direction arrow is navy-filled. Inactive rows show both ↑ ↓ with no fill.
Hover — "Absender" row, ↑ direction hovered
Sortieren: Datum ↓
Sortieren nach
Datum
Titel
Absender
Empfänger
Tag
Hochgeladen
Hovering a row highlights the whole row in muted gray. Hovering a specific direction arrow highlights that arrow with a border. Clicking either closes the dropdown and triggers a search.
After selection — "Absender ↑" now active
Sortieren: Absender ↑
Sortieren nach
Datum
Titel
Absender
Empfänger
Tag
Hochgeladen
After selection: trigger button label updates instantly. Dropdown typically closes after a direction is chosen. The user can re-open to change direction without re-selecting the field.
Interaction model: Clicking a field label selects that field and retains the last-used direction for that field (defaults to ↓ if no prior selection). Clicking a direction arrow selects that field + direction and closes the dropdown. This means two distinct affordances — quick tap on a label re-sorts with same direction, precise tap on an arrow picks direction explicitly.
Implementation Reference — §2 Sort dropdown Real values · mockup above is ~55% scale
ElementTailwind classesReal sizeNotes
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.
3 Mobile layout — 320px breakpoint
Default — no search
9:41
Durchsuchen…
Datum ↓
⊞ Filter
Dashboard
Row 1: input + reset only. Row 2: sort and filter as equal-width buttons, full-width of the card.
Active — custom sort set
9:41
raddatz
Absender ↑
⊞ Filter
8 Dokumente gefunden
PDF
PDF
Sort button adopts navy border + text when non-default, identical to desktop active state. Button label shortens to field name + direction (no "Sortieren:" prefix).
Dropdown open — bottom sheet on mobile
9:41
raddatz
Absender ↑
⊞ Filter
Sortieren nach
Datum
Titel
Absender
Empfänger
Tag
Hochgeladen
On mobile (<768px) the dropdown renders as a bottom sheet with a drag handle and rounded top corners. Overlay taps the backdrop to close. Option rows use generous py for thumb targets.
Implementation Reference — §3 Mobile layout Real values · mockup above is ~55% scale
ElementTailwind classesReal sizeNotes
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
4 Loading & empty states
Loading — search request in flight
localhost:3000/?q=raddatz&sort=sender&dir=asc
Dokumente Personen
raddatz
Sortieren: Absender ↑
⊞ Filter
Spinner (animated ring) replaces the search icon inside the input while the request is in-flight. Skeleton rows replace the document list. Sort button remains interactive during loading so users can change sort without waiting.
Empty state — no results for query
localhost:3000/?q=beethoven&sort=date&dir=desc
Dokumente Personen
beethoven
Sortieren: Datum ↓
⊞ Filter
🔍
Keine Dokumente gefunden
Keine Treffer für „beethoven". Versuche andere Begriffe oder entferne Filter.
Suche zurücksetzen
Zero-results state shows an icon, a friendly headline, and the search term quoted back. Sort button dims to indicate it has no effect. A single CTA resets the search. Addresses #180 Problem 4 (empty state).
Implementation Reference — §4 Loading & empty states Real values · mockup above is ~55% scale
ElementTailwind classesReal sizeNotes
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
5 Backend & data wiring — implementation checklist
Implementation Reference — §5 Full-stack wiring Not a visual section — agent checklist only
LayerWhat to changeDetailNotes
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.