@adecore/ui 0.18.4 → 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,35 @@
1
+ export declare const TAB_TOP_INSET = 4;
2
+ export declare const TAB_RADIUS = 6;
3
+ export declare const BODY_RADIUS = 4;
4
+ export declare const DROP_STROKE = 2;
5
+ export interface DropPreviewShape {
6
+ x: number;
7
+ y: number;
8
+ width: number;
9
+ height: number;
10
+ barHeight: number;
11
+ tabLeft: number;
12
+ tabWidth: number;
13
+ tabTop: number;
14
+ radius: number;
15
+ }
16
+ export interface PreviewRect {
17
+ x: number;
18
+ y: number;
19
+ width: number;
20
+ height: number;
21
+ }
22
+ export declare function rectPreview({ x, y, width, height }: PreviewRect): DropPreviewShape;
23
+ export declare function tabPreview(drop: TabDrop, origin: {
24
+ x: number;
25
+ y: number;
26
+ }): DropPreviewShape;
27
+ export declare function dropPreviewPath({ x, y, width, height, barHeight, tabLeft, tabWidth, tabTop, radius }: DropPreviewShape, inset?: number): string;
28
+ export interface TabDrop {
29
+ gap: number;
30
+ left: number;
31
+ tabWidth: number;
32
+ width: number;
33
+ height: number;
34
+ barHeight: number;
35
+ }
@@ -0,0 +1,72 @@
1
+ /* The gap above the indicator's tab, so it reads as a tab standing in the bar. */
2
+ export const TAB_TOP_INSET = 4;
3
+ /* The corner of the indicator's tab, and of its body, which is `rounded-sm` like the other drop indicator. */
4
+ export const TAB_RADIUS = 6;
5
+ export const BODY_RADIUS = 4;
6
+ /* The stroke is centered on the path, so the path runs half of it inside the box to keep the outer edge on it. */
7
+ export const DROP_STROKE = 2;
8
+ function coordinate(value) {
9
+ return String(Math.round(value * 100) / 100 + 0);
10
+ }
11
+ /*
12
+ * The preview for a split or a trade: the rectangle as a tab with no height, parked just inside its
13
+ * top left corner so the corner keeps its radius. It is where a tab target grows out of the rectangle.
14
+ */
15
+ export function rectPreview({ x, y, width, height }) {
16
+ return { x, y, width, height, barHeight: 0, tabLeft: x + BODY_RADIUS, tabWidth: 0, tabTop: y, radius: TAB_RADIUS };
17
+ }
18
+ /* The preview for a tab: the tab and the body of the cell it lands in, `origin` being the cell's top left in the grid. */
19
+ export function tabPreview(drop, origin) {
20
+ return {
21
+ x: origin.x,
22
+ y: origin.y,
23
+ width: drop.width,
24
+ height: drop.height,
25
+ barHeight: drop.barHeight,
26
+ tabLeft: origin.x + drop.left,
27
+ tabWidth: drop.tabWidth,
28
+ tabTop: origin.y + TAB_TOP_INSET,
29
+ radius: TAB_RADIUS
30
+ };
31
+ }
32
+ /*
33
+ * The outline of a tab grown into the body under it, as one closed path so no line runs between the
34
+ * two. The commands are the same whatever the numbers are (an arc of radius 0 is a straight line), so
35
+ * the browser can move one outline into another with a transition on `d`. `inset` is how far the
36
+ * path runs inside the box, which is half the stroke, so the outer edge of the line is on the box.
37
+ */
38
+ export function dropPreviewPath({ x, y, width, height, barHeight, tabLeft, tabWidth, tabTop, radius }, inset = DROP_STROKE / 2) {
39
+ const left = inset;
40
+ const right = width - inset;
41
+ const bottom = height - inset;
42
+ const top = barHeight + inset;
43
+ const tabStart = Math.max(left, Math.min(tabLeft - x + inset, right));
44
+ const tabEnd = Math.max(tabStart, Math.min(tabLeft - x + tabWidth - inset, right));
45
+ const tabY = Math.min(tabTop - y + inset, top);
46
+ const tabCorner = Math.max(0, Math.min(radius - inset, (tabEnd - tabStart) / 2, top - tabY));
47
+ const bodyCorner = Math.max(0, BODY_RADIUS - inset);
48
+ // A tab flush with a side of the box continues that side, so the body has no corner there.
49
+ const cornerLeft = Math.min(bodyCorner, tabStart - left);
50
+ const cornerRight = Math.min(bodyCorner, right - tabEnd);
51
+ const cornerBottom = Math.max(0, Math.min(bodyCorner, (right - left) / 2, (bottom - top) / 2));
52
+ const at = (px, py) => `${coordinate(x + px)},${coordinate(y + py)}`;
53
+ const arc = (corner, px, py) => `A${coordinate(corner)},${coordinate(corner)} 0 0 1 ${at(px, py)}`;
54
+ return [
55
+ `M${at(left + cornerLeft, top)}`,
56
+ `H${coordinate(x + tabStart)}`,
57
+ `V${coordinate(y + tabY + tabCorner)}`,
58
+ arc(tabCorner, tabStart + tabCorner, tabY),
59
+ `H${coordinate(x + tabEnd - tabCorner)}`,
60
+ arc(tabCorner, tabEnd, tabY + tabCorner),
61
+ `V${coordinate(y + top)}`,
62
+ `H${coordinate(x + right - cornerRight)}`,
63
+ arc(cornerRight, right, top + cornerRight),
64
+ `V${coordinate(y + bottom - cornerBottom)}`,
65
+ arc(cornerBottom, right - cornerBottom, bottom),
66
+ `H${coordinate(x + left + cornerBottom)}`,
67
+ arc(cornerBottom, left, bottom - cornerBottom),
68
+ `V${coordinate(y + top + cornerLeft)}`,
69
+ arc(cornerLeft, left + cornerLeft, top),
70
+ 'Z'
71
+ ].join(' ');
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/ui",
3
- "version": "0.18.4",
3
+ "version": "0.20.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",
@@ -1,4 +1,4 @@
1
- import type { PointerEvent as ReactPointerEvent, Ref } from 'react';
1
+ import type { CSSProperties, PointerEvent as ReactPointerEvent, Ref } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import type { ColumnEdge } from './useColumnResize.ts';
4
4
 
@@ -7,6 +7,7 @@ export interface ColumnResizeHandleProps {
7
7
  from: ColumnEdge;
8
8
  onPointerDown(event: ReactPointerEvent<HTMLElement>): void;
9
9
  className?: string;
10
+ style?: CSSProperties;
10
11
  ref?: Ref<HTMLDivElement>;
11
12
  }
12
13
 
@@ -18,6 +19,6 @@ const PLACEMENT: Record<ColumnEdge, string> = {
18
19
  };
19
20
 
20
21
  /* An invisible strip over the free edge of a column, so the column needs `relative` and draws its own border. */
21
- export function ColumnResizeHandle({ from, onPointerDown, className, ref }: ColumnResizeHandleProps) {
22
- return <div ref={ref} className={clsx('absolute z-10', PLACEMENT[from], className)} onPointerDown={onPointerDown} />;
22
+ export function ColumnResizeHandle({ from, onPointerDown, className, style, ref }: ColumnResizeHandleProps) {
23
+ return <div ref={ref} className={clsx('absolute z-10', PLACEMENT[from], className)} style={style} onPointerDown={onPointerDown} />;
23
24
  }
@@ -11,6 +11,8 @@ export interface SlidingColumnProps {
11
11
  open: boolean;
12
12
  /* Already clamped by the caller, which knows what has to stay beside it (`clampColumnSize`). */
13
13
  width: number;
14
+ /* Space before the surface, in pixels. Replaces the border and carries the resize handle. */
15
+ gap?: number;
14
16
  bounds: { min: number; max(): number };
15
17
  onWidthChange(width: number): void;
16
18
  /*
@@ -38,7 +40,7 @@ export interface SlidingColumnProps {
38
40
  * moves, and they stay mounted until the slide is over, so a close plays out. The left edge is the
39
41
  * drag handle.
40
42
  */
41
- export function SlidingColumn({ open, width, bounds, onWidthChange, instant = false, body, className, ref, children }: SlidingColumnProps) {
43
+ export function SlidingColumn({ open, width, gap = 0, bounds, onWidthChange, instant = false, body, className, ref, children }: SlidingColumnProps) {
42
44
  /* Closed and done animating. Until then the contents stay mounted, so a close plays out. */
43
45
  const [settled, setSettled] = useState(!open);
44
46
  /* A width that lands without a transition fires no `transitionend`, so the motion it would have
@@ -48,7 +50,14 @@ export function SlidingColumn({ open, width, bounds, onWidthChange, instant = fa
48
50
  }
49
51
  const present = open || !settled;
50
52
  const column = useRef<HTMLElement>(null);
51
- const { startResize } = useColumnResize(column, { ...bounds, size: width, from: 'right', onSize: onWidthChange });
53
+ const spacing = Math.max(0, Math.round(gap));
54
+ const { startResize } = useColumnResize(column, {
55
+ min: bounds.min + spacing,
56
+ max: () => bounds.max() + spacing,
57
+ size: width + spacing,
58
+ from: 'right',
59
+ onSize: (next) => onWidthChange(next - spacing)
60
+ });
52
61
 
53
62
  useEffect(() => {
54
63
  if (open || settled) {
@@ -66,8 +75,9 @@ export function SlidingColumn({ open, width, bounds, onWidthChange, instant = fa
66
75
  ref={mergeRefs(column, ref)}
67
76
  inert={!open}
68
77
  data-instant={instant ? '' : undefined}
78
+ data-gap={spacing > 0 ? '' : undefined}
69
79
  className={clsx('sliding-column flex h-full shrink-0 justify-end overflow-hidden', className)}
70
- style={{ width: open ? width : 0 }}
80
+ style={{ width: open ? width + spacing : 0 }}
71
81
  onTransitionEnd={(event) => {
72
82
  if (event.propertyName === 'width' && event.target === event.currentTarget) {
73
83
  setSettled(!open);
@@ -78,11 +88,18 @@ export function SlidingColumn({ open, width, bounds, onWidthChange, instant = fa
78
88
  <div
79
89
  ref={body?.ref}
80
90
  tabIndex={body ? -1 : undefined}
81
- className="relative flex h-full shrink-0 flex-col border-l border-border bg-surface outline-none"
82
- style={{ width }}
91
+ className={clsx('relative flex h-full shrink-0 flex-col bg-surface outline-none', spacing === 0 && 'border-l border-border')}
92
+ style={{ width, marginLeft: spacing }}
83
93
  onKeyDown={body?.onKeyDown}
84
94
  >
85
- {open && <ColumnResizeHandle from="right" onPointerDown={startResize} />}
95
+ {open && (
96
+ <ColumnResizeHandle
97
+ from="right"
98
+ className="sliding-column-resize"
99
+ style={spacing > 0 ? { left: -spacing, width: spacing } : undefined}
100
+ onPointerDown={startResize}
101
+ />
102
+ )}
86
103
  {children}
87
104
  </div>
88
105
  )}
package/src/index.ts CHANGED
@@ -122,3 +122,22 @@ export type {
122
122
  TreeControlProps,
123
123
  TreeCheckboxProps
124
124
  } from './tree/parts.tsx';
125
+
126
+ export { Workspace, type WorkspaceProps } from './workspace/Workspace.tsx';
127
+ export type { WorkspaceLayout } from './workspace/context.ts';
128
+ export { DocumentTab, TabStrip, type DocumentTabProps, type TabStripProps, type TabStripItem } from './workspace/TabStrip.tsx';
129
+ export { SplitView, type SplitViewProps, type SplitViewInfo, type SplitViewBounds, type SplitDropTarget, type SplitDrag } from './workspace/SplitView.tsx';
130
+ export {
131
+ createSplitLayout,
132
+ normalizeSplitLayout,
133
+ updateSplitLayout,
134
+ splitPanes,
135
+ type SplitAxis,
136
+ type SplitSide,
137
+ type SplitPane,
138
+ type SplitBranch,
139
+ type SplitNode,
140
+ type SplitLayout,
141
+ type SplitCommand
142
+ } from './workspace/model.ts';
143
+ export type { SplitRect, SplitEdges, SplitMinimum } from './workspace/geometry.ts';
@@ -64,5 +64,15 @@
64
64
  "label": "Files",
65
65
  "expand": "Expand",
66
66
  "collapse": "Collapse"
67
+ },
68
+ "workspace": {
69
+ "tabs": "Open views",
70
+ "unsaved": "Unsaved changes",
71
+ "closeTab": "Close {{name}}",
72
+ "resize": "Resize panels",
73
+ "pane": "Panel",
74
+ "movePane": "Move panel",
75
+ "maximize": "Maximize panel",
76
+ "restore": "Restore panels"
67
77
  }
68
78
  }
@@ -64,5 +64,15 @@
64
64
  "label": "Bestanden",
65
65
  "expand": "Uitvouwen",
66
66
  "collapse": "Invouwen"
67
+ },
68
+ "workspace": {
69
+ "tabs": "Open views",
70
+ "unsaved": "Niet-opgeslagen wijzigingen",
71
+ "closeTab": "Sluit {{name}}",
72
+ "resize": "Panelen vergroten of verkleinen",
73
+ "pane": "Paneel",
74
+ "movePane": "Paneel verplaatsen",
75
+ "maximize": "Paneel maximaliseren",
76
+ "restore": "Panelen herstellen"
67
77
  }
68
78
  }
package/src/theme.css CHANGED
@@ -26,6 +26,8 @@
26
26
  own clips its background to the padding box (see `@layer base`), or the border would blend
27
27
  with that surface and never with the ground behind it. */
28
28
  --border: rgb(0 0 0 / 0.11);
29
+ /* Opaque, so a gap has the same color on every surface. */
30
+ --separator: oklch(0.922 0.003 286.347);
29
31
  --border-strong: rgb(0 0 0 / 0.2);
30
32
  /* A hairline that stays out of the way: on a sunken field a full `--border` draws a box. */
31
33
  --border-soft: rgb(0 0 0 / 0.06);
@@ -94,6 +96,7 @@
94
96
  --surface-active: #28282e;
95
97
  /* White at a low alpha, which is what gives a dark surface its lifted edge. */
96
98
  --border: rgb(255 255 255 / 0.07);
99
+ --separator: oklch(0.236 0.006 285.956);
97
100
  --border-strong: rgb(255 255 255 / 0.13);
98
101
  --border-soft: rgb(255 255 255 / 0.03);
99
102
  --text: #ececf1;
@@ -205,6 +208,7 @@
205
208
  --color-surface-hover: var(--surface-hover);
206
209
  --color-surface-active: var(--surface-active);
207
210
  --color-border: var(--border);
211
+ --color-separator: var(--separator);
208
212
  --color-border-strong: var(--border-strong);
209
213
  --color-border-soft: var(--border-soft);
210
214
  --color-text: var(--text);
@@ -567,6 +571,17 @@
567
571
 
568
572
  /* A `SlidingColumn` opening and closing. A drag or a width restored on load lands at once. */
569
573
  .sliding-column { transition: width 200ms cubic-bezier(0, 0, 0.2, 1); }
574
+ .sliding-column[data-gap] { background: var(--separator); }
575
+ .sliding-column-resize { touch-action: none; }
576
+ .sliding-column[data-gap] .sliding-column-resize::after {
577
+ content: '';
578
+ position: absolute;
579
+ inset-block: 0;
580
+ left: calc(50% - 1px);
581
+ width: 2px;
582
+ }
583
+ .sliding-column[data-gap] .sliding-column-resize:hover::after,
584
+ .sliding-column[data-gap][data-resizing] .sliding-column-resize::after { background: var(--accent); }
570
585
  .sliding-column[data-resizing],
571
586
  .sliding-column[data-instant] { transition: none; }
572
587
  }
@@ -706,3 +721,169 @@
706
721
  }
707
722
  }
708
723
  }
724
+
725
+ @layer components {
726
+ .ade-workspace {
727
+ display: flex;
728
+ width: 100%;
729
+ height: 100%;
730
+ min-width: 0;
731
+ min-height: 0;
732
+ overflow: hidden;
733
+ background: var(--separator);
734
+ --border: var(--separator);
735
+ }
736
+ .ade-workspace-center {
737
+ display: flex;
738
+ flex: 1;
739
+ min-width: 0;
740
+ min-height: 0;
741
+ flex-direction: column;
742
+ }
743
+ .ade-workspace-sidebar, .ade-workspace-side-panel {
744
+ flex: none;
745
+ min-width: 0;
746
+ min-height: 0;
747
+ overflow: hidden;
748
+ background: var(--surface);
749
+ }
750
+ .ade-workspace-sidebar { border-right: 1px solid var(--separator); }
751
+ .ade-workspace-side-panel { border-left: 1px solid var(--separator); }
752
+ .ade-workspace-toolbar {
753
+ flex: none;
754
+ overflow: hidden;
755
+ background: var(--surface);
756
+ border-bottom: 1px solid var(--separator);
757
+ }
758
+ .ade-workspace-content {
759
+ position: relative;
760
+ flex: 1;
761
+ min-width: 0;
762
+ min-height: 0;
763
+ overflow: hidden;
764
+ background: var(--surface);
765
+ }
766
+ .ade-workspace[data-layout="roomy"] > .ade-workspace-center { gap: 8px; }
767
+ .ade-workspace[data-layout="roomy"] > .ade-workspace-sidebar:not([data-auto]) { margin-right: 8px; }
768
+ .ade-workspace[data-layout="roomy"] > .ade-workspace-side-panel:not([data-auto]) { margin-left: 8px; }
769
+ .ade-workspace-sidebar[data-auto], .ade-workspace-side-panel[data-auto] { display: contents; }
770
+ .ade-workspace[data-layout="roomy"] > .ade-workspace-sidebar,
771
+ .ade-workspace[data-layout="roomy"] > .ade-workspace-side-panel,
772
+ .ade-workspace[data-layout="roomy"] > .ade-workspace-center > .ade-workspace-toolbar { border: 0; }
773
+ .ade-workspace[data-layout="roomy"][data-sidebar] > .ade-workspace-center > .ade-workspace-toolbar { border-bottom-left-radius: 8px; }
774
+ .ade-workspace[data-layout="roomy"][data-side-panel] > .ade-workspace-center > .ade-workspace-toolbar { border-bottom-right-radius: 8px; }
775
+ .ade-split-view {
776
+ position: relative;
777
+ isolation: isolate;
778
+ width: 100%;
779
+ height: 100%;
780
+ min-width: 0;
781
+ min-height: 0;
782
+ overflow: hidden;
783
+ background: var(--separator);
784
+ --border: var(--separator);
785
+ }
786
+ .ade-split-pane, .ade-split-content {
787
+ position: absolute;
788
+ min-width: 0;
789
+ min-height: 0;
790
+ overflow: hidden;
791
+ background: var(--surface);
792
+ }
793
+ .ade-split-pane[hidden], .ade-split-content[hidden] { display: none; }
794
+ .ade-split-header {
795
+ display: flex;
796
+ align-items: center;
797
+ min-width: 0;
798
+ border-bottom: 1px solid var(--separator);
799
+ }
800
+ .ade-split-content { outline-offset: -2px; }
801
+ .ade-split-view[data-dragging] > .ade-split-content { pointer-events: none; }
802
+ .ade-split-divider {
803
+ position: absolute;
804
+ z-index: 3;
805
+ touch-action: none;
806
+ outline-offset: 2px;
807
+ }
808
+ /* The 2px line also marks keyboard focus. */
809
+ .ade-split-divider:focus-visible { outline: none; }
810
+ .ade-split-divider[aria-orientation="vertical"] { cursor: col-resize; }
811
+ .ade-split-divider[aria-orientation="horizontal"] { cursor: row-resize; }
812
+ .ade-split-divider > span {
813
+ position: absolute;
814
+ inset: 0;
815
+ }
816
+ .ade-split-divider[aria-orientation="vertical"] > span { left: -3px; right: -3px; }
817
+ .ade-split-divider[aria-orientation="horizontal"] > span { top: -3px; bottom: -3px; }
818
+ .ade-split-view[data-layout="roomy"] > .ade-split-divider > span { inset: 0; }
819
+ .ade-split-divider::after { content: ""; position: absolute; pointer-events: none; }
820
+ .ade-split-divider[aria-orientation="vertical"]::after { inset-block: 0; left: -1px; width: 2px; }
821
+ .ade-split-divider[aria-orientation="horizontal"]::after { inset-inline: 0; top: -1px; height: 2px; }
822
+ .ade-split-view[data-layout="roomy"] > .ade-split-divider[aria-orientation="vertical"]::after { left: 3px; }
823
+ .ade-split-view[data-layout="roomy"] > .ade-split-divider[aria-orientation="horizontal"]::after { top: 3px; }
824
+ .ade-workspace[data-layout="roomy"][data-toolbar][data-sidebar] > .ade-workspace-center > .ade-workspace-content { border-top-left-radius: 8px; }
825
+ .ade-workspace[data-layout="roomy"][data-toolbar][data-side-panel] > .ade-workspace-center > .ade-workspace-content { border-top-right-radius: 8px; }
826
+ .ade-split-divider:is(:hover, :focus-visible, [data-resizing])::after { background: var(--accent); }
827
+ .ade-split-preview {
828
+ position: absolute;
829
+ inset: 0;
830
+ z-index: 4;
831
+ pointer-events: none;
832
+ overflow: visible;
833
+ }
834
+ .ade-split-preview path {
835
+ fill: color-mix(in srgb, var(--accent) 8%, transparent);
836
+ stroke: var(--accent);
837
+ stroke-width: 2px;
838
+ transition: d 150ms ease-out;
839
+ }
840
+ .ade-tab-strip {
841
+ position: relative;
842
+ display: flex;
843
+ flex: 1;
844
+ height: 100%;
845
+ min-width: 0;
846
+ align-items: stretch;
847
+ overflow-x: auto;
848
+ scrollbar-width: none;
849
+ }
850
+ .ade-tab-strip::-webkit-scrollbar { display: none; }
851
+ .ade-document-tab {
852
+ position: relative;
853
+ display: inline-flex;
854
+ flex: none;
855
+ align-items: center;
856
+ gap: 4px;
857
+ padding: 0 8px 0 12px;
858
+ color: var(--text-muted);
859
+ }
860
+ .ade-document-tab:hover { color: var(--text); background: var(--surface-raised); }
861
+ .ade-document-tab[data-active] { color: var(--text); }
862
+ .ade-document-tab[data-active]::after {
863
+ content: "";
864
+ position: absolute;
865
+ inset-inline: 0;
866
+ bottom: 0;
867
+ height: 2px;
868
+ background: var(--text-muted);
869
+ }
870
+ .ade-tab-strip[data-focused] .ade-document-tab[data-active]::after { background: var(--accent); }
871
+ .ade-document-tab-open {
872
+ display: inline-flex;
873
+ height: 100%;
874
+ min-width: 0;
875
+ max-width: 160px;
876
+ align-items: center;
877
+ gap: 6px;
878
+ font-size: var(--text-sm);
879
+ color: inherit;
880
+ outline-offset: -2px;
881
+ white-space: nowrap;
882
+ }
883
+ .ade-document-tab-close { opacity: 0; }
884
+ .ade-document-tab:is(:hover, :focus-within, [data-active]) .ade-document-tab-close { opacity: 1; }
885
+ .ade-document-tab[data-detail] .ade-document-tab-open { max-width: 200px; }
886
+ .ade-tab-unsaved { width: 6px; height: 6px; flex: none; border-radius: 999px; background: var(--text-muted); }
887
+ .ade-tab-insertion { position: absolute; top: 4px; bottom: 4px; width: 2px; pointer-events: none; background: var(--accent); }
888
+ @media (prefers-reduced-motion: reduce) { .ade-split-preview path { transition: none; } }
889
+ }
@@ -28,20 +28,6 @@ export const clampColumnSize = (options: Pick<ColumnResizeOptions, 'min' | 'max'
28
28
  /* Where the pointer sits on the axis this edge belongs to. */
29
29
  const along = (from: ColumnEdge, event: { clientX: number; clientY: number }): number => (from === 'left' || from === 'right' ? event.clientX : event.clientY);
30
30
 
31
- /* The edge the column is pinned to, in page coordinates, or the window's own when there is no box yet. */
32
- const anchorOf = (from: ColumnEdge, rect: DOMRect | undefined): number => {
33
- switch (from) {
34
- case 'left':
35
- return rect?.left ?? 0;
36
- case 'right':
37
- return rect?.right ?? window.innerWidth;
38
- case 'top':
39
- return rect?.top ?? 0;
40
- case 'bottom':
41
- return rect?.bottom ?? window.innerHeight;
42
- }
43
- };
44
-
45
31
  /*
46
32
  * One resizable column or row: a handle that drags the size its owner keeps. The handle takes the
47
33
  * pointer capture, so the drag survives leaving the few pixels it is wide, and `[data-resizing]` on
@@ -52,23 +38,31 @@ export const useColumnResize = (ref: RefObject<HTMLElement | null>, options: Col
52
38
  event.preventDefault();
53
39
  const handle = event.currentTarget;
54
40
  const column = ref.current;
55
- const anchor = anchorOf(options.from, column?.getBoundingClientRect());
41
+ const rect = column?.getBoundingClientRect();
42
+ const size = (options.from === 'left' || options.from === 'right' ? rect?.width : rect?.height) ?? options.size;
43
+ const start = along(options.from, event);
56
44
  /* The far edge grows as the pointer comes towards it; the near edge grows as it goes away. */
57
45
  const growsTowardsAnchor = options.from === 'right' || options.from === 'bottom';
58
46
  column?.setAttribute('data-resizing', 'true');
59
47
  const onMove = (move: PointerEvent): void => {
60
- const reach = along(options.from, move);
61
- options.onSize(clampColumnSize(options, growsTowardsAnchor ? anchor - reach : reach - anchor));
48
+ const delta = along(options.from, move) - start;
49
+ options.onSize(clampColumnSize(options, size + (growsTowardsAnchor ? -delta : delta)));
62
50
  };
63
51
  const onUp = (): void => {
64
52
  handle.removeEventListener('pointermove', onMove);
65
53
  handle.removeEventListener('pointerup', onUp);
66
- handle.releasePointerCapture(event.pointerId);
54
+ handle.removeEventListener('pointercancel', onUp);
55
+ handle.removeEventListener('lostpointercapture', onUp);
56
+ if (handle.hasPointerCapture(event.pointerId)) {
57
+ handle.releasePointerCapture(event.pointerId);
58
+ }
67
59
  column?.removeAttribute('data-resizing');
68
60
  };
69
61
  handle.setPointerCapture(event.pointerId);
70
62
  handle.addEventListener('pointermove', onMove);
71
63
  handle.addEventListener('pointerup', onUp);
64
+ handle.addEventListener('pointercancel', onUp);
65
+ handle.addEventListener('lostpointercapture', onUp);
72
66
  };
73
67
 
74
68
  return { startResize };