@eifi1/ui-kit 0.6.0 → 0.7.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 (271) hide show
  1. package/README.md +69 -33
  2. package/dist/chart.d.ts +1 -0
  3. package/dist/components/account-settings.js +3 -2
  4. package/dist/components/account-settings.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +3 -0
  6. package/dist/components/amount-input.js +6 -4
  7. package/dist/components/amount-input.js.map +1 -1
  8. package/dist/components/autocomplete.d.ts +36 -7
  9. package/dist/components/autocomplete.js +68 -22
  10. package/dist/components/autocomplete.js.map +1 -1
  11. package/dist/components/calculator.d.ts +3 -0
  12. package/dist/components/calculator.js +2 -1
  13. package/dist/components/calculator.js.map +1 -1
  14. package/dist/components/chart-zoom.js +2 -1
  15. package/dist/components/chart-zoom.js.map +1 -1
  16. package/dist/components/chart.d.ts +33 -2
  17. package/dist/components/chart.js +20 -8
  18. package/dist/components/chart.js.map +1 -1
  19. package/dist/components/checkbox.d.ts +9 -0
  20. package/dist/components/checkbox.js +7 -2
  21. package/dist/components/checkbox.js.map +1 -1
  22. package/dist/components/chip.d.ts +34 -14
  23. package/dist/components/chip.js +63 -28
  24. package/dist/components/chip.js.map +1 -1
  25. package/dist/components/choice-card.d.ts +18 -1
  26. package/dist/components/choice-card.js +21 -8
  27. package/dist/components/choice-card.js.map +1 -1
  28. package/dist/components/combobox-core.d.ts +61 -2
  29. package/dist/components/combobox-core.js +68 -17
  30. package/dist/components/combobox-core.js.map +1 -1
  31. package/dist/components/combobox.d.ts +11 -7
  32. package/dist/components/combobox.js +51 -28
  33. package/dist/components/combobox.js.map +1 -1
  34. package/dist/components/currency-select.js +3 -3
  35. package/dist/components/currency-select.js.map +1 -1
  36. package/dist/components/danger-confirm.d.ts +30 -6
  37. package/dist/components/danger-confirm.js +51 -32
  38. package/dist/components/danger-confirm.js.map +1 -1
  39. package/dist/components/data-table-filter-popover.d.ts +1 -1
  40. package/dist/components/data-table-filters.d.ts +1 -1
  41. package/dist/components/data-table-pagination.d.ts +6 -3
  42. package/dist/components/data-table-pagination.js +6 -5
  43. package/dist/components/data-table-pagination.js.map +1 -1
  44. package/dist/components/data-table.d.ts +1 -1
  45. package/dist/components/data-table.js +332 -248
  46. package/dist/components/data-table.js.map +1 -1
  47. package/dist/components/date-picker.js +17 -7
  48. package/dist/components/date-picker.js.map +1 -1
  49. package/dist/components/dialog-frame.d.ts +28 -3
  50. package/dist/components/dialog-frame.js +47 -11
  51. package/dist/components/dialog-frame.js.map +1 -1
  52. package/dist/components/disclosure.d.ts +22 -2
  53. package/dist/components/disclosure.js +41 -8
  54. package/dist/components/disclosure.js.map +1 -1
  55. package/dist/components/dropdown.d.ts +7 -3
  56. package/dist/components/dropdown.js +6 -2
  57. package/dist/components/dropdown.js.map +1 -1
  58. package/dist/components/entity-combobox.d.ts +17 -8
  59. package/dist/components/entity-combobox.js +7 -5
  60. package/dist/components/entity-combobox.js.map +1 -1
  61. package/dist/components/file-button.d.ts +36 -5
  62. package/dist/components/file-button.js +42 -3
  63. package/dist/components/file-button.js.map +1 -1
  64. package/dist/components/file-dropzone.d.ts +9 -5
  65. package/dist/components/file-dropzone.js +14 -19
  66. package/dist/components/file-dropzone.js.map +1 -1
  67. package/dist/components/grouped-picker.js +2 -2
  68. package/dist/components/grouped-picker.js.map +1 -1
  69. package/dist/components/hover-menu.d.ts +8 -3
  70. package/dist/components/hover-menu.js +88 -4
  71. package/dist/components/hover-menu.js.map +1 -1
  72. package/dist/components/icon-picker.d.ts +10 -1
  73. package/dist/components/icon-picker.js +5 -1
  74. package/dist/components/icon-picker.js.map +1 -1
  75. package/dist/components/measured-grid.d.ts +165 -0
  76. package/dist/components/measured-grid.js +559 -0
  77. package/dist/components/measured-grid.js.map +1 -0
  78. package/dist/components/mini-calendar.js +7 -8
  79. package/dist/components/mini-calendar.js.map +1 -1
  80. package/dist/components/month-picker.js +13 -9
  81. package/dist/components/month-picker.js.map +1 -1
  82. package/dist/components/multi-entity-combobox.js +2 -2
  83. package/dist/components/multi-entity-combobox.js.map +1 -1
  84. package/dist/components/multi-select.d.ts +9 -1
  85. package/dist/components/multi-select.js +22 -8
  86. package/dist/components/multi-select.js.map +1 -1
  87. package/dist/components/number-field.d.ts +3 -0
  88. package/dist/components/number-input.d.ts +3 -0
  89. package/dist/components/number-input.js +3 -3
  90. package/dist/components/number-input.js.map +1 -1
  91. package/dist/components/numpad-sheet.d.ts +3 -0
  92. package/dist/components/numpad-sheet.js +3 -3
  93. package/dist/components/numpad-sheet.js.map +1 -1
  94. package/dist/components/picker-sheet.js +1 -1
  95. package/dist/components/picker-sheet.js.map +1 -1
  96. package/dist/components/popover.js +11 -2
  97. package/dist/components/popover.js.map +1 -1
  98. package/dist/components/series-chart-ticks.d.ts +13 -0
  99. package/dist/components/series-chart-ticks.js +29 -0
  100. package/dist/components/series-chart-ticks.js.map +1 -0
  101. package/dist/components/series-chart.js +33 -27
  102. package/dist/components/series-chart.js.map +1 -1
  103. package/dist/components/swatch-picker.d.ts +10 -1
  104. package/dist/components/swatch-picker.js +2 -0
  105. package/dist/components/swatch-picker.js.map +1 -1
  106. package/dist/components/swipeable-row.d.ts +11 -2
  107. package/dist/components/swipeable-row.js +5 -3
  108. package/dist/components/swipeable-row.js.map +1 -1
  109. package/dist/components/switch.d.ts +9 -0
  110. package/dist/components/switch.js +7 -2
  111. package/dist/components/switch.js.map +1 -1
  112. package/dist/components/tile-radio.js +5 -7
  113. package/dist/components/tile-radio.js.map +1 -1
  114. package/dist/components/toggle-group.d.ts +13 -3
  115. package/dist/components/toggle-group.js +21 -1
  116. package/dist/components/toggle-group.js.map +1 -1
  117. package/dist/components/toggle-legend.js +1 -2
  118. package/dist/components/toggle-legend.js.map +1 -1
  119. package/dist/components/treemap.d.ts +6 -1
  120. package/dist/components/treemap.js +16 -6
  121. package/dist/components/treemap.js.map +1 -1
  122. package/dist/components/ui.d.ts +31 -7
  123. package/dist/components/ui.js +26 -18
  124. package/dist/components/ui.js.map +1 -1
  125. package/dist/components/use-anchor-dir.d.ts +15 -0
  126. package/dist/components/use-anchor-dir.js +14 -0
  127. package/dist/components/use-anchor-dir.js.map +1 -0
  128. package/dist/components/use-mobile-reveal.d.ts +8 -1
  129. package/dist/components/use-mobile-reveal.js +3 -2
  130. package/dist/components/use-mobile-reveal.js.map +1 -1
  131. package/dist/components/use-table-state.d.ts +4 -4
  132. package/dist/components/use-table-state.js +12 -5
  133. package/dist/components/use-table-state.js.map +1 -1
  134. package/dist/{data-table-filters-DM1auUfH.d.ts → data-table-filters-noy0Abvi.d.ts} +40 -7
  135. package/dist/data-table.d.ts +1 -1
  136. package/dist/data-table.js.map +1 -1
  137. package/dist/feedback/feedback-attachment.d.ts +22 -3
  138. package/dist/feedback/feedback-attachment.js +15 -7
  139. package/dist/feedback/feedback-attachment.js.map +1 -1
  140. package/dist/feedback/feedback-dialog.d.ts +3 -2
  141. package/dist/feedback/feedback-dialog.js.map +1 -1
  142. package/dist/feedback.d.ts +1 -1
  143. package/dist/hooks/use-windowed-rows.d.ts +29 -0
  144. package/dist/hooks/use-windowed-rows.js +31 -0
  145. package/dist/hooks/use-windowed-rows.js.map +1 -0
  146. package/dist/i18n/defaults.d.ts +3 -0
  147. package/dist/i18n/defaults.js +5 -1
  148. package/dist/i18n/defaults.js.map +1 -1
  149. package/dist/i18n/kit-labels.d.ts +5 -0
  150. package/dist/i18n/kit-labels.js.map +1 -1
  151. package/dist/i18n/locales/de-CH.d.ts +43 -0
  152. package/dist/i18n/locales/de-CH.js +21 -0
  153. package/dist/i18n/locales/de-CH.js.map +1 -0
  154. package/dist/i18n/locales/de.d.ts +43 -0
  155. package/dist/i18n/locales/de.js +344 -0
  156. package/dist/i18n/locales/de.js.map +1 -0
  157. package/dist/i18n/locales/es.d.ts +43 -0
  158. package/dist/i18n/locales/es.js +339 -0
  159. package/dist/i18n/locales/es.js.map +1 -0
  160. package/dist/i18n/locales/fr.d.ts +43 -0
  161. package/dist/i18n/locales/fr.js +344 -0
  162. package/dist/i18n/locales/fr.js.map +1 -0
  163. package/dist/i18n/locales/hu.d.ts +43 -0
  164. package/dist/i18n/locales/hu.js +343 -0
  165. package/dist/i18n/locales/hu.js.map +1 -0
  166. package/dist/i18n/locales/it.d.ts +43 -0
  167. package/dist/i18n/locales/it.js +342 -0
  168. package/dist/i18n/locales/it.js.map +1 -0
  169. package/dist/i18n/locales/zh.d.ts +43 -0
  170. package/dist/i18n/locales/zh.js +338 -0
  171. package/dist/i18n/locales/zh.js.map +1 -0
  172. package/dist/index.d.ts +6 -3
  173. package/dist/index.js +4 -1
  174. package/dist/index.js.map +1 -1
  175. package/dist/lib/direction.d.ts +24 -0
  176. package/dist/lib/direction.js +19 -0
  177. package/dist/lib/direction.js.map +1 -0
  178. package/dist/rhf/form.js.map +1 -1
  179. package/dist/search/command-palette.d.ts +5 -0
  180. package/dist/search/command-palette.js +80 -50
  181. package/dist/search/command-palette.js.map +1 -1
  182. package/dist/shell/app-shell.d.ts +13 -1
  183. package/dist/shell/app-shell.js +133 -84
  184. package/dist/shell/app-shell.js.map +1 -1
  185. package/dist/shell/topbar-action-menu.js +1 -1
  186. package/dist/shell/topbar-action-menu.js.map +1 -1
  187. package/dist/shell/topbar-controls.d.ts +7 -3
  188. package/dist/shell/topbar-controls.js +38 -31
  189. package/dist/shell/topbar-controls.js.map +1 -1
  190. package/dist/theme/chart-palette.d.ts +7 -1
  191. package/dist/theme/chart-palette.js +2 -1
  192. package/dist/theme/chart-palette.js.map +1 -1
  193. package/dist/tour/tour.d.ts +11 -5
  194. package/dist/tour/tour.js +124 -58
  195. package/dist/tour/tour.js.map +1 -1
  196. package/dist/wizard/use-wizard.js +30 -13
  197. package/dist/wizard/use-wizard.js.map +1 -1
  198. package/dist/wizard/wizard-summary.js +1 -1
  199. package/dist/wizard/wizard-summary.js.map +1 -1
  200. package/package.json +12 -2
  201. package/src/components/account-settings.tsx +6 -2
  202. package/src/components/amount-input.tsx +10 -8
  203. package/src/components/autocomplete.tsx +123 -33
  204. package/src/components/calculator.tsx +5 -1
  205. package/src/components/chart-zoom.tsx +8 -1
  206. package/src/components/chart.tsx +68 -7
  207. package/src/components/checkbox.tsx +16 -0
  208. package/src/components/chip.tsx +131 -49
  209. package/src/components/choice-card.tsx +77 -36
  210. package/src/components/combobox-core.tsx +147 -20
  211. package/src/components/combobox.tsx +100 -44
  212. package/src/components/currency-select.tsx +6 -6
  213. package/src/components/danger-confirm.tsx +90 -36
  214. package/src/components/data-table-pagination.tsx +32 -19
  215. package/src/components/data-table.tsx +198 -36
  216. package/src/components/date-picker.tsx +24 -7
  217. package/src/components/dialog-frame.tsx +97 -22
  218. package/src/components/disclosure.tsx +75 -13
  219. package/src/components/dropdown.tsx +17 -5
  220. package/src/components/entity-combobox.tsx +26 -13
  221. package/src/components/file-button.tsx +102 -6
  222. package/src/components/file-dropzone.tsx +44 -35
  223. package/src/components/grouped-picker.tsx +2 -2
  224. package/src/components/hover-menu.tsx +139 -10
  225. package/src/components/icon-picker.tsx +14 -1
  226. package/src/components/measured-grid.tsx +852 -0
  227. package/src/components/mini-calendar.tsx +11 -8
  228. package/src/components/month-picker.tsx +18 -11
  229. package/src/components/multi-entity-combobox.tsx +2 -2
  230. package/src/components/multi-select.tsx +34 -7
  231. package/src/components/number-input.tsx +4 -4
  232. package/src/components/numpad-sheet.tsx +5 -3
  233. package/src/components/picker-sheet.tsx +1 -1
  234. package/src/components/popover.tsx +18 -3
  235. package/src/components/series-chart-ticks.ts +52 -0
  236. package/src/components/series-chart.tsx +39 -31
  237. package/src/components/swatch-picker.tsx +11 -0
  238. package/src/components/swipeable-row.tsx +21 -6
  239. package/src/components/switch.tsx +16 -0
  240. package/src/components/tile-radio.tsx +5 -7
  241. package/src/components/toggle-group.tsx +39 -3
  242. package/src/components/toggle-legend.tsx +5 -2
  243. package/src/components/treemap.tsx +28 -5
  244. package/src/components/ui.tsx +74 -30
  245. package/src/components/use-anchor-dir.ts +20 -0
  246. package/src/components/use-mobile-reveal.ts +10 -2
  247. package/src/components/use-table-state.ts +44 -7
  248. package/src/data-table.ts +1 -0
  249. package/src/feedback/feedback-attachment.tsx +36 -9
  250. package/src/feedback/feedback-dialog.tsx +3 -2
  251. package/src/hooks/use-windowed-rows.ts +69 -0
  252. package/src/i18n/defaults.ts +4 -0
  253. package/src/i18n/kit-labels.tsx +4 -0
  254. package/src/i18n/locales/de-CH.ts +30 -0
  255. package/src/i18n/locales/de.ts +365 -0
  256. package/src/i18n/locales/es.ts +358 -0
  257. package/src/i18n/locales/fr.ts +371 -0
  258. package/src/i18n/locales/hu.ts +359 -0
  259. package/src/i18n/locales/it.ts +360 -0
  260. package/src/i18n/locales/zh.ts +348 -0
  261. package/src/index.ts +10 -1
  262. package/src/lib/direction.ts +37 -0
  263. package/src/rhf/form.tsx +21 -0
  264. package/src/search/command-palette.tsx +77 -19
  265. package/src/shell/app-shell.tsx +200 -129
  266. package/src/shell/topbar-action-menu.tsx +1 -1
  267. package/src/shell/topbar-controls.tsx +19 -8
  268. package/src/theme/chart-palette.ts +9 -2
  269. package/src/tour/tour.tsx +119 -18
  270. package/src/wizard/use-wizard.ts +55 -17
  271. package/src/wizard/wizard-summary.tsx +1 -1
