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