@adecore/ui 0.16.0 → 0.17.0-beta.2

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 (54) hide show
  1. package/LICENSE +97 -8
  2. package/README.md +1 -1
  3. package/dist/Select.js +2 -1
  4. package/dist/file-tree/helpers.d.ts +42 -0
  5. package/dist/file-tree/helpers.js +167 -0
  6. package/dist/file-tree/index.parts.d.ts +3 -0
  7. package/dist/file-tree/index.parts.js +3 -0
  8. package/dist/file-tree/parts.d.ts +20 -0
  9. package/dist/file-tree/parts.js +150 -0
  10. package/dist/file-tree/shift.d.ts +15 -0
  11. package/dist/file-tree/shift.js +34 -0
  12. package/dist/file-tree/style.d.ts +1 -0
  13. package/dist/file-tree/style.js +24 -0
  14. package/dist/file-tree/use-file-tree-shift.d.ts +6 -0
  15. package/dist/file-tree/use-file-tree-shift.js +142 -0
  16. package/dist/file-tree/use-file-tree.d.ts +5 -0
  17. package/dist/file-tree/use-file-tree.js +26 -0
  18. package/dist/file-tree/use-slots.d.ts +7 -0
  19. package/dist/file-tree/use-slots.js +171 -0
  20. package/dist/index.d.ts +7 -0
  21. package/dist/index.js +3 -0
  22. package/dist/locales/en.json +5 -0
  23. package/dist/locales/nl.json +5 -0
  24. package/dist/menu/parts.js +2 -1
  25. package/dist/popover/parts.js +2 -1
  26. package/dist/popup-layer.d.ts +6 -0
  27. package/dist/popup-layer.js +30 -0
  28. package/dist/theme.css +104 -1
  29. package/dist/tree/index.parts.d.ts +1 -0
  30. package/dist/tree/index.parts.js +1 -0
  31. package/dist/tree/parts.d.ts +27 -0
  32. package/dist/tree/parts.js +113 -0
  33. package/dist/tree/style.d.ts +2 -0
  34. package/dist/tree/style.js +29 -0
  35. package/package.json +6 -4
  36. package/src/Select.tsx +9 -1
  37. package/src/file-tree/helpers.ts +203 -0
  38. package/src/file-tree/index.parts.ts +25 -0
  39. package/src/file-tree/parts.tsx +206 -0
  40. package/src/file-tree/shift.ts +48 -0
  41. package/src/file-tree/style.ts +25 -0
  42. package/src/file-tree/use-file-tree-shift.tsx +169 -0
  43. package/src/file-tree/use-file-tree.ts +31 -0
  44. package/src/file-tree/use-slots.ts +191 -0
  45. package/src/index.ts +17 -0
  46. package/src/locales/en.json +5 -0
  47. package/src/locales/nl.json +5 -0
  48. package/src/menu/parts.tsx +2 -0
  49. package/src/popover/parts.tsx +2 -0
  50. package/src/popup-layer.ts +34 -0
  51. package/src/theme.css +104 -1
  52. package/src/tree/index.parts.ts +10 -0
  53. package/src/tree/parts.tsx +170 -0
  54. package/src/tree/style.ts +30 -0
