@haiyue/ui 0.1.1 → 0.2.1
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 +56 -0
- package/README.md +504 -1
- package/dist/adapters/web/controls.d.ts +19 -0
- package/dist/adapters/web/controls.js +284 -0
- package/dist/adapters/web/dom.d.ts +10 -0
- package/dist/adapters/web/dom.js +98 -0
- package/dist/adapters/web/index.d.ts +3 -0
- package/dist/adapters/web/index.js +3 -0
- package/dist/adapters/web/renderer.d.ts +18 -0
- package/dist/adapters/web/renderer.js +545 -0
- package/dist/adapters/web/styles.d.ts +3 -0
- package/dist/adapters/web/styles.js +62 -0
- package/dist/adapters/web/types.d.ts +46 -0
- package/dist/adapters/web/types.js +2 -0
- package/dist/affix.d.ts +35 -0
- package/dist/affix.js +137 -0
- package/dist/badge.d.ts +45 -0
- package/dist/badge.js +168 -0
- package/dist/border-beam.js +3 -1
- package/dist/breadcrumb.d.ts +37 -0
- package/dist/breadcrumb.js +210 -0
- package/dist/button-group.d.ts +12 -0
- package/dist/button-group.js +72 -0
- package/dist/button.d.ts +15 -1
- package/dist/button.js +101 -12
- package/dist/cascader.d.ts +65 -0
- package/dist/cascader.js +479 -0
- package/dist/collapse.d.ts +55 -0
- package/dist/collapse.js +261 -0
- package/dist/core/actions.d.ts +32 -0
- package/dist/core/actions.js +145 -0
- package/dist/core/data.d.ts +4 -0
- package/dist/core/data.js +27 -0
- package/dist/core/index.d.ts +7 -0
- package/dist/core/index.js +6 -0
- package/dist/core/platform-view.d.ts +17 -0
- package/dist/core/platform-view.js +84 -0
- package/dist/core/selection.d.ts +14 -0
- package/dist/core/selection.js +93 -0
- package/dist/core/text.d.ts +31 -0
- package/dist/core/text.js +154 -0
- package/dist/core/types.d.ts +250 -0
- package/dist/core/types.js +2 -0
- package/dist/core/validation.d.ts +24 -0
- package/dist/core/validation.js +238 -0
- package/dist/date-picker.d.ts +60 -0
- package/dist/date-picker.js +355 -0
- package/dist/dialog.d.ts +4 -0
- package/dist/dialog.js +23 -5
- package/dist/divider.d.ts +33 -0
- package/dist/divider.js +113 -0
- package/dist/draggable.d.ts +78 -0
- package/dist/draggable.js +255 -0
- package/dist/expandable.d.ts +32 -0
- package/dist/expandable.js +136 -0
- package/dist/index.d.ts +47 -1
- package/dist/index.js +69 -0
- package/dist/input.d.ts +30 -1
- package/dist/input.js +166 -54
- package/dist/portable/index.d.ts +4 -0
- package/dist/portable/index.js +3 -0
- package/dist/portable/page.d.ts +38 -0
- package/dist/portable/page.js +361 -0
- package/dist/portable/theme.d.ts +19 -0
- package/dist/portable/theme.js +40 -0
- package/dist/portable/tree.d.ts +10 -0
- package/dist/portable/tree.js +96 -0
- package/dist/range.d.ts +3 -0
- package/dist/range.js +16 -5
- package/dist/rate.d.ts +46 -0
- package/dist/rate.js +193 -0
- package/dist/resizable.d.ts +97 -0
- package/dist/resizable.js +426 -0
- package/dist/skeleton.d.ts +41 -0
- package/dist/skeleton.js +141 -0
- package/dist/steps.d.ts +45 -0
- package/dist/steps.js +203 -0
- package/dist/table.d.ts +106 -0
- package/dist/table.js +689 -0
- package/dist/tabs.d.ts +7 -0
- package/dist/tabs.js +90 -16
- package/dist/tag.d.ts +55 -0
- package/dist/tag.js +225 -0
- package/dist/time-picker.d.ts +62 -0
- package/dist/time-picker.js +298 -0
- package/dist/timeline.d.ts +48 -0
- package/dist/timeline.js +201 -0
- package/dist/toast.d.ts +55 -0
- package/dist/toast.js +224 -0
- package/dist/tour.d.ts +89 -0
- package/dist/tour.js +306 -0
- package/dist/virtual-keyboard.d.ts +95 -0
- package/dist/virtual-keyboard.js +432 -0
- package/dist/virtual-list.js +12 -10
- package/dist/virtual-mouse.d.ts +82 -0
- package/dist/virtual-mouse.js +358 -0
- package/dist/watermark.d.ts +44 -0
- package/dist/watermark.js +148 -0
- package/package.json +135 -7
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { HYUIError, hyValidateCapabilities, hyValidateNode, hyValidateProps, snapshotValidatedProps } from '../core/validation.js';
|
|
2
|
+
import { equalData, snapshot } from '../core/data.js';
|
|
3
|
+
import { diff, flatten } from './tree.js';
|
|
4
|
+
const interactive = new Set(['Button', 'Toggle', 'Select', 'Tabs', 'TextInput', 'TextArea', 'Slider', 'PlatformView']);
|
|
5
|
+
/** A synchronous, bounded page reconciler. It owns bindings/rendering, never the application store. */
|
|
6
|
+
export class HYPage {
|
|
7
|
+
options;
|
|
8
|
+
#renderer;
|
|
9
|
+
#capabilities;
|
|
10
|
+
#status = 'new';
|
|
11
|
+
#revision = 0;
|
|
12
|
+
#epoch = 0;
|
|
13
|
+
#depth = 0;
|
|
14
|
+
#committing = false;
|
|
15
|
+
#desired = new Map();
|
|
16
|
+
#rendered = new Map();
|
|
17
|
+
// Undefined means topology may have changed; otherwise only these props
|
|
18
|
+
// need comparison. Keep tree order deterministic across grouped updates.
|
|
19
|
+
#updates;
|
|
20
|
+
#order = new Map();
|
|
21
|
+
#subscriptions = new Set();
|
|
22
|
+
#focused;
|
|
23
|
+
#boundStores = new Set();
|
|
24
|
+
#openers = new Map();
|
|
25
|
+
#cancelTransaction = 0;
|
|
26
|
+
constructor(renderer, options = {}) {
|
|
27
|
+
this.options = options;
|
|
28
|
+
hyValidateCapabilities(renderer.capabilities);
|
|
29
|
+
this.#renderer = renderer;
|
|
30
|
+
this.#capabilities = snapshot(renderer.capabilities);
|
|
31
|
+
}
|
|
32
|
+
get status() { return this.#status; }
|
|
33
|
+
get revision() { return this.#revision; }
|
|
34
|
+
get capabilities() { return this.#capabilities; }
|
|
35
|
+
#assertMutable() {
|
|
36
|
+
if (this.#status === 'disposed' || this.#committing)
|
|
37
|
+
throw new HYUIError('lifecycle', 'Page disposed or inside renderer commit');
|
|
38
|
+
}
|
|
39
|
+
mount(root) {
|
|
40
|
+
if (this.#status !== 'new')
|
|
41
|
+
throw new HYUIError('lifecycle', 'Page may only mount once');
|
|
42
|
+
this.#desired = flatten(hyValidateNode(root, this.#capabilities));
|
|
43
|
+
this.#mountRenderer();
|
|
44
|
+
this.#flush();
|
|
45
|
+
}
|
|
46
|
+
#mountRenderer() {
|
|
47
|
+
const epoch = ++this.#epoch;
|
|
48
|
+
this.#committing = true;
|
|
49
|
+
try {
|
|
50
|
+
this.#renderer.mount(event => { if (epoch === this.#epoch)
|
|
51
|
+
this.#event(event); });
|
|
52
|
+
this.#status = 'mounted';
|
|
53
|
+
}
|
|
54
|
+
catch (cause) {
|
|
55
|
+
this.#fault(cause, 'mount');
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
this.#committing = false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
render(root) {
|
|
62
|
+
this.#assertMutable();
|
|
63
|
+
if (this.#status === 'new')
|
|
64
|
+
throw new HYUIError('lifecycle', 'Mount the page before render');
|
|
65
|
+
this.#desired = flatten(hyValidateNode(root, this.#capabilities));
|
|
66
|
+
this.#updates = undefined;
|
|
67
|
+
this.#flush();
|
|
68
|
+
}
|
|
69
|
+
setProps(key, kind, props) {
|
|
70
|
+
this.#assertMutable();
|
|
71
|
+
const entry = this.#desired.get(key);
|
|
72
|
+
if (!entry || entry.node.kind !== kind)
|
|
73
|
+
throw new HYUIError('invalid-props', 'Binding target missing or kind changed', { key });
|
|
74
|
+
const context = { key, ...(entry.node.source ? { source: entry.node.source } : {}) };
|
|
75
|
+
const accepted = snapshotValidatedProps(kind, props, context, this.#capabilities);
|
|
76
|
+
if (equalData(entry.node.props, props))
|
|
77
|
+
return;
|
|
78
|
+
const next = new Map(this.#desired);
|
|
79
|
+
next.set(key, { ...entry, node: Object.freeze({ ...entry.node, props: accepted }) });
|
|
80
|
+
this.#desired = next;
|
|
81
|
+
this.#updates?.add(key);
|
|
82
|
+
this.#flush();
|
|
83
|
+
}
|
|
84
|
+
transaction(run) {
|
|
85
|
+
this.#assertMutable();
|
|
86
|
+
const before = this.#desired, updates = this.#updates && new Set(this.#updates);
|
|
87
|
+
this.#depth++;
|
|
88
|
+
try {
|
|
89
|
+
const result = run();
|
|
90
|
+
if (result && typeof result.then === 'function')
|
|
91
|
+
throw new HYUIError('lifecycle', 'Page transactions must be synchronous');
|
|
92
|
+
}
|
|
93
|
+
catch (cause) {
|
|
94
|
+
this.#desired = before;
|
|
95
|
+
this.#updates = updates;
|
|
96
|
+
throw cause;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
this.#depth--;
|
|
100
|
+
}
|
|
101
|
+
this.#flush();
|
|
102
|
+
}
|
|
103
|
+
/** One store subscription: a store transaction produces one final page batch across all bindings. */
|
|
104
|
+
bind(store, bindings) {
|
|
105
|
+
this.#assertMutable();
|
|
106
|
+
if (this.#boundStores.has(store))
|
|
107
|
+
throw new HYUIError('lifecycle', 'Group all bindings for one store in a single bind call');
|
|
108
|
+
const definitions = [...bindings];
|
|
109
|
+
if (new Set(definitions.map(b => b.key)).size !== definitions.length)
|
|
110
|
+
throw new HYUIError('duplicate-key', 'Duplicate binding target');
|
|
111
|
+
const select = (state) => definitions.map(b => b.select(state));
|
|
112
|
+
const equal = (a, b) => a.every((p, i) => (definitions[i].equal ?? equalData)(p, b[i]));
|
|
113
|
+
let applied = [];
|
|
114
|
+
const apply = (props) => {
|
|
115
|
+
if (this.#status === 'disposed')
|
|
116
|
+
return;
|
|
117
|
+
this.transaction(() => definitions.forEach((binding, i) => {
|
|
118
|
+
const node = this.#desired.get(binding.key)?.node;
|
|
119
|
+
// A grouped subscription does not make every binding dirty. Reuse a
|
|
120
|
+
// previously validated result only while its target is still unchanged.
|
|
121
|
+
// External render/setProps and failed transactions invalidate this path.
|
|
122
|
+
if (node === applied[i] && node && equalData(node.props, props[i]))
|
|
123
|
+
return;
|
|
124
|
+
this.setProps(binding.key, binding.kind, props[i]);
|
|
125
|
+
}));
|
|
126
|
+
applied = definitions.map(binding => this.#desired.get(binding.key)?.node);
|
|
127
|
+
};
|
|
128
|
+
apply(select(store.getSnapshot()));
|
|
129
|
+
let live = true;
|
|
130
|
+
const unsubscribe = store.subscribe(select, values => { if (live)
|
|
131
|
+
apply(values); }, equal);
|
|
132
|
+
const dispose = () => { if (!live)
|
|
133
|
+
return; live = false; this.#subscriptions.delete(dispose); this.#boundStores.delete(store); unsubscribe(); };
|
|
134
|
+
this.#boundStores.add(store);
|
|
135
|
+
this.#subscriptions.add(dispose);
|
|
136
|
+
// Also captures synchronous changes caused by mount/update hooks before subscription began.
|
|
137
|
+
try {
|
|
138
|
+
apply(select(store.getSnapshot()));
|
|
139
|
+
}
|
|
140
|
+
catch (cause) {
|
|
141
|
+
dispose();
|
|
142
|
+
throw cause;
|
|
143
|
+
}
|
|
144
|
+
return dispose;
|
|
145
|
+
}
|
|
146
|
+
#fault(cause, operation, key) {
|
|
147
|
+
this.#status = 'faulted';
|
|
148
|
+
const context = cause instanceof HYUIError ? cause.context : {};
|
|
149
|
+
throw new HYUIError('renderer', 'Renderer failed; rebuild required', {
|
|
150
|
+
key: key ?? this.#desired.keys().next().value ?? '<root>', operation, ...context,
|
|
151
|
+
revision: operation === 'commit' || operation === 'mount' ? this.#revision + 1 : this.#revision,
|
|
152
|
+
}, cause);
|
|
153
|
+
}
|
|
154
|
+
#flush() {
|
|
155
|
+
if (this.#depth || this.#status !== 'mounted' || this.#committing)
|
|
156
|
+
return;
|
|
157
|
+
const next = this.#desired, previous = this.#rendered;
|
|
158
|
+
if (next === previous)
|
|
159
|
+
return;
|
|
160
|
+
const topology = this.#updates === undefined;
|
|
161
|
+
const mutations = topology ? diff(previous, next)
|
|
162
|
+
: Object.freeze([...this.#updates].sort((a, b) => this.#order.get(a) - this.#order.get(b)).flatMap(key => {
|
|
163
|
+
const node = next.get(key).node;
|
|
164
|
+
return equalData(previous.get(key).node.props, node.props) ? [] : [Object.freeze({ type: 'update', node })];
|
|
165
|
+
}));
|
|
166
|
+
const focusChanged = topology || mutations.some(m => {
|
|
167
|
+
if (m.type !== 'update')
|
|
168
|
+
return true;
|
|
169
|
+
const a = previous.get(m.node.key).node.props, b = m.node.props;
|
|
170
|
+
return a.visible !== b.visible || ('disabled' in a && a.disabled) !== ('disabled' in b && b.disabled)
|
|
171
|
+
|| ('busy' in a && a.busy) !== ('busy' in b && b.busy)
|
|
172
|
+
|| (m.node.kind === 'Dialog' && ('open' in a && a.open) !== m.node.props.open)
|
|
173
|
+
|| (m.node.kind === 'Tabs' && ('value' in a && a.value) !== m.node.props.value);
|
|
174
|
+
});
|
|
175
|
+
if (!mutations.length) {
|
|
176
|
+
this.#rendered = next;
|
|
177
|
+
this.#updates = new Set();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
this.#committing = true;
|
|
181
|
+
try {
|
|
182
|
+
this.#renderer.commit(this.#revision + 1, mutations);
|
|
183
|
+
}
|
|
184
|
+
catch (cause) {
|
|
185
|
+
this.#fault(cause, 'commit');
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
this.#committing = false;
|
|
189
|
+
}
|
|
190
|
+
this.#rendered = next;
|
|
191
|
+
this.#revision++;
|
|
192
|
+
this.#updates = new Set();
|
|
193
|
+
if (topology)
|
|
194
|
+
this.#order = new Map([...next.keys()].map((key, index) => [key, index]));
|
|
195
|
+
if (focusChanged)
|
|
196
|
+
this.#restoreFocus(previous);
|
|
197
|
+
}
|
|
198
|
+
#available(key, tree = this.#rendered) {
|
|
199
|
+
if (!key)
|
|
200
|
+
return false;
|
|
201
|
+
let entry = tree.get(key);
|
|
202
|
+
if (!entry)
|
|
203
|
+
return false;
|
|
204
|
+
while (entry) {
|
|
205
|
+
const p = entry.node.props;
|
|
206
|
+
if (p.visible === false || ('disabled' in p && p.disabled) || ('busy' in p && p.busy) || (entry.node.kind === 'Dialog' && !entry.node.props.open))
|
|
207
|
+
return false;
|
|
208
|
+
const parent = entry.parent === null ? undefined : tree.get(entry.parent);
|
|
209
|
+
if (parent?.node.kind === 'Tabs' && entry.slot !== parent.node.props.value)
|
|
210
|
+
return false;
|
|
211
|
+
entry = parent;
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
#dialogs(tree) {
|
|
216
|
+
return [...tree].filter(([key, entry]) => entry.node.kind === 'Dialog' && entry.node.props.open && this.#available(key, tree)).map(([key]) => key);
|
|
217
|
+
}
|
|
218
|
+
#inside(key, parent) { return key === parent || key.startsWith(`${parent}/`); }
|
|
219
|
+
#dialogTarget(key) {
|
|
220
|
+
return [...this.#rendered].find(([id, entry]) => this.#inside(id, key) && interactive.has(entry.node.kind) && this.#available(id))?.[0] ?? key;
|
|
221
|
+
}
|
|
222
|
+
#restoreFocus(previous) {
|
|
223
|
+
const oldDialogs = this.#dialogs(previous), dialogs = this.#dialogs(this.#rendered);
|
|
224
|
+
const newDialogs = dialogs.filter(key => !oldDialogs.includes(key));
|
|
225
|
+
for (const key of newDialogs)
|
|
226
|
+
this.#openers.set(key, this.#focused);
|
|
227
|
+
const top = dialogs.at(-1);
|
|
228
|
+
const closed = oldDialogs.filter(key => !dialogs.includes(key));
|
|
229
|
+
if (newDialogs.length || (top && (!this.#focused || !this.#inside(this.#focused, top))))
|
|
230
|
+
this.focus(this.#dialogTarget(top));
|
|
231
|
+
else if (this.#focused && !this.#available(this.#focused)) {
|
|
232
|
+
const opener = closed.slice().reverse().map(key => this.#openers.get(key)).find(key => this.#available(key));
|
|
233
|
+
const target = top ? this.#dialogTarget(top) : opener ?? (this.#available(this.options.defaultFocus) ? this.options.defaultFocus : this.#rendered.keys().next().value);
|
|
234
|
+
if (target)
|
|
235
|
+
this.focus(target);
|
|
236
|
+
else
|
|
237
|
+
this.#focused = undefined;
|
|
238
|
+
}
|
|
239
|
+
for (const key of closed)
|
|
240
|
+
this.#openers.delete(key);
|
|
241
|
+
}
|
|
242
|
+
focus(key) {
|
|
243
|
+
if (this.#status !== 'mounted' || this.#committing || !this.#available(key))
|
|
244
|
+
return false;
|
|
245
|
+
const top = this.#dialogs(this.#rendered).at(-1);
|
|
246
|
+
if (top && !this.#inside(key, top))
|
|
247
|
+
return false;
|
|
248
|
+
this.#committing = true;
|
|
249
|
+
try {
|
|
250
|
+
this.#renderer.focus(key);
|
|
251
|
+
this.#focused = key;
|
|
252
|
+
}
|
|
253
|
+
catch (cause) {
|
|
254
|
+
this.#fault(cause, 'focus', key);
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
this.#committing = false;
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
#event(event) {
|
|
262
|
+
if (this.#committing || this.#status !== 'mounted' || !this.#available(event.key))
|
|
263
|
+
return;
|
|
264
|
+
const top = this.#dialogs(this.#rendered).at(-1);
|
|
265
|
+
if (top && !this.#inside(event.key, top))
|
|
266
|
+
return;
|
|
267
|
+
if (event.type === 'focus')
|
|
268
|
+
this.#focused = event.key;
|
|
269
|
+
this.options.onEvent?.(Object.freeze({ ...event }));
|
|
270
|
+
}
|
|
271
|
+
/** The shell chooses this single system-back owner. A modal consumes even a non-dismissible back. */
|
|
272
|
+
back(reason = 'keyboard') {
|
|
273
|
+
if (this.#status !== 'mounted' || this.#committing)
|
|
274
|
+
return false;
|
|
275
|
+
const key = this.#dialogs(this.#rendered).at(-1);
|
|
276
|
+
if (!key)
|
|
277
|
+
return false;
|
|
278
|
+
const node = this.#rendered.get(key).node;
|
|
279
|
+
if (node.kind === 'Dialog' && node.props.dismissible !== false)
|
|
280
|
+
this.#event({ key, type: 'cancel', reason, transaction: ++this.#cancelTransaction });
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
suspend() {
|
|
284
|
+
if (this.#status !== 'mounted' || this.#committing)
|
|
285
|
+
return;
|
|
286
|
+
this.#status = 'suspended';
|
|
287
|
+
try {
|
|
288
|
+
this.#renderer.suspend();
|
|
289
|
+
}
|
|
290
|
+
catch (cause) {
|
|
291
|
+
this.#fault(cause, 'suspend');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
resume() {
|
|
295
|
+
if (this.#status !== 'suspended')
|
|
296
|
+
return;
|
|
297
|
+
try {
|
|
298
|
+
this.#renderer.resume();
|
|
299
|
+
}
|
|
300
|
+
catch (cause) {
|
|
301
|
+
this.#fault(cause, 'resume');
|
|
302
|
+
}
|
|
303
|
+
this.#status = 'mounted';
|
|
304
|
+
this.#flush();
|
|
305
|
+
}
|
|
306
|
+
/** Replaces the failed backend, preserving latest requested data and existing store bindings. */
|
|
307
|
+
rebuild(renderer) {
|
|
308
|
+
if (this.#status !== 'faulted')
|
|
309
|
+
throw new HYUIError('lifecycle', 'Only a faulted page may rebuild');
|
|
310
|
+
if (renderer === this.#renderer)
|
|
311
|
+
throw new HYUIError('lifecycle', 'Rebuild needs a fresh renderer');
|
|
312
|
+
hyValidateCapabilities(renderer.capabilities);
|
|
313
|
+
for (const [key, entry] of this.#desired) {
|
|
314
|
+
hyValidateProps(entry.node.kind, entry.node.props, { key }, renderer.capabilities);
|
|
315
|
+
for (const feature of entry.node.requires ?? [])
|
|
316
|
+
if (!renderer.capabilities.features.includes(feature))
|
|
317
|
+
throw new HYUIError('capability', `Missing capability ${feature}`, { key });
|
|
318
|
+
}
|
|
319
|
+
++this.#epoch;
|
|
320
|
+
this.#renderer.dispose();
|
|
321
|
+
this.#renderer = renderer;
|
|
322
|
+
this.#capabilities = snapshot(renderer.capabilities);
|
|
323
|
+
this.#rendered = new Map();
|
|
324
|
+
this.#updates = undefined;
|
|
325
|
+
this.#order.clear();
|
|
326
|
+
this.#focused = undefined;
|
|
327
|
+
this.#openers.clear();
|
|
328
|
+
this.#mountRenderer();
|
|
329
|
+
this.#flush();
|
|
330
|
+
}
|
|
331
|
+
dispose() {
|
|
332
|
+
if (this.#status === 'disposed')
|
|
333
|
+
return;
|
|
334
|
+
this.#status = 'disposed';
|
|
335
|
+
++this.#epoch;
|
|
336
|
+
const errors = [];
|
|
337
|
+
for (const unsubscribe of this.#subscriptions)
|
|
338
|
+
try {
|
|
339
|
+
unsubscribe();
|
|
340
|
+
}
|
|
341
|
+
catch (cause) {
|
|
342
|
+
errors.push(cause);
|
|
343
|
+
}
|
|
344
|
+
this.#subscriptions.clear();
|
|
345
|
+
try {
|
|
346
|
+
this.#renderer.dispose();
|
|
347
|
+
}
|
|
348
|
+
catch (cause) {
|
|
349
|
+
errors.push(cause);
|
|
350
|
+
}
|
|
351
|
+
this.#updates = undefined;
|
|
352
|
+
this.#order.clear();
|
|
353
|
+
this.#desired = new Map();
|
|
354
|
+
this.#rendered = new Map();
|
|
355
|
+
this.#openers.clear();
|
|
356
|
+
this.#focused = undefined;
|
|
357
|
+
if (errors.length)
|
|
358
|
+
throw new AggregateError(errors, 'Page cleanup failed');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
//# sourceMappingURL=page.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { HYTheme } from '../core/types.js';
|
|
2
|
+
export declare const HY_THEME_TOKENS: Readonly<{
|
|
3
|
+
readonly text: "--hy-text-color";
|
|
4
|
+
readonly muted: "--hy-secondary-text-color";
|
|
5
|
+
readonly surface: "--hy-surface-color";
|
|
6
|
+
readonly input: "--hy-input-bg-color";
|
|
7
|
+
readonly accent: "--hy-accent-color";
|
|
8
|
+
readonly danger: "--hy-invalid-border-color";
|
|
9
|
+
readonly border: "--hy-border-color";
|
|
10
|
+
readonly focus: "--hy-focus-ring-color";
|
|
11
|
+
readonly fontSize: "--hy-font-size";
|
|
12
|
+
readonly lineHeight: "--hy-line-height";
|
|
13
|
+
readonly gap: "--hy-gap";
|
|
14
|
+
readonly radius: "--hy-radius";
|
|
15
|
+
}>;
|
|
16
|
+
export type HYThemeVariables = Readonly<Record<(typeof HY_THEME_TOKENS)[keyof typeof HY_THEME_TOKENS], string>>;
|
|
17
|
+
export declare function hyNativeTheme(theme: HYTheme, fontScale?: number): HYTheme;
|
|
18
|
+
/** Returns data for an adapter to apply; importing it never creates a stylesheet. */
|
|
19
|
+
export declare function hyThemeVariables(theme: HYTheme, fontScale?: number): HYThemeVariables;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { HYUIError } from '../core/validation.js';
|
|
2
|
+
import { snapshot } from '../core/data.js';
|
|
3
|
+
export const HY_THEME_TOKENS = Object.freeze({
|
|
4
|
+
text: '--hy-text-color', muted: '--hy-secondary-text-color', surface: '--hy-surface-color', input: '--hy-input-bg-color',
|
|
5
|
+
accent: '--hy-accent-color', danger: '--hy-invalid-border-color', border: '--hy-border-color', focus: '--hy-focus-ring-color',
|
|
6
|
+
fontSize: '--hy-font-size', lineHeight: '--hy-line-height', gap: '--hy-gap', radius: '--hy-radius',
|
|
7
|
+
});
|
|
8
|
+
export function hyNativeTheme(theme, fontScale = 1) {
|
|
9
|
+
const bad = (property) => { throw new HYUIError('invalid-props', 'Invalid portable theme value', { property }); };
|
|
10
|
+
if (!theme || typeof theme !== 'object')
|
|
11
|
+
bad('theme');
|
|
12
|
+
for (const key of Object.keys(theme))
|
|
13
|
+
if (!['color', 'fontSize', 'lineHeight', 'gap', 'radius'].includes(key))
|
|
14
|
+
bad(key);
|
|
15
|
+
if (!Number.isFinite(fontScale) || fontScale <= 0)
|
|
16
|
+
bad('fontScale');
|
|
17
|
+
for (const key of ['fontSize', 'lineHeight', 'gap', 'radius']) {
|
|
18
|
+
if (!Number.isFinite(theme[key]) || theme[key] < 0 || ((key === 'fontSize' || key === 'lineHeight') && theme[key] === 0))
|
|
19
|
+
bad(key);
|
|
20
|
+
}
|
|
21
|
+
for (const key of ['text', 'muted', 'surface', 'input', 'accent', 'danger', 'border', 'focus']) {
|
|
22
|
+
if (typeof theme.color?.[key] !== 'string' || !/^#[\da-f]{6}([\da-f]{2})?$/i.test(theme.color[key]))
|
|
23
|
+
bad(`color.${key}`);
|
|
24
|
+
}
|
|
25
|
+
for (const key of Object.keys(theme.color))
|
|
26
|
+
if (!['text', 'muted', 'surface', 'input', 'accent', 'danger', 'border', 'focus'].includes(key))
|
|
27
|
+
bad(`color.${key}`);
|
|
28
|
+
if (!Number.isFinite(theme.fontSize * fontScale) || !Number.isFinite(theme.lineHeight * fontScale))
|
|
29
|
+
bad('fontScale');
|
|
30
|
+
return snapshot({ color: { ...theme.color }, fontSize: theme.fontSize * fontScale, lineHeight: theme.lineHeight * fontScale, gap: theme.gap, radius: theme.radius });
|
|
31
|
+
}
|
|
32
|
+
/** Returns data for an adapter to apply; importing it never creates a stylesheet. */
|
|
33
|
+
export function hyThemeVariables(theme, fontScale = 1) {
|
|
34
|
+
const data = hyNativeTheme(theme, fontScale);
|
|
35
|
+
return Object.freeze(Object.fromEntries([
|
|
36
|
+
...Object.entries(data.color).map(([key, value]) => [HY_THEME_TOKENS[key], value]),
|
|
37
|
+
...['fontSize', 'lineHeight', 'gap', 'radius'].map(key => [HY_THEME_TOKENS[key], `${data[key]}px`]),
|
|
38
|
+
]));
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=theme.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { HYMutation, HYNode } from '../core/types.js';
|
|
2
|
+
export interface Entry {
|
|
3
|
+
readonly node: HYNode;
|
|
4
|
+
readonly parent: string | null;
|
|
5
|
+
readonly slot?: string;
|
|
6
|
+
}
|
|
7
|
+
export type Tree = ReadonlyMap<string, Entry>;
|
|
8
|
+
export declare function flatten(root: HYNode): Tree;
|
|
9
|
+
/** Returns a deterministic batch. Removing an ancestor releases descendants exactly once. */
|
|
10
|
+
export declare function diff(previous: Tree, next: Tree): readonly HYMutation[];
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { hyKey } from '../core/validation.js';
|
|
2
|
+
import { equalData } from '../core/data.js';
|
|
3
|
+
export function flatten(root) {
|
|
4
|
+
const entries = new Map();
|
|
5
|
+
const walk = (node, path, parent, slot) => {
|
|
6
|
+
const nextPath = [...path, node.key], key = hyKey(...nextPath);
|
|
7
|
+
const { children, slots, ...shallow } = node;
|
|
8
|
+
entries.set(key, { node: Object.freeze({ ...shallow, key }), parent, ...(slot === undefined ? {} : { slot }) });
|
|
9
|
+
for (const child of children ?? [])
|
|
10
|
+
walk(child, nextPath, key);
|
|
11
|
+
for (const [name, list] of Object.entries(slots ?? {}))
|
|
12
|
+
for (const child of list)
|
|
13
|
+
walk(child, nextPath, key, name);
|
|
14
|
+
};
|
|
15
|
+
walk(root, [], null);
|
|
16
|
+
return entries;
|
|
17
|
+
}
|
|
18
|
+
function group(entry) { return JSON.stringify([entry.parent, entry.slot ?? null]); }
|
|
19
|
+
function groups(tree) {
|
|
20
|
+
const result = new Map();
|
|
21
|
+
for (const [key, entry] of tree) {
|
|
22
|
+
const id = group(entry);
|
|
23
|
+
if (!result.has(id))
|
|
24
|
+
result.set(id, []);
|
|
25
|
+
result.get(id).push(key);
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
/** Returns a deterministic batch. Removing an ancestor releases descendants exactly once. */
|
|
30
|
+
export function diff(previous, next) {
|
|
31
|
+
// Bound props preserve topology. Avoid building/serializing sibling groups
|
|
32
|
+
// for these updates; keep the general reconciler for insert/move/remove.
|
|
33
|
+
if (previous.size === next.size) {
|
|
34
|
+
const entries = previous.entries(), updates = [];
|
|
35
|
+
let sameStructure = true;
|
|
36
|
+
for (const [key, entry] of next) {
|
|
37
|
+
const [oldKey, old] = entries.next().value;
|
|
38
|
+
if (oldKey !== key || old.parent !== entry.parent || old.slot !== entry.slot || old.node.kind !== entry.node.kind) {
|
|
39
|
+
sameStructure = false;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
if (old !== entry && !equalData(old.node.props, entry.node.props))
|
|
43
|
+
updates.push(Object.freeze({ type: 'update', node: entry.node }));
|
|
44
|
+
}
|
|
45
|
+
if (sameStructure)
|
|
46
|
+
return Object.freeze(updates);
|
|
47
|
+
}
|
|
48
|
+
const mutations = [];
|
|
49
|
+
const removed = new Set();
|
|
50
|
+
for (const [key, old] of previous) {
|
|
51
|
+
if ((old.parent !== null && removed.has(old.parent)) || !next.has(key) || next.get(key).node.kind !== old.node.kind)
|
|
52
|
+
removed.add(key);
|
|
53
|
+
}
|
|
54
|
+
for (const [key, old] of previous)
|
|
55
|
+
if (removed.has(key) && (old.parent === null || !removed.has(old.parent)))
|
|
56
|
+
mutations.push({ type: 'remove', key });
|
|
57
|
+
const created = new Set();
|
|
58
|
+
for (const [key, entry] of next) {
|
|
59
|
+
const old = previous.get(key);
|
|
60
|
+
if (!old || removed.has(key)) {
|
|
61
|
+
created.add(key);
|
|
62
|
+
mutations.push({ type: 'create', node: entry.node });
|
|
63
|
+
}
|
|
64
|
+
else if (!equalData(old.node.props, entry.node.props))
|
|
65
|
+
mutations.push({ type: 'update', node: entry.node });
|
|
66
|
+
}
|
|
67
|
+
const oldGroups = groups(new Map([...previous].filter(([key]) => !removed.has(key))));
|
|
68
|
+
// Drop cross-slot entries from their old location before placing them in their new group.
|
|
69
|
+
for (const list of oldGroups.values())
|
|
70
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
71
|
+
const key = list[i];
|
|
72
|
+
if (next.has(key) && group(previous.get(key)) !== group(next.get(key)))
|
|
73
|
+
list.splice(i, 1);
|
|
74
|
+
}
|
|
75
|
+
for (const [id, ordered] of groups(next)) {
|
|
76
|
+
const current = oldGroups.get(id) ?? [];
|
|
77
|
+
// The common props-only path never searches sibling positions.
|
|
78
|
+
if (current.length === ordered.length && current.every((key, index) => key === ordered[index]))
|
|
79
|
+
continue;
|
|
80
|
+
let before = null;
|
|
81
|
+
for (let i = ordered.length - 1; i >= 0; i--) {
|
|
82
|
+
const key = ordered[i], entry = next.get(key);
|
|
83
|
+
const at = current.indexOf(key), successor = at < 0 ? undefined : (current[at + 1] ?? null);
|
|
84
|
+
if (at < 0 || successor !== before) {
|
|
85
|
+
mutations.push({ type: created.has(key) ? 'insert' : 'move', key, parent: entry.parent, before,
|
|
86
|
+
...(entry.slot === undefined ? {} : { slot: entry.slot }) });
|
|
87
|
+
if (at >= 0)
|
|
88
|
+
current.splice(at, 1);
|
|
89
|
+
current.splice(before === null ? current.length : current.indexOf(before), 0, key);
|
|
90
|
+
}
|
|
91
|
+
before = key;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return Object.freeze(mutations.map(mutation => Object.freeze(mutation)));
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=tree.js.map
|
package/dist/range.d.ts
CHANGED
|
@@ -32,6 +32,9 @@ export declare class HYRange extends HTMLElement {
|
|
|
32
32
|
set step(value: number);
|
|
33
33
|
get value(): number;
|
|
34
34
|
set value(value: number);
|
|
35
|
+
get controlled(): boolean;
|
|
36
|
+
set controlled(value: boolean);
|
|
37
|
+
private _acceptedValue;
|
|
35
38
|
get range(): boolean;
|
|
36
39
|
set range(value: boolean);
|
|
37
40
|
get lowerValue(): number;
|
package/dist/range.js
CHANGED
|
@@ -17,7 +17,7 @@ export class HYRange extends HTMLElement {
|
|
|
17
17
|
static get observedAttributes() {
|
|
18
18
|
return [
|
|
19
19
|
'min', 'max', 'step', 'value', 'range', 'lower-value', 'upper-value',
|
|
20
|
-
'disabled', 'show-value', 'aria-label',
|
|
20
|
+
'disabled', 'show-value', 'aria-label', 'controlled',
|
|
21
21
|
];
|
|
22
22
|
}
|
|
23
23
|
constructor() {
|
|
@@ -147,11 +147,11 @@ export class HYRange extends HTMLElement {
|
|
|
147
147
|
set min(value) { this.setAttribute('min', String(finiteOr(value, DEFAULT_MIN))); }
|
|
148
148
|
get max() {
|
|
149
149
|
const value = Number(this.getAttribute('max'));
|
|
150
|
-
return Number.isFinite(value) && value > this.min ? value : Math.max(DEFAULT_MAX, this.min + DEFAULT_MAX);
|
|
150
|
+
return Number.isFinite(value) && (value > this.min || (this.controlled && value === this.min)) ? value : Math.max(DEFAULT_MAX, this.min + DEFAULT_MAX);
|
|
151
151
|
}
|
|
152
152
|
set max(value) {
|
|
153
153
|
const next = finiteOr(value, DEFAULT_MAX);
|
|
154
|
-
this.setAttribute('max', String(next > this.min ? next : this.min + DEFAULT_MAX));
|
|
154
|
+
this.setAttribute('max', String(next > this.min || (this.controlled && next === this.min) ? next : this.min + DEFAULT_MAX));
|
|
155
155
|
}
|
|
156
156
|
get step() {
|
|
157
157
|
const value = Number(this.getAttribute('step'));
|
|
@@ -161,8 +161,13 @@ export class HYRange extends HTMLElement {
|
|
|
161
161
|
const next = finiteOr(value, DEFAULT_STEP);
|
|
162
162
|
this.setAttribute('step', String(next > 0 ? next : DEFAULT_STEP));
|
|
163
163
|
}
|
|
164
|
-
get value() { return this.
|
|
165
|
-
set value(value) { this.setAttribute('value', String(this.
|
|
164
|
+
get value() { return this._acceptedValue(Number(this.getAttribute('value') ?? this.min)); }
|
|
165
|
+
set value(value) { this.setAttribute('value', String(this._acceptedValue(value))); }
|
|
166
|
+
get controlled() { return this.hasAttribute('controlled'); }
|
|
167
|
+
set controlled(value) { this.toggleAttribute('controlled', value); }
|
|
168
|
+
_acceptedValue(value) {
|
|
169
|
+
return this.controlled ? Math.max(this.min, Math.min(this.max, finiteOr(value, this.min))) : this._normalize(value);
|
|
170
|
+
}
|
|
166
171
|
get range() { return this.hasAttribute('range'); }
|
|
167
172
|
set range(value) { this.toggleAttribute('range', Boolean(value)); }
|
|
168
173
|
get lowerValue() {
|
|
@@ -262,6 +267,10 @@ export class HYRange extends HTMLElement {
|
|
|
262
267
|
const changed = this._dragStartValue !== this._serializedValue();
|
|
263
268
|
const handle = this._activeHandle;
|
|
264
269
|
this._stopDrag();
|
|
270
|
+
if (this.controlled && event.type === 'pointercancel') {
|
|
271
|
+
this._dispatchValueEvent('value-cancel', handle);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
265
274
|
if (changed)
|
|
266
275
|
this._dispatchValueEvent('value-change', handle);
|
|
267
276
|
};
|
|
@@ -359,6 +368,8 @@ export class HYRange extends HTMLElement {
|
|
|
359
368
|
return this._normalize(Number(this.getAttribute('lower-value') ?? this.min));
|
|
360
369
|
}
|
|
361
370
|
_toPercent(value) {
|
|
371
|
+
if (this.max === this.min)
|
|
372
|
+
return 0;
|
|
362
373
|
return (value - this.min) / (this.max - this.min) * 100;
|
|
363
374
|
}
|
|
364
375
|
_formatValue(value) {
|
package/dist/rate.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface HYRateChangeDetail {
|
|
2
|
+
readonly value: number;
|
|
3
|
+
}
|
|
4
|
+
export interface HYRateHoverChangeDetail {
|
|
5
|
+
readonly value: number | null;
|
|
6
|
+
}
|
|
7
|
+
/** Accessible star rating with optional half-step pointer and keyboard selection. */
|
|
8
|
+
export declare class HYRate extends HTMLElement {
|
|
9
|
+
private readonly _group;
|
|
10
|
+
private _value;
|
|
11
|
+
private _hoverValue;
|
|
12
|
+
private _tooltips;
|
|
13
|
+
private _reflecting;
|
|
14
|
+
static get observedAttributes(): string[];
|
|
15
|
+
constructor();
|
|
16
|
+
connectedCallback(): void;
|
|
17
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
|
18
|
+
get value(): number;
|
|
19
|
+
set value(value: number);
|
|
20
|
+
get count(): number;
|
|
21
|
+
set count(value: number);
|
|
22
|
+
get allowHalf(): boolean;
|
|
23
|
+
set allowHalf(value: boolean);
|
|
24
|
+
get allowClear(): boolean;
|
|
25
|
+
set allowClear(value: boolean);
|
|
26
|
+
get disabled(): boolean;
|
|
27
|
+
set disabled(value: boolean);
|
|
28
|
+
get readOnly(): boolean;
|
|
29
|
+
set readOnly(value: boolean);
|
|
30
|
+
get character(): string;
|
|
31
|
+
set character(value: string);
|
|
32
|
+
get tooltips(): readonly string[];
|
|
33
|
+
set tooltips(value: readonly string[]);
|
|
34
|
+
private _normalize;
|
|
35
|
+
private _reflectValue;
|
|
36
|
+
private _render;
|
|
37
|
+
private _updateVisuals;
|
|
38
|
+
private _pointerValue;
|
|
39
|
+
private _handlePointerMove;
|
|
40
|
+
private _handleClick;
|
|
41
|
+
private _handleKeydown;
|
|
42
|
+
private _setHover;
|
|
43
|
+
private _commit;
|
|
44
|
+
private _valueText;
|
|
45
|
+
}
|
|
46
|
+
export declare function defineRateComponents(): void;
|