@carlonicora/nextjs-jsonapi 1.117.1 → 1.118.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 (30) hide show
  1. package/dist/{BlockNoteEditor-CEWZCORH.js → BlockNoteEditor-5FFVS7KW.js} +9 -9
  2. package/dist/{BlockNoteEditor-CEWZCORH.js.map → BlockNoteEditor-5FFVS7KW.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-K4T6QOJO.mjs → BlockNoteEditor-6GRZW6BL.mjs} +2 -2
  4. package/dist/billing/index.js +299 -299
  5. package/dist/billing/index.mjs +1 -1
  6. package/dist/{chunk-6D4GJHLK.mjs → chunk-KXHEOLPZ.mjs} +273 -150
  7. package/dist/chunk-KXHEOLPZ.mjs.map +1 -0
  8. package/dist/{chunk-56QAXZKI.js → chunk-M2ODDWY3.js} +238 -115
  9. package/dist/chunk-M2ODDWY3.js.map +1 -0
  10. package/dist/client/index.js +2 -2
  11. package/dist/client/index.mjs +1 -1
  12. package/dist/components/index.d.mts +72 -30
  13. package/dist/components/index.d.ts +72 -30
  14. package/dist/components/index.js +4 -2
  15. package/dist/components/index.js.map +1 -1
  16. package/dist/components/index.mjs +3 -1
  17. package/dist/contexts/index.js +2 -2
  18. package/dist/contexts/index.mjs +1 -1
  19. package/dist/features/help/index.js +30 -30
  20. package/dist/features/help/index.mjs +1 -1
  21. package/package.json +1 -1
  22. package/src/components/containers/RoundPageContainer.tsx +197 -76
  23. package/src/components/containers/TabsContainer.tsx +14 -0
  24. package/src/components/containers/__tests__/partitionTabs.spec.ts +51 -0
  25. package/src/components/containers/index.ts +1 -0
  26. package/src/components/containers/partitionTabs.ts +43 -0
  27. package/src/components/forms/EditorSheet.tsx +7 -2
  28. package/dist/chunk-56QAXZKI.js.map +0 -1
  29. package/dist/chunk-6D4GJHLK.mjs.map +0 -1
  30. /package/dist/{BlockNoteEditor-K4T6QOJO.mjs.map → BlockNoteEditor-6GRZW6BL.mjs.map} +0 -0
@@ -21,7 +21,7 @@ var _chunkYBZVWLPHjs = require('../../chunk-YBZVWLPH.js');
21
21
 
22
22
 
23
23
 
24
- var _chunk56QAXZKIjs = require('../../chunk-56QAXZKI.js');
24
+ var _chunkM2ODDWY3js = require('../../chunk-M2ODDWY3.js');
25
25
 
26
26
 
27
27
  var _chunk6KLR3WJQjs = require('../../chunk-6KLR3WJQ.js');
@@ -73,29 +73,29 @@ var _lucidereact = require('lucide-react');
73
73
 
74
74
 
75
75
  function HelpAssistantSheet({ open, onOpenChange }) {
76
- return /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk56QAXZKIjs.Sheet, { open, onOpenChange, children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
77
- _chunk56QAXZKIjs.SheetContent,
76
+ return /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkM2ODDWY3js.Sheet, { open, onOpenChange, children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
77
+ _chunkM2ODDWY3js.SheetContent,
78
78
  {
79
79
  side: "right",
80
80
  className: "flex w-full flex-col data-[side=right]:sm:max-w-2xl data-[side=right]:lg:max-w-3xl",
81
- children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk56QAXZKIjs.AssistantProvider, { manageUrl: false, children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, HelpAssistantSheetBody, {}) })
81
+ children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkM2ODDWY3js.AssistantProvider, { manageUrl: false, children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, HelpAssistantSheetBody, {}) })
82
82
  }
83
83
  ) });
84
84
  }
85
85
  _chunk7QVYU63Ejs.__name.call(void 0, HelpAssistantSheet, "HelpAssistantSheet");
