@eifi1/ui-kit 0.7.1 → 0.8.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 (222) hide show
  1. package/README.md +32 -17
  2. package/dist/chart.d.ts +3 -2
  3. package/dist/components/alert-banner.d.ts +59 -10
  4. package/dist/components/alert-banner.js +114 -8
  5. package/dist/components/alert-banner.js.map +1 -1
  6. package/dist/components/amount-input.d.ts +3 -0
  7. package/dist/components/button-group.d.ts +26 -0
  8. package/dist/components/button-group.js +49 -0
  9. package/dist/components/button-group.js.map +1 -0
  10. package/dist/components/calculator.d.ts +3 -0
  11. package/dist/components/chart-zoom.d.ts +63 -14
  12. package/dist/components/chart-zoom.js +82 -16
  13. package/dist/components/chart-zoom.js.map +1 -1
  14. package/dist/components/chip.d.ts +67 -2
  15. package/dist/components/chip.js +73 -8
  16. package/dist/components/chip.js.map +1 -1
  17. package/dist/components/confirm-dialog.d.ts +120 -0
  18. package/dist/components/confirm-dialog.js +96 -0
  19. package/dist/components/confirm-dialog.js.map +1 -0
  20. package/dist/components/copy-button.d.ts +62 -0
  21. package/dist/components/copy-button.js +95 -0
  22. package/dist/components/copy-button.js.map +1 -0
  23. package/dist/components/data-table-filter-popover.d.ts +1 -1
  24. package/dist/components/data-table-filters.d.ts +1 -1
  25. package/dist/components/data-table-pagination.d.ts +5 -1
  26. package/dist/components/data-table-pagination.js +71 -58
  27. package/dist/components/data-table-pagination.js.map +1 -1
  28. package/dist/components/data-table-sort.d.ts +28 -5
  29. package/dist/components/data-table-sort.js +11 -7
  30. package/dist/components/data-table-sort.js.map +1 -1
  31. package/dist/components/data-table.d.ts +1 -1
  32. package/dist/components/data-table.js +544 -423
  33. package/dist/components/data-table.js.map +1 -1
  34. package/dist/components/date-picker.d.ts +51 -5
  35. package/dist/components/date-picker.js +152 -40
  36. package/dist/components/date-picker.js.map +1 -1
  37. package/dist/components/description-list.d.ts +60 -0
  38. package/dist/components/description-list.js +112 -0
  39. package/dist/components/description-list.js.map +1 -0
  40. package/dist/components/disclosure.d.ts +37 -2
  41. package/dist/components/disclosure.js +14 -5
  42. package/dist/components/disclosure.js.map +1 -1
  43. package/dist/components/facing-pair.d.ts +2 -0
  44. package/dist/components/file-button.d.ts +17 -1
  45. package/dist/components/file-button.js +8 -1
  46. package/dist/components/file-button.js.map +1 -1
  47. package/dist/components/file-dropzone.d.ts +54 -9
  48. package/dist/components/file-dropzone.js +100 -70
  49. package/dist/components/file-dropzone.js.map +1 -1
  50. package/dist/components/floating-panel.d.ts +107 -0
  51. package/dist/components/floating-panel.js +206 -0
  52. package/dist/components/floating-panel.js.map +1 -0
  53. package/dist/components/full-bleed-dialog.d.ts +12 -1
  54. package/dist/components/full-bleed-dialog.js +26 -5
  55. package/dist/components/full-bleed-dialog.js.map +1 -1
  56. package/dist/components/modal.d.ts +6 -2
  57. package/dist/components/modal.js +2 -1
  58. package/dist/components/modal.js.map +1 -1
  59. package/dist/components/number-field.d.ts +3 -0
  60. package/dist/components/number-input.d.ts +3 -0
  61. package/dist/components/numpad-sheet.d.ts +3 -0
  62. package/dist/components/progress-bar.d.ts +54 -0
  63. package/dist/components/progress-bar.js +94 -0
  64. package/dist/components/progress-bar.js.map +1 -0
  65. package/dist/components/scroll-area.d.ts +53 -0
  66. package/dist/components/scroll-area.js +86 -0
  67. package/dist/components/scroll-area.js.map +1 -0
  68. package/dist/components/separator.d.ts +23 -0
  69. package/dist/components/separator.js +24 -0
  70. package/dist/components/separator.js.map +1 -0
  71. package/dist/components/series-chart-ticks.d.ts +34 -1
  72. package/dist/components/series-chart-ticks.js +83 -1
  73. package/dist/components/series-chart-ticks.js.map +1 -1
  74. package/dist/components/series-chart.d.ts +314 -19
  75. package/dist/components/series-chart.js +510 -120
  76. package/dist/components/series-chart.js.map +1 -1
  77. package/dist/components/skeleton.d.ts +36 -0
  78. package/dist/components/skeleton.js +35 -0
  79. package/dist/components/skeleton.js.map +1 -0
  80. package/dist/components/stat-tile.d.ts +60 -6
  81. package/dist/components/stat-tile.js +60 -26
  82. package/dist/components/stat-tile.js.map +1 -1
  83. package/dist/components/table.d.ts +83 -0
  84. package/dist/components/table.js +166 -0
  85. package/dist/components/table.js.map +1 -0
  86. package/dist/components/toggle-legend.d.ts +3 -2
  87. package/dist/components/toggle-legend.js +1 -1
  88. package/dist/components/toggle-legend.js.map +1 -1
  89. package/dist/components/tree-view.d.ts +129 -0
  90. package/dist/components/tree-view.js +376 -0
  91. package/dist/components/tree-view.js.map +1 -0
  92. package/dist/components/treemap.d.ts +9 -2
  93. package/dist/components/treemap.js +2 -1
  94. package/dist/components/treemap.js.map +1 -1
  95. package/dist/components/ui.d.ts +87 -13
  96. package/dist/components/ui.js +111 -29
  97. package/dist/components/ui.js.map +1 -1
  98. package/dist/components/use-table-state.d.ts +1 -1
  99. package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
  100. package/dist/data-table.d.ts +2 -2
  101. package/dist/data-table.js.map +1 -1
  102. package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
  103. package/dist/hooks/use-copy-to-clipboard.js +81 -0
  104. package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
  105. package/dist/hooks/use-debounce.d.ts +43 -0
  106. package/dist/hooks/use-debounce.js +56 -0
  107. package/dist/hooks/use-debounce.js.map +1 -0
  108. package/dist/hooks/use-file-drop.d.ts +72 -0
  109. package/dist/hooks/use-file-drop.js +57 -0
  110. package/dist/hooks/use-file-drop.js.map +1 -0
  111. package/dist/i18n/defaults.d.ts +3 -0
  112. package/dist/i18n/defaults.js +7 -1
  113. package/dist/i18n/defaults.js.map +1 -1
  114. package/dist/i18n/kit-labels.d.ts +15 -0
  115. package/dist/i18n/kit-labels.js +6 -2
  116. package/dist/i18n/kit-labels.js.map +1 -1
  117. package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
  118. package/dist/i18n/locales/de-CH-informal.js +8 -0
  119. package/dist/i18n/locales/de-CH-informal.js.map +1 -0
  120. package/dist/i18n/locales/de-CH.d.ts +3 -0
  121. package/dist/i18n/locales/de-CH.js +1 -14
  122. package/dist/i18n/locales/de-CH.js.map +1 -1
  123. package/dist/i18n/locales/de-informal.d.ts +64 -0
  124. package/dist/i18n/locales/de-informal.js +37 -0
  125. package/dist/i18n/locales/de-informal.js.map +1 -0
  126. package/dist/i18n/locales/de.d.ts +3 -0
  127. package/dist/i18n/locales/de.js +25 -0
  128. package/dist/i18n/locales/de.js.map +1 -1
  129. package/dist/i18n/locales/es.d.ts +3 -0
  130. package/dist/i18n/locales/es.js +25 -0
  131. package/dist/i18n/locales/es.js.map +1 -1
  132. package/dist/i18n/locales/fr.d.ts +3 -0
  133. package/dist/i18n/locales/fr.js +25 -0
  134. package/dist/i18n/locales/fr.js.map +1 -1
  135. package/dist/i18n/locales/hu.d.ts +3 -0
  136. package/dist/i18n/locales/hu.js +25 -0
  137. package/dist/i18n/locales/hu.js.map +1 -1
  138. package/dist/i18n/locales/it.d.ts +3 -0
  139. package/dist/i18n/locales/it.js +25 -0
  140. package/dist/i18n/locales/it.js.map +1 -1
  141. package/dist/i18n/locales/zh.d.ts +3 -0
  142. package/dist/i18n/locales/zh.js +25 -0
  143. package/dist/i18n/locales/zh.js.map +1 -1
  144. package/dist/i18n/swiss.d.ts +13 -0
  145. package/dist/i18n/swiss.js +19 -0
  146. package/dist/i18n/swiss.js.map +1 -0
  147. package/dist/index.d.ts +25 -10
  148. package/dist/index.js +22 -0
  149. package/dist/index.js.map +1 -1
  150. package/dist/lib/dates.d.ts +37 -1
  151. package/dist/lib/dates.js +19 -0
  152. package/dist/lib/dates.js.map +1 -1
  153. package/dist/search/command-palette.d.ts +15 -1
  154. package/dist/search/command-palette.js +10 -2
  155. package/dist/search/command-palette.js.map +1 -1
  156. package/dist/wizard/stepper-nav.d.ts +35 -2
  157. package/dist/wizard/stepper-nav.js +54 -22
  158. package/dist/wizard/stepper-nav.js.map +1 -1
  159. package/dist/wizard/types.d.ts +107 -5
  160. package/dist/wizard/types.js +2 -1
  161. package/dist/wizard/types.js.map +1 -1
  162. package/dist/wizard/use-wizard.js +141 -21
  163. package/dist/wizard/use-wizard.js.map +1 -1
  164. package/dist/wizard/wizard-summary.d.ts +10 -2
  165. package/dist/wizard/wizard-summary.js +3 -1
  166. package/dist/wizard/wizard-summary.js.map +1 -1
  167. package/dist/wizard.d.ts +4 -1
  168. package/dist/wizard.js.map +1 -1
  169. package/package.json +1 -1
  170. package/src/components/alert-banner.tsx +211 -20
  171. package/src/components/button-group.tsx +75 -0
  172. package/src/components/chart-zoom.tsx +158 -20
  173. package/src/components/chip.tsx +160 -9
  174. package/src/components/confirm-dialog.tsx +242 -0
  175. package/src/components/copy-button.tsx +158 -0
  176. package/src/components/data-table-pagination.tsx +19 -6
  177. package/src/components/data-table-sort.ts +49 -10
  178. package/src/components/data-table.tsx +293 -24
  179. package/src/components/date-picker.tsx +262 -57
  180. package/src/components/description-list.tsx +174 -0
  181. package/src/components/disclosure.tsx +58 -5
  182. package/src/components/file-button.tsx +22 -2
  183. package/src/components/file-dropzone.tsx +198 -100
  184. package/src/components/floating-panel.tsx +341 -0
  185. package/src/components/full-bleed-dialog.tsx +48 -5
  186. package/src/components/modal.tsx +9 -2
  187. package/src/components/progress-bar.tsx +163 -0
  188. package/src/components/scroll-area.tsx +129 -0
  189. package/src/components/separator.tsx +37 -0
  190. package/src/components/series-chart-ticks.ts +135 -0
  191. package/src/components/series-chart.tsx +934 -67
  192. package/src/components/skeleton.tsx +65 -0
  193. package/src/components/stat-tile.tsx +160 -27
  194. package/src/components/table.tsx +263 -0
  195. package/src/components/toggle-legend.tsx +4 -3
  196. package/src/components/tree-view.tsx +589 -0
  197. package/src/components/treemap.tsx +10 -2
  198. package/src/components/ui.tsx +263 -39
  199. package/src/data-table.ts +2 -0
  200. package/src/hooks/use-copy-to-clipboard.ts +122 -0
  201. package/src/hooks/use-debounce.ts +105 -0
  202. package/src/hooks/use-file-drop.ts +123 -0
  203. package/src/i18n/defaults.ts +6 -0
  204. package/src/i18n/kit-labels.tsx +19 -0
  205. package/src/i18n/locales/de-CH-informal.ts +10 -0
  206. package/src/i18n/locales/de-CH.ts +1 -16
  207. package/src/i18n/locales/de-informal.ts +61 -0
  208. package/src/i18n/locales/de.ts +25 -0
  209. package/src/i18n/locales/es.ts +25 -0
  210. package/src/i18n/locales/fr.ts +25 -0
  211. package/src/i18n/locales/hu.ts +25 -0
  212. package/src/i18n/locales/it.ts +25 -0
  213. package/src/i18n/locales/zh.ts +25 -0
  214. package/src/i18n/swiss.ts +25 -0
  215. package/src/index.ts +29 -0
  216. package/src/lib/dates.ts +48 -0
  217. package/src/search/command-palette.tsx +29 -3
  218. package/src/wizard/stepper-nav.tsx +102 -28
  219. package/src/wizard/types.ts +106 -4
  220. package/src/wizard/use-wizard.ts +211 -31
  221. package/src/wizard/wizard-summary.tsx +24 -12
  222. package/src/wizard.ts +3 -2
