@eifi1/ui-kit 0.17.0 → 0.18.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 +29 -4
- package/dist/chart.d.ts +10 -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 +10 -1
- package/dist/components/button-group.d.ts +10 -1
- package/dist/components/calculator.d.ts +10 -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 +10 -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 +10 -1
- package/dist/components/file-button.d.ts +10 -1
- package/dist/components/file-dropzone.d.ts +10 -1
- package/dist/components/form-actions.d.ts +10 -1
- package/dist/components/form-actions.js +3 -0
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/money-field.d.ts +10 -1
- package/dist/components/number-field.d.ts +11 -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 +10 -1
- package/dist/components/numpad-sheet.d.ts +10 -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 +10 -1
- package/dist/components/server-wake.d.ts +58 -0
- package/dist/components/server-wake.js +58 -0
- package/dist/components/server-wake.js.map +1 -0
- package/dist/components/settings-fields.d.ts +10 -1
- package/dist/components/share-card.d.ts +61 -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 +10 -1
- package/dist/components/time-input.d.ts +10 -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/ui.d.ts +10 -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 +10 -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 +10 -1
- package/dist/i18n/defaults.js +11 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +78 -0
- package/dist/i18n/{locales/de.js → german.js} +59 -9
- package/dist/i18n/german.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +10 -1
- package/dist/i18n/kit-labels.js +5 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH.d.ts +23 -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 +10 -1
- package/dist/i18n/locales/es.d.ts +10 -1
- package/dist/i18n/locales/es.js +51 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +10 -1
- package/dist/i18n/locales/fr.js +51 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +10 -1
- package/dist/i18n/locales/hu.js +54 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +10 -1
- package/dist/i18n/locales/it.js +51 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +10 -1
- package/dist/i18n/locales/zh.js +50 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/swiss.d.ts +3 -3
- package/dist/i18n/swiss.js.map +1 -1
- package/dist/index.d.ts +9 -4
- package/dist/index.js +33 -1
- package/dist/index.js.map +1 -1
- package/dist/{kit-labels-C4uB_zmg.d.ts → kit-labels-CPbsjlXr.d.ts} +229 -9
- package/dist/lib/server-wake.d.ts +130 -0
- package/dist/lib/server-wake.js +121 -0
- package/dist/lib/server-wake.js.map +1 -0
- package/dist/rhf/fields.d.ts +10 -1
- package/dist/rhf/form.d.ts +10 -1
- package/dist/rhf.d.ts +10 -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 +10 -1
- package/dist/shell/top-bar-brand.d.ts +10 -1
- package/dist/shell/topbar-action-menu.d.ts +2 -0
- package/dist/shell.d.ts +10 -1
- package/dist/wizard/stepper-nav.d.ts +10 -1
- package/dist/wizard.d.ts +10 -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/form-actions.tsx +22 -3
- 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 +124 -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/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 +10 -0
- package/src/i18n/{locales/de.ts → german.ts} +68 -13
- package/src/i18n/kit-labels.tsx +22 -2
- package/src/i18n/locales/de-CH.ts +17 -8
- package/src/i18n/locales/es.ts +51 -0
- package/src/i18n/locales/fr.ts +52 -0
- package/src/i18n/locales/hu.ts +55 -0
- package/src/i18n/locales/it.ts +51 -0
- package/src/i18n/locales/zh.ts +50 -0
- package/src/i18n/swiss.ts +3 -3
- package/src/index.ts +23 -1
- package/src/lib/server-wake.ts +270 -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
|
@@ -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 };
|
|
@@ -3,6 +3,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useId } from "react";
|
|
4
4
|
import { cn } from "../lib/cn.js";
|
|
5
5
|
import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels.js";
|
|
6
|
+
import { toneTextClass } from "./signed-amount.js";
|
|
6
7
|
const DEFAULT_PROGRESS_BAR_LABELS = {
|
|
7
8
|
unlimited: "Unlimited",
|
|
8
9
|
overLimit: (amount) => `${amount} over the limit`
|
|
@@ -51,6 +52,7 @@ function ProgressBar({
|
|
|
51
52
|
segments,
|
|
52
53
|
legend = false,
|
|
53
54
|
legendValue,
|
|
55
|
+
legendTone = "muted",
|
|
54
56
|
hint,
|
|
55
57
|
overage,
|
|
56
58
|
sensitive = false,
|
|
@@ -202,7 +204,12 @@ function ProgressBar({
|
|
|
202
204
|
{
|
|
203
205
|
"data-part": "legend-value",
|
|
204
206
|
"data-private": priv,
|
|
205
|
-
className:
|
|
207
|
+
className: cn(
|
|
208
|
+
"ms-auto shrink-0 text-xs tabular-nums",
|
|
209
|
+
toneTextClass(
|
|
210
|
+
(typeof legendTone === "function" ? legendTone(seg, i) : legendTone) ?? "muted"
|
|
211
|
+
)
|
|
212
|
+
),
|
|
206
213
|
children: legendValue ? legendValue(seg, formatPart(seg.value), i) : formatPart(seg.value)
|
|
207
214
|
}
|
|
208
215
|
)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/progress-bar.tsx"],"sourcesContent":["import { useId } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\n\n/** The words a progress bar adds of its own. */\nexport interface ProgressBarLabels {\n /** Shown after the value when `max={null}`: a quota with no ceiling. */\n unlimited: string;\n /** The `overage` line: how far past `max` the value is. `amount` is formatted. */\n overLimit: (amount: string) => string;\n}\n\nexport const DEFAULT_PROGRESS_BAR_LABELS: ProgressBarLabels = {\n unlimited: \"Unlimited\",\n overLimit: (amount) => `${amount} over the limit`,\n};\n\n/**\n * `income` / `expense` are the money pair (`--money-income` / `--money-expense`), the\n * names `Chip`, `StatTile` and `Sparkline` already use for them: a share bar of what\n * came in or went out is money, and painting it `success` / `warning` said \"good\" and\n * \"careful\" about a figure that is neither. keksdose's report meters (food-group-card,\n * drilldown-modal) sit beside amounts that are already in the money colours.\n */\nexport type ProgressBarTone =\n | \"brand\"\n | \"neutral\"\n | \"success\"\n | \"warning\"\n | \"danger\"\n | \"info\"\n | \"income\"\n | \"expense\";\n/**\n * Track heights: `sm` 4px, `slim` 6px, `md` 8px, `lg` 12px. `slim` is a word and not a\n * letter because the letters are taken in order — an `xs` at 6px would be THICKER than\n * `sm` — and renaming `sm` would move every bar already on it. 6px is the Slider's\n * track and keksdose's hand-drawn meters (`h-1.5`, landing-visuals), for a bar under a\n * line of text where 8px outweighs the text.\n */\nexport type ProgressBarSize = \"sm\" | \"slim\" | \"md\" | \"lg\";\n\nconst FILL: Record<ProgressBarTone, string> = {\n brand: \"bg-[var(--brand)]\",\n neutral: \"bg-[var(--text-muted)]\",\n success: \"bg-[var(--success)]\",\n warning: \"bg-[var(--warning)]\",\n danger: \"bg-[var(--danger)]\",\n info: \"bg-[var(--info)]\",\n income: \"bg-[var(--money-income)]\",\n expense: \"bg-[var(--money-expense)]\",\n};\n\nconst TRACK_HEIGHT: Record<ProgressBarSize, string> = { sm: \"h-1\", slim: \"h-1.5\", md: \"h-2\", lg: \"h-3\" };\n\n// A segment with no tone of its own takes the next categorical chart colour, so a\n// stacked bar of unnamed parts is still a set of tellable parts rather than one fill.\nconst SERIES_FILL = [\n \"bg-[var(--chart-1)]\",\n \"bg-[var(--chart-2)]\",\n \"bg-[var(--chart-3)]\",\n \"bg-[var(--chart-4)]\",\n \"bg-[var(--chart-5)]\",\n \"bg-[var(--chart-6)]\",\n \"bg-[var(--chart-7)]\",\n \"bg-[var(--chart-8)]\",\n \"bg-[var(--chart-9)]\",\n];\n\n/** Each segment's fill classes: its own classes, else its tone, else (with no `color`)\n * the next chart colour. \"Next\" counts the UNCOLOURED segments only — a segment with\n * its own `className`/`tone`/`color` does not use up `--chart-1`, so an \"Other\" in grey\n * ahead of two plain parts leaves them `--chart-1` and `--chart-2`. */\nfunction segmentFills(segments: ProgressBarSegment[]): Array<string | undefined> {\n let next = 0;\n return segments.map((seg) => {\n if (seg.className) return seg.className;\n if (seg.tone) return FILL[seg.tone];\n return seg.color ? undefined : SERIES_FILL[next++ % SERIES_FILL.length];\n });\n}\n\n/** One part of a stacked bar. See {@link ProgressBarProps.segments}. */\nexport interface ProgressBarSegment {\n /** Its size, on the bar's own `min`–`max` scale. */\n value: number;\n /** A kit tone. Left out (and no `color`/`className`), the next `--chart-N` colour. */\n tone?: ProgressBarTone;\n /** Any CSS colour (`var(--token)`), for a palette the tones do not cover. Wins over `tone`. */\n color?: string;\n /** Classes for the segment's fill — keksdose's `STEP_CLASS` ramp. Wins over `tone`. */\n className?: string;\n /** The part's name: said in the bar's `aria-valuetext` and shown in the legend. */\n label?: ReactNode;\n /** A stable React key, when `label` is not a string. */\n key?: string;\n /**\n * List it in the `legend` but leave it out of the bar — its value, its share of the\n * `aria-valuetext`, and the sum. keksdose's cut card (reports/cut-card:132) names\n * EVERY bucket under its bar, the empty ones included, while the bar draws only the\n * ones with a share: pass the empty buckets as `legendOnly` entries.\n */\n legendOnly?: boolean;\n}\n\nexport interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\" | \"role\"> {\n /**\n * `\"span\"` builds the bar from spans, for a place only phrasing content may go: inside\n * a button or a link (ListItem's `content`). The `legend` stays a list, so leave it off\n * there. Default `\"div\"`.\n */\n as?: \"div\" | \"span\";\n /** Where it stands. Leave it undefined for an INDETERMINATE bar — work is under way\n * and nobody knows how much is left (an import that reports no count). */\n value?: number;\n min?: number;\n /**\n * The top of the scale (default 100). `null` is a quota with NO ceiling — kastlan's\n * billing usage on an unlimited plan (usage-progress-bar:25): nothing to fill, so no\n * bar is drawn; the label row shows the value and \"Unlimited\" (`progressBar.unlimited`)\n * as plain text. `formatValue` is then called with `max` = `Infinity`; without it,\n * the value is a plain number in the kit's locale.\n */\n max?: number | null;\n /**\n * `progress` (default): a task moving toward completion — `role=\"progressbar\"`.\n * `meter`: a share of a known whole that is not going anywhere (budget used, disk\n * space, one category's slice of spending) — `role=\"meter\"`. keksdose's share bars\n * were progress bars to a screen reader, which reads \"busy\" into a figure that is\n * simply a figure. A meter is never indeterminate; a missing value reads as `min`.\n */\n variant?: \"progress\" | \"meter\";\n /** Visible label above the bar, and its accessible name. Without it, pass\n * `aria-label` (an indeterminate bar with neither is named `common.loading`). */\n label?: ReactNode;\n /** Show the value text at the end of the label row. */\n showValue?: boolean;\n /**\n * The value as words — both the visible `showValue` text and `aria-valuetext`.\n * Default: the fraction as a percentage in the kit's locale (\"42 %\" in French).\n * Pass one for anything that is not a percentage: `(v, max) => \\`${v} of ${max} files\\``.\n */\n formatValue?: (value: number, max: number, min: number) => string;\n tone?: ProgressBarTone;\n size?: ProgressBarSize;\n locale?: string;\n /**\n * Draw a METER OF PARTS: one bar, several fills side by side in order, a 2px gap\n * between them — keksdose's discretionary-spending bar (cut-card:133), a whole\n * split into the steps of a scale. Forces `variant=\"meter\"`: parts of a whole are\n * never \"busy\". `aria-valuenow` is the sum; `aria-valuetext` names every part\n * (\"Rent: 40%, Food: 25%\"), each part's value through `formatValue` and joined in the\n * kit's locale — the words keksdose's hand-drawn bar had to hide from a reader\n * altogether, leaving the legend as the only way in. The sum is clamped to `max`,\n * and parts past it are cut off at the track's end.\n */\n segments?: ProgressBarSegment[];\n /** With `segments`: a list under the bar naming each part with its swatch and value\n * — the table view of the bar, in text tokens rather than the segment colour. */\n legend?: boolean;\n /**\n * What a legend row shows at its end, in place of the part's formatted value — which\n * it is handed, with the segment and its index. That value is the part's OWN, signed\n * and unclamped (`formatValue`, or a percentage of the scale): a part of −40 on a\n * 0–100 scale says \"-40%\" though the bar draws nothing for it. keksdose's cut card\n * shows the share AND a private outflow amount per bucket (cut-card:150), which one\n * figure could not:\n * `legendValue={(seg, share) => <>{share} <Amount … /></>}`.\n */\n legendValue?: (segment: ProgressBarSegment, formatted: string, index: number) => ReactNode;\n /** A muted line under the bar — the plan's reset date, \"3 of 5 seats in use\". It\n * describes the bar (`aria-describedby`). */\n hint?: ReactNode;\n /**\n * When `value` is past `max`, say by how much, in a danger-coloured line under the bar\n * (kastlan usage-progress-bar:46): `true` prints `progressBar.overLimit` with the\n * excess as a plain number; a function renders the line itself from the excess (a\n * currency, \"2 seats\"). The bar stays full at `max` either way — colour it with\n * `tone=\"danger\"` if it should read as over. Ignored for `segments` and `max={null}`.\n */\n overage?: boolean | ((over: number) => ReactNode);\n /**\n * Tag every figure the bar PRINTS `data-private`, so the host's demo-mode rule blurs\n * it — the attribute `StatTile sensitive` and `Tooltip redact` carry: the `showValue`\n * text, each `legend` row's value (yours from `legendValue` too), the unlimited\n * figure and the `overage` line. The track, the swatches and the part names stay\n * readable — a blurred bar is no picture at all. Before this an app with a demo-mode\n * blur had to pass `legendValue` only to wrap the figure in a tagged span (keksdose).\n * The `aria-valuetext` is not touched: a screen reader is not a screen-share.\n */\n sensitive?: boolean;\n labels?: Partial<ProgressBarLabels>;\n}\n\n/**\n * A horizontal bar that fills toward `max`.\n *\n * keksdose (import progress, budget share bars) and kastlan (upload and job\n * progress) each drew their own div-in-a-div. None had a role, so a reader heard\n * nothing at all; the share bars that did have one claimed to be progress.\n *\n * `aria-valuetext` is always set when there is a value: a bare `aria-valuenow=\"37\"`\n * is read as \"37\" by some readers and \"37 percent\" by others, and neither is right\n * when `max` is 212 files.\n *\n * The indeterminate sweep stops under `prefers-reduced-motion`. It becomes a still,\n * half-strength bar across the whole track — still visibly \"something is happening\",\n * with nothing moving. The sweep is a Tailwind arbitrary `animate-[…]` rather than\n * tokens.css's `.animate-indeterminate` class because that one is UNLAYERED, and an\n * unlayered rule beats every layered utility, `motion-reduce:animate-none` included.\n */\nexport function ProgressBar({\n value,\n min = 0,\n max: maxProp = 100,\n variant = \"progress\",\n label,\n showValue = false,\n formatValue,\n tone = \"brand\",\n size = \"md\",\n locale,\n segments,\n legend = false,\n legendValue,\n hint,\n overage,\n sensitive = false,\n labels,\n className,\n as = \"div\",\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledBy,\n \"aria-describedby\": ariaDescribedBy,\n ...rest\n}: ProgressBarProps) {\n // `as=\"span\"`: the same bar from phrasing elements only, so it is valid inside a\n // <button> or <a> — ListItem's `content` puts a budget bar inside the row's target\n // (keksdose P2). Every span is made a block except those already laid out as flex.\n const Tag = as;\n const inlineClass = as === \"span\" ? \"block [&_span:not(.flex):not(.inline-flex)]:block\" : undefined;\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const text = useKitLabels(\"progressBar\", DEFAULT_PROGRESS_BAR_LABELS, labels);\n const kitLocale = useKitLocale(locale);\n const labelId = useId();\n const priv = sensitive ? \"\" : undefined;\n const hintId = useId();\n const hintNode =\n hint != null ? (\n <p id={hintId} data-part=\"hint\" className=\"mt-1 text-xs text-[var(--text-muted)]\">\n {hint}\n </p>\n ) : null;\n\n if (maxProp === null) {\n // No ceiling: no bar, no role — a meter with no maximum is not one. The value and\n // \"Unlimited\" are the content, so they are plain visible text.\n const shown =\n value === undefined || !Number.isFinite(value)\n ? undefined\n : formatValue\n ? formatValue(value, Infinity, min)\n : new Intl.NumberFormat(kitLocale).format(value);\n return (\n <Tag {...rest} className={cn(\"min-w-0\", inlineClass, className)} data-state=\"unlimited\">\n <Tag className=\"flex items-baseline gap-2 text-sm\">\n {label != null && (\n <span id={labelId} className=\"min-w-0 text-[var(--text-secondary)]\">\n {label}\n </span>\n )}\n <span data-part=\"unlimited\" data-private={priv} className=\"ms-auto shrink-0 tabular-nums text-[var(--text-muted)]\">\n {shown !== undefined && `${shown} · `}\n {text.unlimited}\n </span>\n </Tag>\n {hintNode}\n </Tag>\n );\n }\n const max = maxProp;\n\n const stacked = segments !== undefined;\n const meter = stacked || variant === \"meter\";\n const span = max - min;\n const partValue = (v: number) => (Number.isFinite(v) ? Math.max(0, v - min) : 0);\n // Colours are dealt over EVERY segment, legend-only ones included, so an entry keeps\n // its swatch whether or not it is in the bar this time.\n const fills = stacked ? segmentFills(segments) : [];\n const barParts = stacked ? segments.filter((seg) => !seg.legendOnly) : [];\n const partTotal = stacked ? min + barParts.reduce((sum, seg) => sum + partValue(seg.value), 0) : undefined;\n const format = (v: number) =>\n formatValue\n ? formatValue(v, max, min)\n : new Intl.NumberFormat(kitLocale, { style: \"percent\", maximumFractionDigits: 0 }).format(\n span <= 0 ? 0 : (Math.min(max, Math.max(min, v)) - min) / span,\n );\n // A PART is stated as its own value, sign and all — not as the slice of track it\n // draws. The bar cannot draw a negative part (a refund inside a month's spending) or\n // one past `max`, but the legend and the reader can still say it: before 0.16 a\n // −CHF 40 part read \"0 %\" in both, and keksdose had to pass `legendValue` just to get\n // the minus back. For a part inside `min`–`max` this is the same text as before.\n const formatPart = (v: number) => {\n if (!Number.isFinite(v)) return format(min);\n if (formatValue) return formatValue(v, max, min);\n return new Intl.NumberFormat(kitLocale, { style: \"percent\", maximumFractionDigits: 0 }).format(\n span <= 0 ? 0 : (v - min) / span,\n );\n };\n const partTexts = stacked\n ? barParts.map((seg) => {\n const text = formatPart(seg.value);\n return typeof seg.label === \"string\" || typeof seg.label === \"number\"\n ? common.fieldValue(String(seg.label), text)\n : text;\n })\n : [];\n const current =\n partTotal ?? (value === undefined || !Number.isFinite(value) ? (meter ? min : undefined) : value);\n const indeterminate = current === undefined;\n const clamped = indeterminate ? undefined : Math.min(max, Math.max(min, current));\n const fraction = clamped === undefined || span <= 0 ? 0 : (clamped - min) / span;\n\n const valueText = clamped === undefined ? undefined : format(clamped);\n const ariaValueText =\n stacked && partTexts.length > 0\n ? new Intl.ListFormat(kitLocale, { style: \"short\", type: \"unit\" }).format(partTexts)\n : valueText;\n\n const over =\n overage && !stacked && value !== undefined && Number.isFinite(value) && value > max ? value - max : 0;\n const overageNode =\n over > 0 ? (\n <p id={`${hintId}-over`} data-part=\"overage\" data-private={priv} className=\"mt-1 text-xs text-[var(--danger)]\">\n {typeof overage === \"function\"\n ? overage(over)\n : text.overLimit(new Intl.NumberFormat(kitLocale).format(over))}\n </p>\n ) : null;\n const describedBy =\n [ariaDescribedBy, hintNode && hintId, overageNode && `${hintId}-over`].filter(Boolean).join(\" \") || undefined;\n\n const labelledBy = ariaLabelledBy ?? (label != null ? labelId : undefined);\n const name = labelledBy ? undefined : (ariaLabel ?? (indeterminate ? common.loading : undefined));\n\n return (\n <Tag className={cn(\"min-w-0\", inlineClass, className)} data-state={indeterminate ? \"indeterminate\" : \"determinate\"}>\n {(label != null || (showValue && valueText !== undefined)) && (\n <Tag className=\"mb-1 flex items-baseline gap-2 text-sm\">\n {label != null && (\n <span id={labelId} className=\"min-w-0 text-[var(--text-secondary)]\">\n {label}\n </span>\n )}\n {showValue && valueText !== undefined && (\n // Hidden: the same words are the bar's aria-valuetext, and reading them\n // twice in a row is noise.\n <span aria-hidden data-part=\"value\" data-private={priv} className=\"ms-auto shrink-0 tabular-nums text-[var(--text-muted)]\">\n {valueText}\n </span>\n )}\n </Tag>\n )}\n <Tag\n {...rest}\n role={meter ? \"meter\" : \"progressbar\"}\n aria-label={name}\n aria-labelledby={labelledBy}\n aria-describedby={describedBy}\n aria-valuemin={indeterminate ? undefined : min}\n aria-valuemax={indeterminate ? undefined : max}\n aria-valuenow={clamped}\n aria-valuetext={ariaValueText}\n aria-busy={!meter && indeterminate ? true : undefined}\n // `relative` + `overflow-hidden`: the indeterminate segment is positioned in\n // here and must not paint past the rounded ends.\n className={cn(\"relative w-full overflow-hidden rounded-full bg-[var(--bg-active)]\", TRACK_HEIGHT[size])}\n >\n {stacked ? (\n // The parts in a flex row with a 2px gap, so two neighbouring steps of one\n // hue are parted by the track rather than by their difference in lightness.\n <Tag className=\"flex h-full w-full gap-0.5\">\n {segments.map((seg, i) =>\n seg.legendOnly ? null : (\n <Tag\n key={seg.key ?? i}\n data-part=\"segment\"\n className={cn(\n \"h-full shrink-0 rounded-full transition-[width] duration-300 motion-reduce:transition-none\",\n fills[i],\n )}\n style={{\n width: `${span <= 0 ? 0 : (partValue(seg.value) / span) * 100}%`,\n backgroundColor: seg.className ? undefined : seg.color,\n }}\n />\n ),\n )}\n </Tag>\n ) : indeterminate ? (\n <Tag\n data-part=\"fill\"\n className={cn(\n // `left-0`, deliberately physical: the keyframes (tokens.css\n // `indeterminate-sweep`) translate along the physical x axis, and under\n // RTL the same keyframes are run in REVERSE — from the right edge back\n // to the left. A logical `start-0` would park the segment at the right\n // in RTL and the reversed sweep would then start off-screen.\n \"absolute inset-y-0 left-0 w-1/3 rounded-full\",\n \"animate-[indeterminate-sweep_1.4s_ease-in-out_infinite] rtl:[animation-direction:reverse]\",\n \"motion-reduce:w-full motion-reduce:animate-none motion-reduce:opacity-50\",\n FILL[tone],\n )}\n />\n ) : (\n <Tag\n data-part=\"fill\"\n // Block flow starts at the inline start, so a plain width fills from the\n // right under `dir=\"rtl\"` with nothing extra.\n className={cn(\"h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none\", FILL[tone])}\n style={{ width: `${fraction * 100}%` }}\n />\n )}\n </Tag>\n {stacked && legend && segments.length > 0 && (\n <ul className=\"mt-2 space-y-1 text-sm\" data-part=\"legend\">\n {segments.map((seg, i) => (\n <li key={seg.key ?? i} className=\"flex items-center gap-2\">\n <span\n aria-hidden\n className={cn(\n \"size-2.5 shrink-0 rounded-sm\",\n fills[i],\n )}\n style={{ backgroundColor: seg.className ? undefined : seg.color }}\n />\n {seg.label != null && (\n <span className=\"min-w-0 flex-1 truncate text-[var(--text-secondary)]\">{seg.label}</span>\n )}\n <span\n data-part=\"legend-value\"\n data-private={priv}\n className=\"ms-auto shrink-0 text-xs tabular-nums text-[var(--text-muted)]\"\n >\n {legendValue ? legendValue(seg, formatPart(seg.value), i) : formatPart(seg.value)}\n </span>\n </li>\n ))}\n </ul>\n )}\n {overageNode}\n {hintNode}\n </Tag>\n );\n}\n"],"mappings":";AA0PM,cAsBI,YAtBJ;AA1PN,SAAS,aAAa;AAEtB,SAAS,UAAU;AACnB,SAAS,uBAAuB,cAAc,oBAAoB;AAU3D,MAAM,8BAAiD;AAAA,EAC5D,WAAW;AAAA,EACX,WAAW,CAAC,WAAW,GAAG,MAAM;AAClC;AA2BA,MAAM,OAAwC;AAAA,EAC5C,OAAO;AAAA,EACP,SAAS;AAAA,EACT,SAAS;AAAA,EACT,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,SAAS;AACX;AAEA,MAAM,eAAgD,EAAE,IAAI,OAAO,MAAM,SAAS,IAAI,OAAO,IAAI,MAAM;AAIvG,MAAM,cAAc;AAAA,EAClB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAMA,SAAS,aAAa,UAA2D;AAC/E,MAAI,OAAO;AACX,SAAO,SAAS,IAAI,CAAC,QAAQ;AAC3B,QAAI,IAAI,UAAW,QAAO,IAAI;AAC9B,QAAI,IAAI,KAAM,QAAO,KAAK,IAAI,IAAI;AAClC,WAAO,IAAI,QAAQ,SAAY,YAAY,SAAS,YAAY,MAAM;AAAA,EACxE,CAAC;AACH;AAmIO,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA,MAAM;AAAA,EACN,KAAK,UAAU;AAAA,EACf,UAAU;AAAA,EACV;AAAA,EACA,YAAY;AAAA,EACZ;AAAA,EACA,OAAO;AAAA,EACP,OAAO;AAAA,EACP;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ;AAAA,EACA;AAAA,EACA,KAAK;AAAA,EACL,cAAc;AAAA,EACd,mBAAmB;AAAA,EACnB,oBAAoB;AAAA,EACpB,GAAG;AACL,GAAqB;AAInB,QAAM,MAAM;AACZ,QAAM,cAAc,OAAO,SAAS,sDAAsD;AAC1F,QAAM,SAAS,aAAa,UAAU,qBAAqB;AAC3D,QAAM,OAAO,aAAa,eAAe,6BAA6B,MAAM;AAC5E,QAAM,YAAY,aAAa,MAAM;AACrC,QAAM,UAAU,MAAM;AACtB,QAAM,OAAO,YAAY,KAAK;AAC9B,QAAM,SAAS,MAAM;AACrB,QAAM,WACJ,QAAQ,OACN,oBAAC,OAAE,IAAI,QAAQ,aAAU,QAAO,WAAU,yCACvC,gBACH,IACE;AAEN,MAAI,YAAY,MAAM;AAGpB,UAAM,QACJ,UAAU,UAAa,CAAC,OAAO,SAAS,KAAK,IACzC,SACA,cACE,YAAY,OAAO,UAAU,GAAG,IAChC,IAAI,KAAK,aAAa,SAAS,EAAE,OAAO,KAAK;AACrD,WACE,qBAAC,OAAK,GAAG,MAAM,WAAW,GAAG,WAAW,aAAa,SAAS,GAAG,cAAW,aAC1E;AAAA,2BAAC,OAAI,WAAU,qCACZ;AAAA,iBAAS,QACR,oBAAC,UAAK,IAAI,SAAS,WAAU,wCAC1B,iBACH;AAAA,QAEF,qBAAC,UAAK,aAAU,aAAY,gBAAc,MAAM,WAAU,0DACvD;AAAA,oBAAU,UAAa,GAAG,KAAK;AAAA,UAC/B,KAAK;AAAA,WACR;AAAA,SACF;AAAA,MACC;AAAA,OACH;AAAA,EAEJ;AACA,QAAM,MAAM;AAEZ,QAAM,UAAU,aAAa;AAC7B,QAAM,QAAQ,WAAW,YAAY;AACrC,QAAM,OAAO,MAAM;AACnB,QAAM,YAAY,CAAC,MAAe,OAAO,SAAS,CAAC,IAAI,KAAK,IAAI,GAAG,IAAI,GAAG,IAAI;AAG9E,QAAM,QAAQ,UAAU,aAAa,QAAQ,IAAI,CAAC;AAClD,QAAM,WAAW,UAAU,SAAS,OAAO,CAAC,QAAQ,CAAC,IAAI,UAAU,IAAI,CAAC;AACxE,QAAM,YAAY,UAAU,MAAM,SAAS,OAAO,CAAC,KAAK,QAAQ,MAAM,UAAU,IAAI,KAAK,GAAG,CAAC,IAAI;AACjG,QAAM,SAAS,CAAC,MACd,cACI,YAAY,GAAG,KAAK,GAAG,IACvB,IAAI,KAAK,aAAa,WAAW,EAAE,OAAO,WAAW,uBAAuB,EAAE,CAAC,EAAE;AAAA,IAC/E,QAAQ,IAAI,KAAK,KAAK,IAAI,KAAK,KAAK,IAAI,KAAK,CAAC,CAAC,IAAI,OAAO;AAAA,EAC5D;AAMN,QAAM,aAAa,CAAC,MAAc;AAChC,QAAI,CAAC,OAAO,SAAS,CAAC,EAAG,QAAO,OAAO,GAAG;AAC1C,QAAI,YAAa,QAAO,YAAY,GAAG,KAAK,GAAG;AAC/C,WAAO,IAAI,KAAK,aAAa,WAAW,EAAE,OAAO,WAAW,uBAAuB,EAAE,CAAC,EAAE;AAAA,MACtF,QAAQ,IAAI,KAAK,IAAI,OAAO;AAAA,IAC9B;AAAA,EACF;AACA,QAAM,YAAY,UACd,SAAS,IAAI,CAAC,QAAQ;AACpB,UAAMA,QAAO,WAAW,IAAI,KAAK;AACjC,WAAO,OAAO,IAAI,UAAU,YAAY,OAAO,IAAI,UAAU,WACzD,OAAO,WAAW,OAAO,IAAI,KAAK,GAAGA,KAAI,IACzCA;AAAA,EACN,CAAC,IACD,CAAC;AACL,QAAM,UACJ,cAAc,UAAU,UAAa,CAAC,OAAO,SAAS,KAAK,IAAK,QAAQ,MAAM,SAAa;AAC7F,QAAM,gBAAgB,YAAY;AAClC,QAAM,UAAU,gBAAgB,SAAY,KAAK,IAAI,KAAK,KAAK,IAAI,KAAK,OAAO,CAAC;AAChF,QAAM,WAAW,YAAY,UAAa,QAAQ,IAAI,KAAK,UAAU,OAAO;AAE5E,QAAM,YAAY,YAAY,SAAY,SAAY,OAAO,OAAO;AACpE,QAAM,gBACJ,WAAW,UAAU,SAAS,IAC1B,IAAI,KAAK,WAAW,WAAW,EAAE,OAAO,SAAS,MAAM,OAAO,CAAC,EAAE,OAAO,SAAS,IACjF;AAEN,QAAM,OACJ,WAAW,CAAC,WAAW,UAAU,UAAa,OAAO,SAAS,KAAK,KAAK,QAAQ,MAAM,QAAQ,MAAM;AACtG,QAAM,cACJ,OAAO,IACL,oBAAC,OAAE,IAAI,GAAG,MAAM,SAAS,aAAU,WAAU,gBAAc,MAAM,WAAU,qCACxE,iBAAO,YAAY,aAChB,QAAQ,IAAI,IACZ,KAAK,UAAU,IAAI,KAAK,aAAa,SAAS,EAAE,OAAO,IAAI,CAAC,GAClE,IACE;AACN,QAAM,cACJ,CAAC,iBAAiB,YAAY,QAAQ,eAAe,GAAG,MAAM,OAAO,EAAE,OAAO,OAAO,EAAE,KAAK,GAAG,KAAK;AAEtG,QAAM,aAAa,mBAAmB,SAAS,OAAO,UAAU;AAChE,QAAM,OAAO,aAAa,SAAa,cAAc,gBAAgB,OAAO,UAAU;AAEtF,SACE,qBAAC,OAAI,WAAW,GAAG,WAAW,aAAa,SAAS,GAAG,cAAY,gBAAgB,kBAAkB,eACjG;AAAA,cAAS,QAAS,aAAa,cAAc,WAC7C,qBAAC,OAAI,WAAU,0CACZ;AAAA,eAAS,QACR,oBAAC,UAAK,IAAI,SAAS,WAAU,wCAC1B,iBACH;AAAA,MAED,aAAa,cAAc;AAAA;AAAA,MAG1B,oBAAC,UAAK,eAAW,MAAC,aAAU,SAAQ,gBAAc,MAAM,WAAU,0DAC/D,qBACH;AAAA,OAEJ;AAAA,IAEF;AAAA,MAAC;AAAA;AAAA,QACE,GAAG;AAAA,QACJ,MAAM,QAAQ,UAAU;AAAA,QACxB,cAAY;AAAA,QACZ,mBAAiB;AAAA,QACjB,oBAAkB;AAAA,QAClB,iBAAe,gBAAgB,SAAY;AAAA,QAC3C,iBAAe,gBAAgB,SAAY;AAAA,QAC3C,iBAAe;AAAA,QACf,kBAAgB;AAAA,QAChB,aAAW,CAAC,SAAS,gBAAgB,OAAO;AAAA,QAG5C,WAAW,GAAG,sEAAsE,aAAa,IAAI,CAAC;AAAA,QAErG;AAAA;AAAA;AAAA,UAGC,oBAAC,OAAI,WAAU,8BACZ,mBAAS;AAAA,YAAI,CAAC,KAAK,MAClB,IAAI,aAAa,OACf;AAAA,cAAC;AAAA;AAAA,gBAEC,aAAU;AAAA,gBACV,WAAW;AAAA,kBACT;AAAA,kBACA,MAAM,CAAC;AAAA,gBACT;AAAA,gBACA,OAAO;AAAA,kBACL,OAAO,GAAG,QAAQ,IAAI,IAAK,UAAU,IAAI,KAAK,IAAI,OAAQ,GAAG;AAAA,kBAC7D,iBAAiB,IAAI,YAAY,SAAY,IAAI;AAAA,gBACnD;AAAA;AAAA,cATK,IAAI,OAAO;AAAA,YAUlB;AAAA,UAEJ,GACF;AAAA,YACE,gBACF;AAAA,UAAC;AAAA;AAAA,YACC,aAAU;AAAA,YACV,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAMT;AAAA,cACA;AAAA,cACA;AAAA,cACA,KAAK,IAAI;AAAA,YACX;AAAA;AAAA,QACF,IAEA;AAAA,UAAC;AAAA;AAAA,YACC,aAAU;AAAA,YAGV,WAAW,GAAG,qFAAqF,KAAK,IAAI,CAAC;AAAA,YAC7G,OAAO,EAAE,OAAO,GAAG,WAAW,GAAG,IAAI;AAAA;AAAA,QACvC;AAAA;AAAA,IAEJ;AAAA,IACC,WAAW,UAAU,SAAS,SAAS,KACtC,oBAAC,QAAG,WAAU,0BAAyB,aAAU,UAC9C,mBAAS,IAAI,CAAC,KAAK,MAClB,qBAAC,QAAsB,WAAU,2BAC/B;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,eAAW;AAAA,UACX,WAAW;AAAA,YACT;AAAA,YACA,MAAM,CAAC;AAAA,UACT;AAAA,UACA,OAAO,EAAE,iBAAiB,IAAI,YAAY,SAAY,IAAI,MAAM;AAAA;AAAA,MAClE;AAAA,MACC,IAAI,SAAS,QACZ,oBAAC,UAAK,WAAU,wDAAwD,cAAI,OAAM;AAAA,MAEpF;AAAA,QAAC;AAAA;AAAA,UACC,aAAU;AAAA,UACV,gBAAc;AAAA,UACd,WAAU;AAAA,UAET,wBAAc,YAAY,KAAK,WAAW,IAAI,KAAK,GAAG,CAAC,IAAI,WAAW,IAAI,KAAK;AAAA;AAAA,MAClF;AAAA,SAlBO,IAAI,OAAO,CAmBpB,CACD,GACH;AAAA,IAED;AAAA,IACA;AAAA,KACH;AAEJ;","names":["text"]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/progress-bar.tsx"],"sourcesContent":["import { useId } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport { toneTextClass } from \"./signed-amount\";\nimport type { TextTone } from \"./signed-amount\";\n\n/** The words a progress bar adds of its own. */\nexport interface ProgressBarLabels {\n /** Shown after the value when `max={null}`: a quota with no ceiling. */\n unlimited: string;\n /** The `overage` line: how far past `max` the value is. `amount` is formatted. */\n overLimit: (amount: string) => string;\n}\n\nexport const DEFAULT_PROGRESS_BAR_LABELS: ProgressBarLabels = {\n unlimited: \"Unlimited\",\n overLimit: (amount) => `${amount} over the limit`,\n};\n\n/**\n * `income` / `expense` are the money pair (`--money-income` / `--money-expense`), the\n * names `Chip`, `StatTile` and `Sparkline` already use for them: a share bar of what\n * came in or went out is money, and painting it `success` / `warning` said \"good\" and\n * \"careful\" about a figure that is neither. keksdose's report meters (food-group-card,\n * drilldown-modal) sit beside amounts that are already in the money colours.\n */\nexport type ProgressBarTone =\n | \"brand\"\n | \"neutral\"\n | \"success\"\n | \"warning\"\n | \"danger\"\n | \"info\"\n | \"income\"\n | \"expense\";\n/**\n * Track heights: `sm` 4px, `slim` 6px, `md` 8px, `lg` 12px. `slim` is a word and not a\n * letter because the letters are taken in order — an `xs` at 6px would be THICKER than\n * `sm` — and renaming `sm` would move every bar already on it. 6px is the Slider's\n * track and keksdose's hand-drawn meters (`h-1.5`, landing-visuals), for a bar under a\n * line of text where 8px outweighs the text.\n */\nexport type ProgressBarSize = \"sm\" | \"slim\" | \"md\" | \"lg\";\n\nconst FILL: Record<ProgressBarTone, string> = {\n brand: \"bg-[var(--brand)]\",\n neutral: \"bg-[var(--text-muted)]\",\n success: \"bg-[var(--success)]\",\n warning: \"bg-[var(--warning)]\",\n danger: \"bg-[var(--danger)]\",\n info: \"bg-[var(--info)]\",\n income: \"bg-[var(--money-income)]\",\n expense: \"bg-[var(--money-expense)]\",\n};\n\nconst TRACK_HEIGHT: Record<ProgressBarSize, string> = { sm: \"h-1\", slim: \"h-1.5\", md: \"h-2\", lg: \"h-3\" };\n\n// A segment with no tone of its own takes the next categorical chart colour, so a\n// stacked bar of unnamed parts is still a set of tellable parts rather than one fill.\nconst SERIES_FILL = [\n \"bg-[var(--chart-1)]\",\n \"bg-[var(--chart-2)]\",\n \"bg-[var(--chart-3)]\",\n \"bg-[var(--chart-4)]\",\n \"bg-[var(--chart-5)]\",\n \"bg-[var(--chart-6)]\",\n \"bg-[var(--chart-7)]\",\n \"bg-[var(--chart-8)]\",\n \"bg-[var(--chart-9)]\",\n];\n\n/** Each segment's fill classes: its own classes, else its tone, else (with no `color`)\n * the next chart colour. \"Next\" counts the UNCOLOURED segments only — a segment with\n * its own `className`/`tone`/`color` does not use up `--chart-1`, so an \"Other\" in grey\n * ahead of two plain parts leaves them `--chart-1` and `--chart-2`. */\nfunction segmentFills(segments: ProgressBarSegment[]): Array<string | undefined> {\n let next = 0;\n return segments.map((seg) => {\n if (seg.className) return seg.className;\n if (seg.tone) return FILL[seg.tone];\n return seg.color ? undefined : SERIES_FILL[next++ % SERIES_FILL.length];\n });\n}\n\n/** One part of a stacked bar. See {@link ProgressBarProps.segments}. */\nexport interface ProgressBarSegment {\n /** Its size, on the bar's own `min`–`max` scale. */\n value: number;\n /** A kit tone. Left out (and no `color`/`className`), the next `--chart-N` colour. */\n tone?: ProgressBarTone;\n /** Any CSS colour (`var(--token)`), for a palette the tones do not cover. Wins over `tone`. */\n color?: string;\n /** Classes for the segment's fill — keksdose's `STEP_CLASS` ramp. Wins over `tone`. */\n className?: string;\n /** The part's name: said in the bar's `aria-valuetext` and shown in the legend. */\n label?: ReactNode;\n /** A stable React key, when `label` is not a string. */\n key?: string;\n /**\n * List it in the `legend` but leave it out of the bar — its value, its share of the\n * `aria-valuetext`, and the sum. keksdose's cut card (reports/cut-card:132) names\n * EVERY bucket under its bar, the empty ones included, while the bar draws only the\n * ones with a share: pass the empty buckets as `legendOnly` entries.\n */\n legendOnly?: boolean;\n}\n\nexport interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\" | \"role\"> {\n /**\n * `\"span\"` builds the bar from spans, for a place only phrasing content may go: inside\n * a button or a link (ListItem's `content`). The `legend` stays a list, so leave it off\n * there. Default `\"div\"`.\n */\n as?: \"div\" | \"span\";\n /** Where it stands. Leave it undefined for an INDETERMINATE bar — work is under way\n * and nobody knows how much is left (an import that reports no count). */\n value?: number;\n min?: number;\n /**\n * The top of the scale (default 100). `null` is a quota with NO ceiling — kastlan's\n * billing usage on an unlimited plan (usage-progress-bar:25): nothing to fill, so no\n * bar is drawn; the label row shows the value and \"Unlimited\" (`progressBar.unlimited`)\n * as plain text. `formatValue` is then called with `max` = `Infinity`; without it,\n * the value is a plain number in the kit's locale.\n */\n max?: number | null;\n /**\n * `progress` (default): a task moving toward completion — `role=\"progressbar\"`.\n * `meter`: a share of a known whole that is not going anywhere (budget used, disk\n * space, one category's slice of spending) — `role=\"meter\"`. keksdose's share bars\n * were progress bars to a screen reader, which reads \"busy\" into a figure that is\n * simply a figure. A meter is never indeterminate; a missing value reads as `min`.\n */\n variant?: \"progress\" | \"meter\";\n /** Visible label above the bar, and its accessible name. Without it, pass\n * `aria-label` (an indeterminate bar with neither is named `common.loading`). */\n label?: ReactNode;\n /** Show the value text at the end of the label row. */\n showValue?: boolean;\n /**\n * The value as words — both the visible `showValue` text and `aria-valuetext`.\n * Default: the fraction as a percentage in the kit's locale (\"42 %\" in French).\n * Pass one for anything that is not a percentage: `(v, max) => \\`${v} of ${max} files\\``.\n */\n formatValue?: (value: number, max: number, min: number) => string;\n tone?: ProgressBarTone;\n size?: ProgressBarSize;\n locale?: string;\n /**\n * Draw a METER OF PARTS: one bar, several fills side by side in order, a 2px gap\n * between them — keksdose's discretionary-spending bar (cut-card:133), a whole\n * split into the steps of a scale. Forces `variant=\"meter\"`: parts of a whole are\n * never \"busy\". `aria-valuenow` is the sum; `aria-valuetext` names every part\n * (\"Rent: 40%, Food: 25%\"), each part's value through `formatValue` and joined in the\n * kit's locale — the words keksdose's hand-drawn bar had to hide from a reader\n * altogether, leaving the legend as the only way in. The sum is clamped to `max`,\n * and parts past it are cut off at the track's end.\n */\n segments?: ProgressBarSegment[];\n /** With `segments`: a list under the bar naming each part with its swatch and value\n * — the table view of the bar, in text tokens rather than the segment colour. */\n legend?: boolean;\n /**\n * What a legend row shows at its end, in place of the part's formatted value — which\n * it is handed, with the segment and its index. That value is the part's OWN, signed\n * and unclamped (`formatValue`, or a percentage of the scale): a part of −40 on a\n * 0–100 scale says \"-40%\" though the bar draws nothing for it. keksdose's cut card\n * shows the share AND a private outflow amount per bucket (cut-card:150), which one\n * figure could not:\n * `legendValue={(seg, share) => <>{share} <Amount … /></>}`.\n */\n legendValue?: (segment: ProgressBarSegment, formatted: string, index: number) => ReactNode;\n /**\n * The colour of each legend row's figure, as a {@link TextTone} — `toneTextClass`'s\n * palette, the money pair included. Default `\"muted\"`: the legend explains the bar and\n * should not outshout the headline above it. One tone for every row, or a function\n * per row. A tone and not the segment's own `tone`: keksdose's tax card fills its\n * three parts with one bucket's certainty ramp (`className`), while the FIGURES carry\n * the bucket's money direction — `legendTone=\"expense\"` for the tax owed — which it\n * could only say before through `legendValue` and an app-side `Amount`. A function\n * returning `undefined` keeps that row muted. Applies to `legendValue`'s node too\n * (it inherits the colour unless it sets its own).\n */\n legendTone?: TextTone | ((segment: ProgressBarSegment, index: number) => TextTone | undefined);\n /** A muted line under the bar — the plan's reset date, \"3 of 5 seats in use\". It\n * describes the bar (`aria-describedby`). */\n hint?: ReactNode;\n /**\n * When `value` is past `max`, say by how much, in a danger-coloured line under the bar\n * (kastlan usage-progress-bar:46): `true` prints `progressBar.overLimit` with the\n * excess as a plain number; a function renders the line itself from the excess (a\n * currency, \"2 seats\"). The bar stays full at `max` either way — colour it with\n * `tone=\"danger\"` if it should read as over. Ignored for `segments` and `max={null}`.\n */\n overage?: boolean | ((over: number) => ReactNode);\n /**\n * Tag every figure the bar PRINTS `data-private`, so the host's demo-mode rule blurs\n * it — the attribute `StatTile sensitive` and `Tooltip redact` carry: the `showValue`\n * text, each `legend` row's value (yours from `legendValue` too), the unlimited\n * figure and the `overage` line. The track, the swatches and the part names stay\n * readable — a blurred bar is no picture at all. Before this an app with a demo-mode\n * blur had to pass `legendValue` only to wrap the figure in a tagged span (keksdose).\n * The `aria-valuetext` is not touched: a screen reader is not a screen-share.\n */\n sensitive?: boolean;\n labels?: Partial<ProgressBarLabels>;\n}\n\n/**\n * A horizontal bar that fills toward `max`.\n *\n * keksdose (import progress, budget share bars) and kastlan (upload and job\n * progress) each drew their own div-in-a-div. None had a role, so a reader heard\n * nothing at all; the share bars that did have one claimed to be progress.\n *\n * `aria-valuetext` is always set when there is a value: a bare `aria-valuenow=\"37\"`\n * is read as \"37\" by some readers and \"37 percent\" by others, and neither is right\n * when `max` is 212 files.\n *\n * The indeterminate sweep stops under `prefers-reduced-motion`. It becomes a still,\n * half-strength bar across the whole track — still visibly \"something is happening\",\n * with nothing moving. The sweep is a Tailwind arbitrary `animate-[…]` rather than\n * tokens.css's `.animate-indeterminate` class because that one is UNLAYERED, and an\n * unlayered rule beats every layered utility, `motion-reduce:animate-none` included.\n */\nexport function ProgressBar({\n value,\n min = 0,\n max: maxProp = 100,\n variant = \"progress\",\n label,\n showValue = false,\n formatValue,\n tone = \"brand\",\n size = \"md\",\n locale,\n segments,\n legend = false,\n legendValue,\n legendTone = \"muted\",\n hint,\n overage,\n sensitive = false,\n labels,\n className,\n as = \"div\",\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledBy,\n \"aria-describedby\": ariaDescribedBy,\n ...rest\n}: ProgressBarProps) {\n // `as=\"span\"`: the same bar from phrasing elements only, so it is valid inside a\n // <button> or <a> — ListItem's `content` puts a budget bar inside the row's target\n // (keksdose P2). Every span is made a block except those already laid out as flex.\n const Tag = as;\n const inlineClass = as === \"span\" ? \"block [&_span:not(.flex):not(.inline-flex)]:block\" : undefined;\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const text = useKitLabels(\"progressBar\", DEFAULT_PROGRESS_BAR_LABELS, labels);\n const kitLocale = useKitLocale(locale);\n const labelId = useId();\n const priv = sensitive ? \"\" : undefined;\n const hintId = useId();\n const hintNode =\n hint != null ? (\n <p id={hintId} data-part=\"hint\" className=\"mt-1 text-xs text-[var(--text-muted)]\">\n {hint}\n </p>\n ) : null;\n\n if (maxProp === null) {\n // No ceiling: no bar, no role — a meter with no maximum is not one. The value and\n // \"Unlimited\" are the content, so they are plain visible text.\n const shown =\n value === undefined || !Number.isFinite(value)\n ? undefined\n : formatValue\n ? formatValue(value, Infinity, min)\n : new Intl.NumberFormat(kitLocale).format(value);\n return (\n <Tag {...rest} className={cn(\"min-w-0\", inlineClass, className)} data-state=\"unlimited\">\n <Tag className=\"flex items-baseline gap-2 text-sm\">\n {label != null && (\n <span id={labelId} className=\"min-w-0 text-[var(--text-secondary)]\">\n {label}\n </span>\n )}\n <span data-part=\"unlimited\" data-private={priv} className=\"ms-auto shrink-0 tabular-nums text-[var(--text-muted)]\">\n {shown !== undefined && `${shown} · `}\n {text.unlimited}\n </span>\n </Tag>\n {hintNode}\n </Tag>\n );\n }\n const max = maxProp;\n\n const stacked = segments !== undefined;\n const meter = stacked || variant === \"meter\";\n const span = max - min;\n const partValue = (v: number) => (Number.isFinite(v) ? Math.max(0, v - min) : 0);\n // Colours are dealt over EVERY segment, legend-only ones included, so an entry keeps\n // its swatch whether or not it is in the bar this time.\n const fills = stacked ? segmentFills(segments) : [];\n const barParts = stacked ? segments.filter((seg) => !seg.legendOnly) : [];\n const partTotal = stacked ? min + barParts.reduce((sum, seg) => sum + partValue(seg.value), 0) : undefined;\n const format = (v: number) =>\n formatValue\n ? formatValue(v, max, min)\n : new Intl.NumberFormat(kitLocale, { style: \"percent\", maximumFractionDigits: 0 }).format(\n span <= 0 ? 0 : (Math.min(max, Math.max(min, v)) - min) / span,\n );\n // A PART is stated as its own value, sign and all — not as the slice of track it\n // draws. The bar cannot draw a negative part (a refund inside a month's spending) or\n // one past `max`, but the legend and the reader can still say it: before 0.16 a\n // −CHF 40 part read \"0 %\" in both, and keksdose had to pass `legendValue` just to get\n // the minus back. For a part inside `min`–`max` this is the same text as before.\n const formatPart = (v: number) => {\n if (!Number.isFinite(v)) return format(min);\n if (formatValue) return formatValue(v, max, min);\n return new Intl.NumberFormat(kitLocale, { style: \"percent\", maximumFractionDigits: 0 }).format(\n span <= 0 ? 0 : (v - min) / span,\n );\n };\n const partTexts = stacked\n ? barParts.map((seg) => {\n const text = formatPart(seg.value);\n return typeof seg.label === \"string\" || typeof seg.label === \"number\"\n ? common.fieldValue(String(seg.label), text)\n : text;\n })\n : [];\n const current =\n partTotal ?? (value === undefined || !Number.isFinite(value) ? (meter ? min : undefined) : value);\n const indeterminate = current === undefined;\n const clamped = indeterminate ? undefined : Math.min(max, Math.max(min, current));\n const fraction = clamped === undefined || span <= 0 ? 0 : (clamped - min) / span;\n\n const valueText = clamped === undefined ? undefined : format(clamped);\n const ariaValueText =\n stacked && partTexts.length > 0\n ? new Intl.ListFormat(kitLocale, { style: \"short\", type: \"unit\" }).format(partTexts)\n : valueText;\n\n const over =\n overage && !stacked && value !== undefined && Number.isFinite(value) && value > max ? value - max : 0;\n const overageNode =\n over > 0 ? (\n <p id={`${hintId}-over`} data-part=\"overage\" data-private={priv} className=\"mt-1 text-xs text-[var(--danger)]\">\n {typeof overage === \"function\"\n ? overage(over)\n : text.overLimit(new Intl.NumberFormat(kitLocale).format(over))}\n </p>\n ) : null;\n const describedBy =\n [ariaDescribedBy, hintNode && hintId, overageNode && `${hintId}-over`].filter(Boolean).join(\" \") || undefined;\n\n const labelledBy = ariaLabelledBy ?? (label != null ? labelId : undefined);\n const name = labelledBy ? undefined : (ariaLabel ?? (indeterminate ? common.loading : undefined));\n\n return (\n <Tag className={cn(\"min-w-0\", inlineClass, className)} data-state={indeterminate ? \"indeterminate\" : \"determinate\"}>\n {(label != null || (showValue && valueText !== undefined)) && (\n <Tag className=\"mb-1 flex items-baseline gap-2 text-sm\">\n {label != null && (\n <span id={labelId} className=\"min-w-0 text-[var(--text-secondary)]\">\n {label}\n </span>\n )}\n {showValue && valueText !== undefined && (\n // Hidden: the same words are the bar's aria-valuetext, and reading them\n // twice in a row is noise.\n <span aria-hidden data-part=\"value\" data-private={priv} className=\"ms-auto shrink-0 tabular-nums text-[var(--text-muted)]\">\n {valueText}\n </span>\n )}\n </Tag>\n )}\n <Tag\n {...rest}\n role={meter ? \"meter\" : \"progressbar\"}\n aria-label={name}\n aria-labelledby={labelledBy}\n aria-describedby={describedBy}\n aria-valuemin={indeterminate ? undefined : min}\n aria-valuemax={indeterminate ? undefined : max}\n aria-valuenow={clamped}\n aria-valuetext={ariaValueText}\n aria-busy={!meter && indeterminate ? true : undefined}\n // `relative` + `overflow-hidden`: the indeterminate segment is positioned in\n // here and must not paint past the rounded ends.\n className={cn(\"relative w-full overflow-hidden rounded-full bg-[var(--bg-active)]\", TRACK_HEIGHT[size])}\n >\n {stacked ? (\n // The parts in a flex row with a 2px gap, so two neighbouring steps of one\n // hue are parted by the track rather than by their difference in lightness.\n <Tag className=\"flex h-full w-full gap-0.5\">\n {segments.map((seg, i) =>\n seg.legendOnly ? null : (\n <Tag\n key={seg.key ?? i}\n data-part=\"segment\"\n className={cn(\n \"h-full shrink-0 rounded-full transition-[width] duration-300 motion-reduce:transition-none\",\n fills[i],\n )}\n style={{\n width: `${span <= 0 ? 0 : (partValue(seg.value) / span) * 100}%`,\n backgroundColor: seg.className ? undefined : seg.color,\n }}\n />\n ),\n )}\n </Tag>\n ) : indeterminate ? (\n <Tag\n data-part=\"fill\"\n className={cn(\n // `left-0`, deliberately physical: the keyframes (tokens.css\n // `indeterminate-sweep`) translate along the physical x axis, and under\n // RTL the same keyframes are run in REVERSE — from the right edge back\n // to the left. A logical `start-0` would park the segment at the right\n // in RTL and the reversed sweep would then start off-screen.\n \"absolute inset-y-0 left-0 w-1/3 rounded-full\",\n \"animate-[indeterminate-sweep_1.4s_ease-in-out_infinite] rtl:[animation-direction:reverse]\",\n \"motion-reduce:w-full motion-reduce:animate-none motion-reduce:opacity-50\",\n FILL[tone],\n )}\n />\n ) : (\n <Tag\n data-part=\"fill\"\n // Block flow starts at the inline start, so a plain width fills from the\n // right under `dir=\"rtl\"` with nothing extra.\n className={cn(\"h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none\", FILL[tone])}\n style={{ width: `${fraction * 100}%` }}\n />\n )}\n </Tag>\n {stacked && legend && segments.length > 0 && (\n <ul className=\"mt-2 space-y-1 text-sm\" data-part=\"legend\">\n {segments.map((seg, i) => (\n <li key={seg.key ?? i} className=\"flex items-center gap-2\">\n <span\n aria-hidden\n className={cn(\n \"size-2.5 shrink-0 rounded-sm\",\n fills[i],\n )}\n style={{ backgroundColor: seg.className ? undefined : seg.color }}\n />\n {seg.label != null && (\n <span className=\"min-w-0 flex-1 truncate text-[var(--text-secondary)]\">{seg.label}</span>\n )}\n <span\n data-part=\"legend-value\"\n data-private={priv}\n className={cn(\n \"ms-auto shrink-0 text-xs tabular-nums\",\n toneTextClass(\n (typeof legendTone === \"function\" ? legendTone(seg, i) : legendTone) ?? \"muted\",\n ),\n )}\n >\n {legendValue ? legendValue(seg, formatPart(seg.value), i) : formatPart(seg.value)}\n </span>\n </li>\n ))}\n </ul>\n )}\n {overageNode}\n {hintNode}\n </Tag>\n );\n}\n"],"mappings":"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","names":["text"]}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every string the dialog renders — the `reauthDialog` namespace of
|
|
6
|
+
* `<UiKitProvider labels>`, and overridable per instance through `labels`. The title,
|
|
7
|
+
* description and submit button are usually worded per action ("Add a passkey"),
|
|
8
|
+
* which is what the `title` / `description` / `submitLabel` props are for; these are
|
|
9
|
+
* the fallbacks.
|
|
10
|
+
*/
|
|
11
|
+
interface ReauthDialogLabels {
|
|
12
|
+
title: string;
|
|
13
|
+
description: string;
|
|
14
|
+
/** Label of the password field. */
|
|
15
|
+
password: string;
|
|
16
|
+
submit: string;
|
|
17
|
+
cancel: string;
|
|
18
|
+
}
|
|
19
|
+
declare const DEFAULT_REAUTH_DIALOG_LABELS: ReauthDialogLabels;
|
|
20
|
+
interface ReauthDialogProps {
|
|
21
|
+
/**
|
|
22
|
+
* Verifies the password — the app's call, e.g. the request the password guards.
|
|
23
|
+
*
|
|
24
|
+
* Return a promise and the dialog manages itself: busy until it settles, closes
|
|
25
|
+
* (`onClose(true)`) when it resolves, STAYS OPEN with the password kept and selected
|
|
26
|
+
* when it rejects — a wrong password is a typo to correct, not a sign-out. Pass the
|
|
27
|
+
* reason as `error`. Return nothing and the app owns the rest: `busy` for the
|
|
28
|
+
* pending state, unmounting the dialog once it is done.
|
|
29
|
+
*/
|
|
30
|
+
onSubmit: (password: string) => void | Promise<unknown>;
|
|
31
|
+
/**
|
|
32
|
+
* The dialog has closed: `true` after a resolved `onSubmit`, `false` for Cancel,
|
|
33
|
+
* Escape, the backdrop and Back. A dismissal while `onSubmit` is still running also
|
|
34
|
+
* answers `false`, and that call's late result is then ignored.
|
|
35
|
+
*/
|
|
36
|
+
onClose: (confirmed: boolean) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Why the last attempt failed ("Wrong password"), shown under the field, which turns
|
|
39
|
+
* invalid (`aria-invalid`, `--danger-border-strong`). The app decides the wording —
|
|
40
|
+
* a 400 is a wrong password, a 500 is not. Hidden again as soon as the user edits
|
|
41
|
+
* the field (the message is about the password they had typed), and shown again by
|
|
42
|
+
* the next failed submit.
|
|
43
|
+
*/
|
|
44
|
+
error?: ReactNode;
|
|
45
|
+
/** The check is running, for a caller that tracks it itself (a mutation's
|
|
46
|
+
* `isPending`); a promise returned from `onSubmit` does the same on its own. */
|
|
47
|
+
busy?: boolean;
|
|
48
|
+
/** Default: `labels.title` — "Confirm it’s you". */
|
|
49
|
+
title?: ReactNode;
|
|
50
|
+
/** The line under the title. Default: `labels.description`. */
|
|
51
|
+
description?: ReactNode;
|
|
52
|
+
/** Text of the submit button. Default: `labels.submit` — "Continue". */
|
|
53
|
+
submitLabel?: ReactNode;
|
|
54
|
+
/** `"danger"` when what the password unlocks cannot be undone: the submit is then the
|
|
55
|
+
* destructive button. Default `"neutral"`. */
|
|
56
|
+
tone?: "neutral" | "danger";
|
|
57
|
+
/** Extra body above the field — what is about to happen, a warning. */
|
|
58
|
+
children?: ReactNode;
|
|
59
|
+
/** Kept-mounted mode, as on `Modal`: left out, the dialog is open while mounted. A
|
|
60
|
+
* flip to `false` animates it out and wipes the password. */
|
|
61
|
+
open?: boolean;
|
|
62
|
+
/** User-facing strings; see {@link ReauthDialogLabels}. */
|
|
63
|
+
labels?: Partial<ReauthDialogLabels>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* "Enter your current password to continue" — the step-up check in front of an
|
|
67
|
+
* action that changes how an account signs in (adding a passkey) or what it can
|
|
68
|
+
* reach. Kurvenschmiede asked for the password before registering a passkey in a
|
|
69
|
+
* dialog of its own; keksdose asks for it inline in `DangerConfirm`'s
|
|
70
|
+
* `requirePassword` tile. Both are the same three facts — the field is the CURRENT
|
|
71
|
+
* password (the password manager fills it, never generates one), a wrong one stays in
|
|
72
|
+
* the dialog with its error, and the APP verifies it — so the promise handling is
|
|
73
|
+
* `DangerConfirm`'s own, shared, the field is its `CurrentPasswordInput` spelled out
|
|
74
|
+
* with an `error`, and this is the modal form of them.
|
|
75
|
+
*
|
|
76
|
+
* A component rather than a `useReauth()` promise: the step that follows the check
|
|
77
|
+
* (Kurvenschmiede's WebAuthn ceremony) needs what the server answered, and the error
|
|
78
|
+
* wording needs the app's own status codes — both already live in the app's mutation,
|
|
79
|
+
* which this dialog only has to be handed (`onSubmit={(pw) => begin.mutateAsync(pw)}`,
|
|
80
|
+
* `error={…}`).
|
|
81
|
+
*
|
|
82
|
+
* The field is a `<form>`, so Enter submits once something is typed. Focus starts in
|
|
83
|
+
* the field and returns to the trigger through `Modal`'s trap. While a check runs,
|
|
84
|
+
* the field is read-only, Cancel is disabled and Escape is held: a request that has
|
|
85
|
+
* left cannot be cancelled by closing the dialog, and the dialog should not pretend it
|
|
86
|
+
* can.
|
|
87
|
+
*/
|
|
88
|
+
declare function ReauthDialog({ onSubmit, onClose, error, busy: busyProp, title, description, submitLabel, tone, children, open, labels: labelsProp, }: ReauthDialogProps): react.JSX.Element;
|
|
89
|
+
|
|
90
|
+
export { DEFAULT_REAUTH_DIALOG_LABELS, ReauthDialog, type ReauthDialogLabels, type ReauthDialogProps };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useContext, useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import { useKitLabels } from "../i18n/kit-labels.js";
|
|
5
|
+
import { usePromisePending } from "./danger-confirm.js";
|
|
6
|
+
import { DialogFrame } from "./dialog-frame.js";
|
|
7
|
+
import { ModalCloseContext } from "./modal.js";
|
|
8
|
+
import { Button, Input, Spinner } from "./ui.js";
|
|
9
|
+
const DEFAULT_REAUTH_DIALOG_LABELS = {
|
|
10
|
+
title: "Confirm it\u2019s you",
|
|
11
|
+
description: "Enter your current password to continue.",
|
|
12
|
+
password: "Current password",
|
|
13
|
+
submit: "Continue",
|
|
14
|
+
cancel: "Cancel"
|
|
15
|
+
};
|
|
16
|
+
function ReauthDialog({
|
|
17
|
+
onSubmit,
|
|
18
|
+
onClose,
|
|
19
|
+
error,
|
|
20
|
+
busy: busyProp,
|
|
21
|
+
title,
|
|
22
|
+
description,
|
|
23
|
+
submitLabel,
|
|
24
|
+
tone = "neutral",
|
|
25
|
+
children,
|
|
26
|
+
open,
|
|
27
|
+
labels: labelsProp
|
|
28
|
+
}) {
|
|
29
|
+
const labels = useKitLabels("reauthDialog", DEFAULT_REAUTH_DIALOG_LABELS, labelsProp);
|
|
30
|
+
const [password, setPassword] = useState("");
|
|
31
|
+
const [edited, setEdited] = useState(false);
|
|
32
|
+
const { pending, run } = usePromisePending();
|
|
33
|
+
const busy = Boolean(busyProp) || pending;
|
|
34
|
+
const inputRef = useRef(null);
|
|
35
|
+
const [done, setDone] = useState(false);
|
|
36
|
+
const confirmed = useRef(false);
|
|
37
|
+
const formId = useId();
|
|
38
|
+
const isOpen = open ?? true;
|
|
39
|
+
const [wasOpen, setWasOpen] = useState(isOpen);
|
|
40
|
+
if (wasOpen !== isOpen) {
|
|
41
|
+
setWasOpen(isOpen);
|
|
42
|
+
if (!isOpen) {
|
|
43
|
+
setPassword("");
|
|
44
|
+
setEdited(false);
|
|
45
|
+
setDone(false);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!isOpen) return;
|
|
50
|
+
confirmed.current = false;
|
|
51
|
+
inputRef.current?.focus();
|
|
52
|
+
}, [isOpen]);
|
|
53
|
+
const submit = () => {
|
|
54
|
+
if (busy || password === "") return;
|
|
55
|
+
setEdited(false);
|
|
56
|
+
run(onSubmit(password), () => {
|
|
57
|
+
confirmed.current = true;
|
|
58
|
+
setDone(true);
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
const wasPending = useRef(false);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (wasPending.current && !pending && !done) inputRef.current?.select();
|
|
64
|
+
wasPending.current = pending;
|
|
65
|
+
}, [pending, done]);
|
|
66
|
+
const shownError = edited ? void 0 : error;
|
|
67
|
+
return /* @__PURE__ */ jsxs(
|
|
68
|
+
DialogFrame,
|
|
69
|
+
{
|
|
70
|
+
open,
|
|
71
|
+
title: title ?? labels.title,
|
|
72
|
+
description: description ?? labels.description,
|
|
73
|
+
onClose: () => onClose(confirmed.current),
|
|
74
|
+
onKeyDown: (e) => {
|
|
75
|
+
if (busy && e.key === "Escape") e.preventDefault();
|
|
76
|
+
},
|
|
77
|
+
"aria-busy": busy || void 0,
|
|
78
|
+
actions: /* @__PURE__ */ jsx(
|
|
79
|
+
ReauthActions,
|
|
80
|
+
{
|
|
81
|
+
formId,
|
|
82
|
+
done,
|
|
83
|
+
busy,
|
|
84
|
+
canSubmit: password !== "",
|
|
85
|
+
danger: tone === "danger",
|
|
86
|
+
cancelLabel: labels.cancel,
|
|
87
|
+
submitLabel: submitLabel ?? labels.submit
|
|
88
|
+
}
|
|
89
|
+
),
|
|
90
|
+
children: [
|
|
91
|
+
children,
|
|
92
|
+
/* @__PURE__ */ jsx(
|
|
93
|
+
"form",
|
|
94
|
+
{
|
|
95
|
+
id: formId,
|
|
96
|
+
noValidate: true,
|
|
97
|
+
onSubmit: (e) => {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
submit();
|
|
100
|
+
},
|
|
101
|
+
children: /* @__PURE__ */ jsx(
|
|
102
|
+
Input,
|
|
103
|
+
{
|
|
104
|
+
ref: inputRef,
|
|
105
|
+
type: "password",
|
|
106
|
+
autoComplete: "current-password",
|
|
107
|
+
label: labels.password,
|
|
108
|
+
value: password,
|
|
109
|
+
readOnly: busy,
|
|
110
|
+
error: shownError,
|
|
111
|
+
onChange: (e) => {
|
|
112
|
+
setPassword(e.target.value);
|
|
113
|
+
setEdited(true);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
]
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
function ReauthActions({
|
|
124
|
+
formId,
|
|
125
|
+
done,
|
|
126
|
+
busy,
|
|
127
|
+
canSubmit,
|
|
128
|
+
danger,
|
|
129
|
+
cancelLabel,
|
|
130
|
+
submitLabel
|
|
131
|
+
}) {
|
|
132
|
+
const close = useContext(ModalCloseContext);
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (done) close?.();
|
|
135
|
+
}, [done, close]);
|
|
136
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
137
|
+
/* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", disabled: busy, onClick: () => close?.(), children: cancelLabel }),
|
|
138
|
+
/* @__PURE__ */ jsxs(
|
|
139
|
+
Button,
|
|
140
|
+
{
|
|
141
|
+
type: "submit",
|
|
142
|
+
form: formId,
|
|
143
|
+
variant: danger ? "danger" : "primary",
|
|
144
|
+
disabled: !canSubmit || busy,
|
|
145
|
+
"aria-busy": busy || void 0,
|
|
146
|
+
children: [
|
|
147
|
+
busy && /* @__PURE__ */ jsx(Spinner, { label: null, className: "h-4 w-4" }),
|
|
148
|
+
submitLabel
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
] });
|
|
153
|
+
}
|
|
154
|
+
export {
|
|
155
|
+
DEFAULT_REAUTH_DIALOG_LABELS,
|
|
156
|
+
ReauthDialog
|
|
157
|
+
};
|
|
158
|
+
//# sourceMappingURL=reauth-dialog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/reauth-dialog.tsx"],"sourcesContent":["import { useContext, useEffect, useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\n\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { usePromisePending } from \"./danger-confirm\";\nimport { DialogFrame } from \"./dialog-frame\";\nimport { ModalCloseContext } from \"./modal\";\nimport { Button, Input, Spinner } from \"./ui\";\n\n/**\n * Every string the dialog renders — the `reauthDialog` namespace of\n * `<UiKitProvider labels>`, and overridable per instance through `labels`. The title,\n * description and submit button are usually worded per action (\"Add a passkey\"),\n * which is what the `title` / `description` / `submitLabel` props are for; these are\n * the fallbacks.\n */\nexport interface ReauthDialogLabels {\n title: string;\n description: string;\n /** Label of the password field. */\n password: string;\n submit: string;\n cancel: string;\n}\n\nexport const DEFAULT_REAUTH_DIALOG_LABELS: ReauthDialogLabels = {\n title: \"Confirm it’s you\",\n description: \"Enter your current password to continue.\",\n password: \"Current password\",\n submit: \"Continue\",\n cancel: \"Cancel\",\n};\n\nexport interface ReauthDialogProps {\n /**\n * Verifies the password — the app's call, e.g. the request the password guards.\n *\n * Return a promise and the dialog manages itself: busy until it settles, closes\n * (`onClose(true)`) when it resolves, STAYS OPEN with the password kept and selected\n * when it rejects — a wrong password is a typo to correct, not a sign-out. Pass the\n * reason as `error`. Return nothing and the app owns the rest: `busy` for the\n * pending state, unmounting the dialog once it is done.\n */\n onSubmit: (password: string) => void | Promise<unknown>;\n /**\n * The dialog has closed: `true` after a resolved `onSubmit`, `false` for Cancel,\n * Escape, the backdrop and Back. A dismissal while `onSubmit` is still running also\n * answers `false`, and that call's late result is then ignored.\n */\n onClose: (confirmed: boolean) => void;\n /**\n * Why the last attempt failed (\"Wrong password\"), shown under the field, which turns\n * invalid (`aria-invalid`, `--danger-border-strong`). The app decides the wording —\n * a 400 is a wrong password, a 500 is not. Hidden again as soon as the user edits\n * the field (the message is about the password they had typed), and shown again by\n * the next failed submit.\n */\n error?: ReactNode;\n /** The check is running, for a caller that tracks it itself (a mutation's\n * `isPending`); a promise returned from `onSubmit` does the same on its own. */\n busy?: boolean;\n /** Default: `labels.title` — \"Confirm it’s you\". */\n title?: ReactNode;\n /** The line under the title. Default: `labels.description`. */\n description?: ReactNode;\n /** Text of the submit button. Default: `labels.submit` — \"Continue\". */\n submitLabel?: ReactNode;\n /** `\"danger\"` when what the password unlocks cannot be undone: the submit is then the\n * destructive button. Default `\"neutral\"`. */\n tone?: \"neutral\" | \"danger\";\n /** Extra body above the field — what is about to happen, a warning. */\n children?: ReactNode;\n /** Kept-mounted mode, as on `Modal`: left out, the dialog is open while mounted. A\n * flip to `false` animates it out and wipes the password. */\n open?: boolean;\n /** User-facing strings; see {@link ReauthDialogLabels}. */\n labels?: Partial<ReauthDialogLabels>;\n}\n\n/**\n * \"Enter your current password to continue\" — the step-up check in front of an\n * action that changes how an account signs in (adding a passkey) or what it can\n * reach. Kurvenschmiede asked for the password before registering a passkey in a\n * dialog of its own; keksdose asks for it inline in `DangerConfirm`'s\n * `requirePassword` tile. Both are the same three facts — the field is the CURRENT\n * password (the password manager fills it, never generates one), a wrong one stays in\n * the dialog with its error, and the APP verifies it — so the promise handling is\n * `DangerConfirm`'s own, shared, the field is its `CurrentPasswordInput` spelled out\n * with an `error`, and this is the modal form of them.\n *\n * A component rather than a `useReauth()` promise: the step that follows the check\n * (Kurvenschmiede's WebAuthn ceremony) needs what the server answered, and the error\n * wording needs the app's own status codes — both already live in the app's mutation,\n * which this dialog only has to be handed (`onSubmit={(pw) => begin.mutateAsync(pw)}`,\n * `error={…}`).\n *\n * The field is a `<form>`, so Enter submits once something is typed. Focus starts in\n * the field and returns to the trigger through `Modal`'s trap. While a check runs,\n * the field is read-only, Cancel is disabled and Escape is held: a request that has\n * left cannot be cancelled by closing the dialog, and the dialog should not pretend it\n * can.\n */\nexport function ReauthDialog({\n onSubmit,\n onClose,\n error,\n busy: busyProp,\n title,\n description,\n submitLabel,\n tone = \"neutral\",\n children,\n open,\n labels: labelsProp,\n}: ReauthDialogProps) {\n const labels = useKitLabels(\"reauthDialog\", DEFAULT_REAUTH_DIALOG_LABELS, labelsProp);\n const [password, setPassword] = useState(\"\");\n // The error describes the password that was submitted; once the user edits it, it\n // describes nothing on screen. Cleared by the next submit, so a second failure with\n // the same message shows again.\n const [edited, setEdited] = useState(false);\n const { pending, run } = usePromisePending();\n const busy = Boolean(busyProp) || pending;\n const inputRef = useRef<HTMLInputElement>(null);\n // `onSubmit` resolved: the actions row lowers the panel (state, so it re-renders),\n // and `onClose` answers true (a ref, because `Modal` may call the `onClose` it saw\n // BEFORE that re-render — its latest-callback ref is refreshed in an effect that runs\n // after the actions row's).\n const [done, setDone] = useState(false);\n const confirmed = useRef(false);\n const formId = useId();\n const isOpen = open ?? true;\n\n // A close (kept-mounted) wipes the password: it must not sit in a hidden dialog.\n // During render, like DangerConfirm's disarm, so no frame holds it.\n const [wasOpen, setWasOpen] = useState(isOpen);\n if (wasOpen !== isOpen) {\n setWasOpen(isOpen);\n if (!isOpen) {\n setPassword(\"\");\n setEdited(false);\n setDone(false);\n }\n }\n\n // After `Modal`'s own effect (a parent's effects run after its children's), so the\n // trap has recorded the trigger as its restore target before focus moves in.\n useEffect(() => {\n if (!isOpen) return;\n confirmed.current = false;\n inputRef.current?.focus();\n }, [isOpen]);\n\n const submit = () => {\n if (busy || password === \"\") return;\n setEdited(false);\n run(onSubmit(password), () => {\n confirmed.current = true;\n setDone(true);\n });\n };\n\n // A rejection leaves focus where it was (the field, or the submit button) — select\n // the password there so the retry replaces it rather than appending to it.\n const wasPending = useRef(false);\n useEffect(() => {\n if (wasPending.current && !pending && !done) inputRef.current?.select();\n wasPending.current = pending;\n }, [pending, done]);\n\n const shownError = edited ? undefined : error;\n\n return (\n <DialogFrame\n open={open}\n title={title ?? labels.title}\n description={description ?? labels.description}\n onClose={() => onClose(confirmed.current)}\n onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {\n if (busy && e.key === \"Escape\") e.preventDefault();\n }}\n aria-busy={busy || undefined}\n actions={\n <ReauthActions\n formId={formId}\n done={done}\n busy={busy}\n canSubmit={password !== \"\"}\n danger={tone === \"danger\"}\n cancelLabel={labels.cancel}\n submitLabel={submitLabel ?? labels.submit}\n />\n }\n >\n {children}\n <form\n id={formId}\n noValidate\n onSubmit={(e) => {\n e.preventDefault();\n submit();\n }}\n >\n {/* CurrentPasswordInput's field — the password manager fills it with the CURRENT\n password and never offers to generate one; `readOnly`, not `disabled`, while\n busy, because disabling the field that has focus drops focus to <body> —\n plus `Input`'s own `error`, which that helper does not take. The message\n paints the field, sits under it and is its `aria-describedby`; passing the\n prop on every render keeps the field's box steady, so the message going away\n on the first keystroke of a retry no longer remounts the <input> under the\n typist (it did, which is why this used to draw its own message). */}\n <Input\n ref={inputRef}\n type=\"password\"\n autoComplete=\"current-password\"\n label={labels.password}\n value={password}\n readOnly={busy}\n error={shownError}\n onChange={(e) => {\n setPassword(e.target.value);\n setEdited(true);\n }}\n />\n </form>\n </DialogFrame>\n );\n}\n\n/**\n * The actions row. A component of its own because it renders INSIDE the `Modal`, where\n * the panel's animated close is in context: a resolved `onSubmit` lowers the panel\n * through it, the way Cancel and Escape do, and `Modal` then calls `onClose`.\n */\nfunction ReauthActions({\n formId,\n done,\n busy,\n canSubmit,\n danger,\n cancelLabel,\n submitLabel,\n}: {\n formId: string;\n done: boolean;\n busy: boolean;\n canSubmit: boolean;\n danger: boolean;\n cancelLabel: ReactNode;\n submitLabel: ReactNode;\n}) {\n const close = useContext(ModalCloseContext);\n useEffect(() => {\n if (done) close?.();\n }, [done, close]);\n return (\n <>\n <Button type=\"button\" variant=\"ghost\" disabled={busy} onClick={() => close?.()}>\n {cancelLabel}\n </Button>\n <Button\n type=\"submit\"\n form={formId}\n variant={danger ? \"danger\" : \"primary\"}\n disabled={!canSubmit || busy}\n aria-busy={busy || undefined}\n >\n {busy && <Spinner label={null} className=\"h-4 w-4\" />}\n {submitLabel}\n </Button>\n </>\n );\n}\n"],"mappings":";AA6KI,SAmFA,UAzEI,KAVJ;AA7KJ,SAAS,YAAY,WAAW,OAAO,QAAQ,gBAAgB;AAG/D,SAAS,oBAAoB;AAC7B,SAAS,yBAAyB;AAClC,SAAS,mBAAmB;AAC5B,SAAS,yBAAyB;AAClC,SAAS,QAAQ,OAAO,eAAe;AAkBhC,MAAM,+BAAmD;AAAA,EAC9D,OAAO;AAAA,EACP,aAAa;AAAA,EACb,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,QAAQ;AACV;AAuEO,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP;AAAA,EACA;AAAA,EACA,QAAQ;AACV,GAAsB;AACpB,QAAM,SAAS,aAAa,gBAAgB,8BAA8B,UAAU;AACpF,QAAM,CAAC,UAAU,WAAW,IAAI,SAAS,EAAE;AAI3C,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,KAAK;AAC1C,QAAM,EAAE,SAAS,IAAI,IAAI,kBAAkB;AAC3C,QAAM,OAAO,QAAQ,QAAQ,KAAK;AAClC,QAAM,WAAW,OAAyB,IAAI;AAK9C,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AACtC,QAAM,YAAY,OAAO,KAAK;AAC9B,QAAM,SAAS,MAAM;AACrB,QAAM,SAAS,QAAQ;AAIvB,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,MAAM;AAC7C,MAAI,YAAY,QAAQ;AACtB,eAAW,MAAM;AACjB,QAAI,CAAC,QAAQ;AACX,kBAAY,EAAE;AACd,gBAAU,KAAK;AACf,cAAQ,KAAK;AAAA,IACf;AAAA,EACF;AAIA,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AACb,cAAU,UAAU;AACpB,aAAS,SAAS,MAAM;AAAA,EAC1B,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,SAAS,MAAM;AACnB,QAAI,QAAQ,aAAa,GAAI;AAC7B,cAAU,KAAK;AACf,QAAI,SAAS,QAAQ,GAAG,MAAM;AAC5B,gBAAU,UAAU;AACpB,cAAQ,IAAI;AAAA,IACd,CAAC;AAAA,EACH;AAIA,QAAM,aAAa,OAAO,KAAK;AAC/B,YAAU,MAAM;AACd,QAAI,WAAW,WAAW,CAAC,WAAW,CAAC,KAAM,UAAS,SAAS,OAAO;AACtE,eAAW,UAAU;AAAA,EACvB,GAAG,CAAC,SAAS,IAAI,CAAC;AAElB,QAAM,aAAa,SAAS,SAAY;AAExC,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,OAAO,SAAS,OAAO;AAAA,MACvB,aAAa,eAAe,OAAO;AAAA,MACnC,SAAS,MAAM,QAAQ,UAAU,OAAO;AAAA,MACxC,WAAW,CAAC,MAAqC;AAC/C,YAAI,QAAQ,EAAE,QAAQ,SAAU,GAAE,eAAe;AAAA,MACnD;AAAA,MACA,aAAW,QAAQ;AAAA,MACnB,SACE;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA;AAAA,UACA;AAAA,UACA,WAAW,aAAa;AAAA,UACxB,QAAQ,SAAS;AAAA,UACjB,aAAa,OAAO;AAAA,UACpB,aAAa,eAAe,OAAO;AAAA;AAAA,MACrC;AAAA,MAGD;AAAA;AAAA,QACD;AAAA,UAAC;AAAA;AAAA,YACC,IAAI;AAAA,YACJ,YAAU;AAAA,YACV,UAAU,CAAC,MAAM;AACf,gBAAE,eAAe;AACjB,qBAAO;AAAA,YACT;AAAA,YAUA;AAAA,cAAC;AAAA;AAAA,gBACC,KAAK;AAAA,gBACL,MAAK;AAAA,gBACL,cAAa;AAAA,gBACb,OAAO,OAAO;AAAA,gBACd,OAAO;AAAA,gBACP,UAAU;AAAA,gBACV,OAAO;AAAA,gBACP,UAAU,CAAC,MAAM;AACf,8BAAY,EAAE,OAAO,KAAK;AAC1B,4BAAU,IAAI;AAAA,gBAChB;AAAA;AAAA,YACF;AAAA;AAAA,QACF;AAAA;AAAA;AAAA,EACF;AAEJ;AAOA,SAAS,cAAc;AAAA,EACrB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAQG;AACD,QAAM,QAAQ,WAAW,iBAAiB;AAC1C,YAAU,MAAM;AACd,QAAI,KAAM,SAAQ;AAAA,EACpB,GAAG,CAAC,MAAM,KAAK,CAAC;AAChB,SACE,iCACE;AAAA,wBAAC,UAAO,MAAK,UAAS,SAAQ,SAAQ,UAAU,MAAM,SAAS,MAAM,QAAQ,GAC1E,uBACH;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,MAAM;AAAA,QACN,SAAS,SAAS,WAAW;AAAA,QAC7B,UAAU,CAAC,aAAa;AAAA,QACxB,aAAW,QAAQ;AAAA,QAElB;AAAA,kBAAQ,oBAAC,WAAQ,OAAO,MAAM,WAAU,WAAU;AAAA,UAClD;AAAA;AAAA;AAAA,IACH;AAAA,KACF;AAEJ;","names":[]}
|
|
@@ -5,7 +5,7 @@ import { LegendEntry } from './toggle-legend.js';
|
|
|
5
5
|
import { SeriesChartLabels } from './series-chart-labels.js';
|
|
6
6
|
import { TimeTickUnit } from './series-chart-ticks.js';
|
|
7
7
|
import { SeriesChartAxisBudget } from './series-chart-budget.js';
|
|
8
|
-
import { x as ChartTooltipPlacement } from '../kit-labels-
|
|
8
|
+
import { x as ChartTooltipPlacement } from '../kit-labels-CPbsjlXr.js';
|
|
9
9
|
import 'recharts';
|
|
10
10
|
import './data-table-labels.js';
|
|
11
11
|
import './mini-calendar.js';
|
|
@@ -56,6 +56,15 @@ import './authed-image.js';
|
|
|
56
56
|
import '../hooks/use-authed-src.js';
|
|
57
57
|
import './image-grid.js';
|
|
58
58
|
import './lightbox.js';
|
|
59
|
+
import './write-lock.js';
|
|
60
|
+
import './account-chips.js';
|
|
61
|
+
import '../lib/format.js';
|
|
62
|
+
import '../data-table-3mi2R3so.js';
|
|
63
|
+
import './data-table-sort.js';
|
|
64
|
+
import './swipeable-row.js';
|
|
65
|
+
import './reauth-dialog.js';
|
|
66
|
+
import './server-wake.js';
|
|
67
|
+
import '../lib/server-wake.js';
|
|
59
68
|
|
|
60
69
|
interface SeriesChartSeries {
|
|
61
70
|
/** The row key this line reads — and the `--color-<key>` it is painted with, so it
|