import { html, css, TemplateResult, CSSResultArray } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import NileElement from '../../internal/nile-element'; export interface SuggestItem { key: string; label: string; /** Secondary text shown right-aligned (e.g. slash-command hint). */ hint?: string; } /** * Keyboard-navigable suggestion popup shared by mentions and slash commands. * The host positions it, feeds `items`, and drives navigation through * next()/prev()/selectCurrent(); picking an item emits * `wysiwyg-suggest-select` with the item. */ @customElement('nile-wysiwyg-suggest-list') export class NileWysiwygSuggestList extends NileElement { @property({ type: Boolean, reflect: true }) open = false; @property({ attribute: false }) items: SuggestItem[] = []; @state() private activeIndex = 0; public static get styles(): CSSResultArray { return [ css` :host { font-family: var( --nile-font-family-sans-serif, var(--ng-font-family-body, system-ui, -apple-system, sans-serif) ); position: absolute; z-index: 25; display: none; } :host([open]) { display: block; } .list { min-width: 200px; /* Wide enough for long field names (e.g. source.JobAssignment…); longer labels wrap rather than scrolling sideways. */ max-width: min(480px, calc(100vw - 32px)); max-height: 240px; overflow-y: auto; overflow-x: hidden; padding: 4px; background: var(--nile-wysiwyg-popover-bg, var(--nile-colors-white-base, var(--ng-colors-bg-primary))); border: 1px solid var(--nile-colors-neutral-400, var(--ng-colors-border-primary)); border-radius: 8px; box-shadow: var(--nile-wysiwyg-popover-shadow, 0 4px 12px rgba(16, 24, 40, 0.12)); font-size: 13px; } .item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 8px; border-radius: 5px; cursor: pointer; color: var(--nile-colors-neutral-700, var(--ng-colors-text-secondary-700)); } .label { min-width: 0; /* Wrap a long label instead of truncating it: names that differ only in their last word (…JobFunction / …JobFunctionCode) must stay tellable apart. */ overflow-wrap: anywhere; } .item.active { background: var(--nile-colors-primary-50, var(--ng-componentcolors-utility-blue-50)); color: var(--nile-colors-primary-700, var(--ng-componentcolors-utility-blue-700)); } .hint { flex-shrink: 0; font-size: 11px; color: var(--nile-colors-neutral-500, var(--ng-colors-text-quaternary-500)); } .empty { padding: 6px 8px; color: var(--nile-colors-neutral-500, var(--ng-colors-text-quaternary-500)); } `, ]; } protected willUpdate(): void { if (this.activeIndex >= this.items.length) this.activeIndex = 0; } protected updated(changed: Map): void { // Keep the highlighted item visible as the user arrows through a list // that overflows the popup's max-height. `nearest` is a no-op when the // item is already visible (e.g. after a hover), so it never fights the mouse. if (changed.has('activeIndex') || changed.has('items')) { const active = this.renderRoot.querySelector('.item.active'); (active as HTMLElement | null)?.scrollIntoView({ block: 'nearest' }); } } public next(): void { if (this.items.length) this.activeIndex = (this.activeIndex + 1) % this.items.length; } public prev(): void { if (this.items.length) { this.activeIndex = (this.activeIndex - 1 + this.items.length) % this.items.length; } } public resetActive(): void { this.activeIndex = 0; } public selectCurrent(): boolean { const item = this.items[this.activeIndex]; if (!item) return false; this.emit('wysiwyg-suggest-select', { item }); return true; } public render(): TemplateResult { return html`
e.preventDefault()}> ${this.items.length ? this.items.map( (item, index) => html`
{ this.activeIndex = index; }} @click=${() => this.selectCurrent()} > ${item.label} ${item.hint ? html`${item.hint}` : ''}
` ) : html`
No results
`}
`; } } declare global { interface HTMLElementTagNameMap { 'nile-wysiwyg-suggest-list': NileWysiwygSuggestList; } }