basalt-ui 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [1.14.0](https://github.com/jkrumm/basalt-ui/compare/v1.13.0...v1.14.0) (2026-08-10)
2
+
3
+
4
+ ### Features
5
+
6
+ * let the sidebar host arbitrary content in its nav scroll region ([1b1a5f3](https://github.com/jkrumm/basalt-ui/commit/1b1a5f3f50cba0cf5f89e767d438dbd02adfe03f))
7
+
1
8
  # [1.13.0](https://github.com/jkrumm/basalt-ui/compare/v1.12.0...v1.13.0) (2026-08-04)
2
9
 
3
10
 
@@ -51,6 +51,24 @@ export type AppSidebarProps = {
51
51
  * fixed, non-scrolling row. Pair with basalt-ui/commands' openSpotlight.
52
52
  */
53
53
  search?: SidebarSearchConfig;
54
+ /**
55
+ * Arbitrary content appended after `sections` inside the nav `ScrollArea` — a tree, a filter
56
+ * panel, a project list, anything a set of `SidebarItem`s can't express. Renders as the last
57
+ * child of the scrolling nav column, so a long list scrolls with the rest of the nav instead of
58
+ * fighting it for height. Pass `sections={[]}` to use this slot exclusively; the section-spacing
59
+ * rule only fires between adjacent children, so an empty `sections` produces no orphan divider or
60
+ * dead padding above it.
61
+ *
62
+ * Hidden on the collapsed desktop rail; still present in the mobile drawer, which opens at full
63
+ * width. The rail is ~56px of icon buttons with no sensible representation for arbitrary
64
+ * consumer content, so the CSS media query that drives the rail hides this slot the same way it
65
+ * hides `.childList` — never a JS check on `collapsed`, since that one value is shared by the
66
+ * rail AND the drawer (only the media query tells them apart). `SidebarSearch` gets to adapt
67
+ * itself to the rail (it takes `collapsed` and renders its own icon-only form) because the shell
68
+ * owns that control; it cannot adapt content it knows nothing about, so this slot hides instead
69
+ * of squashing it.
70
+ */
71
+ navExtra?: ReactNode;
54
72
  };
55
- export declare function AppSidebar({ brand, sections, collapsed, onToggleCollapse, onClose, renderNavLink, settingsMenuItems, footerExtra, account, search, }: AppSidebarProps): import("react").JSX.Element;
73
+ export declare function AppSidebar({ brand, sections, collapsed, onToggleCollapse, onClose, renderNavLink, settingsMenuItems, footerExtra, account, search, navExtra, }: AppSidebarProps): import("react").JSX.Element;
56
74
  //# sourceMappingURL=app-sidebar.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"app-sidebar.d.ts","sourceRoot":"","sources":["../../src/shell/app-sidebar.tsx"],"names":[],"mappings":"AA+BA,OAAO,KAAK,EAAc,SAAS,EAAE,MAAM,OAAO,CAAA;AAClD,OAAO,KAAK,EAAE,WAAW,EAAE,gBAAgB,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAEzF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AAEzD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAA;AAK3D;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,eAAe,GAAG,CAC5B,IAAI,EAAE,WAAW,EACjB,IAAI,EAAE;IACJ,MAAM,EAAE,OAAO,CAAA;IACf,KAAK,CAAC,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAA;IAChC,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CACtC,KACE,SAAS,CAAA;AAEd,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,EAAE,WAAW,CAAA;IAClB,QAAQ,EAAE,cAAc,EAAE,CAAA;IAC1B,SAAS,EAAE,OAAO,CAAA;IAClB,gBAAgB,EAAE,MAAM,IAAI,CAAA;IAC5B,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB;;;OAGG;IACH,aAAa,CAAC,EAAE,eAAe,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,gBAAgB,EAAE,CAAA;IACtC,mGAAmG;IACnG,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB;;;;OAIG;IACH,OAAO,CAAC,EAAE,kBAAkB,CAAA;IAC5B;;;OAGG;IACH,MAAM,CAAC,EAAE,mBAAmB,CAAA;CAC7B,CAAA;AAkWD,wBAAgB,UAAU,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,SAAS,EACT,gBAAgB,EAChB,OAAO,EACP,aAAa,EACb,iBAAiB,EACjB,WAAW,EACX,OAAO,EACP,MAAM,GACP,EAAE,eAAe,+BAsLjB"}
1
+ {"version":3,"file":"app-sidebar.d.ts","sourceRoot":"","sources":["../../src/shell/app-sidebar.tsx"],"names":[],"mappings":"AA+BA,OAAO,KAAK,EAAc,SAAS,EAAE,MAAM,OAAO,CAAA;AAClD,OAAO,KAAK,EAAE,WAAW,EAAE,gBAAgB,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAEzF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AAEzD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAA;AAK3D;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,eAAe,GAAG,CAC5B,IAAI,EAAE,WAAW,EACjB,IAAI,EAAE;IACJ,MAAM,EAAE,OAAO,CAAA;IACf,KAAK,CAAC,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAA;IAChC,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CACtC,KACE,SAAS,CAAA;AAEd,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,EAAE,WAAW,CAAA;IAClB,QAAQ,EAAE,cAAc,EAAE,CAAA;IAC1B,SAAS,EAAE,OAAO,CAAA;IAClB,gBAAgB,EAAE,MAAM,IAAI,CAAA;IAC5B,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB;;;OAGG;IACH,aAAa,CAAC,EAAE,eAAe,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,gBAAgB,EAAE,CAAA;IACtC,mGAAmG;IACnG,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB;;;;OAIG;IACH,OAAO,CAAC,EAAE,kBAAkB,CAAA;IAC5B;;;OAGG;IACH,MAAM,CAAC,EAAE,mBAAmB,CAAA;IAC5B;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAA;CACrB,CAAA;AAkWD,wBAAgB,UAAU,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,SAAS,EACT,gBAAgB,EAChB,OAAO,EACP,aAAa,EACb,iBAAiB,EACjB,WAAW,EACX,OAAO,EACP,MAAM,EACN,QAAQ,GACT,EAAE,eAAe,+BAyLjB"}
@@ -314,7 +314,8 @@ function AppSidebar({
314
314
  settingsMenuItems,
315
315
  footerExtra,
316
316
  account,
317
- search
317
+ search,
318
+ navExtra
318
319
  }) {
319
320
  const { step } = useBasaltSpacing();
320
321
  const [collapsedSections, setCollapsedSections] = useState(
@@ -410,33 +411,36 @@ function AppSidebar({
410
411
  scrollbarSize: 9,
411
412
  className: classes.navScroll,
412
413
  classNames: { viewport: classes.navViewport },
413
- children: /* @__PURE__ */ jsx(Stack, { gap: 0, className: classes.nav, children: sections.map((section) => {
414
- if (!section.collapsible) {
414
+ children: /* @__PURE__ */ jsxs(Stack, { gap: 0, className: classes.nav, children: [
415
+ sections.map((section) => {
416
+ if (!section.collapsible) {
417
+ return /* @__PURE__ */ jsxs("div", { children: [
418
+ /* @__PURE__ */ jsx("div", { className: classes.sectionBand, children: /* @__PURE__ */ jsx(SectionLabel, { flush: true, children: section.label }) }),
419
+ /* @__PURE__ */ jsx(Stack, { gap: 1, children: renderSectionItems(section) })
420
+ ] }, section.label);
421
+ }
422
+ const isOpen = !collapsedSections[section.label];
415
423
  return /* @__PURE__ */ jsxs("div", { children: [
416
- /* @__PURE__ */ jsx("div", { className: classes.sectionBand, children: /* @__PURE__ */ jsx(SectionLabel, { flush: true, children: section.label }) }),
417
- /* @__PURE__ */ jsx(Stack, { gap: 1, children: renderSectionItems(section) })
424
+ /* @__PURE__ */ jsxs(
425
+ UnstyledButton,
426
+ {
427
+ className: `${classes.sectionBand} ${classes.sectionHeader}`,
428
+ onClick: () => setCollapsedSections((prev) => ({
429
+ ...prev,
430
+ [section.label]: !prev[section.label]
431
+ })),
432
+ "aria-expanded": isOpen,
433
+ children: [
434
+ /* @__PURE__ */ jsx(SectionLabel, { flush: true, children: section.label }),
435
+ /* @__PURE__ */ jsx(IconChevron, { open: isOpen })
436
+ ]
437
+ }
438
+ ),
439
+ /* @__PURE__ */ jsx(Collapse, { expanded: isOpen, children: /* @__PURE__ */ jsx(Stack, { gap: 1, children: renderSectionItems(section) }) })
418
440
  ] }, section.label);
419
- }
420
- const isOpen = !collapsedSections[section.label];
421
- return /* @__PURE__ */ jsxs("div", { children: [
422
- /* @__PURE__ */ jsxs(
423
- UnstyledButton,
424
- {
425
- className: `${classes.sectionBand} ${classes.sectionHeader}`,
426
- onClick: () => setCollapsedSections((prev) => ({
427
- ...prev,
428
- [section.label]: !prev[section.label]
429
- })),
430
- "aria-expanded": isOpen,
431
- children: [
432
- /* @__PURE__ */ jsx(SectionLabel, { flush: true, children: section.label }),
433
- /* @__PURE__ */ jsx(IconChevron, { open: isOpen })
434
- ]
435
- }
436
- ),
437
- /* @__PURE__ */ jsx(Collapse, { expanded: isOpen, children: /* @__PURE__ */ jsx(Stack, { gap: 1, children: renderSectionItems(section) }) })
438
- ] }, section.label);
439
- }) })
441
+ }),
442
+ navExtra !== void 0 && navExtra !== null && /* @__PURE__ */ jsx("div", { className: classes.navExtra, children: navExtra })
443
+ ] })
440
444
  }
441
445
  ),
442
446
  account ? /* @__PURE__ */ jsxs(Stack, { gap: 0, className: classes.footer, children: [
@@ -419,4 +419,15 @@
419
419
  .root[data-collapsed] .childList {
420
420
  display: none;
421
421
  }
422
+
423
+ /* `navExtra` has no sensible icon-rail representation (arbitrary consumer content, not a nav
424
+ * row) — hide it in the rail, same treatment as `.childList`. It stays mounted (never a JS
425
+ * conditional on `collapsed`): `collapsed` is one value shared by the desktop rail AND the
426
+ * mobile drawer (`AppShell.Navbar`'s `collapsed: { mobile: !mobileOpened }` only ever un-collapses
427
+ * the mobile side), and the rail-vs-drawer split lives ENTIRELY in this media query, never in JS —
428
+ * a JS gate on `collapsed` would also drop `navExtra` from the mobile drawer, which opens at full
429
+ * width regardless of the persisted rail state. */
430
+ .root[data-collapsed] .navExtra {
431
+ display: none;
432
+ }
422
433
  }
@@ -29,6 +29,7 @@ declare const classes: {
29
29
  readonly sectionHeader: string
30
30
  readonly subnavDropdown: string
31
31
  readonly childList: string
32
+ readonly navExtra: string
32
33
  readonly ghostIcon: string
33
34
  }
34
35
  export default classes
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=app-sidebar.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-sidebar.test.d.ts","sourceRoot":"","sources":["../../src/shell/app-sidebar.test.tsx"],"names":[],"mappings":""}
@@ -73,6 +73,15 @@ export type BasaltShellProps = {
73
73
  globalActions?: ReactNode;
74
74
  /** Extra content appended to the sidebar footer, beside the settings menu (mobile close, etc.). */
75
75
  sidebarFooterExtra?: ReactNode;
76
+ /**
77
+ * Arbitrary content appended after `sections` inside the sidebar's nav scroll region (a tree, a
78
+ * filter panel, a project list, …). Hidden on the collapsed desktop rail; still present in the
79
+ * mobile drawer, which opens at full width — see `AppSidebarProps.navExtra`'s JSDoc for why.
80
+ * `AppSidebar` renders once and serves both the desktop rail and the mobile drawer (collapse
81
+ * styling is gated behind a `min-width: sm` media query, not a second render), so this slot
82
+ * reaches both.
83
+ */
84
+ sidebarNavExtra?: ReactNode;
76
85
  /** Entries appended to the sidebar settings menu. */
77
86
  settingsMenuItems?: SettingsMenuItem[];
78
87
  /**
@@ -119,5 +128,5 @@ export type BasaltShellProps = {
119
128
  export declare function NavCountBadge({ count }: {
120
129
  count: number;
121
130
  }): import("react").JSX.Element | null;
122
- export declare function BasaltShell({ brand, sections, renderNavLink, globalActions, sidebarFooterExtra, settingsMenuItems, storageKey, collapsed: collapsedProp, onCollapsedChange, renderBreadcrumbLink, account, search, children, }: BasaltShellProps): import("react").JSX.Element;
131
+ export declare function BasaltShell({ brand, sections, renderNavLink, globalActions, sidebarFooterExtra, sidebarNavExtra, settingsMenuItems, storageKey, collapsed: collapsedProp, onCollapsedChange, renderBreadcrumbLink, account, search, children, }: BasaltShellProps): import("react").JSX.Element;
123
132
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/shell/index.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/shell/index.tsx"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAElD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAIpD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAA;AAE/D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AACzD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAA;AAK3D,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AACtF,OAAO,EAAE,aAAa,EAAE,KAAK,mBAAmB,EAAE,MAAM,kBAAkB,CAAA;AAC1E,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AACtD,YAAY,EACV,gBAAgB,EAChB,cAAc,EACd,UAAU,EACV,UAAU,EACV,eAAe,EACf,kBAAkB,EAClB,oBAAoB,EACpB,kBAAkB,GACnB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EACL,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,qBAAqB,GAC3B,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,cAAc,EAAE,KAAK,sBAAsB,EAAE,MAAM,mBAAmB,CAAA;AAC/E,OAAO,EAAE,kBAAkB,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AAElF,+EAA+E;AAC/E,MAAM,MAAM,WAAW,GAAG;IACxB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,iEAAiE;IACjE,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,IAAI,EAAE,SAAS,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,UAAU,KAAK,IAAI,CAAA;IACjC,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,wFAAwF;IACxF,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAA;CACzB,CAAA;AAED,uFAAuF;AACvF,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,WAAW,EAAE,CAAA;IACpB,2CAA2C;IAC3C,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,yEAAyE;IACzE,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,kDAAkD;IAClD,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B,uEAAuE;IACvE,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB,CAAA;AAED,gEAAgE;AAChE,MAAM,MAAM,WAAW,GAAG;IACxB,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,6CAA6C;IAC7C,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,2CAA2C;IAC3C,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,6DAA6D;AAC7D,MAAM,MAAM,gBAAgB,GAAG;IAC7B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,UAAU,KAAK,IAAI,CAAA;CAClC,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,6CAA6C;IAC7C,KAAK,EAAE,WAAW,CAAA;IAClB,6FAA6F;IAC7F,QAAQ,EAAE,cAAc,EAAE,CAAA;IAC1B;;;;OAIG;IACH,aAAa,CAAC,EAAE,eAAe,CAAA;IAC/B,4EAA4E;IAC5E,aAAa,CAAC,EAAE,SAAS,CAAA;IACzB,mGAAmG;IACnG,kBAAkB,CAAC,EAAE,SAAS,CAAA;IAC9B;;;;;;;OAOG;IACH,eAAe,CAAC,EAAE,SAAS,CAAA;IAC3B,qDAAqD;IACrD,iBAAiB,CAAC,EAAE,gBAAgB,EAAE,CAAA;IACtC;;;OAGG;IACH,OAAO,CAAC,EAAE,kBAAkB,CAAA;IAC5B;;;OAGG;IACH,MAAM,CAAC,EAAE,mBAAmB,CAAA;IAC5B,kGAAkG;IAClG,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAA;IAChD;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,sBAAsB,GAAG,SAAS,CAAA;IACzD,oBAAoB;IACpB,QAAQ,CAAC,EAAE,SAAS,CAAA;CACrB,CAAA;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,EAAE,KAAK,EAAE,EAAE;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,sCAsBzD;AA2ED,wBAAgB,WAAW,CAAC,EAC1B,KAAK,EACL,QAAQ,EACR,aAAa,EACb,aAAa,EACb,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,UAAuC,EACvC,SAAS,EAAE,aAAa,EACxB,iBAAiB,EACjB,oBAAoB,EACpB,OAAO,EACP,MAAM,EACN,QAAQ,GACT,EAAE,gBAAgB,+BAkFlB"}
@@ -95,6 +95,7 @@ function BasaltShell({
95
95
  renderNavLink,
96
96
  globalActions,
97
97
  sidebarFooterExtra,
98
+ sidebarNavExtra,
98
99
  settingsMenuItems,
99
100
  storageKey = "basalt-sidebar-collapsed",
100
101
  collapsed: collapsedProp,
@@ -153,6 +154,7 @@ function BasaltShell({
153
154
  onToggleCollapse: toggleCollapse,
154
155
  onClose: closeMobile,
155
156
  footerExtra: sidebarFooterExtra,
157
+ navExtra: sidebarNavExtra,
156
158
  ...renderNavLink !== void 0 && { renderNavLink },
157
159
  ...settingsMenuItems !== void 0 && { settingsMenuItems },
158
160
  ...account !== void 0 && { account },
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../../src/shell/index.test.tsx"],"names":[],"mappings":""}
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # basalt-ui v1.13.0
1
+ # basalt-ui v1.14.0
2
2
  # Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets
3
3
  #
4
4
  # basalt-ui is an opinionated framework for Mantine-based React apps.
@@ -19,7 +19,7 @@
19
19
  # ./tokens.css subpath. Guide: docs/FRAMEWORK-FREE.md in the repo.
20
20
 
21
21
  Package: basalt-ui
22
- Version: 1.13.0
22
+ Version: 1.14.0
23
23
 
24
24
  ## basalt-ui
25
25
  Import: import { ... } from 'basalt-ui'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basalt-ui",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets",
5
5
  "keywords": [
6
6
  "charts",
@@ -419,4 +419,15 @@
419
419
  .root[data-collapsed] .childList {
420
420
  display: none;
421
421
  }
422
+
423
+ /* `navExtra` has no sensible icon-rail representation (arbitrary consumer content, not a nav
424
+ * row) — hide it in the rail, same treatment as `.childList`. It stays mounted (never a JS
425
+ * conditional on `collapsed`): `collapsed` is one value shared by the desktop rail AND the
426
+ * mobile drawer (`AppShell.Navbar`'s `collapsed: { mobile: !mobileOpened }` only ever un-collapses
427
+ * the mobile side), and the rail-vs-drawer split lives ENTIRELY in this media query, never in JS —
428
+ * a JS gate on `collapsed` would also drop `navExtra` from the mobile drawer, which opens at full
429
+ * width regardless of the persisted rail state. */
430
+ .root[data-collapsed] .navExtra {
431
+ display: none;
432
+ }
422
433
  }
@@ -29,6 +29,7 @@ declare const classes: {
29
29
  readonly sectionHeader: string
30
30
  readonly subnavDropdown: string
31
31
  readonly childList: string
32
+ readonly navExtra: string
32
33
  readonly ghostIcon: string
33
34
  }
34
35
  export default classes
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `navExtra` — the seam that lets a consumer put arbitrary content (a tree, a filter panel,
3
+ * a project list) inside the sidebar's nav scroll region instead of rendering a second column
4
+ * beside the shell. Four invariants:
5
+ *
6
+ * 1. It renders INSIDE the nav `ScrollArea`'s content (Mantine's own `.mantine-ScrollArea-content`
7
+ * class, stable across runs — CSS-module class hashes are not available under `bun test`, see
8
+ * `stat-card.test.tsx`/`use-basalt-spacing.test.tsx` for the same constraint elsewhere), as the
9
+ * LAST child, so a long tree scrolls with the nav instead of fighting it for height.
10
+ * 2. It STAYS MOUNTED when `collapsed` — `collapsed` is one value shared by the desktop rail AND
11
+ * the mobile drawer (`AppShell.Navbar`'s `collapsed: { mobile: !mobileOpened }` never touches
12
+ * the desktop side), and the rail-vs-drawer split lives entirely in a `min-width: sm` CSS media
13
+ * query, never in JS. A JS gate on `collapsed` would silently drop `navExtra` from the mobile
14
+ * drawer too, which opens at full width regardless of the persisted rail state. jsdom does not
15
+ * evaluate the media query, so this asserts presence in the DOM, not computed visibility — the
16
+ * CSS half (`.root[data-collapsed] .navExtra { display: none }`) is exercised by the guard rules
17
+ * it shares with `.childList`/`.sectionBand`, not by this test.
18
+ * 3. `sections={[]}` plus only `navExtra` renders cleanly: the nav Stack holds exactly the
19
+ * extra content, no orphan section wrapper.
20
+ * 4. Omitting the prop reproduces today's DOM exactly — the nav Stack's child count matches
21
+ * `sections.length`, with no stray wrapper appended for the (absent) slot. This is the
22
+ * regression guard for every existing consumer.
23
+ */
24
+ import { MantineProvider } from '@mantine/core'
25
+ import { render, screen } from '@testing-library/react'
26
+ import { describe, expect, test } from 'bun:test'
27
+ import type { ReactNode } from 'react'
28
+ import { AppSidebar } from './app-sidebar'
29
+ import type { AppSidebarProps } from './app-sidebar'
30
+
31
+ const BRAND = { name: 'Argo' }
32
+ const ONE_SECTION: AppSidebarProps['sections'] = [
33
+ { label: 'Main', items: [{ key: 'home', label: 'Home', icon: null }] },
34
+ ]
35
+
36
+ function renderSidebar(props: Partial<AppSidebarProps> & { navExtra?: ReactNode }) {
37
+ return render(
38
+ <MantineProvider>
39
+ <AppSidebar
40
+ brand={BRAND}
41
+ sections={ONE_SECTION}
42
+ collapsed={false}
43
+ onToggleCollapse={() => {}}
44
+ onClose={() => {}}
45
+ {...props}
46
+ />
47
+ </MantineProvider>,
48
+ )
49
+ }
50
+
51
+ /** The nav `ScrollArea`'s content Stack — the direct parent of `sections` and `navExtra`. */
52
+ function navStack(container: HTMLElement): Element {
53
+ const stack = container.querySelector('.mantine-ScrollArea-content > .mantine-Stack-root')
54
+ if (!stack) throw new Error('expected the nav ScrollArea content Stack to render')
55
+ return stack
56
+ }
57
+
58
+ describe('navExtra', () => {
59
+ test('renders inside the nav scroll region, as the last child, when expanded', () => {
60
+ const { container } = renderSidebar({
61
+ navExtra: <div data-testid="nav-extra">Extra</div>,
62
+ })
63
+ const extra = screen.getByTestId('nav-extra')
64
+ const stack = navStack(container)
65
+ expect(stack.contains(extra)).toBe(true)
66
+ expect(stack.lastElementChild?.contains(extra)).toBe(true)
67
+ })
68
+
69
+ test('stays mounted in the DOM when collapsed — hiding it is CSS-only, not a JS gate', () => {
70
+ const { container } = renderSidebar({
71
+ collapsed: true,
72
+ navExtra: <div data-testid="nav-extra">Extra</div>,
73
+ })
74
+ const extra = screen.getByTestId('nav-extra')
75
+ const stack = navStack(container)
76
+ // Presence, not visibility — jsdom never evaluates the `min-width: sm` media query that hides
77
+ // this in the rail. A regression here (reintroducing `!collapsed &&` in app-sidebar.tsx) would
78
+ // fail this assertion instead of only breaking silently in a real mobile-drawer browser.
79
+ expect(stack.contains(extra)).toBe(true)
80
+ })
81
+
82
+ test('sections={[]} plus only navExtra renders cleanly — no orphan section wrapper', () => {
83
+ const { container } = renderSidebar({
84
+ sections: [],
85
+ navExtra: <div data-testid="nav-extra">Extra</div>,
86
+ })
87
+ const stack = navStack(container)
88
+ expect(screen.getByTestId('nav-extra')).toBeTruthy()
89
+ expect(stack.children.length).toBe(1)
90
+ })
91
+
92
+ test("omitting the prop reproduces today's DOM — no stray wrapper for the unused slot", () => {
93
+ const { container } = renderSidebar({})
94
+ expect(screen.queryByTestId('nav-extra')).toBeNull()
95
+ const stack = navStack(container)
96
+ expect(stack.children.length).toBe(ONE_SECTION.length)
97
+ })
98
+ })
@@ -92,6 +92,24 @@ export type AppSidebarProps = {
92
92
  * fixed, non-scrolling row. Pair with basalt-ui/commands' openSpotlight.
93
93
  */
94
94
  search?: SidebarSearchConfig
95
+ /**
96
+ * Arbitrary content appended after `sections` inside the nav `ScrollArea` — a tree, a filter
97
+ * panel, a project list, anything a set of `SidebarItem`s can't express. Renders as the last
98
+ * child of the scrolling nav column, so a long list scrolls with the rest of the nav instead of
99
+ * fighting it for height. Pass `sections={[]}` to use this slot exclusively; the section-spacing
100
+ * rule only fires between adjacent children, so an empty `sections` produces no orphan divider or
101
+ * dead padding above it.
102
+ *
103
+ * Hidden on the collapsed desktop rail; still present in the mobile drawer, which opens at full
104
+ * width. The rail is ~56px of icon buttons with no sensible representation for arbitrary
105
+ * consumer content, so the CSS media query that drives the rail hides this slot the same way it
106
+ * hides `.childList` — never a JS check on `collapsed`, since that one value is shared by the
107
+ * rail AND the drawer (only the media query tells them apart). `SidebarSearch` gets to adapt
108
+ * itself to the rail (it takes `collapsed` and renders its own icon-only form) because the shell
109
+ * owns that control; it cannot adapt content it knows nothing about, so this slot hides instead
110
+ * of squashing it.
111
+ */
112
+ navExtra?: ReactNode
95
113
  }
96
114
 
97
115
  /** Inline collapse/expand chevrons — keeps the shell icon-dependency-free. */
@@ -457,6 +475,7 @@ export function AppSidebar({
457
475
  footerExtra,
458
476
  account,
459
477
  search,
478
+ navExtra,
460
479
  }: AppSidebarProps) {
461
480
  // Density-tracking Menu dropdown width (`SPACE_STEP.sidebarSettingsMenuWidth`) — read the ACTIVE
462
481
  // resolved level, not the frozen level-0 constant (see that constant's own doc in
@@ -622,6 +641,9 @@ export function AppSidebar({
622
641
  </div>
623
642
  )
624
643
  })}
644
+ {navExtra !== undefined && navExtra !== null && (
645
+ <div className={classes.navExtra}>{navExtra}</div>
646
+ )}
625
647
  </Stack>
626
648
  </ScrollArea>
627
649
 
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `BasaltShell`'s `sidebarNavExtra` forwarding path — `AppSidebar` (exercised directly in
3
+ * `app-sidebar.test.tsx`) is not the only place this prop can break. `BasaltShell` destructures
4
+ * `sidebarNavExtra` and hands it to `AppSidebar` as `navExtra`; a typo'd destructure key, a dropped
5
+ * prop, or a `sidebarFooterExtra`/`sidebarNavExtra` mix-up in that hand-off would ship silently
6
+ * without a test that renders `BasaltShell` itself.
7
+ */
8
+ import { MantineProvider } from '@mantine/core'
9
+ import { render, screen } from '@testing-library/react'
10
+ import { describe, expect, test } from 'bun:test'
11
+ import { BasaltShell } from './index'
12
+ import type { SidebarSection } from './index'
13
+
14
+ const BRAND = { name: 'Argo' }
15
+ const ONE_SECTION: SidebarSection[] = [
16
+ { label: 'Main', items: [{ key: 'home', label: 'Home', icon: null }] },
17
+ ]
18
+
19
+ describe('BasaltShell sidebarNavExtra', () => {
20
+ test("reaches AppSidebar's nav scroll region through the BasaltShell -> AppSidebar hand-off", () => {
21
+ const { container } = render(
22
+ <MantineProvider>
23
+ <BasaltShell
24
+ brand={BRAND}
25
+ sections={ONE_SECTION}
26
+ sidebarNavExtra={<div data-testid="nav-extra">Extra</div>}
27
+ />
28
+ </MantineProvider>,
29
+ )
30
+ const extra = screen.getByTestId('nav-extra')
31
+ const stack = container.querySelector('.mantine-ScrollArea-content > .mantine-Stack-root')
32
+ expect(stack).not.toBeNull()
33
+ expect(stack?.contains(extra)).toBe(true)
34
+ })
35
+ })
@@ -115,6 +115,15 @@ export type BasaltShellProps = {
115
115
  globalActions?: ReactNode
116
116
  /** Extra content appended to the sidebar footer, beside the settings menu (mobile close, etc.). */
117
117
  sidebarFooterExtra?: ReactNode
118
+ /**
119
+ * Arbitrary content appended after `sections` inside the sidebar's nav scroll region (a tree, a
120
+ * filter panel, a project list, …). Hidden on the collapsed desktop rail; still present in the
121
+ * mobile drawer, which opens at full width — see `AppSidebarProps.navExtra`'s JSDoc for why.
122
+ * `AppSidebar` renders once and serves both the desktop rail and the mobile drawer (collapse
123
+ * styling is gated behind a `min-width: sm` media query, not a second render), so this slot
124
+ * reaches both.
125
+ */
126
+ sidebarNavExtra?: ReactNode
118
127
  /** Entries appended to the sidebar settings menu. */
119
128
  settingsMenuItems?: SettingsMenuItem[]
120
129
  /**
@@ -262,6 +271,7 @@ export function BasaltShell({
262
271
  renderNavLink,
263
272
  globalActions,
264
273
  sidebarFooterExtra,
274
+ sidebarNavExtra,
265
275
  settingsMenuItems,
266
276
  storageKey = 'basalt-sidebar-collapsed',
267
277
  collapsed: collapsedProp,
@@ -332,6 +342,7 @@ export function BasaltShell({
332
342
  onToggleCollapse={toggleCollapse}
333
343
  onClose={closeMobile}
334
344
  footerExtra={sidebarFooterExtra}
345
+ navExtra={sidebarNavExtra}
335
346
  {...(renderNavLink !== undefined && { renderNavLink })}
336
347
  {...(settingsMenuItems !== undefined && { settingsMenuItems })}
337
348
  {...(account !== undefined && { account })}