@object-ui/react 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 (60) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/SchemaRenderer.js +61 -2
  3. package/dist/context/SchemaRendererContext.d.ts +4 -1
  4. package/dist/context/SchemaRendererContext.js +6 -4
  5. package/dist/hooks/index.d.ts +3 -0
  6. package/dist/hooks/index.js +3 -0
  7. package/dist/hooks/useActionEngine.d.ts +27 -0
  8. package/dist/hooks/useActionEngine.js +39 -0
  9. package/dist/hooks/useDebugMode.d.ts +43 -0
  10. package/dist/hooks/useDebugMode.js +71 -0
  11. package/dist/hooks/useGlobalUndo.d.ts +35 -0
  12. package/dist/hooks/useGlobalUndo.js +86 -0
  13. package/dist/hooks/useNavigationOverlay.d.ts +2 -0
  14. package/dist/hooks/useNavigationOverlay.js +12 -5
  15. package/dist/index.d.ts +3 -1
  16. package/dist/index.js +4 -1
  17. package/dist/spec-bridge/SpecBridge.d.ts +26 -0
  18. package/dist/spec-bridge/SpecBridge.js +62 -0
  19. package/dist/spec-bridge/bridges/dashboard.d.ts +81 -0
  20. package/dist/spec-bridge/bridges/dashboard.js +121 -0
  21. package/dist/spec-bridge/bridges/form-view.d.ts +44 -0
  22. package/dist/spec-bridge/bridges/form-view.js +77 -0
  23. package/dist/spec-bridge/bridges/list-view.d.ts +82 -0
  24. package/dist/spec-bridge/bridges/list-view.js +154 -0
  25. package/dist/spec-bridge/bridges/page.d.ts +68 -0
  26. package/dist/spec-bridge/bridges/page.js +81 -0
  27. package/dist/spec-bridge/index.d.ts +13 -0
  28. package/dist/spec-bridge/index.js +12 -0
  29. package/dist/spec-bridge/types.d.ts +34 -0
  30. package/dist/spec-bridge/types.js +8 -0
  31. package/package.json +7 -7
  32. package/src/SchemaRenderer.tsx +66 -0
  33. package/src/__tests__/LazyPluginLoader.test.tsx +1 -1
  34. package/src/__tests__/SchemaRenderer.debug.test.tsx +69 -0
  35. package/src/__tests__/SchemaRenderer.expressions.test.tsx +131 -0
  36. package/src/__tests__/useNavigationOverlay.test.ts +162 -0
  37. package/src/context/SchemaRendererContext.tsx +9 -4
  38. package/src/context/__tests__/useDataScope.test.tsx +91 -0
  39. package/src/hooks/__tests__/useActionEngine.test.ts +170 -0
  40. package/src/hooks/__tests__/useCrudShortcuts.test.ts +85 -0
  41. package/src/hooks/__tests__/useDebugMode.test.ts +83 -0
  42. package/src/hooks/__tests__/useFocusTrap.test.ts +247 -0
  43. package/src/hooks/__tests__/usePageTransition.test.ts +158 -0
  44. package/src/hooks/__tests__/useReducedMotion.test.ts +104 -0
  45. package/src/hooks/index.ts +3 -0
  46. package/src/hooks/useActionEngine.ts +84 -0
  47. package/src/hooks/useDebugMode.ts +95 -0
  48. package/src/hooks/useGlobalUndo.ts +98 -0
  49. package/src/hooks/useNavigationOverlay.ts +14 -5
  50. package/src/index.ts +6 -0
  51. package/src/spec-bridge/SpecBridge.ts +64 -0
  52. package/src/spec-bridge/__tests__/P1SpecBridge.test.ts +855 -0
  53. package/src/spec-bridge/__tests__/SpecBridge.test.ts +550 -0
  54. package/src/spec-bridge/bridges/dashboard.ts +180 -0
  55. package/src/spec-bridge/bridges/form-view.ts +109 -0
  56. package/src/spec-bridge/bridges/list-view.ts +187 -0
  57. package/src/spec-bridge/bridges/page.ts +136 -0
  58. package/src/spec-bridge/index.ts +14 -0
  59. package/src/spec-bridge/types.ts +38 -0
  60. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Tests for usePageTransition hook
