@eifi1/ui-kit 0.29.0 → 0.31.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 +65 -6
- package/dist/account/account-parts.d.ts +21 -0
- package/dist/account/account-parts.js +40 -0
- package/dist/account/account-parts.js.map +1 -0
- package/dist/account/data-export-setting.d.ts +73 -0
- package/dist/account/data-export-setting.js +114 -0
- package/dist/account/data-export-setting.js.map +1 -0
- package/dist/account/delete-account-setting.d.ts +95 -0
- package/dist/account/delete-account-setting.js +200 -0
- package/dist/account/delete-account-setting.js.map +1 -0
- package/dist/account/email-change-setting.d.ts +136 -0
- package/dist/account/email-change-setting.js +278 -0
- package/dist/account/email-change-setting.js.map +1 -0
- package/dist/account/sessions-setting.d.ts +110 -0
- package/dist/account/sessions-setting.js +147 -0
- package/dist/account/sessions-setting.js.map +1 -0
- package/dist/admin/admin-action-confirm.d.ts +95 -0
- package/dist/admin/admin-action-confirm.js +276 -0
- package/dist/admin/admin-action-confirm.js.map +1 -0
- package/dist/admin/admin-action-log.d.ts +105 -0
- package/dist/admin/admin-action-log.js +153 -0
- package/dist/admin/admin-action-log.js.map +1 -0
- package/dist/admin/admin-parts.d.ts +40 -0
- package/dist/admin/admin-parts.js +45 -0
- package/dist/admin/admin-parts.js.map +1 -0
- package/dist/admin/invitations-panel.d.ts +95 -0
- package/dist/admin/invitations-panel.js +290 -0
- package/dist/admin/invitations-panel.js.map +1 -0
- package/dist/admin/reviewer-scope-editor.d.ts +95 -0
- package/dist/admin/reviewer-scope-editor.js +134 -0
- package/dist/admin/reviewer-scope-editor.js.map +1 -0
- package/dist/admin/role-select.d.ts +95 -0
- package/dist/admin/role-select.js +128 -0
- package/dist/admin/role-select.js.map +1 -0
- package/dist/admin/transfer-ownership-dialog.d.ts +95 -0
- package/dist/admin/transfer-ownership-dialog.js +83 -0
- package/dist/admin/transfer-ownership-dialog.js.map +1 -0
- package/dist/admin/user-roster.d.ts +95 -0
- package/dist/admin/user-roster.js +285 -0
- package/dist/admin/user-roster.js.map +1 -0
- package/dist/auth/auth-errors.d.ts +103 -6
- package/dist/auth/auth-errors.js +68 -2
- package/dist/auth/auth-errors.js.map +1 -1
- package/dist/auth/forgot-password-form.d.ts +10 -3
- package/dist/auth/forgot-password-form.js +6 -3
- package/dist/auth/forgot-password-form.js.map +1 -1
- package/dist/auth/register-form.d.ts +21 -12
- package/dist/auth/register-form.js +6 -4
- package/dist/auth/register-form.js.map +1 -1
- package/dist/auth/reset-password-form.d.ts +10 -3
- package/dist/auth/reset-password-form.js +6 -3
- package/dist/auth/reset-password-form.js.map +1 -1
- package/dist/auth/sign-in-form.d.ts +75 -9
- package/dist/auth/sign-in-form.js +105 -15
- package/dist/auth/sign-in-form.js.map +1 -1
- package/dist/auth/verify-email.d.ts +7 -2
- package/dist/auth/verify-email.js +44 -8
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/chart.d.ts +20 -11
- package/dist/components/account-chips.d.ts +47 -7
- package/dist/components/account-chips.js +19 -3
- package/dist/components/account-chips.js.map +1 -1
- package/dist/components/account-settings-labels.d.ts +24 -1
- package/dist/components/account-settings-labels.js +6 -2
- package/dist/components/account-settings-labels.js.map +1 -1
- package/dist/components/account-settings.d.ts +69 -8
- package/dist/components/account-settings.js +133 -25
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +21 -12
- package/dist/components/button-group.d.ts +21 -12
- package/dist/components/calculator.d.ts +21 -12
- package/dist/components/confirm-dialog.d.ts +21 -12
- package/dist/components/copy-button.d.ts +18 -9
- package/dist/components/danger-confirm.d.ts +21 -12
- package/dist/components/data-table.js +8 -1
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.js +9 -4
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/facing-pair.d.ts +21 -12
- package/dist/components/file-button.d.ts +21 -12
- package/dist/components/file-dropzone.d.ts +21 -12
- package/dist/components/form-actions.d.ts +21 -12
- package/dist/components/iban-input.d.ts +21 -12
- package/dist/components/language-select.d.ts +21 -12
- package/dist/components/legal-page.d.ts +21 -12
- package/dist/components/legal.d.ts +21 -12
- package/dist/components/money-field.d.ts +21 -12
- package/dist/components/number-field.d.ts +21 -12
- package/dist/components/number-input.d.ts +21 -12
- package/dist/components/numpad-sheet.d.ts +21 -12
- package/dist/components/passkeys-setting.js +2 -1
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/phone-input.d.ts +21 -12
- package/dist/components/series-chart.d.ts +21 -12
- package/dist/components/settings-fields.d.ts +55 -13
- package/dist/components/settings-fields.js +20 -1
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/share-card.d.ts +20 -11
- package/dist/components/text-link.d.ts +21 -12
- package/dist/components/time-input.d.ts +21 -12
- package/dist/components/ui.d.ts +17 -8
- package/dist/{register-form-V1OeNlMr.d.ts → delete-account-setting-DCFS5Vof.d.ts} +6612 -5612
- package/dist/demo/demo-banner.d.ts +51 -0
- package/dist/demo/demo-banner.js +97 -0
- package/dist/demo/demo-banner.js.map +1 -0
- package/dist/demo/demo-countdown.d.ts +58 -0
- package/dist/demo/demo-countdown.js +79 -0
- package/dist/demo/demo-countdown.js.map +1 -0
- package/dist/demo/demo-ended.d.ts +45 -0
- package/dist/demo/demo-ended.js +42 -0
- package/dist/demo/demo-ended.js.map +1 -0
- package/dist/demo/demo-labels.d.ts +53 -0
- package/dist/demo/demo-labels.js +29 -0
- package/dist/demo/demo-labels.js.map +1 -0
- package/dist/demo/demo-session.d.ts +30 -0
- package/dist/demo/demo-session.js +8 -0
- package/dist/demo/demo-session.js.map +1 -0
- package/dist/demo/demo-start.d.ts +54 -0
- package/dist/demo/demo-start.js +68 -0
- package/dist/demo/demo-start.js.map +1 -0
- package/dist/feedback/feedback-attachment.d.ts +21 -12
- package/dist/feedback/feedback-context.d.ts +21 -12
- package/dist/feedback/feedback-crash.d.ts +21 -12
- package/dist/feedback/feedback-dialog.d.ts +21 -12
- package/dist/feedback/feedback-inbox.d.ts +21 -12
- package/dist/feedback/feedback-inbox.js +12 -2
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-labels.d.ts +21 -12
- package/dist/feedback/feedback-labels.js +1 -0
- package/dist/feedback/feedback-labels.js.map +1 -1
- package/dist/feedback/feedback-menu.d.ts +21 -12
- package/dist/feedback/feedback-record.d.ts +21 -12
- package/dist/feedback/feedback-record.js +2 -0
- package/dist/feedback/feedback-record.js.map +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +21 -12
- package/dist/feedback/feedback-row-detail.js +7 -0
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-status-undo.d.ts +21 -12
- package/dist/feedback/feedback-submit.d.ts +21 -12
- package/dist/feedback/feedback-swipe.d.ts +22 -13
- package/dist/feedback/feedback-swipe.js.map +1 -1
- package/dist/feedback/feedback-table.d.ts +18 -9
- package/dist/feedback/feedback-thread.d.ts +21 -12
- package/dist/feedback.d.ts +21 -12
- package/dist/hooks/use-anchored-panel.d.ts +6 -2
- package/dist/hooks/use-anchored-panel.js +2 -1
- package/dist/hooks/use-anchored-panel.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +21 -12
- package/dist/i18n/defaults.d.ts +21 -12
- package/dist/i18n/defaults.js +28 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +21 -12
- package/dist/i18n/german.js +298 -12
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +14 -5
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +15 -6
- package/dist/i18n/locales/de-CH.d.ts +21 -12
- package/dist/i18n/locales/en.d.ts +21 -12
- package/dist/i18n/locales/es.d.ts +21 -12
- package/dist/i18n/locales/es.js +287 -10
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +21 -12
- package/dist/i18n/locales/fr.js +290 -7
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +21 -12
- package/dist/i18n/locales/hu.js +288 -12
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +21 -12
- package/dist/i18n/locales/it.js +289 -7
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +21 -12
- package/dist/i18n/locales/zh.js +279 -7
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +21 -12
- package/dist/i18n/review.js +50 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +31 -4
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -1
- package/dist/landing/access.d.ts +48 -0
- package/dist/landing/access.js +38 -0
- package/dist/landing/access.js.map +1 -0
- package/dist/landing/landing-actions.d.ts +138 -0
- package/dist/landing/landing-actions.js +39 -0
- package/dist/landing/landing-actions.js.map +1 -0
- package/dist/landing/landing-labels.d.ts +43 -0
- package/dist/landing/landing-labels.js +23 -0
- package/dist/landing/landing-labels.js.map +1 -0
- package/dist/landing/landing-sections.d.ts +218 -0
- package/dist/landing/landing-sections.js +167 -0
- package/dist/landing/landing-sections.js.map +1 -0
- package/dist/landing/page-seo.d.ts +99 -0
- package/dist/landing/page-seo.js +121 -0
- package/dist/landing/page-seo.js.map +1 -0
- package/dist/landing/public-header.d.ts +148 -0
- package/dist/landing/public-header.js +49 -0
- package/dist/landing/public-header.js.map +1 -0
- package/dist/landing/routing.d.ts +218 -0
- package/dist/landing/routing.js +81 -0
- package/dist/landing/routing.js.map +1 -0
- package/dist/rhf/fields.d.ts +21 -12
- package/dist/rhf/form.d.ts +21 -12
- package/dist/rhf.d.ts +21 -12
- package/dist/settings/settings-catalogue.d.ts +84 -0
- package/dist/settings/settings-catalogue.js +47 -0
- package/dist/settings/settings-catalogue.js.map +1 -0
- package/dist/settings/settings-context.d.ts +31 -0
- package/dist/settings/settings-context.js +11 -0
- package/dist/settings/settings-context.js.map +1 -0
- package/dist/settings/settings-heading.d.ts +50 -0
- package/dist/settings/settings-heading.js +27 -0
- package/dist/settings/settings-heading.js.map +1 -0
- package/dist/settings/settings-labels.d.ts +41 -0
- package/dist/settings/settings-labels.js +38 -0
- package/dist/settings/settings-labels.js.map +1 -0
- package/dist/settings/settings-layout.d.ts +77 -0
- package/dist/settings/settings-layout.js +242 -0
- package/dist/settings/settings-layout.js.map +1 -0
- package/dist/settings/settings-section.d.ts +133 -0
- package/dist/settings/settings-section.js +63 -0
- package/dist/settings/settings-section.js.map +1 -0
- package/dist/settings/use-account-language.d.ts +83 -0
- package/dist/settings/use-account-language.js +62 -0
- package/dist/settings/use-account-language.js.map +1 -0
- package/dist/settings/use-settings-focus.d.ts +34 -0
- package/dist/settings/use-settings-focus.js +77 -0
- package/dist/settings/use-settings-focus.js.map +1 -0
- package/dist/settings/use-settings-route.d.ts +118 -0
- package/dist/settings/use-settings-route.js +120 -0
- package/dist/settings/use-settings-route.js.map +1 -0
- package/dist/shell/app-shell.d.ts +27 -12
- package/dist/shell/app-shell.js +3 -2
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/top-bar-brand.d.ts +21 -12
- package/dist/shell.d.ts +25 -6
- package/dist/shell.js +56 -0
- package/dist/shell.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +21 -12
- package/dist/wizard.d.ts +21 -12
- package/package.json +2 -1
- package/src/account/account-parts.ts +67 -0
- package/src/account/data-export-setting.tsx +219 -0
- package/src/account/delete-account-setting.tsx +363 -0
- package/src/account/email-change-setting.tsx +454 -0
- package/src/account/sessions-setting.tsx +284 -0
- package/src/admin/admin-action-confirm.tsx +495 -0
- package/src/admin/admin-action-log.tsx +297 -0
- package/src/admin/admin-parts.ts +82 -0
- package/src/admin/invitations-panel.tsx +523 -0
- package/src/admin/reviewer-scope-editor.tsx +234 -0
- package/src/admin/role-select.tsx +286 -0
- package/src/admin/transfer-ownership-dialog.tsx +172 -0
- package/src/admin/user-roster.tsx +573 -0
- package/src/auth/auth-errors.ts +196 -8
- package/src/auth/forgot-password-form.tsx +21 -5
- package/src/auth/register-form.tsx +24 -6
- package/src/auth/reset-password-form.tsx +19 -5
- package/src/auth/sign-in-form.tsx +241 -38
- package/src/auth/verify-email.tsx +59 -8
- package/src/components/account-chips.tsx +64 -7
- package/src/components/account-settings-labels.ts +28 -1
- package/src/components/account-settings.tsx +231 -34
- package/src/components/data-table.tsx +8 -0
- package/src/components/date-picker.tsx +9 -4
- package/src/components/passkeys-setting.tsx +2 -1
- package/src/components/settings-fields.tsx +50 -1
- package/src/demo/demo-banner.tsx +159 -0
- package/src/demo/demo-countdown.ts +141 -0
- package/src/demo/demo-ended.tsx +89 -0
- package/src/demo/demo-labels.ts +78 -0
- package/src/demo/demo-session.ts +32 -0
- package/src/demo/demo-start.tsx +150 -0
- package/src/feedback/feedback-inbox.tsx +21 -8
- package/src/feedback/feedback-labels.ts +2 -1
- package/src/feedback/feedback-record.ts +10 -7
- package/src/feedback/feedback-row-detail.tsx +28 -4
- package/src/feedback/feedback-swipe.tsx +1 -1
- package/src/hooks/use-anchored-panel.ts +14 -3
- package/src/i18n/defaults.ts +28 -0
- package/src/i18n/german.ts +327 -5
- package/src/i18n/kit-labels.tsx +42 -0
- package/src/i18n/locales/es.ts +313 -3
- package/src/i18n/locales/fr.ts +316 -0
- package/src/i18n/locales/hu.ts +308 -5
- package/src/i18n/locales/it.ts +313 -0
- package/src/i18n/locales/zh.ts +286 -0
- package/src/i18n/review.ts +49 -0
- package/src/index.ts +184 -0
- package/src/landing/access.ts +97 -0
- package/src/landing/landing-actions.tsx +92 -0
- package/src/landing/landing-labels.ts +59 -0
- package/src/landing/landing-sections.tsx +337 -0
- package/src/landing/page-seo.ts +251 -0
- package/src/landing/public-header.tsx +120 -0
- package/src/landing/routing.tsx +209 -0
- package/src/settings/settings-catalogue.ts +132 -0
- package/src/settings/settings-context.ts +33 -0
- package/src/settings/settings-heading.tsx +65 -0
- package/src/settings/settings-labels.ts +75 -0
- package/src/settings/settings-layout.tsx +379 -0
- package/src/settings/settings-section.tsx +108 -0
- package/src/settings/use-account-language.ts +146 -0
- package/src/settings/use-settings-focus.ts +120 -0
- package/src/settings/use-settings-route.ts +258 -0
- package/src/shell/app-shell.tsx +10 -2
- package/src/shell.ts +45 -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
|
+
}
|