@eifi1/ui-kit 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/README.md +69 -33
  2. package/dist/chart.d.ts +1 -0
  3. package/dist/components/account-settings.js +3 -2
  4. package/dist/components/account-settings.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +5 -0
  6. package/dist/components/amount-input.js +6 -4
  7. package/dist/components/amount-input.js.map +1 -1
  8. package/dist/components/autocomplete.d.ts +33 -6
  9. package/dist/components/autocomplete.js +62 -16
  10. package/dist/components/autocomplete.js.map +1 -1
  11. package/dist/components/calculator.d.ts +5 -0
  12. package/dist/components/calculator.js +2 -1
  13. package/dist/components/calculator.js.map +1 -1
  14. package/dist/components/chart-zoom.js +2 -1
  15. package/dist/components/chart-zoom.js.map +1 -1
  16. package/dist/components/chart.d.ts +33 -2
  17. package/dist/components/chart.js +20 -8
  18. package/dist/components/chart.js.map +1 -1
  19. package/dist/components/chip.d.ts +34 -14
  20. package/dist/components/chip.js +63 -28
  21. package/dist/components/chip.js.map +1 -1
  22. package/dist/components/choice-card.d.ts +18 -1
  23. package/dist/components/choice-card.js +21 -8
  24. package/dist/components/choice-card.js.map +1 -1
  25. package/dist/components/combobox-core.d.ts +61 -2
  26. package/dist/components/combobox-core.js +68 -17
  27. package/dist/components/combobox-core.js.map +1 -1
  28. package/dist/components/combobox.d.ts +11 -7
  29. package/dist/components/combobox.js +51 -28
  30. package/dist/components/combobox.js.map +1 -1
  31. package/dist/components/currency-select.js +3 -3
  32. package/dist/components/currency-select.js.map +1 -1
  33. package/dist/components/danger-confirm.d.ts +30 -6
  34. package/dist/components/danger-confirm.js +51 -32
  35. package/dist/components/danger-confirm.js.map +1 -1
  36. package/dist/components/data-table-filter-popover.d.ts +1 -1
  37. package/dist/components/data-table-filters.d.ts +1 -1
  38. package/dist/components/data-table-pagination.d.ts +6 -3
  39. package/dist/components/data-table-pagination.js +6 -5
  40. package/dist/components/data-table-pagination.js.map +1 -1
  41. package/dist/components/data-table.d.ts +1 -1
  42. package/dist/components/data-table.js +332 -248
  43. package/dist/components/data-table.js.map +1 -1
  44. package/dist/components/date-picker.js +17 -7
  45. package/dist/components/date-picker.js.map +1 -1
  46. package/dist/components/dialog-frame.d.ts +28 -3
  47. package/dist/components/dialog-frame.js +47 -11
  48. package/dist/components/dialog-frame.js.map +1 -1
  49. package/dist/components/disclosure.d.ts +22 -2
  50. package/dist/components/disclosure.js +41 -8
  51. package/dist/components/disclosure.js.map +1 -1
  52. package/dist/components/dropdown.d.ts +7 -3
  53. package/dist/components/dropdown.js +6 -2
  54. package/dist/components/dropdown.js.map +1 -1
  55. package/dist/components/entity-combobox.d.ts +17 -8
  56. package/dist/components/entity-combobox.js +7 -5
  57. package/dist/components/entity-combobox.js.map +1 -1
  58. package/dist/components/file-button.d.ts +38 -5
  59. package/dist/components/file-button.js +23 -2
  60. package/dist/components/file-button.js.map +1 -1
  61. package/dist/components/file-dropzone.d.ts +11 -5
  62. package/dist/components/file-dropzone.js +14 -19
  63. package/dist/components/file-dropzone.js.map +1 -1
  64. package/dist/components/grouped-picker.js +2 -2
  65. package/dist/components/grouped-picker.js.map +1 -1
  66. package/dist/components/hover-menu.d.ts +8 -3
  67. package/dist/components/hover-menu.js +88 -4
  68. package/dist/components/hover-menu.js.map +1 -1
  69. package/dist/components/icon-picker.d.ts +10 -1
  70. package/dist/components/icon-picker.js +5 -1
  71. package/dist/components/icon-picker.js.map +1 -1
  72. package/dist/components/measured-grid.d.ts +165 -0
  73. package/dist/components/measured-grid.js +559 -0
  74. package/dist/components/measured-grid.js.map +1 -0
  75. package/dist/components/mini-calendar.js +7 -8
  76. package/dist/components/mini-calendar.js.map +1 -1
  77. package/dist/components/month-picker.js +13 -9
  78. package/dist/components/month-picker.js.map +1 -1
  79. package/dist/components/multi-entity-combobox.js +2 -2
  80. package/dist/components/multi-entity-combobox.js.map +1 -1
  81. package/dist/components/multi-select.d.ts +9 -1
  82. package/dist/components/multi-select.js +22 -8
  83. package/dist/components/multi-select.js.map +1 -1
  84. package/dist/components/number-field.d.ts +5 -0
  85. package/dist/components/number-input.d.ts +5 -0
  86. package/dist/components/number-input.js +3 -3
  87. package/dist/components/number-input.js.map +1 -1
  88. package/dist/components/numpad-sheet.d.ts +5 -0
  89. package/dist/components/numpad-sheet.js +3 -3
  90. package/dist/components/numpad-sheet.js.map +1 -1
  91. package/dist/components/picker-sheet.js +1 -1
  92. package/dist/components/picker-sheet.js.map +1 -1
  93. package/dist/components/popover.js +11 -2
  94. package/dist/components/popover.js.map +1 -1
  95. package/dist/components/series-chart-ticks.d.ts +13 -0
  96. package/dist/components/series-chart-ticks.js +29 -0
  97. package/dist/components/series-chart-ticks.js.map +1 -0
  98. package/dist/components/series-chart.js +33 -27
  99. package/dist/components/series-chart.js.map +1 -1
  100. package/dist/components/settings-fields.d.ts +2 -0
  101. package/dist/components/swatch-picker.d.ts +10 -1
  102. package/dist/components/swatch-picker.js +2 -0
  103. package/dist/components/swatch-picker.js.map +1 -1
  104. package/dist/components/swipeable-row.d.ts +11 -2
  105. package/dist/components/swipeable-row.js +5 -3
  106. package/dist/components/swipeable-row.js.map +1 -1
  107. package/dist/components/tile-radio.js +5 -7
  108. package/dist/components/tile-radio.js.map +1 -1
  109. package/dist/components/time-input.d.ts +2 -0
  110. package/dist/components/toggle-group.d.ts +13 -3
  111. package/dist/components/toggle-group.js +21 -1
  112. package/dist/components/toggle-group.js.map +1 -1
  113. package/dist/components/toggle-legend.js +1 -2
  114. package/dist/components/toggle-legend.js.map +1 -1
  115. package/dist/components/tooltip.d.ts +10 -4
  116. package/dist/components/tooltip.js +23 -6
  117. package/dist/components/tooltip.js.map +1 -1
  118. package/dist/components/treemap.d.ts +6 -1
  119. package/dist/components/treemap.js +16 -6
  120. package/dist/components/treemap.js.map +1 -1
  121. package/dist/components/ui.d.ts +36 -8
  122. package/dist/components/ui.js +27 -19
  123. package/dist/components/ui.js.map +1 -1
  124. package/dist/components/use-anchor-dir.d.ts +15 -0
  125. package/dist/components/use-anchor-dir.js +14 -0
  126. package/dist/components/use-anchor-dir.js.map +1 -0
  127. package/dist/components/use-mobile-reveal.d.ts +8 -1
  128. package/dist/components/use-mobile-reveal.js +3 -2
  129. package/dist/components/use-mobile-reveal.js.map +1 -1
  130. package/dist/components/use-table-state.d.ts +4 -4
  131. package/dist/components/use-table-state.js +12 -5
  132. package/dist/components/use-table-state.js.map +1 -1
  133. package/dist/{data-table-filters-DM1auUfH.d.ts → data-table-filters-noy0Abvi.d.ts} +40 -7
  134. package/dist/data-table.d.ts +1 -1
  135. package/dist/data-table.js.map +1 -1
  136. package/dist/feedback/feedback-attachment.d.ts +22 -3
  137. package/dist/feedback/feedback-attachment.js +15 -7
  138. package/dist/feedback/feedback-attachment.js.map +1 -1
  139. package/dist/feedback/feedback-dialog.d.ts +3 -2
  140. package/dist/feedback/feedback-dialog.js.map +1 -1
  141. package/dist/feedback.d.ts +1 -1
  142. package/dist/hooks/use-windowed-rows.d.ts +29 -0
  143. package/dist/hooks/use-windowed-rows.js +31 -0
  144. package/dist/hooks/use-windowed-rows.js.map +1 -0
  145. package/dist/i18n/defaults.d.ts +5 -0
  146. package/dist/i18n/defaults.js +5 -1
  147. package/dist/i18n/defaults.js.map +1 -1
  148. package/dist/i18n/kit-labels.d.ts +7 -0
  149. package/dist/i18n/kit-labels.js.map +1 -1
  150. package/dist/i18n/locales/de-CH.d.ts +45 -0
  151. package/dist/i18n/locales/de-CH.js +21 -0
  152. package/dist/i18n/locales/de-CH.js.map +1 -0
  153. package/dist/i18n/locales/de.d.ts +45 -0
  154. package/dist/i18n/locales/de.js +344 -0
  155. package/dist/i18n/locales/de.js.map +1 -0
  156. package/dist/i18n/locales/es.d.ts +45 -0
  157. package/dist/i18n/locales/es.js +339 -0
  158. package/dist/i18n/locales/es.js.map +1 -0
  159. package/dist/i18n/locales/fr.d.ts +45 -0
  160. package/dist/i18n/locales/fr.js +344 -0
  161. package/dist/i18n/locales/fr.js.map +1 -0
  162. package/dist/i18n/locales/hu.d.ts +45 -0
  163. package/dist/i18n/locales/hu.js +343 -0
  164. package/dist/i18n/locales/hu.js.map +1 -0
  165. package/dist/i18n/locales/it.d.ts +45 -0
  166. package/dist/i18n/locales/it.js +342 -0
  167. package/dist/i18n/locales/it.js.map +1 -0
  168. package/dist/i18n/locales/zh.d.ts +45 -0
  169. package/dist/i18n/locales/zh.js +338 -0
  170. package/dist/i18n/locales/zh.js.map +1 -0
  171. package/dist/index.d.ts +7 -4
  172. package/dist/index.js +4 -1
  173. package/dist/index.js.map +1 -1
  174. package/dist/lib/direction.d.ts +24 -0
  175. package/dist/lib/direction.js +19 -0
  176. package/dist/lib/direction.js.map +1 -0
  177. package/dist/rhf/form.d.ts +2 -0
  178. package/dist/rhf/form.js.map +1 -1
  179. package/dist/rhf.d.ts +2 -0
  180. package/dist/search/command-palette.d.ts +5 -0
  181. package/dist/search/command-palette.js +80 -50
  182. package/dist/search/command-palette.js.map +1 -1
  183. package/dist/shell/app-shell.d.ts +13 -1
  184. package/dist/shell/app-shell.js +134 -85
  185. package/dist/shell/app-shell.js.map +1 -1
  186. package/dist/shell/topbar-action-menu.js +1 -1
  187. package/dist/shell/topbar-action-menu.js.map +1 -1
  188. package/dist/shell/topbar-controls.d.ts +7 -3
  189. package/dist/shell/topbar-controls.js +38 -31
  190. package/dist/shell/topbar-controls.js.map +1 -1
  191. package/dist/theme/chart-palette.d.ts +7 -1
  192. package/dist/theme/chart-palette.js +2 -1
  193. package/dist/theme/chart-palette.js.map +1 -1
  194. package/dist/tour/tour.d.ts +11 -5
  195. package/dist/tour/tour.js +124 -58
  196. package/dist/tour/tour.js.map +1 -1
  197. package/dist/wizard/use-wizard.js +30 -13
  198. package/dist/wizard/use-wizard.js.map +1 -1
  199. package/dist/wizard/wizard-summary.js +1 -1
  200. package/dist/wizard/wizard-summary.js.map +1 -1
  201. package/package.json +12 -2
  202. package/src/components/account-settings.tsx +6 -2
  203. package/src/components/amount-input.tsx +10 -8
  204. package/src/components/autocomplete.tsx +110 -24
  205. package/src/components/calculator.tsx +5 -1
  206. package/src/components/chart-zoom.tsx +8 -1
  207. package/src/components/chart.tsx +68 -7
  208. package/src/components/chip.tsx +131 -49
  209. package/src/components/choice-card.tsx +77 -36
  210. package/src/components/combobox-core.tsx +147 -20
  211. package/src/components/combobox.tsx +100 -44
  212. package/src/components/currency-select.tsx +6 -6
  213. package/src/components/danger-confirm.tsx +90 -36
  214. package/src/components/data-table-pagination.tsx +32 -19
  215. package/src/components/data-table.tsx +198 -36
  216. package/src/components/date-picker.tsx +24 -7
  217. package/src/components/dialog-frame.tsx +97 -22
  218. package/src/components/disclosure.tsx +75 -13
  219. package/src/components/dropdown.tsx +17 -5
  220. package/src/components/entity-combobox.tsx +26 -13
  221. package/src/components/file-button.tsx +74 -5
  222. package/src/components/file-dropzone.tsx +44 -35
  223. package/src/components/grouped-picker.tsx +2 -2
  224. package/src/components/hover-menu.tsx +139 -10
  225. package/src/components/icon-picker.tsx +14 -1
  226. package/src/components/measured-grid.tsx +852 -0
  227. package/src/components/mini-calendar.tsx +11 -8
  228. package/src/components/month-picker.tsx +18 -11
  229. package/src/components/multi-entity-combobox.tsx +2 -2
  230. package/src/components/multi-select.tsx +34 -7
  231. package/src/components/number-input.tsx +4 -4
  232. package/src/components/numpad-sheet.tsx +5 -3
  233. package/src/components/picker-sheet.tsx +1 -1
  234. package/src/components/popover.tsx +18 -3
  235. package/src/components/series-chart-ticks.ts +52 -0
  236. package/src/components/series-chart.tsx +39 -31
  237. package/src/components/swatch-picker.tsx +11 -0
  238. package/src/components/swipeable-row.tsx +21 -6
  239. package/src/components/tile-radio.tsx +5 -7
  240. package/src/components/toggle-group.tsx +39 -3
  241. package/src/components/toggle-legend.tsx +5 -2
  242. package/src/components/tooltip.tsx +40 -12
  243. package/src/components/treemap.tsx +28 -5
  244. package/src/components/ui.tsx +79 -33
  245. package/src/components/use-anchor-dir.ts +20 -0
  246. package/src/components/use-mobile-reveal.ts +10 -2
  247. package/src/components/use-table-state.ts +44 -7
  248. package/src/data-table.ts +1 -0
  249. package/src/feedback/feedback-attachment.tsx +36 -9
  250. package/src/feedback/feedback-dialog.tsx +3 -2
  251. package/src/hooks/use-windowed-rows.ts +69 -0
  252. package/src/i18n/defaults.ts +4 -0
  253. package/src/i18n/kit-labels.tsx +4 -0
  254. package/src/i18n/locales/de-CH.ts +30 -0
  255. package/src/i18n/locales/de.ts +365 -0
  256. package/src/i18n/locales/es.ts +358 -0
  257. package/src/i18n/locales/fr.ts +371 -0
  258. package/src/i18n/locales/hu.ts +359 -0
  259. package/src/i18n/locales/it.ts +360 -0
  260. package/src/i18n/locales/zh.ts +348 -0
  261. package/src/index.ts +10 -1
  262. package/src/lib/direction.ts +37 -0
  263. package/src/rhf/form.tsx +21 -0
  264. package/src/search/command-palette.tsx +77 -19
  265. package/src/shell/app-shell.tsx +201 -130
  266. package/src/shell/topbar-action-menu.tsx +1 -1
  267. package/src/shell/topbar-controls.tsx +19 -8
  268. package/src/theme/chart-palette.ts +9 -2
  269. package/src/tour/tour.tsx +119 -18
  270. package/src/wizard/use-wizard.ts +55 -17
  271. package/src/wizard/wizard-summary.tsx +1 -1
