@eifi1/ui-kit 0.35.0 → 0.37.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 (269) hide show
  1. package/README.md +35 -5
  2. package/dist/admin/role-select.d.ts +3 -2
  3. package/dist/admin/role-select.js +2 -0
  4. package/dist/admin/role-select.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +2 -2
  6. package/dist/components/amount-input.js.map +1 -1
  7. package/dist/components/autocomplete.d.ts +2 -2
  8. package/dist/components/autocomplete.js +2 -1
  9. package/dist/components/autocomplete.js.map +1 -1
  10. package/dist/components/button-look.d.ts +1 -1
  11. package/dist/components/button-look.js +9 -4
  12. package/dist/components/button-look.js.map +1 -1
  13. package/dist/components/checkbox-group.d.ts +5 -2
  14. package/dist/components/checkbox-group.js +15 -4
  15. package/dist/components/checkbox-group.js.map +1 -1
  16. package/dist/components/column-mapper.d.ts +5 -3
  17. package/dist/components/column-mapper.js +2 -0
  18. package/dist/components/column-mapper.js.map +1 -1
  19. package/dist/components/combobox.d.ts +2 -2
  20. package/dist/components/combobox.js +2 -1
  21. package/dist/components/combobox.js.map +1 -1
  22. package/dist/components/country-select.d.ts +3 -3
  23. package/dist/components/country-select.js +3 -0
  24. package/dist/components/country-select.js.map +1 -1
  25. package/dist/components/currency-select.d.ts +3 -2
  26. package/dist/components/currency-select.js +10 -1
  27. package/dist/components/currency-select.js.map +1 -1
  28. package/dist/components/date-field.d.ts +2 -1
  29. package/dist/components/date-field.js.map +1 -1
  30. package/dist/components/date-picker.js +3 -0
  31. package/dist/components/date-picker.js.map +1 -1
  32. package/dist/components/date-range-picker.js +3 -0
  33. package/dist/components/date-range-picker.js.map +1 -1
  34. package/dist/components/entity-combobox.d.ts +2 -2
  35. package/dist/components/entity-combobox.js +2 -1
  36. package/dist/components/entity-combobox.js.map +1 -1
  37. package/dist/components/field-parts.d.ts +66 -11
  38. package/dist/components/field-parts.js +32 -10
  39. package/dist/components/field-parts.js.map +1 -1
  40. package/dist/components/field-strip.d.ts +3 -2
  41. package/dist/components/field-strip.js +2 -1
  42. package/dist/components/field-strip.js.map +1 -1
  43. package/dist/components/field.d.ts +3 -2
  44. package/dist/components/field.js +24 -15
  45. package/dist/components/field.js.map +1 -1
  46. package/dist/components/help-popover.d.ts +9 -7
  47. package/dist/components/help-popover.js.map +1 -1
  48. package/dist/components/icon-picker.d.ts +3 -2
  49. package/dist/components/icon-picker.js +3 -0
  50. package/dist/components/icon-picker.js.map +1 -1
  51. package/dist/components/inline-entity-combobox.d.ts +2 -2
  52. package/dist/components/inline-entity-combobox.js +2 -1
  53. package/dist/components/inline-entity-combobox.js.map +1 -1
  54. package/dist/components/input.d.ts +2 -2
  55. package/dist/components/input.js +2 -1
  56. package/dist/components/input.js.map +1 -1
  57. package/dist/components/month-picker.d.ts +2 -1
  58. package/dist/components/month-picker.js +8 -1
  59. package/dist/components/month-picker.js.map +1 -1
  60. package/dist/components/multi-entity-combobox.d.ts +2 -2
  61. package/dist/components/multi-entity-combobox.js +2 -1
  62. package/dist/components/multi-entity-combobox.js.map +1 -1
  63. package/dist/components/multi-select.d.ts +2 -2
  64. package/dist/components/multi-select.js +3 -0
  65. package/dist/components/multi-select.js.map +1 -1
  66. package/dist/components/number-input.d.ts +2 -2
  67. package/dist/components/number-input.js.map +1 -1
  68. package/dist/components/numeric-field.d.ts +5 -2
  69. package/dist/components/numeric-field.js +4 -1
  70. package/dist/components/numeric-field.js.map +1 -1
  71. package/dist/components/one-time-code-input.d.ts +2 -1
  72. package/dist/components/one-time-code-input.js +2 -1
  73. package/dist/components/one-time-code-input.js.map +1 -1
  74. package/dist/components/phone-input.d.ts +1 -1
  75. package/dist/components/phone-input.js +4 -0
  76. package/dist/components/phone-input.js.map +1 -1
  77. package/dist/components/popover.js +1 -1
  78. package/dist/components/popover.js.map +1 -1
  79. package/dist/components/select.d.ts +4 -3
  80. package/dist/components/select.js +2 -1
  81. package/dist/components/select.js.map +1 -1
  82. package/dist/components/slider.d.ts +3 -2
  83. package/dist/components/slider.js +7 -1
  84. package/dist/components/slider.js.map +1 -1
  85. package/dist/components/stat-tile.js +2 -11
  86. package/dist/components/stat-tile.js.map +1 -1
  87. package/dist/components/swatch-picker.d.ts +3 -2
  88. package/dist/components/swatch-picker.js +3 -0
  89. package/dist/components/swatch-picker.js.map +1 -1
  90. package/dist/components/text-link.js +3 -1
  91. package/dist/components/text-link.js.map +1 -1
  92. package/dist/components/textarea.d.ts +2 -1
  93. package/dist/components/textarea.js +9 -3
  94. package/dist/components/textarea.js.map +1 -1
  95. package/dist/components/toggle-group.d.ts +2 -2
  96. package/dist/components/toggle-group.js +7 -3
  97. package/dist/components/toggle-group.js.map +1 -1
  98. package/dist/eslint/index.js +1 -1
  99. package/dist/eslint/role-utilities.d.ts +34 -2
  100. package/dist/eslint/role-utilities.js +30 -9
  101. package/dist/eslint/role-utilities.js.map +1 -1
  102. package/dist/i18n/defaults.js +5 -1
  103. package/dist/i18n/defaults.js.map +1 -1
  104. package/dist/i18n/german.js +97 -1
  105. package/dist/i18n/german.js.map +1 -1
  106. package/dist/i18n/kit-labels.d.ts +16 -0
  107. package/dist/i18n/kit-labels.js +21 -1
  108. package/dist/i18n/kit-labels.js.map +1 -1
  109. package/dist/i18n/locales/en.js +103 -2
  110. package/dist/i18n/locales/en.js.map +3 -3
  111. package/dist/i18n/locales/es.js +95 -1
  112. package/dist/i18n/locales/es.js.map +1 -1
  113. package/dist/i18n/locales/fr.js +96 -1
  114. package/dist/i18n/locales/fr.js.map +1 -1
  115. package/dist/i18n/locales/hu.js +95 -1
  116. package/dist/i18n/locales/hu.js.map +1 -1
  117. package/dist/i18n/locales/it.js +96 -1
  118. package/dist/i18n/locales/it.js.map +1 -1
  119. package/dist/i18n/locales/zh.js +95 -1
  120. package/dist/i18n/locales/zh.js.map +1 -1
  121. package/dist/i18n/review.js +13 -0
  122. package/dist/i18n/review.js.map +1 -1
  123. package/dist/i18n/shared-labels.js +3 -1
  124. package/dist/i18n/shared-labels.js.map +1 -1
  125. package/dist/index.d.ts +21 -0
  126. package/dist/index.js +38 -0
  127. package/dist/index.js.map +1 -1
  128. package/dist/lib/device-settings.d.ts +72 -0
  129. package/dist/lib/device-settings.js +64 -0
  130. package/dist/lib/device-settings.js.map +1 -0
  131. package/dist/lib/plain-text.d.ts +11 -0
  132. package/dist/lib/plain-text.js +16 -0
  133. package/dist/lib/plain-text.js.map +1 -0
  134. package/dist/lib/safe-storage.d.ts +6 -0
  135. package/dist/lib/safe-storage.js +11 -0
  136. package/dist/lib/safe-storage.js.map +1 -1
  137. package/dist/notifications/notification-parts.d.ts +39 -0
  138. package/dist/notifications/notification-parts.js +47 -0
  139. package/dist/notifications/notification-parts.js.map +1 -0
  140. package/dist/notifications/notification-types-setting.d.ts +62 -0
  141. package/dist/notifications/notification-types-setting.js +192 -0
  142. package/dist/notifications/notification-types-setting.js.map +1 -0
  143. package/dist/notifications/notification-types.d.ts +75 -0
  144. package/dist/notifications/notification-types.js +2 -0
  145. package/dist/notifications/notification-types.js.map +1 -0
  146. package/dist/notifications/notifications-labels.d.ts +131 -0
  147. package/dist/notifications/notifications-labels.js +83 -0
  148. package/dist/notifications/notifications-labels.js.map +1 -0
  149. package/dist/notifications/push-device-setting.d.ts +37 -0
  150. package/dist/notifications/push-device-setting.js +164 -0
  151. package/dist/notifications/push-device-setting.js.map +1 -0
  152. package/dist/notifications/push-devices-setting.d.ts +44 -0
  153. package/dist/notifications/push-devices-setting.js +203 -0
  154. package/dist/notifications/push-devices-setting.js.map +1 -0
  155. package/dist/notifications/quiet-hours-setting.d.ts +47 -0
  156. package/dist/notifications/quiet-hours-setting.js +131 -0
  157. package/dist/notifications/quiet-hours-setting.js.map +1 -0
  158. package/dist/notifications/time-zone-select.d.ts +50 -0
  159. package/dist/notifications/time-zone-select.js +76 -0
  160. package/dist/notifications/time-zone-select.js.map +1 -0
  161. package/dist/notifications/unsubscribe-panel.d.ts +53 -0
  162. package/dist/notifications/unsubscribe-panel.js +139 -0
  163. package/dist/notifications/unsubscribe-panel.js.map +1 -0
  164. package/dist/notifications/use-push-device.d.ts +122 -0
  165. package/dist/notifications/use-push-device.js +230 -0
  166. package/dist/notifications/use-push-device.js.map +1 -0
  167. package/dist/rhf/fields.d.ts +26 -19
  168. package/dist/rhf/fields.js +51 -22
  169. package/dist/rhf/fields.js.map +1 -1
  170. package/dist/settings/device-reset-setting-labels.d.ts +3 -0
  171. package/dist/settings/device-reset-setting-labels.js +15 -0
  172. package/dist/settings/device-reset-setting-labels.js.map +1 -0
  173. package/dist/settings/device-reset-setting.d.ts +58 -0
  174. package/dist/settings/device-reset-setting.js +51 -0
  175. package/dist/settings/device-reset-setting.js.map +1 -0
  176. package/dist/shell/app-shell.js +5 -0
  177. package/dist/shell/app-shell.js.map +1 -1
  178. package/dist/theme/contrast-tokens.d.ts +42 -4
  179. package/dist/theme/contrast-tokens.js +56 -9
  180. package/dist/theme/contrast-tokens.js.map +1 -1
  181. package/dist/theme/contrast.d.ts +4 -2
  182. package/dist/theme/contrast.js +13 -0
  183. package/dist/theme/contrast.js.map +1 -1
  184. package/dist/theme/palette-derive.d.ts +15 -1
  185. package/dist/theme/palette-derive.js +37 -8
  186. package/dist/theme/palette-derive.js.map +1 -1
  187. package/dist/theme/palette-presets.d.ts +2 -1
  188. package/dist/theme/palette-presets.js +5 -5
  189. package/dist/theme/palette-presets.js.map +1 -1
  190. package/dist/theme/palette-store.js +6 -0
  191. package/dist/theme/palette-store.js.map +1 -1
  192. package/dist/theme/text-size.js +6 -0
  193. package/dist/theme/text-size.js.map +1 -1
  194. package/dist/theme/theme-store.js +6 -0
  195. package/dist/theme/theme-store.js.map +1 -1
  196. package/package.json +1 -1
  197. package/src/admin/role-select.tsx +4 -1
  198. package/src/components/amount-input.tsx +2 -1
  199. package/src/components/autocomplete.tsx +5 -2
  200. package/src/components/button-look.ts +15 -5
  201. package/src/components/checkbox-group.tsx +22 -4
  202. package/src/components/column-mapper.tsx +6 -2
  203. package/src/components/combobox.tsx +5 -2
  204. package/src/components/country-select.tsx +6 -1
  205. package/src/components/currency-select.tsx +12 -3
  206. package/src/components/date-field.tsx +2 -2
  207. package/src/components/date-picker.tsx +3 -0
  208. package/src/components/date-range-picker.tsx +3 -0
  209. package/src/components/entity-combobox.tsx +5 -2
  210. package/src/components/field-parts.tsx +107 -19
  211. package/src/components/field-strip.tsx +4 -3
  212. package/src/components/field.tsx +28 -15
  213. package/src/components/help-popover.tsx +9 -7
  214. package/src/components/icon-picker.tsx +8 -3
  215. package/src/components/inline-entity-combobox.tsx +5 -2
  216. package/src/components/input.tsx +4 -2
  217. package/src/components/month-picker.tsx +10 -3
  218. package/src/components/multi-entity-combobox.tsx +5 -2
  219. package/src/components/multi-select.tsx +6 -1
  220. package/src/components/number-input.tsx +2 -2
  221. package/src/components/numeric-field.ts +10 -2
  222. package/src/components/one-time-code-input.tsx +5 -3
  223. package/src/components/phone-input.tsx +4 -0
  224. package/src/components/popover.tsx +3 -1
  225. package/src/components/select.tsx +6 -3
  226. package/src/components/slider.tsx +10 -3
  227. package/src/components/stat-tile.tsx +3 -18
  228. package/src/components/swatch-picker.tsx +9 -4
  229. package/src/components/text-link.tsx +3 -1
  230. package/src/components/textarea.tsx +11 -4
  231. package/src/components/toggle-group.tsx +21 -5
  232. package/src/eslint/role-utilities.ts +67 -11
  233. package/src/i18n/defaults.ts +4 -0
  234. package/src/i18n/german.ts +113 -0
  235. package/src/i18n/kit-labels.tsx +52 -1
  236. package/src/i18n/locales/en.ts +6 -0
  237. package/src/i18n/locales/es.ts +106 -0
  238. package/src/i18n/locales/fr.ts +106 -0
  239. package/src/i18n/locales/hu.ts +104 -0
  240. package/src/i18n/locales/it.ts +105 -0
  241. package/src/i18n/locales/zh.ts +96 -0
  242. package/src/i18n/review.ts +13 -0
  243. package/src/i18n/shared-labels.ts +2 -0
  244. package/src/index.ts +50 -0
  245. package/src/lib/device-settings.ts +146 -0
  246. package/src/lib/plain-text.ts +22 -0
  247. package/src/lib/safe-storage.ts +16 -0
  248. package/src/notifications/notification-parts.tsx +83 -0
  249. package/src/notifications/notification-types-setting.tsx +309 -0
  250. package/src/notifications/notification-types.ts +80 -0
  251. package/src/notifications/notifications-labels.ts +238 -0
  252. package/src/notifications/push-device-setting.tsx +224 -0
  253. package/src/notifications/push-devices-setting.tsx +306 -0
  254. package/src/notifications/quiet-hours-setting.tsx +185 -0
  255. package/src/notifications/time-zone-select.tsx +126 -0
  256. package/src/notifications/unsubscribe-panel.tsx +203 -0
  257. package/src/notifications/use-push-device.ts +392 -0
  258. package/src/rhf/fields.tsx +77 -29
  259. package/src/settings/device-reset-setting-labels.ts +20 -0
  260. package/src/settings/device-reset-setting.tsx +119 -0
  261. package/src/shell/app-shell.tsx +8 -0
  262. package/src/theme/contrast-tokens.ts +106 -12
  263. package/src/theme/contrast.ts +30 -4
  264. package/src/theme/palette-derive.ts +68 -7
  265. package/src/theme/palette-presets.ts +9 -6
  266. package/src/theme/palette-store.ts +12 -0
  267. package/src/theme/text-size.ts +8 -0
  268. package/src/theme/theme-store.ts +10 -0
  269. package/tokens.css +38 -4
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/date-range-picker.tsx"],"sourcesContent":["// The two-date range picker: presets beside the calendar, `commit=\"apply\"` drafts, a\n// custom trigger. Moved out of date-picker.tsx as it was (MOD-9, refactor audit\n// 2026-10); date-picker still exports every public name here.\nimport { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLocale, type DatePickerLabels } from \"../i18n/kit-labels\";\nimport { Button } from \"./button\";\nimport { FieldBox, useFieldMessages } from \"./field-parts\";\nimport { usePhoneLayout } from \"../hooks/use-breakpoint\";\nimport {\n DateField,\n useDatePickerLabels,\n useTriggerFormat,\n withEndHint,\n type DatePickerBaseProps,\n type DateTriggerAttributes,\n type PanelFrame,\n} from \"./date-field\";\nimport { PanelCalendar, type PanelCalendarProps } from \"./jump-calendar\";\n\nexport interface DateRangePickerPreset {\n /**\n * The preset's identity, independent of its dates — `\"last_3_months\"`, not two ISO\n * strings. Optional for the 0.x callers that pass anonymous pairs.\n *\n * keksdose's report range field (report-range-field.tsx, \"Why this is app-level\")\n * is why it exists: its URL stores `?preset=last_3_months` and recomputes the dates\n * from today on every read, so a preset that is only its dates froze \"last 3 months\"\n * at whatever day the panel rendered, and two presets that happen to cover the same\n * days (this week and today, on a Monday) could not be told apart. With an `id` the\n * choice is reported to `onChange` and shown as selected for as long as the range\n * still matches it. Recompute `from`/`to` on every render (`calendarMonthPresets()`,\n * `dateRangePresets()`) and pass the key as the id.\n */\n id?: string;\n label: ReactNode;\n from: string;\n to: string;\n}\n\n/**\n * When the picker hands its range to `onChange`.\n *\n * - `\"immediate\"` (the default, and all of 0.7): a preset click commits and closes,\n * and each calendar click is reported as it happens — the half-made `(from, \"\")`\n * included. The right shape for quick ranges on a filter, where one click is one\n * answer.\n * - `\"apply\"`: the panel works on a DRAFT. A preset ARMS the calendar instead of\n * committing, days can then be nudged (\"last 3 months, but end it last Friday\"),\n * and nothing reaches `onChange` until Apply — which stays disabled until both ends\n * exist. Cancel, Escape and an outside click discard the draft, and every open\n * starts again from the committed value. keksdose's report range asked for exactly\n * this: each commit there re-runs a whole page of charts, so an intermediate state\n * must never escape the panel.\n */\nexport type DateRangeCommit = \"immediate\" | \"apply\";\n\nexport interface DateRangePickerProps extends DatePickerBaseProps {\n from: string;\n to: string;\n /** The committed range. `presetId` is the chosen preset's `id` when a preset\n * produced it (and the range was not nudged since), `undefined` for days picked by\n * hand. A 0.x two-argument handler simply ignores it. */\n onChange: (from: string, to: string, presetId?: string) => void;\n /** Separator between the two formatted dates in the trigger (default \" – \"). */\n separator?: string;\n /** Optional named ranges rendered as a start-side column (last month / YTD / …). */\n presets?: DateRangePickerPreset[];\n /**\n * The active preset's `id`, controlled — e.g. straight from keksdose's `?preset=`.\n * `null` (or an id no preset has, such as `\"custom\"`) says \"no preset\": nothing in\n * the column is marked even if a preset happens to cover the same days. A preset is\n * marked only while its dates still equal `from`/`to`.\n *\n * Leave it `undefined` and the picker remembers the last preset it committed\n * itself; with no memory at all it falls back to the first preset whose dates match.\n */\n preset?: string | null;\n /** See {@link DateRangeCommit}. Default `\"immediate\"`. */\n commit?: DateRangeCommit;\n /**\n * Draw the trigger yourself. keksdose's report range field\n * (reports/report-range-field.tsx, \"Why this is app-level\") is why: its trigger\n * NAMES the active preset (\"Last 3 months\", the window as a muted suffix) where the\n * kit's shows two dates, sits inside a Tooltip, and is squared off on one side so the\n * granularity control can join it flush at the field's 42px.\n *\n * Spread `props.triggerProps` onto a `<button>` whole and put `props.valueProps` on\n * the element that shows the value — see {@link DateTriggerAttributes} for what each\n * part is for. The field around it (label, hidden name twin, clear/calendar glyph,\n * popover or phone sheet) is unchanged.\n */\n renderTrigger?: (props: DateRangeTriggerRenderProps) => ReactNode;\n /**\n * In the phone layout (`usePhoneLayout()`) the panel opens as a full-screen sheet (`FullBleedDialog`)\n * with presets in a grid above the calendar and Apply pinned in the footer, instead\n * of the 440px popover that hung off a 360px screen (keksdose's report range field,\n * which hand-rolled exactly this). This is its `backCloses`: on by default, so Back\n * dismisses the sheet; off for a caller whose commit rewrites the URL with\n * `replaceState` in the same tick — keksdose's `?preset/from/to` — where the router\n * would overwrite the sheet's history marker.\n */\n sheetBackCloses?: boolean;\n /**\n * With `commit=\"apply\"`: a line above the calendar saying what Apply would commit —\n * keksdose's report range writes \"Custom · 1 Mar 2026 – …\" there\n * (report-range-field.tsx), the one place a preset armed and then nudged by a day is\n * seen to have become \"Custom\", and a half-made range to still lack its end. Called\n * with the DRAFT on every change, in the popover and the phone sheet alike; `preset`\n * is the preset the column marks for it (none once a day was picked by hand), `to` is\n * `\"\"` until the second click. The line is a polite live region, so a screen reader\n * hears the draft change as the calendar is used.\n *\n * Ignored in `\"immediate\"` mode, where there is no draft: every click is the value.\n */\n renderDraftSummary?: (draft: DateRangeDraftSummary) => ReactNode;\n}\n\n/** What {@link DateRangePickerProps.renderDraftSummary} is called with. */\nexport interface DateRangeDraftSummary {\n from: string;\n to: string;\n /** The preset the column marks for the draft, if any. */\n preset: DateRangePickerPreset | undefined;\n}\n\n/** What {@link DateRangePickerProps.renderTrigger} is called with. */\nexport interface DateRangeTriggerRenderProps {\n /** Spread onto the `<button>`, whole. Merge your class after `triggerProps.className`. */\n triggerProps: DateTriggerAttributes;\n /** Put on the element showing the value: the trigger is named \"label, value\" by\n * reference to this id. Without it the name is the label alone. */\n valueProps: { id: string };\n open: boolean;\n from: string;\n to: string;\n /** The preset the column marks for the committed range, if any — the one to name. */\n preset: DateRangePickerPreset | undefined;\n /** What the kit's own trigger would show: the formatted range, or the placeholder. */\n text: string;\n}\n\ninterface RangeDraft {\n from: string;\n to: string;\n presetId: string | undefined;\n}\n\n/** Which preset (by index) the column marks, or -1. See `DateRangePickerProps.preset`. */\nfunction markedPreset(\n presets: readonly DateRangePickerPreset[],\n from: string,\n to: string,\n activeId: string | null | undefined,\n controlled: boolean,\n): number {\n const matches = (p: DateRangePickerPreset) => p.from === from && p.to === to;\n if (activeId != null) {\n const byId = presets.findIndex((p) => p.id === activeId && matches(p));\n if (byId !== -1 || controlled) return byId;\n } else if (controlled) {\n return -1;\n }\n return presets.findIndex(matches);\n}\n\n/**\n * The preset column: a named group of toggle buttons, `aria-pressed` on the marked one.\n * Pressed-state rather than colour alone, because \"which range is this\" is the one\n * thing a screen-reader user opening the panel needs to hear.\n */\nfunction PresetColumn({\n presets,\n marked,\n label,\n onPick,\n sheet,\n}: {\n presets: readonly DateRangePickerPreset[];\n marked: number;\n label: string;\n onPick: (p: DateRangePickerPreset) => void;\n /** The phone sheet's shape: a two-column grid ABOVE the calendar with 44px rows —\n * a 128px column beside a month grid leaves the grid ~200px on a 360px screen. */\n sheet?: boolean;\n}) {\n return (\n // `border-e`/`pe`, not `-r`: the preset column is on the START side, and the rule\n // between it and the calendar has to follow it in a right-to-left UI.\n <div\n role=\"group\"\n aria-label={label}\n className={\n sheet\n ? \"grid grid-cols-2 gap-0.5\"\n : \"flex w-32 shrink-0 flex-col gap-0.5 border-e border-subtle pe-2\"\n }\n >\n {presets.map((p, i) => {\n const selected = i === marked;\n return (\n <button\n key={p.id ?? i}\n type=\"button\"\n aria-pressed={selected}\n data-preset={p.id}\n onClick={() => onPick(p)}\n className={cn(\n \"rounded px-2 text-start\",\n // 44px on a phone: the kit's touch-target size (`SHEET_ROW_CLASS`).\n sheet ? \"min-h-11 py-2 text-sm\" : \"py-1.5 text-xs\",\n selected\n ? \"bg-active font-medium text-primary\"\n : \"text-secondary hover:bg-hover\",\n )}\n >\n {p.label}\n </button>\n );\n })}\n </div>\n );\n}\n\n/**\n * The panel's body. A component of its own so `commit=\"apply\"` can keep its draft in\n * state that is born when the panel opens: `Popover` unmounts its children on close,\n * so the draft is re-seeded from the committed value on every open, and an abandoned\n * half-selection cannot survive into the next one.\n */\nfunction RangePanel({\n from,\n to,\n presets,\n activeId,\n controlled,\n commit,\n close,\n frame,\n sheet,\n commitRange,\n calendarProps,\n labels,\n renderDraftSummary,\n}: {\n from: string;\n to: string;\n presets: readonly DateRangePickerPreset[] | undefined;\n activeId: string | null | undefined;\n controlled: boolean;\n commit: DateRangeCommit;\n close: () => void;\n /** Where the body and the Apply row go — see `PanelFrame`. */\n frame: PanelFrame;\n sheet: boolean;\n commitRange: (from: string, to: string, presetId: string | undefined) => void;\n calendarProps: Pick<PanelCalendarProps, \"locale\" | \"min\" | \"max\" | \"labels\" | \"monthJump\">;\n labels: DatePickerLabels;\n renderDraftSummary?: DateRangePickerProps[\"renderDraftSummary\"];\n}) {\n const drafting = commit === \"apply\";\n const [draft, setDraft] = useState<RangeDraft>({\n from,\n to,\n presetId: activeId ?? undefined,\n });\n // What the panel SHOWS: the draft while drafting, the committed value otherwise.\n const shown = drafting ? draft : { from, to, presetId: activeId ?? undefined };\n const marked = presets\n ? markedPreset(presets, shown.from, shown.to, drafting ? draft.presetId : activeId, drafting || controlled)\n : -1;\n const complete = Boolean(draft.from && draft.to);\n\n const summary =\n drafting && renderDraftSummary\n ? renderDraftSummary({ from: draft.from, to: draft.to, preset: presets?.[marked] })\n : null;\n\n const picker = (\n <PanelCalendar\n {...calendarProps}\n focusOnOpen\n from={shown.from}\n to={shown.to}\n onSelect={(f, t) => {\n if (drafting) {\n setDraft({ from: f, to: t, presetId: undefined });\n return;\n }\n commitRange(f, t, undefined);\n // Two-click range: only dismiss once both ends are chosen.\n if (f && t) close();\n }}\n />\n );\n // The summary heads the CALENDAR's column, not the panel: beside a preset column it\n // reads as the calendar's caption, and in the sheet it sits between the presets and\n // the month it describes. `null` renders no line — a caller may opt out per draft —\n // but keeps the wrapper, so the calendar is not remounted (and its month and focus\n // lost) when a line comes or goes.\n const calendar =\n drafting && renderDraftSummary ? (\n <div className=\"flex flex-col gap-2\">\n {summary != null && (\n <div aria-live=\"polite\" data-draft-summary=\"\" className=\"text-xs text-muted\">\n {summary}\n </div>\n )}\n {picker}\n </div>\n ) : (\n picker\n );\n\n const body =\n presets && presets.length > 0 ? (\n <div className={sheet ? \"flex flex-col gap-3\" : \"flex gap-3\"}>\n <PresetColumn\n sheet={sheet}\n presets={presets}\n marked={marked}\n label={labels.presets}\n onPick={(p) => {\n if (drafting) {\n setDraft({ from: p.from, to: p.to, presetId: p.id });\n return;\n }\n commitRange(p.from, p.to, p.id);\n close();\n }}\n />\n {/* `flex-1`: the calendar takes the rest of the panel. Content-sized it was\n seven tiny cells beside a wide preset column. */}\n <div className=\"min-w-0 flex-1\">{calendar}</div>\n </div>\n ) : (\n calendar\n );\n\n if (!drafting) return frame(body);\n return frame(\n body,\n <>\n {/* `flex-1` in the sheet: two thumb-wide halves of the footer, not two small\n buttons in its corner. */}\n <Button type=\"button\" variant=\"ghost\" className={sheet ? \"flex-1\" : undefined} onClick={close}>\n {labels.cancel}\n </Button>\n <Button\n type=\"button\"\n className={sheet ? \"flex-1\" : undefined}\n // A half-made `(from, \"\")` would commit a window with no end — a blank\n // report, one click into a two-click gesture.\n disabled={!complete}\n onClick={() => {\n if (!complete) return;\n commitRange(draft.from, draft.to, draft.presetId);\n close();\n }}\n >\n {labels.apply}\n </Button>\n </>,\n );\n}\n\n/** Two-date range picker: click a start then an end; closes once both are set.\n * With `presets`, a column of named ranges is shown beside the calendar; with\n * `commit=\"apply\"` the panel drafts and commits on Apply (see {@link DateRangeCommit}). */\nexport function DateRangePicker(props: DateRangePickerProps) {\n const {\n from,\n to,\n onChange,\n locale: localeProp,\n placeholder,\n min,\n max,\n formatOptions,\n formatValue,\n separator = \" – \",\n presets,\n preset,\n commit = \"immediate\",\n renderTrigger,\n sheetBackCloses,\n renderDraftSummary,\n calendarLabels,\n monthJump,\n label,\n clearable,\n clearLabel,\n disabled,\n invalid,\n hint,\n error,\n // The trigger's (API-3).\n ref,\n ...rest\n } = props;\n const locale = useKitLocale(localeProp);\n const text = useDatePickerLabels(clearLabel, undefined, undefined);\n const render = useTriggerFormat(\"dateRangePicker\", locale, formatOptions, formatValue);\n const messages = useFieldMessages({\n hint,\n error,\n invalid,\n describedBy: rest[\"aria-describedby\"],\n ariaInvalid: rest[\"aria-invalid\"],\n });\n // The preset this picker committed itself, for a caller that does not control\n // `preset`. Cleared by a hand-picked range or a clear.\n const [ownPreset, setOwnPreset] = useState<string | undefined>(undefined);\n const controlled = preset !== undefined;\n const activeId = controlled ? preset : ownPreset;\n const a = from ? render(from) : \"\";\n const b = to ? render(to) : \"\";\n const triggerText = from\n ? to\n ? `${a}${separator}${b}`\n : `${a}${separator}…`\n : (placeholder ?? \"\");\n const hasPresets = Boolean(presets && presets.length > 0);\n const sheet = usePhoneLayout();\n const commitRange = (f: string, t: string, presetId: string | undefined) => {\n setOwnPreset(presetId);\n // Two arguments when no preset is involved — exactly the 0.7 call, so a caller's\n // `toHaveBeenCalledWith(from, to)` and a variadic handler see no difference.\n if (presetId === undefined) onChange(f, t);\n else onChange(f, t, presetId);\n };\n const field = (\n <DateField\n {...rest}\n fieldRef={ref}\n aria-describedby={messages.describedBy}\n label={label}\n labelHint={messages.labelHint}\n clearable={clearable}\n clearLabel={text.clear}\n panelLabel={text.rangePanel}\n disabled={disabled}\n invalid={messages.isInvalid}\n // Widen so the preset column sits beside the calendar (default otherwise).\n width={hasPresets ? \"27.5rem\" : undefined}\n hasValue={Boolean(from || to)}\n triggerText={triggerText}\n onClear={() => commitRange(\"\", \"\", undefined)}\n sheet={sheet}\n sheetBackCloses={sheetBackCloses}\n renderTrigger={\n renderTrigger &&\n ((triggerProps, valueId) =>\n renderTrigger({\n triggerProps,\n valueProps: { id: valueId },\n open: triggerProps[\"aria-expanded\"],\n from,\n to,\n preset: presets?.[markedPreset(presets, from, to, activeId, controlled)],\n text: triggerText,\n }))\n }\n >\n {(close, frame) => (\n <RangePanel\n from={from}\n to={to}\n presets={presets}\n activeId={activeId}\n controlled={controlled}\n commit={commit}\n close={close}\n frame={frame}\n sheet={sheet}\n commitRange={commitRange}\n calendarProps={{ locale, min, max, labels: calendarLabels, monthJump }}\n labels={text}\n renderDraftSummary={renderDraftSummary}\n />\n )}\n </DateField>\n );\n return (\n <FieldBox below={messages.below} reserve={\"error\" in props || \"hint\" in props}>\n {withEndHint(field, label === undefined ? messages.labelHint : undefined)}\n </FieldBox>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/date-range-picker.tsx"],"sourcesContent":["// The two-date range picker: presets beside the calendar, `commit=\"apply\"` drafts, a\n// custom trigger. Moved out of date-picker.tsx as it was (MOD-9, refactor audit\n// 2026-10); date-picker still exports every public name here.\nimport { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLocale, type DatePickerLabels } from \"../i18n/kit-labels\";\nimport { Button } from \"./button\";\nimport { FieldBox, useFieldMessages } from \"./field-parts\";\nimport { usePhoneLayout } from \"../hooks/use-breakpoint\";\nimport {\n DateField,\n useDatePickerLabels,\n useTriggerFormat,\n withEndHint,\n type DatePickerBaseProps,\n type DateTriggerAttributes,\n type PanelFrame,\n} from \"./date-field\";\nimport { PanelCalendar, type PanelCalendarProps } from \"./jump-calendar\";\n\nexport interface DateRangePickerPreset {\n /**\n * The preset's identity, independent of its dates — `\"last_3_months\"`, not two ISO\n * strings. Optional for the 0.x callers that pass anonymous pairs.\n *\n * keksdose's report range field (report-range-field.tsx, \"Why this is app-level\")\n * is why it exists: its URL stores `?preset=last_3_months` and recomputes the dates\n * from today on every read, so a preset that is only its dates froze \"last 3 months\"\n * at whatever day the panel rendered, and two presets that happen to cover the same\n * days (this week and today, on a Monday) could not be told apart. With an `id` the\n * choice is reported to `onChange` and shown as selected for as long as the range\n * still matches it. Recompute `from`/`to` on every render (`calendarMonthPresets()`,\n * `dateRangePresets()`) and pass the key as the id.\n */\n id?: string;\n label: ReactNode;\n from: string;\n to: string;\n}\n\n/**\n * When the picker hands its range to `onChange`.\n *\n * - `\"immediate\"` (the default, and all of 0.7): a preset click commits and closes,\n * and each calendar click is reported as it happens — the half-made `(from, \"\")`\n * included. The right shape for quick ranges on a filter, where one click is one\n * answer.\n * - `\"apply\"`: the panel works on a DRAFT. A preset ARMS the calendar instead of\n * committing, days can then be nudged (\"last 3 months, but end it last Friday\"),\n * and nothing reaches `onChange` until Apply — which stays disabled until both ends\n * exist. Cancel, Escape and an outside click discard the draft, and every open\n * starts again from the committed value. keksdose's report range asked for exactly\n * this: each commit there re-runs a whole page of charts, so an intermediate state\n * must never escape the panel.\n */\nexport type DateRangeCommit = \"immediate\" | \"apply\";\n\nexport interface DateRangePickerProps extends DatePickerBaseProps {\n from: string;\n to: string;\n /** The committed range. `presetId` is the chosen preset's `id` when a preset\n * produced it (and the range was not nudged since), `undefined` for days picked by\n * hand. A 0.x two-argument handler simply ignores it. */\n onChange: (from: string, to: string, presetId?: string) => void;\n /** Separator between the two formatted dates in the trigger (default \" – \"). */\n separator?: string;\n /** Optional named ranges rendered as a start-side column (last month / YTD / …). */\n presets?: DateRangePickerPreset[];\n /**\n * The active preset's `id`, controlled — e.g. straight from keksdose's `?preset=`.\n * `null` (or an id no preset has, such as `\"custom\"`) says \"no preset\": nothing in\n * the column is marked even if a preset happens to cover the same days. A preset is\n * marked only while its dates still equal `from`/`to`.\n *\n * Leave it `undefined` and the picker remembers the last preset it committed\n * itself; with no memory at all it falls back to the first preset whose dates match.\n */\n preset?: string | null;\n /** See {@link DateRangeCommit}. Default `\"immediate\"`. */\n commit?: DateRangeCommit;\n /**\n * Draw the trigger yourself. keksdose's report range field\n * (reports/report-range-field.tsx, \"Why this is app-level\") is why: its trigger\n * NAMES the active preset (\"Last 3 months\", the window as a muted suffix) where the\n * kit's shows two dates, sits inside a Tooltip, and is squared off on one side so the\n * granularity control can join it flush at the field's 42px.\n *\n * Spread `props.triggerProps` onto a `<button>` whole and put `props.valueProps` on\n * the element that shows the value — see {@link DateTriggerAttributes} for what each\n * part is for. The field around it (label, hidden name twin, clear/calendar glyph,\n * popover or phone sheet) is unchanged.\n */\n renderTrigger?: (props: DateRangeTriggerRenderProps) => ReactNode;\n /**\n * In the phone layout (`usePhoneLayout()`) the panel opens as a full-screen sheet (`FullBleedDialog`)\n * with presets in a grid above the calendar and Apply pinned in the footer, instead\n * of the 440px popover that hung off a 360px screen (keksdose's report range field,\n * which hand-rolled exactly this). This is its `backCloses`: on by default, so Back\n * dismisses the sheet; off for a caller whose commit rewrites the URL with\n * `replaceState` in the same tick — keksdose's `?preset/from/to` — where the router\n * would overwrite the sheet's history marker.\n */\n sheetBackCloses?: boolean;\n /**\n * With `commit=\"apply\"`: a line above the calendar saying what Apply would commit —\n * keksdose's report range writes \"Custom · 1 Mar 2026 – …\" there\n * (report-range-field.tsx), the one place a preset armed and then nudged by a day is\n * seen to have become \"Custom\", and a half-made range to still lack its end. Called\n * with the DRAFT on every change, in the popover and the phone sheet alike; `preset`\n * is the preset the column marks for it (none once a day was picked by hand), `to` is\n * `\"\"` until the second click. The line is a polite live region, so a screen reader\n * hears the draft change as the calendar is used.\n *\n * Ignored in `\"immediate\"` mode, where there is no draft: every click is the value.\n */\n renderDraftSummary?: (draft: DateRangeDraftSummary) => ReactNode;\n}\n\n/** What {@link DateRangePickerProps.renderDraftSummary} is called with. */\nexport interface DateRangeDraftSummary {\n from: string;\n to: string;\n /** The preset the column marks for the draft, if any. */\n preset: DateRangePickerPreset | undefined;\n}\n\n/** What {@link DateRangePickerProps.renderTrigger} is called with. */\nexport interface DateRangeTriggerRenderProps {\n /** Spread onto the `<button>`, whole. Merge your class after `triggerProps.className`. */\n triggerProps: DateTriggerAttributes;\n /** Put on the element showing the value: the trigger is named \"label, value\" by\n * reference to this id. Without it the name is the label alone. */\n valueProps: { id: string };\n open: boolean;\n from: string;\n to: string;\n /** The preset the column marks for the committed range, if any — the one to name. */\n preset: DateRangePickerPreset | undefined;\n /** What the kit's own trigger would show: the formatted range, or the placeholder. */\n text: string;\n}\n\ninterface RangeDraft {\n from: string;\n to: string;\n presetId: string | undefined;\n}\n\n/** Which preset (by index) the column marks, or -1. See `DateRangePickerProps.preset`. */\nfunction markedPreset(\n presets: readonly DateRangePickerPreset[],\n from: string,\n to: string,\n activeId: string | null | undefined,\n controlled: boolean,\n): number {\n const matches = (p: DateRangePickerPreset) => p.from === from && p.to === to;\n if (activeId != null) {\n const byId = presets.findIndex((p) => p.id === activeId && matches(p));\n if (byId !== -1 || controlled) return byId;\n } else if (controlled) {\n return -1;\n }\n return presets.findIndex(matches);\n}\n\n/**\n * The preset column: a named group of toggle buttons, `aria-pressed` on the marked one.\n * Pressed-state rather than colour alone, because \"which range is this\" is the one\n * thing a screen-reader user opening the panel needs to hear.\n */\nfunction PresetColumn({\n presets,\n marked,\n label,\n onPick,\n sheet,\n}: {\n presets: readonly DateRangePickerPreset[];\n marked: number;\n label: string;\n onPick: (p: DateRangePickerPreset) => void;\n /** The phone sheet's shape: a two-column grid ABOVE the calendar with 44px rows —\n * a 128px column beside a month grid leaves the grid ~200px on a 360px screen. */\n sheet?: boolean;\n}) {\n return (\n // `border-e`/`pe`, not `-r`: the preset column is on the START side, and the rule\n // between it and the calendar has to follow it in a right-to-left UI.\n <div\n role=\"group\"\n aria-label={label}\n className={\n sheet\n ? \"grid grid-cols-2 gap-0.5\"\n : \"flex w-32 shrink-0 flex-col gap-0.5 border-e border-subtle pe-2\"\n }\n >\n {presets.map((p, i) => {\n const selected = i === marked;\n return (\n <button\n key={p.id ?? i}\n type=\"button\"\n aria-pressed={selected}\n data-preset={p.id}\n onClick={() => onPick(p)}\n className={cn(\n \"rounded px-2 text-start\",\n // 44px on a phone: the kit's touch-target size (`SHEET_ROW_CLASS`).\n sheet ? \"min-h-11 py-2 text-sm\" : \"py-1.5 text-xs\",\n selected\n ? \"bg-active font-medium text-primary\"\n : \"text-secondary hover:bg-hover\",\n )}\n >\n {p.label}\n </button>\n );\n })}\n </div>\n );\n}\n\n/**\n * The panel's body. A component of its own so `commit=\"apply\"` can keep its draft in\n * state that is born when the panel opens: `Popover` unmounts its children on close,\n * so the draft is re-seeded from the committed value on every open, and an abandoned\n * half-selection cannot survive into the next one.\n */\nfunction RangePanel({\n from,\n to,\n presets,\n activeId,\n controlled,\n commit,\n close,\n frame,\n sheet,\n commitRange,\n calendarProps,\n labels,\n renderDraftSummary,\n}: {\n from: string;\n to: string;\n presets: readonly DateRangePickerPreset[] | undefined;\n activeId: string | null | undefined;\n controlled: boolean;\n commit: DateRangeCommit;\n close: () => void;\n /** Where the body and the Apply row go — see `PanelFrame`. */\n frame: PanelFrame;\n sheet: boolean;\n commitRange: (from: string, to: string, presetId: string | undefined) => void;\n calendarProps: Pick<PanelCalendarProps, \"locale\" | \"min\" | \"max\" | \"labels\" | \"monthJump\">;\n labels: DatePickerLabels;\n renderDraftSummary?: DateRangePickerProps[\"renderDraftSummary\"];\n}) {\n const drafting = commit === \"apply\";\n const [draft, setDraft] = useState<RangeDraft>({\n from,\n to,\n presetId: activeId ?? undefined,\n });\n // What the panel SHOWS: the draft while drafting, the committed value otherwise.\n const shown = drafting ? draft : { from, to, presetId: activeId ?? undefined };\n const marked = presets\n ? markedPreset(presets, shown.from, shown.to, drafting ? draft.presetId : activeId, drafting || controlled)\n : -1;\n const complete = Boolean(draft.from && draft.to);\n\n const summary =\n drafting && renderDraftSummary\n ? renderDraftSummary({ from: draft.from, to: draft.to, preset: presets?.[marked] })\n : null;\n\n const picker = (\n <PanelCalendar\n {...calendarProps}\n focusOnOpen\n from={shown.from}\n to={shown.to}\n onSelect={(f, t) => {\n if (drafting) {\n setDraft({ from: f, to: t, presetId: undefined });\n return;\n }\n commitRange(f, t, undefined);\n // Two-click range: only dismiss once both ends are chosen.\n if (f && t) close();\n }}\n />\n );\n // The summary heads the CALENDAR's column, not the panel: beside a preset column it\n // reads as the calendar's caption, and in the sheet it sits between the presets and\n // the month it describes. `null` renders no line — a caller may opt out per draft —\n // but keeps the wrapper, so the calendar is not remounted (and its month and focus\n // lost) when a line comes or goes.\n const calendar =\n drafting && renderDraftSummary ? (\n <div className=\"flex flex-col gap-2\">\n {summary != null && (\n <div aria-live=\"polite\" data-draft-summary=\"\" className=\"text-xs text-muted\">\n {summary}\n </div>\n )}\n {picker}\n </div>\n ) : (\n picker\n );\n\n const body =\n presets && presets.length > 0 ? (\n <div className={sheet ? \"flex flex-col gap-3\" : \"flex gap-3\"}>\n <PresetColumn\n sheet={sheet}\n presets={presets}\n marked={marked}\n label={labels.presets}\n onPick={(p) => {\n if (drafting) {\n setDraft({ from: p.from, to: p.to, presetId: p.id });\n return;\n }\n commitRange(p.from, p.to, p.id);\n close();\n }}\n />\n {/* `flex-1`: the calendar takes the rest of the panel. Content-sized it was\n seven tiny cells beside a wide preset column. */}\n <div className=\"min-w-0 flex-1\">{calendar}</div>\n </div>\n ) : (\n calendar\n );\n\n if (!drafting) return frame(body);\n return frame(\n body,\n <>\n {/* `flex-1` in the sheet: two thumb-wide halves of the footer, not two small\n buttons in its corner. */}\n <Button type=\"button\" variant=\"ghost\" className={sheet ? \"flex-1\" : undefined} onClick={close}>\n {labels.cancel}\n </Button>\n <Button\n type=\"button\"\n className={sheet ? \"flex-1\" : undefined}\n // A half-made `(from, \"\")` would commit a window with no end — a blank\n // report, one click into a two-click gesture.\n disabled={!complete}\n onClick={() => {\n if (!complete) return;\n commitRange(draft.from, draft.to, draft.presetId);\n close();\n }}\n >\n {labels.apply}\n </Button>\n </>,\n );\n}\n\n/** Two-date range picker: click a start then an end; closes once both are set.\n * With `presets`, a column of named ranges is shown beside the calendar; with\n * `commit=\"apply\"` the panel drafts and commits on Apply (see {@link DateRangeCommit}). */\nexport function DateRangePicker(props: DateRangePickerProps) {\n const {\n from,\n to,\n onChange,\n locale: localeProp,\n placeholder,\n min,\n max,\n formatOptions,\n formatValue,\n separator = \" – \",\n presets,\n preset,\n commit = \"immediate\",\n renderTrigger,\n sheetBackCloses,\n renderDraftSummary,\n calendarLabels,\n monthJump,\n label,\n clearable,\n clearLabel,\n disabled,\n invalid,\n hint,\n help,\n error,\n // The trigger's (API-3).\n ref,\n ...rest\n } = props;\n const locale = useKitLocale(localeProp);\n const text = useDatePickerLabels(clearLabel, undefined, undefined);\n const render = useTriggerFormat(\"dateRangePicker\", locale, formatOptions, formatValue);\n const messages = useFieldMessages({\n hint,\n help,\n name: label ?? rest[\"aria-label\"],\n error,\n invalid,\n describedBy: rest[\"aria-describedby\"],\n ariaInvalid: rest[\"aria-invalid\"],\n });\n // The preset this picker committed itself, for a caller that does not control\n // `preset`. Cleared by a hand-picked range or a clear.\n const [ownPreset, setOwnPreset] = useState<string | undefined>(undefined);\n const controlled = preset !== undefined;\n const activeId = controlled ? preset : ownPreset;\n const a = from ? render(from) : \"\";\n const b = to ? render(to) : \"\";\n const triggerText = from\n ? to\n ? `${a}${separator}${b}`\n : `${a}${separator}…`\n : (placeholder ?? \"\");\n const hasPresets = Boolean(presets && presets.length > 0);\n const sheet = usePhoneLayout();\n const commitRange = (f: string, t: string, presetId: string | undefined) => {\n setOwnPreset(presetId);\n // Two arguments when no preset is involved — exactly the 0.7 call, so a caller's\n // `toHaveBeenCalledWith(from, to)` and a variadic handler see no difference.\n if (presetId === undefined) onChange(f, t);\n else onChange(f, t, presetId);\n };\n const field = (\n <DateField\n {...rest}\n fieldRef={ref}\n aria-describedby={messages.describedBy}\n label={label}\n labelHint={messages.labelHint}\n clearable={clearable}\n clearLabel={text.clear}\n panelLabel={text.rangePanel}\n disabled={disabled}\n invalid={messages.isInvalid}\n // Widen so the preset column sits beside the calendar (default otherwise).\n width={hasPresets ? \"27.5rem\" : undefined}\n hasValue={Boolean(from || to)}\n triggerText={triggerText}\n onClear={() => commitRange(\"\", \"\", undefined)}\n sheet={sheet}\n sheetBackCloses={sheetBackCloses}\n renderTrigger={\n renderTrigger &&\n ((triggerProps, valueId) =>\n renderTrigger({\n triggerProps,\n valueProps: { id: valueId },\n open: triggerProps[\"aria-expanded\"],\n from,\n to,\n preset: presets?.[markedPreset(presets, from, to, activeId, controlled)],\n text: triggerText,\n }))\n }\n >\n {(close, frame) => (\n <RangePanel\n from={from}\n to={to}\n presets={presets}\n activeId={activeId}\n controlled={controlled}\n commit={commit}\n close={close}\n frame={frame}\n sheet={sheet}\n commitRange={commitRange}\n calendarProps={{ locale, min, max, labels: calendarLabels, monthJump }}\n labels={text}\n renderDraftSummary={renderDraftSummary}\n />\n )}\n </DateField>\n );\n return (\n <FieldBox below={messages.below} reserve={\"error\" in props || \"hint\" in props}>\n {withEndHint(field, label === undefined ? messages.labelHint : undefined)}\n </FieldBox>\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,11 +1,11 @@
1
1
  import type { ComponentPropsWithoutRef, ReactNode, Ref } from "react";
