@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,474 @@
|
|
|
1
|
+
import { Ref, useEffect, useId } from "react";
|
|
2
|
+
|
|
3
|
+
import { Portal } from "../Portal";
|
|
4
|
+
import { Slot, composeEventHandlers, composeRefs } from "../Slot";
|
|
5
|
+
|
|
6
|
+
import { ModalProvider } from "./ModalContext";
|
|
7
|
+
import {
|
|
8
|
+
useModalContent,
|
|
9
|
+
useModalContext,
|
|
10
|
+
useModalRoot,
|
|
11
|
+
useModalTrigger,
|
|
12
|
+
} from "./hooks";
|
|
13
|
+
import {
|
|
14
|
+
ModalCloseProps,
|
|
15
|
+
ModalContentProps,
|
|
16
|
+
ModalDescriptionProps,
|
|
17
|
+
ModalOverlayProps,
|
|
18
|
+
ModalPortalProps,
|
|
19
|
+
ModalRootProps,
|
|
20
|
+
ModalTitleProps,
|
|
21
|
+
ModalTriggerProps,
|
|
22
|
+
} from "./types";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The root of a Modal — owns open state, registers the dialog's id,
|
|
26
|
+
* title id, and description id, and exposes an imperative handle for
|
|
27
|
+
* opening / closing from outside the React subtree.
|
|
28
|
+
*
|
|
29
|
+
* Supports two state modes, statically discriminated at the type level:
|
|
30
|
+
*
|
|
31
|
+
* - **Uncontrolled** — pass {@link ModalRootProps.defaultOpen | `defaultOpen`}
|
|
32
|
+
* (or omit it for closed-on-mount). The component owns the open flag
|
|
33
|
+
* internally.
|
|
34
|
+
* - **Controlled** — pass {@link ModalRootProps.open | `open`} *and*
|
|
35
|
+
* {@link ModalRootProps.onOpenChange | `onOpenChange`} together. The
|
|
36
|
+
* parent owns the flag; the component defers every state change back
|
|
37
|
+
* through the callback.
|
|
38
|
+
*
|
|
39
|
+
* The imperative API is exposed via `ref`:
|
|
40
|
+
*
|
|
41
|
+
* ```tsx
|
|
42
|
+
* const ref = useRef<ModalImperativeApi>(null);
|
|
43
|
+
* ref.current?.open();
|
|
44
|
+
* ref.current?.close();
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* In controlled mode the imperative methods call `onOpenChange` rather
|
|
48
|
+
* than flipping any internal state — the parent stays in charge.
|
|
49
|
+
*
|
|
50
|
+
* @example Uncontrolled
|
|
51
|
+
* ```tsx
|
|
52
|
+
* <Modal.Root defaultOpen>
|
|
53
|
+
* <Modal.Content>…</Modal.Content>
|
|
54
|
+
* </Modal.Root>
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* @example Controlled
|
|
58
|
+
* ```tsx
|
|
59
|
+
* const [open, setOpen] = useState(false);
|
|
60
|
+
*
|
|
61
|
+
* <Modal.Root open={open} onOpenChange={setOpen}>
|
|
62
|
+
* <Modal.Trigger>Open</Modal.Trigger>
|
|
63
|
+
* <Modal.Portal>
|
|
64
|
+
* <Modal.Overlay />
|
|
65
|
+
* <Modal.Content>…</Modal.Content>
|
|
66
|
+
* </Modal.Portal>
|
|
67
|
+
* </Modal.Root>
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
function ModalRoot({
|
|
71
|
+
ref,
|
|
72
|
+
children,
|
|
73
|
+
defaultOpen,
|
|
74
|
+
open,
|
|
75
|
+
onOpenChange,
|
|
76
|
+
}: ModalRootProps) {
|
|
77
|
+
const { contextValue } = useModalRoot(
|
|
78
|
+
{ defaultOpen, open, onOpenChange },
|
|
79
|
+
ref,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return <ModalProvider value={contextValue}>{children}</ModalProvider>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
ModalRoot.displayName = "ModalRoot";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A button that toggles the modal open. Renders a
|
|
89
|
+
* `<button type="button">` with full ARIA wiring:
|
|
90
|
+
*
|
|
91
|
+
* - `aria-haspopup="dialog"`
|
|
92
|
+
* - `aria-expanded` tracks open state
|
|
93
|
+
* - `aria-controls` points at the `Modal.Content` dialog's id
|
|
94
|
+
*
|
|
95
|
+
* **`asChild` prop.** Pass `asChild` to render any consumer-supplied
|
|
96
|
+
* element (e.g. a router `<Link>`) with the trigger's ARIA attributes,
|
|
97
|
+
* composed event handlers, and ref merged in.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```tsx
|
|
101
|
+
* <Modal.Trigger>Open</Modal.Trigger>
|
|
102
|
+
*
|
|
103
|
+
* <Modal.Trigger asChild>
|
|
104
|
+
* <Link to="/upgrade">Upgrade</Link>
|
|
105
|
+
* </Modal.Trigger>
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
function ModalTrigger({
|
|
109
|
+
onClick,
|
|
110
|
+
type,
|
|
111
|
+
asChild = false,
|
|
112
|
+
...rest
|
|
113
|
+
}: ModalTriggerProps) {
|
|
114
|
+
const { getTriggerProps } = useModalTrigger(onClick, rest);
|
|
115
|
+
|
|
116
|
+
if (asChild) {
|
|
117
|
+
return <Slot {...getTriggerProps()} />;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return <button type={type ?? "button"} {...getTriggerProps()} />;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
ModalTrigger.displayName = "ModalTrigger";
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Renders its children through `React.createPortal` so the dialog is
|
|
127
|
+
* detached from wherever `Modal.Root` lives in the React tree and
|
|
128
|
+
* becomes a direct child of {@link ModalPortalProps.container | `container`}
|
|
129
|
+
* (default `document.body`).
|
|
130
|
+
*
|
|
131
|
+
* By default the portal only renders while the modal is open. Pass
|
|
132
|
+
* {@link ModalPortalProps.forceMount | `forceMount`} to keep the
|
|
133
|
+
* subtree in the DOM after `open` flips false — useful for CSS exit
|
|
134
|
+
* animations driven by `data-state="closed"`.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```tsx
|
|
138
|
+
* <Modal.Portal container={document.getElementById("modal-root")!}>
|
|
139
|
+
* <Modal.Overlay />
|
|
140
|
+
* <Modal.Content>…</Modal.Content>
|
|
141
|
+
* </Modal.Portal>
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
function ModalPortal({ children, container, forceMount }: ModalPortalProps) {
|
|
145
|
+
const { open } = useModalContext();
|
|
146
|
+
|
|
147
|
+
if (!open && !forceMount) return null;
|
|
148
|
+
|
|
149
|
+
return <Portal container={container}>{children}</Portal>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
ModalPortal.displayName = "ModalPortal";
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A decorative, animation-friendly backdrop rendered as a **sibling**
|
|
156
|
+
* of `Modal.Content`. It is **not** an event surface for
|
|
157
|
+
* click-outside — opening a native `<dialog>` via `showModal()`
|
|
158
|
+
* promotes it to the top layer with a browser-painted `::backdrop`
|
|
159
|
+
* that sits *above* this overlay, so clicks on the visible backdrop
|
|
160
|
+
* never reach the overlay div. Click-outside-to-close is wired on
|
|
161
|
+
* the dialog itself via {@link ModalContentCallbacks.onPointerDownOutside | `onPointerDownOutside`}.
|
|
162
|
+
*
|
|
163
|
+
* - `aria-hidden="true"` (the backdrop is decorative).
|
|
164
|
+
* - `data-state="open" | "closed"` follows the modal's open state.
|
|
165
|
+
* - Consumer `onClick` (and any other event handler) is forwarded
|
|
166
|
+
* through — the overlay does not compose or intercept them.
|
|
167
|
+
*
|
|
168
|
+
* **`asChild` prop.** Pass `asChild` to render a consumer-supplied
|
|
169
|
+
* element (e.g. a motion wrapper) with the overlay's ARIA and
|
|
170
|
+
* data-state merged in.
|
|
171
|
+
*
|
|
172
|
+
* **`forceMount` prop.** Pass `forceMount` to keep the overlay in the
|
|
173
|
+
* DOM while `open` is false so a CSS exit animation can play.
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* ```tsx
|
|
177
|
+
* <Modal.Overlay />
|
|
178
|
+
*
|
|
179
|
+
* <Modal.Overlay asChild forceMount>
|
|
180
|
+
* <motion.div
|
|
181
|
+
* initial={{ opacity: 0 }}
|
|
182
|
+
* animate={{ opacity: 1 }}
|
|
183
|
+
* exit={{ opacity: 0 }}
|
|
184
|
+
* />
|
|
185
|
+
* </Modal.Overlay>
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
function ModalOverlay({
|
|
189
|
+
asChild = false,
|
|
190
|
+
forceMount,
|
|
191
|
+
...rest
|
|
192
|
+
}: ModalOverlayProps) {
|
|
193
|
+
const { open } = useModalContext();
|
|
194
|
+
if (!open && !forceMount) return null;
|
|
195
|
+
const overlayProps = {
|
|
196
|
+
...rest,
|
|
197
|
+
"aria-hidden": "true" as const,
|
|
198
|
+
"data-state": (open ? "open" : "closed") as "open" | "closed",
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (asChild) {
|
|
202
|
+
return <Slot {...overlayProps} />;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return <div {...overlayProps} />;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
ModalOverlay.displayName = "ModalOverlay";
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The native `<dialog>` element. React drives its `showModal()` and
|
|
212
|
+
* `close()` calls in response to `open` state changes, letting the
|
|
213
|
+
* browser own focus trapping, inert background, top-layer rendering,
|
|
214
|
+
* and the `Esc` key.
|
|
215
|
+
*
|
|
216
|
+
* **ARIA wiring.** `aria-labelledby` and `aria-describedby` are set
|
|
217
|
+
* automatically when `Modal.Title` and `Modal.Description` are rendered
|
|
218
|
+
* as descendants. The dialog has an implicit `role="dialog"` — do not
|
|
219
|
+
* pass one explicitly (WCAG redundancy rule).
|
|
220
|
+
*
|
|
221
|
+
* **Escape hatches.**
|
|
222
|
+
* - {@link ModalContentCallbacks.onEscapeKeyDown | `onEscapeKeyDown`}
|
|
223
|
+
* fires on the native `cancel` event (Esc). Consumers can
|
|
224
|
+
* `event.preventDefault()` to keep the modal open.
|
|
225
|
+
* - {@link ModalContentCallbacks.onPointerDownOutside | `onPointerDownOutside`}
|
|
226
|
+
* fires on a `pointerdown` whose coordinates land outside the
|
|
227
|
+
* dialog's bounding rect — i.e. on the native `::backdrop`. Consumers
|
|
228
|
+
* can `event.preventDefault()` to keep the modal open. The native
|
|
229
|
+
* `PointerEvent` is passed through, not a synthetic React event.
|
|
230
|
+
*
|
|
231
|
+
* **`asChild` is intentionally not supported.** The native dialog
|
|
232
|
+
* primitive is what provides the focus trap and the inert background;
|
|
233
|
+
* swapping it for a `<div>` would break both.
|
|
234
|
+
*
|
|
235
|
+
* **Styling hook.** `data-state="open" | "closed"` on the dialog.
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* ```tsx
|
|
239
|
+
* <Modal.Content
|
|
240
|
+
* onEscapeKeyDown={(event) => {
|
|
241
|
+
* if (hasUnsavedChanges) event.preventDefault();
|
|
242
|
+
* }}
|
|
243
|
+
* >
|
|
244
|
+
* …
|
|
245
|
+
* </Modal.Content>
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
function ModalContent({
|
|
249
|
+
children,
|
|
250
|
+
id,
|
|
251
|
+
onEscapeKeyDown,
|
|
252
|
+
onPointerDownOutside,
|
|
253
|
+
ref: externalRef,
|
|
254
|
+
...rest
|
|
255
|
+
}: ModalContentProps & { ref?: Ref<HTMLDialogElement> }) {
|
|
256
|
+
const { ref: innerRef, open, contentId } = useModalContent();
|
|
257
|
+
const { contentCallbacksRef, titleId, descriptionId } = useModalContext();
|
|
258
|
+
// Keep the ref pointed at the latest callbacks so event handlers wired
|
|
259
|
+
// through context (dialog pointerdown, native cancel) always see the most
|
|
260
|
+
// recent consumer props across re-renders.
|
|
261
|
+
contentCallbacksRef.current = { onEscapeKeyDown, onPointerDownOutside };
|
|
262
|
+
const composedRef = externalRef
|
|
263
|
+
? composeRefs(innerRef, externalRef)
|
|
264
|
+
: innerRef;
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<dialog
|
|
268
|
+
ref={composedRef}
|
|
269
|
+
id={id ?? contentId}
|
|
270
|
+
data-state={open ? "open" : "closed"}
|
|
271
|
+
aria-labelledby={titleId}
|
|
272
|
+
aria-describedby={descriptionId}
|
|
273
|
+
{...rest}
|
|
274
|
+
>
|
|
275
|
+
{children}
|
|
276
|
+
</dialog>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
ModalContent.displayName = "ModalContent";
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The dialog's accessible name. Renders an `<h2>` by default and
|
|
284
|
+
* auto-registers its generated id with `Modal.Root` so
|
|
285
|
+
* `Modal.Content` can wire it up as `aria-labelledby`.
|
|
286
|
+
*
|
|
287
|
+
* Pass `asChild` to render the consumer's own heading element (e.g.
|
|
288
|
+
* an `<h3>` for a nested dialog or a styled heading component); the
|
|
289
|
+
* id is still registered.
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* ```tsx
|
|
293
|
+
* <Modal.Title>Payment</Modal.Title>
|
|
294
|
+
*
|
|
295
|
+
* <Modal.Title asChild>
|
|
296
|
+
* <h3>Nested section</h3>
|
|
297
|
+
* </Modal.Title>
|
|
298
|
+
* ```
|
|
299
|
+
*/
|
|
300
|
+
function ModalTitle({ children, asChild = false, ...rest }: ModalTitleProps) {
|
|
301
|
+
const { registerTitle } = useModalContext();
|
|
302
|
+
const id = useId();
|
|
303
|
+
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
registerTitle(id);
|
|
306
|
+
return () => registerTitle(undefined);
|
|
307
|
+
}, [registerTitle, id]);
|
|
308
|
+
|
|
309
|
+
if (asChild) {
|
|
310
|
+
return (
|
|
311
|
+
<Slot id={id} {...rest}>
|
|
312
|
+
{children}
|
|
313
|
+
</Slot>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<h2 id={id} {...rest}>
|
|
319
|
+
{children}
|
|
320
|
+
</h2>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
ModalTitle.displayName = "ModalTitle";
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The dialog's accessible description. Renders a `<p>` by default and
|
|
328
|
+
* auto-registers its generated id with `Modal.Root` so
|
|
329
|
+
* `Modal.Content` can wire it up as `aria-describedby`.
|
|
330
|
+
*
|
|
331
|
+
* Pass `asChild` to render any consumer-supplied element; the id is
|
|
332
|
+
* still registered.
|
|
333
|
+
*
|
|
334
|
+
* @example
|
|
335
|
+
* ```tsx
|
|
336
|
+
* <Modal.Description>
|
|
337
|
+
* Enter your card details below. You can change your plan later.
|
|
338
|
+
* </Modal.Description>
|
|
339
|
+
* ```
|
|
340
|
+
*/
|
|
341
|
+
function ModalDescription({
|
|
342
|
+
children,
|
|
343
|
+
asChild = false,
|
|
344
|
+
...rest
|
|
345
|
+
}: ModalDescriptionProps) {
|
|
346
|
+
const { registerDescription } = useModalContext();
|
|
347
|
+
const id = useId();
|
|
348
|
+
|
|
349
|
+
useEffect(() => {
|
|
350
|
+
registerDescription(id);
|
|
351
|
+
return () => registerDescription(undefined);
|
|
352
|
+
}, [registerDescription, id]);
|
|
353
|
+
|
|
354
|
+
if (asChild) {
|
|
355
|
+
return (
|
|
356
|
+
<Slot id={id} {...rest}>
|
|
357
|
+
{children}
|
|
358
|
+
</Slot>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<p id={id} {...rest}>
|
|
364
|
+
{children}
|
|
365
|
+
</p>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
ModalDescription.displayName = "ModalDescription";
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* A button that closes the modal. Renders a `<button type="button">`
|
|
373
|
+
* whose `onClick` is composed with `setOpen(false)` — consumer
|
|
374
|
+
* handlers run first and can `event.preventDefault()` to veto closing.
|
|
375
|
+
*
|
|
376
|
+
* **`asChild` prop.** Pass `asChild` to render any element (e.g. a
|
|
377
|
+
* text link or an icon-only `<button>` of your own) with the close
|
|
378
|
+
* behaviour merged in.
|
|
379
|
+
*
|
|
380
|
+
* @example
|
|
381
|
+
* ```tsx
|
|
382
|
+
* <Modal.Close>Cancel</Modal.Close>
|
|
383
|
+
*
|
|
384
|
+
* <Modal.Close asChild>
|
|
385
|
+
* <IconButton aria-label="Close" icon={<XIcon />} />
|
|
386
|
+
* </Modal.Close>
|
|
387
|
+
* ```
|
|
388
|
+
*/
|
|
389
|
+
function ModalClose({ onClick, asChild = false, ...rest }: ModalCloseProps) {
|
|
390
|
+
const { setOpen } = useModalContext();
|
|
391
|
+
const closeProps = {
|
|
392
|
+
...rest,
|
|
393
|
+
onClick: composeEventHandlers(onClick, () => setOpen(false)),
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
if (asChild) {
|
|
397
|
+
return <Slot {...closeProps} />;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return <button type="button" {...closeProps} />;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
ModalClose.displayName = "ModalClose";
|
|
404
|
+
|
|
405
|
+
type ModalCompound = typeof ModalRoot & {
|
|
406
|
+
Root: typeof ModalRoot;
|
|
407
|
+
Trigger: typeof ModalTrigger;
|
|
408
|
+
Portal: typeof ModalPortal;
|
|
409
|
+
Overlay: typeof ModalOverlay;
|
|
410
|
+
Content: typeof ModalContent;
|
|
411
|
+
Title: typeof ModalTitle;
|
|
412
|
+
Description: typeof ModalDescription;
|
|
413
|
+
Close: typeof ModalClose;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Headless, accessible **Modal** — a compound component built on the
|
|
418
|
+
* native `<dialog>` element and its `showModal()` API. Implements the
|
|
419
|
+
* [WAI-ARIA Modal Dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)
|
|
420
|
+
* with zero styles.
|
|
421
|
+
*
|
|
422
|
+
* `Modal` is both callable (it's an alias of {@link ModalRoot | `Modal.Root`})
|
|
423
|
+
* and carries its sub-components as static properties. Prefer the
|
|
424
|
+
* namespaced form in application code for readability and grep-ability.
|
|
425
|
+
*
|
|
426
|
+
* - {@link ModalRoot | `Modal.Root`} — state owner, context provider, imperative API holder.
|
|
427
|
+
* - {@link ModalTrigger | `Modal.Trigger`} — `<button>` that opens the modal.
|
|
428
|
+
* - {@link ModalPortal | `Modal.Portal`} — `createPortal` wrapper with `container` + `forceMount`.
|
|
429
|
+
* - {@link ModalOverlay | `Modal.Overlay`} — click-outside backdrop sibling of the dialog.
|
|
430
|
+
* - {@link ModalContent | `Modal.Content`} — native `<dialog>` with escape hatches and auto-ARIA.
|
|
431
|
+
* - {@link ModalTitle | `Modal.Title`} — accessible name; auto-wires `aria-labelledby`.
|
|
432
|
+
* - {@link ModalDescription | `Modal.Description`} — auto-wires `aria-describedby`.
|
|
433
|
+
* - {@link ModalClose | `Modal.Close`} — `<button>` that closes the modal.
|
|
434
|
+
*
|
|
435
|
+
* @example Minimal usage
|
|
436
|
+
* ```tsx
|
|
437
|
+
* import { Modal } from "@primitiv-ui/react";
|
|
438
|
+
*
|
|
439
|
+
* <Modal.Root>
|
|
440
|
+
* <Modal.Trigger>Open</Modal.Trigger>
|
|
441
|
+
* <Modal.Portal>
|
|
442
|
+
* <Modal.Overlay />
|
|
443
|
+
* <Modal.Content>
|
|
444
|
+
* <Modal.Title>Payment</Modal.Title>
|
|
445
|
+
* <Modal.Description>Enter your card details</Modal.Description>
|
|
446
|
+
* <Modal.Close>Cancel</Modal.Close>
|
|
447
|
+
* </Modal.Content>
|
|
448
|
+
* </Modal.Portal>
|
|
449
|
+
* </Modal.Root>;
|
|
450
|
+
* ```
|
|
451
|
+
*
|
|
452
|
+
* @example Scroll lock (CSS only)
|
|
453
|
+
* ```css
|
|
454
|
+
* html:has(dialog[open]) { overflow: hidden; }
|
|
455
|
+
* ```
|
|
456
|
+
*
|
|
457
|
+
* @see {@link ModalRoot} for state modes and the imperative API.
|
|
458
|
+
* @see {@link ModalContent} for the escape hatches and ARIA auto-wiring.
|
|
459
|
+
* @see {@link ModalOverlay} for the click-outside contract.
|
|
460
|
+
*/
|
|
461
|
+
const ModalCompound: ModalCompound = Object.assign(ModalRoot, {
|
|
462
|
+
Root: ModalRoot,
|
|
463
|
+
Trigger: ModalTrigger,
|
|
464
|
+
Portal: ModalPortal,
|
|
465
|
+
Overlay: ModalOverlay,
|
|
466
|
+
Content: ModalContent,
|
|
467
|
+
Title: ModalTitle,
|
|
468
|
+
Description: ModalDescription,
|
|
469
|
+
Close: ModalClose,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
ModalCompound.displayName = "Modal";
|
|
473
|
+
|
|
474
|
+
export { ModalCompound as Modal };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createStrictContext } from "../utils";
|
|
2
|
+
|
|
3
|
+
import { ModalContextValue } from "./types";
|
|
4
|
+
|
|
5
|
+
export const [ModalContext, useModalContext] =
|
|
6
|
+
createStrictContext<ModalContextValue>(
|
|
7
|
+
"Component must be rendered as a child of Modal.Root",
|
|
8
|
+
"ModalContext",
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
const ModalProvider = ModalContext.Provider;
|
|
12
|
+
|
|
13
|
+
export { ModalProvider };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Modal
|
|
2
|
+
|
|
3
|
+
A headless, accessible compound component implementing the
|
|
4
|
+
[WAI-ARIA Modal Dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/).
|
|
5
|
+
|
|
6
|
+
Modal is built on the native
|
|
7
|
+
[`<dialog>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog)
|
|
8
|
+
element and its `showModal()` API, so focus trapping, inert background,
|
|
9
|
+
top-layer stacking, and Esc-to-close are handled by the browser. The
|
|
10
|
+
React layer adds what native `<dialog>` doesn't give you:
|
|
11
|
+
|
|
12
|
+
- Click-outside-to-close via a `pointerdown` listener on the dialog
|
|
13
|
+
that checks the pointer against `getBoundingClientRect()` — coords
|
|
14
|
+
outside the rect mean the pointer landed on the native `::backdrop`.
|
|
15
|
+
- A portal for placing the dialog at the end of `document.body`.
|
|
16
|
+
- Controlled / uncontrolled state plumbing.
|
|
17
|
+
- `asChild` composition for every slot-able sub-component.
|
|
18
|
+
- An imperative API for firing open/close from outside the subtree.
|
|
19
|
+
- `forceMount` hooks for driving CSS exit animations.
|
|
20
|
+
|
|
21
|
+
### Why is click-outside on the dialog, not the overlay?
|
|
22
|
+
|
|
23
|
+
`showModal()` promotes the `<dialog>` into the browser's **top layer**
|
|
24
|
+
and paints a `::backdrop` pseudo-element underneath it — also in the
|
|
25
|
+
top layer. Any sibling `Modal.Overlay` sits _below_ that backdrop
|
|
26
|
+
visually, so clicks on the "outside" area never reach it. Detection
|
|
27
|
+
therefore has to live on the dialog itself: a `pointerdown` whose
|
|
28
|
+
coordinates fall outside the dialog's bounding rect is, by
|
|
29
|
+
elimination, a click on the backdrop. `Modal.Overlay` stays in the
|
|
30
|
+
tree as a cosmetic + animation surface (for custom backdrop styling
|
|
31
|
+
or motion wrappers that exceed what `::backdrop` can express).
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
import { Modal } from "@primitiv-ui/react";
|
|
35
|
+
|
|
36
|
+
<Modal.Root open={open} onOpenChange={setOpen}>
|
|
37
|
+
<Modal.Trigger>Open</Modal.Trigger>
|
|
38
|
+
<Modal.Portal>
|
|
39
|
+
<Modal.Overlay />
|
|
40
|
+
<Modal.Content>
|
|
41
|
+
<Modal.Title>Payment</Modal.Title>
|
|
42
|
+
<Modal.Description>Enter your card details</Modal.Description>
|
|
43
|
+
{/* body */}
|
|
44
|
+
<Modal.Close>Cancel</Modal.Close>
|
|
45
|
+
</Modal.Content>
|
|
46
|
+
</Modal.Portal>
|
|
47
|
+
</Modal.Root>;
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Sub-components
|
|
51
|
+
|
|
52
|
+
| Export | Element | Notes |
|
|
53
|
+
| ------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
54
|
+
| `Modal.Root` | context provider | Owns open state. Exposes `ModalImperativeApi` via `ref` |
|
|
55
|
+
| `Modal.Trigger` | `<button>` | `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls`. `asChild` |
|
|
56
|
+
| `Modal.Portal` | `createPortal` | `container?: HTMLElement` (default `document.body`). `forceMount` |
|
|
57
|
+
| `Modal.Overlay` | `<div>` (sibling) | Decorative / animation target — **not** a click-outside event surface (see below). `aria-hidden="true"`. `data-state`. `asChild`, `forceMount` |
|
|
58
|
+
| `Modal.Content` | `<dialog>` | Native modal dialog. `data-state`. Escape hatches for Esc / click-outside |
|
|
59
|
+
| `Modal.Title` | `<h2>` | Auto-wires `aria-labelledby` on Content. `asChild` |
|
|
60
|
+
| `Modal.Description` | `<p>` | Auto-wires `aria-describedby` on Content. `asChild` |
|
|
61
|
+
| `Modal.Close` | `<button>` | Closes the modal. `asChild` |
|
|
62
|
+
|
|
63
|
+
## Keyboard interaction
|
|
64
|
+
|
|
65
|
+
| Key | Behaviour |
|
|
66
|
+
| ----- | ----------------------------------------------------------------------------- |
|
|
67
|
+
| `Esc` | Closes the modal (native `cancel` event). Preventable via `onEscapeKeyDown` |
|
|
68
|
+
| `Tab` | Focus is trapped inside the dialog by the browser's native modal dialog logic |
|
|
69
|
+
|
|
70
|
+
## State modes
|
|
71
|
+
|
|
72
|
+
- **Uncontrolled** — pass `defaultOpen` (or omit for closed on mount).
|
|
73
|
+
- **Controlled** — pass `open` **and** `onOpenChange` together.
|
|
74
|
+
|
|
75
|
+
The two shapes are statically discriminated at the type level; TypeScript
|
|
76
|
+
rejects mixing them.
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
// Uncontrolled
|
|
80
|
+
<Modal.Root defaultOpen>…</Modal.Root>;
|
|
81
|
+
|
|
82
|
+
// Controlled
|
|
83
|
+
const [open, setOpen] = useState(false);
|
|
84
|
+
<Modal.Root open={open} onOpenChange={setOpen}>
|
|
85
|
+
…
|
|
86
|
+
</Modal.Root>;
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Escape hatches
|
|
90
|
+
|
|
91
|
+
Both close paths — `Esc` and click-outside — fire observable callbacks
|
|
92
|
+
on `Modal.Content`. Call `event.preventDefault()` to keep the modal
|
|
93
|
+
open. `onPointerDownOutside` receives the native `PointerEvent`
|
|
94
|
+
(fires on `pointerdown`, not `click`, matching the prop name).
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
<Modal.Content
|
|
98
|
+
onEscapeKeyDown={(event) => {
|
|
99
|
+
if (hasUnsavedChanges) event.preventDefault();
|
|
100
|
+
}}
|
|
101
|
+
onPointerDownOutside={(event) => {
|
|
102
|
+
if (isRequiredFlow) event.preventDefault();
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
…
|
|
106
|
+
</Modal.Content>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Imperative API
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
import { Modal, type ModalImperativeApi } from "@primitiv-ui/react";
|
|
113
|
+
|
|
114
|
+
const ref = useRef<ModalImperativeApi>(null);
|
|
115
|
+
|
|
116
|
+
<Modal.Root ref={ref}>…</Modal.Root>;
|
|
117
|
+
|
|
118
|
+
ref.current?.open();
|
|
119
|
+
ref.current?.close();
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
In controlled mode the imperative methods delegate to `onOpenChange` —
|
|
123
|
+
the parent stays in charge of the actual state update.
|
|
124
|
+
|
|
125
|
+
## `asChild` composition
|
|
126
|
+
|
|
127
|
+
`Modal.Trigger`, `Modal.Close`, `Modal.Title`, `Modal.Description`, and
|
|
128
|
+
`Modal.Overlay` accept an `asChild` boolean. When set, the component
|
|
129
|
+
delegates rendering to its single child element and merges its own ARIA
|
|
130
|
+
attributes, ids, composed event handlers, and ref onto the child
|
|
131
|
+
following the asChild pattern (child handler runs first, then the
|
|
132
|
+
library's). `Modal.Overlay` is decorative and has no library-side
|
|
133
|
+
handlers, so only its ARIA and `data-state` are forwarded.
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
// Router link with dialog-trigger semantics
|
|
137
|
+
<Modal.Trigger asChild>
|
|
138
|
+
<Link to="/upgrade">Upgrade</Link>
|
|
139
|
+
</Modal.Trigger>
|
|
140
|
+
|
|
141
|
+
// Alternate heading level
|
|
142
|
+
<Modal.Title asChild>
|
|
143
|
+
<h3>Payment</h3>
|
|
144
|
+
</Modal.Title>
|
|
145
|
+
|
|
146
|
+
// Motion wrapper on the backdrop
|
|
147
|
+
<Modal.Overlay asChild>
|
|
148
|
+
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} />
|
|
149
|
+
</Modal.Overlay>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`Modal.Content` is intentionally **not** slot-able. Its native
|
|
153
|
+
`<dialog>` element is the whole reason Modal works without a focus
|
|
154
|
+
trap or a scroll-lock library, so we don't expose a way to swap it.
|
|
155
|
+
|
|
156
|
+
## Animation hooks
|
|
157
|
+
|
|
158
|
+
`Modal.Portal` and `Modal.Overlay` accept a `forceMount` boolean. When
|
|
159
|
+
set, the subtree stays in the DOM regardless of `open` state so a CSS
|
|
160
|
+
animation can play against the `data-state="closed"` attribute:
|
|
161
|
+
|
|
162
|
+
```tsx
|
|
163
|
+
<Modal.Portal forceMount>
|
|
164
|
+
<Modal.Overlay forceMount />
|
|
165
|
+
<Modal.Content>…</Modal.Content>
|
|
166
|
+
</Modal.Portal>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```css
|
|
170
|
+
[data-state="open"] {
|
|
171
|
+
animation: fade-in 150ms ease-out;
|
|
172
|
+
}
|
|
173
|
+
[data-state="closed"] {
|
|
174
|
+
animation: fade-out 120ms ease-in forwards;
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Consumers who use `forceMount` own the unmount lifecycle themselves
|
|
179
|
+
(e.g. by tracking a separate `presence` state that flips false only
|
|
180
|
+
once the exit animation ends).
|
|
181
|
+
|
|
182
|
+
## Scroll lock
|
|
183
|
+
|
|
184
|
+
Modal intentionally ships no JavaScript scroll lock. The one-line CSS
|
|
185
|
+
equivalent works in every modern browser:
|
|
186
|
+
|
|
187
|
+
```css
|
|
188
|
+
html:has(dialog[open]) {
|
|
189
|
+
overflow: hidden;
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Styling hooks
|
|
194
|
+
|
|
195
|
+
`data-state="open" | "closed"` is set on `Modal.Overlay` and
|
|
196
|
+
`Modal.Content`, letting any CSS system target the two phases.
|
|
197
|
+
|
|
198
|
+
```css
|
|
199
|
+
dialog[data-state="open"] {
|
|
200
|
+
display: grid;
|
|
201
|
+
place-items: center;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
dialog::backdrop {
|
|
205
|
+
background: oklch(0 0 0 / 0.6);
|
|
206
|
+
}
|
|
207
|
+
```
|