@edux-design/buttons 0.1.4 → 0.2.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.
@@ -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
+ };
@@ -0,0 +1,60 @@
1
+ import React from "react";
2
+ import { render } from "@testing-library/react";
3
+ import { Button } from "./Button";
4
+ import { IconButton } from "./IconButton";
5
+ import { MenuButton } from "./MenuButton";
6
+ import { ButtonGroup } from "./ButtonGroup";
7
+ import { ButtonGroupItem } from "./ButtonGroupItem";
8
+ import { ButtonCounter } from "./ButtonCounter";
9
+ import { expectNoAxeViolations } from "./testA11y";
10
+
11
+ describe("buttons accessibility", () => {
12
+ it("Button has no detectable accessibility violations", async () => {
13
+ const { container } = render(
14
+ <Button startIcon={<span aria-hidden="true">+</span>}>Save</Button>,
15
+ );
16
+
17
+ await expectNoAxeViolations(container);
18
+ });
19
+
20
+ it("IconButton has no detectable accessibility violations when named", async () => {
21
+ const { container } = render(
22
+ <IconButton
23
+ icon={<span aria-hidden="true">X</span>}
24
+ aria-label="Close dialog"
25
+ />,
26
+ );
27
+
28
+ await expectNoAxeViolations(container);
29
+ });
30
+
31
+ it("MenuButton has no detectable accessibility violations", async () => {
32
+ const { container } = render(
33
+ <MenuButton aria-expanded={false}>Options</MenuButton>,
34
+ );
35
+
36
+ await expectNoAxeViolations(container);
37
+ });
38
+
39
+ it("ButtonGroup has no detectable accessibility violations", async () => {
40
+ const { container } = render(
41
+ <ButtonGroup defaultValue="first">
42
+ <ButtonGroupItem value="first">First</ButtonGroupItem>
43
+ <ButtonGroupItem value="second">Second</ButtonGroupItem>
44
+ <ButtonGroupItem value="third" disabled>
45
+ Third
46
+ </ButtonGroupItem>
47
+ </ButtonGroup>,
48
+ );
49
+
50
+ await expectNoAxeViolations(container);
51
+ });
52
+
53
+ it("ButtonCounter has no detectable accessibility violations", async () => {
54
+ const { container } = render(
55
+ <ButtonCounter defaultValue={2} aria-label="Quantity" />,
56
+ );
57
+
58
+ await expectNoAxeViolations(container);
59
+ });
60
+ });
@@ -0,0 +1,127 @@
1
+ import React, { forwardRef } from "react";
2
+ import { cx } from "@edux-design/utils";
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
+ * startIcon?: React.ReactNode,
13
+ * endIcon?: React.ReactNode,
14
+ * size?: "small"|"medium"|"large",
15
+ * isSecondary?: boolean,
16
+ * isDanger?: boolean,
17
+ * isBare?: boolean,
18
+ * isStretched?: boolean,
19
+ * disabled?: boolean,
20
+ * className?: string
21
+ * }} ButtonProps
22
+ */
23
+
24
+ /**
25
+ * Button component.
26
+ *
27
+ * Provides design-system styled button with support for
28
+ * multiple visual variants, sizing, and states.
29
+ *
30
+ * @component
31
+ * @type {React.ForwardRefExoticComponent<ButtonProps & React.RefAttributes<HTMLButtonElement>>}
32
+ *
33
+ * @example
34
+ * ```jsx
35
+ * <Button size="small">Save</Button>
36
+ * <Button isSecondary>Cancel</Button>
37
+ * <Button isDanger disabled>Delete</Button>
38
+ * ```
39
+ */
40
+ export const Button = forwardRef(
41
+ (
42
+ {
43
+ children,
44
+ startIcon,
45
+ endIcon,
46
+ size = "medium",
47
+ isSecondary,
48
+ isDanger,
49
+ isBare,
50
+ isStretched,
51
+ disabled,
52
+ className,
53
+ ...props
54
+ },
55
+ ref
56
+ ) => {
57
+ let variant = "primary";
58
+ if (disabled) {
59
+ variant = "disabled";
60
+ } else if (isDanger && isBare) {
61
+ variant = "dangerBare";
62
+ } else if (isDanger) {
63
+ variant = "danger";
64
+ } else if (isSecondary) {
65
+ variant = "secondary";
66
+ } else if (isBare) {
67
+ variant = "bare";
68
+ }
69
+
70
+ // handle conflicting props - the last bool wins
71
+ if (process.env.NODE_ENV !== "production") {
72
+ const count = [isSecondary, isDanger, isBare].filter(Boolean).length;
73
+ const hasAllowedCombo = isDanger && isBare && count === 2;
74
+ if (count > 1 && !hasAllowedCombo) {
75
+ console.warn(
76
+ "[Button]: Multiple variant props passed. Using the highest priority only."
77
+ );
78
+ }
79
+ }
80
+
81
+ const defaults =
82
+ "flex justify-center items-center gap-8 focus:shadow-focus focus-visible:shadow-focus outline-none transition-all duration-300 ease-in-out cursor-pointer";
83
+
84
+ const fonts =
85
+ "text-bold tracking-normal hover:underline hover:underline-offset-(--underline-offset-btn) active:underline active:underline-offset-(--underline-offset-btn)";
86
+
87
+ const sizes = {
88
+ small: "h-control-sm py-8 px-16 rounded-lg text-sm",
89
+ medium: "h-control-md py-8 px-16 rounded-lg text-base",
90
+ large: "h-control-lg py-8 px-16 rounded-lg text-lg",
91
+ };
92
+
93
+ const width = isStretched ? "w-full" : "w-fit-content";
94
+
95
+ const variants = {
96
+ primary:
97
+ "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",
98
+ secondary:
99
+ "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",
100
+ bare: "bg-bg-base text-fg-primary-emphasis hover:bg-bg-minimal active:bg-bg-minimal-5",
101
+ dangerBare:
102
+ "bg-bg-base text-fg-danger-emphasis hover:bg-bg-danger-subtle active:bg-bg-danger-subtle",
103
+ danger:
104
+ "bg-bg-danger-base border-2 border-border-danger-emphasis text-white hover:bg-bg-danger-base-5 active:bg-bg-danger-base-10",
105
+ disabled:
106
+ "border-2 border-border-subtle bg-bg-inactive text-fg-inactive hover:no-underline",
107
+ };
108
+
109
+ const STYLES = cx(defaults, sizes[size], width, fonts, variants[variant]);
110
+
111
+ return (
112
+ <button
113
+ role="button"
114
+ type="button"
115
+ aria-disabled={disabled}
116
+ disabled={disabled}
117
+ className={STYLES}
118
+ ref={ref}
119
+ {...props}
120
+ >
121
+ {startIcon}
122
+ {children ? <span>{children}</span> : null}
123
+ {endIcon}
124
+ </button>
125
+ );
126
+ }
127
+ );
@@ -0,0 +1,120 @@
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
+ });