@eifi1/ui-kit 0.34.0 → 0.35.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 (241) hide show
  1. package/README.md +82 -38
  2. package/dist/billing/billing-banner.d.ts +5 -2
  3. package/dist/billing/billing-banner.js.map +1 -1
  4. package/dist/billing/plan-limit-notice.d.ts +5 -2
  5. package/dist/billing/plan-limit-notice.js.map +1 -1
  6. package/dist/chart.js.map +1 -1
  7. package/dist/components/amount-input.d.ts +0 -3
  8. package/dist/components/amount-input.js +2 -2
  9. package/dist/components/amount-input.js.map +1 -1
  10. package/dist/components/calculator.d.ts +1 -4
  11. package/dist/components/calculator.js +1 -3
  12. package/dist/components/calculator.js.map +1 -1
  13. package/dist/components/chart-zoom.d.ts +0 -37
  14. package/dist/components/chart-zoom.js +1 -28
  15. package/dist/components/chart-zoom.js.map +1 -1
  16. package/dist/components/chip-input.d.ts +0 -4
  17. package/dist/components/chip-input.js +1 -5
  18. package/dist/components/chip-input.js.map +1 -1
  19. package/dist/components/combobox.d.ts +1 -1
  20. package/dist/components/combobox.js.map +1 -1
  21. package/dist/components/copy-button.d.ts +5 -1
  22. package/dist/components/copy-button.js.map +1 -1
  23. package/dist/components/currency-option-list.d.ts +3 -8
  24. package/dist/components/currency-option-list.js +1 -29
  25. package/dist/components/currency-option-list.js.map +1 -1
  26. package/dist/components/danger-confirm.d.ts +3 -3
  27. package/dist/components/danger-confirm.js.map +1 -1
  28. package/dist/components/data-table-filter-popover.d.ts +1 -1
  29. package/dist/components/data-table-filter-popover.js +1 -1
  30. package/dist/components/data-table-filter-popover.js.map +1 -1
  31. package/dist/components/data-table-labels.d.ts +1 -42
  32. package/dist/components/data-table-labels.js +2 -31
  33. package/dist/components/data-table-labels.js.map +1 -1
  34. package/dist/components/data-table-shared.d.ts +17 -1
  35. package/dist/components/data-table-shared.js +22 -0
  36. package/dist/components/data-table-shared.js.map +1 -1
  37. package/dist/components/data-table.d.ts +12 -2
  38. package/dist/components/data-table.js +18 -14
  39. package/dist/components/data-table.js.map +1 -1
  40. package/dist/components/dialog-frame.d.ts +3 -1
  41. package/dist/components/dialog-frame.js.map +1 -1
  42. package/dist/components/dropdown.d.ts +18 -15
  43. package/dist/components/dropdown.js +11 -3
  44. package/dist/components/dropdown.js.map +1 -1
  45. package/dist/components/entity-combobox.d.ts +1 -1
  46. package/dist/components/entity-combobox.js.map +1 -1
  47. package/dist/components/field-base.d.ts +0 -5
  48. package/dist/components/field-base.js +0 -5
  49. package/dist/components/field-base.js.map +1 -1
  50. package/dist/components/field-parts.d.ts +3 -11
  51. package/dist/components/field-parts.js +1 -9
  52. package/dist/components/field-parts.js.map +1 -1
  53. package/dist/components/field-strip.d.ts +8 -23
  54. package/dist/components/field-strip.js +24 -22
  55. package/dist/components/field-strip.js.map +1 -1
  56. package/dist/components/field-sync.d.ts +0 -4
  57. package/dist/components/field-sync.js +0 -4
  58. package/dist/components/field-sync.js.map +1 -1
  59. package/dist/components/field.d.ts +4 -4
  60. package/dist/components/field.js.map +1 -1
  61. package/dist/components/full-bleed-dialog.js +2 -2
  62. package/dist/components/full-bleed-dialog.js.map +1 -1
  63. package/dist/components/hover-menu.d.ts +6 -13
  64. package/dist/components/hover-menu.js +3 -3
  65. package/dist/components/hover-menu.js.map +1 -1
  66. package/dist/components/inline-entity-combobox.d.ts +1 -1
  67. package/dist/components/inline-entity-combobox.js.map +1 -1
  68. package/dist/components/modal.d.ts +9 -1
  69. package/dist/components/modal.js +7 -4
  70. package/dist/components/modal.js.map +1 -1
  71. package/dist/components/multi-select.d.ts +5 -16
  72. package/dist/components/multi-select.js +5 -10
  73. package/dist/components/multi-select.js.map +1 -1
  74. package/dist/components/number-input.d.ts +0 -3
  75. package/dist/components/number-input.js +2 -4
  76. package/dist/components/number-input.js.map +1 -1
  77. package/dist/components/popover.js +2 -2
  78. package/dist/components/popover.js.map +1 -1
  79. package/dist/components/series-chart.js.map +1 -1
  80. package/dist/components/server-wake.d.ts +0 -6
  81. package/dist/components/server-wake.js.map +1 -1
  82. package/dist/components/settings-fields.d.ts +4 -2
  83. package/dist/components/settings-fields.js.map +1 -1
  84. package/dist/components/stat-tile.d.ts +8 -2
  85. package/dist/components/stat-tile.js.map +1 -1
  86. package/dist/components/swipeable-row.d.ts +2 -17
  87. package/dist/components/swipeable-row.js +11 -16
  88. package/dist/components/swipeable-row.js.map +1 -1
  89. package/dist/components/tooltip.d.ts +9 -1
  90. package/dist/components/tooltip.js +20 -5
  91. package/dist/components/tooltip.js.map +1 -1
  92. package/dist/components/translation-review.d.ts +10 -2
  93. package/dist/components/translation-review.js +11 -3
  94. package/dist/components/translation-review.js.map +1 -1
  95. package/dist/components/use-table-state.d.ts +31 -3
  96. package/dist/components/use-table-state.js +53 -8
  97. package/dist/components/use-table-state.js.map +1 -1
  98. package/dist/components/user-avatar.d.ts +2 -2
  99. package/dist/components/user-avatar.js.map +1 -1
  100. package/dist/components/wizard-stepper.d.ts +1 -4
  101. package/dist/components/wizard-stepper.js +2 -3
  102. package/dist/components/wizard-stepper.js.map +1 -1
  103. package/dist/currencies.d.ts +23 -0
  104. package/dist/currencies.js +38 -0
  105. package/dist/currencies.js.map +1 -0
  106. package/dist/demo/demo-banner.d.ts +7 -2
  107. package/dist/demo/demo-banner.js.map +1 -1
  108. package/dist/eslint/index.d.ts +83 -0
  109. package/dist/eslint/index.js +69 -0
  110. package/dist/eslint/index.js.map +1 -0
  111. package/dist/eslint/role-utilities.d.ts +48 -0
  112. package/dist/eslint/role-utilities.generated.d.ts +2 -0
  113. package/dist/eslint/role-utilities.generated.js +143 -0
  114. package/dist/eslint/role-utilities.generated.js.map +1 -0
  115. package/dist/eslint/role-utilities.js +43 -0
  116. package/dist/eslint/role-utilities.js.map +1 -0
  117. package/dist/feedback/feedback-menu.d.ts +2 -2
  118. package/dist/feedback/feedback-menu.js.map +1 -1
  119. package/dist/feedback/feedback-thread.d.ts +3 -8
  120. package/dist/feedback/feedback-thread.js +0 -2
  121. package/dist/feedback/feedback-thread.js.map +1 -1
  122. package/dist/hooks/use-dismiss.d.ts +16 -3
  123. package/dist/hooks/use-dismiss.js +14 -2
  124. package/dist/hooks/use-dismiss.js.map +1 -1
  125. package/dist/i18n/kit-labels.d.ts +14 -4
  126. package/dist/i18n/kit-labels.js.map +1 -1
  127. package/dist/i18n/locales/en.js +1 -1
  128. package/dist/i18n/locales/en.js.map +2 -2
  129. package/dist/i18n/locales/fr.js +3 -2
  130. package/dist/i18n/locales/fr.js.map +1 -1
  131. package/dist/i18n/locales/it.js +3 -2
  132. package/dist/i18n/locales/it.js.map +1 -1
  133. package/dist/index.d.ts +2 -1
  134. package/dist/index.js +2 -2
  135. package/dist/index.js.map +1 -1
  136. package/dist/lib/refs.d.ts +13 -0
  137. package/dist/lib/refs.js +15 -0
  138. package/dist/lib/refs.js.map +1 -0
  139. package/dist/rhf/fields.d.ts +10 -10
  140. package/dist/rhf/fields.js.map +1 -1
  141. package/dist/rhf/form-core.d.ts +5 -4
  142. package/dist/rhf/form-core.js.map +1 -1
  143. package/dist/rhf/form.d.ts +7 -36
  144. package/dist/rhf/form.js +1 -16
  145. package/dist/rhf/form.js.map +1 -1
  146. package/dist/rhf/line-items.d.ts +1 -1
  147. package/dist/rhf/line-items.js.map +1 -1
  148. package/dist/rhf/use-rhf-wizard-step.js.map +1 -1
  149. package/dist/rhf.js.map +1 -1
  150. package/dist/shell/option-switcher-menu.d.ts +5 -17
  151. package/dist/shell/option-switcher-menu.js +1 -3
  152. package/dist/shell/option-switcher-menu.js.map +1 -1
  153. package/dist/shell/role-switcher.d.ts +1 -4
  154. package/dist/shell/role-switcher.js +2 -3
  155. package/dist/shell/role-switcher.js.map +1 -1
  156. package/dist/shell/topbar-action-menu.d.ts +7 -19
  157. package/dist/shell/topbar-action-menu.js +1 -3
  158. package/dist/shell/topbar-action-menu.js.map +1 -1
  159. package/dist/shell/topbar-controls.d.ts +3 -12
  160. package/dist/shell/topbar-controls.js +6 -9
  161. package/dist/shell/topbar-controls.js.map +1 -1
  162. package/dist/testing/index.d.ts +1 -1
  163. package/dist/testing/index.js +2 -0
  164. package/dist/testing/index.js.map +1 -1
  165. package/dist/testing/viewport.d.ts +25 -2
  166. package/dist/testing/viewport.js +61 -10
  167. package/dist/testing/viewport.js.map +1 -1
  168. package/dist/theme/palette-presets.d.ts +0 -7
  169. package/dist/theme/palette-presets.js +0 -279
  170. package/dist/theme/palette-presets.js.map +1 -1
  171. package/dist/wizard/types.d.ts +0 -5
  172. package/dist/wizard/types.js +1 -5
  173. package/dist/wizard/types.js.map +1 -1
  174. package/package.json +9 -1
  175. package/src/billing/billing-banner.tsx +5 -2
  176. package/src/billing/plan-limit-notice.tsx +5 -2
  177. package/src/chart.ts +5 -3
  178. package/src/components/amount-input.tsx +2 -6
  179. package/src/components/calculator.tsx +2 -7
  180. package/src/components/chart-zoom.tsx +4 -47
  181. package/src/components/chip-input.tsx +0 -7
  182. package/src/components/combobox.tsx +1 -1
  183. package/src/components/copy-button.tsx +5 -1
  184. package/src/components/currency-option-list.tsx +8 -42
  185. package/src/components/danger-confirm.tsx +3 -3
  186. package/src/components/data-table-filter-popover.tsx +2 -1
  187. package/src/components/data-table-labels.ts +2 -84
  188. package/src/components/data-table-shared.tsx +55 -2
  189. package/src/components/data-table.tsx +29 -17
  190. package/src/components/dialog-frame.tsx +3 -1
  191. package/src/components/dropdown.tsx +42 -18
  192. package/src/components/entity-combobox.tsx +1 -1
  193. package/src/components/field-base.tsx +0 -11
  194. package/src/components/field-parts.tsx +5 -19
  195. package/src/components/field-strip.tsx +9 -25
  196. package/src/components/field-sync.tsx +0 -7
  197. package/src/components/field.tsx +4 -4
  198. package/src/components/full-bleed-dialog.tsx +4 -3
  199. package/src/components/hover-menu.tsx +12 -16
  200. package/src/components/inline-entity-combobox.tsx +1 -1
  201. package/src/components/modal.tsx +20 -4
  202. package/src/components/multi-select.tsx +13 -30
  203. package/src/components/number-input.tsx +3 -8
  204. package/src/components/popover.tsx +5 -3
  205. package/src/components/series-chart.tsx +1 -1
  206. package/src/components/server-wake.tsx +0 -6
  207. package/src/components/settings-fields.tsx +4 -2
  208. package/src/components/stat-tile.tsx +8 -2
  209. package/src/components/swipeable-row.tsx +15 -37
  210. package/src/components/tooltip.tsx +37 -6
  211. package/src/components/translation-review.tsx +25 -7
  212. package/src/components/use-table-state.ts +91 -10
  213. package/src/components/user-avatar.tsx +2 -2
  214. package/src/components/wizard-stepper.tsx +2 -6
  215. package/src/currencies.ts +70 -0
  216. package/src/demo/demo-banner.tsx +7 -2
  217. package/src/eslint/index.ts +161 -0
  218. package/src/eslint/role-utilities.generated.ts +146 -0
  219. package/src/eslint/role-utilities.ts +99 -0
  220. package/src/feedback/feedback-menu.tsx +2 -2
  221. package/src/feedback/feedback-thread.tsx +3 -8
  222. package/src/hooks/use-dismiss.ts +39 -4
  223. package/src/i18n/kit-labels.tsx +15 -5
  224. package/src/i18n/locales/fr.ts +3 -2
  225. package/src/i18n/locales/it.ts +3 -2
  226. package/src/index.ts +3 -1
  227. package/src/lib/refs.ts +22 -0
  228. package/src/rhf/fields.tsx +10 -10
  229. package/src/rhf/form-core.tsx +5 -4
  230. package/src/rhf/form.tsx +7 -43
  231. package/src/rhf/line-items.tsx +1 -1
  232. package/src/rhf/use-rhf-wizard-step.ts +4 -4
  233. package/src/rhf.ts +3 -2
  234. package/src/shell/option-switcher-menu.tsx +5 -22
  235. package/src/shell/role-switcher.tsx +2 -6
  236. package/src/shell/topbar-action-menu.tsx +7 -24
  237. package/src/shell/topbar-controls.tsx +6 -18
  238. package/src/testing/index.ts +3 -1
  239. package/src/testing/viewport.ts +125 -19
  240. package/src/theme/palette-presets.ts +7 -293
  241. package/src/wizard/types.ts +0 -8
@@ -8,9 +8,6 @@ import { FOCUS_RING } from "./focus-ring.js";
8
8
  import { useKitLabels } from "../i18n/kit-labels.js";
9
9
  import { useLargeText } from "../hooks/use-large-text.js";
10
10
  import { DEFAULT_FIELD_SYNC_LABELS } from "./field-sync-labels.js";
