@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
@@ -3,16 +3,62 @@ import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { FieldHint } from "./ui";
5
5
 
6
- export type DescriptionListLayout = "rows" | "cards";
7
- export type DescriptionListDensity = "comfortable" | "compact";
6
+ export type DescriptionListLayout = "rows" | "cards" | "stacked";
7
+ export type DescriptionListDensity = "comfortable" | "compact" | "tight";
8
+ /** How many columns a `stacked` or `cards` list may use. */
9
+ export type DescriptionListColumns = 1 | 2 | 3 | 4 | 5 | 6;
8
10
 
9
11
  interface ListContextValue {
10
12
  layout: DescriptionListLayout;
11
13
  numeric: boolean;
14
+ prose: boolean;
12
15
  density: DescriptionListDensity;
16
+ columns: DescriptionListColumns;
13
17
  }
14
18
 
15
- const ListContext = createContext<ListContextValue>({ layout: "rows", numeric: false, density: "comfortable" });
19
+ const ListContext = createContext<ListContextValue>({
20
+ layout: "rows",
21
+ numeric: false,
22
+ prose: false,
23
+ density: "comfortable",
24
+ columns: 2,
25
+ });
26
+
27
+ /**
28
+ * `stacked` columns, by the width of the LIST (a container query on a wrapper — the
29
+ * `<dl>` is the grid and cannot query itself). One column on a phone-narrow pane, two
30
+ * from 20rem, then one more column each time every column would still get ~12rem.
31
+ * The same shape as kastlan's DetailGrid (1 → 2 at `sm` → n at `md`), measured on the
32
+ * pane rather than the viewport, so the four preview dialogs and a full-width detail
33
+ * page can share one list. Literal strings, because Tailwind only emits what it reads.
34
+ */
35
+ const STACKED_COLS: Record<DescriptionListColumns, string> = {
36
+ 1: "grid-cols-1",
37
+ 2: "grid-cols-1 @xs:grid-cols-2",
38
+ 3: "grid-cols-1 @xs:grid-cols-2 @xl:grid-cols-3",
39
+ 4: "grid-cols-1 @xs:grid-cols-2 @xl:grid-cols-3 @3xl:grid-cols-4",
40
+ 5: "grid-cols-1 @xs:grid-cols-2 @xl:grid-cols-3 @3xl:grid-cols-4 @5xl:grid-cols-5",
41
+ 6: "grid-cols-1 @xs:grid-cols-2 @xl:grid-cols-3 @3xl:grid-cols-4 @5xl:grid-cols-5 @6xl:grid-cols-6",
42
+ };
43
+
44
+ /** An item's span at each of those thresholds, never more columns than exist there —
45
+ * so a span can never open an implicit column and push the grid past its pane. */
46
+ const STACKED_SPAN: Record<DescriptionListColumns, string> = {
47
+ 1: "",
48
+ 2: "@xs:col-span-2",
49
+ 3: "@xs:col-span-2 @xl:col-span-3",
50
+ 4: "@xs:col-span-2 @xl:col-span-3 @3xl:col-span-4",
51
+ 5: "@xs:col-span-2 @xl:col-span-3 @3xl:col-span-4 @5xl:col-span-5",
52
+ 6: "@xs:col-span-2 @xl:col-span-3 @3xl:col-span-4 @5xl:col-span-5 @6xl:col-span-6",
53
+ };
54
+
55
+ /** The cards grid's gap per density, as a class and as the length the column formula
56
+ * subtracts — one table so the two cannot disagree. */
57
+ const CARD_GAP: Record<DescriptionListDensity, { cls: string; len: string }> = {
58
+ comfortable: { cls: "gap-3", len: "0.75rem" },
59
+ compact: { cls: "gap-2", len: "0.5rem" },
60
+ tight: { cls: "gap-1", len: "0.25rem" },
61
+ };
16
62
 
