@adia-ai/web-components 0.8.5 → 0.8.7

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 (117) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/components/field/field.css +21 -0
  3. package/components/table/table.a2ui.json +1 -1
  4. package/components/table/table.class.js +17 -0
  5. package/components/table/table.css +15 -2
  6. package/components/table/table.d.ts +1 -1
  7. package/components/table/table.test.js +51 -0
  8. package/components/table/table.yaml +6 -1
  9. package/core/element.js +24 -0
  10. package/core/element.test.js +71 -0
  11. package/dist/host.min.css +1 -1
  12. package/dist/host.sheet.js +1 -1
  13. package/dist/theme-provider.min.js +2 -2
  14. package/dist/web-components.min.css +1 -1
  15. package/dist/web-components.min.js +50 -50
  16. package/dist/web-components.sheet.js +1 -1
  17. package/package.json +2 -1
  18. package/patterns/access-requests/access-requests.examples.html +132 -0
  19. package/patterns/access-requests/access-requests.html +60 -0
  20. package/patterns/admin-keys-and-export/admin-keys-and-export.examples.html +236 -0
  21. package/patterns/admin-keys-and-export/admin-keys-and-export.html +64 -0
  22. package/patterns/admin-shell/admin-shell.examples.html +327 -0
  23. package/patterns/admin-shell/admin-shell.html +44 -0
  24. package/patterns/agent-cost/agent-cost.examples.html +111 -0
  25. package/patterns/agent-cost/agent-cost.html +62 -0
  26. package/patterns/agent-memory/agent-memory.examples.html +155 -0
  27. package/patterns/agent-memory/agent-memory.html +64 -0
  28. package/patterns/agent-prompt-library/agent-prompt-library.examples.html +181 -0
  29. package/patterns/agent-prompt-library/agent-prompt-library.html +64 -0
  30. package/patterns/agent-tool-call/agent-tool-call.examples.html +123 -0
  31. package/patterns/agent-tool-call/agent-tool-call.html +60 -0
  32. package/patterns/alert-dialog/alert-dialog.examples.html +105 -0
  33. package/patterns/alert-dialog/alert-dialog.html +57 -0
  34. package/patterns/app-nav/app-nav.examples.html +421 -0
  35. package/patterns/app-nav/app-nav.html +50 -0
  36. package/patterns/approvals/approvals.examples.html +131 -0
  37. package/patterns/approvals/approvals.html +58 -0
  38. package/patterns/audit-log/audit-log.examples.html +74 -0
  39. package/patterns/audit-log/audit-log.examples.js +60 -0
  40. package/patterns/audit-log/audit-log.html +63 -0
  41. package/patterns/bulk-action-toolbar/bulk-action-toolbar.examples.html +89 -0
  42. package/patterns/bulk-action-toolbar/bulk-action-toolbar.examples.js +69 -0
  43. package/patterns/bulk-action-toolbar/bulk-action-toolbar.html +62 -0
  44. package/patterns/changelog-feed/changelog-feed.examples.html +151 -0
  45. package/patterns/changelog-feed/changelog-feed.html +57 -0
  46. package/patterns/chart-in-card/chart-in-card.examples.html +193 -0
  47. package/patterns/chart-in-card/chart-in-card.examples.js +46 -0
  48. package/patterns/chart-in-card/chart-in-card.html +66 -0
  49. package/patterns/chat-shell/chat-shell.examples.html +129 -0
  50. package/patterns/chat-shell/chat-shell.html +46 -0
  51. package/patterns/comments-and-collaboration/comments-and-collaboration.examples.html +241 -0
  52. package/patterns/comments-and-collaboration/comments-and-collaboration.html +66 -0
  53. package/patterns/component-tokens/component-tokens.examples.html +483 -0
  54. package/patterns/component-tokens/component-tokens.html +42 -0
  55. package/patterns/conversion-funnel/conversion-funnel.examples.html +158 -0
  56. package/patterns/conversion-funnel/conversion-funnel.examples.js +4 -0
  57. package/patterns/conversion-funnel/conversion-funnel.html +64 -0
  58. package/patterns/data-tables-inline-edit-and-tree/data-tables-inline-edit-and-tree.examples.html +196 -0
  59. package/patterns/data-tables-inline-edit-and-tree/data-tables-inline-edit-and-tree.html +58 -0
  60. package/patterns/diff-review/diff-review.examples.html +147 -0
  61. package/patterns/diff-review/diff-review.html +56 -0
  62. package/patterns/editor-shell/editor-shell.examples.html +139 -0
  63. package/patterns/editor-shell/editor-shell.html +62 -0
  64. package/patterns/export-flow/export-flow.examples.html +152 -0
  65. package/patterns/export-flow/export-flow.examples.js +37 -0
  66. package/patterns/export-flow/export-flow.html +65 -0
  67. package/patterns/filter-bar/filter-bar.examples.html +116 -0
  68. package/patterns/filter-bar/filter-bar.examples.js +23 -0
  69. package/patterns/filter-bar/filter-bar.html +70 -0
  70. package/patterns/form-drawer/form-drawer.examples.html +210 -0
  71. package/patterns/form-drawer/form-drawer.html +64 -0
  72. package/patterns/form-system/form-system.examples.html +383 -0
  73. package/patterns/form-system/form-system.html +48 -0
  74. package/patterns/forms-conditional-and-autocomplete/forms-conditional-and-autocomplete.examples.html +189 -0
  75. package/patterns/forms-conditional-and-autocomplete/forms-conditional-and-autocomplete.html +64 -0
  76. package/patterns/inline-dialog/inline-dialog.examples.html +143 -0
  77. package/patterns/inline-dialog/inline-dialog.html +59 -0
  78. package/patterns/kanban-board/kanban-board.examples.html +224 -0
  79. package/patterns/kanban-board/kanban-board.html +58 -0
  80. package/patterns/layout/layout.examples.html +365 -0
  81. package/patterns/layout/layout.html +54 -0
  82. package/patterns/marketing-engagement/marketing-engagement.examples.html +280 -0
  83. package/patterns/marketing-engagement/marketing-engagement.html +64 -0
  84. package/patterns/notifications-bell-and-digest/notifications-bell-and-digest.examples.html +213 -0
  85. package/patterns/notifications-bell-and-digest/notifications-bell-and-digest.html +58 -0
  86. package/patterns/overview/overview.examples.html +119 -0
  87. package/patterns/overview/overview.html +42 -0
  88. package/patterns/permissions-matrix/permissions-matrix.examples.html +91 -0
  89. package/patterns/permissions-matrix/permissions-matrix.examples.js +28 -0
  90. package/patterns/permissions-matrix/permissions-matrix.html +56 -0
  91. package/patterns/permissions-role-picker/permissions-role-picker.examples.html +81 -0
  92. package/patterns/permissions-role-picker/permissions-role-picker.html +56 -0
  93. package/patterns/permissions-sharing/permissions-sharing.examples.html +175 -0
  94. package/patterns/permissions-sharing/permissions-sharing.html +66 -0
  95. package/patterns/profile-public-and-verification/profile-public-and-verification.examples.html +227 -0
  96. package/patterns/profile-public-and-verification/profile-public-and-verification.html +70 -0
  97. package/patterns/record-detail-drawer/record-detail-drawer.examples.html +164 -0
  98. package/patterns/record-detail-drawer/record-detail-drawer.html +64 -0
  99. package/patterns/responsive-shell-sidebar/README.md +37 -0
  100. package/patterns/responsive-shell-sidebar/responsive-shell-sidebar.css +39 -0
  101. package/patterns/responsive-shell-sidebar/responsive-shell-sidebar.examples.html +208 -0
  102. package/patterns/responsive-shell-sidebar/responsive-shell-sidebar.html +47 -0
  103. package/patterns/retention-cohort/retention-cohort.examples.html +149 -0
  104. package/patterns/retention-cohort/retention-cohort.examples.js +37 -0
  105. package/patterns/retention-cohort/retention-cohort.html +62 -0
  106. package/patterns/search-discovery/search-discovery.examples.html +263 -0
  107. package/patterns/search-discovery/search-discovery.html +58 -0
  108. package/patterns/system-banners/system-banners.examples.html +150 -0
  109. package/patterns/system-banners/system-banners.html +52 -0
  110. package/patterns/timezone-selector/timezone-selector.examples.html +82 -0
  111. package/patterns/timezone-selector/timezone-selector.examples.js +72 -0
  112. package/patterns/timezone-selector/timezone-selector.html +57 -0
  113. package/patterns/wizard-drawer/wizard-drawer.examples.html +144 -0
  114. package/patterns/wizard-drawer/wizard-drawer.examples.js +33 -0
  115. package/patterns/wizard-drawer/wizard-drawer.html +72 -0
  116. package/styles/colors/semantics/core.css +1 -1
  117. package/styles/colors/semantics/features.css +1 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,24 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