11
- function resolveFieldSyncLabels(labels) {
12
- return labels ? { ...DEFAULT_FIELD_SYNC_LABELS, ...labels } : DEFAULT_FIELD_SYNC_LABELS;
13
- }
14
11
  function toError(cause) {
15
12
  if (cause instanceof Error) return cause;
16
13
  return new Error(typeof cause === "string" ? cause : "", { cause });
@@ -269,7 +266,6 @@ export {
269
266
  FIELD_SYNC_SAVED_MS,
270
267
  FieldSyncIndicator,
271
268
  FieldSyncRow,
272
- resolveFieldSyncLabels,
273
269
  useFieldSync
274
270
  };
275
271
  //# sourceMappingURL=field-sync.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/field-sync.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { AlertCircle, Check, Loader2, Pencil, PencilLine } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { Tooltip } from \"./tooltip\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { useLargeText } from \"../hooks/use-large-text\";\nimport { DEFAULT_FIELD_SYNC_LABELS } from \"./field-sync-labels\";\n\n/**\n * Sync state for a field backed by a database row.\n *\n * The four states a value can be in between the keyboard and the server:\n *\n * synced the draft equals what was last persisted\n * edited the draft differs and nothing has been sent yet\n * pending a save is in flight\n * error the last save was rejected; `error` carries why\n *\n * `useFieldSync` is the engine and holds no opinion about markup;\n * `FieldSyncIndicator` is the affordance and holds no state. They are separate\n * because the state is useful without the dot — a form can disable its submit\n * button while any field is `pending`, and a router can block navigation while\n * any field is `edited`.\n */\nexport type FieldSyncState = \"synced\" | \"edited\" | \"pending\" | \"error\";\n\nexport interface FieldSyncLabels {\n synced: string;\n edited: string;\n pending: string;\n /** Shown when the failure carries no message of its own. */\n error: string;\n retry: string;\n}\n\n// Defined in ./field-sync-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_FIELD_SYNC_LABELS };\n\n/** @deprecated 0.34 — `useKitLabels(\"fieldSync\", DEFAULT_FIELD_SYNC_LABELS, labels)`, which\n * the provider reaches too, or the field's own `labels`; removed in 0.35 (refactor audit\n * 2026-10, API-6a). A merge from before the provider, which no kit component calls. */\nexport function resolveFieldSyncLabels(labels?: Partial<FieldSyncLabels>): FieldSyncLabels {\n return labels ? { ...DEFAULT_FIELD_SYNC_LABELS, ...labels } : DEFAULT_FIELD_SYNC_LABELS;\n}\n\nexport interface UseFieldSyncOptions<T> {\n /** What the database currently holds. Changing it re-seeds a CLEAN field. */\n value: T;\n /** Persist `next`. Reject — or throw — to put the field in `error`. */\n onSave: (next: T) => Promise<void> | void;\n /**\n * Quiet period before an edit saves itself, in ms. Default `0`: no auto-save at\n * all, so `save()` is the only way to persist — called on blur by `FieldSyncRow`,\n * and on Enter by the caller. Saving after a pause in typing wrote half-finished\n * values to the database whenever somebody stopped to think; pass a positive\n * number only for a field where that is genuinely wanted.\n */\n debounceMs?: number;\n /** How to tell two values apart. Defaults to `Object.is`. */\n equals?: (a: T, b: T) => boolean;\n /** Called once per rejected save, with whatever `onSave` threw. */\n onError?: (error: Error) => void;\n}\n\nexport interface UseFieldSyncReturn<T> {\n /** The draft — bind this to the input, not the `value` option. */\n value: T;\n setValue: (next: T) => void;\n state: FieldSyncState;\n /** Non-null exactly when `state === \"error\"`. */\n error: Error | null;\n /** True whenever the draft differs from the last persisted value. */\n dirty: boolean;\n /** Persist now, cancelling any pending debounce. A no-op on a clean field. */\n save: () => void;\n /** Send the current draft again after a failure. */\n retry: () => void;\n /** Throw the draft away and go back to the last persisted value. */\n reset: () => void;\n}\n\nfunction toError(cause: unknown): Error {\n if (cause instanceof Error) return cause;\n // An EMPTY message for a rejection that carried none, not an English one: the\n // indicator shows `error.message` when there is one and the translated\n // `fieldSync.error` otherwise, so a fallback message here was an English sentence\n // that outranked the app's own translation. The original value stays on `cause`\n // for whoever logs it from `onError`.\n return new Error(typeof cause === \"string\" ? cause : \"\", { cause });\n}\n\n/**\n * Track one database-backed field: a local draft, a debounced save, and the state\n * of the round trip.\n *\n * Four things this handles that a `useState` + `useEffect` in a page does not:\n *\n * 1. **An edit during a save is never lost.** A save in flight is not cancelled\n * and not raced — when it settles, the draft is compared again and sent once\n * more if it moved. Cancelling instead would drop a keystroke that arrived\n * mid-flight; firing immediately would let two writes land out of order.\n * 2. **The server value is only adopted when the field is clean.** An external\n * refresh that overwrote what somebody was typing would be a data-loss bug, so\n * a dirty draft wins and keeps winning until it is saved or reset.\n * 3. **Nothing is set after unmount.** The save resolves into a component that\n * may be gone — a row scrolled out of a virtualised table, a closed dialog.\n * 4. **`onSave` is read through a ref**, so an inline arrow — which every call\n * site will pass — does not restart the debounce on every render.\n */\nexport function useFieldSync<T>({\n value,\n onSave,\n debounceMs = 0,\n equals = Object.is,\n onError,\n}: UseFieldSyncOptions<T>): UseFieldSyncReturn<T> {\n const [draft, setDraft] = useState<T>(value);\n const [state, setState] = useState<FieldSyncState>(\"synced\");\n const [error, setError] = useState<Error | null>(null);\n\n // The last value known to be on the server. Not state: it changes together with\n // `state` inside the save, and a second render for it would be a wasted one.\n const savedRef = useRef<T>(value);\n const draftRef = useRef<T>(value);\n const inFlightRef = useRef(false);\n const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const mountedRef = useRef(true);\n\n // Latest-ref for the callbacks and comparators, so a caller passing inline\n // functions (all of them) does not invalidate the timer on every render.\n const onSaveRef = useRef(onSave);\n const onErrorRef = useRef(onError);\n const equalsRef = useRef(equals);\n useEffect(() => {\n onSaveRef.current = onSave;\n onErrorRef.current = onError;\n equalsRef.current = equals;\n });\n\n useEffect(() => {\n mountedRef.current = true;\n return () => {\n mountedRef.current = false;\n if (timerRef.current) clearTimeout(timerRef.current);\n };\n }, []);\n\n // A named inner function so the settle path can start the next round by calling it\n // again; a `useCallback` cannot refer to its own binding while it is being built.\n const flush = useCallback(() => {\n const run = async (): Promise<void> => {\n if (inFlightRef.current) return; // the settle path below picks the new draft up\n const next = draftRef.current;\n if (equalsRef.current(next, savedRef.current)) return;\n\n inFlightRef.current = true;\n setState(\"pending\");\n setError(null);\n try {\n await onSaveRef.current(next);\n savedRef.current = next;\n inFlightRef.current = false;\n if (!mountedRef.current) return;\n // The draft may have moved while this was in the air. Compare against what is\n // on screen NOW, not against what was sent.\n if (equalsRef.current(draftRef.current, next)) {\n setState(\"synced\");\n } else {\n setState(\"edited\");\n void run();\n }\n } catch (cause) {\n inFlightRef.current = false;\n const err = toError(cause);\n onErrorRef.current?.(err);\n if (!mountedRef.current) return;\n setState(\"error\");\n setError(err);\n }\n };\n return run();\n }, []);\n\n const setValue = useCallback(\n (next: T) => {\n draftRef.current = next;\n setDraft(next);\n if (timerRef.current) clearTimeout(timerRef.current);\n\n if (equalsRef.current(next, savedRef.current)) {\n // Typed back to what the server already has. Nothing to send, and an error\n // from the previous attempt no longer describes anything.\n setState(inFlightRef.current ? \"pending\" : \"synced\");\n setError(null);\n return;\n }\n\n setState(\"edited\");\n setError(null);\n if (debounceMs > 0) timerRef.current = setTimeout(() => void flush(), debounceMs);\n },\n [debounceMs, flush],\n );\n\n const save = useCallback(() => {\n if (timerRef.current) clearTimeout(timerRef.current);\n void flush();\n }, [flush]);\n\n const retry = save;\n\n const reset = useCallback(() => {\n if (timerRef.current) clearTimeout(timerRef.current);\n draftRef.current = savedRef.current;\n setDraft(savedRef.current);\n setState(inFlightRef.current ? \"pending\" : \"synced\");\n setError(null);\n }, []);\n\n // Anything that is not `synced` has something not yet on the server: `edited` and\n // `error` obviously, and `pending` too — the write is in the air, not landed.\n // Derived from state rather than by comparing against `savedRef` during render,\n // because a ref read here would not re-render when the comparison's answer changes.\n const dirty = state !== \"synced\";\n\n // Adopt a new server value ONLY when there is nothing local to lose.\n useEffect(() => {\n if (inFlightRef.current) return;\n if (!equalsRef.current(draftRef.current, savedRef.current)) return; // dirty: keep it\n if (equalsRef.current(value, savedRef.current)) return; // unchanged\n savedRef.current = value;\n draftRef.current = value;\n setDraft(value);\n setState(\"synced\");\n setError(null);\n }, [value]);\n\n return { value: draft, setValue, state, error, dirty, save, retry, reset };\n}\n\nconst STATE_STYLE: Record<FieldSyncState, { color: string; Icon: typeof Check }> = {\n synced: { color: \"var(--status-synced)\", Icon: Check },\n edited: { color: \"var(--status-edited)\", Icon: Pencil },\n pending: { color: \"var(--status-pending)\", Icon: Loader2 },\n error: { color: \"var(--status-error)\", Icon: AlertCircle },\n};\n\nexport interface FieldSyncIndicatorProps {\n state: FieldSyncState;\n /** The failure. Its message becomes the tooltip when `state === \"error\"`. */\n error?: Error | null;\n /** Override any of the five English defaults. */\n labels?: LabelOverride<FieldSyncLabels>;\n /** Render the label beside the icon instead of only in the tooltip. Always on at\n * Large and Extra large (0.32, docs/text-size-harmonization.md §4 \"No fact only in a\n * tooltip\"): the reader who asked for bigger type gets the words, not a 14 px glyph\n * and a bubble. */\n showLabel?: boolean;\n /** When given, the error state renders a retry button after the icon. */\n onRetry?: () => void;\n className?: string;\n}\n\n/**\n * The affordance for {@link useFieldSync}: a coloured icon, and the reason on hover.\n *\n * **Every state has its own icon, and that is not decoration.** The four colours\n * include the green/red pair, which is the one combination this package's palette\n * otherwise refuses (see the note in `tokens.css`) because red-green colour blindness\n * affects roughly one man in twelve. The icon is what carries the meaning for them,\n * and what carries it into a greyscale print or a screenshot. WCAG 1.4.1 is the same\n * requirement stated formally.\n *\n * The state is also announced: the wrapper is a polite live region, so a screen\n * reader hears \"Saving…\" then \"Saved\" without the user going looking. `error` is\n * assertive instead — a failed write is worth interrupting for.\n */\nexport function FieldSyncIndicator({\n state,\n error,\n labels,\n showLabel = false,\n onRetry,\n className,\n}: FieldSyncIndicatorProps) {\n const l = useKitLabels(\"fieldSync\", DEFAULT_FIELD_SYNC_LABELS, labels);\n const large = useLargeText();\n showLabel = showLabel || large;\n const { color, Icon } = STATE_STYLE[state];\n const text =\n state === \"error\" ? (error?.message?.trim() ? error.message : l.error) : l[state];\n\n return (\n <span\n // The ICON carries the status colour; the words are `--text-secondary`, because\n // the amber of `edited` is a graphic colour (3:1), not a text colour (4.5:1).\n className={cn(\"inline-flex items-center gap-1.5 text-xs text-secondary\", className)}\n // Polite for the three ordinary states so save chatter does not interrupt\n // typing; assertive for a failure, which the user has to know about now.\n role={state === \"error\" ? \"alert\" : \"status\"}\n aria-live={state === \"error\" ? \"assertive\" : \"polite\"}\n >\n <Tooltip label={showLabel ? undefined : text} redact={state === \"error\"}>\n <span className=\"inline-flex items-center gap-1.5\">\n <Icon\n aria-hidden\n style={{ color }}\n className={cn(\"size-3.5 shrink-0\", state === \"pending\" && \"animate-spin\")}\n />\n {/* Always in the DOM, so the live region has something to announce even\n when the label is visually a tooltip. */}\n <span className={showLabel ? undefined : \"sr-only\"}>{text}</span>\n </span>\n </Tooltip>\n {state === \"error\" && onRetry && (\n <button\n type=\"button\"\n onClick={onRetry}\n // The kit's focus frame (0.32, §5), stood off the word.\n className={cn(\n \"rounded underline underline-offset-2 hover:no-underline focus-visible:ring-offset-2 focus-visible:ring-offset-surface\",\n FOCUS_RING,\n )}\n style={{ color }}\n >\n {l.retry}\n </button>\n )}\n </span>\n );\n}\n\n/**\n * The field's own frame, coloured by state — applied from the WRAPPER to whatever\n * native control sits inside it, so {@link FieldSyncRow} works with any of the kit's\n * inputs (and a caller's own) without each one growing a `syncState` prop.\n *\n * Border AND a 1px ring, for the reason {@link FIELD_INVALID} gives: a lone 1px\n * border loses a vertical edge to sub-pixel spread at 125% display scaling. The\n * `:focus` twins are what keep the state visible while the reader is typing —\n * the base field class's `focus:` brand colour is `(0,2,0)` and would otherwise win\n * over a descendant selector exactly when the field is in use.\n *\n * `synced` is green, but {@link FieldSyncRow} paints it only for a moment after a\n * save lands — a form of eight permanently green boxes says nothing the absence of\n * colour does not.\n *\n * Every class is a literal so Tailwind's scanner can see it.\n */\nexport const FIELD_SYNC_FRAME: Record<FieldSyncState, string> = {\n synced:\n \"[&_:is(input,select,textarea)]:border-[var(--status-synced)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-synced)] [&_:is(input,select,textarea):focus]:border-[var(--status-synced)] [&_:is(input,select,textarea):focus]:ring-[var(--status-synced)]\",\n edited:\n \"[&_:is(input,select,textarea)]:border-[var(--status-edited)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-edited)] [&_:is(input,select,textarea):focus]:border-[var(--status-edited)] [&_:is(input,select,textarea):focus]:ring-[var(--status-edited)]\",\n pending:\n \"[&_:is(input,select,textarea)]:border-[var(--status-pending)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-pending)] [&_:is(input,select,textarea):focus]:border-[var(--status-pending)] [&_:is(input,select,textarea):focus]:ring-[var(--status-pending)]\",\n error:\n \"[&_:is(input,select,textarea)]:border-[var(--status-error)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-error)] [&_:is(input,select,textarea):focus]:border-[var(--status-error)] [&_:is(input,select,textarea):focus]:ring-[var(--status-error)]\",\n};\n\n/** How long the green \"saved\" frame and check stay after a save lands, in ms. */\nexport const FIELD_SYNC_SAVED_MS = 1500;\n\nexport interface FieldSyncRowProps<T> extends Omit<FieldSyncIndicatorProps, \"state\" | \"error\" | \"showLabel\"> {\n sync: UseFieldSyncReturn<T>;\n children: ReactNode;\n /** Call `sync.save()` when focus leaves the field. Default true — together with the\n * hook's default `debounceMs: 0` this is \"save on blur\": nothing is written while\n * the reader is still typing, however long they pause. */\n saveOnBlur?: boolean;\n /** How long the saved confirmation shows. Default {@link FIELD_SYNC_SAVED_MS}. */\n savedMs?: number;\n}\n\n/**\n * A field wearing its sync state — as its FRAME COLOUR and an ICON AT THE END OF THE\n * FIELD, so the field never changes size (a helper line appearing under it moved\n * everything below it on every save):\n *\n * edited amber frame, pencil — changed, not yet saved\n * pending blue frame, spinning circle — for as long as the save is in flight\n * synced green frame, check — for {@link FIELD_SYNC_SAVED_MS} after a\n * save lands, then an ordinary field again\n * error red frame, alert mark — hover or focus the mark for the reason;\n * click it to retry\n *\n * It saves when focus LEAVES the field (`saveOnBlur`), not after a pause in typing: a\n * database write in the middle of a word is a write nobody asked for. Enter in a\n * single-line field is the caller's to wire to `sync.save`.\n *\n * Colour is never the only signal: every state has its own icon, and the state text is\n * always in the DOM as a live region — polite for the ordinary states, assertive for\n * a failure. The input gets end padding for the icon whether or not one is showing,\n * so the text never reflows when the state changes.\n */\nexport function FieldSyncRow<T>({\n sync,\n children,\n className,\n labels,\n onRetry,\n saveOnBlur = true,\n savedMs = FIELD_SYNC_SAVED_MS,\n}: FieldSyncRowProps<T>) {\n const l = useKitLabels(\"fieldSync\", DEFAULT_FIELD_SYNC_LABELS, labels);\n const { state, error } = sync;\n const isError = state === \"error\";\n const text = isError ? (error?.message?.trim() ? error.message : l.error) : l[state];\n const retry = onRetry ?? sync.retry;\n\n // The saved confirmation: on only for the moment after a save lands. Keyed on the\n // TRANSITION pending → synced, so a field that starts synced shows nothing. The\n // transition is seen while rendering, against the previous state; the effect only\n // owns the timer that turns it off again.\n const [previous, setPrevious] = useState(state);\n const [justSaved, setJustSaved] = useState(false);\n if (state !== previous) {\n setPrevious(state);\n setJustSaved(state === \"synced\" && previous === \"pending\");\n }\n useEffect(() => {\n if (!justSaved) return;\n const t = setTimeout(() => setJustSaved(false), savedMs);\n return () => clearTimeout(t);\n }, [justSaved, savedMs]);\n const shown: FieldSyncState | null = state === \"synced\" ? (justSaved ? \"synced\" : null) : state;\n\n return (\n /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- not an interaction:\n `onBlur` listens for focus leaving the row's own controls (which are the\n interactive elements) to save; the wrapper itself is never focused. */\n <div\n data-field-sync={state}\n className={cn(\n // `relative` for the icon and for the sr-only live region's containing block.\n \"relative [&_:is(input,select,textarea)]:pe-9\",\n shown && FIELD_SYNC_FRAME[shown],\n className,\n )}\n onBlur={\n saveOnBlur\n ? (e) => {\n // Focus moving between two controls INSIDE the row is not leaving it.\n if (!e.currentTarget.contains(e.relatedTarget as Node | null)) sync.save();\n }\n : undefined\n }\n >\n {children}\n <span className=\"pointer-events-none absolute inset-y-0 end-0 flex items-center pe-3\">\n {state === \"edited\" && (\n <PencilLine aria-hidden className=\"size-4 text-[var(--status-edited)]\" />\n )}\n {state === \"pending\" && (\n <Loader2 aria-hidden className=\"size-4 animate-spin text-[var(--status-pending)] motion-reduce:animate-none\" />\n )}\n {state === \"synced\" && (\n <Check\n aria-hidden\n className={cn(\n \"size-4 text-[var(--status-synced)] transition-opacity duration-500 motion-reduce:transition-none\",\n justSaved ? \"opacity-100\" : \"opacity-0\",\n )}\n />\n )}\n {isError && (\n <Tooltip label={text} portal redact>\n <button\n type=\"button\"\n onClick={retry}\n aria-label={l.retry}\n className={cn(\n \"pointer-events-auto flex rounded-full text-[var(--status-error)] focus-visible:ring-offset-2 focus-visible:ring-offset-surface\",\n FOCUS_RING,\n )}\n >\n <AlertCircle aria-hidden className=\"size-4\" />\n </button>\n </Tooltip>\n )}\n </span>\n <span\n role={isError ? \"alert\" : \"status\"}\n aria-live={isError ? \"assertive\" : \"polite\"}\n className=\"sr-only\"\n >\n {shown === null ? \"\" : text}\n </span>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/field-sync.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { AlertCircle, Check, Loader2, Pencil, PencilLine } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { Tooltip } from \"./tooltip\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { useLargeText } from \"../hooks/use-large-text\";\nimport { DEFAULT_FIELD_SYNC_LABELS } from \"./field-sync-labels\";\n\n/**\n * Sync state for a field backed by a database row.\n *\n * The four states a value can be in between the keyboard and the server:\n *\n * synced the draft equals what was last persisted\n * edited the draft differs and nothing has been sent yet\n * pending a save is in flight\n * error the last save was rejected; `error` carries why\n *\n * `useFieldSync` is the engine and holds no opinion about markup;\n * `FieldSyncIndicator` is the affordance and holds no state. They are separate\n * because the state is useful without the dot — a form can disable its submit\n * button while any field is `pending`, and a router can block navigation while\n * any field is `edited`.\n */\nexport type FieldSyncState = \"synced\" | \"edited\" | \"pending\" | \"error\";\n\nexport interface FieldSyncLabels {\n synced: string;\n edited: string;\n pending: string;\n /** Shown when the failure carries no message of its own. */\n error: string;\n retry: string;\n}\n\n// Defined in ./field-sync-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_FIELD_SYNC_LABELS };\n\nexport interface UseFieldSyncOptions<T> {\n /** What the database currently holds. Changing it re-seeds a CLEAN field. */\n value: T;\n /** Persist `next`. Reject — or throw — to put the field in `error`. */\n onSave: (next: T) => Promise<void> | void;\n /**\n * Quiet period before an edit saves itself, in ms. Default `0`: no auto-save at\n * all, so `save()` is the only way to persist — called on blur by `FieldSyncRow`,\n * and on Enter by the caller. Saving after a pause in typing wrote half-finished\n * values to the database whenever somebody stopped to think; pass a positive\n * number only for a field where that is genuinely wanted.\n */\n debounceMs?: number;\n /** How to tell two values apart. Defaults to `Object.is`. */\n equals?: (a: T, b: T) => boolean;\n /** Called once per rejected save, with whatever `onSave` threw. */\n onError?: (error: Error) => void;\n}\n\nexport interface UseFieldSyncReturn<T> {\n /** The draft — bind this to the input, not the `value` option. */\n value: T;\n setValue: (next: T) => void;\n state: FieldSyncState;\n /** Non-null exactly when `state === \"error\"`. */\n error: Error | null;\n /** True whenever the draft differs from the last persisted value. */\n dirty: boolean;\n /** Persist now, cancelling any pending debounce. A no-op on a clean field. */\n save: () => void;\n /** Send the current draft again after a failure. */\n retry: () => void;\n /** Throw the draft away and go back to the last persisted value. */\n reset: () => void;\n}\n\nfunction toError(cause: unknown): Error {\n if (cause instanceof Error) return cause;\n // An EMPTY message for a rejection that carried none, not an English one: the\n // indicator shows `error.message` when there is one and the translated\n // `fieldSync.error` otherwise, so a fallback message here was an English sentence\n // that outranked the app's own translation. The original value stays on `cause`\n // for whoever logs it from `onError`.\n return new Error(typeof cause === \"string\" ? cause : \"\", { cause });\n}\n\n/**\n * Track one database-backed field: a local draft, a debounced save, and the state\n * of the round trip.\n *\n * Four things this handles that a `useState` + `useEffect` in a page does not:\n *\n * 1. **An edit during a save is never lost.** A save in flight is not cancelled\n * and not raced — when it settles, the draft is compared again and sent once\n * more if it moved. Cancelling instead would drop a keystroke that arrived\n * mid-flight; firing immediately would let two writes land out of order.\n * 2. **The server value is only adopted when the field is clean.** An external\n * refresh that overwrote what somebody was typing would be a data-loss bug, so\n * a dirty draft wins and keeps winning until it is saved or reset.\n * 3. **Nothing is set after unmount.** The save resolves into a component that\n * may be gone — a row scrolled out of a virtualised table, a closed dialog.\n * 4. **`onSave` is read through a ref**, so an inline arrow — which every call\n * site will pass — does not restart the debounce on every render.\n */\nexport function useFieldSync<T>({\n value,\n onSave,\n debounceMs = 0,\n equals = Object.is,\n onError,\n}: UseFieldSyncOptions<T>): UseFieldSyncReturn<T> {\n const [draft, setDraft] = useState<T>(value);\n const [state, setState] = useState<FieldSyncState>(\"synced\");\n const [error, setError] = useState<Error | null>(null);\n\n // The last value known to be on the server. Not state: it changes together with\n // `state` inside the save, and a second render for it would be a wasted one.\n const savedRef = useRef<T>(value);\n const draftRef = useRef<T>(value);\n const inFlightRef = useRef(false);\n const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const mountedRef = useRef(true);\n\n // Latest-ref for the callbacks and comparators, so a caller passing inline\n // functions (all of them) does not invalidate the timer on every render.\n const onSaveRef = useRef(onSave);\n const onErrorRef = useRef(onError);\n const equalsRef = useRef(equals);\n useEffect(() => {\n onSaveRef.current = onSave;\n onErrorRef.current = onError;\n equalsRef.current = equals;\n });\n\n useEffect(() => {\n mountedRef.current = true;\n return () => {\n mountedRef.current = false;\n if (timerRef.current) clearTimeout(timerRef.current);\n };\n }, []);\n\n // A named inner function so the settle path can start the next round by calling it\n // again; a `useCallback` cannot refer to its own binding while it is being built.\n const flush = useCallback(() => {\n const run = async (): Promise<void> => {\n if (inFlightRef.current) return; // the settle path below picks the new draft up\n const next = draftRef.current;\n if (equalsRef.current(next, savedRef.current)) return;\n\n inFlightRef.current = true;\n setState(\"pending\");\n setError(null);\n try {\n await onSaveRef.current(next);\n savedRef.current = next;\n inFlightRef.current = false;\n if (!mountedRef.current) return;\n // The draft may have moved while this was in the air. Compare against what is\n // on screen NOW, not against what was sent.\n if (equalsRef.current(draftRef.current, next)) {\n setState(\"synced\");\n } else {\n setState(\"edited\");\n void run();\n }\n } catch (cause) {\n inFlightRef.current = false;\n const err = toError(cause);\n onErrorRef.current?.(err);\n if (!mountedRef.current) return;\n setState(\"error\");\n setError(err);\n }\n };\n return run();\n }, []);\n\n const setValue = useCallback(\n (next: T) => {\n draftRef.current = next;\n setDraft(next);\n if (timerRef.current) clearTimeout(timerRef.current);\n\n if (equalsRef.current(next, savedRef.current)) {\n // Typed back to what the server already has. Nothing to send, and an error\n // from the previous attempt no longer describes anything.\n setState(inFlightRef.current ? \"pending\" : \"synced\");\n setError(null);\n return;\n }\n\n setState(\"edited\");\n setError(null);\n if (debounceMs > 0) timerRef.current = setTimeout(() => void flush(), debounceMs);\n },\n [debounceMs, flush],\n );\n\n const save = useCallback(() => {\n if (timerRef.current) clearTimeout(timerRef.current);\n void flush();\n }, [flush]);\n\n const retry = save;\n\n const reset = useCallback(() => {\n if (timerRef.current) clearTimeout(timerRef.current);\n draftRef.current = savedRef.current;\n setDraft(savedRef.current);\n setState(inFlightRef.current ? \"pending\" : \"synced\");\n setError(null);\n }, []);\n\n // Anything that is not `synced` has something not yet on the server: `edited` and\n // `error` obviously, and `pending` too — the write is in the air, not landed.\n // Derived from state rather than by comparing against `savedRef` during render,\n // because a ref read here would not re-render when the comparison's answer changes.\n const dirty = state !== \"synced\";\n\n // Adopt a new server value ONLY when there is nothing local to lose.\n useEffect(() => {\n if (inFlightRef.current) return;\n if (!equalsRef.current(draftRef.current, savedRef.current)) return; // dirty: keep it\n if (equalsRef.current(value, savedRef.current)) return; // unchanged\n savedRef.current = value;\n draftRef.current = value;\n setDraft(value);\n setState(\"synced\");\n setError(null);\n }, [value]);\n\n return { value: draft, setValue, state, error, dirty, save, retry, reset };\n}\n\nconst STATE_STYLE: Record<FieldSyncState, { color: string; Icon: typeof Check }> = {\n synced: { color: \"var(--status-synced)\", Icon: Check },\n edited: { color: \"var(--status-edited)\", Icon: Pencil },\n pending: { color: \"var(--status-pending)\", Icon: Loader2 },\n error: { color: \"var(--status-error)\", Icon: AlertCircle },\n};\n\nexport interface FieldSyncIndicatorProps {\n state: FieldSyncState;\n /** The failure. Its message becomes the tooltip when `state === \"error\"`. */\n error?: Error | null;\n /** Override any of the five English defaults. */\n labels?: LabelOverride<FieldSyncLabels>;\n /** Render the label beside the icon instead of only in the tooltip. Always on at\n * Large and Extra large (0.32, docs/text-size-harmonization.md §4 \"No fact only in a\n * tooltip\"): the reader who asked for bigger type gets the words, not a 14 px glyph\n * and a bubble. */\n showLabel?: boolean;\n /** When given, the error state renders a retry button after the icon. */\n onRetry?: () => void;\n className?: string;\n}\n\n/**\n * The affordance for {@link useFieldSync}: a coloured icon, and the reason on hover.\n *\n * **Every state has its own icon, and that is not decoration.** The four colours\n * include the green/red pair, which is the one combination this package's palette\n * otherwise refuses (see the note in `tokens.css`) because red-green colour blindness\n * affects roughly one man in twelve. The icon is what carries the meaning for them,\n * and what carries it into a greyscale print or a screenshot. WCAG 1.4.1 is the same\n * requirement stated formally.\n *\n * The state is also announced: the wrapper is a polite live region, so a screen\n * reader hears \"Saving…\" then \"Saved\" without the user going looking. `error` is\n * assertive instead — a failed write is worth interrupting for.\n */\nexport function FieldSyncIndicator({\n state,\n error,\n labels,\n showLabel = false,\n onRetry,\n className,\n}: FieldSyncIndicatorProps) {\n const l = useKitLabels(\"fieldSync\", DEFAULT_FIELD_SYNC_LABELS, labels);\n const large = useLargeText();\n showLabel = showLabel || large;\n const { color, Icon } = STATE_STYLE[state];\n const text =\n state === \"error\" ? (error?.message?.trim() ? error.message : l.error) : l[state];\n\n return (\n <span\n // The ICON carries the status colour; the words are `--text-secondary`, because\n // the amber of `edited` is a graphic colour (3:1), not a text colour (4.5:1).\n className={cn(\"inline-flex items-center gap-1.5 text-xs text-secondary\", className)}\n // Polite for the three ordinary states so save chatter does not interrupt\n // typing; assertive for a failure, which the user has to know about now.\n role={state === \"error\" ? \"alert\" : \"status\"}\n aria-live={state === \"error\" ? \"assertive\" : \"polite\"}\n >\n <Tooltip label={showLabel ? undefined : text} redact={state === \"error\"}>\n <span className=\"inline-flex items-center gap-1.5\">\n <Icon\n aria-hidden\n style={{ color }}\n className={cn(\"size-3.5 shrink-0\", state === \"pending\" && \"animate-spin\")}\n />\n {/* Always in the DOM, so the live region has something to announce even\n when the label is visually a tooltip. */}\n <span className={showLabel ? undefined : \"sr-only\"}>{text}</span>\n </span>\n </Tooltip>\n {state === \"error\" && onRetry && (\n <button\n type=\"button\"\n onClick={onRetry}\n // The kit's focus frame (0.32, §5), stood off the word.\n className={cn(\n \"rounded underline underline-offset-2 hover:no-underline focus-visible:ring-offset-2 focus-visible:ring-offset-surface\",\n FOCUS_RING,\n )}\n style={{ color }}\n >\n {l.retry}\n </button>\n )}\n </span>\n );\n}\n\n/**\n * The field's own frame, coloured by state — applied from the WRAPPER to whatever\n * native control sits inside it, so {@link FieldSyncRow} works with any of the kit's\n * inputs (and a caller's own) without each one growing a `syncState` prop.\n *\n * Border AND a 1px ring, for the reason {@link FIELD_INVALID} gives: a lone 1px\n * border loses a vertical edge to sub-pixel spread at 125% display scaling. The\n * `:focus` twins are what keep the state visible while the reader is typing —\n * the base field class's `focus:` brand colour is `(0,2,0)` and would otherwise win\n * over a descendant selector exactly when the field is in use.\n *\n * `synced` is green, but {@link FieldSyncRow} paints it only for a moment after a\n * save lands — a form of eight permanently green boxes says nothing the absence of\n * colour does not.\n *\n * Every class is a literal so Tailwind's scanner can see it.\n */\nexport const FIELD_SYNC_FRAME: Record<FieldSyncState, string> = {\n synced:\n \"[&_:is(input,select,textarea)]:border-[var(--status-synced)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-synced)] [&_:is(input,select,textarea):focus]:border-[var(--status-synced)] [&_:is(input,select,textarea):focus]:ring-[var(--status-synced)]\",\n edited:\n \"[&_:is(input,select,textarea)]:border-[var(--status-edited)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-edited)] [&_:is(input,select,textarea):focus]:border-[var(--status-edited)] [&_:is(input,select,textarea):focus]:ring-[var(--status-edited)]\",\n pending:\n \"[&_:is(input,select,textarea)]:border-[var(--status-pending)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-pending)] [&_:is(input,select,textarea):focus]:border-[var(--status-pending)] [&_:is(input,select,textarea):focus]:ring-[var(--status-pending)]\",\n error:\n \"[&_:is(input,select,textarea)]:border-[var(--status-error)] [&_:is(input,select,textarea)]:ring-1 [&_:is(input,select,textarea)]:ring-[var(--status-error)] [&_:is(input,select,textarea):focus]:border-[var(--status-error)] [&_:is(input,select,textarea):focus]:ring-[var(--status-error)]\",\n};\n\n/** How long the green \"saved\" frame and check stay after a save lands, in ms. */\nexport const FIELD_SYNC_SAVED_MS = 1500;\n\nexport interface FieldSyncRowProps<T> extends Omit<FieldSyncIndicatorProps, \"state\" | \"error\" | \"showLabel\"> {\n sync: UseFieldSyncReturn<T>;\n children: ReactNode;\n /** Call `sync.save()` when focus leaves the field. Default true — together with the\n * hook's default `debounceMs: 0` this is \"save on blur\": nothing is written while\n * the reader is still typing, however long they pause. */\n saveOnBlur?: boolean;\n /** How long the saved confirmation shows. Default {@link FIELD_SYNC_SAVED_MS}. */\n savedMs?: number;\n}\n\n/**\n * A field wearing its sync state — as its FRAME COLOUR and an ICON AT THE END OF THE\n * FIELD, so the field never changes size (a helper line appearing under it moved\n * everything below it on every save):\n *\n * edited amber frame, pencil — changed, not yet saved\n * pending blue frame, spinning circle — for as long as the save is in flight\n * synced green frame, check — for {@link FIELD_SYNC_SAVED_MS} after a\n * save lands, then an ordinary field again\n * error red frame, alert mark — hover or focus the mark for the reason;\n * click it to retry\n *\n * It saves when focus LEAVES the field (`saveOnBlur`), not after a pause in typing: a\n * database write in the middle of a word is a write nobody asked for. Enter in a\n * single-line field is the caller's to wire to `sync.save`.\n *\n * Colour is never the only signal: every state has its own icon, and the state text is\n * always in the DOM as a live region — polite for the ordinary states, assertive for\n * a failure. The input gets end padding for the icon whether or not one is showing,\n * so the text never reflows when the state changes.\n */\nexport function FieldSyncRow<T>({\n sync,\n children,\n className,\n labels,\n onRetry,\n saveOnBlur = true,\n savedMs = FIELD_SYNC_SAVED_MS,\n}: FieldSyncRowProps<T>) {\n const l = useKitLabels(\"fieldSync\", DEFAULT_FIELD_SYNC_LABELS, labels);\n const { state, error } = sync;\n const isError = state === \"error\";\n const text = isError ? (error?.message?.trim() ? error.message : l.error) : l[state];\n const retry = onRetry ?? sync.retry;\n\n // The saved confirmation: on only for the moment after a save lands. Keyed on the\n // TRANSITION pending → synced, so a field that starts synced shows nothing. The\n // transition is seen while rendering, against the previous state; the effect only\n // owns the timer that turns it off again.\n const [previous, setPrevious] = useState(state);\n const [justSaved, setJustSaved] = useState(false);\n if (state !== previous) {\n setPrevious(state);\n setJustSaved(state === \"synced\" && previous === \"pending\");\n }\n useEffect(() => {\n if (!justSaved) return;\n const t = setTimeout(() => setJustSaved(false), savedMs);\n return () => clearTimeout(t);\n }, [justSaved, savedMs]);\n const shown: FieldSyncState | null = state === \"synced\" ? (justSaved ? \"synced\" : null) : state;\n\n return (\n /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- not an interaction:\n `onBlur` listens for focus leaving the row's own controls (which are the\n interactive elements) to save; the wrapper itself is never focused. */\n <div\n data-field-sync={state}\n className={cn(\n // `relative` for the icon and for the sr-only live region's containing block.\n \"relative [&_:is(input,select,textarea)]:pe-9\",\n shown && FIELD_SYNC_FRAME[shown],\n className,\n )}\n onBlur={\n saveOnBlur\n ? (e) => {\n // Focus moving between two controls INSIDE the row is not leaving it.\n if (!e.currentTarget.contains(e.relatedTarget as Node | null)) sync.save();\n }\n : undefined\n }\n >\n {children}\n <span className=\"pointer-events-none absolute inset-y-0 end-0 flex items-center pe-3\">\n {state === \"edited\" && (\n <PencilLine aria-hidden className=\"size-4 text-[var(--status-edited)]\" />\n )}\n {state === \"pending\" && (\n <Loader2 aria-hidden className=\"size-4 animate-spin text-[var(--status-pending)] motion-reduce:animate-none\" />\n )}\n {state === \"synced\" && (\n <Check\n aria-hidden\n className={cn(\n \"size-4 text-[var(--status-synced)] transition-opacity duration-500 motion-reduce:transition-none\",\n justSaved ? \"opacity-100\" : \"opacity-0\",\n )}\n />\n )}\n {isError && (\n <Tooltip label={text} portal redact>\n <button\n type=\"button\"\n onClick={retry}\n aria-label={l.retry}\n className={cn(\n \"pointer-events-auto flex rounded-full text-[var(--status-error)] focus-visible:ring-offset-2 focus-visible:ring-offset-surface\",\n FOCUS_RING,\n )}\n >\n <AlertCircle aria-hidden className=\"size-4\" />\n </button>\n </Tooltip>\n )}\n </span>\n <span\n role={isError ? \"alert\" : \"status\"}\n aria-live={isError ? \"assertive\" : \"polite\"}\n className=\"sr-only\"\n >\n {shown === null ? \"\" : text}\n </span>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -29,10 +29,10 @@
29
29
  *
30
30
  * Deliberately knows neither the wizard nor react-hook-form: `error` is a plain
31
31
  * value. Pass `wizard.fieldErrors.iban`, a step-local error, or
32
- * `fieldState.error?.message` from a `FormField` render — the markup is the same
33
- * whichever of them gates the step, so a step can move between them without its
34
- * fields changing. (Inside a react-hook-form form, `FormItem` + `FormLabel` from
35
- * `@eifi1/ui-kit/rhf` do the same job with the ids taken from the form.)
32
+ * `fieldState.error?.message` from a react-hook-form `Controller` — the markup is the
33
+ * same whichever of them gates the step, so a step can move between them without its
34
+ * fields changing. (Inside a react-hook-form form, `RhfField` from `@eifi1/ui-kit/rhf`
35
+ * does the same job with the ids taken from the form.)
36
36
  *
37
37
  * The error is NOT `role="alert"`, for the reason the kit's `error` prop gives (see
38
38
  * `useFieldError` in components/field-parts.tsx): it is read when focus reaches the control.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/field.tsx"],"sourcesContent":["/**\n * A field group with its label ABOVE the control: label, control, hint, error.\n *\n * ```tsx\n * <Field label=\"IBAN\" required hint=\"22 characters\" error={errors.iban}>\n * {(ids) => <Input {...ids} value={iban} onChange={…} />}\n * </Field>\n * ```\n *\n * The kit's `Input` and `Select` float their label inside the field, and the pickers\n * draw theirs the same way. That is the wrong shape for a control the kit did not\n * render, a radio group, a range slider, or a form that sets its labels above the\n * fields throughout — which is what kastlan's wizards are, and why it kept its own\n * `WizardField` after the kit dropped the one it had never rendered. This is that\n * group, with the wiring the old one left to the caller: the label names the control\n * through `htmlFor`, and the control is described by the hint and the error that are\n * actually on screen.\n *\n * ## Wiring\n *\n * Pass a function as `children` and it receives {@link FieldControlProps} — `id`,\n * `aria-describedby`, `aria-invalid` and `aria-required` — to spread on the control.\n * Plain children render as they are, for a control that wires itself (a checkbox that\n * carries its own label, a group with `aria-labelledby`); `htmlFor` then points the\n * label at the control's own id, and the hint and error are on screen but describe\n * nothing — the same as the `WizardField` this replaces.\n *\n * ## Decoupled\n *\n * Deliberately knows neither the wizard nor react-hook-form: `error` is a plain\n * value. Pass `wizard.fieldErrors.iban`, a step-local error, or\n * `fieldState.error?.message` from a `FormField` render — the markup is the same\n * whichever of them gates the step, so a step can move between them without its\n * fields changing. (Inside a react-hook-form form, `FormItem` + `FormLabel` from\n * `@eifi1/ui-kit/rhf` do the same job with the ids taken from the form.)\n *\n * The error is NOT `role=\"alert\"`, for the reason the kit's `error` prop gives (see\n * `useFieldError` in components/field-parts.tsx): it is read when focus reaches the control.\n */\nimport { useId, type ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { hasContent } from \"../lib/presence\";\nimport { useInlineHint } from \"./field-parts\";\nimport { Label } from \"./field-base\";\n\n/** What a `Field` hands its render-prop children, to spread on the control. */\nexport interface FieldControlProps {\n /** The id the label's `htmlFor` points at. */\n id: string;\n /** The ids of the hint and the error that are rendered, or `undefined` when\n * neither is. */\n \"aria-describedby\": string | undefined;\n /** `true` while the field has an error, `undefined` otherwise. */\n \"aria-invalid\": true | undefined;\n /** `true` when the field is `required`. The label's star is `aria-hidden`; this\n * is what a screen reader hears instead. */\n \"aria-required\": true | undefined;\n}\n\n/**\n * The second argument of a `Field`'s render-prop children: what a GROUP needs that\n * a single control does not. `htmlFor` names an `<input>`, but a `<label>` pointing\n * at a `role=\"radiogroup\"` div names nothing — so a group (a {@link ToggleGroup})\n * takes `aria-labelledby={labelId}` instead. Kept out of {@link FieldControlProps}\n * so the spread every existing control takes is unchanged.\n */\nexport interface FieldRenderMeta {\n /** The label element's id, or `undefined` when the field has no label. */\n labelId: string | undefined;\n}\n\n/**\n * Where the label is visible. `\"visible\"` (default); `\"sr-only\"`: never drawn, still\n * the control's name; `\"below-sm\"` / `\"below-md\"` / `\"below-lg\"`: drawn only under\n * that breakpoint and screen-reader-only from it up.\n *\n * For table-like editors — kastlan's journal entry lines\n * (accounting/components/wizard/journal-entry-lines-step.tsx) — where a desktop\n * row sits under column headers that already say \"Account\" and \"Amount\", but the\n * phone layout stacks each line as a card and needs the labels back. The name stays\n * on the control at every width; only the ink changes.\n */\nexport type FieldLabelVisibility = \"visible\" | \"sr-only\" | \"below-sm\" | \"below-md\" | \"below-lg\";\n\n// Literal class strings so Tailwind's scanner sees each one.\nconst LABEL_VISIBILITY: Record<FieldLabelVisibility, string | undefined> = {\n visible: undefined,\n \"sr-only\": \"sr-only\",\n \"below-sm\": \"sm:sr-only\",\n \"below-md\": \"md:sr-only\",\n \"below-lg\": \"lg:sr-only\",\n};\n\nexport interface FieldProps {\n /** The label above the control. Omit it for a group whose control is labelled\n * some other way; the hint and error still render. */\n label?: ReactNode;\n /** Draws the kit's required mark after the label, and sets `aria-required` on the\n * render-prop control. */\n required?: boolean;\n /** Muted helper text below the control. */\n hint?: ReactNode;\n /** The error below the control. `null`, `false` and `\"\"` are no error — what a\n * `touched && errors.x` evaluates to on the happy path. */\n error?: ReactNode;\n /** The control's id. Defaults to a generated one; set it when the control carries\n * its own id (plain children). */\n htmlFor?: string;\n /** Dims the label with its control. */\n disabled?: boolean;\n /** The label's size: `sm` for a dense row, `md` otherwise. */\n labelSize?: \"sm\" | \"md\";\n /** See {@link FieldLabelVisibility}. */\n labelVisibility?: FieldLabelVisibility;\n /** Wrapper className. */\n className?: string;\n /** The control. A function receives {@link FieldControlProps} to spread on it, and\n * {@link FieldRenderMeta} (the label's id, for a group). */\n children: ReactNode | ((control: FieldControlProps, meta: FieldRenderMeta) => ReactNode);\n}\n\nexport function Field({\n label,\n required,\n hint: hintProp,\n error,\n htmlFor,\n disabled,\n labelSize,\n labelVisibility = \"visible\",\n className,\n children,\n}: FieldProps) {\n const generated = useId();\n const id = htmlFor ?? `${generated}-control`;\n const hintId = `${generated}-hint`;\n const errorId = `${generated}-error`;\n const labelId = `${generated}-label`;\n // §4: a FieldHint under the control is its words at Large and on a touch screen, not\n // a lone \"?\" (DUP-2, refactor audit 2026-10). A HelpPopover stays a \"?\".\n const hint = useInlineHint(hintProp);\n const hasHint = hasContent(hint);\n const hasError = hasContent(error);\n const describedBy = [hasHint && hintId, hasError && errorId].filter(Boolean).join(\" \");\n const control: FieldControlProps = {\n id,\n \"aria-describedby\": describedBy || undefined,\n \"aria-invalid\": hasError || undefined,\n \"aria-required\": required || undefined,\n };\n return (\n <div\n data-slot=\"field\"\n className={cn(\n // `content-start`: in a grid row next to a taller item (one with a hint), a\n // stretched Field would hand the extra height to its rows — the label grew,\n // and the control sat lower than its neighbour's (showcase audit, #/forms).\n \"grid content-start gap-1.5\",\n // A hidden label is `position: absolute`; `relative` keeps its containing\n // block here rather than the page's (see sr-only-containment.test).\n labelVisibility !== \"visible\" && \"relative\",\n className,\n )}\n >\n {label !== undefined && (\n <Label\n id={labelId}\n htmlFor={id}\n required={required}\n disabled={disabled}\n size={labelSize}\n data-error={hasError || undefined}\n className={cn(\"data-[error=true]:text-danger\", LABEL_VISIBILITY[labelVisibility])}\n >\n {label}\n </Label>\n )}\n {typeof children === \"function\"\n ? children(control, { labelId: label !== undefined ? labelId : undefined })\n : children}\n {hasHint && (\n <p id={hintId} className=\"text-caption leading-tight text-muted\">\n {hint}\n </p>\n )}\n {hasError && (\n <p id={errorId} className=\"text-caption leading-tight text-danger\">\n {error}\n </p>\n )}\n </div>\n );\n}\n"],"mappings":";AAuJI,SAcI,KAdJ;AAhHJ,SAAS,aAA6B;AACtC,SAAS,UAAU;AACnB,SAAS,kBAAkB;AAC3B,SAAS,qBAAqB;AAC9B,SAAS,aAAa;AA0CtB,MAAM,mBAAqE;AAAA,EACzE,SAAS;AAAA,EACT,WAAW;AAAA,EACX,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,YAAY;AACd;AA8BO,SAAS,MAAM;AAAA,EACpB;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB;AAAA,EACA;AACF,GAAe;AACb,QAAM,YAAY,MAAM;AACxB,QAAM,KAAK,WAAW,GAAG,SAAS;AAClC,QAAM,SAAS,GAAG,SAAS;AAC3B,QAAM,UAAU,GAAG,SAAS;AAC5B,QAAM,UAAU,GAAG,SAAS;AAG5B,QAAM,OAAO,cAAc,QAAQ;AACnC,QAAM,UAAU,WAAW,IAAI;AAC/B,QAAM,WAAW,WAAW,KAAK;AACjC,QAAM,cAAc,CAAC,WAAW,QAAQ,YAAY,OAAO,EAAE,OAAO,OAAO,EAAE,KAAK,GAAG;AACrF,QAAM,UAA6B;AAAA,IACjC;AAAA,IACA,oBAAoB,eAAe;AAAA,IACnC,gBAAgB,YAAY;AAAA,IAC5B,iBAAiB,YAAY;AAAA,EAC/B;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,WAAW;AAAA;AAAA;AAAA;AAAA,QAIT;AAAA;AAAA;AAAA,QAGA,oBAAoB,aAAa;AAAA,QACjC;AAAA,MACF;AAAA,MAEC;AAAA,kBAAU,UACT;AAAA,UAAC;AAAA;AAAA,YACC,IAAI;AAAA,YACJ,SAAS;AAAA,YACT;AAAA,YACA;AAAA,YACA,MAAM;AAAA,YACN,cAAY,YAAY;AAAA,YACxB,WAAW,GAAG,iCAAiC,iBAAiB,eAAe,CAAC;AAAA,YAE/E;AAAA;AAAA,QACH;AAAA,QAED,OAAO,aAAa,aACjB,SAAS,SAAS,EAAE,SAAS,UAAU,SAAY,UAAU,OAAU,CAAC,IACxE;AAAA,QACH,WACC,oBAAC,OAAE,IAAI,QAAQ,WAAU,yCACtB,gBACH;AAAA,QAED,YACC,oBAAC,OAAE,IAAI,SAAS,WAAU,0CACvB,iBACH;AAAA;AAAA;AAAA,EAEJ;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/field.tsx"],"sourcesContent":["/**\n * A field group with its label ABOVE the control: label, control, hint, error.\n *\n * ```tsx\n * <Field label=\"IBAN\" required hint=\"22 characters\" error={errors.iban}>\n * {(ids) => <Input {...ids} value={iban} onChange={…} />}\n * </Field>\n * ```\n *\n * The kit's `Input` and `Select` float their label inside the field, and the pickers\n * draw theirs the same way. That is the wrong shape for a control the kit did not\n * render, a radio group, a range slider, or a form that sets its labels above the\n * fields throughout — which is what kastlan's wizards are, and why it kept its own\n * `WizardField` after the kit dropped the one it had never rendered. This is that\n * group, with the wiring the old one left to the caller: the label names the control\n * through `htmlFor`, and the control is described by the hint and the error that are\n * actually on screen.\n *\n * ## Wiring\n *\n * Pass a function as `children` and it receives {@link FieldControlProps} — `id`,\n * `aria-describedby`, `aria-invalid` and `aria-required` — to spread on the control.\n * Plain children render as they are, for a control that wires itself (a checkbox that\n * carries its own label, a group with `aria-labelledby`); `htmlFor` then points the\n * label at the control's own id, and the hint and error are on screen but describe\n * nothing — the same as the `WizardField` this replaces.\n *\n * ## Decoupled\n *\n * Deliberately knows neither the wizard nor react-hook-form: `error` is a plain\n * value. Pass `wizard.fieldErrors.iban`, a step-local error, or\n * `fieldState.error?.message` from a react-hook-form `Controller` — the markup is the\n * same whichever of them gates the step, so a step can move between them without its\n * fields changing. (Inside a react-hook-form form, `RhfField` from `@eifi1/ui-kit/rhf`\n * does the same job with the ids taken from the form.)\n *\n * The error is NOT `role=\"alert\"`, for the reason the kit's `error` prop gives (see\n * `useFieldError` in components/field-parts.tsx): it is read when focus reaches the control.\n */\nimport { useId, type ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { hasContent } from \"../lib/presence\";\nimport { useInlineHint } from \"./field-parts\";\nimport { Label } from \"./field-base\";\n\n/** What a `Field` hands its render-prop children, to spread on the control. */\nexport interface FieldControlProps {\n /** The id the label's `htmlFor` points at. */\n id: string;\n /** The ids of the hint and the error that are rendered, or `undefined` when\n * neither is. */\n \"aria-describedby\": string | undefined;\n /** `true` while the field has an error, `undefined` otherwise. */\n \"aria-invalid\": true | undefined;\n /** `true` when the field is `required`. The label's star is `aria-hidden`; this\n * is what a screen reader hears instead. */\n \"aria-required\": true | undefined;\n}\n\n/**\n * The second argument of a `Field`'s render-prop children: what a GROUP needs that\n * a single control does not. `htmlFor` names an `<input>`, but a `<label>` pointing\n * at a `role=\"radiogroup\"` div names nothing — so a group (a {@link ToggleGroup})\n * takes `aria-labelledby={labelId}` instead. Kept out of {@link FieldControlProps}\n * so the spread every existing control takes is unchanged.\n */\nexport interface FieldRenderMeta {\n /** The label element's id, or `undefined` when the field has no label. */\n labelId: string | undefined;\n}\n\n/**\n * Where the label is visible. `\"visible\"` (default); `\"sr-only\"`: never drawn, still\n * the control's name; `\"below-sm\"` / `\"below-md\"` / `\"below-lg\"`: drawn only under\n * that breakpoint and screen-reader-only from it up.\n *\n * For table-like editors — kastlan's journal entry lines\n * (accounting/components/wizard/journal-entry-lines-step.tsx) — where a desktop\n * row sits under column headers that already say \"Account\" and \"Amount\", but the\n * phone layout stacks each line as a card and needs the labels back. The name stays\n * on the control at every width; only the ink changes.\n */\nexport type FieldLabelVisibility = \"visible\" | \"sr-only\" | \"below-sm\" | \"below-md\" | \"below-lg\";\n\n// Literal class strings so Tailwind's scanner sees each one.\nconst LABEL_VISIBILITY: Record<FieldLabelVisibility, string | undefined> = {\n visible: undefined,\n \"sr-only\": \"sr-only\",\n \"below-sm\": \"sm:sr-only\",\n \"below-md\": \"md:sr-only\",\n \"below-lg\": \"lg:sr-only\",\n};\n\nexport interface FieldProps {\n /** The label above the control. Omit it for a group whose control is labelled\n * some other way; the hint and error still render. */\n label?: ReactNode;\n /** Draws the kit's required mark after the label, and sets `aria-required` on the\n * render-prop control. */\n required?: boolean;\n /** Muted helper text below the control. */\n hint?: ReactNode;\n /** The error below the control. `null`, `false` and `\"\"` are no error — what a\n * `touched && errors.x` evaluates to on the happy path. */\n error?: ReactNode;\n /** The control's id. Defaults to a generated one; set it when the control carries\n * its own id (plain children). */\n htmlFor?: string;\n /** Dims the label with its control. */\n disabled?: boolean;\n /** The label's size: `sm` for a dense row, `md` otherwise. */\n labelSize?: \"sm\" | \"md\";\n /** See {@link FieldLabelVisibility}. */\n labelVisibility?: FieldLabelVisibility;\n /** Wrapper className. */\n className?: string;\n /** The control. A function receives {@link FieldControlProps} to spread on it, and\n * {@link FieldRenderMeta} (the label's id, for a group). */\n children: ReactNode | ((control: FieldControlProps, meta: FieldRenderMeta) => ReactNode);\n}\n\nexport function Field({\n label,\n required,\n hint: hintProp,\n error,\n htmlFor,\n disabled,\n labelSize,\n labelVisibility = \"visible\",\n className,\n children,\n}: FieldProps) {\n const generated = useId();\n const id = htmlFor ?? `${generated}-control`;\n const hintId = `${generated}-hint`;\n const errorId = `${generated}-error`;\n const labelId = `${generated}-label`;\n // §4: a FieldHint under the control is its words at Large and on a touch screen, not\n // a lone \"?\" (DUP-2, refactor audit 2026-10). A HelpPopover stays a \"?\".\n const hint = useInlineHint(hintProp);\n const hasHint = hasContent(hint);\n const hasError = hasContent(error);\n const describedBy = [hasHint && hintId, hasError && errorId].filter(Boolean).join(\" \");\n const control: FieldControlProps = {\n id,\n \"aria-describedby\": describedBy || undefined,\n \"aria-invalid\": hasError || undefined,\n \"aria-required\": required || undefined,\n };\n return (\n <div\n data-slot=\"field\"\n className={cn(\n // `content-start`: in a grid row next to a taller item (one with a hint), a\n // stretched Field would hand the extra height to its rows — the label grew,\n // and the control sat lower than its neighbour's (showcase audit, #/forms).\n \"grid content-start gap-1.5\",\n // A hidden label is `position: absolute`; `relative` keeps its containing\n // block here rather than the page's (see sr-only-containment.test).\n labelVisibility !== \"visible\" && \"relative\",\n className,\n )}\n >\n {label !== undefined && (\n <Label\n id={labelId}\n htmlFor={id}\n required={required}\n disabled={disabled}\n size={labelSize}\n data-error={hasError || undefined}\n className={cn(\"data-[error=true]:text-danger\", LABEL_VISIBILITY[labelVisibility])}\n >\n {label}\n </Label>\n )}\n {typeof children === \"function\"\n ? children(control, { labelId: label !== undefined ? labelId : undefined })\n : children}\n {hasHint && (\n <p id={hintId} className=\"text-caption leading-tight text-muted\">\n {hint}\n </p>\n )}\n {hasError && (\n <p id={errorId} className=\"text-caption leading-tight text-danger\">\n {error}\n </p>\n )}\n </div>\n );\n}\n"],"mappings":";AAuJI,SAcI,KAdJ;AAhHJ,SAAS,aAA6B;AACtC,SAAS,UAAU;AACnB,SAAS,kBAAkB;AAC3B,SAAS,qBAAqB;AAC9B,SAAS,aAAa;AA0CtB,MAAM,mBAAqE;AAAA,EACzE,SAAS;AAAA,EACT,WAAW;AAAA,EACX,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,YAAY;AACd;AA8BO,SAAS,MAAM;AAAA,EACpB;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB;AAAA,EACA;AACF,GAAe;AACb,QAAM,YAAY,MAAM;AACxB,QAAM,KAAK,WAAW,GAAG,SAAS;AAClC,QAAM,SAAS,GAAG,SAAS;AAC3B,QAAM,UAAU,GAAG,SAAS;AAC5B,QAAM,UAAU,GAAG,SAAS;AAG5B,QAAM,OAAO,cAAc,QAAQ;AACnC,QAAM,UAAU,WAAW,IAAI;AAC/B,QAAM,WAAW,WAAW,KAAK;AACjC,QAAM,cAAc,CAAC,WAAW,QAAQ,YAAY,OAAO,EAAE,OAAO,OAAO,EAAE,KAAK,GAAG;AACrF,QAAM,UAA6B;AAAA,IACjC;AAAA,IACA,oBAAoB,eAAe;AAAA,IACnC,gBAAgB,YAAY;AAAA,IAC5B,iBAAiB,YAAY;AAAA,EAC/B;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,WAAW;AAAA;AAAA;AAAA;AAAA,QAIT;AAAA;AAAA;AAAA,QAGA,oBAAoB,aAAa;AAAA,QACjC;AAAA,MACF;AAAA,MAEC;AAAA,kBAAU,UACT;AAAA,UAAC;AAAA;AAAA,YACC,IAAI;AAAA,YACJ,SAAS;AAAA,YACT;AAAA,YACA;AAAA,YACA,MAAM;AAAA,YACN,cAAY,YAAY;AAAA,YACxB,WAAW,GAAG,iCAAiC,iBAAiB,eAAe,CAAC;AAAA,YAE/E;AAAA;AAAA,QACH;AAAA,QAED,OAAO,aAAa,aACjB,SAAS,SAAS,EAAE,SAAS,UAAU,SAAY,UAAU,OAAU,CAAC,IACxE;AAAA,QACH,WACC,oBAAC,OAAE,IAAI,QAAQ,WAAU,yCACtB,gBACH;AAAA,QAED,YACC,oBAAC,OAAE,IAAI,SAAS,WAAU,0CACvB,iBACH;AAAA;AAAA;AAAA,EAEJ;AAEJ;","names":[]}
