@eifi1/ui-kit 0.23.0 → 0.24.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 (101) hide show
  1. package/README.md +6 -6
  2. package/dist/chart.d.ts +2 -4
  3. package/dist/components/amount-input.d.ts +16 -9
  4. package/dist/components/amount-input.js +13 -11
  5. package/dist/components/amount-input.js.map +1 -1
  6. package/dist/components/autocomplete.d.ts +5 -0
  7. package/dist/components/autocomplete.js +39 -37
  8. package/dist/components/autocomplete.js.map +1 -1
  9. package/dist/components/button-group.d.ts +2 -4
  10. package/dist/components/calculator.d.ts +2 -4
  11. package/dist/components/column-mapper.d.ts +62 -9
  12. package/dist/components/column-mapper.js +85 -49
  13. package/dist/components/column-mapper.js.map +1 -1
  14. package/dist/components/combobox.d.ts +33 -1
  15. package/dist/components/combobox.js +8 -2
  16. package/dist/components/combobox.js.map +1 -1
  17. package/dist/components/confirm-dialog.d.ts +2 -4
  18. package/dist/components/copy-button.d.ts +2 -4
  19. package/dist/components/danger-confirm.d.ts +2 -4
  20. package/dist/components/danger-confirm.js +52 -5
  21. package/dist/components/danger-confirm.js.map +1 -1
  22. package/dist/components/entity-combobox.d.ts +13 -1
  23. package/dist/components/entity-combobox.js +13 -4
  24. package/dist/components/entity-combobox.js.map +1 -1
  25. package/dist/components/facing-pair.d.ts +2 -4
  26. package/dist/components/file-button.d.ts +2 -4
  27. package/dist/components/file-dropzone.d.ts +2 -4
  28. package/dist/components/form-actions.d.ts +1 -3
  29. package/dist/components/form-actions.js +15 -1
  30. package/dist/components/form-actions.js.map +1 -1
  31. package/dist/components/iban-input.d.ts +2 -4
  32. package/dist/components/language-select.d.ts +2 -4
  33. package/dist/components/money-field.d.ts +2 -4
  34. package/dist/components/multi-entity-combobox.d.ts +5 -1
  35. package/dist/components/multi-entity-combobox.js +11 -3
  36. package/dist/components/multi-entity-combobox.js.map +1 -1
  37. package/dist/components/number-field.d.ts +2 -4
  38. package/dist/components/number-input.d.ts +2 -4
  39. package/dist/components/numpad-sheet.d.ts +2 -4
  40. package/dist/components/phone-input.d.ts +2 -4
  41. package/dist/components/series-chart.d.ts +2 -4
  42. package/dist/components/settings-fields.d.ts +2 -4
  43. package/dist/components/share-card.d.ts +1 -3
  44. package/dist/components/text-link.d.ts +1 -3
  45. package/dist/components/time-input.d.ts +2 -4
  46. package/dist/components/ui.d.ts +2 -4
  47. package/dist/components/ui.js +55 -17
  48. package/dist/components/ui.js.map +1 -1
  49. package/dist/feedback/feedback-attachment.d.ts +66 -1
  50. package/dist/feedback/feedback-attachment.js +32 -12
  51. package/dist/feedback/feedback-attachment.js.map +1 -1
  52. package/dist/feedback/feedback-dialog.d.ts +66 -1
  53. package/dist/feedback/feedback-inbox.d.ts +67 -253
  54. package/dist/feedback/feedback-thread.d.ts +1 -3
  55. package/dist/feedback/feedback-thread.js +8 -2
  56. package/dist/feedback/feedback-thread.js.map +1 -1
  57. package/dist/{feedback-BxeQVzwq.d.ts → feedback-DOwPu-Il.d.ts} +878 -18
  58. package/dist/feedback.d.ts +2 -4
  59. package/dist/hooks/use-file-drop.d.ts +2 -4
  60. package/dist/i18n/defaults.d.ts +2 -4
  61. package/dist/i18n/german.d.ts +2 -4
  62. package/dist/i18n/kit-labels.d.ts +1 -3
  63. package/dist/i18n/languages.d.ts +2 -4
  64. package/dist/i18n/locales/de-CH.d.ts +2 -4
  65. package/dist/i18n/locales/en.d.ts +2 -4
  66. package/dist/i18n/locales/es.d.ts +2 -4
  67. package/dist/i18n/locales/fr.d.ts +2 -4
  68. package/dist/i18n/locales/hu.d.ts +2 -4
  69. package/dist/i18n/locales/it.d.ts +2 -4
  70. package/dist/i18n/locales/zh.d.ts +2 -4
  71. package/dist/i18n/review.d.ts +2 -4
  72. package/dist/index.d.ts +2 -4
  73. package/dist/lib/strip-fade.d.ts +2 -2
  74. package/dist/lib/strip-fade.js.map +1 -1
  75. package/dist/rhf/fields.d.ts +59 -8
  76. package/dist/rhf/fields.js +61 -3
  77. package/dist/rhf/fields.js.map +1 -1
  78. package/dist/rhf/form.d.ts +2 -4
  79. package/dist/rhf.d.ts +3 -5
  80. package/dist/rhf.js.map +1 -1
  81. package/dist/shell/app-shell.d.ts +1 -3
  82. package/dist/shell/top-bar-brand.d.ts +2 -4
  83. package/dist/shell.d.ts +1 -3
  84. package/dist/wizard/stepper-nav.d.ts +2 -4
  85. package/dist/wizard.d.ts +2 -4
  86. package/package.json +2 -2
  87. package/src/components/amount-input.tsx +37 -20
  88. package/src/components/autocomplete.tsx +16 -5
  89. package/src/components/column-mapper.tsx +181 -79
  90. package/src/components/combobox.tsx +41 -3
  91. package/src/components/danger-confirm.tsx +193 -22
  92. package/src/components/entity-combobox.tsx +28 -5
  93. package/src/components/form-actions.tsx +20 -1
  94. package/src/components/multi-entity-combobox.tsx +16 -3
  95. package/src/components/ui.tsx +107 -0
  96. package/src/feedback/feedback-attachment.tsx +65 -12
  97. package/src/feedback/feedback-thread.tsx +66 -6
  98. package/src/lib/strip-fade.ts +2 -2
  99. package/src/rhf/fields.tsx +167 -6
  100. package/src/rhf.ts +1 -1
  101. package/dist/feedback-attachment-fGAzZPf0.d.ts +0 -469
