@aquera/nile-elements 1.7.3 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/index-199b0eac.esm.js +1 -0
  2. package/dist/index-f5e587e2.cjs.js +2 -0
  3. package/dist/index-f5e587e2.cjs.js.map +1 -0
  4. package/dist/index.cjs.js +1 -1
  5. package/dist/index.esm.js +1 -1
  6. package/dist/index.js +993 -680
  7. package/dist/nile-combobox/index.cjs.js +1 -1
  8. package/dist/nile-combobox/index.esm.js +1 -1
  9. package/dist/nile-combobox/nile-combobox.cjs.js +1 -1
  10. package/dist/nile-combobox/nile-combobox.cjs.js.map +1 -1
  11. package/dist/nile-combobox/nile-combobox.esm.js +28 -28
  12. package/dist/nile-detail/index.cjs.js +1 -1
  13. package/dist/nile-detail/index.esm.js +1 -1
  14. package/dist/nile-detail/nile-detail.cjs.js +1 -1
  15. package/dist/nile-detail/nile-detail.cjs.js.map +1 -1
  16. package/dist/nile-detail/nile-detail.css.cjs.js +1 -1
  17. package/dist/nile-detail/nile-detail.css.cjs.js.map +1 -1
  18. package/dist/nile-detail/nile-detail.css.esm.js +230 -0
  19. package/dist/nile-detail/nile-detail.esm.js +89 -7
  20. package/dist/nile-floating-panel/index.cjs.js +1 -1
  21. package/dist/nile-floating-panel/nile-floating-panel.cjs.js +1 -1
  22. package/dist/nile-inline-sidebar-item/nile-inline-sidebar-item.cjs.js +1 -1
  23. package/dist/nile-inline-sidebar-item/nile-inline-sidebar-item.cjs.js.map +1 -1
  24. package/dist/nile-inline-sidebar-item/nile-inline-sidebar-item.esm.js +1 -1
  25. package/dist/nile-inline-sidebar-item-header/nile-inline-sidebar-item-header.css.cjs.js +1 -1
  26. package/dist/nile-inline-sidebar-item-header/nile-inline-sidebar-item-header.css.cjs.js.map +1 -1
  27. package/dist/nile-inline-sidebar-item-header/nile-inline-sidebar-item-header.css.esm.js +2 -1
  28. package/dist/nile-lite-tooltip/index.cjs.js +1 -1
  29. package/dist/nile-lite-tooltip/nile-lite-tooltip.cjs.js +1 -1
  30. package/dist/src/nile-detail/nile-detail.css.js +230 -0
  31. package/dist/src/nile-detail/nile-detail.css.js.map +1 -1
  32. package/dist/src/nile-detail/nile-detail.d.ts +151 -0
  33. package/dist/src/nile-detail/nile-detail.js +829 -4
  34. package/dist/src/nile-detail/nile-detail.js.map +1 -1
  35. package/dist/src/nile-inline-sidebar-item/nile-inline-sidebar-item.js +1 -1
  36. package/dist/src/nile-inline-sidebar-item/nile-inline-sidebar-item.js.map +1 -1
  37. package/dist/src/nile-inline-sidebar-item-header/nile-inline-sidebar-item-header.css.js +2 -1
  38. package/dist/src/nile-inline-sidebar-item-header/nile-inline-sidebar-item-header.css.js.map +1 -1
  39. package/dist/src/version.js +1 -1
  40. package/dist/src/version.js.map +1 -1
  41. package/dist/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +1 -1
  43. package/src/nile-detail/nile-detail.css.ts +230 -0
  44. package/src/nile-detail/nile-detail.ts +876 -4
  45. package/src/nile-inline-sidebar-item/nile-inline-sidebar-item.ts +1 -1
  46. package/src/nile-inline-sidebar-item-header/nile-inline-sidebar-item-header.css.ts +2 -1
  47. package/vscode-html-custom-data.json +120 -1
@@ -1,6 +1,7 @@
1
1
  import { html, TemplateResult, nothing } from 'lit';
2
2
  import { customElement, property, query, state } from 'lit/decorators.js';
3
3
  import { classMap } from 'lit/directives/class-map.js';
4
+ import { unsafeHTML } from 'lit/directives/unsafe-html.js';
4
5
  import { styles } from './nile-detail.css';
5
6
  import NileElement from '../internal/nile-element';
6
7
  import { watch } from '../internal/watch';
@@ -12,8 +13,98 @@ import {
12
13
  handleSummaryClick,
13
14
  handleSummaryKeyDown,
14
15
  } from './nile-detail.utils';
16
+ import '../nile-checkbox/index';
17
+ import '../nile-input/index';
18
+ import '../nile-link/index';
19
+ import '../nile-icon/index';
20
+ import '../nile-lite-tooltip/index';
21
+ import { VirtualizerController } from '@tanstack/lit-virtual';
15
22
  import type { CSSResultGroup } from 'lit';
16
23
 