17
63
  export interface DescriptionListProps extends ComponentPropsWithoutRef<"dl"> {
18
64
  /**
@@ -21,6 +67,10 @@ export interface DescriptionListProps extends ComponentPropsWithoutRef<"dl"> {
21
67
  * beside a sidebar stacks where it is actually cramped, not where the viewport is.
22
68
  * `cards`: a grid of small bordered cards, term above body — keksdose's overview
23
69
  * figures, lenkbank's account facts.
70
+ * `stacked` (0.10.0): a borderless grid, term ABOVE detail, {@link columns} wide —
71
+ * kastlan's DetailField/DetailGrid and the label-over-value grids of its lease,
72
+ * invoice, contact and unit preview dialogs. Items can {@link DescriptionItemProps.span}
73
+ * several columns (a notes field across the whole row).
24
74
  */
25
75
  layout?: DescriptionListLayout;
26
76
  /** Every detail is a figure: tabular digits, end-aligned. A `numeric` list of
@@ -28,10 +78,31 @@ export interface DescriptionListProps extends ComponentPropsWithoutRef<"dl"> {
28
78
  * term would double the list's height to gain no width. Per item: see
29
79
  * {@link DescriptionItemProps.numeric}. */
30
80
  numeric?: boolean;
31
- /** `compact` tightens the rhythm for a dense side panel. */
81
+ /** `compact` tightens the rhythm for a dense side panel. `tight` (0.10.0) is 11px
82
+ * type on a 2px rhythm with no rules between rows — keksdose's admin metrics
83
+ * breakdowns (metrics-panel's label-left, count-right lists), which are read as a
84
+ * block of small print, not scanned row by row. */
32
85
  density?: DescriptionListDensity;
33
86
  /** `cards` only: the narrowest a card may get before the grid drops a column. */
34
87
  minCardWidth?: string;
88
+ /**
89
+ * `stacked`: how many columns the grid grows to on a wide pane (default 2), see
90
+ * {@link STACKED_COLS}. `cards` (0.10.0): the MOST cards per row; the grid still
91
+ * drops columns below {@link minCardWidth}. keksdose's category editor faked "two
92
+ * side by side" with `minCardWidth="calc(50% - 0.5rem)"`, which is `columns={2}
93
+ * minCardWidth="0"` now, without knowing the gap. Ignored by `rows`.
94
+ */
95
+ columns?: DescriptionListColumns;
96
+ /**
97
+ * The details are sentences, not values: regular weight in the secondary ink.
98
+ * lenkbank's control explainer cards each carry a paragraph, and at the card's
99
+ * `font-medium` primary ink a grid of paragraphs reads as a wall of headings — it
100
+ * spelled `font-normal text-slate-700` into every item. A flag rather than a new
101
+ * default because every other card list in the apps holds values (dates, figures,
102
+ * counts) that are not flagged `numeric` and ARE meant to stand out; lightening them
103
+ * all would not be an improvement. Per item: {@link DescriptionItemProps.prose}.
104
+ */
105
+ prose?: boolean;
35
106
  }
36
107
 
