@fpkit/acss 6.3.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 (42) hide show
  1. package/libs/components/alert/alert.css +1 -1
  2. package/libs/components/alert/alert.css.map +1 -1
  3. package/libs/components/alert/alert.min.css +2 -2
  4. package/libs/components/buttons/icon-button.css +1 -1
  5. package/libs/components/buttons/icon-button.css.map +1 -1
  6. package/libs/components/buttons/icon-button.min.css +2 -2
  7. package/libs/components/dialog/dialog.css +1 -1
  8. package/libs/components/dialog/dialog.css.map +1 -1
  9. package/libs/components/dialog/dialog.min.css +2 -2
  10. package/libs/components/icons/icon.d.cts +1 -1
  11. package/libs/components/icons/icon.d.ts +1 -1
  12. package/libs/{icons-2c09535c.d.ts → icons-48788561.d.ts} +32 -32
  13. package/libs/icons.d.cts +1 -1
  14. package/libs/icons.d.ts +1 -1
  15. package/libs/index.cjs.map +1 -1
  16. package/libs/index.css +1 -1
  17. package/libs/index.css.map +1 -1
  18. package/libs/index.d.cts +11 -8
  19. package/libs/index.d.ts +11 -8
  20. package/libs/index.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/components/alert/alert.scss +0 -13
  23. package/src/components/buttons/icon-button.mdx +204 -0
  24. package/src/components/buttons/icon-button.scss +64 -26
  25. package/src/components/buttons/icon-button.tsx +9 -6
  26. package/src/components/dialog/dialog-modal.stories.tsx +71 -0
  27. package/src/components/dialog/dialog-modal.tsx +29 -3
  28. package/src/components/dialog/dialog.scss +1 -0
  29. package/src/components/dialog/dialog.test.tsx +119 -0
  30. package/src/components/dialog/dialog.types.ts +8 -1
  31. package/src/sass/utilities/_display.scss +156 -0
  32. package/src/sass/utilities/_index.scss +3 -0
  33. package/src/sass/utilities/display.mdx +203 -0
  34. package/src/sass/utilities/display.stories.tsx +141 -0
  35. package/src/styles/alert/alert.css +0 -13
  36. package/src/styles/alert/alert.css.map +1 -1
  37. package/src/styles/buttons/icon-button.css +55 -16
  38. package/src/styles/buttons/icon-button.css.map +1 -1
  39. package/src/styles/dialog/dialog.css +1 -0
  40. package/src/styles/dialog/dialog.css.map +1 -1
  41. package/src/styles/index.css +136 -13
  42. package/src/styles/index.css.map +1 -1
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@fpkit/acss",
3
3
  "description": "A lightweight React UI library for building modern and accessible components that leverage CSS custom properties for reactive Styles.",
4
4
  "private": false,