24
+ export type NileDetailVariant = 'default' | 'selection';
25
+
26
+ export interface NileDetailSelectionItem {
27
+ value: string;
28
+ label?: string;
29
+ description?: string;
30
+ prefix?: string;
31
+ suffix?: string;
32
+ disabled?: boolean;
33
+ className?: string;
34
+ }
35
+
36
+ export interface NileDetailRenderItemConfig {
37
+ getDisplayText: (item: any) => string;
38
+ getValue?: (item: any) => string;
39
+ getSearchText?: (item: any) => string;
40
+ getDescription?: (item: any) => string;
41
+ getPrefix?: (item: any) => string;
42
+ getSuffix?: (item: any) => string;
43
+ getDisabled?: (item: any) => boolean;
44
+ getClassName?: (item: any) => string;
45
+ }
46
+
47
+ type SelectionItem = string | NileDetailSelectionItem | Record<string, any>;
48
+
49
+ const IS_BROWSER = typeof window !== 'undefined';
50
+
51
+ export interface NileDetailSelectionConfig {
52
+ // Identity
53
+ variant?: NileDetailVariant;
54
+ heading?: string;
55
+ description?: string;
56
+ itemsLabel?: string;
57
+
58
+ // Items / selection
59
+ items?: SelectionItem[];
60
+ selected?: string[];
61
+ renderItemConfig?: NileDetailRenderItemConfig;
62
+ allowHtmlLabel?: boolean;
63
+
64
+ // Layout
65
+ layout?: {
66
+ orientation?: 'horizontal' | 'vertical' | 'both';
67
+ gridRows?: number;
68
+ gridColumns?: number;
69
+ matrixColumns?: number;
70
+ minColumnWidth?: string;
71
+ laneHeight?: number;
72
+ maxHeight?: string;
73
+ expandIconPlacement?: 'left' | 'right';
74
+ };
75
+
76
+ // Search
77
+ search?: {
78
+ placeholder?: string;
79
+ disableLocal?: boolean;
80
+ };
81
+
82
+ // Virtualization
83
+ virtualization?: {
84
+ enabled?: boolean;
85
+ threshold?: number;
86
+ overscan?: number;
87
+ };
88
+
89
+ // Pagination / infinite scroll
90
+ pagination?: {
91
+ totalCount?: number;
92
+ pageSize?: number;
93
+ fetchPage?: (offset: number, limit: number) => Promise<SelectionItem[]>;
94
+ placeholderLabel?: string;
95
+ };
96
+
97
+ // Action labels
98
+ labels?: {
99
+ restore?: string;
100
+ clear?: string;
101
+ };
102
+
103
+ // Inherited / passthrough
104
+ open?: boolean;
105
+ disabled?: boolean;
106
+ }
107
+
17
108
  @customElement('nile-detail')
