@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,286 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BackButton,
|
|
3
|
+
Button,
|
|
4
|
+
cn,
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuItem,
|
|
8
|
+
DropdownMenuSeparator,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
useConfirm,
|
|
11
|
+
useCopyToClipboard,
|
|
12
|
+
useScrolled,
|
|
13
|
+
} from '@spree/dashboard-ui'
|
|
14
|
+
import type { JsonPreviewDrawerProps } from '@spree/dashboard-ui/spree/json-preview-drawer'
|
|
15
|
+
import { BracesIcon, CheckIcon, CopyIcon, EllipsisVerticalIcon, TrashIcon } from 'lucide-react'
|
|
16
|
+
import { lazy, type ReactNode, Suspense, useState } from 'react'
|
|
17
|
+
import { useTranslation } from 'react-i18next'
|
|
18
|
+
import { Slot } from './slot'
|
|
19
|
+
|
|
20
|
+
// JSON drawer is a developer-only feature; pulling its tree (which includes
|
|
21
|
+
// @uiw/react-json-view at ~30KB gzip) into the route bundle is wasteful.
|
|
22
|
+
// Lazy-load on first open so the chunk only ships to admins who use it.
|
|
23
|
+
// Deep subpath import bypasses the dashboard-ui barrel so the JSON view
|
|
24
|
+
// libs only live in the chunk Rollup splits off here.
|
|
25
|
+
const JsonPreviewDrawer = lazy(() =>
|
|
26
|
+
import('@spree/dashboard-ui/spree/json-preview-drawer').then((m) => ({
|
|
27
|
+
default: m.JsonPreviewDrawer,
|
|
28
|
+
})),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
/** Subset of `JsonPreviewDrawerProps` callers supply; PageHeader manages open state. */
|
|
32
|
+
export type PageHeaderJsonPreview = Pick<
|
|
33
|
+
JsonPreviewDrawerProps,
|
|
34
|
+
'title' | 'fetch' | 'endpoint' | 'resolveLink'
|
|
35
|
+
>
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resource-shaped value PageHeader inspects to wire the more-actions dropdown.
|
|
39
|
+
* Anything not present is simply omitted — no errors, no required fields beyond `id`.
|
|
40
|
+
*/
|
|
41
|
+
export interface PageHeaderResource {
|
|
42
|
+
id: string
|
|
43
|
+
/** Display number for "Copy number". Used on orders. */
|
|
44
|
+
number?: string | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface PageHeaderProps {
|
|
48
|
+
/** Main title (left of any badges). */
|
|
49
|
+
title: ReactNode
|
|
50
|
+
/** Small text below or beside the title (updated_at badge, customer email, etc.). */
|
|
51
|
+
subtitle?: ReactNode
|
|
52
|
+
/** Back button target — passed to <BackButton fallback="..."/>. Omit for top-level pages. */
|
|
53
|
+
backTo?: string
|
|
54
|
+
/** Status badges rendered next to the title. Use <StatusBadge /> for consistency. */
|
|
55
|
+
badges?: ReactNode
|
|
56
|
+
/** Primary action buttons (rightmost, before the dropdown). Most pages use this for "Save". */
|
|
57
|
+
actions?: ReactNode
|
|
58
|
+
/**
|
|
59
|
+
* Domain-specific items to render at the top of the more-actions dropdown
|
|
60
|
+
* (e.g., Complete/Approve/Cancel on orders). Rendered above the
|
|
61
|
+
* `page.actions_dropdown` slot and the auto-wired Copy ID / Delete items.
|
|
62
|
+
*/
|
|
63
|
+
dropdownItems?: ReactNode
|
|
64
|
+
/**
|
|
65
|
+
* The resource being edited. When supplied, PageHeader renders the legacy
|
|
66
|
+
* "more actions" dropdown (Copy ID, Copy number, Delete). When omitted, the
|
|
67
|
+
* dropdown is only rendered if a plugin has registered into `page.actions_dropdown`.
|
|
68
|
+
*/
|
|
69
|
+
resource?: PageHeaderResource
|
|
70
|
+
/** Slot context name. Defaults to inferring from `resource` keys. Optional. */
|
|
71
|
+
slotContext?: Record<string, unknown>
|
|
72
|
+
/**
|
|
73
|
+
* Called after the user confirms the auto-rendered Delete action.
|
|
74
|
+
* When provided, the Delete item is enabled. The confirmation prompt is
|
|
75
|
+
* fixed ("Are you sure? This action cannot be undone.") — pass `dropdownItems`
|
|
76
|
+
* directly if you need a custom delete flow.
|
|
77
|
+
*/
|
|
78
|
+
onDelete?: () => void | Promise<void>
|
|
79
|
+
/** Override the destructive label ("Delete order", "Delete product", etc.). */
|
|
80
|
+
deleteLabel?: string
|
|
81
|
+
/**
|
|
82
|
+
* When supplied, the more-actions dropdown gains a "View as JSON" item that
|
|
83
|
+
* opens a developer-style drawer with the resource payload.
|
|
84
|
+
*/
|
|
85
|
+
jsonPreview?: PageHeaderJsonPreview
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Top-of-page chrome: back button + title + badges + subtitle, with primary
|
|
90
|
+
* actions and a more-actions dropdown on the right.
|
|
91
|
+
*
|
|
92
|
+
* Mirrors `spree/admin/app/views/spree/admin/shared/_content_header.html.erb`:
|
|
93
|
+
* the legacy header yields `:page_title`, `:page_actions`, `:page_actions_dropdown`
|
|
94
|
+
* via `content_for`. Here those become props + the `page.actions` and
|
|
95
|
+
* `page.actions_dropdown` slots, so plugins extend without overriding.
|
|
96
|
+
*/
|
|
97
|
+
export function PageHeader({
|
|
98
|
+
title,
|
|
99
|
+
subtitle,
|
|
100
|
+
backTo,
|
|
101
|
+
badges,
|
|
102
|
+
actions,
|
|
103
|
+
dropdownItems,
|
|
104
|
+
resource,
|
|
105
|
+
slotContext,
|
|
106
|
+
onDelete,
|
|
107
|
+
deleteLabel,
|
|
108
|
+
jsonPreview,
|
|
109
|
+
}: PageHeaderProps) {
|
|
110
|
+
const { t } = useTranslation()
|
|
111
|
+
const slotCtx = { ...slotContext, resource }
|
|
112
|
+
const showDropdown = Boolean(resource || onDelete || dropdownItems || jsonPreview)
|
|
113
|
+
const [jsonOpen, setJsonOpen] = useState(false)
|
|
114
|
+
// Latches true on first open so the drawer (and its lazy JsonView chunk)
|
|
115
|
+
// doesn't mount until the user actually invokes it, but stays mounted
|
|
116
|
+
// afterwards so the close animation plays.
|
|
117
|
+
const [jsonEverOpened, setJsonEverOpened] = useState(false)
|
|
118
|
+
const openJson = () => {
|
|
119
|
+
setJsonEverOpened(true)
|
|
120
|
+
setJsonOpen(true)
|
|
121
|
+
}
|
|
122
|
+
const scrolled = useScrolled()
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
// Sticky below the TopBar (which sticks at `top-0`, height
|
|
126
|
+
// `--spacing-header-height`) so the title, badges, and primary actions
|
|
127
|
+
// (notably Save on form pages) stay reachable as the user scrolls long
|
|
128
|
+
// detail pages. `bg-background` masks the content scrolling behind it;
|
|
129
|
+
// `-mx-4 px-4 lg:-mx-6 lg:px-6` and `-mt-4 lg:-mt-6 pt-4 lg:pt-6` undo
|
|
130
|
+
// and re-apply the parent padding so the sticky band runs edge-to-edge
|
|
131
|
+
// and there's no transparent gap between the TopBar and the header.
|
|
132
|
+
//
|
|
133
|
+
// The `::after` pseudo-element is the bottom hairline — it fades in
|
|
134
|
+
// once the user scrolls (so the header blends at rest, separates when
|
|
135
|
+
// content slides under it). A horizontal `mask-image` gradient feathers
|
|
136
|
+
// the hairline's left/right edges to transparent so it doesn't visually
|
|
137
|
+
// collide with the page edges; the `border-border` color it carries is
|
|
138
|
+
// the same hairline used elsewhere in the app.
|
|
139
|
+
<header
|
|
140
|
+
className={cn(
|
|
141
|
+
'sticky top-header-height z-20 -mx-4 -mt-4 flex items-start gap-3 bg-background px-4 pt-4 pb-3 lg:-mx-6 lg:-mt-6 lg:px-6 lg:pt-6',
|
|
142
|
+
'after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border after:opacity-0 after:transition-opacity after:duration-150 after:[mask-image:linear-gradient(to_right,transparent,black_8%,black_92%,transparent)]',
|
|
143
|
+
scrolled && 'after:opacity-100',
|
|
144
|
+
)}
|
|
145
|
+
>
|
|
146
|
+
{backTo && <BackButton fallback={backTo} />}
|
|
147
|
+
|
|
148
|
+
{/* Title + badges share the first row; subtitle drops to its own line
|
|
149
|
+
underneath so descriptive prose doesn't get crammed alongside the
|
|
150
|
+
heading. Short metadata subtitles (customer location, order status
|
|
151
|
+
hint) read fine on the second line too. */}
|
|
152
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
153
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
154
|
+
<h1 className="text-2xl font-medium leading-tight">{title}</h1>
|
|
155
|
+
{badges}
|
|
156
|
+
</div>
|
|
157
|
+
{subtitle && <span className="text-sm text-muted-foreground">{subtitle}</span>}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div className="ml-auto flex items-center gap-2">
|
|
161
|
+
<Slot name="page.actions" context={slotCtx} />
|
|
162
|
+
{actions}
|
|
163
|
+
{showDropdown && (
|
|
164
|
+
<PageActionsDropdown
|
|
165
|
+
resource={resource}
|
|
166
|
+
slotContext={slotCtx}
|
|
167
|
+
dropdownItems={dropdownItems}
|
|
168
|
+
onDelete={onDelete}
|
|
169
|
+
deleteLabel={deleteLabel ?? t('admin.actions.delete')}
|
|
170
|
+
onOpenJson={jsonPreview ? openJson : undefined}
|
|
171
|
+
/>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{jsonPreview && jsonEverOpened && (
|
|
176
|
+
<Suspense fallback={null}>
|
|
177
|
+
<JsonPreviewDrawer open={jsonOpen} onOpenChange={setJsonOpen} {...jsonPreview} />
|
|
178
|
+
</Suspense>
|
|
179
|
+
)}
|
|
180
|
+
</header>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// More-actions dropdown
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
interface PageActionsDropdownProps {
|
|
189
|
+
resource?: PageHeaderResource
|
|
190
|
+
slotContext: Record<string, unknown>
|
|
191
|
+
dropdownItems?: ReactNode
|
|
192
|
+
onDelete?: () => void | Promise<void>
|
|
193
|
+
deleteLabel: string
|
|
194
|
+
onOpenJson?: () => void
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function PageActionsDropdown({
|
|
198
|
+
resource,
|
|
199
|
+
slotContext,
|
|
200
|
+
dropdownItems,
|
|
201
|
+
onDelete,
|
|
202
|
+
deleteLabel,
|
|
203
|
+
onOpenJson,
|
|
204
|
+
}: PageActionsDropdownProps) {
|
|
205
|
+
const { t } = useTranslation()
|
|
206
|
+
const confirm = useConfirm()
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<DropdownMenu>
|
|
210
|
+
<DropdownMenuTrigger asChild>
|
|
211
|
+
<Button size="icon-sm" variant="outline" aria-label={t('admin.actions.more_actions')}>
|
|
212
|
+
<EllipsisVerticalIcon className="size-4" />
|
|
213
|
+
</Button>
|
|
214
|
+
</DropdownMenuTrigger>
|
|
215
|
+
<DropdownMenuContent align="end">
|
|
216
|
+
{dropdownItems}
|
|
217
|
+
<Slot name="page.actions_dropdown" context={slotContext} />
|
|
218
|
+
|
|
219
|
+
{onOpenJson && (
|
|
220
|
+
<DropdownMenuItem onClick={onOpenJson}>
|
|
221
|
+
<BracesIcon className="size-4" />
|
|
222
|
+
{t('admin.components.page_header.view_as_json')}
|
|
223
|
+
</DropdownMenuItem>
|
|
224
|
+
)}
|
|
225
|
+
{resource?.number && (
|
|
226
|
+
<CopyToClipboardItem
|
|
227
|
+
label={t('admin.components.page_header.copy_number')}
|
|
228
|
+
value={resource.number}
|
|
229
|
+
/>
|
|
230
|
+
)}
|
|
231
|
+
{resource && (
|
|
232
|
+
<CopyToClipboardItem
|
|
233
|
+
label={t('admin.components.page_header.copy_id')}
|
|
234
|
+
value={resource.id}
|
|
235
|
+
/>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{onDelete && (
|
|
239
|
+
<>
|
|
240
|
+
<DropdownMenuSeparator />
|
|
241
|
+
<DropdownMenuItem
|
|
242
|
+
variant="destructive"
|
|
243
|
+
onClick={async () => {
|
|
244
|
+
if (
|
|
245
|
+
await confirm({
|
|
246
|
+
message: t('admin.common.delete_confirm_message'),
|
|
247
|
+
variant: 'destructive',
|
|
248
|
+
confirmLabel: deleteLabel,
|
|
249
|
+
})
|
|
250
|
+
) {
|
|
251
|
+
await onDelete()
|
|
252
|
+
}
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
<TrashIcon className="size-4" />
|
|
256
|
+
{deleteLabel}
|
|
257
|
+
</DropdownMenuItem>
|
|
258
|
+
</>
|
|
259
|
+
)}
|
|
260
|
+
</DropdownMenuContent>
|
|
261
|
+
</DropdownMenu>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Copy-to-clipboard menu item
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
function CopyToClipboardItem({ label, value }: { label: string; value: string }) {
|
|
270
|
+
const { t } = useTranslation()
|
|
271
|
+
const { copied, copy } = useCopyToClipboard()
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<DropdownMenuItem
|
|
275
|
+
// Keep the menu open after click so the user sees the confirmation flash.
|
|
276
|
+
closeOnClick={false}
|
|
277
|
+
onClick={(e) => {
|
|
278
|
+
e.preventDefault()
|
|
279
|
+
copy(value)
|
|
280
|
+
}}
|
|
281
|
+
>
|
|
282
|
+
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
|
283
|
+
{copied ? t('admin.actions.copied') : label}
|
|
284
|
+
</DropdownMenuItem>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { cn } from '@spree/dashboard-ui'
|
|
2
|
+
import { Link, type LinkProps, useLocation, useRouter } from '@tanstack/react-router'
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import { Slot } from './slot'
|
|
5
|
+
|
|
6
|
+
export interface PageTab {
|
|
7
|
+
/** Stable key used for React lists. */
|
|
8
|
+
key: string
|
|
9
|
+
/** Visible label. */
|
|
10
|
+
label: ReactNode
|
|
11
|
+
/** Link target — same shape as TanStack Router <Link>. */
|
|
12
|
+
to: LinkProps['to']
|
|
13
|
+
/** Path/search params for the target route. */
|
|
14
|
+
params?: LinkProps['params']
|
|
15
|
+
search?: LinkProps['search']
|
|
16
|
+
/**
|
|
17
|
+
* Match strategy. `"exact"` (default) matches only when the pathname is
|
|
18
|
+
* identical; `"prefix"` matches when the current pathname starts with `to`.
|
|
19
|
+
*/
|
|
20
|
+
match?: 'exact' | 'prefix'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface PageTabsProps {
|
|
24
|
+
tabs: PageTab[]
|
|
25
|
+
/** Slot name for plugin-injected tabs. Default `'page.tabs'`. */
|
|
26
|
+
slotName?: string
|
|
27
|
+
/** Slot context. Receives `{ tabs }` so plugins can inspect built-ins. */
|
|
28
|
+
slotContext?: Record<string, unknown>
|
|
29
|
+
className?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sub-route–aware tab strip. Each tab is a TanStack Router `<Link>`; the
|
|
34
|
+
* active tab is the one whose `to` matches the current pathname.
|
|
35
|
+
*
|
|
36
|
+
* Replaces `shared/_page_tabs.html.erb` and the various `_*_nav.html.erb`
|
|
37
|
+
* partials. Plugins extend by registering into the `page.tabs` slot.
|
|
38
|
+
*/
|
|
39
|
+
export function PageTabs({ tabs, slotName = 'page.tabs', slotContext, className }: PageTabsProps) {
|
|
40
|
+
const location = useLocation()
|
|
41
|
+
const router = useRouter()
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<nav className={cn('flex items-center gap-1 border-b border-border', className)}>
|
|
45
|
+
{tabs.map((tab) => {
|
|
46
|
+
// Resolve to an absolute pathname so prefix-mode doesn't match every tab
|
|
47
|
+
// when `to` is an object/relative form. Mirrors what <Link> itself does.
|
|
48
|
+
const target =
|
|
49
|
+
typeof tab.to === 'string'
|
|
50
|
+
? tab.to
|
|
51
|
+
: router.buildLocation({ to: tab.to, params: tab.params, search: tab.search }).pathname
|
|
52
|
+
const active =
|
|
53
|
+
tab.match === 'prefix'
|
|
54
|
+
? location.pathname.startsWith(target)
|
|
55
|
+
: location.pathname === target
|
|
56
|
+
return (
|
|
57
|
+
<Link
|
|
58
|
+
key={tab.key}
|
|
59
|
+
// <Link> generics need a literal route path; PageTab is a generic
|
|
60
|
+
// declarative API where `to` is unknown at this site.
|
|
61
|
+
to={tab.to as never}
|
|
62
|
+
params={tab.params}
|
|
63
|
+
search={tab.search}
|
|
64
|
+
className={cn(
|
|
65
|
+
'inline-flex items-center px-3 py-2 -mb-px text-sm font-medium border-b-2 transition-colors',
|
|
66
|
+
active
|
|
67
|
+
? 'border-foreground text-foreground'
|
|
68
|
+
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border',
|
|
69
|
+
)}
|
|
70
|
+
>
|
|
71
|
+
{tab.label}
|
|
72
|
+
</Link>
|
|
73
|
+
)
|
|
74
|
+
})}
|
|
75
|
+
<Slot name={slotName} context={{ tabs, ...slotContext }} />
|
|
76
|
+
</nav>
|
|
77
|
+
)
|
|
78
|
+
}
|