@@ -0,0 +1,341 @@
1
+ import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import type { ButtonHTMLAttributes, CSSProperties, KeyboardEvent, ReactNode, Ref } from "react";
4
+ import { X } from "lucide-react";
5
+
6
+ import { cn } from "../lib/cn";
7
+ import { dirOf, type Direction } from "../lib/direction";
8
+ import { useKitLabels } from "../i18n/kit-labels";
9
+
10
+ export interface FloatingPanelLabels {
11
+ /** Accessible name of the panel header's X. */
12
+ close: string;
13
+ }
14
+
15
+ export const DEFAULT_FLOATING_PANEL_LABELS: FloatingPanelLabels = { close: "Close" };
16
+
17
+ /** A LOGICAL corner: `bottom-end` is bottom-right in LTR and bottom-left in RTL. */
18
+ export type FloatingCorner = "bottom-end" | "bottom-start";
19
+
20
+ /**
21
+ * How far off the bottom edge anything floating here sits: clear of `AppShell`'s phone
22
+ * nav (`--app-nav-h`, `0px` above `md` where the nav is not rendered) and of the home
23
+ * indicator, whichever reaches higher — the nav does not pad itself for the inset, so
24
+ * with no nav on screen the inset is all there is to clear. `max`, not a sum: where the
25
+ * nav does cover the inset, adding both would float the button a nav-height too high.
26
+ */
27
+ const BOTTOM_CLEARANCE = "max(var(--app-nav-h, 0px), env(safe-area-inset-bottom, 0px))";
28
+ /** The side gutter, widened for a landscape notch. Both insets, because a logical
29
+ * side has no one physical inset to name. */
30
+ const SIDE_GUTTER = "max(1rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))";
31
+
32
+ function cornerStyle(corner: FloatingCorner, lift: string): CSSProperties {
33
+ return {
34
+ bottom: `calc(${BOTTOM_CLEARANCE} + ${lift})`,
35
+ // Logical properties, so the corner follows the reading direction with no JS. The
36
+ // portal carries the `dir` across (see `usePortalDir`).
37
+ [corner === "bottom-start" ? "insetInlineStart" : "insetInlineEnd"]: SIDE_GUTTER,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * The reading direction where the component was RENDERED, for its portalled root: a
43
+ * portal to `<body>` leaves the subtree whose `dir` it inherited, and a logical
44
+ * `inset-inline-end` resolves against the portal's own direction. Read off a hidden
45
+ * inline marker in a layout effect, so the first painted frame already has it.
46
+ */
47
+ function usePortalDir(): [Ref<HTMLSpanElement>, Direction | undefined] {
48
+ const marker = useRef<HTMLSpanElement>(null);
49
+ const [dir, setDir] = useState<Direction | undefined>(undefined);
50
+ useLayoutEffect(() => {
51
+ setDir(dirOf(marker.current));
52
+ }, []);
53
+ return [marker, dir];
54
+ }
55
+
56
+ export interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
57
+ /** The accessible name — the button shows only its icon. */
58
+ label: string;
59
+ icon: ReactNode;
60
+ /** Default `bottom-end`. */
61
+ corner?: FloatingCorner;
62
+ /**
63
+ * Distance above the nav / home indicator, as a CSS length. Default `1rem`. Raise it
64
+ * to stack above another floating control in the same corner — keksdose's assistant
65
+ * clears the transactions page's own action group this way (`calc(1rem + 4rem)`).
66
+ */
67
+ offset?: string;
68
+ ref?: Ref<HTMLButtonElement>;
69
+ }
70
+
71
+ /**
72
+ * A round, icon-only button pinned to a corner of the viewport — the trigger of a
73
+ * {@link FloatingPanel}, and usable alone for a page's one primary action.
74
+ *
75
+ * keksdose hand-placed each of its floating controls: the assistant launcher at
76
+ * `fixed bottom-36 right-4 md:bottom-6`, the feedback page's and the transactions
77
+ * page's action groups at `right-4 bottom-20`. Every one of those numbers is a guess
78
+ * at the phone nav's height plus whatever else floats there (Keksdose live #314 found
79
+ * out what such a guess is worth for a footer), `right` rather than `end` puts it over
80
+ * the page's leading edge in RTL, and none allows for the home indicator. Here the
81
+ * base is `--app-nav-h` and the safe-area inset, the corner is logical, and
82
+ * {@link FloatingActionButtonProps.offset} is the one number left to the caller — the
83
+ * height of whatever else it has to clear, not the nav's.
84
+ *
85
+ * Portalled, because `position: fixed` inside any transformed ancestor (an animated
86
+ * page transition) is fixed to that ancestor, not to the screen.
87
+ */
88
+ export function FloatingActionButton({
89
+ label,
90
+ icon,
91
+ corner = "bottom-end",
92
+ offset = "1rem",
93
+ className,
94
+ style,
95
+ type = "button",
96
+ ...rest
97
+ }: FloatingActionButtonProps) {
98
+ const [marker, dir] = usePortalDir();
99
+ return (
100
+ <>
101
+ <span ref={marker} hidden />
102
+ {typeof document !== "undefined" &&
103
+ createPortal(
104
+ <button
105
+ {...rest}
106
+ type={type}
107
+ dir={dir}
108
+ aria-label={label}
109
+ title={rest.title ?? label}
110
+ style={{ ...cornerStyle(corner, offset), ...style }}
111
+ className={cn(
112
+ "fixed z-40 inline-flex size-12 items-center justify-center rounded-full",
113
+ "bg-[var(--brand)] text-[var(--brand-contrast)] shadow-lg transition-colors hover:bg-[var(--brand-hover)]",
114
+ "outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2",
115
+ "[&_svg]:size-6",
116
+ className,
117
+ )}
118
+ >
119
+ {icon}
120
+ </button>,
121
+ document.body,
122
+ )}
123
+ </>
124
+ );
125
+ }
126
+
127
+ export interface FloatingPanelProps {
128
+ /** The panel's heading, and its accessible name. */
129
+ title: ReactNode;
130
+ /** The trigger's accessible name ("Assistant", "Send feedback"). */
131
+ fabLabel: string;
132
+ fabIcon: ReactNode;
133
+ /** Controlled open state. Leave it out to let the panel own it. */
134
+ open?: boolean;
135
+ /** Uncontrolled initial state. Default closed. */
136
+ defaultOpen?: boolean;
137
+ /** Every request to open or close — the FAB, the X, Escape. */
138
+ onOpenChange?: (open: boolean) => void;
139
+ /** Default `bottom-end`. */
140
+ corner?: FloatingCorner;
141
+ /** The FAB's distance above the nav — see {@link FloatingActionButtonProps.offset}.
142
+ * From `md` up the card sits above the FAB, so it moves with it. */
143
+ offset?: string;
144
+ /** Default: `floatingPanel.close` from the {@link UiKitProvider}, else "Close". */
145
+ closeLabel?: string;
146
+ /**
147
+ * Where focus goes on open. Default the panel itself, so a phone's keyboard does not
148
+ * rise over the sheet before the user has asked to type; pass the chat field for a
149
+ * panel whose one job is typing.
150
+ */
151
+ initialFocus?: () => HTMLElement | null;
152
+ /** Extra classes for the panel. */
153
+ className?: string;
154
+ /** Extra classes for the scrolling body. */
155
+ bodyClassName?: string;
156
+ children: ReactNode;
157
+ }
158
+
159
+ /**
160
+ * A docked, NON-MODAL panel in a corner of the screen, opened by a
161
+ * {@link FloatingActionButton} — keksdose's assistant launcher and its feedback page,
162
+ * which each built one.
163
+ *
164
+ * ## Not a Modal
165
+ *
166
+ * The page stays usable while it is open: that is the point of an assistant beside the
167
+ * data it is asked about. So there is no backdrop, no scroll lock, no `aria-modal` and
168
+ * no focus trap — Tab walks out of the panel into the page and back, and a click on the
169
+ * page does not close it. It is `role="dialog"` without `aria-modal`, which is what
170
+ * ARIA calls a non-modal dialog, named by its heading.
171
+ *
172
+ * What it keeps from a dialog is the focus HANDOFF: opening moves focus into it (a
173
+ * keyboard user who pressed the FAB should not have to hunt for what appeared), and
174
+ * closing gives it back to the FAB — but only when focus was in the panel, so a panel
175
+ * closed from the page (a controlled owner) never pulls focus out of the field the
176
+ * user is typing in. Escape closes it while focus is inside, and only then: on the
177
+ * panel element, not the document, so Escape in the page keeps meaning whatever the
178
+ * page means by it.
179
+ *
180
+ * ## Layout
181
+ *
182
+ * From `md` up, a card above the FAB in its corner. Below `md`, a full-width sheet
183
+ * docked to the bottom, sitting ON the `AppShell` bottom nav (`--app-nav-h`) and
184
+ * clear of the home indicator. Pure CSS — no width is measured in JavaScript.
185
+ *
186
+ * The FAB carries `aria-expanded` and, while the panel exists, `aria-controls`. The
187
+ * panel unmounts when closed; an app that must keep a draft across a close keeps it in
188
+ * its own state, where it also survives a reload.
189
+ */
190
+ export function FloatingPanel({
191
+ title,
192
+ fabLabel,
193
+ fabIcon,
194
+ open: openProp,
195
+ defaultOpen = false,
196
+ onOpenChange,
197
+ corner = "bottom-end",
198
+ offset = "1rem",
199
+ closeLabel,
200
+ initialFocus,
201
+ className,
202
+ bodyClassName,
203
+ children,
204
+ }: FloatingPanelProps) {
205
+ const labels = useKitLabels(
206
+ "floatingPanel",
207
+ DEFAULT_FLOATING_PANEL_LABELS,
208
+ closeLabel === undefined ? undefined : { close: closeLabel },
209
+ );
210
+ const [ownOpen, setOwnOpen] = useState(defaultOpen);
211
+ const open = openProp ?? ownOpen;
212
+ const setOpen = (next: boolean) => {
213
+ if (openProp === undefined) setOwnOpen(next);
214
+ onOpenChange?.(next);
215
+ };
216
+
217
+ const uid = useId();
218
+ const panelId = `${uid}-panel`;
219
+ const titleId = `${uid}-title`;
220
+ const fabRef = useRef<HTMLButtonElement>(null);
221
+ const panelRef = useRef<HTMLDivElement>(null);
222
+ const [marker, dir] = usePortalDir();
223
+
224
+ const initialFocusRef = useRef(initialFocus);
225
+ useEffect(() => {
226
+ initialFocusRef.current = initialFocus;
227
+ });
228
+
229
+ // Focus follows the open/closed TRANSITION, never the mount: a panel that renders
230
+ // open (`defaultOpen`, a restored route) does not take the page's focus by existing.
231
+ const wasOpen = useRef(open);
232
+ // Whether focus was inside the panel when it last changed — read at close time, when
233
+ // the panel (and whatever had focus in it) is already gone.
234
+ const focusInside = useRef(false);
235
+ useEffect(() => {
236
+ const changed = wasOpen.current !== open;
237
+ wasOpen.current = open;
238
+ if (!changed) return;
239
+ if (open) {
240
+ (initialFocusRef.current?.() ?? panelRef.current)?.focus();
241
+ return;
242
+ }
243
+ const active = document.activeElement;
244
+ const lost = active === null || active === document.body;
245
+ if (focusInside.current || lost) fabRef.current?.focus();
246
+ focusInside.current = false;
247
+ }, [open]);
248
+
249
+ const onPanelKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
250
+ if (e.key !== "Escape" || e.defaultPrevented) return;
251
+ // Stopped here, so an overlay underneath with a document-level Escape listener does
252
+ // not close too — the innermost thing that has focus is the one that closes.
253
+ e.stopPropagation();
254
+ focusInside.current = true;
255
+ setOpen(false);
256
+ };
257
+
258
+ return (
259
+ <>
260
+ <span ref={marker} hidden />
261
+ <FloatingActionButton
262
+ ref={fabRef}
263
+ label={fabLabel}
264
+ icon={fabIcon}
265
+ corner={corner}
266
+ offset={offset}
267
+ aria-expanded={open}
268
+ aria-controls={open ? panelId : undefined}
269
+ onClick={() => {
270
+ // A press ON the FAB closing the panel: the FAB keeps focus by being clicked,
271
+ // nothing to hand back.
272
+ focusInside.current = false;
273
+ setOpen(!open);
274
+ }}
275
+ />
276
+ {open &&
277
+ typeof document !== "undefined" &&
278
+ createPortal(
279
+ // A container for Escape bubbling up from anything inside it, as a dialog is.
280
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
281
+ <div
282
+ ref={panelRef}
283
+ id={panelId}
284
+ role="dialog"
285
+ aria-labelledby={titleId}
286
+ tabIndex={-1}
287
+ dir={dir}
288
+ data-corner={corner}
289
+ onKeyDown={onPanelKeyDown}
290
+ onFocus={() => {
291
+ focusInside.current = true;
292
+ }}
293
+ onBlur={(e) => {
294
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) focusInside.current = false;
295
+ }}
296
+ // The phone's sheet sits on the nav; from `md` up the card floats above the
297
+ // FAB (its offset + the 3rem button + a 0.75rem gap).
298
+ style={
299
+ {
300
+ "--fp-bottom": `calc(${BOTTOM_CLEARANCE})`,
301
+ "--fp-bottom-md": `calc(${BOTTOM_CLEARANCE} + ${offset} + 3.75rem)`,
302
+ "--fp-side": SIDE_GUTTER,
303
+ } as CSSProperties
304
+ }
305
+ className={cn(
306
+ "animate-sheet fixed z-40 flex flex-col overflow-hidden outline-none",
307
+ "border border-[var(--border)] bg-[var(--bg-surface)] shadow-2xl",
308
+ // Phone: full width, docked, rounded only where it meets the page.
309
+ "inset-x-0 bottom-[var(--fp-bottom)] max-h-[min(85dvh,calc(100dvh_-_var(--fp-bottom)_-_1rem))] rounded-t-xl border-x-0 border-b-0",
310
+ "pb-[env(safe-area-inset-bottom,0px)] md:pb-0",
311
+ // md+: a card in the corner, above the FAB.
312
+ "md:inset-x-auto md:bottom-[var(--fp-bottom-md)] md:w-[24rem] md:max-h-[min(36rem,calc(100dvh_-_var(--fp-bottom-md)_-_1rem))] md:rounded-xl md:border",
313
+ corner === "bottom-start" ? "md:start-[var(--fp-side)]" : "md:end-[var(--fp-side)]",
314
+ className,
315
+ )}
316
+ >
317
+ <div className="flex shrink-0 items-center justify-between gap-2 border-b border-[var(--border)] px-4 py-3">
318
+ <h2 id={titleId} className="min-w-0 truncate text-base font-semibold text-[var(--text-primary)]">
319
+ {title}
320
+ </h2>
321
+ <button
322
+ type="button"
323
+ onClick={() => {
324
+ focusInside.current = true;
325
+ setOpen(false);
326
+ }}
327
+ aria-label={labels.close}
328
+ className="-me-1.5 shrink-0 rounded p-1.5 text-[var(--text-muted)] outline-none hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)] focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
329
+ >
330
+ <X aria-hidden className="size-5" />
331
+ </button>
332
+ </div>
333
+ <div className={cn("min-h-0 flex-1 overflow-y-auto overscroll-contain p-4", bodyClassName)}>
334
+ {children}
335
+ </div>
336
+ </div>,
337
+ document.body,
338
+ )}
339
+ </>
340
+ );
341
+ }
@@ -43,6 +43,17 @@ export interface FullBleedDialogProps extends ComponentPropsWithoutRef<"div"> {
43
43
  backCloses?: boolean;
44
44
  /** Extra classes for the PANEL (not the backdrop). */
45
45
  className?: string;
46
+ /**
47
+ * A row pinned under the scrolling body — an Apply, a Save. Keksdose's `RangeSheet`
48
+ * (reports/report-range-field.tsx) is a hand-rolled copy of this dialog for want of
49
+ * exactly this slot: a date range picked on a phone is committed by a sticky Apply,
50
+ * and a button at the end of the body scrolls away with the calendar.
51
+ *
52
+ * Outside the scroller, so it stays put without `position: sticky` (which would
53
+ * need the body to be the scroll container of its own child and paint over the
54
+ * last field); padded for the home indicator, since it is the panel's bottom edge.
55
+ */
56
+ footer?: ReactNode;
46
57
  }