@@ -0,0 +1,25 @@
1
+ import { treeRowStyle } from '../tree/style.ts';
2
+
3
+ // beta.6 splits the extension into nested grids; flatten them to truncate the end of the entire name.
4
+ export const FILE_TREE_CSS = `
5
+ ${treeRowStyle('[data-type="item"]', 'data-item-selected')}
6
+ [data-type="item"] { overflow: clip; padding-inline: 4px 8px; --trees-item-row-gap: var(--tree-row-gap, 6px); }
7
+ [data-type="item"]::after {
8
+ content: "";
9
+ flex: none;
10
+ order: -100;
11
+ margin-inline: calc(var(--adecore-tree-shift, 0px) * -1) calc(var(--trees-item-row-gap) * -1);
12
+ }
13
+ [data-icon-name="file-tree-icon-chevron"] { width: 12px; height: 12px; }
14
+ [data-item-section="spacing"] { padding: 0; margin-inline-end: -6px; }
15
+ [data-item-section="spacing-item"] { position: relative; flex: none; width: 16px; margin: 0; border: 0; background: transparent; opacity: 1; transform: none; transition: none; }
16
+ [data-item-section="spacing-item"] + [data-item-section="spacing-item"] { margin: 0; }
17
+ [data-item-section="spacing-item"]::before { content: ""; position: absolute; inset-block: 0; inset-inline-start: 7px; width: 1px; background: var(--border); }
18
+ [data-item-section="icon"] { width: 16px; margin-inline-start: var(--adecore-tree-control-space, 0px); }
19
+ [data-item-section="content"] { white-space: nowrap; max-width: none; flex: 1 1 auto; margin-inline-end: var(--adecore-tree-decoration-space, 0px); }
20
+ [data-item-section="content"] :where([data-truncate-group-container], [data-truncate-group-container] div, [data-truncate-container], [data-truncate-container] div) { display: inline; }
21
+ [data-item-section="content"] [data-truncate-content] { direction: ltr; }
22
+ [data-item-section="content"] :where([data-truncate-content="overflow"], [data-truncate-marker-cell], [data-truncate-fill]) { display: none; }
23
+ [data-tree-port] { position: absolute; z-index: 4; display: flex; align-items: center; height: 25px; pointer-events: none; }
24
+ [data-tree-port] > slot { pointer-events: auto; }
25
+ `;
@@ -0,0 +1,169 @@
1
+ import { useEffect, useState, type ReactElement } from 'react';
2
+ import type { FileTree } from '@pierre/trees';
3
+ import { clampShift, maxShift, SHIFT_PROPERTY, shiftNeed, shiftThumb, sidewaysDelta } from './shift.ts';
4
+
5
+ const ROWS = '[data-type="item"]:not([data-item-parked="true"])';
6
+
7
+ const HIDE_AFTER = 900;
8
+
9
+ const NEAR_BOTTOM = 16;
10
+
11
+ function boxesOf(element: Element): Element[] {
12
+ return element.getClientRects().length > 0 ? [element] : [...element.children].flatMap((child) => boxesOf(child));
13
+ }
14
+
15
+ function limitOf(row: HTMLElement, content: Element, gap: number): number {
16
+ const start = content.getBoundingClientRect().left;
17
+ let limit = Number.POSITIVE_INFINITY;
18
+ for (const part of [...row.children].filter((child) => child !== content).flatMap((child) => boxesOf(child))) {
19
+ const rect = part.getBoundingClientRect();
20
+ if (rect.width === 0 || rect.left < start) {
21
+ continue;
22
+ }
23
+ if (part.childNodes.length === 0 && Number.parseFloat(getComputedStyle(part).flexGrow) > 0) {
24
+ continue;
25
+ }
26
+ limit = Math.min(limit, rect.left - gap);
27
+ }
28
+ if (limit !== Number.POSITIVE_INFINITY) {
29
+ return limit - (Number.parseFloat(getComputedStyle(content).marginInlineEnd) || 0);
30
+ }
31
+ return (
32
+ row.getBoundingClientRect().right -
33
+ Number.parseFloat(getComputedStyle(row).paddingInlineEnd) -
34
+ (Number.parseFloat(getComputedStyle(content).marginInlineEnd) || 0)
35
+ );
36
+ }
37
+
38
+ function needOf(row: HTMLElement, shift: number, gap: number, range: Range): number | null {
39
+ const content = row.querySelector(':scope > [data-item-section="content"]');
40
+ if (content === null || row.getBoundingClientRect().width === 0) {
41
+ return null;
42
+ }
43
+ range.selectNodeContents(content);
44
+ return shiftNeed(range.getBoundingClientRect().right, limitOf(row, content, gap), shift);
45
+ }
46
+
47
+ export function useFileTreeShift(model: FileTree, resetKey?: string): { attach(node: HTMLElement | null): void; bar: ReactElement } {
48
+ const [frame, setFrame] = useState<HTMLElement | null>(null);
49
+ const [track, setTrack] = useState<HTMLDivElement | null>(null);
50
+ const [thumb, setThumb] = useState<HTMLDivElement | null>(null);
51
+
52
+ useEffect(() => {
53
+ if (frame === null || track === null || thumb === null) {
54
+ return;
55
+ }
56
+ const range = document.createRange();
57
+ let root: ShadowRoot | null = null;
58
+ let exact = 0;
59
+ let drawn = 0;
60
+ let max = 0;
61
+ let pending = 0;
62
+ let hideTimer = 0;
63
+ let scrolling = false;
64
+ let near = false;
65
+
66
+ const draw = (): void => {
67
+ const shift = Math.round(exact);
68
+ if (shift !== drawn) {
69
+ drawn = shift;
70
+ frame.style.setProperty(SHIFT_PROPERTY, `${shift}px`);
71
+ }
72
+ const { left, width } = shiftThumb(shift, max, track.clientWidth, frame.clientWidth);
73
+ thumb.style.left = `${left}px`;
74
+ thumb.style.width = `${width}px`;
75
+ track.toggleAttribute('data-visible', max > 0 && (scrolling || near));
76
+ };
77
+
78
+ const measure = (): void => {
79
+ pending = 0;
80
+ if (root === null) {
81
+ root = model.getFileTreeContainer()?.shadowRoot ?? null;
82
+ if (root === null) {
83
+ return;
84
+ }
85
+ rows.observe(root, { childList: true, subtree: true, characterData: true });
86
+ }
87
+ const drawnRows = [...root.querySelectorAll<HTMLElement>(ROWS)];
88
+ const gap = drawnRows.length === 0 ? 0 : Number.parseFloat(getComputedStyle(drawnRows[0]!).columnGap) || 0;
89
+ max = maxShift(drawnRows.map((row) => needOf(row, drawn, gap, range)).filter((need) => need !== null));
90
+ exact = clampShift(exact, max);
91
+ draw();
92
+ };
93
+
94
+ const schedule = (): void => {
95
+ if (pending === 0) {
96
+ pending = requestAnimationFrame(measure);
97
+ }
98
+ };
99
+
100
+ const rows = new MutationObserver(schedule);
101
+ const mounts = new MutationObserver(schedule);
102
+ mounts.observe(frame, { childList: true, subtree: true });
103
+ const sizes = new ResizeObserver(schedule);
104
+ sizes.observe(frame);
105
+
106
+ const onWheel = (event: WheelEvent): void => {
107
+ const delta = sidewaysDelta(event, frame.clientWidth);
108
+ if (delta === 0 || max === 0) {
109
+ return;
110
+ }
111
+ scrolling = true;
112
+ window.clearTimeout(hideTimer);
113
+ hideTimer = window.setTimeout(() => {
114
+ scrolling = false;
115
+ draw();
116
+ }, HIDE_AFTER);
117
+ const next = clampShift(exact + delta, max);
118
+ if (next !== exact) {
119
+ event.preventDefault();
120
+ exact = next;
121
+ }
122
+ draw();
123
+ };
124
+
125
+ const onPointerMove = (event: PointerEvent): void => {
126
+ const bottom = track.getBoundingClientRect().bottom;
127
+ const isNear = event.clientY >= bottom - NEAR_BOTTOM;
128
+ if (isNear !== near) {
129
+ near = isNear;
130
+ draw();
131
+ }
132
+ };
133
+
134
+ const onPointerLeave = (): void => {
135
+ if (near) {
136
+ near = false;
137
+ draw();
138
+ }
139
+ };
140
+
141
+ frame.addEventListener('wheel', onWheel, { passive: false });
142
+ frame.addEventListener('pointermove', onPointerMove);
143
+ frame.addEventListener('pointerleave', onPointerLeave);
144
+ schedule();
145
+
146
+ return () => {
147
+ frame.removeEventListener('wheel', onWheel);
148
+ frame.removeEventListener('pointermove', onPointerMove);
149
+ frame.removeEventListener('pointerleave', onPointerLeave);
150
+ rows.disconnect();
151
+ mounts.disconnect();
152
+ sizes.disconnect();
153
+ cancelAnimationFrame(pending);
154
+ window.clearTimeout(hideTimer);
155
+ frame.style.removeProperty(SHIFT_PROPERTY);
156
+ track.removeAttribute('data-visible');
157
+ };
158
+ }, [frame, track, thumb, model, resetKey]);
159
+
160
+ const bar = (
161
+ <div aria-hidden className="adecore-tree-shift">
162
+ <div ref={setTrack} className="adecore-tree-shift-track">
163
+ <div ref={setThumb} className="adecore-tree-shift-thumb" />
164
+ </div>
165
+ </div>
166
+ );
167
+
168
+ return { attach: setFrame, bar };
169
+ }
@@ -0,0 +1,31 @@
1
+ import type { FileTreeOptions as PierreOptions } from '@pierre/trees';
2
+ import { useFileTree as usePierreFileTree } from '@pierre/trees/react';
3
+ import { useLayoutEffect, useRef } from 'react';
4
+ import { FILE_TREE_ICONS } from '../file-icon.ts';
5
+ import { TREE_ROW_HEIGHT } from '../tree/style.ts';
6
+ import { compareRows } from './helpers.ts';
7
+ import { FILE_TREE_CSS } from './style.ts';
8
+
9
+ type WithoutSizing<Options> = Options extends unknown ? Omit<Options, 'itemHeight' | 'density'> : never;
10
+ export type FileTreeOptions = WithoutSizing<PierreOptions>;
11
+
12
+ // Model options are initial; paths and status can subsequently change through the model's mutation methods.
13
+ export function useFileTree(options: FileTreeOptions) {
14
+ const latest = useRef(options.onSelectionChange);
15
+ useLayoutEffect(() => {
16
+ latest.current = options.onSelectionChange;
17
+ }, [options.onSelectionChange]);
18
+ return usePierreFileTree({
19
+ composition: { contextMenu: { enabled: false } },
20
+ icons: FILE_TREE_ICONS,
21
+ initialExpansion: 'closed',
22
+ search: false,
23
+ stickyFolders: false,
24
+ sort: compareRows,
25
+ ...options,
26
+ density: 'compact',
27
+ itemHeight: TREE_ROW_HEIGHT,
28
+ onSelectionChange: (paths) => latest.current?.(paths),
29
+ unsafeCSS: `${FILE_TREE_CSS}\n${options.unsafeCSS ?? ''}`
30
+ });
31
+ }
@@ -0,0 +1,191 @@
1
+ import { useEffect, useId, useState } from 'react';
2
+ import type { FileTree, FileTreeVisibleRow } from '@pierre/trees';
3
+ import { pathOfRow, visibleRows } from './helpers.ts';
4
+ import { FILE_TREE_CSS } from './style.ts';
5
+
6
+ export interface RowSlot {
7
+ row: FileTreeVisibleRow;
8
+ control: HTMLElement;
9
+ decoration: HTMLElement;
10
+ }
11
+
12
+ interface Port {
13
+ rowElement: HTMLElement;
14
+ control: HTMLElement;
15
+ decoration: HTMLElement;
16
+ lightControl: HTMLElement;
17
+ lightDecoration: HTMLElement;
18
+ }
19
+
20
+ // beta.6 owns button rows. Sibling slots keep real controls outside those buttons and in the app's stylesheet.
21
+ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, label: string) {
22
+ const prefix = useId().replaceAll(':', '');
23
+ const [slots, setSlots] = useState<RowSlot[]>([]);
24
+
25
+ useEffect(() => {
26
+ if (frame === null) {
27
+ return;
28
+ }
29
+ const ports = new Map<HTMLElement, Port>();
30
+ let shadow: ShadowRoot | null = null;
31
+ let style: HTMLStyleElement | null = null;
32
+ let serial = 0;
33
+ let disposed = false;
34
+ let pending = false;
35
+ let snapshot: string | null = null;
36
+
37
+ const position = (): void => {
38
+ for (const port of ports.values()) {
39
+ const row = port.rowElement;
40
+ const content = row.querySelector<HTMLElement>('[data-item-section="content"]');
41
+ const icon = row.querySelector<HTMLElement>('[data-item-section="icon"]');
42
+ const control = port.lightControl;
43
+ const decoration = port.lightDecoration;
44
+ const controlSpace = control.childNodes.length > 0 ? Math.ceil(control.getBoundingClientRect().width) + 6 : 0;
45
+ const decorationSpace = decoration.childNodes.length > 0 ? Math.ceil(decoration.getBoundingClientRect().width) + 6 : 0;
46
+ row.style.setProperty('--adecore-tree-control-space', `${controlSpace}px`);
47
+ row.style.setProperty('--adecore-tree-decoration-space', `${decorationSpace}px`);
48
+ port.control.style.top = `${row.offsetTop}px`;
49
+ port.control.style.left = `${row.offsetLeft + (icon?.offsetLeft ?? 4) - controlSpace}px`;
50
+ port.decoration.style.top = `${row.offsetTop}px`;
51
+ port.decoration.style.left = `${row.offsetLeft + (content === null ? row.offsetWidth - decorationSpace - 2 : content.offsetLeft + content.offsetWidth + 6)}px`;
52
+ }
53
+ };
54
+
55
+ const resize = new ResizeObserver(position);
56
+ const scan = (): void => {
57
+ pending = false;
58
+ if (disposed) {
59
+ return;
60
+ }
61
+ const current = model.getFileTreeContainer();
62
+ if (!current?.shadowRoot) {
63
+ return;
64
+ }
65
+ if (shadow !== current.shadowRoot) {
66
+ shadow = current.shadowRoot;
67
+ style = document.createElement('style');
68
+ style.dataset.adecoreTreeStyle = 'true';
69
+ style.textContent = FILE_TREE_CSS;
70
+ shadow.append(style);
71
+ changes.observe(shadow, {
72
+ childList: true,
73
+ subtree: true,
74
+ attributes: true,
75
+ attributeFilter: ['data-item-path', 'aria-selected', 'aria-expanded']
76
+ });
77
+ changes.observe(current, { childList: true, subtree: true });
78
+ resize.observe(current);
79
+ }
80
+ shadow.querySelector('[role="tree"]')?.setAttribute('aria-label', label);
81
+ const rows = visibleRows(model);
82
+ const byPath = new Map(rows.map((row) => [pathOfRow(row), row]));
83
+ const elements = [
84
+ ...shadow.querySelectorAll<HTMLElement>('[data-type="item"]:not([data-item-parked="true"]):not([data-file-tree-sticky-row="true"])')
85
+ ];
86
+ const live = new Set(elements);
87
+ for (const [element, port] of ports) {
88
+ if (!live.has(element)) {
89
+ port.control.remove();
90
+ port.decoration.remove();
91
+ resize.unobserve(port.lightControl);
92
+ resize.unobserve(port.lightDecoration);
93
+ port.lightControl.remove();
94
+ port.lightDecoration.remove();
95
+ ports.delete(element);
96
+ }
97
+ }
98
+ const next: RowSlot[] = [];
99
+ for (const element of elements) {
100
+ const path = element.dataset.itemPath;
101
+ const row = path === undefined ? undefined : byPath.get(path);
102
+ if (row === undefined) {
103
+ continue;
104
+ }
105
+ element.dataset.interactive = 'true';
106
+ element.dataset.joinedStart = String(row.isSelected && rows[row.index - 1]?.isSelected === true);
107
+ element.dataset.joinedEnd = String(row.isSelected && rows[row.index + 1]?.isSelected === true);
108
+ let port = ports.get(element);
109
+ if (port === undefined) {
110
+ const makePort = (kind: string): { container: HTMLElement; target: HTMLElement } => {
111
+ const container = document.createElement('span');
112
+ container.dataset.treePort = kind;
113
+ const slot = document.createElement('slot');
114
+ slot.name = `${prefix}-${kind}-${serial}`;
115
+ container.append(slot);
116
+ element.parentElement?.append(container);
117
+ const target = document.createElement('span');
118
+ target.slot = slot.name;
119
+ target.dataset.treeSlot = kind;
120
+ target.className = kind === 'control' ? 'adecore-tree-control' : 'adecore-tree-decoration';
121
+ // React delegates to this portal target before the event reaches the engine's shadow tree.
122
+ for (const type of ['click', 'dblclick', 'keydown', 'keyup', 'pointerdown', 'dragstart']) {
123
+ target.addEventListener(type, (event) => event.stopPropagation());
124
+ }
125
+ current.append(target);
126
+ resize.observe(target);
127
+ return { container, target };
128
+ };
129
+ serial++;
130
+ const control = makePort('control');
131
+ const decoration = makePort('decoration');
132
+ port = {
133
+ rowElement: element,
134
+ control: control.container,
135
+ decoration: decoration.container,
136
+ lightControl: control.target,
137
+ lightDecoration: decoration.target
138
+ };
139
+ ports.set(element, port);
140
+ }
141
+ if (port.control.dataset.itemPath !== row.path) {
142
+ port.control.dataset.itemPath = row.path;
143
+ }
144
+ if (port.decoration.dataset.itemPath !== row.path) {
145
+ port.decoration.dataset.itemPath = row.path;
146
+ }
147
+ port.lightControl.dataset.itemPath = row.path;
148
+ port.lightDecoration.dataset.itemPath = row.path;
149
+ next.push({ row, control: port.lightControl, decoration: port.lightDecoration });
150
+ }
151
+ const key = next
152
+ .map(({ row, control }) => `${control.slot}:${row.path}:${row.isSelected}:${row.isExpanded}:${row.isFocused}:${row.name}`)
153
+ .join('|');
154
+ if (snapshot !== key) {
155
+ snapshot = key;
156
+ setSlots(next);
157
+ }
158
+ position();
159
+ };
160
+ const schedule = (): void => {
161
+ if (!pending && !disposed) {
162
+ pending = true;
163
+ queueMicrotask(scan);
164
+ }
165
+ };
166
+ const shifts = new MutationObserver(position);
167
+ shifts.observe(frame, { attributes: true, attributeFilter: ['style'] });
168
+ const changes = new MutationObserver(schedule);
169
+ changes.observe(frame, { childList: true, subtree: true });
170
+ const unsubscribe = model.subscribe(schedule);
171
+ schedule();
172
+ return () => {
173
+ disposed = true;
174
+ unsubscribe();
175
+ changes.disconnect();
176
+ shifts.disconnect();
177
+ resize.disconnect();
178
+ style?.remove();
179
+ for (const port of ports.values()) {
180
+ port.rowElement.style.removeProperty('--adecore-tree-control-space');
181
+ port.rowElement.style.removeProperty('--adecore-tree-decoration-space');
182
+ port.control.remove();
183
+ port.decoration.remove();
184
+ port.lightControl.remove();
185
+ port.lightDecoration.remove();
186
+ }
187
+ };
188
+ }, [frame, model, label, prefix]);
189
+
190
+ return slots;
191
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  */
5
5
 
6
6
  export * as ContextMenu from './context-menu/index.parts.ts';
7
+ export * as FileTree from './file-tree/index.parts.ts';
8
+ export * as Tree from './tree/index.parts.ts';
7
9
  export * as Dialog from './dialog/index.parts.ts';
8
10
  export * as KeyValueList from './key-value-list/index.parts.ts';
9
11
  export * as Menu from './menu/index.parts.ts';
@@ -105,3 +107,18 @@ export { useContentSize, type ContentSize } from './useContentSize.ts';
105
107
  export { useMeasuredWidth } from './useMeasuredWidth.ts';
106
108
  export { useNow, useTickingText } from './useNow.ts';
107
109
  export { ZOOM_PRESETS } from './zoom.ts';
110
+
111
+ export { useFileTree, type FileTreeOptions } from './file-tree/use-file-tree.ts';
112
+ export type { FileTreeRootProps } from './file-tree/parts.tsx';
113
+ export type { FileTree as FileTreeModel, FileTreeVisibleRow, FileTreeDragAndDropConfig, FileTreeDropContext, FileTreeDropResult } from '@pierre/trees';
114
+ export type { FoldKeyOf, SortRow } from './file-tree/helpers.ts';
115
+ export type {
116
+ TreeRootProps,
117
+ TreeRowProps,
118
+ TreeChevronProps,
119
+ TreeChevronSlotProps,
120
+ TreeLabelProps,
121
+ TreeDecorationProps,
122
+ TreeControlProps,
123
+ TreeCheckboxProps
124
+ } from './tree/parts.tsx';
@@ -59,5 +59,10 @@
59
59
  "tabs": {
60
60
  "more": "More",
61
61
  "close": "Close tab"
62
+ },
63
+ "tree": {
64
+ "label": "Files",
65
+ "expand": "Expand",
66
+ "collapse": "Collapse"
62
67
  }
63
68
  }
