@adecore/ui 0.19.0 → 0.20.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 (40) hide show
  1. package/README.md +4 -0
  2. package/dist/ColumnResizeHandle.d.ts +3 -2
  3. package/dist/ColumnResizeHandle.js +2 -2
  4. package/dist/SlidingColumn.d.ts +2 -1
  5. package/dist/SlidingColumn.js +11 -4
  6. package/dist/index.d.ts +6 -0
  7. package/dist/index.js +4 -0
  8. package/dist/locales/en.json +10 -0
  9. package/dist/locales/nl.json +10 -0
  10. package/dist/theme.css +181 -0
  11. package/dist/useColumnResize.js +12 -17
  12. package/dist/workspace/SplitView.d.ts +49 -0
  13. package/dist/workspace/SplitView.js +421 -0
  14. package/dist/workspace/TabStrip.d.ts +42 -0
  15. package/dist/workspace/TabStrip.js +168 -0
  16. package/dist/workspace/Workspace.d.ts +15 -0
  17. package/dist/workspace/Workspace.js +27 -0
  18. package/dist/workspace/context.d.ts +6 -0
  19. package/dist/workspace/context.js +5 -0
  20. package/dist/workspace/geometry.d.ts +39 -0
  21. package/dist/workspace/geometry.js +153 -0
  22. package/dist/workspace/model.d.ts +83 -0
  23. package/dist/workspace/model.js +360 -0
  24. package/dist/workspace/preview.d.ts +35 -0
  25. package/dist/workspace/preview.js +72 -0
  26. package/package.json +1 -1
  27. package/src/ColumnResizeHandle.tsx +4 -3
  28. package/src/SlidingColumn.tsx +23 -6
  29. package/src/index.ts +19 -0
  30. package/src/locales/en.json +10 -0
  31. package/src/locales/nl.json +10 -0
  32. package/src/theme.css +181 -0
  33. package/src/useColumnResize.ts +12 -18
  34. package/src/workspace/SplitView.tsx +656 -0
  35. package/src/workspace/TabStrip.tsx +296 -0
  36. package/src/workspace/Workspace.tsx +72 -0
  37. package/src/workspace/context.ts +8 -0
  38. package/src/workspace/geometry.ts +190 -0
  39. package/src/workspace/model.ts +440 -0
  40. package/src/workspace/preview.ts +116 -0
