@carlonicora/nextjs-jsonapi 1.132.0 → 1.134.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.
Files changed (62) hide show
  1. package/dist/{BlockNoteEditor-JAELO23S.js → BlockNoteEditor-2WOKHBLM.js} +19 -19
  2. package/dist/{BlockNoteEditor-JAELO23S.js.map → BlockNoteEditor-2WOKHBLM.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-WZFEJS2B.mjs → BlockNoteEditor-3IHMNBZ2.mjs} +4 -4
  4. package/dist/billing/index.js +357 -357
  5. package/dist/billing/index.mjs +3 -3
  6. package/dist/{chunk-F6KBHC2X.js → chunk-C2LEIBUK.js} +7 -7
  7. package/dist/{chunk-F6KBHC2X.js.map → chunk-C2LEIBUK.js.map} +1 -1
  8. package/dist/{chunk-67LIVKYU.mjs → chunk-DV5YYI3G.mjs} +2003 -1915
  9. package/dist/chunk-DV5YYI3G.mjs.map +1 -0
  10. package/dist/{chunk-DZL2G7NB.mjs → chunk-DVOBOFCF.mjs} +38 -11
  11. package/dist/chunk-DVOBOFCF.mjs.map +1 -0
  12. package/dist/{chunk-MP5VURBB.js → chunk-LBXHYRZH.js} +894 -806
  13. package/dist/chunk-LBXHYRZH.js.map +1 -0
  14. package/dist/{chunk-VSAAVVHY.mjs → chunk-M5IAN7IR.mjs} +2 -2
  15. package/dist/{chunk-5IE6DZ2D.js → chunk-W73JBOIR.js} +39 -12
  16. package/dist/{chunk-5IE6DZ2D.js.map → chunk-W73JBOIR.js.map} +1 -1
  17. package/dist/client/index.js +4 -4
  18. package/dist/client/index.mjs +3 -3
  19. package/dist/components/index.d.mts +8 -1
  20. package/dist/components/index.d.ts +8 -1
  21. package/dist/components/index.js +4 -4
  22. package/dist/components/index.mjs +3 -3
  23. package/dist/contexts/index.d.mts +42 -2
  24. package/dist/contexts/index.d.ts +42 -2
  25. package/dist/contexts/index.js +12 -4
  26. package/dist/contexts/index.js.map +1 -1
  27. package/dist/contexts/index.mjs +11 -3
  28. package/dist/core/index.d.mts +3 -1
  29. package/dist/core/index.d.ts +3 -1
  30. package/dist/core/index.js +6 -2
  31. package/dist/core/index.js.map +1 -1
  32. package/dist/core/index.mjs +5 -1
  33. package/dist/features/help/index.js +37 -37
  34. package/dist/features/help/index.mjs +3 -3
  35. package/dist/index.d.mts +1 -1
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.js +7 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/index.mjs +6 -2
  40. package/dist/server/index.js +3 -3
  41. package/dist/server/index.mjs +1 -1
  42. package/package.json +1 -1
  43. package/src/components/containers/PageContainer.tsx +7 -2
  44. package/src/components/containers/RoundPageContainer.tsx +16 -4
  45. package/src/components/navigations/Header.tsx +17 -6
  46. package/src/components/pages/PageContentContainer.tsx +1 -1
  47. package/src/components/tables/ContentListTable.tsx +33 -19
  48. package/src/components/tables/__tests__/ContentListTable.test.tsx +77 -0
  49. package/src/contexts/HeaderChildrenContext.tsx +23 -2
  50. package/src/contexts/HeaderLogoContext.tsx +25 -0
  51. package/src/contexts/ViewportContext.tsx +46 -0
  52. package/src/contexts/index.ts +2 -0
  53. package/src/core/registry/__tests__/viewportStore.test.ts +50 -0
  54. package/src/core/registry/viewportStore.ts +47 -0
  55. package/src/utils/__tests__/use-mobile.test.tsx +137 -0
  56. package/src/utils/index.ts +1 -1
  57. package/src/utils/use-mobile.tsx +70 -13
  58. package/dist/chunk-67LIVKYU.mjs.map +0 -1
  59. package/dist/chunk-DZL2G7NB.mjs.map +0 -1
  60. package/dist/chunk-MP5VURBB.js.map +0 -1
  61. /package/dist/{BlockNoteEditor-WZFEJS2B.mjs.map → BlockNoteEditor-3IHMNBZ2.mjs.map} +0 -0
  62. /package/dist/{chunk-VSAAVVHY.mjs.map → chunk-M5IAN7IR.mjs.map} +0 -0