@@ -42,6 +42,15 @@ export interface DialogFrameProps extends Omit<ModalProps, "labelledBy" | "child
42
42
  * calling `onClose` directly unmounts it at once.
43
43
  */
44
44
  actions?: ReactNode | ((close: () => void) => ReactNode);
45
+ /**
46
+ * Controls that belong to the HEADER rather than to the actions row — an "Edit"
47
+ * toggle, a status badge with a menu, a "Copy link" (kastlan). Rendered beside the
48
+ * title, before the optional X; on a phone, where title and controls do not fit on
49
+ * one line, they wrap under the title and the X keeps its corner.
50
+ *
51
+ * A function receives the animated close, as `actions` does.
52
+ */
53
+ headerActions?: ReactNode | ((close: () => void) => ReactNode);
45
54
  /**
46
55
  * Show an X in the header. Off by default: a centred dialog has a backdrop and
47
56
  * Escape, and a form dialog has a Cancel. On for a dialog that commits as it goes
@@ -52,8 +61,24 @@ export interface DialogFrameProps extends Omit<ModalProps, "labelledBy" | "child
52
61
  closeLabel?: string;
53
62
  /** Extra classes for the scrolling body (default spacing `space-y-3`). */
54
63
  bodyClassName?: string;
55
- /** The body. */
56
- children: ReactNode;
64
+ /**
65
+ * Extra classes for the header row (default padding `px-4 pt-4 pb-3`), merged last —
66
+ * `px-3` for a phone sheet whose body runs at `px-3`, so heading and fields share
67
+ * one gutter.
68
+ */
69
+ headerClassName?: string;
70
+ /**
71
+ * A rule under the header, the twin of the one over the actions row — for a tall or
72
+ * full-screen body that scrolls under a header which stays. The body then starts a
73
+ * step below the rule instead of flush against it.
74
+ */
75
+ headerDivider?: boolean;
76
+ /**
77
+ * The body. Optional: a dialog whose title, description and actions are the whole
78
+ * of it (a "you have unsynced changes" question) leaves it out, and the frame then
79
+ * renders no body at all rather than an empty padded one.
80
+ */
81
+ children?: ReactNode;
57
82
  }
