@fayz-ai/ui 0.9.0 → 0.10.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/README.md +2 -2
- package/dist/analytics/AnalyticsCard.d.ts +10 -0
- package/dist/analytics/AnalyticsCard.d.ts.map +1 -0
- package/dist/analytics/AnalyticsControls.d.ts +18 -0
- package/dist/analytics/AnalyticsControls.d.ts.map +1 -0
- package/dist/analytics/AnalyticsGrid.d.ts +16 -0
- package/dist/analytics/AnalyticsGrid.d.ts.map +1 -0
- package/dist/analytics/context.d.ts +58 -0
- package/dist/analytics/context.d.ts.map +1 -0
- package/dist/analytics/derive.d.ts +81 -0
- package/dist/analytics/derive.d.ts.map +1 -0
- package/dist/analytics/index.d.ts +13 -0
- package/dist/analytics/index.d.ts.map +1 -0
- package/dist/analytics/range.d.ts +22 -0
- package/dist/analytics/range.d.ts.map +1 -0
- package/dist/{chunk-PVIB724L.js → chunk-22EVFMS3.js} +5 -3
- package/dist/chunk-22EVFMS3.js.map +1 -0
- package/dist/{chunk-G6OKZ7WL.js → chunk-6V5MGYXH.js} +13 -9
- package/dist/chunk-6V5MGYXH.js.map +1 -0
- package/dist/{chunk-KV3MHB6N.js → chunk-H63TOSQ7.js} +196 -53
- package/dist/chunk-H63TOSQ7.js.map +1 -0
- package/dist/{chunk-OUPJDGKE.js → chunk-I6CPKAOD.js} +134 -37
- package/dist/chunk-I6CPKAOD.js.map +1 -0
- package/dist/{chunk-YRVE4FOD.js → chunk-OKZ6WQB3.js} +3 -3
- package/dist/chunk-OKZ6WQB3.js.map +1 -0
- package/dist/{chunk-S5KNM4D2.js → chunk-SEIY2F5F.js} +235 -107
- package/dist/chunk-SEIY2F5F.js.map +1 -0
- package/dist/{chunk-75EFDLST.js → chunk-VAKWUUVC.js} +157 -6
- package/dist/chunk-VAKWUUVC.js.map +1 -0
- package/dist/dashboard/OnboardingWidget.d.ts +2 -14
- package/dist/dashboard/OnboardingWidget.d.ts.map +1 -1
- package/dist/dashboard/SidebarOnboarding.d.ts +28 -0
- package/dist/dashboard/SidebarOnboarding.d.ts.map +1 -0
- package/dist/dashboard/index.d.ts +2 -0
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +4 -4
- package/dist/dashboard/types.d.ts +18 -0
- package/dist/dashboard/types.d.ts.map +1 -1
- package/dist/dashboard/useOnboardingProgress.d.ts +28 -0
- package/dist/dashboard/useOnboardingProgress.d.ts.map +1 -0
- package/dist/icons.js +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +960 -14
- package/dist/index.js.map +1 -1
- package/dist/layout/AppShell.d.ts +14 -1
- package/dist/layout/AppShell.d.ts.map +1 -1
- package/dist/layout/ModulePage.d.ts +11 -2
- package/dist/layout/ModulePage.d.ts.map +1 -1
- package/dist/layout/Sidebar.d.ts +8 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/index.js +4 -4
- package/dist/lib/cn.d.ts.map +1 -0
- package/dist/preset.d.ts +3 -0
- package/dist/preset.d.ts.map +1 -0
- package/dist/primitives/data-table.d.ts +19 -4
- package/dist/primitives/data-table.d.ts.map +1 -1
- package/dist/primitives/date-picker.d.ts +4 -2
- package/dist/primitives/date-picker.d.ts.map +1 -1
- package/dist/primitives/filter-chip.d.ts +26 -0
- package/dist/primitives/filter-chip.d.ts.map +1 -0
- package/dist/primitives/header-tabs.d.ts +32 -0
- package/dist/primitives/header-tabs.d.ts.map +1 -0
- package/dist/primitives/hint.d.ts +25 -0
- package/dist/primitives/hint.d.ts.map +1 -0
- package/dist/primitives/index.d.ts +7 -0
- package/dist/primitives/index.d.ts.map +1 -1
- package/dist/primitives/index.js +4 -4
- package/dist/primitives/list-view.d.ts +14 -1
- package/dist/primitives/list-view.d.ts.map +1 -1
- package/dist/primitives/page-header.d.ts +23 -0
- package/dist/primitives/page-header.d.ts.map +1 -0
- package/dist/primitives/stat-tiles.d.ts +30 -0
- package/dist/primitives/stat-tiles.d.ts.map +1 -0
- package/dist/primitives/switch.d.ts +11 -0
- package/dist/primitives/switch.d.ts.map +1 -0
- package/dist/stores/index.d.ts +1 -0
- package/dist/stores/index.d.ts.map +1 -1
- package/dist/stores/page-header.store.d.ts +69 -0
- package/dist/stores/page-header.store.d.ts.map +1 -0
- package/package.json +4 -13
- package/dist/chunk-75EFDLST.js.map +0 -1
- package/dist/chunk-G6OKZ7WL.js.map +0 -1
- package/dist/chunk-KV3MHB6N.js.map +0 -1
- package/dist/chunk-OUPJDGKE.js.map +0 -1
- package/dist/chunk-PVIB724L.js.map +0 -1
- package/dist/chunk-S5KNM4D2.js.map +0 -1
- package/dist/chunk-YRVE4FOD.js.map +0 -1
- package/dist/utils/cn.d.ts.map +0 -1
- package/src/dashboard/ChartWidget.tsx +0 -50
- package/src/dashboard/ChartWidgetInner.tsx +0 -83
- package/src/dashboard/DashboardCanvas.tsx +0 -210
- package/src/dashboard/DashboardCustomizeMenu.tsx +0 -53
- package/src/dashboard/DashboardGrid.tsx +0 -35
- package/src/dashboard/DashboardRange.tsx +0 -62
- package/src/dashboard/GoldCard.tsx +0 -38
- package/src/dashboard/KpiCard.tsx +0 -238
- package/src/dashboard/OnboardingWidget.tsx +0 -140
- package/src/dashboard/TableWidget.tsx +0 -51
- package/src/dashboard/define.ts +0 -53
- package/src/dashboard/icon.ts +0 -13
- package/src/dashboard/index.ts +0 -38
- package/src/dashboard/layout.ts +0 -50
- package/src/dashboard/preferences.ts +0 -71
- package/src/dashboard/types.ts +0 -41
- package/src/icons.ts +0 -66
- package/src/index.ts +0 -238
- package/src/layout/AppShell.tsx +0 -987
- package/src/layout/ContentSplit.tsx +0 -153
- package/src/layout/ModulePage.tsx +0 -466
- package/src/layout/PageTransition.tsx +0 -57
- package/src/layout/SaveBar.tsx +0 -247
- package/src/layout/Sidebar.tsx +0 -535
- package/src/layout/Topbar.tsx +0 -462
- package/src/layout/index.ts +0 -39
- package/src/layout/useCrossModuleBack.ts +0 -30
- package/src/primitives/Avatar.tsx +0 -48
- package/src/primitives/SearchCombobox.tsx +0 -216
- package/src/primitives/badge.tsx +0 -68
- package/src/primitives/breadcrumb.tsx +0 -43
- package/src/primitives/button.tsx +0 -60
- package/src/primitives/card.tsx +0 -79
- package/src/primitives/checkbox.tsx +0 -53
- package/src/primitives/confirm-dialog.tsx +0 -125
- package/src/primitives/currency-input.tsx +0 -121
- package/src/primitives/data-table.tsx +0 -160
- package/src/primitives/date-picker.tsx +0 -225
- package/src/primitives/dropdown.tsx +0 -196
- package/src/primitives/index.ts +0 -87
- package/src/primitives/input.tsx +0 -50
- package/src/primitives/list-view.tsx +0 -252
- package/src/primitives/markdown-editor.tsx +0 -75
- package/src/primitives/markdown.tsx +0 -169
- package/src/primitives/modal.tsx +0 -156
- package/src/primitives/phone-link.tsx +0 -138
- package/src/primitives/popover.tsx +0 -30
- package/src/primitives/search-select.tsx +0 -314
- package/src/primitives/segmented-control.tsx +0 -66
- package/src/primitives/select.tsx +0 -157
- package/src/primitives/separator.tsx +0 -29
- package/src/primitives/sheet.tsx +0 -181
- package/src/primitives/skeleton.tsx +0 -46
- package/src/primitives/tabs.tsx +0 -53
- package/src/primitives/time-picker.tsx +0 -148
- package/src/primitives/toast.tsx +0 -46
- package/src/primitives/tooltip.tsx +0 -28
- package/src/stores/index.ts +0 -10
- package/src/stores/layout.store.ts +0 -31
- package/src/stores/theme.store.ts +0 -457
- package/src/styles.css +0 -461
- package/src/tailwind.ts +0 -43
- package/src/theme/index.ts +0 -94
- package/src/theme/preset.ts +0 -209
- package/src/theme/presets.ts +0 -106
- package/src/utils/cn.ts +0 -6
- /package/dist/{utils → lib}/cn.d.ts +0 -0
package/src/layout/AppShell.tsx
DELETED
|
@@ -1,987 +0,0 @@
|
|
|
1
|
-
import * as React from 'react'
|
|
2
|
-
import {
|
|
3
|
-
Menu,
|
|
4
|
-
Home,
|
|
5
|
-
Receipt,
|
|
6
|
-
ArrowLeftRight,
|
|
7
|
-
Calendar,
|
|
8
|
-
CreditCard,
|
|
9
|
-
Settings,
|
|
10
|
-
MessageCircle,
|
|
11
|
-
Wallet,
|
|
12
|
-
PiggyBank,
|
|
13
|
-
Plus,
|
|
14
|
-
type LucideIcon,
|
|
15
|
-
} from 'lucide-react'
|
|
16
|
-
import { Sidebar, type NavigationItem, type SidebarUser, type SidebarProps } from './Sidebar'
|
|
17
|
-
import { Topbar } from './Topbar'
|
|
18
|
-
import { SaveBar, SaveBarProvider } from './SaveBar'
|
|
19
|
-
import { useLayoutStore } from '../stores/layout.store'
|
|
20
|
-
import { cn } from '../utils/cn'
|
|
21
|
-
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
// Module header slot — the page top-bar exposes a DOM node that module pages
|
|
24
|
-
// (ModulePage 'tabs' variant) portal their title + sub-nav into, so the module
|
|
25
|
-
// header lives in the shell's top-of-page container (GoHighLevel style).
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
|
|
28
|
-
const ModuleHeaderSlotContext = React.createContext<HTMLElement | null>(null)
|
|
29
|
-
|
|
30
|
-
export function useModuleHeaderSlot(): HTMLElement | null {
|
|
31
|
-
return React.useContext(ModuleHeaderSlotContext)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Right-aligned slot for a page's primary action buttons (e.g. "New campaign").
|
|
35
|
-
const ModuleHeaderActionsSlotContext = React.createContext<HTMLElement | null>(null)
|
|
36
|
-
|
|
37
|
-
export function useModuleHeaderActionsSlot(): HTMLElement | null {
|
|
38
|
-
return React.useContext(ModuleHeaderActionsSlotContext)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
// Types
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
|
|
45
|
-
export interface AppShellUser {
|
|
46
|
-
fullName: string
|
|
47
|
-
avatarUrl?: string
|
|
48
|
-
email: string
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
// Bottom-nav item model
|
|
53
|
-
//
|
|
54
|
-
// Two kinds share the mobile tab bar:
|
|
55
|
-
// - route (default): a normal tab that navigates to `route`.
|
|
56
|
-
// - action: a NON-route item rendered as a raised, elevated circular center
|
|
57
|
-
// button (Mobills style). It fires `onBottomNavAction(id)` instead of
|
|
58
|
-
// navigating — e.g. a global quick-add "+".
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
|
|
61
|
-
export interface BottomNavRouteItem {
|
|
62
|
-
kind?: 'route'
|
|
63
|
-
label: string
|
|
64
|
-
icon: string
|
|
65
|
-
route: string
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export interface BottomNavActionItem {
|
|
69
|
-
kind: 'action'
|
|
70
|
-
/** Identifier passed to onBottomNavAction. */
|
|
71
|
-
id: string
|
|
72
|
-
icon: string
|
|
73
|
-
/** Optional accessible label. */
|
|
74
|
-
label?: string
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export type BottomNavItem = BottomNavRouteItem | BottomNavActionItem
|
|
78
|
-
|
|
79
|
-
/** Mobile header treatment (small screens, <md). Desktop is unaffected.
|
|
80
|
-
* - 'minimal' : a small top header bar (the default / today's behavior).
|
|
81
|
-
* - 'transparent' : NO header bar; content is edge-to-edge with a floating
|
|
82
|
-
* profile avatar pinned top-right (calls onProfile).
|
|
83
|
-
* - 'hidden' : no header bar and no floating avatar. */
|
|
84
|
-
export type MobileHeaderVariant = 'transparent' | 'minimal' | 'hidden'
|
|
85
|
-
|
|
86
|
-
export interface AppShellProps {
|
|
87
|
-
/** Layout variant */
|
|
88
|
-
variant: 'sidebar' | 'topbar' | 'minimal'
|
|
89
|
-
/** Wrap the main content in an inset framed card. The sidebar is always
|
|
90
|
-
* flush/full-height (GoHighLevel style). */
|
|
91
|
-
contentFrame?: boolean
|
|
92
|
-
navigation?: NavigationItem[]
|
|
93
|
-
logo?: React.ReactNode
|
|
94
|
-
user?: AppShellUser
|
|
95
|
-
children?: React.ReactNode
|
|
96
|
-
onNavigate?: (route: string) => void
|
|
97
|
-
onSignOut?: () => void
|
|
98
|
-
onProfile?: () => void
|
|
99
|
-
onSettings?: () => void
|
|
100
|
-
onBilling?: () => void
|
|
101
|
-
/** Current-plan pill shown next to the user in the topbar menu. */
|
|
102
|
-
userPlan?: { label: string; paid: boolean }
|
|
103
|
-
/** i18n label for the billing/subscription user-menu item. */
|
|
104
|
-
billingLabel?: string
|
|
105
|
-
pageTitle?: string
|
|
106
|
-
currentPath?: string
|
|
107
|
-
userMenuExtras?: { label: string; icon?: React.ReactNode; onClick: () => void }[]
|
|
108
|
-
orgSwitcher?: React.ReactNode
|
|
109
|
-
topbarStart?: React.ReactNode
|
|
110
|
-
topbarEnd?: React.ReactNode
|
|
111
|
-
sidebarTopContent?: React.ReactNode
|
|
112
|
-
sidebarFooterContent?: React.ReactNode
|
|
113
|
-
bottomNav?: BottomNavItem[]
|
|
114
|
-
/** Fired when a bottom-nav `action` item (e.g. the center "+") is tapped. */
|
|
115
|
-
onBottomNavAction?: (id: string) => void
|
|
116
|
-
/** Mobile header treatment (<md). Default: 'minimal'. Desktop unaffected. */
|
|
117
|
-
mobileHeader?: MobileHeaderVariant
|
|
118
|
-
/** Rendered in sidebar bottom user slot */
|
|
119
|
-
userMenuSlot?: React.ReactNode
|
|
120
|
-
/** Rendered in sidebar bottom notification slot */
|
|
121
|
-
notificationSlot?: React.ReactNode
|
|
122
|
-
/** Unread notification count */
|
|
123
|
-
unreadCount?: number
|
|
124
|
-
/** Optional headings above the sidebar nav sections (contextual sidebars). */
|
|
125
|
-
sidebarSectionLabels?: { main?: string; secondary?: string; settings?: string }
|
|
126
|
-
/** Identity of the current sidebar context — changing it animates the rail
|
|
127
|
-
* swap as a push (new nav in, previous nav out). */
|
|
128
|
-
sidebarNavKey?: string
|
|
129
|
-
/** Push direction for a `sidebarNavKey` change. Default 'forward'. */
|
|
130
|
-
sidebarNavDirection?: 'forward' | 'back'
|
|
131
|
-
/**
|
|
132
|
-
* Full-height column pinned to the right (the assistant dock, a tasks
|
|
133
|
-
* panel). Rendered as a flex SIBLING of the layout, so the app narrows to
|
|
134
|
-
* make room instead of being covered — the difference between a split view
|
|
135
|
-
* and a modal that happens to be tall.
|
|
136
|
-
*
|
|
137
|
-
* Falsy renders nothing and costs nothing: no wrapper, no divider, the
|
|
138
|
-
* layout element stands alone exactly as before.
|
|
139
|
-
*/
|
|
140
|
-
rightRail?: React.ReactNode
|
|
141
|
-
/** Whether the rail is showing. Separate from `rightRail` so the column can
|
|
142
|
-
* animate its width instead of snapping when it closes. */
|
|
143
|
-
rightRailOpen?: boolean
|
|
144
|
-
/** Starting width in px. Ignored once the user drags. Default 380. */
|
|
145
|
-
defaultRightRailWidth?: number
|
|
146
|
-
/** Fired after every drag so the host can persist the preference. */
|
|
147
|
-
onRightRailWidthChange?: (width: number) => void
|
|
148
|
-
/** Drag bounds in px. Defaults 300 / 720. */
|
|
149
|
-
rightRailMinWidth?: number
|
|
150
|
-
rightRailMaxWidth?: number
|
|
151
|
-
/** Accessible name for the drag handle. */
|
|
152
|
-
rightRailResizeLabel?: string
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
// Sidebar layout
|
|
157
|
-
// ---------------------------------------------------------------------------
|
|
158
|
-
|
|
159
|
-
function SidebarLayout({
|
|
160
|
-
navigation = [],
|
|
161
|
-
logo,
|
|
162
|
-
user,
|
|
163
|
-
children,
|
|
164
|
-
onNavigate,
|
|
165
|
-
onSignOut,
|
|
166
|
-
onProfile,
|
|
167
|
-
onSettings,
|
|
168
|
-
onBilling,
|
|
169
|
-
userMenuExtras: _userMenuExtras,
|
|
170
|
-
orgSwitcher,
|
|
171
|
-
sidebarTopContent,
|
|
172
|
-
sidebarFooterContent,
|
|
173
|
-
frame,
|
|
174
|
-
currentPath,
|
|
175
|
-
pageTitle,
|
|
176
|
-
topbarStart,
|
|
177
|
-
topbarEnd,
|
|
178
|
-
userMenuSlot,
|
|
179
|
-
notificationSlot,
|
|
180
|
-
unreadCount = 0,
|
|
181
|
-
hasBottomNav = false,
|
|
182
|
-
mobileHeader = 'minimal',
|
|
183
|
-
sidebarSectionLabels,
|
|
184
|
-
sidebarNavKey,
|
|
185
|
-
sidebarNavDirection,
|
|
186
|
-
}: {
|
|
187
|
-
navigation?: NavigationItem[]
|
|
188
|
-
logo?: React.ReactNode
|
|
189
|
-
user?: AppShellUser
|
|
190
|
-
children?: React.ReactNode
|
|
191
|
-
onNavigate?: (route: string) => void
|
|
192
|
-
onSignOut?: () => void
|
|
193
|
-
onProfile?: () => void
|
|
194
|
-
onSettings?: () => void
|
|
195
|
-
onBilling?: () => void
|
|
196
|
-
userMenuExtras?: AppShellProps['userMenuExtras']
|
|
197
|
-
orgSwitcher?: React.ReactNode
|
|
198
|
-
sidebarTopContent?: React.ReactNode
|
|
199
|
-
sidebarFooterContent?: React.ReactNode
|
|
200
|
-
frame?: boolean
|
|
201
|
-
currentPath?: string
|
|
202
|
-
pageTitle?: string
|
|
203
|
-
topbarStart?: React.ReactNode
|
|
204
|
-
topbarEnd?: React.ReactNode
|
|
205
|
-
userMenuSlot?: React.ReactNode
|
|
206
|
-
notificationSlot?: React.ReactNode
|
|
207
|
-
unreadCount?: number
|
|
208
|
-
/** When a mobile bottom-nav bar is present, the mobile hamburger is
|
|
209
|
-
* suppressed so the bottom nav is the primary mobile navigation. */
|
|
210
|
-
hasBottomNav?: boolean
|
|
211
|
-
/** Mobile header treatment (<md). Desktop keeps its top bar untouched. */
|
|
212
|
-
mobileHeader?: MobileHeaderVariant
|
|
213
|
-
sidebarSectionLabels?: AppShellProps['sidebarSectionLabels']
|
|
214
|
-
sidebarNavKey?: string
|
|
215
|
-
sidebarNavDirection?: 'forward' | 'back'
|
|
216
|
-
}) {
|
|
217
|
-
const sidebarCollapsed = useLayoutStore((s) => s.sidebarCollapsed)
|
|
218
|
-
const setSidebarCollapsed = useLayoutStore((s) => s.setSidebarCollapsed)
|
|
219
|
-
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false)
|
|
220
|
-
const [headerSlot, setHeaderSlot] = React.useState<HTMLElement | null>(null)
|
|
221
|
-
const [actionsSlot, setActionsSlot] = React.useState<HTMLElement | null>(null)
|
|
222
|
-
|
|
223
|
-
return (
|
|
224
|
-
<SaveBarProvider>
|
|
225
|
-
<ModuleHeaderSlotContext.Provider value={headerSlot}>
|
|
226
|
-
<ModuleHeaderActionsSlotContext.Provider value={actionsSlot}>
|
|
227
|
-
<div className={cn(
|
|
228
|
-
'flex h-screen overflow-hidden bg-background',
|
|
229
|
-
// Frame mode: the inset gap around the framed card takes the sidebar (menu)
|
|
230
|
-
// color, so the white card reads as a panel floating on the same tone as the
|
|
231
|
-
// left menu. The card's own md:m-2 margin reveals this behind it.
|
|
232
|
-
frame && 'md:bg-sidebar',
|
|
233
|
-
)}>
|
|
234
|
-
{/* Desktop Sidebar — always flush / full-height (GoHighLevel style) */}
|
|
235
|
-
<div className="hidden md:flex">
|
|
236
|
-
<Sidebar
|
|
237
|
-
navigation={navigation}
|
|
238
|
-
logo={logo}
|
|
239
|
-
collapsed={sidebarCollapsed}
|
|
240
|
-
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
|
241
|
-
onNavigate={onNavigate}
|
|
242
|
-
currentPath={currentPath}
|
|
243
|
-
user={user}
|
|
244
|
-
onSignOut={onSignOut}
|
|
245
|
-
onProfile={onProfile}
|
|
246
|
-
onSettings={onSettings}
|
|
247
|
-
onBilling={onBilling}
|
|
248
|
-
orgSwitcher={orgSwitcher}
|
|
249
|
-
topContent={sidebarTopContent}
|
|
250
|
-
footerContent={sidebarFooterContent}
|
|
251
|
-
borderless={false}
|
|
252
|
-
userMenuSlot={userMenuSlot}
|
|
253
|
-
notificationSlot={notificationSlot}
|
|
254
|
-
unreadCount={unreadCount}
|
|
255
|
-
sectionLabels={sidebarSectionLabels}
|
|
256
|
-
navKey={sidebarNavKey}
|
|
257
|
-
navDirection={sidebarNavDirection}
|
|
258
|
-
/>
|
|
259
|
-
</div>
|
|
260
|
-
|
|
261
|
-
{/* Main content — optionally wrapped in an inset framed card.
|
|
262
|
-
The inset/rounding is suppressed on mobile so the content stays
|
|
263
|
-
edge-to-edge (the framed card only reads well on larger screens). */}
|
|
264
|
-
<div className={cn(
|
|
265
|
-
'relative flex flex-1 flex-col overflow-hidden',
|
|
266
|
-
frame && 'md:m-2 md:rounded-xl md:border md:border-border md:bg-card'
|
|
267
|
-
)}>
|
|
268
|
-
{/* Top bar — mobile hamburger + module header slot (title + sub-nav) + actions.
|
|
269
|
-
On mobile the bar is suppressed for the 'transparent' / 'hidden' header
|
|
270
|
-
treatments (content goes edge-to-edge); desktop keeps it always. The
|
|
271
|
-
portal targets below stay mounted (display:none on mobile) so module
|
|
272
|
-
pages can still portal their sub-nav on desktop without crashing. */}
|
|
273
|
-
<div className={cn(
|
|
274
|
-
'flex min-h-14 shrink-0 items-center justify-between gap-3 border-b border-border bg-card px-4 py-2',
|
|
275
|
-
mobileHeader !== 'minimal' && 'hidden md:flex',
|
|
276
|
-
)}>
|
|
277
|
-
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
278
|
-
{/* Mobile hamburger — suppressed when a bottom-nav bar owns mobile
|
|
279
|
-
navigation (mobile-first apps). Desktop sidebar is untouched. */}
|
|
280
|
-
{!hasBottomNav && (
|
|
281
|
-
<button
|
|
282
|
-
onClick={() => setMobileMenuOpen(true)}
|
|
283
|
-
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent md:hidden"
|
|
284
|
-
aria-label="Open menu"
|
|
285
|
-
>
|
|
286
|
-
<Menu className="h-5 w-5" />
|
|
287
|
-
</button>
|
|
288
|
-
)}
|
|
289
|
-
{pageTitle && <h2 className="font-semibold text-lg">{pageTitle}</h2>}
|
|
290
|
-
{topbarStart}
|
|
291
|
-
{/* Module pages (tabs variant) portal their sub-nav here */}
|
|
292
|
-
<div ref={setHeaderSlot} className="flex min-w-0 flex-1 items-center" />
|
|
293
|
-
</div>
|
|
294
|
-
<div className="flex shrink-0 items-center gap-2">
|
|
295
|
-
{/* Pages portal their primary action buttons here */}
|
|
296
|
-
<div ref={setActionsSlot} className="flex items-center gap-2" />
|
|
297
|
-
{topbarEnd}
|
|
298
|
-
</div>
|
|
299
|
-
</div>
|
|
300
|
-
|
|
301
|
-
{/* Page content */}
|
|
302
|
-
<main className="min-w-0 flex-1 overflow-y-auto pb-16 md:pb-0">
|
|
303
|
-
{children}
|
|
304
|
-
</main>
|
|
305
|
-
|
|
306
|
-
{/* App-wide floating SaveBar (spans the content column, not the sidebar) */}
|
|
307
|
-
<SaveBar />
|
|
308
|
-
</div>
|
|
309
|
-
|
|
310
|
-
{/* Mobile drawer — animated, owns its own close transition. */}
|
|
311
|
-
{mobileMenuOpen && (
|
|
312
|
-
<MobileOverlay
|
|
313
|
-
navigation={navigation}
|
|
314
|
-
logo={logo}
|
|
315
|
-
user={user}
|
|
316
|
-
currentPath={currentPath}
|
|
317
|
-
onNavigate={onNavigate}
|
|
318
|
-
onSignOut={onSignOut}
|
|
319
|
-
onProfile={onProfile}
|
|
320
|
-
onSettings={onSettings}
|
|
321
|
-
onBilling={onBilling}
|
|
322
|
-
orgSwitcher={orgSwitcher}
|
|
323
|
-
topContent={sidebarTopContent}
|
|
324
|
-
footerContent={sidebarFooterContent}
|
|
325
|
-
userMenuSlot={userMenuSlot}
|
|
326
|
-
notificationSlot={notificationSlot}
|
|
327
|
-
unreadCount={unreadCount}
|
|
328
|
-
onClose={() => setMobileMenuOpen(false)}
|
|
329
|
-
/>
|
|
330
|
-
)}
|
|
331
|
-
</div>
|
|
332
|
-
</ModuleHeaderActionsSlotContext.Provider>
|
|
333
|
-
</ModuleHeaderSlotContext.Provider>
|
|
334
|
-
</SaveBarProvider>
|
|
335
|
-
)
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// ---------------------------------------------------------------------------
|
|
339
|
-
// Topbar layout
|
|
340
|
-
// ---------------------------------------------------------------------------
|
|
341
|
-
|
|
342
|
-
function TopbarLayout({
|
|
343
|
-
navigation = [],
|
|
344
|
-
logo,
|
|
345
|
-
user,
|
|
346
|
-
children,
|
|
347
|
-
onNavigate,
|
|
348
|
-
onSignOut,
|
|
349
|
-
onProfile,
|
|
350
|
-
onSettings,
|
|
351
|
-
onBilling,
|
|
352
|
-
userPlan,
|
|
353
|
-
billingLabel,
|
|
354
|
-
userMenuExtras,
|
|
355
|
-
orgSwitcher,
|
|
356
|
-
frame,
|
|
357
|
-
currentPath,
|
|
358
|
-
topbarStart,
|
|
359
|
-
topbarEnd,
|
|
360
|
-
notificationSlot,
|
|
361
|
-
hasBottomNav = false,
|
|
362
|
-
}: {
|
|
363
|
-
navigation?: NavigationItem[]
|
|
364
|
-
logo?: React.ReactNode
|
|
365
|
-
user?: AppShellUser
|
|
366
|
-
children?: React.ReactNode
|
|
367
|
-
onNavigate?: (route: string) => void
|
|
368
|
-
onSignOut?: () => void
|
|
369
|
-
onProfile?: () => void
|
|
370
|
-
onSettings?: () => void
|
|
371
|
-
onBilling?: () => void
|
|
372
|
-
userPlan?: AppShellProps['userPlan']
|
|
373
|
-
billingLabel?: AppShellProps['billingLabel']
|
|
374
|
-
userMenuExtras?: AppShellProps['userMenuExtras']
|
|
375
|
-
/** Workspace switcher — rendered beside the logo (desktop) and in the mobile drawer. */
|
|
376
|
-
orgSwitcher?: React.ReactNode
|
|
377
|
-
frame?: boolean
|
|
378
|
-
currentPath?: string
|
|
379
|
-
topbarStart?: React.ReactNode
|
|
380
|
-
topbarEnd?: React.ReactNode
|
|
381
|
-
/** Bell/inbox slot — in the topbar layout it renders with the right-side actions. */
|
|
382
|
-
notificationSlot?: React.ReactNode
|
|
383
|
-
/** When a mobile bottom-nav bar is present, the mobile hamburger is
|
|
384
|
-
* suppressed so the bottom nav is the primary mobile navigation. */
|
|
385
|
-
hasBottomNav?: boolean
|
|
386
|
-
}) {
|
|
387
|
-
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false)
|
|
388
|
-
|
|
389
|
-
// Topbar is full-bleed (edge to edge); only the content area gets the frame.
|
|
390
|
-
return (
|
|
391
|
-
<SaveBarProvider>
|
|
392
|
-
<div className="relative flex h-screen flex-col overflow-hidden bg-background">
|
|
393
|
-
<Topbar
|
|
394
|
-
navigation={navigation}
|
|
395
|
-
logo={logo}
|
|
396
|
-
user={user}
|
|
397
|
-
currentPath={currentPath}
|
|
398
|
-
onNavigate={onNavigate}
|
|
399
|
-
onSignOut={onSignOut}
|
|
400
|
-
onProfile={onProfile}
|
|
401
|
-
onSettings={onSettings}
|
|
402
|
-
onBilling={onBilling}
|
|
403
|
-
userPlan={userPlan}
|
|
404
|
-
billingLabel={billingLabel}
|
|
405
|
-
userMenuExtras={userMenuExtras}
|
|
406
|
-
orgSwitcher={orgSwitcher}
|
|
407
|
-
leftContent={topbarStart}
|
|
408
|
-
rightContent={(topbarEnd || notificationSlot) ? <>{topbarEnd}{notificationSlot}</> : undefined}
|
|
409
|
-
onMenuClick={hasBottomNav ? undefined : () => setMobileMenuOpen(true)}
|
|
410
|
-
/>
|
|
411
|
-
{/* Frame inset/border only from md up, so mobile stays edge-to-edge. The
|
|
412
|
-
inset gap behind the framed card is the header color (bg-sidebar), so the
|
|
413
|
-
white card reads as a panel floating on the same navy as the topbar. */}
|
|
414
|
-
{/* The frame is fixed to the viewport and the content scrolls INSIDE it,
|
|
415
|
-
mirroring SidebarLayout. With `main` as the scroller the card grew with
|
|
416
|
-
the page and its bottom edge rode off-screen. */}
|
|
417
|
-
<main className={cn('flex min-w-0 min-h-0 flex-1 flex-col overflow-hidden', frame && 'md:bg-sidebar md:p-2')}>
|
|
418
|
-
<div className={cn('flex min-w-0 min-h-0 flex-1 flex-col overflow-hidden', frame && 'md:rounded-xl md:border md:border-border md:bg-card')}>
|
|
419
|
-
<div className="min-h-0 flex-1 overflow-y-auto pb-16 md:pb-0">
|
|
420
|
-
{children}
|
|
421
|
-
</div>
|
|
422
|
-
</div>
|
|
423
|
-
</main>
|
|
424
|
-
|
|
425
|
-
{/* App-wide floating SaveBar */}
|
|
426
|
-
<SaveBar />
|
|
427
|
-
|
|
428
|
-
{/* Mobile drawer — topbar has no persistent sidebar, so the burger opens
|
|
429
|
-
the same animated nav drawer used by the sidebar layout. */}
|
|
430
|
-
{mobileMenuOpen && (
|
|
431
|
-
<MobileOverlay
|
|
432
|
-
navigation={navigation}
|
|
433
|
-
logo={logo}
|
|
434
|
-
user={user}
|
|
435
|
-
currentPath={currentPath}
|
|
436
|
-
onNavigate={onNavigate}
|
|
437
|
-
onSignOut={onSignOut}
|
|
438
|
-
onProfile={onProfile}
|
|
439
|
-
onSettings={onSettings}
|
|
440
|
-
onBilling={onBilling}
|
|
441
|
-
orgSwitcher={orgSwitcher}
|
|
442
|
-
userMenuSlot={user ? <DrawerUserRow user={user} /> : undefined}
|
|
443
|
-
onClose={() => setMobileMenuOpen(false)}
|
|
444
|
-
/>
|
|
445
|
-
)}
|
|
446
|
-
</div>
|
|
447
|
-
</SaveBarProvider>
|
|
448
|
-
)
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// Compact user identity for the mobile drawer's bottom row.
|
|
452
|
-
function DrawerUserRow({ user }: { user: AppShellUser }) {
|
|
453
|
-
const label = user.fullName || user.email
|
|
454
|
-
return (
|
|
455
|
-
<div className="flex min-w-0 items-center gap-2 px-1">
|
|
456
|
-
{user.avatarUrl ? (
|
|
457
|
-
<img src={user.avatarUrl} alt="" className="h-7 w-7 shrink-0 rounded-full object-cover" />
|
|
458
|
-
) : (
|
|
459
|
-
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
|
460
|
-
{(label || '?').charAt(0).toUpperCase()}
|
|
461
|
-
</span>
|
|
462
|
-
)}
|
|
463
|
-
<span className="truncate text-sm font-medium text-sidebar-foreground">{label}</span>
|
|
464
|
-
</div>
|
|
465
|
-
)
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// ---------------------------------------------------------------------------
|
|
469
|
-
// Minimal layout
|
|
470
|
-
// ---------------------------------------------------------------------------
|
|
471
|
-
|
|
472
|
-
function MinimalLayout({
|
|
473
|
-
logo,
|
|
474
|
-
user: _user,
|
|
475
|
-
children,
|
|
476
|
-
headerEnd,
|
|
477
|
-
}: {
|
|
478
|
-
logo?: React.ReactNode
|
|
479
|
-
user?: AppShellUser
|
|
480
|
-
children?: React.ReactNode
|
|
481
|
-
headerEnd?: React.ReactNode
|
|
482
|
-
}) {
|
|
483
|
-
return (
|
|
484
|
-
<div className="flex h-screen flex-col">
|
|
485
|
-
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border bg-card px-4 md:px-6">
|
|
486
|
-
<div className="flex items-center gap-3">
|
|
487
|
-
{logo ?? <span className="text-lg font-bold">App</span>}
|
|
488
|
-
</div>
|
|
489
|
-
{headerEnd && <div className="flex items-center gap-2">{headerEnd}</div>}
|
|
490
|
-
</header>
|
|
491
|
-
<main className="min-w-0 flex-1 overflow-y-auto pb-16 md:pb-0">
|
|
492
|
-
{children}
|
|
493
|
-
</main>
|
|
494
|
-
</div>
|
|
495
|
-
)
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// ---------------------------------------------------------------------------
|
|
499
|
-
// Mobile overlay drawer
|
|
500
|
-
// ---------------------------------------------------------------------------
|
|
501
|
-
|
|
502
|
-
type MobileOverlayProps = Omit<SidebarProps, 'collapsed' | 'onToggle'> & {
|
|
503
|
-
onClose: () => void
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
function MobileOverlay({ onClose, onNavigate, ...sidebarProps }: MobileOverlayProps) {
|
|
507
|
-
// Mount hidden, then flip to visible on the next frame so the CSS transition
|
|
508
|
-
// plays on open. On dismiss we play the exit transition first, then unmount.
|
|
509
|
-
const [visible, setVisible] = React.useState(false)
|
|
510
|
-
|
|
511
|
-
React.useEffect(() => {
|
|
512
|
-
const id = requestAnimationFrame(() => setVisible(true))
|
|
513
|
-
return () => cancelAnimationFrame(id)
|
|
514
|
-
}, [])
|
|
515
|
-
|
|
516
|
-
const close = React.useCallback(() => {
|
|
517
|
-
setVisible(false)
|
|
518
|
-
window.setTimeout(onClose, 250)
|
|
519
|
-
}, [onClose])
|
|
520
|
-
|
|
521
|
-
// Lock body scroll while the drawer is open.
|
|
522
|
-
React.useEffect(() => {
|
|
523
|
-
const previous = document.body.style.overflow
|
|
524
|
-
document.body.style.overflow = 'hidden'
|
|
525
|
-
return () => {
|
|
526
|
-
document.body.style.overflow = previous
|
|
527
|
-
}
|
|
528
|
-
}, [])
|
|
529
|
-
|
|
530
|
-
// Dismiss on Escape.
|
|
531
|
-
React.useEffect(() => {
|
|
532
|
-
const onKey = (e: KeyboardEvent) => {
|
|
533
|
-
if (e.key === 'Escape') close()
|
|
534
|
-
}
|
|
535
|
-
window.addEventListener('keydown', onKey)
|
|
536
|
-
return () => window.removeEventListener('keydown', onKey)
|
|
537
|
-
}, [close])
|
|
538
|
-
|
|
539
|
-
return (
|
|
540
|
-
<div className="fixed inset-0 z-[60] md:hidden">
|
|
541
|
-
{/* Backdrop — fades in/out */}
|
|
542
|
-
<div
|
|
543
|
-
className={cn(
|
|
544
|
-
'absolute inset-0 bg-black/50 transition-opacity duration-300 ease-out',
|
|
545
|
-
visible ? 'opacity-100' : 'opacity-0',
|
|
546
|
-
)}
|
|
547
|
-
onClick={close}
|
|
548
|
-
/>
|
|
549
|
-
{/* Drawer — slides in from the left, reusing the real Sidebar for full
|
|
550
|
-
design parity (icons, active state, org switcher, bottom user row). */}
|
|
551
|
-
<div
|
|
552
|
-
className={cn(
|
|
553
|
-
'absolute inset-y-0 left-0 shadow-2xl transition-transform duration-300 ease-out will-change-transform',
|
|
554
|
-
visible ? 'translate-x-0' : '-translate-x-full',
|
|
555
|
-
)}
|
|
556
|
-
>
|
|
557
|
-
<Sidebar
|
|
558
|
-
{...sidebarProps}
|
|
559
|
-
collapsed={false}
|
|
560
|
-
onToggle={close}
|
|
561
|
-
borderless
|
|
562
|
-
onNavigate={(route) => {
|
|
563
|
-
onNavigate?.(route)
|
|
564
|
-
close()
|
|
565
|
-
}}
|
|
566
|
-
/>
|
|
567
|
-
</div>
|
|
568
|
-
</div>
|
|
569
|
-
)
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
// ---------------------------------------------------------------------------
|
|
573
|
-
// Mobile bottom tab bar — rendered for every variant on small screens.
|
|
574
|
-
// ui must NOT import from saas, so the icon lookup is a small local map (a
|
|
575
|
-
// mirror of the saas icon convention). Unknown icon names fall back to Home.
|
|
576
|
-
// ---------------------------------------------------------------------------
|
|
577
|
-
|
|
578
|
-
const BOTTOM_NAV_ICONS: Record<string, LucideIcon> = {
|
|
579
|
-
Home,
|
|
580
|
-
Receipt,
|
|
581
|
-
ArrowLeftRight,
|
|
582
|
-
Calendar,
|
|
583
|
-
CreditCard,
|
|
584
|
-
Settings,
|
|
585
|
-
Menu,
|
|
586
|
-
MessageCircle,
|
|
587
|
-
Wallet,
|
|
588
|
-
PiggyBank,
|
|
589
|
-
Plus,
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
// Floating profile avatar — mobile-only (md:hidden), pinned top-right over the
|
|
593
|
-
// content. Used with the 'transparent' mobile header treatment so there is no
|
|
594
|
-
// header bar but the user still has a one-tap route into their profile.
|
|
595
|
-
function FloatingProfileAvatar({ user, onClick }: { user?: AppShellUser; onClick?: () => void }) {
|
|
596
|
-
if (!user) return null
|
|
597
|
-
const label = user.fullName || user.email
|
|
598
|
-
return (
|
|
599
|
-
<button
|
|
600
|
-
type="button"
|
|
601
|
-
onClick={onClick}
|
|
602
|
-
aria-label="Profile"
|
|
603
|
-
className="fixed right-3 top-3 z-40 md:hidden"
|
|
604
|
-
>
|
|
605
|
-
{user.avatarUrl ? (
|
|
606
|
-
<img
|
|
607
|
-
src={user.avatarUrl}
|
|
608
|
-
alt=""
|
|
609
|
-
className="h-9 w-9 rounded-full object-cover shadow-md ring-2 ring-background"
|
|
610
|
-
/>
|
|
611
|
-
) : (
|
|
612
|
-
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-md ring-2 ring-background">
|
|
613
|
-
{(label || '?').charAt(0).toUpperCase()}
|
|
614
|
-
</span>
|
|
615
|
-
)}
|
|
616
|
-
</button>
|
|
617
|
-
)
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
function MobileBottomNav({
|
|
621
|
-
items,
|
|
622
|
-
currentPath,
|
|
623
|
-
onNavigate,
|
|
624
|
-
onAction,
|
|
625
|
-
}: {
|
|
626
|
-
items: NonNullable<AppShellProps['bottomNav']>
|
|
627
|
-
currentPath?: string
|
|
628
|
-
onNavigate?: (route: string) => void
|
|
629
|
-
onAction?: (id: string) => void
|
|
630
|
-
}) {
|
|
631
|
-
const path = currentPath ?? ''
|
|
632
|
-
return (
|
|
633
|
-
<nav className="fixed inset-x-0 bottom-0 z-50 flex h-16 items-center justify-around border-t border-border bg-background md:hidden">
|
|
634
|
-
{items.map((item) => {
|
|
635
|
-
// Elevated center ACTION button (Mobills style) — raised above the bar,
|
|
636
|
-
// circular, primary-filled; fires onBottomNavAction instead of routing.
|
|
637
|
-
if (item.kind === 'action') {
|
|
638
|
-
const ActionIcon = BOTTOM_NAV_ICONS[item.icon] ?? Plus
|
|
639
|
-
return (
|
|
640
|
-
<div key={item.id} className="flex flex-1 items-start justify-center">
|
|
641
|
-
<button
|
|
642
|
-
type="button"
|
|
643
|
-
onClick={() => onAction?.(item.id)}
|
|
644
|
-
aria-label={item.label ?? 'Add'}
|
|
645
|
-
className="-mt-6 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg shadow-primary/30 ring-4 ring-background transition-transform active:scale-95"
|
|
646
|
-
>
|
|
647
|
-
<ActionIcon className="h-6 w-6" />
|
|
648
|
-
</button>
|
|
649
|
-
</div>
|
|
650
|
-
)
|
|
651
|
-
}
|
|
652
|
-
const isActive =
|
|
653
|
-
item.route === '/'
|
|
654
|
-
? path === '/' || path === ''
|
|
655
|
-
: path === item.route || path.startsWith(item.route + '/')
|
|
656
|
-
const Icon = BOTTOM_NAV_ICONS[item.icon] ?? Home
|
|
657
|
-
return (
|
|
658
|
-
<button
|
|
659
|
-
key={item.route}
|
|
660
|
-
type="button"
|
|
661
|
-
onClick={() => onNavigate?.(item.route)}
|
|
662
|
-
className={cn(
|
|
663
|
-
'flex flex-1 flex-col items-center justify-center gap-0.5 px-1 py-1 transition-colors',
|
|
664
|
-
isActive ? 'text-primary' : 'text-muted-foreground',
|
|
665
|
-
)}
|
|
666
|
-
aria-current={isActive ? 'page' : undefined}
|
|
667
|
-
>
|
|
668
|
-
<Icon className="h-5 w-5" />
|
|
669
|
-
<span className="text-xs">{item.label}</span>
|
|
670
|
-
</button>
|
|
671
|
-
)
|
|
672
|
-
})}
|
|
673
|
-
</nav>
|
|
674
|
-
)
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// ---------------------------------------------------------------------------
|
|
678
|
-
// AppShell — main entry point
|
|
679
|
-
// ---------------------------------------------------------------------------
|
|
680
|
-
|
|
681
|
-
export function AppShell({
|
|
682
|
-
variant,
|
|
683
|
-
contentFrame,
|
|
684
|
-
navigation = [],
|
|
685
|
-
logo,
|
|
686
|
-
user,
|
|
687
|
-
children,
|
|
688
|
-
onNavigate,
|
|
689
|
-
onSignOut,
|
|
690
|
-
onProfile,
|
|
691
|
-
onSettings,
|
|
692
|
-
onBilling,
|
|
693
|
-
userPlan,
|
|
694
|
-
billingLabel,
|
|
695
|
-
pageTitle,
|
|
696
|
-
currentPath,
|
|
697
|
-
userMenuExtras,
|
|
698
|
-
orgSwitcher,
|
|
699
|
-
topbarStart,
|
|
700
|
-
topbarEnd,
|
|
701
|
-
sidebarTopContent,
|
|
702
|
-
sidebarFooterContent,
|
|
703
|
-
bottomNav,
|
|
704
|
-
onBottomNavAction,
|
|
705
|
-
mobileHeader = 'minimal',
|
|
706
|
-
userMenuSlot,
|
|
707
|
-
notificationSlot,
|
|
708
|
-
unreadCount = 0,
|
|
709
|
-
sidebarSectionLabels,
|
|
710
|
-
sidebarNavKey,
|
|
711
|
-
sidebarNavDirection,
|
|
712
|
-
rightRail,
|
|
713
|
-
rightRailOpen = true,
|
|
714
|
-
defaultRightRailWidth,
|
|
715
|
-
onRightRailWidthChange,
|
|
716
|
-
rightRailMinWidth,
|
|
717
|
-
rightRailMaxWidth,
|
|
718
|
-
rightRailResizeLabel,
|
|
719
|
-
}: AppShellProps) {
|
|
720
|
-
const hasBottomNav = !!bottomNav?.length
|
|
721
|
-
const bottomBar = hasBottomNav ? (
|
|
722
|
-
<MobileBottomNav
|
|
723
|
-
items={bottomNav!}
|
|
724
|
-
currentPath={currentPath}
|
|
725
|
-
onNavigate={onNavigate}
|
|
726
|
-
onAction={onBottomNavAction}
|
|
727
|
-
/>
|
|
728
|
-
) : null
|
|
729
|
-
// Transparent mobile header → no bar, a floating avatar top-right instead.
|
|
730
|
-
const floatingAvatar =
|
|
731
|
-
mobileHeader === 'transparent' && user ? (
|
|
732
|
-
<FloatingProfileAvatar user={user} onClick={onProfile} />
|
|
733
|
-
) : null
|
|
734
|
-
|
|
735
|
-
let layoutElement: React.ReactNode
|
|
736
|
-
switch (variant) {
|
|
737
|
-
case 'sidebar':
|
|
738
|
-
layoutElement = (
|
|
739
|
-
<SidebarLayout
|
|
740
|
-
navigation={navigation}
|
|
741
|
-
logo={logo}
|
|
742
|
-
user={user}
|
|
743
|
-
onNavigate={onNavigate}
|
|
744
|
-
onSignOut={onSignOut}
|
|
745
|
-
onProfile={onProfile}
|
|
746
|
-
onSettings={onSettings}
|
|
747
|
-
onBilling={onBilling}
|
|
748
|
-
userMenuExtras={userMenuExtras}
|
|
749
|
-
orgSwitcher={orgSwitcher}
|
|
750
|
-
sidebarTopContent={sidebarTopContent}
|
|
751
|
-
sidebarFooterContent={sidebarFooterContent}
|
|
752
|
-
frame={contentFrame}
|
|
753
|
-
currentPath={currentPath}
|
|
754
|
-
pageTitle={pageTitle}
|
|
755
|
-
topbarStart={topbarStart}
|
|
756
|
-
topbarEnd={topbarEnd}
|
|
757
|
-
userMenuSlot={userMenuSlot}
|
|
758
|
-
notificationSlot={notificationSlot}
|
|
759
|
-
unreadCount={unreadCount}
|
|
760
|
-
hasBottomNav={hasBottomNav}
|
|
761
|
-
mobileHeader={mobileHeader}
|
|
762
|
-
sidebarSectionLabels={sidebarSectionLabels}
|
|
763
|
-
sidebarNavKey={sidebarNavKey}
|
|
764
|
-
sidebarNavDirection={sidebarNavDirection}
|
|
765
|
-
>
|
|
766
|
-
{children}
|
|
767
|
-
</SidebarLayout>
|
|
768
|
-
)
|
|
769
|
-
break
|
|
770
|
-
|
|
771
|
-
case 'topbar':
|
|
772
|
-
layoutElement = (
|
|
773
|
-
<TopbarLayout
|
|
774
|
-
navigation={navigation}
|
|
775
|
-
logo={logo}
|
|
776
|
-
user={user}
|
|
777
|
-
onNavigate={onNavigate}
|
|
778
|
-
onSignOut={onSignOut}
|
|
779
|
-
onProfile={onProfile}
|
|
780
|
-
onSettings={onSettings}
|
|
781
|
-
onBilling={onBilling}
|
|
782
|
-
userPlan={userPlan}
|
|
783
|
-
billingLabel={billingLabel}
|
|
784
|
-
userMenuExtras={userMenuExtras}
|
|
785
|
-
orgSwitcher={orgSwitcher}
|
|
786
|
-
frame={contentFrame}
|
|
787
|
-
currentPath={currentPath}
|
|
788
|
-
topbarStart={topbarStart}
|
|
789
|
-
topbarEnd={topbarEnd}
|
|
790
|
-
notificationSlot={notificationSlot}
|
|
791
|
-
hasBottomNav={hasBottomNav}
|
|
792
|
-
>
|
|
793
|
-
{children}
|
|
794
|
-
</TopbarLayout>
|
|
795
|
-
)
|
|
796
|
-
break
|
|
797
|
-
|
|
798
|
-
case 'minimal':
|
|
799
|
-
layoutElement = (
|
|
800
|
-
<MinimalLayout logo={logo} user={user} headerEnd={topbarEnd}>
|
|
801
|
-
{children}
|
|
802
|
-
</MinimalLayout>
|
|
803
|
-
)
|
|
804
|
-
break
|
|
805
|
-
|
|
806
|
-
default: {
|
|
807
|
-
const _exhaustive: never = variant
|
|
808
|
-
return null
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
return (
|
|
813
|
-
<>
|
|
814
|
-
{rightRail ? (
|
|
815
|
-
<SplitWithRightRail
|
|
816
|
-
rail={rightRail}
|
|
817
|
-
open={rightRailOpen}
|
|
818
|
-
frame={contentFrame}
|
|
819
|
-
defaultWidth={defaultRightRailWidth}
|
|
820
|
-
onWidthChange={onRightRailWidthChange}
|
|
821
|
-
minWidth={rightRailMinWidth}
|
|
822
|
-
maxWidth={rightRailMaxWidth}
|
|
823
|
-
resizeLabel={rightRailResizeLabel}
|
|
824
|
-
>
|
|
825
|
-
{layoutElement}
|
|
826
|
-
</SplitWithRightRail>
|
|
827
|
-
) : (
|
|
828
|
-
layoutElement
|
|
829
|
-
)}
|
|
830
|
-
{floatingAvatar}
|
|
831
|
-
{bottomBar}
|
|
832
|
-
</>
|
|
833
|
-
)
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
// ---------------------------------------------------------------------------
|
|
837
|
-
// SplitWithRightRail — the app on the left, a full-height rail on the right,
|
|
838
|
-
// a divider the user owns between them.
|
|
839
|
-
//
|
|
840
|
-
// The layouts all root at `h-screen overflow-hidden`, so putting them in a
|
|
841
|
-
// `flex-1 min-w-0` cell keeps their height and lets them shrink. `min-w-0` is
|
|
842
|
-
// load-bearing: without it a flex child refuses to go below its content width
|
|
843
|
-
// and the rail gets pushed off-screen instead of the content reflowing.
|
|
844
|
-
// ---------------------------------------------------------------------------
|
|
845
|
-
|
|
846
|
-
function SplitWithRightRail({
|
|
847
|
-
children,
|
|
848
|
-
rail,
|
|
849
|
-
open,
|
|
850
|
-
frame,
|
|
851
|
-
defaultWidth = 380,
|
|
852
|
-
onWidthChange,
|
|
853
|
-
minWidth = 300,
|
|
854
|
-
maxWidth = 720,
|
|
855
|
-
resizeLabel = 'Resize panel',
|
|
856
|
-
}: {
|
|
857
|
-
children: React.ReactNode
|
|
858
|
-
rail: React.ReactNode
|
|
859
|
-
open: boolean
|
|
860
|
-
/** Frame mode: the gutter between content and rail takes the sidebar tone,
|
|
861
|
-
* the same surface the content card floats on. Without this the split shows
|
|
862
|
-
* a bare strip of page background and the rail reads as bolted on. */
|
|
863
|
-
frame?: boolean
|
|
864
|
-
defaultWidth?: number
|
|
865
|
-
onWidthChange?: (width: number) => void
|
|
866
|
-
minWidth?: number
|
|
867
|
-
maxWidth?: number
|
|
868
|
-
resizeLabel?: string
|
|
869
|
-
}) {
|
|
870
|
-
const [width, setWidth] = React.useState(defaultWidth)
|
|
871
|
-
const [dragging, setDragging] = React.useState(false)
|
|
872
|
-
|
|
873
|
-
const clamp = React.useCallback(
|
|
874
|
-
(value: number) => Math.min(maxWidth, Math.max(minWidth, value)),
|
|
875
|
-
[minWidth, maxWidth],
|
|
876
|
-
)
|
|
877
|
-
|
|
878
|
-
// The app follows the pointer to the pixel, so the drag runs on window
|
|
879
|
-
// listeners rather than on the handle: the cursor routinely outruns a 6px
|
|
880
|
-
// target, and a resize that stops when you move too fast feels broken.
|
|
881
|
-
React.useEffect(() => {
|
|
882
|
-
if (!dragging) return
|
|
883
|
-
const onMove = (event: PointerEvent) => {
|
|
884
|
-
event.preventDefault()
|
|
885
|
-
setWidth(clamp(window.innerWidth - event.clientX))
|
|
886
|
-
}
|
|
887
|
-
const stop = () => setDragging(false)
|
|
888
|
-
window.addEventListener('pointermove', onMove)
|
|
889
|
-
window.addEventListener('pointerup', stop)
|
|
890
|
-
window.addEventListener('pointercancel', stop)
|
|
891
|
-
// Text selection across the whole app while dragging a divider is pure
|
|
892
|
-
// noise, and iframes/canvases would otherwise swallow the pointer.
|
|
893
|
-
const previousSelect = document.body.style.userSelect
|
|
894
|
-
const previousCursor = document.body.style.cursor
|
|
895
|
-
document.body.style.userSelect = 'none'
|
|
896
|
-
document.body.style.cursor = 'col-resize'
|
|
897
|
-
return () => {
|
|
898
|
-
window.removeEventListener('pointermove', onMove)
|
|
899
|
-
window.removeEventListener('pointerup', stop)
|
|
900
|
-
window.removeEventListener('pointercancel', stop)
|
|
901
|
-
document.body.style.userSelect = previousSelect
|
|
902
|
-
document.body.style.cursor = previousCursor
|
|
903
|
-
}
|
|
904
|
-
}, [dragging, clamp])
|
|
905
|
-
|
|
906
|
-
// Report only on release: firing per pointer frame would write to
|
|
907
|
-
// localStorage ~60×/second for one gesture.
|
|
908
|
-
React.useEffect(() => {
|
|
909
|
-
if (dragging) return
|
|
910
|
-
onWidthChange?.(width)
|
|
911
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
912
|
-
}, [dragging])
|
|
913
|
-
|
|
914
|
-
const nudge = (delta: number) => setWidth((current) => clamp(current + delta))
|
|
915
|
-
|
|
916
|
-
return (
|
|
917
|
-
// Sidebar tone always: the gutter and the divider sit in the shell's
|
|
918
|
-
// chrome, next to the menu — painting them page-white put a bright strip
|
|
919
|
-
// between the dark chrome and the rail, which read as a seam rather than
|
|
920
|
-
// as space.
|
|
921
|
-
<div className={cn('flex h-screen w-full overflow-hidden bg-sidebar', !frame && 'md:bg-background')}>
|
|
922
|
-
<div className="min-w-0 flex-1">{children}</div>
|
|
923
|
-
|
|
924
|
-
{/* Divider — desktop only; on a phone the rail is a sheet over the app,
|
|
925
|
-
and there is nothing to divide. */}
|
|
926
|
-
<div
|
|
927
|
-
role="separator"
|
|
928
|
-
aria-orientation="vertical"
|
|
929
|
-
aria-label={resizeLabel}
|
|
930
|
-
aria-valuenow={width}
|
|
931
|
-
aria-valuemin={minWidth}
|
|
932
|
-
aria-valuemax={maxWidth}
|
|
933
|
-
tabIndex={0}
|
|
934
|
-
onPointerDown={(event) => {
|
|
935
|
-
event.preventDefault()
|
|
936
|
-
setDragging(true)
|
|
937
|
-
}}
|
|
938
|
-
onDoubleClick={() => setWidth(clamp(defaultWidth))}
|
|
939
|
-
onKeyDown={(event) => {
|
|
940
|
-
// Keyboard resize: a pointer-only divider makes the split view
|
|
941
|
-
// unusable for anyone not using a mouse.
|
|
942
|
-
if (event.key === 'ArrowLeft') { event.preventDefault(); nudge(24) }
|
|
943
|
-
else if (event.key === 'ArrowRight') { event.preventDefault(); nudge(-24) }
|
|
944
|
-
else if (event.key === 'Home') { event.preventDefault(); setWidth(maxWidth) }
|
|
945
|
-
else if (event.key === 'End') { event.preventDefault(); setWidth(minWidth) }
|
|
946
|
-
}}
|
|
947
|
-
className={cn(
|
|
948
|
-
'group relative hidden w-1 shrink-0 cursor-col-resize focus-visible:outline-none',
|
|
949
|
-
open && 'md:block',
|
|
950
|
-
)}
|
|
951
|
-
>
|
|
952
|
-
{/* Invisible at rest: the gutter's own tone already separates the two
|
|
953
|
-
panes, and a permanent rule there just adds a line to look at.
|
|
954
|
-
It appears when you reach for it. */}
|
|
955
|
-
<span
|
|
956
|
-
className={cn(
|
|
957
|
-
'absolute inset-y-6 left-1/2 w-px -translate-x-1/2 rounded-full bg-transparent transition-colors',
|
|
958
|
-
'group-hover:bg-primary/60 group-focus-visible:bg-primary',
|
|
959
|
-
dragging && 'bg-primary',
|
|
960
|
-
)}
|
|
961
|
-
/>
|
|
962
|
-
{/* Hit area wider than the line: 1px is honest visually and hostile
|
|
963
|
-
to aim for. */}
|
|
964
|
-
<span className="absolute inset-y-0 -left-1.5 -right-1.5" />
|
|
965
|
-
</div>
|
|
966
|
-
|
|
967
|
-
{/* Rendered once: two copies would mount the rail's contents twice. On a
|
|
968
|
-
phone the box collapses to zero width and the rail positions itself
|
|
969
|
-
`fixed` over the screen. The frame inset is padding HERE rather than
|
|
970
|
-
margin on the rail — a full-height child plus vertical margins
|
|
971
|
-
overflows its own box. */}
|
|
972
|
-
<aside
|
|
973
|
-
style={{ ['--fayz-rail-w' as string]: `${width}px` }}
|
|
974
|
-
className={cn(
|
|
975
|
-
'w-0 shrink-0 overflow-hidden',
|
|
976
|
-
// Animating WIDTH is what makes the app glide back instead of
|
|
977
|
-
// snapping; skipped mid-drag so the divider stays glued to the cursor.
|
|
978
|
-
!dragging && 'motion-safe:transition-[width] motion-safe:duration-200 motion-safe:ease-out',
|
|
979
|
-
open && 'md:w-[var(--fayz-rail-w)]',
|
|
980
|
-
frame && 'md:py-2 md:pr-2',
|
|
981
|
-
)}
|
|
982
|
-
>
|
|
983
|
-
{rail}
|
|
984
|
-
</aside>
|
|
985
|
-
</div>
|
|
986
|
-
)
|
|
987
|
-
}
|