@eifi1/ui-kit 0.16.0 → 0.17.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.
Files changed (89) hide show
  1. package/README.md +7 -7
  2. package/dist/chart.d.ts +1 -1
  3. package/dist/components/amount-input.d.ts +9 -1
  4. package/dist/components/amount-input.js.map +1 -1
  5. package/dist/components/button-group.d.ts +1 -1
  6. package/dist/components/calculator.d.ts +1 -1
  7. package/dist/components/copy-button.d.ts +1 -1
  8. package/dist/components/data-table-cells.d.ts +1 -1
  9. package/dist/components/data-table-filter-popover.d.ts +1 -1
  10. package/dist/components/data-table-filters.d.ts +1 -1
  11. package/dist/components/data-table.d.ts +1 -1
  12. package/dist/components/data-table.js +91 -3
  13. package/dist/components/data-table.js.map +1 -1
  14. package/dist/components/facing-pair.d.ts +1 -1
  15. package/dist/components/file-button.d.ts +1 -1
  16. package/dist/components/file-button.js +7 -4
  17. package/dist/components/file-button.js.map +1 -1
  18. package/dist/components/file-dropzone.d.ts +1 -1
  19. package/dist/components/form-actions.d.ts +1 -1
  20. package/dist/components/form-actions.js +32 -1
  21. package/dist/components/form-actions.js.map +1 -1
  22. package/dist/components/line-items.d.ts +30 -6
  23. package/dist/components/line-items.js +77 -33
  24. package/dist/components/line-items.js.map +1 -1
  25. package/dist/components/list.d.ts +13 -1
  26. package/dist/components/list.js.map +1 -1
  27. package/dist/components/money-field.d.ts +1 -1
  28. package/dist/components/number-field.d.ts +1 -1
  29. package/dist/components/number-input.d.ts +1 -1
  30. package/dist/components/numpad-sheet.d.ts +1 -1
  31. package/dist/components/progress-bar.d.ts +11 -1
  32. package/dist/components/progress-bar.js +14 -4
  33. package/dist/components/progress-bar.js.map +1 -1
  34. package/dist/components/series-chart.d.ts +1 -1
  35. package/dist/components/settings-fields.d.ts +1 -1
  36. package/dist/components/signed-amount.d.ts +12 -3
  37. package/dist/components/signed-amount.js +6 -4
  38. package/dist/components/signed-amount.js.map +1 -1
  39. package/dist/components/text-link.d.ts +1 -1
  40. package/dist/components/time-input.d.ts +1 -1
  41. package/dist/components/ui.d.ts +1 -1
  42. package/dist/components/ui.js +25 -3
  43. package/dist/components/ui.js.map +1 -1
  44. package/dist/components/use-table-state.d.ts +1 -1
  45. package/dist/{data-table-CfVXtCeG.d.ts → data-table-CpkWUJBY.d.ts} +41 -1
  46. package/dist/data-table.d.ts +1 -1
  47. package/dist/hooks/use-file-drop.d.ts +1 -1
  48. package/dist/i18n/defaults.d.ts +1 -1
  49. package/dist/i18n/kit-labels.d.ts +1 -1
  50. package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
  51. package/dist/i18n/locales/de-CH.d.ts +1 -1
  52. package/dist/i18n/locales/de-informal.d.ts +1 -1
  53. package/dist/i18n/locales/de.d.ts +1 -1
  54. package/dist/i18n/locales/en.d.ts +71 -0
  55. package/dist/i18n/locales/en.js +95 -0
  56. package/dist/i18n/locales/en.js.map +1 -0
  57. package/dist/i18n/locales/es.d.ts +1 -1
  58. package/dist/i18n/locales/fr.d.ts +1 -1
  59. package/dist/i18n/locales/hu.d.ts +1 -1
  60. package/dist/i18n/locales/it.d.ts +1 -1
  61. package/dist/i18n/locales/zh.d.ts +1 -1
  62. package/dist/index.d.ts +4 -4
  63. package/dist/{kit-labels-DE--LmCk.d.ts → kit-labels-C4uB_zmg.d.ts} +57 -5
  64. package/dist/rhf/fields.d.ts +1 -1
  65. package/dist/rhf/form.d.ts +1 -1
  66. package/dist/rhf/line-items.js.map +1 -1
  67. package/dist/rhf.d.ts +1 -1
  68. package/dist/search/global-search.d.ts +22 -4
  69. package/dist/search/global-search.js +31 -3
  70. package/dist/search/global-search.js.map +1 -1
  71. package/dist/search.d.ts +1 -1
  72. package/dist/shell/app-shell.d.ts +1 -1
  73. package/dist/shell/top-bar-brand.d.ts +1 -1
  74. package/dist/shell.d.ts +1 -1
  75. package/dist/wizard/stepper-nav.d.ts +1 -1
  76. package/dist/wizard.d.ts +1 -1
  77. package/package.json +1 -1
  78. package/src/components/amount-input.tsx +8 -0
  79. package/src/components/data-table.tsx +165 -3
  80. package/src/components/file-button.tsx +12 -4
  81. package/src/components/form-actions.tsx +73 -3
  82. package/src/components/line-items.tsx +116 -40
  83. package/src/components/list.tsx +13 -1
  84. package/src/components/progress-bar.tsx +20 -4
  85. package/src/components/signed-amount.tsx +16 -5
  86. package/src/components/ui.tsx +57 -3
  87. package/src/i18n/locales/en.ts +116 -0
  88. package/src/rhf/line-items.tsx +2 -1
  89. package/src/search/global-search.tsx +57 -6
@@ -6,7 +6,7 @@ import './series-chart-labels.js';
6
6
  import './toggle-legend.js';
7
7
  import './series-chart-ticks.js';
8
8
  import './series-chart-budget.js';
9
- import '../kit-labels-DE--LmCk.js';
9
+ import '../kit-labels-C4uB_zmg.js';
10
10
  import './data-table-labels.js';
11
11
  import './mini-calendar.js';
12
12
  import './calendar-heatmap.js';
@@ -1,5 +1,5 @@
1
1
  import 'react';
2
- export { O as DEFAULT_FILE_PICKER_LABELS, ac as FileButton, ad as FileButtonProps, bf as FilePickHandler, af as FilePickerLabels, ag as FileRejection, ah as FileRejectionReason, ai as FileScreenOptions, b0 as UseFilePickerOptions, b1 as UseFilePickerReturn, bg as formatAccept, bh as judgePick, b4 as matchesAccept, bi as screenFiles, bj as summariseRejections, b7 as useFilePicker } from '../kit-labels-DE--LmCk.js';
2
+ export { P as DEFAULT_FILE_PICKER_LABELS, ad as FileButton, ae as FileButtonProps, bh as FilePickHandler, ag as FilePickerLabels, ah as FileRejection, ai as FileRejectionReason, aj as FileScreenOptions, b2 as UseFilePickerOptions, b3 as UseFilePickerReturn, bi as formatAccept, bj as judgePick, b6 as matchesAccept, bk as screenFiles, bl as summariseRejections, b9 as useFilePicker } from '../kit-labels-C4uB_zmg.js';
3
3
  import './data-table-labels.js';
4
4
  import './mini-calendar.js';
5
5
  import './calendar-heatmap.js';
@@ -5,6 +5,7 @@ import { cn } from "../lib/cn.js";
5
5
  import { useAnnounce } from "../hooks/use-announce.js";
6
6
  import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels.js";
7
7
  import { Button, Spinner } from "./ui.js";