47
58
 
48
59
  /**
@@ -81,6 +92,10 @@ export function FullBleedDialog({
81
92
  children,
82
93
  backCloses = true,
83
94
  className,
95
+ footer,
96
+ onMouseDown,
97
+ onMouseUp,
98
+ onKeyDown,
84
99
  ...rest
85
100
  }: FullBleedDialogProps) {
86
101
  // Every way OUT goes through `requestClose`, so the panel lowers itself before the
@@ -107,7 +122,10 @@ export function FullBleedDialog({
107
122
  useFocusTrap(dialogRef, { active: open });
108
123
 
109
124
  const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
110
- if (e.key !== "Escape") return;
125
+ // The caller's handler first (keksdose's transaction-search keeps its list keys on
126
+ // the dialog); `preventDefault()` there claims the key, Escape included.
127
+ onKeyDown?.(e);
128
+ if (e.defaultPrevented || e.key !== "Escape") return;
111
129
  // On the panel, the way `Modal` does it, and NOT through `useEscapeKey`: this
112
130
  // dialog is the thing a `PickerSheet` opens on top of, and a document-level
113
131
  // listener cannot tell which of the two the user meant. One press would dismiss
@@ -148,7 +166,21 @@ export function FullBleedDialog({
148
166
  tabIndex={-1}
149
167
  ref={dialogRef}
150
168
  onKeyDown={handleKeyDown}
151
- {...backdropClose}
169
+ // COMPOSED with the backdrop's, not overridden by them. These two used to ride
170
+ // `...rest` and then lose to `{...backdropClose}` spread after it, silently —
171
+ // and the one caller about to pass one is keksdose's RangeSheet, whose
172
+ // `onMouseDown={(e) => e.stopPropagation()}` is its dev#477 fix: a press in a
173
+ // portalled sheet must not reach a document-level "outside click" listener,
174
+ // which would unmount the day cell before its `click`. The caller's runs first;
175
+ // the backdrop's still runs after it, since it is the same element.
176
+ onMouseDown={(e) => {
177
+ onMouseDown?.(e);
178
+ backdropClose.onMouseDown(e);
179
+ }}
180
+ onMouseUp={(e) => {
181
+ onMouseUp?.(e);
182
+ backdropClose.onMouseUp(e);
183
+ }}
152
184
  >
153
185
  <div
154
186
  className={cn(
@@ -158,12 +190,12 @@ export function FullBleedDialog({
158
190
  )}
159
191
  >
160
192
  <div className="flex items-center justify-between gap-2 border-b border-[var(--border)] px-3 py-3">
161
- <div className="min-w-0 font-medium">{header}</div>
193
+ <div className="min-w-0 flex-1 font-medium">{header}</div>
162
194
  <button
163
195
  type="button"
164
196
  onClick={requestClose}
165
197
  aria-label={closeLabel}
166
- className="-mr-1 shrink-0 rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
198
+ className="-me-1 shrink-0 rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
167
199
  >
168
200
  <X className="size-5" />
169
201
  </button>
@@ -171,7 +203,18 @@ export function FullBleedDialog({
171
203
  {/* px-3, not px-4: every pixel of chrome here is width the form fields lose
172
204
  on a phone (feedback #32). The body is the only thing that scrolls, so the
173
205
  header stays put and the page underneath cannot move at all. */}
