@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +28 -2
  3. package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +67 -8
  4. package/dist/types/components/permission/permission.d.ts +3 -0
  5. package/dist/types/components/select/select.d.ts +36 -1
  6. package/dist/types/components/tabbox/tabbox.d.ts +12 -3
  7. package/dist/zx-compat.esm.js +302 -34
  8. package/dist/zx-compat.esm.min.js +1 -1
  9. package/dist/zx-compat.global.js +302 -34
  10. package/dist/zx-compat.global.min.js +3 -3
  11. package/dist/zx-zeyos.esm.js +280 -27
  12. package/dist/zx-zeyos.esm.min.js +1 -1
  13. package/dist/zx.css +159 -33
  14. package/dist/zx.esm.js +302 -34
  15. package/dist/zx.esm.min.js +3 -3
  16. package/dist/zx.global.js +302 -34
  17. package/dist/zx.global.min.js +3 -3
  18. package/dist/zx.min.css +1 -1
  19. package/docs/api.json +420 -17
  20. package/docs/llms.md +54 -12
  21. package/package.json +1 -1
  22. package/src/components/date-picker/date-range-picker.css +7 -0
  23. package/src/components/fieldset/fieldset.css +7 -0
  24. package/src/components/form/form.css +7 -0
  25. package/src/components/multi-value-editor/multi-value-editor.css +99 -15
  26. package/src/components/multi-value-editor/multi-value-editor.js +235 -18
  27. package/src/components/navigation-bar/navigation-bar.css +7 -0
  28. package/src/components/permission/permission.js +3 -0
  29. package/src/components/search/search.css +10 -2
  30. package/src/components/select/select.css +6 -0
  31. package/src/components/select/select.js +99 -11
  32. package/src/components/stepper/stepper.css +12 -2
  33. package/src/components/tabbox/tabbox.css +13 -3
  34. package/src/components/tabbox/tabbox.js +34 -10
  35. package/styles/tokens/dark.css +13 -10
  36. package/styles/tokens/global.css +12 -0
  37. package/styles/tokens/semantic.css +9 -5
  38. package/styles/tokens/themes.css +72 -0
  39. 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
- /** @param {number} [focusIndex=-1] @returns {void} */
63
- _renderRows(focusIndex = -1) {
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
- }, choices.map(([optionValue, label]) => h('option', {
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: index === 0,
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
- disabled: index === this._values.length - 1,
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) rows[focusIndex]?.querySelector('.zx-multi-value-editor__value')?.focus();
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
- 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();
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
- .zx-search__input:focus-visible,
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
- this._items.find((item) => Object.is(this._itemValue(item), selectedValue)) ?? null;
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
- const keys = this.options.searchKeys ?? [this.options.labelKey];
411
- this._visibleItems = matchItems(this._items, query, keys);
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
- const direct = this._items.find((item) => item === candidate);
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 this._items.find((item) => Object.is(this._itemValue(item), value)) ?? null;
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
- if (typeof reader === 'function') return reader(item);
625
- if (item === null || typeof item !== 'object') return item;
626
- return item[reader];
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
- /* The connector doubles as the flexible gap, so the steps size to their labels and the rules
184
- absorb whatever is left over. */
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);