86
86
  function HelpAssistantSheetBody() {
87
87
  const t = _nextintl.useTranslations.call(void 0, );
88
- const ctx = _chunk56QAXZKIjs.useAssistantContext.call(void 0, );
88
+ const ctx = _chunkM2ODDWY3js.useAssistantContext.call(void 0, );
89
89
  const showThread = !!ctx.assistant || ctx.sending || ctx.messages.length > 0;
90
90
  const send = /* @__PURE__ */ _chunk7QVYU63Ejs.__name.call(void 0, (content) => ctx.sendMessage(content, { howToMode: true }), "send");
91
91
  return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _jsxruntime.Fragment, { children: [
92
- /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunk56QAXZKIjs.SheetHeader, { children: [
93
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk56QAXZKIjs.SheetTitle, { children: t("help.askAi.sheet.title") }),
94
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk56QAXZKIjs.SheetDescription, { children: t("help.askAi.sheet.subtitle") })
92
+ /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkM2ODDWY3js.SheetHeader, { children: [
93
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkM2ODDWY3js.SheetTitle, { children: t("help.askAi.sheet.title") }),
94
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkM2ODDWY3js.SheetDescription, { children: t("help.askAi.sheet.subtitle") })
95
95
  ] }),
96
- !showThread ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk56QAXZKIjs.AssistantEmptyState, { onSend: send }) : /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _jsxruntime.Fragment, { children: [
96
+ !showThread ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkM2ODDWY3js.AssistantEmptyState, { onSend: send }) : /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _jsxruntime.Fragment, { children: [
97
97
  /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
98
- _chunk56QAXZKIjs.AssistantThread,
98
+ _chunkM2ODDWY3js.AssistantThread,
99
99
  {
100
100
  messages: ctx.messages,
101
101
  sending: ctx.sending,
@@ -105,7 +105,7 @@ function HelpAssistantSheetBody() {
105
105
  onRetry: ctx.retrySend
106
106
  }
107
107
  ),
108
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk56QAXZKIjs.AssistantComposer, { onSend: send, disabled: ctx.sending })
108
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkM2ODDWY3js.AssistantComposer, { onSend: send, disabled: ctx.sending })
109
109
  ] })
110
110
  ] });
111
111
  }
@@ -115,20 +115,20 @@ _chunk7QVYU63Ejs.__name.call(void 0, HelpAssistantSheetBody, "HelpAssistantSheet
115
115
 
116
116
  function HelpAskAi() {
117
117
  const t = _nextintl.useTranslations.call(void 0, );
118
- const { currentUser } = _chunk56QAXZKIjs.useCurrentUserContext.call(void 0, );
118
+ const { currentUser } = _chunkM2ODDWY3js.useCurrentUserContext.call(void 0, );
119
119
  const [open, setOpen] = _react.useState.call(void 0, false);
120
120
  const disabled = !currentUser;
121
121
  if (disabled) {
122
- return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunk56QAXZKIjs.Tooltip, { children: [
123
- /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunk56QAXZKIjs.TooltipTrigger, { render: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk56QAXZKIjs.Button, { variant: "outline", size: "sm", disabled: true }), children: [
122
+ return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkM2ODDWY3js.Tooltip, { children: [
123
+ /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkM2ODDWY3js.TooltipTrigger, { render: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkM2ODDWY3js.Button, { variant: "outline", size: "sm", disabled: true }), children: [
124
124
  /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.SparklesIcon, { className: "h-4 w-4" }),
125
125
  t("help.askAi.button")
126
126
  ] }),
127
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk56QAXZKIjs.TooltipContent, { children: t("help.askAi.loginTooltip") })
127
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkM2ODDWY3js.TooltipContent, { children: t("help.askAi.loginTooltip") })
128
128
  ] });
129
129
  }
