@fpkit/acss 6.2.0 → 6.3.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/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.js +2 -2
- package/libs/components/icons/icon.d.cts +1 -1
- package/libs/components/icons/icon.d.ts +1 -1
- 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/{icons-48788561.d.ts → icons-2c09535c.d.ts} +32 -32
- package/libs/icons.d.cts +1 -1
- package/libs/icons.d.ts +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 +64 -5
- package/libs/index.d.ts +64 -5
- package/libs/index.js +9 -10
- package/libs/index.js.map +1 -1
- package/package.json +2 -2
- 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.scss +45 -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 +72 -0
- 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/styles/buttons/button.css +78 -16
- package/src/styles/buttons/button.css.map +1 -1
- package/src/styles/buttons/icon-button.css +32 -0
- package/src/styles/buttons/icon-button.css.map +1 -0
- package/src/styles/index.css +268 -18
- 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
|
@@ -4,10 +4,14 @@ button {
|
|
|
4
4
|
--btn-size-sm: 0.8125rem;
|
|
5
5
|
--btn-size-md: 0.9375rem;
|
|
6
6
|
--btn-size-lg: 1.125rem;
|
|
7
|
-
--btn-
|
|
7
|
+
--btn-size-xl: 1.375rem;
|
|
8
|
+
--btn-size-2xl: 1.75rem;
|
|
9
|
+
--btn-pill: 100vw;
|
|
8
10
|
--btn-fs: var(--btn-size-md);
|
|
9
|
-
|
|
10
|
-
--btn-
|
|
11
|
+
|
|
12
|
+
--btn-height: calc(var(--btn-fs) * 2.75);
|
|
13
|
+
--btn-bg: var(--color-primary);
|
|
14
|
+
--btn-color: var(--color-text-inverse);
|
|
11
15
|
--btn-width: max-content;
|
|
12
16
|
|
|
13
17
|
font-size: var(--btn-fs);
|
|
@@ -36,7 +40,7 @@ button {
|
|
|
36
40
|
line-height: 0cap;
|
|
37
41
|
|
|
38
42
|
&[type] {
|
|
39
|
-
background-color: var(--btn-bg, var(--color-
|
|
43
|
+
background-color: var(--btn-bg, var(--color-primary));
|
|
40
44
|
--btn-border: solid var(--btn-sg);
|
|
41
45
|
}
|
|
42
46
|
|
|
@@ -93,8 +97,77 @@ button {
|
|
|
93
97
|
|
|
94
98
|
&[data-fp-btn~="pill"],
|
|
95
99
|
&[data-btn~="pill"],
|
|
96
|
-
&[data-style~="pill"]
|
|
97
|
-
|
|
100
|
+
&[data-style~="pill"],
|
|
101
|
+
&.btn-pill {
|
|
102
|
+
border-radius: var(--btn-pill, 100vw);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Color variants — map to semantic tokens from index.css
|
|
106
|
+
&[data-color="primary"] {
|
|
107
|
+
--btn-bg: var(--color-primary);
|
|
108
|
+
--btn-color: var(--color-text-inverse);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
&[data-color="secondary"] {
|
|
112
|
+
--btn-bg: var(--color-secondary);
|
|
113
|
+
--btn-color: var(--color-text-inverse);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
&[data-color="danger"] {
|
|
117
|
+
--btn-bg: var(--color-error);
|
|
118
|
+
--btn-color: var(--color-text-inverse);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
&[data-color="success"] {
|
|
122
|
+
--btn-bg: var(--color-success);
|
|
123
|
+
--btn-color: var(--color-text-inverse);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
&[data-color="warning"] {
|
|
127
|
+
--btn-bg: var(--color-warning);
|
|
128
|
+
--btn-color: var(--color-text-inverse);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Style variants via data-style (variant prop) — mirrors link button patterns
|
|
132
|
+
&[data-style~="outline"] {
|
|
133
|
+
--btn-bg: transparent;
|
|
134
|
+
--btn-color: currentColor;
|
|
135
|
+
--btn-border: 0.125rem solid currentColor;
|
|
136
|
+
|
|
137
|
+
&:is(:hover, :focus) {
|
|
138
|
+
background-color: color-mix(in srgb, currentColor 10%, transparent);
|
|
139
|
+
filter: none;
|
|
140
|
+
outline: 0.025rem solid currentColor;
|
|
141
|
+
outline-offset: 0;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
&[data-style~="text"] {
|
|
146
|
+
--btn-bg: transparent;
|
|
147
|
+
--btn-color: currentColor;
|
|
148
|
+
--btn-border: none;
|
|
149
|
+
--btn-height: unset;
|
|
150
|
+
--btn-width: unset;
|
|
151
|
+
--btn-padding-block: 0.75rem;
|
|
152
|
+
--btn-padding-inline: 0.75rem;
|
|
153
|
+
&:is(:hover, :focus) {
|
|
154
|
+
background-color: color-mix(in srgb, var(--btn-color) 10%, transparent);
|
|
155
|
+
outline: 0.025rem solid var(--btn-color);
|
|
156
|
+
outline-offset: 0;
|
|
157
|
+
filter: none;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
&[data-style~="icon"] {
|
|
162
|
+
padding: unset;
|
|
163
|
+
height: unset;
|
|
164
|
+
--btn-bg: transparent;
|
|
165
|
+
min-width: 1.5rem;
|
|
166
|
+
min-height: 1.5rem;
|
|
167
|
+
text-align: center;
|
|
168
|
+
display: inline-flex;
|
|
169
|
+
align-items: center;
|
|
170
|
+
justify-content: center;
|
|
98
171
|
}
|
|
99
172
|
|
|
100
173
|
&[data-btn~="xs"],
|
|
@@ -118,16 +191,20 @@ button {
|
|
|
118
191
|
--btn-fs: var(--btn-size-lg);
|
|
119
192
|
}
|
|
120
193
|
|
|
121
|
-
&[data-btn~="
|
|
122
|
-
.btn-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
194
|
+
&[data-btn~="xl"],
|
|
195
|
+
.btn-xl {
|
|
196
|
+
--btn-fs: var(--btn-size-xl);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
&[data-btn~="2xl"],
|
|
200
|
+
.btn-2xl {
|
|
201
|
+
--btn-fs: var(--btn-size-2xl);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
&[data-btn~="block"],
|
|
205
|
+
.btn-block {
|
|
206
|
+
--btn-width: 100%;
|
|
207
|
+
display: flex;
|
|
131
208
|
justify-content: center;
|
|
132
209
|
}
|
|
133
210
|
|
|
@@ -14,6 +14,27 @@ const meta = {
|
|
|
14
14
|
children: "Click me",
|
|
15
15
|
onClick: buttonClicked,
|
|
16
16
|
},
|
|
17
|
+
argTypes: {
|
|
18
|
+
size: {
|
|
19
|
+
control: "select",
|
|
20
|
+
options: ["xs", "sm", "md", "lg", "xl", "2xl"],
|
|
21
|
+
description: "Size token — maps to data-btn attribute",
|
|
22
|
+
},
|
|
23
|
+
block: {
|
|
24
|
+
control: "boolean",
|
|
25
|
+
description: "Stretch button to 100% container width — composes with size and variant",
|
|
26
|
+
},
|
|
27
|
+
variant: {
|
|
28
|
+
control: "select",
|
|
29
|
+
options: ["text", "pill", "icon", "outline"],
|
|
30
|
+
description: "Style variant — maps to data-style attribute",
|
|
31
|
+
},
|
|
32
|
+
color: {
|
|
33
|
+
control: "select",
|
|
34
|
+
options: ["primary", "secondary", "danger", "success", "warning"],
|
|
35
|
+
description: "Color variant using semantic design tokens — maps to data-color attribute",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
17
38
|
parameters: {},
|
|
18
39
|
} as Meta;
|
|
19
40
|
|
|
@@ -109,6 +130,132 @@ export const Large: Story = {
|
|
|
109
130
|
},
|
|
110
131
|
} as Story;
|
|
111
132
|
|
|
133
|
+
// --- Size prop stories (typed API instead of raw data-btn) ---
|
|
134
|
+
|
|
135
|
+
export const SizeXS: Story = {
|
|
136
|
+
args: { size: "xs", children: "Extra Small" },
|
|
137
|
+
} as Story;
|
|
138
|
+
|
|
139
|
+
export const SizeSM: Story = {
|
|
140
|
+
args: { size: "sm", children: "Small" },
|
|
141
|
+
} as Story;
|
|
142
|
+
|
|
143
|
+
export const SizeLG: Story = {
|
|
144
|
+
args: { size: "lg", children: "Large" },
|
|
145
|
+
} as Story;
|
|
146
|
+
|
|
147
|
+
export const SizeXL: Story = {
|
|
148
|
+
args: { size: "xl", children: "Extra Large" },
|
|
149
|
+
} as Story;
|
|
150
|
+
|
|
151
|
+
export const Size2XL: Story = {
|
|
152
|
+
args: { size: "2xl", children: "2X Large" },
|
|
153
|
+
} as Story;
|
|
154
|
+
|
|
155
|
+
// --- Block stories ---
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Block button — stretches to 100% container width at the default size.
|
|
159
|
+
*/
|
|
160
|
+
export const Block: Story = {
|
|
161
|
+
args: { block: true, children: "Block Button" },
|
|
162
|
+
} as Story;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Block button composed with size and color variants.
|
|
166
|
+
*/
|
|
167
|
+
export const BlockVariants: Story = {
|
|
168
|
+
render: () => (
|
|
169
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "1rem", maxWidth: "32rem" }}>
|
|
170
|
+
<Button type="button" block size="sm">Block Small</Button>
|
|
171
|
+
<Button type="button" block>Block Default</Button>
|
|
172
|
+
<Button type="button" block size="lg">Block Large</Button>
|
|
173
|
+
<Button type="button" block size="xl" color="primary">Block XL Primary</Button>
|
|
174
|
+
<Button type="button" block color="danger" variant="outline">Block Danger Outline</Button>
|
|
175
|
+
</div>
|
|
176
|
+
),
|
|
177
|
+
} as Story;
|
|
178
|
+
|
|
179
|
+
// --- Variant stories ---
|
|
180
|
+
|
|
181
|
+
export const Outline: Story = {
|
|
182
|
+
args: { variant: "outline", children: "Outline" },
|
|
183
|
+
} as Story;
|
|
184
|
+
|
|
185
|
+
export const Pill: Story = {
|
|
186
|
+
args: { variant: "pill", children: "Pill" },
|
|
187
|
+
} as Story;
|
|
188
|
+
|
|
189
|
+
export const TextVariant: Story = {
|
|
190
|
+
args: { variant: "text", children: "Text Button" },
|
|
191
|
+
} as Story;
|
|
192
|
+
|
|
193
|
+
// --- Color stories ---
|
|
194
|
+
|
|
195
|
+
export const Primary: Story = {
|
|
196
|
+
args: { color: "primary", children: "Primary" },
|
|
197
|
+
} as Story;
|
|
198
|
+
|
|
199
|
+
export const Secondary: Story = {
|
|
200
|
+
args: { color: "secondary", children: "Secondary" },
|
|
201
|
+
} as Story;
|
|
202
|
+
|
|
203
|
+
export const Danger: Story = {
|
|
204
|
+
args: { color: "danger", children: "Danger" },
|
|
205
|
+
} as Story;
|
|
206
|
+
|
|
207
|
+
export const Success: Story = {
|
|
208
|
+
args: { color: "success", children: "Success" },
|
|
209
|
+
} as Story;
|
|
210
|
+
|
|
211
|
+
export const Warning: Story = {
|
|
212
|
+
args: { color: "warning", children: "Warning" },
|
|
213
|
+
} as Story;
|
|
214
|
+
|
|
215
|
+
// --- Combination stories ---
|
|
216
|
+
|
|
217
|
+
export const PrimaryOutline: Story = {
|
|
218
|
+
args: { color: "primary", variant: "outline", children: "Primary Outline" },
|
|
219
|
+
} as Story;
|
|
220
|
+
|
|
221
|
+
export const DangerPill: Story = {
|
|
222
|
+
args: { color: "danger", variant: "pill", children: "Danger Pill" },
|
|
223
|
+
} as Story;
|
|
224
|
+
|
|
225
|
+
export const SuccessOutline: Story = {
|
|
226
|
+
args: { color: "success", variant: "outline", children: "Success Outline" },
|
|
227
|
+
} as Story;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* All color variants side by side.
|
|
231
|
+
*/
|
|
232
|
+
export const AllColors: Story = {
|
|
233
|
+
render: () => (
|
|
234
|
+
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
|
|
235
|
+
<Button type="button" color="primary">Primary</Button>
|
|
236
|
+
<Button type="button" color="secondary">Secondary</Button>
|
|
237
|
+
<Button type="button" color="danger">Danger</Button>
|
|
238
|
+
<Button type="button" color="success">Success</Button>
|
|
239
|
+
<Button type="button" color="warning">Warning</Button>
|
|
240
|
+
</div>
|
|
241
|
+
),
|
|
242
|
+
} as Story;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* All variant styles side by side.
|
|
246
|
+
*/
|
|
247
|
+
export const AllVariants: Story = {
|
|
248
|
+
render: () => (
|
|
249
|
+
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap", alignItems: "center" }}>
|
|
250
|
+
<Button type="button" variant="outline">Outline</Button>
|
|
251
|
+
<Button type="button" variant="pill">Pill</Button>
|
|
252
|
+
<Button type="button" variant="text">Text</Button>
|
|
253
|
+
<Button type="button" color="primary" variant="outline">Primary Outline</Button>
|
|
254
|
+
<Button type="button" color="danger" variant="pill">Danger Pill</Button>
|
|
255
|
+
</div>
|
|
256
|
+
),
|
|
257
|
+
} as Story;
|
|
258
|
+
|
|
112
259
|
export const Custom: Story = {
|
|
113
260
|
args: {
|
|
114
261
|
styles: {
|
|
@@ -377,6 +524,8 @@ export const Customization: Story = {
|
|
|
377
524
|
- \`--btn-size-sm\`: 0.8125rem (13px)
|
|
378
525
|
- \`--btn-size-md\`: 0.9375rem (15px)
|
|
379
526
|
- \`--btn-size-lg\`: 1.125rem (18px)
|
|
527
|
+
- \`--btn-size-xl\`: 1.375rem (22px)
|
|
528
|
+
- \`--btn-size-2xl\`: 1.75rem (28px)
|
|
380
529
|
|
|
381
530
|
### Base Properties
|
|
382
531
|
- \`--btn-padding-inline\`: Horizontal padding (logical property)
|
|
@@ -79,6 +79,18 @@ describe("Button", () => {
|
|
|
79
79
|
expect(handlePointerEvents).toHaveBeenCalledTimes(1);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
it("applies xl size via data-btn attribute", () => {
|
|
83
|
+
render(<Button type="button" size="xl">XL</Button>);
|
|
84
|
+
const button = screen.getByRole("button");
|
|
85
|
+
expect(button).toHaveAttribute("data-btn", "xl");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("applies 2xl size via data-btn attribute", () => {
|
|
89
|
+
render(<Button type="button" size="2xl">2XL</Button>);
|
|
90
|
+
const button = screen.getByRole("button");
|
|
91
|
+
expect(button).toHaveAttribute("data-btn", "2xl");
|
|
92
|
+
});
|
|
93
|
+
|
|
82
94
|
it("it is disabled when disabled is true", () => {
|
|
83
95
|
const handleClick = jest.fn();
|
|
84
96
|
render(
|
|
@@ -11,6 +11,39 @@ export type ButtonProps = Partial<React.ComponentProps<typeof UI>> &
|
|
|
11
11
|
* Required - 'button' | 'submit' | 'reset'
|
|
12
12
|
*/
|
|
13
13
|
type: "button" | "submit" | "reset";
|
|
14
|
+
/**
|
|
15
|
+
* Raw data-btn tokens. Merged with `size` and `block` — all three contribute
|
|
16
|
+
* whitespace-separated words to the final `data-btn` attribute value.
|
|
17
|
+
* @example <Button data-btn="pill">Pill button</Button>
|
|
18
|
+
*/
|
|
19
|
+
"data-btn"?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Size variant - maps to `data-btn` attribute, aligns with SCSS size tokens.
|
|
22
|
+
* Can coexist with a directly passed `data-btn` attribute (values are merged).
|
|
23
|
+
* @example <Button size="sm">Small</Button>
|
|
24
|
+
*/
|
|
25
|
+
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
|
|
26
|
+
/**
|
|
27
|
+
* Style variant - maps to `data-style` attribute.
|
|
28
|
+
* - `"outline"` — transparent bg with border (mirrors link button style)
|
|
29
|
+
* - `"pill"` — fully rounded corners
|
|
30
|
+
* - `"text"` — ghost text button with subtle hover
|
|
31
|
+
* - `"icon"` — square icon-only, no padding
|
|
32
|
+
* @example <Button variant="outline">Bordered</Button>
|
|
33
|
+
*/
|
|
34
|
+
variant?: "text" | "pill" | "icon" | "outline";
|
|
35
|
+
/**
|
|
36
|
+
* Color variant - maps to `data-color` attribute using semantic color tokens.
|
|
37
|
+
* @example <Button color="danger">Delete</Button>
|
|
38
|
+
*/
|
|
39
|
+
color?: "primary" | "secondary" | "danger" | "success" | "warning";
|
|
40
|
+
/**
|
|
41
|
+
* Block layout — stretches the button to 100% of its container width.
|
|
42
|
+
* Composes with `size` and other `data-btn` values.
|
|
43
|
+
* @example <Button block>Full Width</Button>
|
|
44
|
+
* @example <Button size="lg" block>Large Full Width</Button>
|
|
45
|
+
*/
|
|
46
|
+
block?: boolean;
|
|
14
47
|
};
|
|
15
48
|
|
|
16
49
|
/**
|
|
@@ -73,6 +106,10 @@ export const Button = ({
|
|
|
73
106
|
disabled,
|
|
74
107
|
isDisabled,
|
|
75
108
|
classes,
|
|
109
|
+
size,
|
|
110
|
+
variant,
|
|
111
|
+
color,
|
|
112
|
+
block,
|
|
76
113
|
onPointerDown,
|
|
77
114
|
onPointerOver,
|
|
78
115
|
onPointerLeave,
|
|
@@ -96,30 +133,37 @@ export const Button = ({
|
|
|
96
133
|
className: classes,
|
|
97
134
|
// Note: onPointerOver and onPointerLeave are intentionally NOT wrapped
|
|
98
135
|
// to allow hover effects on disabled buttons for visual feedback
|
|
99
|
-
}
|
|
136
|
+
},
|
|
100
137
|
);
|
|
101
138
|
|
|
139
|
+
// Merge size, block, and any explicit data-btn passed by the consumer.
|
|
140
|
+
// SCSS uses [data-btn~="value"] (whitespace word match), so "lg block" targets both.
|
|
141
|
+
const { "data-btn": dataBtnProp, ...restProps } = props;
|
|
142
|
+
const dataBtnValue =
|
|
143
|
+
[size, block ? "block" : undefined, dataBtnProp]
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.join(" ") || undefined;
|
|
146
|
+
|
|
102
147
|
/* Returning a button element with accessible disabled state */
|
|
103
148
|
return (
|
|
104
149
|
<UI
|
|
105
150
|
as="button"
|
|
106
151
|
type={type}
|
|
152
|
+
data-btn={dataBtnValue}
|
|
153
|
+
data-style={variant}
|
|
154
|
+
data-color={color}
|
|
107
155
|
aria-disabled={disabledProps["aria-disabled"]}
|
|
108
156
|
onPointerOver={onPointerOver}
|
|
109
157
|
onPointerLeave={onPointerLeave}
|
|
110
158
|
style={styles}
|
|
111
159
|
className={disabledProps.className}
|
|
160
|
+
{...restProps}
|
|
112
161
|
{...handlers}
|
|
113
|
-
{...props}
|
|
114
162
|
>
|
|
115
163
|
{children}
|
|
116
164
|
</UI>
|
|
117
165
|
);
|
|
118
166
|
};
|
|
119
167
|
|
|
120
|
-
export const IconButton = ({ icon, ...props }: ButtonProps) => {
|
|
121
|
-
return <Button {...props}>{icon}</Button>;
|
|
122
|
-
};
|
|
123
|
-
|
|
124
168
|
export default Button;
|
|
125
169
|
Button.displayName = "Button";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Breakpoint at which the label hides (icon-only on mobile).
|
|
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
|
+
// Color reset for all IconButton instances.
|
|
7
|
+
// background stays transparent (set by button[data-style~="icon"]);
|
|
8
|
+
// color defaults to currentColor so the icon inherits from context.
|
|
9
|
+
// Override via styles={{ "--btn-color": "..." }} when a specific color is needed.
|
|
10
|
+
button[data-icon-btn],
|
|
11
|
+
.icon-btn {
|
|
12
|
+
--btn-color: currentColor;
|
|
13
|
+
}
|
|
14
|
+
|
|
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;
|
|
22
|
+
|
|
23
|
+
[data-icon-label] {
|
|
24
|
+
font-size: var(--btn-fs, 0.875rem);
|
|
25
|
+
line-height: 1;
|
|
26
|
+
white-space: nowrap;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
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);
|
|
42
|
+
white-space: nowrap;
|
|
43
|
+
border: 0;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -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
|
+
};
|