58
83
 
59
84
  /**
@@ -92,9 +117,12 @@ export function DialogFrame({
92
117
  description,
93
118
  headingAs: Heading = "h2",
94
119
  actions,
120
+ headerActions,
95
121
  closeButton = false,
96
122
  closeLabel,
97
123
  bodyClassName,
124
+ headerClassName,
125
+ headerDivider = false,
98
126
  className,
99
127
  children,
100
128
  "aria-describedby": describedBy,
@@ -103,6 +131,8 @@ export function DialogFrame({
103
131
  const titleId = useId();
104
132
  const descriptionId = useId();
105
133
  const hasDescription = description !== undefined && description !== null;
134
+ // `false` too, so `{cond && <Body/>}` with a false `cond` means "no body".
135
+ const hasBody = children !== undefined && children !== null && children !== false;
106
136
 
107
137
  return (
108
138
  <Modal
@@ -116,30 +146,50 @@ export function DialogFrame({
116
146
  // to the body, which is the only part that should move.
117
147
  className={cn("flex flex-col overflow-hidden p-0", className)}
118
148
  >
119
- <div className="flex shrink-0 items-start justify-between gap-2 px-4 pt-4 pb-3">
120
- <div className="min-w-0">
121
- <Heading id={titleId} className="text-lg font-semibold leading-snug text-[var(--text-primary)]">
122
- {title}
123
- </Heading>
124
- {hasDescription && (
125
- <p id={descriptionId} className="mt-0.5 text-sm text-[var(--text-muted)]">
126
- {description}
127
- </p>
128
- )}
129
- </div>
130
- {closeButton && <FrameClose label={closeLabel} onClose={modal.onClose} />}
131
- </div>
132
149
  <div
133
150
  className={cn(
134
- // `min-h-0` is what lets a flex child shrink below its content and scroll;
135
- // `last:pb-4` closes a frame that has no actions row under it.
136
- "min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-4 pb-3 last:pb-4",
137
- bodyClassName,
151
+ // `last:pb-4`: a frame with neither body nor actions closes on its header.
152
+ "flex shrink-0 items-start justify-between gap-2 px-4 pt-4 pb-3 last:pb-4",
153
+ headerDivider && "border-b border-[var(--border)]",
154
+ headerClassName,
138
155
  )}
139
156
  >
140
- {children}
157
+ {/* Title and header actions share a wrapping row of their own, so it is THEY
158
+ that wrap on a phone — the actions drop under a title that needs its 12rem —
159
+ while the X stays outside it, pinned to the top-end corner. */}
160
+ <div className="flex min-w-0 flex-1 flex-wrap items-start justify-between gap-x-3 gap-y-2">
161
+ <div className="min-w-0 grow basis-48">
162
+ <Heading id={titleId} className="text-lg font-semibold leading-snug text-[var(--text-primary)]">
163
+ {title}
164
+ </Heading>
165
+ {hasDescription && (
166
+ <p id={descriptionId} className="mt-0.5 text-sm text-[var(--text-muted)]">
167
+ {description}
168
+ </p>
169
+ )}
170
+ </div>
171
+ {headerActions !== undefined && headerActions !== null && headerActions !== false && (
172
+ <FrameHeaderActions actions={headerActions} onClose={modal.onClose} />
173
+ )}
174
+ </div>
175
+ {closeButton && <FrameClose label={closeLabel} onClose={modal.onClose} />}
141
176
  </div>
