@multiplatform.one/forms 7.27.0 → 7.28.1
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/dist/cjs/Button/index.cjs +21 -16
- package/dist/cjs/Button/index.native.js +29 -17
- package/dist/cjs/Button/index.native.js.map +1 -1
- package/dist/esm/Button/index.mjs +22 -17
- package/dist/esm/Button/index.mjs.map +1 -1
- package/dist/esm/Button/index.native.js +30 -18
- package/dist/esm/Button/index.native.js.map +1 -1
- package/dist/jsx/Button/index.mjs +22 -17
- package/dist/jsx/Button/index.mjs.map +1 -1
- package/dist/jsx/Button/index.native.js +29 -17
- package/dist/jsx/Button/index.native.js.map +1 -1
- package/package.json +8 -8
- package/src/Button/Button.disabledReasonInk.spec.tsx +134 -0
- package/src/Button/Button.disabledWash.spec.tsx +206 -0
- package/src/Button/Button.stories.tsx +22 -6
- package/src/Button/index.tsx +52 -24
- package/types/Button/index.d.ts.map +1 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* MPO-431 / T-HELPER: a disabled Button's reason is copy on the host surface,
|
|
5
|
+
* not button chrome. Rendered inside the Button's intent <Theme>, its
|
|
6
|
+
* `$color11` resolved on the intent's on-fill ramp and "Enter a title first"
|
|
7
|
+
* painted 1.17:1 on the white page (Screens/Todos, light).
|
|
8
|
+
*
|
|
9
|
+
* jsdom does not cascade Tamagui CSS, so ink is resolved the way the browser
|
|
10
|
+
* resolves it: the token on the text node's `_col-*` class, looked up in the
|
|
11
|
+
* theme scope that encloses that node. Moving the reason back inside the
|
|
12
|
+
* intent scope changes the looked-up ramp and fails the contrast arm.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { render, cleanup, screen } from "@testing-library/react";
|
|
16
|
+
import {
|
|
17
|
+
aaTextContrastRatio,
|
|
18
|
+
createDefaultThemeConfig,
|
|
19
|
+
createThemesBuilder,
|
|
20
|
+
defaultAccentTheme,
|
|
21
|
+
defaultBaseTheme,
|
|
22
|
+
defaultBuilderOptions,
|
|
23
|
+
measureContrast,
|
|
24
|
+
} from "@multiplatform.one/theme";
|
|
25
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
26
|
+
import { TamaguiProvider, YStack } from "tamagui";
|
|
27
|
+
import { Button, type ButtonProps } from "./index";
|
|
28
|
+
|
|
29
|
+
afterEach(cleanup);
|
|
30
|
+
|
|
31
|
+
const houseConfig = createDefaultThemeConfig();
|
|
32
|
+
const houseThemes = createThemesBuilder(
|
|
33
|
+
defaultBaseTheme,
|
|
34
|
+
defaultAccentTheme,
|
|
35
|
+
defaultBuilderOptions,
|
|
36
|
+
).themes() as Record<string, Record<string, string>>;
|
|
37
|
+
|
|
38
|
+
const SCHEMES = ["light", "dark"] as const;
|
|
39
|
+
const PLACEMENTS = ["below", "inline"] as const;
|
|
40
|
+
const PAGE_SURFACES = ["background", "color1", "color2"] as const;
|
|
41
|
+
|
|
42
|
+
/** Every channel that re-themes the frame: the four intent props, and `theme`. */
|
|
43
|
+
const CHANNELS: { name: string; themeName: string; props: Partial<ButtonProps> }[] = [
|
|
44
|
+
{ name: "accent", themeName: "accent", props: { accent: true } },
|
|
45
|
+
{ name: "error", themeName: "error", props: { error: true } },
|
|
46
|
+
{ name: "warning", themeName: "warning", props: { warning: true } },
|
|
47
|
+
{ name: "success", themeName: "success", props: { success: true } },
|
|
48
|
+
{ name: 'theme="accent"', themeName: "accent", props: { theme: "accent" } },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function houseRender(ui: React.ReactElement, scheme: "light" | "dark") {
|
|
52
|
+
return render(
|
|
53
|
+
<TamaguiProvider config={houseConfig.tamagui} defaultTheme={scheme} disableInjectCSS>
|
|
54
|
+
<YStack backgroundColor="$background">{ui}</YStack>
|
|
55
|
+
</TamaguiProvider>,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scopeThemeKey(node: HTMLElement, scheme: string): string {
|
|
60
|
+
const names: string[] = [];
|
|
61
|
+
for (let el: HTMLElement | null = node; el; el = el.parentElement) {
|
|
62
|
+
if (!el.classList.contains("is_Theme")) continue;
|
|
63
|
+
for (const cls of Array.from(el.classList)) {
|
|
64
|
+
if (!cls.startsWith("t_") || cls === "t_sub_theme") continue;
|
|
65
|
+
const name = cls.slice(2);
|
|
66
|
+
if (name !== "light" && name !== "dark") names.unshift(name);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [scheme, ...names].join("_");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolvedInk(
|
|
73
|
+
node: HTMLElement,
|
|
74
|
+
scheme: string,
|
|
75
|
+
): { key: string; token: string; ink: string } {
|
|
76
|
+
const token = node.className.match(/(?:^|\s)_col-(\w+)(?:\s|$)/)?.[1];
|
|
77
|
+
expect(token, `reason carries a colour token class: ${node.className}`).toBeTruthy();
|
|
78
|
+
const key = scopeThemeKey(node, scheme);
|
|
79
|
+
const ink = houseThemes[key]?.[token!];
|
|
80
|
+
expect(ink, `${key}.${token} resolves in the house themes`).toBeTruthy();
|
|
81
|
+
return { key, token: token!, ink: ink! };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("Button disabledReason ink (MPO-431 / T-HELPER)", () => {
|
|
85
|
+
for (const scheme of SCHEMES) {
|
|
86
|
+
for (const placement of PLACEMENTS) {
|
|
87
|
+
it.each(CHANNELS)(
|
|
88
|
+
`${scheme} ${placement}: the $name reason clears 4.5:1 on every page surface`,
|
|
89
|
+
({ name, props }) => {
|
|
90
|
+
const reason = `Reason ${name}`;
|
|
91
|
+
houseRender(
|
|
92
|
+
<Button {...props} disabled disabledReason={reason} disabledReasonPlacement={placement}>
|
|
93
|
+
Add
|
|
94
|
+
</Button>,
|
|
95
|
+
scheme,
|
|
96
|
+
);
|
|
97
|
+
const { key, token, ink } = resolvedInk(screen.getByText(reason), scheme);
|
|
98
|
+
for (const surface of PAGE_SURFACES) {
|
|
99
|
+
const ground = houseThemes[scheme][surface];
|
|
100
|
+
const label = `${key}.${token} ${ink} on ${scheme}.${surface} ${ground}`;
|
|
101
|
+
expect(
|
|
102
|
+
measureContrast({ foreground: ink, background: ground, label }).ratio,
|
|
103
|
+
label,
|
|
104
|
+
).toBeGreaterThanOrEqual(aaTextContrastRatio);
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
it.each(CHANNELS)(
|
|
110
|
+
`${scheme} ${placement}: the $name theme scopes the button, never its reason`,
|
|
111
|
+
({ name, themeName, props }) => {
|
|
112
|
+
const reason = `Reason ${name}`;
|
|
113
|
+
houseRender(
|
|
114
|
+
<Button {...props} disabled disabledReason={reason} disabledReasonPlacement={placement}>
|
|
115
|
+
Add
|
|
116
|
+
</Button>,
|
|
117
|
+
scheme,
|
|
118
|
+
);
|
|
119
|
+
const button = screen.getByRole("button");
|
|
120
|
+
expect(button.getAttribute("aria-disabled")).toBe("true");
|
|
121
|
+
expect(
|
|
122
|
+
button.closest(`.t_${themeName}`),
|
|
123
|
+
"the frame keeps its intent theme",
|
|
124
|
+
).toBeTruthy();
|
|
125
|
+
expect(
|
|
126
|
+
screen.getByText(reason).closest(`.t_${themeName}`),
|
|
127
|
+
"the reason resolves on the host surface",
|
|
128
|
+
).toBeNull();
|
|
129
|
+
expect(scopeThemeKey(screen.getByText(reason), scheme)).toBe(scheme);
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* MPO-433 / LC-40 DISABLED-VISIBLE: every intent Button reads disabled.
|
|
5
|
+
*
|
|
6
|
+
* The keepLabel wash paints the ACTIVE ramp's `$color3`/`$color6` with the
|
|
7
|
+
* label on `$color11`. Error, warning and success parents are Radix ramps, so
|
|
8
|
+
* step 3 is a pale wash far from their step-11 Button fill. The accent slot is
|
|
9
|
+
* solid by palette shape (defaults/accent.ts): step 3 sat one lightness step
|
|
10
|
+
* off the step-4 fill, measured ΔE 8.2 light and 2.5 dark, and the button
|
|
11
|
+
* never read disabled. The sweep also caught light warning and success labels
|
|
12
|
+
* at 4.48 and 4.27:1 on their own wash, since step 11 is only AA on steps 1-2.
|
|
13
|
+
*
|
|
14
|
+
* jsdom cascades no Tamagui CSS, so this reads what the frame asks for: the
|
|
15
|
+
* atomic class, the injected rule behind it, and for a `var(--token)` rule the
|
|
16
|
+
* house theme table of the scope that paints it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
20
|
+
import {
|
|
21
|
+
aaTextContrastRatio,
|
|
22
|
+
createDefaultThemeConfig,
|
|
23
|
+
createThemesBuilder,
|
|
24
|
+
defaultAccentTheme,
|
|
25
|
+
defaultBaseTheme,
|
|
26
|
+
defaultBuilderOptions,
|
|
27
|
+
measureContrast,
|
|
28
|
+
normalizeToHex,
|
|
29
|
+
Preset,
|
|
30
|
+
} from "@multiplatform.one/theme";
|
|
31
|
+
import type { ReactElement } from "react";
|
|
32
|
+
import { TamaguiProvider, YStack } from "tamagui";
|
|
33
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
34
|
+
import { Button, type ButtonProps } from "./index";
|
|
35
|
+
|
|
36
|
+
afterEach(cleanup);
|
|
37
|
+
|
|
38
|
+
const houseConfig = createDefaultThemeConfig();
|
|
39
|
+
const houseThemes = createThemesBuilder(
|
|
40
|
+
defaultBaseTheme,
|
|
41
|
+
defaultAccentTheme,
|
|
42
|
+
defaultBuilderOptions,
|
|
43
|
+
).themes() as Record<string, Record<string, string>>;
|
|
44
|
+
|
|
45
|
+
const SCHEMES = ["light", "dark"] as const;
|
|
46
|
+
const DISABLED_STYLES = ["keepLabel", "dimWhole"] as const;
|
|
47
|
+
const VARIANTS: { name: string; props: Partial<ButtonProps> }[] = [
|
|
48
|
+
{ name: "accent", props: { accent: true } },
|
|
49
|
+
{ name: 'theme="accent"', props: { theme: "accent" } },
|
|
50
|
+
{ name: "error", props: { error: true } },
|
|
51
|
+
{ name: "warning", props: { warning: true } },
|
|
52
|
+
{ name: "success", props: { success: true } },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* LC-40's fill threshold (a HYBRID dial). CIE76 ΔE between the enabled fill
|
|
57
|
+
* and the disabled fill; the house intents land 57-92 once washed, the
|
|
58
|
+
* accent measured 2.5-8.2 before the fix.
|
|
59
|
+
*/
|
|
60
|
+
const MIN_FILL_DELTA_E = 20;
|
|
61
|
+
const MIN_OPACITY_DELTA = 0.3;
|
|
62
|
+
|
|
63
|
+
type Scheme = (typeof SCHEMES)[number];
|
|
64
|
+
|
|
65
|
+
function houseRender(ui: ReactElement, scheme: Scheme, disabledStyle: string) {
|
|
66
|
+
return render(
|
|
67
|
+
<TamaguiProvider config={houseConfig.tamagui} defaultTheme={scheme} disableInjectCSS>
|
|
68
|
+
<Preset overrides={{ disabledStyle: disabledStyle as "keepLabel" | "dimWhole" }}>
|
|
69
|
+
<YStack backgroundColor="$background">{ui}</YStack>
|
|
70
|
+
</Preset>
|
|
71
|
+
</TamaguiProvider>,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function injectedRuleValue(className: string, property: string): string {
|
|
76
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
77
|
+
for (const rule of Array.from(sheet.cssRules)) {
|
|
78
|
+
if (!(rule instanceof CSSStyleRule)) continue;
|
|
79
|
+
if (!rule.selectorText.endsWith(`.${className}`)) continue;
|
|
80
|
+
const value = rule.style.getPropertyValue(property);
|
|
81
|
+
if (value) return value.trim();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`no injected ${property} rule for .${className}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The innermost Tamagui theme scope painting `el`, e.g. `accent_Button`. */
|
|
88
|
+
function scopeOf(el: Element): string | null {
|
|
89
|
+
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
90
|
+
const names = Array.from(node.classList)
|
|
91
|
+
.filter((c) => c.startsWith("t_") && c !== "t_sub_theme")
|
|
92
|
+
.map((c) => c.slice(2))
|
|
93
|
+
.filter((c) => c !== "light" && c !== "dark");
|
|
94
|
+
if (names.length) return names.sort((a, b) => b.length - a.length)[0];
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveInScope(value: string, scheme: Scheme, scope: string | null): string {
|
|
100
|
+
const token = value.match(/^var\(--([\w-]+)\)$/)?.[1];
|
|
101
|
+
if (!token) return value;
|
|
102
|
+
const chain: string[] = [];
|
|
103
|
+
if (scope) {
|
|
104
|
+
const parts = scope.split("_");
|
|
105
|
+
for (let i = parts.length; i > 0; i--) chain.push(`${scheme}_${parts.slice(0, i).join("_")}`);
|
|
106
|
+
}
|
|
107
|
+
chain.push(scheme);
|
|
108
|
+
for (const name of chain) {
|
|
109
|
+
const hit = houseThemes[name]?.[token];
|
|
110
|
+
if (hit) return hit;
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`--${token} resolves in none of ${chain.join(", ")}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** What `el` paints for one atomic property: `_bg-` → background-color. */
|
|
116
|
+
function painted(el: Element, prefix: string, property: string, scheme: Scheme): string {
|
|
117
|
+
const cls = Array.from(el.classList).find(
|
|
118
|
+
(c) => c.startsWith(prefix) && !/^_[a-z]+-0(hover|active|focus|press)/.test(c),
|
|
119
|
+
);
|
|
120
|
+
if (!cls) throw new Error(`${el.tagName} carries no ${prefix} class`);
|
|
121
|
+
return resolveInScope(injectedRuleValue(cls, property), scheme, scopeOf(el));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function opacityOf(el: Element): number {
|
|
125
|
+
const cls = Array.from(el.classList).find((c) => c.startsWith("_o-"));
|
|
126
|
+
return cls ? Number(injectedRuleValue(cls, "opacity")) : 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function toLab(color: string): [number, number, number] {
|
|
130
|
+
const hex = normalizeToHex(color);
|
|
131
|
+
if (!hex) throw new Error(`not an opaque color: ${color}`);
|
|
132
|
+
const lin = (i: number) => {
|
|
133
|
+
const v = Number.parseInt(hex.slice(i, i + 2), 16) / 255;
|
|
134
|
+
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
135
|
+
};
|
|
136
|
+
const [r, g, b] = [lin(1), lin(3), lin(5)];
|
|
137
|
+
const f = (t: number) => (t > 216 / 24389 ? Math.cbrt(t) : ((24389 / 27) * t + 16) / 116);
|
|
138
|
+
const x = f((0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047);
|
|
139
|
+
const y = f(0.2126 * r + 0.7152 * g + 0.0722 * b);
|
|
140
|
+
const z = f((0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883);
|
|
141
|
+
return [116 * y - 16, 500 * (x - y), 200 * (y - z)];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function deltaE(a: string, b: string): number {
|
|
145
|
+
const [p, q] = [toLab(a), toLab(b)];
|
|
146
|
+
return Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function measure(scheme: Scheme, disabledStyle: string, props: Partial<ButtonProps>) {
|
|
150
|
+
houseRender(
|
|
151
|
+
<>
|
|
152
|
+
<Button {...props}>Enabled</Button>
|
|
153
|
+
<Button {...props} disabled>
|
|
154
|
+
Disabled
|
|
155
|
+
</Button>
|
|
156
|
+
</>,
|
|
157
|
+
scheme,
|
|
158
|
+
disabledStyle,
|
|
159
|
+
);
|
|
160
|
+
const enabledLabel = screen.getByText("Enabled");
|
|
161
|
+
const disabledLabel = screen.getByText("Disabled");
|
|
162
|
+
const enabled = enabledLabel.closest("[role=button]") as Element;
|
|
163
|
+
const disabled = disabledLabel.closest("[role=button]") as Element;
|
|
164
|
+
const enabledFill = painted(enabled, "_bg-", "background-color", scheme);
|
|
165
|
+
const disabledFill = painted(disabled, "_bg-", "background-color", scheme);
|
|
166
|
+
const labelInk = painted(disabledLabel, "_col-", "color", scheme);
|
|
167
|
+
return {
|
|
168
|
+
enabledFill,
|
|
169
|
+
disabledFill,
|
|
170
|
+
labelInk,
|
|
171
|
+
fillDeltaE: deltaE(enabledFill, disabledFill),
|
|
172
|
+
opacityDelta: opacityOf(enabled) - opacityOf(disabled),
|
|
173
|
+
labelContrast: measureContrast({ foreground: labelInk, background: disabledFill }).ratio,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
describe("MPO-433 LC-40: a disabled intent Button differs from its enabled twin", () => {
|
|
178
|
+
for (const scheme of SCHEMES) {
|
|
179
|
+
for (const disabledStyle of DISABLED_STYLES) {
|
|
180
|
+
for (const variant of VARIANTS) {
|
|
181
|
+
it(`${scheme} ${disabledStyle} ${variant.name}: fill ΔE ≥ ${MIN_FILL_DELTA_E} or opacity delta ≥ ${MIN_OPACITY_DELTA}`, () => {
|
|
182
|
+
const m = measure(scheme, disabledStyle, variant.props);
|
|
183
|
+
const detail = `${m.enabledFill} -> ${m.disabledFill}: ΔE ${m.fillDeltaE.toFixed(1)}, opacity delta ${m.opacityDelta}`;
|
|
184
|
+
expect(
|
|
185
|
+
m.fillDeltaE >= MIN_FILL_DELTA_E || m.opacityDelta >= MIN_OPACITY_DELTA,
|
|
186
|
+
detail,
|
|
187
|
+
).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("MPO-433 LC-40: keepLabel keeps the disabled label ≥ 4.5:1 on its own fill", () => {
|
|
195
|
+
for (const scheme of SCHEMES) {
|
|
196
|
+
for (const variant of VARIANTS) {
|
|
197
|
+
it(`${scheme} ${variant.name}`, () => {
|
|
198
|
+
const m = measure(scheme, "keepLabel", variant.props);
|
|
199
|
+
expect(
|
|
200
|
+
m.labelContrast,
|
|
201
|
+
`${m.labelInk} on ${m.disabledFill}: ${m.labelContrast.toFixed(2)}:1`,
|
|
202
|
+
).toBeGreaterThanOrEqual(aaTextContrastRatio);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
ensureFocusVisibleRing,
|
|
8
8
|
} from "@multiplatform.one/theme";
|
|
9
9
|
import { Button as TamaguiButton, Paragraph, XStack, YStack, useTheme } from "tamagui";
|
|
10
|
-
import { Button } from "./index";
|
|
10
|
+
import { Button, type ButtonProps } from "./index";
|
|
11
11
|
import { useState } from "react";
|
|
12
12
|
|
|
13
13
|
const pressed = (name: string) => () => action(name)();
|
|
@@ -45,23 +45,39 @@ const meta: Meta<typeof Button> = {
|
|
|
45
45
|
export default meta;
|
|
46
46
|
type Story = StoryObj<typeof Button>;
|
|
47
47
|
|
|
48
|
-
function
|
|
48
|
+
function ContrastExample({ children = "Save", ...props }: ButtonProps) {
|
|
49
49
|
const [presses, setPresses] = useState(0);
|
|
50
50
|
return (
|
|
51
51
|
<YStack data-lc-clicks={presses}>
|
|
52
|
-
<Button
|
|
53
|
-
|
|
52
|
+
<Button {...props} onPress={() => setPresses((value) => value + 1)}>
|
|
53
|
+
{children}
|
|
54
54
|
</Button>
|
|
55
55
|
</YStack>
|
|
56
56
|
);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export const DestructiveEnabled: Story = {
|
|
60
|
-
render: () => <
|
|
60
|
+
render: () => <ContrastExample error>Delete</ContrastExample>,
|
|
61
61
|
};
|
|
62
62
|
|
|
63
63
|
export const DestructiveDisabled: Story = {
|
|
64
|
-
render: () =>
|
|
64
|
+
render: () => (
|
|
65
|
+
<ContrastExample error disabled>
|
|
66
|
+
Delete
|
|
67
|
+
</ContrastExample>
|
|
68
|
+
),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** LC-40 sweeps each intent through args, enabled and disabled (MPO-433). */
|
|
72
|
+
export const IntentContrast: Story = {
|
|
73
|
+
argTypes: {
|
|
74
|
+
accent: { control: "boolean" },
|
|
75
|
+
warning: { control: "boolean" },
|
|
76
|
+
success: { control: "boolean" },
|
|
77
|
+
theme: { control: "text" },
|
|
78
|
+
disabled: { control: "boolean" },
|
|
79
|
+
},
|
|
80
|
+
render: (args) => <ContrastExample {...args} />,
|
|
65
81
|
};
|
|
66
82
|
|
|
67
83
|
export const Basic: Story = {
|
package/src/Button/index.tsx
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
sizeRecipeForToken,
|
|
21
21
|
sizeRecipeFromHeight,
|
|
22
22
|
solidButtonIntents,
|
|
23
|
+
solidPaletteThemes,
|
|
23
24
|
useResolvedKnobs,
|
|
24
25
|
type RecipeFamilyName,
|
|
25
26
|
type SizeRecipe,
|
|
@@ -484,6 +485,32 @@ export function Button({
|
|
|
484
485
|
};
|
|
485
486
|
|
|
486
487
|
const activeThemeName = theme ?? intent;
|
|
488
|
+
const hostScoped = <T extends object>(fragment: T): T =>
|
|
489
|
+
Object.fromEntries(
|
|
490
|
+
Object.entries(fragment).map(([key, value]) => [
|
|
491
|
+
key,
|
|
492
|
+
typeof value === "string" && value.startsWith("$")
|
|
493
|
+
? (parentScopeTheme[value.slice(1) as keyof typeof parentScopeTheme]?.val ?? value)
|
|
494
|
+
: value,
|
|
495
|
+
]),
|
|
496
|
+
) as T;
|
|
497
|
+
// LC-40 / MPO-433: a solid-palette theme has no wash tier, so the wash and
|
|
498
|
+
// its ink come from the host scope. Solid-intent ramps keep their own wash,
|
|
499
|
+
// where `$color11` misses 4.5:1 (light warning 4.48, success 4.27) and the
|
|
500
|
+
// hue's `$color12` text step clears it.
|
|
501
|
+
const washOnHost = Boolean(
|
|
502
|
+
isDisabled && activeThemeName && solidPaletteThemes.has(activeThemeName),
|
|
503
|
+
);
|
|
504
|
+
const disabledSurface = washOnHost
|
|
505
|
+
? hostScoped(disabledState.surfaceKnobProps)
|
|
506
|
+
: disabledState.surfaceKnobProps;
|
|
507
|
+
const disabledText = washOnHost
|
|
508
|
+
? hostScoped(disabledState.textKnobProps)
|
|
509
|
+
: activeThemeName &&
|
|
510
|
+
solidButtonIntents.has(activeThemeName) &&
|
|
511
|
+
disabledState.textKnobProps.color
|
|
512
|
+
? { ...disabledState.textKnobProps, color: "$color12" }
|
|
513
|
+
: disabledState.textKnobProps;
|
|
487
514
|
outlined = outlined ?? knobProps.outlined;
|
|
488
515
|
const isLow = knobProps.textAccent === "low";
|
|
489
516
|
const isMedium = knobProps.textAccent === "medium";
|
|
@@ -546,8 +573,8 @@ export function Button({
|
|
|
546
573
|
? { color: "$color11" }
|
|
547
574
|
: undefined)}
|
|
548
575
|
// LC-40: on the washed disabled chrome the label leaves the on-fill
|
|
549
|
-
// foreground
|
|
550
|
-
{...(isDisabled ?
|
|
576
|
+
// foreground for the wash's AA ink (keepLabel keeps >=4.5:1).
|
|
577
|
+
{...(isDisabled ? disabledText : undefined)}
|
|
551
578
|
// Boards measure ink.weight on the TEXT NODE, never the frame. The
|
|
552
579
|
// prop is restated after the spreads because SizableText's size
|
|
553
580
|
// variant otherwise leaks 500/600 over it (MPO-125: a literal 400
|
|
@@ -702,8 +729,8 @@ export function Button({
|
|
|
702
729
|
// whole-dim that dropped label contrast under AA.
|
|
703
730
|
{...(isDisabled
|
|
704
731
|
? {
|
|
705
|
-
...
|
|
706
|
-
...
|
|
732
|
+
...disabledSurface,
|
|
733
|
+
...disabledText,
|
|
707
734
|
...disabledState.assemblyKnobProps,
|
|
708
735
|
}
|
|
709
736
|
: undefined)}
|
|
@@ -784,11 +811,11 @@ export function Button({
|
|
|
784
811
|
// control must not light up like a pressable one.
|
|
785
812
|
hoverStyle: {
|
|
786
813
|
backgroundColor: outlined ? "transparent" : "$background",
|
|
787
|
-
...
|
|
814
|
+
...disabledSurface,
|
|
788
815
|
},
|
|
789
816
|
pressStyle: {
|
|
790
817
|
backgroundColor: outlined ? "transparent" : "$background",
|
|
791
|
-
...
|
|
818
|
+
...disabledSurface,
|
|
792
819
|
},
|
|
793
820
|
"data-mp-disabled-explained": "true",
|
|
794
821
|
}
|
|
@@ -814,11 +841,20 @@ export function Button({
|
|
|
814
841
|
</ButtonFrame>
|
|
815
842
|
);
|
|
816
843
|
|
|
844
|
+
const themedButton = activeThemeName ? (
|
|
845
|
+
<Theme name={activeThemeName as ThemeName}>{buttonElement}</Theme>
|
|
846
|
+
) : (
|
|
847
|
+
buttonElement
|
|
848
|
+
);
|
|
849
|
+
|
|
817
850
|
// DG-ST-03 explain affordance: visible reason adjacent to the control —
|
|
818
851
|
// discoverable by pointer, touch, keyboard, and SR (aria-describedby)
|
|
819
852
|
// without any hover interaction (Axiom 12). "below" stacks (forms);
|
|
820
853
|
// "inline" keeps toolbar rows on one shared control line, where the
|
|
821
854
|
// stacked reason wrapped into cramped multi-line type under the button.
|
|
855
|
+
// The reason stays OUTSIDE the intent Theme: it is T-HELPER copy on the
|
|
856
|
+
// host surface, and inside it `$color11` is the intent's on-fill ramp
|
|
857
|
+
// (MPO-431: 1.17:1 on the light page under accent).
|
|
822
858
|
const reasonText = explainedDisable ? (
|
|
823
859
|
<SizableText
|
|
824
860
|
id={reasonId}
|
|
@@ -830,26 +866,18 @@ export function Button({
|
|
|
830
866
|
{disabledReason}
|
|
831
867
|
</SizableText>
|
|
832
868
|
) : null;
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
) : (
|
|
840
|
-
<YStack gap="$1" flexShrink={1} data-mp-disabled-reason-wrapper="true">
|
|
841
|
-
{buttonElement}
|
|
842
|
-
{reasonText}
|
|
843
|
-
</YStack>
|
|
844
|
-
)
|
|
869
|
+
if (!explainedDisable) return themedButton;
|
|
870
|
+
return disabledReasonPlacement === "inline" ? (
|
|
871
|
+
<XStack gap="$2" alignItems="center" flexShrink={1} data-mp-disabled-reason-wrapper="true">
|
|
872
|
+
{themedButton}
|
|
873
|
+
{reasonText}
|
|
874
|
+
</XStack>
|
|
845
875
|
) : (
|
|
846
|
-
|
|
876
|
+
<YStack gap="$1" flexShrink={1} data-mp-disabled-reason-wrapper="true">
|
|
877
|
+
{themedButton}
|
|
878
|
+
{reasonText}
|
|
879
|
+
</YStack>
|
|
847
880
|
);
|
|
848
|
-
|
|
849
|
-
if (activeThemeName) {
|
|
850
|
-
return <Theme name={activeThemeName as ThemeName}>{explainedElement}</Theme>;
|
|
851
|
-
}
|
|
852
|
-
return explainedElement;
|
|
853
881
|
}
|
|
854
882
|
|
|
855
883
|
Button.Icon = TamaguiButton.Icon;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/Button/index.tsx"],"names":[],"mappings":"AACA,OAAO,EAOL,KAAK,aAAa,EAClB,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AACf,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/Button/index.tsx"],"names":[],"mappings":"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"}
|