130
130
  return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _jsxruntime.Fragment, { children: [
131
- /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunk56QAXZKIjs.Button, { variant: "outline", size: "sm", onClick: () => setOpen(true), children: [
131
+ /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkM2ODDWY3js.Button, { variant: "outline", size: "sm", onClick: () => setOpen(true), children: [
132
132
  /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.SparklesIcon, { className: "h-4 w-4" }),
133
133
  t("help.askAi.button")
134
134
  ] }),
@@ -141,7 +141,7 @@ _chunk7QVYU63Ejs.__name.call(void 0, HelpAskAi, "HelpAskAi");
141
141
 
142
142
  function HelpHeader() {
143
143
  const t = _nextintl.useTranslations.call(void 0, );
144
- const { currentUser } = _chunk56QAXZKIjs.useCurrentUserContext.call(void 0, );
144
+ const { currentUser } = _chunkM2ODDWY3js.useCurrentUserContext.call(void 0, );
145
145
  const { brand } = useHelp();
146
146
  const logo = _optionalChain([brand, 'optionalAccess', _ => _.logo]);
147
147
  const label = _nullishCoalesce(_optionalChain([brand, 'optionalAccess', _2 => _2.label]), () => ( "Help"));
@@ -157,7 +157,7 @@ function HelpHeader() {
157
157
  ] }),
158
158
  /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "div", { className: "flex items-center gap-2", children: [
159
159
  /* @__PURE__ */ _jsxruntime.jsx.call(void 0, HelpAskAi, {}),
160
- currentUser ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk56QAXZKIjs.Button, { render: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _link2.default, { href: appHref }), nativeButton: false, variant: "outline", size: "sm", children: t("help.header.openApp") }) : /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk56QAXZKIjs.Button, { render: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _link2.default, { href: "/login" }), nativeButton: false, variant: "outline", size: "sm", children: t("help.header.login") })
160
+ currentUser ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkM2ODDWY3js.Button, { render: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _link2.default, { href: appHref }), nativeButton: false, variant: "outline", size: "sm", children: t("help.header.openApp") }) : /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkM2ODDWY3js.Button, { render: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _link2.default, { href: "/login" }), nativeButton: false, variant: "outline", size: "sm", children: t("help.header.login") })
161
161
  ] })
162
162
  ] });
163
163
  }
@@ -199,7 +199,7 @@ _chunk7QVYU63Ejs.__name.call(void 0, useHelpFilter, "useHelpFilter");
199
199
 
