@zeyos/zx 4.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/DESIGN-SYSTEM.md +59 -3
  3. package/dist/types/components/app-icon/app-icon.d.ts +88 -0
  4. package/dist/types/components/filter/filter-model.d.ts +87 -0
  5. package/dist/types/components/filter/filter.d.ts +331 -0
  6. package/dist/types/components/select/select.d.ts +46 -4
  7. package/dist/types/components/table/table.d.ts +103 -0
  8. package/dist/types/components/tag-picker/tag-picker.d.ts +16 -0
  9. package/dist/types/core/dom.d.ts +1 -1
  10. package/dist/types/core/util.d.ts +9 -0
  11. package/dist/types/index.d.ts +4 -1
  12. package/dist/types/zeyos/icons.d.ts +19 -2
  13. package/dist/types/zeyos/index.d.ts +1 -1
  14. package/dist/types/zeyos/select.d.ts +2 -0
  15. package/dist/zx-zeyos.esm.js +702 -75
  16. package/dist/zx-zeyos.esm.min.js +1 -1
  17. package/dist/zx.css +415 -4
  18. package/dist/zx.esm.js +1568 -115
  19. package/dist/zx.esm.min.js +3 -3
  20. package/dist/zx.global.js +1568 -115
  21. package/dist/zx.global.min.js +3 -3
  22. package/dist/zx.min.css +1 -1
  23. package/docs/api.json +379 -0
  24. package/docs/llms.md +93 -16
  25. package/docs/llms.txt +11 -9
  26. package/package.json +1 -1
  27. package/src/components/app-icon/app-icon.css +105 -0
  28. package/src/components/app-icon/app-icon.js +134 -0
  29. package/src/components/filter/filter-model.js +226 -0
  30. package/src/components/filter/filter.css +147 -0
  31. package/src/components/filter/filter.js +554 -0
  32. package/src/components/grid/grid.css +1 -1
  33. package/src/components/grid/grid.js +4 -1
  34. package/src/components/message/message.css +36 -3
  35. package/src/components/select/select.css +17 -0
  36. package/src/components/select/select.js +105 -24
  37. package/src/components/table/table.css +85 -0
  38. package/src/components/table/table.js +418 -24
  39. package/src/components/tag-picker/tag-picker.css +19 -0
  40. package/src/components/tag-picker/tag-picker.js +42 -5
  41. package/src/core/dom.js +1 -1
  42. package/src/core/util.js +20 -0
  43. package/src/index.js +7 -1
  44. package/src/zeyos/icons.js +34 -21
  45. package/src/zeyos/index.js +1 -1
  46. package/src/zeyos/select.js +6 -5
  47. package/styles/tokens/semantic.css +9 -0
  48. package/styles/tokens/themes.css +2 -2
  49. package/styles/zx.css +2 -0
@@ -19,6 +19,7 @@
19
19
 
20
20
  .zx-select__value-adornment {
21
21
  display: inline-flex;
22
+ grid-column: 1;
22
23
  align-items: center;
23
24
  padding-inline-start: var(--zx-control-pad-x);
24
25
  }
@@ -34,6 +35,7 @@
34
35
  }
35
36
 
36
37
  .zx-select__input {
38
+ grid-column: 2;
37
39
  inline-size: 100%;
38
40
  min-inline-size: 0;
39
41
  min-block-size: calc(var(--zx-control-height) - 2px) !important;
@@ -60,6 +62,11 @@
60
62
  padding: 0 !important;
61
63
  }
62
64
 
65
+ .zx-select__clear { grid-column: 3; }
66
+ .zx-select__loading { grid-column: 4; justify-self: center; }
67
+ .zx-select__toggle { grid-column: 5; align-self: stretch; }
68
+ .zx-select__toggle > .zx-icon { display: block; }
69
+
63
70
  .zx-select__clear:hover,
64
71
  .zx-select__toggle:hover {
65
72
  color: var(--zx-color-text);
@@ -198,6 +205,16 @@
198
205
  gap: var(--zx-space-2);
199
206
  }
200
207
 
208
+ .zx-select__entity-glyph {
209
+ display: inline-grid;
210
+ place-items: center;
211
+ inline-size: 20px;
212
+ block-size: 20px;
213
+ border-radius: var(--zx-radius-sm);
214
+ background: var(--zx-select-entity-color, var(--zx-color-accent-subtle));
215
+ color: var(--zx-color-text-invert);
216
+ }
217
+
201
218
  .zx-select__status-copy,
