@eifi1/ui-kit 0.30.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 +42 -5
- package/dist/account/data-export-setting.js +3 -2
- package/dist/account/data-export-setting.js.map +1 -1
- package/dist/account/delete-account-setting.d.ts +5 -1
- package/dist/account/delete-account-setting.js +3 -2
- package/dist/account/delete-account-setting.js.map +1 -1
- package/dist/account/email-change-setting.js +4 -3
- package/dist/account/email-change-setting.js.map +1 -1
- package/dist/account/sessions-setting.js +3 -2
- package/dist/account/sessions-setting.js.map +1 -1
- package/dist/admin/admin-action-confirm.d.ts +5 -1
- package/dist/admin/invitations-panel.d.ts +5 -1
- package/dist/admin/invitations-panel.js +3 -2
- package/dist/admin/invitations-panel.js.map +1 -1
- package/dist/admin/reviewer-scope-editor.d.ts +5 -1
- package/dist/admin/role-select.d.ts +5 -1
- package/dist/admin/transfer-ownership-dialog.d.ts +5 -1
- package/dist/admin/user-roster.d.ts +5 -1
- package/dist/auth/auth-errors.d.ts +24 -6
- package/dist/auth/auth-errors.js +7 -1
- package/dist/auth/auth-errors.js.map +1 -1
- package/dist/auth/register-form.d.ts +5 -1
- package/dist/auth/sign-in-form.d.ts +15 -1
- package/dist/auth/sign-in-form.js +12 -3
- package/dist/auth/sign-in-form.js.map +1 -1
- package/dist/chart.d.ts +5 -1
- package/dist/components/account-settings-labels.d.ts +12 -0
- package/dist/components/account-settings-labels.js +2 -1
- package/dist/components/account-settings-labels.js.map +1 -1
- package/dist/components/account-settings.d.ts +8 -2
- package/dist/components/account-settings.js +19 -11
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +5 -1
- package/dist/components/button-group.d.ts +5 -1
- package/dist/components/calculator.d.ts +5 -1
- package/dist/components/confirm-dialog.d.ts +5 -1
- package/dist/components/copy-button.d.ts +5 -1
- package/dist/components/danger-confirm.d.ts +5 -1
- 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 +5 -1
- package/dist/components/file-button.d.ts +5 -1
- package/dist/components/file-dropzone.d.ts +5 -1
- package/dist/components/form-actions.d.ts +5 -1
- package/dist/components/iban-input.d.ts +5 -1
- package/dist/components/language-select.d.ts +5 -1
- package/dist/components/legal-page.d.ts +5 -1
- package/dist/components/legal.d.ts +5 -1
- package/dist/components/money-field.d.ts +5 -1
- package/dist/components/number-field.d.ts +5 -1
- package/dist/components/number-input.d.ts +5 -1
- package/dist/components/numpad-sheet.d.ts +5 -1
- package/dist/components/passkeys-setting.js +2 -1
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/phone-input.d.ts +5 -1
- package/dist/components/series-chart.d.ts +5 -1
- package/dist/components/settings-fields.d.ts +40 -3
- package/dist/components/settings-fields.js +20 -1
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/share-card.d.ts +5 -1
- package/dist/components/text-link.d.ts +5 -1
- package/dist/components/time-input.d.ts +5 -1
- package/dist/components/ui.d.ts +5 -1
- package/dist/{delete-account-setting-CGVMckMw.d.ts → delete-account-setting-DCFS5Vof.d.ts} +52 -23
- 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 +5 -1
- package/dist/feedback/feedback-context.d.ts +5 -1
- package/dist/feedback/feedback-crash.d.ts +5 -1
- package/dist/feedback/feedback-dialog.d.ts +5 -1
- package/dist/feedback/feedback-inbox.d.ts +5 -1
- package/dist/feedback/feedback-inbox.js +12 -2
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-labels.d.ts +5 -1
- package/dist/feedback/feedback-labels.js +1 -0
- package/dist/feedback/feedback-labels.js.map +1 -1
- package/dist/feedback/feedback-menu.d.ts +5 -1
- package/dist/feedback/feedback-record.d.ts +5 -1
- 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 +5 -1
- 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 +5 -1
- package/dist/feedback/feedback-submit.d.ts +5 -1
- package/dist/feedback/feedback-swipe.d.ts +6 -2
- package/dist/feedback/feedback-swipe.js.map +1 -1
- package/dist/feedback/feedback-table.d.ts +5 -1
- package/dist/feedback/feedback-thread.d.ts +5 -1
- package/dist/feedback.d.ts +5 -1
- package/dist/hooks/use-file-drop.d.ts +5 -1
- package/dist/i18n/defaults.d.ts +5 -1
- package/dist/i18n/defaults.js +6 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +5 -1
- package/dist/i18n/german.js +84 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +5 -1
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +5 -1
- package/dist/i18n/locales/de-CH.d.ts +5 -1
- package/dist/i18n/locales/en.d.ts +5 -1
- package/dist/i18n/locales/es.d.ts +5 -1
- package/dist/i18n/locales/es.js +81 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +5 -1
- package/dist/i18n/locales/fr.js +82 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +5 -1
- package/dist/i18n/locales/hu.js +82 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +5 -1
- package/dist/i18n/locales/it.js +81 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +5 -1
- package/dist/i18n/locales/zh.js +80 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +5 -1
- package/dist/i18n/review.js +14 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +25 -3
- package/dist/index.js +95 -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 +5 -1
- package/dist/rhf/form.d.ts +5 -1
- package/dist/rhf.d.ts +5 -1
- 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 +11 -1
- 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 +5 -1
- package/dist/shell.d.ts +15 -1
- package/dist/shell.js +56 -0
- package/dist/shell.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +5 -1
- package/dist/wizard.d.ts +5 -1
- package/package.json +2 -1
- package/src/account/data-export-setting.tsx +3 -2
- package/src/account/delete-account-setting.tsx +3 -2
- package/src/account/email-change-setting.tsx +4 -7
- package/src/account/sessions-setting.tsx +3 -2
- package/src/admin/invitations-panel.tsx +11 -4
- package/src/auth/auth-errors.ts +36 -5
- package/src/auth/sign-in-form.tsx +34 -3
- package/src/components/account-settings-labels.ts +13 -0
- package/src/components/account-settings.tsx +33 -12
- 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/i18n/defaults.ts +6 -0
- package/src/i18n/german.ts +86 -0
- package/src/i18n/kit-labels.tsx +9 -0
- package/src/i18n/locales/es.ts +83 -0
- package/src/i18n/locales/fr.ts +84 -0
- package/src/i18n/locales/hu.ts +84 -0
- package/src/i18n/locales/it.ts +83 -0
- package/src/i18n/locales/zh.ts +81 -0
- package/src/i18n/review.ts +13 -0
- package/src/index.ts +92 -1
- 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,120 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { Button, PHONE_QUERY } from "../components/ui";
|
|
4
|
+
import { useMediaQuery } from "../hooks/use-media-query";
|
|
5
|
+
import { TopBar } from "../shell/top-bar";
|
|
6
|
+
import type { TopBarProps } from "../shell/top-bar";
|
|
7
|
+
import { TopBarBrand } from "../shell/top-bar-brand";
|
|
8
|
+
import { useAccessAction } from "./access";
|
|
9
|
+
import type { AccessChoice } from "./access";
|
|
10
|
+
import { useLandingLabels } from "./landing-labels";
|
|
11
|
+
import type { LandingLabels } from "./landing-labels";
|
|
12
|
+
import type { LandingSession } from "./landing-actions";
|
|
13
|
+
|
|
14
|
+
/** The header's brand: what `TopBarBrand` draws. */
|
|
15
|
+
export interface PublicHeaderBrand {
|
|
16
|
+
/** The brand mark — an SVG or an `<img alt="">`. */
|
|
17
|
+
logo: ReactNode;
|
|
18
|
+
/** The product name beside it, and the link's accessible name. */
|
|
19
|
+
name: ReactNode;
|
|
20
|
+
/** See `TopBarBrand`. Default `sm`: the logo alone on a phone. */
|
|
21
|
+
collapseBelow?: "sm" | "md" | false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PublicHeaderProps extends Omit<TopBarProps, "brand" | "actions" | "children"> {
|
|
25
|
+
brand: PublicHeaderBrand;
|
|
26
|
+
/** Where the brand links. Default `/` without a session and `/welcome` with one —
|
|
27
|
+
* `/` would resume into the app, `/welcome` is always the landing (§3.1). */
|
|
28
|
+
homeHref?: string;
|
|
29
|
+
session: LandingSession;
|
|
30
|
+
access: AccessChoice;
|
|
31
|
+
/** "Sign in". Default `/login`. */
|
|
32
|
+
signInHref?: string;
|
|
33
|
+
/** "Open app" for a real session — the resume target. Default `/`, which resumes
|
|
34
|
+
* through `RootEntry`. */
|
|
35
|
+
openAppHref?: string;
|
|
36
|
+
/** "Continue the demo" for a demo session. Default `/demo`, which continues a live
|
|
37
|
+
* demo (§3.1). */
|
|
38
|
+
demoHref?: string;
|
|
39
|
+
/** The app's `LanguageMenu`, before the actions. */
|
|
40
|
+
languageMenu?: ReactNode;
|
|
41
|
+
/** The app's `ThemeToggle`, before the language menu. */
|
|
42
|
+
themeToggle?: ReactNode;
|
|
43
|
+
labels?: Partial<LandingLabels>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The header of every public page (docs/landing-demo-harmonization.md §4.1): the
|
|
48
|
+
* landing, the auth pages, the legal pages and the 404 — keksdose's `LandingHeader`, on
|
|
49
|
+
* the kit's `TopBar` and `TopBarBrand`, with its hard-coded teal turned into `--brand`.
|
|
50
|
+
*
|
|
51
|
+
* | session | actions |
|
|
52
|
+
* |---|---|
|
|
53
|
+
* | none | Sign in (secondary, hidden below `sm`), **Request access** (primary) |
|
|
54
|
+
* | demo | Continue the demo (secondary), **Request access** (primary) |
|
|
55
|
+
* | user | **Open app** (primary) |
|
|
56
|
+
*
|
|
57
|
+
* A demo session is not "signed in" here: it is still offered access, which is the point
|
|
58
|
+
* of the demo (§5.6). On a phone (`PHONE_QUERY`) the actions take the kit's `sm` size, so
|
|
59
|
+
* the brand mark, the two controls and two actions fit 390px; "Sign in" gives way below
|
|
60
|
+
* `sm` as in keksdose — the sign-in page is one tap away from "Request access" anyway.
|
|
61
|
+
*/
|
|
62
|
+
export function PublicHeader({
|
|
63
|
+
brand,
|
|
64
|
+
homeHref,
|
|
65
|
+
session,
|
|
66
|
+
access,
|
|
67
|
+
signInHref = "/login",
|
|
68
|
+
openAppHref = "/",
|
|
69
|
+
demoHref = "/demo",
|
|
70
|
+
languageMenu,
|
|
71
|
+
themeToggle,
|
|
72
|
+
labels: labelsProp,
|
|
73
|
+
className,
|
|
74
|
+
...rest
|
|
75
|
+
}: PublicHeaderProps) {
|
|
76
|
+
const labels = useLandingLabels(labelsProp);
|
|
77
|
+
const accessLink = useAccessAction(access, labelsProp);
|
|
78
|
+
const phone = useMediaQuery(PHONE_QUERY, false);
|
|
79
|
+
const size = phone ? "sm" : "md";
|
|
80
|
+
const home = homeHref ?? (session === "none" ? "/" : "/welcome");
|
|
81
|
+
|
|
82
|
+
const actions =
|
|
83
|
+
session === "user" ? (
|
|
84
|
+
<Button href={openAppHref} variant="brand" size={size}>
|
|
85
|
+
{labels.openApp}
|
|
86
|
+
</Button>
|
|
87
|
+
) : (
|
|
88
|
+
<>
|
|
89
|
+
{session === "demo" ? (
|
|
90
|
+
<Button href={demoHref} variant="secondary" size={size}>
|
|
91
|
+
{labels.continueDemo}
|
|
92
|
+
</Button>
|
|
93
|
+
) : (
|
|
94
|
+
<Button href={signInHref} variant="secondary" size={size} className="max-sm:hidden">
|
|
95
|
+
{labels.signIn}
|
|
96
|
+
</Button>
|
|
97
|
+
)}
|
|
98
|
+
<Button href={accessLink.href} variant="brand" size={size}>
|
|
99
|
+
{accessLink.label}
|
|
100
|
+
</Button>
|
|
101
|
+
</>
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<TopBar
|
|
106
|
+
{...rest}
|
|
107
|
+
className={className}
|
|
108
|
+
brand={<TopBarBrand logo={brand.logo} name={brand.name} to={home} collapseBelow={brand.collapseBelow} />}
|
|
109
|
+
actions={
|
|
110
|
+
<>
|
|
111
|
+
{themeToggle}
|
|
112
|
+
{languageMenu}
|
|
113
|
+
<span data-slot="public-header-actions" className="ms-1 flex items-center gap-1.5 sm:gap-2">
|
|
114
|
+
{actions}
|
|
115
|
+
</span>
|
|
116
|
+
</>
|
|
117
|
+
}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Navigate, Outlet, useLocation } from "react-router";
|
|
4
|
+
|
|
5
|
+
import { readStored, writeStored } from "../lib/safe-storage";
|
|
6
|
+
import type { LandingSession } from "./landing-actions";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The public routes' logic (docs/landing-demo-harmonization.md §3): what `/` shows,
|
|
10
|
+
* which pages a session skips, and the page a session resumes on — keksdose's
|
|
11
|
+
* `RootEntry`, `RedirectIfAuthed` and use-last-visited-page.ts, for every app.
|
|
12
|
+
* Router-aware (react-router), so they belong to the `./shell` entry.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/* ── ?next= ──────────────────────────────────────────────────────────────── */
|
|
16
|
+
|
|
17
|
+
/** The query parameter that carries "where I was actually trying to go" through the
|
|
18
|
+
* sign-in bounce (keksdose's `NEXT_PARAM`). */
|
|
19
|
+
export const NEXT_PARAM = "next";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A `?next=` value that is safe to navigate to, or null.
|
|
23
|
+
*
|
|
24
|
+
* Same-origin PATHS only, decided by the URL PARSER rather than by string prefix —
|
|
25
|
+
* this is the open-redirect check, and it is the reason this lives in one place
|
|
26
|
+
* instead of at each call site.
|
|
27
|
+
*
|
|
28
|
+
* The prefix version ("starts with `/`, does not start with `//`") let two shapes
|
|
29
|
+
* through that the browser resolves cross-origin, because the WHATWG parser does not
|
|
30
|
+
* read them as text: a backslash after the leading slash counts as a second slash for
|
|
31
|
+
* a special scheme, so `/\evil.example` resolves to `https://evil.example/`; and tab,
|
|
32
|
+
* LF and CR are STRIPPED anywhere in the input before parsing, so `/<TAB>/evil.example`
|
|
33
|
+
* becomes `//evil.example`. Asking the same parser the browser will use closes every
|
|
34
|
+
* such shape at once, including ones nobody has thought of yet, and it normalises what
|
|
35
|
+
* comes back instead of echoing the caller's spelling.
|
|
36
|
+
*
|
|
37
|
+
* keksdose's (shared/hooks/use-last-visited-page.ts), verbatim but for the fallback
|
|
38
|
+
* origin.
|
|
39
|
+
*/
|
|
40
|
+
export function safeNextPath(raw: string | null | undefined): string | null {
|
|
41
|
+
if (!raw) return null;
|
|
42
|
+
const base = typeof window !== "undefined" ? window.location.origin : "https://kit.invalid";
|
|
43
|
+
try {
|
|
44
|
+
const url = new URL(raw, base);
|
|
45
|
+
if (url.origin !== base) return null;
|
|
46
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ── The last visited page (§3.2) ────────────────────────────────────────── */
|
|
53
|
+
|
|
54
|
+
/** A path to leave out: a string is the path and everything under it (`/demo` is
|
|
55
|
+
* `/demo` and `/demo/ended`; `/` is only `/`), a RegExp is tested on the pathname. */
|
|
56
|
+
export type PathPattern = string | RegExp;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The pages never recorded as a resume target (§3.2): the landing (`/`, `/welcome`), the
|
|
60
|
+
* auth pages, the demo's own (`/demo*`) and the legal pages. The 404 has no path of its
|
|
61
|
+
* own — call {@link useLastVisitedPage} inside the signed-in layout, as keksdose does,
|
|
62
|
+
* and it never sees one. The app adds its own one-shot routes (keksdose:
|
|
63
|
+
* `/banks/callback`, `/join`, `/share-target`) through `exclude`.
|
|
64
|
+
*/
|
|
65
|
+
export const DEFAULT_LAST_VISITED_EXCLUDES: readonly PathPattern[] = Object.freeze([
|
|
66
|
+
"/",
|
|
67
|
+
"/welcome",
|
|
68
|
+
"/login",
|
|
69
|
+
"/register",
|
|
70
|
+
"/forgot-password",
|
|
71
|
+
"/reset-password",
|
|
72
|
+
"/verify-email",
|
|
73
|
+
"/demo",
|
|
74
|
+
"/impressum",
|
|
75
|
+
"/privacy",
|
|
76
|
+
"/terms",
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
function matchesPath(pathname: string, pattern: PathPattern): boolean {
|
|
80
|
+
if (typeof pattern !== "string") return new RegExp(pattern.source, pattern.flags.replace("g", "")).test(pathname);
|
|
81
|
+
if (pattern === "/") return pathname === "/";
|
|
82
|
+
const base = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
|
|
83
|
+
return pathname === base || pathname.startsWith(`${base}/`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether `pathname` is one of the defaults or the app's `exclude`. */
|
|
87
|
+
function isExcluded(pathname: string, exclude: readonly PathPattern[]): boolean {
|
|
88
|
+
return [...DEFAULT_LAST_VISITED_EXCLUDES, ...exclude].some((pattern) => matchesPath(pathname, pattern));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface LastVisitedPageOptions {
|
|
92
|
+
/** The device key: `<app>.lastVisitedPage` (§3.2), one per app on the origin. */
|
|
93
|
+
key: string;
|
|
94
|
+
/** The app's own paths to leave out, ON TOP of {@link DEFAULT_LAST_VISITED_EXCLUDES}. */
|
|
95
|
+
exclude?: readonly PathPattern[];
|
|
96
|
+
/** `false` records nothing — while the session is still being restored, say. */
|
|
97
|
+
enabled?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Remember the page a session is on (path and query), on every navigation, so `/` — a
|
|
102
|
+
* revisit, a fresh sign-in, the installed PWA, whose `start_url` is `/` — resumes there
|
|
103
|
+
* (§3.2, keksdose feedback #59). Device-local, in `localStorage` under `key`; a blocked
|
|
104
|
+
* storage records nothing.
|
|
105
|
+
*
|
|
106
|
+
* Call it once in the signed-in layout. The excluded paths are never written, so a
|
|
107
|
+
* one-shot page (a bank callback, an invitation link) never becomes the place the next
|
|
108
|
+
* launch opens on.
|
|
109
|
+
*/
|
|
110
|
+
export function useLastVisitedPage({ key, exclude = [], enabled = true }: LastVisitedPageOptions): void {
|
|
111
|
+
const { pathname, search } = useLocation();
|
|
112
|
+
// Decided here, so the effect depends on the answer and not on an `exclude` array the
|
|
113
|
+
// caller likely builds inline.
|
|
114
|
+
const skip = !enabled || isExcluded(pathname, exclude);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (skip) return;
|
|
117
|
+
writeStored(key, pathname + search);
|
|
118
|
+
}, [key, pathname, search, skip]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The page {@link useLastVisitedPage} remembered under `key` — checked by
|
|
123
|
+
* {@link safeNextPath}, so a value someone planted in storage cannot leave the origin —
|
|
124
|
+
* or `fallback` (default `null`) when there is none or storage is blocked. A string
|
|
125
|
+
* fallback narrows the answer to a string, for react-router's `to`.
|
|
126
|
+
*/
|
|
127
|
+
export function readLastVisitedPage(key: string, fallback: string): string;
|
|
128
|
+
export function readLastVisitedPage(key: string, fallback?: null): string | null;
|
|
129
|
+
export function readLastVisitedPage(key: string, fallback: string | null = null): string | null {
|
|
130
|
+
return safeNextPath(readStored(key)) ?? fallback;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Forget the remembered page — on an explicit sign-out, so the next account does not
|
|
134
|
+
* resume on the previous one's page. */
|
|
135
|
+
export function clearLastVisitedPage(key: string): void {
|
|
136
|
+
if (typeof window === "undefined") return;
|
|
137
|
+
try {
|
|
138
|
+
window.localStorage.removeItem(key);
|
|
139
|
+
} catch {
|
|
140
|
+
// storage blocked: there is nothing to forget
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* ── RootEntry ───────────────────────────────────────────────────────────── */
|
|
145
|
+
|
|
146
|
+
export interface RootEntryProps {
|
|
147
|
+
/** Without a session `/` is the landing; with one (real or demo) it resumes. */
|
|
148
|
+
session: LandingSession;
|
|
149
|
+
/** The remembered page — `readLastVisitedPage(key)`. */
|
|
150
|
+
resumePath?: string | null;
|
|
151
|
+
/** The landing page element. */
|
|
152
|
+
landing: ReactNode;
|
|
153
|
+
/** The app's home: where a session goes with nothing to resume (keksdose `/budget`,
|
|
154
|
+
* kastlan and Kurvenschmiede `/dashboard`). */
|
|
155
|
+
home: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The `/` route (§3.1): the landing for a visitor, and for a session — real or demo —
|
|
160
|
+
* the last visited page, else the app's home. Indexed and canonical `/` for the visitor;
|
|
161
|
+
* a crawler never has a session, so for it `/` is stable.
|
|
162
|
+
*
|
|
163
|
+
* keksdose showed a session with nothing remembered the landing; the contract sends it
|
|
164
|
+
* home instead, because a session at `/` came to use the app. The landing stays one tap
|
|
165
|
+
* away at `/welcome`, where the signed-in brand link points.
|
|
166
|
+
*/
|
|
167
|
+
export function RootEntry({ session, resumePath, landing, home }: RootEntryProps) {
|
|
168
|
+
if (session === "none") return <>{landing}</>;
|
|
169
|
+
const resume = safeNextPath(resumePath);
|
|
170
|
+
return <Navigate to={resume && resume !== "/" ? resume : home} replace />;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* ── RedirectIfAuthed ────────────────────────────────────────────────────── */
|
|
174
|
+
|
|
175
|
+
export interface RedirectIfAuthedProps {
|
|
176
|
+
session: LandingSession;
|
|
177
|
+
/**
|
|
178
|
+
* Let a DEMO session through (§5.6): on `/login`, `/register` and the app's invitation
|
|
179
|
+
* pages, so a demo visitor can sign in or accept an invitation — a successful one
|
|
180
|
+
* replaces the demo session. Default `false`: on `/demo` a live demo continues instead
|
|
181
|
+
* of starting a second one (§5.2), and on `/forgot-password` a demo has no password.
|
|
182
|
+
*/
|
|
183
|
+
allowDemo?: boolean;
|
|
184
|
+
/** Where a redirected session goes: the resume target —
|
|
185
|
+
* `readLastVisitedPage(key, home)`. A safe `?next=` goes first. */
|
|
186
|
+
fallback: string;
|
|
187
|
+
/** The page. Left out, an `<Outlet />`: the component is a layout route around the
|
|
188
|
+
* public pages, as in keksdose. */
|
|
189
|
+
children?: ReactNode;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Public pages a session skips (§3.1): a signed-in visitor at `/login` goes back into
|
|
194
|
+
* the app, to a safe `?next=` when the URL carries one (a share invitation or a push
|
|
195
|
+
* deep link opened with a live session in another tab — keksdose) and else to
|
|
196
|
+
* `fallback`.
|
|
197
|
+
*
|
|
198
|
+
* A demo session passes where `allowDemo` says so (§5.6). Mount one wrapper with
|
|
199
|
+
* `allowDemo` around `/login`, `/register` and the invitation pages, and one without
|
|
200
|
+
* around `/demo` (and `/forgot-password`).
|
|
201
|
+
*/
|
|
202
|
+
export function RedirectIfAuthed({ session, allowDemo = false, fallback, children }: RedirectIfAuthedProps) {
|
|
203
|
+
const { search } = useLocation();
|
|
204
|
+
if (session === "none" || (session === "demo" && allowDemo)) {
|
|
205
|
+
return children === undefined ? <Outlet /> : <>{children}</>;
|
|
206
|
+
}
|
|
207
|
+
const next = safeNextPath(new URLSearchParams(search).get(NEXT_PARAM));
|
|
208
|
+
return <Navigate to={next ?? fallback} replace />;
|
|
209
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { SearchEntry } from "../search/search-index";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The settings catalogue (docs/settings-harmonization.md §4.1), lifted from keksdose's
|
|
6
|
+
* `settings-index.ts`: the groups a settings page is split into, and one entry per
|
|
7
|
+
* SETTING — not per card — so a search for "quiet hours" finds the field inside the
|
|
8
|
+
* notifications card. One list feeds the sidebar, the phone list, the in-page search and
|
|
9
|
+
* ⌘K ({@link settingsSearchEntries}); keksdose's page and its palette drifted when they
|
|
10
|
+
* had one each.
|
|
11
|
+
*
|
|
12
|
+
* The kit resolves no words: `title`, `help` and `keywords` arrive translated (an app's
|
|
13
|
+
* own, or the core groups' from `DEFAULT_SETTINGS_LABELS.groups`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** One group of the page: a sidebar row on a desktop, a list row on a phone (§3.2, §3.3). */
|
|
17
|
+
export interface SettingsGroup<G extends string = string> {
|
|
18
|
+
/** The path segment, `/settings/<id>`: lowercase, a–z and "-". */
|
|
19
|
+
id: G;
|
|
20
|
+
/** The row's icon — a lucide icon at the row's size is sized by the row. */
|
|
21
|
+
icon: ReactNode;
|
|
22
|
+
/** The group's name: the sidebar row, the heading over its cards, the phone list. */
|
|
23
|
+
title: string;
|
|
24
|
+
/** One line under the heading and in the phone list, where it is cut to one line. */
|
|
25
|
+
help: string;
|
|
26
|
+
/**
|
|
27
|
+
* `false` hides the group: no row, no hits, and its path acts as an unknown one — it
|
|
28
|
+
* lands on the default group (desktop) or the list (phone), never on a 404 (§3.1).
|
|
29
|
+
* kastlan's `/admin/billing` while billing is off. Default: visible.
|
|
30
|
+
*/
|
|
31
|
+
visible?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One setting, and the card that holds it (§4.1). */
|
|
35
|
+
export interface SettingsEntry<G extends string = string> {
|
|
36
|
+
/** Unique within the catalogue. */
|
|
37
|
+
id: string;
|
|
38
|
+
/** The group whose page shows the card. */
|
|
39
|
+
group: G;
|
|
40
|
+
/** The card's DOM id, unique on the page — the `?focus=` target. Several entries may
|
|
41
|
+
* share one: a card that holds four settings has four entries. */
|
|
42
|
+
anchor: string;
|
|
43
|
+
/** What a search hit says. */
|
|
44
|
+
title: string;
|
|
45
|
+
/** Localized, space-separated words people type that the title does not contain
|
|
46
|
+
* ("dark night" for the theme, "2fa totp" for two-factor). Never shown. */
|
|
47
|
+
keywords?: string;
|
|
48
|
+
/**
|
|
49
|
+
* `false` drops the entry from the page's search and ⌘K. A group whose entries are
|
|
50
|
+
* ALL hidden is hidden with them (§4.1): a demo session marks every card it is refused
|
|
51
|
+
* (passkeys, 2FA, export, deletion …) and so never sees a page of cards that each
|
|
52
|
+
* answer 403. Default: visible.
|
|
53
|
+
*/
|
|
54
|
+
visible?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The groups that show, in their order: not `visible: false`, and — when `entries` is
|
|
59
|
+
* given — holding at least one visible entry (§4.1: Kurvenschmiede's notifications group
|
|
60
|
+
* stays hidden until it has a card). Without `entries` only the groups' own flag counts.
|
|
61
|
+
*/
|
|
62
|
+
export function visibleSettingsGroups<G extends string>(
|
|
63
|
+
groups: readonly SettingsGroup<G>[],
|
|
64
|
+
entries?: readonly SettingsEntry<G>[],
|
|
65
|
+
): SettingsGroup<G>[] {
|
|
66
|
+
if (!entries) return groups.filter((g) => g.visible !== false);
|
|
67
|
+
const filled = new Set<G>();
|
|
68
|
+
for (const e of entries) if (e.visible !== false) filled.add(e.group);
|
|
69
|
+
return groups.filter((g) => g.visible !== false && filled.has(g.id));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The visible entries of the visible groups — what search may find. */
|
|
73
|
+
export function visibleSettingsEntries<G extends string>(
|
|
74
|
+
groups: readonly SettingsGroup<G>[],
|
|
75
|
+
entries: readonly SettingsEntry<G>[],
|
|
76
|
+
): SettingsEntry<G>[] {
|
|
77
|
+
const shown = new Set(visibleSettingsGroups(groups, entries).map((g) => g.id));
|
|
78
|
+
return entries.filter((e) => e.visible !== false && shown.has(e.group));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `basePath` without a trailing slash: "/settings/" and "/settings" are one page. */
|
|
82
|
+
export function normalizeSettingsBase(basePath: string): string {
|
|
83
|
+
const trimmed = basePath.replace(/\/+$/, "");
|
|
84
|
+
return trimmed.startsWith("/") || trimmed === "" ? trimmed : `/${trimmed}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `/settings/<group>`, and `?focus=<anchor>` when a card is named (§3.4). */
|
|
88
|
+
export function settingsHref(basePath: string, group: string, anchor?: string): string {
|
|
89
|
+
const path = `${normalizeSettingsBase(basePath)}/${group}`;
|
|
90
|
+
return anchor ? `${path}?focus=${encodeURIComponent(anchor)}` : path;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** "dark night" → ["dark", "night"]. */
|
|
94
|
+
export function settingsKeywords(keywords: string | undefined): string[] {
|
|
95
|
+
return keywords ? keywords.split(/\s+/).filter(Boolean) : [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface SettingsSearchEntriesOptions {
|
|
99
|
+
/** `/settings` or `/admin` — the hits link to `<basePath>/<group>?focus=<anchor>`. */
|
|
100
|
+
basePath: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The catalogue as {@link GlobalSearch} entries (§3.4): each visible setting, under its
|
|
105
|
+
* group's name as the palette's section, linking to `/settings/<group>?focus=<anchor>` —
|
|
106
|
+
* the same link the page's own search hit is, so ⌘K, a tour and a mail all land the
|
|
107
|
+
* same way (§3.5). The group's name is a keyword too, as on the page: "security" lists
|
|
108
|
+
* the security group's settings.
|
|
109
|
+
*
|
|
110
|
+
* Ids are prefixed with the base path ("settings:theme", "admin:users") so a palette
|
|
111
|
+
* that also holds pages and actions keeps them unique.
|
|
112
|
+
*/
|
|
113
|
+
export function settingsSearchEntries<G extends string>(
|
|
114
|
+
groups: readonly SettingsGroup<G>[],
|
|
115
|
+
entries: readonly SettingsEntry<G>[],
|
|
116
|
+
options: SettingsSearchEntriesOptions,
|
|
117
|
+
): SearchEntry[] {
|
|
118
|
+
const base = normalizeSettingsBase(options.basePath);
|
|
119
|
+
const prefix = base.replace(/^\//, "").replace(/\//g, ":") || "settings";
|
|
120
|
+
const byId = new Map(visibleSettingsGroups(groups, entries).map((g) => [g.id, g]));
|
|
121
|
+
return visibleSettingsEntries(groups, entries).map((e) => {
|
|
122
|
+
const group = byId.get(e.group)!;
|
|
123
|
+
return {
|
|
124
|
+
id: `${prefix}:${e.id}`,
|
|
125
|
+
title: e.title,
|
|
126
|
+
keywords: [group.title, ...settingsKeywords(e.keywords)],
|
|
127
|
+
group: group.title,
|
|
128
|
+
icon: group.icon,
|
|
129
|
+
href: settingsHref(base, e.group, e.anchor),
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import type { SettingsHeadingTag } from "./settings-heading";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What {@link SettingsLayout} hands the cards it renders (docs/settings-harmonization.md
|
|
6
|
+
* §3.1, §3.7). Router-free on purpose, so `SettingsSection` and an app's cards can read
|
|
7
|
+
* it without importing the layout.
|
|
8
|
+
*/
|
|
9
|
+
export interface SettingsLayoutContextValue {
|
|
10
|
+
/** `/settings` or `/admin`, without a trailing slash. */
|
|
11
|
+
basePath: string;
|
|
12
|
+
/** The group on screen; `null` on a phone's list. */
|
|
13
|
+
group: string | null;
|
|
14
|
+
/** The path segment after the group — the surface a card owns (keksdose's swipe card). */
|
|
15
|
+
sub: string | null;
|
|
16
|
+
/**
|
|
17
|
+
* Select the card's sub-surface: `/settings/<group>/<sub>`, or the group alone with
|
|
18
|
+
* `null`. A replace, keeping the query and the history marker: switching a tab inside a
|
|
19
|
+
* card is not a place to go back to. The card never builds the path itself (§3.1).
|
|
20
|
+
*/
|
|
21
|
+
selectSub: (sub: string | null) => void;
|
|
22
|
+
/** The level the cards' titles are at: `h3` on a desktop, `h2` on a phone. */
|
|
23
|
+
headingLevel: SettingsHeadingTag;
|
|
24
|
+
/** Whether the catalogue has an entry with this anchor — `SettingsSection`'s check. */
|
|
25
|
+
hasAnchor: (anchor: string) => boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const SettingsLayoutContext = createContext<SettingsLayoutContextValue | null>(null);
|
|
29
|
+
|
|
30
|
+
/** The {@link SettingsLayout} around this card, or `null` outside one. */
|
|
31
|
+
export function useSettingsLayout(): SettingsLayoutContextValue | null {
|
|
32
|
+
return useContext(SettingsLayoutContext);
|
|
33
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { CARD_TITLE_CLASS } from "../account/account-parts";
|
|
5
|
+
|
|
6
|
+
/** The heading a settings card's title becomes. */
|
|
7
|
+
export type SettingsHeadingTag = "h2" | "h3" | "h4";
|
|
8
|
+
|
|
9
|
+
const SettingsHeadingLevelContext = createContext<SettingsHeadingTag | null>(null);
|
|
10
|
+
|
|
11
|
+
export interface SettingsHeadingLevelProps {
|
|
12
|
+
/** The element every kit setting card below titles itself with. */
|
|
13
|
+
level: SettingsHeadingTag;
|
|
14
|
+
children?: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The level the kit's setting cards title themselves at (docs/settings-harmonization.md
|
|
19
|
+
* §3.7). Inside {@link SettingsLayout} a card's title is a heading under the group's —
|
|
20
|
+
* `h3` under the desktop's `h2`, `h2` under a phone group page's `h1` — and the layout
|
|
21
|
+
* sets this for every card it renders. Outside one, nothing is set and the same cards
|
|
22
|
+
* keep the plain `div` title they always had, so a card on an app's own page does not
|
|
23
|
+
* suddenly put an `h3` into an outline that has no `h2`.
|
|
24
|
+
*
|
|
25
|
+
* Set it yourself only where an app lays out settings cards without the layout and
|
|
26
|
+
* wants them in its outline: `<SettingsHeadingLevel level="h3">…</SettingsHeadingLevel>`.
|
|
27
|
+
* `ProfileSetting`, `PasswordSetting`, `TwoFactorSetting`, `PasskeysSetting`,
|
|
28
|
+
* `EmailChangeSetting`, `SessionsSetting`, `DataExportSetting`, `DeleteAccountSetting`
|
|
29
|
+
* and `SettingsSection` read it.
|
|
30
|
+
*/
|
|
31
|
+
export function SettingsHeadingLevel({ level, children }: SettingsHeadingLevelProps) {
|
|
32
|
+
return <SettingsHeadingLevelContext.Provider value={level}>{children}</SettingsHeadingLevelContext.Provider>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The nearest {@link SettingsHeadingLevel}, or `null` outside one. */
|
|
36
|
+
export function useSettingsHeadingLevel(): SettingsHeadingTag | null {
|
|
37
|
+
return useContext(SettingsHeadingLevelContext);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A setting card's title at the settings type scale: a heading inside a
|
|
42
|
+
* {@link SettingsHeadingLevel}, the plain `div` outside one.
|
|
43
|
+
*
|
|
44
|
+
* As a heading it takes `tabIndex={-1}`: `?focus=` moves focus to the card's heading so a
|
|
45
|
+
* screen reader lands on the card it was sent to (§3.5), and a heading is not focusable
|
|
46
|
+
* otherwise. No outline of its own — the ring round the card marks it.
|
|
47
|
+
*
|
|
48
|
+
* @internal The kit's cards use it; not part of the barrel.
|
|
49
|
+
*/
|
|
50
|
+
export function SettingsCardTitle({ className, ...props }: ComponentPropsWithoutRef<"div">) {
|
|
51
|
+
const level = useSettingsHeadingLevel();
|
|
52
|
+
if (!level) return <div {...props} className={cn(CARD_TITLE_CLASS, className)} />;
|
|
53
|
+
return <SettingsHeading as={level} tabIndex={-1} {...props} className={cn(CARD_TITLE_CLASS, "outline-none", className)} />;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A heading at a level chosen at run time. Its own component, taking the tag as a prop
|
|
58
|
+
* (as `CardTitle` does), so the element type is a prop and not a value computed in the
|
|
59
|
+
* caller's render.
|
|
60
|
+
*
|
|
61
|
+
* @internal
|
|
62
|
+
*/
|
|
63
|
+
export function SettingsHeading({ as: Tag, ...props }: { as: SettingsHeadingTag } & ComponentPropsWithoutRef<"h2">) {
|
|
64
|
+
return <Tag {...props} />;
|
|
65
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `settings` namespace (docs/settings-harmonization.md §4.3): the settings page's
|
|
3
|
+
* own words, and the names and help lines of the five core groups every app shares —
|
|
4
|
+
* appearance, account, security, notifications, data (§2.6). An app's own groups bring
|
|
5
|
+
* their own words; any core label may be overridden like any kit label (prop >
|
|
6
|
+
* `<UiKitProvider labels={{ settings }}>` > English).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A core group's two strings, as {@link SettingsGroup} takes them. */
|
|
10
|
+
export interface SettingsGroupLabels {
|
|
11
|
+
/** The group's name. */
|
|
12
|
+
title: string;
|
|
13
|
+
/** One line: under the heading, and in the phone list. */
|
|
14
|
+
help: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The five groups whose words the kit ships (§2.6, §4.2). */
|
|
18
|
+
export type SettingsCoreGroup = "appearance" | "account" | "security" | "notifications" | "data";
|
|
19
|
+
|
|
20
|
+
export interface SettingsLabels {
|
|
21
|
+
/** The page's heading, and the back link's visible text on a phone's group page. */
|
|
22
|
+
title: string;
|
|
23
|
+
/** The search field's accessible name. */
|
|
24
|
+
search: string;
|
|
25
|
+
/** The search field's placeholder. */
|
|
26
|
+
searchPlaceholder: string;
|
|
27
|
+
/** The hit list's accessible name. */
|
|
28
|
+
results: string;
|
|
29
|
+
/** No hits for the trimmed query (§3.4). */
|
|
30
|
+
noMatches: (query: string) => string;
|
|
31
|
+
/** The button beside it that empties the field. */
|
|
32
|
+
clearSearch: string;
|
|
33
|
+
/** The phone's back link's accessible name: "‹ Settings" says where, this says what. */
|
|
34
|
+
back: string;
|
|
35
|
+
/** The sidebar's accessible name (the vertical tablist). */
|
|
36
|
+
sections: string;
|
|
37
|
+
/** A sidebar row's hit badge, read in place of the bare number while searching. */
|
|
38
|
+
matchCount: (count: number) => string;
|
|
39
|
+
/** The core groups' names and help lines. */
|
|
40
|
+
groups: Record<SettingsCoreGroup, SettingsGroupLabels>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_SETTINGS_LABELS: SettingsLabels = {
|
|
44
|
+
title: "Settings",
|
|
45
|
+
search: "Search settings",
|
|
46
|
+
searchPlaceholder: "Search settings…",
|
|
47
|
+
results: "Matching settings",
|
|
48
|
+
noMatches: (query) => `No settings match “${query}”.`,
|
|
49
|
+
clearSearch: "Clear search",
|
|
50
|
+
back: "Back to settings",
|
|
51
|
+
sections: "Settings sections",
|
|
52
|
+
matchCount: (count) => (count === 1 ? "1 match" : `${count} matches`),
|
|
53
|
+
groups: {
|
|
54
|
+
appearance: {
|
|
55
|
+
title: "Appearance",
|
|
56
|
+
help: "Language, theme and how the app looks on this device.",
|
|
57
|
+
},
|
|
58
|
+
account: {
|
|
59
|
+
title: "Account",
|
|
60
|
+
help: "Your profile and your email address.",
|
|
61
|
+
},
|
|
62
|
+
security: {
|
|
63
|
+
title: "Security",
|
|
64
|
+
help: "How you sign in: password, two-factor codes, passkeys and sessions.",
|
|
65
|
+
},
|
|
66
|
+
notifications: {
|
|
67
|
+
title: "Notifications",
|
|
68
|
+
help: "What you are told about, when, and on which devices.",
|
|
69
|
+
},
|
|
70
|
+
data: {
|
|
71
|
+
title: "Data",
|
|
72
|
+
help: "Export what is yours, or delete it together with your account.",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|