@zeyos/zx 2.2.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +110 -0
- package/README.md +28 -2
- package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +67 -8
- package/dist/types/components/permission/permission.d.ts +3 -0
- package/dist/types/components/select/select.d.ts +36 -1
- package/dist/types/components/tabbox/tabbox.d.ts +12 -3
- package/dist/zx-compat.esm.js +302 -34
- package/dist/zx-compat.esm.min.js +1 -1
- package/dist/zx-compat.global.js +302 -34
- package/dist/zx-compat.global.min.js +3 -3
- package/dist/zx-zeyos.esm.js +280 -27
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +159 -33
- package/dist/zx.esm.js +302 -34
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +302 -34
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +420 -17
- package/docs/llms.md +54 -12
- package/package.json +1 -1
- package/src/components/date-picker/date-range-picker.css +7 -0
- package/src/components/fieldset/fieldset.css +7 -0
- package/src/components/form/form.css +7 -0
- package/src/components/multi-value-editor/multi-value-editor.css +99 -15
- package/src/components/multi-value-editor/multi-value-editor.js +235 -18
- package/src/components/navigation-bar/navigation-bar.css +7 -0
- package/src/components/permission/permission.js +3 -0
- package/src/components/search/search.css +10 -2
- package/src/components/select/select.css +6 -0
- package/src/components/select/select.js +99 -11
- package/src/components/stepper/stepper.css +12 -2
- package/src/components/tabbox/tabbox.css +13 -3
- package/src/components/tabbox/tabbox.js +34 -10
- package/styles/tokens/dark.css +13 -10
- package/styles/tokens/global.css +12 -0
- package/styles/tokens/semantic.css +9 -5
- package/styles/tokens/themes.css +72 -0
- package/styles/zx.css +1 -0
|
@@ -1,15 +1,38 @@
|
|
|
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.
|
|
9
12
|
*/
|
|
10
13
|
|
|
14
|
+
/** Affordances each `reorder` setting turns on. */
|
|
15
|
+
const REORDER = Object.freeze({
|
|
16
|
+
both: { drag: true, buttons: true },
|
|
17
|
+
buttons: { drag: false, buttons: true },
|
|
18
|
+
drag: { drag: true, buttons: false },
|
|
19
|
+
none: { drag: false, buttons: false }
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** Focusable parts of a row, so a re-render can put focus back where the reader left it. */
|
|
23
|
+
const PARTS = Object.freeze({
|
|
24
|
+
value: '.zx-multi-value-editor__value',
|
|
25
|
+
handle: '.zx-multi-value-editor__handle',
|
|
26
|
+
up: '[data-action="up"]',
|
|
27
|
+
down: '[data-action="down"]'
|
|
28
|
+
});
|
|
29
|
+
|
|
11
30
|
/**
|
|
12
31
|
* Ordered value editor with explicit editable rows and controls.
|
|
32
|
+
*
|
|
33
|
+
* Rows reorder by dragging their handle or by pressing the up/down buttons — `reorder` picks which
|
|
34
|
+
* of the two a row offers. The handle is also a keyboard control (arrows, Home, End), so drag-only
|
|
35
|
+
* mode still reorders without a pointer.
|
|
13
36
|
* @fires MultiValueEditor#change
|
|
14
37
|
* @extends {Component<MultiValueEditorOptions>}
|
|
15
38
|
*/
|
|
@@ -17,13 +40,18 @@ export class MultiValueEditor extends Component {
|
|
|
17
40
|
static cssName = 'multi-value-editor';
|
|
18
41
|
|
|
19
42
|
/** @type {MultiValueEditorOptions} */
|
|
20
|
-
static defaults = { values: [], options: null, addLabel: 'Add value' };
|
|
43
|
+
static defaults = { values: [], options: null, addLabel: 'Add value', reorder: 'both' };
|
|
21
44
|
|
|
22
45
|
/** @returns {HTMLElement} */
|
|
23
46
|
render() {
|
|
24
47
|
const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
|
|
48
|
+
this.el = root;
|
|
25
49
|
this._values = Array.isArray(this.options.values) ? this.options.values.map(String) : [];
|
|
26
50
|
this._ownedNodes = [];
|
|
51
|
+
this._initialState = root.getAttribute('data-state');
|
|
52
|
+
this._dragIndex = -1;
|
|
53
|
+
/** @type {HTMLElement|null} Row made draggable by a pointer press on its handle. */
|
|
54
|
+
this._armed = null;
|
|
27
55
|
this._rows = h('div', { class: 'zx-multi-value-editor__rows' });
|
|
28
56
|
this._add = h('button', {
|
|
29
57
|
class: 'zx-btn zx-multi-value-editor__add',
|
|
@@ -34,6 +62,19 @@ export class MultiValueEditor extends Component {
|
|
|
34
62
|
root.append(this._rows, this._add);
|
|
35
63
|
this.listen(root, 'click', (event) => this._handleClick(event));
|
|
36
64
|
this.listen(this._rows, 'change', (event) => this._handleChange(event));
|
|
65
|
+
this.listen(this._rows, 'keydown', (event) => this._handleKeydown(event));
|
|
66
|
+
if (this._reorder().drag) {
|
|
67
|
+
this.listen(this._rows, 'pointerdown', (event) => this._handlePointerDown(event));
|
|
68
|
+
this.listen(this._rows, 'dragstart', (event) => this._handleDragStart(event));
|
|
69
|
+
this.listen(this._rows, 'dragover', (event) => this._handleDragOver(event));
|
|
70
|
+
this.listen(this._rows, 'dragleave', (event) => this._handleDragLeave(event));
|
|
71
|
+
this.listen(this._rows, 'drop', (event) => this._handleDrop(event));
|
|
72
|
+
this.listen(this._rows, 'dragend', () => this._finishDrag());
|
|
73
|
+
// A press that never became a drag still has to disarm the row, and its pointerup can land
|
|
74
|
+
// anywhere — including outside the component.
|
|
75
|
+
this.listen(document, 'pointerup', () => this._disarm());
|
|
76
|
+
this.listen(document, 'pointercancel', () => this._disarm());
|
|
77
|
+
}
|
|
37
78
|
this._renderRows();
|
|
38
79
|
return root;
|
|
39
80
|
}
|
|
@@ -59,16 +100,32 @@ export class MultiValueEditor extends Component {
|
|
|
59
100
|
return this;
|
|
60
101
|
}
|
|
61
102
|
|
|
62
|
-
/** @
|
|
63
|
-
|
|
103
|
+
/** @returns {{drag: boolean, buttons: boolean}} */
|
|
104
|
+
_reorder() {
|
|
105
|
+
return REORDER[this.options.reorder] ?? REORDER.both;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {number} [focusIndex=-1] Row to focus after the render, or -1 for none.
|
|
110
|
+
* @param {keyof PARTS} [focusPart='value'] Which control of that row takes focus.
|
|
111
|
+
* @returns {void}
|
|
112
|
+
*/
|
|
113
|
+
_renderRows(focusIndex = -1, focusPart = 'value') {
|
|
64
114
|
const choices = normalizeOptions(this.options.options);
|
|
65
115
|
const useSelect = this.options.options !== null;
|
|
116
|
+
const { drag, buttons } = this._reorder();
|
|
117
|
+
const last = this._values.length - 1;
|
|
66
118
|
const rows = this._values.map((value, index) => {
|
|
119
|
+
// An option list that has lost a stored value still has to round-trip it, or `getValues()`
|
|
120
|
+
// would quietly return something the select never showed.
|
|
121
|
+
const entries = !useSelect || choices.some(([option]) => option === value)
|
|
122
|
+
? choices
|
|
123
|
+
: [[value, value], ...choices];
|
|
67
124
|
const control = useSelect ? h('select', {
|
|
68
125
|
class: 'zx-multi-value-editor__value',
|
|
69
126
|
ariaLabel: `Value ${index + 1}`,
|
|
70
127
|
dataset: { index }
|
|
71
|
-
},
|
|
128
|
+
}, entries.map(([optionValue, label]) => h('option', {
|
|
72
129
|
value: optionValue,
|
|
73
130
|
selected: optionValue === value
|
|
74
131
|
}, label))) : h('input', {
|
|
@@ -79,39 +136,52 @@ export class MultiValueEditor extends Component {
|
|
|
79
136
|
dataset: { index }
|
|
80
137
|
});
|
|
81
138
|
return h('div', { class: 'zx-multi-value-editor__row', dataset: { row: index } },
|
|
139
|
+
drag ? h('button', {
|
|
140
|
+
class: 'zx-multi-value-editor__handle',
|
|
141
|
+
type: 'button',
|
|
142
|
+
ariaLabel: `Reorder value ${index + 1}. Use the arrow keys to move it.`,
|
|
143
|
+
dataset: { action: 'grab', index }
|
|
144
|
+
}, icon('drag', { size: 12 })) : null,
|
|
82
145
|
control,
|
|
83
146
|
h('div', { class: 'zx-multi-value-editor__controls' },
|
|
84
|
-
h('button', {
|
|
147
|
+
buttons ? h('button', {
|
|
85
148
|
class: 'zx-btn',
|
|
86
149
|
type: 'button',
|
|
87
|
-
disabled
|
|
150
|
+
// `aria-disabled` rather than `disabled`: the buttons re-render on every move, and a
|
|
151
|
+
// control that disables itself under the reader's finger drops focus to the document.
|
|
152
|
+
ariaDisabled: String(index === 0),
|
|
88
153
|
ariaLabel: `Move value ${index + 1} up`,
|
|
89
154
|
dataset: { action: 'up', index }
|
|
90
|
-
}, '
|
|
91
|
-
h('button', {
|
|
155
|
+
}, icon('chevron-up', { size: 12 })) : null,
|
|
156
|
+
buttons ? h('button', {
|
|
92
157
|
class: 'zx-btn',
|
|
93
158
|
type: 'button',
|
|
94
|
-
|
|
159
|
+
ariaDisabled: String(index === last),
|
|
95
160
|
ariaLabel: `Move value ${index + 1} down`,
|
|
96
161
|
dataset: { action: 'down', index }
|
|
97
|
-
}, '
|
|
162
|
+
}, icon('chevron-down', { size: 12 })) : null,
|
|
98
163
|
h('button', {
|
|
99
164
|
class: 'zx-btn',
|
|
100
165
|
type: 'button',
|
|
101
166
|
ariaLabel: `Remove value ${index + 1}`,
|
|
102
167
|
dataset: { action: 'remove', index }
|
|
103
|
-
}, '
|
|
168
|
+
}, icon('x', { size: 12 }))
|
|
104
169
|
)
|
|
105
170
|
);
|
|
106
171
|
});
|
|
107
172
|
this._rows.replaceChildren(...rows);
|
|
108
|
-
if (focusIndex >= 0)
|
|
173
|
+
if (focusIndex >= 0) {
|
|
174
|
+
const row = rows[Math.min(focusIndex, rows.length - 1)];
|
|
175
|
+
/** @type {HTMLElement|null|undefined} */
|
|
176
|
+
(row?.querySelector(PARTS[focusPart] ?? PARTS.value))?.focus();
|
|
177
|
+
}
|
|
109
178
|
}
|
|
110
179
|
|
|
111
180
|
/** @param {MouseEvent} event @returns {void} */
|
|
112
181
|
_handleClick(event) {
|
|
113
182
|
const button = event.target instanceof Element ? event.target.closest('[data-action]') : null;
|
|
114
183
|
if (!button || !this.el.contains(button)) return;
|
|
184
|
+
if (button.getAttribute('aria-disabled') === 'true') return;
|
|
115
185
|
const action = button.dataset.action;
|
|
116
186
|
const index = Number(button.dataset.index);
|
|
117
187
|
if (action === 'add') {
|
|
@@ -124,24 +194,169 @@ export class MultiValueEditor extends Component {
|
|
|
124
194
|
this._renderRows(Math.min(index, this._values.length - 1));
|
|
125
195
|
this._emitChange();
|
|
126
196
|
} else if ((action === 'up' || action === 'down') && Number.isInteger(index)) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
[this._values[index], this._values[target]] = [this._values[target], this._values[index]];
|
|
130
|
-
this._renderRows(target);
|
|
131
|
-
this._emitChange();
|
|
197
|
+
// Focus stays on the button that moved the row, so the reader can press it again.
|
|
198
|
+
this._move(index, index + (action === 'up' ? -1 : 1), action);
|
|
132
199
|
}
|
|
133
200
|
}
|
|
134
201
|
|
|
135
202
|
/** @param {Event} event @returns {void} */
|
|
136
203
|
_handleChange(event) {
|
|
137
204
|
const control = event.target instanceof Element ? event.target.closest('[data-index]') : null;
|
|
138
|
-
if (!control) return;
|
|
205
|
+
if (!control || !('value' in control)) return;
|
|
139
206
|
const index = Number(control.dataset.index);
|
|
140
207
|
if (!Number.isInteger(index) || index < 0 || index >= this._values.length) return;
|
|
141
208
|
this._values[index] = control.value;
|
|
142
209
|
this._emitChange();
|
|
143
210
|
}
|
|
144
211
|
|
|
212
|
+
/**
|
|
213
|
+
* The handle is the keyboard equivalent of dragging it, so `reorder: 'drag'` is still operable
|
|
214
|
+
* without a pointer.
|
|
215
|
+
* @param {KeyboardEvent} event
|
|
216
|
+
* @returns {void}
|
|
217
|
+
*/
|
|
218
|
+
_handleKeydown(event) {
|
|
219
|
+
const handle = event.target instanceof Element
|
|
220
|
+
? event.target.closest('.zx-multi-value-editor__handle')
|
|
221
|
+
: null;
|
|
222
|
+
if (!handle) return;
|
|
223
|
+
const index = Number(handle.dataset.index);
|
|
224
|
+
const target = event.key === 'ArrowUp' ? index - 1
|
|
225
|
+
: event.key === 'ArrowDown' ? index + 1
|
|
226
|
+
: event.key === 'Home' ? 0
|
|
227
|
+
: event.key === 'End' ? this._values.length - 1
|
|
228
|
+
: -1;
|
|
229
|
+
if (target < 0 || target >= this._values.length) return;
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
this._move(index, target, 'handle');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Arms the row for dragging. Only the handle does this, so text selection inside a row's input
|
|
236
|
+
* keeps working and a stray drag cannot reorder anything.
|
|
237
|
+
* @param {PointerEvent} event
|
|
238
|
+
* @returns {void}
|
|
239
|
+
*/
|
|
240
|
+
_handlePointerDown(event) {
|
|
241
|
+
const handle = event.target instanceof Element
|
|
242
|
+
? event.target.closest('.zx-multi-value-editor__handle')
|
|
243
|
+
: null;
|
|
244
|
+
this._disarm();
|
|
245
|
+
if (!handle || handle.disabled) return;
|
|
246
|
+
const row = /** @type {HTMLElement|null} */ (handle.closest('.zx-multi-value-editor__row'));
|
|
247
|
+
if (!row) return;
|
|
248
|
+
row.draggable = true;
|
|
249
|
+
this._armed = row;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** @returns {void} */
|
|
253
|
+
_disarm() {
|
|
254
|
+
if (this._armed) this._armed.draggable = false;
|
|
255
|
+
this._armed = null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** @param {DragEvent} event @returns {void} */
|
|
259
|
+
_handleDragStart(event) {
|
|
260
|
+
const row = event.target instanceof Element
|
|
261
|
+
? event.target.closest('.zx-multi-value-editor__row')
|
|
262
|
+
: null;
|
|
263
|
+
if (!row || row !== this._armed) {
|
|
264
|
+
event.preventDefault();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
this._dragIndex = Number(row.dataset.row);
|
|
268
|
+
row.dataset.dragging = 'true';
|
|
269
|
+
this.el.setAttribute('data-state', 'dragging');
|
|
270
|
+
event.dataTransfer?.setData('text/plain', String(this._dragIndex));
|
|
271
|
+
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** @param {DragEvent} event @returns {void} */
|
|
275
|
+
_handleDragOver(event) {
|
|
276
|
+
if (this._dragIndex < 0) return;
|
|
277
|
+
event.preventDefault();
|
|
278
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
|
279
|
+
this._showDrop(this._dropTarget(event.clientY));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** @param {DragEvent} event @returns {void} */
|
|
283
|
+
_handleDragLeave(event) {
|
|
284
|
+
const to = event.relatedTarget;
|
|
285
|
+
if (to instanceof Node && this._rows.contains(to)) return;
|
|
286
|
+
this._clearDrop();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** @param {DragEvent} event @returns {void} */
|
|
290
|
+
_handleDrop(event) {
|
|
291
|
+
if (this._dragIndex < 0) return;
|
|
292
|
+
event.preventDefault();
|
|
293
|
+
const from = this._dragIndex;
|
|
294
|
+
const slot = this._dropTarget(event.clientY);
|
|
295
|
+
this._finishDrag();
|
|
296
|
+
// The slot is an insertion point in the list as it stands; lifting the row out shifts every
|
|
297
|
+
// slot after it down by one.
|
|
298
|
+
this._move(from, slot > from ? slot - 1 : slot, 'handle');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** @returns {void} */
|
|
302
|
+
_finishDrag() {
|
|
303
|
+
this._dragIndex = -1;
|
|
304
|
+
this._disarm();
|
|
305
|
+
this._clearDrop();
|
|
306
|
+
for (const row of this._rows.children) delete (/** @type {HTMLElement} */ (row)).dataset.dragging;
|
|
307
|
+
if (this._initialState === null) this.el.removeAttribute('data-state');
|
|
308
|
+
else this.el.setAttribute('data-state', this._initialState);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Insertion slot (0…length) for a pointer at `clientY`.
|
|
313
|
+
* @param {number} clientY Pointer position.
|
|
314
|
+
* @returns {number}
|
|
315
|
+
*/
|
|
316
|
+
_dropTarget(clientY) {
|
|
317
|
+
const rows = [...this._rows.children];
|
|
318
|
+
for (const [index, row] of rows.entries()) {
|
|
319
|
+
const box = row.getBoundingClientRect();
|
|
320
|
+
if (clientY < box.top + box.height / 2) return index;
|
|
321
|
+
}
|
|
322
|
+
return rows.length;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** @param {number} slot Insertion slot to mark. @returns {void} */
|
|
326
|
+
_showDrop(slot) {
|
|
327
|
+
const rows = [...this._rows.children];
|
|
328
|
+
// Dropping either side of the row being dragged puts it back where it was; showing a line
|
|
329
|
+
// there would promise a move that does not happen.
|
|
330
|
+
const inert = slot === this._dragIndex || slot === this._dragIndex + 1;
|
|
331
|
+
for (const [index, row] of rows.entries()) {
|
|
332
|
+
const cell = /** @type {HTMLElement} */ (row);
|
|
333
|
+
if (!inert && index === slot) cell.dataset.drop = 'before';
|
|
334
|
+
else if (!inert && slot === rows.length && index === rows.length - 1) cell.dataset.drop = 'after';
|
|
335
|
+
else delete cell.dataset.drop;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** @returns {void} */
|
|
340
|
+
_clearDrop() {
|
|
341
|
+
for (const row of this._rows.children) delete (/** @type {HTMLElement} */ (row)).dataset.drop;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* @param {number} from Index to move.
|
|
346
|
+
* @param {number} to Destination index.
|
|
347
|
+
* @param {keyof PARTS} part Control of the moved row that takes focus.
|
|
348
|
+
* @returns {boolean} Whether anything moved.
|
|
349
|
+
*/
|
|
350
|
+
_move(from, to, part) {
|
|
351
|
+
const size = this._values.length;
|
|
352
|
+
if (from === to || from < 0 || to < 0 || from >= size || to >= size) return false;
|
|
353
|
+
const [value] = this._values.splice(from, 1);
|
|
354
|
+
this._values.splice(to, 0, value);
|
|
355
|
+
this._renderRows(to, part);
|
|
356
|
+
this._emitChange();
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
|
|
145
360
|
/** @returns {void} */
|
|
146
361
|
_emitChange() {
|
|
147
362
|
this.emit('change', { values: this.getValues() });
|
|
@@ -149,6 +364,8 @@ export class MultiValueEditor extends Component {
|
|
|
149
364
|
|
|
150
365
|
/** @returns {void} */
|
|
151
366
|
destroy() {
|
|
367
|
+
if (this._initialState === null) this.el?.removeAttribute('data-state');
|
|
368
|
+
else this.el?.setAttribute('data-state', this._initialState);
|
|
152
369
|
for (const node of this._ownedNodes ?? []) node.remove();
|
|
153
370
|
super.destroy();
|
|
154
371
|
}
|
|
@@ -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 {
|
|
@@ -18,6 +18,9 @@ import { Select } from '../select/select.js';
|
|
|
18
18
|
/**
|
|
19
19
|
* Record-permission choice for private, public, and group access.
|
|
20
20
|
*
|
|
21
|
+
* @deprecated Use `Select.permission(target, {groups})`, which carries the same tri-state value in
|
|
22
|
+
* one control and accepts a loader function for installations whose group list is too long to ship
|
|
23
|
+
* with the form. This class stays for `gx.zeyos.Permission` and will be removed in 3.0.
|
|
21
24
|
* @fires Permission#change
|
|
22
25
|
* @extends {Component<PermissionOptions>}
|
|
23
26
|
*/
|
|
@@ -22,10 +22,18 @@
|
|
|
22
22
|
padding-inline: var(--zx-control-pad-x) var(--zx-control-height);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
/* The field draws one ring for the whole control (:focus-within above). Inner controls must not
|
|
26
|
+
add a second, smaller ring — an outset ring on the input cut the submit button out of the field.
|
|
27
|
+
The input rides the field ring; the buttons mark themselves with an inset one, as .zx-icon-btn
|
|
28
|
+
does, so the field's outline stays whole. The transparent outline from base.css is left in place
|
|
29
|
+
for forced-colors mode. */
|
|
30
|
+
.zx-search__input:focus-visible {
|
|
31
|
+
box-shadow: none;
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
.zx-search__clear:focus-visible,
|
|
27
35
|
.zx-search__submit:focus-visible {
|
|
28
|
-
box-shadow: var(--zx-focus-ring);
|
|
36
|
+
box-shadow: inset var(--zx-focus-ring);
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
.zx-search__input::-webkit-search-cancel-button {
|
|
@@ -137,6 +137,12 @@
|
|
|
137
137
|
text-transform: uppercase;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
.zx-select__separator {
|
|
141
|
+
block-size: 1px;
|
|
142
|
+
margin: var(--zx-space-1) var(--zx-control-pad-x);
|
|
143
|
+
background: var(--zx-color-border);
|
|
144
|
+
}
|
|
145
|
+
|
|
140
146
|
.zx-select__empty {
|
|
141
147
|
color: var(--zx-color-text-muted);
|
|
142
148
|
padding: var(--zx-space-3) var(--zx-control-pad-x);
|
|
@@ -13,6 +13,7 @@ import { matchItems } from './filter.js';
|
|
|
13
13
|
/**
|
|
14
14
|
* @typedef {Object} SelectOptions
|
|
15
15
|
* @property {SelectItem[]} [items=[]] Available values.
|
|
16
|
+
* @property {SelectItem[]} [fixedItems=[]] Choices pinned above the list, kept through filtering and async loads.
|
|
16
17
|
* @property {SelectValueReader} [valueKey='ID'] Item ID property or reader.
|
|
17
18
|
* @property {SelectLabelReader} [labelKey='name'] Item label property or reader.
|
|
18
19
|
* @property {((item: SelectItem) => Node|string)|null} [renderItem=null] Option renderer.
|
|
@@ -65,6 +66,7 @@ export class Select extends Component {
|
|
|
65
66
|
/** @type {SelectOptions} */
|
|
66
67
|
static defaults = {
|
|
67
68
|
items: [],
|
|
69
|
+
fixedItems: [],
|
|
68
70
|
valueKey: 'ID',
|
|
69
71
|
labelKey: 'name',
|
|
70
72
|
renderItem: null,
|
|
@@ -86,7 +88,7 @@ export class Select extends Component {
|
|
|
86
88
|
render() {
|
|
87
89
|
// Instance state initialized here because render() runs inside the base
|
|
88
90
|
// constructor, before class-field initializers would run (and clobber it).
|
|
89
|
-
this._items = []; this._visibleItems = []; this._selected = null; this._optionEntries = []; this._activeIndex = -1; this._open = false; this._disabled = false; this._loading = false; this._requestSequence = 0; this._queryTimer = null; this._position = null; this._createdRoot = false; this._root = null; this._originalChildren = []; this._originalAttributes = new Map();
|
|
91
|
+
this._items = []; this._visibleItems = []; this._fixedItems = []; this._visibleFixed = []; this._selected = null; this._optionEntries = []; this._activeIndex = -1; this._open = false; this._disabled = false; this._loading = false; this._requestSequence = 0; this._queryTimer = null; this._position = null; this._createdRoot = false; this._root = null; this._originalChildren = []; this._originalAttributes = new Map();
|
|
90
92
|
this._createdRoot = this.el === null;
|
|
91
93
|
const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
|
|
92
94
|
this.el = root;
|
|
@@ -151,6 +153,8 @@ export class Select extends Component {
|
|
|
151
153
|
|
|
152
154
|
this._items = Array.isArray(this.options.items) ? this.options.items.slice() : [];
|
|
153
155
|
this._visibleItems = this._items.slice();
|
|
156
|
+
this._fixedItems = Array.isArray(this.options.fixedItems) ? this.options.fixedItems.slice() : [];
|
|
157
|
+
this._visibleFixed = this._fixedItems.slice();
|
|
154
158
|
this._disabled = Boolean(this.options.disabled);
|
|
155
159
|
this._handleTypeahead = typeahead(
|
|
156
160
|
() => this._optionEntries.filter((entry) => !entry.clear).map((entry) => entry.element),
|
|
@@ -228,8 +232,23 @@ export class Select extends Component {
|
|
|
228
232
|
const selectedValue = this.value;
|
|
229
233
|
this._items = Array.isArray(items) ? items.slice() : [];
|
|
230
234
|
this._visibleItems = this._items.slice();
|
|
231
|
-
this._selected = selectedValue == null ? null :
|
|
232
|
-
|
|
235
|
+
this._selected = selectedValue == null ? null : this._findItem(selectedValue);
|
|
236
|
+
this._syncInputValue();
|
|
237
|
+
this._renderOptions();
|
|
238
|
+
this._position?.update();
|
|
239
|
+
return this;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Replaces the pinned choices and preserves selection by ID when possible.
|
|
244
|
+
* @param {SelectItem[]} items New fixed items.
|
|
245
|
+
* @returns {this}
|
|
246
|
+
*/
|
|
247
|
+
setFixedItems(items) {
|
|
248
|
+
const selectedValue = this.value;
|
|
249
|
+
this._fixedItems = Array.isArray(items) ? items.slice() : [];
|
|
250
|
+
this._visibleFixed = this._fixedItems.slice();
|
|
251
|
+
this._selected = selectedValue == null ? null : this._findItem(selectedValue);
|
|
233
252
|
this._syncInputValue();
|
|
234
253
|
this._renderOptions();
|
|
235
254
|
this._position?.update();
|
|
@@ -283,6 +302,7 @@ export class Select extends Component {
|
|
|
283
302
|
this.refs.input.removeAttribute('aria-activedescendant');
|
|
284
303
|
this._activeIndex = -1;
|
|
285
304
|
this._visibleItems = this._items.slice();
|
|
305
|
+
this._visibleFixed = this._fixedItems.slice();
|
|
286
306
|
this._syncInputValue();
|
|
287
307
|
this.emit('close', {});
|
|
288
308
|
return this;
|
|
@@ -341,6 +361,40 @@ export class Select extends Component {
|
|
|
341
361
|
return select;
|
|
342
362
|
}
|
|
343
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Creates the record-permission picker: the fixed Private and Public choices above the groups a
|
|
366
|
+
* record can be shared with. The value is the tri-state ZeyOS stores — `false` for private,
|
|
367
|
+
* `true` for public, or a group ID — so the control binds straight to the record field.
|
|
368
|
+
*
|
|
369
|
+
* @param {Element|string|null} target Component target.
|
|
370
|
+
* @param {SelectOptions & {groups?: SelectItem[]|((query: string) => Promise<SelectItem[]>|SelectItem[])}} [options={}]
|
|
371
|
+
* Select overrides other than the fixed choices; `groups` holds the shareable groups, either as
|
|
372
|
+
* an array (filtered locally) or as a loader function (queried remotely).
|
|
373
|
+
* @returns {Select}
|
|
374
|
+
*/
|
|
375
|
+
static permission(target, options = {}) {
|
|
376
|
+
const { groups = [], value = true, valueKey = 'ID', labelKey = 'name', ...rest } = options;
|
|
377
|
+
const loader = typeof groups === 'function' ? groups : null;
|
|
378
|
+
const shareable = loader ? [] : groups;
|
|
379
|
+
const choices = [{ ID: false, name: 'Private' }, { ID: true, name: 'Public' }];
|
|
380
|
+
const select = new Select(target, {
|
|
381
|
+
filter: loader ?? 'local',
|
|
382
|
+
...rest,
|
|
383
|
+
value: null,
|
|
384
|
+
items: shareable,
|
|
385
|
+
fixedItems: [],
|
|
386
|
+
// The two choices carry their own ID and label, so a caller's group readers stay untouched.
|
|
387
|
+
valueKey: (item) => choices.includes(item) ? item.ID : readItem(item, valueKey),
|
|
388
|
+
labelKey: (item) => choices.includes(item) ?
|
|
389
|
+
item.name : String(readItem(item, labelKey) ?? '')
|
|
390
|
+
});
|
|
391
|
+
choices[0].name = select._message('permission.private', 'Private');
|
|
392
|
+
choices[1].name = select._message('permission.public', 'Public');
|
|
393
|
+
select.setFixedItems(choices);
|
|
394
|
+
select.set(value, { silent: true });
|
|
395
|
+
return select;
|
|
396
|
+
}
|
|
397
|
+
|
|
344
398
|
/** Cleans up the panel, pending async work, and enhanced target contents. @returns {void} */
|
|
345
399
|
destroy() {
|
|
346
400
|
this._cancelQuery();
|
|
@@ -407,8 +461,8 @@ export class Select extends Component {
|
|
|
407
461
|
const query = /** @type {HTMLInputElement} */ (this.refs.input).value;
|
|
408
462
|
if (!this._open) this.open();
|
|
409
463
|
if (this.options.filter === 'local') {
|
|
410
|
-
|
|
411
|
-
this._visibleItems = matchItems(this._items, query,
|
|
464
|
+
this._visibleFixed = this._matchFixed(query);
|
|
465
|
+
this._visibleItems = matchItems(this._items, query, this._searchKeys());
|
|
412
466
|
this._renderOptions();
|
|
413
467
|
this._setActive(this._optionEntries.length > 0 ? 0 : -1);
|
|
414
468
|
this._position?.update();
|
|
@@ -466,6 +520,11 @@ export class Select extends Component {
|
|
|
466
520
|
if (this.options.clearable) {
|
|
467
521
|
this._appendOption(null, true, this.options.placeholder || this._message('select.none', 'No selection'));
|
|
468
522
|
}
|
|
523
|
+
for (const item of this._visibleFixed) this._appendOption(item, false);
|
|
524
|
+
// A group heading already divides the two sections, and a rule right above one reads as a slip.
|
|
525
|
+
if (this._visibleFixed.length > 0 && this._visibleItems.length > 0 && !this.options.groupKey) {
|
|
526
|
+
list.append(h('div', { class: 'zx-select__separator', role: 'presentation' }));
|
|
527
|
+
}
|
|
469
528
|
for (const item of this._visibleItems) {
|
|
470
529
|
if (this.options.groupKey) {
|
|
471
530
|
const group = this._read(item, this.options.groupKey);
|
|
@@ -479,7 +538,7 @@ export class Select extends Component {
|
|
|
479
538
|
}
|
|
480
539
|
this._appendOption(item, false);
|
|
481
540
|
}
|
|
482
|
-
if (this._visibleItems.length === 0 && !this._loading) {
|
|
541
|
+
if (this._visibleItems.length === 0 && this._visibleFixed.length === 0 && !this._loading) {
|
|
483
542
|
list.append(h('div', {
|
|
484
543
|
class: 'zx-select__empty',
|
|
485
544
|
role: 'status'
|
|
@@ -521,6 +580,7 @@ export class Select extends Component {
|
|
|
521
580
|
if (this._queryTimer !== null) clearTimeout(this._queryTimer);
|
|
522
581
|
this._requestSequence += 1;
|
|
523
582
|
const sequence = this._requestSequence;
|
|
583
|
+
this._visibleFixed = this._matchFixed(query);
|
|
524
584
|
if (query.length < Math.max(0, Number(this.options.minQuery) || 0)) {
|
|
525
585
|
this._setLoading(false);
|
|
526
586
|
this._visibleItems = [];
|
|
@@ -601,11 +661,14 @@ export class Select extends Component {
|
|
|
601
661
|
|
|
602
662
|
/** @param {unknown} candidate @returns {SelectItem|null} */
|
|
603
663
|
_findItem(candidate) {
|
|
604
|
-
|
|
664
|
+
// Fixed choices are searched too: an async filter replaces the item list on every query, so a
|
|
665
|
+
// pinned value would otherwise stop resolving the moment the first result arrived.
|
|
666
|
+
const pool = this._fixedItems.length === 0 ? this._items : [...this._fixedItems, ...this._items];
|
|
667
|
+
const direct = pool.find((item) => item === candidate);
|
|
605
668
|
if (direct !== undefined) return direct;
|
|
606
669
|
const value = candidate !== null && typeof candidate === 'object' ?
|
|
607
670
|
this._itemValue(/** @type {SelectItem} */ (candidate)) : candidate;
|
|
608
|
-
return
|
|
671
|
+
return pool.find((item) => Object.is(this._itemValue(item), value)) ?? null;
|
|
609
672
|
}
|
|
610
673
|
|
|
611
674
|
/** @param {SelectItem|null} item @returns {unknown} */
|
|
@@ -621,9 +684,22 @@ export class Select extends Component {
|
|
|
621
684
|
|
|
622
685
|
/** @param {SelectItem|null} item @param {SelectValueReader|SelectLabelReader} reader @returns {unknown} */
|
|
623
686
|
_read(item, reader) {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
687
|
+
return readItem(item, reader);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** @returns {Array<SelectValueReader|SelectLabelReader>} */
|
|
691
|
+
_searchKeys() {
|
|
692
|
+
return this.options.searchKeys ?? [this.options.labelKey];
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Narrows the pinned choices. They are always matched here and never handed to a filter
|
|
697
|
+
* function: a fixed choice belongs to the control, not to whatever the remote source returns.
|
|
698
|
+
* @param {string} query Search text.
|
|
699
|
+
* @returns {SelectItem[]}
|
|
700
|
+
*/
|
|
701
|
+
_matchFixed(query) {
|
|
702
|
+
return matchItems(this._fixedItems, query, this._searchKeys());
|
|
627
703
|
}
|
|
628
704
|
|
|
629
705
|
/** @returns {number} */
|
|
@@ -657,6 +733,18 @@ export class Select extends Component {
|
|
|
657
733
|
/** Async-query start notification. @event Select#query @type {CustomEvent<SelectQueryDetail>} */
|
|
658
734
|
/** Async results notification. @event Select#loaded @type {CustomEvent<SelectLoadedDetail>} */
|
|
659
735
|
|
|
736
|
+
/**
|
|
737
|
+
* Reads a value off an item through a property name or a reader function.
|
|
738
|
+
* @param {SelectItem|null} item Item to read.
|
|
739
|
+
* @param {SelectValueReader|SelectLabelReader} reader Property name or reader.
|
|
740
|
+
* @returns {unknown}
|
|
741
|
+
*/
|
|
742
|
+
function readItem(item, reader) {
|
|
743
|
+
if (typeof reader === 'function') return reader(item);
|
|
744
|
+
if (item === null || typeof item !== 'object') return item;
|
|
745
|
+
return item[reader];
|
|
746
|
+
}
|
|
747
|
+
|
|
660
748
|
/** @param {KeyboardEvent} event @returns {boolean} */
|
|
661
749
|
function isPrintable(event) {
|
|
662
750
|
return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
@@ -175,13 +175,23 @@ button.zx-stepper__control:disabled {
|
|
|
175
175
|
gap: var(--zx-space-2);
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/* Every step but the last one grows, and inside it only the connector can take the extra, so the
|
|
179
|
+
rail spans its container: the labels stay their own width and the rules stretch between them.
|
|
180
|
+
The last step is left shrink-to-fit — growing it would strand the space behind its label. */
|
|
178
181
|
.zx-stepper[data-orientation="horizontal"] .zx-stepper__step {
|
|
179
182
|
flex: 0 1 auto;
|
|
180
183
|
gap: var(--zx-space-2);
|
|
181
184
|
}
|
|
182
185
|
|
|
183
|
-
|
|
184
|
-
|
|
186
|
+
.zx-stepper[data-orientation="horizontal"] .zx-stepper__step:not(:last-child) {
|
|
187
|
+
flex: 1 1 auto;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* The slack belongs to the connector, not to the label, so hover chrome keeps hugging the text. */
|
|
191
|
+
.zx-stepper[data-orientation="horizontal"] .zx-stepper__control {
|
|
192
|
+
flex: 0 1 auto;
|
|
193
|
+
}
|
|
194
|
+
|
|
185
195
|
.zx-stepper[data-orientation="horizontal"] .zx-stepper__step:not(:last-child)::after {
|
|
186
196
|
content: "";
|
|
187
197
|
flex: 1 1 var(--zx-space-6);
|