@boostdev/design-system-components 2.0.0 → 2.1.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/README.md +2 -1
- package/dist/client.cjs +52 -50
- package/dist/client.css +536 -523
- package/dist/client.d.cts +3 -1
- package/dist/client.d.ts +3 -1
- package/dist/client.js +52 -50
- package/dist/index.cjs +52 -50
- package/dist/index.css +536 -523
- package/dist/index.d.cts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +52 -50
- package/dist/native/index.cjs +5 -2
- package/dist/native/index.d.cts +3 -1
- package/dist/native/index.d.ts +3 -1
- package/dist/native/index.js +5 -2
- package/dist/web-components/{chunk-3REOIRDW.js → chunk-N3TN6WCH.js} +26 -1
- package/dist/web-components/index.js +1 -1
- package/dist/web-components/interaction/bds-button.d.ts +7 -0
- package/dist/web-components/interaction/bds-button.js +1 -1
- package/package.json +1 -1
- package/src/components/interaction/Button/Button.mdx +81 -36
- package/src/components/interaction/Button/Button.module.css +24 -0
- package/src/components/interaction/Button/Button.native.mdx +31 -12
- package/src/components/interaction/Button/Button.native.spec.tsx +20 -0
- package/src/components/interaction/Button/Button.native.stories.tsx +110 -9
- package/src/components/interaction/Button/Button.native.tsx +13 -4
- package/src/components/interaction/Button/Button.spec.tsx +16 -0
- package/src/components/interaction/Button/Button.stories.tsx +134 -16
- package/src/components/interaction/Button/Button.tsx +4 -0
- package/src/components/layout/IconWrapper/IconWrapper.stories.tsx +46 -14
- package/src/web-components/interaction/BdsButton.mdx +46 -14
- package/src/web-components/interaction/BdsButton.stories.tsx +171 -19
- package/src/web-components/interaction/bds-button.spec.ts +35 -0
- package/src/web-components/interaction/bds-button.ts +28 -1
package/dist/native/index.js
CHANGED
|
@@ -2087,6 +2087,7 @@ function Button({
|
|
|
2087
2087
|
iconStart,
|
|
2088
2088
|
iconEnd,
|
|
2089
2089
|
disabled = false,
|
|
2090
|
+
isIconOnly = false,
|
|
2090
2091
|
onPress,
|
|
2091
2092
|
accessibilityLabel,
|
|
2092
2093
|
style
|
|
@@ -2099,9 +2100,11 @@ function Button({
|
|
|
2099
2100
|
const isOutline = variant === "outline" || variant === "ghost";
|
|
2100
2101
|
const containerStyle = {
|
|
2101
2102
|
height,
|
|
2102
|
-
paddingHorizontal: px,
|
|
2103
|
+
paddingHorizontal: isIconOnly ? spacing.xs : px,
|
|
2103
2104
|
borderRadius: radius,
|
|
2104
2105
|
opacity: disabled ? 0.4 : 1,
|
|
2106
|
+
// Icon-only: square with equal xs padding on both axes.
|
|
2107
|
+
...isIconOnly && { aspectRatio: 1, paddingVertical: spacing.xs },
|
|
2105
2108
|
...variant === "default" && { backgroundColor: primaryColor },
|
|
2106
2109
|
...isOutline && {
|
|
2107
2110
|
backgroundColor: "transparent",
|
|
@@ -2121,7 +2124,7 @@ function Button({
|
|
|
2121
2124
|
style: ({ pressed }) => [styles24.pressable, containerStyle, pressed && { opacity: 0.75 }, style],
|
|
2122
2125
|
children: [
|
|
2123
2126
|
iconStart && /* @__PURE__ */ jsx27(View22, { importantForAccessibility: "no-hide-descendants", accessibilityElementsHidden: true, children: iconStart }),
|
|
2124
|
-
children && /* @__PURE__ */ jsx27(Text20, { style: [styles24.label, { fontSize, color: textColor }], children }),
|
|
2127
|
+
children && (isIconOnly ? children : /* @__PURE__ */ jsx27(Text20, { style: [styles24.label, { fontSize, color: textColor }], children })),
|
|
2125
2128
|
iconEnd && /* @__PURE__ */ jsx27(View22, { importantForAccessibility: "no-hide-descendants", children: iconEnd })
|
|
2126
2129
|
]
|
|
2127
2130
|
}
|
|
@@ -90,6 +90,23 @@ var BdsButton = class extends i2 {
|
|
|
90
90
|
animation: pulse 3s infinite;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/* Icon-only: square (1:1), equal xs padding on both axes — composes with
|
|
94
|
+
any variant/size. box-sizing: border-box keeps the outer box at
|
|
95
|
+
--button_height; without it, all: unset reverts to content-box and
|
|
96
|
+
padding-block would stretch the button past its declared height.
|
|
97
|
+
Slotted child fills the content area (button height minus the two
|
|
98
|
+
padding sides) so SVG icons scale with button size. */
|
|
99
|
+
.button.is-icon-only {
|
|
100
|
+
aspect-ratio: 1;
|
|
101
|
+
box-sizing: border-box;
|
|
102
|
+
padding: var(--bds-space_xs);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
:host([is-icon-only]) ::slotted(*) {
|
|
106
|
+
inline-size: 100%;
|
|
107
|
+
block-size: 100%;
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
/* SVG icon colour */
|
|
94
111
|
.button svg {
|
|
95
112
|
--icon__stroke: currentcolor;
|
|
@@ -117,6 +134,12 @@ var BdsButton = class extends i2 {
|
|
|
117
134
|
transition: var(--bds-animation_transition);
|
|
118
135
|
}
|
|
119
136
|
|
|
137
|
+
/* Icon-only: zero out icon slot margins so icon centres */
|
|
138
|
+
.button.is-icon-only .icon-start.has-content,
|
|
139
|
+
.button.is-icon-only .icon-end.has-content {
|
|
140
|
+
margin-inline: 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
120
143
|
/* Hover icon animation */
|
|
121
144
|
@media (hover: hover) and (pointer: fine) {
|
|
122
145
|
.button:hover .icon-start svg,
|
|
@@ -164,6 +187,7 @@ var BdsButton = class extends i2 {
|
|
|
164
187
|
target: { type: String },
|
|
165
188
|
rel: { type: String },
|
|
166
189
|
hasPulse: { type: Boolean, attribute: "has-pulse", reflect: true },
|
|
190
|
+
isIconOnly: { type: Boolean, attribute: "is-icon-only", reflect: true },
|
|
167
191
|
type: { type: String },
|
|
168
192
|
ariaLabel: { type: String, attribute: "aria-label" },
|
|
169
193
|
// Internal state
|
|
@@ -176,6 +200,7 @@ var BdsButton = class extends i2 {
|
|
|
176
200
|
this.size = "medium";
|
|
177
201
|
this.disabled = false;
|
|
178
202
|
this.hasPulse = false;
|
|
203
|
+
this.isIconOnly = false;
|
|
179
204
|
this.type = "button";
|
|
180
205
|
this.ariaLabel = null;
|
|
181
206
|
this._iconStartFilled = false;
|
|
@@ -191,7 +216,7 @@ var BdsButton = class extends i2 {
|
|
|
191
216
|
}
|
|
192
217
|
}
|
|
193
218
|
get _classes() {
|
|
194
|
-
return `button ${this.variant} ${this.size}${this.hasPulse ? " has-pulse" : ""}`;
|
|
219
|
+
return `button ${this.variant} ${this.size}${this.hasPulse ? " has-pulse" : ""}${this.isIconOnly ? " is-icon-only" : ""}`;
|
|
195
220
|
}
|
|
196
221
|
_handleAnchorClick(e) {
|
|
197
222
|
if (this.disabled) {
|
|
@@ -14,6 +14,7 @@ type ButtonSize = 'small' | 'medium' | 'large';
|
|
|
14
14
|
* target — forwarded to <a> when href is set
|
|
15
15
|
* rel — forwarded to <a> when href is set
|
|
16
16
|
* has-pulse — boolean, enables the pulse ring animation
|
|
17
|
+
* is-icon-only — boolean, forces a 1:1 aspect ratio with minimal xs padding (requires aria-label)
|
|
17
18
|
* type — "button" (default) | "submit" | "reset"
|
|
18
19
|
*
|
|
19
20
|
* Slots:
|
|
@@ -59,6 +60,11 @@ declare class BdsButton extends LitElement {
|
|
|
59
60
|
attribute: string;
|
|
60
61
|
reflect: boolean;
|
|
61
62
|
};
|
|
63
|
+
isIconOnly: {
|
|
64
|
+
type: BooleanConstructor;
|
|
65
|
+
attribute: string;
|
|
66
|
+
reflect: boolean;
|
|
67
|
+
};
|
|
62
68
|
type: {
|
|
63
69
|
type: StringConstructor;
|
|
64
70
|
};
|
|
@@ -80,6 +86,7 @@ declare class BdsButton extends LitElement {
|
|
|
80
86
|
target: string | undefined;
|
|
81
87
|
rel: string | undefined;
|
|
82
88
|
hasPulse: boolean;
|
|
89
|
+
isIconOnly: boolean;
|
|
83
90
|
type: 'button' | 'submit' | 'reset';
|
|
84
91
|
ariaLabel: string | null;
|
|
85
92
|
private _iconStartFilled;
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Meta, Canvas, ArgTypes } from '@storybook/blocks';
|
|
1
|
+
import { Meta, Canvas, ArgTypes, Controls } from '@storybook/blocks';
|
|
2
2
|
import * as Stories from './Button.stories';
|
|
3
3
|
|
|
4
4
|
<Meta of={Stories} />
|
|
@@ -7,62 +7,94 @@ import * as Stories from './Button.stories';
|
|
|
7
7
|
|
|
8
8
|
Primary interactive element for triggering actions. Renders as `<button>` or `<a>` depending on whether `href` is provided.
|
|
9
9
|
|
|
10
|
+
## Playground
|
|
11
|
+
|
|
12
|
+
Use the Controls panel below to try every prop live. `iconStart` and `iconEnd` are backed by a small set of example icons — in your app, pass any `ReactNode` (SVG, icon component, emoji, etc.).
|
|
13
|
+
|
|
14
|
+
<Canvas of={Stories.Playground} />
|
|
15
|
+
<Controls of={Stories.Playground} />
|
|
16
|
+
|
|
10
17
|
## When to use
|
|
11
18
|
- The main action on a page or dialog (`variant="default"`)
|
|
12
19
|
- Secondary or less emphatic actions (`variant="outline"`)
|
|
13
20
|
- Navigation that should look like an action (`href` → renders as `<a>`)
|
|
14
21
|
|
|
15
|
-
> **Form actions:** When using buttons in a form (submit, reset, cancel), always wrap them in a `ButtonContainer` component.
|
|
22
|
+
> **Form actions:** When using buttons in a form (submit, reset, cancel), always wrap them in a `ButtonContainer` component. It provides consistent spacing, alignment, and responsive layout. Use `variant="flow"` for step navigation or `variant="card"` for single-form submissions.
|
|
16
23
|
|
|
17
24
|
## When not to use
|
|
18
|
-
- Navigation to another page when it reads naturally as a link — use `Link` instead
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
## HTML attribute forwarding
|
|
25
|
+
- Navigation to another page when it reads naturally as a link — use `Link` instead.
|
|
26
|
+
- A purely icon-based action without a label — allowed, but always set `aria-label` **and** prefer `isIconOnly` so the shape reflects the intent.
|
|
22
27
|
|
|
23
|
-
|
|
28
|
+
## Variants
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
- **Link-related (on `<a>`)**: `download`, `hreflang`, `ping`, `referrerPolicy` (plus the explicit `target` and `rel` props).
|
|
27
|
-
- **Universal**: `id`, `style`, `tabIndex`, `title`, `role`, all `aria-*` attributes, all `data-*` attributes, and every DOM event handler (`onFocus`, `onBlur`, `onKeyDown`, `onMouseEnter`, etc.).
|
|
28
|
-
|
|
29
|
-
> **Gotcha:** the rendered DOM shows `type="button"` by default because that is the component default — it is **not** forced. Pass `type="submit"` to wire the button to a parent `<form>`'s `onSubmit` handler (preferred over an `onClick` shim, because it preserves Enter-to-submit, native form validation, and assistive-tech semantics).
|
|
30
|
+
Two visual styles:
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
- **`default`** — filled with `--button_color`, text in `--button_on-color`. Hover inverts: background becomes page surface, text becomes `--bdc_color`-on-background. Use for primary actions.
|
|
33
|
+
- **`outline`** — transparent background, border and text both use `--button_color--on-bg` (a text-safe variant of the identity colour). Hover fills with `--button_color`. Use for secondary actions. `'ghost'` remains as a deprecated alias.
|
|
32
34
|
|
|
33
|
-
### All variants & sizes
|
|
34
|
-
<Canvas of={Stories.AllVariants} />
|
|
35
|
-
|
|
36
|
-
### Default
|
|
37
35
|
<Canvas of={Stories.Default} />
|
|
36
|
+
<Canvas of={Stories.Outline} />
|
|
37
|
+
|
|
38
|
+
## Sizes
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
<Canvas of={Stories.Ghost} />
|
|
40
|
+
Size controls both height and horizontal padding. `large` also bumps font-size to the h3 scale.
|
|
41
41
|
|
|
42
|
-
### Sizes
|
|
43
42
|
<Canvas of={Stories.Small} />
|
|
44
43
|
<Canvas of={Stories.Medium} />
|
|
45
44
|
<Canvas of={Stories.Large} />
|
|
46
45
|
|
|
47
|
-
|
|
46
|
+
## All variants × sizes
|
|
47
|
+
|
|
48
|
+
<Canvas of={Stories.AllVariants} />
|
|
49
|
+
|
|
50
|
+
## Icons
|
|
51
|
+
|
|
52
|
+
Pass any `ReactNode` to `iconStart` or `iconEnd`. Icons inherit the button's `currentColor`, so they work on both filled and outline variants automatically.
|
|
53
|
+
|
|
54
|
+
<Canvas of={Stories.WithIconStart} />
|
|
55
|
+
<Canvas of={Stories.WithIconEnd} />
|
|
56
|
+
<Canvas of={Stories.WithBothIcons} />
|
|
57
|
+
|
|
58
|
+
### Icon-only (1:1 aspect ratio)
|
|
59
|
+
|
|
60
|
+
Set `isIconOnly` to render a square button with equal `xs` padding on both axes — composable with any `variant`. The icon sits centred; the button's height (from `size`) determines both width and height. Always pair with `aria-label` — there's no visible text for assistive tech to read.
|
|
61
|
+
|
|
62
|
+
<Canvas of={Stories.IconOnly} />
|
|
63
|
+
<Canvas of={Stories.IconOnlyOutline} />
|
|
64
|
+
<Canvas of={Stories.AllIconOnly} />
|
|
65
|
+
|
|
66
|
+
## Rendered as a link
|
|
67
|
+
|
|
68
|
+
Provide `href` to render as an `<a>` with button styling. `disabled` on a link sets `aria-disabled="true"` and removes it from the tab order (the `href` is also dropped so the link is non-navigable while disabled).
|
|
69
|
+
|
|
48
70
|
<Canvas of={Stories.AsLink} />
|
|
49
71
|
|
|
50
|
-
|
|
51
|
-
|
|
72
|
+
## Pulse (CTA emphasis)
|
|
73
|
+
|
|
74
|
+
`hasPulse` adds a subtle scale-and-glow animation for call-to-action emphasis. Respects `prefers-reduced-motion`.
|
|
52
75
|
|
|
53
|
-
### Pulse (CTA emphasis)
|
|
54
76
|
<Canvas of={Stories.WithPulse} />
|
|
55
77
|
|
|
56
|
-
|
|
78
|
+
## Disabled
|
|
79
|
+
|
|
57
80
|
<Canvas of={Stories.Disabled} />
|
|
58
81
|
|
|
82
|
+
## HTML attribute forwarding
|
|
83
|
+
|
|
84
|
+
`Button` spreads all unrecognised props onto the underlying element (`<button>` when `href` is absent, `<a>` when `href` is provided). Every standard HTML attribute for the target element is accepted as a prop, including:
|
|
85
|
+
|
|
86
|
+
- **Form-related (on `<button>`)**: `type` (default `"button"` — set to `"submit"` for form submission or `"reset"` to reset), `form`, `formAction`, `formMethod`, `formNoValidate`, `formTarget`, `name`, `value`, `autoFocus`.
|
|
87
|
+
- **Link-related (on `<a>`)**: `download`, `hreflang`, `ping`, `referrerPolicy` (plus the explicit `target` and `rel` props).
|
|
88
|
+
- **Universal**: `id`, `style`, `tabIndex`, `title`, `role`, all `aria-*` attributes, all `data-*` attributes, and every DOM event handler (`onFocus`, `onBlur`, `onKeyDown`, `onMouseEnter`, etc.).
|
|
89
|
+
|
|
90
|
+
> **Gotcha:** the rendered DOM shows `type="button"` by default because that is the component default — it is **not** forced. Pass `type="submit"` to wire the button to a parent `<form>`'s `onSubmit` handler (preferred over an `onClick` shim, because it preserves Enter-to-submit, native form validation, and assistive-tech semantics).
|
|
91
|
+
|
|
59
92
|
## Props
|
|
60
93
|
|
|
61
94
|
<ArgTypes of={Stories} />
|
|
62
95
|
|
|
63
96
|
## CSS variables
|
|
64
97
|
|
|
65
|
-
|
|
66
98
|
<table>
|
|
67
99
|
<thead>
|
|
68
100
|
<tr><th>Variable</th><th>Default</th><th>Description</th></tr>
|
|
@@ -70,19 +102,32 @@ Primary interactive element for triggering actions. Renders as `<button>` or `<a
|
|
|
70
102
|
<tbody>
|
|
71
103
|
<tr><td>`--button_color`</td><td>`var(--bds-color_interactive)`</td><td>Identity colour — fills default, outlines outline. Set this to re-theme.</td></tr>
|
|
72
104
|
<tr><td>`--button_on-color`</td><td>`var(--bds-color_on-interactive)`</td><td>Text/icon colour on the button. Pair with `--button_color`.</td></tr>
|
|
73
|
-
<tr><td>`--button_color--on-bg`</td><td>`var(--bds-color_interactive_on-bg)`</td><td>Text-safe variant of `--button_color` for outline variant text/border
|
|
74
|
-
<tr><td>`--button_radius`</td><td>`var(--bds-border_radius--full)`</td><td>Button border radius
|
|
75
|
-
<tr><td>`--
|
|
76
|
-
<tr><td>`--
|
|
77
|
-
<tr><td>`--
|
|
78
|
-
<tr><td>`--
|
|
105
|
+
<tr><td>`--button_color--on-bg`</td><td>`var(--bds-color_interactive_on-bg)`</td><td>Text-safe variant of `--button_color` for outline variant text/border.</td></tr>
|
|
106
|
+
<tr><td>`--button_radius`</td><td>`var(--bds-border_radius--full)`</td><td>Button border radius.</td></tr>
|
|
107
|
+
<tr><td>`--button_height`</td><td>`3em` (medium)</td><td>Button height. Also drives the width when `isIconOnly` is set.</td></tr>
|
|
108
|
+
<tr><td>`--button_bg--hover`</td><td>`var(--bds-color_bg)`</td><td>Default variant background on hover.</td></tr>
|
|
109
|
+
<tr><td>`--button_text--hover`</td><td>`var(--bds-color_interactive_on-bg)`</td><td>Default variant text colour on hover.</td></tr>
|
|
110
|
+
<tr><td>`--button_bg--outline-hover`</td><td>`var(--button_color)`</td><td>Outline variant background on hover.</td></tr>
|
|
111
|
+
<tr><td>`--button_text--outline-hover`</td><td>`var(--button_on-color)`</td><td>Outline variant text colour on hover.</td></tr>
|
|
112
|
+
<tr><td>`--button_pulse-color`</td><td>`var(--button_color)`</td><td>Halo colour for the pulse animation.</td></tr>
|
|
79
113
|
</tbody>
|
|
80
114
|
</table>
|
|
81
115
|
|
|
116
|
+
### Re-theming
|
|
117
|
+
|
|
118
|
+
Change the two identity custom properties to re-theme both variants at once:
|
|
119
|
+
|
|
120
|
+
```css
|
|
121
|
+
.danger-zone .button {
|
|
122
|
+
--button_color: var(--bds-color_error);
|
|
123
|
+
--button_on-color: var(--bds-color_on-error);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
82
126
|
|
|
83
127
|
## Accessibility
|
|
84
128
|
|
|
85
|
-
- Always provide children or `aria-label`
|
|
86
|
-
- Disabled state sets `aria-disabled="true"` and removes the element from the tab order
|
|
87
|
-
- When rendered as `<a>`, `href` must point to a valid destination
|
|
88
|
-
- `hasPulse` animation respects `prefers-reduced-motion
|
|
129
|
+
- Always provide children **or** `aria-label` — icon-only buttons without an accessible name are a WCAG failure.
|
|
130
|
+
- Disabled state sets `aria-disabled="true"` and removes the element from the tab order.
|
|
131
|
+
- When rendered as `<a>`, `href` must point to a valid destination.
|
|
132
|
+
- `hasPulse` animation respects `prefers-reduced-motion`.
|
|
133
|
+
- Minimum hit target is enforced via the size tokens — even `size="small"` at 2.25em font-size satisfies the 44 × 44px guideline at standard root-font sizes.
|
|
@@ -96,6 +96,25 @@
|
|
|
96
96
|
animation: pulse 3s infinite;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/* Icon-only: square (1:1), equal xs padding on both axes — composes with
|
|
100
|
+
any variant/size. `box-sizing: border-box` keeps the outer box at
|
|
101
|
+
`--button_height`; without it, `all: unset` reverts to content-box and
|
|
102
|
+
`padding-block` would stretch the button past its declared height.
|
|
103
|
+
Direct child fills the content area (button height minus the two
|
|
104
|
+
padding sides) so SVG icons scale with button size.
|
|
105
|
+
Nested icon overrides live after .iconStart/.iconEnd below to satisfy
|
|
106
|
+
stylelint's no-descending-specificity rule. */
|
|
107
|
+
.button.--iconOnly {
|
|
108
|
+
aspect-ratio: 1;
|
|
109
|
+
box-sizing: border-box;
|
|
110
|
+
padding: var(--bds-space_xxs);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.button.--iconOnly > * {
|
|
114
|
+
inline-size: 100%;
|
|
115
|
+
block-size: 100%;
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
.button[href] {
|
|
100
119
|
text-decoration: none;
|
|
101
120
|
}
|
|
@@ -122,6 +141,11 @@
|
|
|
122
141
|
transition: var(--bds-animation_transition);
|
|
123
142
|
}
|
|
124
143
|
|
|
144
|
+
.button.--iconOnly .iconStart,
|
|
145
|
+
.button.--iconOnly .iconEnd {
|
|
146
|
+
margin-inline: 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
125
149
|
@media (hover: hover) and (pointer: fine) {
|
|
126
150
|
.button:hover .iconStart svg {
|
|
127
151
|
animation: fadeZoom 2s ease-out infinite;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Meta, Canvas, ArgTypes } from '@storybook/blocks';
|
|
1
|
+
import { Meta, Canvas, ArgTypes, Controls } from '@storybook/blocks';
|
|
2
2
|
import * as Stories from './Button.native.stories';
|
|
3
3
|
|
|
4
4
|
<Meta of={Stories} />
|
|
@@ -29,23 +29,41 @@ export default function App() {
|
|
|
29
29
|
}
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
##
|
|
32
|
+
## Playground
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
<
|
|
34
|
+
<Canvas of={Stories.Playground} />
|
|
35
|
+
<Controls of={Stories.Playground} />
|
|
36
|
+
|
|
37
|
+
## Variants
|
|
36
38
|
|
|
37
|
-
### Default
|
|
38
39
|
<Canvas of={Stories.Default} />
|
|
40
|
+
<Canvas of={Stories.Outline} />
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
<Canvas of={Stories.Ghost} />
|
|
42
|
+
## Sizes
|
|
42
43
|
|
|
43
|
-
### Sizes
|
|
44
44
|
<Canvas of={Stories.Small} />
|
|
45
45
|
<Canvas of={Stories.Medium} />
|
|
46
46
|
<Canvas of={Stories.Large} />
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
## Icons
|
|
49
|
+
|
|
50
|
+
<Canvas of={Stories.WithIconStart} />
|
|
51
|
+
<Canvas of={Stories.WithIconEnd} />
|
|
52
|
+
|
|
53
|
+
### Icon-only (1:1 aspect ratio)
|
|
54
|
+
|
|
55
|
+
Set `isIconOnly` to render a square button with equal `spacing.xs` padding on both axes. Composable with any `variant`. Always pair with `accessibilityLabel` for assistive tech.
|
|
56
|
+
|
|
57
|
+
<Canvas of={Stories.IconOnly} />
|
|
58
|
+
<Canvas of={Stories.IconOnlyOutline} />
|
|
59
|
+
<Canvas of={Stories.AllIconOnly} />
|
|
60
|
+
|
|
61
|
+
## All variants × sizes
|
|
62
|
+
|
|
63
|
+
<Canvas of={Stories.AllVariants} />
|
|
64
|
+
|
|
65
|
+
## Disabled
|
|
66
|
+
|
|
49
67
|
<Canvas of={Stories.Disabled} />
|
|
50
68
|
|
|
51
69
|
## Props
|
|
@@ -56,13 +74,14 @@ export default function App() {
|
|
|
56
74
|
|
|
57
75
|
| Prop | Type | Default | Description |
|
|
58
76
|
|------|------|---------|-------------|
|
|
59
|
-
| `variant` | `"default" \| "
|
|
60
|
-
| `size` | `"small" \| "medium" \| "large"` | `"medium"` | Height and padding
|
|
77
|
+
| `variant` | `"default" \| "outline"` | `"default"` | Visual style (`"ghost"` is a deprecated alias for `"outline"`) |
|
|
78
|
+
| `size` | `"small" \| "medium" \| "large"` | `"medium"` | Height and horizontal padding |
|
|
61
79
|
| `disabled` | boolean | `false` | Prevents interaction, reduces opacity |
|
|
62
80
|
| `onPress` | `() => void` | — | Called when the button is pressed |
|
|
63
81
|
| `iconStart` | `ReactNode` | — | Icon before the label |
|
|
64
82
|
| `iconEnd` | `ReactNode` | — | Icon after the label |
|
|
65
|
-
| `
|
|
83
|
+
| `isIconOnly` | boolean | `false` | 1:1 aspect ratio with equal `spacing.xs` padding on both axes. Requires `accessibilityLabel`. |
|
|
84
|
+
| `accessibilityLabel` | string | — | Screen-reader label (required for icon-only buttons) |
|
|
66
85
|
| `style` | `StyleProp<ViewStyle>` | — | Override container styles |
|
|
67
86
|
|
|
68
87
|
## Accessibility
|
|
@@ -32,4 +32,24 @@ describe('Button (native)', () => {
|
|
|
32
32
|
expect(screen.getByRole('button')).toBeInTheDocument();
|
|
33
33
|
expect(screen.getByTestId('icon-end')).toBeInTheDocument();
|
|
34
34
|
});
|
|
35
|
+
|
|
36
|
+
it('renders icon-only button with accessibilityLabel and icon as children (not wrapped in Text)', () => {
|
|
37
|
+
renderWithTheme(
|
|
38
|
+
<Button isIconOnly accessibilityLabel="Confirm">
|
|
39
|
+
<span data-testid="icon-only">✓</span>
|
|
40
|
+
</Button>
|
|
41
|
+
);
|
|
42
|
+
expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
|
|
43
|
+
expect(screen.getByTestId('icon-only')).toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('renders icon-only with outline variant', () => {
|
|
47
|
+
renderWithTheme(
|
|
48
|
+
<Button isIconOnly variant="outline" accessibilityLabel="Confirm">
|
|
49
|
+
<span data-testid="icon-only-outline">✓</span>
|
|
50
|
+
</Button>
|
|
51
|
+
);
|
|
52
|
+
expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
|
|
53
|
+
expect(screen.getByTestId('icon-only-outline')).toBeInTheDocument();
|
|
54
|
+
});
|
|
35
55
|
});
|
|
@@ -1,34 +1,113 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
2
3
|
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
4
|
import { ThemeProvider } from '../../../native/ThemeContext';
|
|
4
|
-
import { Button } from './Button.native';
|
|
5
|
+
import { Button, type ButtonProps } from './Button.native';
|
|
5
6
|
|
|
6
|
-
const
|
|
7
|
+
const CheckIcon = (
|
|
8
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
|
9
|
+
<path d="M12 0a12 12 0 1 0 12 12A12 12 0 0 0 12 0Zm6.93 8.2-6.85 9.29a1 1 0 0 1-1.43.19l-4.89-3.91a1 1 0 0 1-.15-1.41A1 1 0 0 1 7 12.21l4.08 3.26L17.32 7a1 1 0 0 1 1.39-.21 1 1 0 0 1 .22 1.41Z" fill="currentColor"/>
|
|
10
|
+
</svg>
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const ArrowRight = <span aria-hidden>→</span>;
|
|
14
|
+
const ArrowLeft = <span aria-hidden>←</span>;
|
|
15
|
+
|
|
16
|
+
const iconMap = {
|
|
17
|
+
none: undefined,
|
|
18
|
+
check: CheckIcon,
|
|
19
|
+
arrowRight: ArrowRight,
|
|
20
|
+
arrowLeft: ArrowLeft,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type IconKey = keyof typeof iconMap;
|
|
24
|
+
|
|
25
|
+
type ButtonStoryArgs = Omit<ButtonProps, 'iconStart' | 'iconEnd' | 'children'> & {
|
|
26
|
+
iconStart?: IconKey;
|
|
27
|
+
iconEnd?: IconKey;
|
|
28
|
+
children?: ReactNode;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const meta: Meta<ButtonStoryArgs> = {
|
|
7
32
|
tags: ['!stable', 'alpha'],
|
|
8
33
|
title: 'React Native/Interaction/Button',
|
|
9
34
|
component: Button,
|
|
10
35
|
decorators: [(Story) => <ThemeProvider colorScheme="light"><Story /></ThemeProvider>],
|
|
11
36
|
parameters: { layout: 'centered' },
|
|
12
37
|
argTypes: {
|
|
13
|
-
variant: {
|
|
14
|
-
|
|
38
|
+
variant: {
|
|
39
|
+
control: 'select',
|
|
40
|
+
options: ['default', 'outline'],
|
|
41
|
+
description: 'Visual style. `default` is filled; `outline` is transparent with a border.',
|
|
42
|
+
table: { defaultValue: { summary: 'default' } },
|
|
43
|
+
},
|
|
44
|
+
size: {
|
|
45
|
+
control: 'select',
|
|
46
|
+
options: ['small', 'medium', 'large'],
|
|
47
|
+
description: 'Height + horizontal padding.',
|
|
48
|
+
table: { defaultValue: { summary: 'medium' } },
|
|
49
|
+
},
|
|
50
|
+
iconStart: {
|
|
51
|
+
control: 'select',
|
|
52
|
+
options: Object.keys(iconMap) as IconKey[],
|
|
53
|
+
mapping: iconMap,
|
|
54
|
+
description: 'Icon rendered before the label.',
|
|
55
|
+
},
|
|
56
|
+
iconEnd: {
|
|
57
|
+
control: 'select',
|
|
58
|
+
options: Object.keys(iconMap) as IconKey[],
|
|
59
|
+
mapping: iconMap,
|
|
60
|
+
description: 'Icon rendered after the label.',
|
|
61
|
+
},
|
|
62
|
+
isIconOnly: {
|
|
63
|
+
control: 'boolean',
|
|
64
|
+
description: 'Forces 1:1 aspect ratio with minimal xs padding. Composable with any `variant`. Requires `accessibilityLabel`.',
|
|
65
|
+
table: { defaultValue: { summary: 'false' } },
|
|
66
|
+
},
|
|
67
|
+
disabled: { control: 'boolean' },
|
|
15
68
|
onPress: { action: 'pressed' },
|
|
16
69
|
},
|
|
17
|
-
|
|
70
|
+
args: { variant: 'default', size: 'medium' },
|
|
71
|
+
};
|
|
18
72
|
|
|
19
73
|
export default meta;
|
|
20
|
-
type Story = StoryObj<
|
|
74
|
+
type Story = StoryObj<ButtonStoryArgs>;
|
|
21
75
|
|
|
22
|
-
export const
|
|
23
|
-
|
|
76
|
+
export const Playground: Story = {
|
|
77
|
+
args: { children: 'Button', iconStart: 'none', iconEnd: 'none' },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const Default: Story = { args: { children: 'Default' } };
|
|
81
|
+
export const Outline: Story = { args: { children: 'Outline', variant: 'outline' } };
|
|
24
82
|
export const Small: Story = { args: { children: 'Small', size: 'small' } };
|
|
25
83
|
export const Medium: Story = { args: { children: 'Medium', size: 'medium' } };
|
|
26
84
|
export const Large: Story = { args: { children: 'Large', size: 'large' } };
|
|
27
85
|
export const Disabled: Story = { args: { children: 'Disabled', disabled: true } };
|
|
86
|
+
|
|
87
|
+
export const WithIconStart: Story = { args: { children: 'Confirm', iconStart: 'check' } };
|
|
88
|
+
export const WithIconEnd: Story = { args: { children: 'Continue', iconEnd: 'arrowRight' } };
|
|
89
|
+
|
|
90
|
+
export const IconOnly: Story = {
|
|
91
|
+
args: {
|
|
92
|
+
isIconOnly: true,
|
|
93
|
+
accessibilityLabel: 'Confirm',
|
|
94
|
+
children: CheckIcon,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const IconOnlyOutline: Story = {
|
|
99
|
+
args: {
|
|
100
|
+
variant: 'outline',
|
|
101
|
+
isIconOnly: true,
|
|
102
|
+
accessibilityLabel: 'Confirm',
|
|
103
|
+
children: CheckIcon,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
28
107
|
export const AllVariants: Story = {
|
|
29
108
|
render: () => (
|
|
30
109
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
31
|
-
{(['default', '
|
|
110
|
+
{(['default', 'outline'] as const).map(v => (
|
|
32
111
|
<div key={v} style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
|
33
112
|
{(['small', 'medium', 'large'] as const).map(s => (
|
|
34
113
|
<Button key={s} variant={v} size={s}>{v} {s}</Button>
|
|
@@ -38,3 +117,25 @@ export const AllVariants: Story = {
|
|
|
38
117
|
</div>
|
|
39
118
|
),
|
|
40
119
|
};
|
|
120
|
+
|
|
121
|
+
export const AllIconOnly: Story = {
|
|
122
|
+
render: () => (
|
|
123
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
124
|
+
{(['default', 'outline'] as const).map(v => (
|
|
125
|
+
<div key={v} style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
|
126
|
+
{(['small', 'medium', 'large'] as const).map(s => (
|
|
127
|
+
<Button
|
|
128
|
+
key={s}
|
|
129
|
+
variant={v}
|
|
130
|
+
size={s}
|
|
131
|
+
isIconOnly
|
|
132
|
+
accessibilityLabel={`${v} ${s}`}
|
|
133
|
+
>
|
|
134
|
+
{CheckIcon}
|
|
135
|
+
</Button>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
))}
|
|
139
|
+
</div>
|
|
140
|
+
),
|
|
141
|
+
};
|
|
@@ -13,6 +13,8 @@ export interface ButtonProps {
|
|
|
13
13
|
iconStart?: ReactNode;
|
|
14
14
|
iconEnd?: ReactNode;
|
|
15
15
|
disabled?: boolean;
|
|
16
|
+
/** Forces a 1:1 aspect ratio with minimal padding for icon-only buttons. Requires `accessibilityLabel`. */
|
|
17
|
+
isIconOnly?: boolean;
|
|
16
18
|
onPress?: () => void;
|
|
17
19
|
accessibilityLabel?: string;
|
|
18
20
|
style?: StyleProp<ViewStyle>;
|
|
@@ -46,6 +48,7 @@ export function Button({
|
|
|
46
48
|
iconStart,
|
|
47
49
|
iconEnd,
|
|
48
50
|
disabled = false,
|
|
51
|
+
isIconOnly = false,
|
|
49
52
|
onPress,
|
|
50
53
|
accessibilityLabel,
|
|
51
54
|
style,
|
|
@@ -62,9 +65,11 @@ export function Button({
|
|
|
62
65
|
const isOutline = variant === 'outline' || variant === 'ghost';
|
|
63
66
|
const containerStyle: ViewStyle = {
|
|
64
67
|
height,
|
|
65
|
-
paddingHorizontal: px,
|
|
68
|
+
paddingHorizontal: isIconOnly ? spacing.xs : px,
|
|
66
69
|
borderRadius: radius,
|
|
67
70
|
opacity: disabled ? 0.4 : 1,
|
|
71
|
+
// Icon-only: square with equal xs padding on both axes.
|
|
72
|
+
...(isIconOnly && { aspectRatio: 1, paddingVertical: spacing.xs }),
|
|
68
73
|
...(variant === 'default' && { backgroundColor: primaryColor }),
|
|
69
74
|
...(isOutline && {
|
|
70
75
|
backgroundColor: 'transparent',
|
|
@@ -86,9 +91,13 @@ export function Button({
|
|
|
86
91
|
>
|
|
87
92
|
{iconStart && <View importantForAccessibility="no-hide-descendants" accessibilityElementsHidden={true}>{iconStart}</View>}
|
|
88
93
|
{children && (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
isIconOnly
|
|
95
|
+
? children
|
|
96
|
+
: (
|
|
97
|
+
<Text style={[styles.label, { fontSize, color: textColor }]}>
|
|
98
|
+
{children}
|
|
99
|
+
</Text>
|
|
100
|
+
)
|
|
92
101
|
)}
|
|
93
102
|
{iconEnd && <View importantForAccessibility="no-hide-descendants">{iconEnd}</View>}
|
|
94
103
|
</Pressable>
|