@fpkit/acss 6.2.0 → 6.4.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.
Files changed (121) hide show
  1. package/libs/chunk-25KCUE3R.cjs +17 -0
  2. package/libs/chunk-25KCUE3R.cjs.map +1 -0
  3. package/libs/chunk-34NWHFHP.js +10 -0
  4. package/libs/chunk-34NWHFHP.js.map +1 -0
  5. package/libs/{chunk-SQ44OCJ2.js → chunk-6NMLU5FA.js} +2 -2
  6. package/libs/{chunk-GVVCXXKI.cjs → chunk-6YVR4TDM.cjs} +3 -3
  7. package/libs/chunk-DSQ2TUCR.js +7 -0
  8. package/libs/chunk-DSQ2TUCR.js.map +1 -0
  9. package/libs/{chunk-H6A2CUWA.js → chunk-VQTCTLFN.js} +2 -2
  10. package/libs/chunk-ZJ4RUKI2.cjs +14 -0
  11. package/libs/chunk-ZJ4RUKI2.cjs.map +1 -0
  12. package/libs/{chunk-H4JRUNKU.cjs → chunk-ZOPHCNFD.cjs} +3 -3
  13. package/libs/components/alert/alert.css +1 -1
  14. package/libs/components/alert/alert.css.map +1 -1
  15. package/libs/components/alert/alert.min.css +2 -2
  16. package/libs/components/button.cjs +3 -3
  17. package/libs/components/button.d.cts +34 -1
  18. package/libs/components/button.d.ts +34 -1
  19. package/libs/components/button.js +1 -1
  20. package/libs/components/buttons/button.css +1 -1
  21. package/libs/components/buttons/button.css.map +1 -1
  22. package/libs/components/buttons/button.min.css +2 -2
  23. package/libs/components/buttons/icon-button.css +1 -0
  24. package/libs/components/buttons/icon-button.css.map +1 -0
  25. package/libs/components/buttons/icon-button.min.css +3 -0
  26. package/libs/components/dialog/dialog.cjs +4 -4
  27. package/libs/components/dialog/dialog.css +1 -1
  28. package/libs/components/dialog/dialog.css.map +1 -1
  29. package/libs/components/dialog/dialog.js +2 -2
  30. package/libs/components/dialog/dialog.min.css +2 -2
  31. package/libs/components/link/link.css +1 -1
  32. package/libs/components/link/link.min.css +1 -1
  33. package/libs/components/modal.cjs +3 -3
  34. package/libs/components/modal.js +2 -2
  35. package/libs/components/popover/popover.cjs +3 -8
  36. package/libs/components/popover/popover.css +1 -0
  37. package/libs/components/popover/popover.css.map +1 -0
  38. package/libs/components/popover/popover.d.cts +54 -26
  39. package/libs/components/popover/popover.d.ts +54 -26
  40. package/libs/components/popover/popover.js +1 -2
  41. package/libs/components/popover/popover.min.css +3 -0
  42. package/libs/hooks.cjs +3 -6
  43. package/libs/hooks.cjs.map +1 -1
  44. package/libs/hooks.d.cts +30 -10
  45. package/libs/hooks.d.ts +30 -10
  46. package/libs/hooks.js +5 -1
  47. package/libs/hooks.js.map +1 -1
  48. package/libs/index.cjs +35 -35
  49. package/libs/index.cjs.map +1 -1
  50. package/libs/index.css +1 -1
  51. package/libs/index.css.map +1 -1
  52. package/libs/index.d.cts +65 -3
  53. package/libs/index.d.ts +65 -3
  54. package/libs/index.js +9 -10
  55. package/libs/index.js.map +1 -1
  56. package/package.json +2 -2
  57. package/src/components/alert/alert.scss +0 -13
  58. package/src/components/buttons/README.mdx +107 -11
  59. package/src/components/buttons/STYLES.mdx +182 -47
  60. package/src/components/buttons/button.scss +93 -16
  61. package/src/components/buttons/button.stories.tsx +149 -0
  62. package/src/components/buttons/button.test.tsx +12 -0
  63. package/src/components/buttons/button.tsx +50 -6
  64. package/src/components/buttons/icon-button.mdx +204 -0
  65. package/src/components/buttons/icon-button.scss +83 -0
  66. package/src/components/buttons/icon-button.stories.tsx +200 -0
  67. package/src/components/buttons/icon-button.test.tsx +132 -0
  68. package/src/components/buttons/icon-button.tsx +75 -0
  69. package/src/components/dialog/dialog-modal.stories.tsx +71 -0
  70. package/src/components/dialog/dialog-modal.tsx +29 -3
  71. package/src/components/dialog/dialog.scss +1 -0
  72. package/src/components/dialog/dialog.test.tsx +119 -0
  73. package/src/components/dialog/dialog.types.ts +8 -1
  74. package/src/components/form/select.tsx +55 -51
  75. package/src/components/link/link.scss +2 -2
  76. package/src/components/popover/README.mdx +478 -0
  77. package/src/components/popover/STYLES.mdx +389 -0
  78. package/src/components/popover/index.ts +3 -0
  79. package/src/components/popover/popover.scss +249 -0
  80. package/src/components/popover/popover.stories.tsx +315 -15
  81. package/src/components/popover/popover.test.tsx +249 -37
  82. package/src/components/popover/popover.tsx +165 -62
  83. package/src/hooks/popover/popover.tsx +26 -10
  84. package/src/hooks/popover/use-popover.tsx +30 -10
  85. package/src/hooks.ts +5 -0
  86. package/src/index.scss +1 -0
  87. package/src/index.ts +1 -0
  88. package/src/sass/utilities/_display.scss +156 -0
  89. package/src/sass/utilities/_index.scss +3 -0
  90. package/src/sass/utilities/display.mdx +203 -0
  91. package/src/sass/utilities/display.stories.tsx +141 -0
  92. package/src/styles/alert/alert.css +0 -13
  93. package/src/styles/alert/alert.css.map +1 -1
  94. package/src/styles/buttons/button.css +78 -16
  95. package/src/styles/buttons/button.css.map +1 -1
  96. package/src/styles/buttons/icon-button.css +71 -0
  97. package/src/styles/buttons/icon-button.css.map +1 -0
  98. package/src/styles/dialog/dialog.css +1 -0
  99. package/src/styles/dialog/dialog.css.map +1 -1
  100. package/src/styles/index.css +404 -31
  101. package/src/styles/index.css.map +1 -1
  102. package/src/styles/link/link.css +2 -2
  103. package/src/styles/popover/popover.css +190 -0
  104. package/src/styles/popover/popover.css.map +1 -0
  105. package/src/types/popover.d.ts +64 -0
  106. package/libs/chunk-4I5MF54P.js +0 -8
  107. package/libs/chunk-4I5MF54P.js.map +0 -1
  108. package/libs/chunk-GCGKYLDG.js +0 -7
  109. package/libs/chunk-GCGKYLDG.js.map +0 -1
  110. package/libs/chunk-NZVSXRTB.cjs +0 -16
  111. package/libs/chunk-NZVSXRTB.cjs.map +0 -1
  112. package/libs/chunk-PDD4N5P5.cjs +0 -10
  113. package/libs/chunk-PDD4N5P5.cjs.map +0 -1
  114. package/libs/chunk-S7NIA6PI.cjs +0 -17
  115. package/libs/chunk-S7NIA6PI.cjs.map +0 -1
  116. package/libs/chunk-X2RDXWH5.js +0 -10
  117. package/libs/chunk-X2RDXWH5.js.map +0 -1
  118. /package/libs/{chunk-SQ44OCJ2.js.map → chunk-6NMLU5FA.js.map} +0 -0
  119. /package/libs/{chunk-GVVCXXKI.cjs.map → chunk-6YVR4TDM.cjs.map} +0 -0
  120. /package/libs/{chunk-H6A2CUWA.js.map → chunk-VQTCTLFN.js.map} +0 -0
  121. /package/libs/{chunk-H4JRUNKU.cjs.map → chunk-ZOPHCNFD.cjs.map} +0 -0
