@eifi1/ui-kit 0.30.0 → 0.31.1

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.
Files changed (269) hide show
  1. package/README.md +42 -5
  2. package/dist/account/data-export-setting.js +3 -2
  3. package/dist/account/data-export-setting.js.map +1 -1
  4. package/dist/account/delete-account-setting.d.ts +5 -1
  5. package/dist/account/delete-account-setting.js +3 -2
  6. package/dist/account/delete-account-setting.js.map +1 -1
  7. package/dist/account/email-change-setting.js +4 -3
  8. package/dist/account/email-change-setting.js.map +1 -1
  9. package/dist/account/sessions-setting.js +3 -2
  10. package/dist/account/sessions-setting.js.map +1 -1
  11. package/dist/admin/admin-action-confirm.d.ts +5 -1
  12. package/dist/admin/admin-parts.d.ts +8 -1
  13. package/dist/admin/admin-parts.js +1 -1
  14. package/dist/admin/admin-parts.js.map +1 -1
  15. package/dist/admin/invitations-panel.d.ts +5 -1
  16. package/dist/admin/invitations-panel.js +3 -2
  17. package/dist/admin/invitations-panel.js.map +1 -1
  18. package/dist/admin/reviewer-scope-editor.d.ts +5 -1
  19. package/dist/admin/role-select.d.ts +5 -1
  20. package/dist/admin/transfer-ownership-dialog.d.ts +5 -1
  21. package/dist/admin/user-roster.d.ts +5 -1
  22. package/dist/auth/auth-errors.d.ts +24 -6
  23. package/dist/auth/auth-errors.js +7 -1
  24. package/dist/auth/auth-errors.js.map +1 -1
  25. package/dist/auth/register-form.d.ts +5 -1
  26. package/dist/auth/reset-password-form.d.ts +1 -1
  27. package/dist/auth/reset-password-form.js +2 -1
  28. package/dist/auth/reset-password-form.js.map +1 -1
  29. package/dist/auth/sign-in-form.d.ts +15 -1
  30. package/dist/auth/sign-in-form.js +12 -3
  31. package/dist/auth/sign-in-form.js.map +1 -1
  32. package/dist/auth/verify-email.d.ts +4 -0
  33. package/dist/auth/verify-email.js +3 -2
  34. package/dist/auth/verify-email.js.map +1 -1
  35. package/dist/chart.d.ts +5 -1
  36. package/dist/components/account-settings-labels.d.ts +12 -0
  37. package/dist/components/account-settings-labels.js +2 -1
  38. package/dist/components/account-settings-labels.js.map +1 -1
  39. package/dist/components/account-settings.d.ts +8 -2
  40. package/dist/components/account-settings.js +19 -11
  41. package/dist/components/account-settings.js.map +1 -1
  42. package/dist/components/amount-input.d.ts +5 -1
  43. package/dist/components/button-group.d.ts +5 -1
  44. package/dist/components/calculator.d.ts +5 -1
  45. package/dist/components/confirm-dialog.d.ts +5 -1
  46. package/dist/components/confirm-dialog.js +7 -1
  47. package/dist/components/confirm-dialog.js.map +1 -1
  48. package/dist/components/copy-button.d.ts +5 -1
  49. package/dist/components/danger-confirm.d.ts +5 -1
  50. package/dist/components/data-table.js +8 -1
  51. package/dist/components/data-table.js.map +1 -1
  52. package/dist/components/date-picker.js +9 -4
  53. package/dist/components/date-picker.js.map +1 -1
  54. package/dist/components/facing-pair.d.ts +5 -1
  55. package/dist/components/file-button.d.ts +5 -1
  56. package/dist/components/file-dropzone.d.ts +5 -1
  57. package/dist/components/form-actions.d.ts +5 -1
  58. package/dist/components/iban-input.d.ts +5 -1
  59. package/dist/components/language-select.d.ts +5 -1
  60. package/dist/components/legal-page.d.ts +5 -1
  61. package/dist/components/legal.d.ts +5 -1
  62. package/dist/components/money-field.d.ts +5 -1
  63. package/dist/components/number-field.d.ts +5 -1
  64. package/dist/components/number-input.d.ts +5 -1
  65. package/dist/components/numpad-sheet.d.ts +5 -1
  66. package/dist/components/passkeys-setting.js +2 -1
  67. package/dist/components/passkeys-setting.js.map +1 -1
  68. package/dist/components/phone-input.d.ts +5 -1
  69. package/dist/components/series-chart.d.ts +5 -1
  70. package/dist/components/settings-fields.d.ts +40 -3
  71. package/dist/components/settings-fields.js +20 -1
  72. package/dist/components/settings-fields.js.map +1 -1
  73. package/dist/components/share-card.d.ts +5 -1
  74. package/dist/components/text-link.d.ts +5 -1
  75. package/dist/components/time-input.d.ts +5 -1
  76. package/dist/components/ui.d.ts +5 -1
  77. package/dist/{delete-account-setting-CGVMckMw.d.ts → delete-account-setting-CpIhzxxZ.d.ts} +60 -23
  78. package/dist/demo/demo-banner.d.ts +51 -0
  79. package/dist/demo/demo-banner.js +97 -0
  80. package/dist/demo/demo-banner.js.map +1 -0
  81. package/dist/demo/demo-countdown.d.ts +58 -0
  82. package/dist/demo/demo-countdown.js +79 -0
  83. package/dist/demo/demo-countdown.js.map +1 -0
  84. package/dist/demo/demo-ended.d.ts +45 -0
  85. package/dist/demo/demo-ended.js +42 -0
  86. package/dist/demo/demo-ended.js.map +1 -0
  87. package/dist/demo/demo-labels.d.ts +53 -0
  88. package/dist/demo/demo-labels.js +29 -0
  89. package/dist/demo/demo-labels.js.map +1 -0
  90. package/dist/demo/demo-session.d.ts +30 -0
  91. package/dist/demo/demo-session.js +8 -0
  92. package/dist/demo/demo-session.js.map +1 -0
  93. package/dist/demo/demo-start.d.ts +54 -0
  94. package/dist/demo/demo-start.js +68 -0
  95. package/dist/demo/demo-start.js.map +1 -0
  96. package/dist/feedback/feedback-attachment.d.ts +5 -1
  97. package/dist/feedback/feedback-context.d.ts +5 -1
  98. package/dist/feedback/feedback-crash.d.ts +5 -1
  99. package/dist/feedback/feedback-dialog.d.ts +5 -1
  100. package/dist/feedback/feedback-inbox.d.ts +5 -1
  101. package/dist/feedback/feedback-inbox.js +12 -2
  102. package/dist/feedback/feedback-inbox.js.map +1 -1
  103. package/dist/feedback/feedback-labels.d.ts +5 -1
  104. package/dist/feedback/feedback-labels.js +1 -0
  105. package/dist/feedback/feedback-labels.js.map +1 -1
  106. package/dist/feedback/feedback-menu.d.ts +5 -1
  107. package/dist/feedback/feedback-record.d.ts +5 -1
  108. package/dist/feedback/feedback-record.js +2 -0
  109. package/dist/feedback/feedback-record.js.map +1 -1
  110. package/dist/feedback/feedback-row-detail.d.ts +5 -1
  111. package/dist/feedback/feedback-row-detail.js +7 -0
  112. package/dist/feedback/feedback-row-detail.js.map +1 -1
  113. package/dist/feedback/feedback-status-undo.d.ts +5 -1
  114. package/dist/feedback/feedback-submit.d.ts +5 -1
  115. package/dist/feedback/feedback-swipe.d.ts +6 -2
  116. package/dist/feedback/feedback-swipe.js.map +1 -1
  117. package/dist/feedback/feedback-table.d.ts +5 -1
  118. package/dist/feedback/feedback-thread.d.ts +5 -1
  119. package/dist/feedback.d.ts +5 -1
  120. package/dist/hooks/use-file-drop.d.ts +5 -1
  121. package/dist/i18n/defaults.d.ts +5 -1
  122. package/dist/i18n/defaults.js +6 -0
  123. package/dist/i18n/defaults.js.map +1 -1
  124. package/dist/i18n/german.d.ts +5 -1
  125. package/dist/i18n/german.js +85 -2
  126. package/dist/i18n/german.js.map +1 -1
  127. package/dist/i18n/kit-labels.d.ts +5 -1
  128. package/dist/i18n/kit-labels.js.map +1 -1
  129. package/dist/i18n/languages.d.ts +5 -1
  130. package/dist/i18n/locales/de-CH.d.ts +5 -1
  131. package/dist/i18n/locales/en.d.ts +5 -1
  132. package/dist/i18n/locales/es.d.ts +5 -1
  133. package/dist/i18n/locales/es.js +82 -2
  134. package/dist/i18n/locales/es.js.map +1 -1
  135. package/dist/i18n/locales/fr.d.ts +5 -1
  136. package/dist/i18n/locales/fr.js +83 -2
  137. package/dist/i18n/locales/fr.js.map +1 -1
  138. package/dist/i18n/locales/hu.d.ts +5 -1
  139. package/dist/i18n/locales/hu.js +83 -2
  140. package/dist/i18n/locales/hu.js.map +1 -1
  141. package/dist/i18n/locales/it.d.ts +5 -1
  142. package/dist/i18n/locales/it.js +82 -2
  143. package/dist/i18n/locales/it.js.map +1 -1
  144. package/dist/i18n/locales/zh.d.ts +5 -1
  145. package/dist/i18n/locales/zh.js +81 -2
  146. package/dist/i18n/locales/zh.js.map +1 -1
  147. package/dist/i18n/review.d.ts +5 -1
  148. package/dist/i18n/review.js +15 -1
  149. package/dist/i18n/review.js.map +1 -1
  150. package/dist/index.d.ts +25 -3
  151. package/dist/index.js +96 -0
  152. package/dist/index.js.map +1 -1
  153. package/dist/landing/access.d.ts +48 -0
  154. package/dist/landing/access.js +38 -0
  155. package/dist/landing/access.js.map +1 -0
  156. package/dist/landing/landing-actions.d.ts +140 -0
  157. package/dist/landing/landing-actions.js +39 -0
  158. package/dist/landing/landing-actions.js.map +1 -0
  159. package/dist/landing/landing-labels.d.ts +43 -0
  160. package/dist/landing/landing-labels.js +23 -0
  161. package/dist/landing/landing-labels.js.map +1 -0
  162. package/dist/landing/landing-sections.d.ts +218 -0
  163. package/dist/landing/landing-sections.js +167 -0
  164. package/dist/landing/landing-sections.js.map +1 -0
  165. package/dist/landing/page-seo.d.ts +99 -0
  166. package/dist/landing/page-seo.js +121 -0
  167. package/dist/landing/page-seo.js.map +1 -0
  168. package/dist/landing/public-header.d.ts +148 -0
  169. package/dist/landing/public-header.js +49 -0
  170. package/dist/landing/public-header.js.map +1 -0
  171. package/dist/landing/routing.d.ts +231 -0
  172. package/dist/landing/routing.js +84 -0
  173. package/dist/landing/routing.js.map +1 -0
  174. package/dist/rhf/fields.d.ts +5 -1
  175. package/dist/rhf/form.d.ts +5 -1
  176. package/dist/rhf.d.ts +5 -1
  177. package/dist/settings/settings-catalogue.d.ts +84 -0
  178. package/dist/settings/settings-catalogue.js +47 -0
  179. package/dist/settings/settings-catalogue.js.map +1 -0
  180. package/dist/settings/settings-context.d.ts +31 -0
  181. package/dist/settings/settings-context.js +11 -0
  182. package/dist/settings/settings-context.js.map +1 -0
  183. package/dist/settings/settings-heading.d.ts +50 -0
  184. package/dist/settings/settings-heading.js +27 -0
  185. package/dist/settings/settings-heading.js.map +1 -0
  186. package/dist/settings/settings-labels.d.ts +48 -0
  187. package/dist/settings/settings-labels.js +43 -0
  188. package/dist/settings/settings-labels.js.map +1 -0
  189. package/dist/settings/settings-layout.d.ts +77 -0
  190. package/dist/settings/settings-layout.js +242 -0
  191. package/dist/settings/settings-layout.js.map +1 -0
  192. package/dist/settings/settings-section.d.ts +133 -0
  193. package/dist/settings/settings-section.js +63 -0
  194. package/dist/settings/settings-section.js.map +1 -0
  195. package/dist/settings/use-account-language.d.ts +83 -0
  196. package/dist/settings/use-account-language.js +62 -0
  197. package/dist/settings/use-account-language.js.map +1 -0
  198. package/dist/settings/use-settings-focus.d.ts +34 -0
  199. package/dist/settings/use-settings-focus.js +77 -0
  200. package/dist/settings/use-settings-focus.js.map +1 -0
  201. package/dist/settings/use-settings-route.d.ts +118 -0
  202. package/dist/settings/use-settings-route.js +120 -0
  203. package/dist/settings/use-settings-route.js.map +1 -0
  204. package/dist/shell/app-shell.d.ts +11 -1
  205. package/dist/shell/app-shell.js +3 -2
  206. package/dist/shell/app-shell.js.map +1 -1
  207. package/dist/shell/top-bar-brand.d.ts +5 -1
  208. package/dist/shell.d.ts +15 -1
  209. package/dist/shell.js +56 -0
  210. package/dist/shell.js.map +1 -1
  211. package/dist/wizard/stepper-nav.d.ts +5 -1
  212. package/dist/wizard.d.ts +5 -1
  213. package/package.json +2 -1
  214. package/src/account/data-export-setting.tsx +3 -2
  215. package/src/account/delete-account-setting.tsx +3 -2
  216. package/src/account/email-change-setting.tsx +4 -7
  217. package/src/account/sessions-setting.tsx +3 -2
  218. package/src/admin/admin-parts.ts +9 -2
  219. package/src/admin/invitations-panel.tsx +11 -4
  220. package/src/auth/auth-errors.ts +36 -5
  221. package/src/auth/reset-password-form.tsx +6 -2
  222. package/src/auth/sign-in-form.tsx +34 -3
  223. package/src/auth/verify-email.tsx +14 -2
  224. package/src/components/account-settings-labels.ts +13 -0
  225. package/src/components/account-settings.tsx +33 -12
  226. package/src/components/confirm-dialog.tsx +20 -1
  227. package/src/components/data-table.tsx +8 -0
  228. package/src/components/date-picker.tsx +9 -4
  229. package/src/components/passkeys-setting.tsx +2 -1
  230. package/src/components/settings-fields.tsx +50 -1
  231. package/src/demo/demo-banner.tsx +159 -0
  232. package/src/demo/demo-countdown.ts +141 -0
  233. package/src/demo/demo-ended.tsx +89 -0
  234. package/src/demo/demo-labels.ts +78 -0
  235. package/src/demo/demo-session.ts +32 -0
  236. package/src/demo/demo-start.tsx +150 -0
  237. package/src/feedback/feedback-inbox.tsx +21 -8
  238. package/src/feedback/feedback-labels.ts +2 -1
  239. package/src/feedback/feedback-record.ts +10 -7
  240. package/src/feedback/feedback-row-detail.tsx +28 -4
  241. package/src/feedback/feedback-swipe.tsx +1 -1
  242. package/src/i18n/defaults.ts +6 -0
  243. package/src/i18n/german.ts +87 -0
  244. package/src/i18n/kit-labels.tsx +9 -0
  245. package/src/i18n/locales/es.ts +84 -0
  246. package/src/i18n/locales/fr.ts +85 -0
  247. package/src/i18n/locales/hu.ts +85 -0
  248. package/src/i18n/locales/it.ts +84 -0
  249. package/src/i18n/locales/zh.ts +82 -0
  250. package/src/i18n/review.ts +14 -0
  251. package/src/index.ts +98 -1
  252. package/src/landing/access.ts +97 -0
  253. package/src/landing/landing-actions.tsx +96 -0
  254. package/src/landing/landing-labels.ts +59 -0
  255. package/src/landing/landing-sections.tsx +337 -0
  256. package/src/landing/page-seo.ts +251 -0
  257. package/src/landing/public-header.tsx +120 -0
  258. package/src/landing/routing.tsx +226 -0
  259. package/src/settings/settings-catalogue.ts +132 -0
  260. package/src/settings/settings-context.ts +33 -0
  261. package/src/settings/settings-heading.tsx +65 -0
  262. package/src/settings/settings-labels.ts +87 -0
  263. package/src/settings/settings-layout.tsx +379 -0
  264. package/src/settings/settings-section.tsx +108 -0
  265. package/src/settings/use-account-language.ts +146 -0
  266. package/src/settings/use-settings-focus.ts +120 -0
  267. package/src/settings/use-settings-route.ts +258 -0
  268. package/src/shell/app-shell.tsx +10 -2
  269. package/src/shell.ts +51 -0
