@adia-ai/web-components 0.2.1 → 0.2.3

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/README.md CHANGED
@@ -57,8 +57,11 @@ web-components/
57
57
  │ gen-ui, a2ui-root) ship in the sibling `@adia-ai/web-modules`
58
58
  │ package as of 0.0.29 — see ADR-0012 for the three-tier rationale.
59
59
 
60
- ├── traits/ — 42 composable behaviors via defineTrait()
61
- (pressable, focusTrap, confetti, resizable, …)
60
+ ├── traits/ — 41 composable behaviors via defineTrait() + the
61
+ <traits-host> wrapper for raw-HTML declarative
62
+ │ composition. Generated catalog at _catalog.json
63
+ │ drives the MCP get_traits tool + per-trait demo
64
+ │ pages. Full contract in docs/specs/traits.md.
62
65
 
63
66
  ├── a2ui/ — deprecation shim for one release
64
67
  │ └── index.js Re-exports @adia-ai/a2ui-utils with a
@@ -18,7 +18,11 @@
18
18
  --agent-trace-padding-y: var(--a-space-2);
19
19
  /* Component-intrinsic measurement; no --a-space-* equivalent */
20
20
  --agent-trace-dot-size: 6px;
21
- --agent-trace-row-label-col: 80px;
21
+ /* STAGE column width — `max-content` lets the longest label set the
22
+ track so multi-word labels ("Rows returned", "Query duration",
23
+ "Drift vs. SFDC") stay on one line; the `7rem` floor stops the
24
+ column from collapsing when only short labels are present. */
25
+ --agent-trace-row-label-col: minmax(7rem, max-content);
22
26
  /* Shared across every detail DL so values tabulate at the same x. */
23
27
  --agent-trace-detail-label-col: 9rem;
24
28
 
@@ -113,6 +117,17 @@
113
117
  right of "7%"'s detail column. Subgrid pulls the track widths up
114
118
  to the parent so every row sees the same column stops. */
115
119
  [data-trace-rows] {
120
+ /* Track plan:
121
+ [STAGE max-content] ← label column; widened from 80px so multi-word
122
+ labels ("Rows returned", "Query duration",
123
+ "Drift vs. SFDC") stay on one line
124
+ [SCORE max-content] ← value column, right-aligned
125
+ [DETAIL 1fr] ← detail column, right-aligned content
126
+ The DETAIL column still takes the leftover width, but its text is
127
+ right-anchored ([data-trace-aux] { text-align: end }) so the
128
+ previously-empty right edge becomes the alignment edge for detail
129
+ text. The whitespace between SCORE and DETAIL reads as breathing
130
+ room between key-value and qualifier rather than dead air. */
116
131
  --trace-row-cols: var(--agent-trace-row-label-col) max-content 1fr;
117
132
  display: grid;
118
133
  grid-template-columns: var(--trace-row-cols);
@@ -157,8 +172,9 @@
157
172
  color: var(--agent-trace-fg-subtle);
158
173
  }
159
174
 
160
- [data-trace-header]:nth-of-type(2) {
161
- text-align: right;
175
+ [data-trace-header]:nth-of-type(2),
176
+ [data-trace-header]:nth-of-type(3) {
177
+ text-align: end;
162
178
  }
163
179
 
164
180
  /* Rows */
@@ -198,10 +214,15 @@
198
214
  }
199
215
 
200
216
  [data-trace-aux] {
217
+ /* Right-anchor the detail text so it sits flush with the right edge
218
+ of the row. Without this, the 1fr detail column left-aligns its
219
+ content (typically a 1-3 word qualifier like "warehouse" or
220
+ "reconciled") and the rest of the column reads as dead width. */
201
221
  color: var(--agent-trace-fg-muted);
202
222
  overflow: hidden;
203
223
  text-overflow: ellipsis;
204
224
  white-space: nowrap;
225
+ text-align: end;
205
226
  }
