@adecore/ui 0.21.1 → 0.22.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 (66) hide show
  1. package/README.md +4 -4
  2. package/dist/DisabledReason.js +4 -0
  3. package/dist/Field.js +3 -2
  4. package/dist/Input.js +3 -8
  5. package/dist/SegmentBar.js +2 -1
  6. package/dist/Separator.js +2 -3
  7. package/dist/SlidingColumn.js +1 -1
  8. package/dist/ZoomControls.js +4 -1
  9. package/dist/field-context.d.ts +5 -0
  10. package/dist/field-context.js +6 -0
  11. package/dist/file-icon.js +8 -10
  12. package/dist/file-tree/parts.js +8 -15
  13. package/dist/file-tree/use-file-tree-shift.js +4 -5
  14. package/dist/file-tree/use-slots.js +31 -31
  15. package/dist/format/datetime.js +6 -5
  16. package/dist/format/locale.js +6 -1
  17. package/dist/format/number.js +15 -18
  18. package/dist/menu/parts.d.ts +3 -2
  19. package/dist/menu/parts.js +4 -2
  20. package/dist/modality.js +4 -5
  21. package/dist/placement.d.ts +4 -0
  22. package/dist/placement.js +23 -0
  23. package/dist/popover/parts.d.ts +4 -3
  24. package/dist/popover/parts.js +7 -4
  25. package/dist/shortcut.js +9 -4
  26. package/dist/tabs/parts.js +3 -2
  27. package/dist/tree/parts.js +10 -20
  28. package/dist/tree/use-tree-shift.js +1 -1
  29. package/dist/workspace/SplitView.js +20 -14
  30. package/dist/workspace/TabStrip.js +19 -11
  31. package/dist/workspace/Workspace.js +4 -3
  32. package/dist/workspace/model.js +123 -122
  33. package/package.json +1 -2
  34. package/src/DisabledReason.tsx +4 -5
  35. package/src/Field.tsx +4 -2
  36. package/src/IconButton.tsx +0 -1
  37. package/src/Input.tsx +3 -10
  38. package/src/Pill.tsx +8 -1
  39. package/src/SegmentBar.tsx +3 -10
  40. package/src/Separator.tsx +2 -3
  41. package/src/SlidingColumn.tsx +1 -1
  42. package/src/ZoomControls.tsx +11 -7
  43. package/src/field-context.ts +7 -0
  44. package/src/file-icon.ts +8 -10
  45. package/src/file-tree/parts.tsx +17 -22
  46. package/src/file-tree/use-file-tree-shift.tsx +4 -7
  47. package/src/file-tree/use-slots.ts +31 -31
  48. package/src/format/datetime.ts +7 -5
  49. package/src/format/locale.ts +7 -7
  50. package/src/format/number.ts +17 -20
  51. package/src/menu/parts.tsx +10 -29
  52. package/src/modality.ts +4 -5
  53. package/src/placement.ts +26 -0
  54. package/src/popover/parts.tsx +10 -63
  55. package/src/settings/SettingsDialog.tsx +1 -1
  56. package/src/shortcut.ts +9 -4
  57. package/src/tabs/parts.tsx +8 -8
  58. package/src/tree/parts.tsx +14 -21
  59. package/src/tree/use-tree-shift.tsx +1 -1
  60. package/src/workspace/SplitView.tsx +23 -19
  61. package/src/workspace/TabStrip.tsx +20 -12
  62. package/src/workspace/Workspace.tsx +5 -3
  63. package/src/workspace/model.ts +141 -138
  64. /package/dist/{file-tree → tree}/shift.d.ts +0 -0
  65. /package/dist/{file-tree → tree}/shift.js +0 -0
  66. /package/src/{file-tree → tree}/shift.ts +0 -0
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Popover as BasePopover } from '@base-ui-components/react/popover';
3
3
  import { PreviewCard as BasePreviewCard } from '@base-ui-components/react/preview-card';
4
4
  import { withClass } from '../class-name.js';
5
+ import { splitPlacement } from '../placement.js';
5
6
  import { renderPositioner } from '../popup-layer.js';
6
7
  /*
7
8
  * The surface a popup is drawn on. `menu` is the padded card menus use, `picker` a card whose
@@ -18,12 +19,14 @@ export const PopoverClose = BasePopover.Close;
18
19
  export const PopoverTitle = BasePopover.Title;
19
20
  export const PopoverDescription = BasePopover.Description;
20
21
  /* The portal, the positioner and the popup of a popover: under its trigger and lined up with its start unless it says otherwise. */
