@eifi1/ui-kit 0.9.0 → 0.11.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 (243) hide show
  1. package/README.md +37 -23
  2. package/dist/components/alert-banner.d.ts +57 -5
  3. package/dist/components/alert-banner.js +63 -17
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +6 -0
  6. package/dist/components/breadcrumbs.d.ts +60 -0
  7. package/dist/components/breadcrumbs.js +88 -0
  8. package/dist/components/breadcrumbs.js.map +1 -0
  9. package/dist/components/bulk-action-bar.d.ts +112 -0
  10. package/dist/components/bulk-action-bar.js +196 -0
  11. package/dist/components/bulk-action-bar.js.map +1 -0
  12. package/dist/components/button-group.d.ts +58 -3
  13. package/dist/components/button-group.js +56 -5
  14. package/dist/components/button-group.js.map +1 -1
  15. package/dist/components/calculator.d.ts +6 -0
  16. package/dist/components/calendar-heatmap.d.ts +124 -0
  17. package/dist/components/calendar-heatmap.js +295 -0
  18. package/dist/components/calendar-heatmap.js.map +1 -0
  19. package/dist/components/chip.d.ts +31 -5
  20. package/dist/components/chip.js +85 -5
  21. package/dist/components/chip.js.map +1 -1
  22. package/dist/components/choice-card.d.ts +34 -2
  23. package/dist/components/choice-card.js +49 -0
  24. package/dist/components/choice-card.js.map +1 -1
  25. package/dist/components/copy-button.d.ts +14 -4
  26. package/dist/components/copy-button.js +17 -4
  27. package/dist/components/copy-button.js.map +1 -1
  28. package/dist/components/data-table.js +2 -0
  29. package/dist/components/data-table.js.map +1 -1
  30. package/dist/components/date-picker.d.ts +22 -2
  31. package/dist/components/date-picker.js +11 -3
  32. package/dist/components/date-picker.js.map +1 -1
  33. package/dist/components/description-list.d.ts +43 -6
  34. package/dist/components/description-list.js +91 -11
  35. package/dist/components/description-list.js.map +1 -1
  36. package/dist/components/dialog-frame.d.ts +7 -0
  37. package/dist/components/dialog-frame.js.map +1 -1
  38. package/dist/components/disclosure.d.ts +57 -2
  39. package/dist/components/disclosure.js +24 -12
  40. package/dist/components/disclosure.js.map +1 -1
  41. package/dist/components/field.d.ts +43 -0
  42. package/dist/components/field.js +52 -0
  43. package/dist/components/field.js.map +1 -0
  44. package/dist/components/file-button.d.ts +1 -0
  45. package/dist/components/file-dropzone.d.ts +4 -4
  46. package/dist/components/file-dropzone.js +2 -1
  47. package/dist/components/file-dropzone.js.map +1 -1
  48. package/dist/components/floating-panel.d.ts +147 -4
  49. package/dist/components/floating-panel.js +188 -28
  50. package/dist/components/floating-panel.js.map +1 -1
  51. package/dist/components/full-bleed-dialog.d.ts +23 -4
  52. package/dist/components/full-bleed-dialog.js +11 -2
  53. package/dist/components/full-bleed-dialog.js.map +1 -1
  54. package/dist/components/list.d.ts +187 -0
  55. package/dist/components/list.js +221 -0
  56. package/dist/components/list.js.map +1 -0
  57. package/dist/components/menu-item.d.ts +109 -0
  58. package/dist/components/menu-item.js +88 -0
  59. package/dist/components/menu-item.js.map +1 -0
  60. package/dist/components/mini-calendar.d.ts +70 -5
  61. package/dist/components/mini-calendar.js +166 -62
  62. package/dist/components/mini-calendar.js.map +1 -1
  63. package/dist/components/modal.d.ts +23 -1
  64. package/dist/components/modal.js +36 -10
  65. package/dist/components/modal.js.map +1 -1
  66. package/dist/components/nav-pills.d.ts +66 -0
  67. package/dist/components/nav-pills.js +65 -0
  68. package/dist/components/nav-pills.js.map +1 -0
  69. package/dist/components/number-field.d.ts +6 -0
  70. package/dist/components/number-input.d.ts +6 -0
  71. package/dist/components/numpad-sheet.d.ts +6 -0
  72. package/dist/components/page-contents.js +1 -2
  73. package/dist/components/page-contents.js.map +1 -1
  74. package/dist/components/page-header.d.ts +37 -0
  75. package/dist/components/page-header.js +36 -0
  76. package/dist/components/page-header.js.map +1 -0
  77. package/dist/components/progress-bar.d.ts +31 -2
  78. package/dist/components/progress-bar.js +73 -7
  79. package/dist/components/progress-bar.js.map +1 -1
  80. package/dist/components/series-chart-labels.d.ts +3 -0
  81. package/dist/components/series-chart-labels.js +2 -1
  82. package/dist/components/series-chart-labels.js.map +1 -1
  83. package/dist/components/series-chart.d.ts +32 -0
  84. package/dist/components/series-chart.js +141 -4
  85. package/dist/components/series-chart.js.map +1 -1
  86. package/dist/components/settings-fields.d.ts +1 -0
  87. package/dist/components/status-dot.d.ts +53 -0
  88. package/dist/components/status-dot.js +57 -0
  89. package/dist/components/status-dot.js.map +1 -0
  90. package/dist/components/table.d.ts +78 -13
  91. package/dist/components/table.js +84 -11
  92. package/dist/components/table.js.map +1 -1
  93. package/dist/components/text.d.ts +70 -0
  94. package/dist/components/text.js +24 -0
  95. package/dist/components/text.js.map +1 -0
  96. package/dist/components/time-input.d.ts +1 -0
  97. package/dist/components/toast.d.ts +167 -0
  98. package/dist/components/toast.js +226 -0
  99. package/dist/components/toast.js.map +1 -0
  100. package/dist/components/toggle-group.d.ts +44 -1
  101. package/dist/components/toggle-group.js +73 -4
  102. package/dist/components/toggle-group.js.map +1 -1
  103. package/dist/components/tooltip.d.ts +46 -15
  104. package/dist/components/tooltip.js +87 -47
  105. package/dist/components/tooltip.js.map +1 -1
  106. package/dist/components/ui.d.ts +158 -7
  107. package/dist/components/ui.js +191 -10
  108. package/dist/components/ui.js.map +1 -1
  109. package/dist/components/user-avatar.d.ts +25 -3
  110. package/dist/components/user-avatar.js +32 -3
  111. package/dist/components/user-avatar.js.map +1 -1
  112. package/dist/hooks/use-close-transition.d.ts +7 -1
  113. package/dist/hooks/use-close-transition.js +5 -2
  114. package/dist/hooks/use-close-transition.js.map +1 -1
  115. package/dist/hooks/use-copy-to-clipboard.js +1 -1
  116. package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
  117. package/dist/hooks/use-file-drop.d.ts +1 -0
  118. package/dist/i18n/defaults.d.ts +6 -0
  119. package/dist/i18n/defaults.js +11 -1
  120. package/dist/i18n/defaults.js.map +1 -1
  121. package/dist/i18n/kit-labels.d.ts +11 -0
  122. package/dist/i18n/kit-labels.js.map +1 -1
  123. package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
  124. package/dist/i18n/locales/de-CH.d.ts +6 -0
  125. package/dist/i18n/locales/de-informal.d.ts +6 -0
  126. package/dist/i18n/locales/de.d.ts +6 -0
  127. package/dist/i18n/locales/de.js +30 -2
  128. package/dist/i18n/locales/de.js.map +1 -1
  129. package/dist/i18n/locales/es.d.ts +6 -0
  130. package/dist/i18n/locales/es.js +30 -2
  131. package/dist/i18n/locales/es.js.map +1 -1
  132. package/dist/i18n/locales/fr.d.ts +6 -0
  133. package/dist/i18n/locales/fr.js +30 -2
  134. package/dist/i18n/locales/fr.js.map +1 -1
  135. package/dist/i18n/locales/hu.d.ts +6 -0
  136. package/dist/i18n/locales/hu.js +30 -2
  137. package/dist/i18n/locales/hu.js.map +1 -1
  138. package/dist/i18n/locales/it.d.ts +6 -0
  139. package/dist/i18n/locales/it.js +30 -2
  140. package/dist/i18n/locales/it.js.map +1 -1
  141. package/dist/i18n/locales/zh.d.ts +6 -0
  142. package/dist/i18n/locales/zh.js +30 -2
  143. package/dist/i18n/locales/zh.js.map +1 -1
  144. package/dist/index.d.ts +27 -15
  145. package/dist/index.js +15 -0
  146. package/dist/index.js.map +1 -1
  147. package/dist/lib/clipping.d.ts +25 -0
  148. package/dist/lib/clipping.js +17 -0
  149. package/dist/lib/clipping.js.map +1 -0
  150. package/dist/lib/dates.d.ts +17 -1
  151. package/dist/lib/dates.js +19 -0
  152. package/dist/lib/dates.js.map +1 -1
  153. package/dist/rhf/form.d.ts +1 -0
  154. package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
  155. package/dist/rhf/use-rhf-wizard-step.js +38 -0
  156. package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
  157. package/dist/rhf.d.ts +2 -0
  158. package/dist/rhf.js +1 -0
  159. package/dist/rhf.js.map +1 -1
  160. package/dist/search/command-palette.d.ts +19 -2
  161. package/dist/search/command-palette.js +16 -5
  162. package/dist/search/command-palette.js.map +1 -1
  163. package/dist/search/global-search.d.ts +26 -1
  164. package/dist/search/global-search.js +29 -13
  165. package/dist/search/global-search.js.map +1 -1
  166. package/dist/search.d.ts +1 -1
  167. package/dist/shell/topbar-action-menu.d.ts +73 -3
  168. package/dist/shell/topbar-action-menu.js +100 -27
  169. package/dist/shell/topbar-action-menu.js.map +1 -1
  170. package/dist/shell.d.ts +2 -1
  171. package/dist/wizard/stepper-nav.d.ts +1 -0
  172. package/dist/wizard/stepper-nav.js +1 -1
  173. package/dist/wizard/stepper-nav.js.map +1 -1
  174. package/dist/wizard/types.d.ts +2 -2
  175. package/dist/wizard/types.js.map +1 -1
  176. package/dist/wizard/use-wizard.js +1 -1
  177. package/dist/wizard/use-wizard.js.map +1 -1
  178. package/dist/wizard/wizard-context.d.ts +7 -1
  179. package/dist/wizard/wizard-context.js +4 -0
  180. package/dist/wizard/wizard-context.js.map +1 -1
  181. package/dist/wizard/wizard-summary.js +3 -3
  182. package/dist/wizard/wizard-summary.js.map +1 -1
  183. package/dist/wizard.d.ts +2 -1
  184. package/package.json +16 -12
  185. package/src/components/alert-banner.tsx +149 -21
  186. package/src/components/breadcrumbs.tsx +168 -0
  187. package/src/components/bulk-action-bar.tsx +362 -0
  188. package/src/components/button-group.tsx +124 -4
  189. package/src/components/calendar-heatmap.tsx +504 -0
  190. package/src/components/chip.tsx +128 -7
  191. package/src/components/choice-card.tsx +97 -1
  192. package/src/components/copy-button.tsx +29 -7
  193. package/src/components/data-table.tsx +7 -0
  194. package/src/components/date-picker.tsx +49 -1
  195. package/src/components/description-list.tsx +168 -15
  196. package/src/components/dialog-frame.tsx +7 -0
  197. package/src/components/disclosure.tsx +93 -21
  198. package/src/components/field.tsx +137 -0
  199. package/src/components/file-dropzone.tsx +9 -10
  200. package/src/components/floating-panel.tsx +366 -15
  201. package/src/components/full-bleed-dialog.tsx +42 -5
  202. package/src/components/list.tsx +443 -0
  203. package/src/components/menu-item.tsx +235 -0
  204. package/src/components/mini-calendar.tsx +245 -54
  205. package/src/components/modal.tsx +80 -17
  206. package/src/components/nav-pills.tsx +145 -0
  207. package/src/components/page-contents.tsx +4 -4
  208. package/src/components/page-header.tsx +68 -0
  209. package/src/components/progress-bar.tsx +129 -11
  210. package/src/components/series-chart-labels.ts +4 -0
  211. package/src/components/series-chart.tsx +274 -3
  212. package/src/components/status-dot.tsx +107 -0
  213. package/src/components/table.tsx +176 -17
  214. package/src/components/text.tsx +97 -0
  215. package/src/components/toast.tsx +441 -0
  216. package/src/components/toggle-group.tsx +128 -4
  217. package/src/components/tooltip.tsx +200 -100
  218. package/src/components/ui.tsx +381 -14
  219. package/src/components/user-avatar.tsx +57 -3
  220. package/src/hooks/use-close-transition.ts +14 -5
  221. package/src/hooks/use-copy-to-clipboard.ts +1 -1
  222. package/src/i18n/defaults.ts +10 -0
  223. package/src/i18n/kit-labels.tsx +10 -0
  224. package/src/i18n/locales/de.ts +29 -0
  225. package/src/i18n/locales/es.ts +29 -0
  226. package/src/i18n/locales/fr.ts +29 -0
  227. package/src/i18n/locales/hu.ts +29 -0
  228. package/src/i18n/locales/it.ts +29 -0
  229. package/src/i18n/locales/zh.ts +29 -0
  230. package/src/index.ts +31 -0
  231. package/src/lib/clipping.ts +34 -0
  232. package/src/lib/dates.ts +37 -0
  233. package/src/rhf/use-rhf-wizard-step.ts +113 -0
  234. package/src/rhf.ts +2 -0
  235. package/src/search/command-palette.tsx +42 -5
  236. package/src/search/global-search.tsx +59 -18
  237. package/src/shell/topbar-action-menu.tsx +239 -49
  238. package/src/wizard/stepper-nav.tsx +2 -1
  239. package/src/wizard/types.ts +2 -2
  240. package/src/wizard/use-wizard.ts +3 -3
  241. package/src/wizard/wizard-context.tsx +9 -0
  242. package/src/wizard/wizard-summary.tsx +14 -10
  243. package/tokens.css +97 -0