142
- {actions !== undefined && actions !== null && <FrameActions actions={actions} onClose={modal.onClose} />}
177
+ {hasBody && (
178
+ <div
179
+ className={cn(
180
+ // `min-h-0` is what lets a flex child shrink below its content and scroll;
181
+ // `last:pb-4` closes a frame that has no actions row under it.
182
+ "min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-4 pb-3 last:pb-4",
183
+ headerDivider && "pt-3",
184
+ bodyClassName,
185
+ )}
186
+ >
187
+ {children}
188
+ </div>
189
+ )}
190
+ {actions !== undefined && actions !== null && (
191
+ <FrameActions actions={actions} onClose={modal.onClose} divider={hasBody} />
192
+ )}
143
193
  </Modal>
144
194
  );
145
195
  }
@@ -161,18 +211,43 @@ function FrameClose({ label, onClose }: { label?: string; onClose: () => void })
161
211
  );
162
212
  }
163
213
 
214
+ /** The header's own controls; a component for the same reason as {@link FrameClose}. */
215
+ function FrameHeaderActions({
216
+ actions,
217
+ onClose,
218
+ }: {
219
+ actions: NonNullable<DialogFrameProps["headerActions"]>;
220
+ onClose: () => void;
221
+ }) {
222
+ const close = useContext(ModalCloseContext) ?? onClose;
223
+ return (
224
+ <div data-dialog-header-actions="" className="flex shrink-0 flex-wrap items-center gap-2">
225
+ {typeof actions === "function" ? actions(close) : actions}
226
+ </div>
227
+ );
228
+ }
229
+
164
230
  function FrameActions({
165
231
  actions,
166
232
  onClose,
233
+ divider,
167
234
  }: {
168
235
  actions: NonNullable<DialogFrameProps["actions"]>;
169
236
  onClose: () => void;
237
+ /** Off when there is no body: nothing scrolls, so there is no edge to mark, and a
238
+ * rule straight under the description would cut the question from its answers. */
239
+ divider: boolean;
170
240
  }) {
171
241
  const close = useContext(ModalCloseContext) ?? onClose;
172
242
  return (
173
243
  // The border marks where the scrolling stops; `flex-wrap` keeps three long
174
244
  // translated labels on a phone from pushing the row wider than the sheet.
175
- <div className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-[var(--border)] px-4 py-3">
245
+ <div
246
+ className={cn(
247
+ "flex shrink-0 flex-wrap items-center justify-end gap-2 px-4 py-3",
248
+ divider && "border-t border-[var(--border)]",
249
+ )}
250
+ >
176
251
  {typeof actions === "function" ? actions(close) : actions}
177
252
  </div>
178
253
  );
@@ -149,7 +149,27 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
149
149
  headerClassName?: string;
150
150
  /** Extra classes for the body's wrapper — where its padding and spacing live. */
151
151
  bodyClassName?: string;
152
- children: ReactNode;
152
+ /**
153
+ * Content at the far end of the header row, beside the title: a count, a date, a
154
+ * status chip, an action button. It is a SIBLING of the header button, never inside
155
+ * it — a button may not contain another interactive element, and a count inside it
156
+ * would also be read as part of the button's name. The header button still spans
157
+ * the row under it (a stretched hit area), so clicking the empty space or the
158
+ * card's chevron still toggles, while whatever sits in `trailing` gets its own
159
+ * clicks. With `trailing`, the card's chevron moves after it, to the row's end.
160
+ */
161
+ trailing?: ReactNode;
162
+ /**
163
+ * Trigger-only mode: the id of an element the CALLER renders elsewhere — the hidden
164
+ * rows of a table, a panel in another column — which this header shows and hides.
165
+ * The header's `aria-controls` points at it and the disclosure renders no body of
166
+ * its own (`children`, `bodyClassName` and `keepMounted` are ignored). Pair it with
167
+ * `open` / `onOpenChange`: the caller owns the state, since the caller renders what
168
+ * it governs.
169
+ */
170
+ controls?: string;
171
+ /** The body. Not rendered in trigger-only mode ({@link DisclosureProps.controls}). */
172
+ children?: ReactNode;
153
173
  }
