@zeyos/zx 2.2.1 → 3.0.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.
Files changed (117) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/DESIGN-SYSTEM.md +136 -0
  3. package/MIGRATION.md +155 -4
  4. package/README.md +51 -14
  5. package/dist/types/compat/map/data.d.ts +3 -2
  6. package/dist/types/components/account-menu/account-menu.d.ts +144 -0
  7. package/dist/types/components/app-rail/app-rail.d.ts +250 -0
  8. package/dist/types/components/app-sidebar/app-sidebar.d.ts +169 -0
  9. package/dist/types/components/avatar/avatar.d.ts +100 -0
  10. package/dist/types/components/chart/chart.d.ts +321 -0
  11. package/dist/types/components/date-picker/month-picker.d.ts +6 -0
  12. package/dist/types/components/dialog/dialog.d.ts +9 -0
  13. package/dist/types/components/dock/dock.d.ts +495 -0
  14. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  15. package/dist/types/components/field/field.d.ts +14 -0
  16. package/dist/types/components/field-upload/field-upload.d.ts +37 -4
  17. package/dist/types/components/form/form.d.ts +25 -3
  18. package/dist/types/components/grid/grid.d.ts +172 -0
  19. package/dist/types/components/launcher/launcher.d.ts +300 -0
  20. package/dist/types/components/layout/layout.d.ts +4 -4
  21. package/dist/types/components/menu-button/menu-button.d.ts +10 -2
  22. package/dist/types/components/modal/modal.d.ts +25 -0
  23. package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +74 -9
  24. package/dist/types/components/questionnaire/items.d.ts +327 -0
  25. package/dist/types/components/questionnaire/questionnaire.d.ts +449 -0
  26. package/dist/types/components/select/select.d.ts +36 -1
  27. package/dist/types/components/sheet/sheet-stack.d.ts +167 -0
  28. package/dist/types/components/sheet/sheet.d.ts +294 -0
  29. package/dist/types/components/split-view/split-view.d.ts +2 -21
  30. package/dist/types/components/tabbox/tabbox.d.ts +12 -3
  31. package/dist/types/components/table/table.d.ts +219 -12
  32. package/dist/types/components/tooltip/tooltip.d.ts +2 -2
  33. package/dist/types/components/value-list/value-list.d.ts +24 -3
  34. package/dist/types/core/dom.d.ts +114 -0
  35. package/dist/types/core/drag-axis.d.ts +111 -0
  36. package/dist/types/core/position.d.ts +4 -2
  37. package/dist/types/index.d.ts +12 -2
  38. package/dist/zx-compat.esm.js +943 -311
  39. package/dist/zx-compat.esm.min.js +1 -1
  40. package/dist/zx-compat.global.js +7893 -2252
  41. package/dist/zx-compat.global.min.js +3 -3
  42. package/dist/zx-zeyos.esm.js +826 -114
  43. package/dist/zx-zeyos.esm.min.js +1 -1
  44. package/dist/zx.css +1720 -243
  45. package/dist/zx.esm.js +8227 -2599
  46. package/dist/zx.esm.min.js +3 -3
  47. package/dist/zx.global.js +8227 -2599
  48. package/dist/zx.global.min.js +3 -3
  49. package/dist/zx.min.css +1 -1
  50. package/docs/api.json +2597 -255
  51. package/docs/llms.md +475 -37
  52. package/docs/llms.txt +21 -9
  53. package/package.json +4 -2
  54. package/src/compat/globals.js +3 -1
  55. package/src/compat/map/data.js +31 -9
  56. package/src/components/account-menu/account-menu.css +70 -0
  57. package/src/components/account-menu/account-menu.js +208 -0
  58. package/src/components/app-rail/app-rail.css +107 -0
  59. package/src/components/app-rail/app-rail.js +668 -0
  60. package/src/components/app-sidebar/app-sidebar.css +68 -0
  61. package/src/components/app-sidebar/app-sidebar.js +388 -0
  62. package/src/components/avatar/avatar.css +61 -0
  63. package/src/components/avatar/avatar.js +222 -0
  64. package/src/components/button/button.css +0 -1
  65. package/src/components/chart/chart.css +61 -0
  66. package/src/components/chart/chart.js +518 -0
  67. package/src/components/date-picker/date-range-picker.css +7 -0
  68. package/src/components/date-picker/month-picker.js +9 -0
  69. package/src/components/dialog/dialog.js +11 -4
  70. package/src/components/dock/dock.css +228 -0
  71. package/src/components/dock/dock.js +977 -0
  72. package/src/components/dropdown/dropdown.js +1 -1
  73. package/src/components/field/field.js +2 -0
  74. package/src/components/field-upload/field-upload.js +9 -4
  75. package/src/components/fieldset/fieldset.css +7 -0
  76. package/src/components/form/form.css +7 -0
  77. package/src/components/form/form.js +6 -3
  78. package/src/components/grid/grid.css +6 -0
  79. package/src/components/grid/grid.js +213 -0
  80. package/src/components/launcher/launcher.css +100 -0
  81. package/src/components/launcher/launcher.js +557 -0
  82. package/src/components/layout/layout.js +2 -2
  83. package/src/components/menu-button/menu-button.js +27 -2
  84. package/src/components/modal/modal.css +5 -1
  85. package/src/components/modal/modal.js +37 -2
  86. package/src/components/multi-value-editor/multi-value-editor.css +99 -15
  87. package/src/components/multi-value-editor/multi-value-editor.js +237 -19
  88. package/src/components/navigation-bar/navigation-bar.css +7 -0
  89. package/src/components/questionnaire/items.js +370 -0
  90. package/src/components/questionnaire/questionnaire.css +387 -0
  91. package/src/components/questionnaire/questionnaire.js +1033 -0
  92. package/src/components/search/search.css +10 -2
  93. package/src/components/select/select.css +6 -1
  94. package/src/components/select/select.js +99 -11
  95. package/src/components/sheet/sheet-stack.js +304 -0
  96. package/src/components/sheet/sheet.css +351 -0
  97. package/src/components/sheet/sheet.js +551 -0
  98. package/src/components/split-view/split-view.js +76 -161
  99. package/src/components/stepper/stepper.css +12 -2
  100. package/src/components/tabbox/tabbox.css +13 -3
  101. package/src/components/tabbox/tabbox.js +34 -10
  102. package/src/components/table/table.css +43 -0
  103. package/src/components/table/table.js +523 -45
  104. package/src/components/tooltip/tooltip.js +5 -2
  105. package/src/components/value-list/value-list.js +6 -3
  106. package/src/core/dom.js +302 -0
  107. package/src/core/drag-axis.js +203 -0
  108. package/src/core/position.js +68 -22
  109. package/src/index.js +19 -2
  110. package/styles/tokens/dark.css +15 -13
  111. package/styles/tokens/global.css +17 -5
  112. package/styles/tokens/semantic.css +14 -8
  113. package/styles/tokens/themes.css +72 -0
  114. package/styles/zx.css +11 -1
  115. package/dist/types/components/permission/permission.d.ts +0 -93
  116. package/src/components/permission/permission.css +0 -47
  117. package/src/components/permission/permission.js +0 -198
