@haiyue/ui 0.1.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -0
  3. package/dist/checkbox.d.ts +30 -0
  4. package/dist/checkbox.js +204 -0
  5. package/dist/context-menu.d.ts +41 -0
  6. package/dist/context-menu.js +225 -0
  7. package/dist/dialog.d.ts +30 -0
  8. package/dist/dialog.js +183 -0
  9. package/dist/dropdown.d.ts +45 -0
  10. package/dist/dropdown.js +228 -0
  11. package/dist/index.d.ts +32 -0
  12. package/dist/index.js +110 -0
  13. package/dist/input.d.ts +30 -0
  14. package/dist/input.js +131 -0
  15. package/dist/menu-shared.d.ts +14 -0
  16. package/dist/menu-shared.js +54 -0
  17. package/dist/radio.d.ts +30 -0
  18. package/dist/radio.js +205 -0
  19. package/dist/select.d.ts +40 -0
  20. package/dist/select.js +253 -0
  21. package/dist/split.d.ts +46 -0
  22. package/dist/split.js +277 -0
  23. package/dist/tabs.d.ts +28 -0
  24. package/dist/tabs.js +161 -0
  25. package/dist/tooltip.d.ts +37 -0
  26. package/dist/tooltip.js +286 -0
  27. package/dist/tree-model.d.ts +35 -0
  28. package/dist/tree-model.js +281 -0
  29. package/dist/tree-node.d.ts +11 -0
  30. package/dist/tree-node.js +63 -0
  31. package/dist/tree-types.d.ts +41 -0
  32. package/dist/tree-types.js +2 -0
  33. package/dist/tree.d.ts +102 -0
  34. package/dist/tree.js +942 -0
  35. package/package.json +46 -0
  36. package/src/checkbox.ts +215 -0
  37. package/src/context-menu.ts +248 -0
  38. package/src/dialog.ts +195 -0
  39. package/src/dropdown.ts +249 -0
  40. package/src/index.ts +192 -0
  41. package/src/input.ts +150 -0
  42. package/src/menu-shared.ts +65 -0
  43. package/src/radio.ts +216 -0
  44. package/src/select.ts +271 -0
  45. package/src/split.ts +306 -0
  46. package/src/tabs.ts +182 -0
  47. package/src/tooltip.ts +321 -0
  48. package/src/tree-model.ts +305 -0
  49. package/src/tree-node.ts +67 -0
  50. package/src/tree-types.ts +47 -0
  51. package/src/tree.ts +1002 -0