8
+ import { Tooltip } from "./tooltip.js";
8
9
  const DEFAULT_FILE_PICKER_LABELS = {
9
10
  rejectedType: (name) => `\u201C${name}\u201D is not a supported file type`,
10
11
  rejectedTypeOnly: (accept) => `Only ${accept} files`,
@@ -272,7 +273,10 @@ const FileButton = forwardRef(function FileButton2({
272
273
  }
273
274
  return (
274
275
  // One inline row, so the name sits beside the button wherever the button sits; the
275
- // name truncates (full list in its title) rather than pushing the row wider.
276
+ // name truncates rather than pushing the row wider, and the full list is in the
277
+ // kit's own Tooltip — not a native `title` (0.16.0 shipped one: the one-tooltip rule
278
+ // since keksdose dev#523, and a title never opens on touch). Lazy, so the closed
279
+ // bubble adds nothing to the read-out's text.
276
280
  /* @__PURE__ */ jsxs("span", { className: "inline-flex max-w-full min-w-0 items-center gap-2", children: [
277
281
  button,
278
282
  /* @__PURE__ */ jsx(
@@ -281,9 +285,8 @@ const FileButton = forwardRef(function FileButton2({
281
285
  id: pickedId,
282
286
  "aria-live": "polite",
283
287
  "data-slot": "file-button-name",
284
- title: picked.map((f) => f.name).join(", ") || void 0,
285
- className: "min-w-0 truncate text-sm text-[var(--text-secondary)]",
286
- children: pickedText
288
+ className: "flex min-w-0 text-sm text-[var(--text-secondary)]",
289
+ children: /* @__PURE__ */ jsx(Tooltip, { label: picked.map((f) => f.name).join(", "), lazy: true, className: "min-w-0", children: /* @__PURE__ */ jsx("span", { className: "block truncate", children: pickedText }) })
287
290
  }
288
291
  ),
289
292
  picker.element
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/file-button.tsx"],"sourcesContent":["import { forwardRef, useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, DragEvent, ReactElement, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport { Button, Spinner } from \"./ui\";\n\n/**\n * A button that opens the file picker — the shape all three apps kept writing by hand\n * as a `<Button>` plus a hidden `<input type=\"file\">` plus a ref between them (seven\n * copies in keksdose, two each in kastlan and lenkbank).\n *\n * Every copy had to remember the same four things, and each one forgot at least one:\n *\n * 1. **Reset the input after every pick.** A file input fires `change` only when its\n * value CHANGES, so picking the same file twice in a row — the retry after a failed\n * upload, the second photo of the same receipt — did nothing at all, which reads as\n * a broken button. `value = \"\"` after each pick; always, not per call site.\n * 2. **`type=\"button\"`.** `<Button>` does not set it, and inside a form a bare button\n * submits the form before the picker opens.\n * 3. **Check the file.** `accept` filters the DIALOG, not the result: the dialog's\n * \"All files\" switch, a drop, and a mobile share sheet all hand over whatever the\n * user chose. So `accept` is re-checked here, along with `maxSize`, `maxFiles` and\n * an optional `isValid`.\n * 4. **Say no without a toast.** A rejection is reported through `onReject`, with a\n * translated message per file, and spoken through a live region — the kit does not\n * decide how an app surfaces errors (keksdose's proposal §1 asked for exactly that).\n *\n * The part that is not a button — the hidden input, the check, the announcement — is\n * {@link useFilePicker}, for the case where the thing that opens the picker is someone\n * else's control (a card's \"Add\" action, a menu item, a second button for the camera).\n */\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the file pickers ({@link FileButton}, `FileDropzone`) render or speak.\n * Messages are functions of the file name so a translation can put it anywhere. */\nexport interface FilePickerLabels {\n /** The file's type is not in `accept`, when no `accept` is known to name (see\n * `rejectedTypeOnly`). */\n rejectedType: (name: string) => string;\n /** The file's type is not in `accept`, naming what IS accepted: `accept`'s tokens,\n * lower-cased and joined with \", \" (`\".csv\"` => \"Only .csv files\"). Used whenever\n * `accept` is set — kastlan kept a custom `isValid` + `invalidMessage` per form only\n * to say this, and `accept` already knows it. */\n rejectedTypeOnly: (accept: string, name: string) => string;\n /** The file is larger than `maxSize`; `maxSize` arrives formatted (\"5 MB\"). */\n rejectedSize: (name: string, maxSize: string) => string;\n /** The file was one too many for `maxFiles`. */\n rejectedCount: (name: string, maxFiles: number) => string;\n /** `isValid` said no and the caller gave no `invalidMessage`. */\n rejectedInvalid: (name: string) => string;\n /** Spoken instead of the per-file message when more than one file was refused. */\n rejectedMany: (count: number) => string;\n /** Spoken (and shown, on a dropzone) when `onPick` refused the WHOLE pick.\n * Receives the number of files in it. */\n rejectedPick: (count: number) => string;\n /** Spoken after a pick the component itself echoes (the dropzone). */\n selected: (count: number, firstName: string) => string;\n /** The dropzone's remove button for one file. */\n remove: (name: string) => string;\n /** The dropzone's remove-everything button in `multiple` mode. */\n clearAll: string;\n /** Spoken after a remove / clear, since the button that was pressed is gone. */\n removed: (name: string) => string;\n cleared: string;\n /** The dropzone's accessible name, when the host passes no `dropLabel`. */\n dropzone: string;\n /** The dropzone's Browse button, when the host passes no `browseLabel`. */\n browse: string;\n /** What an empty single-file dropzone says (no `emptyLabel` given). */\n empty: string;\n /** …and an empty `multiple` one. */\n emptyMultiple: string;\n /** The dropzone's second line (no `hint` given). Receives `accept` as a person\n * reads it (`\".pdf, image/png\"`), or `\"\"` when anything goes. */\n hint: (accept: string) => string;\n /** What a `busy` dropzone says in place of its body (\"Uploading…\"). */\n busy: string;\n}\n\nexport const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels = {\n rejectedType: (name) => `“${name}” is not a supported file type`,\n rejectedTypeOnly: (accept) => `Only ${accept} files`,\n rejectedSize: (name, maxSize) => `“${name}” is larger than ${maxSize}`,\n rejectedCount: (name, maxFiles) =>\n `“${name}” was not added: at most ${maxFiles} ${maxFiles === 1 ? \"file\" : \"files\"}`,\n rejectedInvalid: (name) => `“${name}” cannot be used here`,\n rejectedMany: (count) => `${count} files were not added`,\n rejectedPick: (count) =>\n count === 1 ? \"The file was not added\" : `None of the ${count} files were added`,\n selected: (count, firstName) => (count === 1 ? `“${firstName}” selected` : `${count} files selected`),\n remove: (name) => `Remove “${name}”`,\n clearAll: \"Remove all files\",\n removed: (name) => `“${name}” removed`,\n cleared: \"All files removed\",\n dropzone: \"File upload\",\n browse: \"Browse\",\n empty: \"Drop a file here\",\n emptyMultiple: \"Drop files here\",\n hint: (accept) => (accept ? `Accepted: ${accept}` : \"Any file type\"),\n busy: \"Uploading…\",\n};\n\n/* ── Screening ───────────────────────────────────────────────────────────── */\n\nexport type FileRejectionReason = \"type\" | \"size\" | \"count\" | \"invalid\";\n\n/** One file the picker refused, and why. `message` is already translated (see\n * {@link FilePickerLabels}, or the caller's `invalidMessage`), so a host that just\n * wants to show it can render `rejections[0].message` as is. */\nexport interface FileRejection {\n file: File;\n reason: FileRejectionReason;\n message: string;\n}\n\n/**\n * Does `file` satisfy an `accept` string, the way the browser's dialog reads it?\n * Comma-separated tokens: `.ext` (case-insensitive suffix of the name), `type/*`\n * (a MIME family) or an exact MIME type. An empty or absent `accept` takes anything.\n *\n * A file with no `type` — common for `.step`, `.dat`, anything the OS has no MIME\n * mapping for — can only match by extension, which is why lenkbank lists `.stp` AND\n * `model/step`: that is how `accept` has to be written for the dialog anyway.\n */\nexport function matchesAccept(file: File, accept: string | undefined): boolean {\n const tokens = (accept ?? \"\")\n .split(\",\")\n .map((t) => t.trim().toLowerCase())\n .filter(Boolean);\n if (tokens.length === 0) return true;\n const name = file.name.toLowerCase();\n // An EMPTY type is the browser saying \"I don't know\", not \"it's something else\": HEIC\n // photos on Windows without the codec arrive with `type === \"\"`. Refusing them\n // (0.6.0) refused iPhone photos the picker itself had just offered (keksdose). So a\n // missing type is inferred from the extension where that is unambiguous, and a file\n // whose type cannot be known at all is given the benefit of the doubt — nothing here\n // proves it does not match, and the server validates what it receives anyway.\n const type = (file.type || typeFromExtension(name) || \"\").toLowerCase();\n if (!type && !tokens.every((t) => t.startsWith(\".\"))) return true;\n return tokens.some((token) => {\n if (token.startsWith(\".\")) return name.endsWith(token);\n if (!type) return false;\n if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1));\n return type === token;\n });\n}\n\n/** MIME types for the extensions a browser most often leaves untyped. */\nconst EXTENSION_TYPES: Record<string, string> = {\n heic: \"image/heic\",\n heif: \"image/heif\",\n avif: \"image/avif\",\n webp: \"image/webp\",\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n png: \"image/png\",\n gif: \"image/gif\",\n pdf: \"application/pdf\",\n csv: \"text/csv\",\n txt: \"text/plain\",\n};\n\nfunction typeFromExtension(name: string): string | undefined {\n const dot = name.lastIndexOf(\".\");\n return dot === -1 ? undefined : EXTENSION_TYPES[name.slice(dot + 1)];\n}\n\n/** @internal `\" .PDF, image/png ,\"` => `\".pdf, image/png\"` — `accept` as a person reads\n * it. Also the dropzone's default hint. */\nexport function formatAccept(accept: string): string {\n return accept\n .split(\",\")\n .map((t) => t.trim().toLowerCase())\n .filter(Boolean)\n .join(\", \");\n}\n\n/** What the pickers screen a pick with. All optional; nothing set accepts everything. */\nexport interface FileScreenOptions {\n accept?: string;\n /** Bytes. Larger files are refused with reason `\"size\"`. */\n maxSize?: number;\n /** How many files one pick may deliver. The rest are refused with reason `\"count\"`\n * — first come, first kept. For a running cap (\"at most 5 attachments\") pass what\n * is LEFT: `maxFiles={5 - attachments.length}`. */\n maxFiles?: number;\n /** The caller's own check, after `accept` and `maxSize`. */\n isValid?: (file: File) => boolean;\n /** The message for an `isValid` refusal; `labels.rejectedInvalid` otherwise. */\n invalidMessage?: string;\n}\n\n/** @internal Split a pick into the files that pass and the ones that do not. */\nexport function screenFiles(\n files: readonly File[],\n opts: FileScreenOptions,\n labels: FilePickerLabels,\n formatSize: (bytes: number) => string,\n): { accepted: File[]; rejected: FileRejection[] } {\n const accepted: File[] = [];\n const rejected: FileRejection[] = [];\n // Name what `accept` takes (\"Only .csv files\") — unless the host translated\n // `rejectedType` but not yet `rejectedTypeOnly`: its own sentence beats an English one.\n const acceptText = opts.accept ? formatAccept(opts.accept) : \"\";\n const useOnly =\n acceptText !== \"\" &&\n (labels.rejectedTypeOnly !== DEFAULT_FILE_PICKER_LABELS.rejectedTypeOnly ||\n labels.rejectedType === DEFAULT_FILE_PICKER_LABELS.rejectedType);\n const typeMessage = (name: string) =>\n useOnly ? labels.rejectedTypeOnly(acceptText, name) : labels.rejectedType(name);\n for (const file of files) {\n let reason: FileRejectionReason | null = null;\n if (!matchesAccept(file, opts.accept)) reason = \"type\";\n else if (opts.maxSize !== undefined && file.size > opts.maxSize) reason = \"size\";\n else if (opts.isValid && !opts.isValid(file)) reason = \"invalid\";\n // Count last, and only against files that passed everything else: a refused\n // file should not use up one of the slots a good one could have had.\n else if (opts.maxFiles !== undefined && accepted.length >= opts.maxFiles) reason = \"count\";\n if (reason === null) {\n accepted.push(file);\n continue;\n }\n const message =\n reason === \"type\"\n ? typeMessage(file.name)\n : reason === \"size\"\n ? labels.rejectedSize(file.name, formatSize(opts.maxSize ?? 0))\n : reason === \"count\"\n ? labels.rejectedCount(file.name, Math.max(0, opts.maxFiles ?? 0))\n : (opts.invalidMessage ?? labels.rejectedInvalid(file.name));\n rejected.push({ file, reason, message });\n }\n return { accepted, rejected };\n}\n\n/**\n * Judges a pick as a whole — keksdose's \"refuse the pick if any file is bad\", which\n * with `onFiles` + `onReject` alone had to be rebuilt from two calls in a microtask.\n *\n * Called ONCE per pick, after screening and before `onFiles` / `onReject`, with both\n * halves (either may be empty). Return `false` to refuse the whole pick: the accepted\n * files are not delivered, `onReject` still receives what screening refused, and one\n * sentence (`labels.rejectedPick`) is spoken for the pick instead of a per-file one.\n * Return nothing to let the pick through as usual.\n *\n * ```tsx\n * onPick={(ok, bad) => bad.length === 0 || false} // all or nothing\n * ```\n */\nexport type FilePickHandler = (accepted: File[], rejected: FileRejection[]) => boolean | void;\n\n/** @internal The shared tail of both pickers: run `onPick`, and say whether the pick\n * stands. A pick with nothing accepted has nothing to refuse, so `false` there is\n * treated as no verdict and the usual per-file messages are kept. */\nexport function judgePick(\n onPick: FilePickHandler | undefined,\n accepted: File[],\n rejected: FileRejection[],\n): boolean {\n if (!onPick) return true;\n return onPick(accepted, rejected) !== false || accepted.length === 0;\n}\n\n/** One sentence for a whole batch of refusals: the file's own message for one, a\n * count for several — reading out five sentences in a row helps nobody. */\nexport function summariseRejections(rejected: readonly FileRejection[], labels: FilePickerLabels): string {\n if (rejected.length === 0) return \"\";\n if (rejected.length === 1) return rejected[0].message;\n return labels.rejectedMany(rejected.length);\n}\n\n/* ── useFilePicker ───────────────────────────────────────────────────────── */\n\nexport interface UseFilePickerOptions extends FileScreenOptions {\n /** Let one pick deliver several files. Without it a drop of several keeps the first. */\n multiple?: boolean;\n /**\n * Ask a phone for its camera instead of the file chooser: `\"environment\"` is the\n * back camera, `\"user\"` the front. A HINT — desktop browsers ignore it, and Chrome\n * drops it when `multiple` is also set (a camera cannot deliver a list), which is why\n * keksdose's camera input has no `multiple`. Pass one or the other.\n */\n capture?: boolean | \"user\" | \"environment\";\n /** The files that passed, in pick order. Never called with an empty array.\n * Optional since 0.7 for a caller that takes the pick through `onPick` alone. */\n onFiles?: (files: File[]) => void;\n /** See {@link FilePickHandler}: the whole pick at once, with the power to refuse it. */\n onPick?: FilePickHandler;\n /** The files that did not, with a translated message each. The refusals are also\n * spoken through a live region, so this is for SHOWING them, not for a11y. */\n onReject?: (rejections: FileRejection[]) => void;\n /** `open()` and `take()` do nothing while set. */\n disabled?: boolean;\n /** Per-instance overrides of the `filePicker` label namespace. */\n labels?: Partial<FilePickerLabels>;\n}\n\nexport interface UseFilePickerReturn {\n /** Open the system picker. Call it from a click handler — browsers only open a file\n * dialog in response to a user gesture. */\n open: () => void;\n /** Screen and deliver files that arrived some other way — a drop, a paste. Honours\n * `multiple` (only the first file without it) and every check. */\n take: (files: ArrayLike<File> | null | undefined) => void;\n /** The hidden input and the live region. Render it once, anywhere — it takes no\n * space and needs no positioned ancestor. */\n element: ReactElement;\n}\n\n/**\n * The headless half of {@link FileButton}: a hidden file input you can open from any\n * control, with the reset, the screening and the announcement built in.\n *\n * ```tsx\n * const picker = useFilePicker({ accept: \".pdf\", onFiles: ([f]) => upload(f) });\n * return <>{picker.element}<DetailTableCard onAdd={picker.open} … /></>;\n * ```\n */\nexport function useFilePicker({\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onPick,\n onReject,\n disabled,\n labels: labelsProp,\n}: UseFilePickerOptions): UseFilePickerReturn {\n const inputRef = useRef<HTMLInputElement>(null);\n const labels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labelsProp);\n const fileText = useKitFileLabels();\n // Assertive: a refusal is a failure the user has to hear before they move on, and\n // it is the only thing this region ever says.\n const { announce, regionProps } = useAnnounce({ politeness: \"assertive\" });\n\n const take = (list: ArrayLike<File> | null | undefined) => {\n if (disabled || !list || list.length === 0) return;\n const all = Array.from(list);\n const files = multiple ? all : all.slice(0, 1);\n const { accepted, rejected } = screenFiles(\n files,\n // A single-file picker holds one file by definition; `maxFiles` is a multi-mode\n // cap and would only ever say \"0\" there if a caller passed it by mistake.\n { accept, maxSize, maxFiles: multiple ? maxFiles : undefined, isValid, invalidMessage },\n labels,\n fileText.size,\n );\n if (!judgePick(onPick, accepted, rejected)) {\n if (rejected.length > 0) onReject?.(rejected);\n announce(labels.rejectedPick(files.length));\n return;\n }\n if (accepted.length > 0) onFiles?.(accepted);\n if (rejected.length > 0) {\n onReject?.(rejected);\n announce(summariseRejections(rejected, labels));\n }\n };\n\n const element = (\n <>\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n capture={capture}\n disabled={disabled}\n // `hidden` (display: none), not `sr-only`: the control a user reaches is the\n // button, and a second, nameless tab stop for the same action is noise. A\n // display:none file input still opens on `.click()` in every current browser —\n // and, unlike `sr-only`, it cannot escape to the initial containing block and\n // stretch the page (see the note in `file-dropzone.tsx`).\n className=\"hidden\"\n tabIndex={-1}\n aria-hidden\n onChange={(e) => {\n // Copy BEFORE the reset: `files` is a live view of the input's value, and\n // clearing the value empties it.\n const picked = Array.from(e.currentTarget.files ?? []);\n e.currentTarget.value = \"\";\n take(picked);\n }}\n />\n <span {...regionProps} />\n </>\n );\n\n return {\n open: () => {\n if (!disabled) inputRef.current?.click();\n },\n take,\n element,\n };\n}\n\n/* ── FileButton ──────────────────────────────────────────────────────────── */\n\ntype ButtonOwnProps = ComponentPropsWithoutRef<typeof Button>;\n\nexport interface FileButtonProps\n extends Omit<UseFilePickerOptions, \"disabled\">,\n // `onDrop` & co. stay the caller's own when `droppable` is off; `type` is fixed to\n // \"button\" (see 2. above) — a file trigger never submits a form.\n Omit<ButtonOwnProps, \"type\" | \"children\" | \"accept\" | \"capture\" | \"multiple\"> {\n /** The button's content — usually an icon and a word. It is the accessible name. */\n children: ReactNode;\n /** Busy: disabled, `aria-busy`, and a spinner before the content — for \"uploading\". */\n pending?: boolean;\n /** Also accept files dropped ON the button (lenkbank's \"drop onto the button\").\n * Off by default: a button that silently takes drops is a surprise on a page that\n * has a real drop target elsewhere. */\n droppable?: boolean;\n /**\n * Show what was picked beside the button: the file's name, or \"3 files selected\"\n * (`filePicker.selected`) for several — a native `<input type=\"file\">` says this,\n * and a bare button that takes a file and says nothing leaves the user to trust it.\n * The read-out is a polite live region and describes the button, so it is heard\n * when it changes and again on focus. It holds the last ACCEPTED pick: a refused\n * file leaves it as it was. Off by default, so the button's layout is unchanged.\n */\n showFileName?: boolean;\n}\n\n/**\n * {@link useFilePicker} behind a {@link Button}: `variant` and every other button prop\n * pass through, and the ref is the `<button>` (so `ref.current.click()` opens the\n * picker from elsewhere too).\n *\n * ```tsx\n * <FileButton accept=\"image/*,application/pdf\" capture=\"environment\" variant=\"secondary\"\n * maxSize={10_000_000} onFiles={([f]) => upload(f)} onReject={([r]) => setError(r.message)}>\n * <Camera aria-hidden /> Photograph receipt\n * </FileButton>\n * ```\n */\nexport const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(function FileButton(\n {\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onPick,\n onReject,\n labels,\n pending,\n droppable,\n showFileName = false,\n disabled,\n children,\n className,\n onClick,\n onDragEnter,\n onDragOver,\n onDragLeave,\n onDrop,\n ...rest\n },\n ref,\n) {\n const inert = Boolean(disabled || pending);\n const [picked, setPicked] = useState<readonly File[]>([]);\n const pickedId = useId();\n const pickerLabels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labels);\n const picker = useFilePicker({\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles: showFileName\n ? (files) => {\n setPicked(files);\n onFiles?.(files);\n }\n : onFiles,\n onPick,\n onReject,\n labels,\n disabled: inert,\n });\n const pickedText =\n picked.length === 0 ? \"\" : picked.length === 1 ? picked[0].name : pickerLabels.selected(picked.length, picked[0].name);\n const [dragOver, setDragOver] = useState(false);\n\n const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes(\"Files\");\n // Only when `droppable`: otherwise the caller's own handlers are passed untouched.\n const dropHandlers = droppable\n ? {\n onDragEnter: (e: DragEvent<HTMLButtonElement>) => {\n onDragEnter?.(e);\n if (!hasFiles(e)) return;\n e.preventDefault();\n if (!inert) setDragOver(true);\n },\n onDragOver: (e: DragEvent<HTMLButtonElement>) => {\n onDragOver?.(e);\n if (!hasFiles(e)) return;\n // Always cancel for a file drag, busy or not: an uncancelled drop makes the\n // browser NAVIGATE to the file, which loses the page.\n e.preventDefault();\n e.dataTransfer.dropEffect = inert ? \"none\" : \"copy\";\n },\n onDragLeave: (e: DragEvent<HTMLButtonElement>) => {\n onDragLeave?.(e);\n if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;\n setDragOver(false);\n },\n onDrop: (e: DragEvent<HTMLButtonElement>) => {\n onDrop?.(e);\n if (!hasFiles(e)) return;\n e.preventDefault();\n setDragOver(false);\n picker.take(e.dataTransfer.files);\n },\n }\n : { onDragEnter, onDragOver, onDragLeave, onDrop };\n\n // `Button` is a plain function component; under React 19 `ref` is an ordinary prop\n // and reaches the `<button>` through its `...rest`. Its props type just does not\n // declare it, hence the widening here rather than a second button implementation.\n const refProp = { ref } as { ref?: Ref<HTMLButtonElement> };\n\n const button = (\n <Button\n {...rest}\n {...refProp}\n {...dropHandlers}\n type=\"button\"\n disabled={inert}\n aria-busy={pending || undefined}\n aria-describedby={\n showFileName && pickedText\n ? [rest[\"aria-describedby\"], pickedId].filter(Boolean).join(\" \")\n : rest[\"aria-describedby\"]\n }\n data-drag-over={dragOver || undefined}\n className={cn(\n // A live drag gets the focus ring's colour as a ring: \"let go here\", in the\n // same vocabulary the button already uses for \"you are here\".\n dragOver && \"ring-2 ring-[var(--brand)]\",\n // Beside a long file name it is the NAME that gives way, never the button.\n showFileName && \"shrink-0\",\n className,\n )}\n onClick={(e) => {\n onClick?.(e);\n if (!e.defaultPrevented) picker.open();\n }}\n >\n {/* `label={null}`: the spinner is decoration here — `aria-busy` says the same\n thing on the button, and a spoken \"Loading\" would run into its name. */}\n {pending && <Spinner label={null} className=\"size-4\" />}\n {children}\n </Button>\n );\n\n if (!showFileName) {\n return (\n <>\n {button}\n {picker.element}\n </>\n );\n }\n return (\n // One inline row, so the name sits beside the button wherever the button sits; the\n // name truncates (full list in its title) rather than pushing the row wider.\n <span className=\"inline-flex max-w-full min-w-0 items-center gap-2\">\n {button}\n <span\n id={pickedId}\n aria-live=\"polite\"\n data-slot=\"file-button-name\"\n title={picked.map((f) => f.name).join(\", \") || undefined}\n className=\"min-w-0 truncate text-sm text-[var(--text-secondary)]\"\n >\n {pickedText}\n </span>\n {picker.element}\n </span>\n );\n});\nFileButton.displayName = \"FileButton\";\n"],"mappings":"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","names":["FileButton"]}
1
+ {"version":3,"sources":["../../src/components/file-button.tsx"],"sourcesContent":["import { forwardRef, useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, DragEvent, ReactElement, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport { Button, Spinner } from \"./ui\";\nimport { Tooltip } from \"./tooltip\";\n\n/**\n * A button that opens the file picker — the shape all three apps kept writing by hand\n * as a `<Button>` plus a hidden `<input type=\"file\">` plus a ref between them (seven\n * copies in keksdose, two each in kastlan and lenkbank).\n *\n * Every copy had to remember the same four things, and each one forgot at least one:\n *\n * 1. **Reset the input after every pick.** A file input fires `change` only when its\n * value CHANGES, so picking the same file twice in a row — the retry after a failed\n * upload, the second photo of the same receipt — did nothing at all, which reads as\n * a broken button. `value = \"\"` after each pick; always, not per call site.\n * 2. **`type=\"button\"`.** `<Button>` does not set it, and inside a form a bare button\n * submits the form before the picker opens.\n * 3. **Check the file.** `accept` filters the DIALOG, not the result: the dialog's\n * \"All files\" switch, a drop, and a mobile share sheet all hand over whatever the\n * user chose. So `accept` is re-checked here, along with `maxSize`, `maxFiles` and\n * an optional `isValid`.\n * 4. **Say no without a toast.** A rejection is reported through `onReject`, with a\n * translated message per file, and spoken through a live region — the kit does not\n * decide how an app surfaces errors (keksdose's proposal §1 asked for exactly that).\n *\n * The part that is not a button — the hidden input, the check, the announcement — is\n * {@link useFilePicker}, for the case where the thing that opens the picker is someone\n * else's control (a card's \"Add\" action, a menu item, a second button for the camera).\n */\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the file pickers ({@link FileButton}, `FileDropzone`) render or speak.\n * Messages are functions of the file name so a translation can put it anywhere. */\nexport interface FilePickerLabels {\n /** The file's type is not in `accept`, when no `accept` is known to name (see\n * `rejectedTypeOnly`). */\n rejectedType: (name: string) => string;\n /** The file's type is not in `accept`, naming what IS accepted: `accept`'s tokens,\n * lower-cased and joined with \", \" (`\".csv\"` => \"Only .csv files\"). Used whenever\n * `accept` is set — kastlan kept a custom `isValid` + `invalidMessage` per form only\n * to say this, and `accept` already knows it. */\n rejectedTypeOnly: (accept: string, name: string) => string;\n /** The file is larger than `maxSize`; `maxSize` arrives formatted (\"5 MB\"). */\n rejectedSize: (name: string, maxSize: string) => string;\n /** The file was one too many for `maxFiles`. */\n rejectedCount: (name: string, maxFiles: number) => string;\n /** `isValid` said no and the caller gave no `invalidMessage`. */\n rejectedInvalid: (name: string) => string;\n /** Spoken instead of the per-file message when more than one file was refused. */\n rejectedMany: (count: number) => string;\n /** Spoken (and shown, on a dropzone) when `onPick` refused the WHOLE pick.\n * Receives the number of files in it. */\n rejectedPick: (count: number) => string;\n /** Spoken after a pick the component itself echoes (the dropzone). */\n selected: (count: number, firstName: string) => string;\n /** The dropzone's remove button for one file. */\n remove: (name: string) => string;\n /** The dropzone's remove-everything button in `multiple` mode. */\n clearAll: string;\n /** Spoken after a remove / clear, since the button that was pressed is gone. */\n removed: (name: string) => string;\n cleared: string;\n /** The dropzone's accessible name, when the host passes no `dropLabel`. */\n dropzone: string;\n /** The dropzone's Browse button, when the host passes no `browseLabel`. */\n browse: string;\n /** What an empty single-file dropzone says (no `emptyLabel` given). */\n empty: string;\n /** …and an empty `multiple` one. */\n emptyMultiple: string;\n /** The dropzone's second line (no `hint` given). Receives `accept` as a person\n * reads it (`\".pdf, image/png\"`), or `\"\"` when anything goes. */\n hint: (accept: string) => string;\n /** What a `busy` dropzone says in place of its body (\"Uploading…\"). */\n busy: string;\n}\n\nexport const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels = {\n rejectedType: (name) => `“${name}” is not a supported file type`,\n rejectedTypeOnly: (accept) => `Only ${accept} files`,\n rejectedSize: (name, maxSize) => `“${name}” is larger than ${maxSize}`,\n rejectedCount: (name, maxFiles) =>\n `“${name}” was not added: at most ${maxFiles} ${maxFiles === 1 ? \"file\" : \"files\"}`,\n rejectedInvalid: (name) => `“${name}” cannot be used here`,\n rejectedMany: (count) => `${count} files were not added`,\n rejectedPick: (count) =>\n count === 1 ? \"The file was not added\" : `None of the ${count} files were added`,\n selected: (count, firstName) => (count === 1 ? `“${firstName}” selected` : `${count} files selected`),\n remove: (name) => `Remove “${name}”`,\n clearAll: \"Remove all files\",\n removed: (name) => `“${name}” removed`,\n cleared: \"All files removed\",\n dropzone: \"File upload\",\n browse: \"Browse\",\n empty: \"Drop a file here\",\n emptyMultiple: \"Drop files here\",\n hint: (accept) => (accept ? `Accepted: ${accept}` : \"Any file type\"),\n busy: \"Uploading…\",\n};\n\n/* ── Screening ───────────────────────────────────────────────────────────── */\n\nexport type FileRejectionReason = \"type\" | \"size\" | \"count\" | \"invalid\";\n\n/** One file the picker refused, and why. `message` is already translated (see\n * {@link FilePickerLabels}, or the caller's `invalidMessage`), so a host that just\n * wants to show it can render `rejections[0].message` as is. */\nexport interface FileRejection {\n file: File;\n reason: FileRejectionReason;\n message: string;\n}\n\n/**\n * Does `file` satisfy an `accept` string, the way the browser's dialog reads it?\n * Comma-separated tokens: `.ext` (case-insensitive suffix of the name), `type/*`\n * (a MIME family) or an exact MIME type. An empty or absent `accept` takes anything.\n *\n * A file with no `type` — common for `.step`, `.dat`, anything the OS has no MIME\n * mapping for — can only match by extension, which is why lenkbank lists `.stp` AND\n * `model/step`: that is how `accept` has to be written for the dialog anyway.\n */\nexport function matchesAccept(file: File, accept: string | undefined): boolean {\n const tokens = (accept ?? \"\")\n .split(\",\")\n .map((t) => t.trim().toLowerCase())\n .filter(Boolean);\n if (tokens.length === 0) return true;\n const name = file.name.toLowerCase();\n // An EMPTY type is the browser saying \"I don't know\", not \"it's something else\": HEIC\n // photos on Windows without the codec arrive with `type === \"\"`. Refusing them\n // (0.6.0) refused iPhone photos the picker itself had just offered (keksdose). So a\n // missing type is inferred from the extension where that is unambiguous, and a file\n // whose type cannot be known at all is given the benefit of the doubt — nothing here\n // proves it does not match, and the server validates what it receives anyway.\n const type = (file.type || typeFromExtension(name) || \"\").toLowerCase();\n if (!type && !tokens.every((t) => t.startsWith(\".\"))) return true;\n return tokens.some((token) => {\n if (token.startsWith(\".\")) return name.endsWith(token);\n if (!type) return false;\n if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1));\n return type === token;\n });\n}\n\n/** MIME types for the extensions a browser most often leaves untyped. */\nconst EXTENSION_TYPES: Record<string, string> = {\n heic: \"image/heic\",\n heif: \"image/heif\",\n avif: \"image/avif\",\n webp: \"image/webp\",\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n png: \"image/png\",\n gif: \"image/gif\",\n pdf: \"application/pdf\",\n csv: \"text/csv\",\n txt: \"text/plain\",\n};\n\nfunction typeFromExtension(name: string): string | undefined {\n const dot = name.lastIndexOf(\".\");\n return dot === -1 ? undefined : EXTENSION_TYPES[name.slice(dot + 1)];\n}\n\n/** @internal `\" .PDF, image/png ,\"` => `\".pdf, image/png\"` — `accept` as a person reads\n * it. Also the dropzone's default hint. */\nexport function formatAccept(accept: string): string {\n return accept\n .split(\",\")\n .map((t) => t.trim().toLowerCase())\n .filter(Boolean)\n .join(\", \");\n}\n\n/** What the pickers screen a pick with. All optional; nothing set accepts everything. */\nexport interface FileScreenOptions {\n accept?: string;\n /** Bytes. Larger files are refused with reason `\"size\"`. */\n maxSize?: number;\n /** How many files one pick may deliver. The rest are refused with reason `\"count\"`\n * — first come, first kept. For a running cap (\"at most 5 attachments\") pass what\n * is LEFT: `maxFiles={5 - attachments.length}`. */\n maxFiles?: number;\n /** The caller's own check, after `accept` and `maxSize`. */\n isValid?: (file: File) => boolean;\n /** The message for an `isValid` refusal; `labels.rejectedInvalid` otherwise. */\n invalidMessage?: string;\n}\n\n/** @internal Split a pick into the files that pass and the ones that do not. */\nexport function screenFiles(\n files: readonly File[],\n opts: FileScreenOptions,\n labels: FilePickerLabels,\n formatSize: (bytes: number) => string,\n): { accepted: File[]; rejected: FileRejection[] } {\n const accepted: File[] = [];\n const rejected: FileRejection[] = [];\n // Name what `accept` takes (\"Only .csv files\") — unless the host translated\n // `rejectedType` but not yet `rejectedTypeOnly`: its own sentence beats an English one.\n const acceptText = opts.accept ? formatAccept(opts.accept) : \"\";\n const useOnly =\n acceptText !== \"\" &&\n (labels.rejectedTypeOnly !== DEFAULT_FILE_PICKER_LABELS.rejectedTypeOnly ||\n labels.rejectedType === DEFAULT_FILE_PICKER_LABELS.rejectedType);\n const typeMessage = (name: string) =>\n useOnly ? labels.rejectedTypeOnly(acceptText, name) : labels.rejectedType(name);\n for (const file of files) {\n let reason: FileRejectionReason | null = null;\n if (!matchesAccept(file, opts.accept)) reason = \"type\";\n else if (opts.maxSize !== undefined && file.size > opts.maxSize) reason = \"size\";\n else if (opts.isValid && !opts.isValid(file)) reason = \"invalid\";\n // Count last, and only against files that passed everything else: a refused\n // file should not use up one of the slots a good one could have had.\n else if (opts.maxFiles !== undefined && accepted.length >= opts.maxFiles) reason = \"count\";\n if (reason === null) {\n accepted.push(file);\n continue;\n }\n const message =\n reason === \"type\"\n ? typeMessage(file.name)\n : reason === \"size\"\n ? labels.rejectedSize(file.name, formatSize(opts.maxSize ?? 0))\n : reason === \"count\"\n ? labels.rejectedCount(file.name, Math.max(0, opts.maxFiles ?? 0))\n : (opts.invalidMessage ?? labels.rejectedInvalid(file.name));\n rejected.push({ file, reason, message });\n }\n return { accepted, rejected };\n}\n\n/**\n * Judges a pick as a whole — keksdose's \"refuse the pick if any file is bad\", which\n * with `onFiles` + `onReject` alone had to be rebuilt from two calls in a microtask.\n *\n * Called ONCE per pick, after screening and before `onFiles` / `onReject`, with both\n * halves (either may be empty). Return `false` to refuse the whole pick: the accepted\n * files are not delivered, `onReject` still receives what screening refused, and one\n * sentence (`labels.rejectedPick`) is spoken for the pick instead of a per-file one.\n * Return nothing to let the pick through as usual.\n *\n * ```tsx\n * onPick={(ok, bad) => bad.length === 0 || false} // all or nothing\n * ```\n */\nexport type FilePickHandler = (accepted: File[], rejected: FileRejection[]) => boolean | void;\n\n/** @internal The shared tail of both pickers: run `onPick`, and say whether the pick\n * stands. A pick with nothing accepted has nothing to refuse, so `false` there is\n * treated as no verdict and the usual per-file messages are kept. */\nexport function judgePick(\n onPick: FilePickHandler | undefined,\n accepted: File[],\n rejected: FileRejection[],\n): boolean {\n if (!onPick) return true;\n return onPick(accepted, rejected) !== false || accepted.length === 0;\n}\n\n/** One sentence for a whole batch of refusals: the file's own message for one, a\n * count for several — reading out five sentences in a row helps nobody. */\nexport function summariseRejections(rejected: readonly FileRejection[], labels: FilePickerLabels): string {\n if (rejected.length === 0) return \"\";\n if (rejected.length === 1) return rejected[0].message;\n return labels.rejectedMany(rejected.length);\n}\n\n/* ── useFilePicker ───────────────────────────────────────────────────────── */\n\nexport interface UseFilePickerOptions extends FileScreenOptions {\n /** Let one pick deliver several files. Without it a drop of several keeps the first. */\n multiple?: boolean;\n /**\n * Ask a phone for its camera instead of the file chooser: `\"environment\"` is the\n * back camera, `\"user\"` the front. A HINT — desktop browsers ignore it, and Chrome\n * drops it when `multiple` is also set (a camera cannot deliver a list), which is why\n * keksdose's camera input has no `multiple`. Pass one or the other.\n */\n capture?: boolean | \"user\" | \"environment\";\n /** The files that passed, in pick order. Never called with an empty array.\n * Optional since 0.7 for a caller that takes the pick through `onPick` alone. */\n onFiles?: (files: File[]) => void;\n /** See {@link FilePickHandler}: the whole pick at once, with the power to refuse it. */\n onPick?: FilePickHandler;\n /** The files that did not, with a translated message each. The refusals are also\n * spoken through a live region, so this is for SHOWING them, not for a11y. */\n onReject?: (rejections: FileRejection[]) => void;\n /** `open()` and `take()` do nothing while set. */\n disabled?: boolean;\n /** Per-instance overrides of the `filePicker` label namespace. */\n labels?: Partial<FilePickerLabels>;\n}\n\nexport interface UseFilePickerReturn {\n /** Open the system picker. Call it from a click handler — browsers only open a file\n * dialog in response to a user gesture. */\n open: () => void;\n /** Screen and deliver files that arrived some other way — a drop, a paste. Honours\n * `multiple` (only the first file without it) and every check. */\n take: (files: ArrayLike<File> | null | undefined) => void;\n /** The hidden input and the live region. Render it once, anywhere — it takes no\n * space and needs no positioned ancestor. */\n element: ReactElement;\n}\n\n/**\n * The headless half of {@link FileButton}: a hidden file input you can open from any\n * control, with the reset, the screening and the announcement built in.\n *\n * ```tsx\n * const picker = useFilePicker({ accept: \".pdf\", onFiles: ([f]) => upload(f) });\n * return <>{picker.element}<DetailTableCard onAdd={picker.open} … /></>;\n * ```\n */\nexport function useFilePicker({\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onPick,\n onReject,\n disabled,\n labels: labelsProp,\n}: UseFilePickerOptions): UseFilePickerReturn {\n const inputRef = useRef<HTMLInputElement>(null);\n const labels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labelsProp);\n const fileText = useKitFileLabels();\n // Assertive: a refusal is a failure the user has to hear before they move on, and\n // it is the only thing this region ever says.\n const { announce, regionProps } = useAnnounce({ politeness: \"assertive\" });\n\n const take = (list: ArrayLike<File> | null | undefined) => {\n if (disabled || !list || list.length === 0) return;\n const all = Array.from(list);\n const files = multiple ? all : all.slice(0, 1);\n const { accepted, rejected } = screenFiles(\n files,\n // A single-file picker holds one file by definition; `maxFiles` is a multi-mode\n // cap and would only ever say \"0\" there if a caller passed it by mistake.\n { accept, maxSize, maxFiles: multiple ? maxFiles : undefined, isValid, invalidMessage },\n labels,\n fileText.size,\n );\n if (!judgePick(onPick, accepted, rejected)) {\n if (rejected.length > 0) onReject?.(rejected);\n announce(labels.rejectedPick(files.length));\n return;\n }\n if (accepted.length > 0) onFiles?.(accepted);\n if (rejected.length > 0) {\n onReject?.(rejected);\n announce(summariseRejections(rejected, labels));\n }\n };\n\n const element = (\n <>\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n capture={capture}\n disabled={disabled}\n // `hidden` (display: none), not `sr-only`: the control a user reaches is the\n // button, and a second, nameless tab stop for the same action is noise. A\n // display:none file input still opens on `.click()` in every current browser —\n // and, unlike `sr-only`, it cannot escape to the initial containing block and\n // stretch the page (see the note in `file-dropzone.tsx`).\n className=\"hidden\"\n tabIndex={-1}\n aria-hidden\n onChange={(e) => {\n // Copy BEFORE the reset: `files` is a live view of the input's value, and\n // clearing the value empties it.\n const picked = Array.from(e.currentTarget.files ?? []);\n e.currentTarget.value = \"\";\n take(picked);\n }}\n />\n <span {...regionProps} />\n </>\n );\n\n return {\n open: () => {\n if (!disabled) inputRef.current?.click();\n },\n take,\n element,\n };\n}\n\n/* ── FileButton ──────────────────────────────────────────────────────────── */\n\ntype ButtonOwnProps = ComponentPropsWithoutRef<typeof Button>;\n\nexport interface FileButtonProps\n extends Omit<UseFilePickerOptions, \"disabled\">,\n // `onDrop` & co. stay the caller's own when `droppable` is off; `type` is fixed to\n // \"button\" (see 2. above) — a file trigger never submits a form.\n Omit<ButtonOwnProps, \"type\" | \"children\" | \"accept\" | \"capture\" | \"multiple\"> {\n /** The button's content — usually an icon and a word. It is the accessible name. */\n children: ReactNode;\n /** Busy: disabled, `aria-busy`, and a spinner before the content — for \"uploading\". */\n pending?: boolean;\n /** Also accept files dropped ON the button (lenkbank's \"drop onto the button\").\n * Off by default: a button that silently takes drops is a surprise on a page that\n * has a real drop target elsewhere. */\n droppable?: boolean;\n /**\n * Show what was picked beside the button: the file's name, or \"3 files selected\"\n * (`filePicker.selected`) for several — a native `<input type=\"file\">` says this,\n * and a bare button that takes a file and says nothing leaves the user to trust it.\n * The read-out is a polite live region and describes the button, so it is heard\n * when it changes and again on focus. It holds the last ACCEPTED pick: a refused\n * file leaves it as it was. Off by default, so the button's layout is unchanged.\n */\n showFileName?: boolean;\n}\n\n/**\n * {@link useFilePicker} behind a {@link Button}: `variant` and every other button prop\n * pass through, and the ref is the `<button>` (so `ref.current.click()` opens the\n * picker from elsewhere too).\n *\n * ```tsx\n * <FileButton accept=\"image/*,application/pdf\" capture=\"environment\" variant=\"secondary\"\n * maxSize={10_000_000} onFiles={([f]) => upload(f)} onReject={([r]) => setError(r.message)}>\n * <Camera aria-hidden /> Photograph receipt\n * </FileButton>\n * ```\n */\nexport const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(function FileButton(\n {\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onPick,\n onReject,\n labels,\n pending,\n droppable,\n showFileName = false,\n disabled,\n children,\n className,\n onClick,\n onDragEnter,\n onDragOver,\n onDragLeave,\n onDrop,\n ...rest\n },\n ref,\n) {\n const inert = Boolean(disabled || pending);\n const [picked, setPicked] = useState<readonly File[]>([]);\n const pickedId = useId();\n const pickerLabels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labels);\n const picker = useFilePicker({\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles: showFileName\n ? (files) => {\n setPicked(files);\n onFiles?.(files);\n }\n : onFiles,\n onPick,\n onReject,\n labels,\n disabled: inert,\n });\n const pickedText =\n picked.length === 0 ? \"\" : picked.length === 1 ? picked[0].name : pickerLabels.selected(picked.length, picked[0].name);\n const [dragOver, setDragOver] = useState(false);\n\n const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes(\"Files\");\n // Only when `droppable`: otherwise the caller's own handlers are passed untouched.\n const dropHandlers = droppable\n ? {\n onDragEnter: (e: DragEvent<HTMLButtonElement>) => {\n onDragEnter?.(e);\n if (!hasFiles(e)) return;\n e.preventDefault();\n if (!inert) setDragOver(true);\n },\n onDragOver: (e: DragEvent<HTMLButtonElement>) => {\n onDragOver?.(e);\n if (!hasFiles(e)) return;\n // Always cancel for a file drag, busy or not: an uncancelled drop makes the\n // browser NAVIGATE to the file, which loses the page.\n e.preventDefault();\n e.dataTransfer.dropEffect = inert ? \"none\" : \"copy\";\n },\n onDragLeave: (e: DragEvent<HTMLButtonElement>) => {\n onDragLeave?.(e);\n if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;\n setDragOver(false);\n },\n onDrop: (e: DragEvent<HTMLButtonElement>) => {\n onDrop?.(e);\n if (!hasFiles(e)) return;\n e.preventDefault();\n setDragOver(false);\n picker.take(e.dataTransfer.files);\n },\n }\n : { onDragEnter, onDragOver, onDragLeave, onDrop };\n\n // `Button` is a plain function component; under React 19 `ref` is an ordinary prop\n // and reaches the `<button>` through its `...rest`. Its props type just does not\n // declare it, hence the widening here rather than a second button implementation.\n const refProp = { ref } as { ref?: Ref<HTMLButtonElement> };\n\n const button = (\n <Button\n {...rest}\n {...refProp}\n {...dropHandlers}\n type=\"button\"\n disabled={inert}\n aria-busy={pending || undefined}\n aria-describedby={\n showFileName && pickedText\n ? [rest[\"aria-describedby\"], pickedId].filter(Boolean).join(\" \")\n : rest[\"aria-describedby\"]\n }\n data-drag-over={dragOver || undefined}\n className={cn(\n // A live drag gets the focus ring's colour as a ring: \"let go here\", in the\n // same vocabulary the button already uses for \"you are here\".\n dragOver && \"ring-2 ring-[var(--brand)]\",\n // Beside a long file name it is the NAME that gives way, never the button.\n showFileName && \"shrink-0\",\n className,\n )}\n onClick={(e) => {\n onClick?.(e);\n if (!e.defaultPrevented) picker.open();\n }}\n >\n {/* `label={null}`: the spinner is decoration here — `aria-busy` says the same\n thing on the button, and a spoken \"Loading\" would run into its name. */}\n {pending && <Spinner label={null} className=\"size-4\" />}\n {children}\n </Button>\n );\n\n if (!showFileName) {\n return (\n <>\n {button}\n {picker.element}\n </>\n );\n }\n return (\n // One inline row, so the name sits beside the button wherever the button sits; the\n // name truncates rather than pushing the row wider, and the full list is in the\n // kit's own Tooltip — not a native `title` (0.16.0 shipped one: the one-tooltip rule\n // since keksdose dev#523, and a title never opens on touch). Lazy, so the closed\n // bubble adds nothing to the read-out's text.\n <span className=\"inline-flex max-w-full min-w-0 items-center gap-2\">\n {button}\n {/* The live region is the OUTER span and never remounts: a Tooltip with an empty\n label renders no wrapper, so wrapping the region itself would swap its element\n the moment the first file arrives — and a fresh live region may not announce. */}\n <span\n id={pickedId}\n aria-live=\"polite\"\n data-slot=\"file-button-name\"\n className=\"flex min-w-0 text-sm text-[var(--text-secondary)]\"\n >\n <Tooltip label={picked.map((f) => f.name).join(\", \")} lazy className=\"min-w-0\">\n <span className=\"block truncate\">{pickedText}</span>\n </Tooltip>\n </span>\n {picker.element}\n </span>\n );\n});\nFileButton.displayName = \"FileButton\";\n"],"mappings":"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","names":["FileButton"]}
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ComponentPropsWithoutRef, ReactNode } from 'react';
3
- import { ag as FileRejection, bf as FilePickHandler, af as FilePickerLabels } from '../kit-labels-DE--LmCk.js';
3
+ import { ah as FileRejection, bh as FilePickHandler, ag as FilePickerLabels } from '../kit-labels-C4uB_zmg.js';
4
4
  import './data-table-labels.js';
5
5
  import './mini-calendar.js';
6
6
  import './calendar-heatmap.js';
@@ -1,6 +1,6 @@
1
1
  import 'react';
2
2
  import 'lucide-react';
3
- export { P as DEFAULT_FORM_ACTIONS_LABELS, al as FormActions, am as FormActionsAlign, an as FormActionsDestructive, ao as FormActionsLabels, ap as FormActionsPlacement, aq as FormActionsProps, ar as FormActionsStickyWithin, as as FormActionsSubmitProps } from '../kit-labels-DE--LmCk.js';
3
+ export { Q as DEFAULT_FORM_ACTIONS_LABELS, am as FormActions, an as FormActionsAlign, ao as FormActionsDestructive, ap as FormActionsLabels, aq as FormActionsPlacement, ar as FormActionsProps, as as FormActionsStickyWithin, at as FormActionsSubmitProps, aN as ResponsiveFormActionsPlacement } from '../kit-labels-C4uB_zmg.js';
4
4
  import './data-table-labels.js';
5
5
  import './mini-calendar.js';
6
6
  import './calendar-heatmap.js';
@@ -3,11 +3,35 @@ import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { isValidElement } from "react";
4
4
  import { cn } from "../lib/cn.js";
5
5
  import { useKitLabels } from "../i18n/kit-labels.js";
6
+ import { useMediaQuery } from "../hooks/use-media-query.js";
6
7
  import { Button, Spinner } from "./ui.js";
7
8
  const DEFAULT_FORM_ACTIONS_LABELS = {
8
9
  save: "Save",
9
10
  cancel: "Cancel"
10
11
  };
12
+ const BREAKPOINTS = [
13
+ ["xl", "(min-width: 1280px)"],
14
+ ["lg", "(min-width: 1024px)"],
15
+ ["md", "(min-width: 768px)"],
16
+ ["sm", "(min-width: 640px)"]
17
+ ];
18
+ function useResolvedPlacement(placement) {
19
+ const matches = {
20
+ xl: useMediaQuery(BREAKPOINTS[0][1], false),
21
+ lg: useMediaQuery(BREAKPOINTS[1][1], false),
22
+ md: useMediaQuery(BREAKPOINTS[2][1], false),
23
+ sm: useMediaQuery(BREAKPOINTS[3][1], false)
24
+ };
25
+ if (typeof placement === "string") return placement;
26
+ for (const [key] of BREAKPOINTS) {
27
+ const p = placement[key];
28
+ if (matches[key] && p !== void 0) return p;
29
+ }
30
+ return placement.base;
31
+ }
32
+ function cssLength(value) {
33
+ return typeof value === "number" ? `${value}px` : value;
34
+ }
11
35
  const ALIGN_CLASS = {
12
36
  start: "justify-start",
13
37
  center: "justify-center",
@@ -37,8 +61,9 @@ function FormActions({
37
61
  destructive,
38
62
  start: startSlot,
39
63
  align,
40
- placement = "inline",
64
+ placement: placementProp = "inline",
41
65
  stickyWithin = "viewport",
66
+ bleed,
42
67
  form,
43
68
  children,
44
69
  className,
@@ -46,6 +71,8 @@ function FormActions({
46
71
  ...rest
47
72
  }) {
48
73
  const labels = useKitLabels("form", DEFAULT_FORM_ACTIONS_LABELS);
74
+ const placement = useResolvedPlacement(placementProp);
75
+ const bleedLength = bleed === void 0 || bleed === "" ? void 0 : cssLength(bleed);
49
76
  const hasDestructive = destructive !== void 0 && destructive !== null && destructive !== false;
50
77
  const hasStart = startSlot !== void 0 && startSlot !== null && startSlot !== false;
51
78
  const justify = align ?? (hasDestructive || hasStart ? "between" : "end");
@@ -78,6 +105,10 @@ function FormActions({
78
105
  // container the nav is not underneath: `--app-nav-h` is the document's,
79
106
  // and would hold the row that far above the container's own edge.
80
107
  bottom: stickyWithin === "container" ? 0 : "max(0px, calc(var(--app-nav-h, 0px) - 1px))",
108
+ ...bleedLength !== void 0 && {
109
+ marginInline: `calc(-1 * ${bleedLength})`,
110
+ paddingInline: bleedLength
111
+ },
81
112
  ...style
82
113
  } : style,
83
114
  className: cn("flex flex-wrap items-center gap-2", ALIGN_CLASS[justify], PLACEMENT_CLASS[placement], className),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/form-actions.tsx"],"sourcesContent":["import { isValidElement } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { Button, Spinner, type ButtonProps, type ButtonVariant } from \"./ui\";\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\nexport const DEFAULT_FORM_ACTIONS_LABELS: FormActionsLabels = {\n save: \"Save\",\n cancel: \"Cancel\",\n};\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 * 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\"\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 /** 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}. Default `inline`. */\n placement?: FormActionsPlacement;\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 /** Extra actions, placed before Cancel. */\n children?: ReactNode;\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 submitVariant = \"brand\",\n destructive,\n start: startSlot,\n align,\n placement = \"inline\",\n stickyWithin = \"viewport\",\n form,\n children,\n className,\n style,\n ...rest\n}: FormActionsProps) {\n const labels = useKitLabels(\"form\", DEFAULT_FORM_ACTIONS_LABELS);\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 >\n {destructive.label}\n </Button>\n ) : (\n destructive\n );\n const saveLabel = submitLabel ?? labels.save;\n\n return (\n <div\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 ...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 type={onSubmit ? \"button\" : \"submit\"}\n form={form}\n onClick={onSubmit}\n variant={submitVariant}\n disabled={pending || submitDisabled}\n disabledReason={pending ? undefined : submitDisabledReason}\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 </Button>\n </div>\n </div>\n );\n}\n"],"mappings":";AA6MI,cAuCI,YAvCJ;AA7MJ,SAAS,sBAAsB;AAG/B,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAC7B,SAAS,QAAQ,eAAqD;AAQ/D,MAAM,8BAAiD;AAAA,EAC5D,MAAM;AAAA,EACN,QAAQ;AACV;AA2CA,MAAM,cAAgD;AAAA,EACpD,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,KAAK;AAAA,EACL,SAAS;AACX;AAEA,MAAM,kBAAwD;AAAA,EAC5D,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AACV;AAwFA,SAAS,kBAAkB,OAAiD;AAC1E,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,eAAe,KAAK,KAAK,aAAa;AAC/F;AAiBO,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ;AAAA,EACA,UAAU;AAAA,EACV,iBAAiB;AAAA,EACjB;AAAA,EACA,gBAAgB;AAAA,EAChB;AAAA,EACA,OAAO;AAAA,EACP;AAAA,EACA,YAAY;AAAA,EACZ,eAAe;AAAA,EACf;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAqB;AACnB,QAAM,SAAS,aAAa,QAAQ,2BAA2B;AAC/D,QAAM,iBAAiB,gBAAgB,UAAa,gBAAgB,QAAQ,gBAAgB;AAC5F,QAAM,WAAW,cAAc,UAAa,cAAc,QAAQ,cAAc;AAChF,QAAM,UAAU,UAAU,kBAAkB,WAAW,YAAY;AACnE,QAAM,kBAAkB,CAAC,iBAAiB,OAAO,kBAAkB,WAAW,IAC5E;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,SAAQ;AAAA,MACR,MAAK;AAAA,MACL,SAAS,YAAY;AAAA,MACrB,UAAU,YAAY;AAAA,MACtB,gBAAgB,YAAY;AAAA,MAE3B,sBAAY;AAAA;AAAA,EACf,IAEA;AAEF,QAAM,YAAY,eAAe,OAAO;AAExC,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,kBAAgB;AAAA,MAChB,sBAAoB,cAAc,WAAW,eAAe;AAAA,MAC3D,GAAG;AAAA,MACJ,OACE,cAAc,WACV;AAAA,QACE,eAAe;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAOf,QAAQ,iBAAiB,cAAc,IAAI;AAAA,QAC3C,GAAG;AAAA,MACL,IACA;AAAA,MAEN,WAAW,GAAG,qCAAqC,YAAY,OAAO,GAAG,gBAAgB,SAAS,GAAG,SAAS;AAAA,MAE5G;AAAA,2BAAkB,aAClB,qBAAC,SAAI,WAAU,qDACZ;AAAA;AAAA,UACA,YAAY;AAAA,WACf;AAAA,QAEF,qBAAC,SAAI,WAAU,qCACZ;AAAA;AAAA,UACA,YACC,oBAAC,UAAO,MAAK,UAAS,SAAQ,aAAY,SAAS,UAChD,yBAAe,OAAO,QACzB;AAAA,UAEF;AAAA,YAAC;AAAA;AAAA,cACE,GAAG;AAAA,cACJ,MAAM,WAAW,WAAW;AAAA,cAC5B;AAAA,cACA,SAAS;AAAA,cACT,SAAS;AAAA,cACT,UAAU,WAAW;AAAA,cACrB,gBAAgB,UAAU,SAAY;AAAA,cACtC,aAAW,WAAW;AAAA,cAErB;AAAA,0BACC,oBAAC,WAAQ,OAAO,MAAM,WAAU,UAAS,IAEzC,cAAc,oBAAC,cAAW,eAAW,MAAC,WAAU,mBAAkB;AAAA,gBAEnE,WAAW,iBAAiB,SAAY,eAAe;AAAA;AAAA;AAAA,UAC1D;AAAA,WACF;AAAA;AAAA;AAAA,EACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/form-actions.tsx"],"sourcesContent":["import { isValidElement } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } 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 { Button, Spinner, type ButtonProps, type ButtonVariant } from \"./ui\";\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\nexport const DEFAULT_FORM_ACTIONS_LABELS: FormActionsLabels = {\n save: \"Save\",\n cancel: \"Cancel\",\n};\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.\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\"\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 /** 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.\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 /** Extra actions, placed before Cancel. */\n children?: ReactNode;\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 submitVariant = \"brand\",\n destructive,\n start: startSlot,\n align,\n placement: placementProp = \"inline\",\n stickyWithin = \"viewport\",\n bleed,\n form,\n children,\n className,\n style,\n ...rest\n}: FormActionsProps) {\n const labels = useKitLabels(\"form\", DEFAULT_FORM_ACTIONS_LABELS);\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 >\n {destructive.label}\n </Button>\n ) : (\n destructive\n );\n const saveLabel = submitLabel ?? labels.save;\n\n return (\n <div\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 type={onSubmit ? \"button\" : \"submit\"}\n form={form}\n onClick={onSubmit}\n variant={submitVariant}\n disabled={pending || submitDisabled}\n disabledReason={pending ? undefined : submitDisabledReason}\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 </Button>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -38,8 +38,10 @@ interface LineItemCellContext<T> {
38
38
  * `floating`: the field shows its own floating label ({@link LineItemCellContext.fieldLabel}),
39
39
  * so the list draws no header for it. */
40
40
  type LineItemsFieldLabels = "aria" | "floating";
41
- /** The colour of a {@link LineItemsSummary}'s value. */
42
- type LineItemsSummaryTone = "neutral" | "success" | "danger" | "muted";
41
+ /** The colour of a {@link LineItemsSummary}'s value — a verdict (`success` /
42
+ * `danger`), quiet (`muted`), or the money pair (`income` / `expense`, the colours
43
+ * `SignedAmount` draws a figure in) for a remainder that reads as the row it settles. */
44
+ type LineItemsSummaryTone = "neutral" | "success" | "danger" | "muted" | "income" | "expense";
43
45
  /**
44
46
  * A line under the lines that says where they stand — a split's remainder ("Left to
45
47
  * assign CHF 0.00", green at zero, red otherwise, muted while the total is not known
@@ -49,16 +51,30 @@ interface LineItemsSummary {
49
51
  label: ReactNode;
50
52
  value?: ReactNode;
51
53
  /** The value's colour. Default `neutral`. The label stays neutral: the colour is a
52
- * second signal, never the only one — the value and label carry the meaning. */
54
+ * second signal, never the only one — the value and label carry the meaning.
55
+ * With no `value` the line is a state on its own ("Waiting for the total"), and
56
+ * the tone colours the label instead — `muted` quiets the whole line. */
53
57
  tone?: LineItemsSummaryTone;
58
+ /** Beside the value: a chip or badge that names the state ("Balanced" next to a
59
+ * journal entry's zero difference). */
60
+ status?: ReactNode;
54
61
  /** A small button at the end of the line. Disabled along with the list's
55
- * `disabled`. */
62
+ * `disabled`. `variant`: `secondary` (default) is a boxed button; `link` a muted
63
+ * text link, for an action that is an aside rather than the way to settle it. */
56
64
  action?: {
57
65
  label: ReactNode;
58
66
  onClick: () => void;
59
67
  disabled?: boolean;
68
+ variant?: "secondary" | "link";
60
69
  };
61
70
  }
71
+ /** Where a row's remove button sits. `row` (default): stacked, a row of the card to
72
+ * itself (paired, the whole width); wide, its own track. `inline`: stacked, beside
73
+ * the row's last field ("category + amount" over "memo + delete"); wide, unchanged. */
74
+ type LineItemsRemovePlacement = "row" | "inline";
75
+ /** How the remove button sits in the height of a wide row: at its top, centred, or at
76
+ * its foot. */
77
+ type LineItemsRemoveAlign = "start" | "center" | "end";
62
78
  interface LineItemsColumn<T> {
63
79
  /** Stable id: the React key, and the key into `totals`. */
64
80
  key: string;
@@ -129,6 +145,14 @@ interface LineItemsProps<T> extends Omit<ComponentPropsWithoutRef<"div">, "child
129
145
  * them up (a column's `narrowSpan={2}` takes a whole row), once the pane is wide
130
146
  * enough for two fields (18rem); narrower still, one. Default `1`. */
131
147
  narrowColumns?: 1 | 2;
148
+ /** Stacked, where the remove button goes: a row of its own (`row`, default) or
149
+ * beside the last field (`inline`). See {@link LineItemsRemovePlacement}. */
150
+ removePlacement?: LineItemsRemovePlacement;
151
+ /** On the wide grid, the remove button's place in the row's height. Default `start`
152
+ * — or `center` when a column floats its label (`fieldLabels="floating"`): a
153
+ * floating field is taller than the button, and centred is level with its control.
154
+ * A field that shows a message under itself grows the row; pin `start` then. */
155
+ removeAlign?: LineItemsRemoveAlign;
132
156
  /** Disables add and remove (the fields are the caller's to disable). */
133
157
  disabled?: boolean;
134
158
  labels?: Partial<LineItemsLabels>;
@@ -160,6 +184,6 @@ interface LineItemsProps<T> extends Omit<ComponentPropsWithoutRef<"div">, "child
160
184
  * the row that took its place (else the add button). Ctrl/⌘+Enter in any row adds a
161
185
  * row.
162
186
  */
163
- declare function LineItems<T>({ items, columns, getKey, onAdd, onRemove, minItems, maxItems, confirmRemove, addLabel, rowLabel, totals, totalsLabel, empty, error, summary, fieldLabels, narrowColumns, disabled, labels: labelsProp, className, style, ...rest }: LineItemsProps<T>): react.JSX.Element;
187
+ declare function LineItems<T>({ items, columns, getKey, onAdd, onRemove, minItems, maxItems, confirmRemove, addLabel, rowLabel, totals, totalsLabel, empty, error, summary, fieldLabels, narrowColumns, removePlacement, removeAlign: removeAlignProp, disabled, labels: labelsProp, className, style, ...rest }: LineItemsProps<T>): react.JSX.Element;
164
188
 
165
- export { DEFAULT_LINE_ITEMS_LABELS, type LineItemCellContext, LineItems, type LineItemsColumn, type LineItemsFieldLabels, type LineItemsLabels, type LineItemsProps, type LineItemsSummary, type LineItemsSummaryTone };
189
+ export { DEFAULT_LINE_ITEMS_LABELS, type LineItemCellContext, LineItems, type LineItemsColumn, type LineItemsFieldLabels, type LineItemsLabels, type LineItemsProps, type LineItemsRemoveAlign, type LineItemsRemovePlacement, type LineItemsSummary, type LineItemsSummaryTone };