@@ -0,0 +1,656 @@
1
+ import {
2
+ useCallback,
3
+ useContext,
4
+ useEffect,
5
+ useId,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ type CSSProperties,
10
+ type DragEvent,
11
+ type KeyboardEvent,
12
+ type ReactNode
13
+ } from 'react';
14
+ import { useRender } from '@base-ui-components/react/use-render';
15
+ import { GripVertical, Maximize2, Minimize2 } from 'lucide-react';
16
+ import clsx from 'clsx';
17
+ import { useTranslation } from 'react-i18next';
18
+ import { IconButton } from '../IconButton.tsx';
19
+ import { WorkspaceContext, type WorkspaceLayout } from './context.ts';
20
+ import {
21
+ paneRadius,
22
+ resizeSplit,
23
+ splitGeometry,
24
+ splitSideAt,
25
+ tabGapAt,
26
+ type SplitDivider,
27
+ type SplitEdges,
28
+ type SplitMinimum,
29
+ type SplitRect
30
+ } from './geometry.ts';
31
+ import { splitPanes, updateSplitLayout, type SplitCommand, type SplitLayout, type SplitPane } from './model.ts';
32
+ import { TabStrip, type TabStripItem, type TabStripProps } from './TabStrip.tsx';
33
+ import { dropPreviewPath, rectPreview, tabPreview, type DropPreviewShape } from './preview.ts';
34
+
35
+ export interface SplitViewInfo {
36
+ paneId: string;
37
+ active: boolean;
38
+ }
39
+
40
+ export interface SplitViewBounds extends SplitViewInfo {
41
+ viewId: string;
42
+ /** Viewport coordinates, suitable for a native surface kept outside the layout's DOM. */
43
+ rect: SplitRect;
44
+ borderRadius: string;
45
+ }
46
+
47
+ export interface SplitDropTarget {
48
+ paneId: string;
49
+ /** A body drop names its zone; an omitted side denotes the tab strip. */
50
+ side?: 'left' | 'right' | 'top' | 'bottom' | 'center';
51
+ /** A gap in the original tab order, before the source is lifted out. */
52
+ index?: number;
53
+ }
54
+
55
+ export interface SplitDrag {
56
+ sourceId: string;
57
+ viewId?: string;
58
+ }
59
+
60
+ export interface SplitViewProps extends Omit<useRender.ComponentProps<'div'>, 'children'> {
61
+ value: SplitLayout;
62
+ onValueChange(value: SplitLayout, command: SplitCommand): void;
63
+ renderView(id: string, info: SplitViewInfo): ReactNode;
64
+ getTab?(id: string): Omit<TabStripItem, 'id' | 'tabId' | 'panelId'>;
65
+ renderPaneHeader?(pane: SplitPane): ReactNode;
66
+ onClose?(id: string): void;
67
+ onTabDoubleClick?: TabStripProps['onDoubleClick'];
68
+ onTabContextMenu?: TabStripProps['onContextMenu'];
69
+ onViewDragStart?(id: string, event: DragEvent<HTMLButtonElement>): void;
70
+ canDrop?(drag: SplitDrag, target: SplitDropTarget): boolean;
71
+ canDropExternal?(event: DragEvent<HTMLDivElement>, target: SplitDropTarget): boolean;
72
+ onExternalDrop?(target: SplitDropTarget, event: DragEvent<HTMLDivElement>): void;
73
+ onViewBoundsChange?(bounds: readonly SplitViewBounds[]): void;
74
+ minimumResizeShare?: number;
75
+ minimumSize?: SplitMinimum | ((pane: SplitPane) => SplitMinimum);
76
+ layout?: WorkspaceLayout;
77
+ flushEdges?: SplitEdges;
78
+ showTabs?: boolean;
79
+ headerHeight?: number;
80
+ }
81
+
82
+ interface Measurement {
83
+ width: number;
84
+ height: number;
85
+ left: number;
86
+ top: number;
87
+ scaleX: number;
88
+ scaleY: number;
89
+ }
90
+ const ZERO: Measurement = { width: 0, height: 0, left: 0, top: 0, scaleX: 1, scaleY: 1 };
91
+ const MINIMUM: SplitMinimum = { width: 120, height: 80 };
92
+
93
+ function Divider({
94
+ divider,
95
+ minimumResizeShare,
96
+ onResize,
97
+ onEqualize
98
+ }: {
99
+ divider: SplitDivider;
100
+ minimumResizeShare: number;
101
+ onResize(sizes: number[]): void;
102
+ onEqualize(all: boolean): void;
103
+ }) {
104
+ const { t } = useTranslation('ui');
105
+ const cleanup = useRef<(() => void) | null>(null);
106
+ useEffect(() => () => cleanup.current?.(), []);
107
+ const horizontal = divider.axis === 'horizontal';
108
+ const before = divider.lengths.slice(0, divider.index + 1).reduce((sum, value) => sum + value, 0);
109
+ const total = divider.lengths.reduce((sum, value) => sum + value, 0);
110
+ const position = total ? Math.round((before / total) * 100) : 0;
111
+ const limit = (delta: number): number =>
112
+ resizeSplit(divider, delta, false, 0, minimumResizeShare)
113
+ .slice(0, divider.index + 1)
114
+ .reduce((sum, size) => sum + size, 0) * 100;
115
+ return (
116
+ <div
117
+ role="separator"
118
+ tabIndex={0}
119
+ aria-label={t('workspace.resize')}
120
+ aria-orientation={horizontal ? 'vertical' : 'horizontal'}
121
+ aria-valuenow={position}
122
+ aria-valuemin={total ? Math.floor(limit(-total)) : 0}
123
+ aria-valuemax={total ? Math.ceil(limit(total)) : 100}
124
+ className="ade-split-divider"
125
+ style={{ left: divider.x, top: divider.y, width: divider.width, height: divider.height }}
126
+ onDoubleClick={(event) => onEqualize(event.altKey)}
127
+ onKeyDown={(event) => {
128
+ const negative = horizontal ? 'ArrowLeft' : 'ArrowUp';
129
+ const positive = horizontal ? 'ArrowRight' : 'ArrowDown';
130
+ if (event.key === 'Enter') {
131
+ event.preventDefault();
132
+ onEqualize(event.altKey);
133
+ } else if ([negative, positive, 'Home', 'End'].includes(event.key)) {
134
+ event.preventDefault();
135
+ const delta = event.key === 'Home' ? -total : event.key === 'End' ? total : (event.key === negative ? -1 : 1) * (event.shiftKey ? 50 : 10);
136
+ onResize(resizeSplit(divider, delta, event.altKey, 0, minimumResizeShare));
137
+ }
138
+ }}
139
+ onPointerDown={(event) => {
140
+ if (event.button !== 0) {
141
+ return;
142
+ }
143
+ event.preventDefault();
144
+ cleanup.current?.();
145
+ const element = event.currentTarget;
146
+ const pointerId = event.pointerId;
147
+ const start = horizontal ? event.clientX : event.clientY;
148
+ const parent = element.parentElement;
149
+ const rect = parent?.getBoundingClientRect();
150
+ const scale = horizontal ? (rect?.width ?? 1) / (parent?.clientWidth || 1) : (rect?.height ?? 1) / (parent?.clientHeight || 1);
151
+ element.dataset.resizing = '';
152
+ element.focus();
153
+ const move = (next: PointerEvent): void => {
154
+ if (next.pointerId !== pointerId) {
155
+ return;
156
+ }
157
+ const delta = ((horizontal ? next.clientX : next.clientY) - start) / (scale || 1);
158
+ onResize(resizeSplit(divider, delta, next.altKey, 8 / (scale || 1), minimumResizeShare));
159
+ };
160
+ const end = (): void => {
161
+ element.removeEventListener('pointermove', move);
162
+ element.removeEventListener('pointerup', end);
163
+ element.removeEventListener('pointercancel', end);
164
+ element.removeEventListener('lostpointercapture', end);
165
+ element.ownerDocument.defaultView?.removeEventListener('blur', end);
166
+ delete element.dataset.resizing;
167
+ if (element.hasPointerCapture(pointerId)) {
168
+ element.releasePointerCapture(pointerId);
169
+ }
170
+ cleanup.current = null;
171
+ };
172
+ cleanup.current = end;
173
+ element.setPointerCapture(pointerId);
174
+ element.addEventListener('pointermove', move);
175
+ element.addEventListener('pointerup', end);
176
+ element.addEventListener('pointercancel', end);
177
+ element.addEventListener('lostpointercapture', end);
178
+ element.ownerDocument.defaultView?.addEventListener('blur', end);
179
+ }}
180
+ >
181
+ <span />
182
+ </div>
183
+ );
184
+ }
185
+
186
+ export function SplitView({
187
+ value,
188
+ onValueChange,
189
+ renderView,
190
+ getTab,
191
+ renderPaneHeader,
192
+ onClose,
193
+ onTabDoubleClick,
194
+ onTabContextMenu,
195
+ onViewDragStart,
196
+ canDrop,
197
+ canDropExternal,
198
+ onExternalDrop,
199
+ onViewBoundsChange,
200
+ minimumSize = MINIMUM,
201
+ minimumResizeShare = 0,
202
+ layout: ownLayout,
203
+ flushEdges,
204
+ showTabs = true,
205
+ headerHeight = 36,
206
+ render,
207
+ ref,
208
+ className,
209
+ style,
210
+ ...props
211
+ }: SplitViewProps) {
212
+ const { t } = useTranslation('ui');
213
+ const inherited = useContext(WorkspaceContext);
214
+ const layout = ownLayout ?? inherited.layout;
215
+ const edges = flushEdges ?? inherited.edges;
216
+ const root = useRef<HTMLDivElement>(null);
217
+ const prefix = useId();
218
+ const serial = useRef(0);
219
+ const lastFocus = useRef<HTMLElement | null>(null);
220
+ useEffect(() => {
221
+ const previous = lastFocus.current;
222
+ const element = root.current;
223
+ const active = element?.ownerDocument.activeElement;
224
+ if (
225
+ previous &&
226
+ element &&
227
+ (!previous.isConnected || previous.closest('[hidden]')) &&
228
+ (!active || active === element.ownerDocument.body || active === previous)
229
+ ) {
230
+ const pane = Array.from(element.querySelectorAll<HTMLElement>('[data-split-pane]')).find((item) => item.dataset.splitPane === value.focused);
231
+ const target = pane?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]') ?? pane;
232
+ target?.focus({ preventScroll: true });
233
+ }
234
+ });
235
+ const [measurement, setMeasurement] = useState(ZERO);
236
+ const [dragging, setDragging] = useState(false);
237
+ const drag = useRef<(SplitDrag & { width: number }) | null>(null);
238
+ const [preview, setPreview] = useState<DropPreviewShape | null>(null);
239
+ const latest = useRef({ value, onValueChange });
240
+ useLayoutEffect(() => {
241
+ latest.current = { value, onValueChange };
242
+ });
243
+ const dispatch = useCallback((command: SplitCommand): void => {
244
+ const state = latest.current;
245
+ const next = updateSplitLayout(state.value, command);
246
+ if (next !== state.value) {
247
+ state.onValueChange(next, command);
248
+ }
249
+ }, []);
250
+ const measure = useCallback((): void => {
251
+ const element = root.current;
252
+ if (!element) {
253
+ return;
254
+ }
255
+ const rect = element.getBoundingClientRect();
256
+ const next = {
257
+ width: element.clientWidth,
258
+ height: element.clientHeight,
259
+ left: rect.left,
260
+ top: rect.top,
261
+ scaleX: rect.width / (element.clientWidth || 1),
262
+ scaleY: rect.height / (element.clientHeight || 1)
263
+ };
264
+ setMeasurement((current) => (Object.keys(next).every((key) => current[key as keyof Measurement] === next[key as keyof Measurement]) ? current : next));
265
+ }, []);
266
+ useLayoutEffect(measure);
267
+ useEffect(() => {
268
+ const element = root.current;
269
+ if (!element) {
270
+ return;
271
+ }
272
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
273
+ observer?.observe(element);
274
+ const view = element.ownerDocument.defaultView;
275
+ view?.addEventListener('resize', measure);
276
+ view?.addEventListener('scroll', measure, true);
277
+ return () => {
278
+ observer?.disconnect();
279
+ view?.removeEventListener('resize', measure);
280
+ view?.removeEventListener('scroll', measure, true);
281
+ };
282
+ }, [measure]);
283
+ const stopDrag = useCallback((): void => {
284
+ drag.current = null;
285
+ setDragging(false);
286
+ setPreview(null);
287
+ }, []);
288
+ useEffect(() => {
289
+ if (!dragging) {
290
+ return;
291
+ }
292
+ const document = root.current?.ownerDocument;
293
+ const view = document?.defaultView;
294
+ document?.addEventListener('dragend', stopDrag);
295
+ document?.addEventListener('drop', stopDrag);
296
+ view?.addEventListener('blur', stopDrag);
297
+ return () => {
298
+ document?.removeEventListener('dragend', stopDrag);
299
+ document?.removeEventListener('drop', stopDrag);
300
+ view?.removeEventListener('blur', stopDrag);
301
+ };
302
+ }, [dragging, stopDrag]);
303
+ const gap = layout === 'roomy' ? 8 : 1;
304
+ const barHeight = showTabs || renderPaneHeader ? Math.max(0, headerHeight) : 0;
305
+ const geometry = splitGeometry(value, measurement.width, measurement.height, gap, (pane) =>
306
+ typeof minimumSize === 'function' ? minimumSize(pane) : minimumSize
307
+ );
308
+ const panes = splitPanes(value.root);
309
+ // A stable sibling order also avoids DOM moves, which reload embedded browsing contexts.
310
+ const views = panes
311
+ .flatMap((pane) => pane.views.map((viewId) => ({ pane, viewId })))
312
+ .sort((left, right) => (left.viewId < right.viewId ? -1 : left.viewId > right.viewId ? 1 : 0));
313
+ const visible = new Map(geometry.panes.map((pane) => [pane.pane.id, pane]));
314
+ const radius = (rect: SplitRect): string => paneRadius(rect, measurement.width, measurement.height, edges, layout === 'roomy' ? 8 : 0);
315
+ const bounds: SplitViewBounds[] = panes.flatMap((pane) => {
316
+ const rect = visible.get(pane.id);
317
+ const header = Math.min(barHeight, rect?.height ?? 0);
318
+ const body = rect
319
+ ? {
320
+ x: measurement.left + rect.x * measurement.scaleX,
321
+ y: measurement.top + (rect.y + header) * measurement.scaleY,
322
+ width: rect.width * measurement.scaleX,
323
+ height: Math.max(0, rect.height - header) * measurement.scaleY
324
+ }
325
+ : { x: 0, y: 0, width: 0, height: 0 };
326
+ const corners = rect ? radius(rect).split(' ') : ['0px', '0px', '0px', '0px'];
327
+ if (header > 0) {
328
+ corners[0] = corners[1] = '0px';
329
+ }
330
+ return pane.views.map((viewId) => ({ viewId, paneId: pane.id, active: !!rect && pane.active === viewId, rect: body, borderRadius: corners.join(' ') }));
331
+ });
332
+ const boundsKey = JSON.stringify(bounds);
333
+ const reportedBounds = useRef('');
334
+ const boundsCallback = useRef(onViewBoundsChange);
335
+ useLayoutEffect(() => {
336
+ boundsCallback.current = onViewBoundsChange;
337
+ });
338
+ useEffect(() => () => boundsCallback.current?.([]), []);
339
+ useLayoutEffect(() => {
340
+ if (onViewBoundsChange && reportedBounds.current !== boundsKey) {
341
+ reportedBounds.current = boundsKey;
342
+ onViewBoundsChange(bounds);
343
+ }
344
+ });
345
+ const id = (kind: string, viewId: string): string => `${prefix}-${kind}-${encodeURIComponent(viewId)}`;
346
+ const startDrag = (pane: SplitPane, event: DragEvent<HTMLButtonElement>, viewId?: string): void => {
347
+ drag.current = { sourceId: pane.id, viewId, width: event.currentTarget.closest('[data-document-tab]')?.getBoundingClientRect().width ?? 128 };
348
+ event.dataTransfer.setData('application/x-adecore-view', JSON.stringify({ workspace: prefix, paneId: pane.id, viewId }));
349
+ event.dataTransfer.effectAllowed = 'move';
350
+ if (viewId) {
351
+ onViewDragStart?.(viewId, event);
352
+ }
353
+ setDragging(true);
354
+ };
355
+ const commandFor = (target: SplitDropTarget): SplitCommand | null => {
356
+ if (!drag.current) {
357
+ return null;
358
+ }
359
+ const { sourceId, viewId } = drag.current;
360
+ if (target.side === 'center') {
361
+ return { type: 'swap', sourceId, targetId: target.paneId, viewId };
362
+ }
363
+ return {
364
+ type: 'move',
365
+ sourceId,
366
+ viewId,
367
+ targetId: target.paneId,
368
+ side: target.side,
369
+ index: target.index,
370
+ newPaneId: `${prefix}-pane-${serial.current}`,
371
+ splitId: `${prefix}-split-${serial.current}`
372
+ };
373
+ };
374
+ const locateDrop = (event: DragEvent<HTMLDivElement>): { target: SplitDropTarget; shape: DropPreviewShape } | null => {
375
+ const x = (event.clientX - measurement.left) / (measurement.scaleX || 1);
376
+ const y = (event.clientY - measurement.top) / (measurement.scaleY || 1);
377
+ const rect = geometry.panes.find((item) => x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height);
378
+ if (!rect) {
379
+ return null;
380
+ }
381
+ const header = Math.min(barHeight, rect.height);
382
+ const strip = Array.from(root.current?.querySelectorAll<HTMLElement>('[data-split-header]') ?? []).find(
383
+ (element) => element.dataset.splitHeader === rect.pane.id
384
+ );
385
+ const tabRects = Array.from(strip?.querySelectorAll('[data-document-tab]') ?? [], (element) => element.getBoundingClientRect());
386
+ const inHeader = y < rect.y + header;
387
+ const side = inHeader ? undefined : (splitSideAt(rect, x, y) ?? 'center');
388
+ const index = inHeader ? tabGapAt(tabRects, event.clientX) : undefined;
389
+ const target: SplitDropTarget = { paneId: rect.pane.id, side, index };
390
+ const command = commandFor(target);
391
+ if (drag.current) {
392
+ if (canDrop?.(drag.current, target) === false || !command || updateSplitLayout(value, command) === value) {
393
+ return null;
394
+ }
395
+ } else if (!onExternalDrop || !canDropExternal?.(event, target)) {
396
+ return null;
397
+ }
398
+ if (side === 'center') {
399
+ return { target, shape: rectPreview(rect) };
400
+ }
401
+ if (side) {
402
+ const horizontal = side === 'left' || side === 'right';
403
+ const width = horizontal ? Math.max(0, (rect.width - gap) / 2) : rect.width;
404
+ const height = horizontal ? rect.height : Math.max(0, (rect.height - gap) / 2);
405
+ return {
406
+ target,
407
+ shape: rectPreview({
408
+ x: side === 'right' ? rect.x + rect.width - width : rect.x,
409
+ y: side === 'bottom' ? rect.y + rect.height - height : rect.y,
410
+ width,
411
+ height
412
+ })
413
+ };
414
+ }
415
+ if (header === 0) {
416
+ return { target, shape: rectPreview(rect) };
417
+ }
418
+ const tabWidth = Math.min(rect.width, (drag.current?.width ?? 128) / (measurement.scaleX || 1));
419
+ const next = tabRects[index ?? tabRects.length];
420
+ const left = next?.left ?? tabRects.at(-1)?.right ?? measurement.left + rect.x * measurement.scaleX;
421
+ const relative = Math.max(0, Math.min(rect.width - tabWidth, (left - measurement.left) / (measurement.scaleX || 1) - rect.x));
422
+ return {
423
+ target,
424
+ shape: tabPreview(
425
+ { gap: index ?? rect.pane.views.length, left: relative, tabWidth, width: rect.width, height: rect.height, barHeight: header },
426
+ { x: rect.x, y: rect.y }
427
+ )
428
+ };
429
+ };
430
+ const focusDirection = (key: string): void => {
431
+ const source = visible.get(value.focused);
432
+ if (!source) {
433
+ return;
434
+ }
435
+ const horizontal = key === 'ArrowLeft' || key === 'ArrowRight';
436
+ const positive = key === 'ArrowRight' || key === 'ArrowDown';
437
+ const center = (rect: SplitRect): number => (horizontal ? rect.x + rect.width / 2 : rect.y + rect.height / 2);
438
+ const cross = (rect: SplitRect): number => (horizontal ? rect.y + rect.height / 2 : rect.x + rect.width / 2);
439
+ const options = geometry.panes.filter((rect) => rect !== source && (positive ? center(rect) > center(source) : center(rect) < center(source)));
440
+ options.sort(
441
+ (left, right) =>
442
+ Math.abs(center(left) - center(source)) +
443
+ 2 * Math.abs(cross(left) - cross(source)) -
444
+ (Math.abs(center(right) - center(source)) + 2 * Math.abs(cross(right) - cross(source)))
445
+ );
446
+ const next = options[0];
447
+ if (next) {
448
+ dispatch({ type: 'focus', paneId: next.pane.id });
449
+ const element = Array.from(root.current?.querySelectorAll<HTMLElement>('[data-split-pane]') ?? []).find(
450
+ (pane) => pane.dataset.splitPane === next.pane.id
451
+ );
452
+ element?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')?.focus();
453
+ if (!showTabs) {
454
+ element?.focus();
455
+ }
456
+ }
457
+ };
458
+ return useRender({
459
+ render,
460
+ ref: [root, ref ?? null],
461
+ defaultTagName: 'div',
462
+ props: {
463
+ ...props,
464
+ className: clsx('ade-split-view', className),
465
+ style,
466
+ 'data-layout': layout,
467
+ 'data-dragging': dragging || undefined,
468
+ onFocusCapture: (event: React.FocusEvent<HTMLDivElement>) => {
469
+ props.onFocusCapture?.(event);
470
+ lastFocus.current = event.target;
471
+ },
472
+ onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
473
+ props.onKeyDown?.(event);
474
+ const target = event.target as HTMLElement;
475
+ const navigation =
476
+ target === event.currentTarget || target.matches('.ade-split-pane, .ade-split-content') || !!target.closest('.ade-split-header');
477
+ if (!event.defaultPrevented && navigation && event.altKey && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) {
478
+ event.preventDefault();
479
+ focusDirection(event.key);
480
+ }
481
+ if (event.key === 'Escape') {
482
+ stopDrag();
483
+ }
484
+ },
485
+ onDragOver: (event: DragEvent<HTMLDivElement>) => {
486
+ props.onDragOver?.(event);
487
+ if (event.defaultPrevented) {
488
+ setPreview(null);
489
+ return;
490
+ }
491
+ const drop = locateDrop(event);
492
+ setPreview(drop?.shape ?? null);
493
+ if (drop) {
494
+ event.preventDefault();
495
+ event.dataTransfer.dropEffect = drag.current ? 'move' : 'copy';
496
+ }
497
+ },
498
+ onDragLeave: (event: DragEvent<HTMLDivElement>) => {
499
+ props.onDragLeave?.(event);
500
+ if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) {
501
+ setPreview(null);
502
+ }
503
+ },
504
+ onDrop: (event: DragEvent<HTMLDivElement>) => {
505
+ props.onDrop?.(event);
506
+ if (event.defaultPrevented) {
507
+ stopDrag();
508
+ return;
509
+ }
510
+ const drop = locateDrop(event);
511
+ if (drop) {
512
+ event.preventDefault();
513
+ event.stopPropagation();
514
+ const command = commandFor(drop.target);
515
+ if (command) {
516
+ serial.current++;
517
+ dispatch(command);
518
+ } else {
519
+ onExternalDrop?.(drop.target, event);
520
+ }
521
+ }
522
+ stopDrag();
523
+ },
524
+ children: (
525
+ <>
526
+ {panes.map((pane) => {
527
+ const rect = visible.get(pane.id);
528
+ const header = Math.min(barHeight, rect?.height ?? 0);
529
+ return (
530
+ <section
531
+ key={pane.id}
532
+ data-split-pane={pane.id}
533
+ data-focused={value.focused === pane.id || undefined}
534
+ className="ade-split-pane"
535
+ aria-label={t('workspace.pane')}
536
+ tabIndex={-1}
537
+ hidden={!rect}
538
+ inert={!rect}
539
+ style={rect ? { left: rect.x, top: rect.y, width: rect.width, height: rect.height, borderRadius: radius(rect) } : undefined}
540
+ onFocusCapture={() => dispatch({ type: 'focus', paneId: pane.id })}
541
+ onPointerDown={() => dispatch({ type: 'focus', paneId: pane.id })}
542
+ >
543
+ {header > 0 && (
544
+ <div className="ade-split-header" data-split-header={pane.id} style={{ height: header }}>
545
+ {renderPaneHeader ? (
546
+ renderPaneHeader(pane)
547
+ ) : (
548
+ <>
549
+ <TabStrip
550
+ items={pane.views.map((viewId) => {
551
+ const metadata = getTab?.(viewId);
552
+ return {
553
+ ...metadata,
554
+ id: viewId,
555
+ label: metadata?.label ?? viewId,
556
+ tabId: id('tab', viewId),
557
+ panelId: id('view', viewId)
558
+ };
559
+ })}
560
+ value={pane.active}
561
+ focused={value.focused === pane.id}
562
+ onValueChange={(viewId) => dispatch({ type: 'activate', paneId: pane.id, viewId })}
563
+ onClose={onClose ?? ((viewId) => dispatch({ type: 'close', viewId }))}
564
+ onDoubleClick={onTabDoubleClick}
565
+ onContextMenu={onTabContextMenu}
566
+ onDragStart={(viewId, event) => startDrag(pane, event, viewId)}
567
+ onDragEnd={stopDrag}
568
+ />
569
+ <IconButton
570
+ icon={GripVertical}
571
+ size="sm"
572
+ label={t('workspace.movePane')}
573
+ draggable
574
+ onDragStart={(event) => startDrag(pane, event)}
575
+ onDragEnd={stopDrag}
576
+ />
577
+ <IconButton
578
+ icon={value.maximized === pane.id ? Minimize2 : Maximize2}
579
+ size="sm"
580
+ label={t(value.maximized === pane.id ? 'workspace.restore' : 'workspace.maximize')}
581
+ onClick={() => dispatch({ type: 'maximize', paneId: value.maximized === pane.id ? null : pane.id })}
582
+ />
583
+ </>
584
+ )}
585
+ </div>
586
+ )}
587
+ </section>
588
+ );
589
+ })}
590
+ {views.map(({ pane, viewId }) => {
591
+ const rect = visible.get(pane.id);
592
+ const active = !!rect && pane.active === viewId;
593
+ const header = Math.min(barHeight, rect?.height ?? 0);
594
+ const corners = rect ? radius(rect).split(' ') : [];
595
+ if (header > 0) {
596
+ corners[0] = corners[1] = '0px';
597
+ }
598
+ const position: CSSProperties = rect
599
+ ? {
600
+ left: rect.x,
601
+ top: rect.y + header,
602
+ width: rect.width,
603
+ height: Math.max(0, rect.height - header),
604
+ borderRadius: corners.join(' ')
605
+ }
606
+ : {};
607
+ return (
608
+ <div
609
+ key={viewId}
610
+ id={id('view', viewId)}
611
+ role="tabpanel"
612
+ aria-labelledby={showTabs && !renderPaneHeader ? id('tab', viewId) : undefined}
613
+ aria-label={!showTabs || renderPaneHeader ? (getTab?.(viewId).label ?? viewId) : undefined}
614
+ className="ade-split-content"
615
+ data-split-view-id={viewId}
616
+ hidden={!active}
617
+ inert={!active}
618
+ tabIndex={0}
619
+ style={position}
620
+ onFocusCapture={() => dispatch({ type: 'focus', paneId: pane.id })}
621
+ onPointerDown={() => dispatch({ type: 'focus', paneId: pane.id })}
622
+ >
623
+ {renderView(viewId, { paneId: pane.id, active })}
624
+ </div>
625
+ );
626
+ })}
627
+ {geometry.dividers.map((divider) => (
628
+ <Divider
629
+ key={`${divider.splitId}:${divider.index}`}
630
+ divider={divider}
631
+ minimumResizeShare={minimumResizeShare}
632
+ onResize={(sizes) => dispatch({ type: 'resize', splitId: divider.splitId, sizes })}
633
+ onEqualize={(all) =>
634
+ dispatch(
635
+ all
636
+ ? {
637
+ type: 'equalizeAxis',
638
+ axis: divider.axis,
639
+ length: divider.axis === 'horizontal' ? measurement.width : measurement.height,
640
+ gap: layout === 'roomy' ? 8 : 1
641
+ }
642
+ : { type: 'equalize', splitId: divider.splitId, index: divider.index }
643
+ )
644
+ }
645
+ />
646
+ ))}
647
+ {preview && (
648
+ <svg className="ade-split-preview" width="100%" height="100%" aria-hidden="true">
649
+ <path style={{ d: `path("${dropPreviewPath(preview)}")` }} />
650
+ </svg>
651
+ )}
652
+ </>
653
+ )
654
+ }
655
+ });
656
+ }