@eifi1/ui-kit 0.42.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -7
- package/dist/admin/admin-area-labels.d.ts +4 -0
- package/dist/admin/admin-area-labels.js +3 -1
- package/dist/admin/admin-area-labels.js.map +1 -1
- package/dist/admin/admin-overview.d.ts +25 -8
- package/dist/admin/admin-overview.js +26 -3
- package/dist/admin/admin-overview.js.map +1 -1
- package/dist/admin/admin-sections.d.ts +36 -5
- package/dist/admin/admin-sections.js +35 -18
- package/dist/admin/admin-sections.js.map +1 -1
- package/dist/billing/billing-banner.d.ts +8 -0
- package/dist/billing/billing-banner.js +25 -2
- package/dist/billing/billing-banner.js.map +1 -1
- package/dist/billing/billing-labels.d.ts +4 -0
- package/dist/billing/billing-labels.js +1 -0
- package/dist/billing/billing-labels.js.map +1 -1
- package/dist/billing/cancel-request.d.ts +53 -0
- package/dist/billing/cancel-request.js +21 -0
- package/dist/billing/cancel-request.js.map +1 -0
- package/dist/billing/provider-page.d.ts +49 -0
- package/dist/billing/provider-page.js +33 -0
- package/dist/billing/provider-page.js.map +1 -0
- package/dist/billing/subscription-actions.d.ts +32 -1
- package/dist/billing/subscription-actions.js +57 -4
- package/dist/billing/subscription-actions.js.map +1 -1
- package/dist/components/account-chips.d.ts +8 -1
- package/dist/components/account-chips.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-menu.d.ts +7 -4
- package/dist/feedback/feedback-menu.js +4 -2
- package/dist/feedback/feedback-menu.js.map +1 -1
- package/dist/hooks/use-search-param-state.js +4 -1
- package/dist/hooks/use-search-param-state.js.map +1 -1
- package/dist/i18n/defaults.js +2 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +36 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +3 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +51 -2
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +36 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +36 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +36 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +36 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +36 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +15 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/search/global-search.d.ts +3 -0
- package/dist/search/global-search.js +3 -1
- package/dist/search/global-search.js.map +1 -1
- package/dist/settings/settings-catalogue.d.ts +9 -1
- package/dist/settings/settings-catalogue.js +1 -1
- package/dist/settings/settings-catalogue.js.map +1 -1
- package/dist/shell/admin-menu.d.ts +26 -3
- package/dist/shell/admin-menu.js +8 -4
- package/dist/shell/admin-menu.js.map +1 -1
- package/dist/shell/dev-switchers.js +5 -0
- package/dist/shell/dev-switchers.js.map +1 -1
- package/dist/shell/top-bar-parts-labels.d.ts +6 -0
- package/dist/shell/top-bar-parts-labels.js +4 -1
- package/dist/shell/top-bar-parts-labels.js.map +1 -1
- package/dist/shell/tour-menu.d.ts +19 -10
- package/dist/shell/tour-menu.js +20 -6
- package/dist/shell/tour-menu.js.map +1 -1
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/tour/tour-catalogue-labels.d.ts +44 -0
- package/dist/tour/tour-catalogue-labels.js +32 -0
- package/dist/tour/tour-catalogue-labels.js.map +1 -0
- package/dist/tour/tour-catalogue.d.ts +68 -0
- package/dist/tour/tour-catalogue.js +189 -0
- package/dist/tour/tour-catalogue.js.map +1 -0
- package/dist/tour/tour-info.d.ts +112 -0
- package/dist/tour/tour-info.js +36 -0
- package/dist/tour/tour-info.js.map +1 -0
- package/dist/tour/tour.d.ts +38 -2
- package/dist/tour/tour.js +40 -3
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-progress.d.ts +116 -0
- package/dist/tour/use-tour-progress.js +146 -0
- package/dist/tour/use-tour-progress.js.map +1 -0
- package/dist/tour/use-tour-url-sync.d.ts +58 -0
- package/dist/tour/use-tour-url-sync.js +110 -0
- package/dist/tour/use-tour-url-sync.js.map +1 -0
- package/dist/tour.d.ts +8 -0
- package/dist/tour.js +19 -0
- package/dist/tour.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/admin-area-labels.ts +7 -1
- package/src/admin/admin-overview.tsx +61 -10
- package/src/admin/admin-sections.tsx +109 -48
- package/src/billing/billing-banner.tsx +25 -2
- package/src/billing/billing-labels.ts +6 -0
- package/src/billing/cancel-request.ts +75 -0
- package/src/billing/provider-page.ts +84 -0
- package/src/billing/subscription-actions.tsx +97 -11
- package/src/components/account-chips.tsx +8 -1
- package/src/feedback/feedback-menu.tsx +8 -3
- package/src/hooks/use-search-param-state.ts +6 -1
- package/src/i18n/defaults.ts +2 -0
- package/src/i18n/german.ts +36 -1
- package/src/i18n/kit-labels.tsx +3 -0
- package/src/i18n/locales/en.ts +13 -0
- package/src/i18n/locales/es.ts +36 -1
- package/src/i18n/locales/fr.ts +36 -1
- package/src/i18n/locales/hu.ts +36 -1
- package/src/i18n/locales/it.ts +36 -1
- package/src/i18n/locales/zh.ts +36 -1
- package/src/i18n/review.ts +14 -0
- package/src/index.ts +8 -0
- package/src/search/global-search.tsx +5 -0
- package/src/settings/settings-catalogue.ts +10 -2
- package/src/shell/admin-menu.tsx +34 -5
- package/src/shell/dev-switchers.tsx +8 -0
- package/src/shell/top-bar-parts-labels.ts +9 -0
- package/src/shell/tour-menu.tsx +50 -15
- package/src/shell.ts +3 -0
- package/src/tour/tour-catalogue-labels.ts +75 -0
- package/src/tour/tour-catalogue.tsx +316 -0
- package/src/tour/tour-info.ts +144 -0
- package/src/tour/tour.tsx +84 -3
- package/src/tour/use-tour-progress.ts +299 -0
- package/src/tour/use-tour-url-sync.ts +221 -0
- package/src/tour.ts +12 -0
|
@@ -161,6 +161,9 @@ export interface GlobalSearchProps {
|
|
|
161
161
|
labels?: LabelOverride<GlobalSearchLabels>;
|
|
162
162
|
/** Passed through to the palette (`empty`, `loading`, `error`, `hint`, …). */
|
|
163
163
|
paletteLabels?: Partial<CommandPaletteLabels>;
|
|
164
|
+
/** 0.43: `data-*` attributes go on the kit's trigger button (`trigger="button"`) — a
|
|
165
|
+
* tour's anchor (`data-tour="search"`), a test hook. A function trigger is the app's. */
|
|
166
|
+
[key: `data-${string}`]: string | number | boolean | undefined;
|
|
164
167
|
}
|
|
165
168
|
/**
|
|
166
169
|
* The app-wide ⌘K search, whole: the top-bar trigger, the shortcut, and a
|
|
@@ -70,7 +70,8 @@ function GlobalSearchImpl({
|
|
|
70
70
|
triggerName = "plain",
|
|
71
71
|
density,
|
|
72
72
|
labels,
|
|
73
|
-
paletteLabels
|
|
73
|
+
paletteLabels,
|
|
74
|
+
...attributes
|
|
74
75
|
}) {
|
|
75
76
|
const l = useKitLabels("globalSearch", DEFAULT_GLOBAL_SEARCH_LABELS, labels);
|
|
76
77
|
const pl = useKitLabels("commandPalette", DEFAULT_COMMAND_PALETTE_LABELS, paletteLabels);
|
|
@@ -278,6 +279,7 @@ function GlobalSearchImpl({
|
|
|
278
279
|
children: /* @__PURE__ */ jsx(
|
|
279
280
|
"button",
|
|
280
281
|
{
|
|
282
|
+
...Object.fromEntries(Object.entries(attributes).filter(([k]) => k.startsWith("data-"))),
|
|
281
283
|
type: "button",
|
|
282
284
|
onClick: openSearch,
|
|
283
285
|
"aria-label": triggerLabel,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/search/global-search.tsx"],"sourcesContent":["import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { useHref, useInRouterContext, useNavigate } from \"react-router\";\nimport { AlertCircle, Loader2, Search } from \"lucide-react\";\nimport { Tooltip } from \"../components/tooltip\";\nimport { TOPBAR_TRIGGER_CLASS } from \"../shell/topbar-controls\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\n// Types only from ./command-palette, gone from the built module: the palette loads late.\nimport type { CommandItem, CommandPaletteDensity, CommandPaletteLabels } from \"./command-palette\";\nimport { DEFAULT_COMMAND_PALETTE_LABELS } from \"./command-palette-labels\";\nimport { useCommandKey } from \"./use-command-key\";\nimport { createSearchIndex } from \"./search-index\";\nimport type { SearchEntry, SearchIndexOptions } from \"./search-index\";\nimport { DEFAULT_GLOBAL_SEARCH_LABELS } from \"./global-search-labels\";\n\n/**\n * The palette, loaded when the search is first opened rather than with the top bar\n * (0.34, MOD-1 of the refactor audit 2026-10): ~5.5 kB gzip of keksdose's and kastlan's\n * entry chunks, for a dialog drawn only once somebody searches. A LITERAL relative\n * `import()`, as the catalogue loaders are (i18n/languages.ts), so the library build keeps\n * the module its own and an app's bundler splits it off.\n */\nconst LazyCommandPalette = lazy(() =>\n import(\"./command-palette\").then((module) => ({ default: module.CommandPalette })),\n);\n\nexport interface GlobalSearchLabels {\n /** The trigger's accessible name, and the dialog's. */\n trigger: string;\n /** The field's placeholder — says WHAT can be found, which \"Search…\" does not. */\n placeholder: string;\n /** The trigger's tooltip, given the shortcut as the platform writes it (\"⌘K\",\n * \"Ctrl K\"). A function so a language can put the keys where its grammar wants. */\n shortcut: (keys: string) => string;\n /** Heading of the suggestions shown for an empty query. */\n suggestions: string;\n /** Heading for entries that name no `group`. */\n results: string;\n}\n\n// Defined in ./global-search-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_GLOBAL_SEARCH_LABELS };\n\n/**\n * A place results come from that is not the static index — a server search, a\n * local-first store too large to hand over whole. Each source is its own group, with its\n * own debounce, its own \"Searching…\" line and its own failure: the static groups never\n * wait for it, and one source failing never takes another's results with it.\n */\nexport interface GlobalSearchSource {\n /** Stable key — the effect that runs the source is keyed by it, not by identity. */\n id: string;\n /** The group its results are shown under (an entry's own `group` is ignored) — unless\n * `groups` is set, where it is only the home of entries that name no declared group.\n * If every entry names a declared group, it is never shown: a catch-all heading such\n * as \"Other\" is the honest value, so a stray type would still surface, labelled. */\n group: string;\n /**\n * Split ONE request into several groups — kastlan's record search answers with\n * properties, units, contacts, leases and invoices at once, and one source per group\n * meant a dedupe layer so the network still saw one request. Each entry is then shown\n * under the group its own `group` names: a declared `key` (shown by its `label`) or a\n * plain string (key and heading at once). The groups appear in THIS order whatever the\n * answer's order, each kept to `limit` rows; an entry naming no declared group falls\n * under `group`. While the request runs, or when it fails, the source shows one status\n * line under its first group, not one per group. Unset: one group, as before.\n */\n groups?: readonly (string | GlobalSearchSourceGroup)[];\n /** Called with the trimmed query. Its results are shown AS GIVEN — the source ranked\n * them — and never re-filtered by the index's matcher. `signal` aborts when the query\n * moves on or the palette closes; a stale answer is dropped either way. */\n search: (query: string, signal: AbortSignal) => Promise<readonly SearchEntry[]> | readonly SearchEntry[];\n /** Shortest query it is asked for. Default 2 — one letter matches half a database. */\n minChars?: number;\n /** Quiet period after the last keystroke before it is asked. Default 200ms. */\n debounceMs?: number;\n /** Rows kept — per group, when `groups` is set. Default: `indexOptions.groupLimit` (8). */\n limit?: number;\n /** Mask every row of this source for session replay, hints (amounts) included. An\n * entry's own `redact` still wins. */\n redact?: boolean;\n /** Told when `search` throws or rejects (not when it is aborted). The group then\n * shows the palette's `error` line instead of rows. */\n onError?: (error: unknown) => void;\n}\n\n/** One of a multi-group source's groups: the `key` its entries name in `group`, and the\n * heading shown — so a server's type (\"unit\") need not be translated per entry. */\nexport interface GlobalSearchSourceGroup {\n key: string;\n label: string;\n}\n\n/** What a custom trigger gets: everything the default button uses. */\nexport interface GlobalSearchTriggerProps {\n open: () => void;\n /** Resolved `trigger` label — the accessible name. */\n label: string;\n /** Resolved `shortcut(keys)` — the tooltip. */\n tooltip: string;\n /** \"⌘K\" on Apple platforms, \"Ctrl K\" elsewhere. */\n keys: string;\n /** For the trigger's `aria-keyshortcuts`. */\n ariaKeyShortcuts: string;\n}\n\n/** A suggestion for the empty query: an entry, the `id` of one in `entries`, or a query\n * to type into the field. */\nexport type GlobalSearchSuggestion = SearchEntry | string | { query: string };\n\nexport interface GlobalSearchProps {\n /**\n * The static index: pages, actions, settings rows — already filtered by the app (by\n * role, by privacy mode). Matched and ranked by {@link createSearchIndex}.\n *\n * Changing it re-runs the open search by itself. Nothing has to be stable: the index is\n * rebuilt only when the entries' TEXT changes (ids, titles, keywords, …), so a list\n * rebuilt on every render — `data = []` while a query is pending, icons as fresh JSX —\n * neither re-indexes nor loops, and the icons and handlers used are always the latest.\n */\n entries?: readonly SearchEntry[];\n /** The async sources, each streamed into its own group — or several (`groups`). */\n sources?: readonly GlobalSearchSource[];\n /** What the empty query shows, under the `suggestions` heading. */\n suggestions?: readonly GlobalSearchSuggestion[];\n /**\n * Show each suggested ENTRY under its own `group` instead of all of them under the\n * one `suggestions` heading — keksdose's empty palette listed \"Pages\" and \"Actions\"\n * apart, and one \"Try\" heading over both lost the difference. An entry with no\n * `group` and a query suggestion (`{ query }`) stay under `suggestions`. `groupOrder`\n * orders these groups as it orders the results'. Off by default.\n */\n suggestionsKeepGroups?: boolean;\n /** Groups listed here come first, in this order; the rest follow in ranking order. */\n groupOrder?: readonly string[];\n /** Ranking and capping — `groupLimit` (default 8) applies to every group. */\n indexOptions?: SearchIndexOptions;\n /**\n * How an entry's `href` is followed on a plain click or ↵. Default: react-router's\n * `navigate` when the search is rendered inside a router, else a full page load.\n */\n navigate?: (href: string) => void;\n /**\n * The browser URL of an `href`, for the row's real link (middle-/⌘-click opens a tab).\n * Default inside a router: the router's own (`#/page` under a hash router, the\n * basename prefixed under a browser router); outside one, the href itself.\n */\n hrefFor?: (href: string) => string;\n /** Register ⌘K / Ctrl K. Default true; turn it off for a second, scoped search on a\n * page that already has the app-wide one. */\n shortcut?: boolean;\n /** Controlled open state — so a button anywhere can open it. */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Controlled query, as on `CommandPalette` (keksdose binds it to the URL's `q`). */\n query?: string;\n onQueryChange?: (query: string) => void;\n searchOn?: \"input\" | \"submit\";\n /** Mask every row for session replay (see `CommandPalette`). A source's or an entry's\n * own `redact` wins. */\n redactLabels?: boolean;\n fullScreenOnPhone?: boolean;\n /** `\"button\"` (default): the top-bar icon button. `\"none\"`: no trigger — the shortcut\n * or a controlled `open` opens it. A function renders a trigger of your own. */\n trigger?: \"button\" | \"none\" | ((props: GlobalSearchTriggerProps) => ReactNode);\n /** Hide the default trigger below `sm` (kastlan's phone top bar has no room). */\n hideTriggerOnPhone?: boolean;\n /** Class for the default trigger button, merged over `TOPBAR_TRIGGER_CLASS`. */\n triggerClassName?: string;\n /**\n * The default trigger's magnifier, in px. Default 16 (`size-4`), as it always was;\n * 20 matches the kit's other top-bar triggers (`TopbarIconButton` draws `size-5`) and\n * keksdose's top bar, which reached into the button with `[&_svg]:size-5` for it.\n */\n triggerIconSize?: number;\n /**\n * Whether the trigger's accessible name includes the shortcut: `\"plain\"` (default)\n * names it \"Search\"; `\"withShortcut\"` names it by `labels.shortcut(keys)` —\n * \"Search (⌘K)\" on Apple platforms, \"Search (Ctrl K)\" elsewhere — the same text its\n * tooltip shows, so a speech-control user (\"click Search\") and a sighted keyboard user\n * meet the shortcut in the name itself (keksdose C25). `aria-keyshortcuts` is set\n * either way. Also reaches a custom trigger as `triggerProps.label`.\n */\n triggerName?: \"plain\" | \"withShortcut\";\n /** The palette's row height — `CommandPalette`'s `density`. Default `\"compact\"`. */\n density?: CommandPaletteDensity;\n labels?: LabelOverride<GlobalSearchLabels>;\n /** Passed through to the palette (`empty`, `loading`, `error`, `hint`, …). */\n paletteLabels?: Partial<CommandPaletteLabels>;\n}\n\nfunction isApplePlatform(): boolean {\n if (typeof navigator === \"undefined\") return false;\n const nav = navigator as Navigator & { userAgentData?: { platform?: string } };\n return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform || nav.platform || nav.userAgent);\n}\n\nconst pageLoad = (href: string) => window.location.assign(href);\nconst identity = (href: string) => href;\n\n/** The entries' text, which is all the index reads — see `entries`. */\nfunction signature(entries: readonly SearchEntry[]): string {\n return entries\n .map((e) =>\n [e.id, e.title, (e.keywords ?? []).join(\"\\u0003\"), e.description, e.group, e.hint, e.href, e.weight, e.redact].join(\n \"\\u0001\",\n ),\n )\n .join(\"\\u0002\");\n}\n\n/** Focus the open palette's field again — after a suggestion typed itself into it, the\n * row that was clicked (and held focus) is gone. */\nfunction refocusField() {\n requestAnimationFrame(() => {\n const fields = document.querySelectorAll<HTMLInputElement>('[role=\"dialog\"][aria-modal=\"true\"] input[role=\"combobox\"]');\n fields[fields.length - 1]?.focus();\n });\n}\n\ninterface SourceResult {\n query: string;\n status: \"loading\" | \"done\" | \"error\";\n entries: readonly SearchEntry[];\n}\n\n/**\n * The app-wide ⌘K search, whole: the top-bar trigger, the shortcut, and a\n * {@link CommandPalette} fed from a ranked index ({@link createSearchIndex}) and any\n * number of async sources.\n *\n * Every consumer had built this out of `CommandPalette` by hand, and each copy had the\n * same holes — a substring match, no ranking, no typo tolerance, results in declaration\n * order, one monolithic provider in which the static groups waited on the slowest\n * request. This is that wrapper once:\n *\n * <GlobalSearch\n * entries={pages} // { id, title, keywords, group, href, icon }\n * sources={[{ id: \"tx\", group: \"Transactions\", search: (q, signal) => api.search(q, { signal }) }]}\n * suggestions={[\"page-budget\", \"action-new-account\"]}\n * />\n *\n * Inside a react-router tree it navigates with the router and builds its links with it;\n * outside one, pass `navigate` (or accept full page loads).\n *\n * Since 0.34 the palette's module loads on the first open (MOD-1), so the dialog appears a\n * moment after the trigger or ⌘K: a test finds it with `await screen.findBy…`.\n */\nexport function GlobalSearch(props: GlobalSearchProps) {\n // Two components rather than a conditional hook: `useNavigate` throws outside a\n // router, and whether we are in one does not change over a component's life.\n const inRouter = useInRouterContext();\n if (inRouter && (!props.navigate || !props.hrefFor)) return <RoutedGlobalSearch {...props} />;\n return <GlobalSearchImpl {...props} navigate={props.navigate ?? pageLoad} hrefFor={props.hrefFor ?? identity} />;\n}\n\nfunction RoutedGlobalSearch(props: GlobalSearchProps) {\n const routerNavigate = useNavigate();\n // The router's href for \"/\" is its base (\"#/\" under a hash router, \"/app/\" under a\n // basename); every href is that base plus the path.\n const base = useHref(\"/\").replace(/\\/$/, \"\");\n const navigate = useCallback((href: string) => void routerNavigate(href), [routerNavigate]);\n const hrefFor = useCallback((href: string) => (href.startsWith(\"/\") ? base + href : href), [base]);\n return <GlobalSearchImpl {...props} navigate={props.navigate ?? navigate} hrefFor={props.hrefFor ?? hrefFor} />;\n}\n\nfunction GlobalSearchImpl({\n entries,\n sources,\n suggestions,\n suggestionsKeepGroups = false,\n groupOrder,\n indexOptions,\n navigate,\n hrefFor,\n shortcut = true,\n open: openProp,\n onOpenChange,\n query: queryProp,\n onQueryChange,\n searchOn,\n redactLabels = false,\n fullScreenOnPhone,\n trigger = \"button\",\n hideTriggerOnPhone = false,\n triggerClassName,\n triggerIconSize,\n triggerName = \"plain\",\n density,\n labels,\n paletteLabels,\n}: GlobalSearchProps & { navigate: (href: string) => void; hrefFor: (href: string) => string }) {\n const l = useKitLabels(\"globalSearch\", DEFAULT_GLOBAL_SEARCH_LABELS, labels);\n const pl = useKitLabels(\"commandPalette\", DEFAULT_COMMAND_PALETTE_LABELS, paletteLabels);\n const [ownOpen, setOwnOpen] = useState(false);\n const open = openProp ?? ownOpen;\n const [ownQuery, setOwnQuery] = useState(\"\");\n const queryControlled = queryProp !== undefined;\n const query = queryControlled ? queryProp : ownQuery;\n const q = query.trim();\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (openProp === undefined) setOwnOpen(next);\n onOpenChange?.(next);\n },\n [openProp, onOpenChange],\n );\n const setQuery = useCallback(\n (next: string) => {\n if (!queryControlled) setOwnQuery(next);\n onQueryChange?.(next);\n },\n [queryControlled, onQueryChange],\n );\n\n // Each open starts blank — unless the owner controls the query (a URL `q`). Adjusted\n // during render, React's pattern for state that follows a prop, so a controlled `open`\n // gets the same reset as the trigger does.\n const [wasOpen, setWasOpen] = useState(open);\n if (wasOpen !== open) {\n setWasOpen(open);\n if (open && !queryControlled) setOwnQuery(\"\");\n }\n\n // The palette mounts on the first open — its module loads then — and stays mounted\n // after, closed, as it always was (MOD-1): a closed palette keeps the last results, and\n // a reopen over an owner-held query shows them at once, where a palette mounted anew\n // would say \"No results\" through its debounce first.\n const [paletteMounted, setPaletteMounted] = useState(open);\n if (open && !paletteMounted) setPaletteMounted(true);\n\n const openSearch = useCallback(() => setOpen(true), [setOpen]);\n const noop = useCallback(() => {}, []);\n useCommandKey(shortcut ? openSearch : noop);\n\n /* ── the static index ─────────────────────────────────────────────────── */\n\n // Everything below is keyed by CONTENT, never by the identity of what the app passed:\n // inline arrays and `data = []` defaults are the norm, and an identity key would\n // re-index and re-search on every render of the app. Handlers, which can close over\n // anything, are read from refs at the moment a row is chosen — never stale.\n const list = entries ?? EMPTY;\n const sig = signature(list);\n const optionsKey = JSON.stringify(indexOptions ?? {});\n const groupLimit = indexOptions?.groupLimit ?? 8;\n // `sig` and `optionsKey` stand for `list` and `indexOptions`: same text, same index.\n /* eslint-disable react-hooks/exhaustive-deps */\n const index = useMemo(() => createSearchIndex(list, indexOptions), [sig, optionsKey]);\n const byId = useMemo(() => new Map(list.map((e) => [e.id, e])), [sig]);\n\n const sourceKey = (sources ?? [])\n .map((s) =>\n [\n s.id,\n s.group,\n (s.groups ?? []).map((g) => (typeof g === \"string\" ? g : `${g.key}\\u0003${g.label}`)).join(\"\\u0004\"),\n s.minChars ?? 2,\n s.debounceMs ?? 200,\n s.limit,\n s.redact,\n ].join(\"\\u0001\"),\n )\n .join(\"\\u0002\");\n const sourceList = useMemo(() => sources ?? [], [sourceKey]);\n\n const suggestionsKey = (suggestions ?? [])\n .map((s) => (typeof s === \"string\" ? `i:${s}` : \"query\" in s ? `q:${s.query}` : `e:${signature([s])}`))\n .join(\"\\u0002\");\n const suggestionList = useMemo(() => suggestions ?? [], [suggestionsKey]);\n\n const groupOrderKey = (groupOrder ?? []).join(\"\\u0002\");\n const order = useMemo(() => groupOrder ?? [], [groupOrderKey]);\n /* eslint-enable react-hooks/exhaustive-deps */\n\n // The live objects, for the moment a row is chosen or a source is asked.\n const live = useRef({ entries: list, sources: sources ?? [], suggestions: suggestions ?? [], navigate });\n useLayoutEffect(() => {\n live.current = { entries: list, sources: sources ?? [], suggestions: suggestions ?? [], navigate };\n });\n\n /* ── the async sources ────────────────────────────────────────────────── */\n\n const [sourceResults, setSourceResults] = useState<Record<string, SourceResult>>({});\n useEffect(() => {\n const controllers: AbortController[] = [];\n const timers: ReturnType<typeof setTimeout>[] = [];\n const asked = open ? live.current.sources.filter((s) => q.length >= (s.minChars ?? 2)) : [];\n for (const source of asked) {\n const id = source.id;\n const controller = new AbortController();\n controllers.push(controller);\n timers.push(\n setTimeout(async () => {\n const settle = (result: SourceResult) => {\n if (!controller.signal.aborted) setSourceResults((prev) => ({ ...prev, [id]: result }));\n };\n try {\n const found = await source.search(q, controller.signal);\n settle({ query: q, status: \"done\", entries: found });\n } catch (error) {\n if (controller.signal.aborted) return;\n source.onError?.(error);\n settle({ query: q, status: \"error\", entries: [] });\n }\n }, source.debounceMs ?? 200),\n );\n }\n return () => {\n timers.forEach(clearTimeout);\n controllers.forEach((c) => c.abort());\n };\n }, [open, q, sourceKey]);\n\n /* ── rows ─────────────────────────────────────────────────────────────── */\n\n const toItem = useCallback(\n (prefix: string, entry: SearchEntry, group: string, redact = redactLabels): CommandItem => ({\n id: `${prefix}${entry.id}`,\n label: entry.title,\n hint: entry.hint,\n group,\n icon: entry.icon,\n redact: entry.redact ?? redact,\n href: entry.href !== undefined ? hrefFor(entry.href) : undefined,\n onSelect: () => {\n // The entry as the app has it NOW — its handler may close over newer state\n // than the one this row was built from.\n const current = live.current.entries.find((e) => e.id === entry.id) ?? entry;\n if (current.onSelect) current.onSelect();\n else if (current.href !== undefined) live.current.navigate(current.href);\n },\n }),\n [hrefFor, redactLabels],\n );\n\n // `toItem` only BUILDS `onSelect` closures here; `live` is read when a row is chosen,\n // which the compiler lint cannot tell from a read during render.\n /* eslint-disable react-hooks/refs */\n const items = useMemo((): CommandItem[] => {\n const ordered = (out: CommandItem[]) => {\n if (!order.length) return out;\n const rank = (group: string) => {\n const i = order.indexOf(group);\n return i === -1 ? order.length : i;\n };\n // Stable: within a rank, groups keep the order their best hit gave them.\n return out\n .map((item, i) => ({ item, i }))\n .sort((a, b) => rank(a.item.group) - rank(b.item.group) || a.i - b.i)\n .map(({ item }) => item);\n };\n\n if (!q) {\n const out: CommandItem[] = [];\n const groupOf = (entry: SearchEntry) =>\n suggestionsKeepGroups ? (entry.group ?? l.suggestions) : l.suggestions;\n suggestionList.forEach((s, i) => {\n if (typeof s === \"string\") {\n const entry = byId.get(s);\n if (entry) out.push(toItem(\"try:\", entry, groupOf(entry)));\n } else if (\"query\" in s) {\n out.push({\n id: `try:q${i}`,\n label: s.query,\n group: l.suggestions,\n icon: <Search className=\"size-4\" />,\n onSelect: () => {\n // The palette closes before it runs a row; a query suggestion keeps it\n // open and types itself in instead.\n setOpen(true);\n setQuery(s.query);\n refocusField();\n },\n });\n } else out.push(toItem(\"try:\", s, groupOf(s)));\n });\n // One heading has nothing to order; kept groups are ordered like results.\n return suggestionsKeepGroups ? ordered(out) : out;\n }\n\n const out = index.search(q).map((hit) => toItem(\"s:\", hit.entry, hit.entry.group ?? l.results));\n for (const source of sourceList) {\n if (q.length < (source.minChars ?? 2)) continue;\n const result = sourceResults[source.id];\n const declared = (source.groups ?? []).map((g) => (typeof g === \"string\" ? { key: g, label: g } : g));\n const status = (label: string, icon: ReactNode): CommandItem => ({\n id: `src:${source.id}:status`,\n label,\n // One line for the one request, under the group that will head its answer.\n group: declared[0]?.label ?? source.group,\n icon,\n kind: \"status\",\n onSelect: noop,\n });\n // Anything but an answer to THIS query is still loading — including the previous\n // query's rows, which would otherwise sit under the new query as if they matched.\n if (!result || result.query !== q) {\n out.push(status(pl.loading, <Loader2 className=\"size-3.5 animate-spin\" />));\n } else if (result.status === \"error\") {\n out.push(status(pl.error ?? DEFAULT_COMMAND_PALETTE_LABELS.error!, <AlertCircle className=\"size-3.5\" />));\n } else if (!declared.length) {\n for (const entry of result.entries.slice(0, source.limit ?? groupLimit)) {\n out.push(toItem(`src:${source.id}:`, entry, source.group, source.redact ?? redactLabels));\n }\n } else {\n // Bucket by the entry's own group, then emit in the DECLARED order — the answer's\n // order must not reshuffle the headings between keystrokes. Within a group the\n // source's ranking stands. The group key is in the row id: two record types may\n // share an id (\"1\"), and the palette keys rows by it.\n const buckets = new Map<string, { label: string; entries: SearchEntry[] }>();\n for (const g of declared) buckets.set(g.key, { label: g.label, entries: [] });\n const rest: SearchEntry[] = [];\n for (const entry of result.entries) {\n const bucket = entry.group === undefined ? undefined : buckets.get(entry.group);\n (bucket ? bucket.entries : rest).push(entry);\n }\n const limit = source.limit ?? groupLimit;\n const emit = (key: string, label: string, list: SearchEntry[]) => {\n for (const entry of list.slice(0, limit)) {\n out.push(toItem(`src:${source.id}:${key}:`, entry, label, source.redact ?? redactLabels));\n }\n };\n for (const [key, { label, entries: list }] of buckets) emit(key, label, list);\n emit(\"~\", source.group, rest);\n }\n }\n\n return ordered(out);\n }, [q, suggestionList, suggestionsKeepGroups, byId, index, sourceList, sourceResults, order, groupLimit, toItem, l.suggestions, l.results, pl.loading, pl.error, setOpen, setQuery, noop, redactLabels]);\n /* eslint-enable react-hooks/refs */\n\n // The palette re-runs its provider on a `revision` change, never on the provider's\n // identity — and `items` is everything the provider answers with.\n const provider = useCallback(() => items, [items]);\n\n const resolvedPaletteLabels = useMemo(\n () => ({ dialog: l.trigger, placeholder: l.placeholder, ...paletteLabels }),\n [l.trigger, l.placeholder, paletteLabels],\n );\n\n const apple = useMemo(() => isApplePlatform(), []);\n const keys = apple ? \"⌘K\" : \"Ctrl K\";\n const triggerLabel = triggerName === \"withShortcut\" ? l.shortcut(keys) : l.trigger;\n const triggerProps: GlobalSearchTriggerProps = {\n open: openSearch,\n label: triggerLabel,\n tooltip: l.shortcut(keys),\n keys,\n ariaKeyShortcuts: \"Meta+K Control+K\",\n };\n\n return (\n <>\n {typeof trigger === \"function\"\n ? trigger(triggerProps)\n : trigger === \"button\" && (\n <Tooltip\n label={triggerProps.tooltip}\n side=\"bottom\"\n portal\n className={hideTriggerOnPhone ? \"hidden sm:block\" : undefined}\n >\n <button\n type=\"button\"\n onClick={openSearch}\n aria-label={triggerLabel}\n aria-keyshortcuts={triggerProps.ariaKeyShortcuts}\n aria-haspopup=\"dialog\"\n className={cn(TOPBAR_TRIGGER_CLASS, triggerClassName)}\n >\n <Search\n className={triggerIconSize === undefined ? \"size-4\" : undefined}\n style={triggerIconSize === undefined ? undefined : { width: triggerIconSize, height: triggerIconSize }}\n />\n </button>\n </Tooltip>\n )}\n {/* Nothing is drawn while the palette's module is on its way (a few milliseconds,\n once per page load). A failed load is a chunk-load error for the app's error\n boundary, as a lazy route's is. */}\n {paletteMounted && (\n <Suspense fallback={null}>\n <LazyCommandPalette\n open={open}\n onClose={() => setOpen(false)}\n search={provider}\n revision={items}\n query={query}\n onQueryChange={setQuery}\n searchOn={searchOn}\n redactLabels={redactLabels}\n fullScreenOnPhone={fullScreenOnPhone}\n density={density}\n labels={resolvedPaletteLabels}\n />\n </Suspense>\n )}\n </>\n );\n}\n\nconst EMPTY: readonly SearchEntry[] = [];\n"],"mappings":"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","names":["out","list"]}
|
|
1
|
+
{"version":3,"sources":["../../src/search/global-search.tsx"],"sourcesContent":["import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { useHref, useInRouterContext, useNavigate } from \"react-router\";\nimport { AlertCircle, Loader2, Search } from \"lucide-react\";\nimport { Tooltip } from \"../components/tooltip\";\nimport { TOPBAR_TRIGGER_CLASS } from \"../shell/topbar-controls\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\n// Types only from ./command-palette, gone from the built module: the palette loads late.\nimport type { CommandItem, CommandPaletteDensity, CommandPaletteLabels } from \"./command-palette\";\nimport { DEFAULT_COMMAND_PALETTE_LABELS } from \"./command-palette-labels\";\nimport { useCommandKey } from \"./use-command-key\";\nimport { createSearchIndex } from \"./search-index\";\nimport type { SearchEntry, SearchIndexOptions } from \"./search-index\";\nimport { DEFAULT_GLOBAL_SEARCH_LABELS } from \"./global-search-labels\";\n\n/**\n * The palette, loaded when the search is first opened rather than with the top bar\n * (0.34, MOD-1 of the refactor audit 2026-10): ~5.5 kB gzip of keksdose's and kastlan's\n * entry chunks, for a dialog drawn only once somebody searches. A LITERAL relative\n * `import()`, as the catalogue loaders are (i18n/languages.ts), so the library build keeps\n * the module its own and an app's bundler splits it off.\n */\nconst LazyCommandPalette = lazy(() =>\n import(\"./command-palette\").then((module) => ({ default: module.CommandPalette })),\n);\n\nexport interface GlobalSearchLabels {\n /** The trigger's accessible name, and the dialog's. */\n trigger: string;\n /** The field's placeholder — says WHAT can be found, which \"Search…\" does not. */\n placeholder: string;\n /** The trigger's tooltip, given the shortcut as the platform writes it (\"⌘K\",\n * \"Ctrl K\"). A function so a language can put the keys where its grammar wants. */\n shortcut: (keys: string) => string;\n /** Heading of the suggestions shown for an empty query. */\n suggestions: string;\n /** Heading for entries that name no `group`. */\n results: string;\n}\n\n// Defined in ./global-search-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_GLOBAL_SEARCH_LABELS };\n\n/**\n * A place results come from that is not the static index — a server search, a\n * local-first store too large to hand over whole. Each source is its own group, with its\n * own debounce, its own \"Searching…\" line and its own failure: the static groups never\n * wait for it, and one source failing never takes another's results with it.\n */\nexport interface GlobalSearchSource {\n /** Stable key — the effect that runs the source is keyed by it, not by identity. */\n id: string;\n /** The group its results are shown under (an entry's own `group` is ignored) — unless\n * `groups` is set, where it is only the home of entries that name no declared group.\n * If every entry names a declared group, it is never shown: a catch-all heading such\n * as \"Other\" is the honest value, so a stray type would still surface, labelled. */\n group: string;\n /**\n * Split ONE request into several groups — kastlan's record search answers with\n * properties, units, contacts, leases and invoices at once, and one source per group\n * meant a dedupe layer so the network still saw one request. Each entry is then shown\n * under the group its own `group` names: a declared `key` (shown by its `label`) or a\n * plain string (key and heading at once). The groups appear in THIS order whatever the\n * answer's order, each kept to `limit` rows; an entry naming no declared group falls\n * under `group`. While the request runs, or when it fails, the source shows one status\n * line under its first group, not one per group. Unset: one group, as before.\n */\n groups?: readonly (string | GlobalSearchSourceGroup)[];\n /** Called with the trimmed query. Its results are shown AS GIVEN — the source ranked\n * them — and never re-filtered by the index's matcher. `signal` aborts when the query\n * moves on or the palette closes; a stale answer is dropped either way. */\n search: (query: string, signal: AbortSignal) => Promise<readonly SearchEntry[]> | readonly SearchEntry[];\n /** Shortest query it is asked for. Default 2 — one letter matches half a database. */\n minChars?: number;\n /** Quiet period after the last keystroke before it is asked. Default 200ms. */\n debounceMs?: number;\n /** Rows kept — per group, when `groups` is set. Default: `indexOptions.groupLimit` (8). */\n limit?: number;\n /** Mask every row of this source for session replay, hints (amounts) included. An\n * entry's own `redact` still wins. */\n redact?: boolean;\n /** Told when `search` throws or rejects (not when it is aborted). The group then\n * shows the palette's `error` line instead of rows. */\n onError?: (error: unknown) => void;\n}\n\n/** One of a multi-group source's groups: the `key` its entries name in `group`, and the\n * heading shown — so a server's type (\"unit\") need not be translated per entry. */\nexport interface GlobalSearchSourceGroup {\n key: string;\n label: string;\n}\n\n/** What a custom trigger gets: everything the default button uses. */\nexport interface GlobalSearchTriggerProps {\n open: () => void;\n /** Resolved `trigger` label — the accessible name. */\n label: string;\n /** Resolved `shortcut(keys)` — the tooltip. */\n tooltip: string;\n /** \"⌘K\" on Apple platforms, \"Ctrl K\" elsewhere. */\n keys: string;\n /** For the trigger's `aria-keyshortcuts`. */\n ariaKeyShortcuts: string;\n}\n\n/** A suggestion for the empty query: an entry, the `id` of one in `entries`, or a query\n * to type into the field. */\nexport type GlobalSearchSuggestion = SearchEntry | string | { query: string };\n\nexport interface GlobalSearchProps {\n /**\n * The static index: pages, actions, settings rows — already filtered by the app (by\n * role, by privacy mode). Matched and ranked by {@link createSearchIndex}.\n *\n * Changing it re-runs the open search by itself. Nothing has to be stable: the index is\n * rebuilt only when the entries' TEXT changes (ids, titles, keywords, …), so a list\n * rebuilt on every render — `data = []` while a query is pending, icons as fresh JSX —\n * neither re-indexes nor loops, and the icons and handlers used are always the latest.\n */\n entries?: readonly SearchEntry[];\n /** The async sources, each streamed into its own group — or several (`groups`). */\n sources?: readonly GlobalSearchSource[];\n /** What the empty query shows, under the `suggestions` heading. */\n suggestions?: readonly GlobalSearchSuggestion[];\n /**\n * Show each suggested ENTRY under its own `group` instead of all of them under the\n * one `suggestions` heading — keksdose's empty palette listed \"Pages\" and \"Actions\"\n * apart, and one \"Try\" heading over both lost the difference. An entry with no\n * `group` and a query suggestion (`{ query }`) stay under `suggestions`. `groupOrder`\n * orders these groups as it orders the results'. Off by default.\n */\n suggestionsKeepGroups?: boolean;\n /** Groups listed here come first, in this order; the rest follow in ranking order. */\n groupOrder?: readonly string[];\n /** Ranking and capping — `groupLimit` (default 8) applies to every group. */\n indexOptions?: SearchIndexOptions;\n /**\n * How an entry's `href` is followed on a plain click or ↵. Default: react-router's\n * `navigate` when the search is rendered inside a router, else a full page load.\n */\n navigate?: (href: string) => void;\n /**\n * The browser URL of an `href`, for the row's real link (middle-/⌘-click opens a tab).\n * Default inside a router: the router's own (`#/page` under a hash router, the\n * basename prefixed under a browser router); outside one, the href itself.\n */\n hrefFor?: (href: string) => string;\n /** Register ⌘K / Ctrl K. Default true; turn it off for a second, scoped search on a\n * page that already has the app-wide one. */\n shortcut?: boolean;\n /** Controlled open state — so a button anywhere can open it. */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Controlled query, as on `CommandPalette` (keksdose binds it to the URL's `q`). */\n query?: string;\n onQueryChange?: (query: string) => void;\n searchOn?: \"input\" | \"submit\";\n /** Mask every row for session replay (see `CommandPalette`). A source's or an entry's\n * own `redact` wins. */\n redactLabels?: boolean;\n fullScreenOnPhone?: boolean;\n /** `\"button\"` (default): the top-bar icon button. `\"none\"`: no trigger — the shortcut\n * or a controlled `open` opens it. A function renders a trigger of your own. */\n trigger?: \"button\" | \"none\" | ((props: GlobalSearchTriggerProps) => ReactNode);\n /** Hide the default trigger below `sm` (kastlan's phone top bar has no room). */\n hideTriggerOnPhone?: boolean;\n /** Class for the default trigger button, merged over `TOPBAR_TRIGGER_CLASS`. */\n triggerClassName?: string;\n /**\n * The default trigger's magnifier, in px. Default 16 (`size-4`), as it always was;\n * 20 matches the kit's other top-bar triggers (`TopbarIconButton` draws `size-5`) and\n * keksdose's top bar, which reached into the button with `[&_svg]:size-5` for it.\n */\n triggerIconSize?: number;\n /**\n * Whether the trigger's accessible name includes the shortcut: `\"plain\"` (default)\n * names it \"Search\"; `\"withShortcut\"` names it by `labels.shortcut(keys)` —\n * \"Search (⌘K)\" on Apple platforms, \"Search (Ctrl K)\" elsewhere — the same text its\n * tooltip shows, so a speech-control user (\"click Search\") and a sighted keyboard user\n * meet the shortcut in the name itself (keksdose C25). `aria-keyshortcuts` is set\n * either way. Also reaches a custom trigger as `triggerProps.label`.\n */\n triggerName?: \"plain\" | \"withShortcut\";\n /** The palette's row height — `CommandPalette`'s `density`. Default `\"compact\"`. */\n density?: CommandPaletteDensity;\n labels?: LabelOverride<GlobalSearchLabels>;\n /** Passed through to the palette (`empty`, `loading`, `error`, `hint`, …). */\n paletteLabels?: Partial<CommandPaletteLabels>;\n /** 0.43: `data-*` attributes go on the kit's trigger button (`trigger=\"button\"`) — a\n * tour's anchor (`data-tour=\"search\"`), a test hook. A function trigger is the app's. */\n [key: `data-${string}`]: string | number | boolean | undefined;\n}\n\nfunction isApplePlatform(): boolean {\n if (typeof navigator === \"undefined\") return false;\n const nav = navigator as Navigator & { userAgentData?: { platform?: string } };\n return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform || nav.platform || nav.userAgent);\n}\n\nconst pageLoad = (href: string) => window.location.assign(href);\nconst identity = (href: string) => href;\n\n/** The entries' text, which is all the index reads — see `entries`. */\nfunction signature(entries: readonly SearchEntry[]): string {\n return entries\n .map((e) =>\n [e.id, e.title, (e.keywords ?? []).join(\"\\u0003\"), e.description, e.group, e.hint, e.href, e.weight, e.redact].join(\n \"\\u0001\",\n ),\n )\n .join(\"\\u0002\");\n}\n\n/** Focus the open palette's field again — after a suggestion typed itself into it, the\n * row that was clicked (and held focus) is gone. */\nfunction refocusField() {\n requestAnimationFrame(() => {\n const fields = document.querySelectorAll<HTMLInputElement>('[role=\"dialog\"][aria-modal=\"true\"] input[role=\"combobox\"]');\n fields[fields.length - 1]?.focus();\n });\n}\n\ninterface SourceResult {\n query: string;\n status: \"loading\" | \"done\" | \"error\";\n entries: readonly SearchEntry[];\n}\n\n/**\n * The app-wide ⌘K search, whole: the top-bar trigger, the shortcut, and a\n * {@link CommandPalette} fed from a ranked index ({@link createSearchIndex}) and any\n * number of async sources.\n *\n * Every consumer had built this out of `CommandPalette` by hand, and each copy had the\n * same holes — a substring match, no ranking, no typo tolerance, results in declaration\n * order, one monolithic provider in which the static groups waited on the slowest\n * request. This is that wrapper once:\n *\n * <GlobalSearch\n * entries={pages} // { id, title, keywords, group, href, icon }\n * sources={[{ id: \"tx\", group: \"Transactions\", search: (q, signal) => api.search(q, { signal }) }]}\n * suggestions={[\"page-budget\", \"action-new-account\"]}\n * />\n *\n * Inside a react-router tree it navigates with the router and builds its links with it;\n * outside one, pass `navigate` (or accept full page loads).\n *\n * Since 0.34 the palette's module loads on the first open (MOD-1), so the dialog appears a\n * moment after the trigger or ⌘K: a test finds it with `await screen.findBy…`.\n */\nexport function GlobalSearch(props: GlobalSearchProps) {\n // Two components rather than a conditional hook: `useNavigate` throws outside a\n // router, and whether we are in one does not change over a component's life.\n const inRouter = useInRouterContext();\n if (inRouter && (!props.navigate || !props.hrefFor)) return <RoutedGlobalSearch {...props} />;\n return <GlobalSearchImpl {...props} navigate={props.navigate ?? pageLoad} hrefFor={props.hrefFor ?? identity} />;\n}\n\nfunction RoutedGlobalSearch(props: GlobalSearchProps) {\n const routerNavigate = useNavigate();\n // The router's href for \"/\" is its base (\"#/\" under a hash router, \"/app/\" under a\n // basename); every href is that base plus the path.\n const base = useHref(\"/\").replace(/\\/$/, \"\");\n const navigate = useCallback((href: string) => void routerNavigate(href), [routerNavigate]);\n const hrefFor = useCallback((href: string) => (href.startsWith(\"/\") ? base + href : href), [base]);\n return <GlobalSearchImpl {...props} navigate={props.navigate ?? navigate} hrefFor={props.hrefFor ?? hrefFor} />;\n}\n\nfunction GlobalSearchImpl({\n entries,\n sources,\n suggestions,\n suggestionsKeepGroups = false,\n groupOrder,\n indexOptions,\n navigate,\n hrefFor,\n shortcut = true,\n open: openProp,\n onOpenChange,\n query: queryProp,\n onQueryChange,\n searchOn,\n redactLabels = false,\n fullScreenOnPhone,\n trigger = \"button\",\n hideTriggerOnPhone = false,\n triggerClassName,\n triggerIconSize,\n triggerName = \"plain\",\n density,\n labels,\n paletteLabels,\n ...attributes\n}: GlobalSearchProps & { navigate: (href: string) => void; hrefFor: (href: string) => string }) {\n const l = useKitLabels(\"globalSearch\", DEFAULT_GLOBAL_SEARCH_LABELS, labels);\n const pl = useKitLabels(\"commandPalette\", DEFAULT_COMMAND_PALETTE_LABELS, paletteLabels);\n const [ownOpen, setOwnOpen] = useState(false);\n const open = openProp ?? ownOpen;\n const [ownQuery, setOwnQuery] = useState(\"\");\n const queryControlled = queryProp !== undefined;\n const query = queryControlled ? queryProp : ownQuery;\n const q = query.trim();\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (openProp === undefined) setOwnOpen(next);\n onOpenChange?.(next);\n },\n [openProp, onOpenChange],\n );\n const setQuery = useCallback(\n (next: string) => {\n if (!queryControlled) setOwnQuery(next);\n onQueryChange?.(next);\n },\n [queryControlled, onQueryChange],\n );\n\n // Each open starts blank — unless the owner controls the query (a URL `q`). Adjusted\n // during render, React's pattern for state that follows a prop, so a controlled `open`\n // gets the same reset as the trigger does.\n const [wasOpen, setWasOpen] = useState(open);\n if (wasOpen !== open) {\n setWasOpen(open);\n if (open && !queryControlled) setOwnQuery(\"\");\n }\n\n // The palette mounts on the first open — its module loads then — and stays mounted\n // after, closed, as it always was (MOD-1): a closed palette keeps the last results, and\n // a reopen over an owner-held query shows them at once, where a palette mounted anew\n // would say \"No results\" through its debounce first.\n const [paletteMounted, setPaletteMounted] = useState(open);\n if (open && !paletteMounted) setPaletteMounted(true);\n\n const openSearch = useCallback(() => setOpen(true), [setOpen]);\n const noop = useCallback(() => {}, []);\n useCommandKey(shortcut ? openSearch : noop);\n\n /* ── the static index ─────────────────────────────────────────────────── */\n\n // Everything below is keyed by CONTENT, never by the identity of what the app passed:\n // inline arrays and `data = []` defaults are the norm, and an identity key would\n // re-index and re-search on every render of the app. Handlers, which can close over\n // anything, are read from refs at the moment a row is chosen — never stale.\n const list = entries ?? EMPTY;\n const sig = signature(list);\n const optionsKey = JSON.stringify(indexOptions ?? {});\n const groupLimit = indexOptions?.groupLimit ?? 8;\n // `sig` and `optionsKey` stand for `list` and `indexOptions`: same text, same index.\n /* eslint-disable react-hooks/exhaustive-deps */\n const index = useMemo(() => createSearchIndex(list, indexOptions), [sig, optionsKey]);\n const byId = useMemo(() => new Map(list.map((e) => [e.id, e])), [sig]);\n\n const sourceKey = (sources ?? [])\n .map((s) =>\n [\n s.id,\n s.group,\n (s.groups ?? []).map((g) => (typeof g === \"string\" ? g : `${g.key}\\u0003${g.label}`)).join(\"\\u0004\"),\n s.minChars ?? 2,\n s.debounceMs ?? 200,\n s.limit,\n s.redact,\n ].join(\"\\u0001\"),\n )\n .join(\"\\u0002\");\n const sourceList = useMemo(() => sources ?? [], [sourceKey]);\n\n const suggestionsKey = (suggestions ?? [])\n .map((s) => (typeof s === \"string\" ? `i:${s}` : \"query\" in s ? `q:${s.query}` : `e:${signature([s])}`))\n .join(\"\\u0002\");\n const suggestionList = useMemo(() => suggestions ?? [], [suggestionsKey]);\n\n const groupOrderKey = (groupOrder ?? []).join(\"\\u0002\");\n const order = useMemo(() => groupOrder ?? [], [groupOrderKey]);\n /* eslint-enable react-hooks/exhaustive-deps */\n\n // The live objects, for the moment a row is chosen or a source is asked.\n const live = useRef({ entries: list, sources: sources ?? [], suggestions: suggestions ?? [], navigate });\n useLayoutEffect(() => {\n live.current = { entries: list, sources: sources ?? [], suggestions: suggestions ?? [], navigate };\n });\n\n /* ── the async sources ────────────────────────────────────────────────── */\n\n const [sourceResults, setSourceResults] = useState<Record<string, SourceResult>>({});\n useEffect(() => {\n const controllers: AbortController[] = [];\n const timers: ReturnType<typeof setTimeout>[] = [];\n const asked = open ? live.current.sources.filter((s) => q.length >= (s.minChars ?? 2)) : [];\n for (const source of asked) {\n const id = source.id;\n const controller = new AbortController();\n controllers.push(controller);\n timers.push(\n setTimeout(async () => {\n const settle = (result: SourceResult) => {\n if (!controller.signal.aborted) setSourceResults((prev) => ({ ...prev, [id]: result }));\n };\n try {\n const found = await source.search(q, controller.signal);\n settle({ query: q, status: \"done\", entries: found });\n } catch (error) {\n if (controller.signal.aborted) return;\n source.onError?.(error);\n settle({ query: q, status: \"error\", entries: [] });\n }\n }, source.debounceMs ?? 200),\n );\n }\n return () => {\n timers.forEach(clearTimeout);\n controllers.forEach((c) => c.abort());\n };\n }, [open, q, sourceKey]);\n\n /* ── rows ─────────────────────────────────────────────────────────────── */\n\n const toItem = useCallback(\n (prefix: string, entry: SearchEntry, group: string, redact = redactLabels): CommandItem => ({\n id: `${prefix}${entry.id}`,\n label: entry.title,\n hint: entry.hint,\n group,\n icon: entry.icon,\n redact: entry.redact ?? redact,\n href: entry.href !== undefined ? hrefFor(entry.href) : undefined,\n onSelect: () => {\n // The entry as the app has it NOW — its handler may close over newer state\n // than the one this row was built from.\n const current = live.current.entries.find((e) => e.id === entry.id) ?? entry;\n if (current.onSelect) current.onSelect();\n else if (current.href !== undefined) live.current.navigate(current.href);\n },\n }),\n [hrefFor, redactLabels],\n );\n\n // `toItem` only BUILDS `onSelect` closures here; `live` is read when a row is chosen,\n // which the compiler lint cannot tell from a read during render.\n /* eslint-disable react-hooks/refs */\n const items = useMemo((): CommandItem[] => {\n const ordered = (out: CommandItem[]) => {\n if (!order.length) return out;\n const rank = (group: string) => {\n const i = order.indexOf(group);\n return i === -1 ? order.length : i;\n };\n // Stable: within a rank, groups keep the order their best hit gave them.\n return out\n .map((item, i) => ({ item, i }))\n .sort((a, b) => rank(a.item.group) - rank(b.item.group) || a.i - b.i)\n .map(({ item }) => item);\n };\n\n if (!q) {\n const out: CommandItem[] = [];\n const groupOf = (entry: SearchEntry) =>\n suggestionsKeepGroups ? (entry.group ?? l.suggestions) : l.suggestions;\n suggestionList.forEach((s, i) => {\n if (typeof s === \"string\") {\n const entry = byId.get(s);\n if (entry) out.push(toItem(\"try:\", entry, groupOf(entry)));\n } else if (\"query\" in s) {\n out.push({\n id: `try:q${i}`,\n label: s.query,\n group: l.suggestions,\n icon: <Search className=\"size-4\" />,\n onSelect: () => {\n // The palette closes before it runs a row; a query suggestion keeps it\n // open and types itself in instead.\n setOpen(true);\n setQuery(s.query);\n refocusField();\n },\n });\n } else out.push(toItem(\"try:\", s, groupOf(s)));\n });\n // One heading has nothing to order; kept groups are ordered like results.\n return suggestionsKeepGroups ? ordered(out) : out;\n }\n\n const out = index.search(q).map((hit) => toItem(\"s:\", hit.entry, hit.entry.group ?? l.results));\n for (const source of sourceList) {\n if (q.length < (source.minChars ?? 2)) continue;\n const result = sourceResults[source.id];\n const declared = (source.groups ?? []).map((g) => (typeof g === \"string\" ? { key: g, label: g } : g));\n const status = (label: string, icon: ReactNode): CommandItem => ({\n id: `src:${source.id}:status`,\n label,\n // One line for the one request, under the group that will head its answer.\n group: declared[0]?.label ?? source.group,\n icon,\n kind: \"status\",\n onSelect: noop,\n });\n // Anything but an answer to THIS query is still loading — including the previous\n // query's rows, which would otherwise sit under the new query as if they matched.\n if (!result || result.query !== q) {\n out.push(status(pl.loading, <Loader2 className=\"size-3.5 animate-spin\" />));\n } else if (result.status === \"error\") {\n out.push(status(pl.error ?? DEFAULT_COMMAND_PALETTE_LABELS.error!, <AlertCircle className=\"size-3.5\" />));\n } else if (!declared.length) {\n for (const entry of result.entries.slice(0, source.limit ?? groupLimit)) {\n out.push(toItem(`src:${source.id}:`, entry, source.group, source.redact ?? redactLabels));\n }\n } else {\n // Bucket by the entry's own group, then emit in the DECLARED order — the answer's\n // order must not reshuffle the headings between keystrokes. Within a group the\n // source's ranking stands. The group key is in the row id: two record types may\n // share an id (\"1\"), and the palette keys rows by it.\n const buckets = new Map<string, { label: string; entries: SearchEntry[] }>();\n for (const g of declared) buckets.set(g.key, { label: g.label, entries: [] });\n const rest: SearchEntry[] = [];\n for (const entry of result.entries) {\n const bucket = entry.group === undefined ? undefined : buckets.get(entry.group);\n (bucket ? bucket.entries : rest).push(entry);\n }\n const limit = source.limit ?? groupLimit;\n const emit = (key: string, label: string, list: SearchEntry[]) => {\n for (const entry of list.slice(0, limit)) {\n out.push(toItem(`src:${source.id}:${key}:`, entry, label, source.redact ?? redactLabels));\n }\n };\n for (const [key, { label, entries: list }] of buckets) emit(key, label, list);\n emit(\"~\", source.group, rest);\n }\n }\n\n return ordered(out);\n }, [q, suggestionList, suggestionsKeepGroups, byId, index, sourceList, sourceResults, order, groupLimit, toItem, l.suggestions, l.results, pl.loading, pl.error, setOpen, setQuery, noop, redactLabels]);\n /* eslint-enable react-hooks/refs */\n\n // The palette re-runs its provider on a `revision` change, never on the provider's\n // identity — and `items` is everything the provider answers with.\n const provider = useCallback(() => items, [items]);\n\n const resolvedPaletteLabels = useMemo(\n () => ({ dialog: l.trigger, placeholder: l.placeholder, ...paletteLabels }),\n [l.trigger, l.placeholder, paletteLabels],\n );\n\n const apple = useMemo(() => isApplePlatform(), []);\n const keys = apple ? \"⌘K\" : \"Ctrl K\";\n const triggerLabel = triggerName === \"withShortcut\" ? l.shortcut(keys) : l.trigger;\n const triggerProps: GlobalSearchTriggerProps = {\n open: openSearch,\n label: triggerLabel,\n tooltip: l.shortcut(keys),\n keys,\n ariaKeyShortcuts: \"Meta+K Control+K\",\n };\n\n return (\n <>\n {typeof trigger === \"function\"\n ? trigger(triggerProps)\n : trigger === \"button\" && (\n <Tooltip\n label={triggerProps.tooltip}\n side=\"bottom\"\n portal\n className={hideTriggerOnPhone ? \"hidden sm:block\" : undefined}\n >\n <button\n {...Object.fromEntries(Object.entries(attributes).filter(([k]) => k.startsWith(\"data-\")))}\n type=\"button\"\n onClick={openSearch}\n aria-label={triggerLabel}\n aria-keyshortcuts={triggerProps.ariaKeyShortcuts}\n aria-haspopup=\"dialog\"\n className={cn(TOPBAR_TRIGGER_CLASS, triggerClassName)}\n >\n <Search\n className={triggerIconSize === undefined ? \"size-4\" : undefined}\n style={triggerIconSize === undefined ? undefined : { width: triggerIconSize, height: triggerIconSize }}\n />\n </button>\n </Tooltip>\n )}\n {/* Nothing is drawn while the palette's module is on its way (a few milliseconds,\n once per page load). A failed load is a chunk-load error for the app's error\n boundary, as a lazy route's is. */}\n {paletteMounted && (\n <Suspense fallback={null}>\n <LazyCommandPalette\n open={open}\n onClose={() => setOpen(false)}\n search={provider}\n revision={items}\n query={query}\n onQueryChange={setQuery}\n searchOn={searchOn}\n redactLabels={redactLabels}\n fullScreenOnPhone={fullScreenOnPhone}\n density={density}\n labels={resolvedPaletteLabels}\n />\n </Suspense>\n )}\n </>\n );\n}\n\nconst EMPTY: readonly SearchEntry[] = [];\n"],"mappings":"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","names":["out","list"]}
|
|
@@ -27,6 +27,13 @@ export interface SettingsGroup<G extends string = string> {
|
|
|
27
27
|
* kastlan's `/admin/billing` while billing is off. Default: visible.
|
|
28
28
|
*/
|
|
29
29
|
visible?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* 0.43: the group shows with no visible entry — a page of its own rather than of setting
|
|
32
|
+
* cards (the admin area's Overview, whose tiles are no settings; `adminSections` sets
|
|
33
|
+
* it). `visible: false` still hides it. Default: a group shows only with an entry once
|
|
34
|
+
* `entries` are given (§4.1).
|
|
35
|
+
*/
|
|
36
|
+
standalone?: boolean;
|
|
30
37
|
}
|
|
31
38
|
/** One setting, and the card that holds it (§4.1). */
|
|
32
39
|
export interface SettingsEntry<G extends string = string> {
|
|
@@ -53,7 +60,8 @@ export interface SettingsEntry<G extends string = string> {
|
|
|
53
60
|
/**
|
|
54
61
|
* The groups that show, in their order: not `visible: false`, and — when `entries` is
|
|
55
62
|
* given — holding at least one visible entry (§4.1: Kurvenschmiede's notifications group
|
|
56
|
-
* stays hidden until it has a card). Without `entries` only the
|
|
63
|
+
* stays hidden until it has a card) or `standalone` (0.43). Without `entries` only the
|
|
64
|
+
* groups' own flag counts.
|
|
57
65
|
*/
|
|
58
66
|
export declare function visibleSettingsGroups<G extends string>(groups: readonly SettingsGroup<G>[], entries?: readonly SettingsEntry<G>[]): SettingsGroup<G>[];
|
|
59
67
|
/** The visible entries of the visible groups — what search may find. */
|
|
@@ -3,7 +3,7 @@ function visibleSettingsGroups(groups, entries) {
|
|
|
3
3
|
if (!entries) return groups.filter((g) => g.visible !== false);
|
|
4
4
|
const filled = /* @__PURE__ */ new Set();
|
|
5
5
|
for (const e of entries) if (e.visible !== false) filled.add(e.group);
|
|
6
|
-
return groups.filter((g) => g.visible !== false && filled.has(g.id));
|
|
6
|
+
return groups.filter((g) => g.visible !== false && (g.standalone === true || filled.has(g.id)));
|
|
7
7
|
}
|
|
8
8
|
function visibleSettingsEntries(groups, entries) {
|
|
9
9
|
const shown = new Set(visibleSettingsGroups(groups, entries).map((g) => g.id));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/settings/settings-catalogue.ts"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport type { SearchEntry } from \"../search/search-index\";\n\n/**\n * The settings catalogue (docs/settings-harmonization.md §4.1), lifted from keksdose's\n * `settings-index.ts`: the groups a settings page is split into, and one entry per\n * SETTING — not per card — so a search for \"quiet hours\" finds the field inside the\n * notifications card. One list feeds the sidebar, the phone list, the in-page search and\n * ⌘K ({@link settingsSearchEntries}); keksdose's page and its palette drifted when they\n * had one each.\n *\n * The kit resolves no words: `title`, `help` and `keywords` arrive translated (an app's\n * own, or the core groups' from `DEFAULT_SETTINGS_LABELS.groups`).\n */\n\n/** One group of the page: a sidebar row on a desktop, a list row on a phone (§3.2, §3.3). */\nexport interface SettingsGroup<G extends string = string> {\n /** The path segment, `/settings/<id>`: lowercase, a–z and \"-\". */\n id: G;\n /** The row's icon — a lucide icon at the row's size is sized by the row. */\n icon: ReactNode;\n /** The group's name: the sidebar row, the heading over its cards, the phone list. */\n title: string;\n /** One line under the heading and in the phone list, where it is cut to one line. */\n help: string;\n /**\n * `false` hides the group: no row, no hits, and its path acts as an unknown one — it\n * lands on the default group (desktop) or the list (phone), never on a 404 (§3.1).\n * kastlan's `/admin/billing` while billing is off. Default: visible.\n */\n visible?: boolean;\n}\n\n/** One setting, and the card that holds it (§4.1). */\nexport interface SettingsEntry<G extends string = string> {\n /** Unique within the catalogue. */\n id: string;\n /** The group whose page shows the card. */\n group: G;\n /** The card's DOM id, unique on the page — the `?focus=` target. Several entries may\n * share one: a card that holds four settings has four entries. */\n anchor: string;\n /** What a search hit says. */\n title: string;\n /** Localized, space-separated words people type that the title does not contain\n * (\"dark night\" for the theme, \"2fa totp\" for two-factor). Never shown. */\n keywords?: string;\n /**\n * `false` drops the entry from the page's search and ⌘K. A group whose entries are\n * ALL hidden is hidden with them (§4.1): a demo session marks every card it is refused\n * (passkeys, 2FA, export, deletion …) and so never sees a page of cards that each\n * answer 403. Default: visible.\n */\n visible?: boolean;\n}\n\n/**\n * The groups that show, in their order: not `visible: false`, and — when `entries` is\n * given — holding at least one visible entry (§4.1: Kurvenschmiede's notifications group\n * stays hidden until it has a card). Without `entries` only the groups' own flag counts.\n */\nexport function visibleSettingsGroups<G extends string>(\n groups: readonly SettingsGroup<G>[],\n entries?: readonly SettingsEntry<G>[],\n): SettingsGroup<G>[] {\n if (!entries) return groups.filter((g) => g.visible !== false);\n const filled = new Set<G>();\n for (const e of entries) if (e.visible !== false) filled.add(e.group);\n return groups.filter((g) => g.visible !== false && filled.has(g.id));\n}\n\n/** The visible entries of the visible groups — what search may find. */\nexport function visibleSettingsEntries<G extends string>(\n groups: readonly SettingsGroup<G>[],\n entries: readonly SettingsEntry<G>[],\n): SettingsEntry<G>[] {\n const shown = new Set(visibleSettingsGroups(groups, entries).map((g) => g.id));\n return entries.filter((e) => e.visible !== false && shown.has(e.group));\n}\n\n/** `basePath` without a trailing slash: \"/settings/\" and \"/settings\" are one page. */\nexport function normalizeSettingsBase(basePath: string): string {\n const trimmed = basePath.replace(/\\/+$/, \"\");\n return trimmed.startsWith(\"/\") || trimmed === \"\" ? trimmed : `/${trimmed}`;\n}\n\n/** `/settings/<group>`, and `?focus=<anchor>` when a card is named (§3.4). */\nexport function settingsHref(basePath: string, group: string, anchor?: string): string {\n const path = `${normalizeSettingsBase(basePath)}/${group}`;\n return anchor ? `${path}?focus=${encodeURIComponent(anchor)}` : path;\n}\n\n/** \"dark night\" → [\"dark\", \"night\"]. */\nexport function settingsKeywords(keywords: string | undefined): string[] {\n return keywords ? keywords.split(/\\s+/).filter(Boolean) : [];\n}\n\nexport interface SettingsSearchEntriesOptions {\n /** `/settings` or `/admin` — the hits link to `<basePath>/<group>?focus=<anchor>`. */\n basePath: string;\n}\n\n/**\n * The catalogue as {@link GlobalSearch} entries (§3.4): each visible setting, under its\n * group's name as the palette's section, linking to `/settings/<group>?focus=<anchor>` —\n * the same link the page's own search hit is, so ⌘K, a tour and a mail all land the\n * same way (§3.5). The group's name is a keyword too, as on the page: \"security\" lists\n * the security group's settings.\n *\n * Ids are prefixed with the base path (\"settings:theme\", \"admin:users\") so a palette\n * that also holds pages and actions keeps them unique.\n */\nexport function settingsSearchEntries<G extends string>(\n groups: readonly SettingsGroup<G>[],\n entries: readonly SettingsEntry<G>[],\n options: SettingsSearchEntriesOptions,\n): SearchEntry[] {\n const base = normalizeSettingsBase(options.basePath);\n const prefix = base.replace(/^\\//, \"\").replace(/\\//g, \":\") || \"settings\";\n const byId = new Map(visibleSettingsGroups(groups, entries).map((g) => [g.id, g]));\n return visibleSettingsEntries(groups, entries).map((e) => {\n const group = byId.get(e.group)!;\n return {\n id: `${prefix}:${e.id}`,\n title: e.title,\n keywords: [group.title, ...settingsKeywords(e.keywords)],\n group: group.title,\n icon: group.icon,\n href: settingsHref(base, e.group, e.anchor),\n };\n });\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../../src/settings/settings-catalogue.ts"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport type { SearchEntry } from \"../search/search-index\";\n\n/**\n * The settings catalogue (docs/settings-harmonization.md §4.1), lifted from keksdose's\n * `settings-index.ts`: the groups a settings page is split into, and one entry per\n * SETTING — not per card — so a search for \"quiet hours\" finds the field inside the\n * notifications card. One list feeds the sidebar, the phone list, the in-page search and\n * ⌘K ({@link settingsSearchEntries}); keksdose's page and its palette drifted when they\n * had one each.\n *\n * The kit resolves no words: `title`, `help` and `keywords` arrive translated (an app's\n * own, or the core groups' from `DEFAULT_SETTINGS_LABELS.groups`).\n */\n\n/** One group of the page: a sidebar row on a desktop, a list row on a phone (§3.2, §3.3). */\nexport interface SettingsGroup<G extends string = string> {\n /** The path segment, `/settings/<id>`: lowercase, a–z and \"-\". */\n id: G;\n /** The row's icon — a lucide icon at the row's size is sized by the row. */\n icon: ReactNode;\n /** The group's name: the sidebar row, the heading over its cards, the phone list. */\n title: string;\n /** One line under the heading and in the phone list, where it is cut to one line. */\n help: string;\n /**\n * `false` hides the group: no row, no hits, and its path acts as an unknown one — it\n * lands on the default group (desktop) or the list (phone), never on a 404 (§3.1).\n * kastlan's `/admin/billing` while billing is off. Default: visible.\n */\n visible?: boolean;\n /**\n * 0.43: the group shows with no visible entry — a page of its own rather than of setting\n * cards (the admin area's Overview, whose tiles are no settings; `adminSections` sets\n * it). `visible: false` still hides it. Default: a group shows only with an entry once\n * `entries` are given (§4.1).\n */\n standalone?: boolean;\n}\n\n/** One setting, and the card that holds it (§4.1). */\nexport interface SettingsEntry<G extends string = string> {\n /** Unique within the catalogue. */\n id: string;\n /** The group whose page shows the card. */\n group: G;\n /** The card's DOM id, unique on the page — the `?focus=` target. Several entries may\n * share one: a card that holds four settings has four entries. */\n anchor: string;\n /** What a search hit says. */\n title: string;\n /** Localized, space-separated words people type that the title does not contain\n * (\"dark night\" for the theme, \"2fa totp\" for two-factor). Never shown. */\n keywords?: string;\n /**\n * `false` drops the entry from the page's search and ⌘K. A group whose entries are\n * ALL hidden is hidden with them (§4.1): a demo session marks every card it is refused\n * (passkeys, 2FA, export, deletion …) and so never sees a page of cards that each\n * answer 403. Default: visible.\n */\n visible?: boolean;\n}\n\n/**\n * The groups that show, in their order: not `visible: false`, and — when `entries` is\n * given — holding at least one visible entry (§4.1: Kurvenschmiede's notifications group\n * stays hidden until it has a card) or `standalone` (0.43). Without `entries` only the\n * groups' own flag counts.\n */\nexport function visibleSettingsGroups<G extends string>(\n groups: readonly SettingsGroup<G>[],\n entries?: readonly SettingsEntry<G>[],\n): SettingsGroup<G>[] {\n if (!entries) return groups.filter((g) => g.visible !== false);\n const filled = new Set<G>();\n for (const e of entries) if (e.visible !== false) filled.add(e.group);\n return groups.filter((g) => g.visible !== false && (g.standalone === true || filled.has(g.id)));\n}\n\n/** The visible entries of the visible groups — what search may find. */\nexport function visibleSettingsEntries<G extends string>(\n groups: readonly SettingsGroup<G>[],\n entries: readonly SettingsEntry<G>[],\n): SettingsEntry<G>[] {\n const shown = new Set(visibleSettingsGroups(groups, entries).map((g) => g.id));\n return entries.filter((e) => e.visible !== false && shown.has(e.group));\n}\n\n/** `basePath` without a trailing slash: \"/settings/\" and \"/settings\" are one page. */\nexport function normalizeSettingsBase(basePath: string): string {\n const trimmed = basePath.replace(/\\/+$/, \"\");\n return trimmed.startsWith(\"/\") || trimmed === \"\" ? trimmed : `/${trimmed}`;\n}\n\n/** `/settings/<group>`, and `?focus=<anchor>` when a card is named (§3.4). */\nexport function settingsHref(basePath: string, group: string, anchor?: string): string {\n const path = `${normalizeSettingsBase(basePath)}/${group}`;\n return anchor ? `${path}?focus=${encodeURIComponent(anchor)}` : path;\n}\n\n/** \"dark night\" → [\"dark\", \"night\"]. */\nexport function settingsKeywords(keywords: string | undefined): string[] {\n return keywords ? keywords.split(/\\s+/).filter(Boolean) : [];\n}\n\nexport interface SettingsSearchEntriesOptions {\n /** `/settings` or `/admin` — the hits link to `<basePath>/<group>?focus=<anchor>`. */\n basePath: string;\n}\n\n/**\n * The catalogue as {@link GlobalSearch} entries (§3.4): each visible setting, under its\n * group's name as the palette's section, linking to `/settings/<group>?focus=<anchor>` —\n * the same link the page's own search hit is, so ⌘K, a tour and a mail all land the\n * same way (§3.5). The group's name is a keyword too, as on the page: \"security\" lists\n * the security group's settings.\n *\n * Ids are prefixed with the base path (\"settings:theme\", \"admin:users\") so a palette\n * that also holds pages and actions keeps them unique.\n */\nexport function settingsSearchEntries<G extends string>(\n groups: readonly SettingsGroup<G>[],\n entries: readonly SettingsEntry<G>[],\n options: SettingsSearchEntriesOptions,\n): SearchEntry[] {\n const base = normalizeSettingsBase(options.basePath);\n const prefix = base.replace(/^\\//, \"\").replace(/\\//g, \":\") || \"settings\";\n const byId = new Map(visibleSettingsGroups(groups, entries).map((g) => [g.id, g]));\n return visibleSettingsEntries(groups, entries).map((e) => {\n const group = byId.get(e.group)!;\n return {\n id: `${prefix}:${e.id}`,\n title: e.title,\n keywords: [group.title, ...settingsKeywords(e.keywords)],\n group: group.title,\n icon: group.icon,\n href: settingsHref(base, e.group, e.anchor),\n };\n });\n}\n"],"mappings":";AAqEO,SAAS,sBACd,QACA,SACoB;AACpB,MAAI,CAAC,QAAS,QAAO,OAAO,OAAO,CAAC,MAAM,EAAE,YAAY,KAAK;AAC7D,QAAM,SAAS,oBAAI,IAAO;AAC1B,aAAW,KAAK,QAAS,KAAI,EAAE,YAAY,MAAO,QAAO,IAAI,EAAE,KAAK;AACpE,SAAO,OAAO,OAAO,CAAC,MAAM,EAAE,YAAY,UAAU,EAAE,eAAe,QAAQ,OAAO,IAAI,EAAE,EAAE,EAAE;AAChG;AAGO,SAAS,uBACd,QACA,SACoB;AACpB,QAAM,QAAQ,IAAI,IAAI,sBAAsB,QAAQ,OAAO,EAAE,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;AAC7E,SAAO,QAAQ,OAAO,CAAC,MAAM,EAAE,YAAY,SAAS,MAAM,IAAI,EAAE,KAAK,CAAC;AACxE;AAGO,SAAS,sBAAsB,UAA0B;AAC9D,QAAM,UAAU,SAAS,QAAQ,QAAQ,EAAE;AAC3C,SAAO,QAAQ,WAAW,GAAG,KAAK,YAAY,KAAK,UAAU,IAAI,OAAO;AAC1E;AAGO,SAAS,aAAa,UAAkB,OAAe,QAAyB;AACrF,QAAM,OAAO,GAAG,sBAAsB,QAAQ,CAAC,IAAI,KAAK;AACxD,SAAO,SAAS,GAAG,IAAI,UAAU,mBAAmB,MAAM,CAAC,KAAK;AAClE;AAGO,SAAS,iBAAiB,UAAwC;AACvE,SAAO,WAAW,SAAS,MAAM,KAAK,EAAE,OAAO,OAAO,IAAI,CAAC;AAC7D;AAiBO,SAAS,sBACd,QACA,SACA,SACe;AACf,QAAM,OAAO,sBAAsB,QAAQ,QAAQ;AACnD,QAAM,SAAS,KAAK,QAAQ,OAAO,EAAE,EAAE,QAAQ,OAAO,GAAG,KAAK;AAC9D,QAAM,OAAO,IAAI,IAAI,sBAAsB,QAAQ,OAAO,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;AACjF,SAAO,uBAAuB,QAAQ,OAAO,EAAE,IAAI,CAAC,MAAM;AACxD,UAAM,QAAQ,KAAK,IAAI,EAAE,KAAK;AAC9B,WAAO;AAAA,MACL,IAAI,GAAG,MAAM,IAAI,EAAE,EAAE;AAAA,MACrB,OAAO,EAAE;AAAA,MACT,UAAU,CAAC,MAAM,OAAO,GAAG,iBAAiB,EAAE,QAAQ,CAAC;AAAA,MACvD,OAAO,MAAM;AAAA,MACb,MAAM,MAAM;AAAA,MACZ,MAAM,aAAa,MAAM,EAAE,OAAO,EAAE,MAAM;AAAA,IAC5C;AAAA,EACF,CAAC;AACH;","names":[]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { LabelOverride } from "../i18n/kit-labels.js";
|
|
2
2
|
import type { AdminAreaLabels } from "../admin/admin-area-labels.js";
|
|
3
3
|
import type { TopBarMenuEntry } from "./topbar-action-menu.js";
|
|
4
4
|
export interface AdminMenuEntryOptions {
|
|
@@ -9,8 +9,12 @@ export interface AdminMenuEntryOptions {
|
|
|
9
9
|
* `adminWaitingCount(attention, […])`. `0`, `null` or none shows no badge.
|
|
10
10
|
*/
|
|
11
11
|
badge?: number | null;
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
12
|
+
/**
|
|
13
|
+
* The resolved `adminArea` words — pass `useAdminAreaLabels()`, or the row is ENGLISH
|
|
14
|
+
* in every language: a plain function reads no provider (kastlan, 0.42 adoption). Builds
|
|
15
|
+
* no hook, so the entry can be made in the list of entries. {@link useAdminMenuEntry}
|
|
16
|
+
* (0.43) is the same entry with the provider's words read for you.
|
|
17
|
+
*/
|
|
14
18
|
labels?: AdminAreaLabels;
|
|
15
19
|
/** The entry's `key`. Default `"administration"`. */
|
|
16
20
|
key?: string;
|
|
@@ -22,9 +26,28 @@ export interface AdminMenuEntryOptions {
|
|
|
22
26
|
* the kit's word and the waiting count as its badge. Put it in the entries for an admin
|
|
23
27
|
* only — the menu closes on the way, as any link row:
|
|
24
28
|
*
|
|
29
|
+
* const labels = useAdminAreaLabels();
|
|
25
30
|
* entries={[…, ...(isAdmin ? [adminMenuEntry({ badge: waiting, labels })] : []), …]}
|
|
31
|
+
*
|
|
32
|
+
* A function, not a hook: it reads NO provider, so pass `labels` (`useAdminAreaLabels()`)
|
|
33
|
+
* or the row says "Administration" in every language — or call {@link useAdminMenuEntry}
|
|
34
|
+
* at the component's top.
|
|
26
35
|
*/
|
|
27
36
|
export declare function adminMenuEntry({ href, badge, labels, key, current, }?: AdminMenuEntryOptions): TopBarMenuEntry;
|
|
37
|
+
export interface UseAdminMenuEntryOptions extends Omit<AdminMenuEntryOptions, "labels"> {
|
|
38
|
+
/** Over the provider's `adminArea` words: prop > `<UiKitProvider labels={{ adminArea }}>`
|
|
39
|
+
* > English. */
|
|
40
|
+
labels?: LabelOverride<AdminAreaLabels>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* {@link adminMenuEntry} with the provider's words (0.43, kastlan): the hook reads
|
|
44
|
+
* `useAdminAreaLabels()` itself, so the row is in the app's language without a `labels`
|
|
45
|
+
* to remember. Call it at the component's top, then put it in the entries:
|
|
46
|
+
*
|
|
47
|
+
* const admin = useAdminMenuEntry({ badge: waiting });
|
|
48
|
+
* entries={[…, ...(isAdmin ? [admin] : []), …]}
|
|
49
|
+
*/
|
|
50
|
+
export declare function useAdminMenuEntry({ labels: labelsProp, ...options }?: UseAdminMenuEntryOptions): TopBarMenuEntry;
|
|
28
51
|
export interface AdminMenuRowProps {
|
|
29
52
|
/** The admin area. Default `/admin`. */
|
|
30
53
|
href?: string;
|
package/dist/shell/admin-menu.js
CHANGED
|
@@ -4,8 +4,7 @@ import { Link } from "react-router";
|
|
|
4
4
|
import { ShieldCheck } from "lucide-react";
|
|
5
5
|
import { Chip } from "../components/chip.js";
|
|
6
6
|
import { MenuItem } from "../components/menu-item.js";
|
|
7
|
-
import {
|
|
8
|
-
import { DEFAULT_ADMIN_AREA_LABELS } from "../admin/admin-area-labels.js";
|
|
7
|
+
import { DEFAULT_ADMIN_AREA_LABELS, useAdminAreaLabels } from "../admin/admin-area-labels.js";
|
|
9
8
|
function badgeText(badge) {
|
|
10
9
|
if (typeof badge !== "number" || !(badge > 0)) return null;
|
|
11
10
|
return badge > 99 ? "99+" : String(Math.floor(badge));
|
|
@@ -38,8 +37,12 @@ function adminMenuEntry({
|
|
|
38
37
|
current
|
|
39
38
|
};
|
|
40
39
|
}
|
|
40
|
+
function useAdminMenuEntry({ labels: labelsProp, ...options } = {}) {
|
|
41
|
+
const labels = useAdminAreaLabels(labelsProp);
|
|
42
|
+
return adminMenuEntry({ ...options, labels });
|
|
43
|
+
}
|
|
41
44
|
function AdminMenuRow({ href = "/admin", badge, onNavigate, current, labels: labelsProp }) {
|
|
42
|
-
const labels =
|
|
45
|
+
const labels = useAdminAreaLabels(labelsProp);
|
|
43
46
|
return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(
|
|
44
47
|
MenuItem,
|
|
45
48
|
{
|
|
@@ -55,6 +58,7 @@ function AdminMenuRow({ href = "/admin", badge, onNavigate, current, labels: lab
|
|
|
55
58
|
}
|
|
56
59
|
export {
|
|
57
60
|
AdminMenuRow,
|
|
58
|
-
adminMenuEntry
|
|
61
|
+
adminMenuEntry,
|
|
62
|
+
useAdminMenuEntry
|
|
59
63
|
};
|
|
60
64
|
//# sourceMappingURL=admin-menu.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/admin-menu.tsx"],"sourcesContent":["import { Link } from \"react-router\";\nimport { ShieldCheck } from \"lucide-react\";\nimport { Chip } from \"../components/chip\";\nimport { MenuItem } from \"../components/menu-item\";\nimport {
|
|
1
|
+
{"version":3,"sources":["../../src/shell/admin-menu.tsx"],"sourcesContent":["import { Link } from \"react-router\";\nimport { ShieldCheck } from \"lucide-react\";\nimport { Chip } from \"../components/chip\";\nimport { MenuItem } from \"../components/menu-item\";\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { DEFAULT_ADMIN_AREA_LABELS, useAdminAreaLabels } from \"../admin/admin-area-labels\";\nimport type { AdminAreaLabels } from \"../admin/admin-area-labels\";\nimport type { TopBarMenuEntry } from \"./topbar-action-menu\";\n\n/**\n * The account menu's \"Administration\" row (0.42.0, docs/admin-harmonization.md §2.1, §6.6,\n * §7): the way into the admin area everyone finds, in every app, for an admin only. Two\n * forms of one row — {@link adminMenuEntry} for `TopBarActionMenu`'s `entries`, and\n * {@link AdminMenuRow} for a menu built from `HoverMenu` children (Kurvenschmiede's).\n */\n\n/** The badge's text: nothing for none or 0, \"99+\" past 99. */\nfunction badgeText(badge: number | null | undefined): string | null {\n if (typeof badge !== \"number\" || !(badge > 0)) return null;\n return badge > 99 ? \"99+\" : String(Math.floor(badge));\n}\n\n/**\n * The waiting count at the row's end: the `solid` count pill of {@link Chip}, in the\n * pending count's tone, with the number for the eye and `adminArea.waiting` for a reader\n * (\"Administration, 3 waiting\"). `null` for no count.\n */\nfunction WaitingBadge({ badge, labels }: { badge: number | null | undefined; labels: AdminAreaLabels }) {\n const text = badgeText(badge);\n if (text === null) return null;\n return (\n <Chip size=\"sm\" variant=\"solid\" tone=\"warning\" data-slot=\"admin-badge\">\n <span aria-hidden>{text}</span>\n {/* The comma parts it from the row's label in the name: \"Administration, 3 waiting\". */}\n <span className=\"sr-only-fixed\">, {labels.waiting(badge as number)}</span>\n </Chip>\n );\n}\n\nexport interface AdminMenuEntryOptions {\n /** The admin area. Default `/admin`. */\n href?: string;\n /**\n * The waiting count (§2.4 decision 15, §6.3): only work that waits on this admin —\n * `adminWaitingCount(attention, […])`. `0`, `null` or none shows no badge.\n */\n badge?: number | null;\n /**\n * The resolved `adminArea` words — pass `useAdminAreaLabels()`, or the row is ENGLISH\n * in every language: a plain function reads no provider (kastlan, 0.42 adoption). Builds\n * no hook, so the entry can be made in the list of entries. {@link useAdminMenuEntry}\n * (0.43) is the same entry with the provider's words read for you.\n */\n labels?: AdminAreaLabels;\n /** The entry's `key`. Default `\"administration\"`. */\n key?: string;\n /** The page already open: `aria-current` and a check mark. */\n current?: boolean;\n}\n\n/**\n * The \"Administration\" {@link TopBarMenuEntry}: a link row with the `ShieldCheck` icon,\n * the kit's word and the waiting count as its badge. Put it in the entries for an admin\n * only — the menu closes on the way, as any link row:\n *\n * const labels = useAdminAreaLabels();\n * entries={[…, ...(isAdmin ? [adminMenuEntry({ badge: waiting, labels })] : []), …]}\n *\n * A function, not a hook: it reads NO provider, so pass `labels` (`useAdminAreaLabels()`)\n * or the row says \"Administration\" in every language — or call {@link useAdminMenuEntry}\n * at the component's top.\n */\nexport function adminMenuEntry({\n href = \"/admin\",\n badge,\n labels = DEFAULT_ADMIN_AREA_LABELS,\n key = \"administration\",\n current,\n}: AdminMenuEntryOptions = {}): TopBarMenuEntry {\n return {\n kind: \"link\",\n key,\n to: href,\n icon: <ShieldCheck className=\"size-4\" aria-hidden />,\n label: labels.title,\n trailing: badgeText(badge) === null ? undefined : <WaitingBadge badge={badge} labels={labels} />,\n current,\n };\n}\n\nexport interface UseAdminMenuEntryOptions extends Omit<AdminMenuEntryOptions, \"labels\"> {\n /** Over the provider's `adminArea` words: prop > `<UiKitProvider labels={{ adminArea }}>`\n * > English. */\n labels?: LabelOverride<AdminAreaLabels>;\n}\n\n/**\n * {@link adminMenuEntry} with the provider's words (0.43, kastlan): the hook reads\n * `useAdminAreaLabels()` itself, so the row is in the app's language without a `labels`\n * to remember. Call it at the component's top, then put it in the entries:\n *\n * const admin = useAdminMenuEntry({ badge: waiting });\n * entries={[…, ...(isAdmin ? [admin] : []), …]}\n */\nexport function useAdminMenuEntry({ labels: labelsProp, ...options }: UseAdminMenuEntryOptions = {}): TopBarMenuEntry {\n const labels = useAdminAreaLabels(labelsProp);\n return adminMenuEntry({ ...options, labels });\n}\n\nexport interface AdminMenuRowProps {\n /** The admin area. Default `/admin`. */\n href?: string;\n /** As {@link AdminMenuEntryOptions.badge}. */\n badge?: number | null;\n /** Runs on the way — `HoverMenu`'s `close`. */\n onNavigate?: () => void;\n current?: boolean;\n /** Prop > `<UiKitProvider labels={{ adminArea }}>` > English. */\n labels?: LabelOverride<AdminAreaLabels>;\n}\n\n/**\n * The same row as {@link adminMenuEntry} for a menu drawn from `HoverMenu` children: the\n * `<li>` holding the router-link {@link MenuItem} `TopBarActionMenu` draws for a link\n * entry, so the two menus look alike.\n *\n * {(close) => <ul>… {isAdmin && <AdminMenuRow badge={waiting} onNavigate={close} />} …</ul>}\n */\nexport function AdminMenuRow({ href = \"/admin\", badge, onNavigate, current, labels: labelsProp }: AdminMenuRowProps) {\n // The provider's words, as every kit part (0.43: through the namespace's own hook).\n const labels = useAdminAreaLabels(labelsProp);\n return (\n <li>\n <MenuItem\n href={href}\n renderLink={({ href: to, ...p }) => <Link to={to} {...p} />}\n onClick={onNavigate ? () => onNavigate() : undefined}\n leading={<ShieldCheck className=\"size-4\" aria-hidden />}\n trailing={badgeText(badge) === null ? undefined : <WaitingBadge badge={badge} labels={labels} />}\n current={current}\n >\n {labels.title}\n </MenuItem>\n </li>\n );\n}\n"],"mappings":";AAgCM,cAEA,YAFA;AAhCN,SAAS,YAAY;AACrB,SAAS,mBAAmB;AAC5B,SAAS,YAAY;AACrB,SAAS,gBAAgB;AAEzB,SAAS,2BAA2B,0BAA0B;AAY9D,SAAS,UAAU,OAAiD;AAClE,MAAI,OAAO,UAAU,YAAY,EAAE,QAAQ,GAAI,QAAO;AACtD,SAAO,QAAQ,KAAK,QAAQ,OAAO,KAAK,MAAM,KAAK,CAAC;AACtD;AAOA,SAAS,aAAa,EAAE,OAAO,OAAO,GAAkE;AACtG,QAAM,OAAO,UAAU,KAAK;AAC5B,MAAI,SAAS,KAAM,QAAO;AAC1B,SACE,qBAAC,QAAK,MAAK,MAAK,SAAQ,SAAQ,MAAK,WAAU,aAAU,eACvD;AAAA,wBAAC,UAAK,eAAW,MAAE,gBAAK;AAAA,IAExB,qBAAC,UAAK,WAAU,iBAAgB;AAAA;AAAA,MAAG,OAAO,QAAQ,KAAe;AAAA,OAAE;AAAA,KACrE;AAEJ;AAmCO,SAAS,eAAe;AAAA,EAC7B,OAAO;AAAA,EACP;AAAA,EACA,SAAS;AAAA,EACT,MAAM;AAAA,EACN;AACF,IAA2B,CAAC,GAAoB;AAC9C,SAAO;AAAA,IACL,MAAM;AAAA,IACN;AAAA,IACA,IAAI;AAAA,IACJ,MAAM,oBAAC,eAAY,WAAU,UAAS,eAAW,MAAC;AAAA,IAClD,OAAO,OAAO;AAAA,IACd,UAAU,UAAU,KAAK,MAAM,OAAO,SAAY,oBAAC,gBAAa,OAAc,QAAgB;AAAA,IAC9F;AAAA,EACF;AACF;AAgBO,SAAS,kBAAkB,EAAE,QAAQ,YAAY,GAAG,QAAQ,IAA8B,CAAC,GAAoB;AACpH,QAAM,SAAS,mBAAmB,UAAU;AAC5C,SAAO,eAAe,EAAE,GAAG,SAAS,OAAO,CAAC;AAC9C;AAqBO,SAAS,aAAa,EAAE,OAAO,UAAU,OAAO,YAAY,SAAS,QAAQ,WAAW,GAAsB;AAEnH,QAAM,SAAS,mBAAmB,UAAU;AAC5C,SACE,oBAAC,QACC;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,YAAY,CAAC,EAAE,MAAM,IAAI,GAAG,EAAE,MAAM,oBAAC,QAAK,IAAS,GAAG,GAAG;AAAA,MACzD,SAAS,aAAa,MAAM,WAAW,IAAI;AAAA,MAC3C,SAAS,oBAAC,eAAY,WAAU,UAAS,eAAW,MAAC;AAAA,MACrD,UAAU,UAAU,KAAK,MAAM,OAAO,SAAY,oBAAC,gBAAa,OAAc,QAAgB;AAAA,MAC9F;AAAA,MAEC,iBAAO;AAAA;AAAA,EACV,GACF;AAEJ;","names":[]}
|
|
@@ -40,6 +40,11 @@ function DevSwitchers({
|
|
|
40
40
|
const bar = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS);
|
|
41
41
|
const words = useTopBarPartsLabels(labelsProp ? { dev: labelsProp } : void 0).dev;
|
|
42
42
|
const [kept, setKept] = useState(() => enabled ? readKey(storageKey) : null);
|
|
43
|
+
const [wasEnabled, setWasEnabled] = useState(enabled);
|
|
44
|
+
if (wasEnabled !== enabled) {
|
|
45
|
+
setWasEnabled(enabled);
|
|
46
|
+
if (enabled) setKept(readKey(storageKey));
|
|
47
|
+
}
|
|
43
48
|
if (!enabled) return null;
|
|
44
49
|
const original = originalRole ?? kept ?? void 0;
|
|
45
50
|
const pickRole = (next) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/dev-switchers.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { FlaskConical, Undo2 } from \"lucide-react\";\nimport { DEFAULT_TOP_BAR_LABELS, useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { TopBarActionMenu, type TopBarMenuEntry } from \"./topbar-action-menu\";\nimport { useTopBarPartsLabels, type DevSwitchersLabels } from \"./top-bar-parts-labels\";\n\n/** Where the role the session started with is kept, unless the app names its own key. */\nexport const DEV_ORIGINAL_ROLE_KEY = \"ui-kit:dev-original-role\";\n\nfunction readKey(key: string): string | null {\n try {\n return sessionStorage.getItem(key);\n } catch {\n return null;\n }\n}\n\nfunction writeKey(key: string, value: string | null): void {\n try {\n if (value === null) sessionStorage.removeItem(key);\n else sessionStorage.setItem(key, value);\n } catch {\n /* storage blocked: \"Back to\" lasts as long as this page */\n }\n}\n\nexport interface DevSwitchersProps<R extends string, P extends string> {\n /** Draw anything at all: the app passes `import.meta.env.DEV`, so a production build\n * tree-shakes the switchers with the constant. */\n enabled: boolean;\n roles?: readonly R[];\n /** The role the server enforces now. Named `currentRole`, not `role`, because `role` on\n * a JSX element is the ARIA role (as `RoleChip`'s `value`). */\n currentRole?: R;\n /** Apply a role — the app calls server-kit's `POST /dev/switch {role}` and re-reads\n * `/auth/me` (§2.7, §6.2). */\n onRole?: (role: R) => void;\n plans?: readonly P[];\n /** The plan the server enforces now. Left out (not known yet — kastlan's overview is a\n * 404 while billing is off), the plan menu still renders, with no row checked; a\n * `GET /dev/switch` answers it. */\n currentPlan?: P;\n /** Apply a plan — `POST /dev/switch {plan}`, then the overview (§6.3). */\n onPlan?: (plan: P) => void;\n /**\n * The role to offer \"Back to {role}\" for. Left out, the kit keeps it itself: the role\n * before the first switch of this browser session, in `sessionStorage` under\n * `storageKey` — the server answers with the NEW role after a switch, so only the\n * browser still knows the old one.\n */\n originalRole?: R;\n /** The `sessionStorage` key for the kept role. Default {@link DEV_ORIGINAL_ROLE_KEY};\n * an app with several accounts on one browser may scope it per user. */\n storageKey?: string;\n /** Billing is off locally: the plan menu says plans change nothing, and stays usable\n * (§6.3). */\n billingOff?: boolean;\n /** A role's or a plan's words in the menu. Default: the value itself. */\n roleLabel?: (role: R) => ReactNode;\n planLabel?: (plan: P) => ReactNode;\n /** Prop > `<UiKitProvider labels={{ topBarParts: { dev } }}>` > English. */\n labels?: LabelOverride<DevSwitchersLabels>;\n}\n\nconst flask = <FlaskConical className=\"size-5 text-warning\" />;\n\n/** A label's text for a sentence, or the raw value when the label is not text. */\nconst textOf = (label: ReactNode, value: string) =>\n typeof label === \"string\" || typeof label === \"number\" ? String(label) : value;\n\n/**\n * The top bar's local-dev switchers (docs/top-bar-harmonization.md §2.7, §6.2, §6.3): the\n * role, then the plan, each a menu behind `RoleSwitcher`'s amber flask. Nothing renders\n * unless `enabled`; either menu renders only with its list and handler.\n *\n * The role menu ends in \"Back to {role}\" once the role differs from the one the session\n * started with: the dev route needs only a signed-in user, so a switch to a lesser role\n * can always switch back. With `billingOff` the plan menu notes that plans change nothing\n * while billing is off.\n */\nexport function DevSwitchers<R extends string, P extends string>({\n enabled,\n roles,\n currentRole: role,\n onRole,\n plans,\n currentPlan: plan,\n onPlan,\n originalRole,\n storageKey = DEV_ORIGINAL_ROLE_KEY,\n billingOff = false,\n roleLabel = (r) => r,\n planLabel = (p) => p,\n labels: labelsProp,\n}: DevSwitchersProps<R, P>) {\n const bar = useKitLabels(\"topBar\", DEFAULT_TOP_BAR_LABELS);\n const words = useTopBarPartsLabels(labelsProp ? { dev: labelsProp } : undefined).dev;\n const [kept, setKept] = useState<string | null>(() => (enabled ? readKey(storageKey) : null));\n if (!enabled) return null;\n\n const original = (originalRole ?? kept ?? undefined) as R | undefined;\n\n const pickRole = (next: R) => {\n if (originalRole === undefined) {\n if (next === original) {\n // Back where the session began: nothing left to go back to.\n writeKey(storageKey, null);\n setKept(null);\n } else if (kept === null && role !== undefined && next !== role) {\n writeKey(storageKey, role);\n setKept(role);\n }\n }\n onRole?.(next);\n };\n\n const roleMenu =\n roles && roles.length > 0 && role !== undefined && onRole ? (\n <TopBarActionMenu\n icon={flask}\n aria-label={`${bar.switchRole} · ${bar.role(role)}`}\n entries={[\n { kind: \"heading\", key: \"head-role\", label: words.roleHeading },\n ...roles.map(\n (r): TopBarMenuEntry => ({ key: `role-${r}`, label: roleLabel(r), checked: r === role, onSelect: () => pickRole(r) }),\n ),\n ...(original !== undefined && original !== role\n ? ([\n { kind: \"divider\", key: \"sep-back\" },\n {\n key: \"back\",\n icon: <Undo2 className=\"size-4\" />,\n label: words.backTo(textOf(roleLabel(original), original)),\n onSelect: () => pickRole(original),\n },\n ] satisfies TopBarMenuEntry[])\n : []),\n ]}\n />\n ) : null;\n\n const planMenu =\n plans && plans.length > 0 && onPlan ? (\n <TopBarActionMenu\n icon={flask}\n aria-label={plan !== undefined ? `${words.switchPlan} · ${words.plan(plan)}` : words.switchPlan}\n entries={[\n { kind: \"heading\", key: \"head-plan\", label: words.planHeading },\n ...plans.map(\n (p): TopBarMenuEntry => ({ key: `plan-${p}`, label: planLabel(p), checked: p === plan, onSelect: () => onPlan(p) }),\n ),\n ]}\n >\n {billingOff\n ? () => (\n <li data-dev-billing-off=\"\" className=\"mt-1 max-w-64 border-t border-subtle px-3 py-2 text-xs text-muted\">\n {words.billingOff}\n </li>\n )\n : undefined}\n </TopBarActionMenu>\n ) : null;\n\n return (\n <>\n {roleMenu}\n {planMenu}\n </>\n );\n}\n"],"mappings":";AAiEc,
|
|
1
|
+
{"version":3,"sources":["../../src/shell/dev-switchers.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { FlaskConical, Undo2 } from \"lucide-react\";\nimport { DEFAULT_TOP_BAR_LABELS, useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { TopBarActionMenu, type TopBarMenuEntry } from \"./topbar-action-menu\";\nimport { useTopBarPartsLabels, type DevSwitchersLabels } from \"./top-bar-parts-labels\";\n\n/** Where the role the session started with is kept, unless the app names its own key. */\nexport const DEV_ORIGINAL_ROLE_KEY = \"ui-kit:dev-original-role\";\n\nfunction readKey(key: string): string | null {\n try {\n return sessionStorage.getItem(key);\n } catch {\n return null;\n }\n}\n\nfunction writeKey(key: string, value: string | null): void {\n try {\n if (value === null) sessionStorage.removeItem(key);\n else sessionStorage.setItem(key, value);\n } catch {\n /* storage blocked: \"Back to\" lasts as long as this page */\n }\n}\n\nexport interface DevSwitchersProps<R extends string, P extends string> {\n /** Draw anything at all: the app passes `import.meta.env.DEV`, so a production build\n * tree-shakes the switchers with the constant. */\n enabled: boolean;\n roles?: readonly R[];\n /** The role the server enforces now. Named `currentRole`, not `role`, because `role` on\n * a JSX element is the ARIA role (as `RoleChip`'s `value`). */\n currentRole?: R;\n /** Apply a role — the app calls server-kit's `POST /dev/switch {role}` and re-reads\n * `/auth/me` (§2.7, §6.2). */\n onRole?: (role: R) => void;\n plans?: readonly P[];\n /** The plan the server enforces now. Left out (not known yet — kastlan's overview is a\n * 404 while billing is off), the plan menu still renders, with no row checked; a\n * `GET /dev/switch` answers it. */\n currentPlan?: P;\n /** Apply a plan — `POST /dev/switch {plan}`, then the overview (§6.3). */\n onPlan?: (plan: P) => void;\n /**\n * The role to offer \"Back to {role}\" for. Left out, the kit keeps it itself: the role\n * before the first switch of this browser session, in `sessionStorage` under\n * `storageKey` — the server answers with the NEW role after a switch, so only the\n * browser still knows the old one.\n */\n originalRole?: R;\n /** The `sessionStorage` key for the kept role. Default {@link DEV_ORIGINAL_ROLE_KEY};\n * an app with several accounts on one browser may scope it per user. */\n storageKey?: string;\n /** Billing is off locally: the plan menu says plans change nothing, and stays usable\n * (§6.3). */\n billingOff?: boolean;\n /** A role's or a plan's words in the menu. Default: the value itself. */\n roleLabel?: (role: R) => ReactNode;\n planLabel?: (plan: P) => ReactNode;\n /** Prop > `<UiKitProvider labels={{ topBarParts: { dev } }}>` > English. */\n labels?: LabelOverride<DevSwitchersLabels>;\n}\n\nconst flask = <FlaskConical className=\"size-5 text-warning\" />;\n\n/** A label's text for a sentence, or the raw value when the label is not text. */\nconst textOf = (label: ReactNode, value: string) =>\n typeof label === \"string\" || typeof label === \"number\" ? String(label) : value;\n\n/**\n * The top bar's local-dev switchers (docs/top-bar-harmonization.md §2.7, §6.2, §6.3): the\n * role, then the plan, each a menu behind `RoleSwitcher`'s amber flask. Nothing renders\n * unless `enabled`; either menu renders only with its list and handler.\n *\n * The role menu ends in \"Back to {role}\" once the role differs from the one the session\n * started with: the dev route needs only a signed-in user, so a switch to a lesser role\n * can always switch back. With `billingOff` the plan menu notes that plans change nothing\n * while billing is off.\n */\nexport function DevSwitchers<R extends string, P extends string>({\n enabled,\n roles,\n currentRole: role,\n onRole,\n plans,\n currentPlan: plan,\n onPlan,\n originalRole,\n storageKey = DEV_ORIGINAL_ROLE_KEY,\n billingOff = false,\n roleLabel = (r) => r,\n planLabel = (p) => p,\n labels: labelsProp,\n}: DevSwitchersProps<R, P>) {\n const bar = useKitLabels(\"topBar\", DEFAULT_TOP_BAR_LABELS);\n const words = useTopBarPartsLabels(labelsProp ? { dev: labelsProp } : undefined).dev;\n const [kept, setKept] = useState<string | null>(() => (enabled ? readKey(storageKey) : null));\n // 0.43 (keksdose): `enabled` may start false and turn true — an app's GET probe of the\n // dev route — so the kept role is read again then, or \"Back to {role}\" is lost after a\n // reload. Adjusted during render, React's pattern for state that follows a prop.\n const [wasEnabled, setWasEnabled] = useState(enabled);\n if (wasEnabled !== enabled) {\n setWasEnabled(enabled);\n if (enabled) setKept(readKey(storageKey));\n }\n if (!enabled) return null;\n\n const original = (originalRole ?? kept ?? undefined) as R | undefined;\n\n const pickRole = (next: R) => {\n if (originalRole === undefined) {\n if (next === original) {\n // Back where the session began: nothing left to go back to.\n writeKey(storageKey, null);\n setKept(null);\n } else if (kept === null && role !== undefined && next !== role) {\n writeKey(storageKey, role);\n setKept(role);\n }\n }\n onRole?.(next);\n };\n\n const roleMenu =\n roles && roles.length > 0 && role !== undefined && onRole ? (\n <TopBarActionMenu\n icon={flask}\n aria-label={`${bar.switchRole} · ${bar.role(role)}`}\n entries={[\n { kind: \"heading\", key: \"head-role\", label: words.roleHeading },\n ...roles.map(\n (r): TopBarMenuEntry => ({ key: `role-${r}`, label: roleLabel(r), checked: r === role, onSelect: () => pickRole(r) }),\n ),\n ...(original !== undefined && original !== role\n ? ([\n { kind: \"divider\", key: \"sep-back\" },\n {\n key: \"back\",\n icon: <Undo2 className=\"size-4\" />,\n label: words.backTo(textOf(roleLabel(original), original)),\n onSelect: () => pickRole(original),\n },\n ] satisfies TopBarMenuEntry[])\n : []),\n ]}\n />\n ) : null;\n\n const planMenu =\n plans && plans.length > 0 && onPlan ? (\n <TopBarActionMenu\n icon={flask}\n aria-label={plan !== undefined ? `${words.switchPlan} · ${words.plan(plan)}` : words.switchPlan}\n entries={[\n { kind: \"heading\", key: \"head-plan\", label: words.planHeading },\n ...plans.map(\n (p): TopBarMenuEntry => ({ key: `plan-${p}`, label: planLabel(p), checked: p === plan, onSelect: () => onPlan(p) }),\n ),\n ]}\n >\n {billingOff\n ? () => (\n <li data-dev-billing-off=\"\" className=\"mt-1 max-w-64 border-t border-subtle px-3 py-2 text-xs text-muted\">\n {words.billingOff}\n </li>\n )\n : undefined}\n </TopBarActionMenu>\n ) : null;\n\n return (\n <>\n {roleMenu}\n {planMenu}\n </>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -22,6 +22,12 @@ export interface TourMenuLabels {
|
|
|
22
22
|
badgeNew: string;
|
|
23
23
|
/** What the ✓ on a taken tour's row says to a reader. */
|
|
24
24
|
completed: string;
|
|
25
|
+
/** The chip on a tour reset by an update (0.43, docs/tours-harmonization.md §8). */
|
|
26
|
+
badgeUpdated: string;
|
|
27
|
+
/** The dot on the compass while only updated tours light it, for a reader. */
|
|
28
|
+
updatedDot: string;
|
|
29
|
+
/** The heading over the untaken new tours when updated ones are among them. */
|
|
30
|
+
newAndUpdated: string;
|
|
25
31
|
}
|
|
26
32
|
/** `SyncStatusIndicator`'s words (§2.6). The "waiting for a plan" state is
|
|
27
33
|
* `billing.waitingChanges`, the kit's words for it since billing §12.5. */
|
|
@@ -8,7 +8,10 @@ const DEFAULT_TOP_BAR_PARTS_LABELS = {
|
|
|
8
8
|
newTours: "New tours",
|
|
9
9
|
allTours: "All tours\u2026",
|
|
10
10
|
badgeNew: "New",
|
|
11
|
-
completed: "Completed"
|
|
11
|
+
completed: "Completed",
|
|
12
|
+
badgeUpdated: "Updated",
|
|
13
|
+
updatedDot: "Updated tours",
|
|
14
|
+
newAndUpdated: "New and updated"
|
|
12
15
|
},
|
|
13
16
|
sync: {
|
|
14
17
|
blocked: (count) => count === 1 ? "1 change cannot be sent from this device" : `${count} changes cannot be sent from this device`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/top-bar-parts-labels.ts"],"sourcesContent":["import { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\n\n/**\n * The `topBarParts` namespace (0.42, docs/top-bar-harmonization.md §3.1): the words of the\n * top bar's shared parts — `TourMenu`, `SyncStatusIndicator` and `DevSwitchers`. A module\n * of words with no component code, like every `*-labels.ts` (0.34, KS U8). The language\n * sub-list (`AccountMenuLanguage`) names itself with `topBar.language`, and the dev role\n * switcher's name and tooltip are `topBar.switchRole` / `topBar.role`, as `RoleSwitcher`'s.\n */\n\n/** `TourMenu`'s words (§2.4). */\nexport interface TourMenuLabels {\n /** The compass's name, and the menu's. */\n menu: string;\n /** The NEW dot on the compass, for a reader: it joins the trigger's name. */\n newDot: string;\n /** The heading over the tours that start on the open page. */\n thisPage: string;\n /** The heading over the untaken new tours. */\n newTours: string;\n /** The last row, the way to the app's tours page (only with `href`). */\n allTours: string;\n /** The chip on a new tour's row. */\n badgeNew: string;\n /** What the ✓ on a taken tour's row says to a reader. */\n completed: string;\n}\n\n/** `SyncStatusIndicator`'s words (§2.6). The \"waiting for a plan\" state is\n * `billing.waitingChanges`, the kit's words for it since billing §12.5. */\nexport interface SyncStatusLabels {\n /** Writes this device cannot send (keksdose: no key for the budget). Ranked first. */\n blocked: (count: number) => string;\n offline: string;\n /** The server refused the last round. */\n refused: string;\n syncing: string;\n pending: (count: number) => string;\n /** Settled, with the time of the last good round (already formatted). */\n syncedAt: (time: string) => string;\n /** Settled, before the first round. */\n idle: string;\n /** Joined to the state when a tap syncs: what the tap does. */\n syncHint: string;\n /** Joined to the state while offline, when a tap can do nothing. */\n offlineHint: string;\n /** The state and what a tap does, as one tooltip: \"Offline — nothing to sync…\". */\n withHint: (state: string, hint: string) => string;\n /** The state with the app's `scope` words before it: \"Handovers on this device: …\". */\n scoped: (scope: string, state: string) => string;\n /** The short words beside the cloud (`showLabel`): offline. */\n offlineShort: string;\n /** The short words while syncing. */\n syncingShort: string;\n /** The short words while changes wait (pending, held for a plan, or blocked). */\n waitingShort: (count: number) => string;\n /** The short words after a refused round. */\n refusedShort: string;\n /** Offline in an online-only app (`storage=\"none\"`): nothing is kept on the device. */\n offlineUnsaved: string;\n /** A round under way in an online-only app — it saves, it doesn't sync. Also its short\n * words beside the cloud. */\n saving: string;\n}\n\n/** `DevSwitchers`' words (§2.7, §6.2, §6.3). */\nexport interface DevSwitchersLabels {\n /** The heading of the role menu. */\n roleHeading: string;\n /** The plan switcher's name. */\n switchPlan: string;\n /** The plan switcher's tooltip, given the active plan. */\n plan: (value: string) => string;\n /** The heading of the plan menu. */\n planHeading: string;\n /** The row back to the role the session started with. */\n backTo: (role: string) => string;\n /** The plan menu's note while billing is off locally. */\n billingOff: string;\n}\n\nexport interface TopBarPartsLabels {\n tours: TourMenuLabels;\n sync: SyncStatusLabels;\n dev: DevSwitchersLabels;\n}\n\nexport const DEFAULT_TOP_BAR_PARTS_LABELS: TopBarPartsLabels = {\n tours: {\n menu: \"Guided tours\",\n newDot: \"New tours\",\n thisPage: \"On this page\",\n newTours: \"New tours\",\n allTours: \"All tours…\",\n badgeNew: \"New\",\n completed: \"Completed\",\n },\n sync: {\n blocked: (count) =>\n count === 1\n ? \"1 change cannot be sent from this device\"\n : `${count} changes cannot be sent from this device`,\n offline: \"Offline — changes are saved on this device and will sync when you reconnect\",\n refused: \"The server refused the last sync\",\n syncing: \"Syncing…\",\n pending: (count) => (count === 1 ? \"1 change waiting to sync\" : `${count} changes waiting to sync`),\n syncedAt: (time) => `Synced at ${time}`,\n idle: \"Up to date\",\n syncHint: \"Tap to sync now\",\n offlineHint: \"nothing to sync while offline\",\n withHint: (state, hint) => `${state} — ${hint}`,\n scoped: (scope, state) => `${scope}: ${state}`,\n offlineShort: \"Offline\",\n syncingShort: \"Syncing…\",\n waitingShort: (count) => `${count} waiting`,\n refusedShort: \"Not synced\",\n offlineUnsaved: \"Offline — changes can't be saved until you reconnect\",\n saving: \"Saving…\",\n },\n dev: {\n roleHeading: \"Dev · role\",\n switchPlan: \"Switch plan\",\n plan: (value) => `Plan: ${value}`,\n planHeading: \"Dev · plan\",\n backTo: (role) => `Back to ${role}`,\n billingOff: \"Billing is off locally: plans change nothing\",\n },\n};\n\n/** The `topBarParts` namespace, resolved — English, then the provider, then `labels`. */\nexport function useTopBarPartsLabels(labels?: LabelOverride<TopBarPartsLabels>): TopBarPartsLabels {\n return useKitLabels(\"topBarParts\", DEFAULT_TOP_BAR_PARTS_LABELS, labels);\n}\n"],"mappings":";AAAA,SAAS,oBAAwC;
|
|
1
|
+
{"version":3,"sources":["../../src/shell/top-bar-parts-labels.ts"],"sourcesContent":["import { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\n\n/**\n * The `topBarParts` namespace (0.42, docs/top-bar-harmonization.md §3.1): the words of the\n * top bar's shared parts — `TourMenu`, `SyncStatusIndicator` and `DevSwitchers`. A module\n * of words with no component code, like every `*-labels.ts` (0.34, KS U8). The language\n * sub-list (`AccountMenuLanguage`) names itself with `topBar.language`, and the dev role\n * switcher's name and tooltip are `topBar.switchRole` / `topBar.role`, as `RoleSwitcher`'s.\n */\n\n/** `TourMenu`'s words (§2.4). */\nexport interface TourMenuLabels {\n /** The compass's name, and the menu's. */\n menu: string;\n /** The NEW dot on the compass, for a reader: it joins the trigger's name. */\n newDot: string;\n /** The heading over the tours that start on the open page. */\n thisPage: string;\n /** The heading over the untaken new tours. */\n newTours: string;\n /** The last row, the way to the app's tours page (only with `href`). */\n allTours: string;\n /** The chip on a new tour's row. */\n badgeNew: string;\n /** What the ✓ on a taken tour's row says to a reader. */\n completed: string;\n /** The chip on a tour reset by an update (0.43, docs/tours-harmonization.md §8). */\n badgeUpdated: string;\n /** The dot on the compass while only updated tours light it, for a reader. */\n updatedDot: string;\n /** The heading over the untaken new tours when updated ones are among them. */\n newAndUpdated: string;\n}\n\n/** `SyncStatusIndicator`'s words (§2.6). The \"waiting for a plan\" state is\n * `billing.waitingChanges`, the kit's words for it since billing §12.5. */\nexport interface SyncStatusLabels {\n /** Writes this device cannot send (keksdose: no key for the budget). Ranked first. */\n blocked: (count: number) => string;\n offline: string;\n /** The server refused the last round. */\n refused: string;\n syncing: string;\n pending: (count: number) => string;\n /** Settled, with the time of the last good round (already formatted). */\n syncedAt: (time: string) => string;\n /** Settled, before the first round. */\n idle: string;\n /** Joined to the state when a tap syncs: what the tap does. */\n syncHint: string;\n /** Joined to the state while offline, when a tap can do nothing. */\n offlineHint: string;\n /** The state and what a tap does, as one tooltip: \"Offline — nothing to sync…\". */\n withHint: (state: string, hint: string) => string;\n /** The state with the app's `scope` words before it: \"Handovers on this device: …\". */\n scoped: (scope: string, state: string) => string;\n /** The short words beside the cloud (`showLabel`): offline. */\n offlineShort: string;\n /** The short words while syncing. */\n syncingShort: string;\n /** The short words while changes wait (pending, held for a plan, or blocked). */\n waitingShort: (count: number) => string;\n /** The short words after a refused round. */\n refusedShort: string;\n /** Offline in an online-only app (`storage=\"none\"`): nothing is kept on the device. */\n offlineUnsaved: string;\n /** A round under way in an online-only app — it saves, it doesn't sync. Also its short\n * words beside the cloud. */\n saving: string;\n}\n\n/** `DevSwitchers`' words (§2.7, §6.2, §6.3). */\nexport interface DevSwitchersLabels {\n /** The heading of the role menu. */\n roleHeading: string;\n /** The plan switcher's name. */\n switchPlan: string;\n /** The plan switcher's tooltip, given the active plan. */\n plan: (value: string) => string;\n /** The heading of the plan menu. */\n planHeading: string;\n /** The row back to the role the session started with. */\n backTo: (role: string) => string;\n /** The plan menu's note while billing is off locally. */\n billingOff: string;\n}\n\nexport interface TopBarPartsLabels {\n tours: TourMenuLabels;\n sync: SyncStatusLabels;\n dev: DevSwitchersLabels;\n}\n\nexport const DEFAULT_TOP_BAR_PARTS_LABELS: TopBarPartsLabels = {\n tours: {\n menu: \"Guided tours\",\n newDot: \"New tours\",\n thisPage: \"On this page\",\n newTours: \"New tours\",\n allTours: \"All tours…\",\n badgeNew: \"New\",\n completed: \"Completed\",\n badgeUpdated: \"Updated\",\n updatedDot: \"Updated tours\",\n newAndUpdated: \"New and updated\",\n },\n sync: {\n blocked: (count) =>\n count === 1\n ? \"1 change cannot be sent from this device\"\n : `${count} changes cannot be sent from this device`,\n offline: \"Offline — changes are saved on this device and will sync when you reconnect\",\n refused: \"The server refused the last sync\",\n syncing: \"Syncing…\",\n pending: (count) => (count === 1 ? \"1 change waiting to sync\" : `${count} changes waiting to sync`),\n syncedAt: (time) => `Synced at ${time}`,\n idle: \"Up to date\",\n syncHint: \"Tap to sync now\",\n offlineHint: \"nothing to sync while offline\",\n withHint: (state, hint) => `${state} — ${hint}`,\n scoped: (scope, state) => `${scope}: ${state}`,\n offlineShort: \"Offline\",\n syncingShort: \"Syncing…\",\n waitingShort: (count) => `${count} waiting`,\n refusedShort: \"Not synced\",\n offlineUnsaved: \"Offline — changes can't be saved until you reconnect\",\n saving: \"Saving…\",\n },\n dev: {\n roleHeading: \"Dev · role\",\n switchPlan: \"Switch plan\",\n plan: (value) => `Plan: ${value}`,\n planHeading: \"Dev · plan\",\n backTo: (role) => `Back to ${role}`,\n billingOff: \"Billing is off locally: plans change nothing\",\n },\n};\n\n/** The `topBarParts` namespace, resolved — English, then the provider, then `labels`. */\nexport function useTopBarPartsLabels(labels?: LabelOverride<TopBarPartsLabels>): TopBarPartsLabels {\n return useKitLabels(\"topBarParts\", DEFAULT_TOP_BAR_PARTS_LABELS, labels);\n}\n"],"mappings":";AAAA,SAAS,oBAAwC;AA6F1C,MAAM,+BAAkD;AAAA,EAC7D,OAAO;AAAA,IACL,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,UAAU;AAAA,IACV,UAAU;AAAA,IACV,UAAU;AAAA,IACV,WAAW;AAAA,IACX,cAAc;AAAA,IACd,YAAY;AAAA,IACZ,eAAe;AAAA,EACjB;AAAA,EACA,MAAM;AAAA,IACJ,SAAS,CAAC,UACR,UAAU,IACN,6CACA,GAAG,KAAK;AAAA,IACd,SAAS;AAAA,IACT,SAAS;AAAA,IACT,SAAS;AAAA,IACT,SAAS,CAAC,UAAW,UAAU,IAAI,6BAA6B,GAAG,KAAK;AAAA,IACxE,UAAU,CAAC,SAAS,aAAa,IAAI;AAAA,IACrC,MAAM;AAAA,IACN,UAAU;AAAA,IACV,aAAa;AAAA,IACb,UAAU,CAAC,OAAO,SAAS,GAAG,KAAK,WAAM,IAAI;AAAA,IAC7C,QAAQ,CAAC,OAAO,UAAU,GAAG,KAAK,KAAK,KAAK;AAAA,IAC5C,cAAc;AAAA,IACd,cAAc;AAAA,IACd,cAAc,CAAC,UAAU,GAAG,KAAK;AAAA,IACjC,cAAc;AAAA,IACd,gBAAgB;AAAA,IAChB,QAAQ;AAAA,EACV;AAAA,EACA,KAAK;AAAA,IACH,aAAa;AAAA,IACb,YAAY;AAAA,IACZ,MAAM,CAAC,UAAU,SAAS,KAAK;AAAA,IAC/B,aAAa;AAAA,IACb,QAAQ,CAAC,SAAS,WAAW,IAAI;AAAA,IACjC,YAAY;AAAA,EACd;AACF;AAGO,SAAS,qBAAqB,QAA8D;AACjG,SAAO,aAAa,eAAe,8BAA8B,MAAM;AACzE;","names":[]}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { type TourCompletions, type TourInfo } from "../tour/tour-info.js";
|
|
2
3
|
import type { LabelOverride } from "../i18n/kit-labels.js";
|
|
3
4
|
import { type TourMenuLabels } from "./top-bar-parts-labels.js";
|
|
4
5
|
/**
|
|
@@ -6,27 +7,35 @@ import { type TourMenuLabels } from "./top-bar-parts-labels.js";
|
|
|
6
7
|
* The kit's tour engine (`TourProvider`) runs steps and keeps none; which tours exist,
|
|
7
8
|
* which are new and which are taken is the app's (keksdose: `useTours`, `useIsNewTour`,
|
|
8
9
|
* its progress store).
|
|
10
|
+
*
|
|
11
|
+
* Since 0.43 it is a subset of the catalogue's `TourInfo` (docs/tours-harmonization.md
|
|
12
|
+
* §3), so an app keeps ONE list: hand the menu the catalogue's tours with `completed`
|
|
13
|
+
* and `joined`, and it works out ✓, NEW and "Updated" itself. Given the record, the kit's
|
|
14
|
+
* ✓ and "Updated" win over a row's `completed` and `reset`; given `joined`, its NEW wins
|
|
15
|
+
* over `isNew` — so a row built from an app's own booleans cannot hide an update (§8,
|
|
16
|
+
* build review kastlan A3, keksdose K-S6). The booleans are for a menu with neither.
|
|
9
17
|
*/
|
|
10
|
-
export
|
|
18
|
+
export type TourMenuTour = Partial<Pick<TourInfo, "route" | "since" | "updated">> & {
|
|
11
19
|
id: string;
|
|
12
20
|
title: ReactNode;
|
|
13
|
-
/** The
|
|
14
|
-
|
|
15
|
-
route?: string;
|
|
21
|
+
/** The row's icon, drawn at 16px. */
|
|
22
|
+
icon?: ReactNode;
|
|
16
23
|
/** Shipped after this account was opened and not yet taken: a NEW chip on the row,
|
|
17
24
|
* and the dot on the compass. */
|
|
18
25
|
isNew?: boolean;
|
|
19
26
|
/** Taken: a ✓ on the row. */
|
|
20
27
|
completed?: boolean;
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
since?: string;
|
|
25
|
-
}
|
|
28
|
+
/** Taken before its last update (§8): an "Updated" chip, and the dot, as for NEW. */
|
|
29
|
+
reset?: boolean;
|
|
30
|
+
};
|
|
26
31
|
export interface TourMenuProps {
|
|
27
32
|
tours: readonly TourMenuTour[];
|
|
28
33
|
/** Start a tour — `useTour().start(steps, { id })` with the app's steps. */
|
|
29
34
|
onLaunch: (id: string) => void;
|
|
35
|
+
/** The device's completions (`useTourProgress().completed`): ✓ and "Updated" from them. */
|
|
36
|
+
completed?: TourCompletions;
|
|
37
|
+
/** The account's `created_at`: NEW from it, by `isTourNew`. */
|
|
38
|
+
joined?: string | null;
|
|
30
39
|
/**
|
|
31
40
|
* The app's tours page (keksdose `/tours`). With it the compass is a LINK there plus a
|
|
32
41
|
* hover menu, and the menu ends in "All tours…". Without it (§6.7, Kurvenschmiede) the
|
|
@@ -55,4 +64,4 @@ export interface TourMenuProps {
|
|
|
55
64
|
* list — the tours that start on this page, up to three new ones (newest first), a rule,
|
|
56
65
|
* and "All tours…". A taken tour's row carries a ✓, a new one a NEW chip.
|
|
57
66
|
*/
|
|
58
|
-
export declare function TourMenu({ tours, onLaunch, href, pathname: pathnameProp, maxNew, list, labels: labelsProp, ...attributes }: TourMenuProps): import("react").JSX.Element | null;
|
|
67
|
+
export declare function TourMenu({ tours: toursProp, onLaunch, completed: completions, joined, href, pathname: pathnameProp, maxNew, list, labels: labelsProp, ...attributes }: TourMenuProps): import("react").JSX.Element | null;
|