37
108
  /**
@@ -54,27 +125,66 @@ export interface DescriptionListProps extends ComponentPropsWithoutRef<"dl"> {
54
125
  export function DescriptionList({
55
126
  layout = "rows",
56
127
  numeric = false,
128
+ prose = false,
57
129
  density = "comfortable",
58
130
  minCardWidth = "10rem",
131
+ columns,
59
132
  className,
60
133
  style,
61
134
  ...rest
62
135
  }: DescriptionListProps) {
63
136
  const cards = layout === "cards";
137
+ const stacked = layout === "stacked";
138
+ const gap = CARD_GAP[density];
139
+ // Cards: auto-fill at `minCardWidth`, and with `columns` a floor on the track of one
140
+ // n-th of the row (less the gaps) — so auto-fill can never fit more than n. The
141
+ // outer `min(100%, …)` keeps a single card from overflowing a pane narrower than
142
+ // its minimum.
143
+ const track =
144
+ columns !== undefined
145
+ ? `max(${minCardWidth}, (100% - ${columns - 1} * ${gap.len}) / ${columns})`
146
+ : minCardWidth;
64
147
  const grid: CSSProperties | undefined = cards
65
- ? { gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minCardWidth}), 1fr))` }
148
+ ? { gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${track}), 1fr))` }
66
149
  : undefined;
150
+ const ctx: ListContextValue = { layout, numeric, prose, density, columns: columns ?? 2 };
151
+ const text = density === "tight" ? "text-[11px] leading-4" : density === "compact" ? "text-xs" : "text-sm";
152
+
153
+ if (stacked) {
154
+ return (
155
+ <ListContext.Provider value={ctx}>
156
+ {/* The container the columns are measured on — see STACKED_COLS. */}
157
+ <div className="@container">
158
+ <dl
159
+ {...rest}
160
+ data-layout={layout}
161
+ style={style}
162
+ className={cn(
163
+ "grid",
164
+ STACKED_COLS[ctx.columns],
165
+ density === "tight" ? "gap-x-3 gap-y-1" : density === "compact" ? "gap-x-4 gap-y-2" : "gap-x-6 gap-y-4",
166
+ text,
167
+ className,
168
+ )}
169
+ />
170
+ </div>
171
+ </ListContext.Provider>
172
+ );
173
+ }
174
+
67
175
  return (
68
- <ListContext.Provider value={{ layout, numeric, density }}>
176
+ <ListContext.Provider value={ctx}>
69
177
  <dl
70
178
  {...rest}
71
179
  data-layout={layout}
72
180
  style={grid ? { ...grid, ...style } : style}
73
181
  className={cn(
74
182
  cards
75
- ? cn("grid", density === "compact" ? "gap-2" : "gap-3")
76
- : "@container divide-y divide-[var(--border)]",
77
- density === "compact" ? "text-xs" : "text-sm",
183
+ ? cn("grid", gap.cls)
184
+ : // Tight rows carry no rules: at a 2px rhythm a line under every row
185
+ // turns a block of small print into a ruled table.
186
+ cn("@container", density !== "tight" && "divide-y divide-[var(--border)]"),
187
+ text,
78
188
  className,
79
189
  )}
80
190
  />
@@ -95,10 +205,28 @@ export interface DescriptionItemProps extends Omit<ComponentPropsWithoutRef<"div
95
205
  numeric?: boolean;
96
206
  /** Classes for the `<dd>`. */
97
207
  detailClassName?: string;
208
+ /** This detail is prose (see {@link DescriptionListProps.prose}); overrides the
209
+ * list's setting either way. */
210
+ prose?: boolean;
211
+ /**
212
+ * `stacked`: how many columns this item spans, clamped to the list's `columns` and
213
+ * to however many the pane has room for; `"full"` is the whole row. kastlan's
214
+ * DetailField `span={2}` for notes and descriptions. `cards`: only `"full"` applies,
215
+ * because a card grid's column count is not known to CSS ahead of layout.
216
+ */
217
+ span?: number | "full";
98
218
  }
99
219
 
100
- const ROW_PAD: Record<DescriptionListDensity, string> = { comfortable: "py-2.5", compact: "py-1.5" };
101
- const CARD_PAD: Record<DescriptionListDensity, string> = { comfortable: "px-3 py-2.5", compact: "px-2.5 py-1.5" };
220
+ const ROW_PAD: Record<DescriptionListDensity, string> = {
221
+ comfortable: "py-2.5",
222
+ compact: "py-1.5",
223
+ tight: "py-px",
224
+ };
225
+ const CARD_PAD: Record<DescriptionListDensity, string> = {
226
+ comfortable: "px-3 py-2.5",
227
+ compact: "px-2.5 py-1.5",
228
+ tight: "px-2 py-1",
229
+ };
102
230
 
103
231
  /** One term and its detail inside a {@link DescriptionList}. */
