@eifi1/ui-kit 0.12.0 → 0.13.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 (197) hide show
  1. package/README.md +10 -10
  2. package/dist/components/amount-input.d.ts +17 -1
  3. package/dist/components/amount-input.js +46 -11
  4. package/dist/components/amount-input.js.map +1 -1
  5. package/dist/components/bulk-action-bar.d.ts +10 -2
  6. package/dist/components/bulk-action-bar.js.map +1 -1
  7. package/dist/components/button-group.d.ts +1 -1
  8. package/dist/components/calculator.d.ts +1 -1
  9. package/dist/components/chart.js +14 -8
  10. package/dist/components/chart.js.map +1 -1
  11. package/dist/components/chip.js +20 -15
  12. package/dist/components/chip.js.map +1 -1
  13. package/dist/components/combobox-core.d.ts +3 -1
  14. package/dist/components/combobox-core.js +16 -5
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/combobox.js.map +1 -1
  17. package/dist/components/copy-button.d.ts +1 -1
  18. package/dist/components/data-table-filter-popover.js +8 -1
  19. package/dist/components/data-table-filter-popover.js.map +1 -1
  20. package/dist/components/data-table.js +28 -20
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/entity-combobox.js +22 -17
  23. package/dist/components/entity-combobox.js.map +1 -1
  24. package/dist/components/error-boundary.d.ts +202 -13
  25. package/dist/components/error-boundary.js +211 -20
  26. package/dist/components/error-boundary.js.map +1 -1
  27. package/dist/components/field-sync.js +90 -83
  28. package/dist/components/field-sync.js.map +1 -1
  29. package/dist/components/file-button.d.ts +1 -1
  30. package/dist/components/file-dropzone.d.ts +1 -1
  31. package/dist/components/form-actions.d.ts +1 -1
  32. package/dist/components/full-bleed-dialog.js +4 -0
  33. package/dist/components/full-bleed-dialog.js.map +1 -1
  34. package/dist/components/hover-menu.js +64 -56
  35. package/dist/components/hover-menu.js.map +1 -1
  36. package/dist/components/list.d.ts +14 -2
  37. package/dist/components/list.js +3 -1
  38. package/dist/components/list.js.map +1 -1
  39. package/dist/components/modal.js.map +1 -1
  40. package/dist/components/multi-entity-combobox.js +23 -17
  41. package/dist/components/multi-entity-combobox.js.map +1 -1
  42. package/dist/components/number-field.d.ts +1 -1
  43. package/dist/components/number-input.d.ts +1 -1
  44. package/dist/components/number-input.js.map +1 -1
  45. package/dist/components/numpad-sheet.d.ts +6 -2
  46. package/dist/components/numpad-sheet.js +3 -2
  47. package/dist/components/numpad-sheet.js.map +1 -1
  48. package/dist/components/page-header.d.ts +35 -6
  49. package/dist/components/page-header.js +7 -1
  50. package/dist/components/page-header.js.map +1 -1
  51. package/dist/components/picker-sheet.js +3 -0
  52. package/dist/components/picker-sheet.js.map +1 -1
  53. package/dist/components/popover.js +3 -0
  54. package/dist/components/popover.js.map +1 -1
  55. package/dist/components/settings-fields.d.ts +1 -1
  56. package/dist/components/signed-amount.d.ts +2 -1
  57. package/dist/components/signed-amount.js +1 -1
  58. package/dist/components/signed-amount.js.map +1 -1
  59. package/dist/components/stat-tile.js +1 -1
  60. package/dist/components/stat-tile.js.map +1 -1
  61. package/dist/components/swipeable-row.js +4 -3
  62. package/dist/components/swipeable-row.js.map +1 -1
  63. package/dist/components/text-link.d.ts +73 -11
  64. package/dist/components/text-link.js +45 -10
  65. package/dist/components/text-link.js.map +1 -1
  66. package/dist/components/text.d.ts +26 -2
  67. package/dist/components/text.js +16 -2
  68. package/dist/components/text.js.map +1 -1
  69. package/dist/components/time-input.d.ts +1 -1
  70. package/dist/components/toggle-group.d.ts +22 -0
  71. package/dist/components/toggle-group.js +8 -8
  72. package/dist/components/toggle-group.js.map +1 -1
  73. package/dist/components/toggle-legend.d.ts +17 -0
  74. package/dist/components/toggle-legend.js +6 -2
  75. package/dist/components/toggle-legend.js.map +1 -1
  76. package/dist/components/tooltip.d.ts +51 -1
  77. package/dist/components/tooltip.js +59 -35
  78. package/dist/components/tooltip.js.map +1 -1
  79. package/dist/components/ui.d.ts +1 -1
  80. package/dist/components/ui.js +166 -28
  81. package/dist/components/ui.js.map +1 -1
  82. package/dist/feedback/feedback-attachment.js +4 -6
  83. package/dist/feedback/feedback-attachment.js.map +1 -1
  84. package/dist/feedback/feedback-dialog.js +5 -3
  85. package/dist/feedback/feedback-dialog.js.map +1 -1
  86. package/dist/feedback/feedback-inbox.js +86 -75
  87. package/dist/feedback/feedback-inbox.js.map +1 -1
  88. package/dist/feedback/feedback-thread.d.ts +43 -3
  89. package/dist/feedback/feedback-thread.js +53 -26
  90. package/dist/feedback/feedback-thread.js.map +1 -1
  91. package/dist/feedback.d.ts +1 -1
  92. package/dist/hooks/use-file-drop.d.ts +1 -1
  93. package/dist/i18n/defaults.d.ts +1 -1
  94. package/dist/i18n/kit-labels.d.ts +1 -1
  95. package/dist/i18n/kit-labels.js.map +1 -1
  96. package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
  97. package/dist/i18n/locales/de-CH.d.ts +1 -1
  98. package/dist/i18n/locales/de-informal.d.ts +1 -1
  99. package/dist/i18n/locales/de.d.ts +1 -1
  100. package/dist/i18n/locales/de.js +9 -1
  101. package/dist/i18n/locales/de.js.map +1 -1
  102. package/dist/i18n/locales/es.d.ts +1 -1
  103. package/dist/i18n/locales/es.js +9 -1
  104. package/dist/i18n/locales/es.js.map +1 -1
  105. package/dist/i18n/locales/fr.d.ts +1 -1
  106. package/dist/i18n/locales/fr.js +9 -1
  107. package/dist/i18n/locales/fr.js.map +1 -1
  108. package/dist/i18n/locales/hu.d.ts +1 -1
  109. package/dist/i18n/locales/hu.js +9 -1
  110. package/dist/i18n/locales/hu.js.map +1 -1
  111. package/dist/i18n/locales/it.d.ts +1 -1
  112. package/dist/i18n/locales/it.js +9 -1
  113. package/dist/i18n/locales/it.js.map +1 -1
  114. package/dist/i18n/locales/zh.d.ts +1 -1
  115. package/dist/i18n/locales/zh.js +9 -1
  116. package/dist/i18n/locales/zh.js.map +1 -1
  117. package/dist/index.d.ts +6 -6
  118. package/dist/{kit-labels-v3biUF1L.d.ts → kit-labels-Mz8zZHIF.d.ts} +102 -3
  119. package/dist/lib/format.d.ts +23 -5
  120. package/dist/lib/format.js +3 -1
  121. package/dist/lib/format.js.map +1 -1
  122. package/dist/rhf/fields.d.ts +57 -15
  123. package/dist/rhf/fields.js +41 -11
  124. package/dist/rhf/fields.js.map +1 -1
  125. package/dist/rhf/form.d.ts +1 -1
  126. package/dist/rhf.d.ts +2 -2
  127. package/dist/rhf.js.map +1 -1
  128. package/dist/search/command-palette.js +20 -14
  129. package/dist/search/command-palette.js.map +1 -1
  130. package/dist/shell/app-shell.d.ts +1 -1
  131. package/dist/shell/app-shell.js +62 -54
  132. package/dist/shell/app-shell.js.map +1 -1
  133. package/dist/shell/top-bar-brand.d.ts +1 -1
  134. package/dist/shell/topbar-action-menu.d.ts +9 -3
  135. package/dist/shell/topbar-action-menu.js +2 -1
  136. package/dist/shell/topbar-action-menu.js.map +1 -1
  137. package/dist/shell.d.ts +1 -1
  138. package/dist/tour/tour.js +10 -4
  139. package/dist/tour/tour.js.map +1 -1
  140. package/dist/wizard/stepper-nav.d.ts +1 -1
  141. package/dist/wizard/use-wizard.js +4 -2
  142. package/dist/wizard/use-wizard.js.map +1 -1
  143. package/dist/wizard/wizard-step.d.ts +11 -1
  144. package/dist/wizard/wizard-step.js +24 -6
  145. package/dist/wizard/wizard-step.js.map +1 -1
  146. package/dist/wizard.d.ts +1 -1
  147. package/package.json +6 -6
  148. package/src/components/amount-input.tsx +97 -10
  149. package/src/components/bulk-action-bar.tsx +16 -3
  150. package/src/components/chart.tsx +19 -8
  151. package/src/components/chip.tsx +6 -0
  152. package/src/components/combobox-core.tsx +28 -9
  153. package/src/components/combobox.tsx +2 -0
  154. package/src/components/data-table-filter-popover.tsx +13 -5
  155. package/src/components/data-table.tsx +6 -0
  156. package/src/components/entity-combobox.tsx +7 -4
  157. package/src/components/error-boundary.tsx +453 -29
  158. package/src/components/field-sync.tsx +48 -40
  159. package/src/components/full-bleed-dialog.tsx +4 -0
  160. package/src/components/hover-menu.tsx +26 -17
  161. package/src/components/list.tsx +17 -3
  162. package/src/components/modal.tsx +4 -0
  163. package/src/components/multi-entity-combobox.tsx +8 -4
  164. package/src/components/number-input.tsx +1 -0
  165. package/src/components/numpad-sheet.tsx +6 -1
  166. package/src/components/page-header.tsx +42 -5
  167. package/src/components/picker-sheet.tsx +3 -0
  168. package/src/components/popover.tsx +3 -0
  169. package/src/components/signed-amount.tsx +12 -3
  170. package/src/components/stat-tile.tsx +3 -1
  171. package/src/components/swipeable-row.tsx +7 -7
  172. package/src/components/text-link.tsx +126 -18
  173. package/src/components/text.tsx +46 -2
  174. package/src/components/toggle-group.tsx +35 -10
  175. package/src/components/toggle-legend.tsx +22 -1
  176. package/src/components/tooltip.tsx +79 -8
  177. package/src/components/ui.tsx +307 -39
  178. package/src/feedback/feedback-attachment.tsx +10 -7
  179. package/src/feedback/feedback-dialog.tsx +7 -4
  180. package/src/feedback/feedback-inbox.tsx +7 -0
  181. package/src/feedback/feedback-thread.tsx +109 -33
  182. package/src/i18n/kit-labels.tsx +16 -0
  183. package/src/i18n/locales/de.ts +8 -0
  184. package/src/i18n/locales/es.ts +8 -0
  185. package/src/i18n/locales/fr.ts +8 -0
  186. package/src/i18n/locales/hu.ts +8 -0
  187. package/src/i18n/locales/it.ts +8 -0
  188. package/src/i18n/locales/zh.ts +8 -0
  189. package/src/lib/format.ts +29 -6
  190. package/src/rhf/fields.tsx +97 -16
  191. package/src/rhf.ts +1 -1
  192. package/src/search/command-palette.tsx +29 -19
  193. package/src/shell/app-shell.tsx +6 -1
  194. package/src/shell/topbar-action-menu.tsx +11 -4
  195. package/src/tour/tour.tsx +16 -5
  196. package/src/wizard/use-wizard.ts +8 -4
  197. package/src/wizard/wizard-step.tsx +38 -6
