@spree/dashboard-core 0.13.0 → 1.0.0-beta.1
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/README.md +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +97 -36
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +425 -131
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +63 -22
- package/src/components/table-toolbar.tsx +1373 -442
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +261 -14
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +21 -0
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/storefront.ts +19 -0
- package/src/lib/table-registry.ts +56 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -516
- package/src/locales/de.json +1883 -515
- package/src/locales/en.json +1886 -516
- package/src/locales/fr.json +1884 -516
- package/src/locales/pl.json +1913 -516
- package/src/locales/zh-CN.json +1883 -516
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- package/src/components/top-bar.tsx +0 -210
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
+
cn,
|
|
2
3
|
DropdownMenu,
|
|
3
4
|
DropdownMenuContent,
|
|
4
5
|
DropdownMenuItem,
|
|
6
|
+
DropdownMenuSeparator,
|
|
5
7
|
DropdownMenuTrigger,
|
|
6
8
|
SidebarGroup,
|
|
7
9
|
SidebarMenu,
|
|
@@ -14,8 +16,9 @@ import {
|
|
|
14
16
|
} from '@spree/dashboard-ui'
|
|
15
17
|
import { Link, useRouterState } from '@tanstack/react-router'
|
|
16
18
|
import type { LucideIcon } from 'lucide-react'
|
|
17
|
-
import { type ComponentType, useState } from 'react'
|
|
18
|
-
import
|
|
19
|
+
import { type ComponentType, useCallback, useEffect, useRef, useState } from 'react'
|
|
20
|
+
import { isPathWithin } from '../lib/nav-registry'
|
|
21
|
+
import type { ActionName, SubjectName } from '../lib/permissions'
|
|
19
22
|
|
|
20
23
|
export type NavItem = {
|
|
21
24
|
title: string
|
|
@@ -23,9 +26,18 @@ export type NavItem = {
|
|
|
23
26
|
icon: LucideIcon
|
|
24
27
|
/** CanCanCan subject required to see this item. If omitted, item is always visible. */
|
|
25
28
|
subject?: SubjectName
|
|
29
|
+
/** Action checked against `subject`. Defaults to `'read'`. */
|
|
30
|
+
action?: ActionName
|
|
26
31
|
/** Component rendered after the label (e.g. a count badge). May return null. */
|
|
27
32
|
badge?: ComponentType
|
|
28
|
-
items?: {
|
|
33
|
+
items?: {
|
|
34
|
+
title: string
|
|
35
|
+
url: string
|
|
36
|
+
subject?: SubjectName
|
|
37
|
+
action?: ActionName
|
|
38
|
+
/** Component rendered after the label, same contract as the parent's. */
|
|
39
|
+
badge?: ComponentType
|
|
40
|
+
}[]
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
export function NavIcon({ icon: Icon, isActive }: { icon: NavItem['icon']; isActive?: boolean }) {
|
|
@@ -41,28 +53,133 @@ export function NavIcon({ icon: Icon, isActive }: { icon: NavItem['icon']; isAct
|
|
|
41
53
|
)
|
|
42
54
|
}
|
|
43
55
|
|
|
44
|
-
|
|
45
|
-
|
|
56
|
+
/** Grace period between leaving the trigger/menu and the menu closing. */
|
|
57
|
+
const HOVER_CLOSE_DELAY_MS = 150
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Which collapsed-nav menu is open, shared across the whole rail.
|
|
61
|
+
*
|
|
62
|
+
* One value rather than per-item state, so the menus are mutually exclusive by
|
|
63
|
+
* construction: hovering a neighbour replaces the open key instantly instead of
|
|
64
|
+
* leaving the previous menu on screen for the close delay, which read as two
|
|
65
|
+
* panels flickering over each other.
|
|
66
|
+
*/
|
|
67
|
+
interface HoverMenuController {
|
|
68
|
+
openKey: string | null
|
|
69
|
+
/** Opens `key` immediately, replacing whatever was open. */
|
|
70
|
+
open: (key: string) => void
|
|
71
|
+
/** Schedules a close, but only if `key` is still the open one. */
|
|
72
|
+
closeSoon: (key: string) => void
|
|
73
|
+
/** Closes right away — for explicit dismissals (Escape, selecting an item). */
|
|
74
|
+
closeNow: () => void
|
|
75
|
+
/** Cancels a pending close (pointer came back). */
|
|
76
|
+
cancelClose: () => void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function useHoverMenuController(): HoverMenuController {
|
|
80
|
+
const [openKey, setOpenKey] = useState<string | null>(null)
|
|
81
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
82
|
+
|
|
83
|
+
const cancelClose = useCallback(() => {
|
|
84
|
+
if (closeTimer.current) {
|
|
85
|
+
clearTimeout(closeTimer.current)
|
|
86
|
+
closeTimer.current = null
|
|
87
|
+
}
|
|
88
|
+
}, [])
|
|
89
|
+
|
|
90
|
+
const open = useCallback(
|
|
91
|
+
(key: string) => {
|
|
92
|
+
cancelClose()
|
|
93
|
+
setOpenKey(key)
|
|
94
|
+
},
|
|
95
|
+
[cancelClose],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const closeSoon = useCallback(
|
|
99
|
+
(key: string) => {
|
|
100
|
+
cancelClose()
|
|
101
|
+
closeTimer.current = setTimeout(() => {
|
|
102
|
+
// Guarded so a late timer from the menu the pointer just left can't
|
|
103
|
+
// close the one it has since moved to.
|
|
104
|
+
setOpenKey((current) => (current === key ? null : current))
|
|
105
|
+
}, HOVER_CLOSE_DELAY_MS)
|
|
106
|
+
},
|
|
107
|
+
[cancelClose],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const closeNow = useCallback(() => {
|
|
111
|
+
cancelClose()
|
|
112
|
+
setOpenKey(null)
|
|
113
|
+
}, [cancelClose])
|
|
114
|
+
|
|
115
|
+
useEffect(
|
|
116
|
+
() => () => {
|
|
117
|
+
if (closeTimer.current) clearTimeout(closeTimer.current)
|
|
118
|
+
},
|
|
119
|
+
[],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return { openKey, open, closeSoon, closeNow, cancelClose }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function CollapsedDropdown({
|
|
126
|
+
item,
|
|
127
|
+
controller,
|
|
128
|
+
children,
|
|
129
|
+
}: {
|
|
130
|
+
item: NavItem
|
|
131
|
+
controller: HoverMenuController
|
|
132
|
+
children: React.ReactNode
|
|
133
|
+
}) {
|
|
134
|
+
const key = item.title
|
|
135
|
+
const open = controller.openKey === key
|
|
136
|
+
const openNow = () => controller.open(key)
|
|
137
|
+
const closeSoon = () => controller.closeSoon(key)
|
|
46
138
|
|
|
47
139
|
return (
|
|
48
|
-
<DropdownMenu
|
|
140
|
+
<DropdownMenu
|
|
141
|
+
open={open}
|
|
142
|
+
// Base UI's own open/close signals (Escape, outside click, selecting an
|
|
143
|
+
// item) are explicit dismissals, so they close immediately — the grace
|
|
144
|
+
// period exists only for the pointer leaving on its way somewhere.
|
|
145
|
+
onOpenChange={(next) => {
|
|
146
|
+
if (next) controller.open(key)
|
|
147
|
+
else controller.closeNow()
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
49
150
|
{/* `nativeButton={false}` because the trigger renders a `<div>` wrapping
|
|
50
151
|
a `SidebarMenuButton` (whose inner element is a `<Link>` → `<a>`),
|
|
51
152
|
not a `<button>`. The wrapper exists to attach `onMouseLeave` for
|
|
52
153
|
the hover-out close, so we can't drop it; instead we tell Base UI
|
|
53
154
|
we're rendering a non-button trigger and let it apply menu role +
|
|
54
155
|
keyboard semantics. */}
|
|
55
|
-
<DropdownMenuTrigger asChild nativeButton={false} onMouseEnter={
|
|
156
|
+
<DropdownMenuTrigger asChild nativeButton={false} onMouseEnter={openNow}>
|
|
56
157
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: hover trigger for collapsed nav */}
|
|
57
|
-
<div onMouseLeave={
|
|
158
|
+
<div onMouseLeave={closeSoon}>{children}</div>
|
|
58
159
|
</DropdownMenuTrigger>
|
|
160
|
+
{/* `sideOffset={0}` plus inline-start *padding* closes the dead zone the
|
|
161
|
+
pointer used to cross: an offset (or a margin) puts those pixels
|
|
162
|
+
outside both elements, so travelling to the menu reads as leaving
|
|
163
|
+
both. Padding keeps the gap inside the panel's own hover area, so the
|
|
164
|
+
visual inset costs nothing in reachability. */}
|
|
59
165
|
<DropdownMenuContent
|
|
60
166
|
side="right"
|
|
61
167
|
align="start"
|
|
62
|
-
sideOffset={
|
|
63
|
-
|
|
64
|
-
|
|
168
|
+
sideOffset={0}
|
|
169
|
+
className="ps-1"
|
|
170
|
+
onMouseLeave={closeSoon}
|
|
171
|
+
onMouseEnter={openNow}
|
|
65
172
|
>
|
|
173
|
+
{/* The section itself leads the list. Collapsed, the icon is the only
|
|
174
|
+
route to it, and hovering opens this panel instead — so without an
|
|
175
|
+
entry here "Products" is reachable by a click most people won't
|
|
176
|
+
guess at, and not at all by keyboard. */}
|
|
177
|
+
<DropdownMenuItem asChild>
|
|
178
|
+
<Link to={item.url} className="no-underline font-medium">
|
|
179
|
+
{item.title}
|
|
180
|
+
</Link>
|
|
181
|
+
</DropdownMenuItem>
|
|
182
|
+
<DropdownMenuSeparator />
|
|
66
183
|
{item.items!.map((subItem) => (
|
|
67
184
|
<DropdownMenuItem key={subItem.title} asChild>
|
|
68
185
|
<Link to={subItem.url} className="no-underline">
|
|
@@ -79,19 +196,24 @@ function NavItemContent({
|
|
|
79
196
|
item,
|
|
80
197
|
currentPath,
|
|
81
198
|
isCollapsed,
|
|
199
|
+
isMobile,
|
|
200
|
+
hoverMenu,
|
|
82
201
|
}: {
|
|
83
202
|
item: NavItem
|
|
84
203
|
currentPath: string
|
|
85
204
|
isCollapsed: boolean
|
|
205
|
+
isMobile: boolean
|
|
206
|
+
hoverMenu: HoverMenuController
|
|
86
207
|
}) {
|
|
87
208
|
const isExactActive = currentPath === item.url || currentPath === `${item.url}/`
|
|
88
209
|
// Only prefix-match for items with a sub-path after the storeId segment (e.g. /store_abc/orders)
|
|
89
210
|
const hasSubPath = item.url.split('/').filter(Boolean).length > 1
|
|
90
|
-
const isActive = isExactActive || (hasSubPath && currentPath
|
|
91
|
-
const hasActiveChild = item.items?.some(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
211
|
+
const isActive = isExactActive || (hasSubPath && isPathWithin(currentPath, item.url))
|
|
212
|
+
const hasActiveChild = item.items?.some((sub) => isPathWithin(currentPath, sub.url))
|
|
213
|
+
// Desktop reveals children on hover or when the section is active. Touch has
|
|
214
|
+
// no hover, and the drawer closes on navigation, so a collapsed child would
|
|
215
|
+
// take three taps to reach — show every child up front instead.
|
|
216
|
+
const showSubmenu = isMobile || isActive || hasActiveChild
|
|
95
217
|
const itemIsActive = isActive || !!hasActiveChild
|
|
96
218
|
|
|
97
219
|
const button = (
|
|
@@ -100,14 +222,19 @@ function NavItemContent({
|
|
|
100
222
|
asChild
|
|
101
223
|
isActive={itemIsActive}
|
|
102
224
|
>
|
|
103
|
-
|
|
225
|
+
{/* `aria-label` because the visible label is hidden in collapsed icon
|
|
226
|
+
mode, which would otherwise leave the link with no accessible name —
|
|
227
|
+
an icon-only link is unusable to a screen reader. */}
|
|
228
|
+
<Link to={item.url} aria-label={isCollapsed ? item.title : undefined}>
|
|
104
229
|
<NavIcon icon={item.icon} isActive={itemIsActive} />
|
|
105
230
|
{/* Explicitly hide in collapsed icon mode: a trailing badge span would
|
|
106
231
|
otherwise steal the `span:last-child` position the base button style
|
|
107
232
|
relies on to hide the label, leaving the title visible. */}
|
|
108
233
|
<span className="group-data-[collapsible=icon]:hidden">{item.title}</span>
|
|
234
|
+
{/* `shrink-0` so the badge keeps its shape while the sidebar width
|
|
235
|
+
animates — a squeezed badge deforms before the label clips. */}
|
|
109
236
|
{item.badge && (
|
|
110
|
-
<span className="ml-auto group-data-[collapsible=icon]:hidden">
|
|
237
|
+
<span className="ml-auto shrink-0 group-data-[collapsible=icon]:hidden">
|
|
111
238
|
<item.badge />
|
|
112
239
|
</span>
|
|
113
240
|
)}
|
|
@@ -116,21 +243,31 @@ function NavItemContent({
|
|
|
116
243
|
)
|
|
117
244
|
|
|
118
245
|
return (
|
|
119
|
-
|
|
246
|
+
// Hovering an item with no submenu dismisses whatever menu is open: the
|
|
247
|
+
// pointer has clearly moved on, and leaving a neighbour's panel hanging
|
|
248
|
+
// over the rail reads as a stuck popup.
|
|
249
|
+
<SidebarMenuItem onMouseEnter={isCollapsed && !item.items ? hoverMenu.closeNow : undefined}>
|
|
120
250
|
{isCollapsed && item.items ? (
|
|
121
|
-
<CollapsedDropdown item={item}
|
|
251
|
+
<CollapsedDropdown item={item} controller={hoverMenu}>
|
|
252
|
+
{button}
|
|
253
|
+
</CollapsedDropdown>
|
|
122
254
|
) : (
|
|
123
255
|
button
|
|
124
256
|
)}
|
|
125
257
|
{item.items && showSubmenu && (
|
|
126
258
|
<SidebarMenuSub>
|
|
127
259
|
{item.items.map((subItem) => {
|
|
128
|
-
const subActive = currentPath
|
|
260
|
+
const subActive = isPathWithin(currentPath, subItem.url)
|
|
129
261
|
return (
|
|
130
262
|
<SidebarMenuSubItem key={subItem.title}>
|
|
131
263
|
<SidebarMenuSubButton asChild isActive={subActive}>
|
|
132
264
|
<Link to={subItem.url}>
|
|
133
265
|
<span>{subItem.title}</span>
|
|
266
|
+
{subItem.badge && (
|
|
267
|
+
<span className="ml-auto">
|
|
268
|
+
<subItem.badge />
|
|
269
|
+
</span>
|
|
270
|
+
)}
|
|
134
271
|
</Link>
|
|
135
272
|
</SidebarMenuSubButton>
|
|
136
273
|
</SidebarMenuSubItem>
|
|
@@ -145,11 +282,35 @@ function NavItemContent({
|
|
|
145
282
|
export function NavMain({ items, bottomItems }: { items: NavItem[]; bottomItems?: NavItem[] }) {
|
|
146
283
|
const routerState = useRouterState()
|
|
147
284
|
const currentPath = routerState.location.pathname
|
|
148
|
-
const { state } = useSidebar()
|
|
285
|
+
const { state, isMobile, setOpenMobile } = useSidebar()
|
|
149
286
|
const isCollapsed = state === 'collapsed'
|
|
287
|
+
// Close the drawer as the link is tapped. Done here rather than in an effect
|
|
288
|
+
// watching the path: this tree renders *inside* the Sheet, so it remounts on
|
|
289
|
+
// every open and an effect could not tell "just opened" from "just navigated".
|
|
290
|
+
const handleLinkTap = useCallback(
|
|
291
|
+
(event: React.MouseEvent) => {
|
|
292
|
+
if (isMobile && (event.target as HTMLElement).closest('a')) setOpenMobile(false)
|
|
293
|
+
},
|
|
294
|
+
[isMobile, setOpenMobile],
|
|
295
|
+
)
|
|
296
|
+
// One controller for the whole rail so only a single hover menu is ever open.
|
|
297
|
+
const hoverMenu = useHoverMenuController()
|
|
298
|
+
|
|
299
|
+
// Expanding the rail unmounts the dropdowns but leaves `openKey` set, so
|
|
300
|
+
// collapsing again would restore a menu the user never opened.
|
|
301
|
+
const { closeNow } = hoverMenu
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
if (!isCollapsed) closeNow()
|
|
304
|
+
}, [isCollapsed, closeNow])
|
|
150
305
|
|
|
151
306
|
return (
|
|
152
|
-
|
|
307
|
+
// The close handler sits on the list rather than each `<Link>`: those links
|
|
308
|
+
// are rendered through nested `asChild` Slots (Tooltip → SidebarMenuButton),
|
|
309
|
+
// which clone their own `onClick` over anything the child declares. A
|
|
310
|
+
// bubble-phase listener here sees the tap regardless.
|
|
311
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: delegated link taps only
|
|
312
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: links keep their own keyboard behaviour
|
|
313
|
+
<div onClick={handleLinkTap} className="contents">
|
|
153
314
|
<SidebarGroup>
|
|
154
315
|
<SidebarMenu>
|
|
155
316
|
{items.map((item) => (
|
|
@@ -158,22 +319,31 @@ export function NavMain({ items, bottomItems }: { items: NavItem[]; bottomItems?
|
|
|
158
319
|
item={item}
|
|
159
320
|
currentPath={currentPath}
|
|
160
321
|
isCollapsed={isCollapsed}
|
|
322
|
+
isMobile={isMobile}
|
|
323
|
+
hoverMenu={hoverMenu}
|
|
161
324
|
/>
|
|
162
325
|
))}
|
|
163
326
|
</SidebarMenu>
|
|
164
327
|
</SidebarGroup>
|
|
165
328
|
|
|
166
329
|
{bottomItems && bottomItems.length > 0 && (
|
|
167
|
-
|
|
330
|
+
// `mt-auto` pins Settings to the foot of the desktop rail. In a
|
|
331
|
+
// full-height drawer that strands it alone below a screen of empty
|
|
332
|
+
// space, so on mobile it simply follows the list.
|
|
333
|
+
<SidebarGroup className={cn('mb-2', !isMobile && 'mt-auto')}>
|
|
168
334
|
<SidebarMenu>
|
|
169
335
|
{bottomItems.map((item) => {
|
|
170
336
|
const isActive =
|
|
171
|
-
currentPath === item.url ||
|
|
337
|
+
currentPath === item.url ||
|
|
338
|
+
(item.url !== '/' && isPathWithin(currentPath, item.url))
|
|
172
339
|
|
|
173
340
|
return (
|
|
174
341
|
<SidebarMenuItem key={item.title}>
|
|
175
342
|
<SidebarMenuButton tooltip={item.title} asChild isActive={isActive}>
|
|
176
|
-
|
|
343
|
+
{/* `aria-label` for the same reason as the main nav above:
|
|
344
|
+
collapsed mode hides the label, and Settings lives here
|
|
345
|
+
— the one place the rail is collapsed by default. */}
|
|
346
|
+
<Link to={item.url} aria-label={isCollapsed ? item.title : undefined}>
|
|
177
347
|
<NavIcon icon={item.icon} isActive={isActive} />
|
|
178
348
|
<span>{item.title}</span>
|
|
179
349
|
</Link>
|
|
@@ -184,6 +354,6 @@ export function NavMain({ items, bottomItems }: { items: NavItem[]; bottomItems?
|
|
|
184
354
|
</SidebarMenu>
|
|
185
355
|
</SidebarGroup>
|
|
186
356
|
)}
|
|
187
|
-
|
|
357
|
+
</div>
|
|
188
358
|
)
|
|
189
359
|
}
|