@@ -0,0 +1,83 @@
1
+ // Breakpoint at which the label becomes visible (mobile-first).
2
+ // Override this variable in your own SCSS before importing to customise.
3
+ // NOTE: CSS custom properties cannot be used in @media conditions — this must be a SCSS variable.
4
+ $icon-label-bp: 48rem !default; // 768px
5
+
6
+ // Global theming tokens for icon buttons.
7
+ // Override in your theme stylesheet: :root { --icon-btn-size: 2.5rem; }
8
+ // Minimum tap target recommended: 2.75rem (44px, WCAG 2.5.5).
9
+ :root {
10
+ --icon-btn-size: 3rem;
11
+ --icon-btn-gap: 0.375rem;
12
+ --icon-btn-padding-inline: 0.75rem;
13
+ }
14
+
15
+ // Label is visually hidden by default (screen-reader accessible at all sizes).
16
+ // Revealed at tablet+ via min-width media query below.
17
+ [data-icon-btn] [data-icon-label],
18
+ [data-icon-btn] .icon-label {
19
+ position: absolute;
20
+ width: 1px;
21
+ height: 1px;
22
+ padding: 0;
23
+ margin: -1px;
24
+ overflow: hidden;
25
+ clip: rect(0, 0, 0, 0); // fallback for older browsers
26
+ clip-path: inset(50%); // modern replacement (97%+ support)
27
+ white-space: nowrap;
28
+ border: 0;
29
+ }
30
+
31
+ // Color reset for all IconButton instances.
32
+ // background stays transparent (set by button[data-style~="icon"]);
33
+ // color defaults to currentColor so the icon inherits from context.
34
+ // Override via styles={{ "--btn-color": "..." }} when a specific color is needed.
35
+ button[data-icon-btn],
36
+ button.icon-btn,
37
+ [data-icon-btn],
38
+ .icon-btn {
39
+ --btn-color: currentColor;
40
+
41
+ padding: 0;
42
+ width: var(--icon-btn-size);
43
+ height: var(--icon-btn-size);
44
+ display: inline-grid;
45
+ place-items: center;
46
+
47
+ // Layout when a visible label is present alongside the icon.
48
+ // Higher specificity than button[data-style~="icon"] (which uses padding: unset)
49
+ // so padding is restored without needing a consumer override.
50
+ &[data-icon-btn~="has-label"] {
51
+ width: max-content;
52
+ min-width: var(--icon-btn-size);
53
+ gap: var(--icon-btn-gap);
54
+ padding-inline: var(--icon-btn-padding-inline);
55
+ grid-auto-flow: column; // keep icon + label side-by-side
56
+
57
+ [data-icon-label],
58
+ .icon-label {
59
+ font-size: var(--btn-fs, 0.875rem);
60
+ line-height: 1;
61
+ white-space: nowrap;
62
+ }
63
+ }
64
+ }
65
+
66
+ // Reveal label text at tablet+ — icon + label visible together.
67
+ // Uses min-width (mobile-first): hidden by default, shown at 48rem+.
68
+ // BREAKING CHANGE: Previously max-width (desktop-first).
69
+ @media (min-width: #{$icon-label-bp}) {
70
+ [data-icon-btn] [data-icon-label],
71
+ [data-icon-btn] .icon-label {
72
+ position: static;
73
+ width: auto;
74
+ height: auto;
75
+ padding: unset;
76
+ margin: unset;
77
+ overflow: visible;
78
+ clip: unset;
79
+ clip-path: unset;
80
+ white-space: nowrap;
81
+ border: unset;
82
+ }
83
+ }
@@ -0,0 +1,200 @@
1
+ import type { StoryObj, Meta } from "@storybook/react-vite";
2
+ import { within, userEvent, expect, fn } from "storybook/test";
3
+
4
+ import { IconButton } from "./icon-button";
5
+ import "./button.scss";
6
+ import "./icon-button.scss";
7
+
8
+ // Minimal inline SVG icons for stories — no external icon dependency required
9
+ const CloseIcon = () => (
10
+ <svg width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth={2}>
11
+ <line x1="18" y1="6" x2="6" y2="18" />
12
+ <line x1="6" y1="6" x2="18" y2="18" />
13
+ </svg>
14
+ );
15
+
16
+ const SettingsIcon = () => (
17
+ <svg width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth={2}>
18
+ <circle cx="12" cy="12" r="3" />
19
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
20
+ </svg>
21
+ );
22
+
23
+ const TrashIcon = () => (
24
+ <svg width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth={2}>
25
+ <polyline points="3 6 5 6 21 6" />
26
+ <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
27
+ <path d="M10 11v6M14 11v6" />
28
+ <path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
29
+ </svg>
30
+ );
31
+
32
+ const iconClicked = fn();
33
+
34
+ const meta = {
35
+ title: "FP.React Components/Buttons/IconButton",
36
+ component: IconButton,
37
+ tags: ["beta"],
38
+ args: {
39
+ type: "button",
40
+ icon: <CloseIcon />,
41
+ "aria-label": "Close",
42
+ onClick: iconClicked,
43
+ },
44
+ } as Meta;
45
+
46
+ export default meta;
47
+ type Story = StoryObj<typeof IconButton>;
48
+
49
+ /**
50
+ * Default icon-only button. Requires `aria-label` for screen reader accessibility.
51
+ */
52
+ export const IconButtonDefault: Story = {
53
+ args: {
54
+ "aria-label": "Close",
55
+ icon: <CloseIcon />,
56
+ },
57
+ play: async ({ canvasElement, step }) => {
58
+ const canvas = within(canvasElement);
59
+ const button = canvas.getByRole("button", { name: "Close" });
60
+
61
+ await step("IconButton is rendered with aria-label", async () => {
62
+ expect(button).toBeInTheDocument();
63
+ expect(button).toHaveAttribute("aria-label", "Close");
64
+ });
65
+
66
+ await step("IconButton receives focus on tab", async () => {
67
+ await userEvent.tab();
68
+ expect(button).toHaveFocus();
69
+ });
70
+
71
+ await step("IconButton click handler fires", async () => {
72
+ await userEvent.click(button);
73
+ expect(iconClicked).toHaveBeenCalled();
74
+ });
75
+ },
76
+ };
77
+
78
+ /**
79
+ * Uses `aria-labelledby` instead of `aria-label` — references an existing element in the DOM.
80
+ * The XOR type means passing both `aria-label` and `aria-labelledby` is a TypeScript error.
81
+ */
82
+ export const IconButtonLabelledBy: Story = {
83
+ render: () => (
84
+ <div>
85
+ <span id="icon-btn-label" style={{ marginInlineEnd: "0.5rem" }}>
86
+ Delete item
87
+ </span>
88
+ <IconButton
89
+ type="button"
90
+ aria-labelledby="icon-btn-label"
91
+ icon={<TrashIcon />}
92
+ />
93
+ </div>
94
+ ),
95
+ };
96
+
97
+ /**
98
+ * Icon + visible label. Label hides below 768px (overridable via `$icon-label-bp` SCSS variable).
99
+ * Resize the viewport to see the responsive behavior.
100
+ * NOTE: `variant="outline"` overrides the default `variant="icon"` to restore padding.
101
+ */
102
+ export const IconButtonWithLabel: Story = {
103
+ args: {
104
+ "aria-label": "Settings",
105
+ icon: <SettingsIcon />,
106
+ label: "Settings",
107
+ variant: "outline",
108
+ },
109
+ };
110
+
111
+ /**
112
+ * All style variants — icon (default), outline, text, and pill.
113
+ * `icon` is the default: transparent background, currentColor icon, square touch target.
114
+ * Switch `variant` to restore background or border as needed.
115
+ */
116
+ export const IconButtonVariants: Story = {
117
+ render: () => (
118
+ <div style={{ display: "flex", gap: "1rem", alignItems: "center", flexWrap: "wrap" }}>
119
+ <IconButton type="button" aria-label="Icon variant (default)" icon={<SettingsIcon />} />
120
+ <IconButton type="button" aria-label="Outline variant" icon={<SettingsIcon />} variant="outline" />
121
+ <IconButton type="button" aria-label="Text variant" icon={<SettingsIcon />} variant="text" />
122
+ <IconButton type="button" aria-label="Pill variant" icon={<SettingsIcon />} variant="pill" />
123
+ </div>
124
+ ),
125
+ };
126
+
127
+ /**
128
+ * Size variants — xs through 2xl. Height and touch target scale with font size
129
+ * via the `--btn-height: calc(var(--btn-fs) * 2.75)` formula.
130
+ */
131
+ export const IconButtonSizes: Story = {
132
+ render: () => (
133
+ <div style={{ display: "flex", gap: "1rem", alignItems: "center", flexWrap: "wrap" }}>
134
+ <IconButton type="button" aria-label="Close (xs)" icon={<CloseIcon />} size="xs" />
135
+ <IconButton type="button" aria-label="Close (sm)" icon={<CloseIcon />} size="sm" />
136
+ <IconButton type="button" aria-label="Close (md)" icon={<CloseIcon />} size="md" />
137
+ <IconButton type="button" aria-label="Close (lg)" icon={<CloseIcon />} size="lg" />
138
+ <IconButton type="button" aria-label="Close (xl)" icon={<CloseIcon />} size="xl" />
139
+ <IconButton type="button" aria-label="Close (2xl)" icon={<CloseIcon />} size="2xl" />
140
+ </div>
141
+ ),
142
+ };
143
+
144
+ /**
145
+ * All semantic color variants. Color sets `--btn-bg` and `--btn-color` via
146
+ * `data-color` — icon buttons keep a transparent background by default so the
147
+ * icon itself inherits the color token via `currentColor`.
148
+ */
149
+ export const IconButtonColors: Story = {
150
+ render: () => (
151
+ <div style={{ display: "flex", gap: "1rem", alignItems: "center", flexWrap: "wrap" }}>
152
+ <IconButton type="button" aria-label="Primary" icon={<SettingsIcon />} color="primary" />
153
+ <IconButton type="button" aria-label="Secondary" icon={<SettingsIcon />} color="secondary" />
154
+ <IconButton type="button" aria-label="Danger" icon={<TrashIcon />} color="danger" />
155
+ <IconButton type="button" aria-label="Success" icon={<CloseIcon />} color="success" />
156
+ <IconButton type="button" aria-label="Warning" icon={<CloseIcon />} color="warning" />
157
+ </div>
158
+ ),
159
+ };
160
+
161
+ /**
162
+ * Outline variant across all color tokens. The `outline` variant restores a border
163
+ * and uses `currentColor` for both border and icon — color sets the inherited value.
164
+ */
165
+ export const IconButtonOutlineColors: Story = {
166
+ render: () => (
167
+ <div style={{ display: "flex", gap: "1rem", alignItems: "center", flexWrap: "wrap" }}>
168
+ <IconButton type="button" aria-label="Primary outline" icon={<SettingsIcon />} variant="outline" color="primary" />
169
+ <IconButton type="button" aria-label="Secondary outline" icon={<SettingsIcon />} variant="outline" color="secondary" />
170
+ <IconButton type="button" aria-label="Danger outline" icon={<TrashIcon />} variant="outline" color="danger" />
171
+ <IconButton type="button" aria-label="Success outline" icon={<CloseIcon />} variant="outline" color="success" />
172
+ <IconButton type="button" aria-label="Warning outline" icon={<CloseIcon />} variant="outline" color="warning" />
173
+ </div>
174
+ ),
175
+ };
176
+
177
+ /**
178
+ * Disabled state — uses the WCAG-compliant `aria-disabled` pattern.
179
+ * The button remains focusable but all interactions are blocked.
180
+ */
181
+ export const IconButtonDisabled: Story = {
182
+ args: {
183
+ "aria-label": "Close (disabled)",
184
+ icon: <CloseIcon />,
185
+ disabled: true,
186
+ },
187
+ play: async ({ canvasElement, step }) => {
188
+ const canvas = within(canvasElement);
189
+ const button = canvas.getByRole("button", { name: "Close (disabled)" });
190
+
191
+ await step("Disabled button has aria-disabled attribute", async () => {
192
+ expect(button).toHaveAttribute("aria-disabled", "true");
193
+ });
194
+
195
+ await step("Disabled button remains focusable", async () => {
196
+ await userEvent.tab();
197
+ expect(button).toHaveFocus();
198
+ });
199
+ },
200
+ };
@@ -0,0 +1,132 @@
1
+ import React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { vi } from "vitest";
5
+ import { IconButton } from "./icon-button";
6
+
7
+ const TestIcon = () => <svg data-testid="test-icon" aria-hidden="true" />;
8
+
9
+ describe("IconButton", () => {
10
+ it("renders a button element with aria-label", () => {
11
+ render(<IconButton type="button" aria-label="Close" icon={<TestIcon />} />);
12
+ const button = screen.getByRole("button", { name: "Close" });
13
+ expect(button).toBeInTheDocument();
14
+ expect(button).toHaveAttribute("aria-label", "Close");
15
+ });
16
+
17
+ it("renders a button element with aria-labelledby", () => {
18
+ render(
19
+ <>
20
+ <span id="lbl">Delete item</span>
21
+ <IconButton type="button" aria-labelledby="lbl" icon={<TestIcon />} />
22
+ </>
23
+ );
24
+ const button = screen.getByRole("button", { name: "Delete item" });
25
+ expect(button).toBeInTheDocument();
26
+ expect(button).toHaveAttribute("aria-labelledby", "lbl");
27
+ });
28
+
29
+ it("renders the icon as a child of the button", () => {
30
+ render(<IconButton type="button" aria-label="Close" icon={<TestIcon />} />);
31
+ expect(screen.getByTestId("test-icon")).toBeInTheDocument();
32
+ });
33
+
34
+ it("renders label text when label prop is provided", () => {
35
+ render(
36
+ <IconButton
37
+ type="button"
38
+ aria-label="Settings"
39
+ icon={<TestIcon />}
40
+ label="Settings"
41
+ />
42
+ );
43
+ expect(screen.getByText("Settings")).toBeInTheDocument();
44
+ });
45
+
46
+ it("applies data-icon-label attribute to the label span", () => {
47
+ render(
48
+ <IconButton
49
+ type="button"
50
+ aria-label="Settings"
51
+ icon={<TestIcon />}
52
+ label="Settings"
53
+ />
54
+ );
55
+ const labelSpan = screen.getByText("Settings");
56
+ expect(labelSpan).toHaveAttribute("data-icon-label");
57
+ });
58
+
59
+ it("applies data-icon-btn='has-label' to the button when label is provided", () => {
60
+ render(
61
+ <IconButton
62
+ type="button"
63
+ aria-label="Settings"
64
+ icon={<TestIcon />}
65
+ label="Settings"
66
+ />
67
+ );
68
+ const button = screen.getByRole("button", { name: "Settings" });
69
+ expect(button).toHaveAttribute("data-icon-btn", "has-label");
70
+ });
71
+
72
+ it("does not render a label span when label prop is omitted", () => {
73
+ render(<IconButton type="button" aria-label="Close" icon={<TestIcon />} />);
74
+ expect(document.querySelector("[data-icon-label]")).toBeNull();
75
+ });
76
+
77
+ it("sets data-icon-btn to 'icon' when label is omitted", () => {
78
+ render(<IconButton type="button" aria-label="Close" icon={<TestIcon />} />);
79
+ const button = screen.getByRole("button", { name: "Close" });
80
+ expect(button).toHaveAttribute("data-icon-btn", "icon");
81
+ });
82
+
83
+ it("fires the click handler when clicked", async () => {
84
+ const handleClick = vi.fn();
85
+ render(
86
+ <IconButton
87
+ type="button"
88
+ aria-label="Close"
89
+ icon={<TestIcon />}
90
+ onClick={handleClick}
91
+ />
92
+ );
93
+ await userEvent.click(screen.getByRole("button", { name: "Close" }));
94
+ expect(handleClick).toHaveBeenCalledTimes(1);
95
+ });
96
+
97
+ it("does not fire click handler when disabled", async () => {
98
+ const handleClick = vi.fn();
99
+ render(
100
+ <IconButton
101
+ type="button"
102
+ aria-label="Close"
103
+ icon={<TestIcon />}
104
+ disabled
105
+ onClick={handleClick}
106
+ />
107
+ );
108
+ const button = screen.getByRole("button", { name: "Close" });
109
+ expect(button).toHaveAttribute("aria-disabled", "true");
110
+ await userEvent.click(button);
111
+ expect(handleClick).toHaveBeenCalledTimes(0);
112
+ });
113
+
114
+ it("defaults variant to 'icon'", () => {
115
+ render(<IconButton type="button" aria-label="Close" icon={<TestIcon />} />);
116
+ const button = screen.getByRole("button", { name: "Close" });
117
+ expect(button).toHaveAttribute("data-style", "icon");
118
+ });
119
+
120
+ it("accepts a variant override", () => {
121
+ render(
122
+ <IconButton
123
+ type="button"
124
+ aria-label="Settings"
125
+ icon={<TestIcon />}
126
+ variant="outline"
127
+ />
128
+ );
129
+ const button = screen.getByRole("button", { name: "Settings" });
130
+ expect(button).toHaveAttribute("data-style", "outline");
131
+ });
132
+ });
@@ -0,0 +1,75 @@
1
+ import React from "react";
2
+ import { Button, type ButtonProps } from "./button";
3
+
4
+ /**
5
+ * XOR constraint: exactly one of aria-label or aria-labelledby is required.
6
+ * Passing both or neither is a TypeScript compile-time error.
7
+ * Satisfies WCAG 2.1 SC 1.1.1 (Non-text Content).
8
+ */
9
+ type WithAriaLabel = { "aria-label": string; "aria-labelledby"?: never };
10
+ type WithAriaLabelledBy = { "aria-labelledby": string; "aria-label"?: never };
11
+
12
+ export type IconButtonProps = Omit<ButtonProps, "children"> &
13
+ (WithAriaLabel | WithAriaLabelledBy) & {
14
+ /** The icon element rendered inside the button. */
15
+ icon: React.ReactNode;
16
+ /**
17
+ * Optional text shown alongside the icon at desktop widths.
18
+ * Visually hidden below the `$icon-label-bp` SCSS breakpoint (default 48rem / 768px)
19
+ * via a media query on `[data-icon-label]`, but always present in the accessibility
20
+ * tree — screen readers announce it at every viewport size.
21
+ *
22
+ * NOTE: When `label` is provided, the default `variant="icon"` removes padding.
23
+ * Use `variant="outline"` (or another padded variant) to restore layout padding
24
+ * alongside the label.
25
+ */
26
+ label?: string;
27
+ /** Button type: button, submit, or reset. Required. */
28
+ type: "button" | "submit" | "reset";
29
+ };
30
+
31
+ /**
32
+ * Accessible icon button component. Wraps `Button` with:
33
+ * - Required accessible label via `aria-label` or `aria-labelledby` (XOR enforced)
34
+ * - Optional `label` text hidden on mobile (< 48rem), visible on desktop — always in a11y tree
35
+ * - `variant="icon"` default (square, no padding)
36
+ * - Fixed `3rem × 3rem` tap target (48px at default root font size — WCAG 2.5.5 AAA)
37
+ *
38
+ * @example
39
+ * // Icon only
40
+ * <IconButton type="button" aria-label="Close menu" icon={<CloseIcon />} />
41
+ *
42
+ * @example
43
+ * // Icon + label (label hides on mobile, visible at >= 48rem / 768px)
44
+ * <IconButton
45
+ * type="button"
46
+ * aria-label="Settings"
47
+ * icon={<SettingsIcon />}
48
+ * label="Settings"
49
+ * variant="outline"
50
+ * />
51
+ *
52
+ * @example
53
+ * // Labelled by external element
54
+ * <span id="btn-label">Delete item</span>
55
+ * <IconButton type="button" aria-labelledby="btn-label" icon={<TrashIcon />} />
56
+ */
57
+ export const IconButton = ({
58
+ icon,
59
+ label,
60
+ variant = "icon",
61
+ type = "button",
62
+ ...props
63
+ }: IconButtonProps) => (
64
+ <Button
65
+ variant={variant}
66
+ data-icon-btn={label ? "has-label" : "icon"}
67
+ {...props}
68
+ type={type}
69
+ >
70
+ {icon}
71
+ {label && <span data-icon-label>{label}</span>}
72
+ </Button>
73
+ );
74
+
75
+ IconButton.displayName = "IconButton";
@@ -3,6 +3,23 @@ import { within, expect, userEvent, waitFor } from "storybook/test";
3
3
 
