@adia-ai/web-components 0.2.4 → 0.3.0
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 +4 -4
- package/components/fields/fields.a2ui.json +106 -0
- package/components/fields/fields.css +60 -0
- package/components/fields/fields.js +96 -0
- package/components/fields/fields.test.js +88 -0
- package/components/fields/fields.yaml +120 -0
- package/components/index.js +1 -0
- package/components/inspector/inspector.js +1 -1
- package/core/streams-bridge.js +1 -1
- package/package.json +18 -11
- package/styles/components.css +1 -0
- package/styles/typography.css +1 -1
- package/traits/_catalog.json +3 -1
- package/traits/draggable-list-item.js +211 -18
- package/traits/draggable-list-item.test.js +1 -1
- package/traits/droppable-collection.js +1 -1
- package/traits/droppable-collection.test.js +1 -1
- package/traits/droppable.js +18 -7
- package/traits/droppable.test.js +1 -1
- package/traits/keyboard-reorderable.js +1 -1
- package/traits/keyboard-reorderable.test.js +1 -1
package/README.md
CHANGED
|
@@ -64,12 +64,12 @@ web-components/
|
|
|
64
64
|
│ pages. Full contract in docs/specs/traits.md.
|
|
65
65
|
│
|
|
66
66
|
├── a2ui/ — deprecation shim for one release
|
|
67
|
-
│ └── index.js Re-exports @adia-ai/a2ui-
|
|
67
|
+
│ └── index.js Re-exports @adia-ai/a2ui-runtime with a
|
|
68
68
|
│ one-time console.warn. Removed in 0.1.0.
|
|
69
69
|
│ All actual A2UI runtime code (renderer,
|
|
70
70
|
│ registry, streams, surface manifest,
|
|
71
71
|
│ wiring, dockables, controllers) lives in
|
|
72
|
-
│ `@adia-ai/a2ui-
|
|
72
|
+
│ `@adia-ai/a2ui-runtime` at packages/a2ui/runtime/.
|
|
73
73
|
│
|
|
74
74
|
└── styles/ — Global tokens and CSS layering
|
|
75
75
|
├── tokens.css all --a-* design tokens
|
|
@@ -139,7 +139,7 @@ examples.
|
|
|
139
139
|
## A2UI runtime
|
|
140
140
|
|
|
141
141
|
```javascript
|
|
142
|
-
import { A2UIRenderer } from '@adia-ai/a2ui-
|
|
142
|
+
import { A2UIRenderer } from '@adia-ai/a2ui-runtime';
|
|
143
143
|
// (The old `@adia-ai/web-components/a2ui` subpath still resolves in 0.0.4
|
|
144
144
|
// via a deprecation shim that prints a console.warn; removed in 0.1.0.)
|
|
145
145
|
|
|
@@ -198,7 +198,7 @@ the `streams` registry export from `core/data-stream.js`.
|
|
|
198
198
|
|
|
199
199
|
Implementation: `core/data-stream.js` (~360 lines). Full
|
|
200
200
|
attribute table + live demos:
|
|
201
|
-
[`/site/components/chart#data-stream`](
|
|
201
|
+
[`/site/components/chart#data-stream`](./components/chart/chart.html).
|
|
202
202
|
|
|
203
203
|
## Build
|
|
204
204
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://adiaui.dev/a2ui/v0_9/components/Fields.json",
|
|
4
|
+
"title": "Fields",
|
|
5
|
+
"description": "Container for a group of <field-ui> children, laid out on a shared 6-column grid. Each <field-ui> spans the full row by default; opt into a narrower span via [rows=\"1..6\"]. Setting [inline] on the host propagates the inline mode to every direct <field-ui> child so a whole sub-form can switch label-position without per-field edits. The grid alignment lets siblings on the same row line up cleanly — consistent label columns + consistent control columns — without the wrap-flex jitter of <row-ui wrap>.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"allOf": [
|
|
8
|
+
{
|
|
9
|
+
"$ref": "common_types.json#/$defs/ComponentCommon"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"$ref": "common_types.json#/$defs/CatalogComponentCommon"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"properties": {
|
|
16
|
+
"columns": {
|
|
17
|
+
"description": "Number of grid columns the row uses. Defaults to 6 — common multiples (1/2/3/6) divide cleanly. <field-ui rows=\"N\"> spans `N` of these columns. Override per-instance for tighter 4-column or wider 12-column compositions.",
|
|
18
|
+
"type": "number",
|
|
19
|
+
"default": 6
|
|
20
|
+
},
|
|
21
|
+
"component": {
|
|
22
|
+
"const": "Fields"
|
|
23
|
+
},
|
|
24
|
+
"inline": {
|
|
25
|
+
"description": "Propagate the inline layout mode to every direct <field-ui> child. Equivalent to authoring `inline` on each child but drives the whole group from one place. Toggle is reactive — flipping the attribute on the host re-syncs all children.",
|
|
26
|
+
"type": "boolean",
|
|
27
|
+
"default": false
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"required": [
|
|
31
|
+
"component"
|
|
32
|
+
],
|
|
33
|
+
"unevaluatedProperties": false,
|
|
34
|
+
"x-adiaui": {
|
|
35
|
+
"anti_patterns": [
|
|
36
|
+
{
|
|
37
|
+
"description": "Don't wrap <field-ui> children in <row-ui wrap> when grid alignment is desired. row-ui is flex-wrap, which lets each field size to its content — labels and controls won't share a column with siblings.",
|
|
38
|
+
"severity": "high"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"description": "Don't set `inline` on individual <field-ui> children when the whole group should switch — set it on the parent <fields-ui> and let the propagation handle every child.",
|
|
42
|
+
"severity": "medium"
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"category": "form",
|
|
46
|
+
"events": {},
|
|
47
|
+
"examples": [
|
|
48
|
+
{
|
|
49
|
+
"description": "A 3-up stacked field row — Status / Priority / Group — each taking 2 of 6 columns. Equivalent to a Bootstrap \"col-md-4\" pattern (12-col grid, span 4) but with chat-ui's 6-col default.",
|
|
50
|
+
"a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"Fields\",\n \"children\": [\"status\", \"priority\", \"group\"]\n },\n { \"id\": \"status\", \"component\": \"Field\", \"label\": \"Status\", \"rows\": 2, \"children\": [\"status-sel\"] },\n { \"id\": \"priority\", \"component\": \"Field\", \"label\": \"Priority\", \"rows\": 2, \"children\": [\"priority-sel\"] },\n { \"id\": \"group\", \"component\": \"Field\", \"label\": \"Group\", \"rows\": 2, \"children\": [\"group-sel\"] },\n { \"id\": \"status-sel\", \"component\": \"Select\", \"value\": \"todo\" },\n { \"id\": \"priority-sel\", \"component\": \"Select\", \"value\": \"0\" },\n { \"id\": \"group-sel\", \"component\": \"Select\", \"value\": \"\" }\n]",
|
|
51
|
+
"name": "three-up-stacked"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"description": "An inline form — every field renders label-beside-control. Single [inline] on the host drives all children.",
|
|
55
|
+
"a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"Fields\",\n \"inline\": true,\n \"children\": [\"q\", \"kind\"]\n },\n { \"id\": \"q\", \"component\": \"Field\", \"label\": \"Search\", \"children\": [\"q-input\"] },\n { \"id\": \"kind\", \"component\": \"Field\", \"label\": \"Kind\", \"children\": [\"kind-sel\"] },\n { \"id\": \"q-input\", \"component\": \"Input\", \"type\": \"search\" },\n { \"id\": \"kind-sel\", \"component\": \"Select\", \"value\": \"all\" }\n]",
|
|
56
|
+
"name": "inline-search-form"
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
"keywords": [
|
|
60
|
+
"fields",
|
|
61
|
+
"form",
|
|
62
|
+
"grid",
|
|
63
|
+
"layout",
|
|
64
|
+
"group"
|
|
65
|
+
],
|
|
66
|
+
"name": "UIFields",
|
|
67
|
+
"related": [
|
|
68
|
+
"field",
|
|
69
|
+
"input",
|
|
70
|
+
"select",
|
|
71
|
+
"textarea"
|
|
72
|
+
],
|
|
73
|
+
"slots": {
|
|
74
|
+
"default": {
|
|
75
|
+
"description": "<field-ui> children. Non-field children (separators, headings) are placed in the grid too — span them by setting `style=\"grid-column: 1 / -1\"` for full-width content. Nested <fields-ui> works (an outer 6-col grid hosting inner 3-col grids inside a single cell)."
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"states": [
|
|
79
|
+
{
|
|
80
|
+
"description": "Default, ready for interaction.",
|
|
81
|
+
"name": "idle"
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
"synonyms": {
|
|
85
|
+
"form": [
|
|
86
|
+
"fields",
|
|
87
|
+
"group",
|
|
88
|
+
"layout"
|
|
89
|
+
]
|
|
90
|
+
},
|
|
91
|
+
"tag": "fields-ui",
|
|
92
|
+
"tokens": {
|
|
93
|
+
"--fields-column-gap": {
|
|
94
|
+
"description": "Override the column-gap independently of row-gap."
|
|
95
|
+
},
|
|
96
|
+
"--fields-gap": {
|
|
97
|
+
"description": "Gap between adjacent fields (row + column)."
|
|
98
|
+
},
|
|
99
|
+
"--fields-row-gap": {
|
|
100
|
+
"description": "Override the row-gap independently of column-gap."
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"traits": [],
|
|
104
|
+
"version": 1
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
@scope (fields-ui) {
|
|
2
|
+
:where(:scope) {
|
|
3
|
+
/* ── Tokens ── */
|
|
4
|
+
--fields-gap: var(--a-space-3);
|
|
5
|
+
--fields-row-gap: var(--fields-gap);
|
|
6
|
+
--fields-column-gap: var(--fields-gap);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* ── Base — 6-column grid ──
|
|
10
|
+
Children are placed into the grid by their [rows="N"] attr (pure
|
|
11
|
+
CSS, no JS). Default span (no attr) is the full row. The columns
|
|
12
|
+
count is configurable via [columns="N"] on the host — defaults
|
|
13
|
+
handle [columns] absent.
|
|
14
|
+
|
|
15
|
+
`minmax(0, 1fr)` is required so children with overflow content
|
|
16
|
+
(long labels, long select values) don't blow out the grid. */
|
|
17
|
+
:scope {
|
|
18
|
+
box-sizing: border-box;
|
|
19
|
+
display: grid;
|
|
20
|
+
grid-template-columns: repeat(var(--fields-columns, 6), minmax(0, 1fr));
|
|
21
|
+
column-gap: var(--fields-column-gap);
|
|
22
|
+
row-gap: var(--fields-row-gap);
|
|
23
|
+
align-items: start;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* Per-instance column count from [columns="N"] — unrolled for the
|
|
27
|
+
common values 1..12. Beyond 12 the host can set
|
|
28
|
+
`--fields-columns: N` inline. */
|
|
29
|
+
:scope[columns="1"] { --fields-columns: 1; }
|
|
30
|
+
:scope[columns="2"] { --fields-columns: 2; }
|
|
31
|
+
:scope[columns="3"] { --fields-columns: 3; }
|
|
32
|
+
:scope[columns="4"] { --fields-columns: 4; }
|
|
33
|
+
:scope[columns="5"] { --fields-columns: 5; }
|
|
34
|
+
:scope[columns="6"] { --fields-columns: 6; }
|
|
35
|
+
:scope[columns="8"] { --fields-columns: 8; }
|
|
36
|
+
:scope[columns="12"] { --fields-columns: 12; }
|
|
37
|
+
|
|
38
|
+
/* ── field-ui spans ──
|
|
39
|
+
`rows="N"` reads as "this field takes N grid cells". Without the
|
|
40
|
+
attr, the field spans the full row (`grid-column: 1 / -1`) so
|
|
41
|
+
authors can mix narrow + full-width fields without per-row
|
|
42
|
+
wrappers. The attribute is exposed up to 12 to cover larger
|
|
43
|
+
[columns] grids; 13+ also collapses to full-row. */
|
|
44
|
+
:scope > field-ui {
|
|
45
|
+
grid-column: 1 / -1;
|
|
46
|
+
min-width: 0; /* allow children to shrink within the cell */
|
|
47
|
+
}
|
|
48
|
+
:scope > field-ui[rows="1"] { grid-column: span 1; }
|
|
49
|
+
:scope > field-ui[rows="2"] { grid-column: span 2; }
|
|
50
|
+
:scope > field-ui[rows="3"] { grid-column: span 3; }
|
|
51
|
+
:scope > field-ui[rows="4"] { grid-column: span 4; }
|
|
52
|
+
:scope > field-ui[rows="5"] { grid-column: span 5; }
|
|
53
|
+
:scope > field-ui[rows="6"] { grid-column: span 6; }
|
|
54
|
+
:scope > field-ui[rows="7"] { grid-column: span 7; }
|
|
55
|
+
:scope > field-ui[rows="8"] { grid-column: span 8; }
|
|
56
|
+
:scope > field-ui[rows="9"] { grid-column: span 9; }
|
|
57
|
+
:scope > field-ui[rows="10"] { grid-column: span 10; }
|
|
58
|
+
:scope > field-ui[rows="11"] { grid-column: span 11; }
|
|
59
|
+
:scope > field-ui[rows="12"] { grid-column: span 12; }
|
|
60
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fields-ui — Container for a group of <field-ui> children laid out
|
|
3
|
+
* on a shared grid.
|
|
4
|
+
*
|
|
5
|
+
* <fields-ui> <!-- 6-col grid -->
|
|
6
|
+
* <field-ui label="Title">…</field-ui> <!-- rows=6 (full row) -->
|
|
7
|
+
* <field-ui label="Status" rows="2">…</field-ui>
|
|
8
|
+
* <field-ui label="Priority" rows="2">…</field-ui>
|
|
9
|
+
* <field-ui label="Group" rows="2">…</field-ui>
|
|
10
|
+
* </fields-ui>
|
|
11
|
+
*
|
|
12
|
+
* <fields-ui inline> <!-- propagate inline to every child -->
|
|
13
|
+
* <field-ui label="Search">…</field-ui>
|
|
14
|
+
* <field-ui label="Kind">…</field-ui>
|
|
15
|
+
* </fields-ui>
|
|
16
|
+
*
|
|
17
|
+
* The shared grid keeps siblings on the same row aligned. The [inline]
|
|
18
|
+
* attribute is mirrored onto every direct <field-ui> child so a whole
|
|
19
|
+
* sub-form switches layout mode from one host attribute. A small
|
|
20
|
+
* MutationObserver re-syncs when children are added/removed.
|
|
21
|
+
*
|
|
22
|
+
* Per-field column span is driven by the child's [rows="1..6"]
|
|
23
|
+
* attribute — pure CSS via attribute selectors in fields.css. The
|
|
24
|
+
* default span (no attr) is the full row.
|
|
25
|
+
*/
|
|
26
|
+
import { UIElement } from '../../core/element.js';
|
|
27
|
+
|
|
28
|
+
class UIFields extends UIElement {
|
|
29
|
+
static properties = {
|
|
30
|
+
inline: { type: Boolean, default: false, reflect: true },
|
|
31
|
+
columns: { type: Number, default: 6, reflect: true },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
static template = () => null;
|
|
35
|
+
|
|
36
|
+
/** @type {MutationObserver | null} */
|
|
37
|
+
#mo = null;
|
|
38
|
+
|
|
39
|
+
connected() {
|
|
40
|
+
this.#syncInline();
|
|
41
|
+
this.#mo = new MutationObserver((records) => {
|
|
42
|
+
// Only re-sync on childList changes that involve direct <field-ui>
|
|
43
|
+
// children — attribute mutations on grandchildren aren't ours to
|
|
44
|
+
// care about.
|
|
45
|
+
for (const r of records) {
|
|
46
|
+
if (r.type !== 'childList') continue;
|
|
47
|
+
if (this.#hasFieldChild(r.addedNodes) || this.#hasFieldChild(r.removedNodes)) {
|
|
48
|
+
this.#syncInline();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
this.#mo.observe(this, { childList: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
disconnected() {
|
|
57
|
+
this.#mo?.disconnect();
|
|
58
|
+
this.#mo = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
render() {
|
|
62
|
+
// UIElement calls render() on attribute → property reflection. The
|
|
63
|
+
// inline attr's effect is propagation-to-children, not host markup.
|
|
64
|
+
this.#syncInline();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Private ────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
#syncInline() {
|
|
70
|
+
const inline = this.hasAttribute('inline');
|
|
71
|
+
for (const child of this.children) {
|
|
72
|
+
if (child.localName !== 'field-ui') continue;
|
|
73
|
+
if (inline) {
|
|
74
|
+
if (!child.hasAttribute('inline')) child.setAttribute('inline', '');
|
|
75
|
+
} else {
|
|
76
|
+
// Only remove if WE set it. We can't tell, so we always remove —
|
|
77
|
+
// that's the documented contract: <fields-ui inline> is the
|
|
78
|
+
// single source of truth for child inline-mode within the group.
|
|
79
|
+
if (child.hasAttribute('inline')) child.removeAttribute('inline');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @param {NodeList} nodes */
|
|
85
|
+
#hasFieldChild(nodes) {
|
|
86
|
+
for (const n of nodes) {
|
|
87
|
+
if (n.nodeType === 1 && /** @type {Element} */ (n).localName === 'field-ui') {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
customElements.define('fields-ui', UIFields);
|
|
96
|
+
export { UIFields };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import '../../core/element.js';
|
|
3
|
+
import './fields.js';
|
|
4
|
+
import '../field/field.js';
|
|
5
|
+
|
|
6
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
7
|
+
|
|
8
|
+
function mount(html) {
|
|
9
|
+
const wrap = document.createElement('div');
|
|
10
|
+
wrap.innerHTML = html;
|
|
11
|
+
document.body.appendChild(wrap);
|
|
12
|
+
return wrap.firstElementChild;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('fields-ui', () => {
|
|
16
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
17
|
+
|
|
18
|
+
it('registers the custom element', () => {
|
|
19
|
+
expect(customElements.get('fields-ui')).toBeDefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('hosts <field-ui> children without altering them by default', () => {
|
|
23
|
+
const f = mount(`
|
|
24
|
+
<fields-ui>
|
|
25
|
+
<field-ui label="A"><input id="a" /></field-ui>
|
|
26
|
+
<field-ui label="B"><input id="b" /></field-ui>
|
|
27
|
+
</fields-ui>
|
|
28
|
+
`);
|
|
29
|
+
const fields = f.querySelectorAll('field-ui');
|
|
30
|
+
expect(fields).toHaveLength(2);
|
|
31
|
+
// Default — neither child has [inline] propagated.
|
|
32
|
+
expect(fields[0].hasAttribute('inline')).toBe(false);
|
|
33
|
+
expect(fields[1].hasAttribute('inline')).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('propagates [inline] to every direct <field-ui> child on connect', async () => {
|
|
37
|
+
const f = mount(`
|
|
38
|
+
<fields-ui inline>
|
|
39
|
+
<field-ui label="A"><input id="a" /></field-ui>
|
|
40
|
+
<field-ui label="B"><input id="b" /></field-ui>
|
|
41
|
+
</fields-ui>
|
|
42
|
+
`);
|
|
43
|
+
await tick();
|
|
44
|
+
const fields = f.querySelectorAll('field-ui');
|
|
45
|
+
for (const child of fields) {
|
|
46
|
+
expect(child.hasAttribute('inline')).toBe(true);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('removes inline from children when [inline] is removed from host', async () => {
|
|
51
|
+
const f = mount(`
|
|
52
|
+
<fields-ui inline>
|
|
53
|
+
<field-ui label="A"><input id="a" /></field-ui>
|
|
54
|
+
<field-ui label="B"><input id="b" /></field-ui>
|
|
55
|
+
</fields-ui>
|
|
56
|
+
`);
|
|
57
|
+
await tick();
|
|
58
|
+
f.removeAttribute('inline');
|
|
59
|
+
await tick();
|
|
60
|
+
const fields = f.querySelectorAll('field-ui');
|
|
61
|
+
for (const child of fields) {
|
|
62
|
+
expect(child.hasAttribute('inline')).toBe(false);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('re-syncs inline when a new <field-ui> is appended later', async () => {
|
|
67
|
+
const f = mount(`<fields-ui inline></fields-ui>`);
|
|
68
|
+
await tick();
|
|
69
|
+
const child = document.createElement('field-ui');
|
|
70
|
+
child.setAttribute('label', 'Late');
|
|
71
|
+
f.appendChild(child);
|
|
72
|
+
// MutationObserver fires async — give it a microtask.
|
|
73
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
74
|
+
expect(child.hasAttribute('inline')).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('does not propagate [inline] to non-field children', async () => {
|
|
78
|
+
const f = mount(`
|
|
79
|
+
<fields-ui inline>
|
|
80
|
+
<field-ui label="A"><input id="a" /></field-ui>
|
|
81
|
+
<span class="separator">—</span>
|
|
82
|
+
</fields-ui>
|
|
83
|
+
`);
|
|
84
|
+
await tick();
|
|
85
|
+
const span = f.querySelector('.separator');
|
|
86
|
+
expect(span.hasAttribute('inline')).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Edit this file; run `npm run build:components` to regenerate a2ui.json.
|
|
2
|
+
$schema: ../../../../scripts/schemas/component.yaml.schema.json
|
|
3
|
+
name: UIFields
|
|
4
|
+
tag: fields-ui
|
|
5
|
+
component: Fields
|
|
6
|
+
category: form
|
|
7
|
+
version: 1
|
|
8
|
+
description: >-
|
|
9
|
+
Container for a group of <field-ui> children, laid out on a shared
|
|
10
|
+
6-column grid. Each <field-ui> spans the full row by default; opt
|
|
11
|
+
into a narrower span via [rows="1..6"]. Setting [inline] on the host
|
|
12
|
+
propagates the inline mode to every direct <field-ui> child so a
|
|
13
|
+
whole sub-form can switch label-position without per-field edits.
|
|
14
|
+
The grid alignment lets siblings on the same row line up cleanly —
|
|
15
|
+
consistent label columns + consistent control columns — without
|
|
16
|
+
the wrap-flex jitter of <row-ui wrap>.
|
|
17
|
+
props:
|
|
18
|
+
inline:
|
|
19
|
+
description: >-
|
|
20
|
+
Propagate the inline layout mode to every direct <field-ui>
|
|
21
|
+
child. Equivalent to authoring `inline` on each child but
|
|
22
|
+
drives the whole group from one place. Toggle is reactive —
|
|
23
|
+
flipping the attribute on the host re-syncs all children.
|
|
24
|
+
type: boolean
|
|
25
|
+
default: false
|
|
26
|
+
reflect: true
|
|
27
|
+
columns:
|
|
28
|
+
description: >-
|
|
29
|
+
Number of grid columns the row uses. Defaults to 6 — common
|
|
30
|
+
multiples (1/2/3/6) divide cleanly. <field-ui rows="N"> spans
|
|
31
|
+
`N` of these columns. Override per-instance for tighter
|
|
32
|
+
4-column or wider 12-column compositions.
|
|
33
|
+
type: number
|
|
34
|
+
default: 6
|
|
35
|
+
reflect: true
|
|
36
|
+
slots:
|
|
37
|
+
default:
|
|
38
|
+
description: >-
|
|
39
|
+
<field-ui> children. Non-field children (separators, headings)
|
|
40
|
+
are placed in the grid too — span them by setting `style="grid-column:
|
|
41
|
+
1 / -1"` for full-width content. Nested <fields-ui> works (an
|
|
42
|
+
outer 6-col grid hosting inner 3-col grids inside a single cell).
|
|
43
|
+
states:
|
|
44
|
+
- name: idle
|
|
45
|
+
description: Default, ready for interaction.
|
|
46
|
+
traits: []
|
|
47
|
+
tokens:
|
|
48
|
+
--fields-gap:
|
|
49
|
+
description: Gap between adjacent fields (row + column).
|
|
50
|
+
--fields-row-gap:
|
|
51
|
+
description: Override the row-gap independently of column-gap.
|
|
52
|
+
--fields-column-gap:
|
|
53
|
+
description: Override the column-gap independently of row-gap.
|
|
54
|
+
a2ui:
|
|
55
|
+
rules: []
|
|
56
|
+
anti_patterns:
|
|
57
|
+
- description: >-
|
|
58
|
+
Don't wrap <field-ui> children in <row-ui wrap> when grid
|
|
59
|
+
alignment is desired. row-ui is flex-wrap, which lets each
|
|
60
|
+
field size to its content — labels and controls won't share
|
|
61
|
+
a column with siblings.
|
|
62
|
+
severity: high
|
|
63
|
+
- description: >-
|
|
64
|
+
Don't set `inline` on individual <field-ui> children when the
|
|
65
|
+
whole group should switch — set it on the parent <fields-ui>
|
|
66
|
+
and let the propagation handle every child.
|
|
67
|
+
severity: medium
|
|
68
|
+
examples:
|
|
69
|
+
- name: three-up-stacked
|
|
70
|
+
description: >-
|
|
71
|
+
A 3-up stacked field row — Status / Priority / Group — each
|
|
72
|
+
taking 2 of 6 columns. Equivalent to a Bootstrap "col-md-4"
|
|
73
|
+
pattern (12-col grid, span 4) but with chat-ui's 6-col default.
|
|
74
|
+
a2ui: >-
|
|
75
|
+
[
|
|
76
|
+
{
|
|
77
|
+
"id": "root",
|
|
78
|
+
"component": "Fields",
|
|
79
|
+
"children": ["status", "priority", "group"]
|
|
80
|
+
},
|
|
81
|
+
{ "id": "status", "component": "Field", "label": "Status", "rows": 2, "children": ["status-sel"] },
|
|
82
|
+
{ "id": "priority", "component": "Field", "label": "Priority", "rows": 2, "children": ["priority-sel"] },
|
|
83
|
+
{ "id": "group", "component": "Field", "label": "Group", "rows": 2, "children": ["group-sel"] },
|
|
84
|
+
{ "id": "status-sel", "component": "Select", "value": "todo" },
|
|
85
|
+
{ "id": "priority-sel", "component": "Select", "value": "0" },
|
|
86
|
+
{ "id": "group-sel", "component": "Select", "value": "" }
|
|
87
|
+
]
|
|
88
|
+
- name: inline-search-form
|
|
89
|
+
description: >-
|
|
90
|
+
An inline form — every field renders label-beside-control. Single
|
|
91
|
+
[inline] on the host drives all children.
|
|
92
|
+
a2ui: >-
|
|
93
|
+
[
|
|
94
|
+
{
|
|
95
|
+
"id": "root",
|
|
96
|
+
"component": "Fields",
|
|
97
|
+
"inline": true,
|
|
98
|
+
"children": ["q", "kind"]
|
|
99
|
+
},
|
|
100
|
+
{ "id": "q", "component": "Field", "label": "Search", "children": ["q-input"] },
|
|
101
|
+
{ "id": "kind", "component": "Field", "label": "Kind", "children": ["kind-sel"] },
|
|
102
|
+
{ "id": "q-input", "component": "Input", "type": "search" },
|
|
103
|
+
{ "id": "kind-sel", "component": "Select", "value": "all" }
|
|
104
|
+
]
|
|
105
|
+
keywords:
|
|
106
|
+
- fields
|
|
107
|
+
- form
|
|
108
|
+
- grid
|
|
109
|
+
- layout
|
|
110
|
+
- group
|
|
111
|
+
synonyms:
|
|
112
|
+
form:
|
|
113
|
+
- fields
|
|
114
|
+
- group
|
|
115
|
+
- layout
|
|
116
|
+
related:
|
|
117
|
+
- field
|
|
118
|
+
- input
|
|
119
|
+
- select
|
|
120
|
+
- textarea
|
package/components/index.js
CHANGED
|
@@ -56,6 +56,7 @@ export { UITag } from './tag/tag.js';
|
|
|
56
56
|
export { UISwatch } from './swatch/swatch.js';
|
|
57
57
|
export { UICol } from './col/col.js';
|
|
58
58
|
export { UIField } from './field/field.js';
|
|
59
|
+
export { UIFields } from './fields/fields.js';
|
|
59
60
|
export { UIRow } from './row/row.js';
|
|
60
61
|
export { UIGrid } from './grid/grid.js';
|
|
61
62
|
export { UIStack } from './stack/stack.js';
|
package/core/streams-bridge.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* the dependency direction is web-components → a2ui-utils, not the
|
|
13
13
|
* reverse. The bridge duck-types the renderer (it needs `.process()`,
|
|
14
14
|
* nothing else), so it works with any A2UI-protocol consumer — the
|
|
15
|
-
* actual `A2UIRenderer` from `@adia-ai/a2ui-
|
|
15
|
+
* actual `A2UIRenderer` from `@adia-ai/a2ui-runtime`, a custom renderer,
|
|
16
16
|
* or a test stub.
|
|
17
17
|
*
|
|
18
18
|
* Contract:
|
package/package.json
CHANGED
|
@@ -1,25 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/web-components",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AdiaUI web components
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "AdiaUI web components \u2014 vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
|
-
".":
|
|
8
|
-
"./css":
|
|
9
|
-
"./core":
|
|
10
|
-
"./core/*":
|
|
11
|
-
"./components":
|
|
7
|
+
".": "./index.js",
|
|
8
|
+
"./css": "./index.css",
|
|
9
|
+
"./core": "./core/index.js",
|
|
10
|
+
"./core/*": "./core/*.js",
|
|
11
|
+
"./components": "./components/index.js",
|
|
12
12
|
"./components/*": "./components/*/*.js",
|
|
13
|
-
"./styles/*":
|
|
14
|
-
"./traits":
|
|
15
|
-
"./traits/*":
|
|
13
|
+
"./styles/*": "./styles/*",
|
|
14
|
+
"./traits": "./traits/index.js",
|
|
15
|
+
"./traits/*": "./traits/*.js",
|
|
16
16
|
"./package.json": "./package.json"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"core/",
|
|
20
20
|
"components/",
|
|
21
|
+
"!components/**/*.html",
|
|
22
|
+
"!components/**/*.examples.js",
|
|
21
23
|
"styles/",
|
|
24
|
+
"!styles/**/*.html",
|
|
25
|
+
"!styles/**/*.examples.js",
|
|
22
26
|
"traits/",
|
|
27
|
+
"!traits/**/*.html",
|
|
28
|
+
"!traits/**/*.examples.js",
|
|
29
|
+
"!traits/_api-table.js",
|
|
23
30
|
"index.js",
|
|
24
31
|
"index.css"
|
|
25
32
|
],
|
|
@@ -30,7 +37,7 @@
|
|
|
30
37
|
"./core/provider.js"
|
|
31
38
|
],
|
|
32
39
|
"dependencies": {
|
|
33
|
-
"@adia-ai/a2ui-
|
|
40
|
+
"@adia-ai/a2ui-runtime": "^0.3.0"
|
|
34
41
|
},
|
|
35
42
|
"publishConfig": {
|
|
36
43
|
"access": "public",
|
package/styles/components.css
CHANGED
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
@import "../components/swatch/swatch.css";
|
|
55
55
|
@import "../components/col/col.css";
|
|
56
56
|
@import "../components/field/field.css";
|
|
57
|
+
@import "../components/fields/fields.css";
|
|
57
58
|
@import "../components/row/row.css";
|
|
58
59
|
@import "../components/grid/grid.css";
|
|
59
60
|
@import "../components/stack/stack.css";
|
package/styles/typography.css
CHANGED
|
@@ -256,7 +256,7 @@
|
|
|
256
256
|
|
|
257
257
|
/* section — h2/h3, major division within content */
|
|
258
258
|
--a-section-family: var(--a-font-family-heading);
|
|
259
|
-
--a-section-weight: var(--a-weight-
|
|
259
|
+
--a-section-weight: var(--a-weight-semibold);
|
|
260
260
|
--a-section-sm: 16px;
|
|
261
261
|
--a-section-md: 17px;
|
|
262
262
|
--a-section-lg: 19px;
|
package/traits/_catalog.json
CHANGED
|
@@ -3,7 +3,7 @@ import { defineTrait } from './define.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* `draggable-list-item` — pointer-driven list-reorder lifter.
|
|
5
5
|
*
|
|
6
|
-
* Authored for the Tasks UI playground (
|
|
6
|
+
* Authored for the Tasks UI playground (apps/tasks/spec/).
|
|
7
7
|
* Per SPEC §6.2 + ARCHITECTURE-REVIEW H2 (category: 'motion-positioning').
|
|
8
8
|
*
|
|
9
9
|
* Different mental model than the existing `draggable` trait:
|
|
@@ -65,6 +65,8 @@ function announceImmediate(message) {
|
|
|
65
65
|
|
|
66
66
|
const ATTR_LIFTING = 'data-draggable-list-item-lifting';
|
|
67
67
|
const ATTR_SOURCE_ID = 'data-draggable-list-item-id';
|
|
68
|
+
const ATTR_GHOST = 'data-draggable-list-item-ghost';
|
|
69
|
+
const ATTR_GHOST_ACTIVE = 'data-draggable-list-item-ghost-active';
|
|
68
70
|
const DRAG_THRESHOLD_PX = 4;
|
|
69
71
|
|
|
70
72
|
/** @type {{ container: Element; index: number } | null} */
|
|
@@ -99,16 +101,80 @@ function dropTargetFromPoint(x, y) {
|
|
|
99
101
|
return null;
|
|
100
102
|
}
|
|
101
103
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Compute the column-relative insertion index at pointer (x, y) within
|
|
106
|
+
* a vertical-list droppable target. The returned index is in
|
|
107
|
+
* POST-REMOVAL coordinates — i.e., it counts items as if the dragging
|
|
108
|
+
* source had already been removed from the list.
|
|
109
|
+
*
|
|
110
|
+
* Why post-removal: the consumer's command (Move/Reorder) splices the
|
|
111
|
+
* source out FIRST, then splices in at `to_position`. If the trait
|
|
112
|
+
* counted the source in pre-removal coordinates, downward intra-list
|
|
113
|
+
* drags would land one slot too far (the source's current slot
|
|
114
|
+
* disappears post-removal, shifting indices down by one). Pre-removal
|
|
115
|
+
* cross-list drags happen to be immune, but the math is identical.
|
|
116
|
+
*
|
|
117
|
+
* Items are filtered to DIRECT CHILDREN with the trait's id attribute —
|
|
118
|
+
* not descendants — to keep nested droppables from poisoning the count.
|
|
119
|
+
* Zero-layout items (display:none, off-flow) are skipped.
|
|
120
|
+
*
|
|
121
|
+
* @param {Element} target — the droppable container ([data-droppable-id])
|
|
122
|
+
* @param {number} _x — pointer client X (unused for vertical-list math; kept for future horizontal-list support)
|
|
123
|
+
* @param {number} y — pointer client Y
|
|
124
|
+
* @param {string | null} sourceId — the dragging source's data-draggable-list-item-id; this item is
|
|
125
|
+
* skipped while counting so the result is in post-removal coordinates
|
|
126
|
+
* @returns {number} insertion index in [0, count_after_removal]
|
|
127
|
+
*/
|
|
128
|
+
function indexWithinTarget(target, _x, y, sourceId) {
|
|
129
|
+
// Find all `[data-draggable-list-item-id]` descendants whose nearest
|
|
130
|
+
// `[data-droppable-id]` ancestor IS the target. This supports both
|
|
131
|
+
// structural patterns:
|
|
132
|
+
// • Items as DIRECT children of the droppable host
|
|
133
|
+
// (e.g. `<list-section data-droppable-id> <task-row-ui>...`).
|
|
134
|
+
// • Items nested inside an intermediate container
|
|
135
|
+
// (e.g. `<task-column-ui data-droppable-id> <div class="col-list">
|
|
136
|
+
// <task-card-ui>...` — the column's CSS owns chrome on the host
|
|
137
|
+
// while cards live in a flow-styled inner list).
|
|
138
|
+
//
|
|
139
|
+
// The closest-ancestor filter keeps nested droppables from poisoning
|
|
140
|
+
// the count: a child droppable's items have a different closest
|
|
141
|
+
// `[data-droppable-id]`, so they're skipped here.
|
|
142
|
+
//
|
|
143
|
+
// Pre-filter to source-skipped, layout-having items so we know which
|
|
144
|
+
// is truly last in post-removal coordinates.
|
|
145
|
+
const visible = [];
|
|
146
|
+
const candidates = target.querySelectorAll('[data-draggable-list-item-id]');
|
|
147
|
+
for (const child of candidates) {
|
|
148
|
+
if (child.closest('[data-droppable-id]') !== target) continue;
|
|
149
|
+
if (sourceId && child.getAttribute('data-draggable-list-item-id') === sourceId) continue;
|
|
150
|
+
const r = child.getBoundingClientRect();
|
|
151
|
+
if (r.height === 0 && r.width === 0) continue;
|
|
152
|
+
visible.push(r);
|
|
153
|
+
}
|
|
154
|
+
if (visible.length === 0) return 0;
|
|
155
|
+
|
|
156
|
+
for (let i = 0; i < visible.length; i++) {
|
|
157
|
+
const r = visible[i];
|
|
158
|
+
// Standard midline test (Pragmatic-DnD / Linear / Notion convention):
|
|
159
|
+
// cursor above this item's mid-line → insert before it; below → fall
|
|
160
|
+
// through to the next item. After the loop, if no hit, drop after
|
|
161
|
+
// all items (cursor was below every mid-line).
|
|
162
|
+
//
|
|
163
|
+
// History: v0.2.5 introduced a "last-item generous zone" that
|
|
164
|
+
// returned `visible.length` whenever the cursor was anywhere ON the
|
|
165
|
+
// last item — meant to make the 3-item-list "drop on last to send
|
|
166
|
+
// it to the bottom" gesture more permissive. Reverted: it broke the
|
|
167
|
+
// common "drop between second-to-last and last" path (hovering the
|
|
168
|
+
// last item's upper-half no longer resolved to "before last"). With
|
|
169
|
+
// the v0.2.6 indicator-visibility upgrade (3 px stroke + terminal
|
|
170
|
+
// caps + accent glow) the lower-half-targets-end behavior is now
|
|
171
|
+
// visually obvious, so the standard midline test reads cleanly
|
|
172
|
+
// without the carve-out.
|
|
109
173
|
if (y < r.top + r.height / 2) return i;
|
|
110
174
|
}
|
|
111
|
-
|
|
175
|
+
// Cursor is below every item's midline (and below last's top — only
|
|
176
|
+
// possible if the section has padding-bottom and cursor is in that gap).
|
|
177
|
+
return visible.length;
|
|
112
178
|
}
|
|
113
179
|
|
|
114
180
|
export const draggableListItem = defineTrait({
|
|
@@ -117,7 +183,12 @@ export const draggableListItem = defineTrait({
|
|
|
117
183
|
description: 'Pointer drag for list reordering; emits dnd-lift/drop-target-change/drop/drop-cancel',
|
|
118
184
|
attributes: [ATTR_LIFTING, ATTR_SOURCE_ID],
|
|
119
185
|
events: ['dnd-lift', 'dnd-drop-target-change', 'dnd-drop', 'dnd-drop-cancel'],
|
|
120
|
-
|
|
186
|
+
// Opt-in: when present, the trait renders a positioned clone of the
|
|
187
|
+
// host that follows the cursor during the drag (Pragmatic-DnD style
|
|
188
|
+
// ghost preview). Backward-compatible — absent = no ghost, current
|
|
189
|
+
// behavior. Style the ghost via consumer CSS using the
|
|
190
|
+
// [data-draggable-list-item-ghost-active] attribute.
|
|
191
|
+
config: [ATTR_GHOST],
|
|
121
192
|
setup({ host }) {
|
|
122
193
|
let id = host.getAttribute(ATTR_SOURCE_ID);
|
|
123
194
|
if (!id) {
|
|
@@ -131,6 +202,61 @@ export const draggableListItem = defineTrait({
|
|
|
131
202
|
/** @type {string | null} */
|
|
132
203
|
let lastTargetId = null;
|
|
133
204
|
let lastTargetIndex = -1;
|
|
205
|
+
/** @type {HTMLElement | null} */
|
|
206
|
+
let ghost = null;
|
|
207
|
+
|
|
208
|
+
function makeGhost() {
|
|
209
|
+
if (!host.hasAttribute(ATTR_GHOST)) return;
|
|
210
|
+
const r = host.getBoundingClientRect();
|
|
211
|
+
ghost = /** @type {HTMLElement} */ (host.cloneNode(true));
|
|
212
|
+
// Strip everything that could trigger interactive state on the clone:
|
|
213
|
+
// - trait IDs (registry hygiene)
|
|
214
|
+
// - tabindex (would attract focus when appended to body, painting
|
|
215
|
+
// a focus-visible outline that double-traces the ghost)
|
|
216
|
+
// - aria-* state (clone is presentational; the source remains the
|
|
217
|
+
// accessible focus target during drag)
|
|
218
|
+
// - data-selected (visual selection state belongs to the source)
|
|
219
|
+
// - id (no DOM-id duplicates)
|
|
220
|
+
ghost.removeAttribute(ATTR_SOURCE_ID);
|
|
221
|
+
ghost.removeAttribute(ATTR_LIFTING);
|
|
222
|
+
ghost.removeAttribute('data-keyboard-reorderable-id');
|
|
223
|
+
ghost.removeAttribute('data-keyboard-reorderable-lifting');
|
|
224
|
+
ghost.removeAttribute('data-selected');
|
|
225
|
+
ghost.removeAttribute('id');
|
|
226
|
+
ghost.removeAttribute('tabindex');
|
|
227
|
+
ghost.removeAttribute('aria-selected');
|
|
228
|
+
ghost.setAttribute('aria-hidden', 'true');
|
|
229
|
+
Object.assign(ghost.style, {
|
|
230
|
+
position: 'fixed',
|
|
231
|
+
left: `${r.left}px`,
|
|
232
|
+
top: `${r.top}px`,
|
|
233
|
+
width: `${r.width}px`,
|
|
234
|
+
margin: '0',
|
|
235
|
+
pointerEvents: 'none',
|
|
236
|
+
// Belt-and-suspenders: explicitly suppress outline + animation
|
|
237
|
+
// so the cloned element never paints a focus ring or competes
|
|
238
|
+
// with the consumer's [data-…-ghost-active] CSS for animation.
|
|
239
|
+
outline: 'none',
|
|
240
|
+
// Top-layer-ish; avoid 2147483647 to leave room for genuine top-layer
|
|
241
|
+
// surfaces (toasts, dialogs that may fire during drag).
|
|
242
|
+
zIndex: '10000',
|
|
243
|
+
transition: 'none',
|
|
244
|
+
});
|
|
245
|
+
ghost.setAttribute(ATTR_GHOST_ACTIVE, '');
|
|
246
|
+
document.body.appendChild(ghost);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function moveGhost(x, y) {
|
|
250
|
+
if (!ghost || !down) return;
|
|
251
|
+
// Translate the ghost by the cursor delta from the original pointerdown
|
|
252
|
+
// so the cursor stays at the same relative position inside the ghost.
|
|
253
|
+
ghost.style.translate = `${x - down.x}px ${y - down.y}px`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function removeGhost() {
|
|
257
|
+
if (ghost?.parentNode) ghost.parentNode.removeChild(ghost);
|
|
258
|
+
ghost = null;
|
|
259
|
+
}
|
|
134
260
|
|
|
135
261
|
function isDisabled() {
|
|
136
262
|
return host.hasAttribute('disabled') || host.getAttribute('aria-disabled') === 'true';
|
|
@@ -145,7 +271,54 @@ export const draggableListItem = defineTrait({
|
|
|
145
271
|
lastTargetId = null;
|
|
146
272
|
lastTargetIndex = -1;
|
|
147
273
|
host.removeAttribute(ATTR_LIFTING);
|
|
274
|
+
removeGhost();
|
|
275
|
+
// Always remove the keydown listener — even on disconnect — so a
|
|
276
|
+
// disposed trait can never keep firing Esc-cancel handlers against
|
|
277
|
+
// stale closures.
|
|
148
278
|
document.removeEventListener('keydown', onKeyDown, true);
|
|
279
|
+
// Always remove the document-level fallback listeners too. They
|
|
280
|
+
// catch the case where pointerup fires outside the host's bounds
|
|
281
|
+
// and pointer capture didn't deliver it (browser quirks under
|
|
282
|
+
// tab-blur, popup-open, focus-loss). Re-armed on the next lift.
|
|
283
|
+
document.removeEventListener('pointerup', onDocPointerUp, true);
|
|
284
|
+
document.removeEventListener('pointercancel', onDocPointerCancel, true);
|
|
285
|
+
window.removeEventListener('blur', onWindowBlur);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Pointer-event safety net — if the host's own pointerup doesn't
|
|
290
|
+
* fire (browser bug, focus loss mid-drag, popup steals events),
|
|
291
|
+
* the document-level handler catches the release and runs the
|
|
292
|
+
* same cancel-or-drop logic. Idempotent with the host handler:
|
|
293
|
+
* whichever fires first runs reset(); the other is gated by the
|
|
294
|
+
* `down=null` short-circuit.
|
|
295
|
+
*/
|
|
296
|
+
function onDocPointerUp(e) {
|
|
297
|
+
if (!down || down.pointerId !== e.pointerId) return;
|
|
298
|
+
onPointerUp(e);
|
|
299
|
+
}
|
|
300
|
+
function onDocPointerCancel(e) {
|
|
301
|
+
if (!down || down.pointerId !== e.pointerId) return;
|
|
302
|
+
if (lifted) {
|
|
303
|
+
document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
|
|
304
|
+
bubbles: true,
|
|
305
|
+
composed: false,
|
|
306
|
+
detail: { source_id: id, reason: 'pointer-cancel' },
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
reset();
|
|
310
|
+
}
|
|
311
|
+
/** Window blur during a drag → cancel. Covers Cmd-Tab / popup-blocker / fullscreen escape. */
|
|
312
|
+
function onWindowBlur() {
|
|
313
|
+
if (!down) return;
|
|
314
|
+
if (lifted) {
|
|
315
|
+
document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
|
|
316
|
+
bubbles: true,
|
|
317
|
+
composed: false,
|
|
318
|
+
detail: { source_id: id, reason: 'blur' },
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
321
|
+
reset();
|
|
149
322
|
}
|
|
150
323
|
|
|
151
324
|
function onPointerDown(e) {
|
|
@@ -165,6 +338,13 @@ export const draggableListItem = defineTrait({
|
|
|
165
338
|
// Hold the pointer so we keep getting move/up even outside the host.
|
|
166
339
|
try { host.setPointerCapture(e.pointerId); } catch { /* ignore */ }
|
|
167
340
|
document.addEventListener('keydown', onKeyDown, true);
|
|
341
|
+
// Document-level safety net for pointerup / pointercancel. Capture
|
|
342
|
+
// phase (true) so we beat any consumer that calls stopPropagation.
|
|
343
|
+
// host's own pointerup/cancel still fire normally; whichever lands
|
|
344
|
+
// first runs the cleanup, the other no-ops via the down-null guard.
|
|
345
|
+
document.addEventListener('pointerup', onDocPointerUp, true);
|
|
346
|
+
document.addEventListener('pointercancel', onDocPointerCancel, true);
|
|
347
|
+
window.addEventListener('blur', onWindowBlur);
|
|
168
348
|
}
|
|
169
349
|
|
|
170
350
|
function onPointerMove(e) {
|
|
@@ -176,6 +356,12 @@ export const draggableListItem = defineTrait({
|
|
|
176
356
|
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
|
|
177
357
|
lifted = true;
|
|
178
358
|
host.setAttribute(ATTR_LIFTING, '');
|
|
359
|
+
// Stamp the ghost preview AFTER setting the lifting attribute so
|
|
360
|
+
// the host's lifted state (opacity, etc.) is captured in the
|
|
361
|
+
// clone snapshot — except: we explicitly removed ATTR_LIFTING from
|
|
362
|
+
// the clone in makeGhost() so the ghost itself renders as a
|
|
363
|
+
// non-lifted card. Net effect: source dims, ghost looks "alive".
|
|
364
|
+
makeGhost();
|
|
179
365
|
host.dispatchEvent(new CustomEvent('dnd-lift', {
|
|
180
366
|
bubbles: true,
|
|
181
367
|
composed: false,
|
|
@@ -188,9 +374,12 @@ export const draggableListItem = defineTrait({
|
|
|
188
374
|
announceImmediate(`Picked up item. Press arrow keys to navigate. Press Space to drop. Press Esc to cancel.`);
|
|
189
375
|
}
|
|
190
376
|
|
|
377
|
+
// Update the ghost on every move (cheap — single style write).
|
|
378
|
+
moveGhost(e.clientX, e.clientY);
|
|
379
|
+
|
|
191
380
|
const targetEl = dropTargetFromPoint(e.clientX, e.clientY);
|
|
192
381
|
const targetId = targetEl?.getAttribute('data-droppable-id') ?? null;
|
|
193
|
-
const targetIdx = targetEl ? indexWithinTarget(targetEl, e.clientX, e.clientY) : -1;
|
|
382
|
+
const targetIdx = targetEl ? indexWithinTarget(targetEl, e.clientX, e.clientY, id) : -1;
|
|
194
383
|
|
|
195
384
|
if (targetId !== lastTargetId || targetIdx !== lastTargetIndex) {
|
|
196
385
|
lastTargetId = targetId;
|
|
@@ -224,7 +413,7 @@ export const draggableListItem = defineTrait({
|
|
|
224
413
|
}));
|
|
225
414
|
announceImmediate(`Drag cancelled. Item returned to its original position.`);
|
|
226
415
|
} else {
|
|
227
|
-
const targetIdx = indexWithinTarget(targetEl, e.clientX, e.clientY);
|
|
416
|
+
const targetIdx = indexWithinTarget(targetEl, e.clientX, e.clientY, id);
|
|
228
417
|
document.dispatchEvent(new CustomEvent('dnd-drop', {
|
|
229
418
|
bubbles: true,
|
|
230
419
|
composed: false,
|
|
@@ -253,10 +442,7 @@ export const draggableListItem = defineTrait({
|
|
|
253
442
|
}
|
|
254
443
|
}
|
|
255
444
|
|
|
256
|
-
|
|
257
|
-
host.addEventListener('pointermove', onPointerMove);
|
|
258
|
-
host.addEventListener('pointerup', onPointerUp);
|
|
259
|
-
host.addEventListener('pointercancel', () => {
|
|
445
|
+
function onHostPointerCancel() {
|
|
260
446
|
if (lifted) {
|
|
261
447
|
document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
|
|
262
448
|
bubbles: true,
|
|
@@ -265,13 +451,20 @@ export const draggableListItem = defineTrait({
|
|
|
265
451
|
}));
|
|
266
452
|
}
|
|
267
453
|
reset();
|
|
268
|
-
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
host.addEventListener('pointerdown', onPointerDown);
|
|
457
|
+
host.addEventListener('pointermove', onPointerMove);
|
|
458
|
+
host.addEventListener('pointerup', onPointerUp);
|
|
459
|
+
host.addEventListener('pointercancel', onHostPointerCancel);
|
|
269
460
|
|
|
270
461
|
return () => {
|
|
271
462
|
host.removeEventListener('pointerdown', onPointerDown);
|
|
272
463
|
host.removeEventListener('pointermove', onPointerMove);
|
|
273
464
|
host.removeEventListener('pointerup', onPointerUp);
|
|
274
|
-
|
|
465
|
+
host.removeEventListener('pointercancel', onHostPointerCancel);
|
|
466
|
+
// reset() removes the document-level + keydown + window-blur
|
|
467
|
+
// listeners and the host-level lifting/ghost state.
|
|
275
468
|
reset();
|
|
276
469
|
host.removeAttribute(ATTR_SOURCE_ID);
|
|
277
470
|
};
|
|
@@ -3,7 +3,7 @@ import { draggableListItem } from './draggable-list-item.js';
|
|
|
3
3
|
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
// Minimal smoke coverage. The draggable-list-item trait is the pointer
|
|
6
|
-
// driver for the
|
|
6
|
+
// driver for the apps/tasks/ DnD model. The cases
|
|
7
7
|
// below exercise only its own surface (schema, attribute lifecycle,
|
|
8
8
|
// id assignment, idempotent disconnect). Full pointer-flow tests need a
|
|
9
9
|
// laid-out DOM with sibling droppables and live elementFromPoint —
|
|
@@ -3,7 +3,7 @@ import { defineTrait } from './define.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* `droppable-collection` — coordinator over a tree of `droppable` children.
|
|
5
5
|
*
|
|
6
|
-
* Authored for the Tasks UI playground (
|
|
6
|
+
* Authored for the Tasks UI playground (apps/tasks/spec/).
|
|
7
7
|
* Per SPEC §6.2 + ARCHITECTURE-REVIEW H2 (category: 'input-interaction').
|
|
8
8
|
*
|
|
9
9
|
* Pairs with `droppable` (per-target) and `draggable-list-item` (motion-
|
|
@@ -3,7 +3,7 @@ import { droppableCollection } from './droppable-collection.js';
|
|
|
3
3
|
import { mountHost, connectTrait, spyEvent, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
// Minimal smoke coverage. The droppable-collection trait coordinates
|
|
6
|
-
// child droppables of the
|
|
6
|
+
// child droppables of the apps/tasks/ DnD model;
|
|
7
7
|
// the cases below verify only the trait's own surface (schema, attribute
|
|
8
8
|
// lifecycle, child-drop re-emission) so the verify:traits gate stays
|
|
9
9
|
// green. End-to-end multi-column behavior belongs in the playground tests.
|
package/traits/droppable.js
CHANGED
|
@@ -3,7 +3,7 @@ import { defineTrait } from './define.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* `droppable` — marks an element as a drop target for `draggable-list-item`.
|
|
5
5
|
*
|
|
6
|
-
* Authored for the Tasks UI playground (
|
|
6
|
+
* Authored for the Tasks UI playground (apps/tasks/spec/).
|
|
7
7
|
* Per SPEC §6.2 + ARCHITECTURE-REVIEW H2 (category: 'input-interaction').
|
|
8
8
|
*
|
|
9
9
|
* Pairs with `draggable-list-item` (motion-positioning) which lifts a list
|
|
@@ -57,24 +57,35 @@ export const droppable = defineTrait({
|
|
|
57
57
|
|
|
58
58
|
// Listen for drag-in-flight signals on the document so this droppable
|
|
59
59
|
// knows when it should self-mark as the target.
|
|
60
|
+
//
|
|
61
|
+
// Bug guard (2026-05-04): per-host attribute is the source of truth.
|
|
62
|
+
// The previous implementation gated the leave branch on a shared
|
|
63
|
+
// module variable CURRENT_TARGET_ID, which made the result dependent
|
|
64
|
+
// on listener-execution order — when the target swap fired and the
|
|
65
|
+
// NEW target's listener ran before the OLD target's, the old one
|
|
66
|
+
// saw CURRENT_TARGET_ID already moved and skipped clearing its
|
|
67
|
+
// ATTR_OVER, leaving multiple columns visually marked as the drop
|
|
68
|
+
// target. Per-host hasAttribute() is independent of order.
|
|
60
69
|
function onTargetChange(/** @type {CustomEvent} */ e) {
|
|
61
70
|
const detail = e.detail;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
71
|
+
if (!detail) return;
|
|
72
|
+
const isMe = detail.target_container_id === id;
|
|
73
|
+
const wasOver = host.hasAttribute(ATTR_OVER);
|
|
74
|
+
if (isMe && !wasOver) {
|
|
65
75
|
host.setAttribute(ATTR_OVER, '');
|
|
76
|
+
CURRENT_TARGET_ID = id;
|
|
66
77
|
host.dispatchEvent(new CustomEvent('dnd-drop-enter', {
|
|
67
78
|
bubbles: true,
|
|
68
79
|
composed: false,
|
|
69
80
|
detail: { source_id: detail.source_id, pointer_position: detail.pointer ?? { x: 0, y: 0 } },
|
|
70
81
|
}));
|
|
71
|
-
} else if (!isMe &&
|
|
72
|
-
CURRENT_TARGET_ID = null;
|
|
82
|
+
} else if (!isMe && wasOver) {
|
|
73
83
|
host.removeAttribute(ATTR_OVER);
|
|
84
|
+
if (CURRENT_TARGET_ID === id) CURRENT_TARGET_ID = null;
|
|
74
85
|
host.dispatchEvent(new CustomEvent('dnd-drop-leave', {
|
|
75
86
|
bubbles: true,
|
|
76
87
|
composed: false,
|
|
77
|
-
detail: { source_id: detail
|
|
88
|
+
detail: { source_id: detail.source_id ?? null },
|
|
78
89
|
}));
|
|
79
90
|
}
|
|
80
91
|
}
|
package/traits/droppable.test.js
CHANGED
|
@@ -3,7 +3,7 @@ import { droppable } from './droppable.js';
|
|
|
3
3
|
import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
4
4
|
|
|
5
5
|
// Minimal smoke coverage. The droppable trait is authored against the
|
|
6
|
-
//
|
|
6
|
+
// apps/tasks/ DnD coordinator and assumes a sibling
|
|
7
7
|
// `draggable-list-item` trait dispatches `dnd:drop-target-change` /
|
|
8
8
|
// `dnd:drop` on the document. The cases below only exercise the trait's
|
|
9
9
|
// own surface (schema, attribute lifecycle, registry entry, cleanup) so
|
|
@@ -3,7 +3,7 @@ import { defineTrait } from './define.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* `keyboard-reorderable` — keyboard alternative to `draggable-list-item`.
|
|
5
5
|
*
|
|
6
|
-
* Authored for the Tasks UI playground (
|
|
6
|
+
* Authored for the Tasks UI playground (apps/tasks/spec/).
|
|
7
7
|
* Per SPEC §6.2 + ARCHITECTURE-REVIEW H2 (category: 'keyboard-navigation').
|
|
8
8
|
*
|
|
9
9
|
* Implements the WCAG 2.5.7 single-pointer alternative + ARIA 1.1
|
|
@@ -4,7 +4,7 @@ import { mountHost, connectTrait, resetDOM } from './test-helpers.js';
|
|
|
4
4
|
|
|
5
5
|
// Minimal smoke coverage. Keyboard-reorderable is the WCAG 2.5.7 +
|
|
6
6
|
// ARIA 1.1 alternative to draggable-list-item for the
|
|
7
|
-
//
|
|
7
|
+
// apps/tasks/ DnD model. Cases below verify
|
|
8
8
|
// only the trait's own surface (schema, attribute lifecycle, id
|
|
9
9
|
// assignment, tabindex injection). Multi-container keyboard flow
|
|
10
10
|
// belongs in the playground integration suite.
|