202
219
  .zx-select__action-copy {
203
220
  display: grid;
@@ -1,9 +1,9 @@
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 */
@@ -15,6 +15,7 @@ import { matchItems } from './filter.js';
15
15
  * @typedef {Object} SelectOptions
16
16
  * @property {SelectItem[]} [items=[]] Available values.
17
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.
18
19
  * @property {SelectValueReader} [valueKey='ID'] Item ID property or reader.
19
20
  * @property {SelectLabelReader} [labelKey='name'] Item label property or reader.
20
21
  * @property {((item: SelectItem) => Node|string)|null} [renderItem=null] Option renderer.
@@ -41,6 +42,16 @@ import { matchItems } from './filter.js';
41
42
  * @property {(event: CustomEvent<SelectLoadedDetail>) => void} [onloaded] Async-loaded listener.
42
43
  * @property {(event: CustomEvent<SelectActionDetail>) => void} [onaction] Preventable command-row listener.
43
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.
54
+ */
44
55
  /** @typedef {{value: unknown, item: SelectItem|null}} SelectChangeDetail */
45
56
  /** @typedef {{query: string}} SelectQueryDetail */
46
57
  /** @typedef {{items: SelectItem[]}} SelectLoadedDetail */
@@ -86,6 +97,7 @@ export class Select extends Component {
86
97
  static defaults = {
87
98
  items: [],
88
99
  fixedItems: [],
100
+ fixedLabel: null,
89
101
  valueKey: 'ID',
90
102
  labelKey: 'name',
91
103
  renderItem: null,
@@ -111,15 +123,12 @@ export class Select extends Component {
111
123
  render() {
112
124
  // Instance state initialized here because render() runs inside the base
113
125
  // constructor, before class-field initializers would run (and clobber it).
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();
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;
115
127
  this._createdRoot = this.el === null;
116
128
  const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
117
129
  this.el = root;
118
130
  this._root = root;
119
- if (!this._createdRoot) this._originalChildren = Array.from(root.childNodes);
120
- this._rememberAttribute(root, 'data-state');
121
- this._rememberAttribute(root, 'data-loading');
122
- this._rememberAttribute(root, 'aria-disabled');
131
+ this._snapshot = this._createdRoot ? null : snapshotTarget(root);
123
132
 
124
133
  const listId = uid('zx-select-list');
125
134
  const input = h('input', {
@@ -423,6 +432,54 @@ export class Select extends Component {
423
432
  return select;
424
433
  }
425
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
+
426
483
  /**
427
484
  * Creates the record-permission picker: the fixed Private and Public choices above the groups a
428
485
  * record can be shared with. The value is the tri-state ZeyOS stores — `false` for private,
@@ -467,13 +524,7 @@ export class Select extends Component {
467
524
  this._position?.destroy();
468
525
  this._position = null;
469
526
  const root = /** @type {HTMLElement} */ (this.el);
470
- if (!this._createdRoot && root) {
471
- root.replaceChildren(...this._originalChildren);
472
- for (const [name, value] of this._originalAttributes) {
473
- if (value === null) root.removeAttribute(name);
474
- else root.setAttribute(name, value);
475
- }
476
- }
527
+ if (!this._createdRoot && root) restoreTarget(root, this._snapshot);
477
528
  super.destroy();
478
529
  }
479
530
 
@@ -606,17 +657,22 @@ export class Select extends Component {
606
657
  list.replaceChildren();
607
658
  this._optionEntries = [];
608
659
  let lastGroup = Symbol('initial-group');
660
+ const fixed = this._uniqueItems(this._visibleFixed);
661
+ const items = this._uniqueItems(this._visibleItems, fixed);
609
662
 
610
663
  if (this.options.clearable) {
611
664
  this._appendOption(null, true, this.options.noneLabel
612
665
  ?? this._message('select.none', 'No selection'));
613
666
  }
614
- for (const item of this._visibleFixed) this._appendOption(item, false);
667
+ if (fixed.length > 0 && this.options.fixedLabel) {
668
+ list.append(h('div', { class: 'zx-select__group', role: 'presentation' }, String(this.options.fixedLabel)));
669
+ }
670
+ for (const item of fixed) this._appendOption(item, false);
615
671
  // A group heading already divides the two sections, and a rule right above one reads as a slip.
616
- if (this._visibleFixed.length > 0 && this._visibleItems.length > 0 && !this.options.groupKey) {
672
+ if (fixed.length > 0 && items.length > 0 && !this.options.groupKey) {
617
673
  list.append(h('div', { class: 'zx-select__separator', role: 'presentation' }));
618
674
  }
619
- for (const item of this._visibleItems) {
675
+ for (const item of items) {
620
676
  if (this.options.groupKey) {
621
677
  const group = this._read(item, this.options.groupKey);
622
678
  if (!Object.is(group, lastGroup)) {
@@ -630,7 +686,7 @@ export class Select extends Component {
630
686
  this._appendOption(item, false);
631
687
  }
632
688
  if (this._actions.length > 0) {
633
- if (this._visibleFixed.length > 0 || this._visibleItems.length > 0 || this.options.clearable) {
689
+ if (fixed.length > 0 || items.length > 0 || this.options.clearable) {
634
690
  list.append(h('div', { class: 'zx-select__separator', role: 'presentation' }));
635
691
  }
636
692
  let actionGroup = Symbol('initial-action-group');
@@ -645,7 +701,7 @@ export class Select extends Component {
645
701
  this._appendAction(action);
646
702
  }
647
703
  }
648
- if (this._visibleItems.length === 0 && this._visibleFixed.length === 0
704
+ if (items.length === 0 && fixed.length === 0
649
705
  && this._actions.length === 0 && !this._loading) {
650
706
  list.append(h('div', {
651
707
  class: 'zx-select__empty',
@@ -851,6 +907,17 @@ export class Select extends Component {
851
907
  return matchItems(this._fixedItems, query, this._searchKeys());
852
908
  }
853
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
+
854
921
  /** @returns {number} */
855
922
  _selectedEntryIndex() {
856
923
  if (this._selected === null) return this.options.clearable ? 0 : -1;
@@ -864,11 +931,6 @@ export class Select extends Component {
864
931
  return itemIndex + (this.options.clearable ? 1 : 0);
865
932
  }
866
933
 
867
- /** @param {string} name @returns {void} */
868
- _rememberAttribute(element, name) {
869
- this._originalAttributes.set(name, element.getAttribute(name));
870
- }
871
-
872
934
  /** @param {string} key @param {string} fallback @returns {string} */
873
935
  _message(key, fallback) {
874
936
  const message = this.msg(key);
@@ -876,6 +938,25 @@ export class Select extends Component {
876
938
  }
877
939
  }
878
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
+
879
960
  /** @param {unknown} rendered @returns {Node|string} */
880
961
  function normalizeRenderedContent(rendered) {
881
962
  return rendered && typeof rendered === 'object' && 'nodeType' in rendered
@@ -2,6 +2,87 @@
2
2
  min-inline-size: 0;
3
3
  }
4
4
 
5
+ .zx-table__column-controls {
6
+ position: relative;
7
+ inline-size: max-content;
8
+ margin: 0 0 var(--zx-space-2) auto;
9
+ }
10
+
11
+ .zx-table__reorder-status {
12
+ position: absolute;
13
+ inline-size: 1px;
14
+ block-size: 1px;
15
+ overflow: hidden;
16
+ clip-path: inset(50%);
17
+ }
18
+
19
+ .zx-table__column-controls > summary {
20
+ display: inline-flex;
21
+ align-items: center;
22
+ gap: var(--zx-space-2);
23
+ list-style: none;
24
+ cursor: pointer;
25
+ }
26
+
27
+ .zx-table__column-controls > summary::-webkit-details-marker { display: none; }
28
+
29
+ .zx-table__column-list {
30
+ position: absolute;
31
+ z-index: 4;
32
+ inset-block-start: calc(100% + var(--zx-space-1));
33
+ inset-inline-end: 0;
34
+ display: grid;
35
+ min-inline-size: 190px;
36
+ border: 1px solid var(--zx-color-border-strong);
37
+ border-radius: var(--zx-radius-md);
38
+ background: var(--zx-color-bg-raised);
39
+ box-shadow: var(--zx-overlay-shadow);
40
+ padding: var(--zx-space-2);
41
+ }
42
+
43
+ .zx-table__column-option {
44
+ display: flex;
45
+ align-items: center;
46
+ gap: var(--zx-space-2);
47
+ min-block-size: var(--zx-control-height);
48
+ border-radius: var(--zx-radius-sm);
49
+ padding-inline: var(--zx-space-2);
50
+ }
51
+
52
+ .zx-table__column-option:hover { background: var(--zx-color-bg-hover); }
53
+
54
+ .zx-table__reorder-column,
55
+ .zx-table__reorder-header,
56
+ .zx-table__reorder-cell {
57
+ inline-size: var(--zx-control-height);
58
+ text-align: center;
59
+ }
60
+
61
+ .zx-table__row-handle {
62
+ display: inline-grid;
63
+ place-items: center;
64
+ inline-size: 26px;
65
+ block-size: 26px;
66
+ min-block-size: 0;
67
+ border: 0;
68
+ border-radius: var(--zx-radius-sm);
69
+ background: transparent;
70
+ padding: 0;
71
+ color: var(--zx-color-text-muted);
72
+ cursor: grab;
73
+ }
74
+
75
+ .zx-table__row-handle:hover,
76
+ .zx-table__row-handle[aria-pressed="true"] {
77
+ background: var(--zx-color-bg-hover);
78
+ color: var(--zx-color-text);
79
+ }
80
+
81
+ .zx-table__row-handle:active { cursor: grabbing; }
82
+ .zx-table__body tr[data-dragging] { opacity: .55; }
83
+ .zx-table__body tr[data-drop="before"] { box-shadow: inset 0 2px 0 var(--zx-color-accent); }
84
+ .zx-table__body tr[data-drop="after"] { box-shadow: inset 0 -2px 0 var(--zx-color-accent); }
85
+
5
86
  .zx-table__scroll {
6
87
  position: relative;
7
88
  max-inline-size: 100%;
@@ -379,6 +460,10 @@
379
460
  justify-content: flex-start;
380
461
  }
381
462
 
463
+ .zx-table[data-stacked] .zx-table__reorder-cell {
464
+ justify-content: flex-start;
465
+ }
466
+
382
467
 
383
468
  .zx-table[data-stacked] .zx-table__empty-row {
384
469
  display: block;