@chassis-ui/react 0.0.0-20260831090525

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 (275) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/dist/index.d.ts +5909 -0
  4. package/dist/index.js +2 -0
  5. package/dist/style.css +1 -0
  6. package/package.json +114 -0
  7. package/src/components/accordion/Accordion.tsx +190 -0
  8. package/src/components/accordion/AccordionBody.tsx +46 -0
  9. package/src/components/accordion/AccordionHeader.tsx +21 -0
  10. package/src/components/accordion/AccordionItem.tsx +59 -0
  11. package/src/components/accordion/context.ts +8 -0
  12. package/src/components/accordion/index.ts +8 -0
  13. package/src/components/autocomplete/Autocomplete.tsx +464 -0
  14. package/src/components/autocomplete/AutocompleteGroup.tsx +20 -0
  15. package/src/components/autocomplete/AutocompleteItem.tsx +18 -0
  16. package/src/components/autocomplete/index.ts +6 -0
  17. package/src/components/avatar/Avatar.tsx +134 -0
  18. package/src/components/avatar/AvatarImage.tsx +19 -0
  19. package/src/components/avatar/AvatarStack.tsx +98 -0
  20. package/src/components/avatar/index.ts +6 -0
  21. package/src/components/badge/Badge.tsx +94 -0
  22. package/src/components/badge/index.ts +2 -0
  23. package/src/components/breadcrumb/Breadcrumb.tsx +59 -0
  24. package/src/components/breadcrumb/BreadcrumbItem.tsx +38 -0
  25. package/src/components/breadcrumb/index.ts +4 -0
  26. package/src/components/button/Button.tsx +229 -0
  27. package/src/components/button/index.ts +2 -0
  28. package/src/components/button-group/ButtonGroup.tsx +55 -0
  29. package/src/components/button-group/ButtonToolbar.tsx +47 -0
  30. package/src/components/button-group/index.ts +4 -0
  31. package/src/components/calendar/Calendar.scss +57 -0
  32. package/src/components/calendar/Calendar.tsx +324 -0
  33. package/src/components/calendar/CalendarMonthBlock.tsx +57 -0
  34. package/src/components/calendar/CalendarMonthGrid.tsx +92 -0
  35. package/src/components/calendar/CalendarMonthYearPicker.tsx +162 -0
  36. package/src/components/calendar/CalendarNavButton.tsx +24 -0
  37. package/src/components/calendar/CalendarWeekGrid.tsx +58 -0
  38. package/src/components/calendar/CalendarYearGrid.tsx +126 -0
  39. package/src/components/calendar/RangeCalendar.scss +95 -0
  40. package/src/components/calendar/RangeCalendar.tsx +361 -0
  41. package/src/components/calendar/index.ts +5 -0
  42. package/src/components/card/Card.tsx +173 -0
  43. package/src/components/card/CardBody.tsx +75 -0
  44. package/src/components/card/CardFooter.tsx +47 -0
  45. package/src/components/card/CardGroup.tsx +51 -0
  46. package/src/components/card/CardHeader.tsx +47 -0
  47. package/src/components/card/CardImage.tsx +70 -0
  48. package/src/components/card/CardImageOverlay.tsx +47 -0
  49. package/src/components/card/CardLink.tsx +40 -0
  50. package/src/components/card/CardSubtitle.tsx +47 -0
  51. package/src/components/card/CardText.tsx +47 -0
  52. package/src/components/card/CardTitle.tsx +47 -0
  53. package/src/components/card/index.ts +22 -0
  54. package/src/components/carousel/Carousel.tsx +742 -0
  55. package/src/components/carousel/CarouselControlButton.tsx +74 -0
  56. package/src/components/carousel/CarouselControlNext.tsx +22 -0
  57. package/src/components/carousel/CarouselControlPrev.tsx +22 -0
  58. package/src/components/carousel/CarouselIndicators.tsx +44 -0
  59. package/src/components/carousel/CarouselInner.tsx +56 -0
  60. package/src/components/carousel/CarouselItem.tsx +51 -0
  61. package/src/components/carousel/CarouselOverlay.tsx +51 -0
  62. package/src/components/carousel/CarouselPlayPause.tsx +61 -0
  63. package/src/components/carousel/carouselEngine.ts +170 -0
  64. package/src/components/carousel/context.ts +29 -0
  65. package/src/components/carousel/index.ts +21 -0
  66. package/src/components/checkbox/Checkbox.tsx +236 -0
  67. package/src/components/checkbox/CheckboxGroup.tsx +145 -0
  68. package/src/components/checkbox/context.ts +14 -0
  69. package/src/components/checkbox/index.ts +4 -0
  70. package/src/components/chip/Chip.tsx +183 -0
  71. package/src/components/chip/index.ts +2 -0
  72. package/src/components/chip-input/ChipInput.tsx +363 -0
  73. package/src/components/chip-input/ChipList.tsx +95 -0
  74. package/src/components/chip-input/index.ts +2 -0
  75. package/src/components/close-button/CloseButton.tsx +205 -0
  76. package/src/components/close-button/index.ts +2 -0
  77. package/src/components/collapse/Collapse.tsx +128 -0
  78. package/src/components/collapse/index.ts +2 -0
  79. package/src/components/color-input/ColorInput.tsx +106 -0
  80. package/src/components/color-input/index.ts +2 -0
  81. package/src/components/combobox/Combobox.tsx +320 -0
  82. package/src/components/combobox/ComboboxGroup.tsx +27 -0
  83. package/src/components/combobox/ComboboxItem.tsx +44 -0
  84. package/src/components/combobox/ComboboxListBox.tsx +98 -0
  85. package/src/components/combobox/index.ts +6 -0
  86. package/src/components/datepicker/CalendarToggleButton.tsx +58 -0
  87. package/src/components/datepicker/ClearButton.tsx +35 -0
  88. package/src/components/datepicker/DateField.tsx +54 -0
  89. package/src/components/datepicker/DatePicker.scss +51 -0
  90. package/src/components/datepicker/DatePicker.tsx +574 -0
  91. package/src/components/datepicker/DateRangePicker.scss +11 -0
  92. package/src/components/datepicker/DateRangePicker.tsx +343 -0
  93. package/src/components/datepicker/index.ts +4 -0
  94. package/src/components/datepicker/renderDatePickerShell.tsx +104 -0
  95. package/src/components/drawer/Drawer.tsx +211 -0
  96. package/src/components/drawer/DrawerBody.tsx +47 -0
  97. package/src/components/drawer/DrawerFooter.tsx +51 -0
  98. package/src/components/drawer/DrawerHeader.tsx +60 -0
  99. package/src/components/drawer/DrawerTitle.tsx +49 -0
  100. package/src/components/drawer/index.ts +10 -0
  101. package/src/components/file-input/FileInput.tsx +100 -0
  102. package/src/components/file-input/index.ts +2 -0
  103. package/src/components/flex/Flex.tsx +134 -0
  104. package/src/components/flex/index.ts +2 -0
  105. package/src/components/floating-input/FloatingInput.tsx +94 -0
  106. package/src/components/floating-input/index.ts +2 -0
  107. package/src/components/form/Form.tsx +26 -0
  108. package/src/components/form/FormFeedback.tsx +64 -0
  109. package/src/components/form/FormHelp.tsx +46 -0
  110. package/src/components/form/FormLabel.tsx +26 -0
  111. package/src/components/form/index.ts +8 -0
  112. package/src/components/form/renderFormCheck.tsx +86 -0
  113. package/src/components/form-field/FormField.tsx +84 -0
  114. package/src/components/form-field/index.ts +2 -0
  115. package/src/components/form-field/renderFormField.tsx +74 -0
  116. package/src/components/grid/Col.tsx +69 -0
  117. package/src/components/grid/Container.tsx +59 -0
  118. package/src/components/grid/Grid.tsx +86 -0
  119. package/src/components/grid/GridItem.tsx +123 -0
  120. package/src/components/grid/Row.tsx +71 -0
  121. package/src/components/grid/gap.ts +8 -0
  122. package/src/components/grid/index.ts +10 -0
  123. package/src/components/icon/Icon.tsx +74 -0
  124. package/src/components/icon/index.ts +2 -0
  125. package/src/components/input-adorn/InputAdorn.tsx +55 -0
  126. package/src/components/input-adorn/index.ts +2 -0
  127. package/src/components/input-group/InputGroup.tsx +50 -0
  128. package/src/components/input-group/InputGroupAddon.tsx +50 -0
  129. package/src/components/input-group/index.ts +4 -0
  130. package/src/components/link/Link.tsx +148 -0
  131. package/src/components/link/index.ts +2 -0
  132. package/src/components/list/List.tsx +193 -0
  133. package/src/components/list/ListItem.tsx +91 -0
  134. package/src/components/list/index.ts +4 -0
  135. package/src/components/menu/Menu.tsx +398 -0
  136. package/src/components/menu/MenuDivider.tsx +19 -0
  137. package/src/components/menu/MenuHeader.tsx +55 -0
  138. package/src/components/menu/MenuItem.tsx +85 -0
  139. package/src/components/menu/MenuItemDef.ts +83 -0
  140. package/src/components/menu/MenuList.tsx +142 -0
  141. package/src/components/menu/MenuSubmenu.tsx +284 -0
  142. package/src/components/menu/MenuSubmenuBack.tsx +39 -0
  143. package/src/components/menu/MenuText.tsx +47 -0
  144. package/src/components/menu/MenuToggle.tsx +148 -0
  145. package/src/components/menu/index.ts +19 -0
  146. package/src/components/menu/menuNavigation.ts +106 -0
  147. package/src/components/menu/submenuGroup.ts +43 -0
  148. package/src/components/modal/Modal.tsx +162 -0
  149. package/src/components/modal/ModalBody.tsx +47 -0
  150. package/src/components/modal/ModalFooter.tsx +51 -0
  151. package/src/components/modal/ModalHeader.tsx +60 -0
  152. package/src/components/modal/ModalTitle.tsx +49 -0
  153. package/src/components/modal/index.ts +10 -0
  154. package/src/components/nav/Nav.tsx +97 -0
  155. package/src/components/nav/NavItem.tsx +42 -0
  156. package/src/components/nav/NavLink.tsx +48 -0
  157. package/src/components/nav/NavTitle.tsx +22 -0
  158. package/src/components/nav/index.ts +7 -0
  159. package/src/components/navbar/Navbar.tsx +112 -0
  160. package/src/components/navbar/NavbarBrand.tsx +55 -0
  161. package/src/components/navbar/NavbarNav.tsx +47 -0
  162. package/src/components/navbar/NavbarText.tsx +47 -0
  163. package/src/components/navbar/NavbarToggler.tsx +34 -0
  164. package/src/components/navbar/index.ts +10 -0
  165. package/src/components/notification/Notification.scss +15 -0
  166. package/src/components/notification/Notification.tsx +187 -0
  167. package/src/components/notification/NotificationIcon.tsx +21 -0
  168. package/src/components/notification/NotificationStack.tsx +66 -0
  169. package/src/components/notification/NotificationText.tsx +47 -0
  170. package/src/components/notification/NotificationTitle.tsx +47 -0
  171. package/src/components/notification/context.ts +14 -0
  172. package/src/components/notification/index.ts +17 -0
  173. package/src/components/notification/notificationQueue.ts +48 -0
  174. package/src/components/otp-input/OtpBox.tsx +68 -0
  175. package/src/components/otp-input/OtpInput.tsx +341 -0
  176. package/src/components/otp-input/index.ts +2 -0
  177. package/src/components/pagination/Pagination.tsx +199 -0
  178. package/src/components/pagination/PaginationItem.tsx +133 -0
  179. package/src/components/pagination/index.ts +4 -0
  180. package/src/components/password-strength/PasswordStrength.tsx +155 -0
  181. package/src/components/password-strength/index.ts +2 -0
  182. package/src/components/password-strength/strengthScore.ts +79 -0
  183. package/src/components/placeholder/Placeholder.tsx +178 -0
  184. package/src/components/placeholder/index.ts +2 -0
  185. package/src/components/popover/Popover.tsx +243 -0
  186. package/src/components/popover/index.ts +2 -0
  187. package/src/components/progress/Progress.tsx +154 -0
  188. package/src/components/progress/ProgressBar.tsx +88 -0
  189. package/src/components/progress/index.ts +4 -0
  190. package/src/components/radio/Radio.tsx +88 -0
  191. package/src/components/radio/RadioGroup.tsx +142 -0
  192. package/src/components/radio/context.ts +14 -0
  193. package/src/components/radio/index.ts +4 -0
  194. package/src/components/range-input/RangeInput.tsx +109 -0
  195. package/src/components/range-input/index.ts +2 -0
  196. package/src/components/select/Select.tsx +277 -0
  197. package/src/components/select/index.ts +2 -0
  198. package/src/components/skeleton/Skeleton.tsx +89 -0
  199. package/src/components/skeleton/SkeletonLoader.tsx +91 -0
  200. package/src/components/skeleton/index.ts +4 -0
  201. package/src/components/spinner/Spinner.tsx +77 -0
  202. package/src/components/spinner/index.ts +2 -0
  203. package/src/components/stack/Stack.tsx +81 -0
  204. package/src/components/stack/index.ts +2 -0
  205. package/src/components/stepper/Stepper.tsx +172 -0
  206. package/src/components/stepper/StepperItem.tsx +66 -0
  207. package/src/components/stepper/index.ts +4 -0
  208. package/src/components/switch/Switch.tsx +246 -0
  209. package/src/components/switch/index.ts +2 -0
  210. package/src/components/table/Table.css +39 -0
  211. package/src/components/table/Table.tsx +390 -0
  212. package/src/components/table/TableBody.tsx +25 -0
  213. package/src/components/table/TableCell.tsx +27 -0
  214. package/src/components/table/TableColumn.tsx +31 -0
  215. package/src/components/table/TableHeader.tsx +26 -0
  216. package/src/components/table/TableRow.tsx +24 -0
  217. package/src/components/table/index.ts +12 -0
  218. package/src/components/tabs/Tab.tsx +36 -0
  219. package/src/components/tabs/TabList.tsx +76 -0
  220. package/src/components/tabs/TabPanel.tsx +68 -0
  221. package/src/components/tabs/Tabs.tsx +136 -0
  222. package/src/components/tabs/context.ts +24 -0
  223. package/src/components/tabs/index.ts +8 -0
  224. package/src/components/text-input/TextInput.tsx +188 -0
  225. package/src/components/text-input/index.ts +2 -0
  226. package/src/components/textarea/Textarea.tsx +145 -0
  227. package/src/components/textarea/index.ts +2 -0
  228. package/src/components/toast/Toast.tsx +219 -0
  229. package/src/components/toast/ToastBody.tsx +64 -0
  230. package/src/components/toast/ToastFooter.tsx +47 -0
  231. package/src/components/toast/ToastHeader.tsx +104 -0
  232. package/src/components/toast/ToastIcon.tsx +21 -0
  233. package/src/components/toast/Toaster.tsx +87 -0
  234. package/src/components/toast/context.ts +14 -0
  235. package/src/components/toast/index.ts +18 -0
  236. package/src/components/toast/toastQueue.ts +56 -0
  237. package/src/components/tooltip/Tooltip.tsx +155 -0
  238. package/src/components/tooltip/index.ts +2 -0
  239. package/src/global.d.ts +2 -0
  240. package/src/hooks/index.ts +57 -0
  241. package/src/hooks/useAutoDismiss.ts +88 -0
  242. package/src/hooks/useButtonSemantics.ts +33 -0
  243. package/src/hooks/useControllableState.ts +24 -0
  244. package/src/hooks/useDialogElement.ts +216 -0
  245. package/src/hooks/useDisabledAnchorGuard.ts +22 -0
  246. package/src/hooks/useDismissibleTransition.ts +126 -0
  247. package/src/hooks/useDrawer.ts +9 -0
  248. package/src/hooks/useFloatingOverlay.ts +115 -0
  249. package/src/hooks/useForkedRef.ts +50 -0
  250. package/src/hooks/useFormField.ts +89 -0
  251. package/src/hooks/useIsomorphicLayoutEffect.ts +9 -0
  252. package/src/hooks/useModal.ts +9 -0
  253. package/src/hooks/useNotification.ts +10 -0
  254. package/src/hooks/useOverlayPlacement.ts +93 -0
  255. package/src/hooks/usePagination.ts +85 -0
  256. package/src/hooks/useToast.ts +9 -0
  257. package/src/hooks/useToastRegionQueue.ts +27 -0
  258. package/src/index.ts +276 -0
  259. package/src/types.ts +133 -0
  260. package/src/utils/breakpoints.ts +43 -0
  261. package/src/utils/comboboxCollection.tsx +152 -0
  262. package/src/utils/dateRangePresets.ts +6 -0
  263. package/src/utils/dialogTransition.ts +39 -0
  264. package/src/utils/focusRedirect.ts +29 -0
  265. package/src/utils/isWholeUnitDisabled.ts +17 -0
  266. package/src/utils/mergeIsDateUnavailable.ts +15 -0
  267. package/src/utils/overlayPlacement.ts +93 -0
  268. package/src/utils/pointerInteraction.ts +21 -0
  269. package/src/utils/polymorphic.ts +36 -0
  270. package/src/utils/renderMenuItemContent.tsx +62 -0
  271. package/src/utils/setVisibleRangeStart.ts +64 -0
  272. package/src/utils/spacingClassName.ts +14 -0
  273. package/src/utils/suppressFocusRingGlobally.ts +69 -0
  274. package/src/utils/validationClassName.ts +8 -0
  275. package/src/utils/virtualFocusStyle.ts +16 -0
