@edux-design/buttons 0.0.2 → 0.0.4
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/CHANGELOG.md +15 -0
- package/README.md +110 -0
- package/dist/index.d.mts +159 -0
- package/dist/index.d.ts +159 -0
- package/dist/index.js +425 -26
- package/dist/index.mjs +429 -25
- package/package.json +6 -4
- package/src/demo/Button.stories.jsx +84 -0
- package/src/demo/ButtonGroup.stories.jsx +67 -0
- package/src/elements/Button.jsx +35 -3
- package/src/elements/ButtonGroup.jsx +387 -0
- package/src/elements/ButtonGroup.test.jsx +69 -0
- package/src/elements/ButtonGroupItem.jsx +8 -0
- package/src/elements/IconButton.jsx +104 -0
- package/src/elements/MenuButton.jsx +25 -1
- package/src/index.js +3 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { ButtonGroup, ButtonGroupItem } from "./../index";
|
|
3
|
+
import { Warning } from "@edux-design/icons";
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
title: "Components/ButtonGroup",
|
|
7
|
+
component: ButtonGroup,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: "centered",
|
|
10
|
+
},
|
|
11
|
+
argTypes: {
|
|
12
|
+
size: {
|
|
13
|
+
control: { type: "select" },
|
|
14
|
+
options: ["small", "medium", "large"],
|
|
15
|
+
},
|
|
16
|
+
isStretched: { control: "boolean" },
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const Template = (args) => {
|
|
21
|
+
const [value, setValue] = useState(args.value ?? args.defaultValue ?? "one");
|
|
22
|
+
return (
|
|
23
|
+
<ButtonGroup
|
|
24
|
+
{...args}
|
|
25
|
+
value={value}
|
|
26
|
+
onChange={(nextValue) => {
|
|
27
|
+
setValue(nextValue);
|
|
28
|
+
args.onChange?.(nextValue);
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
<ButtonGroupItem value="one">Label (11)</ButtonGroupItem>
|
|
32
|
+
<ButtonGroupItem value="two">Label (11)</ButtonGroupItem>
|
|
33
|
+
<ButtonGroupItem
|
|
34
|
+
value="alert"
|
|
35
|
+
endIcon={<Warning className="h-4 w-4 text-warning-600" />}
|
|
36
|
+
>
|
|
37
|
+
Label
|
|
38
|
+
</ButtonGroupItem>
|
|
39
|
+
</ButtonGroup>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const Playground = Template.bind({});
|
|
44
|
+
Playground.args = { defaultValue: "one" };
|
|
45
|
+
|
|
46
|
+
export const WithIconCounts = () => {
|
|
47
|
+
const [value, setValue] = useState("inbox");
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="w-[360px]">
|
|
51
|
+
<ButtonGroup
|
|
52
|
+
isStretched
|
|
53
|
+
value={value}
|
|
54
|
+
onChange={(next) => setValue(next)}
|
|
55
|
+
>
|
|
56
|
+
<ButtonGroupItem value="inbox">Inbox (11)</ButtonGroupItem>
|
|
57
|
+
<ButtonGroupItem value="drafts">Drafts (11)</ButtonGroupItem>
|
|
58
|
+
<ButtonGroupItem
|
|
59
|
+
value="alerts"
|
|
60
|
+
endAdornment={<Warning className="h-3.5 w-3.5 text-warning-600" />}
|
|
61
|
+
>
|
|
62
|
+
Alerts
|
|
63
|
+
</ButtonGroupItem>
|
|
64
|
+
</ButtonGroup>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
};
|
package/src/elements/Button.jsx
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
import React, { forwardRef } from "react";
|
|
2
2
|
import { cx } from "@edux-design/utils";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Props for the Button component.
|
|
6
|
+
*
|
|
7
|
+
* Extends all native HTML `<button>` attributes, while adding
|
|
8
|
+
* design-system variants and state controls.
|
|
9
|
+
*
|
|
10
|
+
* @typedef {React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
11
|
+
* children?: React.ReactNode,
|
|
12
|
+
* size?: "small"|"medium"|"large",
|
|
13
|
+
* isSecondary?: boolean,
|
|
14
|
+
* isDanger?: boolean,
|
|
15
|
+
* isBare?: boolean,
|
|
16
|
+
* isStretched?: boolean,
|
|
17
|
+
* disabled?: boolean,
|
|
18
|
+
* className?: string
|
|
19
|
+
* }} ButtonProps
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Button component.
|
|
24
|
+
*
|
|
25
|
+
* Provides design-system styled button with support for
|
|
26
|
+
* multiple visual variants, sizing, and states.
|
|
27
|
+
*
|
|
28
|
+
* @component
|
|
29
|
+
* @type {React.ForwardRefExoticComponent<ButtonProps & React.RefAttributes<HTMLButtonElement>>}
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```jsx
|
|
33
|
+
* <Button size="small">Save</Button>
|
|
34
|
+
* <Button isSecondary>Cancel</Button>
|
|
35
|
+
* <Button isDanger disabled>Delete</Button>
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
4
38
|
export const Button = forwardRef(
|
|
5
39
|
(
|
|
6
40
|
{
|
|
@@ -22,8 +56,7 @@ export const Button = forwardRef(
|
|
|
22
56
|
if (isDanger) variant = "danger";
|
|
23
57
|
if (disabled) variant = "disabled";
|
|
24
58
|
|
|
25
|
-
// handle conflicting props - the last bool wins
|
|
26
|
-
|
|
59
|
+
// handle conflicting props - the last bool wins
|
|
27
60
|
if (process.env.NODE_ENV !== "production") {
|
|
28
61
|
const count = [isSecondary, isDanger, isBare].filter(Boolean).length;
|
|
29
62
|
if (count > 1) {
|
|
@@ -39,7 +72,6 @@ export const Button = forwardRef(
|
|
|
39
72
|
const fonts =
|
|
40
73
|
"text-bold tracking-normal hover:underline hover:underline-offset-(--underline-offset-btn) active:underline active:underline-offset-(--underline-offset-btn)";
|
|
41
74
|
|
|
42
|
-
//FIX-ME - Remove hardcoded values
|
|
43
75
|
const sizes = {
|
|
44
76
|
small: "h-[36px] py-8 px-16 rounded-lg text-sm",
|
|
45
77
|
medium: "h-[44px] py-8 px-16 rounded-lg text-base",
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { cx, filterChildrenByDisplayName } from "@edux-design/utils";
|
|
11
|
+
|
|
12
|
+
const SIZE_STYLES = {
|
|
13
|
+
small: {
|
|
14
|
+
button: "text-xs px-12 py-6 rounded-md ",
|
|
15
|
+
},
|
|
16
|
+
medium: {
|
|
17
|
+
button: "text-sm px-12 py-6 rounded-md ",
|
|
18
|
+
},
|
|
19
|
+
large: {
|
|
20
|
+
button: "text-base px-12 py-6 rounded-md",
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const isBrowser = typeof window !== "undefined";
|
|
25
|
+
const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {Object} ButtonGroupOption
|
|
29
|
+
* @property {React.ReactNode} [label] - Main label for the option.
|
|
30
|
+
* @property {React.ReactNode} [startAdornment] - Optional leading element (icon, badge, etc.).
|
|
31
|
+
* @property {React.ReactNode} [endIcon] - Optional trailing element.
|
|
32
|
+
* @property {string|number} [value] - Unique value used for selection.
|
|
33
|
+
* @property {boolean} [disabled] - Whether the option is disabled.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Segmented button group with animated active state.
|
|
38
|
+
*
|
|
39
|
+
* @typedef {React.HTMLAttributes<HTMLDivElement> & {
|
|
40
|
+
* value?: string|number,
|
|
41
|
+
* defaultValue?: string|number,
|
|
42
|
+
* onChange?: (value: string|number, option: ButtonGroupOption) => void,
|
|
43
|
+
* size?: "small"|"medium"|"large",
|
|
44
|
+
* isStretched?: boolean,
|
|
45
|
+
* }} ButtonGroupProps
|
|
46
|
+
*/
|
|
47
|
+
export const ButtonGroup = forwardRef(function ButtonGroup(
|
|
48
|
+
{
|
|
49
|
+
value,
|
|
50
|
+
defaultValue,
|
|
51
|
+
onChange,
|
|
52
|
+
size = "medium",
|
|
53
|
+
isStretched = false,
|
|
54
|
+
className,
|
|
55
|
+
children,
|
|
56
|
+
...rest
|
|
57
|
+
},
|
|
58
|
+
forwardedRef
|
|
59
|
+
) {
|
|
60
|
+
const normalizedOptions = useMemo(() => {
|
|
61
|
+
const childItems = filterChildrenByDisplayName(children, "ButtonGroupItem");
|
|
62
|
+
return childItems.map((child, index) => {
|
|
63
|
+
const props = child.props || {};
|
|
64
|
+
const hasValue = props.value !== undefined;
|
|
65
|
+
const safeValue = hasValue && props.value !== null ? props.value : index;
|
|
66
|
+
return {
|
|
67
|
+
...props,
|
|
68
|
+
label: props.label ?? props.children ?? `Option ${index + 1}`,
|
|
69
|
+
value: safeValue,
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}, [children]);
|
|
73
|
+
|
|
74
|
+
const sizeStyles = SIZE_STYLES[size] ?? SIZE_STYLES.medium;
|
|
75
|
+
|
|
76
|
+
const initialValue =
|
|
77
|
+
value ??
|
|
78
|
+
defaultValue ??
|
|
79
|
+
(normalizedOptions.length ? normalizedOptions[0].value : undefined);
|
|
80
|
+
|
|
81
|
+
const [internalValue, setInternalValue] = useState(initialValue);
|
|
82
|
+
const isControlled = value !== undefined;
|
|
83
|
+
const selectedValue = isControlled ? value : internalValue;
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!isControlled && defaultValue !== undefined) {
|
|
87
|
+
setInternalValue(defaultValue);
|
|
88
|
+
}
|
|
89
|
+
}, [defaultValue, isControlled]);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (isControlled) return;
|
|
93
|
+
if (
|
|
94
|
+
normalizedOptions.length &&
|
|
95
|
+
!normalizedOptions.some((option) => option.value === internalValue)
|
|
96
|
+
) {
|
|
97
|
+
setInternalValue(normalizedOptions[0].value);
|
|
98
|
+
}
|
|
99
|
+
}, [internalValue, isControlled, normalizedOptions]);
|
|
100
|
+
|
|
101
|
+
const buttonRefs = useRef(new Map());
|
|
102
|
+
const containerRef = useRef(null);
|
|
103
|
+
const indicatorRef = useRef({ width: 0, height: 0, x: 0, y: 0 });
|
|
104
|
+
const [indicator, setIndicator] = useState({
|
|
105
|
+
width: 0,
|
|
106
|
+
height: 0,
|
|
107
|
+
x: 0,
|
|
108
|
+
y: 0,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const setContainerRef = useCallback(
|
|
112
|
+
(node) => {
|
|
113
|
+
containerRef.current = node;
|
|
114
|
+
if (typeof forwardedRef === "function") {
|
|
115
|
+
forwardedRef(node);
|
|
116
|
+
} else if (forwardedRef) {
|
|
117
|
+
forwardedRef.current = node;
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
[forwardedRef]
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const registerButtonRef = useCallback((optionValue) => {
|
|
124
|
+
return (node) => {
|
|
125
|
+
if (!buttonRefs.current) return;
|
|
126
|
+
if (node) {
|
|
127
|
+
buttonRefs.current.set(optionValue, node);
|
|
128
|
+
} else {
|
|
129
|
+
buttonRefs.current.delete(optionValue);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
const updateIndicator = useCallback(() => {
|
|
135
|
+
const container = containerRef.current;
|
|
136
|
+
const target = buttonRefs.current.get(selectedValue);
|
|
137
|
+
|
|
138
|
+
if (!container || !target) {
|
|
139
|
+
if (indicatorRef.current.width !== 0) {
|
|
140
|
+
indicatorRef.current = { width: 0, height: 0, x: 0, y: 0 };
|
|
141
|
+
setIndicator(indicatorRef.current);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const containerRect = container.getBoundingClientRect();
|
|
147
|
+
const targetRect = target.getBoundingClientRect();
|
|
148
|
+
|
|
149
|
+
const next = {
|
|
150
|
+
width: targetRect.width,
|
|
151
|
+
height: targetRect.height,
|
|
152
|
+
x: targetRect.left - containerRect.left,
|
|
153
|
+
y: targetRect.top - containerRect.top,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const prev = indicatorRef.current;
|
|
157
|
+
if (
|
|
158
|
+
prev.width !== next.width ||
|
|
159
|
+
prev.height !== next.height ||
|
|
160
|
+
prev.x !== next.x ||
|
|
161
|
+
prev.y !== next.y
|
|
162
|
+
) {
|
|
163
|
+
indicatorRef.current = next;
|
|
164
|
+
setIndicator(next);
|
|
165
|
+
}
|
|
166
|
+
}, [selectedValue]);
|
|
167
|
+
|
|
168
|
+
useIsomorphicLayoutEffect(() => {
|
|
169
|
+
updateIndicator();
|
|
170
|
+
}, [updateIndicator, normalizedOptions.length, size]);
|
|
171
|
+
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!isBrowser) return;
|
|
174
|
+
const handleResize = () => updateIndicator();
|
|
175
|
+
window.addEventListener("resize", handleResize);
|
|
176
|
+
return () => window.removeEventListener("resize", handleResize);
|
|
177
|
+
}, [updateIndicator]);
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (!isBrowser) return;
|
|
181
|
+
const node = containerRef.current;
|
|
182
|
+
if (!node || typeof ResizeObserver === "undefined") return;
|
|
183
|
+
|
|
184
|
+
const observer = new ResizeObserver(() => {
|
|
185
|
+
updateIndicator();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
observer.observe(node);
|
|
189
|
+
return () => observer.disconnect();
|
|
190
|
+
}, [updateIndicator]);
|
|
191
|
+
|
|
192
|
+
const focusOption = useCallback((option) => {
|
|
193
|
+
const node = buttonRefs.current.get(option.value);
|
|
194
|
+
if (node) {
|
|
195
|
+
node.focus();
|
|
196
|
+
}
|
|
197
|
+
}, []);
|
|
198
|
+
|
|
199
|
+
const handleSelect = useCallback(
|
|
200
|
+
(option) => {
|
|
201
|
+
if (option.disabled || option.value === selectedValue) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!isControlled) {
|
|
206
|
+
setInternalValue(option.value);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (typeof onChange === "function") {
|
|
210
|
+
onChange(option.value, option);
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
[isControlled, onChange, selectedValue]
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const getNextEnabledIndex = useCallback(
|
|
217
|
+
(startIndex, direction) => {
|
|
218
|
+
if (!normalizedOptions.length) return -1;
|
|
219
|
+
let nextIndex = startIndex;
|
|
220
|
+
|
|
221
|
+
for (let i = 0; i < normalizedOptions.length; i += 1) {
|
|
222
|
+
nextIndex =
|
|
223
|
+
(nextIndex + direction + normalizedOptions.length) %
|
|
224
|
+
normalizedOptions.length;
|
|
225
|
+
|
|
226
|
+
if (!normalizedOptions[nextIndex].disabled) {
|
|
227
|
+
return nextIndex;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return startIndex;
|
|
232
|
+
},
|
|
233
|
+
[normalizedOptions]
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const findFirstEnabledIndex = useCallback(() => {
|
|
237
|
+
return normalizedOptions.findIndex((option) => !option.disabled);
|
|
238
|
+
}, [normalizedOptions]);
|
|
239
|
+
|
|
240
|
+
const findLastEnabledIndex = useCallback(() => {
|
|
241
|
+
for (let i = normalizedOptions.length - 1; i >= 0; i -= 1) {
|
|
242
|
+
if (!normalizedOptions[i].disabled) {
|
|
243
|
+
return i;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return -1;
|
|
247
|
+
}, [normalizedOptions]);
|
|
248
|
+
|
|
249
|
+
const indicatorVisible = indicator.width > 0 && indicator.height > 0;
|
|
250
|
+
|
|
251
|
+
const rootClasses = cx(
|
|
252
|
+
"inline-flex h-[44px] rounded-lg border-2 border-border-subtle bg-bg-base/90 p-2 shadow-inner",
|
|
253
|
+
isStretched ? "w-full" : "w-auto",
|
|
254
|
+
className
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const trackClasses = cx(
|
|
258
|
+
"relative isolate flex w-full items-center gap-8",
|
|
259
|
+
!isStretched && "w-auto"
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const buttonBaseClasses = cx(
|
|
263
|
+
"relative z-10 h-[36px] inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold tracking-tight transition-colors duration-200 ease-out focus-visible:outline-none focus-visible:shadow-focus motion-reduce:duration-0",
|
|
264
|
+
sizeStyles.button,
|
|
265
|
+
isStretched ? "flex-1" : "w-auto"
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<div
|
|
270
|
+
{...rest}
|
|
271
|
+
ref={setContainerRef}
|
|
272
|
+
role="radiogroup"
|
|
273
|
+
className={rootClasses}
|
|
274
|
+
>
|
|
275
|
+
<div className={trackClasses}>
|
|
276
|
+
<span
|
|
277
|
+
aria-hidden="true"
|
|
278
|
+
className={cx(
|
|
279
|
+
"pointer-events-none h-[44px] absolute rounded-md border-2 border-border-primary-emphasis bg-bg-base shadow-[0_8px_20px_rgb(22_92_131/0.18)] transition-[transform,width,height] duration-300 ease-out motion-reduce:duration-0",
|
|
280
|
+
!indicatorVisible && "opacity-0"
|
|
281
|
+
)}
|
|
282
|
+
style={{
|
|
283
|
+
width: indicator.width || undefined,
|
|
284
|
+
height: indicator.height || undefined,
|
|
285
|
+
transform: `translate(${indicator.x - 4}px`,
|
|
286
|
+
}}
|
|
287
|
+
/>
|
|
288
|
+
|
|
289
|
+
{normalizedOptions.map((option, index) => {
|
|
290
|
+
const isActive = option.value === selectedValue;
|
|
291
|
+
const handleKeyDown = (event) => {
|
|
292
|
+
if (option.disabled) return;
|
|
293
|
+
|
|
294
|
+
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
|
295
|
+
event.preventDefault();
|
|
296
|
+
const nextIndex = getNextEnabledIndex(index, 1);
|
|
297
|
+
const nextOption = normalizedOptions[nextIndex];
|
|
298
|
+
if (nextOption && nextOption !== option) {
|
|
299
|
+
focusOption(nextOption);
|
|
300
|
+
handleSelect(nextOption);
|
|
301
|
+
}
|
|
302
|
+
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
|
303
|
+
event.preventDefault();
|
|
304
|
+
const prevIndex = getNextEnabledIndex(index, -1);
|
|
305
|
+
const prevOption = normalizedOptions[prevIndex];
|
|
306
|
+
if (prevOption && prevOption !== option) {
|
|
307
|
+
focusOption(prevOption);
|
|
308
|
+
handleSelect(prevOption);
|
|
309
|
+
}
|
|
310
|
+
} else if (event.key === "Home") {
|
|
311
|
+
event.preventDefault();
|
|
312
|
+
const firstIndex = findFirstEnabledIndex();
|
|
313
|
+
const firstOption =
|
|
314
|
+
firstIndex >= 0 ? normalizedOptions[firstIndex] : undefined;
|
|
315
|
+
if (firstOption && firstOption !== option) {
|
|
316
|
+
focusOption(firstOption);
|
|
317
|
+
handleSelect(firstOption);
|
|
318
|
+
}
|
|
319
|
+
} else if (event.key === "End") {
|
|
320
|
+
event.preventDefault();
|
|
321
|
+
const lastIndex = findLastEnabledIndex();
|
|
322
|
+
const lastOption =
|
|
323
|
+
lastIndex >= 0 ? normalizedOptions[lastIndex] : undefined;
|
|
324
|
+
if (lastOption && lastOption !== option) {
|
|
325
|
+
focusOption(lastOption);
|
|
326
|
+
handleSelect(lastOption);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const buttonClasses = cx(
|
|
332
|
+
buttonBaseClasses,
|
|
333
|
+
option.disabled
|
|
334
|
+
? "cursor-not-allowed text-fg-inactive opacity-60"
|
|
335
|
+
: "cursor-pointer text-fg-subtle hover:text-fg-base",
|
|
336
|
+
isActive && "text-fg-primary-emphasis",
|
|
337
|
+
!isActive && "hover:bg-bg-minimal-5 active:bg-bg-inactive"
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const hasStartAdornment =
|
|
341
|
+
option.startAdornment !== undefined &&
|
|
342
|
+
option.startAdornment !== null;
|
|
343
|
+
const hasendIcon =
|
|
344
|
+
option.endIcon !== undefined && option.endIcon !== null;
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<button
|
|
348
|
+
key={String(option.value)}
|
|
349
|
+
ref={registerButtonRef(option.value)}
|
|
350
|
+
type="button"
|
|
351
|
+
role="radio"
|
|
352
|
+
aria-checked={isActive}
|
|
353
|
+
// tabIndex={isActive ? 0 : -1}
|
|
354
|
+
disabled={option.disabled}
|
|
355
|
+
className={buttonClasses}
|
|
356
|
+
onClick={() => handleSelect(option)}
|
|
357
|
+
onKeyDown={handleKeyDown}
|
|
358
|
+
data-state={isActive ? "active" : "inactive"}
|
|
359
|
+
>
|
|
360
|
+
{hasStartAdornment && (
|
|
361
|
+
<span
|
|
362
|
+
aria-hidden="true"
|
|
363
|
+
className="flex items-center text-current"
|
|
364
|
+
>
|
|
365
|
+
{option.startAdornment}
|
|
366
|
+
</span>
|
|
367
|
+
)}
|
|
368
|
+
|
|
369
|
+
<span className="flex items-center gap-1 leading-none text-current">
|
|
370
|
+
{option.label ?? `Option ${index + 1}`}
|
|
371
|
+
</span>
|
|
372
|
+
|
|
373
|
+
{hasendIcon && (
|
|
374
|
+
<span
|
|
375
|
+
aria-hidden="true"
|
|
376
|
+
className="flex items-center text-current"
|
|
377
|
+
>
|
|
378
|
+
{option.endIcon}
|
|
379
|
+
</span>
|
|
380
|
+
)}
|
|
381
|
+
</button>
|
|
382
|
+
);
|
|
383
|
+
})}
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { ButtonGroup } from "./ButtonGroup";
|
|
4
|
+
import { ButtonGroupItem } from "./ButtonGroupItem";
|
|
5
|
+
|
|
6
|
+
describe("ButtonGroup", () => {
|
|
7
|
+
it("renders provided options and honors defaultValue", () => {
|
|
8
|
+
render(
|
|
9
|
+
<ButtonGroup defaultValue="second">
|
|
10
|
+
<ButtonGroupItem value="first">Label A (11)</ButtonGroupItem>
|
|
11
|
+
<ButtonGroupItem value="second">Label B (11)</ButtonGroupItem>
|
|
12
|
+
<ButtonGroupItem value="alert" endAdornment={<span>!</span>}>
|
|
13
|
+
Label C
|
|
14
|
+
</ButtonGroupItem>
|
|
15
|
+
</ButtonGroup>
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
expect(screen.getAllByRole("radio")).toHaveLength(3);
|
|
19
|
+
|
|
20
|
+
expect(
|
|
21
|
+
screen.getByRole("radio", { name: "Label B (11)" })
|
|
22
|
+
).toHaveAttribute("aria-checked", "true");
|
|
23
|
+
|
|
24
|
+
expect(
|
|
25
|
+
screen.getByRole("radio", { name: "Label A (11)" })
|
|
26
|
+
).toHaveAttribute("aria-checked", "false");
|
|
27
|
+
|
|
28
|
+
expect(screen.getByText("!")).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("moves selection when clicking on an option", () => {
|
|
32
|
+
const handleChange = vi.fn();
|
|
33
|
+
render(
|
|
34
|
+
<ButtonGroup defaultValue="first" onChange={handleChange}>
|
|
35
|
+
<ButtonGroupItem value="first">Label A (11)</ButtonGroupItem>
|
|
36
|
+
<ButtonGroupItem value="second">Label B (11)</ButtonGroupItem>
|
|
37
|
+
<ButtonGroupItem value="alert">Label C</ButtonGroupItem>
|
|
38
|
+
</ButtonGroup>
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const second = screen.getByRole("radio", { name: "Label B (11)" });
|
|
42
|
+
|
|
43
|
+
fireEvent.click(second);
|
|
44
|
+
|
|
45
|
+
expect(handleChange).toHaveBeenCalledWith(
|
|
46
|
+
"second",
|
|
47
|
+
expect.objectContaining({ value: "second" })
|
|
48
|
+
);
|
|
49
|
+
expect(second).toHaveAttribute("aria-checked", "true");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("ignores clicks for disabled options", () => {
|
|
53
|
+
const handleChange = vi.fn();
|
|
54
|
+
render(
|
|
55
|
+
<ButtonGroup defaultValue="first" onChange={handleChange}>
|
|
56
|
+
<ButtonGroupItem value="first">First</ButtonGroupItem>
|
|
57
|
+
<ButtonGroupItem value="second" disabled>
|
|
58
|
+
Second
|
|
59
|
+
</ButtonGroupItem>
|
|
60
|
+
</ButtonGroup>
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const disabled = screen.getByRole("radio", { name: "Second" });
|
|
64
|
+
fireEvent.click(disabled);
|
|
65
|
+
|
|
66
|
+
expect(handleChange).not.toHaveBeenCalled();
|
|
67
|
+
expect(disabled).toHaveAttribute("aria-checked", "false");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import React, { forwardRef } from "react";
|
|
2
|
+
import { cx } from "@edux-design/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Props for the IconButton component.
|
|
6
|
+
*
|
|
7
|
+
* Extends all native HTML `<button>` attributes and accepts an icon prop.
|
|
8
|
+
* Designed for circular icon-only buttons that follow the same
|
|
9
|
+
* design-system patterns as the regular Button.
|
|
10
|
+
*
|
|
11
|
+
* @typedef {React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
12
|
+
* icon: React.ReactNode,
|
|
13
|
+
* size?: "small"|"medium"|"large",
|
|
14
|
+
* isSecondary?: boolean,
|
|
15
|
+
* isDanger?: boolean,
|
|
16
|
+
* isBare?: boolean,
|
|
17
|
+
* disabled?: boolean,
|
|
18
|
+
* className?: string
|
|
19
|
+
* }} IconButtonProps
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* IconButton component.
|
|
24
|
+
*
|
|
25
|
+
* Provides a circular button used for icon-only actions, matching
|
|
26
|
+
* the design-system variants and states of the main Button component.
|
|
27
|
+
*
|
|
28
|
+
* @component
|
|
29
|
+
* @type {React.ForwardRefExoticComponent<IconButtonProps & React.RefAttributes<HTMLButtonElement>>}
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```jsx
|
|
33
|
+
* <IconButton icon={<XIcon />} aria-label="Close" />
|
|
34
|
+
* <IconButton icon={<EditIcon />} isSecondary />
|
|
35
|
+
* <IconButton icon={<TrashIcon />} isDanger disabled />
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export const IconButton = forwardRef(
|
|
39
|
+
(
|
|
40
|
+
{
|
|
41
|
+
icon,
|
|
42
|
+
size = "medium",
|
|
43
|
+
isSecondary,
|
|
44
|
+
isDanger,
|
|
45
|
+
isBare,
|
|
46
|
+
disabled,
|
|
47
|
+
className,
|
|
48
|
+
...props
|
|
49
|
+
},
|
|
50
|
+
ref
|
|
51
|
+
) => {
|
|
52
|
+
let variant = "primary";
|
|
53
|
+
if (isBare) variant = "bare";
|
|
54
|
+
if (isSecondary) variant = "secondary";
|
|
55
|
+
if (isDanger) variant = "danger";
|
|
56
|
+
if (disabled) variant = "disabled";
|
|
57
|
+
|
|
58
|
+
if (process.env.NODE_ENV !== "production") {
|
|
59
|
+
const count = [isSecondary, isDanger, isBare].filter(Boolean).length;
|
|
60
|
+
if (count > 1) {
|
|
61
|
+
console.warn(
|
|
62
|
+
"[IconButton]: Multiple variant props passed. Using the highest priority only."
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const defaults =
|
|
68
|
+
"flex justify-center items-center focus:shadow-focus focus-visible:shadow-focus outline-none transition-all duration-300 ease-in-out rounded-full";
|
|
69
|
+
|
|
70
|
+
const sizes = {
|
|
71
|
+
small: "h-[36px] w-[36px] text-sm",
|
|
72
|
+
medium: "h-[44px] w-[44px] text-base",
|
|
73
|
+
large: "h-[56px] w-[56px] text-lg",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const variants = {
|
|
77
|
+
primary:
|
|
78
|
+
"border-2 border-border-primary-emphasis bg-bg-primary-base text-white hover:bg-border-focus hover:text-fg-base hover:border-border-emphasis hover:shadow-md active:bg-border-focus active:bg-bg-focus-overlay active:text-fg-base",
|
|
79
|
+
secondary:
|
|
80
|
+
"border-2 border-border-primary-base bg-bg-base text-fg-primary-emphasis hover:shadow-md hover:bg-bg-primary-subtle active:bg-bg-primary-subtle-90",
|
|
81
|
+
bare: "bg-bg-base text-fg-primary-emphasis hover:bg-bg-minimal active:bg-bg-minimal-5",
|
|
82
|
+
danger:
|
|
83
|
+
"bg-bg-danger-base border-2 border-border-danger-emphasis text-white hover:bg-bg-danger-base-5 active:bg-bg-danger-base-10",
|
|
84
|
+
disabled:
|
|
85
|
+
"border-2 border-border-subtle bg-bg-inactive text-fg-inactive hover:no-underline cursor-not-allowed",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const STYLES = cx(defaults, sizes[size], variants[variant], className);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<button
|
|
92
|
+
role="button"
|
|
93
|
+
type="button"
|
|
94
|
+
aria-disabled={disabled}
|
|
95
|
+
disabled={disabled}
|
|
96
|
+
className={STYLES}
|
|
97
|
+
ref={ref}
|
|
98
|
+
{...props}
|
|
99
|
+
>
|
|
100
|
+
{icon}
|
|
101
|
+
</button>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
);
|