@eifi1/ui-kit 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/README.md +37 -23
  2. package/dist/components/alert-banner.d.ts +57 -5
  3. package/dist/components/alert-banner.js +63 -17
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +6 -0
  6. package/dist/components/breadcrumbs.d.ts +60 -0
  7. package/dist/components/breadcrumbs.js +88 -0
  8. package/dist/components/breadcrumbs.js.map +1 -0
  9. package/dist/components/bulk-action-bar.d.ts +112 -0
  10. package/dist/components/bulk-action-bar.js +196 -0
  11. package/dist/components/bulk-action-bar.js.map +1 -0
  12. package/dist/components/button-group.d.ts +58 -3
  13. package/dist/components/button-group.js +56 -5
  14. package/dist/components/button-group.js.map +1 -1
  15. package/dist/components/calculator.d.ts +6 -0
  16. package/dist/components/calendar-heatmap.d.ts +124 -0
  17. package/dist/components/calendar-heatmap.js +295 -0
  18. package/dist/components/calendar-heatmap.js.map +1 -0
  19. package/dist/components/chip.d.ts +31 -5
  20. package/dist/components/chip.js +85 -5
  21. package/dist/components/chip.js.map +1 -1
  22. package/dist/components/choice-card.d.ts +34 -2
  23. package/dist/components/choice-card.js +49 -0
  24. package/dist/components/choice-card.js.map +1 -1
  25. package/dist/components/copy-button.d.ts +14 -4
  26. package/dist/components/copy-button.js +17 -4
  27. package/dist/components/copy-button.js.map +1 -1
  28. package/dist/components/data-table.js +2 -0
  29. package/dist/components/data-table.js.map +1 -1
  30. package/dist/components/date-picker.d.ts +22 -2
  31. package/dist/components/date-picker.js +11 -3
  32. package/dist/components/date-picker.js.map +1 -1
  33. package/dist/components/description-list.d.ts +43 -6
  34. package/dist/components/description-list.js +91 -11
  35. package/dist/components/description-list.js.map +1 -1
  36. package/dist/components/dialog-frame.d.ts +7 -0
  37. package/dist/components/dialog-frame.js.map +1 -1
  38. package/dist/components/disclosure.d.ts +57 -2
  39. package/dist/components/disclosure.js +24 -12
  40. package/dist/components/disclosure.js.map +1 -1
  41. package/dist/components/field.d.ts +43 -0
  42. package/dist/components/field.js +52 -0
  43. package/dist/components/field.js.map +1 -0
  44. package/dist/components/file-button.d.ts +1 -0
  45. package/dist/components/file-dropzone.d.ts +4 -4
  46. package/dist/components/file-dropzone.js +2 -1
  47. package/dist/components/file-dropzone.js.map +1 -1
  48. package/dist/components/floating-panel.d.ts +147 -4
  49. package/dist/components/floating-panel.js +188 -28
  50. package/dist/components/floating-panel.js.map +1 -1
  51. package/dist/components/full-bleed-dialog.d.ts +23 -4
  52. package/dist/components/full-bleed-dialog.js +11 -2
  53. package/dist/components/full-bleed-dialog.js.map +1 -1
  54. package/dist/components/list.d.ts +187 -0
  55. package/dist/components/list.js +221 -0
  56. package/dist/components/list.js.map +1 -0
  57. package/dist/components/menu-item.d.ts +109 -0
  58. package/dist/components/menu-item.js +88 -0
  59. package/dist/components/menu-item.js.map +1 -0
  60. package/dist/components/mini-calendar.d.ts +70 -5
  61. package/dist/components/mini-calendar.js +166 -62
  62. package/dist/components/mini-calendar.js.map +1 -1
  63. package/dist/components/modal.d.ts +23 -1
  64. package/dist/components/modal.js +36 -10
  65. package/dist/components/modal.js.map +1 -1
  66. package/dist/components/nav-pills.d.ts +66 -0
  67. package/dist/components/nav-pills.js +65 -0
  68. package/dist/components/nav-pills.js.map +1 -0
  69. package/dist/components/number-field.d.ts +6 -0
  70. package/dist/components/number-input.d.ts +6 -0
  71. package/dist/components/numpad-sheet.d.ts +6 -0
  72. package/dist/components/page-contents.js +1 -2
  73. package/dist/components/page-contents.js.map +1 -1
  74. package/dist/components/page-header.d.ts +37 -0
  75. package/dist/components/page-header.js +36 -0
  76. package/dist/components/page-header.js.map +1 -0
  77. package/dist/components/progress-bar.d.ts +31 -2
  78. package/dist/components/progress-bar.js +73 -7
  79. package/dist/components/progress-bar.js.map +1 -1
  80. package/dist/components/series-chart-labels.d.ts +3 -0
  81. package/dist/components/series-chart-labels.js +2 -1
  82. package/dist/components/series-chart-labels.js.map +1 -1
  83. package/dist/components/series-chart.d.ts +32 -0
  84. package/dist/components/series-chart.js +141 -4
  85. package/dist/components/series-chart.js.map +1 -1
  86. package/dist/components/settings-fields.d.ts +1 -0
  87. package/dist/components/status-dot.d.ts +53 -0
  88. package/dist/components/status-dot.js +57 -0
  89. package/dist/components/status-dot.js.map +1 -0
  90. package/dist/components/table.d.ts +78 -13
  91. package/dist/components/table.js +84 -11
  92. package/dist/components/table.js.map +1 -1
  93. package/dist/components/text.d.ts +70 -0
  94. package/dist/components/text.js +24 -0
  95. package/dist/components/text.js.map +1 -0
  96. package/dist/components/time-input.d.ts +1 -0
  97. package/dist/components/toast.d.ts +167 -0
  98. package/dist/components/toast.js +226 -0
  99. package/dist/components/toast.js.map +1 -0
  100. package/dist/components/toggle-group.d.ts +44 -1
  101. package/dist/components/toggle-group.js +73 -4
  102. package/dist/components/toggle-group.js.map +1 -1
  103. package/dist/components/tooltip.d.ts +46 -15
  104. package/dist/components/tooltip.js +87 -47
  105. package/dist/components/tooltip.js.map +1 -1
  106. package/dist/components/ui.d.ts +158 -7
  107. package/dist/components/ui.js +191 -10
  108. package/dist/components/ui.js.map +1 -1
  109. package/dist/components/user-avatar.d.ts +25 -3
  110. package/dist/components/user-avatar.js +32 -3
  111. package/dist/components/user-avatar.js.map +1 -1
  112. package/dist/hooks/use-close-transition.d.ts +7 -1
  113. package/dist/hooks/use-close-transition.js +5 -2
  114. package/dist/hooks/use-close-transition.js.map +1 -1
  115. package/dist/hooks/use-copy-to-clipboard.js +1 -1
  116. package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
  117. package/dist/hooks/use-file-drop.d.ts +1 -0
  118. package/dist/i18n/defaults.d.ts +6 -0
  119. package/dist/i18n/defaults.js +11 -1
  120. package/dist/i18n/defaults.js.map +1 -1
  121. package/dist/i18n/kit-labels.d.ts +11 -0
  122. package/dist/i18n/kit-labels.js.map +1 -1
  123. package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
  124. package/dist/i18n/locales/de-CH.d.ts +6 -0
  125. package/dist/i18n/locales/de-informal.d.ts +6 -0
  126. package/dist/i18n/locales/de.d.ts +6 -0
  127. package/dist/i18n/locales/de.js +30 -2
  128. package/dist/i18n/locales/de.js.map +1 -1
  129. package/dist/i18n/locales/es.d.ts +6 -0
  130. package/dist/i18n/locales/es.js +30 -2
  131. package/dist/i18n/locales/es.js.map +1 -1
  132. package/dist/i18n/locales/fr.d.ts +6 -0
  133. package/dist/i18n/locales/fr.js +30 -2
  134. package/dist/i18n/locales/fr.js.map +1 -1
  135. package/dist/i18n/locales/hu.d.ts +6 -0
  136. package/dist/i18n/locales/hu.js +30 -2
  137. package/dist/i18n/locales/hu.js.map +1 -1
  138. package/dist/i18n/locales/it.d.ts +6 -0
  139. package/dist/i18n/locales/it.js +30 -2
  140. package/dist/i18n/locales/it.js.map +1 -1
  141. package/dist/i18n/locales/zh.d.ts +6 -0
  142. package/dist/i18n/locales/zh.js +30 -2
  143. package/dist/i18n/locales/zh.js.map +1 -1
  144. package/dist/index.d.ts +27 -15
  145. package/dist/index.js +15 -0
  146. package/dist/index.js.map +1 -1
  147. package/dist/lib/clipping.d.ts +25 -0
  148. package/dist/lib/clipping.js +17 -0
  149. package/dist/lib/clipping.js.map +1 -0
  150. package/dist/lib/dates.d.ts +17 -1
  151. package/dist/lib/dates.js +19 -0
  152. package/dist/lib/dates.js.map +1 -1
  153. package/dist/rhf/form.d.ts +1 -0
  154. package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
  155. package/dist/rhf/use-rhf-wizard-step.js +38 -0
  156. package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
  157. package/dist/rhf.d.ts +2 -0
  158. package/dist/rhf.js +1 -0
  159. package/dist/rhf.js.map +1 -1
  160. package/dist/search/command-palette.d.ts +19 -2
  161. package/dist/search/command-palette.js +16 -5
  162. package/dist/search/command-palette.js.map +1 -1
  163. package/dist/search/global-search.d.ts +26 -1
  164. package/dist/search/global-search.js +29 -13
  165. package/dist/search/global-search.js.map +1 -1
  166. package/dist/search.d.ts +1 -1
  167. package/dist/shell/topbar-action-menu.d.ts +73 -3
  168. package/dist/shell/topbar-action-menu.js +100 -27
  169. package/dist/shell/topbar-action-menu.js.map +1 -1
  170. package/dist/shell.d.ts +2 -1
  171. package/dist/wizard/stepper-nav.d.ts +1 -0
  172. package/dist/wizard/stepper-nav.js +1 -1
  173. package/dist/wizard/stepper-nav.js.map +1 -1
  174. package/dist/wizard/types.d.ts +2 -2
  175. package/dist/wizard/types.js.map +1 -1
  176. package/dist/wizard/use-wizard.js +1 -1
  177. package/dist/wizard/use-wizard.js.map +1 -1
  178. package/dist/wizard/wizard-context.d.ts +7 -1
  179. package/dist/wizard/wizard-context.js +4 -0
  180. package/dist/wizard/wizard-context.js.map +1 -1
  181. package/dist/wizard/wizard-summary.js +3 -3
  182. package/dist/wizard/wizard-summary.js.map +1 -1
  183. package/dist/wizard.d.ts +2 -1
  184. package/package.json +16 -12
  185. package/src/components/alert-banner.tsx +149 -21
  186. package/src/components/breadcrumbs.tsx +168 -0
  187. package/src/components/bulk-action-bar.tsx +362 -0
  188. package/src/components/button-group.tsx +124 -4
  189. package/src/components/calendar-heatmap.tsx +504 -0
  190. package/src/components/chip.tsx +128 -7
  191. package/src/components/choice-card.tsx +97 -1
  192. package/src/components/copy-button.tsx +29 -7
  193. package/src/components/data-table.tsx +7 -0
  194. package/src/components/date-picker.tsx +49 -1
  195. package/src/components/description-list.tsx +168 -15
  196. package/src/components/dialog-frame.tsx +7 -0
  197. package/src/components/disclosure.tsx +93 -21
  198. package/src/components/field.tsx +137 -0
  199. package/src/components/file-dropzone.tsx +9 -10
  200. package/src/components/floating-panel.tsx +366 -15
  201. package/src/components/full-bleed-dialog.tsx +42 -5
  202. package/src/components/list.tsx +443 -0
  203. package/src/components/menu-item.tsx +235 -0
  204. package/src/components/mini-calendar.tsx +245 -54
  205. package/src/components/modal.tsx +80 -17
  206. package/src/components/nav-pills.tsx +145 -0
  207. package/src/components/page-contents.tsx +4 -4
  208. package/src/components/page-header.tsx +68 -0
  209. package/src/components/progress-bar.tsx +129 -11
  210. package/src/components/series-chart-labels.ts +4 -0
  211. package/src/components/series-chart.tsx +274 -3
  212. package/src/components/status-dot.tsx +107 -0
  213. package/src/components/table.tsx +176 -17
  214. package/src/components/text.tsx +97 -0
  215. package/src/components/toast.tsx +441 -0
  216. package/src/components/toggle-group.tsx +128 -4
  217. package/src/components/tooltip.tsx +200 -100
  218. package/src/components/ui.tsx +381 -14
  219. package/src/components/user-avatar.tsx +57 -3
  220. package/src/hooks/use-close-transition.ts +14 -5
  221. package/src/hooks/use-copy-to-clipboard.ts +1 -1
  222. package/src/i18n/defaults.ts +10 -0
  223. package/src/i18n/kit-labels.tsx +10 -0
  224. package/src/i18n/locales/de.ts +29 -0
  225. package/src/i18n/locales/es.ts +29 -0
  226. package/src/i18n/locales/fr.ts +29 -0
  227. package/src/i18n/locales/hu.ts +29 -0
  228. package/src/i18n/locales/it.ts +29 -0
  229. package/src/i18n/locales/zh.ts +29 -0
  230. package/src/index.ts +31 -0
  231. package/src/lib/clipping.ts +34 -0
  232. package/src/lib/dates.ts +37 -0
  233. package/src/rhf/use-rhf-wizard-step.ts +113 -0
  234. package/src/rhf.ts +2 -0
  235. package/src/search/command-palette.tsx +42 -5
  236. package/src/search/global-search.tsx +59 -18
  237. package/src/shell/topbar-action-menu.tsx +239 -49
  238. package/src/wizard/stepper-nav.tsx +2 -1
  239. package/src/wizard/types.ts +2 -2
  240. package/src/wizard/use-wizard.ts +3 -3
  241. package/src/wizard/wizard-context.tsx +9 -0
  242. package/src/wizard/wizard-summary.tsx +14 -10
  243. package/tokens.css +97 -0
