basalt-ui 1.17.0 → 1.19.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/AGENTS.md +1 -1
- package/CHANGELOG.md +14 -0
- package/agent/rules/basalt-charts.md +12 -0
- package/agent/rules/basalt-mantine.md +71 -6
- package/agent/rules/basalt-router.md +129 -30
- package/agent/rules/basalt-tokens.md +2 -1
- package/agent/skills/basalt-design/SKILL.md +2 -2
- package/dist/charts/kinds/DualPanel.d.ts +7 -0
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +10 -3
- package/dist/charts/primitives/CartesianChart.d.ts +17 -0
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +5 -2
- package/dist/charts/primitives/ChartTooltip.d.ts +6 -1
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +2 -1
- package/dist/charts/series.d.ts +5 -0
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/nav/types.d.ts +110 -0
- package/dist/nav/types.d.ts.map +1 -0
- package/dist/nav/types.js +0 -0
- package/dist/router-tanstack/index.d.ts +9 -2
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/index.js +7 -0
- package/dist/router-tanstack/nav.d.ts +163 -0
- package/dist/router-tanstack/nav.d.ts.map +1 -0
- package/dist/router-tanstack/nav.js +38 -0
- package/dist/router-tanstack/nav.test.d.ts +2 -0
- package/dist/router-tanstack/nav.test.d.ts.map +1 -0
- package/dist/router-tanstack/search-param-store.d.ts +19 -9
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/use-nav.d.ts +30 -0
- package/dist/router-tanstack/use-nav.d.ts.map +1 -0
- package/dist/router-tanstack/use-nav.js +71 -0
- package/dist/shell/app-breadcrumbs.d.ts +7 -9
- package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
- package/dist/shell/app-breadcrumbs.js +8 -4
- package/dist/shell/app-mobile-nav.d.ts +23 -24
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +290 -68
- package/dist/shell/app-mobile-nav.module.css +166 -11
- package/dist/shell/app-mobile-nav.module.css.d.ts +11 -2
- package/dist/shell/app-mobile-nav.test.d.ts +2 -0
- package/dist/shell/app-mobile-nav.test.d.ts.map +1 -0
- package/dist/shell/app-sidebar.d.ts +3 -28
- package/dist/shell/app-sidebar.d.ts.map +1 -1
- package/dist/shell/app-sidebar.js +40 -174
- package/dist/shell/app-sidebar.module.css +3 -2
- package/dist/shell/app-sidebar.module.css.d.ts +0 -2
- package/dist/shell/index.d.ts +22 -65
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +38 -76
- package/dist/shell/mobile-nav-model.d.ts +41 -0
- package/dist/shell/mobile-nav-model.d.ts.map +1 -0
- package/dist/shell/mobile-nav-model.js +244 -0
- package/dist/shell/mobile-nav-model.test.d.ts +2 -0
- package/dist/shell/mobile-nav-model.test.d.ts.map +1 -0
- package/dist/shell/nav-count-badge.d.ts +11 -0
- package/dist/shell/nav-count-badge.d.ts.map +1 -0
- package/dist/shell/nav-count-badge.js +29 -0
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +8 -7
- package/dist/theme/nav-link.module.css +4 -2
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +2 -0
- package/dist/tokens/palette.d.ts +15 -0
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +13 -0
- package/dist/tokens.css +2 -0
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/charts/kinds/DualPanel.test.tsx +81 -1
- package/src/charts/kinds/DualPanel.tsx +26 -4
- package/src/charts/primitives/CartesianChart.test.tsx +173 -1
- package/src/charts/primitives/CartesianChart.tsx +29 -5
- package/src/charts/primitives/ChartTooltip.test.tsx +22 -1
- package/src/charts/primitives/ChartTooltip.tsx +7 -1
- package/src/charts/series.ts +5 -0
- package/src/index.ts +15 -5
- package/src/nav/types.ts +115 -0
- package/src/router-tanstack/index.ts +21 -2
- package/src/router-tanstack/nav.test.ts +111 -0
- package/src/router-tanstack/nav.ts +205 -0
- package/src/router-tanstack/search-param-store.ts +19 -9
- package/src/router-tanstack/use-nav.ts +146 -0
- package/src/shell/app-breadcrumbs.tsx +21 -19
- package/src/shell/app-mobile-nav.module.css +166 -11
- package/src/shell/app-mobile-nav.module.css.d.ts +11 -2
- package/src/shell/app-mobile-nav.test.tsx +369 -0
- package/src/shell/app-mobile-nav.tsx +436 -102
- package/src/shell/app-sidebar.module.css +3 -2
- package/src/shell/app-sidebar.module.css.d.ts +0 -2
- package/src/shell/app-sidebar.test.tsx +6 -10
- package/src/shell/app-sidebar.tsx +49 -206
- package/src/shell/index.test.tsx +62 -7
- package/src/shell/index.tsx +105 -164
- package/src/shell/mobile-nav-model.test.ts +296 -0
- package/src/shell/mobile-nav-model.ts +389 -0
- package/src/shell/nav-count-badge.tsx +39 -0
- package/src/surfaces.ts +1 -1
- package/src/theme/density-relations.test.ts +19 -1
- package/src/theme/index.ts +8 -7
- package/src/theme/nav-link.module.css +4 -2
- package/src/theme/spacing.test.ts +11 -0
- package/src/tokens/density.test.ts +2 -0
- package/src/tokens/index.ts +8 -5
- package/src/tokens/palette.ts +16 -0
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
* optional version label).
|
|
5
5
|
*
|
|
6
6
|
* Router-agnostic by design: active detection, typed navigation and the collapse store stay in the
|
|
7
|
-
* consumer (or in `BasaltShell`), which feeds resolved `sections` + `collapsed`/`onToggleCollapse
|
|
8
|
-
*
|
|
9
|
-
* with `item.onClick` — never a router
|
|
7
|
+
* consumer (or in `BasaltShell`), which feeds resolved `sections` + `collapsed`/`onToggleCollapse`
|
|
8
|
+
* here. Each destination renders through its own `SidebarItem.Anchor` when supplied (the
|
|
9
|
+
* consumer's router `Link`), else a plain `<a href>` with `item.onClick` — never a router
|
|
10
|
+
* primitive. Active state arrives as `item.active`.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* Grounded
|
|
12
|
+
* DESKTOP ONLY. There is no mobile sidebar drawer any more: below the `sm` breakpoint the navbar
|
|
13
|
+
* is permanently collapsed and the bottom bar (`app-mobile-nav.tsx`) is the whole nav. The rail
|
|
14
|
+
* styling is still gated behind a `min-width: sm` media query, and the collapse chevron is
|
|
15
|
+
* `visibleFrom="sm"`. Grounded in argo `apps/dashboard/src/components/app-shell/app-sidebar.tsx`.
|
|
15
16
|
*/
|
|
16
17
|
import {
|
|
17
18
|
ActionIcon,
|
|
@@ -28,9 +29,11 @@ import {
|
|
|
28
29
|
UnstyledButton,
|
|
29
30
|
} from '@mantine/core'
|
|
30
31
|
import { useDisclosure } from '@mantine/hooks'
|
|
31
|
-
import { useRef, useState } from 'react'
|
|
32
|
-
import type {
|
|
33
|
-
import type { BrandConfig, SettingsMenuItem
|
|
32
|
+
import { useEffect, useRef, useState } from 'react'
|
|
33
|
+
import type { ReactNode } from 'react'
|
|
34
|
+
import type { BrandConfig, SettingsMenuItem } from './index'
|
|
35
|
+
import type { SidebarItem, SidebarSection } from '../nav/types'
|
|
36
|
+
import { NavCountBadge } from './nav-count-badge'
|
|
34
37
|
import { SidebarAccount } from './app-sidebar-account'
|
|
35
38
|
import type { BasaltAccountProps } from './account-types'
|
|
36
39
|
import { SidebarSearch } from './sidebar-search'
|
|
@@ -39,39 +42,11 @@ import { VX } from '../tokens'
|
|
|
39
42
|
import { useBasaltSpacing } from '../theme'
|
|
40
43
|
import classes from './app-sidebar.module.css'
|
|
41
44
|
|
|
42
|
-
/**
|
|
43
|
-
* Renders a single nav item. The consumer's router `Link` lives here; `active` is precomputed.
|
|
44
|
-
*
|
|
45
|
-
* `opts.close` (item 20) is the mobile "More" drawer's close handle — invoke-safe when absent, a
|
|
46
|
-
* no-op on desktop. Call it from the rendered link's own `onClick` (e.g. before/after navigating)
|
|
47
|
-
* so tapping a nav row in the mobile drawer also closes it; the built-in fallback `<a>` path does
|
|
48
|
-
* this automatically.
|
|
49
|
-
*
|
|
50
|
-
* `opts.navLinkClassName` (item 18) is the internal `.link` CSS-module class the fallback `<a>`
|
|
51
|
-
* path uses for inactive/hover treatment — present whenever `AppSidebar` renders this callback.
|
|
52
|
-
* Apply it to the rendered link's `className` to match that styling exactly instead of
|
|
53
|
-
* re-deriving it.
|
|
54
|
-
*/
|
|
55
|
-
export type NavLinkRenderer = (
|
|
56
|
-
item: SidebarItem,
|
|
57
|
-
opts: {
|
|
58
|
-
active: boolean
|
|
59
|
-
close?: (() => void) | undefined
|
|
60
|
-
navLinkClassName?: string | undefined
|
|
61
|
-
},
|
|
62
|
-
) => ReactNode
|
|
63
|
-
|
|
64
45
|
export type AppSidebarProps = {
|
|
65
46
|
brand: BrandConfig
|
|
66
47
|
sections: SidebarSection[]
|
|
67
48
|
collapsed: boolean
|
|
68
49
|
onToggleCollapse: () => void
|
|
69
|
-
onClose: () => void
|
|
70
|
-
/**
|
|
71
|
-
* Optional consumer link renderer (e.g. a router `<Link>`). Receives the precomputed `active`
|
|
72
|
-
* flag. When omitted, items fall back to a plain `<a href>` + `item.onClick`.
|
|
73
|
-
*/
|
|
74
|
-
renderNavLink?: NavLinkRenderer
|
|
75
50
|
/**
|
|
76
51
|
* Footer settings-menu entries (theme switcher, devtools, …) — supplied by the consumer.
|
|
77
52
|
* The pinned footer "Settings" row renders ONLY when this is a non-empty list — apps that put
|
|
@@ -79,8 +54,6 @@ export type AppSidebarProps = {
|
|
|
79
54
|
* `brand.version` label renders inside this menu, so it also only shows when the row does.
|
|
80
55
|
*/
|
|
81
56
|
settingsMenuItems?: SettingsMenuItem[]
|
|
82
|
-
/** Extra content appended to the sidebar footer, beside the settings menu (mobile close, etc.). */
|
|
83
|
-
footerExtra?: ReactNode
|
|
84
57
|
/**
|
|
85
58
|
* Optional account row rendered below the settings menu in the sidebar footer (see
|
|
86
59
|
* `SidebarAccount` / `BasaltAccountProps`), separated by its own top hairline. Omitting it
|
|
@@ -170,25 +143,6 @@ function IconGear() {
|
|
|
170
143
|
)
|
|
171
144
|
}
|
|
172
145
|
|
|
173
|
-
function IconClose() {
|
|
174
|
-
return (
|
|
175
|
-
<svg
|
|
176
|
-
width={18}
|
|
177
|
-
height={18}
|
|
178
|
-
viewBox="0 0 24 24"
|
|
179
|
-
fill="none"
|
|
180
|
-
stroke="currentColor"
|
|
181
|
-
strokeWidth={2}
|
|
182
|
-
strokeLinecap="round"
|
|
183
|
-
strokeLinejoin="round"
|
|
184
|
-
aria-hidden
|
|
185
|
-
>
|
|
186
|
-
<path d="M18 6l-12 12" />
|
|
187
|
-
<path d="M6 6l12 12" />
|
|
188
|
-
</svg>
|
|
189
|
-
)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
146
|
const HOVER_OPEN_DELAY = 150
|
|
193
147
|
const HOVER_CLOSE_DELAY = 200
|
|
194
148
|
|
|
@@ -199,39 +153,27 @@ function hasActiveDescendant(item: SidebarItem): boolean {
|
|
|
199
153
|
}
|
|
200
154
|
|
|
201
155
|
/**
|
|
202
|
-
* Renders a nav link body without the Tooltip/Box wrapper.
|
|
203
|
-
*
|
|
204
|
-
* the
|
|
156
|
+
* Renders a nav link body without the Tooltip/Box wrapper.
|
|
157
|
+
*
|
|
158
|
+
* `item.Anchor` is the router seam and the ONLY one: basalt owns every pixel of the row (the
|
|
159
|
+
* `.link` class, the label, the icon, the badge) and merely hosts the consumer's `Link` as the
|
|
160
|
+
* element. That is what replaced the old render callback — a callback had to re-derive basalt's
|
|
161
|
+
* own styling to look right, and every consumer got it subtly wrong.
|
|
205
162
|
*/
|
|
206
|
-
function NavLinkBody({
|
|
207
|
-
item
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
close?: (() => void) | undefined
|
|
216
|
-
}) {
|
|
217
|
-
if (renderNavLink) {
|
|
218
|
-
return <>{renderNavLink(item, { active, close, navLinkClassName: classes.link })}</>
|
|
163
|
+
function NavLinkBody({ item, active }: { item: SidebarItem; active: boolean }) {
|
|
164
|
+
const Anchor = item.Anchor
|
|
165
|
+
const shared = {
|
|
166
|
+
classNames: { root: classes.link },
|
|
167
|
+
label: item.label,
|
|
168
|
+
leftSection: item.icon,
|
|
169
|
+
rightSection: item.badge ?? (item.count ? <NavCountBadge count={item.count} /> : undefined),
|
|
170
|
+
active,
|
|
171
|
+
...(item.onClick !== undefined && { onClick: item.onClick }),
|
|
219
172
|
}
|
|
220
|
-
|
|
221
|
-
<NavLink
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
label={item.label}
|
|
225
|
-
leftSection={item.icon}
|
|
226
|
-
rightSection={item.badge}
|
|
227
|
-
active={active}
|
|
228
|
-
{...(item.href !== undefined && { href: item.href })}
|
|
229
|
-
onClick={(e: MouseEvent) => {
|
|
230
|
-
item.onClick?.(e)
|
|
231
|
-
close?.()
|
|
232
|
-
}}
|
|
233
|
-
/>
|
|
234
|
-
)
|
|
173
|
+
if (Anchor) {
|
|
174
|
+
return <NavLink component={Anchor} {...shared} />
|
|
175
|
+
}
|
|
176
|
+
return <NavLink component="a" {...(item.href !== undefined && { href: item.href })} {...shared} />
|
|
235
177
|
}
|
|
236
178
|
|
|
237
179
|
/**
|
|
@@ -249,21 +191,14 @@ function SectionLabel({ children, flush }: { children: ReactNode; flush?: boolea
|
|
|
249
191
|
)
|
|
250
192
|
}
|
|
251
193
|
|
|
252
|
-
function NavItemRow({
|
|
253
|
-
item,
|
|
254
|
-
collapsed,
|
|
255
|
-
renderNavLink,
|
|
256
|
-
closeMobile,
|
|
257
|
-
}: {
|
|
258
|
-
item: SidebarItem
|
|
259
|
-
collapsed: boolean
|
|
260
|
-
renderNavLink?: NavLinkRenderer | undefined
|
|
261
|
-
/** Closes the mobile "More" drawer (item 20) — forwarded to `NavLinkBody` as `close`. */
|
|
262
|
-
closeMobile?: (() => void) | undefined
|
|
263
|
-
}) {
|
|
194
|
+
function NavItemRow({ item, collapsed }: { item: SidebarItem; collapsed: boolean }) {
|
|
264
195
|
const [opened, { open, close }] = useDisclosure(false)
|
|
265
196
|
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
266
197
|
|
|
198
|
+
// The hover-popover timers outlive the row when a nav re-render unmounts it mid-delay, and a
|
|
199
|
+
// fired `open()` on an unmounted component is a React warning plus a leaked timer.
|
|
200
|
+
useEffect(() => () => clearTimeout(timer.current), [])
|
|
201
|
+
|
|
267
202
|
const scheduleOpen = () => {
|
|
268
203
|
clearTimeout(timer.current)
|
|
269
204
|
timer.current = setTimeout(() => open(), HOVER_OPEN_DELAY)
|
|
@@ -297,12 +232,7 @@ function NavItemRow({
|
|
|
297
232
|
return (
|
|
298
233
|
<Tooltip key={item.key} label={item.label} position="right" withArrow disabled={!collapsed}>
|
|
299
234
|
<Box className={classes.navItem}>
|
|
300
|
-
<NavLinkBody
|
|
301
|
-
item={item}
|
|
302
|
-
active={active}
|
|
303
|
-
renderNavLink={renderNavLink}
|
|
304
|
-
close={closeMobile}
|
|
305
|
-
/>
|
|
235
|
+
<NavLinkBody item={item} active={active} />
|
|
306
236
|
</Box>
|
|
307
237
|
</Tooltip>
|
|
308
238
|
)
|
|
@@ -317,12 +247,7 @@ function NavItemRow({
|
|
|
317
247
|
<Box key={item.key}>
|
|
318
248
|
<Tooltip label={item.label} position="right" withArrow disabled={!collapsed}>
|
|
319
249
|
<Box className={classes.navItem}>
|
|
320
|
-
<NavLinkBody
|
|
321
|
-
item={item}
|
|
322
|
-
active={active}
|
|
323
|
-
renderNavLink={renderNavLink}
|
|
324
|
-
close={closeMobile}
|
|
325
|
-
/>
|
|
250
|
+
<NavLinkBody item={item} active={active} />
|
|
326
251
|
</Box>
|
|
327
252
|
</Tooltip>
|
|
328
253
|
<Stack gap={0} className={classes.childList}>
|
|
@@ -344,12 +269,7 @@ function NavItemRow({
|
|
|
344
269
|
const childActive = Boolean(child.active)
|
|
345
270
|
return (
|
|
346
271
|
<Box key={child.key}>
|
|
347
|
-
<NavLinkBody
|
|
348
|
-
item={child}
|
|
349
|
-
active={childActive}
|
|
350
|
-
renderNavLink={renderNavLink}
|
|
351
|
-
close={closeMobile}
|
|
352
|
-
/>
|
|
272
|
+
<NavLinkBody item={child} active={childActive} />
|
|
353
273
|
</Box>
|
|
354
274
|
)
|
|
355
275
|
})}
|
|
@@ -379,12 +299,7 @@ function NavItemRow({
|
|
|
379
299
|
onFocus={scheduleOpen}
|
|
380
300
|
onBlur={scheduleClose}
|
|
381
301
|
>
|
|
382
|
-
<NavLinkBody
|
|
383
|
-
item={item}
|
|
384
|
-
active={active}
|
|
385
|
-
renderNavLink={renderNavLink}
|
|
386
|
-
close={closeMobile}
|
|
387
|
-
/>
|
|
302
|
+
<NavLinkBody item={item} active={active} />
|
|
388
303
|
</Box>
|
|
389
304
|
</Tooltip>
|
|
390
305
|
</Popover.Target>
|
|
@@ -416,50 +331,15 @@ function NavItemRow({
|
|
|
416
331
|
const childActive = Boolean(child.active)
|
|
417
332
|
return (
|
|
418
333
|
<Box key={child.key}>
|
|
419
|
-
<NavLinkBody
|
|
420
|
-
item={child}
|
|
421
|
-
active={childActive}
|
|
422
|
-
renderNavLink={renderNavLink}
|
|
423
|
-
close={closeMobile}
|
|
424
|
-
/>
|
|
334
|
+
<NavLinkBody item={child} active={childActive} />
|
|
425
335
|
</Box>
|
|
426
336
|
)
|
|
427
337
|
})}
|
|
428
338
|
</Stack>
|
|
429
339
|
</Popover.Dropdown>
|
|
430
340
|
|
|
431
|
-
{/*
|
|
432
|
-
|
|
433
|
-
<Stack gap={0} className={classes.childList}>
|
|
434
|
-
{item.children!.map((child) => {
|
|
435
|
-
if (child.disabled) {
|
|
436
|
-
return (
|
|
437
|
-
<Tooltip key={child.key} label="Coming soon" position="right" withArrow>
|
|
438
|
-
<Box>
|
|
439
|
-
<NavLink
|
|
440
|
-
classNames={{ root: classes.link }}
|
|
441
|
-
label={child.label}
|
|
442
|
-
leftSection={child.icon}
|
|
443
|
-
data-disabled
|
|
444
|
-
/>
|
|
445
|
-
</Box>
|
|
446
|
-
</Tooltip>
|
|
447
|
-
)
|
|
448
|
-
}
|
|
449
|
-
const childActive = Boolean(child.active)
|
|
450
|
-
return (
|
|
451
|
-
<Box key={child.key}>
|
|
452
|
-
<NavLinkBody
|
|
453
|
-
item={child}
|
|
454
|
-
active={childActive}
|
|
455
|
-
renderNavLink={renderNavLink}
|
|
456
|
-
close={closeMobile}
|
|
457
|
-
/>
|
|
458
|
-
</Box>
|
|
459
|
-
)
|
|
460
|
-
})}
|
|
461
|
-
</Stack>
|
|
462
|
-
)}
|
|
341
|
+
{/* No inline child list here: `isExpanded` early-returns above, so this branch is only ever
|
|
342
|
+
* reached when the children are NOT visible inline — that is what the popover is for. */}
|
|
463
343
|
</Popover>
|
|
464
344
|
)
|
|
465
345
|
}
|
|
@@ -469,10 +349,7 @@ export function AppSidebar({
|
|
|
469
349
|
sections,
|
|
470
350
|
collapsed,
|
|
471
351
|
onToggleCollapse,
|
|
472
|
-
onClose,
|
|
473
|
-
renderNavLink,
|
|
474
352
|
settingsMenuItems,
|
|
475
|
-
footerExtra,
|
|
476
353
|
account,
|
|
477
354
|
search,
|
|
478
355
|
navExtra,
|
|
@@ -489,36 +366,12 @@ export function AppSidebar({
|
|
|
489
366
|
)
|
|
490
367
|
|
|
491
368
|
const renderSectionItems = (section: SidebarSection) =>
|
|
492
|
-
section.items.map((item) =>
|
|
493
|
-
<NavItemRow
|
|
494
|
-
key={item.key}
|
|
495
|
-
item={item}
|
|
496
|
-
collapsed={collapsed}
|
|
497
|
-
renderNavLink={renderNavLink}
|
|
498
|
-
closeMobile={onClose}
|
|
499
|
-
/>
|
|
500
|
-
))
|
|
501
|
-
|
|
502
|
-
// Mobile-only footer controls (close + consumer extras) — must render regardless of whether
|
|
503
|
-
// the settings row does, or the mobile drawer loses its close affordance.
|
|
504
|
-
const mobileControls = (
|
|
505
|
-
<Group gap={2} wrap="nowrap" hiddenFrom="sm">
|
|
506
|
-
{footerExtra}
|
|
507
|
-
<ActionIcon
|
|
508
|
-
variant="subtle"
|
|
509
|
-
className={classes.ghostIcon}
|
|
510
|
-
onClick={onClose}
|
|
511
|
-
aria-label="Close navigation"
|
|
512
|
-
>
|
|
513
|
-
<IconClose />
|
|
514
|
-
</ActionIcon>
|
|
515
|
-
</Group>
|
|
516
|
-
)
|
|
369
|
+
section.items.map((item) => <NavItemRow key={item.key} item={item} collapsed={collapsed} />)
|
|
517
370
|
|
|
518
371
|
// The pinned footer "Settings" row is OPT-IN: it renders only when the consumer supplies
|
|
519
372
|
// settings-menu entries. Most apps put Settings in a nav section instead — rendering the row
|
|
520
|
-
// unconditionally produced a duplicate "Settings" the consumer couldn't remove.
|
|
521
|
-
//
|
|
373
|
+
// unconditionally produced a duplicate "Settings" the consumer couldn't remove. On mobile the
|
|
374
|
+
// same entries are reachable as flat rows in the bottom bar's More surface.
|
|
522
375
|
const hasSettingsMenu = (settingsMenuItems?.length ?? 0) > 0
|
|
523
376
|
const settingsRow = hasSettingsMenu ? (
|
|
524
377
|
<Group {...(account ? {} : { className: classes.footer })} gap="xs" wrap="nowrap">
|
|
@@ -547,18 +400,8 @@ export function AppSidebar({
|
|
|
547
400
|
)}
|
|
548
401
|
</Menu.Dropdown>
|
|
549
402
|
</Menu>
|
|
550
|
-
{mobileControls}
|
|
551
|
-
</Group>
|
|
552
|
-
) : (
|
|
553
|
-
<Group
|
|
554
|
-
{...(account ? {} : { className: classes.footer })}
|
|
555
|
-
gap="xs"
|
|
556
|
-
wrap="nowrap"
|
|
557
|
-
hiddenFrom="sm"
|
|
558
|
-
>
|
|
559
|
-
{mobileControls}
|
|
560
403
|
</Group>
|
|
561
|
-
)
|
|
404
|
+
) : null
|
|
562
405
|
|
|
563
406
|
return (
|
|
564
407
|
<Stack gap={0} h="100%" className={classes.root} data-collapsed={collapsed || undefined}>
|
|
@@ -585,7 +428,7 @@ export function AppSidebar({
|
|
|
585
428
|
// control-height override. Reproduces Mantine's own static `--ai-size-md` (28px).
|
|
586
429
|
size={28}
|
|
587
430
|
visibleFrom="sm"
|
|
588
|
-
className={
|
|
431
|
+
className={classes.ghostIcon}
|
|
589
432
|
onClick={onToggleCollapse}
|
|
590
433
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
591
434
|
>
|
package/src/shell/index.test.tsx
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `BasaltShell`
|
|
3
|
-
* `app-sidebar.test.tsx`
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* without a test that renders `BasaltShell` itself.
|
|
2
|
+
* Hand-off paths that only `BasaltShell` can break — the sub-components are exercised directly in
|
|
3
|
+
* `app-sidebar.test.tsx` / `app-mobile-nav.test.tsx`, but the shell is where the props are
|
|
4
|
+
* destructured and where `extraMoreRows` is COMPUTED. A typo'd destructure key or a row count that
|
|
5
|
+
* disagrees with the renderer ships silently without a test that renders `BasaltShell` itself.
|
|
7
6
|
*/
|
|
8
7
|
import { MantineProvider } from '@mantine/core'
|
|
9
|
-
import { render, screen } from '@testing-library/react'
|
|
8
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
10
9
|
import { describe, expect, test } from 'bun:test'
|
|
11
10
|
import { BasaltShell } from './index'
|
|
12
|
-
import type { SidebarSection } from './index'
|
|
11
|
+
import type { BasaltAccountProps, SidebarSection } from './index'
|
|
13
12
|
|
|
14
13
|
const BRAND = { name: 'Argo' }
|
|
15
14
|
const ONE_SECTION: SidebarSection[] = [
|
|
@@ -33,3 +32,59 @@ describe('BasaltShell sidebarNavExtra', () => {
|
|
|
33
32
|
expect(stack?.contains(extra)).toBe(true)
|
|
34
33
|
})
|
|
35
34
|
})
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* §2.2's arithmetic guarantee is that a `menu` never exceeds `menuMax` rows — six rows at 44px fit
|
|
38
|
+
* the headroom above the bar, and the menu runs `flip: false`, so a menu that overflows has no way
|
|
39
|
+
* to escape upward and simply renders off-screen. The guarantee is only as good as the row count
|
|
40
|
+
* the shell feeds `projectMobileNav`: counting `account` as ONE row while `accountRows` expands it
|
|
41
|
+
* into up to seven is what let a nine-row More surface pick `menu`.
|
|
42
|
+
*/
|
|
43
|
+
describe('BasaltShell extraMoreRows', () => {
|
|
44
|
+
const NAV: SidebarSection[] = [
|
|
45
|
+
{ label: 'Main', items: [{ key: 'home', label: 'Home', icon: null, mobile: 'tab' }] },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
/** Seven account rows: manage + billing + upgrade + three extras + sign out. */
|
|
49
|
+
const FAT_ACCOUNT: BasaltAccountProps = {
|
|
50
|
+
state: {
|
|
51
|
+
status: 'authenticated',
|
|
52
|
+
identity: { id: 'u1', name: 'Jo', email: 'jo@example.com' },
|
|
53
|
+
plan: { key: 'free', label: 'Free', isFree: true },
|
|
54
|
+
},
|
|
55
|
+
actions: {
|
|
56
|
+
onManageAccount: () => {},
|
|
57
|
+
onManageBilling: () => {},
|
|
58
|
+
onUpgrade: () => {},
|
|
59
|
+
onSignOut: () => {},
|
|
60
|
+
extraMenuItems: [
|
|
61
|
+
{ key: 'a', label: 'Extra A', onClick: () => {} },
|
|
62
|
+
{ key: 'b', label: 'Extra B', onClick: () => {} },
|
|
63
|
+
{ key: 'c', label: 'Extra C', onClick: () => {} },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test('an account expanding past menuMax raises the SHEET, not an overflowing menu', async () => {
|
|
69
|
+
render(
|
|
70
|
+
<MantineProvider>
|
|
71
|
+
<BasaltShell brand={BRAND} sections={NAV} account={FAT_ACCOUNT} />
|
|
72
|
+
</MantineProvider>,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
fireEvent.click(screen.getByLabelText('More'))
|
|
76
|
+
await waitFor(() => expect(document.querySelector('.mantine-Drawer-content')).not.toBeNull())
|
|
77
|
+
expect(document.querySelector('[role="menu"]')).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
/** A `loading` account renders NO rows, so it must not conjure a More slot that opens empty. */
|
|
81
|
+
test('a loading account produces no More slot at all', () => {
|
|
82
|
+
render(
|
|
83
|
+
<MantineProvider>
|
|
84
|
+
<BasaltShell brand={BRAND} sections={NAV} account={{ state: { status: 'loading' } }} />
|
|
85
|
+
</MantineProvider>,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
expect(screen.queryByLabelText('More')).toBeNull()
|
|
89
|
+
})
|
|
90
|
+
})
|