- ## [0.8.5] — 2026-07-17
3
+ ## [0.8.7] — 2026-07-19
4
+
5
+ ### Changed
6
+ - **`dist/theme-provider.min.js`, `dist/web-components.min.js` rebuilt** (`npm run build:bundle-js`) — both had drifted stale relative to source; regenerated as part of 0.8.7 release pre-flight. No source change; `patterns/` also joins the package's published `files` (see `package.json`) so a consumer install resolves pattern source from `node_modules` (`adia-patterns` skill, `adia-ui-factory` `CHANGELOG.md`).
7
+
8
+ ## [0.8.6] — 2026-07-18
9
+
10
+ ### Fixed
11
+ - **SSR/late-upgrade attribute replay** (`core/element.js`, `core/element.test.js`; gh#284, PR #309): the custom-elements spec's "upgrade an element" algorithm (§4.13.5 step 6) requires replaying `attributeChangedCallback` for attributes already present before `connectedCallback` fires — happy-dom (this repo's test DOM) skips that replay entirely, and linkedom (the real SSR consumers' shim) is a similarly from-scratch registry. Net effect: any `reflect: true` property seeded only from server-rendered/pre-parsed HTML stayed at its class default after upgrade (`<nav-item-ui text="Profile">` rendered an empty label). `connectedCallback` now re-syncs every declared property from its live attribute before `connected()` runs — a no-op in spec-compliant environments (the setter's `Object.is` check short-circuits, no extra render pass). Regression tests: `describe('UIElement — SSR attribute-upgrade replay (gh#284)')`.
12
+ - **`table-ui` row-divider doubling** (`components/table/table.css`; TKT-0026, PR #247): body-cell dividers moved from `border-bottom` (every row, so the last row doubled against the container's own inset box-shadow perimeter) to `border-top` scoped `[role="row"]:not(:first-child)` (so the first row doesn't instead double against the header's border). The `[striped]` variant's stale `border-bottom: none` override updated to the new property in the same pass.
13
+ - **`field-ui` min-width floor** (`components/field/field.css`; gh#305, PR #315): a field sitting next to a grow field in a row no longer collapses to zero width (the State/City layout).
14
+ - **`--a-ui-bg-selected`** (`styles/colors/semantics/features.css`; gh#307, PR #315): resolves to the selected-container role instead of a scrim — nav-item selection reads as a surface again.
15
+ - **`--a-bg-muted`** (`styles/colors/semantics/core.css`; PR #314): resolves to `--md-sys-color-neutral-container-low` instead of the plain `-container` tier (which is `--a-canvas-well-strong`'s distinct-object tier) — muted backgrounds are subtler than "own object" surfaces. `verify:contrast` still 42/42 WCAG AA pairs, both schemes.
16
+
17
+ ### Added
18
+ - **`table-ui` declarative `data="[…]"` attribute** (`components/table/table.{class.js,yaml}`; gh#288, PR #311): row records can now be supplied as a JSON-array HTML attribute, hydrated once at connect — the same pattern `chart-ui`'s `data` attribute already used — so a static-HTML or SSR-authored table renders its rows without a post-hydration wiring script. A programmatic `.data =` set before connect always wins; malformed JSON is ignored silently. Works together with `<col-def>` children for a fully static table. (`select-ui` and `chart-ui` already had declarative forms — `table-ui`'s row data was the actual gap in gh#288's scope.)
19
+
20
+ ### Maintenance
21
+ - `dist/` bundles (CSS + JS + sheet + theme-provider) regenerated for the source changes above (PRs #309, #311, #316).
4
22
 
5
23
  ### Added
6
24
  - **`<adia-mark-ui>`** (`components/adia-mark/adia-mark.{class.js,css,yaml}`; gh#283): the Adia brand mark as a token-driven primitive — background always reads `--a-canvas-surface-inverse`, the wordmark always reads `--a-fg-inverse`, so one component replaces the four hand-maintained static `agent-ui-{light,dark,light-transparent,dark-transparent}.svg` exports and adapts to scheme changes with zero asset swapping. Delivery shape (inlined custom element, not a bare SVG file or `icon-ui` registry entry) was constrained by the ticket's own finding: an `<img src="...svg">` or CSS `background-image: url(...)` reference cannot resolve the host page's CSS custom properties — only inlined markup can. `size` prop (xs/sm/md/lg/xl) matches `avatar-ui`'s scale. Verified live in both color schemes (Playwright) — background/foreground correctly invert together with no code change. Existing static-asset consumers (`public/icons/*.svg`, the a2ui corpus chunks, `apps/embedded-app`'s CSS-background brand mark) were deliberately left untouched — the static assets aren't deleted, so nothing breaks; migrating those ~35+ call sites is a separate, larger follow-up, out of scope for this ticket's `size:small` label.
@@ -43,6 +43,27 @@
43
43
  width: 100%;
44
44
  }
45
45
 
46
+ /* Exception: when a row-ui sibling is marked [grow], width:100% on the
47
+ OTHER fields becomes each one's flex main-size (the row's full width).
48
+ Two or more of those overflow the row, and under the negative-free-
49
+ space shrink algorithm the [grow] sibling's flex-basis:0% share gets
50
+ zeroed out entirely instead of receiving the leftover space (issue
51
+ 305). Scoped to :has(> field-ui[grow]) so rows with NO grow winner —
52
+ where every field intentionally shares the row evenly via this same
53
+ width:100% overflow-shrink split (e.g. a 2-up expiry/CVC pair) — keep
54
+ that existing, relied-upon behavior.
55
+
56
+ min-width is also needed here, not just width:auto: an empty control's
57
+ own content-based min size is ~0 (input-ui's [slot="text"] is a bare
58
+ contenteditable span — the placeholder renders via an out-of-flow
59
+ `::before`, contributing no intrinsic width), so without a floor this
60
+ field can still collapse past legibility once the [grow] sibling
61
+ starts claiming space. */
62
+ :where(row-ui):has(> field-ui[grow]) > :scope:not([inline]):not([grow]) {
63
+ width: auto;
64
+ min-width: 4.5rem;
65
+ }
66
+
46
67
  /* Gate `row-gap` on actual message presence — otherwise the grid
47
68
  reserves --field-gap (~8px) between the control row and a
48
69
  zero-content message row, making field-ui[inline] visibly taller
@@ -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
package/core/element.js CHANGED
@@ -158,6 +158,30 @@ export class UIElement extends HTMLElement {
158
158
  if (!ctor._tag) ctor._tag = this.localName;
159
159
  adoptStyles(ctor);
160
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
+ }
161
185
  // connected() commonly reads this element's reactive properties via
162
186
  // template-literal interpolation (`${this.label}` etc.) to stamp its
163
187
  // inner DOM. If the outer call path is inside another effect (e.g.
@@ -316,3 +316,74 @@ describe('UIElement — SSR browser-API absence (gh#285)', () => {
316
316
  }
317
317
  });
318
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
+ });