import { html, TemplateResult, nothing } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { styles } from './nile-detail.css'; import NileElement from '../internal/nile-element'; import { watch } from '../internal/watch'; import { animateShow, animateHide, showDetail, hideDetail, handleSummaryClick, handleSummaryKeyDown, getPreviewHeight, } from './nile-detail.utils'; import '../nile-checkbox/index'; import '../nile-input/index'; import '../nile-link/index'; import '../nile-icon/index'; import '../nile-lite-tooltip/index'; import { VirtualizerController } from '@tanstack/lit-virtual'; import type { CSSResultGroup } from 'lit'; export type NileDetailVariant = 'default' | 'selection' | 'light' | 'select-light'; export interface NileDetailSelectionItem { value: string; label?: string; description?: string; prefix?: string; suffix?: string; disabled?: boolean; className?: string; } export interface NileDetailRenderItemConfig { getDisplayText: (item: any) => string; getValue?: (item: any) => string; getSearchText?: (item: any) => string; getDescription?: (item: any) => string; getPrefix?: (item: any) => string; getSuffix?: (item: any) => string; getDisabled?: (item: any) => boolean; getClassName?: (item: any) => string; } type SelectionItem = string | NileDetailSelectionItem | Record; const IS_BROWSER = typeof window !== 'undefined'; export interface NileDetailSelectionConfig { // Identity variant?: NileDetailVariant; heading?: string; description?: string; itemsLabel?: string; // Items / selection items?: SelectionItem[]; selected?: string[]; renderItemConfig?: NileDetailRenderItemConfig; allowHtmlLabel?: boolean; // Layout layout?: { orientation?: 'horizontal' | 'vertical' | 'both'; gridRows?: number; gridColumns?: number; matrixColumns?: number; minColumnWidth?: string; laneHeight?: number; maxHeight?: string; expandIconPlacement?: 'left' | 'right'; }; // Search search?: { placeholder?: string; disableLocal?: boolean; }; // Virtualization virtualization?: { enabled?: boolean; threshold?: number; overscan?: number; }; // Pagination / infinite scroll pagination?: { totalCount?: number; pageSize?: number; fetchPage?: (offset: number, limit: number) => Promise; placeholderLabel?: string; }; // Action labels labels?: { restore?: string; clear?: string; selectedOnly?: string; showAll?: string; }; showSelectedToggle?: boolean; // Preview state preview?: boolean; previewPercentage?: number; // Inherited / passthrough open?: boolean; disabled?: boolean; } @customElement('nile-detail') export class NileDetail extends NileElement { static styles: CSSResultGroup = styles; @query('details') detail: HTMLDetailsElement; @query('summary') header: HTMLElement; @query('.detail__body') body: HTMLElement; @property({ attribute: true, type: Boolean, reflect: true }) open = false; @property({ attribute: true, type: String, reflect: true }) heading = ''; @property({ attribute: true, type: String, reflect: true }) description = ''; @property({ attribute: true, reflect: true }) expandIconPlacement: 'left' | 'right' = 'right'; @property({ attribute: true, type: Boolean, reflect: true }) disabled = false; /** When set, the closed state shows a preview of the body content instead of hiding it. */ @property({ attribute: true, type: Boolean, reflect: true }) preview = false; /** Percentage (0–100) of the content height shown while previewing. */ @property({ attribute: 'preview-percentage', type: Number, reflect: true }) previewPercentage = 30; @property({ attribute: true, type: String, reflect: true }) variant: NileDetailVariant = 'default'; @property({ attribute: false, type: Array }) items: SelectionItem[] = []; @property({ attribute: false, type: Array }) selected: string[] = []; @property({ attribute: false }) renderItemConfig?: NileDetailRenderItemConfig; @property({ attribute: 'allow-html-label', type: Boolean }) allowHtmlLabel = false; @property({ attribute: 'disable-local-search', type: Boolean }) disableLocalSearch = false; @property({ attribute: 'total-count', type: Number }) totalCount?: number; @property({ attribute: 'page-size', type: Number }) pageSize = 200; @property({ attribute: false }) fetchPage?: (offset: number, limit: number) => Promise; @property({ attribute: 'placeholder-label', type: String }) placeholderLabel = 'Loading…'; @property({ attribute: false }) config?: NileDetailSelectionConfig; @property({ attribute: 'search-placeholder', type: String }) searchPlaceholder = 'Search...'; @property({ attribute: 'items-label', type: String }) itemsLabel = 'attributes'; @property({ attribute: 'restore-label', type: String }) restoreLabel = 'Restore'; @property({ attribute: 'clear-label', type: String }) clearLabel = 'Clear'; @property({ attribute: 'grid-rows', type: Number }) gridRows = 6; @property({ attribute: 'grid-columns', type: Number }) gridColumns = 3; @property({ attribute: 'min-column-width', type: String }) minColumnWidth = '220px'; @property({ attribute: 'lane-height', type: Number }) laneHeight = 32; @property({ attribute: true, type: String, reflect: true }) orientation: 'horizontal' | 'vertical' | 'both' = 'horizontal'; @property({ attribute: 'max-height', type: String }) maxHeight = '320px'; @property({ attribute: 'matrix-columns', type: Number }) matrixColumns = 20; @property({ attribute: true, type: Boolean }) virtualize = false; @property({ attribute: 'virtualize-threshold', type: Number }) virtualizeThreshold = 60; @property({ attribute: 'overscan', type: Number }) overscan = 4; @state() private _detailOpen = false; @state() private _hasSlottedContent = false; @state() private _searchTerm = ''; @state() private _selectedSet: Set = new Set(); @state() private _showSelectedOnly = false; @property({ attribute: 'show-selected-toggle', type: Boolean, reflect: true }) showSelectedToggle = false; @property({ attribute: 'selected-only-label', type: String }) selectedOnlyLabel = 'Selected'; @property({ attribute: 'show-all-label', type: String }) showAllLabel = 'Show all'; private _restoreDefaults: string[] | null = null; private _gridResizeObserver: ResizeObserver | null = null; private _virtCtrl: VirtualizerController | null = null; private _rowVirtCtrl: VirtualizerController | null = null; private _colVirtCtrl: VirtualizerController | null = null; private _pageCache: any = new Map(); private _pendingPages: any = new Set(); /** True for any variant that renders the selection UI/behavior. */ private get _isSelectionVariant(): boolean { return this.variant === 'selection' || this.variant === 'select-light'; } /** True for any variant that applies the light styling. */ private get _isLightVariant(): boolean { return this.variant === 'light' || this.variant === 'select-light'; } /** True when preview mode is on with a usable percentage. */ private get _previewEnabled(): boolean { return this.preview && Math.min(100, Math.max(0, this.previewPercentage)) > 0; } /** True when the closed state should currently render the preview. */ private get _isPreviewActive(): boolean { return this._previewEnabled && !this.open; } private get _isInfiniteMode(): boolean { return ( this._isSelectionVariant && typeof this.fetchPage === 'function' && typeof this.totalCount === 'number' && this.orientation !== 'both' ); } private _effectiveTotalCount(): number { if (this._isInfiniteMode) return this.totalCount ?? 0; return this._filteredItems.length; } private _getItemAt(index: number): SelectionItem | undefined { if (!this._isInfiniteMode) return this._filteredItems[index]; const pageSize = Math.max(1, this.pageSize); const pageIndex = Math.floor(index / pageSize); const page = this._pageCache.get(pageIndex); if (!page) return undefined; return page[index - pageIndex * pageSize]; } private _scheduleFetchForRange(start: number, end: number) { if (!this._isInfiniteMode || !this.fetchPage) return; const pageSize = Math.max(1, this.pageSize); const total = this.totalCount ?? 0; if (total <= 0) return; const firstPage = Math.floor(start / pageSize); const lastPage = Math.floor(Math.min(end, total - 1) / pageSize); for (let p = firstPage; p <= lastPage; p++) { if (this._pageCache.has(p) || this._pendingPages.has(p)) continue; this._loadPage(p); } } private async _loadPage(pageIndex: number) { if (!this.fetchPage) return; const pageSize = Math.max(1, this.pageSize); const offset = pageIndex * pageSize; const total = this.totalCount ?? 0; const limit = Math.min(pageSize, Math.max(0, total - offset)); if (limit <= 0) return; this._pendingPages.add(pageIndex); try { const rows = await this.fetchPage(offset, limit); this._pageCache.set(pageIndex, Array.isArray(rows) ? rows : []); this.dispatchEvent( new CustomEvent('nile-page-load', { detail: { pageIndex, offset, limit, rows: this._pageCache.get(pageIndex) }, bubbles: true, composed: true, }) ); } catch (err) { this.dispatchEvent( new CustomEvent('nile-page-error', { detail: { pageIndex, offset, limit, error: err }, bubbles: true, composed: true, }) ); } finally { this._pendingPages.delete(pageIndex); this.requestUpdate(); } } /** Public: clear the page cache (use after a query change to refetch). */ resetPages() { this._pageCache = new Map(); this._pendingPages = new Set(); this.requestUpdate(); } private get _isVirtualized(): boolean { if (!IS_BROWSER) return false; if (this._isInfiniteMode) return true; return this._isSelectionVariant && (this.virtualize || this.items.length > this.virtualizeThreshold); } private get _columnWidthPx(): number { const parsed = parseInt(this.minColumnWidth, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : 220; } firstUpdated() { this._detailOpen = this.open || this._isPreviewActive; this.body.hidden = !this.open && !this._isPreviewActive; this.body.style.height = this.open ? 'auto' : '0'; if (this._isPreviewActive) { this._applyPreviewHeight(); } this._syncSelectedFromProperty(); if (this._restoreDefaults === null) { this._restoreDefaults = [...this._selectedSet]; } this._setupGridResizeObserver(); } disconnectedCallback() { super.disconnectedCallback(); this._gridResizeObserver?.disconnect(); this._gridResizeObserver = null; } updated(_changed: Map) { if (this._isSelectionVariant) { this._setupGridResizeObserver(); this._syncVirtualizer(); this._scheduleTooltipOverflowCheck(); } } private _syncVirtualizer() { if (!IS_BROWSER) return; if (!this._isVirtualized) { this._virtCtrl = null; this._rowVirtCtrl = null; this._colVirtCtrl = null; return; } const grid = this.shadowRoot?.querySelector('.detail__selection-grid') as HTMLElement | null; if (!grid) return; if (this.orientation === 'both') { this._virtCtrl = null; this._syncBothVirtualizers(); return; } // Reset 2-axis controllers if we switched away from 'both'. this._rowVirtCtrl = null; this._colVirtCtrl = null; const count = this._effectiveTotalCount(); const isVertical = this.orientation === 'vertical'; const lanes = Math.max(1, isVertical ? this.gridColumns : this.gridRows); const itemSize = isVertical ? this.laneHeight : this._columnWidthPx; if (!this._virtCtrl) { this._virtCtrl = new VirtualizerController(this, { count, getScrollElement: () => this.shadowRoot?.querySelector('.detail__selection-grid') as HTMLElement | null, estimateSize: () => itemSize, horizontal: !isVertical, lanes, overscan: this.overscan, }); return; } const v = this._virtCtrl.getVirtualizer(); const prev = v.options; const orientationChanged = !!prev.horizontal !== !isVertical; if ( prev.count !== count || prev.lanes !== lanes || prev.overscan !== this.overscan || orientationChanged ) { v.setOptions({ ...prev, count, lanes, overscan: this.overscan, horizontal: !isVertical, estimateSize: () => itemSize, }); v.measure(); } } private _syncBothVirtualizers() { const totalItems = this._filteredItems.length; const cols = Math.max(1, this.matrixColumns); const rows = Math.max(1, Math.ceil(totalItems / cols)); const columnWidth = this._columnWidthPx; const rowHeight = this.laneHeight; const getScrollEl = () => this.shadowRoot?.querySelector('.detail__selection-grid') as HTMLElement | null; if (!this._rowVirtCtrl) { this._rowVirtCtrl = new VirtualizerController(this, { count: rows, getScrollElement: getScrollEl, estimateSize: () => rowHeight, horizontal: false, overscan: this.overscan, }); } else { const rv = this._rowVirtCtrl.getVirtualizer(); const prev = rv.options; if (prev.count !== rows || prev.overscan !== this.overscan) { rv.setOptions({ ...prev, count: rows, overscan: this.overscan, estimateSize: () => rowHeight, }); rv.measure(); } } if (!this._colVirtCtrl) { this._colVirtCtrl = new VirtualizerController(this, { count: cols, getScrollElement: getScrollEl, estimateSize: () => columnWidth, horizontal: true, overscan: this.overscan, }); } else { const cv = this._colVirtCtrl.getVirtualizer(); const prev = cv.options; if (prev.count !== cols || prev.overscan !== this.overscan) { cv.setOptions({ ...prev, count: cols, overscan: this.overscan, estimateSize: () => columnWidth, }); cv.measure(); } } } private _setupGridResizeObserver() { if (!this._isSelectionVariant) return; if (typeof ResizeObserver === 'undefined') return; const grid = this.shadowRoot?.querySelector('.detail__selection-grid') as HTMLElement | null; if (!grid) return; if (this._gridResizeObserver) return; this._gridResizeObserver = new ResizeObserver(() => this._scheduleTooltipOverflowCheck()); this._gridResizeObserver.observe(grid); } private _scheduleTooltipOverflowCheck() { if (typeof requestAnimationFrame === 'undefined') return; requestAnimationFrame(() => this._updateTooltipOverflowStates()); } private async _updateTooltipOverflowStates() { const tooltips = this.shadowRoot?.querySelectorAll( '.detail__selection-tooltip' ) as NodeListOf | undefined; if (!tooltips) return; for (const tip of tooltips) { const cb = tip.querySelector('nile-checkbox') as HTMLElement & { updateComplete?: Promise } | null; if (!cb) continue; if (cb.updateComplete) await cb.updateComplete; const labelEl = cb.shadowRoot?.querySelector('[part="label"]') as HTMLElement | null; const overflowing = !!labelEl && labelEl.scrollWidth > labelEl.clientWidth + 1; if (overflowing) tip.removeAttribute('disabled'); else tip.setAttribute('disabled', ''); } } @watch('selected', { waitUntilFirstUpdate: false }) _onSelectedPropertyChange() { this._syncSelectedFromProperty(); } @watch('config', { waitUntilFirstUpdate: false }) _onConfigChange() { this._applyConfig(); } private _applyConfig() { const c = this.config; if (!c) return; if (c.variant !== undefined) this.variant = c.variant; if (c.heading !== undefined) this.heading = c.heading; if (c.description !== undefined) this.description = c.description; if (c.itemsLabel !== undefined) this.itemsLabel = c.itemsLabel; if (c.open !== undefined) this.open = c.open; if (c.disabled !== undefined) this.disabled = c.disabled; if (c.items !== undefined) this.items = c.items; if (c.selected !== undefined) this.selected = c.selected; if (c.renderItemConfig !== undefined) this.renderItemConfig = c.renderItemConfig; if (c.allowHtmlLabel !== undefined) this.allowHtmlLabel = c.allowHtmlLabel; if (c.layout) { const l = c.layout; if (l.orientation !== undefined) this.orientation = l.orientation; if (l.gridRows !== undefined) this.gridRows = l.gridRows; if (l.gridColumns !== undefined) this.gridColumns = l.gridColumns; if (l.matrixColumns !== undefined) this.matrixColumns = l.matrixColumns; if (l.minColumnWidth !== undefined) this.minColumnWidth = l.minColumnWidth; if (l.laneHeight !== undefined) this.laneHeight = l.laneHeight; if (l.maxHeight !== undefined) this.maxHeight = l.maxHeight; if (l.expandIconPlacement !== undefined) this.expandIconPlacement = l.expandIconPlacement; } if (c.search) { if (c.search.placeholder !== undefined) this.searchPlaceholder = c.search.placeholder; if (c.search.disableLocal !== undefined) this.disableLocalSearch = c.search.disableLocal; } if (c.virtualization) { if (c.virtualization.enabled !== undefined) this.virtualize = c.virtualization.enabled; if (c.virtualization.threshold !== undefined) this.virtualizeThreshold = c.virtualization.threshold; if (c.virtualization.overscan !== undefined) this.overscan = c.virtualization.overscan; } if (c.pagination) { if (c.pagination.totalCount !== undefined) this.totalCount = c.pagination.totalCount; if (c.pagination.pageSize !== undefined) this.pageSize = c.pagination.pageSize; if (c.pagination.fetchPage !== undefined) this.fetchPage = c.pagination.fetchPage; if (c.pagination.placeholderLabel !== undefined) this.placeholderLabel = c.pagination.placeholderLabel; } if (c.labels) { if (c.labels.restore !== undefined) this.restoreLabel = c.labels.restore; if (c.labels.clear !== undefined) this.clearLabel = c.labels.clear; if (c.labels.selectedOnly !== undefined) this.selectedOnlyLabel = c.labels.selectedOnly; if (c.labels.showAll !== undefined) this.showAllLabel = c.labels.showAll; } if (c.showSelectedToggle !== undefined) this.showSelectedToggle = c.showSelectedToggle; if (c.preview !== undefined) this.preview = c.preview; if (c.previewPercentage !== undefined) this.previewPercentage = c.previewPercentage; } private _syncSelectedFromProperty() { this._selectedSet = new Set(this.selected || []); } private _getItemValue(item: SelectionItem): string { const rc = this.renderItemConfig; if (rc?.getValue) return String(rc.getValue(item)); if (typeof item === 'string') return item; return String((item as NileDetailSelectionItem).value ?? ''); } private _getItemLabel(item: SelectionItem): string { const rc = this.renderItemConfig; if (rc?.getDisplayText) return rc.getDisplayText(item); if (typeof item === 'string') return item; return (item as NileDetailSelectionItem).label ?? this._getItemValue(item); } private _getItemSearchText(item: SelectionItem): string { const rc = this.renderItemConfig; if (rc?.getSearchText) return rc.getSearchText(item); return this._getItemLabel(item); } private _getItemDescription(item: SelectionItem): string { const rc = this.renderItemConfig; if (rc?.getDescription) return rc.getDescription(item) ?? ''; if (typeof item === 'string') return ''; return (item as NileDetailSelectionItem).description ?? ''; } private _getItemPrefix(item: SelectionItem): string { const rc = this.renderItemConfig; if (rc?.getPrefix) return rc.getPrefix(item) ?? ''; if (typeof item === 'string') return ''; return (item as NileDetailSelectionItem).prefix ?? ''; } private _getItemSuffix(item: SelectionItem): string { const rc = this.renderItemConfig; if (rc?.getSuffix) return rc.getSuffix(item) ?? ''; if (typeof item === 'string') return ''; return (item as NileDetailSelectionItem).suffix ?? ''; } private _getItemDisabled(item: SelectionItem): boolean { const rc = this.renderItemConfig; if (rc?.getDisabled) return !!rc.getDisabled(item); if (typeof item === 'string') return false; return !!(item as NileDetailSelectionItem).disabled; } private _getItemClassName(item: SelectionItem): string { const rc = this.renderItemConfig; if (rc?.getClassName) return rc.getClassName(item) ?? ''; if (typeof item === 'string') return ''; return (item as NileDetailSelectionItem).className ?? ''; } private get _filteredItems(): SelectionItem[] { let source: SelectionItem[] = this.items; if (this._showSelectedOnly) { source = source.filter(item => this._selectedSet.has(this._getItemValue(item))); } if (this.disableLocalSearch) return source; const q = this._searchTerm.trim().toLowerCase(); if (!q) return source; return source.filter(item => this._getItemSearchText(item).toLowerCase().includes(q) ); } private _onToggleSelectedOnly(event: Event) { event.preventDefault(); const next = !this._showSelectedOnly; if (next && this._selectedSet.size === 0) return; this._showSelectedOnly = next; } private _selectableValues(): string[] { const source: SelectionItem[] = this._isInfiniteMode ? (Array.from(this._pageCache.values()).flat() as SelectionItem[]) : this.items; return source .filter(i => !this._getItemDisabled(i)) .map(i => this._getItemValue(i)); } private get _allChecked(): boolean { const selectable = this._selectableValues(); if (selectable.length === 0) return false; return selectable.every(v => this._selectedSet.has(v)); } private get _indeterminate(): boolean { const selectable = this._selectableValues(); const sel = selectable.filter(v => this._selectedSet.has(v)).length; return sel > 0 && sel < selectable.length; } private get _countLabel(): string { const total = this._isInfiniteMode ? (this.totalCount ?? 0) : this.items.length; return `${this._selectedSet.size} of ${total} ${this.itemsLabel} selected`; } private _emitSelectionChange() { this.selected = [...this._selectedSet]; this.dispatchEvent( new CustomEvent('nile-change', { detail: { selected: [...this._selectedSet] }, bubbles: true, composed: true, }) ); } private _onItemChange(value: string, disabled: boolean, event: Event) { if (disabled) { event.preventDefault(); return; } const target = event.target as HTMLInputElement; const next = new Set(this._selectedSet); if (target.checked) next.add(value); else next.delete(value); this._selectedSet = next; this._emitSelectionChange(); } private _onSelectAllChange(event: Event) { const target = event.target as HTMLInputElement; const selectable = this._selectableValues(); const next = new Set(this._selectedSet); if (target.checked) selectable.forEach(v => next.add(v)); else selectable.forEach(v => next.delete(v)); this._selectedSet = next; this._emitSelectionChange(); } private _emitSearch(value: string) { this.dispatchEvent( new CustomEvent('nile-search', { detail: { value }, bubbles: true, composed: true, }) ); } private _onSearchInput(event: Event) { const target = event.target as HTMLInputElement; const value = target.value ?? ''; this._searchTerm = value; if (this.disableLocalSearch) this._emitSearch(value); } private _onSearchClear() { this._searchTerm = ''; if (this.disableLocalSearch) this._emitSearch(''); } private _onRestore(event: Event) { event.preventDefault(); const source = this._restoreDefaults ?? this._selectableValues(); this._selectedSet = new Set(source); this._emitSelectionChange(); } private _onClear(event: Event) { event.preventDefault(); this._selectedSet = new Set(); this._showSelectedOnly = false; this._emitSelectionChange(); } private _handleDefaultSlotChange(event: Event) { const slot = event.target as HTMLSlotElement; const nodes = slot.assignedNodes({ flatten: true }); this._hasSlottedContent = nodes.some(node => { if (node.nodeType === Node.ELEMENT_NODE) return true; if (node.nodeType === Node.TEXT_NODE) return !!(node.textContent && node.textContent.trim()); return false; }); if (this._isPreviewActive) { this._applyPreviewHeight(); } } private _stopHeaderToggle(event: Event) { event.stopPropagation(); } private _handleSummaryClick(event: Event) { event.preventDefault(); handleSummaryClick(this); } private _handleSummaryKeyDown(event: KeyboardEvent) { handleSummaryKeyDown(event, this); } @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange() { if (this.open) { this._detailOpen = true; await animateShow(this); } else { await animateHide(this); // In preview mode the details element stays open so the preview remains visible. this._detailOpen = this._previewEnabled; } } @watch(['preview', 'previewPercentage'], { waitUntilFirstUpdate: true }) handlePreviewChange() { if (this.open) { return; } if (this._previewEnabled) { this._detailOpen = true; this._applyPreviewHeight(); } else { this.body.hidden = true; this.body.style.height = '0'; this._detailOpen = false; } } /** Measures the content and clamps the body to the configured preview height. */ private async _applyPreviewHeight() { await this.updateComplete; if (!this._isPreviewActive || !this.body) { return; } this.body.hidden = false; this.body.style.height = `${getPreviewHeight(this)}px`; } private _handlePreviewClick() { if (this._isPreviewActive && !this.disabled) { showDetail(this); } } private _handlePreviewKeyDown(event: KeyboardEvent) { if (!this._isPreviewActive || this.disabled) { return; } if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); showDetail(this); } } async show() { return showDetail(this); } async hide() { return hideDetail(this); } private get _summaryLabel(): string { return [this.heading, this.description].filter(Boolean).join(', '); } private _renderSelectionLabel(): TemplateResult { return html`
${this.heading} ${this._countLabel}
`; } private _renderSelectionBody(): TemplateResult { const virtualized = this._isVirtualized; const isVertical = this.orientation === 'vertical'; const isBoth = this.orientation === 'both'; const laneHeightPx = this.laneHeight; const columnWidth = this._columnWidthPx; let gridStyle = ''; if (virtualized) { if (isBoth) { gridStyle = `max-height: ${this.maxHeight}; height: ${this.maxHeight};`; } else if (isVertical) { gridStyle = `max-height: ${this.maxHeight}; height: ${this.maxHeight};`; } else { const lanes = Math.max(1, this.gridRows); gridStyle = `height: ${lanes * laneHeightPx}px;`; } } else if (isVertical) { const cols = Math.max(1, this.gridColumns); gridStyle = `grid-template-columns: repeat(${cols}, minmax(0, 1fr)); grid-auto-flow: row; max-height: ${this.maxHeight}; overflow-y: auto; overflow-x: hidden;`; } else { const lanes = Math.max(1, this.gridRows); gridStyle = `grid-template-rows: repeat(${lanes}, auto); grid-auto-columns: minmax(${this.minColumnWidth}, 1fr);`; } return html`
${this.showSelectedToggle ? html` ${this._showSelectedOnly ? this.showAllLabel : this.selectedOnlyLabel} ` : nothing} ${this.restoreLabel} ${this.clearLabel}
${virtualized ? (isBoth ? this._renderBothVirtualGrid(laneHeightPx, columnWidth) : this._renderVirtualGrid(laneHeightPx, columnWidth, isVertical)) : this._renderStaticGrid()}
`; } private _renderItemContent(item: SelectionItem): TemplateResult { const label = this._getItemLabel(item); const description = this._getItemDescription(item); const prefix = this._getItemPrefix(item); const suffix = this._getItemSuffix(item); const allowHtml = this.allowHtmlLabel; return html` ${prefix ? html`${allowHtml ? unsafeHTML(prefix) : prefix}` : ''} ${allowHtml ? unsafeHTML(label) : label} ${description ? html`${allowHtml ? unsafeHTML(description) : description}` : ''} ${suffix ? html`${allowHtml ? unsafeHTML(suffix) : suffix}` : ''} `; } private _renderItemTooltip(item: SelectionItem, opts: { positionStyle?: string; extraClasses?: string } = {}): TemplateResult { const value = this._getItemValue(item); const label = this._getItemLabel(item); const description = this._getItemDescription(item); const isDisabled = this._getItemDisabled(item); const className = this._getItemClassName(item); const tooltipContent = description ? `${label} — ${description}` : label; const tooltipClass = `detail__selection-tooltip${opts.extraClasses ? ' ' + opts.extraClasses : ''}`; const checkboxClass = classMap({ 'detail__selection-checkbox': true, 'detail__selection-checkbox--disabled': isDisabled, [className]: !!className, }); return html` this._onItemChange(value, isDisabled, e)} >${this._renderItemContent(item)} `; } private _renderStaticGrid(): TemplateResult { return html`${this._filteredItems.map(item => this._renderItemTooltip(item))}`; } private _renderBothVirtualGrid(_laneHeightPx: number, _columnWidthPx: number): TemplateResult { const rowV = this._rowVirtCtrl?.getVirtualizer(); const colV = this._colVirtCtrl?.getVirtualizer(); const rowTotal = rowV?.getTotalSize() ?? 0; const colTotal = colV?.getTotalSize() ?? 0; const rowItems = rowV?.getVirtualItems() ?? []; const colItems = colV?.getVirtualItems() ?? []; const cols = Math.max(1, this.matrixColumns); const total = this._filteredItems.length; return html`
${rowItems.flatMap(rvi => colItems.map(cvi => { const idx = rvi.index * cols + cvi.index; if (idx >= total) return null; const item = this._filteredItems[idx]; if (item === undefined) return null; const positionStyle = `position: absolute; top: ${rvi.start}px; left: ${cvi.start}px; width: ${cvi.size}px; height: ${rvi.size}px;`; return this._renderItemTooltip(item, { positionStyle, extraClasses: 'detail__selection-tooltip--virtual', }); }) )}
`; } private _renderVirtualGrid( laneHeightPx: number, _columnWidthPx: number, isVertical: boolean ): TemplateResult { const v = this._virtCtrl?.getVirtualizer(); const totalSize = v?.getTotalSize() ?? 0; const virtualItems = v?.getVirtualItems() ?? []; const lanes = Math.max(1, isVertical ? this.gridColumns : this.gridRows); if (this._isInfiniteMode && virtualItems.length > 0) { const first = virtualItems[0].index; const last = virtualItems[virtualItems.length - 1].index; this._scheduleFetchForRange(first, last); } const trackStyle = isVertical ? `width: 100%; height: ${totalSize}px; position: relative;` : `width: ${totalSize}px; height: 100%; position: relative;`; return html`
${virtualItems.map(vi => { const laneIdx = vi.lane ?? 0; const positionStyle = isVertical ? `position: absolute; top: ${vi.start}px; left: calc(${laneIdx} * (100% / ${lanes})); width: calc(100% / ${lanes}); height: ${vi.size}px;` : `position: absolute; top: ${laneIdx * laneHeightPx}px; left: ${vi.start}px; width: ${vi.size}px; height: ${laneHeightPx}px;`; const item = this._getItemAt(vi.index); if (item === undefined) { return this._renderPlaceholder(positionStyle); } return this._renderItemTooltip(item, { positionStyle, extraClasses: 'detail__selection-tooltip--virtual', }); })}
`; } private _renderPlaceholder(positionStyle: string): TemplateResult { return html`
${this.placeholderLabel}
`; } render(): TemplateResult { const isSelection = this._isSelectionVariant; return html`
${isSelection ? this._renderSelectionLabel() : html` ${this.heading} ${this.description ? html`${this.description}` : ''} `}
${isSelection ? html`
${this._renderSelectionBody()}
` : ''}
`; } } export default NileDetail; declare global { interface HTMLElementTagNameMap { 'nile-detail': NileDetail; } }