@carlonicora/nextjs-jsonapi 3.20.1 → 3.22.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 (34) hide show
  1. package/dist/{BlockNoteEditor-FKYWM4NJ.mjs → BlockNoteEditor-5QBGVWE7.mjs} +2 -2
  2. package/dist/{BlockNoteEditor-2M2M7MT4.js → BlockNoteEditor-VEEMWB2J.js} +9 -9
  3. package/dist/{BlockNoteEditor-2M2M7MT4.js.map → BlockNoteEditor-VEEMWB2J.js.map} +1 -1
  4. package/dist/billing/index.js +335 -335
  5. package/dist/billing/index.mjs +1 -1
  6. package/dist/{chunk-HP3SPOPP.mjs → chunk-5G7I3OUB.mjs} +42 -21
  7. package/dist/chunk-5G7I3OUB.mjs.map +1 -0
  8. package/dist/{chunk-XMYHR2EF.js → chunk-XYSWOW5G.js} +142 -121
  9. package/dist/chunk-XYSWOW5G.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 +10 -2
  13. package/dist/components/index.d.ts +10 -2
  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 +31 -31
  20. package/dist/features/help/index.mjs +1 -1
  21. package/dist/features/tokenusage/index.js +75 -75
  22. package/dist/features/tokenusage/index.mjs +1 -1
  23. package/package.json +1 -1
  24. package/src/components/containers/RoundPageContainer.tsx +6 -2
  25. package/src/components/containers/__tests__/RoundPageContainer.padding.spec.tsx +64 -0
  26. package/src/components/navigations/Header.tsx +3 -2
  27. package/src/components/navigations/__tests__/Header.spec.tsx +60 -0
  28. package/src/components/tables/ContentListTable.tsx +56 -26
  29. package/src/components/tables/__tests__/ContentListTable.spec.tsx +133 -10
  30. package/src/shadcnui/ui/badge.tsx +2 -0
  31. package/src/shadcnui/ui/sidebar.tsx +6 -0
  32. package/dist/chunk-HP3SPOPP.mjs.map +0 -1
  33. package/dist/chunk-XMYHR2EF.js.map +0 -1
  34. /package/dist/{BlockNoteEditor-FKYWM4NJ.mjs.map → BlockNoteEditor-5QBGVWE7.mjs.map} +0 -0
@@ -24,6 +24,7 @@ import { MicroLabel } from "@/components/typography";
24
24
  import { useHeaderChildren, useHeaderLeftContent, useHeaderLogo, useHeaderMobileChildren } from "@/contexts";
25
25
  import { useUrlRewriter } from "@/hooks";
26
26
  import { cn, useIsMobile } from "@/index";
27
+ import { useOptionalSidebar } from "@/shadcnui";
27
28
  import { ModuleWithPermissions } from "@/permissions";
28
29
  import { useSearchParams } from "next/navigation";
