@haiyue/ui 0.1.1 → 0.1.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.
- package/CHANGELOG.md +29 -0
- package/README.md +60 -0
- package/dist/border-beam.js +3 -1
- package/dist/expandable.d.ts +32 -0
- package/dist/expandable.js +136 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/tabs.d.ts +4 -0
- package/dist/tabs.js +80 -16
- package/dist/virtual-list.js +12 -10
- package/package.json +22 -5
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.2 - 2026-09-03
|
|
6
|
+
|
|
7
|
+
- 修复 `<hy-virtual-list>` 快速滚动时 light DOM slot 异步分发旧行的问题;虚拟窗口现在完全在 Shadow DOM 内同步替换,避免上一范围的条目再次闪现。
|
|
8
|
+
- 新增 `HYExpandable` / `<hy-expandable>`:保持内容挂载的非模态展开容器,提供独立子入口、尺寸与按钮位置配置、自定义图标、事件和主题变量。
|
|
9
|
+
- 修复展开容器位于嵌套 Shadow DOM 时 Escape 无法恢复的问题;内部菜单可优先消费 Escape,嵌套展开容器每次只关闭内层。
|
|
10
|
+
- Tabs 支持横向溢出滚动,并保持标签文字不换行。
|
|
11
|
+
- 补充展开容器示例和浏览器交互回归,覆盖键盘、焦点、事件、内容保留及断开重连。
|
|
12
|
+
- API 基线、包体积与消费者验证由 UI 仓库独立维护,不再由 Engine 发布门禁管理。
|
|
13
|
+
|
|
14
|
+
## 0.1.1 - 2026-08-22
|
|
15
|
+
|
|
16
|
+
- 新增可按需导入的 `HYRange` / `<hy-range>` 数值滑块,支持单值和双拖拽点区间、步进与键盘操作、实时/提交事件,以及拖拽点和轨道样式定制。
|
|
17
|
+
- 新增可按需导入的 `HYBorderBeam` / `<hy-border-beam>` 响应式边框流光组件,支持粗细、速度倍率、颜色和多流光错相配置。
|
|
18
|
+
- 新增可按需导入的 `HYNotification` / `<hy-notification>` 通知中心,提供 success/info/warning/error 语义配色和图标、六向 placement、自动关闭、Z 轴堆叠及可选倒计时进度条。
|
|
19
|
+
- 新增可按需导入的 `HYDrawer` / `<hy-drawer>` 抽屉组件,支持四向 placement、可选蒙层、Esc/蒙层关闭、焦点管理及 `destroyOnHidden` 内容卸载。
|
|
20
|
+
- 新增可按需导入的 `HYVirtualList` / `<hy-virtual-list>` 固定行高虚拟列表,仅渲染可视区与 overscan 缓冲项,并提供内部滚动条、范围事件、点击事件和 `scrollToIndex()`。
|
|
21
|
+
- 组件品牌前缀从 `GE/ge` 统一迁移为 `HY/hy`,不保留旧标签、类名或 CSS token alias;新增可独立导入的 Moonlight 淡蓝亮色和 Nightfall 深蓝紫暗色皮肤。
|
|
22
|
+
- 独立 UI 仓库移除重复的 `ui/` workspace 层级,并为全部组件新增 `@haiyue/ui/<component>` 独立、无副作用的公开入口;根入口继续提供显式全量注册。
|
|
23
|
+
|
|
24
|
+
## 0.1.0 - 2026-07-13
|
|
25
|
+
|
|
26
|
+
- 建立海月 3D 优先的稳定包边界、生命周期、编辑器工作流、可观测性、资产管线和脚本 runtime。
|
|
27
|
+
- 用 `simple` / `batched` / `gpu-driven` / `diagnostic` RenderProfile 替换公开渲染布尔开关,并加入显式设备降级报告。
|
|
28
|
+
- 加入原生 glTF metallic-roughness PBR、directional shadow map、environment IBL、material variants 和可扩展材质 shader contract。
|
|
29
|
+
- examples/games 改为 manifest 驱动,统一 build policy、能力覆盖矩阵、真实 WebGPU 像素基线和 release gate。
|
package/README.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
Dependency-free Web Components for HaiyueStudio products. Components own presentation and local interaction;
|
|
4
4
|
they do not depend on Engine, Editor, Games, or AIStudio state.
|
|
5
5
|
|
|
6
|
+
**Online component gallery:** <https://haiyuestudio.github.io/UI/>
|
|
7
|
+
|
|
6
8
|
Public classes use the `HY` prefix and custom-element tags use `hy-`:
|
|
7
9
|
|
|
8
10
|
```html
|
|
@@ -44,6 +46,21 @@ defineHaiyueUI();
|
|
|
44
46
|
Because `defineHaiyueUI()` references the complete component set, prefer focused subpaths in bundle-sensitive
|
|
45
47
|
product entry points.
|
|
46
48
|
|
|
49
|
+
## Tabs
|
|
50
|
+
|
|
51
|
+
`@haiyue/ui/tabs` retains its existing `HYTabs`, `HYTabOption`, `HYTabChangeDetail`, and
|
|
52
|
+
`defineTabsComponents` exports; the 0.1.3 fix adds no new public exports. Assign `options`
|
|
53
|
+
and `value`, provide children with matching `slot` names, and listen for `tab-change`.
|
|
54
|
+
Property-assigned options and the selected value survive reconnection. Set `aria-label`
|
|
55
|
+
to name the tablist. Arrow keys, Home and End both move focus and activate the next
|
|
56
|
+
enabled tab with roving focus; the active tab labels its panel within the component's
|
|
57
|
+
shadow tree. The generated `aria-controls` and tabpanel ids are per-instance opaque
|
|
58
|
+
values and should not be treated as stable application selectors.
|
|
59
|
+
|
|
60
|
+
The existing `@haiyue/ui/border-beam` exports are also unchanged. Its constructor only
|
|
61
|
+
builds shadow content; host appearance is applied after creation, avoiding browser
|
|
62
|
+
custom-element construction errors during repeated `document.createElement` calls.
|
|
63
|
+
|
|
47
64
|
## Virtual list
|
|
48
65
|
|
|
49
66
|
`<hy-virtual-list>` renders only the visible fixed-height rows plus a configurable overscan buffer. The full
|
|
@@ -163,6 +180,8 @@ All theme tokens use the `--hy-` prefix and may be overridden by consuming produ
|
|
|
163
180
|
|
|
164
181
|
## Component examples
|
|
165
182
|
|
|
183
|
+
Browse the published gallery at <https://haiyuestudio.github.io/UI/>.
|
|
184
|
+
|
|
166
185
|
Run the interactive component gallery locally:
|
|
167
186
|
|
|
168
187
|
```bash
|
|
@@ -171,3 +190,44 @@ npm run examples
|
|
|
171
190
|
|
|
172
191
|
Then open <http://localhost:4173/>. The gallery provides a left-side component index and live examples for
|
|
173
192
|
every exported component, including editable parameters and event output.
|
|
193
|
+
|
|
194
|
+
## Expandable container
|
|
195
|
+
|
|
196
|
+
Explicit export: `@haiyue/ui/expandable` → `HYExpandable`, `defineExpandableComponents`,
|
|
197
|
+
`HYExpandableButtonPosition`, `HYExpandableChangeDetail`. Also included in `defineHaiyueUI()`.
|
|
198
|
+
|
|
199
|
+
```html
|
|
200
|
+
<hy-expandable expanded-width="1200px" expanded-height="90dvh"
|
|
201
|
+
button-position="top-right" expand-label="放大" restore-label="还原">
|
|
202
|
+
<div>Your graph, editor, or other stateful content</div>
|
|
203
|
+
<!-- Optional SVG elements: slot="expand-icon" / slot="restore-icon" -->
|
|
204
|
+
</hy-expandable>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`expanded` is a reflected boolean attribute/property; `toggle()` switches it.
|
|
208
|
+
`expandedWidth` / `expandedHeight` accept CSS lengths (defaults `90vw` / `90dvh`).
|
|
209
|
+
Sizes are clamped to the viewport with a margin; the normal size belongs to the host's layout/CSS.
|
|
210
|
+
`buttonPosition` supports `top-right`, `top-left`, `bottom-right`, `bottom-left`.
|
|
211
|
+
`expanded-change` bubbles with `{ expanded: boolean }`. `expand-icon` / `restore-icon` slots
|
|
212
|
+
replace the built-in SVG icons without accepting HTML strings. Labels also become tooltips.
|
|
213
|
+
Style through inherited `--hy-surface-color`, `--hy-border-color`, `--hy-text-color`,
|
|
214
|
+
`--hy-accent-color` and `--hy-expandable-{margin,padding,radius,shadow,button-size,button-offset}`;
|
|
215
|
+
`panel` and `toggle` are CSS parts.
|
|
216
|
+
|
|
217
|
+
Requires a browser supporting the Popover API. Expansion is non-modal and uses a fixed top-layer
|
|
218
|
+
panel so transformed/overflow ancestors cannot clip it. The same slotted DOM stays mounted;
|
|
219
|
+
normal-flow height is preserved while expanded. Keyboard activation uses the native button;
|
|
220
|
+
Escape restores the focused container. Disconnect closes the panel and releases event listeners.
|
|
221
|
+
|
|
222
|
+
## Independent release checks
|
|
223
|
+
|
|
224
|
+
UI releases independently of Engine.
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
npm ci
|
|
228
|
+
npx playwright install chromium
|
|
229
|
+
npm run release:check
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Candidates: `.artifacts/packages/`. Review API changes before `npm run api:update`.
|
|
233
|
+
See [release review](review/release-0.1.2.md).
|
package/dist/border-beam.js
CHANGED
|
@@ -73,6 +73,7 @@ export class HYBorderBeam extends HTMLElement {
|
|
|
73
73
|
if (this._connected)
|
|
74
74
|
return;
|
|
75
75
|
this._connected = true;
|
|
76
|
+
this._updateAppearance();
|
|
76
77
|
if (typeof ResizeObserver !== 'undefined') {
|
|
77
78
|
this._resizeObserver = new ResizeObserver(() => this.refresh());
|
|
78
79
|
this._resizeObserver.observe(this);
|
|
@@ -145,7 +146,8 @@ export class HYBorderBeam extends HTMLElement {
|
|
|
145
146
|
beams.push(beam);
|
|
146
147
|
}
|
|
147
148
|
this._beamLayer.replaceChildren(...beams);
|
|
148
|
-
|
|
149
|
+
// Construction may populate the shadow tree, but must not add host attributes.
|
|
150
|
+
// Host styles are applied by the connection/attribute lifecycle instead.
|
|
149
151
|
}
|
|
150
152
|
_updateAppearance() {
|
|
151
153
|
const duration = BASE_DURATION_SECONDS / this.speed;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type HYExpandableButtonPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
|
|
2
|
+
export interface HYExpandableChangeDetail {
|
|
3
|
+
expanded: boolean;
|
|
4
|
+
}
|
|
5
|
+
/** A non-modal, top-layer expansion of the same slotted content; no cloning or reparenting. */
|
|
6
|
+
export declare class HYExpandable extends HTMLElement {
|
|
7
|
+
static get observedAttributes(): string[];
|
|
8
|
+
private readonly panel;
|
|
9
|
+
private readonly placeholder;
|
|
10
|
+
private readonly button;
|
|
11
|
+
private readonly expandIcon;
|
|
12
|
+
private readonly restoreIcon;
|
|
13
|
+
private lifetime;
|
|
14
|
+
private active;
|
|
15
|
+
constructor();
|
|
16
|
+
get expanded(): boolean;
|
|
17
|
+
set expanded(value: boolean);
|
|
18
|
+
get expandedWidth(): string;
|
|
19
|
+
set expandedWidth(value: string);
|
|
20
|
+
get expandedHeight(): string;
|
|
21
|
+
set expandedHeight(value: string);
|
|
22
|
+
get buttonPosition(): HYExpandableButtonPosition;
|
|
23
|
+
set buttonPosition(value: HYExpandableButtonPosition);
|
|
24
|
+
toggle(): void;
|
|
25
|
+
connectedCallback(): void;
|
|
26
|
+
disconnectedCallback(): void;
|
|
27
|
+
attributeChangedCallback(): void;
|
|
28
|
+
private render;
|
|
29
|
+
private sync;
|
|
30
|
+
private closePanel;
|
|
31
|
+
}
|
|
32
|
+
export declare function defineExpandableComponents(): void;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** A non-modal, top-layer expansion of the same slotted content; no cloning or reparenting. */
|
|
2
|
+
export class HYExpandable extends HTMLElement {
|
|
3
|
+
static get observedAttributes() { return ['expanded', 'expanded-width', 'expanded-height', 'button-position', 'expand-label', 'restore-label']; }
|
|
4
|
+
panel;
|
|
5
|
+
placeholder;
|
|
6
|
+
button;
|
|
7
|
+
expandIcon;
|
|
8
|
+
restoreIcon;
|
|
9
|
+
lifetime;
|
|
10
|
+
active = false;
|
|
11
|
+
constructor() {
|
|
12
|
+
super();
|
|
13
|
+
const root = this.attachShadow({ mode: 'open' });
|
|
14
|
+
const style = document.createElement('style');
|
|
15
|
+
style.textContent = `
|
|
16
|
+
:host { display:block; position:relative; min-width:0; min-height:0; }
|
|
17
|
+
.placeholder { display:none; pointer-events:none; }
|
|
18
|
+
.panel { position:relative; display:grid; grid-template-columns:minmax(0,1fr); grid-template-rows:minmax(0,1fr); width:100%; height:100%; min-width:0; min-height:0; box-sizing:border-box; color:inherit; font:inherit; }
|
|
19
|
+
.panel:popover-open { position:fixed; inset:0; margin:auto; width:var(--_width,90vw); height:var(--_height,90dvh); max-width:calc(100vw - 2 * var(--hy-expandable-margin,16px)); max-height:calc(100dvh - 2 * var(--hy-expandable-margin,16px)); padding:var(--hy-expandable-padding,16px); border:1px solid var(--hy-border-color,#536074); border-radius:var(--hy-expandable-radius,10px); background:var(--hy-surface-color,#18202e); box-shadow:var(--hy-expandable-shadow,0 16px 64px #0006); }
|
|
20
|
+
.panel::backdrop { background:transparent; pointer-events:none; }
|
|
21
|
+
button { position:absolute; z-index:2; top:var(--hy-expandable-button-offset,8px); right:var(--hy-expandable-button-offset,8px); display:grid; place-items:center; width:var(--hy-expandable-button-size,32px); height:var(--hy-expandable-button-size,32px); padding:6px; border:1px solid var(--hy-border-color,#536074); border-radius:6px; color:var(--hy-text-color,inherit); background:var(--hy-surface-color,#18202e); cursor:pointer; }
|
|
22
|
+
button:hover { color:var(--hy-accent-color,#7dd3fc); }
|
|
23
|
+
button:focus-visible { outline:2px solid var(--hy-accent-color,#7dd3fc); outline-offset:2px; }
|
|
24
|
+
button[data-position$="left"] { left:var(--hy-expandable-button-offset,8px); right:auto; }
|
|
25
|
+
button[data-position^="bottom"] { bottom:var(--hy-expandable-button-offset,8px); top:auto; }
|
|
26
|
+
svg, ::slotted([slot="expand-icon"]), ::slotted([slot="restore-icon"]) { width:100%; height:100%; pointer-events:none; }
|
|
27
|
+
slot[hidden] { display:none; }
|
|
28
|
+
`;
|
|
29
|
+
this.placeholder = document.createElement('div');
|
|
30
|
+
this.placeholder.className = 'placeholder';
|
|
31
|
+
this.panel = document.createElement('div');
|
|
32
|
+
this.panel.className = 'panel';
|
|
33
|
+
this.panel.id = 'panel';
|
|
34
|
+
this.panel.part.add('panel');
|
|
35
|
+
this.button = document.createElement('button');
|
|
36
|
+
this.button.type = 'button';
|
|
37
|
+
this.button.part.add('toggle');
|
|
38
|
+
this.button.setAttribute('aria-controls', 'panel');
|
|
39
|
+
const icon = (name, path) => {
|
|
40
|
+
const slot = document.createElement('slot');
|
|
41
|
+
slot.name = name;
|
|
42
|
+
slot.setAttribute('aria-hidden', 'true');
|
|
43
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
44
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
45
|
+
svg.setAttribute('fill', 'none');
|
|
46
|
+
svg.setAttribute('stroke', 'currentColor');
|
|
47
|
+
svg.setAttribute('stroke-width', '1.8');
|
|
48
|
+
const shape = document.createElementNS(svg.namespaceURI, 'path');
|
|
49
|
+
shape.setAttribute('d', path);
|
|
50
|
+
svg.append(shape);
|
|
51
|
+
slot.append(svg);
|
|
52
|
+
return slot;
|
|
53
|
+
};
|
|
54
|
+
this.expandIcon = icon('expand-icon', 'M8 3H3v5m13-5h5v5M3 16v5h5m13-5v5h-5M3 3l6 6m12-6-6 6M3 21l6-6m12 6-6-6');
|
|
55
|
+
this.restoreIcon = icon('restore-icon', 'M3 9h6V3m6 0v6h6M9 21v-6H3m18 0h-6v6M3 3l6 6m12-6-6 6M3 21l6-6m12 6-6-6');
|
|
56
|
+
this.button.append(this.expandIcon, this.restoreIcon);
|
|
57
|
+
this.panel.append(document.createElement('slot'), this.button);
|
|
58
|
+
root.append(style, this.placeholder, this.panel);
|
|
59
|
+
this.button.addEventListener('click', () => this.toggle());
|
|
60
|
+
this.render();
|
|
61
|
+
}
|
|
62
|
+
get expanded() { return this.hasAttribute('expanded'); }
|
|
63
|
+
set expanded(value) { this.toggleAttribute('expanded', Boolean(value)); }
|
|
64
|
+
get expandedWidth() { return this.getAttribute('expanded-width') ?? '90vw'; }
|
|
65
|
+
set expandedWidth(value) { this.setAttribute('expanded-width', value); }
|
|
66
|
+
get expandedHeight() { return this.getAttribute('expanded-height') ?? '90dvh'; }
|
|
67
|
+
set expandedHeight(value) { this.setAttribute('expanded-height', value); }
|
|
68
|
+
get buttonPosition() {
|
|
69
|
+
const value = this.getAttribute('button-position');
|
|
70
|
+
return (['top-left', 'bottom-right', 'bottom-left'].includes(value ?? '') ? value : 'top-right');
|
|
71
|
+
}
|
|
72
|
+
set buttonPosition(value) { this.setAttribute('button-position', value); }
|
|
73
|
+
toggle() { this.expanded = !this.expanded; }
|
|
74
|
+
connectedCallback() {
|
|
75
|
+
this.lifetime?.abort();
|
|
76
|
+
this.lifetime = new AbortController();
|
|
77
|
+
this.addEventListener('keydown', event => {
|
|
78
|
+
// Bubbling follows the composed tree across shadow roots. Child menus and
|
|
79
|
+
// expanded containers consume Escape before it reaches their ancestors.
|
|
80
|
+
if (event.key === 'Escape' && !event.defaultPrevented && this.active) {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
this.expanded = false;
|
|
83
|
+
this.button.focus({ preventScroll: true });
|
|
84
|
+
}
|
|
85
|
+
}, { signal: this.lifetime.signal });
|
|
86
|
+
queueMicrotask(() => { if (this.isConnected)
|
|
87
|
+
this.sync(); });
|
|
88
|
+
}
|
|
89
|
+
disconnectedCallback() {
|
|
90
|
+
this.lifetime?.abort();
|
|
91
|
+
this.lifetime = undefined;
|
|
92
|
+
this.closePanel(); // Attribute remains available if the owner reconnects this element.
|
|
93
|
+
}
|
|
94
|
+
attributeChangedCallback() { this.render(); if (this.isConnected)
|
|
95
|
+
this.sync(); }
|
|
96
|
+
render() {
|
|
97
|
+
for (const [name, value] of [['width', this.expandedWidth], ['height', this.expandedHeight]]) {
|
|
98
|
+
this.panel.style.removeProperty(`--_${name}`);
|
|
99
|
+
if (CSS.supports(name, value))
|
|
100
|
+
this.panel.style.setProperty(`--_${name}`, value);
|
|
101
|
+
}
|
|
102
|
+
this.button.dataset.position = this.buttonPosition;
|
|
103
|
+
this.button.setAttribute('aria-expanded', String(this.expanded));
|
|
104
|
+
const label = this.getAttribute(this.expanded ? 'restore-label' : 'expand-label') ?? (this.expanded ? 'Restore size' : 'Expand');
|
|
105
|
+
this.button.setAttribute('aria-label', label);
|
|
106
|
+
this.button.title = label;
|
|
107
|
+
this.expandIcon.hidden = this.expanded;
|
|
108
|
+
this.restoreIcon.hidden = !this.expanded;
|
|
109
|
+
}
|
|
110
|
+
sync() {
|
|
111
|
+
if (this.expanded === this.active)
|
|
112
|
+
return;
|
|
113
|
+
if (this.expanded) {
|
|
114
|
+
this.placeholder.style.height = `${this.getBoundingClientRect().height}px`;
|
|
115
|
+
this.placeholder.style.display = 'block';
|
|
116
|
+
this.panel.setAttribute('popover', 'manual');
|
|
117
|
+
this.panel.showPopover();
|
|
118
|
+
this.active = true;
|
|
119
|
+
}
|
|
120
|
+
else
|
|
121
|
+
this.closePanel();
|
|
122
|
+
this.dispatchEvent(new CustomEvent('expanded-change', { detail: { expanded: this.active }, bubbles: true, composed: true }));
|
|
123
|
+
}
|
|
124
|
+
closePanel() {
|
|
125
|
+
if (this.panel.matches(':popover-open'))
|
|
126
|
+
this.panel.hidePopover();
|
|
127
|
+
this.panel.removeAttribute('popover');
|
|
128
|
+
this.placeholder.style.display = 'none';
|
|
129
|
+
this.active = false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export function defineExpandableComponents() {
|
|
133
|
+
if (!customElements.get('hy-expandable'))
|
|
134
|
+
customElements.define('hy-expandable', HYExpandable);
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=expandable.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { defineExpandableComponents, HYExpandable } from './expandable.js';
|
|
2
|
+
export type { HYExpandableButtonPosition, HYExpandableChangeDetail } from './expandable.js';
|
|
1
3
|
export { defineButtonComponents, HYButton, } from './button.js';
|
|
2
4
|
export { defineBorderBeamComponents, HYBorderBeam, } from './border-beam.js';
|
|
3
5
|
export { defineTreeComponents, HYTree, HYTreeNode, } from './tree.js';
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { defineExpandableComponents } from './expandable.js';
|
|
2
|
+
export { defineExpandableComponents, HYExpandable } from './expandable.js';
|
|
1
3
|
import { defineButtonComponents } from './button.js';
|
|
2
4
|
import { defineBorderBeamComponents } from './border-beam.js';
|
|
3
5
|
import { defineTreeComponents } from './tree.js';
|
|
@@ -35,6 +37,7 @@ export { defineNotificationComponents, HYNotification, } from './notification.js
|
|
|
35
37
|
export { defineHistoryControlsComponents, HYHistoryControls, } from './history-controls.js';
|
|
36
38
|
export { calculateVirtualListRange, defineVirtualListComponents, HYVirtualList, } from './virtual-list.js';
|
|
37
39
|
export function defineHaiyueUI() {
|
|
40
|
+
defineExpandableComponents();
|
|
38
41
|
defineBorderBeamComponents();
|
|
39
42
|
defineButtonComponents();
|
|
40
43
|
defineTreeComponents();
|
package/dist/tabs.d.ts
CHANGED
|
@@ -11,7 +11,9 @@ export declare class HYTabs extends HTMLElement {
|
|
|
11
11
|
private readonly _style;
|
|
12
12
|
private readonly _tabs;
|
|
13
13
|
private readonly _panels;
|
|
14
|
+
private readonly _instanceId;
|
|
14
15
|
private _options;
|
|
16
|
+
private _pendingFocusValue;
|
|
15
17
|
static get observedAttributes(): string[];
|
|
16
18
|
constructor();
|
|
17
19
|
connectedCallback(): void;
|
|
@@ -23,6 +25,8 @@ export declare class HYTabs extends HTMLElement {
|
|
|
23
25
|
private _syncFromAttributes;
|
|
24
26
|
private _readOptionsAttribute;
|
|
25
27
|
private _render;
|
|
28
|
+
private _focusTab;
|
|
29
|
+
private _resolveValue;
|
|
26
30
|
private _select;
|
|
27
31
|
}
|
|
28
32
|
export declare function defineTabsComponents(): void;
|
package/dist/tabs.js
CHANGED
|
@@ -2,9 +2,11 @@ export class HYTabs extends HTMLElement {
|
|
|
2
2
|
_style = document.createElement('style');
|
|
3
3
|
_tabs = document.createElement('div');
|
|
4
4
|
_panels = document.createElement('div');
|
|
5
|
+
_instanceId = `hy-tabs-${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
|
|
5
6
|
_options = [];
|
|
7
|
+
_pendingFocusValue = null;
|
|
6
8
|
static get observedAttributes() {
|
|
7
|
-
return ['options', 'value'];
|
|
9
|
+
return ['options', 'value', 'aria-label'];
|
|
8
10
|
}
|
|
9
11
|
constructor() {
|
|
10
12
|
super();
|
|
@@ -27,6 +29,9 @@ export class HYTabs extends HTMLElement {
|
|
|
27
29
|
align-items: end;
|
|
28
30
|
gap: 2px;
|
|
29
31
|
min-width: 0;
|
|
32
|
+
overflow-x: auto;
|
|
33
|
+
overflow-y: hidden;
|
|
34
|
+
scrollbar-width: none;
|
|
30
35
|
padding: 4px 6px 0;
|
|
31
36
|
border-bottom: 1px solid var(--hy-border-color, #303746);
|
|
32
37
|
background: var(--hy-tabs-bar-bg-color, var(--hy-menu-bg-color, var(--hy-surface-elevated-color, #1b2230)));
|
|
@@ -34,6 +39,8 @@ export class HYTabs extends HTMLElement {
|
|
|
34
39
|
button {
|
|
35
40
|
height: 28px;
|
|
36
41
|
min-width: 0;
|
|
42
|
+
flex-shrink: 0;
|
|
43
|
+
white-space: nowrap;
|
|
37
44
|
padding: 0 10px;
|
|
38
45
|
border: 1px solid transparent;
|
|
39
46
|
border-bottom: 0;
|
|
@@ -66,6 +73,7 @@ export class HYTabs extends HTMLElement {
|
|
|
66
73
|
}
|
|
67
74
|
`;
|
|
68
75
|
this._tabs.className = 'tabs';
|
|
76
|
+
this._tabs.setAttribute('role', 'tablist');
|
|
69
77
|
this._panels.className = 'panels';
|
|
70
78
|
root.append(this._style, this._tabs, this._panels);
|
|
71
79
|
}
|
|
@@ -74,11 +82,16 @@ export class HYTabs extends HTMLElement {
|
|
|
74
82
|
this._render();
|
|
75
83
|
}
|
|
76
84
|
attributeChangedCallback(name, _oldValue, newValue) {
|
|
85
|
+
if (_oldValue === newValue)
|
|
86
|
+
return;
|
|
77
87
|
if (name === 'options')
|
|
78
88
|
this._readOptionsAttribute();
|
|
79
|
-
if (name === 'value' && newValue === null
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
if (name === 'value' && newValue === null) {
|
|
90
|
+
const value = this._resolveValue();
|
|
91
|
+
if (value) {
|
|
92
|
+
this.setAttribute('value', value);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
82
95
|
}
|
|
83
96
|
this._render();
|
|
84
97
|
}
|
|
@@ -87,9 +100,9 @@ export class HYTabs extends HTMLElement {
|
|
|
87
100
|
}
|
|
88
101
|
set options(value) {
|
|
89
102
|
this._options = Array.isArray(value) ? value : [];
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
const resolved = this._resolveValue();
|
|
104
|
+
if (resolved !== this.value)
|
|
105
|
+
this.value = resolved;
|
|
93
106
|
this._render();
|
|
94
107
|
}
|
|
95
108
|
get value() {
|
|
@@ -97,12 +110,13 @@ export class HYTabs extends HTMLElement {
|
|
|
97
110
|
}
|
|
98
111
|
set value(value) {
|
|
99
112
|
this.setAttribute('value', value);
|
|
100
|
-
this._render();
|
|
101
113
|
}
|
|
102
114
|
_syncFromAttributes() {
|
|
103
|
-
this.
|
|
104
|
-
|
|
105
|
-
|
|
115
|
+
if (this.hasAttribute('options'))
|
|
116
|
+
this._readOptionsAttribute();
|
|
117
|
+
const value = this._resolveValue();
|
|
118
|
+
if (value !== this.value)
|
|
119
|
+
this.setAttribute('value', value);
|
|
106
120
|
}
|
|
107
121
|
_readOptionsAttribute() {
|
|
108
122
|
const raw = this.getAttribute('options');
|
|
@@ -125,25 +139,75 @@ export class HYTabs extends HTMLElement {
|
|
|
125
139
|
}
|
|
126
140
|
}
|
|
127
141
|
_render() {
|
|
142
|
+
const focusedValue = this._pendingFocusValue
|
|
143
|
+
?? [...this._tabs.querySelectorAll('button')].find(button => button === this.shadowRoot?.activeElement)?.dataset.value
|
|
144
|
+
?? null;
|
|
145
|
+
this._pendingFocusValue = null;
|
|
128
146
|
this._tabs.replaceChildren();
|
|
129
147
|
this._panels.replaceChildren();
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
this._tabs.setAttribute('aria-label', this.getAttribute('aria-label') ?? 'Tabs');
|
|
149
|
+
const enabled = this._options.filter(option => !option.disabled);
|
|
150
|
+
const value = this._resolveValue(enabled);
|
|
151
|
+
if (value !== this.value) {
|
|
152
|
+
this.setAttribute('value', value);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const focusValue = enabled.some(option => option.value === value) ? value : enabled[0]?.value;
|
|
156
|
+
this._options.forEach((option, index) => {
|
|
132
157
|
const button = document.createElement('button');
|
|
158
|
+
const panel = document.createElement('div');
|
|
133
159
|
button.type = 'button';
|
|
160
|
+
button.id = `${this._instanceId}-tab-${index}`;
|
|
161
|
+
button.dataset.value = option.value;
|
|
162
|
+
button.setAttribute('role', 'tab');
|
|
163
|
+
button.setAttribute('aria-selected', String(option.value === value));
|
|
164
|
+
panel.id = `${this._instanceId}-panel-${index}`;
|
|
165
|
+
panel.hidden = option.value !== value;
|
|
166
|
+
panel.setAttribute('role', 'tabpanel');
|
|
167
|
+
panel.setAttribute('aria-labelledby', button.id);
|
|
168
|
+
button.setAttribute('aria-controls', panel.id);
|
|
169
|
+
button.tabIndex = option.value === focusValue && !option.disabled ? 0 : -1;
|
|
134
170
|
button.textContent = option.label;
|
|
135
171
|
button.disabled = Boolean(option.disabled);
|
|
136
172
|
button.classList.toggle('active', option.value === value);
|
|
137
173
|
button.addEventListener('click', () => this._select(option.value));
|
|
174
|
+
button.addEventListener('keydown', event => {
|
|
175
|
+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key) || !enabled.length)
|
|
176
|
+
return;
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
const position = enabled.findIndex(item => item.value === option.value);
|
|
179
|
+
const next = event.key === 'Home' ? 0 : event.key === 'End' ? enabled.length - 1
|
|
180
|
+
: (position + (event.key === 'ArrowLeft' ? -1 : 1) + enabled.length) % enabled.length;
|
|
181
|
+
this._pendingFocusValue = enabled[next].value;
|
|
182
|
+
this._select(enabled[next].value);
|
|
183
|
+
});
|
|
184
|
+
const slot = document.createElement('slot');
|
|
185
|
+
slot.name = option.value;
|
|
186
|
+
panel.append(slot);
|
|
138
187
|
this._tabs.append(button);
|
|
188
|
+
this._panels.append(panel);
|
|
189
|
+
});
|
|
190
|
+
if (focusedValue) {
|
|
191
|
+
const restoreValue = enabled.some(option => option.value === focusedValue) ? focusedValue : focusValue;
|
|
192
|
+
if (restoreValue)
|
|
193
|
+
this._focusTab(restoreValue);
|
|
139
194
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
this.
|
|
195
|
+
}
|
|
196
|
+
_focusTab(value) {
|
|
197
|
+
[...this._tabs.querySelectorAll('button')].find(button => button.dataset.value === value)?.focus();
|
|
198
|
+
}
|
|
199
|
+
_resolveValue(enabled = this._options.filter(option => !option.disabled)) {
|
|
200
|
+
if (enabled.some(option => option.value === this.value))
|
|
201
|
+
return this.value;
|
|
202
|
+
if (enabled[0])
|
|
203
|
+
return enabled[0].value;
|
|
204
|
+
return '';
|
|
143
205
|
}
|
|
144
206
|
_select(value) {
|
|
145
207
|
if (value === this.value)
|
|
146
208
|
return;
|
|
209
|
+
if (!this._options.some(option => option.value === value && !option.disabled))
|
|
210
|
+
return;
|
|
147
211
|
this.setAttribute('value', value);
|
|
148
212
|
const option = this._options.find(item => item.value === value) ?? null;
|
|
149
213
|
this.dispatchEvent(new CustomEvent('tab-change', {
|
package/dist/virtual-list.js
CHANGED
|
@@ -8,7 +8,12 @@ export function calculateVirtualListRange(itemCount, itemHeight, viewportHeight,
|
|
|
8
8
|
const count = Math.max(0, Math.floor(finiteNumber(itemCount, 0)));
|
|
9
9
|
const rowHeight = Math.max(1, finiteNumber(itemHeight, DEFAULT_ITEM_HEIGHT));
|
|
10
10
|
const visibleHeight = Math.max(0, finiteNumber(viewportHeight, 0));
|
|
11
|
-
const
|
|
11
|
+
const requestedOffset = Math.max(0, finiteNumber(scrollTop, 0));
|
|
12
|
+
const maximumOffset = Math.max(0, count * rowHeight - visibleHeight);
|
|
13
|
+
// Layout and ResizeObserver notifications can briefly observe a stale
|
|
14
|
+
// scrollTop after the viewport or item count changes. Keep the calculated
|
|
15
|
+
// window inside the collection instead of emitting an empty render range.
|
|
16
|
+
const offset = Math.min(requestedOffset, maximumOffset);
|
|
12
17
|
const buffer = Math.max(0, Math.floor(finiteNumber(overscan, DEFAULT_OVERSCAN)));
|
|
13
18
|
const visibleStartIndex = Math.min(count, Math.floor(offset / rowHeight));
|
|
14
19
|
const visibleEndIndex = Math.min(count, Math.ceil((offset + visibleHeight) / rowHeight));
|
|
@@ -76,7 +81,7 @@ export class HYVirtualList extends HTMLElement {
|
|
|
76
81
|
.viewport::-webkit-scrollbar-thumb:hover { background: var(--hy-accent-color, #79a8ff); }
|
|
77
82
|
.spacer { position: relative; width: 100%; }
|
|
78
83
|
.window { position: absolute; inset: 0 0 auto; will-change: transform; }
|
|
79
|
-
|
|
84
|
+
.hy-virtual-list-item {
|
|
80
85
|
display: flex;
|
|
81
86
|
align-items: center;
|
|
82
87
|
width: 100%;
|
|
@@ -85,7 +90,7 @@ export class HYVirtualList extends HTMLElement {
|
|
|
85
90
|
background: var(--hy-surface-color, #171d27);
|
|
86
91
|
box-sizing: border-box;
|
|
87
92
|
}
|
|
88
|
-
|
|
93
|
+
.hy-virtual-list-item:hover { background: var(--hy-hover-bg-color, #273241); }
|
|
89
94
|
.empty {
|
|
90
95
|
display: grid;
|
|
91
96
|
height: 100%;
|
|
@@ -101,9 +106,6 @@ export class HYVirtualList extends HTMLElement {
|
|
|
101
106
|
this._viewport.setAttribute('role', 'list');
|
|
102
107
|
this._spacer.className = 'spacer';
|
|
103
108
|
this._window.className = 'window';
|
|
104
|
-
const itemSlot = document.createElement('slot');
|
|
105
|
-
itemSlot.name = 'items';
|
|
106
|
-
this._window.append(itemSlot);
|
|
107
109
|
this._spacer.append(this._window);
|
|
108
110
|
this._empty.className = 'empty';
|
|
109
111
|
this._empty.hidden = true;
|
|
@@ -270,7 +272,6 @@ export class HYVirtualList extends HTMLElement {
|
|
|
270
272
|
const item = this._items[index];
|
|
271
273
|
const row = document.createElement('div');
|
|
272
274
|
row.className = 'hy-virtual-list-item';
|
|
273
|
-
row.slot = 'items';
|
|
274
275
|
row.dataset.hyVirtualListGenerated = '';
|
|
275
276
|
row.dataset.index = String(index);
|
|
276
277
|
row.style.height = `${this._itemHeight}px`;
|
|
@@ -310,9 +311,10 @@ export class HYVirtualList extends HTMLElement {
|
|
|
310
311
|
}));
|
|
311
312
|
}
|
|
312
313
|
_replaceRows(rows) {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
314
|
+
// Keep the recycled window entirely inside the shadow tree. Slotted light
|
|
315
|
+
// DOM rows can be redistributed asynchronously while a host application is
|
|
316
|
+
// also updating, which may briefly paint rows from the previous range.
|
|
317
|
+
this._window.replaceChildren(...rows);
|
|
316
318
|
}
|
|
317
319
|
}
|
|
318
320
|
export function defineVirtualListComponents() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haiyue/ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Dependency-free Web Components for HaiyueStudio tools and products.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/HaiyueStudio/UI.git"
|
|
10
10
|
},
|
|
11
|
+
"homepage": "https://haiyuestudio.github.io/UI/",
|
|
11
12
|
"type": "module",
|
|
12
13
|
"main": "dist/index.js",
|
|
13
14
|
"module": "dist/index.js",
|
|
@@ -94,15 +95,22 @@
|
|
|
94
95
|
},
|
|
95
96
|
"./themes/light.css": "./themes/haiyue-light.css",
|
|
96
97
|
"./themes/dark.css": "./themes/haiyue-dark.css",
|
|
97
|
-
"./package.json": "./package.json"
|
|
98
|
+
"./package.json": "./package.json",
|
|
99
|
+
"./expandable": {
|
|
100
|
+
"types": "./dist/expandable.d.ts",
|
|
101
|
+
"import": "./dist/expandable.js"
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
"engines": {
|
|
105
|
+
"node": ">=22"
|
|
98
106
|
},
|
|
99
|
-
"engines": { "node": ">=22" },
|
|
100
107
|
"publishConfig": {
|
|
101
108
|
"access": "public",
|
|
102
109
|
"tag": "latest"
|
|
103
110
|
},
|
|
104
111
|
"files": [
|
|
105
112
|
"README.md",
|
|
113
|
+
"CHANGELOG.md",
|
|
106
114
|
"LICENSE",
|
|
107
115
|
"dist/**/*.js",
|
|
108
116
|
"dist/**/*.d.ts",
|
|
@@ -110,13 +118,22 @@
|
|
|
110
118
|
],
|
|
111
119
|
"scripts": {
|
|
112
120
|
"build": "tsc -p tsconfig.json",
|
|
121
|
+
"build:pages": "npm run build && node scripts/build-pages.mjs",
|
|
122
|
+
"check:pages": "npm run build:pages && node scripts/verify-pages.mjs",
|
|
113
123
|
"examples": "npm run build && node scripts/serve-examples.mjs",
|
|
114
124
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
115
125
|
"test": "npm run build && node --test \"test/**/*.test.mjs\"",
|
|
116
|
-
"prepack": "npm run typecheck && npm test",
|
|
117
|
-
"pack:candidate": "node scripts/pack-candidate.mjs"
|
|
126
|
+
"prepack": "npm run typecheck && npm test && npm run api:check",
|
|
127
|
+
"pack:candidate": "node scripts/pack-candidate.mjs",
|
|
128
|
+
"api:check": "node scripts/api-surface.mjs --check",
|
|
129
|
+
"api:update": "node scripts/api-surface.mjs --write",
|
|
130
|
+
"test:browser": "npm run build && playwright test",
|
|
131
|
+
"verify:package": "npm run prepack && node scripts/verify-package.mjs",
|
|
132
|
+
"release:check": "npm run verify:package && npm run test:browser && npm run check:pages"
|
|
118
133
|
},
|
|
119
134
|
"devDependencies": {
|
|
135
|
+
"@playwright/test": "^1.63.0",
|
|
136
|
+
"rollup": "^4.63.4",
|
|
120
137
|
"typescript": "^5.2.0"
|
|
121
138
|
}
|
|
122
139
|
}
|