@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.
- package/libs/chunk-25KCUE3R.cjs +17 -0
- package/libs/chunk-25KCUE3R.cjs.map +1 -0
- package/libs/chunk-34NWHFHP.js +10 -0
- package/libs/chunk-34NWHFHP.js.map +1 -0
- package/libs/{chunk-SQ44OCJ2.js → chunk-6NMLU5FA.js} +2 -2
- package/libs/{chunk-GVVCXXKI.cjs → chunk-6YVR4TDM.cjs} +3 -3
- package/libs/chunk-DSQ2TUCR.js +7 -0
- package/libs/chunk-DSQ2TUCR.js.map +1 -0
- package/libs/{chunk-H6A2CUWA.js → chunk-VQTCTLFN.js} +2 -2
- package/libs/chunk-ZJ4RUKI2.cjs +14 -0
- package/libs/chunk-ZJ4RUKI2.cjs.map +1 -0
- package/libs/{chunk-H4JRUNKU.cjs → chunk-ZOPHCNFD.cjs} +3 -3
- 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/button.cjs +3 -3
- package/libs/components/button.d.cts +34 -1
- package/libs/components/button.d.ts +34 -1
- package/libs/components/button.js +1 -1
- package/libs/components/buttons/button.css +1 -1
- package/libs/components/buttons/button.css.map +1 -1
- package/libs/components/buttons/button.min.css +2 -2
- package/libs/components/buttons/icon-button.css +1 -0
- package/libs/components/buttons/icon-button.css.map +1 -0
- package/libs/components/buttons/icon-button.min.css +3 -0
- package/libs/components/dialog/dialog.cjs +4 -4
- package/libs/components/dialog/dialog.css +1 -1
- package/libs/components/dialog/dialog.css.map +1 -1
- package/libs/components/dialog/dialog.js +2 -2
- package/libs/components/dialog/dialog.min.css +2 -2
- package/libs/components/link/link.css +1 -1
- package/libs/components/link/link.min.css +1 -1
- package/libs/components/modal.cjs +3 -3
- package/libs/components/modal.js +2 -2
- package/libs/components/popover/popover.cjs +3 -8
- package/libs/components/popover/popover.css +1 -0
- package/libs/components/popover/popover.css.map +1 -0
- package/libs/components/popover/popover.d.cts +54 -26
- package/libs/components/popover/popover.d.ts +54 -26
- package/libs/components/popover/popover.js +1 -2
- package/libs/components/popover/popover.min.css +3 -0
- package/libs/hooks.cjs +3 -6
- package/libs/hooks.cjs.map +1 -1
- package/libs/hooks.d.cts +30 -10
- package/libs/hooks.d.ts +30 -10
- package/libs/hooks.js +5 -1
- package/libs/hooks.js.map +1 -1
- package/libs/index.cjs +35 -35
- 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 +65 -3
- package/libs/index.d.ts +65 -3
- package/libs/index.js +9 -10
- package/libs/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/alert/alert.scss +0 -13
- package/src/components/buttons/README.mdx +107 -11
- package/src/components/buttons/STYLES.mdx +182 -47
- package/src/components/buttons/button.scss +93 -16
- package/src/components/buttons/button.stories.tsx +149 -0
- package/src/components/buttons/button.test.tsx +12 -0
- package/src/components/buttons/button.tsx +50 -6
- package/src/components/buttons/icon-button.mdx +204 -0
- package/src/components/buttons/icon-button.scss +83 -0
- package/src/components/buttons/icon-button.stories.tsx +200 -0
- package/src/components/buttons/icon-button.test.tsx +132 -0
- package/src/components/buttons/icon-button.tsx +75 -0
- 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/components/form/select.tsx +55 -51
- package/src/components/link/link.scss +2 -2
- package/src/components/popover/README.mdx +478 -0
- package/src/components/popover/STYLES.mdx +389 -0
- package/src/components/popover/index.ts +3 -0
- package/src/components/popover/popover.scss +249 -0
- package/src/components/popover/popover.stories.tsx +315 -15
- package/src/components/popover/popover.test.tsx +249 -37
- package/src/components/popover/popover.tsx +165 -62
- package/src/hooks/popover/popover.tsx +26 -10
- package/src/hooks/popover/use-popover.tsx +30 -10
- package/src/hooks.ts +5 -0
- package/src/index.scss +1 -0
- package/src/index.ts +1 -0
- 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/button.css +78 -16
- package/src/styles/buttons/button.css.map +1 -1
- package/src/styles/buttons/icon-button.css +71 -0
- package/src/styles/buttons/icon-button.css.map +1 -0
- package/src/styles/dialog/dialog.css +1 -0
- package/src/styles/dialog/dialog.css.map +1 -1
- package/src/styles/index.css +404 -31
- package/src/styles/index.css.map +1 -1
- package/src/styles/link/link.css +2 -2
- package/src/styles/popover/popover.css +190 -0
- package/src/styles/popover/popover.css.map +1 -0
- package/src/types/popover.d.ts +64 -0
- package/libs/chunk-4I5MF54P.js +0 -8
- package/libs/chunk-4I5MF54P.js.map +0 -1
- package/libs/chunk-GCGKYLDG.js +0 -7
- package/libs/chunk-GCGKYLDG.js.map +0 -1
- package/libs/chunk-NZVSXRTB.cjs +0 -16
- package/libs/chunk-NZVSXRTB.cjs.map +0 -1
- package/libs/chunk-PDD4N5P5.cjs +0 -10
- package/libs/chunk-PDD4N5P5.cjs.map +0 -1
- package/libs/chunk-S7NIA6PI.cjs +0 -17
- package/libs/chunk-S7NIA6PI.cjs.map +0 -1
- package/libs/chunk-X2RDXWH5.js +0 -10
- package/libs/chunk-X2RDXWH5.js.map +0 -1
- /package/libs/{chunk-SQ44OCJ2.js.map → chunk-6NMLU5FA.js.map} +0 -0
- /package/libs/{chunk-GVVCXXKI.cjs.map → chunk-6YVR4TDM.cjs.map} +0 -0
- /package/libs/{chunk-H6A2CUWA.js.map → chunk-VQTCTLFN.js.map} +0 -0
- /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
|
|
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}
|