basalt-ui 1.18.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 +7 -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/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/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
|
@@ -1,38 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* breakpoint. One tab per group plus a trailing "More" opener; tapping a group tab raises a
|
|
5
|
-
* bottom sheet listing that group's destinations as large tap rows. Active state is a neutral
|
|
6
|
-
* fill, never the identity blue ("ink earns its color", DESIGN.md).
|
|
2
|
+
* The mobile bottom bar. Rendered inside an `AppShell.Footer` (`hiddenFrom="sm"`, height collapsed
|
|
3
|
+
* to 0 on desktop) so it only exists below the navbar breakpoint.
|
|
7
4
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* A SLOT IS A DESTINATION. Tapping one navigates through the consumer's router anchor — no
|
|
6
|
+
* overlay, no animation, nothing to dismiss — which is the whole point of the rewrite: reaching a
|
|
7
|
+
* page used to cost a tap to open a sheet plus a tap to pick a row. An overlay now exists only
|
|
8
|
+
* where a slot genuinely holds more than one destination (the trailing "More" slot, or an opt-in
|
|
9
|
+
* group slot), and its surface is INFERRED from the row count rather than configured: a content-
|
|
10
|
+
* sized `Menu` that pops out of the tab up to `menuMax` rows, a bottom `Drawer` past it.
|
|
11
|
+
*
|
|
12
|
+
* This component paints a finished `MobileNavModel` and owns exactly one piece of state — which
|
|
13
|
+
* slot's surface is open. Every selection decision lives in `mobile-nav-model.ts`, which is pure.
|
|
14
|
+
*
|
|
15
|
+
* Router-agnostic: each slot and row renders through `SidebarItem.Anchor` when supplied (the
|
|
16
|
+
* consumer's router `Link`), else a plain `<a href>` + `item.onClick`. Active state arrives
|
|
17
|
+
* precomputed as `item.active`.
|
|
11
18
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import
|
|
19
|
+
import { Drawer, Menu, NavLink, ScrollArea, Stack, Text, UnstyledButton } from '@mantine/core'
|
|
20
|
+
import { useReducedMotion } from '@mantine/hooks'
|
|
21
|
+
import { Fragment, useState } from 'react'
|
|
22
|
+
import type { MouseEvent, ReactElement, ReactNode } from 'react'
|
|
23
|
+
import type {
|
|
24
|
+
MobileNavConfig,
|
|
25
|
+
MobileNavGroup,
|
|
26
|
+
MobileNavModel,
|
|
27
|
+
MobileNavSlot,
|
|
28
|
+
SidebarItem,
|
|
29
|
+
} from '../nav/types'
|
|
30
|
+
import type { BasaltAccountProps } from './account-types'
|
|
31
|
+
import type { SettingsMenuItem } from './index'
|
|
32
|
+
import { NavCountBadge } from './nav-count-badge'
|
|
33
|
+
import { useBasaltSpacing } from '../theme'
|
|
15
34
|
import classes from './app-mobile-nav.module.css'
|
|
16
35
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
onClick?: (e: MouseEvent) => void
|
|
24
|
-
}
|
|
36
|
+
/**
|
|
37
|
+
* Floating-ui middlewares for a tab menu. `flip: false` is correct, not lazy: flipping a
|
|
38
|
+
* bottom-anchored menu puts it UNDER the footer, off-screen — `menuMax` is what guarantees it fits
|
|
39
|
+
* above the bar (§2.2). `shift.padding` is a viewport inset in px, not a layout spacing token.
|
|
40
|
+
*/
|
|
41
|
+
const MENU_MIDDLEWARES = { flip: false, shift: { padding: 8 } } // theme-allow
|
|
25
42
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
active: boolean
|
|
31
|
-
items: MobileNavItem[]
|
|
32
|
-
}
|
|
43
|
+
/** A slot that navigates on tap. */
|
|
44
|
+
type LinkSlot = Extract<MobileNavSlot, { kind: 'link' }>
|
|
45
|
+
/** A slot that raises a surface. `menu` and `sheet` share one shape — only the renderer differs. */
|
|
46
|
+
type SurfaceSlot = Exclude<MobileNavSlot, LinkSlot>
|
|
33
47
|
|
|
34
|
-
|
|
35
|
-
|
|
48
|
+
export type MobileNavProps = {
|
|
49
|
+
/** The projection from `projectMobileNav` — `BasaltShell` builds it, memoized. */
|
|
50
|
+
model: MobileNavModel
|
|
51
|
+
config?: MobileNavConfig | undefined
|
|
52
|
+
/** Rendered as FLAT ROWS in the More surface, never by mounting `SidebarAccount` (which opens
|
|
53
|
+
* its own `Menu` — a menu inside a menu). This is what makes the mobile sidebar drawer
|
|
54
|
+
* deletable: everything it used to hold is reachable from More. */
|
|
55
|
+
account?: BasaltAccountProps | undefined
|
|
56
|
+
settingsMenuItems?: SettingsMenuItem[] | undefined
|
|
57
|
+
}
|
|
36
58
|
|
|
37
59
|
/** Inline "More" glyph — keeps the shell icon-dependency-free. */
|
|
38
60
|
function IconMore() {
|
|
@@ -56,91 +78,403 @@ function IconMore() {
|
|
|
56
78
|
)
|
|
57
79
|
}
|
|
58
80
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}) {
|
|
68
|
-
// The open group sheet; null = closed. A single Drawer is reused across all groups.
|
|
69
|
-
const [openSection, setOpenSection] = useState<MobileNavSection | null>(null)
|
|
81
|
+
/** A row the More surface derives from `account`/`settingsMenuItems` — never a destination. */
|
|
82
|
+
type ActionRow = {
|
|
83
|
+
key: string
|
|
84
|
+
label: string
|
|
85
|
+
icon?: ReactNode
|
|
86
|
+
danger?: boolean
|
|
87
|
+
onClick?: ((e: MouseEvent<HTMLElement>) => void) | undefined
|
|
88
|
+
}
|
|
70
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Flattens `BasaltAccountProps` into rows. `SidebarAccount`'s own dropdown cannot be reused here —
|
|
92
|
+
* it is a `Menu`, and the More surface is already one.
|
|
93
|
+
*/
|
|
94
|
+
function accountRows(account: BasaltAccountProps | undefined): ActionRow[] {
|
|
95
|
+
if (!account || account.state.status === 'loading') return []
|
|
96
|
+
const actions = account.actions
|
|
97
|
+
if (account.state.status === 'unauthenticated') {
|
|
98
|
+
return [{ key: 'basalt-account-sign-in', label: 'Sign in', onClick: actions?.onSignIn }]
|
|
99
|
+
}
|
|
100
|
+
const rows: ActionRow[] = []
|
|
101
|
+
if (actions?.onManageAccount) {
|
|
102
|
+
rows.push({
|
|
103
|
+
key: 'basalt-account-manage',
|
|
104
|
+
label: 'Account settings',
|
|
105
|
+
onClick: actions.onManageAccount,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
if (actions?.onManageBilling) {
|
|
109
|
+
rows.push({
|
|
110
|
+
key: 'basalt-account-billing',
|
|
111
|
+
label: 'Billing & payment',
|
|
112
|
+
onClick: actions.onManageBilling,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
if (account.state.plan?.isFree && actions?.onUpgrade) {
|
|
116
|
+
rows.push({ key: 'basalt-account-upgrade', label: 'Upgrade', onClick: actions.onUpgrade })
|
|
117
|
+
}
|
|
118
|
+
for (const item of actions?.extraMenuItems ?? []) {
|
|
119
|
+
rows.push({
|
|
120
|
+
key: item.key,
|
|
121
|
+
label: item.label,
|
|
122
|
+
icon: item.icon,
|
|
123
|
+
...(item.danger !== undefined && { danger: item.danger }),
|
|
124
|
+
onClick: item.onClick,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
if (actions?.onSignOut) {
|
|
128
|
+
const onSignOut = actions.onSignOut
|
|
129
|
+
rows.push({
|
|
130
|
+
key: 'basalt-account-sign-out',
|
|
131
|
+
label: 'Sign out',
|
|
132
|
+
danger: true,
|
|
133
|
+
onClick: () => void onSignOut(),
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
return rows
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* How many rows `account` contributes to the More surface — DELEGATED to `accountRows` rather than
|
|
141
|
+
* re-derived, because `BasaltShell` needs this number BEFORE the projection runs (it feeds
|
|
142
|
+
* `extraMoreRows`, which picks `menu` vs `sheet` against `menuMax`) while only this module knows
|
|
143
|
+
* how many rows an account actually expands into. Two independent counts is how a "1 row" account
|
|
144
|
+
* shipped a 9-row menu into headroom sized for 6, and how a `loading` account — which renders NO
|
|
145
|
+
* rows — still conjured a More slot that opened empty.
|
|
146
|
+
*/
|
|
147
|
+
export function accountRowCount(account: BasaltAccountProps | undefined): number {
|
|
148
|
+
return accountRows(account).length
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Settings entries already carry the row shape — they just lose the sidebar's `Menu` wrapper. */
|
|
152
|
+
function settingsRows(items: SettingsMenuItem[] | undefined): ActionRow[] {
|
|
153
|
+
return (items ?? []).map((item) => ({
|
|
154
|
+
key: item.key,
|
|
155
|
+
label: item.label,
|
|
156
|
+
icon: item.icon,
|
|
157
|
+
onClick: item.onClick,
|
|
158
|
+
}))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** One destination row, in a `Menu` dropdown. `depth` indents a nested child. */
|
|
162
|
+
const menuRow = (item: SidebarItem, depth: number): ReactElement => {
|
|
163
|
+
// `.menuItem[data-disabled] { pointer-events: none }` is the primary guard, but it cannot be the
|
|
164
|
+
// only one: Mantine's `MenuItem` composes the CALLER's `onClick` first and only then checks
|
|
165
|
+
// `data-disabled` itself, so anything that reaches the node without a pointer — a keyboard
|
|
166
|
+
// activation, a synthetic click, a consumer stylesheet resetting `pointer-events` — would still
|
|
167
|
+
// fire the destination's handler on a row the projection deliberately rendered dead (§2.9).
|
|
168
|
+
const onClick = (event: MouseEvent<HTMLElement>) => {
|
|
169
|
+
if (item.disabled) {
|
|
170
|
+
event.preventDefault()
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
item.onClick?.(event)
|
|
174
|
+
}
|
|
175
|
+
const shared = {
|
|
176
|
+
className: depth > 0 ? `${classes.menuItem} ${classes.menuItemNested}` : classes.menuItem,
|
|
177
|
+
leftSection: item.icon,
|
|
178
|
+
disabled: Boolean(item.disabled),
|
|
179
|
+
// Mantine emits `data-disabled` (a STYLING hook) and nothing else, so the ARIA state is ours
|
|
180
|
+
// to set — a disabled destination still renders (§2.3 rule 11), so it has to announce itself.
|
|
181
|
+
'aria-disabled': item.disabled || undefined,
|
|
182
|
+
'aria-current': item.active ? ('page' as const) : undefined,
|
|
183
|
+
rightSection: item.count ? <NavCountBadge count={item.count} /> : undefined,
|
|
184
|
+
onClick,
|
|
185
|
+
}
|
|
186
|
+
const Anchor = item.Anchor
|
|
187
|
+
if (Anchor && !item.disabled) {
|
|
188
|
+
return (
|
|
189
|
+
<Menu.Item key={item.key} component={Anchor} {...shared}>
|
|
190
|
+
{item.label}
|
|
191
|
+
</Menu.Item>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
71
194
|
return (
|
|
195
|
+
<Menu.Item
|
|
196
|
+
key={item.key}
|
|
197
|
+
component="a"
|
|
198
|
+
{...(item.href !== undefined && !item.disabled && { href: item.href })}
|
|
199
|
+
{...shared}
|
|
200
|
+
>
|
|
201
|
+
{item.label}
|
|
202
|
+
</Menu.Item>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Depth-first: parent, then its children as indented rows — nesting survives to mobile. */
|
|
207
|
+
const rows = (
|
|
208
|
+
items: readonly SidebarItem[],
|
|
209
|
+
depth: number,
|
|
210
|
+
render: (item: SidebarItem, depth: number) => ReactElement,
|
|
211
|
+
): ReactElement[] =>
|
|
212
|
+
items.flatMap((item) => [render(item, depth), ...rows(item.children ?? [], depth + 1, render)])
|
|
213
|
+
|
|
214
|
+
/** An account/settings row, in a `Menu` dropdown. */
|
|
215
|
+
const menuActionRow = (row: ActionRow) => (
|
|
216
|
+
<Menu.Item
|
|
217
|
+
key={row.key}
|
|
218
|
+
className={classes.menuItem}
|
|
219
|
+
leftSection={row.icon}
|
|
220
|
+
{...(row.danger ? { color: 'red' } : {})}
|
|
221
|
+
onClick={(event: MouseEvent<HTMLElement>) => row.onClick?.(event)}
|
|
222
|
+
>
|
|
223
|
+
{row.label}
|
|
224
|
+
</Menu.Item>
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
/** The sheet has no `Menu.Label` equivalent, so the section heading is a plain micro-label. */
|
|
228
|
+
const sheetGroupLabel = (group: MobileNavGroup) =>
|
|
229
|
+
group.label ? (
|
|
230
|
+
<Text component="div" className={classes.menuLabel}>
|
|
231
|
+
{group.label}
|
|
232
|
+
</Text>
|
|
233
|
+
) : null
|
|
234
|
+
|
|
235
|
+
export function MobileNav({
|
|
236
|
+
model,
|
|
237
|
+
config,
|
|
238
|
+
account,
|
|
239
|
+
settingsMenuItems,
|
|
240
|
+
}: MobileNavProps): ReactElement {
|
|
241
|
+
// `openKey` is the ONLY state here, and it is keyed by slot rather than holding a slot object:
|
|
242
|
+
// `sections` identity churning on every consumer render must not close an open menu.
|
|
243
|
+
const [openKey, setOpenKey] = useState<string | null>(null)
|
|
244
|
+
// `DEFAULT_THEME.respectReducedMotion` is false in Mantine 9.3 and `createBasaltTheme` does not
|
|
245
|
+
// set it, so the preference has to be read explicitly at the call site.
|
|
246
|
+
const reduceMotion = useReducedMotion()
|
|
247
|
+
const { step } = useBasaltSpacing()
|
|
248
|
+
|
|
249
|
+
const close = () => setOpenKey(null)
|
|
250
|
+
|
|
251
|
+
/** §2.5 — re-tapping the ACTIVE slot scrolls to top instead of pushing a redundant history entry. */
|
|
252
|
+
const scrollToTop = () => {
|
|
253
|
+
const target =
|
|
254
|
+
config?.getScrollElement?.() ?? document.scrollingElement ?? document.documentElement
|
|
255
|
+
target.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' })
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const extraRows = [...accountRows(account), ...settingsRows(settingsMenuItems)]
|
|
259
|
+
|
|
260
|
+
/** True when any destination in the slot carries an unread count — the icon dot (§2.4). */
|
|
261
|
+
const hasCount = (slot: MobileNavSlot): boolean =>
|
|
262
|
+
slot.kind === 'link'
|
|
263
|
+
? Boolean(slot.item.count)
|
|
264
|
+
: slot.groups.some((g) => groupHasCount(g.items))
|
|
265
|
+
|
|
266
|
+
const tabInner = (slot: MobileNavSlot) => (
|
|
72
267
|
<>
|
|
73
|
-
<
|
|
74
|
-
{
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
268
|
+
<span className={classes.tabIcon}>
|
|
269
|
+
{slot.icon ?? (slot.kind !== 'link' && slot.isMore ? <IconMore /> : null)}
|
|
270
|
+
{hasCount(slot) ? <span className={classes.tabDot} aria-hidden /> : null}
|
|
271
|
+
</span>
|
|
272
|
+
<Text className={classes.label}>{slot.short}</Text>
|
|
273
|
+
</>
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The trigger for a `menu`/`sheet` slot. A plain button: Mantine's `Menu.Target` clones
|
|
278
|
+
* `aria-haspopup`/`aria-expanded`/`aria-controls` onto it (`withRoles` default), so hand-setting
|
|
279
|
+
* them here would double them up (§2.9).
|
|
280
|
+
*/
|
|
281
|
+
const surfaceTab = (slot: SurfaceSlot, onClick?: () => void) => (
|
|
282
|
+
<UnstyledButton
|
|
283
|
+
className={classes.tab}
|
|
284
|
+
data-active={slot.active || undefined}
|
|
285
|
+
aria-current={slot.active ? 'page' : undefined}
|
|
286
|
+
aria-label={slot.label}
|
|
287
|
+
{...(onClick ? { onClick } : {})}
|
|
288
|
+
>
|
|
289
|
+
{tabInner(slot)}
|
|
290
|
+
</UnstyledButton>
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
/** A `link` slot: the consumer's router anchor IS the tab, so preload/back/middle-click all work. */
|
|
294
|
+
const linkTab = (slot: LinkSlot) => {
|
|
295
|
+
const onClick = (e: MouseEvent<HTMLElement>) => {
|
|
296
|
+
// VERIFIED in @tanstack/react-router: `Link` composes the caller's handler FIRST and returns
|
|
297
|
+
// early when `defaultPrevented`, so this suppresses navigation without touching preload.
|
|
298
|
+
if (slot.active) {
|
|
299
|
+
e.preventDefault()
|
|
300
|
+
scrollToTop()
|
|
301
|
+
}
|
|
302
|
+
slot.item.onClick?.(e)
|
|
303
|
+
}
|
|
304
|
+
const shared = {
|
|
305
|
+
className: classes.tab,
|
|
306
|
+
'data-active': slot.active || undefined,
|
|
307
|
+
'aria-current': slot.active ? ('page' as const) : undefined,
|
|
308
|
+
'aria-label': slot.label,
|
|
309
|
+
onClick,
|
|
310
|
+
}
|
|
311
|
+
const Anchor = slot.item.Anchor
|
|
312
|
+
if (Anchor) {
|
|
313
|
+
return (
|
|
314
|
+
<UnstyledButton key={slot.key} component={Anchor} {...shared}>
|
|
315
|
+
{tabInner(slot)}
|
|
90
316
|
</UnstyledButton>
|
|
91
|
-
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
return (
|
|
320
|
+
<UnstyledButton
|
|
321
|
+
key={slot.key}
|
|
322
|
+
component="a"
|
|
323
|
+
{...(slot.item.href !== undefined && { href: slot.item.href })}
|
|
324
|
+
{...shared}
|
|
325
|
+
>
|
|
326
|
+
{tabInner(slot)}
|
|
327
|
+
</UnstyledButton>
|
|
328
|
+
)
|
|
329
|
+
}
|
|
92
330
|
|
|
331
|
+
/** One destination row, in the sheet. 44px minimum, per the touch-target floor. */
|
|
332
|
+
const sheetRow = (item: SidebarItem, depth: number): ReactElement => {
|
|
333
|
+
const onClick = (e: MouseEvent<HTMLElement>) => {
|
|
334
|
+
item.onClick?.(e)
|
|
335
|
+
close()
|
|
336
|
+
}
|
|
337
|
+
const shared = {
|
|
338
|
+
classNames: {
|
|
339
|
+
root: depth > 0 ? `${classes.row} ${classes.rowNested}` : classes.row,
|
|
340
|
+
},
|
|
341
|
+
label: item.label,
|
|
342
|
+
leftSection: item.icon,
|
|
343
|
+
rightSection: item.count ? <NavCountBadge count={item.count} /> : undefined,
|
|
344
|
+
active: Boolean(item.active),
|
|
345
|
+
disabled: Boolean(item.disabled),
|
|
346
|
+
// Same as the menu row above: `NavLink`'s `disabled` is a `mod`, i.e. `data-disabled` only.
|
|
347
|
+
'aria-disabled': item.disabled || undefined,
|
|
348
|
+
'aria-current': item.active ? ('page' as const) : undefined,
|
|
349
|
+
onClick,
|
|
350
|
+
}
|
|
351
|
+
const Anchor = item.Anchor
|
|
352
|
+
if (Anchor && !item.disabled) {
|
|
353
|
+
return <NavLink key={item.key} component={Anchor} {...shared} />
|
|
354
|
+
}
|
|
355
|
+
return (
|
|
356
|
+
<NavLink
|
|
357
|
+
key={item.key}
|
|
358
|
+
component="a"
|
|
359
|
+
{...(item.href !== undefined && !item.disabled && { href: item.href })}
|
|
360
|
+
{...shared}
|
|
361
|
+
/>
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const sheetActionRow = (row: ActionRow) => (
|
|
366
|
+
<NavLink
|
|
367
|
+
key={row.key}
|
|
368
|
+
classNames={{ root: classes.row }}
|
|
369
|
+
label={row.label}
|
|
370
|
+
leftSection={row.icon}
|
|
371
|
+
{...(row.danger ? { color: 'red' } : {})}
|
|
372
|
+
onClick={(event: MouseEvent<HTMLElement>) => {
|
|
373
|
+
row.onClick?.(event)
|
|
374
|
+
close()
|
|
375
|
+
}}
|
|
376
|
+
/>
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
const menuSlot = (slot: SurfaceSlot) => (
|
|
380
|
+
<Menu
|
|
381
|
+
key={slot.key}
|
|
382
|
+
opened={openKey === slot.key}
|
|
383
|
+
onChange={(opened) => setOpenKey(opened ? slot.key : null)}
|
|
384
|
+
position={slot.isMore ? 'top-end' : 'top'}
|
|
385
|
+
offset={8}
|
|
386
|
+
width={step.mobileNavMenuWidth}
|
|
387
|
+
withinPortal
|
|
388
|
+
// AppShell itself sits at z-index 100 (`getDefaultZIndex('app')`), so the menu must clear it.
|
|
389
|
+
zIndex={400}
|
|
390
|
+
middlewares={MENU_MIDDLEWARES}
|
|
391
|
+
trapFocus
|
|
392
|
+
returnFocus
|
|
393
|
+
closeOnItemClick
|
|
394
|
+
closeOnClickOutside
|
|
395
|
+
closeOnEscape
|
|
396
|
+
// Required for a navigation menu following the WAI-ARIA disclosure pattern — Mantine's own
|
|
397
|
+
// note on the prop. Without it every row is `tabindex="-1"`.
|
|
398
|
+
menuItemTabIndex={0}
|
|
399
|
+
transitionProps={{
|
|
400
|
+
transition: slot.isMore ? 'pop-bottom-right' : 'pop',
|
|
401
|
+
duration: reduceMotion ? 0 : 140,
|
|
402
|
+
exitDuration: reduceMotion ? 0 : 100,
|
|
403
|
+
}}
|
|
404
|
+
classNames={{ dropdown: classes.menuDropdown, label: classes.menuLabel }}
|
|
405
|
+
>
|
|
406
|
+
<Menu.Target>{surfaceTab(slot)}</Menu.Target>
|
|
407
|
+
<Menu.Dropdown>
|
|
408
|
+
{slot.groups.flatMap((group) => [
|
|
409
|
+
...(group.label
|
|
410
|
+
? [<Menu.Label key={`${group.key}-label`}>{group.label}</Menu.Label>]
|
|
411
|
+
: []),
|
|
412
|
+
...rows(group.items, 0, menuRow),
|
|
413
|
+
])}
|
|
414
|
+
{/* A separator separates. With nothing above it — a More slot raised purely by the account
|
|
415
|
+
and settings rows — it would render as the dropdown's first child, a rule under the
|
|
416
|
+
top edge. Both sides have to exist. */}
|
|
417
|
+
{slot.isMore && extraRows.length > 0 && slotHasRows(slot) ? <Menu.Divider /> : null}
|
|
418
|
+
{slot.isMore ? extraRows.map(menuActionRow) : null}
|
|
419
|
+
{slot.isMore ? config?.moreExtra : null}
|
|
420
|
+
</Menu.Dropdown>
|
|
421
|
+
</Menu>
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
const sheetSlot = (slot: SurfaceSlot) => (
|
|
425
|
+
<Fragment key={slot.key}>
|
|
426
|
+
{surfaceTab(slot, () => setOpenKey(slot.key))}
|
|
93
427
|
<Drawer
|
|
94
|
-
opened={
|
|
95
|
-
onClose={
|
|
428
|
+
opened={openKey === slot.key}
|
|
429
|
+
onClose={close}
|
|
96
430
|
position="bottom"
|
|
97
431
|
size="auto"
|
|
98
432
|
padding="md"
|
|
99
|
-
title={
|
|
433
|
+
title={slot.label}
|
|
100
434
|
classNames={{ content: classes.sheet, title: classes.sheetTitle }}
|
|
435
|
+
transitionProps={{
|
|
436
|
+
transition: 'slide-up',
|
|
437
|
+
duration: reduceMotion ? 0 : 220,
|
|
438
|
+
timingFunction: 'cubic-bezier(0.32, 0.72, 0, 1)',
|
|
439
|
+
}}
|
|
101
440
|
>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// Items with an href stay anchors so the consumer's router can preload them.
|
|
119
|
-
return item.href ? (
|
|
120
|
-
<NavLink
|
|
121
|
-
key={item.key}
|
|
122
|
-
classNames={{ root: classes.row }}
|
|
123
|
-
component="a"
|
|
124
|
-
href={item.href}
|
|
125
|
-
label={item.label}
|
|
126
|
-
leftSection={item.icon}
|
|
127
|
-
active={active}
|
|
128
|
-
onClick={handleClick}
|
|
129
|
-
/>
|
|
130
|
-
) : (
|
|
131
|
-
<UnstyledButton
|
|
132
|
-
key={item.key}
|
|
133
|
-
onClick={handleClick}
|
|
134
|
-
className={classes.row}
|
|
135
|
-
data-active={active || undefined}
|
|
136
|
-
>
|
|
137
|
-
{item.icon}
|
|
138
|
-
<Text className={classes.rowLabel}>{item.label}</Text>
|
|
139
|
-
</UnstyledButton>
|
|
140
|
-
)
|
|
141
|
-
})}
|
|
142
|
-
</Stack>
|
|
441
|
+
{/* The dismiss affordance. No swipe-to-dismiss — see `.grabber` in the CSS module. */}
|
|
442
|
+
<div className={classes.grabber} aria-hidden />
|
|
443
|
+
<ScrollArea.Autosize mah="62dvh" type="scroll">
|
|
444
|
+
<Stack gap="xs">
|
|
445
|
+
{slot.groups.map((group) => (
|
|
446
|
+
<Stack key={group.key} gap={2}>
|
|
447
|
+
{sheetGroupLabel(group)}
|
|
448
|
+
{rows(group.items, 0, sheetRow)}
|
|
449
|
+
</Stack>
|
|
450
|
+
))}
|
|
451
|
+
{slot.isMore && extraRows.length > 0 ? (
|
|
452
|
+
<Stack gap={2}>{extraRows.map(sheetActionRow)}</Stack>
|
|
453
|
+
) : null}
|
|
454
|
+
{slot.isMore ? config?.moreExtra : null}
|
|
455
|
+
</Stack>
|
|
456
|
+
</ScrollArea.Autosize>
|
|
143
457
|
</Drawer>
|
|
144
|
-
|
|
458
|
+
</Fragment>
|
|
145
459
|
)
|
|
460
|
+
|
|
461
|
+
return (
|
|
462
|
+
<nav className={classes.bar} aria-label="Primary">
|
|
463
|
+
{model.slots.map((slot) => {
|
|
464
|
+
if (slot.kind === 'link') return linkTab(slot)
|
|
465
|
+
if (slot.kind === 'menu') return menuSlot(slot)
|
|
466
|
+
return sheetSlot(slot)
|
|
467
|
+
})}
|
|
468
|
+
</nav>
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Whether a surface slot paints any destination row at all (as opposed to only derived rows). */
|
|
473
|
+
function slotHasRows(slot: SurfaceSlot): boolean {
|
|
474
|
+
return slot.groups.some((group) => group.items.length > 0)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Any destination in the tree carrying a non-zero count. */
|
|
478
|
+
function groupHasCount(items: readonly SidebarItem[]): boolean {
|
|
479
|
+
return items.some((item) => Boolean(item.count) || groupHasCount(item.children ?? []))
|
|
146
480
|
}
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* The active tint and icon-accent color live in the Mantine theme
|
|
10
10
|
* (`NavLink.extend` + `theme/nav-link.module.css`) so they apply to EVERY render path, including a
|
|
11
|
-
* consumer's router `<Link>`
|
|
11
|
+
* consumer's router `<Link>` hosted through `SidebarItem.Anchor` (which never sees this CSS
|
|
12
|
+
* module). This module
|
|
12
13
|
* owns the shell-local layout (docs/DESIGN-SPEC.md §5) and the INACTIVE state (muted text, faint
|
|
13
14
|
* icon, ink-6% hover) — the active state is left to the theme.
|
|
14
15
|
*
|
|
@@ -382,7 +383,7 @@
|
|
|
382
383
|
}
|
|
383
384
|
|
|
384
385
|
/* Collapse every nav row to its icon. Targets `.navItem` — the wrapper Box present on EVERY render
|
|
385
|
-
* path — so it covers the shell's fallback NavLink AND a consumer's `
|
|
386
|
+
* path — so it covers the shell's fallback NavLink AND a consumer's `SidebarItem.Anchor` (e.g. a router
|
|
386
387
|
* <Link>), both of which render a Mantine NavLink inside. Keying off `.link` alone missed consumer
|
|
387
388
|
* links, so their labels overflowed the 64px rail. */
|
|
388
389
|
.root[data-collapsed] .navItem :global(.mantine-NavLink-root) {
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* Typed CSS-module declaration (the S4 CSS-modules path). Named string props — not an index
|
|
3
3
|
* signature — so dot access (`classes.link`) stays verbatim under the package's strict flags
|
|
4
4
|
* (`noUncheckedIndexedAccess`, `noPropertyAccessFromIndexSignature`, `exactOptionalPropertyTypes`).
|
|
5
|
-
* `collapseBtn` is referenced by the component for parity with argo though it has no CSS rule.
|
|
6
5
|
*/
|
|
7
6
|
declare const classes: {
|
|
8
7
|
readonly root: string
|
|
@@ -15,7 +14,6 @@ declare const classes: {
|
|
|
15
14
|
readonly navScroll: string
|
|
16
15
|
readonly navViewport: string
|
|
17
16
|
readonly nav: string
|
|
18
|
-
readonly collapseBtn: string
|
|
19
17
|
readonly footer: string
|
|
20
18
|
readonly footerBtn: string
|
|
21
19
|
readonly footerText: string
|
|
@@ -7,14 +7,11 @@
|
|
|
7
7
|
* class, stable across runs — CSS-module class hashes are not available under `bun test`, see
|
|
8
8
|
* `stat-card.test.tsx`/`use-basalt-spacing.test.tsx` for the same constraint elsewhere), as the
|
|
9
9
|
* LAST child, so a long tree scrolls with the nav instead of fighting it for height.
|
|
10
|
-
* 2. It STAYS MOUNTED when `collapsed` —
|
|
11
|
-
* the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.
|
|
10
|
+
* 2. It STAYS MOUNTED when `collapsed` — hiding it on the rail lives entirely in a `min-width: sm`
|
|
11
|
+
* CSS media query, never in JS, so the DOM is identical either way. jsdom does not evaluate the
|
|
12
|
+
* media query, so this asserts presence in the DOM, not computed visibility — the CSS half
|
|
13
|
+
* (`.root[data-collapsed] .navExtra { display: none }`) is exercised by the guard rules it
|
|
14
|
+
* shares with `.childList`/`.sectionBand`, not by this test.
|
|
18
15
|
* 3. `sections={[]}` plus only `navExtra` renders cleanly: the nav Stack holds exactly the
|
|
19
16
|
* extra content, no orphan section wrapper.
|
|
20
17
|
* 4. Omitting the prop reproduces today's DOM exactly — the nav Stack's child count matches
|
|
@@ -41,7 +38,6 @@ function renderSidebar(props: Partial<AppSidebarProps> & { navExtra?: ReactNode
|
|
|
41
38
|
sections={ONE_SECTION}
|
|
42
39
|
collapsed={false}
|
|
43
40
|
onToggleCollapse={() => {}}
|
|
44
|
-
onClose={() => {}}
|
|
45
41
|
{...props}
|
|
46
42
|
/>
|
|
47
43
|
</MantineProvider>,
|
|
@@ -75,7 +71,7 @@ describe('navExtra', () => {
|
|
|
75
71
|
const stack = navStack(container)
|
|
76
72
|
// Presence, not visibility — jsdom never evaluates the `min-width: sm` media query that hides
|
|
77
73
|
// 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
|
|
74
|
+
// fail this assertion instead of only breaking silently in a real browser at the rail width.
|
|
79
75
|
expect(stack.contains(extra)).toBe(true)
|
|
80
76
|
})
|
|
81
77
|
|