@eifi1/ui-kit 0.8.1 → 0.10.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 (204) hide show
  1. package/README.md +25 -11
  2. package/dist/components/alert-banner.d.ts +32 -5
  3. package/dist/components/alert-banner.js +57 -14
  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 +66 -0
  10. package/dist/components/bulk-action-bar.js +149 -0
  11. package/dist/components/bulk-action-bar.js.map +1 -0
  12. package/dist/components/calculator.d.ts +6 -0
  13. package/dist/components/chip.d.ts +58 -10
  14. package/dist/components/chip.js +91 -7
  15. package/dist/components/chip.js.map +1 -1
  16. package/dist/components/copy-button.d.ts +13 -4
  17. package/dist/components/copy-button.js +17 -4
  18. package/dist/components/copy-button.js.map +1 -1
  19. package/dist/components/data-table-filter-popover.d.ts +1 -1
  20. package/dist/components/data-table-filters.d.ts +1 -1
  21. package/dist/components/data-table.d.ts +1 -1
  22. package/dist/components/data-table.js +122 -91
  23. package/dist/components/data-table.js.map +1 -1
  24. package/dist/components/date-picker.d.ts +89 -3
  25. package/dist/components/date-picker.js +163 -68
  26. package/dist/components/date-picker.js.map +1 -1
  27. package/dist/components/description-list.d.ts +43 -6
  28. package/dist/components/description-list.js +91 -11
  29. package/dist/components/description-list.js.map +1 -1
  30. package/dist/components/dialog-frame.d.ts +7 -0
  31. package/dist/components/dialog-frame.js.map +1 -1
  32. package/dist/components/disclosure.d.ts +68 -4
  33. package/dist/components/disclosure.js +33 -15
  34. package/dist/components/disclosure.js.map +1 -1
  35. package/dist/components/file-dropzone.d.ts +3 -4
  36. package/dist/components/file-dropzone.js +2 -1
  37. package/dist/components/file-dropzone.js.map +1 -1
  38. package/dist/components/floating-panel.d.ts +29 -2
  39. package/dist/components/floating-panel.js +16 -2
  40. package/dist/components/floating-panel.js.map +1 -1
  41. package/dist/components/full-bleed-dialog.d.ts +23 -4
  42. package/dist/components/full-bleed-dialog.js +11 -2
  43. package/dist/components/full-bleed-dialog.js.map +1 -1
  44. package/dist/components/list.d.ts +174 -0
  45. package/dist/components/list.js +215 -0
  46. package/dist/components/list.js.map +1 -0
  47. package/dist/components/menu-item.d.ts +99 -0
  48. package/dist/components/menu-item.js +83 -0
  49. package/dist/components/menu-item.js.map +1 -0
  50. package/dist/components/modal.d.ts +23 -1
  51. package/dist/components/modal.js +36 -10
  52. package/dist/components/modal.js.map +1 -1
  53. package/dist/components/number-field.d.ts +6 -0
  54. package/dist/components/number-input.d.ts +6 -0
  55. package/dist/components/numpad-sheet.d.ts +6 -0
  56. package/dist/components/page-contents.js +1 -2
  57. package/dist/components/page-contents.js.map +1 -1
  58. package/dist/components/page-header.d.ts +37 -0
  59. package/dist/components/page-header.js +36 -0
  60. package/dist/components/page-header.js.map +1 -0
  61. package/dist/components/progress-bar.d.ts +16 -2
  62. package/dist/components/progress-bar.js +4 -2
  63. package/dist/components/progress-bar.js.map +1 -1
  64. package/dist/components/series-chart-labels.d.ts +3 -0
  65. package/dist/components/series-chart-labels.js +2 -1
  66. package/dist/components/series-chart-labels.js.map +1 -1
  67. package/dist/components/series-chart.d.ts +16 -0
  68. package/dist/components/series-chart.js +137 -3
  69. package/dist/components/series-chart.js.map +1 -1
  70. package/dist/components/status-dot.d.ts +49 -0
  71. package/dist/components/status-dot.js +52 -0
  72. package/dist/components/status-dot.js.map +1 -0
  73. package/dist/components/table.d.ts +39 -6
  74. package/dist/components/table.js +43 -4
  75. package/dist/components/table.js.map +1 -1
  76. package/dist/components/text.d.ts +63 -0
  77. package/dist/components/text.js +23 -0
  78. package/dist/components/text.js.map +1 -0
  79. package/dist/components/toast.d.ts +167 -0
  80. package/dist/components/toast.js +226 -0
  81. package/dist/components/toast.js.map +1 -0
  82. package/dist/components/toggle-group.d.ts +29 -1
  83. package/dist/components/toggle-group.js +52 -3
  84. package/dist/components/toggle-group.js.map +1 -1
  85. package/dist/components/tooltip.d.ts +38 -15
  86. package/dist/components/tooltip.js +85 -47
  87. package/dist/components/tooltip.js.map +1 -1
  88. package/dist/components/ui.d.ts +139 -16
  89. package/dist/components/ui.js +136 -26
  90. package/dist/components/ui.js.map +1 -1
  91. package/dist/components/use-table-state.d.ts +1 -1
  92. package/dist/{data-table-filters-CF1PXqjQ.d.ts → data-table-filters-Dh9uF_S-.d.ts} +33 -2
  93. package/dist/data-table.d.ts +1 -1
  94. package/dist/hooks/use-close-transition.d.ts +7 -1
  95. package/dist/hooks/use-close-transition.js +5 -2
  96. package/dist/hooks/use-close-transition.js.map +1 -1
  97. package/dist/hooks/use-copy-to-clipboard.js +1 -1
  98. package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
  99. package/dist/hooks/use-overlay-history.js +84 -4
  100. package/dist/hooks/use-overlay-history.js.map +1 -1
  101. package/dist/i18n/defaults.d.ts +6 -0
  102. package/dist/i18n/defaults.js +11 -1
  103. package/dist/i18n/defaults.js.map +1 -1
  104. package/dist/i18n/kit-labels.d.ts +11 -0
  105. package/dist/i18n/kit-labels.js.map +1 -1
  106. package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
  107. package/dist/i18n/locales/de-CH.d.ts +6 -0
  108. package/dist/i18n/locales/de-informal.d.ts +6 -0
  109. package/dist/i18n/locales/de.d.ts +6 -0
  110. package/dist/i18n/locales/de.js +31 -1
  111. package/dist/i18n/locales/de.js.map +1 -1
  112. package/dist/i18n/locales/es.d.ts +6 -0
  113. package/dist/i18n/locales/es.js +31 -1
  114. package/dist/i18n/locales/es.js.map +1 -1
  115. package/dist/i18n/locales/fr.d.ts +6 -0
  116. package/dist/i18n/locales/fr.js +31 -1
  117. package/dist/i18n/locales/fr.js.map +1 -1
  118. package/dist/i18n/locales/hu.d.ts +6 -0
  119. package/dist/i18n/locales/hu.js +31 -1
  120. package/dist/i18n/locales/hu.js.map +1 -1
  121. package/dist/i18n/locales/it.d.ts +6 -0
  122. package/dist/i18n/locales/it.js +31 -1
  123. package/dist/i18n/locales/it.js.map +1 -1
  124. package/dist/i18n/locales/zh.d.ts +6 -0
  125. package/dist/i18n/locales/zh.js +31 -1
  126. package/dist/i18n/locales/zh.js.map +1 -1
  127. package/dist/index.d.ts +18 -8
  128. package/dist/index.js +14 -0
  129. package/dist/index.js.map +1 -1
  130. package/dist/lib/clipping.d.ts +9 -0
  131. package/dist/lib/clipping.js +14 -0
  132. package/dist/lib/clipping.js.map +1 -0
  133. package/dist/search/command-palette.d.ts +67 -2
  134. package/dist/search/command-palette.js +147 -33
  135. package/dist/search/command-palette.js.map +1 -1
  136. package/dist/search/global-search.d.ts +166 -0
  137. package/dist/search/global-search.js +293 -0
  138. package/dist/search/global-search.js.map +1 -0
  139. package/dist/search/search-index.d.ts +105 -0
  140. package/dist/search/search-index.js +142 -0
  141. package/dist/search/search-index.js.map +1 -0
  142. package/dist/search.d.ts +3 -1
  143. package/dist/search.js +2 -0
  144. package/dist/search.js.map +1 -1
  145. package/dist/wizard/stepper-nav.d.ts +10 -1
  146. package/dist/wizard/stepper-nav.js +3 -2
  147. package/dist/wizard/stepper-nav.js.map +1 -1
  148. package/dist/wizard/types.d.ts +2 -2
  149. package/dist/wizard/types.js.map +1 -1
  150. package/dist/wizard/use-wizard.js +1 -1
  151. package/dist/wizard/use-wizard.js.map +1 -1
  152. package/dist/wizard/wizard-summary.js +3 -3
  153. package/dist/wizard/wizard-summary.js.map +1 -1
  154. package/package.json +25 -12
  155. package/src/components/alert-banner.tsx +117 -18
  156. package/src/components/breadcrumbs.tsx +168 -0
  157. package/src/components/bulk-action-bar.tsx +256 -0
  158. package/src/components/chip.tsx +164 -13
  159. package/src/components/copy-button.tsx +29 -7
  160. package/src/components/data-table.tsx +81 -5
  161. package/src/components/date-picker.tsx +377 -118
  162. package/src/components/description-list.tsx +168 -15
  163. package/src/components/dialog-frame.tsx +7 -0
  164. package/src/components/disclosure.tsx +116 -28
  165. package/src/components/file-dropzone.tsx +9 -10
  166. package/src/components/floating-panel.tsx +46 -2
  167. package/src/components/full-bleed-dialog.tsx +42 -5
  168. package/src/components/list.tsx +417 -0
  169. package/src/components/menu-item.tsx +214 -0
  170. package/src/components/modal.tsx +80 -17
  171. package/src/components/page-contents.tsx +4 -4
  172. package/src/components/page-header.tsx +68 -0
  173. package/src/components/progress-bar.tsx +27 -3
  174. package/src/components/series-chart-labels.ts +4 -0
  175. package/src/components/series-chart.tsx +253 -3
  176. package/src/components/status-dot.tsx +99 -0
  177. package/src/components/table.tsx +91 -6
  178. package/src/components/text.tsx +89 -0
  179. package/src/components/toast.tsx +441 -0
  180. package/src/components/toggle-group.tsx +82 -3
  181. package/src/components/tooltip.tsx +186 -100
  182. package/src/components/ui.tsx +321 -41
  183. package/src/hooks/use-close-transition.ts +14 -5
  184. package/src/hooks/use-copy-to-clipboard.ts +1 -1
  185. package/src/hooks/use-overlay-history.ts +181 -4
  186. package/src/i18n/defaults.ts +10 -0
  187. package/src/i18n/kit-labels.tsx +10 -0
  188. package/src/i18n/locales/de.ts +30 -0
  189. package/src/i18n/locales/es.ts +30 -0
  190. package/src/i18n/locales/fr.ts +30 -0
  191. package/src/i18n/locales/hu.ts +30 -0
  192. package/src/i18n/locales/it.ts +30 -0
  193. package/src/i18n/locales/zh.ts +30 -0
  194. package/src/index.ts +30 -0
  195. package/src/lib/clipping.ts +16 -0
  196. package/src/search/command-palette.tsx +241 -31
  197. package/src/search/global-search.tsx +525 -0
  198. package/src/search/search-index.ts +295 -0
  199. package/src/search.ts +2 -0
  200. package/src/wizard/stepper-nav.tsx +13 -2
  201. package/src/wizard/types.ts +2 -2
  202. package/src/wizard/use-wizard.ts +3 -3
  203. package/src/wizard/wizard-summary.tsx +14 -10
  204. package/tokens.css +97 -0