package/dist/wizard.d.ts CHANGED
@@ -6,10 +6,7 @@ export { WizardStep, WizardStepProps } from './wizard/wizard-step.js';
6
6
  export { WizardSummary } from './wizard/wizard-summary.js';
7
7
  export { StepperNav } from './wizard/stepper-nav.js';
8
8
  import 'react';
9
- import './feedback-BxeQVzwq.js';
10
- import './feedback-attachment-fGAzZPf0.js';
11
- import './feedback/feedback-inbox.js';
12
- import 'lucide-react';
9
+ import './feedback-DOwPu-Il.js';
13
10
  import './components/tooltip.js';
14
11
  import './hooks/use-anchored-rect.js';
15
12
  import './lib/clipping.js';
@@ -20,6 +17,7 @@ import './components/mini-calendar.js';
20
17
  import './components/calendar-heatmap.js';
21
18
  import './components/popover.js';
22
19
  import './components/chip.js';
20
+ import 'lucide-react';
23
21
  import './components/field-sync.js';
24
22
  import './tour/tour.js';
25
23
  import './search/command-palette.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
4
  "type": "module",
5
5
  "description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
6
6
  "_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
@@ -117,7 +117,7 @@
117
117
  "_dependencies": "Only what the package imports AND owns the version of. `lucide-react` is a PEER: every consumer already declares `lucide-react: ^0.511.0` itself, so a hard dependency here bought each of them a second copy of the icon set in node_modules — and pinned to 0.511.x it held all three behind a release line that is now at 1.x. The range is deliberately wide (`>=0.400.0 <2`): the kit imports named icon components and nothing else, which has been stable across that whole span, so the app picks the version. `flag-icons` was never imported at all — 5.6 MB of stylesheet and SVG installed into every consumer to support class NAMES (`fi fi-xx`) that CurrencyFlag, CurrencySelect and LanguageMenu render as strings. The host supplies that stylesheet; see the dependency section of README.md. It stays a devDependency because the showcase imports it.",
118
118
  "dependencies": {
119
119
  "clsx": "^2.1.1",
120
- "tailwind-merge": "^3.3.0",
120
+ "tailwind-merge": "^3.7.0",
121
121
  "zustand": "^5.0.5"
122
122
  },
