@spree/dashboard-core 0.1.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/LICENSE +21 -0
- package/README.md +418 -0
- package/package.json +84 -0
- package/src/client.ts +25 -0
- package/src/components/address-form-dialog.tsx +376 -0
- package/src/components/app-sidebar.tsx +70 -0
- package/src/components/bulk-action-bar.tsx +504 -0
- package/src/components/can.tsx +30 -0
- package/src/components/country-combobox.tsx +238 -0
- package/src/components/country-state-fields.tsx +86 -0
- package/src/components/currency-select.tsx +181 -0
- package/src/components/export-button.tsx +156 -0
- package/src/components/file-upload-field.tsx +265 -0
- package/src/components/image-upload-field.tsx +53 -0
- package/src/components/import-button.tsx +202 -0
- package/src/components/locale-select.tsx +268 -0
- package/src/components/market-combobox.tsx +89 -0
- package/src/components/nav-main.tsx +186 -0
- package/src/components/page-header.tsx +286 -0
- package/src/components/page-tabs.tsx +78 -0
- package/src/components/preferences-form.tsx +404 -0
- package/src/components/resource-combobox.tsx +126 -0
- package/src/components/resource-multi-autocomplete.tsx +176 -0
- package/src/components/resource-picker-sheet.tsx +235 -0
- package/src/components/resource-table.tsx +652 -0
- package/src/components/settings-sidebar.tsx +139 -0
- package/src/components/slot.tsx +50 -0
- package/src/components/store-date-picker.tsx +18 -0
- package/src/components/store-switcher.tsx +70 -0
- package/src/components/table-toolbar.tsx +818 -0
- package/src/components/tag-combobox.tsx +99 -0
- package/src/components/top-bar.tsx +210 -0
- package/src/hooks/use-auth.ts +10 -0
- package/src/hooks/use-command-palette.tsx +34 -0
- package/src/hooks/use-countries.ts +15 -0
- package/src/hooks/use-custom-fields.ts +142 -0
- package/src/hooks/use-direct-upload.ts +104 -0
- package/src/hooks/use-display-name.ts +42 -0
- package/src/hooks/use-export.ts +75 -0
- package/src/hooks/use-global-search.ts +79 -0
- package/src/hooks/use-host-form.ts +34 -0
- package/src/hooks/use-import.ts +62 -0
- package/src/hooks/use-resource-mutation.ts +99 -0
- package/src/hooks/use-switch-admin-locale.ts +55 -0
- package/src/index.ts +97 -0
- package/src/lib/custom-field-components.ts +56 -0
- package/src/lib/download.ts +49 -0
- package/src/lib/filters-to-ransack.ts +47 -0
- package/src/lib/form-errors.ts +103 -0
- package/src/lib/form-fields-registry.ts +102 -0
- package/src/lib/form-mappers.ts +30 -0
- package/src/lib/formatters.ts +19 -0
- package/src/lib/i18n.ts +210 -0
- package/src/lib/nav-registry.ts +224 -0
- package/src/lib/permissions.ts +55 -0
- package/src/lib/query-client.ts +11 -0
- package/src/lib/query-keys.ts +69 -0
- package/src/lib/route-registry.ts +158 -0
- package/src/lib/search-registry.ts +129 -0
- package/src/lib/settings-nav-registry.ts +158 -0
- package/src/lib/slot-registry.ts +116 -0
- package/src/lib/table-registry.ts +274 -0
- package/src/lib/timestamp-columns.tsx +58 -0
- package/src/locales/ar.json +629 -0
- package/src/locales/de.json +629 -0
- package/src/locales/en.json +629 -0
- package/src/locales/fr.json +629 -0
- package/src/locales/pl.json +629 -0
- package/src/locales/zh-CN.json +629 -0
- package/src/plugin.ts +222 -0
- package/src/providers/auth-provider.tsx +188 -0
- package/src/providers/permission-provider.tsx +118 -0
- package/src/providers/store-provider.tsx +111 -0
- package/src/virtual.d.ts +5 -0
- package/src/vite/discover.ts +170 -0
- package/src/vite/index.ts +270 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Combobox,
|
|
3
|
+
ComboboxChip,
|
|
4
|
+
ComboboxChips,
|
|
5
|
+
ComboboxChipsInput,
|
|
6
|
+
ComboboxContent,
|
|
7
|
+
ComboboxEmpty,
|
|
8
|
+
ComboboxInput,
|
|
9
|
+
ComboboxItem,
|
|
10
|
+
ComboboxList,
|
|
11
|
+
ComboboxValue,
|
|
12
|
+
Select,
|
|
13
|
+
SelectContent,
|
|
14
|
+
SelectItem,
|
|
15
|
+
SelectTrigger,
|
|
16
|
+
SelectValue,
|
|
17
|
+
useComboboxAnchor,
|
|
18
|
+
} from '@spree/dashboard-ui'
|
|
19
|
+
import { useMemo, useState } from 'react'
|
|
20
|
+
import { useTranslation } from 'react-i18next'
|
|
21
|
+
import { useDisplayName } from '../hooks/use-display-name'
|
|
22
|
+
import { useStore } from '../providers/store-provider'
|
|
23
|
+
|
|
24
|
+
interface BaseProps {
|
|
25
|
+
/** Locale codes to pick from. Defaults to the current store's `supported_locales`. */
|
|
26
|
+
options?: string[]
|
|
27
|
+
/** Locale to filter out (e.g. the default locale when picking additional supported ones). */
|
|
28
|
+
excludeCode?: string
|
|
29
|
+
/** Marks the field required for screen readers + native form submission. */
|
|
30
|
+
required?: boolean
|
|
31
|
+
disabled?: boolean
|
|
32
|
+
placeholder?: string
|
|
33
|
+
/** ID for the trigger / chips input — paired with the parent `<FieldLabel htmlFor>`. */
|
|
34
|
+
id?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface SingleProps extends BaseProps {
|
|
38
|
+
multiple?: false
|
|
39
|
+
value: string | null | undefined
|
|
40
|
+
onChange: (locale: string) => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface MultiProps extends BaseProps {
|
|
44
|
+
multiple: true
|
|
45
|
+
value: string[]
|
|
46
|
+
onChange: (locales: string[]) => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type LocaleSelectProps = SingleProps | MultiProps
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolves a locale code (`en`, `pt-BR`) to its display name (`English`,
|
|
53
|
+
* `Portuguese (Brazil)`) in the admin UI language, falling back to the code
|
|
54
|
+
* itself when the runtime lacks coverage.
|
|
55
|
+
*/
|
|
56
|
+
function useLocaleDisplayName() {
|
|
57
|
+
const displayName = useDisplayName('language')
|
|
58
|
+
return (code: string) => displayName(code) ?? code
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Mirrors `CurrencySelect`'s row format: `CODE — Localized Name`. */
|
|
62
|
+
function formatOption(code: string, name: string) {
|
|
63
|
+
const upper = code.toUpperCase()
|
|
64
|
+
return name === code ? upper : `${upper} — ${name}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Above this many options a plain `<Select>` is unwieldy — switch to a
|
|
68
|
+
* searchable combobox. Matches `CurrencySelect`. */
|
|
69
|
+
const SEARCHABLE_THRESHOLD = 12
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Combobox `filter` factory shared by the single- and multi-select pickers:
|
|
73
|
+
* case-insensitive match on both the raw code and the localized `CODE — Name`
|
|
74
|
+
* label, so typing "pt", "BR", or "Portuguese" all find `pt-BR`.
|
|
75
|
+
*/
|
|
76
|
+
function localeFilter(labelFor: (locale: string) => string) {
|
|
77
|
+
return (locale: string, query: string) => {
|
|
78
|
+
const q = query.trim().toLowerCase()
|
|
79
|
+
if (!q) return true
|
|
80
|
+
return locale.toLowerCase().includes(q) || labelFor(locale).toLowerCase().includes(q)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Static text label for a locale code: `EN — English`. Use this in tables
|
|
86
|
+
* and read-only contexts where the picker isn't needed but the same
|
|
87
|
+
* formatting is. Falls back to the upper-cased code when `Intl.DisplayNames`
|
|
88
|
+
* has no entry.
|
|
89
|
+
*/
|
|
90
|
+
export function LocaleLabel({ code }: { code: string }) {
|
|
91
|
+
const displayNameFor = useLocaleDisplayName()
|
|
92
|
+
if (!code) return null
|
|
93
|
+
return <>{formatOption(code, displayNameFor(code))}</>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Picker for one of the current store's `supported_locales`. Single-select by
|
|
98
|
+
* default; pass `multiple` for a chips multi-select. Each option reads
|
|
99
|
+
* `CODE — Localized Name` (e.g. `EN — English`), localized via the browser's
|
|
100
|
+
* `Intl.DisplayNames` against the store's `default_locale`. Use this anywhere
|
|
101
|
+
* the merchant is choosing a storefront language (market default, supported
|
|
102
|
+
* locales list, customer preference, etc.).
|
|
103
|
+
*/
|
|
104
|
+
export function LocaleSelect(props: LocaleSelectProps) {
|
|
105
|
+
const { t } = useTranslation()
|
|
106
|
+
const { locales: storeLocales } = useStore()
|
|
107
|
+
const displayNameFor = useLocaleDisplayName()
|
|
108
|
+
|
|
109
|
+
// Union of `props.options` (everything the picker should show) and the
|
|
110
|
+
// currently selected values — so editing an existing record doesn't
|
|
111
|
+
// silently drop chips when the store's locale list later narrows.
|
|
112
|
+
// An empty `options` (store not loaded yet, or a backend that omits
|
|
113
|
+
// `available_locales`) falls back to the store's configured locales rather
|
|
114
|
+
// than rendering an empty picker — matching `CurrencySelect`.
|
|
115
|
+
const items = useMemo(() => {
|
|
116
|
+
const base = props.options?.length ? props.options : storeLocales
|
|
117
|
+
const selected = props.multiple ? props.value : props.value ? [props.value] : []
|
|
118
|
+
const merged = Array.from(new Set([...base, ...selected]))
|
|
119
|
+
return props.excludeCode ? merged.filter((l) => l !== props.excludeCode) : merged
|
|
120
|
+
}, [props.options, props.excludeCode, props.multiple, props.value, storeLocales])
|
|
121
|
+
|
|
122
|
+
// Precompute each option's `CODE — Name` label once. The searchable combobox
|
|
123
|
+
// below runs `renderOption` across every item on each keystroke (filter +
|
|
124
|
+
// render), so resolving `Intl.DisplayNames` per call would repeat ~N lookups
|
|
125
|
+
// per character; a lookup map keeps it to a single pass per list change.
|
|
126
|
+
const labels = useMemo(() => {
|
|
127
|
+
const map = new Map<string, string>()
|
|
128
|
+
for (const locale of items) map.set(locale, formatOption(locale, displayNameFor(locale)))
|
|
129
|
+
return map
|
|
130
|
+
}, [items, displayNameFor])
|
|
131
|
+
|
|
132
|
+
// `CODE — Name` label for a locale, from the precomputed map (falls back to a
|
|
133
|
+
// direct lookup for a value not in the current option list, e.g. a selected
|
|
134
|
+
// chip the list later dropped).
|
|
135
|
+
const labelFor = (locale: string) =>
|
|
136
|
+
labels.get(locale) ?? formatOption(locale, displayNameFor(locale))
|
|
137
|
+
|
|
138
|
+
if (props.multiple) {
|
|
139
|
+
return (
|
|
140
|
+
<MultiLocalePicker
|
|
141
|
+
id={props.id}
|
|
142
|
+
items={items}
|
|
143
|
+
value={props.value}
|
|
144
|
+
onChange={props.onChange}
|
|
145
|
+
labelFor={labelFor}
|
|
146
|
+
placeholder={props.placeholder ?? t('admin.components.locale_select.multi_placeholder')}
|
|
147
|
+
emptyText={t('admin.components.locale_select.empty')}
|
|
148
|
+
disabled={props.disabled}
|
|
149
|
+
/>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const value = props.value ?? ''
|
|
154
|
+
const placeholder = props.placeholder ?? t('admin.components.locale_select.placeholder')
|
|
155
|
+
|
|
156
|
+
// Long lists (e.g. the full set of translatable locales) are unusable as a
|
|
157
|
+
// plain dropdown — switch to a searchable combobox, matching `CurrencySelect`.
|
|
158
|
+
if (items.length > SEARCHABLE_THRESHOLD) {
|
|
159
|
+
return (
|
|
160
|
+
<Combobox
|
|
161
|
+
items={items}
|
|
162
|
+
value={value}
|
|
163
|
+
onValueChange={(next: string | null) => props.onChange(next ?? '')}
|
|
164
|
+
itemToStringLabel={(locale: string | null) => (locale ? labelFor(locale) : '')}
|
|
165
|
+
itemToStringValue={(locale: string | null) => locale ?? ''}
|
|
166
|
+
filter={localeFilter(labelFor)}
|
|
167
|
+
disabled={props.disabled}
|
|
168
|
+
>
|
|
169
|
+
<ComboboxInput id={props.id} aria-required={props.required} placeholder={placeholder} />
|
|
170
|
+
<ComboboxContent>
|
|
171
|
+
<ComboboxEmpty>{t('admin.components.locale_select.empty')}</ComboboxEmpty>
|
|
172
|
+
<ComboboxList>
|
|
173
|
+
{(locale: string) => (
|
|
174
|
+
<ComboboxItem key={locale} value={locale}>
|
|
175
|
+
{labelFor(locale)}
|
|
176
|
+
</ComboboxItem>
|
|
177
|
+
)}
|
|
178
|
+
</ComboboxList>
|
|
179
|
+
</ComboboxContent>
|
|
180
|
+
</Combobox>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<Select value={value} onValueChange={props.onChange} disabled={props.disabled}>
|
|
186
|
+
<SelectTrigger id={props.id} aria-required={props.required}>
|
|
187
|
+
{/* Base UI's `<SelectValue>` defaults to the raw locale code. Use
|
|
188
|
+
the children render-prop so the trigger shows the same
|
|
189
|
+
`CODE — Name` as the items. */}
|
|
190
|
+
<SelectValue placeholder={placeholder}>
|
|
191
|
+
{(v) => (v ? labelFor(v as string) : (v as string))}
|
|
192
|
+
</SelectValue>
|
|
193
|
+
</SelectTrigger>
|
|
194
|
+
<SelectContent>
|
|
195
|
+
{items.map((locale) => (
|
|
196
|
+
<SelectItem key={locale} value={locale}>
|
|
197
|
+
{labelFor(locale)}
|
|
198
|
+
</SelectItem>
|
|
199
|
+
))}
|
|
200
|
+
</SelectContent>
|
|
201
|
+
</Select>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function MultiLocalePicker({
|
|
206
|
+
id,
|
|
207
|
+
items,
|
|
208
|
+
value,
|
|
209
|
+
onChange,
|
|
210
|
+
labelFor,
|
|
211
|
+
placeholder,
|
|
212
|
+
emptyText,
|
|
213
|
+
disabled,
|
|
214
|
+
}: {
|
|
215
|
+
id?: string
|
|
216
|
+
items: string[]
|
|
217
|
+
value: string[]
|
|
218
|
+
onChange: (locales: string[]) => void
|
|
219
|
+
labelFor: (code: string) => string
|
|
220
|
+
placeholder: string
|
|
221
|
+
emptyText: string
|
|
222
|
+
disabled?: boolean
|
|
223
|
+
}) {
|
|
224
|
+
const anchorRef = useComboboxAnchor()
|
|
225
|
+
const [inputValue, setInputValue] = useState('')
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<Combobox
|
|
229
|
+
multiple
|
|
230
|
+
items={items}
|
|
231
|
+
value={value}
|
|
232
|
+
onValueChange={(next: string[]) => {
|
|
233
|
+
onChange(next)
|
|
234
|
+
setInputValue('')
|
|
235
|
+
}}
|
|
236
|
+
// Search the same `CODE — Name` labels as the single-select picker — the
|
|
237
|
+
// canonical locale list is too long to scan by raw code alone.
|
|
238
|
+
itemToStringLabel={(locale: string | null) => (locale ? labelFor(locale) : '')}
|
|
239
|
+
itemToStringValue={(locale: string | null) => locale ?? ''}
|
|
240
|
+
filter={localeFilter(labelFor)}
|
|
241
|
+
disabled={disabled}
|
|
242
|
+
>
|
|
243
|
+
<ComboboxChips ref={anchorRef}>
|
|
244
|
+
<ComboboxValue>
|
|
245
|
+
{(selectedLocales: string[]) =>
|
|
246
|
+
selectedLocales.map((l) => <ComboboxChip key={l}>{labelFor(l)}</ComboboxChip>)
|
|
247
|
+
}
|
|
248
|
+
</ComboboxValue>
|
|
249
|
+
<ComboboxChipsInput
|
|
250
|
+
id={id}
|
|
251
|
+
placeholder={placeholder}
|
|
252
|
+
value={inputValue}
|
|
253
|
+
onChange={(e) => setInputValue((e.target as HTMLInputElement).value)}
|
|
254
|
+
/>
|
|
255
|
+
</ComboboxChips>
|
|
256
|
+
<ComboboxContent anchor={anchorRef}>
|
|
257
|
+
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
|
258
|
+
<ComboboxList>
|
|
259
|
+
{(locale: string) => (
|
|
260
|
+
<ComboboxItem key={locale} value={locale}>
|
|
261
|
+
{labelFor(locale)}
|
|
262
|
+
</ComboboxItem>
|
|
263
|
+
)}
|
|
264
|
+
</ComboboxList>
|
|
265
|
+
</ComboboxContent>
|
|
266
|
+
</Combobox>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Market } from '@spree/admin-sdk'
|
|
2
|
+
import {
|
|
3
|
+
Combobox,
|
|
4
|
+
ComboboxChip,
|
|
5
|
+
ComboboxChips,
|
|
6
|
+
ComboboxChipsInput,
|
|
7
|
+
ComboboxContent,
|
|
8
|
+
ComboboxEmpty,
|
|
9
|
+
ComboboxItem,
|
|
10
|
+
ComboboxList,
|
|
11
|
+
ComboboxValue,
|
|
12
|
+
useComboboxAnchor,
|
|
13
|
+
} from '@spree/dashboard-ui'
|
|
14
|
+
import { useMemo, useState } from 'react'
|
|
15
|
+
import { useTranslation } from 'react-i18next'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Searchable multi-select market picker. Value is an array of prefixed
|
|
19
|
+
* market IDs; filters by name client-side against the caller-supplied
|
|
20
|
+
* `markets` list (typically the long-cached `useAllMarkets()` set in
|
|
21
|
+
* `@spree/dashboard`). Mirrors `<CountryMultiCombobox>` so the two
|
|
22
|
+
* pickers feel identical to use.
|
|
23
|
+
*/
|
|
24
|
+
export function MarketMultiCombobox({
|
|
25
|
+
markets,
|
|
26
|
+
value,
|
|
27
|
+
onValueChange,
|
|
28
|
+
placeholder,
|
|
29
|
+
emptyText,
|
|
30
|
+
disabled = false,
|
|
31
|
+
}: {
|
|
32
|
+
markets: Market[]
|
|
33
|
+
/** Selected prefixed market IDs. */
|
|
34
|
+
value: string[]
|
|
35
|
+
onValueChange: (ids: string[]) => void
|
|
36
|
+
placeholder?: string
|
|
37
|
+
emptyText?: string
|
|
38
|
+
disabled?: boolean
|
|
39
|
+
}) {
|
|
40
|
+
const { t } = useTranslation()
|
|
41
|
+
const anchorRef = useComboboxAnchor()
|
|
42
|
+
const [inputValue, setInputValue] = useState('')
|
|
43
|
+
|
|
44
|
+
// Selected IDs not yet present in `markets` (list still loading) fall
|
|
45
|
+
// back to a stub so chips don't vanish on first render.
|
|
46
|
+
const selected = useMemo<Market[]>(
|
|
47
|
+
() => value.map((id) => markets.find((m) => m.id === id) ?? ({ id, name: id } as Market)),
|
|
48
|
+
[value, markets],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Combobox
|
|
53
|
+
multiple
|
|
54
|
+
items={markets}
|
|
55
|
+
value={selected}
|
|
56
|
+
onValueChange={(next: Market[]) => {
|
|
57
|
+
onValueChange(next.map((m) => m.id))
|
|
58
|
+
setInputValue('')
|
|
59
|
+
}}
|
|
60
|
+
itemToStringLabel={(m: Market | null) => m?.name ?? ''}
|
|
61
|
+
itemToStringValue={(m: Market | null) => m?.id ?? ''}
|
|
62
|
+
isItemEqualToValue={(a: Market, b: Market) => a.id === b.id}
|
|
63
|
+
disabled={disabled}
|
|
64
|
+
>
|
|
65
|
+
<ComboboxChips ref={anchorRef}>
|
|
66
|
+
<ComboboxValue>
|
|
67
|
+
{(selectedMarkets: Market[]) =>
|
|
68
|
+
selectedMarkets.map((m) => <ComboboxChip key={m.id}>{m.name}</ComboboxChip>)
|
|
69
|
+
}
|
|
70
|
+
</ComboboxValue>
|
|
71
|
+
<ComboboxChipsInput
|
|
72
|
+
placeholder={placeholder ?? t('admin.components.market_combobox.search_placeholder')}
|
|
73
|
+
value={inputValue}
|
|
74
|
+
onChange={(e) => setInputValue((e.target as HTMLInputElement).value)}
|
|
75
|
+
/>
|
|
76
|
+
</ComboboxChips>
|
|
77
|
+
<ComboboxContent anchor={anchorRef}>
|
|
78
|
+
<ComboboxEmpty>{emptyText ?? t('admin.components.market_combobox.empty')}</ComboboxEmpty>
|
|
79
|
+
<ComboboxList>
|
|
80
|
+
{(market: Market) => (
|
|
81
|
+
<ComboboxItem key={market.id} value={market}>
|
|
82
|
+
{market.name}
|
|
83
|
+
</ComboboxItem>
|
|
84
|
+
)}
|
|
85
|
+
</ComboboxList>
|
|
86
|
+
</ComboboxContent>
|
|
87
|
+
</Combobox>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DropdownMenu,
|
|
3
|
+
DropdownMenuContent,
|
|
4
|
+
DropdownMenuItem,
|
|
5
|
+
DropdownMenuTrigger,
|
|
6
|
+
SidebarGroup,
|
|
7
|
+
SidebarMenu,
|
|
8
|
+
SidebarMenuButton,
|
|
9
|
+
SidebarMenuItem,
|
|
10
|
+
SidebarMenuSub,
|
|
11
|
+
SidebarMenuSubButton,
|
|
12
|
+
SidebarMenuSubItem,
|
|
13
|
+
useSidebar,
|
|
14
|
+
} from '@spree/dashboard-ui'
|
|
15
|
+
import { Link, useRouterState } from '@tanstack/react-router'
|
|
16
|
+
import type { LucideIcon } from 'lucide-react'
|
|
17
|
+
import { type ComponentType, useState } from 'react'
|
|
18
|
+
import type { SubjectName } from '../lib/permissions'
|
|
19
|
+
|
|
20
|
+
export type NavItem = {
|
|
21
|
+
title: string
|
|
22
|
+
url: string
|
|
23
|
+
icon: LucideIcon
|
|
24
|
+
/** CanCanCan subject required to see this item. If omitted, item is always visible. */
|
|
25
|
+
subject?: SubjectName
|
|
26
|
+
/** Component rendered after the label (e.g. a count badge). May return null. */
|
|
27
|
+
badge?: ComponentType
|
|
28
|
+
items?: { title: string; url: string; subject?: SubjectName }[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function NavIcon({ icon: Icon, isActive }: { icon: NavItem['icon']; isActive?: boolean }) {
|
|
32
|
+
return (
|
|
33
|
+
<span
|
|
34
|
+
className={
|
|
35
|
+
'inline-flex shrink-0 items-center justify-center rounded-lg p-[0.2rem] transition-colors duration-100 ' +
|
|
36
|
+
(isActive ? 'text-foreground' : 'group-hover/menu-button:text-foreground')
|
|
37
|
+
}
|
|
38
|
+
>
|
|
39
|
+
<Icon size={16} strokeWidth={2} />
|
|
40
|
+
</span>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function CollapsedDropdown({ item, children }: { item: NavItem; children: React.ReactNode }) {
|
|
45
|
+
const [open, setOpen] = useState(false)
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
49
|
+
{/* `nativeButton={false}` because the trigger renders a `<div>` wrapping
|
|
50
|
+
a `SidebarMenuButton` (whose inner element is a `<Link>` → `<a>`),
|
|
51
|
+
not a `<button>`. The wrapper exists to attach `onMouseLeave` for
|
|
52
|
+
the hover-out close, so we can't drop it; instead we tell Base UI
|
|
53
|
+
we're rendering a non-button trigger and let it apply menu role +
|
|
54
|
+
keyboard semantics. */}
|
|
55
|
+
<DropdownMenuTrigger asChild nativeButton={false} onMouseEnter={() => setOpen(true)}>
|
|
56
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: hover trigger for collapsed nav */}
|
|
57
|
+
<div onMouseLeave={() => setOpen(false)}>{children}</div>
|
|
58
|
+
</DropdownMenuTrigger>
|
|
59
|
+
<DropdownMenuContent
|
|
60
|
+
side="right"
|
|
61
|
+
align="start"
|
|
62
|
+
sideOffset={4}
|
|
63
|
+
onMouseLeave={() => setOpen(false)}
|
|
64
|
+
onMouseEnter={() => setOpen(true)}
|
|
65
|
+
>
|
|
66
|
+
{item.items!.map((subItem) => (
|
|
67
|
+
<DropdownMenuItem key={subItem.title} asChild>
|
|
68
|
+
<Link to={subItem.url} className="no-underline">
|
|
69
|
+
{subItem.title}
|
|
70
|
+
</Link>
|
|
71
|
+
</DropdownMenuItem>
|
|
72
|
+
))}
|
|
73
|
+
</DropdownMenuContent>
|
|
74
|
+
</DropdownMenu>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function NavItemContent({
|
|
79
|
+
item,
|
|
80
|
+
currentPath,
|
|
81
|
+
isCollapsed,
|
|
82
|
+
}: {
|
|
83
|
+
item: NavItem
|
|
84
|
+
currentPath: string
|
|
85
|
+
isCollapsed: boolean
|
|
86
|
+
}) {
|
|
87
|
+
const isExactActive = currentPath === item.url || currentPath === `${item.url}/`
|
|
88
|
+
// Only prefix-match for items with a sub-path after the storeId segment (e.g. /store_abc/orders)
|
|
89
|
+
const hasSubPath = item.url.split('/').filter(Boolean).length > 1
|
|
90
|
+
const isActive = isExactActive || (hasSubPath && currentPath.startsWith(item.url))
|
|
91
|
+
const hasActiveChild = item.items?.some(
|
|
92
|
+
(sub) => currentPath === sub.url || currentPath.startsWith(sub.url),
|
|
93
|
+
)
|
|
94
|
+
const showSubmenu = isActive || hasActiveChild
|
|
95
|
+
const itemIsActive = isActive || !!hasActiveChild
|
|
96
|
+
|
|
97
|
+
const button = (
|
|
98
|
+
<SidebarMenuButton
|
|
99
|
+
tooltip={!item.items || !isCollapsed ? item.title : undefined}
|
|
100
|
+
asChild
|
|
101
|
+
isActive={itemIsActive}
|
|
102
|
+
>
|
|
103
|
+
<Link to={item.url}>
|
|
104
|
+
<NavIcon icon={item.icon} isActive={itemIsActive} />
|
|
105
|
+
<span>{item.title}</span>
|
|
106
|
+
{item.badge && (
|
|
107
|
+
<span className="ml-auto">
|
|
108
|
+
<item.badge />
|
|
109
|
+
</span>
|
|
110
|
+
)}
|
|
111
|
+
</Link>
|
|
112
|
+
</SidebarMenuButton>
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<SidebarMenuItem>
|
|
117
|
+
{isCollapsed && item.items ? (
|
|
118
|
+
<CollapsedDropdown item={item}>{button}</CollapsedDropdown>
|
|
119
|
+
) : (
|
|
120
|
+
button
|
|
121
|
+
)}
|
|
122
|
+
{item.items && showSubmenu && (
|
|
123
|
+
<SidebarMenuSub>
|
|
124
|
+
{item.items.map((subItem) => {
|
|
125
|
+
const subActive = currentPath === subItem.url || currentPath.startsWith(subItem.url)
|
|
126
|
+
return (
|
|
127
|
+
<SidebarMenuSubItem key={subItem.title}>
|
|
128
|
+
<SidebarMenuSubButton asChild isActive={subActive}>
|
|
129
|
+
<Link to={subItem.url}>
|
|
130
|
+
<span>{subItem.title}</span>
|
|
131
|
+
</Link>
|
|
132
|
+
</SidebarMenuSubButton>
|
|
133
|
+
</SidebarMenuSubItem>
|
|
134
|
+
)
|
|
135
|
+
})}
|
|
136
|
+
</SidebarMenuSub>
|
|
137
|
+
)}
|
|
138
|
+
</SidebarMenuItem>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function NavMain({ items, bottomItems }: { items: NavItem[]; bottomItems?: NavItem[] }) {
|
|
143
|
+
const routerState = useRouterState()
|
|
144
|
+
const currentPath = routerState.location.pathname
|
|
145
|
+
const { state } = useSidebar()
|
|
146
|
+
const isCollapsed = state === 'collapsed'
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<>
|
|
150
|
+
<SidebarGroup>
|
|
151
|
+
<SidebarMenu>
|
|
152
|
+
{items.map((item) => (
|
|
153
|
+
<NavItemContent
|
|
154
|
+
key={item.title}
|
|
155
|
+
item={item}
|
|
156
|
+
currentPath={currentPath}
|
|
157
|
+
isCollapsed={isCollapsed}
|
|
158
|
+
/>
|
|
159
|
+
))}
|
|
160
|
+
</SidebarMenu>
|
|
161
|
+
</SidebarGroup>
|
|
162
|
+
|
|
163
|
+
{bottomItems && bottomItems.length > 0 && (
|
|
164
|
+
<SidebarGroup className="mt-auto mb-2">
|
|
165
|
+
<SidebarMenu>
|
|
166
|
+
{bottomItems.map((item) => {
|
|
167
|
+
const isActive =
|
|
168
|
+
currentPath === item.url || (item.url !== '/' && currentPath.startsWith(item.url))
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<SidebarMenuItem key={item.title}>
|
|
172
|
+
<SidebarMenuButton tooltip={item.title} asChild isActive={isActive}>
|
|
173
|
+
<Link to={item.url}>
|
|
174
|
+
<NavIcon icon={item.icon} isActive={isActive} />
|
|
175
|
+
<span>{item.title}</span>
|
|
176
|
+
</Link>
|
|
177
|
+
</SidebarMenuButton>
|
|
178
|
+
</SidebarMenuItem>
|
|
179
|
+
)
|
|
180
|
+
})}
|
|
181
|
+
</SidebarMenu>
|
|
182
|
+
</SidebarGroup>
|
|
183
|
+
)}
|
|
184
|
+
</>
|
|
185
|
+
)
|
|
186
|
+
}
|