3
+ */
4
+ import { describe, it, expect, vi } from 'vitest';
5
+ import { renderHook } from '@testing-library/react';
6
+ import { usePageTransition } from '../usePageTransition';
7
+
8
+ describe('usePageTransition', () => {
9
+
10
+ it('returns inactive result when type is none (default)', () => {
11
+ const { result } = renderHook(() => usePageTransition());
12
+ expect(result.current.isActive).toBe(false);
13
+ expect(result.current.enterClassName).toBe('');
14
+ expect(result.current.exitClassName).toBe('');
15
+ expect(result.current.type).toBe('none');
16
+ });
17
+
18
+ it('returns active result for fade transition', () => {
19
+ const { result } = renderHook(() => usePageTransition({ type: 'fade' }));
20
+ expect(result.current.isActive).toBe(true);
21
+ expect(result.current.enterClassName).toContain('fade-in');
22
+ expect(result.current.exitClassName).toContain('fade-out');
23
+ expect(result.current.type).toBe('fade');
24
+ });
25
+
26
+ it('returns correct classes for slide_up transition', () => {
27
+ const { result } = renderHook(() => usePageTransition({ type: 'slide_up' }));
28
+ expect(result.current.isActive).toBe(true);
29
+ expect(result.current.enterClassName).toContain('slide-in-from-bottom');
30
+ expect(result.current.exitClassName).toContain('slide-out-to-top');
31
+ });
32
+
33
+ it('returns correct classes for slide_down transition', () => {
34
+ const { result } = renderHook(() => usePageTransition({ type: 'slide_down' }));
35
+ expect(result.current.enterClassName).toContain('slide-in-from-top');
36
+ expect(result.current.exitClassName).toContain('slide-out-to-bottom');
37
+ });
38
+
39
+ it('returns correct classes for slide_left transition', () => {
40
+ const { result } = renderHook(() => usePageTransition({ type: 'slide_left' }));
41
+ expect(result.current.enterClassName).toContain('slide-in-from-right');
42
+ expect(result.current.exitClassName).toContain('slide-out-to-left');
43
+ });
44
+
45
+ it('returns correct classes for slide_right transition', () => {
46
+ const { result } = renderHook(() => usePageTransition({ type: 'slide_right' }));
47
+ expect(result.current.enterClassName).toContain('slide-in-from-left');
48
+ expect(result.current.exitClassName).toContain('slide-out-to-right');
49
+ });
50
+
51
+ it('returns correct classes for scale transition', () => {
52
+ const { result } = renderHook(() => usePageTransition({ type: 'scale' }));
53
+ expect(result.current.enterClassName).toContain('zoom-in');
54
+ expect(result.current.exitClassName).toContain('zoom-out');
55
+ });
56
+
57
+ it('returns correct classes for rotate transition', () => {
58
+ const { result } = renderHook(() => usePageTransition({ type: 'rotate' }));
59
+ expect(result.current.enterClassName).toContain('spin-in');
60
+ expect(result.current.exitClassName).toContain('spin-out');
61
+ });
62
+
63
+ it('returns correct classes for flip transition', () => {
64
+ const { result } = renderHook(() => usePageTransition({ type: 'flip' }));
65
+ expect(result.current.enterClassName).toContain('zoom-in');
66
+ expect(result.current.exitClassName).toContain('zoom-out');
67
+ });
68
+
69
+ it('uses default duration of 300ms', () => {
70
+ const { result } = renderHook(() => usePageTransition({ type: 'fade' }));
71
+ expect(result.current.duration).toBe(300);
72
+ expect(result.current.enterStyle.animationDuration).toBe('300ms');
73
+ });
74
+
75
+ it('accepts custom duration', () => {
76
+ const { result } = renderHook(() => usePageTransition({ type: 'fade', duration: 500 }));
77
+ expect(result.current.duration).toBe(500);
78
+ expect(result.current.enterStyle.animationDuration).toBe('500ms');
79
+ });
80
+
81
+ it('uses ease_in_out easing by default', () => {
82
+ const { result } = renderHook(() => usePageTransition({ type: 'fade' }));
83
+ expect(result.current.enterStyle.animationTimingFunction).toBe('ease-in-out');
84
+ });
85
+
86
+ it('accepts custom easing', () => {
87
+ const { result } = renderHook(() => usePageTransition({ type: 'fade', easing: 'spring' }));
88
+ expect(result.current.enterStyle.animationTimingFunction).toBe('cubic-bezier(0.34, 1.56, 0.64, 1)');
89
+ });
90
+
91
+ it('sets animationFillMode to both', () => {
92
+ const { result } = renderHook(() => usePageTransition({ type: 'fade' }));
93
+ expect(result.current.enterStyle.animationFillMode).toBe('both');
94
+ expect(result.current.exitStyle.animationFillMode).toBe('both');
95
+ });
96
+
97
+ it('applies crossFade positioning', () => {
98
+ const { result } = renderHook(() => usePageTransition({ type: 'fade', crossFade: true }));
99
+ expect(result.current.enterStyle.position).toBe('absolute');
100
+ expect(result.current.enterStyle.inset).toBe('0');
101
+ expect(result.current.exitStyle.position).toBe('absolute');
102
+ expect(result.current.exitStyle.inset).toBe('0');
103
+ });
104
+
105
+ it('does not apply crossFade positioning when disabled', () => {
106
+ const { result } = renderHook(() => usePageTransition({ type: 'fade', crossFade: false }));
107
+ expect(result.current.enterStyle.position).toBeUndefined();
108
+ expect(result.current.exitStyle.position).toBeUndefined();
109
+ });
110
+
111
+ it('returns inactive when reduced motion is preferred', () => {
112
+ // Mock window.matchMedia to simulate reduced motion preference
113
+ const original = window.matchMedia;
114
+ window.matchMedia = vi.fn((query: string) => ({
115
+ matches: query === '(prefers-reduced-motion: reduce)',
116
+ media: query,
117
+ onchange: null,
118
+ addEventListener: vi.fn(),
119
+ removeEventListener: vi.fn(),
120
+ addListener: vi.fn(),
121
+ removeListener: vi.fn(),
122
+ dispatchEvent: vi.fn(),
123
+ })) as unknown as typeof window.matchMedia;
124
+
125
+ const { result } = renderHook(() => usePageTransition({ type: 'fade' }));
126
+ expect(result.current.isActive).toBe(false);
127
+ expect(result.current.enterClassName).toBe('');
128
+ expect(result.current.exitClassName).toBe('');
129
+
130
+ window.matchMedia = original;
131
+ });
132
+
133
+ it('returns empty styles when inactive', () => {
134
+ const { result } = renderHook(() => usePageTransition({ type: 'none' }));
135
+ expect(result.current.enterStyle).toEqual({});
136
+ expect(result.current.exitStyle).toEqual({});
137
+ });
138
+
139
+ it('accepts linear easing', () => {
140
+ const { result } = renderHook(() => usePageTransition({ type: 'fade', easing: 'linear' }));
141
+ expect(result.current.enterStyle.animationTimingFunction).toBe('linear');
142
+ });
143
+
144
+ it('accepts ease easing', () => {
145
+ const { result } = renderHook(() => usePageTransition({ type: 'fade', easing: 'ease' }));
146
+ expect(result.current.enterStyle.animationTimingFunction).toBe('ease');
147
+ });
148
+
149
+ it('accepts ease_in easing', () => {
150
+ const { result } = renderHook(() => usePageTransition({ type: 'fade', easing: 'ease_in' }));
151
+ expect(result.current.enterStyle.animationTimingFunction).toBe('ease-in');
152
+ });
153
+
154
+ it('accepts ease_out easing', () => {
155
+ const { result } = renderHook(() => usePageTransition({ type: 'fade', easing: 'ease_out' }));
156
+ expect(result.current.enterStyle.animationTimingFunction).toBe('ease-out');
157
+ });
158
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Tests for useReducedMotion hook
3
+ */
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5
+ import { renderHook, act } from '@testing-library/react';
6
+ import { useReducedMotion } from '../useReducedMotion';
7
+
8
+ describe('useReducedMotion', () => {
9
+ let originalMatchMedia: typeof window.matchMedia;
10
+ let listeners: Map<string, ((event: MediaQueryListEvent) => void)[]>;
11
+
12
+ beforeEach(() => {
13
+ originalMatchMedia = window.matchMedia;
14
+ listeners = new Map();
15
+
16
+ window.matchMedia = vi.fn((query: string) => {
17
+ const mediaQueryList = {
18
+ matches: false,
19
+ media: query,
20
+ onchange: null,
21
+ addEventListener: vi.fn((event: string, handler: (event: MediaQueryListEvent) => void) => {
22
+ const key = `${query}:${event}`;
23
+ if (!listeners.has(key)) {
24
+ listeners.set(key, []);
25
+ }
26
+ listeners.get(key)!.push(handler);
27
+ }),
28
+ removeEventListener: vi.fn(),
29
+ addListener: vi.fn(),
30
+ removeListener: vi.fn(),
31
+ dispatchEvent: vi.fn(),
32
+ } as unknown as MediaQueryList;
33
+ return mediaQueryList;
34
+ });
35
+ });
36
+
37
+ afterEach(() => {
38
+ window.matchMedia = originalMatchMedia;
39
+ listeners.clear();
40
+ });
41
+
42
+ it('returns false when prefers-reduced-motion is not set', () => {
43
+ const { result } = renderHook(() => useReducedMotion());
44
+ expect(result.current).toBe(false);
45
+ });
46
+
47
+ it('returns true when prefers-reduced-motion is set', () => {
48
+ window.matchMedia = vi.fn((query: string) => ({
49
+ matches: true,
50
+ media: query,
51
+ onchange: null,
52
+ addEventListener: vi.fn(),
53
+ removeEventListener: vi.fn(),
54
+ addListener: vi.fn(),
55
+ removeListener: vi.fn(),
56
+ dispatchEvent: vi.fn(),
57
+ })) as unknown as typeof window.matchMedia;
58
+
59
+ const { result } = renderHook(() => useReducedMotion());
60
+ expect(result.current).toBe(true);
61
+ });
62
+
63
+ it('registers a change event listener', () => {
64
+ renderHook(() => useReducedMotion());
65
+
66
+ const key = '(prefers-reduced-motion: reduce):change';
67
+ expect(listeners.has(key)).toBe(true);
68
+ expect(listeners.get(key)!.length).toBe(1);
69
+ });
70
+
71
+ it('updates when media query changes', () => {
72
+ const { result } = renderHook(() => useReducedMotion());
73
+
74
+ expect(result.current).toBe(false);
75
+
76
+ const key = '(prefers-reduced-motion: reduce):change';
77
+ const handler = listeners.get(key)![0];
78
+
79
+ act(() => {
80
+ handler({ matches: true } as MediaQueryListEvent);
81
+ });
82
+
83
+ expect(result.current).toBe(true);
84
+ });
85
+
86
+ it('cleans up event listener on unmount', () => {
87
+ const removeEventListener = vi.fn();
88
+ window.matchMedia = vi.fn((query: string) => ({
89
+ matches: false,
90
+ media: query,
91
+ onchange: null,
92
+ addEventListener: vi.fn(),
93
+ removeEventListener,
94
+ addListener: vi.fn(),
95
+ removeListener: vi.fn(),
96
+ dispatchEvent: vi.fn(),
97
+ })) as unknown as typeof window.matchMedia;
98
+
99
+ const { unmount } = renderHook(() => useReducedMotion());
100
+ unmount();
101
+
102
+ expect(removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
103
+ });
104
+ });
@@ -30,3 +30,6 @@ export * from './usePageTransition';
30
30
  export * from './useViewTransition';
31
31
  export * from './useETagCache';
32
32
  export * from './useSchemaPersistence';
33
+ export * from './useGlobalUndo';
34
+ export * from './useDebugMode';
35
+ export * from './useActionEngine';
@@ -0,0 +1,84 @@
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
+ * useActionEngine — React hook wrapping ActionEngine for location-based action management.
11
+ *
12
+ * Provides declarative access to the ActionEngine's location filtering, bulk operations,
13
+ * keyboard shortcut handling, and execution pipeline from React components.
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * const { getActionsForLocation, executeAction } = useActionEngine({
18
+ * actions: schema.actions,
19
+ * context: { record, user },
20
+ * });
21
+ *
22
+ * const toolbarActions = getActionsForLocation('list_toolbar');
23
+ * ```
24
+ */
25
+
26
+ import { useCallback, useMemo } from 'react';
27
+ import {
28
+ ActionEngine,
29
+ type ActionLocation,
30
+ type ActionDef,
31
+ type ActionContext,
32
+ type ActionResult,
33
+ } from '@object-ui/core';
34
+
35
+ export interface UseActionEngineOptions {
36
+ /** Action definitions to register */
37
+ actions?: ActionDef[];
38
+ /** Action context (record, user, etc.) */
39
+ context?: ActionContext;
40
+ }
41
+
42
+ export interface UseActionEngineReturn {
43
+ /** Get actions available at a specific location, sorted by priority */
44
+ getActionsForLocation: (location: ActionLocation) => ActionDef[];
45
+ /** Get actions that support bulk operations */
46
+ getBulkActions: () => ActionDef[];
47
+ /** Execute an action by name */
48
+ executeAction: (name: string, contextOverride?: Partial<ActionContext>) => Promise<ActionResult>;
49
+ /** Handle a keyboard shortcut */
50
+ handleShortcut: (keys: string) => Promise<ActionResult | null>;
51
+ /** The underlying ActionEngine instance */
52
+ engine: ActionEngine;
53
+ }
54
+
55
+ export function useActionEngine(options: UseActionEngineOptions = {}): UseActionEngineReturn {
56
+ const { actions = [], context = {} } = options;
57
+
58
+ const engine = useMemo(() => {
59
+ const e = new ActionEngine(context);
60
+ e.registerActions(actions);
61
+ return e;
62
+ // eslint-disable-next-line react-hooks/exhaustive-deps
63
+ }, [JSON.stringify(actions), JSON.stringify(context)]);
64
+
65
+ const getActionsForLocation = useCallback(
66
+ (location: ActionLocation) => engine.getActionsForLocation(location),
67
+ [engine],
68
+ );
69
+
70
+ const getBulkActions = useCallback(() => engine.getBulkActions(), [engine]);
71
+
72
+ const executeAction = useCallback(
73
+ (name: string, contextOverride?: Partial<ActionContext>) =>
74
+ engine.executeAction(name, contextOverride),
75
+ [engine],
76
+ );
77
+
78
+ const handleShortcut = useCallback(
79
+ (keys: string) => engine.handleShortcut(keys),
80
+ [engine],
81
+ );
82
+
83
+ return { getActionsForLocation, getBulkActions, executeAction, handleShortcut, engine };
84
+ }
@@ -0,0 +1,95 @@
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 { useState, useEffect, useCallback, useMemo } from 'react';
10
+ import { parseDebugFlags, type DebugFlags } from '@object-ui/core';
11
+
12
+ /**
13
+ * Result returned by {@link useDebugMode}.
14
+ */
15
+ export interface UseDebugModeResult {
16
+ /** Whether debug mode is currently enabled */
17
+ enabled: boolean;
18
+ /** Fine-grained debug flags parsed from the URL */
19
+ flags: DebugFlags;
20
+ /** Toggle debug mode on/off */
21
+ toggle: () => void;
22
+ /** Explicitly set debug mode */
23
+ setEnabled: (value: boolean) => void;
24
+ }
25
+
26
+ /**
27
+ * Hook for managing debug mode.
28
+ *
29
+ * Automatically detects `?__debug` (and sub-flags like `?__debug_schema`)
30
+ * from the URL. Supports manual toggle and Ctrl+Shift+D keyboard shortcut.
31
+ *
32
+ * SSR-safe — returns disabled state when `window` is unavailable.
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * function App() {
37
+ * const debug = useDebugMode();
38
+ * return (
39
+ * <>
40
+ * {debug.enabled && <DebugPanel flags={debug.flags} />}
41
+ * <button onClick={debug.toggle}>Toggle Debug</button>
42
+ * </>
43
+ * );
44
+ * }
45
+ * ```
46
+ */
47
+ export function useDebugMode(): UseDebugModeResult {
48
+ const urlFlags = useMemo(() => {
49
+ try {
50
+ return typeof window !== 'undefined'
51
+ ? parseDebugFlags(window.location.search)
52
+ : { enabled: false } as DebugFlags;
53
+ } catch {
54
+ return { enabled: false } as DebugFlags;
55
+ }
56
+ }, []);
57
+
58
+ const [manualEnabled, setManualEnabled] = useState<boolean | null>(null);
59
+
60
+ // Effective enabled: manual override wins, then URL flags
61
+ const enabled = manualEnabled !== null ? manualEnabled : urlFlags.enabled;
62
+
63
+ // Flags reflect URL-parsed flags plus effective enabled state
64
+ const flags: DebugFlags = useMemo(
65
+ () => ({ ...urlFlags, enabled }),
66
+ [urlFlags, enabled],
67
+ );
68
+
69
+ const toggle = useCallback(() => {
70
+ setManualEnabled((prev) => {
71
+ if (prev === null) return !urlFlags.enabled;
72
+ return !prev;
73
+ });
74
+ }, [urlFlags.enabled]);
75
+
76
+ const setEnabled = useCallback((value: boolean) => {
77
+ setManualEnabled(value);
78
+ }, []);
79
+
80
+ // Ctrl+Shift+D keyboard shortcut
81
+ useEffect(() => {
82
+ if (typeof window === 'undefined') return;
83
+
84
+ const handler = (e: KeyboardEvent) => {
85
+ if (e.ctrlKey && e.shiftKey && e.key === 'D') {
86
+ e.preventDefault();
87
+ toggle();
88
+ }
89
+ };
90
+ document.addEventListener('keydown', handler);
91
+ return () => document.removeEventListener('keydown', handler);
92
+ }, [toggle]);
93
+
94
+ return { enabled, flags, toggle, setEnabled };
95
+ }
@@ -0,0 +1,98 @@
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 { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
10
+ import { globalUndoManager, type UndoableOperation } from '@object-ui/core';
11
+
12
+ export interface UseGlobalUndoOptions {
13
+ /** DataSource to execute undo/redo operations. */
14
+ dataSource?: {
15
+ create(objectName: string, data: Record<string, unknown>): Promise<unknown>;
16
+ update(objectName: string, recordId: string, data: Record<string, unknown>): Promise<unknown>;
17
+ delete(objectName: string, recordId: string): Promise<unknown>;
18
+ };
19
+ /** Callback after successful undo. */
20
+ onUndo?: (op: UndoableOperation) => void;
21
+ /** Callback after successful redo. */
22
+ onRedo?: (op: UndoableOperation) => void;
23
+ }
24
+
25
+ function getSnapshot() {
26
+ return {
27
+ canUndo: globalUndoManager.canUndo,
28
+ canRedo: globalUndoManager.canRedo,
29
+ undoDescription: globalUndoManager.peekUndo()?.description,
30
+ redoDescription: globalUndoManager.peekRedo()?.description,
31
+ history: globalUndoManager.getHistory(),
32
+ };
33
+ }
34
+
35
+ function getServerSnapshot() {
36
+ return { canUndo: false, canRedo: false, undoDescription: undefined, redoDescription: undefined, history: [] as UndoableOperation[] };
37
+ }
38
+
39
+ // Cache reference to avoid re-renders when nothing changed
40
+ let cachedSnapshot = getSnapshot();
41
+ function subscribe(callback: () => void) {
42
+ return globalUndoManager.subscribe(() => {
43
+ cachedSnapshot = getSnapshot();
44
+ callback();
45
+ });
46
+ }
47
+ function getCachedSnapshot() { return cachedSnapshot; }
48
+
49
+ /**
50
+ * React hook that wraps the global UndoManager for use in console components.
51
+ *
52
+ * Provides reactive undo/redo state, executes data operations through the
53
+ * supplied dataSource, and registers Ctrl+Z / Ctrl+Shift+Z keyboard shortcuts.
54
+ */
55
+ export function useGlobalUndo(options: UseGlobalUndoOptions = {}) {
56
+ const optionsRef = useRef(options);
57
+ optionsRef.current = options;
58
+
59
+ const state = useSyncExternalStore(subscribe, getCachedSnapshot, getServerSnapshot);
60
+
61
+ const executeOp = useCallback(async (op: UndoableOperation, data: Record<string, unknown>, mode: 'undo' | 'redo') => {
62
+ const ds = optionsRef.current.dataSource;
63
+ if (!ds) return;
64
+ const action = mode === 'undo'
65
+ ? ({ create: 'delete', update: 'update', delete: 'create' } as const)[op.type]
66
+ : op.type;
67
+ if (action === 'delete') await ds.delete(op.objectName, op.recordId);
68
+ else if (action === 'update') await ds.update(op.objectName, op.recordId, data);
69
+ else await ds.create(op.objectName, data);
70
+ }, []);
71
+
72
+ const undo = useCallback(async () => {
73
+ const op = globalUndoManager.popUndo();
74
+ if (!op) return;
75
+ await executeOp(op, op.undoData, 'undo');
76
+ optionsRef.current.onUndo?.(op);
77
+ }, [executeOp]);
78
+
79
+ const redo = useCallback(async () => {
80
+ const op = globalUndoManager.popRedo();
81
+ if (!op) return;
82
+ await executeOp(op, op.redoData, 'redo');
83
+ optionsRef.current.onRedo?.(op);
84
+ }, [executeOp]);
85
+
86
+ // Keyboard shortcuts: Ctrl+Z (undo), Ctrl+Shift+Z (redo)
87
+ useEffect(() => {
88
+ const handler = (e: KeyboardEvent) => {
89
+ if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z') return;
90
+ e.preventDefault();
91
+ if (e.shiftKey) { void redo(); } else { void undo(); }
92
+ };
93
+ window.addEventListener('keydown', handler);
94
+ return () => window.removeEventListener('keydown', handler);
95
+ }, [undo, redo]);
96
+
97
+ return useMemo(() => ({ ...state, undo, redo }), [state, undo, redo]);
98
+ }
@@ -64,6 +64,8 @@ export interface NavigationOverlayState {
64
64
  width: string | number | undefined;
65
65
  /** Whether navigation is an overlay mode (drawer/modal/split/popover) */
66
66
  isOverlay: boolean;
67
+ /** The target view/form name from NavigationConfig */
68
+ view: string | undefined;
67
69
  }
68
70
 
69
71
  /**
@@ -102,6 +104,7 @@ export function useNavigationOverlay(
102
104
 
103
105
  const mode: NavigationMode = navigation?.mode ?? 'page';
104
106
  const width = navigation?.width;
107
+ const view = navigation?.view;
105
108
  const isOverlay = mode === 'drawer' || mode === 'modal' || mode === 'split' || mode === 'popover';
106
109
 
107
110
  const close = useCallback(() => {
@@ -126,7 +129,7 @@ export function useNavigationOverlay(
126
129
  if (!navigation) {
127
130
  const recordId = record._id || record.id;
128
131
  if (onNavigate && recordId != null) {
129
- onNavigate(recordId as string | number, 'view');
132
+ onNavigate(recordId as string | number, view ?? 'view');
130
133
  }
131
134
  return;
132
135
  }
@@ -136,10 +139,15 @@ export function useNavigationOverlay(
136
139
  return;
137
140
  }
138
141
 
139
- // new_window / openNewTab — open in new browser tab
142
+ // new_window / openNewTab — delegate to onNavigate when available, else open directly
140
143
  if (mode === 'new_window' || navigation.openNewTab) {
141
144
  const recordId = record._id || record.id;
142
- const url = objectName ? `/${objectName}/${recordId}` : `/${recordId}`;
145
+ if (onNavigate && recordId != null) {
146
+ onNavigate(recordId as string | number, 'new_window');
147
+ return;
148
+ }
149
+ const viewPath = view ? `/${view}` : '';
150
+ const url = objectName ? `/${objectName}/${recordId}${viewPath}` : `/${recordId}${viewPath}`;
143
151
  window.open(url, '_blank');
144
152
  return;
145
153
  }
@@ -148,7 +156,7 @@ export function useNavigationOverlay(
148
156
  if (mode === 'page') {
149
157
  const recordId = record._id || record.id;
150
158
  if (onNavigate && recordId != null) {
151
- onNavigate(recordId as string | number, 'view');
159
+ onNavigate(recordId as string | number, view ?? 'view');
152
160
  }
153
161
  return;
154
162
  }
@@ -173,8 +181,9 @@ export function useNavigationOverlay(
173
181
  setIsOpen,
174
182
  handleClick,
175
183
  width,
184
+ view,
176
185
  isOverlay,
177
186
  }),
178
- [isOpen, selectedRecord, mode, close, open, handleClick, width, isOverlay]
187
+ [isOpen, selectedRecord, mode, close, open, handleClick, width, view, isOverlay]
179
188
  );
180
189
  }
package/src/index.ts CHANGED
@@ -11,11 +11,17 @@ export * from './hooks'; // will be empty for now
11
11
  export * from './context'; // will be empty for now
12
12
  export * from './components/form';
13
13
  export * from './LazyPluginLoader';
14
+ export * from './spec-bridge';
15
+
16
+ // i18n utilities
17
+ export { resolveI18nLabel } from './utils/i18n';
14
18
 
15
19
  // Built-in i18n support
16
20
  export {
17
21
  I18nProvider,
18
22
  useObjectTranslation,
23
+ useObjectLabel,
24
+ useSafeFieldLabel,
19
25
  useI18nContext,
20
26
  createI18n,
21
27
  getDirection,