4
4
  import DialogModal from "./dialog-modal";
5
5
  import WithInstructions from "#/decorators/instructions";
6
+
7
+ // Inline SVG icons for stories — no external icon dependency required
8
+ const SettingsIcon = () => (
9
+ <svg width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth={2}>
10
+ <circle cx="12" cy="12" r="3" />
11
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
12
+ </svg>
13
+ );
14
+
15
+ const TrashIcon = () => (
16
+ <svg width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth={2}>
17
+ <polyline points="3 6 5 6 21 6" />
18
+ <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
19
+ <path d="M10 11v6M14 11v6" />
20
+ <path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
21
+ </svg>
22
+ );
6
23
  const meta: Meta<typeof DialogModal> = {
7
24
  title: "FP.React Components/Dialog/DialogModal",
8
25
  component: DialogModal,
@@ -110,3 +127,57 @@ export const ModalInteractions: Story = {
110
127
  });
111
128
  },
112
129
  } as Story;
130
+
131
+ export const IconTrigger: Story = {
132
+ args: {
133
+ children: "This dialog was opened from an icon button trigger.",
134
+ dialogTitle: "Settings",
135
+ btnLabel: "Settings",
136
+ icon: <SettingsIcon />,
137
+ },
138
+ play: async ({ canvasElement, step }) => {
139
+ const canvas = within(canvasElement);
140
+
141
+ await step("Icon button opens dialog", async () => {
142
+ const iconButton = canvas.getByRole("button", { name: /settings/i });
143
+ expect(iconButton).toHaveAttribute("aria-haspopup", "dialog");
144
+ await userEvent.click(iconButton, { delay: 500 });
145
+ const dialog = canvas.getByRole("dialog");
146
+ expect(dialog).toBeVisible();
147
+ });
148
+
149
+ await step("Close dialog", async () => {
150
+ const closeButton = canvas.getByRole("button", { name: /close dialog/i });
151
+ await userEvent.click(closeButton, { delay: 500 });
152
+ });
153
+ },
154
+ } as Story;
155
+
156
+ export const IconTriggerWithOutlineVariant: Story = {
157
+ args: {
158
+ children: "This dialog uses an icon button with outline variant and visible label.",
159
+ dialogTitle: "Delete Item",
160
+ btnLabel: "Delete",
161
+ icon: <TrashIcon />,
162
+ btnProps: { variant: "outline", color: "danger" },
163
+ onConfirm: () => {},
164
+ confirmLabel: "Delete",
165
+ cancelLabel: "Cancel",
166
+ },
167
+ play: async ({ canvasElement, step }) => {
168
+ const canvas = within(canvasElement);
169
+
170
+ await step("Icon button with label opens dialog", async () => {
171
+ const iconButton = canvas.getByRole("button", { name: /delete/i });
172
+ expect(iconButton).toHaveAttribute("aria-haspopup", "dialog");
173
+ await userEvent.click(iconButton, { delay: 500 });
174
+ const dialog = canvas.getByRole("dialog");
175
+ expect(dialog).toBeVisible();
176
+ });
177
+
178
+ await step("Close with cancel", async () => {
179
+ const cancelButton = canvas.getByRole("button", { name: /cancel/i });
180
+ await userEvent.click(cancelButton, { delay: 500 });
181
+ });
182
+ },
183
+ } as Story;
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useRef, useCallback, useEffect } from "react";
2
2
  import Dialog from "./dialog";
