@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 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).
@@ -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
- this._updateAppearance();
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 && this._options[0]) {
80
- this.setAttribute('value', this._options[0].value);
81
- return;
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
- if (!this._options.some(item => item.value === this.value)) {
91
- this.value = this._options[0]?.value ?? '';
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._readOptionsAttribute();
104
- if (!this.value && this._options[0])
105
- this.setAttribute('value', this._options[0].value);
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
- const value = this.value || this._options[0]?.value || '';
131
- for (const option of this._options) {
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
- const slot = document.createElement('slot');
141
- slot.name = value;
142
- this._panels.append(slot);
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', {
@@ -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 offset = Math.max(0, finiteNumber(scrollTop, 0));
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
- ::slotted(.hy-virtual-list-item) {
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
- ::slotted(.hy-virtual-list-item:hover) { background: var(--hy-hover-bg-color, #273241); }
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
- for (const row of this.querySelectorAll(':scope > [data-hy-virtual-list-generated]'))
314
- row.remove();
315
- this.append(...rows);
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.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
  }