@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
package/docs/llms.txt CHANGED
@@ -22,30 +22,32 @@ exports and as `window.zx.*`.
22
22
 
23
23
  ## Components
24
24
  - Inputs and identity: `button()`, `buttonGroup()`, `badge()`/`badgeGroup()`, `CheckButton`,
25
- `Toggle`, `Search`, `Launcher`, `Avatar`, `AccountMenu`, `Select` (APG combobox;
26
- `filter: false|'local'|async`; priority, status, permission, and optional ZeyOS entity presets),
27
- `Checklist`, `TagPicker`, `NumberField`, `Rating`, `Slider`,
25
+ `Toggle`, `Search`, `AppIcon` (+ `zeyosAppIcon()` presets), `Avatar`,
26
+ `Select` (APG combobox; `filter: false|'local'|async`; priority, status, permission, and
27
+ `Select.entity()` presets), `Checklist`, `TagPicker` (icon/color identity), `NumberField`, `Rating`, `Slider`,
28
28
  `copyButton()`/`CopyInput`, `DatePicker`, `MonthPicker`, `TimePicker`,
29
29
  `Datebox`/`DateTimeBox`, `DateRangePicker`/`DateRangeBox`, `Timebox`.
30
30
  - Overlays: `Message` (toasts/inline/progress), `Modal`, `Dialog` (+ `alert`/`confirm`/`prompt` Promises), `Sheet` (edge-anchored Dialog; `side`, `modal: true|'trap-focus'|false`, `backdrop`, `snap` detents, swipe-to-dismiss), `SheetStack` (drill-down; `stack`/`cascade`), `Dropdown`, `MenuButton`, `ContextMenu` (right-click; `selector` for per-row menus), `Tooltip`/`tooltip()`/`describe()`.
31
- - Data: `Table` (sortable, selectable, sticky header, opt-in inline cell/row editing via
32
- `editMode`), `Grid` with `Grid.BillingItems()`, engine-neutral `Chart` with injected
33
- `ChartJsAdapter`, `DataFilter`, `Pagination` (+ pure `paginationRange()`), `TreeView` (APG tree),
31
+ - Data: `Table` (sortable, selectable, sticky header, opt-in inline cell/row editing, row reorder,
32
+ column visibility), `Grid` with single-click editing and `Grid.BillingItems()`, engine-neutral
33
+ `Chart` with injected `ChartJsAdapter`, backend-neutral nested `Filter`, simple client-side
34
+ `DataFilter`, `Pagination` (+ pure `paginationRange()`), `TreeView` (APG tree),
34
35
  `Finder` (Miller columns).
35
36
  - Forms: `Form`, `Fieldset`, `Questionnaire` (guided one-question-at-a-time flow with branching), `Field` (type registry), `ValueList`, `MultiValueEditor`, `FieldUpload`.
36
37
  - Layout: `stack()`/`grid()`/`aspect()` (spacing, an intrinsically reflowing grid, a fixed-ratio
37
38
  box), `Groupbox`, `Card` (media/title link/actions/footer), `Panel` (header/footer action
38
39
  buttons), `MasterPanel`, `Tabbox`
39
40
  (`variant: 'divided'|'bracket'|'line'|'segmented'`), `NavigationBar`, `AppSidebar` (expanded and
40
- minimized vertical states plus horizontal rail layouts), `Toolbar` (APG toolbar with overflow menu), `SplitView`, `Dock`
41
+ minimized vertical states plus horizontal rail layouts), `AccountMenu`, `Launcher`,
42
+ `spinner()`/`ProgressBar`/`InlineLoading`, `skeleton()`/`skeletonText()`/`skeletonTable()`,
43
+ `Toolbar` (APG toolbar with overflow menu), `SplitView`, `Dock`
41
44
  (collapsible resizable pane stack; tab groups, regions, adopts a `Sheet`), `Stepper`,
42
45
  `Breadcrumb`, `emptyState()`.
43
- - Feedback: `spinner()` (unknown duration), `ProgressBar` (known share; determinate or indeterminate), `InlineLoading` (a wait resolving into its outcome in place), `skeleton()`/`skeletonText()`/`skeletonTable()` (placeholders shaped like the content; `aria-hidden`, set `aria-busy` on the region).
44
46
  - Core: `Component`, `h`/`__`/`icon`, `position`, `breakpoints`/`breakpointOf`/`matchBreakpoint`/`onBreakpoint` (named width bands, observed on an element or the window), `Http`/`zeyosService`, i18n, date utils, `defineElements` (declarative `<zx-*>` custom elements).
