@object-ui/components 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.
Files changed (54) hide show
  1. package/.turbo/turbo-build.log +12 -12
  2. package/dist/index.css +1 -1
  3. package/dist/index.js +24701 -22929
  4. package/dist/index.umd.cjs +37 -37
  5. package/dist/src/custom/config-field-renderer.d.ts +21 -0
  6. package/dist/src/custom/config-panel-renderer.d.ts +81 -0
  7. package/dist/src/custom/config-row.d.ts +27 -0
  8. package/dist/src/custom/index.d.ts +5 -0
  9. package/dist/src/custom/mobile-dialog-content.d.ts +20 -0
  10. package/dist/src/custom/navigation-overlay.d.ts +8 -0
  11. package/dist/src/custom/section-header.d.ts +31 -0
  12. package/dist/src/debug/DebugPanel.d.ts +39 -0
  13. package/dist/src/debug/index.d.ts +9 -0
  14. package/dist/src/hooks/use-config-draft.d.ts +46 -0
  15. package/dist/src/index.d.ts +4 -0
  16. package/dist/src/renderers/action/action-bar.d.ts +23 -0
  17. package/dist/src/types/config-panel.d.ts +92 -0
  18. package/dist/src/ui/sheet.d.ts +2 -0
  19. package/dist/src/ui/sidebar.d.ts +4 -0
  20. package/package.json +17 -17
  21. package/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap +3 -3
  22. package/src/__tests__/action-bar.test.tsx +172 -0
  23. package/src/__tests__/config-field-renderer.test.tsx +307 -0
  24. package/src/__tests__/config-panel-renderer.test.tsx +580 -0
  25. package/src/__tests__/config-primitives.test.tsx +106 -0
  26. package/src/__tests__/mobile-accessibility.test.tsx +120 -0
  27. package/src/__tests__/navigation-overlay.test.tsx +97 -0
  28. package/src/__tests__/use-config-draft.test.tsx +295 -0
  29. package/src/custom/config-field-renderer.tsx +276 -0
  30. package/src/custom/config-panel-renderer.tsx +306 -0
  31. package/src/custom/config-row.tsx +50 -0
  32. package/src/custom/index.ts +5 -0
  33. package/src/custom/mobile-dialog-content.tsx +67 -0
  34. package/src/custom/navigation-overlay.tsx +42 -4
  35. package/src/custom/section-header.tsx +68 -0
  36. package/src/debug/DebugPanel.tsx +313 -0
  37. package/src/debug/__tests__/DebugPanel.test.tsx +134 -0
  38. package/src/debug/index.ts +10 -0
  39. package/src/hooks/use-config-draft.ts +127 -0
  40. package/src/index.css +4 -0
  41. package/src/index.ts +15 -0
  42. package/src/renderers/action/action-bar.tsx +202 -0
  43. package/src/renderers/action/index.ts +1 -0
  44. package/src/renderers/complex/__tests__/data-table-airtable-ux.test.tsx +239 -0
  45. package/src/renderers/complex/__tests__/data-table.test.ts +16 -0
  46. package/src/renderers/complex/data-table.tsx +346 -43
  47. package/src/renderers/data-display/breadcrumb.tsx +3 -2
  48. package/src/renderers/form/form.tsx +4 -4
  49. package/src/renderers/navigation/header-bar.tsx +69 -10
  50. package/src/stories/ConfigPanel.stories.tsx +232 -0
  51. package/src/types/config-panel.ts +101 -0
  52. package/src/ui/dialog.tsx +20 -3
  53. package/src/ui/sheet.tsx +6 -3
  54. package/src/ui/sidebar.tsx +93 -9