@@ -15,7 +15,7 @@ var _chunkIOLU4ASDjs = require('../chunk-IOLU4ASD.js');
15
15
 
16
16
 
17
17
 
18
- var _chunk5IE6DZ2Djs = require('../chunk-5IE6DZ2D.js');
18
+ var _chunkW73JBOIRjs = require('../chunk-W73JBOIR.js');
19
19
  require('../chunk-LXKSUWAV.js');
20
20
  require('../chunk-IBS6NI7D.js');
21
21
 
@@ -86,7 +86,7 @@ var ServerSession = class {
86
86
  if (!rawModules) return false;
87
87
  const modules = JSON.parse(_pako2.default.ungzip(Buffer.from(rawModules, "base64"), { to: "string" }));
88
88
  const selectedModule = modules.find((module) => module.id === params.module.moduleId);
89
- return _chunk5IE6DZ2Djs.checkPermissionsFromServer.call(void 0, {
89
+ return _chunkW73JBOIRjs.checkPermissionsFromServer.call(void 0, {
90
90
  module: params.module,
91
91
  action: params.action,
92
92
  data: params.data,
@@ -296,5 +296,5 @@ _chunk7QVYU63Ejs.__name.call(void 0, ServerJsonApiDelete, "ServerJsonApiDelete")
296
296
 
297
297
 
298
298
 
299
- exports.ServerAuthService = _chunk5IE6DZ2Djs.AuthService; exports.ServerCompanyService = _chunk5IE6DZ2Djs.CompanyService; exports.ServerContentService = _chunk5IE6DZ2Djs.ContentService; exports.ServerFeatureService = _chunk5IE6DZ2Djs.FeatureService; exports.ServerJsonApiDelete = ServerJsonApiDelete; exports.ServerJsonApiGet = ServerJsonApiGet; exports.ServerJsonApiPatch = ServerJsonApiPatch; exports.ServerJsonApiPost = ServerJsonApiPost; exports.ServerJsonApiPut = ServerJsonApiPut; exports.ServerNotificationService = _chunk5IE6DZ2Djs.NotificationService; exports.ServerPushService = _chunk5IE6DZ2Djs.PushService; exports.ServerRoleService = _chunk5IE6DZ2Djs.RoleService; exports.ServerS3Service = _chunk5IE6DZ2Djs.S3Service; exports.ServerSession = ServerSession; exports.ServerUserService = _chunk5IE6DZ2Djs.UserService; exports.configureServerJsonApi = configureServerJsonApi; exports.getServerApiUrl = getServerApiUrl; exports.getServerAppUrl = getServerAppUrl; exports.getServerToken = _chunkYUO55Q5Ajs.getServerToken; exports.getServerTrackablePages = getServerTrackablePages; exports.invalidateCacheTag = invalidateCacheTag; exports.invalidateCacheTags = invalidateCacheTags; exports.serverRequest = _chunkIOLU4ASDjs.serverRequest;
299
+ exports.ServerAuthService = _chunkW73JBOIRjs.AuthService; exports.ServerCompanyService = _chunkW73JBOIRjs.CompanyService; exports.ServerContentService = _chunkW73JBOIRjs.ContentService; exports.ServerFeatureService = _chunkW73JBOIRjs.FeatureService; exports.ServerJsonApiDelete = ServerJsonApiDelete; exports.ServerJsonApiGet = ServerJsonApiGet; exports.ServerJsonApiPatch = ServerJsonApiPatch; exports.ServerJsonApiPost = ServerJsonApiPost; exports.ServerJsonApiPut = ServerJsonApiPut; exports.ServerNotificationService = _chunkW73JBOIRjs.NotificationService; exports.ServerPushService = _chunkW73JBOIRjs.PushService; exports.ServerRoleService = _chunkW73JBOIRjs.RoleService; exports.ServerS3Service = _chunkW73JBOIRjs.S3Service; exports.ServerSession = ServerSession; exports.ServerUserService = _chunkW73JBOIRjs.UserService; exports.configureServerJsonApi = configureServerJsonApi; exports.getServerApiUrl = getServerApiUrl; exports.getServerAppUrl = getServerAppUrl; exports.getServerToken = _chunkYUO55Q5Ajs.getServerToken; exports.getServerTrackablePages = getServerTrackablePages; exports.invalidateCacheTag = invalidateCacheTag; exports.invalidateCacheTags = invalidateCacheTags; exports.serverRequest = _chunkIOLU4ASDjs.serverRequest;
300
300
  //# sourceMappingURL=index.js.map
@@ -15,7 +15,7 @@ import {
15
15
  S3Service,
16
16
  UserService,
17
17
  checkPermissionsFromServer
18
- } from "../chunk-DZL2G7NB.mjs";
18
+ } from "../chunk-DVOBOFCF.mjs";
19
19
  import "../chunk-AUXK7QSA.mjs";
20
20
  import "../chunk-C7C7VY4F.mjs";
21
21
  import {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlonicora/nextjs-jsonapi",
3
- "version": "1.132.0",
3
+ "version": "1.134.0",
4
4
  "description": "Next.js JSON:API client with server/client support and caching",
5
5
  "author": "Carlo Nicora",
6
6
  "license": "GPL-3.0-or-later",
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { useHeaderChildren } from "../../contexts/HeaderChildrenContext";
3
+ import { useHeaderChildren, useHeaderMobileChildren } from "../../contexts/HeaderChildrenContext";
4
4
  import { useHeaderLeftContent } from "../../contexts/HeaderLeftContentContext";
5
+ import { useHeaderLogo } from "../../contexts/HeaderLogoContext";
5
6
  import { cn } from "../../utils";
6
7
  import { Header } from "../navigations";
7
8
 
@@ -9,11 +10,15 @@ type PageContainerProps = { children: React.ReactNode; testId?: string; classNam
9
10
 
10
11
  export function PageContainer({ children, testId, className }: PageContainerProps) {
11
12
  const headerChildren = useHeaderChildren();
13
+ const headerMobileChildren = useHeaderMobileChildren();
12
14
  const headerLeftContent = useHeaderLeftContent();
15
+ const headerLogo = useHeaderLogo();
13
16
 
14
17
  return (
15
18
  <div className={`flex h-full w-full flex-col`} data-testid={testId}>
16
- <Header leftContent={headerLeftContent}>{headerChildren}</Header>
19
+ <Header leftContent={headerLeftContent} logo={headerLogo} mobileChildren={headerMobileChildren}>
20
+ {headerChildren}
21
+ </Header>
17
22
  <main className={cn(`flex w-full flex-1 flex-col gap-y-4 pt-4 pl-4 pr-0`, className)}>{children}</main>
18
23
  </div>
19
24
  );
@@ -20,7 +20,7 @@ import {
20
20
  import { partitionTabs, Tab } from "@/components/containers";
21
21
  import { RoundPageContainerTitle } from "@/components/containers/RoundPageContainerTitle";
22
22
  import { Header } from "@/components/navigations";
23
- import { useHeaderChildren, useHeaderLeftContent } from "@/contexts";
23
+ import { useHeaderChildren, useHeaderLeftContent, useHeaderLogo, useHeaderMobileChildren } from "@/contexts";
24
24
  import { useUrlRewriter } from "@/hooks";
25
25
  import { cn, useIsMobile } from "@/index";
26
26
  import { ModuleWithPermissions } from "@/permissions";
@@ -85,6 +85,8 @@ export function RoundPageContainer({
85
85
  }: RoundPageContainerProps) {
86
86
  const headerChildren = useHeaderChildren();
87
87
  const headerLeftContent = useHeaderLeftContent();
88
+ const headerLogo = useHeaderLogo();
89
+ const headerMobileChildren = useHeaderMobileChildren();
88
90
  const [showDetails, setShowDetailsState] = useState(false);
89
91
  const isMobile = useIsMobile();
90
92
  const [mounted, setMounted] = useState(false);
@@ -148,12 +150,17 @@ export function RoundPageContainer({
148
150
  [tabs],
149
151
  );
150
152
 
151
- const isReady = mounted && isMobile !== undefined;
153
+ const isReady = mounted;
152
154
 
153
155
  if (!isReady) {
154
156
  return (
155
157
  <>
156
- <Header leftContent={headerLeftContent} className="bg-sidebar border-0">
158
+ <Header
159
+ leftContent={headerLeftContent}
160
+ logo={headerLogo}
161
+ mobileChildren={headerMobileChildren}
162
+ className="bg-sidebar border-0"
163
+ >
157
164
  {headerChildren}
158
165
  </Header>
159
166
  <div className={cn("flex h-[calc(100vh-3rem)] w-full flex-col", isMobile ? "" : "p-2 pt-0 pl-0")}>
@@ -167,7 +174,12 @@ export function RoundPageContainer({
167
174
 
168
175
  return (
169
176
  <>
170
- <Header leftContent={headerLeftContent} className="bg-sidebar border-0">
177
+ <Header
178
+ leftContent={headerLeftContent}
179
+ logo={headerLogo}
180
+ mobileChildren={headerMobileChildren}
181
+ className="bg-sidebar border-0"
182
+ >
171
183
  {headerChildren}
172
184
  </Header>
173
185
  <div className={cn(`flex h-[calc(100vh-3rem)] w-full flex-col`, isMobile ? "p-1 pt-0" : "p-2 pt-0 pl-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
- {!isMobile && children && (
29
- <div className="flex w-64 flex-row items-center justify-end gap-x-4 whitespace-nowrap">
30
- <div className="flex flex-row items-center justify-end gap-x-4 whitespace-nowrap">{children}</div>
31
- </div>
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
  );
@@ -21,7 +21,7 @@ export function PageContentContainer({ header, details, footer, content, fullBle
21
21
  setMounted(true);
22
22
  }, []);
23
23
 
24
- const isReady = mounted && isMobile !== undefined;
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"}`;
@@ -10,6 +10,7 @@ import React, { ReactNode, memo, useMemo, useState } from "react";
10
10
  import { DataListRetriever, TableContent, useTableGenerator } from "../../hooks";
11
11
  import { ModuleWithPermissions } from "../../permissions";
12
12
  import { Button, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "../../shadcnui";
13
+ import { MicroLabel } from "../typography";
13
14
  import { ContentTableSearch } from "./ContentTableSearch";
14
15
 
15
16
  const EMPTY_ARRAY: any[] = [];
@@ -49,6 +50,9 @@ type ContentListTableProps = {
49
50
  defaultExpanded?: boolean | ExpandedState;
50
51
  fullWidth?: boolean;
51
52
  groupBy?: string;
53
+ groupLabel?: (key: string) => ReactNode;
54
+ groupOrder?: string[];
55
+ hideHeader?: boolean;
52
56
  emptyState?: ReactNode;
53
57
  onRowClick?: (rowData: any) => void;
54
58
  };
@@ -118,12 +122,24 @@ export const ContentListTable = memo(function ContentListTable(props: ContentLis
118
122
  }
119
123
  }
120
124
 
121
- const sortedKeys = [...groupMap.keys()].sort((a, b) => a.localeCompare(b));
125
+ const order = props.groupOrder;
126
+ const sortedKeys = [...groupMap.keys()].sort((a, b) => {
127
+ if (order) {
128
+ const ia = order.indexOf(a);
129
+ const ib = order.indexOf(b);
130
+ // keys absent from groupOrder sort last, alphabetically among themselves
131
+ if (ia === -1 && ib === -1) return a.localeCompare(b);
132
+ if (ia === -1) return 1;
133
+ if (ib === -1) return -1;
134
+ return ia - ib;
135
+ }
136
+ return a.localeCompare(b);
137
+ });
122
138
  return sortedKeys.map((groupKey) => ({
123
139
  groupKey,
124
140
  rows: groupMap.get(groupKey)!,
125
141
  }));
126
- }, [props.groupBy, rowModel]);
142
+ }, [props.groupBy, props.groupOrder, rowModel]);
127
143
 
128
144
  const showFooter = !!(data.next || data.previous);
129
145
 
@@ -168,18 +184,19 @@ export const ContentListTable = memo(function ContentListTable(props: ContentLis
168
184
  </TableHead>
169
185
  </TableRow>
170
186
  )}
171
- {table.getHeaderGroups().map((headerGroup) => (
172
- <TableRow key={headerGroup.id}>
173
- {headerGroup.headers.map((header) => {
174
- const meta = header.column.columnDef.meta as { className?: string } | undefined;
175
- return (
176
- <TableHead key={header.id} className={meta?.className}>
177
- {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
178
- </TableHead>
179
- );
180
- })}
181
- </TableRow>
182
- ))}
187
+ {!props.hideHeader &&
188
+ table.getHeaderGroups().map((headerGroup) => (
189
+ <TableRow key={headerGroup.id}>
190
+ {headerGroup.headers.map((header) => {
191
+ const meta = header.column.columnDef.meta as { className?: string } | undefined;
192
+ return (
193
+ <TableHead key={header.id} className={meta?.className}>
194
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
195
+ </TableHead>
196
+ );
197
+ })}
198
+ </TableRow>
199
+ ))}
183
200
  </TableHeader>
184
201
  <TableBody>
185
202
  {rowModel && rowModel.rows?.length ? (
@@ -187,11 +204,8 @@ export const ContentListTable = memo(function ContentListTable(props: ContentLis
187
204
  groupedRows.map((group) => (
188
205
  <React.Fragment key={group.groupKey}>
189
206
  <TableRow>
190
- <TableCell
191
- colSpan={tableColumns.length}
192
- className="bg-muted text-muted-foreground px-4 py-2 text-sm font-semibold"
193
- >
194
- {group.groupKey}
207
+ <TableCell colSpan={tableColumns.length} className="bg-muted px-4 py-2">
208
+ <MicroLabel>{props.groupLabel?.(group.groupKey) ?? group.groupKey}</MicroLabel>
195
209
  </TableCell>
196
210
  </TableRow>
197
211
  {group.rows.map((row) => (
@@ -338,6 +338,37 @@ describe("ContentListTable", () => {
338
338
  });
339
339
  });
340
340
 
341
+ describe("hideHeader", () => {
342
+ it("should render the column header row by default", () => {
343
+ const dataRetriever = createMockDataRetriever({
344
+ data: [{ id: "1", title: "Article 1" }],
345
+ });
346
+
347
+ render(<ContentListTable data={dataRetriever} tableGeneratorType={mockModule as any} fields={["id", "title"]} />);
348
+
349
+ expect(screen.getByText("Title")).toBeInTheDocument();
350
+ });
351
+
352
+ it("should not render the column header row when hideHeader is set", () => {
353
+ const dataRetriever = createMockDataRetriever({
354
+ data: [{ id: "1", title: "Article 1" }],
355
+ });
356
+
357
+ render(
358
+ <ContentListTable
359
+ data={dataRetriever}
360
+ tableGeneratorType={mockModule as any}
361
+ fields={["id", "title"]}
362
+ hideHeader
363
+ />,
364
+ );
365
+
366
+ expect(screen.queryByText("Title")).not.toBeInTheDocument();
367
+ // Body rows must still render — only the header row is suppressed.
368
+ expect(screen.getByText("Article 1")).toBeInTheDocument();
369
+ });
370
+ });
371
+
341
372
  describe("grouping", () => {
342
373
  it("should not group rows when groupBy is not provided", () => {
343
374
  const dataRetriever = createMockDataRetriever({
@@ -511,6 +542,52 @@ describe("ContentListTable", () => {
511
542
  const article3Count = allText.filter((t) => t === "Article 3").length;
512
543
  expect(article3Count).toBe(2);
513
544
  });
545
+
546
+ it("should render group headers through groupLabel when provided", () => {
547
+ const dataRetriever = createMockDataRetriever({
548
+ data: [
549
+ { id: "1", title: "Article 1", category: "CIVIL" },
550
+ { id: "2", title: "Article 2", category: "CRIMINAL" },
551
+ ],
552
+ });
553
+
554
+ render(
555
+ <ContentListTable
556
+ data={dataRetriever}
557
+ tableGeneratorType={mockModule as any}
558
+ fields={["id", "title"]}
559
+ groupBy="category"
560
+ groupLabel={(key) => `Branch: ${key}`}
561
+ />,
562
+ );
563
+
564
+ expect(screen.getByText("Branch: CIVIL")).toBeInTheDocument();
565
+ expect(screen.getByText("Branch: CRIMINAL")).toBeInTheDocument();
566
+ expect(screen.queryByText("CIVIL")).not.toBeInTheDocument();
567
+ });
568
+
569
+ it("should order groups by groupOrder instead of alphabetically", () => {
570
+ const dataRetriever = createMockDataRetriever({
571
+ data: [
572
+ { id: "1", title: "A Article", category: "A" },
573
+ { id: "2", title: "B Article", category: "B" },
574
+ { id: "3", title: "C Article", category: "C" },
575
+ ],
576
+ });
577
+
578
+ render(
579
+ <ContentListTable
580
+ data={dataRetriever}
581
+ tableGeneratorType={mockModule as any}
582
+ fields={["id", "title"]}
583
+ groupBy="category"
584
+ groupOrder={["C", "A", "B"]}
585
+ />,
586
+ );
587
+
588
+ const headings = screen.getAllByRole("heading", { level: 4 });
589
+ expect(headings.map((h) => h.textContent)).toEqual(["C", "A", "B"]);
590
+ });
514
591
  });
515
592
 
516
593
  describe("onRowClick", () => {
@@ -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 value={{ headerChildren: content, headerRootLabel: rootLabel ?? null }}>
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,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
+ }
@@ -9,8 +9,10 @@ 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";
12
13
  export * from "./SharedContext";
13
14
  export * from "./SocketContext";
15
+ export * from "./ViewportContext";
14
16
 
15
17
  // Jotai atoms (merged from /atoms entry point)
16
18
  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
+ }