@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
@@ -632,6 +632,19 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
632
632
  awaitClickHint: "Cliquez sur l’élément mis en évidence pour continuer",
633
633
  awaitInputHint: "Saisissez une valeur dans le champ mis en évidence pour continuer", // 0.46: TourStep.awaitInput (keksdose live #436).
634
634
  step: (current, total) => `${n(current)} / ${n(total)}`,
635
+ // 0.50: guided form tours (docs/tours-harmonization.md §11).
636
+ leaveEmpty: "Laisser vide",
637
+ optional: "Facultatif",
638
+ gateHint: "Remplissez le champ mis en évidence pour continuer",
639
+ gateReady: "Prêt. Appuyez sur Suivant ou sur Entrée pour continuer.",
640
+ awaitGateHint: "Enregistrez pour continuer",
641
+ awaitGateRefused: "Ce n’est pas encore enregistré. Vérifiez le formulaire et réessayez.",
642
+ backToStep: (title) => `Retour à «\u202f${title}\u202f»`,
643
+ },
644
+ // 0.50: the form's "Guide me" (TourGuideButton, docs/tours-harmonization.md §11).
645
+ tourGuide: {
646
+ label: "Me guider",
647
+ description: "Vous guide dans ce formulaire, champ par champ",
635
648
  },
636
649
  // 0.43.0 — docs/tours-harmonization.md §3, §8: the tours page. keksdose's reviewed words.
637
650
  tourCatalogue: {
@@ -619,6 +619,19 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
619
619
  awaitClickHint: "A folytatáshoz kattintson a kiemelt elemre",
620
620
  awaitInputHint: "A folytatáshoz adjon meg egy értéket a kiemelt mezőben", // 0.46: TourStep.awaitInput (keksdose live #436).
621
621
  step: (current, total) => `${n(current)} / ${n(total)}`,
622
+ // 0.50: guided form tours (docs/tours-harmonization.md §11).
623
+ leaveEmpty: "Hagyja üresen",
624
+ optional: "Nem kötelező",
625
+ gateHint: "A folytatáshoz töltse ki a kiemelt mezőt",
626
+ gateReady: "Kész. A folytatáshoz nyomja meg a Tovább gombot vagy az Entert.",
627
+ awaitGateHint: "A folytatáshoz mentsen",
628
+ awaitGateRefused: "Ez még nincs mentve. Ellenőrizze az űrlapot, és próbálja újra.",
629
+ backToStep: (title) => `Vissza ide: „${title}”`,
630
+ },
631
+ // 0.50: the form's "Guide me" (TourGuideButton, docs/tours-harmonization.md §11).
632
+ tourGuide: {
633
+ label: "Vezessen végig",
634
+ description: "Mezőről mezőre végigvezeti az űrlapon",
622
635
  },
623
636
  // 0.43.0 — docs/tours-harmonization.md §3, §8: the tours page.
624
637
  tourCatalogue: {
@@ -621,6 +621,19 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
621
621
  awaitClickHint: "Faccia clic sull’elemento evidenziato per continuare",
622
622
  awaitInputHint: "Inserisca un valore nel campo evidenziato per continuare", // 0.46: TourStep.awaitInput (keksdose live #436).
623
623
  step: (current, total) => `${n(current)} / ${n(total)}`,
624
+ // 0.50: guided form tours (docs/tours-harmonization.md §11).
625
+ leaveEmpty: "Lascia vuoto",
626
+ optional: "Facoltativo",
627
+ gateHint: "Compili il campo evidenziato per continuare",
628
+ gateReady: "Pronto. Prema Avanti o Invio per continuare.",
629
+ awaitGateHint: "Salvi per continuare",
630
+ awaitGateRefused: "Non è ancora stato salvato. Controlli il modulo e riprovi.",
631
+ backToStep: (title) => `Torna a «${title}»`,
632
+ },
633
+ // 0.50: the form's "Guide me" (TourGuideButton, docs/tours-harmonization.md §11).
634
+ tourGuide: {
635
+ label: "Guidami",
636
+ description: "La guida in questo modulo, campo per campo",
624
637
  },