@@ -150,6 +150,72 @@ interface AmountInputProps {
150
150
  * it left a 36px figure 16px to live in.
151
151
  */
152
152
  align?: "start" | "center";
153
+ /**
154
+ * Decimals the committed amount is rounded to (kastlan 0.12: "12.345" and a
155
+ * calculator's "100/3" reached a `Numeric(10,2)` column unrounded and failed with a
156
+ * 422). Default: the currency's minor unit, from `Intl` (CHF and EUR 2, JPY 0). With
157
+ * no `currency`, nothing is rounded unless this is set. Rounding is half away from
158
+ * zero (12.345 → 12.35, -12.345 → -12.35) and happens when the figure settles: on
159
+ * blur, on Enter, and on every result the calculator writes back. Keystrokes are left
160
+ * alone, so "12.3" can still become "12.34".
161
+ */
162
+ digits?: number;
163
+ /** The smallest amount the field settles on. A lower figure is raised to it when it
164
+ * settles, like `digits`. Compared with the signed figure. */
165
+ min?: number;
166
+ /** The largest amount the field settles on (a deposit release capped at the
167
+ * balance). A higher figure is lowered to it when it settles. */
168
+ max?: number;
169
+ }
170
+
171
+ /** The currency's minor unit (CHF 2, JPY 0), or `undefined` for no currency or a code
172
+ * `Intl` does not accept. */
173
+ function currencyDigits(code: string | undefined): number | undefined {
174
+ if (!code) return undefined;
175
+ try {
176
+ return new Intl.NumberFormat("en", { style: "currency", currency: code }).resolvedOptions()
177
+ .maximumFractionDigits;
178
+ } catch {
179
+ return undefined;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * A settled figure, rounded to `digits` (half away from zero) and clamped to
185
+ * `min`/`max`. Only a plain number is touched. A half-typed draft or an expression
186
+ * comes back as it was. Rounding works in decimal exponent notation, because
187
+ * `Math.round(1.005 * 100)` is 100 (1.005 is 1.00499… in binary).
188
+ */
189
+ function settleAmount(text: string, digits: number | undefined, min?: number, max?: number): string {
190
+ const t = text.trim();
191
+ if (!/^-?\d*\.?\d*$/.test(t) || !/\d/.test(t)) return text;
192
+ let n = Number(t);
193
+ if (!Number.isFinite(n)) return text;
194
+ if (digits !== undefined) {
195
+ const magnitude = Number(`${Math.round(Number(`${Math.abs(n)}e${digits}`))}e-${digits}`);
196
+ n = n < 0 ? -magnitude : magnitude;
197
+ }
198
+ if (min !== undefined && n < min) n = min;
199
+ if (max !== undefined && n > max) n = max;
200
+ const out = formatResult(n);
201
+ // Unchanged figures keep their own spelling ("12.50" stays "12.50").
202
+ return Number(out) === Number(t) ? text : out;
203
+ }
204
+
205
+ /**
206
+ * The locale's decimal mark, as far as this field can type it: "," or "." (kastlan 40).
207
+ * The same answer `NumberField` gives, for the same reason: a mark outside those two
208
+ * (Arabic's "٫") is not one {@link sanitizeLive} lets through, so those locales keep
209
+ * the dot rather than get a field that eats the key it shows.
210
+ */
211
+ function decimalMark(locale: string | undefined): "," | "." {
212
+ try {
213
+ const part = new Intl.NumberFormat(locale).formatToParts(1.5).find((p) => p.type === "decimal");
214
+ return part?.value === "," ? "," : ".";
215
+ } catch {
216
+ // An invalid tag throws a RangeError; a typo in a locale must not take the form down.
217
+ return ".";
218
+ }
153
219
  }
154
220
 
155
221
  // The consuming app's ONE money palette (`--money-expense` / `--money-income`),
@@ -186,13 +252,14 @@ const DISPLAY_INPUT_CLASS = cn(
186
252
  * sum that comes out positive still takes the chip with it; "-20+50" → "7" is not,
187
253
  * and "-20+" evaluates to nothing at all.
188
254
  */
189
- function isResultOf(previous: string, text: string): boolean {
255
+ function isResultOf(previous: string, text: string, settle: (text: string) => string): boolean {
190
256
  const n = evaluateExpression(previous);
191
- return n !== null && formatResult(n) === text.trim();
257
+ // Compared settled, since the field rounds what the calculator hands it.
258
+ return n !== null && settle(formatResult(n)) === text.trim();
192
259
  }
193
260
 
194
261
  export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
195
- ({ value, onChange, currency, onCurrencyChange, placeholder, label, disabled, invalid: invalidProp, className, id, ariaLabel, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, autoFocus, tone = "neutral", negative = false, onNegativeChange, variant = "field", align = "start", labels, currencyNames }, ref) => {
262
+ ({ value, onChange, currency, onCurrencyChange, placeholder, label, disabled, invalid: invalidProp, className, id, ariaLabel, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, autoFocus, tone = "neutral", negative = false, onNegativeChange, variant = "field", align = "start", labels, currencyNames, digits: digitsProp, min, max }, ref) => {
196
263
  const generatedId = useId();
197
264
  const fieldId = id ?? generatedId;
198
265
  const invalid = Boolean(invalidProp) || ariaInvalid === true || ariaInvalid === "true";
@@ -230,6 +297,20 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
230
297
  // minus and has to survive keystroke-for-keystroke.
231
298
  const signOwned = onNegativeChange !== undefined;
232
299
  const shown = signOwned && negative && isBareAmount(value) ? `-${value}` : value;
300
+ // What the user SEES is `shown` with the locale's decimal mark (kastlan 40: a
301
+ // fr-CH user typed "1,5" and watched it turn into "1.5" under their fingers). Display only — `value`, `onChange`, the sign split and the settle all
302
+ // stay dot-decimal, because that string is the contract: RhfMoneyField's
303
+ // `parseAmount`, every app's `Number(value)` and the calculator's evaluator read
304
+ // it. The way back is free: {@link sanitizeLive} already folds a typed "," into
305
+ // ".", so a comma typed, pasted or tapped on the numpad lands as a dot.
306
+ //
307
+ // One character for one character, and no digit grouping: the caret stays where
308
+ // the typist put it, and "1.000" cannot be read back as a thousand in one locale
309
+ // and as one in the other. de-CH and it-CH are "." — CLDR says so, and this
310
+ // follows `Intl` rather than a guess about a country.
311
+ const locale = useKitLocale();
312
+ const mark = decimalMark(locale);
313
+ const display = mark === "," ? shown.replace(/\./g, ",") : shown;
233
314
  // Every route into the field — typing, the numpad sheet, the desktop
234
315
  // calculator, the blur/Enter commit — funnels through here, so the split is
235
316
  // written once and the four entry paths cannot drift.
@@ -239,6 +320,8 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
239
320
  // edit the field's own text that is simply what the field was showing; the
240
321
  // desktop calculator keeps its expression in a popover the field never sees,
241
322
  // so it passes it in (see `CalculatorButton`'s `onChange`).
323
+ const digits = digitsProp ?? currencyDigits(currency);
324
+ const settle = (text: string) => settleAmount(text, digits, min, max);
242
325
  const handleText = (raw: string, previous: string = shown) => {
243
326
  const text = sanitizeLive(raw);
244
327
  if (!signOwned) {
@@ -249,7 +332,7 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
249
332
  if (sign) {
250
333
  // SET, not flip: re-reporting the same sign on every keystroke is a no-op.
251
334
  onNegativeChange(sign === "-");
252
- } else if (looksLikeExpression(previous) && isBareAmount(text) && isResultOf(previous, text)) {
335
+ } else if (looksLikeExpression(previous) && isBareAmount(text) && isResultOf(previous, text, settle)) {
253
336
  // A calculation that just RESOLVED states its sign in both directions. The
254
337
  // figure it ran on is the SIGNED one the user could see — "-12+30" is +18 —
255
338
  // so a positive result has to move the chip too, or the field would answer
@@ -262,7 +345,7 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
262
345
  }
263
346
  onChange(sign ? rest : text);
264
347
  };
265
- const commit = () => handleText(commitExpression(shown));
348
+ const commit = () => handleText(settle(commitExpression(shown)));
266
349
  const { open, setOpen, wrapperRef, panelRef, query, setQuery, inputRef } = useDropdownSearch();
267
350
  // The chip the currency list hangs off (Keksdose dev#548). It is portalled now, so
268
351
  // the panel needs a real trigger rect rather than a relative parent — see
@@ -281,7 +364,6 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
281
364
  }, [labels?.currency, labels?.currencySearch]);
282
365
  const currencyText = useKitLabels("currency", DEFAULT_CURRENCY_LABELS, currencyOverrides);
283
366
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
284
- const locale = useKitLocale();
285
367
 
286
368
  const selected = getCurrency(currency);
287
369
  const filtered = useMemo(() => {
@@ -321,7 +403,7 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
321
403
  // needs: a zero to aim at, at full size. The caller supplies it because
322
404
  // only the app knows the locale's decimal separator.
323
405
  placeholder={asDisplay ? (placeholder ?? "0") : label !== undefined ? " " : placeholder}
324
- value={shown}
406
+ value={display}
325
407
  onChange={(e) => handleText(e.target.value)}
326
408
  onFocus={() => setFocused(true)}
327
409
  onBlur={() => {
@@ -387,8 +469,10 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
387
469
  >
388
470
  {showCalc && (
389
471
  <CalculatorButton
390
- value={shown}
391
- onChange={handleText}
472
+ // The mark the field shows, as NumberInput hands its calculator; the
473
+ // evaluator reads either, and its result comes back dot-decimal.
474
+ value={display}
475
+ onChange={(result, expression) => handleText(settle(result), expression)}
392
476
  className="px-1.5"
393
477
  ariaLabel={labels?.calculatorTrigger}
394
478
  labels={labels?.calculator}
@@ -470,11 +554,14 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
470
554
  </div>
471
555
  {showNumpad && (
472
556
  <NumberPadSheet
473
- value={shown}
557
+ // Localised like the field it mirrors; its keys come back through
558
+ // `sanitizeLive`, so the "." key and a "," both land as a dot.
559
+ value={display}
474
560
  onChange={handleText}
475
561
  onDone={() => innerRef.current?.blur()}
476
562
  label={label}
477
563
  labels={labels?.pad}
564
+ decimalMark={mark}
478
565
  />
479
566
  )}
480
567
  </div>
@@ -99,8 +99,16 @@ export interface BulkActionBarProps extends Omit<ComponentPropsWithoutRef<"div">
99
99
  open?: boolean;
100
100
  /** Clear the selection / leave selection mode. */
101
101
  onClear: () => void;
102
- /** The actions: buttons (`IconButton size="lg"` on a phone, `Button` from md up),
103
- * in the caller's order. They are the toolbar's arrow-key stops, after the clear. */
102
+ /**
103
+ * The actions: buttons (`IconButton size="lg"` on a phone, `Button` from md up),
104
+ * in the caller's order. They are the toolbar's arrow-key stops, after the clear.
105
+ *
106
+ * LINKS rove too (keksdose F8): a `<Button href>` — "View report", which navigates
107
+ * rather than acts — is an `<a href>`, and it is one more arrow-key stop in its place
108
+ * among the buttons, not a second Tab stop beside the toolbar's one. A disabled
109
+ * `<Button href disabled>` has no `href` and is skipped, as a disabled button is. Any
110
+ * other focusable of your own joins with `data-toolbar-item`.
111
+ */
104
112
  children?: ReactNode;
105
113
  /** Default `floating`. See {@link BulkActionBarVariant}; an object picks one per
106
114
  * breakpoint, see {@link ResponsiveBulkActionBarVariant}. */
@@ -135,7 +143,12 @@ const FLOATING_STYLE: CSSProperties = {
135
143
 
136
144
  /** What the arrow keys walk: the toolbar's enabled controls. Matched by element, not by
137
145
  * `tabindex`, because the roving below sets every one but the current to `-1` — a
138
- * custom control joins with `data-toolbar-item`. */
146
+ * custom control joins with `data-toolbar-item`.
147
+ *
148
+ * `a[href]` is here on purpose (keksdose F8, a "View report" `Button href` in the bar):
149
+ * a link in a toolbar is a toolbar item like any other, and left out it would keep its
150
+ * own Tab stop, so Tab would stop twice in a bar that promises one. `[href]` rather than `a`: the kit's disabled link is an `<a>`
151
+ * WITHOUT one, and is skipped as a disabled button is. */
139
152
  const ITEMS =
140
153
  "button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[data-toolbar-item]";
141
154
 
@@ -8,7 +8,7 @@
8
8
  // The value on show is the *structure*: config-driven colors injected as CSS
9
9
  // vars, muted axis/grid styling, and a polished tooltip/legend — the things
10
10
  // that make shadcn charts read as "designed" rather than "default Recharts".
11
- import { createContext, useContext, useId, useRef } from "react";
11
+ import { createContext, useContext, useId, useLayoutEffect, useRef, useState } from "react";
12
12
  import type { ComponentProps, CSSProperties, ReactNode, RefObject } from "react";
13
13
  import {
14
14
  Legend as RechartsLegend,
@@ -263,7 +263,7 @@ export function ChartTooltipContent({
263
263
  // `ChartTooltip` keeps null values (so they can print "—"), which also keeps the
264
264
  // series a legend toggle switched off with `hide`; those go here instead.
265
265
  const items = includeHidden ? payload : payload?.filter((item) => item.hide !== true);
266
- if (!active || !items?.length) return null;
266
+ const visible = Boolean(active && items?.length);
267
267
  // Recharts anchors the tooltip at coordinate.x inside the full (scrolled) chart
268
268
  // width; subtract the container's scrollLeft to get its on-screen x, then flip
269
269
  // left if the tooltip's own width would run past the visible right edge.
@@ -272,16 +272,27 @@ export function ChartTooltipContent({
272
272
  // `ChartContainer`), but an RTL scroller counts `scrollLeft` from its right edge —
273
273
  // 0 at the start, negative going left — so there the content's left edge sits
274
274
  // `scrollWidth - clientWidth + scrollLeft` px to the left of the visible one.
275
- let flip = false;
276
- const boundary = boundaryRef?.current;
277
- if (boundary && coordinate?.x != null) {
275
+ //
276
+ // Measured in a layout effect (the DOM is not readable during render), so it commits
277
+ // before paint. `payload` and `label` are deps because they change the tooltip's own
278
+ // width, and recharts hands a fresh `payload` on every tooltip render, so a scroll
279
+ // followed by any re-render is re-measured just as it was when this ran in render.
280
+ const [flip, setFlip] = useState(false);
281
+ const x = coordinate?.x;
282
+ useLayoutEffect(() => {
283
+ const boundary = boundaryRef?.current;
284
+ if (!visible || !boundary || x == null) {
285
+ setFlip(false);
286
+ return;
287
+ }
278
288
  const offset = isRtl(boundary)
279
289
  ? boundary.scrollWidth - boundary.clientWidth + boundary.scrollLeft
280
290
  : boundary.scrollLeft;
281
- const visibleX = coordinate.x - offset;
291
+ const visibleX = x - offset;
282
292
  const tipWidth = tipRef.current?.offsetWidth ?? 160;
283
- flip = visibleX + tipWidth + 12 > boundary.clientWidth;
284
- }
293
+ setFlip(visibleX + tipWidth + 12 > boundary.clientWidth);
294
+ }, [visible, x, boundaryRef, payload, label]);
295
+ if (!visible || !items) return null;
285
296
  return (
286
297
  <div
287
298
  ref={tipRef}
@@ -621,6 +621,9 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
621
621
  }
622
622
 
623
623
  return (
624
+ /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- the handlers are
625
+ the roving-focus hooks, live only when the container passes `role` and `tabIndex`
626
+ (ChipInput does); the role is a prop, so the rule cannot see it. */
624
627
  <span
625
628
  ref={ref as React.Ref<HTMLSpanElement>}
626
629
  className={look}
@@ -893,6 +896,9 @@ export function ChipInput({
893
896
  {label}
894
897
  </label>
895
898
  )}
899
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- a
900
+ pointer convenience that widens the input's hit area to the whole field; the
901
+ keyboard path is the <input> itself, which is in the tab order. */}
896
902
  <div
897
903
  // `relative` so the live region below can be `sr-only` without escaping to the
898
904
  // initial containing block and inflating the document height.
@@ -261,7 +261,9 @@ export interface ComboboxCore<V extends string | number> {
261
261
  rect: AnchorRect | null;
262
262
  /** Where the dropdown goes, clamped to the visible viewport (feedback #135). */
263
263
  placement: AnchoredPanel;
264
- cacheRef: RefObject<Map<V, ComboOption<V>>>;
264
+ /** Put an option in the label cache, so {@link ComboboxCore.resolve} still knows a
265
+ * value the caller just chose after the result list has moved on. */
266
+ rememberOption: (o: ComboOption<V>) => void;
265
267
  resolve: (v: V) => ComboOption<V> | null;
266
268
  /** Close and hand focus back to the trigger — the keyboard paths only. See
267
269
  * {@link useDropdown} for why a pointer dismissal must not do this. */
@@ -319,10 +321,23 @@ export function useComboboxCore<V extends string | number>({
319
321
  useOutsideClick([triggerRef, panelRef], close, open && !isPhone);
320
322
  useEscapeKey(closeToTrigger, open);
321
323
 
322
- // Reset the query + focus the search box each time the panel opens.
324
+ // Reset the query each time the panel opens, and the highlighted row whenever the
325
+ // visible set changes (a new query, or the panel opening or closing). Adjusted while
326
+ // rendering against the previous values rather than in an effect, so the render that
327
+ // opens the panel already searches for "" and highlights the first row.
328
+ const [prevOpen, setPrevOpen] = useState(open);
329
+ const [prevQuery, setPrevQuery] = useState(query);
330
+ if (open !== prevOpen || query !== prevQuery) {
331
+ const opened = open && !prevOpen;
332
+ setPrevOpen(open);
333
+ setPrevQuery(opened ? "" : query);
334
+ if (opened) setQuery("");
335
+ setActive(0);
336
+ }
337
+
338
+ // Focus the search box each time the panel opens.
323
339
  useEffect(() => {
324
340
  if (!open) return;
325
- setQuery("");
326
341
  const id = requestAnimationFrame(() => inputRef.current?.focus());
327
342
  return () => cancelAnimationFrame(id);
328
343
  }, [open]);
@@ -346,11 +361,9 @@ export function useComboboxCore<V extends string | number>({
346
361
  }, [options, results]);
347
362
  const resolve = (v: V): ComboOption<V> | null =>
348
363
  options?.find((o) => o.value === v) ?? cacheRef.current.get(v) ?? null;
349
-
350
- // Reset the highlighted row whenever the visible set changes.
351
- useEffect(() => {
352
- setActive(0);
353
- }, [query, open]);
364
+ const rememberOption = (o: ComboOption<V>) => {
365
+ cacheRef.current.set(o.value, o);
366
+ };
354
367
 
355
368
  return {
356
369
  triggerRef,
@@ -369,7 +382,7 @@ export function useComboboxCore<V extends string | number>({
369
382
  minChars,
370
383
  rect,
371
384
  placement,
372
- cacheRef,
385
+ rememberOption,
373
386
  resolve,
374
387
  closeToTrigger,
375
388
  };
@@ -777,6 +790,9 @@ export function ComboboxPanel<V extends string | number>({
777
790
  inputRef={inputRef}
778
791
  closeLabel={closeLabel}
779
792
  >
793
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- never focused
794
+ itself: it catches the listbox keys bubbling from the sheet's search <input>,
795
+ which holds focus while the list is up. */}
780
796
  <div onKeyDown={onKeyDown} className="relative">
781
797
  {list}
782
798
  {announcement}
@@ -796,6 +812,9 @@ export function ComboboxPanel<V extends string | number>({
796
812
  : { left: rect.left };
797
813
 
798
814
  return createPortal(
815
+ /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- never focused
816
+ itself: it catches the listbox keys bubbling from the search <input> inside it,
817
+ which holds focus while the panel is open. */
799
818
  <div
800
819
  // Spread FIRST: `onKeyDown` below is the listbox keyboard (Up/Down/Home/End/
801
820
  // Enter/Tab) and the placement is measured rather than chosen, so neither is a
@@ -420,6 +420,7 @@ export function Combobox({
420
420
  aria-required={ariaRequired}
421
421
  autoComplete="off"
422
422
  disabled={disabled}
423
+ // eslint-disable-next-line jsx-a11y/no-autofocus -- a documented prop the caller opts into (off by default); the field never takes focus on its own.
423
424
  autoFocus={autoFocus}
424
425
  onBlur={onBlur}
425
426
  onMouseDown={primaryOnly.onMouseDown}
@@ -913,6 +914,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
913
914
  aria-required={ariaRequired}
914
915
  autoComplete="off"
915
916
  disabled={disabled}
917
+ // eslint-disable-next-line jsx-a11y/no-autofocus -- a documented prop the caller opts into (off by default); the field never takes focus on its own.
916
918
  autoFocus={autoFocus}
917
919
  // `inputMode="none"` rather than readOnly, for the same reason Combobox
918
920
  // above gives: the sheet carries the keyboard, and a readOnly field would
@@ -1,3 +1,4 @@
1
+ import { useCallback } from "react";
1
2
  import { X } from "lucide-react";
2
3
  import { cn } from "../lib/cn";
3
4
  import { MiniCalendar } from "./mini-calendar";
@@ -53,6 +54,15 @@ export function FilterPopover<T>({
53
54
  // No `"en"` fallback any more: that pinned the calendar to English in a table that
54
55
  // was never handed a locale, under a provider that had one.
55
56
  const locale = useKitLocale(localeProp);
57
+ // What `autoFocus` did, done by hand: a callback ref runs in the same commit phase
58
+ // React's own autofocus does, once when the input mounts (it is stable across
59
+ // renders, so a re-render does not re-take focus).
60
+ const focusOnMount = useCallback(
61
+ (el: HTMLInputElement | null) => {
62
+ if (el && autoFocus) el.focus();
63
+ },
64
+ [autoFocus],
65
+ );
56
66
  const filter = resolveFilter(column);
57
67
  if (!filter) return null;
58
68
 
@@ -80,11 +90,9 @@ export function FilterPopover<T>({
80
90
  <input
81
91
  type="text"
82
92
  // A prop now, defaulting to what the popover needs; see `autoFocus` on the
83
- // props above. Deliberately NOT an eslint-disable: `jsx-a11y/no-autofocus` is
84
- // one of the config's ratcheted warnings and this is still one of the four it
85
- // counts, because the default here is still to take focus. Silencing it would
86
- // shrink the backlog without emptying it.
87
- autoFocus={autoFocus}
93
+ // props above. Focused by `focusOnMount` rather than the `autoFocus`
94
+ // attribute, because here taking focus is the default, not an opt-in.
95
+ ref={focusOnMount}
88
96
  value={v.q}
89
97
  onChange={(e) => onChange({ type: "text", q: e.target.value })}
90
98
  placeholder={labels.filterPlaceholder}
@@ -1548,6 +1548,9 @@ export function DataTable<T>({
1548
1548
  {cardInner}
1549
1549
  </RowLink>
1550
1550
  ) : (
1551
+ /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- the handlers are
1552
+ only attached when `interactive`, and then so are role="button", tabIndex 0 and the
1553
+ Enter/Space handler below; the rule cannot follow the conditional role. */
1551
1554
  <div
1552
1555
  role={interactive ? "button" : undefined}
1553
1556
  tabIndex={interactive ? 0 : undefined}
@@ -1918,6 +1921,9 @@ export function DataTable<T>({
1918
1921
  )}
1919
1922
  </div>
1920
1923
  {resizable && col.key !== ROW_ACTIONS_KEY && (
1924
+ /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a
1925
+ pointer-only drag handle for a visual preference (column width); it is
1926
+ deliberately not a tab stop, and no content or action depends on it. */
1921
1927
  <span
1922
1928
  role="separator"
1923
1929
  aria-orientation="vertical"
@@ -137,7 +137,7 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
137
137
  create: createLabel,
138
138
  });
139
139
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
140
- const { open, results, resolve, setOpen } = core;
140
+ const { open, results, resolve, setOpen, rememberOption, query, triggerRef } = core;
141
141
  // One id per instance, generated here rather than in the core: `aria-controls` on
142
142
  // the trigger has to name the list while the list is still closed, so the id
143
143
  // belongs to whoever renders both ends of it.
@@ -146,7 +146,7 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
146
146
  // The clear value is "nothing selected" too, whichever one the caller picked.
147
147
  const chosen: V | null = value == null || value === clearValue ? null : (value as V);
148
148
  const selectedOption = chosen == null ? null : resolve(chosen);
149
- const q = core.query.trim();
149
+ const q = query.trim();
150
150
  const showCreate =
151
151
  Boolean(onCreate) && q.length > 0 && !results.some((o) => o.label.toLowerCase() === q.toLowerCase());
152
152
  const showClear = Boolean(clearable && chosen != null && !disabled);
@@ -154,7 +154,7 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
154
154
  const triggerText = selectedOption?.label ?? placeholder ?? "";
155
155
 
156
156
  const choose = (o: ComboOption<V>) => {
157
- core.cacheRef.current.set(o.value, o);
157
+ rememberOption(o);
158
158
  onChange(o.value);
159
159
  // Back to the trigger, not to <body>: the panel that held focus is about to
160
160
  // unmount, and a keyboard user who just answered this field should be standing
@@ -170,7 +170,7 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
170
170
  <div {...rest} className={cn("relative", className)}>
171
171
  {label !== undefined && <FieldLabel>{label}</FieldLabel>}
172
172
  <button
173
- ref={core.triggerRef}
173
+ ref={triggerRef}
174
174
  id={id}
175
175
  type="button"
176
176
  // A combobox, not a button. The distinction is not pedantry: this control
@@ -237,6 +237,9 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
237
237
  </span>
238
238
  </span>
239
239
  {showClear ? (
240
+ /* eslint-disable-next-line jsx-a11y/click-events-have-key-events -- tabIndex -1 inside
241
+ the trigger <button>, so it never holds focus and a key handler here could
242
+ never fire; the keys go to the trigger. A pointer shortcut only. */
240
243
  <span
241
244
  role="button"
242
245
  tabIndex={-1}