@@ -0,0 +1,363 @@
1
+ import React, {
2
+ forwardRef,
3
+ HTMLAttributes,
4
+ KeyboardEvent,
5
+ ReactNode,
6
+ useMemo,
7
+ useRef,
8
+ useState
9
+ } from 'react'
10
+ import classNames from 'classnames'
11
+ import { useTextField } from 'react-aria'
12
+ import { Item, Key, useListState } from 'react-stately'
13
+
14
+ import { useControllableState, useForkedRef, useFormField } from '../../hooks'
15
+ import { validationClassName } from '../../utils/validationClassName'
16
+ import { renderFormField } from '../form-field/renderFormField'
17
+ import { ChipList, ChipItem } from './ChipList'
18
+
19
+ // `allowDuplicates` means two tags can share a value, but react-stately's collection needs a
20
+ // unique key per item — keying on the value alone collapses duplicates into the same node, so
21
+ // selecting or removing one affects all of them. Key on (value, occurrence-within-the-array)
22
+ // instead, recomputed fresh from `tags` on every call rather than cached, so it stays correct
23
+ // whether the array changed via our own add/remove or via an externally-controlled `value` prop.
24
+ const buildTagIds = (list: string[]): string[] => {
25
+ const seen = new Map<string, number>()
26
+ return list.map((tag) => {
27
+ const occurrence = seen.get(tag) ?? 0
28
+ seen.set(tag, occurrence + 1)
29
+ return occurrence === 0 ? tag : `${tag}\u0000${occurrence}`
30
+ })
31
+ }
32
+
33
+ export interface ChipInputProps extends Omit<
34
+ HTMLAttributes<HTMLDivElement>,
35
+ 'onChange' | 'defaultValue'
36
+ > {
37
+ /**
38
+ * An accessible label for the chip group, used when there's no visible `<label>`.
39
+ */
40
+ 'aria-label'?: string
41
+ /**
42
+ * Identifies a visible `<label>` element for the chip group.
43
+ */
44
+ 'aria-labelledby'?: string
45
+ /**
46
+ * Allow the same value to be added more than once. Defaults to `false`.
47
+ */
48
+ allowDuplicates?: boolean
49
+ /**
50
+ * Space-separated chassis-css chip modifier classes (e.g. `"primary smooth"`) applied to every
51
+ * chip. Defaults to `"default"`.
52
+ */
53
+ chipVariant?: string
54
+ /**
55
+ * A string of all className you want applied to the base component.
56
+ */
57
+ className?: string
58
+ /**
59
+ * The initial set of chip values (uncontrolled).
60
+ */
61
+ defaultValue?: string[]
62
+ /**
63
+ * Prevents new chips from being added and makes existing chips non-interactive.
64
+ */
65
+ disabled?: boolean
66
+ /**
67
+ * A description for the field, rendered below the chips.
68
+ */
69
+ help?: ReactNode
70
+ /**
71
+ * `id` forwarded to the text input — useful for pairing with a `<label for>`.
72
+ */
73
+ id?: string
74
+ /**
75
+ * Set component validation state to invalid.
76
+ */
77
+ invalid?: boolean
78
+ /**
79
+ * An error message for the field, rendered below the chips when `invalid` is set.
80
+ */
81
+ invalidFeedback?: ReactNode
82
+ /**
83
+ * The field's caption, rendered as a `FormLabel` associated with the text input.
84
+ */
85
+ label?: ReactNode
86
+ /**
87
+ * Maximum number of chips allowed. Omit for no limit.
88
+ */
89
+ maxChips?: number
90
+ /**
91
+ * `name` of an auto-created hidden input per chip, kept in sync with the values, for native
92
+ * form submission. Omit to skip creating them.
93
+ */
94
+ name?: string
95
+ /**
96
+ * Callback fired whenever a chip is added or removed.
97
+ */
98
+ onChange?: (values: string[]) => void
99
+ /**
100
+ * Placeholder shown in the input when empty.
101
+ */
102
+ placeholder?: string
103
+ /**
104
+ * Character that creates a new chip when typed, or pasted text is split on. Set to `null` to
105
+ * disable. Defaults to `,`.
106
+ */
107
+ separator?: string | null
108
+ /**
109
+ * Size the component small or large.
110
+ */
111
+ size?: 'small' | 'large'
112
+ /**
113
+ * Set component validation state to valid.
114
+ */
115
+ valid?: boolean
116
+ /**
117
+ * A success message for the field, rendered below the chips when `valid` is set.
118
+ */
119
+ validFeedback?: ReactNode
120
+ /**
121
+ * The set of chip values (controlled).
122
+ */
123
+ value?: string[]
124
+ }
125
+
126
+ export const ChipInput = forwardRef<HTMLDivElement, ChipInputProps>(
127
+ (
128
+ {
129
+ allowDuplicates = false,
130
+ 'aria-describedby': ariaDescribedBy,
131
+ 'aria-label': ariaLabel,
132
+ 'aria-labelledby': ariaLabelledBy,
133
+ chipVariant = 'default',
134
+ className,
135
+ defaultValue,
136
+ disabled,
137
+ help,
138
+ id,
139
+ invalid,
140
+ invalidFeedback,
141
+ label,
142
+ maxChips,
143
+ name,
144
+ onChange,
145
+ placeholder,
146
+ separator = ',',
147
+ size,
148
+ valid,
149
+ validFeedback,
150
+ value,
151
+ ...rest
152
+ }: ChipInputProps,
153
+ ref
154
+ ): ReactNode => {
155
+ const [tags, updateTags] = useControllableState<string[]>(value, defaultValue ?? [], onChange)
156
+
157
+ // The one memoized computation of `tags`' ids — `removeTags`, `items`, and `focusLastChip`
158
+ // below all used to independently recompute this same array from scratch (fresh on every
159
+ // render, not just when `tags` actually changed); they now all read this instead.
160
+ const ids = useMemo(() => buildTagIds(tags), [tags])
161
+
162
+ // Shared by `addTag` and the paste handler's loop: whether `raw` (already trimmed) is
163
+ // addable to `list` given `allowDuplicates`. `maxChips` is deliberately not part of this
164
+ // predicate — the paste loop needs to `break` (stop entirely) on hitting the limit, while an
165
+ // empty/duplicate value should just be skipped and the loop should keep going.
166
+ const isAddableValue = (list: string[], trimmed: string) =>
167
+ trimmed !== '' && (allowDuplicates || !list.includes(trimmed))
168
+
169
+ const addTag = (raw: string) => {
170
+ const trimmed = raw.trim()
171
+ if (!isAddableValue(tags, trimmed)) return
172
+ if (maxChips != null && tags.length >= maxChips) return
173
+ updateTags([...tags, trimmed])
174
+ }
175
+
176
+ const removeTags = (keys: Iterable<Key>) => {
177
+ const toRemove = new Set(keys)
178
+ updateTags(tags.filter((_tag, index) => !toRemove.has(ids[index]!)))
179
+ }
180
+
181
+ const items = useMemo<ChipItem[]>(
182
+ () => tags.map((tag, index) => ({ id: ids[index]!, value: tag })),
183
+ [tags, ids]
184
+ )
185
+
186
+ const listState = useListState<ChipItem>({
187
+ children: (item: ChipItem) => (
188
+ <Item key={item.id} textValue={item.value}>
189
+ {item.value}
190
+ </Item>
191
+ ),
192
+ disabledKeys: disabled ? items.map((item) => item.id) : undefined,
193
+ items,
194
+ selectionMode: 'multiple'
195
+ })
196
+
197
+ const groupRef = useRef<HTMLDivElement>(null)
198
+ const containerRef = useRef<HTMLDivElement>(null)
199
+ const forkedRef = useForkedRef(ref, containerRef)
200
+ const inputRef = useRef<HTMLInputElement>(null)
201
+ const [inputValue, setInputValue] = useState('')
202
+
203
+ const focusLastChip = (extend: boolean) => {
204
+ if (tags.length === 0) return
205
+ const lastKey = ids[ids.length - 1]!
206
+ if (extend) {
207
+ listState.selectionManager.extendSelection(lastKey)
208
+ } else {
209
+ listState.selectionManager.replaceSelection(lastKey)
210
+ }
211
+ const rows = groupRef.current?.querySelectorAll<HTMLElement>('[role="row"]')
212
+ rows?.[rows.length - 1]?.focus()
213
+ }
214
+
215
+ const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
216
+ if (disabled) return
217
+
218
+ if (separator && event.key === separator) {
219
+ event.preventDefault()
220
+ addTag(inputValue)
221
+ setInputValue('')
222
+ return
223
+ }
224
+
225
+ switch (event.key) {
226
+ case 'Enter': {
227
+ event.preventDefault()
228
+ addTag(inputValue)
229
+ setInputValue('')
230
+ break
231
+ }
232
+ case 'Backspace':
233
+ case 'Delete': {
234
+ if (inputValue === '') {
235
+ event.preventDefault()
236
+ focusLastChip(false)
237
+ }
238
+ break
239
+ }
240
+ case 'ArrowLeft': {
241
+ const input = inputRef.current
242
+ if (input && input.selectionStart === 0 && input.selectionEnd === 0) {
243
+ event.preventDefault()
244
+ focusLastChip(event.shiftKey)
245
+ }
246
+ break
247
+ }
248
+ case 'Escape': {
249
+ setInputValue('')
250
+ listState.selectionManager.clearSelection()
251
+ inputRef.current?.blur()
252
+ break
253
+ }
254
+ default:
255
+ break
256
+ }
257
+ }
258
+
259
+ const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
260
+ if (disabled || !separator) return
261
+ const pasted = event.clipboardData.getData('text')
262
+ if (!pasted.includes(separator)) return
263
+
264
+ event.preventDefault()
265
+ const parts = pasted.split(separator)
266
+
267
+ // Accumulate locally rather than calling `addTag` per part: each call to `addTag` reads
268
+ // `tags` from this render's closure, so several calls in a row within one event would all
269
+ // start from the same stale array instead of building on each other.
270
+ let next = tags
271
+ // `i` tracks how far the loop got, so a `maxChips` cutoff can leave everything from that
272
+ // point on (not just the final unsplit part) in the input instead of silently dropping it.
273
+ let i = 0
274
+ for (; i < parts.length - 1; i++) {
275
+ if (maxChips != null && next.length >= maxChips) break
276
+ const trimmed = parts[i]!.trim()
277
+ if (!isAddableValue(next, trimmed)) continue
278
+ next = [...next, trimmed]
279
+ }
280
+ if (next !== tags) updateTags(next)
281
+ setInputValue(parts.slice(i).join(separator))
282
+ }
283
+
284
+ const { describedBy, feedbackId, helpId, inputId, labelId, labelledBy } = useFormField({
285
+ ariaDescribedBy,
286
+ ariaLabelledBy,
287
+ help,
288
+ id,
289
+ invalid,
290
+ invalidFeedback,
291
+ label,
292
+ valid,
293
+ validFeedback
294
+ })
295
+
296
+ const { inputProps } = useTextField(
297
+ {
298
+ 'aria-describedby': describedBy,
299
+ 'aria-label': ariaLabel ?? (ariaLabelledBy || label ? undefined : 'Add value'),
300
+ 'aria-labelledby': labelledBy,
301
+ id: inputId,
302
+ isDisabled: disabled,
303
+ isInvalid: invalid,
304
+ onChange: setInputValue,
305
+ onFocus: () => listState.selectionManager.clearSelection(),
306
+ onKeyDown: handleKeyDown,
307
+ placeholder,
308
+ value: inputValue
309
+ },
310
+ inputRef
311
+ )
312
+
313
+ return renderFormField({
314
+ children: (
315
+ <div
316
+ className={classNames(
317
+ 'form-input',
318
+ 'chip-input',
319
+ size,
320
+ { disabled },
321
+ validationClassName(invalid, valid),
322
+ className
323
+ )}
324
+ {...rest}
325
+ ref={forkedRef}
326
+ >
327
+ <ChipList
328
+ chipVariant={chipVariant}
329
+ disabled={disabled}
330
+ groupRef={groupRef}
331
+ props={{
332
+ 'aria-label': ariaLabel,
333
+ 'aria-labelledby': labelledBy,
334
+ onRemove: disabled ? undefined : removeTags
335
+ }}
336
+ size={size}
337
+ state={listState}
338
+ />
339
+ <input {...inputProps} className="ghost-input" onPaste={handlePaste} ref={inputRef} />
340
+ {name &&
341
+ items.map((item) => (
342
+ <input
343
+ disabled={disabled}
344
+ key={item.id}
345
+ name={name}
346
+ type="hidden"
347
+ value={item.value}
348
+ />
349
+ ))}
350
+ </div>
351
+ ),
352
+ help,
353
+ ids: { feedback: feedbackId, help: helpId, input: inputId, label: labelId },
354
+ invalid,
355
+ invalidFeedback,
356
+ label,
357
+ valid,
358
+ validFeedback
359
+ })
360
+ }
361
+ )
362
+
363
+ ChipInput.displayName = 'ChipInput'
@@ -0,0 +1,95 @@
1
+ import React, { RefObject, useRef } from 'react'
2
+ import classNames from 'classnames'
3
+ import { AriaTagGroupOptions, useButton, useTag, useTagGroup } from 'react-aria'
4
+ import { ListState, Node } from 'react-stately'
5
+
6
+ import { Chip } from '../chip/Chip'
7
+ import { CloseButton } from '../close-button/CloseButton'
8
+
9
+ // react-stately's collection builder caches nodes in a WeakMap keyed by each item's own
10
+ // identity, so `items` must be objects — plain tag strings can't be WeakMap keys.
11
+ export interface ChipItem {
12
+ id: string
13
+ value: string
14
+ }
15
+
16
+ interface ChipListProps {
17
+ chipVariant?: string
18
+ disabled?: boolean
19
+ groupRef: RefObject<HTMLDivElement | null>
20
+ props: AriaTagGroupOptions<ChipItem>
21
+ size?: 'small' | 'large'
22
+ state: ListState<ChipItem>
23
+ }
24
+
25
+ export const ChipList = ({
26
+ chipVariant,
27
+ disabled,
28
+ groupRef,
29
+ props,
30
+ size,
31
+ state
32
+ }: ChipListProps) => {
33
+ const { gridProps } = useTagGroup(props, state, groupRef)
34
+
35
+ return (
36
+ <div {...gridProps} style={{ display: 'contents' }} ref={groupRef as RefObject<HTMLDivElement>}>
37
+ {[...state.collection].map((item) => (
38
+ <ChipRow
39
+ chipVariant={chipVariant}
40
+ disabled={disabled}
41
+ item={item}
42
+ key={item.key}
43
+ size={size}
44
+ state={state}
45
+ />
46
+ ))}
47
+ </div>
48
+ )
49
+ }
50
+
51
+ interface ChipRowProps {
52
+ chipVariant?: string
53
+ disabled?: boolean
54
+ item: Node<ChipItem>
55
+ size?: 'small' | 'large'
56
+ state: ListState<ChipItem>
57
+ }
58
+
59
+ // Renders one tag as the shared `Chip` component, wrapped in the row/gridcell markup
60
+ // `useTag`'s grid semantics require — see FORMS.md-adjacent notes in ChipInput.tsx for why the
61
+ // grid role lives on `ChipList`'s own wrapper rather than `.chip-input` itself. The `active`
62
+ // class communicates selection directly (react-aria's `aria-selected`, from `rowProps`, is the
63
+ // actual ARIA signal); `Chip`'s own `pressed`/`aria-pressed` prop is deliberately left unset
64
+ // here since `aria-pressed` isn't a valid property on a `role="row"` element.
65
+ const ChipRow = ({ chipVariant, disabled, item, size, state }: ChipRowProps) => {
66
+ const ref = useRef<HTMLDivElement>(null)
67
+ const { rowProps, gridCellProps, removeButtonProps, allowsRemoving } = useTag(
68
+ { item },
69
+ state,
70
+ ref
71
+ )
72
+ const buttonRef = useRef<HTMLButtonElement>(null)
73
+ const { buttonProps } = useButton(removeButtonProps, buttonRef)
74
+
75
+ return (
76
+ <Chip
77
+ {...rowProps}
78
+ className={classNames(chipVariant, { active: state.selectionManager.isSelected(item.key) })}
79
+ component="div"
80
+ disabled={disabled}
81
+ ref={ref}
82
+ size={size}
83
+ >
84
+ <div {...gridCellProps} style={{ display: 'contents' }}>
85
+ {item.rendered}
86
+ {allowsRemoving && (
87
+ // react-aria's generic ButtonHTMLAttributes typing includes a legacy `color?: string`
88
+ // attribute that's wider than CloseButton's `color?: ContextColor` prop; buttonProps
89
+ // never actually sets it, so it's safe to omit from the spread's type.
90
+ <CloseButton {...(buttonProps as Omit<typeof buttonProps, 'color'>)} ref={buttonRef} />
91
+ )}
92
+ </div>
93
+ </Chip>
94
+ )
95
+ }
@@ -0,0 +1,2 @@
1
+ export { ChipInput } from './ChipInput'
2
+ export type { ChipInputProps } from './ChipInput'
@@ -0,0 +1,205 @@
1
+ import React, {
2
+ ElementType,
3
+ ForwardRefRenderFunction,
4
+ MouseEventHandler,
5
+ ReactElement,
6
+ Ref
7
+ } from 'react'
8
+ import classNames from 'classnames'
9
+ import { mergeProps } from 'react-aria'
10
+
11
+ import { ContextColor, ContextStyle } from '../../types'
12
+ import { useButtonSemantics, useDisabledAnchorGuard } from '../../hooks'
13
+ import {
14
+ createPolymorphicComponent,
15
+ PolymorphicComponentProps,
16
+ PolymorphicRef
17
+ } from '../../utils/polymorphic'
18
+
19
+ type CloseButtonOwnProps<C extends ElementType> = {
20
+ /**
21
+ * A string of all className you want applied to the base component.
22
+ */
23
+ className?: string
24
+ /**
25
+ * Sets the color of the component to one of Chassis context colors. Applies the
26
+ * `context` class alongside it, so the icon picks up that color without needing
27
+ * an ancestor `.context` wrapper.
28
+ */
29
+ color?: ContextColor
30
+ /**
31
+ * Component used for the root node. Either a string to use a HTML element or a component.
32
+ * A custom HTML tag (e.g. `'span'`) gets button semantics — role, focus, Enter/Space
33
+ * activation — filled in automatically. A component is trusted to handle its own semantics,
34
+ * so pass one that's already interactive (e.g. `Button`).
35
+ */
36
+ component?: C
37
+ /**
38
+ * Toggle the disabled state for the component.
39
+ */
40
+ disabled?: boolean
41
+ /**
42
+ * The href attribute specifies the URL of the page the link goes to. Only meaningful when
43
+ * `component` is `'a'`.
44
+ */
45
+ href?: string
46
+ /**
47
+ * The accessible label announced by assistive technology. Override this to
48
+ * localize the button for non-English contexts.
49
+ */
50
+ label?: string
51
+ /**
52
+ * Fires on click. Typed for every element `component` can actually render (`button` or `a`
53
+ * natively, plus whatever a custom `component` renders), rather than narrowed to
54
+ * `HTMLButtonElement` alone.
55
+ */
56
+ onClick?: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>
57
+ /**
58
+ * Size the component small or large.
59
+ */
60
+ size?: 'small' | 'large'
61
+ /**
62
+ * Specifies the type of button. Always specify the type attribute for the `<button>` element.
63
+ * Different browsers may use different default types for the `<button>` element.
64
+ */
65
+ type?: 'button' | 'submit' | 'reset'
66
+ /**
67
+ * Set the close button's context style variant. Applies the `context` class
68
+ * alongside it, same as `color`.
69
+ */
70
+ variant?: ContextStyle
71
+ }
72
+
73
+ export type CloseButtonProps<C extends ElementType = 'button'> = PolymorphicComponentProps<
74
+ C,
75
+ CloseButtonOwnProps<C>
76
+ >
77
+
78
+ type CloseButtonComponent = (<C extends ElementType = 'button'>(
79
+ props: CloseButtonProps<C> & { ref?: PolymorphicRef<C> }
80
+ ) => ReactElement | null) & { displayName?: string }
81
+
82
+ function CloseButtonRender<C extends ElementType = 'button'>(
83
+ {
84
+ children,
85
+ className,
86
+ color,
87
+ component,
88
+ disabled,
89
+ href,
90
+ label,
91
+ onClick,
92
+ size,
93
+ type = 'button',
94
+ variant,
95
+ ...rest
96
+ }: CloseButtonProps<C>,
97
+ ref: PolymorphicRef<C>
98
+ ) {
99
+ const component_ = component ?? 'button'
100
+
101
+ // A component reference (e.g. `Button`) has its own visual identity and its own
102
+ // `color`/`size`/`variant` semantics — CloseButton's icon styling (the `close-button`
103
+ // class and its context/color/variant/size classes) would just conflict with it, and
104
+ // would also swallow those props before the component ever saw them. So a component
105
+ // reference gets none of CloseButton's own styling; `color`/`size`/`variant` pass
106
+ // through untouched, same as `className`, letting the component interpret them itself.
107
+ const isComponentReference = typeof component_ !== 'string'
108
+
109
+ // Only a bare, non-`button` HTML tag needs the `.disabled` class applied by hand — a real
110
+ // `<button>` gets native `:disabled` styling for free (chassis-css matches both selectors,
111
+ // `&:disabled, &.disabled`), and a component reference isn't styled by CloseButton at all
112
+ // (see `isComponentReference` above).
113
+ const _className = isComponentReference
114
+ ? className
115
+ : classNames(
116
+ 'close-button',
117
+ { context: color || variant, disabled: component_ !== 'button' && disabled },
118
+ color,
119
+ variant,
120
+ size,
121
+ className
122
+ )
123
+
124
+ // The default icon-only close button has no visible text, so it needs the 'Close' fallback
125
+ // as its accessible name. Once `children` renders visible content (e.g. custom text passed
126
+ // through `component`), let that content be the accessible name instead — forcing the
127
+ // fallback label here would silently override it (a Label-in-Name accessibility failure).
128
+ // An explicit `label` always wins either way, same as an explicit `aria-label` always did.
129
+ const _label = label ?? (children == null ? 'Close' : undefined)
130
+
131
+ // `<a>` has no real `disabled` attribute, so a disabled link close-button still fires
132
+ // click (and still navigates) unless it's blocked here, same guard `Button` applies.
133
+ const handleClick = useDisabledAnchorGuard<HTMLButtonElement | HTMLAnchorElement>(
134
+ component_ === 'a',
135
+ disabled,
136
+ onClick
137
+ )
138
+
139
+ const { buttonProps, forkedRef } = useButtonSemantics<HTMLButtonElement | HTMLAnchorElement>(
140
+ ref as Ref<HTMLButtonElement | HTMLAnchorElement>,
141
+ { disabled, onClick }
142
+ )
143
+
144
+ // Rendered as explicit branches, same as `Button`, rather than one `<Component>` tag
145
+ // driven by a `'button' | 'a' | ElementType`-typed variable — a union-typed tag would
146
+ // make JSX intersect all of `button`/`a`/custom prop types at once.
147
+ // `aria-label={_label}` comes before `...rest` in every branch below so that an explicit
148
+ // `aria-label` (not destructured above, so it lands in `rest`) overrides the computed
149
+ // fallback via JSX's last-attribute-wins rule, rather than the other way around.
150
+ if (component_ === 'button') {
151
+ return (
152
+ <button
153
+ className={_className}
154
+ aria-label={_label}
155
+ {...(rest as Record<string, unknown>)}
156
+ type={type}
157
+ disabled={disabled}
158
+ onClick={handleClick}
159
+ ref={ref as Ref<HTMLButtonElement>}
160
+ >
161
+ {children}
162
+ </button>
163
+ )
164
+ }
165
+
166
+ if (component_ === 'a') {
167
+ return (
168
+ <a
169
+ className={_className}
170
+ aria-label={_label}
171
+ {...(rest as Record<string, unknown>)}
172
+ href={href}
173
+ onClick={handleClick}
174
+ {...(disabled && { 'aria-disabled': true, tabIndex: -1 })}
175
+ ref={ref as Ref<HTMLAnchorElement>}
176
+ >
177
+ {children}
178
+ </a>
179
+ )
180
+ }
181
+
182
+ const Component = component_ as ElementType
183
+
184
+ // Only a bare, non-`button`/`a` HTML tag (e.g. `'span'`) reaches here needing synthesized
185
+ // button semantics — a component reference is trusted to already be interactive, so it
186
+ // skips `buttonProps` (double keyboard activation) and instead gets `color`/`size`/`variant`
187
+ // passed through untouched, per the comment on `isComponentReference` above.
188
+ return (
189
+ <Component
190
+ className={_className}
191
+ aria-label={_label}
192
+ {...(isComponentReference
193
+ ? ({ color, size, variant, disabled, onClick, ...rest } as Record<string, unknown>)
194
+ : (mergeProps(rest, buttonProps) as Record<string, unknown>))}
195
+ ref={isComponentReference ? ref : forkedRef}
196
+ >
197
+ {children}
198
+ </Component>
199
+ )
200
+ }
201
+
202
+ export const CloseButton = createPolymorphicComponent<CloseButtonComponent>(
203
+ CloseButtonRender as ForwardRefRenderFunction<Element, CloseButtonProps<ElementType>>,
204
+ 'CloseButton'
205
+ )
@@ -0,0 +1,2 @@
1
+ export { CloseButton } from './CloseButton'
2
+ export type { CloseButtonProps } from './CloseButton'