@edux-design/buttons 0.1.3 → 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 -3
- 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 +244 -0
- package/dist/index.d.ts +244 -0
- package/dist/index.js +850 -0
- package/dist/index.mjs +825 -0
- package/package.json +4 -3
- package/src/demo/ButtonCompatibility.stories.jsx +115 -0
- package/src/elements/Button.a11y.test.jsx +60 -0
- package/src/elements/Button.test.jsx +3 -3
- package/src/elements/ButtonCounter.jsx +24 -9
- package/src/elements/ButtonCounter.test.jsx +25 -4
- package/src/elements/ButtonGroup.jsx +20 -5
- package/src/elements/ButtonGroup.test.jsx +61 -0
- package/src/elements/IconButton.jsx +16 -5
- package/src/elements/IconButton.test.jsx +62 -0
- package/src/elements/MenuButton.jsx +45 -21
- package/src/elements/MenuButton.test.jsx +40 -0
- package/src/elements/testA11y.js +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edux-design/buttons",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"sideEffects": [
|
|
6
6
|
"**/*.css"
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
11
12
|
"import": "./dist/index.mjs",
|
|
12
|
-
"require": "./dist/index.js"
|
|
13
|
-
"types": "./dist/index.d.ts"
|
|
13
|
+
"require": "./dist/index.js"
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"@edux-design/utils": "*"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
+
"@edux-design/tokens": ">=0.2.0 <0.3.0",
|
|
35
36
|
"react": "^19.1.0",
|
|
36
37
|
"react-dom": "^19.1.0"
|
|
37
38
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
ButtonCounter,
|
|
5
|
+
ButtonGroup,
|
|
6
|
+
ButtonGroupItem,
|
|
7
|
+
IconButton,
|
|
8
|
+
MenuButton,
|
|
9
|
+
} from "./../index";
|
|
10
|
+
|
|
11
|
+
const sizes = ["small", "medium", "large"];
|
|
12
|
+
const variants = [
|
|
13
|
+
{ label: "Primary", props: {} },
|
|
14
|
+
{ label: "Secondary", props: { isSecondary: true } },
|
|
15
|
+
{ label: "Bare", props: { isBare: true } },
|
|
16
|
+
{ label: "Danger", props: { isDanger: true } },
|
|
17
|
+
{ label: "Danger bare", props: { isDanger: true, isBare: true } },
|
|
18
|
+
{ label: "Disabled", props: { disabled: true } },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function GroupExample() {
|
|
22
|
+
const [value, setValue] = useState("one");
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<ButtonGroup value={value} onChange={setValue}>
|
|
26
|
+
<ButtonGroupItem value="one">One</ButtonGroupItem>
|
|
27
|
+
<ButtonGroupItem value="two">Two</ButtonGroupItem>
|
|
28
|
+
<ButtonGroupItem value="three">Three</ButtonGroupItem>
|
|
29
|
+
</ButtonGroup>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ControlMatrix() {
|
|
34
|
+
return (
|
|
35
|
+
<div className="space-y-24">
|
|
36
|
+
<section className="space-y-12">
|
|
37
|
+
<h3 className="text-lg font-semibold text-fg-emphasis">
|
|
38
|
+
Button variants
|
|
39
|
+
</h3>
|
|
40
|
+
<div className="grid gap-12">
|
|
41
|
+
{variants.map(({ label, props }) => (
|
|
42
|
+
<div key={label} className="flex flex-wrap items-center gap-12">
|
|
43
|
+
{sizes.map((size) => (
|
|
44
|
+
<Button key={size} size={size} {...props}>
|
|
45
|
+
{label} {size}
|
|
46
|
+
</Button>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
|
|
53
|
+
<section className="space-y-12">
|
|
54
|
+
<h3 className="text-lg font-semibold text-fg-emphasis">
|
|
55
|
+
Related controls
|
|
56
|
+
</h3>
|
|
57
|
+
<div className="flex flex-wrap items-center gap-12">
|
|
58
|
+
{sizes.map((size) => (
|
|
59
|
+
<IconButton
|
|
60
|
+
key={size}
|
|
61
|
+
size={size}
|
|
62
|
+
icon={<span aria-hidden="true">×</span>}
|
|
63
|
+
aria-label={`Close ${size}`}
|
|
64
|
+
/>
|
|
65
|
+
))}
|
|
66
|
+
<MenuButton size="small" aria-expanded="false">
|
|
67
|
+
Small menu
|
|
68
|
+
</MenuButton>
|
|
69
|
+
<MenuButton size="medium" aria-expanded="false">
|
|
70
|
+
Medium menu
|
|
71
|
+
</MenuButton>
|
|
72
|
+
</div>
|
|
73
|
+
<div className="flex flex-wrap items-center gap-12">
|
|
74
|
+
{sizes.map((size) => (
|
|
75
|
+
<ButtonCounter
|
|
76
|
+
key={size}
|
|
77
|
+
size={size}
|
|
78
|
+
defaultValue={2}
|
|
79
|
+
aria-label={`${size} quantity`}
|
|
80
|
+
/>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
<GroupExample />
|
|
84
|
+
</section>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const themeCases = [
|
|
90
|
+
{ label: "Light · Blue", className: "theme--light brand--blue" },
|
|
91
|
+
{ label: "Light · Teal", className: "theme--light brand--teal" },
|
|
92
|
+
{ label: "Dark · Blue", className: "theme--dark brand--blue" },
|
|
93
|
+
{ label: "Dark · Teal", className: "theme--dark brand--teal" },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
export default {
|
|
97
|
+
title: "Components/Button/Compatibility",
|
|
98
|
+
parameters: {
|
|
99
|
+
layout: "fullscreen",
|
|
100
|
+
chromatic: { disableSnapshot: false },
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const CompatibilityMatrix = {
|
|
105
|
+
render: () => (
|
|
106
|
+
<div className="grid grid-cols-1 2xl:grid-cols-2">
|
|
107
|
+
{themeCases.map(({ label, className }) => (
|
|
108
|
+
<div key={label} className={`${className} bg-bg-base p-24`}>
|
|
109
|
+
<h2 className="mb-16 text-xl font-bold text-fg-emphasis">{label}</h2>
|
|
110
|
+
<ControlMatrix />
|
|
111
|
+
</div>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
),
|
|
115
|
+
};
|
|
@@ -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
|
+
});
|
|
@@ -11,17 +11,17 @@ describe("Button", () => {
|
|
|
11
11
|
it("applies medium size by default", () => {
|
|
12
12
|
render(<Button>Default size</Button>);
|
|
13
13
|
const btn = screen.getByRole("button");
|
|
14
|
-
expect(btn
|
|
14
|
+
expect(btn).toHaveClass("h-control-md");
|
|
15
15
|
});
|
|
16
16
|
|
|
17
17
|
it("applies small size when size='small'", () => {
|
|
18
18
|
render(<Button size="small">Small</Button>);
|
|
19
|
-
expect(screen.getByRole("button")
|
|
19
|
+
expect(screen.getByRole("button")).toHaveClass("h-control-sm");
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
it("applies large size when size='large'", () => {
|
|
23
23
|
render(<Button size="large">Large</Button>);
|
|
24
|
-
expect(screen.getByRole("button")
|
|
24
|
+
expect(screen.getByRole("button")).toHaveClass("h-control-lg");
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
it("uses stretched width when isStretched=true", () => {
|
|
@@ -2,6 +2,7 @@ import React, {
|
|
|
2
2
|
forwardRef,
|
|
3
3
|
useCallback,
|
|
4
4
|
useEffect,
|
|
5
|
+
useId,
|
|
5
6
|
useMemo,
|
|
6
7
|
useRef,
|
|
7
8
|
useState,
|
|
@@ -67,6 +68,7 @@ export const ButtonCounter = forwardRef(function ButtonCounter(
|
|
|
67
68
|
decrementButtonProps,
|
|
68
69
|
incrementButtonProps,
|
|
69
70
|
className,
|
|
71
|
+
"aria-label": ariaLabel,
|
|
70
72
|
...rest
|
|
71
73
|
},
|
|
72
74
|
ref,
|
|
@@ -123,16 +125,16 @@ export const ButtonCounter = forwardRef(function ButtonCounter(
|
|
|
123
125
|
|
|
124
126
|
const CONTROL_SIZES = {
|
|
125
127
|
small: {
|
|
126
|
-
button: "
|
|
127
|
-
display: "h-
|
|
128
|
+
button: "size-control-sm text-sm",
|
|
129
|
+
display: "h-control-sm min-w-[48px] px-12 text-sm",
|
|
128
130
|
},
|
|
129
131
|
medium: {
|
|
130
|
-
button: "
|
|
131
|
-
display: "h-
|
|
132
|
+
button: "size-control-md text-base",
|
|
133
|
+
display: "h-control-md min-w-[56px] px-12 text-base",
|
|
132
134
|
},
|
|
133
135
|
large: {
|
|
134
|
-
button: "
|
|
135
|
-
display: "h-
|
|
136
|
+
button: "size-control-lg text-lg",
|
|
137
|
+
display: "h-control-lg min-w-[72px] px-16 text-lg",
|
|
136
138
|
},
|
|
137
139
|
};
|
|
138
140
|
|
|
@@ -212,6 +214,10 @@ export const ButtonCounter = forwardRef(function ButtonCounter(
|
|
|
212
214
|
const effectiveDecrementIcon = decrementIcon ?? startIcon ?? "-";
|
|
213
215
|
const effectiveIncrementIcon = incrementIcon ?? endIcon ?? "+";
|
|
214
216
|
|
|
217
|
+
const generatedValueId = useId();
|
|
218
|
+
const valueElementId = inputProps?.id ?? generatedValueId;
|
|
219
|
+
const valueAriaLabel = inputProps?.["aria-label"] ?? ariaLabel ?? "Value";
|
|
220
|
+
|
|
215
221
|
const [draftValue, setDraftValue] = useState(String(clampedValue));
|
|
216
222
|
const [isEditing, setIsEditing] = useState(false);
|
|
217
223
|
const inputRef = useRef(null);
|
|
@@ -247,6 +253,7 @@ export const ButtonCounter = forwardRef(function ButtonCounter(
|
|
|
247
253
|
{...decrementButtonProps}
|
|
248
254
|
type="button"
|
|
249
255
|
aria-label={decrementButtonProps?.["aria-label"] ?? "Decrease"}
|
|
256
|
+
aria-controls={valueElementId}
|
|
250
257
|
aria-disabled={decrementDisabled}
|
|
251
258
|
disabled={decrementDisabled}
|
|
252
259
|
className={cx(
|
|
@@ -271,8 +278,9 @@ export const ButtonCounter = forwardRef(function ButtonCounter(
|
|
|
271
278
|
ref={inputRef}
|
|
272
279
|
type="number"
|
|
273
280
|
inputMode="numeric"
|
|
281
|
+
id={valueElementId}
|
|
274
282
|
disabled={!!disabled}
|
|
275
|
-
aria-label={
|
|
283
|
+
aria-label={valueAriaLabel}
|
|
276
284
|
className={cx(
|
|
277
285
|
displayStyles,
|
|
278
286
|
"w-[44px] text-center outline-none bg-transparent text-fg-base",
|
|
@@ -310,15 +318,22 @@ export const ButtonCounter = forwardRef(function ButtonCounter(
|
|
|
310
318
|
}}
|
|
311
319
|
/>
|
|
312
320
|
) : (
|
|
313
|
-
<
|
|
321
|
+
<output
|
|
322
|
+
id={valueElementId}
|
|
323
|
+
aria-label={valueAriaLabel}
|
|
324
|
+
aria-live="polite"
|
|
325
|
+
aria-atomic="true"
|
|
326
|
+
className={cx(displayStyles, "select-none")}
|
|
327
|
+
>
|
|
314
328
|
{children ?? clampedValue}
|
|
315
|
-
</
|
|
329
|
+
</output>
|
|
316
330
|
)}
|
|
317
331
|
|
|
318
332
|
<button
|
|
319
333
|
{...incrementButtonProps}
|
|
320
334
|
type="button"
|
|
321
335
|
aria-label={incrementButtonProps?.["aria-label"] ?? "Increase"}
|
|
336
|
+
aria-controls={valueElementId}
|
|
322
337
|
aria-disabled={incrementDisabled}
|
|
323
338
|
disabled={incrementDisabled}
|
|
324
339
|
className={cx(
|
|
@@ -35,16 +35,19 @@ describe("ButtonCounter", () => {
|
|
|
35
35
|
it("applies medium size by default", () => {
|
|
36
36
|
render(<ButtonCounter defaultValue={1} />);
|
|
37
37
|
const buttons = screen.getAllByRole("button");
|
|
38
|
-
expect(buttons[0]
|
|
39
|
-
expect(buttons[1]
|
|
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");
|
|
40
41
|
});
|
|
41
42
|
|
|
42
43
|
it("applies small and large sizes", () => {
|
|
43
44
|
const { rerender } = render(<ButtonCounter size="small" defaultValue={1} />);
|
|
44
|
-
expect(screen.getAllByRole("button")[0]
|
|
45
|
+
expect(screen.getAllByRole("button")[0]).toHaveClass("size-control-sm");
|
|
46
|
+
expect(screen.getByLabelText("Value")).toHaveClass("h-control-sm");
|
|
45
47
|
|
|
46
48
|
rerender(<ButtonCounter size="large" defaultValue={1} />);
|
|
47
|
-
expect(screen.getAllByRole("button")[0]
|
|
49
|
+
expect(screen.getAllByRole("button")[0]).toHaveClass("size-control-lg");
|
|
50
|
+
expect(screen.getByLabelText("Value")).toHaveClass("h-control-lg");
|
|
48
51
|
});
|
|
49
52
|
|
|
50
53
|
it("applies variants", () => {
|
|
@@ -97,4 +100,22 @@ describe("ButtonCounter", () => {
|
|
|
97
100
|
|
|
98
101
|
warnSpy.mockRestore();
|
|
99
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
|
+
|
|
100
121
|
});
|
|
@@ -248,9 +248,17 @@ export const ButtonGroup = forwardRef(function ButtonGroup(
|
|
|
248
248
|
}, [normalizedOptions]);
|
|
249
249
|
|
|
250
250
|
const indicatorVisible = indicator.width > 0 && indicator.height > 0;
|
|
251
|
+
const selectedIndex = normalizedOptions.findIndex(
|
|
252
|
+
(option) => option.value === selectedValue && !option.disabled
|
|
253
|
+
);
|
|
254
|
+
const firstEnabledIndex = normalizedOptions.findIndex(
|
|
255
|
+
(option) => !option.disabled
|
|
256
|
+
);
|
|
257
|
+
const activeTabStopIndex =
|
|
258
|
+
selectedIndex >= 0 ? selectedIndex : firstEnabledIndex;
|
|
251
259
|
|
|
252
260
|
const rootClasses = cx(
|
|
253
|
-
"inline-flex h-
|
|
261
|
+
"inline-flex h-control-md rounded-lg border-2 border-border-subtle bg-bg-base/90 p-2 shadow-inner",
|
|
254
262
|
isStretched ? "w-full" : "w-auto",
|
|
255
263
|
className
|
|
256
264
|
);
|
|
@@ -261,7 +269,7 @@ export const ButtonGroup = forwardRef(function ButtonGroup(
|
|
|
261
269
|
);
|
|
262
270
|
|
|
263
271
|
const buttonBaseClasses = cx(
|
|
264
|
-
"relative z-10 h-
|
|
272
|
+
"relative z-10 h-control-sm 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
273
|
sizeStyles.button,
|
|
266
274
|
isStretched ? "flex-1" : "w-auto"
|
|
267
275
|
);
|
|
@@ -271,19 +279,20 @@ export const ButtonGroup = forwardRef(function ButtonGroup(
|
|
|
271
279
|
{...rest}
|
|
272
280
|
ref={setContainerRef}
|
|
273
281
|
role="radiogroup"
|
|
282
|
+
aria-orientation="horizontal"
|
|
274
283
|
className={rootClasses}
|
|
275
284
|
>
|
|
276
285
|
<div className={trackClasses}>
|
|
277
286
|
<span
|
|
278
287
|
aria-hidden="true"
|
|
279
288
|
className={cx(
|
|
280
|
-
"pointer-events-none h-
|
|
289
|
+
"pointer-events-none h-control-md 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
290
|
!indicatorVisible && "opacity-0"
|
|
282
291
|
)}
|
|
283
292
|
style={{
|
|
284
293
|
width: indicator.width || undefined,
|
|
285
294
|
height: indicator.height || undefined,
|
|
286
|
-
transform: `translate(${indicator.x - 4}px`,
|
|
295
|
+
transform: `translate(${indicator.x - 4}px, ${indicator.y - 4}px)`,
|
|
287
296
|
}}
|
|
288
297
|
/>
|
|
289
298
|
|
|
@@ -352,7 +361,13 @@ export const ButtonGroup = forwardRef(function ButtonGroup(
|
|
|
352
361
|
type="button"
|
|
353
362
|
role="radio"
|
|
354
363
|
aria-checked={isActive}
|
|
355
|
-
|
|
364
|
+
tabIndex={
|
|
365
|
+
option.disabled
|
|
366
|
+
? -1
|
|
367
|
+
: index === activeTabStopIndex
|
|
368
|
+
? 0
|
|
369
|
+
: -1
|
|
370
|
+
}
|
|
356
371
|
disabled={option.disabled}
|
|
357
372
|
className={buttonClasses}
|
|
358
373
|
onClick={() => handleSelect(option)}
|
|
@@ -4,6 +4,21 @@ import { ButtonGroup } from "./ButtonGroup";
|
|
|
4
4
|
import { ButtonGroupItem } from "./ButtonGroupItem";
|
|
5
5
|
|
|
6
6
|
describe("ButtonGroup", () => {
|
|
7
|
+
it("uses the shared control size contract", () => {
|
|
8
|
+
const { container } = render(
|
|
9
|
+
<ButtonGroup defaultValue="first">
|
|
10
|
+
<ButtonGroupItem value="first">First</ButtonGroupItem>
|
|
11
|
+
<ButtonGroupItem value="second">Second</ButtonGroupItem>
|
|
12
|
+
</ButtonGroup>,
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
expect(screen.getByRole("radiogroup")).toHaveClass("h-control-md");
|
|
16
|
+
expect(screen.getAllByRole("radio")[0]).toHaveClass("h-control-sm");
|
|
17
|
+
expect(container.querySelector('[aria-hidden="true"]')).toHaveClass(
|
|
18
|
+
"h-control-md",
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
7
22
|
it("renders provided options and honors defaultValue", () => {
|
|
8
23
|
render(
|
|
9
24
|
<ButtonGroup defaultValue="second">
|
|
@@ -66,4 +81,50 @@ describe("ButtonGroup", () => {
|
|
|
66
81
|
expect(handleChange).not.toHaveBeenCalled();
|
|
67
82
|
expect(disabled).toHaveAttribute("aria-checked", "false");
|
|
68
83
|
});
|
|
84
|
+
|
|
85
|
+
it("uses roving tabindex for keyboard navigation", () => {
|
|
86
|
+
render(
|
|
87
|
+
<ButtonGroup defaultValue="second">
|
|
88
|
+
<ButtonGroupItem value="first">First</ButtonGroupItem>
|
|
89
|
+
<ButtonGroupItem value="second">Second</ButtonGroupItem>
|
|
90
|
+
<ButtonGroupItem value="third">Third</ButtonGroupItem>
|
|
91
|
+
</ButtonGroup>,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(screen.getByRole("radio", { name: "Second" })).toHaveAttribute(
|
|
95
|
+
"tabindex",
|
|
96
|
+
"0",
|
|
97
|
+
);
|
|
98
|
+
expect(screen.getByRole("radio", { name: "First" })).toHaveAttribute(
|
|
99
|
+
"tabindex",
|
|
100
|
+
"-1",
|
|
101
|
+
);
|
|
102
|
+
expect(screen.getByRole("radio", { name: "Third" })).toHaveAttribute(
|
|
103
|
+
"tabindex",
|
|
104
|
+
"-1",
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("moves focus and selection with arrow keys", () => {
|
|
109
|
+
const handleChange = vi.fn();
|
|
110
|
+
render(
|
|
111
|
+
<ButtonGroup defaultValue="first" onChange={handleChange}>
|
|
112
|
+
<ButtonGroupItem value="first">First</ButtonGroupItem>
|
|
113
|
+
<ButtonGroupItem value="second">Second</ButtonGroupItem>
|
|
114
|
+
<ButtonGroupItem value="third">Third</ButtonGroupItem>
|
|
115
|
+
</ButtonGroup>,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const first = screen.getByRole("radio", { name: "First" });
|
|
119
|
+
fireEvent.keyDown(first, { key: "ArrowRight" });
|
|
120
|
+
|
|
121
|
+
const second = screen.getByRole("radio", { name: "Second" });
|
|
122
|
+
expect(second).toHaveFocus();
|
|
123
|
+
expect(second).toHaveAttribute("aria-checked", "true");
|
|
124
|
+
expect(handleChange).toHaveBeenCalledWith(
|
|
125
|
+
"second",
|
|
126
|
+
expect.objectContaining({ value: "second" }),
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
69
130
|
});
|
|
@@ -8,6 +8,8 @@ import { cx } from "@edux-design/utils";
|
|
|
8
8
|
* Designed for circular icon-only buttons that follow the same
|
|
9
9
|
* design-system patterns as the regular Button.
|
|
10
10
|
*
|
|
11
|
+
* Provide an accessible name with `aria-label` or `aria-labelledby`.
|
|
12
|
+
*
|
|
11
13
|
* @typedef {React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
12
14
|
* icon: React.ReactNode,
|
|
13
15
|
* size?: "small"|"medium"|"large",
|
|
@@ -31,8 +33,8 @@ import { cx } from "@edux-design/utils";
|
|
|
31
33
|
* @example
|
|
32
34
|
* ```jsx
|
|
33
35
|
* <IconButton icon={<XIcon />} aria-label="Close" />
|
|
34
|
-
* <IconButton icon={<EditIcon />} isSecondary />
|
|
35
|
-
* <IconButton icon={<TrashIcon />} isDanger disabled />
|
|
36
|
+
* <IconButton icon={<EditIcon />} isSecondary aria-label="Edit" />
|
|
37
|
+
* <IconButton icon={<TrashIcon />} isDanger disabled aria-label="Delete" />
|
|
36
38
|
* ```
|
|
37
39
|
*/
|
|
38
40
|
export const IconButton = forwardRef(
|
|
@@ -62,15 +64,24 @@ export const IconButton = forwardRef(
|
|
|
62
64
|
"[IconButton]: Multiple variant props passed. Using the highest priority only."
|
|
63
65
|
);
|
|
64
66
|
}
|
|
67
|
+
|
|
68
|
+
const hasAccessibleName = Boolean(
|
|
69
|
+
props["aria-label"] || props["aria-labelledby"]
|
|
70
|
+
);
|
|
71
|
+
if (!hasAccessibleName) {
|
|
72
|
+
console.warn(
|
|
73
|
+
"[IconButton]: Provide an accessible name with aria-label or aria-labelledby."
|
|
74
|
+
);
|
|
75
|
+
}
|
|
65
76
|
}
|
|
66
77
|
|
|
67
78
|
const defaults =
|
|
68
79
|
"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
80
|
|
|
70
81
|
const sizes = {
|
|
71
|
-
small: "
|
|
72
|
-
medium: "
|
|
73
|
-
large: "
|
|
82
|
+
small: "size-control-sm text-sm",
|
|
83
|
+
medium: "size-control-md text-base",
|
|
84
|
+
large: "size-control-lg text-lg",
|
|
74
85
|
};
|
|
75
86
|
|
|
76
87
|
const variants = {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { IconButton } from "./IconButton";
|
|
4
|
+
|
|
5
|
+
describe("IconButton", () => {
|
|
6
|
+
it("renders an icon-only button with an accessible name", () => {
|
|
7
|
+
render(
|
|
8
|
+
<IconButton icon={<span aria-hidden="true">X</span>} aria-label="Close" />,
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("warns in dev mode when no accessible name is provided", () => {
|
|
15
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
16
|
+
process.env.NODE_ENV = "development";
|
|
17
|
+
|
|
18
|
+
render(<IconButton icon={<span aria-hidden="true">X</span>} />);
|
|
19
|
+
|
|
20
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
21
|
+
"[IconButton]: Provide an accessible name with aria-label or aria-labelledby.",
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
warnSpy.mockRestore();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("does not warn when aria-labelledby is provided", () => {
|
|
28
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
29
|
+
process.env.NODE_ENV = "development";
|
|
30
|
+
|
|
31
|
+
render(
|
|
32
|
+
<>
|
|
33
|
+
<span id="icon-label">Close</span>
|
|
34
|
+
<IconButton icon={<span aria-hidden="true">X</span>} aria-labelledby="icon-label" />
|
|
35
|
+
</>,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
expect(warnSpy).not.toHaveBeenCalledWith(
|
|
39
|
+
"[IconButton]: Provide an accessible name with aria-label or aria-labelledby.",
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
warnSpy.mockRestore();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it.each([
|
|
46
|
+
["small", "size-control-sm"],
|
|
47
|
+
["medium", "size-control-md"],
|
|
48
|
+
["large", "size-control-lg"],
|
|
49
|
+
])("maps the %s size to the shared control token", (size, className) => {
|
|
50
|
+
render(
|
|
51
|
+
<IconButton
|
|
52
|
+
icon={<span aria-hidden="true">X</span>}
|
|
53
|
+
aria-label="Close"
|
|
54
|
+
size={size}
|
|
55
|
+
/>,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(screen.getByRole("button", { name: "Close" })).toHaveClass(
|
|
59
|
+
className,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|