@@ -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.
@@ -163,8 +155,14 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
163
155
  * after the flag, like every other menu row with a sub-list. With `trailing`, the
164
156
  * chevron follows it, as on a card. A `card` always has it at the end, so this is
165
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.
166
164
  */
167
- chevronPosition?: "start" | "end";
165
+ chevronPosition?: "start" | "end" | "after-title";
168
166
  /**
169
167
  * Wrap the header button in a heading of this level. The WAI-ARIA disclosure pattern
170
168
  * puts the button INSIDE the heading when the disclosure titles a section, so the
@@ -203,8 +201,26 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
203
201
  * the row under it (a stretched hit area), so clicking the empty space or the
204
202
  * card's chevron still toggles, while whatever sits in `trailing` gets its own
205
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.
206
206
  */
207
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;
208
224
  /**
209
225
  * Trigger-only mode: the id of an element the CALLER renders elsewhere — the hidden
210
226
  * rows of a table, a panel in another column — which this header shows and hides.
@@ -212,6 +228,37 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
212
228
  * its own (`children`, `bodyClassName` and `keepMounted` are ignored). Pair it with
213
229
  * `open` / `onOpenChange`: the caller owns the state, since the caller renders what
214
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
+ * ```
215
262
  */
216
263
  controls?: string;
