@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 +5 -2
- package/components/agent-trace/agent-trace.css +24 -3
- package/components/list/list.css +45 -3
- package/components/nav-group/nav-group.a2ui.json +1 -1
- package/components/nav-group/nav-group.css +5 -5
- package/components/nav-group/nav-group.yaml +1 -1
- package/components/nav-item/nav-item.a2ui.json +1 -1
- package/components/nav-item/nav-item.css +3 -4
- package/components/nav-item/nav-item.yaml +1 -1
- package/core/icons.js +1 -0
- package/package.json +1 -1
- package/traits/_catalog.json +1 -1
- package/traits/confetti-burst.js +83 -57
- package/traits/drag-ghost.js +13 -1
- package/traits/index.js +5 -0
- package/traits/scroll-lock.test.js +3 -1
- package/traits/spring-animate.js +8 -3
- package/traits/traits-host.js +53 -0
- package/traits/traits-host.test.js +73 -0
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/ —
|
|
61
|
-
│
|
|
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
|
-
|
|
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
|
-
|
|
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 */
|
package/components/list/list.css
CHANGED
|
@@ -92,20 +92,62 @@
|
|
|
92
92
|
@scope (list-item-ui) {
|
|
93
93
|
:where(:scope) {
|
|
94
94
|
/* ── Layout ── */
|
|
95
|
-
--list-item-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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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
package/traits/_catalog.json
CHANGED
|
@@ -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
|
],
|
package/traits/confetti-burst.js
CHANGED
|
@@ -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
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
40
|
+
const ctx = canvas.getContext('2d');
|
|
41
|
+
let rafId = null;
|
|
57
42
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
96
|
+
|
|
81
97
|
rafId = requestAnimationFrame(tick);
|
|
82
98
|
}
|
|
83
99
|
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
},
|
package/traits/drag-ghost.js
CHANGED
|
@@ -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,
|
|
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', () => {
|
package/traits/spring-animate.js
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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
|
+
});
|