154
174
 
155
175
  /**
@@ -178,6 +198,8 @@ export function Disclosure({
178
198
  disabled,
179
199
  headerClassName,
180
200
  bodyClassName,
201
+ trailing,
202
+ controls,
181
203
  className,
182
204
  children,
183
205
  ...rest
@@ -186,6 +208,10 @@ export function Disclosure({
186
208
  const open = controlled ?? own;
187
209
  const bodyId = useId();
188
210
  const card = variant === "card";
211
+ const triggerOnly = controls !== undefined;
212
+ // The card's header squares its lower corners only when a body opens under it.
213
+ const joined = open && !triggerOnly;
214
+ const hasTrailing = trailing !== undefined && trailing !== null && trailing !== false;
189
215
 
190
216
  const toggle = () => {
191
217
  const next = !open;
@@ -197,43 +223,79 @@ export function Disclosure({
197
223
  <button
198
224
  type="button"
199
225
  aria-expanded={open}
200
- aria-controls={bodyId}
226
+ aria-controls={triggerOnly ? controls : bodyId}
201
227
  disabled={disabled}
202
228
  onClick={toggle}
229
+ data-disclosure-trigger=""
203
230
  className={cn(
204
231
  "flex w-full gap-2 text-start outline-none disabled:cursor-not-allowed disabled:opacity-50",
205
- "focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
232
+ hasTrailing
233
+ ? // Stretched over the whole header row (the row is `relative`), so the space
234
+ // round `trailing` and the card's chevron still toggle; the ring and the
235
+ // card's hover paint on the stretched area / the row, not the text box.
236
+ cn(
237
+ "min-w-0 flex-1 after:absolute after:inset-0 after:content-['']",
238
+ "focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-[var(--brand)]",
239
+ card ? cn("after:rounded-lg", joined && "after:rounded-b-none") : "after:rounded-sm",
240
+ )
241
+ : "focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
206
242
  card
207
243
  ? cn(
208
- "items-center justify-between rounded-lg p-4 hover:bg-[var(--bg-hover)] focus-visible:ring-inset",
209
- open && "rounded-b-none",
244
+ "items-center justify-between rounded-lg p-4",
245
+ hasTrailing ? "pe-0" : "hover:bg-[var(--bg-hover)] focus-visible:ring-inset",
246
+ joined && "rounded-b-none",
210
247
  )
211
248
  : "items-center rounded-sm text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
212
249
  headerClassName,
213
250
  )}
214
251
  >
215
252
  {!card && <Chevron open={open} leading />}
216
- <span className="min-w-0">
253
+ {/* `flex-1`: the title takes the row, so whatever a caller puts at its end sits
254
+ at the header's far edge. */}
255
+ <span className="min-w-0 flex-1">
217
256
  <span className={cn("block", card && "text-sm font-semibold text-[var(--text-primary)]")}>{title}</span>
