@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,421 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
3
+ import { useRender } from '@base-ui-components/react/use-render';
4
+ import { GripVertical, Maximize2, Minimize2 } from 'lucide-react';
5
+ import clsx from 'clsx';
6
+ import { useTranslation } from 'react-i18next';
7
+ import { IconButton } from '../IconButton.js';
8
+ import { WorkspaceContext } from './context.js';
9
+ import { paneRadius, resizeSplit, splitGeometry, splitSideAt, tabGapAt } from './geometry.js';
10
+ import { splitPanes, updateSplitLayout } from './model.js';
11
+ import { TabStrip } from './TabStrip.js';
12
+ import { dropPreviewPath, rectPreview, tabPreview } from './preview.js';
13
+ const ZERO = { width: 0, height: 0, left: 0, top: 0, scaleX: 1, scaleY: 1 };
14
+ const MINIMUM = { width: 120, height: 80 };
15
+ function Divider({ divider, minimumResizeShare, onResize, onEqualize }) {
16
+ const { t } = useTranslation('ui');
17
+ const cleanup = useRef(null);
18
+ useEffect(() => () => cleanup.current?.(), []);
19
+ const horizontal = divider.axis === 'horizontal';
20
+ const before = divider.lengths.slice(0, divider.index + 1).reduce((sum, value) => sum + value, 0);
21
+ const total = divider.lengths.reduce((sum, value) => sum + value, 0);
22
+ const position = total ? Math.round((before / total) * 100) : 0;
23
+ const limit = (delta) => resizeSplit(divider, delta, false, 0, minimumResizeShare)
24
+ .slice(0, divider.index + 1)
25
+ .reduce((sum, size) => sum + size, 0) * 100;
26
+ return (_jsx("div", { role: "separator", tabIndex: 0, "aria-label": t('workspace.resize'), "aria-orientation": horizontal ? 'vertical' : 'horizontal', "aria-valuenow": position, "aria-valuemin": total ? Math.floor(limit(-total)) : 0, "aria-valuemax": total ? Math.ceil(limit(total)) : 100, className: "ade-split-divider", style: { left: divider.x, top: divider.y, width: divider.width, height: divider.height }, onDoubleClick: (event) => onEqualize(event.altKey), onKeyDown: (event) => {
27
+ const negative = horizontal ? 'ArrowLeft' : 'ArrowUp';
28
+ const positive = horizontal ? 'ArrowRight' : 'ArrowDown';
29
+ if (event.key === 'Enter') {
30
+ event.preventDefault();
31
+ onEqualize(event.altKey);
32
+ }
33
+ else if ([negative, positive, 'Home', 'End'].includes(event.key)) {
34
+ event.preventDefault();
35
+ const delta = event.key === 'Home' ? -total : event.key === 'End' ? total : (event.key === negative ? -1 : 1) * (event.shiftKey ? 50 : 10);
36
+ onResize(resizeSplit(divider, delta, event.altKey, 0, minimumResizeShare));
37
+ }
38
+ }, onPointerDown: (event) => {
39
+ if (event.button !== 0) {
40
+ return;
41
+ }
42
+ event.preventDefault();
43
+ cleanup.current?.();
44
+ const element = event.currentTarget;
45
+ const pointerId = event.pointerId;
46
+ const start = horizontal ? event.clientX : event.clientY;
47
+ const parent = element.parentElement;
48
+ const rect = parent?.getBoundingClientRect();
49
+ const scale = horizontal ? (rect?.width ?? 1) / (parent?.clientWidth || 1) : (rect?.height ?? 1) / (parent?.clientHeight || 1);
50
+ element.dataset.resizing = '';
51
+ element.focus();
52
+ const move = (next) => {
53
+ if (next.pointerId !== pointerId) {
54
+ return;
55
+ }
56
+ const delta = ((horizontal ? next.clientX : next.clientY) - start) / (scale || 1);
57
+ onResize(resizeSplit(divider, delta, next.altKey, 8 / (scale || 1), minimumResizeShare));
58
+ };
59
+ const end = () => {
60
+ element.removeEventListener('pointermove', move);
61
+ element.removeEventListener('pointerup', end);
62
+ element.removeEventListener('pointercancel', end);
63
+ element.removeEventListener('lostpointercapture', end);
64
+ element.ownerDocument.defaultView?.removeEventListener('blur', end);
65
+ delete element.dataset.resizing;
66
+ if (element.hasPointerCapture(pointerId)) {
67
+ element.releasePointerCapture(pointerId);
68
+ }
69
+ cleanup.current = null;
70
+ };
71
+ cleanup.current = end;
72
+ element.setPointerCapture(pointerId);
73
+ element.addEventListener('pointermove', move);
74
+ element.addEventListener('pointerup', end);
75
+ element.addEventListener('pointercancel', end);
76
+ element.addEventListener('lostpointercapture', end);
77
+ element.ownerDocument.defaultView?.addEventListener('blur', end);
78
+ }, children: _jsx("span", {}) }));
79
+ }
80
+ export function SplitView({ value, onValueChange, renderView, getTab, renderPaneHeader, onClose, onTabDoubleClick, onTabContextMenu, onViewDragStart, canDrop, canDropExternal, onExternalDrop, onViewBoundsChange, minimumSize = MINIMUM, minimumResizeShare = 0, layout: ownLayout, flushEdges, showTabs = true, headerHeight = 36, render, ref, className, style, ...props }) {
81
+ const { t } = useTranslation('ui');
82
+ const inherited = useContext(WorkspaceContext);
83
+ const layout = ownLayout ?? inherited.layout;
84
+ const edges = flushEdges ?? inherited.edges;
85
+ const root = useRef(null);
86
+ const prefix = useId();
87
+ const serial = useRef(0);
88
+ const lastFocus = useRef(null);
89
+ useEffect(() => {
90
+ const previous = lastFocus.current;
91
+ const element = root.current;
92
+ const active = element?.ownerDocument.activeElement;
93
+ if (previous &&
94
+ element &&
95
+ (!previous.isConnected || previous.closest('[hidden]')) &&
96
+ (!active || active === element.ownerDocument.body || active === previous)) {
97
+ const pane = Array.from(element.querySelectorAll('[data-split-pane]')).find((item) => item.dataset.splitPane === value.focused);
98
+ const target = pane?.querySelector('[role="tab"][aria-selected="true"]') ?? pane;
99
+ target?.focus({ preventScroll: true });
100
+ }
101
+ });
102
+ const [measurement, setMeasurement] = useState(ZERO);
103
+ const [dragging, setDragging] = useState(false);
104
+ const drag = useRef(null);
105
+ const [preview, setPreview] = useState(null);
106
+ const latest = useRef({ value, onValueChange });
107
+ useLayoutEffect(() => {
108
+ latest.current = { value, onValueChange };
109
+ });
110
+ const dispatch = useCallback((command) => {
111
+ const state = latest.current;
112
+ const next = updateSplitLayout(state.value, command);
113
+ if (next !== state.value) {
114
+ state.onValueChange(next, command);
115
+ }
116
+ }, []);
117
+ const measure = useCallback(() => {
118
+ const element = root.current;
119
+ if (!element) {
120
+ return;
121
+ }
122
+ const rect = element.getBoundingClientRect();
123
+ const next = {
124
+ width: element.clientWidth,
125
+ height: element.clientHeight,
126
+ left: rect.left,
127
+ top: rect.top,
128
+ scaleX: rect.width / (element.clientWidth || 1),
129
+ scaleY: rect.height / (element.clientHeight || 1)
130
+ };
131
+ setMeasurement((current) => (Object.keys(next).every((key) => current[key] === next[key]) ? current : next));
132
+ }, []);
133
+ useLayoutEffect(measure);
134
+ useEffect(() => {
135
+ const element = root.current;
136
+ if (!element) {
137
+ return;
138
+ }
139
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
140
+ observer?.observe(element);
141
+ const view = element.ownerDocument.defaultView;
142
+ view?.addEventListener('resize', measure);
143
+ view?.addEventListener('scroll', measure, true);
144
+ return () => {
145
+ observer?.disconnect();
146
+ view?.removeEventListener('resize', measure);
147
+ view?.removeEventListener('scroll', measure, true);
148
+ };
149
+ }, [measure]);
150
+ const stopDrag = useCallback(() => {
151
+ drag.current = null;
152
+ setDragging(false);
153
+ setPreview(null);
154
+ }, []);
155
+ useEffect(() => {
156
+ if (!dragging) {
157
+ return;
158
+ }
159
+ const document = root.current?.ownerDocument;
160
+ const view = document?.defaultView;
161
+ document?.addEventListener('dragend', stopDrag);
162
+ document?.addEventListener('drop', stopDrag);
163
+ view?.addEventListener('blur', stopDrag);
164
+ return () => {
165
+ document?.removeEventListener('dragend', stopDrag);
166
+ document?.removeEventListener('drop', stopDrag);
167
+ view?.removeEventListener('blur', stopDrag);
168
+ };
169
+ }, [dragging, stopDrag]);
170
+ const gap = layout === 'roomy' ? 8 : 1;
171
+ const barHeight = showTabs || renderPaneHeader ? Math.max(0, headerHeight) : 0;
172
+ const geometry = splitGeometry(value, measurement.width, measurement.height, gap, (pane) => typeof minimumSize === 'function' ? minimumSize(pane) : minimumSize);
173
+ const panes = splitPanes(value.root);
174
+ // A stable sibling order also avoids DOM moves, which reload embedded browsing contexts.
175
+ const views = panes
176
+ .flatMap((pane) => pane.views.map((viewId) => ({ pane, viewId })))
177
+ .sort((left, right) => (left.viewId < right.viewId ? -1 : left.viewId > right.viewId ? 1 : 0));
178
+ const visible = new Map(geometry.panes.map((pane) => [pane.pane.id, pane]));
179
+ const radius = (rect) => paneRadius(rect, measurement.width, measurement.height, edges, layout === 'roomy' ? 8 : 0);
180
+ const bounds = panes.flatMap((pane) => {
181
+ const rect = visible.get(pane.id);
182
+ const header = Math.min(barHeight, rect?.height ?? 0);
183
+ const body = rect
184
+ ? {
185
+ x: measurement.left + rect.x * measurement.scaleX,
186
+ y: measurement.top + (rect.y + header) * measurement.scaleY,
187
+ width: rect.width * measurement.scaleX,
188
+ height: Math.max(0, rect.height - header) * measurement.scaleY
189
+ }
190
+ : { x: 0, y: 0, width: 0, height: 0 };
191
+ const corners = rect ? radius(rect).split(' ') : ['0px', '0px', '0px', '0px'];
192
+ if (header > 0) {
193
+ corners[0] = corners[1] = '0px';
194
+ }
195
+ return pane.views.map((viewId) => ({ viewId, paneId: pane.id, active: !!rect && pane.active === viewId, rect: body, borderRadius: corners.join(' ') }));
196
+ });
197
+ const boundsKey = JSON.stringify(bounds);
198
+ const reportedBounds = useRef('');
199
+ const boundsCallback = useRef(onViewBoundsChange);
200
+ useLayoutEffect(() => {
201
+ boundsCallback.current = onViewBoundsChange;
202
+ });
203
+ useEffect(() => () => boundsCallback.current?.([]), []);
204
+ useLayoutEffect(() => {
205
+ if (onViewBoundsChange && reportedBounds.current !== boundsKey) {
206
+ reportedBounds.current = boundsKey;
207
+ onViewBoundsChange(bounds);
208
+ }
209
+ });
210
+ const id = (kind, viewId) => `${prefix}-${kind}-${encodeURIComponent(viewId)}`;
211
+ const startDrag = (pane, event, viewId) => {
212
+ drag.current = { sourceId: pane.id, viewId, width: event.currentTarget.closest('[data-document-tab]')?.getBoundingClientRect().width ?? 128 };
213
+ event.dataTransfer.setData('application/x-adecore-view', JSON.stringify({ workspace: prefix, paneId: pane.id, viewId }));
214
+ event.dataTransfer.effectAllowed = 'move';
215
+ if (viewId) {
216
+ onViewDragStart?.(viewId, event);
217
+ }
218
+ setDragging(true);
219
+ };
220
+ const commandFor = (target) => {
221
+ if (!drag.current) {
222
+ return null;
223
+ }
224
+ const { sourceId, viewId } = drag.current;
225
+ if (target.side === 'center') {
226
+ return { type: 'swap', sourceId, targetId: target.paneId, viewId };
227
+ }
228
+ return {
229
+ type: 'move',
230
+ sourceId,
231
+ viewId,
232
+ targetId: target.paneId,
233
+ side: target.side,
234
+ index: target.index,
235
+ newPaneId: `${prefix}-pane-${serial.current}`,
236
+ splitId: `${prefix}-split-${serial.current}`
237
+ };
238
+ };
239
+ const locateDrop = (event) => {
240
+ const x = (event.clientX - measurement.left) / (measurement.scaleX || 1);
241
+ const y = (event.clientY - measurement.top) / (measurement.scaleY || 1);
242
+ const rect = geometry.panes.find((item) => x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height);
243
+ if (!rect) {
244
+ return null;
245
+ }
246
+ const header = Math.min(barHeight, rect.height);
247
+ const strip = Array.from(root.current?.querySelectorAll('[data-split-header]') ?? []).find((element) => element.dataset.splitHeader === rect.pane.id);
248
+ const tabRects = Array.from(strip?.querySelectorAll('[data-document-tab]') ?? [], (element) => element.getBoundingClientRect());
249
+ const inHeader = y < rect.y + header;
250
+ const side = inHeader ? undefined : (splitSideAt(rect, x, y) ?? 'center');
251
+ const index = inHeader ? tabGapAt(tabRects, event.clientX) : undefined;
252
+ const target = { paneId: rect.pane.id, side, index };
253
+ const command = commandFor(target);
254
+ if (drag.current) {
255
+ if (canDrop?.(drag.current, target) === false || !command || updateSplitLayout(value, command) === value) {
256
+ return null;
257
+ }
258
+ }
259
+ else if (!onExternalDrop || !canDropExternal?.(event, target)) {
260
+ return null;
261
+ }
262
+ if (side === 'center') {
263
+ return { target, shape: rectPreview(rect) };
264
+ }
265
+ if (side) {
266
+ const horizontal = side === 'left' || side === 'right';
267
+ const width = horizontal ? Math.max(0, (rect.width - gap) / 2) : rect.width;
268
+ const height = horizontal ? rect.height : Math.max(0, (rect.height - gap) / 2);
269
+ return {
270
+ target,
271
+ shape: rectPreview({
272
+ x: side === 'right' ? rect.x + rect.width - width : rect.x,
273
+ y: side === 'bottom' ? rect.y + rect.height - height : rect.y,
274
+ width,
275
+ height
276
+ })
277
+ };
278
+ }
279
+ if (header === 0) {
280
+ return { target, shape: rectPreview(rect) };
281
+ }
282
+ const tabWidth = Math.min(rect.width, (drag.current?.width ?? 128) / (measurement.scaleX || 1));
283
+ const next = tabRects[index ?? tabRects.length];
284
+ const left = next?.left ?? tabRects.at(-1)?.right ?? measurement.left + rect.x * measurement.scaleX;
285
+ const relative = Math.max(0, Math.min(rect.width - tabWidth, (left - measurement.left) / (measurement.scaleX || 1) - rect.x));
286
+ return {
287
+ target,
288
+ shape: tabPreview({ gap: index ?? rect.pane.views.length, left: relative, tabWidth, width: rect.width, height: rect.height, barHeight: header }, { x: rect.x, y: rect.y })
289
+ };
290
+ };
291
+ const focusDirection = (key) => {
292
+ const source = visible.get(value.focused);
293
+ if (!source) {
294
+ return;
295
+ }
296
+ const horizontal = key === 'ArrowLeft' || key === 'ArrowRight';
297
+ const positive = key === 'ArrowRight' || key === 'ArrowDown';
298
+ const center = (rect) => (horizontal ? rect.x + rect.width / 2 : rect.y + rect.height / 2);
299
+ const cross = (rect) => (horizontal ? rect.y + rect.height / 2 : rect.x + rect.width / 2);
300
+ const options = geometry.panes.filter((rect) => rect !== source && (positive ? center(rect) > center(source) : center(rect) < center(source)));
301
+ options.sort((left, right) => Math.abs(center(left) - center(source)) +
302
+ 2 * Math.abs(cross(left) - cross(source)) -
303
+ (Math.abs(center(right) - center(source)) + 2 * Math.abs(cross(right) - cross(source))));
304
+ const next = options[0];
305
+ if (next) {
306
+ dispatch({ type: 'focus', paneId: next.pane.id });
307
+ const element = Array.from(root.current?.querySelectorAll('[data-split-pane]') ?? []).find((pane) => pane.dataset.splitPane === next.pane.id);
308
+ element?.querySelector('[role="tab"][aria-selected="true"]')?.focus();
309
+ if (!showTabs) {
310
+ element?.focus();
311
+ }
312
+ }
313
+ };
314
+ return useRender({
315
+ render,
316
+ ref: [root, ref ?? null],
317
+ defaultTagName: 'div',
318
+ props: {
319
+ ...props,
320
+ className: clsx('ade-split-view', className),
321
+ style,
322
+ 'data-layout': layout,
323
+ 'data-dragging': dragging || undefined,
324
+ onFocusCapture: (event) => {
325
+ props.onFocusCapture?.(event);
326
+ lastFocus.current = event.target;
327
+ },
328
+ onKeyDown: (event) => {
329
+ props.onKeyDown?.(event);
330
+ const target = event.target;
331
+ const navigation = target === event.currentTarget || target.matches('.ade-split-pane, .ade-split-content') || !!target.closest('.ade-split-header');
332
+ if (!event.defaultPrevented && navigation && event.altKey && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) {
333
+ event.preventDefault();
334
+ focusDirection(event.key);
335
+ }
336
+ if (event.key === 'Escape') {
337
+ stopDrag();
338
+ }
339
+ },
340
+ onDragOver: (event) => {
341
+ props.onDragOver?.(event);
342
+ if (event.defaultPrevented) {
343
+ setPreview(null);
344
+ return;
345
+ }
346
+ const drop = locateDrop(event);
347
+ setPreview(drop?.shape ?? null);
348
+ if (drop) {
349
+ event.preventDefault();
350
+ event.dataTransfer.dropEffect = drag.current ? 'move' : 'copy';
351
+ }
352
+ },
353
+ onDragLeave: (event) => {
354
+ props.onDragLeave?.(event);
355
+ if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) {
356
+ setPreview(null);
357
+ }
358
+ },
359
+ onDrop: (event) => {
360
+ props.onDrop?.(event);
361
+ if (event.defaultPrevented) {
362
+ stopDrag();
363
+ return;
364
+ }
365
+ const drop = locateDrop(event);
366
+ if (drop) {
367
+ event.preventDefault();
368
+ event.stopPropagation();
369
+ const command = commandFor(drop.target);
370
+ if (command) {
371
+ serial.current++;
372
+ dispatch(command);
373
+ }
374
+ else {
375
+ onExternalDrop?.(drop.target, event);
376
+ }
377
+ }
378
+ stopDrag();
379
+ },
380
+ children: (_jsxs(_Fragment, { children: [panes.map((pane) => {
381
+ const rect = visible.get(pane.id);
382
+ const header = Math.min(barHeight, rect?.height ?? 0);
383
+ return (_jsx("section", { "data-split-pane": pane.id, "data-focused": value.focused === pane.id || undefined, className: "ade-split-pane", "aria-label": t('workspace.pane'), tabIndex: -1, hidden: !rect, inert: !rect, style: rect ? { left: rect.x, top: rect.y, width: rect.width, height: rect.height, borderRadius: radius(rect) } : undefined, onFocusCapture: () => dispatch({ type: 'focus', paneId: pane.id }), onPointerDown: () => dispatch({ type: 'focus', paneId: pane.id }), children: header > 0 && (_jsx("div", { className: "ade-split-header", "data-split-header": pane.id, style: { height: header }, children: renderPaneHeader ? (renderPaneHeader(pane)) : (_jsxs(_Fragment, { children: [_jsx(TabStrip, { items: pane.views.map((viewId) => {
384
+ const metadata = getTab?.(viewId);
385
+ return {
386
+ ...metadata,
387
+ id: viewId,
388
+ label: metadata?.label ?? viewId,
389
+ tabId: id('tab', viewId),
390
+ panelId: id('view', viewId)
391
+ };
392
+ }), value: pane.active, focused: value.focused === pane.id, onValueChange: (viewId) => dispatch({ type: 'activate', paneId: pane.id, viewId }), onClose: onClose ?? ((viewId) => dispatch({ type: 'close', viewId })), onDoubleClick: onTabDoubleClick, onContextMenu: onTabContextMenu, onDragStart: (viewId, event) => startDrag(pane, event, viewId), onDragEnd: stopDrag }), _jsx(IconButton, { icon: GripVertical, size: "sm", label: t('workspace.movePane'), draggable: true, onDragStart: (event) => startDrag(pane, event), onDragEnd: stopDrag }), _jsx(IconButton, { icon: value.maximized === pane.id ? Minimize2 : Maximize2, size: "sm", label: t(value.maximized === pane.id ? 'workspace.restore' : 'workspace.maximize'), onClick: () => dispatch({ type: 'maximize', paneId: value.maximized === pane.id ? null : pane.id }) })] })) })) }, pane.id));
393
+ }), views.map(({ pane, viewId }) => {
394
+ const rect = visible.get(pane.id);
395
+ const active = !!rect && pane.active === viewId;
396
+ const header = Math.min(barHeight, rect?.height ?? 0);
397
+ const corners = rect ? radius(rect).split(' ') : [];
398
+ if (header > 0) {
399
+ corners[0] = corners[1] = '0px';
400
+ }
401
+ const position = rect
402
+ ? {
403
+ left: rect.x,
404
+ top: rect.y + header,
405
+ width: rect.width,
406
+ height: Math.max(0, rect.height - header),
407
+ borderRadius: corners.join(' ')
408
+ }
409
+ : {};
410
+ return (_jsx("div", { id: id('view', viewId), role: "tabpanel", "aria-labelledby": showTabs && !renderPaneHeader ? id('tab', viewId) : undefined, "aria-label": !showTabs || renderPaneHeader ? (getTab?.(viewId).label ?? viewId) : undefined, className: "ade-split-content", "data-split-view-id": viewId, hidden: !active, inert: !active, tabIndex: 0, style: position, onFocusCapture: () => dispatch({ type: 'focus', paneId: pane.id }), onPointerDown: () => dispatch({ type: 'focus', paneId: pane.id }), children: renderView(viewId, { paneId: pane.id, active }) }, viewId));
411
+ }), geometry.dividers.map((divider) => (_jsx(Divider, { divider: divider, minimumResizeShare: minimumResizeShare, onResize: (sizes) => dispatch({ type: 'resize', splitId: divider.splitId, sizes }), onEqualize: (all) => dispatch(all
412
+ ? {
413
+ type: 'equalizeAxis',
414
+ axis: divider.axis,
415
+ length: divider.axis === 'horizontal' ? measurement.width : measurement.height,
416
+ gap: layout === 'roomy' ? 8 : 1
417
+ }
418
+ : { type: 'equalize', splitId: divider.splitId, index: divider.index }) }, `${divider.splitId}:${divider.index}`))), preview && (_jsx("svg", { className: "ade-split-preview", width: "100%", height: "100%", "aria-hidden": "true", children: _jsx("path", { style: { d: `path("${dropPreviewPath(preview)}")` } }) }))] }))
419
+ }
420
+ });
421
+ }
@@ -0,0 +1,42 @@
1
+ import { type DragEvent, type MouseEvent, type ReactNode } from 'react';
2
+ import { useRender } from '@base-ui-components/react/use-render';
3
+ import { type IconButtonProps } from '../IconButton.tsx';
4
+ export interface TabStripItem {
5
+ id: string;
6
+ label: string;
7
+ hint?: string;
8
+ icon?: ReactNode;
9
+ detail?: ReactNode;
10
+ attention?: ReactNode;
11
+ unsaved?: boolean | string;
12
+ pinned?: boolean;
13
+ closable?: boolean;
14
+ tabId?: string;
15
+ panelId?: string;
16
+ }
17
+ export interface TabStripProps extends Omit<useRender.ComponentProps<'div'>, 'children' | 'onDragStart' | 'onDragEnd' | 'onDoubleClick' | 'onContextMenu'> {
18
+ items: readonly TabStripItem[];
19
+ value: string | null;
20
+ onValueChange(value: string): void;
21
+ renderTab?(item: TabStripItem): ReactNode;
22
+ focused?: boolean;
23
+ insertAt?: number | null;
24
+ onClose?(id: string): void;
25
+ onDoubleClick?(id: string, event: MouseEvent<HTMLButtonElement>): void;
26
+ onContextMenu?(id: string, event: MouseEvent<HTMLSpanElement>): void;
27
+ onDragStart?(id: string, event: DragEvent<HTMLButtonElement>): void;
28
+ onDragEnd?(event: DragEvent<HTMLButtonElement>): void;
29
+ onTabDragOver?(index: number, event: DragEvent<HTMLDivElement>): void;
30
+ onTabDrop?(index: number, event: DragEvent<HTMLDivElement>): void;
31
+ }
32
+ export declare function TabStrip({ items, value, onValueChange, renderTab, focused, insertAt, onClose, onDoubleClick, onContextMenu, onDragStart, onDragEnd, onTabDragOver, onTabDrop, render, className, ref, ...props }: TabStripProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
33
+ export interface DocumentTabProps extends Omit<useRender.ComponentProps<'span'>, 'children' | 'onDoubleClick' | 'onDragStart' | 'onDragEnd'> {
34
+ item: TabStripItem;
35
+ onClose?(): void;
36
+ closeShortcut?: IconButtonProps['kbd'];
37
+ onDoubleClick?: TabStripProps['onDoubleClick'];
38
+ onDragStart?: TabStripProps['onDragStart'];
39
+ onDragEnd?: TabStripProps['onDragEnd'];
40
+ }
41
+ /** A tab with application-owned metadata and menus, rendered inside TabStrip's renderTab slot. */
42
+ export declare function DocumentTab({ item, onClose, closeShortcut, onDoubleClick, onDragStart, onDragEnd, className, render, ref, ...props }: DocumentTabProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
@@ -0,0 +1,168 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useContext, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
3
+ import { useRender } from '@base-ui-components/react/use-render';
4
+ import { Pin, X } from 'lucide-react';
5
+ import clsx from 'clsx';
6
+ import { useTranslation } from 'react-i18next';
7
+ import { Icon } from '../Icon.js';
8
+ import { IconButton } from '../IconButton.js';
9
+ import { Tooltip } from '../Tooltip.js';
10
+ import { tabGapAt } from './geometry.js';
11
+ export function TabStrip({ items, value, onValueChange, renderTab, focused = true, insertAt, onClose, onDoubleClick, onContextMenu, onDragStart, onDragEnd, onTabDragOver, onTabDrop, render, className, ref, ...props }) {
12
+ const { t } = useTranslation('ui');
13
+ const strip = useRef(null);
14
+ const closingFocus = useRef(null);
15
+ useLayoutEffect(() => {
16
+ if (closingFocus.current && !items.some((item) => item.id === closingFocus.current)) {
17
+ closingFocus.current = null;
18
+ const target = strip.current?.querySelector('[aria-selected="true"]') ?? strip.current?.querySelector('[role="tab"]');
19
+ target?.focus({ preventScroll: true });
20
+ }
21
+ });
22
+ const close = (id, action) => {
23
+ if (strip.current?.contains(strip.current.ownerDocument.activeElement)) {
24
+ closingFocus.current = id;
25
+ }
26
+ if (action) {
27
+ action();
28
+ }
29
+ else {
30
+ onClose?.(id);
31
+ }
32
+ };
33
+ const [edges, setEdges] = useState({ start: false, end: false });
34
+ const [insertion, setInsertion] = useState(0);
35
+ const measure = useCallback(() => {
36
+ const element = strip.current;
37
+ if (!element) {
38
+ return;
39
+ }
40
+ const start = element.scrollLeft > 0;
41
+ const end = element.scrollLeft + element.clientWidth < element.scrollWidth - 1;
42
+ setEdges((current) => (current.start === start && current.end === end ? current : { start, end }));
43
+ if (insertAt !== undefined && insertAt !== null) {
44
+ const tabs = Array.from(element.querySelectorAll('[data-document-tab]'));
45
+ const after = tabs[insertAt];
46
+ const last = tabs.at(-1);
47
+ setInsertion(after?.offsetLeft ?? (last ? last.offsetLeft + last.offsetWidth : 0));
48
+ }
49
+ }, [insertAt]);
50
+ useLayoutEffect(measure);
51
+ useEffect(() => {
52
+ const element = strip.current;
53
+ if (!element || typeof ResizeObserver === 'undefined') {
54
+ return;
55
+ }
56
+ const observer = new ResizeObserver(measure);
57
+ observer.observe(element);
58
+ element.querySelectorAll('[data-document-tab]').forEach((tab) => observer.observe(tab));
59
+ return () => observer.disconnect();
60
+ });
61
+ useEffect(() => {
62
+ strip.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
63
+ }, [value]);
64
+ const gap = (event) => {
65
+ const rects = Array.from(event.currentTarget.querySelectorAll('[data-document-tab]'), (tab) => tab.getBoundingClientRect());
66
+ return tabGapAt(rects, event.clientX);
67
+ };
68
+ const context = {
69
+ items,
70
+ value,
71
+ onValueChange,
72
+ onClose,
73
+ onDoubleClick,
74
+ onContextMenu,
75
+ onDragStart,
76
+ onDragEnd,
77
+ close,
78
+ focus: (index) => strip.current?.querySelectorAll('[role="tab"]')[index]?.focus()
79
+ };
80
+ return useRender({
81
+ render,
82
+ ref: [strip, ref ?? null],
83
+ defaultTagName: 'div',
84
+ props: {
85
+ ...props,
86
+ role: 'tablist',
87
+ 'aria-label': props['aria-label'] ?? t('workspace.tabs'),
88
+ className: clsx('ade-tab-strip scroll-fade-x', className),
89
+ 'data-focused': focused || undefined,
90
+ 'data-fade-start': edges.start || undefined,
91
+ 'data-fade-end': edges.end || undefined,
92
+ onScroll: (event) => {
93
+ props.onScroll?.(event);
94
+ measure();
95
+ },
96
+ onWheel: (event) => {
97
+ props.onWheel?.(event);
98
+ if (!event.defaultPrevented && !event.deltaX && event.deltaY) {
99
+ event.currentTarget.scrollLeft += event.deltaY;
100
+ }
101
+ },
102
+ onDragOver: (event) => {
103
+ props.onDragOver?.(event);
104
+ if (!event.defaultPrevented) {
105
+ onTabDragOver?.(gap(event), event);
106
+ }
107
+ },
108
+ onDrop: (event) => {
109
+ props.onDrop?.(event);
110
+ if (!event.defaultPrevented) {
111
+ onTabDrop?.(gap(event), event);
112
+ }
113
+ },
114
+ children: (_jsxs(_Fragment, { children: [items.map((item) => (_jsx(TabContext.Provider, { value: context, children: renderTab ? renderTab(item) : _jsx(DocumentTab, { item: item }) }, item.id))), insertAt !== undefined && insertAt !== null && _jsx("span", { "aria-hidden": "true", className: "ade-tab-insertion", style: { left: insertion } })] }))
115
+ }
116
+ });
117
+ }
118
+ const TabContext = createContext(null);
119
+ /** A tab with application-owned metadata and menus, rendered inside TabStrip's renderTab slot. */
120
+ export function DocumentTab({ item, onClose, closeShortcut, onDoubleClick, onDragStart, onDragEnd, className, render, ref, ...props }) {
121
+ const context = useContext(TabContext);
122
+ if (!context) {
123
+ throw new Error('DocumentTab must be rendered inside TabStrip.');
124
+ }
125
+ const { t } = useTranslation('ui');
126
+ const { items, value, onValueChange } = context;
127
+ const index = items.findIndex((candidate) => candidate.id === item.id);
128
+ const close = () => context.close(item.id, onClose);
129
+ return useRender({
130
+ render,
131
+ ref,
132
+ defaultTagName: 'span',
133
+ props: {
134
+ ...props,
135
+ className: clsx('ade-document-tab', className),
136
+ 'data-document-tab': item.id,
137
+ 'data-active': item.id === value || undefined,
138
+ 'data-detail': !!item.detail || undefined,
139
+ onContextMenu: (event) => {
140
+ props.onContextMenu?.(event);
141
+ context.onContextMenu?.(item.id, event);
142
+ },
143
+ children: (_jsxs(_Fragment, { children: [_jsx(Tooltip, { label: item.hint ?? item.label, children: _jsxs("button", { type: "button", role: "tab", id: item.tabId, "aria-controls": item.panelId, "aria-selected": item.id === value, tabIndex: item.id === value || (value === null && index === 0) ? 0 : -1, className: "ade-document-tab-open", draggable: !!(onDragStart ?? context.onDragStart), onClick: () => onValueChange(item.id), onDoubleClick: (event) => (onDoubleClick ?? context.onDoubleClick)?.(item.id, event), onDragStart: (event) => {
144
+ event.stopPropagation();
145
+ (onDragStart ?? context.onDragStart)?.(item.id, event);
146
+ }, onDragEnd: onDragEnd ?? context.onDragEnd, onKeyDown: (event) => {
147
+ const next = event.key === 'ArrowRight'
148
+ ? (index + 1) % items.length
149
+ : event.key === 'ArrowLeft'
150
+ ? (index + items.length - 1) % items.length
151
+ : event.key === 'Home'
152
+ ? 0
153
+ : event.key === 'End'
154
+ ? items.length - 1
155
+ : null;
156
+ if (next !== null) {
157
+ event.preventDefault();
158
+ onValueChange(items[next].id);
159
+ context.focus(next);
160
+ }
161
+ else if (event.key === 'Delete' && item.closable !== false && (onClose || context.onClose)) {
162
+ event.preventDefault();
163
+ close();
164
+ }
165
+ }, children: [item.icon, _jsx("span", { className: "truncate", children: item.label }), item.detail, item.attention, item.unsaved && (_jsx("span", { className: "ade-tab-unsaved", children: _jsx("span", { className: "sr-only", children: typeof item.unsaved === 'string' ? item.unsaved : t('workspace.unsaved') }) }))] }) }), item.pinned && _jsx(Icon, { icon: Pin, size: 12, className: "shrink-0 text-text-muted" }), item.closable !== false && (onClose || context.onClose) && (_jsx(IconButton, { icon: X, size: "2xs", label: t('workspace.closeTab', { name: item.label }), kbd: closeShortcut, className: "ade-document-tab-close", onClick: close }))] }))
166
+ }
167
+ });
168
+ }
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from 'react';
2
+ import { useRender } from '@base-ui-components/react/use-render';
3
+ import { type WorkspaceLayout } from './context.ts';
4
+ export interface WorkspaceProps extends useRender.ComponentProps<'div'> {
5
+ layout?: WorkspaceLayout;
6
+ toolbar?: ReactNode;
7
+ sidebar?: ReactNode;
8
+ sidePanel?: ReactNode;
9
+ /** Auto-sized regions keep their own width, separators and opening animation. */
10
+ sidebarWidth?: number | 'auto';
11
+ sidePanelWidth?: number | 'auto';
12
+ sidebarOpen?: boolean;
13
+ sidePanelOpen?: boolean;
14
+ }
15
+ export declare function Workspace({ layout, toolbar, sidebar, sidePanel, sidebarWidth, sidePanelWidth, sidebarOpen, sidePanelOpen, children, className, ref, render, style, ...props }: WorkspaceProps): import("react").JSX.Element;