@adia-ai/web-components 0.2.4 → 0.2.5
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/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/package.json +10 -10
- package/styles/components.css +1 -0
- package/traits/_catalog.json +3 -1
- package/traits/draggable-list-item.js +196 -17
- package/traits/droppable.js +17 -6
|
@@ -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/package.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/web-components",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "AdiaUI web components
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"description": "AdiaUI web components \u2014 vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-utils.",
|
|
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": [
|
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/traits/_catalog.json
CHANGED
|
@@ -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,66 @@ 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
|
+
// Direct children only. ChildList is small (usually < 50); filter is O(n).
|
|
130
|
+
// Pre-filter to source-skipped, layout-having items so we know which is
|
|
131
|
+
// truly last in post-removal coordinates.
|
|
132
|
+
const visible = [];
|
|
133
|
+
for (const child of target.children) {
|
|
134
|
+
if (!(child instanceof Element)) continue;
|
|
135
|
+
if (!child.hasAttribute('data-draggable-list-item-id')) continue;
|
|
136
|
+
if (sourceId && child.getAttribute('data-draggable-list-item-id') === sourceId) continue;
|
|
137
|
+
const r = child.getBoundingClientRect();
|
|
138
|
+
if (r.height === 0 && r.width === 0) continue;
|
|
139
|
+
visible.push(r);
|
|
140
|
+
}
|
|
141
|
+
if (visible.length === 0) return 0;
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i < visible.length; i++) {
|
|
144
|
+
const r = visible[i];
|
|
145
|
+
const isLast = i === visible.length - 1;
|
|
146
|
+
if (isLast && y >= r.top) {
|
|
147
|
+
// Last item: cursor anywhere ON (or below) the last item's box →
|
|
148
|
+
// drop AFTER the last. Without this rule, dropping in the upper
|
|
149
|
+
// half of the last item resolves to "before last" = no-op for
|
|
150
|
+
// 3-item-list "move item 2 to item 3 slot" (the lifted source's
|
|
151
|
+
// DOM still occupies its old space, so cursor above last's mid
|
|
152
|
+
// is the only reachable target without leaving the section). The
|
|
153
|
+
// generous last-item zone matches user intuition: dragging onto
|
|
154
|
+
// the last item sends it to the bottom.
|
|
155
|
+
return visible.length;
|
|
156
|
+
}
|
|
157
|
+
// Standard midline test for non-last items (and for cursor above the
|
|
158
|
+
// last item's top edge): above midline → insert before this item.
|
|
109
159
|
if (y < r.top + r.height / 2) return i;
|
|
110
160
|
}
|
|
111
|
-
|
|
161
|
+
// Cursor is below every item's midline (and below last's top — only
|
|
162
|
+
// possible if the section has padding-bottom and cursor is in that gap).
|
|
163
|
+
return visible.length;
|
|
112
164
|
}
|
|
113
165
|
|
|
114
166
|
export const draggableListItem = defineTrait({
|
|
@@ -117,7 +169,12 @@ export const draggableListItem = defineTrait({
|
|
|
117
169
|
description: 'Pointer drag for list reordering; emits dnd-lift/drop-target-change/drop/drop-cancel',
|
|
118
170
|
attributes: [ATTR_LIFTING, ATTR_SOURCE_ID],
|
|
119
171
|
events: ['dnd-lift', 'dnd-drop-target-change', 'dnd-drop', 'dnd-drop-cancel'],
|
|
120
|
-
|
|
172
|
+
// Opt-in: when present, the trait renders a positioned clone of the
|
|
173
|
+
// host that follows the cursor during the drag (Pragmatic-DnD style
|
|
174
|
+
// ghost preview). Backward-compatible — absent = no ghost, current
|
|
175
|
+
// behavior. Style the ghost via consumer CSS using the
|
|
176
|
+
// [data-draggable-list-item-ghost-active] attribute.
|
|
177
|
+
config: [ATTR_GHOST],
|
|
121
178
|
setup({ host }) {
|
|
122
179
|
let id = host.getAttribute(ATTR_SOURCE_ID);
|
|
123
180
|
if (!id) {
|
|
@@ -131,6 +188,61 @@ export const draggableListItem = defineTrait({
|
|
|
131
188
|
/** @type {string | null} */
|
|
132
189
|
let lastTargetId = null;
|
|
133
190
|
let lastTargetIndex = -1;
|
|
191
|
+
/** @type {HTMLElement | null} */
|
|
192
|
+
let ghost = null;
|
|
193
|
+
|
|
194
|
+
function makeGhost() {
|
|
195
|
+
if (!host.hasAttribute(ATTR_GHOST)) return;
|
|
196
|
+
const r = host.getBoundingClientRect();
|
|
197
|
+
ghost = /** @type {HTMLElement} */ (host.cloneNode(true));
|
|
198
|
+
// Strip everything that could trigger interactive state on the clone:
|
|
199
|
+
// - trait IDs (registry hygiene)
|
|
200
|
+
// - tabindex (would attract focus when appended to body, painting
|
|
201
|
+
// a focus-visible outline that double-traces the ghost)
|
|
202
|
+
// - aria-* state (clone is presentational; the source remains the
|
|
203
|
+
// accessible focus target during drag)
|
|
204
|
+
// - data-selected (visual selection state belongs to the source)
|
|
205
|
+
// - id (no DOM-id duplicates)
|
|
206
|
+
ghost.removeAttribute(ATTR_SOURCE_ID);
|
|
207
|
+
ghost.removeAttribute(ATTR_LIFTING);
|
|
208
|
+
ghost.removeAttribute('data-keyboard-reorderable-id');
|
|
209
|
+
ghost.removeAttribute('data-keyboard-reorderable-lifting');
|
|
210
|
+
ghost.removeAttribute('data-selected');
|
|
211
|
+
ghost.removeAttribute('id');
|
|
212
|
+
ghost.removeAttribute('tabindex');
|
|
213
|
+
ghost.removeAttribute('aria-selected');
|
|
214
|
+
ghost.setAttribute('aria-hidden', 'true');
|
|
215
|
+
Object.assign(ghost.style, {
|
|
216
|
+
position: 'fixed',
|
|
217
|
+
left: `${r.left}px`,
|
|
218
|
+
top: `${r.top}px`,
|
|
219
|
+
width: `${r.width}px`,
|
|
220
|
+
margin: '0',
|
|
221
|
+
pointerEvents: 'none',
|
|
222
|
+
// Belt-and-suspenders: explicitly suppress outline + animation
|
|
223
|
+
// so the cloned element never paints a focus ring or competes
|
|
224
|
+
// with the consumer's [data-…-ghost-active] CSS for animation.
|
|
225
|
+
outline: 'none',
|
|
226
|
+
// Top-layer-ish; avoid 2147483647 to leave room for genuine top-layer
|
|
227
|
+
// surfaces (toasts, dialogs that may fire during drag).
|
|
228
|
+
zIndex: '10000',
|
|
229
|
+
transition: 'none',
|
|
230
|
+
});
|
|
231
|
+
ghost.setAttribute(ATTR_GHOST_ACTIVE, '');
|
|
232
|
+
document.body.appendChild(ghost);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function moveGhost(x, y) {
|
|
236
|
+
if (!ghost || !down) return;
|
|
237
|
+
// Translate the ghost by the cursor delta from the original pointerdown
|
|
238
|
+
// so the cursor stays at the same relative position inside the ghost.
|
|
239
|
+
ghost.style.translate = `${x - down.x}px ${y - down.y}px`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function removeGhost() {
|
|
243
|
+
if (ghost?.parentNode) ghost.parentNode.removeChild(ghost);
|
|
244
|
+
ghost = null;
|
|
245
|
+
}
|
|
134
246
|
|
|
135
247
|
function isDisabled() {
|
|
136
248
|
return host.hasAttribute('disabled') || host.getAttribute('aria-disabled') === 'true';
|
|
@@ -145,7 +257,54 @@ export const draggableListItem = defineTrait({
|
|
|
145
257
|
lastTargetId = null;
|
|
146
258
|
lastTargetIndex = -1;
|
|
147
259
|
host.removeAttribute(ATTR_LIFTING);
|
|
260
|
+
removeGhost();
|
|
261
|
+
// Always remove the keydown listener — even on disconnect — so a
|
|
262
|
+
// disposed trait can never keep firing Esc-cancel handlers against
|
|
263
|
+
// stale closures.
|
|
148
264
|
document.removeEventListener('keydown', onKeyDown, true);
|
|
265
|
+
// Always remove the document-level fallback listeners too. They
|
|
266
|
+
// catch the case where pointerup fires outside the host's bounds
|
|
267
|
+
// and pointer capture didn't deliver it (browser quirks under
|
|
268
|
+
// tab-blur, popup-open, focus-loss). Re-armed on the next lift.
|
|
269
|
+
document.removeEventListener('pointerup', onDocPointerUp, true);
|
|
270
|
+
document.removeEventListener('pointercancel', onDocPointerCancel, true);
|
|
271
|
+
window.removeEventListener('blur', onWindowBlur);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Pointer-event safety net — if the host's own pointerup doesn't
|
|
276
|
+
* fire (browser bug, focus loss mid-drag, popup steals events),
|
|
277
|
+
* the document-level handler catches the release and runs the
|
|
278
|
+
* same cancel-or-drop logic. Idempotent with the host handler:
|
|
279
|
+
* whichever fires first runs reset(); the other is gated by the
|
|
280
|
+
* `down=null` short-circuit.
|
|
281
|
+
*/
|
|
282
|
+
function onDocPointerUp(e) {
|
|
283
|
+
if (!down || down.pointerId !== e.pointerId) return;
|
|
284
|
+
onPointerUp(e);
|
|
285
|
+
}
|
|
286
|
+
function onDocPointerCancel(e) {
|
|
287
|
+
if (!down || down.pointerId !== e.pointerId) return;
|
|
288
|
+
if (lifted) {
|
|
289
|
+
document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
|
|
290
|
+
bubbles: true,
|
|
291
|
+
composed: false,
|
|
292
|
+
detail: { source_id: id, reason: 'pointer-cancel' },
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
reset();
|
|
296
|
+
}
|
|
297
|
+
/** Window blur during a drag → cancel. Covers Cmd-Tab / popup-blocker / fullscreen escape. */
|
|
298
|
+
function onWindowBlur() {
|
|
299
|
+
if (!down) return;
|
|
300
|
+
if (lifted) {
|
|
301
|
+
document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
|
|
302
|
+
bubbles: true,
|
|
303
|
+
composed: false,
|
|
304
|
+
detail: { source_id: id, reason: 'blur' },
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
reset();
|
|
149
308
|
}
|
|
150
309
|
|
|
151
310
|
function onPointerDown(e) {
|
|
@@ -165,6 +324,13 @@ export const draggableListItem = defineTrait({
|
|
|
165
324
|
// Hold the pointer so we keep getting move/up even outside the host.
|
|
166
325
|
try { host.setPointerCapture(e.pointerId); } catch { /* ignore */ }
|
|
167
326
|
document.addEventListener('keydown', onKeyDown, true);
|
|
327
|
+
// Document-level safety net for pointerup / pointercancel. Capture
|
|
328
|
+
// phase (true) so we beat any consumer that calls stopPropagation.
|
|
329
|
+
// host's own pointerup/cancel still fire normally; whichever lands
|
|
330
|
+
// first runs the cleanup, the other no-ops via the down-null guard.
|
|
331
|
+
document.addEventListener('pointerup', onDocPointerUp, true);
|
|
332
|
+
document.addEventListener('pointercancel', onDocPointerCancel, true);
|
|
333
|
+
window.addEventListener('blur', onWindowBlur);
|
|
168
334
|
}
|
|
169
335
|
|
|
170
336
|
function onPointerMove(e) {
|
|
@@ -176,6 +342,12 @@ export const draggableListItem = defineTrait({
|
|
|
176
342
|
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
|
|
177
343
|
lifted = true;
|
|
178
344
|
host.setAttribute(ATTR_LIFTING, '');
|
|
345
|
+
// Stamp the ghost preview AFTER setting the lifting attribute so
|
|
346
|
+
// the host's lifted state (opacity, etc.) is captured in the
|
|
347
|
+
// clone snapshot — except: we explicitly removed ATTR_LIFTING from
|
|
348
|
+
// the clone in makeGhost() so the ghost itself renders as a
|
|
349
|
+
// non-lifted card. Net effect: source dims, ghost looks "alive".
|
|
350
|
+
makeGhost();
|
|
179
351
|
host.dispatchEvent(new CustomEvent('dnd-lift', {
|
|
180
352
|
bubbles: true,
|
|
181
353
|
composed: false,
|
|
@@ -188,9 +360,12 @@ export const draggableListItem = defineTrait({
|
|
|
188
360
|
announceImmediate(`Picked up item. Press arrow keys to navigate. Press Space to drop. Press Esc to cancel.`);
|
|
189
361
|
}
|
|
190
362
|
|
|
363
|
+
// Update the ghost on every move (cheap — single style write).
|
|
364
|
+
moveGhost(e.clientX, e.clientY);
|
|
365
|
+
|
|
191
366
|
const targetEl = dropTargetFromPoint(e.clientX, e.clientY);
|
|
192
367
|
const targetId = targetEl?.getAttribute('data-droppable-id') ?? null;
|
|
193
|
-
const targetIdx = targetEl ? indexWithinTarget(targetEl, e.clientX, e.clientY) : -1;
|
|
368
|
+
const targetIdx = targetEl ? indexWithinTarget(targetEl, e.clientX, e.clientY, id) : -1;
|
|
194
369
|
|
|
195
370
|
if (targetId !== lastTargetId || targetIdx !== lastTargetIndex) {
|
|
196
371
|
lastTargetId = targetId;
|
|
@@ -224,7 +399,7 @@ export const draggableListItem = defineTrait({
|
|
|
224
399
|
}));
|
|
225
400
|
announceImmediate(`Drag cancelled. Item returned to its original position.`);
|
|
226
401
|
} else {
|
|
227
|
-
const targetIdx = indexWithinTarget(targetEl, e.clientX, e.clientY);
|
|
402
|
+
const targetIdx = indexWithinTarget(targetEl, e.clientX, e.clientY, id);
|
|
228
403
|
document.dispatchEvent(new CustomEvent('dnd-drop', {
|
|
229
404
|
bubbles: true,
|
|
230
405
|
composed: false,
|
|
@@ -253,10 +428,7 @@ export const draggableListItem = defineTrait({
|
|
|
253
428
|
}
|
|
254
429
|
}
|
|
255
430
|
|
|
256
|
-
|
|
257
|
-
host.addEventListener('pointermove', onPointerMove);
|
|
258
|
-
host.addEventListener('pointerup', onPointerUp);
|
|
259
|
-
host.addEventListener('pointercancel', () => {
|
|
431
|
+
function onHostPointerCancel() {
|
|
260
432
|
if (lifted) {
|
|
261
433
|
document.dispatchEvent(new CustomEvent('dnd-drop-cancel', {
|
|
262
434
|
bubbles: true,
|
|
@@ -265,13 +437,20 @@ export const draggableListItem = defineTrait({
|
|
|
265
437
|
}));
|
|
266
438
|
}
|
|
267
439
|
reset();
|
|
268
|
-
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
host.addEventListener('pointerdown', onPointerDown);
|
|
443
|
+
host.addEventListener('pointermove', onPointerMove);
|
|
444
|
+
host.addEventListener('pointerup', onPointerUp);
|
|
445
|
+
host.addEventListener('pointercancel', onHostPointerCancel);
|
|
269
446
|
|
|
270
447
|
return () => {
|
|
271
448
|
host.removeEventListener('pointerdown', onPointerDown);
|
|
272
449
|
host.removeEventListener('pointermove', onPointerMove);
|
|
273
450
|
host.removeEventListener('pointerup', onPointerUp);
|
|
274
|
-
|
|
451
|
+
host.removeEventListener('pointercancel', onHostPointerCancel);
|
|
452
|
+
// reset() removes the document-level + keydown + window-blur
|
|
453
|
+
// listeners and the host-level lifting/ghost state.
|
|
275
454
|
reset();
|
|
276
455
|
host.removeAttribute(ATTR_SOURCE_ID);
|
|
277
456
|
};
|
package/traits/droppable.js
CHANGED
|
@@ -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
|
}
|