package/src/tooltip.ts ADDED
@@ -0,0 +1,321 @@
1
+ export type GETooltipPlacement =
2
+ | 'top'
3
+ | 'topLeft'
4
+ | 'topRight'
5
+ | 'bottom'
6
+ | 'bottomLeft'
7
+ | 'bottomRight'
8
+ | 'left'
9
+ | 'leftTop'
10
+ | 'leftBottom'
11
+ | 'right'
12
+ | 'rightTop'
13
+ | 'rightBottom';
14
+
15
+ const DEFAULT_DELAY = 1000;
16
+ const VIEWPORT_PADDING = 8;
17
+ const TRIGGER_GAP = 8;
18
+ const ARROW_SIZE = 8;
19
+ const ARROW_OFFSET = 16;
20
+ const TOOLTIP_PLACEMENTS = new Set<GETooltipPlacement>([
21
+ 'top',
22
+ 'topLeft',
23
+ 'topRight',
24
+ 'bottom',
25
+ 'bottomLeft',
26
+ 'bottomRight',
27
+ 'left',
28
+ 'leftTop',
29
+ 'leftBottom',
30
+ 'right',
31
+ 'rightTop',
32
+ 'rightBottom',
33
+ ]);
34
+
35
+ export class GETooltip extends HTMLElement {
36
+ private readonly _root: ShadowRoot;
37
+ private readonly _style = document.createElement('style');
38
+ private readonly _slot = document.createElement('slot');
39
+ private readonly _tooltip = document.createElement('div');
40
+ private readonly _content = document.createElement('div');
41
+ private readonly _arrow = document.createElement('span');
42
+ private _showTimer: number | null = null;
43
+ private _visible = false;
44
+
45
+ static get observedAttributes(): string[] {
46
+ return ['label', 'placement', 'delay', 'disabled', 'arrow', 'no-arrow'];
47
+ }
48
+
49
+ constructor() {
50
+ super();
51
+ this._root = this.attachShadow({ mode: 'open' });
52
+ this._style.textContent = `
53
+ :host {
54
+ display: inline-block;
55
+ width: max-content;
56
+ min-width: 0;
57
+ }
58
+ .tooltip {
59
+ position: fixed;
60
+ z-index: 10001;
61
+ box-sizing: border-box;
62
+ max-width: min(280px, calc(100vw - 16px));
63
+ padding: 5px 8px;
64
+ border-radius: 4px;
65
+ color: var(--ge-tooltip-text-color, #f4f7fb);
66
+ background: var(--ge-tooltip-bg-color, #0f141c);
67
+ border: 1px solid var(--ge-tooltip-border-color, rgba(255, 255, 255, 0.08));
68
+ box-shadow: 0 8px 24px var(--ge-tooltip-shadow-color, rgba(0, 0, 0, 0.35));
69
+ font: 12px system-ui, sans-serif;
70
+ line-height: 1.35;
71
+ width: max-content;
72
+ white-space: normal;
73
+ overflow-wrap: anywhere;
74
+ pointer-events: none;
75
+ opacity: 0;
76
+ transform: translate3d(0, -2px, 0);
77
+ transition: opacity 0.12s ease, transform 0.12s ease;
78
+ }
79
+ .arrow {
80
+ position: absolute;
81
+ width: ${ARROW_SIZE}px;
82
+ height: ${ARROW_SIZE}px;
83
+ background: var(--ge-tooltip-bg-color, #0f141c);
84
+ border: solid var(--ge-tooltip-border-color, rgba(255, 255, 255, 0.08));
85
+ border-width: 0 1px 1px 0;
86
+ transform: rotate(45deg);
87
+ }
88
+ .tooltip.no-arrow .arrow {
89
+ display: none;
90
+ }
91
+ .tooltip.visible {
92
+ opacity: 1;
93
+ transform: translate3d(0, 0, 0);
94
+ }
95
+ `;
96
+ this._tooltip.className = 'tooltip';
97
+ this._tooltip.setAttribute('role', 'tooltip');
98
+ this._content.className = 'content';
99
+ this._arrow.className = 'arrow';
100
+ this._tooltip.append(this._content, this._arrow);
101
+ this._root.append(this._style, this._slot, this._tooltip);
102
+ }
103
+
104
+ connectedCallback(): void {
105
+ this.addEventListener('pointerenter', this._onEnter);
106
+ this.addEventListener('pointerleave', this._onLeave);
107
+ this.addEventListener('focusin', this._onEnter);
108
+ this.addEventListener('focusout', this._onLeave);
109
+ this.addEventListener('keydown', this._onKeyDown);
110
+ this._syncLabel();
111
+ }
112
+
113
+ disconnectedCallback(): void {
114
+ this.removeEventListener('pointerenter', this._onEnter);
115
+ this.removeEventListener('pointerleave', this._onLeave);
116
+ this.removeEventListener('focusin', this._onEnter);
117
+ this.removeEventListener('focusout', this._onLeave);
118
+ this.removeEventListener('keydown', this._onKeyDown);
119
+ this._clearShowTimer();
120
+ this._hide();
121
+ }
122
+
123
+ attributeChangedCallback(name: string): void {
124
+ if (name === 'label') this._syncLabel();
125
+ if (name === 'arrow' || name === 'no-arrow') this._syncArrow();
126
+ if ((name === 'placement' || name === 'disabled' || name === 'arrow' || name === 'no-arrow') && this._visible) {
127
+ this._syncPosition();
128
+ }
129
+ }
130
+
131
+ get label(): string {
132
+ return this.getAttribute('label') ?? '';
133
+ }
134
+
135
+ set label(value: string) {
136
+ if (value) this.setAttribute('label', value);
137
+ else this.removeAttribute('label');
138
+ }
139
+
140
+ get placement(): GETooltipPlacement {
141
+ return normalizePlacement(this.getAttribute('placement'));
142
+ }
143
+
144
+ set placement(value: GETooltipPlacement) {
145
+ this.setAttribute('placement', value);
146
+ }
147
+
148
+ get delay(): number {
149
+ const value = Number(this.getAttribute('delay'));
150
+ return Number.isFinite(value) && value >= 0 ? value : DEFAULT_DELAY;
151
+ }
152
+
153
+ set delay(value: number) {
154
+ this.setAttribute('delay', String(Math.max(0, value)));
155
+ }
156
+
157
+ get arrow(): boolean {
158
+ if (this.hasAttribute('no-arrow')) return false;
159
+ const value = this.getAttribute('arrow');
160
+ return value === null || value !== 'false';
161
+ }
162
+
163
+ set arrow(value: boolean) {
164
+ if (value) {
165
+ this.removeAttribute('no-arrow');
166
+ this.setAttribute('arrow', '');
167
+ } else {
168
+ this.setAttribute('arrow', 'false');
169
+ }
170
+ }
171
+
172
+ private _onEnter = (): void => {
173
+ if (this.hasAttribute('disabled') || !this.label) return;
174
+ this._clearShowTimer();
175
+ this._showTimer = window.setTimeout(() => this._show(), this.delay);
176
+ };
177
+
178
+ private _onLeave = (): void => {
179
+ this._clearShowTimer();
180
+ this._hide();
181
+ };
182
+
183
+ private _onKeyDown = (event: KeyboardEvent): void => {
184
+ if (event.key === 'Escape') this._hide();
185
+ };
186
+
187
+ private _show(): void {
188
+ this._showTimer = null;
189
+ if (!this.isConnected || this.hasAttribute('disabled') || !this.label) return;
190
+ this._visible = true;
191
+ this._syncArrow();
192
+ this._syncPosition();
193
+ this._tooltip.classList.add('visible');
194
+ window.addEventListener('scroll', this._onViewportChange, { capture: true, passive: true });
195
+ window.addEventListener('resize', this._onViewportChange);
196
+ }
197
+
198
+ private _hide(): void {
199
+ if (!this._visible) return;
200
+ this._visible = false;
201
+ this._tooltip.classList.remove('visible');
202
+ window.removeEventListener('scroll', this._onViewportChange, { capture: true });
203
+ window.removeEventListener('resize', this._onViewportChange);
204
+ }
205
+
206
+ private _onViewportChange = (): void => {
207
+ if (this._visible) this._syncPosition();
208
+ };
209
+
210
+ private _syncLabel(): void {
211
+ this._content.textContent = this.label;
212
+ }
213
+
214
+ private _syncArrow(): void {
215
+ this._tooltip.classList.toggle('no-arrow', !this.arrow);
216
+ }
217
+
218
+ private _syncPosition(): void {
219
+ const hostRect = this.getBoundingClientRect();
220
+ const tooltipRect = this._measureTooltip();
221
+ const placement = this.placement;
222
+ let top = getAlignedTop(hostRect, tooltipRect, placement);
223
+ let left = getAlignedLeft(hostRect, tooltipRect, placement);
224
+
225
+ if (placement.startsWith('top')) top = hostRect.top - tooltipRect.height - TRIGGER_GAP;
226
+ else if (placement.startsWith('bottom')) top = hostRect.bottom + TRIGGER_GAP;
227
+ else if (placement.startsWith('left')) left = hostRect.left - tooltipRect.width - TRIGGER_GAP;
228
+ else if (placement.startsWith('right')) left = hostRect.right + TRIGGER_GAP;
229
+
230
+ top = clamp(top, VIEWPORT_PADDING, window.innerHeight - tooltipRect.height - VIEWPORT_PADDING);
231
+ left = clamp(left, VIEWPORT_PADDING, window.innerWidth - tooltipRect.width - VIEWPORT_PADDING);
232
+ this._tooltip.style.top = `${top}px`;
233
+ this._tooltip.style.left = `${left}px`;
234
+ this._syncArrowPosition(hostRect, tooltipRect, top, left, placement);
235
+ }
236
+
237
+ private _syncArrowPosition(
238
+ hostRect: DOMRect,
239
+ tooltipRect: DOMRect,
240
+ top: number,
241
+ left: number,
242
+ placement: GETooltipPlacement,
243
+ ): void {
244
+ const x = clamp(hostRect.left + hostRect.width / 2 - left - ARROW_SIZE / 2, ARROW_OFFSET, tooltipRect.width - ARROW_OFFSET - ARROW_SIZE);
245
+ const y = clamp(hostRect.top + hostRect.height / 2 - top - ARROW_SIZE / 2, ARROW_OFFSET, tooltipRect.height - ARROW_OFFSET - ARROW_SIZE);
246
+ this._arrow.style.inset = 'auto';
247
+
248
+ if (placement.startsWith('top')) {
249
+ this._arrow.style.left = `${x}px`;
250
+ this._arrow.style.bottom = `${-ARROW_SIZE / 2}px`;
251
+ this._arrow.style.transform = 'rotate(45deg)';
252
+ return;
253
+ }
254
+
255
+ if (placement.startsWith('bottom')) {
256
+ this._arrow.style.left = `${x}px`;
257
+ this._arrow.style.top = `${-ARROW_SIZE / 2}px`;
258
+ this._arrow.style.transform = 'rotate(225deg)';
259
+ return;
260
+ }
261
+
262
+ if (placement.startsWith('left')) {
263
+ this._arrow.style.top = `${y}px`;
264
+ this._arrow.style.right = `${-ARROW_SIZE / 2}px`;
265
+ this._arrow.style.transform = 'rotate(315deg)';
266
+ return;
267
+ }
268
+
269
+ this._arrow.style.top = `${y}px`;
270
+ this._arrow.style.left = `${-ARROW_SIZE / 2}px`;
271
+ this._arrow.style.transform = 'rotate(135deg)';
272
+ }
273
+
274
+ private _measureTooltip(): DOMRect {
275
+ const previousVisibility = this._tooltip.style.visibility;
276
+ const previousOpacity = this._tooltip.style.opacity;
277
+ this._tooltip.style.visibility = 'hidden';
278
+ this._tooltip.style.opacity = '0';
279
+ this._tooltip.classList.add('visible');
280
+ const rect = this._tooltip.getBoundingClientRect();
281
+ this._tooltip.classList.toggle('visible', this._visible);
282
+ this._tooltip.style.visibility = previousVisibility;
283
+ this._tooltip.style.opacity = previousOpacity;
284
+ return rect;
285
+ }
286
+
287
+ private _clearShowTimer(): void {
288
+ if (this._showTimer === null) return;
289
+ window.clearTimeout(this._showTimer);
290
+ this._showTimer = null;
291
+ }
292
+ }
293
+
294
+ function clamp(value: number, min: number, max: number): number {
295
+ if (max < min) return min;
296
+ return Math.min(Math.max(value, min), max);
297
+ }
298
+
299
+ function normalizePlacement(value: string | null): GETooltipPlacement {
300
+ return value && TOOLTIP_PLACEMENTS.has(value as GETooltipPlacement)
301
+ ? value as GETooltipPlacement
302
+ : 'top';
303
+ }
304
+
305
+ function getAlignedTop(hostRect: DOMRect, tooltipRect: DOMRect, placement: GETooltipPlacement): number {
306
+ if (placement.endsWith('Top')) return hostRect.top;
307
+ if (placement.endsWith('Bottom')) return hostRect.bottom - tooltipRect.height;
308
+ return hostRect.top + (hostRect.height - tooltipRect.height) / 2;
309
+ }
310
+
311
+ function getAlignedLeft(hostRect: DOMRect, tooltipRect: DOMRect, placement: GETooltipPlacement): number {
312
+ if (placement.endsWith('Left')) return hostRect.left;
313
+ if (placement.endsWith('Right')) return hostRect.right - tooltipRect.width;
314
+ return hostRect.left + (hostRect.width - tooltipRect.width) / 2;
315
+ }
316
+
317
+ export function defineTooltipComponents(): void {
318
+ if (!customElements.get('ge-tooltip')) {
319
+ customElements.define('ge-tooltip', GETooltip);
320
+ }
321
+ }
@@ -0,0 +1,305 @@
1
+ import type {
2
+ FlatTreeNode,
3
+ GETreeDropPosition,
4
+ GETreeNodeData,
5
+ IndexedTreeNode,
6
+ } from './tree-types.js';
7
+
8
+ /** Mutable hierarchy model owned by ge-tree. It has no DOM dependency and is safe to unit test. */
9
+ export class GETreeModel {
10
+ private _data: GETreeNodeData[] = [];
11
+ private readonly _expandedIds: Set<string>;
12
+ private readonly _nodeIndex = new Map<string, IndexedTreeNode>();
13
+ private readonly _visibleNodes: FlatTreeNode[] = [];
14
+ private readonly _visibleIds: string[] = [];
15
+ private readonly _visibleIndex = new Map<string, number>();
16
+ private _indexDirty = true;
17
+ private _visibleDirty = true;
18
+
19
+ constructor(expandedIds: Set<string>) {
20
+ this._expandedIds = expandedIds;
21
+ }
22
+
23
+ get index(): ReadonlyMap<string, IndexedTreeNode> {
24
+ this._ensureIndex();
25
+ return this._nodeIndex;
26
+ }
27
+
28
+ get visibleIndex(): ReadonlyMap<string, number> {
29
+ this.getVisibleNodes();
30
+ return this._visibleIndex;
31
+ }
32
+
33
+ get isVisibilityDirty(): boolean {
34
+ return this._visibleDirty;
35
+ }
36
+
37
+ setData(data: GETreeNodeData[]): void {
38
+ this._data = data;
39
+ this._indexDirty = true;
40
+ this._visibleDirty = true;
41
+ }
42
+
43
+ invalidateVisibility(): void {
44
+ this._visibleDirty = true;
45
+ }
46
+
47
+ getVisibleNodes(): readonly FlatTreeNode[] {
48
+ if (!this._visibleDirty) return this._visibleNodes;
49
+ this._ensureIndex();
50
+ this._visibleNodes.length = 0;
51
+ this._visibleIds.length = 0;
52
+ this._visibleIndex.clear();
53
+ const visit = (nodes: GETreeNodeData[], parent: GETreeNodeData | null, depth: number): void => {
54
+ for (const node of nodes) {
55
+ this._visibleIndex.set(node.id, this._visibleIds.length);
56
+ this._visibleNodes.push(this._nodeIndex.get(node.id) ?? { node, parent, depth });
57
+ this._visibleIds.push(node.id);
58
+ if (node.children?.length && this._expandedIds.has(node.id)) {
59
+ visit(node.children, node, depth + 1);
60
+ }
61
+ }
62
+ };
63
+ visit(this._data, null, 0);
64
+ this._visibleDirty = false;
65
+ return this._visibleNodes;
66
+ }
67
+
68
+ getVisibleIds(): readonly string[] {
69
+ this.getVisibleNodes();
70
+ return this._visibleIds;
71
+ }
72
+
73
+ getIndexedNode(id: string): IndexedTreeNode | null {
74
+ this._ensureIndex();
75
+ return this._nodeIndex.get(id) ?? null;
76
+ }
77
+
78
+ findNode(id: string): { node: GETreeNodeData; parent: GETreeNodeData | null } | null {
79
+ const found = this.getIndexedNode(id);
80
+ return found ? { node: found.node, parent: found.parent } : null;
81
+ }
82
+
83
+ topLevelSelection(ids: Iterable<string>): string[] {
84
+ this._ensureIndex();
85
+ const existing = [...new Set(ids)].filter(id => this._nodeIndex.has(id));
86
+ const selected = new Set(existing);
87
+ return existing.filter(id => {
88
+ const indexed = this._nodeIndex.get(id);
89
+ return indexed !== undefined && !indexed.ancestorIds.some(ancestorId => selected.has(ancestorId));
90
+ });
91
+ }
92
+
93
+ removeNode(id: string): boolean {
94
+ const found = this.findNode(id);
95
+ if (!found) return false;
96
+ const list = found.parent?.children ?? this._data;
97
+ const index = this._nodeIndex.get(id)?.index ?? -1;
98
+ if (index < 0) return false;
99
+ list.splice(index, 1);
100
+ this.setData(this._data);
101
+ return true;
102
+ }
103
+
104
+ moveNode(sourceId: string, targetId: string, position: GETreeDropPosition): boolean {
105
+ this._ensureIndex();
106
+ this.getVisibleNodes();
107
+ const source = this._nodeIndex.get(sourceId);
108
+ const target = this._nodeIndex.get(targetId);
109
+ if (!source || !target || sourceId === targetId) return false;
110
+ if (target.ancestorIds.includes(sourceId)) return false;
111
+
112
+ const sourceList = source.parent?.children ?? this._data;
113
+ const sourceIndex = source.index;
114
+ if (sourceIndex < 0) return false;
115
+ const previousDepth = source.depth;
116
+ const visibleStart = this._visibleIndex.get(sourceId) ?? -1;
117
+ let visibleCount = 0;
118
+ if (visibleStart >= 0) {
119
+ visibleCount = 1;
120
+ while (visibleStart + visibleCount < this._visibleNodes.length
121
+ && (this._visibleNodes[visibleStart + visibleCount]?.depth ?? -1) > previousDepth) {
122
+ visibleCount++;
123
+ }
124
+ }
125
+ sourceList.splice(sourceIndex, 1);
126
+
127
+ let nextParent: GETreeNodeData | null;
128
+ let targetList: GETreeNodeData[];
129
+ let insertIndex: number;
130
+ let expandedNextParent = false;
131
+ if (position === 'inside') {
132
+ target.node.children ??= [];
133
+ nextParent = target.node;
134
+ targetList = target.node.children;
135
+ insertIndex = targetList.length;
136
+ expandedNextParent = !this._expandedIds.has(target.node.id);
137
+ this._expandedIds.add(target.node.id);
138
+ } else {
139
+ nextParent = target.parent;
140
+ targetList = target.parent?.children ?? this._data;
141
+ let targetIndex = target.index;
142
+ if (sourceList === targetList && sourceIndex < targetIndex) targetIndex--;
143
+ if (targetIndex < 0) return false;
144
+ insertIndex = position === 'before' ? targetIndex : targetIndex + 1;
145
+ }
146
+ targetList.splice(insertIndex, 0, source.node);
147
+ this._refreshSiblingIndexes(sourceList);
148
+ if (targetList !== sourceList) this._refreshSiblingIndexes(targetList);
149
+ const parentIndex = nextParent ? this._nodeIndex.get(nextParent.id) : null;
150
+ const ancestorIds = parentIndex ? [...parentIndex.ancestorIds, nextParent!.id] : [];
151
+ this._refreshSubtreeIndex(
152
+ source.node,
153
+ nextParent,
154
+ parentIndex ? parentIndex.depth + 1 : 0,
155
+ ancestorIds,
156
+ );
157
+ this._moveVisibleSubtree(
158
+ source.node,
159
+ visibleStart,
160
+ visibleCount,
161
+ nextParent,
162
+ expandedNextParent,
163
+ );
164
+ return true;
165
+ }
166
+
167
+ private _refreshSiblingIndexes(nodes: readonly GETreeNodeData[]): void {
168
+ for (let index = 0; index < nodes.length; index++) {
169
+ const item = nodes[index];
170
+ const indexed = item ? this._nodeIndex.get(item.id) : null;
171
+ if (indexed) indexed.index = index;
172
+ }
173
+ }
174
+
175
+ private _refreshSubtreeIndex(
176
+ node: GETreeNodeData,
177
+ parent: GETreeNodeData | null,
178
+ depth: number,
179
+ ancestorIds: readonly string[],
180
+ ): void {
181
+ const indexed = this._nodeIndex.get(node.id);
182
+ if (!indexed) return;
183
+ indexed.parent = parent;
184
+ indexed.depth = depth;
185
+ indexed.ancestorIds = ancestorIds;
186
+ const childAncestors = [...ancestorIds, node.id];
187
+ for (let index = 0; index < (node.children?.length ?? 0); index++) {
188
+ const child = node.children?.[index];
189
+ if (!child) continue;
190
+ const childIndex = this._nodeIndex.get(child.id);
191
+ if (childIndex) childIndex.index = index;
192
+ this._refreshSubtreeIndex(child, node, depth + 1, childAncestors);
193
+ }
194
+ }
195
+
196
+ private _moveVisibleSubtree(
197
+ source: GETreeNodeData,
198
+ previousStart: number,
199
+ previousCount: number,
200
+ nextParent: GETreeNodeData | null,
201
+ expandedNextParent: boolean,
202
+ ): void {
203
+ if (previousStart >= 0 && previousCount > 0) {
204
+ this._visibleNodes.splice(previousStart, previousCount);
205
+ this._visibleIds.splice(previousStart, previousCount);
206
+ this._refreshVisibleIndexes(previousStart);
207
+ }
208
+
209
+ if (expandedNextParent && nextParent) {
210
+ const parentVisible = this._visibleIndex.get(nextParent.id);
211
+ if (parentVisible === undefined) return;
212
+ const expandedChildren: IndexedTreeNode[] = [];
213
+ for (const child of nextParent.children ?? []) {
214
+ this._collectVisibleSubtree(child, expandedChildren);
215
+ }
216
+ if (expandedChildren.length === 0) return;
217
+ const insertAt = parentVisible + 1;
218
+ this._visibleNodes.splice(insertAt, 0, ...expandedChildren);
219
+ this._visibleIds.splice(insertAt, 0, ...expandedChildren.map(item => item.node.id));
220
+ this._refreshVisibleIndexes(insertAt);
221
+ return;
222
+ }
223
+
224
+ let insertAt = -1;
225
+ const sourceIndex = this._nodeIndex.get(source.id)?.index ?? -1;
226
+ const siblings = nextParent?.children ?? this._data;
227
+ if (sourceIndex >= 0) {
228
+ if (nextParent) {
229
+ const parentVisible = this._visibleIndex.get(nextParent.id);
230
+ if (parentVisible !== undefined && this._expandedIds.has(nextParent.id)) {
231
+ insertAt = sourceIndex === 0
232
+ ? parentVisible + 1
233
+ : this._visibleSubtreeEnd(siblings[sourceIndex - 1]?.id);
234
+ }
235
+ } else {
236
+ insertAt = sourceIndex === 0
237
+ ? 0
238
+ : this._visibleSubtreeEnd(siblings[sourceIndex - 1]?.id);
239
+ }
240
+ }
241
+ if (insertAt < 0) return;
242
+
243
+ const moved: IndexedTreeNode[] = [];
244
+ this._collectVisibleSubtree(source, moved);
245
+ if (moved.length === 0) return;
246
+ this._visibleNodes.splice(insertAt, 0, ...moved);
247
+ this._visibleIds.splice(insertAt, 0, ...moved.map(item => item.node.id));
248
+ this._refreshVisibleIndexes(insertAt);
249
+ }
250
+
251
+ private _collectVisibleSubtree(node: GETreeNodeData, result: IndexedTreeNode[]): void {
252
+ const indexed = this._nodeIndex.get(node.id);
253
+ if (!indexed) return;
254
+ result.push(indexed);
255
+ if (!this._expandedIds.has(node.id)) return;
256
+ for (const child of node.children ?? []) this._collectVisibleSubtree(child, result);
257
+ }
258
+
259
+ private _visibleSubtreeEnd(id: string | undefined): number {
260
+ if (!id) return -1;
261
+ const start = this._visibleIndex.get(id);
262
+ const depth = this._nodeIndex.get(id)?.depth;
263
+ if (start === undefined || depth === undefined) return -1;
264
+ let end = start + 1;
265
+ while (end < this._visibleNodes.length
266
+ && (this._visibleNodes[end]?.depth ?? -1) > depth) {
267
+ end++;
268
+ }
269
+ return end;
270
+ }
271
+
272
+ private _refreshVisibleIndexes(start: number): void {
273
+ for (let index = Math.max(0, start); index < this._visibleIds.length; index++) {
274
+ const id = this._visibleIds[index];
275
+ if (id !== undefined) this._visibleIndex.set(id, index);
276
+ }
277
+ for (const [id, index] of this._visibleIndex) {
278
+ if (index < this._visibleIds.length && this._visibleIds[index] === id) continue;
279
+ this._visibleIndex.delete(id);
280
+ }
281
+ }
282
+
283
+ private _ensureIndex(): void {
284
+ if (!this._indexDirty) return;
285
+ this._nodeIndex.clear();
286
+ const ancestorIds: string[] = [];
287
+ const visit = (nodes: GETreeNodeData[], parent: GETreeNodeData | null, depth: number): void => {
288
+ for (let index = 0; index < nodes.length; index++) {
289
+ const node = nodes[index];
290
+ if (!node) continue;
291
+ this._nodeIndex.set(node.id, { node, parent, depth, ancestorIds: ancestorIds.slice(), index });
292
+ if (node.children?.length) {
293
+ ancestorIds.push(node.id);
294
+ visit(node.children, node, depth + 1);
295
+ ancestorIds.pop();
296
+ }
297
+ }
298
+ };
299
+ visit(this._data, null, 0);
300
+ for (const id of this._expandedIds) {
301
+ if (!this._nodeIndex.has(id)) this._expandedIds.delete(id);
302
+ }
303
+ this._indexDirty = false;
304
+ }
305
+ }
@@ -0,0 +1,67 @@
1
+ import type { GETreeNodeData } from './tree-types.js';
2
+
3
+ export class GETreeNode extends HTMLElement {
4
+ private readonly _icon = document.createElement('span');
5
+ private readonly _label = document.createElement('span');
6
+
7
+ static get observedAttributes(): string[] {
8
+ return ['label', 'icon', 'selected'];
9
+ }
10
+
11
+ constructor() {
12
+ super();
13
+ const root = this.attachShadow({ mode: 'open' });
14
+ const style = document.createElement('style');
15
+ style.textContent = `
16
+ :host {
17
+ display: flex;
18
+ align-items: center;
19
+ min-width: 0;
20
+ gap: 6px;
21
+ color: inherit;
22
+ font: inherit;
23
+ }
24
+ .icon {
25
+ width: 16px;
26
+ flex: 0 0 16px;
27
+ display: inline-grid;
28
+ place-items: center;
29
+ color: var(--ge-tree-icon-color, var(--ge-secondary-text-color, #8fa7c8));
30
+ font-size: 12px;
31
+ }
32
+ .label {
33
+ min-width: 0;
34
+ overflow: hidden;
35
+ text-overflow: ellipsis;
36
+ white-space: nowrap;
37
+ }
38
+ :host([selected]) .label {
39
+ color: var(--ge-selected-text-color, #ffffff);
40
+ }
41
+ `;
42
+ this._icon.className = 'icon';
43
+ this._label.className = 'label';
44
+ root.append(style, this._icon, this._label);
45
+ }
46
+
47
+ connectedCallback(): void {
48
+ if (!this.hasAttribute('role')) this.setAttribute('role', 'treeitem');
49
+ this._sync();
50
+ }
51
+
52
+ attributeChangedCallback(): void {
53
+ this._sync();
54
+ }
55
+
56
+ set node(value: GETreeNodeData) {
57
+ this.setAttribute('label', String(value.label ?? value.id));
58
+ if (value.icon) this.setAttribute('icon', String(value.icon));
59
+ else this.removeAttribute('icon');
60
+ }
61
+
62
+ private _sync(): void {
63
+ this._icon.textContent = this.getAttribute('icon') ?? '';
64
+ this._label.textContent = this.getAttribute('label') ?? '';
65
+ this.setAttribute('aria-selected', this.hasAttribute('selected') ? 'true' : 'false');
66
+ }
67
+ }