218
257
  {hint !== undefined && (
219
258
  <span className="mt-0.5 block text-xs font-normal text-[var(--text-muted)]">{hint}</span>
220
259
  )}
221
260
  </span>
222
- {card && <Chevron open={open} />}
261
+ {card && !hasTrailing && <Chevron open={open} />}
223
262
  </button>
224
263
  );
225
264
 
265
+ // Preflight already makes h1–h6 inherit size and weight, so the heading adds
266
+ // structure and nothing visible.
267
+ const header = Heading ? <Heading className={hasTrailing ? "min-w-0 flex-1" : undefined}>{button}</Heading> : button;
268
+
226
269
  return (
227
270
  <div
228
271
  {...rest}
229
272
  className={cn(card && "rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm", className)}
230
273
  >
231
- {/* Preflight already makes h1–h6 inherit size and weight, so the heading adds
232
- structure and nothing visible. */}
233
- {Heading ? <Heading>{button}</Heading> : button}
234
- <Collapse id={bodyId} open={open} keepMounted={keepMounted}>
235
- <div className={cn(card ? "space-y-3 px-4 pb-4" : "space-y-2 pt-2", bodyClassName)}>{children}</div>
236
- </Collapse>
274
+ {hasTrailing ? (
275
+ <div
276
+ className={cn(
277
+ "relative flex items-center gap-2",
278
+ card &&
279
+ cn(
280
+ "rounded-lg pe-4 has-[[data-disclosure-trigger]:enabled:hover]:bg-[var(--bg-hover)]",
281
+ joined && "rounded-b-none",
282
+ ),
283
+ )}
284
+ >
285
+ {header}
286
+ {/* Positioned, so it paints over the stretched button and takes its own
287
+ clicks; the chevron is not, so a click on it lands on the button. */}
288
+ <div className="relative flex shrink-0 items-center gap-2">{trailing}</div>
289
+ {card && <Chevron open={open} />}
290
+ </div>
291
+ ) : (
292
+ header
293
+ )}
294
+ {!triggerOnly && (
295
+ <Collapse id={bodyId} open={open} keepMounted={keepMounted}>
296
+ <div className={cn(card ? "space-y-3 px-4 pb-4" : "space-y-2 pt-2", bodyClassName)}>{children}</div>
297
+ </Collapse>
298
+ )}
237
299
  </div>
238
300
  );
