@multiplatform.one/forms 7.88.6 → 7.88.8

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.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ *
4
+ * FZ-9608: an outlined solid-palette Button (the `accent` prop or
5
+ * `theme="accent"`) inks its label on the host (MPO-447). That ink was a
6
+ * literal from the scheme current at render: `useAccentOnSurface()` under the
7
+ * default textAccent, `hostScoped()` of `$color11` under low and medium. A
8
+ * flip that moves only the root's t_light/t_dark class, which never
9
+ * re-renders the Button, left the old scheme's ink on the new page.
10
+ *
11
+ * jsdom cascades no Tamagui CSS, so this reads what each node asks for: the
12
+ * atomic class, the injected rule behind it, a `--mp-host-*` alias on the
13
+ * ancestor that declares it, and for a `var(--token)` rule the house theme
14
+ * table of the scope that paints it.
15
+ */
16
+
17
+ import { act, cleanup, render, screen } from "@testing-library/react";
18
+ import {
19
+ createDefaultThemeConfig,
20
+ createThemesBuilder,
21
+ defaultAccentTheme,
22
+ defaultBaseTheme,
23
+ defaultBuilderOptions,
24
+ type Knobs,
25
+ Preset,
26
+ } from "@multiplatform.one/theme";
27
+ import { type ReactElement, type ReactNode, useState } from "react";
28
+ import { TamaguiProvider, YStack } from "tamagui";
29
+ import { afterEach, describe, expect, it } from "vitest";
30
+ import { Button, type ButtonProps } from "./index";
31
+
32
+ afterEach(cleanup);
33
+
34
+ const houseConfig = createDefaultThemeConfig();
35
+ const houseThemes = createThemesBuilder(
36
+ defaultBaseTheme,
37
+ defaultAccentTheme,
38
+ defaultBuilderOptions,
39
+ ).themes() as Record<string, Record<string, string>>;
40
+
41
+ const FLIPS = [
42
+ ["light", "dark"],
43
+ ["dark", "light"],
44
+ ] as const;
45
+ type Scheme = (typeof FLIPS)[number][number];
46
+
47
+ const VARIANTS: { name: string; props: Partial<ButtonProps> }[] = [
48
+ { name: 'theme="accent" outlined', props: { theme: "accent", outlined: true } },
49
+ { name: "accent outlined", props: { accent: true, outlined: true } },
50
+ ];
51
+ const TEXT_ACCENTS = ["low", "medium", "high"] as const;
52
+ const STATES = [
53
+ { name: "enabled", disabled: false },
54
+ { name: "dimWhole disabled", disabled: true },
55
+ ] as const;
56
+
57
+ function injectedRuleValue(className: string, property: string): string {
58
+ for (const sheet of Array.from(document.styleSheets)) {
59
+ for (const rule of Array.from(sheet.cssRules)) {
60
+ if (!(rule instanceof CSSStyleRule)) continue;
61
+ if (!rule.selectorText.endsWith(`.${className}`)) continue;
62
+ const value = rule.style.getPropertyValue(property);
63
+ if (value) return value.trim();
64
+ }
65
+ }
66
+ throw new Error(`no injected ${property} rule for .${className}`);
67
+ }
68
+
69
+ function scopeOf(el: Element): string | null {
70
+ for (let node = el.parentElement; node; node = node.parentElement) {
71
+ const names = Array.from(node.classList)
72
+ .filter((c) => c.startsWith("t_") && c !== "t_sub_theme")
73
+ .map((c) => c.slice(2))
74
+ .filter((c) => c !== "light" && c !== "dark");
75
+ if (names.length) return names.sort((a, b) => b.length - a.length)[0];
76
+ }
77
+ return null;
78
+ }
79
+
80
+ function resolveInScope(value: string, scheme: Scheme, scope: string | null): string {
81
+ const token = value.match(/^var\(--([\w-]+)\)$/)?.[1];
82
+ if (!token) return value;
83
+ const chain: string[] = [];
84
+ if (scope) {
85
+ const parts = scope.split("_");
86
+ for (let i = parts.length; i > 0; i--) chain.push(`${scheme}_${parts.slice(0, i).join("_")}`);
87
+ }
88
+ chain.push(scheme);
89
+ for (const name of chain) {
90
+ const hit = houseThemes[name]?.[token];
91
+ if (hit) return hit;
92
+ }
93
+ throw new Error(`--${token} resolves in none of ${chain.join(", ")}`);
94
+ }
95
+
96
+ function declaredOn(el: Element, property: string): HTMLElement | null {
97
+ for (let node = el.parentElement; node; node = node.parentElement) {
98
+ if (node.style.getPropertyValue(property)) return node;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /** What `el` paints for one atomic property, or null when it asks for none. */
104
+ function painted(el: Element, prefix: string, property: string, scheme: Scheme): string | null {
105
+ const cls = Array.from(el.classList).find(
106
+ (c) => c.startsWith(prefix) && !/^_[a-z]+-0(hover|active|focus|press)/.test(c),
107
+ );
108
+ if (!cls) return null;
109
+ const value = injectedRuleValue(cls, property);
110
+ const alias = value.match(/^var\((--[\w-]+)\)$/)?.[1];
111
+ const declarer = alias ? declaredOn(el, alias) : null;
112
+ return alias && declarer
113
+ ? resolveInScope(declarer.style.getPropertyValue(alias).trim(), scheme, scopeOf(declarer))
114
+ : resolveInScope(value, scheme, scopeOf(el));
115
+ }
116
+
117
+ function schemeOf(el: Element): Scheme | null {
118
+ for (let node = el.parentElement; node; node = node.parentElement) {
119
+ if (node.classList.contains("t_dark")) return "dark";
120
+ if (node.classList.contains("t_light")) return "light";
121
+ }
122
+ return null;
123
+ }
124
+
125
+ function paintOf(text: string, scheme: Scheme) {
126
+ const label = screen.getByText(text);
127
+ const frame = label.closest("[role=button]") as Element;
128
+ return {
129
+ scheme: schemeOf(frame),
130
+ ink: painted(label, "_col-", "color", scheme),
131
+ frameInk: painted(frame, "_col-", "color", scheme),
132
+ fill: painted(frame, "_bg-", "background-color", scheme),
133
+ border: painted(frame, "_btc-", "border-top-color", scheme),
134
+ };
135
+ }
136
+
137
+ let flipScheme: (scheme: Scheme) => void = () => {};
138
+
139
+ function SchemeRoot({ initial, children }: { initial: Scheme; children: ReactNode }) {
140
+ const [scheme, setScheme] = useState<Scheme>(initial);
141
+ flipScheme = setScheme;
142
+ return (
143
+ <TamaguiProvider config={houseConfig.tamagui} defaultTheme={scheme} disableInjectCSS>
144
+ {children}
145
+ </TamaguiProvider>
146
+ );
147
+ }
148
+
149
+ function swapSchemeClass(from: Scheme, to: Scheme) {
150
+ for (const el of Array.from(document.querySelectorAll(`.t_${from}`))) {
151
+ el.classList.replace(`t_${from}`, `t_${to}`);
152
+ }
153
+ }
154
+
155
+ function onHost(knobs: Partial<Knobs>, button: ReactElement) {
156
+ return (
157
+ <Preset overrides={knobs}>
158
+ <YStack backgroundColor="$background">{button}</YStack>
159
+ </Preset>
160
+ );
161
+ }
162
+
163
+ function expectFlipFollows(from: Scheme, to: Scheme, tree: ReactElement, text: string) {
164
+ render(<SchemeRoot initial={to}>{tree}</SchemeRoot>);
165
+ const fresh = paintOf(text, to);
166
+ cleanup();
167
+ render(<SchemeRoot initial={from}>{tree}</SchemeRoot>);
168
+ swapSchemeClass(from, to);
169
+ try {
170
+ expect(paintOf(text, to)).toEqual(fresh);
171
+ } finally {
172
+ swapSchemeClass(to, from);
173
+ }
174
+ }
175
+
176
+ function expectProviderFlipFollows(from: Scheme, to: Scheme, tree: ReactElement, text: string) {
177
+ render(<SchemeRoot initial={to}>{tree}</SchemeRoot>);
178
+ const fresh = paintOf(text, to);
179
+ cleanup();
180
+ render(<SchemeRoot initial={from}>{tree}</SchemeRoot>);
181
+ act(() => flipScheme(to));
182
+ expect(paintOf(text, to)).toEqual(fresh);
183
+ }
184
+
185
+ describe("FZ-9608 MPO-447: an outlined solid-palette Button's ink follows a scheme flip", () => {
186
+ for (const [from, to] of FLIPS) {
187
+ for (const variant of VARIANTS) {
188
+ for (const textAccent of TEXT_ACCENTS) {
189
+ for (const state of STATES) {
190
+ const tree = onHost(
191
+ { textAccent, disabledStyle: "dimWhole" },
192
+ <Button {...variant.props} disabled={state.disabled}>
193
+ Sign in
194
+ </Button>,
195
+ );
196
+ const label = `${from} -> ${to} ${variant.name} textAccent ${textAccent} ${state.name}`;
197
+
198
+ it(`${label}: a flip that never re-renders it paints what a fresh ${to} mount paints`, () => {
199
+ expectFlipFollows(from, to, tree, "Sign in");
200
+ });
201
+
202
+ it(`${label}: a provider flip paints what a fresh ${to} mount paints`, () => {
203
+ expectProviderFlipFollows(from, to, tree, "Sign in");
204
+ });
205
+ }
206
+ }
207
+ }
208
+ }
209
+ });
210
+
211
+ describe("FZ-9608 MPO-447: the default textAccent inks the label with the host's accentOnSurface", () => {
212
+ for (const [scheme] of FLIPS) {
213
+ for (const variant of VARIANTS) {
214
+ it(`${scheme} ${variant.name}`, () => {
215
+ render(
216
+ <SchemeRoot initial={scheme}>
217
+ {onHost({ textAccent: "high" }, <Button {...variant.props}>Sign in</Button>)}
218
+ </SchemeRoot>,
219
+ );
220
+ const host = houseThemes[scheme]?.accentOnSurface;
221
+ expect(host).toMatch(/^#|^rgb|^hsl/);
222
+ const paint = paintOf("Sign in", scheme);
223
+ expect(paint.ink).toBe(host);
224
+ expect(paint.frameInk).toBe(host);
225
+ });
226
+ }
227
+ }
228
+ });
229
+
230
+ const KNOB_SPACE = {
231
+ fillStyle: ["filled", "outlined"],
232
+ borderRadius: ["none", "small", "medium", "large", "full"],
233
+ borderWidth: ["none", "small", "medium", "large"],
234
+ elevation: ["none", "small", "medium", "large"],
235
+ textAccent: ["low", "medium", "high"],
236
+ disabledStyle: ["keepLabel", "dimWhole"],
237
+ } as const;
238
+
239
+ function knobFuzz(seed: number, count: number): Partial<Knobs>[] {
240
+ let state = seed;
241
+ const next = () => {
242
+ state = (state + 0x6d2b79f5) | 0;
243
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
244
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
245
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
246
+ };
247
+ return Array.from(
248
+ { length: count },
249
+ () =>
250
+ Object.fromEntries(
251
+ Object.entries(KNOB_SPACE).map(([knob, values]) => [
252
+ knob,
253
+ values[Math.floor(next() * values.length)],
254
+ ]),
255
+ ) as Partial<Knobs>,
256
+ );
257
+ }
258
+
259
+ describe('FZ-9608: seeded knob draws of a theme="accent" Button follow a flip with no re-render', () => {
260
+ for (const knobs of knobFuzz(9608, 3)) {
261
+ for (const [from, to] of FLIPS) {
262
+ for (const state of STATES) {
263
+ it(`${from} -> ${to} ${state.name.split(" ").pop()} ${JSON.stringify(knobs)}`, () => {
264
+ expectFlipFollows(
265
+ from,
266
+ to,
267
+ onHost(
268
+ knobs,
269
+ <Button theme="accent" disabled={state.disabled}>
270
+ Sign in
271
+ </Button>,
272
+ ),
273
+ "Sign in",
274
+ );
275
+ });
276
+ }
277
+ }
278
+ }
279
+ });
280
+
281
+ describe("FZ-9608: a focused outlined solid-palette Button keeps its node and focus", () => {
282
+ for (const variant of VARIANTS) {
283
+ it(`${variant.name}: across a scheme flip and when it turns disabled`, () => {
284
+ const view = (disabled: boolean) => (
285
+ <SchemeRoot initial="light">
286
+ {onHost(
287
+ { disabledStyle: "dimWhole" },
288
+ <Button {...variant.props} disabled={disabled}>
289
+ Go
290
+ </Button>,
291
+ )}
292
+ </SchemeRoot>
293
+ );
294
+ const { rerender } = render(view(false));
295
+ const frame = screen.getByText("Go").closest("[role=button]") as HTMLElement;
296
+ frame.focus();
297
+ act(() => flipScheme("dark"));
298
+ expect(screen.getByText("Go").closest("[role=button]")).toBe(frame);
299
+ expect(document.activeElement).toBe(frame);
300
+ rerender(view(true));
301
+ expect(screen.getByText("Go").closest("[role=button]")).toBe(frame);
302
+ expect(document.activeElement).toBe(frame);
303
+ });
304
+ }
305
+ });
@@ -0,0 +1,208 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ *
4
+ * FZ-9617 on native: no CSS variables, so every host-scope paint stays a
5
+ * literal resolved at render, and a scheme flip re-renders. The one change is
6
+ * the media plate's scope: its disabled wash and ring now come from the dark
7
+ * scope the plate sits in, the scope web resolves them in, where native read
8
+ * the page.
9
+ */
10
+
11
+ import { cleanup, render, screen } from "@testing-library/react";
12
+ import { createDefaultThemeConfig, type Knobs, Preset } from "@multiplatform.one/theme";
13
+ import type { ReactNode } from "react";
14
+ import { getVariableValue, TamaguiProvider, Theme, useTheme, YStack } from "tamagui";
15
+ import { afterEach, describe, expect, it, vi } from "vitest";
16
+ import { Button } from "./index";
17
+
18
+ const platform = vi.hoisted(() => ({ web: false }));
19
+ vi.mock("tamagui", async (importOriginal) => ({
20
+ ...(await importOriginal<typeof import("tamagui")>()),
21
+ get isWeb() {
22
+ return platform.web;
23
+ },
24
+ }));
25
+
26
+ afterEach(cleanup);
27
+
28
+ const config = createDefaultThemeConfig();
29
+ const SCHEMES = ["light", "dark"] as const;
30
+ const LITERAL = /^#|^rgb|^hsl/;
31
+
32
+ function IconProbe({ color }: { size?: number; color?: string }) {
33
+ return <span data-testid="icon" data-color={color ?? ""} />;
34
+ }
35
+
36
+ function ScopeProbe({ id }: { id: string }) {
37
+ const theme = useTheme();
38
+ const read = (key: string) =>
39
+ String(getVariableValue((theme as unknown as Record<string, unknown>)[key]));
40
+ return (
41
+ <span
42
+ data-testid={id}
43
+ data-color3={read("color3")}
44
+ data-color6={read("color6")}
45
+ data-color11={read("color11")}
46
+ data-color12={read("color12")}
47
+ data-outline={read("outlineColor")}
48
+ />
49
+ );
50
+ }
51
+
52
+ function* styleRules(rules: CSSRuleList): Generator<CSSStyleRule> {
53
+ for (const rule of Array.from(rules)) {
54
+ if (rule instanceof CSSStyleRule) yield rule;
55
+ else if ("cssRules" in rule) yield* styleRules((rule as CSSGroupingRule).cssRules);
56
+ }
57
+ }
58
+
59
+ function injectedRuleValue(className: string, property: string): string | undefined {
60
+ const selector = new RegExp(`\\.${className.replace(/[^\w-]/g, "\\$&")}(?![\\w-])`);
61
+ for (const sheet of Array.from(document.styleSheets)) {
62
+ for (const rule of styleRules(sheet.cssRules)) {
63
+ if (!selector.test(rule.selectorText)) continue;
64
+ const value = rule.style.getPropertyValue(property);
65
+ if (value) return value.trim();
66
+ }
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ function painted(el: Element, prefix: string, property: string, pseudo?: string) {
72
+ const cls = Array.from(el.classList).find((c) =>
73
+ pseudo
74
+ ? c.startsWith(`${prefix}0${pseudo}-`)
75
+ : c.startsWith(prefix) && !/^_[a-zA-Z]+-0(hover|active|focus|press|focus-visible)-/.test(c),
76
+ );
77
+ return cls ? injectedRuleValue(cls, property) : undefined;
78
+ }
79
+
80
+ function mount(scheme: (typeof SCHEMES)[number], children: ReactNode) {
81
+ return render(
82
+ <TamaguiProvider config={config.tamagui} defaultTheme={scheme} disableInjectCSS>
83
+ <YStack backgroundColor="$background">{children}</YStack>
84
+ </TamaguiProvider>,
85
+ );
86
+ }
87
+
88
+ function frameOf(name: string): HTMLElement {
89
+ return screen.getByRole("button", { name });
90
+ }
91
+
92
+ const WASH_KNOBS: Partial<Knobs> = {
93
+ disabledStyle: "keepLabel",
94
+ fillStyle: "filled",
95
+ borderWidth: "medium",
96
+ };
97
+
98
+ describe("FZ-9617: a native disabled accent media plate washes from the dark scope", () => {
99
+ it.each(SCHEMES)("%s page: fill, edge and glyph are the dark scope's literals", (scheme) => {
100
+ mount(
101
+ scheme,
102
+ <>
103
+ <Theme name="dark">
104
+ <ScopeProbe id="dark" />
105
+ </Theme>
106
+ <Preset overrides={WASH_KNOBS}>
107
+ <Button accent disabled plate="media" icon={IconProbe} aria-label="Play" />
108
+ </Preset>
109
+ </>,
110
+ );
111
+ const dark = screen.getByTestId("dark").dataset;
112
+ expect(dark.color3).toMatch(LITERAL);
113
+ const frame = frameOf("Play");
114
+ expect({
115
+ fill: painted(frame, "_bg-", "background-color"),
116
+ edge: painted(frame, "_btc-", "border-top-color"),
117
+ glyph: screen.getByTestId("icon").dataset.color,
118
+ }).toEqual({ fill: dark.color3, edge: dark.color6, glyph: dark.color11 });
119
+ });
120
+ });
121
+
122
+ describe("FZ-9617: native keeps every host-scope paint a literal", () => {
123
+ it.each(SCHEMES)("%s: no node declares a --mp-host-* alias", (scheme) => {
124
+ mount(
125
+ scheme,
126
+ <>
127
+ <Button accent>Go</Button>
128
+ <Button>Plain</Button>
129
+ <Button icon={IconProbe} aria-label="Add" />
130
+ </>,
131
+ );
132
+ for (const el of Array.from(document.querySelectorAll<HTMLElement>("[style]"))) {
133
+ expect(el.getAttribute("style") ?? "").not.toContain("--mp-host-");
134
+ }
135
+ });
136
+
137
+ it.each(SCHEMES)("%s: the ring is the page's literal $outlineColor", (scheme) => {
138
+ mount(
139
+ scheme,
140
+ <>
141
+ <ScopeProbe id="page" />
142
+ <Button>Plain</Button>
143
+ <Button accent>Go</Button>
144
+ </>,
145
+ );
146
+ const page = screen.getByTestId("page").dataset;
147
+ expect(page.outline).toMatch(LITERAL);
148
+ for (const name of ["Plain", "Go"]) {
149
+ expect(painted(frameOf(name), "_outlineColor-", "outline-color", "focus-visible")).toBe(
150
+ page.outline,
151
+ );
152
+ }
153
+ });
154
+
155
+ it.each(SCHEMES)("%s: a media plate's ring is the dark scope's literal", (scheme) => {
156
+ mount(
157
+ scheme,
158
+ <>
159
+ <Theme name="dark">
160
+ <ScopeProbe id="dark" />
161
+ </Theme>
162
+ <Button plate="media" icon={IconProbe} aria-label="Play" />
163
+ </>,
164
+ );
165
+ expect(painted(frameOf("Play"), "_outlineColor-", "outline-color", "focus-visible")).toBe(
166
+ screen.getByTestId("dark").dataset.outline,
167
+ );
168
+ });
169
+
170
+ it.each(SCHEMES)(
171
+ "%s: a framed circle inks its plate with the page's literal $color12",
172
+ (scheme) => {
173
+ mount(
174
+ scheme,
175
+ <>
176
+ <ScopeProbe id="page" />
177
+ <Button circular icon={IconProbe} aria-label="Add" />
178
+ </>,
179
+ );
180
+ const page = screen.getByTestId("page").dataset;
181
+ expect(page.color12).toMatch(LITERAL);
182
+ expect(painted(frameOf("Add"), "_col-", "color")).toBe(page.color12);
183
+ expect(screen.getByTestId("icon").dataset.color).toBe(page.color12);
184
+ },
185
+ );
186
+
187
+ it.each(SCHEMES)(
188
+ "%s: a bar glyph and its frame take the house glyph tint, $color12, as a literal",
189
+ (scheme) => {
190
+ mount(
191
+ scheme,
192
+ <>
193
+ <ScopeProbe id="page" />
194
+ <Button chromeless accentInk icon={IconProbe} aria-label="Add" />
195
+ </>,
196
+ );
197
+ const page = screen.getByTestId("page").dataset;
198
+ expect(page.color12).toMatch(LITERAL);
199
+ expect(screen.getByTestId("icon").dataset.color).toBe(page.color12);
200
+ expect(painted(frameOf("Add"), "_col-", "color")).toBe(page.color12);
201
+ },
202
+ );
203
+
204
+ it.each(SCHEMES)("%s: an icon button's hover stays transparent", (scheme) => {
205
+ mount(scheme, <Button icon={IconProbe} aria-label="Add" />);
206
+ expect(painted(frameOf("Add"), "_bg-", "background-color", "hover")).toBe("transparent");
207
+ });
208
+ });