@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.
@@ -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
@@ -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",
4
- "description": "AdiaUI web components vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-utils.",
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
- ".": "./index.js",
8
- "./css": "./index.css",
9
- "./core": "./core/index.js",
10
- "./core/*": "./core/*.js",
11
- "./components": "./components/index.js",
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/*": "./styles/*",
14
- "./traits": "./traits/index.js",
15
- "./traits/*": "./traits/*.js",
13
+ "./styles/*": "./styles/*",
14
+ "./traits": "./traits/index.js",
15
+ "./traits/*": "./traits/*.js",
16
16
  "./package.json": "./package.json"
17
17
  },
18
18
  "files": [
@@ -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";
@@ -599,7 +599,9 @@
599
599
  "dnd-drop",
600
600
  "dnd-drop-cancel"
601
601
  ],
602
- "config": []
602
+ "config": [
603
+ "data-draggable-list-item-ghost"
604
+ ]
603
605
  },
604
606
  {
605
607
  "name": "drop-target",
@@ -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
- function indexWithinTarget(target, x, y) {
103
- // Compute the insertion index by counting how many sibling list-items
104
- // start above the pointer's y. Cheap and good-enough for v1; a future
105
- // pass can compute by midpoint of each child rect.
106
- const items = Array.from(target.querySelectorAll('[data-draggable-list-item-id]'));
107
- for (let i = 0; i < items.length; i++) {
108
- const r = items[i].getBoundingClientRect();
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
- return items.length;
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
- config: [],
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
- host.addEventListener('pointerdown', onPointerDown);
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
- document.removeEventListener('keydown', onKeyDown, true);
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
  };
@@ -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
- const isMe = detail && detail.target_container_id === id;
63
- if (isMe && CURRENT_TARGET_ID !== id) {
64
- CURRENT_TARGET_ID = id;
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 && CURRENT_TARGET_ID === id) {
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?.source_id ?? null },
88
+ detail: { source_id: detail.source_id ?? null },
78
89
  }));
79
90
  }
80
91
  }