239
301
  }
@@ -11,6 +11,7 @@ import { useAnchoredPanel } from "../hooks/use-anchored-panel";
11
11
  import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss";
12
12
  import { useOverlayHistory } from "../hooks/use-overlay-history";
13
13
  import { cn } from "../lib/cn";
14
+ import { useAnchorDir } from "./use-anchor-dir";
14
15
 
15
16
  /**
16
17
  * Open state + close-on-outside-click for the custom dropdowns (MultiSelect,
@@ -225,8 +226,12 @@ export interface DropdownPanelProps extends ComponentPropsWithoutRef<"div"> {
225
226
  panelRef?: RefObject<HTMLDivElement | null>;
226
227
  /** Panel width in px, for the anchored form (the CSS `w-*` cannot be measured). */
227
228
  width?: number;
228
- /** Which of the panel's edges lines up with the trigger's, room permitting. */
229
- align?: "left" | "right";
229
+ /** Which of the panel's edges lines up with the trigger's, room permitting.
230
+ * `start`/`end` follow the trigger's reading direction — `end` is the right edge
231
+ * in a left-to-right form and the left one in a right-to-left form — and are what
232
+ * the kit's own pickers pass; `left`/`right` stay physical. Default `end`, which
233
+ * is the old `right` in a left-to-right page. */
234
+ align?: "left" | "right" | "start" | "end";
230
235
  children: ReactNode;
231
236
  }
232
237
 
@@ -239,7 +244,7 @@ export interface DropdownPanelProps extends ComponentPropsWithoutRef<"div"> {
239
244
  *
240
245
  * Without `anchorRef` the panel is `position: absolute` inside whatever relative box
241
246
  * the caller put it in, and the caller places it with `className` (`w-64`,
242
- * `right-0 top-full`). That is fine in a page that does not scroll around it, and
247
+ * `end-0 top-full`). That is fine in a page that does not scroll around it, and
243
248
  * wrong the moment an ancestor has `overflow` — the panel is then CLIPPED by that
244
249
  * ancestor's box, with no error and no scrollbar, just a list with its side sliced
245
250
  * off. Keksdose dev#548 is what that looks like in practice: the app's own content
