@kubex/zinc 1.1.120 → 1.1.122

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 (41) hide show
  1. package/dist/custom-elements.json +1952 -921
  2. package/dist/vscode.html-custom-data.json +80 -58
  3. package/dist/web-types.json +208 -142
  4. package/dist/zn.d.ts +403 -298
  5. package/dist/zn.min.js +1511 -1370
  6. package/docs/pages/components/button.md +9 -0
  7. package/docs/pages/components/data-table-filter.md +60 -4
  8. package/docs/pages/components/data-table-search.md +3 -3
  9. package/docs/pages/components/data-table.md +78 -9
  10. package/docs/pages/components/datepicker.md +10 -0
  11. package/docs/pages/components/menu-item.md +16 -2
  12. package/docs/pages/components/translation-group.md +2 -2
  13. package/package.json +1 -1
  14. package/src/components/button/button.component.ts +11 -4
  15. package/src/components/button/button.scss +12 -1
  16. package/src/components/data-table/data-table.component.ts +341 -139
  17. package/src/components/data-table/data-table.scss +105 -42
  18. package/src/components/data-table/data-table.test.ts +110 -0
  19. package/src/components/data-table-filter/data-table-filter.component.ts +487 -49
  20. package/src/components/data-table-filter/data-table-filter.scss +163 -9
  21. package/src/components/data-table-filter/data-table-filter.test.ts +328 -2
  22. package/src/components/data-table-search/data-table-search.component.ts +1 -1
  23. package/src/components/datepicker/datepicker.component.ts +23 -2
  24. package/src/components/datepicker/datepicker.scss +25 -0
  25. package/src/components/datepicker/datepicker.test.ts +28 -1
  26. package/src/components/input/input.component.ts +1 -0
  27. package/src/components/input/input.scss +1 -1
  28. package/src/components/menu/menu.component.ts +2 -0
  29. package/src/components/menu/menu.scss +23 -2
  30. package/src/components/menu-item/menu-item.component.ts +7 -1
  31. package/src/components/menu-item/menu-item.scss +4 -4
  32. package/src/components/menu-item/menu-item.test.ts +39 -1
  33. package/src/components/panel/panel.component.ts +12 -5
  34. package/src/components/panel/panel.scss +3 -3
  35. package/src/components/query-builder/query-builder.component.ts +1 -1
  36. package/src/components/tooltip/tooltip.component.ts +2 -2
  37. package/src/components/tooltip/tooltip.test.ts +70 -2
  38. package/src/components/translation-group/translation-group.component.ts +5 -7
  39. package/src/components/translation-group/translation-group.test.ts +8 -8
  40. package/src/components/translations/translations.component.ts +3 -4
  41. package/src/components/translations/translations.test.ts +1 -1
@@ -1,39 +1,103 @@
1
- import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
1
+ import {classMap} from 'lit/directives/class-map.js';
2
+ import {type CSSResultGroup, html, nothing, type PropertyValues, unsafeCSS} from 'lit';
2
3
  import {FormControlController, validValidityState} from "../../internal/form";
3
- import {property} from 'lit/decorators.js';
4
+ import {
5
+ operatorText,
6
+ type QueryBuilderData,
7
+ type QueryBuilderDateSubmitFormat,
8
+ type QueryBuilderItem,
9
+ QueryBuilderOperators,
10
+ type QueryBuilderOptions
11
+ } from "../query-builder";
12
+ import {property, state} from 'lit/decorators.js';
4
13
  import ZincElement, {type ZincFormControl} from '../../internal/zinc-element';
5
-
14
+ import ZnButton from "../button";
15
+ import ZnDatepicker from "../datepicker";
16
+ import ZnDropdown from "../dropdown";
17
+ import ZnInput from "../input";
18
+ import ZnMenu from "../menu";
19
+ import ZnMenuItem from "../menu-item";
20
+ import type {ZnInputEvent} from "../../events/zn-input";
6
21
 
7
22
  import styles from './data-table-filter.scss';
8
23
 
