@primitiv-ui/react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -0
- package/package.json +59 -0
- package/src/AccessibleIcon/AccessibleIcon.tsx +40 -0
- package/src/AccessibleIcon/README.md +42 -0
- package/src/AccessibleIcon/__tests__/AccessibleIcon.test.tsx +47 -0
- package/src/AccessibleIcon/index.ts +2 -0
- package/src/AccessibleIcon/types.ts +8 -0
- package/src/Accordion/Accordion.tsx +412 -0
- package/src/Accordion/AccordionContext.ts +12 -0
- package/src/Accordion/README.md +202 -0
- package/src/Accordion/__tests__/Accordion.asChild.test.tsx +237 -0
- package/src/Accordion/__tests__/Accordion.basic-rendering.test.tsx +333 -0
- package/src/Accordion/__tests__/Accordion.controlled-state.test.tsx +175 -0
- package/src/Accordion/__tests__/Accordion.data-attributes.test.tsx +272 -0
- package/src/Accordion/__tests__/Accordion.disabled-items.test.tsx +311 -0
- package/src/Accordion/__tests__/Accordion.error-handling.test.tsx +119 -0
- package/src/Accordion/__tests__/Accordion.forceMount.test.tsx +119 -0
- package/src/Accordion/__tests__/Accordion.keyboard-interaction.test.tsx +736 -0
- package/src/Accordion/__tests__/Accordion.mouse-interaction.test.tsx +212 -0
- package/src/Accordion/__tests__/Accordion.multiple-mode.test.tsx +90 -0
- package/src/Accordion/__tests__/Accordion.reading-direction.test.tsx +139 -0
- package/src/Accordion/__tests__/Accordion.uncontrolled-state.test.tsx +154 -0
- package/src/Accordion/hooks/index.ts +6 -0
- package/src/Accordion/hooks/useAccordionContext.ts +1 -0
- package/src/Accordion/hooks/useAccordionHeaderContext.ts +10 -0
- package/src/Accordion/hooks/useAccordionItem.ts +22 -0
- package/src/Accordion/hooks/useAccordionItemContext.ts +1 -0
- package/src/Accordion/hooks/useAccordionRoot.ts +151 -0
- package/src/Accordion/hooks/useAccordionTrigger.ts +90 -0
- package/src/Accordion/index.ts +1 -0
- package/src/Accordion/types.ts +81 -0
- package/src/Alert/Alert.tsx +43 -0
- package/src/Alert/README.md +54 -0
- package/src/Alert/__tests__/Alert.test.tsx +28 -0
- package/src/Alert/index.ts +2 -0
- package/src/Alert/types.ts +5 -0
- package/src/Avatar/Avatar.tsx +149 -0
- package/src/Avatar/AvatarContext.ts +20 -0
- package/src/Avatar/README.md +116 -0
- package/src/Avatar/__tests__/Avatar.asChild.test.tsx +53 -0
- package/src/Avatar/__tests__/Avatar.basic-rendering.test.tsx +14 -0
- package/src/Avatar/__tests__/Avatar.error-handling.test.tsx +30 -0
- package/src/Avatar/__tests__/Avatar.fallback.test.tsx +75 -0
- package/src/Avatar/__tests__/Avatar.image-loading.test.tsx +81 -0
- package/src/Avatar/hooks/index.ts +2 -0
- package/src/Avatar/hooks/useAvatarContext.ts +1 -0
- package/src/Avatar/hooks/useAvatarImage.ts +40 -0
- package/src/Avatar/index.ts +3 -0
- package/src/Avatar/types.ts +44 -0
- package/src/Breadcrumb/Breadcrumb.tsx +234 -0
- package/src/Breadcrumb/README.md +111 -0
- package/src/Breadcrumb/__tests__/Breadcrumb.asChild.test.tsx +33 -0
- package/src/Breadcrumb/__tests__/Breadcrumb.basic-rendering.test.tsx +132 -0
- package/src/Breadcrumb/index.ts +2 -0
- package/src/Breadcrumb/types.ts +22 -0
- package/src/Button/Button.tsx +95 -0
- package/src/Button/README.md +112 -0
- package/src/Button/__tests__/Button.asChild.test.tsx +91 -0
- package/src/Button/__tests__/Button.basic-rendering.test.tsx +126 -0
- package/src/Button/__tests__/Button.contract.test.tsx +72 -0
- package/src/Button/__tests__/Button.disabled.test.tsx +52 -0
- package/src/Button/__tests__/Button.icon-usage.test.tsx +57 -0
- package/src/Button/__tests__/Button.keyboard-interaction.test.tsx +70 -0
- package/src/Button/index.ts +2 -0
- package/src/Button/types.ts +8 -0
- package/src/Carousel/Carousel.tsx +708 -0
- package/src/Carousel/CarouselContext.ts +11 -0
- package/src/Carousel/README.md +848 -0
- package/src/Carousel/__tests__/Carousel.asChild.test.tsx +178 -0
- package/src/Carousel/__tests__/Carousel.auto-play.test.tsx +617 -0
- package/src/Carousel/__tests__/Carousel.basic-rendering.test.tsx +569 -0
- package/src/Carousel/__tests__/Carousel.controlled-state.test.tsx +137 -0
- package/src/Carousel/__tests__/Carousel.error-handling.test.tsx +81 -0
- package/src/Carousel/__tests__/Carousel.ids.test.tsx +111 -0
- package/src/Carousel/__tests__/Carousel.imperative-api.test.tsx +213 -0
- package/src/Carousel/__tests__/Carousel.indicators.test.tsx +560 -0
- package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +276 -0
- package/src/Carousel/__tests__/Carousel.keyboard-navigation.test.tsx +158 -0
- package/src/Carousel/__tests__/Carousel.play-pause.test.tsx +232 -0
- package/src/Carousel/__tests__/Carousel.prev-next.test.tsx +68 -0
- package/src/Carousel/__tests__/Carousel.reduced-motion.test.tsx +49 -0
- package/src/Carousel/__tests__/Carousel.refresh-progress.test.tsx +87 -0
- package/src/Carousel/__tests__/Carousel.scroll-snap-change.test.tsx +179 -0
- package/src/Carousel/__tests__/Carousel.scroll-sync.test.tsx +109 -0
- package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +151 -0
- package/src/Carousel/__tests__/Carousel.slides-per-page.test.tsx +183 -0
- package/src/Carousel/__tests__/Carousel.touch-interaction.test.tsx +96 -0
- package/src/Carousel/__tests__/Carousel.transition-modes.test.tsx +70 -0
- package/src/Carousel/__tests__/Carousel.translations.test.tsx +157 -0
- package/src/Carousel/__tests__/Carousel.uncontrolled-state.test.tsx +146 -0
- package/src/Carousel/hooks/index.ts +4 -0
- package/src/Carousel/hooks/useCarouselContext.ts +13 -0
- package/src/Carousel/hooks/useCarouselRoot.ts +450 -0
- package/src/Carousel/hooks/useCarouselSlide.ts +45 -0
- package/src/Carousel/hooks/useCarouselViewport.ts +290 -0
- package/src/Carousel/index.ts +3 -0
- package/src/Carousel/types.ts +400 -0
- package/src/Checkbox/Checkbox.tsx +228 -0
- package/src/Checkbox/CheckboxContext.ts +12 -0
- package/src/Checkbox/README.md +156 -0
- package/src/Checkbox/__tests__/Checkbox.asChild.test.tsx +69 -0
- package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +41 -0
- package/src/Checkbox/__tests__/Checkbox.controlled-state.test.tsx +82 -0
- package/src/Checkbox/__tests__/Checkbox.disabled.test.tsx +15 -0
- package/src/Checkbox/__tests__/Checkbox.indeterminate.test.tsx +82 -0
- package/src/Checkbox/__tests__/Checkbox.indicator.test.tsx +117 -0
- package/src/Checkbox/__tests__/Checkbox.uncontrolled-state.test.tsx +89 -0
- package/src/Checkbox/hooks/index.ts +2 -0
- package/src/Checkbox/hooks/useCheckboxContext.ts +1 -0
- package/src/Checkbox/hooks/useCheckboxRoot.ts +32 -0
- package/src/Checkbox/index.ts +1 -0
- package/src/Checkbox/types.ts +33 -0
- package/src/CheckboxCard/CheckboxCard.tsx +208 -0
- package/src/CheckboxCard/CheckboxCardContext.ts +12 -0
- package/src/CheckboxCard/README.md +114 -0
- package/src/CheckboxCard/__tests__/CheckboxCard.asChild.test.tsx +54 -0
- package/src/CheckboxCard/__tests__/CheckboxCard.basic-rendering.test.tsx +58 -0
- package/src/CheckboxCard/__tests__/CheckboxCard.controlled-state.test.tsx +77 -0
- package/src/CheckboxCard/__tests__/CheckboxCard.disabled.test.tsx +55 -0
- package/src/CheckboxCard/__tests__/CheckboxCard.error-handling.test.tsx +20 -0
- package/src/CheckboxCard/__tests__/CheckboxCard.indeterminate.test.tsx +60 -0
- package/src/CheckboxCard/__tests__/CheckboxCard.indicator.test.tsx +136 -0
- package/src/CheckboxCard/__tests__/CheckboxCard.uncontrolled-state.test.tsx +73 -0
- package/src/CheckboxCard/hooks/index.ts +2 -0
- package/src/CheckboxCard/hooks/useCheckboxCardContext.ts +1 -0
- package/src/CheckboxCard/hooks/useCheckboxCardRoot.ts +30 -0
- package/src/CheckboxCard/index.ts +3 -0
- package/src/CheckboxCard/types.ts +33 -0
- package/src/Collapsible/Collapsible.tsx +316 -0
- package/src/Collapsible/CollapsibleContext.ts +7 -0
- package/src/Collapsible/README.md +174 -0
- package/src/Collapsible/__tests__/Collapsible.asChild.test.tsx +240 -0
- package/src/Collapsible/__tests__/Collapsible.basic-rendering.test.tsx +118 -0
- package/src/Collapsible/__tests__/Collapsible.controlled-state.test.tsx +134 -0
- package/src/Collapsible/__tests__/Collapsible.disabled.test.tsx +132 -0
- package/src/Collapsible/__tests__/Collapsible.error-handling.test.tsx +40 -0
- package/src/Collapsible/__tests__/Collapsible.forceMount.test.tsx +111 -0
- package/src/Collapsible/__tests__/Collapsible.triggerIcon.test.tsx +93 -0
- package/src/Collapsible/__tests__/Collapsible.uncontrolled-state.test.tsx +125 -0
- package/src/Collapsible/hooks/index.ts +2 -0
- package/src/Collapsible/hooks/useCollapsibleRoot.ts +34 -0
- package/src/Collapsible/hooks/useCollapsibleTrigger.ts +49 -0
- package/src/Collapsible/index.ts +1 -0
- package/src/Collapsible/types.ts +48 -0
- package/src/ContextMenu/ContextMenu.tsx +1004 -0
- package/src/ContextMenu/ContextMenuContentContext.ts +15 -0
- package/src/ContextMenu/ContextMenuContext.ts +21 -0
- package/src/ContextMenu/ContextMenuGroupContext.ts +8 -0
- package/src/ContextMenu/ContextMenuItemIndicatorContext.ts +8 -0
- package/src/ContextMenu/ContextMenuRadioGroupContext.ts +9 -0
- package/src/ContextMenu/ContextMenuSubContext.ts +15 -0
- package/src/ContextMenu/README.md +275 -0
- package/src/ContextMenu/__tests__/ContextMenu.asChild.test.tsx +186 -0
- package/src/ContextMenu/__tests__/ContextMenu.basic-rendering.test.tsx +39 -0
- package/src/ContextMenu/__tests__/ContextMenu.checkbox-item.test.tsx +145 -0
- package/src/ContextMenu/__tests__/ContextMenu.error-handling.test.tsx +113 -0
- package/src/ContextMenu/__tests__/ContextMenu.group-label.test.tsx +48 -0
- package/src/ContextMenu/__tests__/ContextMenu.item-indicator.test.tsx +88 -0
- package/src/ContextMenu/__tests__/ContextMenu.item.test.tsx +106 -0
- package/src/ContextMenu/__tests__/ContextMenu.keyboard-interaction.test.tsx +172 -0
- package/src/ContextMenu/__tests__/ContextMenu.mouse-interaction.test.tsx +227 -0
- package/src/ContextMenu/__tests__/ContextMenu.radio-item.test.tsx +127 -0
- package/src/ContextMenu/__tests__/ContextMenu.reading-direction.test.tsx +152 -0
- package/src/ContextMenu/__tests__/ContextMenu.separator.test.tsx +47 -0
- package/src/ContextMenu/__tests__/ContextMenu.state-modes.test.tsx +119 -0
- package/src/ContextMenu/__tests__/ContextMenu.sub.test.tsx +262 -0
- package/src/ContextMenu/__tests__/ContextMenu.typeahead.test.tsx +89 -0
- package/src/ContextMenu/constants.ts +4 -0
- package/src/ContextMenu/index.ts +1 -0
- package/src/ContextMenu/types.ts +199 -0
- package/src/DirectionProvider/DirectionContext.ts +21 -0
- package/src/DirectionProvider/DirectionProvider.tsx +31 -0
- package/src/DirectionProvider/README.md +62 -0
- package/src/DirectionProvider/__tests__/DirectionProvider.test.tsx +29 -0
- package/src/DirectionProvider/index.ts +3 -0
- package/src/DirectionProvider/types.ts +10 -0
- package/src/Divider/Divider.tsx +57 -0
- package/src/Divider/README.md +57 -0
- package/src/Divider/__tests__/Divider.test.tsx +41 -0
- package/src/Divider/index.ts +1 -0
- package/src/Divider/types.ts +5 -0
- package/src/Dropdown/Dropdown.tsx +842 -0
- package/src/Dropdown/DropdownContentContext.ts +15 -0
- package/src/Dropdown/DropdownContext.ts +17 -0
- package/src/Dropdown/DropdownGroupContext.ts +8 -0
- package/src/Dropdown/DropdownItemIndicatorContext.ts +13 -0
- package/src/Dropdown/DropdownRadioGroupContext.ts +9 -0
- package/src/Dropdown/DropdownSubContext.ts +15 -0
- package/src/Dropdown/README.md +284 -0
- package/src/Dropdown/__tests__/Dropdown.asChild.test.tsx +286 -0
- package/src/Dropdown/__tests__/Dropdown.basic-rendering.test.tsx +43 -0
- package/src/Dropdown/__tests__/Dropdown.checkbox-item.test.tsx +121 -0
- package/src/Dropdown/__tests__/Dropdown.disabled.test.tsx +143 -0
- package/src/Dropdown/__tests__/Dropdown.error-handling.test.tsx +85 -0
- package/src/Dropdown/__tests__/Dropdown.group-label.test.tsx +68 -0
- package/src/Dropdown/__tests__/Dropdown.item-indicator.test.tsx +260 -0
- package/src/Dropdown/__tests__/Dropdown.item.test.tsx +72 -0
- package/src/Dropdown/__tests__/Dropdown.keyboard-edge-cases.test.tsx +77 -0
- package/src/Dropdown/__tests__/Dropdown.keyboard-interaction.test.tsx +310 -0
- package/src/Dropdown/__tests__/Dropdown.mouse-interaction.test.tsx +347 -0
- package/src/Dropdown/__tests__/Dropdown.radio-item.test.tsx +134 -0
- package/src/Dropdown/__tests__/Dropdown.reading-direction.test.tsx +153 -0
- package/src/Dropdown/__tests__/Dropdown.separator.test.tsx +46 -0
- package/src/Dropdown/__tests__/Dropdown.state-modes.test.tsx +100 -0
- package/src/Dropdown/__tests__/Dropdown.sub.test.tsx +185 -0
- package/src/Dropdown/__tests__/Dropdown.trigger.test.tsx +110 -0
- package/src/Dropdown/__tests__/Dropdown.typeahead.test.tsx +133 -0
- package/src/Dropdown/constants.ts +4 -0
- package/src/Dropdown/hooks/index.ts +9 -0
- package/src/Dropdown/hooks/useCloseSiblingSub.ts +13 -0
- package/src/Dropdown/hooks/useDropdownContent.ts +162 -0
- package/src/Dropdown/hooks/useDropdownContext.ts +1 -0
- package/src/Dropdown/hooks/useDropdownGroup.ts +18 -0
- package/src/Dropdown/hooks/useDropdownItem.ts +49 -0
- package/src/Dropdown/hooks/useDropdownLabel.ts +15 -0
- package/src/Dropdown/hooks/useDropdownRoot.ts +57 -0
- package/src/Dropdown/hooks/useDropdownSubContext.ts +1 -0
- package/src/Dropdown/hooks/useDropdownTrigger.ts +31 -0
- package/src/Dropdown/index.ts +1 -0
- package/src/Dropdown/types.ts +200 -0
- package/src/EmptyState/EmptyState.tsx +245 -0
- package/src/EmptyState/README.md +129 -0
- package/src/EmptyState/__tests__/EmptyState.Actions.test.tsx +32 -0
- package/src/EmptyState/__tests__/EmptyState.Description.test.tsx +30 -0
- package/src/EmptyState/__tests__/EmptyState.Media.test.tsx +34 -0
- package/src/EmptyState/__tests__/EmptyState.Root.test.tsx +28 -0
- package/src/EmptyState/__tests__/EmptyState.Title.test.tsx +26 -0
- package/src/EmptyState/index.ts +2 -0
- package/src/EmptyState/types.ts +21 -0
- package/src/Field/Field.tsx +239 -0
- package/src/Field/FieldContext.ts +22 -0
- package/src/Field/README.md +167 -0
- package/src/Field/__tests__/Field.asChild.test.tsx +83 -0
- package/src/Field/__tests__/Field.basic-rendering.test.tsx +104 -0
- package/src/Field/__tests__/Field.state-cascade.test.tsx +75 -0
- package/src/Field/hooks/index.ts +2 -0
- package/src/Field/hooks/useFieldContext.ts +1 -0
- package/src/Field/hooks/useFieldProps.ts +57 -0
- package/src/Field/index.ts +2 -0
- package/src/Field/types.ts +33 -0
- package/src/Fieldset/Fieldset.tsx +104 -0
- package/src/Fieldset/README.md +74 -0
- package/src/Fieldset/__tests__/Fieldset.basic-rendering.test.tsx +81 -0
- package/src/Fieldset/__tests__/Fieldset.disabled.test.tsx +41 -0
- package/src/Fieldset/index.ts +2 -0
- package/src/Fieldset/types.ts +5 -0
- package/src/Input/Input.tsx +120 -0
- package/src/Input/README.md +180 -0
- package/src/Input/__tests__/Input.asChild.test.tsx +85 -0
- package/src/Input/__tests__/Input.basic-rendering.test.tsx +118 -0
- package/src/Input/__tests__/Input.disabled.test.tsx +49 -0
- package/src/Input/__tests__/Input.field-integration.test.tsx +148 -0
- package/src/Input/index.ts +2 -0
- package/src/Input/types.ts +7 -0
- package/src/InputGroup/InputGroup.tsx +228 -0
- package/src/InputGroup/README.md +178 -0
- package/src/InputGroup/__tests__/InputGroup.asChild.test.tsx +109 -0
- package/src/InputGroup/__tests__/InputGroup.basic-rendering.test.tsx +106 -0
- package/src/InputGroup/index.ts +2 -0
- package/src/InputGroup/types.ts +13 -0
- package/src/MillerColumns/MillerColumns.tsx +329 -0
- package/src/MillerColumns/MillerColumnsContext.ts +25 -0
- package/src/MillerColumns/README.md +278 -0
- package/src/MillerColumns/__tests__/MillerColumns.aria.test.tsx +82 -0
- package/src/MillerColumns/__tests__/MillerColumns.asChild.test.tsx +106 -0
- package/src/MillerColumns/__tests__/MillerColumns.auto-scroll.test.tsx +68 -0
- package/src/MillerColumns/__tests__/MillerColumns.basic-rendering.test.tsx +52 -0
- package/src/MillerColumns/__tests__/MillerColumns.column-projection.test.tsx +161 -0
- package/src/MillerColumns/__tests__/MillerColumns.controlled-state.test.tsx +90 -0
- package/src/MillerColumns/__tests__/MillerColumns.data-attributes.test.tsx +77 -0
- package/src/MillerColumns/__tests__/MillerColumns.disabled-items.test.tsx +65 -0
- package/src/MillerColumns/__tests__/MillerColumns.error-handling.test.tsx +57 -0
- package/src/MillerColumns/__tests__/MillerColumns.fixtures.ts +15 -0
- package/src/MillerColumns/__tests__/MillerColumns.item-indicator.test.tsx +57 -0
- package/src/MillerColumns/__tests__/MillerColumns.keyboard-interaction.test.tsx +181 -0
- package/src/MillerColumns/__tests__/MillerColumns.preview-panel.test.tsx +47 -0
- package/src/MillerColumns/__tests__/MillerColumns.resize.test.tsx +137 -0
- package/src/MillerColumns/__tests__/MillerColumns.roving-tabindex.test.tsx +91 -0
- package/src/MillerColumns/__tests__/MillerColumns.selection.test.tsx +54 -0
- package/src/MillerColumns/__tests__/MillerColumns.uncontrolled-state.test.tsx +70 -0
- package/src/MillerColumns/hooks/index.ts +7 -0
- package/src/MillerColumns/hooks/useMillerColumnsColumn.ts +23 -0
- package/src/MillerColumns/hooks/useMillerColumnsColumnContext.ts +1 -0
- package/src/MillerColumns/hooks/useMillerColumnsContext.ts +1 -0
- package/src/MillerColumns/hooks/useMillerColumnsItem.ts +157 -0
- package/src/MillerColumns/hooks/useMillerColumnsItemContext.ts +1 -0
- package/src/MillerColumns/hooks/useMillerColumnsResizeHandle.ts +76 -0
- package/src/MillerColumns/hooks/useMillerColumnsRoot.ts +0 -0
- package/src/MillerColumns/index.ts +3 -0
- package/src/MillerColumns/types.ts +93 -0
- package/src/MillerColumns/useMillerColumnsSelection.ts +31 -0
- package/src/MillerColumns/utils.ts +75 -0
- package/src/Modal/Modal.tsx +474 -0
- package/src/Modal/ModalContext.ts +13 -0
- package/src/Modal/README.md +207 -0
- package/src/Modal/__tests__/Modal.accessibility.test.tsx +167 -0
- package/src/Modal/__tests__/Modal.asChild.test.tsx +162 -0
- package/src/Modal/__tests__/Modal.click-outside.test.tsx +115 -0
- package/src/Modal/__tests__/Modal.content.test.tsx +193 -0
- package/src/Modal/__tests__/Modal.controlled-state.test.tsx +120 -0
- package/src/Modal/__tests__/Modal.error-handling.test.tsx +30 -0
- package/src/Modal/__tests__/Modal.escape-hatches.test.tsx +99 -0
- package/src/Modal/__tests__/Modal.imperative-api.test.tsx +119 -0
- package/src/Modal/__tests__/Modal.nested.test.tsx +106 -0
- package/src/Modal/__tests__/Modal.overlay.test.tsx +99 -0
- package/src/Modal/__tests__/Modal.portal.test.tsx +90 -0
- package/src/Modal/__tests__/Modal.presence.test.tsx +111 -0
- package/src/Modal/__tests__/Modal.trigger.test.tsx +70 -0
- package/src/Modal/__tests__/Modal.uncontrolled-state.test.tsx +72 -0
- package/src/Modal/__tests__/dialog-polyfill.ts +40 -0
- package/src/Modal/hooks/index.ts +4 -0
- package/src/Modal/hooks/useModalContent.ts +62 -0
- package/src/Modal/hooks/useModalContext.ts +1 -0
- package/src/Modal/hooks/useModalRoot.ts +81 -0
- package/src/Modal/hooks/useModalTrigger.ts +25 -0
- package/src/Modal/index.ts +3 -0
- package/src/Modal/types.ts +76 -0
- package/src/Portal/Portal.tsx +28 -0
- package/src/Portal/README.md +70 -0
- package/src/Portal/__tests__/Portal.basic-rendering.test.tsx +17 -0
- package/src/Portal/index.ts +2 -0
- package/src/Portal/types.ts +6 -0
- package/src/Progress/Progress.tsx +178 -0
- package/src/Progress/ProgressContext.ts +15 -0
- package/src/Progress/README.md +112 -0
- package/src/Progress/__tests__/Progress.asChild.test.tsx +37 -0
- package/src/Progress/__tests__/Progress.basic-rendering.test.tsx +65 -0
- package/src/Progress/__tests__/Progress.error-handling.test.tsx +40 -0
- package/src/Progress/__tests__/Progress.fixtures.ts +7 -0
- package/src/Progress/__tests__/Progress.value.test.tsx +83 -0
- package/src/Progress/hooks/index.ts +2 -0
- package/src/Progress/hooks/useProgressContext.ts +1 -0
- package/src/Progress/hooks/useProgressRoot.ts +45 -0
- package/src/Progress/index.ts +3 -0
- package/src/Progress/types.ts +43 -0
- package/src/RadioCard/README.md +133 -0
- package/src/RadioCard/RadioCard.tsx +334 -0
- package/src/RadioCard/RadioCardContext.ts +23 -0
- package/src/RadioCard/RadioCardItemContext.ts +10 -0
- package/src/RadioCard/__tests__/RadioCard.asChild.test.tsx +76 -0
- package/src/RadioCard/__tests__/RadioCard.basic-rendering.test.tsx +87 -0
- package/src/RadioCard/__tests__/RadioCard.controlled-state.test.tsx +107 -0
- package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +61 -0
- package/src/RadioCard/__tests__/RadioCard.error-handling.test.tsx +35 -0
- package/src/RadioCard/__tests__/RadioCard.indicator.test.tsx +119 -0
- package/src/RadioCard/__tests__/RadioCard.keyboard-interaction.test.tsx +158 -0
- package/src/RadioCard/__tests__/RadioCard.orientation.test.tsx +90 -0
- package/src/RadioCard/__tests__/RadioCard.reading-direction.test.tsx +65 -0
- package/src/RadioCard/__tests__/RadioCard.uncontrolled-state.test.tsx +108 -0
- package/src/RadioCard/hooks/index.ts +3 -0
- package/src/RadioCard/hooks/useRadioCardContext.ts +1 -0
- package/src/RadioCard/hooks/useRadioCardItemContext.ts +1 -0
- package/src/RadioCard/hooks/useRadioCardRoot.ts +77 -0
- package/src/RadioCard/index.ts +4 -0
- package/src/RadioCard/types.ts +51 -0
- package/src/RadioGroup/README.md +185 -0
- package/src/RadioGroup/RadioGroup.tsx +353 -0
- package/src/RadioGroup/RadioGroupContext.ts +23 -0
- package/src/RadioGroup/RadioGroupItemContext.ts +10 -0
- package/src/RadioGroup/__tests__/RadioGroup.asChild.test.tsx +105 -0
- package/src/RadioGroup/__tests__/RadioGroup.basic-rendering.test.tsx +72 -0
- package/src/RadioGroup/__tests__/RadioGroup.controlled-state.test.tsx +109 -0
- package/src/RadioGroup/__tests__/RadioGroup.disabled-keydown-guards.test.tsx +68 -0
- package/src/RadioGroup/__tests__/RadioGroup.disabled.test.tsx +79 -0
- package/src/RadioGroup/__tests__/RadioGroup.error-handling.test.tsx +33 -0
- package/src/RadioGroup/__tests__/RadioGroup.indicator.test.tsx +85 -0
- package/src/RadioGroup/__tests__/RadioGroup.keyboard-interaction.test.tsx +135 -0
- package/src/RadioGroup/__tests__/RadioGroup.orientation.test.tsx +90 -0
- package/src/RadioGroup/__tests__/RadioGroup.reading-direction.test.tsx +65 -0
- package/src/RadioGroup/__tests__/RadioGroup.ref-forwarding.test.tsx +45 -0
- package/src/RadioGroup/__tests__/RadioGroup.roving-tabindex.test.tsx +105 -0
- package/src/RadioGroup/__tests__/RadioGroup.uncontrolled-state.test.tsx +96 -0
- package/src/RadioGroup/hooks/index.ts +3 -0
- package/src/RadioGroup/hooks/useRadioGroupContext.ts +1 -0
- package/src/RadioGroup/hooks/useRadioGroupItemContext.ts +1 -0
- package/src/RadioGroup/hooks/useRadioGroupRoot.ts +87 -0
- package/src/RadioGroup/index.ts +1 -0
- package/src/RadioGroup/types.ts +51 -0
- package/src/Select/README.md +203 -0
- package/src/Select/Select.tsx +204 -0
- package/src/Select/__tests__/Select.asChild.test.tsx +36 -0
- package/src/Select/__tests__/Select.basic-rendering.test.tsx +17 -0
- package/src/Select/__tests__/Select.controlled-state.test.tsx +69 -0
- package/src/Select/__tests__/Select.data-attributes.test.tsx +29 -0
- package/src/Select/__tests__/Select.field-integration.test.tsx +150 -0
- package/src/Select/__tests__/Select.group.test.tsx +42 -0
- package/src/Select/__tests__/Select.placeholder.test.tsx +32 -0
- package/src/Select/index.ts +2 -0
- package/src/Select/types.ts +89 -0
- package/src/SkipNav/README.md +87 -0
- package/src/SkipNav/SkipNav.tsx +116 -0
- package/src/SkipNav/__tests__/SkipNav.basic-rendering.test.tsx +23 -0
- package/src/SkipNav/__tests__/SkipNav.ids.test.tsx +19 -0
- package/src/SkipNav/index.ts +1 -0
- package/src/SkipNav/types.ts +26 -0
- package/src/Slider/README.md +215 -0
- package/src/Slider/Slider.tsx +308 -0
- package/src/Slider/SliderContext.ts +24 -0
- package/src/Slider/__tests__/Slider.asChild.test.tsx +119 -0
- package/src/Slider/__tests__/Slider.basic-rendering.test.tsx +157 -0
- package/src/Slider/__tests__/Slider.controlled-state.test.tsx +78 -0
- package/src/Slider/__tests__/Slider.disabled.test.tsx +82 -0
- package/src/Slider/__tests__/Slider.error-handling.test.tsx +45 -0
- package/src/Slider/__tests__/Slider.fixtures.ts +53 -0
- package/src/Slider/__tests__/Slider.form.test.tsx +67 -0
- package/src/Slider/__tests__/Slider.inverted.test.tsx +112 -0
- package/src/Slider/__tests__/Slider.keyboard-interaction.test.tsx +118 -0
- package/src/Slider/__tests__/Slider.multiple-thumbs.test.tsx +84 -0
- package/src/Slider/__tests__/Slider.orientation.test.tsx +101 -0
- package/src/Slider/__tests__/Slider.pointer-interaction.test.tsx +205 -0
- package/src/Slider/__tests__/Slider.reading-direction.test.tsx +99 -0
- package/src/Slider/__tests__/Slider.uncontrolled-state.test.tsx +69 -0
- package/src/Slider/__tests__/Slider.value-commit.test.tsx +103 -0
- package/src/Slider/hooks/index.ts +3 -0
- package/src/Slider/hooks/useSliderContext.ts +1 -0
- package/src/Slider/hooks/useSliderRoot.ts +197 -0
- package/src/Slider/hooks/useSliderThumb.ts +77 -0
- package/src/Slider/index.ts +3 -0
- package/src/Slider/types.ts +48 -0
- package/src/Slider/utils.ts +155 -0
- package/src/Slot/Slot.tsx +158 -0
- package/src/Slot/__tests__/Slot.test.tsx +163 -0
- package/src/Slot/__tests__/composeEventHandlers.test.ts +74 -0
- package/src/Slot/composeEventHandlers.ts +38 -0
- package/src/Slot/index.ts +3 -0
- package/src/Slot/types.ts +5 -0
- package/src/Status/README.md +50 -0
- package/src/Status/Status.tsx +44 -0
- package/src/Status/__tests__/Status.test.tsx +28 -0
- package/src/Status/index.ts +2 -0
- package/src/Status/types.ts +5 -0
- package/src/Switch/README.md +121 -0
- package/src/Switch/Switch.tsx +167 -0
- package/src/Switch/SwitchContext.ts +10 -0
- package/src/Switch/__tests__/Switch.asChild.test.tsx +56 -0
- package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +76 -0
- package/src/Switch/__tests__/Switch.contract.test.tsx +109 -0
- package/src/Switch/__tests__/Switch.controlled-state.test.tsx +79 -0
- package/src/Switch/__tests__/Switch.disabled.test.tsx +60 -0
- package/src/Switch/__tests__/Switch.error-handling.test.tsx +20 -0
- package/src/Switch/__tests__/Switch.keyboard-interaction.test.tsx +56 -0
- package/src/Switch/__tests__/Switch.thumb.test.tsx +84 -0
- package/src/Switch/__tests__/Switch.uncontrolled-state.test.tsx +83 -0
- package/src/Switch/hooks/index.ts +2 -0
- package/src/Switch/hooks/useSwitchContext.ts +1 -0
- package/src/Switch/hooks/useSwitchRoot.ts +28 -0
- package/src/Switch/index.ts +3 -0
- package/src/Switch/types.ts +37 -0
- package/src/Table/README.md +205 -0
- package/src/Table/Table.tsx +380 -0
- package/src/Table/__tests__/Table.Body.test.tsx +61 -0
- package/src/Table/__tests__/Table.Caption.test.tsx +70 -0
- package/src/Table/__tests__/Table.Cell.test.tsx +73 -0
- package/src/Table/__tests__/Table.Footer.test.tsx +61 -0
- package/src/Table/__tests__/Table.Head.test.tsx +61 -0
- package/src/Table/__tests__/Table.Header.test.tsx +73 -0
- package/src/Table/__tests__/Table.Root.test.tsx +49 -0
- package/src/Table/__tests__/Table.Row.test.tsx +67 -0
- package/src/Table/__tests__/Table.ScrollArea.test.tsx +83 -0
- package/src/Table/index.ts +1 -0
- package/src/Table/types.ts +63 -0
- package/src/Tabs/README.md +110 -0
- package/src/Tabs/Tabs.tsx +434 -0
- package/src/Tabs/TabsContext.ts +13 -0
- package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +114 -0
- package/src/Tabs/__tests__/Tabs.asChild.test.tsx +91 -0
- package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +483 -0
- package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +133 -0
- package/src/Tabs/__tests__/Tabs.controlled-state.test.tsx +152 -0
- package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +203 -0
- package/src/Tabs/__tests__/Tabs.error-handling.test.tsx +82 -0
- package/src/Tabs/__tests__/Tabs.fixtures.ts +171 -0
- package/src/Tabs/__tests__/Tabs.imperative-api.test.tsx +118 -0
- package/src/Tabs/__tests__/Tabs.keyboard-interaction.test.tsx +192 -0
- package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +61 -0
- package/src/Tabs/__tests__/Tabs.mouse-interaction.test.tsx +216 -0
- package/src/Tabs/__tests__/Tabs.reading-direction.test.tsx +58 -0
- package/src/Tabs/__tests__/Tabs.uncontrolled-state.test.tsx +197 -0
- package/src/Tabs/hooks/index.ts +4 -0
- package/src/Tabs/hooks/useTabsContent.ts +27 -0
- package/src/Tabs/hooks/useTabsContext.ts +1 -0
- package/src/Tabs/hooks/useTabsRoot.ts +148 -0
- package/src/Tabs/hooks/useTabsTrigger.ts +111 -0
- package/src/Tabs/index.ts +3 -0
- package/src/Tabs/types.ts +99 -0
- package/src/Tabs/utils.ts +8 -0
- package/src/Textarea/README.md +98 -0
- package/src/Textarea/Textarea.tsx +93 -0
- package/src/Textarea/__tests__/Textarea.asChild.test.tsx +85 -0
- package/src/Textarea/__tests__/Textarea.basic-rendering.test.tsx +107 -0
- package/src/Textarea/__tests__/Textarea.disabled.test.tsx +49 -0
- package/src/Textarea/__tests__/Textarea.field-integration.test.tsx +134 -0
- package/src/Textarea/index.ts +2 -0
- package/src/Textarea/types.ts +7 -0
- package/src/Toggle/README.md +97 -0
- package/src/Toggle/Toggle.tsx +81 -0
- package/src/Toggle/__tests__/Toggle.asChild.test.tsx +42 -0
- package/src/Toggle/__tests__/Toggle.basic-rendering.test.tsx +28 -0
- package/src/Toggle/__tests__/Toggle.controlled-state.test.tsx +60 -0
- package/src/Toggle/__tests__/Toggle.disabled.test.tsx +34 -0
- package/src/Toggle/__tests__/Toggle.keyboard-interaction.test.tsx +42 -0
- package/src/Toggle/__tests__/Toggle.uncontrolled-state.test.tsx +40 -0
- package/src/Toggle/index.ts +2 -0
- package/src/Toggle/types.ts +23 -0
- package/src/ToggleGroup/README.md +137 -0
- package/src/ToggleGroup/ToggleGroup.tsx +298 -0
- package/src/ToggleGroup/ToggleGroupContext.ts +9 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.asChild.test.tsx +65 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.basic-rendering.test.tsx +50 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.disabled.test.tsx +54 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.keyboard-interaction.test.tsx +151 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.multiple-mode.test.tsx +144 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.reading-direction.test.tsx +28 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.single-mode.test.tsx +139 -0
- package/src/ToggleGroup/hooks/index.ts +2 -0
- package/src/ToggleGroup/hooks/useToggleGroupContext.ts +1 -0
- package/src/ToggleGroup/hooks/useToggleGroupRoot.ts +110 -0
- package/src/ToggleGroup/index.ts +2 -0
- package/src/ToggleGroup/types.ts +72 -0
- package/src/Tooltip/README.md +214 -0
- package/src/Tooltip/Tooltip.tsx +260 -0
- package/src/Tooltip/TooltipContext.ts +20 -0
- package/src/Tooltip/__tests__/Tooltip.asChild.test.tsx +77 -0
- package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +180 -0
- package/src/Tooltip/__tests__/Tooltip.controlled-state.test.tsx +128 -0
- package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
- package/src/Tooltip/__tests__/Tooltip.focus-interaction.test.tsx +88 -0
- package/src/Tooltip/__tests__/Tooltip.hover-interaction.test.tsx +179 -0
- package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +85 -0
- package/src/Tooltip/__tests__/Tooltip.uncontrolled-state.test.tsx +67 -0
- package/src/Tooltip/hooks/index.ts +4 -0
- package/src/Tooltip/hooks/useTooltipContent.ts +53 -0
- package/src/Tooltip/hooks/useTooltipProvider.ts +41 -0
- package/src/Tooltip/hooks/useTooltipRoot.ts +106 -0
- package/src/Tooltip/hooks/useTooltipTrigger.ts +44 -0
- package/src/Tooltip/index.ts +1 -0
- package/src/Tooltip/types.ts +64 -0
- package/src/Tree/README.md +339 -0
- package/src/Tree/Tree.tsx +571 -0
- package/src/Tree/TreeContext.ts +24 -0
- package/src/Tree/__tests__/Tree.aria.test.tsx +53 -0
- package/src/Tree/__tests__/Tree.asChild.test.tsx +134 -0
- package/src/Tree/__tests__/Tree.basic-rendering.test.tsx +111 -0
- package/src/Tree/__tests__/Tree.branch-behaviour.test.tsx +87 -0
- package/src/Tree/__tests__/Tree.controlled-expansion.test.tsx +92 -0
- package/src/Tree/__tests__/Tree.data-attributes.test.tsx +88 -0
- package/src/Tree/__tests__/Tree.disabled-items.test.tsx +196 -0
- package/src/Tree/__tests__/Tree.error-handling.test.tsx +71 -0
- package/src/Tree/__tests__/Tree.forceMount.test.tsx +72 -0
- package/src/Tree/__tests__/Tree.keyboard-interaction.test.tsx +150 -0
- package/src/Tree/__tests__/Tree.multiple-selection.test.tsx +151 -0
- package/src/Tree/__tests__/Tree.range-selection.test.tsx +200 -0
- package/src/Tree/__tests__/Tree.recursion-depth.test.tsx +73 -0
- package/src/Tree/__tests__/Tree.roving-tabindex.test.tsx +117 -0
- package/src/Tree/__tests__/Tree.selection-path.test.tsx +404 -0
- package/src/Tree/__tests__/Tree.single-selection.test.tsx +108 -0
- package/src/Tree/__tests__/Tree.uncontrolled-expansion.test.tsx +69 -0
- package/src/Tree/hooks/index.ts +3 -0
- package/src/Tree/hooks/useTreeItemKeyboard.ts +86 -0
- package/src/Tree/hooks/useTreePath.ts +68 -0
- package/src/Tree/hooks/useTreeRoot.ts +279 -0
- package/src/Tree/index.ts +3 -0
- package/src/Tree/types.ts +224 -0
- package/src/Tree/utils.ts +59 -0
- package/src/VisuallyHidden/README.md +58 -0
- package/src/VisuallyHidden/VisuallyHidden.tsx +67 -0
- package/src/VisuallyHidden/__tests__/VisuallyHidden.test.tsx +59 -0
- package/src/VisuallyHidden/index.ts +2 -0
- package/src/VisuallyHidden/types.ts +5 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useCollection.ts +74 -0
- package/src/hooks/useControllableState.ts +81 -0
- package/src/hooks/useRovingTabindex.ts +178 -0
- package/src/index.ts +38 -0
- package/src/test/intersectionObserverPolyfill.ts +83 -0
- package/src/test/popoverPolyfill.ts +86 -0
- package/src/test/scrollPolyfill.ts +23 -0
- package/src/types.ts +13 -0
- package/src/utils/__tests__/createStrictContext.test.tsx +69 -0
- package/src/utils/__tests__/deriveId.test.ts +28 -0
- package/src/utils/__tests__/getKeyToActionMap.test.ts +106 -0
- package/src/utils/createStrictContext.ts +49 -0
- package/src/utils/deriveId.ts +31 -0
- package/src/utils/getKeyToActionMap.ts +95 -0
- package/src/utils/index.ts +3 -0
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
# Carousel
|
|
2
|
+
|
|
3
|
+
Headless, accessible **Carousel** built on native CSS scroll-snap. Implements
|
|
4
|
+
the [WAI-ARIA Carousel pattern](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/),
|
|
5
|
+
ships zero styles, and is fully composable.
|
|
6
|
+
|
|
7
|
+
The component is built incrementally under strict TDD. This README
|
|
8
|
+
documents the surface that exists today.
|
|
9
|
+
|
|
10
|
+
## Status
|
|
11
|
+
|
|
12
|
+
Currently exposes:
|
|
13
|
+
|
|
14
|
+
- **`Carousel.Root`** — labelled `<section>` wrapper with
|
|
15
|
+
`aria-roledescription="carousel"`.
|
|
16
|
+
- **`Carousel.Viewport`** — slide container, rendered as a `<div>` with
|
|
17
|
+
a `data-carousel-viewport` attribute the recommended scroll-snap CSS
|
|
18
|
+
targets. Must be rendered as a descendant of `Carousel.Root`; rendering
|
|
19
|
+
it elsewhere throws a descriptive error.
|
|
20
|
+
- **`Carousel.Slide`** — an individual slide. Renders a `<div role="group"
|
|
21
|
+
aria-roledescription="slide">` and self-registers with the Root so each
|
|
22
|
+
slide knows its zero-based `data-index` and the live `data-total`
|
|
23
|
+
count, even as slides mount and unmount. Each slide is auto-labelled
|
|
24
|
+
`"N of M"` (e.g. `"1 of 3"`); pass `ariaLabel` to override with a more
|
|
25
|
+
meaningful description (e.g. `"Hand-picked for you"`). Emits
|
|
26
|
+
`data-state="active" | "inactive"` tracking the active page, plus a
|
|
27
|
+
`data-carousel-slide` CSS hook.
|
|
28
|
+
- **`Carousel.NextTrigger`** — `<button>` that advances the active page
|
|
29
|
+
by one. `disabled` at the last page, and whenever zero or one slides
|
|
30
|
+
are registered. Consumer `onClick` runs before the navigation;
|
|
31
|
+
consumer-supplied `disabled={true}` is honoured alongside the boundary
|
|
32
|
+
check.
|
|
33
|
+
- **`Carousel.PreviousTrigger`** — `<button>` that retreats the active
|
|
34
|
+
page by one. `disabled` at the first page, with the same zero/one-slide
|
|
35
|
+
and consumer-`disabled` semantics as `NextTrigger`.
|
|
36
|
+
- **`Carousel.IndicatorGroup`** — labelled `<div role="group">`
|
|
37
|
+
wrapping consumer-mapped indicator dots. Pass `label` (becomes
|
|
38
|
+
`aria-label`) or `ariaLabelledBy`; the discriminated union rejects
|
|
39
|
+
both-or-neither at compile time.
|
|
40
|
+
- **`Carousel.Indicator`** — individual `<button>` dot. `index` prop
|
|
41
|
+
targets a zero-based page; clicking jumps to it. Auto-labelled
|
|
42
|
+
`"Slide N"`. The dot at the current page carries
|
|
43
|
+
`aria-disabled="true"` (a soft disable per the WAI-ARIA Carousel
|
|
44
|
+
APG); all dots emit `data-state="active" | "inactive"` and a
|
|
45
|
+
`data-carousel-indicator` CSS hook so consumer styles can paint the
|
|
46
|
+
active dot.
|
|
47
|
+
- **`Carousel.Indicators`** — convenience wrapper that auto-renders
|
|
48
|
+
one `Carousel.Indicator` per registered slide. Reuses the same
|
|
49
|
+
discriminated `label` / `ariaLabelledBy` shape as `IndicatorGroup`.
|
|
50
|
+
For custom indicator content, drop down to `IndicatorGroup` +
|
|
51
|
+
`Indicator`.
|
|
52
|
+
- **`Carousel.PlayPauseTrigger`** — `<button>` that toggles the
|
|
53
|
+
`playing` flag on Root. Auto-labels itself `"Start automatic slide
|
|
54
|
+
show"` / `"Stop automatic slide show"` per the WAI-ARIA Carousel
|
|
55
|
+
APG, exposes a `data-state="playing" | "paused"` styling hook, and
|
|
56
|
+
passes `{ playing }` to a function `children` render prop so
|
|
57
|
+
consumers can swap icons or labels per state.
|
|
58
|
+
|
|
59
|
+
Pass `autoplay` on `Carousel.Root` to advance the active page on a
|
|
60
|
+
timer while `playing` is `true`. Hover, focus, and active-touch
|
|
61
|
+
suspend the timer per the WAI-ARIA APG (with a user-initiated play
|
|
62
|
+
override), and the viewport's `aria-live` region flips between
|
|
63
|
+
`"polite"` and `"off"` so assistive tech doesn't announce every
|
|
64
|
+
auto-rotation tick.
|
|
65
|
+
|
|
66
|
+
## JS vs CSS responsibilities
|
|
67
|
+
|
|
68
|
+
The component ships zero styles, but a few features sit on the line
|
|
69
|
+
between JS and CSS. This table is the contract — the rule of thumb
|
|
70
|
+
is that JS owns _what is the active page_ and _delegates the scroll to
|
|
71
|
+
the browser_ (`scrollIntoView`), and CSS owns _what the user sees_:
|
|
72
|
+
|
|
73
|
+
| Feature | JS owns | CSS owns |
|
|
74
|
+
| ---------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------ |
|
|
75
|
+
| Active page state | `page` / `defaultPage`, `onPageChange`, `goTo` | — |
|
|
76
|
+
| Boundary clamping | `canGoNext` / `canGoPrevious`, trigger `disabled` | — |
|
|
77
|
+
| Crossfade / scale / dissolve | `data-state="active"` flip on slides | `position: absolute`, `opacity` + `transition` |
|
|
78
|
+
| Slide layout & widths | — | `flex-basis` / `inline-size`, `gap`, `aspect-ratio` |
|
|
79
|
+
| Peek of adjacent slides | `snapAlign` → `scrollIntoView({ inline })` | Viewport `padding-inline`, slide `flex-basis`, `scroll-snap-align` |
|
|
80
|
+
| Gap between slides | — | `gap` on the viewport (no `spacing` prop — pure CSS) |
|
|
81
|
+
| Variable-size slides | `scrollIntoView` on the target slide | Per-slide width / `aspect-ratio`, `scroll-snap-align` |
|
|
82
|
+
| Snap targeting | `snapAlign: "start" \| "center"` (Root only) | `scroll-snap-type` on viewport, `scroll-snap-align` on each slide |
|
|
83
|
+
| Reduced motion | `behavior: "instant"` | Optional `@media (prefers-reduced-motion: reduce)` on consumer animations |
|
|
84
|
+
| Keyboard navigation | Arrow / Home / End on focused viewport | `:focus-visible` on viewport |
|
|
85
|
+
| Touch / swipe | Native scroll + `scrollsnapchange` to sync state | `overscroll-behavior-x: contain`, `scrollbar-width: none` |
|
|
86
|
+
| Indicator state | `data-state` on `[data-carousel-indicator]` | Visual: dot, bar, thumbnail, etc. |
|
|
87
|
+
|
|
88
|
+
The only JS prop on the visual side is `snapAlign`, and only because it
|
|
89
|
+
picks the `inline` option passed to `scrollIntoView` (`"start"` or
|
|
90
|
+
`"center"`) so the programmatic scroll lands where the browser's CSS
|
|
91
|
+
snap engine will settle. Everything else is either a state knob (JS) or
|
|
92
|
+
a visual rule (CSS), with no overlap.
|
|
93
|
+
|
|
94
|
+
The `apps/workbench` workbench at `/carousel` ships worked recipes for
|
|
95
|
+
each cell of the matrix (single / multi / multi-step × slide / fade)
|
|
96
|
+
plus peek, variable-size, and programmatic-control examples.
|
|
97
|
+
|
|
98
|
+
## Usage
|
|
99
|
+
|
|
100
|
+
Every carousel must have an accessible name. Pass exactly one of `ariaLabel`
|
|
101
|
+
or `ariaLabelledBy`:
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
import { Carousel } from "@primitiv-ui/react";
|
|
105
|
+
|
|
106
|
+
<Carousel.Root ariaLabel="Featured products">
|
|
107
|
+
<Carousel.Viewport>
|
|
108
|
+
<Carousel.Slide>
|
|
109
|
+
<img src="/cube.png" alt="Cube" />
|
|
110
|
+
</Carousel.Slide>
|
|
111
|
+
<Carousel.Slide>
|
|
112
|
+
<img src="/sphere.png" alt="Sphere" />
|
|
113
|
+
</Carousel.Slide>
|
|
114
|
+
</Carousel.Viewport>
|
|
115
|
+
<Carousel.PreviousTrigger>Previous</Carousel.PreviousTrigger>
|
|
116
|
+
<Carousel.Indicators label="Choose slide" />
|
|
117
|
+
<Carousel.NextTrigger>Next</Carousel.NextTrigger>
|
|
118
|
+
</Carousel.Root>;
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
<h2 id="promos">Promotions</h2>
|
|
123
|
+
<Carousel.Root ariaLabelledBy="promos">…</Carousel.Root>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The discriminated union on the props type rejects shapes that supply both
|
|
127
|
+
or neither at compile time.
|
|
128
|
+
|
|
129
|
+
### Wrapping the slide container
|
|
130
|
+
|
|
131
|
+
Slides go inside `Carousel.Viewport`:
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
<Carousel.Root ariaLabel="Featured products">
|
|
135
|
+
<Carousel.Viewport>
|
|
136
|
+
<Carousel.Slide>First slide</Carousel.Slide>
|
|
137
|
+
<Carousel.Slide>Second slide</Carousel.Slide>
|
|
138
|
+
<Carousel.Slide>Third slide</Carousel.Slide>
|
|
139
|
+
</Carousel.Viewport>
|
|
140
|
+
</Carousel.Root>
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Each `Carousel.Slide` self-registers with the Root, so every slide
|
|
144
|
+
exposes its own `data-index="0"`, `data-index="1"`, … and a live
|
|
145
|
+
`data-total` reflecting the current slide count. Add or remove slides at
|
|
146
|
+
runtime and the indices and totals update automatically.
|
|
147
|
+
|
|
148
|
+
Slides are also auto-labelled `"N of M"` (e.g. `"1 of 3"`) — the format
|
|
149
|
+
the WAI-ARIA Carousel APG example uses. To override the auto-label with
|
|
150
|
+
a more meaningful description, pass `ariaLabel`:
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
<Carousel.Slide ariaLabel="Hand-picked for you">…</Carousel.Slide>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The override remains stable as siblings mount and unmount around it.
|
|
157
|
+
|
|
158
|
+
### Navigating between slides
|
|
159
|
+
|
|
160
|
+
`Carousel.NextTrigger` and `Carousel.PreviousTrigger` advance and retreat
|
|
161
|
+
the active page. Each slide's `data-state` flips between `"active"` and
|
|
162
|
+
`"inactive"` so consumer CSS can paint the current slide differently.
|
|
163
|
+
|
|
164
|
+
#### Uncontrolled
|
|
165
|
+
|
|
166
|
+
Pass `defaultPage` (or omit it for `0`); the Root owns the active page
|
|
167
|
+
internally:
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
<Carousel.Root ariaLabel="Featured products" defaultPage={0}>
|
|
171
|
+
<Carousel.Viewport>
|
|
172
|
+
<Carousel.Slide>First</Carousel.Slide>
|
|
173
|
+
<Carousel.Slide>Second</Carousel.Slide>
|
|
174
|
+
<Carousel.Slide>Third</Carousel.Slide>
|
|
175
|
+
</Carousel.Viewport>
|
|
176
|
+
<Carousel.PreviousTrigger>Previous</Carousel.PreviousTrigger>
|
|
177
|
+
<Carousel.NextTrigger>Next</Carousel.NextTrigger>
|
|
178
|
+
</Carousel.Root>
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
#### Controlled
|
|
182
|
+
|
|
183
|
+
Pass `page` and `onPageChange` together to lift state into the parent.
|
|
184
|
+
The Root defers every state change back through the callback — clicks
|
|
185
|
+
on `NextTrigger` / `PreviousTrigger` invoke `onPageChange` with the
|
|
186
|
+
proposed page; the visual reflects whatever `page` value the parent
|
|
187
|
+
re-renders with. Useful for syncing two carousels (e.g. a thumbnail
|
|
188
|
+
strip), persisting the active page to a URL, or reacting to deep links.
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
const [page, setPage] = useState(0);
|
|
192
|
+
|
|
193
|
+
<Carousel.Root ariaLabel="Featured products" page={page} onPageChange={setPage}>
|
|
194
|
+
…
|
|
195
|
+
</Carousel.Root>;
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The discriminated union on the props type rejects mixed shapes (e.g.
|
|
199
|
+
both `defaultPage` and `page`, or `page` without `onPageChange`) at
|
|
200
|
+
compile time.
|
|
201
|
+
|
|
202
|
+
### Runtime validation
|
|
203
|
+
|
|
204
|
+
`Carousel.PlayPauseTrigger` rendered under a Root with `autoplay`
|
|
205
|
+
disabled (omitted, or `autoplay={false}`) throws on mount —
|
|
206
|
+
toggling `playing` is meaningless when no autoplay timer is wired,
|
|
207
|
+
and the throw surfaces the misuse during development rather than
|
|
208
|
+
shipping a no-op control to users.
|
|
209
|
+
|
|
210
|
+
`Carousel.Root` also throws once slides have registered if the
|
|
211
|
+
active `page` (controlled or `defaultPage`-seeded) is negative or
|
|
212
|
+
greater than or equal to the live `totalPages` —
|
|
213
|
+
otherwise the carousel would render with no active slide. The throw
|
|
214
|
+
is gated on `totalPages > 0` so transient zero-slide initial-render
|
|
215
|
+
states don't false-alarm.
|
|
216
|
+
|
|
217
|
+
### Lightbox composition (with `Modal`)
|
|
218
|
+
|
|
219
|
+
Two `Carousel.Root`s sharing a controlled `page` stay in sync — pair
|
|
220
|
+
a thumbnail strip with a fullscreen viewer mounted inside the
|
|
221
|
+
in-tree `Modal`. Gate the inner carousel's `playing` flag on the
|
|
222
|
+
modal's `open` so autoplay only runs while the modal is visible:
|
|
223
|
+
|
|
224
|
+
```tsx
|
|
225
|
+
const [page, setPage] = useState(0);
|
|
226
|
+
const [open, setOpen] = useState(false);
|
|
227
|
+
|
|
228
|
+
<>
|
|
229
|
+
<Carousel.Root
|
|
230
|
+
ariaLabel="Featured products — thumbnails"
|
|
231
|
+
page={page}
|
|
232
|
+
onPageChange={setPage}
|
|
233
|
+
slidesPerPage={3}
|
|
234
|
+
>
|
|
235
|
+
<Carousel.Viewport>
|
|
236
|
+
<Carousel.Slide>…</Carousel.Slide>…
|
|
237
|
+
</Carousel.Viewport>
|
|
238
|
+
</Carousel.Root>
|
|
239
|
+
|
|
240
|
+
<Modal.Root open={open} onOpenChange={setOpen}>
|
|
241
|
+
<Modal.Trigger>Open lightbox</Modal.Trigger>
|
|
242
|
+
<Modal.Portal>
|
|
243
|
+
<Modal.Content>
|
|
244
|
+
<Carousel.Root
|
|
245
|
+
ariaLabel="Featured products — fullscreen"
|
|
246
|
+
page={page}
|
|
247
|
+
onPageChange={setPage}
|
|
248
|
+
autoplay
|
|
249
|
+
playing={open}
|
|
250
|
+
onPlayingChange={() => {}}
|
|
251
|
+
>
|
|
252
|
+
<Carousel.Viewport>
|
|
253
|
+
<Carousel.Slide>…</Carousel.Slide>…
|
|
254
|
+
</Carousel.Viewport>
|
|
255
|
+
<Carousel.Indicators label="Choose slide" />
|
|
256
|
+
</Carousel.Root>
|
|
257
|
+
</Modal.Content>
|
|
258
|
+
</Modal.Portal>
|
|
259
|
+
</Modal.Root>
|
|
260
|
+
</>;
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`Carousel.Root` doesn't focus anything on mount, so the `Modal`'s
|
|
264
|
+
focus management isn't disturbed when the inner carousel mounts.
|
|
265
|
+
|
|
266
|
+
### Imperative API
|
|
267
|
+
|
|
268
|
+
For programmatic control from outside the component (e.g. a global
|
|
269
|
+
keyboard shortcut, a "skip to last slide" button elsewhere on the
|
|
270
|
+
page, or restoring a remembered position on mount), `Carousel.Root`
|
|
271
|
+
exposes an imperative handle via `ref`:
|
|
272
|
+
|
|
273
|
+
```tsx
|
|
274
|
+
const carouselRef = useRef<CarouselImperativeApi>(null);
|
|
275
|
+
|
|
276
|
+
<Carousel.Root ref={carouselRef} ariaLabel="Featured products">
|
|
277
|
+
…
|
|
278
|
+
</Carousel.Root>;
|
|
279
|
+
|
|
280
|
+
carouselRef.current?.next();
|
|
281
|
+
carouselRef.current?.previous();
|
|
282
|
+
carouselRef.current?.goTo(2);
|
|
283
|
+
carouselRef.current?.play();
|
|
284
|
+
carouselRef.current?.pause();
|
|
285
|
+
carouselRef.current?.refresh();
|
|
286
|
+
const { page, totalPages, value } = carouselRef.current!.getProgress();
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
`refresh()` re-issues the viewport's `scrollIntoView` for the current
|
|
290
|
+
page — useful when external layout changes (window resize, container
|
|
291
|
+
reflow, dynamic content) leave the scroll position misaligned with
|
|
292
|
+
React state. `getProgress()` returns a normalised
|
|
293
|
+
`value` in `[0, 1]` (`0` when there's at most one page) plus the
|
|
294
|
+
live `page` and `totalPages`, intended for custom progress bars.
|
|
295
|
+
|
|
296
|
+
Every method routes through the same internal state machine the
|
|
297
|
+
trigger components use, so controlled-mode `onPageChange` /
|
|
298
|
+
`onPlayingChange` callbacks fire just as if the user had clicked.
|
|
299
|
+
`play()` also dismisses the hover/focus pause for the lifetime of
|
|
300
|
+
that playing session, matching the WAI-ARIA APG semantics for
|
|
301
|
+
user-initiated play.
|
|
302
|
+
|
|
303
|
+
### `asChild` composition
|
|
304
|
+
|
|
305
|
+
`Carousel.NextTrigger`, `Carousel.PreviousTrigger`,
|
|
306
|
+
`Carousel.PlayPauseTrigger`, and `Carousel.Indicator` all accept an
|
|
307
|
+
`asChild` prop. When set, the trigger renders the consumer's child
|
|
308
|
+
element via the in-tree `Slot` (instead of its default `<button>`)
|
|
309
|
+
and merges every trigger prop — `onClick`, `aria-label`,
|
|
310
|
+
`aria-disabled`, `data-state`, custom `id`, etc. — onto it. Useful
|
|
311
|
+
for routing links and other elements that need trigger semantics:
|
|
312
|
+
|
|
313
|
+
```tsx
|
|
314
|
+
<Carousel.NextTrigger asChild>
|
|
315
|
+
<a href="/products?page=2">Next</a>
|
|
316
|
+
</Carousel.NextTrigger>
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Because `<a>` and other non-button elements don't honour the HTML
|
|
320
|
+
`disabled` attribute, the prev/next triggers also short-circuit
|
|
321
|
+
their click handler when boundary clamping says "no further" — so
|
|
322
|
+
asChild on a non-button still respects the boundary clamp.
|
|
323
|
+
|
|
324
|
+
### Snap alignment
|
|
325
|
+
|
|
326
|
+
By default the Viewport scrolls so the **start (left) edge** of the
|
|
327
|
+
target slide aligns with the start edge of the scroll container — matching
|
|
328
|
+
`scroll-snap-align: start` in consumer CSS. For layouts where slides are
|
|
329
|
+
narrower than the Viewport and centred (e.g. Cover Flow), set
|
|
330
|
+
`snapAlign="center"` so programmatic navigation lands on the centred
|
|
331
|
+
position without the browser snapping-correcting after the scroll:
|
|
332
|
+
|
|
333
|
+
```tsx
|
|
334
|
+
<Carousel.Root ariaLabel="Gallery" snapAlign="center">
|
|
335
|
+
…
|
|
336
|
+
</Carousel.Root>
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Pair with `scroll-snap-align: center` on `Carousel.Slide` in your CSS.
|
|
340
|
+
The default is `"start"`; `snapAlign` picks the `inline` option passed
|
|
341
|
+
to `scrollIntoView` (`"start"` or `"center"`), and the browser's CSS
|
|
342
|
+
snap engine makes the final correction.
|
|
343
|
+
|
|
344
|
+
### Transition modes
|
|
345
|
+
|
|
346
|
+
`Carousel.Root` accepts a `transition` prop that controls how the
|
|
347
|
+
viewport handles slide changes visually.
|
|
348
|
+
|
|
349
|
+
- `transition="slide"` (default) — relies on native CSS scroll-snap.
|
|
350
|
+
The Viewport scrolls programmatically when the page changes and
|
|
351
|
+
listens for `scrollsnapchange` so user swipes update React state.
|
|
352
|
+
- `transition="none"` — the Viewport installs no scroll wiring at
|
|
353
|
+
all. Consumer CSS owns the visual via the `data-state="active" |
|
|
354
|
+
"inactive"` hook on each slide, which still flips with the active
|
|
355
|
+
page. This is the entry point for crossfade, dissolve, zoom, or
|
|
356
|
+
any CSS-only transition pattern:
|
|
357
|
+
|
|
358
|
+
```css
|
|
359
|
+
[data-carousel-slide] {
|
|
360
|
+
position: absolute;
|
|
361
|
+
inset: 0;
|
|
362
|
+
opacity: 0;
|
|
363
|
+
transition: opacity 400ms;
|
|
364
|
+
}
|
|
365
|
+
[data-carousel-slide][data-state="active"] {
|
|
366
|
+
opacity: 1;
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Reduced motion
|
|
371
|
+
|
|
372
|
+
The Viewport's programmatic `scrollIntoView` reads
|
|
373
|
+
`window.matchMedia("(prefers-reduced-motion: reduce)")` once on
|
|
374
|
+
mount. When the user has reduced motion enabled at the OS level,
|
|
375
|
+
page changes use `behavior: "instant"` instead of `"smooth"` so the
|
|
376
|
+
carousel doesn't fight that preference. Touch-driven scrolling is
|
|
377
|
+
unaffected — the browser owns that animation.
|
|
378
|
+
|
|
379
|
+
### Programmatic scroll sync
|
|
380
|
+
|
|
381
|
+
When the active page changes for any reason (`Carousel.NextTrigger` /
|
|
382
|
+
`Carousel.PreviousTrigger` click, indicator click, autoplay tick),
|
|
383
|
+
the viewport calls `scrollIntoView` on the first slide of the new page
|
|
384
|
+
so the visual surface tracks React state. Because the browser owns the
|
|
385
|
+
scroll, consumer CSS owns slide width and gap, and `scroll-snap-align`
|
|
386
|
+
makes the final correction. Default `behavior` is `"smooth"`.
|
|
387
|
+
|
|
388
|
+
The reverse path is also wired: when the user swipes the viewport,
|
|
389
|
+
the browser fires `scrollsnapchange` with the snapped slide as the
|
|
390
|
+
target. The Viewport listens for that event, computes
|
|
391
|
+
`floor(slideIndex / slidesPerPage)`, and calls `goTo` so React state
|
|
392
|
+
follows the user's scroll. `onPageChange` is only invoked when the
|
|
393
|
+
page genuinely changes, so a snap that lands back on the active page
|
|
394
|
+
doesn't dispatch a spurious callback.
|
|
395
|
+
|
|
396
|
+
For browsers without `scrollsnapchange`, the same path runs against
|
|
397
|
+
an `IntersectionObserver` (threshold 0.6) on each slide — when the
|
|
398
|
+
observer fires, the lowest-index visible slide derives the active
|
|
399
|
+
page via `floor(firstVisibleSlideIndex / slidesPerPage)`. This
|
|
400
|
+
page-drive is **only** a fallback: when `scrollsnapchange` is
|
|
401
|
+
supported it is authoritative (it reports the precisely-snapped
|
|
402
|
+
slide), so the observer stands down and does not also drive the page.
|
|
403
|
+
That matters for `snapAlign="center"` carousels with several slides
|
|
404
|
+
visible at once (e.g. a cover flow) — the lowest-index-visible
|
|
405
|
+
heuristic would otherwise track the *leftmost* visible slide rather
|
|
406
|
+
than the centred one and fight `scrollsnapchange`. The observer still
|
|
407
|
+
always feeds `Carousel.Root`'s imperative `isInView(slideIndex)` so
|
|
408
|
+
consumers can lazy-load slide content based on actual visibility, not
|
|
409
|
+
just the active-page index.
|
|
410
|
+
|
|
411
|
+
### Custom DOM ids
|
|
412
|
+
|
|
413
|
+
For SSR / hydration stability or external `aria-controls` linkage,
|
|
414
|
+
pin DOM `id`s on the rendered sub-components via the `ids` bag on
|
|
415
|
+
`Carousel.Root`:
|
|
416
|
+
|
|
417
|
+
```tsx
|
|
418
|
+
<Carousel.Root
|
|
419
|
+
ariaLabel="Featured products"
|
|
420
|
+
ids={{
|
|
421
|
+
root: "promo-carousel",
|
|
422
|
+
viewport: "promo-viewport",
|
|
423
|
+
previousTrigger: "promo-prev",
|
|
424
|
+
nextTrigger: "promo-next",
|
|
425
|
+
playPauseTrigger: "promo-play-pause",
|
|
426
|
+
indicatorGroup: "promo-indicators",
|
|
427
|
+
}}
|
|
428
|
+
>
|
|
429
|
+
…
|
|
430
|
+
</Carousel.Root>
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Any keys you omit leave the corresponding element unidentified. A
|
|
434
|
+
direct `id` prop on a sub-component (e.g.
|
|
435
|
+
`<Carousel.NextTrigger id="…">`) wins over `ids.*` because it spreads
|
|
436
|
+
last.
|
|
437
|
+
|
|
438
|
+
### Internationalisation
|
|
439
|
+
|
|
440
|
+
The component owns four user-visible strings: each slide's auto-
|
|
441
|
+
generated `aria-label` (`"N of M"`), each indicator's auto-generated
|
|
442
|
+
`aria-label` (`"Slide N"`), and the two `Carousel.PlayPauseTrigger`
|
|
443
|
+
accessible names (`"Start automatic slide show"` and
|
|
444
|
+
`"Stop automatic slide show"`). Override any subset of them via the
|
|
445
|
+
`translations` prop on `Carousel.Root`:
|
|
446
|
+
|
|
447
|
+
```tsx
|
|
448
|
+
<Carousel.Root
|
|
449
|
+
ariaLabel="Produits en vedette"
|
|
450
|
+
translations={{
|
|
451
|
+
slideLabel: ({ index, total }) => `${index} sur ${total}`,
|
|
452
|
+
indicatorLabel: ({ index }) => `Diapositive ${index}`,
|
|
453
|
+
startSlideshow: "Démarrer le diaporama",
|
|
454
|
+
stopSlideshow: "Arrêter le diaporama",
|
|
455
|
+
}}
|
|
456
|
+
>
|
|
457
|
+
…
|
|
458
|
+
</Carousel.Root>
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
`slideLabel` and `indicatorLabel` are functions (they receive
|
|
462
|
+
position info), the slideshow names are plain strings. Any keys you
|
|
463
|
+
omit fall back to the English defaults. Per-slide `ariaLabel`
|
|
464
|
+
overrides on `Carousel.Slide` still take precedence over
|
|
465
|
+
`translations.slideLabel`, so a single slide can carry a
|
|
466
|
+
domain-meaningful label (e.g. `"Hand-picked for you"`) without
|
|
467
|
+
losing the localised `"N of M"` format on the others.
|
|
468
|
+
|
|
469
|
+
### Multi-slide pages and partial page advance
|
|
470
|
+
|
|
471
|
+
Pass `slidesPerPage` (default `1`) to make several slides visible per
|
|
472
|
+
page — the "image carousel" / "property cards" pattern. By default,
|
|
473
|
+
`Carousel.NextTrigger` / `Carousel.PreviousTrigger` advance one
|
|
474
|
+
full page at a time (`slidesPerMove="auto"`):
|
|
475
|
+
|
|
476
|
+
```tsx
|
|
477
|
+
<Carousel.Root ariaLabel="Featured products" slidesPerPage={3}>
|
|
478
|
+
<Carousel.Viewport>
|
|
479
|
+
<Carousel.Slide>1</Carousel.Slide>
|
|
480
|
+
<Carousel.Slide>2</Carousel.Slide>
|
|
481
|
+
<Carousel.Slide>3</Carousel.Slide>
|
|
482
|
+
<Carousel.Slide>4</Carousel.Slide>
|
|
483
|
+
<Carousel.Slide>5</Carousel.Slide>
|
|
484
|
+
</Carousel.Viewport>
|
|
485
|
+
<Carousel.PreviousTrigger>Previous</Carousel.PreviousTrigger>
|
|
486
|
+
<Carousel.Indicators label="Choose page" />
|
|
487
|
+
<Carousel.NextTrigger>Next</Carousel.NextTrigger>
|
|
488
|
+
</Carousel.Root>
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
With `slidesPerPage={3}` and 5 slides:
|
|
492
|
+
|
|
493
|
+
- Total pages = `ceil(5 / 3) === 2`. `Carousel.Indicators` renders 2
|
|
494
|
+
dots.
|
|
495
|
+
- Page 0 contains slides 0–2; page 1 contains the remaining 3, 4 (a
|
|
496
|
+
partial last page).
|
|
497
|
+
- Each slide on the active page emits `data-state="active"`; slides
|
|
498
|
+
on other pages emit `"inactive"`.
|
|
499
|
+
- `Carousel.NextTrigger` advances one page per click; the boundary
|
|
500
|
+
clamp is at the last page (so Next is disabled while page 1 is
|
|
501
|
+
active).
|
|
502
|
+
|
|
503
|
+
The slide-level `aria-label="N of M"` continues to count individual
|
|
504
|
+
slides (so a 5-slide carousel announces "1 of 5", "2 of 5", … even
|
|
505
|
+
when grouped into 3-per-page).
|
|
506
|
+
|
|
507
|
+
Pass a numeric `slidesPerMove` to advance the visible window by an
|
|
508
|
+
arbitrary slide count per click instead of a full page:
|
|
509
|
+
|
|
510
|
+
```tsx
|
|
511
|
+
<Carousel.Root
|
|
512
|
+
ariaLabel="Featured products"
|
|
513
|
+
slidesPerPage={3}
|
|
514
|
+
slidesPerMove={1}
|
|
515
|
+
>
|
|
516
|
+
…
|
|
517
|
+
</Carousel.Root>
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
With `slidesPerPage=3`, `slidesPerMove=1`, and 5 slides, the active
|
|
521
|
+
window slides one slide at a time — pages show `[0,1,2]`, `[1,2,3]`,
|
|
522
|
+
`[2,3,4]`, so `Carousel.Indicators` renders 3 dots and the boundary
|
|
523
|
+
clamp respects the last full window. The indicator count formula is
|
|
524
|
+
`floor((total - slidesPerPage) / slidesPerMove) + 1` (vs.
|
|
525
|
+
`ceil(total / slidesPerPage)` for `"auto"`), so the visible window
|
|
526
|
+
always stays full in numeric mode.
|
|
527
|
+
|
|
528
|
+
### Boundary behaviour
|
|
529
|
+
|
|
530
|
+
The prev/next triggers clamp at the ends: `Carousel.PreviousTrigger` is
|
|
531
|
+
`disabled` at the first slide, `Carousel.NextTrigger` at the last. Both
|
|
532
|
+
are also `disabled` when zero or one slides are registered, since
|
|
533
|
+
there's nowhere to navigate.
|
|
534
|
+
|
|
535
|
+
### Keyboard navigation
|
|
536
|
+
|
|
537
|
+
`Carousel.Viewport` is in the tab order so keyboard users can reach the
|
|
538
|
+
rotation control without first tabbing through every slide's
|
|
539
|
+
interactive content. With the Viewport focused:
|
|
540
|
+
|
|
541
|
+
| Key | Action |
|
|
542
|
+
| ------------ | ----------------------------------------------- |
|
|
543
|
+
| `ArrowRight` | Advance by one page (same as `NextTrigger`) |
|
|
544
|
+
| `ArrowLeft` | Retreat by one page (same as `PreviousTrigger`) |
|
|
545
|
+
| `Home` | Jump to the first page |
|
|
546
|
+
| `End` | Jump to the last page |
|
|
547
|
+
|
|
548
|
+
Arrow keys clamp at the boundaries, mirroring the trigger buttons.
|
|
549
|
+
Keypresses are only intercepted when the Viewport itself is the focus
|
|
550
|
+
target — focus inside a slide (e.g. on a link or form control) keeps
|
|
551
|
+
its native arrow-key semantics.
|
|
552
|
+
|
|
553
|
+
```tsx
|
|
554
|
+
<Carousel.Root ariaLabel="Featured products">
|
|
555
|
+
<Carousel.Viewport>
|
|
556
|
+
<Carousel.Slide>First</Carousel.Slide>
|
|
557
|
+
<Carousel.Slide>Second</Carousel.Slide>
|
|
558
|
+
<Carousel.Slide>Third</Carousel.Slide>
|
|
559
|
+
</Carousel.Viewport>
|
|
560
|
+
<Carousel.PreviousTrigger>Previous</Carousel.PreviousTrigger>
|
|
561
|
+
<Carousel.NextTrigger>Next</Carousel.NextTrigger>
|
|
562
|
+
</Carousel.Root>
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
Consumer-supplied `disabled={true}` on either trigger is honoured
|
|
566
|
+
regardless of boundary state — useful for momentarily freezing
|
|
567
|
+
navigation while another part of the UI takes over.
|
|
568
|
+
|
|
569
|
+
### Indicator dots (manual)
|
|
570
|
+
|
|
571
|
+
For full control over indicator content, map them yourself with
|
|
572
|
+
`Carousel.IndicatorGroup` + `Carousel.Indicator`. Each dot's `index`
|
|
573
|
+
prop targets a zero-based page; clicking jumps to it.
|
|
574
|
+
|
|
575
|
+
```tsx
|
|
576
|
+
<Carousel.IndicatorGroup label="Choose slide">
|
|
577
|
+
<Carousel.Indicator index={0} />
|
|
578
|
+
<Carousel.Indicator index={1} />
|
|
579
|
+
<Carousel.Indicator index={2} />
|
|
580
|
+
</Carousel.IndicatorGroup>
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
Indicators are auto-labelled `"Slide N"` (1-indexed). The dot at the
|
|
584
|
+
current page carries `aria-disabled="true"` per the WAI-ARIA APG and
|
|
585
|
+
`data-state="active"`; non-active dots carry `aria-disabled="false"`
|
|
586
|
+
and `data-state="inactive"`. Style them via the
|
|
587
|
+
`data-carousel-indicator` attribute and the `data-state` hook:
|
|
588
|
+
|
|
589
|
+
```css
|
|
590
|
+
[data-carousel-indicator][data-state="active"] {
|
|
591
|
+
background: black;
|
|
592
|
+
}
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### Play / pause control
|
|
596
|
+
|
|
597
|
+
`Carousel.PlayPauseTrigger` toggles a `playing` flag on the Root. The
|
|
598
|
+
flag has the same controlled / uncontrolled split as `page`:
|
|
599
|
+
|
|
600
|
+
```tsx
|
|
601
|
+
// Uncontrolled
|
|
602
|
+
<Carousel.Root ariaLabel="Featured products" defaultPlaying={false}>
|
|
603
|
+
<Carousel.PlayPauseTrigger />
|
|
604
|
+
</Carousel.Root>;
|
|
605
|
+
|
|
606
|
+
// Controlled
|
|
607
|
+
const [playing, setPlaying] = useState(false);
|
|
608
|
+
<Carousel.Root
|
|
609
|
+
ariaLabel="Featured products"
|
|
610
|
+
playing={playing}
|
|
611
|
+
onPlayingChange={setPlaying}
|
|
612
|
+
>
|
|
613
|
+
<Carousel.PlayPauseTrigger />
|
|
614
|
+
</Carousel.Root>;
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
The discriminated union rejects mixed shapes (e.g. `defaultPlaying` +
|
|
618
|
+
`playing`, or `playing` without `onPlayingChange`) at compile time.
|
|
619
|
+
|
|
620
|
+
Pass a function as `children` to swap icons or labels per state:
|
|
621
|
+
|
|
622
|
+
```tsx
|
|
623
|
+
<Carousel.PlayPauseTrigger>
|
|
624
|
+
{({ playing }) => (playing ? <PauseIcon /> : <PlayIcon />)}
|
|
625
|
+
</Carousel.PlayPauseTrigger>
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
The trigger is auto-labelled `"Start automatic slide show"` (paused)
|
|
629
|
+
or `"Stop automatic slide show"` (playing) for assistive tech, and
|
|
630
|
+
emits `data-state="playing" | "paused"` for consumer CSS.
|
|
631
|
+
|
|
632
|
+
### Autoplay timer
|
|
633
|
+
|
|
634
|
+
Pass `autoplay` on `Carousel.Root` to advance the active page on a
|
|
635
|
+
timer while `playing` is `true`:
|
|
636
|
+
|
|
637
|
+
```tsx
|
|
638
|
+
// Default 4000ms cadence
|
|
639
|
+
<Carousel.Root ariaLabel="Featured products" autoplay defaultPlaying>
|
|
640
|
+
…
|
|
641
|
+
</Carousel.Root>
|
|
642
|
+
|
|
643
|
+
// Custom delay
|
|
644
|
+
<Carousel.Root
|
|
645
|
+
ariaLabel="Featured products"
|
|
646
|
+
autoplay={{ delay: 6000 }}
|
|
647
|
+
defaultPlaying
|
|
648
|
+
>
|
|
649
|
+
…
|
|
650
|
+
</Carousel.Root>
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
The timer reads from the live `playing` flag and the active page —
|
|
654
|
+
toggling pause via `Carousel.PlayPauseTrigger` (or via the controlled
|
|
655
|
+
`onPlayingChange`) cancels the next tick. The timer stops once the
|
|
656
|
+
active page reaches the last slide.
|
|
657
|
+
|
|
658
|
+
The timer also pauses automatically while the user is hovering the
|
|
659
|
+
Root or has focus on a descendant element, per WCAG 2.2.2 (Pause,
|
|
660
|
+
Stop, Hide). Focus moving between descendants of the Root (e.g.
|
|
661
|
+
tabbing from `Previous` to `Next`) keeps the pause active; the timer
|
|
662
|
+
only resumes once the pointer leaves and focus has moved out of the
|
|
663
|
+
carousel entirely. The `playing` flag is unaffected — it stays
|
|
664
|
+
`true` while suspended, so toggling pause-resume via
|
|
665
|
+
`PlayPauseTrigger` continues to behave as the consumer expects.
|
|
666
|
+
|
|
667
|
+
Touch gestures pause the timer too: `pointerdown` with
|
|
668
|
+
`pointerType === "touch"` sets the suspension flag, and any
|
|
669
|
+
`pointerup` or `pointercancel` releases it. Mouse / pen
|
|
670
|
+
`pointerdown` is filtered out so the existing hover/focus path
|
|
671
|
+
keeps owning non-touch interaction without double-suspension.
|
|
672
|
+
|
|
673
|
+
**User-initiated play overrides the hover/focus pause.** Per the
|
|
674
|
+
WAI-ARIA Carousel APG example, when the user explicitly clicks
|
|
675
|
+
`PlayPauseTrigger` to start the slideshow, the hover/focus pause is
|
|
676
|
+
suspended for the lifetime of that playing session — otherwise the
|
|
677
|
+
user would fight a pause every time their pointer was already over
|
|
678
|
+
the carousel when they pressed play. The override resets when
|
|
679
|
+
`playing` flips back to `false` (via another click, or via an
|
|
680
|
+
external state change), so a subsequent play that's _not_ user-
|
|
681
|
+
initiated falls back to the standard WCAG pause.
|
|
682
|
+
|
|
683
|
+
**Viewport live region.** `Carousel.Viewport` is also the live region
|
|
684
|
+
for slide changes. Its `aria-live` defaults to `"polite"` so paged
|
|
685
|
+
manual navigation is announced to assistive tech, and flips to
|
|
686
|
+
`"off"` while autoplay is actively rotating (`autoplay` enabled and
|
|
687
|
+
`playing=true`) so screen readers aren't spammed with every tick.
|
|
688
|
+
The flip is reactive — pausing via `PlayPauseTrigger` returns the
|
|
689
|
+
viewport to `"polite"` for the duration of the pause.
|
|
690
|
+
|
|
691
|
+
### Indicator dots (auto-rendered)
|
|
692
|
+
|
|
693
|
+
For the common case of one dot per slide with auto-generated labels,
|
|
694
|
+
use `Carousel.Indicators` — it reads the live slide count from
|
|
695
|
+
context and renders the right number of dots without any mapping
|
|
696
|
+
boilerplate:
|
|
697
|
+
|
|
698
|
+
```tsx
|
|
699
|
+
<Carousel.Root ariaLabel="Featured products">
|
|
700
|
+
<Carousel.Viewport>
|
|
701
|
+
<Carousel.Slide>1</Carousel.Slide>
|
|
702
|
+
<Carousel.Slide>2</Carousel.Slide>
|
|
703
|
+
<Carousel.Slide>3</Carousel.Slide>
|
|
704
|
+
</Carousel.Viewport>
|
|
705
|
+
<Carousel.Indicators label="Choose slide" />
|
|
706
|
+
</Carousel.Root>
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
The dot count tracks slide count automatically — add or remove a
|
|
710
|
+
slide and the indicator row updates on the next render. For custom
|
|
711
|
+
indicator content (thumbnails, numbers, mixed icons), drop down to
|
|
712
|
+
the manual `IndicatorGroup` + `Indicator` API above.
|
|
713
|
+
|
|
714
|
+
## Recommended CSS
|
|
715
|
+
|
|
716
|
+
The component ships zero styles. The recipe below is the minimum
|
|
717
|
+
needed to get a working horizontal carousel with snap-aligned slides,
|
|
718
|
+
dot indicators, and sane mobile behaviour. Drop it into your stylesheet
|
|
719
|
+
and target the `data-carousel-*` attributes:
|
|
720
|
+
|
|
721
|
+
```css
|
|
722
|
+
[data-carousel-viewport] {
|
|
723
|
+
display: flex;
|
|
724
|
+
overflow-x: auto;
|
|
725
|
+
scroll-snap-type: x mandatory;
|
|
726
|
+
scroll-behavior: smooth;
|
|
727
|
+
/* Prevent vertical page scroll from "rubber-banding" into the
|
|
728
|
+
carousel and vice-versa on iOS. */
|
|
729
|
+
overscroll-behavior-x: contain;
|
|
730
|
+
/* Hide native scrollbars on mobile while keeping scroll behaviour. */
|
|
731
|
+
scrollbar-width: none;
|
|
732
|
+
}
|
|
733
|
+
[data-carousel-viewport]::-webkit-scrollbar {
|
|
734
|
+
display: none;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
[data-carousel-slide] {
|
|
738
|
+
flex: 0 0 100%;
|
|
739
|
+
scroll-snap-align: start;
|
|
740
|
+
/* Stop the OS picking up images for drag/save during a swipe. */
|
|
741
|
+
-webkit-user-drag: none;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/* WCAG 2.5.8: 24×24 minimum hit area; 44×44 recommended for comfort
|
|
745
|
+
on phones. The visible dot stays small via ::before so the button's
|
|
746
|
+
hit area can be larger than its visual footprint. */
|
|
747
|
+
[data-carousel-indicator] {
|
|
748
|
+
min-width: 44px;
|
|
749
|
+
min-height: 44px;
|
|
750
|
+
display: inline-grid;
|
|
751
|
+
place-items: center;
|
|
752
|
+
}
|
|
753
|
+
[data-carousel-indicator]::before {
|
|
754
|
+
content: "";
|
|
755
|
+
width: 0.5rem;
|
|
756
|
+
height: 0.5rem;
|
|
757
|
+
border-radius: 50%;
|
|
758
|
+
background: lightgray;
|
|
759
|
+
}
|
|
760
|
+
[data-carousel-indicator][data-state="active"]::before {
|
|
761
|
+
background: black;
|
|
762
|
+
}
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
For multi-slide pages (`slidesPerPage={3}`, etc.), tune the slide's
|
|
766
|
+
`flex-basis` to share the viewport — e.g. `calc(100% / 3)` for three
|
|
767
|
+
slides per page, plus a `gap` on the viewport for the inter-slide
|
|
768
|
+
spacing. For a crossfade transition, use `transition="none"` on
|
|
769
|
+
`Carousel.Root` and style the slides absolute-positioned with an
|
|
770
|
+
opacity transition keyed off `[data-carousel-slide][data-state="active"]`.
|
|
771
|
+
|
|
772
|
+
### Cover Flow (scroll-driven 3D)
|
|
773
|
+
|
|
774
|
+
A "cover flow" — narrow snap units with cards that tilt away in 3D
|
|
775
|
+
and stack with depth — can be built entirely in CSS, with no extra JS,
|
|
776
|
+
using scroll-driven animations (`view-timeline` + `animation-timeline`).
|
|
777
|
+
The full working source is the workbench example at
|
|
778
|
+
`apps/workbench/src/pages/CarouselExample/examples/_coverFlow.scss`;
|
|
779
|
+
this section documents the design so it can be reused or ported.
|
|
780
|
+
|
|
781
|
+
**Structure.** Each slide is a narrow snap unit that owns the named
|
|
782
|
+
timelines. Inside it, a card-sized `visual` box is centred over the
|
|
783
|
+
snap unit (and overflows it so neighbours interleave) and handles the
|
|
784
|
+
`translateX`; inside that, a `card` element handles the `rotateY`.
|
|
785
|
+
Keep `perspective()` *inside* the keyframe transform — a `overflow-x`
|
|
786
|
+
scroll container flattens a `perspective` CSS property, but the
|
|
787
|
+
function form is immune.
|
|
788
|
+
|
|
789
|
+
**Two timelines, one slide.** The rotate/translate run on a tight
|
|
790
|
+
centred band (`view-timeline-inset` shrinks the scrollport) over
|
|
791
|
+
`animation-range: contain`. The depth order runs on a *second*,
|
|
792
|
+
full-scrollport timeline (`inset: 0`) over `animation-range: cover`.
|
|
793
|
+
The wide range matters: `z-index` is an animatable integer, so a
|
|
794
|
+
`1 → peak → 1` keyframe driven by each slide's own timeline makes the
|
|
795
|
+
most-centred card win the stack generically — no per-slide numbers.
|
|
796
|
+
A narrow range would clamp every off-centre slide to the boundary
|
|
797
|
+
`z-index`, leaving DOM order to (incorrectly) break the ties on one
|
|
798
|
+
side.
|
|
799
|
+
|
|
800
|
+
**Customising.** Drive every dimension from a small set of custom
|
|
801
|
+
properties on the root, and derive the rest with `calc()`:
|
|
802
|
+
|
|
803
|
+
```css
|
|
804
|
+
.cover-flow {
|
|
805
|
+
/* Geometry knobs */
|
|
806
|
+
--cf-viewport-w: 38rem; /* visible carousel width */
|
|
807
|
+
--cf-card-w: 180px; /* rendered card width */
|
|
808
|
+
--cf-aspect: 1.168; /* width ÷ height — 1 = square, >1 = wide */
|
|
809
|
+
--cf-snap: 90px; /* centre-to-centre spacing (< card-w → overlap) */
|
|
810
|
+
--cf-track-pad: 0px; /* grey track shown above/below the cards */
|
|
811
|
+
--cf-radius: 10px;
|
|
812
|
+
/* Motion knobs */
|
|
813
|
+
--cf-spread: 1.4; /* tilt band width, in multiples of --cf-snap */
|
|
814
|
+
--cf-tilt: 55deg; /* max rotateY of the side cards */
|
|
815
|
+
--cf-perspective: 500px;
|
|
816
|
+
--cf-shift: 30%; /* translateX of the card toward centre */
|
|
817
|
+
--cf-lift: 10; /* peak z-index of the centred card */
|
|
818
|
+
|
|
819
|
+
/* Derived — leave alone */
|
|
820
|
+
--cf-card-h: calc(var(--cf-card-w) / var(--cf-aspect));
|
|
821
|
+
--cf-viewport-h: calc(var(--cf-card-h) + var(--cf-track-pad) * 2);
|
|
822
|
+
--cf-center-pad: calc(50% - var(--cf-snap) / 2); /* centres first/last card */
|
|
823
|
+
--cf-band: calc(var(--cf-snap) * var(--cf-spread));
|
|
824
|
+
}
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
With that in place the three common tweaks are one line each:
|
|
828
|
+
|
|
829
|
+
- **Viewport size** — set `--cf-viewport-w`. The height follows the
|
|
830
|
+
cards automatically (`card-h + 2·track-pad`), so you rarely set it.
|
|
831
|
+
- **Padding / spacing** — `--cf-track-pad` adds grey track above and
|
|
832
|
+
below the cards; `--cf-snap` is the centre-to-centre spacing — the
|
|
833
|
+
smaller it is relative to `--cf-card-w`, the more the cards overlap
|
|
834
|
+
(it also sets the centring gutter).
|
|
835
|
+
- **Slide shape** — `--cf-aspect` is width ÷ height (`1` = exact
|
|
836
|
+
square, `>1` = landscape, `<1` = portrait); `--cf-card-w` is the
|
|
837
|
+
rendered size. A 240px square is `--cf-card-w: 240px; --cf-aspect: 1`.
|
|
838
|
+
|
|
839
|
+
Two gotchas:
|
|
840
|
+
|
|
841
|
+
- `--cf-aspect` must resolve to a *number*, not a fraction token.
|
|
842
|
+
`--cf-aspect: 16 / 9` lands inside `card-w / aspect` and divides
|
|
843
|
+
twice; use `--cf-aspect: calc(16 / 9)` (or a literal like `1.778`).
|
|
844
|
+
- The whole recipe relies on `animation-timeline` and `calc()` length
|
|
845
|
+
division. Wrap it in `@supports (animation-timeline: --x)` and keep
|
|
846
|
+
a static `[data-state="active"] { z-index: var(--cf-lift) }` fallback
|
|
847
|
+
plus `@media (prefers-reduced-motion: reduce) { animation: none }` so
|
|
848
|
+
the carousel degrades to a plain scroll-snap row.
|