18
109
  export class NileDetail extends NileElement {
19
110
 
@@ -33,12 +124,562 @@ export class NileDetail extends NileElement {
33
124
 
34
125
  @property({ attribute: true, type: Boolean, reflect: true }) disabled = false;
35
126
 
127
+ @property({ attribute: true, type: String, reflect: true }) variant: NileDetailVariant = 'default';
128
+
129
+ @property({ attribute: false, type: Array }) items: SelectionItem[] = [];
130
+
131
+ @property({ attribute: false, type: Array }) selected: string[] = [];
132
+
133
+ @property({ attribute: false }) renderItemConfig?: NileDetailRenderItemConfig;
134
+
135
+ @property({ attribute: 'allow-html-label', type: Boolean }) allowHtmlLabel = false;
136
+
137
+ @property({ attribute: 'disable-local-search', type: Boolean }) disableLocalSearch = false;
138
+
139
+ @property({ attribute: 'total-count', type: Number }) totalCount?: number;
140
+
141
+ @property({ attribute: 'page-size', type: Number }) pageSize = 200;
142
+
143
+ @property({ attribute: false }) fetchPage?: (offset: number, limit: number) => Promise<SelectionItem[]>;
144
+
145
+ @property({ attribute: 'placeholder-label', type: String }) placeholderLabel = 'Loading…';
146
+
147
+ @property({ attribute: false }) config?: NileDetailSelectionConfig;
148
+
149
+ @property({ attribute: 'search-placeholder', type: String }) searchPlaceholder = 'Search...';
150
+
151
+ @property({ attribute: 'items-label', type: String }) itemsLabel = 'attributes';
152
+
153
+ @property({ attribute: 'restore-label', type: String }) restoreLabel = 'Restore';
154
+
155
+ @property({ attribute: 'clear-label', type: String }) clearLabel = 'Clear';
156
+
157
+ @property({ attribute: 'grid-rows', type: Number }) gridRows = 6;
158
+
159
+ @property({ attribute: 'grid-columns', type: Number }) gridColumns = 3;
160
+
161
+ @property({ attribute: 'min-column-width', type: String }) minColumnWidth = '220px';
162
+
163
+ @property({ attribute: 'lane-height', type: Number }) laneHeight = 32;
164
+
165
+ @property({ attribute: true, type: String, reflect: true }) orientation: 'horizontal' | 'vertical' | 'both' = 'horizontal';
166
+
167
+ @property({ attribute: 'max-height', type: String }) maxHeight = '320px';
168
+
169
+ @property({ attribute: 'matrix-columns', type: Number }) matrixColumns = 20;
170
+
171
+ @property({ attribute: true, type: Boolean }) virtualize = false;
172
+
173
+ @property({ attribute: 'virtualize-threshold', type: Number }) virtualizeThreshold = 60;
174
+
175
+ @property({ attribute: 'overscan', type: Number }) overscan = 4;
176
+
36
177
  @state() private _detailOpen = false;
37
178
 
179
+ @state() private _searchTerm = '';
180
+
181
+ @state() private _selectedSet: Set<string> = new Set();
182
+
183
+ private _restoreDefaults: string[] | null = null;
184
+
185
+ private _gridResizeObserver: ResizeObserver | null = null;
186
+
187
+ private _virtCtrl: VirtualizerController<HTMLElement, HTMLElement> | null = null;
188
+
189
+ private _rowVirtCtrl: VirtualizerController<HTMLElement, HTMLElement> | null = null;
190
+
191
+ private _colVirtCtrl: VirtualizerController<HTMLElement, HTMLElement> | null = null;
192
+
193
+ private _pageCache: any = new Map();
194
+
195
+ private _pendingPages: any = new Set();
196
+
197
+ private get _isInfiniteMode(): boolean {
198
+ return (
199
+ this.variant === 'selection' &&
200
+ typeof this.fetchPage === 'function' &&
201
+ typeof this.totalCount === 'number' &&
202
+ this.orientation !== 'both'
203
+ );
204
+ }
205
+
206
+ private _effectiveTotalCount(): number {
207
+ if (this._isInfiniteMode) return this.totalCount ?? 0;
208
+ return this._filteredItems.length;
209
+ }
210
+
211
+ private _getItemAt(index: number): SelectionItem | undefined {
212
+ if (!this._isInfiniteMode) return this._filteredItems[index];
213
+ const pageSize = Math.max(1, this.pageSize);
214
+ const pageIndex = Math.floor(index / pageSize);
215
+ const page = this._pageCache.get(pageIndex);
216
+ if (!page) return undefined;
217
+ return page[index - pageIndex * pageSize];
218
+ }
219
+
220
+ private _scheduleFetchForRange(start: number, end: number) {
221
+ if (!this._isInfiniteMode || !this.fetchPage) return;
222
+ const pageSize = Math.max(1, this.pageSize);
223
+ const total = this.totalCount ?? 0;
224
+ if (total <= 0) return;
225
+
226
+ const firstPage = Math.floor(start / pageSize);
227
+ const lastPage = Math.floor(Math.min(end, total - 1) / pageSize);
228
+ for (let p = firstPage; p <= lastPage; p++) {
229
+ if (this._pageCache.has(p) || this._pendingPages.has(p)) continue;
230
+ this._loadPage(p);
231
+ }
232
+ }
233
+
234
+ private async _loadPage(pageIndex: number) {
235
+ if (!this.fetchPage) return;
236
+ const pageSize = Math.max(1, this.pageSize);
237
+ const offset = pageIndex * pageSize;
238
+ const total = this.totalCount ?? 0;
239
+ const limit = Math.min(pageSize, Math.max(0, total - offset));
240
+ if (limit <= 0) return;
241
+
242
+ this._pendingPages.add(pageIndex);
243
+ try {
244
+ const rows = await this.fetchPage(offset, limit);
245
+ this._pageCache.set(pageIndex, Array.isArray(rows) ? rows : []);
246
+ this.dispatchEvent(
247
+ new CustomEvent('nile-page-load', {
248
+ detail: { pageIndex, offset, limit, rows: this._pageCache.get(pageIndex) },
249
+ bubbles: true,
250
+ composed: true,
251
+ })
252
+ );
253
+ } catch (err) {
254
+ this.dispatchEvent(
255
+ new CustomEvent('nile-page-error', {
256
+ detail: { pageIndex, offset, limit, error: err },
257
+ bubbles: true,
258
+ composed: true,
259
+ })
260
+ );
261
+ } finally {
262
+ this._pendingPages.delete(pageIndex);
263
+ this.requestUpdate();
264
+ }
265
+ }
266
+
267
+ /** Public: clear the page cache (use after a query change to refetch). */
268
+ resetPages() {
269
+ this._pageCache = new Map();
270
+ this._pendingPages = new Set();
271
+ this.requestUpdate();
272
+ }
273
+
274
+ private get _isVirtualized(): boolean {
275
+ if (!IS_BROWSER) return false;
276
+ if (this._isInfiniteMode) return true;
277
+ return this.variant === 'selection'
278
+ && (this.virtualize || this.items.length > this.virtualizeThreshold);
279
+ }
280
+
281
+ private get _columnWidthPx(): number {
282
+ const parsed = parseInt(this.minColumnWidth, 10);
283
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 220;
284
+ }
285
+
38
286
  firstUpdated() {
39
287
  this._detailOpen = this.open;
40
288
  this.body.hidden = !this.open;
41
289
  this.body.style.height = this.open ? 'auto' : '0';
290
+ this._syncSelectedFromProperty();
291
+ if (this._restoreDefaults === null) {
292
+ this._restoreDefaults = [...this._selectedSet];
293
+ }
294
+ this._setupGridResizeObserver();
295
+ }
296
+
297
+ disconnectedCallback() {
298
+ super.disconnectedCallback();
299
+ this._gridResizeObserver?.disconnect();
300
+ this._gridResizeObserver = null;
301
+ }
302
+
303
+ updated(_changed: Map<string, unknown>) {
304
+ if (this.variant === 'selection') {
305
+ this._setupGridResizeObserver();
306
+ this._syncVirtualizer();
307
+ this._scheduleTooltipOverflowCheck();
308
+ }
309
+ }
310
+
311
+ private _syncVirtualizer() {
312
+ if (!IS_BROWSER) return;
313
+ if (!this._isVirtualized) {
314
+ this._virtCtrl = null;
315
+ this._rowVirtCtrl = null;
316
+ this._colVirtCtrl = null;
317
+ return;
318
+ }
319
+ const grid = this.shadowRoot?.querySelector('.detail__selection-grid') as HTMLElement | null;
320
+ if (!grid) return;
321
+
322
+ if (this.orientation === 'both') {
323
+ this._virtCtrl = null;
324
+ this._syncBothVirtualizers();
325
+ return;
326
+ }
327
+
328
+ // Reset 2-axis controllers if we switched away from 'both'.
329
+ this._rowVirtCtrl = null;
330
+ this._colVirtCtrl = null;
331
+
332
+ const count = this._effectiveTotalCount();
333
+ const isVertical = this.orientation === 'vertical';
334
+ const lanes = Math.max(1, isVertical ? this.gridColumns : this.gridRows);
335
+ const itemSize = isVertical ? this.laneHeight : this._columnWidthPx;
336
+
337
+ if (!this._virtCtrl) {
338
+ this._virtCtrl = new VirtualizerController<HTMLElement, HTMLElement>(this, {
339
+ count,
340
+ getScrollElement: () =>
341
+ this.shadowRoot?.querySelector('.detail__selection-grid') as HTMLElement | null,
342
+ estimateSize: () => itemSize,
343
+ horizontal: !isVertical,
344
+ lanes,
345
+ overscan: this.overscan,
346
+ });
347
+ return;
348
+ }
349
+
350
+ const v = this._virtCtrl.getVirtualizer();
351
+ const prev = v.options;
352
+ const orientationChanged = !!prev.horizontal !== !isVertical;
353
+ if (
354
+ prev.count !== count ||
355
+ prev.lanes !== lanes ||
356
+ prev.overscan !== this.overscan ||
357
+ orientationChanged
358
+ ) {
359
+ v.setOptions({
360
+ ...prev,
361
+ count,
362
+ lanes,
363
+ overscan: this.overscan,
364
+ horizontal: !isVertical,
365
+ estimateSize: () => itemSize,
366
+ });
367
+ v.measure();
368
+ }
369
+ }
370
+
371
+ private _syncBothVirtualizers() {
372
+ const totalItems = this._filteredItems.length;
373
+ const cols = Math.max(1, this.matrixColumns);
374
+ const rows = Math.max(1, Math.ceil(totalItems / cols));
375
+ const columnWidth = this._columnWidthPx;
376
+ const rowHeight = this.laneHeight;
377
+ const getScrollEl = () =>
378
+ this.shadowRoot?.querySelector('.detail__selection-grid') as HTMLElement | null;
379
+
380
+ if (!this._rowVirtCtrl) {
381
+ this._rowVirtCtrl = new VirtualizerController<HTMLElement, HTMLElement>(this, {
382
+ count: rows,
383
+ getScrollElement: getScrollEl,
384
+ estimateSize: () => rowHeight,
385
+ horizontal: false,
386
+ overscan: this.overscan,
387
+ });
388
+ } else {
389
+ const rv = this._rowVirtCtrl.getVirtualizer();
390
+ const prev = rv.options;
391
+ if (prev.count !== rows || prev.overscan !== this.overscan) {
392
+ rv.setOptions({
393
+ ...prev,
394
+ count: rows,
395
+ overscan: this.overscan,
396
+ estimateSize: () => rowHeight,
397
+ });
398
+ rv.measure();
399
+ }
400
+ }
401
+
402
+ if (!this._colVirtCtrl) {
403
+ this._colVirtCtrl = new VirtualizerController<HTMLElement, HTMLElement>(this, {
404
+ count: cols,
405
+ getScrollElement: getScrollEl,
406
+ estimateSize: () => columnWidth,
407
+ horizontal: true,
408
+ overscan: this.overscan,
409
+ });
410
+ } else {
411
+ const cv = this._colVirtCtrl.getVirtualizer();
412
+ const prev = cv.options;
413
+ if (prev.count !== cols || prev.overscan !== this.overscan) {
414
+ cv.setOptions({
415
+ ...prev,
416
+ count: cols,
417
+ overscan: this.overscan,
418
+ estimateSize: () => columnWidth,
419
+ });
420
+ cv.measure();
421
+ }
422
+ }
423
+ }
424
+
425
+ private _setupGridResizeObserver() {
426
+ if (this.variant !== 'selection') return;
427
+ if (typeof ResizeObserver === 'undefined') return;
428
+ const grid = this.shadowRoot?.querySelector('.detail__selection-grid') as HTMLElement | null;
429
+ if (!grid) return;
430
+ if (this._gridResizeObserver) return;
431
+ this._gridResizeObserver = new ResizeObserver(() => this._scheduleTooltipOverflowCheck());
432
+ this._gridResizeObserver.observe(grid);
433
+ }
434
+
435
+ private _scheduleTooltipOverflowCheck() {
436
+ if (typeof requestAnimationFrame === 'undefined') return;
437
+ requestAnimationFrame(() => this._updateTooltipOverflowStates());
438
+ }
439
+
440
+ private async _updateTooltipOverflowStates() {
441
+ const tooltips = this.shadowRoot?.querySelectorAll(
442
+ '.detail__selection-tooltip'
443
+ ) as NodeListOf<HTMLElement> | undefined;
444
+ if (!tooltips) return;
445
+ for (const tip of tooltips) {
446
+ const cb = tip.querySelector('nile-checkbox') as HTMLElement & { updateComplete?: Promise<unknown> } | null;
447
+ if (!cb) continue;
448
+ if (cb.updateComplete) await cb.updateComplete;
449
+ const labelEl = cb.shadowRoot?.querySelector('[part="label"]') as HTMLElement | null;
450
+ const overflowing = !!labelEl && labelEl.scrollWidth > labelEl.clientWidth + 1;
451
+ if (overflowing) tip.removeAttribute('disabled');
452
+ else tip.setAttribute('disabled', '');
453
+ }
454
+ }
455
+
456
+ @watch('selected', { waitUntilFirstUpdate: false })
457
+ _onSelectedPropertyChange() {
458
+ this._syncSelectedFromProperty();
459
+ }
460
+
461
+ @watch('config', { waitUntilFirstUpdate: false })
462
+ _onConfigChange() {
463
+ this._applyConfig();
464
+ }
465
+
466
+ private _applyConfig() {
467
+ const c = this.config;
468
+ if (!c) return;
469
+
470
+ if (c.variant !== undefined) this.variant = c.variant;
471
+ if (c.heading !== undefined) this.heading = c.heading;
472
+ if (c.description !== undefined) this.description = c.description;
473
+ if (c.itemsLabel !== undefined) this.itemsLabel = c.itemsLabel;
474
+ if (c.open !== undefined) this.open = c.open;
475
+ if (c.disabled !== undefined) this.disabled = c.disabled;
476
+
477
+ if (c.items !== undefined) this.items = c.items;
478
+ if (c.selected !== undefined) this.selected = c.selected;
479
+ if (c.renderItemConfig !== undefined) this.renderItemConfig = c.renderItemConfig;
480
+ if (c.allowHtmlLabel !== undefined) this.allowHtmlLabel = c.allowHtmlLabel;
481
+
482
+ if (c.layout) {
483
+ const l = c.layout;
484
+ if (l.orientation !== undefined) this.orientation = l.orientation;
485
+ if (l.gridRows !== undefined) this.gridRows = l.gridRows;
486
+ if (l.gridColumns !== undefined) this.gridColumns = l.gridColumns;
487
+ if (l.matrixColumns !== undefined) this.matrixColumns = l.matrixColumns;
488
+ if (l.minColumnWidth !== undefined) this.minColumnWidth = l.minColumnWidth;
489
+ if (l.laneHeight !== undefined) this.laneHeight = l.laneHeight;
490
+ if (l.maxHeight !== undefined) this.maxHeight = l.maxHeight;
491
+ if (l.expandIconPlacement !== undefined) this.expandIconPlacement = l.expandIconPlacement;
492
+ }
493
+
494
+ if (c.search) {
495
+ if (c.search.placeholder !== undefined) this.searchPlaceholder = c.search.placeholder;
496
+ if (c.search.disableLocal !== undefined) this.disableLocalSearch = c.search.disableLocal;
497
+ }
498
+
499
+ if (c.virtualization) {
500
+ if (c.virtualization.enabled !== undefined) this.virtualize = c.virtualization.enabled;
501
+ if (c.virtualization.threshold !== undefined) this.virtualizeThreshold = c.virtualization.threshold;
502
+ if (c.virtualization.overscan !== undefined) this.overscan = c.virtualization.overscan;
503
+ }
504
+
505
+ if (c.pagination) {
506
+ if (c.pagination.totalCount !== undefined) this.totalCount = c.pagination.totalCount;
507
+ if (c.pagination.pageSize !== undefined) this.pageSize = c.pagination.pageSize;
508
+ if (c.pagination.fetchPage !== undefined) this.fetchPage = c.pagination.fetchPage;
509
+ if (c.pagination.placeholderLabel !== undefined) this.placeholderLabel = c.pagination.placeholderLabel;
510
+ }
511
+
512
+ if (c.labels) {
513
+ if (c.labels.restore !== undefined) this.restoreLabel = c.labels.restore;
514
+ if (c.labels.clear !== undefined) this.clearLabel = c.labels.clear;
515
+ }
516
+ }
517
+
518
+ private _syncSelectedFromProperty() {
519
+ this._selectedSet = new Set(this.selected || []);
520
+ }
521
+
522
+ private _getItemValue(item: SelectionItem): string {
523
+ const rc = this.renderItemConfig;
524
+ if (rc?.getValue) return String(rc.getValue(item));
525
+ if (typeof item === 'string') return item;
526
+ return String((item as NileDetailSelectionItem).value ?? '');
527
+ }
528
+
529
+ private _getItemLabel(item: SelectionItem): string {
530
+ const rc = this.renderItemConfig;
531
+ if (rc?.getDisplayText) return rc.getDisplayText(item);
532
+ if (typeof item === 'string') return item;
533
+ return (item as NileDetailSelectionItem).label ?? this._getItemValue(item);
534
+ }
535
+
536
+ private _getItemSearchText(item: SelectionItem): string {
537
+ const rc = this.renderItemConfig;
538
+ if (rc?.getSearchText) return rc.getSearchText(item);
539
+ return this._getItemLabel(item);
540
+ }
541
+
542
+ private _getItemDescription(item: SelectionItem): string {
543
+ const rc = this.renderItemConfig;
544
+ if (rc?.getDescription) return rc.getDescription(item) ?? '';
545
+ if (typeof item === 'string') return '';
546
+ return (item as NileDetailSelectionItem).description ?? '';
547
+ }
548
+
549
+ private _getItemPrefix(item: SelectionItem): string {
550
+ const rc = this.renderItemConfig;
551
+ if (rc?.getPrefix) return rc.getPrefix(item) ?? '';
552
+ if (typeof item === 'string') return '';
553
+ return (item as NileDetailSelectionItem).prefix ?? '';
554
+ }
555
+
556
+ private _getItemSuffix(item: SelectionItem): string {
557
+ const rc = this.renderItemConfig;
558
+ if (rc?.getSuffix) return rc.getSuffix(item) ?? '';
559
+ if (typeof item === 'string') return '';
560
+ return (item as NileDetailSelectionItem).suffix ?? '';
561
+ }
562
+
563
+ private _getItemDisabled(item: SelectionItem): boolean {
564
+ const rc = this.renderItemConfig;
565
+ if (rc?.getDisabled) return !!rc.getDisabled(item);
566
+ if (typeof item === 'string') return false;
567
+ return !!(item as NileDetailSelectionItem).disabled;
568
+ }
569
+
570
+ private _getItemClassName(item: SelectionItem): string {
571
+ const rc = this.renderItemConfig;
572
+ if (rc?.getClassName) return rc.getClassName(item) ?? '';
573
+ if (typeof item === 'string') return '';
574
+ return (item as NileDetailSelectionItem).className ?? '';
575
+ }
576
+
577
+ private get _filteredItems(): SelectionItem[] {
578
+ if (this.disableLocalSearch) return this.items;
579
+ const q = this._searchTerm.trim().toLowerCase();
580
+ if (!q) return this.items;
581
+ return this.items.filter(item =>
582
+ this._getItemSearchText(item).toLowerCase().includes(q)
583
+ );
584
+ }
585
+
586
+ private _selectableValues(): string[] {
587
+ const source: SelectionItem[] = this._isInfiniteMode
588
+ ? (Array.from(this._pageCache.values()).flat() as SelectionItem[])
589
+ : this.items;
590
+ return source
591
+ .filter(i => !this._getItemDisabled(i))
592
+ .map(i => this._getItemValue(i));
593
+ }
594
+
595
+ private get _allChecked(): boolean {
596
+ const selectable = this._selectableValues();
597
+ if (selectable.length === 0) return false;
598
+ return selectable.every(v => this._selectedSet.has(v));
599
+ }
600
+
601
+ private get _indeterminate(): boolean {
602
+ const selectable = this._selectableValues();
603
+ const sel = selectable.filter(v => this._selectedSet.has(v)).length;
604
+ return sel > 0 && sel < selectable.length;
605
+ }
606
+
607
+ private get _countLabel(): string {
608
+ const total = this._isInfiniteMode ? (this.totalCount ?? 0) : this.items.length;
609
+ return `${this._selectedSet.size} of ${total} ${this.itemsLabel} selected`;
610
+ }
611
+
612
+ private _emitSelectionChange() {
613
+ this.selected = [...this._selectedSet];
614
+ this.dispatchEvent(
615
+ new CustomEvent('nile-change', {
616
+ detail: { selected: [...this._selectedSet] },
617
+ bubbles: true,
618
+ composed: true,
619
+ })
620
+ );
621
+ }
622
+
623
+ private _onItemChange(value: string, disabled: boolean, event: Event) {
624
+ if (disabled) {
625
+ event.preventDefault();
626
+ return;
627
+ }
628
+ const target = event.target as HTMLInputElement;
629
+ const next = new Set(this._selectedSet);
630
+ if (target.checked) next.add(value);
631
+ else next.delete(value);
632
+ this._selectedSet = next;
633
+ this._emitSelectionChange();
634
+ }
635
+
636
+ private _onSelectAllChange(event: Event) {
637
+ const target = event.target as HTMLInputElement;
638
+ const selectable = this._selectableValues();
639
+ const next = new Set(this._selectedSet);
640
+ if (target.checked) selectable.forEach(v => next.add(v));
641
+ else selectable.forEach(v => next.delete(v));
642
+ this._selectedSet = next;
643
+ this._emitSelectionChange();
644
+ }
645
+
646
+ private _emitSearch(value: string) {
647
+ this.dispatchEvent(
648
+ new CustomEvent('nile-search', {
649
+ detail: { value },
650
+ bubbles: true,
651
+ composed: true,
652
+ })
653
+ );
654
+ }
655
+
656
+ private _onSearchInput(event: Event) {
657
+ const target = event.target as HTMLInputElement;
658
+ const value = target.value ?? '';
659
+ this._searchTerm = value;
660
+ if (this.disableLocalSearch) this._emitSearch(value);
661
+ }
662
+
663
+ private _onSearchClear() {
664
+ this._searchTerm = '';
665
+ if (this.disableLocalSearch) this._emitSearch('');
666
+ }
667
+
668
+ private _onRestore(event: Event) {
669
+ event.preventDefault();
670
+ const source = this._restoreDefaults ?? this._selectableValues();
671
+ this._selectedSet = new Set(source);
672
+ this._emitSelectionChange();
673
+ }
674
+
675
+ private _onClear(event: Event) {
676
+ event.preventDefault();
677
+ this._selectedSet = new Set();
678
+ this._emitSelectionChange();
679
+ }
680
+
681
+ private _stopHeaderToggle(event: Event) {
682
+ event.stopPropagation();
42
683
  }
43
684
 
44
685
  private _handleSummaryClick(event: Event) {
@@ -73,7 +714,230 @@ export class NileDetail extends NileElement {
73
714
  return [this.heading, this.description].filter(Boolean).join(', ');
74
715
  }
75
716
 
717
+ private _renderSelectionLabel(): TemplateResult {
718
+ return html`
719
+ <div part="selection-label" class="detail__selection-label">
720
+ <nile-checkbox
721
+ part="select-all"
722
+ class="detail__select-all"
723
+ ?checked=${this._allChecked}
724
+ ?indeterminate=${this._indeterminate}
725
+ aria-label=${`Select all ${this.heading || this.itemsLabel}`}
726
+ @click=${this._stopHeaderToggle}
727
+ @nile-change=${this._onSelectAllChange}
728
+ ></nile-checkbox>
729
+ <div class="detail__selection-title">
730
+ <span part="label-text" class="detail__heading-text">${this.heading}</span>
731
+ <span part="selection-count" class="detail__selection-count">${this._countLabel}</span>
732
+ </div>
733
+ </div>
734
+ `;
735
+ }
736
+
737
+ private _renderSelectionBody(): TemplateResult {
738
+ const virtualized = this._isVirtualized;
739
+ const isVertical = this.orientation === 'vertical';
740
+ const isBoth = this.orientation === 'both';
741
+ const laneHeightPx = this.laneHeight;
742
+ const columnWidth = this._columnWidthPx;
743
+
744
+ let gridStyle = '';
745
+ if (virtualized) {
746
+ if (isBoth) {
747
+ gridStyle = `max-height: ${this.maxHeight}; height: ${this.maxHeight};`;
748
+ } else if (isVertical) {
749
+ gridStyle = `max-height: ${this.maxHeight}; height: ${this.maxHeight};`;
750
+ } else {
751
+ const lanes = Math.max(1, this.gridRows);
752
+ gridStyle = `height: ${lanes * laneHeightPx}px;`;
753
+ }
754
+ } else if (isVertical) {
755
+ const cols = Math.max(1, this.gridColumns);
756
+ gridStyle = `grid-template-columns: repeat(${cols}, minmax(0, 1fr)); grid-auto-flow: row; max-height: ${this.maxHeight}; overflow-y: auto; overflow-x: hidden;`;
757
+ } else {
758
+ const lanes = Math.max(1, this.gridRows);
759
+ gridStyle = `grid-template-rows: repeat(${lanes}, auto); grid-auto-columns: minmax(${this.minColumnWidth}, 1fr);`;
760
+ }
761
+
762
+ return html`
763
+ <div part="selection-toolbar" class="detail__selection-toolbar">
764
+ <nile-input
765
+ part="search"
766
+ class="detail__selection-search"
767
+ placeholder=${this.searchPlaceholder}
768
+ clearable
769
+ .value=${this._searchTerm}
770
+ @nile-input=${this._onSearchInput}
771
+ @nile-clear=${this._onSearchClear}
772
+ >
773
+ <nile-icon slot="prefix" name="search" library="system"></nile-icon>
774
+ </nile-input>
775
+ <div part="selection-actions" class="detail__selection-actions">
776
+ <nile-link href="#" variant="subtle" @click=${this._onRestore}>${this.restoreLabel}</nile-link>
777
+ <nile-link href="#" variant="subtle" @click=${this._onClear}>${this.clearLabel}</nile-link>
778
+ </div>
779
+ </div>
780
+ <div
781
+ part="selection-grid"
782
+ class=${classMap({
783
+ 'detail__selection-grid': true,
784
+ 'detail__selection-grid--virtual': virtualized,
785
+ 'detail__selection-grid--vertical': isVertical,
786
+ 'detail__selection-grid--both': isBoth,
787
+ })}
788
+ style=${gridStyle}
789
+ >
790
+ ${virtualized
791
+ ? (isBoth
792
+ ? this._renderBothVirtualGrid(laneHeightPx, columnWidth)
793
+ : this._renderVirtualGrid(laneHeightPx, columnWidth, isVertical))
794
+ : this._renderStaticGrid()}
795
+ </div>
796
+ `;
797
+ }
798
+
799
+ private _renderItemContent(item: SelectionItem): TemplateResult {
800
+ const label = this._getItemLabel(item);
801
+ const description = this._getItemDescription(item);
802
+ const prefix = this._getItemPrefix(item);
803
+ const suffix = this._getItemSuffix(item);
804
+ const allowHtml = this.allowHtmlLabel;
805
+ return html`
806
+ ${prefix
807
+ ? html`<span class="detail__selection-prefix" part="item-prefix">${allowHtml ? unsafeHTML(prefix) : prefix}</span>`
808
+ : ''}
809
+ <span class="detail__selection-text" part="item-text">
810
+ <span class="detail__selection-item-label" part="item-label">${allowHtml ? unsafeHTML(label) : label}</span>
811
+ ${description
812
+ ? html`<span class="detail__selection-desc" part="item-description">${allowHtml ? unsafeHTML(description) : description}</span>`
813
+ : ''}
814
+ </span>
815
+ ${suffix
816
+ ? html`<span class="detail__selection-suffix" part="item-suffix">${allowHtml ? unsafeHTML(suffix) : suffix}</span>`
817
+ : ''}
818
+ `;
819
+ }
820
+
821
+ private _renderItemTooltip(item: SelectionItem, opts: { positionStyle?: string; extraClasses?: string } = {}): TemplateResult {
822
+ const value = this._getItemValue(item);
823
+ const label = this._getItemLabel(item);
824
+ const description = this._getItemDescription(item);
825
+ const isDisabled = this._getItemDisabled(item);
826
+ const className = this._getItemClassName(item);
827
+ const tooltipContent = description ? `${label} — ${description}` : label;
828
+
829
+ const tooltipClass = `detail__selection-tooltip${opts.extraClasses ? ' ' + opts.extraClasses : ''}`;
830
+ const checkboxClass = classMap({
831
+ 'detail__selection-checkbox': true,
832
+ 'detail__selection-checkbox--disabled': isDisabled,
833
+ [className]: !!className,
834
+ });
835
+
836
+ return html`
837
+ <nile-lite-tooltip
838
+ class=${tooltipClass}
839
+ content=${tooltipContent}
840
+ size="small"
841
+ disabled
842
+ style=${opts.positionStyle ?? nothing as any}
843
+ >
844
+ <nile-checkbox
845
+ class=${checkboxClass}
846
+ ?checked=${this._selectedSet.has(value)}
847
+ ?disabled=${isDisabled}
848
+ @nile-change=${(e: Event) => this._onItemChange(value, isDisabled, e)}
849
+ >${this._renderItemContent(item)}</nile-checkbox>
850
+ </nile-lite-tooltip>
851
+ `;
852
+ }
853
+
854
+ private _renderStaticGrid(): TemplateResult {
855
+ return html`${this._filteredItems.map(item => this._renderItemTooltip(item))}`;
856
+ }
857
+
858
+ private _renderBothVirtualGrid(_laneHeightPx: number, _columnWidthPx: number): TemplateResult {
859
+ const rowV = this._rowVirtCtrl?.getVirtualizer();
860
+ const colV = this._colVirtCtrl?.getVirtualizer();
861
+ const rowTotal = rowV?.getTotalSize() ?? 0;
862
+ const colTotal = colV?.getTotalSize() ?? 0;
863
+ const rowItems = rowV?.getVirtualItems() ?? [];
864
+ const colItems = colV?.getVirtualItems() ?? [];
865
+ const cols = Math.max(1, this.matrixColumns);
866
+ const total = this._filteredItems.length;
867
+
868
+ return html`
869
+ <div
870
+ class="detail__selection-track"
871
+ style="width: ${colTotal}px; height: ${rowTotal}px; position: relative;"
872
+ >
873
+ ${rowItems.flatMap(rvi =>
874
+ colItems.map(cvi => {
875
+ const idx = rvi.index * cols + cvi.index;
876
+ if (idx >= total) return null;
877
+ const item = this._filteredItems[idx];
878
+ if (item === undefined) return null;
879
+ const positionStyle = `position: absolute; top: ${rvi.start}px; left: ${cvi.start}px; width: ${cvi.size}px; height: ${rvi.size}px;`;
880
+ return this._renderItemTooltip(item, {
881
+ positionStyle,
882
+ extraClasses: 'detail__selection-tooltip--virtual',
883
+ });
884
+ })
885
+ )}
886
+ </div>
887
+ `;
888
+ }
889
+
890
+ private _renderVirtualGrid(
891
+ laneHeightPx: number,
892
+ _columnWidthPx: number,
893
+ isVertical: boolean
894
+ ): TemplateResult {
895
+ const v = this._virtCtrl?.getVirtualizer();
896
+ const totalSize = v?.getTotalSize() ?? 0;
897
+ const virtualItems = v?.getVirtualItems() ?? [];
898
+ const lanes = Math.max(1, isVertical ? this.gridColumns : this.gridRows);
899
+
900
+ if (this._isInfiniteMode && virtualItems.length > 0) {
901
+ const first = virtualItems[0].index;
902
+ const last = virtualItems[virtualItems.length - 1].index;
903
+ this._scheduleFetchForRange(first, last);
904
+ }
905
+
906
+ const trackStyle = isVertical
907
+ ? `width: 100%; height: ${totalSize}px; position: relative;`
908
+ : `width: ${totalSize}px; height: 100%; position: relative;`;
909
+
910
+ return html`
911
+ <div class="detail__selection-track" style=${trackStyle}>
912
+ ${virtualItems.map(vi => {
913
+ const laneIdx = vi.lane ?? 0;
914
+ const positionStyle = isVertical
915
+ ? `position: absolute; top: ${vi.start}px; left: calc(${laneIdx} * (100% / ${lanes})); width: calc(100% / ${lanes}); height: ${vi.size}px;`
916
+ : `position: absolute; top: ${laneIdx * laneHeightPx}px; left: ${vi.start}px; width: ${vi.size}px; height: ${laneHeightPx}px;`;
917
+ const item = this._getItemAt(vi.index);
918
+ if (item === undefined) {
919
+ return this._renderPlaceholder(positionStyle);
920
+ }
921
+ return this._renderItemTooltip(item, {
922
+ positionStyle,
923
+ extraClasses: 'detail__selection-tooltip--virtual',
924
+ });
925
+ })}
926
+ </div>
927
+ `;
928
+ }
929
+
930
+ private _renderPlaceholder(positionStyle: string): TemplateResult {
931
+ return html`
932
+ <div class="detail__selection-placeholder" style=${positionStyle} part="item-placeholder">
933
+ <div class="detail__selection-placeholder-bar"></div>
934
+ <span class="detail__selection-placeholder-label">${this.placeholderLabel}</span>
935
+ </div>
936
+ `;
937
+ }
938
+
76
939
  render(): TemplateResult {
940
+ const isSelection = this.variant === 'selection';
77
941
  return html`
78
942
  <details
79
943
  part="base"
@@ -82,6 +946,7 @@ export class NileDetail extends NileElement {
82
946
  'detail': true,
83
947
  'detail--open': this.open,
84
948
  'detail--disabled': this.disabled,
949
+ 'detail--selection': isSelection,
85
950
  })}
86
951
  >
87
952
  <summary
@@ -99,10 +964,14 @@ export class NileDetail extends NileElement {
99
964
  @keydown=${this._handleSummaryKeyDown}
100
965
  >
101
966
  <slot name="label" part="label" class="detail__label">
102
- <span part="label-text" class="detail__heading-text">${this.heading}</span>
103
- ${this.description
104
- ? html`<span part="description" class="detail__description">${this.description}</span>`
105
- : ''}
967
+ ${isSelection
968
+ ? this._renderSelectionLabel()
969
+ : html`
970
+ <span part="label-text" class="detail__heading-text">${this.heading}</span>
971
+ ${this.description
972
+ ? html`<span part="description" class="detail__description">${this.description}</span>`
973
+ : ''}
974
+ `}
106
975
  </slot>
107
976
 
108
977
  <slot name="header-actions" part="header-actions" class="detail__header-actions"></slot>
@@ -124,6 +993,9 @@ export class NileDetail extends NileElement {
124
993
  </summary>
125
994
 
126
995
  <div part="content" class="detail__body">
996
+ ${isSelection
997
+ ? html`<div part="selection-content" class="detail__selection-content">${this._renderSelectionBody()}</div>`
998
+ : ''}
127
999
  <slot id="content" class="detail__content"></slot>
128
1000
  </div>
129
1001
  </details>