@@ -39,9 +39,9 @@ function FullBleedDialog({
39
39
  const hasTitle = title !== void 0 && title !== null && title !== false;
40
40
  const hasHeader = header !== void 0 && header !== null && header !== false;
41
41
  useFocusTrap(dialogRef, { active: open });
42
- useDismissLayer(open, requestClose);
42
+ const layer = useDismissLayer(open, requestClose);
43
43
  const handleKeyDown = (e) => {
44
- runOwnKeyDown(e, onKeyDown);
44
+ runOwnKeyDown(e, onKeyDown, layer);
45
45
  takeEscape(e);
46
46
  };
47
47
  if (!open || typeof document === "undefined") return null;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/full-bleed-dialog.tsx"],"sourcesContent":["import { useId, useMemo, useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, Ref } from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { mergeRefs } from \"./field-parts\";\nimport { useBackdropClose } from \"./modal\";\nimport { useBodyScrollLock } from \"../hooks/use-body-scroll-lock\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\nimport { useCloseTransition } from \"../hooks/use-close-transition\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\nimport { runOwnKeyDown, takeEscape, useDismissLayer } from \"../hooks/use-dismiss\";\n\n/**\n * The padding around the dialog's scrolling body, as a CSS length (0.32,\n * docs/text-size-harmonization.md §10.11). Exported because a caller's sticky footer has\n * to reach across it: Chrome resolves a sticky offset against the scrollport's CONTENT\n * box, so a pinned row sits this far short of the panel's edge unless it cancels it\n * (keksdose's editor footer). In rem, so it grows with the text size — keksdose's px\n * copy (`SCROLLER_GUTTER_PX = 12`) was 3–6 px off at 125/150 %. Use it in `calc()`:\n * `bottom: calc(${inset}px - ${DIALOG_GUTTER})`.\n */\nexport const DIALOG_GUTTER = \"0.75rem\";\n\n/**\n * Named and exported, because `ComponentProps<typeof FullBleedDialog>` was the only way\n * to say \"the props of this\" and five call sites across keksdose and kastlan had already\n * written one (audit §api-design).\n *\n * It extends the `<div>` attributes so a caller can reach the OUTER element — the one\n * carrying `role=\"dialog\"`, and therefore the one an `aria-label`, an\n * `aria-describedby` or a `data-tour` anchor belongs on. {@link className} keeps going\n * to the panel inside it, which is what it has always meant here.\n */\nexport interface FullBleedDialogProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"title\"> {\n open: boolean;\n /** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —\n * the platform Back gesture all call this. */\n onClose: () => void;\n /**\n * The dialog's HEADING: rendered as a real `h2` (see {@link headingAs}) at the start\n * of the header strip, and wired to the dialog's `aria-labelledby`, so the dialog is\n * announced by name rather than as just \"dialog\".\n *\n * Before this the strip was a styled `div`, and a caller that wanted its full-screen\n * phone editor NAMED had to leave this component for `DialogFrame fullBleed` —\n * keksdose's budget-table phone editor says so in a comment at the call site. A\n * caller's own `aria-label` / `aria-labelledby` still wins.\n */\n title?: ReactNode;\n /** The heading level of {@link title}. `h2` by default, as `DialogFrame`'s. */\n headingAs?: \"h1\" | \"h2\" | \"h3\" | \"h4\";\n /**\n * Free content for the header strip, beside the close button — after the\n * {@link title} when there is one, or on its own (the row editor shows the row's\n * own first cell here). It does not name the dialog; `title` does.\n */\n header?: ReactNode;\n closeLabel: string;\n children: ReactNode;\n /**\n * Push a history entry so Back dismisses this dialog instead of navigating\n * (Keksdose feedback #172). On by default, because a full-screen form that ignores\n * the phone's universal \"go back\" gesture does not merely fail to close — the\n * navigation lands somewhere else with the form still notionally open.\n *\n * **Off for a caller whose open state is already in the URL.** The transactions\n * create card is `?action=new`, so Back already pops it through the router; a\n * sentinel on top of that would cost two presses to close one dialog. One owner of\n * the Back press, always.\n */\n backCloses?: boolean;\n /** Extra classes for the PANEL (not the backdrop). */\n className?: string;\n /**\n * A row pinned under the scrolling body — an Apply, a Save. Keksdose's `RangeSheet`\n * (reports/report-range-field.tsx) is a hand-rolled copy of this dialog for want of\n * exactly this slot: a date range picked on a phone is committed by a sticky Apply,\n * and a button at the end of the body scrolls away with the calendar.\n *\n * Outside the scroller, so it stays put without `position: sticky` (which would\n * need the body to be the scroll container of its own child and paint over the\n * last field); padded for the home indicator, since it is the panel's bottom edge.\n */\n footer?: ReactNode;\n /** The `role=\"dialog\"` element, where the rest of the props go (0.34, API-2, refactor\n * audit 2026-10) — merged with its own ref, which the focus trap holds. */\n ref?: Ref<HTMLDivElement>;\n}\n\n/**\n * The phone's full-screen dialog: a panel that covers the viewport edge to edge.\n *\n * Not {@link Modal}, and that is a decision rather than an omission. Keksdose feedback\n * #32: the inset panel #204 shipped left a strip of page showing beside it on a phone,\n * and tapping that strip dismissed a form mid-edit. With no backdrop exposed there is\n * nothing to mis-tap, so the X is the way out — `backdropClose` stays wired for any\n * layout that does leave a backdrop visible, and costs nothing where none is.\n *\n * ## Why it is a component\n *\n * It was markup inside `data-table.tsx`, where it is the phone's row EDITOR. Keksdose\n * live #307's follow-up needed the same shell for the transactions CREATE card —\n * *\"Is the creation window of the tx still different than the edit window? Since the tx\n * list still appears below when scrolling down?\"* — and the two answers to that are\n * either one component or two copies that drift. Every previous convergence in that app\n * drifted the moment a second surface was written beside the first, which is what\n * `field-layer-parity` exists to catch one layer down.\n *\n * So the panel, the header strip, the scrolling body, the body-scroll lock and the Back\n * handling are all here, once.\n *\n * ## What the caller still owns\n *\n * The HEADER's content, because the two callers name themselves differently: the row\n * editor shows the row's own first column, the create card a plain title. A plain\n * title belongs in `title`, which is a real heading and the dialog's name; `header`\n * stays the free slot beside it. And `open`,\n * because who decides is the caller's business — see {@link backCloses}.\n */\nexport function FullBleedDialog({\n open,\n onClose,\n title,\n headingAs: Heading = \"h2\",\n header,\n closeLabel,\n children,\n backCloses = true,\n className,\n footer,\n onMouseDown,\n onMouseUp,\n onKeyDown,\n ref,\n ...rest\n}: FullBleedDialogProps) {\n // Every way OUT goes through `requestClose`, so the panel lowers itself before the\n // caller unmounts it (live #320 rework). `onClose` is still what finally runs — this\n // only delays it by the length of the animation.\n const { closing, requestClose } = useCloseTransition(onClose);\n const backdropClose = useBackdropClose(requestClose);\n // The page behind must not scroll or jump under the overlay (feedback #204).\n useBodyScrollLock(open);\n useOverlayHistory(open && backCloses, requestClose);\n\n const dialogRef = useRef<HTMLDivElement>(null);\n const setDialog = useMemo(() => mergeRefs(dialogRef, ref), [ref]);\n const titleId = useId();\n const hasTitle = title !== undefined && title !== null && title !== false;\n const hasHeader = header !== undefined && header !== null && header !== false;\n // The half of \"modal\" this dialog was only claiming. It has said\n // `aria-modal=\"true\"` since it was extracted from `data-table.tsx`, which tells\n // assistive technology to hide everything outside it — while the user's focus stayed\n // on the row they tapped, in the part that is now hidden. Tab then walked them\n // through a table the screen reader would not describe, and on a phone this dialog\n // covers the whole screen, so there was nothing on screen to say where they were.\n //\n // `initialFocus` is left at the default — the dialog itself, not the first field.\n // This is the phone's row EDITOR and its first child is usually an input; focusing it\n // would pull the software keyboard up over half the form before the user has said\n // which field they came for. `Modal` made the same call for the same reason.\n //\n // That default only decides when nothing inside asked for focus. A field the app\n // autofocuses on purpose keeps it (0.33.1): keksdose's create card autofocuses its\n // amount, and the trap used to take focus straight back to the dialog — and on a\n // phone the numpad sheet, which shows only while the field is focused, went with it\n // (live #402). Closing still hands focus back to whatever opened the dialog.\n useFocusTrap(dialogRef, { active: open });\n\n // A layer on the Escape stack while open (Wave 2, DUP-1). This dialog is what a\n // `PickerSheet`, a list or the numpad opens on top of, and the stack hands the key\n // to whichever of them is topmost — one press dismissing the sheet AND the edit\n // behind it was Keksdose live #309 (*\"Mouse Back does not only close the select but\n // also the whole edit or create dialog\"*) on a different key.\n useDismissLayer(open, requestClose);\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n // The caller's handler first (keksdose's transaction-search keeps its list keys on\n // the dialog); a `preventDefault()` IN THAT HANDLER claims Escape. Only there: a\n // descendant's does not count. A SearchField in the header prevents Escape to stop\n // Chromium clearing a type=search input, and in 0.8.0 that silently stopped Escape\n // from closing the dialog (keksdose). The one rule `Modal` now shares.\n runOwnKeyDown(e, onKeyDown);\n takeEscape(e);\n };\n\n if (!open || typeof document === \"undefined\") return null;\n\n return createPortal(\n /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the dialog\n container, per the APG dialog pattern: `onKeyDown` is Escape-to-close for whatever\n inside it holds focus, and the mouse handlers are the backdrop press (Escape and the\n close button are its keyboard path). */\n <div\n // `animate-overlay` on the backdrop and `animate-sheet` on the panel (live #320):\n // the backdrop fades, the panel rises from the bottom edge it is anchored to.\n // Both are no-ops under prefers-reduced-motion — see tokens.css.\n //\n // …and the same two backwards while `closing`, which is the #320 rework. The\n // backdrop keeps taking pointer events on the way out: a tap during those 220ms\n // is a tap on a dialog that is leaving, and letting it through to the row\n // underneath would open a second one.\n //\n // `...rest` first, the dialog's own contract after it. A caller adding a\n // `data-tour` anchor must not be able to take away the role, the modality or the\n // `tabIndex` below by passing one of them too — this is the element the focus\n // trap holds, and each of those failures is invisible on screen.\n {...rest}\n className={cn(\n \"fixed inset-0 z-50 flex items-stretch justify-center bg-black/40 outline-none\",\n closing ? \"animate-overlay-out\" : \"animate-overlay\",\n )}\n role=\"dialog\"\n aria-modal=\"true\"\n // The heading names the dialog unless the caller named it some other way — an\n // `aria-labelledby` of its own, or an `aria-label` (which a `labelledby` would\n // silently outrank).\n aria-labelledby={rest[\"aria-labelledby\"] ?? (hasTitle && !rest[\"aria-label\"] ? titleId : undefined)}\n // `tabIndex={-1}` is what makes the line above more than a claim: without it the\n // container cannot take focus and `useFocusTrap` silently does nothing.\n tabIndex={-1}\n ref={setDialog}\n onKeyDown={handleKeyDown}\n // COMPOSED with the backdrop's, not overridden by them. These two used to ride\n // `...rest` and then lose to `{...backdropClose}` spread after it, silently —\n // and the one caller about to pass one is keksdose's RangeSheet, whose\n // `onMouseDown={(e) => e.stopPropagation()}` is its dev#477 fix: a press in a\n // portalled sheet must not reach a document-level \"outside click\" listener,\n // which would unmount the day cell before its `click`. The caller's runs first;\n // the backdrop's still runs after it, since it is the same element.\n onMouseDown={(e) => {\n onMouseDown?.(e);\n backdropClose.onMouseDown(e);\n }}\n onMouseUp={(e) => {\n onMouseUp?.(e);\n backdropClose.onMouseUp(e);\n }}\n >\n <div\n className={cn(\n \"flex h-full w-full flex-col overflow-hidden bg-surface shadow-xl\",\n closing ? \"animate-sheet-out\" : \"animate-sheet\",\n className,\n )}\n >\n <div className=\"flex items-center justify-between gap-2 border-b border-subtle px-3 py-3\">\n {hasTitle ? (\n <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n <Heading id={titleId} className=\"min-w-0 break-words text-base font-semibold leading-snug text-primary\">\n {title}\n </Heading>\n {hasHeader && <div className=\"min-w-0 flex-1 font-medium\">{header}</div>}\n </div>\n ) : (\n <div className=\"min-w-0 flex-1 font-medium\">{header}</div>\n )}\n <button\n type=\"button\"\n onClick={requestClose}\n aria-label={closeLabel}\n className=\"-me-1 shrink-0 rounded p-1.5 text-muted hover:bg-hover hover:text-secondary\"\n >\n <X className=\"size-5\" />\n </button>\n </div>\n {/* DIALOG_GUTTER (0.75rem, the px-3 py-3 it was), not px-4: every pixel of\n chrome here is width the form fields lose on a phone (feedback #32). The\n body is the only thing that scrolls, so the header stays put and the page\n underneath cannot move at all. */}\n {/* `min-h-0` so it is the BODY that gives way to a footer, not the footer that\n is pushed off the bottom edge. */}\n <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\" style={{ padding: DIALOG_GUTTER }}>\n {children}\n </div>\n {footer !== undefined && footer !== null && footer !== false && (\n <div\n data-full-bleed-footer=\"\"\n className=\"flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-subtle px-3 pt-3\"\n style={{ paddingBottom: \"max(0.75rem, env(safe-area-inset-bottom))\" }}\n >\n {footer}\n </div>\n )}\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/full-bleed-dialog.tsx"],"sourcesContent":["import { useId, useMemo, useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, Ref } from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { mergeRefs } from \"./field-parts\";\nimport { useBackdropClose } from \"./modal\";\nimport { useBodyScrollLock } from \"../hooks/use-body-scroll-lock\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\nimport { useCloseTransition } from \"../hooks/use-close-transition\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\nimport { runOwnKeyDown, takeEscape, useDismissLayer } from \"../hooks/use-dismiss\";\n\n/**\n * The padding around the dialog's scrolling body, as a CSS length (0.32,\n * docs/text-size-harmonization.md §10.11). Exported because a caller's sticky footer has\n * to reach across it: Chrome resolves a sticky offset against the scrollport's CONTENT\n * box, so a pinned row sits this far short of the panel's edge unless it cancels it\n * (keksdose's editor footer). In rem, so it grows with the text size — keksdose's px\n * copy (`SCROLLER_GUTTER_PX = 12`) was 3–6 px off at 125/150 %. Use it in `calc()`:\n * `bottom: calc(${inset}px - ${DIALOG_GUTTER})`.\n */\nexport const DIALOG_GUTTER = \"0.75rem\";\n\n/**\n * Named and exported, because `ComponentProps<typeof FullBleedDialog>` was the only way\n * to say \"the props of this\" and five call sites across keksdose and kastlan had already\n * written one (audit §api-design).\n *\n * It extends the `<div>` attributes so a caller can reach the OUTER element — the one\n * carrying `role=\"dialog\"`, and therefore the one an `aria-label`, an\n * `aria-describedby` or a `data-tour` anchor belongs on. {@link className} keeps going\n * to the panel inside it, which is what it has always meant here.\n */\nexport interface FullBleedDialogProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"title\"> {\n open: boolean;\n /** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —\n * the platform Back gesture all call this. */\n onClose: () => void;\n /**\n * The dialog's HEADING: rendered as a real `h2` (see {@link headingAs}) at the start\n * of the header strip, and wired to the dialog's `aria-labelledby`, so the dialog is\n * announced by name rather than as just \"dialog\".\n *\n * Before this the strip was a styled `div`, and a caller that wanted its full-screen\n * phone editor NAMED had to leave this component for `DialogFrame fullBleed` —\n * keksdose's budget-table phone editor says so in a comment at the call site. A\n * caller's own `aria-label` / `aria-labelledby` still wins.\n */\n title?: ReactNode;\n /** The heading level of {@link title}. `h2` by default, as `DialogFrame`'s. */\n headingAs?: \"h1\" | \"h2\" | \"h3\" | \"h4\";\n /**\n * Free content for the header strip, beside the close button — after the\n * {@link title} when there is one, or on its own (the row editor shows the row's\n * own first cell here). It does not name the dialog; `title` does.\n */\n header?: ReactNode;\n closeLabel: string;\n children: ReactNode;\n /**\n * Push a history entry so Back dismisses this dialog instead of navigating\n * (Keksdose feedback #172). On by default, because a full-screen form that ignores\n * the phone's universal \"go back\" gesture does not merely fail to close — the\n * navigation lands somewhere else with the form still notionally open.\n *\n * **Off for a caller whose open state is already in the URL.** The transactions\n * create card is `?action=new`, so Back already pops it through the router; a\n * sentinel on top of that would cost two presses to close one dialog. One owner of\n * the Back press, always.\n */\n backCloses?: boolean;\n /** Extra classes for the PANEL (not the backdrop). */\n className?: string;\n /**\n * A row pinned under the scrolling body — an Apply, a Save. Keksdose's `RangeSheet`\n * (reports/report-range-field.tsx) is a hand-rolled copy of this dialog for want of\n * exactly this slot: a date range picked on a phone is committed by a sticky Apply,\n * and a button at the end of the body scrolls away with the calendar.\n *\n * Outside the scroller, so it stays put without `position: sticky` (which would\n * need the body to be the scroll container of its own child and paint over the\n * last field); padded for the home indicator, since it is the panel's bottom edge.\n */\n footer?: ReactNode;\n /** The `role=\"dialog\"` element, where the rest of the props go (0.34, API-2, refactor\n * audit 2026-10) — merged with its own ref, which the focus trap holds. */\n ref?: Ref<HTMLDivElement>;\n}\n\n/**\n * The phone's full-screen dialog: a panel that covers the viewport edge to edge.\n *\n * Not {@link Modal}, and that is a decision rather than an omission. Keksdose feedback\n * #32: the inset panel #204 shipped left a strip of page showing beside it on a phone,\n * and tapping that strip dismissed a form mid-edit. With no backdrop exposed there is\n * nothing to mis-tap, so the X is the way out — `backdropClose` stays wired for any\n * layout that does leave a backdrop visible, and costs nothing where none is.\n *\n * ## Why it is a component\n *\n * It was markup inside `data-table.tsx`, where it is the phone's row EDITOR. Keksdose\n * live #307's follow-up needed the same shell for the transactions CREATE card —\n * *\"Is the creation window of the tx still different than the edit window? Since the tx\n * list still appears below when scrolling down?\"* — and the two answers to that are\n * either one component or two copies that drift. Every previous convergence in that app\n * drifted the moment a second surface was written beside the first, which is what\n * `field-layer-parity` exists to catch one layer down.\n *\n * So the panel, the header strip, the scrolling body, the body-scroll lock and the Back\n * handling are all here, once.\n *\n * ## What the caller still owns\n *\n * The HEADER's content, because the two callers name themselves differently: the row\n * editor shows the row's own first column, the create card a plain title. A plain\n * title belongs in `title`, which is a real heading and the dialog's name; `header`\n * stays the free slot beside it. And `open`,\n * because who decides is the caller's business — see {@link backCloses}.\n */\nexport function FullBleedDialog({\n open,\n onClose,\n title,\n headingAs: Heading = \"h2\",\n header,\n closeLabel,\n children,\n backCloses = true,\n className,\n footer,\n onMouseDown,\n onMouseUp,\n onKeyDown,\n ref,\n ...rest\n}: FullBleedDialogProps) {\n // Every way OUT goes through `requestClose`, so the panel lowers itself before the\n // caller unmounts it (live #320 rework). `onClose` is still what finally runs — this\n // only delays it by the length of the animation.\n const { closing, requestClose } = useCloseTransition(onClose);\n const backdropClose = useBackdropClose(requestClose);\n // The page behind must not scroll or jump under the overlay (feedback #204).\n useBodyScrollLock(open);\n useOverlayHistory(open && backCloses, requestClose);\n\n const dialogRef = useRef<HTMLDivElement>(null);\n const setDialog = useMemo(() => mergeRefs(dialogRef, ref), [ref]);\n const titleId = useId();\n const hasTitle = title !== undefined && title !== null && title !== false;\n const hasHeader = header !== undefined && header !== null && header !== false;\n // The half of \"modal\" this dialog was only claiming. It has said\n // `aria-modal=\"true\"` since it was extracted from `data-table.tsx`, which tells\n // assistive technology to hide everything outside it — while the user's focus stayed\n // on the row they tapped, in the part that is now hidden. Tab then walked them\n // through a table the screen reader would not describe, and on a phone this dialog\n // covers the whole screen, so there was nothing on screen to say where they were.\n //\n // `initialFocus` is left at the default — the dialog itself, not the first field.\n // This is the phone's row EDITOR and its first child is usually an input; focusing it\n // would pull the software keyboard up over half the form before the user has said\n // which field they came for. `Modal` made the same call for the same reason.\n //\n // That default only decides when nothing inside asked for focus. A field the app\n // autofocuses on purpose keeps it (0.33.1): keksdose's create card autofocuses its\n // amount, and the trap used to take focus straight back to the dialog — and on a\n // phone the numpad sheet, which shows only while the field is focused, went with it\n // (live #402). Closing still hands focus back to whatever opened the dialog.\n useFocusTrap(dialogRef, { active: open });\n\n // A layer on the Escape stack while open (Wave 2, DUP-1). This dialog is what a\n // `PickerSheet`, a list or the numpad opens on top of, and the stack hands the key\n // to whichever of them is topmost — one press dismissing the sheet AND the edit\n // behind it was Keksdose live #309 (*\"Mouse Back does not only close the select but\n // also the whole edit or create dialog\"*) on a different key.\n const layer = useDismissLayer(open, requestClose);\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n // The caller's handler first (keksdose's transaction-search keeps its list keys on\n // the dialog); a `preventDefault()` IN THAT HANDLER claims Escape. Only there: a\n // descendant's does not count. A SearchField in the header prevents Escape to stop\n // Chromium clearing a type=search input, and in 0.8.0 that silently stopped Escape\n // from closing the dialog (keksdose). The one rule `Modal` now shares — as is this\n // one: the handler sees Escape only while this dialog is the layer on top (0.35).\n runOwnKeyDown(e, onKeyDown, layer);\n takeEscape(e);\n };\n\n if (!open || typeof document === \"undefined\") return null;\n\n return createPortal(\n /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the dialog\n container, per the APG dialog pattern: `onKeyDown` is Escape-to-close for whatever\n inside it holds focus, and the mouse handlers are the backdrop press (Escape and the\n close button are its keyboard path). */\n <div\n // `animate-overlay` on the backdrop and `animate-sheet` on the panel (live #320):\n // the backdrop fades, the panel rises from the bottom edge it is anchored to.\n // Both are no-ops under prefers-reduced-motion — see tokens.css.\n //\n // …and the same two backwards while `closing`, which is the #320 rework. The\n // backdrop keeps taking pointer events on the way out: a tap during those 220ms\n // is a tap on a dialog that is leaving, and letting it through to the row\n // underneath would open a second one.\n //\n // `...rest` first, the dialog's own contract after it. A caller adding a\n // `data-tour` anchor must not be able to take away the role, the modality or the\n // `tabIndex` below by passing one of them too — this is the element the focus\n // trap holds, and each of those failures is invisible on screen.\n {...rest}\n className={cn(\n \"fixed inset-0 z-50 flex items-stretch justify-center bg-black/40 outline-none\",\n closing ? \"animate-overlay-out\" : \"animate-overlay\",\n )}\n role=\"dialog\"\n aria-modal=\"true\"\n // The heading names the dialog unless the caller named it some other way — an\n // `aria-labelledby` of its own, or an `aria-label` (which a `labelledby` would\n // silently outrank).\n aria-labelledby={rest[\"aria-labelledby\"] ?? (hasTitle && !rest[\"aria-label\"] ? titleId : undefined)}\n // `tabIndex={-1}` is what makes the line above more than a claim: without it the\n // container cannot take focus and `useFocusTrap` silently does nothing.\n tabIndex={-1}\n ref={setDialog}\n onKeyDown={handleKeyDown}\n // COMPOSED with the backdrop's, not overridden by them. These two used to ride\n // `...rest` and then lose to `{...backdropClose}` spread after it, silently —\n // and the one caller about to pass one is keksdose's RangeSheet, whose\n // `onMouseDown={(e) => e.stopPropagation()}` is its dev#477 fix: a press in a\n // portalled sheet must not reach a document-level \"outside click\" listener,\n // which would unmount the day cell before its `click`. The caller's runs first;\n // the backdrop's still runs after it, since it is the same element.\n onMouseDown={(e) => {\n onMouseDown?.(e);\n backdropClose.onMouseDown(e);\n }}\n onMouseUp={(e) => {\n onMouseUp?.(e);\n backdropClose.onMouseUp(e);\n }}\n >\n <div\n className={cn(\n \"flex h-full w-full flex-col overflow-hidden bg-surface shadow-xl\",\n closing ? \"animate-sheet-out\" : \"animate-sheet\",\n className,\n )}\n >\n <div className=\"flex items-center justify-between gap-2 border-b border-subtle px-3 py-3\">\n {hasTitle ? (\n <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n <Heading id={titleId} className=\"min-w-0 break-words text-base font-semibold leading-snug text-primary\">\n {title}\n </Heading>\n {hasHeader && <div className=\"min-w-0 flex-1 font-medium\">{header}</div>}\n </div>\n ) : (\n <div className=\"min-w-0 flex-1 font-medium\">{header}</div>\n )}\n <button\n type=\"button\"\n onClick={requestClose}\n aria-label={closeLabel}\n className=\"-me-1 shrink-0 rounded p-1.5 text-muted hover:bg-hover hover:text-secondary\"\n >\n <X className=\"size-5\" />\n </button>\n </div>\n {/* DIALOG_GUTTER (0.75rem, the px-3 py-3 it was), not px-4: every pixel of\n chrome here is width the form fields lose on a phone (feedback #32). The\n body is the only thing that scrolls, so the header stays put and the page\n underneath cannot move at all. */}\n {/* `min-h-0` so it is the BODY that gives way to a footer, not the footer that\n is pushed off the bottom edge. */}\n <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\" style={{ padding: DIALOG_GUTTER }}>\n {children}\n </div>\n {footer !== undefined && footer !== null && footer !== false && (\n <div\n data-full-bleed-footer=\"\"\n className=\"flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-subtle px-3 pt-3\"\n style={{ paddingBottom: \"max(0.75rem, env(safe-area-inset-bottom))\" }}\n >\n {footer}\n </div>\n )}\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":"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","names":[]}
@@ -17,12 +17,13 @@ export interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<"div">, "c
17
17
  }) => ReactNode;
