@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.
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/checkbox.d.ts +30 -0
- package/dist/checkbox.js +204 -0
- package/dist/context-menu.d.ts +41 -0
- package/dist/context-menu.js +225 -0
- package/dist/dialog.d.ts +30 -0
- package/dist/dialog.js +183 -0
- package/dist/dropdown.d.ts +45 -0
- package/dist/dropdown.js +228 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +110 -0
- package/dist/input.d.ts +30 -0
- package/dist/input.js +131 -0
- package/dist/menu-shared.d.ts +14 -0
- package/dist/menu-shared.js +54 -0
- package/dist/radio.d.ts +30 -0
- package/dist/radio.js +205 -0
- package/dist/select.d.ts +40 -0
- package/dist/select.js +253 -0
- package/dist/split.d.ts +46 -0
- package/dist/split.js +277 -0
- package/dist/tabs.d.ts +28 -0
- package/dist/tabs.js +161 -0
- package/dist/tooltip.d.ts +37 -0
- package/dist/tooltip.js +286 -0
- package/dist/tree-model.d.ts +35 -0
- package/dist/tree-model.js +281 -0
- package/dist/tree-node.d.ts +11 -0
- package/dist/tree-node.js +63 -0
- package/dist/tree-types.d.ts +41 -0
- package/dist/tree-types.js +2 -0
- package/dist/tree.d.ts +102 -0
- package/dist/tree.js +942 -0
- package/package.json +46 -0
- package/src/checkbox.ts +215 -0
- package/src/context-menu.ts +248 -0
- package/src/dialog.ts +195 -0
- package/src/dropdown.ts +249 -0
- package/src/index.ts +192 -0
- package/src/input.ts +150 -0
- package/src/menu-shared.ts +65 -0
- package/src/radio.ts +216 -0
- package/src/select.ts +271 -0
- package/src/split.ts +306 -0
- package/src/tabs.ts +182 -0
- package/src/tooltip.ts +321 -0
- package/src/tree-model.ts +305 -0
- package/src/tree-node.ts +67 -0
- package/src/tree-types.ts +47 -0
- package/src/tree.ts +1002 -0
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@haiyue/ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Dependency-free Web Components for HaiyueStudio tools and products.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/HaiyueStudio/UI.git",
|
|
10
|
+
"directory": "ui"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "dist/index.js",
|
|
14
|
+
"module": "dist/index.js",
|
|
15
|
+
"types": "dist/index.d.ts",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"source": "./src/index.ts",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"tag": "latest"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"dist/**/*.js",
|
|
35
|
+
"dist/**/*.d.ts",
|
|
36
|
+
"src/**/*"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.json",
|
|
40
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
41
|
+
"test": "npm run build && node --test \"test/**/*.test.mjs\""
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"typescript": "^5.2.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/checkbox.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
export interface GECheckboxChangeDetail {
|
|
2
|
+
checked: boolean;
|
|
3
|
+
value: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export class GECheckbox extends HTMLElement {
|
|
7
|
+
private readonly _input = document.createElement('input');
|
|
8
|
+
private readonly _box = document.createElement('span');
|
|
9
|
+
private readonly _label = document.createElement('span');
|
|
10
|
+
private readonly _slot = document.createElement('slot');
|
|
11
|
+
|
|
12
|
+
static get observedAttributes(): string[] {
|
|
13
|
+
return ['checked', 'disabled', 'indeterminate', 'label', 'value', 'name', 'aria-label'];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
constructor() {
|
|
17
|
+
super();
|
|
18
|
+
const root = this.attachShadow({ mode: 'open' });
|
|
19
|
+
const style = document.createElement('style');
|
|
20
|
+
style.textContent = `
|
|
21
|
+
:host {
|
|
22
|
+
display: inline-block;
|
|
23
|
+
width: max-content;
|
|
24
|
+
min-width: 0;
|
|
25
|
+
color: var(--ge-checkbox-text-color, var(--ge-text-color, #d8e2f2));
|
|
26
|
+
font: 12px system-ui, sans-serif;
|
|
27
|
+
}
|
|
28
|
+
:host([disabled]) {
|
|
29
|
+
opacity: 0.55;
|
|
30
|
+
cursor: default;
|
|
31
|
+
}
|
|
32
|
+
label {
|
|
33
|
+
display: inline-flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
gap: 8px;
|
|
36
|
+
min-width: 0;
|
|
37
|
+
cursor: pointer;
|
|
38
|
+
user-select: none;
|
|
39
|
+
}
|
|
40
|
+
:host([disabled]) label {
|
|
41
|
+
cursor: default;
|
|
42
|
+
}
|
|
43
|
+
input {
|
|
44
|
+
position: absolute;
|
|
45
|
+
width: 1px;
|
|
46
|
+
height: 1px;
|
|
47
|
+
margin: -1px;
|
|
48
|
+
padding: 0;
|
|
49
|
+
overflow: hidden;
|
|
50
|
+
clip: rect(0 0 0 0);
|
|
51
|
+
white-space: nowrap;
|
|
52
|
+
border: 0;
|
|
53
|
+
}
|
|
54
|
+
.box {
|
|
55
|
+
box-sizing: border-box;
|
|
56
|
+
width: 14px;
|
|
57
|
+
height: 14px;
|
|
58
|
+
flex: 0 0 14px;
|
|
59
|
+
display: inline-grid;
|
|
60
|
+
place-items: center;
|
|
61
|
+
border: 1px solid var(--ge-checkbox-border-color, var(--ge-border-color, #303746));
|
|
62
|
+
border-radius: 3px;
|
|
63
|
+
background: var(--ge-checkbox-bg-color, var(--ge-input-bg-color, #121822));
|
|
64
|
+
color: var(--ge-checkbox-mark-color, var(--ge-selected-text-color, #ffffff));
|
|
65
|
+
transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease;
|
|
66
|
+
}
|
|
67
|
+
input:focus-visible + .box {
|
|
68
|
+
border-color: var(--ge-focus-border-color, #3d6fa8);
|
|
69
|
+
box-shadow: 0 0 0 2px var(--ge-focus-ring-color, rgba(61, 111, 168, 0.28));
|
|
70
|
+
}
|
|
71
|
+
input:checked + .box,
|
|
72
|
+
input:indeterminate + .box {
|
|
73
|
+
border-color: var(--ge-checkbox-checked-border-color, var(--ge-accent-color, #3d6fa8));
|
|
74
|
+
background: var(--ge-checkbox-checked-bg-color, var(--ge-accent-strong-color, #255a91));
|
|
75
|
+
}
|
|
76
|
+
input:disabled + .box {
|
|
77
|
+
cursor: default;
|
|
78
|
+
}
|
|
79
|
+
.box::after {
|
|
80
|
+
content: "";
|
|
81
|
+
width: 7px;
|
|
82
|
+
height: 4px;
|
|
83
|
+
border: solid currentColor;
|
|
84
|
+
border-width: 0 0 2px 2px;
|
|
85
|
+
transform: rotate(-45deg) scale(0);
|
|
86
|
+
transform-origin: center;
|
|
87
|
+
transition: transform 0.12s ease;
|
|
88
|
+
}
|
|
89
|
+
input:checked + .box::after {
|
|
90
|
+
transform: rotate(-45deg) scale(1);
|
|
91
|
+
}
|
|
92
|
+
input:indeterminate + .box::after {
|
|
93
|
+
width: 7px;
|
|
94
|
+
height: 0;
|
|
95
|
+
border-width: 0 0 2px;
|
|
96
|
+
transform: rotate(0deg) scale(1);
|
|
97
|
+
}
|
|
98
|
+
.label {
|
|
99
|
+
min-width: 0;
|
|
100
|
+
overflow: hidden;
|
|
101
|
+
text-overflow: ellipsis;
|
|
102
|
+
white-space: nowrap;
|
|
103
|
+
}
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
const label = document.createElement('label');
|
|
107
|
+
this._input.type = 'checkbox';
|
|
108
|
+
this._box.className = 'box';
|
|
109
|
+
this._box.setAttribute('aria-hidden', 'true');
|
|
110
|
+
this._label.className = 'label';
|
|
111
|
+
this._label.append(this._slot);
|
|
112
|
+
label.append(this._input, this._box, this._label);
|
|
113
|
+
root.append(style, label);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
connectedCallback(): void {
|
|
117
|
+
this._input.addEventListener('change', this._onChange);
|
|
118
|
+
this._sync();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
disconnectedCallback(): void {
|
|
122
|
+
this._input.removeEventListener('change', this._onChange);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
attributeChangedCallback(): void {
|
|
126
|
+
this._sync();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get checked(): boolean {
|
|
130
|
+
return this.hasAttribute('checked');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
set checked(value: boolean) {
|
|
134
|
+
if (value) this.setAttribute('checked', '');
|
|
135
|
+
else this.removeAttribute('checked');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get disabled(): boolean {
|
|
139
|
+
return this.hasAttribute('disabled');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
set disabled(value: boolean) {
|
|
143
|
+
if (value) this.setAttribute('disabled', '');
|
|
144
|
+
else this.removeAttribute('disabled');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
get indeterminate(): boolean {
|
|
148
|
+
return this.hasAttribute('indeterminate');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
set indeterminate(value: boolean) {
|
|
152
|
+
if (value) this.setAttribute('indeterminate', '');
|
|
153
|
+
else this.removeAttribute('indeterminate');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
get label(): string {
|
|
157
|
+
return this.getAttribute('label') ?? '';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
set label(value: string) {
|
|
161
|
+
if (value) this.setAttribute('label', value);
|
|
162
|
+
else this.removeAttribute('label');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
get value(): string {
|
|
166
|
+
return this.getAttribute('value') ?? 'on';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
set value(value: string) {
|
|
170
|
+
this.setAttribute('value', value);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
get name(): string {
|
|
174
|
+
return this.getAttribute('name') ?? '';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
set name(value: string) {
|
|
178
|
+
if (value) this.setAttribute('name', value);
|
|
179
|
+
else this.removeAttribute('name');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private _sync(): void {
|
|
183
|
+
this._input.checked = this.checked;
|
|
184
|
+
this._input.disabled = this.disabled;
|
|
185
|
+
this._input.indeterminate = this.indeterminate;
|
|
186
|
+
this._input.value = this.value;
|
|
187
|
+
this._input.name = this.name;
|
|
188
|
+
|
|
189
|
+
const label = this.getAttribute('label');
|
|
190
|
+
if (label !== null) this._slot.textContent = label;
|
|
191
|
+
else if (this._slot.textContent !== '') this._slot.textContent = '';
|
|
192
|
+
|
|
193
|
+
const ariaLabel = this.getAttribute('aria-label') ?? label ?? '';
|
|
194
|
+
if (ariaLabel) this._input.setAttribute('aria-label', ariaLabel);
|
|
195
|
+
else this._input.removeAttribute('aria-label');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private _onChange = (): void => {
|
|
199
|
+
if (this._input.checked) this.setAttribute('checked', '');
|
|
200
|
+
else this.removeAttribute('checked');
|
|
201
|
+
this.removeAttribute('indeterminate');
|
|
202
|
+
this.dispatchEvent(new CustomEvent<GECheckboxChangeDetail>('checked-change', {
|
|
203
|
+
detail: { checked: this.checked, value: this.value },
|
|
204
|
+
bubbles: true,
|
|
205
|
+
composed: true,
|
|
206
|
+
}));
|
|
207
|
+
this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function defineCheckboxComponents(): void {
|
|
212
|
+
if (!customElements.get('ge-checkbox')) {
|
|
213
|
+
customElements.define('ge-checkbox', GECheckbox);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { GE_MENU_ITEM_STYLES, createMenuItemButton, createMenuSeparator } from './menu-shared.js';
|
|
2
|
+
|
|
3
|
+
export interface GEContextMenuItem {
|
|
4
|
+
label?: string;
|
|
5
|
+
value?: string;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
separator?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface GEContextMenuSelectDetail {
|
|
11
|
+
value: string;
|
|
12
|
+
item: GEContextMenuItem;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class GEContextMenu extends HTMLElement {
|
|
16
|
+
private readonly _root: ShadowRoot;
|
|
17
|
+
private readonly _style = document.createElement('style');
|
|
18
|
+
private readonly _menu = document.createElement('div');
|
|
19
|
+
private _items: GEContextMenuItem[] = [];
|
|
20
|
+
private _documentListenerAbort: AbortController | null = null;
|
|
21
|
+
private _activeIndex = -1;
|
|
22
|
+
|
|
23
|
+
static get observedAttributes(): string[] {
|
|
24
|
+
return ['items', 'open'];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
constructor() {
|
|
28
|
+
super();
|
|
29
|
+
this._root = this.attachShadow({ mode: 'open' });
|
|
30
|
+
this._style.textContent = `
|
|
31
|
+
:host {
|
|
32
|
+
position: fixed;
|
|
33
|
+
z-index: 10000;
|
|
34
|
+
display: none;
|
|
35
|
+
min-width: 132px;
|
|
36
|
+
color: var(--ge-text-color, #d8e2f2);
|
|
37
|
+
font: 12px system-ui, sans-serif;
|
|
38
|
+
}
|
|
39
|
+
:host([open]) {
|
|
40
|
+
display: block;
|
|
41
|
+
}
|
|
42
|
+
.menu {
|
|
43
|
+
box-sizing: border-box;
|
|
44
|
+
min-width: 132px;
|
|
45
|
+
padding: 4px;
|
|
46
|
+
border: 1px solid var(--ge-border-color, #303746);
|
|
47
|
+
border-radius: 4px;
|
|
48
|
+
background: var(--ge-menu-bg-color, var(--ge-surface-elevated-color, #1b2230));
|
|
49
|
+
box-shadow: 0 10px 26px var(--ge-menu-shadow-color, rgba(0, 0, 0, 0.36));
|
|
50
|
+
}
|
|
51
|
+
${GE_MENU_ITEM_STYLES}
|
|
52
|
+
`;
|
|
53
|
+
this._menu.className = 'menu';
|
|
54
|
+
this._menu.setAttribute('role', 'menu');
|
|
55
|
+
this._root.append(this._style, this._menu);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
connectedCallback(): void {
|
|
59
|
+
this._readItemsAttribute();
|
|
60
|
+
this._render();
|
|
61
|
+
this._syncDocumentListeners();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
disconnectedCallback(): void {
|
|
65
|
+
this._detachDocumentListeners();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
attributeChangedCallback(name: string): void {
|
|
69
|
+
if (name === 'items') {
|
|
70
|
+
this._readItemsAttribute();
|
|
71
|
+
this._render();
|
|
72
|
+
}
|
|
73
|
+
if (name === 'open') this._syncDocumentListeners();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get items(): GEContextMenuItem[] {
|
|
77
|
+
return this._items;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
set items(value: GEContextMenuItem[]) {
|
|
81
|
+
this._items = Array.isArray(value) ? value : [];
|
|
82
|
+
this._render();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
openAt(x: number, y: number): void {
|
|
86
|
+
this.setAttribute('open', '');
|
|
87
|
+
this.style.left = '0px';
|
|
88
|
+
this.style.top = '0px';
|
|
89
|
+
const rect = this._menu.getBoundingClientRect();
|
|
90
|
+
const viewport = getVisualViewportRect();
|
|
91
|
+
const left = Math.min(x, viewport.left + viewport.width - rect.width - 4);
|
|
92
|
+
const top = Math.min(y, viewport.top + viewport.height - rect.height - 4);
|
|
93
|
+
this.style.left = `${Math.max(viewport.left + 4, left)}px`;
|
|
94
|
+
this.style.top = `${Math.max(viewport.top + 4, top)}px`;
|
|
95
|
+
this._setActiveIndex(this._firstEnabledIndex(), false);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
close(): void {
|
|
99
|
+
this.removeAttribute('open');
|
|
100
|
+
this._setActiveIndex(-1, false);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private _syncDocumentListeners(): void {
|
|
104
|
+
if (this.hasAttribute('open') && this.isConnected) this._attachDocumentListeners();
|
|
105
|
+
else this._detachDocumentListeners();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private _attachDocumentListeners(): void {
|
|
109
|
+
if (this._documentListenerAbort) return;
|
|
110
|
+
this._documentListenerAbort = new AbortController();
|
|
111
|
+
const { signal } = this._documentListenerAbort;
|
|
112
|
+
document.addEventListener('pointerdown', this._onDocumentPointerDown, { capture: true, signal });
|
|
113
|
+
document.addEventListener('keydown', this._onDocumentKeyDown, { capture: true, signal });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private _detachDocumentListeners(): void {
|
|
117
|
+
this._documentListenerAbort?.abort();
|
|
118
|
+
this._documentListenerAbort = null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private _readItemsAttribute(): void {
|
|
122
|
+
const raw = this.getAttribute('items');
|
|
123
|
+
if (!raw) {
|
|
124
|
+
this._items = [];
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(raw);
|
|
129
|
+
this._items = Array.isArray(parsed) ? parsed : [];
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.warn('Invalid ge-context-menu items attribute.', error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private _render(): void {
|
|
136
|
+
this._menu.replaceChildren();
|
|
137
|
+
this._activeIndex = -1;
|
|
138
|
+
for (const [index, item] of this._items.entries()) {
|
|
139
|
+
if (item.separator) {
|
|
140
|
+
this._menu.append(createMenuSeparator());
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const button = createMenuItemButton(item, { index, role: 'menuitem', tabIndex: -1 });
|
|
144
|
+
button.addEventListener('click', () => {
|
|
145
|
+
this._selectIndex(index);
|
|
146
|
+
});
|
|
147
|
+
this._menu.append(button);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private _onDocumentPointerDown = (event: PointerEvent): void => {
|
|
152
|
+
if (!this.hasAttribute('open')) return;
|
|
153
|
+
const path = event.composedPath();
|
|
154
|
+
if (!path.includes(this)) this.close();
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
private _onDocumentKeyDown = (event: KeyboardEvent): void => {
|
|
158
|
+
if (!this.hasAttribute('open')) return;
|
|
159
|
+
if (event.key === 'Escape') {
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
this.close();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (event.key === 'ArrowDown') {
|
|
165
|
+
event.preventDefault();
|
|
166
|
+
this._setActiveIndex(this._nextEnabledIndex(1), true);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (event.key === 'ArrowUp') {
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
this._setActiveIndex(this._nextEnabledIndex(-1), true);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (event.key === 'Home') {
|
|
175
|
+
event.preventDefault();
|
|
176
|
+
this._setActiveIndex(this._firstEnabledIndex(), true);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (event.key === 'End') {
|
|
180
|
+
event.preventDefault();
|
|
181
|
+
this._setActiveIndex(this._lastEnabledIndex(), true);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
this._selectIndex(this._activeIndex);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
private _selectIndex(index: number): void {
|
|
191
|
+
const item = this._items[index];
|
|
192
|
+
if (!item || item.disabled || item.separator || !item.value) return;
|
|
193
|
+
this.dispatchEvent(new CustomEvent<GEContextMenuSelectDetail>('item-select', {
|
|
194
|
+
detail: { value: item.value, item },
|
|
195
|
+
bubbles: true,
|
|
196
|
+
composed: true,
|
|
197
|
+
}));
|
|
198
|
+
this.close();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private _setActiveIndex(index: number, focus: boolean): void {
|
|
202
|
+
this._activeIndex = index;
|
|
203
|
+
for (const button of this._menu.querySelectorAll<HTMLButtonElement>('button')) {
|
|
204
|
+
const active = Number(button.dataset.index) === index;
|
|
205
|
+
button.classList.toggle('active', active);
|
|
206
|
+
if (active && focus) button.focus();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private _nextEnabledIndex(direction: 1 | -1): number {
|
|
211
|
+
if (!this._items.length) return -1;
|
|
212
|
+
const start = this._activeIndex >= 0 ? this._activeIndex : direction > 0 ? -1 : this._items.length;
|
|
213
|
+
for (let step = 1; step <= this._items.length; step++) {
|
|
214
|
+
const index = (start + direction * step + this._items.length) % this._items.length;
|
|
215
|
+
if (this._isEnabledItem(index)) return index;
|
|
216
|
+
}
|
|
217
|
+
return -1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private _firstEnabledIndex(): number {
|
|
221
|
+
return this._items.findIndex((_, index) => this._isEnabledItem(index));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private _lastEnabledIndex(): number {
|
|
225
|
+
for (let index = this._items.length - 1; index >= 0; index--) {
|
|
226
|
+
if (this._isEnabledItem(index)) return index;
|
|
227
|
+
}
|
|
228
|
+
return -1;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private _isEnabledItem(index: number): boolean {
|
|
232
|
+
const item = this._items[index];
|
|
233
|
+
return Boolean(item && !item.separator && !item.disabled && item.value);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function getVisualViewportRect(): { left: number; top: number; width: number; height: number } {
|
|
238
|
+
const viewport = window.visualViewport;
|
|
239
|
+
return viewport
|
|
240
|
+
? { left: viewport.offsetLeft, top: viewport.offsetTop, width: viewport.width, height: viewport.height }
|
|
241
|
+
: { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function defineContextMenuComponents(): void {
|
|
245
|
+
if (!customElements.get('ge-context-menu')) {
|
|
246
|
+
customElements.define('ge-context-menu', GEContextMenu);
|
|
247
|
+
}
|
|
248
|
+
}
|
package/src/dialog.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
export type GEDialogCloseReason = 'action' | 'backdrop' | 'escape' | 'programmatic';
|
|
2
|
+
|
|
3
|
+
export interface GEDialogCloseDetail {
|
|
4
|
+
readonly reason: GEDialogCloseReason | string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Accessible modal surface with slotted body/footer content and reflected open state. */
|
|
8
|
+
export class GEDialog extends HTMLElement {
|
|
9
|
+
private readonly _dialog = document.createElement('dialog');
|
|
10
|
+
private readonly _title = document.createElement('h2');
|
|
11
|
+
private readonly _closeButton = document.createElement('button');
|
|
12
|
+
private _returnFocus: HTMLElement | null = null;
|
|
13
|
+
private _closeReason: GEDialogCloseDetail['reason'] = 'programmatic';
|
|
14
|
+
|
|
15
|
+
static get observedAttributes(): string[] {
|
|
16
|
+
return ['open', 'heading', 'dismissible'];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
constructor() {
|
|
20
|
+
super();
|
|
21
|
+
const root = this.attachShadow({ mode: 'open' });
|
|
22
|
+
const style = document.createElement('style');
|
|
23
|
+
style.textContent = `
|
|
24
|
+
:host {
|
|
25
|
+
color: var(--ge-text-color, #e6edf7);
|
|
26
|
+
font: 13px system-ui, sans-serif;
|
|
27
|
+
}
|
|
28
|
+
dialog {
|
|
29
|
+
width: min(var(--ge-dialog-width, 460px), calc(100vw - 32px));
|
|
30
|
+
max-height: calc(100vh - 32px);
|
|
31
|
+
margin: auto;
|
|
32
|
+
padding: 0;
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
border: 1px solid var(--ge-border-color, #303746);
|
|
35
|
+
border-radius: var(--ge-dialog-radius, 8px);
|
|
36
|
+
color: inherit;
|
|
37
|
+
background: var(--ge-dialog-bg-color, var(--ge-surface-elevated-color, #171e2a));
|
|
38
|
+
box-shadow: 0 22px 70px var(--ge-dialog-shadow-color, rgba(0, 0, 0, 0.52));
|
|
39
|
+
}
|
|
40
|
+
dialog::backdrop {
|
|
41
|
+
background: var(--ge-dialog-backdrop-color, rgba(3, 7, 12, 0.66));
|
|
42
|
+
backdrop-filter: blur(var(--ge-dialog-backdrop-blur, 2px));
|
|
43
|
+
}
|
|
44
|
+
.header {
|
|
45
|
+
display: flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
min-height: 49px;
|
|
48
|
+
padding: 0 12px 0 16px;
|
|
49
|
+
border-bottom: 1px solid var(--ge-border-color, #303746);
|
|
50
|
+
}
|
|
51
|
+
h2 {
|
|
52
|
+
flex: 1;
|
|
53
|
+
min-width: 0;
|
|
54
|
+
margin: 0;
|
|
55
|
+
overflow: hidden;
|
|
56
|
+
font-size: 14px;
|
|
57
|
+
font-weight: 650;
|
|
58
|
+
text-overflow: ellipsis;
|
|
59
|
+
white-space: nowrap;
|
|
60
|
+
}
|
|
61
|
+
.close {
|
|
62
|
+
display: none;
|
|
63
|
+
width: 28px;
|
|
64
|
+
height: 28px;
|
|
65
|
+
border: 0;
|
|
66
|
+
border-radius: 5px;
|
|
67
|
+
color: var(--ge-secondary-text-color, #8b99ad);
|
|
68
|
+
background: transparent;
|
|
69
|
+
cursor: pointer;
|
|
70
|
+
font: 20px/1 system-ui, sans-serif;
|
|
71
|
+
}
|
|
72
|
+
:host([dismissible]) .close { display: grid; place-items: center; }
|
|
73
|
+
.close:hover { color: inherit; background: var(--ge-hover-bg-color, #242d3b); }
|
|
74
|
+
.body {
|
|
75
|
+
max-height: calc(100vh - 160px);
|
|
76
|
+
overflow: auto;
|
|
77
|
+
padding: var(--ge-dialog-body-padding, 16px);
|
|
78
|
+
color: var(--ge-dialog-body-color, var(--ge-secondary-text-color, #a8b4c5));
|
|
79
|
+
line-height: 1.55;
|
|
80
|
+
}
|
|
81
|
+
.footer {
|
|
82
|
+
display: flex;
|
|
83
|
+
justify-content: flex-end;
|
|
84
|
+
gap: 8px;
|
|
85
|
+
min-height: 54px;
|
|
86
|
+
padding: 10px 14px;
|
|
87
|
+
border-top: 1px solid var(--ge-border-color, #303746);
|
|
88
|
+
background: var(--ge-dialog-footer-bg-color, rgba(9, 13, 19, 0.22));
|
|
89
|
+
}
|
|
90
|
+
slot[name="footer"]::slotted(*) { margin: 0; }
|
|
91
|
+
`;
|
|
92
|
+
const header = document.createElement('header');
|
|
93
|
+
header.className = 'header';
|
|
94
|
+
this._title.id = 'title';
|
|
95
|
+
this._closeButton.className = 'close';
|
|
96
|
+
this._closeButton.type = 'button';
|
|
97
|
+
this._closeButton.setAttribute('aria-label', 'Close');
|
|
98
|
+
this._closeButton.textContent = '×';
|
|
99
|
+
header.append(this._title, this._closeButton);
|
|
100
|
+
|
|
101
|
+
const body = document.createElement('div');
|
|
102
|
+
body.className = 'body';
|
|
103
|
+
body.append(document.createElement('slot'));
|
|
104
|
+
const footer = document.createElement('footer');
|
|
105
|
+
footer.className = 'footer';
|
|
106
|
+
const footerSlot = document.createElement('slot');
|
|
107
|
+
footerSlot.name = 'footer';
|
|
108
|
+
footer.append(footerSlot);
|
|
109
|
+
this._dialog.setAttribute('aria-labelledby', this._title.id);
|
|
110
|
+
this._dialog.append(header, body, footer);
|
|
111
|
+
root.append(style, this._dialog);
|
|
112
|
+
|
|
113
|
+
this._closeButton.addEventListener('click', () => this.close('action'));
|
|
114
|
+
this._dialog.addEventListener('cancel', this._onCancel);
|
|
115
|
+
this._dialog.addEventListener('click', this._onDialogClick);
|
|
116
|
+
this._dialog.addEventListener('close', this._onNativeClose);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
connectedCallback(): void {
|
|
120
|
+
this._syncHeading();
|
|
121
|
+
this._syncOpen();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
attributeChangedCallback(name: string): void {
|
|
125
|
+
if (name === 'heading') this._syncHeading();
|
|
126
|
+
if (name === 'open' && this.isConnected) this._syncOpen();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get open(): boolean { return this.hasAttribute('open'); }
|
|
130
|
+
set open(value: boolean) { this.toggleAttribute('open', value); }
|
|
131
|
+
|
|
132
|
+
get dismissible(): boolean { return this.hasAttribute('dismissible'); }
|
|
133
|
+
set dismissible(value: boolean) { this.toggleAttribute('dismissible', value); }
|
|
134
|
+
|
|
135
|
+
get heading(): string { return this.getAttribute('heading') ?? ''; }
|
|
136
|
+
set heading(value: string) { this.setAttribute('heading', value); }
|
|
137
|
+
|
|
138
|
+
showModal(): void {
|
|
139
|
+
if (this.open && this._dialog.open) return;
|
|
140
|
+
this._returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
141
|
+
this._closeReason = 'programmatic';
|
|
142
|
+
this.open = true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
close(reason: GEDialogCloseDetail['reason'] = 'programmatic'): void {
|
|
146
|
+
if (!this.open && !this._dialog.open) return;
|
|
147
|
+
this._closeReason = reason;
|
|
148
|
+
this.open = false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private _syncHeading(): void {
|
|
152
|
+
this._title.textContent = this.heading || 'Dialog';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private _syncOpen(): void {
|
|
156
|
+
if (this.open && !this._dialog.open) {
|
|
157
|
+
this._returnFocus ??= document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
158
|
+
this._dialog.showModal();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (!this.open && this._dialog.open) this._dialog.close();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private _onCancel = (event: Event): void => {
|
|
165
|
+
event.preventDefault();
|
|
166
|
+
if (!this.dismissible) return;
|
|
167
|
+
const forwarded = new CustomEvent('dialog-cancel', { bubbles: true, composed: true, cancelable: true });
|
|
168
|
+
if (this.dispatchEvent(forwarded)) this.close('escape');
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
private _onDialogClick = (event: MouseEvent): void => {
|
|
172
|
+
if (!this.dismissible || event.target !== this._dialog) return;
|
|
173
|
+
const rect = this._dialog.getBoundingClientRect();
|
|
174
|
+
const inside = event.clientX >= rect.left && event.clientX <= rect.right
|
|
175
|
+
&& event.clientY >= rect.top && event.clientY <= rect.bottom;
|
|
176
|
+
if (!inside) this.close('backdrop');
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
private _onNativeClose = (): void => {
|
|
180
|
+
this.removeAttribute('open');
|
|
181
|
+
const reason = this._closeReason;
|
|
182
|
+
this._closeReason = 'programmatic';
|
|
183
|
+
this.dispatchEvent(new CustomEvent<GEDialogCloseDetail>('dialog-close', {
|
|
184
|
+
detail: { reason },
|
|
185
|
+
bubbles: true,
|
|
186
|
+
composed: true,
|
|
187
|
+
}));
|
|
188
|
+
this._returnFocus?.focus();
|
|
189
|
+
this._returnFocus = null;
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function defineDialogComponents(): void {
|
|
194
|
+
if (!customElements.get('ge-dialog')) customElements.define('ge-dialog', GEDialog);
|
|
195
|
+
}
|