@@ -3,6 +3,7 @@ import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactNode } f
3
3
  import { X } from "lucide-react";
4
4
  import type { LucideIcon } from "lucide-react";
5
5
  import { cn } from "../lib/cn";
6
+ import { horizontalStep } from "../lib/direction";
6
7
  import { FIELD_INVALID } from "./ui";
7
8
  import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
8
9
 
@@ -72,20 +73,48 @@ const TONE: Record<ChipTone, { idle: string; selected: string }> = {
72
73
  },
73
74
  };
74
75
 
75
- const SIZE: Record<ChipSize, { body: string; icon: string; remove: string }> = {
76
- sm: { body: "gap-1 px-2 py-0.5 text-xs", icon: "size-3", remove: "size-3" },
77
- md: { body: "gap-1.5 px-2.5 py-1 text-sm", icon: "size-3.5", remove: "size-3.5" },
78
- lg: { body: "min-h-11 gap-2 px-4 py-2 text-sm", icon: "size-4", remove: "size-4" },
76
+ /**
77
+ * `body` is the whole pill when the chip is ONE element. When a link or a button also has
78
+ * a dismiss control, the pill is a wrapper holding two siblings (see the note on
79
+ * {@link Chip}): `split` is then the interactive part's own padding — the full start
80
+ * inset, a short end one before the ×; `tail` is the wrapper's end inset after the ×.
81
+ */
82
+ const SIZE: Record<
83
+ ChipSize,
84
+ { body: string; split: string; tail: string; icon: string; remove: string }
85
+ > = {
86
+ sm: {
87
+ body: "gap-1 px-2 py-0.5 text-xs",
88
+ split: "gap-1 ps-2 pe-1 py-0.5 text-xs",
89
+ tail: "pe-1.5",
90
+ icon: "size-3",
91
+ remove: "size-3",
92
+ },
93
+ md: {
94
+ body: "gap-1.5 px-2.5 py-1 text-sm",
95
+ split: "gap-1.5 ps-2.5 pe-1.5 py-1 text-sm",
96
+ tail: "pe-2",
97
+ icon: "size-3.5",
98
+ remove: "size-3.5",
99
+ },
100
+ lg: {
101
+ body: "min-h-11 gap-2 px-4 py-2 text-sm",
102
+ split: "min-h-11 gap-2 ps-4 pe-2 py-2 text-sm",
103
+ tail: "min-h-11 pe-3.5",
104
+ icon: "size-4",
105
+ remove: "size-4",
106
+ },
79
107
  };
80
108
 
81
- const CHIP_BASE =
82
- "inline-flex max-w-full items-center rounded-full border transition-colors " +
83
- // `focus-visible`, not `focus`: a chip commonly receives focus programmatically (the
84
- // ChipInput moves focus onto one after a removal) and a ring that appears on a
85
- // pointer click reads as a stuck selection.
109
+ const CHIP_PILL = "inline-flex max-w-full items-center rounded-full border transition-colors";
110
+ // `focus-visible`, not `focus`: a chip commonly receives focus programmatically (the
111
+ // ChipInput moves focus onto one after a removal) and a ring that appears on a
112
+ // pointer click reads as a stuck selection.
113
+ const CHIP_RING =
86
114
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--bg-surface)]";
115
+ const CHIP_BASE = `${CHIP_PILL} ${CHIP_RING}`;
87
116
 