@@ -52,6 +52,7 @@ export class Modal extends Component {
52
52
  if (!this.options.closable || cancelEvent.defaultPrevented) event.preventDefault();
53
53
  });
54
54
  this.listen(this.el, 'close', () => {
55
+ if (!this._isRealClose()) return;
55
56
  this.el.dataset.state = 'closed';
56
57
  delete this.el.dataset.zxOverlayOrder;
57
58
  const result = this.#hasPendingResult ? this.#pendingResult : (this.el.returnValue || undefined);
@@ -66,6 +67,40 @@ export class Modal extends Component {
66
67
  });
67
68
  }
68
69
 
70
+ /**
71
+ * Element the owned dialog is appended to. Overridden by subclasses that live inside a host
72
+ * rather than at the document level.
73
+ *
74
+ * Called from `render()`, which the base constructor runs before any subclass field
75
+ * initializers, so an override may read `this.options` but must not touch instance state.
76
+ * @returns {Element}
77
+ */
78
+ mountTarget() {
79
+ return document.body;
80
+ }
81
+
82
+ /**
83
+ * Puts the dialog on screen. Overridden by subclasses that open non-modally.
84
+ * @returns {void}
85
+ */
86
+ _show() {
87
+ this.el.showModal();
88
+ }
89
+
90
+ /**
91
+ * Whether an incoming `close` event is a real dismissal.
92
+ *
93
+ * A dialog cannot move between the top layer and the page flow while it is open, so a subclass
94
+ * that re-hosts itself has to close and immediately reopen. `close` is dispatched in a queued
95
+ * task, not synchronously, so that stale event arrives once the dialog is already open again —
96
+ * late enough to clobber `data-state`, fire a `close` nobody asked for, and trip
97
+ * `destroyOnClose`. Returning false from an override drops it at the source instead.
98
+ * @returns {boolean}
99
+ */
100
+ _isRealClose() {
101
+ return true;
102
+ }
103
+
69
104
  /**
70
105
  * Creates the owned native dialog.
71
106
  * @returns {HTMLDialogElement}
@@ -75,7 +110,7 @@ export class Modal extends Component {
75
110
  class: 'zx-modal'
76
111
  }, h('div', { class: 'zx-modal__content', ref: 'content' })));
77
112
  dialog.dataset.state = 'closed';
78
- document.body.append(dialog);
113
+ this.mountTarget().append(dialog);
79
114
  return dialog;
80
115
  }
81
116
 
@@ -89,7 +124,7 @@ export class Modal extends Component {
89
124
  this.el.dataset.zxOverlayOrder = String(nextOverlayOrder());
90
125
  this.el.returnValue = '';
91
126
  try {
92
- this.el.showModal();
127
+ this._show();
93
128
  } catch (error) {
94
129
  delete this.el.dataset.zxOverlayOrder;
95
130
  throw error;
@@ -1,12 +1,22 @@
1
+ /* Deliberately not a size container. Inline-size containment removes an element's own intrinsic
2
+ width, so every host that sizes to its contents — a flex row, an inline-block, a grid `auto`
3
+ track, a table cell — measured this editor as nothing and the rows spilled out of a 26px sliver.
4
+ `inline-size: 100%` does not rescue it either: the percentage resolves against a containing
5
+ block that was itself measured from the contribution containment had already zeroed. The row
6
+ below reflows on its own instead, which reads the width that actually matters — the row's.
7
+
8
+ Without containment `inline-size: 100%` does work: a percentage that has no definite containing
9
+ block to resolve against counts as `auto` while the host measures its contents, so the editor
10
+ contributes its real width and then fills whatever the host settled on. */
1
11
  .zx-multi-value-editor {
2
12
  display: grid;
3
13
  gap: var(--zx-space-3);
4
14
  min-inline-size: 0;
15
+ inline-size: 100%;
5
16
  padding: var(--zx-space-3);
6
17
  border: 1px solid var(--zx-color-border);
7
18
  border-radius: var(--zx-radius-lg);
8
19
  background: var(--zx-color-bg-surface);
9
- container-type: inline-size;
10
20
  }
