@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
|