@@ -0,0 +1,67 @@
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
+ * MobileDialogContent
11
+ *
12
+ * A mobile-optimized wrapper around the upstream Shadcn DialogContent.
13
+ * On mobile (< sm breakpoint), the dialog is full-screen with a larger
14
+ * close-button touch target (≥ 44×44px per WCAG 2.5.5).
15
+ * On tablet+ (≥ sm), it falls back to the standard centered dialog.
16
+ *
17
+ * This lives in `custom/` to avoid modifying the Shadcn-synced `ui/dialog.tsx`.
18
+ */
19
+
20
+ import * as React from 'react';
21
+ import * as DialogPrimitive from '@radix-ui/react-dialog';
22
+ import { X } from 'lucide-react';
23
+ import { cn } from '../lib/utils';
24
+ import { DialogOverlay, DialogPortal } from '../ui/dialog';
25
+
26
+ export const MobileDialogContent = React.forwardRef<
27
+ React.ElementRef<typeof DialogPrimitive.Content>,
28
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
29
+ >(({ className, children, ...props }, ref) => (
30
+ <DialogPortal>
31
+ <DialogOverlay />
32
+ <DialogPrimitive.Content
33
+ ref={ref}
34
+ className={cn(
35
+ // Mobile-first: full-screen
36
+ 'fixed inset-0 z-50 w-full bg-background p-4 shadow-lg duration-200',
37
+ 'h-[100dvh]',
38
+ // Desktop (sm+): centered dialog with border + rounded corners
39
+ 'sm:inset-auto sm:left-[50%] sm:top-[50%] sm:translate-x-[-50%] sm:translate-y-[-50%]',
40
+ 'sm:max-w-lg sm:h-auto sm:max-h-[90vh] sm:rounded-lg sm:border sm:p-6',
41
+ // Animations
42
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out',
43
+ 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
44
+ 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
45
+ 'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
46
+ 'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
47
+ className,
48
+ )}
49
+ {...props}
50
+ >
51
+ {children}
52
+ <DialogPrimitive.Close
53
+ className={cn(
54
+ 'absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity',
55
+ 'hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
56
+ 'disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground',
57
+ // Mobile touch target ≥ 44×44px (WCAG 2.5.5)
58
+ 'min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0 flex items-center justify-center',
59
+ )}
60
+ >
61
+ <X className="h-5 w-5 sm:h-4 sm:w-4" />
62
+ <span className="sr-only">Close</span>
63
+ </DialogPrimitive.Close>
64
+ </DialogPrimitive.Content>
65
+ </DialogPortal>
66
+ ));
67
+ MobileDialogContent.displayName = 'MobileDialogContent';
@@ -89,6 +89,8 @@ export interface NavigationOverlayProps {
89
89
  width?: string | number;
90
90
  /** Whether navigation is an overlay mode */
91
91
  isOverlay: boolean;
92
+ /** Target view/form name from NavigationConfig */
93
+ view?: string;
92
94
  /** Title for the overlay header */
93
95
  title?: string;
94
96
  /** Description for the overlay header */
@@ -100,6 +102,12 @@ export interface NavigationOverlayProps {
100
102
  * Receives the selected record.
101
103
  */
102
104
  children: (record: Record<string, unknown>) => React.ReactNode;
105
+ /**
106
+ * Optional render function for a specific view/form based on `view` prop.
107
+ * When provided, this takes priority over `children` for rendering overlay content.
108
+ * Receives the selected record and the view name.
109
+ */
110
+ renderView?: (record: Record<string, unknown>, viewName: string) => React.ReactNode;
103
111
  /**
104
112
  * The main content to wrap (for split mode only).
105
113
  * In split mode, the main content is rendered in the left panel.
@@ -146,10 +154,12 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
146
154
  close,
147
155
  setIsOpen,
148
156
  width,
157
+ view,
149
158
  title,
150
159
  description,
151
160
  className,
152
161
  children,
162
+ renderView,
153
163
  mainContent,
154
164
  popoverTrigger,
155
165
  }) => {
@@ -165,6 +175,14 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
165
175
  const widthStyle = getWidthStyle(width);
166
176
  const resolvedTitle = title || 'Record Detail';
167
177
 
178
+ // Use renderView when both renderView and view are provided; otherwise fallback to children
179
+ const renderContent = (record: Record<string, unknown>) => {
180
+ if (renderView && view) {
181
+ return renderView(record, view);
182
+ }
183
+ return children(record);
184
+ };
185
+
168
186
  // --- Drawer Mode (Sheet) ---
169
187
  if (mode === 'drawer') {
170
188
  return (
@@ -179,7 +197,7 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
179
197
  {description && <SheetDescription>{description}</SheetDescription>}
180
198
  </SheetHeader>
181
199
  <div className="mt-4">
182
- {children(selectedRecord)}
200
+ {renderContent(selectedRecord)}
183
201
  </div>
184
202
  </SheetContent>
185
203
  </Sheet>
@@ -199,7 +217,7 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
199
217
  {description && <DialogDescription>{description}</DialogDescription>}
200
218
  </DialogHeader>
201
219
  <div className="mt-4">
202
- {children(selectedRecord)}
220
+ {renderContent(selectedRecord)}
203
221
  </div>
204
222
  </DialogContent>
205
223
  </Dialog>
@@ -258,7 +276,7 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
258
276
  {description && (
259
277
  <p className="text-sm text-muted-foreground mb-4">{description}</p>
260
278
  )}
261
- {children(selectedRecord)}
279
+ {renderContent(selectedRecord)}
262
280
  </div>
263
281
  </ResizablePanel>
264
282
  </PanelGroup>
@@ -267,6 +285,26 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
267
285
 
268
286
  // --- Popover Mode ---
269
287
  if (mode === 'popover') {
288
+ if (!popoverTrigger) {
289
+ // Fallback: render as a compact floating card when no trigger element is provided
290
+ if (!isOpen) return null;
291
+ return (
292
+ <Dialog open={isOpen} onOpenChange={setIsOpen}>
293
+ <DialogContent
294
+ className={cn('w-96 max-h-[80vh] overflow-y-auto p-4', className)}
295
+ style={widthStyle}
296
+ >
297
+ <DialogHeader>
298
+ <DialogTitle className="text-sm">{resolvedTitle}</DialogTitle>
299
+ {description && <DialogDescription className="text-xs">{description}</DialogDescription>}
300
+ </DialogHeader>
301
+ <div className="mt-2">
302
+ {renderContent(selectedRecord)}
303
+ </div>
304
+ </DialogContent>
305
+ </Dialog>
306
+ );
307
+ }
270
308
  return (
271
309
  <Popover open={isOpen} onOpenChange={setIsOpen}>
272
310
  {popoverTrigger && (
@@ -283,7 +321,7 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
283
321
  {description && (
284
322
  <p className="text-xs text-muted-foreground">{description}</p>
285
323
  )}
286
- {children(selectedRecord)}
324
+ {renderContent(selectedRecord)}
287
325
  </div>
288
326
  </PopoverContent>
289
327
  </Popover>
@@ -0,0 +1,68 @@
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
+ import { ChevronDown, ChevronRight } from "lucide-react"
10
+
11
+ import { cn } from "../lib/utils"
12
+
13
+ export interface SectionHeaderProps {
14
+ /** Section heading text */
15
+ title: string
16
+ /** Icon rendered before the title */
17
+ icon?: React.ReactNode
18
+ /** Enable collapse/expand toggle */
19
+ collapsible?: boolean
20
+ /** Current collapsed state */
21
+ collapsed?: boolean
22
+ /** Callback when toggling collapse/expand */
23
+ onToggle?: () => void
24
+ /** Data-testid attribute */
25
+ testId?: string
26
+ /** Additional CSS class name */
27
+ className?: string
28
+ }
29
+
30
+ /**
31
+ * Section heading with optional collapse/expand support.
32
+ *
33
+ * Renders as a `<button>` when collapsible, with a chevron icon
34
+ * indicating the expand/collapse state. Uses `aria-expanded` for accessibility.
35
+ */
36
+ function SectionHeader({ title, icon, collapsible, collapsed, onToggle, testId, className }: SectionHeaderProps) {
37
+ const titleContent = (
38
+ <h3 className="text-xs font-semibold text-foreground uppercase tracking-wider flex items-center gap-1.5">
39
+ {icon && <span className="text-muted-foreground shrink-0" aria-hidden="true">{icon}</span>}
40
+ {title}
41
+ </h3>
42
+ )
43
+ if (collapsible) {
44
+ return (
45
+ <button
46
+ data-testid={testId}
47
+ className={cn("flex items-center justify-between pt-4 pb-1.5 first:pt-0 w-full text-left", className)}
48
+ onClick={onToggle}
49
+ type="button"
50
+ aria-expanded={!collapsed}
51
+ >
52
+ {titleContent}
53
+ {collapsed ? (
54
+ <ChevronRight className="h-3 w-3 text-muted-foreground" />
55
+ ) : (
56
+ <ChevronDown className="h-3 w-3 text-muted-foreground" />
57
+ )}
58
+ </button>
59
+ )
60
+ }
61
+ return (
62
+ <div className={cn("pt-4 pb-1.5 first:pt-0", className)} data-testid={testId}>
63
+ {titleContent}
64
+ </div>
65
+ )
66
+ }
67
+
68
+ export { SectionHeader }
@@ -0,0 +1,313 @@
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
+ import React, { useState, useCallback, useMemo } from 'react';
10
+ import type { DebugFlags } from '@object-ui/core';
11
+ import { ComponentRegistry, DebugCollector } from '@object-ui/core';
12
+ import type { PerfEntry, ExprEntry, EventEntry, DebugEntry } from '@object-ui/core';
13
+ import { cn } from '../lib/utils';
14
+
15
+ /* ------------------------------------------------------------------ */
16
+ /* Types */
17
+ /* ------------------------------------------------------------------ */
18
+
19
+ export interface DebugPanelTab {
20
+ id: string;
21
+ label: string;
22
+ icon?: React.ReactNode;
23
+ render: () => React.ReactNode;
24
+ }
25
+
26
+ export interface DebugPanelProps {
27
+ /** Whether the panel is open */
28
+ open: boolean;
29
+ /** Toggle callback */
30
+ onClose: () => void;
31
+ /** Debug flags from the URL / hook */
32
+ flags?: DebugFlags;
33
+ /** Current schema being rendered (for the Schema tab) */
34
+ schema?: unknown;
35
+ /** Current data context (for the Data tab) */
36
+ dataContext?: unknown;
37
+ /** Extra tabs provided by plugins */
38
+ extraTabs?: DebugPanelTab[];
39
+ /** CSS class override */
40
+ className?: string;
41
+ }
42
+
43
+ /* ------------------------------------------------------------------ */
44
+ /* Built-in tab renderers */
45
+ /* ------------------------------------------------------------------ */
46
+
47
+ function SchemaTab({ schema }: { schema?: unknown }) {
48
+ if (!schema) {
49
+ return <p className="text-xs text-muted-foreground italic">No schema available</p>;
50
+ }
51
+ return (
52
+ <pre className="text-[11px] leading-relaxed font-mono overflow-auto max-h-[60vh] whitespace-pre-wrap break-all">
53
+ {JSON.stringify(schema, null, 2)}
54
+ </pre>
55
+ );
56
+ }
57
+
58
+ function DataTab({ dataContext }: { dataContext?: unknown }) {
59
+ if (!dataContext) {
60
+ return <p className="text-xs text-muted-foreground italic">No data context available</p>;
61
+ }
62
+ return (
63
+ <pre className="text-[11px] leading-relaxed font-mono overflow-auto max-h-[60vh] whitespace-pre-wrap break-all">
64
+ {JSON.stringify(dataContext, null, 2)}
65
+ </pre>
66
+ );
67
+ }
68
+
69
+ function RegistryTab() {
70
+ const entries = useMemo(() => {
71
+ try {
72
+ return ComponentRegistry.getAllTypes();
73
+ } catch {
74
+ return [];
75
+ }
76
+ }, []);
77
+
78
+ if (entries.length === 0) {
79
+ return <p className="text-xs text-muted-foreground italic">No registered components</p>;
80
+ }
81
+ return (
82
+ <div className="space-y-1 max-h-[60vh] overflow-auto">
83
+ {entries.map((name: string) => (
84
+ <div
85
+ key={name}
86
+ className="flex items-center gap-2 px-2 py-1 rounded text-xs font-mono bg-muted/30"
87
+ >
88
+ <span className="inline-block w-2 h-2 rounded-full bg-green-500 shrink-0" />
89
+ {name}
90
+ </div>
91
+ ))}
92
+ <p className="text-[10px] text-muted-foreground mt-2">
93
+ {entries.length} component{entries.length !== 1 ? 's' : ''} registered
94
+ </p>
95
+ </div>
96
+ );
97
+ }
98
+
99
+ function FlagsTab({ flags }: { flags?: DebugFlags }) {
100
+ if (!flags) {
101
+ return <p className="text-xs text-muted-foreground italic">No debug flags</p>;
102
+ }
103
+ return (
104
+ <pre className="text-[11px] leading-relaxed font-mono overflow-auto max-h-[60vh] whitespace-pre-wrap break-all">
105
+ {JSON.stringify(flags, null, 2)}
106
+ </pre>
107
+ );
108
+ }
109
+
110
+ /* ------------------------------------------------------------------ */
111
+ /* Collector-backed tabs (Perf / Expr / Events) */
112
+ /* ------------------------------------------------------------------ */
113
+
114
+ function useCollectorEntries(kind?: DebugEntry['kind']): DebugEntry[] {
115
+ const collector = DebugCollector.getInstance();
116
+ const [entries, setEntries] = useState<DebugEntry[]>(() => collector.getEntries(kind));
117
+
118
+ React.useEffect(() => {
119
+ // Sync on mount in case entries were added before subscribe
120
+ setEntries(collector.getEntries(kind));
121
+ const unsub = collector.subscribe(() => {
122
+ setEntries(collector.getEntries(kind));
123
+ });
124
+ return unsub;
125
+ }, [collector, kind]);
126
+
127
+ return entries;
128
+ }
129
+
130
+ function PerfTab() {
131
+ const entries = useCollectorEntries('perf');
132
+ const perfItems = entries.map((e) => e.data as PerfEntry);
133
+
134
+ if (perfItems.length === 0) {
135
+ return <p className="text-xs text-muted-foreground italic">No performance data collected yet</p>;
136
+ }
137
+ return (
138
+ <div className="space-y-1 max-h-[60vh] overflow-auto">
139
+ {perfItems.map((p, i) => (
140
+ <div
141
+ key={i}
142
+ className={cn(
143
+ 'flex items-center justify-between px-2 py-1 rounded text-xs font-mono',
144
+ p.durationMs > 16 ? 'bg-red-50 text-red-700' : 'bg-muted/30',
145
+ )}
146
+ >
147
+ <span className="truncate mr-2">{p.type}{p.id ? `:${p.id}` : ''}</span>
148
+ <span className="shrink-0 tabular-nums">{p.durationMs.toFixed(2)}ms</span>
149
+ </div>
150
+ ))}
151
+ <p className="text-[10px] text-muted-foreground mt-2">
152
+ {perfItems.length} render{perfItems.length !== 1 ? 's' : ''} tracked
153
+ </p>
154
+ </div>
155
+ );
156
+ }
157
+
158
+ function ExprTab() {
159
+ const entries = useCollectorEntries('expr');
160
+ const exprItems = entries.map((e) => e.data as ExprEntry);
161
+
162
+ if (exprItems.length === 0) {
163
+ return <p className="text-xs text-muted-foreground italic">No expression evaluations tracked yet</p>;
164
+ }
165
+ return (
166
+ <div className="space-y-1.5 max-h-[60vh] overflow-auto">
167
+ {exprItems.map((ex, i) => (
168
+ <div key={i} className="px-2 py-1.5 rounded bg-muted/30 text-xs font-mono">
169
+ <div className="text-muted-foreground truncate">{ex.expression}</div>
170
+ <div className="mt-0.5">→ {JSON.stringify(ex.result)}</div>
171
+ </div>
172
+ ))}
173
+ <p className="text-[10px] text-muted-foreground mt-2">
174
+ {exprItems.length} evaluation{exprItems.length !== 1 ? 's' : ''} tracked
175
+ </p>
176
+ </div>
177
+ );
178
+ }
179
+
180
+ function EventsTab() {
181
+ const entries = useCollectorEntries('event');
182
+ const eventItems = entries.map((e) => e.data as EventEntry);
183
+
184
+ if (eventItems.length === 0) {
185
+ return <p className="text-xs text-muted-foreground italic">No events captured yet</p>;
186
+ }
187
+ return (
188
+ <div className="space-y-1.5 max-h-[60vh] overflow-auto">
189
+ {eventItems.map((ev, i) => (
190
+ <div key={i} className="px-2 py-1.5 rounded bg-muted/30 text-xs font-mono">
191
+ <div className="flex items-center justify-between">
192
+ <span className="font-semibold">{ev.action}</span>
193
+ <span className="text-[10px] text-muted-foreground tabular-nums">
194
+ {new Date(ev.timestamp).toLocaleTimeString()}
195
+ </span>
196
+ </div>
197
+ {ev.payload !== undefined && (
198
+ <pre className="mt-0.5 text-[10px] text-muted-foreground truncate">
199
+ {JSON.stringify(ev.payload)}
200
+ </pre>
201
+ )}
202
+ </div>
203
+ ))}
204
+ <p className="text-[10px] text-muted-foreground mt-2">
205
+ {eventItems.length} event{eventItems.length !== 1 ? 's' : ''} captured
206
+ </p>
207
+ </div>
208
+ );
209
+ }
210
+
211
+ /* ------------------------------------------------------------------ */
212
+ /* DebugPanel */
213
+ /* ------------------------------------------------------------------ */
214
+
215
+ /**
216
+ * A floating developer debug panel activated via URL parameters or manual toggle.
217
+ *
218
+ * Built-in tabs:
219
+ * - **Schema** — current rendered JSON schema
220
+ * - **Data** — active data context
221
+ * - **Perf** — component render timing (highlights slow renders >16ms)
222
+ * - **Expr** — expression evaluation trace
223
+ * - **Events** — action/event timeline
224
+ * - **Registry** — all registered component types
225
+ * - **Flags** — current debug flags
226
+ *
227
+ * Plugins can inject additional tabs via the `extraTabs` prop.
228
+ */
229
+ export function DebugPanel({
230
+ open,
231
+ onClose,
232
+ flags,
233
+ schema,
234
+ dataContext,
235
+ extraTabs = [],
236
+ className,
237
+ }: DebugPanelProps) {
238
+ const builtInTabs: DebugPanelTab[] = useMemo(() => [
239
+ { id: 'schema', label: 'Schema', render: () => <SchemaTab schema={schema} /> },
240
+ { id: 'data', label: 'Data', render: () => <DataTab dataContext={dataContext} /> },
241
+ { id: 'perf', label: 'Perf', render: () => <PerfTab /> },
242
+ { id: 'expr', label: 'Expr', render: () => <ExprTab /> },
243
+ { id: 'events', label: 'Events', render: () => <EventsTab /> },
244
+ { id: 'registry', label: 'Registry', render: () => <RegistryTab /> },
245
+ { id: 'flags', label: 'Flags', render: () => <FlagsTab flags={flags} /> },
246
+ ], [schema, dataContext, flags]);
247
+
248
+ const allTabs = useMemo(() => [...builtInTabs, ...extraTabs], [builtInTabs, extraTabs]);
249
+ const [activeTabId, setActiveTabId] = useState(allTabs[0]?.id ?? 'schema');
250
+
251
+ const activeTab = allTabs.find((t) => t.id === activeTabId) ?? allTabs[0];
252
+
253
+ const handleTabChange = useCallback((id: string) => {
254
+ setActiveTabId(id);
255
+ }, []);
256
+
257
+ if (!open) return null;
258
+
259
+ return (
260
+ <div
261
+ className={cn(
262
+ 'fixed bottom-4 right-4 z-[9999] w-[420px] max-w-[95vw] rounded-lg border bg-background shadow-2xl',
263
+ 'flex flex-col overflow-hidden',
264
+ className,
265
+ )}
266
+ data-testid="debug-panel"
267
+ role="dialog"
268
+ aria-label="Developer Debug Panel"
269
+ >
270
+ {/* Header */}
271
+ <div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
272
+ <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
273
+ 🛠 Debug Panel
274
+ </span>
275
+ <button
276
+ onClick={onClose}
277
+ className="text-muted-foreground hover:text-foreground text-sm leading-none px-1"
278
+ aria-label="Close debug panel"
279
+ data-testid="debug-panel-close"
280
+ >
281
+
282
+ </button>
283
+ </div>
284
+
285
+ {/* Tabs */}
286
+ <div className="flex border-b overflow-x-auto" role="tablist">
287
+ {allTabs.map((tab) => (
288
+ <button
289
+ key={tab.id}
290
+ role="tab"
291
+ aria-selected={tab.id === activeTab?.id}
292
+ onClick={() => handleTabChange(tab.id)}
293
+ className={cn(
294
+ 'px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors',
295
+ tab.id === activeTab?.id
296
+ ? 'border-b-2 border-primary text-foreground'
297
+ : 'text-muted-foreground hover:text-foreground',
298
+ )}
299
+ data-testid={`debug-tab-${tab.id}`}
300
+ >
301
+ {tab.icon && <span className="mr-1">{tab.icon}</span>}
302
+ {tab.label}
303
+ </button>
304
+ ))}
305
+ </div>
306
+
307
+ {/* Content */}
308
+ <div className="p-3 overflow-auto max-h-[50vh]" data-testid="debug-panel-content">
309
+ {activeTab?.render()}
310
+ </div>
311
+ </div>
312
+ );
313
+ }
@@ -0,0 +1,134 @@
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
+ import { describe, it, expect, vi } from 'vitest';
10
+ import { render, screen, fireEvent } from '@testing-library/react';
11
+ import { DebugPanel } from '../DebugPanel';
12
+
13
+ describe('DebugPanel', () => {
14
+ it('should render nothing when open is false', () => {
15
+ const { container } = render(
16
+ <DebugPanel open={false} onClose={vi.fn()} />,
17
+ );
18
+ expect(container.innerHTML).toBe('');
19
+ });
20
+
21
+ it('should render the panel when open is true', () => {
22
+ render(<DebugPanel open={true} onClose={vi.fn()} />);
23
+ expect(screen.getByTestId('debug-panel')).toBeInTheDocument();
24
+ expect(screen.getByText('🛠 Debug Panel')).toBeInTheDocument();
25
+ });
26
+
27
+ it('should render built-in tabs', () => {
28
+ render(<DebugPanel open={true} onClose={vi.fn()} />);
29
+ expect(screen.getByTestId('debug-tab-schema')).toBeInTheDocument();
30
+ expect(screen.getByTestId('debug-tab-data')).toBeInTheDocument();
31
+ expect(screen.getByTestId('debug-tab-perf')).toBeInTheDocument();
32
+ expect(screen.getByTestId('debug-tab-expr')).toBeInTheDocument();
33
+ expect(screen.getByTestId('debug-tab-events')).toBeInTheDocument();
34
+ expect(screen.getByTestId('debug-tab-registry')).toBeInTheDocument();
35
+ expect(screen.getByTestId('debug-tab-flags')).toBeInTheDocument();
36
+ });
37
+
38
+ it('should call onClose when close button is clicked', () => {
39
+ const onClose = vi.fn();
40
+ render(<DebugPanel open={true} onClose={onClose} />);
41
+ fireEvent.click(screen.getByTestId('debug-panel-close'));
42
+ expect(onClose).toHaveBeenCalledTimes(1);
43
+ });
44
+
45
+ it('should switch tabs on click', () => {
46
+ render(
47
+ <DebugPanel
48
+ open={true}
49
+ onClose={vi.fn()}
50
+ schema={{ type: 'text', content: 'Hello' }}
51
+ dataContext={{ name: 'Test' }}
52
+ />,
53
+ );
54
+ // Default tab is Schema
55
+ expect(screen.getByTestId('debug-tab-schema')).toHaveAttribute('aria-selected', 'true');
56
+
57
+ // Click Data tab
58
+ fireEvent.click(screen.getByTestId('debug-tab-data'));
59
+ expect(screen.getByTestId('debug-tab-data')).toHaveAttribute('aria-selected', 'true');
60
+ expect(screen.getByTestId('debug-tab-schema')).toHaveAttribute('aria-selected', 'false');
61
+ });
62
+
63
+ it('should display schema JSON in Schema tab', () => {
64
+ const testSchema = { type: 'button', content: 'Click' };
65
+ render(<DebugPanel open={true} onClose={vi.fn()} schema={testSchema} />);
66
+ const content = screen.getByTestId('debug-panel-content');
67
+ expect(content.textContent).toContain('"type": "button"');
68
+ });
69
+
70
+ it('should display data JSON in Data tab', () => {
71
+ render(
72
+ <DebugPanel
73
+ open={true}
74
+ onClose={vi.fn()}
75
+ dataContext={{ user: 'Alice' }}
76
+ />,
77
+ );
78
+ fireEvent.click(screen.getByTestId('debug-tab-data'));
79
+ const content = screen.getByTestId('debug-panel-content');
80
+ expect(content.textContent).toContain('"user": "Alice"');
81
+ });
82
+
83
+ it('should display debug flags in Flags tab', () => {
84
+ render(
85
+ <DebugPanel
86
+ open={true}
87
+ onClose={vi.fn()}
88
+ flags={{ enabled: true, schema: true }}
89
+ />,
90
+ );
91
+ fireEvent.click(screen.getByTestId('debug-tab-flags'));
92
+ const content = screen.getByTestId('debug-panel-content');
93
+ expect(content.textContent).toContain('"enabled": true');
94
+ expect(content.textContent).toContain('"schema": true');
95
+ });
96
+
97
+ it('should render extra tabs from plugins', () => {
98
+ const extraTab = {
99
+ id: 'custom',
100
+ label: 'Custom',
101
+ render: () => <div data-testid="custom-content">Custom Content</div>,
102
+ };
103
+ render(<DebugPanel open={true} onClose={vi.fn()} extraTabs={[extraTab]} />);
104
+ expect(screen.getByTestId('debug-tab-custom')).toBeInTheDocument();
105
+
106
+ fireEvent.click(screen.getByTestId('debug-tab-custom'));
107
+ expect(screen.getByTestId('custom-content')).toBeInTheDocument();
108
+ });
109
+
110
+ it('should have proper accessibility attributes', () => {
111
+ render(<DebugPanel open={true} onClose={vi.fn()} />);
112
+ const panel = screen.getByTestId('debug-panel');
113
+ expect(panel).toHaveAttribute('role', 'dialog');
114
+ expect(panel).toHaveAttribute('aria-label', 'Developer Debug Panel');
115
+ });
116
+
117
+ it('should show empty state for Perf tab', () => {
118
+ render(<DebugPanel open={true} onClose={vi.fn()} />);
119
+ fireEvent.click(screen.getByTestId('debug-tab-perf'));
120
+ expect(screen.getByTestId('debug-panel-content').textContent).toContain('No performance data');
121
+ });
122
+
123
+ it('should show empty state for Expr tab', () => {
124
+ render(<DebugPanel open={true} onClose={vi.fn()} />);
125
+ fireEvent.click(screen.getByTestId('debug-tab-expr'));
126
+ expect(screen.getByTestId('debug-panel-content').textContent).toContain('No expression evaluations');
127
+ });
128
+
129
+ it('should show empty state for Events tab', () => {
130
+ render(<DebugPanel open={true} onClose={vi.fn()} />);
131
+ fireEvent.click(screen.getByTestId('debug-tab-events'));
132
+ expect(screen.getByTestId('debug-panel-content').textContent).toContain('No events captured');
133
+ });
134
+ });