5
- "version": "6.3.0",
5
+ "version": "6.4.0",
6
6
  "engines": {
7
7
  "node": ">=22.12.0",
8
8
  "npm": ">=8.0.0"
@@ -1,16 +1,3 @@
1
- /* Screen reader only utility class */
2
- .sr-only {
3
- position: absolute;
4
- width: 1px;
5
- height: 1px;
6
- padding: 0;
7
- margin: -1px;
8
- overflow: hidden;
9
- clip: rect(0, 0, 0, 0);
10
- white-space: nowrap;
11
- border-width: 0;
12
- }
13
-
14
1
  [role="alert"] {
15
2
  /* Success colors - WCAG AA compliant (mapped to semantic tokens) */
16
3
  --alert-success-bg: var(--color-success-bg);
@@ -0,0 +1,204 @@
1
+ import { Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ <Meta title="FP.React Components/Buttons/IconButton/Readme" />
4
+
5
+ # IconButton
6
+
7
+ ## Summary
8
+
9
+ `IconButton` is an accessible icon-button component that wraps the base `Button`
10
+ with enforced accessible labelling and icon-specific defaults.
11
+
12
+ It handles the two most common icon-button patterns:
13
+
14
+ - **Icon-only** — a square tap target with a screen-reader label
15
+ - **Icon + label** — icon with visible text that hides on narrow viewports
16
+
17
+ `IconButton` extends all `Button` props (`size`, `variant`, `color`, `disabled`,
18
+ etc.) and enforces one critical constraint at the TypeScript level: exactly one
19
+ of `aria-label` or `aria-labelledby` must be present — passing both or neither
20
+ is a compile-time error.
21
+
22
+ ---
23
+
24
+ ## Props
25
+
26
+ | Prop | Type | Default | Description |
27
+ | ----------------- | -------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------- |
28
+ | `icon` | `React.ReactNode` | — | **Required.** The icon element rendered inside the button. |
29
+ | `aria-label` | `string` | — | Accessible name for icon-only buttons. XOR with `aria-labelledby`. |
30
+ | `aria-labelledby` | `string` | — | References an external element's `id` as the accessible name. XOR with `aria-label`. |
31
+ | `label` | `string` | — | Optional label text alongside the icon. Hidden below `48rem` (768px); always in the accessibility tree.|
32
+ | `type` | `'button' \| 'submit' \| 'reset'` | `'button'` | Button type attribute. Required. |
33
+ | `variant` | `'icon' \| 'outline' \| 'text' \| 'pill'` | `'icon'` | Style variant. Default `'icon'` gives a transparent, square, no-padding button. |
34
+ | `size` | `'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl'` | — | Size token. Scales font size and height via `--btn-fs`. |
35
+ | `color` | `'primary' \| 'secondary' \| 'danger' \| 'success' \| 'warning'` | — | Semantic color token. Sets `--btn-bg` and `--btn-color` via `data-color`. |
36
+ | `disabled` | `boolean` | `false` | Disables the button using WCAG-compliant `aria-disabled` (stays keyboard-focusable). |
37
+ | `onClick` | `(e: React.MouseEvent<HTMLButtonElement>) => void` | — | Click handler. |
38
+ | `styles` | `React.CSSProperties` | — | Inline styles — use to override CSS custom properties e.g. `--btn-color: red`. |
39
+ | `classes` | `string` | — | Additional CSS classes appended to the button element. |
40
+
41
+ ---
42
+
43
+ ## Sizing — 3rem Fixed Tap Target
44
+
45
+ The default `variant="icon"` applies a fixed `width: 3rem; height: 3rem` to
46
+ every `IconButton` instance:
47
+
48
+ ```css
49
+ button[data-icon-btn] {
50
+ width: 3rem; /* 48px at default font size */
51
+ height: 3rem; /* 48px at default font size */
52
+ }
53
+ ```
54
+
55
+ `3rem` equals **48px** at the browser's default 16px root font size, meeting
56
+ the WCAG 2.5.5 (AAA) minimum target size recommendation of 44×44 CSS pixels.
57
+ The fixed size ensures a consistent, touchable target regardless of icon size
58
+ or parent context.
59
+
60
+ When a `label` is present (the `has-label` variant), width becomes `max-content`
61
+ and padding is restored to `0.75rem` inline, but the `3rem` height is preserved
62
+ for a consistent touch target.
63
+
64
+ ---
65
+
66
+ ## Label Visibility
67
+
68
+ When `label` is provided, the text is rendered in a `<span data-icon-label>`.
69
+ Visibility is controlled entirely by CSS — no prop required:
70
+
71
+ - **Below `48rem` (768px):** the span is visually hidden via the visually-hidden
72
+ technique applied by the `[data-icon-label]` media query in `icon-button.scss`.
73
+ The span remains in the DOM and the accessibility tree — screen readers
74
+ announce the label at every viewport size.
75
+ - **At `48rem` and above:** the label is fully visible alongside the icon.
76
+
77
+ The breakpoint is a SCSS variable (not a CSS custom property) because media
78
+ query conditions are evaluated at parse time and cannot reference runtime values:
79
+
80
+ ```scss
81
+ $icon-label-bp: 48rem !default; // Override before import to customise
82
+ ```
83
+
84
+ ---
85
+
86
+ ## Usage Examples
87
+
88
+ ### Icon-only button
89
+
90
+ Requires `aria-label`. The label is only announced by screen readers — not
91
+ visible on screen.
92
+
93
+ ```tsx
94
+ import { IconButton } from "@fpkit/acss";
95
+
96
+ <IconButton
97
+ type="button"
98
+ aria-label="Close menu"
99
+ icon={<CloseIcon />}
100
+ />
101
+ ```
102
+
103
+ ### Icon + visible label
104
+
105
+ Use `variant="outline"` (or any non-`icon` variant) to restore padding alongside
106
+ the label. The default `variant="icon"` sets `padding: 0`, which collapses the
107
+ layout around a label. The label hides automatically on mobile (< 48rem) via CSS.
108
+
109
+ ```tsx
110
+ <IconButton
111
+ type="button"
112
+ aria-label="Settings"
113
+ icon={<SettingsIcon />}
114
+ label="Settings"
115
+ variant="outline"
116
+ />
117
+ ```
118
+
119
+ ### Labelled by external element
120
+
121
+ Use `aria-labelledby` to reference an existing label element in the DOM. Useful
122
+ when the button sits next to a heading or description that already names the action.
123
+
124
+ ```tsx
125
+ <span id="delete-label">Delete item</span>
126
+ <IconButton
127
+ type="button"
128
+ aria-labelledby="delete-label"
129
+ icon={<TrashIcon />}
130
+ />
131
+ ```
132
+
133
+ ### Semantic color variants
134
+
135
+ Color tokens apply to the icon via `currentColor` — the icon's `stroke` or
136
+ `fill` inherits the button's `--btn-color`.
137
+
138
+ ```tsx
139
+ <IconButton type="button" aria-label="Delete" icon={<TrashIcon />} color="danger" />
140
+ <IconButton type="button" aria-label="Confirm" icon={<CheckIcon />} color="success" />
141
+ <IconButton type="button" aria-label="Settings" icon={<GearIcon />} color="primary" variant="outline" />
142
+ ```
143
+
144
+ ### Disabled state
145
+
146
+ Uses the WCAG-compliant `aria-disabled` pattern — the button remains focusable
147
+ and visible in the tab order so keyboard users can discover it and read any
148
+ associated tooltip or help text.
149
+
150
+ ```tsx
151
+ <IconButton
152
+ type="button"
153
+ aria-label="Upload (unavailable)"
154
+ icon={<UploadIcon />}
155
+ disabled
156
+ />
157
+ ```
158
+
159
+ ### Custom color via CSS custom property
160
+
161
+ ```tsx
162
+ <IconButton
163
+ type="button"
164
+ aria-label="Star"
165
+ icon={<StarIcon />}
166
+ styles={{ "--btn-color": "#f59e0b" }}
167
+ />
168
+ ```
169
+
170
+ ---
171
+
172
+ ## Accessibility Notes
173
+
174
+ - **`aria-label` is required** for icon-only buttons. An icon with no label
175
+ gives screen reader users no indication of the button's purpose.
176
+ - **XOR enforcement** — the TypeScript type allows exactly one of `aria-label`
177
+ or `aria-labelledby`. Passing both or neither is a compile-time error.
178
+ - **`label` does not replace `aria-label`** — even when a visible `label` is
179
+ provided, you must still pass `aria-label` (or `aria-labelledby`). The `label`
180
+ prop controls visual presentation only; `aria-label` provides the computed
181
+ accessible name.
182
+ - **`disabled` keeps focus** — `IconButton` uses `aria-disabled="true"` instead
183
+ of the native `disabled` attribute. The button stays in the tab order and can
184
+ receive focus, satisfying WCAG 2.1.1 (Keyboard) and 4.1.2 (Name, Role, Value).
185
+ - **Icon `aria-hidden`** — always render icons with `aria-hidden="true"` so
186
+ screen readers do not double-announce both the SVG content and the button label.
187
+
188
+ ```tsx
189
+ // Correct — icon is decorative, label carries the meaning
190
+ <IconButton
191
+ type="button"
192
+ aria-label="Close"
193
+ icon={<svg aria-hidden="true">...</svg>}
194
+ />
195
+ ```
196
+
197
+ ---
198
+
199
+ ## Related
200
+
201
+ - [Button README](./README.mdx) — base `Button` props and variants
202
+ - [Button Styles](./STYLES.mdx) — full CSS custom property reference
203
+ - [WCAG 2.5.5 Target Size](https://www.w3.org/WAI/WCAG21/Understanding/target-size.html)
204
+ - [WCAG 2.4.1 Bypass Blocks](https://www.w3.org/WAI/WCAG21/Understanding/bypass-blocks.html)
@@ -1,45 +1,83 @@
1
- // Breakpoint at which the label hides (icon-only on mobile).
1
+ // Breakpoint at which the label becomes visible (mobile-first).
2
2
  // Override this variable in your own SCSS before importing to customise.
3
3
  // NOTE: CSS custom properties cannot be used in @media conditions — this must be a SCSS variable.
4
4
  $icon-label-bp: 48rem !default; // 768px
5
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
+
6
31
  // Color reset for all IconButton instances.
7
32
  // background stays transparent (set by button[data-style~="icon"]);
8
33
  // color defaults to currentColor so the icon inherits from context.
9
34
  // Override via styles={{ "--btn-color": "..." }} when a specific color is needed.
10
35
  button[data-icon-btn],
36
+ button.icon-btn,
37
+ [data-icon-btn],
11
38
  .icon-btn {
12
39
  --btn-color: currentColor;
13
- }
14
40
 
15
- // Layout when a visible label is present alongside the icon.
16
- // Higher specificity than button[data-style~="icon"] (which uses padding: unset)
17
- // so padding is restored without needing a consumer override.
18
- button[data-icon-btn~="has-label"],
19
- .icon-btn[data-icon-btn~="has-label"] {
20
- gap: 0.375rem;
21
- padding-inline: 0.75rem;
41
+ padding: 0;
42
+ width: var(--icon-btn-size);
43
+ height: var(--icon-btn-size);
44
+ display: inline-grid;
45
+ place-items: center;
22
46
 
23
- [data-icon-label] {
24
- font-size: var(--btn-fs, 0.875rem);
25
- line-height: 1;
26
- white-space: nowrap;
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
+ }
27
63
  }
28
64
  }
29
65
 
30
- // Hide label text visually on mobile — icon only.
31
- // Uses visually-hidden technique so the span stays in the accessibility tree;
32
- // screen readers always read it (display:none would remove it from the a11y tree).
33
- @media (max-width: #{$icon-label-bp}) {
34
- [data-icon-label] {
35
- position: absolute;
36
- width: 1px;
37
- height: 1px;
38
- padding: 0;
39
- margin: -1px;
40
- overflow: hidden;
41
- clip: rect(0, 0, 0, 0);
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;
42
80
  white-space: nowrap;
43
- border: 0;
81
+ border: unset;
44
82
  }
45
83
  }
@@ -15,11 +15,13 @@ export type IconButtonProps = Omit<ButtonProps, "children"> &
15
15
  icon: React.ReactNode;
16
16
  /**
17
17
  * Optional text shown alongside the icon at desktop widths.
18
- * Hidden visually below the `$icon-label-bp` SCSS breakpoint (default 48rem / 768px),
19
- * but remains in the accessibility tree screen readers always announce it.
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.
20
21
  *
21
- * NOTE: When `label` is used, the default `variant="icon"` removes padding.
22
- * Override with a different variant (e.g. `variant="outline"`) for a padded layout.
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.
23
25
  */
24
26
  label?: string;
25
27
  /** Button type: button, submit, or reset. Required. */
@@ -29,15 +31,16 @@ export type IconButtonProps = Omit<ButtonProps, "children"> &
29
31
  /**
30
32
  * Accessible icon button component. Wraps `Button` with:
31
33
  * - Required accessible label via `aria-label` or `aria-labelledby` (XOR enforced)
32
- * - Optional visible `label` text that hides on mobile (visual only — always in a11y tree)
34
+ * - Optional `label` text hidden on mobile (< 48rem), visible on desktop — always in a11y tree
33
35
  * - `variant="icon"` default (square, no padding)
36
+ * - Fixed `3rem × 3rem` tap target (48px at default root font size — WCAG 2.5.5 AAA)
34
37
  *
35
38
  * @example
36
39
  * // Icon only
37
40
  * <IconButton type="button" aria-label="Close menu" icon={<CloseIcon />} />
38
41
  *
39
42
  * @example
40
- * // Icon + label (label hides on mobile)
43
+ * // Icon + label (label hides on mobile, visible at >= 48rem / 768px)
41
44
  * <IconButton
42
45
  * type="button"
43
46
  * aria-label="Settings"
@@ -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 {
@@ -446,5 +446,124 @@ describe("DialogModal", () => {
446
446
  expect(triggerButton).toBeInTheDocument();
447
447
  expect(triggerButton).not.toBeDisabled();
448
448
  });
449
+
450
+ it("adds aria-haspopup='dialog' to the regular button trigger", () => {
451
+ render(
452
+ <DialogModal dialogTitle="Test" btnLabel="Open">
453
+ Content
454
+ </DialogModal>
455
+ );
456
+
457
+ const triggerButton = screen.getByRole("button", { name: /open/i });
458
+ expect(triggerButton).toHaveAttribute("aria-haspopup", "dialog");
459
+ });
460
+ });
461
+
462
+ describe("Icon Button Trigger", () => {
463
+ const TestIcon = () => (
464
+ <svg data-testid="test-icon" aria-hidden="true">
465
+ <circle cx="12" cy="12" r="10" />
466
+ </svg>
467
+ );
468
+
469
+ it("renders IconButton when icon prop is provided", () => {
470
+ render(
471
+ <DialogModal dialogTitle="Test" btnLabel="Settings" icon={<TestIcon />}>
472
+ Content
473
+ </DialogModal>
474
+ );
475
+
476
+ const iconButton = screen.getByRole("button", { name: /settings/i });
477
+ expect(iconButton).toHaveAttribute("data-icon-btn");
478
+ });
479
+
480
+ it("renders regular Button when icon prop is not provided", () => {
481
+ render(
482
+ <DialogModal dialogTitle="Test" btnLabel="Open">
483
+ Content
484
+ </DialogModal>
485
+ );
486
+
487
+ const button = screen.getByRole("button", { name: /open/i });
488
+ expect(button).not.toHaveAttribute("data-icon-btn");
489
+ });
490
+
491
+ it("uses btnLabel as aria-label on the icon button", () => {
492
+ render(
493
+ <DialogModal dialogTitle="Test" btnLabel="Settings" icon={<TestIcon />}>
494
+ Content
495
+ </DialogModal>
496
+ );
497
+
498
+ const iconButton = screen.getByRole("button", { name: /settings/i });
499
+ expect(iconButton).toHaveAttribute("aria-label", "Settings");
500
+ });
501
+
502
+ it("passes btnLabel as visible label on the icon button", () => {
503
+ render(
504
+ <DialogModal dialogTitle="Test" btnLabel="Settings" icon={<TestIcon />}>
505
+ Content
506
+ </DialogModal>
507
+ );
508
+
509
+ // IconButton renders the label in a span with data-icon-label
510
+ const iconButton = screen.getByRole("button", { name: /settings/i });
511
+ expect(iconButton.querySelector("[data-icon-label]")).toHaveTextContent("Settings");
512
+ });
513
+
514
+ it("adds aria-haspopup='dialog' to the icon button trigger", () => {
515
+ render(
516
+ <DialogModal dialogTitle="Test" btnLabel="Settings" icon={<TestIcon />}>
517
+ Content
518
+ </DialogModal>
519
+ );
520
+
521
+ const iconButton = screen.getByRole("button", { name: /settings/i });
522
+ expect(iconButton).toHaveAttribute("aria-haspopup", "dialog");
523
+ });
524
+
525
+ it("opens dialog when icon button is clicked", async () => {
526
+ const user = userEvent.setup();
527
+
528
+ render(
529
+ <DialogModal dialogTitle="Test Dialog" btnLabel="Settings" icon={<TestIcon />}>
530
+ Dialog content
531
+ </DialogModal>
532
+ );
533
+
534
+ const iconButton = screen.getByRole("button", { name: /settings/i });
535
+ await user.click(iconButton);
536
+
537
+ await waitFor(() => {
538
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
539
+ expect(screen.getByText("Dialog content")).toBeInTheDocument();
540
+ });
541
+ });
542
+
543
+ it("applies btnSize to the icon button", () => {
544
+ render(
545
+ <DialogModal dialogTitle="Test" btnLabel="Settings" icon={<TestIcon />} btnSize="lg">
546
+ Content
547
+ </DialogModal>
548
+ );
549
+
550
+ const iconButton = screen.getByRole("button", { name: /settings/i });
551
+ expect(iconButton).toHaveAttribute("data-btn", "lg");
552
+ });
553
+
554
+ it("forwards btnProps to the icon button", () => {
555
+ render(
556
+ <DialogModal
557
+ dialogTitle="Test"
558
+ btnLabel="Settings"
559
+ icon={<TestIcon />}
560
+ btnProps={{ "data-testid": "icon-trigger" }}
561
+ >
562
+ Content
563
+ </DialogModal>
564
+ );
565
+
566
+ expect(screen.getByTestId("icon-trigger")).toBeInTheDocument();
567
+ });
449
568
  });
450
569
  });