@ahriknow/lux 0.0.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/LICENSE +21 -0
- package/README.md +127 -0
- package/README_zh-CN.md +127 -0
- package/dist/components/lux-button/index.iife.min.js +292 -0
- package/dist/components/lux-button/index.min.js +292 -0
- package/dist/components/lux-code/index.iife.min.js +290 -0
- package/dist/components/lux-code/index.min.js +290 -0
- package/dist/components/lux-dropdown/index.iife.min.js +162 -0
- package/dist/components/lux-dropdown/index.min.js +162 -0
- package/dist/components/lux-example/index.iife.min.js +88 -0
- package/dist/components/lux-example/index.min.js +88 -0
- package/dist/components/lux-icon/index.iife.min.js +22 -0
- package/dist/components/lux-icon/index.min.js +22 -0
- package/dist/components/lux-input/index.iife.min.js +238 -0
- package/dist/components/lux-input/index.min.js +238 -0
- package/dist/components/lux-layout/index.iife.min.js +90 -0
- package/dist/components/lux-layout/index.min.js +90 -0
- package/dist/components/lux-menu/index.iife.min.js +193 -0
- package/dist/components/lux-menu/index.min.js +193 -0
- package/dist/components/lux-scroll/index.iife.min.js +137 -0
- package/dist/components/lux-scroll/index.min.js +137 -0
- package/dist/components/lux-switch/index.iife.min.js +116 -0
- package/dist/components/lux-switch/index.min.js +116 -0
- package/dist/components/lux-table/index.iife.min.js +67 -0
- package/dist/components/lux-table/index.min.js +67 -0
- package/dist/lux.core.min.js +1 -0
- package/dist/lux.i18n.min.js +1 -0
- package/dist/lux.iife.js +1822 -0
- package/dist/lux.iife.js.map +1 -0
- package/dist/lux.iife.min.js +1 -0
- package/dist/lux.js +1792 -0
- package/dist/lux.js.map +1 -0
- package/dist/lux.min.js +1 -0
- package/dist/lux.router.min.js +1 -0
- package/dist/lux.template.min.js +1 -0
- package/dist/lux.theme.min.js +1 -0
- package/dist/themes/dark.css +130 -0
- package/dist/themes/light.css +128 -0
- package/package.json +64 -0
- package/src/components/lux-button/index.js +319 -0
- package/src/components/lux-code/index.js +382 -0
- package/src/components/lux-dropdown/index.js +256 -0
- package/src/components/lux-example/index.js +117 -0
- package/src/components/lux-icon/index.js +180 -0
- package/src/components/lux-input/index.js +363 -0
- package/src/components/lux-layout/index.js +222 -0
- package/src/components/lux-menu/index.js +283 -0
- package/src/components/lux-scroll/index.js +349 -0
- package/src/components/lux-switch/index.js +203 -0
- package/src/components/lux-table/index.js +105 -0
- package/src/core.js +7 -0
- package/src/element.js +477 -0
- package/src/i18n/format.js +108 -0
- package/src/i18n/index.js +102 -0
- package/src/i18n/locale.js +26 -0
- package/src/index.js +22 -0
- package/src/router.js +330 -0
- package/src/template.js +402 -0
- package/src/theme/color.js +148 -0
- package/src/theme/create.js +97 -0
- package/src/theme/index.js +2 -0
- package/src/theme/tokens.js +128 -0
- package/src/themes/dark.css +130 -0
- package/src/themes/light.css +128 -0
package/dist/lux.iife.js
ADDED
|
@@ -0,0 +1,1822 @@
|
|
|
1
|
+
var Lux = (function (exports) {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lux Template System
|
|
6
|
+
*
|
|
7
|
+
* Supports:
|
|
8
|
+
* - html/svg tagged templates with template caching
|
|
9
|
+
* - Child text interpolation: <div>${value}</div>
|
|
10
|
+
* - Event binding: <button @click=${handler}>
|
|
11
|
+
* - Property binding: <input .value=${val}>
|
|
12
|
+
* - Boolean attribute: <div ?hidden=${cond}>
|
|
13
|
+
* - Plain attribute: <div id=${val}>
|
|
14
|
+
* - Class binding: <div class=${classMap({...})}>
|
|
15
|
+
* - Style binding: <div style=${styleMap({...})}>
|
|
16
|
+
* - Ref binding: <input ref=${el => ...}>
|
|
17
|
+
* - Directives: repeat, when, classMap, styleMap, guard, show
|
|
18
|
+
* - Template caching, incremental DOM updates
|
|
19
|
+
* - nothing sentinel for clearing content
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// ─── Template type markers ───
|
|
23
|
+
const HTML_RESULT = 1;
|
|
24
|
+
const SVG_RESULT = 2;
|
|
25
|
+
|
|
26
|
+
// ─── Sentinels ───
|
|
27
|
+
const nothing = Symbol.for('lux-nothing');
|
|
28
|
+
|
|
29
|
+
// ─── html / svg tagged template functions ───
|
|
30
|
+
function html(strings, ...values) {
|
|
31
|
+
return { _$luxType$: HTML_RESULT, strings, values };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function svg(strings, ...values) {
|
|
35
|
+
return { _$luxType$: SVG_RESULT, strings, values };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function css(strings, ...values) {
|
|
39
|
+
return { _$luxType$: 3, strings, values };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isTemplateResult(value) {
|
|
43
|
+
return value && value._$luxType$ !== undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── HTML escaping ──
|
|
47
|
+
function escapeHtml(value) {
|
|
48
|
+
if (value == null) return '';
|
|
49
|
+
const str = String(value);
|
|
50
|
+
if (str.indexOf('<') === -1 && str.indexOf('>') === -1 && str.indexOf('&') === -1) return str;
|
|
51
|
+
return str.replace(
|
|
52
|
+
/[<>&"']/g,
|
|
53
|
+
(c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[c]
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Part marker regex ──
|
|
58
|
+
const ATTR_BINDING_RE = /(?:([@.?][\w-]+)|(ref)|(class))=\s*$/;
|
|
59
|
+
|
|
60
|
+
// ─── Template parsing ──
|
|
61
|
+
function parseTemplate(strings) {
|
|
62
|
+
const parts = [];
|
|
63
|
+
let html = '';
|
|
64
|
+
for (let i = 0; i < strings.length; i++) {
|
|
65
|
+
html += strings[i];
|
|
66
|
+
if (i < strings.length - 1) {
|
|
67
|
+
const attrMatch = html.match(ATTR_BINDING_RE);
|
|
68
|
+
if (attrMatch) {
|
|
69
|
+
const prefixed = attrMatch[1];
|
|
70
|
+
const isRef = !!attrMatch[2];
|
|
71
|
+
const isClass = !!attrMatch[3];
|
|
72
|
+
let prefix = '';
|
|
73
|
+
let name = '';
|
|
74
|
+
if (prefixed) {
|
|
75
|
+
prefix = prefixed[0];
|
|
76
|
+
name = prefixed.slice(1);
|
|
77
|
+
} else if (isRef) {
|
|
78
|
+
prefix = 'ref';
|
|
79
|
+
name = 'ref';
|
|
80
|
+
} else if (isClass) {
|
|
81
|
+
prefix = 'class';
|
|
82
|
+
name = 'class';
|
|
83
|
+
}
|
|
84
|
+
const marker = `lux-attr-${i}`;
|
|
85
|
+
html = html.slice(0, -attrMatch[0].length) + `${marker}="${name}" `;
|
|
86
|
+
parts.push({ type: 'attr', name, prefix, marker, index: i });
|
|
87
|
+
} else {
|
|
88
|
+
const marker = `lux-${i}`;
|
|
89
|
+
html += `<!--${marker}-->`;
|
|
90
|
+
parts.push({ type: 'child', marker, index: i });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { html, parts };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Commit functions ──
|
|
98
|
+
|
|
99
|
+
function commitValue(container, value, context) {
|
|
100
|
+
if (value === nothing || value == null) {
|
|
101
|
+
clearContainer(container);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (isRepeatResult(value)) {
|
|
106
|
+
commitRepeat(container, value);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (isTemplateResult(value)) {
|
|
111
|
+
render(value, container);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (Array.isArray(value)) {
|
|
116
|
+
clearContainer(container);
|
|
117
|
+
for (const item of value) {
|
|
118
|
+
if (isTemplateResult(item)) {
|
|
119
|
+
const span = document.createElement('span');
|
|
120
|
+
span.style.display = 'contents';
|
|
121
|
+
render(item, span);
|
|
122
|
+
container.appendChild(span);
|
|
123
|
+
} else if (item instanceof Node) {
|
|
124
|
+
container.appendChild(item);
|
|
125
|
+
} else if (item != null && item !== false) {
|
|
126
|
+
container.appendChild(document.createTextNode(String(item)));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (value instanceof Node) {
|
|
133
|
+
clearContainer(container);
|
|
134
|
+
container.appendChild(value);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Primitive — replace content (don't append)
|
|
139
|
+
if (container.childNodes.length === 1 && container.firstChild.nodeType === Node.TEXT_NODE) {
|
|
140
|
+
container.firstChild.textContent = String(value);
|
|
141
|
+
} else {
|
|
142
|
+
clearContainer(container);
|
|
143
|
+
container.appendChild(document.createTextNode(String(value)));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function clearContainer(container) {
|
|
148
|
+
while (container.firstChild) {
|
|
149
|
+
container.removeChild(container.firstChild);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function commitAttrPart(part, value) {
|
|
154
|
+
const { element, name, prefix } = part;
|
|
155
|
+
|
|
156
|
+
switch (prefix) {
|
|
157
|
+
case '@': {
|
|
158
|
+
// Event binding
|
|
159
|
+
const oldHandler = part.committedValue;
|
|
160
|
+
|
|
161
|
+
// Determine if we should remove/add based on value changes
|
|
162
|
+
const isNothing = value == null || value === nothing;
|
|
163
|
+
const shouldRemoveListener =
|
|
164
|
+
(isNothing && oldHandler != null) ||
|
|
165
|
+
value?.capture !== oldHandler?.capture ||
|
|
166
|
+
value?.once !== oldHandler?.once ||
|
|
167
|
+
value?.passive !== oldHandler?.passive;
|
|
168
|
+
|
|
169
|
+
const shouldAddListener = !isNothing && (oldHandler == null || shouldRemoveListener);
|
|
170
|
+
|
|
171
|
+
if (shouldRemoveListener) {
|
|
172
|
+
element.removeEventListener(name, part._handler || oldHandler, oldHandler);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (shouldAddListener) {
|
|
176
|
+
if (typeof value === 'function') {
|
|
177
|
+
// Wrap handler to preserve `this` as the host element
|
|
178
|
+
const host =
|
|
179
|
+
part._host ||
|
|
180
|
+
(part._host = {
|
|
181
|
+
handleEvent(e) {
|
|
182
|
+
const fn = part.committedValue;
|
|
183
|
+
if (typeof fn === 'function') {
|
|
184
|
+
// Use host element (custom element instance) as `this`
|
|
185
|
+
const hostEl = element.getRootNode().host || element;
|
|
186
|
+
fn.call(hostEl, e);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
part._handler = host;
|
|
191
|
+
element.addEventListener(name, host);
|
|
192
|
+
} else if (typeof value === 'object' && value?.handleEvent) {
|
|
193
|
+
// EventListenerObject: { handleEvent(event) }
|
|
194
|
+
const host = { handleEvent: (e) => value.handleEvent.call(value, e) };
|
|
195
|
+
part._handler = host;
|
|
196
|
+
element.addEventListener(name, host, {
|
|
197
|
+
capture: value.capture,
|
|
198
|
+
once: value.once,
|
|
199
|
+
passive: value.passive,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
part.committedValue = value;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
case '.': {
|
|
208
|
+
// Property binding
|
|
209
|
+
element[name] = value === nothing ? undefined : value;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case '?': {
|
|
213
|
+
// Boolean attribute binding
|
|
214
|
+
element.toggleAttribute(name, !!value);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case 'ref': {
|
|
218
|
+
// Ref binding
|
|
219
|
+
if (typeof value === 'function') {
|
|
220
|
+
value(element);
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case 'class': {
|
|
225
|
+
// Class binding
|
|
226
|
+
if (typeof value === 'string') {
|
|
227
|
+
element.className = value;
|
|
228
|
+
} else if (typeof value === 'object' && value != null) {
|
|
229
|
+
const existing = element.className || '';
|
|
230
|
+
const names = existing ? existing.split(/\s+/) : [];
|
|
231
|
+
for (const [cls, active] of Object.entries(value)) {
|
|
232
|
+
if (active) {
|
|
233
|
+
if (!names.includes(cls)) names.push(cls);
|
|
234
|
+
} else {
|
|
235
|
+
const idx = names.indexOf(cls);
|
|
236
|
+
if (idx !== -1) names.splice(idx, 1);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
element.className = names.filter(Boolean).join(' ');
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
default: {
|
|
244
|
+
// Plain attribute
|
|
245
|
+
if (value == null || value === false) {
|
|
246
|
+
element.removeAttribute(name);
|
|
247
|
+
} else {
|
|
248
|
+
element.setAttribute(name, value === true ? '' : String(value));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── render() function ──
|
|
255
|
+
|
|
256
|
+
function render(result, container, context) {
|
|
257
|
+
if (!isTemplateResult(result)) {
|
|
258
|
+
container.textContent = result ?? '';
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const { strings, values } = result;
|
|
263
|
+
|
|
264
|
+
// Incremental update
|
|
265
|
+
if (container._luxActiveParts && container._luxStrings === strings) {
|
|
266
|
+
for (const part of container._luxActiveParts) {
|
|
267
|
+
if (part.index < values.length) {
|
|
268
|
+
if (part.type === 'child') {
|
|
269
|
+
commitValue(part.span, values[part.index]);
|
|
270
|
+
} else {
|
|
271
|
+
commitAttrPart(part, values[part.index]);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// First render — build part list
|
|
279
|
+
const tpl = document.createElement('template');
|
|
280
|
+
const parsed = parseTemplate(strings);
|
|
281
|
+
tpl.innerHTML = parsed.html;
|
|
282
|
+
const fragment = tpl.content.cloneNode(true);
|
|
283
|
+
const activeParts = [];
|
|
284
|
+
|
|
285
|
+
for (const part of parsed.parts) {
|
|
286
|
+
if (part.type === 'child') {
|
|
287
|
+
const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_COMMENT);
|
|
288
|
+
let node;
|
|
289
|
+
while ((node = walker.nextNode())) {
|
|
290
|
+
if (node.data === part.marker) {
|
|
291
|
+
const span = document.createElement('span');
|
|
292
|
+
span.style.display = 'contents';
|
|
293
|
+
node.parentNode.replaceChild(span, node);
|
|
294
|
+
commitValue(span, values[part.index]);
|
|
295
|
+
activeParts.push({ type: 'child', span, index: part.index });
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
const element = fragment.querySelector(`[${part.marker}]`);
|
|
301
|
+
if (element) {
|
|
302
|
+
element.removeAttribute(part.marker);
|
|
303
|
+
const ap = {
|
|
304
|
+
type: 'attr',
|
|
305
|
+
element,
|
|
306
|
+
name: part.name,
|
|
307
|
+
prefix: part.prefix,
|
|
308
|
+
index: part.index,
|
|
309
|
+
committedValue: undefined,
|
|
310
|
+
};
|
|
311
|
+
commitAttrPart(ap, values[part.index]);
|
|
312
|
+
activeParts.push(ap);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
container.textContent = '';
|
|
318
|
+
container.appendChild(fragment);
|
|
319
|
+
container._luxActiveParts = activeParts;
|
|
320
|
+
container._luxStrings = strings;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ─── Directives ──
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* repeat(items, keyFn, renderFn) — keyed list rendering
|
|
327
|
+
*/
|
|
328
|
+
function repeat(items, keyFn, renderFn) {
|
|
329
|
+
return { _type: 'repeat', items, keyFn, renderFn };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function isRepeatResult(value) {
|
|
333
|
+
return value && value._type === 'repeat';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function commitRepeat(container, value, context) {
|
|
337
|
+
const { items, keyFn, renderFn } = value;
|
|
338
|
+
const state = container._repeatState;
|
|
339
|
+
|
|
340
|
+
// Clear old content
|
|
341
|
+
if (state) {
|
|
342
|
+
for (const entry of state.entries) {
|
|
343
|
+
if (entry.span && entry.span.parentNode) {
|
|
344
|
+
entry.span.parentNode.removeChild(entry.span);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Build new content
|
|
350
|
+
const frag = document.createDocumentFragment();
|
|
351
|
+
const entries = [];
|
|
352
|
+
for (const item of items) {
|
|
353
|
+
const key = keyFn(item);
|
|
354
|
+
const result = renderFn(item);
|
|
355
|
+
const span = document.createElement('span');
|
|
356
|
+
span.style.display = 'contents';
|
|
357
|
+
if (isTemplateResult(result)) {
|
|
358
|
+
render(result, span);
|
|
359
|
+
} else if (result != null && result !== false) {
|
|
360
|
+
span.appendChild(document.createTextNode(String(result)));
|
|
361
|
+
}
|
|
362
|
+
frag.appendChild(span);
|
|
363
|
+
entries.push({ key, result, span });
|
|
364
|
+
}
|
|
365
|
+
container.appendChild(frag);
|
|
366
|
+
container._repeatState = { entries };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* when(condition, trueFn, falseFn) — conditional rendering
|
|
371
|
+
*/
|
|
372
|
+
function when(condition, trueFn, falseFn = () => '') {
|
|
373
|
+
return condition ? trueFn() : falseFn();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* show(condition, content) — show/hide element
|
|
378
|
+
*/
|
|
379
|
+
function show(condition, content = '') {
|
|
380
|
+
return condition ? content : '';
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* classMap(classes) — dynamic class binding helper
|
|
385
|
+
*/
|
|
386
|
+
function classMap(classes) {
|
|
387
|
+
return classes;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* styleMap(styles) — dynamic style binding helper
|
|
392
|
+
*/
|
|
393
|
+
function styleMap(styles) {
|
|
394
|
+
return styles;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* guard(dependencies, fn) — re-render only when dependencies change
|
|
399
|
+
*/
|
|
400
|
+
function guard(dependencies, fn) {
|
|
401
|
+
return fn();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* LuxElement — Base class for Web Components.
|
|
406
|
+
*
|
|
407
|
+
* Inherits: HTMLElement → LuxElement
|
|
408
|
+
* Features: reactive properties, Shadow DOM, lifecycle, controllers, update batching
|
|
409
|
+
*/
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
// ─── Update scheduling ───
|
|
413
|
+
const pendingUpdates = new Set();
|
|
414
|
+
let updateScheduled = false;
|
|
415
|
+
|
|
416
|
+
function scheduleMicroTask(fn) {
|
|
417
|
+
Promise.resolve().then(fn);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function enqueueUpdate(element) {
|
|
421
|
+
pendingUpdates.add(element);
|
|
422
|
+
if (!updateScheduled) {
|
|
423
|
+
updateScheduled = true;
|
|
424
|
+
scheduleMicroTask(() => {
|
|
425
|
+
updateScheduled = false;
|
|
426
|
+
const elements = [...pendingUpdates];
|
|
427
|
+
pendingUpdates.clear();
|
|
428
|
+
for (const el of elements) {
|
|
429
|
+
el._$performUpdate();
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ─── Default converter (attribute ↔ property) ───
|
|
436
|
+
const defaultConverter = {
|
|
437
|
+
toAttribute(value, type) {
|
|
438
|
+
switch (type) {
|
|
439
|
+
case Boolean:
|
|
440
|
+
return value ? '' : null;
|
|
441
|
+
case Object:
|
|
442
|
+
case Array:
|
|
443
|
+
return value == null ? null : JSON.stringify(value);
|
|
444
|
+
default:
|
|
445
|
+
return value;
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
fromAttribute(value, type) {
|
|
449
|
+
switch (type) {
|
|
450
|
+
case Boolean:
|
|
451
|
+
return value !== null;
|
|
452
|
+
case Number:
|
|
453
|
+
return Number(value);
|
|
454
|
+
case Object:
|
|
455
|
+
case Array:
|
|
456
|
+
try {
|
|
457
|
+
return JSON.parse(value);
|
|
458
|
+
} catch {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
default:
|
|
462
|
+
return value;
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
// ─── LuxElement ───
|
|
468
|
+
class LuxElement extends HTMLElement {
|
|
469
|
+
// ─── Static: properties declaration ───
|
|
470
|
+
static properties = {};
|
|
471
|
+
static styles = undefined;
|
|
472
|
+
|
|
473
|
+
// ─── Static: finalization ───
|
|
474
|
+
static finalized = false;
|
|
475
|
+
static elementProperties = new Map();
|
|
476
|
+
|
|
477
|
+
// ─── Static: __prepare() — subclass isolation ───
|
|
478
|
+
static __prepare() {
|
|
479
|
+
if (this.hasOwnProperty('elementProperties')) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
// Finalize any superclasses first
|
|
483
|
+
const superCtor = Object.getPrototypeOf(this);
|
|
484
|
+
if (superCtor.finalize) {
|
|
485
|
+
superCtor.finalize();
|
|
486
|
+
}
|
|
487
|
+
// Copy superclass elementProperties into own Map
|
|
488
|
+
this.elementProperties = new Map(superCtor.elementProperties);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
static get observedAttributes() {
|
|
492
|
+
this.finalize();
|
|
493
|
+
return this.__attributeToPropertyMap ? [...this.__attributeToPropertyMap.keys()] : [];
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
static finalize() {
|
|
497
|
+
if (this.hasOwnProperty('finalized')) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
this.finalized = true;
|
|
501
|
+
this.__prepare();
|
|
502
|
+
|
|
503
|
+
// Create properties from the static properties block
|
|
504
|
+
if (this.hasOwnProperty('properties')) {
|
|
505
|
+
const props = this.properties;
|
|
506
|
+
const propKeys = [
|
|
507
|
+
...Object.getOwnPropertyNames(props),
|
|
508
|
+
...Object.getOwnPropertySymbols(props),
|
|
509
|
+
];
|
|
510
|
+
for (const p of propKeys) {
|
|
511
|
+
this.createProperty(p, props[p]);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Create the attribute-to-property map
|
|
516
|
+
this.__attributeToPropertyMap = new Map();
|
|
517
|
+
for (const [p, options] of this.elementProperties) {
|
|
518
|
+
const attr = this.__attributeNameForProperty(p, options);
|
|
519
|
+
if (attr !== undefined) {
|
|
520
|
+
this.__attributeToPropertyMap.set(attr, p);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
this.elementStyles = this.styles;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
static __attributeNameForProperty(name, options) {
|
|
528
|
+
const attribute = options.attribute;
|
|
529
|
+
return attribute === false
|
|
530
|
+
? undefined
|
|
531
|
+
: typeof attribute === 'string'
|
|
532
|
+
? attribute
|
|
533
|
+
: typeof name === 'string'
|
|
534
|
+
? name.replace(/([A-Z])/g, '-$1').toLowerCase()
|
|
535
|
+
: undefined;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ─── Static: createProperty ───
|
|
539
|
+
static createProperty(name, options = {}) {
|
|
540
|
+
this.__prepare();
|
|
541
|
+
if (this.elementProperties.has(name)) return;
|
|
542
|
+
|
|
543
|
+
if (options.state) {
|
|
544
|
+
options = { ...options, attribute: false };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
this.elementProperties.set(name, options);
|
|
548
|
+
|
|
549
|
+
if (!options.noAccessor) {
|
|
550
|
+
const key = Symbol();
|
|
551
|
+
const descriptor = this.getPropertyDescriptor(name, key, options);
|
|
552
|
+
if (descriptor !== undefined) {
|
|
553
|
+
Object.defineProperty(this.prototype, name, descriptor);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
static getPropertyDescriptor(name, key, options) {
|
|
559
|
+
const { type, reflect, converter } = options;
|
|
560
|
+
converter?.toAttribute ?? defaultConverter.toAttribute;
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
get() {
|
|
564
|
+
return this[key];
|
|
565
|
+
},
|
|
566
|
+
set(value) {
|
|
567
|
+
const oldValue = this[key];
|
|
568
|
+
this[key] = value;
|
|
569
|
+
this.requestUpdate(name, oldValue, options);
|
|
570
|
+
},
|
|
571
|
+
configurable: true,
|
|
572
|
+
enumerable: true,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ─── Static: addInitializer ───
|
|
577
|
+
static _initializers = [];
|
|
578
|
+
|
|
579
|
+
static addInitializer(fn) {
|
|
580
|
+
this._initializers = [...(this._initializers || []), fn];
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ─── Static: shadowRootOptions ───
|
|
584
|
+
static shadowRootOptions = { mode: 'open' };
|
|
585
|
+
|
|
586
|
+
// ─── Constructor ───
|
|
587
|
+
constructor() {
|
|
588
|
+
super();
|
|
589
|
+
|
|
590
|
+
this.__updatePending = false;
|
|
591
|
+
this.__hasUpdated = false;
|
|
592
|
+
this.__controllers = new Set();
|
|
593
|
+
this.__reflectingProperty = null;
|
|
594
|
+
|
|
595
|
+
// Ensure finalized
|
|
596
|
+
if (!this.constructor.hasOwnProperty('finalized')) {
|
|
597
|
+
this.constructor.finalize();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// CloneNode support: save any properties set before upgrade
|
|
601
|
+
this.__saveInstanceProperties();
|
|
602
|
+
|
|
603
|
+
this.requestUpdate();
|
|
604
|
+
|
|
605
|
+
for (const fn of this.constructor._initializers || []) {
|
|
606
|
+
fn(this);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ─── Instance properties save/replay (cloneNode support) ───
|
|
611
|
+
__saveInstanceProperties() {
|
|
612
|
+
const elementProperties = this.constructor.elementProperties;
|
|
613
|
+
if (!elementProperties) return;
|
|
614
|
+
|
|
615
|
+
for (const [name, options] of elementProperties) {
|
|
616
|
+
if (options.noAccessor) continue;
|
|
617
|
+
|
|
618
|
+
if (this.hasOwnProperty(name)) {
|
|
619
|
+
// Plain property set before setter was defined (cloneNode)
|
|
620
|
+
const value = this[name];
|
|
621
|
+
delete this[name];
|
|
622
|
+
// Store via the Symbol key
|
|
623
|
+
this.__instancePropertyValues = this.__instancePropertyValues || new Map();
|
|
624
|
+
this.__instancePropertyValues.set(name, value);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ─── Lifecycle callbacks ───
|
|
630
|
+
connectedCallback() {
|
|
631
|
+
this.__controllers.forEach((c) => c.hostConnected?.());
|
|
632
|
+
|
|
633
|
+
// Ensure finalized
|
|
634
|
+
if (!this.constructor.hasOwnProperty('finalized')) {
|
|
635
|
+
this.constructor.finalize();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Sync existing attributes to properties (upgrade scenario)
|
|
639
|
+
// attributeChangedCallback doesn't fire for pre-existing attributes
|
|
640
|
+
const attrMap = this.constructor.__attributeToPropertyMap;
|
|
641
|
+
if (attrMap) {
|
|
642
|
+
for (const [attrName, propName] of attrMap) {
|
|
643
|
+
const value = this.getAttribute(attrName);
|
|
644
|
+
if (value !== null) {
|
|
645
|
+
const options = this.constructor.elementProperties.get(propName);
|
|
646
|
+
if (options) {
|
|
647
|
+
const fromAttr =
|
|
648
|
+
options.converter?.fromAttribute ?? defaultConverter.fromAttribute;
|
|
649
|
+
this[propName] = fromAttr(value, options.type);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Replay instance properties saved by __saveInstanceProperties (cloneNode)
|
|
656
|
+
if (this.__instancePropertyValues) {
|
|
657
|
+
for (const [name, value] of this.__instancePropertyValues) {
|
|
658
|
+
this[name] = value;
|
|
659
|
+
}
|
|
660
|
+
this.__instancePropertyValues = undefined;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Ensure renderRoot is created
|
|
664
|
+
this.renderRoot;
|
|
665
|
+
|
|
666
|
+
// Trigger first update if not yet done
|
|
667
|
+
if (!this.__hasUpdated) {
|
|
668
|
+
this.requestUpdate();
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
disconnectedCallback() {
|
|
673
|
+
this.__controllers.forEach((c) => c.hostDisconnected?.());
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
attributeChangedCallback(name, _old, value) {
|
|
677
|
+
const propName = this.constructor.__attributeToPropertyMap?.get(name);
|
|
678
|
+
if (propName !== undefined && this.__reflectingProperty !== propName) {
|
|
679
|
+
const options = this.constructor.elementProperties.get(propName);
|
|
680
|
+
if (options) {
|
|
681
|
+
const converter =
|
|
682
|
+
typeof options.converter === 'function'
|
|
683
|
+
? { fromAttribute: options.converter }
|
|
684
|
+
: options.converter?.fromAttribute !== undefined
|
|
685
|
+
? options.converter
|
|
686
|
+
: defaultConverter;
|
|
687
|
+
this.__reflectingProperty = propName;
|
|
688
|
+
const convertedValue = converter.fromAttribute(value, options.type);
|
|
689
|
+
this[propName] = convertedValue;
|
|
690
|
+
this.__reflectingProperty = null;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ─── RenderRoot & Styles ───
|
|
696
|
+
get renderRoot() {
|
|
697
|
+
if (!this._renderRoot) {
|
|
698
|
+
this._renderRoot = this.createRenderRoot();
|
|
699
|
+
this.__adoptStyles();
|
|
700
|
+
}
|
|
701
|
+
return this._renderRoot;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
createRenderRoot() {
|
|
705
|
+
return this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
__adoptStyles() {
|
|
709
|
+
const styles = this.constructor.elementStyles;
|
|
710
|
+
if (!styles) return;
|
|
711
|
+
|
|
712
|
+
const items = Array.isArray(styles) ? styles : [styles];
|
|
713
|
+
const parts = [];
|
|
714
|
+
for (const item of items) {
|
|
715
|
+
if (typeof item === 'string') {
|
|
716
|
+
parts.push(item);
|
|
717
|
+
} else if (isTemplateResult(item)) {
|
|
718
|
+
parts.push(item.strings.join(''));
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
if (parts.length === 0) return;
|
|
722
|
+
|
|
723
|
+
if (this._renderRoot.adoptedStyleSheets !== undefined) {
|
|
724
|
+
const sheet = new CSSStyleSheet();
|
|
725
|
+
sheet.replaceSync(parts.join('\n'));
|
|
726
|
+
this._renderRoot.adoptedStyleSheets = [...this._renderRoot.adoptedStyleSheets, sheet];
|
|
727
|
+
} else {
|
|
728
|
+
const tag = document.createElement('style');
|
|
729
|
+
tag.textContent = parts.join('\n');
|
|
730
|
+
this._renderRoot.appendChild(tag);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ─── Update lifecycle ───
|
|
735
|
+
enableUpdating(_requestedUpdate) {}
|
|
736
|
+
|
|
737
|
+
get updateComplete() {
|
|
738
|
+
return this.__updateCompletePromise ?? Promise.resolve();
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
get hasUpdated() {
|
|
742
|
+
return this.__hasUpdated;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
get isUpdatePending() {
|
|
746
|
+
return this.__updatePending;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
requestUpdate(name, oldValue, options) {
|
|
750
|
+
if (name !== undefined) {
|
|
751
|
+
const hasChanged = options?.hasChanged ?? ((v, o) => !Object.is(v, o));
|
|
752
|
+
if (!hasChanged(this[name], oldValue)) return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (!this.__updatePending) {
|
|
756
|
+
this.__updatePending = true;
|
|
757
|
+
enqueueUpdate(this);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async performUpdate() {
|
|
762
|
+
if (!this.isUpdatePending) return;
|
|
763
|
+
|
|
764
|
+
this.__updatePending = false;
|
|
765
|
+
|
|
766
|
+
this.willUpdate(new Map());
|
|
767
|
+
|
|
768
|
+
this.__controllers.forEach((c) => c.hostUpdate?.());
|
|
769
|
+
|
|
770
|
+
this.update(new Map());
|
|
771
|
+
|
|
772
|
+
this.__hasUpdated = true;
|
|
773
|
+
|
|
774
|
+
// Reflect properties to attributes after update
|
|
775
|
+
for (const [name, options] of this.constructor.elementProperties) {
|
|
776
|
+
if (options.reflect) {
|
|
777
|
+
const value = this[name];
|
|
778
|
+
const toAttribute = options.converter?.toAttribute ?? defaultConverter.toAttribute;
|
|
779
|
+
const attr = this.constructor.__attributeNameForProperty(name, options);
|
|
780
|
+
if (attr !== undefined) {
|
|
781
|
+
this.__reflectingProperty = name;
|
|
782
|
+
const attrValue = toAttribute(value, options.type);
|
|
783
|
+
if (attrValue === null || attrValue === undefined) {
|
|
784
|
+
this.removeAttribute(attr);
|
|
785
|
+
} else {
|
|
786
|
+
this.setAttribute(attr, String(attrValue));
|
|
787
|
+
}
|
|
788
|
+
this.__reflectingProperty = null;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
this.__controllers.forEach((c) => c.hostUpdated?.());
|
|
794
|
+
this.firstUpdated(new Map());
|
|
795
|
+
this.updated(new Map());
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
_$performUpdate() {
|
|
799
|
+
this.performUpdate();
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ─── Lifecycle hooks ───
|
|
803
|
+
shouldUpdate(_changedProperties) {
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
willUpdate(_changedProperties) {}
|
|
808
|
+
|
|
809
|
+
update(_changedProperties) {
|
|
810
|
+
const result = this.render();
|
|
811
|
+
if (result) {
|
|
812
|
+
render(result, this.renderRoot);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
render() {
|
|
817
|
+
return html``;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
firstUpdated(_changedProperties) {}
|
|
821
|
+
updated(_changedProperties) {}
|
|
822
|
+
propertyChangedCallback(_name, _oldValue, _newValue) {}
|
|
823
|
+
|
|
824
|
+
// ─── Controller API ───
|
|
825
|
+
addController(controller) {
|
|
826
|
+
this.__controllers.add(controller);
|
|
827
|
+
if (this.isConnected) {
|
|
828
|
+
controller.hostConnected?.();
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
removeController(controller) {
|
|
833
|
+
this.__controllers.delete(controller);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ─── Utilities ───
|
|
837
|
+
emit(type, detail, options = {}) {
|
|
838
|
+
return this.dispatchEvent(
|
|
839
|
+
new CustomEvent(type, {
|
|
840
|
+
detail,
|
|
841
|
+
bubbles: true,
|
|
842
|
+
composed: true,
|
|
843
|
+
...options,
|
|
844
|
+
})
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
$(selector) {
|
|
849
|
+
return this.renderRoot.querySelector(selector);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
$$(selector) {
|
|
853
|
+
return this.renderRoot.querySelectorAll(selector);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// ─── Register component ───
|
|
858
|
+
function registerComponent$1(name, component) {
|
|
859
|
+
if (!name.includes('-')) {
|
|
860
|
+
throw new Error(`Component name must contain a hyphen: ${name}`);
|
|
861
|
+
}
|
|
862
|
+
if (!customElements.get(name)) {
|
|
863
|
+
customElements.define(name, component);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ─── createComponent (function-based components) ───
|
|
868
|
+
function createComponent(renderFn, options = {}) {
|
|
869
|
+
const { properties = {} } = options;
|
|
870
|
+
|
|
871
|
+
class DynamicComponent extends LuxElement {
|
|
872
|
+
static properties = properties;
|
|
873
|
+
render() {
|
|
874
|
+
return renderFn.call(this, this);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
return DynamicComponent;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Lux Router - Hash-based SPA routing
|
|
883
|
+
*
|
|
884
|
+
* Usage:
|
|
885
|
+
* const router = createRouter({ routes: [...] });
|
|
886
|
+
* router.push('/users/123');
|
|
887
|
+
*
|
|
888
|
+
* Routes accept component classes or lazy loaders:
|
|
889
|
+
* { path: '/', component: HomePage } // direct class
|
|
890
|
+
* { path: '/admin', component: () => import('./admin.js') } // lazy
|
|
891
|
+
*/
|
|
892
|
+
|
|
893
|
+
function registerComponent(name, component) {
|
|
894
|
+
if (!name.includes('-')) throw new Error(`Component name must contain a hyphen: ${name}`);
|
|
895
|
+
if (!customElements.get(name)) customElements.define(name, component);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ─── Route matching ───
|
|
899
|
+
|
|
900
|
+
function pathToRegex(pattern) {
|
|
901
|
+
let seg = pattern.replace(/\/+$/, '') || '/';
|
|
902
|
+
if (seg === '/') return /^\/?$/;
|
|
903
|
+
const re = '^' + seg.replace(/:(\w+)/g, '([^/]+)').replace(/\(\.\*\)/g, '(.*)') + '$';
|
|
904
|
+
return new RegExp(re);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function extractParamNames(pattern) {
|
|
908
|
+
const names = [];
|
|
909
|
+
const re = /:(\w+)/g;
|
|
910
|
+
let m;
|
|
911
|
+
while ((m = re.exec(pattern))) names.push(m[1]);
|
|
912
|
+
return names;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function parseQuery(hash) {
|
|
916
|
+
const idx = hash.indexOf('?');
|
|
917
|
+
if (idx === -1) return {};
|
|
918
|
+
const params = {};
|
|
919
|
+
for (const pair of hash.slice(idx + 1).split('&')) {
|
|
920
|
+
const [k, v] = pair.split('=');
|
|
921
|
+
if (k) params[decodeURIComponent(k)] = v ? decodeURIComponent(v) : '';
|
|
922
|
+
}
|
|
923
|
+
return params;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function getPathFromHash(hash) {
|
|
927
|
+
return hash.replace(/^#/, '').split('?')[0] || '/';
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function resolveComponent(componentOrLoader) {
|
|
931
|
+
if (typeof componentOrLoader === 'string') {
|
|
932
|
+
return Promise.resolve(componentOrLoader);
|
|
933
|
+
}
|
|
934
|
+
if (
|
|
935
|
+
typeof componentOrLoader === 'function' &&
|
|
936
|
+
componentOrLoader.prototype instanceof HTMLElement
|
|
937
|
+
) {
|
|
938
|
+
return ensureRegistered(componentOrLoader);
|
|
939
|
+
}
|
|
940
|
+
if (typeof componentOrLoader === 'function') {
|
|
941
|
+
// Assume lazy loader: () => import(...)
|
|
942
|
+
return componentOrLoader().then((mod) => {
|
|
943
|
+
const Comp = mod.default || mod[Object.keys(mod)[0]];
|
|
944
|
+
return ensureRegistered(Comp);
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
return Promise.resolve(null);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function ensureRegistered(ComponentClass) {
|
|
951
|
+
if (!ComponentClass || !ComponentClass.prototype) return Promise.resolve(null);
|
|
952
|
+
|
|
953
|
+
const tagName = ComponentClass.tagName || classToTag(ComponentClass.name);
|
|
954
|
+
|
|
955
|
+
// Already registered?
|
|
956
|
+
if (customElements.get(tagName)) {
|
|
957
|
+
return Promise.resolve(tagName);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
registerComponent(tagName, ComponentClass);
|
|
961
|
+
return Promise.resolve(tagName);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function classToTag(name) {
|
|
965
|
+
// HomePage → home-page, UserProfile → user-profile
|
|
966
|
+
return name
|
|
967
|
+
.replace(/([A-Z])/g, '-$1')
|
|
968
|
+
.toLowerCase()
|
|
969
|
+
.replace(/^-/, '');
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ─── Router class ───
|
|
973
|
+
|
|
974
|
+
class Router {
|
|
975
|
+
constructor(options = {}) {
|
|
976
|
+
this.routes = (options.routes || []).map((r) => ({
|
|
977
|
+
...r,
|
|
978
|
+
_regex: pathToRegex(r.path),
|
|
979
|
+
_paramNames: extractParamNames(r.path),
|
|
980
|
+
_children: (r.children || []).map((c) => ({
|
|
981
|
+
...c,
|
|
982
|
+
_regex: pathToRegex(c.path),
|
|
983
|
+
_paramNames: extractParamNames(c.path),
|
|
984
|
+
})),
|
|
985
|
+
}));
|
|
986
|
+
|
|
987
|
+
this._guards = [];
|
|
988
|
+
this._listeners = [];
|
|
989
|
+
this.current = null;
|
|
990
|
+
this._prev = null;
|
|
991
|
+
this._onHashChange = this._onHashChange.bind(this);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
start() {
|
|
995
|
+
window.addEventListener('hashchange', this._onHashChange);
|
|
996
|
+
this._resolve();
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
stop() {
|
|
1000
|
+
window.removeEventListener('hashchange', this._onHashChange);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
push(path) {
|
|
1004
|
+
location.hash = path;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
replace(path) {
|
|
1008
|
+
const url = new URL(location.href);
|
|
1009
|
+
url.hash = path;
|
|
1010
|
+
history.replaceState(null, '', url.toString());
|
|
1011
|
+
this._onHashChange();
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
back() {
|
|
1015
|
+
history.back();
|
|
1016
|
+
}
|
|
1017
|
+
forward() {
|
|
1018
|
+
history.forward();
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
beforeEach(fn) {
|
|
1022
|
+
this._guards.push(fn);
|
|
1023
|
+
return () => {
|
|
1024
|
+
const i = this._guards.indexOf(fn);
|
|
1025
|
+
if (i >= 0) this._guards.splice(i, 1);
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
_subscribe(fn) {
|
|
1030
|
+
this._listeners.push(fn);
|
|
1031
|
+
return () => {
|
|
1032
|
+
const i = this._listeners.indexOf(fn);
|
|
1033
|
+
if (i >= 0) this._listeners.splice(i, 1);
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
_onHashChange() {
|
|
1038
|
+
this._resolve();
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
_resolve() {
|
|
1042
|
+
const hash = location.hash || '#/';
|
|
1043
|
+
const path = getPathFromHash(hash);
|
|
1044
|
+
const query = parseQuery(hash);
|
|
1045
|
+
|
|
1046
|
+
let matched = null;
|
|
1047
|
+
for (const route of this.routes) {
|
|
1048
|
+
const hasChildren = route._children.length > 0;
|
|
1049
|
+
|
|
1050
|
+
// For routes with children, use prefix matching
|
|
1051
|
+
// For leaf routes, use exact matching
|
|
1052
|
+
let m;
|
|
1053
|
+
if (hasChildren) {
|
|
1054
|
+
// Prefix match: path must start with route path
|
|
1055
|
+
const prefix = route.path === '/' ? '' : route.path.replace(/\/+$/, '');
|
|
1056
|
+
if (
|
|
1057
|
+
path === prefix ||
|
|
1058
|
+
path.startsWith(prefix + '/') ||
|
|
1059
|
+
(prefix === '' && path.startsWith('/'))
|
|
1060
|
+
) {
|
|
1061
|
+
m = [path]; // fake match for prefix
|
|
1062
|
+
}
|
|
1063
|
+
} else {
|
|
1064
|
+
m = path.match(route._regex);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (m) {
|
|
1068
|
+
const params = {};
|
|
1069
|
+
route._paramNames.forEach((name, i) => {
|
|
1070
|
+
if (m[i + 1] !== undefined) params[name] = decodeURIComponent(m[i + 1]);
|
|
1071
|
+
});
|
|
1072
|
+
matched = { route, params: { ...params }, query, path };
|
|
1073
|
+
|
|
1074
|
+
// Child matching (check BEFORE redirect)
|
|
1075
|
+
if (hasChildren) {
|
|
1076
|
+
const prefix = route.path === '/' ? '' : route.path.replace(/\/+$/, '');
|
|
1077
|
+
const childPath = path.slice(prefix.length) || '/';
|
|
1078
|
+
let childMatched = false;
|
|
1079
|
+
for (const child of route._children) {
|
|
1080
|
+
const cm = childPath.match(child._regex);
|
|
1081
|
+
if (cm) {
|
|
1082
|
+
const childParams = {};
|
|
1083
|
+
child._paramNames.forEach((name, i) => {
|
|
1084
|
+
childParams[name] = decodeURIComponent(cm[i + 1]);
|
|
1085
|
+
});
|
|
1086
|
+
matched.child = {
|
|
1087
|
+
route: child,
|
|
1088
|
+
params: { ...params, ...childParams },
|
|
1089
|
+
query,
|
|
1090
|
+
path,
|
|
1091
|
+
};
|
|
1092
|
+
childMatched = true;
|
|
1093
|
+
break;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
// No child matched — redirect only if path equals parent path
|
|
1097
|
+
if (
|
|
1098
|
+
!childMatched &&
|
|
1099
|
+
route.redirect &&
|
|
1100
|
+
path === (route.path === '/' ? '/' : route.path)
|
|
1101
|
+
) {
|
|
1102
|
+
this.replace(route.redirect);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
// No child matched and no redirect — skip this parent, try other routes
|
|
1106
|
+
if (!childMatched) {
|
|
1107
|
+
matched = null;
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
} else if (route.redirect) {
|
|
1111
|
+
this.replace(route.redirect);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
break;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (!matched) {
|
|
1119
|
+
this.current = { route: null, params: {}, query, path };
|
|
1120
|
+
} else {
|
|
1121
|
+
this.current = matched;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
for (const guard of this._guards) {
|
|
1125
|
+
if (guard(this.current, this._prev) === false) {
|
|
1126
|
+
if (this._prev) history.replaceState(null, '', '#' + this._prev.path);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
this._prev = { ...this.current };
|
|
1132
|
+
for (const fn of this._listeners) fn(this.current);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// ─── Create router + register <router-outlet> ───
|
|
1137
|
+
|
|
1138
|
+
function createRouter(options) {
|
|
1139
|
+
const router = new Router(options);
|
|
1140
|
+
|
|
1141
|
+
if (!customElements.get('router-outlet')) {
|
|
1142
|
+
class RouterOutlet extends HTMLElement {
|
|
1143
|
+
connectedCallback() {
|
|
1144
|
+
this.style.display = 'contents';
|
|
1145
|
+
this._unsub = router._subscribe(() => this._update());
|
|
1146
|
+
this._update();
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
disconnectedCallback() {
|
|
1150
|
+
this._unsub?.();
|
|
1151
|
+
this._loadingAbort?.abort();
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
async _update() {
|
|
1155
|
+
const match = this._getMatch();
|
|
1156
|
+
this._loadingAbort?.abort();
|
|
1157
|
+
|
|
1158
|
+
if (!match || !match.route || !match.route.component) {
|
|
1159
|
+
this.textContent = '';
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Skip re-render if the same route is already rendered
|
|
1164
|
+
if (this._lastRoute === match.route) {
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const controller = new AbortController();
|
|
1169
|
+
this._loadingAbort = controller;
|
|
1170
|
+
|
|
1171
|
+
try {
|
|
1172
|
+
const tagName = await resolveComponent(match.route.component);
|
|
1173
|
+
if (controller.signal.aborted) return;
|
|
1174
|
+
|
|
1175
|
+
this.textContent = '';
|
|
1176
|
+
if (tagName) {
|
|
1177
|
+
const el = document.createElement(tagName);
|
|
1178
|
+
if (match.params) el.params = match.params;
|
|
1179
|
+
if (match.query) el.query = match.query;
|
|
1180
|
+
this.appendChild(el);
|
|
1181
|
+
this._lastRoute = match.route;
|
|
1182
|
+
}
|
|
1183
|
+
} catch (e) {
|
|
1184
|
+
if (!controller.signal.aborted) {
|
|
1185
|
+
console.error('[Lux Router] Failed to load component:', e);
|
|
1186
|
+
this.textContent = '';
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
_getMatch() {
|
|
1192
|
+
const level = parseInt(this.getAttribute('level')) || 0;
|
|
1193
|
+
let match = router.current;
|
|
1194
|
+
for (let i = 0; i < level; i++) {
|
|
1195
|
+
if (!match || !match.child) return null;
|
|
1196
|
+
match = match.child;
|
|
1197
|
+
}
|
|
1198
|
+
return match;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
registerComponent('router-outlet', RouterOutlet);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
router.start();
|
|
1205
|
+
return router;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* ICU MessageFormat subset parser
|
|
1210
|
+
*
|
|
1211
|
+
* Supports:
|
|
1212
|
+
* - Simple interpolation: "Hello, {name}"
|
|
1213
|
+
* - Plural: "{count, plural, =0{No items} =1{# item} other{# items}}"
|
|
1214
|
+
* - Select: "{gender, select, male{He} female{She} other{They}}"
|
|
1215
|
+
*/
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Find the matching closing brace for an opening brace at position `start`.
|
|
1219
|
+
*/
|
|
1220
|
+
function findMatchingBrace(str, start) {
|
|
1221
|
+
let depth = 1;
|
|
1222
|
+
for (let i = start + 1; i < str.length; i++) {
|
|
1223
|
+
if (str[i] === '{') depth++;
|
|
1224
|
+
else if (str[i] === '}') {
|
|
1225
|
+
depth--;
|
|
1226
|
+
if (depth === 0) return i;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
return -1;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Extract all top-level {...} expressions from the template.
|
|
1234
|
+
*/
|
|
1235
|
+
function extractExpressions(template) {
|
|
1236
|
+
const parts = [];
|
|
1237
|
+
let i = 0;
|
|
1238
|
+
while (i < template.length) {
|
|
1239
|
+
if (template[i] === '{') {
|
|
1240
|
+
const end = findMatchingBrace(template, i);
|
|
1241
|
+
if (end !== -1) {
|
|
1242
|
+
parts.push({ start: i, end, expr: template.slice(i + 1, end) });
|
|
1243
|
+
i = end + 1;
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
i++;
|
|
1248
|
+
}
|
|
1249
|
+
return parts;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function formatMessage(template, values = {}) {
|
|
1253
|
+
if (!template || typeof template !== 'string') return '';
|
|
1254
|
+
|
|
1255
|
+
const exprs = extractExpressions(template);
|
|
1256
|
+
let result = '';
|
|
1257
|
+
let lastIdx = 0;
|
|
1258
|
+
|
|
1259
|
+
for (const { start, end, expr } of exprs) {
|
|
1260
|
+
result += template.slice(lastIdx, start);
|
|
1261
|
+
const trimmed = expr.trim();
|
|
1262
|
+
|
|
1263
|
+
// Plural: {key, plural, =N{...} other{...}}
|
|
1264
|
+
const pluralMatch = trimmed.match(/^(\w+),\s*plural\s*,\s*(.+)$/);
|
|
1265
|
+
if (pluralMatch) {
|
|
1266
|
+
const [, key, rules] = pluralMatch;
|
|
1267
|
+
const count = Number(values[key]) || 0;
|
|
1268
|
+
result += formatPlural(count, rules);
|
|
1269
|
+
lastIdx = end + 1;
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// Select: {key, select, val1{...} val2{...}}
|
|
1274
|
+
const selectMatch = trimmed.match(/^(\w+),\s*select\s*,\s*(.+)$/);
|
|
1275
|
+
if (selectMatch) {
|
|
1276
|
+
const [, key, rules] = selectMatch;
|
|
1277
|
+
const value = String(values[key] || '');
|
|
1278
|
+
result += formatSelect(value, rules);
|
|
1279
|
+
lastIdx = end + 1;
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// Simple interpolation: {key}
|
|
1284
|
+
result += values[trimmed] ?? template.slice(start, end + 1);
|
|
1285
|
+
lastIdx = end + 1;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
result += template.slice(lastIdx);
|
|
1289
|
+
return result;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function formatPlural(count, rulesStr) {
|
|
1293
|
+
const rules = parseRules(rulesStr);
|
|
1294
|
+
const exact = rules[`=${count}`];
|
|
1295
|
+
if (exact !== undefined) return exact.replace(/#/g, String(count));
|
|
1296
|
+
|
|
1297
|
+
const key = count === 1 ? 'one' : 'other';
|
|
1298
|
+
const rule = rules[key] || rules['other'] || '';
|
|
1299
|
+
return rule.replace(/#/g, String(count));
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function formatSelect(value, rulesStr) {
|
|
1303
|
+
const rules = parseRules(rulesStr);
|
|
1304
|
+
return rules[value] || rules['other'] || '';
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function parseRules(rulesStr) {
|
|
1308
|
+
const rules = {};
|
|
1309
|
+
const re = /(\w+|=\d+)\{([^}]*)\}/g;
|
|
1310
|
+
let m;
|
|
1311
|
+
while ((m = re.exec(rulesStr))) {
|
|
1312
|
+
rules[m[1]] = m[2];
|
|
1313
|
+
}
|
|
1314
|
+
return rules;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* Locale detection
|
|
1319
|
+
*/
|
|
1320
|
+
|
|
1321
|
+
function getDefaultLocale() {
|
|
1322
|
+
if (typeof navigator !== 'undefined' && navigator.language) {
|
|
1323
|
+
return navigator.language;
|
|
1324
|
+
}
|
|
1325
|
+
return 'en';
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function formatNumber(value, options = {}, locale) {
|
|
1329
|
+
try {
|
|
1330
|
+
return new Intl.NumberFormat(locale, options).format(value);
|
|
1331
|
+
} catch {
|
|
1332
|
+
return String(value);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function formatDate(value, options = {}, locale) {
|
|
1337
|
+
try {
|
|
1338
|
+
return new Intl.DateTimeFormat(locale, options).format(value);
|
|
1339
|
+
} catch {
|
|
1340
|
+
return String(value);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Lux i18n — Lightweight internationalization
|
|
1346
|
+
*
|
|
1347
|
+
* Usage:
|
|
1348
|
+
* import { createI18n, msg, number, date } from 'lux/i18n';
|
|
1349
|
+
*
|
|
1350
|
+
* const i18n = createI18n({
|
|
1351
|
+
* locale: 'zh-CN',
|
|
1352
|
+
* messages: {
|
|
1353
|
+
* 'zh-CN': { hello: '你好', welcome: '欢迎, {name}' },
|
|
1354
|
+
* 'en': { hello: 'Hello', welcome: 'Welcome, {name}' },
|
|
1355
|
+
* }
|
|
1356
|
+
* });
|
|
1357
|
+
*
|
|
1358
|
+
* // In templates
|
|
1359
|
+
* html`<h1>${msg('hello')}</h1>`
|
|
1360
|
+
* html`<p>${msg('welcome', { name: 'Alice' })}</p>`
|
|
1361
|
+
* html`<span>${msg('items', { count: 5 })}</span>`
|
|
1362
|
+
*
|
|
1363
|
+
* // Number/date formatting
|
|
1364
|
+
* html`<span>${number(1234567)}</span>`
|
|
1365
|
+
* html`<span>${date(new Date())}</span>`
|
|
1366
|
+
*/
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
let currentLocale = '';
|
|
1370
|
+
let currentMessages = {};
|
|
1371
|
+
let listeners = [];
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Get the current translation for a key.
|
|
1375
|
+
*/
|
|
1376
|
+
function msg(key, values = {}) {
|
|
1377
|
+
const dict = currentMessages[currentLocale] || currentMessages;
|
|
1378
|
+
const template = dict[key] ?? key;
|
|
1379
|
+
return formatMessage(template, values);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Format a number using Intl.NumberFormat.
|
|
1384
|
+
*/
|
|
1385
|
+
function number(value, options) {
|
|
1386
|
+
return formatNumber(
|
|
1387
|
+
value,
|
|
1388
|
+
typeof options === 'string' ? { style: options } : options,
|
|
1389
|
+
currentLocale
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Format a date using Intl.DateTimeFormat.
|
|
1395
|
+
*/
|
|
1396
|
+
function date(value, options) {
|
|
1397
|
+
return formatDate(value, options, currentLocale);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* Get the current locale string.
|
|
1402
|
+
*/
|
|
1403
|
+
function getLocale() {
|
|
1404
|
+
return currentLocale;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Create an i18n instance.
|
|
1409
|
+
*
|
|
1410
|
+
* @param {Object} options
|
|
1411
|
+
* @param {string} options.locale - Initial locale (default: navigator.language)
|
|
1412
|
+
* @param {Object} messages - { 'zh-CN': {...}, 'en': {...} }
|
|
1413
|
+
* @returns {Object} i18n instance
|
|
1414
|
+
*/
|
|
1415
|
+
function createI18n(options = {}) {
|
|
1416
|
+
currentLocale = options.locale || getDefaultLocale();
|
|
1417
|
+
currentMessages = options.messages || {};
|
|
1418
|
+
|
|
1419
|
+
function setLocale(locale) {
|
|
1420
|
+
currentLocale = locale;
|
|
1421
|
+
for (const fn of listeners) fn(locale);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function onLocaleChange(fn) {
|
|
1425
|
+
listeners.push(fn);
|
|
1426
|
+
return () => {
|
|
1427
|
+
const i = listeners.indexOf(fn);
|
|
1428
|
+
if (i >= 0) listeners.splice(i, 1);
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
return {
|
|
1433
|
+
get locale() {
|
|
1434
|
+
return currentLocale;
|
|
1435
|
+
},
|
|
1436
|
+
setLocale,
|
|
1437
|
+
onLocaleChange,
|
|
1438
|
+
msg,
|
|
1439
|
+
number,
|
|
1440
|
+
date,
|
|
1441
|
+
getLocale,
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* Color utilities — HSL-based color generation from a seed color
|
|
1447
|
+
*/
|
|
1448
|
+
|
|
1449
|
+
// ─── Conversion ───
|
|
1450
|
+
|
|
1451
|
+
function hexToRgb(hex) {
|
|
1452
|
+
hex = hex.replace('#', '');
|
|
1453
|
+
if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
1454
|
+
return {
|
|
1455
|
+
r: parseInt(hex.slice(0, 2), 16),
|
|
1456
|
+
g: parseInt(hex.slice(2, 4), 16),
|
|
1457
|
+
b: parseInt(hex.slice(4, 6), 16),
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function rgbToHsl(r, g, b) {
|
|
1462
|
+
r /= 255;
|
|
1463
|
+
g /= 255;
|
|
1464
|
+
b /= 255;
|
|
1465
|
+
const max = Math.max(r, g, b),
|
|
1466
|
+
min = Math.min(r, g, b);
|
|
1467
|
+
let h,
|
|
1468
|
+
s,
|
|
1469
|
+
l = (max + min) / 2;
|
|
1470
|
+
|
|
1471
|
+
if (max === min) {
|
|
1472
|
+
h = s = 0;
|
|
1473
|
+
} else {
|
|
1474
|
+
const d = max - min;
|
|
1475
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
1476
|
+
switch (max) {
|
|
1477
|
+
case r:
|
|
1478
|
+
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
1479
|
+
break;
|
|
1480
|
+
case g:
|
|
1481
|
+
h = ((b - r) / d + 2) / 6;
|
|
1482
|
+
break;
|
|
1483
|
+
case b:
|
|
1484
|
+
h = ((r - g) / d + 4) / 6;
|
|
1485
|
+
break;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function hslToRgb(h, s, l) {
|
|
1492
|
+
h /= 360;
|
|
1493
|
+
s /= 100;
|
|
1494
|
+
l /= 100;
|
|
1495
|
+
let r, g, b;
|
|
1496
|
+
|
|
1497
|
+
if (s === 0) {
|
|
1498
|
+
r = g = b = l;
|
|
1499
|
+
} else {
|
|
1500
|
+
const hue2rgb = (p, q, t) => {
|
|
1501
|
+
if (t < 0) t += 1;
|
|
1502
|
+
if (t > 1) t -= 1;
|
|
1503
|
+
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
1504
|
+
if (t < 1 / 2) return q;
|
|
1505
|
+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
1506
|
+
return p;
|
|
1507
|
+
};
|
|
1508
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
1509
|
+
const p = 2 * l - q;
|
|
1510
|
+
r = hue2rgb(p, q, h + 1 / 3);
|
|
1511
|
+
g = hue2rgb(p, q, h);
|
|
1512
|
+
b = hue2rgb(p, q, h - 1 / 3);
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
return {
|
|
1516
|
+
r: Math.round(r * 255),
|
|
1517
|
+
g: Math.round(g * 255),
|
|
1518
|
+
b: Math.round(b * 255),
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
function hexToHsl(hex) {
|
|
1523
|
+
const { r, g, b } = hexToRgb(hex);
|
|
1524
|
+
return rgbToHsl(r, g, b);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function hslToHex(h, s, l) {
|
|
1528
|
+
const { r, g, b } = hslToRgb(h, s, l);
|
|
1529
|
+
return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('');
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function rgbString(r, g, b) {
|
|
1533
|
+
return `${r} ${g} ${b}`;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// ─── Palette generation ───
|
|
1537
|
+
|
|
1538
|
+
/**
|
|
1539
|
+
* Generate a 10-step palette (50-900) from a seed HSL.
|
|
1540
|
+
* Lightness curve: 50→97%, 100→93%, 200→86%, 300→76%, 400→64%,
|
|
1541
|
+
* 500→seed%, 600→45%, 700→38%, 800→28%, 900→18%
|
|
1542
|
+
*/
|
|
1543
|
+
function generatePalette(h, s, l) {
|
|
1544
|
+
const steps = [
|
|
1545
|
+
[50, 97, 90],
|
|
1546
|
+
[100, 93, 85],
|
|
1547
|
+
[200, 86, 78],
|
|
1548
|
+
[300, 76, 68],
|
|
1549
|
+
[400, 64, 58],
|
|
1550
|
+
[500, s, l], // seed
|
|
1551
|
+
[600, s, 45],
|
|
1552
|
+
[700, s, 38],
|
|
1553
|
+
[800, s, 28],
|
|
1554
|
+
[900, s, 18],
|
|
1555
|
+
];
|
|
1556
|
+
|
|
1557
|
+
const palette = {};
|
|
1558
|
+
for (const [step, sat, light] of steps) {
|
|
1559
|
+
palette[step] = hslToHex(h, sat, light);
|
|
1560
|
+
}
|
|
1561
|
+
return palette;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* Generate CSS custom properties (tokens) from a palette.
|
|
1566
|
+
*/
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
function rgb(hex) {
|
|
1570
|
+
const { r, g, b } = hexToRgb(hex);
|
|
1571
|
+
return rgbString(r, g, b);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/**
|
|
1575
|
+
* Generate light theme tokens from palette + semantic colors.
|
|
1576
|
+
*/
|
|
1577
|
+
function generateLightTokens(palette, semantic) {
|
|
1578
|
+
return {
|
|
1579
|
+
// Primary palette
|
|
1580
|
+
'lux-primary-50': rgb(palette[50]),
|
|
1581
|
+
'lux-primary-100': rgb(palette[100]),
|
|
1582
|
+
'lux-primary-200': rgb(palette[200]),
|
|
1583
|
+
'lux-primary-300': rgb(palette[300]),
|
|
1584
|
+
'lux-primary-400': rgb(palette[400]),
|
|
1585
|
+
'lux-primary-500': rgb(palette[500]),
|
|
1586
|
+
'lux-primary-600': rgb(palette[600]),
|
|
1587
|
+
'lux-primary-700': rgb(palette[700]),
|
|
1588
|
+
'lux-primary-800': rgb(palette[800]),
|
|
1589
|
+
'lux-primary-900': rgb(palette[900]),
|
|
1590
|
+
|
|
1591
|
+
// Semantic
|
|
1592
|
+
'lux-success': rgb(semantic.success[500]),
|
|
1593
|
+
'lux-warning': rgb(semantic.warning[500]),
|
|
1594
|
+
'lux-error': rgb(semantic.error[500]),
|
|
1595
|
+
'lux-info': rgb(semantic.info[500]),
|
|
1596
|
+
|
|
1597
|
+
// Surface / background (light mode)
|
|
1598
|
+
'lux-bg': '15 23 42',
|
|
1599
|
+
'lux-bg-alt': '30 41 59',
|
|
1600
|
+
'lux-surface': '15 23 42',
|
|
1601
|
+
'lux-card': '30 41 59',
|
|
1602
|
+
'lux-overlay': '0 0 0 / 50%',
|
|
1603
|
+
|
|
1604
|
+
// Text
|
|
1605
|
+
'lux-text': '241 245 249',
|
|
1606
|
+
'lux-text-dim': '148 163 184',
|
|
1607
|
+
'lux-text-muted': '100 116 139',
|
|
1608
|
+
|
|
1609
|
+
// Border
|
|
1610
|
+
'lux-border': '51 65 85',
|
|
1611
|
+
'lux-border-light': '71 85 105',
|
|
1612
|
+
|
|
1613
|
+
// Interactive
|
|
1614
|
+
'lux-hover': '255 255 255 / 5%',
|
|
1615
|
+
'lux-active': '255 255 255 / 10%',
|
|
1616
|
+
'lux-focus': '0 0 0 / 40%',
|
|
1617
|
+
|
|
1618
|
+
// Radius
|
|
1619
|
+
'lux-radius-sm': '6px',
|
|
1620
|
+
'lux-radius': '8px',
|
|
1621
|
+
'lux-radius-lg': '12px',
|
|
1622
|
+
'lux-radius-xl': '16px',
|
|
1623
|
+
'lux-radius-full': '9999px',
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/**
|
|
1628
|
+
* Generate dark theme tokens.
|
|
1629
|
+
*/
|
|
1630
|
+
function generateDarkTokens(palette, semantic) {
|
|
1631
|
+
return {
|
|
1632
|
+
// Primary — use lighter variants for dark mode
|
|
1633
|
+
'lux-primary-50': rgb(palette[900]),
|
|
1634
|
+
'lux-primary-100': rgb(palette[800]),
|
|
1635
|
+
'lux-primary-200': rgb(palette[700]),
|
|
1636
|
+
'lux-primary-300': rgb(palette[600]),
|
|
1637
|
+
'lux-primary-400': rgb(palette[400]),
|
|
1638
|
+
'lux-primary-500': rgb(palette[500]),
|
|
1639
|
+
'lux-primary-600': rgb(palette[300]),
|
|
1640
|
+
'lux-primary-700': rgb(palette[200]),
|
|
1641
|
+
'lux-primary-800': rgb(palette[100]),
|
|
1642
|
+
'lux-primary-900': rgb(palette[50]),
|
|
1643
|
+
|
|
1644
|
+
// Semantic
|
|
1645
|
+
'lux-success': rgb(semantic.success[500]),
|
|
1646
|
+
'lux-warning': rgb(semantic.warning[500]),
|
|
1647
|
+
'lux-error': rgb(semantic.error[500]),
|
|
1648
|
+
'lux-info': rgb(semantic.info[500]),
|
|
1649
|
+
|
|
1650
|
+
// Surface (dark mode)
|
|
1651
|
+
'lux-bg': '15 23 42',
|
|
1652
|
+
'lux-bg-alt': '30 41 59',
|
|
1653
|
+
'lux-surface': '15 23 42',
|
|
1654
|
+
'lux-card': '30 41 59',
|
|
1655
|
+
'lux-overlay': '0 0 0 / 70%',
|
|
1656
|
+
|
|
1657
|
+
// Text
|
|
1658
|
+
'lux-text': '241 245 249',
|
|
1659
|
+
'lux-text-dim': '148 163 184',
|
|
1660
|
+
'lux-text-muted': '100 116 139',
|
|
1661
|
+
|
|
1662
|
+
// Border
|
|
1663
|
+
'lux-border': '51 65 85',
|
|
1664
|
+
'lux-border-light': '71 85 105',
|
|
1665
|
+
|
|
1666
|
+
// Interactive
|
|
1667
|
+
'lux-hover': '255 255 255 / 8%',
|
|
1668
|
+
'lux-active': '255 255 255 / 12%',
|
|
1669
|
+
'lux-focus': '0 0 0 / 50%',
|
|
1670
|
+
|
|
1671
|
+
// Radius (same as light)
|
|
1672
|
+
'lux-radius-sm': '6px',
|
|
1673
|
+
'lux-radius': '8px',
|
|
1674
|
+
'lux-radius-lg': '12px',
|
|
1675
|
+
'lux-radius-xl': '16px',
|
|
1676
|
+
'lux-radius-full': '9999px',
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/**
|
|
1681
|
+
* Convert token map to CSS string.
|
|
1682
|
+
*/
|
|
1683
|
+
function tokensToCSS(tokens, indent = ' ') {
|
|
1684
|
+
let css = ':root {\n';
|
|
1685
|
+
for (const [key, value] of Object.entries(tokens)) {
|
|
1686
|
+
css += `${indent}--${key}: ${value};\n`;
|
|
1687
|
+
}
|
|
1688
|
+
css += '}';
|
|
1689
|
+
return css;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* createTheme — generate a complete theme from colors.
|
|
1694
|
+
*
|
|
1695
|
+
* Usage:
|
|
1696
|
+
* const theme = createTheme({ primary: '#6c5ce7' });
|
|
1697
|
+
* const theme = createTheme({
|
|
1698
|
+
* primary: '#6c5ce7', success: '#10b981',
|
|
1699
|
+
* warning: '#f59e0b', error: '#ef4444', info: '#3b82f6'
|
|
1700
|
+
* });
|
|
1701
|
+
* theme.setColors({ success: '#22c55e' });
|
|
1702
|
+
* theme.apply();
|
|
1703
|
+
*/
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
const defaultColors = {
|
|
1707
|
+
primary: '#6c5ce7',
|
|
1708
|
+
success: '#10b981',
|
|
1709
|
+
warning: '#f59e0b',
|
|
1710
|
+
error: '#ef4444',
|
|
1711
|
+
info: '#3b82f6',
|
|
1712
|
+
};
|
|
1713
|
+
|
|
1714
|
+
function createTheme(options = {}) {
|
|
1715
|
+
if (typeof options === 'string') options = { primary: options };
|
|
1716
|
+
|
|
1717
|
+
const _colors = { ...defaultColors, ...options.colors };
|
|
1718
|
+
let _dark = options.dark ?? false;
|
|
1719
|
+
let _radius = options.radius ?? '8px';
|
|
1720
|
+
let _font =
|
|
1721
|
+
options.font ?? '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif';
|
|
1722
|
+
|
|
1723
|
+
function _build() {
|
|
1724
|
+
const { h, s, l } = hexToHsl(_colors.primary);
|
|
1725
|
+
const palette = generatePalette(h, s, l);
|
|
1726
|
+
|
|
1727
|
+
const semantic = {};
|
|
1728
|
+
for (const [name, hex] of Object.entries(_colors)) {
|
|
1729
|
+
if (name === 'primary') continue;
|
|
1730
|
+
const { h: sh} = hexToHsl(hex);
|
|
1731
|
+
semantic[name] = { 500: hex, light: hslToHex(sh, 60, 93) };
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
const tokens = _dark
|
|
1735
|
+
? generateDarkTokens(palette, semantic)
|
|
1736
|
+
: generateLightTokens(palette, semantic);
|
|
1737
|
+
|
|
1738
|
+
tokens['lux-radius'] = _radius;
|
|
1739
|
+
tokens['lux-font'] = _font;
|
|
1740
|
+
|
|
1741
|
+
return tokens;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
function _inject(tokens) {
|
|
1745
|
+
let el = document.getElementById('lux-theme');
|
|
1746
|
+
if (!el) {
|
|
1747
|
+
el = document.createElement('style');
|
|
1748
|
+
el.id = 'lux-theme';
|
|
1749
|
+
document.head.appendChild(el);
|
|
1750
|
+
}
|
|
1751
|
+
el.textContent = tokensToCSS(tokens);
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
return {
|
|
1755
|
+
apply() {
|
|
1756
|
+
_inject(_build());
|
|
1757
|
+
},
|
|
1758
|
+
|
|
1759
|
+
setColors(patch) {
|
|
1760
|
+
Object.assign(_colors, patch);
|
|
1761
|
+
this.apply();
|
|
1762
|
+
},
|
|
1763
|
+
|
|
1764
|
+
setDark(dark) {
|
|
1765
|
+
_dark = dark;
|
|
1766
|
+
this.apply();
|
|
1767
|
+
},
|
|
1768
|
+
getCSS() {
|
|
1769
|
+
return tokensToCSS(_build());
|
|
1770
|
+
},
|
|
1771
|
+
getTokens() {
|
|
1772
|
+
return _build();
|
|
1773
|
+
},
|
|
1774
|
+
getColors() {
|
|
1775
|
+
return { ..._colors };
|
|
1776
|
+
},
|
|
1777
|
+
|
|
1778
|
+
get primary() {
|
|
1779
|
+
return _colors.primary;
|
|
1780
|
+
},
|
|
1781
|
+
get dark() {
|
|
1782
|
+
return _dark;
|
|
1783
|
+
},
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
/**
|
|
1788
|
+
* Lux — Lightweight Web Components Framework
|
|
1789
|
+
*/
|
|
1790
|
+
|
|
1791
|
+
|
|
1792
|
+
const version = '0.1.0';
|
|
1793
|
+
|
|
1794
|
+
exports.LuxElement = LuxElement;
|
|
1795
|
+
exports.classMap = classMap;
|
|
1796
|
+
exports.createComponent = createComponent;
|
|
1797
|
+
exports.createI18n = createI18n;
|
|
1798
|
+
exports.createRouter = createRouter;
|
|
1799
|
+
exports.createTheme = createTheme;
|
|
1800
|
+
exports.css = css;
|
|
1801
|
+
exports.date = date;
|
|
1802
|
+
exports.escapeHtml = escapeHtml;
|
|
1803
|
+
exports.getLocale = getLocale;
|
|
1804
|
+
exports.guard = guard;
|
|
1805
|
+
exports.html = html;
|
|
1806
|
+
exports.isTemplateResult = isTemplateResult;
|
|
1807
|
+
exports.msg = msg;
|
|
1808
|
+
exports.nothing = nothing;
|
|
1809
|
+
exports.number = number;
|
|
1810
|
+
exports.registerComponent = registerComponent$1;
|
|
1811
|
+
exports.render = render;
|
|
1812
|
+
exports.repeat = repeat;
|
|
1813
|
+
exports.show = show;
|
|
1814
|
+
exports.styleMap = styleMap;
|
|
1815
|
+
exports.svg = svg;
|
|
1816
|
+
exports.version = version;
|
|
1817
|
+
exports.when = when;
|
|
1818
|
+
|
|
1819
|
+
return exports;
|
|
1820
|
+
|
|
1821
|
+
})({});
|
|
1822
|
+
//# sourceMappingURL=lux.iife.js.map
|