# 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/
FAMILIENARCHIV 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
FAMILIENARCHIV 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

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

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

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

3 Mobile layout — 320px breakpoint
Default — no search
9:41
FAMILIENARCHIV
⌕ 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
FAMILIENARCHIV
⌕ 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
FAMILIENARCHIV
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

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`

4 Loading & empty states
Loading — search request in flight
localhost:3000/?q=raddatz&sort=sender&dir=asc
FAMILIENARCHIV 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
FAMILIENARCHIV 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

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`

5 Backend & data wiring — implementation checklist
Implementation Reference — §5 Full-stack wiring Not a visual section — agent checklist only

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.