@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.
- package/CHANGELOG.md +44 -0
- package/DESIGN-SYSTEM.md +59 -3
- package/dist/types/components/app-icon/app-icon.d.ts +88 -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/select/select.d.ts +46 -4
- package/dist/types/components/table/table.d.ts +103 -0
- package/dist/types/components/tag-picker/tag-picker.d.ts +16 -0
- package/dist/types/core/dom.d.ts +1 -1
- package/dist/types/core/util.d.ts +9 -0
- package/dist/types/index.d.ts +4 -1
- package/dist/types/zeyos/icons.d.ts +19 -2
- package/dist/types/zeyos/index.d.ts +1 -1
- package/dist/types/zeyos/select.d.ts +2 -0
- package/dist/zx-zeyos.esm.js +702 -75
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +415 -4
- package/dist/zx.esm.js +1568 -115
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +1568 -115
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +379 -0
- package/docs/llms.md +93 -16
- package/docs/llms.txt +11 -9
- package/package.json +1 -1
- package/src/components/app-icon/app-icon.css +105 -0
- package/src/components/app-icon/app-icon.js +134 -0
- 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 +4 -1
- package/src/components/message/message.css +36 -3
- package/src/components/select/select.css +17 -0
- package/src/components/select/select.js +105 -24
- package/src/components/table/table.css +85 -0
- package/src/components/table/table.js +418 -24
- 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 +1 -1
- package/src/core/util.js +20 -0
- package/src/index.js +7 -1
- package/src/zeyos/icons.js +34 -21
- package/src/zeyos/index.js +1 -1
- package/src/zeyos/select.js +6 -5
- package/styles/tokens/semantic.css +9 -0
- package/styles/tokens/themes.css +2 -2
- 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`, `
|
|
26
|
-
`filter: false|'local'|async`; priority, status, permission, and
|
|
27
|
-
`Checklist`, `TagPicker
|
|
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
|
|
32
|
-
|
|
33
|
-
`
|
|
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), `
|
|
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
|
@@ -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
|
+
}
|