@@ -59,5 +59,10 @@
59
59
  "tabs": {
60
60
  "more": "Meer",
61
61
  "close": "Tabblad sluiten"
62
+ },
63
+ "tree": {
64
+ "label": "Bestanden",
65
+ "expand": "Uitvouwen",
66
+ "collapse": "Invouwen"
62
67
  }
63
68
  }
@@ -8,6 +8,7 @@ import { useRender } from '@base-ui-components/react/use-render';
8
8
  import { withClass } from '../class-name.ts';
9
9
  import { Icon } from '../Icon.tsx';
10
10
  import { Tooltip } from '../Tooltip.tsx';
11
+ import { renderPositioner } from '../popup-layer.ts';
11
12
 
12
13
  /* Where a popup opens from decides where it goes when a call site says nothing. */
13
14
  type MenuKind = 'menu' | 'context' | 'submenu';
@@ -96,6 +97,7 @@ export function MenuPopup({
96
97
  <BaseMenu.Portal>
97
98
  <BaseMenu.Positioner
98
99
  className="popup-positioner"
100
+ render={renderPositioner}
99
101
  side={side ?? defaults.side}
100
102
  align={align ?? defaults.align}
101
103
  sideOffset={sideOffset ?? defaults.sideOffset}
@@ -2,6 +2,7 @@ import type { ComponentProps } from 'react';
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.ts';
5
+ import { renderPositioner } from '../popup-layer.ts';
5
6
 
6
7
  /*
7
8
  * The surface a popup is drawn on. `menu` is the padded card menus use, `picker` a card whose
@@ -55,6 +56,7 @@ export function PopoverPopup({
55
56
  <BasePopover.Portal>
56
57
  <BasePopover.Positioner
57
58
  className="popup-positioner"
59
+ render={renderPositioner}
58
60
  side={side}
59
61
  align={align}
60
62
  sideOffset={sideOffset}
@@ -0,0 +1,34 @@
1
+ import { createElement, useLayoutEffect, type ComponentProps } from 'react';
2
+
3
+ // One attribute serves every open popup, so it stays until the last one closes.
4
+ const open = new Set<symbol>();
5
+
6
+ /*
7
+ * Marks the root with `data-popup-open` while a popup is open, which the theme reads to turn the
8
+ * window's drag regions off. An attribute on the root instead of `:root:has(...)`, which made the
9
+ * browser restyle the whole page on every DOM insertion.
10
+ */
11
+ function usePopupLayer(isOpen: boolean): void {
12
+ useLayoutEffect(() => {
13
+ if (!isOpen) {
14
+ return;
15
+ }
16
+ const self = Symbol('popup');
17
+ open.add(self);
18
+ document.documentElement.setAttribute('data-popup-open', '');
19
+ return () => {
20
+ open.delete(self);
21
+ if (open.size === 0) {
22
+ document.documentElement.removeAttribute('data-popup-open');
23
+ }
24
+ };
25
+ }, [isOpen]);
26
+ }
27
+
28
+ function LayerPositioner({ open: isOpen, ...props }: ComponentProps<'div'> & { readonly open: boolean }) {
29
+ usePopupLayer(isOpen);
30
+ return createElement('div', props);
31
+ }
32
+
33
+ /* The `render` of a popup's positioner, which knows whether the popup is open and so marks the root for as long as it is. */
34
+ export const renderPositioner = (props: ComponentProps<'div'>, state: { open: boolean }) => createElement(LayerPositioner, { ...props, open: state.open });
package/src/theme.css CHANGED
@@ -424,7 +424,7 @@
424
424
  /* A press on a window's drag region goes to the system and never reaches the page, so a popup
425
425
  that closes on a press outside it would stay open over a custom title bar. While one is open
426
426
  nothing drags, the way a native title bar takes that first press to close a menu. */
427
- :root:has(.popup-positioner[data-open]) * { -webkit-app-region: no-drag; }
427
+ :root[data-popup-open] * { -webkit-app-region: no-drag; }
428
428
  .dialog-backdrop { position: fixed; inset: 0; z-index: var(--z-dialog-backdrop); background: color-mix(in srgb, var(--bg) 55%, transparent); }
429
429
  .dialog-backdrop[data-starting-style], .dialog-backdrop[data-ending-style] { opacity: 0; }
430
430
  .dialog-popup {
@@ -563,3 +563,106 @@
563
563
  .sliding-column[data-resizing],
564
564
  .sliding-column[data-instant] { transition: none; }
565
565
  }
566
+
567
+ @layer components {
568
+ .adecore-tree {
569
+ --tree-row-height: 25px;
570
+ --tree-row-gap: 6px;
571
+ --tree-row-radius: 6px;
572
+ --trees-bg-override: transparent;
573
+ --trees-padding-inline-override: 8px;
574
+ --trees-bg-muted-override: var(--surface-hover);
575
+ --trees-fg-override: var(--text);
576
+ --trees-fg-muted-override: var(--text-muted);
577
+ --trees-selected-bg-override: var(--surface-active);
578
+ --trees-selected-fg-override: var(--text);
579
+ --trees-border-color-override: var(--border);
580
+ --trees-accent-override: var(--accent);
581
+ --trees-focus-ring-color-override: var(--accent);
582
+ --trees-selected-focused-border-color-override: var(--accent);
583
+ --trees-status-ignored-override: var(--text-faint);
584
+ --trees-git-ignored-color-override: var(--text-faint);
585
+ --trees-git-modified-color-override: var(--status-needs-you);
586
+ --trees-git-added-color-override: var(--status-idle);
587
+ --trees-git-untracked-color-override: var(--status-idle);
588
+ --trees-git-deleted-color-override: var(--status-error);
589
+ --trees-git-renamed-color-override: var(--status-running);
590
+ --trees-font-family-override: var(--font-sans);
591
+ --trees-font-size-override: var(--text-xs);
592
+ }
593
+ .adecore-tree-row {
594
+ margin-inline: 8px;
595
+ padding-inline-end: 8px;
596
+ }
597
+ .adecore-tree-guide {
598
+ pointer-events: none;
599
+ position: absolute;
600
+ inset-block: 0;
601
+ width: 1px;
602
+ background: var(--border);
603
+ }
604
+ .adecore-tree-chevron-slot {
605
+ display: grid;
606
+ place-items: center;
607
+ flex: none;
608
+ width: 16px;
609
+ height: 16px;
610
+ padding: 0;
611
+ border: 0;
612
+ background: transparent;
613
+ color: var(--text-muted);
614
+ }
615
+ .adecore-tree-label {
616
+ min-width: 0;
617
+ overflow: hidden;
618
+ text-overflow: ellipsis;
619
+ white-space: nowrap;
620
+ }
621
+ .adecore-tree-control,
622
+ .adecore-tree-decoration {
623
+ display: inline-flex;
624
+ flex: none;
625
+ align-items: center;
626
+ gap: 6px;
627
+ }
628
+ .adecore-tree-decoration {
629
+ font-variant-numeric: tabular-nums;
630
+ }
631
+ .adecore-file-tree {
632
+ position: relative;
633
+ min-height: 0;
634
+ overflow: hidden;
635
+ }
636
+ .adecore-file-tree-host {
637
+ display: block;
638
+ height: 100%;
639
+ }
640
+ .adecore-tree-shift {
641
+ pointer-events: none;
642
+ position: sticky;
643
+ bottom: 0;
644
+ height: 0;
645
+ }
646
+ .adecore-tree-shift-track {
647
+ position: absolute;
648
+ inset-inline: 8px;
649
+ bottom: 2px;
650
+ height: 6px;
651
+ opacity: 0;
652
+ transition: opacity 150ms ease-out;
653
+ }
654
+ .adecore-tree-shift-track[data-visible] {
655
+ opacity: 1;
656
+ }
657
+ .adecore-tree-shift-thumb {
658
+ position: absolute;
659
+ inset-block: 0;
660
+ border-radius: 999px;
661
+ background: var(--text-faint);
662
+ }
663
+ @media (prefers-reduced-motion: reduce) {
664
+ .adecore-tree-shift-track {
665
+ transition: none;
666
+ }
667
+ }
668
+ }
@@ -0,0 +1,10 @@
1
+ export {
2
+ TreeRoot as Root,
3
+ TreeRow as Row,
4
+ TreeChevron as Chevron,
5
+ TreeChevronSlot as ChevronSlot,
6
+ TreeLabel as Label,
7
+ TreeDecoration as Decoration,
8
+ TreeControl as Control,
9
+ TreeCheckbox as Checkbox
10
+ } from './parts.tsx';