@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
  } from "./file-button";
15
15
  import type { FilePickHandler, FilePickerLabels, FileRejection } from "./file-button";
16
16
  import { useDragTarget } from "../hooks/use-file-drop";
17
+ import { toast } from "./toast";
17
18
 
18
19
  /**
19
20
  * Where a refused file's message goes.
@@ -97,10 +98,9 @@ export interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">,
97
98
  * carries the reason and the translated message as well.
98
99
  *
99
100
  * Its existence used to be what switched the toast off, and it still does (see
100
- * {@link rejectionFeedback}). The toast is a dynamic `import("sonner")` on the
101
- * failure path only: `sonner` is an OPTIONAL peer, and a static import here once
102
- * broke `import { Button }` for every app without it (see
103
- * `optional-peer-imports.test.tsx`).
101
+ * {@link rejectionFeedback}). The toast is the kit's `toast`, which loads `sonner`
102
+ * lazily: it is an OPTIONAL peer, and a static import here once broke
103
+ * `import { Button }` for every app without it (see `optional-peer-imports.test.tsx`).
104
104
  */
105
105
  onInvalid?: (file: File) => void;
106
106
  /** Every refused file of one pick, with its reason and message. */
@@ -246,12 +246,11 @@ export function FileDropzone({
246
246
  ? summariseRejections(rejected, labels)
247
247
  : labels.rejectedPick(picked.length);
248
248
  if (feedback === "toast") {
249
- // sonner is an optional peer: imported here, on the failure path only, so an
250
- // app that never trips this never has to install it. Not awaited — nothing
251
- // downstream depends on the toast having appeared, and the handlers that
252
- // reach this are DOM events. sonner's own toaster is a live region, so this
253
- // path does not announce a second time.
254
- void import("sonner").then(({ toast }) => toast.error(message));
249
+ // The kit's `toast`, which loads sonner (an optional peer) lazily, so an app
250
+ // that never trips this never has to install it. Nothing downstream depends
251
+ // on the toast having appeared. The toaster is a live region, so this path
252
+ // does not announce a second time.
253
+ toast.error(message);
255
254
  } else {
256
255
  if (feedback === "inline") setError(message);
257
256
  alert.announce(message);
@@ -1,18 +1,38 @@
1
1
  import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
- import type { ButtonHTMLAttributes, CSSProperties, KeyboardEvent, ReactNode, Ref } from "react";
3
+ import type {
4
+ AnchorHTMLAttributes,
5
+ ButtonHTMLAttributes,
6
+ CSSProperties,
7
+ HTMLAttributes,
8
+ KeyboardEvent,
9
+ MouseEvent,
10
+ ReactElement,
11
+ ReactNode,
12
+ Ref,
13
+ } from "react";
4
14
  import { X } from "lucide-react";
5
15
 
6
16
  import { cn } from "../lib/cn";
7
17
  import { dirOf, type Direction } from "../lib/direction";
8
18
  import { useKitLabels } from "../i18n/kit-labels";
19
+ import { Tooltip, type TooltipSide } from "./tooltip";
9
20
 
10
21
  export interface FloatingPanelLabels {
11
22
  /** Accessible name of the panel header's X. */
12
23
  close: string;
24
+ /**
25
+ * What a {@link FloatingAction}'s count badge adds to its accessible name ("3 new"),
26
+ * read after the label: "Invoices, 3 new". The dot itself is hidden from a reader —
27
+ * a bare "3" read out of context says nothing about what is counted.
28
+ */
29
+ badge: (count: number) => string;
13
30
  }
14
31
 
15
- export const DEFAULT_FLOATING_PANEL_LABELS: FloatingPanelLabels = { close: "Close" };
32
+ export const DEFAULT_FLOATING_PANEL_LABELS: FloatingPanelLabels = {
33
+ close: "Close",
34
+ badge: (count) => `${count} new`,
35
+ };
16
36
 
17
37
  /** A LOGICAL corner: `bottom-end` is bottom-right in LTR and bottom-left in RTL. */
18
38
  export type FloatingCorner = "bottom-end" | "bottom-start";
@@ -89,6 +109,49 @@ export interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTM
89
109
  * in both states; `aria-pressed` already says which one it is in.
90
110
  */
91
111
  pressed?: boolean;
112
+ /**
113
+ * Show `label` beside the icon: the EXTENDED FAB, a pill rather than a disc. For a
114
+ * corner control whose words are the point — kastlan's offline indicator
115
+ * (shared/offline/offline-indicator.tsx:39) is a pill saying "Offline — 2 photo(s)
116
+ * queued" / "Syncing…", hand-placed at `fixed bottom-4 left-4`, i.e. over the
117
+ * bottom nav and on the wrong side in RTL. Same height as the disc (3rem), so a
118
+ * {@link FloatingPanel} card above it still clears it.
119
+ */
120
+ extended?: boolean;
121
+ /**
122
+ * Announce `label` whenever it changes, through a polite `role="status"` region — for
123
+ * an extended FAB that REPORTS something (kastlan's offline/sync state), where a
124
+ * sighted user sees the words change and a screen reader user otherwise hears
125
+ * nothing until they happen to land on the button.
126
+ *
127
+ * ⚠️ A live region announces changes, not its own arrival: a FAB that mounts already
128
+ * saying "Offline" says it to nobody. Keep it mounted and pass `hidden` while there
129
+ * is nothing to report (kastlan returns `null` when online and idle); the region
130
+ * stays in the page, and the first real status is heard.
131
+ */
132
+ live?: boolean;
133
+ /**
134
+ * `primary` (default): the solid brand disc of a page's main action. `surface`: the
135
+ * page's surface with a hairline border — for a FAB that is a status rather than a
136
+ * call to action, as kastlan's offline pill is (a brand pill would shout "press me"
137
+ * at a user who is merely offline). Ignored while `pressed` is set; a toggle has its
138
+ * own two looks.
139
+ */
140
+ variant?: "primary" | "surface";
141
+ /**
142
+ * Hover and focus label in the kit {@link Tooltip}, instead of the native `title` —
143
+ * keksdose has ONE tooltip (dev#523) and wrapped its assistant launcher by hand,
144
+ * which cannot work: the tooltip's anchor is a `relative inline-flex` span, and this
145
+ * button is `fixed` and portalled. With `tooltip` the FAB positions a fixed wrapper
146
+ * instead and the button sits in the tooltip inside it. `true` shows the `label`;
147
+ * any other content shows that. Implies no native `title` (whatever `nativeTitle`
148
+ * says): one hover hint, not two. `style` stays on the button; the corner and
149
+ * `offset` move to the wrapper.
150
+ */
151
+ tooltip?: ReactNode;
152
+ /** Where the {@link tooltip} opens. Default `top`; the bubble is portalled and kept on
153
+ * screen, so a corner button's label is not pushed off the edge. */
154
+ tooltipSide?: TooltipSide;
92
155
  ref?: Ref<HTMLButtonElement>;
93
156
  }
94
157
 
@@ -116,6 +179,11 @@ export function FloatingActionButton({
116
179
  offset = "1rem",
117
180
  nativeTitle = true,
118
181
  pressed,
182
+ extended = false,
183
+ live = false,
184
+ variant = "primary",
185
+ tooltip,
186
+ tooltipSide = "top",
119
187
  className,
120
188
  style,
121
189
  type = "button",
@@ -124,13 +192,12 @@ export function FloatingActionButton({
124
192
  }: FloatingActionButtonProps) {
125
193
  const [marker, dir] = usePortalDir();
126
194
  const toggle = pressed !== undefined;
127
- // An empty title is "no tooltip", not an attribute with nothing in it.
128
- const nativeTip = nativeTitle && title !== "" ? (title ?? label) : undefined;
129
- return (
130
- <>
131
- <span ref={marker} hidden />
132
- {typeof document !== "undefined" &&
133
- createPortal(
195
+ const tip = tooltip === true ? label : tooltip === false || tooltip === "" ? undefined : tooltip;
196
+ const wrapped = tip !== undefined && tip !== null;
197
+ // An empty title is "no tooltip", not an attribute with nothing in it. The kit
198
+ // tooltip replaces it outright.
199
+ const nativeTip = !wrapped && nativeTitle && title !== "" ? (title ?? label) : undefined;
200
+ const button = (
134
201
  <button
135
202
  {...rest}
136
203
  type={type}
@@ -138,11 +205,19 @@ export function FloatingActionButton({
138
205
  aria-label={label}
139
206
  aria-pressed={pressed ?? rest["aria-pressed"]}
140
207
  title={nativeTip}
141
- style={{ ...cornerStyle(corner, offset), ...style }}
208
+ style={wrapped ? style : { ...cornerStyle(corner, offset), ...style }}
142
209
  className={cn(
143
- "fixed z-40 inline-flex size-12 items-center justify-center rounded-full",
144
- "shadow-lg transition-colors",
145
- !toggle && "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]",
210
+ // Wrapped, the wrapper is the fixed thing and the button sits in it.
211
+ !wrapped && "fixed z-40",
212
+ "inline-flex items-center justify-center rounded-full",
213
+ extended ? "h-12 gap-2 px-4 text-sm font-medium [&_svg]:size-5" : "size-12 [&_svg]:size-6",
214
+ "shadow-lg transition-colors disabled:cursor-default",
215
+ !toggle &&
216
+ variant === "primary" &&
217
+ "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]",
218
+ !toggle &&
219
+ variant === "surface" &&
220
+ "border border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text-primary)] hover:bg-[var(--bg-hover)] disabled:hover:bg-[var(--bg-surface)]",
146
221
  // A toggle sits on the page's surface — a solid brand disc would read as
147
222
  // "on" in both states — and says "on" in the brand family.
148
223
  toggle && "border border-[var(--border)]",
@@ -151,12 +226,48 @@ export function FloatingActionButton({
151
226
  pressed === true &&
152
227
  "border-[var(--brand)] bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)]",
153
228
  "outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2",
154
- "[&_svg]:size-6",
155
229
  className,
156
230
  )}
157
231
  >
158
232
  {icon}
159
- </button>,
233
+ {/* Visible words, not the name: `aria-label` already is the name, and the
234
+ span would only repeat it. */}
235
+ {extended && (
236
+ <span aria-hidden className="whitespace-nowrap">
237
+ {label}
238
+ </span>
239
+ )}
240
+ </button>
241
+ );
242
+ return (
243
+ <>
244
+ <span ref={marker} hidden />
245
+ {typeof document !== "undefined" &&
246
+ createPortal(
247
+ <>
248
+ {wrapped ? (
249
+ <div
250
+ dir={dir}
251
+ hidden={rest.hidden}
252
+ style={cornerStyle(corner, offset)}
253
+ className="fixed z-40 inline-flex"
254
+ >
255
+ <Tooltip label={tip} side={tooltipSide} portal>
256
+ {/* A fragment when the bubble says the name again, so the tooltip
257
+ does not describe the button with its own label — a reader would
258
+ hear it twice. Other text is a real description. */}
259
+ {tip === label ? <>{button}</> : button}
260
+ </Tooltip>
261
+ </div>
262
+ ) : (
263
+ button
264
+ )}
265
+ {live && (
266
+ <span role="status" className="sr-only">
267
+ {label}
268
+ </span>
269
+ )}
270
+ </>,
160
271
  document.body,
161
272
  )}
162
273
  </>
@@ -183,6 +294,10 @@ export interface FloatingPanelProps {
183
294
  /** The FAB's native `title` — see {@link FloatingActionButtonProps.nativeTitle}.
184
295
  * `false` for keksdose's assistant launcher, under its one-tooltip rule (dev#523). */
185
296
  fabNativeTitle?: boolean;
297
+ /** The FAB's kit tooltip — see {@link FloatingActionButtonProps.tooltip}. `true` for
298
+ * keksdose's assistant launcher, which wanted the kit bubble and had no way to
299
+ * put one on a fixed, portalled button (dev#523). */
300
+ fabTooltip?: ReactNode;
186
301
  /** Default: `floatingPanel.close` from the {@link UiKitProvider}, else "Close". */
187
302
  closeLabel?: string;
188
303
  /**
@@ -239,6 +354,7 @@ export function FloatingPanel({
239
354
  corner = "bottom-end",
240
355
  offset = "1rem",
241
356
  fabNativeTitle,
357
+ fabTooltip,
242
358
  closeLabel,
243
359
  initialFocus,
244
360
  className,
@@ -308,6 +424,7 @@ export function FloatingPanel({
308
424
  corner={corner}
309
425
  offset={offset}
310
426
  nativeTitle={fabNativeTitle}
427
+ tooltip={fabTooltip}
311
428
  aria-expanded={open}
312
429
  aria-controls={open ? panelId : undefined}
313
430
  onClick={() => {
@@ -383,3 +500,237 @@ export function FloatingPanel({
383
500
  </>
384
501
  );
385
502
  }
503
+
504
+ export interface FloatingActionGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, "role"> {
505
+ /** Name the group ("Register actions"); a reader then introduces the members as one
506
+ * set. A group with no name is one more unlabelled wrapper. */
507
+ "aria-label"?: string;
508
+ /** Default `bottom-end`. */
509
+ corner?: FloatingCorner;
510
+ /** Distance above the nav / home indicator — see {@link FloatingActionButtonProps.offset}. */
511
+ offset?: string;
512
+ /** {@link FloatingAction}s, in reading order. */
513
+ children: ReactNode;
514
+ ref?: Ref<HTMLDivElement>;
515
+ }
516
+
517
+ /**
518
+ * A floating pill of {@link FloatingAction}s — one to four `size-12` toggles, actions
519
+ * or links with hairline dividers — pinned to a corner exactly as the
520
+ * {@link FloatingActionButton} is: `--app-nav-h` and the home indicator cleared, a
521
+ * logical corner, portalled.
522
+ *
523
+ * keksdose built it twice by hand: the /transactions phone toolbar
524
+ * (transactions-page.tsx:644 — upcoming and pending toggles, an invoices link with a
525
+ * pending count, and the brand "+") and the feedback page's "awaiting only" toggle
526
+ * (feedback-page.tsx:854, one button in the group anyway, so the two pages look
527
+ * alike). Both at `right-4 bottom-20` — `right`, not `end`, so over the page's leading
528
+ * edge in RTL — with `divide-x`, slate hovers, and each member's `Tooltip` spelled out.
529
+ *
530
+ * `role="group"`, NOT a toolbar: every member keeps its own tab stop, as the
531
+ * hand-built ones did, and there is no arrow-key roving to learn. Hide it where it
532
+ * does not belong with a class (`md:hidden`, as both keksdose pages do).
533
+ */
534
+ export function FloatingActionGroup({
535
+ corner = "bottom-end",
536
+ offset = "1rem",
537
+ className,
538
+ style,
539
+ children,
540
+ ...rest
541
+ }: FloatingActionGroupProps) {
542
+ const [marker, dir] = usePortalDir();
543
+ return (
544
+ <>
545
+ <span ref={marker} hidden />
546
+ {typeof document !== "undefined" &&
547
+ createPortal(
548
+ <div
549
+ {...rest}
550
+ role="group"
551
+ dir={dir}
552
+ data-corner={corner}
553
+ style={{ ...cornerStyle(corner, offset), ...style }}
554
+ className={cn(
555
+ "fixed z-40 inline-flex flex-row overflow-hidden rounded-2xl",
556
+ "border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg",
557
+ // Dividers on the logical start edge of every member but the first, so
558
+ // they sit between the same two members in RTL. The member is the
559
+ // tooltip's wrapper span, which is the direct child.
560
+ "[&>*:not(:first-child)]:border-s [&>*]:border-[var(--border)]",
561
+ className,
562
+ )}
563
+ >
564
+ {children}
565
+ </div>,
566
+ document.body,
567
+ )}
568
+ </>
569
+ );
570
+ }
571
+
572
+ /** What {@link FloatingActionProps.renderLink} is handed. Spread it onto your router's
573
+ * link, mapping `href` — `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`. */
574
+ export interface FloatingActionLinkProps {
575
+ href: string;
576
+ /** The member's whole look — keep it. */
577
+ className: string;
578
+ children: ReactNode;
579
+ "aria-label": string;
580
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
581
+ [key: `aria-${string}`]: string | boolean | number | undefined;
582
+ [key: `data-${string}`]: unknown;
583
+ }
584
+
585
+ export interface FloatingActionProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "onClick"> {
586
+ /** The accessible name, and the tooltip's text. Keep it the same in both states of a
587
+ * toggle; `aria-pressed` says which one it is in. */
588
+ label: string;
589
+ icon: ReactNode;
590
+ /** Make it a toggle: `aria-pressed` and the brand "on" look (the brand glyph on the
591
+ * quiet brand fill), as {@link FloatingActionButtonProps.pressed}. Ignored on a link. */
592
+ pressed?: boolean;
593
+ /**
594
+ * `primary`: the brand-filled member, for the group's main action (the /transactions
595
+ * "+"). Default: the quiet one.
596
+ */
597
+ variant?: "default" | "primary";
598
+ /**
599
+ * A count in a dot at the member's top end corner — the /transactions invoices link's
600
+ * pending count. Nothing is drawn for `0` or less; above 99 it reads `99+`. The count
601
+ * joins the accessible name through `floatingPanel.badge` ("Invoices, 3 new"), or
602
+ * `badgeLabel` when passed.
603
+ */
604
+ badge?: number;
605
+ /** The badge's words for a reader, replacing `floatingPanel.badge` — "3 to review". */
606
+ badgeLabel?: string;
607
+ /** The dot's colour. Default `warning`, the pending count's. */
608
+ badgeTone?: "warning" | "danger" | "brand";
609
+ /** Show `label` in the kit {@link Tooltip} on hover and focus. Default `true`: the
610
+ * members are icon-only and this is their only visible label. */
611
+ tooltip?: boolean;
612
+ /** Where the tooltip opens. Default `top` — the group sits at the bottom of the screen. */
613
+ tooltipSide?: TooltipSide;
614
+ /** Make it a link — the /transactions invoices member. A plain `<a>` unless
615
+ * `renderLink` hands it to your router. */
616
+ href?: string;
617
+ /** Render the link through your router's `Link` — see {@link FloatingActionLinkProps}. */
618
+ renderLink?: (props: FloatingActionLinkProps) => ReactElement;
619
+ onClick?: (event: MouseEvent<HTMLElement>) => void;
620
+ ref?: Ref<HTMLButtonElement>;
621
+ }
622
+
623
+ const BADGE_TONE: Record<NonNullable<FloatingActionProps["badgeTone"]>, string> = {
624
+ warning: "bg-[var(--warning)] text-[var(--text-inverse)]",
625
+ danger: "bg-[var(--danger)] text-[var(--danger-contrast)]",
626
+ brand: "bg-[var(--brand)] text-[var(--brand-contrast)]",
627
+ };
628
+
629
+ /**
630
+ * One member of a {@link FloatingActionGroup}: a `size-12` icon button, toggle or link
631
+ * with its label in a styled tooltip — what each keksdose member wrapped by hand in
632
+ * `<Tooltip portal>`. The tooltip is portalled because the group clips (`overflow-hidden`
633
+ * keeps the brand member's fill inside the pill's corners), and the focus ring is inset
634
+ * for the same reason.
635
+ */
636
+ export function FloatingAction({
637
+ label,
638
+ icon,
639
+ pressed,
640
+ variant = "default",
641
+ badge,
642
+ badgeLabel,
643
+ badgeTone = "warning",
644
+ tooltip = true,
645
+ tooltipSide = "top",
646
+ href,
647
+ renderLink,
648
+ className,
649
+ type = "button",
650
+ onClick,
651
+ ref,
652
+ ...rest
653
+ }: FloatingActionProps) {
654
+ const labels = useKitLabels("floatingPanel", DEFAULT_FLOATING_PANEL_LABELS);
655
+ const counted = badge !== undefined && badge > 0;
656
+ const name = counted ? `${label}, ${badgeLabel ?? labels.badge(badge)}` : label;
657
+ const isLink = href !== undefined;
658
+ const look = cn(
659
+ "relative flex size-12 items-center justify-center transition-colors",
660
+ "outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]",
661
+ "disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-5",
662
+ variant === "primary"
663
+ ? "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]"
664
+ : pressed && !isLink
665
+ ? "bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)]"
666
+ : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]",
667
+ className,
668
+ );
669
+ const body = (
670
+ <>
671
+ {icon}
672
+ {counted && (
673
+ <span
674
+ aria-hidden
675
+ data-badge=""
676
+ className={cn(
677
+ "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",
678
+ BADGE_TONE[badgeTone],
679
+ )}
680
+ >
681
+ {badge > 99 ? "99+" : badge}
682
+ </span>
683
+ )}
684
+ </>
685
+ );
686
+
687
+ let member: ReactElement;
688
+ if (isLink) {
689
+ // The button-only attributes stay behind; `aria-*`, `data-*` and the like ride along.
690
+ const { disabled: _disabled, form: _form, value: _value, name: _name, ...anchorRest } = rest;
691
+ const linkProps: FloatingActionLinkProps = {
692
+ ...(anchorRest as AnchorHTMLAttributes<HTMLAnchorElement>),
693
+ href,
694
+ "aria-label": name,
695
+ onClick,
696
+ className: look,
697
+ children: body,
698
+ };
699
+ const { children: content, ...anchor } = linkProps;
700
+ member = renderLink ? (
701
+ <RenderedFloatingLink render={renderLink} {...linkProps} />
702
+ ) : (
703
+ <a {...anchor}>{content}</a>
704
+ );
705
+ } else {
706
+ member = (
707
+ <button
708
+ {...rest}
709
+ ref={ref}
710
+ type={type}
711
+ aria-label={name}
712
+ aria-pressed={pressed ?? rest["aria-pressed"]}
713
+ onClick={onClick}
714
+ className={look}
715
+ >
716
+ {body}
717
+ </button>
718
+ );
719
+ }
720
+ if (!tooltip) return member;
721
+ return (
722
+ // A fragment: the bubble says the member's name, and a description that repeats
723
+ // the name is read twice.
724
+ <Tooltip label={label} side={tooltipSide} portal>
725
+ <>{member}</>
726
+ </Tooltip>
727
+ );
728
+ }
729
+
730
+ /** Calls `renderLink` as a component, so its hooks belong to the link, not to us. */
731
+ function RenderedFloatingLink({
732
+ render,
733
+ ...props
734
+ }: FloatingActionLinkProps & { render: (props: FloatingActionLinkProps) => ReactElement }) {
735
+ return render(props);
736
+ }
@@ -1,4 +1,4 @@
1
- import { useRef } from "react";
1
+ import { useId, useRef } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode } from "react";
4
4
  import { X } from "lucide-react";
@@ -20,12 +20,29 @@ import { useFocusTrap } from "../hooks/use-focus-trap";
20
20
  * `aria-describedby` or a `data-tour` anchor belongs on. {@link className} keeps going
21
21
  * to the panel inside it, which is what it has always meant here.
22
22
  */
23
- export interface FullBleedDialogProps extends ComponentPropsWithoutRef<"div"> {
23
+ export interface FullBleedDialogProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
24
24
  open: boolean;
25
25
  /** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —
26
26
  * the platform Back gesture all call this. */
27
27
  onClose: () => void;
28
- /** What the header strip shows beside the close button. */
28
+ /**
29
+ * The dialog's HEADING: rendered as a real `h2` (see {@link headingAs}) at the start
30
+ * of the header strip, and wired to the dialog's `aria-labelledby`, so the dialog is
31
+ * announced by name rather than as just "dialog".
32
+ *
33
+ * Before this the strip was a styled `div`, and a caller that wanted its full-screen
34
+ * phone editor NAMED had to leave this component for `DialogFrame fullBleed` —
35
+ * keksdose's budget-table phone editor says so in a comment at the call site. A
36
+ * caller's own `aria-label` / `aria-labelledby` still wins.
37
+ */
38
+ title?: ReactNode;
39
+ /** The heading level of {@link title}. `h2` by default, as `DialogFrame`'s. */
40
+ headingAs?: "h1" | "h2" | "h3" | "h4";
41
+ /**
42
+ * Free content for the header strip, beside the close button — after the
43
+ * {@link title} when there is one, or on its own (the row editor shows the row's
44
+ * own first cell here). It does not name the dialog; `title` does.
45
+ */
29
46
  header?: ReactNode;
30
47
  closeLabel: string;
31
48
  children: ReactNode;
@@ -81,12 +98,16 @@ export interface FullBleedDialogProps extends ComponentPropsWithoutRef<"div"> {
81
98
  * ## What the caller still owns
82
99
  *
83
100
  * The HEADER's content, because the two callers name themselves differently: the row
84
- * editor shows the row's own first column, the create card a plain title. And `open`,
101
+ * editor shows the row's own first column, the create card a plain title. A plain
102
+ * title belongs in `title`, which is a real heading and the dialog's name; `header`
103
+ * stays the free slot beside it. And `open`,
85
104
  * because who decides is the caller's business — see {@link backCloses}.
86
105
  */
87
106
  export function FullBleedDialog({
88
107
  open,
89
108
  onClose,
109
+ title,
110
+ headingAs: Heading = "h2",
90
111
  header,
91
112
  closeLabel,
92
113
  children,
@@ -108,6 +129,9 @@ export function FullBleedDialog({
108
129
  useOverlayHistory(open && backCloses, requestClose);
109
130
 
110
131
  const dialogRef = useRef<HTMLDivElement>(null);
132
+ const titleId = useId();
133
+ const hasTitle = title !== undefined && title !== null && title !== false;
134
+ const hasHeader = header !== undefined && header !== null && header !== false;
111
135
  // The half of "modal" this dialog was only claiming. It has said
112
136
  // `aria-modal="true"` since it was extracted from `data-table.tsx`, which tells
113
137
  // assistive technology to hide everything outside it — while the user's focus stayed
@@ -166,6 +190,10 @@ export function FullBleedDialog({
166
190
  )}
167
191
  role="dialog"
168
192
  aria-modal="true"
193
+ // The heading names the dialog unless the caller named it some other way — an
194
+ // `aria-labelledby` of its own, or an `aria-label` (which a `labelledby` would
195
+ // silently outrank).
196
+ aria-labelledby={rest["aria-labelledby"] ?? (hasTitle && !rest["aria-label"] ? titleId : undefined)}
169
197
  // `tabIndex={-1}` is what makes the line above more than a claim: without it the
170
198
  // container cannot take focus and `useFocusTrap` silently does nothing.
171
199
  tabIndex={-1}
@@ -195,7 +223,16 @@ export function FullBleedDialog({
195
223
  )}
196
224
  >
197
225
  <div className="flex items-center justify-between gap-2 border-b border-[var(--border)] px-3 py-3">
198
- <div className="min-w-0 flex-1 font-medium">{header}</div>
226
+ {hasTitle ? (
227
+ <div className="flex min-w-0 flex-1 items-center gap-2">
228
+ <Heading id={titleId} className="min-w-0 break-words text-base font-semibold leading-snug text-[var(--text-primary)]">
229
+ {title}
230
+ </Heading>
231
+ {hasHeader && <div className="min-w-0 flex-1 font-medium">{header}</div>}
232
+ </div>
233
+ ) : (
234
+ <div className="min-w-0 flex-1 font-medium">{header}</div>
235
+ )}
199
236
  <button
200
237
  type="button"
201
238
  onClick={requestClose}