@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/USAGE.md +60 -25
  3. package/components/adia-mark/adia-mark.a2ui.json +123 -0
  4. package/components/adia-mark/adia-mark.class.js +64 -0
  5. package/components/adia-mark/adia-mark.css +48 -0
  6. package/components/adia-mark/adia-mark.d.ts +20 -0
  7. package/components/adia-mark/adia-mark.examples.md +26 -0
  8. package/components/adia-mark/adia-mark.js +17 -0
  9. package/components/adia-mark/adia-mark.test.js +69 -0
  10. package/components/adia-mark/adia-mark.yaml +121 -0
  11. package/components/agent-questions/agent-questions.a2ui.json +2 -1
  12. package/components/agent-questions/agent-questions.class.js +96 -53
  13. package/components/agent-questions/agent-questions.css +27 -106
  14. package/components/agent-questions/agent-questions.yaml +1 -0
  15. package/components/button/button.class.js +5 -1
  16. package/components/calendar-grid/calendar-grid.a2ui.json +3 -1
  17. package/components/calendar-grid/calendar-grid.class.js +11 -6
  18. package/components/calendar-grid/calendar-grid.css +42 -20
  19. package/components/calendar-grid/calendar-grid.yaml +5 -0
  20. package/components/calendar-picker/calendar-picker.a2ui.json +3 -1
  21. package/components/calendar-picker/calendar-picker.class.js +11 -6
  22. package/components/calendar-picker/calendar-picker.css +39 -19
  23. package/components/calendar-picker/calendar-picker.yaml +5 -0
  24. package/components/chart/chart.class.js +17 -14
  25. package/components/color-input/color-input.class.js +8 -5
  26. package/components/color-picker/color-picker.class.js +6 -3
  27. package/components/field/field.class.js +10 -7
  28. package/components/field/field.css +21 -0
  29. package/components/fields/fields.class.js +15 -12
  30. package/components/icon/icon.class.js +9 -5
  31. package/components/index.js +1 -0
  32. package/components/nav/nav.class.js +2 -1
  33. package/components/noodles/noodles.class.js +14 -10
  34. package/components/page/page.class.js +3 -2
  35. package/components/select/select.class.js +3 -2
  36. package/components/swatch/swatch.class.js +27 -24
  37. package/components/swiper/swiper.class.js +4 -1
  38. package/components/swiper/swiper.css +13 -4
  39. package/components/table/table.a2ui.json +1 -1
  40. package/components/table/table.class.js +17 -0
  41. package/components/table/table.css +15 -2
  42. package/components/table/table.d.ts +1 -1
  43. package/components/table/table.test.js +51 -0
  44. package/components/table/table.yaml +6 -1
  45. package/components/toolbar/toolbar.class.js +11 -6
  46. package/components/tree/tree.class.js +5 -2
  47. package/core/data-stream.js +21 -15
  48. package/core/element.js +60 -1
  49. package/core/element.test.js +138 -0
  50. package/core/icons-phosphor.js +86 -22
  51. package/core/icons.js +22 -8
  52. package/dist/host.min.css +1 -1
  53. package/dist/host.sheet.js +1 -1
  54. package/dist/theme-provider.min.js +2 -2
  55. package/dist/web-components.min.css +1 -1
  56. package/dist/web-components.min.js +107 -102
  57. package/dist/web-components.sheet.js +1 -1
  58. package/package.json +1 -5
  59. package/styles/colors/semantics/core.css +1 -1
  60. package/styles/colors/semantics/features.css +1 -1
  61. package/styles/components.css +1 -0
  62. package/traits/error-shake/error-shake.js +23 -17
  63. package/traits/fade-presence/fade-presence.js +19 -15
  64. 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
- this.#chromeObserver = new MutationObserver((mutations) => {
212
- let hasChrome = false;
213
- for (const m of mutations) {
214
- for (const n of m.addedNodes) {
215
- if (n.nodeType !== 1) continue;
216
- // Direct chrome child OR a display:contents wrapper-span carrying chrome.
217
- if (n.getAttribute?.('slot') === 'chrome') { hasChrome = true; break; }
218
- if (
219
- n.tagName === 'SPAN' &&
220
- n.style?.display === 'contents' &&
221
- n.querySelector?.('[slot="chrome"]')
222
- ) { hasChrome = true; break; }
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) break;
225
- }
226
- if (!hasChrome) return;
227
- // Re-entrancy guard: #absorbChromeSlot() calls insertBefore() which itself
228
- // generates childList mutations on the host. Without this guard, every
229
- // absorb pass would re-trigger the observer indefinitely. takeRecords()
230
- // drains the queue without dispatch so the next observation cycle starts
231
- // clean.
232
- this.#absorbChromeSlot();
233
- this.#chromeObserver.takeRecords();
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
- const dot = document.createElement('button');
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": "Row records. Array of plain objects keyed to columns[].key.",
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-bottom: none;
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
- /** Row records. Array of plain objects keyed to columns[].key. */
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: Row records. Array of plain objects keyed to columns[].key.
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
- this.#ro = new ResizeObserver(() => this.#queueReflow());
118
- this.#ro.observe(this);
119
- this.#mo = new MutationObserver(() => {
120
- if (this.#measuring) return;
121
- this.#queueReflow();
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
- this.#slotObserver = new MutationObserver(() => this.#adoptSlotted());
328
- this.#slotObserver.observe(this, { childList: true, subtree: true });
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() {
@@ -453,24 +453,30 @@ function visitSubtree(root, fn) {
453
453
  }
454
454
  }
455
455
 
456
- const observer = new MutationObserver((mutations) => {
457
- for (const m of mutations) {
458
- if (m.type === 'attributes' && ATTR_FILTER.includes(m.attributeName)) {
459
- const el = m.target;
460
- if (isStreamingEl(el)) start(el);
461
- else stop(el);
462
- continue;
463
- }
464
- if (m.type === 'childList') {
465
- m.addedNodes.forEach(n => visitSubtree(n, start));
466
- m.removedNodes.forEach(n => visitSubtree(n, stop));
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.observe(document.documentElement, {
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
- this.internals = this.attachInternals();
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.
@@ -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
+ });