@eifi1/ui-kit 0.49.0 → 0.50.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 (144) hide show
  1. package/README.md +11 -7
  2. package/dist/admin/user-roster.d.ts +16 -10
  3. package/dist/admin/user-roster.js +7 -3
  4. package/dist/admin/user-roster.js.map +1 -1
  5. package/dist/auth/verify-email.d.ts +9 -1
  6. package/dist/auth/verify-email.js +3 -1
  7. package/dist/auth/verify-email.js.map +1 -1
  8. package/dist/components/amount-input.d.ts +2 -1
  9. package/dist/components/amount-input.js +3 -2
  10. package/dist/components/amount-input.js.map +1 -1
  11. package/dist/components/autocomplete.js +1 -0
  12. package/dist/components/autocomplete.js.map +1 -1
  13. package/dist/components/combobox-core.js +1 -0
  14. package/dist/components/combobox-core.js.map +1 -1
  15. package/dist/components/combobox-suggestions.js +1 -0
  16. package/dist/components/combobox-suggestions.js.map +1 -1
  17. package/dist/components/currency-select.d.ts +28 -2
  18. package/dist/components/currency-select.js +56 -10
  19. package/dist/components/currency-select.js.map +1 -1
  20. package/dist/components/data-table-mobile.d.ts +11 -2
  21. package/dist/components/data-table-mobile.js +62 -8
  22. package/dist/components/data-table-mobile.js.map +1 -1
  23. package/dist/components/data-table-row.d.ts +1 -1
  24. package/dist/components/data-table-row.js.map +1 -1
  25. package/dist/components/data-table.js +3 -1
  26. package/dist/components/data-table.js.map +1 -1
  27. package/dist/components/dropdown.js +1 -0
  28. package/dist/components/dropdown.js.map +1 -1
  29. package/dist/components/full-bleed-dialog.js +1 -0
  30. package/dist/components/full-bleed-dialog.js.map +1 -1
  31. package/dist/components/modal.js +1 -0
  32. package/dist/components/modal.js.map +1 -1
  33. package/dist/components/number-input.d.ts +2 -1
  34. package/dist/components/number-input.js +6 -2
  35. package/dist/components/number-input.js.map +1 -1
  36. package/dist/components/numpad-sheet.js +2 -0
  37. package/dist/components/numpad-sheet.js.map +1 -1
  38. package/dist/components/picker-sheet.js +2 -0
  39. package/dist/components/picker-sheet.js.map +1 -1
  40. package/dist/components/popover.js +1 -0
  41. package/dist/components/popover.js.map +1 -1
  42. package/dist/eslint/index.js +1 -1
  43. package/dist/i18n/defaults.js +2 -0
  44. package/dist/i18n/defaults.js.map +1 -1
  45. package/dist/i18n/german.js +14 -1
  46. package/dist/i18n/german.js.map +1 -1
  47. package/dist/i18n/kit-labels.d.ts +3 -0
  48. package/dist/i18n/kit-labels.js.map +1 -1
  49. package/dist/i18n/locales/en.js +16 -1
  50. package/dist/i18n/locales/en.js.map +3 -3
  51. package/dist/i18n/locales/es.js +14 -1
  52. package/dist/i18n/locales/es.js.map +1 -1
  53. package/dist/i18n/locales/fr.js +14 -1
  54. package/dist/i18n/locales/fr.js.map +1 -1
  55. package/dist/i18n/locales/hu.js +14 -1
  56. package/dist/i18n/locales/hu.js.map +1 -1
  57. package/dist/i18n/locales/it.js +14 -1
  58. package/dist/i18n/locales/it.js.map +1 -1
  59. package/dist/i18n/locales/zh.js +14 -1
  60. package/dist/i18n/locales/zh.js.map +1 -1
  61. package/dist/i18n/review.js +1 -0
  62. package/dist/i18n/review.js.map +1 -1
  63. package/dist/lib/data-attributes.d.ts +11 -0
  64. package/dist/lib/data-attributes.js +10 -0
  65. package/dist/lib/data-attributes.js.map +1 -0
  66. package/dist/lib/format.d.ts +1 -0
  67. package/dist/lib/format.js +2 -0
  68. package/dist/lib/format.js.map +1 -1
  69. package/dist/lib/minus.d.ts +10 -0
  70. package/dist/lib/minus.js.map +1 -1
  71. package/dist/testing/react.d.ts +25 -0
  72. package/dist/testing/react.js +19 -0
  73. package/dist/testing/react.js.map +1 -0
  74. package/dist/tour/tour-dock.d.ts +42 -0
  75. package/dist/tour/tour-dock.js +80 -0
  76. package/dist/tour/tour-dock.js.map +1 -0
  77. package/dist/tour/tour-gates.d.ts +71 -0
  78. package/dist/tour/tour-gates.js +114 -0
  79. package/dist/tour/tour-gates.js.map +1 -0
  80. package/dist/tour/tour-guide-button.d.ts +27 -0
  81. package/dist/tour/tour-guide-button.js +45 -0
  82. package/dist/tour/tour-guide-button.js.map +1 -0
  83. package/dist/tour/tour-guide-labels.d.ts +8 -0
  84. package/dist/tour/tour-guide-labels.js +9 -0
  85. package/dist/tour/tour-guide-labels.js.map +1 -0
  86. package/dist/tour/tour-labels.js +9 -1
  87. package/dist/tour/tour-labels.js.map +1 -1
  88. package/dist/tour/tour.d.ts +133 -9
  89. package/dist/tour/tour.js +594 -70
  90. package/dist/tour/tour.js.map +1 -1
  91. package/dist/tour/use-tour-url-sync.d.ts +5 -2
  92. package/dist/tour/use-tour-url-sync.js +7 -1
  93. package/dist/tour/use-tour-url-sync.js.map +1 -1
  94. package/dist/tour.d.ts +2 -0
  95. package/dist/tour.js +3 -0
  96. package/dist/tour.js.map +1 -1
  97. package/dist/wizard/stepper-nav.js +15 -2
  98. package/dist/wizard/stepper-nav.js.map +1 -1
  99. package/dist/wizard/types.d.ts +11 -0
  100. package/dist/wizard/types.js.map +1 -1
  101. package/dist/wizard/use-wizard.js +11 -5
  102. package/dist/wizard/use-wizard.js.map +1 -1
  103. package/package.json +9 -1
  104. package/src/admin/user-roster.tsx +22 -12
  105. package/src/auth/verify-email.tsx +10 -0
  106. package/src/components/amount-input.tsx +5 -4
  107. package/src/components/autocomplete.tsx +2 -0
  108. package/src/components/combobox-core.tsx +2 -0
  109. package/src/components/combobox-suggestions.tsx +2 -0
  110. package/src/components/currency-select.tsx +95 -6
  111. package/src/components/data-table-mobile.tsx +99 -8
  112. package/src/components/data-table-row.tsx +1 -0
  113. package/src/components/data-table.tsx +9 -1
  114. package/src/components/dropdown.tsx +2 -0
  115. package/src/components/full-bleed-dialog.tsx +3 -0
  116. package/src/components/modal.tsx +3 -0
  117. package/src/components/number-input.tsx +8 -3
  118. package/src/components/numpad-sheet.tsx +4 -0
  119. package/src/components/picker-sheet.tsx +4 -0
  120. package/src/components/popover.tsx +2 -0
  121. package/src/i18n/defaults.ts +2 -0
  122. package/src/i18n/german.ts +13 -0
  123. package/src/i18n/kit-labels.tsx +3 -0
  124. package/src/i18n/locales/es.ts +13 -0
  125. package/src/i18n/locales/fr.ts +13 -0
  126. package/src/i18n/locales/hu.ts +13 -0
  127. package/src/i18n/locales/it.ts +13 -0
  128. package/src/i18n/locales/zh.ts +13 -0
  129. package/src/i18n/review.ts +1 -0
  130. package/src/lib/data-attributes.ts +14 -0
  131. package/src/lib/format.ts +2 -0
  132. package/src/lib/minus.ts +10 -0
  133. package/src/testing/react.ts +59 -0
  134. package/src/tour/tour-dock.ts +132 -0
  135. package/src/tour/tour-gates.ts +195 -0
  136. package/src/tour/tour-guide-button.tsx +64 -0
  137. package/src/tour/tour-guide-labels.ts +15 -0
  138. package/src/tour/tour-labels.ts +8 -0
  139. package/src/tour/tour.tsx +949 -86
  140. package/src/tour/use-tour-url-sync.ts +14 -4
  141. package/src/tour.ts +3 -0
  142. package/src/wizard/stepper-nav.tsx +23 -3
  143. package/src/wizard/types.ts +12 -0
  144. package/src/wizard/use-wizard.ts +16 -9
