@eifi1/ui-kit 0.12.0 → 0.13.1

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 +23 -1
  3. package/dist/components/amount-input.js +78 -13
  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 +225 -21
  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 +10 -2
  46. package/dist/components/numpad-sheet.js +6 -4
  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 +4 -2
  121. package/dist/lib/format.js.map +1 -1
  122. package/dist/rhf/fields.d.ts +59 -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 +165 -12
  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 +470 -30
  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 +17 -3
  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 +47 -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 +32 -7
  190. package/src/rhf/fields.tsx +99 -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,108 @@ 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
+ * ⚠️ **A unit price or a rate needs `digits`.** The default is right for an amount of
163
+ * money and silently wrong for a price per unit: a fund's NAV of 123.4567 or fuel at
164
+ * 1.789 €/l is stored as 123.46 / 1.79, and no test fails (keksdose G2, a
165
+ * `Numeric(18,6)` holding price and a `Numeric(18,4)` unit price). Pass the column's
166
+ * scale: `digits={4}`, `digits={6}`.
167
+ */
168
+ digits?: number;
169
+ /** The smallest amount the field settles on. A lower figure is raised to it when it
170
+ * settles, like `digits`. Compared with the signed figure. */
171
+ min?: number;
172
+ /** The largest amount the field settles on (a deposit release capped at the
173
+ * balance). A higher figure is lowered to it when it settles. */
174
+ max?: number;
175
+ }
176
+
177
+ /** The currency's minor unit (CHF 2, JPY 0), or `undefined` for no currency or a code
178
+ * `Intl` does not accept. */
179
+ function currencyDigits(code: string | undefined): number | undefined {
180
+ if (!code) return undefined;
181
+ try {
182
+ return new Intl.NumberFormat("en", { style: "currency", currency: code }).resolvedOptions()
183
+ .maximumFractionDigits;
184
+ } catch {
185
+ return undefined;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * A settled figure, rounded to `digits` (half away from zero) and clamped to
191
+ * `min`/`max`. Only a plain number is touched. A half-typed draft or an expression
192
+ * comes back as it was. Rounding works in decimal exponent notation, because
193
+ * `Math.round(1.005 * 100)` is 100 (1.005 is 1.00499… in binary).
194
+ */
195
+ function settleAmount(text: string, digits: number | undefined, min?: number, max?: number): string {
196
+ const t = text.trim();
197
+ if (!/^-?\d*\.?\d*$/.test(t) || !/\d/.test(t)) return text;
198
+ let n = Number(t);
199
+ if (!Number.isFinite(n)) return text;
200
+ if (digits !== undefined) {
201
+ const magnitude = Number(`${Math.round(Number(`${Math.abs(n)}e${digits}`))}e-${digits}`);
202
+ n = n < 0 ? -magnitude : magnitude;
203
+ }
204
+ if (min !== undefined && n < min) n = min;
205
+ if (max !== undefined && n > max) n = max;
206
+ const out = formatResult(n);
207
+ // Unchanged figures keep their own spelling ("12.50" stays "12.50").
208
+ return Number(out) === Number(t) ? text : out;
209
+ }
210
+
211
+ /**
212
+ * The locale's decimal mark, as far as this field can type it: "," or "." (kastlan 40).
213
+ * The same answer `NumberField` gives, for the same reason: a mark outside those two
214
+ * (Arabic's "٫") is not one {@link sanitizeLive} lets through, so those locales keep
215
+ * the dot rather than get a field that eats the key it shows.
216
+ */
217
+ function decimalMark(locale: string | undefined): "," | "." {
218
+ try {
219
+ const part = new Intl.NumberFormat(locale).formatToParts(1.5).find((p) => p.type === "decimal");
220
+ return part?.value === "," ? "," : ".";
221
+ } catch {
222
+ // An invalid tag throws a RangeError; a typo in a locale must not take the form down.
223
+ return ".";
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Which typed mark is the decimal one (keksdose G1). {@link sanitizeLive} folds every
229
+ * "," into ".", so on its own it read the German "1.234,56" as 1.23456 — and the
230
+ * settle then made that 1.23, final. This decides per operand, from the typist's own
231
+ * text, before that fold:
232
+ *
233
+ * - both marks present: the one that is not the locale's is grouping, and goes;
234
+ * - in a "," locale, only "." present: grouping when it cannot be a decimal as a
235
+ * German reader writes it — twice ("1.234.567"), or in thousands shape ("1.234",
236
+ * "12.500") — otherwise a decimal ("1.5", typed by a dot-decimal habit);
237
+ * - in a "." locale, only "," present: grouping when it appears twice ("1,234,567");
238
+ * once it stays a decimal, because Swiss and German typists write "1,5" in a
239
+ * de-CH form (kastlan 40) and a unit price "1,789" is a decimal.
240
+ *
241
+ * Only a keystroke's text comes through here. A calculator result is dot-decimal
242
+ * already, and "1.234" from `100/81.03…` is never a thousand.
243
+ */
244
+ function normalizeTypedMarks(raw: string, mark: "," | "."): string {
245
+ const other = mark === "," ? "." : ",";
246
+ return raw.replace(/[0-9.,]+/g, (operand) => {
247
+ const hasMark = operand.includes(mark);
248
+ const count = operand.split(other).length - 1;
249
+ if (count === 0) return operand;
250
+ const grouping = hasMark
251
+ ? true
252
+ : count > 1 || (mark === "," && /^\d{1,3}(\.\d{3})+$/.test(operand));
253
+ return grouping ? operand.split(other).join("") : operand;
254
+ });
153
255
  }
154
256
 
155
257
  // The consuming app's ONE money palette (`--money-expense` / `--money-income`),
@@ -186,13 +288,14 @@ const DISPLAY_INPUT_CLASS = cn(
186
288
  * sum that comes out positive still takes the chip with it; "-20+50" → "7" is not,
187
289
  * and "-20+" evaluates to nothing at all.
188
290
  */
189
- function isResultOf(previous: string, text: string): boolean {
291
+ function isResultOf(previous: string, text: string, settle: (text: string) => string): boolean {
190
292
  const n = evaluateExpression(previous);
191
- return n !== null && formatResult(n) === text.trim();
293
+ // Compared settled, since the field rounds what the calculator hands it.
294
+ return n !== null && settle(formatResult(n)) === text.trim();
192
295
  }
193
296
 
194
297
  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) => {
298
+ ({ 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
299
  const generatedId = useId();
197
300
  const fieldId = id ?? generatedId;
198
301
  const invalid = Boolean(invalidProp) || ariaInvalid === true || ariaInvalid === "true";
@@ -230,6 +333,36 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
230
333
  // minus and has to survive keystroke-for-keystroke.
231
334
  const signOwned = onNegativeChange !== undefined;
232
335
  const shown = signOwned && negative && isBareAmount(value) ? `-${value}` : value;
336
+ // What the user SEES is `shown` with the locale's decimal mark (kastlan 40: a
337
+ // 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
338
+ // stay dot-decimal, because that string is the contract: RhfMoneyField's
339
+ // `parseAmount`, every app's `Number(value)` and the calculator's evaluator read
340
+ // it. The way back is free: {@link sanitizeLive} already folds a typed "," into
341
+ // ".", so a comma typed, pasted or tapped on the numpad lands as a dot.
342
+ //
343
+ // One character for one character, and no digit grouping: the caret stays where
344
+ // the typist put it, and "1.000" cannot be read back as a thousand in one locale
345
+ // and as one in the other. de-CH and it-CH are "." — CLDR says so, and this
346
+ // follows `Intl` rather than a guess about a country.
347
+ const locale = useKitLocale();
348
+ const mark = decimalMark(locale);
349
+ // While the typist is typing, the field shows THEIR text ("1.234,5" stays exactly
350
+ // that), not the value re-spelt: re-spelling turned a grouping "." into a "," under
351
+ // their fingers, after which no rule could tell the two apart (keksdose G1). The
352
+ // draft is shown only while it still spells what the field holds; a value set from
353
+ // outside, a calculator result or a commit falls back to the derived spelling.
354
+ const [draft, setDraft] = useState<string | null>(null);
355
+ const derived = mark === "," ? shown.replace(/\./g, ",") : shown;
356
+ const draftAs = draft === null ? "" : sanitizeLive(normalizeTypedMarks(draft, mark));
357
+ const display =
358
+ draft === null
359
+ ? derived
360
+ : draftAs === shown
361
+ ? draft
362
+ : // The caller owns the sign: the typed "12" is shown as the "-12" it is.
363
+ signOwned && negative && `-${draftAs}` === shown
364
+ ? `-${draft}`
365
+ : derived;
233
366
  // Every route into the field — typing, the numpad sheet, the desktop
234
367
  // calculator, the blur/Enter commit — funnels through here, so the split is
235
368
  // written once and the four entry paths cannot drift.
@@ -239,6 +372,8 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
239
372
  // edit the field's own text that is simply what the field was showing; the
240
373
  // desktop calculator keeps its expression in a popover the field never sees,
241
374
  // so it passes it in (see `CalculatorButton`'s `onChange`).
375
+ const digits = digitsProp ?? currencyDigits(currency);
376
+ const settle = (text: string) => settleAmount(text, digits, min, max);
242
377
  const handleText = (raw: string, previous: string = shown) => {
243
378
  const text = sanitizeLive(raw);
244
379
  if (!signOwned) {
@@ -249,7 +384,7 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
249
384
  if (sign) {
250
385
  // SET, not flip: re-reporting the same sign on every keystroke is a no-op.
251
386
  onNegativeChange(sign === "-");
252
- } else if (looksLikeExpression(previous) && isBareAmount(text) && isResultOf(previous, text)) {
387
+ } else if (looksLikeExpression(previous) && isBareAmount(text) && isResultOf(previous, text, settle)) {
253
388
  // A calculation that just RESOLVED states its sign in both directions. The
254
389
  // figure it ran on is the SIGNED one the user could see — "-12+30" is +18 —
255
390
  // so a positive result has to move the chip too, or the field would answer
@@ -262,7 +397,14 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
262
397
  }
263
398
  onChange(sign ? rest : text);
264
399
  };
265
- const commit = () => handleText(commitExpression(shown));
400
+ const commit = () => {
401
+ setDraft(null);
402
+ handleText(settle(commitExpression(shown)));
403
+ };
404
+ const typed = (raw: string) => {
405
+ setDraft(raw);
406
+ handleText(normalizeTypedMarks(raw, mark));
407
+ };
266
408
  const { open, setOpen, wrapperRef, panelRef, query, setQuery, inputRef } = useDropdownSearch();
267
409
  // The chip the currency list hangs off (Keksdose dev#548). It is portalled now, so
268
410
  // the panel needs a real trigger rect rather than a relative parent — see
@@ -281,7 +423,6 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
281
423
  }, [labels?.currency, labels?.currencySearch]);
282
424
  const currencyText = useKitLabels("currency", DEFAULT_CURRENCY_LABELS, currencyOverrides);
283
425
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
284
- const locale = useKitLocale();
285
426
 
286
427
  const selected = getCurrency(currency);
287
428
  const filtered = useMemo(() => {
@@ -321,8 +462,8 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
321
462
  // needs: a zero to aim at, at full size. The caller supplies it because
322
463
  // only the app knows the locale's decimal separator.
323
464
  placeholder={asDisplay ? (placeholder ?? "0") : label !== undefined ? " " : placeholder}
324
- value={shown}
325
- onChange={(e) => handleText(e.target.value)}
465
+ value={display}
466
+ onChange={(e) => typed(e.target.value)}
326
467
  onFocus={() => setFocused(true)}
327
468
  onBlur={() => {
328
469
  commit();
@@ -387,8 +528,13 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
387
528
  >
388
529
  {showCalc && (
389
530
  <CalculatorButton
390
- value={shown}
391
- onChange={handleText}
531
+ // The mark the field shows, as NumberInput hands its calculator; the
532
+ // evaluator reads either, and its result comes back dot-decimal.
533
+ value={display}
534
+ onChange={(result, expression) => {
535
+ setDraft(null);
536
+ handleText(settle(result), expression);
537
+ }}
392
538
  className="px-1.5"
393
539
  ariaLabel={labels?.calculatorTrigger}
394
540
  labels={labels?.calculator}
@@ -470,11 +616,18 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
470
616
  </div>
471
617
  {showNumpad && (
472
618
  <NumberPadSheet
473
- value={shown}
474
- onChange={handleText}
619
+ // Localised like the field it mirrors. Keys are typing, read by the
620
+ // locale's marks like the keyboard's; "=" is a result, dot-decimal.
621
+ value={display}
622
+ onChange={typed}
623
+ onResult={(result) => {
624
+ setDraft(null);
625
+ handleText(result);
626
+ }}
475
627
  onDone={() => innerRef.current?.blur()}
476
628
  label={label}
477
629
  labels={labels?.pad}
630
+ decimalMark={mark}
478
631
  />
479
632
  )}
480
633
  </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}