3
3
  import Button from "#components/buttons/button.jsx";
4
+ import { IconButton } from "#components/buttons/icon-button.jsx";
4
5
  import type { DialogModalProps } from "./dialog.types";
5
6
 
6
7
  /**
@@ -34,6 +35,7 @@ import type { DialogModalProps } from "./dialog.types";
34
35
  * @param {boolean} [props.hideFooter=false] - If true, hides the footer with action buttons
35
36
  * @param {string} [props.className] - Additional CSS classes for the dialog
36
37
  * @param {string} [props.dialogLabel] - Optional aria-label for the dialog
38
+ * @param {ReactElement} [props.icon] - Optional icon element. When provided, renders IconButton as trigger.
37
39
  * @returns {JSX.Element} A dialog with trigger button and automatic state management
38
40
  *
39
41
  * @example
@@ -49,6 +51,19 @@ import type { DialogModalProps } from "./dialog.types";
49
51
  * Are you sure you want to delete this item? This action cannot be undone.
50
52
  * </DialogModal>
51
53
  * ```
54
+ *
55
+ * @example
56
+ * ```tsx
57
+ * // Icon trigger — renders IconButton with visible label at desktop widths
58
+ * <DialogModal
59
+ * dialogTitle="Settings"
60
+ * btnLabel="Settings"
61
+ * icon={<SettingsIcon />}
62
+ * btnProps={{ variant: "outline" }}
63
+ * >
64
+ * Settings content here.
65
+ * </DialogModal>
66
+ * ```
52
67
  */