29
30
  import { Fragment, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
@@ -186,6 +187,9 @@ export function RoundPageContainer({
186
187
  const headerMobileChildren = useHeaderMobileChildren();
187
188
  const [showDetails, setShowDetailsState] = useState(defaultDetailsOpen);
188
189
  const isMobile = useIsMobile();
190
+ // The desktop shell drops its start padding only where a sidebar sits against it. A page
191
+ // without one (the customer portal) keeps the padding on both sides.
192
+ const hasSidebar = useOptionalSidebar() !== null;
189
193
  const [mounted, setMounted] = useState(false);
190
194
 
191
195
  useEffect(() => {
@@ -323,7 +327,7 @@ export function RoundPageContainer({
323
327
  // render 78px tall; below the bar it became a visible gap. The
324
328
  // swipe-up gesture region is unaffected either way — it is a gesture
325
329
  // area, not painted chrome.
326
- isMobile ? "gap-1 p-1 pt-0" : "p-2 pt-0 ps-0",
330
+ isMobile ? "gap-1 p-1 pt-0" : cn("p-2 pt-0", hasSidebar && "ps-0"),
327
331
  )}
328
332
  >
329
333
  {/* `min-h-0 flex-1`, NOT `h-full`: the bar below is an in-flow sibling in
@@ -367,7 +371,7 @@ export function RoundPageContainer({
367
371
  // render 78px tall; below the bar it became a visible gap. The
368
372
  // swipe-up gesture region is unaffected either way — it is a gesture
369
373
  // area, not painted chrome.
370
- isMobile ? "gap-1 p-1 pt-0" : "p-2 pt-0 ps-0",
374
+ isMobile ? "gap-1 p-1 pt-0" : cn("p-2 pt-0", hasSidebar && "ps-0"),
371
375
  )}
372
376
  >
373
377
  {/* `min-h-0 flex-1`, NOT `h-full`: MobileNavigationBar below is an in-flow
@@ -0,0 +1,64 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { beforeAll, describe, expect, it, vi } from "vitest";
3
+ import { SharedProvider } from "../../../contexts/SharedContext";
4
+ import { configureI18n } from "../../../i18n";
5
+ import { SidebarProvider } from "../../../shadcnui";
6
+ import { RoundPageContainer } from "../../containers/RoundPageContainer";
7
+
8
+ vi.mock("next/navigation", () => ({
9
+ usePathname: () => "/",
10
+ useRouter: () => ({
11
+ push: vi.fn(),
12
+ replace: vi.fn(),
13
+ prefetch: vi.fn(),
14
+ back: vi.fn(),
15
+ forward: vi.fn(),
16
+ refresh: vi.fn(),
17
+ }),
18
+ useSearchParams: () => new URLSearchParams(),
19
+ useParams: () => ({}),
20
+ redirect: vi.fn(),
21
+ notFound: vi.fn(),
22
+ }));
23
+
24
+ beforeAll(() => {
25
+ // The package Link resolves its inner component at runtime and throws if i18n
26
+ // was never configured (src/i18n/config.ts).
27
+ configureI18n({
28
+ useRouter: () => ({
29
+ push: vi.fn(),
30
+ replace: vi.fn(),
31
+ back: vi.fn(),
32
+ forward: vi.fn(),
33
+ refresh: vi.fn(),
34
+ prefetch: vi.fn(),
35
+ }),
36
+ useTranslations: () => (key: string) => key,
37
+ usePathname: () => "/",
38
+ Link: ({ href, children, ...rest }: { href: string; children: React.ReactNode; [key: string]: any }) => (
39
+ <a href={href} {...rest}>
40
+ {children}
41
+ </a>
42
+ ),
43
+ });
44
+ });
45
+
46
+ function withShared(node: React.ReactNode) {
47
+ return <SharedProvider value={{ breadcrumbs: [], title: { type: "" } }}>{node}</SharedProvider>;
48
+ }
49
+
50
+ describe("RoundPageContainer shell padding", () => {
51
+ it("drops the start padding next to a sidebar", async () => {
52
+ render(
53
+ withShared(<SidebarProvider>{<RoundPageContainer testId="shell">content</RoundPageContainer>}</SidebarProvider>),
54
+ );
55
+ expect((await screen.findByTestId("shell")).classList.contains("ps-0")).toBe(true);
56
+ });
57
+
58
+ it("keeps the start padding when the page has no sidebar", async () => {
59
+ render(withShared(<RoundPageContainer testId="shell">content</RoundPageContainer>));
60
+ const shell = await screen.findByTestId("shell");
61
+ expect(shell.classList.contains("p-2")).toBe(true);
62
+ expect(shell.classList.contains("ps-0")).toBe(false);
63
+ });
64
+ });
@@ -4,7 +4,7 @@ import { useIsMobile } from "@/utils";
4
4
  import { useIsBreadcrumbRootHidden } from "../../contexts/BreadcrumbRootContext";
5
5
  import { useHeaderRootLabel } from "../../contexts/HeaderChildrenContext";
6
6
  import { useSharedContext } from "../../contexts/SharedContext";
7
- import { SidebarTrigger } from "../../shadcnui";
7
+ import { SidebarTrigger, useOptionalSidebar } from "../../shadcnui";
8
8
  import { BreadcrumbNavigation } from "./Breadcrumb";
9
9
 
10
10
  type HeaderProps = {
@@ -22,6 +22,7 @@ export function Header({ children, mobileChildren, leftContent, logo, className
22
22
  const rootLabel = useHeaderRootLabel();
23
23
  const isRootHidden = useIsBreadcrumbRootHidden();
24
24
  const isMobile = useIsMobile();
25
+ const sidebar = useOptionalSidebar();
25
26
 
26
27
  return (
27
28
  // Outer element owns the safe-area inset; the inner row keeps h-12 so the
@@ -41,7 +42,7 @@ export function Header({ children, mobileChildren, leftContent, logo, className
41
42
  >
42
43
  <div className="bg-sidebar flex h-12 w-full flex-row items-center justify-between ps-2 pe-4">
43
44
  {isMobile && logo && <div className="flex shrink-0 flex-row items-center pe-1">{logo}</div>}
44
- <SidebarTrigger aria-label="Toggle sidebar" id="sidebar-trigger" />
45
+ {sidebar && <SidebarTrigger aria-label="Toggle sidebar" id="sidebar-trigger" />}
45
46
  {leftContent}
46
47
  <div className="flex w-full flex-row items-center justify-start">
47
48
  <BreadcrumbNavigation items={breadcrumbs} rootLabel={rootLabel ?? undefined} showRoot={!isRootHidden} />
@@ -0,0 +1,60 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { beforeAll, describe, expect, it, vi } from "vitest";
3
+ import { SharedProvider } from "../../../contexts/SharedContext";
4
+ import { configureI18n } from "../../../i18n";
5
+ import { SidebarProvider } from "../../../shadcnui";
6
+ import { Header } from "../Header";
7
+
8
+ vi.mock("next/navigation", () => ({
9
+ usePathname: () => "/",
10
+ useRouter: () => ({
11
+ push: vi.fn(),
12
+ replace: vi.fn(),
13
+ prefetch: vi.fn(),
14
+ back: vi.fn(),
15
+ forward: vi.fn(),
16
+ refresh: vi.fn(),
17
+ }),
18
+ useSearchParams: () => new URLSearchParams(),
19
+ useParams: () => ({}),
20
+ redirect: vi.fn(),
21
+ notFound: vi.fn(),
22
+ }));
23
+
24
+ beforeAll(() => {
25
+ // The package Link resolves its inner component at runtime and throws if i18n
26
+ // was never configured (src/i18n/config.ts).
27
+ configureI18n({
28
+ useRouter: () => ({
29
+ push: vi.fn(),
30
+ replace: vi.fn(),
31
+ back: vi.fn(),
32
+ forward: vi.fn(),
33
+ refresh: vi.fn(),
34
+ prefetch: vi.fn(),
35
+ }),
36
+ useTranslations: () => (key: string) => key,
37
+ usePathname: () => "/",
38
+ Link: ({ href, children, ...rest }: { href: string; children: React.ReactNode; [key: string]: any }) => (
39
+ <a href={href} {...rest}>
40
+ {children}
41
+ </a>
42
+ ),
43
+ });
44
+ });
45
+
46
+ function withShared(node: React.ReactNode) {
47
+ return <SharedProvider value={{ breadcrumbs: [], title: { type: "" } }}>{node}</SharedProvider>;
48
+ }
49
+
50
+ describe("Header", () => {
51
+ it("renders the sidebar toggle inside a SidebarProvider", () => {
52
+ render(withShared(<SidebarProvider>{<Header />}</SidebarProvider>));
53
+ expect(screen.queryByRole("button", { name: "Toggle sidebar" })).not.toBeNull();
54
+ });
55
+
56
+ it("renders without a SidebarProvider and shows no sidebar toggle", () => {
57
+ expect(() => render(withShared(<Header />))).not.toThrow();
58
+ expect(screen.queryByRole("button", { name: "Toggle sidebar" })).toBeNull();
59
+ });
60
+ });
@@ -69,6 +69,12 @@ type ContentListTableProps = {
69
69
  */
70
70
  hidePagination?: boolean;
71
71
  onRowClick?: (rowData: any) => void;
72
+ /**
73
+ * Renders a full-width detail row under an expanded row. Receives the row's
74
+ * `jsonApiData`. Setting it makes every row expandable; the row's own cell
75
+ * toggles it via `row.getToggleExpandedHandler()`.
76
+ */
77
+ renderExpandedRow?: (rowData: any) => ReactNode;
72
78
  };
73
79
 
74
80
  type CellRenderer = (context: unknown) => ReactNode;
@@ -140,6 +146,14 @@ export const ContentListTable = memo(function ContentListTable(props: ContentLis
140
146
  data: tableData,
141
147
  columns: tableColumns,
142
148
  getCoreRowModel: getCoreRowModel(),
149
+ ...(props.renderExpandedRow && {
150
+ getExpandedRowModel: getExpandedRowModel(),
151
+ // Key rows by record id so an expansion stays with its record when a search or filter reorders the rows.
152
+ getRowId: (row: TableContent<any>, index: number) => row.jsonApiData?.id ?? String(index),
153
+ getRowCanExpand: () => true,
154
+ onExpandedChange: setExpanded,
155
+ state: { expanded },
156
+ }),
143
157
  ...(props.expandable && {
144
158
  getExpandedRowModel: getExpandedRowModel(),
145
159
  getSubRows: props.getSubRows,
@@ -272,39 +286,55 @@ export const ContentListTable = memo(function ContentListTable(props: ContentLis
272
286
  </TableCell>
273
287
  </TableRow>
274
288
  {group.rows.map((row) => (
275
- <TableRow
276
- key={row.id}
277
- onClick={() => onRowClick?.(row.original.jsonApiData)}
278
- className={`group ${onRowClick ? "hover:bg-muted/50 cursor-pointer" : ""}`}
279
- >
280
- {row.getVisibleCells().map((cell) => {
281
- const meta = cell.column.columnDef.meta as { className?: string } | undefined;
282
- return (
283
- <TableCell key={cell.id} className={meta?.className}>
284
- {renderCell(cell.column.columnDef.cell, cell.getContext())}
289
+ <React.Fragment key={row.id}>
290
+ <TableRow
291
+ onClick={() => onRowClick?.(row.original.jsonApiData)}
292
+ className={`group ${onRowClick ? "hover:bg-muted/50 cursor-pointer" : ""}`}
293
+ >
294
+ {row.getVisibleCells().map((cell) => {
295
+ const meta = cell.column.columnDef.meta as { className?: string } | undefined;
296
+ return (
297
+ <TableCell key={cell.id} className={meta?.className}>
298
+ {renderCell(cell.column.columnDef.cell, cell.getContext())}
299
+ </TableCell>
300
+ );
301
+ })}
302
+ </TableRow>
303
+ {props.renderExpandedRow && row.getIsExpanded() && (
304
+ <TableRow data-testid="content-list-table-expanded-row">
305
+ <TableCell colSpan={tableColumns.length} className="bg-muted/30 px-4 py-3">
306
+ {props.renderExpandedRow(row.original.jsonApiData)}
285
307
  </TableCell>
286
- );
287
- })}
288
- </TableRow>
308
+ </TableRow>
309
+ )}
310
+ </React.Fragment>
289
311
  ))}
290
312
  </React.Fragment>
291
313
  ))
292
314
  ) : (
293
315
  rowModel.rows.map((row) => (
294
- <TableRow
295
- key={row.id}
296
- onClick={() => onRowClick?.(row.original.jsonApiData)}
297
- className={`group ${onRowClick ? "hover:bg-muted/50 cursor-pointer" : ""}`}
298
- >
299
- {row.getVisibleCells().map((cell) => {
300
- const meta = cell.column.columnDef.meta as { className?: string } | undefined;
301
- return (
302
- <TableCell key={cell.id} className={meta?.className}>
303
- {renderCell(cell.column.columnDef.cell, cell.getContext())}
316
+ <React.Fragment key={row.id}>
317
+ <TableRow
318
+ onClick={() => onRowClick?.(row.original.jsonApiData)}
319
+ className={`group ${onRowClick ? "hover:bg-muted/50 cursor-pointer" : ""}`}
320
+ >
321
+ {row.getVisibleCells().map((cell) => {
322
+ const meta = cell.column.columnDef.meta as { className?: string } | undefined;
323
+ return (
324
+ <TableCell key={cell.id} className={meta?.className}>
325
+ {renderCell(cell.column.columnDef.cell, cell.getContext())}
326
+ </TableCell>
327
+ );
328
+ })}
329
+ </TableRow>
330
+ {props.renderExpandedRow && row.getIsExpanded() && (
331
+ <TableRow data-testid="content-list-table-expanded-row">
332
+ <TableCell colSpan={tableColumns.length} className="bg-muted/30 px-4 py-3">
333
+ {props.renderExpandedRow(row.original.jsonApiData)}
304
334
  </TableCell>
305
- );
306
- })}
307
- </TableRow>
335
+ </TableRow>
336
+ )}
337
+ </React.Fragment>
308
338
  ))
309
339
  )
310
340
  ) : (
@@ -1,5 +1,5 @@
1
- import { render, screen } from "@testing-library/react";
2
- import { describe, expect, it, vi } from "vitest";
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { ContentListTable } from "../ContentListTable";
4
4
 
5
5
  // `ContentListTable` imports `useTableGenerator` from the `../../hooks` barrel,
@@ -7,20 +7,40 @@ import { ContentListTable } from "../ContentListTable";
7
7
  // barrel (spreading the actual module so the other exports survive) is the same
8
8
  // approach the sibling `ContentListTable.test.tsx` in this folder uses; one
9
9
  // plain column is enough here.
10
+ // `mockColumns.expandToggle` switches the mocked name cell to a button that
11
+ // toggles row expansion (the `renderExpandedRow` suite), plus a second column so
12
+ // the detail row's colSpan is checked against more than one column.
13
+ const mockColumns = vi.hoisted(() => ({ expandToggle: false }));
14
+
10
15
  vi.mock("../../../hooks", async () => {
11
16
  const actual = await vi.importActual("../../../hooks");
12
17
  return {
13
18
  ...actual,
14
19
  useTableGenerator: vi.fn((_type: any, params: any) => ({
15
20
  data: params.data.map((item: any) => ({ ...item, jsonApiData: item })),
16
- columns: [
17
- {
18
- id: "name",
19
- header: "Name",
20
- accessorKey: "name",
21
- cell: ({ row }: any) => row.original.name,
22
- },
23
- ],
21
+ columns: mockColumns.expandToggle
22
+ ? [
23
+ {
24
+ id: "name",
25
+ header: "Name",
26
+ accessorKey: "name",
27
+ cell: ({ row }: any) => <button onClick={row.getToggleExpandedHandler()}>{row.original.name}</button>,
28
+ },
29
+ {
30
+ id: "id",
31
+ header: "Id",
32
+ accessorKey: "id",
33
+ cell: ({ row }: any) => row.original.id,
34
+ },
35
+ ]
36
+ : [
37
+ {
38
+ id: "name",
39
+ header: "Name",
40
+ accessorKey: "name",
41
+ cell: ({ row }: any) => row.original.name,
42
+ },
43
+ ],
24
44
  })),
25
45
  };
26
46
  });
@@ -60,3 +80,106 @@ describe("ContentListTable pagination footer", () => {
60
80
  expect(screen.getByText("Row one")).toBeInTheDocument();
61
81
  });
62
82
  });
83
+
84
+ describe("ContentListTable renderExpandedRow", () => {
85
+ beforeEach(() => {
86
+ mockColumns.expandToggle = true;
87
+ });
88
+
89
+ afterEach(() => {
90
+ mockColumns.expandToggle = false;
91
+ });
92
+
93
+ // The callback receives `row.original.jsonApiData`; the mocked generator sets
94
+ // that to the raw data item, so `d` is `{ id, name }`.
95
+ const renderExpandedRow = (d: any) => `Detail for ${d.name}`;
96
+
97
+ it("renders no detail row until a row is expanded", () => {
98
+ render(
99
+ <ContentListTable
100
+ data={baseData}
101
+ fields={["name", "id"]}
102
+ tableGeneratorType={mockModule}
103
+ renderExpandedRow={renderExpandedRow}
104
+ />,
105
+ );
106
+ expect(screen.queryByTestId("content-list-table-expanded-row")).toBeNull();
107
+ });
108
+
109
+ it("renders the callback output in a full-width row after toggling", () => {
110
+ render(
111
+ <ContentListTable
112
+ data={baseData}
113
+ fields={["name", "id"]}
114
+ tableGeneratorType={mockModule}
115
+ renderExpandedRow={renderExpandedRow}
116
+ />,
117
+ );
118
+ fireEvent.click(screen.getByText("Row one"));
119
+ const detailRow = screen.getByTestId("content-list-table-expanded-row");
120
+ expect(detailRow).toHaveTextContent("Detail for Row one");
121
+ const cells = detailRow.querySelectorAll("td");
122
+ expect(cells).toHaveLength(1);
123
+ expect(cells[0].getAttribute("colspan")).toBe("2");
124
+
125
+ fireEvent.click(screen.getByText("Row one"));
126
+ expect(screen.queryByTestId("content-list-table-expanded-row")).toBeNull();
127
+ });
128
+
129
+ it("keeps the expansion on the record, not on the row position, when the rows change", () => {
130
+ const twoRows = {
131
+ ...baseData,
132
+ data: [
133
+ { id: "1", name: "Row one" },
134
+ { id: "2", name: "Row two" },
135
+ ],
136
+ };
137
+ const { rerender } = render(
138
+ <ContentListTable
139
+ data={twoRows}
140
+ fields={["name", "id"]}
141
+ tableGeneratorType={mockModule}
142
+ renderExpandedRow={renderExpandedRow}
143
+ />,
144
+ );
145
+ fireEvent.click(screen.getByText("Row one"));
146
+ expect(screen.getByTestId("content-list-table-expanded-row")).toHaveTextContent("Detail for Row one");
147
+
148
+ // A search narrows the list: "Row two" now sits where "Row one" was.
149
+ rerender(
150
+ <ContentListTable
151
+ data={{ ...baseData, data: [{ id: "2", name: "Row two" }] }}
152
+ fields={["name", "id"]}
153
+ tableGeneratorType={mockModule}
154
+ renderExpandedRow={renderExpandedRow}
155
+ />,
156
+ );
157
+ expect(screen.queryByTestId("content-list-table-expanded-row")).toBeNull();
158
+ });
159
+
160
+ it("rows cannot expand without the prop", () => {
161
+ render(<ContentListTable data={baseData} fields={["name", "id"]} tableGeneratorType={mockModule} />);
162
+ fireEvent.click(screen.getByText("Row one"));
163
+ expect(screen.queryByTestId("content-list-table-expanded-row")).toBeNull();
164
+ expect(screen.queryByText("Detail for Row one")).toBeNull();
165
+ });
166
+
167
+ it("grouped rows also expand", () => {
168
+ render(
169
+ <ContentListTable
170
+ data={baseData}
171
+ fields={["name", "id"]}
172
+ tableGeneratorType={mockModule}
173
+ groupBy="name"
174
+ renderExpandedRow={renderExpandedRow}
175
+ />,
176
+ );
177
+ fireEvent.click(screen.getByRole("button", { name: "Row one" }));
178
+ const detailRow = screen.getByTestId("content-list-table-expanded-row");
179
+ expect(detailRow).toHaveTextContent("Detail for Row one");
180
+ // The detail row sits directly under the grouped data row, after the group header.
181
+ const dataRow = screen.getByRole("button", { name: "Row one" }).closest("tr")!;
182
+ expect(dataRow.nextElementSibling).toBe(detailRow);
183
+ expect(dataRow.previousElementSibling).toHaveTextContent("Row one");
184
+ });
185
+ });
@@ -25,6 +25,8 @@ const badgeVariants = cva(
25
25
  softGray: "bg-gray-500/10 text-gray-700 dark:bg-gray-500/15 dark:text-gray-400",
26
26
  softOrange: "bg-orange-500/10 text-orange-700 dark:bg-orange-500/15 dark:text-orange-400",
27
27
  softAmber: "bg-amber-500/10 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400",
28
+ softEmerald: "bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400",
29
+ softPurple: "bg-purple-500/10 text-purple-700 dark:bg-purple-500/15 dark:text-purple-400",
28
30
  blue: "bg-sky-500 text-primary-foreground [a]:hover:bg-sky-500/80",
29
31
  green: "bg-emerald-500 text-primary-foreground [a]:hover:bg-emerald-500/80",
30
32
  red: "bg-red-500 text-primary-foreground [a]:hover:bg-red-500/80",
@@ -45,6 +45,11 @@ function useSidebar() {
45
45
  return context;
46
46
  }
47
47
 
48
+ /** Like `useSidebar`, but returns null outside a `SidebarProvider` instead of throwing. */
49
+ function useOptionalSidebar(): SidebarContextProps | null {
50
+ return React.useContext(SidebarContext);
51
+ }
52
+
48
53
  function SidebarProvider({
49
54
  defaultOpen = true,
50
55
  open: openProp,
@@ -688,5 +693,6 @@ export {
688
693
  SidebarRail,
689
694
  SidebarSeparator,
690
695
  SidebarTrigger,
696
+ useOptionalSidebar,
691
697
  useSidebar,
692
698
  };