@object-ui/layout 3.0.3 → 3.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.
@@ -0,0 +1,480 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * @object-ui/layout - AppSchema Renderer
11
+ *
12
+ * Consumes an `AppSchema` JSON object and renders a complete application
13
+ * shell with branding, sidebar navigation (including area switching),
14
+ * and mobile navigation modes.
15
+ *
16
+ * This is the main P0.1 deliverable — it allows Console (or any consumer)
17
+ * to render a fully-functional AppShell from a single JSON document.
18
+ *
19
+ * @module AppSchemaRenderer
20
+ */
21
+
22
+ import React, { useState, useEffect, useMemo } from 'react';
23
+ import { Link, useLocation } from 'react-router-dom';
24
+ import * as LucideIcons from 'lucide-react';
25
+ import {
26
+ Sidebar,
27
+ SidebarHeader,
28
+ SidebarContent,
29
+ SidebarFooter,
30
+ SidebarMenu,
31
+ SidebarMenuItem,
32
+ SidebarMenuButton,
33
+ SidebarGroup,
34
+ SidebarGroupLabel,
35
+ SidebarGroupContent,
36
+ SidebarInput,
37
+ useSidebar,
38
+ } from '@object-ui/components';
39
+ import type { AppSchema, NavigationItem, NavigationArea } from '@object-ui/types';
40
+ import { menuItemToNavigationItem } from '@object-ui/types';
41
+ import { AppShell, type AppShellBranding } from './AppShell';
42
+ import {
43
+ NavigationRenderer,
44
+ resolveIcon,
45
+ resolveLabel,
46
+ type VisibilityEvaluator,
47
+ type PermissionChecker,
48
+ } from './NavigationRenderer';
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Types
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /** Mobile navigation display mode */
55
+ export type MobileNavMode = 'drawer' | 'bottom_nav' | 'hamburger';
56
+
57
+ export interface AppSchemaRendererProps {
58
+ /** The AppSchema JSON to render */
59
+ schema: AppSchema;
60
+
61
+ /** Base URL prefix for generated hrefs (e.g. "/apps/crm") */
62
+ basePath?: string;
63
+
64
+ /** Mobile navigation mode @default "drawer" */
65
+ mobileNavMode?: MobileNavMode;
66
+
67
+ /** Optional visibility evaluator passed to NavigationRenderer */
68
+ evaluateVisibility?: VisibilityEvaluator;
69
+
70
+ /** Optional permission checker passed to NavigationRenderer */
71
+ checkPermission?: PermissionChecker;
72
+
73
+ /** Called when an action-type navigation item is clicked */
74
+ onAction?: (item: NavigationItem) => void;
75
+
76
+ /** Slot: top navbar content (rendered beside the sidebar trigger) */
77
+ navbar?: React.ReactNode;
78
+
79
+ /** Slot: sidebar header (e.g. app switcher dropdown). Replaces default branding header when provided. */
80
+ sidebarHeader?: React.ReactNode;
81
+
82
+ /** Slot: sidebar footer (e.g. user profile menu) */
83
+ sidebarFooter?: React.ReactNode;
84
+
85
+ /** Slot: extra sidebar content rendered after navigation (e.g. favorites, recent items) */
86
+ sidebarExtra?: React.ReactNode;
87
+
88
+ /** Page content */
89
+ children: React.ReactNode;
90
+
91
+ /** Extra class on the <main> content area */
92
+ className?: string;
93
+
94
+ /** Whether the sidebar starts open @default true */
95
+ defaultOpen?: boolean;
96
+
97
+ // --- P1.7 Navigation Enhancements ---
98
+
99
+ /** Show a search input in the sidebar to filter navigation items */
100
+ enableSearch?: boolean;
101
+
102
+ /** Enable pin/favorite toggle on navigation items */
103
+ enablePinning?: boolean;
104
+
105
+ /** Called when a navigation item is pinned or unpinned */
106
+ onPinToggle?: (itemId: string, pinned: boolean) => void;
107
+
108
+ /** Enable drag-to-reorder for navigation items */
109
+ enableReorder?: boolean;
110
+
111
+ /** Called when navigation items are reordered via drag */
112
+ onReorder?: (reorderedItems: NavigationItem[]) => void;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // AreaSwitcher
117
+ // ---------------------------------------------------------------------------
118
+
119
+ function AreaSwitcher({
120
+ areas,
121
+ activeAreaId,
122
+ onAreaChange,
123
+ evalVis,
124
+ checkPerm,
125
+ }: {
126
+ areas: NavigationArea[];
127
+ activeAreaId: string;
128
+ onAreaChange: (id: string) => void;
129
+ evalVis: VisibilityEvaluator;
130
+ checkPerm: PermissionChecker;
131
+ }) {
132
+ // Filter areas by visibility & permissions
133
+ const visibleAreas = areas.filter((a) => {
134
+ if (!evalVis(a.visible)) return false;
135
+ if (a.requiredPermissions?.length && !checkPerm(a.requiredPermissions)) return false;
136
+ return true;
137
+ });
138
+
139
+ if (visibleAreas.length <= 1) return null;
140
+
141
+ return (
142
+ <SidebarGroup>
143
+ <SidebarGroupLabel className="flex items-center gap-1.5">
144
+ <LucideIcons.Layers className="h-3.5 w-3.5" />
145
+ Area
146
+ </SidebarGroupLabel>
147
+ <SidebarGroupContent>
148
+ <SidebarMenu>
149
+ {visibleAreas.map((area) => {
150
+ const AreaIcon = resolveIcon(area.icon);
151
+ return (
152
+ <SidebarMenuItem key={area.id}>
153
+ <SidebarMenuButton
154
+ isActive={area.id === activeAreaId}
155
+ tooltip={resolveLabel(area.label)}
156
+ onClick={() => onAreaChange(area.id)}
157
+ >
158
+ <AreaIcon className="h-4 w-4" />
159
+ <span>{resolveLabel(area.label)}</span>
160
+ </SidebarMenuButton>
161
+ </SidebarMenuItem>
162
+ );
163
+ })}
164
+ </SidebarMenu>
165
+ </SidebarGroupContent>
166
+ </SidebarGroup>
167
+ );
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // MobileBottomNav
172
+ // ---------------------------------------------------------------------------
173
+
174
+ function MobileBottomNav({
175
+ items,
176
+ basePath,
177
+ }: {
178
+ items: NavigationItem[];
179
+ basePath: string;
180
+ }) {
181
+ const location = useLocation();
182
+ // Show up to 5 non-group leaf items
183
+ const leaves = items
184
+ .filter((n) => n.type !== 'group' && n.type !== 'separator')
185
+ .slice(0, 5);
186
+
187
+ if (leaves.length === 0) return null;
188
+
189
+ return (
190
+ <div
191
+ className="fixed bottom-0 left-0 right-0 z-50 flex items-center justify-around border-t bg-background/95 backdrop-blur-sm px-2 py-1 sm:hidden safe-area-bottom"
192
+ role="navigation"
193
+ aria-label="Mobile navigation"
194
+ >
195
+ {leaves.map((item) => {
196
+ const NavIcon = resolveIcon(item.icon);
197
+ let href = '#';
198
+ if (item.type === 'object') {
199
+ href = `${basePath}/${item.objectName}`;
200
+ if (item.viewName) href += `/view/${item.viewName}`;
201
+ }
202
+ else if (item.type === 'dashboard') href = item.dashboardName ? `${basePath}/dashboard/${item.dashboardName}` : '#';
203
+ else if (item.type === 'page') href = item.pageName ? `${basePath}/page/${item.pageName}` : '#';
204
+ else if (item.type === 'report') href = item.reportName ? `${basePath}/report/${item.reportName}` : '#';
205
+ else if (item.type === 'url') href = item.url ?? '#';
206
+
207
+ const isActive = href !== '#' && location.pathname.startsWith(href);
208
+
209
+ return (
210
+ <Link
211
+ key={item.id}
212
+ to={href}
213
+ className={`flex flex-col items-center gap-0.5 px-2 py-1.5 transition-colors min-w-[44px] min-h-[44px] justify-center ${
214
+ isActive ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
215
+ }`}
216
+ >
217
+ <NavIcon className="h-5 w-5" />
218
+ <span className="text-[10px] truncate max-w-[60px]">{resolveLabel(item.label)}</span>
219
+ </Link>
220
+ );
221
+ })}
222
+ </div>
223
+ );
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // InternalSidebar (wraps Sidebar primitive + header + navigation)
228
+ // ---------------------------------------------------------------------------
229
+
230
+ function InternalSidebar({
231
+ schema,
232
+ basePath,
233
+ evalVis,
234
+ checkPerm,
235
+ onAction,
236
+ sidebarHeader,
237
+ sidebarFooter,
238
+ sidebarExtra,
239
+ activeAreaId,
240
+ setActiveAreaId,
241
+ resolvedNavigation,
242
+ enableSearch,
243
+ enablePinning,
244
+ onPinToggle,
245
+ enableReorder,
246
+ onReorder,
247
+ }: {
248
+ schema: AppSchema;
249
+ basePath: string;
250
+ evalVis: VisibilityEvaluator;
251
+ checkPerm: PermissionChecker;
252
+ onAction?: (item: NavigationItem) => void;
253
+ sidebarHeader?: React.ReactNode;
254
+ sidebarFooter?: React.ReactNode;
255
+ sidebarExtra?: React.ReactNode;
256
+ activeAreaId: string | null;
257
+ setActiveAreaId: (id: string) => void;
258
+ resolvedNavigation: NavigationItem[];
259
+ enableSearch?: boolean;
260
+ enablePinning?: boolean;
261
+ onPinToggle?: (itemId: string, pinned: boolean) => void;
262
+ enableReorder?: boolean;
263
+ onReorder?: (reorderedItems: NavigationItem[]) => void;
264
+ }) {
265
+ const Icon = resolveIcon(schema.logo);
266
+ const areas = schema.areas ?? [];
267
+ const [searchQuery, setSearchQuery] = useState('');
268
+
269
+ return (
270
+ <Sidebar collapsible="icon">
271
+ {/* Header: custom slot or default branding */}
272
+ <SidebarHeader>
273
+ {sidebarHeader ?? (
274
+ <SidebarMenu>
275
+ <SidebarMenuItem>
276
+ <SidebarMenuButton size="lg" tooltip={schema.title ?? schema.name}>
277
+ <div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
278
+ {schema.logo && schema.logo.startsWith('http') ? (
279
+ <img
280
+ src={schema.logo}
281
+ alt={schema.title ?? ''}
282
+ className="size-6 object-contain"
283
+ />
284
+ ) : (
285
+ <Icon className="size-4" />
286
+ )}
287
+ </div>
288
+ <div className="grid flex-1 text-left text-sm leading-tight">
289
+ <span className="truncate font-semibold">
290
+ {schema.title ?? schema.name ?? 'App'}
291
+ </span>
292
+ {schema.description && (
293
+ <span className="truncate text-xs text-muted-foreground">
294
+ {schema.description}
295
+ </span>
296
+ )}
297
+ </div>
298
+ </SidebarMenuButton>
299
+ </SidebarMenuItem>
300
+ </SidebarMenu>
301
+ )}
302
+ {/* Search input */}
303
+ {enableSearch && (
304
+ <SidebarInput
305
+ placeholder="Search navigation…"
306
+ value={searchQuery}
307
+ onChange={(e) => setSearchQuery(e.target.value)}
308
+ aria-label="Search navigation"
309
+ />
310
+ )}
311
+ </SidebarHeader>
312
+
313
+ <SidebarContent>
314
+ {/* Area Switcher */}
315
+ {areas.length > 1 && activeAreaId && (
316
+ <AreaSwitcher
317
+ areas={areas}
318
+ activeAreaId={activeAreaId}
319
+ onAreaChange={setActiveAreaId}
320
+ evalVis={evalVis}
321
+ checkPerm={checkPerm}
322
+ />
323
+ )}
324
+
325
+ {/* Navigation tree */}
326
+ <NavigationRenderer
327
+ items={resolvedNavigation}
328
+ basePath={basePath}
329
+ evaluateVisibility={evalVis}
330
+ checkPermission={checkPerm}
331
+ onAction={onAction}
332
+ searchQuery={searchQuery}
333
+ enablePinning={enablePinning}
334
+ onPinToggle={onPinToggle}
335
+ enableReorder={enableReorder}
336
+ onReorder={onReorder}
337
+ />
338
+
339
+ {/* Extra sidebar content slot (e.g. favorites, recent items) */}
340
+ {sidebarExtra}
341
+ </SidebarContent>
342
+
343
+ {/* Optional footer slot */}
344
+ {sidebarFooter && <SidebarFooter>{sidebarFooter}</SidebarFooter>}
345
+ </Sidebar>
346
+ );
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // AppSchemaRenderer (main export)
351
+ // ---------------------------------------------------------------------------
352
+
353
+ /**
354
+ * Renders a complete application shell from an `AppSchema` JSON document.
355
+ *
356
+ * Responsibilities:
357
+ * - Reads `name`, `title`, `description`, `logo`, `favicon` for branding
358
+ * - Renders sidebar navigation from `navigation` or `areas[].navigation`
359
+ * - Area switcher when multiple `areas` are defined
360
+ * - Mobile modes: `drawer` (sheet overlay, default), `bottom_nav` (fixed
361
+ * bottom bar), `hamburger` (collapsed sidebar)
362
+ * - Evaluates `visible` expressions and `requiredPermissions` on every item
363
+ *
364
+ * @example
365
+ * ```tsx
366
+ * <AppSchemaRenderer
367
+ * schema={appJson}
368
+ * basePath="/apps/sales"
369
+ * mobileNavMode="bottom_nav"
370
+ * evaluateVisibility={(expr) => evaluateVisibility(expr, evaluator)}
371
+ * checkPermission={(perms) => perms.every(p => can(p))}
372
+ * >
373
+ * <Outlet />
374
+ * </AppSchemaRenderer>
375
+ * ```
376
+ */
377
+ export function AppSchemaRenderer({
378
+ schema,
379
+ basePath = '',
380
+ mobileNavMode = 'drawer',
381
+ evaluateVisibility: evalVisProp,
382
+ checkPermission: checkPermProp,
383
+ onAction,
384
+ navbar,
385
+ sidebarHeader,
386
+ sidebarFooter,
387
+ sidebarExtra,
388
+ children,
389
+ className,
390
+ defaultOpen = true,
391
+ enableSearch,
392
+ enablePinning,
393
+ onPinToggle,
394
+ enableReorder,
395
+ onReorder,
396
+ }: AppSchemaRendererProps) {
397
+ // Default evaluators
398
+ const evalVis: VisibilityEvaluator = evalVisProp ?? ((expr) => {
399
+ if (expr === false || expr === 'false') return false;
400
+ return true;
401
+ });
402
+ const checkPerm: PermissionChecker = checkPermProp ?? (() => true);
403
+
404
+ // --- Resolve navigation from legacy `menu` or modern `navigation`/`areas` ---
405
+ const legacyNavigation = useMemo(
406
+ () => (schema.menu ?? []).map((m, i) => menuItemToNavigationItem(m, i)),
407
+ [schema.menu],
408
+ );
409
+ const flatNavigation = schema.navigation ?? legacyNavigation;
410
+
411
+ // --- Area management ---
412
+ const areas = schema.areas ?? [];
413
+ const [activeAreaId, setActiveAreaId] = useState<string | null>(
414
+ () => areas.length > 0 ? areas[0].id : null,
415
+ );
416
+
417
+ const areaIds = areas.map((a) => a.id).join(',');
418
+
419
+ useEffect(() => {
420
+ if (areas.length > 0) {
421
+ setActiveAreaId((prev) =>
422
+ areas.some((a) => a.id === prev) ? prev : areas[0].id,
423
+ );
424
+ } else {
425
+ setActiveAreaId(null);
426
+ }
427
+ }, [schema.name, areaIds]);
428
+
429
+ const activeArea = areas.find((a) => a.id === activeAreaId);
430
+ const resolvedNavigation: NavigationItem[] = activeArea?.navigation ?? flatNavigation;
431
+
432
+ // --- Branding ---
433
+ const branding: AppShellBranding = {
434
+ title: schema.title,
435
+ favicon: schema.favicon,
436
+ logo: schema.logo,
437
+ };
438
+
439
+ // --- Build sidebar element ---
440
+ const sidebarElement = (
441
+ <InternalSidebar
442
+ schema={schema}
443
+ basePath={basePath}
444
+ evalVis={evalVis}
445
+ checkPerm={checkPerm}
446
+ onAction={onAction}
447
+ sidebarHeader={sidebarHeader}
448
+ sidebarFooter={sidebarFooter}
449
+ sidebarExtra={sidebarExtra}
450
+ activeAreaId={activeAreaId}
451
+ setActiveAreaId={setActiveAreaId}
452
+ resolvedNavigation={resolvedNavigation}
453
+ enableSearch={enableSearch}
454
+ enablePinning={enablePinning}
455
+ onPinToggle={onPinToggle}
456
+ enableReorder={enableReorder}
457
+ onReorder={onReorder}
458
+ />
459
+ );
460
+
461
+ // --- Mobile bottom nav (shown alongside drawer sidebar on mobile) ---
462
+ const showBottomNav = mobileNavMode === 'bottom_nav';
463
+
464
+ return (
465
+ <>
466
+ <AppShell
467
+ sidebar={sidebarElement}
468
+ navbar={navbar}
469
+ className={className}
470
+ defaultOpen={defaultOpen}
471
+ branding={branding}
472
+ >
473
+ {children}
474
+ </AppShell>
475
+ {showBottomNav && (
476
+ <MobileBottomNav items={resolvedNavigation} basePath={basePath} />
477
+ )}
478
+ </>
479
+ );
480
+ }
package/src/AppShell.tsx CHANGED
@@ -140,7 +140,7 @@ export function AppShell({
140
140
  <div className="w-px h-4 bg-border mx-1 sm:mx-2" />
141
141
  {navbar}
142
142
  </header>
143
- <main className={cn("flex-1 overflow-auto p-3 sm:p-4 md:p-6", className)}>
143
+ <main className={cn("flex-1 min-w-0 overflow-auto p-3 sm:p-4 md:p-6", className)}>
144
144
  {children}
145
145
  </main>
146
146
  </SidebarInset>