@adia-ai/web-components 0.8.4 → 0.8.6
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 +31 -1
- package/USAGE.md +60 -25
- package/components/adia-mark/adia-mark.a2ui.json +123 -0
- package/components/adia-mark/adia-mark.class.js +64 -0
- package/components/adia-mark/adia-mark.css +48 -0
- package/components/adia-mark/adia-mark.d.ts +20 -0
- package/components/adia-mark/adia-mark.examples.md +26 -0
- package/components/adia-mark/adia-mark.js +17 -0
- package/components/adia-mark/adia-mark.test.js +69 -0
- package/components/adia-mark/adia-mark.yaml +121 -0
- package/components/agent-questions/agent-questions.a2ui.json +2 -1
- package/components/agent-questions/agent-questions.class.js +96 -53
- package/components/agent-questions/agent-questions.css +27 -106
- package/components/agent-questions/agent-questions.yaml +1 -0
- package/components/button/button.class.js +5 -1
- package/components/calendar-grid/calendar-grid.a2ui.json +3 -1
- package/components/calendar-grid/calendar-grid.class.js +11 -6
- package/components/calendar-grid/calendar-grid.css +42 -20
- package/components/calendar-grid/calendar-grid.yaml +5 -0
- package/components/calendar-picker/calendar-picker.a2ui.json +3 -1
- package/components/calendar-picker/calendar-picker.class.js +11 -6
- package/components/calendar-picker/calendar-picker.css +39 -19
- package/components/calendar-picker/calendar-picker.yaml +5 -0
- package/components/chart/chart.class.js +17 -14
- package/components/color-input/color-input.class.js +8 -5
- package/components/color-picker/color-picker.class.js +6 -3
- package/components/field/field.class.js +10 -7
- package/components/field/field.css +21 -0
- package/components/fields/fields.class.js +15 -12
- package/components/icon/icon.class.js +9 -5
- package/components/index.js +1 -0
- package/components/nav/nav.class.js +2 -1
- package/components/noodles/noodles.class.js +14 -10
- package/components/page/page.class.js +3 -2
- package/components/select/select.class.js +3 -2
- package/components/swatch/swatch.class.js +27 -24
- package/components/swiper/swiper.class.js +4 -1
- package/components/swiper/swiper.css +13 -4
- package/components/table/table.a2ui.json +1 -1
- package/components/table/table.class.js +17 -0
- package/components/table/table.css +15 -2
- package/components/table/table.d.ts +1 -1
- package/components/table/table.test.js +51 -0
- package/components/table/table.yaml +6 -1
- package/components/toolbar/toolbar.class.js +11 -6
- package/components/tree/tree.class.js +5 -2
- package/core/data-stream.js +21 -15
- package/core/element.js +60 -1
- package/core/element.test.js +138 -0
- package/core/icons-phosphor.js +86 -22
- package/core/icons.js +22 -8
- package/dist/host.min.css +1 -1
- package/dist/host.sheet.js +1 -1
- package/dist/theme-provider.min.js +2 -2
- package/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +107 -102
- package/dist/web-components.sheet.js +1 -1
- package/package.json +1 -5
- package/styles/colors/semantics/core.css +1 -1
- package/styles/colors/semantics/features.css +1 -1
- package/styles/components.css +1 -0
- package/traits/error-shake/error-shake.js +23 -17
- package/traits/fade-presence/fade-presence.js +19 -15
- package/traits/success-checkmark/success-checkmark.js +21 -16
|
@@ -208,31 +208,34 @@ export class UISwatch extends UIElement {
|
|
|
208
208
|
// the canonical post-tile region. MutationObserver closes this gap for
|
|
209
209
|
// every arrival path: single-template ordering skew, separate render
|
|
210
210
|
// cycle, or imperative host.appendChild().
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
n.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
211
|
+
// gh#285 — SSR DOM shims (linkedom) have no MutationObserver global.
|
|
212
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
213
|
+
this.#chromeObserver = new MutationObserver((mutations) => {
|
|
214
|
+
let hasChrome = false;
|
|
215
|
+
for (const m of mutations) {
|
|
216
|
+
for (const n of m.addedNodes) {
|
|
217
|
+
if (n.nodeType !== 1) continue;
|
|
218
|
+
// Direct chrome child OR a display:contents wrapper-span carrying chrome.
|
|
219
|
+
if (n.getAttribute?.('slot') === 'chrome') { hasChrome = true; break; }
|
|
220
|
+
if (
|
|
221
|
+
n.tagName === 'SPAN' &&
|
|
222
|
+
n.style?.display === 'contents' &&
|
|
223
|
+
n.querySelector?.('[slot="chrome"]')
|
|
224
|
+
) { hasChrome = true; break; }
|
|
225
|
+
}
|
|
226
|
+
if (hasChrome) break;
|
|
223
227
|
}
|
|
224
|
-
if (hasChrome)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
this.#chromeObserver.
|
|
234
|
-
}
|
|
235
|
-
this.#chromeObserver.observe(this, { childList: true });
|
|
228
|
+
if (!hasChrome) return;
|
|
229
|
+
// Re-entrancy guard: #absorbChromeSlot() calls insertBefore() which itself
|
|
230
|
+
// generates childList mutations on the host. Without this guard, every
|
|
231
|
+
// absorb pass would re-trigger the observer indefinitely. takeRecords()
|
|
232
|
+
// drains the queue without dispatch so the next observation cycle starts
|
|
233
|
+
// clean.
|
|
234
|
+
this.#absorbChromeSlot();
|
|
235
|
+
this.#chromeObserver.takeRecords();
|
|
236
|
+
});
|
|
237
|
+
this.#chromeObserver.observe(this, { childList: true });
|
|
238
|
+
}
|
|
236
239
|
}
|
|
237
240
|
|
|
238
241
|
render() {
|
|
@@ -436,7 +436,10 @@ export class UISwiper extends UIElement {
|
|
|
436
436
|
if (dots.children.length !== desired) {
|
|
437
437
|
while (dots.firstChild) dots.removeChild(dots.firstChild);
|
|
438
438
|
for (let i = 0; i < desired; i++) {
|
|
439
|
-
|
|
439
|
+
// button-ui, not a native <button> (gh issue 276 wave 4) — it
|
|
440
|
+
// respects the consumer-set role, so the tab semantics survive.
|
|
441
|
+
const dot = document.createElement('button-ui');
|
|
442
|
+
dot.setAttribute('variant', 'ghost');
|
|
440
443
|
dot.setAttribute('role', 'tab');
|
|
441
444
|
dot.setAttribute('aria-label', `Page ${i + 1}`);
|
|
442
445
|
dot.addEventListener('click', () => this.goTo(this.#pageToSlideIndex(i)));
|
|
@@ -191,7 +191,16 @@
|
|
|
191
191
|
`--swiper-dot-size` for idle dots and `--swiper-dot-size-active`
|
|
192
192
|
for the active one (one space token larger). Padding is computed
|
|
193
193
|
from the size so the dot stays centered in the hitbox. */
|
|
194
|
-
:scope [data-swiper-dots] > button {
|
|
194
|
+
:scope [data-swiper-dots] > button-ui {
|
|
195
|
+
/* Stamped <button-ui variant="ghost" role="tab"> (gh issue 276 wave 4).
|
|
196
|
+
The dot look is bespoke (content-box clip keeps a 1rem hitbox around
|
|
197
|
+
a --swiper-dot-size circle) — these host declarations out-specify
|
|
198
|
+
button-ui's token-driven chrome; the ghost variant keeps its own
|
|
199
|
+
hover fill out of the way. */
|
|
200
|
+
/* button-ui floors min-width/height at --button-height — point it at
|
|
201
|
+
the VISIBLE circle so the padding (not the min-floor) builds the
|
|
202
|
+
1rem hitbox, exactly like the native dot did. */
|
|
203
|
+
--button-height: var(--swiper-dot-size);
|
|
195
204
|
width: var(--swiper-dot-size);
|
|
196
205
|
height: var(--swiper-dot-size);
|
|
197
206
|
border-radius: var(--swiper-dot-radius);
|
|
@@ -208,18 +217,18 @@
|
|
|
208
217
|
`background-clip: content-box` from the base rule isn't reset to
|
|
209
218
|
its initial `border-box` value — the colored area would otherwise
|
|
210
219
|
fill the full 1rem hitbox and turn into a huge circle. */
|
|
211
|
-
:scope [data-swiper-dots] > button:hover {
|
|
220
|
+
:scope [data-swiper-dots] > button-ui:hover {
|
|
212
221
|
background-color: var(--swiper-dot-bg-active);
|
|
213
222
|
}
|
|
214
223
|
|
|
215
224
|
/* Focus ring is rendered INSIDE the padding area (inset shadow), so
|
|
216
225
|
the dot's visual footprint stays the size of every other dot. */
|
|
217
|
-
:scope [data-swiper-dots] > button:focus-visible {
|
|
226
|
+
:scope [data-swiper-dots] > button-ui:focus-visible {
|
|
218
227
|
outline: none;
|
|
219
228
|
box-shadow: inset 0 0 0 var(--a-focus-width) var(--a-focus-color);
|
|
220
229
|
}
|
|
221
230
|
|
|
222
|
-
:scope [data-swiper-dots] > button[aria-current="true"] {
|
|
231
|
+
:scope [data-swiper-dots] > button-ui[aria-current="true"] {
|
|
223
232
|
background-color: var(--swiper-dot-bg-active);
|
|
224
233
|
width: var(--swiper-dot-size-active);
|
|
225
234
|
height: var(--swiper-dot-size-active);
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"const": "Table"
|
|
22
22
|
},
|
|
23
23
|
"data": {
|
|
24
|
-
"description": "
|
|
24
|
+
"description": "JS property (set programmatically — `el.data = [...]`) — row records, an array of plain objects keyed to columns[].key. May also be supplied declaratively as a JSON-array `data=\"[…]\"` attribute, hydrated once at connect (gh#288) — the same pattern chart-ui's `data` attribute uses. Custom accessor on the element class, not a reflected attribute.",
|
|
25
25
|
"$ref": "common_types.json#/$defs/DynamicObjectList"
|
|
26
26
|
},
|
|
27
27
|
"density": {
|
|
@@ -341,6 +341,23 @@ export class UITable extends UIElement {
|
|
|
341
341
|
// Parse declarative <col-def> children
|
|
342
342
|
this.#parseColDefs();
|
|
343
343
|
|
|
344
|
+
/* gh#288 — hydrate from an inline `data="[…]"` HTML attribute, same
|
|
345
|
+
pattern as chart-ui's `data` attribute (chart.class.js connected()).
|
|
346
|
+
The canonical entry point is the `.data` property (set
|
|
347
|
+
programmatically), but a static-HTML/SSR-authored table has no other
|
|
348
|
+
way to seed rows — `columns` has `<col-def>` children as its
|
|
349
|
+
declarative form; `data` had none. JSON-parse once at connect; a
|
|
350
|
+
malformed payload is ignored silently, and a later property
|
|
351
|
+
assignment still wins (guarded by the `#data.length === 0` check, so
|
|
352
|
+
a real programmatic `.data = [...]` set before connect is never
|
|
353
|
+
overwritten by a stale attribute). */
|
|
354
|
+
if (this.#data.length === 0 && this.hasAttribute('data')) {
|
|
355
|
+
try {
|
|
356
|
+
const parsed = JSON.parse(this.getAttribute('data'));
|
|
357
|
+
if (Array.isArray(parsed)) this.data = parsed;
|
|
358
|
+
} catch (_) { /* malformed JSON — leave empty, render() shows empty state */ }
|
|
359
|
+
}
|
|
360
|
+
|
|
344
361
|
this.setAttribute('role', 'grid');
|
|
345
362
|
this.setAttribute('tabindex', '0');
|
|
346
363
|
|
|
@@ -250,7 +250,6 @@
|
|
|
250
250
|
align-items: center;
|
|
251
251
|
align-self: stretch;
|
|
252
252
|
padding: var(--table-py) var(--table-px);
|
|
253
|
-
border-bottom: 1px solid var(--table-border);
|
|
254
253
|
min-width: 0;
|
|
255
254
|
/* Default: single line with ellipsis. Authors opt in to wrapping
|
|
256
255
|
via [wrap] on the host (whole table) or [data-wrap] on a column /
|
|
@@ -262,6 +261,17 @@
|
|
|
262
261
|
text-overflow: ellipsis;
|
|
263
262
|
}
|
|
264
263
|
|
|
264
|
+
/* Row divider as border-top, not border-bottom (TKT-0026) — a
|
|
265
|
+
border-bottom on every cell put the LAST row's own border at the
|
|
266
|
+
same coordinate as :scope's inset box-shadow perimeter (:133),
|
|
267
|
+
doubling the table's bottom edge. border-top divides every OTHER
|
|
268
|
+
adjacent row pair identically; :not(:first-child) keeps the first
|
|
269
|
+
body row from doubling against [data-header]'s own border-bottom
|
|
270
|
+
(:170) instead — the same class of collision, just relocated. */
|
|
271
|
+
[data-body] [role="row"]:not(:first-child) [role="gridcell"] {
|
|
272
|
+
border-top: 1px solid var(--table-border);
|
|
273
|
+
}
|
|
274
|
+
|
|
265
275
|
/* text-overflow on a flex container doesn't reach text nodes (anonymous
|
|
266
276
|
flex items). Built-in cell types now wrap text in <span>; link type
|
|
267
277
|
creates <a>; avatar/progress composite types use row-ui > span. */
|
|
@@ -342,8 +352,11 @@
|
|
|
342
352
|
|
|
343
353
|
/* ═══════ Striped ═══════ */
|
|
344
354
|
|
|
355
|
+
/* border-top, not border-bottom (TKT-0026) — matches the base rule's
|
|
356
|
+
own divider property now; striping uses background alone to
|
|
357
|
+
distinguish rows, no divider lines. */
|
|
345
358
|
:scope[striped] [data-body] [role="gridcell"] {
|
|
346
|
-
border-
|
|
359
|
+
border-top: none;
|
|
347
360
|
}
|
|
348
361
|
|
|
349
362
|
:scope[striped] [data-body] > [role="row"]:nth-child(even) {
|
|
@@ -88,7 +88,7 @@ export type TableSortEvent = CustomEvent<TableSortEventDetail>;
|
|
|
88
88
|
export class UITable extends UIElement {
|
|
89
89
|
/** Column definitions. Array of {key, label, type?, width?, minWidth?, maxWidth?, flex?, sortable?, resizable?, filterable?, pinned?, hidden?, wrap?, accessor?, format?, render?, sortFn?, filterType?, meta?}. Alternative to declarative <col-def> children. */
|
|
90
90
|
columns: string;
|
|
91
|
-
/**
|
|
91
|
+
/** JS property (set programmatically — `el.data = [...]`) — row records, an array of plain objects keyed to columns[].key. May also be supplied declaratively as a JSON-array `data="[…]"` attribute, hydrated once at connect (gh#288) — the same pattern chart-ui's `data` attribute uses. Custom accessor on the element class, not a reflected attribute. */
|
|
92
92
|
data: string;
|
|
93
93
|
/** Controls cell padding and row height. 'compact' for dense data, 'standard' for default spacing, 'comfortable' for spacious rows. */
|
|
94
94
|
density: 'compact' | 'standard' | 'comfortable';
|
|
@@ -426,3 +426,54 @@ describe('table-ui — "markdown" cell type (TKT-0008)', () => {
|
|
|
426
426
|
expect(cell.textContent).toBe('Plain sentence, no special characters.');
|
|
427
427
|
});
|
|
428
428
|
});
|
|
429
|
+
|
|
430
|
+
// gh#288 — declarative `data="[…]"` attribute, mirroring chart-ui's existing
|
|
431
|
+
// pattern. Row data previously had no declarative form at all — `columns`
|
|
432
|
+
// already had `<col-def>` children; `data` had nothing, so a static-HTML or
|
|
433
|
+
// SSR-authored table always rendered its data empty.
|
|
434
|
+
describe('table-ui — declarative data="[…]" attribute (gh#288)', () => {
|
|
435
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
436
|
+
|
|
437
|
+
it('hydrates .data from a JSON-array data attribute at connect', async () => {
|
|
438
|
+
const el = mount(
|
|
439
|
+
`<table-ui data='${JSON.stringify(ROWS)}'></table-ui>`,
|
|
440
|
+
);
|
|
441
|
+
el.columns = COLS;
|
|
442
|
+
await raf();
|
|
443
|
+
expect(el.data).toEqual(ROWS);
|
|
444
|
+
expect(el.querySelectorAll('[role="gridcell"][data-key="name"]').length).toBe(2);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('a malformed data attribute is ignored silently, not thrown', async () => {
|
|
448
|
+
expect(() => mount(`<table-ui data='not json'></table-ui>`)).not.toThrow();
|
|
449
|
+
const el = document.querySelector('table-ui');
|
|
450
|
+
expect(el.data).toEqual([]);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('a non-array JSON value in the attribute is ignored, not coerced', async () => {
|
|
454
|
+
const el = mount(`<table-ui data='{"not":"an array"}'></table-ui>`);
|
|
455
|
+
await raf();
|
|
456
|
+
expect(el.data).toEqual([]);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('a programmatic .data set before connect wins over the attribute', () => {
|
|
460
|
+
const el = document.createElement('table-ui');
|
|
461
|
+
el.setAttribute('data', JSON.stringify(ROWS));
|
|
462
|
+
el.data = [{ id: 99, name: 'Override', email: 'override@acme.com' }];
|
|
463
|
+
document.body.appendChild(el);
|
|
464
|
+
expect(el.data).toEqual([{ id: 99, name: 'Override', email: 'override@acme.com' }]);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('works together with declarative <col-def> children — fully static-HTML table', async () => {
|
|
468
|
+
const el = mount(`
|
|
469
|
+
<table-ui data='${JSON.stringify(ROWS)}'>
|
|
470
|
+
<col-def key="id" label="ID"></col-def>
|
|
471
|
+
<col-def key="name" label="Name"></col-def>
|
|
472
|
+
</table-ui>
|
|
473
|
+
`);
|
|
474
|
+
await raf();
|
|
475
|
+
expect(el.data).toEqual(ROWS);
|
|
476
|
+
expect(el.columns.length).toBe(2);
|
|
477
|
+
expect(el.querySelectorAll('[role="gridcell"][data-key="name"]').length).toBe(2);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
@@ -25,7 +25,12 @@ props:
|
|
|
25
25
|
default: []
|
|
26
26
|
dynamic: true # JS-set collection prop with a custom setter — deliberately not in static properties
|
|
27
27
|
data:
|
|
28
|
-
description:
|
|
28
|
+
description: >-
|
|
29
|
+
JS property (set programmatically — `el.data = [...]`) — row records,
|
|
30
|
+
an array of plain objects keyed to columns[].key. May also be supplied
|
|
31
|
+
declaratively as a JSON-array `data="[…]"` attribute, hydrated once at
|
|
32
|
+
connect (gh#288) — the same pattern chart-ui's `data` attribute uses.
|
|
33
|
+
Custom accessor on the element class, not a reflected attribute.
|
|
29
34
|
type: array
|
|
30
35
|
items:
|
|
31
36
|
type: object
|
|
@@ -114,12 +114,17 @@ export class UIToolbar extends UIElement {
|
|
|
114
114
|
connected() {
|
|
115
115
|
this.setAttribute('role', 'toolbar');
|
|
116
116
|
this.#ensureSpillover();
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
117
|
+
// gh#285 — SSR DOM shims (linkedom) have neither observer global.
|
|
118
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
119
|
+
this.#ro = new ResizeObserver(() => this.#queueReflow());
|
|
120
|
+
this.#ro.observe(this);
|
|
121
|
+
}
|
|
122
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
123
|
+
this.#mo = new MutationObserver(() => {
|
|
124
|
+
if (this.#measuring) return;
|
|
125
|
+
this.#queueReflow();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
123
128
|
this.#moObserve();
|
|
124
129
|
this.#queueReflow();
|
|
125
130
|
}
|
|
@@ -324,8 +324,11 @@ export class UITreeItem extends UIElement {
|
|
|
324
324
|
// role=button child of the group/tree (axe `aria-required-children`). Adopt
|
|
325
325
|
// what's present now, then observe for the late arrivals.
|
|
326
326
|
this.#adoptSlotted();
|
|
327
|
-
|
|
328
|
-
|
|
327
|
+
// gh#285 — SSR DOM shims (linkedom) have no MutationObserver global.
|
|
328
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
329
|
+
this.#slotObserver = new MutationObserver(() => this.#adoptSlotted());
|
|
330
|
+
this.#slotObserver.observe(this, { childList: true, subtree: true });
|
|
331
|
+
}
|
|
329
332
|
}
|
|
330
333
|
|
|
331
334
|
#stamp() {
|
package/core/data-stream.js
CHANGED
|
@@ -453,24 +453,30 @@ function visitSubtree(root, fn) {
|
|
|
453
453
|
}
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
456
|
+
// gh#285 — SSR DOM shims (linkedom) have no MutationObserver global.
|
|
457
|
+
// Constructed lazily so import-time evaluation never throws; bootstrap()
|
|
458
|
+
// no-ops the document-level watch when the global is missing (existing
|
|
459
|
+
// stream elements found via querySelectorAll still get started once).
|
|
460
|
+
const observer = typeof MutationObserver !== 'undefined'
|
|
461
|
+
? new MutationObserver((mutations) => {
|
|
462
|
+
for (const m of mutations) {
|
|
463
|
+
if (m.type === 'attributes' && ATTR_FILTER.includes(m.attributeName)) {
|
|
464
|
+
const el = m.target;
|
|
465
|
+
if (isStreamingEl(el)) start(el);
|
|
466
|
+
else stop(el);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
if (m.type === 'childList') {
|
|
470
|
+
m.addedNodes.forEach(n => visitSubtree(n, start));
|
|
471
|
+
m.removedNodes.forEach(n => visitSubtree(n, stop));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
})
|
|
475
|
+
: null;
|
|
470
476
|
|
|
471
477
|
function bootstrap() {
|
|
472
478
|
if (typeof document === 'undefined') return;
|
|
473
|
-
observer
|
|
479
|
+
observer?.observe(document.documentElement, {
|
|
474
480
|
childList: true, subtree: true,
|
|
475
481
|
attributes: true, attributeFilter: ATTR_FILTER,
|
|
476
482
|
});
|
package/core/element.js
CHANGED
|
@@ -64,6 +64,26 @@ function installProps(el, props) {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// A minimal no-op ElementInternals shim for environments with no real
|
|
68
|
+
// implementation (linkedom and other DOM-shim SSR passes — gh#285). Real
|
|
69
|
+
// form-participation/validation behavior is meaningless server-side (no
|
|
70
|
+
// live user interaction to validate); the shim exists so the ~12
|
|
71
|
+
// call sites across the framework that read `this.internals.setValidity(…)`
|
|
72
|
+
// / `.setFormValue(…)` / `.validity` etc. keep working as inert no-ops
|
|
73
|
+
// instead of throwing on a missing method. Surface matches exactly what
|
|
74
|
+
// the framework's own consumers call (grepped, not guessed).
|
|
75
|
+
const NOOP_INTERNALS = Object.freeze({
|
|
76
|
+
setFormValue() {},
|
|
77
|
+
setValidity() {},
|
|
78
|
+
checkValidity() { return true; },
|
|
79
|
+
reportValidity() { return true; },
|
|
80
|
+
get form() { return null; },
|
|
81
|
+
get labels() { return []; },
|
|
82
|
+
get validity() { return {}; },
|
|
83
|
+
get validationMessage() { return ''; },
|
|
84
|
+
get willValidate() { return false; },
|
|
85
|
+
});
|
|
86
|
+
|
|
67
87
|
function reflect(el, a, v, t) {
|
|
68
88
|
t === Boolean
|
|
69
89
|
? v ? el.setAttribute(a, '') : el.removeAttribute(a)
|
|
@@ -78,6 +98,11 @@ function adoptStyles(ctor) {
|
|
|
78
98
|
ctor._sa = true;
|
|
79
99
|
const sheets = ctor.styles;
|
|
80
100
|
if (!sheets) return;
|
|
101
|
+
// gh#285 — linkedom's document has no adoptedStyleSheets at all; SSR
|
|
102
|
+
// has no rendered stylesheet to adopt into anyway (styles matter for
|
|
103
|
+
// paint, which doesn't happen server-side), so skipping is a correct
|
|
104
|
+
// no-op, not a degraded feature.
|
|
105
|
+
if (!('adoptedStyleSheets' in document)) return;
|
|
81
106
|
const list = Array.isArray(sheets) ? sheets : [sheets];
|
|
82
107
|
document.adoptedStyleSheets = [...document.adoptedStyleSheets, ...list.filter(s => !document.adoptedStyleSheets.includes(s))];
|
|
83
108
|
}
|
|
@@ -114,7 +139,17 @@ export class UIElement extends HTMLElement {
|
|
|
114
139
|
|
|
115
140
|
constructor() {
|
|
116
141
|
super();
|
|
117
|
-
|
|
142
|
+
// gh#285 — linkedom (and other DOM-shim SSR passes) implement no
|
|
143
|
+
// ElementInternals; an unconditional attachInternals() call threw
|
|
144
|
+
// HERE, before ANY subclass code ran — the single highest-leverage
|
|
145
|
+
// crash site in the whole framework under SSR. Feature-detect and
|
|
146
|
+
// fall back to a no-op shim (form participation is meaningless
|
|
147
|
+
// server-side) rather than leaving `this.internals` undefined,
|
|
148
|
+
// which would just move the crash to every consumer's first
|
|
149
|
+
// `.setValidity(…)` call instead of fixing it.
|
|
150
|
+
this.internals = typeof this.attachInternals === 'function'
|
|
151
|
+
? this.attachInternals()
|
|
152
|
+
: NOOP_INTERNALS;
|
|
118
153
|
installProps(this, this.constructor.properties);
|
|
119
154
|
}
|
|
120
155
|
|
|
@@ -123,6 +158,30 @@ export class UIElement extends HTMLElement {
|
|
|
123
158
|
if (!ctor._tag) ctor._tag = this.localName;
|
|
124
159
|
adoptStyles(ctor);
|
|
125
160
|
prepareParts(ctor);
|
|
161
|
+
// gh#284 — the custom-elements spec's "upgrade an element" algorithm
|
|
162
|
+
// (§4.13.5 step 6) requires replaying attributeChangedCallback for
|
|
163
|
+
// every attribute already present on the element BEFORE
|
|
164
|
+
// connectedCallback fires. Several DOM shims used for SSR (linkedom)
|
|
165
|
+
// and this repo's own happy-dom test environment skip that replay —
|
|
166
|
+
// confirmed directly (element.test.js, "SSR attribute-upgrade replay
|
|
167
|
+
// (gh#284)"): a bare custom element upgraded with a pre-existing
|
|
168
|
+
// observed attribute gets connectedCallback but never the matching
|
|
169
|
+
// attributeChangedCallback.
|
|
170
|
+
// Any reflected property whose only value came from server-rendered
|
|
171
|
+
// or otherwise pre-parsed HTML is silently stuck at its class
|
|
172
|
+
// default — e.g. <nav-item-ui text="Profile"> renders an empty
|
|
173
|
+
// label. connectedCallback fires reliably everywhere, so re-sync
|
|
174
|
+
// every declared property from its live attribute here rather than
|
|
175
|
+
// trusting the shim's upgrade-reaction queue. A no-op in spec-
|
|
176
|
+
// compliant environments: the value already matches (the property
|
|
177
|
+
// setter's Object.is check short-circuits), so this never causes an
|
|
178
|
+
// extra render pass on browsers/environments that already got it right.
|
|
179
|
+
for (const [key, cfg] of Object.entries(ctor.properties)) {
|
|
180
|
+
const attr = cfg.attribute ?? key.toLowerCase();
|
|
181
|
+
if (this.hasAttribute(attr)) {
|
|
182
|
+
this[key] = parseAttr(this.getAttribute(attr), cfg.type ?? String);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
126
185
|
// connected() commonly reads this element's reactive properties via
|
|
127
186
|
// template-literal interpolation (`${this.label}` etc.) to stamp its
|
|
128
187
|
// inner DOM. If the outer call path is inside another effect (e.g.
|
package/core/element.test.js
CHANGED
|
@@ -249,3 +249,141 @@ describe('UIElement — addTrait', () => {
|
|
|
249
249
|
expect(disconnects).toBe(1);
|
|
250
250
|
});
|
|
251
251
|
});
|
|
252
|
+
|
|
253
|
+
// gh#285 — SSR DOM shims (linkedom et al.) implement no ElementInternals
|
|
254
|
+
// and no document.adoptedStyleSheets. These tests simulate that absence
|
|
255
|
+
// directly (deleting the real APIs) rather than trusting a mock — the
|
|
256
|
+
// bug was an unconditional call crashing the CONSTRUCTOR, before any
|
|
257
|
+
// subclass code ran, so the only real proof is exercising construction
|
|
258
|
+
// with the API genuinely gone.
|
|
259
|
+
describe('UIElement — SSR browser-API absence (gh#285)', () => {
|
|
260
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
261
|
+
|
|
262
|
+
it('constructs without throwing when attachInternals does not exist', () => {
|
|
263
|
+
const original = HTMLElement.prototype.attachInternals;
|
|
264
|
+
delete HTMLElement.prototype.attachInternals;
|
|
265
|
+
try {
|
|
266
|
+
class El extends UIElement {}
|
|
267
|
+
const tag = registerTestElement(El);
|
|
268
|
+
expect(() => mount(tag)).not.toThrow();
|
|
269
|
+
} finally {
|
|
270
|
+
HTMLElement.prototype.attachInternals = original;
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('the no-op internals shim answers every call site the framework actually makes', () => {
|
|
275
|
+
const original = HTMLElement.prototype.attachInternals;
|
|
276
|
+
delete HTMLElement.prototype.attachInternals;
|
|
277
|
+
let el;
|
|
278
|
+
try {
|
|
279
|
+
class El extends UIElement {}
|
|
280
|
+
const tag = registerTestElement(El);
|
|
281
|
+
el = mount(tag);
|
|
282
|
+
} finally {
|
|
283
|
+
HTMLElement.prototype.attachInternals = original;
|
|
284
|
+
}
|
|
285
|
+
expect(() => el.internals.setFormValue('x')).not.toThrow();
|
|
286
|
+
expect(() => el.internals.setValidity({})).not.toThrow();
|
|
287
|
+
expect(el.internals.checkValidity()).toBe(true);
|
|
288
|
+
expect(el.internals.reportValidity()).toBe(true);
|
|
289
|
+
expect(el.internals.form).toBeNull();
|
|
290
|
+
expect(el.internals.labels).toEqual([]);
|
|
291
|
+
expect(el.internals.validity).toEqual({});
|
|
292
|
+
expect(el.internals.validationMessage).toBe('');
|
|
293
|
+
expect(el.internals.willValidate).toBe(false);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('a real attachInternals is still used when the browser provides one (no regression)', () => {
|
|
297
|
+
class El extends UIElement {}
|
|
298
|
+
const tag = registerTestElement(El);
|
|
299
|
+
const el = mount(tag);
|
|
300
|
+
// happy-dom's real attachInternals — not the shim.
|
|
301
|
+
expect(el.internals).not.toBe(null);
|
|
302
|
+
expect(typeof el.internals.setFormValue).toBe('function');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('adoptStyles no-ops instead of throwing when adoptedStyleSheets does not exist', () => {
|
|
306
|
+
const desc = Object.getOwnPropertyDescriptor(Document.prototype, 'adoptedStyleSheets');
|
|
307
|
+
delete Document.prototype.adoptedStyleSheets;
|
|
308
|
+
try {
|
|
309
|
+
class El extends UIElement {
|
|
310
|
+
static styles = ['/* fake sheet */'];
|
|
311
|
+
}
|
|
312
|
+
const tag = registerTestElement(El);
|
|
313
|
+
expect(() => mount(tag)).not.toThrow();
|
|
314
|
+
} finally {
|
|
315
|
+
if (desc) Object.defineProperty(Document.prototype, 'adoptedStyleSheets', desc);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// gh#284 — the custom-elements spec's "upgrade an element" algorithm
|
|
321
|
+
// (§4.13.5 step 6) requires replaying attributeChangedCallback for every
|
|
322
|
+
// attribute already present on an element BEFORE connectedCallback fires
|
|
323
|
+
// on upgrade. happy-dom (this repo's own test DOM) skips that replay —
|
|
324
|
+
// confirmed directly: a bare custom element upgraded with a pre-existing
|
|
325
|
+
// observed attribute gets connectedCallback but never the matching
|
|
326
|
+
// attributeChangedCallback. linkedom (the real SSR consumer's DOM shim,
|
|
327
|
+
// per adia-ssr) is a similarly from-scratch custom-elements registry, so
|
|
328
|
+
// the same gap is expected there too. Simulating the absence directly
|
|
329
|
+
// (a real late-upgrade sequence, not a mock) rather than trusting that
|
|
330
|
+
// happy-dom already reproduces the SSR shape — it does, which is exactly
|
|
331
|
+
// how this was found.
|
|
332
|
+
describe('UIElement — SSR attribute-upgrade replay (gh#284)', () => {
|
|
333
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
334
|
+
|
|
335
|
+
// Parses markup into an UNDEFINED custom element (so its attributes land
|
|
336
|
+
// on the element the way a server-rendered / pre-parsed document would),
|
|
337
|
+
// THEN defines the tag — matching the "define after parse" shape SSR
|
|
338
|
+
// hydration and lazy client registration both produce.
|
|
339
|
+
function mountLateUpgrade(tag, BaseImpl, html) {
|
|
340
|
+
const container = document.createElement('div');
|
|
341
|
+
container.innerHTML = html;
|
|
342
|
+
document.body.appendChild(container);
|
|
343
|
+
customElements.define(tag, BaseImpl);
|
|
344
|
+
return container.querySelector(tag);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
it('confirms the environment gap this fix compensates for', () => {
|
|
348
|
+
const tag = `test-plain-${++registrationCounter}`;
|
|
349
|
+
const calls = [];
|
|
350
|
+
class Plain extends HTMLElement {
|
|
351
|
+
static get observedAttributes() { return ['foo']; }
|
|
352
|
+
attributeChangedCallback(name, oldV, newV) { calls.push([name, oldV, newV]); }
|
|
353
|
+
connectedCallback() { calls.push(['connected']); }
|
|
354
|
+
}
|
|
355
|
+
mountLateUpgrade(tag, Plain, `<${tag} foo="bar"></${tag}>`);
|
|
356
|
+
expect(calls).toEqual([['connected']]); // NOT ['foo', null, 'bar'] first, per spec
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('a reflected property set only via pre-parsed HTML is not stuck at its class default', () => {
|
|
360
|
+
const tag = `test-late-${++registrationCounter}`;
|
|
361
|
+
class El extends UIElement {
|
|
362
|
+
static properties = { label: { type: String, default: '', reflect: true } };
|
|
363
|
+
}
|
|
364
|
+
const el = mountLateUpgrade(tag, El, `<${tag} label="Profile"></${tag}>`);
|
|
365
|
+
expect(el.label).toBe('Profile');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('a reflected boolean attribute present-with-no-value resolves true, not the class default', () => {
|
|
369
|
+
const tag = `test-late-bool-${++registrationCounter}`;
|
|
370
|
+
class El extends UIElement {
|
|
371
|
+
static properties = { active: { type: Boolean, default: false, reflect: true } };
|
|
372
|
+
}
|
|
373
|
+
const el = mountLateUpgrade(tag, El, `<${tag} active></${tag}>`);
|
|
374
|
+
expect(el.active).toBe(true);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('no regression: normal define-before-parse still gets attributeChangedCallback (no extra render)', async () => {
|
|
378
|
+
let renders = 0;
|
|
379
|
+
class El extends UIElement {
|
|
380
|
+
static properties = { label: { type: String, default: '', reflect: true } };
|
|
381
|
+
render() { renders++; }
|
|
382
|
+
}
|
|
383
|
+
const tag = registerTestElement(El);
|
|
384
|
+
const el = mount(tag, { label: 'Profile' });
|
|
385
|
+
await tick();
|
|
386
|
+
expect(el.label).toBe('Profile');
|
|
387
|
+
expect(renders).toBe(1); // the connectedCallback re-sync is a no-op here, not a second render
|
|
388
|
+
});
|
|
389
|
+
});
|