45
47
  - Helpers: `formatNumber`/`formatCurrency`/`formatPercent`/`formatFileSize`/`formatRelativeTime` (locale-aware, cached `Intl`), `debounce`/`throttle`, `groupBy`/`sortBy`/`uniqueBy`, `escapeRegExp`/`highlightMatch`, `storage()` (namespaced JSON, degrades to memory), `toCsv`/`downloadBlob`/`copyToClipboard`, `truncate()`/`isTruncated()` (line clamp with the full value on hover).
46
48
 
47
49
  ## Theming
48
- Load `zx.css` once; set `data-zx-theme` (`light`|`dark`|`auto`) and `data-zx-density` (`cozy`|`compact`) on any ancestor. Define a product theme by overriding semantic `--zx-color-*` / `--zx-control-*` tokens under `[data-zx-theme="name"]`. Never use tier-1 palette tokens or raw color literals in application CSS.
50
+ Load `zx.css` once; set `data-zx-theme` (`light`|`dark`|`auto`) and `data-zx-density` (`cozy`|`compact`) on any ancestor. Define a product theme by overriding semantic `--zx-color-*` / `--zx-control-*` tokens under `[data-zx-theme="name"]`. The theme studio controls accent, neutral tint, density, font, type size, radius, control height, and CSS-first glass material. Never use tier-1 palette tokens or raw color literals in application CSS.
49
51
 
50
52
  ## Talking to ZeyOS
51
53
  Use the dedicated ZeyOS client library **`@zeyos/client`** (`npm install @zeyos/client`) for ZeyOS business data: `createZeyosClient({ platform, auth })`, typed `client.api.list/get/create/update<Resource>` over the full OpenAPI surface, and `normalizeListResult`. Zx accepts the client by injection and does not bundle it. See the `website/layouts/zeyos-invoices.layout.js` layout for a Zx-components-on-@zeyos/client reference. Zx also ships a minimal `Http`/`zeyosService` for ad-hoc in-app `remotecall`, but `@zeyos/client` is the recommended default.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeyos/zx",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Dependency-free JavaScript implementation of the Xenon Design System.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,105 @@
