@carlonicora/nextjs-jsonapi 1.133.0 → 1.135.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/dist/{BlockNoteEditor-VXK7INMR.mjs → BlockNoteEditor-GVNC7UWS.mjs} +4 -4
- package/dist/{BlockNoteEditor-H626BS5Y.js → BlockNoteEditor-XIB4Q2B7.js} +19 -19
- package/dist/{BlockNoteEditor-H626BS5Y.js.map → BlockNoteEditor-XIB4Q2B7.js.map} +1 -1
- package/dist/billing/index.js +357 -357
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-F6KBHC2X.js → chunk-C2LEIBUK.js} +7 -7
- package/dist/{chunk-F6KBHC2X.js.map → chunk-C2LEIBUK.js.map} +1 -1
- package/dist/{chunk-DZL2G7NB.mjs → chunk-DVOBOFCF.mjs} +38 -11
- package/dist/chunk-DVOBOFCF.mjs.map +1 -0
- package/dist/{chunk-TVJFCWST.js → chunk-IWSLZEWZ.js} +1058 -885
- package/dist/chunk-IWSLZEWZ.js.map +1 -0
- package/dist/{chunk-VSAAVVHY.mjs → chunk-M5IAN7IR.mjs} +2 -2
- package/dist/{chunk-WEDLGKSS.mjs → chunk-MZAD63EI.mjs} +2629 -2456
- package/dist/chunk-MZAD63EI.mjs.map +1 -0
- package/dist/{chunk-5IE6DZ2D.js → chunk-W73JBOIR.js} +39 -12
- package/dist/{chunk-5IE6DZ2D.js.map → chunk-W73JBOIR.js.map} +1 -1
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +17 -2
- package/dist/components/index.d.ts +17 -2
- package/dist/components/index.js +6 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +5 -3
- package/dist/contexts/index.d.mts +85 -3
- package/dist/contexts/index.d.ts +85 -3
- package/dist/contexts/index.js +16 -4
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +15 -3
- package/dist/core/index.d.mts +3 -1
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.js +6 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +5 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/package.json +1 -1
- package/src/components/containers/PageContainer.tsx +7 -2
- package/src/components/containers/RoundPageContainer.tsx +31 -9
- package/src/components/navigations/Header.tsx +17 -6
- package/src/components/navigations/MobileNavigationBar.tsx +99 -0
- package/src/components/navigations/__tests__/MobileNavigationBar.spec.tsx +131 -0
- package/src/components/navigations/index.ts +1 -0
- package/src/components/pages/PageContentContainer.tsx +1 -1
- package/src/contexts/HeaderChildrenContext.tsx +23 -2
- package/src/contexts/HeaderLogoContext.tsx +25 -0
- package/src/contexts/MobileNavigationContext.tsx +66 -0
- package/src/contexts/ViewportContext.tsx +46 -0
- package/src/contexts/index.ts +3 -0
- package/src/core/registry/__tests__/viewportStore.test.ts +50 -0
- package/src/core/registry/viewportStore.ts +47 -0
- package/src/utils/__tests__/use-mobile.test.tsx +137 -0
- package/src/utils/index.ts +1 -1
- package/src/utils/use-mobile.tsx +70 -13
- package/dist/chunk-DZL2G7NB.mjs.map +0 -1
- package/dist/chunk-TVJFCWST.js.map +0 -1
- package/dist/chunk-WEDLGKSS.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-VXK7INMR.mjs.map → BlockNoteEditor-GVNC7UWS.mjs.map} +0 -0
- /package/dist/{chunk-VSAAVVHY.mjs.map → chunk-M5IAN7IR.mjs.map} +0 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { render, screen, act } from "@testing-library/react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { useIsMobile, VIEWPORT_COOKIE_NAME } from "../use-mobile";
|
|
4
|
+
// The provider lives in `contexts/` (not `utils/`) because the server layouts
|
|
5
|
+
// render it directly and only `dist/contexts/*` gets tsup's "use client" banner.
|
|
6
|
+
import { ViewportProvider } from "../../contexts/ViewportContext";
|
|
7
|
+
|
|
8
|
+
function Probe() {
|
|
9
|
+
const isMobile = useIsMobile();
|
|
10
|
+
return <span data-testid="probe">{isMobile ? "mobile" : "desktop"}</span>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function setWidth(width: number) {
|
|
14
|
+
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: width });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
setWidth(1280);
|
|
19
|
+
document.cookie = `${VIEWPORT_COOKIE_NAME}=; path=/; max-age=0`;
|
|
20
|
+
vi.mocked(window.matchMedia).mockClear();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("server-graph safety", () => {
|
|
24
|
+
/**
|
|
25
|
+
* Regression guard. This module is reachable from the RSC/Node graph:
|
|
26
|
+
* apps/web instrumentation.ts -> jsonapi.config -> Bootstrapper ->
|
|
27
|
+
* @carlonicora/nextjs-jsonapi/core -> utils barrel -> use-mobile.
|
|
28
|
+
* Creating the context at module scope crashed the dev server on boot
|
|
29
|
+
* ("createContext is not a function"), because React resolves to the
|
|
30
|
+
* vendored RSC build there. Importing must stay side-effect free.
|
|
31
|
+
*/
|
|
32
|
+
it("does not create the context merely by being imported", async () => {
|
|
33
|
+
const key = Symbol.for("nextjs-jsonapi:viewportContext");
|
|
34
|
+
const store = globalThis as unknown as Record<symbol, unknown>;
|
|
35
|
+
const previous = store[key];
|
|
36
|
+
delete store[key];
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
vi.resetModules();
|
|
40
|
+
await import("../use-mobile");
|
|
41
|
+
expect(store[key]).toBeUndefined();
|
|
42
|
+
} finally {
|
|
43
|
+
store[key] = previous;
|
|
44
|
+
vi.resetModules();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("creates the context on first call and returns a stable identity after", async () => {
|
|
49
|
+
const key = Symbol.for("nextjs-jsonapi:viewportContext");
|
|
50
|
+
const store = globalThis as unknown as Record<symbol, unknown>;
|
|
51
|
+
const previous = store[key];
|
|
52
|
+
delete store[key];
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
vi.resetModules();
|
|
56
|
+
const { getViewportContext } = await import("../use-mobile");
|
|
57
|
+
const first = getViewportContext();
|
|
58
|
+
expect(first).toBeDefined();
|
|
59
|
+
expect(getViewportContext()).toBe(first);
|
|
60
|
+
} finally {
|
|
61
|
+
store[key] = previous;
|
|
62
|
+
vi.resetModules();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("useIsMobile with a ViewportProvider", () => {
|
|
68
|
+
it("returns the server seed on the first render, before any measurement", () => {
|
|
69
|
+
setWidth(1280);
|
|
70
|
+
const seen: string[] = [];
|
|
71
|
+
function Recorder() {
|
|
72
|
+
const isMobile = useIsMobile();
|
|
73
|
+
seen.push(isMobile ? "mobile" : "desktop");
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
render(
|
|
77
|
+
<ViewportProvider initialIsMobile={true}>
|
|
78
|
+
<Recorder />
|
|
79
|
+
</ViewportProvider>,
|
|
80
|
+
);
|
|
81
|
+
expect(seen[0]).toBe("mobile");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("corrects a wrong seed after mounting and persists the measurement", () => {
|
|
85
|
+
setWidth(1280);
|
|
86
|
+
render(
|
|
87
|
+
<ViewportProvider initialIsMobile={true}>
|
|
88
|
+
<Probe />
|
|
89
|
+
</ViewportProvider>,
|
|
90
|
+
);
|
|
91
|
+
expect(screen.getByTestId("probe").textContent).toBe("desktop");
|
|
92
|
+
expect(document.cookie).toContain(`${VIEWPORT_COOKIE_NAME}=0`);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("keeps a correct seed and persists it", () => {
|
|
96
|
+
setWidth(500);
|
|
97
|
+
render(
|
|
98
|
+
<ViewportProvider initialIsMobile={true}>
|
|
99
|
+
<Probe />
|
|
100
|
+
</ViewportProvider>,
|
|
101
|
+
);
|
|
102
|
+
expect(screen.getByTestId("probe").textContent).toBe("mobile");
|
|
103
|
+
expect(document.cookie).toContain(`${VIEWPORT_COOKIE_NAME}=1`);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("follows a viewport change through the matchMedia listener", () => {
|
|
107
|
+
setWidth(1280);
|
|
108
|
+
render(
|
|
109
|
+
<ViewportProvider initialIsMobile={false}>
|
|
110
|
+
<Probe />
|
|
111
|
+
</ViewportProvider>,
|
|
112
|
+
);
|
|
113
|
+
expect(screen.getByTestId("probe").textContent).toBe("desktop");
|
|
114
|
+
|
|
115
|
+
const mql = vi.mocked(window.matchMedia).mock.results[0].value;
|
|
116
|
+
const handler = mql.addEventListener.mock.calls[0][1] as () => void;
|
|
117
|
+
setWidth(500);
|
|
118
|
+
act(() => handler());
|
|
119
|
+
|
|
120
|
+
expect(screen.getByTestId("probe").textContent).toBe("mobile");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("useIsMobile without a provider (legacy fallback)", () => {
|
|
125
|
+
it("measures the viewport itself and does not write a cookie", () => {
|
|
126
|
+
setWidth(500);
|
|
127
|
+
render(<Probe />);
|
|
128
|
+
expect(screen.getByTestId("probe").textContent).toBe("mobile");
|
|
129
|
+
expect(document.cookie).not.toContain(`${VIEWPORT_COOKIE_NAME}=`);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("reports desktop for a wide viewport", () => {
|
|
133
|
+
setWidth(1280);
|
|
134
|
+
render(<Probe />);
|
|
135
|
+
expect(screen.getByTestId("probe").textContent).toBe("desktop");
|
|
136
|
+
});
|
|
137
|
+
});
|
package/src/utils/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type { ClassValue } from "clsx";
|
|
2
2
|
export { cn } from "./cn";
|
|
3
3
|
export { composeRefs, useComposedRefs } from "./compose-refs";
|
|
4
|
-
export { useIsMobile } from "./use-mobile";
|
|
4
|
+
export { useIsMobile, MOBILE_BREAKPOINT, VIEWPORT_COOKIE_NAME } from "./use-mobile";
|
|
5
5
|
|
|
6
6
|
// New utilities
|
|
7
7
|
export { formatDate, formatLocalDate, type FormatOption } from "./date-formatter";
|
package/src/utils/use-mobile.tsx
CHANGED
|
@@ -2,20 +2,77 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
|
|
5
|
-
const MOBILE_BREAKPOINT = 768;
|
|
5
|
+
export const MOBILE_BREAKPOINT = 768;
|
|
6
|
+
export const VIEWPORT_COOKIE_NAME = "viewport_mobile";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The viewport context is keyed on a globalThis Symbol so that tsup's
|
|
10
|
+
* multi-entry `splitting: true` build cannot produce two distinct context
|
|
11
|
+
* objects. If it did, the ViewportProvider (built into the `contexts` entry)
|
|
12
|
+
* and this hook (built into the `core` entry) would silently miss each other:
|
|
13
|
+
* the hook would read `undefined`, fall back to the legacy path, and the
|
|
14
|
+
* mobile-layout flash would return with nothing failing loudly.
|
|
15
|
+
*
|
|
16
|
+
* THIS MODULE IS SERVER-REACHABLE. `core` is deliberately absent from
|
|
17
|
+
* tsup.config.ts's `clientEntries` list, so `dist/core/*` gets NO "use client"
|
|
18
|
+
* banner — that is what lets `apps/web/src/instrumentation.ts` import
|
|
19
|
+
* jsonapi.config -> Bootstrapper -> `@carlonicora/nextjs-jsonapi/core` at
|
|
20
|
+
* server startup. Two rules follow, both load-bearing:
|
|
21
|
+
*
|
|
22
|
+
* 1. React is imported as a NAMESPACE. A named
|
|
23
|
+
* `import { createContext } from "react"` makes Turbopack statically refuse
|
|
24
|
+
* the module: "You're importing a module that depends on `createContext`
|
|
25
|
+
* into a React Server Component module."
|
|
26
|
+
* 2. Creation is LAZY, never at module scope. On the server React resolves to
|
|
27
|
+
* the vendored RSC build, which omits `createContext` entirely
|
|
28
|
+
* ("createContext is not a function" — crashes the dev server at boot).
|
|
29
|
+
*
|
|
30
|
+
* `useIsMobile` only ever runs inside a Client Component render, so calling the
|
|
31
|
+
* accessor from there is safe. The PROVIDER must not live here — it is rendered
|
|
32
|
+
* by the server layouts, so it needs a real "use client" boundary and therefore
|
|
33
|
+
* lives in `contexts/ViewportContext.tsx`.
|
|
34
|
+
*/
|
|
35
|
+
const VIEWPORT_CONTEXT_KEY = Symbol.for("nextjs-jsonapi:viewportContext");
|
|
36
|
+
|
|
37
|
+
const globalStore = globalThis as unknown as {
|
|
38
|
+
[VIEWPORT_CONTEXT_KEY]?: React.Context<boolean | undefined>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Returns the process-wide viewport context, creating it on first use. */
|
|
42
|
+
export function getViewportContext(): React.Context<boolean | undefined> {
|
|
43
|
+
if (globalStore[VIEWPORT_CONTEXT_KEY] === undefined) {
|
|
44
|
+
globalStore[VIEWPORT_CONTEXT_KEY] = React.createContext<boolean | undefined>(undefined);
|
|
45
|
+
}
|
|
46
|
+
return globalStore[VIEWPORT_CONTEXT_KEY]!;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @internal — consumed by contexts/ViewportContext.tsx */
|
|
50
|
+
export function measureViewport(): boolean {
|
|
51
|
+
return window.innerWidth < MOBILE_BREAKPOINT;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @internal — consumed by contexts/ViewportContext.tsx */
|
|
55
|
+
export function persistViewport(isMobile: boolean): void {
|
|
56
|
+
document.cookie = `${VIEWPORT_COOKIE_NAME}=${isMobile ? "1" : "0"}; path=/; max-age=31536000; samesite=lax`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @internal — consumed by contexts/ViewportContext.tsx */
|
|
60
|
+
export function subscribeViewport(onChange: () => void): () => void {
|
|
61
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
62
|
+
onChange();
|
|
63
|
+
mql.addEventListener("change", onChange);
|
|
64
|
+
return () => mql.removeEventListener("change", onChange);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function useIsMobile(): boolean {
|
|
68
|
+
const seeded = React.useContext(getViewportContext());
|
|
69
|
+
const [legacy, setLegacy] = React.useState<boolean>(false);
|
|
9
70
|
|
|
10
71
|
React.useEffect(() => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return () => mql.removeEventListener("change", onChange);
|
|
18
|
-
}, []);
|
|
19
|
-
|
|
20
|
-
return !!isMobile;
|
|
72
|
+
// A provider owns the value; never run the legacy measurement alongside it.
|
|
73
|
+
if (seeded !== undefined) return;
|
|
74
|
+
return subscribeViewport(() => setLegacy(measureViewport()));
|
|
75
|
+
}, [seeded]);
|
|
76
|
+
|
|
77
|
+
return seeded ?? legacy;
|
|
21
78
|
}
|