@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.
@@ -1,120 +0,0 @@
1
- import React from "react";
2
- import { render, screen } from "@testing-library/react";
3
- import { Button } from "./Button";
4
-
5
- describe("Button", () => {
6
- it("renders children", () => {
7
- render(<Button>Click me</Button>);
8
- expect(screen.getByRole("button")).toHaveTextContent("Click me");
9
- });
10
-
11
- it("applies medium size by default", () => {
12
- render(<Button>Default size</Button>);
13
- const btn = screen.getByRole("button");
14
- expect(btn).toHaveClass("h-control-md");
15
- });
16
-
17
- it("applies small size when size='small'", () => {
18
- render(<Button size="small">Small</Button>);
19
- expect(screen.getByRole("button")).toHaveClass("h-control-sm");
20
- });
21
-
22
- it("applies large size when size='large'", () => {
23
- render(<Button size="large">Large</Button>);
24
- expect(screen.getByRole("button")).toHaveClass("h-control-lg");
25
- });
26
-
27
- it("uses stretched width when isStretched=true", () => {
28
- render(<Button isStretched>Stretch</Button>);
29
- expect(screen.getByRole("button").className).toMatch(/w-full/);
30
- });
31
-
32
- it("applies primary variant by default", () => {
33
- render(<Button>Primary</Button>);
34
- expect(screen.getByRole("button").className).toMatch(/bg-bg-primary-base/);
35
- });
36
-
37
- it("applies secondary variant when isSecondary=true", () => {
38
- render(<Button isSecondary>Secondary</Button>);
39
- expect(screen.getByRole("button").className).toMatch(/bg-bg-base/);
40
- });
41
-
42
- it("applies bare variant when isBare=true", () => {
43
- render(<Button isBare>Bare</Button>);
44
- expect(screen.getByRole("button").className).toMatch(/bg-bg-base/);
45
- expect(screen.getByRole("button").className).toMatch(
46
- /text-fg-primary-emphasis/
47
- );
48
- });
49
-
50
- it("applies danger variant when isDanger=true", () => {
51
- render(<Button isDanger>Danger</Button>);
52
- expect(screen.getByRole("button").className).toMatch(/bg-bg-danger-base/);
53
- });
54
-
55
- it("applies danger bare variant when isDanger and isBare", () => {
56
- render(
57
- <Button isDanger isBare>
58
- Danger Bare
59
- </Button>
60
- );
61
- const btn = screen.getByRole("button");
62
- expect(btn.className).toMatch(/text-fg-danger-emphasis/);
63
- expect(btn.className).toMatch(/bg-bg-base/);
64
- expect(btn.className).not.toMatch(/bg-bg-danger-base/);
65
- });
66
-
67
- it("applies disabled variant when disabled=true", () => {
68
- render(<Button disabled>Disabled</Button>);
69
- const btn = screen.getByRole("button");
70
- expect(btn).toBeDisabled();
71
- expect(btn).toHaveAttribute("aria-disabled", "true");
72
- expect(btn.className).toMatch(/bg-bg-inactive/);
73
- });
74
-
75
- it("logs a warning in dev mode if multiple variants are passed", () => {
76
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
77
- process.env.NODE_ENV = "development";
78
-
79
- render(
80
- <Button isSecondary isDanger>
81
- Conflict
82
- </Button>
83
- );
84
-
85
- expect(warnSpy).toHaveBeenCalledWith(
86
- "[Button]: Multiple variant props passed. Using the highest priority only."
87
- );
88
-
89
- warnSpy.mockRestore();
90
- });
91
-
92
- it("renders start and end icons when provided", () => {
93
- render(
94
- <Button
95
- startIcon={<span data-testid="start-icon">S</span>}
96
- endIcon={<span data-testid="end-icon">E</span>}
97
- >
98
- With icons
99
- </Button>
100
- );
101
-
102
- expect(screen.getByTestId("start-icon")).toBeInTheDocument();
103
- expect(screen.getByTestId("end-icon")).toBeInTheDocument();
104
- });
105
-
106
- it("does not warn for the danger bare combination", () => {
107
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
108
- process.env.NODE_ENV = "development";
109
-
110
- render(
111
- <Button isDanger isBare>
112
- Allowed
113
- </Button>
114
- );
115
-
116
- expect(warnSpy).not.toHaveBeenCalled();
117
-
118
- warnSpy.mockRestore();
119
- });
120
- });
@@ -1,356 +0,0 @@
1
- import React, {
2
- forwardRef,
3
- useCallback,
4
- useEffect,
5
- useId,
6
- useMemo,
7
- useRef,
8
- useState,
9
- } from "react";
10
- import { cx } from "@edux-design/utils";
11
-
12
- /**
13
- * ButtonCounter (stepper) component.
14
- *
15
- * Renders decrement/increment buttons with a central value display.
16
- * Supports the same size + variant flags as the main Button.
17
- *
18
- * @typedef {React.HTMLAttributes<HTMLDivElement> & {
19
- * value?: number,
20
- * defaultValue?: number,
21
- * onChange?: (value: number) => void,
22
- * min?: number,
23
- * max?: number,
24
- * step?: number,
25
- * children?: React.ReactNode,
26
- * size?: "small"|"medium"|"large",
27
- * isSecondary?: boolean,
28
- * isDanger?: boolean,
29
- * isBare?: boolean,
30
- * isStretched?: boolean,
31
- * disabled?: boolean,
32
- * isEditable?: boolean,
33
- * inputProps?: Omit<React.InputHTMLAttributes<HTMLInputElement>, "value"|"defaultValue"|"onChange"|"type">,
34
- * decrementIcon?: React.ReactNode,
35
- * incrementIcon?: React.ReactNode,
36
- * startIcon?: React.ReactNode,
37
- * endIcon?: React.ReactNode,
38
- * decrementButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>,
39
- * incrementButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>,
40
- * className?: string,
41
- * }} ButtonCounterProps
42
- */
43
-
44
- /**
45
- * @type {React.ForwardRefExoticComponent<ButtonCounterProps & React.RefAttributes<HTMLDivElement>>}
46
- */
47
- export const ButtonCounter = forwardRef(function ButtonCounter(
48
- {
49
- value,
50
- defaultValue = 1,
51
- onChange,
52
- min = Number.NEGATIVE_INFINITY,
53
- max = Number.POSITIVE_INFINITY,
54
- step = 1,
55
- children,
56
- size = "medium",
57
- isSecondary,
58
- isDanger,
59
- isBare,
60
- isStretched,
61
- disabled,
62
- isEditable = false,
63
- inputProps,
64
- decrementIcon,
65
- incrementIcon,
66
- startIcon,
67
- endIcon,
68
- decrementButtonProps,
69
- incrementButtonProps,
70
- className,
71
- "aria-label": ariaLabel,
72
- ...rest
73
- },
74
- ref,
75
- ) {
76
- const isControlled = value !== undefined;
77
- const [internalValue, setInternalValue] = useState(defaultValue);
78
- const currentValue = isControlled ? value : internalValue;
79
-
80
- const clampedValue = useMemo(() => {
81
- const safe = Number.isFinite(currentValue) ? currentValue : defaultValue;
82
- return Math.min(max, Math.max(min, safe));
83
- }, [currentValue, defaultValue, max, min]);
84
-
85
- const commitValue = useCallback(
86
- (next) => {
87
- const clamped = Math.min(max, Math.max(min, next));
88
- if (!isControlled) setInternalValue(clamped);
89
- onChange?.(clamped);
90
- },
91
- [isControlled, max, min, onChange],
92
- );
93
-
94
- const decrementDisabled =
95
- !!disabled || !Number.isFinite(clampedValue) || clampedValue - step < min;
96
- const incrementDisabled =
97
- !!disabled || !Number.isFinite(clampedValue) || clampedValue + step > max;
98
-
99
- let variant = "primary";
100
- if (disabled) {
101
- variant = "disabled";
102
- } else if (isDanger && isBare) {
103
- variant = "dangerBare";
104
- } else if (isDanger) {
105
- variant = "danger";
106
- } else if (isSecondary) {
107
- variant = "secondary";
108
- } else if (isBare) {
109
- variant = "bare";
110
- }
111
-
112
- if (process.env.NODE_ENV !== "production") {
113
- const count = [isSecondary, isDanger, isBare].filter(Boolean).length;
114
- const hasAllowedCombo = isDanger && isBare && count === 2;
115
- if (count > 1 && !hasAllowedCombo) {
116
- console.warn(
117
- "[ButtonCounter]: Multiple variant props passed. Using the highest priority only.",
118
- );
119
- }
120
-
121
- if (step <= 0) {
122
- console.warn("[ButtonCounter]: step should be > 0.");
123
- }
124
- }
125
-
126
- const CONTROL_SIZES = {
127
- small: {
128
- button: "size-control-sm text-sm",
129
- display: "h-control-sm min-w-[48px] px-12 text-sm",
130
- },
131
- medium: {
132
- button: "size-control-md text-base",
133
- display: "h-control-md min-w-[56px] px-12 text-base",
134
- },
135
- large: {
136
- button: "size-control-lg text-lg",
137
- display: "h-control-lg min-w-[72px] px-16 text-lg",
138
- },
139
- };
140
-
141
- const defaultsButton =
142
- "flex justify-center items-center focus:shadow-focus focus-visible:shadow-focus outline-none transition-all duration-300 ease-in-out cursor-pointer";
143
-
144
- const defaultsDisplay =
145
- "flex items-center justify-center text-bold tracking-normal";
146
-
147
- const width = isStretched ? "w-full" : "w-fit-content";
148
-
149
- const variants = {
150
- primary: {
151
- button:
152
- "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",
153
- display:
154
- "border-y-2 border-x-2 border-border-primary-emphasis bg-bg-primary-base text-white",
155
- },
156
- secondary: {
157
- button:
158
- "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",
159
- display:
160
- "border-y-2 border-x-2 border-border-primary-base bg-bg-base text-fg-primary-emphasis",
161
- },
162
- bare: {
163
- button:
164
- "bg-bg-base text-fg-primary-emphasis hover:bg-bg-minimal active:bg-bg-minimal-5",
165
- display: "bg-bg-base text-fg-primary-emphasis",
166
- },
167
- dangerBare: {
168
- button:
169
- "bg-bg-base text-fg-danger-emphasis hover:bg-bg-danger-subtle active:bg-bg-danger-subtle",
170
- display: "bg-bg-base text-fg-danger-emphasis",
171
- },
172
- danger: {
173
- button:
174
- "bg-bg-danger-base border-2 border-border-danger-emphasis text-white hover:bg-bg-danger-base-5 active:bg-bg-danger-base-10",
175
- display:
176
- "border-y-2 border-x-2 border-border-danger-emphasis bg-bg-danger-base text-white",
177
- },
178
- disabled: {
179
- button:
180
- "border-2 border-border-subtle bg-bg-inactive text-fg-inactive hover:no-underline cursor-not-allowed",
181
- display:
182
- "border-y-2 border-x-2 border-border-subtle bg-bg-inactive text-fg-inactive",
183
- },
184
- };
185
-
186
- const sizeStyles = CONTROL_SIZES[size] ?? CONTROL_SIZES.medium;
187
- const variantStyles = variants[variant] ?? variants.primary;
188
-
189
- const leftButtonStyles = cx(
190
- defaultsButton,
191
- sizeStyles.button,
192
- "rounded-l-lg",
193
- "relative z-10",
194
- variantStyles.button,
195
- variant !== "bare" && variant !== "dangerBare" ? "border-r-0" : null,
196
- );
197
-
198
- const rightButtonStyles = cx(
199
- defaultsButton,
200
- sizeStyles.button,
201
- "rounded-r-lg",
202
- variantStyles.button,
203
- variant !== "bare" && variant !== "dangerBare" ? "border-l-0" : null,
204
- );
205
-
206
- const displayStyles = cx(
207
- defaultsDisplay,
208
- sizeStyles.display,
209
- "relative z-0",
210
- variantStyles.display,
211
- isStretched ? "flex-1" : null,
212
- );
213
-
214
- const effectiveDecrementIcon = decrementIcon ?? startIcon ?? "-";
215
- const effectiveIncrementIcon = incrementIcon ?? endIcon ?? "+";
216
-
217
- const generatedValueId = useId();
218
- const valueElementId = inputProps?.id ?? generatedValueId;
219
- const valueAriaLabel = inputProps?.["aria-label"] ?? ariaLabel ?? "Value";
220
-
221
- const [draftValue, setDraftValue] = useState(String(clampedValue));
222
- const [isEditing, setIsEditing] = useState(false);
223
- const inputRef = useRef(null);
224
-
225
- useEffect(() => {
226
- if (isEditing) return;
227
- setDraftValue(String(clampedValue));
228
- }, [clampedValue, isEditing]);
229
-
230
- const commitDraft = useCallback(() => {
231
- const trimmed = String(draftValue).trim();
232
- if (trimmed === "") {
233
- setDraftValue(String(clampedValue));
234
- return;
235
- }
236
-
237
- const next = Number(trimmed);
238
- if (!Number.isFinite(next)) {
239
- setDraftValue(String(clampedValue));
240
- return;
241
- }
242
-
243
- commitValue(next);
244
- }, [clampedValue, commitValue, draftValue]);
245
-
246
- return (
247
- <div
248
- ref={ref}
249
- className={cx("inline-flex items-stretch", width, className)}
250
- {...rest}
251
- >
252
- <button
253
- {...decrementButtonProps}
254
- type="button"
255
- aria-label={decrementButtonProps?.["aria-label"] ?? "Decrease"}
256
- aria-controls={valueElementId}
257
- aria-disabled={decrementDisabled}
258
- disabled={decrementDisabled}
259
- className={cx(
260
- leftButtonStyles,
261
- decrementDisabled && variant !== "disabled"
262
- ? variants.disabled.button
263
- : null,
264
- decrementButtonProps?.className,
265
- )}
266
- onClick={(event) => {
267
- decrementButtonProps?.onClick?.(event);
268
- if (event.defaultPrevented) return;
269
- commitValue(clampedValue - step);
270
- }}
271
- >
272
- {effectiveDecrementIcon}
273
- </button>
274
-
275
- {isEditable && children == null ? (
276
- <input
277
- {...inputProps}
278
- ref={inputRef}
279
- type="number"
280
- inputMode="numeric"
281
- id={valueElementId}
282
- disabled={!!disabled}
283
- aria-label={valueAriaLabel}
284
- className={cx(
285
- displayStyles,
286
- "w-[44px] text-center outline-none bg-transparent text-fg-base",
287
- "[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-inner-spin-button]:m-0",
288
- "focus:shadow-focus focus-visible:shadow-focus focus:z-15 focus-within:z-15",
289
- inputProps?.className,
290
- )}
291
- value={draftValue}
292
- onChange={(event) => {
293
- inputProps?.onChange?.(event);
294
- if (event.defaultPrevented) return;
295
- setDraftValue(event.target.value);
296
- }}
297
- onFocus={(event) => {
298
- inputProps?.onFocus?.(event);
299
- if (event.defaultPrevented) return;
300
- setIsEditing(true);
301
- }}
302
- onBlur={(event) => {
303
- inputProps?.onBlur?.(event);
304
- if (event.defaultPrevented) return;
305
- setIsEditing(false);
306
- commitDraft();
307
- }}
308
- onKeyDown={(event) => {
309
- inputProps?.onKeyDown?.(event);
310
- if (event.defaultPrevented) return;
311
- if (event.key === "Enter") {
312
- event.currentTarget.blur();
313
- }
314
- if (event.key === "Escape") {
315
- setDraftValue(String(clampedValue));
316
- event.currentTarget.blur();
317
- }
318
- }}
319
- />
320
- ) : (
321
- <output
322
- id={valueElementId}
323
- aria-label={valueAriaLabel}
324
- aria-live="polite"
325
- aria-atomic="true"
326
- className={cx(displayStyles, "select-none")}
327
- >
328
- {children ?? clampedValue}
329
- </output>
330
- )}
331
-
332
- <button
333
- {...incrementButtonProps}
334
- type="button"
335
- aria-label={incrementButtonProps?.["aria-label"] ?? "Increase"}
336
- aria-controls={valueElementId}
337
- aria-disabled={incrementDisabled}
338
- disabled={incrementDisabled}
339
- className={cx(
340
- rightButtonStyles,
341
- incrementDisabled && variant !== "disabled"
342
- ? variants.disabled.button
343
- : null,
344
- incrementButtonProps?.className,
345
- )}
346
- onClick={(event) => {
347
- incrementButtonProps?.onClick?.(event);
348
- if (event.defaultPrevented) return;
349
- commitValue(clampedValue + step);
350
- }}
351
- >
352
- {effectiveIncrementIcon}
353
- </button>
354
- </div>
355
- );
356
- });
@@ -1,121 +0,0 @@
1
- import React from "react";
2
- import { fireEvent, render, screen } from "@testing-library/react";
3
- import { ButtonCounter } from "./ButtonCounter";
4
-
5
- describe("ButtonCounter", () => {
6
- it("renders defaultValue", () => {
7
- render(<ButtonCounter defaultValue={5} />);
8
- expect(screen.getByText("5")).toBeInTheDocument();
9
- });
10
-
11
- it("increments and decrements", () => {
12
- render(<ButtonCounter defaultValue={1} />);
13
-
14
- fireEvent.click(screen.getByRole("button", { name: "Increase" }));
15
- expect(screen.getByText("2")).toBeInTheDocument();
16
-
17
- fireEvent.click(screen.getByRole("button", { name: "Decrease" }));
18
- expect(screen.getByText("1")).toBeInTheDocument();
19
- });
20
-
21
- it("renders an editable input when isEditable=true", () => {
22
- const onChange = vi.fn();
23
- render(<ButtonCounter defaultValue={3} isEditable onChange={onChange} />);
24
-
25
- const input = screen.getByRole("spinbutton", { name: "Value" });
26
- expect(input).toHaveValue(3);
27
-
28
- fireEvent.change(input, { target: { value: "7" } });
29
- fireEvent.blur(input);
30
-
31
- expect(onChange).toHaveBeenCalledWith(7);
32
- expect(screen.getByRole("spinbutton", { name: "Value" })).toHaveValue(7);
33
- });
34
-
35
- it("applies medium size by default", () => {
36
- render(<ButtonCounter defaultValue={1} />);
37
- const buttons = screen.getAllByRole("button");
38
- expect(buttons[0]).toHaveClass("size-control-md");
39
- expect(buttons[1]).toHaveClass("size-control-md");
40
- expect(screen.getByLabelText("Value")).toHaveClass("h-control-md");
41
- });
42
-
43
- it("applies small and large sizes", () => {
44
- const { rerender } = render(<ButtonCounter size="small" defaultValue={1} />);
45
- expect(screen.getAllByRole("button")[0]).toHaveClass("size-control-sm");
46
- expect(screen.getByLabelText("Value")).toHaveClass("h-control-sm");
47
-
48
- rerender(<ButtonCounter size="large" defaultValue={1} />);
49
- expect(screen.getAllByRole("button")[0]).toHaveClass("size-control-lg");
50
- expect(screen.getByLabelText("Value")).toHaveClass("h-control-lg");
51
- });
52
-
53
- it("applies variants", () => {
54
- const { rerender } = render(<ButtonCounter defaultValue={1} />);
55
- expect(screen.getAllByRole("button")[0].className).toMatch(/bg-bg-primary-base/);
56
-
57
- rerender(<ButtonCounter isSecondary defaultValue={1} />);
58
- expect(screen.getAllByRole("button")[0].className).toMatch(/bg-bg-base/);
59
-
60
- rerender(<ButtonCounter isDanger defaultValue={1} />);
61
- expect(screen.getAllByRole("button")[0].className).toMatch(/bg-bg-danger-base/);
62
-
63
- rerender(<ButtonCounter isDanger isBare defaultValue={1} />);
64
- expect(screen.getAllByRole("button")[0].className).toMatch(/text-fg-danger-emphasis/);
65
- });
66
-
67
- it("applies disabled state", () => {
68
- render(<ButtonCounter disabled defaultValue={1} />);
69
- const buttons = screen.getAllByRole("button");
70
- expect(buttons[0]).toBeDisabled();
71
- expect(buttons[1]).toBeDisabled();
72
- expect(buttons[0].className).toMatch(/bg-bg-inactive/);
73
- });
74
-
75
- it("uses stretched width when isStretched=true", () => {
76
- const { container } = render(<ButtonCounter isStretched defaultValue={1} />);
77
- expect(container.firstChild.className).toMatch(/w-full/);
78
- });
79
-
80
- it("logs a warning in dev mode if multiple variants are passed", () => {
81
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
82
- process.env.NODE_ENV = "development";
83
-
84
- render(<ButtonCounter isSecondary isDanger defaultValue={1} />);
85
-
86
- expect(warnSpy).toHaveBeenCalledWith(
87
- "[ButtonCounter]: Multiple variant props passed. Using the highest priority only."
88
- );
89
-
90
- warnSpy.mockRestore();
91
- });
92
-
93
- it("does not warn for the danger bare combination", () => {
94
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
95
- process.env.NODE_ENV = "development";
96
-
97
- render(<ButtonCounter isDanger isBare defaultValue={1} />);
98
-
99
- expect(warnSpy).not.toHaveBeenCalled();
100
-
101
- warnSpy.mockRestore();
102
- });
103
-
104
- it("exposes the non-editable value through an output element", () => {
105
- render(<ButtonCounter defaultValue={5} />);
106
-
107
- expect(screen.getByLabelText("Value")).toHaveTextContent("5");
108
- expect(screen.getByRole("button", { name: "Increase" })).toHaveAttribute(
109
- "aria-controls",
110
- screen.getByLabelText("Value").getAttribute("id"),
111
- );
112
- });
113
-
114
-
115
- it("uses a top-level aria-label for the exposed value when provided", () => {
116
- render(<ButtonCounter defaultValue={2} aria-label="Quantity" />);
117
-
118
- expect(screen.getByLabelText("Quantity")).toHaveTextContent("2");
119
- });
120
-
121
- });