104
232
  export function DescriptionItem({
@@ -106,19 +234,32 @@ export function DescriptionItem({
106
234
  children,
107
235
  hint,
108
236
  numeric: numericProp,
237
+ prose: proseProp,
238
+ span,
109
239
  detailClassName,
110
240
  className,
111
241
  ...rest
112
242
  }: DescriptionItemProps) {
113
- const { layout, numeric: listNumeric, density } = useContext(ListContext);
243
+ const { layout, numeric: listNumeric, prose: listProse, density, columns } = useContext(ListContext);
114
244
  const numeric = numericProp ?? listNumeric;
245
+ const prose = proseProp ?? listProse;
115
246
  const cards = layout === "cards";
247
+ const stacked = layout === "stacked";
248
+ const spanClass =
249
+ span === "full"
250
+ ? "col-span-full"
251
+ : stacked && span !== undefined && span > 1
252
+ ? STACKED_SPAN[Math.min(Math.floor(span), columns) as DescriptionListColumns]
253
+ : undefined;
116
254
 
117
255
  const termNode = (
118
256
  <dt
119
257
  className={cn(
120
258
  "flex min-w-0 items-center gap-1 text-[var(--text-muted)]",
121
- cards && "text-xs uppercase tracking-wide",
259
+ cards && (density === "tight" ? "text-[10px] uppercase tracking-wide" : "text-xs uppercase tracking-wide"),
260
+ // Stacked terms are a step smaller than the detail, as kastlan's dialogs set
261
+ // them; in a list that is already small they stay the list's own size.
262
+ stacked && density === "comfortable" && "text-xs",
122
263
  )}
123
264
  >
124
265
  <span className="min-w-0 break-words">{term}</span>
@@ -129,8 +270,10 @@ export function DescriptionItem({
129
270
  <dd
130
271
  className={cn(
131
272
  "min-w-0 break-words text-[var(--text-primary)]",
132
- numeric && "tabular-nums text-end",
133
- cards && "mt-0.5 font-medium",
273
+ // A stacked figure sits under its term, so it keeps the term's start edge.
274
+ numeric && (stacked ? "tabular-nums" : "tabular-nums text-end"),
275
+ (cards || stacked) && "mt-0.5 font-medium",
276
+ prose && "font-normal text-[var(--text-secondary)]",
134
277
  detailClassName,
135
278
  )}
136
279
  >
@@ -145,6 +288,7 @@ export function DescriptionItem({
145
288
  className={cn(
146
289
  "min-w-0 rounded-md border border-[var(--border)] bg-[var(--bg-surface)]",
147
290
  CARD_PAD[density],
291
+ spanClass,
148
292
  className,
149
293
  )}
150
294
  >
@@ -154,6 +298,15 @@ export function DescriptionItem({
154
298
  );
155
299
  }
156
300
 
301
+ if (stacked) {
302
+ return (
303
+ <div {...rest} className={cn("min-w-0", spanClass, className)}>
304
+ {termNode}
305
+ {detail}
306
+ </div>
307
+ );
308
+ }
309
+
157
310
  return (
158
311
  <div
159
312
  {...rest}
@@ -103,6 +103,13 @@ export interface DialogFrameProps extends Omit<ModalProps, "labelledBy" | "child
103
103
  * tailwind-merged last), which is how a full-screen phone sheet is spelt:
104
104
  * `fullBleed className="h-[100dvh] max-w-full rounded-none md:h-auto md:rounded-lg"`.
105
105
  *
106
+ * ## Kept mounted
107
+ *
108
+ * `open` reaches the `Modal` like every other prop, so `<DialogFrame open={open} …>`
109
+ * replaces the `{open && <DialogFrame …/>}` gate (kastlan's `FormModal` was only
110
+ * that gate) and gains the exit a caller-driven close otherwise skips. See
111
+ * {@link ModalProps.open}.
112
+ *
106
113
  * ## What it is not
107
114
  *
108
115
  * Not `FullBleedDialog`: that is the phone's full-screen editor with its own `open`,
@@ -3,22 +3,14 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
3
  import { ChevronDown } from "lucide-react";
4
4
 
5
5
  import { cn } from "../lib/cn";
6
+ // Read at the moment of closing, as `useCloseTransition` does: the setting can change
7
+ // under a long-lived page.
8
+ import { prefersReducedMotion } from "../hooks/use-close-transition";
6
9
 
7
10
  /** How long the fold takes, in ms. The same number as the `duration-200` below and as
8
11
  * the unmount timer, because they are the same movement. */
9
12
  const COLLAPSE_MS = 200;
10
13
 
11
- /** Read at the moment of closing, like `useCloseTransition` does: the setting can
12
- * change under a long-lived page, and jsdom/SSR have no `matchMedia` — where "no
13
- * animation" is also the only correct answer, since nothing is painting. */
14
- function prefersReducedMotion(): boolean {
15
- return (
16
- typeof window === "undefined" ||
17
- typeof window.matchMedia !== "function" ||
18
- window.matchMedia("(prefers-reduced-motion: reduce)").matches
19
- );
20
- }
21
-
22
14
  /**
23
15
  * `extends` the div's props so an `id` (what a trigger's `aria-controls` points at), a
24
16
  * `data-tour` anchor or a test id reaches the element that folds.
@@ -144,8 +136,17 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
144
136
  * settings or analysis page. `bare` draws nothing: a leading chevron that turns
145
137
  * down, for an inline "Show 3 hidden accounts" inside something that already has
146
138
  * its own surface.
139
+ *
140
+ * `menu` is a row of a `HoverMenu`: the top bar's menu-item look
141
+ * (`TOPBAR_MENU_ITEM_CLASS` — full-width, `px-3 py-2`, regular weight, square, the
142
+ * hover wash with the text colour left alone), an INSET focus ring so the menu's
143
+ * clipping edge cannot cut it off, the chevron at the end (`chevronPosition`
144
+ * defaults to `end` here) and a body with no padding of its own, so the sub-list's
145
+ * rows sit flush like the rows around them. keksdose's account menu opens its
146
+ * language sub-list this way, and on `bare` it took four header overrides and a
147
+ * body one to get there (account-menu.tsx).
147
148
  */
148
- variant?: "card" | "bare";
149
+ variant?: "card" | "bare" | "menu";
149
150
  /**
150
151
  * Where a `bare` disclosure draws its chevron. `start` (default) is the leading
151
152
  * chevron that turns from the reading direction to down. `end` puts it at the far
@@ -153,9 +154,15 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
153
154
  * account menu, whose language sub-list reads "Language 🇩🇪 ⌄" with the chevron
154
155
  * after the flag, like every other menu row with a sub-list. With `trailing`, the
155
156
  * chevron follows it, as on a card. A `card` always has it at the end, so this is
156
- * ignored there.
157
+ * ignored there. A `menu` defaults to `end`, like the menu rows around it.
158
+ *
159
+ * `after-title` sits it right after the title's text, pointing down and turning up —
160
+ * "Net profit ⌄ ······ 1,234.50", Lenkbank's result rows, where the far end of the
161
+ * row belongs to a figure (see {@link trailingInTrigger}) and a leading chevron
162
+ * would push every label off the column the non-expandable rows' labels start on.
163
+ * Not a card's: its chevron keeps the end.
157
164
  */
158
- chevronPosition?: "start" | "end";
165
+ chevronPosition?: "start" | "end" | "after-title";
159
166
  /**
160
167
  * Wrap the header button in a heading of this level. The WAI-ARIA disclosure pattern
161
168
  * puts the button INSIDE the heading when the disclosure titles a section, so the
@@ -194,8 +201,26 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
194
201
  * the row under it (a stretched hit area), so clicking the empty space or the
195
202
  * card's chevron still toggles, while whatever sits in `trailing` gets its own
196
203
  * clicks. With `trailing`, the card's chevron moves after it, to the row's end.
204
+ *
205
+ * Unless {@link trailingInTrigger} is set, which moves it INTO the button.
197
206
  */
198
207
  trailing?: ReactNode;
208
+ /**
209
+ * Render {@link trailing} inside the header button instead of beside it, so the
210
+ * whole row — figure included — is one click target and the figure is part of the
211
+ * button's accessible name: "Net profit 1,234.50, collapsed" rather than a button
212
+ * called "Net profit" next to an unexplained number.
213
+ *
214
+ * Lenkbank's result rows (features/steering/results-panel.tsx) are the case: label,
215
+ * chevron, and the value at the far end, all one `<button>`, because a result row
216
+ * has nothing else it could mean.
217
+ *
218
+ * ⚠️ Only for NON-interactive content — a count, a figure, a status chip. A button
219
+ * may not contain another interactive element; an action belongs in the default,
220
+ * sibling `trailing`. The content is rendered in a `<span>`, so keep it phrasing
221
+ * content too (no `div`s).
222
+ */
223
+ trailingInTrigger?: boolean;
199
224
  /**
200
225
  * Trigger-only mode: the id of an element the CALLER renders elsewhere — the hidden
201
226
  * rows of a table, a panel in another column — which this header shows and hides.
@@ -203,6 +228,37 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
203
228
  * its own (`children`, `bodyClassName` and `keepMounted` are ignored). Pair it with
204
229
  * `open` / `onOpenChange`: the caller owns the state, since the caller renders what
205
230
  * it governs.
231
+ *
232
+ * It is an IDREF LIST, as `aria-controls` is: several space-separated ids when what
233
+ * folds is several elements — a budget group's category rows, each its own `<li>`,
234
+ * siblings of this header (keksdose's budget-mobile-list joins their ids with
235
+ * `" "`). It may be the EMPTY string: trigger-only mode with nothing to point at yet
236
+ * (a group whose rows are all filtered away, rows that are not rendered while shut).
237
+ * The header then carries no `aria-controls` at all rather than an empty one, and
238
+ * still toggles.
239
+ *
240
+ * A table's leading row — keksdose's "Upcoming" toggle over the scheduled rows of
241
+ * its transactions table — is this mode too: the rows it folds are the TABLE's, so
242
+ * the disclosure is only the header, and the table renders or drops them from the
243
+ * same state.
244
+ *
245
+ * ```tsx
246
+ * <DataTable
247
+ * rows={showFuture ? [...scheduled, ...posted] : posted}
248
+ * leadingRow={
249
+ * <Disclosure
250
+ * variant="bare"
251
+ * title={upcomingLabel}
252
+ * open={showFuture}
253
+ * onOpenChange={setShowFuture}
254
+ * // The scheduled rows' ids while they are rendered; "" while they are not.
255
+ * controls={showFuture ? scheduled.map((tx) => `row-s${tx.id}`).join(" ") : ""}
256
+ * headerClassName="px-3 py-2"
257
+ * />
258
+ * }
259
+ * …
260
+ * />
261
+ * ```
206
262
  */
207
263
  controls?: string;
208
264
  /** The body. Not rendered in trigger-only mode ({@link DisclosureProps.controls}). */
@@ -230,7 +286,7 @@ export function Disclosure({
230
286
  defaultOpen = false,
231
287
  onOpenChange,
232
288
  variant = "card",
233
- chevronPosition = "start",
289
+ chevronPosition,
234
290
  headingAs: Heading,
235
291
  keepMounted,
236
292
  disabled,
@@ -238,6 +294,7 @@ export function Disclosure({
238
294
  triggerProps,
239
295
  bodyClassName,
240
296
  trailing,
297
+ trailingInTrigger = false,
241
298
  controls,
242
299
  className,
243
300
  children,
@@ -247,12 +304,20 @@ export function Disclosure({
247
304
  const open = controlled ?? own;
248
305
  const bodyId = useId();
249
306
  const card = variant === "card";
250
- // The card's chevron trails always; a bare one only when asked to.
251
- const chevronAtEnd = card || chevronPosition === "end";
307
+ const menu = variant === "menu";
308
+ // The card's chevron trails always; a menu row's unless asked otherwise; a bare
309
+ // one only when asked to.
310
+ const chevronAt = card ? "end" : (chevronPosition ?? (menu ? "end" : "start"));
311
+ const chevronAtEnd = chevronAt === "end";
312
+ const chevronAfterTitle = chevronAt === "after-title";
252
313
  const triggerOnly = controls !== undefined;
253
314
  // The card's header squares its lower corners only when a body opens under it.
254
315
  const joined = open && !triggerOnly;
255
316
  const hasTrailing = trailing !== undefined && trailing !== null && trailing !== false;
317
+ // `trailing` beside the button, which then stretches under it; inside the button,
318
+ // the row is simply the button.
319
+ const trailingInside = hasTrailing && trailingInTrigger;
320
+ const trailingBeside = hasTrailing && !trailingInTrigger;
256
321
 
257
322
  const toggle = () => {
258
323
  const next = !open;
@@ -267,55 +332,78 @@ export function Disclosure({
267
332
  {...extraTriggerProps}
268
333
  type="button"
269
334
  aria-expanded={open}
270
- aria-controls={triggerOnly ? controls : bodyId}
335
+ // `||`: an empty `controls` is trigger-only with nothing to name, not `aria-controls=""`.
336
+ aria-controls={triggerOnly ? controls || undefined : bodyId}
271
337
  disabled={disabled}
272
338
  onClick={toggle}
273
339
  data-disclosure-trigger=""
274
340
  className={cn(
275
341
  "flex w-full gap-2 text-start outline-none disabled:cursor-not-allowed disabled:opacity-50",
276
- hasTrailing
342
+ trailingBeside
277
343
  ? // Stretched over the whole header row (the row is `relative`), so the space
278
344
  // round `trailing` and the card's chevron still toggle; the ring and the
279
345
  // card's hover paint on the stretched area / the row, not the text box.
280
346
  cn(
281
347
  "min-w-0 flex-1 after:absolute after:inset-0 after:content-['']",
282
348
  "focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-[var(--brand)]",
283
- card ? cn("after:rounded-lg", joined && "after:rounded-b-none") : "after:rounded-sm",
349
+ card ? cn("after:rounded-lg", joined && "after:rounded-b-none") : !menu && "after:rounded-sm",
284
350
  )
285
351
  : "focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
286
352
  card
287
353
  ? cn(
288
354
  "items-center justify-between rounded-lg p-4",
289
- hasTrailing ? "pe-0" : "hover:bg-[var(--bg-hover)] focus-visible:ring-inset",
355
+ trailingBeside ? "pe-0" : "hover:bg-[var(--bg-hover)] focus-visible:ring-inset",
290
356
  joined && "rounded-b-none",
291
357
  )
292
- : "items-center rounded-sm text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
358
+ : menu
359
+ ? // TOPBAR_MENU_ITEM_CLASS's row, spelled out rather than imported: a
360
+ // component does not reach up into the shell. A test holds the two to
361
+ // the same classes.
362
+ "items-center justify-between gap-3 px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] focus-visible:ring-inset"
363
+ : "items-center rounded-sm text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
293
364
  headerClassName,
294
365
  )}
295
366
  >
296
- {!chevronAtEnd && <Chevron open={open} leading />}
367
+ {chevronAt === "start" && <Chevron open={open} leading />}
297
368
  {/* `flex-1`: the title takes the row, so whatever a caller puts at its end sits
298
369
  at the header's far edge. */}
299
370
  <span className="min-w-0 flex-1">
300
- <span className={cn("block", card && "text-sm font-semibold text-[var(--text-primary)]")}>{title}</span>
371
+ {chevronAfterTitle ? (
372
+ // The chevron hugs the text, however long the translation: the text wraps
373
+ // before the chevron is pushed away from it.
374
+ <span className="flex items-center gap-1">
375
+ <span className="min-w-0">{title}</span>
376
+ <Chevron open={open} small />
377
+ </span>
378
+ ) : (
379
+ <span className={cn("block", card && "text-sm font-semibold text-[var(--text-primary)]")}>{title}</span>
380
+ )}
301
381
  {hint !== undefined && (
302
382
  <span className="mt-0.5 block text-xs font-normal text-[var(--text-muted)]">{hint}</span>
303
383
  )}
304
384
  </span>
305
- {chevronAtEnd && !hasTrailing && <Chevron open={open} small={!card} />}
385
+ {/* The space is for the accessible name — "Net profit 1,234.50", not
386
+ "Net profit1,234.50"; between flex items it renders as nothing. */}
387
+ {trailingInside && " "}
388
+ {trailingInside && (
389
+ <span data-disclosure-trailing="" className="flex shrink-0 items-center gap-2">
390
+ {trailing}
391
+ </span>
392
+ )}
393
+ {chevronAtEnd && !trailingBeside && <Chevron open={open} small={!card} />}
306
394
  </button>
307
395
  );
308
396
 
309
397
  // Preflight already makes h1–h6 inherit size and weight, so the heading adds
310
398
  // structure and nothing visible.
311
- const header = Heading ? <Heading className={hasTrailing ? "min-w-0 flex-1" : undefined}>{button}</Heading> : button;
399
+ const header = Heading ? <Heading className={trailingBeside ? "min-w-0 flex-1" : undefined}>{button}</Heading> : button;
312
400
 
313
401
  return (
314
402
  <div
315
403
  {...rest}
316
404
  className={cn(card && "rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm", className)}
317
405
  >
318
- {hasTrailing ? (
406
+ {trailingBeside ? (
319
407
  <div
320
408
  className={cn(
321
409
  "relative flex items-center gap-2",
@@ -337,7 +425,7 @@ export function Disclosure({
337
425
  )}
338
426
  {!triggerOnly && (
339
427
  <Collapse id={bodyId} open={open} keepMounted={keepMounted}>
340
- <div className={cn(card ? "space-y-3 px-4 pb-4" : "space-y-2 pt-2", bodyClassName)}>{children}</div>
428
+ <div className={cn(card ? "space-y-3 px-4 pb-4" : !menu && "space-y-2 pt-2", bodyClassName)}>{children}</div>
341
429
  </Collapse>
342
430
  )}
343
431
  </div>
@@ -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);