@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
|
@@ -8,11 +8,15 @@ import { BreadcrumbNavigation } from "./Breadcrumb";
|
|
|
8
8
|
|
|
9
9
|
type HeaderProps = {
|
|
10
10
|
children?: React.ReactNode;
|
|
11
|
+
/** Widgets kept on mobile, where there is no room for the full `children` set. */
|
|
12
|
+
mobileChildren?: React.ReactNode;
|
|
11
13
|
leftContent?: React.ReactNode;
|
|
14
|
+
/** Rendered before everything else, on mobile only — where the sidebar (and its logo) is off-canvas. */
|
|
15
|
+
logo?: React.ReactNode;
|
|
12
16
|
className?: string;
|
|
13
17
|
};
|
|
14
18
|
|
|
15
|
-
export function Header({ children, leftContent, className }: HeaderProps) {
|
|
19
|
+
export function Header({ children, mobileChildren, leftContent, logo, className }: HeaderProps) {
|
|
16
20
|
const { breadcrumbs } = useSharedContext();
|
|
17
21
|
const rootLabel = useHeaderRootLabel();
|
|
18
22
|
const isMobile = useIsMobile();
|
|
@@ -20,16 +24,23 @@ export function Header({ children, leftContent, className }: HeaderProps) {
|
|
|
20
24
|
return (
|
|
21
25
|
<header className={`sticky top-0 z-10 flex h-12 flex-col items-center justify-start gap-x-4 ${className ?? ""}`}>
|
|
22
26
|
<div className="bg-sidebar flex h-12 w-full flex-row items-center justify-between pl-2 pr-4">
|
|
27
|
+
{isMobile && logo && <div className="flex shrink-0 flex-row items-center pr-1">{logo}</div>}
|
|
23
28
|
<SidebarTrigger aria-label="Toggle sidebar" id="sidebar-trigger" />
|
|
24
29
|
{leftContent}
|
|
25
30
|
<div className="flex w-full flex-row items-center justify-start">
|
|
26
31
|
<BreadcrumbNavigation items={breadcrumbs} rootLabel={rootLabel ?? undefined} />
|
|
27
32
|
</div>
|
|
28
|
-
{
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
{isMobile
|
|
34
|
+
? mobileChildren && (
|
|
35
|
+
<div className="flex shrink-0 flex-row items-center justify-end gap-x-2 whitespace-nowrap">
|
|
36
|
+
{mobileChildren}
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
: children && (
|
|
40
|
+
<div className="flex w-64 flex-row items-center justify-end gap-x-4 whitespace-nowrap">
|
|
41
|
+
<div className="flex flex-row items-center justify-end gap-x-4 whitespace-nowrap">{children}</div>
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
33
44
|
</div>
|
|
34
45
|
</header>
|
|
35
46
|
);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePathname } from "next/navigation";
|
|
4
|
+
import { useMobileNavigationItems, type MobileNavigationItem } from "../../contexts/MobileNavigationContext";
|
|
5
|
+
import { Link } from "../../shadcnui";
|
|
6
|
+
import { cn } from "../../utils/cn";
|
|
7
|
+
import { useIsMobile } from "../../utils/use-mobile";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Active only for navigational slots.
|
|
11
|
+
*
|
|
12
|
+
* Exact-or-prefix, NOT a bare `startsWith`: the prefix arm keeps a detail route
|
|
13
|
+
* (/accounts/123) highlighting its section (/accounts).
|
|
14
|
+
*
|
|
15
|
+
* "/" is special-cased to EXACT match. Without that, its prefix arm degenerates
|
|
16
|
+
* to `pathname.startsWith("/")`, which is true for every route, and the Home
|
|
17
|
+
* slot highlights everywhere. This is pinned by a unit test in Task C.
|
|
18
|
+
*/
|
|
19
|
+
function isActiveHref(pathname: string, href: string): boolean {
|
|
20
|
+
if (href === "/") return pathname === "/";
|
|
21
|
+
return pathname === href || pathname.startsWith(`${href}/`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const slotClass = cn(
|
|
25
|
+
// min-h-12/min-w-12 = 48px touch target (Material/HIG guidance).
|
|
26
|
+
"relative flex min-h-12 min-w-12 flex-1 items-center justify-center",
|
|
27
|
+
"transition-colors",
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
function SlotContent({ item }: { item: MobileNavigationItem }) {
|
|
31
|
+
if (item.render) return <>{item.render}</>;
|
|
32
|
+
const Icon = item.icon;
|
|
33
|
+
return Icon ? <Icon className="size-6" /> : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The mobile bottom navigation bar.
|
|
38
|
+
*
|
|
39
|
+
* Renders nothing on desktop, and nothing when the application supplied no
|
|
40
|
+
* items — so page containers can render it unconditionally.
|
|
41
|
+
*
|
|
42
|
+
* Rendered as an in-flow flex sibling of the page content (NOT position:fixed),
|
|
43
|
+
* so the content column shrinks to fit and can never be hidden behind the bar.
|
|
44
|
+
*/
|
|
45
|
+
export function MobileNavigationBar() {
|
|
46
|
+
const items = useMobileNavigationItems();
|
|
47
|
+
const isMobile = useIsMobile();
|
|
48
|
+
const pathname = usePathname();
|
|
49
|
+
|
|
50
|
+
if (!isMobile || items.length === 0) return null;
|
|
51
|
+
|
|
52
|
+
// A detached surface sitting BELOW the page card with a gap, so it carries a
|
|
53
|
+
// rounded border rather than a `border-t`, which would read as if the bar were
|
|
54
|
+
// welded to the card's bottom edge. `bg-sidebar` deliberately matches the
|
|
55
|
+
// Header's own background so the two chrome edges of the viewport agree.
|
|
56
|
+
return (
|
|
57
|
+
<nav
|
|
58
|
+
aria-label="Primary"
|
|
59
|
+
data-testid="mobile-navigation-bar"
|
|
60
|
+
className="bg-sidebar flex w-full shrink-0 flex-row items-stretch rounded-lg border pb-[env(safe-area-inset-bottom)]"
|
|
61
|
+
>
|
|
62
|
+
{items.map((item) => {
|
|
63
|
+
const active = item.href ? isActiveHref(pathname, item.href) : false;
|
|
64
|
+
const tone = active ? "text-primary" : "text-muted-foreground";
|
|
65
|
+
|
|
66
|
+
const body = (
|
|
67
|
+
<>
|
|
68
|
+
{active && <span aria-hidden className="bg-primary absolute inset-x-0 top-0 h-0.5" />}
|
|
69
|
+
<SlotContent item={item} />
|
|
70
|
+
</>
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return item.href ? (
|
|
74
|
+
<Link
|
|
75
|
+
key={item.key}
|
|
76
|
+
href={item.href}
|
|
77
|
+
aria-label={item.label}
|
|
78
|
+
aria-current={active ? "page" : undefined}
|
|
79
|
+
data-testid={`mobile-nav-${item.key}`}
|
|
80
|
+
className={cn(slotClass, tone)}
|
|
81
|
+
>
|
|
82
|
+
{body}
|
|
83
|
+
</Link>
|
|
84
|
+
) : (
|
|
85
|
+
<button
|
|
86
|
+
key={item.key}
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={item.onClick}
|
|
89
|
+
aria-label={item.label}
|
|
90
|
+
data-testid={`mobile-nav-${item.key}`}
|
|
91
|
+
className={cn(slotClass, tone)}
|
|
92
|
+
>
|
|
93
|
+
{body}
|
|
94
|
+
</button>
|
|
95
|
+
);
|
|
96
|
+
})}
|
|
97
|
+
</nav>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { HomeIcon, SearchIcon } from "lucide-react";
|
|
4
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { MobileNavigationProvider, type MobileNavigationItem } from "../../../contexts/MobileNavigationContext";
|
|
6
|
+
import { ViewportProvider } from "../../../contexts/ViewportContext";
|
|
7
|
+
import { configureI18n } from "../../../i18n";
|
|
8
|
+
import { MobileNavigationBar } from "../MobileNavigationBar";
|
|
9
|
+
|
|
10
|
+
// usePathname is globally mocked to "/" in vitest.setup.ts. Override it here so
|
|
11
|
+
// the active-state rule can be exercised against a real route. A spec-level
|
|
12
|
+
// vi.mock takes precedence over the setup-file mock for this module.
|
|
13
|
+
const pathnameRef = { current: "/" };
|
|
14
|
+
vi.mock("next/navigation", () => ({
|
|
15
|
+
usePathname: () => pathnameRef.current,
|
|
16
|
+
useRouter: () => ({
|
|
17
|
+
push: vi.fn(),
|
|
18
|
+
replace: vi.fn(),
|
|
19
|
+
prefetch: vi.fn(),
|
|
20
|
+
back: vi.fn(),
|
|
21
|
+
forward: vi.fn(),
|
|
22
|
+
refresh: vi.fn(),
|
|
23
|
+
}),
|
|
24
|
+
useSearchParams: () => new URLSearchParams(),
|
|
25
|
+
useParams: () => ({}),
|
|
26
|
+
redirect: vi.fn(),
|
|
27
|
+
notFound: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
beforeAll(() => {
|
|
31
|
+
// The package Link resolves its inner component at runtime and throws if i18n
|
|
32
|
+
// was never configured (src/i18n/config.ts:53-58).
|
|
33
|
+
configureI18n({
|
|
34
|
+
useRouter: () => ({
|
|
35
|
+
push: vi.fn(),
|
|
36
|
+
replace: vi.fn(),
|
|
37
|
+
back: vi.fn(),
|
|
38
|
+
forward: vi.fn(),
|
|
39
|
+
refresh: vi.fn(),
|
|
40
|
+
prefetch: vi.fn(),
|
|
41
|
+
}),
|
|
42
|
+
useTranslations: () => (key: string) => key,
|
|
43
|
+
usePathname: () => pathnameRef.current,
|
|
44
|
+
Link: ({ href, children, ...rest }: { href: string; children: React.ReactNode; [key: string]: any }) => (
|
|
45
|
+
<a href={href} {...rest}>
|
|
46
|
+
{children}
|
|
47
|
+
</a>
|
|
48
|
+
),
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
pathnameRef.current = "/";
|
|
54
|
+
// jsdom defaults to 1024; ViewportProvider's effect measures immediately and
|
|
55
|
+
// would otherwise overwrite the mobile seed with "desktop".
|
|
56
|
+
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 375 });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function renderBar(items: MobileNavigationItem[], { mobile = true }: { mobile?: boolean } = {}) {
|
|
60
|
+
if (!mobile) {
|
|
61
|
+
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 });
|
|
62
|
+
}
|
|
63
|
+
return render(
|
|
64
|
+
<ViewportProvider initialIsMobile={mobile}>
|
|
65
|
+
<MobileNavigationProvider items={items}>
|
|
66
|
+
<MobileNavigationBar />
|
|
67
|
+
</MobileNavigationProvider>
|
|
68
|
+
</ViewportProvider>,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const searchItem: MobileNavigationItem = { key: "search", label: "Cerca", icon: SearchIcon, onClick: vi.fn() };
|
|
73
|
+
const homeItem: MobileNavigationItem = { key: "home", label: "Dashboard", icon: HomeIcon, href: "/" };
|
|
74
|
+
|
|
75
|
+
describe("MobileNavigationBar", () => {
|
|
76
|
+
it("renders nothing when the application supplied no items", () => {
|
|
77
|
+
renderBar([]);
|
|
78
|
+
expect(screen.queryByTestId("mobile-navigation-bar")).not.toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("renders nothing on desktop even when items exist", () => {
|
|
82
|
+
renderBar([searchItem], { mobile: false });
|
|
83
|
+
expect(screen.queryByTestId("mobile-navigation-bar")).not.toBeInTheDocument();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("renders one slot per item, labelled by its accessible name", () => {
|
|
87
|
+
renderBar([searchItem, homeItem]);
|
|
88
|
+
expect(screen.getByTestId("mobile-navigation-bar")).toBeInTheDocument();
|
|
89
|
+
expect(screen.getByRole("button", { name: "Cerca" })).toBeInTheDocument();
|
|
90
|
+
expect(screen.getByRole("link", { name: "Dashboard" })).toBeInTheDocument();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("does not render the label as visible text (icons-only bar)", () => {
|
|
94
|
+
renderBar([searchItem]);
|
|
95
|
+
expect(screen.queryByText("Cerca")).not.toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("fires onClick for action slots", async () => {
|
|
99
|
+
const onClick = vi.fn();
|
|
100
|
+
renderBar([{ ...searchItem, onClick }]);
|
|
101
|
+
await userEvent.click(screen.getByRole("button", { name: "Cerca" }));
|
|
102
|
+
expect(onClick).toHaveBeenCalledOnce();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("renders the `render` escape hatch instead of an icon", () => {
|
|
106
|
+
renderBar([
|
|
107
|
+
{ key: "profile", label: "Impostazioni", render: <span data-testid="avatar-stub" />, href: "/settings" },
|
|
108
|
+
]);
|
|
109
|
+
expect(screen.getByTestId("avatar-stub")).toBeInTheDocument();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// The load-bearing case: a bare startsWith would make href="/" match everything.
|
|
113
|
+
it("marks only the exactly-matching slot active when one href is '/'", () => {
|
|
114
|
+
pathnameRef.current = "/conversations";
|
|
115
|
+
renderBar([homeItem, { key: "ai", label: "Chiedi all'AI", icon: SearchIcon, href: "/conversations" }]);
|
|
116
|
+
expect(screen.getByRole("link", { name: "Dashboard" })).not.toHaveAttribute("aria-current");
|
|
117
|
+
expect(screen.getByRole("link", { name: "Chiedi all'AI" })).toHaveAttribute("aria-current", "page");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("keeps a section active on its detail route", () => {
|
|
121
|
+
pathnameRef.current = "/conversations/abc-123";
|
|
122
|
+
renderBar([{ key: "ai", label: "Chiedi all'AI", icon: SearchIcon, href: "/conversations" }]);
|
|
123
|
+
expect(screen.getByRole("link", { name: "Chiedi all'AI" })).toHaveAttribute("aria-current", "page");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("never marks an action slot active", () => {
|
|
127
|
+
pathnameRef.current = "/";
|
|
128
|
+
renderBar([searchItem]);
|
|
129
|
+
expect(screen.getByRole("button", { name: "Cerca" })).not.toHaveAttribute("aria-current");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -21,7 +21,7 @@ export function PageContentContainer({ header, details, footer, content, fullBle
|
|
|
21
21
|
setMounted(true);
|
|
22
22
|
}, []);
|
|
23
23
|
|
|
24
|
-
const isReady = mounted
|
|
24
|
+
const isReady = mounted;
|
|
25
25
|
|
|
26
26
|
// Create layout ID based on device type for separate persistence
|
|
27
27
|
const layoutId = `page-content-container-${isMobile ? "mobile" : "desktop"}`;
|
|
@@ -4,17 +4,20 @@ import { createContext, useContext, ReactNode } from "react";
|
|
|
4
4
|
|
|
5
5
|
interface HeaderChildrenContextType {
|
|
6
6
|
headerChildren: ReactNode | null;
|
|
7
|
+
headerMobileChildren: ReactNode | null;
|
|
7
8
|
headerRootLabel: string | null;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
const HeaderChildrenContext = createContext<HeaderChildrenContextType>({
|
|
11
12
|
headerChildren: null,
|
|
13
|
+
headerMobileChildren: null,
|
|
12
14
|
headerRootLabel: null,
|
|
13
15
|
});
|
|
14
16
|
|
|
15
17
|
interface HeaderChildrenProviderProps {
|
|
16
18
|
children: ReactNode;
|
|
17
19
|
content: ReactNode;
|
|
20
|
+
mobileContent?: ReactNode;
|
|
18
21
|
rootLabel?: string;
|
|
19
22
|
}
|
|
20
23
|
|
|
@@ -22,10 +25,19 @@ interface HeaderChildrenProviderProps {
|
|
|
22
25
|
* Provider to supply custom content to be rendered in the Header component.
|
|
23
26
|
* Wrap your layout with this provider and pass the content you want in the header.
|
|
24
27
|
* Optionally pass `rootLabel` to override the first breadcrumb entry (defaults to `common.home`).
|
|
28
|
+
*
|
|
29
|
+
* The header has far less room on mobile, so `content` is desktop-only. Pass `mobileContent`
|
|
30
|
+
* to choose which widgets survive there; omit it and the header stays bare on mobile.
|
|
25
31
|
*/
|
|
26
|
-
export function HeaderChildrenProvider({ children, content, rootLabel }: HeaderChildrenProviderProps) {
|
|
32
|
+
export function HeaderChildrenProvider({ children, content, mobileContent, rootLabel }: HeaderChildrenProviderProps) {
|
|
27
33
|
return (
|
|
28
|
-
<HeaderChildrenContext.Provider
|
|
34
|
+
<HeaderChildrenContext.Provider
|
|
35
|
+
value={{
|
|
36
|
+
headerChildren: content,
|
|
37
|
+
headerMobileChildren: mobileContent ?? null,
|
|
38
|
+
headerRootLabel: rootLabel ?? null,
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
29
41
|
{children}
|
|
30
42
|
</HeaderChildrenContext.Provider>
|
|
31
43
|
);
|
|
@@ -40,6 +52,15 @@ export function useHeaderChildren(): ReactNode | null {
|
|
|
40
52
|
return context.headerChildren;
|
|
41
53
|
}
|
|
42
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Hook to get the header content the application wants kept on mobile.
|
|
57
|
+
* Returns null when the application supplied none, in which case the Header shows no widgets there.
|
|
58
|
+
*/
|
|
59
|
+
export function useHeaderMobileChildren(): ReactNode | null {
|
|
60
|
+
const context = useContext(HeaderChildrenContext);
|
|
61
|
+
return context.headerMobileChildren;
|
|
62
|
+
}
|
|
63
|
+
|
|
43
64
|
/**
|
|
44
65
|
* Hook to get the label of the first breadcrumb entry, when the application supplies one.
|
|
45
66
|
* Returns null when no override was provided, in which case the Header falls back to `common.home`.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
interface HeaderLogoContextType {
|
|
6
|
+
headerLogo: ReactNode | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const HeaderLogoContext = createContext<HeaderLogoContextType>({
|
|
10
|
+
headerLogo: null,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
interface HeaderLogoProviderProps {
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
content: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function HeaderLogoProvider({ children, content }: HeaderLogoProviderProps) {
|
|
19
|
+
return <HeaderLogoContext.Provider value={{ headerLogo: content }}>{children}</HeaderLogoContext.Provider>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useHeaderLogo(): ReactNode | null {
|
|
23
|
+
const context = useContext(HeaderLogoContext);
|
|
24
|
+
return context.headerLogo;
|
|
25
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import type { LucideIcon } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One slot in the mobile bottom navigation bar.
|
|
9
|
+
*
|
|
10
|
+
* Supply exactly one of `icon` / `render`, and exactly one of `href` / `onClick`.
|
|
11
|
+
* This is a review-time contract, not a runtime-validated one — the mistake is
|
|
12
|
+
* immediately visible in dev, and validation code would ship to every consumer.
|
|
13
|
+
*
|
|
14
|
+
* `render` content MUST NOT contain an interactive element: an `href` slot is
|
|
15
|
+
* already wrapped in a <Link>, so a nested <a>/<button> is invalid HTML and
|
|
16
|
+
* triggers a hydration error.
|
|
17
|
+
*/
|
|
18
|
+
export type MobileNavigationItem = {
|
|
19
|
+
/** Stable unique key. Used as the React key and the `data-testid` suffix. */
|
|
20
|
+
key: string;
|
|
21
|
+
/** Accessible name. The bar is icons-only, so this is never rendered as text. */
|
|
22
|
+
label: string;
|
|
23
|
+
/** Rendered at 24px. Mutually exclusive with `render`. */
|
|
24
|
+
icon?: LucideIcon;
|
|
25
|
+
/** Escape hatch for slots an icon cannot express (logo mark, user avatar). */
|
|
26
|
+
render?: ReactNode;
|
|
27
|
+
/** Navigational slot — receives the active highlight. */
|
|
28
|
+
href?: string;
|
|
29
|
+
/** Action slot — never highlighted. */
|
|
30
|
+
onClick?: () => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A plain module-scope context, mirroring the sibling `HeaderChildrenContext`.
|
|
35
|
+
*
|
|
36
|
+
* The `globalThis`-Symbol lazy-creation dance in `utils/use-mobile.tsx` is NOT
|
|
37
|
+
* needed here: that exists for the `core` entry, which is deliberately excluded
|
|
38
|
+
* from tsup's `clientEntries` and is reachable from server code. Provider (the
|
|
39
|
+
* `contexts` entry) and consumer (the `components` entry) share one context
|
|
40
|
+
* object, exactly as `HeaderChildrenContext` already does in production.
|
|
41
|
+
*/
|
|
42
|
+
const MobileNavigationContext = React.createContext<MobileNavigationItem[]>([]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Supplies the items rendered by `MobileNavigationBar`.
|
|
46
|
+
*
|
|
47
|
+
* Unlike `useSharedContext`, the hook below does NOT throw without a provider —
|
|
48
|
+
* it returns `[]` and the bar renders nothing. Apps that never install this
|
|
49
|
+
* provider are completely unaffected.
|
|
50
|
+
*
|
|
51
|
+
* MUST be rendered inside `ViewportProvider` so the bar's `useIsMobile()` reads
|
|
52
|
+
* the server-seeded viewport rather than flashing.
|
|
53
|
+
*/
|
|
54
|
+
export function MobileNavigationProvider({ children, items }: { children: ReactNode; items: MobileNavigationItem[] }) {
|
|
55
|
+
// Items are rebuilt on every render of the app-side provider (it depends on
|
|
56
|
+
// hooks); memoising on the array identity keeps consumers from re-rendering
|
|
57
|
+
// more than the parent already does.
|
|
58
|
+
const value = React.useMemo(() => items, [items]);
|
|
59
|
+
|
|
60
|
+
return <MobileNavigationContext.Provider value={value}>{children}</MobileNavigationContext.Provider>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Returns the application-supplied bottom-navigation items, or `[]` when none. */
|
|
64
|
+
export function useMobileNavigationItems(): MobileNavigationItem[] {
|
|
65
|
+
return React.useContext(MobileNavigationContext);
|
|
66
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { getViewportContext, measureViewport, persistViewport, subscribeViewport } from "../utils/use-mobile";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Seeds the viewport from the server so SSR and the first client render agree,
|
|
8
|
+
* removing the desktop-then-mobile chrome flash.
|
|
9
|
+
*
|
|
10
|
+
* `initialIsMobile` comes from the request — cookie first (a real measurement
|
|
11
|
+
* from a previous visit), user agent as the first-visit fallback. See the
|
|
12
|
+
* (main)/(admin) layouts.
|
|
13
|
+
*
|
|
14
|
+
* THIS LIVES IN `contexts/`, NOT `utils/`, AND MUST STAY HERE. The server
|
|
15
|
+
* layouts render it directly, so it needs a genuine "use client" boundary in
|
|
16
|
+
* the BUILT output. tsup.config.ts stamps the directive onto `dist/contexts/*`
|
|
17
|
+
* via its `clientEntries` list; `dist/core/*` is deliberately excluded so that
|
|
18
|
+
* instrumentation can import it server-side. Exporting this provider from
|
|
19
|
+
* `core` therefore made Next execute it as a Server Component and fail with
|
|
20
|
+
* "createContext only works in Client Components".
|
|
21
|
+
*
|
|
22
|
+
* `SocketContext` in this directory is the reference: it is likewise rendered
|
|
23
|
+
* straight from `(main)/layout.tsx`, a Server Component, and works.
|
|
24
|
+
*/
|
|
25
|
+
export function ViewportProvider({
|
|
26
|
+
initialIsMobile,
|
|
27
|
+
children,
|
|
28
|
+
}: {
|
|
29
|
+
initialIsMobile: boolean;
|
|
30
|
+
children: React.ReactNode;
|
|
31
|
+
}) {
|
|
32
|
+
const ViewportContext = getViewportContext();
|
|
33
|
+
const [isMobile, setIsMobile] = React.useState<boolean>(initialIsMobile);
|
|
34
|
+
|
|
35
|
+
React.useEffect(
|
|
36
|
+
() =>
|
|
37
|
+
subscribeViewport(() => {
|
|
38
|
+
const next = measureViewport();
|
|
39
|
+
setIsMobile(next);
|
|
40
|
+
persistViewport(next);
|
|
41
|
+
}),
|
|
42
|
+
[],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return <ViewportContext.Provider value={isMobile}>{children}</ViewportContext.Provider>;
|
|
46
|
+
}
|
package/src/contexts/index.ts
CHANGED
|
@@ -9,8 +9,11 @@ export * from "../features/assistant/contexts/AssistantContext";
|
|
|
9
9
|
export * from "./CommonContext";
|
|
10
10
|
export * from "./HeaderChildrenContext";
|
|
11
11
|
export * from "./HeaderLeftContentContext";
|
|
12
|
+
export * from "./HeaderLogoContext";
|
|
13
|
+
export * from "./MobileNavigationContext";
|
|
12
14
|
export * from "./SharedContext";
|
|
13
15
|
export * from "./SocketContext";
|
|
16
|
+
export * from "./ViewportContext";
|
|
14
17
|
|
|
15
18
|
// Jotai atoms (merged from /atoms entry point)
|
|
16
19
|
export * from "../atoms";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const VIEWPORT_CONTEXT_KEY = Symbol.for("nextjs-jsonapi:viewportContext");
|
|
4
|
+
|
|
5
|
+
type GlobalStore = Record<symbol, unknown>;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.resetModules();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("viewportStore", () => {
|
|
12
|
+
/**
|
|
13
|
+
* Regression guard. `apps/web/src/instrumentation.ts` pulls this module into
|
|
14
|
+
* the Node/RSC server graph at startup (jsonapi.config -> Bootstrapper ->
|
|
15
|
+
* @carlonicora/nextjs-jsonapi/core -> utils barrel -> use-mobile ->
|
|
16
|
+
* viewportStore). React resolves to the vendored RSC build there, which omits
|
|
17
|
+
* `createContext`. A module-scope call crashed the dev server on boot with
|
|
18
|
+
* "createContext is not a function". Importing must stay side-effect free.
|
|
19
|
+
*/
|
|
20
|
+
it("does not create the context merely by being imported", async () => {
|
|
21
|
+
const store = globalThis as unknown as GlobalStore;
|
|
22
|
+
const previous = store[VIEWPORT_CONTEXT_KEY];
|
|
23
|
+
delete store[VIEWPORT_CONTEXT_KEY];
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
vi.resetModules();
|
|
27
|
+
await import("../viewportStore");
|
|
28
|
+
expect(store[VIEWPORT_CONTEXT_KEY]).toBeUndefined();
|
|
29
|
+
} finally {
|
|
30
|
+
store[VIEWPORT_CONTEXT_KEY] = previous;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("creates the context on first call and returns a stable identity after", async () => {
|
|
35
|
+
const store = globalThis as unknown as GlobalStore;
|
|
36
|
+
const previous = store[VIEWPORT_CONTEXT_KEY];
|
|
37
|
+
delete store[VIEWPORT_CONTEXT_KEY];
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
vi.resetModules();
|
|
41
|
+
const { getViewportContext } = await import("../viewportStore");
|
|
42
|
+
|
|
43
|
+
const first = getViewportContext();
|
|
44
|
+
expect(first).toBeDefined();
|
|
45
|
+
expect(getViewportContext()).toBe(first);
|
|
46
|
+
} finally {
|
|
47
|
+
store[VIEWPORT_CONTEXT_KEY] = previous;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Centralized viewport context object.
|
|
5
|
+
*
|
|
6
|
+
* Uses a globalThis Symbol key so that tsup's multi-entry `splitting: true`
|
|
7
|
+
* build cannot produce two distinct React context objects. If it did, a
|
|
8
|
+
* ViewportProvider from one bundle graph and a useIsMobile from another would
|
|
9
|
+
* silently miss each other: the hook would read `undefined`, fall back to the
|
|
10
|
+
* legacy path, and the flash would return with nothing failing loudly.
|
|
11
|
+
*
|
|
12
|
+
* CREATION IS LAZY, AND MUST STAY THAT WAY. Do not hoist the `createContext`
|
|
13
|
+
* call to module scope. `apps/web/src/instrumentation.ts` imports
|
|
14
|
+
* `jsonapi.config.ts` -> `Bootstrapper.ts` -> `@carlonicora/nextjs-jsonapi/core`,
|
|
15
|
+
* whose barrel re-exports `../utils` and therefore pulls this module into the
|
|
16
|
+
* Node/RSC server graph at server startup. In that graph Next resolves `react`
|
|
17
|
+
* to the vendored RSC build, which deliberately omits `createContext` because
|
|
18
|
+
* it is a client-only API. A module-scope call there crashes the dev server on
|
|
19
|
+
* boot with "createContext is not a function" — the `"use client"` directive
|
|
20
|
+
* does not help, because instrumentation is not a React render.
|
|
21
|
+
*
|
|
22
|
+
* Calling the accessor only from inside the provider/hook guarantees it runs in
|
|
23
|
+
* a rendering context, where `createContext` exists.
|
|
24
|
+
*
|
|
25
|
+
* NO external dependencies beyond React, to avoid circular imports.
|
|
26
|
+
*
|
|
27
|
+
* Pattern mirrors `helpStore.ts` / `bootstrapStore.ts`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createContext, type Context } from "react";
|
|
31
|
+
|
|
32
|
+
const VIEWPORT_CONTEXT_KEY = Symbol.for("nextjs-jsonapi:viewportContext");
|
|
33
|
+
|
|
34
|
+
const globalStore = globalThis as unknown as {
|
|
35
|
+
[VIEWPORT_CONTEXT_KEY]?: Context<boolean | undefined>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns the process-wide viewport context, creating it on first use.
|
|
40
|
+
* Never call this at module scope — see the note above.
|
|
41
|
+
*/
|
|
42
|
+
export function getViewportContext(): Context<boolean | undefined> {
|
|
43
|
+
if (globalStore[VIEWPORT_CONTEXT_KEY] === undefined) {
|
|
44
|
+
globalStore[VIEWPORT_CONTEXT_KEY] = createContext<boolean | undefined>(undefined);
|
|
45
|
+
}
|
|
46
|
+
return globalStore[VIEWPORT_CONTEXT_KEY]!;
|
|
47
|
+
}
|