@@ -41,10 +41,11 @@ import {
41
41
  } from "./field-parts";
42
42
  import { useEndWidth, useNumericField } from "./numeric-field";
43
43
  import type { CommitScope } from "./write-lock";
44
+ import { dataAttributes, type DataAttributes } from "../lib/data-attributes";
44
45
 
45
46
  /** {@link AmountInput}'s props — exported since 0.34 (refactor audit 2026-10, API-9): a
46
47
  * wrapper typed itself off `ComponentProps<typeof AmountInput>` until then. */
47
- export interface AmountInputProps extends FieldHelpProps {
48
+ export interface AmountInputProps extends FieldHelpProps, DataAttributes {
48
49
  value: string;
49
50
  onChange: (value: string) => void;
50
51
  currency?: string;
@@ -427,8 +428,8 @@ export function AmountInput(props: AmountInputProps) {
427
428
  const { lock, locked, off, isMobile, asDisplay, showCalc, showNumpad, setFocused, mark, draft, setDraft, derived } =
428
429
  numeric;
429
430
  const invalid = numeric.isInvalid;
430
- // A locked chip is a read-out: picking a currency is a change too.
431
- const editable = !!onCurrencyChange && !locked;
431
+ // A locked or disabled chip is a read-out: picking a currency is a change too.
432
+ const editable = !!onCurrencyChange && !locked && !off;
432
433
  // Room for the trailing controls — the calculator and the currency chip — once
433
434
  // measured, as NumberInput reserves its own (keksdose live #367, DUP-10): their
434
435
  // width, the track's `end-1` inset in the boxed shape, and NumberInput's 4px gap.
@@ -533,7 +534,7 @@ export function AmountInput(props: AmountInputProps) {
533
534
  const selected = getCurrency(currency);
534
535
 
535
536
  return (
536
- <div className={cn("w-full", className)}>
537
+ <div {...dataAttributes(props)} className={cn("w-full", className)}>
537
538
  {/* Inner relative box holds the input + its absolutely-positioned
538
539
  trailing controls (calculator / currency), so those stay anchored to
539
540
  the INPUT even when the mobile math bar is rendered below (#334). It is
@@ -519,6 +519,8 @@ export function Autocomplete<V extends string | number = string>(props: Autocomp
519
519
  // Every press inside the list keeps focus in the field — rows, the status
520
520
  // line, the scrollbar — or the input's blur would close the list first.
521
521
  role="presentation"
522
+ // A kit layer, for the tour's layer order (0.50, docs/tours-harmonization.md §11.6).
523
+ data-kit-layer=""
522
524
  // Portalled out of the form's `dir`; the field's is put back. The list is
523
525
  // exactly the field's width, so `left` places it in either direction.
524
526
  dir={dir}
@@ -1064,6 +1064,8 @@ export function ComboboxPanel<V extends string | number>({
1064
1064
  // `aria-describedby` has nothing to collide with.
1065
1065
  {...rest}
1066
1066
  ref={panelRef}
1067
+ // A kit layer, for the tour's layer order (0.50, docs/tours-harmonization.md §11.6).
1068
+ data-kit-layer=""
1067
1069
  dir={dir}
1068
1070
  onKeyDown={onKeyDown}
1069
1071
  // A press in a panel opened from a row is inside this list for the core's outside
@@ -92,6 +92,8 @@ export function SuggestionList({
92
92
  return createPortal(
93
93
  <div
94
94
  ref={panelRef}
95
+ // A kit layer, for the tour's layer order (0.50, docs/tours-harmonization.md §11.6).
96
+ data-kit-layer=""
95
97
  // z-50, not the old z-30: the list is a child of <body> now, so it is
96
98
  // competing with the app's own overlays rather than with its own siblings.
97
99
  className="fixed z-50"
@@ -2,7 +2,16 @@ import { useCallback, useId } from "react";
2
2
  import type { ComponentPropsWithoutRef, ReactNode, Ref } from "react";
3
3
  import { FieldChevron, FIELD_TRIGGER, FIELD_INVALID, FIELD_FLOATING_PAD } from "./field-base";
4
4
  import { FieldLabel } from "./field-parts";
5
- import { assignRef, EndHintRow, useFieldMessages, type FieldHelpProps } from "./field-parts";
5
+ import {
6
+ assignRef,
7
+ EndHintRow,
8
+ LockedReason,
9
+ useFieldMessages,
10
+ useLockReason,
11
+ type DisabledReasonDisplay,
12
+ type FieldHelpProps,
13
+ } from "./field-parts";
14
+ import type { CommitScope } from "./write-lock";
6
15
  import { cn } from "../lib/cn";
7
16
  import { useComboboxCore } from "./combobox-core";
8
17
  import {
@@ -90,6 +99,31 @@ export interface CurrencySelectProps extends Omit<ComponentPropsWithoutRef<"div"
90
99
  * filter that only matches the hidden English name is worse than no filter.
91
100
  */
92
101
  currencyNames?: Record<string, string>;
102
+ /** As {@link Select}'s: no focus, no list, the settled look, no chevron. */
103
+ disabled?: boolean;
104
+ /**
105
+ * Why the currency cannot be changed — {@link Select}'s `disabledReason` (keksdose,
106
+ * 2026-10-23: a budget's currency for a guest or a viewer, and while offline, which
107
+ * keksdose drew as a one-option `Select` to keep the reason and lost the flag). The
108
+ * trigger stays focusable and `aria-disabled` (never the `disabled` attribute), keeps
109
+ * its flag and code, opens nothing, and `onChange` is never called; the reason is a
110
+ * line under the field at Large and on a touch screen, else in the kit
111
+ * {@link Tooltip} (`disabledReasonDisplay`), and on the trigger's `aria-describedby`
112
+ * (after a caption, before an error). It wears the settled look of a disabled field
113
+ * and drops its chevron, as a locked Select does. It wins over `disabled`.
114
+ */
115
+ disabledReason?: ReactNode;
116
+ /** Where the reason shows — {@link Select}'s `disabledReasonDisplay`: a line under the
117
+ * field at Large and on a touch screen by default, the tooltip otherwise. */
118
+ disabledReasonDisplay?: DisabledReasonDisplay;
119
+ /**
120
+ * This picker COMMITS — choosing saves. Under a locked {@link WriteLockProvider} it is
121
+ * locked the `disabledReason` way with the lock's reason (which wins over its own).
122
+ * No provider, or an unlocked one: no effect. {@link Select}'s `commit`. A currency
123
+ * field inside a form with its own Save stays editable under the lock — leave this off
124
+ * there, and put `commit` on the Save.
125
+ */
126
+ commit?: CommitScope;
93
127
  /** The TRIGGER — the focusable `<button>` — as a React 19 ref prop, as on
94
128
  * {@link MultiSelect} and {@link CountrySelect}: react-hook-form's focus-on-error
95
129
  * calls `focus()` on it (0.34, API-3, refactor audit 2026-10). Not the wrapper. */
@@ -109,6 +143,10 @@ export function CurrencySelect({
109
143
  help,
110
144
  labels,
111
145
  currencyNames,
146
+ disabled,
147
+ disabledReason,
148
+ disabledReasonDisplay,
149
+ commit,
112
150
  "aria-label": ariaLabel,
113
151
  "aria-describedby": ariaDescribedBy,
114
152
  "aria-invalid": ariaInvalid,
@@ -137,6 +175,15 @@ export function CurrencySelect({
137
175
 
138
176
  const selected = getCurrency(value);
139
177
 
178
+ // The lock — Select's (`useLockReason`): the WriteLock's reason under `commit`, else
179
+ // the caller's own. Locked or `disabled`, nothing opens the list or picks a value.
180
+ const lock = useLockReason(commit, disabledReason);
181
+ const locked = lock.locked;
182
+ const inert = locked || Boolean(disabled);
183
+ // A lock (or `disabled`) arriving while the list is up closes it — adjusted while
184
+ // rendering, as CountrySelect does, so no frame shows a list that cannot be used.
185
+ if (inert && open) setOpen(false);
186
+
140
187
  const listboxId = `${useId()}-listbox`;
141
188
  // Text is a caption under the field; anything else (a FieldHint) rides the label
142
189
  // line — and a FieldHint is that caption at Large and on touch (§4). See `hint`. On
@@ -152,6 +199,8 @@ export function CurrencySelect({
152
199
  invalid,
153
200
  describedBy: ariaDescribedBy,
154
201
  ariaInvalid,
202
+ // A lock's reason sits between the caption and the error, as on Select.
203
+ reasonId: locked && lock.reasonId,
155
204
  });
156
205
  const { labelHint, describedBy, isInvalid } = field;
157
206
 
@@ -169,10 +218,21 @@ export function CurrencySelect({
169
218
  ref={setTrigger}
170
219
  type="button"
171
220
  aria-label={name}
172
- onClick={() => setOpen((v) => !v)}
221
+ // The focusable kind of disabled while locked (Select's rule): native `disabled`
222
+ // would take the field out of the tab order, and the reason with it.
223
+ disabled={locked ? undefined : disabled}
224
+ aria-disabled={locked || undefined}
225
+ onClick={(e) => {
226
+ if (inert) {
227
+ e.preventDefault();
228
+ return;
229
+ }
230
+ setOpen((v) => !v);
231
+ }}
173
232
  // Down/Up opens the list from the closed trigger, per the APG; Enter and Space
174
233
  // already do through the button's own click.
175
234
  onKeyDown={(e) => {
235
+ if (inert) return;
176
236
  if (e.key === "ArrowDown" || e.key === "ArrowUp") {
177
237
  e.preventDefault();
178
238
  setOpen(true);
@@ -191,12 +251,27 @@ export function CurrencySelect({
191
251
  aria-expanded={open}
192
252
  aria-invalid={isInvalid || undefined}
193
253
  aria-describedby={describedBy}
194
- className={cn(FIELD_TRIGGER, "pe-9", label !== undefined && FIELD_FLOATING_PAD, isInvalid && FIELD_INVALID)}
254
+ className={cn(
255
+ FIELD_TRIGGER,
256
+ "pe-9",
257
+ label !== undefined && FIELD_FLOATING_PAD,
258
+ // FIELD_BASE's `disabled:` look, for a trigger locked without being `disabled`
259
+ // (the `:disabled` variants cannot match it) — Select's FIELD_LOCKED. No hover:
260
+ // nothing here answers a press.
261
+ locked && "cursor-not-allowed bg-surface-2 text-muted hover:bg-surface-2",
262
+ isInvalid && FIELD_INVALID,
263
+ )}
195
264
  >
196
265
  {/* The trigger stays COMPACT — flag + code only — so it never clips in a
197
266
  narrow field; the full names live in the (wider) popup (feedback
198
267
  #308). */}
199
- <span className={cn("flex items-center gap-2 min-w-0", selected ? "text-primary" : "text-placeholder")}>
268
+ <span
269
+ className={cn(
270
+ "flex items-center gap-2 min-w-0",
271
+ // The settled look reaches the code too: `text-primary` here would outweigh it.
272
+ !selected ? "text-placeholder" : inert ? "text-muted" : "text-primary",
273
+ )}
274
+ >
200
275
  {selected ? (
201
276
  <>
202
277
  <CurrencyFlag country={selected.country} />
@@ -206,10 +281,23 @@ export function CurrencySelect({
206
281
  placeholder ?? text.currency
207
282
  )}
208
283
  </span>
209
- <FieldChevron />
284
+ {/* No menu to drop while disabled or locked, so no chevron — the arrow is the one
285
+ thing on the control that promises a choice (dev#474, as on Select). */}
286
+ {!inert && <FieldChevron />}
210
287
  </button>
211
288
  );
212
289
 
290
+ // Select's frame: the line under the trigger, or the Tooltip with a hidden copy for
291
+ // `aria-describedby`. The wrapper `<div>` keeps `className`, so the layout holds.
292
+ // `flex w-full items-stretch`: the trigger keeps the field's full width in it.
293
+ const control = locked ? (
294
+ <LockedReason lock={lock} display={disabledReasonDisplay} className="flex w-full items-stretch">
295
+ {trigger}
296
+ </LockedReason>
297
+ ) : (
298
+ trigger
299
+ );
300
+
213
301
  return (
214
302
  // The wrapper takes `rest`; the trigger takes the name. This field's label is a
215
303
  // floating <span> rather than a <label for>, so a caller who wants the control to
@@ -230,7 +318,7 @@ export function CurrencySelect({
230
318
  ))}
231
319
  {/* No label line to ride: at the field's end edge, outside the box, as an
232
320
  unlabelled Select places its FieldHint. */}
233
- <EndHintRow hint={label === undefined ? labelHint : undefined}>{trigger}</EndHintRow>
321
+ <EndHintRow hint={label === undefined ? labelHint : undefined}>{control}</EndHintRow>
234
322
  {field.below}
235
323
  {/* Portalled to <body> and anchored to the trigger (dev#548), so where it stands
236
324
  in the flow no longer moves it. */}
@@ -246,6 +334,7 @@ export function CurrencySelect({
246
334
  // to say what it asks for (live #200).
247
335
  sheetTitle={label ?? text.currency}
248
336
  onChoose={(code) => {
337
+ if (inert) return;
249
338
  onChange(code);
250
339
  // Back to the trigger: the panel holding focus is about to unmount, and a
251
340
  // keyboard user who has answered the field should stand on it, ready to Tab on.
@@ -72,7 +72,18 @@ const CARD_NO_TRUNCATE =
72
72
  * BETWEEN facts rather than between a label and its own value. `relative` holds a
73
73
  * header's `sr-only` text (an actions column's), as every kit container must.
74
74
  */
75
- export function StackedFields<T>({ columns, row, compact }: { columns: DataTableColumn<T>[]; row: T; compact: boolean }) {
75
+ export function StackedFields<T>({
76
+ columns,
77
+ row,
78
+ compact,
79
+ liftOwnClicks = false,
80
+ }: {
81
+ columns: DataTableColumn<T>[];
82
+ row: T;
83
+ compact: boolean;
84
+ /** On a stretched card (0.50 K4): lift the own-click fields above its title link. */
85
+ liftOwnClicks?: boolean;
86
+ }) {
76
87
  const fields = filledFields(columns, row);
77
88
  if (fields.length === 0) return null;
78
89
  return (
@@ -85,7 +96,7 @@ export function StackedFields<T>({ columns, row, compact }: { columns: DataTable
85
96
  <dt className="text-xs uppercase tracking-wide text-muted">{col.header}</dt>
86
97
  <dd
87
98
  {...dataAttrsOf(col.cellProps?.(row))}
88
- className="mt-0.5 min-w-0 break-words text-secondary"
99
+ className={cn("mt-0.5 min-w-0 break-words text-secondary", liftOwnClicks && keepsOwnClicks(col) && LIFTED_FIELD)}
89
100
  >
90
101
  {value}
91
102
  </dd>
@@ -95,6 +106,21 @@ export function StackedFields<T>({ columns, row, compact }: { columns: DataTable
95
106
  );
96
107
  }
97
108
 
109
+ /**
110
+ * A stretched card's own-click cell (0.50 K4): above the title control's overlay
111
+ * (`after:z-[1]`), so its link or buttons take their own taps. `w-fit` keeps the lifted
112
+ * box to the cell's content, so the field's empty rest still opens the row.
113
+ */
114
+ const LIFTED_CELL = "relative z-[2]";
115
+ const LIFTED_FIELD = cn(LIFTED_CELL, "w-fit max-w-full justify-self-start");
116
+
117
+ /**
118
+ * The keyboard frame of a stretched card (0.50 K4): FOCUS_RING's, inside the card,
119
+ * while its title control (`data-card-link`) has the focus.
120
+ */
121
+ const STRETCHED_RING =
122
+ "has-[[data-card-link]:focus-visible]:ring-inset has-[[data-card-link]:focus-visible]:ring-focus has-[[data-card-link]:focus-visible]:ring-brand";
123
+
98
124
  /** The most fields the default phone card shows under its title (0.49 KIT-7). */
99
125
  export const MOBILE_CARD_FIELDS = 3;
100
126
 
@@ -308,6 +334,13 @@ export interface MobileRowProps<T> {
308
334
  mobileDir: Direction;
309
335
  /** No column the card renders owns a link, so the card itself may be one. */
310
336
  mobileCardLinkable: boolean;
337
+ /**
338
+ * The card draws cells that keep their own clicks, so it cannot be one control (0.50
339
+ * K4): its TITLE is the row's link (`rowHref`) or button (`onRowClick`), stretched over
340
+ * the card, and those cells sit above it. Only for the default card, never a
341
+ * `mobileCard`, and never when the title itself keeps its own clicks.
342
+ */
343
+ mobileCardStretched: boolean;
311
344
  mobileCard: DataTableProps<T>["mobileCard"];
312
345
  mobilePrimaryCol: DataTableColumn<T> | null;
313
346
  /** The `role: "status"` columns, drawn as chips beside the title (0.49 KIT-7). */
@@ -349,6 +382,7 @@ export function MobileRow<T>({
349
382
  swipeActionsFor,
350
383
  mobileDir,
351
384
  mobileCardLinkable,
385
+ mobileCardStretched,
352
386
  mobileCard,
353
387
  mobilePrimaryCol,
354
388
  mobileStatusColumns,
@@ -389,7 +423,10 @@ export function MobileRow<T>({
389
423
  // scrolling never meet the gesture. Until 0.40 an open card silently lost it.
390
424
  // In selection mode a sideways drag would be a second meaning for a selecting touch.
391
425
  const swipeEnabled = !!swipe && !selecting;
392
- const href = mobileCardLinkable ? rowHref?.(row) : undefined;
426
+ // The stretched card (0.50 K4): the title is the control, the card a plain box.
427
+ // Selection mode keeps its own card (the whole card is the toggle there).
428
+ const stretched = mobileCardStretched && interactive && !selecting && !mobileCard && !!mobilePrimaryCol;
429
+ const href = mobileCardLinkable || stretched ? rowHref?.(row) : undefined;
393
430
  const cardClass = cn(
394
431
  "w-full text-start flex items-center",
395
432
  // §4: more room per row at Large — a taller row is a bigger tap target too.
@@ -409,7 +446,13 @@ export function MobileRow<T>({
409
446
  // AppShell main's `overflow-x-clip` — so the frame went missing on one side. A press
410
447
  // keeps the fill and the squeeze above; an outline on every tap of a long list would
411
448
  // be louder than the acknowledgement it replaces.
412
- (interactive || selecting) && cn("[-webkit-tap-highlight-color:transparent] focus-visible:ring-inset", FOCUS_RING),
449
+ (interactive || selecting) &&
450
+ !stretched &&
451
+ cn("[-webkit-tap-highlight-color:transparent] focus-visible:ring-inset", FOCUS_RING),
452
+ // A stretched card (0.50 K4) draws the same frame, inside the card, while its TITLE
453
+ // control has the keyboard: the ring outlines the whole tap target, as on every other
454
+ // card, not the title's line alone. The own-click cells draw their own.
455
+ stretched && cn("relative [-webkit-tap-highlight-color:transparent]", STRETCHED_RING),
413
456
  selecting && "cursor-pointer",
414
457
  select?.selected && selecting && "bg-brand-soft",
415
458
  // A 500 ms hold would otherwise start a text selection, and iOS's link callout —
@@ -424,6 +467,45 @@ export function MobileRow<T>({
424
467
  // exactly one anchor exists per row.
425
468
  // The default card's filled fields and status values (0.49 KIT-7): an empty one
426
469
  // gets no line. Not computed for a `mobileCard`, which draws its own.
470
+ // The stretched card's title control (0.50 K4): the row's link, or a real <button>
471
+ // when the table only has `onRowClick` — a plain control, since nothing interactive
472
+ // sits inside it any more. Its `::after` covers the whole card, so a tap anywhere
473
+ // but on an own-click cell (lifted above it) lands on it, and a long press on the
474
+ // card is a long press on the link ("open in new tab"). Its accessible name is the
475
+ // title alone; the fields read as text after it. `data-row-link` marks it as the
476
+ // row itself for `fromOwnControl`, so the long press still arms on it.
477
+ const stretchedClass =
478
+ "block w-full text-start focus-visible:outline-none after:absolute after:inset-0 after:z-[1] after:content-['']";
479
+ const titleControl = (title: ReactNode) =>
480
+ href ? (
481
+ <RowLink
482
+ href={href}
483
+ data-card-link=""
484
+ data-row-link=""
485
+ onActivate={() => onRowClick!(row)}
486
+ aria-expanded={opensRow ? expanded : undefined}
487
+ className={stretchedClass}
488
+ // Enter follows RowLink's own onClick; Space opens the row too, not the list's scroll.
489
+ onKeyDown={(e) => {
490
+ if (e.key !== " ") return;
491
+ e.preventDefault();
492
+ onRowClick!(row);
493
+ }}
494
+ >
495
+ {title}
496
+ </RowLink>
497
+ ) : (
498
+ <button
499
+ type="button"
500
+ data-card-link=""
501
+ data-row-link=""
502
+ aria-expanded={opensRow ? expanded : undefined}
503
+ onClick={() => onRowClick!(row)}
504
+ className={cn(stretchedClass, "font-[inherit]")}
505
+ >
506
+ {title}
507
+ </button>
508
+ );
427
509
  const fields = mobileCard ? [] : filledFields(mobileSecondaryColumns, row);
428
510
  const statuses = mobileCard ? [] : filledFields(mobileStatusColumns, row);
429
511
  const actionCells = mobileCard ? [] : filledFields(mobileActionColumns, row);
@@ -455,7 +537,7 @@ export function MobileRow<T>({
455
537
  data-card-title=""
456
538
  className="min-w-0 flex-1 break-words font-medium"
457
539
  >
458
- {mobilePrimaryCol.cell(row)}
540
+ {stretched ? titleControl(mobilePrimaryCol.cell(row)) : mobilePrimaryCol.cell(row)}
459
541
  </div>
460
542
  )}
461
543
  {statuses.length > 0 && (
@@ -472,7 +554,7 @@ export function MobileRow<T>({
472
554
  {fields.length > 0 && large ? (
473
555
  // §4: each label ABOVE its value at Large. Side by side, a label column
474
556
  // sized to the widest label leaves a value a sliver of a 240 px card.
475
- <StackedFields columns={mobileSecondaryColumns} row={row} compact={compact} />
557
+ <StackedFields columns={mobileSecondaryColumns} row={row} compact={compact} liftOwnClicks={stretched} />
476
558
  ) : fields.length > 0 && (
477
559
  <dl
478
560
  className={cn(
@@ -487,7 +569,7 @@ export function MobileRow<T>({
487
569
  </dt>
488
570
  <dd
489
571
  {...dataAttrsOf(col.cellProps?.(row))}
490
- className="min-w-0 text-secondary self-center"
572
+ className={cn("min-w-0 text-secondary self-center", stretched && keepsOwnClicks(col) && LIFTED_FIELD)}
491
573
  >
492
574
  {value}
493
575
  </dd>
@@ -500,7 +582,11 @@ export function MobileRow<T>({
500
582
  {actionCells.length > 0 && (
501
583
  <div data-card-actions="" className="flex min-w-0 flex-wrap items-center justify-end gap-2">
502
584
  {actionCells.map(({ col, value }) => (
503
- <div key={col.key} {...dataAttrsOf(col.cellProps?.(row))} className="flex min-w-0 justify-end">
585
+ <div
586
+ key={col.key}
587
+ {...dataAttrsOf(col.cellProps?.(row))}
588
+ className={cn("flex min-w-0 justify-end", stretched && LIFTED_CELL)}
589
+ >
504
590
  {value}
505
591
  </div>
506
592
  ))}
@@ -592,6 +678,11 @@ export function MobileRow<T>({
592
678
  />
593
679
  {cardInner}
594
680
  </div>
681
+ ) : stretched ? (
682
+ // The stretched card (0.50 K4): a plain box; its title is the control (above).
683
+ <div {...press} data-card-stretched="" className={cardClass}>
684
+ {cardInner}
685
+ </div>
595
686
  ) : interactive && href ? (
596
687
  // A card with a URL of its own is a LINK, not a div wearing role="button"
597
688
  // (feedback #451): the phone's long-press "open in new tab" and a screen
@@ -404,6 +404,7 @@ export interface DataTableMobileRowProps<T>
404
404
  | "opensRow"
405
405
  | "mobileDir"
406
406
  | "mobileCardLinkable"
407
+ | "mobileCardStretched"
407
408
  | "mobileCard"
408
409
  | "mobilePrimaryCol"
409
410
  | "mobileStatusColumns"
@@ -1483,6 +1483,13 @@ export function DataTable<T>({
1483
1483
  // renderer is opaque to us; a link inside one is the caller's to declare by
1484
1484
  // marking the column it comes from.
1485
1485
  const mobileCardLinkable = (mobileCard ? mobileColumns : [...onCard]).every((c) => !keepsOwnClicks(c));
1486
+ // ...except on the default card (0.50 K4, kastlan): its title becomes the row's link
1487
+ // (or, with only `onRowClick`, a button), stretched over the card, and the own-click
1488
+ // cells — a tel: link, the actions row — sit above it, so "tap to open" stays and no
1489
+ // control nests in another. A `mobileCard` stays the fallback above (its controls
1490
+ // cannot be lifted), and so does a card whose title keeps its own clicks.
1491
+ const mobileCardStretched =
1492
+ !mobileCard && !mobileCardLinkable && !!mobilePrimaryCol && !keepsOwnClicks(mobilePrimaryCol);
1486
1493
  // The card list's reading direction, for the one thing on it CSS cannot flip: which
1487
1494
  // physical drag a logical `start`/`end` swipe action means. Read off the DOM through
1488
1495
  // a ref callback (it runs at commit, like a layout effect) because `dir` is an
@@ -1646,10 +1653,11 @@ export function DataTable<T>({
1646
1653
  className={rowClassName?.(row)}
1647
1654
  attrs={rowAttributes?.(row)}
1648
1655
  interactive={!!onRowClick}
1649
- href={mobileCardLinkable ? rowHref?.(row) : undefined}
1656
+ href={mobileCardLinkable || mobileCardStretched ? rowHref?.(row) : undefined}
1650
1657
  swipeActionsFor={swipeActionsFor}
1651
1658
  mobileDir={mobileDir}
1652
1659
  mobileCardLinkable={mobileCardLinkable}
1660
+ mobileCardStretched={mobileCardStretched}
1653
1661
  mobileCard={mobileCard}
1654
1662
  mobilePrimaryCol={mobilePrimaryCol}
1655
1663
  mobileStatusColumns={mobileStatusColumns}
@@ -432,6 +432,8 @@ export function DropdownPanel({
432
432
  <div
433
433
  {...rest}
434
434
  ref={setPanel}
435
+ // A kit layer, for the tour's layer order (0.50, docs/tours-harmonization.md §11.6).
436
+ data-kit-layer=""
435
437
  dir={dir}
436
438
  onKeyDown={handleKeyDown}
437
439
  onPointerDownCapture={handlePointerDownCapture}
@@ -208,6 +208,9 @@ export function FullBleedDialog({
208
208
  // `tabIndex` below by passing one of them too — this is the element the focus
209
209
  // trap holds, and each of those failures is invisible on screen.
210
210
  {...rest}
211
+ // A kit layer, for the tour's layer order (0.50, docs/tours-harmonization.md §11.6):
212
+ // a backdrop, so the tour measures the panel inside it to tell a full-screen sheet.
213
+ data-kit-layer="backdrop"
211
214
  className={cn(
212
215
  "fixed inset-0 z-50 flex items-stretch justify-center bg-black/40 outline-none",
213
216
  closing ? "animate-overlay-out" : "animate-overlay",
@@ -394,6 +394,9 @@ function ModalPanel({
394
394
  // that page from a screen reader for 220ms. Not `inert` — that would let a tap
395
395
  // fall through the fading backdrop to the row underneath.
396
396
  aria-hidden={exiting || undefined}
397
+ // A kit layer, for the tour's layer order (0.50, docs/tours-harmonization.md §11.6):
398
+ // a backdrop, so the tour measures the panel inside it to tell a full-screen sheet.
399
+ data-kit-layer="backdrop"
397
400
  className={cn(
398
401
  // Live #320. The panel below rises only where it is bottom-anchored: from md up
399
402
  // it is centred, and a centred box sliding up from off-screen reads as a
@@ -16,10 +16,11 @@ import { useEndWidth, useNumericField } from "./numeric-field";
16
16
  import type { CommitScope } from "./write-lock";
17
17
  import { cn } from "../lib/cn";
18
18
  import { commitExpression, formatResult } from "../lib/calc";
19
+ import { dataAttributes, type DataAttributes } from "../lib/data-attributes";
19
20
 
20
21
  /** {@link NumberInput}'s props — exported since 0.34 (refactor audit 2026-10, API-9): a
21
22
  * wrapper typed itself off `ComponentProps<typeof NumberInput>` until then. */
22
- export interface NumberInputProps extends FieldHelpProps {
23
+ export interface NumberInputProps extends FieldHelpProps, DataAttributes {
23
24
  /** The text, DOT-decimal ("1234.5", or a calculation being typed: "12+5"), as
24
25
  * `onChange` reports it — so a caller's `Number(value)` reads it. The field SHOWS it
25
26
  * in the locale's mark (see `locale`). A comma here is read as the decimal mark too,
@@ -549,12 +550,16 @@ export function NumberInput(props: NumberInputProps) {
549
550
  ) : (
550
551
  field
551
552
  );
552
- if (numeric.hint.captionText === undefined && numeric.errorEl === null && !reserve) return box;
553
+ const data = dataAttributes(props);
554
+ if (numeric.hint.captionText === undefined && numeric.errorEl === null && !reserve) {
555
+ // Bare, unless the caller tags it: then the same plain root as below carries the tags.
556
+ return Object.keys(data).length > 0 ? <div {...data}>{box}</div> : box;
557
+ }
553
558
  // Outside the field's own `relative` box, as Select's caption is: the calculator
554
559
  // and the unit are `inset-y-0` in it and would stretch down over a second line.
555
560
  // `className` stays on the field, so adding a caption cannot change what it styles.
556
561
  return (
557
- <div>
562
+ <div {...data}>
558
563
  {box}
559
564
  <FieldCaption parts={numeric.hint} />
560
565
  {numeric.errorEl}
@@ -209,6 +209,10 @@ export function NumberPadSheet({
209
209
  <div
210
210
  role="group"
211
211
  aria-label={text.panel}
212
+ // A kit layer, and the bottom layer a docked tour card stays clear of (0.50,
213
+ // docs/tours-harmonization.md §11.6).
214
+ data-kit-layer=""
215
+ data-tour-obstruction=""
212
216
  // Keep the host input focused when tapping the pad: preventDefault on
213
217
  // pointerdown blocks the focus/blur, while the buttons' click still fires.
214
218
  onPointerDown={(e) => e.preventDefault()}
@@ -211,6 +211,10 @@ export function PickerSheet({
211
211
  // caller has something to add.
212
212
  {...rest}
213
213
  ref={setSheet}
214
+ // A kit layer and a bottom layer a docked tour card stays clear of (0.50,
215
+ // docs/tours-harmonization.md §11.6).
216
+ data-kit-layer=""
217
+ data-tour-obstruction=""
214
218
  role="dialog"
215
219
  aria-modal="true"
216
220
  // Without it the container cannot take focus, and `useFocusTrap` has nothing to
@@ -206,6 +206,8 @@ export function Popover({
206
206
  // to remove one of them by accident.
207
207
  {...rest}
208
208
  ref={setPanel}
209
+ // A kit layer, for the tour's layer order (0.50, docs/tours-harmonization.md §11.6).
210
+ data-kit-layer=""
209
211
  role="dialog"
210
212
  id={panelId}
211
213
  // The caller's own `aria-label` wins over {@link PopoverLabels.panel}, which
@@ -14,6 +14,7 @@ import {
14
14
  import { DEFAULT_WIZARD_LABELS } from "../wizard/wizard-labels";
15
15
  import { DEFAULT_TOUR_LABELS } from "../tour/tour-labels";
16
16
  import { DEFAULT_TOUR_CATALOGUE_LABELS } from "../tour/tour-catalogue-labels";
17
+ import { DEFAULT_TOUR_GUIDE_LABELS } from "../tour/tour-guide-labels";
17
18
  import { DEFAULT_COMMAND_PALETTE_LABELS } from "../search/command-palette-labels";
18
19
  import { DEFAULT_GLOBAL_SEARCH_LABELS } from "../search/global-search-labels";
19
20
  import { DEFAULT_SERIES_CHART_LABELS } from "../components/series-chart-labels";
@@ -173,6 +174,7 @@ export const DEFAULT_UI_KIT_LABELS: UiKitLabels = {
173
174
  wizard: DEFAULT_WIZARD_LABELS,
174
175
  tour: DEFAULT_TOUR_LABELS,
175
176
  tourCatalogue: DEFAULT_TOUR_CATALOGUE_LABELS,
177
+ tourGuide: DEFAULT_TOUR_GUIDE_LABELS,
176
178
  commandPalette: DEFAULT_COMMAND_PALETTE_LABELS,
177
179
  globalSearch: DEFAULT_GLOBAL_SEARCH_LABELS,
178
180
  seriesChart: DEFAULT_SERIES_CHART_LABELS,
@@ -634,6 +634,19 @@ export function germanLabels(numberLocale: string): UiKitLabels {
634
634
  awaitClickHint: "Klicken Sie auf das hervorgehobene Element, um fortzufahren",
635
635
  awaitInputHint: "Geben Sie im hervorgehobenen Feld einen Wert ein, um fortzufahren", // 0.46: TourStep.awaitInput (keksdose live #436).
636
636
  step: (current, total) => `${n(current)} / ${n(total)}`,
637
+ // 0.50: guided form tours (docs/tours-harmonization.md §11).
638
+ leaveEmpty: "Leer lassen",
639
+ optional: "Optional",
640
+ gateHint: "Füllen Sie das hervorgehobene Feld aus, um fortzufahren",
641
+ gateReady: "Bereit. Drücken Sie Weiter oder die Eingabetaste, um fortzufahren.",
642
+ awaitGateHint: "Speichern Sie, um fortzufahren",
643
+ awaitGateRefused: "Das ist noch nicht gespeichert. Prüfen Sie das Formular und versuchen Sie es erneut.",
644
+ backToStep: (title) => `Zurück zu „${title}“`,
645
+ },
646
+ // 0.50: the form's "Guide me" (TourGuideButton, docs/tours-harmonization.md §11).
647
+ tourGuide: {
648
+ label: "Schritt für Schritt",
649
+ description: "Führt Sie Feld für Feld durch dieses Formular",
637
650
  },
638
651
  // 0.43.0 — docs/tours-harmonization.md §3, §8: the tours page. keksdose's reviewed words.
639
652
  tourCatalogue: {
@@ -26,6 +26,7 @@ import type { TabsLabels } from "../components/tabs";
26
26
  import type { WizardLabels } from "../wizard/types";
27
27
  import type { TourLabels } from "../tour/tour";
28
28
  import type { TourCatalogueLabels } from "../tour/tour-catalogue-labels";
29
+ import type { TourGuideLabels } from "../tour/tour-guide-labels";
29
30
  import type { CommandPaletteLabels } from "../search/command-palette";
30
31
  import type { GlobalSearchLabels } from "../search/global-search";
31
32
  import type { MonthPickerLabels } from "../components/month-picker";
@@ -335,6 +336,8 @@ export interface UiKitLabels {
335
336
  tour: TourLabels;
336
337
  /** 0.43.0: the tours page — `TourCatalogue` and its card (docs/tours-harmonization.md §3). */
337
338
  tourCatalogue: TourCatalogueLabels;
339
+ /** 0.50: the form's "Guide me" — `TourGuideButton` (docs/tours-harmonization.md §11). */
340
+ tourGuide: TourGuideLabels;
338
341
  commandPalette: CommandPaletteLabels;
339
342
  globalSearch: GlobalSearchLabels;
340
343
  seriesChart: SeriesChartLabels;
@@ -617,6 +617,19 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
617
617
  awaitClickHint: "Haga clic en el elemento resaltado para continuar",
618
618
  awaitInputHint: "Introduzca un valor en el campo resaltado para continuar", // 0.46: TourStep.awaitInput (keksdose live #436).
619
619
  step: (current, total) => `${n(current)} / ${n(total)}`,
620
+ // 0.50: guided form tours (docs/tours-harmonization.md §11).
621
+ leaveEmpty: "Dejar vacío",
622
+ optional: "Opcional",
623
+ gateHint: "Rellene el campo resaltado para continuar",
624
+ gateReady: "Listo. Pulse Siguiente o Intro para continuar.",
625
+ awaitGateHint: "Guarde para continuar",
626
+ awaitGateRefused: "Todavía no se ha guardado. Revise el formulario e inténtelo de nuevo.",
627
+ backToStep: (title) => `Volver a «${title}»`,
628
+ },
629
+ // 0.50: the form's "Guide me" (TourGuideButton, docs/tours-harmonization.md §11).
630
+ tourGuide: {
631
+ label: "Guíeme",
632
+ description: "Le guía por este formulario, campo a campo",
620
633
  },
621
634
  // 0.43.0 — docs/tours-harmonization.md §3, §8: the tours page.
622
635
  tourCatalogue: {