@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edux-design/buttons",
3
- "version": "0.1.3",
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.className).toMatch(/h-control-md/);
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").className).toMatch(/h-control-sm/);
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").className).toMatch(/h-control-lg/);
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: "h-[36px] w-[36px] text-sm",
127
- display: "h-[36px] min-w-[48px] px-12 text-sm",
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: "h-[44px] w-[44px] text-base",
131
- display: "h-[44px] min-w-[56px] px-12 text-base",
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: "h-[56px] w-[56px] text-lg",
135
- display: "h-[56px] min-w-[72px] px-16 text-lg",
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={inputProps?.["aria-label"] ?? "Value"}
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
- <div className={cx(displayStyles, "select-none")}>
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
- </div>
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].className).toMatch(/h-\[44px\]/);
39
- expect(buttons[1].className).toMatch(/h-\[44px\]/);
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].className).toMatch(/h-\[36px\]/);
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].className).toMatch(/h-\[56px\]/);
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-[44px] rounded-lg border-2 border-border-subtle bg-bg-base/90 p-2 shadow-inner",
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-[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",
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-[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",
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
- // tabIndex={isActive ? 0 : -1}
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: "h-[36px] w-[36px] text-sm",
72
- medium: "h-[44px] w-[44px] text-base",
73
- large: "h-[56px] w-[56px] text-lg",
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
+ });