@carlonicora/nextjs-jsonapi 1.117.1 → 1.119.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 (74) hide show
  1. package/dist/{BlockNoteEditor-CEWZCORH.js → BlockNoteEditor-BPACLNMB.js} +20 -20
  2. package/dist/{BlockNoteEditor-CEWZCORH.js.map → BlockNoteEditor-BPACLNMB.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-K4T6QOJO.mjs → BlockNoteEditor-CEDKBT67.mjs} +5 -5
  4. package/dist/JsonApiRequest-HE5LTEYK.js +26 -0
  5. package/dist/JsonApiRequest-HE5LTEYK.js.map +1 -0
  6. package/dist/{JsonApiRequest-IYL5RZ7I.mjs → JsonApiRequest-SN3DNFDR.mjs} +4 -2
  7. package/dist/billing/index.js +347 -347
  8. package/dist/billing/index.mjs +4 -4
  9. package/dist/{chunk-6D4GJHLK.mjs → chunk-4SPL6QH3.mjs} +277 -154
  10. package/dist/chunk-4SPL6QH3.mjs.map +1 -0
  11. package/dist/{chunk-6KLR3WJQ.js → chunk-55Q43DI5.js} +9 -9
  12. package/dist/{chunk-6KLR3WJQ.js.map → chunk-55Q43DI5.js.map} +1 -1
  13. package/dist/{chunk-WEPQD5MP.mjs → chunk-5U7QDGDQ.mjs} +3 -3
  14. package/dist/{chunk-S2PTWBN6.js → chunk-6P7MEU7Q.js} +64 -50
  15. package/dist/chunk-6P7MEU7Q.js.map +1 -0
  16. package/dist/{chunk-56QAXZKI.js → chunk-DXTOA7OO.js} +932 -809
  17. package/dist/chunk-DXTOA7OO.js.map +1 -0
  18. package/dist/{chunk-M7XAKLSP.mjs → chunk-FWOS6PC5.mjs} +38 -24
  19. package/dist/chunk-FWOS6PC5.mjs.map +1 -0
  20. package/dist/{chunk-J5LROFGL.js → chunk-RCMIVTVL.js} +14 -11
  21. package/dist/chunk-RCMIVTVL.js.map +1 -0
  22. package/dist/{chunk-KK7PYKVI.mjs → chunk-WC2BTKJP.mjs} +13 -10
  23. package/dist/{chunk-KK7PYKVI.mjs.map → chunk-WC2BTKJP.mjs.map} +1 -1
  24. package/dist/client/index.d.mts +15 -1
  25. package/dist/client/index.d.ts +15 -1
  26. package/dist/client/index.js +7 -5
  27. package/dist/client/index.js.map +1 -1
  28. package/dist/client/index.mjs +6 -4
  29. package/dist/components/index.d.mts +72 -30
  30. package/dist/components/index.d.ts +72 -30
  31. package/dist/components/index.js +7 -5
  32. package/dist/components/index.js.map +1 -1
  33. package/dist/components/index.mjs +6 -4
  34. package/dist/contexts/index.js +5 -5
  35. package/dist/contexts/index.mjs +4 -4
  36. package/dist/core/index.d.mts +7 -2
  37. package/dist/core/index.d.ts +7 -2
  38. package/dist/core/index.js +3 -3
  39. package/dist/core/index.mjs +2 -2
  40. package/dist/features/help/index.js +37 -37
  41. package/dist/features/help/index.mjs +4 -4
  42. package/dist/index.d.mts +2 -2
  43. package/dist/index.d.ts +2 -2
  44. package/dist/index.js +4 -4
  45. package/dist/index.mjs +3 -3
  46. package/dist/{s3.service-CihgBy97.d.mts → s3.service-CBqyDZyz.d.mts} +7 -0
  47. package/dist/{s3.service-Cyvfk3qV.d.ts → s3.service-gcEjq8gF.d.ts} +7 -0
  48. package/dist/server/index.d.mts +1 -1
  49. package/dist/server/index.d.ts +1 -1
  50. package/dist/server/index.js +12 -12
  51. package/dist/server/index.mjs +2 -2
  52. package/package.json +1 -1
  53. package/src/client/JsonApiClient.ts +23 -8
  54. package/src/client/__tests__/JsonApiClient.test.ts +31 -0
  55. package/src/components/containers/RoundPageContainer.tsx +197 -76
  56. package/src/components/containers/TabsContainer.tsx +14 -0
  57. package/src/components/containers/__tests__/partitionTabs.spec.ts +51 -0
  58. package/src/components/containers/index.ts +1 -0
  59. package/src/components/containers/partitionTabs.ts +43 -0
  60. package/src/components/forms/EditorSheet.tsx +7 -2
  61. package/src/core/abstracts/AbstractService.ts +13 -0
  62. package/src/core/abstracts/ClientAbstractService.ts +10 -0
  63. package/src/unified/JsonApiRequest.ts +22 -8
  64. package/src/unified/__tests__/JsonApiRequest.test.ts +29 -0
  65. package/dist/JsonApiRequest-OSGJ63XR.js +0 -24
  66. package/dist/JsonApiRequest-OSGJ63XR.js.map +0 -1
  67. package/dist/chunk-56QAXZKI.js.map +0 -1
  68. package/dist/chunk-6D4GJHLK.mjs.map +0 -1
  69. package/dist/chunk-J5LROFGL.js.map +0 -1
  70. package/dist/chunk-M7XAKLSP.mjs.map +0 -1
  71. package/dist/chunk-S2PTWBN6.js.map +0 -1
  72. /package/dist/{BlockNoteEditor-K4T6QOJO.mjs.map → BlockNoteEditor-CEDKBT67.mjs.map} +0 -0
  73. /package/dist/{JsonApiRequest-IYL5RZ7I.mjs.map → JsonApiRequest-SN3DNFDR.mjs.map} +0 -0
  74. /package/dist/{chunk-WEPQD5MP.mjs.map → chunk-5U7QDGDQ.mjs.map} +0 -0