217
264
  /** The body. Not rendered in trigger-only mode ({@link DisclosureProps.controls}). */
@@ -247,6 +294,7 @@ export function Disclosure({
247
294
  triggerProps,
248
295
  bodyClassName,
249
296
  trailing,
297
+ trailingInTrigger = false,
250
298
  controls,
251
299
  className,
252
300
  children,
@@ -259,11 +307,17 @@ export function Disclosure({
259
307
  const menu = variant === "menu";
260
308
  // The card's chevron trails always; a menu row's unless asked otherwise; a bare
261
309
  // one only when asked to.
262
- const chevronAtEnd = card || (chevronPosition ?? (menu ? "end" : "start")) === "end";
310
+ const chevronAt = card ? "end" : (chevronPosition ?? (menu ? "end" : "start"));
311
+ const chevronAtEnd = chevronAt === "end";
312
+ const chevronAfterTitle = chevronAt === "after-title";
263
313
  const triggerOnly = controls !== undefined;
264
314
  // The card's header squares its lower corners only when a body opens under it.
265
315
  const joined = open && !triggerOnly;
266
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;
267
321
 
268
322
  const toggle = () => {
269
323
  const next = !open;
@@ -278,13 +332,14 @@ export function Disclosure({
278
332
  {...extraTriggerProps}
279
333
  type="button"
280
334
  aria-expanded={open}
281
- 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}
282
337
  disabled={disabled}
283
338
  onClick={toggle}
284
339
  data-disclosure-trigger=""
285
340
  className={cn(
286
341
  "flex w-full gap-2 text-start outline-none disabled:cursor-not-allowed disabled:opacity-50",
287
- hasTrailing
342
+ trailingBeside
288
343
  ? // Stretched over the whole header row (the row is `relative`), so the space
289
344
  // round `trailing` and the card's chevron still toggle; the ring and the
290
345
  // card's hover paint on the stretched area / the row, not the text box.
@@ -297,7 +352,7 @@ export function Disclosure({
297
352
  card
298
353
  ? cn(
299
354
  "items-center justify-between rounded-lg p-4",
300
- 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",
301
356
  joined && "rounded-b-none",
302
357
  )
303
358
  : menu
@@ -309,29 +364,46 @@ export function Disclosure({
309
364
  headerClassName,
310
365
  )}
311
366
  >
312
- {!chevronAtEnd && <Chevron open={open} leading />}
367
+ {chevronAt === "start" && <Chevron open={open} leading />}
313
368
  {/* `flex-1`: the title takes the row, so whatever a caller puts at its end sits
314
369
  at the header's far edge. */}
315
370
  <span className="min-w-0 flex-1">
316
- <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
+ )}
317
381
  {hint !== undefined && (
318
382
  <span className="mt-0.5 block text-xs font-normal text-[var(--text-muted)]">{hint}</span>
319
383
  )}
320
384
  </span>
321
- {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} />}
322
394
  </button>
323
395
  );
324
396
 
325
397
  // Preflight already makes h1–h6 inherit size and weight, so the heading adds
326
398
  // structure and nothing visible.
327
- 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;
328
400
 
329
401
  return (
330
402
  <div
331
403
  {...rest}
332
404
  className={cn(card && "rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm", className)}
333
405
  >
334
- {hasTrailing ? (
406
+ {trailingBeside ? (
335
407
  <div
336
408
  className={cn(
337
409
  "relative flex items-center gap-2",
@@ -0,0 +1,137 @@
1
+ /**
2
+ * A field group with its label ABOVE the control: label, control, hint, error.
3
+ *
4
+ * ```tsx
5
+ * <Field label="IBAN" required hint="22 characters" error={errors.iban}>
6
+ * {(ids) => <Input {...ids} value={iban} onChange={…} />}
7
+ * </Field>
8
+ * ```
9
+ *
10
+ * The kit's `Input` and `Select` float their label inside the field, and the pickers
11
+ * draw theirs the same way. That is the wrong shape for a control the kit did not
12
+ * render, a radio group, a range slider, or a form that sets its labels above the
13
+ * fields throughout — which is what kastlan's wizards are, and why it kept its own
14
+ * `WizardField` after the kit dropped the one it had never rendered. This is that
15
+ * group, with the wiring the old one left to the caller: the label names the control
16
+ * through `htmlFor`, and the control is described by the hint and the error that are
17
+ * actually on screen.
18
+ *
19
+ * ## Wiring
20
+ *
21
+ * Pass a function as `children` and it receives {@link FieldControlProps} — `id`,
22
+ * `aria-describedby`, `aria-invalid` and `aria-required` — to spread on the control.
23
+ * Plain children render as they are, for a control that wires itself (a checkbox that
24
+ * carries its own label, a group with `aria-labelledby`); `htmlFor` then points the
25
+ * label at the control's own id, and the hint and error are on screen but describe
26
+ * nothing — the same as the `WizardField` this replaces.
27
+ *
28
+ * ## Decoupled
29
+ *
30
+ * Deliberately knows neither the wizard nor react-hook-form: `error` is a plain
31
+ * value. Pass `wizard.fieldErrors.iban`, a step-local error, or
32
+ * `fieldState.error?.message` from a `FormField` render — the markup is the same
33
+ * whichever of them gates the step, so a step can move between them without its
34
+ * fields changing. (Inside a react-hook-form form, `FormItem` + `FormLabel` from
35
+ * `@eifi1/ui-kit/rhf` do the same job with the ids taken from the form.)
36
+ *
37
+ * The error is NOT `role="alert"`, for the reason the kit's `error` prop gives (see
38
+ * `useFieldError` in components/ui.tsx): it is read when focus reaches the control.
39
+ */
40
+ import { useId, type ReactNode } from "react";
41
+ import { cn } from "../lib/cn";
42
+ import { Label } from "./ui";
43
+
44
+ /** What a `Field` hands its render-prop children, to spread on the control. */
45
+ export interface FieldControlProps {
46
+ /** The id the label's `htmlFor` points at. */
47
+ id: string;
48
+ /** The ids of the hint and the error that are rendered, or `undefined` when
49
+ * neither is. */
50
+ "aria-describedby": string | undefined;
51
+ /** `true` while the field has an error, `undefined` otherwise. */
52
+ "aria-invalid": true | undefined;
53
+ /** `true` when the field is `required`. The label's star is `aria-hidden`; this
54
+ * is what a screen reader hears instead. */
55
+ "aria-required": true | undefined;
56
+ }
57
+
58
+ export interface FieldProps {
59
+ /** The label above the control. Omit it for a group whose control is labelled
60
+ * some other way; the hint and error still render. */
61
+ label?: ReactNode;
62
+ /** Draws the kit's required mark after the label, and sets `aria-required` on the
63
+ * render-prop control. */
64
+ required?: boolean;
65
+ /** Muted helper text below the control. */
66
+ hint?: ReactNode;
67
+ /** The error below the control. `null`, `false` and `""` are no error — what a
68
+ * `touched && errors.x` evaluates to on the happy path. */
69
+ error?: ReactNode;
70
+ /** The control's id. Defaults to a generated one; set it when the control carries
71
+ * its own id (plain children). */
72
+ htmlFor?: string;
73
+ /** Dims the label with its control. */
74
+ disabled?: boolean;
75
+ /** The label's size: `sm` for a dense row, `md` otherwise. */
76
+ labelSize?: "sm" | "md";
77
+ /** Wrapper className. */
78
+ className?: string;
79
+ /** The control. A function receives {@link FieldControlProps} to spread on it. */
80
+ children: ReactNode | ((control: FieldControlProps) => ReactNode);
81
+ }
82
+
83
+ const isShown = (node: ReactNode) =>
84
+ node !== undefined && node !== null && node !== false && node !== "";
85
+
86
+ export function Field({
87
+ label,
88
+ required,
89
+ hint,
90
+ error,
91
+ htmlFor,
92
+ disabled,
93
+ labelSize,
94
+ className,
95
+ children,
96
+ }: FieldProps) {
97
+ const generated = useId();
98
+ const id = htmlFor ?? `${generated}-control`;
99
+ const hintId = `${generated}-hint`;
100
+ const errorId = `${generated}-error`;
101
+ const hasHint = isShown(hint);
102
+ const hasError = isShown(error);
103
+ const describedBy = [hasHint && hintId, hasError && errorId].filter(Boolean).join(" ");
104
+ const control: FieldControlProps = {
105
+ id,
106
+ "aria-describedby": describedBy || undefined,
107
+ "aria-invalid": hasError || undefined,
108
+ "aria-required": required || undefined,
109
+ };
110
+ return (
111
+ <div data-slot="field" className={cn("grid gap-1.5", className)}>
112
+ {label !== undefined && (
113
+ <Label
114
+ htmlFor={id}
115
+ required={required}
116
+ disabled={disabled}
117
+ size={labelSize}
118
+ data-error={hasError || undefined}
119
+ className="data-[error=true]:text-[var(--danger)]"
120
+ >
121
+ {label}
122
+ </Label>
123
+ )}
124
+ {typeof children === "function" ? children(control) : children}
125
+ {hasHint && (
126
+ <p id={hintId} className="text-[11px] leading-tight text-[var(--text-muted)]">
127
+ {hint}
128
+ </p>
129
+ )}
130
+ {hasError && (
131
+ <p id={errorId} className="text-[11px] leading-tight text-[var(--danger)]">
132
+ {error}
133
+ </p>
134
+ )}
135
+ </div>
136
+ );
137
+ }