@primitiv-ui/react 0.1.29 → 0.1.31
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 +15 -2
- package/package.json +1 -1
- package/src/AccessibleIcon/AccessibleIcon.tsx +1 -1
- package/src/Accordion/Accordion.tsx +7 -7
- package/src/Accordion/README.md +2 -2
- package/src/Avatar/__tests__/Avatar.fallback.test.tsx +3 -3
- package/src/Breadcrumb/Breadcrumb.tsx +7 -7
- package/src/Breadcrumb/README.md +2 -2
- package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +1 -1
- package/src/Breadcrumb/types.ts +1 -1
- package/src/Button/__tests__/Button.contract.test.tsx +1 -1
- package/src/Carousel/Carousel.tsx +6 -6
- package/src/Carousel/README.md +22 -22
- package/src/Carousel/__tests__/Carousel.infinite.test.tsx +5 -5
- package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +2 -2
- package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +1 -1
- package/src/Carousel/loopEngine.ts +1 -1
- package/src/Carousel/types.ts +1 -1
- package/src/Checkbox/Checkbox.tsx +2 -2
- package/src/CheckboxCard/CheckboxCard.tsx +1 -1
- package/src/CheckboxCard/README.md +1 -1
- package/src/Collapsible/README.md +2 -2
- package/src/Combobox/Combobox.tsx +508 -0
- package/src/Combobox/ComboboxContext.ts +23 -0
- package/src/Combobox/README.md +170 -0
- package/src/Combobox/__tests__/Combobox.asChild.test.tsx +62 -0
- package/src/Combobox/__tests__/Combobox.basic-rendering.test.tsx +16 -0
- package/src/Combobox/__tests__/Combobox.content.test.tsx +36 -0
- package/src/Combobox/__tests__/Combobox.controlled-state.test.tsx +88 -0
- package/src/Combobox/__tests__/Combobox.empty.test.tsx +42 -0
- package/src/Combobox/__tests__/Combobox.ids.test.tsx +66 -0
- package/src/Combobox/__tests__/Combobox.item.test.tsx +76 -0
- package/src/Combobox/__tests__/Combobox.keyboard-interaction.test.tsx +196 -0
- package/src/Combobox/__tests__/Combobox.light-dismiss.test.tsx +101 -0
- package/src/Combobox/__tests__/Combobox.query.test.tsx +105 -0
- package/src/Combobox/hooks/index.ts +1 -0
- package/src/Combobox/hooks/useComboboxRoot.ts +2 -0
- package/src/Combobox/index.ts +3 -0
- package/src/Combobox/types.ts +133 -0
- package/src/ContextMenu/ContextMenu.tsx +3 -3
- package/src/ContextMenu/README.md +1 -1
- package/src/DirectionProvider/DirectionProvider.tsx +5 -5
- package/src/DirectionProvider/README.md +4 -4
- package/src/Divider/__tests__/Divider.contract.test.tsx +1 -1
- package/src/Drawer/Drawer.tsx +5 -5
- package/src/Drawer/README.md +2 -2
- package/src/Dropdown/Dropdown.tsx +2 -2
- package/src/Dropdown/README.md +7 -5
- package/src/EmptyState/README.md +2 -2
- package/src/Field/README.md +1 -1
- package/src/Field/hooks/useFieldProps.ts +1 -1
- package/src/Fieldset/Fieldset.tsx +3 -3
- package/src/Fieldset/README.md +2 -2
- package/src/Input/Input.tsx +1 -1
- package/src/Input/README.md +2 -2
- package/src/Listbox/Listbox.tsx +475 -0
- package/src/Listbox/ListboxContext.ts +48 -0
- package/src/Listbox/README.md +248 -0
- package/src/Listbox/__tests__/Listbox.active-descendant.test.tsx +128 -0
- package/src/Listbox/__tests__/Listbox.asChild.test.tsx +91 -0
- package/src/Listbox/__tests__/Listbox.basic-rendering.test.tsx +16 -0
- package/src/Listbox/__tests__/Listbox.controlled-state.test.tsx +114 -0
- package/src/Listbox/__tests__/Listbox.disabled.test.tsx +90 -0
- package/src/Listbox/__tests__/Listbox.fixtures.ts +27 -0
- package/src/Listbox/__tests__/Listbox.group-label.test.tsx +97 -0
- package/src/Listbox/__tests__/Listbox.group.test.tsx +45 -0
- package/src/Listbox/__tests__/Listbox.keyboard-edge-cases.test.tsx +144 -0
- package/src/Listbox/__tests__/Listbox.keyboard-interaction.test.tsx +112 -0
- package/src/Listbox/__tests__/Listbox.modifier-keys.test.tsx +86 -0
- package/src/Listbox/__tests__/Listbox.multi-select-keyboard.test.tsx +368 -0
- package/src/Listbox/__tests__/Listbox.multiple-mode.test.tsx +91 -0
- package/src/Listbox/__tests__/Listbox.mutation-hardening.test.tsx +480 -0
- package/src/Listbox/__tests__/Listbox.orientation.test.tsx +59 -0
- package/src/Listbox/__tests__/Listbox.reading-direction.test.tsx +65 -0
- package/src/Listbox/__tests__/Listbox.reorder.test.tsx +78 -0
- package/src/Listbox/__tests__/Listbox.selection-follows-focus.test.tsx +64 -0
- package/src/Listbox/__tests__/Listbox.strict-context.test.tsx +21 -0
- package/src/Listbox/__tests__/Listbox.typeahead.test.tsx +123 -0
- package/src/Listbox/__tests__/Listbox.uncontrolled-state.test.tsx +48 -0
- package/src/Listbox/constants.ts +2 -0
- package/src/Listbox/hooks/index.ts +1 -0
- package/src/Listbox/hooks/useListboxRoot.ts +407 -0
- package/src/Listbox/index.ts +3 -0
- package/src/Listbox/types.ts +238 -0
- package/src/MillerColumns/MillerColumns.tsx +104 -30
- package/src/MillerColumns/README.md +90 -20
- package/src/MillerColumns/__tests__/MillerColumns.aria.test.tsx +45 -0
- package/src/MillerColumns/__tests__/MillerColumns.auto-scroll.test.tsx +4 -2
- package/src/MillerColumns/__tests__/MillerColumns.basic-rendering.test.tsx +18 -3
- package/src/MillerColumns/__tests__/MillerColumns.data-attributes.test.tsx +48 -0
- package/src/MillerColumns/__tests__/MillerColumns.keyboard-interaction.test.tsx +35 -2
- package/src/MillerColumns/__tests__/MillerColumns.keyboard-selection-state.test.tsx +96 -0
- package/src/MillerColumns/__tests__/MillerColumns.preview-panel.test.tsx +29 -4
- package/src/MillerColumns/__tests__/MillerColumns.preview-visibility.test.tsx +79 -0
- package/src/MillerColumns/__tests__/MillerColumns.resize.test.tsx +189 -0
- package/src/MillerColumns/__tests__/MillerColumns.styled-parts.test.tsx +143 -0
- package/src/MillerColumns/__tests__/MillerColumns.typeahead.test.tsx +104 -0
- package/src/MillerColumns/constants.ts +2 -0
- package/src/MillerColumns/hooks/useMillerColumnsColumn.ts +5 -2
- package/src/MillerColumns/hooks/useMillerColumnsItem.ts +29 -7
- package/src/MillerColumns/hooks/useMillerColumnsResizeHandle.ts +80 -10
- package/src/MillerColumns/hooks/useMillerColumnsRoot.ts +0 -0
- package/src/MillerColumns/index.ts +4 -0
- package/src/MillerColumns/types.ts +56 -6
- package/src/MillerColumns/utils.ts +69 -3
- package/src/Modal/Modal.tsx +4 -4
- package/src/Modal/README.md +5 -5
- package/src/Modal/types.ts +1 -1
- package/src/NavigationMenu/NavigationMenu.tsx +6 -6
- package/src/NavigationMenu/README.md +3 -3
- package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +1 -1
- package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +1 -1
- package/src/Popover/Popover.tsx +2 -2
- package/src/Popover/README.md +1 -1
- package/src/Portal/Portal.tsx +2 -2
- package/src/Portal/README.md +2 -2
- package/src/Progress/README.md +1 -1
- package/src/Radio/Radio.tsx +1 -1
- package/src/RadioCard/README.md +2 -1
- package/src/RadioCard/RadioCard.tsx +5 -2
- package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +23 -0
- package/src/RadioGroup/README.md +2 -2
- package/src/RadioGroup/RadioGroup.tsx +1 -1
- package/src/SegmentedControl/README.md +2 -2
- package/src/SegmentedControl/SegmentedControl.tsx +1 -1
- package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +1 -0
- package/src/Select/README.md +2 -2
- package/src/Select/Select.tsx +8 -8
- package/src/Select/__tests__/Select.placeholder.test.tsx +3 -3
- package/src/Select/__tests__/Select.typeahead.test.tsx +3 -3
- package/src/Select/__tests__/Select.value-mirror.test.tsx +1 -1
- package/src/Select/types.ts +2 -2
- package/src/SkipNav/README.md +3 -3
- package/src/SkipNav/SkipNav.tsx +5 -5
- package/src/Slider/README.md +37 -7
- package/src/Slider/Slider.tsx +9 -3
- package/src/Slot/Slot.tsx +2 -2
- package/src/Slot/__tests__/Slot.test.tsx +2 -2
- package/src/Slot/types.ts +1 -1
- package/src/SplitButton/README.md +225 -0
- package/src/SplitButton/SplitButton.tsx +457 -0
- package/src/SplitButton/SplitButtonContext.ts +26 -0
- package/src/SplitButton/__tests__/SplitButton.accessibility.test.tsx +55 -0
- package/src/SplitButton/__tests__/SplitButton.asChild.test.tsx +51 -0
- package/src/SplitButton/__tests__/SplitButton.basic-rendering.test.tsx +57 -0
- package/src/SplitButton/__tests__/SplitButton.data-attributes.test.tsx +28 -0
- package/src/SplitButton/__tests__/SplitButton.disabled.test.tsx +79 -0
- package/src/SplitButton/__tests__/SplitButton.error-handling.test.tsx +29 -0
- package/src/SplitButton/__tests__/SplitButton.keyboard-interaction.test.tsx +56 -0
- package/src/SplitButton/__tests__/SplitButton.state-modes.test.tsx +68 -0
- package/src/SplitButton/index.ts +2 -0
- package/src/SplitButton/types.ts +186 -0
- package/src/Switch/README.md +1 -1
- package/src/Switch/Switch.tsx +2 -2
- package/src/Switch/types.ts +1 -1
- package/src/Table/README.md +110 -8
- package/src/Table/Table.tsx +206 -10
- package/src/Table/TableExpandableContext.ts +7 -0
- package/src/Table/__tests__/Table.DetailRow.test.tsx +83 -0
- package/src/Table/__tests__/Table.ExpandTrigger.test.tsx +102 -0
- package/src/Table/__tests__/Table.Expandable.test.tsx +121 -0
- package/src/Table/types.ts +125 -0
- package/src/Tabs/README.md +4 -4
- package/src/Tabs/Tabs.tsx +5 -4
- package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +58 -0
- package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +15 -0
- package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +24 -0
- package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +36 -0
- package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +17 -0
- package/src/Tabs/hooks/useTabsContent.ts +5 -0
- package/src/Tabs/hooks/useTabsRoot.ts +29 -5
- package/src/Tabs/hooks/useTabsTrigger.ts +7 -2
- package/src/ToggleGroup/README.md +4 -4
- package/src/Tooltip/README.md +7 -7
- package/src/Tooltip/Tooltip.tsx +2 -2
- package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +50 -0
- package/src/Tooltip/__tests__/Tooltip.error-handling.test.tsx +37 -0
- package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
- package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +134 -0
- package/src/Tooltip/__tests__/Tooltip.skip-delay.test.tsx +216 -0
- package/src/Tooltip/__tests__/Tooltip.timer-coordination.test.tsx +223 -0
- package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
- package/src/Tooltip/hooks/useTooltipProvider.ts +40 -14
- package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
- package/src/Tree/README.md +69 -5
- package/src/Tree/Tree.tsx +1 -1
- package/src/Tree/__tests__/Tree.styled-wrapper.test.tsx +159 -0
- package/src/Tree/hooks/useTreePath.ts +33 -1
- package/src/Tree/index.ts +6 -1
- package/src/Tree/utils.ts +46 -4
- package/src/VisuallyHidden/VisuallyHidden.tsx +1 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useTypeahead.ts +95 -0
- package/src/index.ts +6 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.reading.test.tsx +31 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.resilience.test.tsx +97 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.ssr.test.tsx +20 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.writing.test.tsx +72 -0
- package/src/useLocalStorage/index.ts +1 -0
- package/src/useLocalStorage/useLocalStorage.ts +140 -0
- package/src/useMediaQuery/README.md +66 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.matching.test.tsx +42 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.ssr.test.tsx +19 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.subscription.test.tsx +89 -0
- package/src/useMediaQuery/index.ts +1 -0
- package/src/useMediaQuery/useMediaQuery.ts +49 -0
- package/src/usePagination/README.md +170 -0
- package/src/usePagination/__tests__/usePagination.navigation.test.tsx +109 -0
- package/src/usePagination/__tests__/usePagination.page-count.test.tsx +56 -0
- package/src/usePagination/__tests__/usePagination.page-state.test.tsx +87 -0
- package/src/usePagination/__tests__/usePagination.range.test.tsx +82 -0
- package/src/usePagination/__tests__/usePagination.slice.test.tsx +59 -0
- package/src/usePagination/__tests__/usePagination.windowing.test.tsx +68 -0
- package/src/usePagination/index.ts +1 -0
- package/src/usePagination/usePagination.ts +292 -0
- package/src/utils/getKeyToActionMap.ts +1 -1
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { useId, useMemo, type KeyboardEvent, type ReactElement } from "react";
|
|
2
|
+
|
|
3
|
+
import { Dropdown } from "../Dropdown/index.ts";
|
|
4
|
+
import { useDropdownContext } from "../Dropdown/hooks/index.ts";
|
|
5
|
+
import type { DropdownRootProps } from "../Dropdown/types";
|
|
6
|
+
import { Slot, composeEventHandlers } from "../Slot/index.ts";
|
|
7
|
+
import { deriveId } from "../utils/index.ts";
|
|
8
|
+
import {
|
|
9
|
+
SplitButtonProvider,
|
|
10
|
+
useSplitButtonContext,
|
|
11
|
+
} from "./SplitButtonContext";
|
|
12
|
+
import {
|
|
13
|
+
SplitButtonActionProps,
|
|
14
|
+
SplitButtonFrameProps,
|
|
15
|
+
SplitButtonItemProps,
|
|
16
|
+
SplitButtonMenuProps,
|
|
17
|
+
SplitButtonRootProps,
|
|
18
|
+
SplitButtonSeparatorProps,
|
|
19
|
+
SplitButtonTriggerProps,
|
|
20
|
+
} from "./types";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The root of a SplitButton — renders the `role="group"` element that binds
|
|
24
|
+
* the primary action and the menu trigger into one widget, and owns the
|
|
25
|
+
* menu's open state by rendering a {@link Dropdown | `Dropdown.Root`} around
|
|
26
|
+
* it.
|
|
27
|
+
*
|
|
28
|
+
* **Two controls, one widget.** A split button is *not* a `Select`: the left
|
|
29
|
+
* half performs an action immediately, the right half opens a menu of related
|
|
30
|
+
* alternatives. Both halves stay independently tabbable — that is correct for
|
|
31
|
+
* two genuinely separate commands, and deliberately *not* a roving tabstop.
|
|
32
|
+
*
|
|
33
|
+
* **Open state.** Identical to `Dropdown.Root`'s: uncontrolled via
|
|
34
|
+
* `defaultOpen`, or controlled via `open` + `onOpenChange`, statically
|
|
35
|
+
* discriminated at the type level. `dir` is forwarded too, so submenus
|
|
36
|
+
* composed from `Dropdown.Sub` invert their arrow keys in RTL.
|
|
37
|
+
*
|
|
38
|
+
* **Styling hooks.** `data-split-button=""`, `data-state="open" | "closed"`
|
|
39
|
+
* tracking the menu, and `data-disabled=""` when the group is disabled.
|
|
40
|
+
*
|
|
41
|
+
* **`asChild` composition.** Pass `asChild` to render a consumer-supplied
|
|
42
|
+
* element as the group instead of `<div>`.
|
|
43
|
+
*
|
|
44
|
+
* @extends HTMLDivElement
|
|
45
|
+
*
|
|
46
|
+
* @example Uncontrolled
|
|
47
|
+
* ```tsx
|
|
48
|
+
* <SplitButton>
|
|
49
|
+
* <SplitButton.Action onClick={squashAndMerge}>
|
|
50
|
+
* Squash and merge
|
|
51
|
+
* </SplitButton.Action>
|
|
52
|
+
* <SplitButton.Trigger>
|
|
53
|
+
* <ChevronDown aria-hidden="true" />
|
|
54
|
+
* <VisuallyHidden>More merge options</VisuallyHidden>
|
|
55
|
+
* </SplitButton.Trigger>
|
|
56
|
+
* <SplitButton.Menu>
|
|
57
|
+
* <SplitButton.Item onSelect={mergeCommit}>
|
|
58
|
+
* Create a merge commit
|
|
59
|
+
* </SplitButton.Item>
|
|
60
|
+
* <SplitButton.Item onSelect={rebase}>Rebase and merge</SplitButton.Item>
|
|
61
|
+
* </SplitButton.Menu>
|
|
62
|
+
* </SplitButton>
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* @example Controlled menu, whole widget disabled
|
|
66
|
+
* ```tsx
|
|
67
|
+
* const [open, setOpen] = useState(false);
|
|
68
|
+
*
|
|
69
|
+
* <SplitButton open={open} onOpenChange={setOpen} disabled={saving}>
|
|
70
|
+
* ...
|
|
71
|
+
* </SplitButton>
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function SplitButtonRoot({
|
|
75
|
+
defaultOpen,
|
|
76
|
+
open,
|
|
77
|
+
onOpenChange,
|
|
78
|
+
dir,
|
|
79
|
+
disabled = false,
|
|
80
|
+
children,
|
|
81
|
+
ref,
|
|
82
|
+
...rest
|
|
83
|
+
}: SplitButtonRootProps): ReactElement {
|
|
84
|
+
return (
|
|
85
|
+
<Dropdown.Root
|
|
86
|
+
{...({ defaultOpen, open, onOpenChange, dir } as DropdownRootProps)}
|
|
87
|
+
>
|
|
88
|
+
<SplitButtonFrame {...rest} ref={ref} disabled={disabled}>
|
|
89
|
+
{children}
|
|
90
|
+
</SplitButtonFrame>
|
|
91
|
+
</Dropdown.Root>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @internal */
|
|
96
|
+
// Stryker disable next-line StringLiteral: the compound is `Object.assign(SplitButtonRoot, ...)`,
|
|
97
|
+
// so `SplitButton.displayName = "SplitButton"` overwrites this value at load — it is never
|
|
98
|
+
// observable at runtime. The assignment itself must stay: it is what declares `displayName`
|
|
99
|
+
// on `typeof SplitButtonRoot`, which `TSplitButtonCompound` extends.
|
|
100
|
+
SplitButtonRoot.displayName = "SplitButtonRoot";
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The group element itself, split out of {@link SplitButtonRoot} so it can
|
|
104
|
+
* read the open state of the `Dropdown.Root` that Root renders around it.
|
|
105
|
+
*
|
|
106
|
+
* @internal
|
|
107
|
+
*/
|
|
108
|
+
function SplitButtonFrame({
|
|
109
|
+
asChild = false,
|
|
110
|
+
disabled,
|
|
111
|
+
children,
|
|
112
|
+
ref,
|
|
113
|
+
...rest
|
|
114
|
+
}: SplitButtonFrameProps): ReactElement {
|
|
115
|
+
const { open } = useDropdownContext();
|
|
116
|
+
const rootId = useId();
|
|
117
|
+
const contextValue = useMemo(
|
|
118
|
+
() => ({
|
|
119
|
+
actionId: deriveId(rootId, "split-button", "action"),
|
|
120
|
+
triggerId: deriveId(rootId, "split-button", "trigger"),
|
|
121
|
+
disabled,
|
|
122
|
+
}),
|
|
123
|
+
[rootId, disabled],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const frameProps = {
|
|
127
|
+
...rest,
|
|
128
|
+
ref,
|
|
129
|
+
role: "group" as const,
|
|
130
|
+
"data-split-button": "",
|
|
131
|
+
"data-state": open ? ("open" as const) : ("closed" as const),
|
|
132
|
+
"data-disabled": disabled ? "" : undefined,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<SplitButtonProvider value={contextValue}>
|
|
137
|
+
{asChild ? (
|
|
138
|
+
<Slot {...frameProps}>{children}</Slot>
|
|
139
|
+
) : (
|
|
140
|
+
<div {...frameProps}>{children}</div>
|
|
141
|
+
)}
|
|
142
|
+
</SplitButtonProvider>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The primary half — a `<button type="button">` that performs the default
|
|
148
|
+
* action immediately on click. It is **not** the menu trigger and carries no
|
|
149
|
+
* `aria-haspopup`.
|
|
150
|
+
*
|
|
151
|
+
* **Keyboard.** `ArrowDown` opens the menu and moves focus to its first item,
|
|
152
|
+
* the standard split-button affordance for reaching the alternatives without
|
|
153
|
+
* tabbing past the action. Every other key falls through, and a consumer's own
|
|
154
|
+
* `onKeyDown` runs first.
|
|
155
|
+
*
|
|
156
|
+
* **Accessible naming.** This element's `id` is component-owned (hence
|
|
157
|
+
* `Omit`-ted from the props) because
|
|
158
|
+
* {@link SplitButtonTrigger | `SplitButton.Trigger`} references it to build
|
|
159
|
+
* its own accessible name — give the action a visible text label and the
|
|
160
|
+
* chevron half is named for free.
|
|
161
|
+
*
|
|
162
|
+
* **Disabled.** Sets native `disabled` plus `data-disabled=""` when either
|
|
163
|
+
* this prop or the group's `disabled` is set; the two are OR-ed, so the group
|
|
164
|
+
* can never be overridden back to enabled.
|
|
165
|
+
*
|
|
166
|
+
* **`asChild` composition.** Renders the consumer's element instead of
|
|
167
|
+
* `<button>` — e.g. a router link. `type="button"` is **not** forwarded in
|
|
168
|
+
* this mode; the child owns its own type semantics.
|
|
169
|
+
*
|
|
170
|
+
* @extends HTMLButtonElement
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```tsx
|
|
174
|
+
* <SplitButton.Action onClick={squashAndMerge}>
|
|
175
|
+
* Squash and merge
|
|
176
|
+
* </SplitButton.Action>
|
|
177
|
+
* ```
|
|
178
|
+
*
|
|
179
|
+
* @example asChild — the action navigates instead of firing a handler
|
|
180
|
+
* ```tsx
|
|
181
|
+
* <SplitButton.Action asChild>
|
|
182
|
+
* <a href="/merge">Squash and merge</a>
|
|
183
|
+
* </SplitButton.Action>
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
export function SplitButtonAction({
|
|
187
|
+
asChild = false,
|
|
188
|
+
children,
|
|
189
|
+
disabled,
|
|
190
|
+
onKeyDown,
|
|
191
|
+
ref,
|
|
192
|
+
...rest
|
|
193
|
+
}: SplitButtonActionProps): ReactElement {
|
|
194
|
+
const { actionId, disabled: groupDisabled } = useSplitButtonContext();
|
|
195
|
+
const { setOpen } = useDropdownContext();
|
|
196
|
+
const isDisabled = groupDisabled || disabled === true;
|
|
197
|
+
|
|
198
|
+
const openMenu = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
199
|
+
if (event.key !== "ArrowDown") return;
|
|
200
|
+
event.preventDefault();
|
|
201
|
+
setOpen(true);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const actionProps = {
|
|
205
|
+
...rest,
|
|
206
|
+
ref,
|
|
207
|
+
id: actionId,
|
|
208
|
+
disabled: isDisabled,
|
|
209
|
+
onKeyDown: composeEventHandlers(onKeyDown, openMenu),
|
|
210
|
+
"data-disabled": isDisabled ? "" : undefined,
|
|
211
|
+
"data-split-button-action": "",
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (asChild) {
|
|
215
|
+
return <Slot {...actionProps}>{children}</Slot>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<button type="button" {...actionProps}>
|
|
220
|
+
{children}
|
|
221
|
+
</button>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
SplitButtonAction.displayName = "SplitButtonAction";
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The menu half — a `<button type="button">` carrying `aria-haspopup="menu"`,
|
|
229
|
+
* `aria-expanded` and `aria-controls`, all inherited from
|
|
230
|
+
* {@link Dropdown | `Dropdown.Trigger`}.
|
|
231
|
+
*
|
|
232
|
+
* **Accessible name, derived.** A chevron-only control has no text of its own,
|
|
233
|
+
* so unless you pass `aria-label` or `aria-labelledby` the trigger defaults to
|
|
234
|
+
* `aria-labelledby="<its own id> <the action's id>"`. Self-reference is valid
|
|
235
|
+
* in `aria-labelledby` and resolves to the element's own contents, so a
|
|
236
|
+
* visually-hidden "More options" inside the trigger yields
|
|
237
|
+
* *"More options, Squash and merge"* rather than an unlabelled button or a
|
|
238
|
+
* second control with the same name as the action. Passing either ARIA
|
|
239
|
+
* attribute yourself opts out entirely.
|
|
240
|
+
*
|
|
241
|
+
* **Disabled.** OR-ed with the group's `disabled`, exactly like
|
|
242
|
+
* {@link SplitButtonAction | `SplitButton.Action`}.
|
|
243
|
+
*
|
|
244
|
+
* **Styling hooks.** `data-split-button-trigger=""`, plus `data-disabled=""`.
|
|
245
|
+
* Its `id` is component-owned (`Omit`-ted from the props) because the derived
|
|
246
|
+
* name references it.
|
|
247
|
+
*
|
|
248
|
+
* @extends HTMLButtonElement
|
|
249
|
+
*
|
|
250
|
+
* @example Derived name — chevron plus hidden text
|
|
251
|
+
* ```tsx
|
|
252
|
+
* <SplitButton.Trigger>
|
|
253
|
+
* <ChevronDown aria-hidden="true" />
|
|
254
|
+
* <VisuallyHidden>More merge options</VisuallyHidden>
|
|
255
|
+
* </SplitButton.Trigger>
|
|
256
|
+
* ```
|
|
257
|
+
*
|
|
258
|
+
* @example Explicit name — opts out of the derivation
|
|
259
|
+
* ```tsx
|
|
260
|
+
* <SplitButton.Trigger aria-label="More merge options">
|
|
261
|
+
* <ChevronDown aria-hidden="true" />
|
|
262
|
+
* </SplitButton.Trigger>
|
|
263
|
+
* ```
|
|
264
|
+
*/
|
|
265
|
+
export function SplitButtonTrigger({
|
|
266
|
+
"aria-label": ariaLabel,
|
|
267
|
+
"aria-labelledby": ariaLabelledBy,
|
|
268
|
+
disabled,
|
|
269
|
+
...rest
|
|
270
|
+
}: SplitButtonTriggerProps): ReactElement {
|
|
271
|
+
const {
|
|
272
|
+
actionId,
|
|
273
|
+
triggerId,
|
|
274
|
+
disabled: groupDisabled,
|
|
275
|
+
} = useSplitButtonContext();
|
|
276
|
+
const named = ariaLabel !== undefined || ariaLabelledBy !== undefined;
|
|
277
|
+
const isDisabled = groupDisabled || disabled === true;
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<Dropdown.Trigger
|
|
281
|
+
{...rest}
|
|
282
|
+
id={triggerId}
|
|
283
|
+
aria-label={ariaLabel}
|
|
284
|
+
aria-labelledby={named ? ariaLabelledBy : `${triggerId} ${actionId}`}
|
|
285
|
+
disabled={isDisabled}
|
|
286
|
+
data-disabled={isDisabled ? "" : undefined}
|
|
287
|
+
data-split-button-trigger=""
|
|
288
|
+
/>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
SplitButtonTrigger.displayName = "SplitButtonTrigger";
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The menu panel of alternative actions — a `role="menu"` native
|
|
296
|
+
* `popover="auto"` element delegating wholly to
|
|
297
|
+
* {@link Dropdown | `Dropdown.Content`}: arrow-key navigation, Home/End,
|
|
298
|
+
* typeahead, Escape-to-close-and-restore-focus, and light dismiss all come
|
|
299
|
+
* from there.
|
|
300
|
+
*
|
|
301
|
+
* Anything valid inside a `Dropdown.Content` is valid here — `Dropdown.Group`,
|
|
302
|
+
* `Dropdown.Label`, `Dropdown.CheckboxItem`, `Dropdown.Sub` — because Root
|
|
303
|
+
* provides the same Dropdown context. `SplitButton` re-exports only the two
|
|
304
|
+
* parts a split button's menu almost always needs
|
|
305
|
+
* ({@link SplitButtonItem | `Item`} and
|
|
306
|
+
* {@link SplitButtonSeparator | `Separator`}); reach for `Dropdown.*` directly
|
|
307
|
+
* for the rest.
|
|
308
|
+
*
|
|
309
|
+
* **Styling hook.** `data-split-button-menu=""`, alongside Dropdown's own.
|
|
310
|
+
*
|
|
311
|
+
* @extends HTMLMenuElement
|
|
312
|
+
*
|
|
313
|
+
* @example
|
|
314
|
+
* ```tsx
|
|
315
|
+
* <SplitButton.Menu>
|
|
316
|
+
* <SplitButton.Item onSelect={mergeCommit}>Create a merge commit</SplitButton.Item>
|
|
317
|
+
* <SplitButton.Separator />
|
|
318
|
+
* <SplitButton.Item disabled>Rebase and merge</SplitButton.Item>
|
|
319
|
+
* </SplitButton.Menu>
|
|
320
|
+
* ```
|
|
321
|
+
*/
|
|
322
|
+
export function SplitButtonMenu(props: SplitButtonMenuProps): ReactElement {
|
|
323
|
+
return <Dropdown.Content {...props} data-split-button-menu="" />;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
SplitButtonMenu.displayName = "SplitButtonMenu";
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* One alternative action in the menu — a `role="menuitem"` row, delegating
|
|
330
|
+
* wholly to {@link Dropdown | `Dropdown.Item`}. Fires `onSelect` with a
|
|
331
|
+
* cancellable event and auto-closes the menu; call `event.preventDefault()`
|
|
332
|
+
* to keep it open. `disabled` rows get `aria-disabled` and are skipped by
|
|
333
|
+
* arrow navigation and typeahead.
|
|
334
|
+
*
|
|
335
|
+
* @extends HTMLDivElement
|
|
336
|
+
*
|
|
337
|
+
* @example
|
|
338
|
+
* ```tsx
|
|
339
|
+
* <SplitButton.Item onSelect={() => merge("rebase")}>
|
|
340
|
+
* Rebase and merge
|
|
341
|
+
* </SplitButton.Item>
|
|
342
|
+
* ```
|
|
343
|
+
*/
|
|
344
|
+
export function SplitButtonItem(props: SplitButtonItemProps): ReactElement {
|
|
345
|
+
return <Dropdown.Item {...props} />;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
SplitButtonItem.displayName = "SplitButtonItem";
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* A `role="separator"` divider between groups of alternatives, delegating
|
|
352
|
+
* wholly to {@link Dropdown | `Dropdown.Separator`}. Skipped by focus and
|
|
353
|
+
* typeahead.
|
|
354
|
+
*
|
|
355
|
+
* @extends HTMLDivElement
|
|
356
|
+
*
|
|
357
|
+
* @example
|
|
358
|
+
* ```tsx
|
|
359
|
+
* <SplitButton.Separator />
|
|
360
|
+
* ```
|
|
361
|
+
*/
|
|
362
|
+
export function SplitButtonSeparator(
|
|
363
|
+
props: SplitButtonSeparatorProps,
|
|
364
|
+
): ReactElement {
|
|
365
|
+
return <Dropdown.Separator {...props} />;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
SplitButtonSeparator.displayName = "SplitButtonSeparator";
|
|
369
|
+
|
|
370
|
+
/** Type of the {@link SplitButton} compound: the root callable plus its
|
|
371
|
+
* attached sub-components. */
|
|
372
|
+
export type TSplitButtonCompound = typeof SplitButtonRoot & {
|
|
373
|
+
Root: typeof SplitButtonRoot;
|
|
374
|
+
Action: typeof SplitButtonAction;
|
|
375
|
+
Trigger: typeof SplitButtonTrigger;
|
|
376
|
+
Menu: typeof SplitButtonMenu;
|
|
377
|
+
Item: typeof SplitButtonItem;
|
|
378
|
+
Separator: typeof SplitButtonSeparator;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Headless, accessible **SplitButton** — one primary action paired with a menu
|
|
383
|
+
* of related alternatives, bound into a single `role="group"` widget. Zero
|
|
384
|
+
* styles ship.
|
|
385
|
+
*
|
|
386
|
+
* A split button is an **actions** control, not a value control: clicking the
|
|
387
|
+
* left half runs the default action immediately, and the right half opens a
|
|
388
|
+
* menu of alternatives (which may run their own action, or re-point what the
|
|
389
|
+
* primary half does). If what you need is "which option is currently
|
|
390
|
+
* selected?", reach for {@link Select} instead.
|
|
391
|
+
*
|
|
392
|
+
* `SplitButton` is a composition over {@link Dropdown} — Root renders a
|
|
393
|
+
* `Dropdown.Root` and every menu part delegates to Dropdown's, so all the menu
|
|
394
|
+
* keyboard/focus/light-dismiss behaviour is Dropdown's, tested once. What this
|
|
395
|
+
* component adds on top is the coordination between the two halves that
|
|
396
|
+
* Dropdown alone cannot provide:
|
|
397
|
+
*
|
|
398
|
+
* - the `role="group"` boundary that makes them read as one widget;
|
|
399
|
+
* - the trigger's accessible name, derived from the primary action;
|
|
400
|
+
* - `disabled` propagating from the group to both halves;
|
|
401
|
+
* - `ArrowDown` on the action opening the menu.
|
|
402
|
+
*
|
|
403
|
+
* `SplitButton` is both callable (an alias of `SplitButton.Root`) and carries
|
|
404
|
+
* its sub-components as static properties:
|
|
405
|
+
*
|
|
406
|
+
* - {@link SplitButtonRoot | `SplitButton.Root`} — the `role="group"` element
|
|
407
|
+
* and the menu's state owner.
|
|
408
|
+
* - {@link SplitButtonAction | `SplitButton.Action`} — the primary half.
|
|
409
|
+
* - {@link SplitButtonTrigger | `SplitButton.Trigger`} — the menu half.
|
|
410
|
+
* - {@link SplitButtonMenu | `SplitButton.Menu`} — the menu panel.
|
|
411
|
+
* - {@link SplitButtonItem | `SplitButton.Item`} — one alternative action.
|
|
412
|
+
* - {@link SplitButtonSeparator | `SplitButton.Separator`} — a divider.
|
|
413
|
+
*
|
|
414
|
+
* **Both halves are tabbable.** They are two separate commands, so each is its
|
|
415
|
+
* own tab stop — this is not a roving-tabindex widget, and should not become
|
|
416
|
+
* one.
|
|
417
|
+
*
|
|
418
|
+
* @example
|
|
419
|
+
* ```tsx
|
|
420
|
+
* import { SplitButton, VisuallyHidden } from "@primitiv-ui/react";
|
|
421
|
+
*
|
|
422
|
+
* <SplitButton>
|
|
423
|
+
* <SplitButton.Action onClick={squashAndMerge}>
|
|
424
|
+
* Squash and merge
|
|
425
|
+
* </SplitButton.Action>
|
|
426
|
+
* <SplitButton.Trigger>
|
|
427
|
+
* <ChevronDown aria-hidden="true" />
|
|
428
|
+
* <VisuallyHidden>More merge options</VisuallyHidden>
|
|
429
|
+
* </SplitButton.Trigger>
|
|
430
|
+
* <SplitButton.Menu>
|
|
431
|
+
* <SplitButton.Item onSelect={mergeCommit}>
|
|
432
|
+
* Create a merge commit
|
|
433
|
+
* </SplitButton.Item>
|
|
434
|
+
* <SplitButton.Item onSelect={rebase}>Rebase and merge</SplitButton.Item>
|
|
435
|
+
* </SplitButton.Menu>
|
|
436
|
+
* </SplitButton>
|
|
437
|
+
* ```
|
|
438
|
+
*
|
|
439
|
+
* @see {@link SplitButtonRoot} for open-state modes and the group anatomy.
|
|
440
|
+
* @see {@link SplitButtonTrigger} for how the derived accessible name works.
|
|
441
|
+
* @see {@link Dropdown} for everything the menu itself does.
|
|
442
|
+
*/
|
|
443
|
+
const SplitButtonCompound: TSplitButtonCompound = Object.assign(
|
|
444
|
+
SplitButtonRoot,
|
|
445
|
+
{
|
|
446
|
+
Root: SplitButtonRoot,
|
|
447
|
+
Action: SplitButtonAction,
|
|
448
|
+
Trigger: SplitButtonTrigger,
|
|
449
|
+
Menu: SplitButtonMenu,
|
|
450
|
+
Item: SplitButtonItem,
|
|
451
|
+
Separator: SplitButtonSeparator,
|
|
452
|
+
},
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
SplitButtonCompound.displayName = "SplitButton";
|
|
456
|
+
|
|
457
|
+
export { SplitButtonCompound as SplitButton };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Context, Provider } from "react";
|
|
2
|
+
|
|
3
|
+
import { createStrictContext } from "../utils/index.ts";
|
|
4
|
+
|
|
5
|
+
import type { SplitButtonContextValue } from "./types";
|
|
6
|
+
|
|
7
|
+
const splitButtonContextPair = createStrictContext<SplitButtonContextValue>(
|
|
8
|
+
"SplitButton sub-components must be rendered inside a <SplitButton.Root>.",
|
|
9
|
+
"SplitButtonContext",
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
/** Strict React context carrying the {@link SplitButtonContextValue} from
|
|
13
|
+
* `SplitButton.Root` to its Action and Trigger. `null` outside a Root. */
|
|
14
|
+
export const SplitButtonContext: Context<SplitButtonContextValue | null> =
|
|
15
|
+
splitButtonContextPair[0];
|
|
16
|
+
|
|
17
|
+
/** Reads the {@link SplitButtonContextValue}; throws outside a
|
|
18
|
+
* `SplitButton.Root`. */
|
|
19
|
+
export const useSplitButtonContext: () => SplitButtonContextValue =
|
|
20
|
+
splitButtonContextPair[1];
|
|
21
|
+
|
|
22
|
+
/** Provider for {@link SplitButtonContext}, used by `SplitButton.Root`. */
|
|
23
|
+
const SplitButtonProvider: Provider<SplitButtonContextValue | null> =
|
|
24
|
+
SplitButtonContext.Provider;
|
|
25
|
+
|
|
26
|
+
export { SplitButtonProvider };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { SplitButton } from "../SplitButton";
|
|
5
|
+
|
|
6
|
+
describe("SplitButton — accessibility", () => {
|
|
7
|
+
it("names the menu trigger with its own text followed by the action's label", () => {
|
|
8
|
+
render(
|
|
9
|
+
<SplitButton>
|
|
10
|
+
<SplitButton.Action>Squash and merge</SplitButton.Action>
|
|
11
|
+
<SplitButton.Trigger>
|
|
12
|
+
<span>More options</span>
|
|
13
|
+
</SplitButton.Trigger>
|
|
14
|
+
<SplitButton.Menu />
|
|
15
|
+
</SplitButton>,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
expect(
|
|
19
|
+
screen.getByRole("button", { name: "More options Squash and merge" }),
|
|
20
|
+
).toHaveAttribute("aria-haspopup", "menu");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("lets an explicit aria-labelledby replace the derived name", () => {
|
|
24
|
+
render(
|
|
25
|
+
<>
|
|
26
|
+
<span id="external-label">Merge options</span>
|
|
27
|
+
<SplitButton>
|
|
28
|
+
<SplitButton.Action>Squash and merge</SplitButton.Action>
|
|
29
|
+
<SplitButton.Trigger aria-labelledby="external-label" />
|
|
30
|
+
<SplitButton.Menu />
|
|
31
|
+
</SplitButton>
|
|
32
|
+
</>,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
expect(
|
|
36
|
+
screen.getByRole("button", { name: "Merge options" }),
|
|
37
|
+
).toHaveAttribute("aria-labelledby", "external-label");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("gives each half a stable, role-suffixed id derived from the root", () => {
|
|
41
|
+
render(
|
|
42
|
+
<SplitButton>
|
|
43
|
+
<SplitButton.Action>Squash and merge</SplitButton.Action>
|
|
44
|
+
<SplitButton.Trigger aria-label="More merge options" />
|
|
45
|
+
<SplitButton.Menu />
|
|
46
|
+
</SplitButton>,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const action = screen.getByRole("button", { name: "Squash and merge" });
|
|
50
|
+
const trigger = screen.getByRole("button", { name: "More merge options" });
|
|
51
|
+
|
|
52
|
+
expect(action.id).toMatch(/-split-button-action$/);
|
|
53
|
+
expect(trigger.id).toMatch(/-split-button-trigger$/);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { createRef } from "react";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { SplitButton } from "../SplitButton";
|
|
7
|
+
|
|
8
|
+
describe("SplitButton — asChild", () => {
|
|
9
|
+
it("renders the consumer's element as the group", () => {
|
|
10
|
+
render(
|
|
11
|
+
<SplitButton asChild>
|
|
12
|
+
<section aria-label="Merge">
|
|
13
|
+
<SplitButton.Action>Squash and merge</SplitButton.Action>
|
|
14
|
+
</section>
|
|
15
|
+
</SplitButton>,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const group = screen.getByRole("group", { name: "Merge" });
|
|
19
|
+
|
|
20
|
+
expect(group.tagName).toBe("SECTION");
|
|
21
|
+
expect(group).toHaveAttribute("data-split-button", "");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("merges the action onto a consumer element, composing handlers and refs", async () => {
|
|
25
|
+
const user = userEvent.setup();
|
|
26
|
+
const onClick = vi.fn();
|
|
27
|
+
const ref = createRef<HTMLAnchorElement>();
|
|
28
|
+
|
|
29
|
+
render(
|
|
30
|
+
<SplitButton>
|
|
31
|
+
<SplitButton.Action asChild>
|
|
32
|
+
<a href="/merge" ref={ref} onClick={onClick}>
|
|
33
|
+
Squash and merge
|
|
34
|
+
</a>
|
|
35
|
+
</SplitButton.Action>
|
|
36
|
+
<SplitButton.Trigger aria-label="More merge options" />
|
|
37
|
+
<SplitButton.Menu />
|
|
38
|
+
</SplitButton>,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const action = screen.getByRole("link", { name: "Squash and merge" });
|
|
42
|
+
|
|
43
|
+
expect(ref.current).toBe(action);
|
|
44
|
+
expect(action).toHaveAttribute("data-split-button-action", "");
|
|
45
|
+
expect(action).not.toHaveAttribute("type");
|
|
46
|
+
|
|
47
|
+
await user.click(action);
|
|
48
|
+
|
|
49
|
+
expect(onClick).toHaveBeenCalledOnce();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { SplitButton } from "../SplitButton";
|
|
5
|
+
import { SplitButtonContext } from "../SplitButtonContext";
|
|
6
|
+
|
|
7
|
+
describe("SplitButton — basic rendering", () => {
|
|
8
|
+
it("renders a group wrapping the primary action", () => {
|
|
9
|
+
render(
|
|
10
|
+
<SplitButton>
|
|
11
|
+
<SplitButton.Action>Squash and merge</SplitButton.Action>
|
|
12
|
+
</SplitButton>,
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const group = screen.getByRole("group");
|
|
16
|
+
|
|
17
|
+
expect(group).toContainElement(
|
|
18
|
+
screen.getByRole("button", { name: "Squash and merge" }),
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("renders the menu half as a menu button over a menu of items", () => {
|
|
23
|
+
render(
|
|
24
|
+
<SplitButton>
|
|
25
|
+
<SplitButton.Action>Squash and merge</SplitButton.Action>
|
|
26
|
+
<SplitButton.Trigger aria-label="More merge options" />
|
|
27
|
+
<SplitButton.Menu>
|
|
28
|
+
<SplitButton.Item>Create a merge commit</SplitButton.Item>
|
|
29
|
+
<SplitButton.Separator />
|
|
30
|
+
<SplitButton.Item>Rebase and merge</SplitButton.Item>
|
|
31
|
+
</SplitButton.Menu>
|
|
32
|
+
</SplitButton>,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const trigger = screen.getByRole("button", { name: "More merge options" });
|
|
36
|
+
|
|
37
|
+
expect(trigger).toHaveAttribute("aria-haspopup", "menu");
|
|
38
|
+
expect(trigger).toHaveAttribute(
|
|
39
|
+
"aria-controls",
|
|
40
|
+
screen.getByRole("menu", { hidden: true }).id,
|
|
41
|
+
);
|
|
42
|
+
expect(screen.getAllByRole("menuitem", { hidden: true })).toHaveLength(2);
|
|
43
|
+
expect(screen.getByRole("separator", { hidden: true })).toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("sets a displayName on the compound, every sub-component and the context", () => {
|
|
47
|
+
// Empty displayNames would render each as anonymous in DevTools. Root
|
|
48
|
+
// aliases the compound (Object.assign), so its name is "SplitButton".
|
|
49
|
+
expect(SplitButton.displayName).toBe("SplitButton");
|
|
50
|
+
expect(SplitButton.Action.displayName).toBe("SplitButtonAction");
|
|
51
|
+
expect(SplitButton.Trigger.displayName).toBe("SplitButtonTrigger");
|
|
52
|
+
expect(SplitButton.Menu.displayName).toBe("SplitButtonMenu");
|
|
53
|
+
expect(SplitButton.Item.displayName).toBe("SplitButtonItem");
|
|
54
|
+
expect(SplitButton.Separator.displayName).toBe("SplitButtonSeparator");
|
|
55
|
+
expect(SplitButtonContext.displayName).toBe("SplitButtonContext");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { SplitButton } from "../SplitButton";
|
|
6
|
+
|
|
7
|
+
describe("SplitButton — data attributes", () => {
|
|
8
|
+
it("reflects the menu's open state on the group", async () => {
|
|
9
|
+
const user = userEvent.setup();
|
|
10
|
+
render(
|
|
11
|
+
<SplitButton>
|
|
12
|
+
<SplitButton.Action>Squash and merge</SplitButton.Action>
|
|
13
|
+
<SplitButton.Trigger aria-label="More merge options" />
|
|
14
|
+
<SplitButton.Menu>
|
|
15
|
+
<SplitButton.Item>Rebase and merge</SplitButton.Item>
|
|
16
|
+
</SplitButton.Menu>
|
|
17
|
+
</SplitButton>,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
expect(screen.getByRole("group")).toHaveAttribute("data-state", "closed");
|
|
21
|
+
|
|
22
|
+
await user.click(
|
|
23
|
+
screen.getByRole("button", { name: "More merge options" }),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
expect(screen.getByRole("group")).toHaveAttribute("data-state", "open");
|
|
27
|
+
});
|
|
28
|
+
});
|