53
68
  export const DialogModal: React.FC<DialogModalProps> = ({
54
69
  isAlertDialog = false,
@@ -65,6 +80,7 @@ export const DialogModal: React.FC<DialogModalProps> = ({
65
80
  className,
66
81
  hideFooter = false,
67
82
  btnProps,
83
+ icon,
68
84
  }) => {
69
85
  const [isOpen, setIsOpen] = useState(false);
70
86
  const lastFocusedElement = useRef<HTMLElement | null>(null);
@@ -103,16 +119,26 @@ export const DialogModal: React.FC<DialogModalProps> = ({
103
119
  }
104
120
  }, [isOpen]);
105
121
 
106
- const triggerButtonProps = {
122
+ const sharedTriggerProps = {
107
123
  type: "button" as const,
108
124
  onClick: handleButtonClick,
109
- "data-btn": btnSize,
125
+ "aria-haspopup": "dialog" as const,
110
126
  ...btnProps,
111
127
  };
112
128
 
113
129
  return (
114
130
  <>
115
- <Button {...triggerButtonProps}>{btnLabel}</Button>
131
+ {icon ? (
132
+ <IconButton
133
+ icon={icon}
134
+ aria-label={btnLabel}
135
+ label={btnLabel}
136
+ size={btnSize}
137
+ {...sharedTriggerProps}
138
+ />
139
+ ) : (
140
+ <Button data-btn={btnSize} {...sharedTriggerProps}>{btnLabel}</Button>
141
+ )}
116
142
  <Dialog
117
143
  isOpen={isOpen}
118
144
  onOpenChange={handleOpenChange}
@@ -83,6 +83,7 @@ dialog {
83
83
  button[type="button"] {
84
84
  background-color: var(--dialog-button-bg);
85
85
  border: var(--dialog-button-border);
86
+ color: var(--dialog-close-color);
86
87
  cursor: pointer;
87
88
 
88
89
  &:hover {