@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.
- package/CHANGELOG.md +4 -9
- package/README.md +62 -50
- package/dist/chevron-EVM4GL6D.svg +4 -0
- package/dist/chunk-M47FNL4P.mjs +10 -0
- package/dist/elements/Button.js +39 -0
- package/dist/elements/Button.mjs +6 -0
- package/dist/index.d.mts +29 -15
- package/dist/index.d.ts +29 -15
- package/dist/index.js +110 -63
- package/dist/index.mjs +88 -40
- package/package.json +4 -9
- package/src/demo/Button.stories.jsx +103 -0
- package/src/demo/ButtonCompatibility.stories.jsx +115 -0
- package/src/demo/ButtonCounter.stories.jsx +101 -0
- package/src/demo/ButtonGroup.stories.jsx +67 -0
- package/src/elements/Button.a11y.test.jsx +60 -0
- package/src/elements/Button.jsx +127 -0
- package/src/elements/Button.test.jsx +120 -0
- package/src/elements/ButtonCounter.jsx +356 -0
- package/src/elements/ButtonCounter.test.jsx +121 -0
- package/src/elements/ButtonGroup.jsx +404 -0
- package/src/elements/ButtonGroup.test.jsx +130 -0
- package/src/elements/ButtonGroupItem.jsx +8 -0
- package/src/elements/IconButton.jsx +115 -0
- package/src/elements/IconButton.test.jsx +62 -0
- package/src/elements/MenuButton.jsx +68 -0
- package/src/elements/MenuButton.test.jsx +40 -0
- package/src/elements/testA11y.js +14 -0
- package/src/index.js +6 -0
- package/src/util/MenuButtonContext.jsx +3 -0
- package/tsconfig.json +12 -0
|
@@ -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
|
+
});
|