@theredhead/lucid-blocks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3827 @@
1
+ import * as i0 from '@angular/core';
2
+ import { input, output, model, contentChildren, contentChild, viewChild, computed, signal, effect, untracked, ChangeDetectionStrategy, Component, inject, DestroyRef, ElementRef, Injectable, InjectionToken, Injector, afterNextRender, TemplateRef } from '@angular/core';
3
+ import { NgTemplateOutlet, DatePipe } from '@angular/common';
4
+ import * as i1 from '@theredhead/lucid-foundation';
5
+ import { isTreeDatasource, FilterableArrayTreeDatasource, UISurface, LoggerFactory, UI_DEFAULT_SURFACE_TYPE, StorageService, ArrayTreeDatasource, FilterableArrayDatasource as FilterableArrayDatasource$1 } from '@theredhead/lucid-foundation';
6
+ import { UITableViewColumn, UITableView, FilterableArrayDatasource, inferFilterFields, SelectionModel, UIIcons, toFilterExpression, toPredicate, UISplitContainer, UITreeView, UIFilter, UIIcon, UISidebarItem, UIButton, UIDrawer, UISidebarNav, UISidebarGroup, UIBreadcrumb, UIDialog, UIDialogBody, UIDialogFooter, UIDialogHeader, UIInput, UIPagination, UIDropdownList, UICheckbox, UIColorPicker, UISlider, UIAvatar, UIRichTextEditor, ModalRef, ModalService } from '@theredhead/lucid-kit';
7
+ import { moveItemInArray, transferArrayItem, CdkDropListGroup, CdkDropList, CdkDrag, CdkDragPlaceholder } from '@angular/cdk/drag-drop';
8
+ import { firstValueFrom } from 'rxjs';
9
+
10
+ /**
11
+ * A master-detail layout that shows a list of items in a
12
+ * {@link UITableView} and renders a detail template for the
13
+ * currently selected item.
14
+ *
15
+ * ### Basic usage (table mode)
16
+ * ```html
17
+ * <ui-master-detail-view [datasource]="adapter" title="People">
18
+ * <ui-text-column key="name" headerText="Name" />
19
+ * <ui-text-column key="email" headerText="Email" />
20
+ *
21
+ * <ng-template #detail let-object>
22
+ * <h3>{{ object.name }}</h3>
23
+ * <p>{{ object.email }}</p>
24
+ * </ng-template>
25
+ * </ui-master-detail-view>
26
+ * ```
27
+ *
28
+ * ### Tree mode
29
+ * ```html
30
+ * <ui-master-detail-view [datasource]="treeDs" [treeDisplayWith]="labelFn">
31
+ * <ng-template #detail let-object>
32
+ * <h3>{{ object.name }}</h3>
33
+ * </ng-template>
34
+ * </ui-master-detail-view>
35
+ * ```
36
+ *
37
+ * ### With filter
38
+ * ```html
39
+ * <!-- Auto-inferred fields from columns + data types -->
40
+ * <ui-master-detail-view [datasource]="adapter" [showFilter]="true">
41
+ * <ui-text-column key="name" headerText="Name" />
42
+ * <ui-text-column key="email" headerText="Email" />
43
+ * <ng-template #detail let-object>…</ng-template>
44
+ * </ui-master-detail-view>
45
+ * ```
46
+ *
47
+ * ### With custom filter template (override)
48
+ * ```html
49
+ * <ui-master-detail-view [datasource]="adapter" [showFilter]="true">
50
+ * <ng-template #filter>
51
+ * <ui-filter [fields]="fields" [(value)]="descriptor"
52
+ * (expressionChange)="ds.filterBy($event)" />
53
+ * </ng-template>
54
+ * <!-- columns and detail template -->
55
+ * </ui-master-detail-view>
56
+ * ```
57
+ */
58
+ class UIMasterDetailView {
59
+ // ── Inputs ────────────────────────────────────────────────────────
60
+ /** Title displayed above the list panel. */
61
+ title = input("Items", ...(ngDevMode ? [{ debugName: "title" }] : []));
62
+ /**
63
+ * The datasource powering the master list.
64
+ *
65
+ * Accepts any {@link IDatasource} (for flat table mode)
66
+ * or an {@link ITreeDatasource} (for hierarchical tree mode).
67
+ * The component detects the type at runtime and renders the
68
+ * appropriate view.
69
+ */
70
+ datasource = input(undefined, ...(ngDevMode ? [{ debugName: "datasource" }] : []));
71
+ /**
72
+ * Function that returns a display string for tree node data.
73
+ * Only used in tree mode when no `#nodeTemplate` is projected.
74
+ * Defaults to `String(data)`.
75
+ */
76
+ treeDisplayWith = input((data) => String(data), ...(ngDevMode ? [{ debugName: "treeDisplayWith" }] : []));
77
+ /** Placeholder text shown when no item is selected. */
78
+ placeholder = input("Select an item to view details", ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
79
+ /**
80
+ * Whether the filter section is visible.
81
+ *
82
+ * - `true` — always show the filter.
83
+ * - `false` — never show the filter.
84
+ * - `undefined` (default) — auto-detect: show the filter when the
85
+ * resolved datasource is a {@link FilterableArrayDatasource}.
86
+ *
87
+ * When shown without a projected `#filter` template, the component
88
+ * embeds a `<ui-filter>` internally using auto-inferred field
89
+ * definitions.
90
+ */
91
+ showFilter = input(undefined, ...(ngDevMode ? [{ debugName: "showFilter" }] : []));
92
+ /** Whether the filter section starts expanded. */
93
+ filterExpanded = input(true, ...(ngDevMode ? [{ debugName: "filterExpanded" }] : []));
94
+ /**
95
+ * Whether the filter toggle button is hidden.
96
+ *
97
+ * When `true`, the toggle is removed and the filter section stays
98
+ * permanently in whatever state `filterExpanded` dictates:
99
+ * - `filterExpanded: true` + `filterModeLocked: true` → filter is
100
+ * always visible, cannot be collapsed.
101
+ * - `filterExpanded: false` + `filterModeLocked: true` → filter bar
102
+ * is completely hidden (equivalent to `showFilter: false`).
103
+ *
104
+ * This value is also forwarded to the embedded `<ui-filter>` as
105
+ * `[modeLocked]`, preventing the user from toggling between
106
+ * simple and advanced filter modes.
107
+ */
108
+ filterModeLocked = input(false, ...(ngDevMode ? [{ debugName: "filterModeLocked" }] : []));
109
+ /**
110
+ * Explicit filter field definitions.
111
+ *
112
+ * When provided these override the auto-inferred fields.
113
+ * Only relevant when no `#filter` template is projected.
114
+ */
115
+ filterFields = input(undefined, ...(ngDevMode ? [{ debugName: "filterFields" }] : []));
116
+ /**
117
+ * Initial split sizes as a `[list, detail]` percentage tuple.
118
+ * Must sum to 100. Defaults to `[33, 67]`.
119
+ */
120
+ splitSizes = input([33, 67], ...(ngDevMode ? [{ debugName: "splitSizes" }] : []));
121
+ /**
122
+ * Optional localStorage key for persisting the split panel sizes.
123
+ * When set the user's last divider position is restored on init.
124
+ */
125
+ splitName = input(undefined, ...(ngDevMode ? [{ debugName: "splitName" }] : []));
126
+ /**
127
+ * Which panel to collapse when the divider is double-clicked.
128
+ * Defaults to `'first'` so the list panel can be collapsed.
129
+ */
130
+ splitCollapseTarget = input("first", ...(ngDevMode ? [{ debugName: "splitCollapseTarget" }] : []));
131
+ /**
132
+ * Size constraints for the list (first) panel in pixels.
133
+ * Defaults to `{ min: 200 }`.
134
+ */
135
+ listConstraints = input({
136
+ min: 200,
137
+ }, ...(ngDevMode ? [{ debugName: "listConstraints" }] : []));
138
+ // ── Table pass-through inputs ─────────────────────────────────
139
+ /** Whether the embedded table view is disabled. */
140
+ disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled" }] : []));
141
+ /**
142
+ * Page size for the embedded table view's paginator.
143
+ * Leave `undefined` to use the table's default.
144
+ */
145
+ pageSize = input(undefined, ...(ngDevMode ? [{ debugName: "pageSize" }] : []));
146
+ /**
147
+ * External page index (zero-based) for the embedded table view.
148
+ * Leave `undefined` to let the built-in paginator manage it.
149
+ */
150
+ pageIndex = input(undefined, ...(ngDevMode ? [{ debugName: "pageIndex" }] : []));
151
+ /**
152
+ * Whether to show the table's built-in paginator.
153
+ * Defaults to `false`.
154
+ */
155
+ showBuiltInPaginator = input(false, ...(ngDevMode ? [{ debugName: "showBuiltInPaginator" }] : []));
156
+ /** Accessible caption for the embedded table. */
157
+ caption = input("", ...(ngDevMode ? [{ debugName: "caption" }] : []));
158
+ /** Whether to show row-index numbers in the table. */
159
+ showRowIndexIndicator = input(false, ...(ngDevMode ? [{ debugName: "showRowIndexIndicator" }] : []));
160
+ /** Header text for the row-index column. Defaults to `"#"`. */
161
+ rowIndexHeaderText = input("#", ...(ngDevMode ? [{ debugName: "rowIndexHeaderText" }] : []));
162
+ /**
163
+ * Unique table identifier used for persisting column widths.
164
+ * When set, column widths are stored in localStorage.
165
+ */
166
+ tableId = input("", ...(ngDevMode ? [{ debugName: "tableId" }] : []));
167
+ /**
168
+ * Whether table columns can be resized by dragging header borders.
169
+ * Defaults to `true`.
170
+ */
171
+ resizable = input(true, ...(ngDevMode ? [{ debugName: "resizable" }] : []));
172
+ /**
173
+ * Row height in pixels for the internal table-view.
174
+ * Forwarded to `<ui-table-view [rowHeight]>`.
175
+ * Defaults to 36 px.
176
+ */
177
+ rowHeight = input(36, ...(ngDevMode ? [{ debugName: "rowHeight" }] : []));
178
+ // ── Outputs ───────────────────────────────────────────────────────
179
+ /** Emits whenever the selection changes. Carries the selected item or `undefined`. */
180
+ selectedChange = output();
181
+ /**
182
+ * Emits the {@link FilterExpression} every time the filter rules
183
+ * change. Emits an empty array when no valid rules remain.
184
+ *
185
+ * For {@link FilterableArrayDatasource} instances the expression is
186
+ * applied automatically — this output is for consumers who use a
187
+ * custom datasource and need to handle filtering manually.
188
+ */
189
+ expressionChange = output();
190
+ // ── Models ────────────────────────────────────────────────────────
191
+ /**
192
+ * The filter descriptor state (two-way bindable).
193
+ *
194
+ * Provides full read/write access to the filter's rule set and
195
+ * junction mode. Defaults to an empty AND descriptor.
196
+ */
197
+ filterDescriptor = model({
198
+ junction: "and",
199
+ rules: [],
200
+ }, ...(ngDevMode ? [{ debugName: "filterDescriptor" }] : []));
201
+ // ── Content queries ───────────────────────────────────────────────
202
+ /**
203
+ * Projected table-view columns.
204
+ * These are forwarded to the internal `<ui-table-view>`.
205
+ */
206
+ columns = contentChildren(UITableViewColumn, ...(ngDevMode ? [{ debugName: "columns" }] : []));
207
+ /** Detail template — rendered when an item is selected. */
208
+ detailTemplate = contentChild("detail", ...(ngDevMode ? [{ debugName: "detailTemplate" }] : []));
209
+ /** Optional filter template — shown in the collapsible filter area. */
210
+ filterTemplate = contentChild("filter", ...(ngDevMode ? [{ debugName: "filterTemplate" }] : []));
211
+ /**
212
+ * Optional tree-node template — forwarded to `<ui-tree-view>`.
213
+ * Receives {@link TreeNodeContext} as its context.
214
+ */
215
+ treeNodeTemplate = contentChild("nodeTemplate", ...(ngDevMode ? [{ debugName: "treeNodeTemplate" }] : []));
216
+ // ── View queries ──────────────────────────────────────────────────
217
+ /** @internal — the internal table view instance (table mode). */
218
+ tableViewChild = viewChild(UITableView, ...(ngDevMode ? [{ debugName: "tableViewChild" }] : []));
219
+ // ── Computed ──────────────────────────────────────────────────────
220
+ /**
221
+ * Whether the component is in tree mode.
222
+ * `true` when the `datasource` input is an `ITreeDatasource`. @internal
223
+ */
224
+ isTreeMode = computed(() => isTreeDatasource(this.datasource()), ...(ngDevMode ? [{ debugName: "isTreeMode" }] : []));
225
+ /**
226
+ * The tree datasource, extracted from the unified `datasource`
227
+ * input. Returns `undefined` when not in tree mode.
228
+ * @internal
229
+ */
230
+ resolvedTreeDatasource = computed(() => {
231
+ const ds = this.datasource();
232
+ return isTreeDatasource(ds) ? ds : undefined;
233
+ }, ...(ngDevMode ? [{ debugName: "resolvedTreeDatasource" }] : []));
234
+ /**
235
+ * The currently selected item, derived from the table selection
236
+ * model or the tree selection signal. @internal
237
+ */
238
+ selectedItem = computed(() => {
239
+ if (this.isTreeMode()) {
240
+ const nodes = this.selectedTreeNodes();
241
+ return nodes.length > 0 ? nodes[0].data : undefined;
242
+ }
243
+ const items = this.selectionModel.selected();
244
+ return items.length > 0 ? items[0] : undefined;
245
+ }, ...(ngDevMode ? [{ debugName: "selectedItem" }] : []));
246
+ /** Context for the detail template outlet. @internal */
247
+ detailContext = computed(() => {
248
+ const item = this.selectedItem();
249
+ if (item === undefined)
250
+ return undefined;
251
+ return { $implicit: item };
252
+ }, ...(ngDevMode ? [{ debugName: "detailContext" }] : []));
253
+ /**
254
+ * The resolved flat-table datasource.
255
+ *
256
+ * Returns the `datasource` input when it is a flat
257
+ * {@link IDatasource} (not a tree). Falls back to an empty
258
+ * {@link FilterableArrayDatasource} when no datasource is set or
259
+ * when the input is a tree datasource.
260
+ * @internal
261
+ */
262
+ resolvedTableDatasource = computed(() => {
263
+ const explicit = this.datasource();
264
+ if (explicit && !isTreeDatasource(explicit)) {
265
+ return explicit;
266
+ }
267
+ return new FilterableArrayDatasource([]);
268
+ }, ...(ngDevMode ? [{ debugName: "resolvedTableDatasource" }] : []));
269
+ /**
270
+ * Whether the filter section should be displayed.
271
+ *
272
+ * - Explicit `showFilter` input wins when not `undefined`.
273
+ * - Otherwise auto-detects: `true` when the underlying raw
274
+ * datasource is a {@link FilterableArrayDatasource}.
275
+ * @internal
276
+ */
277
+ resolvedShowFilter = computed(() => {
278
+ const explicit = this.showFilter();
279
+ if (explicit !== undefined)
280
+ return explicit;
281
+ // Tree mode: auto-show when a tree datasource is provided
282
+ if (this.resolvedTreeDatasource() !== undefined)
283
+ return false;
284
+ // Auto-detect: show filter when the datasource supports filtering
285
+ const ds = this.resolvedTableDatasource();
286
+ return ds instanceof FilterableArrayDatasource;
287
+ }, ...(ngDevMode ? [{ debugName: "resolvedShowFilter" }] : []));
288
+ /**
289
+ * The filter field definitions used by the embedded `<ui-filter>`.
290
+ *
291
+ * Priority: explicit `filterFields` input → inferred from projected
292
+ * columns and the first data row.
293
+ * @internal
294
+ */
295
+ resolvedFilterFields = computed(() => {
296
+ const explicit = this.filterFields();
297
+ if (explicit)
298
+ return explicit;
299
+ // ── Tree mode: infer from tree node data ──
300
+ const treeDs = this.resolvedTreeDatasource();
301
+ if (treeDs) {
302
+ const flatData = this.collectTreeData(treeDs);
303
+ if (flatData.length === 0)
304
+ return [];
305
+ return inferFilterFields(flatData[0]);
306
+ }
307
+ // ── Table mode: infer from columns + datasource rows ──
308
+ // Derive column metadata from projected UITableViewColumn instances
309
+ const cols = this.columns();
310
+ const columnMeta = cols.map((c) => ({
311
+ key: c.key(),
312
+ headerText: c.headerText(),
313
+ }));
314
+ // Get a sample row from the *unfiltered* datasource to sniff
315
+ // types. We must NOT use getNumberOfItems() on a filtered
316
+ // datasource — the filtered count drops to 0 when no rows match
317
+ // the current predicate and would make the filter fields (and
318
+ // the filter component) disappear.
319
+ const ds = this.resolvedTableDatasource();
320
+ // Prefer the full unfiltered list when available
321
+ const allRows = ds instanceof FilterableArrayDatasource ? ds.allRows : undefined;
322
+ let sample;
323
+ if (allRows && allRows.length > 0) {
324
+ sample = allRows[0];
325
+ }
326
+ else {
327
+ // Fallback for non-filterable datasources
328
+ const count = ds.getNumberOfItems();
329
+ if (typeof count !== "number" || count === 0)
330
+ return [];
331
+ const result = ds.getObjectAtRowIndex(0);
332
+ if (!result || result instanceof Promise)
333
+ return [];
334
+ sample = result;
335
+ }
336
+ if (!sample)
337
+ return [];
338
+ return inferFilterFields(sample, columnMeta.length > 0 ? columnMeta : undefined);
339
+ }, ...(ngDevMode ? [{ debugName: "resolvedFilterFields" }] : []));
340
+ /**
341
+ * The raw data array for the embedded filter (used to derive
342
+ * distinct values for autocomplete / select).
343
+ * @internal
344
+ */
345
+ resolvedFilterData = computed(() => {
346
+ // ── Tree mode: flatten all node data ──
347
+ const treeDs = this.resolvedTreeDatasource();
348
+ if (treeDs) {
349
+ const flatData = this.collectTreeData(treeDs);
350
+ return flatData.length < 1000 ? flatData : [];
351
+ }
352
+ // ── Table mode ──
353
+ const ds = this.resolvedTableDatasource();
354
+ // Use the full unfiltered dataset when available so that distinct
355
+ // value lists and autocomplete options don't shrink as the user
356
+ // narrows the filter.
357
+ if (ds instanceof FilterableArrayDatasource) {
358
+ const all = ds.allRows;
359
+ return all.length < 1000 ? all : [];
360
+ }
361
+ // Fallback for non-filterable datasources: gather visible rows
362
+ const count = ds.getNumberOfItems();
363
+ if (typeof count !== "number" || count === 0 || count >= 1000)
364
+ return [];
365
+ const rows = [];
366
+ for (let i = 0; i < count; i++) {
367
+ const row = ds.getObjectAtRowIndex(i);
368
+ if (!(row instanceof Promise))
369
+ rows.push(row);
370
+ }
371
+ return rows;
372
+ }, ...(ngDevMode ? [{ debugName: "resolvedFilterData" }] : []));
373
+ // ── Public fields ───────────────────────────────────────────────
374
+ /** Selection model for the table-view (single mode). */
375
+ selectionModel = new SelectionModel("single");
376
+ // ── Protected fields ──────────────────────────────────────────────
377
+ /** @internal — currently selected tree nodes (tree mode). */
378
+ selectedTreeNodes = signal([], ...(ngDevMode ? [{ debugName: "selectedTreeNodes" }] : []));
379
+ /** @internal — whether the filter section is collapsed. */
380
+ filterCollapsed = signal(false, ...(ngDevMode ? [{ debugName: "filterCollapsed" }] : []));
381
+ /**
382
+ * The current filter predicate for tree mode.
383
+ * Forwarded to the `<ui-tree-view>` `filterPredicate` input.
384
+ * @internal
385
+ */
386
+ treeFilterPredicate = signal(undefined, ...(ngDevMode ? [{ debugName: "treeFilterPredicate" }] : []));
387
+ // ── Protected constants ───────────────────────────────────────────
388
+ /** @internal */
389
+ chevronDown = UIIcons.Lucide.Arrows.ChevronDown;
390
+ /** @internal */
391
+ chevronRight = UIIcons.Lucide.Arrows.ChevronRight;
392
+ // ── Constructor ───────────────────────────────────────────────────
393
+ constructor() {
394
+ // Initialise filterCollapsed from the filterExpanded input
395
+ effect(() => {
396
+ const expanded = this.filterExpanded();
397
+ untracked(() => this.filterCollapsed.set(!expanded));
398
+ });
399
+ // Emit selectedChange whenever selection changes
400
+ effect(() => {
401
+ const item = this.selectedItem();
402
+ untracked(() => this.selectedChange.emit(item));
403
+ });
404
+ }
405
+ // ── Public methods ────────────────────────────────────────────────
406
+ /**
407
+ * Called by the embedded `<ui-filter>` when the expression changes.
408
+ *
409
+ * - For {@link FilterableArrayDatasource} the expression is applied
410
+ * via `filterBy()` and the adapter is refreshed.
411
+ * - For tree mode the expression is compiled into a predicate for
412
+ * the tree-view's `filterPredicate` input.
413
+ * - Always emits via {@link expressionChange} so consumers with
414
+ * custom datasources can react.
415
+ */
416
+ onFilterExpressionChange(expression) {
417
+ this.expressionChange.emit(expression);
418
+ if (this.isTreeMode()) {
419
+ const treeDs = this.resolvedTreeDatasource();
420
+ // FilterableArrayTreeDatasource: compile expression and apply
421
+ if (treeDs instanceof FilterableArrayTreeDatasource) {
422
+ const fields = this.resolvedFilterFields();
423
+ const compiled = toFilterExpression(expression, fields);
424
+ treeDs.filterBy(compiled.length === 0 ? null : compiled);
425
+ return;
426
+ }
427
+ // Plain ITreeDatasource: fall back to tree-view filterPredicate input
428
+ const fields = this.resolvedFilterFields();
429
+ const descriptor = this.filterDescriptor();
430
+ this.treeFilterPredicate.set(toPredicate(descriptor, fields));
431
+ return;
432
+ }
433
+ // Table mode: apply expression to FilterableArrayDatasource
434
+ const ds = this.resolvedTableDatasource();
435
+ if (ds instanceof FilterableArrayDatasource) {
436
+ const fields = this.resolvedFilterFields();
437
+ const compiled = toFilterExpression(expression, fields);
438
+ ds.filterBy(compiled.length === 0 ? null : compiled);
439
+ this.tableViewChild()?.refreshDatasource();
440
+ }
441
+ }
442
+ // ── Protected methods ─────────────────────────────────────────────
443
+ /** @internal — toggle filter visibility. */
444
+ toggleFilter() {
445
+ this.filterCollapsed.update((v) => !v);
446
+ }
447
+ // ── Private helpers ───────────────────────────────────────────────
448
+ /**
449
+ * Recursively collects the `data` payloads from every node in the
450
+ * tree datasource into a flat array. Used to infer filter fields
451
+ * and provide distinct values in tree mode.
452
+ */
453
+ collectTreeData(ds) {
454
+ const items = [];
455
+ const walk = (nodes) => {
456
+ for (const node of nodes) {
457
+ items.push(node.data);
458
+ const children = ds.getChildren(node);
459
+ if (Array.isArray(children))
460
+ walk(children);
461
+ }
462
+ };
463
+ const roots = ds.getRootNodes();
464
+ if (Array.isArray(roots))
465
+ walk(roots);
466
+ return items;
467
+ }
468
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIMasterDetailView, deps: [], target: i0.ɵɵFactoryTarget.Component });
469
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIMasterDetailView, isStandalone: true, selector: "ui-master-detail-view", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, datasource: { classPropertyName: "datasource", publicName: "datasource", isSignal: true, isRequired: false, transformFunction: null }, treeDisplayWith: { classPropertyName: "treeDisplayWith", publicName: "treeDisplayWith", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, showFilter: { classPropertyName: "showFilter", publicName: "showFilter", isSignal: true, isRequired: false, transformFunction: null }, filterExpanded: { classPropertyName: "filterExpanded", publicName: "filterExpanded", isSignal: true, isRequired: false, transformFunction: null }, filterModeLocked: { classPropertyName: "filterModeLocked", publicName: "filterModeLocked", isSignal: true, isRequired: false, transformFunction: null }, filterFields: { classPropertyName: "filterFields", publicName: "filterFields", isSignal: true, isRequired: false, transformFunction: null }, splitSizes: { classPropertyName: "splitSizes", publicName: "splitSizes", isSignal: true, isRequired: false, transformFunction: null }, splitName: { classPropertyName: "splitName", publicName: "splitName", isSignal: true, isRequired: false, transformFunction: null }, splitCollapseTarget: { classPropertyName: "splitCollapseTarget", publicName: "splitCollapseTarget", isSignal: true, isRequired: false, transformFunction: null }, listConstraints: { classPropertyName: "listConstraints", publicName: "listConstraints", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, pageSize: { classPropertyName: "pageSize", publicName: "pageSize", isSignal: true, isRequired: false, transformFunction: null }, pageIndex: { classPropertyName: "pageIndex", publicName: "pageIndex", isSignal: true, isRequired: false, transformFunction: null }, showBuiltInPaginator: { classPropertyName: "showBuiltInPaginator", publicName: "showBuiltInPaginator", isSignal: true, isRequired: false, transformFunction: null }, caption: { classPropertyName: "caption", publicName: "caption", isSignal: true, isRequired: false, transformFunction: null }, showRowIndexIndicator: { classPropertyName: "showRowIndexIndicator", publicName: "showRowIndexIndicator", isSignal: true, isRequired: false, transformFunction: null }, rowIndexHeaderText: { classPropertyName: "rowIndexHeaderText", publicName: "rowIndexHeaderText", isSignal: true, isRequired: false, transformFunction: null }, tableId: { classPropertyName: "tableId", publicName: "tableId", isSignal: true, isRequired: false, transformFunction: null }, resizable: { classPropertyName: "resizable", publicName: "resizable", isSignal: true, isRequired: false, transformFunction: null }, rowHeight: { classPropertyName: "rowHeight", publicName: "rowHeight", isSignal: true, isRequired: false, transformFunction: null }, filterDescriptor: { classPropertyName: "filterDescriptor", publicName: "filterDescriptor", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectedChange: "selectedChange", expressionChange: "expressionChange", filterDescriptor: "filterDescriptorChange" }, host: { classAttribute: "ui-master-detail-view" }, queries: [{ propertyName: "columns", predicate: UITableViewColumn, isSignal: true }, { propertyName: "detailTemplate", first: true, predicate: ["detail"], descendants: true, isSignal: true }, { propertyName: "filterTemplate", first: true, predicate: ["filter"], descendants: true, isSignal: true }, { propertyName: "treeNodeTemplate", first: true, predicate: ["nodeTemplate"], descendants: true, isSignal: true }], viewQueries: [{ propertyName: "tableViewChild", first: true, predicate: UITableView, descendants: true, isSignal: true }], hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "<!-- Hidden slot: instantiates projected columns so contentChildren can discover them. -->\r\n<div class=\"columns-slot\"><ng-content /></div>\r\n\r\n@if (resolvedShowFilter()) {\r\n <div class=\"filter-bar\">\r\n @if (!filterModeLocked()) {\r\n <button\r\n class=\"filter-toggle\"\r\n type=\"button\"\r\n (click)=\"toggleFilter()\"\r\n [attr.aria-expanded]=\"!filterCollapsed()\"\r\n aria-controls=\"mdv-filter-content\"\r\n >\r\n <ui-icon\r\n [svg]=\"filterCollapsed() ? chevronRight : chevronDown\"\r\n [size]=\"14\"\r\n />\r\n <span>Filter</span>\r\n </button>\r\n }\r\n @if (!filterCollapsed()) {\r\n <div class=\"filter-content\" id=\"mdv-filter-content\">\r\n @if (filterTemplate(); as filterTpl) {\r\n <ng-container *ngTemplateOutlet=\"filterTpl\" />\r\n } @else {\r\n @if (resolvedFilterFields(); as fields) {\r\n @if (fields.length > 0) {\r\n <ui-filter\r\n [fields]=\"fields\"\r\n [data]=\"resolvedFilterData()\"\r\n [allowJunction]=\"true\"\r\n [modeLocked]=\"filterModeLocked()\"\r\n [(value)]=\"filterDescriptor\"\r\n (expressionChange)=\"onFilterExpressionChange($event)\"\r\n />\r\n }\r\n }\r\n }\r\n </div>\r\n }\r\n </div>\r\n}\r\n\r\n<ui-split-container\r\n class=\"split\"\r\n [initialSizes]=\"splitSizes()\"\r\n [name]=\"splitName()\"\r\n [collapseTarget]=\"splitCollapseTarget()\"\r\n [firstConstraints]=\"listConstraints()\"\r\n ariaLabel=\"Resize list and detail panels\"\r\n>\r\n <aside first class=\"list\">\r\n <header class=\"ui-surface-type-panel\">\r\n <h2 class=\"title\">{{ title() }}</h2>\r\n </header>\r\n\r\n @if (isTreeMode()) {\r\n <div class=\"tree-wrapper\">\r\n <ui-tree-view\r\n [datasource]=\"resolvedTreeDatasource()!\"\r\n [displayWith]=\"treeDisplayWith()\"\r\n [filterPredicate]=\"treeFilterPredicate()\"\r\n [(selected)]=\"selectedTreeNodes\"\r\n ariaLabel=\"Master list\"\r\n >\r\n @if (treeNodeTemplate(); as nodeTpl) {\r\n <ng-template #nodeTemplate let-node let-level=\"level\" let-expanded=\"expanded\" let-hasChildren=\"hasChildren\">\r\n <ng-container *ngTemplateOutlet=\"nodeTpl; context: { $implicit: node, level: level, expanded: expanded, hasChildren: hasChildren }\" />\r\n </ng-template>\r\n }\r\n </ui-tree-view>\r\n </div>\r\n } @else {\r\n <div class=\"table-wrapper\">\r\n <ui-table-view\r\n [datasource]=\"resolvedTableDatasource()\"\r\n [externalColumns]=\"columns()\"\r\n [disabled]=\"disabled()\"\r\n [pageSize]=\"pageSize()\"\r\n [pageIndex]=\"pageIndex()\"\r\n [showBuiltInPaginator]=\"showBuiltInPaginator()\"\r\n [caption]=\"caption()\"\r\n [showRowIndexIndicator]=\"showRowIndexIndicator()\"\r\n [rowIndexHeaderText]=\"rowIndexHeaderText()\"\r\n [tableId]=\"tableId()\"\r\n [resizable]=\"resizable()\"\r\n [rowHeight]=\"rowHeight()\"\n selectionMode=\"single\"\r\n [showSelectionColumn]=\"false\"\r\n [rowClickSelect]=\"true\"\r\n [selectionModel]=\"selectionModel\"\r\n />\r\n </div>\r\n }\r\n </aside>\r\n\r\n <main second class=\"detail\">\r\n @if (detailContext(); as ctx) {\r\n @if (detailTemplate(); as tpl) {\r\n <section class=\"detail-content\">\r\n <ng-container *ngTemplateOutlet=\"tpl; context: ctx\" />\r\n </section>\r\n }\r\n } @else {\r\n <div class=\"placeholder\">\r\n <p>{{ placeholder() }}</p>\r\n </div>\r\n }\r\n </main>\r\n</ui-split-container>", styles: ["@charset \"UTF-8\";:host{--ui-radius: 6px;display:flex;flex-direction:column;height:100%;font-family:Cantarell,Noto Sans,Segoe UI,sans-serif}.columns-slot{display:none}.split{flex:1;min-height:0}.list{display:flex;flex-direction:column;overflow:hidden;height:100%;min-height:0}.header{padding:.75rem 1rem;flex-shrink:0}.title{margin:0;font-size:.9rem;font-weight:600;letter-spacing:.01em}.filter-bar{flex-shrink:0}.filter-toggle{appearance:none;border:none;background:none;cursor:pointer;font-family:var(--ui-font, inherit);display:flex;align-items:center;gap:6px;width:100%;padding:.45rem 1rem;font-size:.78rem;font-weight:500;text-align:left}.table-wrapper{flex:1;overflow:hidden;min-height:0}.table-wrapper ::ng-deep ui-table-view{display:flex;flex-direction:column;height:100%}.table-wrapper ::ng-deep .root{display:flex;flex-direction:column;flex:1;min-height:0;border-radius:0;border:none;box-shadow:none}.table-wrapper ::ng-deep ui-table-body{flex:1;min-height:0}.table-wrapper ::ng-deep .table-body-viewport{height:100%}.tree-wrapper{flex:1;overflow-y:auto;overscroll-behavior:contain;min-height:0;padding:.25rem 0}.detail{display:flex;flex-direction:column;overflow:hidden;height:100%}.detail-content{flex:1;overflow-y:auto;padding:1.25rem}.placeholder{flex:1;display:flex;align-items:center;justify-content:center}.placeholder p{margin:0;font-size:.88rem}\n"], dependencies: [{ kind: "component", type: UISplitContainer, selector: "ui-split-container", inputs: ["disabled", "orientation", "initialSizes", "name", "firstConstraints", "secondConstraints", "dividerWidth", "collapseTarget", "ariaLabel"], outputs: ["resized", "resizing"] }, { kind: "component", type: UITableView, selector: "ui-table-view", inputs: ["renderingStrategy", "disabled", "rowHeight", "datasource", "pageSize", "pageIndex", "showBuiltInPaginator", "caption", "showRowIndexIndicator", "rowIndexHeaderText", "tableId", "resizable", "selectionMode", "rowClickSelect", "showSelectionColumn", "selectionModel", "externalColumns"], outputs: ["selectionChange"] }, { kind: "component", type: UITreeView, selector: "ui-tree-view", inputs: ["disabled", "datasource", "ariaLabel", "displayWith", "filterPredicate", "sortComparator", "selected"], outputs: ["selectedChange", "nodeExpanded", "nodeCollapsed", "nodeActivated"] }, { kind: "component", type: UIFilter, selector: "ui-filter", inputs: ["disabled", "fields", "allowJunction", "allowSimple", "allowAdvanced", "modeLocked", "showSaveButton", "data", "value"], outputs: ["valueChange", "expressionChange", "saveFilter"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: UIIcon, selector: "ui-icon", inputs: ["svg", "size", "ariaLabel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
470
+ }
471
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIMasterDetailView, decorators: [{
472
+ type: Component,
473
+ args: [{ selector: "ui-master-detail-view", standalone: true, imports: [
474
+ UISplitContainer,
475
+ UITableView,
476
+ UITreeView,
477
+ UIFilter,
478
+ NgTemplateOutlet,
479
+ UIIcon,
480
+ ], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ["surfaceType"] }], host: {
481
+ class: "ui-master-detail-view",
482
+ }, template: "<!-- Hidden slot: instantiates projected columns so contentChildren can discover them. -->\r\n<div class=\"columns-slot\"><ng-content /></div>\r\n\r\n@if (resolvedShowFilter()) {\r\n <div class=\"filter-bar\">\r\n @if (!filterModeLocked()) {\r\n <button\r\n class=\"filter-toggle\"\r\n type=\"button\"\r\n (click)=\"toggleFilter()\"\r\n [attr.aria-expanded]=\"!filterCollapsed()\"\r\n aria-controls=\"mdv-filter-content\"\r\n >\r\n <ui-icon\r\n [svg]=\"filterCollapsed() ? chevronRight : chevronDown\"\r\n [size]=\"14\"\r\n />\r\n <span>Filter</span>\r\n </button>\r\n }\r\n @if (!filterCollapsed()) {\r\n <div class=\"filter-content\" id=\"mdv-filter-content\">\r\n @if (filterTemplate(); as filterTpl) {\r\n <ng-container *ngTemplateOutlet=\"filterTpl\" />\r\n } @else {\r\n @if (resolvedFilterFields(); as fields) {\r\n @if (fields.length > 0) {\r\n <ui-filter\r\n [fields]=\"fields\"\r\n [data]=\"resolvedFilterData()\"\r\n [allowJunction]=\"true\"\r\n [modeLocked]=\"filterModeLocked()\"\r\n [(value)]=\"filterDescriptor\"\r\n (expressionChange)=\"onFilterExpressionChange($event)\"\r\n />\r\n }\r\n }\r\n }\r\n </div>\r\n }\r\n </div>\r\n}\r\n\r\n<ui-split-container\r\n class=\"split\"\r\n [initialSizes]=\"splitSizes()\"\r\n [name]=\"splitName()\"\r\n [collapseTarget]=\"splitCollapseTarget()\"\r\n [firstConstraints]=\"listConstraints()\"\r\n ariaLabel=\"Resize list and detail panels\"\r\n>\r\n <aside first class=\"list\">\r\n <header class=\"ui-surface-type-panel\">\r\n <h2 class=\"title\">{{ title() }}</h2>\r\n </header>\r\n\r\n @if (isTreeMode()) {\r\n <div class=\"tree-wrapper\">\r\n <ui-tree-view\r\n [datasource]=\"resolvedTreeDatasource()!\"\r\n [displayWith]=\"treeDisplayWith()\"\r\n [filterPredicate]=\"treeFilterPredicate()\"\r\n [(selected)]=\"selectedTreeNodes\"\r\n ariaLabel=\"Master list\"\r\n >\r\n @if (treeNodeTemplate(); as nodeTpl) {\r\n <ng-template #nodeTemplate let-node let-level=\"level\" let-expanded=\"expanded\" let-hasChildren=\"hasChildren\">\r\n <ng-container *ngTemplateOutlet=\"nodeTpl; context: { $implicit: node, level: level, expanded: expanded, hasChildren: hasChildren }\" />\r\n </ng-template>\r\n }\r\n </ui-tree-view>\r\n </div>\r\n } @else {\r\n <div class=\"table-wrapper\">\r\n <ui-table-view\r\n [datasource]=\"resolvedTableDatasource()\"\r\n [externalColumns]=\"columns()\"\r\n [disabled]=\"disabled()\"\r\n [pageSize]=\"pageSize()\"\r\n [pageIndex]=\"pageIndex()\"\r\n [showBuiltInPaginator]=\"showBuiltInPaginator()\"\r\n [caption]=\"caption()\"\r\n [showRowIndexIndicator]=\"showRowIndexIndicator()\"\r\n [rowIndexHeaderText]=\"rowIndexHeaderText()\"\r\n [tableId]=\"tableId()\"\r\n [resizable]=\"resizable()\"\r\n [rowHeight]=\"rowHeight()\"\n selectionMode=\"single\"\r\n [showSelectionColumn]=\"false\"\r\n [rowClickSelect]=\"true\"\r\n [selectionModel]=\"selectionModel\"\r\n />\r\n </div>\r\n }\r\n </aside>\r\n\r\n <main second class=\"detail\">\r\n @if (detailContext(); as ctx) {\r\n @if (detailTemplate(); as tpl) {\r\n <section class=\"detail-content\">\r\n <ng-container *ngTemplateOutlet=\"tpl; context: ctx\" />\r\n </section>\r\n }\r\n } @else {\r\n <div class=\"placeholder\">\r\n <p>{{ placeholder() }}</p>\r\n </div>\r\n }\r\n </main>\r\n</ui-split-container>", styles: ["@charset \"UTF-8\";:host{--ui-radius: 6px;display:flex;flex-direction:column;height:100%;font-family:Cantarell,Noto Sans,Segoe UI,sans-serif}.columns-slot{display:none}.split{flex:1;min-height:0}.list{display:flex;flex-direction:column;overflow:hidden;height:100%;min-height:0}.header{padding:.75rem 1rem;flex-shrink:0}.title{margin:0;font-size:.9rem;font-weight:600;letter-spacing:.01em}.filter-bar{flex-shrink:0}.filter-toggle{appearance:none;border:none;background:none;cursor:pointer;font-family:var(--ui-font, inherit);display:flex;align-items:center;gap:6px;width:100%;padding:.45rem 1rem;font-size:.78rem;font-weight:500;text-align:left}.table-wrapper{flex:1;overflow:hidden;min-height:0}.table-wrapper ::ng-deep ui-table-view{display:flex;flex-direction:column;height:100%}.table-wrapper ::ng-deep .root{display:flex;flex-direction:column;flex:1;min-height:0;border-radius:0;border:none;box-shadow:none}.table-wrapper ::ng-deep ui-table-body{flex:1;min-height:0}.table-wrapper ::ng-deep .table-body-viewport{height:100%}.tree-wrapper{flex:1;overflow-y:auto;overscroll-behavior:contain;min-height:0;padding:.25rem 0}.detail{display:flex;flex-direction:column;overflow:hidden;height:100%}.detail-content{flex:1;overflow-y:auto;padding:1.25rem}.placeholder{flex:1;display:flex;align-items:center;justify-content:center}.placeholder p{margin:0;font-size:.88rem}\n"] }]
483
+ }], ctorParameters: () => [], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], datasource: [{ type: i0.Input, args: [{ isSignal: true, alias: "datasource", required: false }] }], treeDisplayWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "treeDisplayWith", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], showFilter: [{ type: i0.Input, args: [{ isSignal: true, alias: "showFilter", required: false }] }], filterExpanded: [{ type: i0.Input, args: [{ isSignal: true, alias: "filterExpanded", required: false }] }], filterModeLocked: [{ type: i0.Input, args: [{ isSignal: true, alias: "filterModeLocked", required: false }] }], filterFields: [{ type: i0.Input, args: [{ isSignal: true, alias: "filterFields", required: false }] }], splitSizes: [{ type: i0.Input, args: [{ isSignal: true, alias: "splitSizes", required: false }] }], splitName: [{ type: i0.Input, args: [{ isSignal: true, alias: "splitName", required: false }] }], splitCollapseTarget: [{ type: i0.Input, args: [{ isSignal: true, alias: "splitCollapseTarget", required: false }] }], listConstraints: [{ type: i0.Input, args: [{ isSignal: true, alias: "listConstraints", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], pageSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageSize", required: false }] }], pageIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageIndex", required: false }] }], showBuiltInPaginator: [{ type: i0.Input, args: [{ isSignal: true, alias: "showBuiltInPaginator", required: false }] }], caption: [{ type: i0.Input, args: [{ isSignal: true, alias: "caption", required: false }] }], showRowIndexIndicator: [{ type: i0.Input, args: [{ isSignal: true, alias: "showRowIndexIndicator", required: false }] }], rowIndexHeaderText: [{ type: i0.Input, args: [{ isSignal: true, alias: "rowIndexHeaderText", required: false }] }], tableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "tableId", required: false }] }], resizable: [{ type: i0.Input, args: [{ isSignal: true, alias: "resizable", required: false }] }], rowHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "rowHeight", required: false }] }], selectedChange: [{ type: i0.Output, args: ["selectedChange"] }], expressionChange: [{ type: i0.Output, args: ["expressionChange"] }], filterDescriptor: [{ type: i0.Input, args: [{ isSignal: true, alias: "filterDescriptor", required: false }] }, { type: i0.Output, args: ["filterDescriptorChange"] }], columns: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => UITableViewColumn), { isSignal: true }] }], detailTemplate: [{ type: i0.ContentChild, args: ["detail", { isSignal: true }] }], filterTemplate: [{ type: i0.ContentChild, args: ["filter", { isSignal: true }] }], treeNodeTemplate: [{ type: i0.ContentChild, args: ["nodeTemplate", { isSignal: true }] }], tableViewChild: [{ type: i0.ViewChild, args: [i0.forwardRef(() => UITableView), { isSignal: true }] }] } });
484
+
485
+ /**
486
+ * A single panel inside a {@link UIDashboard}.
487
+ *
488
+ * The panel renders a header bar (title + optional collapse / remove
489
+ * controls) and projects arbitrary content via `<ng-content>`.
490
+ *
491
+ * @example
492
+ * ```html
493
+ * <ui-dashboard-panel [config]="{ id: 'sales', title: 'Sales KPI', collapsible: true }">
494
+ * <p>Revenue chart goes here</p>
495
+ * </ui-dashboard-panel>
496
+ * ```
497
+ */
498
+ class UIDashboardPanel {
499
+ // ── Inputs ──────────────────────────────────────────────────────
500
+ /** Panel configuration (id, title, grid placement, flags). */
501
+ config = input.required(...(ngDevMode ? [{ debugName: "config" }] : []));
502
+ // ── Outputs ─────────────────────────────────────────────────────
503
+ /** Emitted when the user removes (hides) this panel. */
504
+ panelRemoved = output();
505
+ /** Emitted when the collapsed state changes. */
506
+ collapsedChange = output();
507
+ // ── Computed ────────────────────────────────────────────────────
508
+ /** CSS `grid-column` value derived from placement config. */
509
+ gridColumn = computed(() => {
510
+ const span = this.config().placement?.colSpan ?? 1;
511
+ return span > 1 ? `span ${span}` : undefined;
512
+ }, ...(ngDevMode ? [{ debugName: "gridColumn" }] : []));
513
+ /** CSS `grid-row` value derived from placement config. */
514
+ gridRow = computed(() => {
515
+ const span = this.config().placement?.rowSpan ?? 1;
516
+ return span > 1 ? `span ${span}` : undefined;
517
+ }, ...(ngDevMode ? [{ debugName: "gridRow" }] : []));
518
+ /** Whether the panel header shows a collapse toggle. */
519
+ isCollapsible = computed(() => this.config().collapsible === true, ...(ngDevMode ? [{ debugName: "isCollapsible" }] : []));
520
+ /** Whether the panel header shows a remove button. */
521
+ isRemovable = computed(() => this.config().removable === true, ...(ngDevMode ? [{ debugName: "isRemovable" }] : []));
522
+ /** SVG icon for the collapse toggle button. */
523
+ collapseIcon = computed(() => this.collapsed()
524
+ ? UIIcons.Lucide.Arrows.ChevronRight
525
+ : UIIcons.Lucide.Arrows.ChevronDown, ...(ngDevMode ? [{ debugName: "collapseIcon" }] : []));
526
+ /** SVG icon for the remove button. */
527
+ removeIcon = UIIcons.Lucide.Math.X;
528
+ // ── Public fields ───────────────────────────────────────────────
529
+ /** Whether the panel body is currently collapsed. */
530
+ collapsed = signal(false, ...(ngDevMode ? [{ debugName: "collapsed" }] : []));
531
+ /** Whether the panel has been removed (hidden) by the user. */
532
+ removed = signal(false, ...(ngDevMode ? [{ debugName: "removed" }] : []));
533
+ /** Whether the panel currently has an active notification. */
534
+ notified = signal(false, ...(ngDevMode ? [{ debugName: "notified" }] : []));
535
+ // ── Private fields ──────────────────────────────────────────────
536
+ log = inject(LoggerFactory).createLogger("UIDashboardPanel");
537
+ destroyRef = inject(DestroyRef);
538
+ notificationTimer = null;
539
+ // ── Public methods ──────────────────────────────────────────────
540
+ constructor() {
541
+ this.destroyRef.onDestroy(() => this.clearNotificationTimer());
542
+ }
543
+ // ── Public methods ──────────────────────────────────────────────
544
+ /**
545
+ * Activate a notification on this panel.
546
+ *
547
+ * The notification accent remains visible until the panel is
548
+ * focused (expanded) or the optional `timeoutMs` elapses.
549
+ *
550
+ * @param timeoutMs - Auto-clear delay in milliseconds. When `0`
551
+ * or omitted the notification persists until manually cleared.
552
+ */
553
+ notify(timeoutMs = 0) {
554
+ this.clearNotificationTimer();
555
+ this.notified.set(true);
556
+ this.log.debug("Panel notified", [this.config().id, timeoutMs]);
557
+ if (timeoutMs > 0) {
558
+ this.notificationTimer = setTimeout(() => {
559
+ this.clearNotification();
560
+ }, timeoutMs);
561
+ }
562
+ }
563
+ /** Clear the active notification. */
564
+ clearNotification() {
565
+ this.clearNotificationTimer();
566
+ this.notified.set(false);
567
+ }
568
+ /** Toggle the collapsed state. */
569
+ toggleCollapse() {
570
+ if (!this.isCollapsible())
571
+ return;
572
+ this.collapsed.update((v) => !v);
573
+ this.collapsedChange.emit(this.collapsed());
574
+ // Expanding a panel clears its notification
575
+ if (!this.collapsed()) {
576
+ this.clearNotification();
577
+ }
578
+ this.log.debug("Panel collapse toggled", [
579
+ this.config().id,
580
+ this.collapsed(),
581
+ ]);
582
+ }
583
+ /** Remove (hide) the panel. */
584
+ remove() {
585
+ if (!this.isRemovable())
586
+ return;
587
+ this.removed.set(true);
588
+ this.panelRemoved.emit(this.config().id);
589
+ this.log.debug("Panel removed", [this.config().id]);
590
+ }
591
+ /** Restore a previously removed panel. */
592
+ restore() {
593
+ this.removed.set(false);
594
+ this.log.debug("Panel restored", [this.config().id]);
595
+ }
596
+ // ── Private methods ─────────────────────────────────────────────
597
+ clearNotificationTimer() {
598
+ if (this.notificationTimer !== null) {
599
+ clearTimeout(this.notificationTimer);
600
+ this.notificationTimer = null;
601
+ }
602
+ }
603
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIDashboardPanel, deps: [], target: i0.ɵɵFactoryTarget.Component });
604
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIDashboardPanel, isStandalone: true, selector: "ui-dashboard-panel", inputs: { config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { panelRemoved: "panelRemoved", collapsedChange: "collapsedChange" }, host: { properties: { "class.collapsed": "collapsed()", "class.removed": "removed()", "class.notified": "notified()", "style.grid-column": "gridColumn()", "style.grid-row": "gridRow()", "attr.data-panel-id": "config().id" }, classAttribute: "ui-dashboard-panel" }, providers: [{ provide: UI_DEFAULT_SURFACE_TYPE, useValue: "panel" }], hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "<header class=\"header\">\n <div class=\"heading\">\n @if (config().icon) {\n <ui-icon [svg]=\"config().icon!\" [size]=\"14\" class=\"icon\" />\n }\n <h3 class=\"title\">{{ config().title }}</h3>\n </div>\n\n <div class=\"actions\">\n @if (isCollapsible()) {\n <button\n type=\"button\"\n class=\"action\"\n [attr.aria-label]=\"collapsed() ? 'Expand panel' : 'Collapse panel'\"\n [attr.aria-expanded]=\"!collapsed()\"\n (click)=\"toggleCollapse()\"\n >\n <ui-icon [svg]=\"collapseIcon()\" [size]=\"14\" />\n </button>\n }\n @if (isRemovable()) {\n <button\n type=\"button\"\n class=\"action action--remove\"\n aria-label=\"Remove panel\"\n (click)=\"remove()\"\n >\n <ui-icon [svg]=\"removeIcon\" [size]=\"14\" />\n </button>\n }\n </div>\n</header>\n\n@if (!collapsed()) {\n <div class=\"body\">\n <ng-content />\n </div>\n}\n", styles: [":host{--ui-panel-radius: 8px;--ui-panel-shadow: 0 1px 3px rgba(0, 0, 0, .08), 0 2px 8px rgba(0, 0, 0, .06);display:flex;flex-direction:column;border-radius:var(--ui-panel-radius);overflow:hidden;min-width:0;min-height:0}:host.removed{display:none}:host-context(html.dark-theme){--ui-panel-shadow: 0 1px 3px rgba(0, 0, 0, .3), 0 2px 8px rgba(0, 0, 0, .2)}@media(prefers-color-scheme:dark){:host-context(html:not(.light-theme):not(.dark-theme)){--ui-panel-shadow: 0 1px 3px rgba(0, 0, 0, .3), 0 2px 8px rgba(0, 0, 0, .2)}}.header{display:flex;align-items:center;justify-content:space-between;padding:.625rem .875rem;flex-shrink:0}.heading{display:flex;align-items:center;gap:6px;min-width:0}.icon{flex-shrink:0}.title{margin:0;font-size:.82rem;font-weight:600;letter-spacing:.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.actions{display:flex;align-items:center;gap:2px;flex-shrink:0;margin-left:.5rem}.action{appearance:none;border:none;background:none;cursor:pointer;font-family:var(--ui-font, inherit);display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:4px;font-size:.7rem;transition:background-color .15s,color .15s}.body{flex:1;overflow:auto;padding:.875rem;min-height:0}:host(.collapsed){display:none}:host(.notified){animation:ui-panel-pulse 2s ease-in-out infinite}:host(.notified) .icon{animation:ui-icon-pulse 2s ease-in-out infinite}@keyframes ui-icon-pulse{0%,to{opacity:1}50%{opacity:.5}}\n"], dependencies: [{ kind: "component", type: UIIcon, selector: "ui-icon", inputs: ["svg", "size", "ariaLabel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
605
+ }
606
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIDashboardPanel, decorators: [{
607
+ type: Component,
608
+ args: [{ selector: "ui-dashboard-panel", standalone: true, imports: [UIIcon], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ["surfaceType"] }], providers: [{ provide: UI_DEFAULT_SURFACE_TYPE, useValue: "panel" }], host: {
609
+ class: "ui-dashboard-panel",
610
+ "[class.collapsed]": "collapsed()",
611
+ "[class.removed]": "removed()",
612
+ "[class.notified]": "notified()",
613
+ "[style.grid-column]": "gridColumn()",
614
+ "[style.grid-row]": "gridRow()",
615
+ "[attr.data-panel-id]": "config().id",
616
+ }, template: "<header class=\"header\">\n <div class=\"heading\">\n @if (config().icon) {\n <ui-icon [svg]=\"config().icon!\" [size]=\"14\" class=\"icon\" />\n }\n <h3 class=\"title\">{{ config().title }}</h3>\n </div>\n\n <div class=\"actions\">\n @if (isCollapsible()) {\n <button\n type=\"button\"\n class=\"action\"\n [attr.aria-label]=\"collapsed() ? 'Expand panel' : 'Collapse panel'\"\n [attr.aria-expanded]=\"!collapsed()\"\n (click)=\"toggleCollapse()\"\n >\n <ui-icon [svg]=\"collapseIcon()\" [size]=\"14\" />\n </button>\n }\n @if (isRemovable()) {\n <button\n type=\"button\"\n class=\"action action--remove\"\n aria-label=\"Remove panel\"\n (click)=\"remove()\"\n >\n <ui-icon [svg]=\"removeIcon\" [size]=\"14\" />\n </button>\n }\n </div>\n</header>\n\n@if (!collapsed()) {\n <div class=\"body\">\n <ng-content />\n </div>\n}\n", styles: [":host{--ui-panel-radius: 8px;--ui-panel-shadow: 0 1px 3px rgba(0, 0, 0, .08), 0 2px 8px rgba(0, 0, 0, .06);display:flex;flex-direction:column;border-radius:var(--ui-panel-radius);overflow:hidden;min-width:0;min-height:0}:host.removed{display:none}:host-context(html.dark-theme){--ui-panel-shadow: 0 1px 3px rgba(0, 0, 0, .3), 0 2px 8px rgba(0, 0, 0, .2)}@media(prefers-color-scheme:dark){:host-context(html:not(.light-theme):not(.dark-theme)){--ui-panel-shadow: 0 1px 3px rgba(0, 0, 0, .3), 0 2px 8px rgba(0, 0, 0, .2)}}.header{display:flex;align-items:center;justify-content:space-between;padding:.625rem .875rem;flex-shrink:0}.heading{display:flex;align-items:center;gap:6px;min-width:0}.icon{flex-shrink:0}.title{margin:0;font-size:.82rem;font-weight:600;letter-spacing:.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.actions{display:flex;align-items:center;gap:2px;flex-shrink:0;margin-left:.5rem}.action{appearance:none;border:none;background:none;cursor:pointer;font-family:var(--ui-font, inherit);display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:4px;font-size:.7rem;transition:background-color .15s,color .15s}.body{flex:1;overflow:auto;padding:.875rem;min-height:0}:host(.collapsed){display:none}:host(.notified){animation:ui-panel-pulse 2s ease-in-out infinite}:host(.notified) .icon{animation:ui-icon-pulse 2s ease-in-out infinite}@keyframes ui-icon-pulse{0%,to{opacity:1}50%{opacity:.5}}\n"] }]
617
+ }], ctorParameters: () => [], propDecorators: { config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", required: true }] }], panelRemoved: [{ type: i0.Output, args: ["panelRemoved"] }], collapsedChange: [{ type: i0.Output, args: ["collapsedChange"] }] } });
618
+
619
+ /**
620
+ * Dashboard host component.
621
+ *
622
+ * Lays out projected {@link UIDashboardPanel} children on a CSS grid.
623
+ * The grid column count is configurable via the `columns` input — either
624
+ * a fixed number or `'auto'` for responsive auto-fill.
625
+ *
626
+ * Panels declare their own grid span via their `config.placement`
627
+ * property. The host is intentionally *content-agnostic*: it provides
628
+ * the grid shell and panel management (collapse, remove, restore)
629
+ * while consumers project whatever widgets they need.
630
+ *
631
+ * @example
632
+ * ```html
633
+ * <ui-dashboard [columns]="3" [gap]="16">
634
+ * <ui-dashboard-panel [config]="{ id: 'kpi', title: 'KPI', placement: { colSpan: 2 } }">
635
+ * <my-kpi-widget />
636
+ * </ui-dashboard-panel>
637
+ *
638
+ * <ui-dashboard-panel [config]="{ id: 'chart', title: 'Revenue' }">
639
+ * <my-chart />
640
+ * </ui-dashboard-panel>
641
+ *
642
+ * <ui-dashboard-panel [config]="{ id: 'feed', title: 'Activity', placement: { colSpan: 3 } }">
643
+ * <my-activity-feed />
644
+ * </ui-dashboard-panel>
645
+ * </ui-dashboard>
646
+ * ```
647
+ */
648
+ class UIDashboard {
649
+ // ── Inputs ──────────────────────────────────────────────────────
650
+ /**
651
+ * Number of grid columns.
652
+ *
653
+ * - A number (e.g. `3`) creates a fixed column layout.
654
+ * - `'auto'` uses responsive `auto-fill` with `minmax(280px, 1fr)`.
655
+ *
656
+ * Defaults to `'auto'`.
657
+ */
658
+ columns = input("auto", ...(ngDevMode ? [{ debugName: "columns" }] : []));
659
+ /**
660
+ * Gap between grid cells in pixels.
661
+ * Defaults to `16`.
662
+ */
663
+ gap = input(16, ...(ngDevMode ? [{ debugName: "gap" }] : []));
664
+ /**
665
+ * Minimum column width in pixels (only used when `columns` is `'auto'`).
666
+ * Defaults to `280`.
667
+ */
668
+ minColumnWidth = input(280, ...(ngDevMode ? [{ debugName: "minColumnWidth" }] : []));
669
+ /** Accessible label for the dashboard region. */
670
+ ariaLabel = input("Dashboard", ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
671
+ /**
672
+ * Where the collapsed-panel dock is rendered.
673
+ * Defaults to `'bottom'`.
674
+ */
675
+ dockPosition = input("bottom", ...(ngDevMode ? [{ debugName: "dockPosition" }] : []));
676
+ /**
677
+ * Whether dock chips show the panel title alongside the icon.
678
+ * Defaults to `false` (icon-only with title in tooltip).
679
+ */
680
+ dockShowTitles = input(false, ...(ngDevMode ? [{ debugName: "dockShowTitles" }] : []));
681
+ /**
682
+ * Default SVG icon for panels that don't declare their own `icon`.
683
+ * Defaults to the Lucide `LayoutDashboard` icon.
684
+ */
685
+ defaultDockIcon = input(UIIcons.Lucide.Layout.LayoutDashboard, ...(ngDevMode ? [{ debugName: "defaultDockIcon" }] : []));
686
+ /**
687
+ * SVG icon for the optional dock menu button.
688
+ * When set, a menu button is rendered at the leading edge of the dock.
689
+ * When omitted (`undefined`), no menu button is shown.
690
+ */
691
+ dockMenuIcon = input(undefined, ...(ngDevMode ? [{ debugName: "dockMenuIcon" }] : []));
692
+ // ── Outputs ─────────────────────────────────────────────────────
693
+ /** Emitted when any panel is removed by the user. */
694
+ panelRemoved = output();
695
+ /** Emitted when the dock menu button is clicked. */
696
+ dockMenuClicked = output();
697
+ // ── Content queries ─────────────────────────────────────────────
698
+ /** All projected dashboard panels. */
699
+ panels = contentChildren(UIDashboardPanel, ...(ngDevMode ? [{ debugName: "panels" }] : []));
700
+ // ── Computed ────────────────────────────────────────────────────
701
+ /** CSS value for `grid-template-columns`. */
702
+ gridTemplateColumns = computed(() => {
703
+ const cols = this.columns();
704
+ if (cols === "auto") {
705
+ return `repeat(auto-fill, minmax(${this.minColumnWidth()}px, 1fr))`;
706
+ }
707
+ return `repeat(${cols}, 1fr)`;
708
+ }, ...(ngDevMode ? [{ debugName: "gridTemplateColumns" }] : []));
709
+ /** CSS value for `gap`. */
710
+ gridGap = computed(() => `${this.gap()}px`, ...(ngDevMode ? [{ debugName: "gridGap" }] : []));
711
+ /** Panels that have been removed by the user. */
712
+ removedPanelIds = computed(() => this.panels()
713
+ .filter((p) => p.removed())
714
+ .map((p) => p.config().id), ...(ngDevMode ? [{ debugName: "removedPanelIds" }] : []));
715
+ /** Panels that are currently collapsed (shown in the dock). */
716
+ collapsedPanels = computed(() => this.panels().filter((p) => p.collapsed() && !p.removed()), ...(ngDevMode ? [{ debugName: "collapsedPanels" }] : []));
717
+ // ── Public fields ───────────────────────────────────────────────
718
+ /** Whether the dock panel-picker menu is open. */
719
+ dockMenuOpen = signal(false, ...(ngDevMode ? [{ debugName: "dockMenuOpen" }] : []));
720
+ // ── Protected fields ────────────────────────────────────────────
721
+ // ── Private fields ──────────────────────────────────────────────
722
+ log = inject(LoggerFactory).createLogger("UIDashboard");
723
+ destroyRef = inject(DestroyRef);
724
+ elRef = inject((ElementRef));
725
+ /** Subscriptions to panel `panelRemoved` outputs. */
726
+ panelSubs = [];
727
+ // ── Constructor ─────────────────────────────────────────────────
728
+ constructor() {
729
+ // Close dock menu on outside click
730
+ const onDocClick = (e) => {
731
+ if (this.dockMenuOpen() &&
732
+ !this.elRef.nativeElement
733
+ .querySelector(".dock-menu-anchor")
734
+ ?.contains(e.target)) {
735
+ this.dockMenuOpen.set(false);
736
+ }
737
+ };
738
+ document.addEventListener("pointerdown", onDocClick);
739
+ this.destroyRef.onDestroy(() => document.removeEventListener("pointerdown", onDocClick));
740
+ // Whenever the projected panels change, subscribe to their
741
+ // panelRemoved outputs so the host can re-emit them.
742
+ effect(() => {
743
+ const panels = this.panels();
744
+ untracked(() => {
745
+ // Tear down previous subscriptions
746
+ for (const unsub of this.panelSubs) {
747
+ unsub();
748
+ }
749
+ this.panelSubs = [];
750
+ for (const panel of panels) {
751
+ const sub = panel.panelRemoved.subscribe((id) => {
752
+ this.panelRemoved.emit(id);
753
+ });
754
+ this.panelSubs.push(() => sub.unsubscribe());
755
+ }
756
+ });
757
+ });
758
+ // Clean up on destroy
759
+ this.destroyRef.onDestroy(() => {
760
+ for (const unsub of this.panelSubs) {
761
+ unsub();
762
+ }
763
+ this.panelSubs = [];
764
+ });
765
+ }
766
+ // ── Public methods ──────────────────────────────────────────────
767
+ /**
768
+ * Restore a previously removed panel by its id.
769
+ *
770
+ * @param panelId - The `config.id` of the panel to restore.
771
+ * @returns `true` if the panel was found and restored.
772
+ */
773
+ restorePanel(panelId) {
774
+ const panel = this.panels().find((p) => p.config().id === panelId);
775
+ if (panel) {
776
+ panel.restore();
777
+ this.log.debug("Panel restored via host", [panelId]);
778
+ return true;
779
+ }
780
+ return false;
781
+ }
782
+ /**
783
+ * Restore all removed panels.
784
+ */
785
+ restoreAll() {
786
+ for (const panel of this.panels()) {
787
+ if (panel.removed()) {
788
+ panel.restore();
789
+ }
790
+ }
791
+ this.log.debug("All panels restored");
792
+ }
793
+ /**
794
+ * Resolve the icon SVG for a panel, falling back to the default.
795
+ *
796
+ * @param panelIcon - The panel's own icon, if any.
797
+ * @returns SVG inner-content string.
798
+ */
799
+ resolveIcon(panelIcon) {
800
+ return panelIcon ?? this.defaultDockIcon();
801
+ }
802
+ /** Toggle the dock panel-picker menu. */
803
+ toggleDockMenu() {
804
+ this.dockMenuOpen.update((v) => !v);
805
+ this.dockMenuClicked.emit();
806
+ }
807
+ /** Close the dock panel-picker menu. */
808
+ closeDockMenu() {
809
+ this.dockMenuOpen.set(false);
810
+ }
811
+ /**
812
+ * Toggle a panel's collapsed state from the menu.
813
+ * If removed, restore it first.
814
+ */
815
+ menuTogglePanel(panel) {
816
+ if (panel.removed()) {
817
+ panel.restore();
818
+ }
819
+ else if (panel.collapsed()) {
820
+ panel.toggleCollapse();
821
+ }
822
+ else if (panel.config().collapsible) {
823
+ panel.toggleCollapse();
824
+ }
825
+ }
826
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIDashboard, deps: [], target: i0.ɵɵFactoryTarget.Component });
827
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIDashboard, isStandalone: true, selector: "ui-dashboard", inputs: { columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: false, transformFunction: null }, gap: { classPropertyName: "gap", publicName: "gap", isSignal: true, isRequired: false, transformFunction: null }, minColumnWidth: { classPropertyName: "minColumnWidth", publicName: "minColumnWidth", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, dockPosition: { classPropertyName: "dockPosition", publicName: "dockPosition", isSignal: true, isRequired: false, transformFunction: null }, dockShowTitles: { classPropertyName: "dockShowTitles", publicName: "dockShowTitles", isSignal: true, isRequired: false, transformFunction: null }, defaultDockIcon: { classPropertyName: "defaultDockIcon", publicName: "defaultDockIcon", isSignal: true, isRequired: false, transformFunction: null }, dockMenuIcon: { classPropertyName: "dockMenuIcon", publicName: "dockMenuIcon", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { panelRemoved: "panelRemoved", dockMenuClicked: "dockMenuClicked" }, host: { classAttribute: "ui-dashboard" }, queries: [{ propertyName: "panels", predicate: UIDashboardPanel, isSignal: true }], hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "@if (dockPosition() === 'top') {\n <div class=\"dock\" role=\"toolbar\" aria-label=\"Dashboard dock\">\n @if (dockMenuIcon()) {\n <div class=\"anchor\">\n <button\n type=\"button\"\n class=\"menu-btn\"\n [class.menu-btn--active]=\"dockMenuOpen()\"\n aria-label=\"Dashboard menu\"\n [attr.aria-expanded]=\"dockMenuOpen()\"\n aria-haspopup=\"menu\"\n (click)=\"toggleDockMenu()\"\n >\n <ui-icon [svg]=\"dockMenuIcon()!\" [size]=\"16\" />\n </button>\n @if (dockMenuOpen()) {\n <div class=\"menu\" role=\"menu\" aria-label=\"Panel picker\">\n @for (panel of panels(); track panel.config().id) {\n <button\n type=\"button\"\n class=\"item\"\n [class.item--hidden]=\"panel.collapsed() || panel.removed()\"\n role=\"menuitem\"\n (click)=\"menuTogglePanel(panel)\"\n >\n <ui-icon\n [svg]=\"resolveIcon(panel.config().icon)\"\n [size]=\"14\"\n class=\"item-icon\"\n />\n <span class=\"item-label\">{{ panel.config().title }}</span>\n @if (panel.collapsed() || panel.removed()) {\n <span class=\"item-badge\">hidden</span>\n }\n </button>\n }\n <ng-content select=\"[dockMenuItem]\" />\n </div>\n }\n </div>\n @if (collapsedPanels().length > 0) {\n <span class=\"separator\" aria-hidden=\"true\"></span>\n }\n }\n @for (panel of collapsedPanels(); track panel.config().id) {\n <button\n type=\"button\"\n class=\"chip\"\n [class.chip--notified]=\"panel.notified()\"\n [attr.title]=\"panel.config().title\"\n [attr.aria-label]=\"'Restore ' + panel.config().title\"\n (click)=\"panel.toggleCollapse()\"\n >\n <ui-icon [svg]=\"resolveIcon(panel.config().icon)\" [size]=\"16\" />\n @if (dockShowTitles()) {\n <span class=\"chip-label\">{{ panel.config().title }}</span>\n }\n </button>\n }\n </div>\n}\n\n<div\n class=\"grid\"\n [style.grid-template-columns]=\"gridTemplateColumns()\"\n [style.gap]=\"gridGap()\"\n [attr.aria-label]=\"ariaLabel()\"\n role=\"region\"\n>\n <ng-content />\n</div>\n\n@if (dockPosition() === 'bottom') {\n <div class=\"dock\" role=\"toolbar\" aria-label=\"Dashboard dock\">\n @if (dockMenuIcon()) {\n <div class=\"anchor\">\n <button\n type=\"button\"\n class=\"menu-btn\"\n [class.menu-btn--active]=\"dockMenuOpen()\"\n aria-label=\"Dashboard menu\"\n [attr.aria-expanded]=\"dockMenuOpen()\"\n aria-haspopup=\"menu\"\n (click)=\"toggleDockMenu()\"\n >\n <ui-icon [svg]=\"dockMenuIcon()!\" [size]=\"16\" />\n </button>\n @if (dockMenuOpen()) {\n <div class=\"menu menu--above\" role=\"menu\" aria-label=\"Panel picker\">\n @for (panel of panels(); track panel.config().id) {\n <button\n type=\"button\"\n class=\"item\"\n [class.item--hidden]=\"panel.collapsed() || panel.removed()\"\n role=\"menuitem\"\n (click)=\"menuTogglePanel(panel)\"\n >\n <ui-icon\n [svg]=\"resolveIcon(panel.config().icon)\"\n [size]=\"14\"\n class=\"item-icon\"\n />\n <span class=\"item-label\">{{ panel.config().title }}</span>\n @if (panel.collapsed() || panel.removed()) {\n <span class=\"item-badge\">hidden</span>\n }\n </button>\n }\n <ng-content select=\"[dockMenuItem]\" />\n </div>\n }\n </div>\n @if (collapsedPanels().length > 0) {\n <span class=\"separator\" aria-hidden=\"true\"></span>\n }\n }\n @for (panel of collapsedPanels(); track panel.config().id) {\n <button\n type=\"button\"\n class=\"chip\"\n [class.chip--notified]=\"panel.notified()\"\n [attr.title]=\"panel.config().title\"\n [attr.aria-label]=\"'Restore ' + panel.config().title\"\n (click)=\"panel.toggleCollapse()\"\n >\n <ui-icon [svg]=\"resolveIcon(panel.config().icon)\" [size]=\"16\" />\n @if (dockShowTitles()) {\n <span class=\"chip-label\">{{ panel.config().title }}</span>\n }\n </button>\n }\n </div>\n}\n", styles: [":host{display:block;font-family:system-ui,-apple-system,sans-serif}.grid{display:grid;grid-auto-rows:minmax(120px,auto)}.dock{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:8px;margin-top:12px}.dock:first-child{margin-top:0;margin-bottom:12px}.chip{appearance:none;border:none;background:none;cursor:pointer;font-family:var(--ui-font, inherit);display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:6px;font-size:.78rem;font-weight:500;white-space:nowrap;transition:background-color .15s,box-shadow .15s}.chip-label{line-height:1}.chip--notified{animation:ui-dock-chip-pulse 2s ease-in-out infinite}.chip--notified ui-icon{animation:ui-dock-icon-pulse 2s ease-in-out infinite}@keyframes ui-dock-icon-pulse{0%,to{opacity:1}50%{opacity:.5}}.menu-btn{appearance:none;border:none;background:none;cursor:pointer;font-family:var(--ui-font, inherit);display:inline-flex;align-items:center;justify-content:center;padding:6px;border-radius:6px;transition:background-color .15s,color .15s}.separator{width:1px;height:20px;flex-shrink:0}.anchor{position:relative}.menu{position:absolute;top:calc(100% + 6px);left:0;z-index:100;min-width:200px;padding:4px;border-radius:8px}.menu--above{top:auto;bottom:calc(100% + 6px)}.menu hr{border:none;height:1px;margin:4px 0}.item{appearance:none;border:none;background:none;cursor:pointer;font-family:var(--ui-font, inherit);display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;border-radius:6px;font-size:.8rem;font-weight:500;white-space:nowrap;text-align:left;transition:background-color .15s}.item--hidden{opacity:.5}.item--disabled{opacity:.4;pointer-events:none}.item-icon{flex-shrink:0}.item-label{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.item-badge{font-size:.65rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;padding:2px 6px;border-radius:4px;opacity:.7}\n"], dependencies: [{ kind: "component", type: UIIcon, selector: "ui-icon", inputs: ["svg", "size", "ariaLabel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
828
+ }
829
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIDashboard, decorators: [{
830
+ type: Component,
831
+ args: [{ selector: "ui-dashboard", standalone: true, imports: [UIIcon], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ['surfaceType'] }], host: {
832
+ class: "ui-dashboard",
833
+ }, template: "@if (dockPosition() === 'top') {\n <div class=\"dock\" role=\"toolbar\" aria-label=\"Dashboard dock\">\n @if (dockMenuIcon()) {\n <div class=\"anchor\">\n <button\n type=\"button\"\n class=\"menu-btn\"\n [class.menu-btn--active]=\"dockMenuOpen()\"\n aria-label=\"Dashboard menu\"\n [attr.aria-expanded]=\"dockMenuOpen()\"\n aria-haspopup=\"menu\"\n (click)=\"toggleDockMenu()\"\n >\n <ui-icon [svg]=\"dockMenuIcon()!\" [size]=\"16\" />\n </button>\n @if (dockMenuOpen()) {\n <div class=\"menu\" role=\"menu\" aria-label=\"Panel picker\">\n @for (panel of panels(); track panel.config().id) {\n <button\n type=\"button\"\n class=\"item\"\n [class.item--hidden]=\"panel.collapsed() || panel.removed()\"\n role=\"menuitem\"\n (click)=\"menuTogglePanel(panel)\"\n >\n <ui-icon\n [svg]=\"resolveIcon(panel.config().icon)\"\n [size]=\"14\"\n class=\"item-icon\"\n />\n <span class=\"item-label\">{{ panel.config().title }}</span>\n @if (panel.collapsed() || panel.removed()) {\n <span class=\"item-badge\">hidden</span>\n }\n </button>\n }\n <ng-content select=\"[dockMenuItem]\" />\n </div>\n }\n </div>\n @if (collapsedPanels().length > 0) {\n <span class=\"separator\" aria-hidden=\"true\"></span>\n }\n }\n @for (panel of collapsedPanels(); track panel.config().id) {\n <button\n type=\"button\"\n class=\"chip\"\n [class.chip--notified]=\"panel.notified()\"\n [attr.title]=\"panel.config().title\"\n [attr.aria-label]=\"'Restore ' + panel.config().title\"\n (click)=\"panel.toggleCollapse()\"\n >\n <ui-icon [svg]=\"resolveIcon(panel.config().icon)\" [size]=\"16\" />\n @if (dockShowTitles()) {\n <span class=\"chip-label\">{{ panel.config().title }}</span>\n }\n </button>\n }\n </div>\n}\n\n<div\n class=\"grid\"\n [style.grid-template-columns]=\"gridTemplateColumns()\"\n [style.gap]=\"gridGap()\"\n [attr.aria-label]=\"ariaLabel()\"\n role=\"region\"\n>\n <ng-content />\n</div>\n\n@if (dockPosition() === 'bottom') {\n <div class=\"dock\" role=\"toolbar\" aria-label=\"Dashboard dock\">\n @if (dockMenuIcon()) {\n <div class=\"anchor\">\n <button\n type=\"button\"\n class=\"menu-btn\"\n [class.menu-btn--active]=\"dockMenuOpen()\"\n aria-label=\"Dashboard menu\"\n [attr.aria-expanded]=\"dockMenuOpen()\"\n aria-haspopup=\"menu\"\n (click)=\"toggleDockMenu()\"\n >\n <ui-icon [svg]=\"dockMenuIcon()!\" [size]=\"16\" />\n </button>\n @if (dockMenuOpen()) {\n <div class=\"menu menu--above\" role=\"menu\" aria-label=\"Panel picker\">\n @for (panel of panels(); track panel.config().id) {\n <button\n type=\"button\"\n class=\"item\"\n [class.item--hidden]=\"panel.collapsed() || panel.removed()\"\n role=\"menuitem\"\n (click)=\"menuTogglePanel(panel)\"\n >\n <ui-icon\n [svg]=\"resolveIcon(panel.config().icon)\"\n [size]=\"14\"\n class=\"item-icon\"\n />\n <span class=\"item-label\">{{ panel.config().title }}</span>\n @if (panel.collapsed() || panel.removed()) {\n <span class=\"item-badge\">hidden</span>\n }\n </button>\n }\n <ng-content select=\"[dockMenuItem]\" />\n </div>\n }\n </div>\n @if (collapsedPanels().length > 0) {\n <span class=\"separator\" aria-hidden=\"true\"></span>\n }\n }\n @for (panel of collapsedPanels(); track panel.config().id) {\n <button\n type=\"button\"\n class=\"chip\"\n [class.chip--notified]=\"panel.notified()\"\n [attr.title]=\"panel.config().title\"\n [attr.aria-label]=\"'Restore ' + panel.config().title\"\n (click)=\"panel.toggleCollapse()\"\n >\n <ui-icon [svg]=\"resolveIcon(panel.config().icon)\" [size]=\"16\" />\n @if (dockShowTitles()) {\n <span class=\"chip-label\">{{ panel.config().title }}</span>\n }\n </button>\n }\n </div>\n}\n", styles: [":host{display:block;font-family:system-ui,-apple-system,sans-serif}.grid{display:grid;grid-auto-rows:minmax(120px,auto)}.dock{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:8px;margin-top:12px}.dock:first-child{margin-top:0;margin-bottom:12px}.chip{appearance:none;border:none;background:none;cursor:pointer;font-family:var(--ui-font, inherit);display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:6px;font-size:.78rem;font-weight:500;white-space:nowrap;transition:background-color .15s,box-shadow .15s}.chip-label{line-height:1}.chip--notified{animation:ui-dock-chip-pulse 2s ease-in-out infinite}.chip--notified ui-icon{animation:ui-dock-icon-pulse 2s ease-in-out infinite}@keyframes ui-dock-icon-pulse{0%,to{opacity:1}50%{opacity:.5}}.menu-btn{appearance:none;border:none;background:none;cursor:pointer;font-family:var(--ui-font, inherit);display:inline-flex;align-items:center;justify-content:center;padding:6px;border-radius:6px;transition:background-color .15s,color .15s}.separator{width:1px;height:20px;flex-shrink:0}.anchor{position:relative}.menu{position:absolute;top:calc(100% + 6px);left:0;z-index:100;min-width:200px;padding:4px;border-radius:8px}.menu--above{top:auto;bottom:calc(100% + 6px)}.menu hr{border:none;height:1px;margin:4px 0}.item{appearance:none;border:none;background:none;cursor:pointer;font-family:var(--ui-font, inherit);display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;border-radius:6px;font-size:.8rem;font-weight:500;white-space:nowrap;text-align:left;transition:background-color .15s}.item--hidden{opacity:.5}.item--disabled{opacity:.4;pointer-events:none}.item-icon{flex-shrink:0}.item-label{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.item-badge{font-size:.65rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;padding:2px 6px;border-radius:4px;opacity:.7}\n"] }]
834
+ }], ctorParameters: () => [], propDecorators: { columns: [{ type: i0.Input, args: [{ isSignal: true, alias: "columns", required: false }] }], gap: [{ type: i0.Input, args: [{ isSignal: true, alias: "gap", required: false }] }], minColumnWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "minColumnWidth", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], dockPosition: [{ type: i0.Input, args: [{ isSignal: true, alias: "dockPosition", required: false }] }], dockShowTitles: [{ type: i0.Input, args: [{ isSignal: true, alias: "dockShowTitles", required: false }] }], defaultDockIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaultDockIcon", required: false }] }], dockMenuIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "dockMenuIcon", required: false }] }], panelRemoved: [{ type: i0.Output, args: ["panelRemoved"] }], dockMenuClicked: [{ type: i0.Output, args: ["dockMenuClicked"] }], panels: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => UIDashboardPanel), { isSignal: true }] }] } });
835
+
836
+ // ── Component ────────────────────────────────────────────────────────
837
+ /**
838
+ * A full-page navigation layout that combines a {@link UIDrawer},
839
+ * a {@link UISidebarNav} with configurable items, a {@link UIBreadcrumb}
840
+ * path, and a main content area.
841
+ *
842
+ * Navigation items are provided as a tree — either via an
843
+ * {@link ITreeDatasource} for dynamic data, or via a convenience
844
+ * `items` input accepting `NavigationNode[]`.
845
+ *
846
+ * Tree nodes with **children** are rendered as collapsible
847
+ * `<ui-sidebar-group>` elements; leaf nodes render as
848
+ * `<ui-sidebar-item>` entries. The breadcrumb trail is built
849
+ * automatically from the node hierarchy.
850
+ *
851
+ * ### Data sources
852
+ *
853
+ * | Input | Type | Use case |
854
+ * |--------------|------------------------------------------|------------------------------|
855
+ * | `datasource` | `ITreeDatasource<NavigationNodeData>` | Dynamic / async data |
856
+ * | `items` | `NavigationNode[]` | Static in-memory tree |
857
+ *
858
+ * When both are set, `datasource` takes precedence.
859
+ *
860
+ * Use the `navItem()` and `navGroup()` factory functions (or
861
+ * `routesToNavigation()` for Angular Router integration) to build
862
+ * the node array ergonomically.
863
+ *
864
+ * @example
865
+ * ```html
866
+ * <ui-navigation-page
867
+ * [items]="navItems"
868
+ * [(activePage)]="currentPage"
869
+ * (navigated)="onNavigate($event)"
870
+ * >
871
+ * <ng-template #content let-node>
872
+ * <h2>{{ node.data.label }}</h2>
873
+ * </ng-template>
874
+ * </ui-navigation-page>
875
+ * ```
876
+ */
877
+ class UINavigationPage {
878
+ // ── Inputs ──────────────────────────────────────────────────────────
879
+ /**
880
+ * Tree datasource providing navigation nodes.
881
+ *
882
+ * Accepts any {@link ITreeDatasource} whose data payload is
883
+ * {@link NavigationNodeData}. Root nodes with `children` render as
884
+ * collapsible groups; leaf nodes render as sidebar items.
885
+ *
886
+ * Takes precedence over the `items` convenience input.
887
+ */
888
+ datasource = input(undefined, ...(ngDevMode ? [{ debugName: "datasource" }] : []));
889
+ /**
890
+ * Convenience: static array of navigation tree nodes.
891
+ *
892
+ * When set (and no `datasource` is provided), an internal
893
+ * `ArrayTreeDatasource` is created automatically.
894
+ *
895
+ * Build nodes with the `navItem()` / `navGroup()` factories
896
+ * or the `routesToNavigation()` utility.
897
+ */
898
+ items = input([], ...(ngDevMode ? [{ debugName: "items" }] : []));
899
+ /**
900
+ * Root breadcrumb label displayed as the first item in the trail.
901
+ * Clicking it navigates to the first leaf node.
902
+ */
903
+ rootLabel = input("Home", ...(ngDevMode ? [{ debugName: "rootLabel" }] : []));
904
+ /** Side the drawer slides in from. */
905
+ drawerPosition = input("left", ...(ngDevMode ? [{ debugName: "drawerPosition" }] : []));
906
+ /** Width of the drawer panel. */
907
+ drawerWidth = input("medium", ...(ngDevMode ? [{ debugName: "drawerWidth" }] : []));
908
+ /** Visual style for the breadcrumb trail. */
909
+ breadcrumbVariant = input("button", ...(ngDevMode ? [{ debugName: "breadcrumbVariant" }] : []));
910
+ /** Accessible label for the navigation landmark. */
911
+ ariaLabel = input("Page navigation", ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
912
+ /** Whether the drawer should always be visible (desktop layout). */
913
+ sidebarPinned = input(true, ...(ngDevMode ? [{ debugName: "sidebarPinned" }] : []));
914
+ /** Whether to show the sidebar toggle button before the breadcrumb. */
915
+ showSidebarToggle = input(true, ...(ngDevMode ? [{ debugName: "showSidebarToggle" }] : []));
916
+ /**
917
+ * Optional localStorage key for persisting the sidebar open/closed state.
918
+ *
919
+ * When set, the component reads the stored value on creation and
920
+ * writes back whenever `sidebarVisible` changes. Leave empty
921
+ * (the default) to disable persistence.
922
+ */
923
+ storageKey = input("", ...(ngDevMode ? [{ debugName: "storageKey" }] : []));
924
+ // ── Models ──────────────────────────────────────────────────────────
925
+ /** The currently active node id. Supports two-way binding. */
926
+ activePage = model("", ...(ngDevMode ? [{ debugName: "activePage" }] : []));
927
+ /** Whether the drawer is open (when not pinned). Supports two-way binding. */
928
+ drawerOpen = model(false, ...(ngDevMode ? [{ debugName: "drawerOpen" }] : []));
929
+ /** Whether the sidebar panel is visible. Supports two-way binding. Defaults to shown. */
930
+ sidebarVisible = model(true, ...(ngDevMode ? [{ debugName: "sidebarVisible" }] : []));
931
+ // ── Outputs ─────────────────────────────────────────────────────────
932
+ /** Emitted when the user navigates to a node. */
933
+ navigated = output();
934
+ // ── Content queries ─────────────────────────────────────────────────
935
+ /**
936
+ * Projected template for the main content area.
937
+ * Receives the current {@link NavigationNode} as the implicit context.
938
+ *
939
+ * ```html
940
+ * <ng-template #content let-node>
941
+ * <h2>{{ node.data.label }}</h2>
942
+ * </ng-template>
943
+ * ```
944
+ */
945
+ contentTemplate = contentChild("content", ...(ngDevMode ? [{ debugName: "contentTemplate" }] : []));
946
+ /** Projected sidebar items for custom content above the generated items. */
947
+ projectedItems = contentChildren(UISidebarItem, ...(ngDevMode ? [{ debugName: "projectedItems" }] : []));
948
+ // ── Computed ────────────────────────────────────────────────────────
949
+ /** Icon for the sidebar toggle button. @internal */
950
+ sidebarToggleIcon = computed(() => this.sidebarVisible()
951
+ ? UIIcons.Lucide.Arrows.PanelLeftClose
952
+ : UIIcons.Lucide.Arrows.PanelLeftOpen, ...(ngDevMode ? [{ debugName: "sidebarToggleIcon" }] : []));
953
+ // ── Private fields ──────────────────────────────────────────────────
954
+ storage = inject(StorageService);
955
+ // ── Lifecycle ───────────────────────────────────────────────────────
956
+ ngOnInit() {
957
+ const key = this.storageKey();
958
+ if (key) {
959
+ const stored = this.storage.getItem(key);
960
+ if (stored !== null) {
961
+ this.sidebarVisible.set(stored === "true");
962
+ }
963
+ }
964
+ }
965
+ // ── Public methods ──────────────────────────────────────────────────
966
+ /** Toggle the sidebar and persist the new state to storage. */
967
+ toggleSidebar() {
968
+ const next = !this.sidebarVisible();
969
+ this.sidebarVisible.set(next);
970
+ const key = this.storageKey();
971
+ if (key) {
972
+ this.storage.setItem(key, String(next));
973
+ }
974
+ }
975
+ /** Resolved datasource — from the input or auto-created from items. */
976
+ resolvedDatasource = computed(() => {
977
+ const ds = this.datasource();
978
+ if (ds) {
979
+ return ds;
980
+ }
981
+ return new ArrayTreeDatasource(this.items());
982
+ }, ...(ngDevMode ? [{ debugName: "resolvedDatasource" }] : []));
983
+ /** Root nodes from the resolved datasource. */
984
+ rootNodes = computed(() => {
985
+ const ds = this.resolvedDatasource();
986
+ const roots = ds.getRootNodes();
987
+ // Support only synchronous datasources for now
988
+ return Array.isArray(roots) ? roots : [];
989
+ }, ...(ngDevMode ? [{ debugName: "rootNodes" }] : []));
990
+ /** The currently active navigation node. */
991
+ currentPage = computed(() => {
992
+ const id = this.activePage();
993
+ return this.findNodeById(id, this.rootNodes()) ?? this.firstLeaf();
994
+ }, ...(ngDevMode ? [{ debugName: "currentPage" }] : []));
995
+ /** Breadcrumb trail derived from the active node and its ancestors. */
996
+ breadcrumbItems = computed(() => {
997
+ const current = this.currentPage();
998
+ if (!current) {
999
+ return [{ label: this.rootLabel() }];
1000
+ }
1001
+ const items = [{ label: this.rootLabel(), url: "/" }];
1002
+ // If the node is a child of a group, add the group crumb
1003
+ const parent = this.findParent(current.id, this.rootNodes());
1004
+ if (parent) {
1005
+ items.push({ label: parent.data.label });
1006
+ }
1007
+ items.push({ label: current.data.label });
1008
+ return items;
1009
+ }, ...(ngDevMode ? [{ debugName: "breadcrumbItems" }] : []));
1010
+ // ── Public methods ──────────────────────────────────────────────────
1011
+ /** Navigate to a node by its tree-node reference. */
1012
+ navigate(node) {
1013
+ if (node.disabled) {
1014
+ return;
1015
+ }
1016
+ this.activePage.set(node.id);
1017
+ this.navigated.emit(node);
1018
+ // Auto-close drawer on navigation when not pinned
1019
+ if (!this.sidebarPinned()) {
1020
+ this.drawerOpen.set(false);
1021
+ }
1022
+ }
1023
+ /** Navigate to the first leaf node (root breadcrumb click handler). */
1024
+ navigateToRoot() {
1025
+ const first = this.firstLeaf();
1026
+ if (first) {
1027
+ this.navigate(first);
1028
+ }
1029
+ }
1030
+ /** @internal Handle breadcrumb item clicks. */
1031
+ onBreadcrumbClick(item) {
1032
+ if (item.url === "/") {
1033
+ this.navigateToRoot();
1034
+ return;
1035
+ }
1036
+ // Try to find a node matching the breadcrumb label
1037
+ const node = this.findNodeByLabel(item.label, this.rootNodes());
1038
+ if (node) {
1039
+ this.navigate(node);
1040
+ }
1041
+ }
1042
+ // ── Private helpers ─────────────────────────────────────────────────
1043
+ /**
1044
+ * Finds a node by `id` anywhere in the tree (depth-first).
1045
+ * @internal
1046
+ */
1047
+ findNodeById(id, nodes) {
1048
+ for (const node of nodes) {
1049
+ if (node.id === id) {
1050
+ return node;
1051
+ }
1052
+ if (node.children?.length) {
1053
+ const found = this.findNodeById(id, node.children);
1054
+ if (found) {
1055
+ return found;
1056
+ }
1057
+ }
1058
+ }
1059
+ return undefined;
1060
+ }
1061
+ /**
1062
+ * Finds a node by `data.label` anywhere in the tree (depth-first).
1063
+ * @internal
1064
+ */
1065
+ findNodeByLabel(label, nodes) {
1066
+ for (const node of nodes) {
1067
+ if (node.data.label === label) {
1068
+ return node;
1069
+ }
1070
+ if (node.children?.length) {
1071
+ const found = this.findNodeByLabel(label, node.children);
1072
+ if (found) {
1073
+ return found;
1074
+ }
1075
+ }
1076
+ }
1077
+ return undefined;
1078
+ }
1079
+ /**
1080
+ * Finds the parent node of a given child `id`.
1081
+ * @internal
1082
+ */
1083
+ findParent(childId, nodes) {
1084
+ for (const node of nodes) {
1085
+ if (node.children?.some((c) => c.id === childId)) {
1086
+ return node;
1087
+ }
1088
+ if (node.children?.length) {
1089
+ const found = this.findParent(childId, node.children);
1090
+ if (found) {
1091
+ return found;
1092
+ }
1093
+ }
1094
+ }
1095
+ return undefined;
1096
+ }
1097
+ /**
1098
+ * Returns the first leaf node (no children) in the tree.
1099
+ * @internal
1100
+ */
1101
+ firstLeaf() {
1102
+ const roots = this.rootNodes();
1103
+ for (const node of roots) {
1104
+ if (!node.children?.length) {
1105
+ return node;
1106
+ }
1107
+ // First child of a group
1108
+ const leaf = node.children.find((c) => !c.children?.length);
1109
+ if (leaf) {
1110
+ return leaf;
1111
+ }
1112
+ }
1113
+ return roots[0];
1114
+ }
1115
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UINavigationPage, deps: [], target: i0.ɵɵFactoryTarget.Component });
1116
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UINavigationPage, isStandalone: true, selector: "ui-navigation-page", inputs: { datasource: { classPropertyName: "datasource", publicName: "datasource", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, rootLabel: { classPropertyName: "rootLabel", publicName: "rootLabel", isSignal: true, isRequired: false, transformFunction: null }, drawerPosition: { classPropertyName: "drawerPosition", publicName: "drawerPosition", isSignal: true, isRequired: false, transformFunction: null }, drawerWidth: { classPropertyName: "drawerWidth", publicName: "drawerWidth", isSignal: true, isRequired: false, transformFunction: null }, breadcrumbVariant: { classPropertyName: "breadcrumbVariant", publicName: "breadcrumbVariant", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, sidebarPinned: { classPropertyName: "sidebarPinned", publicName: "sidebarPinned", isSignal: true, isRequired: false, transformFunction: null }, showSidebarToggle: { classPropertyName: "showSidebarToggle", publicName: "showSidebarToggle", isSignal: true, isRequired: false, transformFunction: null }, storageKey: { classPropertyName: "storageKey", publicName: "storageKey", isSignal: true, isRequired: false, transformFunction: null }, activePage: { classPropertyName: "activePage", publicName: "activePage", isSignal: true, isRequired: false, transformFunction: null }, drawerOpen: { classPropertyName: "drawerOpen", publicName: "drawerOpen", isSignal: true, isRequired: false, transformFunction: null }, sidebarVisible: { classPropertyName: "sidebarVisible", publicName: "sidebarVisible", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { activePage: "activePageChange", drawerOpen: "drawerOpenChange", sidebarVisible: "sidebarVisibleChange", navigated: "navigated" }, host: { properties: { "class.drawer-open": "drawerOpen()", "class.sidebar-hidden": "!sidebarVisible()" }, classAttribute: "ui-navigation-page" }, queries: [{ propertyName: "contentTemplate", first: true, predicate: ["content"], descendants: true, isSignal: true }, { propertyName: "projectedItems", predicate: UISidebarItem, isSignal: true }], hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "<div class=\"layout\">\n <!-- Sidebar (pinned or inside drawer) -->\n @if (sidebarPinned()) {\n <aside class=\"sidebar\" [class.sidebar--collapsed]=\"!sidebarVisible()\">\n <ng-content select=\"[uiSidebarHeader]\" />\n <ui-sidebar-nav [ariaLabel]=\"ariaLabel()\">\n <ng-content select=\"ui-sidebar-item\" />\n\n @for (node of rootNodes(); track node.id) {\n @if (node.children?.length) {\n <ui-sidebar-group\n [label]=\"node.data.label\"\n [icon]=\"node.icon ?? ''\"\n [expanded]=\"node.expanded ?? true\"\n >\n @for (child of node.children; track child.id) {\n <ui-sidebar-item\n [label]=\"child.data.label\"\n [icon]=\"child.icon ?? ''\"\n [badge]=\"child.data.badge ?? ''\"\n [active]=\"currentPage()?.id === child.id\"\n [disabled]=\"child.disabled ?? false\"\n (activated)=\"navigate(child)\"\n />\n }\n </ui-sidebar-group>\n } @else {\n <ui-sidebar-item\n [label]=\"node.data.label\"\n [icon]=\"node.icon ?? ''\"\n [badge]=\"node.data.badge ?? ''\"\n [active]=\"currentPage()?.id === node.id\"\n [disabled]=\"node.disabled ?? false\"\n (activated)=\"navigate(node)\"\n />\n }\n }\n </ui-sidebar-nav>\n <ng-content select=\"[uiSidebarFooter]\" />\n </aside>\n } @else {\n <ui-drawer\n [(open)]=\"drawerOpen\"\n [position]=\"drawerPosition()\"\n [width]=\"drawerWidth()\"\n [ariaLabel]=\"ariaLabel()\"\n >\n <ng-content select=\"[uiSidebarHeader]\" />\n <ui-sidebar-nav [ariaLabel]=\"ariaLabel()\">\n <ng-content select=\"ui-sidebar-item\" />\n\n @for (node of rootNodes(); track node.id) {\n @if (node.children?.length) {\n <ui-sidebar-group\n [label]=\"node.data.label\"\n [icon]=\"node.icon ?? ''\"\n [expanded]=\"node.expanded ?? true\"\n >\n @for (child of node.children; track child.id) {\n <ui-sidebar-item\n [label]=\"child.data.label\"\n [icon]=\"child.icon ?? ''\"\n [badge]=\"child.data.badge ?? ''\"\n [active]=\"currentPage()?.id === child.id\"\n [disabled]=\"child.disabled ?? false\"\n (activated)=\"navigate(child)\"\n />\n }\n </ui-sidebar-group>\n } @else {\n <ui-sidebar-item\n [label]=\"node.data.label\"\n [icon]=\"node.icon ?? ''\"\n [badge]=\"node.data.badge ?? ''\"\n [active]=\"currentPage()?.id === node.id\"\n [disabled]=\"node.disabled ?? false\"\n (activated)=\"navigate(node)\"\n />\n }\n }\n </ui-sidebar-nav>\n <ng-content select=\"[uiSidebarFooter]\" />\n </ui-drawer>\n }\n\n <!-- Main content area -->\n <main class=\"main\">\n <header class=\"breadcrumb\">\n @if (showSidebarToggle()) {\n <ui-button\n class=\"sidebar-toggle\"\n variant=\"ghost\"\n ariaLabel=\"Toggle sidebar\"\n (click)=\"toggleSidebar()\"\n >\n <ui-icon [svg]=\"sidebarToggleIcon()\" [size]=\"18\" />\n </ui-button>\n }\n <ui-breadcrumb\n surfaceType=\"panel\"\n [items]=\"breadcrumbItems()\"\n [variant]=\"breadcrumbVariant()\"\n ariaLabel=\"Page breadcrumb\"\n (itemClicked)=\"onBreadcrumbClick($event)\"\n />\n </header>\n\n <div class=\"content\">\n @if (contentTemplate(); as tpl) {\n <ng-container\n *ngTemplateOutlet=\"tpl; context: { $implicit: currentPage(), page: currentPage() }\"\n />\n } @else {\n <ng-content />\n }\n </div>\n </main>\n</div>\n", styles: ["@charset \"UTF-8\";:host{--ui-sidebar-width: 16rem;display:block;height:100%;width:100%}.layout{display:flex;height:100%;width:100%}.sidebar{display:flex;flex-direction:column;flex-shrink:0;width:var(--ui-sidebar-width);transition:width .25s ease,opacity .2s ease;opacity:1;overflow-y:auto;overscroll-behavior:contain;min-height:0;overflow-x:hidden}.sidebar ui-sidebar-nav{flex:1;min-height:0;height:auto}.sidebar--collapsed{width:0;opacity:0;overflow:hidden}.main{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden}.breadcrumb{display:flex;align-items:center;gap:.25rem;flex-shrink:0;padding:.75rem 1.25rem}.sidebar-toggle{flex-shrink:0}.content{flex:1;padding:1.5rem;overflow-y:auto}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: UIButton, selector: "ui-button", inputs: ["type", "variant", "color", "size", "pill", "disabled", "ariaLabel"] }, { kind: "component", type: UIDrawer, selector: "ui-drawer", inputs: ["open", "position", "width", "closeOnBackdropClick", "closeOnEscape", "ariaLabel"], outputs: ["openChange", "closed"] }, { kind: "component", type: UIIcon, selector: "ui-icon", inputs: ["svg", "size", "ariaLabel"] }, { kind: "component", type: UISidebarNav, selector: "ui-sidebar-nav", inputs: ["ariaLabel", "collapsed"] }, { kind: "component", type: UISidebarItem, selector: "ui-sidebar-item", inputs: ["label", "icon", "badge", "active", "disabled"], outputs: ["activated"] }, { kind: "component", type: UISidebarGroup, selector: "ui-sidebar-group", inputs: ["label", "icon", "expanded"], outputs: ["expandedChange"] }, { kind: "component", type: UIBreadcrumb, selector: "ui-breadcrumb", inputs: ["disabled", "items", "variant", "separator", "ariaLabel"], outputs: ["itemClicked"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1117
+ }
1118
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UINavigationPage, decorators: [{
1119
+ type: Component,
1120
+ args: [{ selector: "ui-navigation-page", standalone: true, imports: [
1121
+ NgTemplateOutlet,
1122
+ UIButton,
1123
+ UIDrawer,
1124
+ UIIcon,
1125
+ UISidebarNav,
1126
+ UISidebarItem,
1127
+ UISidebarGroup,
1128
+ UIBreadcrumb,
1129
+ ], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ["surfaceType"] }], host: {
1130
+ class: "ui-navigation-page",
1131
+ "[class.drawer-open]": "drawerOpen()",
1132
+ "[class.sidebar-hidden]": "!sidebarVisible()",
1133
+ }, template: "<div class=\"layout\">\n <!-- Sidebar (pinned or inside drawer) -->\n @if (sidebarPinned()) {\n <aside class=\"sidebar\" [class.sidebar--collapsed]=\"!sidebarVisible()\">\n <ng-content select=\"[uiSidebarHeader]\" />\n <ui-sidebar-nav [ariaLabel]=\"ariaLabel()\">\n <ng-content select=\"ui-sidebar-item\" />\n\n @for (node of rootNodes(); track node.id) {\n @if (node.children?.length) {\n <ui-sidebar-group\n [label]=\"node.data.label\"\n [icon]=\"node.icon ?? ''\"\n [expanded]=\"node.expanded ?? true\"\n >\n @for (child of node.children; track child.id) {\n <ui-sidebar-item\n [label]=\"child.data.label\"\n [icon]=\"child.icon ?? ''\"\n [badge]=\"child.data.badge ?? ''\"\n [active]=\"currentPage()?.id === child.id\"\n [disabled]=\"child.disabled ?? false\"\n (activated)=\"navigate(child)\"\n />\n }\n </ui-sidebar-group>\n } @else {\n <ui-sidebar-item\n [label]=\"node.data.label\"\n [icon]=\"node.icon ?? ''\"\n [badge]=\"node.data.badge ?? ''\"\n [active]=\"currentPage()?.id === node.id\"\n [disabled]=\"node.disabled ?? false\"\n (activated)=\"navigate(node)\"\n />\n }\n }\n </ui-sidebar-nav>\n <ng-content select=\"[uiSidebarFooter]\" />\n </aside>\n } @else {\n <ui-drawer\n [(open)]=\"drawerOpen\"\n [position]=\"drawerPosition()\"\n [width]=\"drawerWidth()\"\n [ariaLabel]=\"ariaLabel()\"\n >\n <ng-content select=\"[uiSidebarHeader]\" />\n <ui-sidebar-nav [ariaLabel]=\"ariaLabel()\">\n <ng-content select=\"ui-sidebar-item\" />\n\n @for (node of rootNodes(); track node.id) {\n @if (node.children?.length) {\n <ui-sidebar-group\n [label]=\"node.data.label\"\n [icon]=\"node.icon ?? ''\"\n [expanded]=\"node.expanded ?? true\"\n >\n @for (child of node.children; track child.id) {\n <ui-sidebar-item\n [label]=\"child.data.label\"\n [icon]=\"child.icon ?? ''\"\n [badge]=\"child.data.badge ?? ''\"\n [active]=\"currentPage()?.id === child.id\"\n [disabled]=\"child.disabled ?? false\"\n (activated)=\"navigate(child)\"\n />\n }\n </ui-sidebar-group>\n } @else {\n <ui-sidebar-item\n [label]=\"node.data.label\"\n [icon]=\"node.icon ?? ''\"\n [badge]=\"node.data.badge ?? ''\"\n [active]=\"currentPage()?.id === node.id\"\n [disabled]=\"node.disabled ?? false\"\n (activated)=\"navigate(node)\"\n />\n }\n }\n </ui-sidebar-nav>\n <ng-content select=\"[uiSidebarFooter]\" />\n </ui-drawer>\n }\n\n <!-- Main content area -->\n <main class=\"main\">\n <header class=\"breadcrumb\">\n @if (showSidebarToggle()) {\n <ui-button\n class=\"sidebar-toggle\"\n variant=\"ghost\"\n ariaLabel=\"Toggle sidebar\"\n (click)=\"toggleSidebar()\"\n >\n <ui-icon [svg]=\"sidebarToggleIcon()\" [size]=\"18\" />\n </ui-button>\n }\n <ui-breadcrumb\n surfaceType=\"panel\"\n [items]=\"breadcrumbItems()\"\n [variant]=\"breadcrumbVariant()\"\n ariaLabel=\"Page breadcrumb\"\n (itemClicked)=\"onBreadcrumbClick($event)\"\n />\n </header>\n\n <div class=\"content\">\n @if (contentTemplate(); as tpl) {\n <ng-container\n *ngTemplateOutlet=\"tpl; context: { $implicit: currentPage(), page: currentPage() }\"\n />\n } @else {\n <ng-content />\n }\n </div>\n </main>\n</div>\n", styles: ["@charset \"UTF-8\";:host{--ui-sidebar-width: 16rem;display:block;height:100%;width:100%}.layout{display:flex;height:100%;width:100%}.sidebar{display:flex;flex-direction:column;flex-shrink:0;width:var(--ui-sidebar-width);transition:width .25s ease,opacity .2s ease;opacity:1;overflow-y:auto;overscroll-behavior:contain;min-height:0;overflow-x:hidden}.sidebar ui-sidebar-nav{flex:1;min-height:0;height:auto}.sidebar--collapsed{width:0;opacity:0;overflow:hidden}.main{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden}.breadcrumb{display:flex;align-items:center;gap:.25rem;flex-shrink:0;padding:.75rem 1.25rem}.sidebar-toggle{flex-shrink:0}.content{flex:1;padding:1.5rem;overflow-y:auto}\n"] }]
1134
+ }], propDecorators: { datasource: [{ type: i0.Input, args: [{ isSignal: true, alias: "datasource", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], rootLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "rootLabel", required: false }] }], drawerPosition: [{ type: i0.Input, args: [{ isSignal: true, alias: "drawerPosition", required: false }] }], drawerWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "drawerWidth", required: false }] }], breadcrumbVariant: [{ type: i0.Input, args: [{ isSignal: true, alias: "breadcrumbVariant", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], sidebarPinned: [{ type: i0.Input, args: [{ isSignal: true, alias: "sidebarPinned", required: false }] }], showSidebarToggle: [{ type: i0.Input, args: [{ isSignal: true, alias: "showSidebarToggle", required: false }] }], storageKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "storageKey", required: false }] }], activePage: [{ type: i0.Input, args: [{ isSignal: true, alias: "activePage", required: false }] }, { type: i0.Output, args: ["activePageChange"] }], drawerOpen: [{ type: i0.Input, args: [{ isSignal: true, alias: "drawerOpen", required: false }] }, { type: i0.Output, args: ["drawerOpenChange"] }], sidebarVisible: [{ type: i0.Input, args: [{ isSignal: true, alias: "sidebarVisible", required: false }] }, { type: i0.Output, args: ["sidebarVisibleChange"] }], navigated: [{ type: i0.Output, args: ["navigated"] }], contentTemplate: [{ type: i0.ContentChild, args: ["content", { isSignal: true }] }], projectedItems: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => UISidebarItem), { isSignal: true }] }] } });
1135
+
1136
+ // ── Factory helpers ──────────────────────────────────────────────────
1137
+ /**
1138
+ * Creates a leaf navigation node (sidebar item).
1139
+ *
1140
+ * @param id - Unique identifier (also used as the active-page key).
1141
+ * @param label - Display text in the sidebar and breadcrumb.
1142
+ * @param options - Optional icon, badge, route, and disabled state.
1143
+ * @returns A {@link NavigationNode} without children.
1144
+ *
1145
+ * @example
1146
+ * ```ts
1147
+ * navItem('dashboard', 'Dashboard', {
1148
+ * icon: UIIcons.Lucide.Layout.LayoutDashboard,
1149
+ * badge: '3',
1150
+ * })
1151
+ * ```
1152
+ */
1153
+ function navItem(id, label, options) {
1154
+ return {
1155
+ id,
1156
+ data: {
1157
+ label,
1158
+ badge: options?.badge,
1159
+ route: options?.route,
1160
+ },
1161
+ icon: options?.icon,
1162
+ disabled: options?.disabled,
1163
+ };
1164
+ }
1165
+ /**
1166
+ * Creates a group navigation node (collapsible sidebar group).
1167
+ *
1168
+ * @param id - Unique identifier for the group.
1169
+ * @param label - Display text for the group header.
1170
+ * @param children - Child navigation nodes rendered inside the group.
1171
+ * @param options - Optional icon and expanded state.
1172
+ * @returns A {@link NavigationNode} with children.
1173
+ *
1174
+ * @example
1175
+ * ```ts
1176
+ * navGroup('settings', 'Settings', [
1177
+ * navItem('general', 'General'),
1178
+ * navItem('security', 'Security'),
1179
+ * ], { icon: UIIcons.Lucide.Account.Settings })
1180
+ * ```
1181
+ */
1182
+ function navGroup(id, label, children, options) {
1183
+ return {
1184
+ id,
1185
+ data: { label },
1186
+ icon: options?.icon,
1187
+ expanded: options?.expanded ?? true,
1188
+ children: [...children],
1189
+ };
1190
+ }
1191
+ /**
1192
+ * Converts an Angular-Router-compatible route config array into
1193
+ * {@link NavigationNode NavigationNode[]} for use with
1194
+ * {@link UINavigationPage}.
1195
+ *
1196
+ * Only routes that include `data: { navLabel: '…' }` are emitted.
1197
+ * Child routes are recursively processed, producing group nodes
1198
+ * when a parent has navigable children.
1199
+ *
1200
+ * @param routes - The route configuration (e.g. `inject(Router).config`).
1201
+ * @param parentPath - Internal: accumulated path prefix for nested routes.
1202
+ * @returns An array of navigation tree nodes.
1203
+ *
1204
+ * @example
1205
+ * ```ts
1206
+ * // In your app component:
1207
+ * private readonly router = inject(Router);
1208
+ * readonly navItems = routesToNavigation(this.router.config);
1209
+ * ```
1210
+ */
1211
+ function routesToNavigation(routes, parentPath = "") {
1212
+ const nodes = [];
1213
+ for (const route of routes) {
1214
+ const data = route.data;
1215
+ if (!data?.["navLabel"]) {
1216
+ // Skip routes without navigation metadata, but recurse into children
1217
+ if (route.children?.length) {
1218
+ const childPath = joinPath(parentPath, route.path);
1219
+ nodes.push(...routesToNavigation(route.children, childPath));
1220
+ }
1221
+ continue;
1222
+ }
1223
+ const fullPath = joinPath(parentPath, route.path);
1224
+ const children = route.children
1225
+ ? routesToNavigation(route.children, fullPath)
1226
+ : undefined;
1227
+ nodes.push({
1228
+ id: fullPath || "/",
1229
+ data: {
1230
+ label: data["navLabel"],
1231
+ badge: data["navBadge"] ?? undefined,
1232
+ route: fullPath || "/",
1233
+ },
1234
+ icon: data["navIcon"] ?? undefined,
1235
+ disabled: data["navDisabled"] ?? false,
1236
+ expanded: data["navExpanded"] ?? true,
1237
+ children: children?.length ? children : undefined,
1238
+ });
1239
+ }
1240
+ return nodes;
1241
+ }
1242
+ /** @internal Join two path segments, avoiding double slashes. */
1243
+ function joinPath(parent, segment) {
1244
+ if (!segment) {
1245
+ return parent;
1246
+ }
1247
+ if (!parent) {
1248
+ return segment;
1249
+ }
1250
+ return `${parent}/${segment}`.replace(/\/+/g, "/");
1251
+ }
1252
+
1253
+ /**
1254
+ * Manages persisted saved searches through the application-wide
1255
+ * {@link StorageService} (which defaults to `localStorage` via
1256
+ * the `STORAGE_STRATEGY` injection token).
1257
+ *
1258
+ * Searches are stored as a JSON array under a key derived from the
1259
+ * caller-supplied `storageKey`. Multiple independent search views can
1260
+ * coexist by using different storage keys.
1261
+ *
1262
+ * ### Usage
1263
+ *
1264
+ * ```ts
1265
+ * private readonly savedSearchService = inject(SavedSearchService);
1266
+ *
1267
+ * // list
1268
+ * const searches = this.savedSearchService.list('my-view');
1269
+ *
1270
+ * // save
1271
+ * this.savedSearchService.save('my-view', {
1272
+ * id: crypto.randomUUID(),
1273
+ * name: 'Active users',
1274
+ * descriptor: { junction: 'and', rules: [...] },
1275
+ * savedAt: new Date().toISOString(),
1276
+ * });
1277
+ *
1278
+ * // delete
1279
+ * this.savedSearchService.remove('my-view', searchId);
1280
+ * ```
1281
+ *
1282
+ * ### Swapping the storage backend
1283
+ *
1284
+ * Override the `STORAGE_STRATEGY` token from `@theredhead/lucid-foundation`:
1285
+ *
1286
+ * ```ts
1287
+ * providers: [
1288
+ * { provide: STORAGE_STRATEGY, useClass: MyIndexedDbStrategy },
1289
+ * ]
1290
+ * ```
1291
+ */
1292
+ class SavedSearchService {
1293
+ storage = inject(StorageService);
1294
+ /**
1295
+ * Retrieve all saved searches for the given storage key.
1296
+ *
1297
+ * Returns an empty array when nothing is stored or when the stored
1298
+ * value cannot be parsed.
1299
+ */
1300
+ list(storageKey) {
1301
+ const raw = this.storage.getItem(this.prefixedKey(storageKey));
1302
+ if (!raw)
1303
+ return [];
1304
+ try {
1305
+ const parsed = JSON.parse(raw);
1306
+ if (!Array.isArray(parsed))
1307
+ return [];
1308
+ return parsed;
1309
+ }
1310
+ catch {
1311
+ return [];
1312
+ }
1313
+ }
1314
+ /**
1315
+ * Persist a saved search. If a search with the same `id` already
1316
+ * exists it is replaced; otherwise the new search is appended.
1317
+ */
1318
+ save(storageKey, search) {
1319
+ const list = this.list(storageKey);
1320
+ const index = list.findIndex((s) => s.id === search.id);
1321
+ if (index >= 0) {
1322
+ list[index] = search;
1323
+ }
1324
+ else {
1325
+ list.push(search);
1326
+ }
1327
+ this.persist(storageKey, list);
1328
+ }
1329
+ /**
1330
+ * Remove a saved search by its `id`.
1331
+ *
1332
+ * No-op if the id does not exist.
1333
+ */
1334
+ remove(storageKey, searchId) {
1335
+ const list = this.list(storageKey);
1336
+ const filtered = list.filter((s) => s.id !== searchId);
1337
+ this.persist(storageKey, filtered);
1338
+ }
1339
+ /**
1340
+ * Persist a new ordering of saved searches.
1341
+ *
1342
+ * Accepts an array of IDs in the desired order. Searches whose
1343
+ * IDs are not in the list are dropped.
1344
+ */
1345
+ reorder(storageKey, orderedIds) {
1346
+ const list = this.list(storageKey);
1347
+ const map = new Map(list.map((s) => [s.id, s]));
1348
+ const reordered = orderedIds
1349
+ .map((id) => map.get(id))
1350
+ .filter((s) => s !== undefined);
1351
+ this.persist(storageKey, reordered);
1352
+ }
1353
+ /**
1354
+ * Remove all saved searches for a given storage key.
1355
+ */
1356
+ clear(storageKey) {
1357
+ this.storage.removeItem(this.prefixedKey(storageKey));
1358
+ }
1359
+ // ── Private helpers ─────────────────────────────────────────────
1360
+ prefixedKey(storageKey) {
1361
+ return `ui-saved-searches:${storageKey}`;
1362
+ }
1363
+ persist(storageKey, list) {
1364
+ this.storage.setItem(this.prefixedKey(storageKey), JSON.stringify(list));
1365
+ }
1366
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: SavedSearchService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1367
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: SavedSearchService, providedIn: "root" });
1368
+ }
1369
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: SavedSearchService, decorators: [{
1370
+ type: Injectable,
1371
+ args: [{ providedIn: "root" }]
1372
+ }] });
1373
+
1374
+ /**
1375
+ * A unified browse-and-filter layout that composes {@link UIFilter},
1376
+ * {@link UITableView} (or a custom results template), and
1377
+ * {@link UIPagination} into a single search screen.
1378
+ *
1379
+ * ### Table mode (default)
1380
+ * ```html
1381
+ * <ui-search-view [datasource]="ds" title="Users">
1382
+ * <ui-text-column key="name" headerText="Name" />
1383
+ * <ui-text-column key="email" headerText="Email" />
1384
+ * </ui-search-view>
1385
+ * ```
1386
+ *
1387
+ * ### Custom results template
1388
+ * ```html
1389
+ * <ui-search-view [datasource]="ds" layout="custom" [filterFields]="fields">
1390
+ * <ng-template #results let-items>
1391
+ * @for (item of items; track item.id) {
1392
+ * <app-card [data]="item" />
1393
+ * }
1394
+ * </ng-template>
1395
+ * </ui-search-view>
1396
+ * ```
1397
+ *
1398
+ * ### With custom filter template
1399
+ * ```html
1400
+ * <ui-search-view [datasource]="ds">
1401
+ * <ng-template #filter>
1402
+ * <my-custom-filter (change)="applyFilter($event)" />
1403
+ * </ng-template>
1404
+ * <ui-text-column key="name" headerText="Name" />
1405
+ * </ui-search-view>
1406
+ * ```
1407
+ */
1408
+ class UISearchView {
1409
+ // ── Inputs ────────────────────────────────────────────────────────
1410
+ /** Title displayed in the header area. */
1411
+ title = input("Results", ...(ngDevMode ? [{ debugName: "title" }] : []));
1412
+ /**
1413
+ * The datasource powering the search results.
1414
+ *
1415
+ * Accepts any {@link IDatasource}. When the datasource is a
1416
+ * {@link FilterableArrayDatasource} the component automatically
1417
+ * applies filter expressions from the embedded filter.
1418
+ */
1419
+ datasource = input(undefined, ...(ngDevMode ? [{ debugName: "datasource" }] : []));
1420
+ /**
1421
+ * Results layout mode.
1422
+ *
1423
+ * - `'table'` (default) — uses `<ui-table-view>` with projected columns.
1424
+ * - `'custom'` — renders the projected `#results` template.
1425
+ */
1426
+ layout = input("table", ...(ngDevMode ? [{ debugName: "layout" }] : []));
1427
+ /**
1428
+ * Explicit filter field definitions.
1429
+ *
1430
+ * When provided these override the auto-inferred fields derived
1431
+ * from projected columns and sample data.
1432
+ */
1433
+ filterFields = input(undefined, ...(ngDevMode ? [{ debugName: "filterFields" }] : []));
1434
+ /**
1435
+ * Whether the filter section is visible.
1436
+ *
1437
+ * - `true` — always show the filter.
1438
+ * - `false` — never show the filter.
1439
+ * - `undefined` (default) — auto-detect: show when the datasource
1440
+ * is a {@link FilterableArrayDatasource}.
1441
+ */
1442
+ showFilter = input(undefined, ...(ngDevMode ? [{ debugName: "showFilter" }] : []));
1443
+ /** Whether the filter section starts expanded. */
1444
+ filterExpanded = input(true, ...(ngDevMode ? [{ debugName: "filterExpanded" }] : []));
1445
+ /** Whether the filter toggle button is hidden. */
1446
+ filterModeLocked = input(false, ...(ngDevMode ? [{ debugName: "filterModeLocked" }] : []));
1447
+ /** Whether to show the pagination footer. */
1448
+ showPagination = input(true, ...(ngDevMode ? [{ debugName: "showPagination" }] : []));
1449
+ /** Items per page. */
1450
+ pageSize = input(25, ...(ngDevMode ? [{ debugName: "pageSize" }] : []));
1451
+ /** Available page-size options in the pagination selector. */
1452
+ pageSizeOptions = input([10, 25, 50, 100], ...(ngDevMode ? [{ debugName: "pageSizeOptions" }] : []));
1453
+ /** Placeholder text shown when there are no results. */
1454
+ placeholder = input("No results found", ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
1455
+ /** Accessible label for the component. */
1456
+ ariaLabel = input("Search view", ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
1457
+ /**
1458
+ * Storage key that enables the saved-searches feature.
1459
+ *
1460
+ * When set, a toolbar strip is shown allowing users to save, load,
1461
+ * and delete named filter states. The key is used to namespace the
1462
+ * persisted data in the underlying {@link StorageService}.
1463
+ *
1464
+ * Leave `undefined` (default) to disable saved searches.
1465
+ */
1466
+ storageKey = input(undefined, ...(ngDevMode ? [{ debugName: "storageKey" }] : []));
1467
+ // ── Outputs ───────────────────────────────────────────────────────
1468
+ /**
1469
+ * Emits the {@link FilterExpression} every time the filter rules change.
1470
+ */
1471
+ expressionChange = output();
1472
+ /** Emits when the page changes. */
1473
+ pageChange = output();
1474
+ /** Emits when a saved search is loaded, saved, or deleted. */
1475
+ savedSearchChange = output();
1476
+ // ── Models ────────────────────────────────────────────────────────
1477
+ /** The filter descriptor state (two-way bindable). */
1478
+ filterDescriptor = model({
1479
+ junction: "and",
1480
+ rules: [],
1481
+ }, ...(ngDevMode ? [{ debugName: "filterDescriptor" }] : []));
1482
+ /** Current page index (two-way bindable, zero-based). */
1483
+ pageIndex = model(0, ...(ngDevMode ? [{ debugName: "pageIndex" }] : []));
1484
+ // ── Content queries ───────────────────────────────────────────────
1485
+ /** Projected table-view columns (used in table layout mode). */
1486
+ columns = contentChildren(UITableViewColumn, ...(ngDevMode ? [{ debugName: "columns" }] : []));
1487
+ /** Optional custom results template (used in custom layout mode). */
1488
+ resultsTemplate = contentChild("results", ...(ngDevMode ? [{ debugName: "resultsTemplate" }] : []));
1489
+ /** Optional custom filter template. */
1490
+ filterTemplate = contentChild("filter", ...(ngDevMode ? [{ debugName: "filterTemplate" }] : []));
1491
+ /** Optional empty-state template. */
1492
+ emptyTemplate = contentChild("empty", ...(ngDevMode ? [{ debugName: "emptyTemplate" }] : []));
1493
+ // ── View queries ──────────────────────────────────────────────────
1494
+ /** @internal — reference to the embedded table-view (when in table layout). */
1495
+ tableView = viewChild(UITableView, ...(ngDevMode ? [{ debugName: "tableView" }] : []));
1496
+ // ── Computed ──────────────────────────────────────────────────────
1497
+ /** @internal */
1498
+ chevronRight = UIIcons.Lucide.Arrows.ChevronRight;
1499
+ /** @internal */
1500
+ chevronDown = UIIcons.Lucide.Arrows.ChevronDown;
1501
+ /** @internal — whether the filter panel is collapsed. */
1502
+ filterCollapsed = signal(false, ...(ngDevMode ? [{ debugName: "filterCollapsed" }] : []));
1503
+ /** @internal — total item count from the datasource. */
1504
+ totalItems = signal(0, ...(ngDevMode ? [{ debugName: "totalItems" }] : []));
1505
+ /** @internal */
1506
+ resolvedShowFilter = computed(() => {
1507
+ const explicit = this.showFilter();
1508
+ if (explicit !== undefined)
1509
+ return explicit;
1510
+ return this.datasource() instanceof FilterableArrayDatasource$1;
1511
+ }, ...(ngDevMode ? [{ debugName: "resolvedShowFilter" }] : []));
1512
+ /** @internal — auto-inferred or explicit filter fields. */
1513
+ resolvedFilterFields = computed(() => {
1514
+ const explicit = this.filterFields();
1515
+ if (explicit)
1516
+ return explicit;
1517
+ const ds = this.datasource();
1518
+ if (!ds)
1519
+ return [];
1520
+ const cols = this.columns();
1521
+ const columnMeta = cols.map((c) => ({
1522
+ key: c.key(),
1523
+ headerText: c.headerText(),
1524
+ }));
1525
+ // Prefer the full unfiltered list when available
1526
+ const allRows = ds instanceof FilterableArrayDatasource$1 ? ds.allRows : undefined;
1527
+ let sample;
1528
+ if (allRows && allRows.length > 0) {
1529
+ sample = allRows[0];
1530
+ }
1531
+ else {
1532
+ const count = ds.getNumberOfItems();
1533
+ if (typeof count !== "number" || count === 0)
1534
+ return [];
1535
+ const result = ds.getObjectAtRowIndex(0);
1536
+ if (!result || result instanceof Promise)
1537
+ return [];
1538
+ sample = result;
1539
+ }
1540
+ if (!sample)
1541
+ return [];
1542
+ return inferFilterFields(sample, columnMeta.length > 0 ? columnMeta : undefined);
1543
+ }, ...(ngDevMode ? [{ debugName: "resolvedFilterFields" }] : []));
1544
+ /** @internal — sample data for filter value suggestions. */
1545
+ resolvedFilterData = computed(() => {
1546
+ const ds = this.datasource();
1547
+ if (!ds)
1548
+ return [];
1549
+ if (ds instanceof FilterableArrayDatasource$1) {
1550
+ const all = ds.allRows;
1551
+ return all.length < 1000 ? all : [];
1552
+ }
1553
+ const count = ds.getNumberOfItems();
1554
+ if (typeof count !== "number" || count === 0 || count >= 1000)
1555
+ return [];
1556
+ const rows = [];
1557
+ for (let i = 0; i < count; i++) {
1558
+ const row = ds.getObjectAtRowIndex(i);
1559
+ if (!(row instanceof Promise))
1560
+ rows.push(row);
1561
+ }
1562
+ return rows;
1563
+ }, ...(ngDevMode ? [{ debugName: "resolvedFilterData" }] : []));
1564
+ // ── Saved-search state ──────────────────────────────────────────
1565
+ /** @internal — whether the saved-searches feature is enabled. */
1566
+ savedSearchesEnabled = computed(() => this.storageKey() !== undefined, ...(ngDevMode ? [{ debugName: "savedSearchesEnabled" }] : []));
1567
+ /** @internal — list of persisted saved searches. */
1568
+ savedSearches = signal([], ...(ngDevMode ? [{ debugName: "savedSearches" }] : []));
1569
+ /** @internal — ID of the currently selected saved search (empty = none). */
1570
+ selectedSearchId = signal("", ...(ngDevMode ? [{ debugName: "selectedSearchId" }] : []));
1571
+ /** @internal — name input value when saving a filter. */
1572
+ searchName = signal("", ...(ngDevMode ? [{ debugName: "searchName" }] : []));
1573
+ /** @internal — whether the save-filter dialog is open. */
1574
+ saveDialogOpen = signal(false, ...(ngDevMode ? [{ debugName: "saveDialogOpen" }] : []));
1575
+ /** @internal — index of the chip being dragged (−1 = none). */
1576
+ dragSourceIndex = -1;
1577
+ /**
1578
+ * @internal — bumped after every `filterBy` call so computeds
1579
+ * that depend on the datasource's filtered state re-evaluate.
1580
+ */
1581
+ _filterVersion = signal(0, ...(ngDevMode ? [{ debugName: "_filterVersion" }] : []));
1582
+ /**
1583
+ * @internal — the rows for the current page (filtered + paged).
1584
+ * Used only for custom layout mode where the table is not
1585
+ * available to handle paging internally.
1586
+ */
1587
+ pagedRows = computed(() => {
1588
+ this._filterVersion();
1589
+ const ds = this.datasource();
1590
+ if (!ds)
1591
+ return [];
1592
+ const total = ds.getNumberOfItems();
1593
+ if (typeof total !== "number" || total === 0)
1594
+ return [];
1595
+ if (!this.showPagination()) {
1596
+ const rows = [];
1597
+ for (let i = 0; i < total; i++) {
1598
+ const row = ds.getObjectAtRowIndex(i);
1599
+ if (!(row instanceof Promise))
1600
+ rows.push(row);
1601
+ }
1602
+ return rows;
1603
+ }
1604
+ const size = this.pageSize();
1605
+ const start = this.pageIndex() * size;
1606
+ const end = Math.min(start + size, total);
1607
+ const rows = [];
1608
+ for (let i = start; i < end; i++) {
1609
+ const row = ds.getObjectAtRowIndex(i);
1610
+ if (!(row instanceof Promise))
1611
+ rows.push(row);
1612
+ }
1613
+ return rows;
1614
+ }, ...(ngDevMode ? [{ debugName: "pagedRows" }] : []));
1615
+ savedSearchService = inject(SavedSearchService);
1616
+ // ── Constructor ───────────────────────────────────────────────────
1617
+ constructor() {
1618
+ this.filterCollapsed.set(!this.filterExpanded());
1619
+ effect(() => {
1620
+ this.filterCollapsed.set(!this.filterExpanded());
1621
+ });
1622
+ effect(() => {
1623
+ const ds = this.datasource();
1624
+ if (!ds) {
1625
+ this.totalItems.set(0);
1626
+ return;
1627
+ }
1628
+ const count = ds.getNumberOfItems();
1629
+ if (typeof count === "number") {
1630
+ this.totalItems.set(count);
1631
+ }
1632
+ });
1633
+ // Reload persisted saved searches whenever the storage key changes.
1634
+ effect(() => {
1635
+ const key = this.storageKey();
1636
+ if (key) {
1637
+ this.savedSearches.set(this.savedSearchService.list(key));
1638
+ }
1639
+ else {
1640
+ this.savedSearches.set([]);
1641
+ }
1642
+ this.selectedSearchId.set("");
1643
+ this.saveDialogOpen.set(false);
1644
+ });
1645
+ }
1646
+ // ── Public methods ────────────────────────────────────────────────
1647
+ /** Toggle the filter panel open/closed. */
1648
+ toggleFilter() {
1649
+ this.filterCollapsed.update((c) => !c);
1650
+ }
1651
+ /**
1652
+ * Load a saved search by its ID, applying its filter descriptor
1653
+ * to the search view.
1654
+ */
1655
+ loadSavedSearch(searchId) {
1656
+ if (!searchId) {
1657
+ this.selectedSearchId.set("");
1658
+ return;
1659
+ }
1660
+ const search = this.savedSearches().find((s) => s.id === searchId);
1661
+ if (search) {
1662
+ this.filterDescriptor.set(structuredClone(search.descriptor));
1663
+ this.selectedSearchId.set(search.id);
1664
+ this.savedSearchChange.emit(search);
1665
+ }
1666
+ }
1667
+ /**
1668
+ * Save the current filter state as a new named search.
1669
+ */
1670
+ saveNewSearch(name) {
1671
+ const key = this.storageKey();
1672
+ if (!key || !name.trim())
1673
+ return;
1674
+ const search = {
1675
+ id: crypto.randomUUID(),
1676
+ name: name.trim(),
1677
+ descriptor: structuredClone(this.filterDescriptor()),
1678
+ savedAt: new Date().toISOString(),
1679
+ };
1680
+ this.savedSearchService.save(key, search);
1681
+ this.savedSearches.set(this.savedSearchService.list(key));
1682
+ this.selectedSearchId.set(search.id);
1683
+ this.saveDialogOpen.set(false);
1684
+ this.searchName.set("");
1685
+ this.savedSearchChange.emit(search);
1686
+ }
1687
+ /**
1688
+ * Delete a saved search by its ID.
1689
+ */
1690
+ deleteSavedSearch(searchId) {
1691
+ const key = this.storageKey();
1692
+ if (!key)
1693
+ return;
1694
+ this.savedSearchService.remove(key, searchId);
1695
+ this.savedSearches.set(this.savedSearchService.list(key));
1696
+ if (this.selectedSearchId() === searchId) {
1697
+ this.selectedSearchId.set("");
1698
+ }
1699
+ this.savedSearchChange.emit(null);
1700
+ }
1701
+ // ── Protected methods ─────────────────────────────────────────────
1702
+ /** @internal */
1703
+ saveIcon = UIIcons.Lucide.Files.Save;
1704
+ /** @internal */
1705
+ xIcon = UIIcons.Lucide.Math.X;
1706
+ /** @internal */
1707
+ onLoadSearch(searchId) {
1708
+ this.loadSavedSearch(searchId);
1709
+ }
1710
+ /** @internal */
1711
+ onDeleteSearch(event, searchId) {
1712
+ event.stopPropagation();
1713
+ this.deleteSavedSearch(searchId);
1714
+ }
1715
+ /** @internal — opens the save-filter dialog. */
1716
+ onSaveFilterClick() {
1717
+ this.searchName.set("");
1718
+ this.saveDialogOpen.set(true);
1719
+ }
1720
+ /** @internal */
1721
+ onConfirmSave() {
1722
+ this.saveNewSearch(this.searchName());
1723
+ }
1724
+ /** @internal */
1725
+ onCancelSave() {
1726
+ this.saveDialogOpen.set(false);
1727
+ this.searchName.set("");
1728
+ }
1729
+ // ── Chip drag-and-drop reorder ────────────────────────────────────
1730
+ /** @internal */
1731
+ onChipDragStart(event, index) {
1732
+ this.dragSourceIndex = index;
1733
+ if (event.dataTransfer) {
1734
+ event.dataTransfer.effectAllowed = "move";
1735
+ }
1736
+ }
1737
+ /** @internal */
1738
+ onChipDragOver(event) {
1739
+ event.preventDefault();
1740
+ if (event.dataTransfer) {
1741
+ event.dataTransfer.dropEffect = "move";
1742
+ }
1743
+ }
1744
+ /** @internal */
1745
+ onChipDrop(event, targetIndex) {
1746
+ event.preventDefault();
1747
+ const from = this.dragSourceIndex;
1748
+ if (from < 0 || from === targetIndex)
1749
+ return;
1750
+ const key = this.storageKey();
1751
+ if (!key)
1752
+ return;
1753
+ const list = [...this.savedSearches()];
1754
+ const [moved] = list.splice(from, 1);
1755
+ list.splice(targetIndex, 0, moved);
1756
+ const orderedIds = list.map((s) => s.id);
1757
+ this.savedSearchService.reorder(key, orderedIds);
1758
+ this.savedSearches.set(this.savedSearchService.list(key));
1759
+ this.dragSourceIndex = -1;
1760
+ }
1761
+ /** @internal */
1762
+ onChipDragEnd() {
1763
+ this.dragSourceIndex = -1;
1764
+ }
1765
+ /** @internal */
1766
+ onFilterExpressionChange(expression) {
1767
+ this.expressionChange.emit(expression);
1768
+ const ds = this.datasource();
1769
+ if (ds instanceof FilterableArrayDatasource$1) {
1770
+ const compiled = toFilterExpression(expression, this.resolvedFilterFields());
1771
+ ds.filterBy(compiled.length === 0 ? null : compiled);
1772
+ const count = ds.getNumberOfItems();
1773
+ this.totalItems.set(typeof count === "number" ? count : 0);
1774
+ this.pageIndex.set(0);
1775
+ // Tell the table to rebuild its adapter so it picks up the
1776
+ // newly filtered datasource content.
1777
+ this.tableView()?.refreshDatasource();
1778
+ }
1779
+ this._filterVersion.update((v) => v + 1);
1780
+ }
1781
+ /** @internal */
1782
+ onPageChange(event) {
1783
+ this.pageChange.emit(event);
1784
+ }
1785
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UISearchView, deps: [], target: i0.ɵɵFactoryTarget.Component });
1786
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UISearchView, isStandalone: true, selector: "ui-search-view", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, datasource: { classPropertyName: "datasource", publicName: "datasource", isSignal: true, isRequired: false, transformFunction: null }, layout: { classPropertyName: "layout", publicName: "layout", isSignal: true, isRequired: false, transformFunction: null }, filterFields: { classPropertyName: "filterFields", publicName: "filterFields", isSignal: true, isRequired: false, transformFunction: null }, showFilter: { classPropertyName: "showFilter", publicName: "showFilter", isSignal: true, isRequired: false, transformFunction: null }, filterExpanded: { classPropertyName: "filterExpanded", publicName: "filterExpanded", isSignal: true, isRequired: false, transformFunction: null }, filterModeLocked: { classPropertyName: "filterModeLocked", publicName: "filterModeLocked", isSignal: true, isRequired: false, transformFunction: null }, showPagination: { classPropertyName: "showPagination", publicName: "showPagination", isSignal: true, isRequired: false, transformFunction: null }, pageSize: { classPropertyName: "pageSize", publicName: "pageSize", isSignal: true, isRequired: false, transformFunction: null }, pageSizeOptions: { classPropertyName: "pageSizeOptions", publicName: "pageSizeOptions", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, storageKey: { classPropertyName: "storageKey", publicName: "storageKey", isSignal: true, isRequired: false, transformFunction: null }, filterDescriptor: { classPropertyName: "filterDescriptor", publicName: "filterDescriptor", isSignal: true, isRequired: false, transformFunction: null }, pageIndex: { classPropertyName: "pageIndex", publicName: "pageIndex", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { expressionChange: "expressionChange", pageChange: "pageChange", savedSearchChange: "savedSearchChange", filterDescriptor: "filterDescriptorChange", pageIndex: "pageIndexChange" }, host: { classAttribute: "ui-search-view" }, queries: [{ propertyName: "columns", predicate: UITableViewColumn, isSignal: true }, { propertyName: "resultsTemplate", first: true, predicate: ["results"], descendants: true, isSignal: true }, { propertyName: "filterTemplate", first: true, predicate: ["filter"], descendants: true, isSignal: true }, { propertyName: "emptyTemplate", first: true, predicate: ["empty"], descendants: true, isSignal: true }], viewQueries: [{ propertyName: "tableView", first: true, predicate: UITableView, descendants: true, isSignal: true }], hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "<!-- Hidden slot: instantiates projected columns for contentChildren discovery. -->\n<div class=\"columns-slot\"><ng-content /></div>\n\n<header class=\"sv-header\">\n <h2 class=\"sv-title\">{{ title() }}</h2>\n <span class=\"sv-count\">{{ totalItems() }} items</span>\n</header>\n\n@if (resolvedShowFilter()) {\n <div class=\"sv-filter-bar\">\n @if (!filterModeLocked()) {\n <button\n class=\"sv-filter-toggle\"\n type=\"button\"\n (click)=\"toggleFilter()\"\n [attr.aria-expanded]=\"!filterCollapsed()\"\n aria-controls=\"sv-filter-content\"\n >\n <ui-icon\n [svg]=\"filterCollapsed() ? chevronRight : chevronDown\"\n [size]=\"14\"\n />\n <span>Filter</span>\n </button>\n }\n @if (!filterCollapsed()) {\n <div class=\"sv-filter-content\" id=\"sv-filter-content\">\n @if (filterTemplate(); as filterTpl) {\n <ng-container *ngTemplateOutlet=\"filterTpl\" />\n } @else {\n @if (resolvedFilterFields(); as fields) {\n @if (fields.length > 0) {\n <ui-filter\n [fields]=\"fields\"\n [data]=\"resolvedFilterData()\"\n [allowJunction]=\"true\"\n [modeLocked]=\"filterModeLocked()\"\n [showSaveButton]=\"savedSearchesEnabled()\"\n [(value)]=\"filterDescriptor\"\n (expressionChange)=\"onFilterExpressionChange($event)\"\n (saveFilter)=\"onSaveFilterClick()\"\n />\n }\n }\n }\n </div>\n }\n </div>\n}\n\n@if (savedSearchesEnabled() && savedSearches().length > 0) {\n <div\n class=\"sv-chip-strip\"\n role=\"listbox\"\n aria-label=\"Saved searches\"\n >\n @for (search of savedSearches(); track search.id; let idx = $index) {\n <button\n class=\"sv-chip\"\n type=\"button\"\n role=\"option\"\n [attr.aria-selected]=\"selectedSearchId() === search.id\"\n [class.sv-chip--active]=\"selectedSearchId() === search.id\"\n draggable=\"true\"\n (click)=\"onLoadSearch(search.id)\"\n (dragstart)=\"onChipDragStart($event, idx)\"\n (dragover)=\"onChipDragOver($event)\"\n (drop)=\"onChipDrop($event, idx)\"\n (dragend)=\"onChipDragEnd()\"\n >\n <span class=\"sv-chip-label\">{{ search.name }}</span>\n <span\n class=\"sv-chip-remove\"\n role=\"button\"\n tabindex=\"0\"\n [attr.aria-label]=\"'Delete ' + search.name\"\n (click)=\"onDeleteSearch($event, search.id)\"\n (keydown.enter)=\"onDeleteSearch($event, search.id)\"\n (keydown.space)=\"onDeleteSearch($event, search.id)\"\n >\n <ui-icon [svg]=\"xIcon\" [size]=\"10\" />\n </span>\n </button>\n }\n </div>\n}\n\n<div class=\"sv-results\" role=\"region\" [attr.aria-label]=\"ariaLabel()\">\n @if (layout() === 'table') {\n <div class=\"sv-table-wrapper\">\n <ui-table-view\n [datasource]=\"datasource()!\"\n [externalColumns]=\"columns()\"\n [showBuiltInPaginator]=\"false\"\n [showRowIndexIndicator]=\"false\"\n [pageSize]=\"pageSize()\"\n [pageIndex]=\"pageIndex()\"\n />\n @if (totalItems() === 0) {\n @if (emptyTemplate(); as emptyTpl) {\n <ng-container *ngTemplateOutlet=\"emptyTpl\" />\n } @else {\n <div class=\"sv-empty\">\n <p>{{ placeholder() }}</p>\n </div>\n }\n }\n </div>\n } @else if (totalItems() === 0) {\n @if (emptyTemplate(); as emptyTpl) {\n <ng-container *ngTemplateOutlet=\"emptyTpl\" />\n } @else {\n <div class=\"sv-empty\">\n <p>{{ placeholder() }}</p>\n </div>\n }\n } @else if (resultsTemplate(); as resultsTpl) {\n <div class=\"sv-custom-results\">\n <ng-container\n *ngTemplateOutlet=\"resultsTpl; context: { $implicit: pagedRows() }\"\n />\n </div>\n }\n</div>\n\n@if (showPagination() && totalItems() > 0) {\n <footer class=\"sv-footer\">\n <ui-pagination\n [totalItems]=\"totalItems()\"\n [pageSize]=\"pageSize()\"\n [pageSizeOptions]=\"pageSizeOptions()\"\n [(pageIndex)]=\"pageIndex\"\n (pageChange)=\"onPageChange($event)\"\n />\n </footer>\n}\n\n<ui-dialog [(open)]=\"saveDialogOpen\" ariaLabel=\"Save filter\">\n <ui-dialog-header>Save Filter</ui-dialog-header>\n <ui-dialog-body>\n <ui-input\n [(value)]=\"searchName\"\n placeholder=\"Filter name\"\n ariaLabel=\"Filter name\"\n />\n </ui-dialog-body>\n <ui-dialog-footer>\n <ui-button variant=\"ghost\" (click)=\"onCancelSave()\">Cancel</ui-button>\n <ui-button variant=\"filled\" (click)=\"onConfirmSave()\" [disabled]=\"!searchName().trim()\">Save</ui-button>\n </ui-dialog-footer>\n</ui-dialog>\n", styles: ["@charset \"UTF-8\";:host{--ui-radius: 6px;display:flex;flex-direction:column;height:100%;font-family:Cantarell,Noto Sans,Segoe UI,sans-serif}.columns-slot{display:none}.sv-header{display:flex;align-items:baseline;gap:.75rem;padding:.75rem 1rem;flex-shrink:0}.sv-title{margin:0;font-size:.9rem;font-weight:600;letter-spacing:.01em}.sv-count{font-size:.75rem}.sv-filter-bar{flex-shrink:0}.sv-chip-strip{display:flex;align-items:center;gap:.35rem;padding:.35rem 1rem;flex-shrink:0;overflow-x:auto;scrollbar-width:thin}.sv-chip{appearance:none;border:none;background:none;font-family:var(--ui-font, inherit);display:inline-flex;align-items:center;gap:.3rem;padding:.2rem .55rem;border-radius:999px;font-size:.75rem;font-weight:500;white-space:nowrap;cursor:pointer;-webkit-user-select:none;user-select:none;transition:background .15s,border-color .15s}.sv-chip[draggable=true]{cursor:grab}.sv-chip[draggable=true]:active{cursor:grabbing;opacity:.6}.sv-chip-label{pointer-events:none}.sv-chip-remove{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:50%;opacity:.6;transition:opacity .15s}.sv-chip-remove:hover{opacity:1}.sv-filter-toggle{appearance:none;border:none;background:none;cursor:pointer;font-family:var(--ui-font, inherit);display:flex;align-items:center;gap:6px;width:100%;padding:.45rem 1rem;font-size:.78rem;font-weight:500;text-align:left}.sv-filter-content ::ng-deep ui-filter{border:none;padding:.75rem 1rem}.sv-results{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column}.sv-table-wrapper{position:relative;flex:1;overflow:hidden;min-height:0}.sv-table-wrapper ::ng-deep ui-table-view{display:flex;flex-direction:column;height:100%}.sv-table-wrapper ::ng-deep .root{display:flex;flex-direction:column;flex:1;min-height:0;border-radius:0;border:none;box-shadow:none}.sv-table-wrapper ::ng-deep ui-table-body{flex:1;min-height:0}.sv-table-wrapper ::ng-deep .table-body-viewport{height:100%}.sv-custom-results{flex:1;overflow-y:auto;padding:1rem}.sv-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none}.sv-empty p{margin:0;font-size:.88rem}.sv-footer{flex-shrink:0;padding:.25rem .5rem}\n"], dependencies: [{ kind: "component", type: UIButton, selector: "ui-button", inputs: ["type", "variant", "color", "size", "pill", "disabled", "ariaLabel"] }, { kind: "component", type: UIDialog, selector: "ui-dialog", inputs: ["open", "closeOnBackdropClick", "closeOnEscape", "ariaLabel"], outputs: ["openChange", "closed"] }, { kind: "component", type: UIDialogBody, selector: "ui-dialog-body" }, { kind: "component", type: UIDialogFooter, selector: "ui-dialog-footer" }, { kind: "component", type: UIDialogHeader, selector: "ui-dialog-header" }, { kind: "component", type: UIFilter, selector: "ui-filter", inputs: ["disabled", "fields", "allowJunction", "allowSimple", "allowAdvanced", "modeLocked", "showSaveButton", "data", "value"], outputs: ["valueChange", "expressionChange", "saveFilter"] }, { kind: "component", type: UIInput, selector: "ui-input", inputs: ["type", "text", "value", "adapter", "placeholder", "disabled", "multiline", "rows", "heightAdjustable", "ariaLabel"], outputs: ["textChange", "valueChange"] }, { kind: "component", type: UITableView, selector: "ui-table-view", inputs: ["renderingStrategy", "disabled", "rowHeight", "datasource", "pageSize", "pageIndex", "showBuiltInPaginator", "caption", "showRowIndexIndicator", "rowIndexHeaderText", "tableId", "resizable", "selectionMode", "rowClickSelect", "showSelectionColumn", "selectionModel", "externalColumns"], outputs: ["selectionChange"] }, { kind: "component", type: UIPagination, selector: "ui-pagination", inputs: ["totalItems", "pageSize", "pageSizeOptions", "pageIndex", "disabled", "ariaLabel"], outputs: ["pageIndexChange", "pageChange"] }, { kind: "component", type: UIIcon, selector: "ui-icon", inputs: ["svg", "size", "ariaLabel"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1787
+ }
1788
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UISearchView, decorators: [{
1789
+ type: Component,
1790
+ args: [{ selector: "ui-search-view", standalone: true, imports: [
1791
+ UIButton,
1792
+ UIDialog,
1793
+ UIDialogBody,
1794
+ UIDialogFooter,
1795
+ UIDialogHeader,
1796
+ UIFilter,
1797
+ UIInput,
1798
+ UITableView,
1799
+ UIPagination,
1800
+ UIIcon,
1801
+ NgTemplateOutlet,
1802
+ ], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ["surfaceType"] }], host: {
1803
+ class: "ui-search-view",
1804
+ }, template: "<!-- Hidden slot: instantiates projected columns for contentChildren discovery. -->\n<div class=\"columns-slot\"><ng-content /></div>\n\n<header class=\"sv-header\">\n <h2 class=\"sv-title\">{{ title() }}</h2>\n <span class=\"sv-count\">{{ totalItems() }} items</span>\n</header>\n\n@if (resolvedShowFilter()) {\n <div class=\"sv-filter-bar\">\n @if (!filterModeLocked()) {\n <button\n class=\"sv-filter-toggle\"\n type=\"button\"\n (click)=\"toggleFilter()\"\n [attr.aria-expanded]=\"!filterCollapsed()\"\n aria-controls=\"sv-filter-content\"\n >\n <ui-icon\n [svg]=\"filterCollapsed() ? chevronRight : chevronDown\"\n [size]=\"14\"\n />\n <span>Filter</span>\n </button>\n }\n @if (!filterCollapsed()) {\n <div class=\"sv-filter-content\" id=\"sv-filter-content\">\n @if (filterTemplate(); as filterTpl) {\n <ng-container *ngTemplateOutlet=\"filterTpl\" />\n } @else {\n @if (resolvedFilterFields(); as fields) {\n @if (fields.length > 0) {\n <ui-filter\n [fields]=\"fields\"\n [data]=\"resolvedFilterData()\"\n [allowJunction]=\"true\"\n [modeLocked]=\"filterModeLocked()\"\n [showSaveButton]=\"savedSearchesEnabled()\"\n [(value)]=\"filterDescriptor\"\n (expressionChange)=\"onFilterExpressionChange($event)\"\n (saveFilter)=\"onSaveFilterClick()\"\n />\n }\n }\n }\n </div>\n }\n </div>\n}\n\n@if (savedSearchesEnabled() && savedSearches().length > 0) {\n <div\n class=\"sv-chip-strip\"\n role=\"listbox\"\n aria-label=\"Saved searches\"\n >\n @for (search of savedSearches(); track search.id; let idx = $index) {\n <button\n class=\"sv-chip\"\n type=\"button\"\n role=\"option\"\n [attr.aria-selected]=\"selectedSearchId() === search.id\"\n [class.sv-chip--active]=\"selectedSearchId() === search.id\"\n draggable=\"true\"\n (click)=\"onLoadSearch(search.id)\"\n (dragstart)=\"onChipDragStart($event, idx)\"\n (dragover)=\"onChipDragOver($event)\"\n (drop)=\"onChipDrop($event, idx)\"\n (dragend)=\"onChipDragEnd()\"\n >\n <span class=\"sv-chip-label\">{{ search.name }}</span>\n <span\n class=\"sv-chip-remove\"\n role=\"button\"\n tabindex=\"0\"\n [attr.aria-label]=\"'Delete ' + search.name\"\n (click)=\"onDeleteSearch($event, search.id)\"\n (keydown.enter)=\"onDeleteSearch($event, search.id)\"\n (keydown.space)=\"onDeleteSearch($event, search.id)\"\n >\n <ui-icon [svg]=\"xIcon\" [size]=\"10\" />\n </span>\n </button>\n }\n </div>\n}\n\n<div class=\"sv-results\" role=\"region\" [attr.aria-label]=\"ariaLabel()\">\n @if (layout() === 'table') {\n <div class=\"sv-table-wrapper\">\n <ui-table-view\n [datasource]=\"datasource()!\"\n [externalColumns]=\"columns()\"\n [showBuiltInPaginator]=\"false\"\n [showRowIndexIndicator]=\"false\"\n [pageSize]=\"pageSize()\"\n [pageIndex]=\"pageIndex()\"\n />\n @if (totalItems() === 0) {\n @if (emptyTemplate(); as emptyTpl) {\n <ng-container *ngTemplateOutlet=\"emptyTpl\" />\n } @else {\n <div class=\"sv-empty\">\n <p>{{ placeholder() }}</p>\n </div>\n }\n }\n </div>\n } @else if (totalItems() === 0) {\n @if (emptyTemplate(); as emptyTpl) {\n <ng-container *ngTemplateOutlet=\"emptyTpl\" />\n } @else {\n <div class=\"sv-empty\">\n <p>{{ placeholder() }}</p>\n </div>\n }\n } @else if (resultsTemplate(); as resultsTpl) {\n <div class=\"sv-custom-results\">\n <ng-container\n *ngTemplateOutlet=\"resultsTpl; context: { $implicit: pagedRows() }\"\n />\n </div>\n }\n</div>\n\n@if (showPagination() && totalItems() > 0) {\n <footer class=\"sv-footer\">\n <ui-pagination\n [totalItems]=\"totalItems()\"\n [pageSize]=\"pageSize()\"\n [pageSizeOptions]=\"pageSizeOptions()\"\n [(pageIndex)]=\"pageIndex\"\n (pageChange)=\"onPageChange($event)\"\n />\n </footer>\n}\n\n<ui-dialog [(open)]=\"saveDialogOpen\" ariaLabel=\"Save filter\">\n <ui-dialog-header>Save Filter</ui-dialog-header>\n <ui-dialog-body>\n <ui-input\n [(value)]=\"searchName\"\n placeholder=\"Filter name\"\n ariaLabel=\"Filter name\"\n />\n </ui-dialog-body>\n <ui-dialog-footer>\n <ui-button variant=\"ghost\" (click)=\"onCancelSave()\">Cancel</ui-button>\n <ui-button variant=\"filled\" (click)=\"onConfirmSave()\" [disabled]=\"!searchName().trim()\">Save</ui-button>\n </ui-dialog-footer>\n</ui-dialog>\n", styles: ["@charset \"UTF-8\";:host{--ui-radius: 6px;display:flex;flex-direction:column;height:100%;font-family:Cantarell,Noto Sans,Segoe UI,sans-serif}.columns-slot{display:none}.sv-header{display:flex;align-items:baseline;gap:.75rem;padding:.75rem 1rem;flex-shrink:0}.sv-title{margin:0;font-size:.9rem;font-weight:600;letter-spacing:.01em}.sv-count{font-size:.75rem}.sv-filter-bar{flex-shrink:0}.sv-chip-strip{display:flex;align-items:center;gap:.35rem;padding:.35rem 1rem;flex-shrink:0;overflow-x:auto;scrollbar-width:thin}.sv-chip{appearance:none;border:none;background:none;font-family:var(--ui-font, inherit);display:inline-flex;align-items:center;gap:.3rem;padding:.2rem .55rem;border-radius:999px;font-size:.75rem;font-weight:500;white-space:nowrap;cursor:pointer;-webkit-user-select:none;user-select:none;transition:background .15s,border-color .15s}.sv-chip[draggable=true]{cursor:grab}.sv-chip[draggable=true]:active{cursor:grabbing;opacity:.6}.sv-chip-label{pointer-events:none}.sv-chip-remove{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:50%;opacity:.6;transition:opacity .15s}.sv-chip-remove:hover{opacity:1}.sv-filter-toggle{appearance:none;border:none;background:none;cursor:pointer;font-family:var(--ui-font, inherit);display:flex;align-items:center;gap:6px;width:100%;padding:.45rem 1rem;font-size:.78rem;font-weight:500;text-align:left}.sv-filter-content ::ng-deep ui-filter{border:none;padding:.75rem 1rem}.sv-results{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column}.sv-table-wrapper{position:relative;flex:1;overflow:hidden;min-height:0}.sv-table-wrapper ::ng-deep ui-table-view{display:flex;flex-direction:column;height:100%}.sv-table-wrapper ::ng-deep .root{display:flex;flex-direction:column;flex:1;min-height:0;border-radius:0;border:none;box-shadow:none}.sv-table-wrapper ::ng-deep ui-table-body{flex:1;min-height:0}.sv-table-wrapper ::ng-deep .table-body-viewport{height:100%}.sv-custom-results{flex:1;overflow-y:auto;padding:1rem}.sv-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none}.sv-empty p{margin:0;font-size:.88rem}.sv-footer{flex-shrink:0;padding:.25rem .5rem}\n"] }]
1805
+ }], ctorParameters: () => [], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], datasource: [{ type: i0.Input, args: [{ isSignal: true, alias: "datasource", required: false }] }], layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: false }] }], filterFields: [{ type: i0.Input, args: [{ isSignal: true, alias: "filterFields", required: false }] }], showFilter: [{ type: i0.Input, args: [{ isSignal: true, alias: "showFilter", required: false }] }], filterExpanded: [{ type: i0.Input, args: [{ isSignal: true, alias: "filterExpanded", required: false }] }], filterModeLocked: [{ type: i0.Input, args: [{ isSignal: true, alias: "filterModeLocked", required: false }] }], showPagination: [{ type: i0.Input, args: [{ isSignal: true, alias: "showPagination", required: false }] }], pageSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageSize", required: false }] }], pageSizeOptions: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageSizeOptions", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], storageKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "storageKey", required: false }] }], expressionChange: [{ type: i0.Output, args: ["expressionChange"] }], pageChange: [{ type: i0.Output, args: ["pageChange"] }], savedSearchChange: [{ type: i0.Output, args: ["savedSearchChange"] }], filterDescriptor: [{ type: i0.Input, args: [{ isSignal: true, alias: "filterDescriptor", required: false }] }, { type: i0.Output, args: ["filterDescriptorChange"] }], pageIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageIndex", required: false }] }, { type: i0.Output, args: ["pageIndexChange"] }], columns: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => UITableViewColumn), { isSignal: true }] }], resultsTemplate: [{ type: i0.ContentChild, args: ["results", { isSignal: true }] }], filterTemplate: [{ type: i0.ContentChild, args: ["filter", { isSignal: true }] }], emptyTemplate: [{ type: i0.ContentChild, args: ["empty", { isSignal: true }] }], tableView: [{ type: i0.ViewChild, args: [i0.forwardRef(() => UITableView), { isSignal: true }] }] } });
1806
+
1807
+ /**
1808
+ * A key-value inspector panel that renders a schema of typed fields
1809
+ * against a data object.
1810
+ *
1811
+ * Each field definition maps to an appropriate editor widget
1812
+ * (`UIInput`, `UISelect`, `UICheckbox`, `UIColorPicker`, or
1813
+ * `UISlider`). Changes are emitted per-field via
1814
+ * {@link propertyChange} and the data model is updated in-place.
1815
+ *
1816
+ * ### Basic usage
1817
+ * ```html
1818
+ * <ui-property-sheet
1819
+ * [fields]="fields"
1820
+ * [(data)]="config"
1821
+ * (propertyChange)="onChanged($event)"
1822
+ * />
1823
+ * ```
1824
+ *
1825
+ * ### Grouped fields
1826
+ * ```ts
1827
+ * const fields: PropertyFieldDefinition<Config>[] = [
1828
+ * { key: 'name', label: 'Name', type: 'string', group: 'General' },
1829
+ * { key: 'color', label: 'Color', type: 'color', group: 'Appearance' },
1830
+ * ];
1831
+ * ```
1832
+ */
1833
+ class UIPropertySheet {
1834
+ // ── Inputs ────────────────────────────────────────────────────────
1835
+ /** Field definitions describing the properties to edit. */
1836
+ fields = input.required(...(ngDevMode ? [{ debugName: "fields" }] : []));
1837
+ /** Accessible label for the sheet. */
1838
+ ariaLabel = input("Property sheet", ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
1839
+ // ── Models ────────────────────────────────────────────────────────
1840
+ /** The data object being inspected/edited (two-way bindable). */
1841
+ data = model({}, ...(ngDevMode ? [{ debugName: "data" }] : []));
1842
+ // ── Outputs ───────────────────────────────────────────────────────
1843
+ /** Emits when any property value changes. */
1844
+ propertyChange = output();
1845
+ // ── Computed ──────────────────────────────────────────────────────
1846
+ /** @internal — fields organised into named groups. */
1847
+ groups = computed(() => {
1848
+ const defs = this.fields();
1849
+ const map = new Map();
1850
+ for (const field of defs) {
1851
+ const group = field.group ?? "";
1852
+ let list = map.get(group);
1853
+ if (!list) {
1854
+ list = [];
1855
+ map.set(group, list);
1856
+ }
1857
+ list.push(field);
1858
+ }
1859
+ const result = [];
1860
+ for (const [name, fields] of map) {
1861
+ result.push({ name, fields });
1862
+ }
1863
+ return result;
1864
+ }, ...(ngDevMode ? [{ debugName: "groups" }] : []));
1865
+ // ── Protected methods ─────────────────────────────────────────────
1866
+ /** @internal — read a field value from the data object. */
1867
+ getValue(key) {
1868
+ return this.data()[key];
1869
+ }
1870
+ /** @internal — update a field and emit. */
1871
+ onValueChange(key, value) {
1872
+ const updated = { ...this.data(), [key]: value };
1873
+ this.data.set(updated);
1874
+ this.propertyChange.emit({ key, value, data: updated });
1875
+ }
1876
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIPropertySheet, deps: [], target: i0.ɵɵFactoryTarget.Component });
1877
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIPropertySheet, isStandalone: true, selector: "ui-property-sheet", inputs: { fields: { classPropertyName: "fields", publicName: "fields", isSignal: true, isRequired: true, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { data: "dataChange", propertyChange: "propertyChange" }, host: { classAttribute: "ui-property-sheet" }, providers: [{ provide: UI_DEFAULT_SURFACE_TYPE, useValue: "panel" }], hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "<div class=\"ps-sheet\" role=\"form\" [attr.aria-label]=\"ariaLabel()\">\n @for (group of groups(); track group.name) {\n @if (group.name) {\n <h3 class=\"ps-group-heading\">{{ group.name }}</h3>\n }\n @for (field of group.fields; track field.key) {\n <div class=\"ps-row\" [class.ps-row--readonly]=\"field.readonly\">\n <label class=\"ps-label\" [attr.for]=\"'ps-' + field.key\">\n {{ field.label }}\n </label>\n <div class=\"ps-editor\">\n @switch (field.type) {\n @case ('string') {\n <ui-input\n [attr.id]=\"'ps-' + field.key\"\n [value]=\"'' + (getValue(field.key) ?? '')\"\n [placeholder]=\"field.placeholder ?? ''\"\n [disabled]=\"field.readonly ?? false\"\n [ariaLabel]=\"field.label\"\n (valueChange)=\"onValueChange(field.key, $event)\"\n />\n }\n @case ('number') {\n <ui-input\n [attr.id]=\"'ps-' + field.key\"\n type=\"number\"\n [value]=\"'' + (getValue(field.key) ?? '')\"\n [placeholder]=\"field.placeholder ?? ''\"\n [disabled]=\"field.readonly ?? false\"\n [ariaLabel]=\"field.label\"\n (valueChange)=\"onValueChange(field.key, +$event)\"\n />\n }\n @case ('boolean') {\n <ui-checkbox\n [checked]=\"!!getValue(field.key)\"\n [disabled]=\"field.readonly ?? false\"\n [ariaLabel]=\"field.label\"\n (checkedChange)=\"onValueChange(field.key, $event)\"\n />\n }\n @case ('select') {\n <ui-dropdown-list\n [options]=\"field.options ?? []\"\n [value]=\"'' + (getValue(field.key) ?? '')\"\n [disabled]=\"field.readonly ?? false\"\n [ariaLabel]=\"field.label\"\n (valueChange)=\"onValueChange(field.key, $event)\"\n />\n }\n @case ('color') {\n <ui-color-picker\n [value]=\"'' + (getValue(field.key) ?? '#000000')\"\n (valueChange)=\"onValueChange(field.key, $event)\"\n />\n }\n @case ('slider') {\n <ui-slider\n [value]=\"+(getValue(field.key) ?? 0)\"\n [min]=\"field.min ?? 0\"\n [max]=\"field.max ?? 100\"\n [step]=\"field.step ?? 1\"\n [disabled]=\"field.readonly ?? false\"\n [showValue]=\"true\"\n (valueChange)=\"onValueChange(field.key, $event)\"\n />\n }\n }\n </div>\n </div>\n }\n }\n</div>\n", styles: ["@charset \"UTF-8\";:host{display:block;font-family:Cantarell,Noto Sans,Segoe UI,sans-serif;font-size:.8125rem}.ps-sheet{display:flex;flex-direction:column}.ps-group-heading{margin:0;padding:.5rem .75rem .25rem;font-size:.6875rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase}.ps-group-heading:first-child{border-top:none}.ps-row{display:flex;align-items:center;gap:.5rem;padding:.3rem .75rem;min-height:2rem}.ps-row:last-child{border-bottom:none}.ps-row--readonly{opacity:.65}.ps-label{flex:0 0 38%;max-width:38%;font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ps-editor{flex:1;min-width:0}.ps-editor ui-input,.ps-editor ui-select,.ps-editor ui-slider{width:100%}\n"], dependencies: [{ kind: "component", type: UIInput, selector: "ui-input", inputs: ["type", "text", "value", "adapter", "placeholder", "disabled", "multiline", "rows", "heightAdjustable", "ariaLabel"], outputs: ["textChange", "valueChange"] }, { kind: "component", type: UIDropdownList, selector: "ui-dropdown-list", inputs: ["options", "placeholder", "disabled", "ariaLabel", "value"], outputs: ["valueChange"] }, { kind: "component", type: UICheckbox, selector: "ui-checkbox", inputs: ["variant", "checked", "disabled", "indeterminate", "ariaLabel"], outputs: ["checkedChange"] }, { kind: "component", type: UIColorPicker, selector: "ui-color-picker", inputs: ["value", "initialMode", "availableModes", "disabled", "ariaLabel"], outputs: ["valueChange", "colorChange"] }, { kind: "component", type: UISlider, selector: "ui-slider", inputs: ["mode", "value", "min", "max", "step", "disabled", "showValue", "showMinMax", "ariaLabel", "showTicks", "ticks"], outputs: ["valueChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1878
+ }
1879
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIPropertySheet, decorators: [{
1880
+ type: Component,
1881
+ args: [{ selector: "ui-property-sheet", standalone: true, imports: [UIInput, UIDropdownList, UICheckbox, UIColorPicker, UISlider], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ["surfaceType"] }], providers: [{ provide: UI_DEFAULT_SURFACE_TYPE, useValue: "panel" }], host: {
1882
+ class: "ui-property-sheet",
1883
+ }, template: "<div class=\"ps-sheet\" role=\"form\" [attr.aria-label]=\"ariaLabel()\">\n @for (group of groups(); track group.name) {\n @if (group.name) {\n <h3 class=\"ps-group-heading\">{{ group.name }}</h3>\n }\n @for (field of group.fields; track field.key) {\n <div class=\"ps-row\" [class.ps-row--readonly]=\"field.readonly\">\n <label class=\"ps-label\" [attr.for]=\"'ps-' + field.key\">\n {{ field.label }}\n </label>\n <div class=\"ps-editor\">\n @switch (field.type) {\n @case ('string') {\n <ui-input\n [attr.id]=\"'ps-' + field.key\"\n [value]=\"'' + (getValue(field.key) ?? '')\"\n [placeholder]=\"field.placeholder ?? ''\"\n [disabled]=\"field.readonly ?? false\"\n [ariaLabel]=\"field.label\"\n (valueChange)=\"onValueChange(field.key, $event)\"\n />\n }\n @case ('number') {\n <ui-input\n [attr.id]=\"'ps-' + field.key\"\n type=\"number\"\n [value]=\"'' + (getValue(field.key) ?? '')\"\n [placeholder]=\"field.placeholder ?? ''\"\n [disabled]=\"field.readonly ?? false\"\n [ariaLabel]=\"field.label\"\n (valueChange)=\"onValueChange(field.key, +$event)\"\n />\n }\n @case ('boolean') {\n <ui-checkbox\n [checked]=\"!!getValue(field.key)\"\n [disabled]=\"field.readonly ?? false\"\n [ariaLabel]=\"field.label\"\n (checkedChange)=\"onValueChange(field.key, $event)\"\n />\n }\n @case ('select') {\n <ui-dropdown-list\n [options]=\"field.options ?? []\"\n [value]=\"'' + (getValue(field.key) ?? '')\"\n [disabled]=\"field.readonly ?? false\"\n [ariaLabel]=\"field.label\"\n (valueChange)=\"onValueChange(field.key, $event)\"\n />\n }\n @case ('color') {\n <ui-color-picker\n [value]=\"'' + (getValue(field.key) ?? '#000000')\"\n (valueChange)=\"onValueChange(field.key, $event)\"\n />\n }\n @case ('slider') {\n <ui-slider\n [value]=\"+(getValue(field.key) ?? 0)\"\n [min]=\"field.min ?? 0\"\n [max]=\"field.max ?? 100\"\n [step]=\"field.step ?? 1\"\n [disabled]=\"field.readonly ?? false\"\n [showValue]=\"true\"\n (valueChange)=\"onValueChange(field.key, $event)\"\n />\n }\n }\n </div>\n </div>\n }\n }\n</div>\n", styles: ["@charset \"UTF-8\";:host{display:block;font-family:Cantarell,Noto Sans,Segoe UI,sans-serif;font-size:.8125rem}.ps-sheet{display:flex;flex-direction:column}.ps-group-heading{margin:0;padding:.5rem .75rem .25rem;font-size:.6875rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase}.ps-group-heading:first-child{border-top:none}.ps-row{display:flex;align-items:center;gap:.5rem;padding:.3rem .75rem;min-height:2rem}.ps-row:last-child{border-bottom:none}.ps-row--readonly{opacity:.65}.ps-label{flex:0 0 38%;max-width:38%;font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ps-editor{flex:1;min-width:0}.ps-editor ui-input,.ps-editor ui-select,.ps-editor ui-slider{width:100%}\n"] }]
1884
+ }], propDecorators: { fields: [{ type: i0.Input, args: [{ isSignal: true, alias: "fields", required: true }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], data: [{ type: i0.Input, args: [{ isSignal: true, alias: "data", required: false }] }, { type: i0.Output, args: ["dataChange"] }], propertyChange: [{ type: i0.Output, args: ["propertyChange"] }] } });
1885
+
1886
+ /**
1887
+ * A keyboard-triggered command palette that provides quick access
1888
+ * to application actions through a searchable, grouped list.
1889
+ *
1890
+ * Open with `Cmd+K` / `Ctrl+K` (when {@link globalShortcut} is
1891
+ * enabled) or by setting the `open` model to `true`.
1892
+ *
1893
+ * ### Basic usage
1894
+ * ```html
1895
+ * <ui-command-palette
1896
+ * [commands]="commands"
1897
+ * [(open)]="paletteOpen"
1898
+ * (execute)="onExecute($event)"
1899
+ * />
1900
+ * ```
1901
+ *
1902
+ * ### With recent items
1903
+ * ```html
1904
+ * <ui-command-palette
1905
+ * [commands]="commands"
1906
+ * [maxRecent]="5"
1907
+ * [(open)]="paletteOpen"
1908
+ * (execute)="onExecute($event)"
1909
+ * />
1910
+ * ```
1911
+ */
1912
+ class UICommandPalette {
1913
+ // ── Inputs ────────────────────────────────────────────────────────
1914
+ /** The full list of available commands. */
1915
+ commands = input.required(...(ngDevMode ? [{ debugName: "commands" }] : []));
1916
+ /** Placeholder text for the search input. */
1917
+ placeholder = input("Type a command…", ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
1918
+ /** Accessible label for the palette. */
1919
+ ariaLabel = input("Command palette", ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
1920
+ /**
1921
+ * Whether the global `Cmd+K` / `Ctrl+K` keyboard shortcut is
1922
+ * active. Defaults to `true`.
1923
+ */
1924
+ globalShortcut = input(true, ...(ngDevMode ? [{ debugName: "globalShortcut" }] : []));
1925
+ /**
1926
+ * Maximum number of recent commands to track. Set to `0` to
1927
+ * disable the recent-items section. Defaults to `5`.
1928
+ */
1929
+ maxRecent = input(5, ...(ngDevMode ? [{ debugName: "maxRecent" }] : []));
1930
+ // ── Models ────────────────────────────────────────────────────────
1931
+ /** Whether the palette is open (two-way bindable). */
1932
+ open = model(false, ...(ngDevMode ? [{ debugName: "open" }] : []));
1933
+ // ── Outputs ───────────────────────────────────────────────────────
1934
+ /** Emitted when a command is executed. */
1935
+ execute = output();
1936
+ // ── View queries ──────────────────────────────────────────────────
1937
+ /** @internal */
1938
+ searchInputRef = viewChild("searchInput", ...(ngDevMode ? [{ debugName: "searchInputRef" }] : []));
1939
+ // ── Internal state ────────────────────────────────────────────────
1940
+ /** @internal — current search query. */
1941
+ query = signal("", ...(ngDevMode ? [{ debugName: "query" }] : []));
1942
+ /** @internal — index of the active (highlighted) item. */
1943
+ activeIndex = signal(0, ...(ngDevMode ? [{ debugName: "activeIndex" }] : []));
1944
+ /** @internal — IDs of recently executed commands (most recent first). */
1945
+ recentIds = signal([], ...(ngDevMode ? [{ debugName: "recentIds" }] : []));
1946
+ // ── Icons ─────────────────────────────────────────────────────────
1947
+ /** @internal */
1948
+ searchIcon = UIIcons.Lucide.Social.Search;
1949
+ /** @internal */
1950
+ returnIcon = UIIcons.Lucide.Arrows.CornerDownLeft;
1951
+ /** @internal */
1952
+ arrowUpIcon = UIIcons.Lucide.Arrows.ArrowUp;
1953
+ /** @internal */
1954
+ arrowDownIcon = UIIcons.Lucide.Arrows.ArrowDown;
1955
+ // ── Computed ──────────────────────────────────────────────────────
1956
+ /** @internal — filtered and grouped commands based on the query. */
1957
+ filteredGroups = computed(() => {
1958
+ const q = this.query().toLowerCase().trim();
1959
+ const all = this.commands();
1960
+ const matching = q
1961
+ ? all.filter((cmd) => cmd.label.toLowerCase().includes(q) ||
1962
+ (cmd.keywords ?? []).some((k) => k.toLowerCase().includes(q)) ||
1963
+ (cmd.group ?? "").toLowerCase().includes(q))
1964
+ : all;
1965
+ return this.groupCommands(matching);
1966
+ }, ...(ngDevMode ? [{ debugName: "filteredGroups" }] : []));
1967
+ /** @internal — recent commands that are still in the full commands list. */
1968
+ recentCommands = computed(() => {
1969
+ const ids = this.recentIds();
1970
+ const max = this.maxRecent();
1971
+ if (max === 0 || ids.length === 0)
1972
+ return [];
1973
+ const all = this.commands();
1974
+ const lookup = new Map(all.map((c) => [c.id, c]));
1975
+ return ids
1976
+ .map((id) => lookup.get(id))
1977
+ .filter((c) => c !== undefined)
1978
+ .slice(0, max);
1979
+ }, ...(ngDevMode ? [{ debugName: "recentCommands" }] : []));
1980
+ /** @internal — whether to show the recent section. */
1981
+ showRecent = computed(() => this.maxRecent() > 0 &&
1982
+ this.recentCommands().length > 0 &&
1983
+ !this.query().trim(), ...(ngDevMode ? [{ debugName: "showRecent" }] : []));
1984
+ /** @internal — flat list of all currently visible items for keyboard nav. */
1985
+ flatItems = computed(() => {
1986
+ const items = [];
1987
+ if (this.showRecent()) {
1988
+ items.push(...this.recentCommands());
1989
+ }
1990
+ for (const group of this.filteredGroups()) {
1991
+ items.push(...group.items);
1992
+ }
1993
+ return items;
1994
+ }, ...(ngDevMode ? [{ debugName: "flatItems" }] : []));
1995
+ // ── Constructor ───────────────────────────────────────────────────
1996
+ constructor() {
1997
+ // Register global keyboard shortcut
1998
+ const onKeydown = (e) => {
1999
+ if (!this.globalShortcut())
2000
+ return;
2001
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
2002
+ e.preventDefault();
2003
+ this.open.update((v) => !v);
2004
+ }
2005
+ };
2006
+ if (typeof document !== "undefined") {
2007
+ document.addEventListener("keydown", onKeydown);
2008
+ }
2009
+ // Focus the search input when the palette opens
2010
+ effect(() => {
2011
+ if (this.open()) {
2012
+ this.query.set("");
2013
+ this.activeIndex.set(0);
2014
+ // Allow DOM to render before focusing
2015
+ queueMicrotask(() => {
2016
+ this.searchInputRef()?.nativeElement.focus();
2017
+ });
2018
+ }
2019
+ });
2020
+ // Reset active index when query changes
2021
+ effect(() => {
2022
+ this.query();
2023
+ this.activeIndex.set(0);
2024
+ });
2025
+ }
2026
+ // ── Public methods ────────────────────────────────────────────────
2027
+ /** Programmatically open the palette. */
2028
+ show() {
2029
+ this.open.set(true);
2030
+ }
2031
+ /** Programmatically close the palette. */
2032
+ close() {
2033
+ this.open.set(false);
2034
+ }
2035
+ // ── Protected methods ─────────────────────────────────────────────
2036
+ /** @internal */
2037
+ onSearchInput(event) {
2038
+ const input = event.target;
2039
+ this.query.set(input.value);
2040
+ }
2041
+ /** @internal */
2042
+ onKeydown(event) {
2043
+ const items = this.flatItems();
2044
+ const len = items.length;
2045
+ if (len === 0)
2046
+ return;
2047
+ switch (event.key) {
2048
+ case "ArrowDown":
2049
+ event.preventDefault();
2050
+ this.activeIndex.update((i) => (i + 1) % len);
2051
+ this.scrollActiveIntoView();
2052
+ break;
2053
+ case "ArrowUp":
2054
+ event.preventDefault();
2055
+ this.activeIndex.update((i) => (i - 1 + len) % len);
2056
+ this.scrollActiveIntoView();
2057
+ break;
2058
+ case "Enter": {
2059
+ event.preventDefault();
2060
+ const item = items[this.activeIndex()];
2061
+ if (item && !item.disabled) {
2062
+ this.executeCommand(item);
2063
+ }
2064
+ break;
2065
+ }
2066
+ case "Escape":
2067
+ event.preventDefault();
2068
+ this.close();
2069
+ break;
2070
+ }
2071
+ }
2072
+ /** @internal */
2073
+ onItemClick(item) {
2074
+ if (item.disabled)
2075
+ return;
2076
+ this.executeCommand(item);
2077
+ }
2078
+ /** @internal */
2079
+ onBackdropClick() {
2080
+ this.close();
2081
+ }
2082
+ /** @internal — compute the flat index for a group item. */
2083
+ getFlatIndex(groupIndex, itemIndex) {
2084
+ const groups = this.filteredGroups();
2085
+ let offset = this.showRecent() ? this.recentCommands().length : 0;
2086
+ for (let g = 0; g < groupIndex; g++) {
2087
+ offset += groups[g].items.length;
2088
+ }
2089
+ return offset + itemIndex;
2090
+ }
2091
+ /** @internal — compute the flat index for a recent item. */
2092
+ getRecentFlatIndex(index) {
2093
+ return index;
2094
+ }
2095
+ // ── Private methods ───────────────────────────────────────────────
2096
+ executeCommand(item) {
2097
+ this.execute.emit({
2098
+ command: item,
2099
+ executedAt: new Date().toISOString(),
2100
+ });
2101
+ this.addToRecent(item.id);
2102
+ this.close();
2103
+ }
2104
+ addToRecent(id) {
2105
+ const max = this.maxRecent();
2106
+ if (max === 0)
2107
+ return;
2108
+ this.recentIds.update((ids) => {
2109
+ const filtered = ids.filter((i) => i !== id);
2110
+ return [id, ...filtered].slice(0, max);
2111
+ });
2112
+ }
2113
+ groupCommands(items) {
2114
+ const map = new Map();
2115
+ for (const item of items) {
2116
+ const group = item.group ?? "";
2117
+ let list = map.get(group);
2118
+ if (!list) {
2119
+ list = [];
2120
+ map.set(group, list);
2121
+ }
2122
+ list.push(item);
2123
+ }
2124
+ const result = [];
2125
+ for (const [name, groupItems] of map) {
2126
+ result.push({ name, items: groupItems });
2127
+ }
2128
+ return result;
2129
+ }
2130
+ scrollActiveIntoView() {
2131
+ queueMicrotask(() => {
2132
+ const active = this.searchInputRef()
2133
+ ?.nativeElement.closest(".cp-dialog")
2134
+ ?.querySelector(".cp-item--active");
2135
+ active?.scrollIntoView?.({ block: "nearest" });
2136
+ });
2137
+ }
2138
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UICommandPalette, deps: [], target: i0.ɵɵFactoryTarget.Component });
2139
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UICommandPalette, isStandalone: true, selector: "ui-command-palette", inputs: { commands: { classPropertyName: "commands", publicName: "commands", isSignal: true, isRequired: true, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, globalShortcut: { classPropertyName: "globalShortcut", publicName: "globalShortcut", isSignal: true, isRequired: false, transformFunction: null }, maxRecent: { classPropertyName: "maxRecent", publicName: "maxRecent", isSignal: true, isRequired: false, transformFunction: null }, open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { open: "openChange", execute: "execute" }, host: { classAttribute: "ui-command-palette" }, viewQueries: [{ propertyName: "searchInputRef", first: true, predicate: ["searchInput"], descendants: true, isSignal: true }], hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "@if (open()) {\n <div class=\"cp-backdrop\" (click)=\"onBackdropClick()\" (keydown)=\"onKeydown($event)\" tabindex=\"-1\"></div>\n <div\n class=\"cp-dialog\"\n role=\"dialog\"\n [attr.aria-label]=\"ariaLabel()\"\n (keydown)=\"onKeydown($event)\"\n >\n <!-- Search bar -->\n <div class=\"cp-search\">\n <ui-icon class=\"cp-search-icon\" [svg]=\"searchIcon\" [size]=\"18\" />\n <input\n #searchInput\n class=\"cp-search-input\"\n type=\"text\"\n [placeholder]=\"placeholder()\"\n [value]=\"query()\"\n (input)=\"onSearchInput($event)\"\n role=\"combobox\"\n aria-autocomplete=\"list\"\n aria-expanded=\"true\"\n aria-controls=\"cp-results\"\n [attr.aria-activedescendant]=\"\n flatItems().length\n ? 'cp-item-' + activeIndex()\n : null\n \"\n />\n <kbd class=\"cp-kbd\">esc</kbd>\n </div>\n\n <!-- Results List -->\n <div\n id=\"cp-results\"\n class=\"cp-results\"\n role=\"listbox\"\n [attr.aria-label]=\"'Search results'\"\n >\n @if (flatItems().length === 0) {\n <div class=\"cp-empty\">No matching commands</div>\n } @else {\n <!-- Recent Commands -->\n @if (showRecent()) {\n <div class=\"cp-group\">\n <div class=\"cp-group-heading\">Recent</div>\n @for (item of recentCommands(); track item.id; let i = $index) {\n <div\n class=\"cp-item\"\n role=\"option\"\n tabindex=\"-1\"\n [id]=\"'cp-item-' + getRecentFlatIndex(i)\"\n [class.cp-item--active]=\"activeIndex() === getRecentFlatIndex(i)\"\n [class.cp-item--disabled]=\"item.disabled\"\n [attr.aria-selected]=\"activeIndex() === getRecentFlatIndex(i)\"\n [attr.aria-disabled]=\"item.disabled ?? false\"\n (click)=\"onItemClick(item)\"\n (keydown)=\"onKeydown($event)\"\n (pointerenter)=\"activeIndex.set(getRecentFlatIndex(i))\"\n >\n @if (item.icon) {\n <ui-icon class=\"cp-item-icon\" [svg]=\"item.icon\" [size]=\"16\" />\n }\n <span class=\"cp-item-label\">{{ item.label }}</span>\n @if (item.shortcut) {\n <kbd class=\"cp-item-shortcut\">{{ item.shortcut }}</kbd>\n }\n </div>\n }\n </div>\n }\n\n <!-- Grouped commands -->\n @for (group of filteredGroups(); track group.name; let gi = $index) {\n <div class=\"cp-group\">\n @if (group.name) {\n <div class=\"cp-group-heading\">{{ group.name }}</div>\n }\n @for (item of group.items; track item.id; let ii = $index) {\n <div\n class=\"cp-item\"\n role=\"option\"\n tabindex=\"-1\"\n [id]=\"'cp-item-' + getFlatIndex(gi, ii)\"\n [class.cp-item--active]=\"activeIndex() === getFlatIndex(gi, ii)\"\n [class.cp-item--disabled]=\"item.disabled\"\n [attr.aria-selected]=\"activeIndex() === getFlatIndex(gi, ii)\"\n [attr.aria-disabled]=\"item.disabled ?? false\"\n (click)=\"onItemClick(item)\"\n (keydown)=\"onKeydown($event)\"\n (pointerenter)=\"activeIndex.set(getFlatIndex(gi, ii))\"\n >\n @if (item.icon) {\n <ui-icon class=\"cp-item-icon\" [svg]=\"item.icon\" [size]=\"16\" />\n }\n <span class=\"cp-item-label\">{{ item.label }}</span>\n @if (item.shortcut) {\n <kbd class=\"cp-item-shortcut\">{{ item.shortcut }}</kbd>\n }\n </div>\n }\n </div>\n }\n }\n </div>\n\n <!-- Footer hints -->\n <div class=\"cp-footer\">\n <span class=\"cp-hint\">\n <ui-icon [svg]=\"returnIcon\" [size]=\"12\" />\n <span>to select</span>\n </span>\n <span class=\"cp-hint\">\n <ui-icon [svg]=\"arrowUpIcon\" [size]=\"12\" />\n <ui-icon [svg]=\"arrowDownIcon\" [size]=\"12\" />\n <span>to navigate</span>\n </span>\n <span class=\"cp-hint\">\n <kbd>esc</kbd>\n <span>to close</span>\n </span>\n </div>\n </div>\n}\n", styles: [":host{--cp-disabled-opacity: .4;display:contents}.cp-backdrop{position:fixed;inset:0;z-index:999;animation:cp-fade-in .12s ease-out}.cp-dialog{position:fixed;top:20%;left:50%;transform:translate(-50%);z-index:1000;width:min(560px,100vw - 32px);max-height:420px;border-radius:12px;display:flex;flex-direction:column;overflow:hidden;animation:cp-slide-in .14s ease-out}.cp-search{display:flex;align-items:center;gap:8px;padding:12px 16px}.cp-search-icon{flex-shrink:0}.cp-search-input{appearance:none;border:none;background:none;cursor:pointer;font-family:var(--ui-font, inherit);flex:1;font:inherit;font-size:15px}.cp-results{flex:1;overflow-y:auto;padding:6px 0}.cp-empty{padding:24px 16px;text-align:center;font-size:14px}.cp-group+.cp-group{margin-top:4px}.cp-group-heading{padding:6px 16px 4px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.cp-item{display:flex;align-items:center;gap:10px;padding:8px 16px;cursor:pointer;font-size:14px;border-radius:6px;margin:0 6px;transition:background 60ms ease}.cp-item.cp-item--disabled{opacity:var(--cp-disabled-opacity);cursor:not-allowed}.cp-item-icon{flex-shrink:0}.cp-item-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.cp-item-shortcut{flex-shrink:0;font-size:11px;font-family:inherit;padding:2px 6px;border-radius:4px}.cp-footer{display:flex;align-items:center;gap:16px;padding:8px 16px;font-size:12px}.cp-hint{display:inline-flex;align-items:center;gap:4px}.cp-hint kbd{font-size:10px;font-family:inherit;padding:1px 5px;border-radius:3px}@keyframes cp-fade-in{0%{opacity:0}to{opacity:1}}@keyframes cp-slide-in{0%{opacity:0;transform:translate(-50%) translateY(-8px)}to{opacity:1;transform:translate(-50%) translateY(0)}}\n"], dependencies: [{ kind: "component", type: UIIcon, selector: "ui-icon", inputs: ["svg", "size", "ariaLabel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2140
+ }
2141
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UICommandPalette, decorators: [{
2142
+ type: Component,
2143
+ args: [{ selector: "ui-command-palette", standalone: true, imports: [UIIcon], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ["surfaceType"] }], host: {
2144
+ class: "ui-command-palette",
2145
+ }, template: "@if (open()) {\n <div class=\"cp-backdrop\" (click)=\"onBackdropClick()\" (keydown)=\"onKeydown($event)\" tabindex=\"-1\"></div>\n <div\n class=\"cp-dialog\"\n role=\"dialog\"\n [attr.aria-label]=\"ariaLabel()\"\n (keydown)=\"onKeydown($event)\"\n >\n <!-- Search bar -->\n <div class=\"cp-search\">\n <ui-icon class=\"cp-search-icon\" [svg]=\"searchIcon\" [size]=\"18\" />\n <input\n #searchInput\n class=\"cp-search-input\"\n type=\"text\"\n [placeholder]=\"placeholder()\"\n [value]=\"query()\"\n (input)=\"onSearchInput($event)\"\n role=\"combobox\"\n aria-autocomplete=\"list\"\n aria-expanded=\"true\"\n aria-controls=\"cp-results\"\n [attr.aria-activedescendant]=\"\n flatItems().length\n ? 'cp-item-' + activeIndex()\n : null\n \"\n />\n <kbd class=\"cp-kbd\">esc</kbd>\n </div>\n\n <!-- Results List -->\n <div\n id=\"cp-results\"\n class=\"cp-results\"\n role=\"listbox\"\n [attr.aria-label]=\"'Search results'\"\n >\n @if (flatItems().length === 0) {\n <div class=\"cp-empty\">No matching commands</div>\n } @else {\n <!-- Recent Commands -->\n @if (showRecent()) {\n <div class=\"cp-group\">\n <div class=\"cp-group-heading\">Recent</div>\n @for (item of recentCommands(); track item.id; let i = $index) {\n <div\n class=\"cp-item\"\n role=\"option\"\n tabindex=\"-1\"\n [id]=\"'cp-item-' + getRecentFlatIndex(i)\"\n [class.cp-item--active]=\"activeIndex() === getRecentFlatIndex(i)\"\n [class.cp-item--disabled]=\"item.disabled\"\n [attr.aria-selected]=\"activeIndex() === getRecentFlatIndex(i)\"\n [attr.aria-disabled]=\"item.disabled ?? false\"\n (click)=\"onItemClick(item)\"\n (keydown)=\"onKeydown($event)\"\n (pointerenter)=\"activeIndex.set(getRecentFlatIndex(i))\"\n >\n @if (item.icon) {\n <ui-icon class=\"cp-item-icon\" [svg]=\"item.icon\" [size]=\"16\" />\n }\n <span class=\"cp-item-label\">{{ item.label }}</span>\n @if (item.shortcut) {\n <kbd class=\"cp-item-shortcut\">{{ item.shortcut }}</kbd>\n }\n </div>\n }\n </div>\n }\n\n <!-- Grouped commands -->\n @for (group of filteredGroups(); track group.name; let gi = $index) {\n <div class=\"cp-group\">\n @if (group.name) {\n <div class=\"cp-group-heading\">{{ group.name }}</div>\n }\n @for (item of group.items; track item.id; let ii = $index) {\n <div\n class=\"cp-item\"\n role=\"option\"\n tabindex=\"-1\"\n [id]=\"'cp-item-' + getFlatIndex(gi, ii)\"\n [class.cp-item--active]=\"activeIndex() === getFlatIndex(gi, ii)\"\n [class.cp-item--disabled]=\"item.disabled\"\n [attr.aria-selected]=\"activeIndex() === getFlatIndex(gi, ii)\"\n [attr.aria-disabled]=\"item.disabled ?? false\"\n (click)=\"onItemClick(item)\"\n (keydown)=\"onKeydown($event)\"\n (pointerenter)=\"activeIndex.set(getFlatIndex(gi, ii))\"\n >\n @if (item.icon) {\n <ui-icon class=\"cp-item-icon\" [svg]=\"item.icon\" [size]=\"16\" />\n }\n <span class=\"cp-item-label\">{{ item.label }}</span>\n @if (item.shortcut) {\n <kbd class=\"cp-item-shortcut\">{{ item.shortcut }}</kbd>\n }\n </div>\n }\n </div>\n }\n }\n </div>\n\n <!-- Footer hints -->\n <div class=\"cp-footer\">\n <span class=\"cp-hint\">\n <ui-icon [svg]=\"returnIcon\" [size]=\"12\" />\n <span>to select</span>\n </span>\n <span class=\"cp-hint\">\n <ui-icon [svg]=\"arrowUpIcon\" [size]=\"12\" />\n <ui-icon [svg]=\"arrowDownIcon\" [size]=\"12\" />\n <span>to navigate</span>\n </span>\n <span class=\"cp-hint\">\n <kbd>esc</kbd>\n <span>to close</span>\n </span>\n </div>\n </div>\n}\n", styles: [":host{--cp-disabled-opacity: .4;display:contents}.cp-backdrop{position:fixed;inset:0;z-index:999;animation:cp-fade-in .12s ease-out}.cp-dialog{position:fixed;top:20%;left:50%;transform:translate(-50%);z-index:1000;width:min(560px,100vw - 32px);max-height:420px;border-radius:12px;display:flex;flex-direction:column;overflow:hidden;animation:cp-slide-in .14s ease-out}.cp-search{display:flex;align-items:center;gap:8px;padding:12px 16px}.cp-search-icon{flex-shrink:0}.cp-search-input{appearance:none;border:none;background:none;cursor:pointer;font-family:var(--ui-font, inherit);flex:1;font:inherit;font-size:15px}.cp-results{flex:1;overflow-y:auto;padding:6px 0}.cp-empty{padding:24px 16px;text-align:center;font-size:14px}.cp-group+.cp-group{margin-top:4px}.cp-group-heading{padding:6px 16px 4px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.cp-item{display:flex;align-items:center;gap:10px;padding:8px 16px;cursor:pointer;font-size:14px;border-radius:6px;margin:0 6px;transition:background 60ms ease}.cp-item.cp-item--disabled{opacity:var(--cp-disabled-opacity);cursor:not-allowed}.cp-item-icon{flex-shrink:0}.cp-item-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.cp-item-shortcut{flex-shrink:0;font-size:11px;font-family:inherit;padding:2px 6px;border-radius:4px}.cp-footer{display:flex;align-items:center;gap:16px;padding:8px 16px;font-size:12px}.cp-hint{display:inline-flex;align-items:center;gap:4px}.cp-hint kbd{font-size:10px;font-family:inherit;padding:1px 5px;border-radius:3px}@keyframes cp-fade-in{0%{opacity:0}to{opacity:1}}@keyframes cp-slide-in{0%{opacity:0;transform:translate(-50%) translateY(-8px)}to{opacity:1;transform:translate(-50%) translateY(0)}}\n"] }]
2146
+ }], ctorParameters: () => [], propDecorators: { commands: [{ type: i0.Input, args: [{ isSignal: true, alias: "commands", required: true }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], globalShortcut: [{ type: i0.Input, args: [{ isSignal: true, alias: "globalShortcut", required: false }] }], maxRecent: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxRecent", required: false }] }], open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }, { type: i0.Output, args: ["openChange"] }], execute: [{ type: i0.Output, args: ["execute"] }], searchInputRef: [{ type: i0.ViewChild, args: ["searchInput", { isSignal: true }] }] } });
2147
+
2148
+ /**
2149
+ * DI token for the customisable file-icon registry.
2150
+ *
2151
+ * When provided, the icon view resolves icons by looking up the
2152
+ * file's extension (lower-case, without the leading dot) in the
2153
+ * registry. Falls back to the default file/folder icon.
2154
+ */
2155
+ const FILE_ICON_REGISTRY = new InjectionToken("FILE_ICON_REGISTRY");
2156
+ /**
2157
+ * Adapter that converts {@link FileBrowserEntry} items to
2158
+ * {@link TreeNode} items for the tree-view sidebar.
2159
+ *
2160
+ * @internal
2161
+ */
2162
+ function entryToTreeNode(entry, children) {
2163
+ return {
2164
+ id: entry.id,
2165
+ data: entry,
2166
+ children,
2167
+ icon: entry.icon,
2168
+ };
2169
+ }
2170
+
2171
+ /**
2172
+ * A two-panel file browser block composing {@link UITreeView},
2173
+ * {@link UIBreadcrumb}, and a contents list.
2174
+ *
2175
+ * The tree sidebar shows the folder hierarchy. Selecting a folder
2176
+ * displays its contents (files and sub-folders) in the main panel.
2177
+ * A breadcrumb bar shows the current path and allows quick
2178
+ * navigation to ancestor folders.
2179
+ *
2180
+ * ### Basic usage
2181
+ * ```html
2182
+ * <ui-file-browser [datasource]="ds" (fileActivated)="open($event)" />
2183
+ * ```
2184
+ *
2185
+ * ### With custom entry template
2186
+ * ```html
2187
+ * <ui-file-browser [datasource]="ds">
2188
+ * <ng-template #entryTemplate let-entry>
2189
+ * <span>{{ entry.name }} — {{ entry.meta?.size }}</span>
2190
+ * </ng-template>
2191
+ * </ui-file-browser>
2192
+ * ```
2193
+ */
2194
+ class UIFileBrowser {
2195
+ // ── Inputs ────────────────────────────────────────────────────────
2196
+ /** The datasource providing the file/directory structure. */
2197
+ datasource = input.required(...(ngDevMode ? [{ debugName: "datasource" }] : []));
2198
+ /** Accessible label for the file browser. */
2199
+ ariaLabel = input("File browser", ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
2200
+ /** Whether to show the sidebar tree panel. */
2201
+ showSidebar = input(true, ...(ngDevMode ? [{ debugName: "showSidebar" }] : []));
2202
+ /** Label for the root breadcrumb item. */
2203
+ rootLabel = input("Root", ...(ngDevMode ? [{ debugName: "rootLabel" }] : []));
2204
+ /** Active view mode for the contents panel. */
2205
+ viewMode = input("list", ...(ngDevMode ? [{ debugName: "viewMode" }] : []));
2206
+ /** Whether to show the details pane for the selected entry. */
2207
+ showDetails = input(false, ...(ngDevMode ? [{ debugName: "showDetails" }] : []));
2208
+ /**
2209
+ * Optional persistence key. When set, sidebar and details panel widths
2210
+ * are saved to and restored from storage.
2211
+ */
2212
+ name = input(undefined, ...(ngDevMode ? [{ debugName: "name" }] : []));
2213
+ /**
2214
+ * Callback that extracts metadata fields from a selected entry.
2215
+ * Required for the details pane to display meaningful data.
2216
+ */
2217
+ metadataProvider = input(null, ...(ngDevMode ? [{ debugName: "metadataProvider" }] : []));
2218
+ // ── Models ────────────────────────────────────────────────────────
2219
+ /** The currently selected entry (two-way bindable). */
2220
+ selectedEntry = model(null, ...(ngDevMode ? [{ debugName: "selectedEntry" }] : []));
2221
+ // ── Outputs ───────────────────────────────────────────────────────
2222
+ /** Emitted when a file (non-directory) is activated (double-click / Enter). */
2223
+ fileActivated = output();
2224
+ /** Emitted when the current directory changes. */
2225
+ directoryChange = output();
2226
+ // ── Content projection ────────────────────────────────────────────
2227
+ /** Optional custom template for rendering each entry in the contents panel. */
2228
+ entryTemplate = contentChild("entryTemplate", ...(ngDevMode ? [{ debugName: "entryTemplate" }] : []));
2229
+ // ── View queries ──────────────────────────────────────────────────
2230
+ /** @internal */
2231
+ treeViewRef = viewChild("treeView", ...(ngDevMode ? [{ debugName: "treeViewRef" }] : []));
2232
+ // ── Internal state ────────────────────────────────────────────────
2233
+ /** @internal — the breadcrumb path from root to the current directory. */
2234
+ path = signal([], ...(ngDevMode ? [{ debugName: "path" }] : []));
2235
+ /** @internal — the current directory (`null` = root). */
2236
+ currentDirectory = signal(null, ...(ngDevMode ? [{ debugName: "currentDirectory" }] : []));
2237
+ /** @internal — contents of the current directory. */
2238
+ contents = signal([], ...(ngDevMode ? [{ debugName: "contents" }] : []));
2239
+ /** @internal — tree selection for one-way binding. */
2240
+ treeSelected = signal([], ...(ngDevMode ? [{ debugName: "treeSelected" }] : []));
2241
+ /** @internal — tree datasource adapter. */
2242
+ treeDatasource = computed(() => {
2243
+ const ds = this.datasource();
2244
+ return this.createTreeDatasource(ds);
2245
+ }, ...(ngDevMode ? [{ debugName: "treeDatasource" }] : []));
2246
+ /** @internal — display function for tree nodes. */
2247
+ displayNodeLabel = (entry) => entry.name;
2248
+ // ── Icons ─────────────────────────────────────────────────────────
2249
+ /** @internal */
2250
+ icons = {
2251
+ folder: UIIcons.Lucide.Files.Folder,
2252
+ folderOpen: UIIcons.Lucide.Files.FolderOpen,
2253
+ file: UIIcons.Lucide.Files.File,
2254
+ fileText: UIIcons.Lucide.Files.FileText,
2255
+ chevronRight: UIIcons.Lucide.Arrows.ChevronRight,
2256
+ };
2257
+ // ── DI ────────────────────────────────────────────────────────────
2258
+ /** @internal — optional icon registry supplied via DI. */
2259
+ iconRegistry = inject(FILE_ICON_REGISTRY, {
2260
+ optional: true,
2261
+ });
2262
+ /** @internal */
2263
+ elRef = inject(ElementRef);
2264
+ /** @internal */
2265
+ injector = inject(Injector);
2266
+ /** @internal */
2267
+ storage = inject(StorageService);
2268
+ // ── Resize state ──────────────────────────────────────────────────
2269
+ /** @internal — current sidebar width in pixels. */
2270
+ sidebarWidthPx = signal(240, ...(ngDevMode ? [{ debugName: "sidebarWidthPx" }] : []));
2271
+ /** @internal — current details panel width in pixels. */
2272
+ detailsWidthPx = signal(220, ...(ngDevMode ? [{ debugName: "detailsWidthPx" }] : []));
2273
+ /** @internal — which panel divider is actively being dragged. */
2274
+ draggingPanel = signal(null, ...(ngDevMode ? [{ debugName: "draggingPanel" }] : []));
2275
+ /** @internal — collapsed state for sidebar. */
2276
+ sidebarCollapsed = signal(false, ...(ngDevMode ? [{ debugName: "sidebarCollapsed" }] : []));
2277
+ /** @internal — collapsed state for details panel. */
2278
+ detailsCollapsed = signal(false, ...(ngDevMode ? [{ debugName: "detailsCollapsed" }] : []));
2279
+ /** @internal — width before sidebar collapse for restore. */
2280
+ preSidebarCollapseWidth = null;
2281
+ /** @internal — width before details collapse for restore. */
2282
+ preDetailsCollapseWidth = null;
2283
+ // ── Column-view state ─────────────────────────────────────────────
2284
+ /**
2285
+ * @internal — each element represents one column in the column view.
2286
+ * Index 0 = root, subsequent = each directory navigated into.
2287
+ */
2288
+ columnPanes = signal([], ...(ngDevMode ? [{ debugName: "columnPanes" }] : []));
2289
+ /**
2290
+ * @internal — the entry selected in each column pane (by pane index).
2291
+ */
2292
+ columnSelections = signal([], ...(ngDevMode ? [{ debugName: "columnSelections" }] : []));
2293
+ // ── Computed ──────────────────────────────────────────────────────
2294
+ /** @internal — breadcrumb items built from the current path. */
2295
+ breadcrumbItems = computed(() => {
2296
+ const items = [{ label: this.rootLabel() }];
2297
+ for (const entry of this.path()) {
2298
+ items.push({ label: entry.name });
2299
+ }
2300
+ return items;
2301
+ }, ...(ngDevMode ? [{ debugName: "breadcrumbItems" }] : []));
2302
+ /** @internal — whether an entry is selected in the contents panel. */
2303
+ hasSelection = computed(() => this.selectedEntry() !== null, ...(ngDevMode ? [{ debugName: "hasSelection" }] : []));
2304
+ /** @internal — metadata fields for the selected entry (details pane). */
2305
+ detailFields = computed(() => {
2306
+ const entry = this.selectedEntry();
2307
+ const provider = this.metadataProvider();
2308
+ if (!entry || !provider)
2309
+ return [];
2310
+ return provider(entry);
2311
+ }, ...(ngDevMode ? [{ debugName: "detailFields" }] : []));
2312
+ // ── Constructor ───────────────────────────────────────────────────
2313
+ constructor() {
2314
+ // Load root contents on init
2315
+ effect(() => {
2316
+ const ds = this.datasource();
2317
+ this.loadContents(ds, null);
2318
+ });
2319
+ // Initialise column-view root pane when datasource is ready
2320
+ effect(() => {
2321
+ const ds = this.datasource();
2322
+ const mode = this.viewMode();
2323
+ if (mode === "column") {
2324
+ this.initColumnView(ds);
2325
+ }
2326
+ });
2327
+ }
2328
+ // ── Lifecycle ─────────────────────────────────────────────────────
2329
+ ngAfterViewInit() {
2330
+ const saved = this.loadPanelWidths();
2331
+ if (saved) {
2332
+ this.sidebarWidthPx.set(saved.sidebar);
2333
+ this.detailsWidthPx.set(saved.details);
2334
+ this.sidebarCollapsed.set(saved.sidebarCollapsed);
2335
+ this.detailsCollapsed.set(saved.detailsCollapsed);
2336
+ }
2337
+ }
2338
+ // ── Public methods ────────────────────────────────────────────────
2339
+ /** Navigate to the root directory. */
2340
+ navigateToRoot() {
2341
+ this.navigateTo(null, []);
2342
+ }
2343
+ /** Navigate to a specific directory entry. */
2344
+ navigateToDirectory(entry) {
2345
+ if (!entry.isDirectory)
2346
+ return;
2347
+ const newPath = [...this.path(), entry];
2348
+ this.navigateTo(entry, newPath);
2349
+ }
2350
+ // ── Protected methods ─────────────────────────────────────────────
2351
+ /** @internal — starts pointer-based divider dragging for a panel. */
2352
+ onDividerPointerDown(event, panel) {
2353
+ event.preventDefault();
2354
+ const divider = event.currentTarget;
2355
+ divider.setPointerCapture(event.pointerId);
2356
+ this.draggingPanel.set(panel);
2357
+ const body = this.elRef.nativeElement.querySelector(".fb-body");
2358
+ if (!body)
2359
+ return;
2360
+ const bodyRect = body.getBoundingClientRect();
2361
+ const onMove = (e) => {
2362
+ const cursor = e.clientX - bodyRect.left;
2363
+ const bodyWidth = bodyRect.width;
2364
+ if (panel === "sidebar") {
2365
+ const clampedPx = Math.max(80, Math.min(cursor, bodyWidth * 0.5));
2366
+ this.sidebarWidthPx.set(Math.round(clampedPx));
2367
+ this.sidebarCollapsed.set(false);
2368
+ }
2369
+ else {
2370
+ // Details panel: measure from the right edge
2371
+ const fromRight = bodyRect.right - e.clientX;
2372
+ const clampedPx = Math.max(120, Math.min(fromRight, bodyWidth * 0.5));
2373
+ this.detailsWidthPx.set(Math.round(clampedPx));
2374
+ this.detailsCollapsed.set(false);
2375
+ }
2376
+ };
2377
+ const onUp = () => {
2378
+ this.draggingPanel.set(null);
2379
+ divider.removeEventListener("pointermove", onMove);
2380
+ divider.removeEventListener("pointerup", onUp);
2381
+ divider.removeEventListener("pointercancel", onUp);
2382
+ this.savePanelWidths();
2383
+ };
2384
+ divider.addEventListener("pointermove", onMove);
2385
+ divider.addEventListener("pointerup", onUp);
2386
+ divider.addEventListener("pointercancel", onUp);
2387
+ }
2388
+ /** @internal — double-click on a divider toggles panel collapse. */
2389
+ onDividerDblClick(panel) {
2390
+ if (panel === "sidebar") {
2391
+ if (this.sidebarCollapsed()) {
2392
+ this.sidebarCollapsed.set(false);
2393
+ if (this.preSidebarCollapseWidth) {
2394
+ this.sidebarWidthPx.set(this.preSidebarCollapseWidth);
2395
+ this.preSidebarCollapseWidth = null;
2396
+ }
2397
+ }
2398
+ else {
2399
+ this.preSidebarCollapseWidth = this.sidebarWidthPx();
2400
+ this.sidebarCollapsed.set(true);
2401
+ }
2402
+ }
2403
+ else {
2404
+ if (this.detailsCollapsed()) {
2405
+ this.detailsCollapsed.set(false);
2406
+ if (this.preDetailsCollapseWidth) {
2407
+ this.detailsWidthPx.set(this.preDetailsCollapseWidth);
2408
+ this.preDetailsCollapseWidth = null;
2409
+ }
2410
+ }
2411
+ else {
2412
+ this.preDetailsCollapseWidth = this.detailsWidthPx();
2413
+ this.detailsCollapsed.set(true);
2414
+ }
2415
+ }
2416
+ this.savePanelWidths();
2417
+ }
2418
+ /** @internal */
2419
+ onBreadcrumbClick(item) {
2420
+ const items = this.breadcrumbItems();
2421
+ const idx = items.indexOf(item);
2422
+ if (idx === 0) {
2423
+ // Root
2424
+ this.navigateTo(null, []);
2425
+ }
2426
+ else {
2427
+ // Navigate to that ancestor
2428
+ const newPath = this.path().slice(0, idx);
2429
+ const target = newPath[newPath.length - 1] ?? null;
2430
+ this.navigateTo(target, [...newPath]);
2431
+ }
2432
+ }
2433
+ /** @internal */
2434
+ onTreeNodeSelected(selected) {
2435
+ if (selected.length === 0)
2436
+ return;
2437
+ const node = selected[0];
2438
+ const entry = node.data;
2439
+ if (entry.isDirectory) {
2440
+ const newPath = this.buildPathToNode(node);
2441
+ this.navigateTo(entry, newPath);
2442
+ }
2443
+ }
2444
+ /** @internal */
2445
+ onTreeNodeActivated(node) {
2446
+ const entry = node.data;
2447
+ if (entry.isDirectory) {
2448
+ const newPath = this.buildPathToNode(node);
2449
+ this.navigateTo(entry, newPath);
2450
+ }
2451
+ }
2452
+ /** @internal */
2453
+ onEntryClick(entry) {
2454
+ this.selectedEntry.set(entry);
2455
+ }
2456
+ /** @internal */
2457
+ onEntryDblClick(entry) {
2458
+ if (entry.isDirectory) {
2459
+ this.navigateToDirectory(entry);
2460
+ }
2461
+ else {
2462
+ this.fileActivated.emit({
2463
+ entry,
2464
+ activatedAt: new Date().toISOString(),
2465
+ });
2466
+ }
2467
+ }
2468
+ /** @internal */
2469
+ onEntryKeydown(event, entry) {
2470
+ if (event.key === "Enter") {
2471
+ event.preventDefault();
2472
+ this.onEntryDblClick(entry);
2473
+ }
2474
+ }
2475
+ /** @internal */
2476
+ getEntryIcon(entry) {
2477
+ if (entry.icon)
2478
+ return entry.icon;
2479
+ if (entry.isDirectory)
2480
+ return this.icons.folder;
2481
+ // Check DI icon registry by file extension
2482
+ if (this.iconRegistry) {
2483
+ const ext = this.extractExtension(entry.name);
2484
+ if (ext && ext in this.iconRegistry) {
2485
+ return this.iconRegistry[ext];
2486
+ }
2487
+ }
2488
+ return this.icons.file;
2489
+ }
2490
+ /** @internal */
2491
+ isEntrySelected(entry) {
2492
+ return this.selectedEntry()?.id === entry.id;
2493
+ }
2494
+ // ── Private methods ───────────────────────────────────────────────
2495
+ navigateTo(directory, path) {
2496
+ this.currentDirectory.set(directory);
2497
+ this.path.set(path);
2498
+ this.selectedEntry.set(null);
2499
+ this.loadContents(this.datasource(), directory);
2500
+ this.directoryChange.emit({ directory, path });
2501
+ // Expand the tree to the selected folder
2502
+ this.expandTreeToPath(path);
2503
+ }
2504
+ async loadContents(ds, parent) {
2505
+ const result = await Promise.resolve(ds.getChildren(parent));
2506
+ this.contents.set(result);
2507
+ }
2508
+ createTreeDatasource(ds) {
2509
+ return {
2510
+ getRootNodes: () => {
2511
+ const children = ds.getChildren(null);
2512
+ if (children instanceof Promise) {
2513
+ return children.then((items) => items.filter((e) => e.isDirectory).map((e) => entryToTreeNode(e)));
2514
+ }
2515
+ return children
2516
+ .filter((e) => e.isDirectory)
2517
+ .map((e) => entryToTreeNode(e));
2518
+ },
2519
+ getChildren: (node) => {
2520
+ const children = ds.getChildren(node.data);
2521
+ if (children instanceof Promise) {
2522
+ return children.then((items) => items.filter((e) => e.isDirectory).map((e) => entryToTreeNode(e)));
2523
+ }
2524
+ return children
2525
+ .filter((e) => e.isDirectory)
2526
+ .map((e) => entryToTreeNode(e));
2527
+ },
2528
+ hasChildren: (node) => ds.isDirectory(node.data),
2529
+ };
2530
+ }
2531
+ buildPathToNode(node) {
2532
+ // Walk the tree from roots to find the path to this node
2533
+ const ds = this.treeDatasource();
2534
+ const roots = ds.getRootNodes();
2535
+ if (!Array.isArray(roots))
2536
+ return [node.data];
2537
+ const path = [];
2538
+ const found = this.findNodePath(roots, node.id, path, ds);
2539
+ return found ? path : [node.data];
2540
+ }
2541
+ findNodePath(nodes, targetId, path, ds) {
2542
+ for (const n of nodes) {
2543
+ path.push(n.data);
2544
+ if (n.id === targetId)
2545
+ return true;
2546
+ if (ds.hasChildren(n)) {
2547
+ const children = ds.getChildren(n);
2548
+ if (Array.isArray(children) &&
2549
+ this.findNodePath(children, targetId, path, ds)) {
2550
+ return true;
2551
+ }
2552
+ }
2553
+ path.pop();
2554
+ }
2555
+ return false;
2556
+ }
2557
+ expandTreeToPath(path) {
2558
+ const tree = this.treeViewRef();
2559
+ if (!tree)
2560
+ return;
2561
+ for (const entry of path) {
2562
+ const node = entryToTreeNode(entry);
2563
+ tree.expand(node);
2564
+ }
2565
+ }
2566
+ // ── Column-view helpers ───────────────────────────────────────────
2567
+ /** @internal — handle click on an entry inside a column pane. */
2568
+ async onColumnEntryClick(paneIndex, entry) {
2569
+ // Update selection in this pane
2570
+ const sels = [...this.columnSelections()];
2571
+ sels[paneIndex] = entry;
2572
+ // Clear selections in deeper panes
2573
+ this.columnSelections.set(sels.slice(0, paneIndex + 1));
2574
+ this.selectedEntry.set(entry);
2575
+ // If directory, open a new pane to the right (truncate deeper panes first)
2576
+ if (entry.isDirectory) {
2577
+ const ds = this.datasource();
2578
+ const children = await Promise.resolve(ds.getChildren(entry));
2579
+ const panes = this.columnPanes().slice(0, paneIndex + 1);
2580
+ this.columnPanes.set([...panes, { directory: entry, entries: children }]);
2581
+ this.scrollLastColumnPaneIntoView();
2582
+ }
2583
+ else {
2584
+ // Truncate deeper panes for file selection
2585
+ this.columnPanes.set(this.columnPanes().slice(0, paneIndex + 1));
2586
+ }
2587
+ }
2588
+ /** @internal — double-click in column view activates a file. */
2589
+ onColumnEntryDblClick(entry) {
2590
+ if (!entry.isDirectory) {
2591
+ this.fileActivated.emit({
2592
+ entry,
2593
+ activatedAt: new Date().toISOString(),
2594
+ });
2595
+ }
2596
+ }
2597
+ /** @internal — check if entry is selected in a column pane. */
2598
+ isColumnEntrySelected(paneIndex, entry) {
2599
+ return this.columnSelections()[paneIndex]?.id === entry.id;
2600
+ }
2601
+ async initColumnView(ds) {
2602
+ const rootEntries = await Promise.resolve(ds.getChildren(null));
2603
+ this.columnPanes.set([{ directory: null, entries: rootEntries }]);
2604
+ this.columnSelections.set([]);
2605
+ }
2606
+ extractExtension(filename) {
2607
+ const dot = filename.lastIndexOf(".");
2608
+ if (dot < 1)
2609
+ return null;
2610
+ return filename.slice(dot + 1).toLowerCase();
2611
+ }
2612
+ /** @internal — scroll the rightmost column pane into view after DOM update. */
2613
+ scrollLastColumnPaneIntoView() {
2614
+ afterNextRender(() => {
2615
+ const container = this.elRef.nativeElement.querySelector(".fb-contents--column");
2616
+ if (container) {
2617
+ const lastPane = container.querySelector(".fb-column-pane:last-child");
2618
+ lastPane?.scrollIntoView?.({
2619
+ behavior: "smooth",
2620
+ block: "nearest",
2621
+ inline: "end",
2622
+ });
2623
+ }
2624
+ }, { injector: this.injector });
2625
+ }
2626
+ // ── Panel width persistence ───────────────────────────────────────
2627
+ static STORAGE_PREFIX = "ui-file-browser:";
2628
+ savePanelWidths() {
2629
+ const key = this.name();
2630
+ if (!key)
2631
+ return;
2632
+ const data = JSON.stringify({
2633
+ sidebar: this.sidebarWidthPx(),
2634
+ details: this.detailsWidthPx(),
2635
+ sidebarCollapsed: this.sidebarCollapsed(),
2636
+ detailsCollapsed: this.detailsCollapsed(),
2637
+ });
2638
+ this.storage.setItem(UIFileBrowser.STORAGE_PREFIX + key, data);
2639
+ }
2640
+ loadPanelWidths() {
2641
+ const key = this.name();
2642
+ if (!key)
2643
+ return null;
2644
+ try {
2645
+ const raw = this.storage.getItem(UIFileBrowser.STORAGE_PREFIX + key);
2646
+ if (!raw)
2647
+ return null;
2648
+ const parsed = JSON.parse(raw);
2649
+ if (typeof parsed === "object" &&
2650
+ parsed !== null &&
2651
+ typeof parsed.sidebar === "number" &&
2652
+ typeof parsed.details === "number") {
2653
+ return {
2654
+ sidebar: parsed.sidebar,
2655
+ details: parsed.details,
2656
+ sidebarCollapsed: !!parsed.sidebarCollapsed,
2657
+ detailsCollapsed: !!parsed.detailsCollapsed,
2658
+ };
2659
+ }
2660
+ }
2661
+ catch {
2662
+ // Corrupt data — ignore.
2663
+ }
2664
+ return null;
2665
+ }
2666
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIFileBrowser, deps: [], target: i0.ɵɵFactoryTarget.Component });
2667
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIFileBrowser, isStandalone: true, selector: "ui-file-browser", inputs: { datasource: { classPropertyName: "datasource", publicName: "datasource", isSignal: true, isRequired: true, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, showSidebar: { classPropertyName: "showSidebar", publicName: "showSidebar", isSignal: true, isRequired: false, transformFunction: null }, rootLabel: { classPropertyName: "rootLabel", publicName: "rootLabel", isSignal: true, isRequired: false, transformFunction: null }, viewMode: { classPropertyName: "viewMode", publicName: "viewMode", isSignal: true, isRequired: false, transformFunction: null }, showDetails: { classPropertyName: "showDetails", publicName: "showDetails", isSignal: true, isRequired: false, transformFunction: null }, name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, metadataProvider: { classPropertyName: "metadataProvider", publicName: "metadataProvider", isSignal: true, isRequired: false, transformFunction: null }, selectedEntry: { classPropertyName: "selectedEntry", publicName: "selectedEntry", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectedEntry: "selectedEntryChange", fileActivated: "fileActivated", directoryChange: "directoryChange" }, host: { properties: { "class.dragging": "draggingPanel()", "style.--fb-sidebar-width": "sidebarWidthPx() + 'px'", "style.--fb-details-width": "detailsWidthPx() + 'px'" }, classAttribute: "ui-file-browser" }, queries: [{ propertyName: "entryTemplate", first: true, predicate: ["entryTemplate"], descendants: true, isSignal: true }], viewQueries: [{ propertyName: "treeViewRef", first: true, predicate: ["treeView"], descendants: true, isSignal: true }], hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "<div class=\"fb-header\">\n <ui-breadcrumb\n [items]=\"breadcrumbItems()\"\n variant=\"button\"\n ariaLabel=\"File path\"\n (itemClicked)=\"onBreadcrumbClick($event)\"\n />\n</div>\n\n<div class=\"fb-body\">\n <!-- Sidebar: Folder tree -->\n @if (showSidebar()) {\n <div class=\"fb-sidebar\" [class.fb-sidebar--collapsed]=\"sidebarCollapsed()\">\n @if (!sidebarCollapsed()) {\n <ui-tree-view\n #treeView\n [datasource]=\"treeDatasource()\"\n [displayWith]=\"displayNodeLabel\"\n ariaLabel=\"Folder tree\"\n [selected]=\"treeSelected()\"\n (selectedChange)=\"onTreeNodeSelected($event)\"\n (nodeActivated)=\"onTreeNodeActivated($event)\"\n >\n <ng-template #nodeTemplate let-node>\n <ui-icon\n [svg]=\"node.data.icon ?? icons.folder\"\n [size]=\"16\"\n />\n <span class=\"fb-node-label\">{{ node.data.name }}</span>\n </ng-template>\n </ui-tree-view>\n }\n </div>\n\n <!-- Sidebar resize divider -->\n <div\n class=\"fb-divider fb-divider--sidebar ui-surface-type-raised\"\n role=\"separator\"\n tabindex=\"0\"\n aria-label=\"Resize sidebar\"\n aria-orientation=\"horizontal\"\n (pointerdown)=\"onDividerPointerDown($event, 'sidebar')\"\n (dblclick)=\"onDividerDblClick('sidebar')\"\n >\n <div class=\"fb-divider-handle\"></div>\n </div>\n }\n\n <!-- \u2550\u2550\u2550 Contents panel \u2014 switches on viewMode \u2550\u2550\u2550 -->\n\n @switch (viewMode()) {\n\n <!-- \u2500\u2500 List view (default) \u2500\u2500 -->\n @case ('list') {\n <div class=\"fb-contents fb-contents--list\" role=\"list\" [attr.aria-label]=\"ariaLabel()\">\n @if (contents().length === 0) {\n <div class=\"fb-empty\">Empty folder</div>\n } @else {\n @for (entry of contents(); track entry.id) {\n <div\n class=\"fb-entry\"\n role=\"listitem\"\n tabindex=\"0\"\n [class.fb-entry--selected]=\"isEntrySelected(entry)\"\n [class.fb-entry--directory]=\"entry.isDirectory\"\n (click)=\"onEntryClick(entry)\"\n (dblclick)=\"onEntryDblClick(entry)\"\n (keydown)=\"onEntryKeydown($event, entry)\"\n >\n @if (entryTemplate(); as tpl) {\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{ $implicit: entry }\"\n />\n } @else {\n <ui-icon class=\"fb-entry-icon\" [svg]=\"getEntryIcon(entry)\" [size]=\"18\" />\n <span class=\"fb-entry-name\">{{ entry.name }}</span>\n @if (entry.isDirectory) {\n <ui-icon class=\"fb-entry-chevron\" [svg]=\"icons.chevronRight\" [size]=\"14\" />\n }\n }\n </div>\n }\n }\n </div>\n }\n\n <!-- \u2500\u2500 Icon view \u2500\u2500 -->\n @case ('icons') {\n <div class=\"fb-contents fb-contents--icons\" role=\"list\" [attr.aria-label]=\"ariaLabel()\">\n @if (contents().length === 0) {\n <div class=\"fb-empty\">Empty folder</div>\n } @else {\n @for (entry of contents(); track entry.id) {\n <div\n class=\"fb-icon-tile\"\n role=\"listitem\"\n tabindex=\"0\"\n [class.fb-icon-tile--selected]=\"isEntrySelected(entry)\"\n (click)=\"onEntryClick(entry)\"\n (dblclick)=\"onEntryDblClick(entry)\"\n (keydown)=\"onEntryKeydown($event, entry)\"\n >\n <ui-icon class=\"fb-icon-tile-icon\" [svg]=\"getEntryIcon(entry)\" [size]=\"40\" />\n <span class=\"fb-icon-tile-name\">{{ entry.name }}</span>\n </div>\n }\n }\n </div>\n }\n\n <!-- \u2500\u2500 Detail view (table-like) \u2500\u2500 -->\n @case ('detail') {\n <div class=\"fb-contents fb-contents--detail\" role=\"table\" [attr.aria-label]=\"ariaLabel()\">\n <div class=\"fb-detail-header\" role=\"row\">\n <span class=\"fb-detail-cell fb-detail-cell--icon\" role=\"columnheader\"></span>\n <span class=\"fb-detail-cell fb-detail-cell--name\" role=\"columnheader\">Name</span>\n <span class=\"fb-detail-cell fb-detail-cell--meta\" role=\"columnheader\">Size</span>\n <span class=\"fb-detail-cell fb-detail-cell--meta\" role=\"columnheader\">Type</span>\n <span class=\"fb-detail-cell fb-detail-cell--meta\" role=\"columnheader\">Modified</span>\n </div>\n @if (contents().length === 0) {\n <div class=\"fb-empty\">Empty folder</div>\n } @else {\n @for (entry of contents(); track entry.id) {\n <div\n class=\"fb-detail-row\"\n role=\"row\"\n tabindex=\"0\"\n [class.fb-detail-row--selected]=\"isEntrySelected(entry)\"\n [class.fb-detail-row--directory]=\"entry.isDirectory\"\n (click)=\"onEntryClick(entry)\"\n (dblclick)=\"onEntryDblClick(entry)\"\n (keydown)=\"onEntryKeydown($event, entry)\"\n >\n <span class=\"fb-detail-cell fb-detail-cell--icon\" role=\"cell\">\n <ui-icon [svg]=\"getEntryIcon(entry)\" [size]=\"16\" />\n </span>\n <span class=\"fb-detail-cell fb-detail-cell--name\" role=\"cell\">{{ entry.name }}</span>\n <span class=\"fb-detail-cell fb-detail-cell--meta\" role=\"cell\">{{ $any(entry.meta)?.size ?? '\u2014' }}</span>\n <span class=\"fb-detail-cell fb-detail-cell--meta\" role=\"cell\">{{ $any(entry.meta)?.type ?? '\u2014' }}</span>\n <span class=\"fb-detail-cell fb-detail-cell--meta\" role=\"cell\">{{ $any(entry.meta)?.modified ?? '\u2014' }}</span>\n </div>\n }\n }\n </div>\n }\n\n <!-- \u2500\u2500 Tree view \u2500\u2500 -->\n @case ('tree') {\n <div class=\"fb-contents fb-contents--tree\" [attr.aria-label]=\"ariaLabel()\">\n <ui-tree-view\n [datasource]=\"treeDatasource()\"\n [displayWith]=\"displayNodeLabel\"\n ariaLabel=\"File tree\"\n (nodeActivated)=\"onTreeNodeActivated($event)\"\n >\n <ng-template #nodeTemplate let-node>\n <ui-icon [svg]=\"getEntryIcon(node.data)\" [size]=\"16\" />\n <span class=\"fb-node-label\">{{ node.data.name }}</span>\n </ng-template>\n </ui-tree-view>\n </div>\n }\n\n <!-- \u2500\u2500 Column view (NeXTSTEP / macOS Finder) \u2500\u2500 -->\n @case ('column') {\n <div class=\"fb-contents fb-contents--column\" [attr.aria-label]=\"ariaLabel()\">\n @for (pane of columnPanes(); track $index; let i = $index) {\n <div class=\"fb-column-pane\" role=\"list\">\n @for (entry of pane.entries; track entry.id) {\n <div\n class=\"fb-column-entry\"\n role=\"listitem\"\n tabindex=\"0\"\n [class.fb-column-entry--selected]=\"isColumnEntrySelected(i, entry)\"\n [class.fb-column-entry--directory]=\"entry.isDirectory\"\n (click)=\"onColumnEntryClick(i, entry)\"\n (dblclick)=\"onColumnEntryDblClick(entry)\"\n (keydown)=\"onEntryKeydown($event, entry)\"\n >\n <ui-icon [svg]=\"getEntryIcon(entry)\" [size]=\"16\" />\n <span class=\"fb-column-entry-name\">{{ entry.name }}</span>\n @if (entry.isDirectory) {\n <ui-icon class=\"fb-entry-chevron\" [svg]=\"icons.chevronRight\" [size]=\"12\" />\n }\n </div>\n } @empty {\n <div class=\"fb-empty\">Empty</div>\n }\n </div>\n }\n </div>\n }\n }\n\n <!-- \u2550\u2550\u2550 Details pane \u2550\u2550\u2550 -->\n @if (showDetails() && hasSelection()) {\n <!-- Details resize divider -->\n <div\n class=\"fb-divider fb-divider--details ui-surface-type-raised\"\n role=\"separator\"\n tabindex=\"0\"\n aria-label=\"Resize details\"\n aria-orientation=\"horizontal\"\n (pointerdown)=\"onDividerPointerDown($event, 'details')\"\n (dblclick)=\"onDividerDblClick('details')\"\n >\n <div class=\"fb-divider-handle\"></div>\n </div>\n\n <aside class=\"fb-details\" [class.fb-details--collapsed]=\"detailsCollapsed()\" aria-label=\"File details\">\n @if (!detailsCollapsed()) {\n <div class=\"fb-details-header\">\n <ui-icon [svg]=\"getEntryIcon(selectedEntry()!)\" [size]=\"32\" />\n <span class=\"fb-details-name\">{{ selectedEntry()!.name }}</span>\n <span class=\"fb-details-kind\">\n {{ selectedEntry()!.isDirectory ? 'Folder' : 'File' }}\n </span>\n </div>\n\n @if (detailFields().length > 0) {\n <dl class=\"fb-details-meta\">\n @for (field of detailFields(); track field.label) {\n <dt class=\"fb-details-label\">{{ field.label }}</dt>\n <dd class=\"fb-details-value\">{{ field.value }}</dd>\n }\n </dl>\n }\n }\n </aside>\n }\n</div>\n", styles: [":host{--fb-sidebar-width: 240px;--fb-details-width: 220px;--fb-column-width: 220px;display:flex;flex-direction:column;height:100%;border-radius:8px;overflow:hidden}.fb-header{display:flex;align-items:center;padding:8px 12px;min-height:40px}.fb-body{display:flex;flex:1;min-height:0}.fb-sidebar{width:var(--fb-sidebar-width);min-width:var(--fb-sidebar-width);border-right:none;overflow-y:auto;padding:4px 0;transition:width .15s ease,min-width .15s ease}.fb-sidebar ui-tree-view{font-size:13px}.fb-sidebar--collapsed{width:0;min-width:0;overflow:hidden;padding:0}.fb-node-label{margin-left:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.fb-divider{flex:0 0 5px;display:flex;align-items:center;justify-content:center;border:none;outline:none;-webkit-user-select:none;user-select:none;touch-action:none;cursor:col-resize;background:transparent;transition:background-color .15s ease;position:relative;z-index:2}.fb-divider:hover{background:var(--ui-hover-bg, rgba(0, 0, 0, .06))}.fb-divider:hover .fb-divider-handle{background:var(--ui-accent, #3584e4)}.fb-divider:focus-visible{outline-offset:-2px}.fb-divider-handle{width:2px;height:24px;border-radius:2px;background:var(--ui-border-strong, #505d6d);transition:background-color .15s ease}:host(.dragging){cursor:col-resize}:host(.dragging) .fb-contents,:host(.dragging) .fb-sidebar,:host(.dragging) .fb-details{pointer-events:none;-webkit-user-select:none;user-select:none}.fb-contents{flex:1;overflow-y:auto;padding:4px 0;min-width:0}.fb-empty{padding:32px 16px;text-align:center;font-size:14px}.fb-entry{display:flex;align-items:center;gap:8px;padding:6px 12px;cursor:pointer;font-size:14px;border-radius:4px;margin:1px 6px;transition:background 60ms ease;outline:none}.fb-entry-icon{flex-shrink:0}.fb-entry-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.fb-entry-chevron{flex-shrink:0;opacity:.5}.fb-entry--directory{font-weight:500}.fb-contents--icons{display:flex;flex-wrap:wrap;align-content:flex-start;gap:4px;padding:12px}.fb-icon-tile{display:flex;flex-direction:column;align-items:center;justify-content:flex-start;gap:6px;width:88px;padding:10px 4px;border-radius:6px;cursor:pointer;text-align:center;outline:none;transition:background 60ms ease}.fb-icon-tile-icon{flex-shrink:0}.fb-icon-tile-name{font-size:11px;line-height:1.3;max-width:80px;word-break:break-word;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.fb-contents--detail{padding:0}.fb-detail-header{display:flex;align-items:center;padding:6px 12px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;position:sticky;top:0;z-index:1}.fb-detail-row{display:flex;align-items:center;padding:5px 12px;font-size:13px;cursor:pointer;outline:none;transition:background 60ms ease}.fb-detail-row.fb-detail-row--directory{font-weight:500}.fb-detail-cell{padding:0 6px}.fb-detail-cell--icon{flex:0 0 28px;display:flex;align-items:center;justify-content:center}.fb-detail-cell--name{flex:2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.fb-detail-cell--meta{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;font-size:12px}.fb-detail-row--selected .fb-detail-cell--meta{opacity:.8}.fb-contents--tree ui-tree-view{font-size:13px}.fb-contents--column{display:flex;overflow-x:auto;overflow-y:hidden;padding:0}.fb-column-pane{flex:0 0 var(--fb-column-width);min-width:var(--fb-column-width);overflow-y:auto;padding:2px 0}.fb-column-pane:last-child{border-right:none}.fb-column-entry{display:flex;align-items:center;gap:6px;padding:5px 10px;font-size:13px;cursor:pointer;outline:none;transition:background 60ms ease}.fb-column-entry.fb-column-entry--directory{font-weight:500}.fb-column-entry-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.fb-details{width:var(--fb-details-width);min-width:var(--fb-details-width);border-left:none;overflow-y:auto;padding:16px 12px;display:flex;flex-direction:column;gap:16px;transition:width .15s ease,min-width .15s ease}.fb-details--collapsed{width:0;min-width:0;overflow:hidden;padding:0}.fb-details-header{display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center}.fb-details-name{font-weight:600;font-size:14px;word-break:break-word}.fb-details-kind{font-size:12px}.fb-details-meta{display:grid;grid-template-columns:auto 1fr;gap:6px 10px;font-size:12px;margin:0}.fb-details-label{font-weight:600}.fb-details-value{margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}\n"], dependencies: [{ kind: "component", type: UITreeView, selector: "ui-tree-view", inputs: ["disabled", "datasource", "ariaLabel", "displayWith", "filterPredicate", "sortComparator", "selected"], outputs: ["selectedChange", "nodeExpanded", "nodeCollapsed", "nodeActivated"] }, { kind: "component", type: UIBreadcrumb, selector: "ui-breadcrumb", inputs: ["disabled", "items", "variant", "separator", "ariaLabel"], outputs: ["itemClicked"] }, { kind: "component", type: UIIcon, selector: "ui-icon", inputs: ["svg", "size", "ariaLabel"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2668
+ }
2669
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIFileBrowser, decorators: [{
2670
+ type: Component,
2671
+ args: [{ selector: "ui-file-browser", standalone: true, imports: [UITreeView, UIBreadcrumb, UIIcon, NgTemplateOutlet], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ["surfaceType"] }], host: {
2672
+ class: "ui-file-browser",
2673
+ "[class.dragging]": "draggingPanel()",
2674
+ "[style.--fb-sidebar-width]": "sidebarWidthPx() + 'px'",
2675
+ "[style.--fb-details-width]": "detailsWidthPx() + 'px'",
2676
+ }, template: "<div class=\"fb-header\">\n <ui-breadcrumb\n [items]=\"breadcrumbItems()\"\n variant=\"button\"\n ariaLabel=\"File path\"\n (itemClicked)=\"onBreadcrumbClick($event)\"\n />\n</div>\n\n<div class=\"fb-body\">\n <!-- Sidebar: Folder tree -->\n @if (showSidebar()) {\n <div class=\"fb-sidebar\" [class.fb-sidebar--collapsed]=\"sidebarCollapsed()\">\n @if (!sidebarCollapsed()) {\n <ui-tree-view\n #treeView\n [datasource]=\"treeDatasource()\"\n [displayWith]=\"displayNodeLabel\"\n ariaLabel=\"Folder tree\"\n [selected]=\"treeSelected()\"\n (selectedChange)=\"onTreeNodeSelected($event)\"\n (nodeActivated)=\"onTreeNodeActivated($event)\"\n >\n <ng-template #nodeTemplate let-node>\n <ui-icon\n [svg]=\"node.data.icon ?? icons.folder\"\n [size]=\"16\"\n />\n <span class=\"fb-node-label\">{{ node.data.name }}</span>\n </ng-template>\n </ui-tree-view>\n }\n </div>\n\n <!-- Sidebar resize divider -->\n <div\n class=\"fb-divider fb-divider--sidebar ui-surface-type-raised\"\n role=\"separator\"\n tabindex=\"0\"\n aria-label=\"Resize sidebar\"\n aria-orientation=\"horizontal\"\n (pointerdown)=\"onDividerPointerDown($event, 'sidebar')\"\n (dblclick)=\"onDividerDblClick('sidebar')\"\n >\n <div class=\"fb-divider-handle\"></div>\n </div>\n }\n\n <!-- \u2550\u2550\u2550 Contents panel \u2014 switches on viewMode \u2550\u2550\u2550 -->\n\n @switch (viewMode()) {\n\n <!-- \u2500\u2500 List view (default) \u2500\u2500 -->\n @case ('list') {\n <div class=\"fb-contents fb-contents--list\" role=\"list\" [attr.aria-label]=\"ariaLabel()\">\n @if (contents().length === 0) {\n <div class=\"fb-empty\">Empty folder</div>\n } @else {\n @for (entry of contents(); track entry.id) {\n <div\n class=\"fb-entry\"\n role=\"listitem\"\n tabindex=\"0\"\n [class.fb-entry--selected]=\"isEntrySelected(entry)\"\n [class.fb-entry--directory]=\"entry.isDirectory\"\n (click)=\"onEntryClick(entry)\"\n (dblclick)=\"onEntryDblClick(entry)\"\n (keydown)=\"onEntryKeydown($event, entry)\"\n >\n @if (entryTemplate(); as tpl) {\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"{ $implicit: entry }\"\n />\n } @else {\n <ui-icon class=\"fb-entry-icon\" [svg]=\"getEntryIcon(entry)\" [size]=\"18\" />\n <span class=\"fb-entry-name\">{{ entry.name }}</span>\n @if (entry.isDirectory) {\n <ui-icon class=\"fb-entry-chevron\" [svg]=\"icons.chevronRight\" [size]=\"14\" />\n }\n }\n </div>\n }\n }\n </div>\n }\n\n <!-- \u2500\u2500 Icon view \u2500\u2500 -->\n @case ('icons') {\n <div class=\"fb-contents fb-contents--icons\" role=\"list\" [attr.aria-label]=\"ariaLabel()\">\n @if (contents().length === 0) {\n <div class=\"fb-empty\">Empty folder</div>\n } @else {\n @for (entry of contents(); track entry.id) {\n <div\n class=\"fb-icon-tile\"\n role=\"listitem\"\n tabindex=\"0\"\n [class.fb-icon-tile--selected]=\"isEntrySelected(entry)\"\n (click)=\"onEntryClick(entry)\"\n (dblclick)=\"onEntryDblClick(entry)\"\n (keydown)=\"onEntryKeydown($event, entry)\"\n >\n <ui-icon class=\"fb-icon-tile-icon\" [svg]=\"getEntryIcon(entry)\" [size]=\"40\" />\n <span class=\"fb-icon-tile-name\">{{ entry.name }}</span>\n </div>\n }\n }\n </div>\n }\n\n <!-- \u2500\u2500 Detail view (table-like) \u2500\u2500 -->\n @case ('detail') {\n <div class=\"fb-contents fb-contents--detail\" role=\"table\" [attr.aria-label]=\"ariaLabel()\">\n <div class=\"fb-detail-header\" role=\"row\">\n <span class=\"fb-detail-cell fb-detail-cell--icon\" role=\"columnheader\"></span>\n <span class=\"fb-detail-cell fb-detail-cell--name\" role=\"columnheader\">Name</span>\n <span class=\"fb-detail-cell fb-detail-cell--meta\" role=\"columnheader\">Size</span>\n <span class=\"fb-detail-cell fb-detail-cell--meta\" role=\"columnheader\">Type</span>\n <span class=\"fb-detail-cell fb-detail-cell--meta\" role=\"columnheader\">Modified</span>\n </div>\n @if (contents().length === 0) {\n <div class=\"fb-empty\">Empty folder</div>\n } @else {\n @for (entry of contents(); track entry.id) {\n <div\n class=\"fb-detail-row\"\n role=\"row\"\n tabindex=\"0\"\n [class.fb-detail-row--selected]=\"isEntrySelected(entry)\"\n [class.fb-detail-row--directory]=\"entry.isDirectory\"\n (click)=\"onEntryClick(entry)\"\n (dblclick)=\"onEntryDblClick(entry)\"\n (keydown)=\"onEntryKeydown($event, entry)\"\n >\n <span class=\"fb-detail-cell fb-detail-cell--icon\" role=\"cell\">\n <ui-icon [svg]=\"getEntryIcon(entry)\" [size]=\"16\" />\n </span>\n <span class=\"fb-detail-cell fb-detail-cell--name\" role=\"cell\">{{ entry.name }}</span>\n <span class=\"fb-detail-cell fb-detail-cell--meta\" role=\"cell\">{{ $any(entry.meta)?.size ?? '\u2014' }}</span>\n <span class=\"fb-detail-cell fb-detail-cell--meta\" role=\"cell\">{{ $any(entry.meta)?.type ?? '\u2014' }}</span>\n <span class=\"fb-detail-cell fb-detail-cell--meta\" role=\"cell\">{{ $any(entry.meta)?.modified ?? '\u2014' }}</span>\n </div>\n }\n }\n </div>\n }\n\n <!-- \u2500\u2500 Tree view \u2500\u2500 -->\n @case ('tree') {\n <div class=\"fb-contents fb-contents--tree\" [attr.aria-label]=\"ariaLabel()\">\n <ui-tree-view\n [datasource]=\"treeDatasource()\"\n [displayWith]=\"displayNodeLabel\"\n ariaLabel=\"File tree\"\n (nodeActivated)=\"onTreeNodeActivated($event)\"\n >\n <ng-template #nodeTemplate let-node>\n <ui-icon [svg]=\"getEntryIcon(node.data)\" [size]=\"16\" />\n <span class=\"fb-node-label\">{{ node.data.name }}</span>\n </ng-template>\n </ui-tree-view>\n </div>\n }\n\n <!-- \u2500\u2500 Column view (NeXTSTEP / macOS Finder) \u2500\u2500 -->\n @case ('column') {\n <div class=\"fb-contents fb-contents--column\" [attr.aria-label]=\"ariaLabel()\">\n @for (pane of columnPanes(); track $index; let i = $index) {\n <div class=\"fb-column-pane\" role=\"list\">\n @for (entry of pane.entries; track entry.id) {\n <div\n class=\"fb-column-entry\"\n role=\"listitem\"\n tabindex=\"0\"\n [class.fb-column-entry--selected]=\"isColumnEntrySelected(i, entry)\"\n [class.fb-column-entry--directory]=\"entry.isDirectory\"\n (click)=\"onColumnEntryClick(i, entry)\"\n (dblclick)=\"onColumnEntryDblClick(entry)\"\n (keydown)=\"onEntryKeydown($event, entry)\"\n >\n <ui-icon [svg]=\"getEntryIcon(entry)\" [size]=\"16\" />\n <span class=\"fb-column-entry-name\">{{ entry.name }}</span>\n @if (entry.isDirectory) {\n <ui-icon class=\"fb-entry-chevron\" [svg]=\"icons.chevronRight\" [size]=\"12\" />\n }\n </div>\n } @empty {\n <div class=\"fb-empty\">Empty</div>\n }\n </div>\n }\n </div>\n }\n }\n\n <!-- \u2550\u2550\u2550 Details pane \u2550\u2550\u2550 -->\n @if (showDetails() && hasSelection()) {\n <!-- Details resize divider -->\n <div\n class=\"fb-divider fb-divider--details ui-surface-type-raised\"\n role=\"separator\"\n tabindex=\"0\"\n aria-label=\"Resize details\"\n aria-orientation=\"horizontal\"\n (pointerdown)=\"onDividerPointerDown($event, 'details')\"\n (dblclick)=\"onDividerDblClick('details')\"\n >\n <div class=\"fb-divider-handle\"></div>\n </div>\n\n <aside class=\"fb-details\" [class.fb-details--collapsed]=\"detailsCollapsed()\" aria-label=\"File details\">\n @if (!detailsCollapsed()) {\n <div class=\"fb-details-header\">\n <ui-icon [svg]=\"getEntryIcon(selectedEntry()!)\" [size]=\"32\" />\n <span class=\"fb-details-name\">{{ selectedEntry()!.name }}</span>\n <span class=\"fb-details-kind\">\n {{ selectedEntry()!.isDirectory ? 'Folder' : 'File' }}\n </span>\n </div>\n\n @if (detailFields().length > 0) {\n <dl class=\"fb-details-meta\">\n @for (field of detailFields(); track field.label) {\n <dt class=\"fb-details-label\">{{ field.label }}</dt>\n <dd class=\"fb-details-value\">{{ field.value }}</dd>\n }\n </dl>\n }\n }\n </aside>\n }\n</div>\n", styles: [":host{--fb-sidebar-width: 240px;--fb-details-width: 220px;--fb-column-width: 220px;display:flex;flex-direction:column;height:100%;border-radius:8px;overflow:hidden}.fb-header{display:flex;align-items:center;padding:8px 12px;min-height:40px}.fb-body{display:flex;flex:1;min-height:0}.fb-sidebar{width:var(--fb-sidebar-width);min-width:var(--fb-sidebar-width);border-right:none;overflow-y:auto;padding:4px 0;transition:width .15s ease,min-width .15s ease}.fb-sidebar ui-tree-view{font-size:13px}.fb-sidebar--collapsed{width:0;min-width:0;overflow:hidden;padding:0}.fb-node-label{margin-left:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.fb-divider{flex:0 0 5px;display:flex;align-items:center;justify-content:center;border:none;outline:none;-webkit-user-select:none;user-select:none;touch-action:none;cursor:col-resize;background:transparent;transition:background-color .15s ease;position:relative;z-index:2}.fb-divider:hover{background:var(--ui-hover-bg, rgba(0, 0, 0, .06))}.fb-divider:hover .fb-divider-handle{background:var(--ui-accent, #3584e4)}.fb-divider:focus-visible{outline-offset:-2px}.fb-divider-handle{width:2px;height:24px;border-radius:2px;background:var(--ui-border-strong, #505d6d);transition:background-color .15s ease}:host(.dragging){cursor:col-resize}:host(.dragging) .fb-contents,:host(.dragging) .fb-sidebar,:host(.dragging) .fb-details{pointer-events:none;-webkit-user-select:none;user-select:none}.fb-contents{flex:1;overflow-y:auto;padding:4px 0;min-width:0}.fb-empty{padding:32px 16px;text-align:center;font-size:14px}.fb-entry{display:flex;align-items:center;gap:8px;padding:6px 12px;cursor:pointer;font-size:14px;border-radius:4px;margin:1px 6px;transition:background 60ms ease;outline:none}.fb-entry-icon{flex-shrink:0}.fb-entry-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.fb-entry-chevron{flex-shrink:0;opacity:.5}.fb-entry--directory{font-weight:500}.fb-contents--icons{display:flex;flex-wrap:wrap;align-content:flex-start;gap:4px;padding:12px}.fb-icon-tile{display:flex;flex-direction:column;align-items:center;justify-content:flex-start;gap:6px;width:88px;padding:10px 4px;border-radius:6px;cursor:pointer;text-align:center;outline:none;transition:background 60ms ease}.fb-icon-tile-icon{flex-shrink:0}.fb-icon-tile-name{font-size:11px;line-height:1.3;max-width:80px;word-break:break-word;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.fb-contents--detail{padding:0}.fb-detail-header{display:flex;align-items:center;padding:6px 12px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;position:sticky;top:0;z-index:1}.fb-detail-row{display:flex;align-items:center;padding:5px 12px;font-size:13px;cursor:pointer;outline:none;transition:background 60ms ease}.fb-detail-row.fb-detail-row--directory{font-weight:500}.fb-detail-cell{padding:0 6px}.fb-detail-cell--icon{flex:0 0 28px;display:flex;align-items:center;justify-content:center}.fb-detail-cell--name{flex:2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.fb-detail-cell--meta{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;font-size:12px}.fb-detail-row--selected .fb-detail-cell--meta{opacity:.8}.fb-contents--tree ui-tree-view{font-size:13px}.fb-contents--column{display:flex;overflow-x:auto;overflow-y:hidden;padding:0}.fb-column-pane{flex:0 0 var(--fb-column-width);min-width:var(--fb-column-width);overflow-y:auto;padding:2px 0}.fb-column-pane:last-child{border-right:none}.fb-column-entry{display:flex;align-items:center;gap:6px;padding:5px 10px;font-size:13px;cursor:pointer;outline:none;transition:background 60ms ease}.fb-column-entry.fb-column-entry--directory{font-weight:500}.fb-column-entry-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.fb-details{width:var(--fb-details-width);min-width:var(--fb-details-width);border-left:none;overflow-y:auto;padding:16px 12px;display:flex;flex-direction:column;gap:16px;transition:width .15s ease,min-width .15s ease}.fb-details--collapsed{width:0;min-width:0;overflow:hidden;padding:0}.fb-details-header{display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center}.fb-details-name{font-weight:600;font-size:14px;word-break:break-word}.fb-details-kind{font-size:12px}.fb-details-meta{display:grid;grid-template-columns:auto 1fr;gap:6px 10px;font-size:12px;margin:0}.fb-details-label{font-weight:600}.fb-details-value{margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}\n"] }]
2677
+ }], ctorParameters: () => [], propDecorators: { datasource: [{ type: i0.Input, args: [{ isSignal: true, alias: "datasource", required: true }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], showSidebar: [{ type: i0.Input, args: [{ isSignal: true, alias: "showSidebar", required: false }] }], rootLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "rootLabel", required: false }] }], viewMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "viewMode", required: false }] }], showDetails: [{ type: i0.Input, args: [{ isSignal: true, alias: "showDetails", required: false }] }], name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: false }] }], metadataProvider: [{ type: i0.Input, args: [{ isSignal: true, alias: "metadataProvider", required: false }] }], selectedEntry: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectedEntry", required: false }] }, { type: i0.Output, args: ["selectedEntryChange"] }], fileActivated: [{ type: i0.Output, args: ["fileActivated"] }], directoryChange: [{ type: i0.Output, args: ["directoryChange"] }], entryTemplate: [{ type: i0.ContentChild, args: ["entryTemplate", { isSignal: true }] }], treeViewRef: [{ type: i0.ViewChild, args: ["treeView", { isSignal: true }] }] } });
2678
+
2679
+ /**
2680
+ * A single chat message bubble with optional avatar, sender name,
2681
+ * content, and timestamp.
2682
+ *
2683
+ * The bubble adjusts its visual alignment and styling based on the
2684
+ * `isMine` input — right-aligned with accent colour for the
2685
+ * current user, left-aligned with a neutral surface for others.
2686
+ *
2687
+ * ### Usage
2688
+ * ```html
2689
+ * <ui-message-bubble [message]="msg" [isMine]="false" />
2690
+ * ```
2691
+ */
2692
+ class UIMessageBubble {
2693
+ // ── Inputs ────────────────────────────────────────────────────────
2694
+ /** The message to render. */
2695
+ message = input.required(...(ngDevMode ? [{ debugName: "message" }] : []));
2696
+ /** Whether this message belongs to the current user. */
2697
+ isMine = input(false, ...(ngDevMode ? [{ debugName: "isMine" }] : []));
2698
+ // ── Computed ──────────────────────────────────────────────────────
2699
+ /** @internal — whether content should be rendered as HTML. */
2700
+ isRichText = computed(() => (this.message().type ?? "text") === "rich-text", ...(ngDevMode ? [{ debugName: "isRichText" }] : []));
2701
+ /** @internal — formatted short time string. */
2702
+ timeString = computed(() => {
2703
+ const ts = this.message().timestamp;
2704
+ return ts.toLocaleTimeString(undefined, {
2705
+ hour: "numeric",
2706
+ minute: "2-digit",
2707
+ });
2708
+ }, ...(ngDevMode ? [{ debugName: "timeString" }] : []));
2709
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIMessageBubble, deps: [], target: i0.ɵɵFactoryTarget.Component });
2710
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIMessageBubble, isStandalone: true, selector: "ui-message-bubble", inputs: { message: { classPropertyName: "message", publicName: "message", isSignal: true, isRequired: true, transformFunction: null }, isMine: { classPropertyName: "isMine", publicName: "isMine", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class.mine": "isMine()" }, classAttribute: "ui-message-bubble" }, ngImport: i0, template: "@if (!isMine()) {\n <ui-avatar\n [name]=\"message().sender.name\"\n [src]=\"message().sender.avatarSrc\"\n [email]=\"message().sender.avatarEmail\"\n size=\"small\"\n />\n}\n\n<div class=\"bubble-body\">\n @if (!isMine()) {\n <span class=\"bubble-sender\">{{ message().sender.name }}</span>\n }\n\n @if (isRichText()) {\n <div class=\"bubble-content\" [innerHTML]=\"message().content\"></div>\n } @else {\n <div class=\"bubble-content\">{{ message().content }}</div>\n }\n\n <time class=\"bubble-timestamp\">{{ timeString() }}</time>\n</div>\n", styles: [":host{display:flex;align-items:flex-end;gap:8px;max-width:80%;margin-bottom:2px}:host(.mine){align-self:flex-end;flex-direction:row-reverse}:host(.mine) .bubble-body{align-items:flex-end}:host(.mine) .bubble-content{color:var(--ui-accent-contrast, #ffffff);background:var(--ui-accent, #3584e4);border-radius:16px 16px 4px}:host(.mine) .bubble-timestamp{text-align:right}.bubble-body{display:flex;flex-direction:column;gap:2px;min-width:0}.bubble-sender{font-size:.75rem;font-weight:600;padding-left:4px;color:var(--ui-text-muted, #5a6470)}.bubble-content{padding:8px 14px;border-radius:16px 16px 16px 4px;font-size:.875rem;line-height:1.45;word-wrap:break-word;overflow-wrap:break-word;color:var(--ui-text, #1d232b);background:var(--ui-surface-2, #f0f2f5)}.bubble-timestamp{font-size:.6875rem;padding:0 4px;color:var(--ui-text-muted, #5a6470)}\n"], dependencies: [{ kind: "component", type: UIAvatar, selector: "ui-avatar", inputs: ["src", "email", "name", "size", "ariaLabel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2711
+ }
2712
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIMessageBubble, decorators: [{
2713
+ type: Component,
2714
+ args: [{ selector: "ui-message-bubble", standalone: true, imports: [UIAvatar], changeDetection: ChangeDetectionStrategy.OnPush, host: {
2715
+ class: "ui-message-bubble",
2716
+ "[class.mine]": "isMine()",
2717
+ }, template: "@if (!isMine()) {\n <ui-avatar\n [name]=\"message().sender.name\"\n [src]=\"message().sender.avatarSrc\"\n [email]=\"message().sender.avatarEmail\"\n size=\"small\"\n />\n}\n\n<div class=\"bubble-body\">\n @if (!isMine()) {\n <span class=\"bubble-sender\">{{ message().sender.name }}</span>\n }\n\n @if (isRichText()) {\n <div class=\"bubble-content\" [innerHTML]=\"message().content\"></div>\n } @else {\n <div class=\"bubble-content\">{{ message().content }}</div>\n }\n\n <time class=\"bubble-timestamp\">{{ timeString() }}</time>\n</div>\n", styles: [":host{display:flex;align-items:flex-end;gap:8px;max-width:80%;margin-bottom:2px}:host(.mine){align-self:flex-end;flex-direction:row-reverse}:host(.mine) .bubble-body{align-items:flex-end}:host(.mine) .bubble-content{color:var(--ui-accent-contrast, #ffffff);background:var(--ui-accent, #3584e4);border-radius:16px 16px 4px}:host(.mine) .bubble-timestamp{text-align:right}.bubble-body{display:flex;flex-direction:column;gap:2px;min-width:0}.bubble-sender{font-size:.75rem;font-weight:600;padding-left:4px;color:var(--ui-text-muted, #5a6470)}.bubble-content{padding:8px 14px;border-radius:16px 16px 16px 4px;font-size:.875rem;line-height:1.45;word-wrap:break-word;overflow-wrap:break-word;color:var(--ui-text, #1d232b);background:var(--ui-surface-2, #f0f2f5)}.bubble-timestamp{font-size:.6875rem;padding:0 4px;color:var(--ui-text-muted, #5a6470)}\n"] }]
2718
+ }], propDecorators: { message: [{ type: i0.Input, args: [{ isSignal: true, alias: "message", required: true }] }], isMine: [{ type: i0.Input, args: [{ isSignal: true, alias: "isMine", required: false }] }] } });
2719
+
2720
+ /**
2721
+ * A chat / messaging view composing UIAvatar, UIRichTextEditor,
2722
+ * and a scrollable message list with a composer bar.
2723
+ *
2724
+ * Messages are supplied via the `messages` input. The component
2725
+ * groups them by date and renders each with sender avatar, bubble,
2726
+ * and timestamp. The current user's messages are right-aligned.
2727
+ *
2728
+ * The composer bar supports both plain-text (`textarea`) and
2729
+ * rich-text (`UIRichTextEditor`) modes via the `composerMode`
2730
+ * input.
2731
+ *
2732
+ * Custom message templates can be projected using a
2733
+ * `#messageTemplate` content child.
2734
+ *
2735
+ * ### Basic usage
2736
+ * ```html
2737
+ * <ui-chat-view
2738
+ * [messages]="messages"
2739
+ * [currentUser]="me"
2740
+ * (messageSend)="onSend($event)"
2741
+ * />
2742
+ * ```
2743
+ *
2744
+ * ### Rich-text composer
2745
+ * ```html
2746
+ * <ui-chat-view
2747
+ * [messages]="messages"
2748
+ * [currentUser]="me"
2749
+ * composerMode="rich-text"
2750
+ * (messageSend)="onSend($event)"
2751
+ * />
2752
+ * ```
2753
+ */
2754
+ class UIChatView {
2755
+ // ── Inputs ────────────────────────────────────────────────────────
2756
+ /** The list of messages to display. */
2757
+ messages = input([], ...(ngDevMode ? [{ debugName: "messages" }] : []));
2758
+ /** The current user (their messages are right-aligned). */
2759
+ currentUser = input.required(...(ngDevMode ? [{ debugName: "currentUser" }] : []));
2760
+ /** Composer input mode. */
2761
+ composerMode = input("text", ...(ngDevMode ? [{ debugName: "composerMode" }] : []));
2762
+ /** Placeholder text for the composer. */
2763
+ placeholder = input("Type a message…", ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
2764
+ /** Accessible label for the chat view. */
2765
+ ariaLabel = input("Chat", ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
2766
+ // ── Content children ──────────────────────────────────────────────
2767
+ /**
2768
+ * Optional custom template for rendering individual messages.
2769
+ * Receives a {@link MessageTemplateContext}.
2770
+ */
2771
+ messageTemplate = contentChild(TemplateRef, ...(ngDevMode ? [{ debugName: "messageTemplate" }] : []));
2772
+ // ── Outputs ───────────────────────────────────────────────────────
2773
+ /** Emitted when the user sends a message. */
2774
+ messageSend = output();
2775
+ // ── View queries ──────────────────────────────────────────────────
2776
+ /** @internal */
2777
+ messageListRef = viewChild("messageList", ...(ngDevMode ? [{ debugName: "messageListRef" }] : []));
2778
+ /** @internal */
2779
+ composerInputRef = viewChild("composerInput", ...(ngDevMode ? [{ debugName: "composerInputRef" }] : []));
2780
+ // ── Internal state ────────────────────────────────────────────────
2781
+ /** @internal — current text in the composer. */
2782
+ composerValue = signal("", ...(ngDevMode ? [{ debugName: "composerValue" }] : []));
2783
+ // ── Icons ─────────────────────────────────────────────────────────
2784
+ /** @internal */
2785
+ sendIcon = UIIcons.Lucide.Communication.SendHorizontal;
2786
+ // ── Computed ──────────────────────────────────────────────────────
2787
+ /** @internal — messages grouped by date. */
2788
+ groupedMessages = computed(() => this.groupByDate(this.messages()), ...(ngDevMode ? [{ debugName: "groupedMessages" }] : []));
2789
+ /** @internal — whether the send button should be enabled. */
2790
+ canSend = computed(() => this.composerValue().trim().length > 0, ...(ngDevMode ? [{ debugName: "canSend" }] : []));
2791
+ // ── Constructor ───────────────────────────────────────────────────
2792
+ constructor() {
2793
+ // Auto-scroll to bottom when messages change
2794
+ effect(() => {
2795
+ this.messages(); // track
2796
+ this.scrollToBottom();
2797
+ });
2798
+ }
2799
+ // ── Lifecycle ─────────────────────────────────────────────────────
2800
+ ngAfterViewInit() {
2801
+ this.scrollToBottom();
2802
+ }
2803
+ // ── Public methods ────────────────────────────────────────────────
2804
+ /** Scroll the message list to the bottom. */
2805
+ scrollToBottom() {
2806
+ requestAnimationFrame(() => {
2807
+ const el = this.messageListRef()?.nativeElement;
2808
+ if (el) {
2809
+ el.scrollTop = el.scrollHeight;
2810
+ }
2811
+ });
2812
+ }
2813
+ // ── Protected methods ─────────────────────────────────────────────
2814
+ /** @internal — determine if a message was sent by the current user. */
2815
+ isMine(message) {
2816
+ return message.sender.id === this.currentUser().id;
2817
+ }
2818
+ /** @internal — send the composed message. */
2819
+ send() {
2820
+ const content = this.composerValue().trim();
2821
+ if (!content)
2822
+ return;
2823
+ this.messageSend.emit({ content });
2824
+ this.composerValue.set("");
2825
+ // Re-focus the plain-text composer
2826
+ const textarea = this.composerInputRef()?.nativeElement;
2827
+ if (textarea) {
2828
+ textarea.focus();
2829
+ }
2830
+ }
2831
+ /** @internal — handle Enter key in the text composer. */
2832
+ onComposerKeydown(event) {
2833
+ if (event.key === "Enter" && !event.shiftKey) {
2834
+ event.preventDefault();
2835
+ this.send();
2836
+ }
2837
+ }
2838
+ /** @internal — handle input events on the textarea. */
2839
+ onComposerInput(event) {
2840
+ const target = event.target;
2841
+ this.composerValue.set(target.value);
2842
+ }
2843
+ // ── Private methods ───────────────────────────────────────────────
2844
+ /** Group messages by date, producing human-readable labels. */
2845
+ groupByDate(messages) {
2846
+ const groups = new Map();
2847
+ for (const msg of messages) {
2848
+ const key = this.toDateKey(msg.timestamp);
2849
+ const list = groups.get(key);
2850
+ if (list) {
2851
+ list.push(msg);
2852
+ }
2853
+ else {
2854
+ groups.set(key, [msg]);
2855
+ }
2856
+ }
2857
+ const today = this.toDateKey(new Date());
2858
+ const yesterday = this.toDateKey(new Date(Date.now() - 86_400_000));
2859
+ const result = [];
2860
+ for (const [date, msgs] of groups) {
2861
+ let label;
2862
+ if (date === today) {
2863
+ label = "Today";
2864
+ }
2865
+ else if (date === yesterday) {
2866
+ label = "Yesterday";
2867
+ }
2868
+ else {
2869
+ label = new Date(date).toLocaleDateString(undefined, {
2870
+ weekday: "long",
2871
+ year: "numeric",
2872
+ month: "long",
2873
+ day: "numeric",
2874
+ });
2875
+ }
2876
+ result.push({ date, label, messages: msgs });
2877
+ }
2878
+ return result;
2879
+ }
2880
+ /** Convert a Date to a YYYY-MM-DD key. */
2881
+ toDateKey(d) {
2882
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
2883
+ }
2884
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIChatView, deps: [], target: i0.ɵɵFactoryTarget.Component });
2885
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIChatView, isStandalone: true, selector: "ui-chat-view", inputs: { messages: { classPropertyName: "messages", publicName: "messages", isSignal: true, isRequired: false, transformFunction: null }, currentUser: { classPropertyName: "currentUser", publicName: "currentUser", isSignal: true, isRequired: true, transformFunction: null }, composerMode: { classPropertyName: "composerMode", publicName: "composerMode", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { messageSend: "messageSend" }, host: { classAttribute: "ui-chat-view" }, queries: [{ propertyName: "messageTemplate", first: true, predicate: TemplateRef, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "messageListRef", first: true, predicate: ["messageList"], descendants: true, isSignal: true }, { propertyName: "composerInputRef", first: true, predicate: ["composerInput"], descendants: true, isSignal: true }], hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "<div class=\"chat-messages\" #messageList role=\"log\" [attr.aria-label]=\"ariaLabel()\">\n @for (group of groupedMessages(); track group.date) {\n <div class=\"chat-date-divider\" role=\"separator\">\n <span class=\"chat-date-label\">{{ group.label }}</span>\n </div>\n\n @for (message of group.messages; track message.id) {\n @if (messageTemplate(); as tpl) {\n <ng-container\n *ngTemplateOutlet=\"tpl; context: { $implicit: message, message: message, isMine: isMine(message) }\"\n />\n } @else {\n @if ((message.type ?? 'text') === 'system') {\n <div class=\"chat-system-message\">\n {{ message.content }}\n </div>\n } @else {\n <ui-message-bubble\n [message]=\"message\"\n [isMine]=\"isMine(message)\"\n />\n }\n }\n }\n }\n\n @if (groupedMessages().length === 0) {\n <div class=\"chat-empty\">No messages yet</div>\n }\n</div>\n\n<div class=\"chat-composer\">\n @if (composerMode() === 'rich-text') {\n <ui-rich-text-editor\n [(value)]=\"composerValue\"\n [placeholder]=\"placeholder()\"\n ariaLabel=\"Message composer\"\n />\n } @else {\n <textarea\n #composerInput\n class=\"chat-composer-input\"\n [placeholder]=\"placeholder()\"\n [value]=\"composerValue()\"\n (input)=\"onComposerInput($event)\"\n (keydown)=\"onComposerKeydown($event)\"\n rows=\"1\"\n aria-label=\"Message composer\"\n ></textarea>\n }\n\n <button\n type=\"button\"\n class=\"chat-send-button\"\n [disabled]=\"!canSend()\"\n [attr.aria-label]=\"'Send message'\"\n (click)=\"send()\"\n >\n <ui-icon [svg]=\"sendIcon\" [size]=\"18\" />\n </button>\n</div>\n", styles: [":host{display:flex;flex-direction:column;height:100%;border-radius:8px;overflow:hidden}.chat-messages{flex:1;padding:16px;display:flex;flex-direction:column;gap:4px;overflow-y:auto;overscroll-behavior:contain;min-height:0}.chat-date-divider{display:flex;align-items:center;gap:12px;margin:16px 0 8px}.chat-date-divider:before,.chat-date-divider:after{content:\"\";flex:1;height:1px;background:var(--ui-border, #d4d8dd)}.chat-date-label{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;color:var(--ui-text-muted, #5a6470)}.chat-system-message{text-align:center;font-size:.8125rem;font-style:italic;padding:4px 0;color:var(--ui-text-muted, #5a6470)}.chat-empty{flex:1;display:flex;align-items:center;justify-content:center;font-size:.875rem;color:var(--ui-text-muted, #5a6470)}.chat-composer{display:flex;align-items:flex-end;gap:8px;padding:12px 16px;border-top:1px solid var(--ui-border, #d4d8dd)}.chat-composer ui-rich-text-editor{flex:1;min-width:0}.chat-composer-input{flex:1;min-width:0;resize:none;border-radius:20px;padding:8px 16px;font-size:.875rem;line-height:1.4;font-family:inherit;outline:none;max-height:120px;color:var(--ui-text, #1d232b);background:var(--ui-surface-2, #f0f2f5);border:1px solid var(--ui-border, #d4d8dd)}.chat-composer-input:focus{border-color:var(--ui-accent, #3584e4)}.chat-send-button{appearance:none;border:none;background:none;font-family:var(--ui-font, inherit);display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;flex-shrink:0;cursor:pointer;transition:opacity .15s;color:var(--ui-accent-contrast, #ffffff);background:var(--ui-accent, #3584e4)}.chat-send-button:disabled{cursor:default;opacity:.4}.chat-send-button:not(:disabled):hover{opacity:.85}.chat-send-button:focus-visible{outline:2px solid var(--ui-focus-ring, var(--ui-brand, var(--ui-accent, #3584e4)));outline-offset:2px}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: UIIcon, selector: "ui-icon", inputs: ["svg", "size", "ariaLabel"] }, { kind: "component", type: UIMessageBubble, selector: "ui-message-bubble", inputs: ["message", "isMine"] }, { kind: "component", type: UIRichTextEditor, selector: "ui-rich-text-editor", inputs: ["mode", "disabled", "readonly", "ariaLabel", "placeholder", "toolbarActions", "placeholders", "sanitise", "maxLength", "imageHandler", "emojiCategories", "value"], outputs: ["valueChange", "placeholderInserted"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2886
+ }
2887
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIChatView, decorators: [{
2888
+ type: Component,
2889
+ args: [{ selector: "ui-chat-view", standalone: true, imports: [
2890
+ NgTemplateOutlet,
2891
+ DatePipe,
2892
+ UIAvatar,
2893
+ UIButton,
2894
+ UIIcon,
2895
+ UIMessageBubble,
2896
+ UIRichTextEditor,
2897
+ ], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ["surfaceType"] }], host: {
2898
+ class: "ui-chat-view",
2899
+ }, template: "<div class=\"chat-messages\" #messageList role=\"log\" [attr.aria-label]=\"ariaLabel()\">\n @for (group of groupedMessages(); track group.date) {\n <div class=\"chat-date-divider\" role=\"separator\">\n <span class=\"chat-date-label\">{{ group.label }}</span>\n </div>\n\n @for (message of group.messages; track message.id) {\n @if (messageTemplate(); as tpl) {\n <ng-container\n *ngTemplateOutlet=\"tpl; context: { $implicit: message, message: message, isMine: isMine(message) }\"\n />\n } @else {\n @if ((message.type ?? 'text') === 'system') {\n <div class=\"chat-system-message\">\n {{ message.content }}\n </div>\n } @else {\n <ui-message-bubble\n [message]=\"message\"\n [isMine]=\"isMine(message)\"\n />\n }\n }\n }\n }\n\n @if (groupedMessages().length === 0) {\n <div class=\"chat-empty\">No messages yet</div>\n }\n</div>\n\n<div class=\"chat-composer\">\n @if (composerMode() === 'rich-text') {\n <ui-rich-text-editor\n [(value)]=\"composerValue\"\n [placeholder]=\"placeholder()\"\n ariaLabel=\"Message composer\"\n />\n } @else {\n <textarea\n #composerInput\n class=\"chat-composer-input\"\n [placeholder]=\"placeholder()\"\n [value]=\"composerValue()\"\n (input)=\"onComposerInput($event)\"\n (keydown)=\"onComposerKeydown($event)\"\n rows=\"1\"\n aria-label=\"Message composer\"\n ></textarea>\n }\n\n <button\n type=\"button\"\n class=\"chat-send-button\"\n [disabled]=\"!canSend()\"\n [attr.aria-label]=\"'Send message'\"\n (click)=\"send()\"\n >\n <ui-icon [svg]=\"sendIcon\" [size]=\"18\" />\n </button>\n</div>\n", styles: [":host{display:flex;flex-direction:column;height:100%;border-radius:8px;overflow:hidden}.chat-messages{flex:1;padding:16px;display:flex;flex-direction:column;gap:4px;overflow-y:auto;overscroll-behavior:contain;min-height:0}.chat-date-divider{display:flex;align-items:center;gap:12px;margin:16px 0 8px}.chat-date-divider:before,.chat-date-divider:after{content:\"\";flex:1;height:1px;background:var(--ui-border, #d4d8dd)}.chat-date-label{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;color:var(--ui-text-muted, #5a6470)}.chat-system-message{text-align:center;font-size:.8125rem;font-style:italic;padding:4px 0;color:var(--ui-text-muted, #5a6470)}.chat-empty{flex:1;display:flex;align-items:center;justify-content:center;font-size:.875rem;color:var(--ui-text-muted, #5a6470)}.chat-composer{display:flex;align-items:flex-end;gap:8px;padding:12px 16px;border-top:1px solid var(--ui-border, #d4d8dd)}.chat-composer ui-rich-text-editor{flex:1;min-width:0}.chat-composer-input{flex:1;min-width:0;resize:none;border-radius:20px;padding:8px 16px;font-size:.875rem;line-height:1.4;font-family:inherit;outline:none;max-height:120px;color:var(--ui-text, #1d232b);background:var(--ui-surface-2, #f0f2f5);border:1px solid var(--ui-border, #d4d8dd)}.chat-composer-input:focus{border-color:var(--ui-accent, #3584e4)}.chat-send-button{appearance:none;border:none;background:none;font-family:var(--ui-font, inherit);display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;flex-shrink:0;cursor:pointer;transition:opacity .15s;color:var(--ui-accent-contrast, #ffffff);background:var(--ui-accent, #3584e4)}.chat-send-button:disabled{cursor:default;opacity:.4}.chat-send-button:not(:disabled):hover{opacity:.85}.chat-send-button:focus-visible{outline:2px solid var(--ui-focus-ring, var(--ui-brand, var(--ui-accent, #3584e4)));outline-offset:2px}\n"] }]
2900
+ }], ctorParameters: () => [], propDecorators: { messages: [{ type: i0.Input, args: [{ isSignal: true, alias: "messages", required: false }] }], currentUser: [{ type: i0.Input, args: [{ isSignal: true, alias: "currentUser", required: true }] }], composerMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "composerMode", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], messageTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => TemplateRef), { isSignal: true }] }], messageSend: [{ type: i0.Output, args: ["messageSend"] }], messageListRef: [{ type: i0.ViewChild, args: ["messageList", { isSignal: true }] }], composerInputRef: [{ type: i0.ViewChild, args: ["composerInput", { isSignal: true }] }] } });
2901
+
2902
+ /**
2903
+ * A single step inside a {@link UIWizard}.
2904
+ *
2905
+ * Wrap each piece of wizard content in this component and project
2906
+ * it into `<ui-wizard>`.
2907
+ *
2908
+ * ### Usage
2909
+ * ```html
2910
+ * <ui-wizard>
2911
+ * <ui-wizard-step label="Account">…</ui-wizard-step>
2912
+ * <ui-wizard-step label="Profile">…</ui-wizard-step>
2913
+ * <ui-wizard-step label="Confirm">…</ui-wizard-step>
2914
+ * </ui-wizard>
2915
+ * ```
2916
+ */
2917
+ class UIWizardStep {
2918
+ // ── Inputs ────────────────────────────────────────────────────────
2919
+ /** Label shown in the step indicator. */
2920
+ label = input.required(...(ngDevMode ? [{ debugName: "label" }] : []));
2921
+ /** Whether this step is optional (shown as hint text). */
2922
+ optional = input(false, ...(ngDevMode ? [{ debugName: "optional" }] : []));
2923
+ /**
2924
+ * Whether the user can advance past this step. Bind a
2925
+ * signal expression to create a validation gate.
2926
+ *
2927
+ * @default true
2928
+ */
2929
+ canAdvance = input(true, ...(ngDevMode ? [{ debugName: "canAdvance" }] : []));
2930
+ // ── View queries ──────────────────────────────────────────────────
2931
+ /** @internal — template holding the step's projected content. */
2932
+ contentTemplate = viewChild.required("stepContent");
2933
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIWizardStep, deps: [], target: i0.ɵɵFactoryTarget.Component });
2934
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.1.0", type: UIWizardStep, isStandalone: true, selector: "ui-wizard-step", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null }, optional: { classPropertyName: "optional", publicName: "optional", isSignal: true, isRequired: false, transformFunction: null }, canAdvance: { classPropertyName: "canAdvance", publicName: "canAdvance", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "style.display": "\"none\"" }, classAttribute: "ui-wizard-step" }, providers: [{ provide: UI_DEFAULT_SURFACE_TYPE, useValue: "panel" }], viewQueries: [{ propertyName: "contentTemplate", first: true, predicate: ["stepContent"], descendants: true, isSignal: true }], hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "<ng-template #stepContent><ng-content /></ng-template>", isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
2935
+ }
2936
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIWizardStep, decorators: [{
2937
+ type: Component,
2938
+ args: [{
2939
+ selector: "ui-wizard-step",
2940
+ standalone: true,
2941
+ changeDetection: ChangeDetectionStrategy.OnPush,
2942
+ hostDirectives: [{ directive: UISurface, inputs: ["surfaceType"] }],
2943
+ providers: [{ provide: UI_DEFAULT_SURFACE_TYPE, useValue: "panel" }],
2944
+ template: "<ng-template #stepContent><ng-content /></ng-template>",
2945
+ host: {
2946
+ class: "ui-wizard-step",
2947
+ "[style.display]": '"none"',
2948
+ },
2949
+ }]
2950
+ }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], optional: [{ type: i0.Input, args: [{ isSignal: true, alias: "optional", required: false }] }], canAdvance: [{ type: i0.Input, args: [{ isSignal: true, alias: "canAdvance", required: false }] }], contentTemplate: [{ type: i0.ViewChild, args: ["stepContent", { isSignal: true }] }] } });
2951
+
2952
+ /**
2953
+ * A multi-step workflow shell with a step indicator, navigation
2954
+ * buttons, and optional validation gates.
2955
+ *
2956
+ * Steps are defined by projecting `<ui-wizard-step>` children.
2957
+ * The wizard discovers them via content queries and renders only
2958
+ * the active step.
2959
+ *
2960
+ * ### Basic usage
2961
+ * ```html
2962
+ * <ui-wizard (complete)="onFinish()">
2963
+ * <ui-wizard-step label="Account">…</ui-wizard-step>
2964
+ * <ui-wizard-step label="Profile">…</ui-wizard-step>
2965
+ * <ui-wizard-step label="Confirm">…</ui-wizard-step>
2966
+ * </ui-wizard>
2967
+ * ```
2968
+ *
2969
+ * ### With validation gates
2970
+ * ```html
2971
+ * <ui-wizard linear>
2972
+ * <ui-wizard-step label="Details" [canAdvance]="formValid()">
2973
+ * …
2974
+ * </ui-wizard-step>
2975
+ * <ui-wizard-step label="Review">…</ui-wizard-step>
2976
+ * </ui-wizard>
2977
+ * ```
2978
+ */
2979
+ class UIWizard {
2980
+ // ── Inputs ────────────────────────────────────────────────────────
2981
+ /**
2982
+ * When `true`, the user must complete steps in order and
2983
+ * cannot click ahead on the step indicator.
2984
+ */
2985
+ linear = input(false, ...(ngDevMode ? [{ debugName: "linear" }] : []));
2986
+ /** Whether to show the step indicator bar. */
2987
+ showStepIndicator = input(true, ...(ngDevMode ? [{ debugName: "showStepIndicator" }] : []));
2988
+ /** Label for the Back button. */
2989
+ backLabel = input("Back", ...(ngDevMode ? [{ debugName: "backLabel" }] : []));
2990
+ /** Label for the Next button. */
2991
+ nextLabel = input("Next", ...(ngDevMode ? [{ debugName: "nextLabel" }] : []));
2992
+ /** Label for the Finish button (shown on the last step). */
2993
+ finishLabel = input("Finish", ...(ngDevMode ? [{ debugName: "finishLabel" }] : []));
2994
+ /** Accessible label for the wizard. */
2995
+ ariaLabel = input("Wizard", ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
2996
+ // ── Models ────────────────────────────────────────────────────────
2997
+ /** Zero-based index of the active step (two-way bindable). */
2998
+ activeIndex = model(0, ...(ngDevMode ? [{ debugName: "activeIndex" }] : []));
2999
+ // ── Content children ──────────────────────────────────────────────
3000
+ /** @internal — discovered wizard steps. */
3001
+ steps = contentChildren(UIWizardStep, ...(ngDevMode ? [{ debugName: "steps" }] : []));
3002
+ // ── Outputs ───────────────────────────────────────────────────────
3003
+ /** Emitted when the active step changes. */
3004
+ stepChange = output();
3005
+ /** Emitted when the user clicks Finish on the last step. */
3006
+ complete = output();
3007
+ // ── Icons ─────────────────────────────────────────────────────────
3008
+ /** @internal */
3009
+ checkIcon = UIIcons.Lucide.Notifications.Check;
3010
+ // ── Computed ──────────────────────────────────────────────────────
3011
+ /** @internal — the currently active step. */
3012
+ activeStep = computed(() => {
3013
+ const all = this.steps();
3014
+ const idx = this.activeIndex();
3015
+ return idx >= 0 && idx < all.length ? all[idx] : undefined;
3016
+ }, ...(ngDevMode ? [{ debugName: "activeStep" }] : []));
3017
+ /** @internal */
3018
+ isFirstStep = computed(() => this.activeIndex() === 0, ...(ngDevMode ? [{ debugName: "isFirstStep" }] : []));
3019
+ /** @internal */
3020
+ isLastStep = computed(() => this.activeIndex() === this.steps().length - 1, ...(ngDevMode ? [{ debugName: "isLastStep" }] : []));
3021
+ /** @internal — whether the current step allows advancing. */
3022
+ canGoNext = computed(() => {
3023
+ const step = this.activeStep();
3024
+ return step ? step.canAdvance() : false;
3025
+ }, ...(ngDevMode ? [{ debugName: "canGoNext" }] : []));
3026
+ // ── Public methods ────────────────────────────────────────────────
3027
+ /** Advance to the next step (respects validation). */
3028
+ next() {
3029
+ if (this.isLastStep() || !this.canGoNext())
3030
+ return;
3031
+ this.goToStep(this.activeIndex() + 1);
3032
+ }
3033
+ /** Go back to the previous step. */
3034
+ previous() {
3035
+ if (this.isFirstStep())
3036
+ return;
3037
+ this.goToStep(this.activeIndex() - 1);
3038
+ }
3039
+ /** Navigate to a specific step by index. */
3040
+ goToStep(index) {
3041
+ const all = this.steps();
3042
+ if (index < 0 || index >= all.length)
3043
+ return;
3044
+ // In linear mode, prevent skipping ahead beyond validated steps
3045
+ if (this.linear() && index > this.activeIndex()) {
3046
+ // Check all steps between current and target are valid
3047
+ for (let i = this.activeIndex(); i < index; i++) {
3048
+ if (!all[i].canAdvance())
3049
+ return;
3050
+ }
3051
+ }
3052
+ const previous = this.activeIndex();
3053
+ if (previous === index)
3054
+ return;
3055
+ this.activeIndex.set(index);
3056
+ this.stepChange.emit({ previousIndex: previous, currentIndex: index });
3057
+ }
3058
+ /** Complete the wizard (only works on the last step). */
3059
+ finish() {
3060
+ if (!this.isLastStep() || !this.canGoNext())
3061
+ return;
3062
+ this.complete.emit();
3063
+ }
3064
+ // ── Protected methods ─────────────────────────────────────────────
3065
+ /** @internal — handle step indicator click. */
3066
+ onStepClick(index) {
3067
+ if (this.linear() && index > this.activeIndex()) {
3068
+ // In linear mode, only allow clicking completed steps or current
3069
+ return;
3070
+ }
3071
+ this.goToStep(index);
3072
+ }
3073
+ /** @internal — whether a step indicator is clickable. */
3074
+ isStepClickable(index) {
3075
+ if (!this.linear())
3076
+ return true;
3077
+ return index <= this.activeIndex();
3078
+ }
3079
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIWizard, deps: [], target: i0.ɵɵFactoryTarget.Component });
3080
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIWizard, isStandalone: true, selector: "ui-wizard", inputs: { linear: { classPropertyName: "linear", publicName: "linear", isSignal: true, isRequired: false, transformFunction: null }, showStepIndicator: { classPropertyName: "showStepIndicator", publicName: "showStepIndicator", isSignal: true, isRequired: false, transformFunction: null }, backLabel: { classPropertyName: "backLabel", publicName: "backLabel", isSignal: true, isRequired: false, transformFunction: null }, nextLabel: { classPropertyName: "nextLabel", publicName: "nextLabel", isSignal: true, isRequired: false, transformFunction: null }, finishLabel: { classPropertyName: "finishLabel", publicName: "finishLabel", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, activeIndex: { classPropertyName: "activeIndex", publicName: "activeIndex", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { activeIndex: "activeIndexChange", stepChange: "stepChange", complete: "complete" }, host: { classAttribute: "ui-wizard" }, providers: [{ provide: UI_DEFAULT_SURFACE_TYPE, useValue: "panel" }], queries: [{ propertyName: "steps", predicate: UIWizardStep, isSignal: true }], hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "@if (showStepIndicator()) {\n <nav class=\"wizard-indicator\" [attr.aria-label]=\"ariaLabel() + ' steps'\" role=\"tablist\">\n @for (step of steps(); track $index; let i = $index) {\n @if (i > 0) {\n <div\n class=\"wizard-connector\"\n [class.wizard-connector--completed]=\"i <= activeIndex()\"\n ></div>\n }\n\n <button\n type=\"button\"\n class=\"wizard-step-indicator\"\n [class.wizard-step-indicator--active]=\"i === activeIndex()\"\n [class.wizard-step-indicator--completed]=\"i < activeIndex()\"\n [class.wizard-step-indicator--clickable]=\"isStepClickable(i)\"\n [disabled]=\"!isStepClickable(i)\"\n role=\"tab\"\n [attr.aria-selected]=\"i === activeIndex()\"\n [attr.aria-label]=\"step.label()\"\n (click)=\"onStepClick(i)\"\n >\n <span class=\"wizard-step-number\">\n @if (i < activeIndex()) {\n <ui-icon [svg]=\"checkIcon\" [size]=\"14\" />\n } @else {\n {{ i + 1 }}\n }\n </span>\n <span class=\"wizard-step-label\">\n {{ step.label() }}\n @if (step.optional()) {\n <span class=\"wizard-step-optional\">Optional</span>\n }\n </span>\n </button>\n }\n </nav>\n}\n\n<div class=\"wizard-content\" role=\"tabpanel\">\n @if (activeStep(); as step) {\n <ng-container *ngTemplateOutlet=\"step.contentTemplate()\" />\n }\n</div>\n\n<div class=\"wizard-navigation\">\n @if (!isFirstStep()) {\n <button\n type=\"button\"\n class=\"wizard-nav-button wizard-nav-button--back\"\n (click)=\"previous()\"\n >\n {{ backLabel() }}\n </button>\n }\n\n <div class=\"wizard-nav-spacer\"></div>\n\n @if (isLastStep()) {\n <button\n type=\"button\"\n class=\"wizard-nav-button wizard-nav-button--finish\"\n [disabled]=\"!canGoNext()\"\n (click)=\"finish()\"\n >\n {{ finishLabel() }}\n </button>\n } @else {\n <button\n type=\"button\"\n class=\"wizard-nav-button wizard-nav-button--next\"\n [disabled]=\"!canGoNext()\"\n (click)=\"next()\"\n >\n {{ nextLabel() }}\n </button>\n }\n</div>\n", styles: [":host{color:var(--ui-text, #1d232b);display:flex;flex-direction:column;border-radius:8px;overflow:hidden}.wizard-indicator{display:flex;align-items:center;padding:20px 24px;gap:0;border-bottom:1px solid var(--ui-border, #dde1e6)}.wizard-connector{flex:1;height:2px;margin:0 4px;background:var(--ui-border, #dde1e6);transition:background .2s}.wizard-connector--completed{background:var(--ui-accent, #3b82f6)}.wizard-step-indicator{appearance:none;border:none;background:none;cursor:pointer;font-family:var(--ui-font, inherit);display:flex;align-items:center;gap:8px;padding:4px 8px;border-radius:6px;cursor:default;flex-shrink:0;color:var(--ui-text-muted, #697077)}.wizard-step-indicator--active,.wizard-step-indicator--completed{color:var(--ui-accent, #3b82f6)}.wizard-step-indicator--clickable{cursor:pointer}.wizard-step-indicator--clickable:hover{background:var(--ui-hover-bg, rgba(0, 0, 0, .04))}.wizard-step-indicator:disabled{opacity:.5;cursor:default}.wizard-step-indicator:focus-visible{outline:2px solid var(--ui-focus-ring, var(--ui-brand, var(--ui-accent, #3584e4)));outline-offset:2px}.wizard-step-number{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;font-size:.8125rem;font-weight:600;transition:background .2s,color .2s;flex-shrink:0;color:var(--ui-text-muted, #697077);background:var(--ui-surface-2, #f2f4f8);border:2px solid var(--ui-border, #dde1e6)}.wizard-step-indicator--active .wizard-step-number,.wizard-step-indicator--completed .wizard-step-number{color:var(--ui-accent-contrast, #ffffff);background:var(--ui-accent, #3b82f6);border-color:var(--ui-accent, #3b82f6)}.wizard-step-label{display:flex;flex-direction:column;font-size:.8125rem;font-weight:500;line-height:1.3}.wizard-step-indicator--active .wizard-step-label{font-weight:600}.wizard-step-optional{font-size:.6875rem;font-weight:400;color:var(--ui-text-muted, #697077)}.wizard-content{flex:1;padding:24px;overflow-y:auto;overscroll-behavior:contain;min-height:0}.wizard-navigation{display:flex;align-items:center;gap:12px;padding:16px 24px;border-top:1px solid var(--ui-border, #dde1e6)}.wizard-nav-spacer{flex:1}.wizard-nav-button{appearance:none;border:none;background:none;font-family:var(--ui-font, inherit);display:inline-flex;align-items:center;justify-content:center;padding:8px 20px;border-radius:6px;font-size:.875rem;font-weight:500;cursor:pointer;transition:background .15s,opacity .15s}.wizard-nav-button--back{color:var(--ui-text, #1d232b);background:transparent;border:1px solid var(--ui-border, #dde1e6)}.wizard-nav-button--back:hover{background:var(--ui-hover-bg, rgba(0, 0, 0, .04))}.wizard-nav-button--next,.wizard-nav-button--finish{color:var(--ui-accent-contrast, #ffffff);background:var(--ui-accent, #3b82f6)}.wizard-nav-button--next:not(:disabled):hover,.wizard-nav-button--finish:not(:disabled):hover{opacity:.85}.wizard-nav-button--next:disabled,.wizard-nav-button--finish:disabled{cursor:default;opacity:.6}.wizard-nav-button:focus-visible{outline:2px solid var(--ui-focus-ring, var(--ui-brand, var(--ui-accent, #3584e4)));outline-offset:2px}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: UIIcon, selector: "ui-icon", inputs: ["svg", "size", "ariaLabel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3081
+ }
3082
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIWizard, decorators: [{
3083
+ type: Component,
3084
+ args: [{ selector: "ui-wizard", standalone: true, imports: [NgTemplateOutlet, UIIcon], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ["surfaceType"] }], providers: [{ provide: UI_DEFAULT_SURFACE_TYPE, useValue: "panel" }], host: {
3085
+ class: "ui-wizard",
3086
+ }, template: "@if (showStepIndicator()) {\n <nav class=\"wizard-indicator\" [attr.aria-label]=\"ariaLabel() + ' steps'\" role=\"tablist\">\n @for (step of steps(); track $index; let i = $index) {\n @if (i > 0) {\n <div\n class=\"wizard-connector\"\n [class.wizard-connector--completed]=\"i <= activeIndex()\"\n ></div>\n }\n\n <button\n type=\"button\"\n class=\"wizard-step-indicator\"\n [class.wizard-step-indicator--active]=\"i === activeIndex()\"\n [class.wizard-step-indicator--completed]=\"i < activeIndex()\"\n [class.wizard-step-indicator--clickable]=\"isStepClickable(i)\"\n [disabled]=\"!isStepClickable(i)\"\n role=\"tab\"\n [attr.aria-selected]=\"i === activeIndex()\"\n [attr.aria-label]=\"step.label()\"\n (click)=\"onStepClick(i)\"\n >\n <span class=\"wizard-step-number\">\n @if (i < activeIndex()) {\n <ui-icon [svg]=\"checkIcon\" [size]=\"14\" />\n } @else {\n {{ i + 1 }}\n }\n </span>\n <span class=\"wizard-step-label\">\n {{ step.label() }}\n @if (step.optional()) {\n <span class=\"wizard-step-optional\">Optional</span>\n }\n </span>\n </button>\n }\n </nav>\n}\n\n<div class=\"wizard-content\" role=\"tabpanel\">\n @if (activeStep(); as step) {\n <ng-container *ngTemplateOutlet=\"step.contentTemplate()\" />\n }\n</div>\n\n<div class=\"wizard-navigation\">\n @if (!isFirstStep()) {\n <button\n type=\"button\"\n class=\"wizard-nav-button wizard-nav-button--back\"\n (click)=\"previous()\"\n >\n {{ backLabel() }}\n </button>\n }\n\n <div class=\"wizard-nav-spacer\"></div>\n\n @if (isLastStep()) {\n <button\n type=\"button\"\n class=\"wizard-nav-button wizard-nav-button--finish\"\n [disabled]=\"!canGoNext()\"\n (click)=\"finish()\"\n >\n {{ finishLabel() }}\n </button>\n } @else {\n <button\n type=\"button\"\n class=\"wizard-nav-button wizard-nav-button--next\"\n [disabled]=\"!canGoNext()\"\n (click)=\"next()\"\n >\n {{ nextLabel() }}\n </button>\n }\n</div>\n", styles: [":host{color:var(--ui-text, #1d232b);display:flex;flex-direction:column;border-radius:8px;overflow:hidden}.wizard-indicator{display:flex;align-items:center;padding:20px 24px;gap:0;border-bottom:1px solid var(--ui-border, #dde1e6)}.wizard-connector{flex:1;height:2px;margin:0 4px;background:var(--ui-border, #dde1e6);transition:background .2s}.wizard-connector--completed{background:var(--ui-accent, #3b82f6)}.wizard-step-indicator{appearance:none;border:none;background:none;cursor:pointer;font-family:var(--ui-font, inherit);display:flex;align-items:center;gap:8px;padding:4px 8px;border-radius:6px;cursor:default;flex-shrink:0;color:var(--ui-text-muted, #697077)}.wizard-step-indicator--active,.wizard-step-indicator--completed{color:var(--ui-accent, #3b82f6)}.wizard-step-indicator--clickable{cursor:pointer}.wizard-step-indicator--clickable:hover{background:var(--ui-hover-bg, rgba(0, 0, 0, .04))}.wizard-step-indicator:disabled{opacity:.5;cursor:default}.wizard-step-indicator:focus-visible{outline:2px solid var(--ui-focus-ring, var(--ui-brand, var(--ui-accent, #3584e4)));outline-offset:2px}.wizard-step-number{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;font-size:.8125rem;font-weight:600;transition:background .2s,color .2s;flex-shrink:0;color:var(--ui-text-muted, #697077);background:var(--ui-surface-2, #f2f4f8);border:2px solid var(--ui-border, #dde1e6)}.wizard-step-indicator--active .wizard-step-number,.wizard-step-indicator--completed .wizard-step-number{color:var(--ui-accent-contrast, #ffffff);background:var(--ui-accent, #3b82f6);border-color:var(--ui-accent, #3b82f6)}.wizard-step-label{display:flex;flex-direction:column;font-size:.8125rem;font-weight:500;line-height:1.3}.wizard-step-indicator--active .wizard-step-label{font-weight:600}.wizard-step-optional{font-size:.6875rem;font-weight:400;color:var(--ui-text-muted, #697077)}.wizard-content{flex:1;padding:24px;overflow-y:auto;overscroll-behavior:contain;min-height:0}.wizard-navigation{display:flex;align-items:center;gap:12px;padding:16px 24px;border-top:1px solid var(--ui-border, #dde1e6)}.wizard-nav-spacer{flex:1}.wizard-nav-button{appearance:none;border:none;background:none;font-family:var(--ui-font, inherit);display:inline-flex;align-items:center;justify-content:center;padding:8px 20px;border-radius:6px;font-size:.875rem;font-weight:500;cursor:pointer;transition:background .15s,opacity .15s}.wizard-nav-button--back{color:var(--ui-text, #1d232b);background:transparent;border:1px solid var(--ui-border, #dde1e6)}.wizard-nav-button--back:hover{background:var(--ui-hover-bg, rgba(0, 0, 0, .04))}.wizard-nav-button--next,.wizard-nav-button--finish{color:var(--ui-accent-contrast, #ffffff);background:var(--ui-accent, #3b82f6)}.wizard-nav-button--next:not(:disabled):hover,.wizard-nav-button--finish:not(:disabled):hover{opacity:.85}.wizard-nav-button--next:disabled,.wizard-nav-button--finish:disabled{cursor:default;opacity:.6}.wizard-nav-button:focus-visible{outline:2px solid var(--ui-focus-ring, var(--ui-brand, var(--ui-accent, #3584e4)));outline-offset:2px}\n"] }]
3087
+ }], propDecorators: { linear: [{ type: i0.Input, args: [{ isSignal: true, alias: "linear", required: false }] }], showStepIndicator: [{ type: i0.Input, args: [{ isSignal: true, alias: "showStepIndicator", required: false }] }], backLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "backLabel", required: false }] }], nextLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "nextLabel", required: false }] }], finishLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "finishLabel", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], activeIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeIndex", required: false }] }, { type: i0.Output, args: ["activeIndexChange"] }], steps: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => UIWizardStep), { isSignal: true }] }], stepChange: [{ type: i0.Output, args: ["stepChange"] }], complete: [{ type: i0.Output, args: ["complete"] }] } });
3088
+
3089
+ /**
3090
+ * Column-based kanban board with drag-and-drop card reordering.
3091
+ *
3092
+ * Cards can be moved within a column (reorder) or across columns
3093
+ * (transfer). Columns and cards are provided via a two-way `columns`
3094
+ * model. Project an `<ng-template>` to customise card rendering.
3095
+ *
3096
+ * ### Basic usage
3097
+ * ```html
3098
+ * <ui-kanban-board [(columns)]="columns">
3099
+ * <ng-template let-card let-column="column">
3100
+ * <h4>{{ card.data.title }}</h4>
3101
+ * <p>{{ card.data.description }}</p>
3102
+ * </ng-template>
3103
+ * </ui-kanban-board>
3104
+ * ```
3105
+ */
3106
+ class UIKanbanBoard {
3107
+ // ── Models ────────────────────────────────────────────────────────
3108
+ /** Column data (two-way bindable). Mutated in-place during drag operations. */
3109
+ columns = model.required(...(ngDevMode ? [{ debugName: "columns" }] : []));
3110
+ // ── Inputs ────────────────────────────────────────────────────────
3111
+ /** Accessible label for the board region. */
3112
+ ariaLabel = input("Kanban board", ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
3113
+ // ── Content children ──────────────────────────────────────────────
3114
+ /** Optional projected template for card rendering. */
3115
+ cardTemplate = contentChild(TemplateRef, ...(ngDevMode ? [{ debugName: "cardTemplate" }] : []));
3116
+ // ── Outputs ───────────────────────────────────────────────────────
3117
+ /** Emitted after a card is moved (reorder or transfer). */
3118
+ cardMoved = output();
3119
+ /** Emitted when a card is clicked. */
3120
+ cardClicked = output();
3121
+ // ── Protected methods ─────────────────────────────────────────────
3122
+ /** @internal — handle CDK drop event. */
3123
+ onDrop(event) {
3124
+ const card = event.item.data;
3125
+ const previousColumnId = event.previousContainer.id;
3126
+ const currentColumnId = event.container.id;
3127
+ const previousIndex = event.previousIndex;
3128
+ const currentIndex = event.currentIndex;
3129
+ if (event.previousContainer === event.container) {
3130
+ moveItemInArray(event.container.data, previousIndex, currentIndex);
3131
+ }
3132
+ else {
3133
+ transferArrayItem(event.previousContainer.data, event.container.data, previousIndex, currentIndex);
3134
+ }
3135
+ // Trigger signal update with new array reference
3136
+ this.columns.set([...this.columns()]);
3137
+ this.cardMoved.emit({
3138
+ card,
3139
+ previousColumnId,
3140
+ currentColumnId,
3141
+ previousIndex,
3142
+ currentIndex,
3143
+ });
3144
+ }
3145
+ /** @internal — handle card click. */
3146
+ onCardClick(card) {
3147
+ this.cardClicked.emit(card);
3148
+ }
3149
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIKanbanBoard, deps: [], target: i0.ɵɵFactoryTarget.Component });
3150
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIKanbanBoard, isStandalone: true, selector: "ui-kanban-board", inputs: { columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: true, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { columns: "columnsChange", cardMoved: "cardMoved", cardClicked: "cardClicked" }, host: { classAttribute: "ui-kanban-board" }, queries: [{ propertyName: "cardTemplate", first: true, predicate: TemplateRef, descendants: true, isSignal: true }], hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "<div class=\"kanban-columns\" cdkDropListGroup role=\"region\" [attr.aria-label]=\"ariaLabel()\">\n @for (column of columns(); track column.id) {\n <div class=\"kanban-column\" [style.border-top-color]=\"column.color ?? null\">\n <div class=\"kanban-column-header\">\n <span class=\"kanban-column-title\">{{ column.title }}</span>\n <span class=\"kanban-column-count\" [attr.aria-label]=\"column.cards.length + ' cards'\">\n {{ column.cards.length }}\n </span>\n </div>\n\n <div\n class=\"kanban-column-body\"\n cdkDropList\n [cdkDropListData]=\"column.cards\"\n [id]=\"column.id\"\n (cdkDropListDropped)=\"onDrop($event)\"\n >\n @for (card of column.cards; track card.id) {\n <div\n class=\"kanban-card-wrapper\"\n cdkDrag\n [cdkDragData]=\"card\"\n tabindex=\"0\"\n role=\"listitem\"\n (click)=\"onCardClick(card)\"\n (keydown.enter)=\"onCardClick(card)\"\n >\n <div cdkDragPlaceholder class=\"kanban-card-placeholder\"></div>\n\n @if (cardTemplate(); as tpl) {\n <ng-container\n *ngTemplateOutlet=\"tpl; context: { $implicit: card, column: column }\"\n />\n } @else {\n <div class=\"kanban-card-fallback\">{{ card.id }}</div>\n }\n </div>\n } @empty {\n <div class=\"kanban-column-empty\">No cards</div>\n }\n </div>\n </div>\n }\n</div>\n", styles: [":host{--kb-column-width: 280px;--kb-column-gap: 16px;--kb-card-gap: 8px;--kb-column-radius: 8px;--kb-card-radius: 6px;display:flex;flex-direction:column;width:100%;height:100%;min-height:0}.kanban-columns{display:flex;flex:1;gap:var(--kb-column-gap);overflow-x:auto;overscroll-behavior:contain;padding:4px;min-height:0}.kanban-column{flex:1 0 var(--kb-column-width);display:flex;flex-direction:column;border-radius:var(--kb-column-radius);min-height:0;max-height:100%;color:var(--ui-text, #1d232b);background:var(--ui-surface-2, #f0f2f5);border:1px solid var(--ui-border, #d4d8dd);border-top:3px solid var(--ui-border, #d4d8dd)}.kanban-column-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px}.kanban-column-title{font-weight:600;font-size:.875rem}.kanban-column-count{font-size:.75rem;border-radius:10px;padding:2px 8px;min-width:1.5em;text-align:center;color:var(--ui-text-muted, #5a6470);background:var(--ui-surface, #ffffff)}.kanban-column-body{flex:1;overflow-y:auto;overscroll-behavior:contain;min-height:0;padding:8px;display:flex;flex-direction:column;gap:var(--kb-card-gap);min-height:60px}.kanban-card-wrapper{border-radius:var(--kb-card-radius);cursor:grab;color:var(--ui-text, #1d232b);background:var(--ui-surface, #ffffff);border:1px solid var(--ui-border, #d4d8dd);box-shadow:0 1px 3px #0000000f;transition:box-shadow .15s ease,border-color .15s ease}.kanban-card-wrapper:hover{border-color:var(--ui-border-strong, #505d6d);box-shadow:0 2px 6px #0000001a}.kanban-card-wrapper:active{cursor:grabbing}.kanban-card-fallback{padding:8px 12px;font-size:.875rem}.kanban-column-empty{padding:16px;text-align:center;font-size:.813rem;font-style:italic;color:var(--ui-text-muted, #5a6470)}.cdk-drag-preview{border-radius:var(--kb-card-radius);box-shadow:0 4px 16px #00000029}.cdk-drag-animating{transition:transform .2s cubic-bezier(0,0,.2,1)}.kanban-card-placeholder{display:none;opacity:.12;border-radius:var(--kb-card-radius);min-height:48px;background:var(--ui-accent, #3584e4)}.cdk-drag-placeholder .kanban-card-placeholder{display:block}.kanban-column-body.cdk-drop-list-dragging .kanban-card-wrapper:not(.cdk-drag-placeholder){transition:transform .2s cubic-bezier(0,0,.2,1)}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: CdkDropListGroup, selector: "[cdkDropListGroup]", inputs: ["cdkDropListGroupDisabled"], exportAs: ["cdkDropListGroup"] }, { kind: "directive", type: CdkDropList, selector: "[cdkDropList], cdk-drop-list", inputs: ["cdkDropListConnectedTo", "cdkDropListData", "cdkDropListOrientation", "id", "cdkDropListLockAxis", "cdkDropListDisabled", "cdkDropListSortingDisabled", "cdkDropListEnterPredicate", "cdkDropListSortPredicate", "cdkDropListAutoScrollDisabled", "cdkDropListAutoScrollStep", "cdkDropListElementContainer", "cdkDropListHasAnchor"], outputs: ["cdkDropListDropped", "cdkDropListEntered", "cdkDropListExited", "cdkDropListSorted"], exportAs: ["cdkDropList"] }, { kind: "directive", type: CdkDrag, selector: "[cdkDrag]", inputs: ["cdkDragData", "cdkDragLockAxis", "cdkDragRootElement", "cdkDragBoundary", "cdkDragStartDelay", "cdkDragFreeDragPosition", "cdkDragDisabled", "cdkDragConstrainPosition", "cdkDragPreviewClass", "cdkDragPreviewContainer", "cdkDragScale"], outputs: ["cdkDragStarted", "cdkDragReleased", "cdkDragEnded", "cdkDragEntered", "cdkDragExited", "cdkDragDropped", "cdkDragMoved"], exportAs: ["cdkDrag"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3151
+ }
3152
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIKanbanBoard, decorators: [{
3153
+ type: Component,
3154
+ args: [{ selector: "ui-kanban-board", standalone: true, imports: [
3155
+ NgTemplateOutlet,
3156
+ CdkDropListGroup,
3157
+ CdkDropList,
3158
+ CdkDrag,
3159
+ CdkDragPlaceholder,
3160
+ ], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ['surfaceType'] }], host: {
3161
+ class: "ui-kanban-board",
3162
+ }, template: "<div class=\"kanban-columns\" cdkDropListGroup role=\"region\" [attr.aria-label]=\"ariaLabel()\">\n @for (column of columns(); track column.id) {\n <div class=\"kanban-column\" [style.border-top-color]=\"column.color ?? null\">\n <div class=\"kanban-column-header\">\n <span class=\"kanban-column-title\">{{ column.title }}</span>\n <span class=\"kanban-column-count\" [attr.aria-label]=\"column.cards.length + ' cards'\">\n {{ column.cards.length }}\n </span>\n </div>\n\n <div\n class=\"kanban-column-body\"\n cdkDropList\n [cdkDropListData]=\"column.cards\"\n [id]=\"column.id\"\n (cdkDropListDropped)=\"onDrop($event)\"\n >\n @for (card of column.cards; track card.id) {\n <div\n class=\"kanban-card-wrapper\"\n cdkDrag\n [cdkDragData]=\"card\"\n tabindex=\"0\"\n role=\"listitem\"\n (click)=\"onCardClick(card)\"\n (keydown.enter)=\"onCardClick(card)\"\n >\n <div cdkDragPlaceholder class=\"kanban-card-placeholder\"></div>\n\n @if (cardTemplate(); as tpl) {\n <ng-container\n *ngTemplateOutlet=\"tpl; context: { $implicit: card, column: column }\"\n />\n } @else {\n <div class=\"kanban-card-fallback\">{{ card.id }}</div>\n }\n </div>\n } @empty {\n <div class=\"kanban-column-empty\">No cards</div>\n }\n </div>\n </div>\n }\n</div>\n", styles: [":host{--kb-column-width: 280px;--kb-column-gap: 16px;--kb-card-gap: 8px;--kb-column-radius: 8px;--kb-card-radius: 6px;display:flex;flex-direction:column;width:100%;height:100%;min-height:0}.kanban-columns{display:flex;flex:1;gap:var(--kb-column-gap);overflow-x:auto;overscroll-behavior:contain;padding:4px;min-height:0}.kanban-column{flex:1 0 var(--kb-column-width);display:flex;flex-direction:column;border-radius:var(--kb-column-radius);min-height:0;max-height:100%;color:var(--ui-text, #1d232b);background:var(--ui-surface-2, #f0f2f5);border:1px solid var(--ui-border, #d4d8dd);border-top:3px solid var(--ui-border, #d4d8dd)}.kanban-column-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px}.kanban-column-title{font-weight:600;font-size:.875rem}.kanban-column-count{font-size:.75rem;border-radius:10px;padding:2px 8px;min-width:1.5em;text-align:center;color:var(--ui-text-muted, #5a6470);background:var(--ui-surface, #ffffff)}.kanban-column-body{flex:1;overflow-y:auto;overscroll-behavior:contain;min-height:0;padding:8px;display:flex;flex-direction:column;gap:var(--kb-card-gap);min-height:60px}.kanban-card-wrapper{border-radius:var(--kb-card-radius);cursor:grab;color:var(--ui-text, #1d232b);background:var(--ui-surface, #ffffff);border:1px solid var(--ui-border, #d4d8dd);box-shadow:0 1px 3px #0000000f;transition:box-shadow .15s ease,border-color .15s ease}.kanban-card-wrapper:hover{border-color:var(--ui-border-strong, #505d6d);box-shadow:0 2px 6px #0000001a}.kanban-card-wrapper:active{cursor:grabbing}.kanban-card-fallback{padding:8px 12px;font-size:.875rem}.kanban-column-empty{padding:16px;text-align:center;font-size:.813rem;font-style:italic;color:var(--ui-text-muted, #5a6470)}.cdk-drag-preview{border-radius:var(--kb-card-radius);box-shadow:0 4px 16px #00000029}.cdk-drag-animating{transition:transform .2s cubic-bezier(0,0,.2,1)}.kanban-card-placeholder{display:none;opacity:.12;border-radius:var(--kb-card-radius);min-height:48px;background:var(--ui-accent, #3584e4)}.cdk-drag-placeholder .kanban-card-placeholder{display:block}.kanban-column-body.cdk-drop-list-dragging .kanban-card-wrapper:not(.cdk-drag-placeholder){transition:transform .2s cubic-bezier(0,0,.2,1)}\n"] }]
3163
+ }], propDecorators: { columns: [{ type: i0.Input, args: [{ isSignal: true, alias: "columns", required: true }] }, { type: i0.Output, args: ["columnsChange"] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], cardTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => TemplateRef), { isSignal: true }] }], cardMoved: [{ type: i0.Output, args: ["cardMoved"] }], cardClicked: [{ type: i0.Output, args: ["cardClicked"] }] } });
3164
+
3165
+ /**
3166
+ * Content component for a simple alert dialog.
3167
+ *
3168
+ * Displayed by {@link CommonDialogService.alert}. Shows a title,
3169
+ * message, and a single dismiss button.
3170
+ *
3171
+ * @internal — not intended for direct use; use the service instead.
3172
+ */
3173
+ class UIAlertDialog {
3174
+ title = input("Alert", ...(ngDevMode ? [{ debugName: "title" }] : []));
3175
+ message = input("", ...(ngDevMode ? [{ debugName: "message" }] : []));
3176
+ buttonLabel = input("OK", ...(ngDevMode ? [{ debugName: "buttonLabel" }] : []));
3177
+ modalRef = inject((ModalRef));
3178
+ dismiss() {
3179
+ this.modalRef.close(undefined);
3180
+ }
3181
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIAlertDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
3182
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.1.0", type: UIAlertDialog, isStandalone: true, selector: "ui-alert-dialog", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, message: { classPropertyName: "message", publicName: "message", isSignal: true, isRequired: false, transformFunction: null }, buttonLabel: { classPropertyName: "buttonLabel", publicName: "buttonLabel", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "ui-alert-dialog" }, hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: `
3183
+ <ui-dialog-header>{{ title() }}</ui-dialog-header>
3184
+ <ui-dialog-body>
3185
+ <p class="cd-message">{{ message() }}</p>
3186
+ </ui-dialog-body>
3187
+ <ui-dialog-footer>
3188
+ <ui-button
3189
+ variant="filled"
3190
+ [ariaLabel]="buttonLabel()"
3191
+ (click)="dismiss()"
3192
+ >
3193
+ {{ buttonLabel() }}
3194
+ </ui-button>
3195
+ </ui-dialog-footer>
3196
+ `, isInline: true, styles: [":host{display:flex;flex-direction:column;min-width:20rem}.cd-message{margin:0;line-height:1.55;white-space:pre-wrap}\n"], dependencies: [{ kind: "component", type: UIButton, selector: "ui-button", inputs: ["type", "variant", "color", "size", "pill", "disabled", "ariaLabel"] }, { kind: "component", type: UIDialogHeader, selector: "ui-dialog-header" }, { kind: "component", type: UIDialogBody, selector: "ui-dialog-body" }, { kind: "component", type: UIDialogFooter, selector: "ui-dialog-footer" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3197
+ }
3198
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIAlertDialog, decorators: [{
3199
+ type: Component,
3200
+ args: [{ selector: "ui-alert-dialog", standalone: true, imports: [UIButton, UIDialogHeader, UIDialogBody, UIDialogFooter], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ['surfaceType'] }], host: { class: "ui-alert-dialog" }, template: `
3201
+ <ui-dialog-header>{{ title() }}</ui-dialog-header>
3202
+ <ui-dialog-body>
3203
+ <p class="cd-message">{{ message() }}</p>
3204
+ </ui-dialog-body>
3205
+ <ui-dialog-footer>
3206
+ <ui-button
3207
+ variant="filled"
3208
+ [ariaLabel]="buttonLabel()"
3209
+ (click)="dismiss()"
3210
+ >
3211
+ {{ buttonLabel() }}
3212
+ </ui-button>
3213
+ </ui-dialog-footer>
3214
+ `, styles: [":host{display:flex;flex-direction:column;min-width:20rem}.cd-message{margin:0;line-height:1.55;white-space:pre-wrap}\n"] }]
3215
+ }], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], message: [{ type: i0.Input, args: [{ isSignal: true, alias: "message", required: false }] }], buttonLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "buttonLabel", required: false }] }] } });
3216
+
3217
+ /**
3218
+ * Content component for a confirm dialog.
3219
+ *
3220
+ * Displayed by {@link CommonDialogService.confirm}. Shows a title,
3221
+ * message, and confirm / cancel buttons. Resolves to `true` (confirm)
3222
+ * or `false` (cancel / dismiss).
3223
+ *
3224
+ * @internal — not intended for direct use; use the service instead.
3225
+ */
3226
+ class UIConfirmDialog {
3227
+ title = input("Confirm", ...(ngDevMode ? [{ debugName: "title" }] : []));
3228
+ message = input("", ...(ngDevMode ? [{ debugName: "message" }] : []));
3229
+ confirmLabel = input("OK", ...(ngDevMode ? [{ debugName: "confirmLabel" }] : []));
3230
+ cancelLabel = input("Cancel", ...(ngDevMode ? [{ debugName: "cancelLabel" }] : []));
3231
+ variant = input("primary", ...(ngDevMode ? [{ debugName: "variant" }] : []));
3232
+ modalRef = inject((ModalRef));
3233
+ confirm() {
3234
+ this.modalRef.close(true);
3235
+ }
3236
+ cancel() {
3237
+ this.modalRef.close(false);
3238
+ }
3239
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIConfirmDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
3240
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.1.0", type: UIConfirmDialog, isStandalone: true, selector: "ui-confirm-dialog", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, message: { classPropertyName: "message", publicName: "message", isSignal: true, isRequired: false, transformFunction: null }, confirmLabel: { classPropertyName: "confirmLabel", publicName: "confirmLabel", isSignal: true, isRequired: false, transformFunction: null }, cancelLabel: { classPropertyName: "cancelLabel", publicName: "cancelLabel", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class.danger": "variant() === 'danger'", "class.warning": "variant() === 'warning'" }, classAttribute: "ui-confirm-dialog" }, hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: `
3241
+ <ui-dialog-header>{{ title() }}</ui-dialog-header>
3242
+ <ui-dialog-body>
3243
+ <p class="cd-message">{{ message() }}</p>
3244
+ </ui-dialog-body>
3245
+ <ui-dialog-footer>
3246
+ <ui-button
3247
+ variant="outlined"
3248
+ [ariaLabel]="cancelLabel()"
3249
+ (click)="cancel()"
3250
+ >
3251
+ {{ cancelLabel() }}
3252
+ </ui-button>
3253
+ <ui-button
3254
+ variant="filled"
3255
+ [ariaLabel]="confirmLabel()"
3256
+ (click)="confirm()"
3257
+ >
3258
+ {{ confirmLabel() }}
3259
+ </ui-button>
3260
+ </ui-dialog-footer>
3261
+ `, isInline: true, styles: [":host{display:flex;flex-direction:column;min-width:20rem}.cd-message{margin:0;line-height:1.55;white-space:pre-wrap}:host(.danger){--ui-accent: #d93025}:host(.warning){--ui-accent: #e5a50a}\n"], dependencies: [{ kind: "component", type: UIButton, selector: "ui-button", inputs: ["type", "variant", "color", "size", "pill", "disabled", "ariaLabel"] }, { kind: "component", type: UIDialogHeader, selector: "ui-dialog-header" }, { kind: "component", type: UIDialogBody, selector: "ui-dialog-body" }, { kind: "component", type: UIDialogFooter, selector: "ui-dialog-footer" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3262
+ }
3263
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIConfirmDialog, decorators: [{
3264
+ type: Component,
3265
+ args: [{ selector: "ui-confirm-dialog", standalone: true, imports: [UIButton, UIDialogHeader, UIDialogBody, UIDialogFooter], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ["surfaceType"] }], host: {
3266
+ class: "ui-confirm-dialog",
3267
+ "[class.danger]": "variant() === 'danger'",
3268
+ "[class.warning]": "variant() === 'warning'",
3269
+ }, template: `
3270
+ <ui-dialog-header>{{ title() }}</ui-dialog-header>
3271
+ <ui-dialog-body>
3272
+ <p class="cd-message">{{ message() }}</p>
3273
+ </ui-dialog-body>
3274
+ <ui-dialog-footer>
3275
+ <ui-button
3276
+ variant="outlined"
3277
+ [ariaLabel]="cancelLabel()"
3278
+ (click)="cancel()"
3279
+ >
3280
+ {{ cancelLabel() }}
3281
+ </ui-button>
3282
+ <ui-button
3283
+ variant="filled"
3284
+ [ariaLabel]="confirmLabel()"
3285
+ (click)="confirm()"
3286
+ >
3287
+ {{ confirmLabel() }}
3288
+ </ui-button>
3289
+ </ui-dialog-footer>
3290
+ `, styles: [":host{display:flex;flex-direction:column;min-width:20rem}.cd-message{margin:0;line-height:1.55;white-space:pre-wrap}:host(.danger){--ui-accent: #d93025}:host(.warning){--ui-accent: #e5a50a}\n"] }]
3291
+ }], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], message: [{ type: i0.Input, args: [{ isSignal: true, alias: "message", required: false }] }], confirmLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "confirmLabel", required: false }] }], cancelLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "cancelLabel", required: false }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }] } });
3292
+
3293
+ /**
3294
+ * Content component for a prompt dialog.
3295
+ *
3296
+ * Displayed by {@link CommonDialogService.prompt}. Shows a title,
3297
+ * message, a text input, and OK / Cancel buttons. Resolves to the
3298
+ * entered string or `null` if cancelled.
3299
+ *
3300
+ * @internal — not intended for direct use; use the service instead.
3301
+ */
3302
+ class UIPromptDialog {
3303
+ title = input("Prompt", ...(ngDevMode ? [{ debugName: "title" }] : []));
3304
+ message = input("", ...(ngDevMode ? [{ debugName: "message" }] : []));
3305
+ defaultValue = input("", ...(ngDevMode ? [{ debugName: "defaultValue" }] : []));
3306
+ placeholder = input("", ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
3307
+ okLabel = input("OK", ...(ngDevMode ? [{ debugName: "okLabel" }] : []));
3308
+ cancelLabel = input("Cancel", ...(ngDevMode ? [{ debugName: "cancelLabel" }] : []));
3309
+ inputValue = signal("", ...(ngDevMode ? [{ debugName: "inputValue" }] : []));
3310
+ modalRef = inject((ModalRef));
3311
+ ngOnInit() {
3312
+ this.inputValue.set(this.defaultValue());
3313
+ }
3314
+ ok() {
3315
+ this.modalRef.close(this.inputValue());
3316
+ }
3317
+ cancel() {
3318
+ this.modalRef.close(null);
3319
+ }
3320
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIPromptDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
3321
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.1.0", type: UIPromptDialog, isStandalone: true, selector: "ui-prompt-dialog", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, message: { classPropertyName: "message", publicName: "message", isSignal: true, isRequired: false, transformFunction: null }, defaultValue: { classPropertyName: "defaultValue", publicName: "defaultValue", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, okLabel: { classPropertyName: "okLabel", publicName: "okLabel", isSignal: true, isRequired: false, transformFunction: null }, cancelLabel: { classPropertyName: "cancelLabel", publicName: "cancelLabel", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "ui-prompt-dialog" }, hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: `
3322
+ <ui-dialog-header>{{ title() }}</ui-dialog-header>
3323
+ <ui-dialog-body>
3324
+ <p class="cd-message">{{ message() }}</p>
3325
+ <ui-input
3326
+ [(value)]="inputValue"
3327
+ [placeholder]="placeholder()"
3328
+ ariaLabel="Prompt input"
3329
+ />
3330
+ </ui-dialog-body>
3331
+ <ui-dialog-footer>
3332
+ <ui-button
3333
+ variant="outlined"
3334
+ [ariaLabel]="cancelLabel()"
3335
+ (click)="cancel()"
3336
+ >
3337
+ {{ cancelLabel() }}
3338
+ </ui-button>
3339
+ <ui-button variant="filled" [ariaLabel]="okLabel()" (click)="ok()">
3340
+ {{ okLabel() }}
3341
+ </ui-button>
3342
+ </ui-dialog-footer>
3343
+ `, isInline: true, styles: [":host{display:flex;flex-direction:column;min-width:24rem}.cd-message{margin:0 0 .75rem;line-height:1.55;white-space:pre-wrap}\n"], dependencies: [{ kind: "component", type: UIButton, selector: "ui-button", inputs: ["type", "variant", "color", "size", "pill", "disabled", "ariaLabel"] }, { kind: "component", type: UIInput, selector: "ui-input", inputs: ["type", "text", "value", "adapter", "placeholder", "disabled", "multiline", "rows", "heightAdjustable", "ariaLabel"], outputs: ["textChange", "valueChange"] }, { kind: "component", type: UIDialogHeader, selector: "ui-dialog-header" }, { kind: "component", type: UIDialogBody, selector: "ui-dialog-body" }, { kind: "component", type: UIDialogFooter, selector: "ui-dialog-footer" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3344
+ }
3345
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIPromptDialog, decorators: [{
3346
+ type: Component,
3347
+ args: [{ selector: "ui-prompt-dialog", standalone: true, imports: [UIButton, UIInput, UIDialogHeader, UIDialogBody, UIDialogFooter], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ['surfaceType'] }], host: { class: "ui-prompt-dialog" }, template: `
3348
+ <ui-dialog-header>{{ title() }}</ui-dialog-header>
3349
+ <ui-dialog-body>
3350
+ <p class="cd-message">{{ message() }}</p>
3351
+ <ui-input
3352
+ [(value)]="inputValue"
3353
+ [placeholder]="placeholder()"
3354
+ ariaLabel="Prompt input"
3355
+ />
3356
+ </ui-dialog-body>
3357
+ <ui-dialog-footer>
3358
+ <ui-button
3359
+ variant="outlined"
3360
+ [ariaLabel]="cancelLabel()"
3361
+ (click)="cancel()"
3362
+ >
3363
+ {{ cancelLabel() }}
3364
+ </ui-button>
3365
+ <ui-button variant="filled" [ariaLabel]="okLabel()" (click)="ok()">
3366
+ {{ okLabel() }}
3367
+ </ui-button>
3368
+ </ui-dialog-footer>
3369
+ `, styles: [":host{display:flex;flex-direction:column;min-width:24rem}.cd-message{margin:0 0 .75rem;line-height:1.55;white-space:pre-wrap}\n"] }]
3370
+ }], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], message: [{ type: i0.Input, args: [{ isSignal: true, alias: "message", required: false }] }], defaultValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaultValue", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], okLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "okLabel", required: false }] }], cancelLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "cancelLabel", required: false }] }] } });
3371
+
3372
+ /**
3373
+ * Content component for an open-file dialog.
3374
+ *
3375
+ * Displayed by {@link CommonDialogService.openFile}. Embeds a
3376
+ * {@link UIFileBrowser} and resolves to the selected file(s) or
3377
+ * `null` if cancelled.
3378
+ *
3379
+ * @internal — not intended for direct use; use the service instead.
3380
+ */
3381
+ class UIOpenFileDialog {
3382
+ title = input("Open File", ...(ngDevMode ? [{ debugName: "title" }] : []));
3383
+ openLabel = input("Open", ...(ngDevMode ? [{ debugName: "openLabel" }] : []));
3384
+ datasource = input.required(...(ngDevMode ? [{ debugName: "datasource" }] : []));
3385
+ selectedFile = signal(null, ...(ngDevMode ? [{ debugName: "selectedFile" }] : []));
3386
+ modalRef = inject((ModalRef));
3387
+ onFileActivated(_event) {
3388
+ const entry = this.selectedFile();
3389
+ if (entry && !entry.isDirectory) {
3390
+ this.modalRef.close({ files: [entry] });
3391
+ }
3392
+ }
3393
+ open() {
3394
+ const entry = this.selectedFile();
3395
+ if (entry && !entry.isDirectory) {
3396
+ this.modalRef.close({ files: [entry] });
3397
+ }
3398
+ }
3399
+ cancel() {
3400
+ this.modalRef.close(null);
3401
+ }
3402
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIOpenFileDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
3403
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.1.0", type: UIOpenFileDialog, isStandalone: true, selector: "ui-open-file-dialog", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, openLabel: { classPropertyName: "openLabel", publicName: "openLabel", isSignal: true, isRequired: false, transformFunction: null }, datasource: { classPropertyName: "datasource", publicName: "datasource", isSignal: true, isRequired: true, transformFunction: null } }, host: { classAttribute: "ui-open-file-dialog" }, hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: `
3404
+ <ui-dialog-header>{{ title() }}</ui-dialog-header>
3405
+ <ui-dialog-body>
3406
+ <ui-file-browser
3407
+ [datasource]="datasource()"
3408
+ [(selectedEntry)]="selectedFile"
3409
+ (fileActivated)="onFileActivated($event)"
3410
+ ariaLabel="Browse files"
3411
+ />
3412
+ </ui-dialog-body>
3413
+ <ui-dialog-footer>
3414
+ <ui-button variant="outlined" ariaLabel="Cancel" (click)="cancel()">
3415
+ Cancel
3416
+ </ui-button>
3417
+ <ui-button
3418
+ variant="filled"
3419
+ [ariaLabel]="openLabel()"
3420
+ [disabled]="!selectedFile() || selectedFile()!.isDirectory"
3421
+ (click)="open()"
3422
+ >
3423
+ {{ openLabel() }}
3424
+ </ui-button>
3425
+ </ui-dialog-footer>
3426
+ `, isInline: true, styles: [":host{display:flex;flex-direction:column;min-width:36rem}:host ::ng-deep ui-dialog-body{height:24rem;overflow:hidden}:host ::ng-deep ui-dialog-body ui-file-browser{height:100%}\n"], dependencies: [{ kind: "component", type: UIButton, selector: "ui-button", inputs: ["type", "variant", "color", "size", "pill", "disabled", "ariaLabel"] }, { kind: "component", type: UIDialogHeader, selector: "ui-dialog-header" }, { kind: "component", type: UIDialogBody, selector: "ui-dialog-body" }, { kind: "component", type: UIDialogFooter, selector: "ui-dialog-footer" }, { kind: "component", type: UIFileBrowser, selector: "ui-file-browser", inputs: ["datasource", "ariaLabel", "showSidebar", "rootLabel", "viewMode", "showDetails", "name", "metadataProvider", "selectedEntry"], outputs: ["selectedEntryChange", "fileActivated", "directoryChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3427
+ }
3428
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIOpenFileDialog, decorators: [{
3429
+ type: Component,
3430
+ args: [{ selector: "ui-open-file-dialog", standalone: true, imports: [
3431
+ UIButton,
3432
+ UIDialogHeader,
3433
+ UIDialogBody,
3434
+ UIDialogFooter,
3435
+ UIFileBrowser,
3436
+ ], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ['surfaceType'] }], host: { class: "ui-open-file-dialog" }, template: `
3437
+ <ui-dialog-header>{{ title() }}</ui-dialog-header>
3438
+ <ui-dialog-body>
3439
+ <ui-file-browser
3440
+ [datasource]="datasource()"
3441
+ [(selectedEntry)]="selectedFile"
3442
+ (fileActivated)="onFileActivated($event)"
3443
+ ariaLabel="Browse files"
3444
+ />
3445
+ </ui-dialog-body>
3446
+ <ui-dialog-footer>
3447
+ <ui-button variant="outlined" ariaLabel="Cancel" (click)="cancel()">
3448
+ Cancel
3449
+ </ui-button>
3450
+ <ui-button
3451
+ variant="filled"
3452
+ [ariaLabel]="openLabel()"
3453
+ [disabled]="!selectedFile() || selectedFile()!.isDirectory"
3454
+ (click)="open()"
3455
+ >
3456
+ {{ openLabel() }}
3457
+ </ui-button>
3458
+ </ui-dialog-footer>
3459
+ `, styles: [":host{display:flex;flex-direction:column;min-width:36rem}:host ::ng-deep ui-dialog-body{height:24rem;overflow:hidden}:host ::ng-deep ui-dialog-body ui-file-browser{height:100%}\n"] }]
3460
+ }], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], openLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "openLabel", required: false }] }], datasource: [{ type: i0.Input, args: [{ isSignal: true, alias: "datasource", required: true }] }] } });
3461
+
3462
+ /**
3463
+ * Content component for a save-file dialog.
3464
+ *
3465
+ * Displayed by {@link CommonDialogService.saveFile}. Embeds a
3466
+ * {@link UIFileBrowser} for directory navigation plus a file-name
3467
+ * input. Resolves to a directory + file name, or `null` if cancelled.
3468
+ *
3469
+ * @internal — not intended for direct use; use the service instead.
3470
+ */
3471
+ class UISaveFileDialog {
3472
+ title = input("Save File", ...(ngDevMode ? [{ debugName: "title" }] : []));
3473
+ saveLabel = input("Save", ...(ngDevMode ? [{ debugName: "saveLabel" }] : []));
3474
+ defaultName = input("", ...(ngDevMode ? [{ debugName: "defaultName" }] : []));
3475
+ datasource = input.required(...(ngDevMode ? [{ debugName: "datasource" }] : []));
3476
+ fileName = signal("", ...(ngDevMode ? [{ debugName: "fileName" }] : []));
3477
+ currentDir = signal(null, ...(ngDevMode ? [{ debugName: "currentDir" }] : []));
3478
+ modalRef = inject((ModalRef));
3479
+ ngOnInit() {
3480
+ this.fileName.set(this.defaultName());
3481
+ }
3482
+ onDirectoryChange(event) {
3483
+ this.currentDir.set(event.directory);
3484
+ }
3485
+ save() {
3486
+ const name = this.fileName().trim();
3487
+ if (name) {
3488
+ this.modalRef.close({ directory: this.currentDir(), name });
3489
+ }
3490
+ }
3491
+ cancel() {
3492
+ this.modalRef.close(null);
3493
+ }
3494
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UISaveFileDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
3495
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.1.0", type: UISaveFileDialog, isStandalone: true, selector: "ui-save-file-dialog", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, saveLabel: { classPropertyName: "saveLabel", publicName: "saveLabel", isSignal: true, isRequired: false, transformFunction: null }, defaultName: { classPropertyName: "defaultName", publicName: "defaultName", isSignal: true, isRequired: false, transformFunction: null }, datasource: { classPropertyName: "datasource", publicName: "datasource", isSignal: true, isRequired: true, transformFunction: null } }, host: { classAttribute: "ui-save-file-dialog" }, hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: `
3496
+ <ui-dialog-header>{{ title() }}</ui-dialog-header>
3497
+ <ui-dialog-body>
3498
+ <ui-file-browser
3499
+ [datasource]="datasource()"
3500
+ (directoryChange)="onDirectoryChange($event)"
3501
+ ariaLabel="Browse directories"
3502
+ />
3503
+ <div class="cd-filename-row">
3504
+ <span class="cd-filename-label">File name:</span>
3505
+ <ui-input
3506
+ [(value)]="fileName"
3507
+ placeholder="Enter file name"
3508
+ ariaLabel="File name"
3509
+ />
3510
+ </div>
3511
+ </ui-dialog-body>
3512
+ <ui-dialog-footer>
3513
+ <ui-button variant="outlined" ariaLabel="Cancel" (click)="cancel()">
3514
+ Cancel
3515
+ </ui-button>
3516
+ <ui-button
3517
+ variant="filled"
3518
+ [ariaLabel]="saveLabel()"
3519
+ [disabled]="!fileName().trim()"
3520
+ (click)="save()"
3521
+ >
3522
+ {{ saveLabel() }}
3523
+ </ui-button>
3524
+ </ui-dialog-footer>
3525
+ `, isInline: true, styles: [":host{display:flex;flex-direction:column;min-width:36rem}:host ::ng-deep ui-dialog-body{display:flex;flex-direction:column;gap:.75rem;height:26rem;overflow:hidden}:host ::ng-deep ui-dialog-body ui-file-browser{flex:1;min-height:0}.cd-filename-row{display:flex;align-items:center;gap:.75rem}.cd-filename-label{font-weight:600;font-size:.88rem;white-space:nowrap}.cd-filename-row ui-input{flex:1}\n"], dependencies: [{ kind: "component", type: UIButton, selector: "ui-button", inputs: ["type", "variant", "color", "size", "pill", "disabled", "ariaLabel"] }, { kind: "component", type: UIInput, selector: "ui-input", inputs: ["type", "text", "value", "adapter", "placeholder", "disabled", "multiline", "rows", "heightAdjustable", "ariaLabel"], outputs: ["textChange", "valueChange"] }, { kind: "component", type: UIDialogHeader, selector: "ui-dialog-header" }, { kind: "component", type: UIDialogBody, selector: "ui-dialog-body" }, { kind: "component", type: UIDialogFooter, selector: "ui-dialog-footer" }, { kind: "component", type: UIFileBrowser, selector: "ui-file-browser", inputs: ["datasource", "ariaLabel", "showSidebar", "rootLabel", "viewMode", "showDetails", "name", "metadataProvider", "selectedEntry"], outputs: ["selectedEntryChange", "fileActivated", "directoryChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3526
+ }
3527
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UISaveFileDialog, decorators: [{
3528
+ type: Component,
3529
+ args: [{ selector: "ui-save-file-dialog", standalone: true, imports: [
3530
+ UIButton,
3531
+ UIInput,
3532
+ UIDialogHeader,
3533
+ UIDialogBody,
3534
+ UIDialogFooter,
3535
+ UIFileBrowser,
3536
+ ], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ['surfaceType'] }], host: { class: "ui-save-file-dialog" }, template: `
3537
+ <ui-dialog-header>{{ title() }}</ui-dialog-header>
3538
+ <ui-dialog-body>
3539
+ <ui-file-browser
3540
+ [datasource]="datasource()"
3541
+ (directoryChange)="onDirectoryChange($event)"
3542
+ ariaLabel="Browse directories"
3543
+ />
3544
+ <div class="cd-filename-row">
3545
+ <span class="cd-filename-label">File name:</span>
3546
+ <ui-input
3547
+ [(value)]="fileName"
3548
+ placeholder="Enter file name"
3549
+ ariaLabel="File name"
3550
+ />
3551
+ </div>
3552
+ </ui-dialog-body>
3553
+ <ui-dialog-footer>
3554
+ <ui-button variant="outlined" ariaLabel="Cancel" (click)="cancel()">
3555
+ Cancel
3556
+ </ui-button>
3557
+ <ui-button
3558
+ variant="filled"
3559
+ [ariaLabel]="saveLabel()"
3560
+ [disabled]="!fileName().trim()"
3561
+ (click)="save()"
3562
+ >
3563
+ {{ saveLabel() }}
3564
+ </ui-button>
3565
+ </ui-dialog-footer>
3566
+ `, styles: [":host{display:flex;flex-direction:column;min-width:36rem}:host ::ng-deep ui-dialog-body{display:flex;flex-direction:column;gap:.75rem;height:26rem;overflow:hidden}:host ::ng-deep ui-dialog-body ui-file-browser{flex:1;min-height:0}.cd-filename-row{display:flex;align-items:center;gap:.75rem}.cd-filename-label{font-weight:600;font-size:.88rem;white-space:nowrap}.cd-filename-row ui-input{flex:1}\n"] }]
3567
+ }], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], saveLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "saveLabel", required: false }] }], defaultName: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaultName", required: false }] }], datasource: [{ type: i0.Input, args: [{ isSignal: true, alias: "datasource", required: true }] }] } });
3568
+
3569
+ /**
3570
+ * Content component for an "About" dialog.
3571
+ *
3572
+ * Displayed by {@link CommonDialogService.about}. Shows the
3573
+ * application name, version, description, optional logo, copyright
3574
+ * notice, and credits.
3575
+ *
3576
+ * @internal — not intended for direct use; use the service instead.
3577
+ */
3578
+ class UIAboutDialog {
3579
+ appName = input("Application", ...(ngDevMode ? [{ debugName: "appName" }] : []));
3580
+ version = input("", ...(ngDevMode ? [{ debugName: "version" }] : []));
3581
+ description = input("", ...(ngDevMode ? [{ debugName: "description" }] : []));
3582
+ logoUrl = input("", ...(ngDevMode ? [{ debugName: "logoUrl" }] : []));
3583
+ copyright = input("", ...(ngDevMode ? [{ debugName: "copyright" }] : []));
3584
+ credits = input([], ...(ngDevMode ? [{ debugName: "credits" }] : []));
3585
+ modalRef = inject((ModalRef));
3586
+ dismiss() {
3587
+ this.modalRef.close(undefined);
3588
+ }
3589
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIAboutDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
3590
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIAboutDialog, isStandalone: true, selector: "ui-about-dialog", inputs: { appName: { classPropertyName: "appName", publicName: "appName", isSignal: true, isRequired: false, transformFunction: null }, version: { classPropertyName: "version", publicName: "version", isSignal: true, isRequired: false, transformFunction: null }, description: { classPropertyName: "description", publicName: "description", isSignal: true, isRequired: false, transformFunction: null }, logoUrl: { classPropertyName: "logoUrl", publicName: "logoUrl", isSignal: true, isRequired: false, transformFunction: null }, copyright: { classPropertyName: "copyright", publicName: "copyright", isSignal: true, isRequired: false, transformFunction: null }, credits: { classPropertyName: "credits", publicName: "credits", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "ui-about-dialog" }, hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: `
3591
+ <ui-dialog-header>About {{ appName() }}</ui-dialog-header>
3592
+ <ui-dialog-body>
3593
+ <div class="cd-about-content">
3594
+ @if (logoUrl()) {
3595
+ <img
3596
+ class="cd-about-logo"
3597
+ [src]="logoUrl()"
3598
+ [alt]="appName() + ' logo'"
3599
+ />
3600
+ }
3601
+ <h2 class="cd-about-name">{{ appName() }}</h2>
3602
+ @if (version()) {
3603
+ <span class="cd-about-version">Version {{ version() }}</span>
3604
+ }
3605
+ @if (description()) {
3606
+ <p class="cd-about-desc">{{ description() }}</p>
3607
+ }
3608
+ @if (credits().length > 0) {
3609
+ <div class="cd-about-credits">
3610
+ <h4 class="cd-about-credits-title">Credits</h4>
3611
+ <ul class="cd-about-credits-list">
3612
+ @for (credit of credits(); track credit) {
3613
+ <li>{{ credit }}</li>
3614
+ }
3615
+ </ul>
3616
+ </div>
3617
+ }
3618
+ @if (copyright()) {
3619
+ <p class="cd-about-copyright">{{ copyright() }}</p>
3620
+ }
3621
+ </div>
3622
+ </ui-dialog-body>
3623
+ <ui-dialog-footer>
3624
+ <ui-button variant="filled" ariaLabel="Close" (click)="dismiss()">
3625
+ Close
3626
+ </ui-button>
3627
+ </ui-dialog-footer>
3628
+ `, isInline: true, styles: [":host{display:flex;flex-direction:column;min-width:22rem;max-width:28rem}.cd-about-content{display:flex;flex-direction:column;align-items:center;text-align:center;gap:.25rem}.cd-about-logo{width:64px;height:64px;object-fit:contain;margin-bottom:.5rem}.cd-about-name{margin:0;font-size:1.25rem;font-weight:700}.cd-about-version{font-size:.82rem;color:var(--ui-text-muted, #5a6470)}.cd-about-desc{margin:.5rem 0 0;line-height:1.55;font-size:.92rem}.cd-about-credits{margin-top:.75rem;width:100%;text-align:left}.cd-about-credits-title{margin:0 0 .35rem;font-size:.85rem;font-weight:600}.cd-about-credits-list{margin:0;padding-left:1.25rem;font-size:.85rem;line-height:1.6;color:var(--ui-text-muted, #5a6470)}.cd-about-copyright{margin:.75rem 0 0;font-size:.78rem;color:var(--ui-text-muted, #5a6470)}\n"], dependencies: [{ kind: "component", type: UIButton, selector: "ui-button", inputs: ["type", "variant", "color", "size", "pill", "disabled", "ariaLabel"] }, { kind: "component", type: UIDialogHeader, selector: "ui-dialog-header" }, { kind: "component", type: UIDialogBody, selector: "ui-dialog-body" }, { kind: "component", type: UIDialogFooter, selector: "ui-dialog-footer" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3629
+ }
3630
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIAboutDialog, decorators: [{
3631
+ type: Component,
3632
+ args: [{ selector: "ui-about-dialog", standalone: true, imports: [UIButton, UIDialogHeader, UIDialogBody, UIDialogFooter], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ['surfaceType'] }], host: { class: "ui-about-dialog" }, template: `
3633
+ <ui-dialog-header>About {{ appName() }}</ui-dialog-header>
3634
+ <ui-dialog-body>
3635
+ <div class="cd-about-content">
3636
+ @if (logoUrl()) {
3637
+ <img
3638
+ class="cd-about-logo"
3639
+ [src]="logoUrl()"
3640
+ [alt]="appName() + ' logo'"
3641
+ />
3642
+ }
3643
+ <h2 class="cd-about-name">{{ appName() }}</h2>
3644
+ @if (version()) {
3645
+ <span class="cd-about-version">Version {{ version() }}</span>
3646
+ }
3647
+ @if (description()) {
3648
+ <p class="cd-about-desc">{{ description() }}</p>
3649
+ }
3650
+ @if (credits().length > 0) {
3651
+ <div class="cd-about-credits">
3652
+ <h4 class="cd-about-credits-title">Credits</h4>
3653
+ <ul class="cd-about-credits-list">
3654
+ @for (credit of credits(); track credit) {
3655
+ <li>{{ credit }}</li>
3656
+ }
3657
+ </ul>
3658
+ </div>
3659
+ }
3660
+ @if (copyright()) {
3661
+ <p class="cd-about-copyright">{{ copyright() }}</p>
3662
+ }
3663
+ </div>
3664
+ </ui-dialog-body>
3665
+ <ui-dialog-footer>
3666
+ <ui-button variant="filled" ariaLabel="Close" (click)="dismiss()">
3667
+ Close
3668
+ </ui-button>
3669
+ </ui-dialog-footer>
3670
+ `, styles: [":host{display:flex;flex-direction:column;min-width:22rem;max-width:28rem}.cd-about-content{display:flex;flex-direction:column;align-items:center;text-align:center;gap:.25rem}.cd-about-logo{width:64px;height:64px;object-fit:contain;margin-bottom:.5rem}.cd-about-name{margin:0;font-size:1.25rem;font-weight:700}.cd-about-version{font-size:.82rem;color:var(--ui-text-muted, #5a6470)}.cd-about-desc{margin:.5rem 0 0;line-height:1.55;font-size:.92rem}.cd-about-credits{margin-top:.75rem;width:100%;text-align:left}.cd-about-credits-title{margin:0 0 .35rem;font-size:.85rem;font-weight:600}.cd-about-credits-list{margin:0;padding-left:1.25rem;font-size:.85rem;line-height:1.6;color:var(--ui-text-muted, #5a6470)}.cd-about-copyright{margin:.75rem 0 0;font-size:.78rem;color:var(--ui-text-muted, #5a6470)}\n"] }]
3671
+ }], propDecorators: { appName: [{ type: i0.Input, args: [{ isSignal: true, alias: "appName", required: false }] }], version: [{ type: i0.Input, args: [{ isSignal: true, alias: "version", required: false }] }], description: [{ type: i0.Input, args: [{ isSignal: true, alias: "description", required: false }] }], logoUrl: [{ type: i0.Input, args: [{ isSignal: true, alias: "logoUrl", required: false }] }], copyright: [{ type: i0.Input, args: [{ isSignal: true, alias: "copyright", required: false }] }], credits: [{ type: i0.Input, args: [{ isSignal: true, alias: "credits", required: false }] }] } });
3672
+
3673
+ /**
3674
+ * Service for showing common application dialogs — alert, confirm,
3675
+ * prompt, open-file, save-file, and about.
3676
+ *
3677
+ * Each method opens a modal dialog and returns a `Promise` that
3678
+ * resolves when the user closes it. The service delegates to
3679
+ * {@link ModalService} for the underlying dialog lifecycle.
3680
+ *
3681
+ * @example
3682
+ * ```ts
3683
+ * const confirmed = await this.dialogs.confirm({
3684
+ * title: 'Delete item?',
3685
+ * message: 'This action cannot be undone.',
3686
+ * variant: 'danger',
3687
+ * });
3688
+ *
3689
+ * if (confirmed) { ... }
3690
+ * ```
3691
+ */
3692
+ class CommonDialogService {
3693
+ modal = inject(ModalService);
3694
+ /**
3695
+ * Show a simple informational alert with a dismiss button.
3696
+ *
3697
+ * @returns Resolves when the user dismisses the dialog.
3698
+ */
3699
+ async alert(options) {
3700
+ const ref = this.modal.openModal({
3701
+ component: UIAlertDialog,
3702
+ inputs: {
3703
+ title: options.title,
3704
+ message: options.message,
3705
+ buttonLabel: options.buttonLabel ?? "OK",
3706
+ },
3707
+ ariaLabel: options.ariaLabel ?? options.title,
3708
+ });
3709
+ await firstValueFrom(ref.closed);
3710
+ }
3711
+ /**
3712
+ * Show a confirmation dialog with confirm and cancel buttons.
3713
+ *
3714
+ * @returns `true` if the user confirmed, `false` otherwise.
3715
+ */
3716
+ async confirm(options) {
3717
+ const ref = this.modal.openModal({
3718
+ component: UIConfirmDialog,
3719
+ inputs: {
3720
+ title: options.title,
3721
+ message: options.message,
3722
+ confirmLabel: options.confirmLabel ?? "OK",
3723
+ cancelLabel: options.cancelLabel ?? "Cancel",
3724
+ variant: options.variant ?? "primary",
3725
+ },
3726
+ ariaLabel: options.ariaLabel ?? options.title,
3727
+ });
3728
+ const result = await firstValueFrom(ref.closed);
3729
+ return result ?? false;
3730
+ }
3731
+ /**
3732
+ * Show a prompt dialog with a text input.
3733
+ *
3734
+ * @returns The entered string, or `null` if cancelled.
3735
+ */
3736
+ async prompt(options) {
3737
+ const ref = this.modal.openModal({
3738
+ component: UIPromptDialog,
3739
+ inputs: {
3740
+ title: options.title,
3741
+ message: options.message,
3742
+ defaultValue: options.defaultValue ?? "",
3743
+ placeholder: options.placeholder ?? "",
3744
+ okLabel: options.okLabel ?? "OK",
3745
+ cancelLabel: options.cancelLabel ?? "Cancel",
3746
+ },
3747
+ ariaLabel: options.ariaLabel ?? options.title,
3748
+ });
3749
+ const result = await firstValueFrom(ref.closed);
3750
+ return result ?? null;
3751
+ }
3752
+ /**
3753
+ * Show an open-file dialog powered by {@link UIFileBrowser}.
3754
+ *
3755
+ * @returns The selected file(s), or `null` if cancelled.
3756
+ */
3757
+ async openFile(options) {
3758
+ const ref = this.modal.openModal({
3759
+ component: UIOpenFileDialog,
3760
+ inputs: {
3761
+ title: options.title ?? "Open File",
3762
+ openLabel: options.openLabel ?? "Open",
3763
+ datasource: options.datasource,
3764
+ },
3765
+ ariaLabel: options.ariaLabel ?? options.title ?? "Open File",
3766
+ });
3767
+ const result = await firstValueFrom(ref.closed);
3768
+ return result ?? null;
3769
+ }
3770
+ /**
3771
+ * Show a save-file dialog powered by {@link UIFileBrowser} with a
3772
+ * file-name input.
3773
+ *
3774
+ * @returns The directory and file name, or `null` if cancelled.
3775
+ */
3776
+ async saveFile(options) {
3777
+ const ref = this.modal.openModal({
3778
+ component: UISaveFileDialog,
3779
+ inputs: {
3780
+ title: options.title ?? "Save File",
3781
+ saveLabel: options.saveLabel ?? "Save",
3782
+ defaultName: options.defaultName ?? "",
3783
+ datasource: options.datasource,
3784
+ },
3785
+ ariaLabel: options.ariaLabel ?? options.title ?? "Save File",
3786
+ });
3787
+ const result = await firstValueFrom(ref.closed);
3788
+ return result ?? null;
3789
+ }
3790
+ /**
3791
+ * Show an "About" dialog with application information.
3792
+ *
3793
+ * @returns Resolves when the user closes the dialog.
3794
+ */
3795
+ async about(options) {
3796
+ const ref = this.modal.openModal({
3797
+ component: UIAboutDialog,
3798
+ inputs: {
3799
+ appName: options.appName,
3800
+ version: options.version ?? "",
3801
+ description: options.description ?? "",
3802
+ logoUrl: options.logoUrl ?? "",
3803
+ copyright: options.copyright ?? "",
3804
+ credits: options.credits ?? [],
3805
+ },
3806
+ ariaLabel: options.ariaLabel ?? `About ${options.appName}`,
3807
+ });
3808
+ await firstValueFrom(ref.closed);
3809
+ }
3810
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: CommonDialogService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
3811
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: CommonDialogService, providedIn: "root" });
3812
+ }
3813
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: CommonDialogService, decorators: [{
3814
+ type: Injectable,
3815
+ args: [{ providedIn: "root" }]
3816
+ }] });
3817
+
3818
+ /*
3819
+ * Public API Surface of @theredhead/lucid-blocks
3820
+ */
3821
+
3822
+ /**
3823
+ * Generated bundle index. Do not edit.
3824
+ */
3825
+
3826
+ export { CommonDialogService, FILE_ICON_REGISTRY, SavedSearchService, UIAboutDialog, UIAlertDialog, UIChatView, UICommandPalette, UIConfirmDialog, UIDashboard, UIDashboardPanel, UIFileBrowser, UIKanbanBoard, UIMasterDetailView, UIMessageBubble, UINavigationPage, UIOpenFileDialog, UIPromptDialog, UIPropertySheet, UISaveFileDialog, UISearchView, UIWizard, UIWizardStep, navGroup, navItem, routesToNavigation };
3827
+ //# sourceMappingURL=theredhead-lucid-blocks.mjs.map