@remit/web-client 0.0.125 → 0.0.127
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/harness/index.html +15 -0
- package/index.html +13 -6
- package/package.json +1 -1
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon-maskable-512.png +0 -0
- package/public/manifest.webmanifest +34 -0
- package/src/components/mail/MoveToTrigger.render.test.ts +36 -1
- package/src/hooks/useLayoutTier.test.ts +94 -23
- package/src/hooks/useLayoutTier.ts +25 -12
- package/src/hooks/useMediaQuery.ts +11 -10
- package/src/lib/desktop-breakpoint.test.ts +84 -0
- package/src/test-support/dom.ts +10 -6
package/harness/index.html
CHANGED
|
@@ -4,6 +4,21 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Remit</title>
|
|
7
|
+
<!--
|
|
8
|
+
Installability. This is the template the shipped image builds from; the
|
|
9
|
+
package's top-level index.html is the dev server's and carries the same
|
|
10
|
+
tags. The two theme colours are the light and dark --canvas tokens from
|
|
11
|
+
packages/ui/src/tokens.css.
|
|
12
|
+
-->
|
|
13
|
+
<link rel="manifest" href="/manifest.webmanifest" />
|
|
14
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
15
|
+
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32" />
|
|
16
|
+
<link rel="icon" href="/icons/icon-192.png" type="image/png" sizes="192x192" />
|
|
17
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
18
|
+
<meta name="apple-mobile-web-app-title" content="Remit" />
|
|
19
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
|
20
|
+
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f6f2e7" />
|
|
21
|
+
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0c1d23" />
|
|
7
22
|
<!--
|
|
8
23
|
Pre-paint theme to avoid a flash-of-wrong-theme on reload. Mirrors
|
|
9
24
|
theme-preference.ts: a stored "remit.theme" of "light"/"dark" wins;
|
package/index.html
CHANGED
|
@@ -5,13 +5,20 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Remit</title>
|
|
7
7
|
<!--
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
through the Vite server).
|
|
8
|
+
Installability. Keep in sync with harness/index.html, which is the
|
|
9
|
+
template the shipped image builds from: this one is the dev server's.
|
|
10
|
+
The two theme colours are the light and dark --canvas tokens from
|
|
11
|
+
packages/ui/src/tokens.css.
|
|
13
12
|
-->
|
|
14
|
-
<link rel="
|
|
13
|
+
<link rel="manifest" href="/manifest.webmanifest" />
|
|
14
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
15
|
+
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32" />
|
|
16
|
+
<link rel="icon" href="/icons/icon-192.png" type="image/png" sizes="192x192" />
|
|
17
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
18
|
+
<meta name="apple-mobile-web-app-title" content="Remit" />
|
|
19
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
|
20
|
+
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f6f2e7" />
|
|
21
|
+
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0c1d23" />
|
|
15
22
|
<!--
|
|
16
23
|
Pre-paint theme to avoid a flash-of-wrong-theme on reload. Mirrors
|
|
17
24
|
theme-preference.ts: a stored "remit.theme" of "light"/"dark" wins;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.127",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "/",
|
|
3
|
+
"name": "Remit",
|
|
4
|
+
"short_name": "Remit",
|
|
5
|
+
"description": "An email client you run yourself.",
|
|
6
|
+
"start_url": "/",
|
|
7
|
+
"scope": "/",
|
|
8
|
+
"display": "standalone",
|
|
9
|
+
"lang": "en",
|
|
10
|
+
"dir": "ltr",
|
|
11
|
+
"categories": ["productivity"],
|
|
12
|
+
"background_color": "#f5f5bd",
|
|
13
|
+
"theme_color": "#0c1d23",
|
|
14
|
+
"icons": [
|
|
15
|
+
{
|
|
16
|
+
"src": "/icons/icon-192.png",
|
|
17
|
+
"sizes": "192x192",
|
|
18
|
+
"type": "image/png",
|
|
19
|
+
"purpose": "any"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"src": "/icons/icon-512.png",
|
|
23
|
+
"sizes": "512x512",
|
|
24
|
+
"type": "image/png",
|
|
25
|
+
"purpose": "any"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"src": "/icons/icon-maskable-512.png",
|
|
29
|
+
"sizes": "512x512",
|
|
30
|
+
"type": "image/png",
|
|
31
|
+
"purpose": "maskable"
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
@@ -30,9 +30,15 @@ const mount = (
|
|
|
30
30
|
disabledHint?: string;
|
|
31
31
|
onMove?: (destinationMailboxId: string) => void;
|
|
32
32
|
viewportWidth?: number;
|
|
33
|
+
orientation?: "portrait" | "landscape";
|
|
34
|
+
pointer?: "coarse" | "fine";
|
|
33
35
|
} = {},
|
|
34
36
|
): DomHarness => {
|
|
35
|
-
harness = createDomHarness({
|
|
37
|
+
harness = createDomHarness({
|
|
38
|
+
viewportWidth: options.viewportWidth,
|
|
39
|
+
orientation: options.orientation,
|
|
40
|
+
pointer: options.pointer,
|
|
41
|
+
});
|
|
36
42
|
if (options.mailboxes) {
|
|
37
43
|
harness.queryClient.setQueryData(
|
|
38
44
|
mailboxOperationsListMailboxesQueryKey({
|
|
@@ -171,6 +177,35 @@ describe("MoveToTrigger", () => {
|
|
|
171
177
|
);
|
|
172
178
|
});
|
|
173
179
|
|
|
180
|
+
it("opens a dialog on a tablet held upright, which is 1024px wide", () => {
|
|
181
|
+
// Regression: the desktop gate was a bare width, and a large tablet in
|
|
182
|
+
// portrait is exactly 1024px — it got the desktop popover on a touch
|
|
183
|
+
// screen with no room for it.
|
|
184
|
+
const dom = mount({
|
|
185
|
+
mailboxes: FOLDERS,
|
|
186
|
+
viewportWidth: 1024,
|
|
187
|
+
orientation: "portrait",
|
|
188
|
+
pointer: "coarse",
|
|
189
|
+
});
|
|
190
|
+
assert.equal(
|
|
191
|
+
dom.byLabel("Move to folder").getAttribute("aria-haspopup"),
|
|
192
|
+
"dialog",
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("opens a popover on the same tablet turned sideways", () => {
|
|
197
|
+
const dom = mount({
|
|
198
|
+
mailboxes: FOLDERS,
|
|
199
|
+
viewportWidth: 1024,
|
|
200
|
+
orientation: "landscape",
|
|
201
|
+
pointer: "coarse",
|
|
202
|
+
});
|
|
203
|
+
assert.notEqual(
|
|
204
|
+
dom.byLabel("Move to folder").getAttribute("aria-haspopup"),
|
|
205
|
+
"dialog",
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
174
209
|
it("asks for the folder list only once the picker is opened", () => {
|
|
175
210
|
const dom = mount();
|
|
176
211
|
assert.equal(dom.queryAll("[role=treeitem]").length, 0);
|
|
@@ -1,23 +1,79 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
isSinglePaneTier,
|
|
5
|
+
resolveLayoutTier,
|
|
6
|
+
type ViewportProfile,
|
|
7
|
+
} from "./useLayoutTier";
|
|
8
|
+
|
|
9
|
+
const mouse = (width: number): ViewportProfile => ({
|
|
10
|
+
width,
|
|
11
|
+
orientation: "landscape",
|
|
12
|
+
pointer: "fine",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const tabletHeldUpright = (width: number): ViewportProfile => ({
|
|
16
|
+
width,
|
|
17
|
+
orientation: "portrait",
|
|
18
|
+
pointer: "coarse",
|
|
19
|
+
});
|
|
4
20
|
|
|
5
21
|
describe("resolveLayoutTier breakpoints (#784)", () => {
|
|
6
22
|
it("is phone below 768", () => {
|
|
7
|
-
assert.equal(resolveLayoutTier(767), "phone");
|
|
8
|
-
assert.equal(resolveLayoutTier(390), "phone");
|
|
9
|
-
assert.equal(resolveLayoutTier(0), "phone");
|
|
23
|
+
assert.equal(resolveLayoutTier(mouse(767)), "phone");
|
|
24
|
+
assert.equal(resolveLayoutTier(mouse(390)), "phone");
|
|
25
|
+
assert.equal(resolveLayoutTier(mouse(0)), "phone");
|
|
10
26
|
});
|
|
11
27
|
|
|
12
28
|
it("is tablet from 768 up to 1023", () => {
|
|
13
|
-
assert.equal(resolveLayoutTier(768), "tablet");
|
|
14
|
-
assert.equal(resolveLayoutTier(900), "tablet");
|
|
15
|
-
assert.equal(resolveLayoutTier(1023), "tablet");
|
|
29
|
+
assert.equal(resolveLayoutTier(mouse(768)), "tablet");
|
|
30
|
+
assert.equal(resolveLayoutTier(mouse(900)), "tablet");
|
|
31
|
+
assert.equal(resolveLayoutTier(mouse(1023)), "tablet");
|
|
16
32
|
});
|
|
17
33
|
|
|
18
34
|
it("is desktop from 1024 up", () => {
|
|
19
|
-
assert.equal(resolveLayoutTier(1024), "desktop");
|
|
20
|
-
assert.equal(resolveLayoutTier(1440), "desktop");
|
|
35
|
+
assert.equal(resolveLayoutTier(mouse(1024)), "desktop");
|
|
36
|
+
assert.equal(resolveLayoutTier(mouse(1440)), "desktop");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("a touch screen held upright is never desktop", () => {
|
|
41
|
+
// Regression: a large tablet is exactly 1024px wide in portrait, so a width
|
|
42
|
+
// threshold alone handed it the four-pane desktop shell. Turned sideways
|
|
43
|
+
// the same device is a desktop surface, and a tall monitor always is.
|
|
44
|
+
it("is tablet at 1024px in portrait on a touch screen", () => {
|
|
45
|
+
assert.equal(resolveLayoutTier(tabletHeldUpright(1024)), "tablet");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("is tablet at wider portrait touch widths too", () => {
|
|
49
|
+
assert.equal(resolveLayoutTier(tabletHeldUpright(1366)), "tablet");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("is desktop at 1024px on the same device turned sideways", () => {
|
|
53
|
+
assert.equal(
|
|
54
|
+
resolveLayoutTier({
|
|
55
|
+
width: 1024,
|
|
56
|
+
orientation: "landscape",
|
|
57
|
+
pointer: "coarse",
|
|
58
|
+
}),
|
|
59
|
+
"desktop",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("is desktop on a portrait monitor, which has a fine pointer", () => {
|
|
64
|
+
assert.equal(
|
|
65
|
+
resolveLayoutTier({
|
|
66
|
+
width: 1200,
|
|
67
|
+
orientation: "portrait",
|
|
68
|
+
pointer: "fine",
|
|
69
|
+
}),
|
|
70
|
+
"desktop",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("keeps the phone/tablet split on width alone", () => {
|
|
75
|
+
assert.equal(resolveLayoutTier(tabletHeldUpright(767)), "phone");
|
|
76
|
+
assert.equal(resolveLayoutTier(tabletHeldUpright(768)), "tablet");
|
|
21
77
|
});
|
|
22
78
|
});
|
|
23
79
|
|
|
@@ -39,10 +95,14 @@ describe("isSinglePaneTier — compose surface must mount below desktop", () =>
|
|
|
39
95
|
});
|
|
40
96
|
|
|
41
97
|
it("treats every below-desktop width as single-pane via resolveLayoutTier", () => {
|
|
42
|
-
assert.equal(isSinglePaneTier(resolveLayoutTier(390)), true);
|
|
43
|
-
assert.equal(isSinglePaneTier(resolveLayoutTier(768)), true);
|
|
44
|
-
assert.equal(isSinglePaneTier(resolveLayoutTier(1023)), true);
|
|
45
|
-
assert.equal(isSinglePaneTier(resolveLayoutTier(1024)), false);
|
|
98
|
+
assert.equal(isSinglePaneTier(resolveLayoutTier(mouse(390))), true);
|
|
99
|
+
assert.equal(isSinglePaneTier(resolveLayoutTier(mouse(768))), true);
|
|
100
|
+
assert.equal(isSinglePaneTier(resolveLayoutTier(mouse(1023))), true);
|
|
101
|
+
assert.equal(isSinglePaneTier(resolveLayoutTier(mouse(1024))), false);
|
|
102
|
+
assert.equal(
|
|
103
|
+
isSinglePaneTier(resolveLayoutTier(tabletHeldUpright(1024))),
|
|
104
|
+
true,
|
|
105
|
+
);
|
|
46
106
|
});
|
|
47
107
|
});
|
|
48
108
|
|
|
@@ -53,21 +113,32 @@ describe("exactly one search field is mounted at every width", () => {
|
|
|
53
113
|
// `isSinglePaneTier`, so they are each other's complement by construction —
|
|
54
114
|
// if either is rewritten to its own expression this fails. Two mounted
|
|
55
115
|
// fields compete for "/" and for focus (#59); zero leaves no way to search.
|
|
56
|
-
const mountsTopBarField = (
|
|
57
|
-
!isSinglePaneTier(resolveLayoutTier(
|
|
58
|
-
const mountsHeaderField = (
|
|
59
|
-
isSinglePaneTier(resolveLayoutTier(
|
|
116
|
+
const mountsTopBarField = (viewport: ViewportProfile): boolean =>
|
|
117
|
+
!isSinglePaneTier(resolveLayoutTier(viewport));
|
|
118
|
+
const mountsHeaderField = (viewport: ViewportProfile): boolean =>
|
|
119
|
+
isSinglePaneTier(resolveLayoutTier(viewport));
|
|
60
120
|
|
|
61
|
-
for (const
|
|
62
|
-
|
|
121
|
+
for (const viewport of [
|
|
122
|
+
mouse(0),
|
|
123
|
+
mouse(390),
|
|
124
|
+
mouse(767),
|
|
125
|
+
mouse(768),
|
|
126
|
+
mouse(1023),
|
|
127
|
+
mouse(1024),
|
|
128
|
+
mouse(1440),
|
|
129
|
+
tabletHeldUpright(1024),
|
|
130
|
+
]) {
|
|
131
|
+
it(`mounts one field at ${viewport.width}px ${viewport.orientation}/${viewport.pointer}`, () => {
|
|
63
132
|
const fields =
|
|
64
|
-
Number(mountsTopBarField(
|
|
65
|
-
|
|
133
|
+
Number(mountsTopBarField(viewport)) +
|
|
134
|
+
Number(mountsHeaderField(viewport));
|
|
135
|
+
assert.equal(fields, 1, `${viewport.width}px mounts ${fields} fields`);
|
|
66
136
|
});
|
|
67
137
|
}
|
|
68
138
|
|
|
69
139
|
it("puts the field in the top bar only from desktop up", () => {
|
|
70
|
-
assert.equal(mountsTopBarField(1023), false);
|
|
71
|
-
assert.equal(mountsTopBarField(1024), true);
|
|
140
|
+
assert.equal(mountsTopBarField(mouse(1023)), false);
|
|
141
|
+
assert.equal(mountsTopBarField(mouse(1024)), true);
|
|
142
|
+
assert.equal(mountsTopBarField(tabletHeldUpright(1024)), false);
|
|
72
143
|
});
|
|
73
144
|
});
|
|
@@ -1,32 +1,45 @@
|
|
|
1
|
+
import { DESKTOP_MEDIA_QUERY, DESKTOP_MIN_WIDTH } from "@remit/ui";
|
|
1
2
|
import { useMediaQuery } from "./useMediaQuery";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Responsive layout tiers for the mail shell:
|
|
5
6
|
* - `phone` (< 768px) single pane: list → reading swaps in place.
|
|
6
|
-
* - `tablet` (768–1023px
|
|
7
|
-
*
|
|
7
|
+
* - `tablet` (768–1023px, or any width on a touch screen held upright)
|
|
8
|
+
* single pane, nav rail is drawer-backed.
|
|
9
|
+
* - `desktop` (>= 1024px, not portrait-on-touch)
|
|
10
|
+
* full four-pane shell (nav · list · reading · pane 4).
|
|
8
11
|
*
|
|
9
12
|
* Pane 4 (intelligence) is desktop-only; `useIsDesktop` stays the pane-4 gate.
|
|
10
13
|
*/
|
|
11
14
|
export type LayoutTier = "phone" | "tablet" | "desktop";
|
|
12
15
|
|
|
13
16
|
export const TABLET_MIN_WIDTH = 768;
|
|
14
|
-
export const DESKTOP_MIN_WIDTH = 1024;
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
export interface ViewportProfile {
|
|
19
|
+
width: number;
|
|
20
|
+
orientation: "portrait" | "landscape";
|
|
21
|
+
pointer: "coarse" | "fine";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Pure tier resolution, so the logic is testable without a DOM. */
|
|
25
|
+
export const resolveLayoutTier = ({
|
|
26
|
+
width,
|
|
27
|
+
orientation,
|
|
28
|
+
pointer,
|
|
29
|
+
}: ViewportProfile): LayoutTier => {
|
|
30
|
+
const heldUpright = orientation === "portrait" && pointer === "coarse";
|
|
31
|
+
if (width >= DESKTOP_MIN_WIDTH && !heldUpright) return "desktop";
|
|
19
32
|
if (width >= TABLET_MIN_WIDTH) return "tablet";
|
|
20
33
|
return "phone";
|
|
21
34
|
};
|
|
22
35
|
|
|
23
36
|
/**
|
|
24
37
|
* Whether the mail shell shows a SINGLE pane (no reading pane) at this tier.
|
|
25
|
-
* AppShellSlotted mounts the reading pane only at desktop
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
38
|
+
* AppShellSlotted mounts the reading pane only at desktop, so both phone AND
|
|
39
|
+
* tablet are single-pane: the one pane swaps in place between the list, an
|
|
40
|
+
* open thread, and the compose surface. Compose therefore must mount via the
|
|
41
|
+
* single-pane view at tablet too — keying the pane choice off "phone" alone
|
|
42
|
+
* left tablet with no compose surface ("c" / FAB opened nothing).
|
|
30
43
|
*/
|
|
31
44
|
export const isSinglePaneTier = (tier: LayoutTier): boolean =>
|
|
32
45
|
tier !== "desktop";
|
|
@@ -37,7 +50,7 @@ export const isSinglePaneTier = (tier: LayoutTier): boolean =>
|
|
|
37
50
|
*/
|
|
38
51
|
export const useLayoutTier = (): LayoutTier => {
|
|
39
52
|
const isTabletUp = useMediaQuery(`(min-width: ${TABLET_MIN_WIDTH}px)`);
|
|
40
|
-
const isDesktop = useMediaQuery(
|
|
53
|
+
const isDesktop = useMediaQuery(DESKTOP_MEDIA_QUERY);
|
|
41
54
|
if (isDesktop) return "desktop";
|
|
42
55
|
if (isTabletUp) return "tablet";
|
|
43
56
|
return "phone";
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
+
import { DESKTOP_MEDIA_QUERY } from "@remit/ui";
|
|
1
2
|
import { useEffect, useState } from "react";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Subscribes to a CSS media query and returns its current `matches` value.
|
|
5
6
|
* SSR-safe: returns `false` on the server / initial render before hydration.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* useMediaQuery("(min-width: 1024px)") // Tailwind lg and up
|
|
8
|
+
* Layout breakpoints are exported by `@remit/ui` alongside the Tailwind
|
|
9
|
+
* variants they mirror — pass those, not a hand-written width.
|
|
10
10
|
*/
|
|
11
11
|
export const useMediaQuery = (query: string): boolean => {
|
|
12
12
|
const [matches, setMatches] = useState(() => {
|
|
@@ -28,12 +28,13 @@ export const useMediaQuery = (query: string): boolean => {
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* True at desktop
|
|
32
|
-
* renders the single-pane mobile
|
|
33
|
-
*
|
|
34
|
-
*
|
|
31
|
+
* True at desktop (Tailwind `lg:` and up): at least 1024px wide and not a
|
|
32
|
+
* touch screen held upright. Everything else renders the single-pane mobile
|
|
33
|
+
* layout — phones, narrow tablets, and a large tablet in portrait, which is
|
|
34
|
+
* 1024px wide but has no room for the three-pane desktop grid (#682).
|
|
35
35
|
*
|
|
36
|
-
* The CSS-gated mobile chrome (Drawer, ComposeFab) uses `lg:hidden
|
|
37
|
-
*
|
|
36
|
+
* The CSS-gated mobile chrome (Drawer, ComposeFab) uses `lg:hidden`, and the
|
|
37
|
+
* `lg` variant is redefined in `@remit/ui`'s token sheet with the same
|
|
38
|
+
* condition — change `DESKTOP_MEDIA_QUERY` and both move together.
|
|
38
39
|
*/
|
|
39
|
-
export const useIsDesktop = (): boolean => useMediaQuery(
|
|
40
|
+
export const useIsDesktop = (): boolean => useMediaQuery(DESKTOP_MEDIA_QUERY);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { DESKTOP_MEDIA_QUERY } from "@remit/ui";
|
|
8
|
+
import { compile } from "tailwindcss";
|
|
9
|
+
|
|
10
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
const entryStylesheets = {
|
|
13
|
+
app: resolve(here, "../index.css"),
|
|
14
|
+
workbench: resolve(here, "../../../workbench/.storybook/tailwind.css"),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const loadStylesheet = async (id: string, base: string) => {
|
|
18
|
+
const require = createRequire(resolve(base, "resolve-from-here.js"));
|
|
19
|
+
const path =
|
|
20
|
+
id.startsWith(".") || id.startsWith("/")
|
|
21
|
+
? resolve(base, id)
|
|
22
|
+
: resolveCssExport(require, id);
|
|
23
|
+
return { path, base: dirname(path), content: await readFile(path, "utf8") };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const resolveCssExport = (require: NodeRequire, id: string): string => {
|
|
27
|
+
const direct = tryResolve(require, id);
|
|
28
|
+
if (direct?.endsWith(".css")) return direct;
|
|
29
|
+
return require.resolve(`${id}/index.css`);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const tryResolve = (require: NodeRequire, id: string): string | undefined => {
|
|
33
|
+
try {
|
|
34
|
+
return require.resolve(id);
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The media condition Tailwind wraps a utility in, read back off the compiled
|
|
42
|
+
* stylesheet. Building one candidate at a time keeps a single rule inside the
|
|
43
|
+
* block, so the prelude immediately precedes it.
|
|
44
|
+
*/
|
|
45
|
+
const compiledMediaConditionFor = async (
|
|
46
|
+
stylesheet: string,
|
|
47
|
+
utility: string,
|
|
48
|
+
): Promise<string> => {
|
|
49
|
+
const compiler = await compile(await readFile(stylesheet, "utf8"), {
|
|
50
|
+
base: dirname(stylesheet),
|
|
51
|
+
loadStylesheet,
|
|
52
|
+
});
|
|
53
|
+
const css = compiler.build([utility]);
|
|
54
|
+
const escaped = utility.replace(/:/g, "\\\\:");
|
|
55
|
+
const match = css.match(
|
|
56
|
+
new RegExp(`@media ([^\\n{]+?)\\s*\\{\\s*\\.${escaped}\\s*\\{`),
|
|
57
|
+
);
|
|
58
|
+
assert.ok(match, `${utility} emitted no media-wrapped rule in ${stylesheet}`);
|
|
59
|
+
return match[1];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe("the lg variant and the desktop hooks gate on the same condition", () => {
|
|
63
|
+
// Regression: the desktop gate used to be a bare 1024px width, so a large
|
|
64
|
+
// tablet in portrait — exactly 1024px wide — got the four-pane desktop
|
|
65
|
+
// shell. Two systems encode the gate: DESKTOP_MEDIA_QUERY drives the JS
|
|
66
|
+
// hooks, and Tailwind's `lg` variant (redefined in @remit/ui's token sheet)
|
|
67
|
+
// drives the CSS chrome. If either moves alone the app splits in half.
|
|
68
|
+
for (const [name, stylesheet] of Object.entries(entryStylesheets)) {
|
|
69
|
+
it(`compiles lg:hidden in the ${name} stylesheet to DESKTOP_MEDIA_QUERY`, async () => {
|
|
70
|
+
assert.equal(
|
|
71
|
+
await compiledMediaConditionFor(stylesheet, "lg:hidden"),
|
|
72
|
+
DESKTOP_MEDIA_QUERY,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
it("excludes a coarse pointer held upright", () => {
|
|
78
|
+
assert.match(DESKTOP_MEDIA_QUERY, /min-width: 1024px/);
|
|
79
|
+
assert.match(
|
|
80
|
+
DESKTOP_MEDIA_QUERY,
|
|
81
|
+
/not \(\(orientation: portrait\) and \(pointer: coarse\)\)/,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
});
|
package/src/test-support/dom.ts
CHANGED
|
@@ -9,10 +9,7 @@
|
|
|
9
9
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
10
10
|
import React, { act, createElement } from "react";
|
|
11
11
|
import { createRoot, type Root } from "react-dom/client";
|
|
12
|
-
import {
|
|
13
|
-
DEFAULT_VIEWPORT_WIDTH,
|
|
14
|
-
setViewportWidth,
|
|
15
|
-
} from "../../test-support/dom-env.mjs";
|
|
12
|
+
import { DEFAULT_VIEWPORT, setViewport } from "../../test-support/dom-env.mjs";
|
|
16
13
|
import { ErrorBannerProvider } from "../components/ui/ErrorBannerProvider";
|
|
17
14
|
|
|
18
15
|
export interface DomHarness {
|
|
@@ -46,10 +43,17 @@ export interface DomHarness {
|
|
|
46
43
|
export interface DomOptions {
|
|
47
44
|
/** Width `matchMedia` answers against — jsdom has no layout of its own. */
|
|
48
45
|
viewportWidth?: number;
|
|
46
|
+
/** Screen posture `matchMedia` answers against — jsdom has no device. */
|
|
47
|
+
orientation?: "portrait" | "landscape";
|
|
48
|
+
pointer?: "coarse" | "fine";
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export const createDomHarness = (options: DomOptions = {}): DomHarness => {
|
|
52
|
-
|
|
52
|
+
setViewport({
|
|
53
|
+
width: options.viewportWidth ?? DEFAULT_VIEWPORT.width,
|
|
54
|
+
orientation: options.orientation ?? DEFAULT_VIEWPORT.orientation,
|
|
55
|
+
pointer: options.pointer ?? DEFAULT_VIEWPORT.pointer,
|
|
56
|
+
});
|
|
53
57
|
// The test loader transpiles remit-ui's `.tsx` with the classic JSX runtime,
|
|
54
58
|
// which reads a global `React`. Vite uses the automatic runtime, so this
|
|
55
59
|
// shim exists only for the test harness.
|
|
@@ -106,7 +110,7 @@ export const createDomHarness = (options: DomOptions = {}): DomHarness => {
|
|
|
106
110
|
// which otherwise holds the test process open for its full gcTime.
|
|
107
111
|
queryClient.clear();
|
|
108
112
|
container.remove();
|
|
109
|
-
|
|
113
|
+
setViewport(DEFAULT_VIEWPORT);
|
|
110
114
|
},
|
|
111
115
|
html: () => container.innerHTML,
|
|
112
116
|
text: () => container.textContent ?? "",
|