@yuuvis/client-framework 3.7.1 → 3.8.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.
@@ -0,0 +1,1944 @@
1
+ import * as i0 from '@angular/core';
2
+ import { inject, signal, computed, Injectable, input, ChangeDetectionStrategy, Component, ElementRef, output, viewChild, effect, NgModule } from '@angular/core';
3
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
4
+ import * as i1$1 from '@angular/forms';
5
+ import { FormControl, ReactiveFormsModule } from '@angular/forms';
6
+ import * as i2$1 from '@angular/material/autocomplete';
7
+ import { MatAutocompleteTrigger, MatAutocompleteModule } from '@angular/material/autocomplete';
8
+ import { MatButtonModule } from '@angular/material/button';
9
+ import * as i4 from '@angular/material/chips';
10
+ import { MatChipsModule } from '@angular/material/chips';
11
+ import * as i1 from '@angular/material/icon';
12
+ import { MatIconModule } from '@angular/material/icon';
13
+ import { MatInputModule } from '@angular/material/input';
14
+ import * as i5 from '@angular/material/select';
15
+ import { MatSelectModule } from '@angular/material/select';
16
+ import * as i2 from '@angular/material/tooltip';
17
+ import { MatTooltipModule } from '@angular/material/tooltip';
18
+ import { SystemService, TranslateService, BaseObjectTypeField, TranslatePipe } from '@yuuvis/client-core';
19
+ import { MetadataFormFieldComponent } from '@yuuvis/client-framework/metadata-form';
20
+ import { RendererDirective } from '@yuuvis/client-framework/renderer';
21
+ import { YmtIconButtonDirective } from '@yuuvis/material';
22
+ import { debounceTime } from 'rxjs/operators';
23
+ import { NgTemplateOutlet } from '@angular/common';
24
+
25
+ function isConditionGroup(node) {
26
+ return node.kind === 'group';
27
+ }
28
+ function isTableCondition(node) {
29
+ return node.kind === 'table';
30
+ }
31
+ function isFieldCondition(node) {
32
+ return !isConditionGroup(node) && !isTableCondition(node);
33
+ }
34
+
35
+ const DATE_PREFIX_LEN$2 = 5;
36
+ function escapeCmis(value) {
37
+ return value.replace(/'/g, "''").replace(/\\/g, '\\\\');
38
+ }
39
+ /**
40
+ * Escape a value for use inside a CMIS `LIKE` pattern. The wildcard characters
41
+ * `%` and `_` are backslash-escaped so a user-typed `%`/`_` matches literally
42
+ * instead of acting as a wildcard. The backslash itself is escaped first (so an
43
+ * existing `\` can't turn the following character into an escape sequence), then
44
+ * single quotes are doubled as everywhere else.
45
+ */
46
+ function escapeCmisLike(value) {
47
+ return value
48
+ .replace(/\\/g, '\\\\')
49
+ .replace(/%/g, '\\%')
50
+ .replace(/_/g, '\\_')
51
+ .replace(/'/g, "''");
52
+ }
53
+ function isNumericType(internalType) {
54
+ return internalType === 'integer' || internalType === 'decimal';
55
+ }
56
+ function isBooleanType(internalType) {
57
+ return internalType === 'boolean' || internalType.startsWith('boolean:');
58
+ }
59
+ function isFiniteNumberLiteral(value) {
60
+ return value.trim() !== '' && Number.isFinite(Number(value));
61
+ }
62
+ /**
63
+ * Render a scalar value as a CMIS literal, driven by the field's internal type.
64
+ * Strings and datetimes are quoted and escaped. Numbers and booleans are emitted
65
+ * bare — but ONLY when the value really is a finite number / a `true|false`
66
+ * boolean; any other value for a non-string field is quoted-and-escaped as a
67
+ * fallback so a malformed or hostile value (e.g. `1 OR 1=1` on an integer field)
68
+ * can never break out of the literal.
69
+ */
70
+ function renderScalarLiteral(value, internalType) {
71
+ if (internalType.startsWith('string') || internalType === 'datetime') {
72
+ return `'${escapeCmis(value)}'`;
73
+ }
74
+ if (isNumericType(internalType) && isFiniteNumberLiteral(value)) {
75
+ return value;
76
+ }
77
+ if (isBooleanType(internalType) && (value === 'true' || value === 'false')) {
78
+ return value;
79
+ }
80
+ // Unknown type, or a value that doesn't match its declared type: never emit it
81
+ // bare (injection / malformed-SQL guard).
82
+ return `'${escapeCmis(value)}'`;
83
+ }
84
+ /**
85
+ * Serialize a single raw form-control value to its CMIS string form.
86
+ * `Date` → ISO 8601; everything else → trimmed string.
87
+ */
88
+ function serializeScalar(value) {
89
+ if (value === null || value === undefined)
90
+ return '';
91
+ if (typeof value === 'string')
92
+ return value.trim();
93
+ if (value instanceof Date)
94
+ return value.toISOString();
95
+ return String(value);
96
+ }
97
+ /**
98
+ * Type-aware "the user actually entered something" check. The metadata value
99
+ * editor emits strings, numbers, dates and arrays depending on the field type,
100
+ * so a plain truthiness/`.trim()` test is wrong (`0` and `false` are valid).
101
+ */
102
+ function isValuePresent(value) {
103
+ if (value === null || value === undefined)
104
+ return false;
105
+ if (typeof value === 'string')
106
+ return value.trim().length > 0;
107
+ if (typeof value === 'number')
108
+ return !Number.isNaN(value);
109
+ if (typeof value === 'boolean')
110
+ return true;
111
+ if (value instanceof Date)
112
+ return !Number.isNaN(value.getTime());
113
+ if (Array.isArray(value))
114
+ return value.some(isValuePresent);
115
+ return true; // objects (e.g. RangeValue) — present if non-null
116
+ }
117
+ /**
118
+ * Normalize a raw form-control value into what we store in `FieldCondition.value`.
119
+ * Arrays are kept as a cleaned `string[]` (drives IN-clauses); everything else
120
+ * collapses to a single string.
121
+ */
122
+ function normalizeConditionValue(value) {
123
+ if (Array.isArray(value)) {
124
+ return value.map((entry) => serializeScalar(entry)).filter((entry) => entry.length > 0);
125
+ }
126
+ return serializeScalar(value);
127
+ }
128
+ /* eslint-disable id-length */
129
+ const CMIS_OPERATOR = {
130
+ eq: '=',
131
+ neq: '<>',
132
+ gt: '>',
133
+ gte: '>=',
134
+ lt: '<',
135
+ lte: '<='
136
+ };
137
+ /* eslint-enable id-length */
138
+ /**
139
+ * Operators that carry their own meaning and need no value step: date presets,
140
+ * the boolean `= true/false` shortcuts, and the null checks (`empty`/`not_empty`).
141
+ * The build flow commits these immediately instead of advancing to the value editor.
142
+ */
143
+ function isValuelessOperator(operator) {
144
+ return (operator.startsWith('date:') ||
145
+ operator === 'eq_true' ||
146
+ operator === 'eq_false' ||
147
+ operator === 'empty' ||
148
+ operator === 'not_empty');
149
+ }
150
+ /* eslint-disable id-length */
151
+ /**
152
+ * Calendar-day arithmetic. Using `new Date(y, m, d ± n)` (rather than
153
+ * adding milliseconds) keeps boundaries aligned to local midnight across
154
+ * DST transitions where a day is not exactly 24h.
155
+ *
156
+ * Week starts on Monday (ISO 8601 / European default).
157
+ */
158
+ function datePresetToRange(preset) {
159
+ const now = new Date();
160
+ const year = now.getFullYear();
161
+ const month = now.getMonth();
162
+ const date = now.getDate();
163
+ let from;
164
+ let to;
165
+ switch (preset) {
166
+ case 'today':
167
+ from = new Date(year, month, date);
168
+ to = new Date(year, month, date + 1);
169
+ break;
170
+ case 'thisWeek': {
171
+ // getDay(): 0=Sun, 1=Mon … 6=Sat. Shift so Mon=0 … Sun=6.
172
+ const dayFromMonday = (now.getDay() + 6) % 7;
173
+ from = new Date(year, month, date - dayFromMonday);
174
+ to = new Date(year, month, date - dayFromMonday + 7);
175
+ break;
176
+ }
177
+ case 'thisMonth':
178
+ from = new Date(year, month, 1);
179
+ to = new Date(year, month + 1, 1);
180
+ break;
181
+ case 'thisYear':
182
+ default:
183
+ from = new Date(year, 0, 1);
184
+ to = new Date(year + 1, 0, 1);
185
+ break;
186
+ }
187
+ return { from: from.toISOString(), to: to.toISOString() };
188
+ }
189
+ /* eslint-enable id-length */
190
+ /**
191
+ * Render a single {@link FieldCondition} as a CMIS predicate. Handles the null
192
+ * checks (`IS NULL` / `IS NOT NULL`), multi-value `IN` / `NOT IN`, `LIKE`
193
+ * (with wildcard-escaped, `%`-wrapped pattern), date-preset ranges
194
+ * (`>= from AND < to`), and the comparison operators (`=`, `<>`, `>`, …).
195
+ */
196
+ function buildConditionClause(cond) {
197
+ const operator = cond.operator;
198
+ // Valueless null checks short-circuit before any value handling.
199
+ if (operator === 'empty')
200
+ return `${cond.fieldId} IS NULL`;
201
+ if (operator === 'not_empty')
202
+ return `${cond.fieldId} IS NOT NULL`;
203
+ // Multi-value (e.g. multi-select catalog): `eq` → IN (...), `neq` → NOT IN (...).
204
+ if (Array.isArray(cond.value)) {
205
+ const list = cond.value.map((entry) => renderScalarLiteral(entry, cond.internalType)).join(', ');
206
+ const inOperator = operator === 'neq' ? 'NOT IN' : 'IN';
207
+ return `${cond.fieldId} ${inOperator} (${list})`;
208
+ }
209
+ const value = serializeScalar(cond.value);
210
+ if (operator === 'like') {
211
+ return `${cond.fieldId} LIKE '%${escapeCmisLike(value)}%'`;
212
+ }
213
+ if (operator.startsWith('date:')) {
214
+ // eslint-disable-next-line id-length
215
+ const { from, to } = datePresetToRange(operator.slice(DATE_PREFIX_LEN$2));
216
+ return `${cond.fieldId} >= '${from}' AND ${cond.fieldId} < '${to}'`;
217
+ }
218
+ const cmisOp = CMIS_OPERATOR[operator] ?? '=';
219
+ return `${cond.fieldId} ${cmisOp} ${renderScalarLiteral(value, cond.internalType)}`;
220
+ }
221
+ /**
222
+ * Render a queryable-table condition as `tableField[*].(col op val AND/OR …)`.
223
+ * `[*]` matches any row; the parenthesized column predicates join with the
224
+ * table's combinator. Empty tables contribute nothing.
225
+ *
226
+ * Column predicates reuse {@link buildConditionClause}: a column condition's
227
+ * `fieldId` is the bare column id (no table prefix), which is exactly what
228
+ * belongs inside the parentheses.
229
+ */
230
+ function buildTableClause(table) {
231
+ const cols = table.conditions.map(buildNodeClause).filter((part) => part !== '');
232
+ if (cols.length === 0)
233
+ return '';
234
+ return `${table.fieldId}[*].(${cols.join(` ${table.combinator} `)})`;
235
+ }
236
+ /**
237
+ * Recursively render a condition node. Groups produce parenthesized
238
+ * sub-expressions; empty groups contribute nothing; single-child groups
239
+ * collapse their redundant parens. Table conditions delegate to
240
+ * {@link buildTableClause}.
241
+ */
242
+ function buildNodeClause(node) {
243
+ if (isTableCondition(node)) {
244
+ return buildTableClause(node);
245
+ }
246
+ if (isConditionGroup(node)) {
247
+ const parts = node.conditions.map(buildNodeClause).filter((part) => part !== '');
248
+ if (parts.length === 0)
249
+ return '';
250
+ if (parts.length === 1)
251
+ return parts[0];
252
+ return `(${parts.join(` ${node.combinator} `)})`;
253
+ }
254
+ return buildConditionClause(node);
255
+ }
256
+ /**
257
+ * Build the type-matching clause for a block. Primary object types collapse to
258
+ * `objectTypeId = 'x'` (single) or `objectTypeId IN (...)` (multiple); secondary
259
+ * object types use `system:secondaryObjectTypeIds IN (...)`. A block mixing both
260
+ * flavors OR-combines the two clauses inside parentheses.
261
+ */
262
+ function buildTypeClause(types) {
263
+ const quote = (id) => `'${escapeCmis(id)}'`;
264
+ const primaries = types.filter((type) => !type.isSot).map((type) => type.id);
265
+ const sots = types.filter((type) => type.isSot).map((type) => type.id);
266
+ const parts = [];
267
+ if (primaries.length > 0) {
268
+ parts.push(primaries.length === 1
269
+ ? `objectTypeId = ${quote(primaries[0])}`
270
+ : `objectTypeId IN (${primaries.map(quote).join(', ')})`);
271
+ }
272
+ if (sots.length > 0) {
273
+ parts.push(`system:secondaryObjectTypeIds IN (${sots.map(quote).join(', ')})`);
274
+ }
275
+ if (parts.length === 0)
276
+ return '';
277
+ return parts.length === 1 ? parts[0] : `(${parts.join(' OR ')})`;
278
+ }
279
+ /**
280
+ * The column qualifier that scopes a full-text CONTAINS. `all` searches metadata + content (no
281
+ * qualifier); `content` / `metadata` restrict to that virtual full-text column.
282
+ */
283
+ const FULLTEXT_SCOPE_COLUMN = {
284
+ all: '',
285
+ content: 'system:content',
286
+ metadata: 'system:metadata'
287
+ };
288
+ /**
289
+ * Build the whole-object full-text clause: a single `CONTAINS('term')` predicate, optionally scoped
290
+ * to a column and AND-ed with a type restriction. Returns `''` when the term is blank.
291
+ */
292
+ function buildFulltextClause(fulltext) {
293
+ const term = fulltext.term.trim();
294
+ if (!term)
295
+ return '';
296
+ const column = FULLTEXT_SCOPE_COLUMN[fulltext.scope];
297
+ const contains = column ? `${column} CONTAINS('${escapeCmis(term)}')` : `CONTAINS('${escapeCmis(term)}')`;
298
+ const typeCond = buildTypeClause(fulltext.types);
299
+ return typeCond ? `(${typeCond} AND ${contains})` : contains;
300
+ }
301
+ /**
302
+ * Assemble the full CMIS statement from the search state. Each unit — the
303
+ * full-text clause plus every type block (type restriction `AND`-ed with its
304
+ * conditions) — is parenthesized and joined by `combinator`. Returns `''` when
305
+ * nothing contributes a clause, so an empty search yields no query.
306
+ *
307
+ * @param blocks The type blocks with their conditions.
308
+ * @param combinator How the top-level units combine (`AND` / `OR`).
309
+ * @param fulltext Optional whole-object full-text unit.
310
+ * @returns A `SELECT * FROM system:object WHERE …` statement, or `''`.
311
+ */
312
+ function buildCmisQuery(blocks, combinator, fulltext) {
313
+ const units = [];
314
+ const fulltextClause = fulltext ? buildFulltextClause(fulltext) : '';
315
+ if (fulltextClause !== '')
316
+ units.push(fulltextClause);
317
+ for (const block of blocks) {
318
+ const condJoin = ` ${block.conditionCombinator} `;
319
+ const typeCond = buildTypeClause(block.types);
320
+ const condParts = [];
321
+ for (const node of block.conditions) {
322
+ const clause = buildNodeClause(node);
323
+ if (clause !== '')
324
+ condParts.push(clause);
325
+ }
326
+ // Conditions combine with the user-chosen combinator; the type restriction
327
+ // is always AND-ed with them so a block matches the chosen type(s) *and*
328
+ // satisfies the condition expression.
329
+ const condsClause = condParts.length === 0 ? '' : condParts.length === 1 ? condParts[0] : `(${condParts.join(condJoin)})`;
330
+ const blockParts = [];
331
+ if (typeCond !== '')
332
+ blockParts.push(typeCond);
333
+ if (condsClause !== '')
334
+ blockParts.push(condsClause);
335
+ if (blockParts.length === 0)
336
+ continue;
337
+ units.push(blockParts.length === 1 ? blockParts[0] : `(${blockParts.join(' AND ')})`);
338
+ }
339
+ if (units.length === 0)
340
+ return '';
341
+ const joined = units.length === 1 ? units[0] : units.join(` ${combinator} `);
342
+ return `SELECT * FROM system:object WHERE ${joined}`;
343
+ }
344
+
345
+ /**
346
+ * Walk the block tree and return a new `blocks` array where `target` has been
347
+ * replaced by `updater(target)`. Identity is by reference equality.
348
+ *
349
+ * Returns the original array reference if the target is not found, so callers
350
+ * can detect a no-op via reference equality.
351
+ */
352
+ /**
353
+ * Rebuild a container with a new `conditions` array while preserving its
354
+ * discriminated kind (`SearchBlock` / `ConditionGroup` / `TableCondition`).
355
+ *
356
+ * The cast is sound: every caller only ever produces conditions that are valid
357
+ * for the container's kind (e.g. a table only receives row-groups). TypeScript
358
+ * cannot verify that across a union spread — `{ ...target, conditions }` widens
359
+ * a `TableCondition`'s `conditions` from `ConditionGroup[]` to `ConditionNode[]`
360
+ * and so fails to narrow back to the input type.
361
+ */
362
+ function withConditions(container, conditions) {
363
+ return { ...container, conditions };
364
+ }
365
+ function updateContainer(blocks, target, updater) {
366
+ let changed = false;
367
+ const walk = (node) => {
368
+ if (node === target) {
369
+ changed = true;
370
+ return updater(node);
371
+ }
372
+ if (isConditionGroup(node) || isTableCondition(node)) {
373
+ const next = node.conditions.map(walk);
374
+ if (next.some((child, idx) => child !== node.conditions[idx])) {
375
+ return withConditions(node, next);
376
+ }
377
+ }
378
+ return node;
379
+ };
380
+ const nextBlocks = blocks.map((block) => {
381
+ if (block === target) {
382
+ changed = true;
383
+ return updater(block);
384
+ }
385
+ const nextConditions = block.conditions.map(walk);
386
+ if (nextConditions.some((child, idx) => child !== block.conditions[idx])) {
387
+ return { ...block, conditions: nextConditions };
388
+ }
389
+ return block;
390
+ });
391
+ return changed ? nextBlocks : blocks;
392
+ }
393
+ /**
394
+ * Remove `target` from its parent container. If the parent is a
395
+ * `ConditionGroup` and becomes empty as a result, the parent is also removed
396
+ * (recursively up the chain). Top-level `SearchBlock`s are never auto-pruned.
397
+ *
398
+ * Returns the original array reference if the target is not found.
399
+ */
400
+ function removeNode(blocks, target) {
401
+ let changed = false;
402
+ // A group or table that loses its last child is pruned, cascading up the chain.
403
+ const pruneContainer = (container) => {
404
+ const kids = container.conditions;
405
+ const next = kids.map(walk).filter((node) => node !== null);
406
+ if (next.length === 0)
407
+ return null;
408
+ if (next.length === kids.length && next.every((child, idx) => child === kids[idx])) {
409
+ return container;
410
+ }
411
+ return withConditions(container, next);
412
+ };
413
+ const walk = (node) => {
414
+ if (node === target) {
415
+ changed = true;
416
+ return null;
417
+ }
418
+ if (isConditionGroup(node) || isTableCondition(node)) {
419
+ return pruneContainer(node);
420
+ }
421
+ return node;
422
+ };
423
+ const nextBlocks = blocks
424
+ .map((block) => {
425
+ if (block === target) {
426
+ changed = true;
427
+ return null;
428
+ }
429
+ const nextConditions = block.conditions.map(walk).filter((node) => node !== null);
430
+ if (nextConditions.length === block.conditions.length &&
431
+ nextConditions.every((child, idx) => child === block.conditions[idx])) {
432
+ return block;
433
+ }
434
+ return { ...block, conditions: nextConditions };
435
+ })
436
+ .filter((block) => block !== null);
437
+ return changed ? nextBlocks : blocks;
438
+ }
439
+ /**
440
+ * Find the top-level `SearchBlock` that contains `container` (directly or
441
+ * transitively). Returns `container` itself when it already is a `SearchBlock`.
442
+ * Returns `null` if not found in the tree.
443
+ */
444
+ function findOwningBlock(blocks, container) {
445
+ // A SearchBlock is its own owner; only groups and tables need a tree walk.
446
+ if (!isConditionGroup(container) && !isTableCondition(container)) {
447
+ return blocks.includes(container) ? container : null;
448
+ }
449
+ const contains = (node, needle) => {
450
+ if (node === needle)
451
+ return true;
452
+ if (isConditionGroup(node) || isTableCondition(node)) {
453
+ return node.conditions.some((child) => contains(child, needle));
454
+ }
455
+ return false;
456
+ };
457
+ for (const block of blocks) {
458
+ if (block.conditions.some((child) => contains(child, container)))
459
+ return block;
460
+ }
461
+ return null;
462
+ }
463
+
464
+ const DATE_PREFIX_LEN$1 = 5;
465
+ /* eslint-disable id-length */
466
+ const OPERATOR_LABEL_KEYS = {
467
+ like: 'yuv.smart-search.operator.like',
468
+ empty: 'yuv.smart-search.operator.empty',
469
+ not_empty: 'yuv.smart-search.operator.not-empty'
470
+ };
471
+ const OPERATOR_SYMBOLS = {
472
+ eq: '=',
473
+ neq: '≠',
474
+ gt: '>',
475
+ gte: '>=',
476
+ lt: '<',
477
+ lte: '<='
478
+ };
479
+ /* eslint-enable id-length */
480
+ const DATE_PRESETS = [
481
+ { id: 'today', labelKey: 'yuv.smart-search.date-preset.today' },
482
+ { id: 'thisWeek', labelKey: 'yuv.smart-search.date-preset.this-week' },
483
+ { id: 'thisMonth', labelKey: 'yuv.smart-search.date-preset.this-month' },
484
+ { id: 'thisYear', labelKey: 'yuv.smart-search.date-preset.this-year' }
485
+ ];
486
+ /**
487
+ * State + mutators for SmartSearch, shared between the host component and the
488
+ * recursive `SmartSearchGroupComponent`. Provided at the host component level
489
+ * so each `<yuv-smart-search>` instance gets its own controller.
490
+ *
491
+ * UI concerns (focus management, autocomplete plumbing, blur suppression) stay
492
+ * in the host component; this controller is purely about data.
493
+ */
494
+ class SmartSearchEditController {
495
+ constructor() {
496
+ this.#system = inject(SystemService);
497
+ this.#translate = inject(TranslateService);
498
+ /** The set of object type IDs that can be used as search blocks. */
499
+ this.allowedTypes = signal([], ...(ngDevMode ? [{ debugName: "allowedTypes" }] : /* istanbul ignore next */ []));
500
+ /** ObjectTypeField IDs to exclude from the field-step autocomplete suggestions. */
501
+ this.skipProperties = signal([], ...(ngDevMode ? [{ debugName: "skipProperties" }] : /* istanbul ignore next */ []));
502
+ // ── Persistent form controls (one per step) ──────────────────────────────
503
+ this.fieldCtrl = new FormControl('');
504
+ this.operatorCtrl = new FormControl('');
505
+ // Untyped value: the metadata renderer for the picked field may write a
506
+ // string, number, Date or string[] (see normalizeConditionValue).
507
+ this.valueCtrl = new FormControl('');
508
+ // ── State signals ─────────────────────────────────────────────────────────
509
+ /** Current input step */
510
+ this.step = signal('type', ...(ngDevMode ? [{ debugName: "step" }] : /* istanbul ignore next */ []));
511
+ /** The owning top-level block (drives field suggestions for the active edit) */
512
+ this.activeBlock = signal(null, ...(ngDevMode ? [{ debugName: "activeBlock" }] : /* istanbul ignore next */ []));
513
+ /** The immediate parent container the new condition will land in */
514
+ this.activeContainer = signal(null, ...(ngDevMode ? [{ debugName: "activeContainer" }] : /* istanbul ignore next */ []));
515
+ /** Field selected waiting for operator/value */
516
+ this.pendingField = signal(null, ...(ngDevMode ? [{ debugName: "pendingField" }] : /* istanbul ignore next */ []));
517
+ /** Operator label for the pending operator (displayed as a pill) */
518
+ this.pendingOperatorLabel = signal('', ...(ngDevMode ? [{ debugName: "pendingOperatorLabel" }] : /* istanbul ignore next */ []));
519
+ /** The accumulated search blocks */
520
+ this.blocks = signal([], ...(ngDevMode ? [{ debugName: "blocks" }] : /* istanbul ignore next */ []));
521
+ /** Types staged in the multi-select before the block is committed (type step). */
522
+ this.draftTypes = signal([], ...(ngDevMode ? [{ debugName: "draftTypes" }] : /* istanbul ignore next */ []));
523
+ /** Index of the condition being edited (-1 = new condition) */
524
+ this.editingConditionIndex = signal(-1, ...(ngDevMode ? [{ debugName: "editingConditionIndex" }] : /* istanbul ignore next */ []));
525
+ /** Snapshot of the condition under edit, used to restore on cancel */
526
+ this.#editingSnapshot = null;
527
+ /** Monotonic counter for generating stable block ids. */
528
+ this.#blockSeq = 0;
529
+ /**
530
+ * How to combine the top-level units (full-text unit + type blocks). These are
531
+ * type-scoped, so they always join with OR ("this as well as that"); the UI no
532
+ * longer exposes a toggle. Kept as a signal so saved states still round-trip.
533
+ */
534
+ this.combinator = signal('OR', ...(ngDevMode ? [{ debugName: "combinator" }] : /* istanbul ignore next */ []));
535
+ /** The current filter term for autocomplete suggestions */
536
+ this.inputTerm = signal('', ...(ngDevMode ? [{ debugName: "inputTerm" }] : /* istanbul ignore next */ []));
537
+ /** The whole-object full-text search unit (independent of the condition blocks). */
538
+ this.fulltext = signal({ term: '', scope: 'all', types: [] }, ...(ngDevMode ? [{ debugName: "fulltext" }] : /* istanbul ignore next */ []));
539
+ this.objectTypes = computed(() => this.#system.getObjectTypes(true, 'search').filter((type) => this.allowedTypes().includes(type.id)), ...(ngDevMode ? [{ debugName: "objectTypes" }] : /* istanbul ignore next */ []));
540
+ /** Fields for the active top-level block, used by the field-step suggestions. */
541
+ this.activeBlockFields = computed(() => this.#sharedFields(this.activeBlock()?.types ?? []), ...(ngDevMode ? [{ debugName: "activeBlockFields" }] : /* istanbul ignore next */ []));
542
+ /**
543
+ * When the active edit is scoped inside a table, the resolved column definitions
544
+ * of that table (as full `ObjectTypeField`s with `_internalType`). `null` when
545
+ * not editing inside a table.
546
+ */
547
+ this.activeTableColumns = computed(() => {
548
+ const container = this.activeContainer();
549
+ return container && isTableCondition(container) ? this.#tableColumns(container) : null;
550
+ }, ...(ngDevMode ? [{ debugName: "activeTableColumns" }] : /* istanbul ignore next */ []));
551
+ /**
552
+ * The fields offered at the field step: a table's **columns** when editing
553
+ * inside a table, otherwise the active block's shared fields.
554
+ */
555
+ this.activeFields = computed(() => {
556
+ const columns = this.activeTableColumns();
557
+ if (columns)
558
+ return this.#columnSuggestions(columns);
559
+ return this.activeBlockFields();
560
+ }, ...(ngDevMode ? [{ debugName: "activeFields" }] : /* istanbul ignore next */ []));
561
+ this.suggestions = computed(() => {
562
+ const step = this.step();
563
+ const term = this.inputTerm().toLowerCase();
564
+ if (step === 'type') {
565
+ // Only hide types already staged in the *current* draft block. Types used by
566
+ // other blocks stay available — a new block may target the same type again.
567
+ const stagedIds = new Set(this.draftTypes().map((type) => type.id));
568
+ const all = this.objectTypes()
569
+ .filter((type) => !stagedIds.has(type.id))
570
+ .map((type) => ({ kind: 'type', id: type.id, label: type.label ?? type.id }))
571
+ .sort((a, b) => a.label.localeCompare(b.label));
572
+ return term ? all.filter((item) => item.label.toLowerCase().includes(term)) : all;
573
+ }
574
+ if (step === 'field') {
575
+ const all = [...this.activeFields()].sort((a, b) => a.label.localeCompare(b.label));
576
+ return term
577
+ ? all.filter((item) => item.label.toLowerCase().includes(term) || item.id.toLowerCase().includes(term))
578
+ : all;
579
+ }
580
+ if (step === 'operator') {
581
+ const field = this.pendingField();
582
+ if (!field)
583
+ return [];
584
+ const all = this.#operatorsForInternalType(field.internalType ?? 'string');
585
+ return term ? all.filter((item) => item.label.toLowerCase().includes(term)) : all;
586
+ }
587
+ return [];
588
+ }, ...(ngDevMode ? [{ debugName: "suggestions" }] : /* istanbul ignore next */ []));
589
+ /** Number of top-level query units: the full-text unit (when it has a term) plus each block. */
590
+ this.unitCount = computed(() => (this.fulltext().term.trim() ? 1 : 0) + this.blocks().length, ...(ngDevMode ? [{ debugName: "unitCount" }] : /* istanbul ignore next */ []));
591
+ this.showCombinator = computed(() => this.unitCount() >= 2, ...(ngDevMode ? [{ debugName: "showCombinator" }] : /* istanbul ignore next */ []));
592
+ this.cmisQuery = computed(() => buildCmisQuery(this.blocks(), this.combinator(), this.fulltext()), ...(ngDevMode ? [{ debugName: "cmisQuery" }] : /* istanbul ignore next */ []));
593
+ /** The active input control for the current step. */
594
+ this.activeCtrl = computed(() => {
595
+ switch (this.step()) {
596
+ case 'field':
597
+ return this.fieldCtrl;
598
+ case 'operator':
599
+ return this.operatorCtrl;
600
+ case 'value':
601
+ return this.valueCtrl;
602
+ default:
603
+ return this.fieldCtrl;
604
+ }
605
+ }, ...(ngDevMode ? [{ debugName: "activeCtrl" }] : /* istanbul ignore next */ []));
606
+ }
607
+ #system;
608
+ #translate;
609
+ /** Snapshot of the condition under edit, used to restore on cancel */
610
+ #editingSnapshot;
611
+ /** Monotonic counter for generating stable block ids. */
612
+ #blockSeq;
613
+ // ── State save / restore ──────────────────────────────────────────────────
614
+ /** Deep-clone the current blocks, combinator and full-text unit into a serializable {@link SmartSearchState}. */
615
+ getState() {
616
+ return {
617
+ blocks: JSON.parse(JSON.stringify(this.blocks())),
618
+ combinator: this.combinator(),
619
+ fulltext: JSON.parse(JSON.stringify(this.fulltext()))
620
+ };
621
+ }
622
+ /** Replace the current state with a saved one, normalizing legacy blocks and cancelling any in-progress edit. */
623
+ loadState(state) {
624
+ this.cancelPending();
625
+ this.blocks.set(state.blocks.map((block) => this.#normalizeBlock(block)));
626
+ this.combinator.set(state.combinator);
627
+ this.fulltext.set(state.fulltext ?? { term: '', scope: 'all', types: [] });
628
+ }
629
+ /** Reset all search state back to its initial empty values. */
630
+ reset() {
631
+ this.cancelPending();
632
+ this.blocks.set([]);
633
+ this.combinator.set('OR');
634
+ this.fulltext.set({ term: '', scope: 'all', types: [] });
635
+ }
636
+ // ── Full-text unit ────────────────────────────────────────────────────────
637
+ /** Set the full-text search term. */
638
+ setFulltextTerm(term) {
639
+ this.fulltext.update((current) => ({ ...current, term }));
640
+ }
641
+ /** Set the full-text search scope (`all` / `metadata` / `content`). */
642
+ setFulltextScope(scope) {
643
+ this.fulltext.update((current) => ({ ...current, scope }));
644
+ }
645
+ /** Add a target type to the full-text unit (no-op if already present). */
646
+ addFulltextType(type, label) {
647
+ this.fulltext.update((current) => current.types.some((picked) => picked.id === type.id)
648
+ ? current
649
+ : { ...current, types: [...current.types, { id: type.id, label, isSot: type.isSot }] });
650
+ }
651
+ /** Remove a target type from the full-text unit by id. */
652
+ removeFulltextType(id) {
653
+ this.fulltext.update((current) => ({ ...current, types: current.types.filter((type) => type.id !== id) }));
654
+ }
655
+ /** Replace the full-text target types from a list of object-type ids (unknown ids are dropped). */
656
+ setFulltextTypes(ids) {
657
+ const types = ids
658
+ .map((id) => this.objectTypes().find((objectType) => objectType.id === id))
659
+ .filter((objectType) => !!objectType)
660
+ .map((objectType) => ({ id: objectType.id, label: objectType.label ?? objectType.id, isSot: objectType.isSot }));
661
+ this.fulltext.update((current) => ({ ...current, types }));
662
+ }
663
+ // ── Type draft (multi-select) ─────────────────────────────────────────────
664
+ /** Stage a type in the draft multi-select (no-op if already staged). */
665
+ addDraftType(type, label) {
666
+ if (this.draftTypes().some((draft) => draft.id === type.id))
667
+ return;
668
+ this.draftTypes.update((draft) => [...draft, { id: type.id, label, isSot: type.isSot }]);
669
+ }
670
+ /** Remove a staged type from the draft multi-select. */
671
+ removeDraftType(id) {
672
+ this.draftTypes.update((draft) => draft.filter((type) => type.id !== id));
673
+ }
674
+ /**
675
+ * Commit the staged draft types into a new block and clear the draft.
676
+ * Returns the created block, or `null` when the draft is empty.
677
+ */
678
+ confirmDraftTypes() {
679
+ const types = this.draftTypes();
680
+ if (types.length === 0)
681
+ return null;
682
+ const newBlock = {
683
+ id: this.#nextId(),
684
+ types: [...types],
685
+ conditions: [],
686
+ conditionCombinator: 'AND'
687
+ };
688
+ this.blocks.update((blocks) => [...blocks, newBlock]);
689
+ this.draftTypes.set([]);
690
+ return newBlock;
691
+ }
692
+ // ── Block + group + condition mutators ───────────────────────────────────
693
+ /** Remove a whole block; cancels the in-progress edit if it belonged to that block. */
694
+ removeBlock(block) {
695
+ this.blocks.update((blocks) => blocks.filter((blk) => blk !== block));
696
+ if (this.activeBlock() === block) {
697
+ this.cancelPending();
698
+ }
699
+ }
700
+ /** Append an empty `ConditionGroup` to a container and start adding inside it. */
701
+ addGroup(parent) {
702
+ const newGroup = { kind: 'group', conditions: [], combinator: 'AND' };
703
+ this.blocks.update((blocks) => updateContainer(blocks, parent, (container) => withConditions(container, [...container.conditions, newGroup])));
704
+ // The new group's reference is stable across the update because we appended
705
+ // its literal — safe to use directly as the active container.
706
+ this.startAddCondition(newGroup);
707
+ return newGroup;
708
+ }
709
+ /**
710
+ * Remove a group from its parent container. If the parent group becomes
711
+ * empty, it is also removed (cascading up). Top-level blocks are preserved.
712
+ */
713
+ removeGroup(group) {
714
+ this.blocks.update((blocks) => removeNode(blocks, group));
715
+ if (this.activeContainer() === group) {
716
+ this.cancelPending();
717
+ }
718
+ }
719
+ /** Remove a condition from the tree; empty parent groups/tables are pruned by the cascade. */
720
+ removeCondition(_container, condition) {
721
+ // While an *existing* condition is being edited it is temporarily pulled out
722
+ // of the tree (see editCondition). Removing the last remaining sibling would
723
+ // empty — and thus prune — the shared container, losing the in-flight edit.
724
+ // Re-home the snapshot first so it survives, then end the edit cleanly.
725
+ if (this.editingConditionIndex() >= 0 && this.#editingSnapshot) {
726
+ this.restoreEditingSnapshot();
727
+ this.cancelPending();
728
+ }
729
+ this.blocks.update((blocks) => removeNode(blocks, condition));
730
+ // If the active container itself was pruned by the cascade, drop the edit.
731
+ const active = this.activeContainer();
732
+ if (active && !this.#containerExists(active)) {
733
+ this.cancelPending();
734
+ }
735
+ }
736
+ /** Set the top-level combinator joining the query units (full-text unit + blocks). */
737
+ setCombinator(value) {
738
+ this.combinator.set(value);
739
+ }
740
+ /** Set the AND/OR combinator of a single container (block, group or table). */
741
+ setContainerCombinator(container, value) {
742
+ this.blocks.update((blocks) => updateContainer(blocks, container, (target) => isConditionGroup(target) || isTableCondition(target)
743
+ ? { ...target, combinator: value }
744
+ : { ...target, conditionCombinator: value }));
745
+ }
746
+ /**
747
+ * The picked field resolves to a queryable `table` type. A table has no operator
748
+ * of its own; instead it holds column conditions directly. Insert an empty
749
+ * {@link TableCondition} and scope the edit into it so the user picks a column next.
750
+ */
751
+ addTableCondition(field) {
752
+ const container = this.activeContainer();
753
+ if (!container)
754
+ return;
755
+ const table = {
756
+ kind: 'table',
757
+ fieldId: field.id,
758
+ fieldLabel: field.label,
759
+ conditions: [],
760
+ // Columns are row-scoped and always join with OR ("a row that has colA as
761
+ // well as colB"); the UI shows a static "as well as" label, not a toggle.
762
+ combinator: 'OR'
763
+ };
764
+ this.blocks.update((blocks) => updateContainer(blocks, container, (target) => withConditions(target, [...target.conditions, table])));
765
+ // `table`'s reference is stable across the update (we appended its literal).
766
+ this.startAddCondition(table);
767
+ }
768
+ // ── Inline-edit flow ──────────────────────────────────────────────────────
769
+ /** Begin adding a condition inside `container`. */
770
+ startAddCondition(container) {
771
+ const owningBlock = findOwningBlock(this.blocks(), container);
772
+ if (!owningBlock)
773
+ return;
774
+ this.activeBlock.set(owningBlock);
775
+ this.activeContainer.set(container);
776
+ this.pendingField.set(null);
777
+ this.pendingOperatorLabel.set('');
778
+ this.editingConditionIndex.set(-1);
779
+ this.#editingSnapshot = null;
780
+ this.fieldCtrl.setValue('', { emitEvent: false });
781
+ this.operatorCtrl.setValue('', { emitEvent: false });
782
+ this.valueCtrl.setValue('', { emitEvent: false });
783
+ this.step.set('field');
784
+ }
785
+ /** Begin editing an existing condition inside `container`. */
786
+ editCondition(container, condition, index) {
787
+ this.#editingSnapshot = { ...condition };
788
+ // Remove the condition from the tree so the inline editor takes its slot,
789
+ // and capture the new container reference produced by the immutable update.
790
+ let refreshed = container;
791
+ this.blocks.update((blocks) => updateContainer(blocks, container, (target) => {
792
+ const next = withConditions(target, target.conditions.filter((node) => node !== condition));
793
+ refreshed = next;
794
+ return next;
795
+ }));
796
+ const owningBlock = findOwningBlock(this.blocks(), refreshed);
797
+ if (!owningBlock)
798
+ return;
799
+ this.activeBlock.set(owningBlock);
800
+ this.activeContainer.set(refreshed);
801
+ this.editingConditionIndex.set(index);
802
+ const fieldItem = {
803
+ kind: 'field',
804
+ id: condition.fieldId,
805
+ label: condition.fieldLabel,
806
+ internalType: condition.internalType,
807
+ operator: condition.operator
808
+ };
809
+ this.pendingField.set(fieldItem);
810
+ this.pendingOperatorLabel.set(this.operatorLabel(condition.operator));
811
+ this.fieldCtrl.setValue(condition.fieldLabel, { emitEvent: false });
812
+ this.operatorCtrl.setValue(this.operatorLabel(condition.operator), { emitEvent: false });
813
+ this.step.set(isValuelessOperator(condition.operator) ? 'operator' : 'value');
814
+ }
815
+ /** Jump back to the field step and clear the input so the user can re-type. */
816
+ editField() {
817
+ this.fieldCtrl.setValue('', { emitEvent: false });
818
+ this.step.set('field');
819
+ this.inputTerm.set('');
820
+ }
821
+ /** Jump back to the operator step and clear the input so the user can re-type. */
822
+ editOperator() {
823
+ this.operatorCtrl.setValue('', { emitEvent: false });
824
+ this.step.set('operator');
825
+ this.inputTerm.set('');
826
+ }
827
+ /** Abandon the in-progress edit, restoring state and pruning any empty container created for it. */
828
+ cancelPending() {
829
+ // Prune empty containers created via addGroup / addTableCondition and never
830
+ // committed into. removeNode cascades up, so an empty parent is removed too.
831
+ const container = this.activeContainer();
832
+ if (container &&
833
+ (isConditionGroup(container) || isTableCondition(container)) &&
834
+ container.conditions.length === 0) {
835
+ this.blocks.update((blocks) => removeNode(blocks, container));
836
+ }
837
+ this.activeBlock.set(null);
838
+ this.activeContainer.set(null);
839
+ this.pendingField.set(null);
840
+ this.pendingOperatorLabel.set('');
841
+ this.editingConditionIndex.set(-1);
842
+ this.#editingSnapshot = null;
843
+ this.draftTypes.set([]);
844
+ this.step.set('type');
845
+ this.fieldCtrl.setValue('', { emitEvent: false });
846
+ this.operatorCtrl.setValue('', { emitEvent: false });
847
+ this.valueCtrl.setValue('', { emitEvent: false });
848
+ this.inputTerm.set('');
849
+ }
850
+ /** Insert (or, when editing, re-insert at its original index) the finished condition into the active container. */
851
+ commitCondition(condition) {
852
+ const container = this.activeContainer();
853
+ if (!container)
854
+ return;
855
+ const idx = this.editingConditionIndex();
856
+ this.blocks.update((blocks) => updateContainer(blocks, container, (target) => {
857
+ const conditions = [...target.conditions];
858
+ if (idx >= 0 && idx <= conditions.length) {
859
+ conditions.splice(idx, 0, condition);
860
+ }
861
+ else {
862
+ conditions.push(condition);
863
+ }
864
+ return withConditions(target, conditions);
865
+ }));
866
+ this.activeBlock.set(null);
867
+ this.activeContainer.set(null);
868
+ this.pendingField.set(null);
869
+ this.pendingOperatorLabel.set('');
870
+ this.editingConditionIndex.set(-1);
871
+ this.#editingSnapshot = null;
872
+ this.step.set('type');
873
+ this.fieldCtrl.setValue('', { emitEvent: false });
874
+ this.operatorCtrl.setValue('', { emitEvent: false });
875
+ this.valueCtrl.setValue('', { emitEvent: false });
876
+ this.inputTerm.set('');
877
+ }
878
+ /**
879
+ * On blur with an incomplete edit of an existing condition, reinsert the
880
+ * original verbatim at its original index.
881
+ */
882
+ restoreEditingSnapshot() {
883
+ const idx = this.editingConditionIndex();
884
+ const container = this.activeContainer();
885
+ const snapshot = this.#editingSnapshot;
886
+ if (idx < 0 || !container || !snapshot)
887
+ return;
888
+ this.blocks.update((blocks) => updateContainer(blocks, container, (target) => {
889
+ const conditions = [...target.conditions];
890
+ conditions.splice(idx, 0, snapshot);
891
+ return withConditions(target, conditions);
892
+ }));
893
+ }
894
+ /** Whether the in-progress condition has all parts needed to be committed. */
895
+ isConditionComplete() {
896
+ const field = this.pendingField();
897
+ if (!field)
898
+ return false;
899
+ const operator = field.operator ?? '';
900
+ if (!operator)
901
+ return false;
902
+ if (isValuelessOperator(operator))
903
+ return true;
904
+ return isValuePresent(this.valueCtrl.value);
905
+ }
906
+ /** The operators available for a field's internal type, as autocomplete items. */
907
+ operatorsForField(field) {
908
+ return this.#operatorsForInternalType(field.internalType ?? 'string');
909
+ }
910
+ /** Human-readable label for an operator id: a math symbol (`=`, `≠`, …), a translated key, or the id itself. */
911
+ operatorLabel(operator) {
912
+ const symbol = OPERATOR_SYMBOLS[operator];
913
+ if (symbol)
914
+ return symbol;
915
+ const key = OPERATOR_LABEL_KEYS[operator];
916
+ return key ? this.#translate.instant(key) : operator;
917
+ }
918
+ /** Build a condition record from a date preset / boolean operator / value. */
919
+ buildCommitCondition(field, operatorId, operatorLabelText, value, internalTypeOverride) {
920
+ if (operatorId.startsWith('date:')) {
921
+ return {
922
+ fieldId: field.id,
923
+ internalType: internalTypeOverride ?? field.internalType ?? 'datetime',
924
+ fieldLabel: field.label,
925
+ operator: operatorId,
926
+ operatorLabel: operatorLabelText,
927
+ value: operatorId.slice(DATE_PREFIX_LEN$1),
928
+ conditionLabel: `${field.label} ${operatorLabelText}`
929
+ };
930
+ }
931
+ if (operatorId === 'empty' || operatorId === 'not_empty') {
932
+ return {
933
+ fieldId: field.id,
934
+ internalType: internalTypeOverride ?? field.internalType ?? 'string',
935
+ fieldLabel: field.label,
936
+ operator: operatorId,
937
+ operatorLabel: operatorLabelText,
938
+ value: '',
939
+ conditionLabel: `${field.label} ${operatorLabelText}`
940
+ };
941
+ }
942
+ if (operatorId === 'eq_true' || operatorId === 'eq_false') {
943
+ const val = operatorId === 'eq_true' ? 'true' : 'false';
944
+ return {
945
+ fieldId: field.id,
946
+ internalType: internalTypeOverride ?? field.internalType ?? 'boolean',
947
+ fieldLabel: field.label,
948
+ operator: 'eq',
949
+ operatorLabel: '=',
950
+ value: val,
951
+ conditionLabel: `${field.label} = ${val}`
952
+ };
953
+ }
954
+ const valueLabel = Array.isArray(value) ? value.join(', ') : value;
955
+ return {
956
+ fieldId: field.id,
957
+ internalType: internalTypeOverride ?? field.internalType ?? 'string',
958
+ fieldLabel: field.label,
959
+ operator: operatorId,
960
+ operatorLabel: operatorLabelText,
961
+ value,
962
+ conditionLabel: `${field.label} ${operatorLabelText} ${valueLabel}`
963
+ };
964
+ }
965
+ /**
966
+ * Resolve a field definition by id across the given block types, falling back to
967
+ * the universal base fields. Used by the host to render the value editor for a
968
+ * picked field (including inherited base fields not present on any type).
969
+ */
970
+ resolveFieldDefinition(types, fieldId) {
971
+ // When editing inside a table row, the field is one of the table's columns.
972
+ const column = this.activeTableColumns()?.find((col) => col.id === fieldId);
973
+ if (column)
974
+ return column;
975
+ return this.#resolveTypeField(types, fieldId);
976
+ }
977
+ /** Resolve a field by id from the block types, falling back to base/system fields. */
978
+ #resolveTypeField(types, fieldId) {
979
+ for (const type of types) {
980
+ const objectType = this.objectTypes().find((candidate) => candidate.id === type.id);
981
+ const field = objectType?.fields.find((typeField) => typeField.id === fieldId);
982
+ if (field)
983
+ return this.#withInternalType(field);
984
+ }
985
+ const baseField = this.#baseFields().find((field) => field.id === fieldId);
986
+ return baseField ? this.#withInternalType(baseField) : null;
987
+ }
988
+ /**
989
+ * Re-derive `_internalType` from the field's full classification — including its
990
+ * `catalog` reference. The schema-baked `_internalType` omits `catalog`, so a
991
+ * dynamic catalog would otherwise be misrendered as a static (empty) catalog.
992
+ * Mirrors how object-form / renderer.service resolve the element type.
993
+ */
994
+ #withInternalType(field) {
995
+ return {
996
+ ...field,
997
+ _internalType: this.#system.getInternalFormElementType(field.propertyType, field.classifications, field.catalog)
998
+ };
999
+ }
1000
+ // ── Internals ─────────────────────────────────────────────────────────────
1001
+ #nextId() {
1002
+ this.#blockSeq += 1;
1003
+ return `blk-${this.#blockSeq}`;
1004
+ }
1005
+ /**
1006
+ * Keep the id sequence ahead of any id already in the tree (e.g. ids carried in
1007
+ * by a loaded state). Without this, `#blockSeq` stays at 0 after `loadState` and
1008
+ * the next generated id (`blk-1`) collides with a restored `blk-1`, breaking the
1009
+ * `@for` track-by on block id.
1010
+ */
1011
+ #trackSeq(id) {
1012
+ const match = /^blk-(\d+)$/.exec(id);
1013
+ if (match)
1014
+ this.#blockSeq = Math.max(this.#blockSeq, Number(match[1]));
1015
+ }
1016
+ /**
1017
+ * Normalize a block coming from a loaded state. Ensures an `id`, and migrates
1018
+ * legacy single-type blocks (`typeId`/`typeLabel`/`isSot`) into the `types[]`
1019
+ * shape so older saved states remain loadable.
1020
+ */
1021
+ #normalizeBlock(block) {
1022
+ const legacy = block;
1023
+ const types = Array.isArray(legacy.types)
1024
+ ? legacy.types
1025
+ : [{ id: legacy.typeId ?? '', label: legacy.typeLabel ?? legacy.typeId ?? '', isSot: legacy.isSot }];
1026
+ const id = legacy.id ?? this.#nextId();
1027
+ this.#trackSeq(id);
1028
+ return {
1029
+ id,
1030
+ types,
1031
+ conditions: legacy.conditions ?? [],
1032
+ conditionCombinator: legacy.conditionCombinator ?? 'AND'
1033
+ };
1034
+ }
1035
+ /**
1036
+ * Fields shared by *all* given types (the intersection by field id), shaped as
1037
+ * field suggestions. A field must be `queryable`, not in `skipProperties`, and
1038
+ * present on every type. Field metadata (label, internalType) is taken from the
1039
+ * first type that declares it.
1040
+ *
1041
+ * Each type's own fields are unioned with the universal base/system fields so
1042
+ * inherited properties (e.g. `system:creationDate`) become selectable too.
1043
+ */
1044
+ #sharedFields(types) {
1045
+ if (types.length === 0)
1046
+ return [];
1047
+ const objectTypes = types
1048
+ .map((type) => this.objectTypes().find((objectType) => objectType.id === type.id))
1049
+ .filter((objectType) => !!objectType);
1050
+ if (objectTypes.length !== types.length)
1051
+ return [];
1052
+ const skip = new Set(this.skipProperties());
1053
+ const [first, ...rest] = objectTypes;
1054
+ const restFieldIds = rest.map((objectType) => new Set(this.#resolveFields(objectType).map((field) => field.id)));
1055
+ return this.#resolveFields(first)
1056
+ .filter((field) => field.queryable !== false && !skip.has(field.id))
1057
+ .filter((field) => restFieldIds.every((ids) => ids.has(field.id)))
1058
+ .map((field) => ({
1059
+ kind: 'field',
1060
+ id: field.id,
1061
+ label: this.#system.getLocalizedLabel(field.id) || field.label || field.name || field.id,
1062
+ internalType: this.#system.getInternalFormElementType(field.propertyType, field.classifications, field.catalog)
1063
+ }));
1064
+ }
1065
+ /**
1066
+ * The universal base/system fields (`system:creationDate`, `system:createdBy`, …)
1067
+ * resolved from the global property definitions. These are inherited by every
1068
+ * object type but are not part of any concrete type's resolved `fields`.
1069
+ */
1070
+ #baseFields() {
1071
+ return Object.values(BaseObjectTypeField)
1072
+ .map((id) => this.#system.getObjectTypeField(id))
1073
+ .filter((field) => !!field);
1074
+ }
1075
+ /** A type's own fields unioned with the base fields, de-duplicated by id (own wins). */
1076
+ #resolveFields(objectType) {
1077
+ return this.#dedupeById([...objectType.fields, ...this.#baseFields()]);
1078
+ }
1079
+ #dedupeById(fields) {
1080
+ const seen = new Set();
1081
+ return fields.filter((field) => (seen.has(field.id) ? false : seen.add(field.id) && true));
1082
+ }
1083
+ // ── Table columns ──────────────────────────────────────────────────────────
1084
+ /** Resolve a table field's `columnDefinitions` into full `ObjectTypeField`s. */
1085
+ #tableColumns(table) {
1086
+ const block = this.activeBlock();
1087
+ if (!block)
1088
+ return [];
1089
+ const tableField = this.#resolveTypeField(block.types, table.fieldId);
1090
+ return (tableField?.columnDefinitions ?? []).map((col) => this.#columnToField(col));
1091
+ }
1092
+ /**
1093
+ * Map a table column definition to an `ObjectTypeField` with a re-derived
1094
+ * `_internalType` (so the value editor renders the right widget). Column defs may
1095
+ * carry `classification` (schema) or `classifications` (base shape) — accept both.
1096
+ */
1097
+ #columnToField(col) {
1098
+ const classifications = col.classifications ?? col.classification;
1099
+ return {
1100
+ ...col,
1101
+ name: col.name ?? col.id,
1102
+ label: this.#system.getLocalizedLabel(col.id) || col.id,
1103
+ classifications,
1104
+ _internalType: this.#system.getInternalFormElementType(col.propertyType, classifications, col.catalog)
1105
+ };
1106
+ }
1107
+ /** Shape resolved table columns as field-step suggestions. */
1108
+ #columnSuggestions(columns) {
1109
+ const skip = new Set(this.skipProperties());
1110
+ return columns
1111
+ .filter((col) => col.queryable !== false && !skip.has(col.id))
1112
+ .map((col) => ({
1113
+ kind: 'field',
1114
+ id: col.id,
1115
+ label: this.#system.getLocalizedLabel(col.id) || col.label || col.name || col.id,
1116
+ internalType: col._internalType
1117
+ }));
1118
+ }
1119
+ /** Check whether a container (block, group or table) still exists in the current tree by reference. */
1120
+ #containerExists(container) {
1121
+ if (!isConditionGroup(container) && !isTableCondition(container)) {
1122
+ return this.blocks().includes(container);
1123
+ }
1124
+ return this.#containsRef(this.blocks().flatMap((block) => block.conditions), container);
1125
+ }
1126
+ #containsRef(nodes, needle) {
1127
+ for (const node of nodes) {
1128
+ if (node === needle)
1129
+ return true;
1130
+ if ((isConditionGroup(node) || isTableCondition(node)) && this.#containsRef(node.conditions, needle))
1131
+ return true;
1132
+ }
1133
+ return false;
1134
+ }
1135
+ #operatorsForInternalType(internalType) {
1136
+ const base = (ids) => ids.map((id) => ({ kind: 'operator', id, label: this.operatorLabel(id) }));
1137
+ // Null checks are offered on every queryable type.
1138
+ const empty = () => base(['empty', 'not_empty']);
1139
+ switch (internalType) {
1140
+ case 'integer':
1141
+ case 'decimal':
1142
+ return [...base(['eq', 'neq', 'gt', 'gte', 'lt', 'lte']), ...empty()];
1143
+ case 'datetime':
1144
+ return [
1145
+ ...base(['eq', 'neq', 'gt', 'gte', 'lt', 'lte']),
1146
+ ...DATE_PRESETS.map((preset) => ({
1147
+ kind: 'date-preset',
1148
+ id: `date:${preset.id}`,
1149
+ label: this.#translate.instant(preset.labelKey)
1150
+ })),
1151
+ ...empty()
1152
+ ];
1153
+ case 'boolean':
1154
+ case 'boolean:switch':
1155
+ return [
1156
+ { kind: 'operator', id: 'eq_true', label: this.#translate.instant('yuv.smart-search.operator.eq-true') },
1157
+ { kind: 'operator', id: 'eq_false', label: this.#translate.instant('yuv.smart-search.operator.eq-false') },
1158
+ ...empty()
1159
+ ];
1160
+ case 'string:catalog':
1161
+ case 'string:catalog:i18n':
1162
+ case 'string:catalog:dynamic':
1163
+ case 'string:organization':
1164
+ case 'string:organization:set':
1165
+ case 'string:reference':
1166
+ return [...base(['eq', 'neq']), ...empty()];
1167
+ case 'table':
1168
+ return [];
1169
+ default:
1170
+ return [...base(['like', 'eq', 'neq']), ...empty()];
1171
+ }
1172
+ }
1173
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: SmartSearchEditController, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
1174
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: SmartSearchEditController }); }
1175
+ }
1176
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: SmartSearchEditController, decorators: [{
1177
+ type: Injectable
1178
+ }] });
1179
+
1180
+ /**
1181
+ * Renders a single `ConditionContainer` (block body, nested group, or table).
1182
+ *
1183
+ * - When `bare=true`, the panel wrapper (combinator header + remove button) is
1184
+ * omitted; used by the host for the top-level `SearchBlock` body where the
1185
+ * block already has its own type header.
1186
+ * - When `bare=false` (default), renders the full nested-group panel with a
1187
+ * group-scoped combinator toggle and an X to remove the group.
1188
+ * - A `TableCondition` renders as a labelled panel whose children are column
1189
+ * conditions; like a block/group it offers "Add condition", but no "Add group".
1190
+ *
1191
+ * Chip and inline-editor markup is supplied by the host as `TemplateRef`s so
1192
+ * Material plumbing (FormControls, autocomplete) stays in one place.
1193
+ *
1194
+ * This is an internal building block of {@link SmartSearchComponent} and is
1195
+ * rendered recursively for nested groups and tables — it is not meant to be used
1196
+ * standalone. The host shares its {@link SmartSearchEditController} instance with it.
1197
+ *
1198
+ * @example
1199
+ * ```html
1200
+ * <yuv-smart-search-group
1201
+ * [group]="block"
1202
+ * [bare]="true"
1203
+ * [chipTpl]="chipTpl"
1204
+ * [editorTpl]="editorTpl"
1205
+ * />
1206
+ * ```
1207
+ */
1208
+ class SmartSearchGroupComponent {
1209
+ constructor() {
1210
+ /** The container to render: a top-level block body, a nested group, or a table condition. */
1211
+ this.group = input.required(...(ngDevMode ? [{ debugName: "group" }] : /* istanbul ignore next */ []));
1212
+ /**
1213
+ * When `true`, omit the panel wrapper (combinator header + remove button) and
1214
+ * render only the children. Used for the top-level block body, which already
1215
+ * carries its own type header. Defaults to `false` (full nested-group panel).
1216
+ */
1217
+ this.bare = input(false, ...(ngDevMode ? [{ debugName: "bare" }] : /* istanbul ignore next */ []));
1218
+ /** Host-supplied template for a committed condition chip. */
1219
+ this.chipTpl = input.required(...(ngDevMode ? [{ debugName: "chipTpl" }] : /* istanbul ignore next */ []));
1220
+ /** Host-supplied template for the inline condition editor. */
1221
+ this.editorTpl = input.required(...(ngDevMode ? [{ debugName: "editorTpl" }] : /* istanbul ignore next */ []));
1222
+ this.ctrl = inject(SmartSearchEditController);
1223
+ this.isGroup = isConditionGroup;
1224
+ this.isTable = isTableCondition;
1225
+ /** True when this container is a `TableCondition` (its children are column conditions). */
1226
+ this.isTableContainer = computed(() => isTableCondition(this.group()), ...(ngDevMode ? [{ debugName: "isTableContainer" }] : /* istanbul ignore next */ []));
1227
+ /** Label shown in the table panel header. */
1228
+ this.tableLabel = computed(() => {
1229
+ const container = this.group();
1230
+ return isTableCondition(container) ? container.fieldLabel : '';
1231
+ }, ...(ngDevMode ? [{ debugName: "tableLabel" }] : /* istanbul ignore next */ []));
1232
+ /** Combinator value irrespective of whether this is a block, group or table. */
1233
+ this.containerCombinator = computed(() => {
1234
+ const container = this.group();
1235
+ if (isConditionGroup(container) || isTableCondition(container))
1236
+ return container.combinator;
1237
+ return container.conditionCombinator;
1238
+ }, ...(ngDevMode ? [{ debugName: "containerCombinator" }] : /* istanbul ignore next */ []));
1239
+ this.isActive = computed(() => this.ctrl.activeContainer() === this.group(), ...(ngDevMode ? [{ debugName: "isActive" }] : /* istanbul ignore next */ []));
1240
+ }
1241
+ /** Set this container's AND/OR combinator. */
1242
+ setCombinator(value) {
1243
+ this.ctrl.setContainerCombinator(this.group(), value);
1244
+ }
1245
+ /** Remove this container when it is a nested group (no-op for blocks and tables). */
1246
+ removeGroup() {
1247
+ const container = this.group();
1248
+ if (isConditionGroup(container)) {
1249
+ this.ctrl.removeGroup(container);
1250
+ }
1251
+ }
1252
+ /** Begin adding a condition inside this container. */
1253
+ startAddCondition() {
1254
+ this.ctrl.startAddCondition(this.group());
1255
+ }
1256
+ /** Add a nested condition group inside this container. */
1257
+ addGroup() {
1258
+ this.ctrl.addGroup(this.group());
1259
+ }
1260
+ /** Narrow a child node to a {@link ConditionGroup} for the template (guarded by `isGroup`). */
1261
+ asGroup(node) {
1262
+ return node;
1263
+ }
1264
+ /** Narrow a child node to a {@link TableCondition} for the template (guarded by `isTable`). */
1265
+ asTable(node) {
1266
+ return node;
1267
+ }
1268
+ /** Narrow a child node to a {@link FieldCondition} for the template (the remaining case). */
1269
+ asCondition(node) {
1270
+ return node;
1271
+ }
1272
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: SmartSearchGroupComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1273
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: SmartSearchGroupComponent, isStandalone: true, selector: "yuv-smart-search-group", inputs: { group: { classPropertyName: "group", publicName: "group", isSignal: true, isRequired: true, transformFunction: null }, bare: { classPropertyName: "bare", publicName: "bare", isSignal: true, isRequired: false, transformFunction: null }, chipTpl: { classPropertyName: "chipTpl", publicName: "chipTpl", isSignal: true, isRequired: true, transformFunction: null }, editorTpl: { classPropertyName: "editorTpl", publicName: "editorTpl", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: "@if (bare()) {\n <ng-container *ngTemplateOutlet=\"body\" />\n} @else {\n <div class=\"group\" [class.group--active]=\"isActive()\" [class.group--table]=\"isTableContainer()\">\n @if (isTableContainer()) {\n <div class=\"group__header\">\n <span class=\"group__table-label\">{{ tableLabel() }}</span>\n <!-- Pseudo-operator: a table reads like a normal condition \u2014 \"Agent has \u2026\".\n It is the only operator a table offers, so it is applied implicitly. -->\n <span class=\"group__table-op\">{{ 'yuv.smart-search.operator.has' | translate }}</span>\n </div>\n }\n <div class=\"group__body\">\n <ng-container *ngTemplateOutlet=\"body\" />\n </div>\n </div>\n}\n\n<ng-template #body>\n @for (node of group().conditions; track node; let i = $index) {\n @if (isTable(node)) {\n <yuv-smart-search-group [group]=\"asTable(node)\" [chipTpl]=\"chipTpl()\" [editorTpl]=\"editorTpl()\" />\n } @else if (isGroup(node)) {\n <yuv-smart-search-group [group]=\"asGroup(node)\" [chipTpl]=\"chipTpl()\" [editorTpl]=\"editorTpl()\" />\n } @else {\n <ng-container\n *ngTemplateOutlet=\"chipTpl(); context: { $implicit: asCondition(node), container: group(), index: i }\"\n />\n }\n @if (!$last) {\n <div class=\"condition-combinator\">\n <!-- Table columns are row-scoped and always combine with OR (\"a row that\n has this as well as that\"), so they show a static label instead of a\n toggle. Groups and blocks keep their AND/OR toggle. -->\n @if (isTableContainer()) {\n <span class=\"combinator__label\">{{ 'yuv.smart-search.combinator.as-well-as' | translate }}</span>\n } @else {\n <button\n class=\"combinator__btn\"\n [class.combinator__btn--active]=\"containerCombinator() === 'AND'\"\n (click)=\"setCombinator('AND')\"\n >\n {{ 'yuv.smart-search.combinator.and' | translate }}\n </button>\n <button\n class=\"combinator__btn\"\n [class.combinator__btn--active]=\"containerCombinator() === 'OR'\"\n (click)=\"setCombinator('OR')\"\n >\n {{ 'yuv.smart-search.combinator.or' | translate }}\n </button>\n }\n </div>\n }\n }\n\n @if (isActive()) {\n <ng-container *ngTemplateOutlet=\"editorTpl(); context: { $implicit: group() }\" />\n } @else {\n <div class=\"add-btns\">\n <button\n ymtIconButton\n icon-button-size=\"small\"\n class=\"add-condition-btn\"\n [matTooltip]=\"'yuv.smart-search.add-condition' | translate\"\n (click)=\"startAddCondition()\"\n >\n <mat-icon>add</mat-icon>\n </button>\n <!-- Tables hold column conditions directly \u2014 no grouping inside them. -->\n @if (!isTableContainer()) {\n <button\n ymtIconButton\n icon-button-size=\"small\"\n class=\"add-condition-btn\"\n [matTooltip]=\"'yuv.smart-search.add-group' | translate\"\n (click)=\"addGroup()\"\n >\n <mat-icon>data_array</mat-icon>\n </button>\n }\n </div>\n }\n</ng-template>\n", styles: [":host{display:contents}.group{display:flex;flex-direction:column;gap:var(--ymt-spacing-3xs);padding:var(--ymt-spacing-2xs);margin:var(--ymt-spacing-2xs) 0;border-radius:var(--ymt-corner-xs);background:rgb(from var(--ymt-outline) r g b/.06);border:1px dashed var(--ymt-outline)}.group--active{border-color:var(--ymt-primary)}.group--table{border-style:solid}.group__header{display:flex;align-items:center;gap:var(--ymt-spacing-xs)}.group__table-label{font-weight:600;font-size:.85em;opacity:.85}.group__table-op{font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle)}.group__remove{margin-left:auto;opacity:.6}.group__remove:hover{opacity:1}.group__body{display:flex;flex-wrap:wrap;align-items:center;row-gap:var(--ymt-spacing-3xs);column-gap:var(--ymt-spacing-s)}.add-btns{display:flex;align-items:center;gap:var(--ymt-spacing-xs)}.add-condition-btn{color:var(--ymt-text-color-subtle)}.combinator{display:flex;justify-content:center;gap:0}.combinator__label{font:var(--ymt-font-body-subtle);text-transform:lowercase;color:var(--ymt-text-color-subtle);padding:var(--ymt-spacing-2xs) var(--ymt-spacing-xs)}.combinator__btn{--bg: transparent;--fg: var(--ymt-text-color);--mdc-shape-small: var(--ymt-corner-xs);font-size:.82em;background-color:var(--bg);color:var(--fg);padding:var(--ymt-spacing-2xs) var(--ymt-spacing-xs);border:0;outline:1px solid var(--ymt-inverse-surface);outline-offset:-1px}.combinator__btn:first-child{border-radius:var(--mdc-shape-small) 0 0 var(--mdc-shape-small)}.combinator__btn:last-child{border-radius:0 var(--mdc-shape-small) var(--mdc-shape-small) 0}.combinator__btn--active{--bg: var(--ymt-inverse-surface);--fg: var(--ymt-on-inverse-surface)}\n"], dependencies: [{ kind: "component", type: SmartSearchGroupComponent, selector: "yuv-smart-search-group", inputs: ["group", "bare", "chipTpl", "editorTpl"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "directive", type: YmtIconButtonDirective, selector: "button[ymtIconButton],button[ymt-icon-button],a[ymtIconButton],a[ymt-icon-button]", inputs: ["disabled", "disableRipple", "aria-disabled", "disabledInteractive", "icon-button-size"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1274
+ }
1275
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: SmartSearchGroupComponent, decorators: [{
1276
+ type: Component,
1277
+ args: [{ selector: 'yuv-smart-search-group', imports: [
1278
+ NgTemplateOutlet,
1279
+ MatButtonModule,
1280
+ YmtIconButtonDirective,
1281
+ MatIconModule,
1282
+ MatTooltipModule,
1283
+ YmtIconButtonDirective,
1284
+ TranslatePipe
1285
+ ], changeDetection: ChangeDetectionStrategy.OnPush, template: "@if (bare()) {\n <ng-container *ngTemplateOutlet=\"body\" />\n} @else {\n <div class=\"group\" [class.group--active]=\"isActive()\" [class.group--table]=\"isTableContainer()\">\n @if (isTableContainer()) {\n <div class=\"group__header\">\n <span class=\"group__table-label\">{{ tableLabel() }}</span>\n <!-- Pseudo-operator: a table reads like a normal condition \u2014 \"Agent has \u2026\".\n It is the only operator a table offers, so it is applied implicitly. -->\n <span class=\"group__table-op\">{{ 'yuv.smart-search.operator.has' | translate }}</span>\n </div>\n }\n <div class=\"group__body\">\n <ng-container *ngTemplateOutlet=\"body\" />\n </div>\n </div>\n}\n\n<ng-template #body>\n @for (node of group().conditions; track node; let i = $index) {\n @if (isTable(node)) {\n <yuv-smart-search-group [group]=\"asTable(node)\" [chipTpl]=\"chipTpl()\" [editorTpl]=\"editorTpl()\" />\n } @else if (isGroup(node)) {\n <yuv-smart-search-group [group]=\"asGroup(node)\" [chipTpl]=\"chipTpl()\" [editorTpl]=\"editorTpl()\" />\n } @else {\n <ng-container\n *ngTemplateOutlet=\"chipTpl(); context: { $implicit: asCondition(node), container: group(), index: i }\"\n />\n }\n @if (!$last) {\n <div class=\"condition-combinator\">\n <!-- Table columns are row-scoped and always combine with OR (\"a row that\n has this as well as that\"), so they show a static label instead of a\n toggle. Groups and blocks keep their AND/OR toggle. -->\n @if (isTableContainer()) {\n <span class=\"combinator__label\">{{ 'yuv.smart-search.combinator.as-well-as' | translate }}</span>\n } @else {\n <button\n class=\"combinator__btn\"\n [class.combinator__btn--active]=\"containerCombinator() === 'AND'\"\n (click)=\"setCombinator('AND')\"\n >\n {{ 'yuv.smart-search.combinator.and' | translate }}\n </button>\n <button\n class=\"combinator__btn\"\n [class.combinator__btn--active]=\"containerCombinator() === 'OR'\"\n (click)=\"setCombinator('OR')\"\n >\n {{ 'yuv.smart-search.combinator.or' | translate }}\n </button>\n }\n </div>\n }\n }\n\n @if (isActive()) {\n <ng-container *ngTemplateOutlet=\"editorTpl(); context: { $implicit: group() }\" />\n } @else {\n <div class=\"add-btns\">\n <button\n ymtIconButton\n icon-button-size=\"small\"\n class=\"add-condition-btn\"\n [matTooltip]=\"'yuv.smart-search.add-condition' | translate\"\n (click)=\"startAddCondition()\"\n >\n <mat-icon>add</mat-icon>\n </button>\n <!-- Tables hold column conditions directly \u2014 no grouping inside them. -->\n @if (!isTableContainer()) {\n <button\n ymtIconButton\n icon-button-size=\"small\"\n class=\"add-condition-btn\"\n [matTooltip]=\"'yuv.smart-search.add-group' | translate\"\n (click)=\"addGroup()\"\n >\n <mat-icon>data_array</mat-icon>\n </button>\n }\n </div>\n }\n</ng-template>\n", styles: [":host{display:contents}.group{display:flex;flex-direction:column;gap:var(--ymt-spacing-3xs);padding:var(--ymt-spacing-2xs);margin:var(--ymt-spacing-2xs) 0;border-radius:var(--ymt-corner-xs);background:rgb(from var(--ymt-outline) r g b/.06);border:1px dashed var(--ymt-outline)}.group--active{border-color:var(--ymt-primary)}.group--table{border-style:solid}.group__header{display:flex;align-items:center;gap:var(--ymt-spacing-xs)}.group__table-label{font-weight:600;font-size:.85em;opacity:.85}.group__table-op{font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle)}.group__remove{margin-left:auto;opacity:.6}.group__remove:hover{opacity:1}.group__body{display:flex;flex-wrap:wrap;align-items:center;row-gap:var(--ymt-spacing-3xs);column-gap:var(--ymt-spacing-s)}.add-btns{display:flex;align-items:center;gap:var(--ymt-spacing-xs)}.add-condition-btn{color:var(--ymt-text-color-subtle)}.combinator{display:flex;justify-content:center;gap:0}.combinator__label{font:var(--ymt-font-body-subtle);text-transform:lowercase;color:var(--ymt-text-color-subtle);padding:var(--ymt-spacing-2xs) var(--ymt-spacing-xs)}.combinator__btn{--bg: transparent;--fg: var(--ymt-text-color);--mdc-shape-small: var(--ymt-corner-xs);font-size:.82em;background-color:var(--bg);color:var(--fg);padding:var(--ymt-spacing-2xs) var(--ymt-spacing-xs);border:0;outline:1px solid var(--ymt-inverse-surface);outline-offset:-1px}.combinator__btn:first-child{border-radius:var(--mdc-shape-small) 0 0 var(--mdc-shape-small)}.combinator__btn:last-child{border-radius:0 var(--mdc-shape-small) var(--mdc-shape-small) 0}.combinator__btn--active{--bg: var(--ymt-inverse-surface);--fg: var(--ymt-on-inverse-surface)}\n"] }]
1286
+ }], propDecorators: { group: [{ type: i0.Input, args: [{ isSignal: true, alias: "group", required: true }] }], bare: [{ type: i0.Input, args: [{ isSignal: true, alias: "bare", required: false }] }], chipTpl: [{ type: i0.Input, args: [{ isSignal: true, alias: "chipTpl", required: true }] }], editorTpl: [{ type: i0.Input, args: [{ isSignal: true, alias: "editorTpl", required: true }] }] } });
1287
+
1288
+ const DEBOUNCE_MS = 150;
1289
+ const DATE_PREFIX_LEN = 5;
1290
+ /**
1291
+ * Visual query builder that turns guided, chip-based user input into a CMIS query.
1292
+ *
1293
+ * The user composes a search in two independent parts:
1294
+ * - a **full-text bar** (`CONTAINS`) with a scope (all / metadata / content) and an
1295
+ * optional object-type restriction, and
1296
+ * - one or more **type blocks**, each targeting one or more object types and holding
1297
+ * field conditions, nested groups and table-column conditions.
1298
+ *
1299
+ * Conditions are built step by step (type → field → operator → value) with an inline
1300
+ * autocomplete editor; the value step renders the field's real metadata widget
1301
+ * (datepicker, catalog select, organization picker, …). The resulting CMIS query is
1302
+ * emitted through {@link queryChange} on every change and can be saved/restored as a
1303
+ * plain-data {@link SmartSearchState} via {@link getState} / {@link loadState}.
1304
+ *
1305
+ * State and mutators live in {@link SmartSearchEditController} (provided per instance);
1306
+ * this component owns only the UI concerns (focus, autocomplete plumbing, blur handling).
1307
+ *
1308
+ * @example
1309
+ * ```html
1310
+ * <!-- Restrict the picker to two object types and skip a noisy property -->
1311
+ * <yuv-smart-search
1312
+ * [types]="['document', 'invoice']"
1313
+ * [skipProperties]="['system:traceId']"
1314
+ * (queryChange)="onQuery($event)"
1315
+ * />
1316
+ * ```
1317
+ *
1318
+ * @example
1319
+ * ```ts
1320
+ * // Save and restore the builder state (e.g. a stored search)
1321
+ * const search = viewChild.required(SmartSearchComponent);
1322
+ *
1323
+ * onQuery(cmisQuery: string) {
1324
+ * // empty string means "no query" — treat it as a cleared search
1325
+ * this.results.set(cmisQuery ? this.backend.search(cmisQuery) : []);
1326
+ * }
1327
+ *
1328
+ * persist() {
1329
+ * localStorage.setItem('search', JSON.stringify(this.search().getState()));
1330
+ * }
1331
+ *
1332
+ * restore() {
1333
+ * const state = localStorage.getItem('search');
1334
+ * if (state) this.search().loadState(JSON.parse(state));
1335
+ * }
1336
+ * ```
1337
+ */
1338
+ class SmartSearchComponent {
1339
+ #host;
1340
+ /** Previous activeContainer value — used to detect transitions for focus management. */
1341
+ #prevActiveContainer;
1342
+ constructor() {
1343
+ this.#host = inject((ElementRef));
1344
+ this.ctrl = inject(SmartSearchEditController);
1345
+ /**
1346
+ * Object-type ids that may be searched. Restricts the type picker (both the
1347
+ * full-text type filter and the type-block multi-select) to these types. When
1348
+ * empty, no type is offered — set at least one id to enable building blocks.
1349
+ */
1350
+ this.types = input([], ...(ngDevMode ? [{ debugName: "types" }] : /* istanbul ignore next */ []));
1351
+ /**
1352
+ * Field ids to hide from the field-step autocomplete (e.g. internal/system
1353
+ * properties that should not be user-queryable). Applies to block fields and
1354
+ * table columns alike.
1355
+ */
1356
+ this.skipProperties = input([], ...(ngDevMode ? [{ debugName: "skipProperties" }] : /* istanbul ignore next */ []));
1357
+ /**
1358
+ * Emits the current CMIS query string whenever the search changes. An empty
1359
+ * string is emitted for an empty search (including the initial seed), so
1360
+ * consumers should treat `''` as "no query" rather than expecting only
1361
+ * non-empty values.
1362
+ */
1363
+ this.queryChange = output();
1364
+ this.auto = viewChild.required('auto');
1365
+ /** Trigger of the currently-focused autocomplete input — used to re-open the
1366
+ * panel after a type pick so the multi-select stays open. */
1367
+ this.trigger = viewChild(MatAutocompleteTrigger, ...(ngDevMode ? [{ debugName: "trigger" }] : /* istanbul ignore next */ []));
1368
+ /** Term control for the full-text bar (independent of the build-step controls). */
1369
+ this.fulltextTermCtrl = new FormControl('');
1370
+ /** Sentinel option value representing "no type restriction" in the type multi-select. */
1371
+ this.ALL_TYPES = '__all__';
1372
+ /**
1373
+ * Guard that prevents `onInlineBlur` from cancelling the pending condition
1374
+ * when we programmatically open the inline editor.
1375
+ */
1376
+ this._suppressNextBlur = false;
1377
+ /** Prevents the valueChanges subscriber from overwriting inputTerm during programmatic setValue */
1378
+ this._suppressInputTerm = false;
1379
+ /**
1380
+ * Set when a type is staged via the autocomplete so the ENTER that triggered the
1381
+ * selection does not also fall through to `confirmTypes()`. Cleared on the next
1382
+ * tick. Needed because the chip-input directive changes the keydown listener order,
1383
+ * so `onTypeEnter` can run *after* the autocomplete has already closed its panel.
1384
+ */
1385
+ this._suppressTypeConfirm = false;
1386
+ /**
1387
+ * Set while an autocomplete option is being applied so the panel's `closed`
1388
+ * event (which Material emits synchronously right after `optionSelected`) is not
1389
+ * mistaken for the user abandoning an empty property picker. Consumed
1390
+ * synchronously in `onPickerClosed`.
1391
+ */
1392
+ this._pickerJustSelected = false;
1393
+ /** Previous activeContainer value — used to detect transitions for focus management. */
1394
+ this.#prevActiveContainer = null;
1395
+ // ── Public API (proxies onto the controller) ─────────────────────────────
1396
+ this.blocks = this.ctrl.blocks;
1397
+ this.draftTypes = this.ctrl.draftTypes;
1398
+ this.combinator = this.ctrl.combinator;
1399
+ this.step = this.ctrl.step;
1400
+ this.activeBlock = this.ctrl.activeBlock;
1401
+ this.activeContainer = this.ctrl.activeContainer;
1402
+ this.pendingField = this.ctrl.pendingField;
1403
+ this.pendingOperatorLabel = this.ctrl.pendingOperatorLabel;
1404
+ this.editingConditionIndex = this.ctrl.editingConditionIndex;
1405
+ this.inputTerm = this.ctrl.inputTerm;
1406
+ this.cmisQuery = this.ctrl.cmisQuery;
1407
+ this.showCombinator = this.ctrl.showCombinator;
1408
+ this.suggestions = this.ctrl.suggestions;
1409
+ this.fulltext = this.ctrl.fulltext;
1410
+ this.objectTypes = this.ctrl.objectTypes;
1411
+ this.activeBlockFields = this.ctrl.activeBlockFields;
1412
+ /**
1413
+ * Selected ids for the type multi-select. Falls back to the `ALL_TYPES`
1414
+ * sentinel when no concrete type is picked (empty selection = no restriction).
1415
+ */
1416
+ this.fulltextTypeSelection = computed(() => {
1417
+ const ids = this.fulltext().types.map((type) => type.id);
1418
+ return ids.length ? ids : [this.ALL_TYPES];
1419
+ }, ...(ngDevMode ? [{ debugName: "fulltextTypeSelection" }] : /* istanbul ignore next */ []));
1420
+ this.fieldCtrl = this.ctrl.fieldCtrl;
1421
+ this.operatorCtrl = this.ctrl.operatorCtrl;
1422
+ this.valueCtrl = this.ctrl.valueCtrl;
1423
+ /**
1424
+ * Field definition driving the value-step editor. Memoized so the `[field]`
1425
+ * reference stays stable across change-detection cycles — re-resolving on every
1426
+ * CD (via a template method call) would re-create the editor and reset widgets
1427
+ * like the catalog select before their options render.
1428
+ */
1429
+ this.valueFieldDef = computed(() => this.getObjectTypeField(this.ctrl.pendingField()), ...(ngDevMode ? [{ debugName: "valueFieldDef" }] : /* istanbul ignore next */ []));
1430
+ /**
1431
+ * `displayWith` for the shared autocomplete. Suggestions are objects but the
1432
+ * input text is driven by the form controls, so a picked option must not write
1433
+ * a label back into the field — return the raw string, or empty otherwise.
1434
+ */
1435
+ this.displayFn = (item) => {
1436
+ if (typeof item === 'string')
1437
+ return item;
1438
+ return '';
1439
+ };
1440
+ // Mirror the `types` input into the controller signal so it can compute
1441
+ // available object types and filter the type suggestions.
1442
+ effect(() => {
1443
+ this.ctrl.allowedTypes.set(this.types());
1444
+ });
1445
+ effect(() => {
1446
+ this.ctrl.skipProperties.set(this.skipProperties());
1447
+ });
1448
+ // Drive inputTerm from whichever control is active for the current step
1449
+ this.ctrl.fieldCtrl.valueChanges.pipe(debounceTime(DEBOUNCE_MS), takeUntilDestroyed()).subscribe((val) => {
1450
+ const step = this.ctrl.step();
1451
+ if (step === 'field' || step === 'type') {
1452
+ this.ctrl.inputTerm.set(typeof val === 'string' ? val : '');
1453
+ }
1454
+ });
1455
+ this.ctrl.operatorCtrl.valueChanges.pipe(debounceTime(DEBOUNCE_MS), takeUntilDestroyed()).subscribe((val) => {
1456
+ if (this.ctrl.step() === 'operator')
1457
+ this.ctrl.inputTerm.set(typeof val === 'string' ? val : '');
1458
+ });
1459
+ this.ctrl.valueCtrl.valueChanges.pipe(debounceTime(DEBOUNCE_MS), takeUntilDestroyed()).subscribe((val) => {
1460
+ if (this._suppressInputTerm)
1461
+ return;
1462
+ if (this.ctrl.step() === 'value')
1463
+ this.ctrl.inputTerm.set(typeof val === 'string' ? val : '');
1464
+ });
1465
+ // Drive the full-text term (independent of the build steps).
1466
+ this.fulltextTermCtrl.valueChanges.pipe(debounceTime(DEBOUNCE_MS), takeUntilDestroyed()).subscribe((val) => {
1467
+ this.ctrl.setFulltextTerm(typeof val === 'string' ? val : '');
1468
+ });
1469
+ // Emits the seed empty query on first run too — consumers should treat
1470
+ // an empty string as "no query" rather than expecting only non-empty values.
1471
+ effect(() => {
1472
+ this.queryChange.emit(this.ctrl.cmisQuery());
1473
+ });
1474
+ // Focus the inline editor whenever a new container becomes active. Covers
1475
+ // "Add condition" / "Add group" clicks coming from any nesting depth —
1476
+ // those go straight through the controller without touching the host.
1477
+ effect(() => {
1478
+ const container = this.ctrl.activeContainer();
1479
+ if (!container) {
1480
+ this.#prevActiveContainer = null;
1481
+ return;
1482
+ }
1483
+ if (container === this.#prevActiveContainer)
1484
+ return;
1485
+ this.#prevActiveContainer = container;
1486
+ this._suppressNextBlur = true;
1487
+ setTimeout(() => {
1488
+ this.#focusInput();
1489
+ this._suppressNextBlur = false;
1490
+ });
1491
+ });
1492
+ }
1493
+ /** Set the AND/OR combinator that joins the conditions within a single container (block, group or table). */
1494
+ setConditionCombinator(container, value) {
1495
+ this.ctrl.setContainerCombinator(container, value);
1496
+ }
1497
+ /**
1498
+ * Capture the current search as a serializable snapshot. Safe for
1499
+ * `JSON.stringify`/`JSON.parse` roundtrips — use it to persist a search and
1500
+ * later restore it with {@link loadState}.
1501
+ */
1502
+ getState() {
1503
+ return this.ctrl.getState();
1504
+ }
1505
+ /** Restore a previously {@link getState saved} search, replacing the current one. */
1506
+ loadState(state) {
1507
+ this.ctrl.loadState(state);
1508
+ }
1509
+ /** Clear the whole search: discard all blocks, conditions and the full-text term. */
1510
+ clear() {
1511
+ this.fulltextTermCtrl.setValue('', { emitEvent: false });
1512
+ this.trigger()?.closePanel();
1513
+ this.ctrl.reset();
1514
+ }
1515
+ /**
1516
+ * Set the top-level combinator joining the query units (the full-text unit and
1517
+ * the type blocks). Kept for state round-tripping; the UI currently always
1518
+ * joins units with `OR`.
1519
+ */
1520
+ setCombinator(value) {
1521
+ this.ctrl.setCombinator(value);
1522
+ }
1523
+ /** Remove an entire type block and all of its conditions. */
1524
+ removeBlock(block) {
1525
+ this.ctrl.removeBlock(block);
1526
+ }
1527
+ /** Commit the staged draft types into a new block and reset the type input. */
1528
+ confirmTypes() {
1529
+ const block = this.ctrl.confirmDraftTypes();
1530
+ if (!block)
1531
+ return;
1532
+ this.trigger()?.closePanel();
1533
+ this.ctrl.fieldCtrl.setValue('', { emitEvent: false });
1534
+ this.ctrl.inputTerm.set('');
1535
+ setTimeout(() => this.#focusAddCondition(block.id));
1536
+ }
1537
+ /** Start adding a new condition inside the given container and focus the inline editor. */
1538
+ startAddCondition(block) {
1539
+ this.ctrl.startAddCondition(block);
1540
+ this._suppressNextBlur = true;
1541
+ setTimeout(() => {
1542
+ this.#focusInput();
1543
+ this._suppressNextBlur = false;
1544
+ });
1545
+ }
1546
+ /** Add a nested condition group inside the given container and focus the inline editor. */
1547
+ addGroup(block) {
1548
+ this.ctrl.addGroup(block);
1549
+ this._suppressNextBlur = true;
1550
+ setTimeout(() => {
1551
+ this.#focusInput();
1552
+ this._suppressNextBlur = false;
1553
+ });
1554
+ }
1555
+ // ── Full-text bar ───────────────────────────────────────────────────────────
1556
+ /** Set where the full-text search looks: `all` (metadata + content), `metadata`, or `content`. */
1557
+ setFulltextScope(scope) {
1558
+ this.ctrl.setFulltextScope(scope);
1559
+ }
1560
+ /**
1561
+ * Reconcile the type multi-select with the "All types" sentinel. Picking
1562
+ * "All types" while concrete types are selected clears the restriction;
1563
+ * picking any concrete type drops the sentinel.
1564
+ */
1565
+ onFulltextTypesChange(ids) {
1566
+ const hadAll = this.fulltext().types.length === 0;
1567
+ // User just clicked "All types" while specific types were selected → clear restriction.
1568
+ if (ids.includes(this.ALL_TYPES) && !hadAll) {
1569
+ this.ctrl.setFulltextTypes([]);
1570
+ return;
1571
+ }
1572
+ // Otherwise keep only the concrete ids (an empty array also means "all").
1573
+ this.ctrl.setFulltextTypes(ids.filter((id) => id !== this.ALL_TYPES));
1574
+ }
1575
+ // ── Inline-editor coordination ─────────────────────────────────────────────
1576
+ /**
1577
+ * Handle a pick from the shared autocomplete panel. Branches on the current
1578
+ * build step: stages a type (keeping the panel open for more), advances a
1579
+ * field to its operator (or opens a table container), or applies a chosen
1580
+ * operator. Single-operator fields and valueless operators commit immediately.
1581
+ */
1582
+ onSuggestionSelected(event) {
1583
+ const item = event.option.value;
1584
+ // The panel closes (and emits `closed`) as a side effect of this selection.
1585
+ // Flag it so `onPickerClosed` doesn't treat that close as an abandoned edit —
1586
+ // matters for the table branch below, which leaves `step` at 'field'.
1587
+ this._pickerJustSelected = true;
1588
+ setTimeout(() => (this._pickerJustSelected = false));
1589
+ switch (this.ctrl.step()) {
1590
+ case 'type': {
1591
+ const objectType = this.ctrl.objectTypes().find((type) => type.id === item.id);
1592
+ if (!objectType)
1593
+ break;
1594
+ // Stage the type and keep the panel open so more types can be picked.
1595
+ this.ctrl.addDraftType(objectType, item.label);
1596
+ this.ctrl.fieldCtrl.setValue('', { emitEvent: false });
1597
+ this.ctrl.inputTerm.set('');
1598
+ this._suppressNextBlur = true;
1599
+ this._suppressTypeConfirm = true;
1600
+ setTimeout(() => {
1601
+ this.#focusInput();
1602
+ this.trigger()?.openPanel();
1603
+ this._suppressNextBlur = false;
1604
+ this._suppressTypeConfirm = false;
1605
+ });
1606
+ break;
1607
+ }
1608
+ case 'field': {
1609
+ // A table field has no operator of its own: open a table container with a
1610
+ // first row and scope the edit into that row so the user picks a column next.
1611
+ if ((item.internalType ?? '') === 'table') {
1612
+ this.ctrl.addTableCondition(item);
1613
+ this._suppressNextBlur = true;
1614
+ setTimeout(() => {
1615
+ this.#focusInput();
1616
+ this._suppressNextBlur = false;
1617
+ });
1618
+ break;
1619
+ }
1620
+ this.ctrl.pendingField.set(item);
1621
+ this.ctrl.fieldCtrl.setValue(item.label, { emitEvent: false });
1622
+ this.ctrl.operatorCtrl.setValue('', { emitEvent: false });
1623
+ // Clear the field-step filter term so the operator suggestions aren't
1624
+ // filtered by the text typed to find the field — otherwise the operator
1625
+ // panel has no matching options and never opens.
1626
+ this.ctrl.inputTerm.set('');
1627
+ // If the field's type allows exactly one operator, skip the operator
1628
+ // picker and apply it immediately.
1629
+ const operators = this.ctrl.operatorsForField(item);
1630
+ if (operators.length === 1) {
1631
+ this.#applyOperator(item, operators[0]);
1632
+ break;
1633
+ }
1634
+ this.ctrl.step.set('operator');
1635
+ this._suppressNextBlur = true;
1636
+ setTimeout(() => {
1637
+ this.#focusInput();
1638
+ this._suppressNextBlur = false;
1639
+ });
1640
+ break;
1641
+ }
1642
+ case 'operator': {
1643
+ const field = this.ctrl.pendingField();
1644
+ if (!field)
1645
+ break;
1646
+ this.#applyOperator(field, item);
1647
+ break;
1648
+ }
1649
+ }
1650
+ }
1651
+ /**
1652
+ * Enter in the add-type input. When the input is empty there's no option to
1653
+ * pick, so Enter commits the staged types — even while the autocomplete panel
1654
+ * is open. While the user is typing a filter term, Enter is left to the
1655
+ * autocomplete so it can select the highlighted option.
1656
+ */
1657
+ onTypeEnter() {
1658
+ // The ENTER that selected an autocomplete option must not also confirm the
1659
+ // block — regardless of whether this handler runs before or after the
1660
+ // autocomplete closes its panel.
1661
+ if (this._suppressTypeConfirm)
1662
+ return;
1663
+ const term = this.ctrl.fieldCtrl.value;
1664
+ if (typeof term === 'string' && term.trim())
1665
+ return;
1666
+ if (this.ctrl.draftTypes().length === 0)
1667
+ return;
1668
+ this.confirmTypes();
1669
+ }
1670
+ /**
1671
+ * Enter / confirm-button handler for the value step. Commits the in-progress
1672
+ * condition when a value is present. No-op while the autocomplete panel is open
1673
+ * (Enter selects the highlighted option there) or before the value step.
1674
+ */
1675
+ onEnter() {
1676
+ if (this.auto().isOpen)
1677
+ return;
1678
+ if (this.ctrl.step() !== 'value')
1679
+ return;
1680
+ const raw = this.ctrl.valueCtrl.value;
1681
+ if (!isValuePresent(raw))
1682
+ return;
1683
+ const field = this.ctrl.pendingField();
1684
+ const fieldOp = field?.operator ?? '';
1685
+ if (!field || !fieldOp)
1686
+ return;
1687
+ this.ctrl.commitCondition(this.ctrl.buildCommitCondition(field, fieldOp, this.ctrl.operatorLabel(fieldOp), normalizeConditionValue(raw)));
1688
+ }
1689
+ /**
1690
+ * Called when the inline-input wrapper loses focus.
1691
+ * Commits the condition if complete, discards it if incomplete.
1692
+ *
1693
+ * Deferred via setTimeout(0): raw value renderers (datepicker dialog,
1694
+ * mat-select panel, mat-autocomplete panel) mount their UI in the
1695
+ * .cdk-overlay-container, which is OUTSIDE the wrapper. Reading
1696
+ * document.activeElement on the next task lets us detect that focus
1697
+ * is still in our logical scope.
1698
+ */
1699
+ onInlineBlur(event) {
1700
+ if (this._suppressNextBlur)
1701
+ return;
1702
+ const wrapper = event.currentTarget;
1703
+ setTimeout(() => {
1704
+ if (this._suppressNextBlur)
1705
+ return;
1706
+ if (!this.ctrl.activeContainer())
1707
+ return;
1708
+ if (this.auto().isOpen)
1709
+ return;
1710
+ const active = document.activeElement;
1711
+ if (active && wrapper.contains(active))
1712
+ return;
1713
+ if (active?.closest('.cdk-overlay-container'))
1714
+ return;
1715
+ // Backdrop dismissal: focus is briefly on <body> while an overlay pane is still mounted.
1716
+ if (document.querySelector('.cdk-overlay-container .cdk-overlay-pane'))
1717
+ return;
1718
+ if (this.ctrl.isConditionComplete()) {
1719
+ this.#commitFromBlur();
1720
+ }
1721
+ else {
1722
+ this.ctrl.restoreEditingSnapshot();
1723
+ this.ctrl.cancelPending();
1724
+ }
1725
+ });
1726
+ }
1727
+ /**
1728
+ * Fired when the shared autocomplete panel closes. Starting a condition opens
1729
+ * the property picker; if the user dismisses it (outside click, Escape) without
1730
+ * choosing a field, there's an empty editor with nothing to commit — drop it.
1731
+ *
1732
+ * Only the field step is handled here: operator/value abandonment is already
1733
+ * covered by `onInlineBlur` (those steps don't auto-open a picker the user can
1734
+ * dismiss while keeping focus). Restores the original when editing an existing
1735
+ * condition, mirroring the blur path.
1736
+ */
1737
+ onPickerClosed() {
1738
+ // `closed` fires synchronously right after `optionSelected`, so consume the
1739
+ // flag here (not in a deferred task) to stay ahead of the focus timers.
1740
+ const justSelected = this._pickerJustSelected;
1741
+ this._pickerJustSelected = false;
1742
+ if (justSelected)
1743
+ return;
1744
+ setTimeout(() => {
1745
+ if (this._suppressNextBlur)
1746
+ return;
1747
+ if (this.auto().isOpen)
1748
+ return;
1749
+ if (this.ctrl.step() !== 'field')
1750
+ return;
1751
+ if (!this.ctrl.activeContainer())
1752
+ return;
1753
+ // A non-empty filter term means the user is still picking a field.
1754
+ const term = this.ctrl.fieldCtrl.value;
1755
+ if (typeof term === 'string' && term.trim())
1756
+ return;
1757
+ this.ctrl.restoreEditingSnapshot();
1758
+ this.ctrl.cancelPending();
1759
+ });
1760
+ }
1761
+ /** Abandon the in-progress condition edit, discarding any partial input. */
1762
+ cancelPending() {
1763
+ this.ctrl.cancelPending();
1764
+ }
1765
+ /** Jump the inline editor back to the field step so the user can re-pick the field. */
1766
+ editField() {
1767
+ this.ctrl.editField();
1768
+ setTimeout(() => this.#focusInput());
1769
+ }
1770
+ /** Jump the inline editor back to the operator step so the user can re-pick the operator. */
1771
+ editOperator() {
1772
+ this.ctrl.editOperator();
1773
+ setTimeout(() => this.#focusInput());
1774
+ }
1775
+ /**
1776
+ * Open an already-committed condition for editing in place. The condition is
1777
+ * pulled out of the tree into the inline editor at its original index, pre-set
1778
+ * to the appropriate step (value, or operator for valueless operators).
1779
+ */
1780
+ editCondition(container, condition, index) {
1781
+ this.ctrl.editCondition(container, condition, index);
1782
+ this._suppressNextBlur = true;
1783
+ if (this.ctrl.step() === 'value') {
1784
+ this._suppressInputTerm = true;
1785
+ setTimeout(() => {
1786
+ this.ctrl.valueCtrl.setValue(condition.value);
1787
+ this._suppressInputTerm = false;
1788
+ this.#focusInput();
1789
+ this._suppressNextBlur = false;
1790
+ });
1791
+ }
1792
+ else {
1793
+ setTimeout(() => {
1794
+ this.#focusInput();
1795
+ this._suppressNextBlur = false;
1796
+ });
1797
+ }
1798
+ }
1799
+ /** Remove a committed condition from its container. */
1800
+ removeCondition(container, condition) {
1801
+ this.ctrl.removeCondition(container, condition);
1802
+ }
1803
+ /**
1804
+ * Renderer input for a committed condition's value, or `null` when the value
1805
+ * should render as plain text. id-based fields (e.g. organization) store an
1806
+ * opaque id as the query value; routing it through the property renderer
1807
+ * resolves it to a human-readable label for the chip. Valueless operators
1808
+ * (empty/not_empty, date presets) and booleans keep their plain text: their
1809
+ * stored value is synthetic/serialized, not the shape those renderers expect.
1810
+ */
1811
+ valueRendererInput(condition) {
1812
+ if (isValuelessOperator(condition.operator))
1813
+ return null;
1814
+ const internalType = condition.internalType ?? '';
1815
+ if (internalType.startsWith('boolean'))
1816
+ return null;
1817
+ if (!isValuePresent(condition.value))
1818
+ return null;
1819
+ return { propertyName: condition.fieldId, rendererType: internalType, value: condition.value };
1820
+ }
1821
+ /**
1822
+ * Resolve the full {@link ObjectTypeField} definition for a suggestion item in
1823
+ * the active block, or `null` when none applies. Drives the value-step metadata
1824
+ * widget so the right editor (datepicker, catalog, …) is rendered.
1825
+ */
1826
+ getObjectTypeField(item) {
1827
+ const block = this.ctrl.activeBlock();
1828
+ if (!item || !block)
1829
+ return null;
1830
+ // The field is shared across all of the block's types — take the definition
1831
+ // from the first type that declares it, falling back to the universal base fields.
1832
+ return this.ctrl.resolveFieldDefinition(block.types, item.id);
1833
+ }
1834
+ // ── Internals ─────────────────────────────────────────────────────────────
1835
+ /**
1836
+ * Apply a chosen operator to the pending field: commit straight away for
1837
+ * date-preset / boolean operators (which carry their own value), otherwise
1838
+ * advance to the value step.
1839
+ */
1840
+ #applyOperator(field, item) {
1841
+ if (item.kind === 'date-preset') {
1842
+ this.ctrl.commitCondition({
1843
+ fieldId: field.id,
1844
+ internalType: field.internalType ?? 'datetime',
1845
+ fieldLabel: field.label,
1846
+ operator: item.id,
1847
+ operatorLabel: item.label,
1848
+ value: item.id.slice(DATE_PREFIX_LEN),
1849
+ conditionLabel: `${field.label} ${item.label}`
1850
+ });
1851
+ }
1852
+ else if (item.id === 'eq_true' || item.id === 'eq_false') {
1853
+ const val = item.id === 'eq_true' ? 'true' : 'false';
1854
+ this.ctrl.commitCondition({
1855
+ fieldId: field.id,
1856
+ internalType: field.internalType ?? 'boolean',
1857
+ fieldLabel: field.label,
1858
+ operator: 'eq',
1859
+ operatorLabel: '=',
1860
+ value: val,
1861
+ conditionLabel: `${field.label} = ${val}`
1862
+ });
1863
+ }
1864
+ else if (item.id === 'empty' || item.id === 'not_empty') {
1865
+ this.ctrl.commitCondition(this.ctrl.buildCommitCondition(field, item.id, item.label, ''));
1866
+ }
1867
+ else {
1868
+ this.ctrl.pendingField.set({ ...field, operator: item.id });
1869
+ this.ctrl.pendingOperatorLabel.set(item.label);
1870
+ this.ctrl.operatorCtrl.setValue(item.label, { emitEvent: false });
1871
+ this.ctrl.inputTerm.set('');
1872
+ this.ctrl.step.set('value');
1873
+ this._suppressNextBlur = true;
1874
+ setTimeout(() => {
1875
+ this.#focusInput();
1876
+ this._suppressNextBlur = false;
1877
+ });
1878
+ }
1879
+ }
1880
+ #commitFromBlur() {
1881
+ const field = this.ctrl.pendingField();
1882
+ if (!field)
1883
+ return;
1884
+ const operator = field.operator ?? '';
1885
+ if (!operator)
1886
+ return;
1887
+ const raw = this.ctrl.valueCtrl.value;
1888
+ this.ctrl.commitCondition(this.ctrl.buildCommitCondition(field, operator, this.ctrl.operatorLabel(operator), normalizeConditionValue(raw)));
1889
+ }
1890
+ #focusInput() {
1891
+ this.#host.nativeElement.querySelector('.inline-input input')?.focus();
1892
+ }
1893
+ /** Focus the "Add condition" (+) button of a block — used after confirming its types. */
1894
+ #focusAddCondition(blockId) {
1895
+ this.#host.nativeElement.querySelector(`.block[data-block-id="${blockId}"] .add-condition-btn`)?.focus();
1896
+ }
1897
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: SmartSearchComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1898
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: SmartSearchComponent, isStandalone: true, selector: "yuv-smart-search", inputs: { types: { classPropertyName: "types", publicName: "types", isSignal: true, isRequired: false, transformFunction: null }, skipProperties: { classPropertyName: "skipProperties", publicName: "skipProperties", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { queryChange: "queryChange" }, providers: [SmartSearchEditController], viewQueries: [{ propertyName: "auto", first: true, predicate: ["auto"], descendants: true, isSignal: true }, { propertyName: "trigger", first: true, predicate: MatAutocompleteTrigger, descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"smart-search\" halo-container halo-container-skip=\"true\">\n <!-- Condition chip template \u2014 passed down into the recursive group component. -->\n <ng-template #chipTpl let-condition let-container=\"container\" let-i=\"index\">\n <div\n class=\"condition-chip\"\n tabindex=\"0\"\n role=\"button\"\n [attr.aria-label]=\"'yuv.smart-search.condition.edit-aria' | translate: { label: condition.conditionLabel }\"\n (click)=\"editCondition(container, condition, i)\"\n (keydown.space)=\"$event.preventDefault(); editCondition(container, condition, i)\"\n (keydown.enter)=\"$event.preventDefault(); editCondition(container, condition, i)\"\n >\n <span class=\"condition-chip__part condition-chip__field\">\n <!-- <mat-icon>tune</mat-icon> -->\n {{ condition.fieldLabel }}\n </span>\n <span class=\"condition-chip__part condition-chip__op\">{{ condition.operatorLabel }}</span>\n <span class=\"condition-chip__part condition-chip__value\">\n @if (valueRendererInput(condition); as rendererInput) {\n <ng-container *yuvRenderer=\"rendererInput\" />\n } @else {\n {{ condition.value }}\n }\n </span>\n <button\n ymtIconButton\n icon-button-size=\"extra-small\"\n class=\"condition-chip__remove\"\n tabindex=\"-1\"\n [attr.aria-label]=\"'yuv.smart-search.condition.remove' | translate\"\n (click)=\"$event.stopPropagation(); removeCondition(container, condition)\"\n >\n <mat-icon>close</mat-icon>\n </button>\n </div>\n </ng-template>\n\n <!-- Inline editor template \u2014 rendered inside whichever container is active. -->\n <ng-template #editorTpl>\n <div class=\"inline-input\" tabindex=\"-1\" (focusout)=\"onInlineBlur($event)\" (keydown.escape)=\"cancelPending()\">\n @if (ctrl.step() !== 'field' && ctrl.pendingField()) {\n <button\n class=\"part-pill\"\n [class.part-pill--active]=\"ctrl.step() === 'field'\"\n (click)=\"editField()\"\n [matTooltip]=\"'yuv.smart-search.field.change' | translate\"\n >\n {{ ctrl.pendingField()?.label }}\n </button>\n }\n\n @if (ctrl.step() === 'value' && ctrl.operatorCtrl.value) {\n <button\n class=\"part-pill\"\n [class.part-pill--active]=\"ctrl.step() === 'operator'\"\n (click)=\"editOperator()\"\n [matTooltip]=\"'yuv.smart-search.operator.change' | translate\"\n >\n {{ ctrl.operatorCtrl.value }}\n </button>\n }\n\n @if (ctrl.step() === 'field') {\n <input\n [formControl]=\"ctrl.fieldCtrl\"\n [matAutocomplete]=\"auto\"\n [placeholder]=\"'yuv.smart-search.field.pick' | translate\"\n (keydown.enter)=\"onEnter()\"\n />\n } @else if (ctrl.step() === 'operator') {\n <input\n [formControl]=\"ctrl.operatorCtrl\"\n [matAutocomplete]=\"auto\"\n [placeholder]=\"'yuv.smart-search.operator.select' | translate\"\n (keydown.enter)=\"onEnter()\"\n />\n } @else if (ctrl.step() === 'value') {\n @let otf = valueFieldDef();\n @if (otf) {\n <yuv-metadata-form-field variant=\"raw\" [field]=\"otf\" situation=\"EDIT\" [formControl]=\"ctrl.valueCtrl\" />\n } @else {\n <input\n [formControl]=\"ctrl.valueCtrl\"\n [matAutocomplete]=\"auto\"\n [placeholder]=\"'yuv.smart-search.value.enter' | translate\"\n (keydown.enter)=\"onEnter()\"\n />\n }\n }\n\n <button\n ymtIconButton\n icon-button-size=\"extra-small\"\n class=\"confirm-btn\"\n [disabled]=\"!ctrl.isConditionComplete()\"\n (click)=\"onEnter()\"\n [matTooltip]=\"'yuv.smart-search.confirm' | translate\"\n >\n <mat-icon>check</mat-icon>\n </button>\n <button\n ymtIconButton\n icon-button-size=\"extra-small\"\n class=\"cancel-btn\"\n (click)=\"cancelPending()\"\n [matTooltip]=\"'yuv.smart-search.cancel' | translate\"\n >\n <mat-icon>close</mat-icon>\n </button>\n </div>\n </ng-template>\n\n <!-- \u2500\u2500 Full-text search bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"fulltext\" [class.muted]=\"!fulltext().term.trim() && ctrl.blocks().length\">\n <!-- Row 1: full-width term -->\n <div class=\"fulltext__term\">\n <mat-icon class=\"fulltext__icon\">search</mat-icon>\n <input\n class=\"fulltext__input\"\n [formControl]=\"fulltextTermCtrl\"\n [placeholder]=\"'yuv.smart-search.fulltext.placeholder' | translate\"\n />\n </div>\n\n <!-- Row 2: scope (single) + types (multiple) -->\n <div class=\"fulltext__filters\">\n <mat-select\n class=\"fulltext__scope\"\n [panelWidth]=\"null\"\n panelClass=\"smart-search-select-panel\"\n [value]=\"fulltext().scope\"\n (selectionChange)=\"setFulltextScope($event.value)\"\n [aria-label]=\"'yuv.smart-search.fulltext.scope.label' | translate\"\n >\n <mat-option value=\"all\">{{ 'yuv.smart-search.fulltext.scope.all' | translate }}</mat-option>\n <mat-option value=\"metadata\">{{ 'yuv.smart-search.fulltext.scope.metadata' | translate }}</mat-option>\n <mat-option value=\"content\">{{ 'yuv.smart-search.fulltext.scope.content' | translate }}</mat-option>\n </mat-select>\n\n <mat-select\n class=\"fulltext__types\"\n multiple\n [panelWidth]=\"null\"\n panelClass=\"smart-search-select-panel\"\n [value]=\"fulltextTypeSelection()\"\n (selectionChange)=\"onFulltextTypesChange($event.value)\"\n [aria-label]=\"'yuv.smart-search.fulltext.types.label' | translate\"\n >\n <mat-option [value]=\"ALL_TYPES\">{{ 'yuv.smart-search.fulltext.types.all' | translate }}</mat-option>\n @for (t of objectTypes(); track t.id) {\n <mat-option [value]=\"t.id\">{{ t.label ?? t.id }}</mat-option>\n }\n </mat-select>\n </div>\n </div>\n\n <!-- Joiner between the full-text unit and the condition blocks. Top-level units\n always combine with OR (\"looking for this as well as that\"), so this is a\n static label rather than a toggle. -->\n @if (fulltext().term.trim() && ctrl.blocks().length) {\n <div class=\"combinator\">\n <span class=\"combinator__label\">{{ 'yuv.smart-search.combinator.as-well-as' | translate }}</span>\n </div>\n }\n\n <!-- \u2500\u2500 Step 1: Type blocks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n @for (block of ctrl.blocks(); track block.id) {\n <div class=\"block\" [attr.data-block-id]=\"block.id\" [class.block--active]=\"ctrl.activeBlock() === block\">\n <div class=\"block__header\">\n <div class=\"block__types\">\n @for (t of block.types; track t.id) {\n @if (!$first) {\n <span class=\"block__type-sep\">{{ 'yuv.smart-search.combinator.or' | translate }}</span>\n }\n <span class=\"block__type\">\n <span class=\"block__type-label\">{{ t.label }}</span>\n </span>\n }\n </div>\n <button\n ymtIconButton\n icon-button-size=\"extra-small\"\n class=\"block__remove\"\n (click)=\"removeBlock(block)\"\n [matTooltip]=\"'yuv.smart-search.type.remove' | translate\"\n >\n <mat-icon>close</mat-icon>\n </button>\n </div>\n\n <div class=\"block__conditions\">\n <yuv-smart-search-group [group]=\"block\" [bare]=\"true\" [chipTpl]=\"chipTpl\" [editorTpl]=\"editorTpl\" />\n </div>\n </div>\n\n <!-- Static joiner between blocks \u2014 blocks are type-scoped, so they always\n combine with OR (\"this type as well as that type\"). -->\n @if (!$last && ctrl.showCombinator()) {\n <div class=\"combinator\">\n <span class=\"combinator__label\">{{ 'yuv.smart-search.combinator.as-well-as' | translate }}</span>\n </div>\n }\n }\n\n <!-- \u2500\u2500 Step 1 input: add a type block (multi-select) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n @if (ctrl.step() === 'type') {\n <div class=\"add-type-row\">\n <button\n type=\"button\"\n ymtIconButton\n icon-button-size=\"extra-small\"\n class=\"add-type-row__icon\"\n (click)=\"typeInput.focus()\"\n [attr.aria-label]=\"'yuv.smart-search.add-type' | translate\"\n >\n <mat-icon>add_circle_outline</mat-icon>\n </button>\n\n <!-- Staged draft types as removable Material chips -->\n <mat-chip-grid\n #typeChipGrid\n class=\"add-type-row__chips\"\n [attr.aria-label]=\"'yuv.smart-search.add-type' | translate\"\n >\n @for (t of ctrl.draftTypes(); track t.id) {\n <mat-chip-row (removed)=\"ctrl.removeDraftType(t.id)\">\n {{ t.label }}\n <button matChipRemove [attr.aria-label]=\"'yuv.smart-search.type.remove-draft' | translate\">\n <mat-icon>close</mat-icon>\n </button>\n </mat-chip-row>\n }\n <input\n #typeInput\n [formControl]=\"ctrl.fieldCtrl\"\n [placeholder]=\"'yuv.smart-search.add-type' | translate\"\n [matAutocomplete]=\"auto\"\n [matChipInputFor]=\"typeChipGrid\"\n (keydown.enter)=\"onTypeEnter()\"\n (keydown.escape)=\"cancelPending()\"\n />\n </mat-chip-grid>\n\n @if (ctrl.draftTypes().length) {\n <button\n ymtIconButton\n icon-button-size=\"extra-small\"\n class=\"add-type-row__confirm\"\n (click)=\"confirmTypes()\"\n [matTooltip]=\"'yuv.smart-search.type.confirm' | translate\"\n >\n <mat-icon>check</mat-icon>\n </button>\n }\n </div>\n }\n\n <!-- \u2500\u2500 Shared autocomplete panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <mat-autocomplete\n #auto\n panelWidth=\"auto\"\n [displayWith]=\"displayFn\"\n (optionSelected)=\"onSuggestionSelected($event)\"\n (closed)=\"onPickerClosed()\"\n >\n @for (s of ctrl.suggestions(); track s.id) {\n <mat-option [value]=\"s\">\n <div class=\"suggestion\">\n @if (s.kind !== 'type') {\n <mat-icon class=\"suggestion__icon\">\n @switch (s.kind) {\n @case ('field') {\n tune\n }\n @case ('date-preset') {\n calendar_today\n }\n @default {\n manage_search\n }\n }\n </mat-icon>\n }\n <span class=\"suggestion__label\">{{ s.label }}</span>\n </div>\n </mat-option>\n }\n </mat-autocomplete>\n</div>\n", styles: [":host{display:block;--outline: rgb(from var(--ymt-text-color) r g b / .5);--focus-visible-border-color: var(--ymt-primary);--focus-visible-border-shadow-color: rgb(from var(--ymt-primary) r g b / .3)}::ng-deep .smart-search-select-panel.mat-mdc-select-panel{min-width:max-content}::ng-deep .smart-search-select-panel.mat-mdc-select-panel .mat-mdc-option .mdc-list-item__primary-text{white-space:nowrap}.smart-search{display:flex;flex-direction:column;gap:var(--ymt-spacing-xs)}.fulltext{display:flex;flex-direction:column;gap:var(--ymt-spacing-xs);padding:var(--ymt-spacing-xs) var(--ymt-spacing-s);border:1px solid var(--outline);border-radius:var(--ymt-corner-s);transition:opacity .15s}.fulltext.muted{opacity:.7}.fulltext.muted:hover,.fulltext.muted:focus-within{opacity:1}.fulltext__term{display:flex;align-items:center;gap:var(--ymt-spacing-xs)}.fulltext__icon{color:var(--ymt-text-color-subtle)}.fulltext__input{flex:1;min-width:120px;border:none;outline:none;background:transparent;font:inherit;color:var(--ymt-text-color)}.fulltext__input::placeholder{color:var(--ymt-text-color-subtle);opacity:.7}.fulltext__filters{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:var(--ymt-spacing-s)}.fulltext__scope,.fulltext__types{width:auto;font-size:.9em;color:var(--ymt-text-color-subtle);border:1px solid var(--ymt-outline);border-radius:var(--ymt-corner-xs);padding-inline:var(--ymt-spacing-xs)}.fulltext__scope:focus-visible,.fulltext__types:focus-visible{border-color:var(--focus-visible-border-color);box-shadow:0 0 0 2px var(--focus-visible-border-shadow-color)}.block{border:1px solid var(--outline);border-radius:var(--ymt-corner-s);overflow:hidden;transition:border-color .15s;padding:var(--ymt-spacing-2xs)}.block:focus-within{border-color:var(--focus-visible-border-color);box-shadow:0 0 0 3px var(--focus-visible-border-shadow-color)}.block__header{border-radius:var(--ymt-corner-xs);display:flex;flex-wrap:wrap;align-items:center;gap:var(--ymt-spacing-xs);padding:var(--ymt-spacing-xs) var(--ymt-spacing-s);background:var(--ymt-surface-container-high)}.block__types{flex:1;display:flex;align-items:center;flex-wrap:wrap}.block__type{display:inline-flex;align-items:center;gap:var(--ymt-spacing-2xs)}.block__type-sep{font:var(--ymt-font-body-subtle);text-transform:lowercase;color:var(--ymt-text-color-subtle);padding:0 var(--ymt-spacing-xs)}.block__remove{margin-inline-start:auto;opacity:.7}.block__conditions{display:flex;flex-wrap:wrap;align-items:center;row-gap:var(--ymt-spacing-2xs);column-gap:var(--ymt-spacing-xs);padding:var(--ymt-spacing-xs) var(--ymt-spacing-s);min-height:44px}.condition-chip{display:inline-flex;align-items:center;gap:0;border-radius:var(--ymt-corner-xs);border:1px solid var(--outline);cursor:pointer;overflow:hidden;outline:none;padding:var(--ymt-spacing-4xs) var(--ymt-spacing-3xs);gap:var(--ymt-spacing-4xs)}.condition-chip:hover{border-color:var(--ymt-outline)}.condition-chip:focus-visible{border-color:var(--focus-visible-border-color);box-shadow:0 0 0 2px var(--focus-visible-border-shadow-color)}.condition-chip__part{display:inline-flex;align-items:center;gap:var(--ymt-spacing-4xs);padding:0 var(--ymt-spacing-xs);height:100%;white-space:nowrap;color:var(--ymt-text-color)}.condition-chip__part:focus-visible{border-color:var(--focus-visible-border-color);box-shadow:0 0 0 2px var(--focus-visible-border-shadow-color)}.condition-chip__op{font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle);padding:0}.condition-chip__value{max-width:120px;overflow:hidden;text-overflow:ellipsis;--tile-slot-padding: 0;--yuv-renderer-display: inline-flex}.condition-chip__remove{color:var(--ymt-text-color-subtle)}.condition-chip__remove:hover{color:var(--ymt-text-color)}.inline-input{display:flex;align-items:center;gap:var(--ymt-spacing-3xs);flex:1;border:1px dashed var(--outline);border-radius:var(--ymt-corner-xs);padding:var(--ymt-spacing-4xs) var(--ymt-spacing-2xs);background:var(--ymt-surface)}.inline-input .part-pill{display:inline-flex;align-items:center;gap:var(--ymt-spacing-4xs);white-space:nowrap;font:inherit;font-size:var(--ymt-font-body-subtle-size);color:var(--ymt-text-color);background:var(--ymt-surface-container);border:1px solid transparent;border-radius:var(--ymt-corner-xs);padding:var(--ymt-spacing-4xs) var(--ymt-spacing-xs);cursor:pointer;transition:border-color .1s,background .1s}.inline-input .part-pill:hover{background:var(--ymt-surface-container-high)}.inline-input .part-pill:focus-visible{border-color:var(--focus-visible-border-color);box-shadow:0 0 0 2px var(--focus-visible-border-shadow-color)}.inline-input .part-pill--active{border-color:var(--ymt-primary);box-shadow:0 0 0 2px var(--focus-visible-border-shadow-color)}.inline-input .part-pill mat-icon{font-size:var(--ymt-sizing-xs);width:var(--ymt-sizing-xs);height:var(--ymt-sizing-xs)}.inline-input yuv-metadata-form-field{flex:1;min-width:150px}.inline-input input{flex:1;border:none;outline:none;background:transparent}.inline-input .confirm-btn:not(:disabled){opacity:1;color:var(--ymt-color-accent)}.combinator{display:flex;justify-content:center;gap:0}.combinator__label{font:var(--ymt-font-body-subtle);text-transform:lowercase;color:var(--ymt-text-color-subtle);padding:var(--ymt-spacing-2xs) var(--ymt-spacing-xs)}.combinator__btn{--bg: transparent;--fg: var(--ymt-text-color);--mdc-shape-small: var(--ymt-corner-xs);background-color:var(--bg);color:var(--fg);padding:var(--ymt-spacing-2xs) var(--ymt-spacing-xs);border:0;outline:1px solid var(--ymt-inverse-surface);outline-offset:-1px}.combinator__btn:first-child{border-radius:var(--mdc-shape-small) 0 0 var(--mdc-shape-small)}.combinator__btn:last-child{border-radius:0 var(--mdc-shape-small) var(--mdc-shape-small) 0}.combinator__btn--active{--bg: var(--ymt-inverse-surface);--fg: var(--ymt-on-inverse-surface)}.add-type-row{display:flex;flex-wrap:wrap;align-items:center;gap:var(--ymt-spacing-xs);padding:var(--ymt-spacing-xs) var(--ymt-spacing-s);border:1px dashed var(--ymt-outline);border-radius:var(--ymt-corner-s);color:var(--ymt-text-color-subtle)}.add-type-row__icon{display:inline-flex;align-items:center;padding:0;border:0;background:transparent;color:inherit;cursor:pointer}.add-type-row__icon mat-icon{font-size:var(--ymt-sizing-m);width:var(--ymt-sizing-m);height:var(--ymt-sizing-m);opacity:.5}.add-type-row__icon:hover mat-icon{opacity:.8}.add-type-row__confirm{color:var(--ymt-primary)}.add-type-row__chips{flex:1;min-width:120px}.add-type-row input{flex:1;min-width:120px;border:none;outline:none;background:transparent;font:inherit;font-size:.9em;color:inherit}.add-type-row input::placeholder{color:var(--ymt-text-color-subtle);opacity:.7}.condition-combinator{display:flex;align-self:center}.suggestion{display:flex;align-items:center;gap:var(--ymt-spacing-xs)}.suggestion__icon{font-size:var(--ymt-sizing-s);width:var(--ymt-sizing-s);height:var(--ymt-sizing-s);--icon-size: var(--ymt-sizing-s);opacity:.6}.suggestion__label{flex:1}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatAutocompleteModule }, { kind: "component", type: i2$1.MatAutocomplete, selector: "mat-autocomplete", inputs: ["aria-label", "aria-labelledby", "displayWith", "autoActiveFirstOption", "autoSelectActiveOption", "requireSelection", "panelWidth", "disableRipple", "class", "hideSingleSelectionIndicator"], outputs: ["optionSelected", "opened", "closed", "optionActivated"], exportAs: ["matAutocomplete"] }, { kind: "component", type: i2$1.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "directive", type: i2$1.MatAutocompleteTrigger, selector: "input[matAutocomplete], textarea[matAutocomplete]", inputs: ["matAutocomplete", "matAutocompletePosition", "matAutocompleteConnectedTo", "autocomplete", "matAutocompleteDisabled"], exportAs: ["matAutocompleteTrigger"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "ngmodule", type: MatChipsModule }, { kind: "component", type: i4.MatChipGrid, selector: "mat-chip-grid", inputs: ["disabled", "placeholder", "required", "value", "errorStateMatcher"], outputs: ["change", "valueChange"] }, { kind: "directive", type: i4.MatChipInput, selector: "input[matChipInputFor]", inputs: ["matChipInputFor", "matChipInputAddOnBlur", "matChipInputSeparatorKeyCodes", "placeholder", "id", "disabled", "readonly", "matChipInputDisabledInteractive"], outputs: ["matChipInputTokenEnd"], exportAs: ["matChipInput", "matChipInputFor"] }, { kind: "directive", type: i4.MatChipRemove, selector: "[matChipRemove]" }, { kind: "component", type: i4.MatChipRow, selector: "mat-chip-row, [mat-chip-row], mat-basic-chip-row, [mat-basic-chip-row]", inputs: ["editable"], outputs: ["edited"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i5.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "directive", type: YmtIconButtonDirective, selector: "button[ymtIconButton],button[ymt-icon-button],a[ymtIconButton],a[ymt-icon-button]", inputs: ["disabled", "disableRipple", "aria-disabled", "disabledInteractive", "icon-button-size"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: MetadataFormFieldComponent, selector: "yuv-metadata-form-field", inputs: ["formChangedSubject", "field", "variant", "situation"] }, { kind: "directive", type: RendererDirective, selector: "[yuvRenderer]", inputs: ["yuvRenderer"] }, { kind: "component", type: SmartSearchGroupComponent, selector: "yuv-smart-search-group", inputs: ["group", "bare", "chipTpl", "editorTpl"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1899
+ }
1900
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: SmartSearchComponent, decorators: [{
1901
+ type: Component,
1902
+ args: [{ selector: 'yuv-smart-search', imports: [
1903
+ ReactiveFormsModule,
1904
+ MatAutocompleteModule,
1905
+ MatInputModule,
1906
+ MatIconModule,
1907
+ MatButtonModule,
1908
+ MatChipsModule,
1909
+ MatSelectModule,
1910
+ YmtIconButtonDirective,
1911
+ MatTooltipModule,
1912
+ MetadataFormFieldComponent,
1913
+ RendererDirective,
1914
+ SmartSearchGroupComponent,
1915
+ TranslatePipe
1916
+ ], providers: [SmartSearchEditController], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"smart-search\" halo-container halo-container-skip=\"true\">\n <!-- Condition chip template \u2014 passed down into the recursive group component. -->\n <ng-template #chipTpl let-condition let-container=\"container\" let-i=\"index\">\n <div\n class=\"condition-chip\"\n tabindex=\"0\"\n role=\"button\"\n [attr.aria-label]=\"'yuv.smart-search.condition.edit-aria' | translate: { label: condition.conditionLabel }\"\n (click)=\"editCondition(container, condition, i)\"\n (keydown.space)=\"$event.preventDefault(); editCondition(container, condition, i)\"\n (keydown.enter)=\"$event.preventDefault(); editCondition(container, condition, i)\"\n >\n <span class=\"condition-chip__part condition-chip__field\">\n <!-- <mat-icon>tune</mat-icon> -->\n {{ condition.fieldLabel }}\n </span>\n <span class=\"condition-chip__part condition-chip__op\">{{ condition.operatorLabel }}</span>\n <span class=\"condition-chip__part condition-chip__value\">\n @if (valueRendererInput(condition); as rendererInput) {\n <ng-container *yuvRenderer=\"rendererInput\" />\n } @else {\n {{ condition.value }}\n }\n </span>\n <button\n ymtIconButton\n icon-button-size=\"extra-small\"\n class=\"condition-chip__remove\"\n tabindex=\"-1\"\n [attr.aria-label]=\"'yuv.smart-search.condition.remove' | translate\"\n (click)=\"$event.stopPropagation(); removeCondition(container, condition)\"\n >\n <mat-icon>close</mat-icon>\n </button>\n </div>\n </ng-template>\n\n <!-- Inline editor template \u2014 rendered inside whichever container is active. -->\n <ng-template #editorTpl>\n <div class=\"inline-input\" tabindex=\"-1\" (focusout)=\"onInlineBlur($event)\" (keydown.escape)=\"cancelPending()\">\n @if (ctrl.step() !== 'field' && ctrl.pendingField()) {\n <button\n class=\"part-pill\"\n [class.part-pill--active]=\"ctrl.step() === 'field'\"\n (click)=\"editField()\"\n [matTooltip]=\"'yuv.smart-search.field.change' | translate\"\n >\n {{ ctrl.pendingField()?.label }}\n </button>\n }\n\n @if (ctrl.step() === 'value' && ctrl.operatorCtrl.value) {\n <button\n class=\"part-pill\"\n [class.part-pill--active]=\"ctrl.step() === 'operator'\"\n (click)=\"editOperator()\"\n [matTooltip]=\"'yuv.smart-search.operator.change' | translate\"\n >\n {{ ctrl.operatorCtrl.value }}\n </button>\n }\n\n @if (ctrl.step() === 'field') {\n <input\n [formControl]=\"ctrl.fieldCtrl\"\n [matAutocomplete]=\"auto\"\n [placeholder]=\"'yuv.smart-search.field.pick' | translate\"\n (keydown.enter)=\"onEnter()\"\n />\n } @else if (ctrl.step() === 'operator') {\n <input\n [formControl]=\"ctrl.operatorCtrl\"\n [matAutocomplete]=\"auto\"\n [placeholder]=\"'yuv.smart-search.operator.select' | translate\"\n (keydown.enter)=\"onEnter()\"\n />\n } @else if (ctrl.step() === 'value') {\n @let otf = valueFieldDef();\n @if (otf) {\n <yuv-metadata-form-field variant=\"raw\" [field]=\"otf\" situation=\"EDIT\" [formControl]=\"ctrl.valueCtrl\" />\n } @else {\n <input\n [formControl]=\"ctrl.valueCtrl\"\n [matAutocomplete]=\"auto\"\n [placeholder]=\"'yuv.smart-search.value.enter' | translate\"\n (keydown.enter)=\"onEnter()\"\n />\n }\n }\n\n <button\n ymtIconButton\n icon-button-size=\"extra-small\"\n class=\"confirm-btn\"\n [disabled]=\"!ctrl.isConditionComplete()\"\n (click)=\"onEnter()\"\n [matTooltip]=\"'yuv.smart-search.confirm' | translate\"\n >\n <mat-icon>check</mat-icon>\n </button>\n <button\n ymtIconButton\n icon-button-size=\"extra-small\"\n class=\"cancel-btn\"\n (click)=\"cancelPending()\"\n [matTooltip]=\"'yuv.smart-search.cancel' | translate\"\n >\n <mat-icon>close</mat-icon>\n </button>\n </div>\n </ng-template>\n\n <!-- \u2500\u2500 Full-text search bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"fulltext\" [class.muted]=\"!fulltext().term.trim() && ctrl.blocks().length\">\n <!-- Row 1: full-width term -->\n <div class=\"fulltext__term\">\n <mat-icon class=\"fulltext__icon\">search</mat-icon>\n <input\n class=\"fulltext__input\"\n [formControl]=\"fulltextTermCtrl\"\n [placeholder]=\"'yuv.smart-search.fulltext.placeholder' | translate\"\n />\n </div>\n\n <!-- Row 2: scope (single) + types (multiple) -->\n <div class=\"fulltext__filters\">\n <mat-select\n class=\"fulltext__scope\"\n [panelWidth]=\"null\"\n panelClass=\"smart-search-select-panel\"\n [value]=\"fulltext().scope\"\n (selectionChange)=\"setFulltextScope($event.value)\"\n [aria-label]=\"'yuv.smart-search.fulltext.scope.label' | translate\"\n >\n <mat-option value=\"all\">{{ 'yuv.smart-search.fulltext.scope.all' | translate }}</mat-option>\n <mat-option value=\"metadata\">{{ 'yuv.smart-search.fulltext.scope.metadata' | translate }}</mat-option>\n <mat-option value=\"content\">{{ 'yuv.smart-search.fulltext.scope.content' | translate }}</mat-option>\n </mat-select>\n\n <mat-select\n class=\"fulltext__types\"\n multiple\n [panelWidth]=\"null\"\n panelClass=\"smart-search-select-panel\"\n [value]=\"fulltextTypeSelection()\"\n (selectionChange)=\"onFulltextTypesChange($event.value)\"\n [aria-label]=\"'yuv.smart-search.fulltext.types.label' | translate\"\n >\n <mat-option [value]=\"ALL_TYPES\">{{ 'yuv.smart-search.fulltext.types.all' | translate }}</mat-option>\n @for (t of objectTypes(); track t.id) {\n <mat-option [value]=\"t.id\">{{ t.label ?? t.id }}</mat-option>\n }\n </mat-select>\n </div>\n </div>\n\n <!-- Joiner between the full-text unit and the condition blocks. Top-level units\n always combine with OR (\"looking for this as well as that\"), so this is a\n static label rather than a toggle. -->\n @if (fulltext().term.trim() && ctrl.blocks().length) {\n <div class=\"combinator\">\n <span class=\"combinator__label\">{{ 'yuv.smart-search.combinator.as-well-as' | translate }}</span>\n </div>\n }\n\n <!-- \u2500\u2500 Step 1: Type blocks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n @for (block of ctrl.blocks(); track block.id) {\n <div class=\"block\" [attr.data-block-id]=\"block.id\" [class.block--active]=\"ctrl.activeBlock() === block\">\n <div class=\"block__header\">\n <div class=\"block__types\">\n @for (t of block.types; track t.id) {\n @if (!$first) {\n <span class=\"block__type-sep\">{{ 'yuv.smart-search.combinator.or' | translate }}</span>\n }\n <span class=\"block__type\">\n <span class=\"block__type-label\">{{ t.label }}</span>\n </span>\n }\n </div>\n <button\n ymtIconButton\n icon-button-size=\"extra-small\"\n class=\"block__remove\"\n (click)=\"removeBlock(block)\"\n [matTooltip]=\"'yuv.smart-search.type.remove' | translate\"\n >\n <mat-icon>close</mat-icon>\n </button>\n </div>\n\n <div class=\"block__conditions\">\n <yuv-smart-search-group [group]=\"block\" [bare]=\"true\" [chipTpl]=\"chipTpl\" [editorTpl]=\"editorTpl\" />\n </div>\n </div>\n\n <!-- Static joiner between blocks \u2014 blocks are type-scoped, so they always\n combine with OR (\"this type as well as that type\"). -->\n @if (!$last && ctrl.showCombinator()) {\n <div class=\"combinator\">\n <span class=\"combinator__label\">{{ 'yuv.smart-search.combinator.as-well-as' | translate }}</span>\n </div>\n }\n }\n\n <!-- \u2500\u2500 Step 1 input: add a type block (multi-select) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n @if (ctrl.step() === 'type') {\n <div class=\"add-type-row\">\n <button\n type=\"button\"\n ymtIconButton\n icon-button-size=\"extra-small\"\n class=\"add-type-row__icon\"\n (click)=\"typeInput.focus()\"\n [attr.aria-label]=\"'yuv.smart-search.add-type' | translate\"\n >\n <mat-icon>add_circle_outline</mat-icon>\n </button>\n\n <!-- Staged draft types as removable Material chips -->\n <mat-chip-grid\n #typeChipGrid\n class=\"add-type-row__chips\"\n [attr.aria-label]=\"'yuv.smart-search.add-type' | translate\"\n >\n @for (t of ctrl.draftTypes(); track t.id) {\n <mat-chip-row (removed)=\"ctrl.removeDraftType(t.id)\">\n {{ t.label }}\n <button matChipRemove [attr.aria-label]=\"'yuv.smart-search.type.remove-draft' | translate\">\n <mat-icon>close</mat-icon>\n </button>\n </mat-chip-row>\n }\n <input\n #typeInput\n [formControl]=\"ctrl.fieldCtrl\"\n [placeholder]=\"'yuv.smart-search.add-type' | translate\"\n [matAutocomplete]=\"auto\"\n [matChipInputFor]=\"typeChipGrid\"\n (keydown.enter)=\"onTypeEnter()\"\n (keydown.escape)=\"cancelPending()\"\n />\n </mat-chip-grid>\n\n @if (ctrl.draftTypes().length) {\n <button\n ymtIconButton\n icon-button-size=\"extra-small\"\n class=\"add-type-row__confirm\"\n (click)=\"confirmTypes()\"\n [matTooltip]=\"'yuv.smart-search.type.confirm' | translate\"\n >\n <mat-icon>check</mat-icon>\n </button>\n }\n </div>\n }\n\n <!-- \u2500\u2500 Shared autocomplete panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <mat-autocomplete\n #auto\n panelWidth=\"auto\"\n [displayWith]=\"displayFn\"\n (optionSelected)=\"onSuggestionSelected($event)\"\n (closed)=\"onPickerClosed()\"\n >\n @for (s of ctrl.suggestions(); track s.id) {\n <mat-option [value]=\"s\">\n <div class=\"suggestion\">\n @if (s.kind !== 'type') {\n <mat-icon class=\"suggestion__icon\">\n @switch (s.kind) {\n @case ('field') {\n tune\n }\n @case ('date-preset') {\n calendar_today\n }\n @default {\n manage_search\n }\n }\n </mat-icon>\n }\n <span class=\"suggestion__label\">{{ s.label }}</span>\n </div>\n </mat-option>\n }\n </mat-autocomplete>\n</div>\n", styles: [":host{display:block;--outline: rgb(from var(--ymt-text-color) r g b / .5);--focus-visible-border-color: var(--ymt-primary);--focus-visible-border-shadow-color: rgb(from var(--ymt-primary) r g b / .3)}::ng-deep .smart-search-select-panel.mat-mdc-select-panel{min-width:max-content}::ng-deep .smart-search-select-panel.mat-mdc-select-panel .mat-mdc-option .mdc-list-item__primary-text{white-space:nowrap}.smart-search{display:flex;flex-direction:column;gap:var(--ymt-spacing-xs)}.fulltext{display:flex;flex-direction:column;gap:var(--ymt-spacing-xs);padding:var(--ymt-spacing-xs) var(--ymt-spacing-s);border:1px solid var(--outline);border-radius:var(--ymt-corner-s);transition:opacity .15s}.fulltext.muted{opacity:.7}.fulltext.muted:hover,.fulltext.muted:focus-within{opacity:1}.fulltext__term{display:flex;align-items:center;gap:var(--ymt-spacing-xs)}.fulltext__icon{color:var(--ymt-text-color-subtle)}.fulltext__input{flex:1;min-width:120px;border:none;outline:none;background:transparent;font:inherit;color:var(--ymt-text-color)}.fulltext__input::placeholder{color:var(--ymt-text-color-subtle);opacity:.7}.fulltext__filters{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:var(--ymt-spacing-s)}.fulltext__scope,.fulltext__types{width:auto;font-size:.9em;color:var(--ymt-text-color-subtle);border:1px solid var(--ymt-outline);border-radius:var(--ymt-corner-xs);padding-inline:var(--ymt-spacing-xs)}.fulltext__scope:focus-visible,.fulltext__types:focus-visible{border-color:var(--focus-visible-border-color);box-shadow:0 0 0 2px var(--focus-visible-border-shadow-color)}.block{border:1px solid var(--outline);border-radius:var(--ymt-corner-s);overflow:hidden;transition:border-color .15s;padding:var(--ymt-spacing-2xs)}.block:focus-within{border-color:var(--focus-visible-border-color);box-shadow:0 0 0 3px var(--focus-visible-border-shadow-color)}.block__header{border-radius:var(--ymt-corner-xs);display:flex;flex-wrap:wrap;align-items:center;gap:var(--ymt-spacing-xs);padding:var(--ymt-spacing-xs) var(--ymt-spacing-s);background:var(--ymt-surface-container-high)}.block__types{flex:1;display:flex;align-items:center;flex-wrap:wrap}.block__type{display:inline-flex;align-items:center;gap:var(--ymt-spacing-2xs)}.block__type-sep{font:var(--ymt-font-body-subtle);text-transform:lowercase;color:var(--ymt-text-color-subtle);padding:0 var(--ymt-spacing-xs)}.block__remove{margin-inline-start:auto;opacity:.7}.block__conditions{display:flex;flex-wrap:wrap;align-items:center;row-gap:var(--ymt-spacing-2xs);column-gap:var(--ymt-spacing-xs);padding:var(--ymt-spacing-xs) var(--ymt-spacing-s);min-height:44px}.condition-chip{display:inline-flex;align-items:center;gap:0;border-radius:var(--ymt-corner-xs);border:1px solid var(--outline);cursor:pointer;overflow:hidden;outline:none;padding:var(--ymt-spacing-4xs) var(--ymt-spacing-3xs);gap:var(--ymt-spacing-4xs)}.condition-chip:hover{border-color:var(--ymt-outline)}.condition-chip:focus-visible{border-color:var(--focus-visible-border-color);box-shadow:0 0 0 2px var(--focus-visible-border-shadow-color)}.condition-chip__part{display:inline-flex;align-items:center;gap:var(--ymt-spacing-4xs);padding:0 var(--ymt-spacing-xs);height:100%;white-space:nowrap;color:var(--ymt-text-color)}.condition-chip__part:focus-visible{border-color:var(--focus-visible-border-color);box-shadow:0 0 0 2px var(--focus-visible-border-shadow-color)}.condition-chip__op{font:var(--ymt-font-body-subtle);color:var(--ymt-text-color-subtle);padding:0}.condition-chip__value{max-width:120px;overflow:hidden;text-overflow:ellipsis;--tile-slot-padding: 0;--yuv-renderer-display: inline-flex}.condition-chip__remove{color:var(--ymt-text-color-subtle)}.condition-chip__remove:hover{color:var(--ymt-text-color)}.inline-input{display:flex;align-items:center;gap:var(--ymt-spacing-3xs);flex:1;border:1px dashed var(--outline);border-radius:var(--ymt-corner-xs);padding:var(--ymt-spacing-4xs) var(--ymt-spacing-2xs);background:var(--ymt-surface)}.inline-input .part-pill{display:inline-flex;align-items:center;gap:var(--ymt-spacing-4xs);white-space:nowrap;font:inherit;font-size:var(--ymt-font-body-subtle-size);color:var(--ymt-text-color);background:var(--ymt-surface-container);border:1px solid transparent;border-radius:var(--ymt-corner-xs);padding:var(--ymt-spacing-4xs) var(--ymt-spacing-xs);cursor:pointer;transition:border-color .1s,background .1s}.inline-input .part-pill:hover{background:var(--ymt-surface-container-high)}.inline-input .part-pill:focus-visible{border-color:var(--focus-visible-border-color);box-shadow:0 0 0 2px var(--focus-visible-border-shadow-color)}.inline-input .part-pill--active{border-color:var(--ymt-primary);box-shadow:0 0 0 2px var(--focus-visible-border-shadow-color)}.inline-input .part-pill mat-icon{font-size:var(--ymt-sizing-xs);width:var(--ymt-sizing-xs);height:var(--ymt-sizing-xs)}.inline-input yuv-metadata-form-field{flex:1;min-width:150px}.inline-input input{flex:1;border:none;outline:none;background:transparent}.inline-input .confirm-btn:not(:disabled){opacity:1;color:var(--ymt-color-accent)}.combinator{display:flex;justify-content:center;gap:0}.combinator__label{font:var(--ymt-font-body-subtle);text-transform:lowercase;color:var(--ymt-text-color-subtle);padding:var(--ymt-spacing-2xs) var(--ymt-spacing-xs)}.combinator__btn{--bg: transparent;--fg: var(--ymt-text-color);--mdc-shape-small: var(--ymt-corner-xs);background-color:var(--bg);color:var(--fg);padding:var(--ymt-spacing-2xs) var(--ymt-spacing-xs);border:0;outline:1px solid var(--ymt-inverse-surface);outline-offset:-1px}.combinator__btn:first-child{border-radius:var(--mdc-shape-small) 0 0 var(--mdc-shape-small)}.combinator__btn:last-child{border-radius:0 var(--mdc-shape-small) var(--mdc-shape-small) 0}.combinator__btn--active{--bg: var(--ymt-inverse-surface);--fg: var(--ymt-on-inverse-surface)}.add-type-row{display:flex;flex-wrap:wrap;align-items:center;gap:var(--ymt-spacing-xs);padding:var(--ymt-spacing-xs) var(--ymt-spacing-s);border:1px dashed var(--ymt-outline);border-radius:var(--ymt-corner-s);color:var(--ymt-text-color-subtle)}.add-type-row__icon{display:inline-flex;align-items:center;padding:0;border:0;background:transparent;color:inherit;cursor:pointer}.add-type-row__icon mat-icon{font-size:var(--ymt-sizing-m);width:var(--ymt-sizing-m);height:var(--ymt-sizing-m);opacity:.5}.add-type-row__icon:hover mat-icon{opacity:.8}.add-type-row__confirm{color:var(--ymt-primary)}.add-type-row__chips{flex:1;min-width:120px}.add-type-row input{flex:1;min-width:120px;border:none;outline:none;background:transparent;font:inherit;font-size:.9em;color:inherit}.add-type-row input::placeholder{color:var(--ymt-text-color-subtle);opacity:.7}.condition-combinator{display:flex;align-self:center}.suggestion{display:flex;align-items:center;gap:var(--ymt-spacing-xs)}.suggestion__icon{font-size:var(--ymt-sizing-s);width:var(--ymt-sizing-s);height:var(--ymt-sizing-s);--icon-size: var(--ymt-sizing-s);opacity:.6}.suggestion__label{flex:1}\n"] }]
1917
+ }], ctorParameters: () => [], propDecorators: { types: [{ type: i0.Input, args: [{ isSignal: true, alias: "types", required: false }] }], skipProperties: [{ type: i0.Input, args: [{ isSignal: true, alias: "skipProperties", required: false }] }], queryChange: [{ type: i0.Output, args: ["queryChange"] }], auto: [{ type: i0.ViewChild, args: ['auto', { isSignal: true }] }], trigger: [{ type: i0.ViewChild, args: [i0.forwardRef(() => MatAutocompleteTrigger), { isSignal: true }] }] } });
1918
+
1919
+ /**
1920
+ * Convenience NgModule that imports and re-exports {@link SmartSearchComponent}.
1921
+ *
1922
+ * The component is standalone — prefer importing `SmartSearchComponent` directly.
1923
+ * This module exists only for consumers still organized around NgModules.
1924
+ */
1925
+ const cmp = [SmartSearchComponent];
1926
+ class YuvSmartSearchModule {
1927
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: YuvSmartSearchModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
1928
+ static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "21.2.12", ngImport: i0, type: YuvSmartSearchModule, imports: [SmartSearchComponent], exports: [SmartSearchComponent] }); }
1929
+ static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: YuvSmartSearchModule, imports: [cmp] }); }
1930
+ }
1931
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: YuvSmartSearchModule, decorators: [{
1932
+ type: NgModule,
1933
+ args: [{
1934
+ imports: cmp,
1935
+ exports: cmp
1936
+ }]
1937
+ }] });
1938
+
1939
+ /**
1940
+ * Generated bundle index. Do not edit.
1941
+ */
1942
+
1943
+ export { SmartSearchComponent, YuvSmartSearchModule, buildCmisQuery, buildFulltextClause, buildNodeClause, isConditionGroup, isFieldCondition, isTableCondition };
1944
+ //# sourceMappingURL=yuuvis-client-framework-smart-search.mjs.map