@edux-design/buttons 0.0.3 → 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 +9 -0
- package/README.md +110 -0
- package/dist/index.d.mts +28 -1
- package/dist/index.d.ts +28 -1
- package/dist/index.js +328 -12
- package/dist/index.mjs +330 -8
- package/package.json +1 -1
- package/src/demo/ButtonGroup.stories.jsx +67 -0
- package/src/elements/ButtonGroup.jsx +387 -0
- package/src/elements/ButtonGroup.test.jsx +69 -0
- package/src/elements/ButtonGroupItem.jsx +8 -0
- package/src/index.js +2 -0
|
@@ -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
|
+
});
|
package/src/index.js
CHANGED