18
18
  children: (close: () => void) => ReactNode;
19
19
  /**
20
- * Which edge of the trigger the panel lines up with. `start`/`end` follow the
21
- * reading direction (`end` is the right edge in LTR, the left in RTL); `left`/`right`
22
- * are physical and deprecated ({@link PhysicalAlign}). Default `end` — the same
23
- * right-aligned panel as before in LTR, mirrored in RTL.
20
+ * Which edge of the trigger the panel lines up with, by the reading direction: `end`
21
+ * is the right edge in LTR and the left in RTL. Default `end` — the same right-aligned
22
+ * panel as before in LTR, mirrored in RTL. The physical `left` / `right` went in 0.35
23
+ * (round 0.35 R1, refactor audit 2026-10, API-6e): `right` is `end` in a left-to-right
24
+ * page, `left` is `start`.
24
25
  */
25
- align?: "start" | "end" | PhysicalAlign;
26
+ align?: "start" | "end";
26
27
  panelClassName?: string;
27
28
  /** Extra classes for the WRAPPER (the panel has {@link panelClassName}). */
28
29
  className?: string;
@@ -30,12 +31,4 @@ export interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<"div">, "c
30
31
  * merged with its own ref, which the outside click and the hover are measured on. */
31
32
  ref?: Ref<HTMLDivElement>;
