@fpkit/acss 6.1.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.
Files changed (116) hide show
  1. package/libs/chunk-25KCUE3R.cjs +17 -0
  2. package/libs/chunk-25KCUE3R.cjs.map +1 -0
  3. package/libs/chunk-34NWHFHP.js +10 -0
  4. package/libs/chunk-34NWHFHP.js.map +1 -0
  5. package/libs/{chunk-SQ44OCJ2.js → chunk-6NMLU5FA.js} +2 -2
  6. package/libs/{chunk-GVVCXXKI.cjs → chunk-6YVR4TDM.cjs} +3 -3
  7. package/libs/chunk-DSQ2TUCR.js +7 -0
  8. package/libs/chunk-DSQ2TUCR.js.map +1 -0
  9. package/libs/{chunk-H6A2CUWA.js → chunk-VQTCTLFN.js} +2 -2
  10. package/libs/chunk-ZJ4RUKI2.cjs +14 -0
  11. package/libs/chunk-ZJ4RUKI2.cjs.map +1 -0
  12. package/libs/{chunk-H4JRUNKU.cjs → chunk-ZOPHCNFD.cjs} +3 -3
  13. package/libs/components/button.cjs +3 -3
  14. package/libs/components/button.d.cts +34 -1
  15. package/libs/components/button.d.ts +34 -1
  16. package/libs/components/button.js +1 -1
  17. package/libs/components/buttons/button.css +1 -1
  18. package/libs/components/buttons/button.css.map +1 -1
  19. package/libs/components/buttons/button.min.css +2 -2
  20. package/libs/components/buttons/icon-button.css +1 -0
  21. package/libs/components/buttons/icon-button.css.map +1 -0
  22. package/libs/components/buttons/icon-button.min.css +3 -0
  23. package/libs/components/dialog/dialog.cjs +4 -4
  24. package/libs/components/dialog/dialog.js +2 -2
  25. package/libs/components/icons/icon.d.cts +1 -1
  26. package/libs/components/icons/icon.d.ts +1 -1
  27. package/libs/components/layout/landmarks.css +1 -1
  28. package/libs/components/layout/landmarks.css.map +1 -1
  29. package/libs/components/layout/landmarks.min.css +2 -2
  30. package/libs/components/link/link.css +1 -1
  31. package/libs/components/link/link.min.css +1 -1
  32. package/libs/components/modal.cjs +3 -3
  33. package/libs/components/modal.js +2 -2
  34. package/libs/components/popover/popover.cjs +3 -8
  35. package/libs/components/popover/popover.css +1 -0
  36. package/libs/components/popover/popover.css.map +1 -0
  37. package/libs/components/popover/popover.d.cts +54 -26
  38. package/libs/components/popover/popover.d.ts +54 -26
  39. package/libs/components/popover/popover.js +1 -2
  40. package/libs/components/popover/popover.min.css +3 -0
  41. package/libs/hooks.cjs +3 -6
  42. package/libs/hooks.cjs.map +1 -1
  43. package/libs/hooks.d.cts +30 -10
  44. package/libs/hooks.d.ts +30 -10
  45. package/libs/hooks.js +5 -1
  46. package/libs/hooks.js.map +1 -1
  47. package/libs/{icons-48788561.d.ts → icons-2c09535c.d.ts} +32 -32
  48. package/libs/icons.d.cts +1 -1
  49. package/libs/icons.d.ts +1 -1
  50. package/libs/index.cjs +41 -40
  51. package/libs/index.cjs.map +1 -1
  52. package/libs/index.css +1 -1
  53. package/libs/index.css.map +1 -1
  54. package/libs/index.d.cts +101 -5
  55. package/libs/index.d.ts +101 -5
  56. package/libs/index.js +14 -15
  57. package/libs/index.js.map +1 -1
  58. package/package.json +2 -2
  59. package/src/components/buttons/README.mdx +107 -11
  60. package/src/components/buttons/STYLES.mdx +182 -47
  61. package/src/components/buttons/button.scss +93 -16
  62. package/src/components/buttons/button.stories.tsx +149 -0
  63. package/src/components/buttons/button.test.tsx +12 -0
  64. package/src/components/buttons/button.tsx +50 -6
  65. package/src/components/buttons/icon-button.scss +45 -0
  66. package/src/components/buttons/icon-button.stories.tsx +200 -0
  67. package/src/components/buttons/icon-button.test.tsx +132 -0
  68. package/src/components/buttons/icon-button.tsx +72 -0
  69. package/src/components/form/select.tsx +55 -51
  70. package/src/components/layout/README.mdx +1117 -0
  71. package/src/components/layout/STYLES.mdx +159 -4
  72. package/src/components/layout/fieldset.stories.tsx +387 -0
  73. package/src/components/layout/landmarks.scss +115 -2
  74. package/src/components/layout/landmarks.stories.tsx +2 -6
  75. package/src/components/layout/landmarks.tsx +96 -27
  76. package/src/components/link/link.scss +2 -2
  77. package/src/components/popover/README.mdx +478 -0
  78. package/src/components/popover/STYLES.mdx +389 -0
  79. package/src/components/popover/index.ts +3 -0
  80. package/src/components/popover/popover.scss +249 -0
  81. package/src/components/popover/popover.stories.tsx +315 -15
  82. package/src/components/popover/popover.test.tsx +249 -37
  83. package/src/components/popover/popover.tsx +165 -62
  84. package/src/hooks/popover/popover.tsx +26 -10
  85. package/src/hooks/popover/use-popover.tsx +30 -10
  86. package/src/hooks.ts +5 -0
  87. package/src/index.scss +1 -0
  88. package/src/index.ts +1 -0
  89. package/src/styles/buttons/button.css +78 -16
  90. package/src/styles/buttons/button.css.map +1 -1
  91. package/src/styles/buttons/icon-button.css +32 -0
  92. package/src/styles/buttons/icon-button.css.map +1 -0
  93. package/src/styles/index.css +350 -18
  94. package/src/styles/index.css.map +1 -1
  95. package/src/styles/layout/landmarks.css +83 -0
  96. package/src/styles/layout/landmarks.css.map +1 -1
  97. package/src/styles/link/link.css +2 -2
  98. package/src/styles/popover/popover.css +190 -0
  99. package/src/styles/popover/popover.css.map +1 -0
  100. package/src/types/popover.d.ts +64 -0
  101. package/libs/chunk-4I5MF54P.js +0 -8
  102. package/libs/chunk-4I5MF54P.js.map +0 -1
  103. package/libs/chunk-GCGKYLDG.js +0 -7
  104. package/libs/chunk-GCGKYLDG.js.map +0 -1
  105. package/libs/chunk-NZVSXRTB.cjs +0 -16
  106. package/libs/chunk-NZVSXRTB.cjs.map +0 -1
  107. package/libs/chunk-PDD4N5P5.cjs +0 -10
  108. package/libs/chunk-PDD4N5P5.cjs.map +0 -1
  109. package/libs/chunk-S7NIA6PI.cjs +0 -17
  110. package/libs/chunk-S7NIA6PI.cjs.map +0 -1
  111. package/libs/chunk-X2RDXWH5.js +0 -10
  112. package/libs/chunk-X2RDXWH5.js.map +0 -1
  113. /package/libs/{chunk-SQ44OCJ2.js.map → chunk-6NMLU5FA.js.map} +0 -0
  114. /package/libs/{chunk-GVVCXXKI.cjs.map → chunk-6YVR4TDM.cjs.map} +0 -0
  115. /package/libs/{chunk-H6A2CUWA.js.map → chunk-VQTCTLFN.js.map} +0 -0
  116. /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-pill: 100rem;
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
- --btn-height: calc(var(--btn-fs) * 2.25);
10
- --btn-bg: var(--color-neutral-300);
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-neutral-300));
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
- border-radius: var(--btn-pill, 100rem);
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~="icon"],
122
- .btn-icon {
123
- padding: unset;
124
- height: unset;
125
- --btn-bg: transparent;
126
- min-width: 1.5rem;
127
- min-height: 1.5rem;
128
- text-align: center;
129
- display: inline-flex;
130
- align-items: center;
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
+ };