@eifi1/ui-kit 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -25
- package/dist/chart.d.ts +11 -8
- package/dist/components/amount-input.d.ts +28 -8
- package/dist/components/amount-input.js +3 -1
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.d.ts +21 -0
- package/dist/components/autocomplete.js +128 -103
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/button-group.d.ts +11 -8
- package/dist/components/calculator.d.ts +11 -8
- package/dist/components/column-mapper.d.ts +182 -0
- package/dist/components/column-mapper.js +375 -0
- package/dist/components/column-mapper.js.map +1 -0
- package/dist/components/combobox-core.d.ts +39 -2
- package/dist/components/combobox-core.js +29 -6
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +80 -2
- package/dist/components/combobox.js +448 -311
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +11 -8
- package/dist/components/confirm-dialog.js +7 -8
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +8 -5
- package/dist/components/country-select.d.ts +50 -7
- package/dist/components/country-select.js +65 -9
- package/dist/components/country-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +11 -8
- package/dist/components/danger-confirm.js +13 -3
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/date-picker.d.ts +41 -0
- package/dist/components/date-picker.js +269 -8
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +35 -1
- package/dist/components/entity-combobox.js +148 -112
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +11 -8
- package/dist/components/field-parts.d.ts +116 -6
- package/dist/components/field-parts.js +84 -0
- package/dist/components/field-parts.js.map +1 -1
- package/dist/components/field-strip.d.ts +105 -0
- package/dist/components/field-strip.js +46 -0
- package/dist/components/field-strip.js.map +1 -0
- package/dist/components/file-button.d.ts +11 -8
- package/dist/components/file-dropzone.d.ts +11 -8
- package/dist/components/form-actions.d.ts +10 -7
- package/dist/components/form-actions.js +8 -2
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/iban-input.d.ts +11 -8
- package/dist/components/icon-picker.d.ts +10 -1
- package/dist/components/icon-picker.js +12 -4
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/language-select.d.ts +11 -8
- package/dist/components/money-field.d.ts +14 -8
- package/dist/components/money-field.js.map +1 -1
- package/dist/components/month-picker.js +2 -1
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +26 -1
- package/dist/components/multi-entity-combobox.js +143 -102
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/number-field.d.ts +11 -8
- package/dist/components/number-input.d.ts +11 -8
- package/dist/components/numpad-sheet.d.ts +11 -8
- package/dist/components/phone-input.d.ts +11 -8
- package/dist/components/reauth-dialog.d.ts +2 -2
- package/dist/components/reauth-dialog.js +6 -8
- package/dist/components/reauth-dialog.js.map +1 -1
- package/dist/components/series-chart.d.ts +11 -8
- package/dist/components/settings-fields.d.ts +11 -8
- package/dist/components/share-card.d.ts +10 -7
- package/dist/components/swatch-picker.d.ts +17 -1
- package/dist/components/swatch-picker.js +10 -4
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/text-link.d.ts +10 -7
- package/dist/components/tile-radio.d.ts +20 -0
- package/dist/components/tile-radio.js +25 -8
- package/dist/components/tile-radio.js.map +1 -1
- package/dist/components/time-input.d.ts +11 -8
- package/dist/components/toggle-group.js +2 -2
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/trigger-aria.d.ts +3 -0
- package/dist/components/trigger-aria.js +3 -1
- package/dist/components/trigger-aria.js.map +1 -1
- package/dist/components/ui.d.ts +8 -5
- package/dist/components/ui.js +5 -11
- package/dist/components/ui.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +1 -1
- package/dist/feedback/feedback-attachment.js +156 -56
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +1 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +3 -2
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +68 -287
- package/dist/feedback/feedback-thread.js +8 -1
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/{kit-labels-R8mIAc9N.d.ts → feedback-BxeQVzwq.d.ts} +405 -22
- package/dist/{feedback-attachment-NhUm7Zga.d.ts → feedback-attachment-fGAzZPf0.d.ts} +112 -10
- package/dist/feedback.d.ts +66 -2
- package/dist/hooks/use-file-drop.d.ts +11 -8
- package/dist/i18n/defaults.d.ts +11 -8
- package/dist/i18n/defaults.js +3 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +11 -8
- package/dist/i18n/german.js +36 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +7 -4
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +11 -8
- package/dist/i18n/locales/de-CH.d.ts +11 -8
- package/dist/i18n/locales/en.d.ts +11 -8
- package/dist/i18n/locales/en.js +7 -0
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +11 -8
- package/dist/i18n/locales/es.js +37 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +11 -8
- package/dist/i18n/locales/fr.js +36 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +11 -8
- package/dist/i18n/locales/hu.js +38 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +11 -8
- package/dist/i18n/locales/it.js +37 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +11 -8
- package/dist/i18n/locales/zh.js +34 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +11 -8
- package/dist/i18n/review.js +20 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/column-mapping.d.ts +81 -0
- package/dist/lib/column-mapping.js +108 -0
- package/dist/lib/column-mapping.js.map +1 -0
- package/dist/lib/table-text.d.ts +109 -1
- package/dist/lib/table-text.js +122 -1
- package/dist/lib/table-text.js.map +1 -1
- package/dist/rhf/fields.d.ts +89 -11
- package/dist/rhf/fields.js +124 -0
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form.d.ts +11 -8
- package/dist/rhf.d.ts +12 -9
- package/dist/rhf.js.map +1 -1
- package/dist/shell/app-shell.d.ts +10 -7
- package/dist/shell/top-bar-brand.d.ts +11 -8
- package/dist/shell/topbar-action-menu.d.ts +36 -3
- package/dist/shell/topbar-action-menu.js +74 -33
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +10 -7
- package/dist/table-text.d.ts +1 -1
- package/dist/wizard/stepper-nav.d.ts +41 -10
- package/dist/wizard/stepper-nav.js +4 -0
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard.d.ts +11 -8
- package/package.json +1 -1
- package/src/components/amount-input.tsx +20 -1
- package/src/components/autocomplete.tsx +172 -111
- package/src/components/column-mapper.tsx +666 -0
- package/src/components/combobox-core.tsx +82 -9
- package/src/components/combobox.tsx +614 -332
- package/src/components/confirm-dialog.tsx +12 -8
- package/src/components/country-select.tsx +135 -22
- package/src/components/danger-confirm.tsx +91 -12
- package/src/components/date-picker.tsx +423 -10
- package/src/components/entity-combobox.tsx +217 -126
- package/src/components/field-parts.tsx +224 -5
- package/src/components/field-strip.tsx +149 -0
- package/src/components/form-actions.tsx +32 -4
- package/src/components/icon-picker.tsx +23 -4
- package/src/components/money-field.tsx +3 -0
- package/src/components/month-picker.tsx +2 -1
- package/src/components/multi-entity-combobox.tsx +207 -120
- package/src/components/reauth-dialog.tsx +17 -18
- package/src/components/swatch-picker.tsx +29 -5
- package/src/components/tile-radio.tsx +74 -13
- package/src/components/toggle-group.tsx +2 -2
- package/src/components/trigger-aria.ts +5 -0
- package/src/components/ui.tsx +7 -33
- package/src/feedback/feedback-attachment.tsx +308 -61
- package/src/feedback/feedback-dialog.tsx +7 -3
- package/src/feedback/feedback-inbox.tsx +3 -2
- package/src/feedback/feedback-thread.tsx +47 -4
- package/src/i18n/defaults.ts +2 -0
- package/src/i18n/german.ts +42 -0
- package/src/i18n/kit-labels.tsx +4 -0
- package/src/i18n/locales/en.ts +27 -5
- package/src/i18n/locales/es.ts +41 -0
- package/src/i18n/locales/fr.ts +42 -0
- package/src/i18n/locales/hu.ts +37 -0
- package/src/i18n/locales/it.ts +41 -0
- package/src/i18n/locales/zh.ts +32 -0
- package/src/i18n/review.ts +19 -0
- package/src/index.ts +18 -0
- package/src/lib/column-mapping.ts +234 -0
- package/src/lib/table-text.ts +271 -0
- package/src/rhf/fields.tsx +254 -0
- package/src/rhf.ts +2 -1
- package/src/shell/topbar-action-menu.tsx +134 -38
- package/src/wizard/stepper-nav.tsx +35 -1
- package/dist/components/field-anatomy.d.ts +0 -95
- package/dist/components/field-anatomy.js +0 -84
- package/dist/components/field-anatomy.js.map +0 -1
- package/src/components/field-anatomy.tsx +0 -190
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/form-actions.tsx"],"sourcesContent":["import { isValidElement, useCallback, useEffect, useRef } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode, Ref } from \"react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { isApplePlatform } from \"../hooks/use-hotkey\";\nimport { Button, Spinner, type ButtonProps, type ButtonVariant } from \"./ui\";\nimport { useWriteLock } from \"./write-lock\";\n\n/** The words {@link FormActions} renders on its own behalf — the `form` namespace. */\nexport interface FormActionsLabels {\n save: string;\n cancel: string;\n /**\n * The {@link FormActionsProps.submitShortcut} hint beside Save's text, when\n * {@link FormActionsProps.submitShortcutHint} shows it. `apple` is true on a Mac,\n * iPhone or iPad, where the key is Cmd: \"⌘ Enter\" there, \"Ctrl+Enter\" elsewhere\n * (a German keyboard says \"Strg\").\n */\n submitShortcut: (apple: boolean) => string;\n}\n\nexport const DEFAULT_FORM_ACTIONS_LABELS: FormActionsLabels = {\n save: \"Save\",\n cancel: \"Cancel\",\n submitShortcut: (apple) => (apple ? \"⌘ Enter\" : \"Ctrl+Enter\"),\n};\n\n/** See {@link FormActionsProps.submitShortcut}. */\nexport type FormActionsSubmitShortcut = \"mod-enter\";\n\nexport type FormActionsAlign = \"start\" | \"center\" | \"end\" | \"between\";\n\n/**\n * Where the row sits:\n * - `inline` (default): under the form's last field, `pt-4` above it.\n * - `sticky`: stuck to the bottom of the scrolling page or pane, on the surface\n * colour with a rule above, clear of the phone's home indicator — for a long form\n * whose Save should not be a scroll away.\n * - `dialog`: no spacing of its own, for a dialog's footer slot (DialogFrame,\n * FullBleedDialog's `footer`), which already pads and rules it.\n */\nexport type FormActionsPlacement = \"inline\" | \"sticky\" | \"dialog\";\n\n/**\n * A placement per breakpoint, mobile first — `{ base: \"sticky\", md: \"inline\" }` is a\n * Save row stuck to the bottom of a phone's long form and in the flow under the last\n * field from 768px up, where the form fits. The breakpoints are Tailwind's (sm 640,\n * md 768, lg 1024, xl 1280px), as `BulkActionBar`'s `variant` breakpoints are, and\n * resolved the same way, in JS: the sticky row is positioned by inline style, which a\n * `md:` class cannot reach. keksdose switched `placement` on its own media query at\n * every long form for want of this. The breakpoints are the VIEWPORT's, not the\n * container's: a form in a narrow pane on a wide screen resolves to `md`.\n */\nexport interface ResponsiveFormActionsPlacement {\n base: FormActionsPlacement;\n sm?: FormActionsPlacement;\n md?: FormActionsPlacement;\n lg?: FormActionsPlacement;\n xl?: FormActionsPlacement;\n}\n\nconst BREAKPOINTS = [\n [\"xl\", \"(min-width: 1280px)\"],\n [\"lg\", \"(min-width: 1024px)\"],\n [\"md\", \"(min-width: 768px)\"],\n [\"sm\", \"(min-width: 640px)\"],\n] as const;\n\n/** The placement in force: the widest breakpoint that matches AND names one, else\n * `base`. The queries are subscribed unconditionally (hooks cannot be skipped); a\n * plain string ignores them. Without `matchMedia` (SSR, tests) none match, so the row\n * renders its `base`, the phone's. The same resolution as BulkActionBar's variant. */\nfunction useResolvedPlacement(\n placement: FormActionsPlacement | ResponsiveFormActionsPlacement,\n): FormActionsPlacement {\n const matches = {\n xl: useMediaQuery(BREAKPOINTS[0][1], false),\n lg: useMediaQuery(BREAKPOINTS[1][1], false),\n md: useMediaQuery(BREAKPOINTS[2][1], false),\n sm: useMediaQuery(BREAKPOINTS[3][1], false),\n };\n if (typeof placement === \"string\") return placement;\n for (const [key] of BREAKPOINTS) {\n const p = placement[key];\n if (matches[key] && p !== undefined) return p;\n }\n return placement.base;\n}\n\n/** A CSS length: a number is pixels. */\nfunction cssLength(value: string | number): string {\n return typeof value === \"number\" ? `${value}px` : value;\n}\n\n/**\n * What a `sticky` row sticks to — see {@link FormActionsProps.stickyWithin}.\n * - `viewport` (default): the page's (or AppShell pane's) bottom edge, lifted onto\n * the phone's bottom nav by `--app-nav-h`.\n * - `container`: the bottom of whatever scroll container it is in — a dialog body, a\n * side pane, a panel with `overflow-y-auto` — at `bottom: 0`, with no nav offset.\n */\nexport type FormActionsStickyWithin = \"viewport\" | \"container\";\n\n/**\n * Extra attributes for the save button — an `id`, `data-*` hooks for tests or\n * analytics, an `aria-describedby`. The props FormActions drives itself (type, click,\n * disabled, variant, form, children, the busy state) are not among them.\n */\nexport type FormActionsSubmitProps = Omit<\n ButtonProps,\n \"type\" | \"onClick\" | \"disabled\" | \"disabledReason\" | \"variant\" | \"form\" | \"children\" | \"aria-busy\" | \"commit\"\n> & { [key: `data-${string}`]: string | number | boolean | undefined };\n\n/** The start-side action of {@link FormActionsProps.destructive}, as data. */\nexport interface FormActionsDestructive {\n label: ReactNode;\n onClick: () => void;\n disabled?: boolean;\n /** See Button's `disabledReason`. */\n disabledReason?: ReactNode;\n}\n\nconst ALIGN_CLASS: Record<FormActionsAlign, string> = {\n start: \"justify-start\",\n center: \"justify-center\",\n end: \"justify-end\",\n between: \"justify-between\",\n};\n\nconst PLACEMENT_CLASS: Record<FormActionsPlacement, string> = {\n inline: \"pt-4\",\n sticky: \"sticky bottom-0 z-10 border-t border-[var(--border)] bg-[var(--bg-surface)] pt-3\",\n dialog: \"\",\n};\n\nexport interface FormActionsProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n /** Called by Cancel. Omitted: no Cancel button (a form that only saves). */\n onCancel?: () => void;\n /**\n * Given: the save button is `type=\"button\"` and calls this — for a dialog or a row\n * that saves from a handler. Omitted: it is `type=\"submit\"` and submits the\n * enclosing `<form>` (or the one named by {@link form}).\n */\n onSubmit?: () => void;\n /** The save button's text. Default `form.save` from the provider, else \"Save\". */\n submitLabel?: ReactNode;\n /** The cancel button's text. Default `form.cancel` from the provider, else \"Cancel\". */\n cancelLabel?: ReactNode;\n /** The save button's text while `pending` (\"Saving…\"). Default: `submitLabel`. */\n pendingLabel?: ReactNode;\n /**\n * A Lucide icon before the save button's text. While `pending` the spinner takes\n * ITS place rather than being added in front of it — an icon written into\n * `submitLabel` itself stays, so the button would show the spinner AND the glyph,\n * two statuses side by side (keksdose's crop Apply needed a `pendingLabel` copy of\n * its text only to drop the check mark).\n */\n submitIcon?: LucideIcon;\n /** Attributes passed through to the save button: `id`, `data-*`, `aria-*`, a\n * `className`. See {@link FormActionsSubmitProps}. */\n submitProps?: FormActionsSubmitProps;\n /**\n * The save is running: a spinner in the save button, `aria-busy` on it, and the\n * button disabled, so a second click cannot send the form twice. Cancel stays\n * usable. The spinner goes in front of the text, or in place of {@link submitIcon}.\n */\n pending?: boolean;\n /** Disable save for a reason of the form's own (an unbalanced entry). */\n submitDisabled?: boolean;\n /** Why save is disabled — see Button's `disabledReason`. Keeps it focusable. */\n submitDisabledReason?: ReactNode;\n /**\n * Save is a commit: under a locked {@link WriteLockProvider} it is disabled with the\n * lock's reason (over `submitDisabledReason`), focusable, the reason in its tooltip —\n * Button's `commit`. The `{ label, onClick }` form of {@link destructive} is a\n * commit too and is locked with it; Cancel and `children` are not (closing a form\n * writes nothing). No provider, or an unlocked one: no effect. Default `false`.\n *\n * keksdose wrote `submitDisabled={lock.locked || …}` and\n * `submitDisabledReason={lock.locked ? lock.reason : undefined}` at each form; this is\n * that pair, read from the provider.\n */\n commit?: boolean;\n /** The save button's variant. Default `brand`; `danger` for a save that destroys\n * (kastlan's `destructive` flag on its own FormActions). */\n submitVariant?: ButtonVariant;\n /**\n * A destructive action at the START of the row, apart from Save and Cancel — the\n * \"Delete\" of an edit dialog. Pass `{ label, onClick }` for the kit's quiet danger\n * button, or an element of your own (a {@link DangerConfirm}, say). With it the row\n * is `between`-aligned unless `align` says otherwise.\n */\n destructive?: ReactNode | FormActionsDestructive;\n /**\n * Something neutral at the START of the row — a caption (\"Last saved 2 min ago\",\n * \"Drag the corners to crop\"), a \"View activity\" link. After {@link destructive}\n * when both are given; like it, makes the row `between`-aligned unless `align` says\n * otherwise. `children` are for further ACTIONS, beside Cancel.\n */\n start?: ReactNode;\n /** Horizontal alignment. Default `end` (`between` with a `destructive` action). */\n align?: FormActionsAlign;\n /** See {@link FormActionsPlacement}, or one per breakpoint — see\n * {@link ResponsiveFormActionsPlacement}. Default `inline`. */\n placement?: FormActionsPlacement | ResponsiveFormActionsPlacement;\n /**\n * While the row is `sticky`: pull it out over its container's inline padding by this\n * much (a CSS length, a number in px) and give the same back as padding — so the\n * rule above it and its surface run edge to edge, while the buttons stay on the\n * content's line. Without it a sticky row in a padded card or pane is a strip inset\n * from both sides, with the content scrolling past it in the gutters (keksdose wrote\n * `@max-md:-mx-3 @max-md:px-3` by hand). Pass the container's padding (`\"0.75rem\"`,\n * `12`, `\"var(--pane-px)\"`). Applied only while the resolved placement is sticky, so\n * `placement={{ base: \"sticky\", md: \"inline\" }}` bleeds on the phone alone. The margin\n * is `calc(-1 * X)`; jsdom folds that to `calc(-X)`, so match it loosely in tests.\n */\n bleed?: string | number;\n /**\n * With `placement=\"sticky\"`: what the row sticks to. Default `viewport`.\n *\n * CSS sticky always sticks to the NEAREST scroll container, so the default row\n * already holds inside a scrolling pane — but it offsets itself by `--app-nav-h`,\n * which AppShell publishes on the document: inside a pane or a portalled dialog\n * body on a phone it then floats a nav's height above that container's bottom,\n * with the content scrolling through the gap. `container` drops that offset.\n *\n * What neither value can fix, because it is the layout, not the row:\n * - sticky moves only within its PARENT box. Inside a DataTable expansion row the\n * parent is the expansion cell, so the row pins to the table's own scroller\n * (`overflow-auto`, bounded by `maxBodyHeight`) and only while the cell spans\n * that scroller's bottom edge — not to the page.\n * - any `overflow` other than `visible`/`clip` between the row and the scroller\n * you mean (an `overflow-x-auto` wrapper, an `overflow-hidden` card) becomes the\n * scroll container; if THAT box does not scroll, the row never sticks.\n * - the offset is resolved against the scroller's content box, so a scroller with\n * bottom padding leaves that strip uncovered; give the row a negative `bottom`\n * through `style` and the same amount back as padding.\n */\n stickyWithin?: FormActionsStickyWithin;\n /** The `id` of the `<form>` the save button submits, when the row is rendered\n * outside it (a dialog's footer slot). */\n form?: string;\n /**\n * A keyboard shortcut that presses Save from anywhere in the form. `\"mod-enter\"`:\n * Ctrl+Enter, or Cmd+Enter on a Mac — both are accepted everywhere, as keksdose's\n * editors always have. keksdose K14 (inputs audit 2026-10-02, S5): its transaction\n * editor, category editor and create card each wrote this as an `onKeyDown` on a\n * container, re-stating `!pending && !saveDisabled && !lock.locked` every time, and\n * the one that forgot the lock let the demo's refused save through from the keyboard.\n *\n * What it does is what pressing Save does — the same button is clicked, so a form\n * submits with its validation and its submitter, and `onSubmit` is called in the\n * `type=\"button\"` mode — and it does NOTHING while Save could not be pressed:\n * `pending`, `submitDisabled`, or a `commit` under a locked {@link WriteLockProvider}.\n *\n * Scoped to the form Save submits (the `form` prop's, else the enclosing `<form>`):\n * the chord counts only when the focus is inside it, so two editors on one page each\n * answer for their own fields. Without a form (an `onSubmit` row in a plain container)\n * the scope is the nearest `role=\"dialog\"`, else the row's parent element — the\n * container the row ends.\n *\n * Listened for on the document, so it runs AFTER every React handler: a field that\n * takes Ctrl+Enter for itself calls `preventDefault()` or `stopPropagation()` and\n * the row stays out of it (keksdose live #202 — the create card's Ctrl+Shift+Enter\n * must create ONE row; the chord here is exactly Ctrl/Cmd+Enter, never with Shift or\n * Alt). Not during IME composition, and not on a held key's repeats.\n *\n * The save button announces it with `aria-keyshortcuts`; {@link submitShortcutHint}\n * also shows it.\n */\n submitShortcut?: FormActionsSubmitShortcut;\n /** Show the {@link submitShortcut} beside Save's text (\"Ctrl+Enter\", \"⌘ Enter\"; the\n * `form.submitShortcut` label), dimmed. Hidden on a touch-only device, which has no\n * keyboard to press it on. Default `false`. */\n submitShortcutHint?: boolean;\n /** Extra actions, placed before Cancel. */\n children?: ReactNode;\n}\n\n/** Hand `el` to a caller's ref, whichever kind it is. */\nfunction assignRef<E>(ref: Ref<E> | undefined, el: E | null): void {\n if (typeof ref === \"function\") ref(el);\n else if (ref) (ref as { current: E | null }).current = el;\n}\n\n/**\n * Where {@link FormActionsProps.submitShortcut} listens: the form Save submits (named\n * by `formId`, else the enclosing one), else the nearest dialog, else the row's parent.\n */\nfunction shortcutScope(row: HTMLElement | null, formId: string | undefined): Element | null {\n if (!row) return null;\n const named = formId ? row.ownerDocument.getElementById(formId) : null;\n if (named instanceof HTMLFormElement) return named;\n return row.closest(\"form\") ?? row.closest('[role=\"dialog\"]') ?? row.parentElement;\n}\n\nfunction isDestructiveData(value: unknown): value is FormActionsDestructive {\n return typeof value === \"object\" && value !== null && !isValidElement(value) && \"onClick\" in value;\n}\n\n/**\n * The Save / Cancel row every form, wizard-step form and dialog ends in — kastlan's\n * `FormActions`, with the labels from the provider, a pending state and a place for\n * the destructive action.\n *\n * ```tsx\n * <form onSubmit={form.handleSubmit(save)}>\n * …\n * <FormActions onCancel={close} pending={mutation.isPending} />\n * </form>\n * ```\n *\n * The spinner is the kit's {@link Spinner}, drawn inside the button with `label={null}`\n * so the button's name stays its text; `aria-busy` is what says it is working.\n */\nexport function FormActions({\n onCancel,\n onSubmit,\n submitLabel,\n cancelLabel,\n pendingLabel,\n submitIcon: SubmitIcon,\n submitProps,\n pending = false,\n submitDisabled = false,\n submitDisabledReason,\n commit = false,\n submitVariant = \"brand\",\n destructive,\n start: startSlot,\n align,\n placement: placementProp = \"inline\",\n stickyWithin = \"viewport\",\n bleed,\n form,\n submitShortcut,\n submitShortcutHint = false,\n children,\n className,\n style,\n ...rest\n}: FormActionsProps) {\n const labels = useKitLabels(\"form\", DEFAULT_FORM_ACTIONS_LABELS);\n const lock = useWriteLock();\n const rootRef = useRef<HTMLDivElement>(null);\n const saveRef = useRef<HTMLButtonElement | null>(null);\n // The caller's own `submitProps.ref` still gets the element.\n const callerSaveRef = submitProps?.ref;\n const setSaveRef = useCallback(\n (el: HTMLButtonElement | null) => {\n saveRef.current = el;\n assignRef(callerSaveRef, el);\n },\n [callerSaveRef],\n );\n // Read by the shortcut's listener at the moment of the key press, so the listener\n // itself is attached once and never sees a stale `pending`.\n const canSave = !pending && !submitDisabled && !(commit && lock.locked);\n const canSaveRef = useRef(canSave);\n useEffect(() => {\n canSaveRef.current = canSave;\n });\n useEffect(() => {\n if (submitShortcut !== \"mod-enter\") return;\n const onKeyDown = (e: KeyboardEvent) => {\n if (e.key !== \"Enter\" || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;\n if (e.repeat || e.isComposing || e.defaultPrevented || !canSaveRef.current) return;\n const scope = shortcutScope(rootRef.current, form);\n if (!scope || !(e.target instanceof Node) || !scope.contains(e.target)) return;\n e.preventDefault();\n saveRef.current?.click();\n };\n document.addEventListener(\"keydown\", onKeyDown);\n return () => document.removeEventListener(\"keydown\", onKeyDown);\n }, [submitShortcut, form]);\n const apple = submitShortcut ? isApplePlatform() : false;\n const placement = useResolvedPlacement(placementProp);\n const bleedLength = bleed === undefined || bleed === \"\" ? undefined : cssLength(bleed);\n const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;\n const hasStart = startSlot !== undefined && startSlot !== null && startSlot !== false;\n const justify = align ?? (hasDestructive || hasStart ? \"between\" : \"end\");\n const destructiveNode = !hasDestructive ? null : isDestructiveData(destructive) ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n tone=\"danger\"\n onClick={destructive.onClick}\n disabled={destructive.disabled}\n disabledReason={destructive.disabledReason}\n commit={commit}\n >\n {destructive.label}\n </Button>\n ) : (\n destructive\n );\n const saveLabel = submitLabel ?? labels.save;\n\n return (\n <div\n ref={rootRef}\n data-slot=\"form-actions\"\n data-placement={placement}\n data-sticky-within={placement === \"sticky\" ? stickyWithin : undefined}\n {...rest}\n style={\n placement === \"sticky\"\n ? {\n paddingBottom: \"max(0.75rem, env(safe-area-inset-bottom))\",\n // On the phone's bottom nav, not behind it (keksdose live #361): `bottom-0`\n // pinned the row under AppShell's nav, which covers the page's last ~56px.\n // `--app-nav-h` is the nav's measured height (0px from md up, and outside an\n // AppShell); the 1px overlaps the two top borders into one line. In a\n // container the nav is not underneath: `--app-nav-h` is the document's,\n // and would hold the row that far above the container's own edge.\n bottom: stickyWithin === \"container\" ? 0 : \"max(0px, calc(var(--app-nav-h, 0px) - 1px))\",\n ...(bleedLength !== undefined && {\n marginInline: `calc(-1 * ${bleedLength})`,\n paddingInline: bleedLength,\n }),\n ...style,\n }\n : style\n }\n className={cn(\"flex flex-wrap items-center gap-2\", ALIGN_CLASS[justify], PLACEMENT_CLASS[placement], className)}\n >\n {(hasDestructive || hasStart) && (\n <div className=\"me-auto flex min-w-0 flex-wrap items-center gap-2\">\n {destructiveNode}\n {hasStart && startSlot}\n </div>\n )}\n <div className=\"flex flex-wrap items-center gap-2\">\n {children}\n {onCancel && (\n <Button type=\"button\" variant=\"secondary\" onClick={onCancel}>\n {cancelLabel ?? labels.cancel}\n </Button>\n )}\n <Button\n {...submitProps}\n ref={setSaveRef}\n aria-keyshortcuts={\n submitShortcut === \"mod-enter\" ? \"Control+Enter Meta+Enter\" : submitProps?.[\"aria-keyshortcuts\"]\n }\n type={onSubmit ? \"button\" : \"submit\"}\n form={form}\n onClick={onSubmit}\n variant={submitVariant}\n disabled={pending || submitDisabled}\n disabledReason={pending ? undefined : submitDisabledReason}\n // Not while pending: the save already left, and the lock's look over the\n // spinner would say it had not.\n commit={commit && !pending}\n aria-busy={pending || undefined}\n >\n {pending ? (\n <Spinner label={null} className=\"size-4\" />\n ) : (\n SubmitIcon && <SubmitIcon aria-hidden className=\"size-4 shrink-0\" />\n )}\n {pending && pendingLabel !== undefined ? pendingLabel : saveLabel}\n {submitShortcut && submitShortcutHint && (\n // Hidden from the accessibility tree: `aria-keyshortcuts` already says it,\n // and in the name it would make the button \"Save Ctrl+Enter\".\n <kbd\n aria-hidden\n className=\"ms-1 font-sans text-[0.85em] font-normal whitespace-nowrap opacity-70 pointer-coarse:hidden\"\n >\n {labels.submitShortcut(apple)}\n </kbd>\n )}\n </Button>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/form-actions.tsx"],"sourcesContent":["import { isValidElement, useCallback, useEffect, useRef } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode, Ref } from \"react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { isApplePlatform } from \"../hooks/use-hotkey\";\nimport { Button, Spinner, type ButtonProps, type ButtonSize, type ButtonVariant } from \"./ui\";\nimport { useWriteLock } from \"./write-lock\";\n\n/** The words {@link FormActions} renders on its own behalf — the `form` namespace. */\nexport interface FormActionsLabels {\n save: string;\n cancel: string;\n /**\n * The {@link FormActionsProps.submitShortcut} hint beside Save's text, when\n * {@link FormActionsProps.submitShortcutHint} shows it. `apple` is true on a Mac,\n * iPhone or iPad, where the key is Cmd: \"⌘ Enter\" there, \"Ctrl+Enter\" elsewhere\n * (a German keyboard says \"Strg\").\n */\n submitShortcut: (apple: boolean) => string;\n}\n\nexport const DEFAULT_FORM_ACTIONS_LABELS: FormActionsLabels = {\n save: \"Save\",\n cancel: \"Cancel\",\n submitShortcut: (apple) => (apple ? \"⌘ Enter\" : \"Ctrl+Enter\"),\n};\n\n/** See {@link FormActionsProps.submitShortcut}. */\nexport type FormActionsSubmitShortcut = \"mod-enter\";\n\nexport type FormActionsAlign = \"start\" | \"center\" | \"end\" | \"between\";\n\n/**\n * Where the row sits:\n * - `inline` (default): under the form's last field, `pt-4` above it.\n * - `sticky`: stuck to the bottom of the scrolling page or pane, on the surface\n * colour with a rule above, clear of the phone's home indicator — for a long form\n * whose Save should not be a scroll away.\n * - `dialog`: no spacing of its own, for a dialog's footer slot (DialogFrame,\n * FullBleedDialog's `footer`), which already pads and rules it.\n */\nexport type FormActionsPlacement = \"inline\" | \"sticky\" | \"dialog\";\n\n/**\n * A placement per breakpoint, mobile first — `{ base: \"sticky\", md: \"inline\" }` is a\n * Save row stuck to the bottom of a phone's long form and in the flow under the last\n * field from 768px up, where the form fits. The breakpoints are Tailwind's (sm 640,\n * md 768, lg 1024, xl 1280px), as `BulkActionBar`'s `variant` breakpoints are, and\n * resolved the same way, in JS: the sticky row is positioned by inline style, which a\n * `md:` class cannot reach. keksdose switched `placement` on its own media query at\n * every long form for want of this. The breakpoints are the VIEWPORT's, not the\n * container's: a form in a narrow pane on a wide screen resolves to `md`.\n */\nexport interface ResponsiveFormActionsPlacement {\n base: FormActionsPlacement;\n sm?: FormActionsPlacement;\n md?: FormActionsPlacement;\n lg?: FormActionsPlacement;\n xl?: FormActionsPlacement;\n}\n\nconst BREAKPOINTS = [\n [\"xl\", \"(min-width: 1280px)\"],\n [\"lg\", \"(min-width: 1024px)\"],\n [\"md\", \"(min-width: 768px)\"],\n [\"sm\", \"(min-width: 640px)\"],\n] as const;\n\n/** The placement in force: the widest breakpoint that matches AND names one, else\n * `base`. The queries are subscribed unconditionally (hooks cannot be skipped); a\n * plain string ignores them. Without `matchMedia` (SSR, tests) none match, so the row\n * renders its `base`, the phone's. The same resolution as BulkActionBar's variant. */\nfunction useResolvedPlacement(\n placement: FormActionsPlacement | ResponsiveFormActionsPlacement,\n): FormActionsPlacement {\n const matches = {\n xl: useMediaQuery(BREAKPOINTS[0][1], false),\n lg: useMediaQuery(BREAKPOINTS[1][1], false),\n md: useMediaQuery(BREAKPOINTS[2][1], false),\n sm: useMediaQuery(BREAKPOINTS[3][1], false),\n };\n if (typeof placement === \"string\") return placement;\n for (const [key] of BREAKPOINTS) {\n const p = placement[key];\n if (matches[key] && p !== undefined) return p;\n }\n return placement.base;\n}\n\n/** A CSS length: a number is pixels. */\nfunction cssLength(value: string | number): string {\n return typeof value === \"number\" ? `${value}px` : value;\n}\n\n/**\n * What a `sticky` row sticks to — see {@link FormActionsProps.stickyWithin}.\n * - `viewport` (default): the page's (or AppShell pane's) bottom edge, lifted onto\n * the phone's bottom nav by `--app-nav-h`.\n * - `container`: the bottom of whatever scroll container it is in — a dialog body, a\n * side pane, a panel with `overflow-y-auto` — at `bottom: 0`, with no nav offset.\n */\nexport type FormActionsStickyWithin = \"viewport\" | \"container\";\n\n/**\n * Extra attributes for the save button — an `id`, `data-*` hooks for tests or\n * analytics, an `aria-describedby`. The props FormActions drives itself (type, click,\n * disabled, variant, form, children, the busy state) are not among them.\n */\nexport type FormActionsSubmitProps = Omit<\n ButtonProps,\n \"type\" | \"onClick\" | \"disabled\" | \"disabledReason\" | \"variant\" | \"form\" | \"children\" | \"aria-busy\" | \"commit\"\n> & { [key: `data-${string}`]: string | number | boolean | undefined };\n\n/** The start-side action of {@link FormActionsProps.destructive}, as data. */\nexport interface FormActionsDestructive {\n label: ReactNode;\n onClick: () => void;\n disabled?: boolean;\n /** See Button's `disabledReason`. */\n disabledReason?: ReactNode;\n}\n\nconst ALIGN_CLASS: Record<FormActionsAlign, string> = {\n start: \"justify-start\",\n center: \"justify-center\",\n end: \"justify-end\",\n between: \"justify-between\",\n};\n\nconst PLACEMENT_CLASS: Record<FormActionsPlacement, string> = {\n inline: \"pt-4\",\n sticky: \"sticky bottom-0 z-10 border-t border-[var(--border)] bg-[var(--bg-surface)] pt-3\",\n dialog: \"\",\n};\n\nexport interface FormActionsProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n /** Called by Cancel. Omitted: no Cancel button (a form that only saves). */\n onCancel?: () => void;\n /**\n * Given: the save button is `type=\"button\"` and calls this — for a dialog or a row\n * that saves from a handler. Omitted: it is `type=\"submit\"` and submits the\n * enclosing `<form>` (or the one named by {@link form}).\n */\n onSubmit?: () => void;\n /** The save button's text. Default `form.save` from the provider, else \"Save\". */\n submitLabel?: ReactNode;\n /** The cancel button's text. Default `form.cancel` from the provider, else \"Cancel\". */\n cancelLabel?: ReactNode;\n /** The save button's text while `pending` (\"Saving…\"). Default: `submitLabel`. */\n pendingLabel?: ReactNode;\n /**\n * A Lucide icon before the save button's text. While `pending` the spinner takes\n * ITS place rather than being added in front of it — an icon written into\n * `submitLabel` itself stays, so the button would show the spinner AND the glyph,\n * two statuses side by side (keksdose's crop Apply needed a `pendingLabel` copy of\n * its text only to drop the check mark).\n */\n submitIcon?: LucideIcon;\n /** Attributes passed through to the save button: `id`, `data-*`, `aria-*`, a\n * `className`. See {@link FormActionsSubmitProps}. */\n submitProps?: FormActionsSubmitProps;\n /**\n * The save is running: a spinner in the save button, `aria-busy` on it, and the\n * button disabled, so a second click cannot send the form twice. Cancel stays\n * usable. The spinner goes in front of the text, or in place of {@link submitIcon}.\n */\n pending?: boolean;\n /** Disable save for a reason of the form's own (an unbalanced entry). */\n submitDisabled?: boolean;\n /** Why save is disabled — see Button's `disabledReason`. Keeps it focusable. */\n submitDisabledReason?: ReactNode;\n /**\n * Save is a commit: under a locked {@link WriteLockProvider} it is disabled with the\n * lock's reason (over `submitDisabledReason`), focusable, the reason in its tooltip —\n * Button's `commit`. The `{ label, onClick }` form of {@link destructive} is a\n * commit too and is locked with it; Cancel and `children` are not (closing a form\n * writes nothing). No provider, or an unlocked one: no effect. Default `false`.\n *\n * keksdose wrote `submitDisabled={lock.locked || …}` and\n * `submitDisabledReason={lock.locked ? lock.reason : undefined}` at each form; this is\n * that pair, read from the provider.\n */\n commit?: boolean;\n /** The save button's variant. Default `brand`; `danger` for a save that destroys\n * (kastlan's `destructive` flag on its own FormActions). */\n submitVariant?: ButtonVariant;\n /**\n * The Cancel button's variant. Default `secondary` (the outlined button, as before).\n * `ghost` for a row where Cancel should step back further than an outline — keksdose\n * G3: a quiet inline row (a confirm under a table row, a card's own small editor)\n * whose Cancel was a ghost button before it moved onto FormActions, which drew every\n * Cancel outlined.\n */\n cancelVariant?: ButtonVariant;\n /**\n * The size of every button the row draws itself — Save, Cancel and the\n * `{ label, onClick }` form of {@link destructive}: Button's `size`. Default `md`.\n * `sm` (12px text, `px-2 py-1`) for a dense row: a confirm inside a table's expansion\n * row, a toolbar card. keksdose G3: two such rows had small hand-built buttons and lost\n * them on adopting FormActions, which had no size. At `sm` the spinner and\n * {@link submitIcon} shrink with the text.\n *\n * Your own elements (`children`, an element passed as `destructive`, `start`) are\n * yours to size — pass them `size=\"sm\"` too. A `size` in {@link submitProps} still\n * wins for Save, as it always has.\n */\n size?: ButtonSize;\n /**\n * A destructive action at the START of the row, apart from Save and Cancel — the\n * \"Delete\" of an edit dialog. Pass `{ label, onClick }` for the kit's quiet danger\n * button, or an element of your own (a {@link DangerConfirm}, say). With it the row\n * is `between`-aligned unless `align` says otherwise.\n */\n destructive?: ReactNode | FormActionsDestructive;\n /**\n * Something neutral at the START of the row — a caption (\"Last saved 2 min ago\",\n * \"Drag the corners to crop\"), a \"View activity\" link. After {@link destructive}\n * when both are given; like it, makes the row `between`-aligned unless `align` says\n * otherwise. `children` are for further ACTIONS, beside Cancel.\n */\n start?: ReactNode;\n /** Horizontal alignment. Default `end` (`between` with a `destructive` action). */\n align?: FormActionsAlign;\n /** See {@link FormActionsPlacement}, or one per breakpoint — see\n * {@link ResponsiveFormActionsPlacement}. Default `inline`. */\n placement?: FormActionsPlacement | ResponsiveFormActionsPlacement;\n /**\n * While the row is `sticky`: pull it out over its container's inline padding by this\n * much (a CSS length, a number in px) and give the same back as padding — so the\n * rule above it and its surface run edge to edge, while the buttons stay on the\n * content's line. Without it a sticky row in a padded card or pane is a strip inset\n * from both sides, with the content scrolling past it in the gutters (keksdose wrote\n * `@max-md:-mx-3 @max-md:px-3` by hand). Pass the container's padding (`\"0.75rem\"`,\n * `12`, `\"var(--pane-px)\"`). Applied only while the resolved placement is sticky, so\n * `placement={{ base: \"sticky\", md: \"inline\" }}` bleeds on the phone alone. The margin\n * is `calc(-1 * X)`; jsdom folds that to `calc(-X)`, so match it loosely in tests.\n */\n bleed?: string | number;\n /**\n * With `placement=\"sticky\"`: what the row sticks to. Default `viewport`.\n *\n * CSS sticky always sticks to the NEAREST scroll container, so the default row\n * already holds inside a scrolling pane — but it offsets itself by `--app-nav-h`,\n * which AppShell publishes on the document: inside a pane or a portalled dialog\n * body on a phone it then floats a nav's height above that container's bottom,\n * with the content scrolling through the gap. `container` drops that offset.\n *\n * What neither value can fix, because it is the layout, not the row:\n * - sticky moves only within its PARENT box. Inside a DataTable expansion row the\n * parent is the expansion cell, so the row pins to the table's own scroller\n * (`overflow-auto`, bounded by `maxBodyHeight`) and only while the cell spans\n * that scroller's bottom edge — not to the page.\n * - any `overflow` other than `visible`/`clip` between the row and the scroller\n * you mean (an `overflow-x-auto` wrapper, an `overflow-hidden` card) becomes the\n * scroll container; if THAT box does not scroll, the row never sticks.\n * - the offset is resolved against the scroller's content box, so a scroller with\n * bottom padding leaves that strip uncovered; give the row a negative `bottom`\n * through `style` and the same amount back as padding.\n */\n stickyWithin?: FormActionsStickyWithin;\n /** The `id` of the `<form>` the save button submits, when the row is rendered\n * outside it (a dialog's footer slot). */\n form?: string;\n /**\n * A keyboard shortcut that presses Save from anywhere in the form. `\"mod-enter\"`:\n * Ctrl+Enter, or Cmd+Enter on a Mac — both are accepted everywhere, as keksdose's\n * editors always have. keksdose K14 (inputs audit 2026-10-02, S5): its transaction\n * editor, category editor and create card each wrote this as an `onKeyDown` on a\n * container, re-stating `!pending && !saveDisabled && !lock.locked` every time, and\n * the one that forgot the lock let the demo's refused save through from the keyboard.\n *\n * What it does is what pressing Save does — the same button is clicked, so a form\n * submits with its validation and its submitter, and `onSubmit` is called in the\n * `type=\"button\"` mode — and it does NOTHING while Save could not be pressed:\n * `pending`, `submitDisabled`, or a `commit` under a locked {@link WriteLockProvider}.\n *\n * Scoped to the form Save submits (the `form` prop's, else the enclosing `<form>`):\n * the chord counts only when the focus is inside it, so two editors on one page each\n * answer for their own fields. Without a form (an `onSubmit` row in a plain container)\n * the scope is the nearest `role=\"dialog\"`, else the row's parent element — the\n * container the row ends.\n *\n * Listened for on the document, so it runs AFTER every React handler: a field that\n * takes Ctrl+Enter for itself calls `preventDefault()` or `stopPropagation()` and\n * the row stays out of it (keksdose live #202 — the create card's Ctrl+Shift+Enter\n * must create ONE row; the chord here is exactly Ctrl/Cmd+Enter, never with Shift or\n * Alt). Not during IME composition, and not on a held key's repeats.\n *\n * The save button announces it with `aria-keyshortcuts`; {@link submitShortcutHint}\n * also shows it.\n */\n submitShortcut?: FormActionsSubmitShortcut;\n /** Show the {@link submitShortcut} beside Save's text (\"Ctrl+Enter\", \"⌘ Enter\"; the\n * `form.submitShortcut` label), dimmed. Hidden on a touch-only device, which has no\n * keyboard to press it on. Default `false`. */\n submitShortcutHint?: boolean;\n /** Extra actions, placed before Cancel. */\n children?: ReactNode;\n}\n\n/** Hand `el` to a caller's ref, whichever kind it is. */\nfunction assignRef<E>(ref: Ref<E> | undefined, el: E | null): void {\n if (typeof ref === \"function\") ref(el);\n else if (ref) (ref as { current: E | null }).current = el;\n}\n\n/**\n * Where {@link FormActionsProps.submitShortcut} listens: the form Save submits (named\n * by `formId`, else the enclosing one), else the nearest dialog, else the row's parent.\n */\nfunction shortcutScope(row: HTMLElement | null, formId: string | undefined): Element | null {\n if (!row) return null;\n const named = formId ? row.ownerDocument.getElementById(formId) : null;\n if (named instanceof HTMLFormElement) return named;\n return row.closest(\"form\") ?? row.closest('[role=\"dialog\"]') ?? row.parentElement;\n}\n\nfunction isDestructiveData(value: unknown): value is FormActionsDestructive {\n return typeof value === \"object\" && value !== null && !isValidElement(value) && \"onClick\" in value;\n}\n\n/**\n * The Save / Cancel row every form, wizard-step form and dialog ends in — kastlan's\n * `FormActions`, with the labels from the provider, a pending state and a place for\n * the destructive action.\n *\n * ```tsx\n * <form onSubmit={form.handleSubmit(save)}>\n * …\n * <FormActions onCancel={close} pending={mutation.isPending} />\n * </form>\n * ```\n *\n * The spinner is the kit's {@link Spinner}, drawn inside the button with `label={null}`\n * so the button's name stays its text; `aria-busy` is what says it is working.\n */\nexport function FormActions({\n onCancel,\n onSubmit,\n submitLabel,\n cancelLabel,\n pendingLabel,\n submitIcon: SubmitIcon,\n submitProps,\n pending = false,\n submitDisabled = false,\n submitDisabledReason,\n commit = false,\n submitVariant = \"brand\",\n cancelVariant = \"secondary\",\n size = \"md\",\n destructive,\n start: startSlot,\n align,\n placement: placementProp = \"inline\",\n stickyWithin = \"viewport\",\n bleed,\n form,\n submitShortcut,\n submitShortcutHint = false,\n children,\n className,\n style,\n ...rest\n}: FormActionsProps) {\n const labels = useKitLabels(\"form\", DEFAULT_FORM_ACTIONS_LABELS);\n const lock = useWriteLock();\n const rootRef = useRef<HTMLDivElement>(null);\n const saveRef = useRef<HTMLButtonElement | null>(null);\n // The caller's own `submitProps.ref` still gets the element.\n const callerSaveRef = submitProps?.ref;\n const setSaveRef = useCallback(\n (el: HTMLButtonElement | null) => {\n saveRef.current = el;\n assignRef(callerSaveRef, el);\n },\n [callerSaveRef],\n );\n // Read by the shortcut's listener at the moment of the key press, so the listener\n // itself is attached once and never sees a stale `pending`.\n const canSave = !pending && !submitDisabled && !(commit && lock.locked);\n const canSaveRef = useRef(canSave);\n useEffect(() => {\n canSaveRef.current = canSave;\n });\n useEffect(() => {\n if (submitShortcut !== \"mod-enter\") return;\n const onKeyDown = (e: KeyboardEvent) => {\n if (e.key !== \"Enter\" || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;\n if (e.repeat || e.isComposing || e.defaultPrevented || !canSaveRef.current) return;\n const scope = shortcutScope(rootRef.current, form);\n if (!scope || !(e.target instanceof Node) || !scope.contains(e.target)) return;\n e.preventDefault();\n saveRef.current?.click();\n };\n document.addEventListener(\"keydown\", onKeyDown);\n return () => document.removeEventListener(\"keydown\", onKeyDown);\n }, [submitShortcut, form]);\n const apple = submitShortcut ? isApplePlatform() : false;\n const placement = useResolvedPlacement(placementProp);\n const bleedLength = bleed === undefined || bleed === \"\" ? undefined : cssLength(bleed);\n const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;\n const hasStart = startSlot !== undefined && startSlot !== null && startSlot !== false;\n const justify = align ?? (hasDestructive || hasStart ? \"between\" : \"end\");\n const destructiveNode = !hasDestructive ? null : isDestructiveData(destructive) ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n tone=\"danger\"\n size={size}\n onClick={destructive.onClick}\n disabled={destructive.disabled}\n disabledReason={destructive.disabledReason}\n commit={commit}\n >\n {destructive.label}\n </Button>\n ) : (\n destructive\n );\n const saveLabel = submitLabel ?? labels.save;\n // The glyphs follow the text: 16px beside `md`'s 14px text, 14px beside `sm`'s 12px.\n const saveSize = submitProps?.size ?? size;\n const glyphClass = saveSize === \"sm\" ? \"size-3.5\" : \"size-4\";\n\n return (\n <div\n ref={rootRef}\n data-slot=\"form-actions\"\n data-placement={placement}\n data-sticky-within={placement === \"sticky\" ? stickyWithin : undefined}\n {...rest}\n style={\n placement === \"sticky\"\n ? {\n paddingBottom: \"max(0.75rem, env(safe-area-inset-bottom))\",\n // On the phone's bottom nav, not behind it (keksdose live #361): `bottom-0`\n // pinned the row under AppShell's nav, which covers the page's last ~56px.\n // `--app-nav-h` is the nav's measured height (0px from md up, and outside an\n // AppShell); the 1px overlaps the two top borders into one line. In a\n // container the nav is not underneath: `--app-nav-h` is the document's,\n // and would hold the row that far above the container's own edge.\n bottom: stickyWithin === \"container\" ? 0 : \"max(0px, calc(var(--app-nav-h, 0px) - 1px))\",\n ...(bleedLength !== undefined && {\n marginInline: `calc(-1 * ${bleedLength})`,\n paddingInline: bleedLength,\n }),\n ...style,\n }\n : style\n }\n className={cn(\"flex flex-wrap items-center gap-2\", ALIGN_CLASS[justify], PLACEMENT_CLASS[placement], className)}\n >\n {(hasDestructive || hasStart) && (\n <div className=\"me-auto flex min-w-0 flex-wrap items-center gap-2\">\n {destructiveNode}\n {hasStart && startSlot}\n </div>\n )}\n <div className=\"flex flex-wrap items-center gap-2\">\n {children}\n {onCancel && (\n <Button type=\"button\" variant={cancelVariant} size={size} onClick={onCancel}>\n {cancelLabel ?? labels.cancel}\n </Button>\n )}\n <Button\n {...submitProps}\n ref={setSaveRef}\n aria-keyshortcuts={\n submitShortcut === \"mod-enter\" ? \"Control+Enter Meta+Enter\" : submitProps?.[\"aria-keyshortcuts\"]\n }\n type={onSubmit ? \"button\" : \"submit\"}\n form={form}\n onClick={onSubmit}\n variant={submitVariant}\n size={saveSize}\n disabled={pending || submitDisabled}\n disabledReason={pending ? undefined : submitDisabledReason}\n // Not while pending: the save already left, and the lock's look over the\n // spinner would say it had not.\n commit={commit && !pending}\n aria-busy={pending || undefined}\n >\n {pending ? (\n <Spinner label={null} className={glyphClass} />\n ) : (\n SubmitIcon && <SubmitIcon aria-hidden className={cn(glyphClass, \"shrink-0\")} />\n )}\n {pending && pendingLabel !== undefined ? pendingLabel : saveLabel}\n {submitShortcut && submitShortcutHint && (\n // Hidden from the accessibility tree: `aria-keyshortcuts` already says it,\n // and in the name it would make the button \"Save Ctrl+Enter\".\n <kbd\n aria-hidden\n className=\"ms-1 font-sans text-[0.85em] font-normal whitespace-nowrap opacity-70 pointer-coarse:hidden\"\n >\n {labels.submitShortcut(apple)}\n </kbd>\n )}\n </Button>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import 'react';
|
|
2
2
|
import '../lib/iban.js';
|
|
3
|
-
export {
|
|
3
|
+
export { a8 as DEFAULT_IBAN_INPUT_LABELS, b3 as IbanInput, b4 as IbanInputLabels, b5 as IbanInputProps } from '../feedback-BxeQVzwq.js';
|
|
4
|
+
import '../feedback-attachment-fGAzZPf0.js';
|
|
5
|
+
import '../feedback/feedback-inbox.js';
|
|
6
|
+
import 'lucide-react';
|
|
7
|
+
import './tooltip.js';
|
|
8
|
+
import '../hooks/use-anchored-rect.js';
|
|
9
|
+
import '../lib/clipping.js';
|
|
4
10
|
import '../data-table-labels-B7OdnM0S.js';
|
|
5
11
|
import './data-table-sort.js';
|
|
6
12
|
import './swipeable-row.js';
|
|
@@ -8,11 +14,7 @@ import './mini-calendar.js';
|
|
|
8
14
|
import './calendar-heatmap.js';
|
|
9
15
|
import './popover.js';
|
|
10
16
|
import './chip.js';
|
|
11
|
-
import 'lucide-react';
|
|
12
17
|
import './field-sync.js';
|
|
13
|
-
import './tooltip.js';
|
|
14
|
-
import '../hooks/use-anchored-rect.js';
|
|
15
|
-
import '../lib/clipping.js';
|
|
16
18
|
import '../wizard/types.js';
|
|
17
19
|
import '../tour/tour.js';
|
|
18
20
|
import '../search/command-palette.js';
|
|
@@ -33,9 +35,6 @@ import './dialog-frame.js';
|
|
|
33
35
|
import './modal.js';
|
|
34
36
|
import '../hooks/use-search-param-state.js';
|
|
35
37
|
import './measured-grid.js';
|
|
36
|
-
import '../feedback-attachment-NhUm7Zga.js';
|
|
37
|
-
import '../feedback/feedback-thread.js';
|
|
38
|
-
import '../feedback/feedback-inbox.js';
|
|
39
38
|
import './account-settings-labels.js';
|
|
40
39
|
import './floating-panel.js';
|
|
41
40
|
import './bulk-action-bar.js';
|
|
@@ -64,3 +63,7 @@ import './country-select.js';
|
|
|
64
63
|
import './inline-edit-field.js';
|
|
65
64
|
import '../lib/phone.js';
|
|
66
65
|
import './sign-chip.js';
|
|
66
|
+
import './column-mapper.js';
|
|
67
|
+
import '../lib/column-mapping.js';
|
|
68
|
+
import '../lib/table-text.js';
|
|
69
|
+
import './field-parts.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import { ComponentType,
|
|
2
|
+
import { ComponentType, ReactNode, ComponentPropsWithoutRef } from 'react';
|
|
3
3
|
import { TileSize } from './tile-radio.js';
|
|
4
4
|
|
|
5
5
|
interface IconPickerLabels {
|
|
@@ -31,7 +31,16 @@ interface IconOption<T extends string> {
|
|
|
31
31
|
/** A remark read after the name and shown under it in the bubble ("used by
|
|
32
32
|
* Leisure"). The tile wears a dot while it has one. */
|
|
33
33
|
note?: string;
|
|
34
|
+
/** Out of the choice and out of reach — skipped by the arrow keys and Tab. With a
|
|
35
|
+
* `disabledReason` the tile stays reachable instead and says why. */
|
|
34
36
|
disabled?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Why THIS symbol cannot be chosen right now — {@link SwatchOption.disabledReason}'s
|
|
39
|
+
* twin: the tile is `aria-disabled` but reachable by Tab and the arrow keys, the reason
|
|
40
|
+
* shows in its bubble under its name and describes it, and choosing it does nothing.
|
|
41
|
+
* Wins over `disabled`. Kept through a search like any other tile.
|
|
42
|
+
*/
|
|
43
|
+
disabledReason?: ReactNode;
|
|
35
44
|
}
|
|
36
45
|
interface IconPickerProps<T extends string> extends Omit<ComponentPropsWithoutRef<"div">, "onChange" | "children" | "defaultValue"> {
|
|
37
46
|
options: IconOption<T>[];
|
|
@@ -5,7 +5,7 @@ import { cn } from "../lib/cn.js";
|
|
|
5
5
|
import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels.js";
|
|
6
6
|
import { SearchField } from "./search-field.js";
|
|
7
7
|
import { TILE_SIZE, TileRadioGroup } from "./tile-radio.js";
|
|
8
|
-
import { hasContent, LabelStrip } from "./field-
|
|
8
|
+
import { hasContent, LabelStrip } from "./field-parts.js";
|
|
9
9
|
const DEFAULT_ICON_PICKER_LABELS = {
|
|
10
10
|
none: "No icon",
|
|
11
11
|
mixed: "Mixed: the selected items have different icons",
|
|
@@ -55,7 +55,13 @@ function IconPicker({
|
|
|
55
55
|
// "None" stays through a search: it is the way back to no icon, and it matches
|
|
56
56
|
// no word anybody would type for it.
|
|
57
57
|
...allowNone ? [{ key: null, label: text.none }] : [],
|
|
58
|
-
...visible.map((o) => ({
|
|
58
|
+
...visible.map((o) => ({
|
|
59
|
+
key: o.value,
|
|
60
|
+
label: o.label,
|
|
61
|
+
note: o.note,
|
|
62
|
+
disabled: o.disabled,
|
|
63
|
+
disabledReason: o.disabledReason
|
|
64
|
+
}))
|
|
59
65
|
];
|
|
60
66
|
const picker = /* @__PURE__ */ jsxs("div", { ...rest, className: cn("space-y-2", !labelled && className), children: [
|
|
61
67
|
searchable && /* @__PURE__ */ jsx(
|
|
@@ -82,7 +88,9 @@ function IconPicker({
|
|
|
82
88
|
{
|
|
83
89
|
items,
|
|
84
90
|
checked: mixed ? void 0 : value,
|
|
85
|
-
onSelect:
|
|
91
|
+
onSelect: (next) => {
|
|
92
|
+
if (!hasContent(next === null ? void 0 : byValue.get(next)?.disabledReason)) onChange(next);
|
|
93
|
+
},
|
|
86
94
|
activation,
|
|
87
95
|
disabled,
|
|
88
96
|
size,
|
|
@@ -106,7 +114,7 @@ function IconPicker({
|
|
|
106
114
|
/* @__PURE__ */ jsx("span", { role: "status", "aria-live": "polite", "aria-atomic": true, className: "sr-only-fixed", children: needle ? text.resultCount(visible.length) : "" })
|
|
107
115
|
] });
|
|
108
116
|
if (!labelled) return picker;
|
|
109
|
-
return /* @__PURE__ */ jsx(LabelStrip, { labelId, label, hint, disabled, pad: "
|
|
117
|
+
return /* @__PURE__ */ jsx(LabelStrip, { labelId, label, hint, disabled, pad: "clear", className, children: picker });
|
|
110
118
|
}
|
|
111
119
|
export {
|
|
112
120
|
DEFAULT_ICON_PICKER_LABELS,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/icon-picker.tsx"],"sourcesContent":["import { useId, useMemo, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ComponentType, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport { SearchField } from \"./search-field\";\nimport { TILE_SIZE, TileRadioGroup } from \"./tile-radio\";\nimport type { TileItem, TileSize } from \"./tile-radio\";\nimport { hasContent, LabelStrip } from \"./field-anatomy\";\n\nexport interface IconPickerLabels {\n /** The \"no icon\" tile's name, when `allowNone` is set. */\n none: string;\n /** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */\n mixed: string;\n /** The search box's accessible name and placeholder, when `searchable` is set. */\n search: string;\n /** Shown in place of the tiles when the search matches nothing. */\n noResults: string;\n /** Announced as the search narrows the grid. Receives the number of matches. */\n resultCount: (count: number) => string;\n}\n\nexport const DEFAULT_ICON_PICKER_LABELS: IconPickerLabels = {\n none: \"No icon\",\n mixed: \"Mixed: the selected items have different icons\",\n search: \"Search icons\",\n noResults: \"No icons match\",\n resultCount: (count) => (count === 1 ? \"1 icon\" : `${count} icons`),\n};\n\nexport interface IconOption<T extends string> {\n value: T;\n /** A Lucide icon, or any component that takes a `className`. Rendered `aria-hidden`\n * — the tile's name is `label`. */\n icon: ComponentType<{ className?: string; \"aria-hidden\"?: boolean }>;\n /** What the symbol IS, in the user's language (\"Game controller\") — not the icon\n * library's identifier (`Gamepad2`), which is nobody's word for anything. */\n label: string;\n /** More words the search matches (\"games\", \"play\"), besides `label`. */\n keywords?: string[];\n /** A remark read after the name and shown under it in the bubble (\"used by\n * Leisure\"). The tile wears a dot while it has one. */\n note?: string;\n disabled?: boolean;\n}\n\nexport interface IconPickerProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\" | \"defaultValue\"> {\n options: IconOption<T>[];\n /** The chosen icon, or `null` for none. */\n value: T | null;\n onChange: (value: T | null) => void;\n /** Lead with a \"no icon\" tile whose value is `null`. */\n allowNone?: boolean;\n /** See {@link SwatchPickerProps.mixed}: nothing is checked, and the group is\n * described as mixed. */\n mixed?: boolean;\n /**\n * \"automatic\" (default): an arrow key moves AND chooses, the APG radio group.\n * \"manual\": arrows move focus only; Space or Enter chooses. For a picker whose every\n * change is expensive — a bulk edit that saves as it goes.\n */\n activation?: \"automatic\" | \"manual\";\n /** A search box above the grid, matching `label` and `keywords`. For a set large\n * enough that scanning it is slower than typing. */\n searchable?: boolean;\n /** 28 / 32 / 44px tiles. Default \"md\"; \"lg\" is the touch-target size. */\n size?: TileSize;\n /**\n * Classes for every tile's frame, merged after the kit's own — so a caller's\n * `size-*` beats `size`'s. For a tile that has to follow a container query or a\n * breakpoint (keksdose grows its tiles to 40px in a narrow card:\n * `tileClassName=\"@max-md:size-10\"`) without reaching into the kit's markup with an\n * `[&_[role=radio]]` selector. The swatch dot / glyph inside keeps `size`'s\n * dimensions; the tile grows round it.\n */\n tileClassName?: string;\n disabled?: boolean;\n labels?: Partial<IconPickerLabels>;\n /**\n * An 11px static label in a strip over the picker, naming the grid — keksdose K2,\n * {@link SwatchPickerProps.label}'s twin: a symbol picker standing in a form row\n * beside labelled fields, with its label on their line. With `searchable` the strip\n * is over the search box too, which keeps its own name. With a label, `className`\n * styles the wrapper.\n */\n label?: ReactNode;\n /** A {@link FieldHint} beside the label. Only with `label`. */\n hint?: ReactNode;\n}\n\n/** Case- and accent-insensitive, so \"cafe\" finds \"Café\". */\nfunction fold(text: string, locale: string | undefined): string {\n return text.normalize(\"NFD\").replace(/\\p{M}/gu, \"\").toLocaleLowerCase(locale);\n}\n\n/**\n * A grid of symbols that is one radio group, with an optional search.\n *\n * Name the group with `aria-label` or `aria-labelledby`. The name lands on the\n * radiogroup itself, not on the outer box: with `searchable` the outer box also holds\n * the search field, which has a name of its own.\n *\n * Keyboard is {@link SwatchPicker}'s: one tab stop, arrows to move (flipped in a\n * right-to-left page), Home and End.\n */\nexport function IconPicker<T extends string>({\n options,\n value,\n onChange,\n allowNone = false,\n mixed = false,\n activation = \"automatic\",\n searchable = false,\n size = \"md\",\n tileClassName,\n disabled = false,\n labels,\n label,\n hint,\n className,\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledBy,\n \"aria-describedby\": ariaDescribedBy,\n ...rest\n}: IconPickerProps<T>) {\n const text = useKitLabels(\"iconPicker\", DEFAULT_ICON_PICKER_LABELS, labels);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const locale = useKitLocale();\n const mixedId = useId();\n const labelId = useId();\n const labelled = hasContent(label);\n const [query, setQuery] = useState(\"\");\n const needle = fold(query.trim(), locale);\n\n const visible = useMemo(\n () =>\n needle\n ? options.filter((o) =>\n [o.label, ...(o.keywords ?? [])].some((w) => fold(w, locale).includes(needle)),\n )\n : options,\n [options, needle, locale],\n );\n const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);\n const items: TileItem<T>[] = [\n // \"None\" stays through a search: it is the way back to no icon, and it matches\n // no word anybody would type for it.\n ...(allowNone ? [{ key: null, label: text.none }] : []),\n ...visible.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),\n ];\n\n const picker = (\n <div {...rest} className={cn(\"space-y-2\", !labelled && className)}>\n {searchable && (\n <SearchField\n value={query}\n onChange={setQuery}\n aria-label={text.search}\n clearLabel={common.clear}\n disabled={disabled}\n />\n )}\n <div\n role=\"radiogroup\"\n aria-label={ariaLabel}\n // The strip's label names the GRID, unless the caller named it already.\n aria-labelledby={labelled && ariaLabel === undefined && ariaLabelledBy === undefined ? labelId : ariaLabelledBy}\n aria-describedby={[ariaDescribedBy, mixed && mixedId].filter(Boolean).join(\" \") || undefined}\n aria-disabled={disabled || undefined}\n className=\"flex flex-wrap items-center gap-1.5\"\n >\n <TileRadioGroup\n items={items}\n checked={mixed ? undefined : value}\n onSelect={onChange}\n activation={activation}\n disabled={disabled}\n size={size}\n renderTile={(item) => {\n const Icon = item.key === null ? undefined : byValue.get(item.key)?.icon;\n return Icon ? <Icon aria-hidden className={TILE_SIZE[size].glyph} /> : null;\n }}\n tileClassName={(_item, selected) =>\n cn(\n selected ? \"text-[var(--text-primary)]\" : \"text-[var(--text-secondary)]\",\n tileClassName,\n )\n }\n />\n </div>\n {needle && visible.length === 0 && (\n <p className=\"text-xs text-[var(--text-muted)]\">{text.noResults}</p>\n )}\n {mixed && (\n <span id={mixedId} hidden>\n {text.mixed}\n </span>\n )}\n {searchable && (\n // Typing moves no focus and the grid changes silently, so the count is said.\n // `sr-only-fixed`: this sits inside a consumer's markup and must not extend\n // the document height (see tokens.css).\n <span role=\"status\" aria-live=\"polite\" aria-atomic className=\"sr-only-fixed\">\n {needle ? text.resultCount(visible.length) : \"\"}\n </span>\n )}\n </div>\n );\n if (!labelled) return picker;\n return (\n <LabelStrip labelId={labelId} label={label} hint={hint} disabled={disabled} pad=\"tiles\" className={className}>\n {picker}\n </LabelStrip>\n );\n}\n"],"mappings":";AAyJI,SAEI,KAFJ;AAzJJ,SAAS,OAAO,SAAS,gBAAgB;AAEzC,SAAS,UAAU;AACnB,SAAS,uBAAuB,cAAc,oBAAoB;AAClE,SAAS,mBAAmB;AAC5B,SAAS,WAAW,sBAAsB;AAE1C,SAAS,YAAY,kBAAkB;AAehC,MAAM,6BAA+C;AAAA,EAC1D,MAAM;AAAA,EACN,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,aAAa,CAAC,UAAW,UAAU,IAAI,WAAW,GAAG,KAAK;AAC5D;AAgEA,SAAS,KAAK,MAAc,QAAoC;AAC9D,SAAO,KAAK,UAAU,KAAK,EAAE,QAAQ,WAAW,EAAE,EAAE,kBAAkB,MAAM;AAC9E;AAYO,SAAS,WAA6B;AAAA,EAC3C;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,aAAa;AAAA,EACb,OAAO;AAAA,EACP;AAAA,EACA,WAAW;AAAA,EACX;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,mBAAmB;AAAA,EACnB,oBAAoB;AAAA,EACpB,GAAG;AACL,GAAuB;AACrB,QAAM,OAAO,aAAa,cAAc,4BAA4B,MAAM;AAC1E,QAAM,SAAS,aAAa,UAAU,qBAAqB;AAC3D,QAAM,SAAS,aAAa;AAC5B,QAAM,UAAU,MAAM;AACtB,QAAM,UAAU,MAAM;AACtB,QAAM,WAAW,WAAW,KAAK;AACjC,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAS,EAAE;AACrC,QAAM,SAAS,KAAK,MAAM,KAAK,GAAG,MAAM;AAExC,QAAM,UAAU;AAAA,IACd,MACE,SACI,QAAQ;AAAA,MAAO,CAAC,MACd,CAAC,EAAE,OAAO,GAAI,EAAE,YAAY,CAAC,CAAE,EAAE,KAAK,CAAC,MAAM,KAAK,GAAG,MAAM,EAAE,SAAS,MAAM,CAAC;AAAA,IAC/E,IACA;AAAA,IACN,CAAC,SAAS,QAAQ,MAAM;AAAA,EAC1B;AACA,QAAM,UAAU,QAAQ,MAAM,IAAI,IAAI,QAAQ,IAAI,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC;AAClF,QAAM,QAAuB;AAAA;AAAA;AAAA,IAG3B,GAAI,YAAY,CAAC,EAAE,KAAK,MAAM,OAAO,KAAK,KAAK,CAAC,IAAI,CAAC;AAAA,IACrD,GAAG,QAAQ,IAAI,CAAC,OAAO,EAAE,KAAK,EAAE,OAAO,OAAO,EAAE,OAAO,MAAM,EAAE,MAAM,UAAU,EAAE,SAAS,EAAE;AAAA,EAC9F;AAEA,QAAM,SACJ,qBAAC,SAAK,GAAG,MAAM,WAAW,GAAG,aAAa,CAAC,YAAY,SAAS,GAC7D;AAAA,kBACC;AAAA,MAAC;AAAA;AAAA,QACC,OAAO;AAAA,QACP,UAAU;AAAA,QACV,cAAY,KAAK;AAAA,QACjB,YAAY,OAAO;AAAA,QACnB;AAAA;AAAA,IACF;AAAA,IAEF;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,cAAY;AAAA,QAEZ,mBAAiB,YAAY,cAAc,UAAa,mBAAmB,SAAY,UAAU;AAAA,QACjG,oBAAkB,CAAC,iBAAiB,SAAS,OAAO,EAAE,OAAO,OAAO,EAAE,KAAK,GAAG,KAAK;AAAA,QACnF,iBAAe,YAAY;AAAA,QAC3B,WAAU;AAAA,QAEV;AAAA,UAAC;AAAA;AAAA,YACC;AAAA,YACA,SAAS,QAAQ,SAAY;AAAA,YAC7B,UAAU;AAAA,YACV;AAAA,YACA;AAAA,YACA;AAAA,YACA,YAAY,CAAC,SAAS;AACpB,oBAAM,OAAO,KAAK,QAAQ,OAAO,SAAY,QAAQ,IAAI,KAAK,GAAG,GAAG;AACpE,qBAAO,OAAO,oBAAC,QAAK,eAAW,MAAC,WAAW,UAAU,IAAI,EAAE,OAAO,IAAK;AAAA,YACzE;AAAA,YACA,eAAe,CAAC,OAAO,aACrB;AAAA,cACE,WAAW,+BAA+B;AAAA,cAC1C;AAAA,YACF;AAAA;AAAA,QAEJ;AAAA;AAAA,IACF;AAAA,IACC,UAAU,QAAQ,WAAW,KAC5B,oBAAC,OAAE,WAAU,oCAAoC,eAAK,WAAU;AAAA,IAEjE,SACC,oBAAC,UAAK,IAAI,SAAS,QAAM,MACtB,eAAK,OACR;AAAA,IAED;AAAA;AAAA;AAAA,IAIC,oBAAC,UAAK,MAAK,UAAS,aAAU,UAAS,eAAW,MAAC,WAAU,iBAC1D,mBAAS,KAAK,YAAY,QAAQ,MAAM,IAAI,IAC/C;AAAA,KAEJ;AAEF,MAAI,CAAC,SAAU,QAAO;AACtB,SACE,oBAAC,cAAW,SAAkB,OAAc,MAAY,UAAoB,KAAI,SAAQ,WACrF,kBACH;AAEJ;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/icon-picker.tsx"],"sourcesContent":["import { useId, useMemo, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ComponentType, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport { SearchField } from \"./search-field\";\nimport { TILE_SIZE, TileRadioGroup } from \"./tile-radio\";\nimport type { TileItem, TileSize } from \"./tile-radio\";\nimport { hasContent, LabelStrip } from \"./field-parts\";\n\nexport interface IconPickerLabels {\n /** The \"no icon\" tile's name, when `allowNone` is set. */\n none: string;\n /** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */\n mixed: string;\n /** The search box's accessible name and placeholder, when `searchable` is set. */\n search: string;\n /** Shown in place of the tiles when the search matches nothing. */\n noResults: string;\n /** Announced as the search narrows the grid. Receives the number of matches. */\n resultCount: (count: number) => string;\n}\n\nexport const DEFAULT_ICON_PICKER_LABELS: IconPickerLabels = {\n none: \"No icon\",\n mixed: \"Mixed: the selected items have different icons\",\n search: \"Search icons\",\n noResults: \"No icons match\",\n resultCount: (count) => (count === 1 ? \"1 icon\" : `${count} icons`),\n};\n\nexport interface IconOption<T extends string> {\n value: T;\n /** A Lucide icon, or any component that takes a `className`. Rendered `aria-hidden`\n * — the tile's name is `label`. */\n icon: ComponentType<{ className?: string; \"aria-hidden\"?: boolean }>;\n /** What the symbol IS, in the user's language (\"Game controller\") — not the icon\n * library's identifier (`Gamepad2`), which is nobody's word for anything. */\n label: string;\n /** More words the search matches (\"games\", \"play\"), besides `label`. */\n keywords?: string[];\n /** A remark read after the name and shown under it in the bubble (\"used by\n * Leisure\"). The tile wears a dot while it has one. */\n note?: string;\n /** Out of the choice and out of reach — skipped by the arrow keys and Tab. With a\n * `disabledReason` the tile stays reachable instead and says why. */\n disabled?: boolean;\n /**\n * Why THIS symbol cannot be chosen right now — {@link SwatchOption.disabledReason}'s\n * twin: the tile is `aria-disabled` but reachable by Tab and the arrow keys, the reason\n * shows in its bubble under its name and describes it, and choosing it does nothing.\n * Wins over `disabled`. Kept through a search like any other tile.\n */\n disabledReason?: ReactNode;\n}\n\nexport interface IconPickerProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\" | \"defaultValue\"> {\n options: IconOption<T>[];\n /** The chosen icon, or `null` for none. */\n value: T | null;\n onChange: (value: T | null) => void;\n /** Lead with a \"no icon\" tile whose value is `null`. */\n allowNone?: boolean;\n /** See {@link SwatchPickerProps.mixed}: nothing is checked, and the group is\n * described as mixed. */\n mixed?: boolean;\n /**\n * \"automatic\" (default): an arrow key moves AND chooses, the APG radio group.\n * \"manual\": arrows move focus only; Space or Enter chooses. For a picker whose every\n * change is expensive — a bulk edit that saves as it goes.\n */\n activation?: \"automatic\" | \"manual\";\n /** A search box above the grid, matching `label` and `keywords`. For a set large\n * enough that scanning it is slower than typing. */\n searchable?: boolean;\n /** 28 / 32 / 44px tiles. Default \"md\"; \"lg\" is the touch-target size. */\n size?: TileSize;\n /**\n * Classes for every tile's frame, merged after the kit's own — so a caller's\n * `size-*` beats `size`'s. For a tile that has to follow a container query or a\n * breakpoint (keksdose grows its tiles to 40px in a narrow card:\n * `tileClassName=\"@max-md:size-10\"`) without reaching into the kit's markup with an\n * `[&_[role=radio]]` selector. The swatch dot / glyph inside keeps `size`'s\n * dimensions; the tile grows round it.\n */\n tileClassName?: string;\n disabled?: boolean;\n labels?: Partial<IconPickerLabels>;\n /**\n * An 11px static label in a strip over the picker, naming the grid — keksdose K2,\n * {@link SwatchPickerProps.label}'s twin: a symbol picker standing in a form row\n * beside labelled fields, with its label on their line. With `searchable` the strip\n * is over the search box too, which keeps its own name. With a label, `className`\n * styles the wrapper.\n */\n label?: ReactNode;\n /** A {@link FieldHint} beside the label. Only with `label`. */\n hint?: ReactNode;\n}\n\n/** Case- and accent-insensitive, so \"cafe\" finds \"Café\". */\nfunction fold(text: string, locale: string | undefined): string {\n return text.normalize(\"NFD\").replace(/\\p{M}/gu, \"\").toLocaleLowerCase(locale);\n}\n\n/**\n * A grid of symbols that is one radio group, with an optional search.\n *\n * Name the group with `aria-label` or `aria-labelledby`. The name lands on the\n * radiogroup itself, not on the outer box: with `searchable` the outer box also holds\n * the search field, which has a name of its own.\n *\n * Keyboard is {@link SwatchPicker}'s: one tab stop, arrows to move (flipped in a\n * right-to-left page), Home and End.\n */\nexport function IconPicker<T extends string>({\n options,\n value,\n onChange,\n allowNone = false,\n mixed = false,\n activation = \"automatic\",\n searchable = false,\n size = \"md\",\n tileClassName,\n disabled = false,\n labels,\n label,\n hint,\n className,\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledBy,\n \"aria-describedby\": ariaDescribedBy,\n ...rest\n}: IconPickerProps<T>) {\n const text = useKitLabels(\"iconPicker\", DEFAULT_ICON_PICKER_LABELS, labels);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const locale = useKitLocale();\n const mixedId = useId();\n const labelId = useId();\n const labelled = hasContent(label);\n const [query, setQuery] = useState(\"\");\n const needle = fold(query.trim(), locale);\n\n const visible = useMemo(\n () =>\n needle\n ? options.filter((o) =>\n [o.label, ...(o.keywords ?? [])].some((w) => fold(w, locale).includes(needle)),\n )\n : options,\n [options, needle, locale],\n );\n const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);\n const items: TileItem<T>[] = [\n // \"None\" stays through a search: it is the way back to no icon, and it matches\n // no word anybody would type for it.\n ...(allowNone ? [{ key: null, label: text.none }] : []),\n ...visible.map((o) => ({\n key: o.value,\n label: o.label,\n note: o.note,\n disabled: o.disabled,\n disabledReason: o.disabledReason,\n })),\n ];\n\n const picker = (\n <div {...rest} className={cn(\"space-y-2\", !labelled && className)}>\n {searchable && (\n <SearchField\n value={query}\n onChange={setQuery}\n aria-label={text.search}\n clearLabel={common.clear}\n disabled={disabled}\n />\n )}\n <div\n role=\"radiogroup\"\n aria-label={ariaLabel}\n // The strip's label names the GRID, unless the caller named it already.\n aria-labelledby={labelled && ariaLabel === undefined && ariaLabelledBy === undefined ? labelId : ariaLabelledBy}\n aria-describedby={[ariaDescribedBy, mixed && mixedId].filter(Boolean).join(\" \") || undefined}\n aria-disabled={disabled || undefined}\n className=\"flex flex-wrap items-center gap-1.5\"\n >\n <TileRadioGroup\n items={items}\n checked={mixed ? undefined : value}\n // A locked tile is refused by the tiles themselves; asked again here so a\n // reason can never become a change, whatever the tiles do.\n onSelect={(next) => {\n if (!hasContent(next === null ? undefined : byValue.get(next)?.disabledReason)) onChange(next);\n }}\n activation={activation}\n disabled={disabled}\n size={size}\n renderTile={(item) => {\n const Icon = item.key === null ? undefined : byValue.get(item.key)?.icon;\n return Icon ? <Icon aria-hidden className={TILE_SIZE[size].glyph} /> : null;\n }}\n tileClassName={(_item, selected) =>\n cn(\n selected ? \"text-[var(--text-primary)]\" : \"text-[var(--text-secondary)]\",\n tileClassName,\n )\n }\n />\n </div>\n {needle && visible.length === 0 && (\n <p className=\"text-xs text-[var(--text-muted)]\">{text.noResults}</p>\n )}\n {mixed && (\n <span id={mixedId} hidden>\n {text.mixed}\n </span>\n )}\n {searchable && (\n // Typing moves no focus and the grid changes silently, so the count is said.\n // `sr-only-fixed`: this sits inside a consumer's markup and must not extend\n // the document height (see tokens.css).\n <span role=\"status\" aria-live=\"polite\" aria-atomic className=\"sr-only-fixed\">\n {needle ? text.resultCount(visible.length) : \"\"}\n </span>\n )}\n </div>\n );\n if (!labelled) return picker;\n return (\n <LabelStrip labelId={labelId} label={label} hint={hint} disabled={disabled} pad=\"clear\" className={className}>\n {picker}\n </LabelStrip>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { KitLanguageCode, KitLanguage } from '../i18n/languages.js';
|
|
3
|
-
import {
|
|
3
|
+
import { bw as SelectProps } from '../feedback-BxeQVzwq.js';
|
|
4
4
|
import '../shell/topbar-controls.js';
|
|
5
5
|
import '../theme/palette-presets.js';
|
|
6
6
|
import '../theme/chart-palette.js';
|
|
7
7
|
import '../theme/theme-store.js';
|
|
8
8
|
import 'zustand/middleware';
|
|
9
9
|
import 'zustand';
|
|
10
|
+
import '../feedback-attachment-fGAzZPf0.js';
|
|
11
|
+
import '../feedback/feedback-inbox.js';
|
|
12
|
+
import 'lucide-react';
|
|
13
|
+
import './tooltip.js';
|
|
14
|
+
import '../hooks/use-anchored-rect.js';
|
|
15
|
+
import '../lib/clipping.js';
|
|
10
16
|
import '../data-table-labels-B7OdnM0S.js';
|
|
11
17
|
import './data-table-sort.js';
|
|
12
18
|
import './swipeable-row.js';
|
|
@@ -14,11 +20,7 @@ import './mini-calendar.js';
|
|
|
14
20
|
import './calendar-heatmap.js';
|
|
15
21
|
import './popover.js';
|
|
16
22
|
import './chip.js';
|
|
17
|
-
import 'lucide-react';
|
|
18
23
|
import './field-sync.js';
|
|
19
|
-
import './tooltip.js';
|
|
20
|
-
import '../hooks/use-anchored-rect.js';
|
|
21
|
-
import '../lib/clipping.js';
|
|
22
24
|
import '../wizard/types.js';
|
|
23
25
|
import '../tour/tour.js';
|
|
24
26
|
import '../search/command-palette.js';
|
|
@@ -39,9 +41,6 @@ import './dialog-frame.js';
|
|
|
39
41
|
import './modal.js';
|
|
40
42
|
import '../hooks/use-search-param-state.js';
|
|
41
43
|
import './measured-grid.js';
|
|
42
|
-
import '../feedback-attachment-NhUm7Zga.js';
|
|
43
|
-
import '../feedback/feedback-thread.js';
|
|
44
|
-
import '../feedback/feedback-inbox.js';
|
|
45
44
|
import './account-settings-labels.js';
|
|
46
45
|
import './floating-panel.js';
|
|
47
46
|
import './bulk-action-bar.js';
|
|
@@ -71,6 +70,10 @@ import './inline-edit-field.js';
|
|
|
71
70
|
import '../lib/iban.js';
|
|
72
71
|
import '../lib/phone.js';
|
|
73
72
|
import './sign-chip.js';
|
|
73
|
+
import './column-mapper.js';
|
|
74
|
+
import '../lib/column-mapping.js';
|
|
75
|
+
import '../lib/table-text.js';
|
|
76
|
+
import './field-parts.js';
|
|
74
77
|
|
|
75
78
|
/**
|
|
76
79
|
* A language as a FORM FIELD: one of the codes an app offers, picked in a form beside
|
|
@@ -2,7 +2,13 @@ import * as react from 'react';
|
|
|
2
2
|
import { ComponentProps, Ref } from 'react';
|
|
3
3
|
import { AmountInput } from './amount-input.js';
|
|
4
4
|
import './calculator.js';
|
|
5
|
-
import '../
|
|
5
|
+
import '../feedback-BxeQVzwq.js';
|
|
6
|
+
import '../feedback-attachment-fGAzZPf0.js';
|
|
7
|
+
import '../feedback/feedback-inbox.js';
|
|
8
|
+
import 'lucide-react';
|
|
9
|
+
import './tooltip.js';
|
|
10
|
+
import '../hooks/use-anchored-rect.js';
|
|
11
|
+
import '../lib/clipping.js';
|
|
6
12
|
import '../data-table-labels-B7OdnM0S.js';
|
|
7
13
|
import './data-table-sort.js';
|
|
8
14
|
import './swipeable-row.js';
|
|
@@ -10,11 +16,7 @@ import './mini-calendar.js';
|
|
|
10
16
|
import './calendar-heatmap.js';
|
|
11
17
|
import './popover.js';
|
|
12
18
|
import './chip.js';
|
|
13
|
-
import 'lucide-react';
|
|
14
19
|
import './field-sync.js';
|
|
15
|
-
import './tooltip.js';
|
|
16
|
-
import '../hooks/use-anchored-rect.js';
|
|
17
|
-
import '../lib/clipping.js';
|
|
18
20
|
import '../wizard/types.js';
|
|
19
21
|
import '../tour/tour.js';
|
|
20
22
|
import '../search/command-palette.js';
|
|
@@ -35,9 +37,6 @@ import './dialog-frame.js';
|
|
|
35
37
|
import './modal.js';
|
|
36
38
|
import '../hooks/use-search-param-state.js';
|
|
37
39
|
import './measured-grid.js';
|
|
38
|
-
import '../feedback-attachment-NhUm7Zga.js';
|
|
39
|
-
import '../feedback/feedback-thread.js';
|
|
40
|
-
import '../feedback/feedback-inbox.js';
|
|
41
40
|
import './account-settings-labels.js';
|
|
42
41
|
import './floating-panel.js';
|
|
43
42
|
import './bulk-action-bar.js';
|
|
@@ -67,6 +66,10 @@ import './inline-edit-field.js';
|
|
|
67
66
|
import '../lib/iban.js';
|
|
68
67
|
import '../lib/phone.js';
|
|
69
68
|
import './sign-chip.js';
|
|
69
|
+
import './column-mapper.js';
|
|
70
|
+
import '../lib/column-mapping.js';
|
|
71
|
+
import '../lib/table-text.js';
|
|
72
|
+
import './field-parts.js';
|
|
70
73
|
import './numpad-sheet.js';
|
|
71
74
|
|
|
72
75
|
type AmountInputProps = ComponentProps<typeof AmountInput>;
|
|
@@ -101,6 +104,9 @@ interface MoneyFieldProps extends Omit<AmountInputProps, "value" | "onChange" |
|
|
|
101
104
|
* blurs the field and tears the editor down before the popover can open (typed
|
|
102
105
|
* calculations still evaluate).
|
|
103
106
|
*
|
|
107
|
+
* `inputClassName` (0.23, keksdose G7) styles the `<input>` itself — `text-end` for a
|
|
108
|
+
* money column — where `className` sizes the wrapper; see {@link AmountInput}'s.
|
|
109
|
+
*
|
|
104
110
|
* This, not a `NumberField` with `digits={2}`, is the number-valued field for money:
|
|
105
111
|
* it settles to the CURRENCY's minor unit (JPY 0, CHF 2) and `digits` takes a unit
|
|
106
112
|
* price's finer scale (kastlan 5).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/money-field.tsx"],"sourcesContent":["import { useState, type ComponentProps, type Ref } from \"react\";\nimport { AmountInput } from \"./amount-input\";\n\ntype AmountInputProps = ComponentProps<typeof AmountInput>;\n\nexport interface MoneyFieldProps\n extends Omit<AmountInputProps, \"value\" | \"onChange\" | \"onCommit\" | \"negative\" | \"onNegativeChange\" | \"ref\"> {\n /** The amount, or `null` for an empty field. */\n value: number | null;\n /**\n * The settled amount — on blur, on Enter, on the numpad's \"=\" and on a calculator\n * result; never a half-typed \"1200+\". Rounded to the currency's minor unit (or\n * `digits`) and clamped to `min` / `max`, like {@link AmountInput}. `null` when the\n * field was emptied. Not fired when the settled amount equals `value`, so an\n * Enter-then-blur saves once.\n */\n onCommit: (value: number | null) => void;\n ref?: Ref<HTMLInputElement>;\n}\n\n/** The number a settled text reads as: `null` for empty, `undefined` for no number\n * (a lone \"-\" or \".\"). */\nfunction parseSettled(text: string): number | null | undefined {\n const t = text.trim();\n if (t === \"\") return null;\n const n = Number(t);\n return Number.isFinite(n) ? n : undefined;\n}\n\n/**\n * {@link AmountInput} with a NUMBER in and a number out, for money edited outside a\n * form — a budget row, a table cell (kastlan 52: new-budget-page edited a CHF amount per\n * row with `NumberField` because AmountInput is string-valued and reports every\n * keystroke, \"1200+\" included, so a number-state row needed its own parse-and-settle\n * glue). This is that glue, the same one `RhfMoneyField` has: the text being typed\n * lives here, and only a settled figure reaches `onCommit`.\n *\n * The currency chip, the calculator, the numpad, the locale's decimal mark, the\n * rounding to the currency's minor unit and `min` / `max` are AmountInput's. A caller-\n * owned sign (`negative`) is not offered: the figure here carries its own minus.\n *\n * So are two props a table cell needs (keksdose K4, K6): `error`, the message under the\n * field that paints and describes it like {@link Input}'s, and `calculator={false}`,\n * for an amount in a close-on-blur inline editor, where a click on the calculator icon\n * blurs the field and tears the editor down before the popover can open (typed\n * calculations still evaluate).\n *\n * This, not a `NumberField` with `digits={2}`, is the number-valued field for money:\n * it settles to the CURRENCY's minor unit (JPY 0, CHF 2) and `digits` takes a unit\n * price's finer scale (kastlan 5).\n *\n * ```tsx\n * <MoneyField ariaLabel=\"Amount\" currency=\"CHF\" value={row.amount}\n * onCommit={(amount) => updateRow(row.id, { amount })} />\n * ```\n */\nexport function MoneyField({ value, onCommit, ref, ...props }: MoneyFieldProps) {\n const external = value === null ? \"\" : String(value);\n // The draft is re-read from `value` only when `value` moves away from what the draft\n // already says (a reset, another row's save), so a commit's own echo keeps the\n // typist's spelling (\"12.50\" stays \"12.50\" when 12.5 comes back).\n const [draft, setDraft] = useState(external);\n const [seen, setSeen] = useState(value);\n if (seen !== value) {\n setSeen(value);\n if (parseSettled(draft) !== value) setDraft(external);\n }\n return (\n <AmountInput\n {...props}\n ref={ref}\n value={draft}\n onChange={setDraft}\n onCommit={(text) => {\n const next = parseSettled(text);\n // No number (\"-\" alone): back to the stored amount rather than a guess.\n if (next === undefined) {\n setDraft(external);\n return;\n }\n if (next !== value) onCommit(next);\n }}\n />\n );\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../../src/components/money-field.tsx"],"sourcesContent":["import { useState, type ComponentProps, type Ref } from \"react\";\nimport { AmountInput } from \"./amount-input\";\n\ntype AmountInputProps = ComponentProps<typeof AmountInput>;\n\nexport interface MoneyFieldProps\n extends Omit<AmountInputProps, \"value\" | \"onChange\" | \"onCommit\" | \"negative\" | \"onNegativeChange\" | \"ref\"> {\n /** The amount, or `null` for an empty field. */\n value: number | null;\n /**\n * The settled amount — on blur, on Enter, on the numpad's \"=\" and on a calculator\n * result; never a half-typed \"1200+\". Rounded to the currency's minor unit (or\n * `digits`) and clamped to `min` / `max`, like {@link AmountInput}. `null` when the\n * field was emptied. Not fired when the settled amount equals `value`, so an\n * Enter-then-blur saves once.\n */\n onCommit: (value: number | null) => void;\n ref?: Ref<HTMLInputElement>;\n}\n\n/** The number a settled text reads as: `null` for empty, `undefined` for no number\n * (a lone \"-\" or \".\"). */\nfunction parseSettled(text: string): number | null | undefined {\n const t = text.trim();\n if (t === \"\") return null;\n const n = Number(t);\n return Number.isFinite(n) ? n : undefined;\n}\n\n/**\n * {@link AmountInput} with a NUMBER in and a number out, for money edited outside a\n * form — a budget row, a table cell (kastlan 52: new-budget-page edited a CHF amount per\n * row with `NumberField` because AmountInput is string-valued and reports every\n * keystroke, \"1200+\" included, so a number-state row needed its own parse-and-settle\n * glue). This is that glue, the same one `RhfMoneyField` has: the text being typed\n * lives here, and only a settled figure reaches `onCommit`.\n *\n * The currency chip, the calculator, the numpad, the locale's decimal mark, the\n * rounding to the currency's minor unit and `min` / `max` are AmountInput's. A caller-\n * owned sign (`negative`) is not offered: the figure here carries its own minus.\n *\n * So are two props a table cell needs (keksdose K4, K6): `error`, the message under the\n * field that paints and describes it like {@link Input}'s, and `calculator={false}`,\n * for an amount in a close-on-blur inline editor, where a click on the calculator icon\n * blurs the field and tears the editor down before the popover can open (typed\n * calculations still evaluate).\n *\n * `inputClassName` (0.23, keksdose G7) styles the `<input>` itself — `text-end` for a\n * money column — where `className` sizes the wrapper; see {@link AmountInput}'s.\n *\n * This, not a `NumberField` with `digits={2}`, is the number-valued field for money:\n * it settles to the CURRENCY's minor unit (JPY 0, CHF 2) and `digits` takes a unit\n * price's finer scale (kastlan 5).\n *\n * ```tsx\n * <MoneyField ariaLabel=\"Amount\" currency=\"CHF\" value={row.amount}\n * onCommit={(amount) => updateRow(row.id, { amount })} />\n * ```\n */\nexport function MoneyField({ value, onCommit, ref, ...props }: MoneyFieldProps) {\n const external = value === null ? \"\" : String(value);\n // The draft is re-read from `value` only when `value` moves away from what the draft\n // already says (a reset, another row's save), so a commit's own echo keeps the\n // typist's spelling (\"12.50\" stays \"12.50\" when 12.5 comes back).\n const [draft, setDraft] = useState(external);\n const [seen, setSeen] = useState(value);\n if (seen !== value) {\n setSeen(value);\n if (parseSettled(draft) !== value) setDraft(external);\n }\n return (\n <AmountInput\n {...props}\n ref={ref}\n value={draft}\n onChange={setDraft}\n onCommit={(text) => {\n const next = parseSettled(text);\n // No number (\"-\" alone): back to the stored amount rather than a guess.\n if (next === undefined) {\n setDraft(external);\n return;\n }\n if (next !== value) onCommit(next);\n }}\n />\n );\n}\n"],"mappings":";AAuEI;AAvEJ,SAAS,gBAA+C;AACxD,SAAS,mBAAmB;AAqB5B,SAAS,aAAa,MAAyC;AAC7D,QAAM,IAAI,KAAK,KAAK;AACpB,MAAI,MAAM,GAAI,QAAO;AACrB,QAAM,IAAI,OAAO,CAAC;AAClB,SAAO,OAAO,SAAS,CAAC,IAAI,IAAI;AAClC;AAgCO,SAAS,WAAW,EAAE,OAAO,UAAU,KAAK,GAAG,MAAM,GAAoB;AAC9E,QAAM,WAAW,UAAU,OAAO,KAAK,OAAO,KAAK;AAInD,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAS,QAAQ;AAC3C,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AACtC,MAAI,SAAS,OAAO;AAClB,YAAQ,KAAK;AACb,QAAI,aAAa,KAAK,MAAM,MAAO,UAAS,QAAQ;AAAA,EACtD;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ;AAAA,MACA,OAAO;AAAA,MACP,UAAU;AAAA,MACV,UAAU,CAAC,SAAS;AAClB,cAAM,OAAO,aAAa,IAAI;AAE9B,YAAI,SAAS,QAAW;AACtB,mBAAS,QAAQ;AACjB;AAAA,QACF;AACA,YAAI,SAAS,MAAO,UAAS,IAAI;AAAA,MACnC;AAAA;AAAA,EACF;AAEJ;","names":[]}
|
|
@@ -9,7 +9,7 @@ import { Button, FIELD_FLOATING_PAD, FIELD_INVALID, FIELD_TRIGGER, IconButton }
|
|
|
9
9
|
import { Popover } from "./popover.js";
|
|
10
10
|
import { splitTriggerAria } from "./trigger-aria.js";
|
|
11
11
|
import { useKitDateFormatter, useKitLabels, useKitLocale } from "../i18n/kit-labels.js";
|
|
12
|
-
import { FieldBox, FieldLabelLine, hasContent, useFieldMessages } from "./field-
|
|
12
|
+
import { FieldBox, FieldLabelLine, hasContent, useFieldMessages } from "./field-parts.js";
|
|
13
13
|
const DEFAULT_MONTH_PICKER_LABELS = {
|
|
14
14
|
previousYear: "Previous year",
|
|
15
15
|
nextYear: "Next year",
|
|
@@ -131,6 +131,7 @@ function MonthFieldTrigger({
|
|
|
131
131
|
"aria-expanded": open,
|
|
132
132
|
id: aria.id,
|
|
133
133
|
"aria-invalid": invalid || aria["aria-invalid"] === true || aria["aria-invalid"] === "true" || void 0,
|
|
134
|
+
"aria-required": aria["aria-required"],
|
|
134
135
|
"aria-labelledby": aria["aria-label"] && !aria["aria-labelledby"] ? void 0 : labelledBy,
|
|
135
136
|
"aria-label": aria["aria-label"],
|
|
136
137
|
"aria-describedby": aria["aria-describedby"],
|