@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.
- package/libs/components/alert/alert.css +1 -1
- package/libs/components/alert/alert.css.map +1 -1
- package/libs/components/alert/alert.min.css +2 -2
- package/libs/components/buttons/icon-button.css +1 -1
- package/libs/components/buttons/icon-button.css.map +1 -1
- package/libs/components/buttons/icon-button.min.css +2 -2
- package/libs/components/dialog/dialog.css +1 -1
- package/libs/components/dialog/dialog.css.map +1 -1
- package/libs/components/dialog/dialog.min.css +2 -2
- package/libs/components/icons/icon.d.cts +1 -1
- package/libs/components/icons/icon.d.ts +1 -1
- package/libs/{icons-2c09535c.d.ts → icons-48788561.d.ts} +32 -32
- package/libs/icons.d.cts +1 -1
- package/libs/icons.d.ts +1 -1
- package/libs/index.cjs.map +1 -1
- package/libs/index.css +1 -1
- package/libs/index.css.map +1 -1
- package/libs/index.d.cts +11 -8
- package/libs/index.d.ts +11 -8
- package/libs/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/alert/alert.scss +0 -13
- package/src/components/buttons/icon-button.mdx +204 -0
- package/src/components/buttons/icon-button.scss +64 -26
- package/src/components/buttons/icon-button.tsx +9 -6
- package/src/components/dialog/dialog-modal.stories.tsx +71 -0
- package/src/components/dialog/dialog-modal.tsx +29 -3
- package/src/components/dialog/dialog.scss +1 -0
- package/src/components/dialog/dialog.test.tsx +119 -0
- package/src/components/dialog/dialog.types.ts +8 -1
- package/src/sass/utilities/_display.scss +156 -0
- package/src/sass/utilities/_index.scss +3 -0
- package/src/sass/utilities/display.mdx +203 -0
- package/src/sass/utilities/display.stories.tsx +141 -0
- package/src/styles/alert/alert.css +0 -13
- package/src/styles/alert/alert.css.map +1 -1
- package/src/styles/buttons/icon-button.css +55 -16
- package/src/styles/buttons/icon-button.css.map +1 -1
- package/src/styles/dialog/dialog.css +1 -0
- package/src/styles/dialog/dialog.css.map +1 -1
- package/src/styles/index.css +136 -13
- 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.
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
//
|
|
31
|
-
// Uses
|
|
32
|
-
//
|
|
33
|
-
@media (
|
|
34
|
-
[data-icon-label]
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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:
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
|
22
|
-
*
|
|
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
|
|
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
|
|
122
|
+
const sharedTriggerProps = {
|
|
107
123
|
type: "button" as const,
|
|
108
124
|
onClick: handleButtonClick,
|
|
109
|
-
"
|
|
125
|
+
"aria-haspopup": "dialog" as const,
|
|
110
126
|
...btnProps,
|
|
111
127
|
};
|
|
112
128
|
|
|
113
129
|
return (
|
|
114
130
|
<>
|
|
115
|
-
|
|
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}
|
|
@@ -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
|
});
|