@zeyos/zx 3.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -0
- package/DESIGN-SYSTEM.md +87 -71
- package/README.md +16 -37
- package/dist/types/components/app-icon/app-icon.d.ts +88 -0
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +106 -32
- package/dist/types/components/card/card.d.ts +128 -0
- package/dist/types/components/chart/chart.d.ts +1 -0
- package/dist/types/components/filter/filter-model.d.ts +87 -0
- package/dist/types/components/filter/filter.d.ts +331 -0
- package/dist/types/components/launcher/launcher.d.ts +65 -6
- package/dist/types/components/select/select.d.ts +97 -4
- package/dist/types/components/table/table.d.ts +107 -3
- package/dist/types/components/tag-picker/tag-picker.d.ts +16 -0
- package/dist/types/core/dom.d.ts +9 -1
- package/dist/types/core/util.d.ts +9 -0
- package/dist/types/index.d.ts +6 -2
- package/dist/types/{components/app-rail → internal}/app-rail.d.ts +34 -19
- package/dist/types/zeyos/icons.d.ts +24 -2
- package/dist/types/zeyos/index.d.ts +3 -2
- package/dist/types/zeyos/launcher.d.ts +147 -0
- package/dist/types/zeyos/select.d.ts +41 -0
- package/dist/zx-zeyos.esm.js +2018 -113
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +815 -16
- package/dist/zx.esm.js +2661 -338
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +2661 -338
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +602 -110
- package/docs/llms.md +181 -123
- package/docs/llms.txt +24 -21
- package/package.json +8 -17
- package/src/components/app-icon/app-icon.css +105 -0
- package/src/components/app-icon/app-icon.js +134 -0
- package/src/components/app-sidebar/app-sidebar.css +12 -0
- package/src/components/app-sidebar/app-sidebar.js +310 -110
- package/src/components/card/card.css +149 -0
- package/src/components/card/card.js +285 -0
- package/src/components/chart/chart.js +8 -3
- package/src/components/filter/filter-model.js +226 -0
- package/src/components/filter/filter.css +147 -0
- package/src/components/filter/filter.js +554 -0
- package/src/components/grid/grid.css +1 -1
- package/src/components/grid/grid.js +14 -4
- package/src/components/launcher/launcher.css +113 -3
- package/src/components/launcher/launcher.js +234 -26
- package/src/components/message/message.css +36 -3
- package/src/components/select/field-adapter.js +9 -4
- package/src/components/select/select.css +161 -10
- package/src/components/select/select.js +361 -48
- package/src/components/table/table.css +85 -0
- package/src/components/table/table.js +426 -31
- package/src/components/tag-picker/tag-picker.css +19 -0
- package/src/components/tag-picker/tag-picker.js +42 -5
- package/src/core/dom.js +21 -1
- package/src/core/util.js +20 -0
- package/src/index.js +9 -2
- package/src/{components/app-rail → internal}/app-rail.js +80 -30
- package/src/zeyos/icons.js +38 -20
- package/src/zeyos/index.js +8 -2
- package/src/zeyos/launcher.js +338 -0
- package/src/zeyos/modules.js +1 -0
- package/src/zeyos/select.js +116 -1
- package/styles/tokens/global.css +1 -1
- package/styles/tokens/modules.css +1 -0
- package/styles/tokens/semantic.css +9 -0
- package/styles/tokens/themes.css +2 -2
- package/styles/zx.css +3 -0
- package/MIGRATION.md +0 -749
- package/dist/types/compat/base.d.ts +0 -156
- package/dist/types/compat/globals.d.ts +0 -20
- package/dist/types/compat/index.d.ts +0 -128
- package/dist/types/compat/map/basic.d.ts +0 -175
- package/dist/types/compat/map/data.d.ts +0 -103
- package/dist/types/compat/map/date-time.d.ts +0 -80
- package/dist/types/compat/map/factory.d.ts +0 -9
- package/dist/types/compat/map/form.d.ts +0 -98
- package/dist/types/compat/map/helpers.d.ts +0 -12
- package/dist/types/compat/map/http.d.ts +0 -36
- package/dist/types/compat/map/layout.d.ts +0 -106
- package/dist/types/compat/map/options.d.ts +0 -80
- package/dist/types/compat/map/select.d.ts +0 -95
- package/dist/types/compat/map/stubs.d.ts +0 -32
- package/dist/types/compat/map/utilities.d.ts +0 -37
- package/dist/types/compat-entry.d.ts +0 -5
- package/dist/zx-compat.esm.js +0 -17848
- package/dist/zx-compat.esm.min.js +0 -1
- package/dist/zx-compat.global.js +0 -29008
- package/dist/zx-compat.global.min.js +0 -3
- package/src/compat/base.js +0 -291
- package/src/compat/globals.js +0 -167
- package/src/compat/index.js +0 -84
- package/src/compat/map/basic.js +0 -464
- package/src/compat/map/data.js +0 -254
- package/src/compat/map/date-time.js +0 -177
- package/src/compat/map/factory.js +0 -45
- package/src/compat/map/form.js +0 -330
- package/src/compat/map/helpers.js +0 -46
- package/src/compat/map/http.js +0 -117
- package/src/compat/map/layout.js +0 -256
- package/src/compat/map/options.js +0 -267
- package/src/compat/map/select.js +0 -198
- package/src/compat/map/stubs.js +0 -26
- package/src/compat/map/utilities.js +0 -113
- package/src/compat-entry.js +0 -11
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import { Component } from '../../core/component.js';
|
|
2
|
+
import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
|
|
3
|
+
import { icon } from '../../core/icons.js';
|
|
4
|
+
import {
|
|
5
|
+
cloneFilterAst, cloneFilterValue, emptyFilterAst, filterCondition, filterGroup, filterOperators,
|
|
6
|
+
parseFilterAst, validateFilterAst
|
|
7
|
+
} from './filter-model.js';
|
|
8
|
+
|
|
9
|
+
/** @typedef {import('./filter-model.js').FilterAst} FilterAst */
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} FilterField
|
|
12
|
+
* @property {string} id Stable allowlisted field ID.
|
|
13
|
+
* @property {string} label Display label.
|
|
14
|
+
* @property {'text'|'number'|'money'|'date'|'datetime'|'boolean'|'enum'|'status'|'priority'|'country'|'currency'|'unit'|'entity'|'tags'|'custom'} type Editor/operator type.
|
|
15
|
+
* @property {string[]} [operators] Explicit allowlisted operator IDs.
|
|
16
|
+
* @property {string} [defaultOperator] Initial operator ID.
|
|
17
|
+
* @property {Array<{value:string|number|boolean,label:string,icon?:Node|string,color?:string}>} [choices] Static choices.
|
|
18
|
+
* @property {(context:{query:string,signal:AbortSignal,conditionId:string,field:FilterField})=>Promise<Array<{value:string|number|boolean,label:string}>>|Array<{value:string|number|boolean,label:string}>} [loadChoices] Async choices.
|
|
19
|
+
* @property {(target:Element, context:{value:unknown,setValue:(value:unknown)=>void,field:FilterField,conditionId:string})=>({destroy?:()=>void}|void)} [editor] Custom editor.
|
|
20
|
+
* @property {number} [minQuery=0] Async minimum query length.
|
|
21
|
+
* @property {number} [debounce=200] Async debounce in milliseconds.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} FilterOptions
|
|
25
|
+
* @property {FilterField[]} fields Field definitions.
|
|
26
|
+
* @property {FilterAst} [value] Initial expression.
|
|
27
|
+
* @property {Array<{id:string,label:string,arity:'none'|'single'|'pair'|'many',types:string[]}>} [operators] Additional/overridden operators.
|
|
28
|
+
* @property {boolean} [allowGroups=true] Whether nested groups may be authored.
|
|
29
|
+
* @property {number} [maxDepth=3] Maximum nested group depth.
|
|
30
|
+
* @property {number} [maxConditions=50] Maximum condition count.
|
|
31
|
+
* @property {boolean} [autoApply=false] Emit apply after every valid mutation.
|
|
32
|
+
* @property {boolean} [readonly=false] Prevent mutations while keeping values readable.
|
|
33
|
+
* @property {boolean} [disabled=false] Disable all controls.
|
|
34
|
+
* @property {string} [applyLabel='Apply filters'] Apply button label.
|
|
35
|
+
* @property {string} [clearLabel='Clear'] Clear button label.
|
|
36
|
+
* @property {(event:CustomEvent<Record<string,unknown>>)=>void} [onchange] Draft-change listener.
|
|
37
|
+
* @property {(event:CustomEvent<Record<string,unknown>>)=>void} [onapply] Valid-apply listener.
|
|
38
|
+
* @property {(event:CustomEvent<Record<string,unknown>>)=>void} [oninvalid] Invalid-apply listener.
|
|
39
|
+
* @property {(event:CustomEvent<Record<string,unknown>>)=>void} [onquery] Async query listener.
|
|
40
|
+
* @property {(event:CustomEvent<Record<string,unknown>>)=>void} [onloaded] Async loaded listener.
|
|
41
|
+
* @property {(event:CustomEvent<Record<string,unknown>>)=>void} [onerror] Async failure listener.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Backend-neutral dynamic filter expression editor.
|
|
46
|
+
* @fires Filter#change
|
|
47
|
+
* @fires Filter#apply
|
|
48
|
+
* @fires Filter#invalid
|
|
49
|
+
* @fires Filter#query
|
|
50
|
+
* @fires Filter#loaded
|
|
51
|
+
* @fires Filter#error
|
|
52
|
+
* @extends {Component<FilterOptions>}
|
|
53
|
+
*/
|
|
54
|
+
export class Filter extends Component {
|
|
55
|
+
static cssName = 'filter';
|
|
56
|
+
|
|
57
|
+
/** @type {Readonly<FilterOptions>} */
|
|
58
|
+
static defaults = {
|
|
59
|
+
fields: [], value: null, operators: [], allowGroups: true, maxDepth: 3, maxConditions: 50,
|
|
60
|
+
autoApply: false, readonly: false, disabled: false,
|
|
61
|
+
applyLabel: 'Apply filters', clearLabel: 'Clear'
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** @returns {HTMLElement} */
|
|
65
|
+
render() {
|
|
66
|
+
this._createdRoot = this.el === null;
|
|
67
|
+
const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
|
|
68
|
+
this.el = root;
|
|
69
|
+
this._snapshot = this._createdRoot ? null : snapshotTarget(root);
|
|
70
|
+
this._fields = (Array.isArray(this.options.fields) ? this.options.fields : []).map((field) => ({ ...field }));
|
|
71
|
+
this._operators = mergeOperators(filterOperators, this.options.operators);
|
|
72
|
+
this._readonly = Boolean(this.options.readonly);
|
|
73
|
+
this._disabled = Boolean(this.options.disabled);
|
|
74
|
+
this._async = new Map();
|
|
75
|
+
this._customEditors = [];
|
|
76
|
+
this._value = this.options.value == null ? emptyFilterAst()
|
|
77
|
+
: parseFilterAst(this.options.value, this._limits());
|
|
78
|
+
|
|
79
|
+
this.listen(root, 'change', (event) => this._handleChange(event));
|
|
80
|
+
this.listen(root, 'input', (event) => this._handleInput(event));
|
|
81
|
+
this.listen(root, 'click', (event) => this._handleClick(event));
|
|
82
|
+
this._render();
|
|
83
|
+
return root;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Returns a defensive AST copy. @returns {FilterAst} */
|
|
87
|
+
getValue() { return cloneFilterAst(this._value); }
|
|
88
|
+
|
|
89
|
+
/** Atomically replaces the AST. @param {unknown} value @param {{silent?:boolean}} [options={}] @returns {this} */
|
|
90
|
+
setValue(value, { silent = false } = {}) {
|
|
91
|
+
const parsed = parseFilterAst(value, this._limits());
|
|
92
|
+
this._abortAll();
|
|
93
|
+
this._value = parsed;
|
|
94
|
+
this._render();
|
|
95
|
+
if (!silent) this._changed('set', this._value.root.id);
|
|
96
|
+
return this;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Adds a condition to a group. @param {string|null} [parentId=null] @param {Record<string,unknown>} [initial={}] @returns {string|null} */
|
|
100
|
+
addCondition(parentId = null, initial = {}) {
|
|
101
|
+
if (!this._canMutate() || conditionCount(this._value.root) >= Number(this.options.maxConditions)) return null;
|
|
102
|
+
const parent = findGroup(this._value.root, parentId ?? this._value.root.id);
|
|
103
|
+
if (!parent) return null;
|
|
104
|
+
const first = this._fields[0];
|
|
105
|
+
const field = String(initial.field ?? first?.id ?? '');
|
|
106
|
+
const definition = this._field(field);
|
|
107
|
+
const node = filterCondition({
|
|
108
|
+
...initial, field,
|
|
109
|
+
operator: String(initial.operator ?? this._defaultOperator(definition) ?? '')
|
|
110
|
+
});
|
|
111
|
+
parent.children.push(node);
|
|
112
|
+
this._render();
|
|
113
|
+
this._changed('add', node.id);
|
|
114
|
+
this.focus(node.id);
|
|
115
|
+
return node.id;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Adds a nested group. @param {string|null} [parentId=null] @param {'and'|'or'} [logic='and'] @returns {string|null} */
|
|
119
|
+
addGroup(parentId = null, logic = 'and') {
|
|
120
|
+
if (!this._canMutate() || !this.options.allowGroups) return null;
|
|
121
|
+
const parent = findGroup(this._value.root, parentId ?? this._value.root.id);
|
|
122
|
+
if (!parent || groupDepth(this._value.root, parent.id) >= Number(this.options.maxDepth)) return null;
|
|
123
|
+
const group = filterGroup(logic === 'or' ? 'or' : 'and');
|
|
124
|
+
parent.children.push(group);
|
|
125
|
+
this._render();
|
|
126
|
+
this._changed('add', group.id);
|
|
127
|
+
this.focus(group.id);
|
|
128
|
+
return group.id;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Updates a node by stable ID. @param {string} id @param {Record<string,unknown>} patch @returns {this} */
|
|
132
|
+
update(id, patch = {}) {
|
|
133
|
+
if (!this._canMutate()) return this;
|
|
134
|
+
const node = findNode(this._value.root, id);
|
|
135
|
+
if (!node) return this;
|
|
136
|
+
if (node.kind === 'group') {
|
|
137
|
+
if (patch.logic === 'and' || patch.logic === 'or') node.logic = patch.logic;
|
|
138
|
+
} else {
|
|
139
|
+
if (Object.hasOwn(patch, 'field')) node.field = String(patch.field ?? '');
|
|
140
|
+
if (Object.hasOwn(patch, 'operator')) node.operator = String(patch.operator ?? '');
|
|
141
|
+
if (Object.hasOwn(patch, 'value')) node.value = parseValueJson(patch.value);
|
|
142
|
+
}
|
|
143
|
+
this._abort(id);
|
|
144
|
+
this._render();
|
|
145
|
+
this._changed('update', id);
|
|
146
|
+
return this;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Removes a non-root node. @param {string} id @returns {this} */
|
|
150
|
+
remove(id) {
|
|
151
|
+
if (!this._canMutate() || id === this._value.root.id) return this;
|
|
152
|
+
const parent = findParent(this._value.root, id);
|
|
153
|
+
if (!parent) return this;
|
|
154
|
+
const index = parent.children.findIndex((child) => child.id === id);
|
|
155
|
+
const focusId = parent.children[index + 1]?.id ?? parent.children[index - 1]?.id ?? parent.id;
|
|
156
|
+
const removed = removeNode(this._value.root, id);
|
|
157
|
+
if (!removed) return this;
|
|
158
|
+
this._abortTree(removed);
|
|
159
|
+
this._render();
|
|
160
|
+
const label = removed.kind === 'condition' ? this._field(removed.field)?.label ?? 'Filter' : 'Filter group';
|
|
161
|
+
this._announce(`${label} removed`);
|
|
162
|
+
this._changed('remove', id);
|
|
163
|
+
this.focus(focusId);
|
|
164
|
+
return this;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Moves a node into a group while preserving its identity. @param {string} id @param {string} parentId @param {number} index @returns {this} */
|
|
168
|
+
move(id, parentId, index) {
|
|
169
|
+
const node = findNode(this._value.root, id);
|
|
170
|
+
const parent = findGroup(this._value.root, parentId);
|
|
171
|
+
const previous = findParent(this._value.root, id);
|
|
172
|
+
if (!this._canMutate() || id === this._value.root.id || !node || !parent || !previous
|
|
173
|
+
|| containsNode(node, parentId)) return this;
|
|
174
|
+
const previousIndex = previous.children.findIndex((child) => child.id === id);
|
|
175
|
+
const removed = previous.children.splice(previousIndex, 1)[0];
|
|
176
|
+
parent.children.splice(Math.max(0, Math.min(parent.children.length, Number(index) || 0)), 0, removed);
|
|
177
|
+
try { parseFilterAst(this._value, this._limits()); } catch {
|
|
178
|
+
parent.children.splice(parent.children.indexOf(removed), 1);
|
|
179
|
+
previous.children.splice(previousIndex, 0, removed);
|
|
180
|
+
return this;
|
|
181
|
+
}
|
|
182
|
+
this._render();
|
|
183
|
+
this._changed('move', id);
|
|
184
|
+
return this;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Returns semantic validity and errors. @returns {{valid:boolean,errors:Array<Record<string,string>>}} */
|
|
188
|
+
validate() { return validateFilterAst(this._value, this._fields, this._operators); }
|
|
189
|
+
|
|
190
|
+
/** Applies only a valid AST. @returns {FilterAst|null} */
|
|
191
|
+
apply() {
|
|
192
|
+
if (this._disabled) return null;
|
|
193
|
+
const result = this.validate();
|
|
194
|
+
if (!result.valid) {
|
|
195
|
+
this.emit('invalid', { errors: result.errors });
|
|
196
|
+
this.focus(result.errors[0]?.nodeId);
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const value = this.getValue();
|
|
200
|
+
this.emit('apply', { value });
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Clears all conditions. @param {{silent?:boolean}} [options={}] @returns {this} */
|
|
205
|
+
clear({ silent = false } = {}) {
|
|
206
|
+
if (!this._canMutate()) return this;
|
|
207
|
+
this._abortAll();
|
|
208
|
+
this._value.root.children = [];
|
|
209
|
+
this._render();
|
|
210
|
+
if (!silent) this._changed('clear', this._value.root.id);
|
|
211
|
+
this.refs.add?.focus();
|
|
212
|
+
return this;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Focuses a condition's field or the Add button. @param {string|null} [id=null] @returns {this} */
|
|
216
|
+
focus(id = null) {
|
|
217
|
+
const selector = id
|
|
218
|
+
? `[data-node-id="${cssEscape(id)}"] :is([data-filter-field], [data-filter-logic], [data-filter-action="add"])`
|
|
219
|
+
: '[data-filter-action="add"]';
|
|
220
|
+
queueMicrotask(() => /** @type {HTMLElement|null} */ (this.el.querySelector(selector))?.focus());
|
|
221
|
+
return this;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @param {boolean} readonly @returns {this} */
|
|
225
|
+
setReadonly(readonly) { this._readonly = Boolean(readonly); this._render(); return this; }
|
|
226
|
+
/** @returns {this} */
|
|
227
|
+
enable() { this._disabled = false; this._render(); return this; }
|
|
228
|
+
/** @returns {this} */
|
|
229
|
+
disable() { this._disabled = true; this._abortAll(); this._render(); return this; }
|
|
230
|
+
|
|
231
|
+
/** @returns {void} */
|
|
232
|
+
destroy() {
|
|
233
|
+
this._abortAll();
|
|
234
|
+
this._destroyEditors();
|
|
235
|
+
const root = this.el;
|
|
236
|
+
const snapshot = this._snapshot;
|
|
237
|
+
super.destroy();
|
|
238
|
+
if (!this._createdRoot && root) restoreTarget(root, snapshot);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** @returns {void} */
|
|
242
|
+
_render() {
|
|
243
|
+
this._destroyEditors();
|
|
244
|
+
this._renderVersion = (this._renderVersion ?? 0) + 1;
|
|
245
|
+
const status = h('div', { ref: 'status', class: 'zx-filter__status', role: 'status', ariaLive: 'polite' });
|
|
246
|
+
const tree = this._renderGroup(this._value.root, 0, true);
|
|
247
|
+
const actions = h('div', { class: 'zx-filter__actions' },
|
|
248
|
+
actionButton('add', this._value.root.id, 'plus', 'Add filter', { ref: 'add', disabled: this._blocked() }),
|
|
249
|
+
this.options.allowGroups ? actionButton('add-group', this._value.root.id, 'folder', 'Add group', { disabled: this._blocked() }) : null,
|
|
250
|
+
actionButton('clear', '', 'x', String(this.options.clearLabel), { disabled: this._blocked() }),
|
|
251
|
+
actionButton('apply', '', 'check', String(this.options.applyLabel), { class: 'zx-button--primary', disabled: this._disabled })
|
|
252
|
+
);
|
|
253
|
+
this.el.replaceChildren(status, tree, actions);
|
|
254
|
+
this.refs.status = status;
|
|
255
|
+
this.refs.add = /** @type {HTMLElement} */ (this.el.querySelector('[data-filter-action="add"]'));
|
|
256
|
+
this.el.toggleAttribute('data-readonly', this._readonly);
|
|
257
|
+
this.el.toggleAttribute('data-disabled', this._disabled);
|
|
258
|
+
const result = this.validate();
|
|
259
|
+
this.el.dataset.valid = String(result.valid);
|
|
260
|
+
for (const error of result.errors) this.el.querySelector(`[data-node-id="${cssEscape(error.nodeId)}"]`)?.setAttribute('data-invalid', '');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** @param {any} group @param {number} depth @param {boolean} root @returns {HTMLElement} */
|
|
264
|
+
_renderGroup(group, depth, root) {
|
|
265
|
+
const children = h('ol', { class: 'zx-filter__children' },
|
|
266
|
+
group.children.map((node) => node.kind === 'group' ? this._renderGroup(node, depth + 1, false) : this._renderCondition(node)));
|
|
267
|
+
const logic = h('select', {
|
|
268
|
+
class: 'zx-filter__logic', dataset: { filterLogic: '', nodeId: group.id },
|
|
269
|
+
ariaLabel: root ? 'Root filter logic' : 'Group filter logic', disabled: this._blocked()
|
|
270
|
+
},
|
|
271
|
+
h('option', { value: 'and', selected: group.logic === 'and' }, 'Match all'),
|
|
272
|
+
h('option', { value: 'or', selected: group.logic === 'or' }, 'Match any'));
|
|
273
|
+
const legend = h('legend', { class: 'zx-filter__legend' }, logic, h('span', {}, root ? 'conditions' : 'in this group'));
|
|
274
|
+
const controls = h('div', { class: 'zx-filter__group-actions' },
|
|
275
|
+
actionButton('add', group.id, 'plus', 'Add filter', { disabled: this._blocked() }),
|
|
276
|
+
this.options.allowGroups && depth < Number(this.options.maxDepth)
|
|
277
|
+
? actionButton('add-group', group.id, 'folder', 'Add subgroup', { disabled: this._blocked() }) : null,
|
|
278
|
+
root ? null : actionButton('remove', group.id, 'trash', 'Remove group', { disabled: this._blocked() })
|
|
279
|
+
);
|
|
280
|
+
return h(root ? 'fieldset' : 'li', {
|
|
281
|
+
class: 'zx-filter__group', dataset: { nodeId: group.id, depth: String(depth) }
|
|
282
|
+
}, root ? legend : h('fieldset', {}, legend, children, controls), root ? children : null, root ? controls : null);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** @param {any} node @returns {HTMLElement} */
|
|
286
|
+
_renderCondition(node) {
|
|
287
|
+
const field = this._field(node.field);
|
|
288
|
+
const operators = this._operatorsFor(field);
|
|
289
|
+
const operator = this._operator(node.operator);
|
|
290
|
+
const fieldSelect = h('select', {
|
|
291
|
+
class: 'zx-filter__field', dataset: { filterField: '', nodeId: node.id },
|
|
292
|
+
ariaLabel: 'Filter field', disabled: this._blocked()
|
|
293
|
+
},
|
|
294
|
+
h('option', { value: '', selected: !node.field }, 'Choose a field…'),
|
|
295
|
+
this._fields.map((candidate) => h('option', { value: candidate.id, selected: candidate.id === node.field }, candidate.label)),
|
|
296
|
+
node.field && !field ? h('option', { value: node.field, selected: true }, `Unavailable: ${node.field}`) : null);
|
|
297
|
+
const operatorSelect = h('select', {
|
|
298
|
+
class: 'zx-filter__operator', dataset: { filterOperator: '', nodeId: node.id },
|
|
299
|
+
ariaLabel: 'Filter operator', disabled: this._blocked()
|
|
300
|
+
},
|
|
301
|
+
h('option', { value: '', selected: !node.operator }, 'Choose an operator…'),
|
|
302
|
+
operators.map((candidate) => h('option', { value: candidate.id, selected: candidate.id === node.operator }, candidate.label)),
|
|
303
|
+
node.operator && !operators.some((candidate) => candidate.id === node.operator)
|
|
304
|
+
? h('option', { value: node.operator, selected: true }, `Unavailable: ${node.operator}`) : null);
|
|
305
|
+
return h('li', { class: 'zx-filter__condition', dataset: { nodeId: node.id } },
|
|
306
|
+
fieldSelect, operatorSelect, this._renderValue(node, field, operator),
|
|
307
|
+
actionButton('remove', node.id, 'trash', `Remove ${field?.label ?? 'filter'}`, { disabled: this._blocked() })
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** @param {any} node @param {FilterField|null} field @param {any} operator @returns {HTMLElement} */
|
|
312
|
+
_renderValue(node, field, operator) {
|
|
313
|
+
const host = h('div', { class: 'zx-filter__value' });
|
|
314
|
+
if (!field || !operator) return host;
|
|
315
|
+
if (operator.arity === 'none') return h('div', { class: 'zx-filter__value zx-filter__value--empty' }, 'No value');
|
|
316
|
+
if (field.type === 'custom' && typeof field.editor === 'function') {
|
|
317
|
+
const renderVersion = this._renderVersion;
|
|
318
|
+
queueMicrotask(() => {
|
|
319
|
+
if (renderVersion !== this._renderVersion || !this.el.contains(host)) return;
|
|
320
|
+
const handle = field.editor(host, {
|
|
321
|
+
value: node.value, field, conditionId: node.id,
|
|
322
|
+
setValue: (value) => this._setNodeValue(node.id, parseValueJson(value))
|
|
323
|
+
});
|
|
324
|
+
if (handle && typeof handle.destroy === 'function') this._customEditors.push(handle);
|
|
325
|
+
});
|
|
326
|
+
return host;
|
|
327
|
+
}
|
|
328
|
+
const values = operator.arity === 'pair' ? (Array.isArray(node.value) ? node.value : [null, null]) : [node.value];
|
|
329
|
+
if (operator.arity === 'many') {
|
|
330
|
+
const control = choiceSelect(field, node, values[0], true, this._blocked());
|
|
331
|
+
if (control) return h('div', { class: 'zx-filter__value' }, control);
|
|
332
|
+
return h('div', { class: 'zx-filter__value' }, valueInput(field, node.id, Array.isArray(node.value) ? node.value.join(', ') : '', 0, this._blocked(), true));
|
|
333
|
+
}
|
|
334
|
+
const controls = values.map((value, index) => choiceSelect(field, node, value, false, this._blocked(), index)
|
|
335
|
+
?? valueInput(field, node.id, value, index, this._blocked()));
|
|
336
|
+
return h('div', { class: 'zx-filter__value', dataset: { arity: operator.arity } }, controls);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** @param {Event} event @returns {void} */
|
|
340
|
+
_handleChange(event) {
|
|
341
|
+
if (!this._canMutate()) return;
|
|
342
|
+
const target = /** @type {HTMLInputElement|HTMLSelectElement|null} */ (event.target);
|
|
343
|
+
const id = target?.dataset.nodeId;
|
|
344
|
+
if (!target || !id) return;
|
|
345
|
+
if (target.dataset.filterField !== undefined) {
|
|
346
|
+
const node = findNode(this._value.root, id);
|
|
347
|
+
if (node?.kind !== 'condition') return;
|
|
348
|
+
node.field = target.value;
|
|
349
|
+
const field = this._field(node.field);
|
|
350
|
+
node.operator = this._defaultOperator(field) ?? '';
|
|
351
|
+
node.value = null;
|
|
352
|
+
this._abort(id); this._render(); this._changed('update', id); this.focus(id);
|
|
353
|
+
} else if (target.dataset.filterOperator !== undefined) {
|
|
354
|
+
const node = findNode(this._value.root, id);
|
|
355
|
+
if (node?.kind !== 'condition') return;
|
|
356
|
+
node.operator = target.value; node.value = null;
|
|
357
|
+
this._abort(id); this._render(); this._changed('update', id);
|
|
358
|
+
queueMicrotask(() => /** @type {HTMLElement|null} */ (this.el.querySelector(`[data-node-id="${cssEscape(id)}"] [data-filter-value]`))?.focus());
|
|
359
|
+
} else if (target.dataset.filterLogic !== undefined) {
|
|
360
|
+
const node = findNode(this._value.root, id);
|
|
361
|
+
if (node?.kind === 'group') node.logic = target.value === 'or' ? 'or' : 'and';
|
|
362
|
+
this._changed('update', id);
|
|
363
|
+
} else if (target.dataset.filterValue !== undefined) this._valueInput(target);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** @param {Event} event @returns {void} */
|
|
367
|
+
_handleInput(event) {
|
|
368
|
+
if (!this._canMutate()) return;
|
|
369
|
+
const target = /** @type {HTMLInputElement|null} */ (event.target);
|
|
370
|
+
if (!target || target.dataset.filterValue === undefined) return;
|
|
371
|
+
this._valueInput(target);
|
|
372
|
+
const id = target.dataset.nodeId;
|
|
373
|
+
const node = findNode(this._value.root, id);
|
|
374
|
+
const field = node?.kind === 'condition' ? this._field(node.field) : null;
|
|
375
|
+
if (field?.loadChoices) this._scheduleChoices(node.id, field, target.value);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** @param {HTMLInputElement|HTMLSelectElement} target @returns {void} */
|
|
379
|
+
_valueInput(target) {
|
|
380
|
+
const node = findNode(this._value.root, target.dataset.nodeId);
|
|
381
|
+
if (node?.kind !== 'condition') return;
|
|
382
|
+
const operator = this._operator(node.operator);
|
|
383
|
+
const field = this._field(node.field);
|
|
384
|
+
if (!operator || !field) return;
|
|
385
|
+
let value;
|
|
386
|
+
if (target instanceof HTMLSelectElement && target.multiple) {
|
|
387
|
+
value = Array.from(target.selectedOptions, (option) => parseScalar(option.value, field.type));
|
|
388
|
+
} else if (operator.arity === 'many') {
|
|
389
|
+
value = target.value.split(',').map((item) => item.trim()).filter(Boolean).map((item) => parseScalar(item, field.type));
|
|
390
|
+
} else if (operator.arity === 'pair') {
|
|
391
|
+
const pair = Array.isArray(node.value) ? [...node.value] : [null, null];
|
|
392
|
+
pair[Number(target.dataset.valueIndex) || 0] = parseScalar(target.value, field.type);
|
|
393
|
+
value = pair;
|
|
394
|
+
} else value = parseScalar(target.value, field.type);
|
|
395
|
+
this._setNodeValue(node.id, /** @type {any} */ (value));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** @param {MouseEvent} event @returns {void} */
|
|
399
|
+
_handleClick(event) {
|
|
400
|
+
const button = /** @type {HTMLElement|null} */ (event.target)?.closest?.('[data-filter-action]');
|
|
401
|
+
if (!button || !this.el.contains(button)) return;
|
|
402
|
+
const action = button.dataset.filterAction;
|
|
403
|
+
const id = button.dataset.nodeId || null;
|
|
404
|
+
if (action === 'add') this.addCondition(id);
|
|
405
|
+
else if (action === 'add-group') this.addGroup(id);
|
|
406
|
+
else if (action === 'remove' && id) this.remove(id);
|
|
407
|
+
else if (action === 'clear') this.clear();
|
|
408
|
+
else if (action === 'apply') this.apply();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** @param {string} id @param {any} value @returns {void} */
|
|
412
|
+
_setNodeValue(id, value) {
|
|
413
|
+
const node = findNode(this._value.root, id);
|
|
414
|
+
if (node?.kind !== 'condition') return;
|
|
415
|
+
node.value = value;
|
|
416
|
+
const result = this.validate();
|
|
417
|
+
this.el.dataset.valid = String(result.valid);
|
|
418
|
+
const row = this.el.querySelector(`[data-node-id="${cssEscape(id)}"]`);
|
|
419
|
+
row?.toggleAttribute('data-invalid', result.errors.some((candidate) => candidate.nodeId === id));
|
|
420
|
+
this._changed('update', id);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** @param {string} reason @param {string} nodeId @returns {void} */
|
|
424
|
+
_changed(reason, nodeId) {
|
|
425
|
+
const result = this.validate();
|
|
426
|
+
const value = this.getValue();
|
|
427
|
+
this.emit('change', { value, valid: result.valid, errors: result.errors, reason, nodeId });
|
|
428
|
+
if (this.options.autoApply && result.valid) this.emit('apply', { value });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** @param {string} id @param {FilterField} field @param {string} query @returns {void} */
|
|
432
|
+
_scheduleChoices(id, field, query) {
|
|
433
|
+
this._abort(id);
|
|
434
|
+
if (query.length < Number(field.minQuery ?? 0)) return;
|
|
435
|
+
const controller = new AbortController();
|
|
436
|
+
this._setAsyncBusy(id, true);
|
|
437
|
+
const entry = { controller, timer: setTimeout(async () => {
|
|
438
|
+
this.emit('query', { conditionId: id, fieldId: field.id, query });
|
|
439
|
+
try {
|
|
440
|
+
const choices = await field.loadChoices({ query, signal: controller.signal, conditionId: id, field });
|
|
441
|
+
if (controller.signal.aborted || this._async.get(id)?.controller !== controller) return;
|
|
442
|
+
const input = this.el.querySelector(`[data-node-id="${cssEscape(id)}"] [data-filter-value]`);
|
|
443
|
+
const listId = input?.getAttribute('list');
|
|
444
|
+
const list = listId ? this.el.querySelector(`#${cssEscape(listId)}`) : null;
|
|
445
|
+
if (list) list.replaceChildren(...(Array.isArray(choices) ? choices : []).map((choice) => h('option', { value: choice.value }, choice.label)));
|
|
446
|
+
this.emit('loaded', { conditionId: id, fieldId: field.id, query, choices });
|
|
447
|
+
} catch (error) {
|
|
448
|
+
if (!controller.signal.aborted) this.emit('error', { conditionId: id, fieldId: field.id, query, error: error instanceof Error ? error : new Error(String(error)) });
|
|
449
|
+
} finally {
|
|
450
|
+
if (this._async.get(id)?.controller === controller) {
|
|
451
|
+
this._async.delete(id);
|
|
452
|
+
this._setAsyncBusy(id, false);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}, Math.max(0, Number(field.debounce ?? 200))) };
|
|
456
|
+
this._async.set(id, entry);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** @param {string} id @returns {void} */
|
|
460
|
+
_abort(id) { const entry = this._async.get(id); if (entry) { clearTimeout(entry.timer); entry.controller.abort(); this._async.delete(id); this._setAsyncBusy(id, false); } }
|
|
461
|
+
/** @param {string} id @param {boolean} busy @returns {void} */
|
|
462
|
+
_setAsyncBusy(id, busy) {
|
|
463
|
+
const input = this.el.querySelector(`[data-node-id="${cssEscape(id)}"] [data-filter-value]`);
|
|
464
|
+
if (busy) input?.setAttribute('aria-busy', 'true');
|
|
465
|
+
else input?.removeAttribute('aria-busy');
|
|
466
|
+
}
|
|
467
|
+
/** @param {any} node @returns {void} */
|
|
468
|
+
_abortTree(node) { if (node.kind === 'condition') this._abort(node.id); else node.children.forEach((child) => this._abortTree(child)); }
|
|
469
|
+
/** @returns {void} */
|
|
470
|
+
_abortAll() { for (const id of [...this._async.keys()]) this._abort(id); }
|
|
471
|
+
/** @returns {void} */
|
|
472
|
+
_destroyEditors() { for (const editor of this._customEditors ?? []) editor.destroy?.(); this._customEditors = []; }
|
|
473
|
+
/** @returns {boolean} */
|
|
474
|
+
_blocked() { return this._readonly || this._disabled; }
|
|
475
|
+
/** @returns {boolean} */
|
|
476
|
+
_canMutate() { return !this._blocked(); }
|
|
477
|
+
/** @returns {{maxDepth:number,maxConditions:number}} */
|
|
478
|
+
_limits() { return { maxDepth: Number(this.options.maxDepth), maxConditions: Number(this.options.maxConditions) }; }
|
|
479
|
+
/** @param {string} id @returns {FilterField|null} */
|
|
480
|
+
_field(id) { return this._fields.find((field) => field.id === id) ?? null; }
|
|
481
|
+
/** @param {string} id @returns {any} */
|
|
482
|
+
_operator(id) { return this._operators.find((operator) => operator.id === id) ?? null; }
|
|
483
|
+
/** @param {FilterField|null} field @returns {any[]} */
|
|
484
|
+
_operatorsFor(field) { if (!field) return []; const ids = field.operators?.length ? field.operators : null; return this._operators.filter((operator) => ids ? ids.includes(operator.id) : operator.types.includes(field.type)); }
|
|
485
|
+
/** @param {FilterField|null} field @returns {string|null} */
|
|
486
|
+
_defaultOperator(field) { return field?.defaultOperator && this._operatorsFor(field).some((operator) => operator.id === field.defaultOperator) ? field.defaultOperator : this._operatorsFor(field)[0]?.id ?? null; }
|
|
487
|
+
/** @param {string} text @returns {void} */
|
|
488
|
+
_announce(text) { if (this.refs.status) this.refs.status.textContent = text; }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** @param {string} action @param {string} id @param {string} iconName @param {string} label @param {Record<string,any>} [props={}] @returns {HTMLElement} */
|
|
492
|
+
function actionButton(action, id, iconName, label, props = {}) {
|
|
493
|
+
return h('button', { type: 'button', class: ['zx-button', 'zx-button--sm', props.class].filter(Boolean), ...props, dataset: { filterAction: action, nodeId: id } }, icon(iconName, { size: 13 }), h('span', {}, label));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** @param {FilterField} field @param {any} node @param {any} value @param {boolean} multiple @param {boolean} disabled @param {number} [index=0] @returns {HTMLSelectElement|null} */
|
|
497
|
+
function choiceSelect(field, node, value, multiple, disabled, index = 0) {
|
|
498
|
+
const choices = Array.isArray(field.choices) && field.choices.length
|
|
499
|
+
? field.choices
|
|
500
|
+
: field.type === 'boolean'
|
|
501
|
+
? [{ value: true, label: 'True' }, { value: false, label: 'False' }]
|
|
502
|
+
: null;
|
|
503
|
+
if (!choices) return null;
|
|
504
|
+
const values = new Set(Array.isArray(value) ? value.map(String) : [String(value ?? '')]);
|
|
505
|
+
return /** @type {HTMLSelectElement} */ (h('select', {
|
|
506
|
+
dataset: { filterValue: '', nodeId: node.id, valueIndex: String(index) },
|
|
507
|
+
ariaLabel: `${field.label} value`, multiple, disabled
|
|
508
|
+
}, !multiple ? h('option', { value: '', selected: !values.has(String(value)) || value == null }, 'Choose…') : null,
|
|
509
|
+
choices.map((choice) => h('option', { value: choice.value, selected: values.has(String(choice.value)) }, choice.label))));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** @param {FilterField} field @param {string} id @param {any} value @param {number} index @param {boolean} disabled @param {boolean} [many=false] @returns {HTMLElement} */
|
|
513
|
+
function valueInput(field, id, value, index, disabled, many = false) {
|
|
514
|
+
const type = many ? 'text' : ['number', 'money'].includes(field.type) ? 'number' : field.type === 'date' ? 'date' : field.type === 'datetime' ? 'datetime-local' : 'text';
|
|
515
|
+
const listId = field.loadChoices ? `zx-filter-choices-${id}-${index}` : null;
|
|
516
|
+
const input = /** @type {HTMLInputElement} */ (h('input', {
|
|
517
|
+
type, value: value ?? '', dataset: { filterValue: '', nodeId: id, valueIndex: String(index) },
|
|
518
|
+
ariaLabel: `${field.label} value${index ? ` ${index + 1}` : ''}`, disabled,
|
|
519
|
+
placeholder: many ? 'Comma-separated values' : '', list: listId
|
|
520
|
+
}));
|
|
521
|
+
return h('span', { class: 'zx-filter__value-control' },
|
|
522
|
+
input, listId ? h('datalist', { id: listId }) : null);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** @param {unknown} value @param {string} type @returns {any} */
|
|
526
|
+
function parseScalar(value, type) { if (value === '') return null; if (['number', 'money'].includes(type)) { const number = Number(value); return Number.isFinite(number) ? number : null; } if (type === 'boolean') return value === 'true'; return value; }
|
|
527
|
+
/** @param {unknown} value @returns {any} */
|
|
528
|
+
function parseValueJson(value) { return cloneFilterValue(value === undefined ? null : value); }
|
|
529
|
+
/** @param {any} root @param {string} id @returns {any|null} */
|
|
530
|
+
function findNode(root, id) { if (!root || root.id === id) return root ?? null; if (root.kind === 'group') for (const child of root.children) { const match = findNode(child, id); if (match) return match; } return null; }
|
|
531
|
+
/** @param {any} root @param {string} id @returns {any|null} */
|
|
532
|
+
function findGroup(root, id) { const node = findNode(root, id); return node?.kind === 'group' ? node : null; }
|
|
533
|
+
/** @param {any} root @param {string} id @returns {any|null} */
|
|
534
|
+
function findParent(root, id) { if (root?.kind !== 'group') return null; if (root.children.some((child) => child.id === id)) return root; for (const child of root.children) { const parent = findParent(child, id); if (parent) return parent; } return null; }
|
|
535
|
+
/** @param {any} root @param {string} id @returns {any|null} */
|
|
536
|
+
function removeNode(root, id) { if (root.kind !== 'group') return null; const index = root.children.findIndex((child) => child.id === id); if (index >= 0) return root.children.splice(index, 1)[0]; for (const child of root.children) { const found = removeNode(child, id); if (found) return found; } return null; }
|
|
537
|
+
/** @param {any} node @param {string} id @returns {boolean} */
|
|
538
|
+
function containsNode(node, id) { return Boolean(node && findNode(node, id)); }
|
|
539
|
+
/** @param {any} node @returns {number} */
|
|
540
|
+
function conditionCount(node) { return node.kind === 'condition' ? 1 : node.children.reduce((sum, child) => sum + conditionCount(child), 0); }
|
|
541
|
+
/** @param {any} node @returns {string[]} */
|
|
542
|
+
/** @param {any} root @param {string} id @param {number} [depth=0] @returns {number} */
|
|
543
|
+
function groupDepth(root, id, depth = 0) { if (root.id === id) return depth; if (root.kind === 'group') for (const child of root.children) { if (child.kind === 'group') { const found = groupDepth(child, id, depth + 1); if (found >= 0) return found; } } return -1; }
|
|
544
|
+
/** @param {any[]} base @param {any[]} extra @returns {any[]} */
|
|
545
|
+
function mergeOperators(base, extra) { const map = new Map(base.map((item) => [item.id, { ...item, types: [...item.types] }])); for (const item of Array.isArray(extra) ? extra : []) if (item?.id) map.set(String(item.id), { ...item, id: String(item.id), types: Array.isArray(item.types) ? [...item.types] : [] }); return [...map.values()]; }
|
|
546
|
+
/** @param {string} value @returns {string} */
|
|
547
|
+
function cssEscape(value) { return globalThis.CSS?.escape ? CSS.escape(value) : String(value).replaceAll(/[^a-zA-Z0-9_-]/g, '\\$&'); }
|
|
548
|
+
|
|
549
|
+
/** @event Filter#change @type {CustomEvent<Record<string,unknown>>} */
|
|
550
|
+
/** @event Filter#apply @type {CustomEvent<{value:FilterAst}>} */
|
|
551
|
+
/** @event Filter#invalid @type {CustomEvent<Record<string,unknown>>} */
|
|
552
|
+
/** @event Filter#query @type {CustomEvent<Record<string,unknown>>} */
|
|
553
|
+
/** @event Filter#loaded @type {CustomEvent<Record<string,unknown>>} */
|
|
554
|
+
/** @event Filter#error @type {CustomEvent<Record<string,unknown>>} */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Data Grid intentionally renders the Table block. Preset-only treatment stays narrowly scoped so
|
|
2
2
|
the lowercase `.zx-grid` layout helper remains a separate, unchanged primitive. */
|
|
3
3
|
.zx-table[data-preset="billing-items"] tr.zx-grid-billing__summary > td {
|
|
4
|
-
background: var(--zx-color-bg-
|
|
4
|
+
background: var(--zx-color-bg-muted);
|
|
5
5
|
font-weight: 700;
|
|
6
6
|
}
|
|
@@ -47,7 +47,7 @@ export class Grid extends Table {
|
|
|
47
47
|
* @param {GridOptions & import('../table/table.js').TableOptions} [options={}] Grid/Table options.
|
|
48
48
|
*/
|
|
49
49
|
constructor(target = null, options = {}) {
|
|
50
|
-
super(target, options);
|
|
50
|
+
super(target, { editTrigger: 'single', ...options });
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
@@ -85,15 +85,18 @@ export function billingItemsConfig(options = {}) {
|
|
|
85
85
|
} = options;
|
|
86
86
|
const fields = { ...DEFAULT_FIELDS, ...requestedFields };
|
|
87
87
|
const calculateLineTotal = typeof lineTotal === 'function' ? lineTotal : (row) => {
|
|
88
|
-
const quantity =
|
|
89
|
-
const price =
|
|
90
|
-
return
|
|
88
|
+
const quantity = billingNumber(row?.[fields.quantity]);
|
|
89
|
+
const price = billingNumber(row?.[fields.unitPrice]);
|
|
90
|
+
return quantity !== null && price !== null ? quantity * price : null;
|
|
91
91
|
};
|
|
92
92
|
const columns = Array.isArray(suppliedColumns) ? suppliedColumns.map((column) => ({ ...column }))
|
|
93
93
|
: billingColumns(fields, { units, currencies, currency, locale, decimals, columnOverrides });
|
|
94
94
|
return {
|
|
95
95
|
rowId: fields.id,
|
|
96
96
|
editMode: 'cell',
|
|
97
|
+
editTrigger: 'single',
|
|
98
|
+
rowReorder: true,
|
|
99
|
+
columnVisibility: true,
|
|
97
100
|
hierarchy: { parentId: fields.parent, column: fields.item, expanded: true },
|
|
98
101
|
responsive: 'sm',
|
|
99
102
|
...tableOptions,
|
|
@@ -211,3 +214,10 @@ export function billingColumns(fields, { units, currencies, currency, locale, de
|
|
|
211
214
|
function hasChoices(value) {
|
|
212
215
|
return Array.isArray(value) ? value.length > 0 : Boolean(value && typeof value === 'object' && Object.keys(value).length);
|
|
213
216
|
}
|
|
217
|
+
|
|
218
|
+
/** Missing billing operands stay missing; explicit numeric zero remains a valid value. @param {unknown} value @returns {number|null} */
|
|
219
|
+
function billingNumber(value) {
|
|
220
|
+
if (value == null || typeof value === 'string' && value.trim() === '') return null;
|
|
221
|
+
const number = Number(value);
|
|
222
|
+
return Number.isFinite(number) ? number : null;
|
|
223
|
+
}
|