@object-ui/layout 3.3.0 → 3.3.2

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.
@@ -1,480 +0,0 @@
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 DELETED
@@ -1,149 +0,0 @@
1
- import React, { useEffect } from 'react';
2
- import {
3
- SidebarProvider,
4
- SidebarTrigger,
5
- SidebarInset,
6
- Sidebar
7
- } from '@object-ui/components';
8
- import { cn } from '@object-ui/components';
9
-
10
- /**
11
- * Branding configuration for the AppShell.
12
- * Applies CSS custom properties to the document root for theme customization.
13
- */
14
- export interface AppShellBranding {
15
- /** Primary brand color (hex, e.g. "#3B82F6") */
16
- primaryColor?: string;
17
- /** Accent brand color (hex, e.g. "#10B981") */
18
- accentColor?: string;
19
- /** Favicon URL — replaces the <link rel="icon"> href */
20
- favicon?: string;
21
- /** Logo URL — passed to sidebar/navbar via context */
22
- logo?: string;
23
- /** Page title suffix (sets document.title) */
24
- title?: string;
25
- }
26
-
27
- export interface AppShellProps {
28
- sidebar?: React.ReactNode;
29
- navbar?: React.ReactNode; // Top navbar content
30
- children: React.ReactNode;
31
- className?: string;
32
- defaultOpen?: boolean;
33
- /** App branding — applies CSS custom properties for theming */
34
- branding?: AppShellBranding;
35
- }
36
-
37
- /**
38
- * Convert a hex color (#RRGGBB) to HSL string "H S% L%"
39
- * for use in Tailwind CSS custom properties.
40
- */
41
- function hexToHSL(hex: string): string | null {
42
- const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
43
- if (!result) return null;
44
-
45
- const r = parseInt(result[1], 16) / 255;
46
- const g = parseInt(result[2], 16) / 255;
47
- const b = parseInt(result[3], 16) / 255;
48
-
49
- const max = Math.max(r, g, b);
50
- const min = Math.min(r, g, b);
51
- let h = 0;
52
- let s = 0;
53
- const l = (max + min) / 2;
54
-
55
- if (max !== min) {
56
- const d = max - min;
57
- s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
58
- switch (max) {
59
- case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
60
- case g: h = ((b - r) / d + 2) / 6; break;
61
- case b: h = ((r - g) / d + 4) / 6; break;
62
- }
63
- }
64
-
65
- return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
66
- }
67
-
68
- /**
69
- * Apply branding CSS custom properties to the document root.
70
- * This is extracted as a standalone hook so it can be re-used independently.
71
- */
72
- export function useAppShellBranding(branding?: AppShellBranding, title?: string) {
73
- useEffect(() => {
74
- const root = document.documentElement;
75
-
76
- // Primary color
77
- if (branding?.primaryColor) {
78
- const hsl = hexToHSL(branding.primaryColor);
79
- if (hsl) {
80
- root.style.setProperty('--brand-primary', branding.primaryColor);
81
- root.style.setProperty('--brand-primary-hsl', hsl);
82
- }
83
- } else {
84
- root.style.removeProperty('--brand-primary');
85
- root.style.removeProperty('--brand-primary-hsl');
86
- }
87
-
88
- // Accent color
89
- if (branding?.accentColor) {
90
- const hsl = hexToHSL(branding.accentColor);
91
- if (hsl) {
92
- root.style.setProperty('--brand-accent', branding.accentColor);
93
- root.style.setProperty('--brand-accent-hsl', hsl);
94
- }
95
- } else {
96
- root.style.removeProperty('--brand-accent');
97
- root.style.removeProperty('--brand-accent-hsl');
98
- }
99
-
100
- // Favicon
101
- if (branding?.favicon) {
102
- const link = document.querySelector<HTMLLinkElement>('#favicon')
103
- || document.querySelector<HTMLLinkElement>('link[rel="icon"]');
104
- if (link) {
105
- link.href = branding.favicon;
106
- }
107
- }
108
-
109
- // Page title
110
- if (title) {
111
- document.title = title;
112
- }
113
-
114
- return () => {
115
- root.style.removeProperty('--brand-primary');
116
- root.style.removeProperty('--brand-primary-hsl');
117
- root.style.removeProperty('--brand-accent');
118
- root.style.removeProperty('--brand-accent-hsl');
119
- };
120
- }, [branding?.primaryColor, branding?.accentColor, branding?.favicon, title]);
121
- }
122
-
123
- export function AppShell({
124
- sidebar,
125
- navbar,
126
- children,
127
- className,
128
- defaultOpen = true,
129
- branding,
130
- }: AppShellProps) {
131
- // Apply branding CSS custom properties
132
- useAppShellBranding(branding, branding?.title);
133
-
134
- return (
135
- <SidebarProvider defaultOpen={defaultOpen}>
136
- {sidebar}
137
- <SidebarInset>
138
- <header className="flex h-14 sm:h-16 shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4">
139
- <SidebarTrigger className="-ml-1 hidden md:inline-flex" />
140
- <div className="w-px h-4 bg-border mx-1 sm:mx-2 hidden md:block" />
141
- {navbar}
142
- </header>
143
- <main className={cn("flex-1 min-w-0 overflow-auto p-3 sm:p-4 md:p-6", className)}>
144
- {children}
145
- </main>
146
- </SidebarInset>
147
- </SidebarProvider>
148
- );
149
- }