24
+ interface ActiveFilter {
25
+ key: string;
26
+ comparator: QueryBuilderOperators;
27
+ value: string;
28
+ /** Display text for values the wire format renders unreadable, such as dates. */
29
+ label?: string;
30
+ /** Kept so a date can be re-encoded when the comparator changes. */
31
+ timestamp?: number;
32
+ }
33
+
34
+ const BOOLEAN_OPTIONS: QueryBuilderOptions = {1: 'True', 0: 'False'};
35
+ const TEXT_DEBOUNCE = 350;
36
+ const SUGGESTION_LIMIT = 8;
37
+ const OPTION_SEARCH_THRESHOLD = 8;
38
+
39
+ // Pills have to stay short, so the wordier operators get a compact label
40
+ const OPERATOR_LABELS: Partial<Record<QueryBuilderOperators, string>> = {
41
+ [QueryBuilderOperators.Eq]: 'is',
42
+ [QueryBuilderOperators.Neq]: 'is not',
43
+ [QueryBuilderOperators.Gt]: '>',
44
+ [QueryBuilderOperators.Gte]: '\u2265',
45
+ [QueryBuilderOperators.Lt]: '<',
46
+ [QueryBuilderOperators.Lte]: '\u2264',
47
+ [QueryBuilderOperators.In]: 'is any of',
48
+ [QueryBuilderOperators.Nin]: 'is none of',
49
+ [QueryBuilderOperators.Before]: 'before',
50
+ [QueryBuilderOperators.After]: 'after',
51
+ };
9
52
 
10
53
  /**
11
- * @summary Short summary of the component's intended use.
54
+ * @summary An inline filter bar for data tables. Each active filter is a pill whose dropdown sets its value.
12
55
  * @documentation https://zinc.style/components/data-table-filter
13
56
  * @status experimental
14
57
  * @since 1.0
15
58
  *
16
- * @dependency zn-example
59
+ * @dependency zn-button
60
+ * @dependency zn-datepicker
61
+ * @dependency zn-dropdown
62
+ * @dependency zn-input
63
+ * @dependency zn-menu
64
+ * @dependency zn-menu-item
17
65
  *
18
- * @event zn-event-name - Emitted as an example.
19
- *
20
- * @slot - The default slot.
21
- * @slot example - An example slot.
22
- *
23
- * @csspart base - The component's base wrapper.
24
- *
25
- * @cssproperty --example - An example CSS custom property.
66
+ * @event zn-filter-change - Emitted when the active filters change. Read the encoded query off `value`.
26
67
  */