206
227
 
207
228
  /* Chevron in the trailing column */
@@ -92,20 +92,62 @@
92
92
  @scope (list-item-ui) {
93
93
  :where(:scope) {
94
94
  /* ── Layout ── */
95
- --list-item-inset: var(--a-inset);
95
+ --list-item-inset: var(--a-inset);
96
+ --list-item-gap-column: var(--a-space-3);
97
+ --list-item-gap-row: var(--a-space-1);
96
98
 
97
99
  /* ── Colors ── */
98
100
  --list-item-text-color: var(--a-fg);
101
+ --list-item-desc-color: var(--a-fg-muted);
102
+ --list-item-icon-color: var(--a-fg-muted);
99
103
 
100
104
  /* ── Typography ── */
101
- --list-item-font-size: var(--a-ui-size);
105
+ --list-item-font-size: var(--a-ui-size);
106
+ --list-item-desc-font-size: var(--a-ui-sm);
102
107
  }
103
108
 
109
+ /* Anatomy:
110
+ col 1 = icon (auto width; collapses to 0 when no icon)
111
+ col 2 = content stack (text on row 1, description on row 2)
112
+ The icon spans both rows + centers vertically with the stack.
113
+ `[slot="content"]` (used when consumer provides custom rendering)
114
+ spans all columns. */
104
115
  :scope {
105
116
  box-sizing: border-box;
106
- display: block;
117
+ display: grid;
118
+ grid-template-columns: auto 1fr;
119
+ column-gap: var(--list-item-gap-column);
120
+ row-gap: var(--list-item-gap-row);
107
121
  font-size: var(--list-item-font-size);
108
122
  color: var(--list-item-text-color);
109
123
  line-height: 1.4;
110
124
  }
125
+
126
+ :scope > [slot="icon"] {
127
+ grid-column: 1;
128
+ grid-row: 1 / -1;
129
+ align-self: center;
130
+ color: var(--list-item-icon-color);
131
+ }
132
+
133
+ :scope > [slot="text"] {
134
+ grid-column: 2;
135
+ grid-row: 1;
136
+ }
137
+
138
+ :scope > [slot="description"] {
139
+ grid-column: 2;
140
+ grid-row: 2;
141
+ color: var(--list-item-desc-color);
142
+ font-size: var(--list-item-desc-font-size);
143
+ line-height: 1.3;
144
+ }
145
+
146
+ /* Custom-content escape hatch — consumer authored a [slot="content"]
147
+ child, the auto-stamp early-returned, and the consumer owns the
148
+ full body. Span all columns so the consumer's layout isn't boxed
149
+ into the content column. */
150
+ :scope > [slot="content"] {
151
+ grid-column: 1 / -1;
152
+ }
111
153
  }
@@ -42,7 +42,7 @@
42
42
  "default": ""
43
43
  },