@@ -0,0 +1,379 @@
1
+ import { useCallback, useId, useMemo, useRef, useState } from "react";
2
+ import type { ReactElement, ReactNode } from "react";
3
+ import { Link, useHref, useLocation } from "react-router";
4
+ import { ChevronLeft, ChevronRight } from "lucide-react";
5
+ import { Button, Card, EmptyState, Tabs } from "../components/ui";
6
+ import { Chip } from "../components/chip";
7
+ import { List, ListItem } from "../components/list";
8
+ import type { ListItemLinkProps } from "../components/list";
9
+ import { PageHeader } from "../components/page-header";
10
+ import { SearchField } from "../components/search-field";
11
+ import { cn } from "../lib/cn";
12
+ import { useKitLabels } from "../i18n/kit-labels";
13
+ import { createSearchIndex } from "../search/search-index";
14
+ import type { SearchEntry } from "../search/search-index";
15
+ import {
16
+ normalizeSettingsBase,
17
+ settingsHref,
18
+ settingsKeywords,
19
+ visibleSettingsEntries,
20
+ } from "./settings-catalogue";
21
+ import type { SettingsEntry, SettingsGroup } from "./settings-catalogue";
22
+ import { SettingsLayoutContext } from "./settings-context";
23
+ import type { SettingsLayoutContextValue } from "./settings-context";
24
+ import { SettingsHeadingLevel } from "./settings-heading";
25
+ import { DEFAULT_SETTINGS_LABELS } from "./settings-labels";
26
+ import type { SettingsLabels } from "./settings-labels";
27
+ import { settingsFromListState, useSettingsRoute } from "./use-settings-route";
28
+ import type { SettingsLayoutMode } from "./use-settings-route";
29
+ import { useSettingsFocus } from "./use-settings-focus";
30
+
31
+ /** The page's width cap (§3.2): `6xl` for settings, `7xl` for keksdose's admin, whose
32
+ * user table scrolled sideways at 6xl (dev#488). */
33
+ export type SettingsLayoutWidth = "6xl" | "7xl";
34
+
35
+ /** More entries than this and the search field shows by itself (§3.4): Kurvenschmiede has
36
+ * about twelve, keksdose 26; a page of six needs none. */
37
+ export const SETTINGS_SEARCH_THRESHOLD = 8;
38
+
39
+ export interface SettingsLayoutProps<G extends string> {
40
+ /** The page's `h1`: "Settings", "Administration". Default: `labels.title`. */
41
+ title?: ReactNode;
42
+ /** The groups, in order (§4.2). */
43
+ groups: readonly SettingsGroup<G>[];
44
+ /** One entry per setting (§4.1). Hidden entries leave search; a group left without a
45
+ * visible one is hidden. */
46
+ entries: readonly SettingsEntry<G>[];
47
+ /** `/settings` or `/admin`. The layout owns every path below it. */
48
+ basePath: string;
49
+ /** The group `/settings` opens on a desktop. Default: the first visible one. */
50
+ defaultGroup?: G;
51
+ /** Retired group ids and their successors, replaced in the address (§3.1). */
52
+ aliases?: Readonly<Partial<Record<string, G>>>;
53
+ /**
54
+ * The search field. Left out, it shows once more than {@link SETTINGS_SEARCH_THRESHOLD}
55
+ * entries are visible; `true` or `false` wins over the count — keksdose's admin keeps
56
+ * none (§3.4, §10.3). ⌘K gets the entries either way ({@link settingsSearchEntries}).
57
+ */
58
+ search?: boolean;
59
+ /** See {@link SettingsLayoutWidth}. Default `"6xl"`. */
60
+ width?: SettingsLayoutWidth;
61
+ /** The group's cards, in one column. `sub` is the path segment after the group, which
62
+ * a card owns (`useSettingsLayout().selectSub`). */
63
+ renderGroup: (group: G, sub: string | null) => ReactNode;
64
+ /**
65
+ * Rows under the phone's group list that LEAVE settings — kastlan's "Administration",
66
+ * its import page (§3.3, §5). `ListItem`s with an `href`; the layout puts them in a list
67
+ * of their own, so they never read as a group. Not shown on a desktop, where the app's
68
+ * own navigation carries them.
69
+ */
70
+ phoneFooter?: ReactNode;
71
+ /** Prop > `<UiKitProvider labels={{ settings }}>` > English. */
72
+ labels?: Partial<SettingsLabels>;
73
+ /** See {@link SettingsLayoutMode}. Default `"auto"`: the screen decides. */
74
+ layout?: SettingsLayoutMode;
75
+ className?: string;
76
+ }
77
+
78
+ interface Searchable<G extends string> extends SearchEntry {
79
+ anchor: string;
80
+ settingsGroup: G;
81
+ }
82
+
83
+ /**
84
+ * The settings page (docs/settings-harmonization.md §3), and the admin page built the
85
+ * same way (§5). keksdose's page is the reference for the desktop; the phone pattern and
86
+ * the path segments are new for all three apps.
87
+ *
88
+ * **Desktop** (§3.2): the page's `PageHeader`, the search field, then a 14rem sidebar —
89
+ * the kit's vertical `Tabs`, a link per group, sticky so a long group keeps the list in
90
+ * view — beside the open group: its heading (`h2`), its help line and its cards in ONE
91
+ * column, never a masonry (keksdose dev#490/#493).
92
+ *
93
+ * **Phone** (§3.3, below `md`): a drill-down. `/settings` is a list of the groups (icon,
94
+ * name, help line, chevron) with `phoneFooter` under it; a group is a page of its own with
95
+ * a back link, its name as the `h1` and the cards under it. It replaces keksdose's chip
96
+ * strip.
97
+ *
98
+ * **Routes, history and `?focus=`** are {@link useSettingsRoute}'s and
99
+ * {@link useSettingsFocus}'s. **Search** (§3.4) runs the kit's matcher over the catalogue:
100
+ * accents folded, ranked, over the title, the group's name and the keywords. On a desktop
101
+ * the sidebar dims the groups without hits and counts the others' while the hits replace
102
+ * the group; on a phone they replace the list. A hit is a link to its card.
103
+ *
104
+ * The cards inside read {@link useSettingsLayout} for `selectSub`, and their titles are
105
+ * headings under the group's ({@link SettingsHeadingLevel}).
106
+ */
107
+ export function SettingsLayout<G extends string>({
108
+ title,
109
+ groups,
110
+ entries,
111
+ basePath,
112
+ defaultGroup,
113
+ aliases,
114
+ search,
115
+ width = "6xl",
116
+ renderGroup,
117
+ phoneFooter,
118
+ labels: labelsProp,
119
+ layout = "auto",
120
+ className,
121
+ }: SettingsLayoutProps<G>): ReactElement {
122
+ const labels = useKitLabels("settings", DEFAULT_SETTINGS_LABELS, labelsProp);
123
+ const base = normalizeSettingsBase(basePath);
124
+ const route = useSettingsRoute({ groups, entries, basePath: base, defaultGroup, aliases, layout });
125
+ const location = useLocation();
126
+ // The browser URL of an app path, for the sidebar's real links (a middle click opens a
127
+ // tab): `#/settings/…` under a hash router, the basename in front under a browser one.
128
+ const root = useHref("/").replace(/\/$/, "");
129
+ const panelId = useId();
130
+ const searchRef = useRef<HTMLInputElement>(null);
131
+ const [query, setQuery] = useState("");
132
+
133
+ const visibleEntries = useMemo(() => visibleSettingsEntries(groups, entries), [groups, entries]);
134
+ const showSearch = search ?? visibleEntries.length > SETTINGS_SEARCH_THRESHOLD;
135
+ const trimmed = query.trim();
136
+ const searching = showSearch && trimmed !== "";
137
+
138
+ const groupById = useMemo(() => new Map(route.groups.map((g) => [g.id, g])), [route.groups]);
139
+ const index = useMemo(
140
+ () =>
141
+ createSearchIndex<Searchable<G>>(
142
+ visibleEntries.map((e) => ({
143
+ id: e.id,
144
+ title: e.title,
145
+ keywords: [groupById.get(e.group)?.title ?? e.group, ...settingsKeywords(e.keywords)],
146
+ group: e.group,
147
+ anchor: e.anchor,
148
+ settingsGroup: e.group,
149
+ })),
150
+ // Every hit: the list is the page's whole answer, not a palette's top few.
151
+ { groupLimit: Infinity },
152
+ ),
153
+ [visibleEntries, groupById],
154
+ );
155
+ const hits = useMemo(() => (searching ? index.search(trimmed).map((h) => h.entry) : []), [searching, index, trimmed]);
156
+ const hitsByGroup = useMemo(() => {
157
+ const counts = new Map<G, number>();
158
+ for (const hit of hits) counts.set(hit.settingsGroup, (counts.get(hit.settingsGroup) ?? 0) + 1);
159
+ return counts;
160
+ }, [hits]);
161
+
162
+ const anchors = useMemo(() => new Set(entries.map((e) => e.anchor)), [entries]);
163
+ const { phone, group, sub, selectSub, select } = route;
164
+ const headingLevel = phone ? "h2" : "h3";
165
+ const context = useMemo<SettingsLayoutContextValue>(
166
+ () => ({
167
+ basePath: base,
168
+ group,
169
+ sub,
170
+ selectSub,
171
+ headingLevel,
172
+ hasAnchor: (anchor) => anchors.has(anchor),
173
+ }),
174
+ [base, group, sub, selectSub, headingLevel, anchors],
175
+ );
176
+
177
+ // Not while the address is about to be replaced (its removal of `focus` would undo the
178
+ // replace), not on the list, and not while the hits stand where the cards would be (a
179
+ // phone's group page has no search: a query left from its list does not count there).
180
+ const hitsShown = searching && (!phone || group === null);
181
+ useSettingsFocus(route.redirecting || group === null || hitsShown ? null : route.focus);
182
+
183
+ // A link inside the page. On a phone it opens a page of its own — a push that marks the
184
+ // list as the entry before it, so the group page's back is a real back. On a desktop
185
+ // it switches the group in place — a replace.
186
+ const { state } = location;
187
+ const renderLink = useCallback(
188
+ ({ href, ...props }: ListItemLinkProps) => (
189
+ <Link to={href} replace={!phone} state={phone ? settingsFromListState() : state} {...props} />
190
+ ),
191
+ [phone, state],
192
+ );
193
+
194
+ const clearSearch = () => {
195
+ setQuery("");
196
+ // The button that was pressed goes with the empty state; the field is where the
197
+ // next query is typed.
198
+ searchRef.current?.focus();
199
+ };
200
+
201
+ const pageTitle = title ?? labels.title;
202
+ const pageClass = cn("mx-auto w-full space-y-4 p-4 md:p-6", width === "7xl" ? "max-w-7xl" : "max-w-6xl", className);
203
+
204
+ const searchField = showSearch ? (
205
+ <SearchField
206
+ ref={searchRef}
207
+ className="max-w-md"
208
+ value={query}
209
+ onChange={setQuery}
210
+ aria-label={labels.search}
211
+ placeholder={labels.searchPlaceholder}
212
+ />
213
+ ) : null;
214
+
215
+ const results =
216
+ hits.length === 0 ? (
217
+ <EmptyState
218
+ title={labels.noMatches(trimmed)}
219
+ action={
220
+ <Button variant="secondary" size="sm" onClick={clearSearch}>
221
+ {labels.clearSearch}
222
+ </Button>
223
+ }
224
+ />
225
+ ) : (
226
+ <Card className="p-1">
227
+ <List separator="divider" density="comfortable" aria-label={labels.results}>
228
+ {hits.map((hit) => (
229
+ <ListItem
230
+ key={hit.id}
231
+ title={hit.title}
232
+ trailing={groupById.get(hit.settingsGroup)?.title}
233
+ href={settingsHref(base, hit.settingsGroup, hit.anchor)}
234
+ renderLink={renderLink}
235
+ // Leave search on the way: the hit's card is what the user asked for.
236
+ onClick={() => setQuery("")}
237
+ />
238
+ ))}
239
+ </List>
240
+ </Card>
241
+ );
242
+
243
+ const cards = (open: G) => (
244
+ <SettingsHeadingLevel level={headingLevel}>
245
+ <div className="space-y-4">{renderGroup(open, sub)}</div>
246
+ </SettingsHeadingLevel>
247
+ );
248
+
249
+ let body: ReactNode;
250
+ if (phone) {
251
+ const open = group === null ? undefined : groupById.get(group);
252
+ body = open ? (
253
+ <div className={pageClass} data-settings-view="group">
254
+ <PageHeader
255
+ size="compact"
256
+ title={open.title}
257
+ description={open.help}
258
+ breadcrumbs={
259
+ <Link
260
+ to={base || "/"}
261
+ aria-label={labels.back}
262
+ onClick={(e) => {
263
+ // A real link for a middle click; a plain click is the page's own back,
264
+ // which is `navigate(-1)` when the list is the entry before this one.
265
+ if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
266
+ e.preventDefault();
267
+ route.back();
268
+ }}
269
+ className="-ms-1 inline-flex items-center gap-0.5 self-start rounded-md pe-1 text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
270
+ >
271
+ <ChevronLeft aria-hidden className="size-4 rtl:-scale-x-100" />
272
+ {pageTitle}
273
+ </Link>
274
+ }
275
+ />
276
+ {cards(open.id)}
277
+ </div>
278
+ ) : (
279
+ <div className={pageClass} data-settings-view="list">
280
+ <PageHeader size="compact" title={pageTitle} />
281
+ {searchField}
282
+ {searching ? (
283
+ results
284
+ ) : (
285
+ <Card className="p-1">
286
+ <List separator="divider" density="comfortable" aria-label={labels.sections}>
287
+ {route.groups.map((g) => (
288
+ <ListItem
289
+ key={g.id}
290
+ href={settingsHref(base, g.id)}
291
+ renderLink={renderLink}
292
+ leading={
293
+ <span
294
+ aria-hidden
295
+ className="flex size-8 shrink-0 items-center justify-center rounded-md bg-[var(--bg-surface-2)] text-[var(--text-secondary)] [&_svg]:size-4"
296
+ >
297
+ {g.icon}
298
+ </span>
299
+ }
300
+ title={g.title}
301
+ subtitle={g.help}
302
+ trailing={<ChevronRight aria-hidden className="size-4 rtl:-scale-x-100" />}
303
+ />
304
+ ))}
305
+ </List>
306
+ </Card>
307
+ )}
308
+ {!searching && phoneFooter != null && (
309
+ <Card className="p-1">
310
+ <List separator="divider" density="comfortable">
311
+ {phoneFooter}
312
+ </List>
313
+ </Card>
314
+ )}
315
+ </div>
316
+ );
317
+ } else {
318
+ const open = group === null ? undefined : groupById.get(group);
319
+ body = (
320
+ <div className={pageClass} data-settings-view="desktop">
321
+ <PageHeader size="compact" title={pageTitle} />
322
+ {searchField}
323
+ <div className="md:grid md:grid-cols-[14rem_minmax(0,1fr)] md:gap-6">
324
+ <Tabs<G>
325
+ orientation="vertical"
326
+ aria-label={labels.sections}
327
+ // Sticky (new in the harmonisation, §3.2): keksdose's notifications group
328
+ // scrolls for screens, and the list should stay in view beside it.
329
+ className="mb-4 md:sticky md:top-6 md:mb-0 md:self-start"
330
+ panelId={open && !searching ? panelId : undefined}
331
+ tabs={route.groups.map((g) => {
332
+ const count = hitsByGroup.get(g.id) ?? 0;
333
+ // While searching, the groups with nothing to show step back (keksdose).
334
+ const dim = searching && count === 0;
335
+ return {
336
+ id: g.id,
337
+ href: `${root}${settingsHref(base, g.id)}`,
338
+ name: g.title,
339
+ label: <span className={cn(dim && "opacity-40")}>{g.title}</span>,
340
+ icon: <span className={cn("inline-flex [&_svg]:size-4", dim && "opacity-40")}>{g.icon}</span>,
341
+ badge:
342
+ searching && count > 0 ? (
343
+ <Chip size="sm" tone="brand">
344
+ <span aria-hidden>{count}</span>
345
+ <span className="sr-only-fixed">{labels.matchCount(count)}</span>
346
+ </Chip>
347
+ ) : undefined,
348
+ };
349
+ })}
350
+ active={(open?.id ?? route.groups[0]?.id) as G}
351
+ onChange={(id) => {
352
+ // While searching the hits stand where the group would; a click on a group
353
+ // means "go there", so the search makes way (keksdose).
354
+ setQuery("");
355
+ select(id);
356
+ }}
357
+ />
358
+ <div className="min-w-0 space-y-4">
359
+ {searching ? (
360
+ results
361
+ ) : open ? (
362
+ <div
363
+ id={panelId}
364
+ role="tabpanel"
365
+ aria-labelledby={`${panelId}-tab`}
366
+ className="space-y-4"
367
+ >
368
+ <PageHeader as="h2" size="sm" title={open.title} description={open.help} />
369
+ {cards(open.id)}
370
+ </div>
371
+ ) : null}
372
+ </div>
373
+ </div>
374
+ </div>
375
+ );
376
+ }
377
+
378
+ return <SettingsLayoutContext.Provider value={context}>{body}</SettingsLayoutContext.Provider>;
379
+ }
@@ -0,0 +1,108 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
+ import { Card } from "../components/ui";
3
+ import type { CardTone } from "../components/ui";
4
+ import { cn } from "../lib/cn";
5
+ import { CARD_DESCRIPTION_CLASS, CARD_TITLE_CLASS } from "../account/account-parts";
6
+ import { SettingsHeading, useSettingsHeadingLevel } from "./settings-heading";
7
+ import { useSettingsLayout } from "./settings-context";
8
+
9
+ export interface SettingsSectionProps extends Omit<ComponentPropsWithoutRef<"div">, "title" | "id"> {
10
+ /** The card's DOM id — the catalogue entry's `anchor`, the `?focus=` target. */
11
+ anchor: string;
12
+ /** The card's name: a heading at the layout's level (`h3` by default). */
13
+ title: ReactNode;
14
+ /**
15
+ * `false` keeps the title for screen readers only — for a card alone in a group named
16
+ * after it, so the page does not print the same heading twice (§3.7, keksdose's admin,
17
+ * dev#490/#493/#494). The heading is still there: the outline and `?focus=` need it.
18
+ * Default `true`.
19
+ */
20
+ titleVisible?: boolean;
21
+ /** One muted line under the title. */
22
+ description?: ReactNode;
23
+ /** `danger` for the destructive cards at the end of "data" (§3.7). */
24
+ tone?: CardTone;
25
+ /** A control at the title's end — a refresh, a toggle. It drops under the title when
26
+ * the card is too narrow for both. */
27
+ action?: ReactNode;
28
+ children?: ReactNode;
29
+ }
30
+
31
+ /**
32
+ * Is this a development build? The consumer's bundler makes `import.meta.env.DEV` false in
33
+ * production, so the check below is dead code there. Unlike the kit's warnings it stays
34
+ * on under vitest: a card that cannot be found is a bug a test should fail on.
35
+ * Optional-chained: `import.meta.env` is a Vite injection, absent under plain Node.
36
+ */
37
+ function devChecks(): boolean {
38
+ return Boolean(import.meta.env?.DEV);
39
+ }
40
+
41
+ /**
42
+ * An app's own settings card (docs/settings-harmonization.md §3.7, §7.1): a kit `Card` at
43
+ * the settings type scale — the title `text-sm font-medium`, the description `text-xs`
44
+ * muted, as the kit's own setting cards — with the catalogue anchor as its id, the title
45
+ * as a heading under the group's, and the `?focus=` ring landing on it.
46
+ *
47
+ * **A card without a catalogue entry throws in development** (keksdose's `section()`
48
+ * guard). A card search cannot find breaks the rule that every setting is findable
49
+ * (§4.1), and the old failure was silent: a hit that scrolled nowhere. The check runs
50
+ * inside a {@link SettingsLayout}, which holds the catalogue; outside one there is
51
+ * nothing to check against.
52
+ */
53
+ export function SettingsSection({
54
+ anchor,
55
+ title,
56
+ titleVisible = true,
57
+ description,
58
+ tone,
59
+ action,
60
+ children,
61
+ className,
62
+ ...rest
63
+ }: SettingsSectionProps) {
64
+ const layout = useSettingsLayout();
65
+ const level = useSettingsHeadingLevel() ?? "h3";
66
+ if (layout && devChecks() && !layout.hasAnchor(anchor)) {
67
+ throw new Error(
68
+ `SettingsSection "${anchor}" has no catalogue entry: add a SettingsEntry with anchor "${anchor}" so search and ?focus= can reach it.`,
69
+ );
70
+ }
71
+ const heading = (
72
+ <SettingsHeading
73
+ as={level}
74
+ // Focusable from script only: `?focus=` moves focus here (§3.5).
75
+ tabIndex={-1}
76
+ data-settings-heading=""
77
+ className={cn(CARD_TITLE_CLASS, "outline-none", !titleVisible && "sr-only-fixed")}
78
+ >
79
+ {title}
80
+ </SettingsHeading>
81
+ );
82
+ const header =
83
+ titleVisible || description != null || action != null ? (
84
+ <div className="flex flex-wrap items-start justify-between gap-x-3 gap-y-2">
85
+ <div className="min-w-0 flex-1 basis-48">
86
+ {heading}
87
+ {description != null && <div className={CARD_DESCRIPTION_CLASS}>{description}</div>}
88
+ </div>
89
+ {action != null && <div className="flex shrink-0 items-center gap-2">{action}</div>}
90
+ </div>
91
+ ) : (
92
+ heading
93
+ );
94
+ return (
95
+ <Card
96
+ {...rest}
97
+ id={anchor}
98
+ tone={tone}
99
+ // A column with a gap, not `space-y-3`: a hidden title is `position: fixed` and so
100
+ // out of the flow, and a gap is only drawn between items in it — `space-y` would
101
+ // still push the first card part down by a gap for the title nobody sees.
102
+ className={cn("flex flex-col gap-3 p-4 transition-shadow motion-reduce:transition-none", className)}
103
+ >
104
+ {header}
105
+ {children}
106
+ </Card>
107
+ );
108
+ }
@@ -0,0 +1,146 @@
1
+ import { useCallback, useMemo } from "react";
2
+
3
+ /**
4
+ * Where the language on screen came from (docs/settings-harmonization.md §6.2), in the
5
+ * order they are asked.
6
+ */
7
+ export type AccountLanguageSource = "device" | "account" | "browser" | "default";
8
+
9
+ /** `de_ch`, `DE-ch` → `de-CH`: the case and separator a BCP 47 tag is written in. */
10
+ function canonicalTag(tag: string): string {
11
+ const parts = tag.trim().replace(/_/g, "-").split("-").filter(Boolean);
12
+ return parts
13
+ .map((part, i) => {
14
+ if (i === 0) return part.toLowerCase();
15
+ if (part.length === 2) return part.toUpperCase();
16
+ if (part.length === 4) return part[0].toUpperCase() + part.slice(1).toLowerCase();
17
+ return part.toLowerCase();
18
+ })
19
+ .join("-");
20
+ }
21
+
22
+ /**
23
+ * The offered language a tag means, or `null` — the client's half of server-kit's
24
+ * `canonical_locale(tag, offered)` (§6.2): case and `_` normalised; an offered tag matches
25
+ * exactly; otherwise the first offered tag of the same language (`de`, `de-DE` → `de-CH`
26
+ * where `de-CH` is what the app offers); anything else is no match.
27
+ */
28
+ export function matchOfferedLanguage(tag: string | null | undefined, offered: readonly string[]): string | null {
29
+ if (!tag || !tag.trim()) return null;
30
+ const wanted = canonicalTag(tag);
31
+ const exact = offered.find((code) => canonicalTag(code) === wanted);
32
+ if (exact !== undefined) return exact;
33
+ const language = wanted.split("-")[0];
34
+ return offered.find((code) => canonicalTag(code).split("-")[0] === language) ?? null;
35
+ }
36
+
37
+ export interface ResolveAccountLanguageInput {
38
+ /** This device's own choice — made on this device, stored only when the user picks. */
39
+ device: string | null | undefined;
40
+ /** The signed-in account's `locale`; `undefined` while signed out. */
41
+ account: string | null | undefined;
42
+ /** The browser's languages, most wanted first (`navigator.languages`). */
43
+ browser?: readonly string[];
44
+ /** The languages the app offers. */
45
+ offered: readonly string[];
46
+ /** The app's default, when nothing else matches. */
47
+ fallback: string;
48
+ }
49
+
50
+ export interface ResolvedAccountLanguage {
51
+ language: string;
52
+ source: AccountLanguageSource;
53
+ }
54
+
55
+ /**
56
+ * The language on a device (§6.2): **its own choice → the account's locale → the
57
+ * browser's language → the app's default**, each taken only when it names a language the
58
+ * app offers. Pure: the order, without the storage.
59
+ *
60
+ * "At sign-in the account applies unless the device has its own choice" is this order and
61
+ * nothing more: once the account's locale arrives it outranks the browser, never the
62
+ * device's pick.
63
+ */
64
+ export function resolveAccountLanguage(input: ResolveAccountLanguageInput): ResolvedAccountLanguage {
65
+ const { device, account, browser = [], offered, fallback } = input;
66
+ const fromDevice = matchOfferedLanguage(device, offered);
67
+ if (fromDevice) return { language: fromDevice, source: "device" };
68
+ const fromAccount = matchOfferedLanguage(account, offered);
69
+ if (fromAccount) return { language: fromAccount, source: "account" };
70
+ for (const tag of browser) {
71
+ const fromBrowser = matchOfferedLanguage(tag, offered);
72
+ if (fromBrowser) return { language: fromBrowser, source: "browser" };
73
+ }
74
+ return { language: fallback, source: "default" };
75
+ }
76
+
77
+ export interface UseAccountLanguageOptions {
78
+ /** The signed-in account's `locale` (`/auth/me`). `undefined` while signed out or not
79
+ * loaded yet; `null` for an account with no language set. */
80
+ account: string | null | undefined;
81
+ /** This device's own choice — `<app>-lang` — or `null` when it was never picked here.
82
+ * Detection must not fill it (i18next's detector with `caches: []`, §6.2). */
83
+ device: string | null | undefined;
84
+ /** Store the device's own choice (`<app>-lang`). */
85
+ setDevice: (code: string) => void;
86
+ /** Write the account's locale: `PATCH /auth/me {locale}`. Return its promise. */
87
+ save: (code: string) => unknown;
88
+ /** A demo session: a pick stays on the device, the account is never written (§6.2). */
89
+ isDemo?: boolean;
90
+ /** The languages the app offers, as it writes them. */
91
+ offered: readonly string[];
92
+ /** The app's default language. */
93
+ fallback: string;
94
+ /** The browser's languages. Default: `navigator.languages`. */
95
+ browser?: readonly string[];
96
+ }
97
+
98
+ export interface AccountLanguage extends ResolvedAccountLanguage {
99
+ /**
100
+ * The user picked `code` (the language card, the top bar's quick switch): it becomes the
101
+ * device's own choice and — signed in, and not a demo — the account's locale. The
102
+ * promise is `save`'s; the device keeps the pick if it fails, and the app says so.
103
+ */
104
+ pick: (code: string) => Promise<unknown>;
105
+ }
106
+
107
+ function navigatorLanguages(): readonly string[] {
108
+ if (typeof navigator === "undefined") return [];
109
+ return navigator.languages?.length ? navigator.languages : navigator.language ? [navigator.language] : [];
110
+ }
111
+
112
+ /**
113
+ * The account's language on this device (§6.2, §10.8): the order of
114
+ * {@link resolveAccountLanguage}, and the writes — so the three apps' copies go.
115
+ *
116
+ * - **Nothing writes the account on its own.** Rendering, signing in and adopting the
117
+ * account's locale call neither `save` nor `setDevice`; keksdose's
118
+ * `useSyncLanguageToAccount` (a PATCH on every session start) overwrote the account
119
+ * from a new device before that device could adopt it.
120
+ * - **A pick** writes the device's choice, then `save` — except in a demo session, whose
121
+ * throwaway account needs no language (and model R refuses the write).
122
+ *
123
+ * The hook only answers; the app applies `language` (`i18n.changeLanguage`) where it
124
+ * applies languages today.
125
+ */
126
+ export function useAccountLanguage(options: UseAccountLanguageOptions): AccountLanguage {
127
+ const { account, device, setDevice, save, isDemo = false, offered, fallback, browser } = options;
128
+ const resolved = useMemo(
129
+ () => resolveAccountLanguage({ device, account, browser: browser ?? navigatorLanguages(), offered, fallback }),
130
+ [device, account, browser, offered, fallback],
131
+ );
132
+ const signedIn = account !== undefined;
133
+ const pick = useCallback(
134
+ (code: string) => {
135
+ setDevice(code);
136
+ if (isDemo || !signedIn) return Promise.resolve();
137
+ try {
138
+ return Promise.resolve(save(code));
139
+ } catch (error) {
140
+ return Promise.reject(error);
141
+ }
142
+ },
143
+ [setDevice, save, isDemo, signedIn],
144
+ );
145
+ return { ...resolved, pick };
146
+ }