@zeyos/zx 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/DESIGN-SYSTEM.md +28 -68
- package/README.md +16 -37
- 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/launcher/launcher.d.ts +65 -6
- package/dist/types/components/select/select.d.ts +51 -0
- package/dist/types/components/table/table.d.ts +4 -3
- package/dist/types/core/dom.d.ts +8 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/{components/app-rail → internal}/app-rail.d.ts +34 -19
- package/dist/types/zeyos/icons.d.ts +5 -0
- package/dist/types/zeyos/index.d.ts +2 -1
- package/dist/types/zeyos/launcher.d.ts +147 -0
- package/dist/types/zeyos/select.d.ts +39 -0
- package/dist/zx-zeyos.esm.js +1324 -46
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +400 -12
- package/dist/zx.esm.js +1059 -189
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +1059 -189
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +258 -145
- package/docs/llms.md +93 -112
- package/docs/llms.txt +16 -15
- package/package.json +8 -17
- 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/grid/grid.js +10 -3
- package/src/components/launcher/launcher.css +113 -3
- package/src/components/launcher/launcher.js +234 -26
- package/src/components/select/field-adapter.js +9 -4
- package/src/components/select/select.css +144 -10
- package/src/components/select/select.js +259 -27
- package/src/components/table/table.js +8 -7
- package/src/core/dom.js +20 -0
- package/src/index.js +2 -1
- package/src/{components/app-rail → internal}/app-rail.js +80 -30
- package/src/zeyos/icons.js +8 -3
- package/src/zeyos/index.js +7 -1
- package/src/zeyos/launcher.js +338 -0
- package/src/zeyos/modules.js +1 -0
- package/src/zeyos/select.js +115 -1
- package/styles/tokens/global.css +1 -1
- package/styles/tokens/modules.css +1 -0
- package/styles/zx.css +1 -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
|
@@ -10,6 +10,7 @@ import { matchItems } from './filter.js';
|
|
|
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.
|
|
@@ -18,6 +19,10 @@ import { matchItems } from './filter.js';
|
|
|
18
19
|
* @property {SelectLabelReader} [labelKey='name'] Item label property or reader.
|
|
19
20
|
* @property {((item: SelectItem) => Node|string)|null} [renderItem=null] Option renderer.
|
|
20
21
|
* @property {((item: SelectItem) => string)|null} [renderValue=null] Selected-value renderer.
|
|
22
|
+
* @property {((item: SelectItem) => Node|string|null)|null} [renderValueAdornment=null] Decorative content before the selected text.
|
|
23
|
+
* @property {((label: string) => Node|string)|null} [renderNone=null] Clear-row renderer.
|
|
24
|
+
* @property {string|null} [noneLabel=null] Clear-row label independent from the input placeholder.
|
|
25
|
+
* @property {SelectAction[]} [actions=[]] Command rows that emit `action` without changing value.
|
|
21
26
|
* @property {unknown} [value=null] Initially selected ID or item.
|
|
22
27
|
* @property {boolean} [disabled=false] Whether interaction is disabled.
|
|
23
28
|
* @property {string} [placeholder=''] Empty control text.
|
|
@@ -34,11 +39,13 @@ import { matchItems } from './filter.js';
|
|
|
34
39
|
* @property {(event: CustomEvent<Record<string, never>>) => void} [onclose] Close listener.
|
|
35
40
|
* @property {(event: CustomEvent<SelectQueryDetail>) => void} [onquery] Async-query listener.
|
|
36
41
|
* @property {(event: CustomEvent<SelectLoadedDetail>) => void} [onloaded] Async-loaded listener.
|
|
42
|
+
* @property {(event: CustomEvent<SelectActionDetail>) => void} [onaction] Preventable command-row listener.
|
|
37
43
|
*/
|
|
38
44
|
/** @typedef {{value: unknown, item: SelectItem|null}} SelectChangeDetail */
|
|
39
45
|
/** @typedef {{query: string}} SelectQueryDetail */
|
|
40
46
|
/** @typedef {{items: SelectItem[]}} SelectLoadedDetail */
|
|
41
|
-
/** @typedef {{
|
|
47
|
+
/** @typedef {{id: string|number, action: SelectAction, query: string, select: Select}} SelectActionDetail */
|
|
48
|
+
/** @typedef {{item: SelectItem|null, clear: boolean, action: SelectAction|null, element: HTMLElement}} SelectOptionEntry */
|
|
42
49
|
/** @typedef {{silent?: boolean}} SelectSetOptions */
|
|
43
50
|
|
|
44
51
|
const PRIORITY_KEYS = [
|
|
@@ -48,7 +55,19 @@ const PRIORITY_KEYS = [
|
|
|
48
55
|
'priority.high',
|
|
49
56
|
'priority.highest'
|
|
50
57
|
];
|
|
51
|
-
const PRIORITY_FALLBACKS = ['Lowest', 'Low', '
|
|
58
|
+
const PRIORITY_FALLBACKS = ['Lowest', 'Low', 'Medium', 'High', 'Highest'];
|
|
59
|
+
|
|
60
|
+
const DEFAULT_STATUSES = Object.freeze([
|
|
61
|
+
['backlog', 'status.backlog', 'Backlog', 'dotted', 'muted'],
|
|
62
|
+
['todo', 'status.todo', 'Todo', 'ring', 'neutral'],
|
|
63
|
+
['in-progress', 'status.inProgress', 'In Progress', 'progress', 'warning'],
|
|
64
|
+
['in-review', 'status.inReview', 'In Review', 'review', 'info'],
|
|
65
|
+
['ready', 'status.ready', 'Ready to Merge', 'ready', 'accent'],
|
|
66
|
+
['waiting', 'status.waiting', 'Waiting for feedback', 'waiting', 'warning'],
|
|
67
|
+
['done', 'status.done', 'Done', 'done', 'info'],
|
|
68
|
+
['canceled', 'status.canceled', 'Canceled', 'canceled', 'muted'],
|
|
69
|
+
['duplicate', 'status.duplicate', 'Duplicate', 'duplicate', 'muted']
|
|
70
|
+
]);
|
|
52
71
|
|
|
53
72
|
/**
|
|
54
73
|
* APG editable combobox with optional local or asynchronous filtering.
|
|
@@ -71,6 +90,10 @@ export class Select extends Component {
|
|
|
71
90
|
labelKey: 'name',
|
|
72
91
|
renderItem: null,
|
|
73
92
|
renderValue: null,
|
|
93
|
+
renderValueAdornment: null,
|
|
94
|
+
renderNone: null,
|
|
95
|
+
noneLabel: null,
|
|
96
|
+
actions: [],
|
|
74
97
|
value: null,
|
|
75
98
|
disabled: false,
|
|
76
99
|
placeholder: '',
|
|
@@ -88,7 +111,7 @@ export class Select extends Component {
|
|
|
88
111
|
render() {
|
|
89
112
|
// Instance state initialized here because render() runs inside the base
|
|
90
113
|
// 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._originalChildren = []; this._originalAttributes = new Map();
|
|
114
|
+
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._originalChildren = []; this._originalAttributes = new Map();
|
|
92
115
|
this._createdRoot = this.el === null;
|
|
93
116
|
const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
|
|
94
117
|
this.el = root;
|
|
@@ -112,6 +135,12 @@ export class Select extends Component {
|
|
|
112
135
|
placeholder: String(this.options.placeholder ?? ''),
|
|
113
136
|
readOnly: !this.options.filter
|
|
114
137
|
});
|
|
138
|
+
const valueAdornment = h('span', {
|
|
139
|
+
ref: 'valueAdornment',
|
|
140
|
+
class: 'zx-select__value-adornment',
|
|
141
|
+
ariaHidden: 'true',
|
|
142
|
+
hidden: true
|
|
143
|
+
});
|
|
115
144
|
const clear = h('button', {
|
|
116
145
|
ref: 'clear',
|
|
117
146
|
class: 'zx-icon-btn zx-select__clear',
|
|
@@ -135,7 +164,7 @@ export class Select extends Component {
|
|
|
135
164
|
ariaLabel: this._message('select.toggle', 'Toggle options')
|
|
136
165
|
}, icon('chevron-down', { size: 16 }));
|
|
137
166
|
const control = h('div', { ref: 'control', class: 'zx-select__control' },
|
|
138
|
-
input, clear, loading, toggle
|
|
167
|
+
valueAdornment, input, clear, loading, toggle
|
|
139
168
|
);
|
|
140
169
|
const list = h('div', {
|
|
141
170
|
ref: 'list',
|
|
@@ -155,9 +184,11 @@ export class Select extends Component {
|
|
|
155
184
|
this._visibleItems = this._items.slice();
|
|
156
185
|
this._fixedItems = Array.isArray(this.options.fixedItems) ? this.options.fixedItems.slice() : [];
|
|
157
186
|
this._visibleFixed = this._fixedItems.slice();
|
|
187
|
+
this._actions = normalizeSelectActions(this.options.actions);
|
|
158
188
|
this._disabled = Boolean(this.options.disabled);
|
|
159
189
|
this._handleTypeahead = typeahead(
|
|
160
|
-
() => this._optionEntries.filter((entry) => !entry.clear
|
|
190
|
+
() => this._optionEntries.filter((entry) => !entry.clear && !entry.action)
|
|
191
|
+
.map((entry) => entry.element),
|
|
161
192
|
(_option, index) => {
|
|
162
193
|
if (!this._open) this.open();
|
|
163
194
|
this._setActive(this._itemEntryIndex(index));
|
|
@@ -255,6 +286,14 @@ export class Select extends Component {
|
|
|
255
286
|
return this;
|
|
256
287
|
}
|
|
257
288
|
|
|
289
|
+
/** Replaces command rows without changing the selected value. @param {SelectAction[]} actions @returns {this} */
|
|
290
|
+
setActions(actions) {
|
|
291
|
+
this._actions = normalizeSelectActions(actions);
|
|
292
|
+
this._renderOptions();
|
|
293
|
+
this._position?.update();
|
|
294
|
+
return this;
|
|
295
|
+
}
|
|
296
|
+
|
|
258
297
|
/**
|
|
259
298
|
* Restores the configured initial value.
|
|
260
299
|
* @param {{silent?: boolean}} [options={}] Reset behavior.
|
|
@@ -342,25 +381,48 @@ export class Select extends Component {
|
|
|
342
381
|
value: null,
|
|
343
382
|
items: [],
|
|
344
383
|
renderItem(item) {
|
|
345
|
-
return
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
dataset: { level: item.level },
|
|
349
|
-
ariaHidden: 'true'
|
|
350
|
-
}),
|
|
351
|
-
h('span', {}, String(item.name))
|
|
352
|
-
);
|
|
353
|
-
}
|
|
384
|
+
return priorityContent(item, true);
|
|
385
|
+
},
|
|
386
|
+
renderValueAdornment(item) { return priorityIndicator(item.level); }
|
|
354
387
|
});
|
|
355
|
-
select.setItems(PRIORITY_KEYS.map((key, level) =>
|
|
356
|
-
|
|
357
|
-
name
|
|
358
|
-
level
|
|
359
|
-
|
|
388
|
+
select.setItems(PRIORITY_KEYS.map((key, level) => {
|
|
389
|
+
let name = level === 2 ? select.msg('priority.medium') : key;
|
|
390
|
+
if (name === 'priority.medium') name = select._message(key, PRIORITY_FALLBACKS[level]);
|
|
391
|
+
else if (level !== 2) name = select._message(key, PRIORITY_FALLBACKS[level]);
|
|
392
|
+
return { ID: level, name, level };
|
|
393
|
+
}));
|
|
360
394
|
select.set(requestedValue, { silent: true });
|
|
361
395
|
return select;
|
|
362
396
|
}
|
|
363
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Creates a status picker with shape- and color-distinct workflow indicators.
|
|
400
|
+
* @param {Element|string|null} target Component target.
|
|
401
|
+
* @param {SelectOptions & {items?: Array<Record<string, any>>}} [options={}] Select overrides and status items.
|
|
402
|
+
* @returns {Select}
|
|
403
|
+
*/
|
|
404
|
+
static status(target, options = {}) {
|
|
405
|
+
const { items = null, value = null, ...rest } = options;
|
|
406
|
+
const select = new Select(target, {
|
|
407
|
+
...rest,
|
|
408
|
+
value: null,
|
|
409
|
+
items: [],
|
|
410
|
+
renderItem(item) { return statusContent(item); },
|
|
411
|
+
renderValueAdornment(item) { return statusIndicator(item); }
|
|
412
|
+
});
|
|
413
|
+
const statuses = Array.isArray(items) ? items.map(normalizeStatusItem).filter(Boolean)
|
|
414
|
+
: DEFAULT_STATUSES.map(([ID, key, fallback, indicator, tone], index) => ({
|
|
415
|
+
ID,
|
|
416
|
+
name: select._message(key, fallback),
|
|
417
|
+
indicator,
|
|
418
|
+
tone,
|
|
419
|
+
shortcut: String(index + 1)
|
|
420
|
+
}));
|
|
421
|
+
select.setItems(statuses);
|
|
422
|
+
select.set(value, { silent: true });
|
|
423
|
+
return select;
|
|
424
|
+
}
|
|
425
|
+
|
|
364
426
|
/**
|
|
365
427
|
* Creates the record-permission picker: the fixed Private and Public choices above the groups a
|
|
366
428
|
* record can be shared with. The value is the tri-state ZeyOS stores — `false` for private,
|
|
@@ -373,12 +435,16 @@ export class Select extends Component {
|
|
|
373
435
|
* @returns {Select}
|
|
374
436
|
*/
|
|
375
437
|
static permission(target, options = {}) {
|
|
376
|
-
const {
|
|
438
|
+
const {
|
|
439
|
+
groups = [], value = true, valueKey = 'ID', labelKey = 'name', filter = 'local', ...rest
|
|
440
|
+
} = options;
|
|
377
441
|
const loader = typeof groups === 'function' ? groups : null;
|
|
378
442
|
const shareable = loader ? [] : groups;
|
|
379
443
|
const choices = [{ ID: false, name: 'Private' }, { ID: true, name: 'Public' }];
|
|
380
444
|
const select = new Select(target, {
|
|
381
|
-
|
|
445
|
+
// An async group loader is the data source and cannot be disconnected by an incidental
|
|
446
|
+
// `filter` override. Array-backed groups still honor local/disabled filtering.
|
|
447
|
+
filter: loader ?? filter,
|
|
382
448
|
...rest,
|
|
383
449
|
value: null,
|
|
384
450
|
items: shareable,
|
|
@@ -414,6 +480,16 @@ export class Select extends Component {
|
|
|
414
480
|
/** @param {KeyboardEvent} event @returns {void} */
|
|
415
481
|
_onKeydown(event) {
|
|
416
482
|
if (this._disabled) return;
|
|
483
|
+
if (this._open && isPrintable(event)) {
|
|
484
|
+
const shortcut = this._optionEntries.findIndex((entry) => !entry.clear && !entry.action
|
|
485
|
+
&& String((/** @type {Record<string, any>|null} */ (entry.item))?.shortcut ?? '') === event.key);
|
|
486
|
+
if (shortcut >= 0) {
|
|
487
|
+
event.preventDefault();
|
|
488
|
+
this._setActive(shortcut);
|
|
489
|
+
this._chooseEntry(this._optionEntries[shortcut]);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
417
493
|
if (event.key === 'ArrowDown') {
|
|
418
494
|
event.preventDefault();
|
|
419
495
|
if (!this._open) this.open();
|
|
@@ -459,6 +535,7 @@ export class Select extends Component {
|
|
|
459
535
|
_onInput() {
|
|
460
536
|
if (!this.options.filter) return;
|
|
461
537
|
const query = /** @type {HTMLInputElement} */ (this.refs.input).value;
|
|
538
|
+
this._syncValueAdornment();
|
|
462
539
|
if (!this._open) this.open();
|
|
463
540
|
if (this.options.filter === 'local') {
|
|
464
541
|
this._visibleFixed = this._matchFixed(query);
|
|
@@ -481,6 +558,19 @@ export class Select extends Component {
|
|
|
481
558
|
/** @param {SelectOptionEntry|undefined} entry @returns {void} */
|
|
482
559
|
_chooseEntry(entry) {
|
|
483
560
|
if (!entry) return;
|
|
561
|
+
if (entry.action) {
|
|
562
|
+
const detail = {
|
|
563
|
+
id: entry.action.id,
|
|
564
|
+
action: entry.action,
|
|
565
|
+
query: /** @type {HTMLInputElement} */ (this.refs.input).value,
|
|
566
|
+
select: this
|
|
567
|
+
};
|
|
568
|
+
const action = this.emit('action', detail);
|
|
569
|
+
if (!action.defaultPrevented) entry.action.invoke?.(detail);
|
|
570
|
+
this.close();
|
|
571
|
+
this.focus();
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
484
574
|
this.set(entry.clear ? null : entry.item);
|
|
485
575
|
this.close();
|
|
486
576
|
this.focus();
|
|
@@ -518,7 +608,8 @@ export class Select extends Component {
|
|
|
518
608
|
let lastGroup = Symbol('initial-group');
|
|
519
609
|
|
|
520
610
|
if (this.options.clearable) {
|
|
521
|
-
this._appendOption(null, true, this.options.
|
|
611
|
+
this._appendOption(null, true, this.options.noneLabel
|
|
612
|
+
?? this._message('select.none', 'No selection'));
|
|
522
613
|
}
|
|
523
614
|
for (const item of this._visibleFixed) this._appendOption(item, false);
|
|
524
615
|
// A group heading already divides the two sections, and a rule right above one reads as a slip.
|
|
@@ -538,7 +629,24 @@ export class Select extends Component {
|
|
|
538
629
|
}
|
|
539
630
|
this._appendOption(item, false);
|
|
540
631
|
}
|
|
541
|
-
if (this.
|
|
632
|
+
if (this._actions.length > 0) {
|
|
633
|
+
if (this._visibleFixed.length > 0 || this._visibleItems.length > 0 || this.options.clearable) {
|
|
634
|
+
list.append(h('div', { class: 'zx-select__separator', role: 'presentation' }));
|
|
635
|
+
}
|
|
636
|
+
let actionGroup = Symbol('initial-action-group');
|
|
637
|
+
for (const action of this._actions) {
|
|
638
|
+
if (action.group && action.group !== actionGroup) {
|
|
639
|
+
list.append(h('div', {
|
|
640
|
+
class: 'zx-select__group',
|
|
641
|
+
role: 'presentation'
|
|
642
|
+
}, action.group));
|
|
643
|
+
actionGroup = action.group;
|
|
644
|
+
}
|
|
645
|
+
this._appendAction(action);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (this._visibleItems.length === 0 && this._visibleFixed.length === 0
|
|
649
|
+
&& this._actions.length === 0 && !this._loading) {
|
|
542
650
|
list.append(h('div', {
|
|
543
651
|
class: 'zx-select__empty',
|
|
544
652
|
role: 'status'
|
|
@@ -552,7 +660,9 @@ export class Select extends Component {
|
|
|
552
660
|
const selected = clear ? this._selected === null :
|
|
553
661
|
this._selected !== null && Object.is(this._itemValue(item), this.value);
|
|
554
662
|
const index = this._optionEntries.length;
|
|
555
|
-
const content = clear
|
|
663
|
+
const content = clear && typeof this.options.renderNone === 'function'
|
|
664
|
+
? normalizeRenderedContent(this.options.renderNone(String(label ?? '')))
|
|
665
|
+
: clear ? String(label ?? '') : this._renderItem(item);
|
|
556
666
|
const option = h('div', {
|
|
557
667
|
class: 'zx-select__option',
|
|
558
668
|
id: uid('zx-select-option'),
|
|
@@ -562,15 +672,33 @@ export class Select extends Component {
|
|
|
562
672
|
}, h('span', { class: 'zx-select__option-content' }, content));
|
|
563
673
|
if (selected) option.append(icon('check', { size: 15 }));
|
|
564
674
|
this.refs.list.append(option);
|
|
565
|
-
this._optionEntries.push({ item, clear, element: option });
|
|
675
|
+
this._optionEntries.push({ item, clear, action: null, element: option });
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** @param {SelectAction} action @returns {void} */
|
|
679
|
+
_appendAction(action) {
|
|
680
|
+
const index = this._optionEntries.length;
|
|
681
|
+
const visual = selectActionIcon(action.icon);
|
|
682
|
+
const option = h('div', {
|
|
683
|
+
class: 'zx-select__option zx-select__action',
|
|
684
|
+
id: uid('zx-select-option'),
|
|
685
|
+
role: 'option',
|
|
686
|
+
ariaSelected: 'false',
|
|
687
|
+
dataset: { index, kind: 'action' }
|
|
688
|
+
}, h('span', { class: 'zx-select__option-content zx-select__action-content' },
|
|
689
|
+
visual ? h('span', { class: 'zx-select__action-icon', ariaHidden: 'true' }, visual) : null,
|
|
690
|
+
h('span', { class: 'zx-select__action-copy' },
|
|
691
|
+
h('span', { class: 'zx-select__action-label' }, action.label),
|
|
692
|
+
action.description ? h('span', { class: 'zx-select__action-description' }, action.description) : null)));
|
|
693
|
+
this.refs.list.append(option);
|
|
694
|
+
this._optionEntries.push({ item: null, clear: false, action, element: option });
|
|
566
695
|
}
|
|
567
696
|
|
|
568
697
|
/** @param {SelectItem|null} item @returns {Node|string} */
|
|
569
698
|
_renderItem(item) {
|
|
570
699
|
if (typeof this.options.renderItem === 'function') {
|
|
571
700
|
const rendered = this.options.renderItem(item);
|
|
572
|
-
return rendered
|
|
573
|
-
/** @type {Node} */ (rendered) : String(rendered ?? '');
|
|
701
|
+
return normalizeRenderedContent(rendered);
|
|
574
702
|
}
|
|
575
703
|
return this._itemLabel(item);
|
|
576
704
|
}
|
|
@@ -645,9 +773,30 @@ export class Select extends Component {
|
|
|
645
773
|
input.value = this._selected === null ? '' :
|
|
646
774
|
(typeof this.options.renderValue === 'function' ?
|
|
647
775
|
String(this.options.renderValue(this._selected) ?? '') : this._itemLabel(this._selected));
|
|
776
|
+
this._syncValueAdornment();
|
|
648
777
|
this.refs.clear.hidden = !this.options.clearable || this._selected === null;
|
|
649
778
|
}
|
|
650
779
|
|
|
780
|
+
/** @returns {void} */
|
|
781
|
+
_syncValueAdornment() {
|
|
782
|
+
const host = this.refs.valueAdornment;
|
|
783
|
+
if (!host) return;
|
|
784
|
+
const selectedLabel = this._selected === null ? '' :
|
|
785
|
+
(typeof this.options.renderValue === 'function'
|
|
786
|
+
? String(this.options.renderValue(this._selected) ?? '') : this._itemLabel(this._selected));
|
|
787
|
+
const query = String((/** @type {HTMLInputElement} */ (this.refs.input))?.value ?? '');
|
|
788
|
+
if (this._selected === null || typeof this.options.renderValueAdornment !== 'function'
|
|
789
|
+
|| Boolean(this.options.filter && query !== selectedLabel)) {
|
|
790
|
+
host.replaceChildren();
|
|
791
|
+
host.hidden = true;
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const rendered = this.options.renderValueAdornment(this._selected);
|
|
795
|
+
const content = normalizeRenderedContent(rendered);
|
|
796
|
+
host.replaceChildren(content instanceof Node ? content : document.createTextNode(content));
|
|
797
|
+
host.hidden = rendered == null || (typeof content === 'string' && content === '');
|
|
798
|
+
}
|
|
799
|
+
|
|
651
800
|
/** @returns {void} */
|
|
652
801
|
_syncDisabled() {
|
|
653
802
|
const input = /** @type {HTMLInputElement} */ (this.refs.input);
|
|
@@ -727,11 +876,94 @@ export class Select extends Component {
|
|
|
727
876
|
}
|
|
728
877
|
}
|
|
729
878
|
|
|
879
|
+
/** @param {unknown} rendered @returns {Node|string} */
|
|
880
|
+
function normalizeRenderedContent(rendered) {
|
|
881
|
+
return rendered && typeof rendered === 'object' && 'nodeType' in rendered
|
|
882
|
+
? /** @type {Node} */ (rendered) : String(rendered ?? '');
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** @param {unknown} actions @returns {SelectAction[]} */
|
|
886
|
+
function normalizeSelectActions(actions) {
|
|
887
|
+
if (!Array.isArray(actions)) return [];
|
|
888
|
+
return actions.filter((action) => action && typeof action === 'object'
|
|
889
|
+
&& action.id != null && action.label != null).map((action) => ({
|
|
890
|
+
...action,
|
|
891
|
+
label: String(action.label),
|
|
892
|
+
description: action.description == null ? undefined : String(action.description),
|
|
893
|
+
group: action.group == null ? undefined : String(action.group)
|
|
894
|
+
}));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** @param {unknown} visual @returns {Node|null} */
|
|
898
|
+
function selectActionIcon(visual) {
|
|
899
|
+
if (typeof visual === 'string') return icon(visual);
|
|
900
|
+
if (visual instanceof Node) return visual.cloneNode(true);
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** @param {Record<string, any>} item @param {boolean} label @returns {Node} */
|
|
905
|
+
function priorityContent(item, label) {
|
|
906
|
+
return h('span', { class: 'zx-select__priority' },
|
|
907
|
+
priorityIndicator(item.level),
|
|
908
|
+
label ? h('span', {}, String(item.name)) : null);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** @param {unknown} level @returns {Node} */
|
|
912
|
+
function priorityIndicator(level) {
|
|
913
|
+
const normalized = Math.max(0, Math.min(4, Math.trunc(Number(level) || 0)));
|
|
914
|
+
return h('span', {
|
|
915
|
+
class: 'zx-select__priority-icon',
|
|
916
|
+
dataset: { level: normalized },
|
|
917
|
+
ariaHidden: 'true'
|
|
918
|
+
}, Array.from({ length: 5 }, (_, index) => h('span', {
|
|
919
|
+
dataset: { filled: String(index <= normalized) }
|
|
920
|
+
})));
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** @param {unknown} input @returns {Record<string, any>|null} */
|
|
924
|
+
function normalizeStatusItem(input) {
|
|
925
|
+
if (!input || typeof input !== 'object' || input.ID == null || input.name == null) return null;
|
|
926
|
+
const indicators = ['dotted', 'ring', 'progress', 'review', 'ready', 'waiting', 'done', 'canceled', 'duplicate'];
|
|
927
|
+
const tones = ['muted', 'neutral', 'warning', 'info', 'accent', 'success', 'danger'];
|
|
928
|
+
return {
|
|
929
|
+
...input,
|
|
930
|
+
name: String(input.name),
|
|
931
|
+
indicator: indicators.includes(String(input.indicator)) ? String(input.indicator) : 'ring',
|
|
932
|
+
tone: tones.includes(String(input.tone)) ? String(input.tone) : 'muted',
|
|
933
|
+
description: input.description == null ? '' : String(input.description),
|
|
934
|
+
shortcut: input.shortcut == null ? '' : String(input.shortcut)
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** @param {Record<string, any>} item @returns {Node} */
|
|
939
|
+
function statusContent(item) {
|
|
940
|
+
return h('span', { class: 'zx-select__status' },
|
|
941
|
+
statusIndicator(item),
|
|
942
|
+
h('span', { class: 'zx-select__status-copy' },
|
|
943
|
+
h('span', { class: 'zx-select__status-label' }, String(item.name)),
|
|
944
|
+
item.description ? h('span', { class: 'zx-select__status-description' }, item.description) : null),
|
|
945
|
+
item.shortcut ? h('kbd', { class: 'zx-select__status-shortcut' }, item.shortcut) : null);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** @param {Record<string, any>} item @returns {Node} */
|
|
949
|
+
function statusIndicator(item) {
|
|
950
|
+
const indicator = String(item.indicator ?? 'ring');
|
|
951
|
+
const marker = h('span', {
|
|
952
|
+
class: 'zx-select__status-indicator',
|
|
953
|
+
dataset: { indicator, tone: String(item.tone ?? 'muted') },
|
|
954
|
+
ariaHidden: 'true'
|
|
955
|
+
});
|
|
956
|
+
if (indicator === 'done') marker.append(icon('check', { size: 8 }));
|
|
957
|
+
if (indicator === 'canceled') marker.append(icon('x', { size: 7 }));
|
|
958
|
+
return marker;
|
|
959
|
+
}
|
|
960
|
+
|
|
730
961
|
/** Change emitted when the selected value changes. @event Select#change @type {CustomEvent<SelectChangeDetail>} */
|
|
731
962
|
/** Panel-open notification. @event Select#open @type {CustomEvent<Record<string, never>>} */
|
|
732
963
|
/** Panel-close notification. @event Select#close @type {CustomEvent<Record<string, never>>} */
|
|
733
964
|
/** Async-query start notification. @event Select#query @type {CustomEvent<SelectQueryDetail>} */
|
|
734
965
|
/** Async results notification. @event Select#loaded @type {CustomEvent<SelectLoadedDetail>} */
|
|
966
|
+
/** Command-row activation; preventing it suppresses the action callback. @event Select#action @type {CustomEvent<SelectActionDetail>} */
|
|
735
967
|
|
|
736
968
|
/**
|
|
737
969
|
* Reads a value off an item through a property name or a reader function.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Component } from '../../core/component.js';
|
|
2
2
|
import { breakpoints, onBreakpoint } from '../../core/breakpoint.js';
|
|
3
|
-
import { h } from '../../core/dom.js';
|
|
3
|
+
import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
|
|
4
4
|
import { formatCurrency, formatNumber, formatPercent } from '../../core/format.js';
|
|
5
5
|
import { icon } from '../../core/icons.js';
|
|
6
6
|
import { printf } from '../../core/i18n.js';
|
|
@@ -261,11 +261,13 @@ export class Table extends Component {
|
|
|
261
261
|
|
|
262
262
|
/** @returns {HTMLElement} Table root. */
|
|
263
263
|
render() {
|
|
264
|
+
const created = this.el === null;
|
|
264
265
|
const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
|
|
265
266
|
// Claimed here, not left to the base constructor: `_watchWidth()` below observes `this.el`,
|
|
266
267
|
// and until this assignment it is null for a component creating its own root.
|
|
267
268
|
this.el = root;
|
|
268
|
-
this.
|
|
269
|
+
this._createdRoot = created;
|
|
270
|
+
this._original = created ? null : snapshotTarget(root);
|
|
269
271
|
this._columns = Array.isArray(this.options.columns) ? this.options.columns.map((column) => ({ ...column })) : [];
|
|
270
272
|
this._data = Array.isArray(this.options.data) ? [...this.options.data] : [];
|
|
271
273
|
this._selected = new Set();
|
|
@@ -347,8 +349,8 @@ export class Table extends Component {
|
|
|
347
349
|
|
|
348
350
|
/**
|
|
349
351
|
* Toggles a loading state. While loading, the body is dimmed, an indeterminate progress bar is
|
|
350
|
-
* shown, and `aria-busy` is set — a lightweight
|
|
351
|
-
*
|
|
352
|
+
* shown, and `aria-busy` is set — a lightweight enterprise-table busy and skeleton pattern.
|
|
353
|
+
* Call `setLoading(false)` (or `setData`) when done.
|
|
352
354
|
* @param {boolean} [loading=true] Whether the table is loading.
|
|
353
355
|
* @returns {this}
|
|
354
356
|
*/
|
|
@@ -942,9 +944,8 @@ export class Table extends Component {
|
|
|
942
944
|
this._teardownEdit();
|
|
943
945
|
this._width?.destroy();
|
|
944
946
|
this._width = null;
|
|
945
|
-
if (this.
|
|
946
|
-
|
|
947
|
-
this.el.replaceChildren(...this._originalChildren);
|
|
947
|
+
if (!this._restored && !this._createdRoot) {
|
|
948
|
+
restoreTarget(this.el, this._original);
|
|
948
949
|
this._restored = true;
|
|
949
950
|
}
|
|
950
951
|
super.destroy();
|
package/src/core/dom.js
CHANGED
|
@@ -95,6 +95,26 @@ export function compactElement(compact, properties = null, content = null) {
|
|
|
95
95
|
/** Exact migration-facing alias retained for existing ZeyOS builder calls. */
|
|
96
96
|
export const __ = compactElement;
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Returns a trimmed native-link destination unless its parsed scheme can execute document code.
|
|
100
|
+
* Relative URLs, fragments, and ordinary application protocols remain valid; malformed and
|
|
101
|
+
* control-obfuscated script/data schemes are rejected.
|
|
102
|
+
* @param {unknown} value Candidate href.
|
|
103
|
+
* @returns {string|null}
|
|
104
|
+
*/
|
|
105
|
+
export function safeHref(value) {
|
|
106
|
+
if (typeof value !== 'string') return null;
|
|
107
|
+
const href = value.trim();
|
|
108
|
+
if (!href) return null;
|
|
109
|
+
try {
|
|
110
|
+
const protocol = new URL(href, globalThis.location?.href ?? 'https://zx.invalid/').protocol;
|
|
111
|
+
if (['javascript:', 'data:', 'vbscript:'].includes(protocol.toLowerCase())) return null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return href;
|
|
116
|
+
}
|
|
117
|
+
|
|
98
118
|
/**
|
|
99
119
|
* Applies the established ZeyOS property rules to an existing element:
|
|
100
120
|
* `Dname` → `data-name`, `SfontSize` → `style.fontSize`, `on*` → a legacy-compatible listener,
|
package/src/index.js
CHANGED
|
@@ -88,6 +88,7 @@ export { CopyInput } from './components/copy/copy-input.js';
|
|
|
88
88
|
export { Toolbar } from './components/toolbar/toolbar.js';
|
|
89
89
|
export { Toggle } from './components/toggle/toggle.js';
|
|
90
90
|
export { Groupbox } from './components/groupbox/groupbox.js';
|
|
91
|
+
export { Card } from './components/card/card.js';
|
|
91
92
|
export { Panel } from './components/panel/panel.js';
|
|
92
93
|
export { MasterPanel } from './components/master-panel/master-panel.js';
|
|
93
94
|
export {
|
|
@@ -103,7 +104,7 @@ export { Search } from './components/search/search.js';
|
|
|
103
104
|
export { Launcher, rankLauncherItems } from './components/launcher/launcher.js';
|
|
104
105
|
export { Avatar, avatarInitials } from './components/avatar/avatar.js';
|
|
105
106
|
export { AccountMenu } from './components/account-menu/account-menu.js';
|
|
106
|
-
export {
|
|
107
|
+
export { normalizeAppItems } from './internal/app-rail.js';
|
|
107
108
|
export { AppSidebar } from './components/app-sidebar/app-sidebar.js';
|
|
108
109
|
export { Message } from './components/message/message.js';
|
|
109
110
|
export { Modal } from './components/modal/modal.js';
|