@zentauri-ui/zentauri-components 2.1.7 → 2.1.8
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 +7 -5
- package/cli/props.json +189 -3
- package/cli/registry.json +9 -0
- package/dist/{chunk-KVSRUAXP.mjs → chunk-4PAHLHYF.mjs} +3 -3
- package/dist/{chunk-KVSRUAXP.mjs.map → chunk-4PAHLHYF.mjs.map} +1 -1
- package/dist/chunk-4SLVTSHM.js +241 -0
- package/dist/chunk-4SLVTSHM.js.map +1 -0
- package/dist/chunk-6OVDBAMI.js +19 -0
- package/dist/{chunk-5FU57ZVQ.js.map → chunk-6OVDBAMI.js.map} +1 -1
- package/dist/{chunk-5ELR6MIN.js → chunk-BAAXQPZ7.js} +6 -6
- package/dist/{chunk-5ELR6MIN.js.map → chunk-BAAXQPZ7.js.map} +1 -1
- package/dist/{chunk-DBNGLT5U.mjs → chunk-D7ZTSAA6.mjs} +4 -4
- package/dist/{chunk-DBNGLT5U.mjs.map → chunk-D7ZTSAA6.mjs.map} +1 -1
- package/dist/{chunk-TJ2EWPER.js → chunk-DPNTQ4AK.js} +47 -3
- package/dist/chunk-DPNTQ4AK.js.map +1 -0
- package/dist/chunk-IHDM7AHY.mjs +233 -0
- package/dist/chunk-IHDM7AHY.mjs.map +1 -0
- package/dist/{chunk-G7FVHZRB.js → chunk-L5QORCUO.js} +12 -12
- package/dist/{chunk-G7FVHZRB.js.map → chunk-L5QORCUO.js.map} +1 -1
- package/dist/{chunk-7UXPXCKV.mjs → chunk-OWVQVAOY.mjs} +3 -3
- package/dist/{chunk-7UXPXCKV.mjs.map → chunk-OWVQVAOY.mjs.map} +1 -1
- package/dist/{chunk-FUCW5GPE.mjs → chunk-UVP3MUBU.mjs} +39 -4
- package/dist/chunk-UVP3MUBU.mjs.map +1 -0
- package/dist/design-system/facade.js +3 -3
- package/dist/design-system/facade.mjs +2 -2
- package/dist/design-system/index.d.ts +1 -0
- package/dist/design-system/index.d.ts.map +1 -1
- package/dist/design-system/split-button.d.ts +25 -0
- package/dist/design-system/split-button.d.ts.map +1 -0
- package/dist/ui/buttons/animated.js +5 -5
- package/dist/ui/buttons/animated.mjs +3 -3
- package/dist/ui/buttons.js +6 -6
- package/dist/ui/buttons.mjs +4 -4
- package/dist/ui/data-table.js +16 -16
- package/dist/ui/data-table.mjs +6 -6
- package/dist/ui/dropdown/dropdown.d.ts +1 -1
- package/dist/ui/dropdown/dropdown.d.ts.map +1 -1
- package/dist/ui/dropdown/types.d.ts +2 -2
- package/dist/ui/dropdown/types.d.ts.map +1 -1
- package/dist/ui/dropdown.js +31 -231
- package/dist/ui/dropdown.js.map +1 -1
- package/dist/ui/dropdown.mjs +4 -229
- package/dist/ui/dropdown.mjs.map +1 -1
- package/dist/ui/dynamic-stepper.js +15 -15
- package/dist/ui/dynamic-stepper.mjs +4 -4
- package/dist/ui/pagination.js +7 -7
- package/dist/ui/pagination.mjs +4 -4
- package/dist/ui/split-button/index.d.ts +4 -0
- package/dist/ui/split-button/index.d.ts.map +1 -0
- package/dist/ui/split-button/split-button-base.d.ts +6 -0
- package/dist/ui/split-button/split-button-base.d.ts.map +1 -0
- package/dist/ui/split-button/split-button.d.ts +6 -0
- package/dist/ui/split-button/split-button.d.ts.map +1 -0
- package/dist/ui/split-button/types.d.ts +30 -0
- package/dist/ui/split-button/types.d.ts.map +1 -0
- package/dist/ui/split-button/variants.d.ts +16 -0
- package/dist/ui/split-button/variants.d.ts.map +1 -0
- package/dist/ui/split-button.js +287 -0
- package/dist/ui/split-button.js.map +1 -0
- package/dist/ui/split-button.mjs +278 -0
- package/dist/ui/split-button.mjs.map +1 -0
- package/package.json +1 -1
- package/src/design-system/index.ts +1 -0
- package/src/design-system/split-button.ts +38 -0
- package/src/ui/dropdown/dropdown.tsx +7 -3
- package/src/ui/dropdown/types.ts +2 -2
- package/src/ui/split-button/index.ts +19 -0
- package/src/ui/split-button/split-button-base.tsx +232 -0
- package/src/ui/split-button/split-button.test.tsx +208 -0
- package/src/ui/split-button/split-button.tsx +9 -0
- package/src/ui/split-button/types.ts +46 -0
- package/src/ui/split-button/variants.ts +46 -0
- package/dist/chunk-5FU57ZVQ.js +0 -19
- package/dist/chunk-FUCW5GPE.mjs.map +0 -1
- package/dist/chunk-TJ2EWPER.js.map +0 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const zuiSplitButtonRoot =
|
|
2
|
+
"inline-flex align-middle data-[full-width=true]:w-full";
|
|
3
|
+
|
|
4
|
+
export const zuiSplitButtonFullWidth = "w-full";
|
|
5
|
+
|
|
6
|
+
export const zuiSplitButtonDropdown =
|
|
7
|
+
"data-[full-width=true]:block data-[full-width=true]:w-full";
|
|
8
|
+
|
|
9
|
+
export const zuiSplitButtonGroup =
|
|
10
|
+
"inline-flex items-stretch data-[full-width=true]:w-full";
|
|
11
|
+
|
|
12
|
+
export const zuiSplitButtonPrimary =
|
|
13
|
+
"rounded-r-none data-[full-width=true]:min-w-0 data-[full-width=true]:flex-1";
|
|
14
|
+
|
|
15
|
+
export const zuiSplitButtonTrigger =
|
|
16
|
+
"rounded-l-none border-l border-[color:var(--zui-split-button-separator,var(--zui-border,#ffffff33))] dark:border-[color:var(--zui-split-button-separator-dark,var(--zui-border-dark,#00000033))] px-2.5";
|
|
17
|
+
|
|
18
|
+
export const zuiSplitButtonTriggerSizes = {
|
|
19
|
+
sm: "min-w-8 px-2",
|
|
20
|
+
md: "min-w-10 px-2.5",
|
|
21
|
+
lg: "min-w-11 px-3",
|
|
22
|
+
xl: "min-w-12 px-3.5",
|
|
23
|
+
"2xl": "min-w-14 px-4",
|
|
24
|
+
"3xl": "min-w-16 px-4",
|
|
25
|
+
"4xl": "min-w-18 px-5",
|
|
26
|
+
"5xl": "min-w-20 px-5",
|
|
27
|
+
"6xl": "min-w-22 px-6",
|
|
28
|
+
"7xl": "min-w-24 px-6",
|
|
29
|
+
"8xl": "min-w-26 px-7",
|
|
30
|
+
"9xl": "min-w-28 px-7",
|
|
31
|
+
"10xl": "min-w-30 px-8",
|
|
32
|
+
icon: "min-w-10 px-0",
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
export const zuiSplitButtonContent =
|
|
36
|
+
"min-w-[var(--zui-split-button-menu-min-width,12rem)]";
|
|
37
|
+
|
|
38
|
+
export const zuiSplitButtonItemDisabled = "pointer-events-none opacity-50";
|
|
@@ -37,10 +37,12 @@ const useDropdown = () => {
|
|
|
37
37
|
========================= */
|
|
38
38
|
export const Dropdown = ({
|
|
39
39
|
children,
|
|
40
|
+
className,
|
|
40
41
|
defaultOpen = false,
|
|
41
42
|
open: controlledOpen,
|
|
42
43
|
onOpenChange,
|
|
43
44
|
multiSelect = false,
|
|
45
|
+
...props
|
|
44
46
|
}: DropdownProps) => {
|
|
45
47
|
const menuId = `${useId()}-menu`;
|
|
46
48
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
@@ -82,7 +84,9 @@ export const Dropdown = ({
|
|
|
82
84
|
menuId,
|
|
83
85
|
}}
|
|
84
86
|
>
|
|
85
|
-
<div className="relative inline-block"
|
|
87
|
+
<div className={cn("relative inline-block", className)} {...props}>
|
|
88
|
+
{children}
|
|
89
|
+
</div>
|
|
86
90
|
</DropdownContext.Provider>
|
|
87
91
|
);
|
|
88
92
|
};
|
|
@@ -180,10 +184,10 @@ export const DropdownItem = ({
|
|
|
180
184
|
...props
|
|
181
185
|
}: DropdownItemProps) => {
|
|
182
186
|
const { toggleSelect, selectedValues } = useDropdown();
|
|
183
|
-
const isSelected = selectedValues.includes(value);
|
|
187
|
+
const isSelected = value !== undefined && selectedValues.includes(value);
|
|
184
188
|
|
|
185
189
|
const handleClick = () => {
|
|
186
|
-
toggleSelect(value);
|
|
190
|
+
if (value !== undefined) toggleSelect(value);
|
|
187
191
|
onSelect?.();
|
|
188
192
|
};
|
|
189
193
|
|
package/src/ui/dropdown/types.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type DropdownContextType = {
|
|
|
14
14
|
|
|
15
15
|
type Variant = keyof typeof zuiDropdownTriggerVariants;
|
|
16
16
|
|
|
17
|
-
export type DropdownProps = {
|
|
17
|
+
export type DropdownProps = HTMLAttributes<HTMLDivElement> & {
|
|
18
18
|
children: ReactNode;
|
|
19
19
|
defaultOpen?: boolean;
|
|
20
20
|
open?: boolean;
|
|
@@ -37,7 +37,7 @@ export type DropdownContentProps = HTMLAttributes<HTMLDivElement> & {
|
|
|
37
37
|
|
|
38
38
|
export type DropdownItemProps = HTMLAttributes<HTMLDivElement> & {
|
|
39
39
|
children: ReactNode;
|
|
40
|
-
value
|
|
40
|
+
value?: string;
|
|
41
41
|
onSelect?: () => void;
|
|
42
42
|
leftIcon?: ReactNode;
|
|
43
43
|
rightIcon?: ReactNode;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
export { SplitButton } from "./split-button";
|
|
4
|
+
export type {
|
|
5
|
+
SplitButtonAppearance,
|
|
6
|
+
SplitButtonItem,
|
|
7
|
+
SplitButtonProps,
|
|
8
|
+
SplitButtonSize,
|
|
9
|
+
SplitButtonVariant,
|
|
10
|
+
} from "./types";
|
|
11
|
+
export {
|
|
12
|
+
splitButtonContentVariants,
|
|
13
|
+
splitButtonDropdownVariants,
|
|
14
|
+
splitButtonGroupVariants,
|
|
15
|
+
splitButtonItemDisabledVariants,
|
|
16
|
+
splitButtonPrimaryVariants,
|
|
17
|
+
splitButtonRootVariants,
|
|
18
|
+
splitButtonTriggerVariants,
|
|
19
|
+
} from "./variants";
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { FiChevronDown, FiLoader } from "react-icons/fi";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
Dropdown,
|
|
8
|
+
DropdownContent,
|
|
9
|
+
DropdownItem,
|
|
10
|
+
DropdownTrigger,
|
|
11
|
+
} from "../dropdown";
|
|
12
|
+
import { buttonVariants } from "../buttons";
|
|
13
|
+
import { cn } from "../../lib/utils";
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
SplitButtonAppearance,
|
|
17
|
+
SplitButtonProps,
|
|
18
|
+
SplitButtonVariant,
|
|
19
|
+
} from "./types";
|
|
20
|
+
import {
|
|
21
|
+
splitButtonContentVariants,
|
|
22
|
+
splitButtonDropdownVariants,
|
|
23
|
+
splitButtonGroupVariants,
|
|
24
|
+
splitButtonItemDisabledVariants,
|
|
25
|
+
splitButtonPrimaryVariants,
|
|
26
|
+
splitButtonRootVariants,
|
|
27
|
+
splitButtonTriggerVariants,
|
|
28
|
+
} from "./variants";
|
|
29
|
+
|
|
30
|
+
const variantAppearanceMap = {
|
|
31
|
+
primary: "default",
|
|
32
|
+
secondary: "secondary",
|
|
33
|
+
outline: "outline",
|
|
34
|
+
ghost: "ghost",
|
|
35
|
+
danger: "destructive",
|
|
36
|
+
success: "green",
|
|
37
|
+
} as const satisfies Record<SplitButtonVariant, SplitButtonAppearance>;
|
|
38
|
+
|
|
39
|
+
function resolveAppearance({
|
|
40
|
+
appearance,
|
|
41
|
+
variant,
|
|
42
|
+
}: {
|
|
43
|
+
appearance?: SplitButtonAppearance;
|
|
44
|
+
variant?: SplitButtonVariant;
|
|
45
|
+
}) {
|
|
46
|
+
return appearance ?? (variant ? variantAppearanceMap[variant] : "default");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function SplitButtonBase({
|
|
50
|
+
label,
|
|
51
|
+
onClick,
|
|
52
|
+
items,
|
|
53
|
+
disabled = false,
|
|
54
|
+
loading = false,
|
|
55
|
+
appearance,
|
|
56
|
+
variant,
|
|
57
|
+
size = "md",
|
|
58
|
+
startIcon,
|
|
59
|
+
fullWidth = false,
|
|
60
|
+
open: controlledOpen,
|
|
61
|
+
defaultOpen = false,
|
|
62
|
+
onOpenChange,
|
|
63
|
+
triggerLabel,
|
|
64
|
+
triggerOn = "click",
|
|
65
|
+
className,
|
|
66
|
+
ref,
|
|
67
|
+
...rest
|
|
68
|
+
}: SplitButtonProps) {
|
|
69
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
70
|
+
const isUnavailable = disabled || loading;
|
|
71
|
+
const resolvedAppearance = resolveAppearance({ appearance, variant });
|
|
72
|
+
const isControlled = controlledOpen !== undefined;
|
|
73
|
+
const open = isUnavailable
|
|
74
|
+
? false
|
|
75
|
+
: isControlled
|
|
76
|
+
? controlledOpen
|
|
77
|
+
: uncontrolledOpen;
|
|
78
|
+
|
|
79
|
+
const setOpen = (nextOpen: boolean) => {
|
|
80
|
+
if (isUnavailable && nextOpen) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!isControlled) {
|
|
84
|
+
setUncontrolledOpen(nextOpen);
|
|
85
|
+
}
|
|
86
|
+
onOpenChange?.(nextOpen);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const fullWidthFlag = fullWidth ? "true" : undefined;
|
|
90
|
+
const menuLabel = triggerLabel ?? `More ${label} actions`;
|
|
91
|
+
|
|
92
|
+
// Shared timeout ref for hover mode: delays close so the cursor can
|
|
93
|
+
// travel through the mt-2 gap between the button and the menu panel
|
|
94
|
+
// without dismissing the menu.
|
|
95
|
+
const hoverCloseRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
96
|
+
|
|
97
|
+
const scheduleClose = () => {
|
|
98
|
+
cancelClose();
|
|
99
|
+
hoverCloseRef.current = setTimeout(() => setOpen(false), 120);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const cancelClose = () => {
|
|
103
|
+
if (hoverCloseRef.current !== null) {
|
|
104
|
+
clearTimeout(hoverCloseRef.current);
|
|
105
|
+
hoverCloseRef.current = null;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
useEffect(() => cancelClose, []);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (isUnavailable && !isControlled) {
|
|
113
|
+
setUncontrolledOpen(false);
|
|
114
|
+
onOpenChange?.(false);
|
|
115
|
+
}
|
|
116
|
+
}, [isUnavailable, onOpenChange, isControlled]);
|
|
117
|
+
|
|
118
|
+
const dropdownHoverHandlers =
|
|
119
|
+
triggerOn === "hover"
|
|
120
|
+
? {
|
|
121
|
+
onMouseEnter: () => {
|
|
122
|
+
cancelClose();
|
|
123
|
+
setOpen(true);
|
|
124
|
+
},
|
|
125
|
+
onMouseLeave: scheduleClose,
|
|
126
|
+
}
|
|
127
|
+
: undefined;
|
|
128
|
+
|
|
129
|
+
const contentHoverHandlers =
|
|
130
|
+
triggerOn === "hover"
|
|
131
|
+
? { onMouseEnter: cancelClose, onMouseLeave: scheduleClose }
|
|
132
|
+
: undefined;
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div
|
|
136
|
+
ref={ref}
|
|
137
|
+
data-slot="split-button"
|
|
138
|
+
data-full-width={fullWidthFlag}
|
|
139
|
+
className={cn(splitButtonRootVariants({ fullWidth }), className)}
|
|
140
|
+
{...rest}
|
|
141
|
+
>
|
|
142
|
+
<Dropdown
|
|
143
|
+
open={open}
|
|
144
|
+
defaultOpen={defaultOpen}
|
|
145
|
+
onOpenChange={setOpen}
|
|
146
|
+
data-full-width={fullWidthFlag}
|
|
147
|
+
className={splitButtonDropdownVariants({ fullWidth })}
|
|
148
|
+
{...dropdownHoverHandlers}
|
|
149
|
+
>
|
|
150
|
+
<div
|
|
151
|
+
data-slot="split-button-group"
|
|
152
|
+
data-full-width={fullWidthFlag}
|
|
153
|
+
className={splitButtonGroupVariants({ fullWidth })}
|
|
154
|
+
>
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
data-slot="split-button-primary"
|
|
158
|
+
data-full-width={fullWidthFlag}
|
|
159
|
+
disabled={isUnavailable}
|
|
160
|
+
onClick={onClick}
|
|
161
|
+
className={cn(
|
|
162
|
+
buttonVariants({ appearance: resolvedAppearance, size }),
|
|
163
|
+
splitButtonPrimaryVariants(),
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
{loading ? (
|
|
167
|
+
<FiLoader
|
|
168
|
+
aria-hidden
|
|
169
|
+
className="animate-spin"
|
|
170
|
+
data-slot="split-button-spinner-icon"
|
|
171
|
+
/>
|
|
172
|
+
) : (
|
|
173
|
+
startIcon
|
|
174
|
+
)}
|
|
175
|
+
<span>{label}</span>
|
|
176
|
+
{loading ? (
|
|
177
|
+
<span className="sr-only" role="status" aria-label="Loading" />
|
|
178
|
+
) : null}
|
|
179
|
+
</button>
|
|
180
|
+
|
|
181
|
+
<DropdownTrigger
|
|
182
|
+
aria-label={menuLabel}
|
|
183
|
+
disabled={isUnavailable}
|
|
184
|
+
className={cn(
|
|
185
|
+
buttonVariants({ appearance: resolvedAppearance, size }),
|
|
186
|
+
splitButtonTriggerVariants({ size }),
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
189
|
+
<FiChevronDown aria-hidden />
|
|
190
|
+
</DropdownTrigger>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<DropdownContent
|
|
194
|
+
className={splitButtonContentVariants()}
|
|
195
|
+
placement="bottom"
|
|
196
|
+
{...contentHoverHandlers}
|
|
197
|
+
>
|
|
198
|
+
{items.map((item) => (
|
|
199
|
+
<DropdownItem
|
|
200
|
+
key={item.id}
|
|
201
|
+
leftIcon={item.icon}
|
|
202
|
+
aria-disabled={item.disabled ? "true" : undefined}
|
|
203
|
+
className={
|
|
204
|
+
item.disabled ? splitButtonItemDisabledVariants() : undefined
|
|
205
|
+
}
|
|
206
|
+
onClick={(event) => {
|
|
207
|
+
if (item.disabled) {
|
|
208
|
+
event.preventDefault();
|
|
209
|
+
event.stopPropagation();
|
|
210
|
+
}
|
|
211
|
+
}}
|
|
212
|
+
onKeyDown={(event) => {
|
|
213
|
+
if (item.disabled) {
|
|
214
|
+
event.preventDefault();
|
|
215
|
+
event.stopPropagation();
|
|
216
|
+
}
|
|
217
|
+
}}
|
|
218
|
+
onSelect={() => {
|
|
219
|
+
setOpen(false);
|
|
220
|
+
item.onSelect?.();
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
{item.label}
|
|
224
|
+
</DropdownItem>
|
|
225
|
+
))}
|
|
226
|
+
</DropdownContent>
|
|
227
|
+
</Dropdown>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
SplitButtonBase.displayName = "SplitButton";
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { SplitButton } from "./split-button";
|
|
7
|
+
|
|
8
|
+
const items = [
|
|
9
|
+
{ id: "save-as", label: "Save As", onSelect: vi.fn() },
|
|
10
|
+
{ id: "export", label: "Export", onSelect: vi.fn() },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
describe("SplitButton", () => {
|
|
14
|
+
it("should render the primary label and menu trigger", () => {
|
|
15
|
+
render(<SplitButton label="Save" items={items} />);
|
|
16
|
+
|
|
17
|
+
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
|
|
18
|
+
expect(
|
|
19
|
+
screen.getByRole("button", { name: /more save actions/i }),
|
|
20
|
+
).toHaveAttribute("aria-haspopup", "menu");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should call onClick from the primary button", async () => {
|
|
24
|
+
const user = userEvent.setup();
|
|
25
|
+
const onClick = vi.fn();
|
|
26
|
+
render(<SplitButton label="Save" items={items} onClick={onClick} />);
|
|
27
|
+
|
|
28
|
+
await user.click(screen.getByRole("button", { name: "Save" }));
|
|
29
|
+
|
|
30
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should open the menu and call an item onSelect", async () => {
|
|
34
|
+
const user = userEvent.setup();
|
|
35
|
+
const handleExport = vi.fn();
|
|
36
|
+
render(
|
|
37
|
+
<SplitButton
|
|
38
|
+
label="Save"
|
|
39
|
+
items={[
|
|
40
|
+
{ id: "save-as", label: "Save As" },
|
|
41
|
+
{ id: "export", label: "Export", onSelect: handleExport },
|
|
42
|
+
]}
|
|
43
|
+
/>,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
await user.click(
|
|
47
|
+
screen.getByRole("button", { name: /more save actions/i }),
|
|
48
|
+
);
|
|
49
|
+
await user.click(await screen.findByRole("menuitem", { name: "Export" }));
|
|
50
|
+
|
|
51
|
+
expect(handleExport).toHaveBeenCalledTimes(1);
|
|
52
|
+
await waitFor(() => {
|
|
53
|
+
expect(screen.queryByRole("menuitem", { name: "Export" })).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should not open or call actions while disabled", async () => {
|
|
58
|
+
const user = userEvent.setup();
|
|
59
|
+
const onClick = vi.fn();
|
|
60
|
+
render(
|
|
61
|
+
<SplitButton
|
|
62
|
+
disabled
|
|
63
|
+
label="Save"
|
|
64
|
+
items={[{ id: "export", label: "Export" }]}
|
|
65
|
+
onClick={onClick}
|
|
66
|
+
/>,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const buttons = screen.getAllByRole("button");
|
|
70
|
+
expect(buttons).toHaveLength(2);
|
|
71
|
+
const [primary, trigger] = buttons as [HTMLElement, HTMLElement];
|
|
72
|
+
|
|
73
|
+
expect(primary).toBeDisabled();
|
|
74
|
+
expect(trigger).toBeDisabled();
|
|
75
|
+
await user.click(primary);
|
|
76
|
+
await user.click(trigger);
|
|
77
|
+
|
|
78
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
79
|
+
expect(screen.queryByRole("menu")).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should show a spinner and disable both buttons while loading", async () => {
|
|
83
|
+
const user = userEvent.setup();
|
|
84
|
+
render(
|
|
85
|
+
<SplitButton
|
|
86
|
+
loading
|
|
87
|
+
label="Saving"
|
|
88
|
+
items={[{ id: "export", label: "Export" }]}
|
|
89
|
+
/>,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(
|
|
93
|
+
screen.getByRole("status", { name: /loading/i }),
|
|
94
|
+
).toBeInTheDocument();
|
|
95
|
+
for (const button of screen.getAllByRole("button")) {
|
|
96
|
+
expect(button).toBeDisabled();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await user.click(
|
|
100
|
+
screen.getByRole("button", { name: /more saving actions/i }),
|
|
101
|
+
);
|
|
102
|
+
expect(screen.queryByRole("menu")).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("should support controlled open state", () => {
|
|
106
|
+
const onOpenChange = vi.fn();
|
|
107
|
+
render(
|
|
108
|
+
<SplitButton
|
|
109
|
+
open
|
|
110
|
+
onOpenChange={onOpenChange}
|
|
111
|
+
label="Save"
|
|
112
|
+
items={[{ id: "export", label: "Export" }]}
|
|
113
|
+
/>,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(screen.getByRole("menuitem", { name: "Export" })).toBeVisible();
|
|
117
|
+
expect(
|
|
118
|
+
screen.getByRole("button", { name: /more save actions/i }),
|
|
119
|
+
).toHaveAttribute("aria-expanded", "true");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should render start and item icons", async () => {
|
|
123
|
+
const user = userEvent.setup();
|
|
124
|
+
render(
|
|
125
|
+
<SplitButton
|
|
126
|
+
label="Save"
|
|
127
|
+
startIcon={<span data-testid="start-icon" />}
|
|
128
|
+
items={[
|
|
129
|
+
{
|
|
130
|
+
id: "export",
|
|
131
|
+
label: "Export",
|
|
132
|
+
icon: <span data-testid="item-icon" />,
|
|
133
|
+
},
|
|
134
|
+
]}
|
|
135
|
+
/>,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
expect(screen.getByTestId("start-icon")).toBeInTheDocument();
|
|
139
|
+
await user.click(
|
|
140
|
+
screen.getByRole("button", { name: /more save actions/i }),
|
|
141
|
+
);
|
|
142
|
+
expect(screen.getByTestId("item-icon")).toBeInTheDocument();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should apply variant aliases and fullWidth layout", () => {
|
|
146
|
+
render(
|
|
147
|
+
<SplitButton
|
|
148
|
+
fullWidth
|
|
149
|
+
label="Save"
|
|
150
|
+
variant="danger"
|
|
151
|
+
items={[{ id: "export", label: "Export" }]}
|
|
152
|
+
/>,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(document.querySelector('[data-slot="split-button"]')).toHaveClass(
|
|
156
|
+
"w-full",
|
|
157
|
+
);
|
|
158
|
+
expect(screen.getByRole("button", { name: "Save" }).className).toMatch(
|
|
159
|
+
/destructive/,
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("should forward ref to the root element", () => {
|
|
164
|
+
const ref = createRef<HTMLDivElement>();
|
|
165
|
+
render(<SplitButton ref={ref} label="Save" items={items} />);
|
|
166
|
+
|
|
167
|
+
expect(ref.current?.getAttribute("data-slot")).toBe("split-button");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("should open the menu on hover and close on mouse leave when triggerOn is hover", async () => {
|
|
171
|
+
const user = userEvent.setup();
|
|
172
|
+
render(
|
|
173
|
+
<SplitButton
|
|
174
|
+
triggerOn="hover"
|
|
175
|
+
label="Save"
|
|
176
|
+
items={[{ id: "export", label: "Export" }]}
|
|
177
|
+
/>,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const trigger = screen.getByRole("button", { name: /more save actions/i });
|
|
181
|
+
await user.hover(trigger);
|
|
182
|
+
expect(
|
|
183
|
+
await screen.findByRole("menuitem", { name: "Export" }),
|
|
184
|
+
).toBeVisible();
|
|
185
|
+
|
|
186
|
+
await user.unhover(trigger);
|
|
187
|
+
await waitFor(() => {
|
|
188
|
+
expect(screen.queryByRole("menuitem", { name: "Export" })).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("should not open on hover when disabled with triggerOn hover", async () => {
|
|
193
|
+
const user = userEvent.setup();
|
|
194
|
+
render(
|
|
195
|
+
<SplitButton
|
|
196
|
+
triggerOn="hover"
|
|
197
|
+
disabled
|
|
198
|
+
label="Save"
|
|
199
|
+
items={[{ id: "export", label: "Export" }]}
|
|
200
|
+
/>,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
await user.hover(
|
|
204
|
+
screen.getByRole("button", { name: /more save actions/i }),
|
|
205
|
+
);
|
|
206
|
+
expect(screen.queryByRole("menu")).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// split-button.tsx — default static entry (no framer-motion)
|
|
2
|
+
import { SplitButtonBase } from "./split-button-base";
|
|
3
|
+
import type { SplitButtonProps } from "./types";
|
|
4
|
+
|
|
5
|
+
export const SplitButton = (props: SplitButtonProps) => {
|
|
6
|
+
return <SplitButtonBase {...props} />;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
SplitButton.displayName = "SplitButton";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComponentPropsWithRef,
|
|
3
|
+
MouseEventHandler,
|
|
4
|
+
ReactNode,
|
|
5
|
+
} from "react";
|
|
6
|
+
|
|
7
|
+
import type { ButtonSharedStatic } from "../buttons";
|
|
8
|
+
|
|
9
|
+
export type SplitButtonAppearance = ButtonSharedStatic["appearance"];
|
|
10
|
+
export type SplitButtonSize = Extract<ButtonSharedStatic["size"], string>;
|
|
11
|
+
export type SplitButtonVariant =
|
|
12
|
+
| "primary"
|
|
13
|
+
| "secondary"
|
|
14
|
+
| "outline"
|
|
15
|
+
| "ghost"
|
|
16
|
+
| "danger"
|
|
17
|
+
| "success";
|
|
18
|
+
|
|
19
|
+
export interface SplitButtonItem {
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
icon?: ReactNode;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
onSelect?: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SplitButtonProps extends Omit<
|
|
28
|
+
ComponentPropsWithRef<"div">,
|
|
29
|
+
"children" | "onClick"
|
|
30
|
+
> {
|
|
31
|
+
label: string;
|
|
32
|
+
onClick?: MouseEventHandler<HTMLButtonElement>;
|
|
33
|
+
items: SplitButtonItem[];
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
loading?: boolean;
|
|
36
|
+
appearance?: SplitButtonAppearance;
|
|
37
|
+
variant?: SplitButtonVariant;
|
|
38
|
+
size?: SplitButtonSize;
|
|
39
|
+
startIcon?: ReactNode;
|
|
40
|
+
fullWidth?: boolean;
|
|
41
|
+
open?: boolean;
|
|
42
|
+
defaultOpen?: boolean;
|
|
43
|
+
onOpenChange?: (open: boolean) => void;
|
|
44
|
+
triggerLabel?: string;
|
|
45
|
+
triggerOn?: "click" | "hover";
|
|
46
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { cva } from "class-variance-authority";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
zuiSplitButtonContent,
|
|
5
|
+
zuiSplitButtonDropdown,
|
|
6
|
+
zuiSplitButtonFullWidth,
|
|
7
|
+
zuiSplitButtonGroup,
|
|
8
|
+
zuiSplitButtonItemDisabled,
|
|
9
|
+
zuiSplitButtonPrimary,
|
|
10
|
+
zuiSplitButtonRoot,
|
|
11
|
+
zuiSplitButtonTrigger,
|
|
12
|
+
zuiSplitButtonTriggerSizes,
|
|
13
|
+
} from "../../design-system";
|
|
14
|
+
|
|
15
|
+
export const splitButtonRootVariants = cva(zuiSplitButtonRoot, {
|
|
16
|
+
variants: {
|
|
17
|
+
fullWidth: {
|
|
18
|
+
true: zuiSplitButtonFullWidth,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
export const splitButtonDropdownVariants = cva(zuiSplitButtonDropdown, {
|
|
23
|
+
variants: {
|
|
24
|
+
fullWidth: {
|
|
25
|
+
true: zuiSplitButtonFullWidth,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
export const splitButtonGroupVariants = cva(zuiSplitButtonGroup, {
|
|
30
|
+
variants: {
|
|
31
|
+
fullWidth: {
|
|
32
|
+
true: zuiSplitButtonFullWidth,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
export const splitButtonPrimaryVariants = cva(zuiSplitButtonPrimary);
|
|
37
|
+
export const splitButtonTriggerVariants = cva(zuiSplitButtonTrigger, {
|
|
38
|
+
variants: {
|
|
39
|
+
size: zuiSplitButtonTriggerSizes,
|
|
40
|
+
},
|
|
41
|
+
defaultVariants: {
|
|
42
|
+
size: "md",
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
export const splitButtonContentVariants = cva(zuiSplitButtonContent);
|
|
46
|
+
export const splitButtonItemDisabledVariants = cva(zuiSplitButtonItemDisabled);
|
package/dist/chunk-5FU57ZVQ.js
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
var chunkTJ2EWPER_js = require('./chunk-TJ2EWPER.js');
|
|
4
|
-
var classVarianceAuthority = require('class-variance-authority');
|
|
5
|
-
|
|
6
|
-
var buttonVariants = classVarianceAuthority.cva(chunkTJ2EWPER_js.zuiButtonBase, {
|
|
7
|
-
variants: {
|
|
8
|
-
appearance: chunkTJ2EWPER_js.zuiButtonAppearances,
|
|
9
|
-
size: chunkTJ2EWPER_js.zuiButtonSizes
|
|
10
|
-
},
|
|
11
|
-
defaultVariants: {
|
|
12
|
-
appearance: "default",
|
|
13
|
-
size: "md"
|
|
14
|
-
}
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
exports.buttonVariants = buttonVariants;
|
|
18
|
-
//# sourceMappingURL=chunk-5FU57ZVQ.js.map
|
|
19
|
-
//# sourceMappingURL=chunk-5FU57ZVQ.js.map
|