@edux-design/buttons 0.1.2 → 0.1.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.
@@ -1,389 +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
-
252
- const rootClasses = cx(
253
- "inline-flex h-[44px] rounded-lg border-2 border-border-subtle bg-bg-base/90 p-2 shadow-inner",
254
- isStretched ? "w-full" : "w-auto",
255
- className
256
- );
257
-
258
- const trackClasses = cx(
259
- "relative isolate flex w-full items-center gap-8",
260
- !isStretched && "w-auto"
261
- );
262
-
263
- const buttonBaseClasses = cx(
264
- "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",
265
- sizeStyles.button,
266
- isStretched ? "flex-1" : "w-auto"
267
- );
268
-
269
- return (
270
- <div
271
- {...rest}
272
- ref={setContainerRef}
273
- role="radiogroup"
274
- className={rootClasses}
275
- >
276
- <div className={trackClasses}>
277
- <span
278
- aria-hidden="true"
279
- className={cx(
280
- "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",
281
- !indicatorVisible && "opacity-0"
282
- )}
283
- style={{
284
- width: indicator.width || undefined,
285
- height: indicator.height || undefined,
286
- transform: `translate(${indicator.x - 4}px`,
287
- }}
288
- />
289
-
290
- {normalizedOptions.map((option, index) => {
291
- const isActive = option.value === selectedValue;
292
- const handleKeyDown = (event) => {
293
- if (option.disabled) return;
294
-
295
- if (event.key === "ArrowRight" || event.key === "ArrowDown") {
296
- event.preventDefault();
297
- const nextIndex = getNextEnabledIndex(index, 1);
298
- const nextOption = normalizedOptions[nextIndex];
299
- if (nextOption && nextOption !== option) {
300
- focusOption(nextOption);
301
- handleSelect(nextOption);
302
- }
303
- } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
304
- event.preventDefault();
305
- const prevIndex = getNextEnabledIndex(index, -1);
306
- const prevOption = normalizedOptions[prevIndex];
307
- if (prevOption && prevOption !== option) {
308
- focusOption(prevOption);
309
- handleSelect(prevOption);
310
- }
311
- } else if (event.key === "Home") {
312
- event.preventDefault();
313
- const firstIndex = findFirstEnabledIndex();
314
- const firstOption =
315
- firstIndex >= 0 ? normalizedOptions[firstIndex] : undefined;
316
- if (firstOption && firstOption !== option) {
317
- focusOption(firstOption);
318
- handleSelect(firstOption);
319
- }
320
- } else if (event.key === "End") {
321
- event.preventDefault();
322
- const lastIndex = findLastEnabledIndex();
323
- const lastOption =
324
- lastIndex >= 0 ? normalizedOptions[lastIndex] : undefined;
325
- if (lastOption && lastOption !== option) {
326
- focusOption(lastOption);
327
- handleSelect(lastOption);
328
- }
329
- }
330
- };
331
-
332
- const buttonClasses = cx(
333
- buttonBaseClasses,
334
- option.disabled
335
- ? "cursor-not-allowed text-fg-inactive opacity-60"
336
- : "cursor-pointer text-fg-subtle hover:text-fg-base",
337
- isActive && "text-fg-primary-emphasis",
338
- !isActive && "hover:bg-bg-minimal-5 active:bg-bg-inactive"
339
- );
340
-
341
- const hasStartAdornment =
342
- option.startAdornment !== undefined &&
343
- option.startAdornment !== null;
344
- const endAdornment = option.endAdornment ?? option.endIcon;
345
- const hasEndAdornment =
346
- endAdornment !== undefined && endAdornment !== null;
347
-
348
- return (
349
- <button
350
- key={String(option.value)}
351
- ref={registerButtonRef(option.value)}
352
- type="button"
353
- role="radio"
354
- aria-checked={isActive}
355
- // tabIndex={isActive ? 0 : -1}
356
- disabled={option.disabled}
357
- className={buttonClasses}
358
- onClick={() => handleSelect(option)}
359
- onKeyDown={handleKeyDown}
360
- data-state={isActive ? "active" : "inactive"}
361
- >
362
- {hasStartAdornment && (
363
- <span
364
- aria-hidden="true"
365
- className="flex items-center text-current"
366
- >
367
- {option.startAdornment}
368
- </span>
369
- )}
370
-
371
- <span className="flex items-center gap-1 leading-none text-current">
372
- {option.label ?? `Option ${index + 1}`}
373
- </span>
374
-
375
- {hasEndAdornment && (
376
- <span
377
- aria-hidden="true"
378
- className="flex items-center text-current"
379
- >
380
- {endAdornment}
381
- </span>
382
- )}
383
- </button>
384
- );
385
- })}
386
- </div>
387
- </div>
388
- );
389
- });
@@ -1,69 +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("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
- });
@@ -1,8 +0,0 @@
1
- import React from "react";
2
-
3
- export function ButtonGroupItem(_props) {
4
- return null;
5
- }
6
-
7
- ButtonGroupItem.displayName = "ButtonGroupItem";
8
-
@@ -1,104 +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
- * @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 cursor-pointer";
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
- );
@@ -1,44 +0,0 @@
1
- import React from "react";
2
- import { MenuButtonContext } from "../util/MenuButtonContext";
3
- import { cx } from "@edux-design/utils";
4
- import { Chevron } from "@edux-design/icons";
5
-
6
- /**
7
- * Props for the MenuButton component.
8
- *
9
- * @typedef {Object} MenuButtonProps
10
- * @property {React.ReactNode} [children] - Content to display inside the button (usually label or icon).
11
- * @property {"small"|"medium"} [size="medium"] - Size variant controlling button height.
12
- */
13
-
14
- /**
15
- * MenuButton component.
16
- *
17
- * Provides a styled menu trigger button with optional sizing.
18
- * It uses a `MenuButtonContext` provider to share internal state
19
- * with child menu items.
20
- *
21
- * @component
22
- * @type {React.FC<MenuButtonProps>}
23
- *
24
- * @example
25
- * ```jsx
26
- * <MenuButton size="small">Options</MenuButton>
27
- * ```
28
- */
29
- export function MenuButton({ children, size }) {
30
- const SPLIT_BUTTON_STYLES = cx(
31
- "w-[36px] border-l-[1px] border-l-black flex flex-row justify-center items-center",
32
- {
33
- "h-[32px]": size === "small",
34
- "h-[40px] py-2": size === "medium" || size === undefined,
35
- }
36
- );
37
-
38
- return (
39
- <MenuButtonContext.Provider value>
40
- <div className={SPLIT_BUTTON_STYLES}>{children}</div>
41
- <Chevron />
42
- </MenuButtonContext.Provider>
43
- );
44
- }
package/src/index.js DELETED
@@ -1,6 +0,0 @@
1
- export { IconButton } from "./elements/IconButton";
2
- export { Button } from "./elements/Button";
3
- export { MenuButton } from "./elements/MenuButton";
4
- export { ButtonGroup } from "./elements/ButtonGroup";
5
- export { ButtonGroupItem } from "./elements/ButtonGroupItem";
6
- export { ButtonCounter } from "./elements/ButtonCounter";
@@ -1,3 +0,0 @@
1
- import React, { createContext, useContext } from "react";
2
-
3
- export const MenuButtonContext = createContext();
package/tsconfig.json DELETED
@@ -1,12 +0,0 @@
1
-
2
- {
3
-
4
- "compilerOptions": {
5
- "allowJs": true,
6
- "checkJs": false,
7
- "declaration": true,
8
- "emitDeclarationOnly": true,
9
- "jsx": "react-jsx",
10
- "outDir": "dist"
11
- }
12
- }