44
44
  "variant": {
45
- "description": "Visual treatment. Default ('') renders as a primary-rail group (icon row, caret, collapsible). 'section' renders the header as a static kicker label with always-visible children — matches the prior <section-nav-group-ui>. When the parent <nav-ui> carries variant=\"section\", this group inherits it via CSS cascade unless an explicit variant is set on the group.",
45
+ "description": "Visual treatment. Default ('') renders as a primary-rail group (icon row, caret, collapsible). 'section' renders the header as a static kicker label with always-visible children. When the parent <nav-ui> carries variant=\"section\", this group inherits it via CSS cascade unless an explicit variant is set on the group.",
46
46
  "type": "string",
47
47
  "enum": [
48
48
  "",
@@ -264,11 +264,11 @@ nav-group-ui [slot="popover"] [role="option"][aria-selected="true"]::before {
264
264
  }
265
265
 
266
266
  /* ── Section variant — groups render as kicker labels ──
267
- The prior <section-nav-group-ui> defaulted to collapsible:false
268
- (header was a label, children always visible). The consolidated
269
- nav-group-ui defaults collapsible:true (the primary-variant default).
270
- Section variant restores the old kicker rendering so subnav rails
271
- show all children without an explicit [open] toggle.
267
+ Primary variant defaults to collapsible:true (caret-toggleable).
268
+ Section variant flips to collapsible:false-shape header is a
269
+ static kicker label, children always visible — so subnav rails
270
+ show their full structure without requiring an explicit [open]
271
+ toggle.
272
272
 
273
273
  Two ways to enable: either
274
274
  (a) `<nav-group-ui variant="section">` directly, or
@@ -39,7 +39,7 @@ props:
39
39
  type: string
40
40
  default: ''
41
41
  enum: ['', section]
42
- description: "Visual treatment. Default ('') renders as a primary-rail group (icon row, caret, collapsible). 'section' renders the header as a static kicker label with always-visible children — matches the prior <section-nav-group-ui>. When the parent <nav-ui> carries variant=\"section\", this group inherits it via CSS cascade unless an explicit variant is set on the group."
42
+ description: "Visual treatment. Default ('') renders as a primary-rail group (icon row, caret, collapsible). 'section' renders the header as a static kicker label with always-visible children. When the parent <nav-ui> carries variant=\"section\", this group inherits it via CSS cascade unless an explicit variant is set on the group."
43
43
 
44
44
  events:
45
45
  group-toggle:
@@ -47,7 +47,7 @@
47
47
  "default": ""
48
48
  },
49
49
  "variant": {
50
- "description": "Visual treatment. Default ('') renders as a primary-rail item (reserved icon space, in-icon selected accent). 'section' renders flat — no icon space when absent, left-edge accent bar for selected — matching the prior <section-nav-item-ui>. When the parent <nav-ui> carries variant=\"section\", this item inherits it via CSS cascade unless an explicit variant is set.",
50
+ "description": "Visual treatment. Default ('') renders as a primary-rail item (reserved icon space, in-icon selected accent). 'section' renders flat — no icon space when absent, left-edge accent bar for selected. When the parent <nav-ui> carries variant=\"section\", this item inherits it via CSS cascade unless an explicit variant is set.",
51
51
  "type": "string",
52
52
  "enum": [
53
53
  "",
@@ -162,10 +162,9 @@ nav-item-ui[selected] [slot="icon"]:empty::before {
162
162
  }
163
163
 
164
164
  /* ── Section variant — items render flat (no icon space, no indent) ──
165
- The prior <section-nav-item-ui> didn't reserve space for an absent icon
166
- and used a left-edge accent bar for the selected state. Restore that
167
- shape so subnav rails read as plain links rather than a primary-style
168
- row with a hidden icon slot.
165
+ Section-variant rails read as plain links rather than primary-style
166
+ rows: no reserved icon space when absent, left-edge accent bar for
167
+ the selected state instead of an in-icon accent.
169
168
 
170
169
  Two ways to enable: either
171
170
  (a) `<nav-item-ui variant="section">` directly, or
@@ -43,7 +43,7 @@ props:
43
43
  type: string
44
44
  default: ''
45
45
  enum: ['', section]
46
- description: "Visual treatment. Default ('') renders as a primary-rail item (reserved icon space, in-icon selected accent). 'section' renders flat — no icon space when absent, left-edge accent bar for selected — matching the prior <section-nav-item-ui>. When the parent <nav-ui> carries variant=\"section\", this item inherits it via CSS cascade unless an explicit variant is set."
46
+ description: "Visual treatment. Default ('') renders as a primary-rail item (reserved icon space, in-icon selected accent). 'section' renders flat — no icon space when absent, left-edge accent bar for selected. When the parent <nav-ui> carries variant=\"section\", this item inherits it via CSS cascade unless an explicit variant is set."
47
47
 
48
48
  events:
49
49
  nav-select:
package/core/icons.js CHANGED
@@ -224,6 +224,7 @@ const ICON_ALIASES = {
224
224
  'attachment': 'paperclip', 'attach': 'paperclip',
225
225
  'expand': 'arrows-out', 'collapse': 'arrows-in',
226
226
  'fullscreen': 'arrows-out', 'exit-fullscreen': 'arrows-in',
227
+ 'fade': 'gradient', 'fade-presence': 'gradient',
227
228
  'notification': 'bell', 'notifications': 'bell',
228
229
  'bookmark': 'bookmark-simple', 'favorite': 'heart', 'like': 'heart',
229
230
  'comment': 'chat-circle', 'chat': 'chat-circle',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/web-components",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "AdiaUI web components — vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-utils.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -230,7 +230,7 @@
230
230
  {
231
231
  "name": "confetti-burst",
232
232
  "category": "interaction-delight",
233
- "description": "Upward fountain particle burst",
233
+ "description": "Upward fountain particle burst — fires on each `press` event",
234
234
  "attributes": [
235
235
  "data-confetti-burst-active"
236
236
  ],
@@ -4,88 +4,114 @@ import { prefersReducedMotion } from './_motion.js';
4
4
  export const confettiBurst = defineTrait({
5
5
  name: 'confetti-burst',
6
6
  category: 'interaction-delight',
7
- description: 'Upward fountain particle burst',
7
+ description: 'Upward fountain particle burst — fires on each `press` event',
8
8
  attributes: ['data-confetti-burst-active'],
9
9
  events: ['confetti-burst-done'],
10
10
  config: [],
11
11
  setup({ host }) {
12
- // Reduced-motion: skip the burst, fire done event immediately.
13
- if (prefersReducedMotion()) {
14
- host.setAttribute('data-confetti-burst-active', '');
15
- queueMicrotask(() => {
16
- host.removeAttribute('data-confetti-burst-active');
17
- host.dispatchEvent(new CustomEvent('confetti-burst-done', { bubbles: true }));
18
- });
19
- return () => host.removeAttribute('data-confetti-burst-active');
20
- }
12
+ // Per-burst state — separate from the trait's lifecycle so multiple
13
+ // presses queue up cleanly. Each `fireBurst()` spawns its own canvas +
14
+ // raf loop and tears them down 2s later. Listening for `press` lets
15
+ // declarative usage (`<button-ui traits="confetti-burst">`) fire on
16
+ // every click; programmatic usage (animation page's
17
+ // `confettiBurst().connect(el)` per-click) gets the immediate burst
18
+ // it expects via the on-connect call below.
19
+ const inflight = new Set();
21
20
 
22
- const canvas = document.createElement('canvas');
23
- canvas.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:9999;';
24
- host.style.position = host.style.position || 'relative';
25
- host.appendChild(canvas);
26
-
27
- const ctx = canvas.getContext('2d');
28
- let rafId = null;
29
-
30
- // Bail gracefully when canvas 2D isn't available (SSR, JSDOM, happy-dom).
31
- if (!ctx) {
21
+ function reducedMotionBurst() {
32
22
  host.setAttribute('data-confetti-burst-active', '');
33
- // Fire the done event on next tick so listeners still hear it.
34
23
  queueMicrotask(() => {
35
24
  host.removeAttribute('data-confetti-burst-active');
36
25
  host.dispatchEvent(new CustomEvent('confetti-burst-done', { bubbles: true }));
37
26
  });
38
- return () => {
39
- canvas.remove();
40
- host.removeAttribute('data-confetti-burst-active');
41
- };
42
27
  }
43
28
 
44
- const colors = ['#f44', '#4a4', '#44f', '#ff4', '#f4f', '#4ff'];
45
- const particles = Array.from({ length: 80 }, () => ({
46
- x: 0.5,
47
- y: 0.5,
48
- vx: (Math.random() - 0.5) * 8,
49
- vy: (Math.random() - 0.5) * 8 - 2,
50
- size: Math.random() * 4 + 2,
51
- color: colors[Math.floor(Math.random() * colors.length)],
52
- life: 1,
53
- }));
29
+ function fireBurst() {
30
+ if (prefersReducedMotion()) {
31
+ reducedMotionBurst();
32
+ return;
33
+ }
34
+
35
+ const canvas = document.createElement('canvas');
36
+ canvas.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:9999;';
37
+ host.style.position = host.style.position || 'relative';
38
+ host.appendChild(canvas);
54
39
 
55
- host.setAttribute('data-confetti-burst-active', '');
56
- const startTime = performance.now();
40
+ const ctx = canvas.getContext('2d');
41
+ let rafId = null;
57
42
 
58
- function tick(now) {
59
- const elapsed = now - startTime;
60
- if (elapsed > 2000) {
43
+ // Bail gracefully when canvas 2D isn't available (SSR, JSDOM, happy-dom).
44
+ if (!ctx) {
45
+ host.setAttribute('data-confetti-burst-active', '');
46
+ queueMicrotask(() => {
47
+ host.removeAttribute('data-confetti-burst-active');
48
+ host.dispatchEvent(new CustomEvent('confetti-burst-done', { bubbles: true }));
49
+ });
61
50
  canvas.remove();
62
- host.removeAttribute('data-confetti-burst-active');
63
- host.dispatchEvent(new CustomEvent('confetti-burst-done', { bubbles: true }));
64
51
  return;
65
52
  }
66
53
 
67
- canvas.width = host.offsetWidth;
68
- canvas.height = host.offsetHeight;
69
- ctx.clearRect(0, 0, canvas.width, canvas.height);
54
+ const colors = ['#f44', '#4a4', '#44f', '#ff4', '#f4f', '#4ff'];
55
+ const particles = Array.from({ length: 80 }, () => ({
56
+ x: 0.5,
57
+ y: 0.5,
58
+ vx: (Math.random() - 0.5) * 8,
59
+ vy: (Math.random() - 0.5) * 8 - 2,
60
+ size: Math.random() * 4 + 2,
61
+ color: colors[Math.floor(Math.random() * colors.length)],
62
+ life: 1,
63
+ }));
64
+
65
+ host.setAttribute('data-confetti-burst-active', '');
66
+ const startTime = performance.now();
67
+ const burstHandle = { canvas, cancel: () => { if (rafId) cancelAnimationFrame(rafId); canvas.remove(); } };
68
+ inflight.add(burstHandle);
69
+
70
+ function tick(now) {
71
+ const elapsed = now - startTime;
72
+ if (elapsed > 2000) {
73
+ inflight.delete(burstHandle);
74
+ canvas.remove();
75
+ if (inflight.size === 0) host.removeAttribute('data-confetti-burst-active');
76
+ host.dispatchEvent(new CustomEvent('confetti-burst-done', { bubbles: true }));
77
+ return;
78
+ }
79
+
80
+ canvas.width = host.offsetWidth;
81
+ canvas.height = host.offsetHeight;
82
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
70
83
 
71
- for (const p of particles) {
72
- p.x += p.vx * 0.004;
73
- p.y += p.vy * 0.004;
74
- p.vy += 0.15;
75
- p.life = Math.max(0, 1 - elapsed / 2000);
76
- ctx.globalAlpha = p.life;
77
- ctx.fillStyle = p.color;
78
- ctx.fillRect(p.x * canvas.width, p.y * canvas.height, p.size, p.size);
84
+ for (const p of particles) {
85
+ p.x += p.vx * 0.004;
86
+ p.y += p.vy * 0.004;
87
+ p.vy += 0.15;
88
+ p.life = Math.max(0, 1 - elapsed / 2000);
89
+ ctx.globalAlpha = p.life;
90
+ ctx.fillStyle = p.color;
91
+ ctx.fillRect(p.x * canvas.width, p.y * canvas.height, p.size, p.size);
92
+ }
93
+ ctx.globalAlpha = 1;
94
+ rafId = requestAnimationFrame(tick);
79
95
  }
80
- ctx.globalAlpha = 1;
96
+
81
97
  rafId = requestAnimationFrame(tick);
82
98
  }
83
99
 
84
- rafId = requestAnimationFrame(tick);
100
+ // Fire one burst at connect-time. Preserves the existing animation
101
+ // page semantic (`confettiBurst().connect(burstEl)` on every click of
102
+ // a separate trigger button).
103
+ fireBurst();
104
+
105
+ // Listen for `press` so the declarative case
106
+ // (`<button-ui traits="confetti-burst">`) fires a fresh burst on
107
+ // each click. button-ui dispatches `press` from its #onClick.
108
+ const onPress = () => fireBurst();
109
+ host.addEventListener('press', onPress);
85
110
 
86
111
  return () => {
87
- if (rafId) cancelAnimationFrame(rafId);
88
- canvas.remove();
112
+ host.removeEventListener('press', onPress);
113
+ for (const handle of inflight) handle.cancel();
114
+ inflight.clear();
89
115
  host.removeAttribute('data-confetti-burst-active');
90
116
  };
91
117
  },
@@ -9,15 +9,25 @@ export const dragGhost = defineTrait({
9
9
  config: [],
10
10
  setup({ host }) {
11
11
  let ghost = null;
12
+ let clickOffsetX = 0;
13
+ let clickOffsetY = 0;
14
+
15
+ function onPointerDown(e) {
16
+ const rect = host.getBoundingClientRect();
17
+ clickOffsetX = e.clientX - rect.left;
18
+ clickOffsetY = e.clientY - rect.top;
19
+ }
12
20
 
13
21
  function onDragStart(e) {
14
22
  ghost = host.cloneNode(true);
23
+ const rect = host.getBoundingClientRect();
15
24
  ghost.style.cssText = `
16
25
  position: fixed; top: -9999px; left: -9999px;
26
+ width: ${rect.width}px; height: ${rect.height}px;
17
27
  pointer-events: none; opacity: 0.8; z-index: 99999;
18
28
  `;
19
29
  document.body.appendChild(ghost);
20
- e.dataTransfer?.setDragImage(ghost, 0, 0);
30
+ e.dataTransfer?.setDragImage(ghost, clickOffsetX, clickOffsetY);
21
31
  host.setAttribute('data-drag-ghost-active', '');
22
32
  }
23
33
 
@@ -27,10 +37,12 @@ export const dragGhost = defineTrait({
27
37
  }
28
38
 
29
39
  host.setAttribute('draggable', 'true');
40
+ host.addEventListener('pointerdown', onPointerDown);
30
41
  host.addEventListener('dragstart', onDragStart);
31
42
  host.addEventListener('dragend', onDragEnd);
32
43
 
33
44
  return () => {
45
+ host.removeEventListener('pointerdown', onPointerDown);
34
46
  host.removeEventListener('dragstart', onDragStart);
35
47
  host.removeEventListener('dragend', onDragEnd);
36
48
  if (ghost) { ghost.remove(); ghost = null; }
package/traits/index.js CHANGED
@@ -2,6 +2,11 @@
2
2
  // _catalog.json (generated from defineTrait() metadata). When adding a
3
3
  // trait, place it under its category header here, then run
4
4
  // `npm run build:traits` to refresh the catalog.
5
+ //
6
+ // Side-effect: the <traits-host> wrapper is auto-registered when this
7
+ // barrel is imported, so consumers who use the trait library at all
8
+ // also get raw-HTML declarative composition for free.
9
+ import './traits-host.js';
5
10
 
6
11
  // input-interaction
7
12
  export { pressable } from './pressable.js';
@@ -10,9 +10,11 @@ describe('scroll-lock', () => {
10
10
 
11
11
  it('connect sets body overflow:hidden + active attribute', () => {
12
12
  const host = mountHost();
13
- connectTrait(scrollLock, host);
13
+ const inst = connectTrait(scrollLock, host);
14
14
  expect(document.body.style.overflow).toBe('hidden');
15
15
  expect(host.hasAttribute('data-scroll-lock-active')).toBe(true);
16
+ // Balanced disconnect to keep the module-level lockCount honest for sibling tests.
17
+ inst.disconnect(host);
16
18
  });
17
19
 
18
20
  it('disconnect restores body overflow + clears attribute', () => {
@@ -14,10 +14,15 @@ export const springAnimate = defineTrait({
14
14
  let rafId = null;
15
15
  const target = 0;
16
16
 
17
- // Read current translate as starting position
17
+ // Read current translate as starting position. The trait writes to
18
+ // `style.translate` (not `transform`), so read that first; fall back to
19
+ // the transform matrix for callers that nudged via `transform`.
18
20
  const cs = getComputedStyle(host);
19
- const matrix = new DOMMatrixReadOnly(cs.transform);
20
- let position = matrix.m41 || 0;
21
+ let position = parseFloat(cs.translate) || 0;
22
+ if (!position) {
23
+ const matrix = new DOMMatrixReadOnly(cs.transform);
24
+ position = matrix.m41 || 0;
25
+ }
21
26
  let velocity = 0;
22
27
 
23
28
  host.setAttribute('data-spring-animate-active', '');
@@ -0,0 +1,53 @@
1
+ /**
2
+ * <traits-host traits="pressable scale-press ripple">
3
+ * <div>raw markup with trait behaviors attached</div>
4
+ * </traits-host>
5
+ *
6
+ * Tiny pass-through wrapper that extends declarative trait composition
7
+ * to raw HTML. Without this element, only UIElement subclasses can read
8
+ * the `traits=` attribute. Wrap any markup in <traits-host> and the
9
+ * named traits attach to the wrapper itself — events bubble up from
10
+ * children, attribute toggles land on the wrapper, and the children
11
+ * render unaffected.
12
+ *
13
+ * The host uses `display: contents` so it does not introduce a layout
14
+ * box; the wrapped children participate in the parent's flex/grid
15
+ * exactly as if the wrapper were not there.
16
+ *
17
+ * Use this for:
18
+ * - sprinkling `pressable` onto a custom button you don't want to
19
+ * turn into a UIElement
20
+ * - giving a `<dialog>` or `<details>` a `focus-trap` without a
21
+ * wrapper component
22
+ * - composing `magnetic-hover` + `tilt-hover` onto an existing
23
+ * marketing CTA without rewriting it as a component
24
+ *
25
+ * For UIElement subclasses, prefer the bare attribute on the element
26
+ * itself: `<button-ui traits="ripple">…</button-ui>`. The wrapper is
27
+ * for cases where the host element is NOT a UIElement.
28
+ */
29
+
30
+ import { UIElement } from '../core/element.js';
31
+
32
+ class TraitsHost extends UIElement {
33
+ static template = () => null;
34
+
35
+ connected() {
36
+ // No-op — UIElement reads `traits` attribute and applies behavior.
37
+ }
38
+ }
39
+
40
+ if (typeof customElements !== 'undefined' && !customElements.get('traits-host')) {
41
+ customElements.define('traits-host', TraitsHost);
42
+ }
43
+
44
+ // One-shot stylesheet keeps the host out of the layout flow so children
45
+ // render in the parent's box.
46
+ if (typeof document !== 'undefined' && document.head && !document.querySelector('#adia-traits-host-style')) {
47
+ const style = document.createElement('style');
48
+ style.id = 'adia-traits-host-style';
49
+ style.textContent = 'traits-host { display: contents; }';
50
+ document.head.appendChild(style);
51
+ }
52
+
53
+ export { TraitsHost };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * <traits-host> behavior tests — focus on the wrapper-specific contract:
3
+ * - children pass through visually (display: contents)
4
+ * - declarative traits attach to the wrapper
5
+ * - traits attribute swaps work the same as on UIElement subclasses
6
+ * - events from children bubble up to the wrapper for trait capture
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach } from 'vitest';
10
+ import './traits-host.js';
11
+ import './pressable.js';
12
+ import './hoverable.js';
13
+ import { resetDOM } from './_test-helpers.js';
14
+
15
+ describe('<traits-host>', () => {
16
+ beforeEach(resetDOM);
17
+
18
+ it('is registered as a UIElement subclass', () => {
19
+ const ctor = customElements.get('traits-host');
20
+ expect(ctor).toBeTruthy();
21
+ });
22
+
23
+ it('declarative traits=" " attaches to the wrapper itself', () => {
24
+ document.body.innerHTML = '<traits-host traits="pressable"><div>Raw</div></traits-host>';
25
+ const host = document.body.firstElementChild;
26
+ host.dispatchEvent(new PointerEvent('pointerdown'));
27
+ expect(host.hasAttribute('data-pressable-pressed')).toBe(true);
28
+ host.dispatchEvent(new PointerEvent('pointerup'));
29
+ expect(host.hasAttribute('data-pressable-pressed')).toBe(false);
30
+ });
31
+
32
+ it('events from children bubble up so the trait captures them', () => {
33
+ document.body.innerHTML = '<traits-host traits="hoverable"><span class="inner">Hover</span></traits-host>';
34
+ const host = document.body.firstElementChild;
35
+ const inner = host.querySelector('.inner');
36
+ // Synthesize a pointerenter on the wrapper (DOM bubbling up from inner
37
+ // would also work but happy-dom doesn't always reflect that for hover).
38
+ host.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }));
39
+ expect(host.hasAttribute('data-hoverable-hover')).toBe(true);
40
+ host.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true }));
41
+ expect(host.hasAttribute('data-hoverable-hover')).toBe(false);
42
+ });
43
+
44
+ it('changing traits attribute swaps the trait stack', () => {
45
+ document.body.innerHTML = '<traits-host traits="pressable"><div>x</div></traits-host>';
46
+ const host = document.body.firstElementChild;
47
+ host.dispatchEvent(new PointerEvent('pointerdown'));
48
+ expect(host.hasAttribute('data-pressable-pressed')).toBe(true);
49
+ host.dispatchEvent(new PointerEvent('pointerup'));
50
+
51
+ host.setAttribute('traits', 'hoverable');
52
+ host.dispatchEvent(new PointerEvent('pointerdown'));
53
+ expect(host.hasAttribute('data-pressable-pressed')).toBe(false);
54
+ host.dispatchEvent(new PointerEvent('pointerenter'));
55
+ expect(host.hasAttribute('data-hoverable-hover')).toBe(true);
56
+ });
57
+
58
+ it('removing the host cleans up trait attributes', () => {
59
+ document.body.innerHTML = '<traits-host traits="pressable"><div>x</div></traits-host>';
60
+ const host = document.body.firstElementChild;
61
+ host.dispatchEvent(new PointerEvent('pointerdown'));
62
+ expect(host.hasAttribute('data-pressable-pressed')).toBe(true);
63
+ host.remove();
64
+ expect(host.hasAttribute('data-pressable-pressed')).toBe(false);
65
+ });
66
+
67
+ it('with no traits attribute: pure pass-through, children render', () => {
68
+ document.body.innerHTML = '<traits-host><span class="x">child</span></traits-host>';
69
+ const host = document.body.firstElementChild;
70
+ expect(host.querySelector('.x')).toBeTruthy();
71
+ expect(host.querySelector('.x').textContent).toBe('child');
72
+ });
73
+ });