@@ -266,12 +271,13 @@ export function DropdownPanel({
266
271
  anchorRef,
267
272
  panelRef,
268
273
  width = 256,
269
- align = "right",
274
+ align = "end",
270
275
  children,
271
276
  style,
272
277
  ...rest
273
278
  }: DropdownPanelProps) {
274
279
  const anchored = useAnchoredPanel(anchorRef ?? EMPTY_REF, Boolean(anchorRef));
280
+ const dir = useAnchorDir(anchorRef ?? EMPTY_REF, Boolean(anchorRef));
275
281
  const body = (
276
282
  <>
277
283
  {header}
@@ -313,12 +319,18 @@ export function DropdownPanel({
313
319
  if (!rect) return null;
314
320
  // Clamped exactly as `Popover` clamps: the panel may leave the trigger's edge to
315
321
  // stay on screen, because a list half off the viewport is the bug this exists for.
316
- const wanted = align === "right" ? rect.right - width : rect.left;
322
+ // The portal leaves the subtree the trigger's `dir` came from, so it is read off
323
+ // the trigger — for the alignment here, and for the panel's own `dir` below,
324
+ // without which a right-to-left form got a left-to-right list.
325
+ const physical =
326
+ align === "start" ? (dir === "rtl" ? "right" : "left") : align === "end" ? (dir === "rtl" ? "left" : "right") : align;
327
+ const wanted = physical === "right" ? rect.right - width : rect.left;
317
328
  const left = Math.min(Math.max(8, wanted), window.innerWidth - width - 8);
318
329
  return createPortal(
319
330
  <div
320
331
  {...rest}
321
332
  ref={panelRef}
333
+ dir={dir}
322
334
  // The caller's `style` is kept underneath, but the placement is measured rather
323
335
  // than chosen (dev#548: an `overflow` ancestor clipped a third of this panel
324
336
  // away), so those five values are the panel's own.
@@ -7,20 +7,30 @@ import {
7
7
  ComboboxPanel,
8
8
  useComboboxCore,
9
9
  useComboboxFieldError,
10
+ type ComboClearValue,
10
11
  type ComboOption,
11
12
  } from "./combobox-core";
12
13
  import { DEFAULT_COMBOBOX_LABELS, DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
13
14
 
14
- export type { ComboOption } from "./combobox-core";
15
+ export type { ComboClearValue, ComboOption } from "./combobox-core";
15
16
 
16
17
  /** `onChange` is the kit's — "a selection was made", carrying values — rather
17
18
  * than the div's form event, so the DOM's spelling of it is omitted. */
18
- export interface EntityComboboxProps<V extends string | number>
19
+ export interface EntityComboboxProps<V extends string | number, C extends ComboClearValue = null>
19
20
  extends Omit<ComponentPropsWithoutRef<"div">, "onChange"> {
20
- /** Selected id, or null/undefined when nothing is selected. */
21
- value: V | null | undefined;
22
- /** Selecting an option emits its value; the clear button emits `null`. */
23
- onChange: (value: V | null) => void;
21
+ /** Selected id; `null`, `undefined` or `clearValue` when nothing is selected. */
22
+ value: V | C | null | undefined;
23
+ /** Selecting an option emits its value; the clear button emits `clearValue`. */
24
+ onChange: (value: V | C) => void;
25
+ /**
26
+ * What the clear button emits. Default `null`. Pass `""` for a form whose schema
27
+ * wants an empty string for "no choice" (a zod `z.string()` field), rather than
28
+ * mapping `null` in every `onChange` — the type of `onChange` follows it, so the
29
+ * mapping cannot be forgotten on one field and not another. The picker also reads
30
+ * the value back as empty, so `value=""` shows the placeholder and no "×".
31
+ * See {@link ComboClearValue} for why the choice is closed.
32
+ */
33
+ clearValue?: C;
24
34
  /** Already-loaded options (client-side filtered). Also used to resolve the
25
35
  * trigger label for the current `value`. */
26
36
  options?: ComboOption<V>[];
@@ -70,9 +80,10 @@ export interface EntityComboboxProps<V extends string | number>
70
80
  * the shared field/anchor/dismiss/search primitives (no cmdk/Radix). For picking
71
81
  * several entities use {@link MultiEntityCombobox}.
72
82
  */
73
- export function EntityCombobox<V extends string | number>({
83
+ export function EntityCombobox<V extends string | number, C extends ComboClearValue = null>({
74
84
  value,
75
85
  onChange,
86
+ clearValue = null as C,
76
87
  options,
77
88
  loadOptions,
78
89
  loading,
@@ -95,7 +106,7 @@ export function EntityCombobox<V extends string | number>({
95
106
  loadErrorLabel,
96
107
  "aria-label": ariaLabel,
97
108
  ...rest
98
- }: EntityComboboxProps<V>) {
109
+ }: EntityComboboxProps<V, C>) {
99
110
  const core = useComboboxCore<V>({
100
111
  options,
101
112
  loadOptions,
@@ -120,11 +131,13 @@ export function EntityCombobox<V extends string | number>({
120
131
  // belongs to whoever renders both ends of it.
121
132
  const listboxId = useId();
122
133
 
123
- const selectedOption = value == null ? null : resolve(value);
134
+ // The clear value is "nothing selected" too, whichever one the caller picked.
135
+ const chosen: V | null = value == null || value === clearValue ? null : (value as V);
136
+ const selectedOption = chosen == null ? null : resolve(chosen);
124
137
  const q = core.query.trim();
125
138
  const showCreate =
126
139
  Boolean(onCreate) && q.length > 0 && !results.some((o) => o.label.toLowerCase() === q.toLowerCase());
127
- const showClear = Boolean(clearable && value != null && !disabled);
140
+ const showClear = Boolean(clearable && chosen != null && !disabled);
128
141
  /** What the closed control is showing — the second half of its accessible name. */
129
142
  const triggerText = selectedOption?.label ?? placeholder ?? "";
130
143
 
@@ -184,7 +197,7 @@ export function EntityCombobox<V extends string | number>({
184
197
  }}
185
198
  className={cn(
186
199
  FIELD_TRIGGER,
187
- "pr-9",
200
+ "pe-9",
188
201
  label !== undefined && FIELD_FLOATING_PAD,
189
202
  disabled && "cursor-not-allowed opacity-50",
190
203
  field.isInvalid && FIELD_INVALID,
@@ -215,9 +228,9 @@ export function EntityCombobox<V extends string | number>({
215
228
  aria-label={labels.clear}
216
229
  onClick={(e) => {
217
230
  e.stopPropagation();
218
- onChange(null);
231
+ onChange(clearValue);
219
232
  }}
220
- className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
233
+ className="absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
221
234
  >
222
235
  <X className="size-4" />
223
236
  </span>