11
21
 
12
22
  .zx-multi-value-editor__rows {
@@ -15,19 +25,32 @@
15
25
  }
16
26
 
17
27
  .zx-multi-value-editor__row {
18
- display: grid;
19
- grid-template-columns: minmax(0, 1fr) auto;
28
+ position: relative;
29
+ display: flex;
30
+ flex-wrap: wrap;
31
+ /* A grid item refuses to go under its min-content width unless told to, which in a pane narrower
32
+ than the control cluster pushed the whole row back out through the editor's side. */
33
+ min-inline-size: 0;
20
34
  gap: var(--zx-space-2);
21
35
  align-items: center;
22
36
  }
23
37
 
38
+ /* Below roughly this basis the controls wrap onto a line of their own, which is what the container
39
+ query used to do — except a row measures the space the row actually has. */
24
40
  .zx-multi-value-editor__value {
25
- inline-size: 100%;
41
+ flex: 1 1 11rem;
42
+ min-inline-size: 0;
26
43
  }
27
44
 
45
+ /* Wide enough and this keeps its natural size at the end of the row; narrower than the buttons it
46
+ holds, and they wrap among themselves rather than out through the side. */
28
47
  .zx-multi-value-editor__controls {
29
48
  display: inline-flex;
49
+ flex: 0 1 auto;
50
+ flex-wrap: wrap;
51
+ min-inline-size: 0;
30
52
  gap: var(--zx-space-1);
53
+ margin-inline-start: auto;
31
54
  }
32
55
 
33
56
  .zx-multi-value-editor__controls .zx-btn {
@@ -35,22 +58,83 @@
35
58
  padding-inline: var(--zx-space-2);
36
59
  }
37
60
 
