@primitiv-ui/react 0.1.28 → 0.1.29
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 +4 -0
- package/package.json +6 -1
- package/src/AccessibleIcon/AccessibleIcon.tsx +32 -9
- package/src/AccessibleIcon/__tests__/AccessibleIcon.test.tsx +5 -0
- package/src/AccessibleIcon/types.ts +15 -2
- package/src/Accordion/Accordion.tsx +25 -4
- package/src/Accordion/__tests__/Accordion.asChild.test.tsx +46 -0
- package/src/Accordion/__tests__/Accordion.basic-rendering.test.tsx +15 -0
- package/src/Accordion/__tests__/Accordion.disabled-items.test.tsx +42 -0
- package/src/Accordion/__tests__/Accordion.error-handling.test.tsx +33 -0
- package/src/Accordion/hooks/useAccordionRoot.ts +44 -7
- package/src/Accordion/hooks/useAccordionTrigger.ts +9 -1
- package/src/Accordion/types.ts +72 -21
- package/src/Alert/Alert.tsx +36 -7
- package/src/Alert/__tests__/Alert.test.tsx +5 -0
- package/src/Alert/types.ts +18 -2
- package/src/Avatar/Avatar.tsx +99 -15
- package/src/Avatar/__tests__/Avatar.basic-rendering.test.tsx +10 -0
- package/src/Avatar/__tests__/Avatar.fallback.test.tsx +30 -0
- package/src/Avatar/__tests__/Avatar.image-loading.test.tsx +35 -0
- package/src/Avatar/hooks/useAvatarImage.ts +16 -0
- package/src/Avatar/types.ts +15 -3
- package/src/Breadcrumb/Breadcrumb.tsx +136 -18
- package/src/Breadcrumb/README.md +47 -0
- package/src/Breadcrumb/__tests__/Breadcrumb.basic-rendering.test.tsx +11 -0
- package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +110 -0
- package/src/Breadcrumb/types.ts +73 -7
- package/src/Button/Button.tsx +2 -0
- package/src/Button/__tests__/Button.basic-rendering.test.tsx +6 -0
- package/src/Button/types.ts +21 -0
- package/src/Carousel/Carousel.tsx +263 -15
- package/src/Carousel/README.md +749 -72
- package/src/Carousel/__tests__/Carousel.autoplay-status.test.tsx +239 -0
- package/src/Carousel/__tests__/Carousel.drag-status.test.tsx +292 -0
- package/src/Carousel/__tests__/Carousel.dynamic-slides.test.tsx +38 -0
- package/src/Carousel/__tests__/Carousel.indicators.test.tsx +101 -0
- package/src/Carousel/__tests__/Carousel.infinite.test.tsx +1453 -0
- package/src/Carousel/__tests__/Carousel.instant-scroll.test.tsx +124 -0
- package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +88 -0
- package/src/Carousel/__tests__/Carousel.loop.test.tsx +163 -0
- package/src/Carousel/__tests__/Carousel.loopEngine.test.ts +77 -0
- package/src/Carousel/__tests__/Carousel.mouse-drag.test.tsx +504 -0
- package/src/Carousel/__tests__/Carousel.multi-slide-bounds.test.tsx +91 -0
- package/src/Carousel/__tests__/Carousel.orientation.test.tsx +207 -0
- package/src/Carousel/__tests__/Carousel.overscroll.test.tsx +532 -0
- package/src/Carousel/__tests__/Carousel.page-snap-points.test.tsx +50 -0
- package/src/Carousel/__tests__/Carousel.progress-text.test.tsx +76 -0
- package/src/Carousel/__tests__/Carousel.reduced-motion.test.tsx +3 -6
- package/src/Carousel/__tests__/Carousel.refresh-progress.test.tsx +2 -5
- package/src/Carousel/__tests__/Carousel.scroll-margin.test.tsx +167 -0
- package/src/Carousel/__tests__/Carousel.scroll-progress.test.tsx +518 -0
- package/src/Carousel/__tests__/Carousel.scroll-snap-change.test.tsx +32 -11
- package/src/Carousel/__tests__/Carousel.scroll-sync.test.tsx +237 -24
- package/src/Carousel/__tests__/Carousel.scroll-to-index.test.tsx +115 -0
- package/src/Carousel/__tests__/Carousel.slide-snap.test.tsx +172 -0
- package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +179 -1
- package/src/Carousel/__tests__/Carousel.slides-per-page.test.tsx +91 -7
- package/src/Carousel/__tests__/Carousel.snap-type.test.tsx +35 -0
- package/src/Carousel/__tests__/Carousel.transition-modes.test.tsx +30 -5
- package/src/Carousel/__tests__/Carousel.translations.test.tsx +23 -0
- package/src/Carousel/__tests__/Carousel.wheel.test.tsx +160 -0
- package/src/Carousel/hooks/index.ts +1 -0
- package/src/Carousel/hooks/useCarouselLoop.ts +869 -0
- package/src/Carousel/hooks/useCarouselRoot.ts +338 -42
- package/src/Carousel/hooks/useCarouselSlide.ts +44 -9
- package/src/Carousel/hooks/useCarouselViewport.ts +649 -46
- package/src/Carousel/loopEngine.ts +54 -0
- package/src/Carousel/types.ts +457 -35
- package/src/Checkbox/Checkbox.tsx +9 -1
- package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +14 -0
- package/src/Checkbox/hooks/useCheckboxInput.ts +4 -0
- package/src/Checkbox/types.ts +19 -0
- package/src/CheckboxCard/CheckboxCard.tsx +13 -4
- package/src/CheckboxCard/__tests__/CheckboxCard.basic-rendering.test.tsx +7 -0
- package/src/CheckboxCard/types.ts +65 -17
- package/src/Collapsible/Collapsible.tsx +49 -3
- package/src/Collapsible/README.md +40 -0
- package/src/Collapsible/__tests__/Collapsible.asChild.test.tsx +89 -0
- package/src/Collapsible/__tests__/Collapsible.basic-rendering.test.tsx +27 -0
- package/src/Collapsible/__tests__/Collapsible.collapsedHeight.test.tsx +140 -0
- package/src/Collapsible/hooks/useCollapsibleTrigger.ts +3 -1
- package/src/Collapsible/types.ts +89 -22
- package/src/ContextMenu/ContextMenu.tsx +270 -4
- package/src/ContextMenu/types.ts +148 -6
- package/src/DirectionProvider/DirectionProvider.tsx +41 -9
- package/src/DirectionProvider/__tests__/DirectionProvider.test.tsx +5 -0
- package/src/DirectionProvider/types.ts +28 -3
- package/src/Divider/Divider.tsx +24 -10
- package/src/Divider/__tests__/Divider.test.tsx +5 -0
- package/src/Divider/types.ts +10 -3
- package/src/Drawer/Drawer.tsx +315 -0
- package/src/Drawer/README.md +124 -0
- package/src/Drawer/__tests__/Drawer.behavior.test.tsx +100 -0
- package/src/Drawer/__tests__/Drawer.focus-trap.test.tsx +55 -0
- package/src/Drawer/__tests__/Drawer.rendering.test.tsx +111 -0
- package/src/Drawer/__tests__/Drawer.side.test.tsx +69 -0
- package/src/Drawer/__tests__/dialog-polyfill.ts +39 -0
- package/src/Drawer/index.ts +2 -0
- package/src/Drawer/types.ts +66 -0
- package/src/Dropdown/Dropdown.tsx +117 -0
- package/src/Dropdown/types.ts +241 -11
- package/src/EmptyState/EmptyState.tsx +74 -4
- package/src/EmptyState/__tests__/EmptyState.Actions.test.tsx +7 -2
- package/src/EmptyState/__tests__/EmptyState.Description.test.tsx +3 -1
- package/src/EmptyState/__tests__/EmptyState.Root.test.tsx +10 -0
- package/src/EmptyState/__tests__/EmptyState.Title.test.tsx +3 -1
- package/src/EmptyState/types.ts +49 -6
- package/src/Field/Field.tsx +23 -0
- package/src/Field/__tests__/Field.basic-rendering.test.tsx +11 -0
- package/src/Field/__tests__/Field.error-handling.test.tsx +23 -0
- package/src/Field/__tests__/Field.state-cascade.test.tsx +21 -0
- package/src/Field/hooks/__tests__/useFieldProps.test.tsx +104 -9
- package/src/Field/types.ts +60 -12
- package/src/Fieldset/Fieldset.tsx +54 -18
- package/src/Fieldset/__tests__/Fieldset.basic-rendering.test.tsx +9 -0
- package/src/Fieldset/types.ts +20 -2
- package/src/Input/Input.tsx +14 -9
- package/src/Input/__tests__/Input.basic-rendering.test.tsx +5 -0
- package/src/Input/types.ts +19 -2
- package/src/InputGroup/InputGroup.tsx +11 -3
- package/src/InputGroup/__tests__/InputGroup.basic-rendering.test.tsx +14 -0
- package/src/InputGroup/types.ts +26 -5
- package/src/MillerColumns/MillerColumns.tsx +86 -19
- package/src/MillerColumns/types.ts +83 -27
- package/src/Modal/Modal.tsx +31 -4
- package/src/Modal/README.md +19 -10
- package/src/Modal/__tests__/Modal.focus-trap.test.tsx +162 -0
- package/src/Modal/hooks/useModalContent.ts +32 -0
- package/src/Modal/types.ts +110 -29
- package/src/NavigationMenu/NavigationMenu.tsx +890 -0
- package/src/NavigationMenu/NavigationMenuContext.ts +67 -0
- package/src/NavigationMenu/README.md +355 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.anchor-positioning.test.tsx +132 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.asChild.test.tsx +73 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.basic-rendering.test.tsx +92 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.contract.test.tsx +45 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.controlled-state.test.tsx +134 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.error-handling.test.tsx +62 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.fixtures.tsx +79 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.forceMount.test.tsx +52 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.hover-interaction.test.tsx +254 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.indicator.test.tsx +252 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-interaction.test.tsx +294 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-navigation.test.tsx +126 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.link.test.tsx +139 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.motion.test.tsx +568 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.mouse-interaction.test.tsx +76 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.uncontrolled-state.test.tsx +30 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.viewport.test.tsx +113 -0
- package/src/NavigationMenu/constants.ts +5 -0
- package/src/NavigationMenu/hooks/index.ts +7 -0
- package/src/NavigationMenu/hooks/useNavigationMenuEntry.ts +33 -0
- package/src/NavigationMenu/hooks/useNavigationMenuIndicator.ts +70 -0
- package/src/NavigationMenu/hooks/useNavigationMenuLink.ts +72 -0
- package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +301 -0
- package/src/NavigationMenu/hooks/useNavigationMenuTopLevelEntry.ts +65 -0
- package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +158 -0
- package/src/NavigationMenu/hooks/useNavigationMenuViewport.ts +101 -0
- package/src/NavigationMenu/index.ts +3 -0
- package/src/NavigationMenu/types.ts +259 -0
- package/src/NavigationMenu/utils.ts +89 -0
- package/src/Popover/Popover.tsx +427 -0
- package/src/Popover/PopoverContext.ts +13 -0
- package/src/Popover/README.md +106 -0
- package/src/Popover/__tests__/Popover.anchor.test.tsx +38 -0
- package/src/Popover/__tests__/Popover.asChild.test.tsx +160 -0
- package/src/Popover/__tests__/Popover.basic-rendering.test.tsx +106 -0
- package/src/Popover/__tests__/Popover.click-outside.test.tsx +149 -0
- package/src/Popover/__tests__/Popover.close.test.tsx +70 -0
- package/src/Popover/__tests__/Popover.content.test.tsx +58 -0
- package/src/Popover/__tests__/Popover.controlled-state.test.tsx +45 -0
- package/src/Popover/__tests__/Popover.error-handling.test.tsx +51 -0
- package/src/Popover/__tests__/Popover.focus.test.tsx +43 -0
- package/src/Popover/__tests__/Popover.keyboard-interaction.test.tsx +71 -0
- package/src/Popover/__tests__/Popover.mouse-interaction.test.tsx +69 -0
- package/src/Popover/__tests__/Popover.uncontrolled-state.test.tsx +44 -0
- package/src/Popover/hooks/index.ts +3 -0
- package/src/Popover/hooks/usePopoverContent.ts +98 -0
- package/src/Popover/hooks/usePopoverRoot.ts +84 -0
- package/src/Popover/hooks/usePopoverTrigger.ts +33 -0
- package/src/Popover/index.ts +2 -0
- package/src/Popover/types.ts +197 -0
- package/src/Portal/Portal.tsx +50 -10
- package/src/Portal/__tests__/Portal.basic-rendering.test.tsx +5 -0
- package/src/Portal/types.ts +5 -0
- package/src/Progress/Progress.tsx +33 -1
- package/src/Progress/__tests__/Progress.basic-rendering.test.tsx +10 -0
- package/src/Progress/__tests__/Progress.value.test.tsx +21 -0
- package/src/Progress/types.ts +20 -4
- package/src/Radio/Radio.tsx +9 -1
- package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +14 -0
- package/src/Radio/hooks/useRadioRoot.ts +3 -0
- package/src/Radio/types.ts +20 -0
- package/src/RadioCard/RadioCard.tsx +11 -0
- package/src/RadioCard/__tests__/RadioCard.basic-rendering.test.tsx +8 -0
- package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +27 -0
- package/src/RadioCard/__tests__/RadioCard.keyboard-interaction.test.tsx +53 -0
- package/src/RadioCard/__tests__/RadioCard.ref-forwarding.test.tsx +54 -0
- package/src/RadioCard/__tests__/RadioCard.uncontrolled-state.test.tsx +21 -0
- package/src/RadioCard/hooks/useRadioCardRoot.ts +11 -0
- package/src/RadioCard/types.ts +95 -22
- package/src/RadioGroup/RadioGroup.tsx +11 -0
- package/src/RadioGroup/__tests__/RadioGroup.basic-rendering.test.tsx +8 -0
- package/src/RadioGroup/__tests__/RadioGroup.disabled.test.tsx +27 -0
- package/src/RadioGroup/__tests__/RadioGroup.ref-forwarding.test.tsx +29 -0
- package/src/RadioGroup/__tests__/RadioGroup.roving-tabindex.test.tsx +31 -0
- package/src/RadioGroup/hooks/useRadioGroupRoot.ts +11 -0
- package/src/RadioGroup/types.ts +73 -9
- package/src/SegmentedControl/README.md +154 -0
- package/src/SegmentedControl/SegmentedControl.tsx +309 -0
- package/src/SegmentedControl/SegmentedControlContext.ts +28 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.asChild.test.tsx +50 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.basic-rendering.test.tsx +100 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.controlled-state.test.tsx +107 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.disabled-keydown-guards.test.tsx +62 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.disabled.test.tsx +151 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.error-handling.test.tsx +17 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.keyboard-interaction.test.tsx +117 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.orientation.test.tsx +54 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.reading-direction.test.tsx +56 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.ref-forwarding.test.tsx +45 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.roving-tabindex.test.tsx +104 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.uncontrolled-state.test.tsx +94 -0
- package/src/SegmentedControl/hooks/index.ts +2 -0
- package/src/SegmentedControl/hooks/useSegmentedControlContext.ts +1 -0
- package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +86 -0
- package/src/SegmentedControl/index.ts +2 -0
- package/src/SegmentedControl/types.ts +138 -0
- package/src/Select/README.md +122 -157
- package/src/Select/Select.tsx +595 -68
- package/src/Select/SelectContext.ts +38 -0
- package/src/Select/SelectItemIndicatorContext.ts +29 -0
- package/src/Select/__tests__/Select.asChild.test.tsx +2 -2
- package/src/Select/__tests__/Select.basic-rendering.test.tsx +22 -3
- package/src/Select/__tests__/Select.controlled-state.test.tsx +6 -6
- package/src/Select/__tests__/Select.data-attributes.test.tsx +4 -4
- package/src/Select/__tests__/Select.field-integration.test.tsx +11 -11
- package/src/Select/__tests__/Select.form.test.tsx +38 -0
- package/src/Select/__tests__/Select.group.test.tsx +6 -6
- package/src/Select/__tests__/Select.keyboard-edge.test.tsx +95 -0
- package/src/Select/__tests__/Select.keyboard.test.tsx +124 -0
- package/src/Select/__tests__/Select.mutation-hardening.test.tsx +317 -0
- package/src/Select/__tests__/Select.native-item.test.tsx +30 -0
- package/src/Select/__tests__/Select.open-close.test.tsx +57 -0
- package/src/Select/__tests__/Select.placeholder.test.tsx +4 -4
- package/src/Select/__tests__/Select.rich-asChild.test.tsx +60 -0
- package/src/Select/__tests__/Select.rich-mode.test.tsx +16 -0
- package/src/Select/__tests__/Select.selection.test.tsx +104 -0
- package/src/Select/__tests__/Select.separator.test.tsx +71 -0
- package/src/Select/__tests__/Select.strict-context.test.tsx +36 -0
- package/src/Select/__tests__/Select.typeahead.test.tsx +156 -0
- package/src/Select/__tests__/Select.value-mirror.test.tsx +240 -0
- package/src/Select/constants.ts +8 -0
- package/src/Select/hooks/index.ts +2 -0
- package/src/Select/hooks/useSelectContent.ts +149 -0
- package/src/Select/hooks/useSelectRoot.ts +149 -0
- package/src/Select/types.ts +199 -31
- package/src/SkipNav/SkipNav.tsx +69 -29
- package/src/SkipNav/__tests__/SkipNav.basic-rendering.test.tsx +6 -0
- package/src/SkipNav/types.ts +17 -9
- package/src/Slider/Slider.tsx +33 -1
- package/src/Slider/SliderContext.ts +28 -1
- package/src/Slider/types.ts +157 -9
- package/src/Slot/Slot.tsx +94 -19
- package/src/Slot/__tests__/Slot.test.tsx +110 -0
- package/src/Slot/composeEventHandlers.ts +26 -2
- package/src/Slot/types.ts +16 -0
- package/src/Status/Status.tsx +36 -15
- package/src/Status/__tests__/Status.test.tsx +5 -0
- package/src/Status/types.ts +12 -1
- package/src/Switch/Switch.tsx +15 -2
- package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +7 -0
- package/src/Switch/hooks/useSwitchRoot.ts +3 -0
- package/src/Switch/types.ts +49 -22
- package/src/Table/Table.tsx +44 -10
- package/src/Table/types.ts +66 -17
- package/src/Tabs/Tabs.tsx +8 -0
- package/src/Tabs/types.ts +29 -4
- package/src/Textarea/Textarea.tsx +9 -1
- package/src/Textarea/__tests__/Textarea.basic-rendering.test.tsx +5 -0
- package/src/Textarea/types.ts +20 -3
- package/src/Toggle/Toggle.tsx +51 -13
- package/src/Toggle/__tests__/Toggle.basic-rendering.test.tsx +5 -0
- package/src/Toggle/types.ts +54 -4
- package/src/ToggleGroup/ToggleGroup.tsx +14 -1
- package/src/ToggleGroup/__tests__/ToggleGroup.asChild.test.tsx +20 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.basic-rendering.test.tsx +9 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.dynamic-items.test.tsx +70 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.error-handling.test.tsx +23 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.keyboard-interaction.test.tsx +23 -1
- package/src/ToggleGroup/__tests__/ToggleGroup.ref-forwarding.test.tsx +58 -0
- package/src/ToggleGroup/hooks/useToggleGroupRoot.ts +21 -1
- package/src/ToggleGroup/types.ts +55 -2
- package/src/Tooltip/Tooltip.tsx +98 -20
- package/src/Tooltip/hooks/__tests__/useTooltipProvider.test.tsx +40 -0
- package/src/Tooltip/hooks/useTooltipProvider.ts +13 -1
- package/src/Tooltip/types.ts +89 -8
- package/src/Tree/Tree.tsx +187 -33
- package/src/Tree/types.ts +54 -12
- package/src/VisuallyHidden/VisuallyHidden.tsx +33 -8
- package/src/VisuallyHidden/__tests__/VisuallyHidden.test.tsx +15 -1
- package/src/VisuallyHidden/types.ts +11 -1
- package/src/index.ts +4 -0
- package/src/test/intersectionObserverPolyfill.ts +7 -0
- package/src/test/resizeObserverPolyfill.ts +80 -0
- package/src/utils/focusable.ts +15 -0
- package/src/utils/index.ts +1 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ReactNode,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useId,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
import { useControllableState } from "../../hooks/index.ts";
|
|
12
|
+
import type { SelectContextValue } from "../SelectContext";
|
|
13
|
+
|
|
14
|
+
type UseSelectRootArgs = {
|
|
15
|
+
defaultOpen?: boolean;
|
|
16
|
+
open?: boolean;
|
|
17
|
+
onOpenChange?: (open: boolean) => void;
|
|
18
|
+
value?: string;
|
|
19
|
+
defaultValue?: string;
|
|
20
|
+
onValueChange?: (value: string) => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* State owner for the rich (non-`native`) Select. Manages the open/close
|
|
25
|
+
* state of the listbox popover — controlled (`open` + `onOpenChange`) or
|
|
26
|
+
* uncontrolled (`defaultOpen`) — and derives the trigger/content ids.
|
|
27
|
+
* Mirrors {@link useDropdownRoot}, including the `openRef` de-dupe so a
|
|
28
|
+
* single light-dismiss that fires both a `toggle` event and our own close
|
|
29
|
+
* does not double-notify `onOpenChange`.
|
|
30
|
+
*/
|
|
31
|
+
export function useSelectRoot({
|
|
32
|
+
defaultOpen = false,
|
|
33
|
+
open: controlledOpen,
|
|
34
|
+
onOpenChange,
|
|
35
|
+
value: controlledValue,
|
|
36
|
+
defaultValue,
|
|
37
|
+
onValueChange,
|
|
38
|
+
}: UseSelectRootArgs): {
|
|
39
|
+
contextValue: SelectContextValue;
|
|
40
|
+
value: string;
|
|
41
|
+
itemValues: string[];
|
|
42
|
+
} {
|
|
43
|
+
const contentId = useId();
|
|
44
|
+
const triggerId = useId();
|
|
45
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
46
|
+
const [open, setOpenBase] = useControllableState<boolean>(
|
|
47
|
+
controlledOpen,
|
|
48
|
+
defaultOpen,
|
|
49
|
+
onOpenChange,
|
|
50
|
+
);
|
|
51
|
+
const [value, setValue] = useControllableState<string>(
|
|
52
|
+
controlledValue,
|
|
53
|
+
defaultValue ?? "",
|
|
54
|
+
onValueChange,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const openRef = useRef(open);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
openRef.current = open;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const setOpen = useCallback(
|
|
63
|
+
(next: boolean) => {
|
|
64
|
+
if (openRef.current === next) return;
|
|
65
|
+
openRef.current = next;
|
|
66
|
+
setOpenBase(next);
|
|
67
|
+
},
|
|
68
|
+
[setOpenBase],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Commit a selection: update the value, close the listbox, and return
|
|
72
|
+
// focus to the trigger (the standard listbox close-on-select behaviour).
|
|
73
|
+
const select = useCallback(
|
|
74
|
+
(next: string) => {
|
|
75
|
+
setValue(next);
|
|
76
|
+
setOpenBase(false);
|
|
77
|
+
openRef.current = false;
|
|
78
|
+
// Stryker disable next-line OptionalChaining: unreachable — the trigger is always mounted when an item is selected (selection happens inside the open Content).
|
|
79
|
+
triggerRef.current?.focus();
|
|
80
|
+
},
|
|
81
|
+
[setValue, setOpenBase],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Item registry — lets Select.Value mirror the selected item's content
|
|
85
|
+
// (written once on the Item). Children live in a ref (updated silently
|
|
86
|
+
// every render, no re-render churn); `itemVersion` bumps only when the set
|
|
87
|
+
// of registered values changes (mount/unmount), so Value re-renders when
|
|
88
|
+
// items appear or disappear. See the react-component-patterns skill.
|
|
89
|
+
const itemChildrenRef = useRef<Map<string, ReactNode>>(new Map());
|
|
90
|
+
const [itemVersion, setItemVersion] = useState(0);
|
|
91
|
+
|
|
92
|
+
const registerItem = useCallback((itemValue: string, node: ReactNode) => {
|
|
93
|
+
const isNew = !itemChildrenRef.current.has(itemValue);
|
|
94
|
+
itemChildrenRef.current.set(itemValue, node);
|
|
95
|
+
// Stryker disable next-line ArithmeticOperator,ArrowFunction: equivalent — the version is an opaque re-render trigger; any updater (v+1, v-1, or `() => undefined`) changes state and re-renders identically.
|
|
96
|
+
if (isNew) setItemVersion((v) => v + 1);
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
const unregisterItem = useCallback((itemValue: string) => {
|
|
100
|
+
itemChildrenRef.current.delete(itemValue);
|
|
101
|
+
// Stryker disable next-line ArithmeticOperator,ArrowFunction: equivalent — the version is an opaque re-render trigger; any updater changes state and re-renders identically.
|
|
102
|
+
setItemVersion((v) => v + 1);
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
const getItemChildren = useCallback(
|
|
106
|
+
(itemValue: string) => itemChildrenRef.current.get(itemValue),
|
|
107
|
+
[],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Registered values, recomputed whenever items mount/unmount, so Root can
|
|
111
|
+
// render a matching <option> in the hidden form <select>.
|
|
112
|
+
const itemValues = useMemo(
|
|
113
|
+
() => Array.from(itemChildrenRef.current.keys()),
|
|
114
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
115
|
+
[itemVersion],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const contextValue = useMemo(
|
|
119
|
+
() => ({
|
|
120
|
+
open,
|
|
121
|
+
setOpen,
|
|
122
|
+
value,
|
|
123
|
+
select,
|
|
124
|
+
registerItem,
|
|
125
|
+
unregisterItem,
|
|
126
|
+
getItemChildren,
|
|
127
|
+
contentId,
|
|
128
|
+
triggerId,
|
|
129
|
+
triggerRef,
|
|
130
|
+
}),
|
|
131
|
+
// itemVersion is intentionally in the dep list (not the value) so the
|
|
132
|
+
// context identity changes when items mount/unmount, re-rendering
|
|
133
|
+
// Select.Value even though the callbacks are stable.
|
|
134
|
+
[
|
|
135
|
+
open,
|
|
136
|
+
setOpen,
|
|
137
|
+
value,
|
|
138
|
+
select,
|
|
139
|
+
registerItem,
|
|
140
|
+
unregisterItem,
|
|
141
|
+
getItemChildren,
|
|
142
|
+
contentId,
|
|
143
|
+
triggerId,
|
|
144
|
+
itemVersion,
|
|
145
|
+
],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return { contextValue, value, itemValues };
|
|
149
|
+
}
|
package/src/Select/types.ts
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ChangeEventHandler,
|
|
3
|
+
ComponentProps,
|
|
4
|
+
HTMLAttributes,
|
|
5
|
+
ReactNode,
|
|
6
|
+
Ref,
|
|
7
|
+
} from "react";
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* Shared base for both {@link SelectRootProps} variants — the native
|
|
5
|
-
* `<select>` attributes (minus the state-owning
|
|
6
|
-
* escape hatch, and the raw `onChange` passthrough.
|
|
11
|
+
* `<select>` attributes (minus the state-owning and multiple-selection
|
|
12
|
+
* ones), the `asChild` escape hatch, and the raw `onChange` passthrough.
|
|
7
13
|
*/
|
|
8
14
|
export type SelectRootBaseProps = Omit<
|
|
9
15
|
ComponentProps<"select">,
|
|
10
16
|
"value" | "defaultValue" | "multiple" | "onChange"
|
|
11
17
|
> & {
|
|
18
|
+
/** Content of the select — typically {@link SelectOption}, {@link SelectGroup},
|
|
19
|
+
* and optionally a leading {@link SelectPlaceholder}. */
|
|
12
20
|
children?: ReactNode;
|
|
21
|
+
/** Forwarded to the underlying `HTMLSelectElement`. */
|
|
13
22
|
ref?: Ref<HTMLSelectElement>;
|
|
14
23
|
/**
|
|
15
24
|
* Native `change` handler. Fires alongside `onValueChange` whenever the
|
|
@@ -20,11 +29,52 @@ export type SelectRootBaseProps = Omit<
|
|
|
20
29
|
/**
|
|
21
30
|
* When `true`, Root delegates to a single consumer-supplied element
|
|
22
31
|
* (expected to render a `<select>`) and merges its own props onto it
|
|
23
|
-
* via the {@link Slot} pattern.
|
|
24
|
-
* walks direct children only, so
|
|
25
|
-
* consumer to set
|
|
32
|
+
* via the {@link Slot} pattern. Placeholder-detection inside Root
|
|
33
|
+
* walks direct children only in this mode, so the `asChild` +
|
|
34
|
+
* placeholder combination requires the consumer to set
|
|
35
|
+
* `defaultValue=""` explicitly.
|
|
36
|
+
* @default false
|
|
26
37
|
*/
|
|
27
38
|
asChild?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Selects the render path:
|
|
41
|
+
*
|
|
42
|
+
* - `false` (the default) — the **rich** render path: a fully-styleable
|
|
43
|
+
* Popover-API listbox built from {@link SelectTrigger | `Select.Trigger`},
|
|
44
|
+
* {@link SelectValue | `Select.Value`},
|
|
45
|
+
* {@link SelectContent | `Select.Content`} and
|
|
46
|
+
* {@link SelectItem | `Select.Item`}. Icons and other rich content on an
|
|
47
|
+
* item render as authored.
|
|
48
|
+
* - `true` — the **native** render path: a thin wrapper over a real
|
|
49
|
+
* `<select>` / `<option>` / `<optgroup>`, for flat, OS-native cases
|
|
50
|
+
* (mobile wheel pickers, maximum-compatibility forms). Under `native`,
|
|
51
|
+
* {@link SelectItem | `Select.Item`} renders an `<option>` from its
|
|
52
|
+
* string/number children only — element children (icons, indicators)
|
|
53
|
+
* are dropped.
|
|
54
|
+
*
|
|
55
|
+
* Both modes share the same `value` / `onValueChange` / `disabled` / form
|
|
56
|
+
* `name` API.
|
|
57
|
+
* @default false
|
|
58
|
+
*/
|
|
59
|
+
native?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Rich mode only — whether the listbox popover is open on first render.
|
|
62
|
+
* Ignored under `native` (the browser owns the popup). Uncontrolled: pass
|
|
63
|
+
* this (or omit for closed) and let the component manage the state.
|
|
64
|
+
* @default false
|
|
65
|
+
*/
|
|
66
|
+
defaultOpen?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Rich mode only — the controlled open state of the listbox popover. Pass
|
|
69
|
+
* together with {@link SelectRootBaseProps.onOpenChange | `onOpenChange`}
|
|
70
|
+
* to own the state from the parent.
|
|
71
|
+
*/
|
|
72
|
+
open?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Rich mode only — called with the next open state whenever the listbox
|
|
75
|
+
* opens or closes (trigger click, selection, Escape, light-dismiss).
|
|
76
|
+
*/
|
|
77
|
+
onOpenChange?: (open: boolean) => void;
|
|
28
78
|
};
|
|
29
79
|
|
|
30
80
|
/**
|
|
@@ -33,8 +83,15 @@ export type SelectRootBaseProps = Omit<
|
|
|
33
83
|
* and `value` is forbidden.
|
|
34
84
|
*/
|
|
35
85
|
export type SelectRootUncontrolledProps = SelectRootBaseProps & {
|
|
86
|
+
/** Value of the option selected on first render. When omitted and a
|
|
87
|
+
* {@link SelectPlaceholder} is present among Root's direct children,
|
|
88
|
+
* Root infers `""` automatically so the placeholder is the initial
|
|
89
|
+
* selection. */
|
|
36
90
|
defaultValue?: string;
|
|
91
|
+
/** Forbidden in uncontrolled mode — use `defaultValue` instead. */
|
|
37
92
|
value?: never;
|
|
93
|
+
/** Called with the new option value whenever the user changes the
|
|
94
|
+
* selection. Optional in uncontrolled mode. */
|
|
38
95
|
onValueChange?: (value: string) => void;
|
|
39
96
|
};
|
|
40
97
|
|
|
@@ -44,61 +101,172 @@ export type SelectRootUncontrolledProps = SelectRootBaseProps & {
|
|
|
44
101
|
* forbidden.
|
|
45
102
|
*/
|
|
46
103
|
export type SelectRootControlledProps = SelectRootBaseProps & {
|
|
104
|
+
/** Forbidden in controlled mode — use `value` instead. */
|
|
47
105
|
defaultValue?: never;
|
|
106
|
+
/** The currently selected option value. Must be kept in sync by the
|
|
107
|
+
* parent via `onValueChange`. */
|
|
48
108
|
value: string;
|
|
109
|
+
/** Called with the new option value whenever the user changes the
|
|
110
|
+
* selection. Required in controlled mode. */
|
|
49
111
|
onValueChange: (value: string) => void;
|
|
50
112
|
};
|
|
51
113
|
|
|
52
114
|
/**
|
|
53
|
-
* Props for {@link Select.Root}.
|
|
54
|
-
*
|
|
55
|
-
* Two state modes are statically discriminated at the type level so only
|
|
56
|
-
* one shape is accepted by TypeScript:
|
|
115
|
+
* Props for {@link SelectRoot | `Select.Root`}.
|
|
57
116
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* back through the callback.
|
|
63
|
-
*
|
|
64
|
-
* Native `multiple`-selection mode is not supported in v1.
|
|
117
|
+
* Resolves to either {@link SelectRootUncontrolledProps} or
|
|
118
|
+
* {@link SelectRootControlledProps} — only one shape is accepted by
|
|
119
|
+
* TypeScript at a time. Native `multiple`-selection mode is not supported
|
|
120
|
+
* in v1.
|
|
65
121
|
*/
|
|
66
122
|
export type SelectRootProps =
|
|
67
123
|
| SelectRootUncontrolledProps
|
|
68
124
|
| SelectRootControlledProps;
|
|
69
125
|
|
|
70
126
|
/**
|
|
71
|
-
* Props for {@link
|
|
72
|
-
*
|
|
127
|
+
* Props for {@link SelectItem | `Select.Item`}.
|
|
128
|
+
*
|
|
129
|
+
* `Select.Item` renders different elements per mode — an `<option>` under
|
|
130
|
+
* `native`, a `<div role="option">` in rich mode — so it extends the
|
|
131
|
+
* element-agnostic `HTMLAttributes<HTMLElement>` rather than an
|
|
132
|
+
* option-specific type.
|
|
73
133
|
*/
|
|
74
|
-
export type
|
|
134
|
+
export type SelectItemProps = HTMLAttributes<HTMLElement> & {
|
|
135
|
+
/** The value this item represents; committed as the Select's value when
|
|
136
|
+
* chosen, and matched against the current value for the selected state. */
|
|
137
|
+
value: string;
|
|
138
|
+
/** Marks the item unselectable while still visible. */
|
|
139
|
+
disabled?: boolean;
|
|
140
|
+
/** The item content. In `native` mode only its string/number parts are
|
|
141
|
+
* kept as the `<option>` text (element children are dropped); in rich mode
|
|
142
|
+
* arbitrary content (icons, indicators) renders as authored. */
|
|
75
143
|
children?: ReactNode;
|
|
76
|
-
ref?: Ref<HTMLOptionElement>;
|
|
77
144
|
};
|
|
78
145
|
|
|
79
146
|
/**
|
|
80
|
-
* Props for {@link Select.Group} — all
|
|
81
|
-
* underlying `<optgroup>` element
|
|
82
|
-
*
|
|
83
|
-
* announces for the group.
|
|
147
|
+
* Props for {@link SelectGroup | `Select.Group`} — all
|
|
148
|
+
* `OptgroupHTMLAttributes` on the underlying `<optgroup>` element (with
|
|
149
|
+
* `label` narrowed to a required `string`), plus a typed `ref`.
|
|
84
150
|
*/
|
|
85
|
-
export type SelectGroupProps =
|
|
151
|
+
export type SelectGroupProps = HTMLAttributes<HTMLElement> & {
|
|
152
|
+
/**
|
|
153
|
+
* The group heading. In `native` mode it is the `<optgroup>`'s `label`
|
|
154
|
+
* attribute; in rich mode it becomes the `<div role="group">`'s
|
|
155
|
+
* `aria-label`. Either way it is the group's accessible name. Required —
|
|
156
|
+
* a group without one is inaccessible.
|
|
157
|
+
*/
|
|
86
158
|
label: string;
|
|
159
|
+
/** The {@link SelectItem} elements belonging to this group. */
|
|
87
160
|
children?: ReactNode;
|
|
88
|
-
ref?: Ref<HTMLOptGroupElement>;
|
|
89
161
|
};
|
|
90
162
|
|
|
91
163
|
/**
|
|
92
|
-
* Props for {@link Select.Placeholder}.
|
|
164
|
+
* Props for {@link SelectPlaceholder | `Select.Placeholder`}.
|
|
93
165
|
*
|
|
94
|
-
* `value`, `disabled`, and `hidden` are owned by the component —
|
|
95
|
-
* placeholder always
|
|
96
|
-
*
|
|
166
|
+
* `value`, `disabled`, and `hidden` are owned by the component —
|
|
167
|
+
* the placeholder always renders with `value=""`, `disabled`, and `hidden`
|
|
168
|
+
* — so they are excluded from this type and cannot be set by the consumer.
|
|
97
169
|
*/
|
|
98
170
|
export type SelectPlaceholderProps = Omit<
|
|
99
171
|
ComponentProps<"option">,
|
|
100
172
|
"value" | "disabled" | "hidden"
|
|
101
173
|
> & {
|
|
174
|
+
/** The placeholder hint text shown in the closed select before the user
|
|
175
|
+
* makes a selection (e.g. `"Choose a fruit…"`). */
|
|
102
176
|
children?: ReactNode;
|
|
177
|
+
/** Forwarded to the underlying `HTMLOptionElement`. */
|
|
103
178
|
ref?: Ref<HTMLOptionElement>;
|
|
104
179
|
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Props for {@link SelectTrigger | `Select.Trigger`} — the rich-mode button
|
|
183
|
+
* that opens the listbox. Extends the native `<button>` attributes.
|
|
184
|
+
*/
|
|
185
|
+
export type SelectTriggerProps = ComponentProps<"button"> & {
|
|
186
|
+
/** Trigger content — typically a {@link SelectValue | `Select.Value`}. */
|
|
187
|
+
children?: ReactNode;
|
|
188
|
+
/**
|
|
189
|
+
* Render the composed child element instead of a `<button>`, merging the
|
|
190
|
+
* trigger's ARIA attributes and click handler via the {@link Slot}
|
|
191
|
+
* pattern.
|
|
192
|
+
* @default false
|
|
193
|
+
*/
|
|
194
|
+
asChild?: boolean;
|
|
195
|
+
/** Forwarded to the underlying `HTMLButtonElement`. */
|
|
196
|
+
ref?: Ref<HTMLButtonElement>;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Props for {@link SelectValue | `Select.Value`} — the rich-mode element,
|
|
201
|
+
* placed inside {@link SelectTrigger | `Select.Trigger`}, that displays the
|
|
202
|
+
* current selection (mirrored from the selected item in a later cycle) or a
|
|
203
|
+
* placeholder when nothing is selected. Extends `<span>` attributes minus
|
|
204
|
+
* `children`, which the component owns.
|
|
205
|
+
*/
|
|
206
|
+
export type SelectValueProps = Omit<ComponentProps<"span">, "children"> & {
|
|
207
|
+
/** Shown when no value is selected (e.g. `"Select a framework…"`). */
|
|
208
|
+
placeholder?: ReactNode;
|
|
209
|
+
/** Forwarded to the underlying `HTMLSpanElement`. */
|
|
210
|
+
ref?: Ref<HTMLSpanElement>;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Props for {@link SelectItemIndicator | `Select.ItemIndicator`} — the
|
|
215
|
+
* selection mark inside a rich {@link SelectItem | `Select.Item`}. Extends
|
|
216
|
+
* the native `<span>` attributes.
|
|
217
|
+
*/
|
|
218
|
+
export type SelectItemIndicatorProps = ComponentProps<"span"> & {
|
|
219
|
+
/** The mark to render (e.g. a checkmark glyph or icon). */
|
|
220
|
+
children?: ReactNode;
|
|
221
|
+
/**
|
|
222
|
+
* Render the composed child element instead of a `<span>` via the
|
|
223
|
+
* {@link Slot} pattern.
|
|
224
|
+
* @default false
|
|
225
|
+
*/
|
|
226
|
+
asChild?: boolean;
|
|
227
|
+
/**
|
|
228
|
+
* Keep the indicator mounted even when the item is not selected (it still
|
|
229
|
+
* exposes `data-state="unchecked"`), useful for CSS enter/exit animation.
|
|
230
|
+
* @default false
|
|
231
|
+
*/
|
|
232
|
+
forceMount?: boolean;
|
|
233
|
+
/** Forwarded to the underlying `HTMLSpanElement`. */
|
|
234
|
+
ref?: Ref<HTMLSpanElement>;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Props for {@link SelectContent | `Select.Content`} — the rich-mode
|
|
239
|
+
* Popover-API listbox panel. Extends the native `<div>` attributes.
|
|
240
|
+
*/
|
|
241
|
+
export type SelectContentProps = ComponentProps<"div"> & {
|
|
242
|
+
/** Listbox content — {@link SelectItem | `Select.Item`} /
|
|
243
|
+
* {@link SelectGroup | `Select.Group`} elements. */
|
|
244
|
+
children?: ReactNode;
|
|
245
|
+
/**
|
|
246
|
+
* Render the composed child element instead of a `<div>`, merging the
|
|
247
|
+
* listbox props via the {@link Slot} pattern.
|
|
248
|
+
* @default false
|
|
249
|
+
*/
|
|
250
|
+
asChild?: boolean;
|
|
251
|
+
/** Forwarded to the underlying `HTMLDivElement`. */
|
|
252
|
+
ref?: Ref<HTMLDivElement>;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Props for {@link SelectSeparator | `Select.Separator`} — a non-interactive
|
|
257
|
+
* divider between groups of options in rich mode. Extends the native `<div>`
|
|
258
|
+
* attributes and adds `asChild`.
|
|
259
|
+
*/
|
|
260
|
+
export type SelectSeparatorProps = Omit<ComponentProps<"div">, "role"> & {
|
|
261
|
+
/** Optional content; separators are usually empty, and only render (via
|
|
262
|
+
* the {@link Slot} pattern) when `asChild` composes onto a custom element. */
|
|
263
|
+
children?: ReactNode;
|
|
264
|
+
/** Forwarded to the underlying `HTMLDivElement`. */
|
|
265
|
+
ref?: Ref<HTMLDivElement>;
|
|
266
|
+
/**
|
|
267
|
+
* Render the composed child element (with separator semantics) instead of
|
|
268
|
+
* the default `<div role="separator">`.
|
|
269
|
+
* @default false
|
|
270
|
+
*/
|
|
271
|
+
asChild?: boolean;
|
|
272
|
+
};
|
package/src/SkipNav/SkipNav.tsx
CHANGED
|
@@ -5,29 +5,42 @@ import { SkipNavContentProps, SkipNavLinkProps } from "./types";
|
|
|
5
5
|
const DEFAULT_CONTENT_ID = "primitiv-skip-nav";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* The skip link
|
|
9
|
-
*
|
|
8
|
+
* The skip link — an `<a>` that jumps keyboard and screen-reader users past
|
|
9
|
+
* repeated navigation straight to the main content.
|
|
10
10
|
*
|
|
11
11
|
* Place it as the very first focusable element on the page (typically the
|
|
12
|
-
* first child of `<body>`). It is a
|
|
12
|
+
* first child of `<body>`). It is a plain in-page anchor: its `href` is a
|
|
13
13
|
* URL fragment pointing at the `id` of the matching {@link SkipNavContent |
|
|
14
14
|
* `SkipNav.Content`}, so following it moves focus there with no JavaScript.
|
|
15
15
|
*
|
|
16
16
|
* **`contentId`.** The `href` is derived as `#${contentId}` and defaults to
|
|
17
|
-
* a shared id, so an unconfigured
|
|
18
|
-
* works out of the box. Override
|
|
19
|
-
* set a matching `id` on
|
|
17
|
+
* a shared id (`"primitiv-skip-nav"`), so an unconfigured
|
|
18
|
+
* `SkipNav.Link` / `SkipNav.Content` pair works out of the box. Override
|
|
19
|
+
* `contentId` on the link only if you also set a matching `id` on
|
|
20
|
+
* {@link SkipNavContent | `SkipNav.Content`}.
|
|
20
21
|
*
|
|
21
|
-
* **Visibility.**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* **Visibility.** No styles ship with the component. The conventional
|
|
23
|
+
* pattern keeps the link visually hidden until it receives focus — position
|
|
24
|
+
* it off-screen by default and restore it on `:focus`. Do **not** use
|
|
25
|
+
* `display: none` or `visibility: hidden`, as those remove it from the tab
|
|
26
|
+
* order entirely:
|
|
27
|
+
*
|
|
28
|
+
* ```css
|
|
29
|
+
* a[href="#primitiv-skip-nav"] { position: absolute; left: -9999px; }
|
|
30
|
+
* a[href="#primitiv-skip-nav"]:focus { left: 0; top: 0; }
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* Alternatively, pass a `className` and target that class in your
|
|
34
|
+
* stylesheet.
|
|
35
|
+
*
|
|
36
|
+
* @extends HTMLAnchorElement
|
|
24
37
|
*
|
|
25
38
|
* @example Default — shared content id
|
|
26
39
|
* ```tsx
|
|
27
40
|
* <SkipNav.Link>Skip to main content</SkipNav.Link>
|
|
28
41
|
* ```
|
|
29
42
|
*
|
|
30
|
-
* @example Custom content id
|
|
43
|
+
* @example Custom content id (must match the `id` on `SkipNav.Content`)
|
|
31
44
|
* ```tsx
|
|
32
45
|
* <SkipNav.Link contentId="main">Skip to main content</SkipNav.Link>
|
|
33
46
|
* ```
|
|
@@ -47,23 +60,36 @@ function SkipNavLink({
|
|
|
47
60
|
SkipNavLink.displayName = "SkipNavLink";
|
|
48
61
|
|
|
49
62
|
/**
|
|
50
|
-
* The skip target — a `<div>` placed at the start of the main
|
|
51
|
-
* {@link SkipNavLink | `SkipNav.Link`} jumps to.
|
|
63
|
+
* The skip target — a `<div tabIndex={-1}>` placed at the start of the main
|
|
64
|
+
* content that {@link SkipNavLink | `SkipNav.Link`} jumps to.
|
|
52
65
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
66
|
+
* `tabIndex={-1}` makes the element a valid programmatic focus destination:
|
|
67
|
+
* when the link is followed the browser scrolls to this element *and* moves
|
|
68
|
+
* focus into it, so the next <kbd>Tab</kbd> press continues from within the
|
|
69
|
+
* main content rather than cycling back to the top of the page. No
|
|
70
|
+
* JavaScript runs; it is pure anchor navigation.
|
|
57
71
|
*
|
|
58
|
-
* **`id`.** Defaults to the same shared id
|
|
59
|
-
*
|
|
72
|
+
* **`id`.** Defaults to the same shared id (`"primitiv-skip-nav"`) as
|
|
73
|
+
* {@link SkipNavLink | `SkipNav.Link`}'s `contentId`. Pass a custom `id`
|
|
74
|
+
* via the native `id` prop only if you set a matching `contentId` on the
|
|
75
|
+
* link — the spread props override the default, so the two always stay in
|
|
76
|
+
* sync.
|
|
60
77
|
*
|
|
61
|
-
* @
|
|
78
|
+
* @extends HTMLDivElement
|
|
79
|
+
*
|
|
80
|
+
* @example Default — wraps the main page content
|
|
62
81
|
* ```tsx
|
|
63
82
|
* <SkipNav.Content>
|
|
64
83
|
* <main>…</main>
|
|
65
84
|
* </SkipNav.Content>
|
|
66
85
|
* ```
|
|
86
|
+
*
|
|
87
|
+
* @example Custom id (must match `contentId` on `SkipNav.Link`)
|
|
88
|
+
* ```tsx
|
|
89
|
+
* <SkipNav.Content id="main">
|
|
90
|
+
* <main>…</main>
|
|
91
|
+
* </SkipNav.Content>
|
|
92
|
+
* ```
|
|
67
93
|
*/
|
|
68
94
|
function SkipNavContent({
|
|
69
95
|
children,
|
|
@@ -81,18 +107,22 @@ SkipNavContent.displayName = "SkipNavContent";
|
|
|
81
107
|
/**
|
|
82
108
|
* Headless, accessible **Skip Nav** — a "skip to main content" link and its
|
|
83
109
|
* focus target, letting keyboard and screen-reader users bypass repeated
|
|
84
|
-
* navigation.
|
|
110
|
+
* navigation. Implements the
|
|
111
|
+
* [WCAG 2.4.1 Bypass Blocks](https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html)
|
|
112
|
+
* technique with no JavaScript — pure in-page anchor navigation.
|
|
85
113
|
*
|
|
86
|
-
*
|
|
87
|
-
* nested
|
|
114
|
+
* `SkipNav` is a stateless pair of sub-components used as siblings (not
|
|
115
|
+
* nested). Render the link first; place the content wrapper at the start of
|
|
116
|
+
* the main content area:
|
|
88
117
|
*
|
|
89
|
-
* - {@link SkipNavLink | `SkipNav.Link`} — the `<a>` skip link;
|
|
90
|
-
* first on the page.
|
|
91
|
-
* - {@link SkipNavContent | `SkipNav.Content`} — the
|
|
92
|
-
* target
|
|
118
|
+
* - {@link SkipNavLink | `SkipNav.Link`} — the `<a>` skip link; must be
|
|
119
|
+
* the first focusable element on the page.
|
|
120
|
+
* - {@link SkipNavContent | `SkipNav.Content`} — the `<div tabIndex={-1}>`
|
|
121
|
+
* target; wrap it around (or immediately before) your main content.
|
|
93
122
|
*
|
|
94
|
-
* The two
|
|
95
|
-
*
|
|
123
|
+
* The two sub-components are connected by a shared content id
|
|
124
|
+
* (`"primitiv-skip-nav"`) with no configuration required. Override both
|
|
125
|
+
* sides when you need a custom id.
|
|
96
126
|
*
|
|
97
127
|
* @example Minimal usage
|
|
98
128
|
* ```tsx
|
|
@@ -111,7 +141,17 @@ SkipNavContent.displayName = "SkipNavContent";
|
|
|
111
141
|
* }
|
|
112
142
|
* ```
|
|
113
143
|
*
|
|
114
|
-
* @
|
|
144
|
+
* @example Custom id — both sides must match
|
|
145
|
+
* ```tsx
|
|
146
|
+
* <SkipNav.Link contentId="main">Skip to main content</SkipNav.Link>
|
|
147
|
+
* <SkipNav.Content id="main">
|
|
148
|
+
* <main>…</main>
|
|
149
|
+
* </SkipNav.Content>
|
|
150
|
+
* ```
|
|
151
|
+
*
|
|
152
|
+
* @see {@link SkipNavLink} for the `contentId` contract and the
|
|
153
|
+
* visually-hidden-until-focused CSS pattern.
|
|
154
|
+
* @see {@link SkipNavContent} for the `tabIndex={-1}` focus-target behaviour.
|
|
115
155
|
*/
|
|
116
156
|
const SkipNav = {
|
|
117
157
|
Link: SkipNavLink,
|
|
@@ -20,4 +20,10 @@ describe("SkipNav component", () => {
|
|
|
20
20
|
expect(content).toHaveAttribute("id", "primitiv-skip-nav");
|
|
21
21
|
expect(content).toHaveAttribute("tabindex", "-1");
|
|
22
22
|
});
|
|
23
|
+
|
|
24
|
+
it("sets displayName on each sub-component for React DevTools and stack traces", () => {
|
|
25
|
+
// Assert — an empty displayName would render each as anonymous.
|
|
26
|
+
expect(SkipNav.Link.displayName).toBe("SkipNavLink");
|
|
27
|
+
expect(SkipNav.Content.displayName).toBe("SkipNavContent");
|
|
28
|
+
});
|
|
23
29
|
});
|
package/src/SkipNav/types.ts
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Props for {@link SkipNav.Link} — `children`, an optional
|
|
5
|
-
* plus all
|
|
4
|
+
* Props for {@link SkipNavLink | `SkipNav.Link`} — `children`, an optional
|
|
5
|
+
* `contentId`, plus all native `<a>` attributes.
|
|
6
6
|
*/
|
|
7
7
|
export type SkipNavLinkProps = {
|
|
8
|
+
/** The visible link text (e.g. `"Skip to main content"`). */
|
|
8
9
|
children?: ReactNode;
|
|
9
10
|
/**
|
|
10
|
-
* Id of the matching {@link SkipNav.Content} target.
|
|
11
|
-
* is derived as `#${contentId}`. Defaults to
|
|
12
|
-
*
|
|
11
|
+
* Id of the matching {@link SkipNavContent | `SkipNav.Content`} target.
|
|
12
|
+
* The link's `href` is derived as `#${contentId}`. Defaults to the shared
|
|
13
|
+
* id (`"primitiv-skip-nav"`), so an unconfigured
|
|
14
|
+
* `SkipNav.Link` / `SkipNav.Content` pair works out of the box. Override
|
|
15
|
+
* only when you also set a matching native `id` prop on `SkipNav.Content`.
|
|
16
|
+
* @default "primitiv-skip-nav"
|
|
13
17
|
*/
|
|
14
18
|
contentId?: string;
|
|
15
19
|
} & AnchorHTMLAttributes<HTMLAnchorElement>;
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
|
-
* Props for {@link SkipNav.Content} — `children` plus
|
|
19
|
-
*
|
|
22
|
+
* Props for {@link SkipNavContent | `SkipNav.Content`} — `children` plus
|
|
23
|
+
* all native `<div>` attributes.
|
|
20
24
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
25
|
+
* The component always renders with `tabIndex={-1}` (making it a valid
|
|
26
|
+
* programmatic focus target) and sets `id` to `"primitiv-skip-nav"` by
|
|
27
|
+
* default. Pass a native `id` prop to override the default; when you do,
|
|
28
|
+
* set the same value as `contentId` on {@link SkipNavLink | `SkipNav.Link`}
|
|
29
|
+
* so the anchor's `href` resolves correctly.
|
|
23
30
|
*/
|
|
24
31
|
export type SkipNavContentProps = {
|
|
32
|
+
/** The main page content that follows the skip target. */
|
|
25
33
|
children?: ReactNode;
|
|
26
34
|
} & HTMLAttributes<HTMLDivElement>;
|