@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,15 @@
1
+ import { DateValue } from 'react-stately'
2
+
3
+ // `date.toString()` on a `CalendarDate` (the only `DateValue` variant `Calendar`/`DatePicker`
4
+ // ever produce — both are date-only, no time segments) yields the same ISO 8601 `YYYY-MM-DD`
5
+ // string already used elsewhere for form submission (see `DatePicker`'s hidden input), so
6
+ // `unavailableDates` entries are expected in that same format.
7
+ export const mergeIsDateUnavailable = (
8
+ unavailableDates: string[] | undefined,
9
+ isDateUnavailable: ((date: DateValue) => boolean) | undefined
10
+ ): ((date: DateValue) => boolean) | undefined => {
11
+ if (!unavailableDates?.length && !isDateUnavailable) return undefined
12
+
13
+ const set = unavailableDates?.length ? new Set(unavailableDates) : null
14
+ return (date) => Boolean(set?.has(date.toString())) || Boolean(isDateUnavailable?.(date))
15
+ }
@@ -0,0 +1,93 @@
1
+ import type { CSSProperties } from 'react'
2
+ import type { Placement as AriaPlacement, PlacementAxis } from 'react-aria'
3
+
4
+ // Chassis CSS's floating components (`.menu`, `.tooltip`, `.popover`) read a `data-cx-placement`
5
+ // attribute in this hyphenated, compound form (`"bottom-end"`, `"top-start"`, ...) to pick the
6
+ // right transform-origin for the open/close animation. React Aria's `useOverlayPosition` speaks a
7
+ // different, space-separated placement vocabulary (`"bottom end"`) and only reports back the
8
+ // resolved main axis after flipping, not the full compound value — this module bridges the two.
9
+ export type Placement =
10
+ | 'top'
11
+ | 'top-start'
12
+ | 'top-end'
13
+ | 'bottom'
14
+ | 'bottom-start'
15
+ | 'bottom-end'
16
+ | 'left'
17
+ | 'left-start'
18
+ | 'left-end'
19
+ | 'right'
20
+ | 'right-start'
21
+ | 'right-end'
22
+
23
+ const ARIA_PLACEMENT: Record<Placement, AriaPlacement> = {
24
+ top: 'top',
25
+ 'top-start': 'top start',
26
+ 'top-end': 'top end',
27
+ bottom: 'bottom',
28
+ 'bottom-start': 'bottom start',
29
+ 'bottom-end': 'bottom end',
30
+ left: 'left',
31
+ 'left-start': 'left top',
32
+ 'left-end': 'left bottom',
33
+ right: 'right',
34
+ 'right-start': 'right top',
35
+ 'right-end': 'right bottom'
36
+ }
37
+
38
+ // Translates chassis-react's public `placement` prop values into the strings
39
+ // `useOverlayPosition` expects.
40
+ export const toAriaPlacement = (placement: Placement): AriaPlacement => ARIA_PLACEMENT[placement]
41
+
42
+ // `useOverlayPosition`'s own `shouldFlip` only ever swaps the main axis (top<->bottom or
43
+ // left<->right) — it never changes the requested cross-axis alignment — so the resolved compound
44
+ // value is always the requested cross-axis suffix reattached to the (possibly flipped) main axis
45
+ // `useOverlayPosition` reports back.
46
+ export const resolveDataPlacement = (
47
+ requested: Placement,
48
+ resolvedMain: PlacementAxis | null
49
+ ): string => {
50
+ if (!resolvedMain || resolvedMain === 'center') return requested
51
+ const cross = requested.split('-')[1]
52
+ return cross ? `${resolvedMain}-${cross}` : resolvedMain
53
+ }
54
+
55
+ // Shared between `Menu`/`MenuSubmenu` and `Combobox`/`Autocomplete`, whose floating panels all
56
+ // position via `useOverlayPosition` the same way: only `position`/`top`/`left` are taken from the
57
+ // hook's computed style (`zIndex`/`maxHeight` stay owned by chassis-css's own `--zindex`/
58
+ // `--max-height` tokens, see `_menu.scss`), and `data-cx-placement` needs the resolved main axis
59
+ // reattached to the originally-requested cross-axis alignment. Lives here (rather than
60
+ // `components/menu/`) for the same reason `renderMenuItemContent` does — `Combobox.tsx`/
61
+ // `Autocomplete.tsx` need it too, and importing across that folder boundary from three call sites
62
+ // is worse than housing it where none of the four has to reach into a sibling's folder.
63
+ export interface MenuOverlayPositioning {
64
+ menuStyle: CSSProperties
65
+ placementAttr: string
66
+ }
67
+
68
+ export const resolveMenuOverlayPositioning = (
69
+ overlayStyle: CSSProperties | undefined,
70
+ requestedPlacement: Placement,
71
+ resolvedPlacement: PlacementAxis | null
72
+ ): MenuOverlayPositioning => ({
73
+ menuStyle: {
74
+ position: overlayStyle?.position,
75
+ top: overlayStyle?.top,
76
+ left: overlayStyle?.left
77
+ },
78
+ placementAttr: resolveDataPlacement(requestedPlacement, resolvedPlacement)
79
+ })
80
+
81
+ // chassis-css's `.combobox + .menu` sibling-selector rule (`_combobox.scss`) sets
82
+ // `--menu-max-height`/`--menu-overflow-y` from `$combobox-menu-max-height`/
83
+ // `$combobox-menu-overflow-y` (320px/auto by default) — but only while the panel is an adjacent
84
+ // DOM sibling of `.combobox`. `Autocomplete`/`Combobox` portal their panel to `document.body` (or
85
+ // an enclosing open `<dialog>`, via `useFloatingOverlay`), breaking that adjacency, so both inline
86
+ // this as a fallback matching the shipped Sass defaults instead of the generic, uncapped `.menu`
87
+ // fallback (`max-height: none`). A consumer who customized either Sass variable needs to
88
+ // re-override `--menu-max-height`/`--menu-overflow-y` on `.menu` directly, since the
89
+ // sibling-selector auto-application can no longer reach a portaled panel.
90
+ export const COMBOBOX_MENU_OVERLAY_STYLE: CSSProperties = {
91
+ '--menu-max-height': '320px',
92
+ '--menu-overflow-y': 'auto'
93
+ } as CSSProperties
@@ -0,0 +1,21 @@
1
+ // Tracks, per-element, whether its most recent activation came from a real pointer click rather
2
+ // than a keyboard one (a `click` fired by pointer activation has `detail >= 1`; one fired by
3
+ // keyboard activation, e.g. Enter/Space on a focused button, has `detail === 0`) — for callers
4
+ // that need that information later, asynchronously, once React has committed the state update the
5
+ // click triggered (see `Carousel.tsx`'s disabled-end focus redirect, which needs it to decide
6
+ // whether `focusRedirect` should suppress Safari's script-focus-after-pointer-click ring). A
7
+ // `WeakSet` rather than a DOM attribute so it doesn't leak into markup/snapshots, and consuming it
8
+ // clears the entry so a later, unrelated keyboard activation on the same element isn't
9
+ // misattributed as pointer-triggered.
10
+ const pointerClicks = new WeakSet<HTMLElement>()
11
+
12
+ export function markPointerClick(el: HTMLElement): void {
13
+ pointerClicks.add(el)
14
+ }
15
+
16
+ export function consumePointerClick(el: HTMLElement | null): boolean {
17
+ if (!el) return false
18
+ const wasPointer = pointerClicks.has(el)
19
+ pointerClicks.delete(el)
20
+ return wasPointer
21
+ }
@@ -0,0 +1,36 @@
1
+ import {
2
+ ComponentPropsWithoutRef,
3
+ ComponentPropsWithRef,
4
+ ElementType,
5
+ ForwardRefRenderFunction,
6
+ forwardRef
7
+ } from 'react'
8
+
9
+ // Ref type inferred from a polymorphic component's currently-selected `component` element type.
10
+ export type PolymorphicRef<C extends ElementType> = ComponentPropsWithRef<C>['ref']
11
+
12
+ /**
13
+ * Props for a polymorphic component: `OwnProps` (which must declare `component?: C`) plus
14
+ * whatever props `C` itself accepts, minus any name already claimed by `OwnProps` so the two
15
+ * don't conflict. Lets consumers swap `component` for e.g. a framework's `Image` and get full
16
+ * type-checking/autocomplete for that component's own props at the call site.
17
+ */
18
+ export type PolymorphicComponentProps<C extends ElementType, OwnProps extends object> = OwnProps &
19
+ Omit<ComponentPropsWithoutRef<C>, keyof OwnProps>
20
+
21
+ /**
22
+ * Wraps a polymorphic component's render function in `forwardRef` and stamps `displayName` — the
23
+ * two-step wiring every polymorphic component in this library repeats identically. `T` is the
24
+ * fully generic component type (e.g. `ButtonComponent`) that `forwardRef`'s own return type can't
25
+ * express, since `forwardRef` only accepts a non-generic render function — the caller still casts
26
+ * the render function to a concrete `Element`-typed instance before passing it in here.
27
+ */
28
+ export function createPolymorphicComponent<T>(
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ render: ForwardRefRenderFunction<Element, any>,
31
+ displayName: string
32
+ ): T {
33
+ const Component = forwardRef(render) as unknown as T & { displayName?: string }
34
+ Component.displayName = displayName
35
+ return Component
36
+ }
@@ -0,0 +1,62 @@
1
+ import React, { ReactNode } from 'react'
2
+
3
+ import { Icon } from '../components/icon/Icon'
4
+
5
+ export interface MenuItemContentDef {
6
+ /**
7
+ * Icon rendered at the item's leading edge (`.menu-item-icon`).
8
+ */
9
+ icon?: ReactNode
10
+ /**
11
+ * Label content.
12
+ */
13
+ label: ReactNode
14
+ /**
15
+ * Secondary line of text rendered below the label (`.menu-item-description`).
16
+ */
17
+ description?: ReactNode
18
+ /**
19
+ * Renders a `.menu-item-check` at the trailing edge when true.
20
+ */
21
+ selected?: boolean
22
+ /**
23
+ * Icon name for the selected-state check. Defaults to chassis-css's own doc example.
24
+ */
25
+ checkIcon?: string
26
+ }
27
+
28
+ // Chassis-css defines identical markup for rich item content in both Menu and Combobox items
29
+ // (and, by extension, Autocomplete) — `.menu-item-icon` / `.menu-item-content` (label +
30
+ // `.menu-item-description`) / `.menu-item-check`. Shared here so every consumer produces
31
+ // byte-identical DOM instead of three near-duplicate implementations. Lives in `utils/` rather
32
+ // than `components/menu/` because it crosses that folder boundary — Combobox.tsx and
33
+ // Autocomplete.tsx both import it too. Renders bare `label` when there's nothing else to show,
34
+ // keeping simple items' markup as minimal as chassis-css's own simplest example.
35
+ export const renderMenuItemContent = ({
36
+ icon,
37
+ label,
38
+ description,
39
+ selected,
40
+ checkIcon = 'check-solid'
41
+ }: MenuItemContentDef): ReactNode => {
42
+ if (icon == null && description == null && !selected) return label
43
+
44
+ return (
45
+ <>
46
+ {icon != null && (
47
+ <span className="menu-item-icon" aria-hidden="true">
48
+ {icon}
49
+ </span>
50
+ )}
51
+ {description != null ? (
52
+ <span className="menu-item-content">
53
+ <span>{label}</span>
54
+ <small className="menu-item-description">{description}</small>
55
+ </span>
56
+ ) : (
57
+ label
58
+ )}
59
+ {selected && <Icon name={checkIcon} className="menu-item-check" />}
60
+ </>
61
+ )
62
+ }
@@ -0,0 +1,64 @@
1
+ import { flushSync } from 'react-dom'
2
+ import { CalendarState, RangeCalendarState } from 'react-stately'
3
+ import { CalendarDate } from '@internationalized/date'
4
+
5
+ // Moves a (possibly multi-month) calendar's visible range so it starts at `desiredStart` — used by
6
+ // `CalendarMonthYearPicker` to jump straight to a picked month/year, in any visible block.
7
+ //
8
+ // `state.setFocusedDate` is the only way to move `visibleRange` from outside react-stately, but it
9
+ // doesn't just set `visibleRange.start` to wherever focus goes: react-stately keeps the visible
10
+ // range as separate internal state, only repositioning it when the newly focused date falls
11
+ // entirely *before* the current range (realigning so the range *ends* at that date's month) or
12
+ // entirely *after* it (realigning so it *starts* there) — a focused date that lands inside the
13
+ // current range moves focus without paging the range at all. That means a single `setFocusedDate`
14
+ // call can only land exactly on an arbitrary `desiredStart` when it's at least a full
15
+ // `visibleDuration` away from the current range. Anything closer — the common case here, since
16
+ // adjacent blocks in a multi-month calendar are only one month apart — either no-ops (desired
17
+ // start still inside the current range) or overshoots by up to a full `visibleDuration` (desired
18
+ // start just outside it, but not far enough for the realignment to land exactly).
19
+ //
20
+ // Worked around with two `setFocusedDate` hops when a single one can't reach exactly: first past
21
+ // `desiredStart` by one extra `visibleDuration` in the same direction (always far enough to land
22
+ // exactly, by the same rule), flushed synchronously so `visibleRange` actually updates before the
23
+ // second call runs, then back onto `desiredStart` itself — which, relative to that intermediate
24
+ // range, is now exactly one `visibleDuration` away and so lands exactly too.
25
+ export const setVisibleRangeStart = (
26
+ state: CalendarState<'single' | 'multiple'> | RangeCalendarState,
27
+ desiredStart: CalendarDate
28
+ ): void => {
29
+ const { start: currentStart, end: currentEnd } = state.visibleRange
30
+ if (desiredStart.compare(currentStart) === 0) return
31
+
32
+ const durationMonths = state.visibleDuration.months || 1
33
+ const isForward = desiredStart.compare(currentStart) > 0
34
+
35
+ const reachableInOneHop = isForward
36
+ ? desiredStart.compare(currentEnd) > 0
37
+ : desiredStart.add({ months: durationMonths - 1 }).compare(currentStart) < 0
38
+
39
+ if (reachableInOneHop) {
40
+ state.setFocusedDate(focusedDateForStart(desiredStart, isForward, durationMonths))
41
+ return
42
+ }
43
+
44
+ const overshootStart = isForward
45
+ ? desiredStart.add({ months: durationMonths })
46
+ : desiredStart.subtract({ months: durationMonths })
47
+
48
+ flushSync(() => {
49
+ state.setFocusedDate(focusedDateForStart(overshootStart, isForward, durationMonths))
50
+ })
51
+ // Relative to the now-updated range, `desiredStart` sits exactly one `visibleDuration` back the
52
+ // way we came — always far enough for a single hop to land exactly.
53
+ state.setFocusedDate(focusedDateForStart(desiredStart, !isForward, durationMonths))
54
+ }
55
+
56
+ // react-stately's forward-paging realignment anchors the new range to whatever month
57
+ // `focusedDate` itself falls in; its backward-paging one anchors it `visibleDuration - 1` months
58
+ // *before* `focusedDate`'s month instead — so landing on a specific desired start takes a
59
+ // different `focusedDate` depending on which of the two is going to fire.
60
+ const focusedDateForStart = (
61
+ desiredStart: CalendarDate,
62
+ isForward: boolean,
63
+ durationMonths: number
64
+ ): CalendarDate => (isForward ? desiredStart : desiredStart.add({ months: durationMonths - 1 }))
@@ -0,0 +1,14 @@
1
+ import { Spacing } from '../types'
2
+
3
+ // A spacing prop (`gap`/`rowGap`/`gutter`/...) is typed `Spacing | 0`, but `0` is meaningful
4
+ // (chassis-css's `gap-0` etc. exist) while `undefined`/`false`-ish values mean "not set" — a
5
+ // plain truthy check would drop `0`, so every caller needs this same `typeof` guard instead.
6
+ // Shared by `Flex`, `Stack`, `Row`, and `CardBody`'s own `layoutClassNames`/`_className` builders,
7
+ // which otherwise duplicated it once per spacing prop (`gap`, `rowGap`, `columnGap`, `gutter`,
8
+ // `gutterX`, `gutterY`, ...).
9
+ export const spacingClassName = (
10
+ base: string,
11
+ value: Spacing | 0 | undefined,
12
+ prefix = ''
13
+ ): string | null =>
14
+ typeof value === 'string' || typeof value === 'number' ? `${prefix}${base}-${value}` : null
@@ -0,0 +1,69 @@
1
+ // Chromium/WebKit's `:focus-visible` heuristic has no interaction history to consult on the very
2
+ // first focus event of a page load, so a genuine pointer press can still paint a focus ring as if
3
+ // it came from the keyboard — most visible on whatever element happens to be the first thing a
4
+ // visitor clicks. A single capture-phase `pointerdown` listener on `document`, installed once (see
5
+ // `install` below, called for its side effect from `index.ts`), preemptively suppresses the
6
+ // outline on whatever focusable element the press is about to focus — before the browser's own
7
+ // default action grants focus and paints the ring for it — then restores it on blur. Safe to run
8
+ // on every press, not just the page's first: if the browser was already going to correctly
9
+ // withhold the ring, this suppress/restore cycle is a no-op within the same frame.
10
+ //
11
+ // Mirrors react-aria's own `@react-aria/interactions` global modality tracking (one document-level
12
+ // listener rather than one per component/instance) without switching this library's focus rings
13
+ // from chassis-css's native `:focus-visible` CSS to JS-driven state — react-aria's `isFocusVisible`
14
+ // is consumed by applying a class/attribute, which would fork focus-ring styling from the vanilla,
15
+ // non-React product chassis-css also ships. Deliberately unscoped to this library's own markup, for
16
+ // the same reason react-aria's own fix is unscoped: the browser bug isn't specific to any one
17
+ // component library, and a page generally wants one consistent focus-ring policy for every
18
+ // focusable element on it, not just the subset a component library happens to own.
19
+ const FOCUSABLE_SELECTOR = 'a[href], button, input, select, textarea, [tabindex]'
20
+
21
+ const suppressed = new WeakSet<HTMLElement>()
22
+
23
+ // Shared by the global `pointerdown` listener below and by call sites that move focus themselves
24
+ // via script (e.g. `RangeCalendar`'s hover-to-preview-a-range, which calls `element.focus()` from
25
+ // a `pointerenter` handler — outside the `pointerdown` this module otherwise listens for).
26
+ export function suppressFocusRing(el: HTMLElement): void {
27
+ // chassis-css's `.form-input` placeholder deliberately grants its focus ring on `:focus-within`
28
+ // as well as `:focus-visible` (see `_form.scss`), unlike the `:focus-visible`-only policy this
29
+ // module otherwise mirrors for buttons/links/checks/etc. — a form field is meant to show its
30
+ // ring regardless of whether the focus came from a click or the keyboard, so the very
31
+ // first-press-of-page-load quirk this module works around isn't a bug for `.form-input` in the
32
+ // first place: forcing its outline off here would fight, then correctly-but-uselessly restore,
33
+ // an outline chassis-css wants visible the whole time the field is focused.
34
+ if (el.classList.contains('form-input')) return
35
+ // `suppressed` guards re-entrancy: a second call for the same element before it blurs (e.g. a
36
+ // rapid re-press, or the pointer re-entering a cell) would otherwise stack a second blur
37
+ // listener that clobbers the first restoration and leaves the ring permanently suppressed.
38
+ if (suppressed.has(el)) return
39
+
40
+ suppressed.add(el)
41
+ const prevOutline = el.style.outline
42
+ el.style.outline = 'none'
43
+ el.addEventListener(
44
+ 'blur',
45
+ () => {
46
+ el.style.outline = prevOutline
47
+ suppressed.delete(el)
48
+ },
49
+ { once: true }
50
+ )
51
+ }
52
+
53
+ function handlePointerDown(event: PointerEvent) {
54
+ const target = event.target
55
+ if (!(target instanceof Element)) return
56
+ const el = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
57
+ if (!el) return
58
+ suppressFocusRing(el)
59
+ }
60
+
61
+ let installed = false
62
+
63
+ export function install(): void {
64
+ if (installed || typeof document === 'undefined') return
65
+ installed = true
66
+ document.addEventListener('pointerdown', handlePointerDown, true)
67
+ }
68
+
69
+ install()
@@ -0,0 +1,8 @@
1
+ // Chassis-css's validation-state classes, shared by every form control that supports
2
+ // `invalid`/`valid` (Radio, Switch, RangeInput, Select, TextInput, Textarea). Returns a
3
+ // `classNames`-compatible object rather than a resolved string so callers can still combine it
4
+ // with their own base/size classes and `className` in one `classNames(...)` call.
5
+ export const validationClassName = (invalid?: boolean, valid?: boolean) => ({
6
+ 'is-invalid': invalid,
7
+ 'is-valid': valid
8
+ })
@@ -0,0 +1,16 @@
1
+ import { CSSProperties } from 'react'
2
+
3
+ // Options in a virtually-focused listbox (react-aria's combobox/listbox patterns) never receive
4
+ // real DOM focus — focus stays on the trigger (a text input or button) while the highlighted
5
+ // option is tracked virtually, so `:hover`/`:focus-visible` can't style it. To indicate the
6
+ // highlight without touching chassis-css, we reuse its own hover color tokens (already defined
7
+ // on the ancestor `.menu`, prefixed `--cx-` by chassis-css's build) as inline overrides instead
8
+ // of relying on `.active`, which chassis-css reserves for the pressed/`:active` look.
9
+ export const virtualFocusStyle: CSSProperties = {
10
+ '--cx-icon-color': 'var(--cx-item-hover-icon-color)',
11
+ '--cx-item-fg-color': 'var(--cx-item-hover-fg-color)',
12
+ '--cx-item-bg-color': 'var(--cx-item-hover-bg-color)'
13
+ } as CSSProperties
14
+
15
+ export const getVirtualFocusStyle = (isFocused: boolean): CSSProperties | undefined =>
16
+ isFocused ? virtualFocusStyle : undefined