@eifi1/ui-kit 0.17.0 → 0.19.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 +83 -4
- package/dist/chart.d.ts +13 -1
- package/dist/components/account-chips.d.ts +153 -0
- package/dist/components/account-chips.js +135 -0
- package/dist/components/account-chips.js.map +1 -0
- package/dist/components/account-settings-labels.d.ts +10 -2
- package/dist/components/account-settings-labels.js +3 -0
- package/dist/components/account-settings-labels.js.map +1 -1
- package/dist/components/amount-input.d.ts +13 -1
- package/dist/components/button-group.d.ts +13 -1
- package/dist/components/calculator.d.ts +13 -1
- package/dist/components/chip.d.ts +25 -1
- package/dist/components/chip.js +143 -6
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +42 -1
- package/dist/components/confirm-dialog.js +36 -5
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +13 -1
- package/dist/components/danger-confirm.d.ts +55 -4
- package/dist/components/danger-confirm.js +67 -30
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +2 -0
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +13 -1
- package/dist/components/file-button.d.ts +13 -1
- package/dist/components/file-dropzone.d.ts +13 -1
- package/dist/components/floating-panel.d.ts +4 -1
- package/dist/components/floating-panel.js +15 -1
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/form-actions.d.ts +13 -1
- package/dist/components/form-actions.js +3 -0
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/legal.d.ts +84 -0
- package/dist/components/legal.js +55 -0
- package/dist/components/legal.js.map +1 -0
- package/dist/components/money-field.d.ts +13 -1
- package/dist/components/number-field.d.ts +14 -2
- package/dist/components/number-field.js +23 -22
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +13 -1
- package/dist/components/numpad-sheet.d.ts +13 -1
- package/dist/components/passkeys-setting.d.ts +27 -1
- package/dist/components/passkeys-setting.js +31 -5
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/progress-bar.d.ts +15 -1
- package/dist/components/progress-bar.js +8 -1
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/reauth-dialog.d.ts +90 -0
- package/dist/components/reauth-dialog.js +158 -0
- package/dist/components/reauth-dialog.js.map +1 -0
- package/dist/components/series-chart.d.ts +13 -1
- package/dist/components/server-wake.d.ts +60 -0
- package/dist/components/server-wake.js +79 -0
- package/dist/components/server-wake.js.map +1 -0
- package/dist/components/settings-fields.d.ts +13 -1
- package/dist/components/share-card.d.ts +64 -0
- package/dist/components/share-card.js +350 -0
- package/dist/components/share-card.js.map +1 -0
- package/dist/components/status-dot.d.ts +2 -0
- package/dist/components/text-link.d.ts +13 -1
- package/dist/components/time-input.d.ts +13 -1
- package/dist/components/toast.d.ts +10 -1
- package/dist/components/toast.js +42 -2
- package/dist/components/toast.js.map +1 -1
- package/dist/components/toggle-group.js +5 -2
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/translation-review-editor.d.ts +56 -0
- package/dist/components/translation-review-editor.js +159 -0
- package/dist/components/translation-review-editor.js.map +1 -0
- package/dist/components/translation-review-labels.d.ts +92 -0
- package/dist/components/translation-review-labels.js +67 -0
- package/dist/components/translation-review-labels.js.map +1 -0
- package/dist/components/translation-review-parts.d.ts +97 -0
- package/dist/components/translation-review-parts.js +163 -0
- package/dist/components/translation-review-parts.js.map +1 -0
- package/dist/components/translation-review.d.ts +94 -0
- package/dist/components/translation-review.js +344 -0
- package/dist/components/translation-review.js.map +1 -0
- package/dist/components/ui.d.ts +13 -1
- package/dist/components/ui.js +43 -28
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/components/user-avatar.d.ts +2 -0
- package/dist/components/write-lock.d.ts +68 -0
- package/dist/components/write-lock.js +33 -0
- package/dist/components/write-lock.js.map +1 -0
- package/dist/{data-table-CpkWUJBY.d.ts → data-table-3mi2R3so.d.ts} +19 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/hooks/use-file-drop.d.ts +13 -1
- package/dist/hooks/use-overlay-history.js +16 -0
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/i18n/defaults.d.ts +13 -1
- package/dist/i18n/defaults.js +15 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +81 -0
- package/dist/i18n/{locales/de.js → german.js} +110 -9
- package/dist/i18n/german.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +13 -1
- package/dist/i18n/kit-labels.js +5 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +243 -0
- package/dist/i18n/languages.js +70 -0
- package/dist/i18n/languages.js.map +1 -0
- package/dist/i18n/locales/de-CH.d.ts +26 -8
- package/dist/i18n/locales/de-CH.js +7 -3
- package/dist/i18n/locales/de-CH.js.map +1 -1
- package/dist/i18n/locales/en.d.ts +13 -1
- package/dist/i18n/locales/en.js +8 -0
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +13 -1
- package/dist/i18n/locales/es.js +127 -27
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +13 -1
- package/dist/i18n/locales/fr.js +101 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +13 -1
- package/dist/i18n/locales/hu.js +105 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +13 -1
- package/dist/i18n/locales/it.js +134 -34
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +13 -1
- package/dist/i18n/locales/zh.js +101 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +102 -0
- package/dist/i18n/review.js +158 -0
- package/dist/i18n/review.js.map +1 -0
- package/dist/i18n/swiss.d.ts +3 -3
- package/dist/i18n/swiss.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +97 -1
- package/dist/index.js.map +1 -1
- package/dist/{kit-labels-C4uB_zmg.d.ts → kit-labels-D1J2_jyD.d.ts} +236 -9
- package/dist/lib/floating-stack.d.ts +31 -0
- package/dist/lib/floating-stack.js +42 -0
- package/dist/lib/floating-stack.js.map +1 -0
- package/dist/lib/server-wake.d.ts +146 -0
- package/dist/lib/server-wake.js +122 -0
- package/dist/lib/server-wake.js.map +1 -0
- package/dist/lib/translation-review.d.ts +232 -0
- package/dist/lib/translation-review.js +194 -0
- package/dist/lib/translation-review.js.map +1 -0
- package/dist/rhf/fields.d.ts +13 -1
- package/dist/rhf/form.d.ts +13 -1
- package/dist/rhf.d.ts +13 -1
- package/dist/search/global-search.d.ts +3 -1
- package/dist/search/global-search.js.map +1 -1
- package/dist/shell/app-shell.d.ts +13 -1
- package/dist/shell/top-bar-brand.d.ts +13 -1
- package/dist/shell/topbar-action-menu.d.ts +2 -0
- package/dist/shell.d.ts +13 -1
- package/dist/wizard/stepper-nav.d.ts +13 -1
- package/dist/wizard.d.ts +13 -1
- package/package.json +1 -1
- package/src/components/account-chips.tsx +302 -0
- package/src/components/account-settings-labels.ts +16 -2
- package/src/components/chip.tsx +239 -6
- package/src/components/confirm-dialog.tsx +82 -7
- package/src/components/danger-confirm.tsx +118 -33
- package/src/components/data-table.tsx +20 -0
- package/src/components/floating-panel.tsx +19 -1
- package/src/components/form-actions.tsx +22 -3
- package/src/components/legal.tsx +159 -0
- package/src/components/number-field.tsx +32 -26
- package/src/components/passkeys-setting.tsx +68 -7
- package/src/components/progress-bar.tsx +21 -1
- package/src/components/reauth-dialog.tsx +273 -0
- package/src/components/server-wake.tsx +156 -0
- package/src/components/share-card.tsx +615 -0
- package/src/components/toast.tsx +72 -2
- package/src/components/toggle-group.tsx +5 -2
- package/src/components/translation-review-editor.tsx +261 -0
- package/src/components/translation-review-labels.ts +152 -0
- package/src/components/translation-review-parts.tsx +269 -0
- package/src/components/translation-review.tsx +502 -0
- package/src/components/ui.tsx +89 -32
- package/src/components/write-lock.tsx +96 -0
- package/src/hooks/use-overlay-history.ts +51 -0
- package/src/i18n/defaults.ts +14 -0
- package/src/i18n/{locales/de.ts → german.ts} +120 -13
- package/src/i18n/kit-labels.tsx +29 -2
- package/src/i18n/languages.ts +285 -0
- package/src/i18n/locales/de-CH.ts +17 -8
- package/src/i18n/locales/en.ts +8 -0
- package/src/i18n/locales/es.ts +128 -26
- package/src/i18n/locales/fr.ts +103 -0
- package/src/i18n/locales/hu.ts +107 -0
- package/src/i18n/locales/it.ts +135 -33
- package/src/i18n/locales/zh.ts +102 -0
- package/src/i18n/review.ts +193 -0
- package/src/i18n/swiss.ts +3 -3
- package/src/index.ts +86 -1
- package/src/lib/floating-stack.ts +66 -0
- package/src/lib/server-wake.ts +291 -0
- package/src/lib/translation-review.ts +465 -0
- package/src/search/global-search.tsx +3 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
- package/dist/i18n/locales/de-CH-informal.js +0 -8
- package/dist/i18n/locales/de-CH-informal.js.map +0 -1
- package/dist/i18n/locales/de-informal.d.ts +0 -82
- package/dist/i18n/locales/de-informal.js +0 -37
- package/dist/i18n/locales/de-informal.js.map +0 -1
- package/dist/i18n/locales/de.d.ts +0 -66
- package/dist/i18n/locales/de.js.map +0 -1
- package/src/i18n/locales/de-CH-informal.ts +0 -10
- package/src/i18n/locales/de-informal.ts +0 -61
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useKitLabels } from "../i18n/kit-labels.js";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { AlertBanner } from "./alert-banner.js";
|
|
6
|
+
import { TextLink } from "./text-link.js";
|
|
7
|
+
const DEFAULT_LEGAL_LABELS = {
|
|
8
|
+
navLabel: "Legal"
|
|
9
|
+
};
|
|
10
|
+
function LegalLinks({ links, currentHref, nav = true, labels: labelsProp, className }) {
|
|
11
|
+
const labels = useKitLabels("legal", DEFAULT_LEGAL_LABELS, labelsProp);
|
|
12
|
+
const items = links.map((link) => /* @__PURE__ */ jsx(TextLink, { href: link.href, tone: "muted", current: link.href === currentHref, children: link.label }, link.href));
|
|
13
|
+
if (!nav) return /* @__PURE__ */ jsx(Fragment, { children: items });
|
|
14
|
+
return /* @__PURE__ */ jsx(
|
|
15
|
+
"nav",
|
|
16
|
+
{
|
|
17
|
+
"aria-label": labels.navLabel,
|
|
18
|
+
className: cn("flex flex-wrap gap-x-4 gap-y-1 text-xs", className),
|
|
19
|
+
children: items
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
function LegalSection({ heading, body, children, id, headingAs: Heading = "h2" }) {
|
|
24
|
+
return /* @__PURE__ */ jsxs("section", { id, className: "scroll-mt-20", children: [
|
|
25
|
+
/* @__PURE__ */ jsx(Heading, { className: "text-lg font-semibold text-[var(--text-primary)]", children: heading }),
|
|
26
|
+
body !== void 0 && /* @__PURE__ */ jsx("p", { className: "mt-2 whitespace-pre-line text-sm leading-relaxed text-[var(--text-secondary)]", children: body }),
|
|
27
|
+
children !== void 0 && /* @__PURE__ */ jsx("div", { className: "mt-2 text-sm leading-relaxed text-[var(--text-secondary)]", children })
|
|
28
|
+
] });
|
|
29
|
+
}
|
|
30
|
+
function LegalLayout({
|
|
31
|
+
title,
|
|
32
|
+
back,
|
|
33
|
+
notice,
|
|
34
|
+
children,
|
|
35
|
+
links,
|
|
36
|
+
currentHref,
|
|
37
|
+
headingAs: Heading = "h1",
|
|
38
|
+
className,
|
|
39
|
+
labels
|
|
40
|
+
}) {
|
|
41
|
+
return /* @__PURE__ */ jsxs("article", { className: cn("mx-auto w-full max-w-3xl px-4 py-12", className), children: [
|
|
42
|
+
back !== void 0 && /* @__PURE__ */ jsx("div", { className: "mb-4 text-sm", children: back }),
|
|
43
|
+
/* @__PURE__ */ jsx(Heading, { className: "text-3xl font-bold tracking-tight text-[var(--text-primary)]", children: title }),
|
|
44
|
+
notice !== void 0 && notice !== null && /* @__PURE__ */ jsx(AlertBanner, { tone: "warning", role: "note", className: "mt-4", children: notice }),
|
|
45
|
+
/* @__PURE__ */ jsx("div", { className: "mt-8 space-y-8", children }),
|
|
46
|
+
links !== void 0 && links.length > 0 && /* @__PURE__ */ jsx(LegalLinks, { links, currentHref, labels, className: "mt-12 border-t border-[var(--border)] pt-4" })
|
|
47
|
+
] });
|
|
48
|
+
}
|
|
49
|
+
export {
|
|
50
|
+
DEFAULT_LEGAL_LABELS,
|
|
51
|
+
LegalLayout,
|
|
52
|
+
LegalLinks,
|
|
53
|
+
LegalSection
|
|
54
|
+
};
|
|
55
|
+
//# sourceMappingURL=legal.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/legal.tsx"],"sourcesContent":["import type { ReactNode } from \"react\";\n\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { AlertBanner } from \"./alert-banner\";\nimport { TextLink } from \"./text-link\";\n\n/**\n * The legal pages' shell — Imprint, Privacy Policy, Terms — for every app (Marcel,\n * 2026-10-02: every app has them; docs/i18n-harmonization.md H10).\n *\n * keksdose and kastlan each carried the same shell, written twice: a back link, the\n * title, a \"closed beta, not yet reviewed by a lawyer\" notice, sections of heading +\n * running text, and kastlan's row of links to the three pages with the current one\n * marked. Kurvenschmiede had none. The words stay in each app — they are the app's\n * legal statements, reviewed under the `legal` area of the translation review — and\n * only the shape moves here.\n *\n * A CONTENT COLUMN, not a page: keksdose shows its legal pages inside its public\n * landing header and footer, kastlan inside `AuthLayout` (`width=\"wide\" card={false}`,\n * no title of its own). Both put this inside their chrome; a page-owning layout would\n * have fought one of them.\n */\n\n// ── Labels ────────────────────────────────────────────────────────────────────\n\nexport interface LegalLabels {\n /** The accessible name of {@link LegalLinks}' `<nav>`. */\n navLabel: string;\n}\n\nexport const DEFAULT_LEGAL_LABELS: LegalLabels = {\n navLabel: \"Legal\",\n};\n\n// ── Links ─────────────────────────────────────────────────────────────────────\n\nexport interface LegalLink {\n href: string;\n /** \"Imprint\", \"Privacy Policy\", \"Terms\" — the app's words. */\n label: ReactNode;\n}\n\nexport interface LegalLinksProps {\n links: readonly LegalLink[];\n /** The page being shown; its link is marked `aria-current=\"page\"`. Usually the\n * router's pathname. */\n currentHref?: string;\n /** `false` renders the links without the `<nav>` — inside `AuthLayout`'s `footer`,\n * which wraps its footer in a `<nav>` named by `footerLabel` already. Default true. */\n nav?: boolean;\n labels?: Partial<LegalLabels>;\n className?: string;\n}\n\n/** The legal pages as a row of quiet links, the one you are on marked (kastlan's\n * `LegalLinks`). Wraps on a phone. */\nexport function LegalLinks({ links, currentHref, nav = true, labels: labelsProp, className }: LegalLinksProps) {\n const labels = useKitLabels(\"legal\", DEFAULT_LEGAL_LABELS, labelsProp);\n const items = links.map((link) => (\n <TextLink key={link.href} href={link.href} tone=\"muted\" current={link.href === currentHref}>\n {link.label}\n </TextLink>\n ));\n if (!nav) return <>{items}</>;\n return (\n <nav\n aria-label={labels.navLabel}\n className={cn(\"flex flex-wrap gap-x-4 gap-y-1 text-xs\", className)}\n >\n {items}\n </nav>\n );\n}\n\n// ── Section ───────────────────────────────────────────────────────────────────\n\nexport interface LegalSectionProps {\n heading: ReactNode;\n /** Running text. Line breaks in it are kept (`white-space: pre-line`), so a\n * translation can hold an address or a list as plain lines. */\n body?: string;\n /** Anything richer than plain text — links, a list — after `body`. */\n children?: ReactNode;\n /** The section's `id`, for a link to it from elsewhere (\"see §4\"). */\n id?: string;\n /** The heading's element. Default `h2`, under the layout's `h1`. */\n headingAs?: \"h2\" | \"h3\";\n}\n\nexport function LegalSection({ heading, body, children, id, headingAs: Heading = \"h2\" }: LegalSectionProps) {\n return (\n <section id={id} className=\"scroll-mt-20\">\n <Heading className=\"text-lg font-semibold text-[var(--text-primary)]\">{heading}</Heading>\n {body !== undefined && (\n <p className=\"mt-2 whitespace-pre-line text-sm leading-relaxed text-[var(--text-secondary)]\">{body}</p>\n )}\n {children !== undefined && (\n <div className=\"mt-2 text-sm leading-relaxed text-[var(--text-secondary)]\">{children}</div>\n )}\n </section>\n );\n}\n\n// ── Layout ────────────────────────────────────────────────────────────────────\n\nexport interface LegalLayoutProps {\n /** \"Imprint\", \"Privacy Policy\", \"Terms\". */\n title: ReactNode;\n /** Above the title — the app's \"← Back\" link (`<TextLink icon={ArrowLeft}>`), whose\n * target differs per app (keksdose's start page, kastlan's sign-in). */\n back?: ReactNode;\n /**\n * A notice under the title, as a warning banner with `role=\"note\"` — page furniture,\n * not an alert. Both apps say their legal texts await a lawyer's review; keksdose's\n * privacy page says something more precise than the others (its technical sections\n * describe running code), so the notice is per page, not per layout.\n */\n notice?: ReactNode;\n /** The sections — {@link LegalSection}s. */\n children: ReactNode;\n /** The three pages, linked at the end of the column with the current one marked.\n * Leave out when the page's chrome already shows them (kastlan's AuthLayout footer). */\n links?: readonly LegalLink[];\n currentHref?: string;\n /** Default `h1`: the page's heading. */\n headingAs?: \"h1\" | \"h2\";\n /** Extra classes on the column — `px-0 py-0` inside a chrome that pads already. */\n className?: string;\n labels?: Partial<LegalLabels>;\n}\n\nexport function LegalLayout({\n title,\n back,\n notice,\n children,\n links,\n currentHref,\n headingAs: Heading = \"h1\",\n className,\n labels,\n}: LegalLayoutProps) {\n return (\n <article className={cn(\"mx-auto w-full max-w-3xl px-4 py-12\", className)}>\n {back !== undefined && <div className=\"mb-4 text-sm\">{back}</div>}\n <Heading className=\"text-3xl font-bold tracking-tight text-[var(--text-primary)]\">{title}</Heading>\n {notice !== undefined && notice !== null && (\n <AlertBanner tone=\"warning\" role=\"note\" className=\"mt-4\">\n {notice}\n </AlertBanner>\n )}\n <div className=\"mt-8 space-y-8\">{children}</div>\n {links !== undefined && links.length > 0 && (\n <LegalLinks links={links} currentHref={currentHref} labels={labels} className=\"mt-12 border-t border-[var(--border)] pt-4\" />\n )}\n </article>\n );\n}\n"],"mappings":";AA4DI,SAIe,UAJf,KAgCA,YAhCA;AA1DJ,SAAS,oBAAoB;AAC7B,SAAS,UAAU;AACnB,SAAS,mBAAmB;AAC5B,SAAS,gBAAgB;AA0BlB,MAAM,uBAAoC;AAAA,EAC/C,UAAU;AACZ;AAwBO,SAAS,WAAW,EAAE,OAAO,aAAa,MAAM,MAAM,QAAQ,YAAY,UAAU,GAAoB;AAC7G,QAAM,SAAS,aAAa,SAAS,sBAAsB,UAAU;AACrE,QAAM,QAAQ,MAAM,IAAI,CAAC,SACvB,oBAAC,YAAyB,MAAM,KAAK,MAAM,MAAK,SAAQ,SAAS,KAAK,SAAS,aAC5E,eAAK,SADO,KAAK,IAEpB,CACD;AACD,MAAI,CAAC,IAAK,QAAO,gCAAG,iBAAM;AAC1B,SACE;AAAA,IAAC;AAAA;AAAA,MACC,cAAY,OAAO;AAAA,MACnB,WAAW,GAAG,0CAA0C,SAAS;AAAA,MAEhE;AAAA;AAAA,EACH;AAEJ;AAiBO,SAAS,aAAa,EAAE,SAAS,MAAM,UAAU,IAAI,WAAW,UAAU,KAAK,GAAsB;AAC1G,SACE,qBAAC,aAAQ,IAAQ,WAAU,gBACzB;AAAA,wBAAC,WAAQ,WAAU,oDAAoD,mBAAQ;AAAA,IAC9E,SAAS,UACR,oBAAC,OAAE,WAAU,iFAAiF,gBAAK;AAAA,IAEpG,aAAa,UACZ,oBAAC,SAAI,WAAU,6DAA6D,UAAS;AAAA,KAEzF;AAEJ;AA8BO,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW,UAAU;AAAA,EACrB;AAAA,EACA;AACF,GAAqB;AACnB,SACE,qBAAC,aAAQ,WAAW,GAAG,uCAAuC,SAAS,GACpE;AAAA,aAAS,UAAa,oBAAC,SAAI,WAAU,gBAAgB,gBAAK;AAAA,IAC3D,oBAAC,WAAQ,WAAU,gEAAgE,iBAAM;AAAA,IACxF,WAAW,UAAa,WAAW,QAClC,oBAAC,eAAY,MAAK,WAAU,MAAK,QAAO,WAAU,QAC/C,kBACH;AAAA,IAEF,oBAAC,SAAI,WAAU,kBAAkB,UAAS;AAAA,IACzC,UAAU,UAAa,MAAM,SAAS,KACrC,oBAAC,cAAW,OAAc,aAA0B,QAAgB,WAAU,8CAA6C;AAAA,KAE/H;AAEJ;","names":[]}
|
|
@@ -2,7 +2,7 @@ import * as react from 'react';
|
|
|
2
2
|
import { ComponentProps, Ref } from 'react';
|
|
3
3
|
import { AmountInput } from './amount-input.js';
|
|
4
4
|
import './calculator.js';
|
|
5
|
-
import '../kit-labels-
|
|
5
|
+
import '../kit-labels-D1J2_jyD.js';
|
|
6
6
|
import './data-table-labels.js';
|
|
7
7
|
import './mini-calendar.js';
|
|
8
8
|
import './calendar-heatmap.js';
|
|
@@ -53,6 +53,18 @@ import './authed-image.js';
|
|
|
53
53
|
import '../hooks/use-authed-src.js';
|
|
54
54
|
import './image-grid.js';
|
|
55
55
|
import './lightbox.js';
|
|
56
|
+
import './write-lock.js';
|
|
57
|
+
import './legal.js';
|
|
58
|
+
import './account-chips.js';
|
|
59
|
+
import '../lib/format.js';
|
|
60
|
+
import '../data-table-3mi2R3so.js';
|
|
61
|
+
import './data-table-sort.js';
|
|
62
|
+
import './swipeable-row.js';
|
|
63
|
+
import './reauth-dialog.js';
|
|
64
|
+
import './server-wake.js';
|
|
65
|
+
import '../lib/server-wake.js';
|
|
66
|
+
import './translation-review-labels.js';
|
|
67
|
+
import '../lib/translation-review.js';
|
|
56
68
|
import './numpad-sheet.js';
|
|
57
69
|
|
|
58
70
|
type AmountInputProps = ComponentProps<typeof AmountInput>;
|
|
@@ -2,7 +2,7 @@ import * as react from 'react';
|
|
|
2
2
|
import { ComponentProps, ReactNode } from 'react';
|
|
3
3
|
import { NumberInput } from './number-input.js';
|
|
4
4
|
import './calculator.js';
|
|
5
|
-
import '../kit-labels-
|
|
5
|
+
import '../kit-labels-D1J2_jyD.js';
|
|
6
6
|
import './data-table-labels.js';
|
|
7
7
|
import './mini-calendar.js';
|
|
8
8
|
import './calendar-heatmap.js';
|
|
@@ -53,6 +53,18 @@ import './authed-image.js';
|
|
|
53
53
|
import '../hooks/use-authed-src.js';
|
|
54
54
|
import './image-grid.js';
|
|
55
55
|
import './lightbox.js';
|
|
56
|
+
import './write-lock.js';
|
|
57
|
+
import './legal.js';
|
|
58
|
+
import './account-chips.js';
|
|
59
|
+
import '../lib/format.js';
|
|
60
|
+
import '../data-table-3mi2R3so.js';
|
|
61
|
+
import './data-table-sort.js';
|
|
62
|
+
import './swipeable-row.js';
|
|
63
|
+
import './reauth-dialog.js';
|
|
64
|
+
import './server-wake.js';
|
|
65
|
+
import '../lib/server-wake.js';
|
|
66
|
+
import './translation-review-labels.js';
|
|
67
|
+
import '../lib/translation-review.js';
|
|
56
68
|
import './numpad-sheet.js';
|
|
57
69
|
|
|
58
70
|
type NumberInputProps = ComponentProps<typeof NumberInput>;
|
|
@@ -161,7 +173,7 @@ interface NumberFieldProps extends Omit<NumberInputProps, "value" | "onChange" |
|
|
|
161
173
|
* digit grouping — "1.000" would read as a thousand in German and as one in English,
|
|
162
174
|
* and a field that parses its own display back must not have that ambiguity.
|
|
163
175
|
*/
|
|
164
|
-
declare function NumberField(
|
|
176
|
+
declare function NumberField(props: NumberFieldProps): react.JSX.Element;
|
|
165
177
|
/**
|
|
166
178
|
* A {@link NumberField} for whole numbers: `digits={0}` and `calculator={false}` as
|
|
167
179
|
* defaults, each overridable — the preset `RhfIntegerField` (`@eifi1/ui-kit/rhf`) applies to a
|
|
@@ -29,26 +29,27 @@ function roundTo(n, digits) {
|
|
|
29
29
|
return Number.isFinite(rounded) ? rounded : n;
|
|
30
30
|
}
|
|
31
31
|
const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
|
|
32
|
-
function NumberField({
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
32
|
+
function NumberField(props) {
|
|
33
|
+
const {
|
|
34
|
+
value,
|
|
35
|
+
onCommit,
|
|
36
|
+
onValueChange,
|
|
37
|
+
digits,
|
|
38
|
+
min,
|
|
39
|
+
max,
|
|
40
|
+
nullable = false,
|
|
41
|
+
unit,
|
|
42
|
+
unitPlacement = "suffix",
|
|
43
|
+
locale: localeProp,
|
|
44
|
+
label,
|
|
45
|
+
invalid,
|
|
46
|
+
error,
|
|
47
|
+
id,
|
|
48
|
+
"aria-describedby": ariaDescribedBy,
|
|
49
|
+
"aria-invalid": ariaInvalid,
|
|
50
|
+
"aria-required": ariaRequired,
|
|
51
|
+
...rest
|
|
52
|
+
} = props;
|
|
52
53
|
const locale = useKitLocale(localeProp);
|
|
53
54
|
const mark = decimalMark(locale);
|
|
54
55
|
const localize = (text) => mark === "," ? text.replace(/\./g, ",") : text;
|
|
@@ -129,10 +130,10 @@ function NumberField({
|
|
|
129
130
|
invalid: invalid || hasError || ariaInvalid === true || ariaInvalid === "true"
|
|
130
131
|
}
|
|
131
132
|
);
|
|
132
|
-
if (!hasError) return field;
|
|
133
|
+
if (!hasError && !("error" in props)) return field;
|
|
133
134
|
return /* @__PURE__ */ jsxs("div", { children: [
|
|
134
135
|
field,
|
|
135
|
-
/* @__PURE__ */ jsx("p", { id: errorId, className: FIELD_ERROR_CLASS, children: error })
|
|
136
|
+
hasError && /* @__PURE__ */ jsx("p", { id: errorId, className: FIELD_ERROR_CLASS, children: error })
|
|
136
137
|
] });
|
|
137
138
|
}
|
|
138
139
|
function IntegerField(props) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/number-field.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { NumberInput } from \"./number-input\";\nimport { evaluateExpression, formatResult } from \"../lib/calc\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\n\ntype NumberInputProps = ComponentProps<typeof NumberInput>;\n\nexport interface NumberFieldProps\n extends Omit<NumberInputProps, \"value\" | \"onChange\" | \"onCommit\" | \"suffix\" | \"invalid\"> {\n /** The committed number. `null` is \"no value\" — shown as an empty field. */\n value: number | null;\n /**\n * The parsed number, on blur or Enter — never per keystroke — and only when it\n * differs from the last one committed. Already rounded to `digits` and clamped\n * into `[min, max]`. `null` only when `nullable` and the field was emptied.\n */\n onCommit: (value: number | null) => void;\n /**\n * LIVE MODE: the number the text reads as, on every keystroke (and every step\n * key) — for a calculator that recomputes as the user types, while `onCommit`\n * stays the one to save on.\n *\n * What it is NOT, until the commit: rounded to `digits` or clamped into\n * `[min, max]` — a half-typed \"1\" on the way to \"150\" must not read as the\n * minimum of 100. A calculation reads as its result (\"12+5\" → 17); an empty field\n * as `null` (whether or not `nullable`); a draft that is no number yet (\"-\", \"12+\")\n * fires nothing, so the last number stands. On blur/Enter it fires once more with\n * the committed value if that differs from the last one it reported — the rounded,\n * clamped or snapped-back number — so a live consumer always ends where `onCommit`\n * does.\n *\n * Feeding it back into `value` is safe: a `value` equal to the number this field\n * last reported does not re-render the text, so \"1,\" stays \"1,\" while the rest of\n * the form already shows 1. Not called when `value` changes from outside.\n */\n onValueChange?: (value: number | null) => void;\n /** Round to this many decimals on commit, and show at most this many (trailing\n * zeros are trimmed: `digits={2}` shows 12.5, not 12.50). Omit for no rounding. */\n digits?: number;\n /** Clamp the committed number into `[min, max]`. Applied after rounding. */\n min?: number;\n max?: number;\n /** An emptied field commits `null`. Without it an empty field is not an answer\n * and snaps back to the last value, like any other unparsable text. */\n nullable?: boolean;\n /** The unit — \"mm\", \"%\", \"km/h\". */\n unit?: ReactNode;\n /** Where `unit` goes: `\"suffix\"` (default) is NumberInput's read-out at the\n * field's end edge; `\"label\"` appends it to the label as \"Label (unit)\", for rows\n * of narrow fields where a suffix would eat the digits. */\n unitPlacement?: \"suffix\" | \"label\";\n /** BCP 47 tag for the decimal mark, else the `<UiKitProvider locale>`, else the\n * runtime's. */\n locale?: string;\n /** See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /** See {@link Input}'s `error`: shown under the field, attached via\n * `aria-describedby`, implies `invalid`. */\n error?: ReactNode;\n /** Merged with the error's id. Accepted so a form library's control slot (which\n * injects `id` / `aria-describedby` / `aria-invalid`) can wrap this directly. */\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: boolean | \"true\" | \"false\";\n /**\n * Set on the `<input>`. Declared, with `id` (from NumberInput) and the two above, so\n * `Field`'s render-prop spread — `{(ids) => <NumberField {...ids} … />}`, kastlan's\n * wizard steps — is typed and reaches the input rather than being dropped: before\n * this, `aria-required` was not in the type and a required number was announced as\n * optional.\n */\n \"aria-required\"?: boolean | \"true\" | \"false\";\n}\n\n/** The locale's decimal mark, as far as this field can type it: \",\" or \".\". A mark\n * outside those two (Arabic's \"٫\") is not something {@link sanitizeLive} lets\n * through, so those locales keep the dot rather than get a field that eats keys. */\nfunction decimalMark(locale: string | undefined): \",\" | \".\" {\n try {\n const part = new Intl.NumberFormat(locale).formatToParts(1.5).find((p) => p.type === \"decimal\");\n return part?.value === \",\" ? \",\" : \".\";\n } catch {\n // An invalid tag throws a RangeError; a typo in a locale prop must not take the\n // form down with it.\n return \".\";\n }\n}\n\n/** What the draft reads as while it is being typed — see `onValueChange`. `undefined`\n * is \"not a number yet\", distinct from `null` (empty). */\nfunction parseLive(text: string): number | null | undefined {\n const t = text.trim().replace(/,/g, \".\");\n if (t === \"\") return null;\n if (/^-?\\d*\\.?\\d*$/.test(t)) {\n // \"-\", \".\" and \"-.\" pass the pattern and are no number yet.\n if (!/\\d/.test(t)) return undefined;\n const n = Number(t);\n return Number.isFinite(n) ? n : undefined;\n }\n return evaluateExpression(t) ?? undefined;\n}\n\n/** `toFixed` rounding rather than `Math.round(n * 10^d)`, which turns 1.005 into\n * 1.00 by way of 100.49999999999999 — the same choice the lenkbank field made. */\nfunction roundTo(n: number, digits: number | undefined): number {\n if (digits === undefined) return n;\n const d = Math.min(Math.max(Math.trunc(digits), 0), 100);\n const rounded = Number(n.toFixed(d));\n return Number.isFinite(rounded) ? rounded : n;\n}\n\n// The error line under a field — the same type as `ui.tsx`'s (module-private) one,\n// so a NumberField's message is indistinguishable from an Input's.\nconst FIELD_ERROR_CLASS = \"mt-1 text-[11px] leading-tight text-[var(--danger)]\";\n\n/**\n * A number field whose value IS a number: {@link NumberInput}'s text, calculator and\n * numpad, with the parse/commit/revert loop every consumer was writing on top of it.\n *\n * WHY THE DRAFT. Half-typed values — \"-\", \"1,\", \"12+\" — are not numbers, and\n * reformatting them mid-keystroke moves the caret and fights the typist; committing\n * per keystroke would also fire whatever a commit fires (a recalculation, a save) for\n * every meaningless intermediate. So the field keeps a draft STRING while it is being\n * typed and resolves it on blur/Enter:\n *\n * - a number (or a calculation, \"2700+50\") → rounded to `digits`, clamped into\n * `[min, max]`, committed, and the draft re-rendered from the result;\n * - empty → `null` when `nullable`, otherwise the same as the next case — `Number(\"\")`\n * is 0, and clearing a wheelbase and tabbing away must not set it to zero;\n * - anything else unparsable → the draft SNAPS BACK to the last committed value\n * rather than sending NaN on for a server to reject.\n *\n * The draft follows `value` when it changes from outside (a reset, a loaded record);\n * a change that came from here arrives back identical and is a no-op.\n *\n * LIVE MODE. A calculator that recomputes per keystroke used to have to pick: this\n * field (limits, rounding, but a number only on blur) or a bare NumberInput (every\n * keystroke, but a string and no limits). `onValueChange` is the per-keystroke\n * channel here, next to the commit rather than instead of it: live numbers for the\n * read-out, `onCommit` for the save. It lives on this field rather than as\n * `min`/`max`/`digits` on NumberInput because parsing, the locale's mark and the\n * limits are what this field already is — a second copy on the string field would be\n * a second, subtly different NumberField.\n *\n * STEPS. `step` (with `min`/`max` as bounds) turns on ArrowUp/ArrowDown and PageUp/\n * PageDown (×10) — see NumberInput's `step`. A step is a draft change like a\n * keystroke: `onValueChange` hears it at once, `onCommit` on blur/Enter. Pick a\n * `step` that `digits` can show (0.25 needs `digits={2}`), or the commit rounds it.\n *\n * LOCALE. The decimal mark is the locale's: a German user sees and types \"1,5\".\n * NumberInput normalises every keystroke to a dot (so its evaluator reads one\n * alphabet); this field maps the dot back before the draft is shown, which also\n * means the numpad's \".\" key types the mark the field is displaying. There is no\n * digit grouping — \"1.000\" would read as a thousand in German and as one in English,\n * and a field that parses its own display back must not have that ambiguity.\n */\nexport function NumberField({\n value,\n onCommit,\n onValueChange,\n digits,\n min,\n max,\n nullable = false,\n unit,\n unitPlacement = \"suffix\",\n locale: localeProp,\n label,\n invalid,\n error,\n id,\n \"aria-describedby\": ariaDescribedBy,\n \"aria-invalid\": ariaInvalid,\n \"aria-required\": ariaRequired,\n ...rest\n}: NumberFieldProps) {\n const locale = useKitLocale(localeProp);\n const mark = decimalMark(locale);\n const localize = (text: string) => (mark === \",\" ? text.replace(/\\./g, \",\") : text);\n // `formatResult` for fixed notation: `String(1e-7)` is \"1e-7\", which the field\n // would read back as 17 (see calc.ts).\n const format = (n: number | null) =>\n n === null || !Number.isFinite(n) ? \"\" : localize(formatResult(roundTo(n, digits)));\n\n const [draft, setDraft] = useState(() => format(value));\n // Everything the displayed text depends on, not just `value`: switching the\n // provider's language or a field's `digits` has to re-render the draft too.\n const [seen, setSeen] = useState({ value, mark, digits });\n // The last number handed to `onCommit`, so Enter-then-blur (both commit) and a\n // parent that does not echo the value back cannot commit the same number twice.\n // State rather than a ref because the external-change branch below resets it\n // during render, and a ref may not be written there.\n const [committed, setCommitted] = useState(value);\n // The last number handed to `onValueChange` — state for the same reason.\n const [reported, setReported] = useState(value);\n\n // Adjust state during render rather than in an effect: React re-renders at once\n // with the new draft, before anything is painted, so there is no frame showing\n // the old text.\n if (seen.value !== value || seen.mark !== mark || seen.digits !== digits) {\n setSeen({ value, mark, digits });\n // Live mode echoing back what it was just told: the draft already says it, in\n // the typist's own spelling (\"1,\" or \"12+5\"), and reformatting would fight them.\n // Only a changed `value` qualifies — a new mark or `digits` still re-renders.\n const echo =\n onValueChange !== undefined && value === reported && seen.mark === mark && seen.digits === digits;\n if (!echo) {\n setDraft(format(value));\n setCommitted(value);\n setReported(value);\n }\n }\n\n const report = (next: number | null) => {\n if (next === reported) return;\n setReported(next);\n onValueChange?.(next);\n };\n\n const onText = (text: string) => {\n setDraft(localize(text));\n const live = parseLive(text);\n if (live !== undefined) report(live);\n };\n\n const commit = (text: string) => {\n const trimmed = text.trim();\n let next: number | null;\n const snapBack = () => {\n setDraft(format(committed));\n report(committed);\n };\n if (trimmed === \"\") {\n if (!nullable) return snapBack();\n next = null;\n } else {\n // `text` is NumberInput's committed form: an evaluated calculation, dot-decimal.\n // What survives its digits-only fallback can still be \"-\" or \".\" alone.\n const parsed = Number(trimmed.replace(/,/g, \".\"));\n if (!Number.isFinite(parsed)) return snapBack();\n next = roundTo(parsed, digits);\n if (min !== undefined) next = Math.max(min, next);\n if (max !== undefined) next = Math.min(max, next);\n }\n // Always re-render the draft from the result — \"007\" becomes \"7\", a clamped 500\n // shows the 100 it was clamped to — even when there is nothing new to commit.\n setDraft(format(next));\n report(next);\n if (next !== committed) {\n setCommitted(next);\n onCommit(next);\n }\n };\n\n const generatedId = useId();\n const fieldId = id ?? generatedId;\n const errorId = useId();\n // `null`/`false`/`\"\"` are what `touched && errors.x` evaluates to on the happy path.\n const hasError = error !== undefined && error !== null && error !== false && error !== \"\";\n const describedBy = [ariaDescribedBy, hasError ? errorId : undefined].filter(Boolean).join(\" \");\n\n // Straight onto NumberInput's <input>, which declares both now. They used to be set\n // on the DOM node from an effect, because NumberInput had no props for them.\n const required = ariaRequired === true || ariaRequired === \"true\";\n\n const labelWithUnit =\n unit !== undefined && unitPlacement === \"label\"\n ? label === undefined || label === \"\"\n ? unit\n : typeof label === \"string\" && typeof unit === \"string\"\n ? `${label} (${unit})`\n : (\n <>\n {label} ({unit})\n </>\n )\n : label;\n\n const field = (\n <NumberInput\n {...rest}\n id={fieldId}\n aria-describedby={describedBy || undefined}\n aria-required={required || undefined}\n label={labelWithUnit}\n value={draft}\n onChange={onText}\n onCommit={commit}\n min={min}\n max={max}\n suffix={unitPlacement === \"suffix\" ? unit : undefined}\n invalid={invalid || hasError || ariaInvalid === true || ariaInvalid === \"true\"}\n />\n );\n // Same shape as Input's FieldGroup: no wrapper at all without a message, so adding\n // `error` support changed nothing about the DOM a flex row lays out.\n if (!hasError) return field;\n return (\n <div>\n {field}\n <p id={errorId} className={FIELD_ERROR_CLASS}>\n {error}\n </p>\n </div>\n );\n}\n\n/**\n * A {@link NumberField} for whole numbers: `digits={0}` and `calculator={false}` as\n * defaults, each overridable — the preset `RhfIntegerField` (`@eifi1/ui-kit/rhf`) applies to a\n * form-bound field, for the plain one (kastlan 51: a building's construction year in a\n * wizard's local state, an entity id in the document upload spelled the two props out\n * by hand).\n *\n * `??` rather than a spread over the defaults, so a wrapper forwarding its own optional\n * `digits={props.digits}` still gets the preset.\n */\nexport function IntegerField(props: NumberFieldProps) {\n return <NumberField {...props} digits={props.digits ?? 0} calculator={props.calculator ?? false} />;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/number-field.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { NumberInput } from \"./number-input\";\nimport { evaluateExpression, formatResult } from \"../lib/calc\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\n\ntype NumberInputProps = ComponentProps<typeof NumberInput>;\n\nexport interface NumberFieldProps\n extends Omit<NumberInputProps, \"value\" | \"onChange\" | \"onCommit\" | \"suffix\" | \"invalid\"> {\n /** The committed number. `null` is \"no value\" — shown as an empty field. */\n value: number | null;\n /**\n * The parsed number, on blur or Enter — never per keystroke — and only when it\n * differs from the last one committed. Already rounded to `digits` and clamped\n * into `[min, max]`. `null` only when `nullable` and the field was emptied.\n */\n onCommit: (value: number | null) => void;\n /**\n * LIVE MODE: the number the text reads as, on every keystroke (and every step\n * key) — for a calculator that recomputes as the user types, while `onCommit`\n * stays the one to save on.\n *\n * What it is NOT, until the commit: rounded to `digits` or clamped into\n * `[min, max]` — a half-typed \"1\" on the way to \"150\" must not read as the\n * minimum of 100. A calculation reads as its result (\"12+5\" → 17); an empty field\n * as `null` (whether or not `nullable`); a draft that is no number yet (\"-\", \"12+\")\n * fires nothing, so the last number stands. On blur/Enter it fires once more with\n * the committed value if that differs from the last one it reported — the rounded,\n * clamped or snapped-back number — so a live consumer always ends where `onCommit`\n * does.\n *\n * Feeding it back into `value` is safe: a `value` equal to the number this field\n * last reported does not re-render the text, so \"1,\" stays \"1,\" while the rest of\n * the form already shows 1. Not called when `value` changes from outside.\n */\n onValueChange?: (value: number | null) => void;\n /** Round to this many decimals on commit, and show at most this many (trailing\n * zeros are trimmed: `digits={2}` shows 12.5, not 12.50). Omit for no rounding. */\n digits?: number;\n /** Clamp the committed number into `[min, max]`. Applied after rounding. */\n min?: number;\n max?: number;\n /** An emptied field commits `null`. Without it an empty field is not an answer\n * and snaps back to the last value, like any other unparsable text. */\n nullable?: boolean;\n /** The unit — \"mm\", \"%\", \"km/h\". */\n unit?: ReactNode;\n /** Where `unit` goes: `\"suffix\"` (default) is NumberInput's read-out at the\n * field's end edge; `\"label\"` appends it to the label as \"Label (unit)\", for rows\n * of narrow fields where a suffix would eat the digits. */\n unitPlacement?: \"suffix\" | \"label\";\n /** BCP 47 tag for the decimal mark, else the `<UiKitProvider locale>`, else the\n * runtime's. */\n locale?: string;\n /** See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /** See {@link Input}'s `error`: shown under the field, attached via\n * `aria-describedby`, implies `invalid`. */\n error?: ReactNode;\n /** Merged with the error's id. Accepted so a form library's control slot (which\n * injects `id` / `aria-describedby` / `aria-invalid`) can wrap this directly. */\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: boolean | \"true\" | \"false\";\n /**\n * Set on the `<input>`. Declared, with `id` (from NumberInput) and the two above, so\n * `Field`'s render-prop spread — `{(ids) => <NumberField {...ids} … />}`, kastlan's\n * wizard steps — is typed and reaches the input rather than being dropped: before\n * this, `aria-required` was not in the type and a required number was announced as\n * optional.\n */\n \"aria-required\"?: boolean | \"true\" | \"false\";\n}\n\n/** The locale's decimal mark, as far as this field can type it: \",\" or \".\". A mark\n * outside those two (Arabic's \"٫\") is not something {@link sanitizeLive} lets\n * through, so those locales keep the dot rather than get a field that eats keys. */\nfunction decimalMark(locale: string | undefined): \",\" | \".\" {\n try {\n const part = new Intl.NumberFormat(locale).formatToParts(1.5).find((p) => p.type === \"decimal\");\n return part?.value === \",\" ? \",\" : \".\";\n } catch {\n // An invalid tag throws a RangeError; a typo in a locale prop must not take the\n // form down with it.\n return \".\";\n }\n}\n\n/** What the draft reads as while it is being typed — see `onValueChange`. `undefined`\n * is \"not a number yet\", distinct from `null` (empty). */\nfunction parseLive(text: string): number | null | undefined {\n const t = text.trim().replace(/,/g, \".\");\n if (t === \"\") return null;\n if (/^-?\\d*\\.?\\d*$/.test(t)) {\n // \"-\", \".\" and \"-.\" pass the pattern and are no number yet.\n if (!/\\d/.test(t)) return undefined;\n const n = Number(t);\n return Number.isFinite(n) ? n : undefined;\n }\n return evaluateExpression(t) ?? undefined;\n}\n\n/** `toFixed` rounding rather than `Math.round(n * 10^d)`, which turns 1.005 into\n * 1.00 by way of 100.49999999999999 — the same choice the lenkbank field made. */\nfunction roundTo(n: number, digits: number | undefined): number {\n if (digits === undefined) return n;\n const d = Math.min(Math.max(Math.trunc(digits), 0), 100);\n const rounded = Number(n.toFixed(d));\n return Number.isFinite(rounded) ? rounded : n;\n}\n\n// The error line under a field — the same type as `ui.tsx`'s (module-private) one,\n// so a NumberField's message is indistinguishable from an Input's.\nconst FIELD_ERROR_CLASS = \"mt-1 text-[11px] leading-tight text-[var(--danger)]\";\n\n/**\n * A number field whose value IS a number: {@link NumberInput}'s text, calculator and\n * numpad, with the parse/commit/revert loop every consumer was writing on top of it.\n *\n * WHY THE DRAFT. Half-typed values — \"-\", \"1,\", \"12+\" — are not numbers, and\n * reformatting them mid-keystroke moves the caret and fights the typist; committing\n * per keystroke would also fire whatever a commit fires (a recalculation, a save) for\n * every meaningless intermediate. So the field keeps a draft STRING while it is being\n * typed and resolves it on blur/Enter:\n *\n * - a number (or a calculation, \"2700+50\") → rounded to `digits`, clamped into\n * `[min, max]`, committed, and the draft re-rendered from the result;\n * - empty → `null` when `nullable`, otherwise the same as the next case — `Number(\"\")`\n * is 0, and clearing a wheelbase and tabbing away must not set it to zero;\n * - anything else unparsable → the draft SNAPS BACK to the last committed value\n * rather than sending NaN on for a server to reject.\n *\n * The draft follows `value` when it changes from outside (a reset, a loaded record);\n * a change that came from here arrives back identical and is a no-op.\n *\n * LIVE MODE. A calculator that recomputes per keystroke used to have to pick: this\n * field (limits, rounding, but a number only on blur) or a bare NumberInput (every\n * keystroke, but a string and no limits). `onValueChange` is the per-keystroke\n * channel here, next to the commit rather than instead of it: live numbers for the\n * read-out, `onCommit` for the save. It lives on this field rather than as\n * `min`/`max`/`digits` on NumberInput because parsing, the locale's mark and the\n * limits are what this field already is — a second copy on the string field would be\n * a second, subtly different NumberField.\n *\n * STEPS. `step` (with `min`/`max` as bounds) turns on ArrowUp/ArrowDown and PageUp/\n * PageDown (×10) — see NumberInput's `step`. A step is a draft change like a\n * keystroke: `onValueChange` hears it at once, `onCommit` on blur/Enter. Pick a\n * `step` that `digits` can show (0.25 needs `digits={2}`), or the commit rounds it.\n *\n * LOCALE. The decimal mark is the locale's: a German user sees and types \"1,5\".\n * NumberInput normalises every keystroke to a dot (so its evaluator reads one\n * alphabet); this field maps the dot back before the draft is shown, which also\n * means the numpad's \".\" key types the mark the field is displaying. There is no\n * digit grouping — \"1.000\" would read as a thousand in German and as one in English,\n * and a field that parses its own display back must not have that ambiguity.\n */\nexport function NumberField(props: NumberFieldProps) {\n const {\n value,\n onCommit,\n onValueChange,\n digits,\n min,\n max,\n nullable = false,\n unit,\n unitPlacement = \"suffix\",\n locale: localeProp,\n label,\n invalid,\n error,\n id,\n \"aria-describedby\": ariaDescribedBy,\n \"aria-invalid\": ariaInvalid,\n \"aria-required\": ariaRequired,\n ...rest\n } = props;\n const locale = useKitLocale(localeProp);\n const mark = decimalMark(locale);\n const localize = (text: string) => (mark === \",\" ? text.replace(/\\./g, \",\") : text);\n // `formatResult` for fixed notation: `String(1e-7)` is \"1e-7\", which the field\n // would read back as 17 (see calc.ts).\n const format = (n: number | null) =>\n n === null || !Number.isFinite(n) ? \"\" : localize(formatResult(roundTo(n, digits)));\n\n const [draft, setDraft] = useState(() => format(value));\n // Everything the displayed text depends on, not just `value`: switching the\n // provider's language or a field's `digits` has to re-render the draft too.\n const [seen, setSeen] = useState({ value, mark, digits });\n // The last number handed to `onCommit`, so Enter-then-blur (both commit) and a\n // parent that does not echo the value back cannot commit the same number twice.\n // State rather than a ref because the external-change branch below resets it\n // during render, and a ref may not be written there.\n const [committed, setCommitted] = useState(value);\n // The last number handed to `onValueChange` — state for the same reason.\n const [reported, setReported] = useState(value);\n\n // Adjust state during render rather than in an effect: React re-renders at once\n // with the new draft, before anything is painted, so there is no frame showing\n // the old text.\n if (seen.value !== value || seen.mark !== mark || seen.digits !== digits) {\n setSeen({ value, mark, digits });\n // Live mode echoing back what it was just told: the draft already says it, in\n // the typist's own spelling (\"1,\" or \"12+5\"), and reformatting would fight them.\n // Only a changed `value` qualifies — a new mark or `digits` still re-renders.\n const echo =\n onValueChange !== undefined && value === reported && seen.mark === mark && seen.digits === digits;\n if (!echo) {\n setDraft(format(value));\n setCommitted(value);\n setReported(value);\n }\n }\n\n const report = (next: number | null) => {\n if (next === reported) return;\n setReported(next);\n onValueChange?.(next);\n };\n\n const onText = (text: string) => {\n setDraft(localize(text));\n const live = parseLive(text);\n if (live !== undefined) report(live);\n };\n\n const commit = (text: string) => {\n const trimmed = text.trim();\n let next: number | null;\n const snapBack = () => {\n setDraft(format(committed));\n report(committed);\n };\n if (trimmed === \"\") {\n if (!nullable) return snapBack();\n next = null;\n } else {\n // `text` is NumberInput's committed form: an evaluated calculation, dot-decimal.\n // What survives its digits-only fallback can still be \"-\" or \".\" alone.\n const parsed = Number(trimmed.replace(/,/g, \".\"));\n if (!Number.isFinite(parsed)) return snapBack();\n next = roundTo(parsed, digits);\n if (min !== undefined) next = Math.max(min, next);\n if (max !== undefined) next = Math.min(max, next);\n }\n // Always re-render the draft from the result — \"007\" becomes \"7\", a clamped 500\n // shows the 100 it was clamped to — even when there is nothing new to commit.\n setDraft(format(next));\n report(next);\n if (next !== committed) {\n setCommitted(next);\n onCommit(next);\n }\n };\n\n const generatedId = useId();\n const fieldId = id ?? generatedId;\n const errorId = useId();\n // `null`/`false`/`\"\"` are what `touched && errors.x` evaluates to on the happy path.\n const hasError = error !== undefined && error !== null && error !== false && error !== \"\";\n const describedBy = [ariaDescribedBy, hasError ? errorId : undefined].filter(Boolean).join(\" \");\n\n // Straight onto NumberInput's <input>, which declares both now. They used to be set\n // on the DOM node from an effect, because NumberInput had no props for them.\n const required = ariaRequired === true || ariaRequired === \"true\";\n\n const labelWithUnit =\n unit !== undefined && unitPlacement === \"label\"\n ? label === undefined || label === \"\"\n ? unit\n : typeof label === \"string\" && typeof unit === \"string\"\n ? `${label} (${unit})`\n : (\n <>\n {label} ({unit})\n </>\n )\n : label;\n\n const field = (\n <NumberInput\n {...rest}\n id={fieldId}\n aria-describedby={describedBy || undefined}\n aria-required={required || undefined}\n label={labelWithUnit}\n value={draft}\n onChange={onText}\n onCommit={commit}\n min={min}\n max={max}\n suffix={unitPlacement === \"suffix\" ? unit : undefined}\n invalid={invalid || hasError || ariaInvalid === true || ariaInvalid === \"true\"}\n />\n );\n // Input's FieldGroup, restated: no wrapper for a field that never takes `error`, so\n // the prop changed nothing about the DOM a flex row lays out — and a STEADY one for a\n // field that does, message or not. Wrapping only while a message showed swapped the\n // element above the <input>, which remounted it: focus, caret and the half-typed\n // draft's keystrokes were lost the moment the message came or went.\n if (!hasError && !(\"error\" in props)) return field;\n return (\n <div>\n {field}\n {hasError && (\n <p id={errorId} className={FIELD_ERROR_CLASS}>\n {error}\n </p>\n )}\n </div>\n );\n}\n\n/**\n * A {@link NumberField} for whole numbers: `digits={0}` and `calculator={false}` as\n * defaults, each overridable — the preset `RhfIntegerField` (`@eifi1/ui-kit/rhf`) applies to a\n * form-bound field, for the plain one (kastlan 51: a building's construction year in a\n * wizard's local state, an entity id in the document upload spelled the two props out\n * by hand).\n *\n * `??` rather than a spread over the defaults, so a wrapper forwarding its own optional\n * `digits={props.digits}` still gets the preset.\n */\nexport function IntegerField(props: NumberFieldProps) {\n return <NumberField {...props} digits={props.digits ?? 0} calculator={props.calculator ?? false} />;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -2,7 +2,7 @@ import * as react from 'react';
|
|
|
2
2
|
import { ReactNode } from 'react';
|
|
3
3
|
import { CalculatorButtonLabels } from './calculator.js';
|
|
4
4
|
import { NumberPadSheetLabels } from './numpad-sheet.js';
|
|
5
|
-
import '../kit-labels-
|
|
5
|
+
import '../kit-labels-D1J2_jyD.js';
|
|
6
6
|
import './data-table-labels.js';
|
|
7
7
|
import './mini-calendar.js';
|
|
8
8
|
import './calendar-heatmap.js';
|
|
@@ -53,6 +53,18 @@ import './authed-image.js';
|
|
|
53
53
|
import '../hooks/use-authed-src.js';
|
|
54
54
|
import './image-grid.js';
|
|
55
55
|
import './lightbox.js';
|
|
56
|
+
import './write-lock.js';
|
|
57
|
+
import './legal.js';
|
|
58
|
+
import './account-chips.js';
|
|
59
|
+
import '../lib/format.js';
|
|
60
|
+
import '../data-table-3mi2R3so.js';
|
|
61
|
+
import './data-table-sort.js';
|
|
62
|
+
import './swipeable-row.js';
|
|
63
|
+
import './reauth-dialog.js';
|
|
64
|
+
import './server-wake.js';
|
|
65
|
+
import '../lib/server-wake.js';
|
|
66
|
+
import './translation-review-labels.js';
|
|
67
|
+
import '../lib/translation-review.js';
|
|
56
68
|
|
|
57
69
|
interface NumberInputProps {
|
|
58
70
|
value: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { ReactNode } from 'react';
|
|
3
|
-
import { C as CalculatorLabels } from '../kit-labels-
|
|
3
|
+
import { C as CalculatorLabels } from '../kit-labels-D1J2_jyD.js';
|
|
4
4
|
import './data-table-labels.js';
|
|
5
5
|
import './mini-calendar.js';
|
|
6
6
|
import './calendar-heatmap.js';
|
|
@@ -51,6 +51,18 @@ import './authed-image.js';
|
|
|
51
51
|
import '../hooks/use-authed-src.js';
|
|
52
52
|
import './image-grid.js';
|
|
53
53
|
import './lightbox.js';
|
|
54
|
+
import './write-lock.js';
|
|
55
|
+
import './legal.js';
|
|
56
|
+
import './account-chips.js';
|
|
57
|
+
import '../lib/format.js';
|
|
58
|
+
import '../data-table-3mi2R3so.js';
|
|
59
|
+
import './data-table-sort.js';
|
|
60
|
+
import './swipeable-row.js';
|
|
61
|
+
import './reauth-dialog.js';
|
|
62
|
+
import './server-wake.js';
|
|
63
|
+
import '../lib/server-wake.js';
|
|
64
|
+
import './translation-review-labels.js';
|
|
65
|
+
import '../lib/translation-review.js';
|
|
54
66
|
|
|
55
67
|
/**
|
|
56
68
|
* A full calculator keypad rendered as a bottom sheet — the mobile counterpart to
|
|
@@ -35,6 +35,32 @@ interface PasskeysSettingProps<Id extends PasskeyId = PasskeyId> extends Passkey
|
|
|
35
35
|
* the user cancelling the OS prompt) keeps what was typed.
|
|
36
36
|
*/
|
|
37
37
|
onAdd: (name: string) => void | Promise<unknown>;
|
|
38
|
+
/**
|
|
39
|
+
* A step the app runs BEFORE `onAdd` (0.18.0) — re-asking for the current password,
|
|
40
|
+
* a policy notice, anything that may stop the add. Called with the same trimmed name;
|
|
41
|
+
* resolve `true` to go on to `onAdd`, `false` to stop quietly (nothing is said, the
|
|
42
|
+
* typed name stays). A rejection, or a throw, stops the same way: the app reports
|
|
43
|
+
* its own errors. While it runs the add button is disabled and busy, so a second
|
|
44
|
+
* click cannot start a second check.
|
|
45
|
+
*
|
|
46
|
+
* Kurvenschmiede asked for the password in its own dialog by handing `onAdd` a
|
|
47
|
+
* promise it kept open across that dialog (passkeys-card.tsx) — which still works.
|
|
48
|
+
* This is the documented place for it: open the dialog here, resolve with whether it
|
|
49
|
+
* was confirmed, and keep what it produced (the server's registration options) for
|
|
50
|
+
* `onAdd` in a ref. The ceremony then starts from the dialog's click rather than the
|
|
51
|
+
* card's, which is the user activation `navigator.credentials.create` wants.
|
|
52
|
+
*
|
|
53
|
+
* Left out, `onAdd` is called synchronously in the click, exactly as before.
|
|
54
|
+
*/
|
|
55
|
+
beforeAdd?: (name: string) => boolean | Promise<boolean>;
|
|
56
|
+
/**
|
|
57
|
+
* What a passkey does to the password, which picks the line under the title (0.18.0).
|
|
58
|
+
* `"instead"` (default): it replaces it — `labels.description`, kastlan's promise.
|
|
59
|
+
* `"alongside"`: the password stays and the passkey is a second way in —
|
|
60
|
+
* `labels.descriptionAlongside`. Kurvenschmiede overrode `description` for exactly
|
|
61
|
+
* this; an app that sets `mode` needs no override.
|
|
62
|
+
*/
|
|
63
|
+
mode?: "instead" | "alongside";
|
|
38
64
|
/** The ceremony is running. The add button says so and does not start a second one. */
|
|
39
65
|
adding?: boolean;
|
|
40
66
|
/** Offer "Rename" on each row. Return a promise to leave edit mode only on success. */
|
|
@@ -82,6 +108,6 @@ interface PasskeysSettingProps<Id extends PasskeyId = PasskeyId> extends Passkey
|
|
|
82
108
|
* the passkey it names. Focus moves to Cancel, as in every destructive confirm in the
|
|
83
109
|
* kit, so the Enter that pressed Delete cannot also answer it.
|
|
84
110
|
*/
|
|
85
|
-
declare function PasskeysSetting<Id extends PasskeyId = PasskeyId>({ passkeys, loading, onAdd, adding, onRename, onDelete, busyId, nameField, unavailable, formatDate, locale: localeProp, labels: labelsProp, className, rowProps, ...rest }: PasskeysSettingProps<Id>): react.JSX.Element;
|
|
111
|
+
declare function PasskeysSetting<Id extends PasskeyId = PasskeyId>({ passkeys, loading, onAdd, beforeAdd, mode, adding, onRename, onDelete, busyId, nameField, unavailable, formatDate, locale: localeProp, labels: labelsProp, className, rowProps, ...rest }: PasskeysSettingProps<Id>): react.JSX.Element;
|
|
86
112
|
|
|
87
113
|
export { type PasskeyDataAttributes, type PasskeyId, type PasskeyItem, PasskeysSetting, type PasskeysSettingProps };
|
|
@@ -11,6 +11,8 @@ function PasskeysSetting({
|
|
|
11
11
|
passkeys,
|
|
12
12
|
loading,
|
|
13
13
|
onAdd,
|
|
14
|
+
beforeAdd,
|
|
15
|
+
mode = "instead",
|
|
14
16
|
adding = false,
|
|
15
17
|
onRename,
|
|
16
18
|
onDelete,
|
|
@@ -27,6 +29,8 @@ function PasskeysSetting({
|
|
|
27
29
|
const labels = useAccountSettingsLabels("passkeys", labelsProp);
|
|
28
30
|
const locale = useKitLocale(localeProp);
|
|
29
31
|
const [name, setName] = useState("");
|
|
32
|
+
const [checking, setChecking] = useState(false);
|
|
33
|
+
const checkingRef = useRef(false);
|
|
30
34
|
const date = (value) => {
|
|
31
35
|
if (value === null || value === void 0 || value === "") return null;
|
|
32
36
|
const d = value instanceof Date ? value : new Date(value);
|
|
@@ -34,11 +38,33 @@ function PasskeysSetting({
|
|
|
34
38
|
return formatDate ? formatDate(d) : new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(d);
|
|
35
39
|
};
|
|
36
40
|
const add = () => {
|
|
37
|
-
if (adding) return;
|
|
41
|
+
if (adding || checkingRef.current) return;
|
|
38
42
|
const typed = name;
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
const value = typed.trim();
|
|
44
|
+
const send = () => Promise.resolve(onAdd(value)).then(() => setName((current) => current === typed ? "" : current));
|
|
45
|
+
if (!beforeAdd) {
|
|
46
|
+
send().catch(() => {
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const done = () => {
|
|
51
|
+
checkingRef.current = false;
|
|
52
|
+
setChecking(false);
|
|
53
|
+
};
|
|
54
|
+
checkingRef.current = true;
|
|
55
|
+
setChecking(true);
|
|
56
|
+
let gate;
|
|
57
|
+
try {
|
|
58
|
+
gate = Promise.resolve(beforeAdd(value));
|
|
59
|
+
} catch (error) {
|
|
60
|
+
gate = Promise.reject(error);
|
|
61
|
+
}
|
|
62
|
+
gate.then((go) => {
|
|
63
|
+
done();
|
|
64
|
+
if (go === true) return send();
|
|
65
|
+
}).catch(done);
|
|
41
66
|
};
|
|
67
|
+
const busy = adding || checking;
|
|
42
68
|
const isLoading = loading || passkeys === void 0;
|
|
43
69
|
const items = passkeys ?? [];
|
|
44
70
|
const body = (() => {
|
|
@@ -77,7 +103,7 @@ function PasskeysSetting({
|
|
|
77
103
|
}
|
|
78
104
|
}
|
|
79
105
|
),
|
|
80
|
-
/* @__PURE__ */ jsxs(Button, { onClick: add, disabled:
|
|
106
|
+
/* @__PURE__ */ jsxs(Button, { onClick: add, disabled: busy, "aria-busy": busy || void 0, children: [
|
|
81
107
|
/* @__PURE__ */ jsx(KeyRound, { className: "size-4", "aria-hidden": true }),
|
|
82
108
|
adding ? labels.adding : labels.add
|
|
83
109
|
] })
|
|
@@ -89,7 +115,7 @@ function PasskeysSetting({
|
|
|
89
115
|
/* @__PURE__ */ jsxs(Card, { ...rest, className: cn("p-4 space-y-3", className), children: [
|
|
90
116
|
/* @__PURE__ */ jsxs("div", { children: [
|
|
91
117
|
/* @__PURE__ */ jsx("div", { className: "text-sm font-medium", children: labels.title }),
|
|
92
|
-
/* @__PURE__ */ jsx("div", { className: "text-xs text-[var(--text-muted)]", children: labels.description })
|
|
118
|
+
/* @__PURE__ */ jsx("div", { className: "text-xs text-[var(--text-muted)]", children: mode === "alongside" ? labels.descriptionAlongside : labels.description })
|
|
93
119
|
] }),
|
|
94
120
|
body
|
|
95
121
|
] })
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/passkeys-setting.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { KeyRound } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\nimport { Button, Card, EmptyState, Input } from \"./ui\";\nimport { Skeleton } from \"./skeleton\";\nimport { useAccountSettingsLabels } from \"./account-settings-labels\";\nimport type { PasskeysSettingLabels } from \"./account-settings-labels\";\n\n/** What a passkey's `id` may be: the app's own key type, string or numeric. */\nexport type PasskeyId = string | number;\n\n/**\n * One registered passkey, as the app's API lists it.\n *\n * Generic in its `id` (0.16.0): `PasskeysSetting` infers `Id` from `passkeys`, so an\n * app whose ids are numbers gets numbers back in `onRename` / `onDelete` — keksdose\n * cast `id as number` in both. Left unparameterised it is the old `string | number`.\n */\nexport interface PasskeyItem<Id extends PasskeyId = PasskeyId> {\n id: Id;\n name: string;\n /** A `Date`, an ISO string or epoch ms. */\n createdAt?: Date | string | number | null;\n /** Null (or left out) reads as \"Never used\". */\n lastUsedAt?: Date | string | number | null;\n}\n\n/** `data-*` attributes, passed through to the element they are given for. */\nexport type PasskeyDataAttributes = { [key: `data-${string}`]: string | number | boolean | undefined };\n\nexport interface PasskeysSettingProps<Id extends PasskeyId = PasskeyId> extends PasskeyDataAttributes {\n /** `undefined` while the first load is in flight (or pass `loading`). */\n passkeys: readonly PasskeyItem<Id>[] | undefined;\n loading?: boolean;\n /**\n * Run the WebAuthn ceremony and register the passkey — app-side, since the options\n * and the attestation are the app's server's business. Called with the trimmed name\n * field, which may be EMPTY: pick your own default (\"Passkey\", the device name).\n * Return the promise and the field clears when it resolves; a rejection (including\n * the user cancelling the OS prompt) keeps what was typed.\n */\n onAdd: (name: string) => void | Promise<unknown>;\n /** The ceremony is running. The add button says so and does not start a second one. */\n adding?: boolean;\n /** Offer \"Rename\" on each row. Return a promise to leave edit mode only on success. */\n onRename?: (id: Id, name: string) => void | Promise<unknown>;\n /** Offer \"Delete\" on each row — always behind an inline confirmation. */\n onDelete?: (id: Id) => void | Promise<unknown>;\n /** A row whose rename or delete is in flight; its buttons are disabled. */\n busyId?: Id | null;\n /** Show the name field above the add button. Default true. */\n nameField?: boolean;\n /**\n * Shown INSTEAD of the list and the add button — passkeys are not supported by this\n * browser, or the page is not a secure context. Its wording is the app's: only the\n * app knows which of those it detected.\n */\n unavailable?: ReactNode;\n /** A date for the \"Added …\" / \"Last used …\" lines. Default: the locale's medium date. */\n formatDate?: (date: Date) => string;\n /** Overrides the provider's locale for the default date format. */\n locale?: string;\n /** Prop > `<UiKitProvider labels={{ accountSettings: { passkeys } }}>` > English. */\n labels?: Partial<PasskeysSettingLabels>;\n className?: string;\n /** On the card (0.16.0), for an in-page link (`#passkeys`) or a test hook. `data-*`\n * attributes go there too. */\n id?: string;\n /** Extra attributes for one row's `<li>` (0.16.0) — an `id` to scroll a just-added\n * key into view, `data-*` for a test. Every row also carries `data-passkey-id`. */\n rowProps?: (item: PasskeyItem<Id>) => ({ id?: string } & PasskeyDataAttributes) | undefined;\n}\n\n/**\n * The passkeys section of an account page: what is registered, when it was last used,\n * rename and delete, and \"Add passkey\" — the fourth card beside `ProfileSetting`,\n * `PasswordSetting` and `TwoFactorSetting`, and built the same way (a self-contained\n * `<Card>` owning its transient form state; the app owns the data and the API).\n *\n * Lifted from kastlan's passkeys-card. **The WebAuthn ceremony stays app-side**:\n * `navigator.credentials.create` needs options minted by the app's server and hands\n * back an attestation only that server can verify, so the kit calls `onAdd` and waits.\n *\n * **Delete confirms inline**, in the row, rather than through `useConfirm`: this card\n * must work in an app without a `ConfirmProvider`, and the question belongs next to\n * the passkey it names. Focus moves to Cancel, as in every destructive confirm in the\n * kit, so the Enter that pressed Delete cannot also answer it.\n */\nexport function PasskeysSetting<Id extends PasskeyId = PasskeyId>({\n passkeys,\n loading,\n onAdd,\n adding = false,\n onRename,\n onDelete,\n busyId,\n nameField = true,\n unavailable,\n formatDate,\n locale: localeProp,\n labels: labelsProp,\n className,\n rowProps,\n ...rest\n}: PasskeysSettingProps<Id>) {\n const labels = useAccountSettingsLabels(\"passkeys\", labelsProp);\n const locale = useKitLocale(localeProp);\n const [name, setName] = useState(\"\");\n\n const date = (value: PasskeyItem[\"createdAt\"]): string | null => {\n if (value === null || value === undefined || value === \"\") return null;\n const d = value instanceof Date ? value : new Date(value);\n if (Number.isNaN(d.getTime())) return null;\n return formatDate ? formatDate(d) : new Intl.DateTimeFormat(locale, { dateStyle: \"medium\" }).format(d);\n };\n\n const add = () => {\n if (adding) return;\n const typed = name;\n Promise.resolve(onAdd(typed.trim()))\n // Clear only what was sent: the field stays editable while the OS prompt is up.\n .then(() => setName((current) => (current === typed ? \"\" : current)))\n .catch(() => {});\n };\n\n const isLoading = loading || passkeys === undefined;\n const items = passkeys ?? [];\n\n const body = (() => {\n if (unavailable != null && unavailable !== false) {\n return <EmptyState variant=\"inline\" size=\"sm\" icon={<KeyRound />} title={unavailable} />;\n }\n return (\n <>\n {isLoading ? (\n <Skeleton lines={2} label={labels.loading} />\n ) : items.length === 0 ? (\n <EmptyState variant=\"inline\" size=\"sm\" icon={<KeyRound />} title={labels.empty} />\n ) : (\n <ul aria-label={labels.list} className=\"divide-y divide-[var(--border)]\">\n {items.map((item) => (\n <PasskeyRow\n key={item.id}\n item={item}\n created={date(item.createdAt)}\n lastUsed={date(item.lastUsedAt)}\n attributes={rowProps?.(item)}\n busy={busyId !== undefined && busyId !== null && busyId === item.id}\n onRename={onRename}\n onDelete={onDelete}\n labels={labels}\n />\n ))}\n </ul>\n )}\n <div className=\"space-y-2 pt-1\">\n {nameField && (\n <Input\n label={labels.nameLabel}\n placeholder={labels.namePlaceholder}\n value={name}\n maxLength={120}\n onChange={(e) => setName(e.target.value)}\n onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n add();\n }\n }}\n />\n )}\n <Button onClick={add} disabled={adding} aria-busy={adding || undefined}>\n <KeyRound className=\"size-4\" aria-hidden />\n {adding ? labels.adding : labels.add}\n </Button>\n </div>\n </>\n );\n })();\n\n return (\n // `rest` is only `id` and `data-*`: everything else the props name is destructured.\n <Card {...rest} className={cn(\"p-4 space-y-3\", className)}>\n <div>\n <div className=\"text-sm font-medium\">{labels.title}</div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {labels.description}\n </div>\n </div>\n {body}\n </Card>\n );\n}\n\ntype RowMode = \"view\" | \"rename\" | \"delete\";\n\nfunction PasskeyRow<Id extends PasskeyId>({\n item,\n created,\n lastUsed,\n attributes,\n busy,\n onRename,\n onDelete,\n labels,\n}: {\n item: PasskeyItem<Id>;\n created: string | null;\n lastUsed: string | null;\n attributes?: { id?: string } & PasskeyDataAttributes;\n busy: boolean;\n onRename?: PasskeysSettingProps<Id>[\"onRename\"];\n onDelete?: PasskeysSettingProps<Id>[\"onDelete\"];\n labels: PasskeysSettingLabels;\n}) {\n const [mode, setMode] = useState<RowMode>(\"view\");\n const [draft, setDraft] = useState(item.name);\n // Which button opened the sub-mode, so leaving it puts focus back there — the\n // confirm or the field unmounts under the focus, which otherwise falls to <body>.\n const [returnTo, setReturnTo] = useState<\"rename\" | \"delete\" | null>(null);\n const cancelDeleteRef = useRef<HTMLButtonElement>(null);\n const renameRef = useRef<HTMLInputElement>(null);\n const renameButtonRef = useRef<HTMLButtonElement>(null);\n const deleteButtonRef = useRef<HTMLButtonElement>(null);\n\n useEffect(() => {\n if (mode === \"delete\") cancelDeleteRef.current?.focus();\n else if (mode === \"rename\") renameRef.current?.select();\n else if (returnTo) (returnTo === \"rename\" ? renameButtonRef : deleteButtonRef).current?.focus();\n }, [mode, returnTo]);\n\n const open = (next: \"rename\" | \"delete\") => {\n setReturnTo(next);\n setDraft(item.name);\n setMode(next);\n };\n const leave = () => setMode(\"view\");\n const escapeLeaves = (e: KeyboardEvent<HTMLButtonElement>) => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n leave();\n }\n };\n\n const saveRename = () => {\n const next = draft.trim();\n if (!onRename || !next || busy) return;\n if (next === item.name) {\n leave();\n return;\n }\n Promise.resolve(onRename(item.id, next))\n .then(leave)\n .catch(() => {});\n };\n\n const confirmDelete = () => {\n if (!onDelete || busy) return;\n // The row unmounts once the list refreshes; on a failure it stays, confirm open.\n Promise.resolve(onDelete(item.id))\n .then(() => setMode(\"view\"))\n .catch(() => {});\n };\n\n // On every mode's <li>, so an id the app scrolls to survives a rename or a confirm.\n const li = { ...attributes, \"data-passkey-id\": String(item.id) };\n\n const meta = [created && labels.created(created), lastUsed ? labels.lastUsed(lastUsed) : labels.neverUsed]\n .filter(Boolean)\n .join(\" · \");\n\n if (mode === \"rename\") {\n return (\n <li {...li} className=\"flex flex-wrap items-center gap-2 py-2\">\n <Input\n ref={renameRef}\n aria-label={labels.renameField(item.name)}\n className=\"min-w-0 flex-1\"\n value={draft}\n maxLength={120}\n onChange={(e) => setDraft(e.target.value)}\n onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n saveRename();\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n leave();\n }\n }}\n />\n <Button size=\"sm\" onClick={saveRename} disabled={busy || !draft.trim()}>\n {labels.save}\n </Button>\n <Button size=\"sm\" variant=\"ghost\" onClick={leave}>\n {labels.cancel}\n </Button>\n </li>\n );\n }\n\n if (mode === \"delete\") {\n return (\n <li {...li} className=\"space-y-2 py-2\">\n <p role=\"alert\" className=\"text-sm\">\n {labels.deleteConfirm(item.name)}\n </p>\n <div className=\"flex flex-wrap gap-2\">\n <Button size=\"sm\" variant=\"danger\" onClick={confirmDelete} onKeyDown={escapeLeaves} disabled={busy}>\n {labels.confirmDelete}\n </Button>\n <Button ref={cancelDeleteRef} size=\"sm\" variant=\"ghost\" onClick={leave} onKeyDown={escapeLeaves}>\n {labels.cancel}\n </Button>\n </div>\n </li>\n );\n }\n\n return (\n <li {...li} className=\"flex items-center gap-3 py-2\">\n <KeyRound className=\"size-4 shrink-0 text-[var(--text-muted)]\" aria-hidden />\n <div className=\"min-w-0 flex-1\">\n <div className=\"truncate text-sm font-medium\">{item.name}</div>\n <div className=\"text-xs text-[var(--text-muted)]\">{meta}</div>\n </div>\n {onRename && (\n <Button\n ref={renameButtonRef}\n size=\"sm\"\n variant=\"ghost\"\n disabled={busy}\n onClick={() => open(\"rename\")}\n aria-label={labels.renameItem(item.name)}\n >\n {labels.rename}\n </Button>\n )}\n {onDelete && (\n <Button\n ref={deleteButtonRef}\n size=\"sm\"\n variant=\"ghost\"\n tone=\"danger\"\n disabled={busy}\n onClick={() => open(\"delete\")}\n aria-label={labels.deleteItem(item.name)}\n >\n {labels.delete}\n </Button>\n )}\n </li>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/passkeys-setting.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { KeyRound } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\nimport { Button, Card, EmptyState, Input } from \"./ui\";\nimport { Skeleton } from \"./skeleton\";\nimport { useAccountSettingsLabels } from \"./account-settings-labels\";\nimport type { PasskeysSettingLabels } from \"./account-settings-labels\";\n\n/** What a passkey's `id` may be: the app's own key type, string or numeric. */\nexport type PasskeyId = string | number;\n\n/**\n * One registered passkey, as the app's API lists it.\n *\n * Generic in its `id` (0.16.0): `PasskeysSetting` infers `Id` from `passkeys`, so an\n * app whose ids are numbers gets numbers back in `onRename` / `onDelete` — keksdose\n * cast `id as number` in both. Left unparameterised it is the old `string | number`.\n */\nexport interface PasskeyItem<Id extends PasskeyId = PasskeyId> {\n id: Id;\n name: string;\n /** A `Date`, an ISO string or epoch ms. */\n createdAt?: Date | string | number | null;\n /** Null (or left out) reads as \"Never used\". */\n lastUsedAt?: Date | string | number | null;\n}\n\n/** `data-*` attributes, passed through to the element they are given for. */\nexport type PasskeyDataAttributes = { [key: `data-${string}`]: string | number | boolean | undefined };\n\nexport interface PasskeysSettingProps<Id extends PasskeyId = PasskeyId> extends PasskeyDataAttributes {\n /** `undefined` while the first load is in flight (or pass `loading`). */\n passkeys: readonly PasskeyItem<Id>[] | undefined;\n loading?: boolean;\n /**\n * Run the WebAuthn ceremony and register the passkey — app-side, since the options\n * and the attestation are the app's server's business. Called with the trimmed name\n * field, which may be EMPTY: pick your own default (\"Passkey\", the device name).\n * Return the promise and the field clears when it resolves; a rejection (including\n * the user cancelling the OS prompt) keeps what was typed.\n */\n onAdd: (name: string) => void | Promise<unknown>;\n /**\n * A step the app runs BEFORE `onAdd` (0.18.0) — re-asking for the current password,\n * a policy notice, anything that may stop the add. Called with the same trimmed name;\n * resolve `true` to go on to `onAdd`, `false` to stop quietly (nothing is said, the\n * typed name stays). A rejection, or a throw, stops the same way: the app reports\n * its own errors. While it runs the add button is disabled and busy, so a second\n * click cannot start a second check.\n *\n * Kurvenschmiede asked for the password in its own dialog by handing `onAdd` a\n * promise it kept open across that dialog (passkeys-card.tsx) — which still works.\n * This is the documented place for it: open the dialog here, resolve with whether it\n * was confirmed, and keep what it produced (the server's registration options) for\n * `onAdd` in a ref. The ceremony then starts from the dialog's click rather than the\n * card's, which is the user activation `navigator.credentials.create` wants.\n *\n * Left out, `onAdd` is called synchronously in the click, exactly as before.\n */\n beforeAdd?: (name: string) => boolean | Promise<boolean>;\n /**\n * What a passkey does to the password, which picks the line under the title (0.18.0).\n * `\"instead\"` (default): it replaces it — `labels.description`, kastlan's promise.\n * `\"alongside\"`: the password stays and the passkey is a second way in —\n * `labels.descriptionAlongside`. Kurvenschmiede overrode `description` for exactly\n * this; an app that sets `mode` needs no override.\n */\n mode?: \"instead\" | \"alongside\";\n /** The ceremony is running. The add button says so and does not start a second one. */\n adding?: boolean;\n /** Offer \"Rename\" on each row. Return a promise to leave edit mode only on success. */\n onRename?: (id: Id, name: string) => void | Promise<unknown>;\n /** Offer \"Delete\" on each row — always behind an inline confirmation. */\n onDelete?: (id: Id) => void | Promise<unknown>;\n /** A row whose rename or delete is in flight; its buttons are disabled. */\n busyId?: Id | null;\n /** Show the name field above the add button. Default true. */\n nameField?: boolean;\n /**\n * Shown INSTEAD of the list and the add button — passkeys are not supported by this\n * browser, or the page is not a secure context. Its wording is the app's: only the\n * app knows which of those it detected.\n */\n unavailable?: ReactNode;\n /** A date for the \"Added …\" / \"Last used …\" lines. Default: the locale's medium date. */\n formatDate?: (date: Date) => string;\n /** Overrides the provider's locale for the default date format. */\n locale?: string;\n /** Prop > `<UiKitProvider labels={{ accountSettings: { passkeys } }}>` > English. */\n labels?: Partial<PasskeysSettingLabels>;\n className?: string;\n /** On the card (0.16.0), for an in-page link (`#passkeys`) or a test hook. `data-*`\n * attributes go there too. */\n id?: string;\n /** Extra attributes for one row's `<li>` (0.16.0) — an `id` to scroll a just-added\n * key into view, `data-*` for a test. Every row also carries `data-passkey-id`. */\n rowProps?: (item: PasskeyItem<Id>) => ({ id?: string } & PasskeyDataAttributes) | undefined;\n}\n\n/**\n * The passkeys section of an account page: what is registered, when it was last used,\n * rename and delete, and \"Add passkey\" — the fourth card beside `ProfileSetting`,\n * `PasswordSetting` and `TwoFactorSetting`, and built the same way (a self-contained\n * `<Card>` owning its transient form state; the app owns the data and the API).\n *\n * Lifted from kastlan's passkeys-card. **The WebAuthn ceremony stays app-side**:\n * `navigator.credentials.create` needs options minted by the app's server and hands\n * back an attestation only that server can verify, so the kit calls `onAdd` and waits.\n *\n * **Delete confirms inline**, in the row, rather than through `useConfirm`: this card\n * must work in an app without a `ConfirmProvider`, and the question belongs next to\n * the passkey it names. Focus moves to Cancel, as in every destructive confirm in the\n * kit, so the Enter that pressed Delete cannot also answer it.\n */\nexport function PasskeysSetting<Id extends PasskeyId = PasskeyId>({\n passkeys,\n loading,\n onAdd,\n beforeAdd,\n mode = \"instead\",\n adding = false,\n onRename,\n onDelete,\n busyId,\n nameField = true,\n unavailable,\n formatDate,\n locale: localeProp,\n labels: labelsProp,\n className,\n rowProps,\n ...rest\n}: PasskeysSettingProps<Id>) {\n const labels = useAccountSettingsLabels(\"passkeys\", labelsProp);\n const locale = useKitLocale(localeProp);\n const [name, setName] = useState(\"\");\n // `beforeAdd` is running. State for the button, and a ref for the guard: two clicks\n // in one frame both read the state from before either set it.\n const [checking, setChecking] = useState(false);\n const checkingRef = useRef(false);\n\n const date = (value: PasskeyItem[\"createdAt\"]): string | null => {\n if (value === null || value === undefined || value === \"\") return null;\n const d = value instanceof Date ? value : new Date(value);\n if (Number.isNaN(d.getTime())) return null;\n return formatDate ? formatDate(d) : new Intl.DateTimeFormat(locale, { dateStyle: \"medium\" }).format(d);\n };\n\n const add = () => {\n if (adding || checkingRef.current) return;\n const typed = name;\n const value = typed.trim();\n const send = () =>\n Promise.resolve(onAdd(value))\n // Clear only what was sent: the field stays editable while the OS prompt is up.\n .then(() => setName((current) => (current === typed ? \"\" : current)));\n if (!beforeAdd) {\n send().catch(() => {});\n return;\n }\n const done = () => {\n checkingRef.current = false;\n setChecking(false);\n };\n checkingRef.current = true;\n setChecking(true);\n let gate: Promise<boolean>;\n // Called in the click, not a microtask later, so a step that itself needs the\n // user's activation (a popup, the clipboard) still has it; a throw is a \"no\".\n try {\n gate = Promise.resolve(beforeAdd(value));\n } catch (error) {\n gate = Promise.reject(error);\n }\n gate\n .then((go) => {\n done();\n // Strictly `true`: an app whose step resolves `undefined` meant nothing.\n if (go === true) return send();\n })\n .catch(done);\n };\n const busy = adding || checking;\n\n const isLoading = loading || passkeys === undefined;\n const items = passkeys ?? [];\n\n const body = (() => {\n if (unavailable != null && unavailable !== false) {\n return <EmptyState variant=\"inline\" size=\"sm\" icon={<KeyRound />} title={unavailable} />;\n }\n return (\n <>\n {isLoading ? (\n <Skeleton lines={2} label={labels.loading} />\n ) : items.length === 0 ? (\n <EmptyState variant=\"inline\" size=\"sm\" icon={<KeyRound />} title={labels.empty} />\n ) : (\n <ul aria-label={labels.list} className=\"divide-y divide-[var(--border)]\">\n {items.map((item) => (\n <PasskeyRow\n key={item.id}\n item={item}\n created={date(item.createdAt)}\n lastUsed={date(item.lastUsedAt)}\n attributes={rowProps?.(item)}\n busy={busyId !== undefined && busyId !== null && busyId === item.id}\n onRename={onRename}\n onDelete={onDelete}\n labels={labels}\n />\n ))}\n </ul>\n )}\n <div className=\"space-y-2 pt-1\">\n {nameField && (\n <Input\n label={labels.nameLabel}\n placeholder={labels.namePlaceholder}\n value={name}\n maxLength={120}\n onChange={(e) => setName(e.target.value)}\n onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n add();\n }\n }}\n />\n )}\n <Button onClick={add} disabled={busy} aria-busy={busy || undefined}>\n <KeyRound className=\"size-4\" aria-hidden />\n {adding ? labels.adding : labels.add}\n </Button>\n </div>\n </>\n );\n })();\n\n return (\n // `rest` is only `id` and `data-*`: everything else the props name is destructured.\n <Card {...rest} className={cn(\"p-4 space-y-3\", className)}>\n <div>\n <div className=\"text-sm font-medium\">{labels.title}</div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {mode === \"alongside\"\n ? labels.descriptionAlongside\n : labels.description}\n </div>\n </div>\n {body}\n </Card>\n );\n}\n\ntype RowMode = \"view\" | \"rename\" | \"delete\";\n\nfunction PasskeyRow<Id extends PasskeyId>({\n item,\n created,\n lastUsed,\n attributes,\n busy,\n onRename,\n onDelete,\n labels,\n}: {\n item: PasskeyItem<Id>;\n created: string | null;\n lastUsed: string | null;\n attributes?: { id?: string } & PasskeyDataAttributes;\n busy: boolean;\n onRename?: PasskeysSettingProps<Id>[\"onRename\"];\n onDelete?: PasskeysSettingProps<Id>[\"onDelete\"];\n labels: PasskeysSettingLabels;\n}) {\n const [mode, setMode] = useState<RowMode>(\"view\");\n const [draft, setDraft] = useState(item.name);\n // Which button opened the sub-mode, so leaving it puts focus back there — the\n // confirm or the field unmounts under the focus, which otherwise falls to <body>.\n const [returnTo, setReturnTo] = useState<\"rename\" | \"delete\" | null>(null);\n const cancelDeleteRef = useRef<HTMLButtonElement>(null);\n const renameRef = useRef<HTMLInputElement>(null);\n const renameButtonRef = useRef<HTMLButtonElement>(null);\n const deleteButtonRef = useRef<HTMLButtonElement>(null);\n\n useEffect(() => {\n if (mode === \"delete\") cancelDeleteRef.current?.focus();\n else if (mode === \"rename\") renameRef.current?.select();\n else if (returnTo) (returnTo === \"rename\" ? renameButtonRef : deleteButtonRef).current?.focus();\n }, [mode, returnTo]);\n\n const open = (next: \"rename\" | \"delete\") => {\n setReturnTo(next);\n setDraft(item.name);\n setMode(next);\n };\n const leave = () => setMode(\"view\");\n const escapeLeaves = (e: KeyboardEvent<HTMLButtonElement>) => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n leave();\n }\n };\n\n const saveRename = () => {\n const next = draft.trim();\n if (!onRename || !next || busy) return;\n if (next === item.name) {\n leave();\n return;\n }\n Promise.resolve(onRename(item.id, next))\n .then(leave)\n .catch(() => {});\n };\n\n const confirmDelete = () => {\n if (!onDelete || busy) return;\n // The row unmounts once the list refreshes; on a failure it stays, confirm open.\n Promise.resolve(onDelete(item.id))\n .then(() => setMode(\"view\"))\n .catch(() => {});\n };\n\n // On every mode's <li>, so an id the app scrolls to survives a rename or a confirm.\n const li = { ...attributes, \"data-passkey-id\": String(item.id) };\n\n const meta = [created && labels.created(created), lastUsed ? labels.lastUsed(lastUsed) : labels.neverUsed]\n .filter(Boolean)\n .join(\" · \");\n\n if (mode === \"rename\") {\n return (\n <li {...li} className=\"flex flex-wrap items-center gap-2 py-2\">\n <Input\n ref={renameRef}\n aria-label={labels.renameField(item.name)}\n className=\"min-w-0 flex-1\"\n value={draft}\n maxLength={120}\n onChange={(e) => setDraft(e.target.value)}\n onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n saveRename();\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n leave();\n }\n }}\n />\n <Button size=\"sm\" onClick={saveRename} disabled={busy || !draft.trim()}>\n {labels.save}\n </Button>\n <Button size=\"sm\" variant=\"ghost\" onClick={leave}>\n {labels.cancel}\n </Button>\n </li>\n );\n }\n\n if (mode === \"delete\") {\n return (\n <li {...li} className=\"space-y-2 py-2\">\n <p role=\"alert\" className=\"text-sm\">\n {labels.deleteConfirm(item.name)}\n </p>\n <div className=\"flex flex-wrap gap-2\">\n <Button size=\"sm\" variant=\"danger\" onClick={confirmDelete} onKeyDown={escapeLeaves} disabled={busy}>\n {labels.confirmDelete}\n </Button>\n <Button ref={cancelDeleteRef} size=\"sm\" variant=\"ghost\" onClick={leave} onKeyDown={escapeLeaves}>\n {labels.cancel}\n </Button>\n </div>\n </li>\n );\n }\n\n return (\n <li {...li} className=\"flex items-center gap-3 py-2\">\n <KeyRound className=\"size-4 shrink-0 text-[var(--text-muted)]\" aria-hidden />\n <div className=\"min-w-0 flex-1\">\n <div className=\"truncate text-sm font-medium\">{item.name}</div>\n <div className=\"text-xs text-[var(--text-muted)]\">{meta}</div>\n </div>\n {onRename && (\n <Button\n ref={renameButtonRef}\n size=\"sm\"\n variant=\"ghost\"\n disabled={busy}\n onClick={() => open(\"rename\")}\n aria-label={labels.renameItem(item.name)}\n >\n {labels.rename}\n </Button>\n )}\n {onDelete && (\n <Button\n ref={deleteButtonRef}\n size=\"sm\"\n variant=\"ghost\"\n tone=\"danger\"\n disabled={busy}\n onClick={() => open(\"delete\")}\n aria-label={labels.deleteItem(item.name)}\n >\n {labels.delete}\n </Button>\n )}\n </li>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
3
|
+
import { TextTone } from './signed-amount.js';
|
|
4
|
+
import './stat-tile.js';
|
|
3
5
|
|
|
4
6
|
/** The words a progress bar adds of its own. */
|
|
5
7
|
interface ProgressBarLabels {
|
|
@@ -112,6 +114,18 @@ interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">, "childr
|
|
|
112
114
|
* `legendValue={(seg, share) => <>{share} <Amount … /></>}`.
|
|
113
115
|
*/
|
|
114
116
|
legendValue?: (segment: ProgressBarSegment, formatted: string, index: number) => ReactNode;
|
|
117
|
+
/**
|
|
118
|
+
* The colour of each legend row's figure, as a {@link TextTone} — `toneTextClass`'s
|
|
119
|
+
* palette, the money pair included. Default `"muted"`: the legend explains the bar and
|
|
120
|
+
* should not outshout the headline above it. One tone for every row, or a function
|
|
121
|
+
* per row. A tone and not the segment's own `tone`: keksdose's tax card fills its
|
|
122
|
+
* three parts with one bucket's certainty ramp (`className`), while the FIGURES carry
|
|
123
|
+
* the bucket's money direction — `legendTone="expense"` for the tax owed — which it
|
|
124
|
+
* could only say before through `legendValue` and an app-side `Amount`. A function
|
|
125
|
+
* returning `undefined` keeps that row muted. Applies to `legendValue`'s node too
|
|
126
|
+
* (it inherits the colour unless it sets its own).
|
|
127
|
+
*/
|
|
128
|
+
legendTone?: TextTone | ((segment: ProgressBarSegment, index: number) => TextTone | undefined);
|
|
115
129
|
/** A muted line under the bar — the plan's reset date, "3 of 5 seats in use". It
|
|
116
130
|
* describes the bar (`aria-describedby`). */
|
|
117
131
|
hint?: ReactNode;
|
|
@@ -152,6 +166,6 @@ interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">, "childr
|
|
|
152
166
|
* tokens.css's `.animate-indeterminate` class because that one is UNLAYERED, and an
|
|
153
167
|
* unlayered rule beats every layered utility, `motion-reduce:animate-none` included.
|
|
154
168
|
*/
|
|
155
|
-
declare function ProgressBar({ value, min, max: maxProp, variant, label, showValue, formatValue, tone, size, locale, segments, legend, legendValue, hint, overage, sensitive, labels, className, as, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...rest }: ProgressBarProps): react.JSX.Element;
|
|
169
|
+
declare function ProgressBar({ value, min, max: maxProp, variant, label, showValue, formatValue, tone, size, locale, segments, legend, legendValue, legendTone, hint, overage, sensitive, labels, className, as, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...rest }: ProgressBarProps): react.JSX.Element;
|
|
156
170
|
|
|
157
171
|
export { DEFAULT_PROGRESS_BAR_LABELS, ProgressBar, type ProgressBarLabels, type ProgressBarProps, type ProgressBarSegment, type ProgressBarSize, type ProgressBarTone };
|