27
68
  export default class ZnDataTableFilter extends ZincElement implements ZincFormControl {
28
69
  static styles: CSSResultGroup = unsafeCSS(styles);
70
+ static dependencies = {
71
+ 'zn-button': ZnButton,
72
+ 'zn-datepicker': ZnDatepicker,
73
+ 'zn-dropdown': ZnDropdown,
74
+ 'zn-input': ZnInput,
75
+ 'zn-menu': ZnMenu,
76
+ 'zn-menu-item': ZnMenuItem,
77
+ };
29
78
 
30
79
  private _formController: FormControlController = new FormControlController(this, {});
80
+ private _textTimeout?: number;
81
+ private _pendingOpen?: string;
31
82
 
32
- @property() filters = "";
83
+ @property({type: Array}) filters: QueryBuilderData = [];
33
84
 
34
85
  @property() name: string = "data-table-filter";
35
86
 
36
- @property() value: string;
87
+ @property() value: string = '';
88
+
89
+ /** Filter keys to show a pill for before the user adds any. */
90
+ @property({attribute: 'default-filters'}) defaultFilters: string = '';
91
+
92
+ /** Typeahead values per filter key. `zn-data-table` fills this from the rows it has loaded. */
93
+ @property({attribute: false}) suggestions: Record<string, string[]> = {};
94
+
95
+ @state() private _active: ActiveFilter[] = [];
96
+
97
+ // What the user has typed so far, which the debounced value hasn't caught up with yet
98
+ @state() private _typing: Record<string, string> = {};
99
+
100
+ @state() private _optionQuery: Record<string, string> = {};
37
101
 
38
102
  get validationMessage(): string {
39
103
  return '';
@@ -64,63 +128,437 @@ export default class ZnDataTableFilter extends ZincElement implements ZincFormCo
64
128
  this._formController.updateValidity();
65
129
  }
66
130
 
67
- handleQBClear = () => {
68
- const builder = this.shadowRoot?.querySelector('zn-query-builder');
69
- if (!builder) {
70
- return;
131
+ protected updated(changed: PropertyValues) {
132
+ super.updated(changed);
133
+
134
+ const key = this._pendingOpen;
135
+ if (!key) return;
136
+ this._pendingOpen = undefined;
137
+
138
+ // The pill has only just rendered, so let it upgrade before anchoring a panel to it
139
+ requestAnimationFrame(() => {
140
+ const pill = [...this.shadowRoot!.querySelectorAll('zn-dropdown')]
141
+ .find(dropdown => dropdown.dataset.key === key);
142
+
143
+ // The panel animates in, so the field can only take focus once it has settled
144
+ void pill?.show().then(() => requestAnimationFrame(() => pill.querySelector('zn-input')?.focus()));
145
+ });
146
+ }
147
+
148
+ protected willUpdate(changed: PropertyValues) {
149
+ super.willUpdate(changed);
150
+
151
+ if (changed.has('filters') && this._active.length === 0) {
152
+ this.defaultFilters
153
+ .split(',')
154
+ .map(key => key.trim())
155
+ .filter(key => key.length > 0)
156
+ .forEach(key => this.addFilter(key, false));
157
+ }
158
+ }
159
+
160
+ disconnectedCallback() {
161
+ super.disconnectedCallback();
162
+ if (this._textTimeout) {
163
+ window.clearTimeout(this._textTimeout);
71
164
  }
165
+ }
72
166
 
73
- builder.clear();
167
+ /** Number of filters with a value set. */
168
+ get activeCount(): number {
169
+ return this._active.filter(item => item.value.length > 0).length;
74
170
  }
75
171
 
76
- handleQBReset = () => {
77
- const builder = this.shadowRoot?.querySelector('zn-query-builder');
78
- if (!builder) {
79
- return;
172
+ /** Removes every active filter. */
173
+ clear() {
174
+ this._active = [];
175
+ this.emitChange();
176
+ this.emit('zn-clear');
177
+ }
178
+
179
+ private definition(key: string): QueryBuilderItem | undefined {
180
+ return this.filters.find((item: QueryBuilderItem) => item.id === key);
181
+ }
182
+
183
+ private operatorsFor(filter: QueryBuilderItem): QueryBuilderOperators[] {
184
+ return filter.operators?.length > 0 ? filter.operators : [QueryBuilderOperators.Eq];
185
+ }
186
+
187
+ private comparatorFor(filter: QueryBuilderItem): QueryBuilderOperators {
188
+ return this.operatorsFor(filter)[0];
189
+ }
190
+
191
+ private operatorLabel(comparator: QueryBuilderOperators): string {
192
+ return OPERATOR_LABELS[comparator] ?? operatorText[comparator].toLowerCase();
193
+ }
194
+
195
+ // `in`/`nin` take a set of values; every other comparator holds one
196
+ private isMultiple(comparator: QueryBuilderOperators): boolean {
197
+ return comparator === QueryBuilderOperators.In || comparator === QueryBuilderOperators.Nin;
198
+ }
199
+
200
+ private optionsFor(filter: QueryBuilderItem): QueryBuilderOptions | undefined {
201
+ if (filter.options) return filter.options;
202
+ if (filter.type === 'bool' || filter.type === 'boolean') return BOOLEAN_OPTIONS;
203
+ return undefined;
204
+ }
205
+
206
+ private addFilter(key: string, emit = true) {
207
+ if (this._active.some(item => item.key === key)) return;
208
+
209
+ const filter = this.definition(key);
210
+ if (!filter) return;
211
+
212
+ this._active = [...this._active, {key, comparator: this.comparatorFor(filter), value: ''}];
213
+
214
+ if (emit) {
215
+ // Picking a filter and setting its value is one action, so carry straight on to the value
216
+ this._pendingOpen = key;
217
+ this.emitChange();
80
218
  }
219
+ }
220
+
221
+ private removeFilter(key: string) {
222
+ this._active = this._active.filter(item => item.key !== key);
223
+ this.emitChange();
224
+ }
225
+
226
+ private setValue(key: string, value: string, label?: string, timestamp?: number) {
227
+ this._active = this._active.map(item => item.key === key ? {...item, value, label, timestamp} : item);
228
+ this.emitChange();
229
+ }
81
230
 
82
- builder.reset();
231
+ private setComparator(filter: QueryBuilderItem, active: ActiveFilter, comparator: QueryBuilderOperators) {
232
+ // A set of values can't survive a move to a comparator that holds one
233
+ const value = this.isMultiple(active.comparator) && !this.isMultiple(comparator)
234
+ ? this.selectedValues(filter, active)[0] ?? ''
235
+ : active.value;
236
+
237
+ this._active = this._active.map(item => item.key === active.key
238
+ ? {
239
+ ...item,
240
+ comparator,
241
+ value: item.timestamp
242
+ ? this.serializeDate(item.timestamp, comparator, filter.dateSubmitFormat)
243
+ : value,
244
+ }
245
+ : item);
246
+ this.emitChange();
83
247
  }
84
248
 
85
- handleQBUpdate = () => {
86
- const builder = this.shadowRoot?.querySelector('zn-query-builder');
87
- if (!builder) {
249
+ private toggleOption(filter: QueryBuilderItem, active: ActiveFilter, option: string) {
250
+ if (!this.isMultiple(active.comparator)) {
251
+ this.setValue(active.key, active.value === option ? '' : option);
88
252
  return;
89
253
  }
90
254
 
91
- this.value = builder.value as string;
255
+ const selected = this.selectedValues(filter, active);
256
+ const next = selected.includes(option)
257
+ ? selected.filter(item => item !== option)
258
+ : [...selected, option];
259
+
260
+ this.setValue(active.key, next.join(' '));
261
+ }
262
+
263
+ // Option keys keep the space-separated wire format the query builder uses; typed
264
+ // values can contain spaces of their own, so they only split on commas
265
+ private selectedValues(filter: QueryBuilderItem, active: ActiveFilter): string[] {
266
+ return active.value
267
+ .split(this.optionsFor(filter) ? ' ' : ',')
268
+ .map(item => item.trim())
269
+ .filter(item => item.length > 0);
270
+ }
271
+
272
+ private emitChange() {
273
+ const data = this._active
274
+ .filter(item => item.value.length > 0)
275
+ .map(item => ({key: item.key, comparator: item.comparator, value: item.value}));
92
276
 
93
- this.closeSlideout();
277
+ this.value = data.length > 0 ? btoa(JSON.stringify(data)) : '';
94
278
  this._formController.updateValidity();
95
279
  this.emit('zn-filter-change');
96
280
  }
97
281
 
98
- closeSlideout() {
99
- const slideout = this.shadowRoot?.querySelector('zn-slideout');
100
- if (slideout) {
101
- slideout.hide();
282
+ private handleDateInput(filter: QueryBuilderItem, active: ActiveFilter, event: Event) {
283
+ const picker = event.target as ZnDatepicker;
284
+ const timestamp = picker.timestamp;
285
+
286
+ if (!timestamp) {
287
+ this.setValue(active.key, '');
288
+ return;
289
+ }
290
+
291
+ this.setValue(
292
+ active.key,
293
+ this.serializeDate(timestamp, active.comparator, filter.dateSubmitFormat),
294
+ picker.value as string,
295
+ timestamp
296
+ );
297
+
298
+ // A time picker needs the panel to stay put while the time is set
299
+ if (filter.type !== 'dateTime') {
300
+ void picker.closest('zn-dropdown')?.hide();
102
301
  }
103
302
  }
104
303
 
105
- render() {
304
+ // Mirrors zn-query-builder's date serialization so backends built against it keep working
305
+ private serializeDate(
306
+ timestamp: number,
307
+ comparator: QueryBuilderOperators,
308
+ format: QueryBuilderDateSubmitFormat = 'legacy'
309
+ ): string {
310
+ if (format === 'timestamp') return timestamp.toString();
311
+
312
+ if (format === 'iso') {
313
+ const date = new Date(timestamp);
314
+ return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString();
315
+ }
316
+
317
+ if (comparator === QueryBuilderOperators.Eq || comparator === QueryBuilderOperators.Neq) {
318
+ return (timestamp / 1000).toString();
319
+ }
320
+
321
+ // Everything else goes as minutes from now, negated for `before`
322
+ const multiplier = comparator === QueryBuilderOperators.Before ? -1 : 1;
323
+ return (Math.floor((Date.now() - timestamp) / 1000 / 60) * multiplier).toString();
324
+ }
325
+
326
+ private handleTextInput(key: string, event: ZnInputEvent) {
327
+ const input = event.target as ZnInput;
328
+ const value = input.value as string;
329
+
330
+ this._typing = {...this._typing, [key]: value};
331
+
332
+ if (this._textTimeout) window.clearTimeout(this._textTimeout);
333
+ this._textTimeout = window.setTimeout(() => this.setValue(key, value), TEXT_DEBOUNCE);
334
+ }
335
+
336
+ private suggestionsFor(filter: QueryBuilderItem, active: ActiveFilter): string[] {
337
+ const available = this.suggestions[active.key] ?? [];
338
+ if (available.length === 0) return [];
339
+
340
+ // Match the term being typed, which for a multi-value filter is the one after the last comma
341
+ const typed = (this._typing[active.key] ?? active.value).split(',').pop()!.trim().toLowerCase();
342
+ const selected = this.selectedValues(filter, active).map(item => item.toLowerCase());
343
+
344
+ return available
345
+ .filter(value => value.toLowerCase().includes(typed) && !selected.includes(value.toLowerCase()))
346
+ .slice(0, SUGGESTION_LIMIT);
347
+ }
348
+
349
+ private applySuggestion(filter: QueryBuilderItem, active: ActiveFilter, value: string, event: Event) {
350
+ if (this._textTimeout) window.clearTimeout(this._textTimeout);
351
+
352
+ const next = this.isMultiple(active.comparator)
353
+ ? [...this.selectedValues(filter, active), value].join(',')
354
+ : value;
355
+
356
+ this._typing = {...this._typing, [active.key]: next};
357
+ this.setValue(active.key, next);
358
+
359
+ if (!this.isMultiple(active.comparator)) {
360
+ void (event.currentTarget as HTMLElement).closest('zn-dropdown')?.hide();
361
+ }
362
+ }
363
+
364
+ private humanize(value: string) {
365
+ return value.charAt(0).toUpperCase() + value.slice(1);
366
+ }
367
+
368
+ private pillLabel(filter: QueryBuilderItem, active: ActiveFilter) {
369
+ const name = this.humanize(filter.name);
370
+ const selected = this.selectedValues(filter, active);
371
+
372
+ if (selected.length === 0) return name;
373
+
374
+ // With a choice of operator the pill has to say which one is applied
375
+ const operator = this.operatorsFor(filter).length > 1
376
+ ? ` ${this.operatorLabel(active.comparator)}`
377
+ : '';
378
+
379
+ if (selected.length > 1) return `${name}${operator} (${selected.length})`;
380
+
381
+ const options = this.optionsFor(filter);
382
+ const value = active.label ?? (options ? options[selected[0]] ?? selected[0] : selected[0]);
383
+
384
+ return operator ? `${name}${operator} ${value}` : `${name}: ${value}`;
385
+ }
386
+
387
+ private renderOperators(filter: QueryBuilderItem, active: ActiveFilter) {
388
+ const operators = this.operatorsFor(filter);
389
+ if (operators.length < 2) return nothing;
390
+
106
391
  return html`
107
- <zn-button id="slideout-trigger" color="transparent" icon="filter_alt" icon-size="22"
108
- slot="trigger"
109
- tooltip="Filter">
110
- </zn-button>
111
- <zn-slideout class="slideout-basic" trigger="slideout-trigger" label="Filters">
392
+ <div class="filter-bar__operators">
393
+ ${operators.map(operator => html`
394
+ <zn-button class="${classMap({
395
+ 'filter-bar__operator': true,
396
+ 'filter-bar__operator--selected': operator === active.comparator,
397
+ })}"
398
+ outline
399
+ @click="${() => this.setComparator(filter, active, operator)}">
400
+ ${this.operatorLabel(operator)}
401
+ </zn-button>`)}
402
+ </div>`;
403
+ }
404
+
405
+ private renderPill(active: ActiveFilter) {
406
+ const filter = this.definition(active.key);
407
+ if (!filter) return nothing;
112
408
 
113
- <div class="data-table-filter">
114
- <zn-query-builder filters="${this.filters}"></zn-query-builder>
115
- </div>
409
+ const operators = this.operatorsFor(filter);
116
410
 
117
- <zn-button color="secondary" slot="footer" slideout-closer @click=${this.handleQBReset}>Cancel
411
+ return html`
412
+ <div class="filter-bar__chip">
413
+ <zn-dropdown placement="bottom-start"
414
+ data-key="${active.key}"
415
+ ?stay-open-on-select="${this.isMultiple(active.comparator)}">
416
+ <zn-button slot="trigger"
417
+ class="filter-bar__pill"
418
+ pill
419
+ panel-bg
420
+ icon="chevron-down@lu"
421
+ icon-size="16"
422
+ icon-position="right">${this.pillLabel(filter, active)}
423
+ </zn-button>
424
+ ${this.optionsFor(filter)
425
+ ? html`
426
+ <zn-menu variant="shell">
427
+ ${operators.length > 1 ? html`
428
+ ${operators.map(operator => html`
429
+ <zn-menu-item type="checkbox"
430
+ keep-open
431
+ ?checked="${operator === active.comparator}"
432
+ @zn-menu-select="${() => this.setComparator(filter, active, operator)}">
433
+ ${this.operatorLabel(operator)}
434
+ </zn-menu-item>`)}
435
+ <div class="filter-bar__separator"></div>` : nothing}
436
+ ${this.renderOptionSearch(filter, active)}
437
+ ${this.renderOptionItems(filter, active)}
438
+ </zn-menu>`
439
+ : html`
440
+ <div class="filter-bar__editor">
441
+ ${this.renderOperators(filter, active)}
442
+ ${this.renderValueInput(filter, active)}
443
+ </div>`}
444
+ </zn-dropdown>
445
+ <zn-button class="filter-bar__remove"
446
+ icon-button="small"
447
+ plain
448
+ icon="x@lu"
449
+ icon-size="16"
450
+ label="Remove ${this.humanize(filter.name)} filter"
451
+ @click="${() => this.removeFilter(active.key)}">
118
452
  </zn-button>
119
- <div slot="footer">
120
- <zn-button color="transparent" slot="footer" @click=${this.handleQBClear}>Clear</zn-button>
121
- <zn-button color="default" slot="footer" @click=${this.handleQBUpdate}>Show Results</zn-button>
122
- </div>
123
- </zn-slideout>
453
+ </div>`;
454
+ }
455
+
456
+ private isDateFilter(filter: QueryBuilderItem): boolean {
457
+ return filter.type === 'date' || filter.type === 'dateTime';
458
+ }
459
+
460
+ private renderValueInput(filter: QueryBuilderItem, active: ActiveFilter) {
461
+ if (this.isDateFilter(filter)) {
462
+ return html`
463
+ <zn-datepicker inline
464
+ ?time-picker="${filter.type === 'dateTime'}"
465
+ format-time="HH:mm"
466
+ @zn-change="${(e: Event) => this.handleDateInput(filter, active, e)}">
467
+ </zn-datepicker>`;
468
+ }
469
+
470
+ const suggestions = this.suggestionsFor(filter, active);
471
+
472
+ return html`
473
+ <zn-input type="${filter.type === 'number' ? 'number' : 'text'}"
474
+ no-spin-buttons
475
+ placeholder="${this.humanize(filter.name)}"
476
+ value="${active.value}"
477
+ @zn-input="${(e: ZnInputEvent) => this.handleTextInput(active.key, e)}">
478
+ </zn-input>
479
+ ${suggestions.length > 0 ? html`
480
+ <div class="filter-bar__suggestions">
481
+ ${suggestions.map(value => html`
482
+ <zn-button class="filter-bar__suggestion"
483
+ text
484
+ @click="${(e: Event) => this.applySuggestion(filter, active, value, e)}">${value}
485
+ </zn-button>`)}
486
+ </div>` : nothing}`;
487
+ }
488
+
489
+ private handleOptionSearch(key: string, event: ZnInputEvent) {
490
+ const input = event.target as ZnInput;
491
+ this._optionQuery = {...this._optionQuery, [key]: input.value as string};
492
+ }
493
+
494
+ private renderOptionSearch(filter: QueryBuilderItem, active: ActiveFilter) {
495
+ if (Object.keys(this.optionsFor(filter)!).length <= OPTION_SEARCH_THRESHOLD) return nothing;
496
+
497
+ return html`
498
+ <zn-input class="filter-bar__option-search"
499
+ size="small"
500
+ placeholder="Search ${this.humanize(filter.name)}"
501
+ value="${this._optionQuery[active.key] ?? ''}"
502
+ @zn-input="${(e: ZnInputEvent) => this.handleOptionSearch(active.key, e)}"
503
+ @keydown="${(e: KeyboardEvent) => e.stopPropagation()}">
504
+ </zn-input>`;
505
+ }
506
+
507
+ private renderOptionItems(filter: QueryBuilderItem, active: ActiveFilter) {
508
+ const options = this.optionsFor(filter)!;
509
+ const selected = this.selectedValues(filter, active);
510
+ const query = (this._optionQuery[active.key] ?? '').trim().toLowerCase();
511
+ const matches = Object.keys(options).filter(key => String(options[key]).toLowerCase().includes(query));
512
+
513
+ if (matches.length === 0) {
514
+ return html`
515
+ <zn-menu-item class="filter-bar__no-matches" disabled>No matches</zn-menu-item>`;
516
+ }
517
+
518
+ return matches.map(key => html`
519
+ <zn-menu-item type="checkbox"
520
+ value="${key}"
521
+ ?checked="${selected.includes(key)}"
522
+ @zn-menu-select="${() => this.toggleOption(filter, active, key)}">${options[key]}
523
+ </zn-menu-item>`);
524
+ }
525
+
526
+ private renderAddFilter() {
527
+ const available = this.filters.filter(
528
+ (filter: QueryBuilderItem) => !this._active.some(item => item.key === filter.id)
529
+ );
530
+
531
+ if (available.length === 0) return nothing;
532
+
533
+ return html`
534
+ <zn-dropdown placement="bottom-start">
535
+ <zn-button slot="trigger"
536
+ class="filter-bar__add"
537
+ icon-button="small"
538
+ icon="plus@lu"
539
+ icon-size="18"
540
+ tooltip="Add filter"
541
+ aria-label="Add filter">
542
+ </zn-button>
543
+ <zn-menu variant="shell">
544
+ ${available.map((filter: QueryBuilderItem) => html`
545
+ <zn-menu-item value="${filter.id}"
546
+ @zn-menu-select="${() => this.addFilter(filter.id)}">${this.humanize(filter.name)}
547
+ </zn-menu-item>`)}
548
+ </zn-menu>
549
+ </zn-dropdown>`;
550
+ }
551
+
552
+ render() {
553
+ if (this.filters.length === 0) return nothing;
554
+
555
+ return html`
556
+ <div class="filter-bar">
557
+ ${this._active.map((active: ActiveFilter) => this.renderPill(active))}
558
+ ${this.renderAddFilter()}
559
+ ${this._active.length > 0 ? html`
560
+ <zn-button class="filter-bar__clear" text @click="${this.clear}">Clear</zn-button>` : nothing}
561
+ </div>
124
562
  `;
125
563
  }
126
564
  }