625
638
  // 0.43.0 — docs/tours-harmonization.md §3, §8: the tours page. keksdose's reviewed words.
626
639
  tourCatalogue: {
@@ -600,6 +600,19 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
600
600
  awaitClickHint: "点击高亮的元素以继续",
601
601
  awaitInputHint: "在高亮的字段中输入一个值以继续", // 0.46: TourStep.awaitInput (keksdose live #436).
602
602
  step: (current, total) => `${n(current)} / ${n(total)}`,
603
+ // 0.50: guided form tours (docs/tours-harmonization.md §11).
604
+ leaveEmpty: "留空",
605
+ optional: "可选",
606
+ gateHint: "填写高亮的字段以继续",
607
+ gateReady: "已就绪。按“下一步”或回车键继续。",
608
+ awaitGateHint: "保存以继续",
609
+ awaitGateRefused: "尚未保存。请检查表单后重试。",
610
+ backToStep: (title) => `返回“${title}”`,
611
+ },
612
+ // 0.50: the form's "Guide me" (TourGuideButton, docs/tours-harmonization.md §11).
613
+ tourGuide: {
614
+ label: "引导我",
615
+ description: "逐个字段引导您填写此表单",
603
616
  },
604
617
  // 0.43.0 — docs/tours-harmonization.md §3, §8: the tours page.