1
+ .zx-app-icon {
2
+ --zx-app-icon-size: 36px;
3
+ --zx-app-icon-glyph-size: 52%;
4
+ --zx-app-icon-color: var(--zx-color-accent);
5
+ position: relative;
6
+ isolation: isolate;
7
+ display: inline-grid;
8
+ place-items: center;
9
+ flex: none;
10
+ inline-size: var(--zx-app-icon-size);
11
+ block-size: var(--zx-app-icon-size);
12
+ overflow: hidden;
13
+ border: 1px solid var(--zx-color-glass-border);
14
+ border-radius: max(var(--zx-radius-md), calc(var(--zx-app-icon-size) * .22));
15
+ background: var(--zx-app-icon-color);
16
+ box-shadow: var(--zx-app-icon-shadow);
17
+ color: var(--zx-app-icon-glyph, var(--zx-color-text-invert));
18
+ font-size: var(--zx-app-icon-size);
19
+ vertical-align: middle;
20
+ }
21
+
22
+ .zx-app-icon[data-glass="subtle"],
23
+ .zx-app-icon[data-glass="strong"] {
24
+ background:
25
+ linear-gradient(145deg, var(--zx-color-glass-highlight), transparent 44%),
26
+ color-mix(in srgb, var(--zx-app-icon-color) var(--zx-glass-color-strength), transparent);
27
+ }
28
+
29
+ .zx-app-icon[data-glass="strong"] {
30
+ backdrop-filter: blur(var(--zx-glass-blur)) saturate(var(--zx-glass-saturation));
31
+ }
32
+
33
+ .zx-app-icon[data-glass="none"] {
34
+ border-color: var(--zx-color-border);
35
+ box-shadow: none;
36
+ }
37
+
38
+ .zx-app-icon[data-glass="none"] .zx-app-icon__shine { display: none; }
39
+
40
+ .zx-app-icon[data-selected] {
41
+ outline: 2px solid var(--zx-color-bg-page);
42
+ box-shadow: 0 0 0 3px var(--zx-color-ring), var(--zx-overlay-shadow);
43
+ }
44
+
45
+ .zx-app-icon__shine {
46
+ position: absolute;
47
+ z-index: -1;
48
+ inset: 1px 12% auto;
49
+ block-size: 42%;
50
+ border-radius: 999px;
51
+ background: linear-gradient(to bottom, var(--zx-color-glass-highlight-strong), transparent);
52
+ filter: blur(2px);
53
+ pointer-events: none;
54
+ }
55
+
56
+ .zx-app-icon__glyph {
57
+ display: inline-grid;
58
+ place-items: center;
59
+ inline-size: var(--zx-app-icon-glyph-size);
60
+ block-size: var(--zx-app-icon-glyph-size);
61
+ font-size: var(--zx-app-icon-glyph-size);
62
+ line-height: 1;
63
+ filter: drop-shadow(var(--zx-glass-glyph-shadow));
64
+ }
65
+
66
+ .zx-app-icon__glyph > .zx-icon,
67
+ .zx-app-icon__glyph > svg,
68
+ .zx-app-icon__glyph > i {
69
+ inline-size: 1em;
70
+ block-size: 1em;
71
+ }
72
+
73
+ .zx-app-icon__badge {
74
+ position: absolute;
75
+ inset-block-end: 1px;
76
+ inset-inline-end: 1px;
77
+ display: grid;
78
+ place-items: center;
79
+ min-inline-size: 14px;
80
+ block-size: 14px;
81
+ padding-inline: 3px;
82
+ border: 1px solid var(--zx-color-bg-raised);
83
+ border-radius: var(--zx-radius-full);
84
+ background: var(--zx-color-danger);
85
+ color: var(--zx-color-text-invert);
86
+ font-size: 9px;
87
+ font-weight: 700;
88
+ line-height: 1;
89
+ }
90
+
91
+ @media (prefers-reduced-transparency: reduce) {
92
+ .zx-app-icon[data-glass="strong"] {
93
+ background: var(--zx-app-icon-color);
94
+ backdrop-filter: none;
95
+ }
96
+ }
97
+
98
+ @media (forced-colors: active) {
99
+ .zx-app-icon {
100
+ border-color: ButtonText;
101
+ background: ButtonFace;
102
+ color: ButtonText;
103
+ box-shadow: none;
104
+ }
105
+ }
@@ -0,0 +1,134 @@
1
+ import { Component } from '../../core/component.js';
2
+ import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
3
+ import { icon as renderIcon } from '../../core/icons.js';
4
+ import { isCssColor } from '../../core/util.js';
5
+
6
+ /**
7
+ * @typedef {Object} AppIconOptions
8
+ * @property {string|Node|(()=>string|Node)|null} [icon=null] Icon name, node, or lazy renderer.
9
+ * @property {string|null} [color=null] Identity colour; falls back to the accent colour.
10
+ * @property {number|string} [size=36] Edge length in pixels or any CSS length.
11
+ * @property {number|string} [iconSize='52%'] Glyph size.
12
+ * @property {string|null} [label=null] Accessible image label; null makes the icon decorative.
13
+ * @property {string|number|null} [badge=null] Optional corner badge.
14
+ * @property {boolean} [selected=false] Whether the icon represents the active application.
15
+ * @property {false|'subtle'|'strong'} [glass='subtle'] CSS-first glass treatment.
16
+ * @property {string|string[]} [class] Additional class names.
17
+ */
18
+
19
+ /**
20
+ * Stable application-identity tile with progressive CSS glass treatment.
21
+ * @extends {Component<AppIconOptions>}
22
+ */
23
+ export class AppIcon extends Component {
24
+ static cssName = 'app-icon';
25
+
26
+ /** @type {Readonly<AppIconOptions>} */
27
+ static defaults = {
28
+ icon: null,
29
+ color: null,
30
+ size: 36,
31
+ iconSize: '52%',
32
+ label: null,
33
+ badge: null,
34
+ selected: false,
35
+ glass: 'subtle',
36
+ class: []
37
+ };
38
+
39
+ /** @returns {HTMLElement} */
40
+ render() {
41
+ this._createdRoot = this.el === null;
42
+ const root = /** @type {HTMLElement} */ (this.el ?? h('span'));
43
+ this.el = root;
44
+ this._snapshot = this._createdRoot ? null : snapshotTarget(root);
45
+ this._state = { ...this.options };
46
+ this._appliedClasses = [];
47
+ this._sync();
48
+ return root;
49
+ }
50
+
51
+ /** Updates identity or presentation without replacing the root. @param {Partial<AppIconOptions>} values @returns {this} */
52
+ set(values = {}) {
53
+ if (!values || typeof values !== 'object') return this;
54
+ Object.assign(this._state, values);
55
+ this._sync();
56
+ return this;
57
+ }
58
+
59
+ /** Restores an enhanced target. @returns {void} */
60
+ destroy() {
61
+ const root = this.el;
62
+ const snapshot = this._snapshot;
63
+ super.destroy();
64
+ if (!this._createdRoot && root) restoreTarget(root, snapshot);
65
+ }
66
+
67
+ /** @returns {void} */
68
+ _sync() {
69
+ const state = this._state;
70
+ this.el.classList.remove(...this._appliedClasses);
71
+ this._appliedClasses = [state.class].flat()
72
+ .flatMap((value) => String(value ?? '').split(/\s+/)).filter(Boolean);
73
+ this.el.classList.add(...this._appliedClasses);
74
+ const size = cssLength(state.size, '36px');
75
+ const glyphSize = cssLength(state.iconSize, '52%');
76
+ this.el.style.setProperty('--zx-app-icon-size', size);
77
+ this.el.style.setProperty('--zx-app-icon-glyph-size', glyphSize);
78
+ if (isCssColor(state.color)) this.el.style.setProperty('--zx-app-icon-color', String(state.color));
79
+ else this.el.style.removeProperty('--zx-app-icon-color');
80
+ this.el.dataset.glass = normalizeGlass(state.glass);
81
+ this.el.toggleAttribute('data-selected', Boolean(state.selected));
82
+
83
+ const label = state.label == null ? '' : String(state.label).trim();
84
+ if (label) {
85
+ this.el.setAttribute('role', 'img');
86
+ this.el.setAttribute('aria-label', label);
87
+ this.el.removeAttribute('aria-hidden');
88
+ } else {
89
+ this.el.removeAttribute('role');
90
+ this.el.removeAttribute('aria-label');
91
+ this.el.setAttribute('aria-hidden', 'true');
92
+ }
93
+
94
+ const visual = resolveVisual(state.icon);
95
+ const badge = state.badge == null || state.badge === '' ? null : h('span', {
96
+ class: 'zx-app-icon__badge', ariaHidden: 'true'
97
+ }, String(state.badge));
98
+ this.el.replaceChildren(
99
+ h('span', { class: 'zx-app-icon__shine', ariaHidden: 'true' }),
100
+ h('span', { class: 'zx-app-icon__glyph', ariaHidden: 'true' }, visual),
101
+ badge
102
+ );
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Creates an AppIcon element without retaining a component lifecycle handle.
108
+ * @param {AppIconOptions} [options={}] Icon options.
109
+ * @returns {HTMLElement}
110
+ */
111
+ export function appIcon(options = {}) {
112
+ return new AppIcon(null, options).toElement();
113
+ }
114
+
115
+ /** @param {AppIconOptions['icon']} value @returns {Node|string|null} */
116
+ function resolveVisual(value) {
117
+ const resolved = typeof value === 'function' ? value() : value;
118
+ if (typeof resolved === 'string') return renderIcon(resolved, { size: '1em' });
119
+ return resolved && typeof resolved === 'object' && typeof resolved.nodeType === 'number'
120
+ ? /** @type {Node} */ (resolved) : null;
121
+ }
122
+
123
+ /** @param {unknown} value @param {string} fallback @returns {string} */
124
+ function cssLength(value, fallback) {
125
+ if (typeof value === 'number' && Number.isFinite(value)) return `${Math.max(1, value)}px`;
126
+ const text = String(value ?? '').trim();
127
+ return text && !/[;{}]/.test(text) ? text : fallback;
128
+ }
129
+
130
+ /** @param {unknown} value @returns {'none'|'subtle'|'strong'} */
131
+ function normalizeGlass(value) {
132
+ if (value === false || value === 'none') return 'none';
133
+ return value === 'strong' ? 'strong' : 'subtle';
134
+ }
@@ -0,0 +1,226 @@
1
+ import { uid } from '../../core/util.js';
2
+
3
+ /** @typedef {null|boolean|number|string|FilterJson[]|{[key:string]: FilterJson}} FilterJson */
4
+ /** @typedef {{kind:'condition', id:string, field:string, operator:string, value:FilterJson}} FilterCondition */
5
+ /** @typedef {{kind:'group', id:string, logic:'and'|'or', children:FilterNode[]}} FilterGroup */
6
+ /** @typedef {FilterCondition|FilterGroup} FilterNode */
7
+ /** @typedef {{version:1, root:FilterGroup}} FilterAst */
8
+ /** @typedef {{id:string, label:string, arity:'none'|'single'|'pair'|'many', types:string[]}} FilterOperator */
9
+ /** @typedef {{id:string, label:string, type:string, operators?:string[], defaultOperator?:string}} FilterField */
10
+ /** @typedef {{nodeId:string, code:string, message:string}} FilterValidationError */
11
+
12
+ /** Core operators are application-neutral IDs; an adapter maps them to executable query syntax. */
13
+ export const filterOperators = Object.freeze([
14
+ op('contains', 'contains', 'single', ['text']),
15
+ op('notContains', 'does not contain', 'single', ['text']),
16
+ op('eq', 'is', 'single', ['text', 'number', 'money', 'boolean', 'enum', 'status', 'priority', 'country', 'currency', 'unit', 'entity', 'date', 'datetime']),
17
+ op('neq', 'is not', 'single', ['text', 'number', 'money', 'boolean', 'enum', 'status', 'priority', 'country', 'currency', 'unit', 'entity', 'date', 'datetime']),
18
+ op('startsWith', 'starts with', 'single', ['text']),
19
+ op('endsWith', 'ends with', 'single', ['text']),
20
+ op('lt', 'is less than', 'single', ['number', 'money']),
21
+ op('lte', 'is at most', 'single', ['number', 'money']),
22
+ op('gt', 'is greater than', 'single', ['number', 'money']),
23
+ op('gte', 'is at least', 'single', ['number', 'money']),
24
+ op('on', 'is on', 'single', ['date', 'datetime']),
25
+ op('notOn', 'is not on', 'single', ['date', 'datetime']),
26
+ op('before', 'is before', 'single', ['date', 'datetime']),
27
+ op('onOrBefore', 'is on or before', 'single', ['date', 'datetime']),
28
+ op('after', 'is after', 'single', ['date', 'datetime']),
29
+ op('onOrAfter', 'is on or after', 'single', ['date', 'datetime']),
30
+ op('between', 'is between', 'pair', ['number', 'money', 'date', 'datetime']),
31
+ op('notBetween', 'is not between', 'pair', ['number', 'money', 'date', 'datetime']),
32
+ op('withinLast', 'is within the last', 'pair', ['date', 'datetime']),
33
+ op('withinNext', 'is within the next', 'pair', ['date', 'datetime']),
34
+ op('anyOf', 'is any of', 'many', ['enum', 'status', 'priority', 'country', 'currency', 'unit', 'entity']),
35
+ op('noneOf', 'is none of', 'many', ['enum', 'status', 'priority', 'country', 'currency', 'unit', 'entity']),
36
+ op('containsAny', 'contains any', 'many', ['tags']),
37
+ op('containsAll', 'contains all', 'many', ['tags']),
38
+ op('containsNone', 'contains none', 'many', ['tags']),
39
+ op('isEmpty', 'is empty', 'none', ['text', 'number', 'money', 'date', 'datetime', 'boolean', 'enum', 'status', 'priority', 'country', 'currency', 'unit', 'entity', 'tags']),
40
+ op('isNotEmpty', 'is not empty', 'none', ['text', 'number', 'money', 'date', 'datetime', 'boolean', 'enum', 'status', 'priority', 'country', 'currency', 'unit', 'entity', 'tags'])
41
+ ]);
42
+
43
+ /** @returns {FilterAst} */
44
+ export function emptyFilterAst() {
45
+ return { version: 1, root: { kind: 'group', id: uid('filter-root'), logic: 'and', children: [] } };
46
+ }
47
+
48
+ /** @param {Partial<FilterCondition>} [initial={}] @returns {FilterCondition} */
49
+ export function filterCondition(initial = {}) {
50
+ return {
51
+ kind: 'condition',
52
+ id: String(initial.id || uid('filter-condition')),
53
+ field: String(initial.field ?? ''),
54
+ operator: String(initial.operator ?? ''),
55
+ value: cloneFilterValue(initial.value ?? null)
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Returns a defensive JSON-safe copy suitable for a condition value.
61
+ * @param {unknown} value
62
+ * @returns {FilterJson}
63
+ */
64
+ export function cloneFilterValue(value) {
65
+ return cloneJson(value);
66
+ }
67
+
68
+ /** @param {'and'|'or'} [logic='and'] @param {FilterNode[]} [children=[]] @returns {FilterGroup} */
69
+ export function filterGroup(logic = 'and', children = []) {
70
+ return { kind: 'group', id: uid('filter-group'), logic, children };
71
+ }
72
+
73
+ /**
74
+ * Parses untrusted state into a defensive JSON-safe AST. It rejects structural corruption but
75
+ * deliberately keeps unknown field/operator IDs for semantic validation and migration UI.
76
+ * @param {unknown} value
77
+ * @param {{maxDepth?:number, maxConditions?:number}} [limits={}]
78
+ * @returns {FilterAst}
79
+ */
80
+ export function parseFilterAst(value, limits = {}) {
81
+ const maxDepth = finiteLimit(limits.maxDepth, 3);
82
+ const maxConditions = finiteLimit(limits.maxConditions, 50);
83
+ if (!plainObject(value) || value.version !== 1 || !plainObject(value.root)) {
84
+ throw new TypeError('Filter AST must have version 1 and a root group');
85
+ }
86
+ const ids = new Set();
87
+ let conditions = 0;
88
+ const visit = (raw, depth, root = false) => {
89
+ if (!plainObject(raw)) throw new TypeError('Filter nodes must be plain objects');
90
+ const id = String(raw.id ?? '').trim();
91
+ if (!id || ids.has(id)) throw new TypeError('Filter node IDs must be non-empty and unique');
92
+ ids.add(id);
93
+ if (raw.kind === 'group') {
94
+ if (depth > maxDepth) throw new RangeError('Filter group depth exceeds maxDepth');
95
+ if (raw.logic !== 'and' && raw.logic !== 'or') throw new TypeError('Filter group logic must be and or or');
96
+ if (!Array.isArray(raw.children)) throw new TypeError('Filter group children must be an array');
97
+ return {
98
+ kind: 'group', id, logic: raw.logic,
99
+ children: raw.children.map((child) => visit(child, depth + 1, false))
100
+ };
101
+ }
102
+ if (root || raw.kind !== 'condition') throw new TypeError('Filter root must be a group');
103
+ conditions += 1;
104
+ if (conditions > maxConditions) throw new RangeError('Filter condition count exceeds maxConditions');
105
+ return {
106
+ kind: 'condition', id,
107
+ field: String(raw.field ?? ''), operator: String(raw.operator ?? ''),
108
+ value: cloneJson(raw.value)
109
+ };
110
+ };
111
+ const root = visit(value.root, 0, true);
112
+ if (root.kind !== 'group') throw new TypeError('Filter root must be a group');
113
+ return /** @type {FilterAst} */ ({ version: 1, root });
114
+ }
115
+
116
+ /** @param {FilterAst} value @returns {FilterAst} */
117
+ export function cloneFilterAst(value) {
118
+ return parseFilterAst(value, { maxDepth: Infinity, maxConditions: Infinity });
119
+ }
120
+
121
+ /** @param {FilterAst} value @returns {string} */
122
+ export function stringifyFilterAst(value) {
123
+ return JSON.stringify(cloneFilterAst(value));
124
+ }
125
+
126
+ /**
127
+ * Performs field/operator/arity validation without compiling or executing the expression.
128
+ * @param {FilterAst} ast
129
+ * @param {FilterField[]} fields
130
+ * @param {FilterOperator[]} [operators=filterOperators]
131
+ * @returns {{valid:boolean, errors:FilterValidationError[]}}
132
+ */
133
+ export function validateFilterAst(ast, fields, operators = filterOperators) {
134
+ const errors = [];
135
+ const fieldMap = new Map((Array.isArray(fields) ? fields : []).map((field) => [String(field.id), field]));
136
+ const operatorMap = new Map((Array.isArray(operators) ? operators : []).map((operator) => [String(operator.id), operator]));
137
+ const visit = (node, root = false) => {
138
+ if (node.kind === 'group') {
139
+ if (!root && node.children.length === 0) errors.push(error(node.id, 'empty-group', 'Nested groups need at least one condition'));
140
+ node.children.forEach((child) => visit(child));
141
+ return;
142
+ }
143
+ const field = fieldMap.get(node.field);
144
+ if (!field) errors.push(error(node.id, 'unknown-field', node.field ? `Unavailable field: ${node.field}` : 'Choose a field'));
145
+ const operator = operatorMap.get(node.operator);
146
+ if (!operator) {
147
+ errors.push(error(node.id, 'unknown-operator', node.operator ? `Unavailable operator: ${node.operator}` : 'Choose an operator'));
148
+ return;
149
+ }
150
+ if (field) {
151
+ const allowed = Array.isArray(field.operators) && field.operators.length
152
+ ? field.operators : operators.filter((candidate) => candidate.types.includes(field.type)).map((candidate) => candidate.id);
153
+ if (!allowed.includes(operator.id) || !operator.types.includes(field.type)) {
154
+ errors.push(error(node.id, 'operator-type', `${operator.label} is not available for ${field.label}`));
155
+ }
156
+ }
157
+ if (!validArity(operator.arity, node.value)) {
158
+ errors.push(error(node.id, 'value', operator.arity === 'pair' ? 'Enter both values'
159
+ : operator.arity === 'many' ? 'Choose at least one value'
160
+ : operator.arity === 'none' ? 'This operator does not accept a value' : 'Enter a value'));
161
+ }
162
+ };
163
+ visit(ast.root, true);
164
+ return { valid: errors.length === 0, errors };
165
+ }
166
+
167
+ /** @param {string} id @param {string} label @param {FilterOperator['arity']} arity @param {string[]} types @returns {FilterOperator} */
168
+ function op(id, label, arity, types) { return Object.freeze({ id, label, arity, types: Object.freeze(types) }); }
169
+
170
+ /** @param {string} nodeId @param {string} code @param {string} message @returns {FilterValidationError} */
171
+ function error(nodeId, code, message) { return { nodeId, code, message }; }
172
+
173
+ /** @param {FilterOperator['arity']} arity @param {FilterJson} value @returns {boolean} */
174
+ function validArity(arity, value) {
175
+ if (arity === 'none') return value === null;
176
+ if (arity === 'pair') return Array.isArray(value) && value.length === 2 && value.every(filled);
177
+ if (arity === 'many') return Array.isArray(value) && value.length > 0 && value.every(filled);
178
+ return filled(value);
179
+ }
180
+
181
+ /** @param {unknown} value @returns {boolean} */
182
+ function filled(value) { return value !== null && value !== undefined && !(typeof value === 'string' && value.trim() === ''); }
183
+
184
+ /** @param {unknown} value @param {WeakSet<object>} [seen=new WeakSet()] @returns {FilterJson} */
185
+ function cloneJson(value, seen = new WeakSet()) {
186
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
187
+ if (typeof value === 'number') {
188
+ if (!Number.isFinite(value)) throw new TypeError('Filter values cannot contain non-finite numbers');
189
+ return value;
190
+ }
191
+ if (typeof value !== 'object' || value instanceof Date || typeof value.nodeType === 'number') {
192
+ throw new TypeError('Filter values must be JSON-safe');
193
+ }
194
+ if (seen.has(value)) throw new TypeError('Filter values cannot contain cycles');
195
+ seen.add(value);
196
+ if (Array.isArray(value)) {
197
+ const result = value.map((item) => cloneJson(item, seen));
198
+ seen.delete(value);
199
+ return result;
200
+ }
201
+ if (!plainObject(value)) throw new TypeError('Filter values must be plain JSON objects');
202
+ const result = {};
203
+ for (const [key, item] of Object.entries(value)) {
204
+ // Assignment to `__proto__` is special on ordinary objects. Defining an own data property
205
+ // preserves the JSON member without changing the clone's prototype.
206
+ Object.defineProperty(result, key, {
207
+ value: cloneJson(item, seen), enumerable: true, configurable: true, writable: true
208
+ });
209
+ }
210
+ seen.delete(value);
211
+ return result;
212
+ }
213
+
214
+ /** @param {unknown} value @returns {boolean} */
215
+ function plainObject(value) {
216
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
217
+ const prototype = Object.getPrototypeOf(value);
218
+ return prototype === Object.prototype || prototype === null;
219
+ }
220
+
221
+ /** @param {unknown} value @param {number} fallback @returns {number} */
222
+ function finiteLimit(value, fallback) {
223
+ if (value === Infinity) return Infinity;
224
+ const number = Number(value);
225
+ return Number.isFinite(number) && number >= 0 ? Math.floor(number) : fallback;
226
+ }
@@ -0,0 +1,147 @@
1
+ .zx-filter {
2
+ display: grid;
3
+ gap: var(--zx-space-3);
4
+ color: var(--zx-color-text);
5
+ }
6
+
7
+ .zx-filter__status {
8
+ position: absolute;
9
+ inline-size: 1px;
10
+ block-size: 1px;
11
+ overflow: hidden;
12
+ clip-path: inset(50%);
13
+ }
14
+
15
+ .zx-filter__group,
16
+ .zx-filter__group > fieldset {
17
+ min-inline-size: 0;
18
+ margin: 0;
19
+ border: 1px solid var(--zx-color-border);
20
+ border-radius: var(--zx-radius-lg);
21
+ background: var(--zx-color-bg-surface);
22
+ padding: var(--zx-space-3);
23
+ }
24
+
25
+ .zx-filter__group .zx-filter__group {
26
+ border: 0;
27
+ border-inline-start: 3px solid var(--zx-color-border-strong);
28
+ border-radius: 0;
29
+ background: transparent;
30
+ padding: 0 0 0 var(--zx-space-3);
31
+ }
32
+
33
+ .zx-filter__legend {
34
+ display: inline-flex;
35
+ align-items: center;
36
+ gap: var(--zx-space-2);
37
+ padding-inline: var(--zx-space-1);
38
+ color: var(--zx-color-text-muted);
39
+ font-size: var(--zx-text-sm);
40
+ }
41
+
42
+ .zx-filter__logic,
43
+ .zx-filter__field,
44
+ .zx-filter__operator,
45
+ .zx-filter__value input,
46
+ .zx-filter__value select {
47
+ min-block-size: var(--zx-control-height);
48
+ border: 1px solid var(--zx-color-border-control);
49
+ border-radius: var(--zx-control-radius);
50
+ background: var(--zx-color-bg-control);
51
+ padding-inline: var(--zx-control-pad-x);
52
+ color: var(--zx-color-text);
53
+ font: inherit;
54
+ }
55
+
56
+ .zx-filter__logic {
57
+ inline-size: auto;
58
+ min-block-size: 28px;
59
+ font-size: var(--zx-text-sm);
60
+ }
61
+
62
+ .zx-filter__children {
63
+ display: grid;
64
+ gap: var(--zx-space-2);
65
+ margin: var(--zx-space-2) 0;
66
+ padding: 0;
67
+ list-style: none;
68
+ }
69
+
70
+ .zx-filter__condition {
71
+ display: grid;
72
+ grid-template-columns: minmax(9rem, 1fr) minmax(9rem, 1fr) minmax(10rem, 1.4fr) auto;
73
+ align-items: start;
74
+ gap: var(--zx-space-2);
75
+ border-radius: var(--zx-radius-md);
76
+ }
77
+
78
+ .zx-filter__condition[data-invalid] {
79
+ outline: 1px solid var(--zx-color-danger);
80
+ outline-offset: var(--zx-space-1);
81
+ }
82
+
83
+ .zx-filter__value {
84
+ display: grid;
85
+ grid-template-columns: repeat(2, minmax(0, 1fr));
86
+ gap: var(--zx-space-2);
87
+ min-inline-size: 0;
88
+ }
89
+
90
+ .zx-filter__value-control,
91
+ .zx-filter__value-control > input {
92
+ inline-size: 100%;
93
+ min-inline-size: 0;
94
+ }
95
+
96
+ .zx-filter__value > :only-child,
97
+ .zx-filter__value--empty {
98
+ grid-column: 1 / -1;
99
+ }
100
+
101
+ .zx-filter__value--empty {
102
+ display: flex;
103
+ align-items: center;
104
+ min-block-size: var(--zx-control-height);
105
+ padding-inline: var(--zx-control-pad-x);
106
+ color: var(--zx-color-text-muted);
107
+ font-size: var(--zx-text-sm);
108
+ }
109
+
110
+ .zx-filter__group-actions,
111
+ .zx-filter__actions {
112
+ display: flex;
113
+ flex-wrap: wrap;
114
+ gap: var(--zx-space-2);
115
+ }
116
+
117
+ .zx-filter__actions {
118
+ justify-content: flex-end;
119
+ }
120
+
121
+ .zx-filter[data-readonly] .zx-filter__group-actions,
122
+ .zx-filter[data-readonly] .zx-filter__actions,
123
+ .zx-filter[data-disabled] .zx-filter__group-actions,
124
+ .zx-filter[data-disabled] .zx-filter__actions {
125
+ display: none;
126
+ }
127
+
128
+ @media (max-width: 680px) {
129
+ .zx-filter__condition {
130
+ grid-template-columns: minmax(0, 1fr);
131
+ }
132
+
133
+ .zx-filter__group .zx-filter__group {
134
+ padding-inline-start: var(--zx-space-2);
135
+ }
136
+
137
+ .zx-filter__value {
138
+ grid-template-columns: minmax(0, 1fr);
139
+ }
140
+ }
141
+
142
+ @media (forced-colors: active) {
143
+ .zx-filter__group,
144
+ .zx-filter__condition[data-invalid] {
145
+ border-color: CanvasText;
146
+ }
147
+ }