170 lines
11 KiB
Markdown
170 lines
11 KiB
Markdown
# App Header Wayfinding — Design
|
|
|
|
**Date:** 2026-06-07
|
|
**Status:** Approved (brainstorming) — ready for implementation planning.
|
|
**Issue:** #54.
|
|
|
|
## Context
|
|
|
|
The `<header>` in `web/src/shell/app-shell.tsx` carries only a flex spacer + ThemeSwitch +
|
|
LangSwitch + a Sign out button — no "where am I", no user identity, no search entry. Three further
|
|
gaps: deep-linking to nested/create routes gives no header cue; the configured `app_name`
|
|
(`useConfig()`, default "Collection Management System") is never shown — the sidebar brand
|
|
(`sidebar.tsx:76`) and login heading (`login-page.tsx:38`) render the hardcoded `t("app.name")` =
|
|
"Collection"; and global search requires a full route change via the sidebar.
|
|
|
|
Facts established: routes are JSX `<Route>` elements (no `useMatches`/`handle`), so a breadcrumb can't
|
|
use react-router's match data — and the object routes carry a UUID `:id`, not the `object_number` the
|
|
breadcrumb wants. `useMe()` (`api/queries.ts:30`) returns `UserView | null` =
|
|
`{ email, id, role }`. `useLogout()` (`queries.ts:129`) POSTs `/api/admin/logout`. Base UI ships a
|
|
Menu primitive — `import { Menu } from "@base-ui/react/menu"` (namespace export) — no new dependency.
|
|
`#57` added `useDocumentTitle(page)` per page; this milestone adds a parallel page-driven breadcrumb.
|
|
The full ⌘K command palette / in-place search modal is **#33** (out of scope here).
|
|
|
|
### Decisions (from brainstorming)
|
|
1. **Page-driven breadcrumb context** (`useBreadcrumb(trail)`), parallel to `useDocumentTitle` — the
|
|
header stays dumb; detail pages supply exact dynamic crumbs (`object_number`, vocab name).
|
|
2. **User-menu dropdown** using a new `ui/menu.tsx` Base UI Menu wrapper; Sign out moves into it.
|
|
3. **Compact header search** that navigates to `/search?q=…` (light entry; the palette is #33).
|
|
4. **Brand + login use `useConfig().app_name`**; the hardcoded `app.name` i18n key is removed.
|
|
|
|
## Components
|
|
|
|
### 1. `app_name` everywhere (kill "Collection")
|
|
- `web/src/shell/sidebar.tsx:76`: `t("app.name")` → `useConfig().app_name` (still hidden when the
|
|
sidebar is collapsed).
|
|
- `web/src/auth/login-page.tsx`: the `<h1>` (line 38) and the `document.title` effect (line 18,
|
|
added in #57) → `useConfig().app_name`. Login is rendered inside `ConfigProvider` (mounted in
|
|
`main.tsx` around everything), so `useConfig()` works there; if `/api/config` is auth-gated
|
|
pre-login, it returns the default "Collection Management System" — still the correct product name,
|
|
and better than "Collection".
|
|
- Remove the now-unused `app.name` key from `en.json` and `sv.json` (grep confirms only the three
|
|
usages above; all are migrated). `useDocumentTitle` already reads `app_name` from `useConfig`, not
|
|
i18n, so nothing else depends on the key.
|
|
|
|
### 2. Breadcrumb (page-driven context)
|
|
- **`web/src/shell/breadcrumb-context.ts`** — `export type BreadcrumbItem = { label: string; to?: string }`
|
|
and a context `{ trail: BreadcrumbItem[]; setTrail: (t: BreadcrumbItem[]) => void }` (default
|
|
`{ trail: [], setTrail: () => {} }`).
|
|
- **`web/src/shell/breadcrumb-provider.tsx`** — holds `useState<BreadcrumbItem[]>([])`, provides
|
|
`{ trail, setTrail }`. Mounted in `AppShell` wrapping the header + main.
|
|
- **`web/src/shell/use-breadcrumb.ts`** — `useBreadcrumb(trail: BreadcrumbItem[])`: in a `useEffect`
|
|
calls `setTrail(trail)`, keyed on a **serialized** trail string
|
|
(`trail.map(i => \`${i.label}${i.to ?? ""}\`).join("")`) to avoid re-running on every
|
|
render from a fresh array literal. (No clear-on-unmount: every AppShell route sets its own trail,
|
|
so the next page overwrites — avoids an empty-then-refill flash.)
|
|
- **Header rendering:** a `Breadcrumb` element on the header's left renders the trail as
|
|
`crumb / crumb / crumb`; non-terminal crumbs with a `to` are `<Link>`s (`text-muted-foreground
|
|
hover:text-foreground`), the terminal crumb is `text-foreground`. Separator `/` (muted). Uses
|
|
`text-sm`. Truncates with `truncate`/`min-w-0` so a long `object_number` doesn't push the right side
|
|
off. (No `aria` nav landmark needed beyond a simple `<nav aria-label="Breadcrumb">`.)
|
|
|
|
**Per-page trails** (reusing existing i18n keys; dynamic labels from already-loaded data):
|
|
| Route / component | Trail |
|
|
|---|---|
|
|
| `objects-page` | `[{label: t("nav.objects")}]` |
|
|
| `object-new-page` | `[{label: t("nav.objects"), to: "/objects"}, {label: t("objects.new")}]` |
|
|
| `object-detail` (`ObjectDetailLoaded`) | `[{label: t("nav.objects"), to: "/objects"}, {label: object.object_number}]` |
|
|
| `object-edit-form` (loaded) | `[{nav.objects→/objects}, {object_number→/objects/:id}, {t("actions.edit")}]` |
|
|
| `vocabularies-page` | `[{label: t("nav.vocabularies")}]` |
|
|
| `vocabulary-terms` (loaded) | `[{nav.vocabularies→/vocabularies}, {label: <vocab name>}]` |
|
|
| `authorities-page` | `[{label: t("nav.authorities")}]` |
|
|
| `fields-page` | `[{label: t("nav.fields")}]` |
|
|
| `search-page` | `[{label: t("nav.search")}]` |
|
|
|
|
`/search/:id` reuses `ObjectDetail`, so it shows the object's canonical `Objects / {number}` trail
|
|
(acceptable — it identifies the record; refining to a search-relative trail is a later nicety).
|
|
|
|
### 3. `web/src/components/ui/menu.tsx` (new — Base UI Menu wrapper)
|
|
Wrap the Base UI Menu parts in the established `ui/*` style (`data-slot`, `cn()`, `render` prop where
|
|
a part should be a `Button`). Minimum surface needed: `Menu.Root` → `MenuTrigger`, and a
|
|
`MenuContent` composing `Menu.Portal` + `Menu.Positioner` + `Menu.Popup`, plus `MenuItem` and
|
|
`MenuSeparator`. Style the popup as a card (`bg-popover text-popover-foreground border rounded-md
|
|
shadow-md p-1`), items as `data-[highlighted]` rows (mirror the combobox/alert-dialog token classes).
|
|
**Base UI Menu is novel in this repo → the exact part tree + props (`render`, positioner side/align,
|
|
portal) must be validated by running** (a story), as combobox/drawer/tooltip/toast were. A
|
|
`menu.stories.tsx` renders a trigger + a few items and (play test) opens it and asserts an item.
|
|
|
|
### 4. `web/src/shell/user-menu.tsx`
|
|
- `const { data: me } = useMe();` and the `useLogout()` flow (moved out of the header bar).
|
|
- Trigger: a `Button variant="ghost" size="sm"` with a lucide `User`/`CircleUser` icon + the email
|
|
(truncated; icon-only below `sm` if needed). Opens `MenuContent`:
|
|
- email (a non-interactive header row, `label-caption` or muted),
|
|
- role (secondary muted text — raw `me.role`),
|
|
- `MenuSeparator`,
|
|
- `MenuItem` **Sign out** (`t("auth.signOut")`) → triggers logout + navigate to `/login` (the same
|
|
logic currently in `app-shell.tsx`).
|
|
- If `me` is null (shouldn't happen inside `AppShell`/RequireAuth), render nothing or just the trigger.
|
|
|
|
### 5. `web/src/shell/header-search.tsx`
|
|
- A small `<form>` with an `<Input>` (lucide `Search` icon, placeholder `t("search.headerPlaceholder")`).
|
|
- `onSubmit`: `navigate("/search?q=" + encodeURIComponent(query.trim()))` when non-empty; clears or
|
|
keeps the field (keep). The search page already reads `?q=` (`search-panel.tsx`), so it pre-fills +
|
|
executes. Width compact (`w-48 lg:w-64`); **hidden below `sm`** (`hidden sm:block`) to keep the
|
|
narrow header uncluttered (full responsive header = #58).
|
|
- New i18n key `search.headerPlaceholder` (en: "Search…", sv: "Sök…") in both locales (parity).
|
|
|
|
### 6. Header assembly (`app-shell.tsx`)
|
|
```tsx
|
|
<header className="flex items-center gap-4 border-b px-4 py-2">
|
|
<Breadcrumb /> {/* left, truncates */}
|
|
<div className="flex-1" />
|
|
<HeaderSearch />
|
|
<ThemeSwitch />
|
|
<LangSwitch />
|
|
<UserMenu />
|
|
</header>
|
|
```
|
|
The standalone Sign out `<Button>` is removed (now in `UserMenu`); `onSignOut`/logout logic moves to
|
|
`UserMenu`. Wrap header+main in `BreadcrumbProvider` so both the pages (setters) and the header
|
|
(reader) share it.
|
|
|
|
## Data flow
|
|
Route mounts → page calls `useBreadcrumb(trail)` → provider state updates → header `Breadcrumb`
|
|
renders it. `UserMenu` reads `useMe()` (cached `["me"]` query). `HeaderSearch` submit → router
|
|
navigate to `/search?q=`. Brand/login read `useConfig().app_name`.
|
|
|
|
## Error handling / edges
|
|
- `useBreadcrumb` effect keyed on serialized trail → no render loop, no stale array identity churn.
|
|
- A page that forgets to set a trail would show the previous page's crumbs; all AppShell routes set
|
|
one (acceptance check). Login is outside AppShell (no breadcrumb there).
|
|
- Long `object_number`/email truncate (`min-w-0 truncate`) so the header never overflows.
|
|
- Base UI Menu requires Portal + Positioner (like the other primitives) — validate by running.
|
|
- `me` null inside AppShell is not expected (RequireAuth guards), but `UserMenu` guards it.
|
|
|
|
## Testing
|
|
- **Breadcrumb:** a page sets a trail → the header renders the crumbs; a non-leaf crumb is a working
|
|
`<Link>` (click navigates). Test via `renderApp` at a nested route (e.g. `/objects/new` shows
|
|
"Objects / New" with "Objects" linking to `/objects`).
|
|
- **`ui/menu` story** (validate-by-running): open the menu, assert an item is visible.
|
|
- **UserMenu:** renders the email from a mocked `useMe`; opening it shows Sign out; clicking Sign out
|
|
triggers the logout request (MSW) and navigates to `/login`. (Mirror the existing app-shell signout
|
|
expectations — move them here.)
|
|
- **HeaderSearch:** typing a query + submit navigates to `/search?q=<encoded>` (assert the resulting
|
|
route/`?q=`).
|
|
- **app_name:** sidebar brand + login render `useConfig().app_name` (the default in tests).
|
|
- **app-shell.test.tsx:** update — the Sign out button moved into UserMenu; keep/upgrade the signout
|
|
assertion to go through the menu. Don't weaken.
|
|
- Gate: `pnpm typecheck && lint && test && build && check:size && check:colors`; en/sv parity (one new
|
|
key `search.headerPlaceholder`; `app.name` removed from both); no codename. **`check:size`:** Base UI
|
|
Menu is added to the always-loaded shell — it may nudge the largest chunk; report the value (budget
|
|
250 KB gz; raise only if it genuinely exceeds, and flag to the user rather than silently bumping).
|
|
|
|
## Acceptance criteria
|
|
1. The header shows a route-driven breadcrumb on the left for every AppShell route (section for list
|
|
pages; `Section / New|Edit` and `Section / {object_number|vocab name}` for nested), via a
|
|
page-driven `useBreadcrumb`/context; non-leaf crumbs link.
|
|
2. A user-menu dropdown on the right shows the signed-in email + role and a Sign out item (which logs
|
|
out); the standalone Sign out button is gone. Built on a reusable `ui/menu` Base UI wrapper.
|
|
3. A compact header search field navigates to `/search?q=…` (hidden below `sm`).
|
|
4. The sidebar brand and the login heading + tab title render `useConfig().app_name`; the hardcoded
|
|
`app.name` i18n key is removed.
|
|
5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported (within budget or
|
|
flagged); en/sv parity; no codename; no new npm dependency.
|
|
|
|
## Out of scope → follow-ups
|
|
- Full ⌘K command palette / in-place search-results modal (#33).
|
|
- Broader responsive header behavior (#58) — this only hides the search field below `sm`.
|
|
- User avatar images, a user/account settings page, role-name i18n mapping.
|
|
- Refining `/search/:id` to a search-relative breadcrumb (currently shows the canonical object trail).
|