200
200
  function HelpSideNav() {
201
201
  const t = _nextintl.useTranslations.call(void 0, );
202
- const generateUrl = _chunk56QAXZKIjs.usePageUrlGenerator.call(void 0, );
202
+ const generateUrl = _chunkM2ODDWY3js.usePageUrlGenerator.call(void 0, );
203
203
  const pathname = _navigation.usePathname.call(void 0, );
204
204
  const [articles, setArticles] = _react.useState.call(void 0, []);
205
205
  _react.useEffect.call(void 0, () => {
@@ -280,7 +280,7 @@ _chunk7QVYU63Ejs.__name.call(void 0, HelpSideNav, "HelpSideNav");
280
280
  function HelpArticleBody(props) {
281
281
  const t = _nextintl.useTranslations.call(void 0, );
282
282
  const format = _nextintl.useFormatter.call(void 0, );
283
- const generateUrl = _chunk56QAXZKIjs.usePageUrlGenerator.call(void 0, );
283
+ const generateUrl = _chunkM2ODDWY3js.usePageUrlGenerator.call(void 0, );
284
284
  const { howToType, title, summary, updatedAt, prev, next, children } = props;
285
285
  return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "article", { className: "prose dark:prose-invert max-w-none", children: [
286
286
  /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "nav", { className: "text-muted-foreground mb-2 text-xs", children: [
@@ -358,7 +358,7 @@ _chunk7QVYU63Ejs.__name.call(void 0, HelpTOC, "HelpTOC");
358
358
 
359
359
  function HelpHint({ contextKey }) {
360
360
  const t = _nextintl.useTranslations.call(void 0, );
361
- const generateUrl = _chunk56QAXZKIjs.usePageUrlGenerator.call(void 0, );
361
+ const generateUrl = _chunkM2ODDWY3js.usePageUrlGenerator.call(void 0, );
362
362
  const [open, setOpen] = _react.useState.call(void 0, false);
363
363
  const [picked, setPicked] = _react.useState.call(void 0, null);
364
364
  const [articles, setArticles] = _react.useState.call(void 0, []);
@@ -376,7 +376,7 @@ function HelpHint({ contextKey }) {
376
376
  if (matches.length === 0) return null;
377
377
  const active = _nullishCoalesce(picked, () => ( (matches.length === 1 ? matches[0] : null)));
378
378
  return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0,
379
- _chunk56QAXZKIjs.Sheet,
379
+ _chunkM2ODDWY3js.Sheet,
380
380
  {
381
381
  open,
382
382
  onOpenChange: (o) => {
@@ -384,11 +384,11 @@ function HelpHint({ contextKey }) {
384
384
  if (!o) setPicked(null);
385
385
  },
386
386
  children: [
387
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk56QAXZKIjs.SheetTrigger, { render: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk56QAXZKIjs.Button, { variant: "ghost", size: "icon-sm", "aria-label": t("help.hint.trigger") }), children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.HelpCircleIcon, { className: "h-4 w-4" }) }),
388
- /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunk56QAXZKIjs.SheetContent, { side: "right", className: "w-full max-w-md sm:max-w-lg", children: [
389
- /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunk56QAXZKIjs.SheetHeader, { children: [
390
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk56QAXZKIjs.SheetTitle, { children: active ? active.name : t("help.hint.pickArticle") }),
391
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk56QAXZKIjs.SheetDescription, { children: _nullishCoalesce(_optionalChain([active, 'optionalAccess', _4 => _4.summary]), () => ( "")) })
387
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkM2ODDWY3js.SheetTrigger, { render: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkM2ODDWY3js.Button, { variant: "ghost", size: "icon-sm", "aria-label": t("help.hint.trigger") }), children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.HelpCircleIcon, { className: "h-4 w-4" }) }),
388
+ /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkM2ODDWY3js.SheetContent, { side: "right", className: "w-full max-w-md sm:max-w-lg", children: [
389
+ /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _chunkM2ODDWY3js.SheetHeader, { children: [
390
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkM2ODDWY3js.SheetTitle, { children: active ? active.name : t("help.hint.pickArticle") }),
391
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkM2ODDWY3js.SheetDescription, { children: _nullishCoalesce(_optionalChain([active, 'optionalAccess', _4 => _4.summary]), () => ( "")) })
392
392
  ] }),
393
393
  active ? /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "div", { className: "mt-4 space-y-3", children: [
394
394
  /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "p", { className: "text-muted-foreground text-sm", children: active.summary }),
@@ -420,7 +420,7 @@ _chunk7QVYU63Ejs.__name.call(void 0, HelpHint, "HelpHint");
420
420
 
421
421
  function HelpSearchResultRow({ result, onSelect }) {
422
422
  const t = _nextintl.useTranslations.call(void 0, );
423
- const generateUrl = _chunk56QAXZKIjs.usePageUrlGenerator.call(void 0, );
423
+ const generateUrl = _chunkM2ODDWY3js.usePageUrlGenerator.call(void 0, );
424
424
  const [article, setArticle] = _react.useState.call(void 0, null);
425
425
  _react.useEffect.call(void 0, () => {
426
426
  let active = true;
@@ -431,7 +431,7 @@ function HelpSearchResultRow({ result, onSelect }) {
431
431
  }, [result.id]);
432
432
  if (!article) {
433
433
  return /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
434
- _chunk56QAXZKIjs.CommandItem,
434
+ _chunkM2ODDWY3js.CommandItem,
435
435
  {
436
436
  value: result.id,
437
437
  disabled: true,
@@ -440,7 +440,7 @@ function HelpSearchResultRow({ result, onSelect }) {
440
440
  }
441
441
  );
442
442
  }
443
- return /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunk56QAXZKIjs.CommandItem, { value: result.id, onSelect, className: "cursor-pointer p-0", children: /* @__PURE__ */ _jsxruntime.jsxs.call(void 0,
443
+ return /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkM2ODDWY3js.CommandItem, { value: result.id, onSelect, className: "cursor-pointer p-0", children: /* @__PURE__ */ _jsxruntime.jsxs.call(void 0,
444
444
  _link2.default,
445
445
  {
446
446
  href: generateUrl({ page: `/help/${article.howToType}/${article.slug}` }),
@@ -21,7 +21,7 @@ import {
21
21
  useAssistantContext,
22
22
  useCurrentUserContext,
23
23
  usePageUrlGenerator
24
- } from "../../chunk-6D4GJHLK.mjs";
24
+ } from "../../chunk-KXHEOLPZ.mjs";
25
25
  import {
26
26
  _getStaticHelpContent
27
27
  } from "../../chunk-WEPQD5MP.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.118.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",
@@ -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
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { partitionTabs } from "../partitionTabs";
3
+ import type { Tab } from "../TabsContainer";
4
+
5
+ const tab = (label: string, group?: string): Tab => ({
6
+ label,
7
+ content: null,
8
+ group,
9
+ });
10
+
11
+ describe("partitionTabs", () => {
12
+ it("returns empty partition for empty input", () => {
13
+ expect(partitionTabs([])).toEqual({ ungrouped: [], groups: [] });
14
+ });
15
+
16
+ it("keeps tabs without a group in the ungrouped stratum, in declared order", () => {
17
+ const result = partitionTabs([tab("a"), tab("b"), tab("c")]);
18
+ expect(result.ungrouped.map((t) => t.label)).toEqual(["a", "b", "c"]);
19
+ expect(result.groups).toEqual([]);
20
+ });
21
+
22
+ it("clusters tabs by group label", () => {
23
+ const result = partitionTabs([tab("a", "Casebook"), tab("b", "Casebook"), tab("c", "Work")]);
24
+ expect(result.ungrouped).toEqual([]);
25
+ expect(result.groups).toEqual([
26
+ { label: "Casebook", items: [expect.objectContaining({ label: "a" }), expect.objectContaining({ label: "b" })] },
27
+ { label: "Work", items: [expect.objectContaining({ label: "c" })] },
28
+ ]);
29
+ });
30
+
31
+ it("renders groups in the order they first appear", () => {
32
+ const result = partitionTabs([tab("a", "Work"), tab("b", "Casebook"), tab("c", "Work"), tab("d", "Casebook")]);
33
+ expect(result.groups.map((g) => g.label)).toEqual(["Work", "Casebook"]);
34
+ expect(result.groups[0].items.map((t) => t.label)).toEqual(["a", "c"]);
35
+ expect(result.groups[1].items.map((t) => t.label)).toEqual(["b", "d"]);
36
+ });
37
+
38
+ it("preserves ungrouped tabs above grouped tabs regardless of array order", () => {
39
+ const result = partitionTabs([tab("one", "Work"), tab("two"), tab("three", "Work"), tab("four")]);
40
+ expect(result.ungrouped.map((t) => t.label)).toEqual(["two", "four"]);
41
+ expect(result.groups).toHaveLength(1);
42
+ expect(result.groups[0].items.map((t) => t.label)).toEqual(["one", "three"]);
43
+ });
44
+
45
+ it("treats an empty-string group as 'no group'", () => {
46
+ const result = partitionTabs([tab("a", ""), tab("b", "Work")]);
47
+ expect(result.ungrouped.map((t) => t.label)).toEqual(["a"]);
48
+ expect(result.groups).toHaveLength(1);
49
+ expect(result.groups[0].label).toBe("Work");
50
+ });
51
+ });
@@ -1,4 +1,5 @@
1
1
  export * from "./PageContainer";
2
+ export * from "./partitionTabs";
2
3
  export * from "./ReactMarkdownContainer";
3
4
  export * from "./RoundPageContainer";
4
5
  export * from "./RoundPageContainerTitle";