123
123
  "devDependencies": {
@@ -60,17 +60,26 @@ interface AmountInputProps {
60
60
  className?: string;
61
61
  /**
62
62
  * Classes for the `<input>` itself — {@link NumberInput}'s and {@link Input}'s prop of
63
- * the same name, merged the same way: after the field's own base classes, so it can
64
- * restyle the figure (`text-end` for a money column whose decimal points line up,
65
- * `font-medium`, a tighter `py-1` in a table cell), and before the end padding the
66
- * calculator and the currency chip reserve and before the invalid border, so neither
67
- * can be taken away by accident.
63
+ * the same name, merged the same way: after everything the field sets for LOOKS — the
64
+ * base classes, the plain end padding, the display shape's centring and the `tone`
65
+ * colour — so it can restyle the figure (`text-end` for a money column whose decimal
66
+ * points line up, `font-medium`, a tighter `py-1` or a symmetric `px-2` in a table
67
+ * cell, `text-transparent` while a figure is being re-fetched), and before what the
68
+ * field needs to WORK: the end padding the calculator and the currency chip reserve,
69
+ * and the invalid border. Neither of those can be taken away by accident — a caller's
70
+ * `px-2` keeps its start edge and the digits still stop short of the chip, and a
71
+ * borderless cell's `border-transparent` still turns red when the value is wrong.
68
72
  *
69
73
  * keksdose G7: the invoice's VAT cell right-aligns its figure, and with only
70
74
  * `className` (the wrapper) it reached the element through `[&_input]:text-end` — a
71
75
  * descendant selector that depends on the field's inner markup, which the kit is free
72
76
  * to change, and that a reader of the call site has to decode. {@link MoneyField}
73
77
  * passes it through, and `RhfMoneyField` takes it too.
78
+ *
79
+ * keksdose 0.24: until then it sat BEFORE the plain end padding (`pe-3`) and the tone,
80
+ * so the budget's assigned cell could not make its padding symmetric (`px-2` came out
81
+ * `px-2 pe-3`) and the holdings price could not hide an outflow-toned figure while
82
+ * re-fetching — both were back on `[&_input]:` selectors on the wrapper.
74
83
  */
75
84
  inputClassName?: string;
76
85
  id?: string;
@@ -337,6 +346,17 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
337
346
  // fighting itself through responsive overrides. `useMediaQuery` reads
338
347
  // synchronously on mount, so there is no field→figure flash.
339
348
  const asDisplay = variant === "display" && isMobile;
349
+ // Room for the trailing controls — the calculator and the currency chip — or
350
+ // nothing when neither trails. showCalc is always false on a phone, so the display
351
+ // shape only ever has to clear the currency chip — and it clears it by the chip's
352
+ // actual width (a text-sm code plus a chevron, ~58px) rather than the 5rem the
353
+ // boxed shape reserves. On a 375px screen those 16px are the difference between a
354
+ // readable figure and a clipped one (feedback #430 rework).
355
+ const trailingRoom = asDisplay
356
+ ? currency ? (editable ? "pe-16" : "pe-12") : undefined
357
+ : showCalc
358
+ ? currency ? (editable ? "pe-24" : "pe-16") : "pe-10"
359
+ : currency ? (editable ? "pe-20" : "pe-14") : undefined;
340
360
  const innerRef = useRef<HTMLInputElement>(null);
341
361
  const setRefs = useCallback(
342
362
  (el: HTMLInputElement | null) => {
@@ -521,27 +541,24 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
521
541
  // (feedback #314). FLOATING_INPUT_CLASS already bundles the peer +
522
542
  // transparent-placeholder bits.
523
543
  asDisplay ? DISPLAY_INPUT_CLASS : label !== undefined ? FLOATING_INPUT_CLASS : FIELD_BASE,
524
- // The caller's, over the base and under everything below (see `inputClassName`).
525
- inputClassName,
526
- // Room for the trailing controls. showCalc is always false on a phone,
527
- // so the display shape only ever has to clear the currency chip — and
528
- // it clears it by the chip's actual width (a text-sm code plus a
529
- // chevron, ~58px) rather than the 5rem the boxed shape reserves. On a
530
- // 375px screen those 16px are the difference between a readable figure
531
- // and a clipped one (feedback #430 rework).
532
- asDisplay
533
- ? currency ? (editable ? "pe-16" : "pe-12") : "pe-0"
534
- : showCalc
535
- ? currency ? (editable ? "pe-24" : "pe-16") : "pe-10"
536
- : currency ? (editable ? "pe-20" : "pe-14") : "pe-3",
544
+ // The plain end padding, with nothing trailing: cosmetic, so the caller's
545
+ // to change (keksdose 0.24 — a symmetric `px-2` in a budget cell).
546
+ !trailingRoom && (asDisplay ? "pe-0" : "pe-3"),
537
547
  // Centred display shape: centre in what is LEFT of the currency chip.
538
548
  // `text-center` alone would centre the figure in the whole box, i.e.
539
- // partly underneath the chip; the padding above is what takes the chip
549
+ // partly underneath the chip; the padding below is what takes the chip
540
550
  // out of the centring, and nothing is added at the start because a
541
551
  // mirrored reservation would spend the width twice (see `align`).
542
552
  asDisplay && align === "center" && "text-center",
543
- // Last, so it wins over the base class's own text colour.
553
+ // Over the base class's own text colour.
544
554
  TONE_CLASS[tone],
555
+ // The caller's, over all of the above — base, plain padding, alignment and
556
+ // tone — and under only what the field needs to work (see `inputClassName`).
557
+ inputClassName,
558
+ // Room for the trailing controls, after the caller's so a `px-2` cannot run
559
+ // the digits under the chip (NumberInput's order, which reserves its unit
560
+ // and calculator the same way).
561
+ trailingRoom,
545
562
  invalid && FIELD_INVALID,
546
563
  )}
547
564
  aria-invalid={invalid || undefined}
@@ -14,6 +14,7 @@ import { useAnchorDir } from "./use-anchor-dir";
14
14
  import {
15
15
  ComboboxFieldLabel,
16
16
  DISABLED_ROW_CLASS,
17
+ endHintRowProps,
17
18
  isOptionEnabled,
18
19
  stepEnabled,
19
20
  useActiveOptionScroll,
@@ -120,6 +121,11 @@ export interface AutocompleteProps<V extends string | number = string>
120
121
  * `aria-describedby`, before any error; a {@link FieldHint} "?" rides the label line,
121
122
  * or with no label sits at the field's end edge, outside the box. Not the list's
122
123
  * `status` line, which talks about the lookup, not the field.
124
+ *
125
+ * Pass it — even as `undefined` — on a field whose hint comes and goes (0.24): the
126
+ * unlabelled field then keeps its end-hint row whether or not a "?" is in it, so the
127
+ * `<input>` is never re-parented, and focus and caret survive the toggle. The rule
128
+ * the rest of the family has had since 0.23 (and the fields since 0.22).
123
129
  */
124
130
  hint?: ReactNode;
125
131
  /**
@@ -159,7 +165,10 @@ export interface AutocompleteProps<V extends string | number = string>
159
165
  }
160
166
 
161
167
  function AutocompleteInner<V extends string | number = string>(
162
- {
168
+ props: AutocompleteProps<V>,
169
+ ref: ForwardedRef<HTMLInputElement>,
170
+ ) {
171
+ const {
163
172
  value,
164
173
  onChange,
165
174
  options,
@@ -194,9 +203,7 @@ function AutocompleteInner<V extends string | number = string>(
194
203
  "aria-labelledby": ariaLabelledBy,
195
204
  "aria-describedby": ariaDescribedBy,
196
205
  ...rest
197
- }: AutocompleteProps<V>,
198
- ref: ForwardedRef<HTMLInputElement>,
199
- ) {
206
+ } = props;
200
207
  const generated = useId();
201
208
  const fieldId = id ?? generated;
202
209
  // Off the GENERATED id: a caller's `id` is theirs to collide with.
@@ -303,7 +310,11 @@ function AutocompleteInner<V extends string | number = string>(
303
310
  {label}
304
311
  </ComboboxFieldLabel>
305
312
  )}
306
- <EndHintRow hint={hasLabel ? undefined : hintParts.labelHint}>
313
+ {/* `"hint" in props`: the family's 0.22 rule (see `endHintRowProps`) — passed at
314
+ all, even as `undefined`, a hint keeps the unlabelled field's end-hint row, so
315
+ a "?" coming and going never re-parents the <input> under the caret (0.24, the
316
+ last of the family to get it; the siblings had it in 0.23). */}
317
+ <EndHintRow {...endHintRowProps("hint" in props, hasLabel, hintParts.labelHint)}>
307
318
  {withLock(
308
319
  <div ref={fieldRef} className={cn("relative", locked && "cursor-not-allowed")}>
309
320
  {icon && (
@@ -1,5 +1,5 @@
1
- import { useEffect, useEffectEvent, useState } from "react";
2
- import type { ChangeEvent, DragEvent, ReactNode } from "react";
1
+ import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
2
+ import type { ChangeEvent, DragEvent, HTMLAttributes, ReactNode } from "react";
3
3
  import { Upload } from "lucide-react";
4
4
 
5
5
  import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
@@ -17,6 +17,7 @@ import type {
17
17
  ColumnMapping,
18
18
  ColumnRole,
19
19
  } from "../lib/column-mapping";
20
+ import { scrollIntoStrip, useStripFade } from "../lib/strip-fade";
20
21
  import { parseTextTable } from "../lib/table-text";
21
22
  import type { TableSeparator, TextTable } from "../lib/table-text";
22
23
  import { AlertBanner } from "./alert-banner";
@@ -100,7 +101,8 @@ export interface ColumnMapperLabels {
100
101
  roleOf: (column: string) => string;
101
102
  /** The role select's "no role" option. */
102
103
  ignore: string;
103
- /** A required role in the select. */
104
+ /** A required role in the select — only while some role is NOT required: when
105
+ * every one is, the mark tells no role from another (0.24, Kurvenschmiede). */
104
106
  requiredRole: (role: string) => string;
105
107
  /** Under the preview, when it shows fewer rows than were read. */
106
108
  previewOf: (shown: number, total: number) => string;
@@ -176,6 +178,29 @@ function formatList(
176
178
 
177
179
  // ── ColumnRoleTable ───────────────────────────────────────────────────────────
178
180
 
181
+ /** A `data-*` attribute, typed so `{ "data-private": "" }` needs no cast. */
182
+ type DataAttributes = { [key: `data-${string}`]: string | number | boolean | undefined };
183
+
184
+ /**
185
+ * What {@link ColumnRoleTableProps.bodyProps} puts on the preview's `<tbody>`: `data-*`
186
+ * hooks, an `id`, a class, a style.
187
+ */
188
+ export type ColumnRoleTableBodyProps = Omit<
189
+ HTMLAttributes<HTMLTableSectionElement>,
190
+ "children" | "dangerouslySetInnerHTML"
191
+ > &
192
+ DataAttributes;
193
+
194
+ /**
195
+ * What {@link ColumnRoleTableProps.rowProps} returns for one preview row's `<tr>` —
196
+ * the same shape as LineItems' `LineItemsRowProps`, on a table row.
197
+ */
198
+ export type ColumnRoleTableRowProps = Omit<
199
+ HTMLAttributes<HTMLTableRowElement>,
200
+ "children" | "dangerouslySetInnerHTML"
201
+ > &
202
+ DataAttributes;
203
+
179
204
  export interface ColumnRoleTableProps<
180
205
  R extends string,
181
206
  M extends ColumnMapping<R> = ColumnMapping<R>,
@@ -202,6 +227,26 @@ export interface ColumnRoleTableProps<
202
227
  /** Every role select disabled — keksdose locks them while a re-sniff replaces the
203
228
  * table they point into. */
204
229
  disabled?: boolean;
230
+ /**
231
+ * Extra attributes for the preview's `<tbody>` (0.24) — keksdose: the sample rows are
232
+ * the user's own bank data (payees, memos, amounts), and its demo mode blurs exactly
233
+ * that through `.demo-mode [data-private]` (#336). With nowhere to put the attribute,
234
+ * its map step reached into the table with a `useLayoutEffect` that set it on the
235
+ * `<tbody>` after every commit; `bodyProps={{ "data-private": "" }}` declares it.
236
+ * There is deliberately no head counterpart: the column names and the role selects
237
+ * are what such a mode is there to show.
238
+ *
239
+ * Spread on the kit `TableBody`; a `className` is added to its own.
240
+ */
241
+ bodyProps?: ColumnRoleTableBodyProps;
242
+ /**
243
+ * Extra attributes for one preview row's `<tr>`, from its cells and its index in the
244
+ * preview (0.24) — the contract of LineItems' `rowProps` (keksdose K20), on a table:
245
+ * `data-*` hooks (a per-row `data-private`, a test id), an `id`, a class. Called for
246
+ * the rows drawn, the first {@link previewRows}. The row has no attributes of its own
247
+ * to protect; a `className` is added after its classes.
248
+ */
249
+ rowProps?: (row: readonly string[], index: number) => ColumnRoleTableRowProps | undefined;
205
250
  labels?: Partial<ColumnMapperLabels>;
206
251
  className?: string;
207
252
  }
@@ -217,13 +262,25 @@ export interface ColumnRoleTableProps<
217
262
  * not after the import. A column with no role says "Ignore" and its values are dimmed:
218
263
  * the state that most needs to be visible in a twelve-column bank export.
219
264
  *
220
- * Picking a role another column holds MOVES it ({@link assignColumnRole}). Required
221
- * roles say so in the list, and what the table still needs is named under it — a
222
- * required group as "either Amount, Debit or Credit".
265
+ * Picking a role another column holds MOVES it ({@link assignColumnRole}). What the
266
+ * table still needs is named under it — a required group as "either Amount, Debit or
267
+ * Credit". Required roles say "(required)" in the list only where some role is not
268
+ * (0.24): Kurvenschmiede's roles are all required, so the mark told no option from
269
+ * another, and on a phone it cut the role's own name short ("Zeit (s) (erforderl…")
270
+ * — the part of the option the reader has to read. The "Still needed" line already
271
+ * says what is missing; a mark that distinguishes nothing is only noise.
223
272
  *
224
273
  * The table scrolls sideways inside its own box (the kit {@link Table}'s wrapper, a
225
274
  * named, keyboard-reachable region while it overflows), so a wide export never widens
226
- * the page at 390px.
275
+ * the page at 390px. An edge with columns behind it fades out (0.24, Kurvenschmiede:
276
+ * on a phone the fourth column's select sat off-screen and nothing said there was more
277
+ * to the right; phone scrollbars are overlays that show only while you drag). It is
278
+ * the kit Tabs strip's measured fade: drawn only while something is hidden on that
279
+ * side, so a table that fits is painted as before; both ends once scrolled into the
280
+ * middle; the reading direction's own in RTL; never animated, so there is nothing for
281
+ * reduced motion to turn off; and a `mask-image`, which changes no layout and needs no
282
+ * background colour to fade to. A role select focused behind the fade is scrolled
283
+ * clear of it.
227
284
  */
228
285
  export function ColumnRoleTable<
229
286
  R extends string,
@@ -238,6 +295,8 @@ export function ColumnRoleTable<
238
295
  previewRows = 5,
239
296
  totalRows,
240
297
  disabled,
298
+ bodyProps,
299
+ rowProps,
241
300
  labels: labelsProp,
242
301
  className,
243
302
  }: ColumnRoleTableProps<R, M>) {
@@ -265,86 +324,119 @@ export function ColumnRoleTable<
265
324
  ? labelOf(gap[0])
266
325
  : labels.oneOf(formatList(gap.map(labelOf), "disjunction", locale)),
267
326
  );
327
+ // "(required)" only where it tells one option from another.
328
+ const markRequired = roles.some((role) => role.required !== true);
329
+
330
+ // The Table's own scroll wrapper — the `<table>`'s parent — is what overflows; the
331
+ // fade is measured on it and drawn on the box round it, which has the same edges.
332
+ const scroller = useRef<HTMLElement | null>(null);
333
+ const tableRef = useCallback((table: HTMLTableElement | null) => {
334
+ scroller.current = table?.parentElement ?? null;
335
+ }, []);
336
+ const fade = useStripFade(scroller);
268
337
 
269
338
  return (
270
339
  <div
271
340
  className={cn("min-w-0 space-y-1.5", className)}
272
341
  data-slot="column-role-table"
273
342
  >
274
- <Table density="compact" framed aria-label={labels.table}>
275
- <TableHead>
276
- <TableRow>
277
- {columns.map((column) => {
278
- const role = roleOfColumn(roles, mapping, column);
279
- const name = nameOf(column);
280
- return (
281
- <TableHeaderCell
282
- key={column}
283
- scope="col"
284
- className="min-w-36 py-2 align-top"
285
- >
286
- <span className="block max-w-56 truncate pb-1 text-[11px] font-normal text-[var(--text-muted)]">
287
- {name}
288
- </span>
289
- <Select
290
- size="sm"
291
- className="w-full"
292
- aria-label={labels.roleOf(name)}
293
- value={role ?? ""}
294
- disabled={disabled}
295
- onChange={(event) =>
296
- onMappingChange(
297
- assignColumnRole(
298
- roles,
299
- mapping,
300
- column,
301
- event.target.value === ""
302
- ? null
303
- : (event.target.value as R),
304
- ),
305
- )
306
- }
343
+ <div
344
+ data-slot="column-role-table-scroll"
345
+ // In reading-direction terms ("start", "end", "both"), for a caller's styling.
346
+ data-overflow={fade.overflow}
347
+ style={
348
+ fade.mask
349
+ ? { maskImage: fade.mask, WebkitMaskImage: fade.mask }
350
+ : undefined
351
+ }
352
+ >
353
+ <Table
354
+ ref={tableRef}
355
+ density="compact"
356
+ framed
357
+ aria-label={labels.table}
358
+ >
359
+ <TableHead>
360
+ <TableRow>
361
+ {columns.map((column) => {
362
+ const role = roleOfColumn(roles, mapping, column);
363
+ const name = nameOf(column);
364
+ return (
365
+ <TableHeaderCell
366
+ key={column}
367
+ scope="col"
368
+ className="min-w-36 py-2 align-top"
307
369
  >
308
- <option value="">{labels.ignore}</option>
309
- {roles.map((option) => (
310
- <option key={option.value} value={option.value}>
311
- {option.required === true
312
- ? labels.requiredRole(option.label)
313
- : option.label}
314
- </option>
315
- ))}
316
- </Select>
317
- </TableHeaderCell>
318
- );
319
- })}
320
- </TableRow>
321
- </TableHead>
322
- <TableBody>
323
- {shown.map((row, index) => (
324
- <TableRow key={index}>
325
- {columns.map((column) => (
326
- <TableCell
327
- key={column}
328
- data-ignored={
329
- roleOfColumn(roles, mapping, column) === null || undefined
330
- }
331
- className={cn(
332
- "font-mono whitespace-nowrap",
333
- // Dimmed, not hidden: its values are what tell you whether ignoring
334
- // it was right.
335
- roleOfColumn(roles, mapping, column) === null &&
336
- "text-[var(--text-muted)]",
337
- )}
338
- >
339
- <span className="block max-w-56 truncate">
340
- {row[column] ?? ""}
341
- </span>
342
- </TableCell>
343
- ))}
370
+ <span className="block max-w-56 truncate pb-1 text-[11px] font-normal text-[var(--text-muted)]">
371
+ {name}
372
+ </span>
373
+ <Select
374
+ size="sm"
375
+ className="w-full"
376
+ aria-label={labels.roleOf(name)}
377
+ value={role ?? ""}
378
+ disabled={disabled}
379
+ // Focus brings a select behind the fade clear of it — the browser's
380
+ // own scroll stops at the edge, under the fade.
381
+ onFocus={(event) => {
382
+ const cell = event.currentTarget.closest("th");
383
+ if (scroller.current && cell)
384
+ scrollIntoStrip(scroller.current, cell);
385
+ }}
386
+ onChange={(event) =>
387
+ onMappingChange(
388
+ assignColumnRole(
389
+ roles,
390
+ mapping,
391
+ column,
392
+ event.target.value === ""
393
+ ? null
394
+ : (event.target.value as R),
395
+ ),
396
+ )
397
+ }
398
+ >
399
+ <option value="">{labels.ignore}</option>
400
+ {roles.map((option) => (
401
+ <option key={option.value} value={option.value}>
402
+ {option.required === true && markRequired
403
+ ? labels.requiredRole(option.label)
404
+ : option.label}
405
+ </option>
406
+ ))}
407
+ </Select>
408
+ </TableHeaderCell>
409
+ );
410
+ })}
344
411
  </TableRow>
345
- ))}
346
- </TableBody>
347
- </Table>
412
+ </TableHead>
413
+ <TableBody {...bodyProps}>
414
+ {shown.map((row, index) => (
415
+ <TableRow {...rowProps?.(row, index)} key={index}>
416
+ {columns.map((column) => (
417
+ <TableCell
418
+ key={column}
419
+ data-ignored={
420
+ roleOfColumn(roles, mapping, column) === null || undefined
421
+ }
422
+ className={cn(
423
+ "font-mono whitespace-nowrap",
424
+ // Dimmed, not hidden: its values are what tell you whether ignoring
425
+ // it was right.
426
+ roleOfColumn(roles, mapping, column) === null &&
427
+ "text-[var(--text-muted)]",
428
+ )}
429
+ >
430
+ <span className="block max-w-56 truncate">
431
+ {row[column] ?? ""}
432
+ </span>
433
+ </TableCell>
434
+ ))}
435
+ </TableRow>
436
+ ))}
437
+ </TableBody>
438
+ </Table>
439
+ </div>
348
440
  {total > shown.length && (
349
441
  <p className="text-xs text-[var(--text-muted)]">
350
442
  {labels.previewOf(shown.length, total)}
@@ -392,6 +484,12 @@ export interface ColumnMapperProps<R extends string> {
392
484
  * order. */
393
485
  guess?: (table: TextTable) => ColumnMapping<R>;
394
486
  disabled?: boolean;
487
+ /** The preview table's `<tbody>` attributes — {@link ColumnRoleTableProps.bodyProps}
488
+ * (0.24): `{ "data-private": "" }` for a table of someone's own data. */
489
+ bodyProps?: ColumnRoleTableBodyProps;
490
+ /** One preview row's `<tr>` attributes — {@link ColumnRoleTableProps.rowProps}
491
+ * (0.24). `row` is the row as read, `index` its place in the preview. */
492
+ rowProps?: (row: readonly string[], index: number) => ColumnRoleTableRowProps | undefined;
395
493
  labels?: Partial<ColumnMapperLabels>;
396
494
  className?: string;
397
495
  }
@@ -439,6 +537,8 @@ export function ColumnMapper<R extends string>({
439
537
  previewRows = 5,
440
538
  guess,
441
539
  disabled = false,
540
+ bodyProps,
541
+ rowProps,
442
542
  labels: labelsProp,
443
543
  className,
444
544
  }: ColumnMapperProps<R>) {
@@ -632,6 +732,8 @@ export function ColumnMapper<R extends string>({
632
732
  onMappingChange={(mapping) => commit({ ...state, mapping })}
633
733
  previewRows={previewRows}
634
734
  disabled={disabled}
735
+ bodyProps={bodyProps}
736
+ rowProps={rowProps}
635
737
  labels={labelsProp}
636
738
  />
637
739
  {unread.length > 0 && (
@@ -1,5 +1,5 @@
1
1
  import { Fragment, useId, useMemo, useRef, useState } from "react";
2
- import type { ComponentPropsWithoutRef, ReactNode, RefObject } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode, Ref, RefObject } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
  import { ChevronDown, Plus, X } from "lucide-react";
5
5
  import { FIELD_BASE, FIELD_FLOATING_PAD, FIELD_INVALID, PHONE_QUERY } from "./ui";
@@ -244,6 +244,14 @@ export interface ComboboxProps
244
244
  maxSuggestions?: number;
245
245
  searchPlaceholder?: string;
246
246
  closeLabel?: string;
247
+ /**
248
+ * The phone sheet's heading — and, as a string, the dialog's accessible name — for a
249
+ * field whose label is drawn by someone else: a form's `FormLabel` above it,
250
+ * which `RhfTextCombobox` hands over here (0.24). Default: `label`. Without either, the
251
+ * full-screen sheet opened untitled and unnamed, the one screen on a phone that has to
252
+ * say what it is asking for (live #200).
253
+ */
254
+ sheetTitle?: ReactNode;
247
255
  /**
248
256
  * Label for the row that COMMITS a value the list does not contain — e.g.
249
257
  * `(v) => \`Add "${v}" as payee\``. Supplying it turns the free text into
@@ -260,6 +268,15 @@ export interface ComboboxProps
260
268
  * values are all supposed to already exist.
261
269
  */
262
270
  createLabel?: (value: string) => string;
271
+ /**
272
+ * The `<input>` — the control a reader types in and the one focus belongs on — as a
273
+ * React 19 ref prop, the way {@link CountrySelect} takes its trigger's (0.24, kastlan:
274
+ * react-hook-form's focus-on-error calls `focus()` on whatever `field.ref` is handed,
275
+ * and a combobox with no ref inside a hand-wired `RhfField` gave it nothing, so a
276
+ * failed submit could not take the user to it). Not the wrapper: a ref is for focus,
277
+ * measurement and selection, and all three are the input's. `RhfTextCombobox` passes it.
278
+ */
279
+ ref?: Ref<HTMLInputElement>;
263
280
  }
264
281
 
265
282
  /**
@@ -285,6 +302,7 @@ export function Combobox(props: ComboboxProps) {
285
302
  maxSuggestions,
286
303
  searchPlaceholder,
287
304
  closeLabel,
305
+ sheetTitle,
288
306
  createLabel,
289
307
  invalid,
290
308
  error,
@@ -296,6 +314,7 @@ export function Combobox(props: ComboboxProps) {
296
314
  autoFocus,
297
315
  onBlur,
298
316
  onSubmit,
317
+ ref,
299
318
  "aria-label": ariaLabel,
300
319
  // Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s
301
320
  // render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and
@@ -481,6 +500,7 @@ export function Combobox(props: ComboboxProps) {
481
500
  `peer`, so it is dimmed by hand above). */
482
501
  <div ref={fieldRef} className={cn("relative", inert && "cursor-not-allowed opacity-50")}>
483
502
  <input
503
+ ref={ref}
484
504
  id={fieldId}
485
505
  value={value}
486
506
  placeholder={placeholder}
@@ -616,7 +636,7 @@ export function Combobox(props: ComboboxProps) {
616
636
  <PickerSheet
617
637
  open={open && !inert}
618
638
  onClose={() => setOpen(false)}
619
- title={label}
639
+ title={sheetTitle ?? label}
620
640
  // The sheet's input IS the field: this is a free-text control, so what is
621
641
  // typed here is the value (a brand-new payee is just a name nothing
622
642
  // matches), and the list below narrows as it changes.
@@ -794,6 +814,11 @@ export interface InlineEntityComboboxProps<V extends string | number, C extends
794
814
  * list simply does not open. Default: `combobox.noResults` from the provider. */
795
815
  emptyLabel?: string;
796
816
  closeLabel?: string;
817
+ /** The phone sheet's heading (and, as a string, its accessible name) for a field
818
+ * whose label is drawn by someone else — a form's `FormLabel`;
819
+ * `RhfInlineEntityCombobox` hands its label over here (0.24). Default: `label`.
820
+ * See {@link Combobox}'s `sheetTitle`. */
821
+ sheetTitle?: ReactNode;
797
822
  /** Offer a clear "×" in place of the chevron whenever something is selected.
798
823
  *
799
824
  * Emptying the text already clears (see {@link reconcile}), and on a desktop that
@@ -870,6 +895,16 @@ export interface InlineEntityComboboxProps<V extends string | number, C extends
870
895
  * `commit`: a locked field opens no list at all.
871
896
  */
872
897
  createCommit?: boolean;
898
+ /**
899
+ * The `<input>` — the focusable control, the one a reader types in — as a React 19
900
+ * ref prop (0.24, kastlan). Its account picker is this component inside an
901
+ * `RhfField` render, and react-hook-form's focus-on-error calls `focus()` on whatever
902
+ * `field.ref` is handed: with no ref there was nothing to hand it, so a line-item form
903
+ * whose account was missing failed its submit and left the caret where it was. Pass
904
+ * `field.ref` here, or use `RhfInlineEntityCombobox`, which does. The wrapper is not
905
+ * reachable this way — focus and selection are the input's. See {@link Combobox}'s `ref`.
906
+ */
907
+ ref?: Ref<HTMLInputElement>;
873
908
  }
874
909
 
875
910
  /**
@@ -907,6 +942,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
907
942
  searchPlaceholder,
908
943
  emptyLabel,
909
944
  closeLabel,
945
+ sheetTitle,
910
946
  clearable,
911
947
  clearLabel,
912
948
  disabledReason,
@@ -918,6 +954,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
918
954
  invalid,
919
955
  error,
920
956
  hint,
957
+ ref,
921
958
  "aria-label": ariaLabel,
922
959
  // Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s
923
960
  // render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and
@@ -1146,6 +1183,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
1146
1183
  `peer`, so it is dimmed by hand above). */
1147
1184
  <div ref={fieldRef} className={cn("relative", inert && "cursor-not-allowed opacity-50")}>
1148
1185
  <input
1186
+ ref={ref}
1149
1187
  id={fieldId}
1150
1188
  value={shown}
1151
1189
  placeholder={placeholder}
@@ -1300,7 +1338,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
1300
1338
  // Closing without choosing keeps the value: `text` was never emptied, so
1301
1339
  // reconcile has nothing to undo — it just puts the label back.
1302
1340
  onClose={reconcile}
1303
- title={label}
1341
+ title={sheetTitle ?? label}
1304
1342
  query={sheetQuery}
1305
1343
  onQueryChange={(v) => {
1306
1344
  setSheetQuery(v);