32
33
  }
33
- /**
34
- * @deprecated 0.34 — `"start"` / `"end"`, which follow the reading direction: `"right"`
35
- * is `"end"` in a left-to-right page, the default; removed in 0.35 (refactor audit
36
- * 2026-10, API-6e). The physical edges HoverMenu's `align` — and TopBarActionMenu's and
37
- * FeedbackMenu's, which hand it on — still take.
38
- */
39
- type PhysicalAlign = "left" | "right";
40
34
  export declare function HoverMenu({ trigger, children, align, panelClassName, className, "aria-label": ariaLabel, ref, ...rest }: HoverMenuProps): import("react").JSX.Element;
41
- export {};
@@ -8,7 +8,7 @@ import { anchoredInline } from "../hooks/use-anchored-panel.js";
8
8
  import { dirOf } from "../lib/direction.js";
9
9
  const OPEN_DELAY_MS = 120;
10
10
  const CLOSE_DELAY_MS = 120;
11
- const ALIGN_CLASS = { start: "start-0", end: "end-0", left: "left-0", right: "right-0" };
11
+ const ALIGN_CLASS = { start: "start-0", end: "end-0" };
12
12
  const ITEM_ROLES = '[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]';
13
13
  const PLAIN_ITEMS = "button:not([role]),a[href]:not([role])";