174
- <div className="flex-1 overflow-y-auto overscroll-contain px-3 py-3">{children}</div>
206
+ {/* `min-h-0` so it is the BODY that gives way to a footer, not the footer that
207
+ is pushed off the bottom edge. */}
208
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3">{children}</div>
209
+ {footer !== undefined && footer !== null && footer !== false && (
210
+ <div
211
+ data-full-bleed-footer=""
212
+ className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-[var(--border)] px-3 pt-3"
213
+ style={{ paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))" }}
214
+ >
215
+ {footer}
216
+ </div>
217
+ )}
175
218
  </div>
176
219
  </div>,
177
220
  document.body,
@@ -117,9 +117,13 @@ export function useBackdropClose(onClose: () => void) {
117
117
  * where a `data-tour` anchor, a test id, an `aria-describedby` or an `aria-label` has
118
118
  * to land; before this, a closed prop list dropped all four silently (audit §api-design).
119
119
  */
120
- export interface ModalProps extends ComponentPropsWithoutRef<"div"> {
120
+ export interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"> {
121
121
  /** Invoked on backdrop click and on Escape. */
122
122
  onClose: () => void;
123
+ /** `"alertdialog"` for a dialog that interrupts to ask a question the user must
124
+ * answer (a confirmation) — screen readers announce it with more urgency. Only the
125
+ * two dialog roles are allowed: the panel stays modal either way. */
126
+ role?: "dialog" | "alertdialog";
123
127
  children: ReactNode;
124
128
  /**
125
129
  * Panel max-width: `md` (28rem), `lg` (32rem), `xl` (48rem).
@@ -187,6 +191,7 @@ export function Modal({
187
191
  fullBleed,
188
192
  draggable,
189
193
  style,
194
+ role = "dialog",
190
195
  ...rest
191
196
  }: ModalProps) {
192
197
  const panelRef = useRef<HTMLDivElement>(null);
@@ -252,7 +257,9 @@ export function Modal({
252
257
  // walks straight out of) shows up nowhere near the call site that caused it.
253
258
  {...rest}
254
259
  ref={panelRef}
255
- role="dialog"
260
+ // Normalised at runtime too: an untyped caller's `role="presentation"` must not
261
+ // take the dialog role away (props-passthrough pins that).
262
+ role={role === "alertdialog" ? "alertdialog" : "dialog"}
256
263
  aria-modal="true"
257
264
  aria-labelledby={labelledBy}
258
265
  tabIndex={-1}
@@ -0,0 +1,163 @@
1
+ import { useId } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
5
+
6
+ export type ProgressBarTone = "brand" | "neutral" | "success" | "warning" | "danger" | "info";
7
+ export type ProgressBarSize = "sm" | "md" | "lg";
8
+
9
+ const FILL: Record<ProgressBarTone, string> = {
10
+ brand: "bg-[var(--brand)]",
11
+ neutral: "bg-[var(--text-muted)]",
12
+ success: "bg-[var(--success)]",
13
+ warning: "bg-[var(--warning)]",
14
+ danger: "bg-[var(--danger)]",
15
+ info: "bg-[var(--info)]",
16
+ };
17
+
18
+ const TRACK_HEIGHT: Record<ProgressBarSize, string> = { sm: "h-1", md: "h-2", lg: "h-3" };
19
+
20
+ export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> {
21
+ /** Where it stands. Leave it undefined for an INDETERMINATE bar — work is under way
22
+ * and nobody knows how much is left (an import that reports no count). */
23
+ value?: number;
24
+ min?: number;
25
+ max?: number;
26
+ /**
27
+ * `progress` (default): a task moving toward completion — `role="progressbar"`.
28
+ * `meter`: a share of a known whole that is not going anywhere (budget used, disk
29
+ * space, one category's slice of spending) — `role="meter"`. keksdose's share bars
30
+ * were progress bars to a screen reader, which reads "busy" into a figure that is
31
+ * simply a figure. A meter is never indeterminate; a missing value reads as `min`.
32
+ */
33
+ variant?: "progress" | "meter";
34
+ /** Visible label above the bar, and its accessible name. Without it, pass
35
+ * `aria-label` (an indeterminate bar with neither is named `common.loading`). */
36
+ label?: ReactNode;
37
+ /** Show the value text at the end of the label row. */
38
+ showValue?: boolean;
39
+ /**
40
+ * The value as words — both the visible `showValue` text and `aria-valuetext`.
41
+ * Default: the fraction as a percentage in the kit's locale ("42 %" in French).
42
+ * Pass one for anything that is not a percentage: `(v, max) => \`${v} of ${max} files\``.
43
+ */
44
+ formatValue?: (value: number, max: number, min: number) => string;
45
+ tone?: ProgressBarTone;
46
+ size?: ProgressBarSize;
47
+ locale?: string;
48
+ }
49
+
50
+ /**
51
+ * A horizontal bar that fills toward `max`.
52
+ *
53
+ * keksdose (import progress, budget share bars) and kastlan (upload and job
54
+ * progress) each drew their own div-in-a-div. None had a role, so a reader heard
55
+ * nothing at all; the share bars that did have one claimed to be progress.
56
+ *
57
+ * `aria-valuetext` is always set when there is a value: a bare `aria-valuenow="37"`
58
+ * is read as "37" by some readers and "37 percent" by others, and neither is right
59
+ * when `max` is 212 files.
60
+ *
61
+ * The indeterminate sweep stops under `prefers-reduced-motion`. It becomes a still,
62
+ * half-strength bar across the whole track — still visibly "something is happening",
63
+ * with nothing moving. The sweep is a Tailwind arbitrary `animate-[…]` rather than
64
+ * tokens.css's `.animate-indeterminate` class because that one is UNLAYERED, and an
65
+ * unlayered rule beats every layered utility, `motion-reduce:animate-none` included.
66
+ */
67
+ export function ProgressBar({
68
+ value,
69
+ min = 0,
70
+ max = 100,
71
+ variant = "progress",
72
+ label,
73
+ showValue = false,
74
+ formatValue,
75
+ tone = "brand",
76
+ size = "md",
77
+ locale,
78
+ className,
79
+ "aria-label": ariaLabel,
80
+ "aria-labelledby": ariaLabelledBy,
81
+ ...rest
82
+ }: ProgressBarProps) {
83
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
84
+ const kitLocale = useKitLocale(locale);
85
+ const labelId = useId();
86
+
87
+ const meter = variant === "meter";
88
+ const current = value === undefined || !Number.isFinite(value) ? (meter ? min : undefined) : value;
89
+ const indeterminate = current === undefined;
90
+ const span = max - min;
91
+ const clamped = indeterminate ? undefined : Math.min(max, Math.max(min, current));
92
+ const fraction = clamped === undefined || span <= 0 ? 0 : (clamped - min) / span;
93
+
94
+ const valueText =
95
+ clamped === undefined
96
+ ? undefined
97
+ : formatValue
98
+ ? formatValue(clamped, max, min)
99
+ : new Intl.NumberFormat(kitLocale, { style: "percent", maximumFractionDigits: 0 }).format(fraction);
100
+
101
+ const labelledBy = ariaLabelledBy ?? (label != null ? labelId : undefined);
102
+ const name = labelledBy ? undefined : (ariaLabel ?? (indeterminate ? common.loading : undefined));
103
+
104
+ return (
105
+ <div className={cn("min-w-0", className)} data-state={indeterminate ? "indeterminate" : "determinate"}>
106
+ {(label != null || (showValue && valueText !== undefined)) && (
107
+ <div className="mb-1 flex items-baseline gap-2 text-sm">
108
+ {label != null && (
109
+ <span id={labelId} className="min-w-0 text-[var(--text-secondary)]">
110
+ {label}
111
+ </span>
112
+ )}
113
+ {showValue && valueText !== undefined && (
114
+ // Hidden: the same words are the bar's aria-valuetext, and reading them
115
+ // twice in a row is noise.
116
+ <span aria-hidden className="ms-auto shrink-0 tabular-nums text-[var(--text-muted)]">
117
+ {valueText}
118
+ </span>
119
+ )}
120
+ </div>
121
+ )}
122
+ <div
123
+ {...rest}
124
+ role={meter ? "meter" : "progressbar"}
125
+ aria-label={name}
126
+ aria-labelledby={labelledBy}
127
+ aria-valuemin={indeterminate ? undefined : min}
128
+ aria-valuemax={indeterminate ? undefined : max}
129
+ aria-valuenow={clamped}
130
+ aria-valuetext={valueText}
131
+ aria-busy={!meter && indeterminate ? true : undefined}
132
+ // `relative` + `overflow-hidden`: the indeterminate segment is positioned in
133
+ // here and must not paint past the rounded ends.
134
+ className={cn("relative w-full overflow-hidden rounded-full bg-[var(--bg-active)]", TRACK_HEIGHT[size])}
135
+ >
136
+ {indeterminate ? (
137
+ <div
138
+ data-part="fill"
139
+ className={cn(
140
+ // `left-0`, deliberately physical: the keyframes (tokens.css
141
+ // `indeterminate-sweep`) translate along the physical x axis, and under
142
+ // RTL the same keyframes are run in REVERSE — from the right edge back
143
+ // to the left. A logical `start-0` would park the segment at the right
144
+ // in RTL and the reversed sweep would then start off-screen.
145
+ "absolute inset-y-0 left-0 w-1/3 rounded-full",
146
+ "animate-[indeterminate-sweep_1.4s_ease-in-out_infinite] rtl:[animation-direction:reverse]",
147
+ "motion-reduce:w-full motion-reduce:animate-none motion-reduce:opacity-50",
148
+ FILL[tone],
149
+ )}
150
+ />
151
+ ) : (
152
+ <div
153
+ data-part="fill"
154
+ // Block flow starts at the inline start, so a plain width fills from the
155
+ // right under `dir="rtl"` with nothing extra.
156
+ className={cn("h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none", FILL[tone])}
157
+ style={{ width: `${fraction * 100}%` }}
158
+ />
159
+ )}
160
+ </div>
161
+ </div>
162
+ );
163
+ }