88
- export interface ChipProps {
117
+ interface ChipBaseProps {
89
118
  children: ReactNode;
90
119
  tone?: ChipTone;
91
120
  size?: ChipSize;
@@ -94,16 +123,19 @@ export interface ChipProps {
94
123
  * Marks the chip as the current one — `aria-current` on a link, `aria-pressed` on a
95
124
  * toggle.
96
125
  *
97
- * On a toggle, keep the LABEL the same in both states and let `selected` carry the
98
- * state. A label that flips with it ("Skip this month" / "Ask again this month") is
99
- * announced together with "pressed", and then says the opposite of what it does.
126
+ * On an ON/OFF toggle, keep the LABEL the same in both states and let `selected`
127
+ * carry the state. A label that flips with it ("Skip this month" / "Ask again this
128
+ * month") is announced together with "pressed", and then says the opposite of what
129
+ * it does.
130
+ *
131
+ * A control that switches between two NAMED states (outflow ⇄ inflow) is not a
132
+ * toggle but an ACTION button: leave `selected` out, let the visible label and the
133
+ * tone follow the state ("− Outflow" / "+ Inflow"), and name the action in
134
+ * `aria-label` ("Direction: outflow — tap for inflow"). Without `selected` the chip
135
+ * reports no pressed state at all. A fixed label there contradicts the figure beside
136
+ * it — "+ Outflow" in green next to an inflow (keksdose #417).
100
137
  */
101
138
  selected?: boolean;
102
- /** Renders the chip as a link. Mutually exclusive with `onClick`. */
103
- href?: string;
104
- /** Renders the chip as a toggle button. Mutually exclusive with `href`. Receives the
105
- * click, so a chip inside a clickable row can `stopPropagation()`. */
106
- onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
107
139
  /** Renders a dismiss affordance. Works alongside `href`/`onClick` — see the note below. */
108
140
  onRemove?: () => void;
109
141
  /** Accessible name for the dismiss button. Default: `common.remove` from the
@@ -132,12 +164,34 @@ export interface ChipProps {
132
164
  title?: string;
133
165
  }
134
166
 
167
+ /**
168
+ * A chip is a link OR a button, never both — so the types say so. Before 0.7.0 both were
169
+ * accepted and `onClick` was silently dropped whenever `href` was set; a link that must
170
+ * also run code wants a router `Link`-style `onClick` on the anchor, which is a different
171
+ * component's job.
172
+ */
173
+ export type ChipProps = ChipBaseProps &
174
+ (
175
+ | {
176
+ /** Renders the chip as a link. Mutually exclusive with `onClick`. */
177
+ href?: string;
178
+ onClick?: never;
179
+ }
180
+ | {
181
+ href?: never;
182
+ /** Renders the chip as a toggle button. Mutually exclusive with `href`. Receives
183
+ * the click, so a chip inside a clickable row can `stopPropagation()`. */
184
+ onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
185
+ }
186
+ );
187
+
135
188
  /**
136
189
  * Note on `onRemove` with `href`/`onClick`: a button cannot be nested inside a button or
137
190
  * a link, so when both are present the chip renders a wrapper holding TWO siblings — the
138
- * interactive body and the dismiss control. That is why the outer element is not always
139
- * the interactive one, and why the focus ring is drawn on the body rather than the
140
- * wrapper.
191
+ * interactive body and the dismiss control. The WRAPPER is the visual pill (border, tone,
192
+ * `className`) and the two sit inside it, so the × reads as part of the chip; the body's
193
+ * focus ring is drawn on the body alone, and `ref`, `id` and the ARIA props still reach
194
+ * the body — the interactive element.
141
195
  */
142
196
  export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
143
197
  {
@@ -145,7 +199,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
145
199
  tone = "neutral",
146
200
  size = "md",
147
201
  icon: Icon,
148
- selected = false,
202
+ selected,
149
203
  href,
150
204
  onClick,
151
205
  onRemove,
@@ -164,12 +218,15 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
164
218
  const s = SIZE[size];
165
219
  const palette = TONE[tone];
166
220
  const interactive = !!href || !!onClick;
221
+ const surface = cn(
222
+ selected ? palette.selected : palette.idle,
223
+ disabled && "cursor-default opacity-50",
224
+ );
167
225
  const look = cn(
168
226
  CHIP_BASE,
169
227
  s.body,
170
- selected ? palette.selected : palette.idle,
228
+ surface,
171
229
  interactive && !disabled && "cursor-pointer hover:brightness-[0.97] dark:hover:brightness-110",
172
- disabled && "cursor-default opacity-50",
173
230
  className,
174
231
  );
175
232
 
@@ -202,7 +259,8 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
202
259
  }}
203
260
  disabled={disabled}
204
261
  className={cn(
205
- "-mr-0.5 ml-0.5 shrink-0 rounded-full p-0.5 transition-colors",
262
+ // Logical margins: the × sits at the END of the pill, which is the left in RTL.
263
+ "-me-0.5 ms-0.5 shrink-0 rounded-full p-0.5 transition-colors",
206
264
  "hover:bg-[var(--bg-active)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
207
265
  disabled && "pointer-events-none",
208
266
  )}
@@ -211,6 +269,23 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
211
269
  </button>
212
270
  ) : null;
213
271
 
272
+ // ── The combined shape: one pill, two interactive siblings inside it.
273
+ const pill = (inner: ReactNode) => (
274
+ <span className={cn(CHIP_PILL, s.tail, surface, className)}>
275
+ {inner}
276
+ {remove}
277
+ </span>
278
+ );
279
+ // The body inside a pill: no border or surface of its own (the pill has them), a
280
+ // hover wash in the text's own hue so it works on every tone.
281
+ const inner = cn(
282
+ "inline-flex min-w-0 items-center self-stretch rounded-full transition-colors",
283
+ CHIP_RING,
284
+ s.split,
285
+ !disabled && "cursor-pointer hover:bg-current/10",
286
+ disabled && "cursor-default",
287
+ );
288
+
214
289
  // ── The three shapes.
215
290
  if (href && !disabled) {
216
291
  const link = (
@@ -218,21 +293,13 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
218
293
  ref={ref as React.Ref<HTMLAnchorElement>}
219
294
  href={href}
220
295
  aria-current={selected ? "true" : undefined}
221
- className={cn(look, remove && "pr-1.5")}
296
+ className={remove ? inner : look}
222
297
  {...rest}
223
298
  >
224
299
  {body}
225
- {!remove && null}
226
300
  </a>
227
301
  );
228
- return remove ? (
229
- <span className="inline-flex items-center">
230
- {link}
231
- {remove}
232
- </span>
233
- ) : (
234
- link
235
- );
302
+ return remove ? pill(link) : link;
236
303
  }
237
304
 
238
305
  if (onClick) {
@@ -242,21 +309,17 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
242
309
  type="button"
243
310
  onClick={onClick}
244
311
  disabled={disabled}
312
+ // Only when `selected` is PASSED: a chip with `onClick` and no `selected` is an
313
+ // action button (it does something), not a toggle (it is on or off), and
314
+ // announcing it "not pressed" would claim a state it does not have.
245
315
  aria-pressed={selected}
246
- className={cn(look, remove && "pr-1.5")}
316
+ className={remove ? inner : look}
247
317
  {...rest}
248
318
  >
249
319
  {body}
250
320
  </button>
251
321
  );
252
- return remove ? (
253
- <span className="inline-flex items-center">
254
- {button}
255
- {remove}
256
- </span>
257
- ) : (
258
- button
259
- );
322
+ return remove ? pill(button) : button;
260
323
  }
261
324
 
262
325
  return (
@@ -344,7 +407,7 @@ export interface ChipInputProps
344
407
  *
345
408
  * Enter, or any separator commit the typed text
346
409
  * Backspace on empty text move focus to the last chip (does NOT delete it)
347
- * ← → while on a chip move between chips
410
+ * ← → while on a chip move between chips, in reading order (mirrored in RTL)
348
411
  * Backspace / Delete remove the focused chip, focus its neighbour
349
412
  * Escape clear what is typed but keep the committed chips
350
413
  * paste splits on the separators, so a pasted CSV becomes chips
@@ -384,13 +447,21 @@ export function ChipInput({
384
447
  const locale = useKitLocale();
385
448
  const [draft, setDraft] = useState("");
386
449
  const [message, setMessage] = useState("");
450
+ // The last refusal, shown under the field until the next edit. The live region alone
451
+ // told only a screen-reader user why Enter did nothing; everyone else saw the text
452
+ // simply stay put.
453
+ const [rejected, setRejected] = useState<string | null>(null);
387
454
  const [focusedChip, setFocusedChip] = useState<number | null>(null);
388
455
  const inputRef = useRef<HTMLInputElement>(null);
389
456
  const chipRefs = useRef<Array<HTMLElement | null>>([]);
390
457
  const id = useId();
391
458
  const listId = `${id}-list`;
392
459
  const errorId = `${id}-error`;
460
+ const rejectedId = `${id}-rejected`;
393
461
  const isInvalid = invalid || !!error;
462
+ const describedBy = [error ? errorId : null, rejected ? rejectedId : null]
463
+ .filter(Boolean)
464
+ .join(" ");
394
465
 
395
466
  const focusChip = (index: number | null) => {
396
467
  setFocusedChip(index);
@@ -437,6 +508,7 @@ export function ChipInput({
437
508
  // chips appearing, whereas "already in the list" is the only sign of what did not.
438
509
  const addedList = new Intl.ListFormat(locale, { type: "conjunction", style: "long" });
439
510
  setMessage(rejection ?? (added.length ? text.added(addedList.format(added)) : ""));
511
+ setRejected(rejection);
440
512
  return { added, rejection };
441
513
  };
442
514
 
@@ -446,6 +518,7 @@ export function ChipInput({
446
518
  const removed = value[index];
447
519
  onChange(value.filter((_, i) => i !== index));
448
520
  setMessage(text.removed(removed));
521
+ setRejected(null);
449
522
  // Focus the neighbour that takes its place, or the input when the list empties —
450
523
  // dropping focus to <body> here is how a keyboard user loses the field entirely.
451
524
  const next = index >= value.length - 1 ? null : index;
@@ -483,12 +556,15 @@ export function ChipInput({
483
556
  removeAt(index);
484
557
  return;
485
558
  }
486
- if (e.key === "ArrowLeft" && index > 0) {
559
+ // ←/→ follow the reading direction, not DOM order: in RTL the previous chip is to
560
+ // the RIGHT, and ArrowLeft moves on towards the input.
561
+ const step = horizontalStep(e.key, e.currentTarget);
562
+ if (step === -1 && index > 0) {
487
563
  e.preventDefault();
488
564
  focusChip(index - 1);
489
565
  return;
490
566
  }
491
- if (e.key === "ArrowRight") {
567
+ if (step === 1) {
492
568
  e.preventDefault();
493
569
  focusChip(index === value.length - 1 ? null : index + 1);
494
570
  return;
@@ -564,6 +640,7 @@ export function ChipInput({
564
640
  onChange={(e) => {
565
641
  setDraft(e.target.value);
566
642
  setMessage("");
643
+ setRejected(null);
567
644
  }}
568
645
  onKeyDown={onInputKeyDown}
569
646
  onBlur={() => {
@@ -585,8 +662,8 @@ export function ChipInput({
585
662
  // here would give the field two. Failing that, the DOM spelling wins and
586
663
  // the deprecated `ariaLabel` is the fallback.
587
664
  aria-label={label ? undefined : (ariaLabelAttr ?? ariaLabel)}
588
- aria-describedby={error ? errorId : undefined}
589
- aria-invalid={isInvalid || undefined}
665
+ aria-describedby={describedBy || undefined}
666
+ aria-invalid={isInvalid || !!rejected || undefined}
590
667
  className="min-w-[6rem] flex-1 bg-transparent py-0.5 text-sm text-[var(--text-primary)] outline-none placeholder:text-[var(--text-placeholder)] disabled:cursor-default"
591
668
  />
592
669
  {/* Additions, removals and rejections move no focus, so nothing would announce
@@ -601,6 +678,11 @@ export function ChipInput({
601
678
  {error}
602
679
  </p>
603
680
  )}
681
+ {rejected && (
682
+ <p id={rejectedId} className="text-xs text-[var(--danger)]" data-chip-input-rejected="">
683
+ {rejected}
684
+ </p>
685
+ )}
604
686
  </div>
605
687
  );
606
688
  }
@@ -1,4 +1,4 @@
1
- import { forwardRef, useCallback, useEffect, useId, useRef } from "react";
1
+ import { createContext, forwardRef, useCallback, useContext, useEffect, useId, useRef } from "react";
2
2
  import type { ChangeEvent, ComponentType, InputHTMLAttributes, ReactNode } from "react";
3
3
  import { Check, Minus } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
@@ -48,6 +48,29 @@ export interface ChoiceCardProps extends Omit<InputHTMLAttributes<HTMLInputEleme
48
48
  onCheckedChange?: (checked: boolean) => void;
49
49
  /** Classes for the `<input>`. `className` styles the card. */
50
50
  inputClassName?: string;
51
+ /**
52
+ * Must be ticked to submit. Reaches the `<input>` as the native `required` (a
53
+ * `<form>` refuses to submit without it, a screen reader announces "required"),
54
+ * and draws the kit's `aria-hidden` star after the title — {@link Checkbox}'s
55
+ * contract, down to writing the title WITHOUT a literal "*". Inside a
56
+ * {@link ChoiceCardGroup} the star goes on the legend instead, once.
57
+ */
58
+ required?: boolean;
59
+ }
60
+
61
+ /** Set by {@link ChoiceCardGroup}: its legend carries the one required mark, so the
62
+ * cards inside it draw none of their own. Private — not a prop a caller could set
63
+ * by accident on a lone card. */
64
+ const InGroupContext = createContext(false);
65
+
66
+ /** The kit's required mark — {@link Label}'s: `aria-hidden`, because the word is
67
+ * announced from the control and a star read out in the name is only noise. */
68
+ function RequiredMark() {
69
+ return (
70
+ <span aria-hidden className="ms-0.5 text-[var(--danger)]">
71
+ *
72
+ </span>
73
+ );
51
74
  }
52
75
 
53
76
  // The card. `has-[:checked]` and `has-[:focus-visible]` read the INPUT's state, so an
@@ -93,10 +116,12 @@ export const ChoiceCard = forwardRef<HTMLInputElement, ChoiceCardProps>(function
93
116
  inputClassName,
94
117
  id,
95
118
  disabled,
119
+ required,
96
120
  ...rest
97
121
  },
98
122
  ref,
99
123
  ) {
124
+ const inGroup = useContext(InGroupContext);
100
125
  const generated = useId();
101
126
  const inputId = id ?? generated;
102
127
  const titleId = `${inputId}-title`;
@@ -131,6 +156,7 @@ export const ChoiceCard = forwardRef<HTMLInputElement, ChoiceCardProps>(function
131
156
  ref={setRef}
132
157
  id={inputId}
133
158
  disabled={disabled}
159
+ required={required}
134
160
  {...rest}
135
161
  type={type}
136
162
  aria-labelledby={rest["aria-labelledby"] ?? titleId}
@@ -174,6 +200,7 @@ export const ChoiceCard = forwardRef<HTMLInputElement, ChoiceCardProps>(function
174
200
  className="block select-none text-sm font-medium leading-5 text-[var(--text-primary)]"
175
201
  >
176
202
  {title}
203
+ {required && !inGroup && <RequiredMark />}
177
204
  </span>
178
205
  {showDescription && (
179
206
  <span id={descriptionId} className="mt-0.5 block text-xs text-[var(--text-muted)]">
@@ -217,7 +244,16 @@ interface ChoiceCardGroupBaseProps<T extends string> {
217
244
  /** What is wrong with the answer as a whole. Rendered under the cards and attached
218
245
  * to the fieldset. */
219
246
  error?: ReactNode;
220
- /** Radio mode only: marks the first input `required`, which is how a radio set is. */
247
+ /**
248
+ * An answer is needed. Draws the required star after the `legend` (none without
249
+ * one — the name then comes from `aria-label`, which has nowhere to draw it), and
250
+ * reaches the inputs as the native `required`:
251
+ * - radios: every radio of the set, which is how HTML marks a radio group — the
252
+ * group is satisfied by any one of them, and each announces "required";
253
+ * - checkboxes: every box while NONE is ticked, dropped from all of them once one
254
+ * is — "at least one", which checkboxes cannot say natively. A form then refuses
255
+ * to submit an empty set, and nothing is left demanding a second box.
256
+ */
221
257
  required?: boolean;
222
258
  }
223
259
 
@@ -267,39 +303,44 @@ export function ChoiceCardGroup<T extends string>(props: ChoiceCardGroupProps<T>
267
303
  };
268
304
 
269
305
  return (
270
- <fieldset
271
- aria-label={props["aria-label"]}
272
- aria-describedby={showError ? errorId : undefined}
273
- disabled={disabled}
274
- className="min-w-0"
275
- >
276
- {legend !== undefined && (
277
- <legend className="mb-2 text-sm font-medium text-[var(--text-primary)]">{legend}</legend>
278
- )}
279
- <div className={cn("grid gap-2 sm:grid-cols-2", className)}>
280
- {options.map((o, i) => (
281
- <ChoiceCard
282
- key={o.value}
283
- type={props.multiple ? "checkbox" : "radio"}
284
- name={groupName}
285
- value={o.value}
286
- title={o.title}
287
- description={o.description}
288
- icon={o.icon}
289
- disabled={o.disabled}
290
- invalid={showError}
291
- required={!props.multiple && required && i === 0 ? true : undefined}
292
- checked={isChecked(o.value)}
293
- onCheckedChange={(on) => toggle(o.value, on)}
294
- className={cardClassName}
295
- />
296
- ))}
297
- </div>
298
- {showError && (
299
- <p id={errorId} className="mt-1 text-[11px] leading-tight text-[var(--danger)]">
300
- {error}
301
- </p>
302
- )}
303
- </fieldset>
306
+ <InGroupContext.Provider value={true}>
307
+ <fieldset
308
+ aria-label={props["aria-label"]}
309
+ aria-describedby={showError ? errorId : undefined}
310
+ disabled={disabled}
311
+ className="min-w-0"
312
+ >
313
+ {legend !== undefined && (
314
+ <legend className="mb-2 text-sm font-medium text-[var(--text-primary)]">
315
+ {legend}
316
+ {required && <RequiredMark />}
317
+ </legend>
318
+ )}
319
+ <div className={cn("grid gap-2 sm:grid-cols-2", className)}>
320
+ {options.map((o) => (
321
+ <ChoiceCard
322
+ key={o.value}
323
+ type={props.multiple ? "checkbox" : "radio"}
324
+ name={groupName}
325
+ value={o.value}
326
+ title={o.title}
327
+ description={o.description}
328
+ icon={o.icon}
329
+ disabled={o.disabled}
330
+ invalid={showError}
331
+ required={required && (!props.multiple || props.value.length === 0) ? true : undefined}
332
+ checked={isChecked(o.value)}
333
+ onCheckedChange={(on) => toggle(o.value, on)}
334
+ className={cardClassName}
335
+ />
336
+ ))}
337
+ </div>
338
+ {showError && (
339
+ <p id={errorId} className="mt-1 text-[11px] leading-tight text-[var(--danger)]">
340
+ {error}
341
+ </p>
342
+ )}
343
+ </fieldset>
344
+ </InGroupContext.Provider>
304
345
  );
305
346
  }