-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathuseNavigationOverlay.ts
More file actions
269 lines (248 loc) · 9.51 KB
/
Copy pathuseNavigationOverlay.ts
File metadata and controls
269 lines (248 loc) · 9.51 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* useNavigationOverlay
*
* A reusable hook for handling NavigationConfig-driven row/item click behavior.
* Manages overlay state (drawer/modal/split/popover) and generates click handlers
* that respect the ViewNavigationConfig specification.
*
* Used by plugin-grid, plugin-list, plugin-detail and any component that needs
* NavigationConfig support.
*/
import { useState, useCallback, useMemo } from 'react';
/**
* Inline ViewNavigationConfig to avoid importing from @object-ui/types
* (which may not be a direct dependency of @object-ui/react).
* Mirrors the canonical definition in @object-ui/types/objectql.ts.
*/
export interface NavigationConfig {
mode: 'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none';
view?: string;
preventNavigation?: boolean;
openNewTab?: boolean;
/** Spec `NavigationConfig.size` (#2578) — coarse overlay bucket. */
size?: 'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
/** @deprecated [#2578 → `size`] explicit pixel/percent width. */
width?: string | number;
}
/**
* Pixel cap per overlay `size` bucket, clamped to the viewport at render —
* mirrors `plugin-view/src/recordSurface.ts` (`OVERLAY_SIZE_PX`), which owns
* the `size: 'auto'` field-count derivation this layer cannot perform (no
* object schema here). Kept in lockstep by the spec-parity test.
*
* Before #2942 this hook only read the deprecated `width`, so an authored
* `size` bucket was silently ignored by every host except app-shell (which
* pre-resolves it before calling in).
*/
const OVERLAY_SIZE_WIDTHS: Record<'sm' | 'md' | 'lg' | 'xl' | 'full', string> = {
sm: 'min(92vw, 480px)',
md: 'min(92vw, 720px)',
lg: 'min(92vw, 960px)',
xl: 'min(92vw, 1200px)',
full: 'min(92vw, 1600px)',
};
/**
* Resolve the overlay width from a NavigationConfig: an explicit `width` wins
* (app-shell pre-resolves `size` into it); otherwise a declared bucket maps
* through {@link OVERLAY_SIZE_WIDTHS}. `'auto'`/absent stays `undefined` — the
* host's default width — because deriving `auto` needs the object's field
* count, which only schema-aware hosts have. Exported for the parity test.
*/
export function resolveOverlayWidth(navigation: NavigationConfig | undefined): string | number | undefined {
if (!navigation) return undefined;
if (navigation.width !== undefined) return navigation.width;
const size = navigation.size;
if (size && size !== 'auto') return OVERLAY_SIZE_WIDTHS[size];
return undefined;
}
export type NavigationMode = NavigationConfig['mode'];
export interface UseNavigationOverlayOptions {
/** The navigation configuration from the schema */
navigation?: NavigationConfig;
/** Object name — used to build default URLs for page/new_window modes */
objectName?: string;
/** External onNavigate callback (e.g., from ActionProvider or parent) */
onNavigate?: (recordId: string | number, action?: string) => void;
/** External onRowClick callback — if set, takes full priority */
onRowClick?: (record: Record<string, unknown>) => void;
}
/**
* Optional event-like payload accepted by `handleClick`. We don't depend on
* React's synthetic event type to keep this hook framework-agnostic — only
* the few fields needed for modifier detection are read.
*/
export interface HandleClickModifiers {
/** macOS Command key — open in new tab when held */
metaKey?: boolean;
/** Windows/Linux Control key — open in new tab when held */
ctrlKey?: boolean;
/** Mouse button — 1 = middle click (treated as new tab) */
button?: number;
}
export interface NavigationOverlayState {
/** Whether the overlay (drawer/modal/split/popover) is open */
isOpen: boolean;
/** The record that triggered the navigation */
selectedRecord: Record<string, unknown> | null;
/** The resolved navigation mode */
mode: NavigationMode;
/** Close the overlay */
close: () => void;
/** Open the overlay with a specific record */
open: (record: Record<string, unknown>) => void;
/** Set the open state (for controlled Sheet/Dialog `onOpenChange`) */
setIsOpen: (open: boolean) => void;
/**
* The click handler to attach to rows/items.
*
* Accepts an optional event (or any object with `metaKey`/`ctrlKey`/`button`)
* to detect modifier clicks. When `Cmd`/`Ctrl`/middle-click is detected, the
* record opens in a new browser tab as a full page regardless of the
* configured mode — matches Linear / Notion / Airtable convention.
*/
handleClick: (record: Record<string, unknown>, event?: HandleClickModifiers) => void;
/** The width from NavigationConfig (for drawer/modal/split sizing) */
width: string | number | undefined;
/** Whether navigation is an overlay mode (drawer/modal/split/popover) */
isOverlay: boolean;
/** The target view/form name from NavigationConfig */
view: string | undefined;
}
/**
* Hook for NavigationConfig-driven navigation overlay.
*
* @example
* ```tsx
* const { handleClick, isOpen, selectedRecord, mode, close, width } =
* useNavigationOverlay({
* navigation: schema.navigation,
* objectName: schema.objectName,
* onNavigate: schema.onNavigate,
* onRowClick: props.onRowClick,
* });
*
* return (
* <>
* <DataTable onRowClick={handleClick} ... />
* {isOpen && mode === 'drawer' && (
* <Sheet open onOpenChange={() => close()}>
* <SheetContent style={{ maxWidth: width }}>
* <RecordDetail record={selectedRecord} />
* </SheetContent>
* </Sheet>
* )}
* </>
* );
* ```
*/
export function useNavigationOverlay(
options: UseNavigationOverlayOptions
): NavigationOverlayState {
const { navigation, objectName, onNavigate, onRowClick } = options;
const [isOpen, setIsOpen] = useState(false);
const [selectedRecord, setSelectedRecord] = useState<Record<string, unknown> | null>(null);
const mode: NavigationMode = navigation?.mode ?? 'page';
const width = resolveOverlayWidth(navigation);
const view = navigation?.view;
const isOverlay = mode === 'drawer' || mode === 'modal' || mode === 'split' || mode === 'popover';
const close = useCallback(() => {
setIsOpen(false);
setSelectedRecord(null);
}, []);
const open = useCallback((record: Record<string, unknown>) => {
setSelectedRecord(record);
setIsOpen(true);
}, []);
const handleClick = useCallback(
(record: Record<string, unknown>, event?: HandleClickModifiers) => {
// External onRowClick takes full priority. Forward the modifier event
// so parent handlers (e.g. ObjectView) can still implement Cmd/Ctrl/
// middle-click → open in new tab.
if (onRowClick) {
(onRowClick as (r: Record<string, unknown>, e?: HandleClickModifiers) => void)(record, event);
return;
}
// Modifier / middle-click → always open in a new browser tab as a full
// page. Mirrors browser link convention (Cmd/Ctrl+Click, middle-click)
// so users can fan out multiple records into tabs from any list/board/
// gallery, regardless of the configured navigation mode.
const isModifierClick = !!(
event && (event.metaKey || event.ctrlKey || event.button === 1)
);
if (isModifierClick) {
const recordId = record.id || record._id;
if (onNavigate && recordId != null) {
onNavigate(recordId as string | number, 'new_window');
return;
}
}
// No navigation config — default to page navigation
if (!navigation) {
const recordId = record.id || record._id;
if (onNavigate && recordId != null) {
onNavigate(recordId as string | number, view ?? 'view');
}
return;
}
// 'none' or preventNavigation — do nothing
if (mode === 'none' || navigation.preventNavigation) {
return;
}
// new_window / openNewTab — delegate to onNavigate when available, else open directly
if (mode === 'new_window' || navigation.openNewTab) {
const recordId = record.id || record._id;
if (onNavigate && recordId != null) {
onNavigate(recordId as string | number, 'new_window');
return;
}
// Build a URL that matches the AppContent route shape
// `:objectName/record/:recordId`. Previously this used
// `/{object}/{id}` which is unrouted and produced a silent
// blank page when users middle-/Cmd-clicked a gallery card.
const encodedId = encodeURIComponent(String(recordId));
const url = objectName
? `/${objectName}/record/${encodedId}`
: `/${encodedId}`;
window.open(url, '_blank');
return;
}
// page — delegate to onNavigate callback
if (mode === 'page') {
const recordId = record.id || record._id;
if (onNavigate && recordId != null) {
onNavigate(recordId as string | number, view ?? 'view');
}
return;
}
// Overlay modes: drawer, modal, split, popover
if (isOverlay) {
setSelectedRecord(record);
setIsOpen(true);
return;
}
},
[onRowClick, navigation, mode, objectName, onNavigate, isOverlay, view]
);
return useMemo(
() => ({
isOpen,
selectedRecord,
mode,
close,
open,
setIsOpen,
handleClick,
width,
view,
isOverlay,
}),
[isOpen, selectedRecord, mode, close, open, handleClick, width, view, isOverlay]
);
}