@@ -14,6 +14,7 @@ import {
14
14
  summariseRejections
15
15
  } from "./file-button.js";
16
16
  import { useDragTarget } from "../hooks/use-file-drop.js";
17
+ import { toast } from "./toast.js";
17
18
  function FileDropzone({
18
19
  file,
19
20
  onFileSelected,
@@ -90,7 +91,7 @@ function FileDropzone({
90
91
  if (rejected.length > 0) onReject?.(rejected);
91
92
  const message = stands ? summariseRejections(rejected, labels) : labels.rejectedPick(picked.length);
92
93
  if (feedback === "toast") {
93
- void import("sonner").then(({ toast }) => toast.error(message));
94
+ toast.error(message);
94
95
  } else {
95
96
  if (feedback === "inline") setError(message);
96
97
  alert.announce(message);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/file-dropzone.tsx"],"sourcesContent":["import { useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, MouseEvent, ReactNode } from \"react\";\nimport { Upload, X } from \"lucide-react\";\nimport { Button, IconButton, Spinner } from \"./ui\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport {\n DEFAULT_FILE_PICKER_LABELS,\n formatAccept,\n judgePick,\n screenFiles,\n summariseRejections,\n} from \"./file-button\";\nimport type { FilePickHandler, FilePickerLabels, FileRejection } from \"./file-button\";\nimport { useDragTarget } from \"../hooks/use-file-drop\";\n\n/**\n * Where a refused file's message goes.\n *\n * - `\"toast\"` — a `sonner` toast (the 0.5 behaviour, kept as the default for a caller\n * that handles nothing itself so no existing dropzone goes quiet on upgrade).\n * - `\"inline\"` — under the drop target, in the danger colour, tied to it with\n * `aria-describedby` (plus `data-invalid` for styling), and spoken through a live\n * region. Cleared by the next accepted pick.\n * - `\"none\"` — nothing visible; the caller shows it from `onReject` / `onInvalid`.\n * Still spoken, so a host rendering it should not ALSO make it `role=\"alert\"`.\n */\nexport type FileDropzoneRejectionFeedback = \"toast\" | \"inline\" | \"none\";\n\n/** What a custom {@link FileDropzoneProps.renderBody} is handed. */\nexport interface FileDropzoneState {\n /** A file drag is over the zone right now. */\n dragOver: boolean;\n disabled: boolean;\n busy: boolean;\n /** What is chosen: `files` in multiple mode, `[file]` or `[]` in single mode. */\n files: readonly File[];\n /** The inline refusal, when `rejectionFeedback=\"inline\"` has one to show. */\n error: string | null;\n /** Open the system picker — for a body that brings its own trigger (a second\n * \"Take photo\" button). Does nothing while `disabled` or `busy`. */\n open: () => void;\n /** The resolved `filePicker` strings, so a custom body speaks the same language. */\n labels: FilePickerLabels;\n}\n\n/**\n * `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.\n * The DOM event of that name belongs to form validation and takes a `FormEvent`; this\n * one takes the rejected `File`, and the kit's meaning is the one every caller already\n * writes. Everything else a `<div>` takes reaches the root — which carries\n * `role=\"group\"` named by `dropLabel`, so that is also where an `aria-label` or an\n * `aria-describedby` belongs.\n *\n * Two modes. SINGLE (the default, and all of 0.5): `file` + `onFileSelected`. MULTIPLE\n * (`multiple`): `files` + `onFilesSelected`. They are optional rather than a union so\n * `FileDropzoneProps` stays an interface a caller can extend; a single-mode pick also\n * reaches `onFilesSelected` as a one-element array, so one handler can serve both.\n */\nexport interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onInvalid\"> {\n /** Single mode: the chosen file, echoed with its size. */\n file?: File | null;\n /** Single mode: called with the accepted file. */\n onFileSelected?: (file: File) => void;\n /** Take several files per pick or drop. Without it a drop of several keeps the first. */\n multiple?: boolean;\n /** Multiple mode: the chosen files, listed in the zone. */\n files?: readonly File[];\n /** Every accepted file of one pick, in order. Never called with an empty array. */\n onFilesSelected?: (files: File[]) => void;\n /**\n * Show a remove button inside the zone once something is chosen (kastlan asked for\n * it: the only way to un-choose a file was to reload). Single mode: removes the file.\n * Multiple mode: a \"remove all\" button. The host clears its own state here — the\n * zone is controlled. Focus moves to the Browse button, and the removal is announced.\n */\n onClear?: () => void;\n /** Multiple mode: a remove button per listed file. */\n onRemove?: (file: File, index: number) => void;\n accept: string;\n /**\n * The caller's check. Optional since 0.6: without it the zone checks `accept` itself\n * (a drop ignores the dialog's filter). With it, `accept` is NOT re-checked — every\n * 0.5 caller's `isValid` is its own type check, and doubling it could refuse a file\n * the caller accepts (a `.zip` the OS labelled `application/octet-stream`).\n */\n isValid?: (file: File) => boolean;\n /** The message for an `isValid` refusal. Defaults to `labels.rejectedInvalid`. */\n invalidMessage?: string;\n /** Bytes; larger files are refused. */\n maxSize?: number;\n /** Multiple mode: files one pick may add. Pass what is LEFT for a running cap. */\n maxFiles?: number;\n /**\n * Called once per refused file. The 0.5 hook, kept as it was; {@link onReject}\n * carries the reason and the translated message as well.\n *\n * Its existence used to be what switched the toast off, and it still does (see\n * {@link rejectionFeedback}). The toast is a dynamic `import(\"sonner\")` on the\n * failure path only: `sonner` is an OPTIONAL peer, and a static import here once\n * broke `import { Button }` for every app without it (see\n * `optional-peer-imports.test.tsx`).\n */\n onInvalid?: (file: File) => void;\n /** Every refused file of one pick, with its reason and message. */\n onReject?: (rejections: FileRejection[]) => void;\n /** The whole pick at once, before any of the above — return `false` to refuse all\n * of it (see {@link FilePickHandler}). A refused pick selects nothing; its one\n * message (`labels.rejectedPick`) goes where `rejectionFeedback` says. */\n onPick?: FilePickHandler;\n /** See {@link FileDropzoneRejectionFeedback}. Defaults to `\"toast\"` when neither\n * `onInvalid` nor `onReject` is given, `\"none\"` when one is. */\n rejectionFeedback?: FileDropzoneRejectionFeedback;\n /** Per-instance overrides of the `filePicker` label namespace. */\n labels?: Partial<FilePickerLabels>;\n /** The dropzone's accessible name. A caller's own `aria-label` wins over it — see\n * the root element below. Optional since 0.8, like the three below: each falls back\n * to its `filePicker` string from `<UiKitProvider labels>`, then to English. They\n * were required, so every call site passed four strings through `t()` by hand, and a\n * zone nested in a kit component (the feedback form) had no call site to pass them. */\n dropLabel?: string;\n /** Default `filePicker.browse`. */\n browseLabel?: string;\n /** What the zone says while nothing is chosen. Default `filePicker.empty` /\n * `emptyMultiple`. */\n emptyLabel?: string;\n /** The second line, e.g. what may be dropped. Default `filePicker.hint(accept)`. */\n hint?: string;\n /**\n * Not taking files: no drop, no picker, no remove buttons; `aria-disabled` on the\n * group. keksdose locks its invoice zone while another form holds unsaved changes,\n * and hand-rolled the whole zone to be able to (invoice-upload.tsx, \"Locked\").\n */\n disabled?: boolean;\n /**\n * Working on what it was given — an upload, an OCR pass. Inert like `disabled`, plus\n * `aria-busy`, a spinner in place of the upload icon, and `filePicker.busy` as the\n * text. The other half of why keksdose's invoice zone was hand-rolled.\n */\n busy?: boolean;\n /**\n * Replace the zone's body — the icon, the text and the chosen-file list — with your\n * own, given the zone's state: keksdose's spinner-plus-\"uploading\", its multi-shot\n * hint (\"several photos are possible\"), a second trigger for the camera.\n *\n * The Browse button, the inline error and the live regions stay: Browse is the\n * keyboard path, and a body that forgot it would leave the zone unreachable without\n * a pointer. A click on a control inside the body does not also open the picker.\n */\n renderBody?: (state: FileDropzoneState) => ReactNode;\n /** Extra classes for the dropzone's root. */\n className?: string;\n}\n\n/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml) and\n * kastlan's document forms. Refusals go to `onReject`/`onInvalid` and to\n * `rejectionFeedback`; the chosen file(s) are echoed with their size, otherwise\n * `emptyLabel` + `hint` describe what to drop. For a trigger with no drop area, see\n * `FileButton`. */\nexport function FileDropzone({\n file,\n onFileSelected,\n multiple,\n files,\n onFilesSelected,\n onClear,\n onRemove,\n accept,\n isValid,\n invalidMessage,\n maxSize,\n maxFiles,\n onInvalid,\n onReject,\n onPick,\n rejectionFeedback,\n labels: labelsProp,\n dropLabel: dropLabelProp,\n browseLabel: browseLabelProp,\n emptyLabel: emptyLabelProp,\n hint: hintProp,\n disabled,\n busy,\n renderBody,\n className,\n \"aria-label\": ariaLabel,\n \"aria-describedby\": describedByProp,\n ...rest\n}: FileDropzoneProps) {\n const [error, setError] = useState<string | null>(null);\n const inert = Boolean(disabled || busy);\n // State rather than a ref: `open` is handed to `renderBody` during render, and a\n // function that reads a ref there is exactly what the refs rule cannot prove safe.\n const [fileInput, setFileInput] = useState<HTMLInputElement | null>(null);\n const browseRef = useRef<HTMLButtonElement>(null);\n const errorId = useId();\n // The size through the kit's `file.size` label (the provider's locale by default).\n // This was `${n / 1024} KB`: ASCII digits, an English unit, and a binary kilobyte\n // mislabelled as a decimal one, all in one template literal.\n const fileText = useKitFileLabels();\n const labels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labelsProp);\n // Two regions, two urgencies: a pick or a removal is news, a refusal is a failure.\n // The echoed file name is only text in the zone, and text changing somewhere on the\n // page is not announced by itself.\n const status = useAnnounce();\n const alert = useAnnounce({ politeness: \"assertive\" });\n const feedback: FileDropzoneRejectionFeedback =\n rejectionFeedback ?? (onInvalid || onReject ? \"none\" : \"toast\");\n const dropLabel = dropLabelProp ?? labels.dropzone;\n const browseLabel = browseLabelProp ?? labels.browse;\n const emptyLabel = emptyLabelProp ?? (multiple ? labels.emptyMultiple : labels.empty);\n const hint = hintProp ?? labels.hint(formatAccept(accept));\n\n const acceptFiles = (list: ArrayLike<File> | null | undefined) => {\n if (inert || !list || list.length === 0) return;\n const all = Array.from(list);\n const picked = multiple ? all : all.slice(0, 1);\n const { accepted, rejected } = screenFiles(\n picked,\n {\n accept: isValid ? undefined : accept,\n maxSize,\n maxFiles: multiple ? maxFiles : undefined,\n isValid,\n invalidMessage,\n },\n labels,\n fileText.size,\n );\n const stands = judgePick(onPick, accepted, rejected);\n if (stands && accepted.length > 0) {\n setError(null);\n if (multiple) {\n onFilesSelected?.(accepted);\n } else {\n onFileSelected?.(accepted[0]);\n onFilesSelected?.(accepted);\n }\n status.announce(labels.selected(accepted.length, accepted[0].name));\n }\n if (!stands || rejected.length > 0) {\n for (const r of rejected) onInvalid?.(r.file);\n if (rejected.length > 0) onReject?.(rejected);\n const message = stands\n ? summariseRejections(rejected, labels)\n : labels.rejectedPick(picked.length);\n if (feedback === \"toast\") {\n // sonner is an optional peer: imported here, on the failure path only, so an\n // app that never trips this never has to install it. Not awaited — nothing\n // downstream depends on the toast having appeared, and the handlers that\n // reach this are DOM events. sonner's own toaster is a live region, so this\n // path does not announce a second time.\n void import(\"sonner\").then(({ toast }) => toast.error(message));\n } else {\n if (feedback === \"inline\") setError(message);\n alert.announce(message);\n }\n }\n };\n\n // The remove buttons unmount with the file they removed, and focus would fall to\n // <body>. Browse is where the next action (pick again) starts.\n const afterRemoval = (message: string) => {\n browseRef.current?.focus();\n status.announce(message);\n };\n\n // The enter/leave counter lives in the shared hook: the boolean this was flickered\n // across every child of the zone in Safari, whose drag events carry no\n // `relatedTarget` to tell a move onto a child from a real leave.\n const { dropProps, isOver: dragOver } = useDragTarget<HTMLDivElement>({\n disabled: inert,\n onDropFiles: acceptFiles,\n });\n const open = () => {\n if (!inert) fileInput?.click();\n };\n\n const chosen: readonly File[] = multiple ? (files ?? []) : file ? [file] : [];\n const showError = feedback === \"inline\" && error !== null;\n const describedBy = cn(describedByProp, showError && errorId) || undefined;\n\n return (\n <>\n {/* A named group, NOT a `role=\"button\"`. It was one, with real buttons (Browse,\n the remove buttons) inside it: nested interactive controls, which ARIA forbids\n because a button's children are presentational — a screen reader either read\n the zone as one opaque button or skipped the inner ones, and the zone was an\n extra Tab stop that did what Browse does. Now the keyboard path is Browse, a\n real button; the zone keeps its drop target and its click-anywhere for a\n pointer, which is a convenience on top of Browse rather than the only way in. */}\n {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -- the click is a pointer shortcut for Browse, see above */}\n <div\n // `...rest` first: every handler below is the drop gesture itself.\n {...rest}\n {...dropProps}\n // Pointer only; every control inside stops its own click from reaching here\n // (and one in a custom body is skipped below). The keyboard equivalent is the\n // Browse button, so no key handler belongs on a group.\n onClick={(e) => {\n if (fromControl(e)) return;\n open();\n }}\n role=\"group\"\n // The DOM spelling wins over `dropLabel`, which stays the name for every caller\n // that passes no `aria-label` — i.e. all of them today. Standardising on\n // `aria-label` (audit §api-design) does not get to silently rename an existing\n // required prop.\n aria-label={ariaLabel ?? dropLabel}\n // No `aria-invalid`: ARIA does not allow it on `role=\"group\"`. The message is\n // reached through the description instead, and spoken when it appears.\n aria-describedby={describedBy}\n data-invalid={showError || undefined}\n aria-disabled={disabled || undefined}\n aria-busy={busy || undefined}\n data-drag-over={dragOver || undefined}\n className={cn(\n // `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,\n // which Tailwind implements as `position: absolute` — so without a positioned\n // ancestor its containing block is the INITIAL one, and it is laid out at its\n // own offset from the top of the document. Nothing looks wrong, because the\n // input is 1x1 and clipped; what breaks is the page height. On a long page\n // every escaped sr-only element extends `documentElement.scrollHeight` to its\n // own offset, which produces a second, whole-document scrollbar alongside the\n // app shell's own — one that scrolls past the end of the content into nothing.\n // Measured on the showcase: body 900px, document 47,919px.\n \"relative flex flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors\",\n // Three states out of two border tokens: the target rests on the plain\n // hairline, hover pulls it to `--border-strong`, and a live drag keeps that\n // border and adds the `--bg-active` wash on top — so \"let go here\" still reads\n // one step louder than \"you are over it\". A refusal shown inline takes the\n // danger border until the next good pick.\n // Inert zones keep the resting hairline and lose the hover pull: a border that\n // answers the pointer promises a drop the zone will refuse.\n disabled\n ? \"cursor-not-allowed border-[var(--border)] opacity-60\"\n : busy\n ? \"cursor-progress border-[var(--border)]\"\n : dragOver\n ? \"cursor-pointer border-[var(--border-strong)] bg-[var(--bg-active)]\"\n : showError\n ? \"cursor-pointer border-[var(--danger-border)]\"\n : \"cursor-pointer border-[var(--border)] hover:border-[var(--border-strong)]\",\n className,\n )}\n >\n {renderBody ? (\n renderBody({\n dragOver,\n disabled: Boolean(disabled),\n busy: Boolean(busy),\n files: chosen,\n error: showError ? error : null,\n open,\n labels,\n })\n ) : busy ? (\n <>\n {/* `label={null}`: the zone is `aria-busy` and the line below says the\n same in words; a spoken \"Loading\" would only run into it. */}\n <Spinner label={null} className=\"size-5\" />\n <div className=\"text-sm text-[var(--text-secondary)]\">{labels.busy}</div>\n </>\n ) : (\n <>\n <Upload aria-hidden className=\"size-5 text-[var(--text-muted)]\" />\n {multiple && chosen.length > 0 ? (\n <ul className=\"flex w-full max-w-sm flex-col gap-1 text-start\">\n {chosen.map((f, i) => (\n <li\n // Name + size + position: two files may share a name, and the index\n // alone would re-key every row below a removal.\n key={`${f.name}-${f.size}-${i}`}\n className=\"flex items-center gap-2 text-sm text-[var(--text-secondary)]\"\n >\n <span className=\"min-w-0 flex-1 truncate font-medium\">{f.name}</span>\n <span className=\"shrink-0 text-xs text-[var(--text-muted)]\">{fileText.size(f.size)}</span>\n {onRemove && (\n <IconButton\n type=\"button\"\n size=\"sm\"\n disabled={inert}\n aria-label={labels.remove(f.name)}\n onClick={(e) => {\n e.stopPropagation();\n onRemove(f, i);\n afterRemoval(labels.removed(f.name));\n }}\n >\n <X aria-hidden />\n </IconButton>\n )}\n </li>\n ))}\n </ul>\n ) : (\n <>\n <div className=\"flex items-center gap-1 text-sm text-[var(--text-secondary)]\">\n {/* `!multiple`: in multiple mode an empty `files` list is \"nothing chosen\",\n whatever a stray `file` prop says — it used to show that file's name. */}\n {!multiple && file ? <span className=\"font-medium\">{file.name}</span> : emptyLabel}\n {!multiple && file && onClear && (\n <IconButton\n type=\"button\"\n size=\"sm\"\n disabled={inert}\n aria-label={labels.remove(file.name)}\n onClick={(e) => {\n e.stopPropagation();\n onClear();\n afterRemoval(labels.removed(file.name));\n }}\n >\n <X aria-hidden />\n </IconButton>\n )}\n </div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {file && !multiple ? fileText.size(file.size) : hint}\n </div>\n </>\n )}\n </>\n )}\n <div className=\"flex flex-wrap items-center justify-center gap-2\">\n <Button\n ref={browseRef}\n type=\"button\"\n variant=\"secondary\"\n disabled={inert}\n onClick={(e) => {\n e.stopPropagation();\n open();\n }}\n >\n {browseLabel}\n </Button>\n {multiple && chosen.length > 0 && onClear && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n disabled={inert}\n onClick={(e) => {\n e.stopPropagation();\n onClear();\n afterRemoval(labels.cleared);\n }}\n >\n {labels.clearAll}\n </Button>\n )}\n </div>\n {showError && (\n <p id={errorId} className=\"text-xs text-[var(--danger)]\">\n {error}\n </p>\n )}\n <input\n ref={setFileInput}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n disabled={inert}\n onChange={(e) => {\n // Copy, THEN reset: `files` is a live view of the value, and without the\n // reset picking the same file a second time fires no `change` at all —\n // the retry after a failed upload silently did nothing (kastlan).\n const picked = Array.from(e.currentTarget.files ?? []);\n e.currentTarget.value = \"\";\n acceptFiles(picked);\n }}\n // Clicks are forwarded here programmatically; one reaching the zone's own\n // handler again would open the dialog a second time.\n onClick={(e) => e.stopPropagation()}\n className=\"sr-only\"\n />\n </div>\n {/* Outside the zone, where they have always been (the zone used to be a\n `role=\"button\"`, whose content is presentational). `sr-only-fixed`, so they\n need no positioned ancestor. */}\n <span {...status.regionProps} />\n <span {...alert.regionProps} />\n </>\n );\n}\n\n/** A click that landed on a control inside the zone (a custom body's own button, a\n * link in the hint) is that control's, not a request to open the picker. */\nfunction fromControl(e: MouseEvent<HTMLElement>): boolean {\n const target = e.target as Element | null;\n const control = target?.closest?.(\"button, a[href], input, select, textarea, label, [role='button']\");\n return Boolean(control && control !== e.currentTarget && e.currentTarget.contains(control));\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/file-dropzone.tsx"],"sourcesContent":["import { useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, MouseEvent, ReactNode } from \"react\";\nimport { Upload, X } from \"lucide-react\";\nimport { Button, IconButton, Spinner } from \"./ui\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport {\n DEFAULT_FILE_PICKER_LABELS,\n formatAccept,\n judgePick,\n screenFiles,\n summariseRejections,\n} from \"./file-button\";\nimport type { FilePickHandler, FilePickerLabels, FileRejection } from \"./file-button\";\nimport { useDragTarget } from \"../hooks/use-file-drop\";\nimport { toast } from \"./toast\";\n\n/**\n * Where a refused file's message goes.\n *\n * - `\"toast\"` — a `sonner` toast (the 0.5 behaviour, kept as the default for a caller\n * that handles nothing itself so no existing dropzone goes quiet on upgrade).\n * - `\"inline\"` — under the drop target, in the danger colour, tied to it with\n * `aria-describedby` (plus `data-invalid` for styling), and spoken through a live\n * region. Cleared by the next accepted pick.\n * - `\"none\"` — nothing visible; the caller shows it from `onReject` / `onInvalid`.\n * Still spoken, so a host rendering it should not ALSO make it `role=\"alert\"`.\n */\nexport type FileDropzoneRejectionFeedback = \"toast\" | \"inline\" | \"none\";\n\n/** What a custom {@link FileDropzoneProps.renderBody} is handed. */\nexport interface FileDropzoneState {\n /** A file drag is over the zone right now. */\n dragOver: boolean;\n disabled: boolean;\n busy: boolean;\n /** What is chosen: `files` in multiple mode, `[file]` or `[]` in single mode. */\n files: readonly File[];\n /** The inline refusal, when `rejectionFeedback=\"inline\"` has one to show. */\n error: string | null;\n /** Open the system picker — for a body that brings its own trigger (a second\n * \"Take photo\" button). Does nothing while `disabled` or `busy`. */\n open: () => void;\n /** The resolved `filePicker` strings, so a custom body speaks the same language. */\n labels: FilePickerLabels;\n}\n\n/**\n * `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.\n * The DOM event of that name belongs to form validation and takes a `FormEvent`; this\n * one takes the rejected `File`, and the kit's meaning is the one every caller already\n * writes. Everything else a `<div>` takes reaches the root — which carries\n * `role=\"group\"` named by `dropLabel`, so that is also where an `aria-label` or an\n * `aria-describedby` belongs.\n *\n * Two modes. SINGLE (the default, and all of 0.5): `file` + `onFileSelected`. MULTIPLE\n * (`multiple`): `files` + `onFilesSelected`. They are optional rather than a union so\n * `FileDropzoneProps` stays an interface a caller can extend; a single-mode pick also\n * reaches `onFilesSelected` as a one-element array, so one handler can serve both.\n */\nexport interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onInvalid\"> {\n /** Single mode: the chosen file, echoed with its size. */\n file?: File | null;\n /** Single mode: called with the accepted file. */\n onFileSelected?: (file: File) => void;\n /** Take several files per pick or drop. Without it a drop of several keeps the first. */\n multiple?: boolean;\n /** Multiple mode: the chosen files, listed in the zone. */\n files?: readonly File[];\n /** Every accepted file of one pick, in order. Never called with an empty array. */\n onFilesSelected?: (files: File[]) => void;\n /**\n * Show a remove button inside the zone once something is chosen (kastlan asked for\n * it: the only way to un-choose a file was to reload). Single mode: removes the file.\n * Multiple mode: a \"remove all\" button. The host clears its own state here — the\n * zone is controlled. Focus moves to the Browse button, and the removal is announced.\n */\n onClear?: () => void;\n /** Multiple mode: a remove button per listed file. */\n onRemove?: (file: File, index: number) => void;\n accept: string;\n /**\n * The caller's check. Optional since 0.6: without it the zone checks `accept` itself\n * (a drop ignores the dialog's filter). With it, `accept` is NOT re-checked — every\n * 0.5 caller's `isValid` is its own type check, and doubling it could refuse a file\n * the caller accepts (a `.zip` the OS labelled `application/octet-stream`).\n */\n isValid?: (file: File) => boolean;\n /** The message for an `isValid` refusal. Defaults to `labels.rejectedInvalid`. */\n invalidMessage?: string;\n /** Bytes; larger files are refused. */\n maxSize?: number;\n /** Multiple mode: files one pick may add. Pass what is LEFT for a running cap. */\n maxFiles?: number;\n /**\n * Called once per refused file. The 0.5 hook, kept as it was; {@link onReject}\n * carries the reason and the translated message as well.\n *\n * Its existence used to be what switched the toast off, and it still does (see\n * {@link rejectionFeedback}). The toast is the kit's `toast`, which loads `sonner`\n * lazily: it is an OPTIONAL peer, and a static import here once broke\n * `import { Button }` for every app without it (see `optional-peer-imports.test.tsx`).\n */\n onInvalid?: (file: File) => void;\n /** Every refused file of one pick, with its reason and message. */\n onReject?: (rejections: FileRejection[]) => void;\n /** The whole pick at once, before any of the above — return `false` to refuse all\n * of it (see {@link FilePickHandler}). A refused pick selects nothing; its one\n * message (`labels.rejectedPick`) goes where `rejectionFeedback` says. */\n onPick?: FilePickHandler;\n /** See {@link FileDropzoneRejectionFeedback}. Defaults to `\"toast\"` when neither\n * `onInvalid` nor `onReject` is given, `\"none\"` when one is. */\n rejectionFeedback?: FileDropzoneRejectionFeedback;\n /** Per-instance overrides of the `filePicker` label namespace. */\n labels?: Partial<FilePickerLabels>;\n /** The dropzone's accessible name. A caller's own `aria-label` wins over it — see\n * the root element below. Optional since 0.8, like the three below: each falls back\n * to its `filePicker` string from `<UiKitProvider labels>`, then to English. They\n * were required, so every call site passed four strings through `t()` by hand, and a\n * zone nested in a kit component (the feedback form) had no call site to pass them. */\n dropLabel?: string;\n /** Default `filePicker.browse`. */\n browseLabel?: string;\n /** What the zone says while nothing is chosen. Default `filePicker.empty` /\n * `emptyMultiple`. */\n emptyLabel?: string;\n /** The second line, e.g. what may be dropped. Default `filePicker.hint(accept)`. */\n hint?: string;\n /**\n * Not taking files: no drop, no picker, no remove buttons; `aria-disabled` on the\n * group. keksdose locks its invoice zone while another form holds unsaved changes,\n * and hand-rolled the whole zone to be able to (invoice-upload.tsx, \"Locked\").\n */\n disabled?: boolean;\n /**\n * Working on what it was given — an upload, an OCR pass. Inert like `disabled`, plus\n * `aria-busy`, a spinner in place of the upload icon, and `filePicker.busy` as the\n * text. The other half of why keksdose's invoice zone was hand-rolled.\n */\n busy?: boolean;\n /**\n * Replace the zone's body — the icon, the text and the chosen-file list — with your\n * own, given the zone's state: keksdose's spinner-plus-\"uploading\", its multi-shot\n * hint (\"several photos are possible\"), a second trigger for the camera.\n *\n * The Browse button, the inline error and the live regions stay: Browse is the\n * keyboard path, and a body that forgot it would leave the zone unreachable without\n * a pointer. A click on a control inside the body does not also open the picker.\n */\n renderBody?: (state: FileDropzoneState) => ReactNode;\n /** Extra classes for the dropzone's root. */\n className?: string;\n}\n\n/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml) and\n * kastlan's document forms. Refusals go to `onReject`/`onInvalid` and to\n * `rejectionFeedback`; the chosen file(s) are echoed with their size, otherwise\n * `emptyLabel` + `hint` describe what to drop. For a trigger with no drop area, see\n * `FileButton`. */\nexport function FileDropzone({\n file,\n onFileSelected,\n multiple,\n files,\n onFilesSelected,\n onClear,\n onRemove,\n accept,\n isValid,\n invalidMessage,\n maxSize,\n maxFiles,\n onInvalid,\n onReject,\n onPick,\n rejectionFeedback,\n labels: labelsProp,\n dropLabel: dropLabelProp,\n browseLabel: browseLabelProp,\n emptyLabel: emptyLabelProp,\n hint: hintProp,\n disabled,\n busy,\n renderBody,\n className,\n \"aria-label\": ariaLabel,\n \"aria-describedby\": describedByProp,\n ...rest\n}: FileDropzoneProps) {\n const [error, setError] = useState<string | null>(null);\n const inert = Boolean(disabled || busy);\n // State rather than a ref: `open` is handed to `renderBody` during render, and a\n // function that reads a ref there is exactly what the refs rule cannot prove safe.\n const [fileInput, setFileInput] = useState<HTMLInputElement | null>(null);\n const browseRef = useRef<HTMLButtonElement>(null);\n const errorId = useId();\n // The size through the kit's `file.size` label (the provider's locale by default).\n // This was `${n / 1024} KB`: ASCII digits, an English unit, and a binary kilobyte\n // mislabelled as a decimal one, all in one template literal.\n const fileText = useKitFileLabels();\n const labels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labelsProp);\n // Two regions, two urgencies: a pick or a removal is news, a refusal is a failure.\n // The echoed file name is only text in the zone, and text changing somewhere on the\n // page is not announced by itself.\n const status = useAnnounce();\n const alert = useAnnounce({ politeness: \"assertive\" });\n const feedback: FileDropzoneRejectionFeedback =\n rejectionFeedback ?? (onInvalid || onReject ? \"none\" : \"toast\");\n const dropLabel = dropLabelProp ?? labels.dropzone;\n const browseLabel = browseLabelProp ?? labels.browse;\n const emptyLabel = emptyLabelProp ?? (multiple ? labels.emptyMultiple : labels.empty);\n const hint = hintProp ?? labels.hint(formatAccept(accept));\n\n const acceptFiles = (list: ArrayLike<File> | null | undefined) => {\n if (inert || !list || list.length === 0) return;\n const all = Array.from(list);\n const picked = multiple ? all : all.slice(0, 1);\n const { accepted, rejected } = screenFiles(\n picked,\n {\n accept: isValid ? undefined : accept,\n maxSize,\n maxFiles: multiple ? maxFiles : undefined,\n isValid,\n invalidMessage,\n },\n labels,\n fileText.size,\n );\n const stands = judgePick(onPick, accepted, rejected);\n if (stands && accepted.length > 0) {\n setError(null);\n if (multiple) {\n onFilesSelected?.(accepted);\n } else {\n onFileSelected?.(accepted[0]);\n onFilesSelected?.(accepted);\n }\n status.announce(labels.selected(accepted.length, accepted[0].name));\n }\n if (!stands || rejected.length > 0) {\n for (const r of rejected) onInvalid?.(r.file);\n if (rejected.length > 0) onReject?.(rejected);\n const message = stands\n ? summariseRejections(rejected, labels)\n : labels.rejectedPick(picked.length);\n if (feedback === \"toast\") {\n // The kit's `toast`, which loads sonner (an optional peer) lazily, so an app\n // that never trips this never has to install it. Nothing downstream depends\n // on the toast having appeared. The toaster is a live region, so this path\n // does not announce a second time.\n toast.error(message);\n } else {\n if (feedback === \"inline\") setError(message);\n alert.announce(message);\n }\n }\n };\n\n // The remove buttons unmount with the file they removed, and focus would fall to\n // <body>. Browse is where the next action (pick again) starts.\n const afterRemoval = (message: string) => {\n browseRef.current?.focus();\n status.announce(message);\n };\n\n // The enter/leave counter lives in the shared hook: the boolean this was flickered\n // across every child of the zone in Safari, whose drag events carry no\n // `relatedTarget` to tell a move onto a child from a real leave.\n const { dropProps, isOver: dragOver } = useDragTarget<HTMLDivElement>({\n disabled: inert,\n onDropFiles: acceptFiles,\n });\n const open = () => {\n if (!inert) fileInput?.click();\n };\n\n const chosen: readonly File[] = multiple ? (files ?? []) : file ? [file] : [];\n const showError = feedback === \"inline\" && error !== null;\n const describedBy = cn(describedByProp, showError && errorId) || undefined;\n\n return (\n <>\n {/* A named group, NOT a `role=\"button\"`. It was one, with real buttons (Browse,\n the remove buttons) inside it: nested interactive controls, which ARIA forbids\n because a button's children are presentational — a screen reader either read\n the zone as one opaque button or skipped the inner ones, and the zone was an\n extra Tab stop that did what Browse does. Now the keyboard path is Browse, a\n real button; the zone keeps its drop target and its click-anywhere for a\n pointer, which is a convenience on top of Browse rather than the only way in. */}\n {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -- the click is a pointer shortcut for Browse, see above */}\n <div\n // `...rest` first: every handler below is the drop gesture itself.\n {...rest}\n {...dropProps}\n // Pointer only; every control inside stops its own click from reaching here\n // (and one in a custom body is skipped below). The keyboard equivalent is the\n // Browse button, so no key handler belongs on a group.\n onClick={(e) => {\n if (fromControl(e)) return;\n open();\n }}\n role=\"group\"\n // The DOM spelling wins over `dropLabel`, which stays the name for every caller\n // that passes no `aria-label` — i.e. all of them today. Standardising on\n // `aria-label` (audit §api-design) does not get to silently rename an existing\n // required prop.\n aria-label={ariaLabel ?? dropLabel}\n // No `aria-invalid`: ARIA does not allow it on `role=\"group\"`. The message is\n // reached through the description instead, and spoken when it appears.\n aria-describedby={describedBy}\n data-invalid={showError || undefined}\n aria-disabled={disabled || undefined}\n aria-busy={busy || undefined}\n data-drag-over={dragOver || undefined}\n className={cn(\n // `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,\n // which Tailwind implements as `position: absolute` — so without a positioned\n // ancestor its containing block is the INITIAL one, and it is laid out at its\n // own offset from the top of the document. Nothing looks wrong, because the\n // input is 1x1 and clipped; what breaks is the page height. On a long page\n // every escaped sr-only element extends `documentElement.scrollHeight` to its\n // own offset, which produces a second, whole-document scrollbar alongside the\n // app shell's own — one that scrolls past the end of the content into nothing.\n // Measured on the showcase: body 900px, document 47,919px.\n \"relative flex flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors\",\n // Three states out of two border tokens: the target rests on the plain\n // hairline, hover pulls it to `--border-strong`, and a live drag keeps that\n // border and adds the `--bg-active` wash on top — so \"let go here\" still reads\n // one step louder than \"you are over it\". A refusal shown inline takes the\n // danger border until the next good pick.\n // Inert zones keep the resting hairline and lose the hover pull: a border that\n // answers the pointer promises a drop the zone will refuse.\n disabled\n ? \"cursor-not-allowed border-[var(--border)] opacity-60\"\n : busy\n ? \"cursor-progress border-[var(--border)]\"\n : dragOver\n ? \"cursor-pointer border-[var(--border-strong)] bg-[var(--bg-active)]\"\n : showError\n ? \"cursor-pointer border-[var(--danger-border)]\"\n : \"cursor-pointer border-[var(--border)] hover:border-[var(--border-strong)]\",\n className,\n )}\n >\n {renderBody ? (\n renderBody({\n dragOver,\n disabled: Boolean(disabled),\n busy: Boolean(busy),\n files: chosen,\n error: showError ? error : null,\n open,\n labels,\n })\n ) : busy ? (\n <>\n {/* `label={null}`: the zone is `aria-busy` and the line below says the\n same in words; a spoken \"Loading\" would only run into it. */}\n <Spinner label={null} className=\"size-5\" />\n <div className=\"text-sm text-[var(--text-secondary)]\">{labels.busy}</div>\n </>\n ) : (\n <>\n <Upload aria-hidden className=\"size-5 text-[var(--text-muted)]\" />\n {multiple && chosen.length > 0 ? (\n <ul className=\"flex w-full max-w-sm flex-col gap-1 text-start\">\n {chosen.map((f, i) => (\n <li\n // Name + size + position: two files may share a name, and the index\n // alone would re-key every row below a removal.\n key={`${f.name}-${f.size}-${i}`}\n className=\"flex items-center gap-2 text-sm text-[var(--text-secondary)]\"\n >\n <span className=\"min-w-0 flex-1 truncate font-medium\">{f.name}</span>\n <span className=\"shrink-0 text-xs text-[var(--text-muted)]\">{fileText.size(f.size)}</span>\n {onRemove && (\n <IconButton\n type=\"button\"\n size=\"sm\"\n disabled={inert}\n aria-label={labels.remove(f.name)}\n onClick={(e) => {\n e.stopPropagation();\n onRemove(f, i);\n afterRemoval(labels.removed(f.name));\n }}\n >\n <X aria-hidden />\n </IconButton>\n )}\n </li>\n ))}\n </ul>\n ) : (\n <>\n <div className=\"flex items-center gap-1 text-sm text-[var(--text-secondary)]\">\n {/* `!multiple`: in multiple mode an empty `files` list is \"nothing chosen\",\n whatever a stray `file` prop says — it used to show that file's name. */}\n {!multiple && file ? <span className=\"font-medium\">{file.name}</span> : emptyLabel}\n {!multiple && file && onClear && (\n <IconButton\n type=\"button\"\n size=\"sm\"\n disabled={inert}\n aria-label={labels.remove(file.name)}\n onClick={(e) => {\n e.stopPropagation();\n onClear();\n afterRemoval(labels.removed(file.name));\n }}\n >\n <X aria-hidden />\n </IconButton>\n )}\n </div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {file && !multiple ? fileText.size(file.size) : hint}\n </div>\n </>\n )}\n </>\n )}\n <div className=\"flex flex-wrap items-center justify-center gap-2\">\n <Button\n ref={browseRef}\n type=\"button\"\n variant=\"secondary\"\n disabled={inert}\n onClick={(e) => {\n e.stopPropagation();\n open();\n }}\n >\n {browseLabel}\n </Button>\n {multiple && chosen.length > 0 && onClear && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n disabled={inert}\n onClick={(e) => {\n e.stopPropagation();\n onClear();\n afterRemoval(labels.cleared);\n }}\n >\n {labels.clearAll}\n </Button>\n )}\n </div>\n {showError && (\n <p id={errorId} className=\"text-xs text-[var(--danger)]\">\n {error}\n </p>\n )}\n <input\n ref={setFileInput}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n disabled={inert}\n onChange={(e) => {\n // Copy, THEN reset: `files` is a live view of the value, and without the\n // reset picking the same file a second time fires no `change` at all —\n // the retry after a failed upload silently did nothing (kastlan).\n const picked = Array.from(e.currentTarget.files ?? []);\n e.currentTarget.value = \"\";\n acceptFiles(picked);\n }}\n // Clicks are forwarded here programmatically; one reaching the zone's own\n // handler again would open the dialog a second time.\n onClick={(e) => e.stopPropagation()}\n className=\"sr-only\"\n />\n </div>\n {/* Outside the zone, where they have always been (the zone used to be a\n `role=\"button\"`, whose content is presentational). `sr-only-fixed`, so they\n need no positioned ancestor. */}\n <span {...status.regionProps} />\n <span {...alert.regionProps} />\n </>\n );\n}\n\n/** A click that landed on a control inside the zone (a custom body's own button, a\n * link in the hint) is that control's, not a request to open the picker. */\nfunction fromControl(e: MouseEvent<HTMLElement>): boolean {\n const target = e.target as Element | null;\n const control = target?.closest?.(\"button, a[href], input, select, textarea, label, [role='button']\");\n return Boolean(control && control !== e.currentTarget && e.currentTarget.contains(control));\n}\n"],"mappings":"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","names":[]}
@@ -1,9 +1,18 @@
1
1
  import * as react from 'react';
2
- import { ButtonHTMLAttributes, ReactNode, Ref } from 'react';
2
+ import { ButtonHTMLAttributes, ReactNode, MouseEvent, ReactElement, Ref, HTMLAttributes } from 'react';
3
+ import { TooltipSide } from './tooltip.js';
4
+ import '../hooks/use-anchored-rect.js';
5
+ import '../lib/clipping.js';
3
6
 
4
7
  interface FloatingPanelLabels {
5
8
  /** Accessible name of the panel header's X. */
6
9
  close: string;
10
+ /**
11
+ * What a {@link FloatingAction}'s count badge adds to its accessible name ("3 new"),
12
+ * read after the label: "Invoices, 3 new". The dot itself is hidden from a reader —
13
+ * a bare "3" read out of context says nothing about what is counted.
14
+ */
15
+ badge: (count: number) => string;
7
16
  }
8
17
  declare const DEFAULT_FLOATING_PANEL_LABELS: FloatingPanelLabels;
9
18
  /** A LOGICAL corner: `bottom-end` is bottom-right in LTR and bottom-left in RTL. */
@@ -44,6 +53,49 @@ interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButton
44
53
  * in both states; `aria-pressed` already says which one it is in.
45
54
  */
46
55
  pressed?: boolean;
56
+ /**
57
+ * Show `label` beside the icon: the EXTENDED FAB, a pill rather than a disc. For a
58
+ * corner control whose words are the point — kastlan's offline indicator
59
+ * (shared/offline/offline-indicator.tsx:39) is a pill saying "Offline — 2 photo(s)
60
+ * queued" / "Syncing…", hand-placed at `fixed bottom-4 left-4`, i.e. over the
61
+ * bottom nav and on the wrong side in RTL. Same height as the disc (3rem), so a
62
+ * {@link FloatingPanel} card above it still clears it.
63
+ */
64
+ extended?: boolean;
65
+ /**
66
+ * Announce `label` whenever it changes, through a polite `role="status"` region — for
67
+ * an extended FAB that REPORTS something (kastlan's offline/sync state), where a
68
+ * sighted user sees the words change and a screen reader user otherwise hears
69
+ * nothing until they happen to land on the button.
70
+ *
71
+ * ⚠️ A live region announces changes, not its own arrival: a FAB that mounts already
72
+ * saying "Offline" says it to nobody. Keep it mounted and pass `hidden` while there
73
+ * is nothing to report (kastlan returns `null` when online and idle); the region
74
+ * stays in the page, and the first real status is heard.
75
+ */
76
+ live?: boolean;
77
+ /**
78
+ * `primary` (default): the solid brand disc of a page's main action. `surface`: the
79
+ * page's surface with a hairline border — for a FAB that is a status rather than a
80
+ * call to action, as kastlan's offline pill is (a brand pill would shout "press me"
81
+ * at a user who is merely offline). Ignored while `pressed` is set; a toggle has its
82
+ * own two looks.
83
+ */
84
+ variant?: "primary" | "surface";
85
+ /**
86
+ * Hover and focus label in the kit {@link Tooltip}, instead of the native `title` —
87
+ * keksdose has ONE tooltip (dev#523) and wrapped its assistant launcher by hand,
88
+ * which cannot work: the tooltip's anchor is a `relative inline-flex` span, and this
89
+ * button is `fixed` and portalled. With `tooltip` the FAB positions a fixed wrapper
90
+ * instead and the button sits in the tooltip inside it. `true` shows the `label`;
91
+ * any other content shows that. Implies no native `title` (whatever `nativeTitle`
92
+ * says): one hover hint, not two. `style` stays on the button; the corner and
93
+ * `offset` move to the wrapper.
94
+ */
95
+ tooltip?: ReactNode;
96
+ /** Where the {@link tooltip} opens. Default `top`; the bubble is portalled and kept on
97
+ * screen, so a corner button's label is not pushed off the edge. */
98
+ tooltipSide?: TooltipSide;
47
99
  ref?: Ref<HTMLButtonElement>;
48
100
  }
49
101
  /**
@@ -63,7 +115,7 @@ interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButton
63
115
  * Portalled, because `position: fixed` inside any transformed ancestor (an animated
64
116
  * page transition) is fixed to that ancestor, not to the screen.
65
117
  */
66
- declare function FloatingActionButton({ label, icon, corner, offset, nativeTitle, pressed, className, style, type, title, ...rest }: FloatingActionButtonProps): react.JSX.Element;
118
+ declare function FloatingActionButton({ label, icon, corner, offset, nativeTitle, pressed, extended, live, variant, tooltip, tooltipSide, className, style, type, title, ...rest }: FloatingActionButtonProps): react.JSX.Element;
67
119
  interface FloatingPanelProps {
68
120
  /** The panel's heading, and its accessible name. */
69
121
  title: ReactNode;
@@ -84,6 +136,10 @@ interface FloatingPanelProps {
84
136
  /** The FAB's native `title` — see {@link FloatingActionButtonProps.nativeTitle}.
85
137
  * `false` for keksdose's assistant launcher, under its one-tooltip rule (dev#523). */
86
138
  fabNativeTitle?: boolean;
139
+ /** The FAB's kit tooltip — see {@link FloatingActionButtonProps.tooltip}. `true` for
140
+ * keksdose's assistant launcher, which wanted the kit bubble and had no way to
141
+ * put one on a fixed, portalled button (dev#523). */
142
+ fabTooltip?: ReactNode;
87
143
  /** Default: `floatingPanel.close` from the {@link UiKitProvider}, else "Close". */
88
144
  closeLabel?: string;
89
145
  /**
@@ -129,6 +185,93 @@ interface FloatingPanelProps {
129
185
  * panel unmounts when closed; an app that must keep a draft across a close keeps it in
130
186
  * its own state, where it also survives a reload.
131
187
  */
132
- declare function FloatingPanel({ title, fabLabel, fabIcon, open: openProp, defaultOpen, onOpenChange, corner, offset, fabNativeTitle, closeLabel, initialFocus, className, bodyClassName, children, }: FloatingPanelProps): react.JSX.Element;
188
+ declare function FloatingPanel({ title, fabLabel, fabIcon, open: openProp, defaultOpen, onOpenChange, corner, offset, fabNativeTitle, fabTooltip, closeLabel, initialFocus, className, bodyClassName, children, }: FloatingPanelProps): react.JSX.Element;
189
+ interface FloatingActionGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, "role"> {
190
+ /** Name the group ("Register actions"); a reader then introduces the members as one
191
+ * set. A group with no name is one more unlabelled wrapper. */
192
+ "aria-label"?: string;
193
+ /** Default `bottom-end`. */
194
+ corner?: FloatingCorner;
195
+ /** Distance above the nav / home indicator — see {@link FloatingActionButtonProps.offset}. */
196
+ offset?: string;
197
+ /** {@link FloatingAction}s, in reading order. */
198
+ children: ReactNode;
199
+ ref?: Ref<HTMLDivElement>;
200
+ }
201
+ /**
202
+ * A floating pill of {@link FloatingAction}s — one to four `size-12` toggles, actions
203
+ * or links with hairline dividers — pinned to a corner exactly as the
204
+ * {@link FloatingActionButton} is: `--app-nav-h` and the home indicator cleared, a
205
+ * logical corner, portalled.
206
+ *
207
+ * keksdose built it twice by hand: the /transactions phone toolbar
208
+ * (transactions-page.tsx:644 — upcoming and pending toggles, an invoices link with a
209
+ * pending count, and the brand "+") and the feedback page's "awaiting only" toggle
210
+ * (feedback-page.tsx:854, one button in the group anyway, so the two pages look
211
+ * alike). Both at `right-4 bottom-20` — `right`, not `end`, so over the page's leading
212
+ * edge in RTL — with `divide-x`, slate hovers, and each member's `Tooltip` spelled out.
213
+ *
214
+ * `role="group"`, NOT a toolbar: every member keeps its own tab stop, as the
215
+ * hand-built ones did, and there is no arrow-key roving to learn. Hide it where it
216
+ * does not belong with a class (`md:hidden`, as both keksdose pages do).
217
+ */
218
+ declare function FloatingActionGroup({ corner, offset, className, style, children, ...rest }: FloatingActionGroupProps): react.JSX.Element;
219
+ /** What {@link FloatingActionProps.renderLink} is handed. Spread it onto your router's
220
+ * link, mapping `href` — `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`. */
221
+ interface FloatingActionLinkProps {
222
+ href: string;
223
+ /** The member's whole look — keep it. */
224
+ className: string;
225
+ children: ReactNode;
226
+ "aria-label": string;
227
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
228
+ [key: `aria-${string}`]: string | boolean | number | undefined;
229
+ [key: `data-${string}`]: unknown;
230
+ }
231
+ interface FloatingActionProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "onClick"> {
232
+ /** The accessible name, and the tooltip's text. Keep it the same in both states of a
233
+ * toggle; `aria-pressed` says which one it is in. */
234
+ label: string;
235
+ icon: ReactNode;
236
+ /** Make it a toggle: `aria-pressed` and the brand "on" look (the brand glyph on the
237
+ * quiet brand fill), as {@link FloatingActionButtonProps.pressed}. Ignored on a link. */
238
+ pressed?: boolean;
239
+ /**
240
+ * `primary`: the brand-filled member, for the group's main action (the /transactions
241
+ * "+"). Default: the quiet one.
242
+ */
243
+ variant?: "default" | "primary";
244
+ /**
245
+ * A count in a dot at the member's top end corner — the /transactions invoices link's
246
+ * pending count. Nothing is drawn for `0` or less; above 99 it reads `99+`. The count
247
+ * joins the accessible name through `floatingPanel.badge` ("Invoices, 3 new"), or
248
+ * `badgeLabel` when passed.
249
+ */
250
+ badge?: number;
251
+ /** The badge's words for a reader, replacing `floatingPanel.badge` — "3 to review". */
252
+ badgeLabel?: string;
253
+ /** The dot's colour. Default `warning`, the pending count's. */
254
+ badgeTone?: "warning" | "danger" | "brand";
255
+ /** Show `label` in the kit {@link Tooltip} on hover and focus. Default `true`: the
256
+ * members are icon-only and this is their only visible label. */
257
+ tooltip?: boolean;
258
+ /** Where the tooltip opens. Default `top` — the group sits at the bottom of the screen. */
259
+ tooltipSide?: TooltipSide;
260
+ /** Make it a link — the /transactions invoices member. A plain `<a>` unless
261
+ * `renderLink` hands it to your router. */
262
+ href?: string;
263
+ /** Render the link through your router's `Link` — see {@link FloatingActionLinkProps}. */
264
+ renderLink?: (props: FloatingActionLinkProps) => ReactElement;
265
+ onClick?: (event: MouseEvent<HTMLElement>) => void;
266
+ ref?: Ref<HTMLButtonElement>;
267
+ }
268
+ /**
269
+ * One member of a {@link FloatingActionGroup}: a `size-12` icon button, toggle or link
270
+ * with its label in a styled tooltip — what each keksdose member wrapped by hand in
271
+ * `<Tooltip portal>`. The tooltip is portalled because the group clips (`overflow-hidden`
272
+ * keeps the brand member's fill inside the pill's corners), and the focus ring is inset
273
+ * for the same reason.
274
+ */
275
+ declare function FloatingAction({ label, icon, pressed, variant, badge, badgeLabel, badgeTone, tooltip, tooltipSide, href, renderLink, className, type, onClick, ref, ...rest }: FloatingActionProps): react.JSX.Element;
133
276
 
134
- export { DEFAULT_FLOATING_PANEL_LABELS, FloatingActionButton, type FloatingActionButtonProps, type FloatingCorner, FloatingPanel, type FloatingPanelLabels, type FloatingPanelProps };
277
+ export { DEFAULT_FLOATING_PANEL_LABELS, FloatingAction, FloatingActionButton, type FloatingActionButtonProps, FloatingActionGroup, type FloatingActionGroupProps, type FloatingActionLinkProps, type FloatingActionProps, type FloatingCorner, FloatingPanel, type FloatingPanelLabels, type FloatingPanelProps };
@@ -6,7 +6,11 @@ import { X } from "lucide-react";
6
6
  import { cn } from "../lib/cn.js";
7
7
  import { dirOf } from "../lib/direction.js";
8
8
  import { useKitLabels } from "../i18n/kit-labels.js";
9
- const DEFAULT_FLOATING_PANEL_LABELS = { close: "Close" };
9
+ import { Tooltip } from "./tooltip.js";
10
+ const DEFAULT_FLOATING_PANEL_LABELS = {
11
+ close: "Close",
12
+ badge: (count) => `${count} new`
13
+ };
10
14
  const BOTTOM_CLEARANCE = "max(var(--app-nav-h, 0px), env(safe-area-inset-bottom, 0px))";
11
15
  const SIDE_GUTTER = "max(1rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))";
12
16
  function cornerStyle(corner, lift) {
@@ -32,6 +36,11 @@ function FloatingActionButton({
32
36
  offset = "1rem",
33
37
  nativeTitle = true,
34
38
  pressed,
39
+ extended = false,
40
+ live = false,
41
+ variant = "primary",
42
+ tooltip,
43
+ tooltipSide = "top",
35
44
  className,
36
45
  style,
37
46
  type = "button",
@@ -40,36 +49,57 @@ function FloatingActionButton({
40
49
  }) {
41
50
  const [marker, dir] = usePortalDir();
42
51
  const toggle = pressed !== void 0;
43
- const nativeTip = nativeTitle && title !== "" ? title ?? label : void 0;
52
+ const tip = tooltip === true ? label : tooltip === false || tooltip === "" ? void 0 : tooltip;
53
+ const wrapped = tip !== void 0 && tip !== null;
54
+ const nativeTip = !wrapped && nativeTitle && title !== "" ? title ?? label : void 0;
55
+ const button = /* @__PURE__ */ jsxs(
56
+ "button",
57
+ {
58
+ ...rest,
59
+ type,
60
+ dir,
61
+ "aria-label": label,
62
+ "aria-pressed": pressed ?? rest["aria-pressed"],
63
+ title: nativeTip,
64
+ style: wrapped ? style : { ...cornerStyle(corner, offset), ...style },
65
+ className: cn(
66
+ // Wrapped, the wrapper is the fixed thing and the button sits in it.
67
+ !wrapped && "fixed z-40",
68
+ "inline-flex items-center justify-center rounded-full",
69
+ extended ? "h-12 gap-2 px-4 text-sm font-medium [&_svg]:size-5" : "size-12 [&_svg]:size-6",
70
+ "shadow-lg transition-colors disabled:cursor-default",
71
+ !toggle && variant === "primary" && "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]",
72
+ !toggle && variant === "surface" && "border border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text-primary)] hover:bg-[var(--bg-hover)] disabled:hover:bg-[var(--bg-surface)]",
73
+ // A toggle sits on the page's surface — a solid brand disc would read as
74
+ // "on" in both states — and says "on" in the brand family.
75
+ toggle && "border border-[var(--border)]",
76
+ pressed === false && "bg-[var(--bg-surface)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]",
77
+ pressed === true && "border-[var(--brand)] bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)]",
78
+ "outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2",
79
+ className
80
+ ),
81
+ children: [
82
+ icon,
83
+ extended && /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "whitespace-nowrap", children: label })
84
+ ]
85
+ }
86
+ );
44
87
  return /* @__PURE__ */ jsxs(Fragment, { children: [
45
88
  /* @__PURE__ */ jsx("span", { ref: marker, hidden: true }),
46
89
  typeof document !== "undefined" && createPortal(
47
- /* @__PURE__ */ jsx(
48
- "button",
49
- {
50
- ...rest,
51
- type,
52
- dir,
53
- "aria-label": label,
54
- "aria-pressed": pressed ?? rest["aria-pressed"],
55
- title: nativeTip,
56
- style: { ...cornerStyle(corner, offset), ...style },
57
- className: cn(
58
- "fixed z-40 inline-flex size-12 items-center justify-center rounded-full",
59
- "shadow-lg transition-colors",
60
- !toggle && "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]",
61
- // A toggle sits on the page's surface — a solid brand disc would read as
62
- // "on" in both states — and says "on" in the brand family.
63
- toggle && "border border-[var(--border)]",
64
- pressed === false && "bg-[var(--bg-surface)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]",
65
- pressed === true && "border-[var(--brand)] bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)]",
66
- "outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2",
67
- "[&_svg]:size-6",
68
- className
69
- ),
70
- children: icon
71
- }
72
- ),
90
+ /* @__PURE__ */ jsxs(Fragment, { children: [
91
+ wrapped ? /* @__PURE__ */ jsx(
92
+ "div",
93
+ {
94
+ dir,
95
+ hidden: rest.hidden,
96
+ style: cornerStyle(corner, offset),
97
+ className: "fixed z-40 inline-flex",
98
+ children: /* @__PURE__ */ jsx(Tooltip, { label: tip, side: tooltipSide, portal: true, children: tip === label ? /* @__PURE__ */ jsx(Fragment, { children: button }) : button })
99
+ }
100
+ ) : button,
101
+ live && /* @__PURE__ */ jsx("span", { role: "status", className: "sr-only", children: label })
102
+ ] }),
73
103
  document.body
74
104
  )
75
105
  ] });
@@ -84,6 +114,7 @@ function FloatingPanel({
84
114
  corner = "bottom-end",
85
115
  offset = "1rem",
86
116
  fabNativeTitle,
117
+ fabTooltip,
87
118
  closeLabel,
88
119
  initialFocus,
89
120
  className,
@@ -143,6 +174,7 @@ function FloatingPanel({
143
174
  corner,
144
175
  offset,
145
176
  nativeTitle: fabNativeTitle,
177
+ tooltip: fabTooltip,
146
178
  "aria-expanded": open,
147
179
  "aria-controls": open ? panelId : void 0,
148
180
  onClick: () => {
@@ -212,9 +244,137 @@ function FloatingPanel({
212
244
  )
213
245
  ] });
214
246
  }
247
+ function FloatingActionGroup({
248
+ corner = "bottom-end",
249
+ offset = "1rem",
250
+ className,
251
+ style,
252
+ children,
253
+ ...rest
254
+ }) {
255
+ const [marker, dir] = usePortalDir();
256
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
257
+ /* @__PURE__ */ jsx("span", { ref: marker, hidden: true }),
258
+ typeof document !== "undefined" && createPortal(
259
+ /* @__PURE__ */ jsx(
260
+ "div",
261
+ {
262
+ ...rest,
263
+ role: "group",
264
+ dir,
265
+ "data-corner": corner,
266
+ style: { ...cornerStyle(corner, offset), ...style },
267
+ className: cn(
268
+ "fixed z-40 inline-flex flex-row overflow-hidden rounded-2xl",
269
+ "border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg",
270
+ // Dividers on the logical start edge of every member but the first, so
271
+ // they sit between the same two members in RTL. The member is the
272
+ // tooltip's wrapper span, which is the direct child.
273
+ "[&>*:not(:first-child)]:border-s [&>*]:border-[var(--border)]",
274
+ className
275
+ ),
276
+ children
277
+ }
278
+ ),
279
+ document.body
280
+ )
281
+ ] });
282
+ }
283
+ const BADGE_TONE = {
284
+ warning: "bg-[var(--warning)] text-[var(--text-inverse)]",
285
+ danger: "bg-[var(--danger)] text-[var(--danger-contrast)]",
286
+ brand: "bg-[var(--brand)] text-[var(--brand-contrast)]"
287
+ };
288
+ function FloatingAction({
289
+ label,
290
+ icon,
291
+ pressed,
292
+ variant = "default",
293
+ badge,
294
+ badgeLabel,
295
+ badgeTone = "warning",
296
+ tooltip = true,
297
+ tooltipSide = "top",
298
+ href,
299
+ renderLink,
300
+ className,
301
+ type = "button",
302
+ onClick,
303
+ ref,
304
+ ...rest
305
+ }) {
306
+ const labels = useKitLabels("floatingPanel", DEFAULT_FLOATING_PANEL_LABELS);
307
+ const counted = badge !== void 0 && badge > 0;
308
+ const name = counted ? `${label}, ${badgeLabel ?? labels.badge(badge)}` : label;
309
+ const isLink = href !== void 0;
310
+ const look = cn(
311
+ "relative flex size-12 items-center justify-center transition-colors",
312
+ "outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]",
313
+ "disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-5",
314
+ variant === "primary" ? "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]" : pressed && !isLink ? "bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)]" : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]",
315
+ className
316
+ );
317
+ const body = /* @__PURE__ */ jsxs(Fragment, { children: [
318
+ icon,
319
+ counted && /* @__PURE__ */ jsx(
320
+ "span",
321
+ {
322
+ "aria-hidden": true,
323
+ "data-badge": "",
324
+ className: cn(
325
+ "pointer-events-none absolute end-1 top-1 h-4 min-w-4 rounded-full px-1 text-center text-[10px] leading-4 font-semibold",
326
+ BADGE_TONE[badgeTone]
327
+ ),
328
+ children: badge > 99 ? "99+" : badge
329
+ }
330
+ )
331
+ ] });
332
+ let member;
333
+ if (isLink) {
334
+ const { disabled: _disabled, form: _form, value: _value, name: _name, ...anchorRest } = rest;
335
+ const linkProps = {
336
+ ...anchorRest,
337
+ href,
338
+ "aria-label": name,
339
+ onClick,
340
+ className: look,
341
+ children: body
342
+ };
343
+ const { children: content, ...anchor } = linkProps;
344
+ member = renderLink ? /* @__PURE__ */ jsx(RenderedFloatingLink, { render: renderLink, ...linkProps }) : /* @__PURE__ */ jsx("a", { ...anchor, children: content });
345
+ } else {
346
+ member = /* @__PURE__ */ jsx(
347
+ "button",
348
+ {
349
+ ...rest,
350
+ ref,
351
+ type,
352
+ "aria-label": name,
353
+ "aria-pressed": pressed ?? rest["aria-pressed"],
354
+ onClick,
355
+ className: look,
356
+ children: body
357
+ }
358
+ );
359
+ }
360
+ if (!tooltip) return member;
361
+ return (
362
+ // A fragment: the bubble says the member's name, and a description that repeats
363
+ // the name is read twice.
364
+ /* @__PURE__ */ jsx(Tooltip, { label, side: tooltipSide, portal: true, children: /* @__PURE__ */ jsx(Fragment, { children: member }) })
365
+ );
366
+ }
367
+ function RenderedFloatingLink({
368
+ render,
369
+ ...props
370
+ }) {
371
+ return render(props);
372
+ }
215
373
  export {
216
374
  DEFAULT_FLOATING_PANEL_LABELS,
375
+ FloatingAction,
217
376
  FloatingActionButton,
377
+ FloatingActionGroup,
218
378
  FloatingPanel
219
379
  };
220
380
  //# sourceMappingURL=floating-panel.js.map