@zeyos/zx 3.0.0 → 4.1.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 +95 -0
- package/DESIGN-SYSTEM.md +87 -71
- package/README.md +16 -37
- package/dist/types/components/app-icon/app-icon.d.ts +88 -0
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +106 -32
- package/dist/types/components/card/card.d.ts +128 -0
- package/dist/types/components/chart/chart.d.ts +1 -0
- package/dist/types/components/filter/filter-model.d.ts +87 -0
- package/dist/types/components/filter/filter.d.ts +331 -0
- package/dist/types/components/launcher/launcher.d.ts +65 -6
- package/dist/types/components/select/select.d.ts +97 -4
- package/dist/types/components/table/table.d.ts +107 -3
- package/dist/types/components/tag-picker/tag-picker.d.ts +16 -0
- package/dist/types/core/dom.d.ts +9 -1
- package/dist/types/core/util.d.ts +9 -0
- package/dist/types/index.d.ts +6 -2
- package/dist/types/{components/app-rail → internal}/app-rail.d.ts +34 -19
- package/dist/types/zeyos/icons.d.ts +24 -2
- package/dist/types/zeyos/index.d.ts +3 -2
- package/dist/types/zeyos/launcher.d.ts +147 -0
- package/dist/types/zeyos/select.d.ts +41 -0
- package/dist/zx-zeyos.esm.js +2018 -113
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +815 -16
- package/dist/zx.esm.js +2661 -338
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +2661 -338
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +602 -110
- package/docs/llms.md +181 -123
- package/docs/llms.txt +24 -21
- package/package.json +8 -17
- package/src/components/app-icon/app-icon.css +105 -0
- package/src/components/app-icon/app-icon.js +134 -0
- package/src/components/app-sidebar/app-sidebar.css +12 -0
- package/src/components/app-sidebar/app-sidebar.js +310 -110
- package/src/components/card/card.css +149 -0
- package/src/components/card/card.js +285 -0
- package/src/components/chart/chart.js +8 -3
- package/src/components/filter/filter-model.js +226 -0
- package/src/components/filter/filter.css +147 -0
- package/src/components/filter/filter.js +554 -0
- package/src/components/grid/grid.css +1 -1
- package/src/components/grid/grid.js +14 -4
- package/src/components/launcher/launcher.css +113 -3
- package/src/components/launcher/launcher.js +234 -26
- package/src/components/message/message.css +36 -3
- package/src/components/select/field-adapter.js +9 -4
- package/src/components/select/select.css +161 -10
- package/src/components/select/select.js +361 -48
- package/src/components/table/table.css +85 -0
- package/src/components/table/table.js +426 -31
- package/src/components/tag-picker/tag-picker.css +19 -0
- package/src/components/tag-picker/tag-picker.js +42 -5
- package/src/core/dom.js +21 -1
- package/src/core/util.js +20 -0
- package/src/index.js +9 -2
- package/src/{components/app-rail → internal}/app-rail.js +80 -30
- package/src/zeyos/icons.js +38 -20
- package/src/zeyos/index.js +8 -2
- package/src/zeyos/launcher.js +338 -0
- package/src/zeyos/modules.js +1 -0
- package/src/zeyos/select.js +116 -1
- package/styles/tokens/global.css +1 -1
- package/styles/tokens/modules.css +1 -0
- package/styles/tokens/semantic.css +9 -0
- package/styles/tokens/themes.css +2 -2
- package/styles/zx.css +3 -0
- package/MIGRATION.md +0 -749
- package/dist/types/compat/base.d.ts +0 -156
- package/dist/types/compat/globals.d.ts +0 -20
- package/dist/types/compat/index.d.ts +0 -128
- package/dist/types/compat/map/basic.d.ts +0 -175
- package/dist/types/compat/map/data.d.ts +0 -103
- package/dist/types/compat/map/date-time.d.ts +0 -80
- package/dist/types/compat/map/factory.d.ts +0 -9
- package/dist/types/compat/map/form.d.ts +0 -98
- package/dist/types/compat/map/helpers.d.ts +0 -12
- package/dist/types/compat/map/http.d.ts +0 -36
- package/dist/types/compat/map/layout.d.ts +0 -106
- package/dist/types/compat/map/options.d.ts +0 -80
- package/dist/types/compat/map/select.d.ts +0 -95
- package/dist/types/compat/map/stubs.d.ts +0 -32
- package/dist/types/compat/map/utilities.d.ts +0 -37
- package/dist/types/compat-entry.d.ts +0 -5
- package/dist/zx-compat.esm.js +0 -17848
- package/dist/zx-compat.esm.min.js +0 -1
- package/dist/zx-compat.global.js +0 -29008
- package/dist/zx-compat.global.min.js +0 -3
- package/src/compat/base.js +0 -291
- package/src/compat/globals.js +0 -167
- package/src/compat/index.js +0 -84
- package/src/compat/map/basic.js +0 -464
- package/src/compat/map/data.js +0 -254
- package/src/compat/map/date-time.js +0 -177
- package/src/compat/map/factory.js +0 -45
- package/src/compat/map/form.js +0 -330
- package/src/compat/map/helpers.js +0 -46
- package/src/compat/map/http.js +0 -117
- package/src/compat/map/layout.js +0 -256
- package/src/compat/map/options.js +0 -267
- package/src/compat/map/select.js +0 -198
- package/src/compat/map/stubs.js +0 -26
- package/src/compat/map/utilities.js +0 -113
- package/src/compat-entry.js +0 -11
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
import { Component } from '../../core/component.js';
|
|
2
|
-
import { h } from '../../core/dom.js';
|
|
2
|
+
import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
|
|
3
3
|
import { icon } from '../../core/icons.js';
|
|
4
4
|
import { typeahead } from '../../core/keyboard.js';
|
|
5
5
|
import { position } from '../../core/position.js';
|
|
6
|
-
import { uid } from '../../core/util.js';
|
|
6
|
+
import { isCssColor, uid } from '../../core/util.js';
|
|
7
7
|
import { matchItems } from './filter.js';
|
|
8
8
|
|
|
9
9
|
/** @typedef {string|number|boolean|symbol|bigint|null|undefined} Primitive */
|
|
10
10
|
/** @typedef {Record<string, any>|Primitive} SelectItem */
|
|
11
11
|
/** @typedef {string|((item: SelectItem) => unknown)} SelectValueReader */
|
|
12
12
|
/** @typedef {string|((item: SelectItem) => string)} SelectLabelReader */
|
|
13
|
+
/** @typedef {{id: string|number, label: string, icon?: string|Node, description?: string, group?: string, invoke?: (detail: SelectActionDetail) => void}} SelectAction */
|
|
13
14
|
/**
|
|
14
15
|
* @typedef {Object} SelectOptions
|
|
15
16
|
* @property {SelectItem[]} [items=[]] Available values.
|
|
16
17
|
* @property {SelectItem[]} [fixedItems=[]] Choices pinned above the list, kept through filtering and async loads.
|
|
18
|
+
* @property {string|null} [fixedLabel=null] Optional heading above pinned choices.
|
|
17
19
|
* @property {SelectValueReader} [valueKey='ID'] Item ID property or reader.
|
|
18
20
|
* @property {SelectLabelReader} [labelKey='name'] Item label property or reader.
|
|
19
21
|
* @property {((item: SelectItem) => Node|string)|null} [renderItem=null] Option renderer.
|
|
20
22
|
* @property {((item: SelectItem) => string)|null} [renderValue=null] Selected-value renderer.
|
|
23
|
+
* @property {((item: SelectItem) => Node|string|null)|null} [renderValueAdornment=null] Decorative content before the selected text.
|
|
24
|
+
* @property {((label: string) => Node|string)|null} [renderNone=null] Clear-row renderer.
|
|
25
|
+
* @property {string|null} [noneLabel=null] Clear-row label independent from the input placeholder.
|
|
26
|
+
* @property {SelectAction[]} [actions=[]] Command rows that emit `action` without changing value.
|
|
21
27
|
* @property {unknown} [value=null] Initially selected ID or item.
|
|
22
28
|
* @property {boolean} [disabled=false] Whether interaction is disabled.
|
|
23
29
|
* @property {string} [placeholder=''] Empty control text.
|
|
@@ -34,11 +40,23 @@ import { matchItems } from './filter.js';
|
|
|
34
40
|
* @property {(event: CustomEvent<Record<string, never>>) => void} [onclose] Close listener.
|
|
35
41
|
* @property {(event: CustomEvent<SelectQueryDetail>) => void} [onquery] Async-query listener.
|
|
36
42
|
* @property {(event: CustomEvent<SelectLoadedDetail>) => void} [onloaded] Async-loaded listener.
|
|
43
|
+
* @property {(event: CustomEvent<SelectActionDetail>) => void} [onaction] Preventable command-row listener.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {Object} EntitySelectOptions
|
|
47
|
+
* @property {SelectItem[]} [recent=[]] Choices pinned above ordinary results.
|
|
48
|
+
* @property {string} [recentLabel='Recent'] Heading above recent choices.
|
|
49
|
+
* @property {boolean|SelectAction} [create=false] Optional create command at the bottom.
|
|
50
|
+
* @property {string|((item: SelectItem)=>unknown)} [iconKey='icon'] Item icon reader.
|
|
51
|
+
* @property {string|((item: SelectItem)=>unknown)} [colorKey='color'] Optional icon colour reader.
|
|
52
|
+
* @property {string|((item: SelectItem)=>unknown)} [subtitleKey='description'] Secondary-text reader.
|
|
53
|
+
* @property {((item: SelectItem, context:{selected:boolean})=>Node|string|null)|null} [renderIcon=null] Entity icon renderer.
|
|
37
54
|
*/
|
|
38
55
|
/** @typedef {{value: unknown, item: SelectItem|null}} SelectChangeDetail */
|
|
39
56
|
/** @typedef {{query: string}} SelectQueryDetail */
|
|
40
57
|
/** @typedef {{items: SelectItem[]}} SelectLoadedDetail */
|
|
41
|
-
/** @typedef {{
|
|
58
|
+
/** @typedef {{id: string|number, action: SelectAction, query: string, select: Select}} SelectActionDetail */
|
|
59
|
+
/** @typedef {{item: SelectItem|null, clear: boolean, action: SelectAction|null, element: HTMLElement}} SelectOptionEntry */
|
|
42
60
|
/** @typedef {{silent?: boolean}} SelectSetOptions */
|
|
43
61
|
|
|
44
62
|
const PRIORITY_KEYS = [
|
|
@@ -48,7 +66,19 @@ const PRIORITY_KEYS = [
|
|
|
48
66
|
'priority.high',
|
|
49
67
|
'priority.highest'
|
|
50
68
|
];
|
|
51
|
-
const PRIORITY_FALLBACKS = ['Lowest', 'Low', '
|
|
69
|
+
const PRIORITY_FALLBACKS = ['Lowest', 'Low', 'Medium', 'High', 'Highest'];
|
|
70
|
+
|
|
71
|
+
const DEFAULT_STATUSES = Object.freeze([
|
|
72
|
+
['backlog', 'status.backlog', 'Backlog', 'dotted', 'muted'],
|
|
73
|
+
['todo', 'status.todo', 'Todo', 'ring', 'neutral'],
|
|
74
|
+
['in-progress', 'status.inProgress', 'In Progress', 'progress', 'warning'],
|
|
75
|
+
['in-review', 'status.inReview', 'In Review', 'review', 'info'],
|
|
76
|
+
['ready', 'status.ready', 'Ready to Merge', 'ready', 'accent'],
|
|
77
|
+
['waiting', 'status.waiting', 'Waiting for feedback', 'waiting', 'warning'],
|
|
78
|
+
['done', 'status.done', 'Done', 'done', 'info'],
|
|
79
|
+
['canceled', 'status.canceled', 'Canceled', 'canceled', 'muted'],
|
|
80
|
+
['duplicate', 'status.duplicate', 'Duplicate', 'duplicate', 'muted']
|
|
81
|
+
]);
|
|
52
82
|
|
|
53
83
|
/**
|
|
54
84
|
* APG editable combobox with optional local or asynchronous filtering.
|
|
@@ -67,10 +97,15 @@ export class Select extends Component {
|
|
|
67
97
|
static defaults = {
|
|
68
98
|
items: [],
|
|
69
99
|
fixedItems: [],
|
|
100
|
+
fixedLabel: null,
|
|
70
101
|
valueKey: 'ID',
|
|
71
102
|
labelKey: 'name',
|
|
72
103
|
renderItem: null,
|
|
73
104
|
renderValue: null,
|
|
105
|
+
renderValueAdornment: null,
|
|
106
|
+
renderNone: null,
|
|
107
|
+
noneLabel: null,
|
|
108
|
+
actions: [],
|
|
74
109
|
value: null,
|
|
75
110
|
disabled: false,
|
|
76
111
|
placeholder: '',
|
|
@@ -88,15 +123,12 @@ export class Select extends Component {
|
|
|
88
123
|
render() {
|
|
89
124
|
// Instance state initialized here because render() runs inside the base
|
|
90
125
|
// constructor, before class-field initializers would run (and clobber it).
|
|
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.
|
|
126
|
+
this._items = []; this._visibleItems = []; this._fixedItems = []; this._visibleFixed = []; this._actions = []; 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._snapshot = null;
|
|
92
127
|
this._createdRoot = this.el === null;
|
|
93
128
|
const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
|
|
94
129
|
this.el = root;
|
|
95
130
|
this._root = root;
|
|
96
|
-
|
|
97
|
-
this._rememberAttribute(root, 'data-state');
|
|
98
|
-
this._rememberAttribute(root, 'data-loading');
|
|
99
|
-
this._rememberAttribute(root, 'aria-disabled');
|
|
131
|
+
this._snapshot = this._createdRoot ? null : snapshotTarget(root);
|
|
100
132
|
|
|
101
133
|
const listId = uid('zx-select-list');
|
|
102
134
|
const input = h('input', {
|
|
@@ -112,6 +144,12 @@ export class Select extends Component {
|
|
|
112
144
|
placeholder: String(this.options.placeholder ?? ''),
|
|
113
145
|
readOnly: !this.options.filter
|
|
114
146
|
});
|
|
147
|
+
const valueAdornment = h('span', {
|
|
148
|
+
ref: 'valueAdornment',
|
|
149
|
+
class: 'zx-select__value-adornment',
|
|
150
|
+
ariaHidden: 'true',
|
|
151
|
+
hidden: true
|
|
152
|
+
});
|
|
115
153
|
const clear = h('button', {
|
|
116
154
|
ref: 'clear',
|
|
117
155
|
class: 'zx-icon-btn zx-select__clear',
|
|
@@ -135,7 +173,7 @@ export class Select extends Component {
|
|
|
135
173
|
ariaLabel: this._message('select.toggle', 'Toggle options')
|
|
136
174
|
}, icon('chevron-down', { size: 16 }));
|
|
137
175
|
const control = h('div', { ref: 'control', class: 'zx-select__control' },
|
|
138
|
-
input, clear, loading, toggle
|
|
176
|
+
valueAdornment, input, clear, loading, toggle
|
|
139
177
|
);
|
|
140
178
|
const list = h('div', {
|
|
141
179
|
ref: 'list',
|
|
@@ -155,9 +193,11 @@ export class Select extends Component {
|
|
|
155
193
|
this._visibleItems = this._items.slice();
|
|
156
194
|
this._fixedItems = Array.isArray(this.options.fixedItems) ? this.options.fixedItems.slice() : [];
|
|
157
195
|
this._visibleFixed = this._fixedItems.slice();
|
|
196
|
+
this._actions = normalizeSelectActions(this.options.actions);
|
|
158
197
|
this._disabled = Boolean(this.options.disabled);
|
|
159
198
|
this._handleTypeahead = typeahead(
|
|
160
|
-
() => this._optionEntries.filter((entry) => !entry.clear
|
|
199
|
+
() => this._optionEntries.filter((entry) => !entry.clear && !entry.action)
|
|
200
|
+
.map((entry) => entry.element),
|
|
161
201
|
(_option, index) => {
|
|
162
202
|
if (!this._open) this.open();
|
|
163
203
|
this._setActive(this._itemEntryIndex(index));
|
|
@@ -255,6 +295,14 @@ export class Select extends Component {
|
|
|
255
295
|
return this;
|
|
256
296
|
}
|
|
257
297
|
|
|
298
|
+
/** Replaces command rows without changing the selected value. @param {SelectAction[]} actions @returns {this} */
|
|
299
|
+
setActions(actions) {
|
|
300
|
+
this._actions = normalizeSelectActions(actions);
|
|
301
|
+
this._renderOptions();
|
|
302
|
+
this._position?.update();
|
|
303
|
+
return this;
|
|
304
|
+
}
|
|
305
|
+
|
|
258
306
|
/**
|
|
259
307
|
* Restores the configured initial value.
|
|
260
308
|
* @param {{silent?: boolean}} [options={}] Reset behavior.
|
|
@@ -342,25 +390,96 @@ export class Select extends Component {
|
|
|
342
390
|
value: null,
|
|
343
391
|
items: [],
|
|
344
392
|
renderItem(item) {
|
|
345
|
-
return
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
dataset: { level: item.level },
|
|
349
|
-
ariaHidden: 'true'
|
|
350
|
-
}),
|
|
351
|
-
h('span', {}, String(item.name))
|
|
352
|
-
);
|
|
353
|
-
}
|
|
393
|
+
return priorityContent(item, true);
|
|
394
|
+
},
|
|
395
|
+
renderValueAdornment(item) { return priorityIndicator(item.level); }
|
|
354
396
|
});
|
|
355
|
-
select.setItems(PRIORITY_KEYS.map((key, level) =>
|
|
356
|
-
|
|
357
|
-
name
|
|
358
|
-
level
|
|
359
|
-
|
|
397
|
+
select.setItems(PRIORITY_KEYS.map((key, level) => {
|
|
398
|
+
let name = level === 2 ? select.msg('priority.medium') : key;
|
|
399
|
+
if (name === 'priority.medium') name = select._message(key, PRIORITY_FALLBACKS[level]);
|
|
400
|
+
else if (level !== 2) name = select._message(key, PRIORITY_FALLBACKS[level]);
|
|
401
|
+
return { ID: level, name, level };
|
|
402
|
+
}));
|
|
360
403
|
select.set(requestedValue, { silent: true });
|
|
361
404
|
return select;
|
|
362
405
|
}
|
|
363
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Creates a status picker with shape- and color-distinct workflow indicators.
|
|
409
|
+
* @param {Element|string|null} target Component target.
|
|
410
|
+
* @param {SelectOptions & {items?: Array<Record<string, any>>}} [options={}] Select overrides and status items.
|
|
411
|
+
* @returns {Select}
|
|
412
|
+
*/
|
|
413
|
+
static status(target, options = {}) {
|
|
414
|
+
const { items = null, value = null, ...rest } = options;
|
|
415
|
+
const select = new Select(target, {
|
|
416
|
+
...rest,
|
|
417
|
+
value: null,
|
|
418
|
+
items: [],
|
|
419
|
+
renderItem(item) { return statusContent(item); },
|
|
420
|
+
renderValueAdornment(item) { return statusIndicator(item); }
|
|
421
|
+
});
|
|
422
|
+
const statuses = Array.isArray(items) ? items.map(normalizeStatusItem).filter(Boolean)
|
|
423
|
+
: DEFAULT_STATUSES.map(([ID, key, fallback, indicator, tone], index) => ({
|
|
424
|
+
ID,
|
|
425
|
+
name: select._message(key, fallback),
|
|
426
|
+
indicator,
|
|
427
|
+
tone,
|
|
428
|
+
shortcut: String(index + 1)
|
|
429
|
+
}));
|
|
430
|
+
select.setItems(statuses);
|
|
431
|
+
select.set(value, { silent: true });
|
|
432
|
+
return select;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Creates a rich entity picker with optional Recent choices and a create command.
|
|
437
|
+
* @param {Element|string|null} target Component target.
|
|
438
|
+
* @param {SelectOptions & EntitySelectOptions} [options={}] Entity-preset options.
|
|
439
|
+
* @returns {Select}
|
|
440
|
+
*/
|
|
441
|
+
static entity(target, options = {}) {
|
|
442
|
+
const {
|
|
443
|
+
recent = [], recentLabel = 'Recent', create = false, iconKey = 'icon', colorKey = 'color',
|
|
444
|
+
subtitleKey = 'description', renderIcon = null,
|
|
445
|
+
fixedItems = [], actions = [], renderItem = null, renderValueAdornment = null,
|
|
446
|
+
...rest
|
|
447
|
+
} = options;
|
|
448
|
+
const visual = (item, selected = false) => {
|
|
449
|
+
if (typeof renderIcon === 'function') return renderIcon(item, { selected });
|
|
450
|
+
const value = readSelectItem(item, iconKey);
|
|
451
|
+
if (value && typeof value === 'object' && typeof value.nodeType === 'number') return value.cloneNode(true);
|
|
452
|
+
if (typeof value !== 'string' || !value) return null;
|
|
453
|
+
const color = readSelectItem(item, colorKey);
|
|
454
|
+
return h('span', {
|
|
455
|
+
class: 'zx-select__entity-glyph',
|
|
456
|
+
style: isCssColor(color) ? { '--zx-select-entity-color': String(color) } : null,
|
|
457
|
+
ariaHidden: 'true'
|
|
458
|
+
}, icon(value, { size: 14 }));
|
|
459
|
+
};
|
|
460
|
+
const createAction = create ? normalizeEntityCreateAction(create) : null;
|
|
461
|
+
const select = new Select(target, {
|
|
462
|
+
...rest,
|
|
463
|
+
fixedItems: [...(Array.isArray(recent) ? recent : []), ...(Array.isArray(fixedItems) ? fixedItems : [])],
|
|
464
|
+
fixedLabel: Array.isArray(recent) && recent.length ? recentLabel : rest.fixedLabel ?? null,
|
|
465
|
+
actions: [...(Array.isArray(actions) ? actions : []), ...(createAction ? [createAction] : [])],
|
|
466
|
+
renderItem: renderItem ?? ((item) => {
|
|
467
|
+
const decoration = visual(item, false);
|
|
468
|
+
const subtitle = readSelectItem(item, subtitleKey);
|
|
469
|
+
const copy = h('span', { class: 'zx-select__entity-copy' },
|
|
470
|
+
h('span', { class: 'zx-select__entity-label' }, String(readSelectItem(item, rest.labelKey ?? 'name') ?? '')),
|
|
471
|
+
subtitle == null || subtitle === ''
|
|
472
|
+
? null : h('span', { class: 'zx-select__entity-subtitle' }, String(subtitle)));
|
|
473
|
+
return h('span', { class: 'zx-select__entity' },
|
|
474
|
+
decoration ? h('span', { class: 'zx-select__entity-icon', ariaHidden: 'true' }, decoration) : null,
|
|
475
|
+
copy);
|
|
476
|
+
}),
|
|
477
|
+
renderValueAdornment: renderValueAdornment ?? ((item) => visual(item, true))
|
|
478
|
+
});
|
|
479
|
+
select.el.dataset.preset = 'entity';
|
|
480
|
+
return select;
|
|
481
|
+
}
|
|
482
|
+
|
|
364
483
|
/**
|
|
365
484
|
* Creates the record-permission picker: the fixed Private and Public choices above the groups a
|
|
366
485
|
* record can be shared with. The value is the tri-state ZeyOS stores — `false` for private,
|
|
@@ -373,12 +492,16 @@ export class Select extends Component {
|
|
|
373
492
|
* @returns {Select}
|
|
374
493
|
*/
|
|
375
494
|
static permission(target, options = {}) {
|
|
376
|
-
const {
|
|
495
|
+
const {
|
|
496
|
+
groups = [], value = true, valueKey = 'ID', labelKey = 'name', filter = 'local', ...rest
|
|
497
|
+
} = options;
|
|
377
498
|
const loader = typeof groups === 'function' ? groups : null;
|
|
378
499
|
const shareable = loader ? [] : groups;
|
|
379
500
|
const choices = [{ ID: false, name: 'Private' }, { ID: true, name: 'Public' }];
|
|
380
501
|
const select = new Select(target, {
|
|
381
|
-
|
|
502
|
+
// An async group loader is the data source and cannot be disconnected by an incidental
|
|
503
|
+
// `filter` override. Array-backed groups still honor local/disabled filtering.
|
|
504
|
+
filter: loader ?? filter,
|
|
382
505
|
...rest,
|
|
383
506
|
value: null,
|
|
384
507
|
items: shareable,
|
|
@@ -401,19 +524,23 @@ export class Select extends Component {
|
|
|
401
524
|
this._position?.destroy();
|
|
402
525
|
this._position = null;
|
|
403
526
|
const root = /** @type {HTMLElement} */ (this.el);
|
|
404
|
-
if (!this._createdRoot && root)
|
|
405
|
-
root.replaceChildren(...this._originalChildren);
|
|
406
|
-
for (const [name, value] of this._originalAttributes) {
|
|
407
|
-
if (value === null) root.removeAttribute(name);
|
|
408
|
-
else root.setAttribute(name, value);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
527
|
+
if (!this._createdRoot && root) restoreTarget(root, this._snapshot);
|
|
411
528
|
super.destroy();
|
|
412
529
|
}
|
|
413
530
|
|
|
414
531
|
/** @param {KeyboardEvent} event @returns {void} */
|
|
415
532
|
_onKeydown(event) {
|
|
416
533
|
if (this._disabled) return;
|
|
534
|
+
if (this._open && isPrintable(event)) {
|
|
535
|
+
const shortcut = this._optionEntries.findIndex((entry) => !entry.clear && !entry.action
|
|
536
|
+
&& String((/** @type {Record<string, any>|null} */ (entry.item))?.shortcut ?? '') === event.key);
|
|
537
|
+
if (shortcut >= 0) {
|
|
538
|
+
event.preventDefault();
|
|
539
|
+
this._setActive(shortcut);
|
|
540
|
+
this._chooseEntry(this._optionEntries[shortcut]);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
417
544
|
if (event.key === 'ArrowDown') {
|
|
418
545
|
event.preventDefault();
|
|
419
546
|
if (!this._open) this.open();
|
|
@@ -459,6 +586,7 @@ export class Select extends Component {
|
|
|
459
586
|
_onInput() {
|
|
460
587
|
if (!this.options.filter) return;
|
|
461
588
|
const query = /** @type {HTMLInputElement} */ (this.refs.input).value;
|
|
589
|
+
this._syncValueAdornment();
|
|
462
590
|
if (!this._open) this.open();
|
|
463
591
|
if (this.options.filter === 'local') {
|
|
464
592
|
this._visibleFixed = this._matchFixed(query);
|
|
@@ -481,6 +609,19 @@ export class Select extends Component {
|
|
|
481
609
|
/** @param {SelectOptionEntry|undefined} entry @returns {void} */
|
|
482
610
|
_chooseEntry(entry) {
|
|
483
611
|
if (!entry) return;
|
|
612
|
+
if (entry.action) {
|
|
613
|
+
const detail = {
|
|
614
|
+
id: entry.action.id,
|
|
615
|
+
action: entry.action,
|
|
616
|
+
query: /** @type {HTMLInputElement} */ (this.refs.input).value,
|
|
617
|
+
select: this
|
|
618
|
+
};
|
|
619
|
+
const action = this.emit('action', detail);
|
|
620
|
+
if (!action.defaultPrevented) entry.action.invoke?.(detail);
|
|
621
|
+
this.close();
|
|
622
|
+
this.focus();
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
484
625
|
this.set(entry.clear ? null : entry.item);
|
|
485
626
|
this.close();
|
|
486
627
|
this.focus();
|
|
@@ -516,16 +657,22 @@ export class Select extends Component {
|
|
|
516
657
|
list.replaceChildren();
|
|
517
658
|
this._optionEntries = [];
|
|
518
659
|
let lastGroup = Symbol('initial-group');
|
|
660
|
+
const fixed = this._uniqueItems(this._visibleFixed);
|
|
661
|
+
const items = this._uniqueItems(this._visibleItems, fixed);
|
|
519
662
|
|
|
520
663
|
if (this.options.clearable) {
|
|
521
|
-
this._appendOption(null, true, this.options.
|
|
664
|
+
this._appendOption(null, true, this.options.noneLabel
|
|
665
|
+
?? this._message('select.none', 'No selection'));
|
|
666
|
+
}
|
|
667
|
+
if (fixed.length > 0 && this.options.fixedLabel) {
|
|
668
|
+
list.append(h('div', { class: 'zx-select__group', role: 'presentation' }, String(this.options.fixedLabel)));
|
|
522
669
|
}
|
|
523
|
-
for (const item of
|
|
670
|
+
for (const item of fixed) this._appendOption(item, false);
|
|
524
671
|
// A group heading already divides the two sections, and a rule right above one reads as a slip.
|
|
525
|
-
if (
|
|
672
|
+
if (fixed.length > 0 && items.length > 0 && !this.options.groupKey) {
|
|
526
673
|
list.append(h('div', { class: 'zx-select__separator', role: 'presentation' }));
|
|
527
674
|
}
|
|
528
|
-
for (const item of
|
|
675
|
+
for (const item of items) {
|
|
529
676
|
if (this.options.groupKey) {
|
|
530
677
|
const group = this._read(item, this.options.groupKey);
|
|
531
678
|
if (!Object.is(group, lastGroup)) {
|
|
@@ -538,7 +685,24 @@ export class Select extends Component {
|
|
|
538
685
|
}
|
|
539
686
|
this._appendOption(item, false);
|
|
540
687
|
}
|
|
541
|
-
if (this.
|
|
688
|
+
if (this._actions.length > 0) {
|
|
689
|
+
if (fixed.length > 0 || items.length > 0 || this.options.clearable) {
|
|
690
|
+
list.append(h('div', { class: 'zx-select__separator', role: 'presentation' }));
|
|
691
|
+
}
|
|
692
|
+
let actionGroup = Symbol('initial-action-group');
|
|
693
|
+
for (const action of this._actions) {
|
|
694
|
+
if (action.group && action.group !== actionGroup) {
|
|
695
|
+
list.append(h('div', {
|
|
696
|
+
class: 'zx-select__group',
|
|
697
|
+
role: 'presentation'
|
|
698
|
+
}, action.group));
|
|
699
|
+
actionGroup = action.group;
|
|
700
|
+
}
|
|
701
|
+
this._appendAction(action);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (items.length === 0 && fixed.length === 0
|
|
705
|
+
&& this._actions.length === 0 && !this._loading) {
|
|
542
706
|
list.append(h('div', {
|
|
543
707
|
class: 'zx-select__empty',
|
|
544
708
|
role: 'status'
|
|
@@ -552,7 +716,9 @@ export class Select extends Component {
|
|
|
552
716
|
const selected = clear ? this._selected === null :
|
|
553
717
|
this._selected !== null && Object.is(this._itemValue(item), this.value);
|
|
554
718
|
const index = this._optionEntries.length;
|
|
555
|
-
const content = clear
|
|
719
|
+
const content = clear && typeof this.options.renderNone === 'function'
|
|
720
|
+
? normalizeRenderedContent(this.options.renderNone(String(label ?? '')))
|
|
721
|
+
: clear ? String(label ?? '') : this._renderItem(item);
|
|
556
722
|
const option = h('div', {
|
|
557
723
|
class: 'zx-select__option',
|
|
558
724
|
id: uid('zx-select-option'),
|
|
@@ -562,15 +728,33 @@ export class Select extends Component {
|
|
|
562
728
|
}, h('span', { class: 'zx-select__option-content' }, content));
|
|
563
729
|
if (selected) option.append(icon('check', { size: 15 }));
|
|
564
730
|
this.refs.list.append(option);
|
|
565
|
-
this._optionEntries.push({ item, clear, element: option });
|
|
731
|
+
this._optionEntries.push({ item, clear, action: null, element: option });
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** @param {SelectAction} action @returns {void} */
|
|
735
|
+
_appendAction(action) {
|
|
736
|
+
const index = this._optionEntries.length;
|
|
737
|
+
const visual = selectActionIcon(action.icon);
|
|
738
|
+
const option = h('div', {
|
|
739
|
+
class: 'zx-select__option zx-select__action',
|
|
740
|
+
id: uid('zx-select-option'),
|
|
741
|
+
role: 'option',
|
|
742
|
+
ariaSelected: 'false',
|
|
743
|
+
dataset: { index, kind: 'action' }
|
|
744
|
+
}, h('span', { class: 'zx-select__option-content zx-select__action-content' },
|
|
745
|
+
visual ? h('span', { class: 'zx-select__action-icon', ariaHidden: 'true' }, visual) : null,
|
|
746
|
+
h('span', { class: 'zx-select__action-copy' },
|
|
747
|
+
h('span', { class: 'zx-select__action-label' }, action.label),
|
|
748
|
+
action.description ? h('span', { class: 'zx-select__action-description' }, action.description) : null)));
|
|
749
|
+
this.refs.list.append(option);
|
|
750
|
+
this._optionEntries.push({ item: null, clear: false, action, element: option });
|
|
566
751
|
}
|
|
567
752
|
|
|
568
753
|
/** @param {SelectItem|null} item @returns {Node|string} */
|
|
569
754
|
_renderItem(item) {
|
|
570
755
|
if (typeof this.options.renderItem === 'function') {
|
|
571
756
|
const rendered = this.options.renderItem(item);
|
|
572
|
-
return rendered
|
|
573
|
-
/** @type {Node} */ (rendered) : String(rendered ?? '');
|
|
757
|
+
return normalizeRenderedContent(rendered);
|
|
574
758
|
}
|
|
575
759
|
return this._itemLabel(item);
|
|
576
760
|
}
|
|
@@ -645,9 +829,30 @@ export class Select extends Component {
|
|
|
645
829
|
input.value = this._selected === null ? '' :
|
|
646
830
|
(typeof this.options.renderValue === 'function' ?
|
|
647
831
|
String(this.options.renderValue(this._selected) ?? '') : this._itemLabel(this._selected));
|
|
832
|
+
this._syncValueAdornment();
|
|
648
833
|
this.refs.clear.hidden = !this.options.clearable || this._selected === null;
|
|
649
834
|
}
|
|
650
835
|
|
|
836
|
+
/** @returns {void} */
|
|
837
|
+
_syncValueAdornment() {
|
|
838
|
+
const host = this.refs.valueAdornment;
|
|
839
|
+
if (!host) return;
|
|
840
|
+
const selectedLabel = this._selected === null ? '' :
|
|
841
|
+
(typeof this.options.renderValue === 'function'
|
|
842
|
+
? String(this.options.renderValue(this._selected) ?? '') : this._itemLabel(this._selected));
|
|
843
|
+
const query = String((/** @type {HTMLInputElement} */ (this.refs.input))?.value ?? '');
|
|
844
|
+
if (this._selected === null || typeof this.options.renderValueAdornment !== 'function'
|
|
845
|
+
|| Boolean(this.options.filter && query !== selectedLabel)) {
|
|
846
|
+
host.replaceChildren();
|
|
847
|
+
host.hidden = true;
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
const rendered = this.options.renderValueAdornment(this._selected);
|
|
851
|
+
const content = normalizeRenderedContent(rendered);
|
|
852
|
+
host.replaceChildren(content instanceof Node ? content : document.createTextNode(content));
|
|
853
|
+
host.hidden = rendered == null || (typeof content === 'string' && content === '');
|
|
854
|
+
}
|
|
855
|
+
|
|
651
856
|
/** @returns {void} */
|
|
652
857
|
_syncDisabled() {
|
|
653
858
|
const input = /** @type {HTMLInputElement} */ (this.refs.input);
|
|
@@ -702,6 +907,17 @@ export class Select extends Component {
|
|
|
702
907
|
return matchItems(this._fixedItems, query, this._searchKeys());
|
|
703
908
|
}
|
|
704
909
|
|
|
910
|
+
/** De-duplicates choices by public value while preserving caller order. @param {SelectItem[]} items @param {SelectItem[]} [excluded=[]] @returns {SelectItem[]} */
|
|
911
|
+
_uniqueItems(items, excluded = []) {
|
|
912
|
+
const values = excluded.map((item) => this._itemValue(item));
|
|
913
|
+
return items.filter((item) => {
|
|
914
|
+
const value = this._itemValue(item);
|
|
915
|
+
if (values.some((candidate) => Object.is(candidate, value))) return false;
|
|
916
|
+
values.push(value);
|
|
917
|
+
return true;
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
705
921
|
/** @returns {number} */
|
|
706
922
|
_selectedEntryIndex() {
|
|
707
923
|
if (this._selected === null) return this.options.clearable ? 0 : -1;
|
|
@@ -715,11 +931,6 @@ export class Select extends Component {
|
|
|
715
931
|
return itemIndex + (this.options.clearable ? 1 : 0);
|
|
716
932
|
}
|
|
717
933
|
|
|
718
|
-
/** @param {string} name @returns {void} */
|
|
719
|
-
_rememberAttribute(element, name) {
|
|
720
|
-
this._originalAttributes.set(name, element.getAttribute(name));
|
|
721
|
-
}
|
|
722
|
-
|
|
723
934
|
/** @param {string} key @param {string} fallback @returns {string} */
|
|
724
935
|
_message(key, fallback) {
|
|
725
936
|
const message = this.msg(key);
|
|
@@ -727,11 +938,113 @@ export class Select extends Component {
|
|
|
727
938
|
}
|
|
728
939
|
}
|
|
729
940
|
|
|
941
|
+
/** @param {SelectItem} item @param {unknown} reader @returns {unknown} */
|
|
942
|
+
function readSelectItem(item, reader) {
|
|
943
|
+
if (typeof reader === 'function') return reader(item);
|
|
944
|
+
if (item !== null && typeof item === 'object' && typeof reader === 'string') return item[reader];
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** @param {boolean|SelectAction} value @returns {SelectAction} */
|
|
949
|
+
function normalizeEntityCreateAction(value) {
|
|
950
|
+
const descriptor = value === true ? {} : value;
|
|
951
|
+
return {
|
|
952
|
+
id: descriptor.id ?? 'create', label: descriptor.label ?? 'Create new…',
|
|
953
|
+
description: descriptor.description, group: descriptor.group ?? 'New',
|
|
954
|
+
icon: descriptor.icon ?? 'plus', invoke: descriptor.invoke
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** @param {unknown} value @returns {boolean} */
|
|
959
|
+
|
|
960
|
+
/** @param {unknown} rendered @returns {Node|string} */
|
|
961
|
+
function normalizeRenderedContent(rendered) {
|
|
962
|
+
return rendered && typeof rendered === 'object' && 'nodeType' in rendered
|
|
963
|
+
? /** @type {Node} */ (rendered) : String(rendered ?? '');
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** @param {unknown} actions @returns {SelectAction[]} */
|
|
967
|
+
function normalizeSelectActions(actions) {
|
|
968
|
+
if (!Array.isArray(actions)) return [];
|
|
969
|
+
return actions.filter((action) => action && typeof action === 'object'
|
|
970
|
+
&& action.id != null && action.label != null).map((action) => ({
|
|
971
|
+
...action,
|
|
972
|
+
label: String(action.label),
|
|
973
|
+
description: action.description == null ? undefined : String(action.description),
|
|
974
|
+
group: action.group == null ? undefined : String(action.group)
|
|
975
|
+
}));
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** @param {unknown} visual @returns {Node|null} */
|
|
979
|
+
function selectActionIcon(visual) {
|
|
980
|
+
if (typeof visual === 'string') return icon(visual);
|
|
981
|
+
if (visual instanceof Node) return visual.cloneNode(true);
|
|
982
|
+
return null;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** @param {Record<string, any>} item @param {boolean} label @returns {Node} */
|
|
986
|
+
function priorityContent(item, label) {
|
|
987
|
+
return h('span', { class: 'zx-select__priority' },
|
|
988
|
+
priorityIndicator(item.level),
|
|
989
|
+
label ? h('span', {}, String(item.name)) : null);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** @param {unknown} level @returns {Node} */
|
|
993
|
+
function priorityIndicator(level) {
|
|
994
|
+
const normalized = Math.max(0, Math.min(4, Math.trunc(Number(level) || 0)));
|
|
995
|
+
return h('span', {
|
|
996
|
+
class: 'zx-select__priority-icon',
|
|
997
|
+
dataset: { level: normalized },
|
|
998
|
+
ariaHidden: 'true'
|
|
999
|
+
}, Array.from({ length: 5 }, (_, index) => h('span', {
|
|
1000
|
+
dataset: { filled: String(index <= normalized) }
|
|
1001
|
+
})));
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** @param {unknown} input @returns {Record<string, any>|null} */
|
|
1005
|
+
function normalizeStatusItem(input) {
|
|
1006
|
+
if (!input || typeof input !== 'object' || input.ID == null || input.name == null) return null;
|
|
1007
|
+
const indicators = ['dotted', 'ring', 'progress', 'review', 'ready', 'waiting', 'done', 'canceled', 'duplicate'];
|
|
1008
|
+
const tones = ['muted', 'neutral', 'warning', 'info', 'accent', 'success', 'danger'];
|
|
1009
|
+
return {
|
|
1010
|
+
...input,
|
|
1011
|
+
name: String(input.name),
|
|
1012
|
+
indicator: indicators.includes(String(input.indicator)) ? String(input.indicator) : 'ring',
|
|
1013
|
+
tone: tones.includes(String(input.tone)) ? String(input.tone) : 'muted',
|
|
1014
|
+
description: input.description == null ? '' : String(input.description),
|
|
1015
|
+
shortcut: input.shortcut == null ? '' : String(input.shortcut)
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** @param {Record<string, any>} item @returns {Node} */
|
|
1020
|
+
function statusContent(item) {
|
|
1021
|
+
return h('span', { class: 'zx-select__status' },
|
|
1022
|
+
statusIndicator(item),
|
|
1023
|
+
h('span', { class: 'zx-select__status-copy' },
|
|
1024
|
+
h('span', { class: 'zx-select__status-label' }, String(item.name)),
|
|
1025
|
+
item.description ? h('span', { class: 'zx-select__status-description' }, item.description) : null),
|
|
1026
|
+
item.shortcut ? h('kbd', { class: 'zx-select__status-shortcut' }, item.shortcut) : null);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** @param {Record<string, any>} item @returns {Node} */
|
|
1030
|
+
function statusIndicator(item) {
|
|
1031
|
+
const indicator = String(item.indicator ?? 'ring');
|
|
1032
|
+
const marker = h('span', {
|
|
1033
|
+
class: 'zx-select__status-indicator',
|
|
1034
|
+
dataset: { indicator, tone: String(item.tone ?? 'muted') },
|
|
1035
|
+
ariaHidden: 'true'
|
|
1036
|
+
});
|
|
1037
|
+
if (indicator === 'done') marker.append(icon('check', { size: 8 }));
|
|
1038
|
+
if (indicator === 'canceled') marker.append(icon('x', { size: 7 }));
|
|
1039
|
+
return marker;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
730
1042
|
/** Change emitted when the selected value changes. @event Select#change @type {CustomEvent<SelectChangeDetail>} */
|
|
731
1043
|
/** Panel-open notification. @event Select#open @type {CustomEvent<Record<string, never>>} */
|
|
732
1044
|
/** Panel-close notification. @event Select#close @type {CustomEvent<Record<string, never>>} */
|
|
733
1045
|
/** Async-query start notification. @event Select#query @type {CustomEvent<SelectQueryDetail>} */
|
|
734
1046
|
/** Async results notification. @event Select#loaded @type {CustomEvent<SelectLoadedDetail>} */
|
|
1047
|
+
/** Command-row activation; preventing it suppresses the action callback. @event Select#action @type {CustomEvent<SelectActionDetail>} */
|
|
735
1048
|
|
|
736
1049
|
/**
|
|
737
1050
|
* Reads a value off an item through a property name or a reader function.
|