14
14
  const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])';
@@ -90,7 +90,7 @@ function HoverMenu({
90
90
  (el) => !menuRef.current?.contains(el)
91
91
  ) ?? null;
92
92
  const items = () => menuRef.current ? applyMenuSemantics(menuRef.current) : [];
93
- useDismissLayer(open, () => {
93
+ const layer = useDismissLayer(open, () => {
94
94
  const hadFocus = wrapperRef.current?.contains(document.activeElement) ?? false;
95
95
  close();
96
96
  if (hadFocus) triggerEl()?.focus();
@@ -176,7 +176,7 @@ function HoverMenu({
176
176
  onMouseEnter: handleMouseEnter,
177
177
  onMouseLeave: handleMouseLeave,
178
178
  onKeyDown: (e) => {
179
- runOwnKeyDown(e, rest.onKeyDown);
179
+ runOwnKeyDown(e, rest.onKeyDown, layer);
180
180
  if (open) takeEscape(e);
181
181
  onKeyDown(e);
182
182
  },
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/hover-menu.tsx"],"sourcesContent":["import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent as ReactKeyboardEvent, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { mergeRefs } from \"./field-parts\";\nimport { runOwnKeyDown, takeEscape, useDismissLayer, useOutsideClick } from \"../hooks/use-dismiss\";\nimport { anchoredInline } from \"../hooks/use-anchored-panel\";\nimport { dirOf } from \"../lib/direction\";\n\n/**\n * Exported and `<div>`-shaped: the top bar composes several of these side by side, and\n * telling them apart — in a test, in a guided tour, in an accessibility tree — needs an\n * attribute the kit does not know about. A closed prop list dropped every one of them.\n *\n * `children` is omitted from the div attributes because here it is a render prop taking\n * the panel's `close`, not a `ReactNode`.\n */\nexport interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n /** The trigger. `toggle` for a button's click; `close` for a trigger that is a LINK —\n * following it should shut a panel hover opened (TopBarActionMenu `href`). */\n trigger: (state: { open: boolean; toggle: () => void; close: () => void }) => ReactNode;\n children: (close: () => void) => ReactNode;\n /**\n * Which edge of the trigger the panel lines up with. `start`/`end` follow the\n * reading direction (`end` is the right edge in LTR, the left in RTL); `left`/`right`\n * are physical and deprecated ({@link PhysicalAlign}). Default `end` — the same\n * right-aligned panel as before in LTR, mirrored in RTL.\n */\n align?: \"start\" | \"end\" | PhysicalAlign;\n panelClassName?: string;\n /** Extra classes for the WRAPPER (the panel has {@link panelClassName}). */\n className?: string;\n /** The WRAPPER, where the rest of the props go (0.34, API-2, refactor audit 2026-10) —\n * merged with its own ref, which the outside click and the hover are measured on. */\n ref?: Ref<HTMLDivElement>;\n}\n\n/**\n * @deprecated 0.34 — `\"start\"` / `\"end\"`, which follow the reading direction: `\"right\"`\n * is `\"end\"` in a left-to-right page, the default; removed in 0.35 (refactor audit\n * 2026-10, API-6e). The physical edges HoverMenu's `align` — and TopBarActionMenu's and\n * FeedbackMenu's, which hand it on — still take.\n */\ntype PhysicalAlign = \"left\" | \"right\";\n\n// Hovering waits briefly before opening so sweeping the cursor across a row of\n// triggers doesn't flash menus; closing waits so the cursor can travel from\n// trigger to panel. Click/keyboard toggling is instant (no \"opening\" phase).\nconst OPEN_DELAY_MS = 120;\nconst CLOSE_DELAY_MS = 120;\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\nconst ALIGN_CLASS = { start: \"start-0\", end: \"end-0\", left: \"left-0\", right: \"right-0\" } as const;\n\n/** Explicit menu-item roles a caller may already have written. */\nconst ITEM_ROLES = '[role=\"menuitem\"],[role=\"menuitemcheckbox\"],[role=\"menuitemradio\"]';\n/** What the panel promotes to `menuitem` when the caller wrote plain controls. */\nconst PLAIN_ITEMS = \"button:not([role]),a[href]:not([role])\";\n/** The trigger: the first focusable in the wrapper that is not inside the panel. */\nconst FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\n/**\n * Give the caller's markup menu semantics.\n *\n * `children` is arbitrary — every caller in the kit renders a `<ul>` of `<li>`s holding\n * `<button>`s and router `<Link>`s — so the panel was a `role=\"menu\"` whose children\n * were a list, list items and plain buttons: no `menuitem` anywhere, which is a menu\n * with nothing in it as far as a screen reader is concerned. Rather than make every\n * caller rewrite its rows, the panel upgrades them after render: controls become\n * `menuitem`s out of the Tab order (the arrow keys move between them), and the list\n * markup in between becomes `role=\"none\"` so the menu owns its items directly. An\n * element that already carries a role is the caller's decision and is left alone.\n */\nfunction applyMenuSemantics(menu: HTMLElement): HTMLElement[] {\n for (const el of menu.querySelectorAll<HTMLElement>(PLAIN_ITEMS)) el.setAttribute(\"role\", \"menuitem\");\n const items = Array.from(menu.querySelectorAll<HTMLElement>(ITEM_ROLES));\n for (const item of items) {\n item.setAttribute(\"tabindex\", \"-1\");\n for (let el = item.parentElement; el && el !== menu; el = el.parentElement) {\n if (!el.hasAttribute(\"role\")) el.setAttribute(\"role\", \"none\");\n }\n }\n for (const el of menu.querySelectorAll<HTMLElement>(\"ul:not([role]),ol:not([role]),li:not([role])\")) {\n el.setAttribute(\"role\", \"none\");\n }\n return items.filter((el) => !el.hasAttribute(\"disabled\") && el.getAttribute(\"aria-disabled\") !== \"true\");\n}\n\n// Only one hover menu may show its panel at a time across the app — opening\n// one closes whichever was open, so adjacent top-bar menus can't overlap\n// (feedback #251). Module-level is fine: there is one cursor per document.\nlet activeClose: (() => void) | null = null;\n\nexport function HoverMenu({\n trigger,\n children,\n align = \"end\",\n panelClassName,\n className,\n \"aria-label\": ariaLabel,\n ref,\n ...rest\n}: HoverMenuProps) {\n const wrapperRef = useRef<HTMLDivElement>(null);\n const setWrapper = useMemo(() => mergeRefs(wrapperRef, ref), [ref]);\n const [phase, setPhase] = useState<Phase>(\"closed\");\n const open = phase === \"open\" || phase === \"closing\";\n\n // Whether the panel is open *because hover opened it*, vs. an explicit\n // click/keyboard toggle. A real mouse click on the trigger is always\n // preceded by a real mouseenter — the cursor has to land on the button\n // before it can be clicked — so once hover has opened the panel, the click\n // that inevitably follows is the same physical gesture, not a request to\n // close what was just opened (feedback #19). Toggling closed on click stays\n // for keyboard/touch activation, which reaches \"open\" without going through\n // the hover path.\n //\n // State rather than a ref: `close` is handed to the caller's render prop, and a\n // function that writes a ref must not be reachable from render.\n const [openedByHover, setOpenedByHover] = useState(false);\n\n const close = useCallback(() => {\n setOpenedByHover(false);\n setPhase(\"closed\");\n }, []);\n\n const toggle = useCallback(() => {\n if (open) {\n if (!openedByHover) setPhase(\"closed\");\n return;\n }\n setOpenedByHover(false);\n setPhase(\"open\");\n }, [open, openedByHover]);\n\n const handleMouseEnter = useCallback(\n () => setPhase((p) => (p === \"closing\" ? \"open\" : p === \"closed\" ? \"opening\" : p)),\n [],\n );\n\n const handleMouseLeave = useCallback(\n () => setPhase((p) => (p === \"opening\" ? \"closed\" : p === \"open\" ? \"closing\" : p)),\n [],\n );\n\n // Drive the delayed transitions; unmount/phase changes cancel the timer.\n useEffect(() => {\n if (phase === \"opening\") {\n const id = setTimeout(() => {\n setOpenedByHover(true);\n setPhase(\"open\");\n }, OPEN_DELAY_MS);\n return () => clearTimeout(id);\n }\n if (phase === \"closing\") {\n const id = setTimeout(() => setPhase(\"closed\"), CLOSE_DELAY_MS);\n return () => clearTimeout(id);\n }\n }, [phase]);\n\n // Claim the \"active menu\" slot while our panel is visible, closing the\n // previous holder. Cleanup releases the slot only if we still hold it\n // (a successor that displaced us has already overwritten it).\n useEffect(() => {\n if (!open) return;\n if (activeClose !== close) activeClose?.();\n activeClose = close;\n return () => {\n if (activeClose === close) activeClose = null;\n };\n }, [open, close]);\n\n useOutsideClick(wrapperRef, close, open);\n\n const menuRef = useRef<HTMLDivElement>(null);\n /** Set by a keyboard press on the trigger, so the open that follows moves focus into\n * the menu. A hover or a mouse click opens it without taking focus off the page. */\n const focusOnOpenRef = useRef<\"first\" | \"last\" | null>(null);\n\n const triggerEl = () =>\n Array.from(wrapperRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []).find(\n (el) => !menuRef.current?.contains(el),\n ) ?? null;\n const items = () => (menuRef.current ? applyMenuSemantics(menuRef.current) : []);\n\n // Escape closes, from anywhere — it did nothing at all before — as a layer on the kit's\n // Escape stack, so inside a dialog it closes the menu and not the dialog (Wave 2,\n // DUP-1). Focus goes back to the trigger only when it was inside the menu: a panel\n // opened by hover while the user is typing elsewhere must not yank the caret over\n // here on the way out.\n useDismissLayer(open, () => {\n const hadFocus = wrapperRef.current?.contains(document.activeElement) ?? false;\n close();\n if (hadFocus) triggerEl()?.focus();\n });\n\n // Upgrade the markup every render while open (the caller's rows can change), then\n // honour a pending keyboard open.\n useLayoutEffect(() => {\n if (!open) return;\n const list = items();\n const want = focusOnOpenRef.current;\n focusOnOpenRef.current = null;\n if (want) (want === \"last\" ? list[list.length - 1] : list[0])?.focus();\n });\n\n // Trigger state for assistive technology, on whatever element the caller rendered.\n // Only where the caller has not said something of its own.\n useLayoutEffect(() => {\n const el = triggerEl();\n if (!el) return;\n if (!el.hasAttribute(\"aria-haspopup\")) el.setAttribute(\"aria-haspopup\", \"menu\");\n if (el.getAttribute(\"aria-haspopup\") === \"menu\") el.setAttribute(\"aria-expanded\", String(open));\n });\n\n /** ↑/↓ roving focus with wrap, Home/End, Tab leaves (and closes); on the trigger,\n * Enter/Space/↓ open onto the first item and ↑ onto the last. */\n const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n const target = e.target as HTMLElement;\n const inMenu = menuRef.current?.contains(target) ?? false;\n if (!inMenu) {\n if (target !== triggerEl()) return;\n if (e.key === \"Enter\" || e.key === \" \") {\n // The trigger's own click handler does the toggling; this only records that\n // the press came from a keyboard. Not for a link trigger: Enter follows it, and\n // a mark left here would pull focus into the panel the next time hover opens it.\n if (!open && !target.matches(\"a[href]\")) focusOnOpenRef.current = \"first\";\n } else if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n const edge = e.key === \"ArrowDown\" ? \"first\" : \"last\";\n if (open) {\n const list = items();\n (edge === \"last\" ? list[list.length - 1] : list[0])?.focus();\n } else {\n focusOnOpenRef.current = edge;\n setOpenedByHover(false);\n setPhase(\"open\");\n }\n }\n return;\n }\n if (e.key === \"Tab\") {\n close();\n return;\n }\n const list = items();\n if (list.length === 0) return;\n const at = list.indexOf(document.activeElement as HTMLElement);\n let next: number | null = null;\n if (e.key === \"ArrowDown\") next = at < 0 ? 0 : (at + 1) % list.length;\n else if (e.key === \"ArrowUp\") next = at < 0 ? list.length - 1 : (at - 1 + list.length) % list.length;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = list.length - 1;\n if (next === null) return;\n e.preventDefault();\n list[next].focus();\n };\n\n // Keep the panel inside the viewport (feedback #85): a wide panel anchored to a\n // trigger near a screen edge would otherwise spill off it (on mobile the guided-\n // tours menu ran off the left). Once open, measure and translate it horizontally\n // just enough to sit within an 8px margin of the GLASS — `anchoredInline` against\n // the panel's own natural box, as every anchored panel clamps (Wave 2, DUP-7; it\n // measured against `innerWidth`, which on a phone is the grown layout viewport).\n // Recomputed on each open; `shiftX` is subtracted first so the measurement is\n // against the natural position.\n const panelRef = useRef<HTMLDivElement>(null);\n const [shiftX, setShiftX] = useState(0);\n // Back to 0 on close, adjusted while rendering against the previous `open`.\n const [wasOpen, setWasOpen] = useState(open);\n if (open !== wasOpen) {\n setWasOpen(open);\n if (!open) setShiftX(0);\n }\n useLayoutEffect(() => {\n if (!open) return;\n const el = panelRef.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n if (rect.width === 0) return; // unmeasured (e.g. jsdom) — leave as-is\n const natural = { left: rect.left - shiftX, right: rect.right - shiftX };\n const { left } = anchoredInline(natural, rect.width, { dir: dirOf(el), align: \"left\" });\n setShiftX(left - natural.left);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- recompute only on open/close\n }, [open]);\n\n return (\n /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- never focused\n itself: the mouse handlers are the hover-to-open convenience, and `onKeyDown`\n catches keys bubbling from the caller's trigger button and the menuitems, which\n are the keyboard path (Enter/Space/arrows open, arrows move, Escape closes). */\n <div\n // `...rest` first: the hover handlers below are the whole component, and a caller\n // passing one of its own must not silently replace them.\n {...rest}\n ref={setWrapper}\n className={cn(\"relative\", className)}\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n onKeyDown={(e) => {\n runOwnKeyDown(e, rest.onKeyDown);\n // Focus on the trigger or in the menu: the key stops here, so a panel around\n // the menu does not close with it.\n if (open) takeEscape(e);\n onKeyDown(e);\n }}\n >\n {trigger({ open, toggle, close })}\n {open && (\n <div\n ref={panelRef}\n className={cn(\n \"absolute top-full z-40 pt-2\",\n ALIGN_CLASS[align],\n )}\n style={shiftX ? { transform: `translateX(${shiftX}px)` } : undefined}\n >\n <div\n ref={menuRef}\n role=\"menu\"\n // On the element with the role. It used to sit on the role-less wrapper,\n // where no screen reader reads a name.\n aria-label={ariaLabel}\n className={cn(\n \"min-w-44 max-w-[calc(100vw-1rem)] rounded-md border border-subtle bg-surface shadow-lg\",\n panelClassName,\n )}\n >\n {children(close)}\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["list"]}
1
+ {"version":3,"sources":["../../src/components/hover-menu.tsx"],"sourcesContent":["import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent as ReactKeyboardEvent, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { mergeRefs } from \"./field-parts\";\nimport { runOwnKeyDown, takeEscape, useDismissLayer, useOutsideClick } from \"../hooks/use-dismiss\";\nimport { anchoredInline } from \"../hooks/use-anchored-panel\";\nimport { dirOf } from \"../lib/direction\";\n\n/**\n * Exported and `<div>`-shaped: the top bar composes several of these side by side, and\n * telling them apart — in a test, in a guided tour, in an accessibility tree — needs an\n * attribute the kit does not know about. A closed prop list dropped every one of them.\n *\n * `children` is omitted from the div attributes because here it is a render prop taking\n * the panel's `close`, not a `ReactNode`.\n */\nexport interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n /** The trigger. `toggle` for a button's click; `close` for a trigger that is a LINK —\n * following it should shut a panel hover opened (TopBarActionMenu `href`). */\n trigger: (state: { open: boolean; toggle: () => void; close: () => void }) => ReactNode;\n children: (close: () => void) => ReactNode;\n /**\n * Which edge of the trigger the panel lines up with, by the reading direction: `end`\n * is the right edge in LTR and the left in RTL. Default `end` — the same right-aligned\n * panel as before in LTR, mirrored in RTL. The physical `left` / `right` went in 0.35\n * (round 0.35 R1, refactor audit 2026-10, API-6e): `right` is `end` in a left-to-right\n * page, `left` is `start`.\n */\n align?: \"start\" | \"end\";\n panelClassName?: string;\n /** Extra classes for the WRAPPER (the panel has {@link panelClassName}). */\n className?: string;\n /** The WRAPPER, where the rest of the props go (0.34, API-2, refactor audit 2026-10) —\n * merged with its own ref, which the outside click and the hover are measured on. */\n ref?: Ref<HTMLDivElement>;\n}\n\n// Hovering waits briefly before opening so sweeping the cursor across a row of\n// triggers doesn't flash menus; closing waits so the cursor can travel from\n// trigger to panel. Click/keyboard toggling is instant (no \"opening\" phase).\nconst OPEN_DELAY_MS = 120;\nconst CLOSE_DELAY_MS = 120;\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\nconst ALIGN_CLASS = { start: \"start-0\", end: \"end-0\" } as const;\n\n/** Explicit menu-item roles a caller may already have written. */\nconst ITEM_ROLES = '[role=\"menuitem\"],[role=\"menuitemcheckbox\"],[role=\"menuitemradio\"]';\n/** What the panel promotes to `menuitem` when the caller wrote plain controls. */\nconst PLAIN_ITEMS = \"button:not([role]),a[href]:not([role])\";\n/** The trigger: the first focusable in the wrapper that is not inside the panel. */\nconst FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\n/**\n * Give the caller's markup menu semantics.\n *\n * `children` is arbitrary — every caller in the kit renders a `<ul>` of `<li>`s holding\n * `<button>`s and router `<Link>`s — so the panel was a `role=\"menu\"` whose children\n * were a list, list items and plain buttons: no `menuitem` anywhere, which is a menu\n * with nothing in it as far as a screen reader is concerned. Rather than make every\n * caller rewrite its rows, the panel upgrades them after render: controls become\n * `menuitem`s out of the Tab order (the arrow keys move between them), and the list\n * markup in between becomes `role=\"none\"` so the menu owns its items directly. An\n * element that already carries a role is the caller's decision and is left alone.\n */\nfunction applyMenuSemantics(menu: HTMLElement): HTMLElement[] {\n for (const el of menu.querySelectorAll<HTMLElement>(PLAIN_ITEMS)) el.setAttribute(\"role\", \"menuitem\");\n const items = Array.from(menu.querySelectorAll<HTMLElement>(ITEM_ROLES));\n for (const item of items) {\n item.setAttribute(\"tabindex\", \"-1\");\n for (let el = item.parentElement; el && el !== menu; el = el.parentElement) {\n if (!el.hasAttribute(\"role\")) el.setAttribute(\"role\", \"none\");\n }\n }\n for (const el of menu.querySelectorAll<HTMLElement>(\"ul:not([role]),ol:not([role]),li:not([role])\")) {\n el.setAttribute(\"role\", \"none\");\n }\n return items.filter((el) => !el.hasAttribute(\"disabled\") && el.getAttribute(\"aria-disabled\") !== \"true\");\n}\n\n// Only one hover menu may show its panel at a time across the app — opening\n// one closes whichever was open, so adjacent top-bar menus can't overlap\n// (feedback #251). Module-level is fine: there is one cursor per document.\nlet activeClose: (() => void) | null = null;\n\nexport function HoverMenu({\n trigger,\n children,\n align = \"end\",\n panelClassName,\n className,\n \"aria-label\": ariaLabel,\n ref,\n ...rest\n}: HoverMenuProps) {\n const wrapperRef = useRef<HTMLDivElement>(null);\n const setWrapper = useMemo(() => mergeRefs(wrapperRef, ref), [ref]);\n const [phase, setPhase] = useState<Phase>(\"closed\");\n const open = phase === \"open\" || phase === \"closing\";\n\n // Whether the panel is open *because hover opened it*, vs. an explicit\n // click/keyboard toggle. A real mouse click on the trigger is always\n // preceded by a real mouseenter — the cursor has to land on the button\n // before it can be clicked — so once hover has opened the panel, the click\n // that inevitably follows is the same physical gesture, not a request to\n // close what was just opened (feedback #19). Toggling closed on click stays\n // for keyboard/touch activation, which reaches \"open\" without going through\n // the hover path.\n //\n // State rather than a ref: `close` is handed to the caller's render prop, and a\n // function that writes a ref must not be reachable from render.\n const [openedByHover, setOpenedByHover] = useState(false);\n\n const close = useCallback(() => {\n setOpenedByHover(false);\n setPhase(\"closed\");\n }, []);\n\n const toggle = useCallback(() => {\n if (open) {\n if (!openedByHover) setPhase(\"closed\");\n return;\n }\n setOpenedByHover(false);\n setPhase(\"open\");\n }, [open, openedByHover]);\n\n const handleMouseEnter = useCallback(\n () => setPhase((p) => (p === \"closing\" ? \"open\" : p === \"closed\" ? \"opening\" : p)),\n [],\n );\n\n const handleMouseLeave = useCallback(\n () => setPhase((p) => (p === \"opening\" ? \"closed\" : p === \"open\" ? \"closing\" : p)),\n [],\n );\n\n // Drive the delayed transitions; unmount/phase changes cancel the timer.\n useEffect(() => {\n if (phase === \"opening\") {\n const id = setTimeout(() => {\n setOpenedByHover(true);\n setPhase(\"open\");\n }, OPEN_DELAY_MS);\n return () => clearTimeout(id);\n }\n if (phase === \"closing\") {\n const id = setTimeout(() => setPhase(\"closed\"), CLOSE_DELAY_MS);\n return () => clearTimeout(id);\n }\n }, [phase]);\n\n // Claim the \"active menu\" slot while our panel is visible, closing the\n // previous holder. Cleanup releases the slot only if we still hold it\n // (a successor that displaced us has already overwritten it).\n useEffect(() => {\n if (!open) return;\n if (activeClose !== close) activeClose?.();\n activeClose = close;\n return () => {\n if (activeClose === close) activeClose = null;\n };\n }, [open, close]);\n\n useOutsideClick(wrapperRef, close, open);\n\n const menuRef = useRef<HTMLDivElement>(null);\n /** Set by a keyboard press on the trigger, so the open that follows moves focus into\n * the menu. A hover or a mouse click opens it without taking focus off the page. */\n const focusOnOpenRef = useRef<\"first\" | \"last\" | null>(null);\n\n const triggerEl = () =>\n Array.from(wrapperRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []).find(\n (el) => !menuRef.current?.contains(el),\n ) ?? null;\n const items = () => (menuRef.current ? applyMenuSemantics(menuRef.current) : []);\n\n // Escape closes, from anywhere — it did nothing at all before — as a layer on the kit's\n // Escape stack, so inside a dialog it closes the menu and not the dialog (Wave 2,\n // DUP-1). Focus goes back to the trigger only when it was inside the menu: a panel\n // opened by hover while the user is typing elsewhere must not yank the caret over\n // here on the way out.\n const layer = useDismissLayer(open, () => {\n const hadFocus = wrapperRef.current?.contains(document.activeElement) ?? false;\n close();\n if (hadFocus) triggerEl()?.focus();\n });\n\n // Upgrade the markup every render while open (the caller's rows can change), then\n // honour a pending keyboard open.\n useLayoutEffect(() => {\n if (!open) return;\n const list = items();\n const want = focusOnOpenRef.current;\n focusOnOpenRef.current = null;\n if (want) (want === \"last\" ? list[list.length - 1] : list[0])?.focus();\n });\n\n // Trigger state for assistive technology, on whatever element the caller rendered.\n // Only where the caller has not said something of its own.\n useLayoutEffect(() => {\n const el = triggerEl();\n if (!el) return;\n if (!el.hasAttribute(\"aria-haspopup\")) el.setAttribute(\"aria-haspopup\", \"menu\");\n if (el.getAttribute(\"aria-haspopup\") === \"menu\") el.setAttribute(\"aria-expanded\", String(open));\n });\n\n /** ↑/↓ roving focus with wrap, Home/End, Tab leaves (and closes); on the trigger,\n * Enter/Space/↓ open onto the first item and ↑ onto the last. */\n const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n const target = e.target as HTMLElement;\n const inMenu = menuRef.current?.contains(target) ?? false;\n if (!inMenu) {\n if (target !== triggerEl()) return;\n if (e.key === \"Enter\" || e.key === \" \") {\n // The trigger's own click handler does the toggling; this only records that\n // the press came from a keyboard. Not for a link trigger: Enter follows it, and\n // a mark left here would pull focus into the panel the next time hover opens it.\n if (!open && !target.matches(\"a[href]\")) focusOnOpenRef.current = \"first\";\n } else if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n const edge = e.key === \"ArrowDown\" ? \"first\" : \"last\";\n if (open) {\n const list = items();\n (edge === \"last\" ? list[list.length - 1] : list[0])?.focus();\n } else {\n focusOnOpenRef.current = edge;\n setOpenedByHover(false);\n setPhase(\"open\");\n }\n }\n return;\n }\n if (e.key === \"Tab\") {\n close();\n return;\n }\n const list = items();\n if (list.length === 0) return;\n const at = list.indexOf(document.activeElement as HTMLElement);\n let next: number | null = null;\n if (e.key === \"ArrowDown\") next = at < 0 ? 0 : (at + 1) % list.length;\n else if (e.key === \"ArrowUp\") next = at < 0 ? list.length - 1 : (at - 1 + list.length) % list.length;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = list.length - 1;\n if (next === null) return;\n e.preventDefault();\n list[next].focus();\n };\n\n // Keep the panel inside the viewport (feedback #85): a wide panel anchored to a\n // trigger near a screen edge would otherwise spill off it (on mobile the guided-\n // tours menu ran off the left). Once open, measure and translate it horizontally\n // just enough to sit within an 8px margin of the GLASS — `anchoredInline` against\n // the panel's own natural box, as every anchored panel clamps (Wave 2, DUP-7; it\n // measured against `innerWidth`, which on a phone is the grown layout viewport).\n // Recomputed on each open; `shiftX` is subtracted first so the measurement is\n // against the natural position.\n const panelRef = useRef<HTMLDivElement>(null);\n const [shiftX, setShiftX] = useState(0);\n // Back to 0 on close, adjusted while rendering against the previous `open`.\n const [wasOpen, setWasOpen] = useState(open);\n if (open !== wasOpen) {\n setWasOpen(open);\n if (!open) setShiftX(0);\n }\n useLayoutEffect(() => {\n if (!open) return;\n const el = panelRef.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n if (rect.width === 0) return; // unmeasured (e.g. jsdom) — leave as-is\n const natural = { left: rect.left - shiftX, right: rect.right - shiftX };\n const { left } = anchoredInline(natural, rect.width, { dir: dirOf(el), align: \"left\" });\n setShiftX(left - natural.left);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- recompute only on open/close\n }, [open]);\n\n return (\n /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- never focused\n itself: the mouse handlers are the hover-to-open convenience, and `onKeyDown`\n catches keys bubbling from the caller's trigger button and the menuitems, which\n are the keyboard path (Enter/Space/arrows open, arrows move, Escape closes). */\n <div\n // `...rest` first: the hover handlers below are the whole component, and a caller\n // passing one of its own must not silently replace them.\n {...rest}\n ref={setWrapper}\n className={cn(\"relative\", className)}\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n onKeyDown={(e) => {\n // The caller's handler sees Escape only while this menu is the layer on top: a\n // tooltip on one of its rows takes that press first (round 0.35 R2, the rule\n // the dialogs got first). Every other key, and a closed menu's, as before.\n runOwnKeyDown(e, rest.onKeyDown, layer);\n // Focus on the trigger or in the menu: the key stops here, so a panel around\n // the menu does not close with it.\n if (open) takeEscape(e);\n onKeyDown(e);\n }}\n >\n {trigger({ open, toggle, close })}\n {open && (\n <div\n ref={panelRef}\n className={cn(\n \"absolute top-full z-40 pt-2\",\n ALIGN_CLASS[align],\n )}\n style={shiftX ? { transform: `translateX(${shiftX}px)` } : undefined}\n >\n <div\n ref={menuRef}\n role=\"menu\"\n // On the element with the role. It used to sit on the role-less wrapper,\n // where no screen reader reads a name.\n aria-label={ariaLabel}\n className={cn(\n \"min-w-44 max-w-[calc(100vw-1rem)] rounded-md border border-subtle bg-surface shadow-lg\",\n panelClassName,\n )}\n >\n {children(close)}\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["list"]}
@@ -40,7 +40,7 @@ export interface InlineEntityComboboxProps<V extends string | number, C extends
40
40
  emptyLabel?: string;
41
41
  closeLabel?: string;
42
42
  /** The phone sheet's heading (and, as a string, its accessible name) for a field
43
- * whose label is drawn by someone else — a form's `FormLabel`;
43
+ * whose label is drawn by someone else — a bound field's label;
44
44
  * `RhfInlineEntityCombobox` hands its label over here (0.24). Default: `label`.
45
45
  * See {@link Combobox}'s `sheetTitle`. */
46
46
  sheetTitle?: ReactNode;