2
2
  import { type ComboClearValue, type ComboOption } from "./combobox-core.js";
3
- import { type DisabledReasonDisplay } from "./field-parts.js";
3
+ import { type DisabledReasonDisplay, type FieldHelpProps } from "./field-parts.js";
4
4
  import { type CommitScope } from "./write-lock.js";
5
5
  export type { ComboClearValue, ComboOption } from "./combobox-core.js";
6
6
  /** `onChange` is the kit's — "a selection was made", carrying values — rather
7
7
  * than the div's form event, so the DOM's spelling of it is omitted. */
8
- export interface EntityComboboxProps<V extends string | number, C extends ComboClearValue = null> extends Omit<ComponentPropsWithoutRef<"div">, "onChange"> {
8
+ export interface EntityComboboxProps<V extends string | number, C extends ComboClearValue = null> extends Omit<ComponentPropsWithoutRef<"div">, "onChange">, FieldHelpProps {
9
9
  /** Selected id; `null`, `undefined` or `clearValue` when nothing is selected. */
10
10
  value: V | C | null | undefined;
11
11
  /** Selecting an option emits its value; the clear button emits `clearValue`. */
@@ -53,6 +53,7 @@ function EntityCombobox(props) {
53
53
  invalid,
54
54
  error,
55
55
  hint,
56
+ help,
56
57
  filter,
57
58
  minChars,
58
59
  debounceMs,
@@ -77,7 +78,7 @@ function EntityCombobox(props) {
77
78
  minChars,
78
79
  debounceMs
79
80
  });
80
- const hintParts = useFieldHint(hint, ariaDescribedBy);
81
+ const hintParts = useFieldHint(hint, ariaDescribedBy, { help, name: label ?? ariaLabel });
81
82
  const field = useFieldError(error, invalid, hintParts.describedBy, ariaInvalid);
82
83
  const labels = useKitLabels("combobox", DEFAULT_COMBOBOX_LABELS, {
83
84
  search: searchPlaceholder,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/entity-combobox.tsx"],"sourcesContent":["import { useCallback, useId } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode, Ref } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { FieldChevron, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from \"./field-base\";\nimport { FieldLabel } from \"./field-parts\";\nimport {\n ComboboxPanel,\n endHintRowProps,\n offersCreate,\n useComboboxCore,\n type ComboClearValue,\n type ComboOption,\n} from \"./combobox-core\";\nimport { DEFAULT_COMBOBOX_LABELS, DEFAULT_COMMON_LABELS, useKitLabels } from \"../i18n/kit-labels\";\nimport {\n assignRef,\n EndHintRow,\n FieldCaption,\n LABEL_IN_ROW,\n LockedReason,\n mergeDescribedBy,\n StaticLabelRow,\n useFieldError,\n useFieldHint,\n useLockReason,\n type DisabledReasonDisplay,\n} from \"./field-parts\";\nimport { useCommitReason, type CommitScope } from \"./write-lock\";\n\nexport type { ComboClearValue, ComboOption } from \"./combobox-core\";\n\n/** `onChange` is the kit's — \"a selection was made\", carrying values — rather\n * than the div's form event, so the DOM's spelling of it is omitted. */\nexport interface EntityComboboxProps<V extends string | number, C extends ComboClearValue = null>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\"> {\n /** Selected id; `null`, `undefined` or `clearValue` when nothing is selected. */\n value: V | C | null | undefined;\n /** Selecting an option emits its value; the clear button emits `clearValue`. */\n onChange: (value: V | C) => void;\n /**\n * What the clear button emits. Default `null`. Pass `\"\"` for a form whose schema\n * wants an empty string for \"no choice\" (a zod `z.string()` field), rather than\n * mapping `null` in every `onChange` — the type of `onChange` follows it, so the\n * mapping cannot be forgotten on one field and not another. The picker also reads\n * the value back as empty, so `value=\"\"` shows the placeholder and no \"×\".\n * See {@link ComboClearValue} for why the choice is closed.\n */\n clearValue?: C;\n /** Already-loaded options (client-side filtered). Also used to resolve the\n * trigger label for the current `value`. */\n options?: ComboOption<V>[];\n /** Async option source, debounced and called on open + as the query changes\n * (at `minChars` and up). Stale responses are ignored; a rejection empties the\n * list and says `loadErrorLabel` instead of leaving the last query's rows up. When set, `options` is used only for label\n * resolution, not as the result list. */\n loadOptions?: (query: string) => Promise<ComboOption<V>[]>;\n /** External loading flag (OR-ed with the internal async state). */\n loading?: boolean;\n label?: ReactNode;\n placeholder?: string;\n searchPlaceholder?: string;\n emptyLabel?: string;\n /** Offer a clear \"×\" in place of the chevron while something is chosen; it emits\n * `clearValue`. A pointer shortcut inside the trigger, out of the tab order — the\n * keyboard's way is Delete or Backspace on the CLOSED trigger (0.23, as\n * {@link CountrySelect}). Not offered while `disabled` or locked. */\n clearable?: boolean;\n clearLabel?: string;\n /** The phone sheet's close button. Its own prop rather than a reuse of\n * `clearLabel`: \"clear the selection\" and \"close this screen\" are different\n * actions, and on a full-screen sheet the close button is the only way out —\n * so it is the one control here that MUST be in the reader's language. */\n closeLabel?: string;\n /** The phone sheet's heading (and, as a string, its accessible name) for a picker\n * whose label is drawn by someone else — a bound field's label; `RhfCombobox`\n * hands its label over here (0.24). Default: `label`, then `placeholder`. */\n sheetTitle?: ReactNode;\n disabled?: boolean;\n /**\n * Why the choice cannot be changed — {@link Button}'s `disabledReason`, for a picker\n * that SAVES on change (keksdose G2: the statement's account, where picking IS the\n * write). The trigger stays focusable and `aria-disabled`, the list does not open,\n * the clear \"×\" is gone and `onChange` never fires; the reason is in the kit\n * {@link Tooltip} and on the trigger's `aria-describedby`. Wins over `disabled`.\n * {@link CountrySelect}'s lock, which is built on the same core.\n */\n disabledReason?: ReactNode;\n /**\n * This picker COMMITS — choosing saves. Under a locked {@link WriteLockProvider} it is\n * locked the `disabledReason` way with the lock's reason (which wins over its own).\n * A picker inside a form with its own Save stays editable under the lock: leave this\n * off there and put `commit` on the Save. No provider, or an unlocked one: no effect.\n */\n commit?: CommitScope;\n /** Where the reason shows — {@link Button}'s `disabledReasonDisplay`: a line under the\n * field at Large and on a touch screen by default, the tooltip otherwise (DUP-3). */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /** When set, a \"create\" row appears for a non-empty query with no exact match. */\n onCreate?: (query: string) => void;\n createLabel?: (query: string) => string;\n /**\n * Offer the create row with NOTHING typed too, worded as this — keksdose G9's\n * \"Create cash account\", which mints a record under a default name and so needs no\n * query. `onCreate` then receives `\"\"`. Left out, the row needs a query, as before.\n * See {@link InlineEntityCombobox}'s `createEmptyLabel`.\n */\n createEmptyLabel?: string;\n /**\n * The create row WRITES (it mints the record on the server) while picking does not.\n * Under a locked {@link WriteLockProvider} the row stays in the list but cannot be\n * taken, with the lock's reason as its second line — the picker itself stays live.\n * keksdose dev#496: on the read-only demo choosing is draft state, minting is not.\n */\n createCommit?: CommitScope;\n /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /** What is wrong with the value, as {@link Input}'s `error`: rendered under the\n * field, on the trigger's `aria-describedby`, and implies `invalid`. */\n error?: ReactNode;\n /**\n * Standing advice — {@link Combobox}'s `hint`, so the pickers read like every other\n * field (keksdose K4): plain TEXT is a caption under the field on the trigger's\n * `aria-describedby`, before any error; a {@link FieldHint} \"?\" rides the label line,\n * or with no label sits at the trigger's end edge, outside the box.\n */\n hint?: ReactNode;\n /** Narrow `options` client-side by the query. Default `true`; `false` shows them\n * as given (a server-ranked list). */\n filter?: boolean;\n /** Offer nothing, and call no `loadOptions`, below this many characters. Default\n * `0`, i.e. the list loads as the panel opens. */\n minChars?: number;\n /** `loadOptions` debounce. Default 150 ms. */\n debounceMs?: number;\n /** Shown when `loadOptions` rejects. Default: `combobox.loadError`. */\n loadErrorLabel?: string;\n /**\n * The TRIGGER — the focusable `<button>` a reader meets — as a React 19 ref prop, as\n * on {@link CountrySelect}, which is built on the same core (0.24, kastlan: react-hook-\n * form's focus-on-error calls `focus()` on whatever `field.ref` is handed, and a\n * picker with no ref gave it nothing to focus). Not the wrapper: focus belongs on the\n * trigger, which is also where the panel hands it back after a pick.\n */\n ref?: Ref<HTMLButtonElement>;\n}\n\n/**\n * An id-keyed, searchable entity picker: a field-styled trigger showing the\n * selected item's label, and a portalled dropdown of `{label, sublabel, icon}`\n * options — loaded up front via `options` or lazily via `loadOptions`. Built on\n * the shared field/anchor/dismiss/search primitives (no cmdk/Radix). For picking\n * several entities use {@link MultiEntityCombobox}.\n */\nexport function EntityCombobox<V extends string | number, C extends ComboClearValue = null>(\n props: EntityComboboxProps<V, C>,\n) {\n const {\n value,\n onChange,\n clearValue = null as C,\n options,\n loadOptions,\n loading,\n label,\n placeholder,\n searchPlaceholder,\n emptyLabel,\n clearable,\n clearLabel,\n closeLabel,\n sheetTitle,\n disabled,\n disabledReason,\n disabledReasonDisplay,\n commit,\n onCreate,\n createLabel,\n createEmptyLabel,\n createCommit,\n className,\n invalid,\n error,\n hint,\n filter,\n minChars,\n debounceMs,\n loadErrorLabel,\n ref,\n \"aria-label\": ariaLabel,\n // The control's own wiring, taken off `rest` so it lands on the TRIGGER rather than\n // the wrapper — see MultiEntityCombobox: `Field`'s render-prop spreads `{ id,\n // aria-describedby, aria-invalid, aria-required }`, and on the role-less wrapper\n // div the label's `htmlFor` named nothing and the hint described nothing.\n id,\n \"aria-describedby\": ariaDescribedBy,\n \"aria-invalid\": ariaInvalid,\n \"aria-required\": ariaRequired,\n ...rest\n } = props;\n const core = useComboboxCore<V>({\n options,\n loadOptions,\n loading,\n filter,\n minChars,\n debounceMs,\n });\n const hintParts = useFieldHint(hint, ariaDescribedBy);\n const field = useFieldError(error, invalid, hintParts.describedBy, ariaInvalid);\n // The props are the per-instance overrides, the provider the app-wide ones; a\n // prop left `undefined` falls through to the provider rather than masking it.\n const labels = useKitLabels(\"combobox\", DEFAULT_COMBOBOX_LABELS, {\n search: searchPlaceholder,\n noResults: emptyLabel,\n clear: clearLabel,\n create: createLabel,\n });\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const { open, results, resolve, setOpen, rememberOption, query, triggerRef } = core;\n // The core anchors the panel to the trigger and hands focus back to it; the caller's\n // `ref` wants the same element. One callback feeds both — CountrySelect's.\n const setTrigger = useCallback(\n (el: HTMLButtonElement | null) => {\n triggerRef.current = el;\n assignRef(ref, el);\n },\n [ref, triggerRef],\n );\n // The lock: this picker's own reason, or the provider's under `commit`.\n const lock = useLockReason(commit, disabledReason);\n const inert = lock.locked || Boolean(disabled);\n // A lock arriving while the list is up closes it — adjusted while\n // rendering, as the core adjusts its own query, so no frame offers a pick that would\n // be thrown away.\n if (lock.locked && open) setOpen(false);\n // The create row's own lock, for a picker whose choosing is draft and whose create\n // is the write (`createCommit`).\n const createReason = useCommitReason(createCommit, undefined);\n // One id per instance, generated here rather than in the core: `aria-controls` on\n // the trigger has to name the list while the list is still closed, so the id\n // belongs to whoever renders both ends of it.\n const listboxId = useId();\n\n // The clear value is \"nothing selected\" too, whichever one the caller picked.\n const chosen: V | null = value == null || value === clearValue ? null : (value as V);\n const selectedOption = chosen == null ? null : resolve(chosen);\n const q = query.trim();\n const showCreate = offersCreate(\n onCreate,\n q,\n results.map((o) => o.label),\n createEmptyLabel,\n );\n const showClear = Boolean(clearable && chosen != null && !inert);\n /** What the closed control is showing — the second half of its accessible name. */\n const triggerText = selectedOption?.label ?? placeholder ?? \"\";\n // While locked the trigger sits in the reason's Tooltip — in a fragment with a hidden\n // copy of the reason, Button's anatomy — `block` so a full-width field stays full width.\n const withLock = (trigger: ReactNode) =>\n lock.locked ? (\n <LockedReason lock={lock} display={disabledReasonDisplay} className=\"block\">\n {trigger}\n </LockedReason>\n ) : (\n trigger\n );\n\n const choose = (o: ComboOption<V>) => {\n if (lock.locked) return;\n rememberOption(o);\n onChange(o.value);\n // Back to the trigger, not to <body>: the panel that held focus is about to\n // unmount, and a keyboard user who just answered this field should be standing\n // on it, ready to Tab to the next one.\n core.closeToTrigger();\n };\n\n return (\n // `rest` dresses the wrapper, which has no role; the accessible NAME — and the\n // id, description, invalid and required state above — go on the trigger, which\n // has one. Spread FIRST so the trigger's ARIA and the\n // handlers that open the panel cannot be clobbered from outside.\n <div {...rest} className={cn(\"relative\", className)}>\n {label !== undefined && hintParts.labelHint === undefined && <FieldLabel>{label}</FieldLabel>}\n <EndHintRow {...endHintRowProps(\"hint\" in props, label !== undefined, hintParts.labelHint)}>\n {withLock(\n <button\n ref={setTrigger}\n id={id}\n type=\"button\"\n // A combobox, not a button. The distinction is not pedantry: this control\n // carried `aria-invalid`, which `button` does not support, so a required\n // field left empty painted a rose border and told a reader nothing at all —\n // and ESLint flagged it as exactly that (`role-supports-aria-props`). The\n // fix the audit asked for is the role that describes what this IS: a closed\n // choice that expands into the list named below. `combobox` supports\n // `aria-invalid`, so the border and the announcement finally agree.\n role=\"combobox\"\n aria-expanded={open}\n aria-controls={listboxId}\n aria-haspopup=\"listbox\"\n // The label is a floating <span>, not a <label for>, so without this the\n // trigger's accessible name is whatever value happens to be selected —\n // \"Checking\" with nothing saying it is the account. The label AND the\n // value, because `aria-label` replaces the content rather than adding to\n // it, and a control that announces only its name has lost the answer.\n // A caller's own name wins over the composition: two fields labelled\n // \"Account\" on a transfer form are the from and the to, and only the\n // caller knows which is which.\n aria-label={\n ariaLabel ??\n (typeof label === \"string\" ? common.fieldValue(label, triggerText) : undefined)\n }\n // The focusable kind of disabled while locked (Button's rule): a native\n // `disabled` would take the field out of the tab order, and the reason with it.\n disabled={lock.locked ? undefined : disabled}\n aria-disabled={lock.locked || undefined}\n aria-invalid={field.isInvalid || undefined}\n aria-describedby={mergeDescribedBy(field.describedBy, lock.locked && lock.reasonId)}\n aria-required={ariaRequired}\n onClick={() => !inert && setOpen((o) => !o)}\n // Down/Up opens the list from the closed trigger, per the APG. Enter and\n // Space already do it through the button's own click.\n onKeyDown={(e) => {\n if (inert) return;\n if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n setOpen(true);\n } else if (showClear && !open && (e.key === \"Delete\" || e.key === \"Backspace\")) {\n // The keyboard's clear: the \"×\" is a pointer shortcut out of the tab\n // order, so without this a cleared-by-mouse field was set-only from the\n // keyboard. The closed trigger only — CountrySelect's rule (0.23).\n e.preventDefault();\n onChange(clearValue);\n }\n }}\n className={cn(\n FIELD_TRIGGER,\n \"pe-9\",\n label !== undefined && FIELD_FLOATING_PAD,\n inert && \"cursor-not-allowed opacity-50\",\n field.isInvalid && FIELD_INVALID,\n )}\n >\n <span className=\"flex min-w-0 items-center gap-2\">\n {selectedOption?.icon && <span className=\"shrink-0\">{selectedOption.icon}</span>}\n <span\n className={cn(\n // One line at Normal; from Large up the value wraps and the trigger\n // grows, as DatePicker's does (text size §10.17, round 0.34 T1).\n \"truncate-until-large\",\n // A chosen value is the field's VALUE, so it is set in the same ink an\n // <input>'s value is — FIELD_BASE's own text colour, inherited rather\n // than restated (Keksdose dev#477). It used to be one notch lighter\n // than the typeahead fields beside it, which is visible when a picker\n // and a text field share a form row. Nothing selected keeps the\n // placeholder tone (`--text-placeholder`), which every field here\n // agrees on.\n !selectedOption && \"text-placeholder\",\n )}\n >\n {selectedOption?.label ?? placeholder ?? \"\"}\n </span>\n </span>\n {showClear ? (\n /* eslint-disable-next-line jsx-a11y/click-events-have-key-events -- tabIndex -1 inside\n the trigger <button>, so it never holds focus and a key handler here could\n never fire; the keys go to the trigger, where Delete and Backspace clear. */\n <span\n role=\"button\"\n tabIndex={-1}\n aria-label={labels.clear}\n onClick={(e) => {\n e.stopPropagation();\n onChange(clearValue);\n }}\n className=\"absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-placeholder hover:text-secondary\"\n >\n <X className=\"size-4\" />\n </span>\n ) : (\n <FieldChevron />\n )}\n </button>,\n )}\n </EndHintRow>\n {/* With a \"?\" the label shares the top strip with it — after the trigger, so the\n \"?\" follows the control in the tab order, as on Input and Select. */}\n {label !== undefined && hintParts.labelHint !== undefined && (\n <StaticLabelRow hint={hintParts.labelHint}>\n <FieldLabel className={LABEL_IN_ROW}>{label}</FieldLabel>\n </StaticLabelRow>\n )}\n <ComboboxPanel\n core={core}\n listboxId={listboxId}\n // On a phone the panel becomes a full-screen sheet, which needs the field's\n // own label to say what it is asking for (live #200).\n sheetTitle={sheetTitle ?? label ?? placeholder}\n searchPlaceholder={labels.search}\n emptyLabel={labels.noResults}\n closeLabel={closeLabel}\n loadErrorLabel={loadErrorLabel}\n isSelected={(v) => v === value}\n onChoose={choose}\n showCreate={showCreate}\n onCreate={() => {\n if (lock.locked) return;\n onCreate?.(q);\n core.closeToTrigger();\n }}\n createContent={q ? labels.create(q) : createEmptyLabel}\n createDisabledReason={createReason}\n />\n <FieldCaption parts={hintParts} />\n {field.errorEl}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/entity-combobox.tsx"],"sourcesContent":["import { useCallback, useId } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode, Ref } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { FieldChevron, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from \"./field-base\";\nimport { FieldLabel } from \"./field-parts\";\nimport {\n ComboboxPanel,\n endHintRowProps,\n offersCreate,\n useComboboxCore,\n type ComboClearValue,\n type ComboOption,\n} from \"./combobox-core\";\nimport { DEFAULT_COMBOBOX_LABELS, DEFAULT_COMMON_LABELS, useKitLabels } from \"../i18n/kit-labels\";\nimport {\n assignRef,\n EndHintRow,\n FieldCaption,\n LABEL_IN_ROW,\n LockedReason,\n mergeDescribedBy,\n StaticLabelRow,\n useFieldError,\n useFieldHint,\n useLockReason,\n type DisabledReasonDisplay,\n type FieldHelpProps,\n} from \"./field-parts\";\nimport { useCommitReason, type CommitScope } from \"./write-lock\";\n\nexport type { ComboClearValue, ComboOption } from \"./combobox-core\";\n\n/** `onChange` is the kit's — \"a selection was made\", carrying values — rather\n * than the div's form event, so the DOM's spelling of it is omitted. */\nexport interface EntityComboboxProps<V extends string | number, C extends ComboClearValue = null>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\">,\n FieldHelpProps {\n /** Selected id; `null`, `undefined` or `clearValue` when nothing is selected. */\n value: V | C | null | undefined;\n /** Selecting an option emits its value; the clear button emits `clearValue`. */\n onChange: (value: V | C) => void;\n /**\n * What the clear button emits. Default `null`. Pass `\"\"` for a form whose schema\n * wants an empty string for \"no choice\" (a zod `z.string()` field), rather than\n * mapping `null` in every `onChange` — the type of `onChange` follows it, so the\n * mapping cannot be forgotten on one field and not another. The picker also reads\n * the value back as empty, so `value=\"\"` shows the placeholder and no \"×\".\n * See {@link ComboClearValue} for why the choice is closed.\n */\n clearValue?: C;\n /** Already-loaded options (client-side filtered). Also used to resolve the\n * trigger label for the current `value`. */\n options?: ComboOption<V>[];\n /** Async option source, debounced and called on open + as the query changes\n * (at `minChars` and up). Stale responses are ignored; a rejection empties the\n * list and says `loadErrorLabel` instead of leaving the last query's rows up. When set, `options` is used only for label\n * resolution, not as the result list. */\n loadOptions?: (query: string) => Promise<ComboOption<V>[]>;\n /** External loading flag (OR-ed with the internal async state). */\n loading?: boolean;\n label?: ReactNode;\n placeholder?: string;\n searchPlaceholder?: string;\n emptyLabel?: string;\n /** Offer a clear \"×\" in place of the chevron while something is chosen; it emits\n * `clearValue`. A pointer shortcut inside the trigger, out of the tab order — the\n * keyboard's way is Delete or Backspace on the CLOSED trigger (0.23, as\n * {@link CountrySelect}). Not offered while `disabled` or locked. */\n clearable?: boolean;\n clearLabel?: string;\n /** The phone sheet's close button. Its own prop rather than a reuse of\n * `clearLabel`: \"clear the selection\" and \"close this screen\" are different\n * actions, and on a full-screen sheet the close button is the only way out —\n * so it is the one control here that MUST be in the reader's language. */\n closeLabel?: string;\n /** The phone sheet's heading (and, as a string, its accessible name) for a picker\n * whose label is drawn by someone else — a bound field's label; `RhfCombobox`\n * hands its label over here (0.24). Default: `label`, then `placeholder`. */\n sheetTitle?: ReactNode;\n disabled?: boolean;\n /**\n * Why the choice cannot be changed — {@link Button}'s `disabledReason`, for a picker\n * that SAVES on change (keksdose G2: the statement's account, where picking IS the\n * write). The trigger stays focusable and `aria-disabled`, the list does not open,\n * the clear \"×\" is gone and `onChange` never fires; the reason is in the kit\n * {@link Tooltip} and on the trigger's `aria-describedby`. Wins over `disabled`.\n * {@link CountrySelect}'s lock, which is built on the same core.\n */\n disabledReason?: ReactNode;\n /**\n * This picker COMMITS — choosing saves. Under a locked {@link WriteLockProvider} it is\n * locked the `disabledReason` way with the lock's reason (which wins over its own).\n * A picker inside a form with its own Save stays editable under the lock: leave this\n * off there and put `commit` on the Save. No provider, or an unlocked one: no effect.\n */\n commit?: CommitScope;\n /** Where the reason shows — {@link Button}'s `disabledReasonDisplay`: a line under the\n * field at Large and on a touch screen by default, the tooltip otherwise (DUP-3). */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /** When set, a \"create\" row appears for a non-empty query with no exact match. */\n onCreate?: (query: string) => void;\n createLabel?: (query: string) => string;\n /**\n * Offer the create row with NOTHING typed too, worded as this — keksdose G9's\n * \"Create cash account\", which mints a record under a default name and so needs no\n * query. `onCreate` then receives `\"\"`. Left out, the row needs a query, as before.\n * See {@link InlineEntityCombobox}'s `createEmptyLabel`.\n */\n createEmptyLabel?: string;\n /**\n * The create row WRITES (it mints the record on the server) while picking does not.\n * Under a locked {@link WriteLockProvider} the row stays in the list but cannot be\n * taken, with the lock's reason as its second line — the picker itself stays live.\n * keksdose dev#496: on the read-only demo choosing is draft state, minting is not.\n */\n createCommit?: CommitScope;\n /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /** What is wrong with the value, as {@link Input}'s `error`: rendered under the\n * field, on the trigger's `aria-describedby`, and implies `invalid`. */\n error?: ReactNode;\n /**\n * Standing advice — {@link Combobox}'s `hint`, so the pickers read like every other\n * field (keksdose K4): plain TEXT is a caption under the field on the trigger's\n * `aria-describedby`, before any error; a {@link FieldHint} \"?\" rides the label line,\n * or with no label sits at the trigger's end edge, outside the box.\n */\n hint?: ReactNode;\n /** Narrow `options` client-side by the query. Default `true`; `false` shows them\n * as given (a server-ranked list). */\n filter?: boolean;\n /** Offer nothing, and call no `loadOptions`, below this many characters. Default\n * `0`, i.e. the list loads as the panel opens. */\n minChars?: number;\n /** `loadOptions` debounce. Default 150 ms. */\n debounceMs?: number;\n /** Shown when `loadOptions` rejects. Default: `combobox.loadError`. */\n loadErrorLabel?: string;\n /**\n * The TRIGGER — the focusable `<button>` a reader meets — as a React 19 ref prop, as\n * on {@link CountrySelect}, which is built on the same core (0.24, kastlan: react-hook-\n * form's focus-on-error calls `focus()` on whatever `field.ref` is handed, and a\n * picker with no ref gave it nothing to focus). Not the wrapper: focus belongs on the\n * trigger, which is also where the panel hands it back after a pick.\n */\n ref?: Ref<HTMLButtonElement>;\n}\n\n/**\n * An id-keyed, searchable entity picker: a field-styled trigger showing the\n * selected item's label, and a portalled dropdown of `{label, sublabel, icon}`\n * options — loaded up front via `options` or lazily via `loadOptions`. Built on\n * the shared field/anchor/dismiss/search primitives (no cmdk/Radix). For picking\n * several entities use {@link MultiEntityCombobox}.\n */\nexport function EntityCombobox<V extends string | number, C extends ComboClearValue = null>(\n props: EntityComboboxProps<V, C>,\n) {\n const {\n value,\n onChange,\n clearValue = null as C,\n options,\n loadOptions,\n loading,\n label,\n placeholder,\n searchPlaceholder,\n emptyLabel,\n clearable,\n clearLabel,\n closeLabel,\n sheetTitle,\n disabled,\n disabledReason,\n disabledReasonDisplay,\n commit,\n onCreate,\n createLabel,\n createEmptyLabel,\n createCommit,\n className,\n invalid,\n error,\n hint,\n help,\n filter,\n minChars,\n debounceMs,\n loadErrorLabel,\n ref,\n \"aria-label\": ariaLabel,\n // The control's own wiring, taken off `rest` so it lands on the TRIGGER rather than\n // the wrapper — see MultiEntityCombobox: `Field`'s render-prop spreads `{ id,\n // aria-describedby, aria-invalid, aria-required }`, and on the role-less wrapper\n // div the label's `htmlFor` named nothing and the hint described nothing.\n id,\n \"aria-describedby\": ariaDescribedBy,\n \"aria-invalid\": ariaInvalid,\n \"aria-required\": ariaRequired,\n ...rest\n } = props;\n const core = useComboboxCore<V>({\n options,\n loadOptions,\n loading,\n filter,\n minChars,\n debounceMs,\n });\n const hintParts = useFieldHint(hint, ariaDescribedBy, { help, name: label ?? ariaLabel });\n const field = useFieldError(error, invalid, hintParts.describedBy, ariaInvalid);\n // The props are the per-instance overrides, the provider the app-wide ones; a\n // prop left `undefined` falls through to the provider rather than masking it.\n const labels = useKitLabels(\"combobox\", DEFAULT_COMBOBOX_LABELS, {\n search: searchPlaceholder,\n noResults: emptyLabel,\n clear: clearLabel,\n create: createLabel,\n });\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const { open, results, resolve, setOpen, rememberOption, query, triggerRef } = core;\n // The core anchors the panel to the trigger and hands focus back to it; the caller's\n // `ref` wants the same element. One callback feeds both — CountrySelect's.\n const setTrigger = useCallback(\n (el: HTMLButtonElement | null) => {\n triggerRef.current = el;\n assignRef(ref, el);\n },\n [ref, triggerRef],\n );\n // The lock: this picker's own reason, or the provider's under `commit`.\n const lock = useLockReason(commit, disabledReason);\n const inert = lock.locked || Boolean(disabled);\n // A lock arriving while the list is up closes it — adjusted while\n // rendering, as the core adjusts its own query, so no frame offers a pick that would\n // be thrown away.\n if (lock.locked && open) setOpen(false);\n // The create row's own lock, for a picker whose choosing is draft and whose create\n // is the write (`createCommit`).\n const createReason = useCommitReason(createCommit, undefined);\n // One id per instance, generated here rather than in the core: `aria-controls` on\n // the trigger has to name the list while the list is still closed, so the id\n // belongs to whoever renders both ends of it.\n const listboxId = useId();\n\n // The clear value is \"nothing selected\" too, whichever one the caller picked.\n const chosen: V | null = value == null || value === clearValue ? null : (value as V);\n const selectedOption = chosen == null ? null : resolve(chosen);\n const q = query.trim();\n const showCreate = offersCreate(\n onCreate,\n q,\n results.map((o) => o.label),\n createEmptyLabel,\n );\n const showClear = Boolean(clearable && chosen != null && !inert);\n /** What the closed control is showing — the second half of its accessible name. */\n const triggerText = selectedOption?.label ?? placeholder ?? \"\";\n // While locked the trigger sits in the reason's Tooltip — in a fragment with a hidden\n // copy of the reason, Button's anatomy — `block` so a full-width field stays full width.\n const withLock = (trigger: ReactNode) =>\n lock.locked ? (\n <LockedReason lock={lock} display={disabledReasonDisplay} className=\"block\">\n {trigger}\n </LockedReason>\n ) : (\n trigger\n );\n\n const choose = (o: ComboOption<V>) => {\n if (lock.locked) return;\n rememberOption(o);\n onChange(o.value);\n // Back to the trigger, not to <body>: the panel that held focus is about to\n // unmount, and a keyboard user who just answered this field should be standing\n // on it, ready to Tab to the next one.\n core.closeToTrigger();\n };\n\n return (\n // `rest` dresses the wrapper, which has no role; the accessible NAME — and the\n // id, description, invalid and required state above — go on the trigger, which\n // has one. Spread FIRST so the trigger's ARIA and the\n // handlers that open the panel cannot be clobbered from outside.\n <div {...rest} className={cn(\"relative\", className)}>\n {label !== undefined && hintParts.labelHint === undefined && <FieldLabel>{label}</FieldLabel>}\n <EndHintRow {...endHintRowProps(\"hint\" in props, label !== undefined, hintParts.labelHint)}>\n {withLock(\n <button\n ref={setTrigger}\n id={id}\n type=\"button\"\n // A combobox, not a button. The distinction is not pedantry: this control\n // carried `aria-invalid`, which `button` does not support, so a required\n // field left empty painted a rose border and told a reader nothing at all —\n // and ESLint flagged it as exactly that (`role-supports-aria-props`). The\n // fix the audit asked for is the role that describes what this IS: a closed\n // choice that expands into the list named below. `combobox` supports\n // `aria-invalid`, so the border and the announcement finally agree.\n role=\"combobox\"\n aria-expanded={open}\n aria-controls={listboxId}\n aria-haspopup=\"listbox\"\n // The label is a floating <span>, not a <label for>, so without this the\n // trigger's accessible name is whatever value happens to be selected —\n // \"Checking\" with nothing saying it is the account. The label AND the\n // value, because `aria-label` replaces the content rather than adding to\n // it, and a control that announces only its name has lost the answer.\n // A caller's own name wins over the composition: two fields labelled\n // \"Account\" on a transfer form are the from and the to, and only the\n // caller knows which is which.\n aria-label={\n ariaLabel ??\n (typeof label === \"string\" ? common.fieldValue(label, triggerText) : undefined)\n }\n // The focusable kind of disabled while locked (Button's rule): a native\n // `disabled` would take the field out of the tab order, and the reason with it.\n disabled={lock.locked ? undefined : disabled}\n aria-disabled={lock.locked || undefined}\n aria-invalid={field.isInvalid || undefined}\n aria-describedby={mergeDescribedBy(field.describedBy, lock.locked && lock.reasonId)}\n aria-required={ariaRequired}\n onClick={() => !inert && setOpen((o) => !o)}\n // Down/Up opens the list from the closed trigger, per the APG. Enter and\n // Space already do it through the button's own click.\n onKeyDown={(e) => {\n if (inert) return;\n if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n setOpen(true);\n } else if (showClear && !open && (e.key === \"Delete\" || e.key === \"Backspace\")) {\n // The keyboard's clear: the \"×\" is a pointer shortcut out of the tab\n // order, so without this a cleared-by-mouse field was set-only from the\n // keyboard. The closed trigger only — CountrySelect's rule (0.23).\n e.preventDefault();\n onChange(clearValue);\n }\n }}\n className={cn(\n FIELD_TRIGGER,\n \"pe-9\",\n label !== undefined && FIELD_FLOATING_PAD,\n inert && \"cursor-not-allowed opacity-50\",\n field.isInvalid && FIELD_INVALID,\n )}\n >\n <span className=\"flex min-w-0 items-center gap-2\">\n {selectedOption?.icon && <span className=\"shrink-0\">{selectedOption.icon}</span>}\n <span\n className={cn(\n // One line at Normal; from Large up the value wraps and the trigger\n // grows, as DatePicker's does (text size §10.17, round 0.34 T1).\n \"truncate-until-large\",\n // A chosen value is the field's VALUE, so it is set in the same ink an\n // <input>'s value is — FIELD_BASE's own text colour, inherited rather\n // than restated (Keksdose dev#477). It used to be one notch lighter\n // than the typeahead fields beside it, which is visible when a picker\n // and a text field share a form row. Nothing selected keeps the\n // placeholder tone (`--text-placeholder`), which every field here\n // agrees on.\n !selectedOption && \"text-placeholder\",\n )}\n >\n {selectedOption?.label ?? placeholder ?? \"\"}\n </span>\n </span>\n {showClear ? (\n /* eslint-disable-next-line jsx-a11y/click-events-have-key-events -- tabIndex -1 inside\n the trigger <button>, so it never holds focus and a key handler here could\n never fire; the keys go to the trigger, where Delete and Backspace clear. */\n <span\n role=\"button\"\n tabIndex={-1}\n aria-label={labels.clear}\n onClick={(e) => {\n e.stopPropagation();\n onChange(clearValue);\n }}\n className=\"absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-placeholder hover:text-secondary\"\n >\n <X className=\"size-4\" />\n </span>\n ) : (\n <FieldChevron />\n )}\n </button>,\n )}\n </EndHintRow>\n {/* With a \"?\" the label shares the top strip with it — after the trigger, so the\n \"?\" follows the control in the tab order, as on Input and Select. */}\n {label !== undefined && hintParts.labelHint !== undefined && (\n <StaticLabelRow hint={hintParts.labelHint}>\n <FieldLabel className={LABEL_IN_ROW}>{label}</FieldLabel>\n </StaticLabelRow>\n )}\n <ComboboxPanel\n core={core}\n listboxId={listboxId}\n // On a phone the panel becomes a full-screen sheet, which needs the field's\n // own label to say what it is asking for (live #200).\n sheetTitle={sheetTitle ?? label ?? placeholder}\n searchPlaceholder={labels.search}\n emptyLabel={labels.noResults}\n closeLabel={closeLabel}\n loadErrorLabel={loadErrorLabel}\n isSelected={(v) => v === value}\n onChoose={choose}\n showCreate={showCreate}\n onCreate={() => {\n if (lock.locked) return;\n onCreate?.(q);\n core.closeToTrigger();\n }}\n createContent={q ? labels.create(q) : createEmptyLabel}\n createDisabledReason={createReason}\n />\n <FieldCaption parts={hintParts} />\n {field.errorEl}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -110,8 +110,55 @@ export declare function FieldHint({ label, side, className, "aria-label": ariaLa
110
110
  * The kit's fields call it through {@link useFieldHint} / {@link useFieldMessages}; a
111
111
  * field that resolves its own `hint` (an app's, or one of the kit's own outside this
112
112
  * file) calls it first and then treats the result as it treated a text hint.
113
+ *
114
+ * `withHelp` (round 0.36): the field has a `help`, whose "?" is the field's only one
115
+ * ({@link fieldHelpMark}) — so a FieldHint is its words at EVERY size, not only at Large
116
+ * and on touch.
117
+ */
118
+ export declare function useInlineHint(hint: ReactNode, withHelp?: boolean): ReactNode;
119
+ /**
120
+ * The doc every field's `help` prop points at.
121
+ *
122
+ * A field may carry two kinds of words (§10.23): a FACT — a format, a limit, a
123
+ * consequence — which stays visible, and HELP — why the field exists, how to choose,
124
+ * what the options mean — which the field is usable without and which may sit behind a
125
+ * "?". Until 0.36 a field had one slot, `hint`, so a field with both had to drop one or
126
+ * print a paragraph (Kurvenschmiede's "How it was measured", round 0.36 H). Now:
127
+ *
128
+ * - `hint` is the fact, the caption under the field, on its `aria-describedby`;
129
+ * - `help` is the help, a {@link HelpPopover} "?" on the label line (an unlabelled
130
+ * field's at its end edge, as a hint's), named "About “<label>”" (`common.aboutField`,
131
+ * from the label's text, else the `aria-label`, else `common.help`). Pass a
132
+ * `HelpPopover` as `help` to name it yourself ("What do the statuses mean?").
133
+ *
134
+ * The help's "?" is then the field's ONLY "?": a `FieldHint` passed as `hint` beside it
135
+ * draws none of its own and shows its words as the caption at every size, as it does at
136
+ * Large and on touch; any other `hint` is the caption too. Two "?" side by side would
137
+ * look the same and open different things.
113
138
  */
114
- export declare function useInlineHint(hint: ReactNode): ReactNode;
139
+ export interface FieldHelpProps {
140
+ /**
141
+ * Help the field is usable without — why it exists, how to choose, what the options
142
+ * mean — behind the field's one "?" on its label line: a {@link HelpPopover} named
143
+ * "About “<label>”" that opens on a tap (round 0.36, docs/text-size-harmonization.md §4).
144
+ * Pass a `HelpPopover` to name it yourself. `hint` stays the visible fact: with `help`
145
+ * set it is the caption under the field at every size, a `FieldHint` included.
146
+ */
147
+ help?: ReactNode;
148
+ }
149
+ /**
150
+ * A field's `help` as the "?" for its label line, or `undefined` when there is none. A
151
+ * {@link HelpPopover} is used as it is (it carries its own name); anything else is put
152
+ * in one named "About “<name>”" — `name` is the field's label, else its `aria-label`.
153
+ */
154
+ export declare function fieldHelpMark(help: ReactNode, name: ReactNode): ReactNode | undefined;
155
+ /** What {@link useFieldHint} needs to draw a field's `help`. */
156
+ export interface FieldHelpSource {
157
+ /** The field's `help`. */
158
+ help?: ReactNode;
159
+ /** What names its "?": the field's label, else its `aria-label`. */
160
+ name?: ReactNode;
161
+ }
115
162
  /**
116
163
  * Whether a `hint` is a CAPTION (plain text: a non-empty string or a number) or
117
164
  * something for the label line (a {@link FieldHint} "?", or any other node).
@@ -131,17 +178,25 @@ export interface FieldHintParts {
131
178
  describedBy: string | undefined;
132
179
  /** The caption's id while there is one. */
133
180
  captionId: string | undefined;
134
- /** The caption itself, or `undefined` for a hint that is not text. */
135
- captionText: string | number | undefined;
136
- /** A non-text hint (a FieldHint) for the label line — or, on a field with no label
137
- * line, the end edge outside the box. `undefined` when there is none. */
181
+ /** The caption itself — a text hint, or any hint beside a `help` — or `undefined`. */
182
+ captionText: ReactNode | undefined;
183
+ /** What rides the label line — the `help`'s "?" ({@link fieldHelpMark}), else a
184
+ * non-text hint (a FieldHint) — or, on a field with no label line, the end edge
185
+ * outside the box. `undefined` when there is none. */
138
186
  labelHint: ReactNode | undefined;
139
187
  }
188
+ /**
189
+ * Whether a resolved `hint` goes under the field as its caption: text always (see
190
+ * {@link isTextHint}); with a `help` beside it, whatever it is — the label line's "?"
191
+ * is the help's (round 0.36, §4).
192
+ */
193
+ export declare function isHintCaption(hint: ReactNode, withHelp: boolean): boolean;
140
194
  /**
141
195
  * Split a field's `hint` into the caption under it (attached with `aria-describedby`)
142
- * or the "?" on its label line. See {@link isTextHint}.
196
+ * or the "?" on its label line. See {@link isTextHint}. With a `help` ({@link
197
+ * FieldHelpProps}), the help's "?" takes the label line and the hint is the caption.
143
198
  */
144
- export declare function useFieldHint(hintProp: ReactNode, describedBy: string | undefined): FieldHintParts;
199
+ export declare function useFieldHint(hintProp: ReactNode, describedBy: string | undefined, { help, name }?: FieldHelpSource): FieldHintParts;
145
200
  /** The error line under a field — one type for every field's message (DUP-8, refactor
146
201
  * audit 2026-10: ChipInput and FileDropzone printed theirs in `text-xs`). */
147
202
  export declare const FIELD_ERROR_CLASS = "mt-1 text-caption leading-tight text-danger";
@@ -345,16 +400,16 @@ export interface FieldMessages {
345
400
  describedBy: string | undefined;
346
401
  /** The caption and the error, for under the field; `null` when there is neither. */
347
402
  below: ReactNode;
348
- /** The hint for the LABEL line (a `FieldHint`), or `undefined` when the hint is a
349
- * caption or there is none. */
403
+ /** What rides the LABEL line — the `help`'s "?", else a `FieldHint` — or `undefined`
404
+ * when the hint is a caption and there is no help. */
350
405
  labelHint: ReactNode;
351
406
  }
352
407
  /**
353
- * `hint` / `error` / `invalid` resolved the way `Select` resolves them —
408
+ * `hint` / `help` / `error` / `invalid` resolved the way `Select` resolves them —
354
409
  * {@link useFieldHint} and {@link useFieldError} together, for a field that draws what
355
410
  * goes under it in one place (a picker, a group, a field built on another).
356
411
  */
357
- export declare function useFieldMessages({ hint, error, invalid, describedBy, ariaInvalid, reasonId, }: {
412
+ export declare function useFieldMessages({ hint, help, name, error, invalid, describedBy, ariaInvalid, reasonId, }: FieldHelpSource & {
358
413
  hint?: ReactNode;
359
414
  error?: ReactNode;
360
415
  invalid?: boolean;
@@ -4,8 +4,11 @@ import { createContext, isValidElement, useContext, useId } from "react";
4
4
  import { HelpCircle } from "lucide-react";
5
5
  import { cn } from "../lib/cn.js";
6
6
  import { hasContent } from "../lib/presence.js";
7
+ import { plainText } from "../lib/plain-text.js";
8
+ import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels.js";
7
9
  import { useInlineFacts } from "../hooks/use-large-text.js";
8
10
  import { FOCUS_RING } from "./focus-ring.js";
11
+ import { HelpPopover } from "./help-popover.js";
9
12
  import { Tooltip } from "./tooltip.js";
10
13
  import { useCommitReason } from "./write-lock.js";
11
14
  function mergeDescribedBy(...ids) {
@@ -58,27 +61,42 @@ function FieldHint({
58
61
  ) })
59
62
  );
60
63
  }
61
- function useInlineHint(hint) {
64
+ function useInlineHint(hint, withHelp = false) {
62
65
  const inline = useInlineFacts();
63
- if (inline && isValidElement(hint) && hint.type === FieldHint) {
66
+ if ((inline || withHelp) && isValidElement(hint) && hint.type === FieldHint) {
64
67
  const words = hint.props.label;
65
68
  if (words) return words;
66
69
  }
67
70
  return hint;
68
71
  }
72
+ function FieldHelp({ name, children }) {
73
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
74
+ const text = plainText(name);
75
+ return /* @__PURE__ */ jsx(HelpPopover, { label: text === void 0 ? common.help : common.aboutField(text), children });
76
+ }
77
+ function fieldHelpMark(help, name) {
78
+ if (!hasContent(help)) return void 0;
79
+ if (isValidElement(help) && help.type === HelpPopover) return help;
80
+ return /* @__PURE__ */ jsx(FieldHelp, { name, children: help });
81
+ }
69
82
  function isTextHint(hint) {
70
83
  return typeof hint === "string" && hint !== "" || typeof hint === "number";
71
84
  }
72
85
  const FIELD_CAPTION_CLASS = "mt-1 text-caption leading-tight text-muted";
73
- function useFieldHint(hintProp, describedBy) {
74
- const hint = useInlineHint(hintProp);
86
+ function isHintCaption(hint, withHelp) {
87
+ return isTextHint(hint) || withHelp && hasContent(hint);
88
+ }
89
+ function useFieldHint(hintProp, describedBy, { help, name } = {}) {
90
+ const helpMark = fieldHelpMark(help, name);
91
+ const withHelp = helpMark !== void 0;
92
+ const hint = useInlineHint(hintProp, withHelp);
75
93
  const captionId = useId();
76
- const text = isTextHint(hint);
94
+ const caption = isHintCaption(hint, withHelp);
77
95
  return {
78
- describedBy: text ? mergeDescribedBy(describedBy, captionId) : describedBy,
79
- captionId: text ? captionId : void 0,
80
- captionText: text ? hint : void 0,
81
- labelHint: !text && hasContent(hint) ? hint : void 0
96
+ describedBy: caption ? mergeDescribedBy(describedBy, captionId) : describedBy,
97
+ captionId: caption ? captionId : void 0,
98
+ captionText: caption ? hint : void 0,
99
+ labelHint: helpMark ?? (!caption && hasContent(hint) ? hint : void 0)
82
100
  };
83
101
  }
84
102
  const FIELD_ERROR_CLASS = "mt-1 text-caption leading-tight text-danger";
@@ -172,13 +190,15 @@ function DisabledReasonLine({
172
190
  const RequiredStarOnLegend = createContext(false);
173
191
  function useFieldMessages({
174
192
  hint,
193
+ help,
194
+ name,
175
195
  error,
176
196
  invalid,
177
197
  describedBy,
178
198
  ariaInvalid,
179
199
  reasonId
180
200
  }) {
181
- const hintParts = useFieldHint(hint, describedBy);
201
+ const hintParts = useFieldHint(hint, describedBy, { help, name });
182
202
  const field = useFieldError(error, invalid, mergeDescribedBy(hintParts.describedBy, reasonId), ariaInvalid);
183
203
  return {
184
204
  isInvalid: field.isInvalid,
@@ -272,6 +292,8 @@ export {
272
292
  STATIC_LABEL_TYPE,
273
293
  StaticLabelRow,
274
294
  assignRef,
295
+ fieldHelpMark,
296
+ isHintCaption,
275
297
  isTextHint,
276
298
  mergeDescribedBy,
277
299
  mergeRefs,