@multiplatform.one/theme 7.18.0 → 7.20.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/theme",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.20.0",
|
|
4
4
|
"description": "Tamagui theme system for multiplatform.one",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"multiplatform",
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"access": "public"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@multiplatform.one/platform": "7.
|
|
48
|
-
"@multiplatform.one/store": "7.
|
|
47
|
+
"@multiplatform.one/platform": "7.20.0",
|
|
48
|
+
"@multiplatform.one/store": "7.20.0",
|
|
49
49
|
"@tamagui/colors": "2.7.6",
|
|
50
50
|
"@tamagui/config": "2.7.6",
|
|
51
51
|
"@tamagui/get-token": "2.7.6",
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
"react-cookie": "^8.1.2"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@multiplatform.one/config": "7.
|
|
61
|
-
"@multiplatform.one/test-utils": "7.
|
|
60
|
+
"@multiplatform.one/config": "7.20.0",
|
|
61
|
+
"@multiplatform.one/test-utils": "7.20.0",
|
|
62
62
|
"@tamagui/animations-css": "2.7.6",
|
|
63
63
|
"@tamagui/animations-reanimated": "2.7.6",
|
|
64
64
|
"@tamagui/font-inter": "2.7.6",
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MPO-376: the server has no viewport, so it renders the `medium` size class.
|
|
3
|
+
* A component that branches on `useLayoutSizeClass() === "compact"` (the
|
|
4
|
+
* scaffold Navbar's desktop list vs phone menu) must hydrate that markup at a
|
|
5
|
+
* phone width, then adopt `compact`. Reading the viewport on the first client
|
|
6
|
+
* render is a mismatch React answers by throwing the server tree away.
|
|
7
|
+
* Native has no server HTML, so its first render reads the window directly.
|
|
8
|
+
*/
|
|
9
|
+
import { act } from "react";
|
|
10
|
+
import { hydrateRoot, type Root } from "react-dom/client";
|
|
11
|
+
import { renderToString } from "react-dom/server";
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
13
|
+
|
|
14
|
+
const host = vi.hoisted(() => ({ web: true, nativeWidth: 390 }));
|
|
15
|
+
|
|
16
|
+
vi.mock(import("tamagui"), async (importOriginal) => ({
|
|
17
|
+
...(await importOriginal()),
|
|
18
|
+
get isWeb() {
|
|
19
|
+
return host.web;
|
|
20
|
+
},
|
|
21
|
+
useWindowDimensions: () => ({ width: host.nativeWidth, height: 800, scale: 2, fontScale: 1 }),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import { useLayoutSizeClass } from "./layoutTokens";
|
|
25
|
+
|
|
26
|
+
function NavFixture() {
|
|
27
|
+
const sizeClass = useLayoutSizeClass();
|
|
28
|
+
return (
|
|
29
|
+
<nav data-size-class={sizeClass}>
|
|
30
|
+
{sizeClass === "compact" ? (
|
|
31
|
+
<button type="button">Menu</button>
|
|
32
|
+
) : (
|
|
33
|
+
<ul>
|
|
34
|
+
<li>Home</li>
|
|
35
|
+
<li>Pokemon</li>
|
|
36
|
+
</ul>
|
|
37
|
+
)}
|
|
38
|
+
</nav>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let root: Root | undefined;
|
|
43
|
+
let container: HTMLDivElement | undefined;
|
|
44
|
+
let errors: unknown[] = [];
|
|
45
|
+
|
|
46
|
+
function serverRender(): string {
|
|
47
|
+
const browserWindow = window;
|
|
48
|
+
vi.stubGlobal("window", undefined);
|
|
49
|
+
try {
|
|
50
|
+
return renderToString(<NavFixture />);
|
|
51
|
+
} finally {
|
|
52
|
+
vi.stubGlobal("window", browserWindow);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function hydrateAt(width: number) {
|
|
57
|
+
Object.defineProperty(window, "innerWidth", { configurable: true, value: width, writable: true });
|
|
58
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
59
|
+
const html = serverRender();
|
|
60
|
+
container = document.createElement("div");
|
|
61
|
+
container.innerHTML = html;
|
|
62
|
+
document.body.append(container);
|
|
63
|
+
const serverNav = container.querySelector("nav");
|
|
64
|
+
errors = [];
|
|
65
|
+
vi.spyOn(console, "error").mockImplementation((...args) => errors.push(args));
|
|
66
|
+
await act(async () => {
|
|
67
|
+
root = hydrateRoot(container!, <NavFixture />, {
|
|
68
|
+
onRecoverableError: (error) => errors.push(error),
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
return { html, serverNav };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hydrationErrors = () =>
|
|
75
|
+
errors.filter((error) => /hydration|hydrated|didn't match/i.test(String(error)));
|
|
76
|
+
|
|
77
|
+
afterEach(async () => {
|
|
78
|
+
if (root) await act(async () => root?.unmount());
|
|
79
|
+
root = undefined;
|
|
80
|
+
container?.remove();
|
|
81
|
+
container = undefined;
|
|
82
|
+
host.web = true;
|
|
83
|
+
vi.restoreAllMocks();
|
|
84
|
+
vi.unstubAllGlobals();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("useLayoutSizeClass through SSR hydration (MPO-376)", () => {
|
|
88
|
+
it("hydrates the server's medium markup at 390px, then adopts compact", async () => {
|
|
89
|
+
const { html, serverNav } = await hydrateAt(390);
|
|
90
|
+
expect(html).toContain('data-size-class="medium"');
|
|
91
|
+
expect(html).toContain("<ul>");
|
|
92
|
+
|
|
93
|
+
expect(hydrationErrors()).toEqual([]);
|
|
94
|
+
expect(container!.querySelector("nav")).toBe(serverNav);
|
|
95
|
+
expect(serverNav!.getAttribute("data-size-class")).toBe("compact");
|
|
96
|
+
expect(serverNav!.querySelector("ul")).toBeNull();
|
|
97
|
+
expect(serverNav!.querySelector("button")?.textContent).toBe("Menu");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("hydrates at 1280px with no mismatch and adopts xl", async () => {
|
|
101
|
+
const { serverNav } = await hydrateAt(1280);
|
|
102
|
+
expect(hydrationErrors()).toEqual([]);
|
|
103
|
+
expect(container!.querySelector("nav")).toBe(serverNav);
|
|
104
|
+
expect(serverNav!.getAttribute("data-size-class")).toBe("xl");
|
|
105
|
+
expect(serverNav!.querySelector("ul")).not.toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("sizes a native first render from the window, with no medium frame first", () => {
|
|
109
|
+
host.web = false;
|
|
110
|
+
host.nativeWidth = 390;
|
|
111
|
+
expect(renderToString(<NavFixture />)).toContain('data-size-class="compact"');
|
|
112
|
+
host.nativeWidth = 1280;
|
|
113
|
+
expect(renderToString(<NavFixture />)).toContain('data-size-class="xl"');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { isTouchable, isWebTouchable } from "@multiplatform.one/platform";
|
|
35
|
-
import {
|
|
35
|
+
import { useSyncExternalStore } from "react";
|
|
36
36
|
import { isWeb, useWindowDimensions } from "tamagui";
|
|
37
37
|
import { useResolvedKnobs } from "./useResolvedKnobs";
|
|
38
38
|
|
|
@@ -134,46 +134,47 @@ function readViewportWidth(): number {
|
|
|
134
134
|
return window.innerWidth;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
function subscribeViewportSizeClass(onChange: () => void): () => void {
|
|
138
|
+
if (!isWeb || typeof window === "undefined") return () => {};
|
|
139
|
+
const queries = [
|
|
140
|
+
layoutBreakpoints.xl,
|
|
141
|
+
layoutBreakpoints.large,
|
|
142
|
+
layoutBreakpoints.expanded,
|
|
143
|
+
layoutBreakpoints.medium,
|
|
144
|
+
].map((width) => window.matchMedia(`(min-width: ${width}px)`));
|
|
145
|
+
for (const mq of queries) mq.addEventListener("change", onChange);
|
|
146
|
+
return () => {
|
|
147
|
+
for (const mq of queries) mq.removeEventListener("change", onChange);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function viewportSizeClass(): LayoutSizeClass {
|
|
152
|
+
return getLayoutSizeClass(readViewportWidth());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function serverViewportSizeClass(): LayoutSizeClass {
|
|
156
|
+
return getLayoutSizeClass(layoutBreakpoints.medium);
|
|
157
|
+
}
|
|
158
|
+
|
|
137
159
|
/**
|
|
138
160
|
* Live layout size class from viewport width — cross-platform:
|
|
139
161
|
* - Web: matchMedia listeners on the class thresholds (re-renders only when
|
|
140
|
-
* the class flips, not per resize pixel).
|
|
162
|
+
* the class flips, not per resize pixel). The server renders `medium`, and
|
|
163
|
+
* so does the hydration render (MPO-376): a first client render at the
|
|
164
|
+
* viewport's class would not match the server HTML, so React adopts the
|
|
165
|
+
* live class right after hydrating. A client-only mount reads the viewport
|
|
166
|
+
* on its first render.
|
|
141
167
|
* - Native: react-native window dimensions (rotation / split-screen), so
|
|
142
|
-
* phones report `compact`
|
|
168
|
+
* phones report `compact` from the first render.
|
|
143
169
|
*/
|
|
144
170
|
export function useLayoutSizeClass(): LayoutSizeClass {
|
|
145
|
-
// Hook order is unconditional; on web the value is ignored in favor of the
|
|
146
|
-
// matchMedia path below (threshold-only updates).
|
|
147
171
|
const nativeWidth = useWindowDimensions().width;
|
|
148
|
-
const
|
|
149
|
-
|
|
172
|
+
const webSizeClass = useSyncExternalStore(
|
|
173
|
+
subscribeViewportSizeClass,
|
|
174
|
+
viewportSizeClass,
|
|
175
|
+
serverViewportSizeClass,
|
|
150
176
|
);
|
|
151
|
-
|
|
152
|
-
useEffect(() => {
|
|
153
|
-
if (!isWeb || typeof window === "undefined") return;
|
|
154
|
-
|
|
155
|
-
const queries = [
|
|
156
|
-
window.matchMedia(`(min-width: ${layoutBreakpoints.xl}px)`),
|
|
157
|
-
window.matchMedia(`(min-width: ${layoutBreakpoints.large}px)`),
|
|
158
|
-
window.matchMedia(`(min-width: ${layoutBreakpoints.expanded}px)`),
|
|
159
|
-
window.matchMedia(`(min-width: ${layoutBreakpoints.medium}px)`),
|
|
160
|
-
];
|
|
161
|
-
|
|
162
|
-
const sync = () => setSizeClass(getLayoutSizeClass(window.innerWidth));
|
|
163
|
-
sync();
|
|
164
|
-
for (const mq of queries) mq.addEventListener("change", sync);
|
|
165
|
-
return () => {
|
|
166
|
-
for (const mq of queries) mq.removeEventListener("change", sync);
|
|
167
|
-
};
|
|
168
|
-
}, []);
|
|
169
|
-
|
|
170
|
-
// Native: derive from the live dimensions; setState bails when unchanged.
|
|
171
|
-
useEffect(() => {
|
|
172
|
-
if (isWeb) return;
|
|
173
|
-
setSizeClass(getLayoutSizeClass(nativeWidth));
|
|
174
|
-
}, [nativeWidth]);
|
|
175
|
-
|
|
176
|
-
return sizeClass;
|
|
177
|
+
return isWeb ? webSizeClass : getLayoutSizeClass(nativeWidth);
|
|
177
178
|
}
|
|
178
179
|
|
|
179
180
|
/** True when the viewport may show two side-by-side panes. */
|
|
@@ -95,9 +95,13 @@ export declare function layoutSizeClassAtLeast(current: LayoutSizeClass, minimum
|
|
|
95
95
|
/**
|
|
96
96
|
* Live layout size class from viewport width — cross-platform:
|
|
97
97
|
* - Web: matchMedia listeners on the class thresholds (re-renders only when
|
|
98
|
-
* the class flips, not per resize pixel).
|
|
98
|
+
* the class flips, not per resize pixel). The server renders `medium`, and
|
|
99
|
+
* so does the hydration render (MPO-376): a first client render at the
|
|
100
|
+
* viewport's class would not match the server HTML, so React adopts the
|
|
101
|
+
* live class right after hydrating. A client-only mount reads the viewport
|
|
102
|
+
* on its first render.
|
|
99
103
|
* - Native: react-native window dimensions (rotation / split-screen), so
|
|
100
|
-
* phones report `compact`
|
|
104
|
+
* phones report `compact` from the first render.
|
|
101
105
|
*/
|
|
102
106
|
export declare function useLayoutSizeClass(): LayoutSizeClass;
|
|
103
107
|
/** True when the viewport may show two side-by-side panes. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"layoutTokens.d.ts","sourceRoot":"","sources":["../../src/theme/layoutTokens.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AASH,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,IAAI,CAAC;AAEjF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iBAAiB;IAC5B,uCAAuC;;IAEvC,qBAAqB;;IAErB;;;;OAIG;;IAEH;;;;;;OAMG;;IAEH,0EAA0E;;IAE1E,qBAAqB;;IAErB,qBAAqB;;IAErB,sBAAsB;;CAEd,CAAC;AAEX,MAAM,MAAM,oBAAoB,GAAG,MAAM,OAAO,iBAAiB,CAAC;AAElE;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,KAA2B,CAAC;AAE3D,6EAA6E;AAC7E,eAAO,MAAM,iBAAiB,KAA2B,CAAC;AAE1D,eAAO,MAAM,uBAAuB,EAAE,SAAS,eAAe,EAMpD,CAAC;AAEX,mEAAmE;AACnE,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,eAAe,CAMjE;AAED,qFAAqF;AACrF,wBAAgB,eAAe,CAAC,SAAS,EAAE,eAAe,GAAG,CAAC,GAAG,CAAC,CAEjE;AAED,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,eAAe,GACvB,OAAO,CAET;
|
|
1
|
+
{"version":3,"file":"layoutTokens.d.ts","sourceRoot":"","sources":["../../src/theme/layoutTokens.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AASH,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,IAAI,CAAC;AAEjF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iBAAiB;IAC5B,uCAAuC;;IAEvC,qBAAqB;;IAErB;;;;OAIG;;IAEH;;;;;;OAMG;;IAEH,0EAA0E;;IAE1E,qBAAqB;;IAErB,qBAAqB;;IAErB,sBAAsB;;CAEd,CAAC;AAEX,MAAM,MAAM,oBAAoB,GAAG,MAAM,OAAO,iBAAiB,CAAC;AAElE;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,KAA2B,CAAC;AAE3D,6EAA6E;AAC7E,eAAO,MAAM,iBAAiB,KAA2B,CAAC;AAE1D,eAAO,MAAM,uBAAuB,EAAE,SAAS,eAAe,EAMpD,CAAC;AAEX,mEAAmE;AACnE,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,eAAe,CAMjE;AAED,qFAAqF;AACrF,wBAAgB,eAAe,CAAC,SAAS,EAAE,eAAe,GAAG,CAAC,GAAG,CAAC,CAEjE;AAED,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,eAAe,GACvB,OAAO,CAET;AA8BD;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,IAAI,eAAe,CAQpD;AAED,8DAA8D;AAC9D,wBAAgB,YAAY,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,OAAO,CAItD;AAID,2DAA2D;AAC3D,eAAO,MAAM,gBAAgB,KAAK,CAAC;AACnC,eAAO,MAAM,oBAAoB,KAAK,CAAC;AACvC,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,gFAAgF;AAChF,wBAAgB,iBAAiB,CAC/B,OAAO,GAAE,MAAM,GAAG,MAAM,GAAG,IAAuB,GACjD,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAI3C;AAID,wFAAwF;AACxF,eAAO,MAAM,gBAAgB,KAAK,CAAC;AAEnC,kEAAkE;AAClE,wBAAgB,cAAc,IAAI,OAAO,CAExC;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,wBAAgB,gBAAgB,CAAC,IAAI,GAAE,MAAyB,GAAG,gBAAgB,CAElF;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,yEAAyE;AACzE,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,GAAG,GAAE,MAAyB,GAC7B,aAAa,CAGf;AAID;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,kBAAkB,IAAI,CAAC;AAEpC;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,kBAAkB,IAAI,CAAC;AAIpC;;;GAGG;AACH,wBAAgB,eAAe,IAAI;IACjC,WAAW,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7B,aAAa,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;CAChC,CAMA"}
|