605
618
  tourCatalogue: {
@@ -157,6 +157,7 @@ export const KIT_LABEL_SAMPLES: Readonly<Record<string, readonly (readonly unkno
157
157
  "file.size": [[2_500_000]],
158
158
  "wizard.step": [[2, 4]],
159
159
  "tour.step": [[2, 4]],
160
+ "tour.backToStep": [["{{title}}"]],
160
161
  "globalSearch.shortcut": [["{{keys}}"]],
161
162
  "pieChart.slice": [["{{label}}", "{{value}}", "{{percent}}"]],
162
163
  "sparkline.rising": [["{{first}}", "{{last}}"]],
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A component's `data-*` props, for its root element (0.50, keksdose): `data-tour`,
3
+ * `data-testid` and the like reach the DOM even when the props are not spread there
4
+ * whole. TypeScript does not check hyphenated JSX attributes, so without this a
5
+ * `data-tour` on such a component vanished without an error.
6
+ */
7
+ export type DataAttributes = { [attribute: `data-${string}`]: string | number | boolean | undefined };
8
+
9
+ /** Only the `data-*` entries of `props`. */
10
+ export function dataAttributes(props: object): DataAttributes {
11
+ const out: DataAttributes = {};
12
+ for (const [k, v] of Object.entries(props)) if (k.startsWith("data-")) out[k as `data-${string}`] = v;
13
+ return out;
14
+ }
package/src/lib/format.ts CHANGED
@@ -5,6 +5,8 @@ import { formatWithMinus, type MinusStyle } from "./minus";
5
5
  import { roundHalfAway } from "./round";
6
6
 
7
7
  export type { MinusStyle } from "./minus";
8
+ // 0.50 (KIT-1 follow-up): the kit's minus for an app's OWN `Intl.NumberFormat` wrapper.
9
+ export { formatWithMinus } from "./minus";
8
10
 
9
11
  /**
10
12
  * Number, money, percentage, date and relative-time formatting — `Intl`, in one place,
package/src/lib/minus.ts CHANGED
@@ -24,6 +24,16 @@ const MINUS_PARTS: ReadonlySet<string> = new Set(["minusSign", "exponentMinusSig
24
24
  * `Intl` marks as a minus are touched — a hyphen inside a currency's name
25
25
  * ("Bosnia-Herzegovina convertible marks") stays — and only when the text holds the
26
26
  * other sign at all, so a positive figure costs no `formatToParts`.
27
+ *
28
+ * Public since 0.50 (the KIT-1 follow-up): an app that keeps its OWN `Intl.NumberFormat`
29
+ * wrapper (a currency with app-specific digits, a quantity) calls it in place of
30
+ * `formatter.format(value)`, so its negatives match the kit's "−". Where the wrapper only
31
+ * re-states `formatMoney` / `formatNumber`, route it through those instead.
32
+ *
33
+ * @example
34
+ * const eur = new Intl.NumberFormat("en", { style: "currency", currency: "EUR" });
35
+ * formatWithMinus(eur, -150); // "−€150.00"
36
+ * formatWithMinus(eur, -150, "ascii"); // "-€150.00", for a CSV
27
37
  */
28
38
  export function formatWithMinus(
29
39
  formatter: Intl.NumberFormat,
@@ -0,0 +1,59 @@
1
+ import { fireEvent, screen, within } from "@testing-library/react";
2
+
3
+ // `@eifi1/ui-kit/testing/react` (0.50) — helpers that drive the kit's components through
4
+ // Testing Library, from an app's own test suite.
5
+ //
6
+ // A sibling of `@eifi1/ui-kit/testing`, not part of it: that entry is pure (it reaches no
7
+ // package, so it runs under node or jsdom alike), and this one needs
8
+ // `@testing-library/react`, an optional peer only a test suite installs. Neither is part of
9
+ // the barrel, so an app's production bundle never carries either.
10
+
11
+ /** The words of the row's "⋯" button, when the app's tests render translated kit labels. */
12
+ export interface RowMenuLabels {
13
+ /** The button's name on a row with no `name`. Default "Actions". */
14
+ actions?: string;
15
+ /** The button's name given the row's. Default `Actions for ${name}`. */
16
+ actionsFor?: (name: string) => string;
17
+ }
18
+
19
+ // The kit's English defaults (DEFAULT_ROW_ACTIONS_LABELS, and the user roster's), written
20
+ // out rather than imported: the label module's type import would walk this entry into the
21
+ // components. src/testing/__tests__/react.test.tsx holds them equal.
22
+ const ACTIONS = "Actions";
23
+ const ACTIONS_FOR = (name: string) => `Actions for ${name}`;
24
+
25
+ /**
26
+ * Open a row's "⋯" menu and find one of its actions — the entry, unclicked, so a test can
27
+ * check it (locked, described) before it clicks it.
28
+ *
29
+ * `row` is the row's name as the app gave it to `RowActions` / `rowActionsColumn` /
30
+ * `UserRoster` ("Actions for <row>"); `undefined` takes the FIRST "⋯" on the page, named
31
+ * or not. Since 0.49 (KIT-12) a `tone: "danger"` action lives in that menu rather than as
32
+ * an icon in the row, and at Large text every action does.
33
+ *
34
+ * The entries are BUTTONS in a dialog popover named like the "⋯" button — not `menuitem`s:
35
+ * `getByRole("menuitem")` finds nothing there. A danger action asks for confirmation once
36
+ * clicked (an `alertdialog`), unless the app turned that off.
37
+ *
38
+ * @example
39
+ * fireEvent.click(await rowMenuAction("Ada Example", "Delete"));
40
+ * fireEvent.click(await screen.findByRole("button", { name: "Confirm" }));
41
+ */
42
+ export async function rowMenuAction(
43
+ row: string | undefined,
44
+ action: string | RegExp,
45
+ labels: RowMenuLabels = {},
46
+ ): Promise<HTMLElement> {
47
+ const actions = labels.actions ?? ACTIONS;
48
+ const actionsFor = labels.actionsFor ?? ACTIONS_FOR;
49
+ // Any row's name: the words around the name, whatever the language puts first.
50
+ const [before, after] = actionsFor("\u0000").split("\u0000");
51
+ const anyRow = (name: string) =>
52
+ name === actions ||
53
+ (name.length > before.length + after.length && name.startsWith(before) && name.endsWith(after));
54
+ const trigger = (await screen.findAllByRole("button", { name: row === undefined ? anyRow : actionsFor(row) }))[0];
55
+ fireEvent.click(trigger);
56
+ const title = trigger.getAttribute("aria-label") ?? trigger.textContent ?? "";
57
+ const menu = await screen.findByRole("dialog", { name: title });
58
+ return within(menu).getByRole("button", { name: action });
59
+ }
@@ -0,0 +1,132 @@
1
+ import type { CSSProperties } from "react";
2
+ import { readKeyboardInset } from "../hooks/use-anchored-panel";
3
+
4
+ /*
5
+ * # The docked card (docs/tours-harmonization.md §11.7, contract round 054 §6)
6
+ *
7
+ * On a phone a field step's card is pinned full-width to the top or bottom edge of the
8
+ * USABLE BAND — the visual viewport less the keyboard, raised above the highest
9
+ * `[data-tour-obstruction]` (the number pad, a picker sheet) — on the edge away from the
10
+ * spotlighted field, so it covers neither the field nor the keyboard nor the pad.
11
+ */
12
+
13
+ export type DockEdge = "top" | "bottom";
14
+
15
+ export interface DockRect {
16
+ top: number;
17
+ left: number;
18
+ width: number;
19
+ height: number;
20
+ }
21
+
22
+ /** The band, in layout-viewport px (what `position: fixed` measures from). */
23
+ export interface Band {
24
+ top: number;
25
+ bottom: number;
26
+ }
27
+
28
+ /** Below this the docked layout applies (Tailwind's `sm`). */
29
+ export const DOCK_MAX_WIDTH = 640;
30
+ /** The gutter between the card and the band's edge, and between the card and the field. */
31
+ const GUTTER = 8;
32
+ /** An obstruction whose top is within this of the band's top covers the screen. */
33
+ const FULL_SCREEN_SLACK = 64;
34
+ /** Under this much free space on its edge the card flips (the hysteresis floor). */
35
+ const MIN_DOCK = 140;
36
+
37
+ /**
38
+ * The usable band now: the visual viewport while a keyboard is up (`readKeyboardInset`'s
39
+ * rules — pinch zoom is not a keyboard, 100 px minimum), else the layout viewport; its
40
+ * bottom raised to the top of the highest visible obstruction that holds neither the field
41
+ * nor the card. An obstruction that starts at the band's top (a full-screen picker, opened
42
+ * over everything) is not a bottom layer and is ignored; a tall one (the number pad at
43
+ * Large text is over half a phone) still counts.
44
+ */
45
+ export function readBand(target: Element | null, card: Element | null): Band {
46
+ const vh = window.innerHeight;
47
+ let top = 0;
48
+ let bottom = vh;
49
+ const vv = window.visualViewport;
50
+ if (vv && readKeyboardInset() > 0) {
51
+ top = vv.offsetTop;
52
+ bottom = vv.offsetTop + vv.height;
53
+ }
54
+ for (const el of Array.from(document.querySelectorAll("[data-tour-obstruction]"))) {
55
+ if ((target && el.contains(target)) || (card && el.contains(card))) continue;
56
+ const r = el.getBoundingClientRect();
57
+ if (r.width <= 0 || r.height <= 0 || r.top <= top + FULL_SCREEN_SLACK) continue;
58
+ if (r.top < bottom) bottom = r.top;
59
+ }
60
+ return { top, bottom };
61
+ }
62
+
63
+ /** The card's free space on `edge`, between the band's edge and the spotlight. */
64
+ function room(edge: DockEdge, spot: DockRect, band: Band): number {
65
+ return edge === "bottom" ? band.bottom - (spot.top + spot.height) - GUTTER * 2 : spot.top - band.top - GUTTER * 2;
66
+ }
67
+
68
+ /**
69
+ * The edge away from the field: the bottom when the spotlight's centre is in the band's
70
+ * upper half, else the top. With a `previous` edge, it stays unless the card would have to
71
+ * cover the spotlight there (no flip on every keyboard animation frame).
72
+ */
73
+ export function dockEdge(spot: DockRect, band: Band, previous: DockEdge | null, cardHeight: number): DockEdge {
74
+ const preferred: DockEdge = spot.top + spot.height / 2 < band.top + (band.bottom - band.top) / 2 ? "bottom" : "top";
75
+ if (!previous || previous === preferred) return preferred;
76
+ return room(previous, spot, band) >= Math.min(MIN_DOCK, cardHeight) ? previous : preferred;
77
+ }
78
+
79
+ /** The docked card's position: full-width with 8 px (safe-area aware) gutters, its height
80
+ * capped to the free space so its body scrolls and its title and buttons show. */
81
+ export function dockStyle(edge: DockEdge, spot: DockRect | null, band: Band): CSSProperties {
82
+ const vh = window.innerHeight;
83
+ const cap = spot ? Math.max(MIN_DOCK / 2, room(edge, spot, band)) : band.bottom - band.top - GUTTER * 2;
84
+ const base: CSSProperties = {
85
+ left: `max(${GUTTER}px, env(safe-area-inset-left))`,
86
+ right: `max(${GUTTER}px, env(safe-area-inset-right))`,
87
+ width: "auto",
88
+ maxWidth: "none",
89
+ maxHeight: cap,
90
+ };
91
+ if (edge === "top") {
92
+ return { ...base, top: band.top <= 0 ? `max(${GUTTER}px, env(safe-area-inset-top))` : band.top + GUTTER, bottom: "auto" };
93
+ }
94
+ const fromBottom = vh - band.bottom;
95
+ return {
96
+ ...base,
97
+ top: "auto",
98
+ bottom: fromBottom <= 0 ? `max(${GUTTER}px, env(safe-area-inset-bottom))` : fromBottom + GUTTER,
99
+ };
100
+ }
101
+
102
+ /** The target's nearest ancestor that scrolls vertically now (a sheet's body), or null. */
103
+ export function scrollParent(el: Element): HTMLElement | null {
104
+ for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
105
+ const { overflowY } = getComputedStyle(node);
106
+ if ((overflowY === "auto" || overflowY === "scroll") && node.scrollHeight > node.clientHeight) return node;
107
+ }
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * Scroll the field into the half of the band away from the card, through its nearest
113
+ * scrollable ancestor (the window only when there is none — the page behind a sheet never
114
+ * moves). A field already inside that half is left where it is. Returns the edge the card
115
+ * takes.
116
+ */
117
+ export function scrollIntoFreeHalf(el: Element, card: Element | null, previous: DockEdge | null): DockEdge {
118
+ const r = el.getBoundingClientRect();
119
+ const band = readBand(el, card);
120
+ const edge = dockEdge(r, band, previous, card instanceof HTMLElement ? card.offsetHeight : MIN_DOCK);
121
+ const mid = band.top + (band.bottom - band.top) / 2;
122
+ const half = edge === "bottom" ? { top: band.top + GUTTER, bottom: mid } : { top: mid, bottom: band.bottom - GUTTER };
123
+ if (r.top >= half.top && r.top + r.height <= half.bottom) return edge;
124
+ // Centred in the half, or its top at the half's top when it is taller than the half.
125
+ const delta =
126
+ r.height > half.bottom - half.top ? r.top - half.top : r.top + r.height / 2 - (half.top + half.bottom) / 2;
127
+ const scroller = scrollParent(el);
128
+ if (scroller) scroller.scrollTop += delta;
129
+ // A field in a kit layer with nothing to scroll: the page behind it stays put.
130
+ else if (!el.closest("[data-kit-layer]")) window.scrollBy?.(0, delta);
131
+ return edge;
132
+ }
@@ -0,0 +1,195 @@
1
+ import { createContext, useContext, useLayoutEffect, useMemo, useRef } from "react";
2
+
3
+ /*
4
+ * # Value gates (docs/tours-harmonization.md §11.1)
5
+ *
6
+ * A form publishes whether a field holds what a tour waits for (`useTourGate`), a step
7
+ * names the key (`TourStep.gate`). The form knows nothing about steps, the step nothing
8
+ * about the form's state: the registry between them is a `Map` held by the provider.
9
+ *
10
+ * - Each key holds a STACK of publishers in mount order; the most recently mounted one
11
+ * is current. When it unmounts, the one below it is current again. StrictMode's
12
+ * double mount is push, pop, push.
13
+ * - Writes happen in a layout effect, only when `key` or `satisfied` changes, and never
14
+ * re-render the provider: only the overlay subscribes (`useSyncExternalStore`), and it
15
+ * is mounted only while a tour runs.
16
+ * - The same object carries the release bus of `awaitGate` steps (§11.4): `release` and
17
+ * `refuse` are EVENTS, not states, and are not buffered.
18
+ */
19
+
20
+ /** One publisher of a key. */
21
+ interface GateEntry {
22
+ satisfied: boolean;
23
+ }
24
+
25
+ /** `release(key)` / `refuse(key, reason)` from the app, as the provider hears them. */
26
+ export type GateSignal = { kind: "release"; key: string } | { kind: "refuse"; key: string; reason?: string };
27
+
28
+ export interface GateRegistry {
29
+ /** The current publisher's value; `undefined` while nobody publishes `key`. */
30
+ get(key: string): boolean | undefined;
31
+ /** Is anyone publishing `key`? */
32
+ has(key: string): boolean;
33
+ /** For `useSyncExternalStore`: bumped on every change. */
34
+ subscribe(listener: () => void): () => void;
35
+ version(): number;
36
+ push(key: string, entry: GateEntry): void;
37
+ pop(key: string, entry: GateEntry): void;
38
+ changed(key: string): void;
39
+ /** Whether a tour runs (for the two-publishers warning). */
40
+ setActive(active: boolean): void;
41
+ /** The release bus. */
42
+ listen(listener: (signal: GateSignal) => void): () => void;
43
+ emit(signal: GateSignal): void;
44
+ }
45
+
46
+ /** The kit's dev switch (as number-input's): a development build, never under vitest. */
47
+ export function devWarnings(): boolean {
48
+ return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
49
+ }
50
+
51
+ export function createGateRegistry(): GateRegistry {
52
+ let active = false;
53
+ const stacks = new Map<string, GateEntry[]>();
54
+ const listeners = new Set<() => void>();
55
+ const signals = new Set<(signal: GateSignal) => void>();
56
+ const warned = new Set<string>();
57
+ let version = 0;
58
+ const notify = (key: string) => {
59
+ version++;
60
+ const stack = stacks.get(key);
61
+ // Two publishers of one key that disagree while a tour runs: the most recent wins,
62
+ // which is right, but an app keying fields `amount` in two forms wants to hear it.
63
+ if (stack && stack.length > 1 && active && devWarnings() && !warned.has(key)) {
64
+ const values = new Set(stack.map((e) => e.satisfied));
65
+ if (values.size > 1) {
66
+ warned.add(key);
67
+ console.warn(
68
+ `[ui-kit] Two components publish the tour gate "${key}" with different values; the one mounted last ` +
69
+ "wins. Qualify gate keys by form (`txn.amount`, `account.name`).",
70
+ );
71
+ }
72
+ }
73
+ for (const listener of listeners) listener();
74
+ };
75
+ return {
76
+ get(key) {
77
+ const stack = stacks.get(key);
78
+ return stack && stack.length > 0 ? stack[stack.length - 1].satisfied : undefined;
79
+ },
80
+ has(key) {
81
+ return (stacks.get(key)?.length ?? 0) > 0;
82
+ },
83
+ subscribe(listener) {
84
+ listeners.add(listener);
85
+ return () => {
86
+ listeners.delete(listener);
87
+ };
88
+ },
89
+ version: () => version,
90
+ push(key, entry) {
91
+ const stack = stacks.get(key) ?? [];
92
+ stack.push(entry);
93
+ stacks.set(key, stack);
94
+ notify(key);
95
+ },
96
+ pop(key, entry) {
97
+ const stack = stacks.get(key);
98
+ if (!stack) return;
99
+ const at = stack.indexOf(entry);
100
+ if (at >= 0) stack.splice(at, 1);
101
+ if (stack.length === 0) stacks.delete(key);
102
+ notify(key);
103
+ },
104
+ changed: notify,
105
+ setActive(value) {
106
+ active = value;
107
+ },
108
+ listen(listener) {
109
+ signals.add(listener);
110
+ return () => {
111
+ signals.delete(listener);
112
+ };
113
+ },
114
+ emit(signal) {
115
+ for (const listener of signals) listener(signal);
116
+ },
117
+ };
118
+ }
119
+
120
+ /** What the provider shares with the gate hooks and `TourGuideButton` — internal. */
121
+ export interface TourInternals {
122
+ gates: GateRegistry;
123
+ /** Did the provider get a `launch`? */
124
+ canLaunch: boolean;
125
+ }
126
+
127
+ export const TourInternalContext = createContext<TourInternals | null>(null);
128
+
129
+ /**
130
+ * Publish whether this field holds what a tour waits for (§11.1): `useTourGate("txn.amount",
131
+ * amount !== "")` beside the field. A step with `gate: "txn.amount"` keeps its Next locked
132
+ * until it is `true`. A key no step names as `gate` is a FACT a step's `when` can ask
133
+ * (`ctx.gate("txn.transfer")`).
134
+ *
135
+ * - `key` `null`/`undefined` publishes nothing: one field component used by a create and
136
+ * an edit form takes part only in create (`mode === "create" ? "txn.memo" : null`).
137
+ * - Publish the value's state. Do not invent a "touched" gate: a field already filled
138
+ * when the step arrives (a date prefilled with today) shows Next open at once, which is
139
+ * intended.
140
+ * - Qualify keys by form (`txn.amount`, not `amount`). Two publishers of one key: the one
141
+ * mounted last wins.
142
+ * - A no-op without a `TourProvider`, so a form's tests need none. It keeps publishing
143
+ * while no tour runs, because "Guide me" can start inside a form already filled.
144
+ */
145
+ export function useTourGate(key: string | null | undefined, satisfied: boolean): void {
146
+ const gates = useContext(TourInternalContext)?.gates ?? null;
147
+ const entryRef = useRef<GateEntry | null>(null);
148
+ const satisfiedRef = useRef(satisfied);
149
+ useLayoutEffect(() => {
150
+ satisfiedRef.current = satisfied;
151
+ const entry = entryRef.current;
152
+ if (!gates || key == null || !entry || entry.satisfied === satisfied) return;
153
+ entry.satisfied = satisfied;
154
+ gates.changed(key);
155
+ }, [gates, key, satisfied]);
156
+ useLayoutEffect(() => {
157
+ if (!gates || key == null) return;
158
+ const entry: GateEntry = { satisfied: satisfiedRef.current };
159
+ entryRef.current = entry;
160
+ gates.push(key, entry);
161
+ return () => {
162
+ entryRef.current = null;
163
+ gates.pop(key, entry);
164
+ };
165
+ }, [gates, key]);
166
+ }
167
+
168
+ /** What {@link useTourGateRelease} hands back. */
169
+ export interface TourGateRelease {
170
+ /** The awaited thing happened (a save succeeded): the tour moves past the step that awaits `key`. */
171
+ release(key: string): void;
172
+ /** It was refused (a save failed): the step awaiting `key` stays and shows `reason`. */
173
+ refuse(key: string, reason?: string): void;
174
+ }
175
+
176
+ const NO_RELEASE: TourGateRelease = { release: () => {}, refuse: () => {} };
177
+
178
+ /**
179
+ * The signals of `awaitGate` steps (§11.4), usually from a mutation's `onSuccess` /
180
+ * `onError`. Stable identities. No-ops without a provider, and while no tour runs or no
181
+ * step of the run awaits the key: a release is an event and is never buffered.
182
+ */
183
+ export function useTourGateRelease(): TourGateRelease {
184
+ const gates = useContext(TourInternalContext)?.gates ?? null;
185
+ return useMemo(
186
+ () =>
187
+ gates
188
+ ? {
189
+ release: (key: string) => gates.emit({ kind: "release", key }),
190
+ refuse: (key: string, reason?: string) => gates.emit({ kind: "refuse", key, reason }),
191
+ }
192
+ : NO_RELEASE,
193
+ [gates],
194
+ );
195
+ }
@@ -0,0 +1,64 @@
1
+ import { useContext, useId } from "react";
2
+ import type { ReactElement } from "react";
3
+ import { Compass } from "lucide-react";
4
+ import { Button } from "../components/button";
5
+ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
6
+ import { useTourOptional, type TourAt } from "./tour";
7
+ import { TourInternalContext } from "./tour-gates";
8
+ import { DEFAULT_TOUR_GUIDE_LABELS, type TourGuideLabels } from "./tour-guide-labels";
9
+
10
+ export { DEFAULT_TOUR_GUIDE_LABELS };
11
+ export type { TourGuideLabels };
12
+
13
+ export interface TourGuideButtonProps {
14
+ /** The tour id handed to the provider's `launch`. */
15
+ tour: string;
16
+ /** Usually the form's first in-form anchor (`start(tour, { at })`). */
17
+ at?: TourAt;
18
+ /** Default `"ghost"`. */
19
+ variant?: "ghost" | "secondary";
20
+ /** Default `"sm"`. */
21
+ size?: "sm" | "md";
22
+ labels?: LabelOverride<TourGuideLabels>;
23
+ className?: string;
24
+ }
25
+
26
+ /**
27
+ * "Guide me" inside a form (docs/tours-harmonization.md §11.5): starts the tour `tour` by id,
28
+ * at `at`, through `TourProvider`'s `launch` — the run starts inside the open form, with no
29
+ * intro and no Back past `at`, and counts as completing the tour (Marcel, Q2).
30
+ *
31
+ * A decoration: it renders nothing without a provider, without `launch`, or while a tour
32
+ * runs. Place it in the form's header or in `FormActions`' `start` slot.
33
+ */
34
+ export function TourGuideButton({
35
+ tour,
36
+ at,
37
+ variant = "ghost",
38
+ size = "sm",
39
+ labels,
40
+ className,
41
+ }: TourGuideButtonProps): ReactElement | null {
42
+ const engine = useTourOptional();
43
+ const internals = useContext(TourInternalContext);
44
+ const words = useKitLabels("tourGuide", DEFAULT_TOUR_GUIDE_LABELS, labels);
45
+ const descriptionId = useId();
46
+ if (!engine || !internals?.canLaunch || engine.active) return null;
47
+ return (
48
+ <>
49
+ <Button
50
+ variant={variant}
51
+ size={size}
52
+ className={className}
53
+ aria-describedby={descriptionId}
54
+ onClick={() => engine.start(tour, { at })}
55
+ >
56
+ <Compass className="size-4" aria-hidden="true" />
57
+ {words.label}
58
+ </Button>
59
+ <span id={descriptionId} hidden>
60
+ {words.description}
61
+ </span>
62
+ </>
63
+ );
64
+ }
@@ -0,0 +1,15 @@
1
+ // The English words of `TourGuideButton` (docs/tours-harmonization.md §11), in a module with
2
+ // no component code, like every `*-labels.ts` (0.34, KS U8).
3
+
4
+ /** The `tourGuide` namespace: the form's "Guide me". */
5
+ export interface TourGuideLabels {
6
+ /** The button's text. */
7
+ label: string;
8
+ /** Its accessible description. */
9
+ description: string;
10
+ }
11
+
12
+ export const DEFAULT_TOUR_GUIDE_LABELS: TourGuideLabels = {
13
+ label: "Guide me",
14
+ description: "Walks you through this form, field by field",
15
+ };
@@ -12,4 +12,12 @@ export const DEFAULT_TOUR_LABELS: TourLabels = {
12
12
  awaitClickHint: "Click the highlighted element to continue",
13
13
  awaitInputHint: "Enter a value in the highlighted field to continue",
14
14
  step: (c, t) => `${c} / ${t}`,
15
+ // 0.50: guided form tours (docs/tours-harmonization.md §11).
16
+ leaveEmpty: "Leave empty",
17
+ optional: "Optional",
18
+ gateHint: "Fill in the highlighted field to continue",
19
+ gateReady: "Ready. Press Next or Enter to continue.",
20
+ awaitGateHint: "Save to continue",
21
+ awaitGateRefused: "That wasn’t saved yet. Check the form and try again.",
22
+ backToStep: (title) => `Back to “${title}”`,
15
23
  };