38
- .zx-multi-value-editor__controls .zx-btn:last-child {
61
+ .zx-multi-value-editor__controls .zx-btn[data-action="remove"] {
39
62
  color: var(--zx-color-danger);
40
63
  }
41
64
 
65
+ /* A row at either end keeps its move button focusable and merely announces that it cannot go
66
+ further, so pressing it repeatedly never drops focus to the document. */
67
+ .zx-multi-value-editor__controls .zx-btn[aria-disabled="true"] {
68
+ opacity: 0.55;
69
+ cursor: not-allowed;
70
+ }
71
+
72
+ .zx-multi-value-editor__controls .zx-btn[aria-disabled="true"]:hover {
73
+ background: var(--zx-color-bg-control);
74
+ }
75
+
76
+ .zx-multi-value-editor__handle {
77
+ display: inline-flex;
78
+ flex: none;
79
+ align-items: center;
80
+ justify-content: center;
81
+ min-block-size: var(--zx-control-height);
82
+ min-inline-size: var(--zx-space-6);
83
+ padding: 0;
84
+ border: 0;
85
+ border-radius: var(--zx-control-radius);
86
+ background: none;
87
+ color: var(--zx-color-text-muted);
88
+ cursor: grab;
89
+ touch-action: none;
90
+ transition: color var(--zx-dur-fast) var(--zx-ease),
91
+ background-color var(--zx-dur-fast) var(--zx-ease);
92
+ }
93
+
94
+ .zx-multi-value-editor__handle:hover:not(:disabled) {
95
+ background: var(--zx-color-bg-hover);
96
+ color: var(--zx-color-text);
97
+ }
98
+
99
+ .zx-multi-value-editor__handle:focus-visible {
100
+ outline: none;
101
+ box-shadow: var(--zx-focus-ring);
102
+ }
103
+
104
+ .zx-multi-value-editor__handle:disabled {
105
+ opacity: 0.55;
106
+ cursor: default;
107
+ }
108
+
109
+ .zx-multi-value-editor[data-state="dragging"] .zx-multi-value-editor__handle {
110
+ cursor: grabbing;
111
+ }
112
+
113
+ .zx-multi-value-editor__row[data-dragging="true"] {
114
+ opacity: 0.45;
115
+ }
116
+
117
+ /* The line sits in the gap between two rows, so it reads as "it lands here", not "it replaces
118
+ this row". */
119
+ .zx-multi-value-editor__row[data-drop]::after {
120
+ content: '';
121
+ position: absolute;
122
+ inset-inline: 0;
123
+ block-size: 2px;
124
+ border-radius: var(--zx-radius-full);
125
+ background: var(--zx-color-accent);
126
+ }
127
+
128
+ .zx-multi-value-editor__row[data-drop="before"]::after {
129
+ inset-block-start: calc(var(--zx-space-2) / -2 - 1px);
130
+ }
131
+
132
+ .zx-multi-value-editor__row[data-drop="after"]::after {
133
+ inset-block-end: calc(var(--zx-space-2) / -2 - 1px);
134
+ }
135
+
42
136
  .zx-multi-value-editor__add {
43
137
  justify-self: start;
44
138
  border-color: var(--zx-color-accent);
45
139
  color: var(--zx-color-accent);
46
140
  }
47
-
48
- @container (max-width: 24rem) {
49
- .zx-multi-value-editor__row {
50
- grid-template-columns: minmax(0, 1fr);
51
- }
52
-
53
- .zx-multi-value-editor__controls {
54
- justify-self: end;
55
- }
56
- }
@@ -1,15 +1,39 @@
1
1
  import { Component } from '../../core/component.js';
2
2
  import { h } from '../../core/dom.js';
3
+ import { icon } from '../../core/icons.js';
3
4
 
4
5
  /**
5
6
  * @typedef {Object} MultiValueEditorOptions
6
7
  * @property {unknown[]} [values=[]] Initial ordered values.
7
8
  * @property {unknown[]|Record<string, string>|null} [options=null] Allowed values; null uses text inputs.
8
9
  * @property {string} [addLabel='Add value'] Add-row button text.
10
+ * @property {'both'|'buttons'|'drag'|'none'} [reorder='both'] Reordering affordances each row gets:
11
+ * a drag handle, the up/down buttons, both, or neither.
12
+ * @property {(event: CustomEvent<{values: string[]}>) => void} [onchange] Values change listener.
9
13
  */
10
14
 
15
+ /** Affordances each `reorder` setting turns on. */
16
+ const REORDER = Object.freeze({
17
+ both: { drag: true, buttons: true },
18
+ buttons: { drag: false, buttons: true },
19
+ drag: { drag: true, buttons: false },
20
+ none: { drag: false, buttons: false }
21
+ });
22
+
23
+ /** Focusable parts of a row, so a re-render can put focus back where the reader left it. */
24
+ const PARTS = Object.freeze({
25
+ value: '.zx-multi-value-editor__value',
26
+ handle: '.zx-multi-value-editor__handle',
27
+ up: '[data-action="up"]',
28
+ down: '[data-action="down"]'
29
+ });
30
+
11
31
  /**
12
32
  * Ordered value editor with explicit editable rows and controls.
33
+ *
34
+ * Rows reorder by dragging their handle or by pressing the up/down buttons — `reorder` picks which
35
+ * of the two a row offers. The handle is also a keyboard control (arrows, Home, End), so drag-only
36
+ * mode still reorders without a pointer.
13
37
  * @fires MultiValueEditor#change
14
38
  * @extends {Component<MultiValueEditorOptions>}
15
39
  */
@@ -17,13 +41,18 @@ export class MultiValueEditor extends Component {
17
41
  static cssName = 'multi-value-editor';
18
42
 
19
43
  /** @type {MultiValueEditorOptions} */
20
- static defaults = { values: [], options: null, addLabel: 'Add value' };
44
+ static defaults = { values: [], options: null, addLabel: 'Add value', reorder: 'both' };
21
45
 
22
46
  /** @returns {HTMLElement} */
23
47
  render() {
24
48
  const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
49
+ this.el = root;
25
50
  this._values = Array.isArray(this.options.values) ? this.options.values.map(String) : [];
26
51
  this._ownedNodes = [];
52
+ this._initialState = root.getAttribute('data-state');
53
+ this._dragIndex = -1;
54
+ /** @type {HTMLElement|null} Row made draggable by a pointer press on its handle. */
55
+ this._armed = null;
27
56
  this._rows = h('div', { class: 'zx-multi-value-editor__rows' });
28
57
  this._add = h('button', {
29
58
  class: 'zx-btn zx-multi-value-editor__add',
@@ -34,6 +63,19 @@ export class MultiValueEditor extends Component {
34
63
  root.append(this._rows, this._add);
35
64
  this.listen(root, 'click', (event) => this._handleClick(event));
36
65
  this.listen(this._rows, 'change', (event) => this._handleChange(event));
66
+ this.listen(this._rows, 'keydown', (event) => this._handleKeydown(event));
67
+ if (this._reorder().drag) {
68
+ this.listen(this._rows, 'pointerdown', (event) => this._handlePointerDown(event));
69
+ this.listen(this._rows, 'dragstart', (event) => this._handleDragStart(event));
70
+ this.listen(this._rows, 'dragover', (event) => this._handleDragOver(event));
71
+ this.listen(this._rows, 'dragleave', (event) => this._handleDragLeave(event));
72
+ this.listen(this._rows, 'drop', (event) => this._handleDrop(event));
73
+ this.listen(this._rows, 'dragend', () => this._finishDrag());
74
+ // A press that never became a drag still has to disarm the row, and its pointerup can land
75
+ // anywhere — including outside the component.
76
+ this.listen(document, 'pointerup', () => this._disarm());
77
+ this.listen(document, 'pointercancel', () => this._disarm());
78
+ }
37
79
  this._renderRows();
38
80
  return root;
39
81
  }
@@ -59,16 +101,32 @@ export class MultiValueEditor extends Component {
59
101
  return this;
60
102
  }
61
103
 
62
- /** @param {number} [focusIndex=-1] @returns {void} */
63
- _renderRows(focusIndex = -1) {
104
+ /** @returns {{drag: boolean, buttons: boolean}} */
105
+ _reorder() {
106
+ return REORDER[this.options.reorder] ?? REORDER.both;
107
+ }
108
+
109
+ /**
110
+ * @param {number} [focusIndex=-1] Row to focus after the render, or -1 for none.
111
+ * @param {keyof PARTS} [focusPart='value'] Which control of that row takes focus.
112
+ * @returns {void}
113
+ */
114
+ _renderRows(focusIndex = -1, focusPart = 'value') {
64
115
  const choices = normalizeOptions(this.options.options);
65
116
  const useSelect = this.options.options !== null;
117
+ const { drag, buttons } = this._reorder();
118
+ const last = this._values.length - 1;
66
119
  const rows = this._values.map((value, index) => {
120
+ // An option list that has lost a stored value still has to round-trip it, or `getValues()`
121
+ // would quietly return something the select never showed.
122
+ const entries = !useSelect || choices.some(([option]) => option === value)
123
+ ? choices
124
+ : [[value, value], ...choices];
67
125
  const control = useSelect ? h('select', {
68
126
  class: 'zx-multi-value-editor__value',
69
127
  ariaLabel: `Value ${index + 1}`,
70
128
  dataset: { index }
71
- }, choices.map(([optionValue, label]) => h('option', {
129
+ }, entries.map(([optionValue, label]) => h('option', {
72
130
  value: optionValue,
73
131
  selected: optionValue === value
74
132
  }, label))) : h('input', {
@@ -79,39 +137,52 @@ export class MultiValueEditor extends Component {
79
137
  dataset: { index }
80
138
  });
81
139
  return h('div', { class: 'zx-multi-value-editor__row', dataset: { row: index } },
140
+ drag ? h('button', {
141
+ class: 'zx-multi-value-editor__handle',
142
+ type: 'button',
143
+ ariaLabel: `Reorder value ${index + 1}. Use the arrow keys to move it.`,
144
+ dataset: { action: 'grab', index }
145
+ }, icon('drag', { size: 12 })) : null,
82
146
  control,
83
147
  h('div', { class: 'zx-multi-value-editor__controls' },
84
- h('button', {
148
+ buttons ? h('button', {
85
149
  class: 'zx-btn',
86
150
  type: 'button',
87
- disabled: index === 0,
151
+ // `aria-disabled` rather than `disabled`: the buttons re-render on every move, and a
152
+ // control that disables itself under the reader's finger drops focus to the document.
153
+ ariaDisabled: String(index === 0),
88
154
  ariaLabel: `Move value ${index + 1} up`,
89
155
  dataset: { action: 'up', index }
90
- }, '↑'),
91
- h('button', {
156
+ }, icon('chevron-up', { size: 12 })) : null,
157
+ buttons ? h('button', {
92
158
  class: 'zx-btn',
93
159
  type: 'button',
94
- disabled: index === this._values.length - 1,
160
+ ariaDisabled: String(index === last),
95
161
  ariaLabel: `Move value ${index + 1} down`,
96
162
  dataset: { action: 'down', index }
97
- }, '↓'),
163
+ }, icon('chevron-down', { size: 12 })) : null,
98
164
  h('button', {
99
165
  class: 'zx-btn',
100
166
  type: 'button',
101
167
  ariaLabel: `Remove value ${index + 1}`,
102
168
  dataset: { action: 'remove', index }
103
- }, '×')
169
+ }, icon('x', { size: 12 }))
104
170
  )
105
171
  );
106
172
  });
107
173
  this._rows.replaceChildren(...rows);
108
- if (focusIndex >= 0) rows[focusIndex]?.querySelector('.zx-multi-value-editor__value')?.focus();
174
+ if (focusIndex >= 0) {
175
+ const row = rows[Math.min(focusIndex, rows.length - 1)];
176
+ /** @type {HTMLElement|null|undefined} */
177
+ (row?.querySelector(PARTS[focusPart] ?? PARTS.value))?.focus();
178
+ }
109
179
  }
110
180
 
111
181
  /** @param {MouseEvent} event @returns {void} */
112
182
  _handleClick(event) {
113
183
  const button = event.target instanceof Element ? event.target.closest('[data-action]') : null;
114
184
  if (!button || !this.el.contains(button)) return;
185
+ if (button.getAttribute('aria-disabled') === 'true') return;
115
186
  const action = button.dataset.action;
116
187
  const index = Number(button.dataset.index);
117
188
  if (action === 'add') {
@@ -124,24 +195,169 @@ export class MultiValueEditor extends Component {
124
195
  this._renderRows(Math.min(index, this._values.length - 1));
125
196
  this._emitChange();
126
197
  } else if ((action === 'up' || action === 'down') && Number.isInteger(index)) {
127
- const target = index + (action === 'up' ? -1 : 1);
128
- if (target < 0 || target >= this._values.length) return;
129
- [this._values[index], this._values[target]] = [this._values[target], this._values[index]];
130
- this._renderRows(target);
131
- this._emitChange();
198
+ // Focus stays on the button that moved the row, so the reader can press it again.
199
+ this._move(index, index + (action === 'up' ? -1 : 1), action);
132
200
  }
133
201
  }
134
202
 
135
203
  /** @param {Event} event @returns {void} */
136
204
  _handleChange(event) {
137
205
  const control = event.target instanceof Element ? event.target.closest('[data-index]') : null;
138
- if (!control) return;
206
+ if (!control || !('value' in control)) return;
139
207
  const index = Number(control.dataset.index);
140
208
  if (!Number.isInteger(index) || index < 0 || index >= this._values.length) return;
141
209
  this._values[index] = control.value;
142
210
  this._emitChange();
143
211
  }
144
212
 
213
+ /**
214
+ * The handle is the keyboard equivalent of dragging it, so `reorder: 'drag'` is still operable
215
+ * without a pointer.
216
+ * @param {KeyboardEvent} event
217
+ * @returns {void}
218
+ */
219
+ _handleKeydown(event) {
220
+ const handle = event.target instanceof Element
221
+ ? event.target.closest('.zx-multi-value-editor__handle')
222
+ : null;
223
+ if (!handle) return;
224
+ const index = Number(handle.dataset.index);
225
+ const target = event.key === 'ArrowUp' ? index - 1
226
+ : event.key === 'ArrowDown' ? index + 1
227
+ : event.key === 'Home' ? 0
228
+ : event.key === 'End' ? this._values.length - 1
229
+ : -1;
230
+ if (target < 0 || target >= this._values.length) return;
231
+ event.preventDefault();
232
+ this._move(index, target, 'handle');
233
+ }
234
+
235
+ /**
236
+ * Arms the row for dragging. Only the handle does this, so text selection inside a row's input
237
+ * keeps working and a stray drag cannot reorder anything.
238
+ * @param {PointerEvent} event
239
+ * @returns {void}
240
+ */
241
+ _handlePointerDown(event) {
242
+ const handle = event.target instanceof Element
243
+ ? event.target.closest('.zx-multi-value-editor__handle')
244
+ : null;
245
+ this._disarm();
246
+ if (!handle || handle.disabled) return;
247
+ const row = /** @type {HTMLElement|null} */ (handle.closest('.zx-multi-value-editor__row'));
248
+ if (!row) return;
249
+ row.draggable = true;
250
+ this._armed = row;
251
+ }
252
+
253
+ /** @returns {void} */
254
+ _disarm() {
255
+ if (this._armed) this._armed.draggable = false;
256
+ this._armed = null;
257
+ }
258
+
259
+ /** @param {DragEvent} event @returns {void} */
260
+ _handleDragStart(event) {
261
+ const row = event.target instanceof Element
262
+ ? event.target.closest('.zx-multi-value-editor__row')
263
+ : null;
264
+ if (!row || row !== this._armed) {
265
+ event.preventDefault();
266
+ return;
267
+ }
268
+ this._dragIndex = Number(row.dataset.row);
269
+ row.dataset.dragging = 'true';
270
+ this.el.setAttribute('data-state', 'dragging');
271
+ event.dataTransfer?.setData('text/plain', String(this._dragIndex));
272
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
273
+ }
274
+
275
+ /** @param {DragEvent} event @returns {void} */
276
+ _handleDragOver(event) {
277
+ if (this._dragIndex < 0) return;
278
+ event.preventDefault();
279
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
280
+ this._showDrop(this._dropTarget(event.clientY));
281
+ }
282
+
283
+ /** @param {DragEvent} event @returns {void} */
284
+ _handleDragLeave(event) {
285
+ const to = event.relatedTarget;
286
+ if (to instanceof Node && this._rows.contains(to)) return;
287
+ this._clearDrop();
288
+ }
289
+
290
+ /** @param {DragEvent} event @returns {void} */
291
+ _handleDrop(event) {
292
+ if (this._dragIndex < 0) return;
293
+ event.preventDefault();
294
+ const from = this._dragIndex;
295
+ const slot = this._dropTarget(event.clientY);
296
+ this._finishDrag();
297
+ // The slot is an insertion point in the list as it stands; lifting the row out shifts every
298
+ // slot after it down by one.
299
+ this._move(from, slot > from ? slot - 1 : slot, 'handle');
300
+ }
301
+
302
+ /** @returns {void} */
303
+ _finishDrag() {
304
+ this._dragIndex = -1;
305
+ this._disarm();
306
+ this._clearDrop();
307
+ for (const row of this._rows.children) delete (/** @type {HTMLElement} */ (row)).dataset.dragging;
308
+ if (this._initialState === null) this.el.removeAttribute('data-state');
309
+ else this.el.setAttribute('data-state', this._initialState);
310
+ }
311
+
312
+ /**
313
+ * Insertion slot (0…length) for a pointer at `clientY`.
314
+ * @param {number} clientY Pointer position.
315
+ * @returns {number}
316
+ */
317
+ _dropTarget(clientY) {
318
+ const rows = [...this._rows.children];
319
+ for (const [index, row] of rows.entries()) {
320
+ const box = row.getBoundingClientRect();
321
+ if (clientY < box.top + box.height / 2) return index;
322
+ }
323
+ return rows.length;
324
+ }
325
+
326
+ /** @param {number} slot Insertion slot to mark. @returns {void} */
327
+ _showDrop(slot) {
328
+ const rows = [...this._rows.children];
329
+ // Dropping either side of the row being dragged puts it back where it was; showing a line
330
+ // there would promise a move that does not happen.
331
+ const inert = slot === this._dragIndex || slot === this._dragIndex + 1;
332
+ for (const [index, row] of rows.entries()) {
333
+ const cell = /** @type {HTMLElement} */ (row);
334
+ if (!inert && index === slot) cell.dataset.drop = 'before';
335
+ else if (!inert && slot === rows.length && index === rows.length - 1) cell.dataset.drop = 'after';
336
+ else delete cell.dataset.drop;
337
+ }
338
+ }
339
+
340
+ /** @returns {void} */
341
+ _clearDrop() {
342
+ for (const row of this._rows.children) delete (/** @type {HTMLElement} */ (row)).dataset.drop;
343
+ }
344
+
345
+ /**
346
+ * @param {number} from Index to move.
347
+ * @param {number} to Destination index.
348
+ * @param {keyof PARTS} part Control of the moved row that takes focus.
349
+ * @returns {boolean} Whether anything moved.
350
+ */
351
+ _move(from, to, part) {
352
+ const size = this._values.length;
353
+ if (from === to || from < 0 || to < 0 || from >= size || to >= size) return false;
354
+ const [value] = this._values.splice(from, 1);
355
+ this._values.splice(to, 0, value);
356
+ this._renderRows(to, part);
357
+ this._emitChange();
358
+ return true;
359
+ }
360
+
145
361
  /** @returns {void} */
146
362
  _emitChange() {
147
363
  this.emit('change', { values: this.getValues() });
@@ -149,6 +365,8 @@ export class MultiValueEditor extends Component {
149
365
 
150
366
  /** @returns {void} */
151
367
  destroy() {
368
+ if (this._initialState === null) this.el?.removeAttribute('data-state');
369
+ else this.el?.setAttribute('data-state', this._initialState);
152
370
  for (const node of this._ownedNodes ?? []) node.remove();
153
371
  super.destroy();
154
372
  }
@@ -166,5 +384,5 @@ function normalizeOptions(options) {
166
384
  /**
167
385
  * Ordered values changed event.
168
386
  * @event MultiValueEditor#change
169
- * @type {CustomEvent<{values: string[]}>
387
+ * @type {CustomEvent<{values: string[]}>}
170
388
  */
@@ -1,6 +1,13 @@
1
1
  .zx-navigation-bar {
2
2
  min-inline-size: 0;
3
3
  container-type: inline-size;
4
+ /* A size container takes no width from its own contents, so in a shrink-to-fit host — a flex
5
+ row, an inline-block, a grid `auto` track — it would collapse to nothing and spill. Only the
6
+ host can size it, so it always fills the host. That covers every host with a width of its
7
+ own; a host that shrink-wraps *its* contents still collapses, because 100% then resolves
8
+ against a containing block this element's own containment zeroed. Dropping containment is the
9
+ only fix for that — see multi-value-editor.css. */
10
+ inline-size: 100%;
4
11
  }
5
12
 
6
13
  .zx-navigation-bar__bar {