@@ -15,13 +15,13 @@ var _chunkIOLU4ASDjs = require('../chunk-IOLU4ASD.js');
15
15
 
16
16
 
17
17
 
18
- var _chunkS2PTWBN6js = require('../chunk-S2PTWBN6.js');
18
+ var _chunk6P7MEU7Qjs = require('../chunk-6P7MEU7Q.js');
19
19
  require('../chunk-LXKSUWAV.js');
20
20
  require('../chunk-IBS6NI7D.js');
21
21
 
22
22
 
23
23
 
24
- var _chunkJ5LROFGLjs = require('../chunk-J5LROFGL.js');
24
+ var _chunkRCMIVTVLjs = require('../chunk-RCMIVTVL.js');
25
25
 
26
26
 
27
27
  var _chunk7QVYU63Ejs = require('../chunk-7QVYU63E.js');
@@ -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 _chunkS2PTWBN6js.checkPermissionsFromServer.call(void 0, {
89
+ return _chunk6P7MEU7Qjs.checkPermissionsFromServer.call(void 0, {
90
90
  module: params.module,
91
91
  action: params.action,
92
92
  data: params.data,
@@ -158,7 +158,7 @@ async function ServerJsonApiGet(params) {
158
158
  language: params.language,
159
159
  additionalHeaders: _optionalChain([_serverConfig, 'optionalAccess', _23 => _23.additionalHeaders])
160
160
  });
161
- return _chunkJ5LROFGLjs.translateResponse.call(void 0, {
161
+ return _chunkRCMIVTVLjs.translateResponse.call(void 0, {
162
162
  classKey: params.classKey,
163
163
  apiResponse,
164
164
  companyId: params.companyId,
@@ -179,7 +179,7 @@ async function ServerJsonApiPost(params) {
179
179
  if (!body) {
180
180
  body = {};
181
181
  } else if (params.overridesJsonApiCreation !== true) {
182
- body = _chunkJ5LROFGLjs.JsonApiDataFactory.create(params.classKey, body);
182
+ body = _chunkRCMIVTVLjs.JsonApiDataFactory.create(params.classKey, body);
183
183
  }
184
184
  const apiResponse = await _chunkIOLU4ASDjs.serverRequest.call(void 0, {
185
185
  method: "POST",
@@ -191,7 +191,7 @@ async function ServerJsonApiPost(params) {
191
191
  language: params.language,
192
192
  additionalHeaders: _optionalChain([_serverConfig, 'optionalAccess', _24 => _24.additionalHeaders])
193
193
  });
194
- return _chunkJ5LROFGLjs.translateResponse.call(void 0, {
194
+ return _chunkRCMIVTVLjs.translateResponse.call(void 0, {
195
195
  classKey: _nullishCoalesce(params.responseType, () => ( params.classKey)),
196
196
  apiResponse,
197
197
  companyId: params.companyId,
@@ -206,7 +206,7 @@ async function ServerJsonApiPut(params) {
206
206
  if (!body) {
207
207
  body = {};
208
208
  } else {
209
- body = _chunkJ5LROFGLjs.JsonApiDataFactory.create(params.classKey, body);
209
+ body = _chunkRCMIVTVLjs.JsonApiDataFactory.create(params.classKey, body);
210
210
  }
211
211
  const apiResponse = await _chunkIOLU4ASDjs.serverRequest.call(void 0, {
212
212
  method: "PUT",
@@ -218,7 +218,7 @@ async function ServerJsonApiPut(params) {
218
218
  language: params.language,
219
219
  additionalHeaders: _optionalChain([_serverConfig, 'optionalAccess', _25 => _25.additionalHeaders])
220
220
  });
221
- return _chunkJ5LROFGLjs.translateResponse.call(void 0, {
221
+ return _chunkRCMIVTVLjs.translateResponse.call(void 0, {
222
222
  classKey: _nullishCoalesce(params.responseType, () => ( params.classKey)),
223
223
  apiResponse,
224
224
  companyId: params.companyId,
@@ -233,7 +233,7 @@ async function ServerJsonApiPatch(params) {
233
233
  if (!body) {
234
234
  body = {};
235
235
  } else if (params.overridesJsonApiCreation !== true) {
236
- body = _chunkJ5LROFGLjs.JsonApiDataFactory.create(params.classKey, body);
236
+ body = _chunkRCMIVTVLjs.JsonApiDataFactory.create(params.classKey, body);
237
237
  }
238
238
  const apiResponse = await _chunkIOLU4ASDjs.serverRequest.call(void 0, {
239
239
  method: "PATCH",
@@ -245,7 +245,7 @@ async function ServerJsonApiPatch(params) {
245
245
  language: params.language,
246
246
  additionalHeaders: _optionalChain([_serverConfig, 'optionalAccess', _26 => _26.additionalHeaders])
247
247
  });
248
- return _chunkJ5LROFGLjs.translateResponse.call(void 0, {
248
+ return _chunkRCMIVTVLjs.translateResponse.call(void 0, {
249
249
  classKey: _nullishCoalesce(params.responseType, () => ( params.classKey)),
250
250
  apiResponse,
251
251
  companyId: params.companyId,
@@ -264,7 +264,7 @@ async function ServerJsonApiDelete(params) {
264
264
  language: params.language,
265
265
  additionalHeaders: _optionalChain([_serverConfig, 'optionalAccess', _27 => _27.additionalHeaders])
266
266
  });
267
- return _chunkJ5LROFGLjs.translateResponse.call(void 0, {
267
+ return _chunkRCMIVTVLjs.translateResponse.call(void 0, {
268
268
  classKey: _nullishCoalesce(params.responseType, () => ( params.classKey)),
269
269
  apiResponse,
270
270
  companyId: params.companyId,
@@ -296,5 +296,5 @@ _chunk7QVYU63Ejs.__name.call(void 0, ServerJsonApiDelete, "ServerJsonApiDelete")
296
296
 
297
297
 
298
298
 
299
- exports.ServerAuthService = _chunkS2PTWBN6js.AuthService; exports.ServerCompanyService = _chunkS2PTWBN6js.CompanyService; exports.ServerContentService = _chunkS2PTWBN6js.ContentService; exports.ServerFeatureService = _chunkS2PTWBN6js.FeatureService; exports.ServerJsonApiDelete = ServerJsonApiDelete; exports.ServerJsonApiGet = ServerJsonApiGet; exports.ServerJsonApiPatch = ServerJsonApiPatch; exports.ServerJsonApiPost = ServerJsonApiPost; exports.ServerJsonApiPut = ServerJsonApiPut; exports.ServerNotificationService = _chunkS2PTWBN6js.NotificationService; exports.ServerPushService = _chunkS2PTWBN6js.PushService; exports.ServerRoleService = _chunkS2PTWBN6js.RoleService; exports.ServerS3Service = _chunkS2PTWBN6js.S3Service; exports.ServerSession = ServerSession; exports.ServerUserService = _chunkS2PTWBN6js.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 = _chunk6P7MEU7Qjs.AuthService; exports.ServerCompanyService = _chunk6P7MEU7Qjs.CompanyService; exports.ServerContentService = _chunk6P7MEU7Qjs.ContentService; exports.ServerFeatureService = _chunk6P7MEU7Qjs.FeatureService; exports.ServerJsonApiDelete = ServerJsonApiDelete; exports.ServerJsonApiGet = ServerJsonApiGet; exports.ServerJsonApiPatch = ServerJsonApiPatch; exports.ServerJsonApiPost = ServerJsonApiPost; exports.ServerJsonApiPut = ServerJsonApiPut; exports.ServerNotificationService = _chunk6P7MEU7Qjs.NotificationService; exports.ServerPushService = _chunk6P7MEU7Qjs.PushService; exports.ServerRoleService = _chunk6P7MEU7Qjs.RoleService; exports.ServerS3Service = _chunk6P7MEU7Qjs.S3Service; exports.ServerSession = ServerSession; exports.ServerUserService = _chunk6P7MEU7Qjs.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,13 +15,13 @@ import {
15
15
  S3Service,
16
16
  UserService,
17
17
  checkPermissionsFromServer
18
- } from "../chunk-M7XAKLSP.mjs";
18
+ } from "../chunk-FWOS6PC5.mjs";
19
19
  import "../chunk-AUXK7QSA.mjs";
20
20
  import "../chunk-C7C7VY4F.mjs";
21
21
  import {
22
22
  JsonApiDataFactory,
23
23
  translateResponse
24
- } from "../chunk-KK7PYKVI.mjs";
24
+ } from "../chunk-WC2BTKJP.mjs";
25
25
  import {
26
26
  __name
27
27
  } from "../chunk-PAWJFY3S.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlonicora/nextjs-jsonapi",
3
- "version": "1.117.1",
3
+ "version": "1.119.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",
@@ -72,9 +72,18 @@ function runClientBootstrapper(): void {
72
72
  }
73
73
  }
74
74
 
75
- function buildClientUrl(endpoint: string): string {
76
- const apiUrl = getClientApiUrl();
77
- return endpoint.startsWith("http") ? endpoint : `${apiUrl}${endpoint}`;
75
+ /**
76
+ * Resolve the final request URL for an endpoint (client-side variant of `buildUrl`
77
+ * in `unified/JsonApiRequest.ts` kept in lockstep with it).
78
+ *
79
+ * - `endpoint` starting with "http" is always passed through unchanged (existing behaviour).
80
+ * - Otherwise, an explicit `baseUrl` (per-call override) takes precedence over the global
81
+ * `getClientApiUrl()` resolution. Omitting `baseUrl` preserves today's behaviour exactly.
82
+ */
83
+ export function buildClientUrl(endpoint: string, baseUrl?: string): string {
84
+ if (endpoint.startsWith("http")) return endpoint;
85
+ const apiUrl = baseUrl ?? getClientApiUrl();
86
+ return `${apiUrl}${endpoint}`;
78
87
  }
79
88
 
80
89
  export async function ClientJsonApiGet(params: {
@@ -82,13 +91,14 @@ export async function ClientJsonApiGet(params: {
82
91
  endpoint: string;
83
92
  companyId?: string;
84
93
  language: string;
94
+ baseUrl?: string;
85
95
  }): Promise<ApiResponseInterface> {
86
96
  runClientBootstrapper();
87
97
  const token = await getClientToken();
88
98
 
89
99
  const apiResponse = await directFetch({
90
100
  method: "GET",
91
- url: buildClientUrl(params.endpoint),
101
+ url: buildClientUrl(params.endpoint, params.baseUrl),
92
102
  token,
93
103
  companyId: params.companyId,
94
104
  language: params.language,
@@ -106,6 +116,7 @@ export async function ClientJsonApiGet(params: {
106
116
  endpoint,
107
117
  companyId: params.companyId,
108
118
  language: params.language,
119
+ baseUrl: params.baseUrl,
109
120
  }),
110
121
  });
111
122
  }
@@ -119,6 +130,7 @@ export async function ClientJsonApiPost(params: {
119
130
  files?: { [key: string]: File | Blob } | File | Blob;
120
131
  language: string;
121
132
  responseType?: ApiRequestDataTypeInterface;
133
+ baseUrl?: string;
122
134
  }): Promise<ApiResponseInterface> {
123
135
  runClientBootstrapper();
124
136
  const token = await getClientToken();
@@ -132,7 +144,7 @@ export async function ClientJsonApiPost(params: {
132
144
 
133
145
  const apiResponse = await directFetch({
134
146
  method: "POST",
135
- url: buildClientUrl(params.endpoint),
147
+ url: buildClientUrl(params.endpoint, params.baseUrl),
136
148
  token,
137
149
  body,
138
150
  files: params.files,
@@ -157,6 +169,7 @@ export async function ClientJsonApiPut(params: {
157
169
  files?: { [key: string]: File | Blob } | File | Blob;
158
170
  language: string;
159
171
  responseType?: ApiRequestDataTypeInterface;
172
+ baseUrl?: string;
160
173
  }): Promise<ApiResponseInterface> {
161
174
  runClientBootstrapper();
162
175
  const token = await getClientToken();
@@ -170,7 +183,7 @@ export async function ClientJsonApiPut(params: {
170
183
 
171
184
  const apiResponse = await directFetch({
172
185
  method: "PUT",
173
- url: buildClientUrl(params.endpoint),
186
+ url: buildClientUrl(params.endpoint, params.baseUrl),
174
187
  token,
175
188
  body,
176
189
  files: params.files,
@@ -196,6 +209,7 @@ export async function ClientJsonApiPatch(params: {
196
209
  overridesJsonApiCreation?: boolean;
197
210
  responseType?: ApiRequestDataTypeInterface;
198
211
  language: string;
212
+ baseUrl?: string;
199
213
  }): Promise<ApiResponseInterface> {
200
214
  runClientBootstrapper();
201
215
  const token = await getClientToken();
@@ -209,7 +223,7 @@ export async function ClientJsonApiPatch(params: {
209
223
 
210
224
  const apiResponse = await directFetch({
211
225
  method: "PATCH",
212
- url: buildClientUrl(params.endpoint),
226
+ url: buildClientUrl(params.endpoint, params.baseUrl),
213
227
  token,
214
228
  body,
215
229
  files: params.files,
@@ -232,13 +246,14 @@ export async function ClientJsonApiDelete(params: {
232
246
  companyId?: string;
233
247
  language: string;
234
248
  responseType?: ApiRequestDataTypeInterface;
249
+ baseUrl?: string;
235
250
  }): Promise<ApiResponseInterface> {
236
251
  runClientBootstrapper();
237
252
  const token = await getClientToken();
238
253
 
239
254
  const apiResponse = await directFetch({
240
255
  method: "DELETE",
241
- url: buildClientUrl(params.endpoint),
256
+ url: buildClientUrl(params.endpoint, params.baseUrl),
242
257
  token,
243
258
  companyId: params.companyId,
244
259
  language: params.language,
@@ -0,0 +1,31 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { buildClientUrl } from "../JsonApiClient";
3
+
4
+ describe("buildClientUrl", () => {
5
+ afterEach(() => {
6
+ vi.unstubAllEnvs();
7
+ });
8
+
9
+ it("uses NEXT_PUBLIC_API_URL when baseUrl is omitted", () => {
10
+ vi.stubEnv("NEXT_PUBLIC_API_URL", "https://api.example.com");
11
+
12
+ expect(buildClientUrl("/sncass/sync")).toBe("https://api.example.com/sncass/sync");
13
+ });
14
+
15
+ it("uses the per-call baseUrl override when provided", () => {
16
+ vi.stubEnv("NEXT_PUBLIC_API_URL", "https://api.example.com");
17
+
18
+ expect(buildClientUrl("/sncass/sync", "https://corpus.example.com")).toBe("https://corpus.example.com/sncass/sync");
19
+ });
20
+
21
+ it("passes through an http-prefixed endpoint regardless of baseUrl", () => {
22
+ vi.stubEnv("NEXT_PUBLIC_API_URL", "https://api.example.com");
23
+
24
+ expect(buildClientUrl("https://elsewhere.example.com/sncass/sync", "https://corpus.example.com")).toBe(
25
+ "https://elsewhere.example.com/sncass/sync",
26
+ );
27
+ expect(buildClientUrl("https://elsewhere.example.com/sncass/sync")).toBe(
28
+ "https://elsewhere.example.com/sncass/sync",
29
+ );
30
+ });
31
+ });
@@ -3,7 +3,9 @@
3
3
  import {
4
4
  Select,
5
5
  SelectContent,
6
+ SelectGroup,
6
7
  SelectItem,
8
+ SelectLabel,
7
9
  SelectTrigger,
8
10
  SelectValue,
9
11
  Sheet,
@@ -15,7 +17,7 @@ import {
15
17
  TabsList,
16
18
  TabsTrigger,
17
19
  } from "@/components";
18
- import { Tab } from "@/components/containers";
20
+ import { partitionTabs, Tab } from "@/components/containers";
19
21
  import { RoundPageContainerTitle } from "@/components/containers/RoundPageContainerTitle";
20
22
  import { Header } from "@/components/navigations";
21
23
  import { useHeaderChildren, useHeaderLeftContent } from "@/contexts";
@@ -23,7 +25,7 @@ import { useUrlRewriter } from "@/hooks";
23
25
  import { cn, useIsMobile } from "@/index";
24
26
  import { ModuleWithPermissions } from "@/permissions";
25
27
  import { useSearchParams } from "next/navigation";
26
- import { ReactNode, useCallback, useEffect, useState } from "react";
28
+ import { Fragment, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
27
29
 
28
30
  const DETAILS_COOKIE_NAME = "round_page_details_state";
29
31
  const DETAILS_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
@@ -37,8 +39,31 @@ type RoundPageContainerProps = {
37
39
  fullWidth?: boolean;
38
40
  forceHeader?: boolean;
39
41
  header?: ReactNode;
42
+ /**
43
+ * Section-navigation layout for `tabs`.
44
+ * - `"tabs"` (default) — horizontal `TabsList` (desktop) / `Select` (mobile).
45
+ * Unchanged from prior behaviour; existing callers need no edits.
46
+ * - `"rail"` — vertical 220px left rail grouped by each tab's `group`, with a
47
+ * `<md` grouped `Select` fallback. Mirrors the retired
48
+ * `PageContainerContentDetails` rail.
49
+ */
50
+ layout?: "tabs" | "rail";
40
51
  };
41
52
 
53
+ // Rail trigger class: override the horizontal TabsTrigger defaults for a
54
+ // vertical, left-aligned, dark-filled active state. tailwind-merge inside cn()
55
+ // resolves the conflicts with the base classes.
56
+ const railTriggerClass = cn(
57
+ "flex w-full items-center justify-start rounded-md px-3 py-1.5 text-left text-sm leading-tight whitespace-normal",
58
+ "text-muted-foreground",
59
+ "hover:bg-muted hover:text-foreground",
60
+ "data-[state=active]:bg-foreground data-[state=active]:text-background",
61
+ "data-[state=active]:font-semibold data-[state=active]:shadow-none",
62
+ );
63
+
64
+ /** Stable value for the URL `?section=` and active-tab matching. */
65
+ const tabValue = (tab: Tab): string => tab.sectionKey ?? tab.key?.name ?? tab.label;
66
+
42
67
  export function RoundPageContainer({
43
68
  module,
44
69
  id,
@@ -48,6 +73,7 @@ export function RoundPageContainer({
48
73
  fullWidth,
49
74
  forceHeader,
50
75
  header,
76
+ layout = "tabs",
51
77
  }: RoundPageContainerProps) {
52
78
  const headerChildren = useHeaderChildren();
53
79
  const headerLeftContent = useHeaderLeftContent();
@@ -75,15 +101,14 @@ export function RoundPageContainer({
75
101
  const rewriteUrl = useUrlRewriter();
76
102
 
77
103
  const initialValue = tabs
78
- ? (section && tabs.find((i) => (i.key?.name ?? i.label) === section) ? section : null) ||
79
- (tabs[0].key?.name ?? tabs[0].label)
104
+ ? (section && tabs.find((i) => tabValue(i) === section) ? section : null) || tabValue(tabs[0])
80
105
  : undefined;
81
106
 
82
107
  const [activeTab, setActiveTab] = useState(initialValue);
83
108
 
84
109
  useEffect(() => {
85
110
  if (tabs && section) {
86
- const tab = tabs.find((i) => (i.key?.name ?? i.label) === section);
111
+ const tab = tabs.find((i) => tabValue(i) === section);
87
112
  if (tab) {
88
113
  setActiveTab(section);
89
114
  }
@@ -98,7 +123,10 @@ export function RoundPageContainer({
98
123
  [module, id, rewriteUrl],
99
124
  );
100
125
 
101
- const activeFillHeight = tabs?.find((t) => (t.key?.name ?? t.label) === activeTab)?.fillHeight === true;
126
+ const activeFillHeight = tabs?.find((t) => tabValue(t) === activeTab)?.fillHeight === true;
127
+
128
+ // Rail partition — only consumed by `layout="rail"` but cheap to compute.
129
+ const { ungrouped, groups } = useMemo(() => partitionTabs(tabs ?? []), [tabs]);
102
130
 
103
131
  const isReady = mounted && isMobile !== undefined;
104
132
 
@@ -135,85 +163,178 @@ export function RoundPageContainer({
135
163
  />
136
164
  )}
137
165
  <div className="flex h-full w-full overflow-hidden">
138
- <div
139
- className={cn(
140
- `grow`,
141
- isMobile ? `p-2` : `p-4`,
142
- activeFillHeight ? `flex flex-col overflow-hidden` : `overflow-y-auto`,
143
- fullWidth && `p-0`,
144
- )}
145
- >
166
+ {layout === "rail" && tabs ? (
167
+ // Rail layout: the vertical-tab navigation is a flush-left
168
+ // sidebar of the card and the content fills the full remaining
169
+ // width (like `fullWidth`) NOT the centred max-w-6xl column.
170
+ <Tabs
171
+ value={activeTab}
172
+ onValueChange={handleTabChange}
173
+ orientation="vertical"
174
+ className="flex h-full min-w-0 grow overflow-hidden"
175
+ >
176
+ {/* Flush-left section rail — md and up */}
177
+ <aside
178
+ data-testid="round-page-rail"
179
+ className="hidden shrink-0 border-r p-4 md:flex md:w-56 md:flex-col md:overflow-y-auto"
180
+ >
181
+ <TabsList className="flex h-auto flex-col items-stretch gap-0.5 bg-transparent p-0">
182
+ {ungrouped.map((tab) => (
183
+ <TabsTrigger key={tab.label} value={tabValue(tab)} className={railTriggerClass}>
184
+ {tab.contentLabel ?? tab.label}
185
+ </TabsTrigger>
186
+ ))}
187
+ {groups.map((group) => (
188
+ <Fragment key={group.label}>
189
+ <div
190
+ role="presentation"
191
+ className="text-muted-foreground px-3 pt-3 pb-1 text-[10px] font-bold tracking-wider uppercase"
192
+ >
193
+ {group.label}
194
+ </div>
195
+ {group.items.map((tab) => (
196
+ <TabsTrigger key={tab.label} value={tabValue(tab)} className={railTriggerClass}>
197
+ {tab.contentLabel ?? tab.label}
198
+ </TabsTrigger>
199
+ ))}
200
+ </Fragment>
201
+ ))}
202
+ </TabsList>
203
+ </aside>
204
+
205
+ {/* Content — full width, fills the remaining space */}
206
+ <div className="flex min-w-0 grow flex-col overflow-hidden">
207
+ {/* Section Select — below md */}
208
+ <div data-testid="round-page-rail-select" className="p-2 md:hidden">
209
+ <Select
210
+ value={activeTab}
211
+ onValueChange={(value) => {
212
+ if (value) handleTabChange(value);
213
+ }}
214
+ >
215
+ <SelectTrigger className="w-full">
216
+ <SelectValue />
217
+ </SelectTrigger>
218
+ <SelectContent>
219
+ {ungrouped.map((tab) => (
220
+ <SelectItem key={tab.label} value={tabValue(tab)}>
221
+ {tab.contentLabel ?? tab.label}
222
+ </SelectItem>
223
+ ))}
224
+ {groups.map((group) => (
225
+ <SelectGroup key={group.label}>
226
+ <SelectLabel>{group.label}</SelectLabel>
227
+ {group.items.map((tab) => (
228
+ <SelectItem key={tab.label} value={tabValue(tab)}>
229
+ {tab.contentLabel ?? tab.label}
230
+ </SelectItem>
231
+ ))}
232
+ </SelectGroup>
233
+ ))}
234
+ </SelectContent>
235
+ </Select>
236
+ </div>
237
+ <div
238
+ className={cn(
239
+ `min-w-0 grow`,
240
+ activeFillHeight ? `flex flex-col overflow-hidden` : `overflow-y-auto`,
241
+ )}
242
+ >
243
+ {header}
244
+ {tabs.map((tab) => (
245
+ <TabsContent
246
+ key={tab.label}
247
+ value={tabValue(tab)}
248
+ className={tab.fillHeight ? `flex min-h-0 w-full flex-1 flex-col` : ``}
249
+ >
250
+ {tab.content}
251
+ </TabsContent>
252
+ ))}
253
+ {children && <div className="flex">{children}</div>}
254
+ </div>
255
+ </div>
256
+ </Tabs>
257
+ ) : (
146
258
  <div
147
259
  className={cn(
148
- `mx-auto max-w-6xl space-y-8`,
149
- activeFillHeight && `flex w-full flex-1 min-h-0 flex-col space-y-0`,
150
- fullWidth && `max-w-full w-full p-0 h-full`,
260
+ `grow`,
261
+ isMobile ? `p-2` : `p-4`,
262
+ activeFillHeight ? `flex flex-col overflow-hidden` : `overflow-y-auto`,
263
+ fullWidth && `p-0`,
151
264
  )}
152
265
  >
153
- {header}
154
- {tabs ? (
155
- <>
156
- <Tabs
157
- value={activeTab}
158
- className={cn(`w-full`, activeFillHeight && `flex flex-1 min-h-0 flex-col`)}
159
- onValueChange={handleTabChange}
160
- >
161
- {isMobile ? (
162
- <div className="p-0">
163
- <Select
164
- value={activeTab}
165
- onValueChange={(value) => {
166
- if (value) handleTabChange(value);
167
- }}
168
- >
169
- <SelectTrigger className="w-full">
170
- <SelectValue />
171
- </SelectTrigger>
172
- <SelectContent>
266
+ <div
267
+ className={cn(
268
+ `mx-auto max-w-6xl space-y-8`,
269
+ activeFillHeight && `flex w-full flex-1 min-h-0 flex-col space-y-0`,
270
+ fullWidth && `max-w-full w-full p-0 h-full`,
271
+ )}
272
+ >
273
+ {header}
274
+ {tabs ? (
275
+ <>
276
+ <Tabs
277
+ value={activeTab}
278
+ className={cn(`w-full`, activeFillHeight && `flex flex-1 min-h-0 flex-col`)}
279
+ onValueChange={handleTabChange}
280
+ >
281
+ {isMobile ? (
282
+ <div className="p-0">
283
+ <Select
284
+ value={activeTab}
285
+ onValueChange={(value) => {
286
+ if (value) handleTabChange(value);
287
+ }}
288
+ >
289
+ <SelectTrigger className="w-full">
290
+ <SelectValue />
291
+ </SelectTrigger>
292
+ <SelectContent>
293
+ {tabs.map((tab) => (
294
+ <SelectItem key={tab.label} value={tabValue(tab)}>
295
+ {tab.contentLabel ?? tab.label}
296
+ </SelectItem>
297
+ ))}
298
+ </SelectContent>
299
+ </Select>
300
+ </div>
301
+ ) : (
302
+ <div className="p-4">
303
+ <TabsList>
173
304
  {tabs.map((tab) => (
174
- <SelectItem key={tab.label} value={tab.key?.name ?? tab.label}>
305
+ <TabsTrigger key={tab.label} value={tabValue(tab)} className="px-4">
175
306
  {tab.contentLabel ?? tab.label}
176
- </SelectItem>
307
+ </TabsTrigger>
177
308
  ))}
178
- </SelectContent>
179
- </Select>
180
- </div>
181
- ) : (
182
- <div className="p-4">
183
- <TabsList>
184
- {tabs.map((tab) => (
185
- <TabsTrigger key={tab.label} value={tab.key?.name ?? tab.label} className="px-4">
186
- {tab.contentLabel ?? tab.label}
187
- </TabsTrigger>
188
- ))}
189
- </TabsList>
190
- </div>
191
- )}
192
- <div
193
- className={cn(
194
- `flex w-full `,
195
- isMobile ? `` : `px-4`,
196
- activeFillHeight ? `flex-1 min-h-0` : `overflow-y-auto`,
309
+ </TabsList>
310
+ </div>
197
311
  )}
198
- >
199
- {tabs.map((tab) => (
200
- <TabsContent
201
- key={tab.label}
202
- value={tab.key?.name ?? tab.label}
203
- className={tab.fillHeight ? `flex flex-1 min-h-0 w-full flex-col` : `pb-20`}
204
- >
205
- {tab.content}
206
- </TabsContent>
207
- ))}
208
- </div>
209
- </Tabs>
210
- {children && <div className={cn(`flex`, isMobile ? `px-2` : `px-4`)}>{children}</div>}
211
- </>
212
- ) : (
213
- children
214
- )}
312
+ <div
313
+ className={cn(
314
+ `flex w-full `,
315
+ isMobile ? `` : `px-4`,
316
+ activeFillHeight ? `flex-1 min-h-0` : `overflow-y-auto`,
317
+ )}
318
+ >
319
+ {tabs.map((tab) => (
320
+ <TabsContent
321
+ key={tab.label}
322
+ value={tabValue(tab)}
323
+ className={tab.fillHeight ? `flex flex-1 min-h-0 w-full flex-col` : `pb-20`}
324
+ >
325
+ {tab.content}
326
+ </TabsContent>
327
+ ))}
328
+ </div>
329
+ </Tabs>
330
+ {children && <div className={cn(`flex`, isMobile ? `px-2` : `px-4`)}>{children}</div>}
331
+ </>
332
+ ) : (
333
+ children
334
+ )}
335
+ </div>
215
336
  </div>
216
- </div>
337
+ )}
217
338
  {details &&
218
339
  (isMobile ? (
219
340
  <Sheet open={showDetails} onOpenChange={setShowDetails}>
@@ -13,6 +13,20 @@ export type Tab = {
13
13
  content: React.ReactNode;
14
14
  modules?: ModuleWithPermissions[];
15
15
  action?: Action;
16
+ /**
17
+ * Section header this tab clusters under in the `RoundPageContainer`
18
+ * `layout="rail"` navigation. Tabs without a `group` render at the top of the
19
+ * rail (the "pinned" stratum) in declared order; groups render in first-seen
20
+ * order. Ignored by the default `layout="tabs"` (flat) rendering.
21
+ */
22
+ group?: string;
23
+ /**
24
+ * Stable identifier used for the URL `?section=` value and active-tab
25
+ * matching. Defaults to `key?.name ?? label`. Provide this for tabs that have
26
+ * no `key` (no backing module) but still need a stable, locale-independent
27
+ * deep link — e.g. `"dashboard"`, `"analysis"`, `"timeline"`.
28
+ */
29
+ sectionKey?: string;
16
30
  /**
17
31
  * When true, the tab content fills the available viewport height inside its
18
32
  * container. The tab's wrapper chain switches to a flex-column layout with