@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,46 @@
1
+ import React, { ElementType, ForwardRefRenderFunction, ReactElement } from 'react'
2
+ import classNames from 'classnames'
3
+
4
+ import {
5
+ createPolymorphicComponent,
6
+ PolymorphicComponentProps,
7
+ PolymorphicRef
8
+ } from '../../utils/polymorphic'
9
+
10
+ type AccordionBodyOwnProps<C extends ElementType> = {
11
+ /**
12
+ * A string of all className you want applied to the base component.
13
+ */
14
+ className?: string
15
+ /**
16
+ * Component used for the root node. Either a string to use a HTML element or a component.
17
+ */
18
+ component?: C
19
+ }
20
+
21
+ export type AccordionBodyProps<C extends ElementType = 'div'> = PolymorphicComponentProps<
22
+ C,
23
+ AccordionBodyOwnProps<C>
24
+ >
25
+
26
+ type AccordionBodyComponent = (<C extends ElementType = 'div'>(
27
+ props: AccordionBodyProps<C> & { ref?: PolymorphicRef<C> }
28
+ ) => ReactElement | null) & { displayName?: string }
29
+
30
+ function AccordionBodyRender<C extends ElementType = 'div'>(
31
+ { children, className, component, ...rest }: AccordionBodyProps<C>,
32
+ ref: PolymorphicRef<C>
33
+ ) {
34
+ const Component = component ?? 'div'
35
+
36
+ return (
37
+ <Component className={classNames('accordion-body', className)} {...rest} ref={ref}>
38
+ {children}
39
+ </Component>
40
+ )
41
+ }
42
+
43
+ export const AccordionBody = createPolymorphicComponent<AccordionBodyComponent>(
44
+ AccordionBodyRender as ForwardRefRenderFunction<Element, AccordionBodyProps<ElementType>>,
45
+ 'AccordionBody'
46
+ )
@@ -0,0 +1,21 @@
1
+ import React, { forwardRef, HTMLAttributes } from 'react'
2
+ import classNames from 'classnames'
3
+
4
+ export interface AccordionHeaderProps extends HTMLAttributes<HTMLElement> {
5
+ /**
6
+ * A string of all className you want applied to the base component.
7
+ */
8
+ className?: string
9
+ }
10
+
11
+ export const AccordionHeader = forwardRef<HTMLElement, AccordionHeaderProps>(
12
+ ({ children, className, ...rest }, ref) => {
13
+ return (
14
+ <summary className={classNames(className) || undefined} {...rest} ref={ref}>
15
+ <span className="accordion-title">{children}</span>
16
+ </summary>
17
+ )
18
+ }
19
+ )
20
+
21
+ AccordionHeader.displayName = 'AccordionHeader'
@@ -0,0 +1,59 @@
1
+ import React, { forwardRef, DetailsHTMLAttributes, useContext } from 'react'
2
+ import classNames from 'classnames'
3
+
4
+ import { AccordionContext } from './context'
5
+
6
+ export interface AccordionItemProps extends DetailsHTMLAttributes<HTMLDetailsElement> {
7
+ /**
8
+ * Let this item stay open when another item opens, overriding the accordion's `alwaysOpen` setting.
9
+ */
10
+ alwaysOpen?: boolean
11
+ /**
12
+ * A string of all className you want applied to the base component.
13
+ */
14
+ className?: string
15
+ /**
16
+ * The group this item shares with other items so only one can be open at a time, overriding the
17
+ * accordion's shared `name`.
18
+ */
19
+ name?: string
20
+ /**
21
+ * Start the item in the open state.
22
+ */
23
+ open?: boolean
24
+ /**
25
+ * @deprecated No longer used. The native details element manages its own state.
26
+ */
27
+ itemKey?: number | string
28
+ }
29
+
30
+ export const AccordionItem = forwardRef<HTMLDetailsElement, AccordionItemProps>(
31
+ ({ children, alwaysOpen, className, name, open, itemKey: _itemKey, ...rest }, ref) => {
32
+ if (_itemKey !== undefined) {
33
+ console.warn(
34
+ 'AccordionItem: the itemKey prop is deprecated and no longer used — the native ' +
35
+ '<details> element manages its own state. It will be removed in a future major version.'
36
+ )
37
+ }
38
+
39
+ const context = useContext(AccordionContext)
40
+ const isAlwaysOpen = alwaysOpen ?? context.alwaysOpen
41
+ const groupName = name ?? context.name
42
+
43
+ const groupProps = isAlwaysOpen ? {} : { name: groupName }
44
+
45
+ return (
46
+ <details
47
+ className={classNames('accordion-item', className)}
48
+ open={open}
49
+ {...(groupProps as React.HTMLAttributes<HTMLDetailsElement>)}
50
+ {...rest}
51
+ ref={ref}
52
+ >
53
+ {children}
54
+ </details>
55
+ )
56
+ }
57
+ )
58
+
59
+ AccordionItem.displayName = 'AccordionItem'
@@ -0,0 +1,8 @@
1
+ import { createContext } from 'react'
2
+
3
+ export interface AccordionContextProps {
4
+ alwaysOpen?: boolean
5
+ name?: string
6
+ }
7
+
8
+ export const AccordionContext = createContext<AccordionContextProps>({})
@@ -0,0 +1,8 @@
1
+ export { Accordion } from './Accordion'
2
+ export type { AccordionProps, AccordionItemDef } from './Accordion'
3
+ export { AccordionBody } from './AccordionBody'
4
+ export type { AccordionBodyProps } from './AccordionBody'
5
+ export { AccordionHeader } from './AccordionHeader'
6
+ export type { AccordionHeaderProps } from './AccordionHeader'
7
+ export { AccordionItem } from './AccordionItem'
8
+ export type { AccordionItemProps } from './AccordionItem'
@@ -0,0 +1,464 @@
1
+ import React, {
2
+ HTMLAttributes,
3
+ InputHTMLAttributes,
4
+ ReactNode,
5
+ useEffect,
6
+ useMemo,
7
+ useRef
8
+ } from 'react'
9
+ import { createPortal } from 'react-dom'
10
+ import classNames from 'classnames'
11
+ import { mergeProps, useButton, useComboBox, useFilter, useOverlayPosition } from 'react-aria'
12
+ import { Key, useComboBoxState } from 'react-stately'
13
+
14
+ import { useFloatingOverlay, useFormField } from '../../hooks'
15
+ import {
16
+ buildEntriesFromChildren,
17
+ buildEntriesFromItemsDef,
18
+ ComboboxEntry,
19
+ comboboxCollectionChildren,
20
+ getDisabledKeys
21
+ } from '../../utils/comboboxCollection'
22
+ import {
23
+ COMBOBOX_MENU_OVERLAY_STYLE,
24
+ resolveMenuOverlayPositioning,
25
+ toAriaPlacement
26
+ } from '../../utils/overlayPlacement'
27
+ import { ComboboxListBox } from '../combobox/ComboboxListBox'
28
+ import { renderFormField } from '../form-field/renderFormField'
29
+ import { MenuItemsDef } from '../menu/MenuItemDef'
30
+ import { AutocompleteGroup } from './AutocompleteGroup'
31
+ import { AutocompleteItem } from './AutocompleteItem'
32
+
33
+ // `useComboBoxState`/`useComboBox` are generic over a `SelectionMode` ('single' | 'multiple')
34
+ // that only affects *types* — actual runtime behavior (whether a selection closes the menu,
35
+ // disallows an empty selection, *and* whether `state.value`/`onChange`'s callback argument come
36
+ // through as a bare `Key` or a `Key[]`) is driven entirely by the `selectionMode` *string* passed
37
+ // at the call site below, not by this type parameter. Fixing the parameter at `'multiple'` here
38
+ // (the more permissive of the two shapes) lets one component serve both modes without a full
39
+ // code fork, but it means the type system's claim that `state.value`/`onChange`'s argument are
40
+ // always `Key[]` is untrustworthy for single-select at runtime — confirmed by testing (the
41
+ // naive `keys[0]` on a bare string key silently returned its first *character*). `asKeyArray`
42
+ // normalizes either shape; every read of `state.value` or `onChange`'s argument goes through it.
43
+ const toMultiValue = (value: Key | Key[] | null | undefined): Key[] | undefined => {
44
+ if (value === undefined) return undefined
45
+ if (value === null) return []
46
+ return Array.isArray(value) ? value : [value]
47
+ }
48
+
49
+ const asKeyArray = (value: unknown): Key[] => {
50
+ if (value == null) return []
51
+ return Array.isArray(value) ? value : [value as Key]
52
+ }
53
+
54
+ export interface AutocompleteProps extends Omit<
55
+ HTMLAttributes<HTMLDivElement>,
56
+ 'onChange' | 'defaultValue'
57
+ > {
58
+ /**
59
+ * An accessible label for the autocomplete, used when there's no visible `<label>`.
60
+ */
61
+ 'aria-label'?: string
62
+ /**
63
+ * Identifies a visible `<label>` element for the autocomplete.
64
+ */
65
+ 'aria-labelledby'?: string
66
+ /**
67
+ * `AutocompleteItem` elements, optionally wrapped in `AutocompleteGroup` — read as data by
68
+ * `Autocomplete` to build the option list. Not rendered directly. Ignored when `items` is set.
69
+ */
70
+ children?: ReactNode
71
+ /**
72
+ * A string of all className you want applied to the base component.
73
+ */
74
+ className?: string
75
+ /**
76
+ * The initial selected option's id(s) (uncontrolled). An array when `multiple` is set.
77
+ */
78
+ defaultValue?: Key | Key[] | null
79
+ /**
80
+ * Prevents the autocomplete from being focused or interacted with.
81
+ */
82
+ disabled?: boolean
83
+ /**
84
+ * A description for the field, rendered below the autocomplete.
85
+ */
86
+ help?: ReactNode
87
+ /**
88
+ * `id` forwarded to the search input inside the popover. The toggle — the field's real,
89
+ * always-focusable surface — has no single labelable element `<label for>` can target (it's a
90
+ * `role="button"` `<div>`, and the search input itself is hidden until open), so `label` is
91
+ * associated via `aria-labelledby` on the toggle instead. See FORMS.md's "role=group" pattern.
92
+ */
93
+ id?: string
94
+ /**
95
+ * Set component validation state to invalid.
96
+ */
97
+ invalid?: boolean
98
+ /**
99
+ * An error message for the field, rendered below the autocomplete when `invalid` is set.
100
+ */
101
+ invalidFeedback?: ReactNode
102
+ /**
103
+ * Array of item/header/divider definitions for data-driven rendering. When provided, children
104
+ * are ignored. A `'header'` entry starts a group that all following items join until the next
105
+ * header or the end of the array. `'divider'` entries are a no-op here — use
106
+ * `AutocompleteGroup` composition instead if you need finer control over grouping. An entry's
107
+ * `href`/`onClick` are `Menu`-only and are not read here — use `onChange` to react to the
108
+ * selection instead.
109
+ */
110
+ items?: MenuItemsDef
111
+ /**
112
+ * The field's caption, rendered as a `FormLabel` associated with the toggle.
113
+ */
114
+ label?: ReactNode
115
+ /**
116
+ * Allows more than one option to be selected. The toggle shows the single selection's label,
117
+ * or an "N selected" count once more than one is picked. The menu stays open after each
118
+ * selection, and `Backspace` in the empty search field removes the last selected option.
119
+ */
120
+ multiple?: boolean
121
+ /**
122
+ * `name` of auto-created hidden input(s), kept in sync with the selection, for native form
123
+ * submission. Single-select renders one; `multiple` renders one per selected key. Omit to skip
124
+ * hidden-input creation.
125
+ */
126
+ name?: string
127
+ /**
128
+ * Text shown in the listbox when no options match the current query.
129
+ */
130
+ noResultsText?: ReactNode
131
+ /**
132
+ * Callback fired when the selection changes. Receives an array of keys when `multiple` is set.
133
+ */
134
+ onChange?: (value: Key | Key[] | null) => void
135
+ /**
136
+ * Text shown on the toggle when nothing is selected.
137
+ */
138
+ placeholder?: string
139
+ /**
140
+ * Placeholder for the search field inside the dropdown.
141
+ */
142
+ searchPlaceholder?: string
143
+ /**
144
+ * Size the component small or large.
145
+ */
146
+ size?: 'small' | 'large'
147
+ /**
148
+ * Set component validation state to valid.
149
+ */
150
+ valid?: boolean
151
+ /**
152
+ * A success message for the field, rendered below the autocomplete when `valid` is set.
153
+ */
154
+ validFeedback?: ReactNode
155
+ /**
156
+ * The selected option's id(s) (controlled). An array when `multiple` is set.
157
+ */
158
+ value?: Key | Key[] | null
159
+ }
160
+
161
+ // `Autocomplete` implements chassis-css's button-trigger combobox — a display-only toggle
162
+ // (not a text input) that opens a `.menu` containing its own search field, matching
163
+ // https://chassis-ui.com/css/docs/forms/combobox/#search-field. This is deliberately NOT the
164
+ // same composition as `Combobox` (whose text input *is* the trigger): here, react-aria's
165
+ // `useComboBox` is given a separate `buttonRef` — a first-class option the hook supports
166
+ // specifically for this "button opens a listbox with its own input" shape — so the real
167
+ // `role="combobox"` input lives inside the popover (styled as `.combobox-search-input`) while a
168
+ // toggle serves as the always-visible, always-focusable trigger. The toggle is a `<div
169
+ // role="button">` (via `useButton`'s `elementType: 'div'`, the same pattern `Button.tsx` uses
170
+ // for a non-native trigger) rather than a real `<button>` — an earlier version rendered
171
+ // removable chips inside it for `multiple` mode, which a real (or ARIA) button can't legally
172
+ // contain per axe's `nested-interactive` check; that's now plain "N selected" text instead (see
173
+ // `triggerText` below), but the `<div>` stays since reverting buys little and this is proven.
174
+ // `useComboBox`'s own `buttonProps` also sets `excludeFromTabOrder: true` by default — correct
175
+ // for its usual "auxiliary button beside an always-visible input" composition, wrong here since
176
+ // the toggle is the *only* focusable surface before opening; overridden back to reachable below.
177
+ export const Autocomplete = ({
178
+ children,
179
+ className,
180
+ defaultValue,
181
+ disabled,
182
+ help,
183
+ id,
184
+ invalid,
185
+ invalidFeedback,
186
+ items,
187
+ label,
188
+ multiple = false,
189
+ name,
190
+ noResultsText = 'No results found',
191
+ onChange,
192
+ placeholder,
193
+ searchPlaceholder,
194
+ size,
195
+ valid,
196
+ validFeedback,
197
+ value,
198
+ ...rest
199
+ }: AutocompleteProps): ReactNode => {
200
+ // `entries`/`disabledKeys` only need to change when the data driving them does — without this,
201
+ // both re-derive from scratch on every render, including every keystroke while typing.
202
+ const entries = useMemo(
203
+ () =>
204
+ items
205
+ ? buildEntriesFromItemsDef(items)
206
+ : buildEntriesFromChildren(children, { Group: AutocompleteGroup, Item: AutocompleteItem }),
207
+ [items, children]
208
+ )
209
+ const disabledKeys = useMemo(() => getDisabledKeys(entries), [entries])
210
+
211
+ // Case- and accent-insensitive substring matching, mirroring chassis-css's own
212
+ // always-case-insensitive combobox.js filtering.
213
+ const { contains } = useFilter({ sensitivity: 'base' })
214
+
215
+ const state = useComboBoxState<ComboboxEntry, 'multiple'>({
216
+ children: comboboxCollectionChildren,
217
+ defaultItems: entries,
218
+ disabledKeys,
219
+ defaultFilter: contains,
220
+ // Cast: the actual runtime string ('single' or 'multiple') is what drives behavior — see the
221
+ // `toMultiValue` comment above for why the *type* parameter above is fixed at 'multiple'.
222
+ selectionMode: (multiple ? 'multiple' : 'single') as 'multiple',
223
+ defaultValue: toMultiValue(defaultValue),
224
+ value: toMultiValue(value),
225
+ onChange: (keys) => {
226
+ const keyArray = asKeyArray(keys)
227
+ onChange?.(multiple ? keyArray : (keyArray[0] ?? null))
228
+ },
229
+ allowsEmptyCollection: true,
230
+ // Opening is driven only by the toggle (see `buttonProps` below) — there's no input to focus
231
+ // or type into until the panel is already open, so 'input'/'focus' triggers (the hook's
232
+ // defaults, meant for `Combobox`'s text-input trigger) don't apply here.
233
+ menuTrigger: 'manual'
234
+ })
235
+
236
+ const triggerRef = useRef<HTMLDivElement>(null)
237
+ const inputRef = useRef<HTMLInputElement>(null)
238
+ const listBoxRef = useRef<HTMLElement>(null)
239
+ const popoverRef = useRef<HTMLDivElement>(null)
240
+
241
+ const { describedBy, feedbackId, helpId, inputId, labelId, labelledBy } = useFormField({
242
+ ariaDescribedBy: rest['aria-describedby'],
243
+ ariaLabelledBy: rest['aria-labelledby'],
244
+ help,
245
+ id,
246
+ invalid,
247
+ invalidFeedback,
248
+ label,
249
+ valid,
250
+ validFeedback
251
+ })
252
+
253
+ const { buttonProps, inputProps, listBoxProps } = useComboBox<ComboboxEntry, 'multiple'>(
254
+ {
255
+ 'aria-label': rest['aria-label'],
256
+ 'aria-labelledby': labelledBy,
257
+ buttonRef: triggerRef,
258
+ id: inputId,
259
+ inputRef,
260
+ listBoxRef,
261
+ popoverRef,
262
+ isDisabled: disabled,
263
+ placeholder: searchPlaceholder
264
+ },
265
+ state
266
+ )
267
+
268
+ // `useComboBox`'s own `buttonProps` sets `excludeFromTabOrder: true` — correct for its
269
+ // intended composition (an auxiliary icon button *next to* an already-focusable, always-
270
+ // visible input, e.g. a date picker's calendar-icon trigger), where the input is expected to
271
+ // be the primary tab stop. That doesn't hold here: the input lives inside the popover, `hidden`
272
+ // (and so untabbable) until opened, and this toggle is the *only* focusable surface before
273
+ // that — leaving `excludeFromTabOrder` in place would make the whole control unreachable by
274
+ // keyboard. Overriding it back to `false` is what actually makes the toggle the tab stop.
275
+ const { buttonProps: triggerButtonProps } = useButton(
276
+ { ...buttonProps, elementType: 'div', isDisabled: disabled, excludeFromTabOrder: false },
277
+ triggerRef
278
+ )
279
+
280
+ // The search field autofocuses once the panel is actually open (chassis-css docs: "When the
281
+ // menu opens, focus jumps to the search field"), and focus returns to the toggle once it
282
+ // closes — `useComboBox` only knows about `inputRef` as its focus target, and that input is
283
+ // `hidden` (via the panel) while closed, so without this, closing (via Escape, selection, or
284
+ // outside click) would drop focus entirely instead of landing back on the toggle.
285
+ const wasOpen = useRef(state.isOpen)
286
+ useEffect(() => {
287
+ if (state.isOpen && !wasOpen.current) {
288
+ inputRef.current?.focus()
289
+ } else if (!state.isOpen && wasOpen.current) {
290
+ triggerRef.current?.focus()
291
+ }
292
+ wasOpen.current = state.isOpen
293
+ }, [state.isOpen])
294
+
295
+ // `useComboBox` also runs react-aria's `ariaHideOutside` while open, to keep background
296
+ // content out of screen readers' way — but it only knows to protect `inputRef`/`popoverRef`
297
+ // (the elements it was given), not this separate toggle, so the toggle (and, in `multiple`
298
+ // mode, its chips and their focusable remove buttons) would otherwise get `aria-hidden`
299
+ // applied to it too while the panel is open. Walking up to `document.body` (rather than
300
+ // clearing just `triggerRef.current` itself) matters now that the popover portals there too
301
+ // (see `useFloatingOverlay` below): `ariaHideOutside` hides the *first* ancestor level whose
302
+ // subtree contains no protected element, which — once the toggle and the popover no longer
303
+ // share a common non-`body` container — can land on one of the toggle's own ancestors instead
304
+ // of the toggle element itself. `ariaHideOutside`'s own doc comment says it watches for *new*
305
+ // elements to hide, not attribute changes on existing ones, so removing it here (in an effect
306
+ // that necessarily runs after react-aria's own — declared later in this same component)
307
+ // doesn't get silently re-applied.
308
+ useEffect(() => {
309
+ if (!state.isOpen) return
310
+ let node: HTMLElement | null = triggerRef.current
311
+ while (node && node !== document.body) {
312
+ node.removeAttribute('aria-hidden')
313
+ node = node.parentElement
314
+ }
315
+ }, [state.isOpen])
316
+
317
+ const { overlayProps, placement: resolvedPlacement } = useOverlayPosition({
318
+ targetRef: triggerRef,
319
+ overlayRef: popoverRef,
320
+ placement: toAriaPlacement('bottom-start'),
321
+ offset: 2,
322
+ isOpen: state.isOpen,
323
+ // Leaving `onClose` unset (`undefined`) doesn't actually disable react-aria's close-on-scroll
324
+ // listener — only an explicit `null` does (`useCloseOnScroll` only early-returns on
325
+ // `onClose === null`, not falsy). It happens to be a no-op today only because this toggle is
326
+ // never registered in react-aria's `useOverlayTrigger`/`onCloseMap` backward-compat map (this
327
+ // component calls `useComboBox`, not `useOverlayTrigger`) — an incidental, not guaranteed,
328
+ // safety net. Passing `null` here makes the opt-out explicit, matching `Menu`/`Popover`.
329
+ onClose: null
330
+ })
331
+
332
+ // Portals the panel to `document.body` (or an enclosing open `<dialog>`) instead of rendering
333
+ // it inline, matching `Popover`/`Tooltip`/`Combobox` — an inline-rendered panel gets clipped by
334
+ // any ancestor with `overflow: hidden`/`auto` (a `ModalBody`, a scrollable card, a table cell),
335
+ // and `Autocomplete` is commonly composed inside exactly those. See
336
+ // `COMBOBOX_MENU_OVERLAY_STYLE` for the styling this trades away by leaving the DOM position
337
+ // `.combobox + .menu` relies on.
338
+ const portalContainer = useFloatingOverlay({
339
+ close: state.close,
340
+ isOpen: state.isOpen,
341
+ open: state.open,
342
+ triggerRef
343
+ })
344
+
345
+ const { menuStyle, placementAttr } = resolveMenuOverlayPositioning(
346
+ overlayProps.style,
347
+ 'bottom-start',
348
+ resolvedPlacement
349
+ )
350
+ const overlayStyle: React.CSSProperties = { ...COMBOBOX_MENU_OVERLAY_STYLE, ...menuStyle }
351
+
352
+ const removeSelected = (key: Key) => state.selectionManager.toggleSelection(key)
353
+
354
+ // Backspace-removes-last-chip only touches the search field's own keydown handling — no
355
+ // nested interactive element needed for it, unlike a rendered "remove" button would be (see
356
+ // the trigger's `combobox-value` text below for why that path was dropped).
357
+ const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
358
+ if (!multiple || event.key !== 'Backspace' || event.currentTarget.value !== '') return
359
+ const last = state.selectedItems[state.selectedItems.length - 1]
360
+ if (last) removeSelected(last.key)
361
+ }
362
+
363
+ const inputHtmlProps = mergeProps(inputProps, {
364
+ onKeyDown: handleSearchKeyDown
365
+ }) as InputHTMLAttributes<HTMLInputElement>
366
+
367
+ const hasSelection = state.selectedItems.length > 0
368
+ // Matches chassis-css's own vanilla multi-select combobox: the toggle shows the single
369
+ // selected item's label, or "N selected" once more than one is picked — deliberately plain
370
+ // text, not per-item chips with their own remove buttons. An earlier version rendered chips
371
+ // here; that failed a real axe check ("Interactive controls must not be nested") because the
372
+ // toggle is itself `role="button"`, and a button can't correctly contain other focusable
373
+ // controls. Deselecting an option is still possible by clicking it again in the open list
374
+ // (native to `useComboBoxState`'s multiple-selection toggle behavior) or via Backspace in the
375
+ // search field, just not from the toggle itself.
376
+ const triggerText = !hasSelection
377
+ ? placeholder
378
+ : state.selectedItems.length === 1
379
+ ? (state.selectedItems[0]?.textValue ?? '')
380
+ : `${state.selectedItems.length} selected`
381
+
382
+ return renderFormField({
383
+ children: (
384
+ <>
385
+ <div
386
+ className={classNames(
387
+ 'form-input',
388
+ 'combobox',
389
+ { small: size === 'small', large: size === 'large', disabled },
390
+ { 'is-invalid': invalid, 'is-valid': valid },
391
+ className
392
+ )}
393
+ {...rest}
394
+ {...triggerButtonProps}
395
+ // `useComboBox`'s own `buttonProps` defaults to a generic "Show suggestions" label,
396
+ // since its primary a11y attention goes to the search input — override with the same
397
+ // label the input gets, so the toggle (the element actually reachable via Tab, since
398
+ // the input is hidden until open) announces the field's real name, matching the
399
+ // vanilla docs' own `aria-label="Select a country"` on the toggle button.
400
+ aria-label={rest['aria-label']}
401
+ aria-labelledby={labelledBy}
402
+ aria-describedby={describedBy}
403
+ aria-invalid={invalid || undefined}
404
+ ref={triggerRef}
405
+ >
406
+ <span className={classNames('combobox-value', { 'combobox-placeholder': !hasSelection })}>
407
+ {triggerText}
408
+ </span>
409
+ </div>
410
+ {typeof window !== 'undefined' &&
411
+ createPortal(
412
+ <div
413
+ className={classNames('menu', { show: state.isOpen })}
414
+ data-cx-placement={placementAttr}
415
+ style={overlayStyle}
416
+ hidden={!state.isOpen}
417
+ ref={popoverRef}
418
+ >
419
+ <div className="combobox-search">
420
+ <input
421
+ autoComplete="off"
422
+ className="form-input combobox-search-input small"
423
+ {...inputHtmlProps}
424
+ ref={inputRef}
425
+ />
426
+ </div>
427
+ <ComboboxListBox state={state} listBoxProps={listBoxProps} listBoxRef={listBoxRef} />
428
+ {state.collection.size === 0 && (
429
+ <div className="combobox-no-results">{noResultsText}</div>
430
+ )}
431
+ </div>,
432
+ portalContainer ?? document.body
433
+ )}
434
+ {name &&
435
+ (multiple ? (
436
+ asKeyArray(state.value).map((key) => (
437
+ <input key={key} type="hidden" name={name} value={key} disabled={disabled} />
438
+ ))
439
+ ) : (
440
+ <input
441
+ type="hidden"
442
+ name={name}
443
+ value={asKeyArray(state.value)[0] ?? ''}
444
+ disabled={disabled}
445
+ />
446
+ ))}
447
+ </>
448
+ ),
449
+ help,
450
+ // No `input` id: the toggle is a `role="button"` `<div>`, not a labelable element, so
451
+ // `htmlFor` can't target it — `aria-labelledby={labelledBy}` on the toggle above (fed by
452
+ // `labelId`) is what actually associates the label, matching DatePicker/OtpInput's
453
+ // `role="group"` pattern in FORMS.md. Passing `inputId` here would point `<label for>` at
454
+ // the hidden search input instead, which sits inert until the popover opens.
455
+ ids: { feedback: feedbackId, help: helpId, label: labelId },
456
+ invalid,
457
+ invalidFeedback,
458
+ label,
459
+ valid,
460
+ validFeedback
461
+ })
462
+ }
463
+
464
+ Autocomplete.displayName = 'Autocomplete'
@@ -0,0 +1,20 @@
1
+ import { ComboboxGroupProps } from '../combobox/ComboboxGroup'
2
+
3
+ // Same shape as `ComboboxGroupProps` — see `AutocompleteItemProps` for why this is its own
4
+ // named type instead of a re-export.
5
+ export type AutocompleteGroupProps = ComboboxGroupProps
6
+
7
+ // `AutocompleteGroup` is never actually mounted — like `ComboboxGroup`, it's read as data by
8
+ // `Autocomplete`, which splits its top-level children into groups (wrapping react-stately
9
+ // `Section` nodes) and bare items. Groups and bare items can be mixed at the top level:
10
+ //
11
+ // <Autocomplete aria-label="Language">
12
+ // <AutocompleteGroup label="Frontend">
13
+ // <AutocompleteItem id="html">HTML</AutocompleteItem>
14
+ // <AutocompleteItem id="css">CSS</AutocompleteItem>
15
+ // </AutocompleteGroup>
16
+ // <AutocompleteItem id="python">Python</AutocompleteItem>
17
+ // </Autocomplete>
18
+ export const AutocompleteGroup = (_props: AutocompleteGroupProps): null => null
19
+
20
+ AutocompleteGroup.displayName = 'AutocompleteGroup'
@@ -0,0 +1,18 @@
1
+ import { ComboboxItemProps } from '../combobox/ComboboxItem'
2
+
3
+ // Same shape as `ComboboxItemProps` — kept as its own named type (rather than re-exporting
4
+ // `ComboboxItemProps` directly) so `Autocomplete`'s public API doesn't leak an unrelated
5
+ // component's prop type, even though the two are structurally identical under the hood.
6
+ export type AutocompleteItemProps = ComboboxItemProps
7
+
8
+ // `AutocompleteItem` is never actually mounted — like `ComboboxItem`, it's read as data by
9
+ // `Autocomplete`, which builds react-aria's listbox collection from each item's `id` and
10
+ // `children`. This keeps the public authoring shape as plain composed JSX:
11
+ //
12
+ // <Autocomplete aria-label="Fruit">
13
+ // <AutocompleteItem id="apple">Apple</AutocompleteItem>
14
+ // <AutocompleteItem id="banana">Banana</AutocompleteItem>
15
+ // </Autocomplete>
16
+ export const AutocompleteItem = (_props: AutocompleteItemProps): null => null
17
+
18
+ AutocompleteItem.displayName = 'AutocompleteItem'
@@ -0,0 +1,6 @@
1
+ export { Autocomplete } from './Autocomplete'
2
+ export type { AutocompleteProps } from './Autocomplete'
3
+ export { AutocompleteGroup } from './AutocompleteGroup'
4
+ export type { AutocompleteGroupProps } from './AutocompleteGroup'
5
+ export { AutocompleteItem } from './AutocompleteItem'
6
+ export type { AutocompleteItemProps } from './AutocompleteItem'