21
- export function PopoverPopup({ variant = 'menu', side = 'bottom', align = 'start', sideOffset = 8, alignOffset, collisionPadding, collisionBoundary, collisionAvoidance, anchor, sticky, positionMethod, className, ...props }) {
22
- return (_jsx(BasePopover.Portal, { children: _jsx(BasePopover.Positioner, { className: "popup-positioner", render: renderPositioner, side: side, align: align, sideOffset: sideOffset, alignOffset: alignOffset, collisionPadding: collisionPadding, collisionBoundary: collisionBoundary, collisionAvoidance: collisionAvoidance, anchor: anchor, sticky: sticky, positionMethod: positionMethod, children: _jsx(BasePopover.Popup, { className: withClass(POPUP_VARIANT[variant], className), ...props }) }) }));
22
+ export function PopoverPopup({ variant = 'menu', side = 'bottom', align = 'start', sideOffset = 8, className, ...props }) {
23
+ const [placement, popupProps] = splitPlacement(props);
24
+ return (_jsx(BasePopover.Portal, { children: _jsx(BasePopover.Positioner, { className: "popup-positioner", render: renderPositioner, ...placement, side: side, align: align, sideOffset: sideOffset, children: _jsx(BasePopover.Popup, { className: withClass(POPUP_VARIANT[variant], className), ...popupProps }) }) }));
23
25
  }
24
26
  export const PreviewCardRoot = BasePreviewCard.Root;
25
27
  export const PreviewCardTrigger = BasePreviewCard.Trigger;
26
28
  /* A card that opens while the pointer rests on its trigger, drawn like a popover. */
27
- export function PreviewCardPopup({ variant = 'menu', side = 'bottom', align = 'start', sideOffset = 8, alignOffset, collisionPadding, collisionBoundary, collisionAvoidance, anchor, sticky, positionMethod, className, ...props }) {
28
- return (_jsx(BasePreviewCard.Portal, { children: _jsx(BasePreviewCard.Positioner, { className: "z-(--z-popup)", side: side, align: align, sideOffset: sideOffset, alignOffset: alignOffset, collisionPadding: collisionPadding, collisionBoundary: collisionBoundary, collisionAvoidance: collisionAvoidance, anchor: anchor, sticky: sticky, positionMethod: positionMethod, children: _jsx(BasePreviewCard.Popup, { className: withClass(POPUP_VARIANT[variant], className), ...props }) }) }));
29
+ export function PreviewCardPopup({ variant = 'menu', side = 'bottom', align = 'start', sideOffset = 8, className, ...props }) {
30
+ const [placement, popupProps] = splitPlacement(props);
31
+ return (_jsx(BasePreviewCard.Portal, { children: _jsx(BasePreviewCard.Positioner, { className: "z-(--z-popup)", ...placement, side: side, align: align, sideOffset: sideOffset, children: _jsx(BasePreviewCard.Popup, { className: withClass(POPUP_VARIANT[variant], className), ...popupProps }) }) }));
29
32
  }
package/dist/shortcut.js CHANGED
@@ -14,7 +14,13 @@ const PUNCTUATION_CODES = {
14
14
  const CHARACTER_KEYS = new Set(['+', '-', '=']);
15
15
  const NAMED_KEYS = new Set(['Enter', 'Escape', 'Backspace', 'Delete', 'Tab', 'Space', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']);
16
16
  const FUNCTION_KEY = /^F([1-9]|1[0-2])$/;
17
- const MODIFIER_WORDS = ['Mod', 'Ctrl', 'Meta', 'Alt', 'Shift'];
17
+ const MODIFIER_FIELDS = {
18
+ Mod: 'mod',
19
+ Ctrl: 'ctrl',
20
+ Meta: 'meta',
21
+ Alt: 'alt',
22
+ Shift: 'shift'
23
+ };
18
24
  const isKey = (word) => /^[A-Za-z0-9]$/.test(word) || word in PUNCTUATION_CODES || CHARACTER_KEYS.has(word) || NAMED_KEYS.has(word) || FUNCTION_KEY.test(word);
19
25
  /**
20
26
  * Parses `Mod+Shift+K`. Throws on a word it does not know or on two keys, so a typo in a table fails
@@ -25,9 +31,8 @@ export const shortcut = (text) => {
25
31
  const words = text === '+' ? ['+'] : text.endsWith('++') ? [...text.slice(0, -2).split('+'), '+'] : text.split('+');
26
32
  const result = { mod: false, ctrl: false, meta: false, alt: false, shift: false, key: '' };
27
33
  for (const word of words) {
28
- const modifier = MODIFIER_WORDS.find((candidate) => candidate === word);
29
- if (modifier) {
30
- result[modifier === 'Mod' ? 'mod' : modifier === 'Ctrl' ? 'ctrl' : modifier === 'Meta' ? 'meta' : modifier === 'Alt' ? 'alt' : 'shift'] = true;
34
+ if (Object.hasOwn(MODIFIER_FIELDS, word)) {
35
+ result[MODIFIER_FIELDS[word]] = true;
31
36
  continue;
32
37
  }
33
38
  if (!isKey(word)) {
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Children, createContext, isValidElement, useContext, useLayoutEffect, useRef, useState } from 'react';
3
3
  import clsx from 'clsx';
4
4
  import { ChevronDown, X } from 'lucide-react';
@@ -77,7 +77,8 @@ export function TabsList({ className, children, start, end, ...props }) {
77
77
  });
78
78
  const visible = shown === null ? tabs : shown.map((index) => tabs[index]);
79
79
  const hidden = shown === null ? [] : tabs.filter((_, index) => !shown.includes(index));
80
- return (_jsxs("div", { ref: strip, className: clsx('relative flex items-center gap-4 border-b border-border', className), children: [start !== undefined && start !== null && (_jsx("div", { ref: head, className: "flex shrink-0 items-center gap-1", children: start })), _jsx(BaseTabs.List, { className: "flex min-w-0 items-end gap-4", ...props, children: visible }), hidden.length > 0 && (_jsxs(MenuRoot, { children: [_jsxs(MenuTrigger, { className: MORE, children: [t('tabs.more'), _jsx(Icon, { icon: ChevronDown, size: 14 })] }), _jsx(MenuPopup, { align: "end", className: "min-w-48", children: _jsx(InMenuContext, { value: true, children: hidden.map((tab) => (_jsx(MenuItem, { disabled: tab.props.disabled, onClick: () => select(tab.props.value), children: tab.props.children }, tab.key))) }) })] })), end !== undefined && end !== null && (_jsx("div", { ref: tail, className: clsx('flex shrink-0 items-center gap-1', hidden.length === 0 && 'ml-auto'), children: end })), _jsxs("div", { ref: measure, "aria-hidden": true, inert: true, className: "pointer-events-none invisible absolute top-0 left-0 flex gap-4", children: [tabs.map((tab) => (_jsxs("span", { className: TAB, children: [tab.props.children, tab.props.onClose !== undefined && _jsx("span", { className: "size-5 shrink-0" })] }, tab.key))), _jsxs("span", { className: MORE, children: [t('tabs.more'), _jsx(Icon, { icon: ChevronDown, size: 14 })] })] })] }));
80
+ const more = (_jsxs(_Fragment, { children: [t('tabs.more'), _jsx(Icon, { icon: ChevronDown, size: 14 })] }));
81
+ return (_jsxs("div", { ref: strip, className: clsx('relative flex items-center gap-4 border-b border-border', className), children: [start !== undefined && start !== null && (_jsx("div", { ref: head, className: "flex shrink-0 items-center gap-1", children: start })), _jsx(BaseTabs.List, { className: "flex min-w-0 items-end gap-4", ...props, children: visible }), hidden.length > 0 && (_jsxs(MenuRoot, { children: [_jsx(MenuTrigger, { className: MORE, children: more }), _jsx(MenuPopup, { align: "end", className: "min-w-48", children: _jsx(InMenuContext, { value: true, children: hidden.map((tab) => (_jsx(MenuItem, { disabled: tab.props.disabled, onClick: () => select(tab.props.value), children: tab.props.children }, tab.key))) }) })] })), end !== undefined && end !== null && (_jsx("div", { ref: tail, className: clsx('flex shrink-0 items-center gap-1', hidden.length === 0 && 'ml-auto'), children: end })), _jsxs("div", { ref: measure, "aria-hidden": true, inert: true, className: "pointer-events-none invisible absolute top-0 left-0 flex gap-4", children: [tabs.map((tab) => (_jsxs("span", { className: TAB, children: [tab.props.children, tab.props.onClose !== undefined && _jsx("span", { className: "size-5 shrink-0" })] }, tab.key))), _jsx("span", { className: MORE, children: more })] })] }));
81
82
  }
82
83
  /* The close button stays out of the tab order, so a closable tab is still one stop; the keys close it from the tab itself. */
83
84
  export function TabsTab({ className, onClose, children, ...props }) {
@@ -9,6 +9,11 @@ import { Icon } from '../Icon.js';
9
9
  import { mergeRefs } from '../merge-refs.js';
10
10
  import { treeRowStyle } from './style.js';
11
11
  import { TREE_ROWS, useTreeShift } from './use-tree-shift.js';
12
+ /* A handler that keeps the event to the control, so the row under it never sees the press. */
13
+ const stopped = (handler) => (event) => {
14
+ event.stopPropagation();
15
+ handler?.(event);
16
+ };
12
17
  export function TreeRoot({ overflow = 'truncate', render, className, ref, children, ...props }) {
13
18
  const { attach, bar } = useTreeShift(TREE_ROWS);
14
19
  const scrolls = overflow === 'scroll';
@@ -76,10 +81,7 @@ export function TreeChevron({ expanded, onExpandedChange, render, className, ref
76
81
  onExpandedChange?.(!expanded);
77
82
  }
78
83
  },
79
- onDoubleClick: (event) => {
80
- event.stopPropagation();
81
- onDoubleClick?.(event);
82
- },
84
+ onDoubleClick: stopped(onDoubleClick),
83
85
  children: _jsx(Icon, { icon: ChevronRight, size: 12, className: expanded ? 'rotate-90' : undefined })
84
86
  }
85
87
  });
@@ -99,22 +101,10 @@ export function TreeControl({ render, className, ref, onClick, onDoubleClick, on
99
101
  ...props,
100
102
  'data-tree-control': true,
101
103
  className: clsx('adecore-tree-control', className),
102
- onClick: (event) => {
103
- event.stopPropagation();
104
- onClick?.(event);
105
- },
106
- onDoubleClick: (event) => {
107
- event.stopPropagation();
108
- onDoubleClick?.(event);
109
- },
110
- onKeyDown: (event) => {
111
- event.stopPropagation();
112
- onKeyDown?.(event);
113
- },
114
- onPointerDown: (event) => {
115
- event.stopPropagation();
116
- onPointerDown?.(event);
117
- }
104
+ onClick: stopped(onClick),
105
+ onDoubleClick: stopped(onDoubleClick),
106
+ onKeyDown: stopped(onKeyDown),
107
+ onPointerDown: stopped(onPointerDown)
118
108
  }
119
109
  });
120
110
  }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
- import { clampShift, maxShift, SHIFT_PROPERTY, shiftNeed, shiftThumb, sidewaysDelta } from '../file-tree/shift.js';
3
+ import { clampShift, maxShift, SHIFT_PROPERTY, shiftNeed, shiftThumb, sidewaysDelta } from './shift.js';
4
4
  const HIDE_AFTER = 900;
5
5
  const NEAR_BOTTOM = 16;
6
6
  /*
@@ -12,6 +12,11 @@ import { TabStrip } from './TabStrip.js';
12
12
  import { dropPreviewPath, rectPreview, tabPreview } from './preview.js';
13
13
  const ZERO = { width: 0, height: 0, left: 0, top: 0, scaleX: 1, scaleY: 1 };
14
14
  const MINIMUM = { width: 120, height: 80 };
15
+ /* The pane's section or header under `container`, found by its data attribute rather than a selector, so no id needs escaping. */
16
+ function paneElement(container, attribute, paneId) {
17
+ const selector = attribute === 'splitPane' ? '[data-split-pane]' : '[data-split-header]';
18
+ return Array.from(container?.querySelectorAll(selector) ?? []).find((element) => element.dataset[attribute] === paneId);
19
+ }
15
20
  function Divider({ divider, minimumResizeShare, onResize, onEqualize }) {
16
21
  const { t } = useTranslation('ui');
17
22
  const cleanup = useRef(null);
@@ -94,7 +99,7 @@ export function SplitView({ value, onValueChange, renderView, getTab, renderPane
94
99
  element &&
95
100
  (!previous.isConnected || previous.closest('[hidden]')) &&
96
101
  (!active || active === element.ownerDocument.body || active === previous)) {
97
- const pane = Array.from(element.querySelectorAll('[data-split-pane]')).find((item) => item.dataset.splitPane === value.focused);
102
+ const pane = paneElement(element, 'splitPane', value.focused);
98
103
  const target = pane?.querySelector('[role="tab"][aria-selected="true"]') ?? pane;
99
104
  target?.focus({ preventScroll: true });
100
105
  }
@@ -177,6 +182,14 @@ export function SplitView({ value, onValueChange, renderView, getTab, renderPane
177
182
  .sort((left, right) => (left.viewId < right.viewId ? -1 : left.viewId > right.viewId ? 1 : 0));
178
183
  const visible = new Map(geometry.panes.map((pane) => [pane.pane.id, pane]));
179
184
  const radius = (rect) => paneRadius(rect, measurement.width, measurement.height, edges, layout === 'roomy' ? 8 : 0);
185
+ // A header squares off the top corners of the body under it.
186
+ const bodyRadius = (rect, header) => {
187
+ const corners = radius(rect).split(' ');
188
+ if (header > 0) {
189
+ corners[0] = corners[1] = '0px';
190
+ }
191
+ return corners.join(' ');
192
+ };
180
193
  const bounds = panes.flatMap((pane) => {
181
194
  const rect = visible.get(pane.id);
182
195
  const header = Math.min(barHeight, rect?.height ?? 0);
@@ -188,11 +201,8 @@ export function SplitView({ value, onValueChange, renderView, getTab, renderPane
188
201
  height: Math.max(0, rect.height - header) * measurement.scaleY
189
202
  }
190
203
  : { 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(' ') }));
204
+ const borderRadius = rect ? bodyRadius(rect, header) : '0px 0px 0px 0px';
205
+ return pane.views.map((viewId) => ({ viewId, paneId: pane.id, active: !!rect && pane.active === viewId, rect: body, borderRadius }));
196
206
  });
197
207
  const boundsKey = JSON.stringify(bounds);
198
208
  const reportedBounds = useRef('');
@@ -244,7 +254,7 @@ export function SplitView({ value, onValueChange, renderView, getTab, renderPane
244
254
  return null;
245
255
  }
246
256
  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);
257
+ const strip = paneElement(root.current, 'splitHeader', rect.pane.id);
248
258
  const tabRects = Array.from(strip?.querySelectorAll('[data-document-tab]') ?? [], (element) => element.getBoundingClientRect());
249
259
  const inHeader = y < rect.y + header;
250
260
  const side = inHeader ? undefined : (splitSideAt(rect, x, y) ?? 'center');
@@ -304,7 +314,7 @@ export function SplitView({ value, onValueChange, renderView, getTab, renderPane
304
314
  const next = options[0];
305
315
  if (next) {
306
316
  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);
317
+ const element = paneElement(root.current, 'splitPane', next.pane.id);
308
318
  element?.querySelector('[role="tab"][aria-selected="true"]')?.focus();
309
319
  if (!showTabs) {
310
320
  element?.focus();
@@ -394,17 +404,13 @@ export function SplitView({ value, onValueChange, renderView, getTab, renderPane
394
404
  const rect = visible.get(pane.id);
395
405
  const active = !!rect && pane.active === viewId;
396
406
  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
407
  const position = rect
402
408
  ? {
403
409
  left: rect.x,
404
410
  top: rect.y + header,
405
411
  width: rect.width,
406
412
  height: Math.max(0, rect.height - header),
407
- borderRadius: corners.join(' ')
413
+ borderRadius: bodyRadius(rect, header)
408
414
  }
409
415
  : {};
410
416
  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));
@@ -413,7 +419,7 @@ export function SplitView({ value, onValueChange, renderView, getTab, renderPane
413
419
  type: 'equalizeAxis',
414
420
  axis: divider.axis,
415
421
  length: divider.axis === 'horizontal' ? measurement.width : measurement.height,
416
- gap: layout === 'roomy' ? 8 : 1
422
+ gap
417
423
  }
418
424
  : { 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
425
  }
@@ -8,6 +8,21 @@ import { Icon } from '../Icon.js';
8
8
  import { IconButton } from '../IconButton.js';
9
9
  import { Tooltip } from '../Tooltip.js';
10
10
  import { tabGapAt } from './geometry.js';
11
+ /* Where an arrow, Home or End moves the selection from `index`, wrapping at either end; null for any other key. */
12
+ function tabIndexForKey(key, index, count) {
13
+ switch (key) {
14
+ case 'ArrowRight':
15
+ return (index + 1) % count;
16
+ case 'ArrowLeft':
17
+ return (index + count - 1) % count;
18
+ case 'Home':
19
+ return 0;
20
+ case 'End':
21
+ return count - 1;
22
+ default:
23
+ return null;
24
+ }
25
+ }
11
26
  export function TabStrip({ items, value, onValueChange, renderTab, focused = true, insertAt, onClose, onDoubleClick, onContextMenu, onDragStart, onDragEnd, onTabDragOver, onTabDrop, render, className, ref, ...props }) {
12
27
  const { t } = useTranslation('ui');
13
28
  const strip = useRef(null);
@@ -126,6 +141,7 @@ export function DocumentTab({ item, onClose, closeShortcut, onDoubleClick, onDra
126
141
  const { items, value, onValueChange } = context;
127
142
  const index = items.findIndex((candidate) => candidate.id === item.id);
128
143
  const close = () => context.close(item.id, onClose);
144
+ const closable = item.closable !== false && (onClose ?? context.onClose) !== undefined;
129
145
  return useRender({
130
146
  render,
131
147
  ref,
@@ -144,25 +160,17 @@ export function DocumentTab({ item, onClose, closeShortcut, onDoubleClick, onDra
144
160
  event.stopPropagation();
145
161
  (onDragStart ?? context.onDragStart)?.(item.id, event);
146
162
  }, 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;
163
+ const next = tabIndexForKey(event.key, index, items.length);
156
164
  if (next !== null) {
157
165
  event.preventDefault();
158
166
  onValueChange(items[next].id);
159
167
  context.focus(next);
160
168
  }
161
- else if (event.key === 'Delete' && item.closable !== false && (onClose || context.onClose)) {
169
+ else if (event.key === 'Delete' && closable) {
162
170
  event.preventDefault();
163
171
  close();
164
172
  }
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 }))] }))
173
+ }, 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" }), closable && (_jsx(IconButton, { icon: X, size: "2xs", label: t('workspace.closeTab', { name: item.label }), kbd: closeShortcut, className: "ade-document-tab-close", onClick: close }))] }))
166
174
  }
167
175
  });
168
176
  }
@@ -2,12 +2,13 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import clsx from 'clsx';
3
3
  import { useRender } from '@base-ui-components/react/use-render';
4
4
  import { WorkspaceContext } from './context.js';
5
+ const isPresent = (node) => node !== undefined && node !== null && node !== false;
5
6
  export function Workspace({ layout = 'standard', toolbar, sidebar, sidePanel, sidebarWidth = 248, sidePanelWidth = 320, sidebarOpen, sidePanelOpen, children, className, ref, render, style, ...props }) {
6
- const hasSidebar = sidebar !== undefined && sidebar !== null && sidebar !== false;
7
- const hasPanel = sidePanel !== undefined && sidePanel !== null && sidePanel !== false;
7
+ const hasSidebar = isPresent(sidebar);
8
+ const hasPanel = isPresent(sidePanel);
8
9
  const leftOpen = hasSidebar && (sidebarOpen ?? true);
9
10
  const rightOpen = hasPanel && (sidePanelOpen ?? true);
10
- const hasToolbar = toolbar !== undefined && toolbar !== null && toolbar !== false;
11
+ const hasToolbar = isPresent(toolbar);
11
12
  const element = useRender({
12
13
  render,
13
14
  ref,
@@ -188,6 +188,121 @@ function equalizeAxis(root, axis, length, gap) {
188
188
  };
189
189
  return distribute(root, length);
190
190
  }
191
+ function addPane(layout, panes, pane, command) {
192
+ if (!pane || findSplitNode(layout.root, command.newPane.id) || findSplitNode(layout.root, command.splitId) || command.newPane.id === command.splitId) {
193
+ return layout;
194
+ }
195
+ const allViews = new Set(panes.flatMap((item) => item.views));
196
+ if (command.newPane.views.some((view) => allViews.has(view)) || new Set(command.newPane.views).size !== command.newPane.views.length) {
197
+ return layout;
198
+ }
199
+ const added = {
200
+ ...command.newPane,
201
+ active: command.newPane.views.includes(command.newPane.active ?? '') ? command.newPane.active : (command.newPane.views[0] ?? null)
202
+ };
203
+ return finish({ ...layout, maximized: null }, replace(layout.root, pane.id, () => beside(pane, added, command.side, command.splitId)), added.id);
204
+ }
205
+ function swapPanes(layout, panes, command) {
206
+ const source = panes.find((item) => item.id === command.sourceId);
207
+ const target = panes.find((item) => item.id === command.targetId);
208
+ if (!source || !target || source === target || source.views.length === 0 || (command.viewId !== undefined && !source.views.includes(command.viewId))) {
209
+ return layout;
210
+ }
211
+ if (command.viewId === undefined || source.views.length === 1) {
212
+ const exchange = (node) => {
213
+ if (node.id === source.id) {
214
+ return target;
215
+ }
216
+ if (node.id === target.id) {
217
+ return source;
218
+ }
219
+ return node.type === 'pane' ? node : { ...node, children: node.children.map(exchange) };
220
+ };
221
+ return finish({ ...layout, maximized: null }, exchange(layout.root), source.id);
222
+ }
223
+ const moving = command.viewId;
224
+ const displaced = target.active;
225
+ const sourceNext = displaced === null
226
+ ? without(source, [moving])
227
+ : {
228
+ ...source,
229
+ views: source.views.map((view) => (view === moving ? displaced : view)),
230
+ active: source.active === moving ? displaced : source.active
231
+ };
232
+ const targetNext = {
233
+ ...target,
234
+ views: displaced === null ? [moving] : target.views.map((view) => (view === displaced ? moving : view)),
235
+ active: moving
236
+ };
237
+ const root = replace(layout.root, source.id, () => sourceNext);
238
+ return finish({ ...layout, maximized: null }, replace(root, target.id, () => targetNext), target.id);
239
+ }
240
+ function moveViews(layout, panes, command) {
241
+ const source = panes.find((item) => item.id === command.sourceId);
242
+ const target = panes.find((item) => item.id === command.targetId);
243
+ if (!source || !target || (command.viewId !== undefined && !source.views.includes(command.viewId))) {
244
+ return layout;
245
+ }
246
+ const moving = command.viewId === undefined ? source.views : [command.viewId];
247
+ if (!moving.length || (source === target && (command.viewId === undefined || (command.side !== undefined && source.views.length === 1)))) {
248
+ return layout;
249
+ }
250
+ let added;
251
+ if (command.side) {
252
+ const id = command.viewId === undefined ? source.id : command.newPaneId;
253
+ if (!id ||
254
+ !command.splitId ||
255
+ id === command.splitId ||
256
+ findSplitNode(layout.root, command.splitId) ||
257
+ (findSplitNode(layout.root, id) && !(id === source.id && command.viewId === undefined))) {
258
+ return layout;
259
+ }
260
+ added = { type: 'pane', id, views: moving, active: command.viewId ?? source.active };
261
+ }
262
+ const remaining = without(source, moving);
263
+ const root = replace(layout.root, source.id, () => (remaining.views.length || source === target ? remaining : null));
264
+ if (!root) {
265
+ return layout;
266
+ }
267
+ const next = replace(root, target.id, (node) => {
268
+ if (node.type !== 'pane') {
269
+ return node;
270
+ }
271
+ if (added && command.side && command.splitId) {
272
+ return beside(node, added, command.side, command.splitId);
273
+ }
274
+ const gap = Math.max(0, Math.min(target.views.length, command.index ?? target.views.length));
275
+ const lifted = source === target ? target.views.slice(0, gap).filter((view) => moving.includes(view)).length : 0;
276
+ const views = [...node.views];
277
+ views.splice(gap - lifted, 0, ...moving);
278
+ return { ...node, views, active: command.viewId ?? source.active ?? views[0] ?? null };
279
+ });
280
+ return finish({ ...layout, maximized: null }, next, added?.id ?? target.id);
281
+ }
282
+ function resizeBranch(layout, command) {
283
+ const node = findSplitNode(layout.root, command.splitId);
284
+ if (!node || node.type !== 'split') {
285
+ return layout;
286
+ }
287
+ let sizes = [...node.sizes];
288
+ if (command.type === 'resize') {
289
+ if (command.sizes.length !== node.children.length || command.sizes.some((size) => !Number.isFinite(size) || size <= 0)) {
290
+ return layout;
291
+ }
292
+ sizes = shares(command.sizes, node.children.length);
293
+ }
294
+ else if (command.index === undefined) {
295
+ sizes = node.children.map(() => 1 / node.children.length);
296
+ }
297
+ else {
298
+ const index = command.index;
299
+ if (index < 0 || index >= sizes.length - 1) {
300
+ return layout;
301
+ }
302
+ sizes[index] = sizes[index + 1] = (sizes[index] + sizes[index + 1]) / 2;
303
+ }
304
+ return finish(layout, replace(layout.root, node.id, () => ({ ...node, sizes })));
305
+ }
191
306
  /** Invalid or stale commands are no-ops. IDs for new nodes come from the caller. */
192
307
  export function updateSplitLayout(layout, command) {
193
308
  const panes = splitPanes(layout.root);
@@ -225,104 +340,12 @@ export function updateSplitLayout(layout, command) {
225
340
  views.splice(Math.max(0, Math.min(views.length, command.index ?? views.length)), 0, command.viewId);
226
341
  return finish(layout, replace(layout.root, pane.id, () => ({ ...pane, views, active: command.viewId })), pane.id);
227
342
  }
228
- case 'split': {
229
- if (!pane ||
230
- findSplitNode(layout.root, command.newPane.id) ||
231
- findSplitNode(layout.root, command.splitId) ||
232
- command.newPane.id === command.splitId) {
233
- return layout;
234
- }
235
- const allViews = new Set(panes.flatMap((item) => item.views));
236
- if (command.newPane.views.some((view) => allViews.has(view)) || new Set(command.newPane.views).size !== command.newPane.views.length) {
237
- return layout;
238
- }
239
- const added = {
240
- ...command.newPane,
241
- active: command.newPane.views.includes(command.newPane.active ?? '') ? command.newPane.active : (command.newPane.views[0] ?? null)
242
- };
243
- return finish({ ...layout, maximized: null }, replace(layout.root, pane.id, () => beside(pane, added, command.side, command.splitId)), added.id);
244
- }
245
- case 'swap': {
246
- const source = panes.find((item) => item.id === command.sourceId);
247
- const target = panes.find((item) => item.id === command.targetId);
248
- if (!source ||
249
- !target ||
250
- source === target ||
251
- source.views.length === 0 ||
252
- (command.viewId !== undefined && !source.views.includes(command.viewId))) {
253
- return layout;
254
- }
255
- if (command.viewId === undefined || source.views.length === 1) {
256
- const exchange = (node) => {
257
- if (node.id === source.id) {
258
- return target;
259
- }
260
- if (node.id === target.id) {
261
- return source;
262
- }
263
- return node.type === 'pane' ? node : { ...node, children: node.children.map(exchange) };
264
- };
265
- return finish({ ...layout, maximized: null }, exchange(layout.root), source.id);
266
- }
267
- const moving = command.viewId;
268
- const displaced = target.active;
269
- const sourceNext = displaced === null
270
- ? without(source, [moving])
271
- : {
272
- ...source,
273
- views: source.views.map((view) => (view === moving ? displaced : view)),
274
- active: source.active === moving ? displaced : source.active
275
- };
276
- const targetNext = {
277
- ...target,
278
- views: displaced === null ? [moving] : target.views.map((view) => (view === displaced ? moving : view)),
279
- active: moving
280
- };
281
- const root = replace(layout.root, source.id, () => sourceNext);
282
- return finish({ ...layout, maximized: null }, replace(root, target.id, () => targetNext), target.id);
283
- }
284
- case 'move': {
285
- const source = panes.find((item) => item.id === command.sourceId);
286
- const target = panes.find((item) => item.id === command.targetId);
287
- if (!source || !target || (command.viewId !== undefined && !source.views.includes(command.viewId))) {
288
- return layout;
289
- }
290
- const moving = command.viewId === undefined ? source.views : [command.viewId];
291
- if (!moving.length || (source === target && (command.viewId === undefined || (command.side !== undefined && source.views.length === 1)))) {
292
- return layout;
293
- }
294
- let added;
295
- if (command.side) {
296
- const id = command.viewId === undefined ? source.id : command.newPaneId;
297
- if (!id ||
298
- !command.splitId ||
299
- id === command.splitId ||
300
- findSplitNode(layout.root, command.splitId) ||
301
- (findSplitNode(layout.root, id) && !(id === source.id && command.viewId === undefined))) {
302
- return layout;
303
- }
304
- added = { type: 'pane', id, views: moving, active: command.viewId ?? source.active };
305
- }
306
- const remaining = without(source, moving);
307
- const root = replace(layout.root, source.id, () => (remaining.views.length || source === target ? remaining : null));
308
- if (!root) {
309
- return layout;
310
- }
311
- const next = replace(root, target.id, (node) => {
312
- if (node.type !== 'pane') {
313
- return node;
314
- }
315
- if (added && command.side && command.splitId) {
316
- return beside(node, added, command.side, command.splitId);
317
- }
318
- const gap = Math.max(0, Math.min(target.views.length, command.index ?? target.views.length));
319
- const lifted = source === target ? target.views.slice(0, gap).filter((view) => moving.includes(view)).length : 0;
320
- const views = [...node.views];
321
- views.splice(gap - lifted, 0, ...moving);
322
- return { ...node, views, active: command.viewId ?? source.active ?? views[0] ?? null };
323
- });
324
- return finish({ ...layout, maximized: null }, next, added?.id ?? target.id);
325
- }
343
+ case 'split':
344
+ return addPane(layout, panes, pane, command);
345
+ case 'swap':
346
+ return swapPanes(layout, panes, command);
347
+ case 'move':
348
+ return moveViews(layout, panes, command);
326
349
  case 'equalizeAxis': {
327
350
  const length = command.length ?? 1;
328
351
  const gap = command.gap ?? 0;
@@ -332,29 +355,7 @@ export function updateSplitLayout(layout, command) {
332
355
  return finish(layout, equalizeAxis(layout.root, command.axis, length, gap));
333
356
  }
334
357
  case 'resize':
335
- case 'equalize': {
336
- const node = findSplitNode(layout.root, command.splitId);
337
- if (!node || node.type !== 'split') {
338
- return layout;
339
- }
340
- let sizes = [...node.sizes];
341
- if (command.type === 'resize') {
342
- if (command.sizes.length !== node.children.length || command.sizes.some((size) => !Number.isFinite(size) || size <= 0)) {
343
- return layout;
344
- }
345
- sizes = shares(command.sizes, node.children.length);
346
- }
347
- else if (command.index === undefined) {
348
- sizes = node.children.map(() => 1 / node.children.length);
349
- }
350
- else {
351
- const index = command.index;
352
- if (index < 0 || index >= sizes.length - 1) {
353
- return layout;
354
- }
355
- sizes[index] = sizes[index + 1] = (sizes[index] + sizes[index + 1]) / 2;
356
- }
357
- return finish(layout, replace(layout.root, node.id, () => ({ ...node, sizes })));
358
- }
358
+ case 'equalize':
359
+ return resizeBranch(layout, command);
359
360
  }
360
361
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/ui",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "description": "React components, a theme, formatters and a settings dialog for desktop-grade apps on React 19, Base UI and Tailwind 4.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "author": "Bas Milius",
@@ -85,7 +85,6 @@
85
85
  "@types/react-dom": "^19.3.0",
86
86
  "i18next": "^26.4.2",
87
87
  "opentype.js": "2.0.0",
88
- "oxc-parser": "^0.151.0",
89
88
  "react": "^19.3.0",
90
89
  "react-dom": "^19.3.0",
91
90
  "react-i18next": "^17.0.15",
@@ -1,16 +1,15 @@
1
1
  import type { ReactElement } from 'react';
2
2
  import { Tooltip } from './Tooltip.tsx';
3
3
 
4
- /*
5
- * Why a row cannot be picked, in a tooltip beside it rather than in the row itself. A reason is a
6
- * sentence, and a sentence in a menu row sets the width of the whole menu. A row with nothing in its
7
- * way is handed through untouched, so it carries no tooltip at all.
8
- */
9
4
  export interface DisabledReasonProps {
10
5
  reason: string | null;
11
6
  children: ReactElement<Record<string, unknown>>;
12
7
  }
13
8
 
9
+ /*
10
+ * Why a row cannot be picked, in a tooltip beside it rather than in the row itself: a sentence in a menu
11
+ * row sets the width of the whole menu. A row with nothing in its way is handed through untouched.
12
+ */
14
13
  export function DisabledReason({ reason, children }: DisabledReasonProps) {
15
14
  if (reason === null) {
16
15
  return children;
package/src/Field.tsx CHANGED
@@ -9,6 +9,8 @@ const ERROR = 'text-xs text-status-error';
9
9
  // Beside the control the label reads at the size of what is typed, on the middle of the control's first line however tall it grows.
10
10
  const LABEL_BESIDE = 'flex h-8 items-center self-start text-sm text-text-muted';
11
11
 
12
+ const isShown = (line: ReactNode): boolean => line !== undefined && line !== null && line !== '';
13
+
12
14
  export type FieldHintProps = useRender.ComponentProps<'p'>;
13
15
 
14
16
  /* The line under a field that says what goes in it. */
@@ -80,8 +82,8 @@ export function Field({ label, hint, error, group = false, orientation = 'vertic
80
82
  const horizontal = orientation === 'horizontal';
81
83
  const id = useId();
82
84
  const hasLabel = label !== undefined;
83
- const hasHint = hint !== undefined && hint !== null && hint !== '';
84
- const hasError = error !== undefined && error !== null && error !== '';
85
+ const hasHint = isShown(hint);
86
+ const hasError = isShown(error);
85
87
  const describedBy = [hasHint ? `${id}-hint` : null, hasError ? `${id}-error` : null].filter((part) => part !== null).join(' ') || undefined;
86
88
  return (
87
89
  <div