@@ -0,0 +1,214 @@
1
+ import { forwardRef } from "react";
2
+ import type { MouseEvent, ReactElement, ReactNode, Ref } from "react";
3
+ import { Check } from "lucide-react";
4
+ import type { LucideIcon } from "lucide-react";
5
+ import { cn } from "../lib/cn";
6
+
7
+ /**
8
+ * `TOPBAR_MENU_ITEM_CLASS`'s row, spelled out rather than imported: a component does
9
+ * not reach up into the shell (the same call `Disclosure`'s menu variant made). A test
10
+ * holds the two to the same classes, so a menu mixing `MenuItem`s with the shell's own
11
+ * rows (`TopBarActionMenu`, `OptionSwitcherMenu`) cannot drift into two looks.
12
+ */
13
+ const MENU_ITEM_ROW =
14
+ "flex w-full items-center justify-between gap-3 px-3 py-2 text-sm text-start text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]";
15
+
16
+ /** HoverMenu moves focus onto items with the arrow keys; a pointer user never sees the
17
+ * ring, a keyboard user always does. Inset, because the panel clips its children. */
18
+ const MENU_ITEM_FOCUS =
19
+ "focus-visible:outline-none focus-visible:bg-[var(--bg-hover)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]";
20
+
21
+ export type MenuItemTone = "default" | "danger";
22
+
23
+ /** What {@link MenuItemProps.renderLink} is handed. Spread it onto your router's link,
24
+ * mapping `href` to what the link calls it —
25
+ * `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`. */
26
+ export interface MenuItemLinkProps {
27
+ href: string;
28
+ className: string;
29
+ children: ReactNode;
30
+ role: "menuitem";
31
+ ref?: Ref<HTMLAnchorElement>;
32
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
33
+ "aria-current"?: "page";
34
+ id?: string;
35
+ [key: `aria-${string}`]: string | boolean | number | undefined;
36
+ [key: `data-${string}`]: unknown;
37
+ }
38
+
39
+ interface MenuItemBaseProps {
40
+ /** The row's text. */
41
+ children: ReactNode;
42
+ /** An icon before the label, drawn at the menu's 16px. */
43
+ icon?: LucideIcon;
44
+ /** Anything else before the label — an avatar, a flag — when a Lucide icon will not do. */
45
+ leading?: ReactNode;
46
+ /** After the label, at the row's end: a shortcut, a count, a badge. */
47
+ trailing?: ReactNode;
48
+ /** `danger` for the row that destroys or signs out. Colour is not the whole message:
49
+ * the label must still say what the row does. */
50
+ tone?: MenuItemTone;
51
+ disabled?: boolean;
52
+ className?: string;
53
+ id?: string;
54
+ [key: `aria-${string}`]: string | boolean | number | undefined;
55
+ [key: `data-${string}`]: unknown;
56
+ }
57
+
58
+ /**
59
+ * A menu row is a button OR a link, and only the button can be CHECKED: a checked link
60
+ * is a link to where you already are, which is `current`, not a selection.
61
+ */
62
+ export type MenuItemProps = MenuItemBaseProps &
63
+ (
64
+ | {
65
+ href?: never;
66
+ renderLink?: never;
67
+ current?: never;
68
+ /** Runs the row. Close the menu from here — `HoverMenu` hands its `close` to
69
+ * the render prop that draws the rows. */
70
+ onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
71
+ /**
72
+ * Makes the row a CHOICE and says whether it is chosen. Leave it undefined for a
73
+ * plain command; `false` is a choice that is not chosen, which is different.
74
+ * A check mark is drawn at the end when it is on.
75
+ */
76
+ checked?: boolean;
77
+ /**
78
+ * What kind of choice, when `checked` is given:
79
+ *
80
+ * - `radio` (default): one of a set — keksdose's budget switcher
81
+ * (budget-switcher.tsx:51), the theme and language pickers. `menuitemradio`.
82
+ * - `checkbox`: an independent on/off — "Show archived". `menuitemcheckbox`.
83
+ *
84
+ * Not `aria-pressed`: that belongs to a toggle BUTTON, and inside a
85
+ * `role="menu"` a screen reader expects the menu roles and reads "checked"
86
+ * only from them. A button with `aria-pressed` in a menu is announced as a
87
+ * button in a menu, which is two widgets in one.
88
+ */
89
+ checkable?: "radio" | "checkbox";
90
+ }
91
+ | {
92
+ /** Makes the row a link — keksdose's "Manage budgets" and account-menu rows. */
93
+ href: string;
94
+ /** Renders the link — pass your router's `<Link>`, since a plain `<a>` reloads
95
+ * a single-page app. Default `<a>`. The same API as `Chip` and `StatTile`. */
96
+ renderLink?: (props: MenuItemLinkProps) => ReactElement;
97
+ /** Runs on the way (close the menu). */
98
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
99
+ /** The link to the page already open: `aria-current="page"` and a check mark. */
100
+ current?: boolean;
101
+ checked?: never;
102
+ checkable?: never;
103
+ }
104
+ );
105
+
106
+ /**
107
+ * One row of a `HoverMenu` / `TopBarActionMenu` panel.
108
+ *
109
+ * keksdose's account menu and budget switcher each rebuild this row by hand — the
110
+ * shell's class, an icon, a label, a badge, a check mark on the active budget — and the
111
+ * check mark was the only thing saying which budget was active, so a screen reader
112
+ * heard four identical buttons. This row says it (`menuitemradio` + `aria-checked`).
113
+ *
114
+ * The role is set HERE rather than left to `HoverMenu`, which promotes only role-less
115
+ * buttons and links to `menuitem`: a checked row must be `menuitemradio` or
116
+ * `menuitemcheckbox` from its first render, and `HoverMenu` leaves an element that
117
+ * already has a role alone — so its roving focus (↑/↓, Home/End) picks these rows up
118
+ * with the rest. Render it inside an `<li>` like the shell's own rows; `HoverMenu`
119
+ * turns the list markup into `role="none"`.
120
+ */
121
+ export const MenuItem = forwardRef<HTMLElement, MenuItemProps>(function MenuItem(
122
+ {
123
+ children,
124
+ icon: Icon,
125
+ leading,
126
+ trailing,
127
+ tone = "default",
128
+ disabled = false,
129
+ className,
130
+ href,
131
+ renderLink,
132
+ onClick,
133
+ checked,
134
+ checkable,
135
+ current,
136
+ ...rest
137
+ },
138
+ ref,
139
+ ) {
140
+ const on = href !== undefined ? !!current : !!checked;
141
+ const look = cn(
142
+ MENU_ITEM_ROW,
143
+ MENU_ITEM_FOCUS,
144
+ tone === "danger" && "text-[var(--danger)] hover:bg-[var(--danger-bg)] focus-visible:bg-[var(--danger-bg)]",
145
+ disabled && "cursor-default opacity-50 hover:bg-transparent",
146
+ className,
147
+ );
148
+ const body = (
149
+ <>
150
+ <span className="flex min-w-0 items-center gap-2">
151
+ {Icon && <Icon className="size-4 shrink-0" aria-hidden />}
152
+ {leading}
153
+ <span className="min-w-0 truncate">{children}</span>
154
+ </span>
155
+ {(trailing != null || on) && (
156
+ <span className="flex shrink-0 items-center gap-2">
157
+ {trailing}
158
+ {on && <Check className="size-4 text-[var(--text-secondary)]" aria-hidden />}
159
+ </span>
160
+ )}
161
+ </>
162
+ );
163
+
164
+ if (href !== undefined && !disabled) {
165
+ const linkProps: MenuItemLinkProps = {
166
+ ...rest,
167
+ ref: ref as Ref<HTMLAnchorElement>,
168
+ href,
169
+ role: "menuitem",
170
+ className: look,
171
+ onClick: onClick as MenuItemLinkProps["onClick"],
172
+ "aria-current": current ? "page" : undefined,
173
+ children: body,
174
+ };
175
+ if (renderLink) return <RenderedLink render={renderLink} {...linkProps} />;
176
+ const { children: linkBody, ...anchorProps } = linkProps;
177
+ return <a {...anchorProps}>{linkBody}</a>;
178
+ }
179
+
180
+ const role =
181
+ href === undefined && checked !== undefined
182
+ ? checkable === "checkbox"
183
+ ? "menuitemcheckbox"
184
+ : "menuitemradio"
185
+ : "menuitem";
186
+ return (
187
+ <button
188
+ {...rest}
189
+ ref={ref as Ref<HTMLButtonElement>}
190
+ type="button"
191
+ role={role}
192
+ aria-checked={role === "menuitem" ? undefined : !!checked}
193
+ // `aria-disabled` rather than `disabled`: HoverMenu's roving focus skips either,
194
+ // and the ARIA menu pattern keeps an unavailable item perceivable ("Delete,
195
+ // dimmed") rather than greyed to the point some browsers stop exposing it.
196
+ aria-disabled={disabled || undefined}
197
+ onClick={
198
+ disabled
199
+ ? (e) => e.preventDefault()
200
+ : (onClick as ((event: MouseEvent<HTMLButtonElement>) => void) | undefined)
201
+ }
202
+ className={look}
203
+ >
204
+ {body}
205
+ </button>
206
+ );
207
+ });
208
+
209
+ function RenderedLink({
210
+ render,
211
+ ...props
212
+ }: MenuItemLinkProps & { render: (props: MenuItemLinkProps) => ReactElement }) {
213
+ return render(props);
214
+ }
@@ -1,4 +1,4 @@
1
- import { createContext, useRef, useState } from "react";
1
+ import { createContext, useEffect, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import type {
4
4
  ComponentPropsWithoutRef,
@@ -11,7 +11,7 @@ import { cn } from "../lib/cn";
11
11
  import { useFocusTrap } from "../hooks/use-focus-trap";
12
12
  import { useBodyScrollLock } from "../hooks/use-body-scroll-lock";
13
13
  import { useOverlayHistory } from "../hooks/use-overlay-history";
14
- import { useCloseTransition } from "../hooks/use-close-transition";
14
+ import { OVERLAY_EXIT_MS, prefersReducedMotion, useCloseTransition } from "../hooks/use-close-transition";
15
15
 
16
16
  /**
17
17
  * Drag the panel by its top strip, in pointer space (dev#460).
@@ -40,6 +40,9 @@ function useDragOffset(enabled: boolean) {
40
40
  };
41
41
 
42
42
  return {
43
+ /** Back to where the panel opens. A `Modal` with an `open` prop outlives its own
44
+ * closes, so "the position resets when the dialog closes" needs saying. */
45
+ reset: () => setOffset(null),
43
46
  style: offset ? { transform: `translate(${offset.x}px, ${offset.y}px)` } : undefined,
44
47
  handlers: !enabled
45
48
  ? undefined
@@ -120,6 +123,28 @@ export function useBackdropClose(onClose: () => void) {
120
123
  export interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"> {
121
124
  /** Invoked on backdrop click and on Escape. */
122
125
  onClose: () => void;
126
+ /**
127
+ * Who decides whether the dialog is up, when the caller wants to keep it MOUNTED.
128
+ *
129
+ * Left out, the dialog is open for as long as it is mounted — `{open && <Modal/>}`,
130
+ * which is every caller before this prop and stays exactly as it was. That idiom has
131
+ * one blind spot: a close the CALLER decides on (a save that succeeded, a
132
+ * `setOpen(false)` from outside) unmounts the panel on the spot, with no exit,
133
+ * because there is nothing left to animate. kastlan's `FormModal` was nothing but an
134
+ * `if (!open) return null` gate in front of this.
135
+ *
136
+ * Passed, the component stays mounted while closed and renders nothing, and a flip
137
+ * to `false` plays the same exit a dismissal does before the panel goes. The focus
138
+ * trap, the scroll lock and the Back-gesture history entry are held only while
139
+ * `open` is true — they are released the moment it turns false, so focus is back on
140
+ * the trigger while the panel is still lowering. A dismissal (Escape, the backdrop,
141
+ * Back, the frame's X) animates first and then calls `onClose`; the `false` that
142
+ * comes back from it is not animated a second time.
143
+ *
144
+ * The body is unmounted once the exit has finished, as it is today, so a form inside
145
+ * still starts fresh on the next open.
146
+ */
147
+ open?: boolean;
123
148
  /** `"alertdialog"` for a dialog that interrupts to ask a question the user must
124
149
  * answer (a confirmation) — screen readers announce it with more urgency. Only the
125
150
  * two dialog roles are allowed: the panel stays modal either way. */
@@ -183,6 +208,7 @@ export interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"
183
208
  */
184
209
  export function Modal({
185
210
  onClose,
211
+ open,
186
212
  children,
187
213
  size = "md",
188
214
  className,
@@ -196,37 +222,68 @@ export function Modal({
196
222
  }: ModalProps) {
197
223
  const panelRef = useRef<HTMLDivElement>(null);
198
224
  const drag = useDragOffset(Boolean(draggable));
225
+ // Without `open`, mounted means open — the constant `true` every hook below used to
226
+ // be given. With it, `open` itself.
227
+ const active = open ?? true;
228
+
229
+ // Whether the last close came through `requestClose`, i.e. has ALREADY been
230
+ // animated. Set in the same batch as the caller's `onClose`, so the render that sees
231
+ // `open` turn false also sees this and does not lower the panel a second time.
232
+ const [dismissed, setDismissed] = useState(false);
233
+ // The exit of a close the caller decided on (only reachable with `open`). Adjusted
234
+ // during render when `open` flips — `Collapse`'s pattern — so the very render that
235
+ // closes it already paints the `-out` classes rather than a frame of nothing.
236
+ const [exiting, setExiting] = useState(false);
237
+ const [prevOpen, setPrevOpen] = useState(open);
238
+ if (open !== prevOpen) {
239
+ setPrevOpen(open);
240
+ setDismissed(false);
241
+ setExiting(open === false && prevOpen === true && !dismissed && !prefersReducedMotion());
242
+ if (open) drag.reset();
243
+ }
244
+ useEffect(() => {
245
+ if (!exiting) return;
246
+ const timer = setTimeout(() => setExiting(false), OVERLAY_EXIT_MS);
247
+ return () => clearTimeout(timer);
248
+ }, [exiting]);
249
+
199
250
  // The panel lowers itself before the caller unmounts it (live #320 rework). Every
200
251
  // dismissal below goes through `requestClose`; `onClose` still does the closing, one
201
252
  // animation later. A caller that closes the dialog ITSELF — after a save, say —
202
- // unmounts with no exit, which is the documented limit of this.
203
- const { closing, requestClose } = useCloseTransition(onClose);
204
- const backdropClose = useBackdropClose(requestClose);
253
+ // unmounts with no exit unless it passes `open`, which is what that prop is for.
254
+ const { closing, requestClose } = useCloseTransition(() => {
255
+ setDismissed(true);
256
+ onClose();
257
+ });
258
+ // A panel on its way out after `open` turned false is already closed: a press on
259
+ // its fading backdrop must not report a second close.
260
+ const backdropClose = useBackdropClose(active ? requestClose : () => {});
205
261
  // Back means the same as Escape here. On a phone Escape doesn't exist, so without
206
262
  // this the only way out of a dialog is finding its close button — and Back, the
207
263
  // gesture everyone reaches for, navigated the page underneath instead (#172).
208
- // Mounted only while open, hence the constant `true`.
209
- useOverlayHistory(true, requestClose);
264
+ // Held only while open: a closed `open={false}` dialog must not own a history entry.
265
+ useOverlayHistory(active, requestClose);
210
266
 
211
- // Mounted only while open, hence the constant `true`. Through the shared hook and
212
- // not by hand: this component's own save/restore copy was one half of the pair that
213
- // left the page permanently unscrollable when a dialog containing an open sheet was
214
- // closed — see the note in use-body-scroll-lock.ts.
215
- useBodyScrollLock(true);
267
+ // Through the shared hook and not by hand: this component's own save/restore copy
268
+ // was one half of the pair that left the page permanently unscrollable when a dialog
269
+ // containing an open sheet was closed — see the note in use-body-scroll-lock.ts.
270
+ useBodyScrollLock(active);
216
271
 
217
272
  // Focus the panel itself (not the first field) so opening doesn't pop the mobile
218
273
  // keyboard, while still moving focus into the dialog for keyboard and screen-reader
219
274
  // users. Through the shared hook since this component is where it came from — the
220
275
  // hook adds per-keystroke recomputation, a guard on restoring to a detached node,
221
- // and nesting, none of which this copy had.
222
- useFocusTrap(panelRef, { active: true, initialFocus: "container" });
276
+ // and nesting, none of which this copy had. Released when `open` turns false, which
277
+ // hands focus back to the trigger while the panel is still on its way out.
278
+ useFocusTrap(panelRef, { active, initialFocus: "container" });
279
+ const leaving = closing || exiting;
223
280
 
224
281
  const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
225
282
  onKeyDown?.(e);
226
283
  if (e.defaultPrevented) return;
227
284
  if (e.key === "Escape") {
228
285
  e.stopPropagation();
229
- requestClose();
286
+ if (active) requestClose();
230
287
  return;
231
288
  }
232
289
  // Tab containment lives in useFocusTrap, which listens on the panel itself.
@@ -237,13 +294,19 @@ export function Modal({
237
294
  // bar (a z-30 context) could paint BELOW a sibling z-30 sticky element like the
238
295
  // expanded transaction editor (feedback #320). The tour/command-palette overlays
239
296
  // already portal for the same reason.
297
+ if (!active && !exiting) return null;
240
298
  return createPortal(
241
299
  <div
300
+ // Out of the accessibility tree for the length of a caller-driven exit: focus is
301
+ // already back on the page, and an `aria-modal` panel that is leaving would hide
302
+ // that page from a screen reader for 220ms. Not `inert` — that would let a tap
303
+ // fall through the fading backdrop to the row underneath.
304
+ aria-hidden={exiting || undefined}
242
305
  className={cn(
243
306
  // Live #320. The panel below rises only where it is bottom-anchored: from md up
244
307
  // it is centred, and a centred box sliding up from off-screen reads as a
245
308
  // different component arriving rather than as the same one settling.
246
- closing ? "animate-overlay-out" : "animate-overlay",
309
+ leaving ? "animate-overlay-out" : "animate-overlay",
247
310
  "fixed inset-0 z-50 flex items-end justify-center bg-black/40 md:items-center",
248
311
  fullBleed ? "p-0 md:p-4" : "p-4",
249
312
  )}
@@ -270,7 +333,7 @@ export function Modal({
270
333
  // the ones where the panel is being dragged.
271
334
  style={{ ...style, ...drag.style }}
272
335
  className={cn(
273
- closing ? "animate-sheet-out md:animate-none" : "animate-sheet md:animate-none",
336
+ leaving ? "animate-sheet-out md:animate-none" : "animate-sheet md:animate-none",
274
337
  "w-full rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm outline-none",
275
338
  { md: "max-w-md", lg: "max-w-lg", xl: "max-w-3xl" }[size],
276
339
  // A panel taller than the screen has to scroll ITSELF. The backdrop is
@@ -176,14 +176,14 @@ export function PageContents({
176
176
  <li key={item.id} data-entry={item.id}>
177
177
  <a
178
178
  href={hrefFor(item.id)}
179
- // One line per entry in the rail; past 22rem the ellipsis takes over and
180
- // the full text is on hover. The disclosure has the page's width and wraps.
181
- title={variant === "rail" && typeof item.label === "string" ? item.label : undefined}
179
+ // Entries WRAP in the rail rather than truncate: a cut-off heading needed a
180
+ // native `title` to be read at all, which a keyboard never sees and which
181
+ // broke keksdose's one-tooltip rule (dev#523).
182
182
  aria-current={current ? "location" : undefined}
183
183
  onClick={() => setClicked(item.id)}
184
184
  className={cn(
185
185
  "-ms-px block border-s-2 py-1 pe-1 text-[13px] leading-snug transition-colors duration-150",
186
- variant === "rail" && "truncate",
186
+ variant === "rail" && "[overflow-wrap:anywhere]",
187
187
  item.level === 2 ? "ps-6" : "ps-3",
188
188
  current
189
189
  ? "border-[var(--brand)] font-medium text-[var(--brand)]"
@@ -0,0 +1,68 @@
1
+ import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+ import { SECTION_LABEL_CLASS } from "./text";
4
+
5
+ /** `md` (default) is kastlan's `text-2xl` page title from `sm` up, a step smaller on a
6
+ * phone; `sm` is lenkbank's `text-lg` title on a dense tool page (projects-page.tsx). */
7
+ export type PageHeaderSize = "sm" | "md";
8
+
9
+ const TITLE: Record<PageHeaderSize, string> = {
10
+ sm: "text-lg font-semibold",
11
+ md: "text-xl font-semibold tracking-tight sm:text-2xl",
12
+ };
13
+
14
+ export interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
15
+ title: ReactNode;
16
+ /** A sentence under the title. */
17
+ description?: ReactNode;
18
+ /** Buttons for the page — "New", "Export". At the title's end from `sm` up; under it,
19
+ * wrapping, on a phone. */
20
+ actions?: ReactNode;
21
+ /** A `Breadcrumbs` trail, drawn above everything else. */
22
+ breadcrumbs?: ReactNode;
23
+ /** A small uppercase line above the title — the section or the kind of record
24
+ * ("Building", "Settings"). The section-label type. */
25
+ eyebrow?: ReactNode;
26
+ /** The title's element. Default `h1`: a page has one, and this is it. `h2` for a
27
+ * header inside a page that already has its `h1` (a tab's own header). */
28
+ as?: "h1" | "h2" | "h3";
29
+ size?: PageHeaderSize;
30
+ }
31
+
32
+ /**
33
+ * A page's head: optional breadcrumbs, an eyebrow, the title, a description, and the
34
+ * page's actions.
35
+ *
36
+ * kastlan's (shared/components/display/page-header.tsx) is the model — title and
37
+ * description on one side, actions on the other, stacked on a phone — and this adds
38
+ * what its pages kept writing around it: a trail above, an eyebrow, a `ReactNode`
39
+ * title (a status chip beside the name), and actions that WRAP when there are more
40
+ * than fit a phone's width, instead of running off it. No outer margin: the page's
41
+ * own rhythm (`space-y-*`, a grid gap) places it.
42
+ */
43
+ export function PageHeader({
44
+ title,
45
+ description,
46
+ actions,
47
+ breadcrumbs,
48
+ eyebrow,
49
+ as = "h1",
50
+ size = "md",
51
+ className,
52
+ ...rest
53
+ }: PageHeaderProps) {
54
+ const Heading = as as ElementType;
55
+ return (
56
+ <div {...rest} className={cn("flex min-w-0 flex-col gap-2", className)}>
57
+ {breadcrumbs}
58
+ <div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
59
+ <div className="min-w-0 flex-1">
60
+ {eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, "mb-1")}>{eyebrow}</p>}
61
+ <Heading className={cn("break-words text-[var(--text-primary)]", TITLE[size])}>{title}</Heading>
62
+ {description != null && <p className="mt-1 text-sm text-[var(--text-muted)]">{description}</p>}
63
+ </div>
64
+ {actions != null && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
65
+ </div>
66
+ </div>
67
+ );
68
+ }
@@ -3,8 +3,30 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
5
5
 
6
- export type ProgressBarTone = "brand" | "neutral" | "success" | "warning" | "danger" | "info";
7
- export type ProgressBarSize = "sm" | "md" | "lg";
6
+ /**
7
+ * `income` / `expense` are the money pair (`--money-income` / `--money-expense`), the
8
+ * names `Chip`, `StatTile` and `Sparkline` already use for them: a share bar of what
9
+ * came in or went out is money, and painting it `success` / `warning` said "good" and
10
+ * "careful" about a figure that is neither. keksdose's report meters (food-group-card,
11
+ * drilldown-modal) sit beside amounts that are already in the money colours.
12
+ */
13
+ export type ProgressBarTone =
14
+ | "brand"
15
+ | "neutral"
16
+ | "success"
17
+ | "warning"
18
+ | "danger"
19
+ | "info"
20
+ | "income"
21
+ | "expense";
22
+ /**
23
+ * Track heights: `sm` 4px, `slim` 6px, `md` 8px, `lg` 12px. `slim` is a word and not a
24
+ * letter because the letters are taken in order — an `xs` at 6px would be THICKER than
25
+ * `sm` — and renaming `sm` would move every bar already on it. 6px is the Slider's
26
+ * track and keksdose's hand-drawn meters (`h-1.5`, landing-visuals), for a bar under a
27
+ * line of text where 8px outweighs the text.
28
+ */
29
+ export type ProgressBarSize = "sm" | "slim" | "md" | "lg";
8
30
 
9
31
  const FILL: Record<ProgressBarTone, string> = {
10
32
  brand: "bg-[var(--brand)]",
@@ -13,9 +35,11 @@ const FILL: Record<ProgressBarTone, string> = {
13
35
  warning: "bg-[var(--warning)]",
14
36
  danger: "bg-[var(--danger)]",
15
37
  info: "bg-[var(--info)]",
38
+ income: "bg-[var(--money-income)]",
39
+ expense: "bg-[var(--money-expense)]",
16
40
  };
17
41
 
18
- const TRACK_HEIGHT: Record<ProgressBarSize, string> = { sm: "h-1", md: "h-2", lg: "h-3" };
42
+ const TRACK_HEIGHT: Record<ProgressBarSize, string> = { sm: "h-1", slim: "h-1.5", md: "h-2", lg: "h-3" };
19
43
 
20
44
  export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> {
21
45
  /** Where it stands. Leave it undefined for an INDETERMINATE bar — work is under way
@@ -15,6 +15,9 @@ export interface SeriesChartLabels {
15
15
  empty: string;
16
16
  /** Accessible name of a `ToggleLegend` — a group of switches, one per series. */
17
17
  legend: string;
18
+ /** Accessible name of the group of keyboard stops a clickable chart (`onPointClick`)
19
+ * lays over its bars or periods — one tab stop, arrow keys between them. */
20
+ points: string;
18
21
  }
19
22
 
20
23
  export const DEFAULT_SERIES_CHART_LABELS: SeriesChartLabels = {
@@ -23,4 +26,5 @@ export const DEFAULT_SERIES_CHART_LABELS: SeriesChartLabels = {
23
26
  "Drag to zoom: a roughly square selection zooms both axes, a long thin one only its own. Double-click to reset.",
24
27
  empty: "No data",
25
28
  legend: "Series",
29
+ points: "Chart values",
26
30
  };