@multiplatform.one/theme 7.88.8 → 7.88.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/theme",
3
- "version": "7.88.8",
3
+ "version": "7.88.10",
4
4
  "description": "Tamagui theme system for multiplatform.one",
5
5
  "keywords": [
6
6
  "multiplatform",
@@ -115,8 +115,8 @@
115
115
  "access": "public"
116
116
  },
117
117
  "dependencies": {
118
- "@multiplatform.one/platform": "^7.88.8",
119
- "@multiplatform.one/store": "^7.88.8",
118
+ "@multiplatform.one/platform": "^7.88.10",
119
+ "@multiplatform.one/store": "^7.88.10",
120
120
  "@tamagui/animations-react-native": "2.7.6",
121
121
  "@tamagui/colors": "2.7.6",
122
122
  "@tamagui/config": "2.7.6",
@@ -132,8 +132,8 @@
132
132
  },
133
133
  "devDependencies": {
134
134
  "@expo/prebuild-config": "55.0.18",
135
- "@multiplatform.one/config": "7.88.8",
136
- "@multiplatform.one/test-utils": "7.88.8",
135
+ "@multiplatform.one/config": "7.88.10",
136
+ "@multiplatform.one/test-utils": "7.88.10",
137
137
  "@tamagui/animations-css": "2.7.6",
138
138
  "@tamagui/animations-reanimated": "2.7.6",
139
139
  "@tamagui/build": "2.7.6",
@@ -0,0 +1,251 @@
1
+ import { cleanup, render } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ type Props = Record<string, any>;
5
+
6
+ const seen = vi.hoisted(() => ({
7
+ props: {} as Record<string, Props>,
8
+ dialog: { open: false, contentId: undefined as string | undefined },
9
+ width: 0,
10
+ }));
11
+
12
+ vi.mock("tamagui", async () => {
13
+ const React = await import("react");
14
+ const part = (name: string) =>
15
+ React.forwardRef<HTMLDivElement, Props>((props, ref) => {
16
+ seen.props[name] = props;
17
+ return React.createElement("div", { ref, "data-part": name }, props.children);
18
+ });
19
+ return {
20
+ isWeb: true,
21
+ Dialog: { Content: part("Dialog.Content"), Overlay: part("Dialog.Overlay") },
22
+ AlertDialog: { Content: part("AlertDialog.Content"), Overlay: part("AlertDialog.Overlay") },
23
+ Popover: { Content: part("Popover.Content") },
24
+ useDialogContext: () => seen.dialog,
25
+ useWindowDimensions: () => ({ width: seen.width, height: 800 }),
26
+ };
27
+ });
28
+ vi.mock("./Surface", () => ({ Surface: ({ children }: { children?: unknown }) => children }));
29
+ vi.mock("./useResolvedKnobs", () => ({
30
+ useResolvedKnobs: () => ({
31
+ knobProps: {
32
+ elevatedSurface: {},
33
+ containerRadius: {},
34
+ panelPadding: {},
35
+ gap: {},
36
+ motion: { overlay: undefined, scrim: undefined },
37
+ },
38
+ }),
39
+ }));
40
+ vi.mock("./resolveKnobs", () => ({ panelPaddingPx: () => 16 }));
41
+
42
+ const {
43
+ AlertDialogContent,
44
+ AlertDialogOverlay,
45
+ DialogContent,
46
+ DialogOverlay,
47
+ DialogShowFocus,
48
+ PopoverContent,
49
+ overlayScrim,
50
+ useIsInDialogRegion,
51
+ } = await import("./overlaySurfaces");
52
+
53
+ const pointer = () => document.dispatchEvent(new Event("pointerdown"));
54
+ const keyboard = () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
55
+
56
+ function el<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, string> = {}) {
57
+ const node = document.createElement(tag);
58
+ for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value);
59
+ return node;
60
+ }
61
+
62
+ function openFocus(onOpenAutoFocus?: (event: Event) => void) {
63
+ render(<PopoverContent onOpenAutoFocus={onOpenAutoFocus}>body</PopoverContent>);
64
+ return seen.props["Popover.Content"].onOpenAutoFocus as (event: Event) => void;
65
+ }
66
+
67
+ function fire(target: EventTarget, handler: (event: Event) => void) {
68
+ const event = new Event("open", { cancelable: true });
69
+ target.addEventListener("open", handler, { once: true });
70
+ target.dispatchEvent(event);
71
+ return event;
72
+ }
73
+
74
+ beforeEach(() => {
75
+ seen.props = {};
76
+ seen.dialog = { open: false, contentId: undefined };
77
+ seen.width = 0;
78
+ pointer();
79
+ });
80
+
81
+ afterEach(() => {
82
+ cleanup();
83
+ document.body.replaceChildren();
84
+ });
85
+
86
+ describe("house open focus (MPO-452, LC-71)", () => {
87
+ it("skips everything that is not a tab stop and lands the caret in the first field", () => {
88
+ const frame = el("div");
89
+ const wrapper = el("div", { style: "display: none" });
90
+ wrapper.append(el("button"));
91
+ const field = el("input", { type: "text" });
92
+ const select = vi.spyOn(field, "select");
93
+ frame.append(
94
+ el("div", { tabindex: "-1" }),
95
+ el("a", { href: "#", tabindex: "0" }),
96
+ el("button", { hidden: "" }),
97
+ el("button", { disabled: "" }),
98
+ el("input", { type: "hidden", tabindex: "0" }),
99
+ el("button", { style: "visibility: hidden" }),
100
+ wrapper,
101
+ field,
102
+ el("button"),
103
+ );
104
+ document.body.append(frame);
105
+ const event = fire(frame, openFocus());
106
+ expect(event.defaultPrevented).toBe(true);
107
+ expect(document.activeElement).toBe(field);
108
+ expect(select).toHaveBeenCalledTimes(1);
109
+ });
110
+
111
+ it("puts a pointer open on the frame, and a keyboard open on the first control", () => {
112
+ const frame = el("div");
113
+ const button = el("button");
114
+ frame.append(button);
115
+ document.body.append(frame);
116
+ const handler = openFocus();
117
+ expect(fire(frame, handler).defaultPrevented).toBe(true);
118
+ expect(document.activeElement).toBe(frame);
119
+ expect(frame.tabIndex).toBe(-1);
120
+ keyboard();
121
+ fire(frame, handler);
122
+ expect(document.activeElement).toBe(button);
123
+ });
124
+
125
+ it("runs the caller first and stands down when it prevents the default", () => {
126
+ const frame = el("div");
127
+ frame.append(el("input", { type: "text" }));
128
+ document.body.append(frame);
129
+ const caller = vi.fn((event: Event) => event.preventDefault());
130
+ const before = document.activeElement;
131
+ fire(frame, openFocus(caller));
132
+ expect(caller).toHaveBeenCalledTimes(1);
133
+ expect(document.activeElement).toBe(before);
134
+ });
135
+
136
+ it("leaves the default alone when nothing can take focus or the target is not an element", () => {
137
+ const frame = el("div");
138
+ frame.append(el("button", { disabled: "" }));
139
+ document.body.append(frame);
140
+ const handler = openFocus();
141
+ expect(fire(frame, handler).defaultPrevented).toBe(false);
142
+ expect(fire(document, handler).defaultPrevented).toBe(false);
143
+ });
144
+ });
145
+
146
+ describe("<dialog>.show() focus (MPO-470)", () => {
147
+ function content(focusable: HTMLElement) {
148
+ const node = el("div", { id: "dialog-1" });
149
+ const field = el("input", { type: "text" });
150
+ node.append(focusable, field);
151
+ document.body.append(node);
152
+ focusable.focus();
153
+ return { node, field };
154
+ }
155
+
156
+ it("moves focus off a frame show() left it on, after the caller's handler", () => {
157
+ const { field } = content(el("div", { tabindex: "-1" }));
158
+ seen.dialog = { open: true, contentId: "dialog-1" };
159
+ const caller = vi.fn();
160
+ render(<DialogShowFocus onOpenAutoFocus={caller} />);
161
+ expect(caller).toHaveBeenCalledTimes(1);
162
+ expect(document.activeElement).toBe(field);
163
+ });
164
+
165
+ it("keeps a keyboard user's tab stop, and does nothing while closed", () => {
166
+ const button = el("button");
167
+ content(button);
168
+ keyboard();
169
+ seen.dialog = { open: true, contentId: "dialog-1" };
170
+ const caller = vi.fn();
171
+ render(<DialogShowFocus onOpenAutoFocus={caller} />);
172
+ seen.dialog = { open: false, contentId: "dialog-1" };
173
+ render(<DialogShowFocus onOpenAutoFocus={caller} />);
174
+ expect(caller).not.toHaveBeenCalled();
175
+ expect(document.activeElement).toBe(button);
176
+ });
177
+
178
+ it("ignores focus outside the content and a content id that is not in the page", () => {
179
+ const outside = el("button");
180
+ document.body.append(outside);
181
+ content(el("div", { tabindex: "-1" }));
182
+ outside.focus();
183
+ const caller = vi.fn();
184
+ seen.dialog = { open: true, contentId: "dialog-1" };
185
+ render(<DialogShowFocus onOpenAutoFocus={caller} />);
186
+ seen.dialog = { open: true, contentId: "missing" };
187
+ render(<DialogShowFocus onOpenAutoFocus={caller} />);
188
+ expect(caller).not.toHaveBeenCalled();
189
+ expect(document.activeElement).toBe(outside);
190
+ });
191
+ });
192
+
193
+ describe("dialog frames", () => {
194
+ it("fit a numeric width to the window less one panel inset each side (FZ-8020)", () => {
195
+ seen.width = 390;
196
+ render(
197
+ <DialogContent width={400} minWidth={300}>
198
+ body
199
+ </DialogContent>,
200
+ );
201
+ const props = seen.props["Dialog.Content"];
202
+ expect(props.maxWidth).toBe(358);
203
+ expect(props.width).toBe(358);
204
+ expect(props.minWidth).toBe(300);
205
+ expect(props.unstyled).toBe(true);
206
+ expect(props.elevate).toBe(false);
207
+ });
208
+
209
+ it("leave a token width alone and add no cap before the window is measured", () => {
210
+ seen.width = 390;
211
+ render(<DialogContent width="$20">body</DialogContent>);
212
+ expect(seen.props["Dialog.Content"].width).toBe("$20");
213
+ seen.width = 0;
214
+ render(<DialogContent width={400}>body</DialogContent>);
215
+ expect(seen.props["Dialog.Content"].maxWidth).toBeUndefined();
216
+ expect(seen.props["Dialog.Content"].width).toBe(400);
217
+ });
218
+
219
+ it("mark their children as inside a dialog region (DG-OVL-04)", () => {
220
+ const regions: boolean[] = [];
221
+ function Probe() {
222
+ regions.push(useIsInDialogRegion());
223
+ return null;
224
+ }
225
+ render(
226
+ <>
227
+ <Probe />
228
+ <DialogContent>
229
+ <Probe />
230
+ </DialogContent>
231
+ <AlertDialogContent>
232
+ <Probe />
233
+ </AlertDialogContent>
234
+ </>,
235
+ );
236
+ expect(regions).toEqual([false, true, true]);
237
+ expect(seen.props["AlertDialog.Content"].unstyled).toBe(true);
238
+ });
239
+
240
+ it("dim the page with the house scrim under both dialog kinds", () => {
241
+ render(
242
+ <>
243
+ <DialogOverlay />
244
+ <AlertDialogOverlay />
245
+ </>,
246
+ );
247
+ expect(seen.props["Dialog.Overlay"].backgroundColor).toBe(overlayScrim);
248
+ expect(seen.props["AlertDialog.Overlay"].backgroundColor).toBe(overlayScrim);
249
+ expect(seen.props["Dialog.Overlay"].enterStyle).toEqual({ opacity: 0 });
250
+ });
251
+ });
@@ -0,0 +1,103 @@
1
+ import { cleanup, render } from "@testing-library/react";
2
+ import type { ReactElement } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ const media = vi.hoisted(() => ({ sm: false as boolean | undefined }));
6
+ vi.mock("tamagui", () => ({ useMedia: () => ({ sm: media.sm }) }));
7
+
8
+ const {
9
+ PageGutter,
10
+ ScreenNesting,
11
+ negateSpaceToken,
12
+ usePageGutter,
13
+ useParentScreen,
14
+ usePhoneTableBleed,
15
+ } = await import("./pageGutter");
16
+
17
+ afterEach(() => {
18
+ cleanup();
19
+ media.sm = false;
20
+ });
21
+
22
+ function read<T>(hook: () => T, wrap: (probe: ReactElement) => ReactElement) {
23
+ const out: { value?: T } = {};
24
+ function Probe() {
25
+ out.value = hook();
26
+ return null;
27
+ }
28
+ render(wrap(<Probe />));
29
+ return out.value as T;
30
+ }
31
+
32
+ describe("page gutter (LC-146)", () => {
33
+ it("is null off the page line and the nearest declared token inside one", () => {
34
+ expect(read(usePageGutter, (probe) => probe)).toBeNull();
35
+ expect(read(usePageGutter, (probe) => <PageGutter inset="$4">{probe}</PageGutter>)).toBe("$4");
36
+ expect(
37
+ read(usePageGutter, (probe) => (
38
+ <PageGutter inset="$4">
39
+ <PageGutter inset={null}>{probe}</PageGutter>
40
+ </PageGutter>
41
+ )),
42
+ ).toBeNull();
43
+ });
44
+
45
+ it("negates a space token both ways", () => {
46
+ expect(negateSpaceToken("$4")).toBe("$-4");
47
+ expect(negateSpaceToken("$-4")).toBe("$4");
48
+ expect(negateSpaceToken("4")).toBe("$-4");
49
+ });
50
+
51
+ it("bleeds a table to the screen edges on a phone that sits on the page line", () => {
52
+ media.sm = false;
53
+ expect(
54
+ read(usePhoneTableBleed, (probe) => <PageGutter inset="$3">{probe}</PageGutter>),
55
+ ).toEqual({ inset: "$3", frame: { marginHorizontal: "$-3" } });
56
+ });
57
+
58
+ it("keeps the frame when wide, when the media is unknown, or off the page line", () => {
59
+ media.sm = true;
60
+ expect(read(usePhoneTableBleed, (probe) => <PageGutter inset="$3">{probe}</PageGutter>)).toBe(
61
+ null,
62
+ );
63
+ media.sm = undefined;
64
+ expect(read(usePhoneTableBleed, (probe) => <PageGutter inset="$3">{probe}</PageGutter>)).toBe(
65
+ null,
66
+ );
67
+ media.sm = false;
68
+ expect(read(usePhoneTableBleed, (probe) => probe)).toBeNull();
69
+ });
70
+ });
71
+
72
+ describe("screen nesting (LC-159, FZ-7993)", () => {
73
+ it("is null at the top of a page and the parent shell's record under one", () => {
74
+ expect(read(useParentScreen, (probe) => probe)).toBeNull();
75
+ expect(
76
+ read(useParentScreen, (probe) => (
77
+ <ScreenNesting scrolls inset="$4" capped={false}>
78
+ {probe}
79
+ </ScreenNesting>
80
+ )),
81
+ ).toEqual({ scrolls: true, inset: "$4", capped: false });
82
+ });
83
+
84
+ it("keeps the record's identity while the shell's values hold", () => {
85
+ const seen: unknown[] = [];
86
+ function Probe() {
87
+ seen.push(useParentScreen());
88
+ return null;
89
+ }
90
+ const tree = (capped: boolean) => (
91
+ <ScreenNesting scrolls={false} inset={null} capped={capped}>
92
+ <Probe />
93
+ </ScreenNesting>
94
+ );
95
+ const { rerender } = render(tree(true));
96
+ rerender(tree(true));
97
+ rerender(tree(false));
98
+ expect(seen).toHaveLength(3);
99
+ expect(seen[1]).toBe(seen[0]);
100
+ expect(seen[2]).not.toBe(seen[0]);
101
+ expect(seen[2]).toEqual({ scrolls: false, inset: null, capped: false });
102
+ });
103
+ });
@@ -0,0 +1,142 @@
1
+ import { cleanup, render } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { contrastRatio, normalizeToHex, relativeLuminance } from "./colorRules";
4
+
5
+ const live = vi.hoisted(() => ({
6
+ name: "light" as string | undefined,
7
+ values: {} as Record<string, string>,
8
+ }));
9
+
10
+ vi.mock("tamagui", async (importOriginal) => ({
11
+ ...((await importOriginal()) as object),
12
+ useThemeName: () => live.name,
13
+ useTheme: () =>
14
+ Object.fromEntries(Object.entries(live.values).map(([key, val]) => [key, { val }])),
15
+ }));
16
+
17
+ const {
18
+ resolveAccentTintedSurface,
19
+ resolveActiveTabInk,
20
+ useAccentTintedSurface,
21
+ useActiveTabInk,
22
+ useIdentityHue,
23
+ useLegibleInkOn,
24
+ useReadableTextOn,
25
+ } = await import("./readableColor");
26
+
27
+ const paper = "#ffffff";
28
+ const ink = "#1c1b1f";
29
+ const contrast = (a: string, b: string) =>
30
+ contrastRatio(relativeLuminance(normalizeToHex(a)!), relativeLuminance(normalizeToHex(b)!));
31
+
32
+ function read<T>(hook: () => T): T {
33
+ const out: { value?: T } = {};
34
+ function Probe() {
35
+ out.value = hook();
36
+ return null;
37
+ }
38
+ render(<Probe />);
39
+ return out.value as T;
40
+ }
41
+
42
+ afterEach(() => {
43
+ cleanup();
44
+ live.name = "light";
45
+ live.values = {};
46
+ });
47
+
48
+ describe("useReadableTextOn", () => {
49
+ it("picks the anchor that reads on a resolved fill token", () => {
50
+ live.values = { color1: paper, color12: ink, color9: "#101010", color3: "#f4f4f4" };
51
+ expect(read(() => useReadableTextOn("$color9"))).toBe(paper);
52
+ expect(read(() => useReadableTextOn("$color3"))).toBe(ink);
53
+ expect(read(() => useReadableTextOn("#0a0a0a"))).toBe(paper);
54
+ });
55
+
56
+ it("gives up, so the caller keeps its default, when any colour fails to resolve", () => {
57
+ live.values = { color1: paper, color12: ink };
58
+ expect(read(() => useReadableTextOn("$missing"))).toBeUndefined();
59
+ expect(read(() => useReadableTextOn(undefined))).toBeUndefined();
60
+ expect(read(() => useReadableTextOn("not a colour"))).toBeUndefined();
61
+ live.values = { color1: "nope", color12: ink };
62
+ expect(read(() => useReadableTextOn("#000000"))).toBeUndefined();
63
+ });
64
+ });
65
+
66
+ describe("useLegibleInkOn", () => {
67
+ it("keeps the component's own ink token while it clears AA", () => {
68
+ live.values = { color1: paper, color12: ink, color2: "#fafafa", color11: "#5a5860" };
69
+ expect(read(() => useLegibleInkOn("$color2", "$color11"))).toBe("$color11");
70
+ });
71
+
72
+ it("hands a failing ink over to a concrete readable colour", () => {
73
+ live.values = { color1: paper, color12: ink, color2: "#fafafa", color6: "#dddddd" };
74
+ const swapped = read(() => useLegibleInkOn("$color2", "$color6"));
75
+ expect(swapped).not.toBe("$color6");
76
+ expect(contrast(swapped, "#fafafa")).toBeGreaterThanOrEqual(4.5);
77
+ });
78
+
79
+ it("returns the token untouched when the fill or ink cannot be read", () => {
80
+ live.values = { color1: paper, color12: ink };
81
+ expect(read(() => useLegibleInkOn("$missing", "$color11"))).toBe("$color11");
82
+ expect(read(() => useLegibleInkOn("#fafafa", "#dddddd"))).not.toBe("#dddddd");
83
+ });
84
+ });
85
+
86
+ describe("useAccentTintedSurface", () => {
87
+ it("mixes the accent solid into paper just past the wash ratio", () => {
88
+ live.values = { accentBackground: "#2a5bd7", color1: paper };
89
+ const wash = read(useAccentTintedSurface);
90
+ expect(wash).toBe(resolveAccentTintedSurface({ solid: "#2a5bd7", paper }));
91
+ expect(contrast(wash!, paper)).toBeGreaterThanOrEqual(1.2);
92
+ expect(contrast(wash!, paper)).toBeLessThan(1.3);
93
+ });
94
+
95
+ it("is undefined without a resolvable solid", () => {
96
+ live.values = { color1: paper };
97
+ expect(read(useAccentTintedSurface)).toBeUndefined();
98
+ });
99
+ });
100
+
101
+ describe("identity hue and active tab ink (LC-150)", () => {
102
+ const accents = Object.fromEntries(
103
+ Array.from({ length: 12 }, (_, index) => [`accent${index + 1}`, "#9a9a9a"]),
104
+ );
105
+
106
+ it("takes the most chromatic accent step that clears AA on both page surfaces", () => {
107
+ live.values = {
108
+ ...accents,
109
+ accent9: "#1a55c9",
110
+ accent11: "#1f3f8f",
111
+ color1: paper,
112
+ color2: "#f7f7f8",
113
+ color12: ink,
114
+ };
115
+ const { hue, c1, c2 } = read(useIdentityHue);
116
+ expect(hue).toBe("#1a55c9");
117
+ expect([c1, c2]).toEqual([paper, "#f7f7f8"]);
118
+ expect(read(useActiveTabInk)).toBe("#1a55c9");
119
+ });
120
+
121
+ it("has no hue on a grey ramp, so the active tab falls back to the accent solid or ink", () => {
122
+ live.values = { ...accents, color1: paper, color2: "#f7f7f8", color12: ink };
123
+ expect(read(useIdentityHue).hue).toBeUndefined();
124
+ expect(read(useActiveTabInk)).toBe(ink);
125
+ live.values = { ...live.values, accentBackground: "#d93a2b" };
126
+ live.name = "dark";
127
+ expect(read(useActiveTabInk)).toBe(
128
+ resolveActiveTabInk({
129
+ hue: undefined,
130
+ solid: "#d93a2b",
131
+ dark: true,
132
+ surfaces: [paper, "#f7f7f8"],
133
+ ink,
134
+ }),
135
+ );
136
+ });
137
+
138
+ it("falls back to the $color12 token when the theme has no ink", () => {
139
+ live.values = { ...accents, color1: paper, color2: "#f7f7f8" };
140
+ expect(read(useIdentityHue).ink).toBe("$color12");
141
+ });
142
+ });