/* eslint-disable ts/no-this-alias */ /* eslint-disable style/max-statements-per-line -- compact `if (x) { y; }` is intentional throughout this file */ /* * Pure-TSX — no jQuery, no select2. See MIGRATION.md for the * design contract, prop reference, and async/virtualization usage. */ import type { VNode } from 'vue'; import type { DropdownButtonItemArgs } from '../dropdown-button/dropdown-button-item'; import type { FormItemWrapperArgs, MarginType } from '../form/form-item-wrapper'; import Sortable from 'sortablejs'; import { Prop, toNative } from 'vue-facing-decorator'; import { globalState } from '../../app/global-state'; import PowerduckState from '../../app/powerduck-state'; import TsxComponent, { Component } from '../../app/vuetsx'; import { capitalize } from '../../common/extensions/string-extensions'; import { MobileModeConfig } from '../../common/mobile-mode-config'; import { isNullOrEmpty } from '../../common/utils/is-null-or-empty'; import { PortalUtils } from '../../common/utils/utils'; import VueUtils from '../../common/utils/vue-utils'; import FormItemWrapper from '../form/form-item-wrapper'; import CheckBox, { CheckBoxSize, CheckBoxSkin } from '../input/checkbox'; import LoadingIndicator from '../loading-indicator'; import Modal, { ModalMobileMode, ModalSize } from '../modal/modal'; import ModalBody from '../modal/modal-body'; import './css/dropdown.css'; // ───────────────────────────────────────────────────────────────────────────── // Public enums / shapes // ───────────────────────────────────────────────────────────────────────────── export enum MultiselectMode { Tags = 0, Checkboxes = 1, } export enum MultiSelectExclusivity { Inclusive = 0, Exclusive = 1, } export class DropdownOptionGroup { isOptGroup: true; text: string; children: Array | Array; } export interface DropdownListOption { id: number | string; text: string; dataRow?: any; } export interface DropdownListButtonClickedArgs { item: any; } export interface DropdownListButton { iconCss: string; cssClass?: string; clicked: (e: { item: any }) => void; } export interface DropdownListTagAddedArgs { closeSelection: boolean; tagArr: string[]; } export interface DropdownTrailingButtonArgs { cssClass: string; icon: string; text: string; clicked: (row: any) => void; } /** * Args passed to an async loader. The loader is responsible for including any * rows whose IDs appear in `selectedIds` in the response — see MIGRATION.md. */ export interface AsyncLoaderArgs { selectedIds: string[]; searchQuery: string; limit: number; page: number; } export interface AsyncLoaderResult { items: any[]; hasMore: boolean; } export type AsyncLoaderFn = (args: AsyncLoaderArgs) => Promise; /** * Internal descriptor type used by the virtualized renderer. Each item the * panel can show is described once, then either fully rendered (non-virt) or * only rendered if inside the visible window (virtualized). */ type ResultRowDescriptor = | { kind: 'group'; item: DropdownDisplayArgs } | { kind: 'option'; item: DropdownDisplayArgs; flatIndex: number } | { kind: 'add'; flatIndex: number } | { kind: 'loading' } | { kind: 'error'; msg: string } | { kind: 'no-results' }; type RowToString = (row: any) => string; export interface DropdownDisplayArgs { id: string; text: string; dataRow: any; children?: DropdownDisplayArgs[]; isOptionGroup?: boolean; } export type CustomRenderOriginator = 'default' | 'mobile'; interface DropdownListArgs extends FormItemWrapperArgs { placeholder?: string; blocked?: boolean; disabled?: boolean; disableSearch?: boolean; containerCssClass?: string; options: Array | Array | Array; selected: string | any | Array | Array; displayMode?: 'default' | 'inline'; displayMember?: string | RowToString; valueMember?: string | RowToString; multiselect?: boolean; multiselectMode?: MultiselectMode; closeOnSelect?: boolean; name?: string; autocomplete?: string; mobileShortMode?: boolean; changedEventDelay?: number; /** * Legacy select2 width-auto knob retained for back-compat — the new * component sizes its panel to the trigger width anyway. Accepted as a * prop but ignored. */ dropdownAutoWidth?: boolean; // Tags mode: free-text add, sortable, per-chip action buttons tags?: boolean; tagsSortable?: boolean; tagsShouldPrependContent?: boolean; tagsButtons?: (item: any) => DropdownListButton[]; tagsAdded?: (e: DropdownListTagAddedArgs) => void; tagsNewPlaceLast?: boolean; trailingButton?: DropdownTrailingButtonArgs; allowExclusiveSearch?: boolean; changed: (newValue: any, exclusivity?: MultiSelectExclusivity) => void; noResultsFound?: () => void; customRenderOption?: (h: any, state: DropdownDisplayArgs, originator?: CustomRenderOriginator) => any; customRenderSelectionResult?: (h: any, state: DropdownDisplayArgs, originator?: CustomRenderOriginator) => any; customIdProperty?: string; /** * When set, the dropdown enters async mode: it ignores `options` and asks * the loader for paged data. The loader is invoked on open (page 0 with * selectedIds), after a 300ms debounce on search-text change, and when the * user scrolls within 100px of the bottom of the option list. */ asyncLoader?: AsyncLoaderFn; /** Page size passed to the async loader. Defaults to 50. */ asyncPageSize?: number; /** * When true, the option list virtualizes (only renders window-visible * rows). Row height is measured from the first rendered row. Enable for * lists that may exceed ~300 items. */ virtualizeOptions?: boolean; } const NORMALIZE_RE = /\p{Diacritic}/gu; const normalize = (text: string): string => { if (text == null) { return ''; } return text.toString().toLowerCase().normalize('NFD').replace(NORMALIZE_RE, '').trim(); }; @Component class DropdownListComponent extends TsxComponent implements DropdownListArgs { // ── FormItemWrapper passthrough props ────────────────────────────────────── @Prop() label!: string | VNode; @Prop() labelButtons!: DropdownButtonItemArgs[]; @Prop() blocked!: boolean; @Prop() mandatory!: boolean; @Prop() hint: string; @Prop() subtitle!: string; @Prop() cssClass!: string; @Prop() appendIcon: string; @Prop() prependIcon: string; @Prop() appendIconClicked: () => void; @Prop() prependIconClicked: () => void; @Prop() appendClicked: () => void; @Prop() prependClicked: () => void; @Prop() showClearValueButton!: boolean; @Prop() maxWidth?: number; @Prop() marginType?: MarginType; @Prop() wrap!: boolean; // ── Dropdown-specific props ─────────────────────────────────────────────── @Prop() name!: string; @Prop() autocomplete!: string; @Prop() options!: Array | Array; @Prop() displayMember!: (row: any) => string | string; @Prop() valueMember!: (row: any) => string | string; @Prop() selected!: string | any | Array | Array; @Prop() displayMode?: 'default' | 'inline'; @Prop() changed!: (newValue: any, exclusivity?: MultiSelectExclusivity) => void; @Prop() multiselect!: boolean; @Prop() multiselectMode!: MultiselectMode; @Prop() closeOnSelect!: boolean; @Prop() placeholder!: string; @Prop() disabled?: boolean; @Prop() disableSearch?: boolean; @Prop() containerCssClass?: string; @Prop() mobileShortMode!: boolean; @Prop() dropdownAutoWidth?: boolean; @Prop() changedEventDelay!: number; @Prop() noResultsFound: () => void; @Prop() customRenderOption?: (h: any, state: DropdownDisplayArgs, originator?: CustomRenderOriginator) => any; @Prop() customRenderSelectionResult?: (h: any, state: DropdownDisplayArgs, originator?: CustomRenderOriginator) => any; @Prop() customIdProperty?: string; @Prop() asyncLoader?: AsyncLoaderFn; @Prop() asyncPageSize?: number; @Prop() virtualizeOptions?: boolean; // ── Tags-mode props ────────────────────────────────────────────────────── @Prop() tags!: boolean; @Prop() tagsSortable?: boolean; @Prop() tagsShouldPrependContent?: boolean; @Prop() tagsButtons?: (item: any) => DropdownListButton[]; @Prop() tagsAdded?: (e: DropdownListTagAddedArgs) => void; @Prop() tagsNewPlaceLast?: boolean; @Prop() trailingButton?: DropdownTrailingButtonArgs; @Prop() allowExclusiveSearch?: boolean; // ── Reactive state ──────────────────────────────────────────────────────── isOpen: boolean = false; searchQuery: string = ''; focusedFlatIndex: number = -1; // In multi-select we accumulate pending selection in the panel and commit // on "Done". For single-select we commit on item click. pendingMulti: string[] | null = null; // In tags mode the pending list is committed on every change (chip add/remove), // matching select2's "tags" behavior. tagsPending: any[] | null = null; currentExclusivity: MultiSelectExclusivity = MultiSelectExclusivity.Inclusive; // Viewport-aware drop direction. Default is downward (top:100%); set to true // only when there is not enough room below the trigger AND more room above, // so bottom-of-viewport dropdowns open upward instead of below the fold. openUp: boolean = false; private rafScrollId: number | null = null; private outsideMouseDownHandler: ((e: MouseEvent) => void) | null = null; private sortableInstance: any = null; private uuid: string = `pd-dd-${PortalUtils.randomString(7)}`; // ── Async-loader state ──────────────────────────────────────────────────── /** Items currently visible in the panel for the active (search, page) tuple. */ asyncList: any[] = []; /** * Every item we've ever seen from the loader, keyed by ID. Used to resolve * chip labels for selected IDs that may not be in the current page. */ asyncCache: Map = new Map(); asyncPage: number = 0; asyncHasMore: boolean = false; asyncLoading: boolean = false; asyncError: string | null = null; private asyncDebounceTimer: any = null; private asyncRequestSeq: number = 0; // ── Virtualization state ────────────────────────────────────────────────── virtualScrollTop: number = 0; virtualViewportHeight: number = 0; /** * Row height in px. Defaults to 30 (matches the base option styling) and * is replaced after the first rendered row is measured. The estimate is * "good enough" for first-paint; the measurement narrows the gap on the * second paint with no visible jump for uniform-height rows. */ measuredRowHeight: number = 30; private virtualScrollHandlerBound: ((e: Event) => void) | null = null; private rowMeasureRaf: number | null = null; // ── Computed-ish getters ────────────────────────────────────────────────── get isInlineMode(): boolean { return this.displayMode === 'inline'; } get useModalMode(): boolean { // Match legacy: mobile + not in iframe + not tags-mode (chip multi-add // flows badly inside a bottom-sheet) + not inline. if (this.isInlineMode) { return false; } if (this.tags === true) { return false; } if (PortalUtils.isInIframe()) { return false; } return MobileModeConfig.shouldDisplayInModal(); } get isMultiCheckboxes(): boolean { return this.multiselect === true && this.multiselectMode === MultiselectMode.Checkboxes; } get useDoneButton(): boolean { // Bottom Done/Cancel footer — used by the default multi-chip mode. // Checkboxes mode has its own OK button at the TOP of the panel // (`useOkHeader`); inline and tags modes commit immediately. if (this.isInlineMode) { return false; } if (this.isMultiCheckboxes) { return false; } if (this.tags === true) { return false; } return this.multiselect === true; } get useOkHeader(): boolean { // Checkboxes mode mirrors select2's `s2-multi-cb` plugin: a single OK // button at the top of the panel commits the pending session. Closing // any other way (Escape / click outside) discards. Same pending/commit // semantics as `useDoneButton` — just a different placement. return this.isMultiCheckboxes && !this.isInlineMode; } private get okButtonLabel(): string { return this.resolveResource('ok', 'OK'); } private get fallbackAllLabel(): string { return this.resolveResource('all', 'All'); } /** * Look up a project resource and fall back to a plain string when the * resource registry returns the unresolved `{{key}}` placeholder * (downstream apps without the i18n bundle loaded). */ private resolveResource(key: string, fallback: string): string { const v = PowerduckState.getResourceValue(key as any) as any; if (typeof v === 'string' && v.length > 0 && !v.startsWith('{{')) { return v; } return fallback; } get useAllowClear(): boolean { // Mirror select2: allowClear defaults to ON when a placeholder is set. // Tags / multi-select have their own remove paths (× per chip), so the // trigger-level × only applies to single-select. if (this.multiselect === true) { return false; } if (this.disabled || this.blocked) { return false; } if (this.placeholder == null || this.placeholder.length === 0) { return false; } return this.getSelectedItems().length > 0; } get effectivePlaceholder(): string { return this.placeholder ?? ''; } get isAsyncMode(): boolean { return typeof this.asyncLoader === 'function'; } get isVirtualized(): boolean { return this.virtualizeOptions === true; } get effectiveAsyncPageSize(): number { return this.asyncPageSize != null && this.asyncPageSize > 0 ? this.asyncPageSize : 50; } // ── Option/data normalization (mirrors legacy getOptions) ───────────────── private getValueFromArr(paramArr: string[], row: any): string | null { for (const key of paramArr) { let val = row[key]; if (val == null) { val = row[(key as any)[capitalize]()]; } if (val != null) { if (PortalUtils.isString(val) && (val as string).length > 0) { return val as string; } if (PortalUtils.isNumber(val)) { return (val as number).toString(); } if (PortalUtils.isFunction(val)) { return (val as any).call(row, row); } } } return null; } private getReflectedRowValue(row: any, isValueMember: boolean): string { const member = isValueMember ? this.valueMember : this.displayMember; if (member == null) { if (isValueMember) { if (this.customIdProperty != null && this.customIdProperty.length > 0) { return this.getValueFromArr([this.customIdProperty], row) ?? ''; } return this.getValueFromArr([ 'id', 'uuid', ], row) ?? ''; } return this.getValueFromArr([ 'name', 'text', 'identifier', ], row) ?? ''; } if (PortalUtils.isString(member)) { return row[member as any]; } if (PortalUtils.isFunction(member)) { return (member as any).call(row, row); } return String(row[member as any]); } private isOptionGroupBinding(): boolean { const firstOpt = this.options?.[0] as any; return firstOpt != null && firstOpt.isOptGroup === true; } getOptions(preserveOptGroups: boolean = true): DropdownDisplayArgs[] { if (this.isAsyncMode) { // In async mode the server is the authority — `options` prop is ignored. // preserveOptGroups is moot here since async results are a flat list. const _unused = preserveOptGroups; void _unused; return this.asyncList.map(row => this.rowToDisplay(row)); } const opts = this.options as any; const retVal: DropdownDisplayArgs[] = []; if (opts == null || opts.length === 0) { return retVal; } const firstItem = opts[0]; if (PortalUtils.isString(firstItem)) { for (const item of opts) { retVal.push({ id: item, text: item, dataRow: item }); } return retVal; } if (PortalUtils.isNumber(firstItem)) { for (const item of opts) { retVal.push({ id: String(item), text: String(item), dataRow: item }); } return retVal; } if ((firstItem as DropdownOptionGroup).isOptGroup === true) { (opts as DropdownOptionGroup[]).forEach((optGroup) => { const groupItems: DropdownDisplayArgs[] = optGroup.children.map((item: any) => ({ id: this.getReflectedRowValue(item, true), text: this.getReflectedRowValue(item, false), dataRow: item, })); if (preserveOptGroups) { retVal.push({ id: `__group__:${optGroup.text}`, text: optGroup.text, dataRow: optGroup, children: groupItems, isOptionGroup: true, }); } else { // Flatten — used for keyboard-nav math retVal.push(...groupItems); } }); return retVal; } for (const item of opts) { retVal.push({ id: this.getReflectedRowValue(item, true), text: this.getReflectedRowValue(item, false), dataRow: item, }); } return retVal; } getSelectedIds(): string[] { if (this.selected == null) { return []; } const arr = PortalUtils.isArray(this.selected) ? this.selected : [this.selected]; return (arr as any[]).map((item) => { if (PortalUtils.isString(item) || PortalUtils.isNumber(item)) { return String(item); } return this.getReflectedRowValue(item, true); }); } private get effectiveSelectedIds(): string[] { // During an open multi-select session, the pending set is authoritative. if (this.multiselect === true && this.pendingMulti != null) { return this.pendingMulti; } return this.getSelectedIds(); } getSelectedItems(): DropdownDisplayArgs[] { const selIds = this.effectiveSelectedIds; if (selIds.length === 0) { return []; } if (this.isAsyncMode) { // Use the asyncCache (every row we've ever seen) to resolve chip // labels — even when the current page doesn't contain a given // selection (e.g. it was loaded for a different search). return selIds.map((id) => { const row = this.asyncCache.get(id); if (row != null) { return this.rowToDisplay(row); } // Fallback: render the raw ID until the loader returns it. // Loader-author contract (per AsyncLoaderArgs.selectedIds): they // should include this row in the first response. return { id, text: id, dataRow: row ?? id }; }); } const flat = this.getOptions(false); const byId = new Map(flat.map(o => [ o.id, o, ])); return selIds.map(id => byId.get(id)).filter(Boolean) as DropdownDisplayArgs[]; } private rowToDisplay(row: any): DropdownDisplayArgs { if (PortalUtils.isString(row) || PortalUtils.isNumber(row)) { return { id: String(row), text: String(row), dataRow: row }; } return { id: this.getReflectedRowValue(row, true), text: this.getReflectedRowValue(row, false), dataRow: row, }; } // ── Search / filter ─────────────────────────────────────────────────────── private filterTree(items: DropdownDisplayArgs[], q: string): DropdownDisplayArgs[] { if (q.length === 0) { return items; } const out: DropdownDisplayArgs[] = []; for (const item of items) { if (item.children?.length) { const filtered = this.filterTree(item.children, q); if (filtered.length > 0) { out.push({ ...item, children: filtered }); } } else { if (normalize(item.text).includes(q)) { out.push(item); } } } return out; } get visibleItems(): DropdownDisplayArgs[] { // Async mode: the server has already filtered; just show what's loaded. if (this.isAsyncMode) { return this.getOptions(true); } const opts = this.getOptions(true); const q = normalize(this.searchQuery); return this.filterTree(opts, q); } get visibleFlat(): DropdownDisplayArgs[] { const flat: DropdownDisplayArgs[] = []; for (const item of this.visibleItems) { if (item.isOptionGroup && item.children) { flat.push(...item.children); } else { flat.push(item); } } // Add the "Add: " virtual entry at the end so keyboard nav can // reach it and Enter on it triggers free-text-tag creation. if (this.canShowAddSuggestion) { flat.push({ id: '__pd_dd_add__', text: this.addSuggestionText, dataRow: null, isOptionGroup: false, // Marker for handleItemClick / toggleItem to route through addFreeTextTag. _isAddSuggestion: true, } as any); } return flat; } get hasVisibleResults(): boolean { return this.visibleFlat.length > 0; } // ── Open / close / commit ───────────────────────────────────────────────── open(): void { if (this.disabled || this.blocked) { return; } if (this.isOpen) { return; } this.searchQuery = ''; this.focusedFlatIndex = -1; // Tags mode commits per click via toggleTagItem (emits `changed` directly). // Initialising pendingMulti here would cause close()'s commit branch to // re-emit the pre-toggle selection and override per-click commits — QA_GO-429 Bug A. if (this.multiselect === true && this.tags !== true) { this.pendingMulti = [...this.getSelectedIds()]; } this.isOpen = true; this.$nextTick(() => { if (this.isAsyncMode) { this.loadAsyncFirstPage(); } // One scroll listener handles both virtualization windowing and // async bottom-of-list pagination. Attached after the panel mounts. if (this.isAsyncMode || this.isVirtualized) { this.attachPanelScrollHandler(); if (this.isVirtualized) { this.scheduleRowMeasurement(); } } if (this.useModalMode) { (this.$refs.pdDdModal as any)?.show({ onHidden: () => { this.handleClose(); }, }); } else { this.attachOutsideHandler(); this.focusSearch(); this.updatePanelDirection(); } }); } close(commit: boolean = true): void { if (!this.isOpen) { return; } if (commit && this.multiselect === true && this.pendingMulti != null) { this.commitMultiPending(); } if (this.useModalMode) { (this.$refs.pdDdModal as any)?.hide(); return; } this.handleClose(); } private handleClose(): void { this.isOpen = false; this.pendingMulti = null; this.searchQuery = ''; this.focusedFlatIndex = -1; this.openUp = false; this.detachOutsideHandler(); this.detachPanelScrollHandler(); if (this.asyncDebounceTimer != null) { clearTimeout(this.asyncDebounceTimer); this.asyncDebounceTimer = null; } // Bump the request seq so any in-flight loader response is discarded. this.asyncRequestSeq++; this.asyncLoading = false; this.asyncError = null; } private commitMultiPending(): void { const ids = this.pendingMulti ?? []; const flat = this.getOptions(false); const byId = new Map(flat.map(o => [ o.id, o, ])); const data = ids.map(id => byId.get(id)?.dataRow).filter(v => v != null); this.changed?.(data, undefined); } private commitSingle(item: DropdownDisplayArgs | null): void { this.changed?.(item?.dataRow ?? null, undefined); } private attachOutsideHandler(): void { this.detachOutsideHandler(); const self = this; this.outsideMouseDownHandler = (e: MouseEvent) => { const root = self.$el as HTMLElement | null; if (root == null) { return; } if (!root.contains(e.target as Node)) { self.close(true); } }; document.addEventListener('mousedown', this.outsideMouseDownHandler); } private detachOutsideHandler(): void { if (this.outsideMouseDownHandler != null) { document.removeEventListener('mousedown', this.outsideMouseDownHandler); this.outsideMouseDownHandler = null; } } private focusSearch(): void { const input = (this.$el as HTMLElement | null)?.querySelector('.pd-dd-search-input'); input?.focus(); } // ── Viewport-aware drop direction ───────────────────────────────────────── // Additive to the default downward panel: measure the trigger against the // viewport and open upward only when there is not enough room below AND more // room above. When there is room below, `openUp` stays false and the panel // renders byte-for-byte as before (`.pd-dd-panel { top: 100% }`). private updatePanelDirection(): void { // Inline mode renders a static panel with no absolute positioning; the // flip must be a no-op there. if (this.isInlineMode) { return; } const root = this.$el as HTMLElement | null; const trigger = root?.querySelector('.pd-dd-trigger'); const panel = root?.querySelector('.pd-dd-panel'); if (trigger == null || panel == null) { return; } const rect = trigger.getBoundingClientRect(); // Clamp to a sensible cap so async/virtualized panels (height 0 at first // measure) still make a stable decision. const panelHeight = Math.min(panel.offsetHeight || 0, 360); const spaceBelow = globalState.innerHeight - rect.bottom; const spaceAbove = rect.top; this.openUp = spaceBelow < panelHeight && spaceAbove > spaceBelow; } // ── Item interaction ────────────────────────────────────────────────────── private toggleItem(item: DropdownDisplayArgs): void { if (this.tags === true) { // Route the virtual "Add: " suggestion through the free-text // add pipeline; everything else toggles selection. if ((item as any)._isAddSuggestion === true) { this.addFreeTextTag(this.searchQuery); return; } // In tags mode each click on an existing option toggles its // presence in the immediately-committed selection. this.toggleTagItem(item); return; } if (this.multiselect !== true) { this.commitSingle(item); if (this.closeOnSelect !== false && !this.isInlineMode) { this.close(false); } return; } // Inline mode commits immediately (no Done/OK button — every toggle is // authoritative). Multi-chip and Checkboxes modes both accumulate into // `pendingMulti` and commit on Done / OK respectively. if (this.isInlineMode) { const currentIds = this.getSelectedIds(); const idx = currentIds.indexOf(item.id); const nextIds = idx >= 0 ? currentIds.filter(id => id !== item.id) : [ ...currentIds, item.id, ]; const flat = this.getOptions(false); const byId = new Map(flat.map(o => [ o.id, o, ])); const data = nextIds.map(id => byId.get(id)?.dataRow).filter(v => v != null); this.changed?.(data, undefined); return; } const ids = this.pendingMulti ?? [...this.getSelectedIds()]; const idx = ids.indexOf(item.id); if (idx >= 0) { ids.splice(idx, 1); } else { ids.push(item.id); } this.pendingMulti = ids; } // ── Tags mode ───────────────────────────────────────────────────────────── private getSelectedTagRows(): any[] { // In tags mode `selected` is always an array of domain rows. if (this.selected == null) { return []; } return PortalUtils.isArray(this.selected) ? (this.selected as any[]) : [this.selected]; } private toggleTagItem(item: DropdownDisplayArgs): void { // Select-by-click semantics in tags mode: add to selection if missing, // remove if already present. Same as a multi-select but committed now. const current = this.getSelectedTagRows(); const existingIdx = current.findIndex((r) => { const id = (r != null && PortalUtils.isString(r) === false && PortalUtils.isNumber(r) === false) ? this.getReflectedRowValue(r, true) : String(r); return id === item.id; }); let next: any[]; if (existingIdx >= 0) { next = current.slice(); next.splice(existingIdx, 1); } else { next = this.tagsNewPlaceLast !== false ? [ ...current, item.dataRow, ] : [ item.dataRow, ...current, ]; } this.changed?.(next, this.currentExclusivity); // Clear search and keep focus in the tag input this.searchQuery = ''; this.focusedFlatIndex = -1; this.$nextTick(() => this.focusTagInput()); } private removeTagAt(index: number): void { const current = this.getSelectedTagRows(); if (index < 0 || index >= current.length) { return; } const next = current.slice(); next.splice(index, 1); this.changed?.(next, this.currentExclusivity); } private addFreeTextTag(raw: string): void { const trimmed = (raw || '').trim(); if (trimmed.length === 0) { return; } // If trimmed already matches an existing option (case-insensitive), select // it instead of adding a duplicate free-text tag. const normQ = normalize(trimmed); const flat = this.getOptions(false); const match = flat.find(o => normalize(o.text) === normQ); if (match != null) { this.toggleTagItem(match); return; } const eventArgs: DropdownListTagAddedArgs = { closeSelection: false, tagArr: [trimmed], }; if (this.tagsAdded != null) { this.tagsAdded(eventArgs); } else { // No callback registered → append the bare string to the selection, // matching select2's default "tags: true" behaviour. const current = this.getSelectedTagRows(); const next = this.tagsNewPlaceLast !== false ? [ ...current, trimmed, ] : [ trimmed, ...current, ]; this.changed?.(next, this.currentExclusivity); } this.searchQuery = ''; this.focusedFlatIndex = -1; if (eventArgs.closeSelection) { this.close(false); } else { this.$nextTick(() => this.focusTagInput()); } } private focusTagInput(): void { const inp = (this.$el as HTMLElement | null)?.querySelector('.pd-dd-tag-input'); inp?.focus(); } private handleTagInputKeyDown(e: KeyboardEvent): void { switch (e.key) { case 'Enter': { e.preventDefault(); // If an option is focused, take it. Otherwise turn the search into // a new tag (free-text add). if (this.focusedFlatIndex >= 0 && this.focusedFlatIndex < this.visibleFlat.length) { this.toggleItem(this.visibleFlat[this.focusedFlatIndex]); } else if (this.searchQuery.trim().length > 0) { this.addFreeTextTag(this.searchQuery); } break; } case 'Backspace': { if (this.searchQuery.length === 0) { const sel = this.getSelectedTagRows(); if (sel.length > 0) { this.removeTagAt(sel.length - 1); } } break; } case 'ArrowDown': e.preventDefault(); if (!this.isOpen) { this.open(); } else { this.focusedFlatIndex = Math.min(this.visibleFlat.length - 1, this.focusedFlatIndex + 1); this.scrollFocusedIntoView(); } break; case 'ArrowUp': e.preventDefault(); this.focusedFlatIndex = Math.max(-1, this.focusedFlatIndex - 1); this.scrollFocusedIntoView(); break; case 'Escape': e.preventDefault(); this.close(false); break; case 'Tab': this.close(false); break; } } private bindSortable(): void { if (this.tags !== true || this.tagsSortable !== true) { return; } const list = (this.$el as HTMLElement | null)?.querySelector('.pd-dd-tags-chips'); if (list == null) { return; } // Tear down any prior instance. this.destroySortable(); this.sortableInstance = new (Sortable as any)(list, { animation: 150, handle: '.pd-dd-chip.pd-dd-tag-chip', draggable: '.pd-dd-tag-chip', onEnd: (evt: any) => { const oldIndex = evt.oldIndex; let newIndex = evt.newIndex; if (newIndex > oldIndex) { newIndex -= 1; } const current = this.getSelectedTagRows(); if (current == null || current[oldIndex] == null) { return; } const next = [...current]; const removed = next.splice(oldIndex, 1); next.splice( newIndex, 0, removed[0], ); this.changed?.(next, this.currentExclusivity); }, }); } private destroySortable(): void { if (this.sortableInstance != null) { try { this.sortableInstance.destroy(); } catch { } this.sortableInstance = null; } } private handleItemClick(item: DropdownDisplayArgs, e: MouseEvent): void { e.preventDefault(); e.stopPropagation(); if (item.isOptionGroup) { return; } // headers are not selectable this.toggleItem(item); } private isItemSelected(item: DropdownDisplayArgs): boolean { return this.effectiveSelectedIds.includes(item.id); } // ── Keyboard nav ────────────────────────────────────────────────────────── private handleSearchKeyDown(e: KeyboardEvent): void { switch (e.key) { case 'ArrowDown': e.preventDefault(); this.focusedFlatIndex = Math.min(this.visibleFlat.length - 1, this.focusedFlatIndex + 1); this.scrollFocusedIntoView(); break; case 'ArrowUp': e.preventDefault(); this.focusedFlatIndex = Math.max(0, this.focusedFlatIndex - 1); this.scrollFocusedIntoView(); break; case 'Home': e.preventDefault(); this.focusedFlatIndex = 0; this.scrollFocusedIntoView(); break; case 'End': e.preventDefault(); this.focusedFlatIndex = this.visibleFlat.length - 1; this.scrollFocusedIntoView(); break; case 'Enter': e.preventDefault(); if (this.focusedFlatIndex >= 0 && this.focusedFlatIndex < this.visibleFlat.length) { this.toggleItem(this.visibleFlat[this.focusedFlatIndex]); } else if (this.visibleFlat.length === 1) { // Auto-pick when query narrows results to exactly one match. this.toggleItem(this.visibleFlat[0]); } break; case 'Escape': e.preventDefault(); this.close(false); break; case 'Tab': this.close(true); break; } } // ── Async loader ────────────────────────────────────────────────────────── private async loadAsyncFirstPage(): Promise { if (this.asyncLoader == null) { return; } this.asyncList = []; this.asyncPage = 0; this.asyncHasMore = false; await this.runAsyncLoader(0, this.getSelectedIds()); } private async loadAsyncNextPage(): Promise { if (this.asyncLoader == null) { return; } if (this.asyncLoading || !this.asyncHasMore) { return; } await this.runAsyncLoader(this.asyncPage + 1, []); } private async runAsyncLoader(page: number, selectedIdsForRequest: string[]): Promise { if (this.asyncLoader == null) { return; } this.asyncLoading = true; this.asyncError = null; const seq = ++this.asyncRequestSeq; try { const res = await this.asyncLoader({ selectedIds: selectedIdsForRequest, searchQuery: this.searchQuery, limit: this.effectiveAsyncPageSize, page, }); // Discard if a newer request superseded us (or component closed). if (seq !== this.asyncRequestSeq) { return; } const items = Array.isArray(res?.items) ? res.items : []; if (page === 0) { this.asyncList = items.slice(); } else { this.asyncList = [ ...this.asyncList, ...items, ]; } for (const row of items) { const id = this.rowToDisplay(row).id; if (id != null && id.length > 0) { this.asyncCache.set(id, row); } } this.asyncPage = page; this.asyncHasMore = res?.hasMore === true; } catch (err: any) { if (seq !== this.asyncRequestSeq) { return; } this.asyncError = err?.message ?? String(err); } finally { if (seq === this.asyncRequestSeq) { this.asyncLoading = false; } } } private scheduleAsyncSearch(): void { if (this.asyncLoader == null) { return; } if (this.asyncDebounceTimer != null) { clearTimeout(this.asyncDebounceTimer); } this.asyncDebounceTimer = setTimeout(() => { this.asyncDebounceTimer = null; this.loadAsyncFirstPage(); }, 300); } // ── Virtualization ──────────────────────────────────────────────────────── /** * Unified scroll handler for the option panel: updates virtualization * windowing state AND drives async pagination. Bound to one specific * scroller element on open, removed on close. Re-renders of the panel do * not replace the wrapper element so the binding stays valid. */ private attachPanelScrollHandler(): void { this.detachPanelScrollHandler(); const root = this.$el as HTMLElement | null; const scroller = root?.querySelector('.pd-dd-results'); if (scroller == null) { return; } this.virtualViewportHeight = scroller.clientHeight; this.virtualScrollTop = scroller.scrollTop; const self = this; this.virtualScrollHandlerBound = (e: Event) => { const el = e.target as HTMLElement; if (self.isVirtualized) { self.virtualScrollTop = el.scrollTop; self.virtualViewportHeight = el.clientHeight; } if (self.isAsyncMode && self.asyncHasMore && !self.asyncLoading) { const remaining = el.scrollHeight - (el.scrollTop + el.clientHeight); if (remaining < 100) { self.loadAsyncNextPage(); } } }; scroller.addEventListener( 'scroll', this.virtualScrollHandlerBound, { passive: true }, ); } private detachPanelScrollHandler(): void { if (this.virtualScrollHandlerBound != null) { const root = this.$el as HTMLElement | null; const scroller = root?.querySelector('.pd-dd-results'); scroller?.removeEventListener('scroll', this.virtualScrollHandlerBound); this.virtualScrollHandlerBound = null; } } private scheduleRowMeasurement(): void { if (this.rowMeasureRaf != null) { return; } this.rowMeasureRaf = globalState.requestAnimationFrame(() => { this.rowMeasureRaf = null; const root = this.$el as HTMLElement | null; const firstRow = root?.querySelector('.pd-dd-results-list .pd-dd-option'); if (firstRow != null) { const h = firstRow.getBoundingClientRect().height; if (h > 0 && h !== this.measuredRowHeight) { this.measuredRowHeight = h; } } }); } private scrollFocusedIntoView(): void { if (this.rafScrollId != null) { return; } this.rafScrollId = globalState.requestAnimationFrame(() => { this.rafScrollId = null; const root = this.$el as HTMLElement | null; const focused = root?.querySelector(`#${this.uuid}-opt-${this.focusedFlatIndex}`); focused?.scrollIntoView({ block: 'nearest' }); }); } // ── Lifecycle ───────────────────────────────────────────────────────────── mounted(): void { this.bindSortable(); // In async mode, eagerly resolve labels for pre-selected IDs so chips // render with meaningful text immediately rather than as bare IDs. if (this.isAsyncMode) { const selIds = this.getSelectedIds(); if (selIds.length > 0) { this.runAsyncLoader(0, selIds); } } } updated(): void { this.bindSortable(); } beforeUnmount(): void { this.detachOutsideHandler(); this.destroySortable(); this.detachPanelScrollHandler(); if (this.asyncDebounceTimer != null) { clearTimeout(this.asyncDebounceTimer); this.asyncDebounceTimer = null; } if (this.rafScrollId != null) { globalState.cancelAnimationFrame(this.rafScrollId); this.rafScrollId = null; } if (this.rowMeasureRaf != null) { globalState.cancelAnimationFrame(this.rowMeasureRaf); this.rowMeasureRaf = null; } // Invalidate any in-flight loader response. this.asyncRequestSeq++; } // ── Custom render plumbing ──────────────────────────────────────────────── private renderCustom( fn: (h: any, state: DropdownDisplayArgs, originator?: CustomRenderOriginator) => any, item: DropdownDisplayArgs, originator: CustomRenderOriginator, ): any { const out = fn( null, item, originator, ); if (VueUtils.isVNode(out)) { // Vue can render VNodes returned from a render function directly — // no need to imperatively render them into a detached host first. return out; } if (typeof out === 'string') { return ; } return out ?? {item.text}; } // ── Render: trigger ─────────────────────────────────────────────────────── private renderTriggerLabel(): VNode { const selectedItems = this.getSelectedItems(); const isMulti = this.multiselect === true; // Multi-select chip mode (NOT Checkboxes): chips inside the trigger, // each with its own × remove button. if (isMulti && !this.isMultiCheckboxes) { if (selectedItems.length === 0) { return {this.effectivePlaceholder}; } return ( {selectedItems.map((item, idx) => ( {this.renderItemContent(item, 'default')} { // Don't let the chip click bubble to the trigger // (which would re-toggle the dropdown). e.preventDefault(); e.stopPropagation(); }} onClick={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.removeMultiChipAt(idx); }} onKeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); this.removeMultiChipAt(idx); } }} > × ))} ); } // Single-select / Checkboxes: show selection text or placeholder, plus // a × clear button when placeholder is set and a value is selected. if (selectedItems.length === 0) { // Checkboxes mode mirrors legacy s2-multi-cb: placeholder if set, // otherwise fall back to the project's `all` resource ("All" in // English) — single-select keeps the existing empty-placeholder // behaviour to stay backward-compatible. if (this.isMultiCheckboxes) { const emptyText = !isNullOrEmpty(this.placeholder) ? this.placeholder : this.fallbackAllLabel; return {emptyText}; } return {this.effectivePlaceholder}; } const renderText = (): any => { if (this.customRenderSelectionResult != null) { return this.renderCustom( this.customRenderSelectionResult, selectedItems[0], this.useModalMode ? 'mobile' : 'default', ); } const text = selectedItems.length === 1 ? selectedItems[0].text : `${selectedItems.length} ${this.resolveResource('selected', 'selected')}`; return text; }; const text = selectedItems.length === 1 ? selectedItems[0].text : ''; return ( {renderText()} {this.useAllowClear && ( { e.preventDefault(); e.stopPropagation(); }} onClick={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.clearSelection(); }} onKeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); this.clearSelection(); } }} > × )} ); } private removeMultiChipAt(index: number): void { const currentIds = this.getSelectedIds(); if (index < 0 || index >= currentIds.length) { return; } const nextIds = currentIds.filter((_, i) => i !== index); const flat = this.getOptions(false); const byId = new Map(flat.map(o => [ o.id, o, ])); const data = nextIds.map(id => byId.get(id)?.dataRow).filter(v => v != null); this.changed?.(data, this.currentExclusivity); } private clearSelection(): void { // For single-select; multi has per-chip removal. this.changed?.(null, this.currentExclusivity); } private renderTrigger(): VNode { if (this.tags === true) { return this.renderTagsTrigger(); } const isInline = this.isInlineMode; const isMulti = this.multiselect === true; const triggerClass = [ 'pd-dd-trigger', isMulti ? 'pd-dd-trigger--multiple' : 'pd-dd-trigger--single', this.disabled ? 'pd-dd-disabled' : '', ].filter(Boolean).join(' '); const rootClass = [ 'pd-dd-root', isInline ? 'pd-dd-inline' : '', isInline && this.containerCssClass ? this.containerCssClass : '', ].filter(Boolean).join(' '); // In inline mode the panel is always rendered alongside the trigger, // and the trigger itself doesn't toggle anything (clicking it is a // no-op — the panel never closes). const panel = (isInline || (!this.useModalMode && this.isOpen)) ? this.renderInlinePanel() : null; const activeDescendantId = this.focusedOptionDomId; const listboxId = `${this.uuid}-results`; return (
{ e.preventDefault(); if (this.disabled || isInline) { return; } if (this.isOpen) { this.close(true); } else { this.open(); } }} onKeydown={(e: KeyboardEvent) => { if (this.disabled || isInline) { return; } if (!this.isOpen) { if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { e.preventDefault(); this.open(); } return; } // Open + no search input (disableSearch): the trigger keeps // focus, so it must own arrow/Home/End/Enter/Escape nav — // otherwise the panel opens but arrow keys do nothing (WCAG 2.1.1). if (this.disableSearch === true) { this.handleSearchKeyDown(e); return; } // Search input normally has focus; if focus is still on the // trigger, Escape must still close the open panel. if (e.key === 'Escape') { e.preventDefault(); this.close(false); } }} > {this.renderTriggerLabel()} {panel}
); } private get focusedOptionDomId(): string | undefined { if (!this.isOpen && !this.isInlineMode) { return undefined; } if (this.focusedFlatIndex < 0) { return undefined; } const addIdx = this.canShowAddSuggestion ? this.visibleFlat.length - 1 : -1; if (addIdx >= 0 && this.focusedFlatIndex === addIdx) { return `${this.uuid}-opt-add`; } return `${this.uuid}-opt-${this.focusedFlatIndex}`; } private renderTagsTrigger(): VNode { const chips = this.getSelectedTagRows().map((row, i) => { const id = (PortalUtils.isString(row) || PortalUtils.isNumber(row)) ? String(row) : this.getReflectedRowValue(row, true); const text = (PortalUtils.isString(row) || PortalUtils.isNumber(row)) ? String(row) : this.getReflectedRowValue(row, false); const buttons = this.tagsButtons != null ? this.tagsButtons(row) : []; const renderButton = (btn: DropdownListButton, idx: number) => ( { // Prevent the chip-area click from also opening the dropdown e.preventDefault(); e.stopPropagation(); }} onClick={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); btn.clicked({ item: row }); }} onKeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); btn.clicked({ item: row }); } }} > ); const renderText = () => { if (this.customRenderSelectionResult != null) { return ( {this.renderCustom( this.customRenderSelectionResult, { id, text, dataRow: row }, 'default', )} ); } return {text}; }; return (
  • {this.tagsShouldPrependContent === true && buttons.map(renderButton)} {renderText()} {this.tagsShouldPrependContent !== true && buttons.map(renderButton)} { e.preventDefault(); e.stopPropagation(); this.removeTagAt(i); }} onKeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); this.removeTagAt(i); } }} > ×
  • ); }); const showPlaceholder = chips.length === 0 && this.searchQuery.length === 0 && !isNullOrEmpty(this.placeholder); const listboxId = `${this.uuid}-results`; const activeDescendantId = this.focusedOptionDomId; return (
    { // Don't steal focus when a chip action was clicked const target = e.target as HTMLElement | null; if (target?.closest('.pd-dd-chip-action, .pd-dd-chip-remove')) { return; } if (this.disabled) { return; } this.focusTagInput(); if (!this.isOpen) { this.open(); } }} >
      {chips}
    • { this.searchQuery = (e.target as HTMLInputElement).value; this.focusedFlatIndex = -1; if (!this.isOpen) { this.open(); } if (this.isAsyncMode) { this.scheduleAsyncSearch(); } }} onFocus={() => { if (!this.isOpen) { this.open(); } }} onKeydown={(e: KeyboardEvent) => this.handleTagInputKeyDown(e)} />
    {!this.useModalMode && this.isOpen && this.renderInlinePanel()}
    ); } // ── Render: panel ───────────────────────────────────────────────────────── private renderInlinePanel(): VNode { const panelClass = [ 'pd-dd-panel', this.isInlineMode ? 'pd-dd-panel-inline' : '', this.openUp ? 'pd-dd-open-up' : '', this.containerCssClass ?? '', ].filter(Boolean).join(' '); return (
    {this.renderPanelContent('default')}
    ); } private renderPanelContent(originator: CustomRenderOriginator): VNode { // When to render a search input INSIDE the panel: // - Tags mode: never — the chip-area input already drives `searchQuery` // and filters the option list (matches select2's tags-mode hack of // reusing the chip input as the search input). // - Inline mode: never — matches select2's inline configuration which // uses minimumResultsForSearch=Infinity. // - disableSearch={true}: never. // - Otherwise: yes. const showSearch = !this.isInlineMode && this.tags !== true && this.disableSearch !== true; return (
    {this.useOkHeader && (
    )} {showSearch && ( )}
    {this.isVirtualized ? this.renderVirtualizedList(originator) :
      {this.renderResultsList(originator)}
    }
    {this.useDoneButton && ( )}
    ); } private get canShowAddSuggestion(): boolean { if (this.tags !== true) { return false; } const q = this.searchQuery.trim(); if (q.length === 0) { return false; } // Only show if the typed value isn't already an existing option (case- and // diacritic-insensitive). If it is, the option itself is in the list. const normQ = normalize(q); const flat = this.getOptions(false); return !flat.some(o => normalize(o.text) === normQ); } private get addSuggestionText(): string { const suffix = this.resolveResource('add', 'add'); return `${this.searchQuery.trim()} (${suffix})`; } private renderResultsList(originator: CustomRenderOriginator): VNode[] { const items = this.visibleItems; const addSuggestion = this.canShowAddSuggestion; if (items.length === 0 && !addSuggestion) { if (this.isAsyncMode && this.asyncLoading) { return [
  • {this.resolveResource('loading', 'Loading…')}
  • , ]; } if (this.isAsyncMode && this.asyncError != null) { return []; } return [
  • {this.resolveResource('noResultsFound', 'No results found')}
  • , ]; } const nodes: VNode[] = []; let flatIndex = 0; for (const item of items) { if (item.isOptionGroup && item.children) { // `role="group"` (with aria-label) is the semantic match for an // optgroup-style heading inside a listbox. const header = (
  • {item.text}
  • ); nodes.push(header); for (const child of item.children) { nodes.push(this.renderOptionItem( child, flatIndex, originator, )); flatIndex++; } } else { nodes.push(this.renderOptionItem( item, flatIndex, originator, )); flatIndex++; } } // In tags mode, append a virtual "Add: " suggestion as the last // option. Clicking it (or pressing Enter when focused) creates the new // free-text tag via the existing addFreeTextTag pipeline. if (addSuggestion) { const focused = flatIndex === this.focusedFlatIndex; const cls = [ 'pd-dd-option', 'pd-dd-option-add', focused ? 'pd-dd-option--focused' : '', ].filter(Boolean).join(' '); const query = this.searchQuery.trim(); const addRow = (
  • e.preventDefault()} onClick={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.addFreeTextTag(query); }} onMouseenter={() => { this.focusedFlatIndex = flatIndex; }} > {this.addSuggestionText}
  • ); nodes.push(addRow); } // Async: while loading more pages, append a non-interactive spinner row. // `role="status" aria-live="polite"` makes screen readers announce the // state change without stealing focus. if (this.isAsyncMode && this.asyncLoading && items.length > 0) { const loadingRow = (
  • ); nodes.push(loadingRow); } return nodes; } /** * Build the ordered list of row descriptors that the panel will render. * Shared by the non-virtualized and virtualized paths — virtualized skips * the VNode construction for descriptors outside the visible window. */ private buildResultDescriptors(): ResultRowDescriptor[] { const items = this.visibleItems; const addSuggestion = this.canShowAddSuggestion; const rows: ResultRowDescriptor[] = []; if (items.length === 0 && !addSuggestion) { if (this.isAsyncMode && this.asyncLoading) { rows.push({ kind: 'loading' }); return rows; } if (this.isAsyncMode && this.asyncError != null) { rows.push({ kind: 'error', msg: this.asyncError }); return rows; } rows.push({ kind: 'no-results' }); return rows; } let flatIndex = 0; for (const item of items) { if (item.isOptionGroup && item.children) { rows.push({ kind: 'group', item }); for (const child of item.children) { rows.push({ kind: 'option', item: child, flatIndex }); flatIndex++; } } else { rows.push({ kind: 'option', item, flatIndex }); flatIndex++; } } if (addSuggestion) { rows.push({ kind: 'add', flatIndex }); } if (this.isAsyncMode && this.asyncLoading && items.length > 0) { rows.push({ kind: 'loading' }); } return rows; } private renderRowDescriptor(row: ResultRowDescriptor, originator: CustomRenderOriginator): VNode { switch (row.kind) { case 'group': return (
  • {row.item.text}
  • ); case 'option': return this.renderOptionItem( row.item, row.flatIndex, originator, ); case 'add': { const focused = row.flatIndex === this.focusedFlatIndex; const cls = [ 'pd-dd-option', 'pd-dd-option-add', focused ? 'pd-dd-option--focused' : '', ].filter(Boolean).join(' '); const query = this.searchQuery.trim(); const flatIdx = row.flatIndex; return (
  • e.preventDefault()} onClick={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.addFreeTextTag(query); }} onMouseenter={() => { this.focusedFlatIndex = flatIdx; }} > {this.addSuggestionText}
  • ); } case 'loading': return (
  • {this.resolveResource('loading', 'Loading…')}
  • ); case 'error': return ; case 'no-results': return (
  • {this.resolveResource('noResultsFound', 'No results found')}
  • ); } } /** * Render only the visible window of rows. Each row is sized to the * measured row height (defaults to 30px until the first measurement lands). * A tall absolutely-positioned spacer holds open the scroll height. */ private renderVirtualizedList(originator: CustomRenderOriginator): VNode { const rows = this.buildResultDescriptors(); const total = rows.length; const rowH = this.measuredRowHeight > 0 ? this.measuredRowHeight : 30; const viewport = this.virtualViewportHeight > 0 ? this.virtualViewportHeight : 240; const scrollTop = this.virtualScrollTop; const buffer = 5; const visibleStart = Math.max(0, Math.floor(scrollTop / rowH) - buffer); const visibleEnd = Math.min(total, Math.ceil((scrollTop + viewport) / rowH) + buffer); const topPad = visibleStart * rowH; const totalH = total * rowH; const visibleNodes: VNode[] = []; for (let i = visibleStart; i < visibleEnd; i++) { visibleNodes.push(this.renderRowDescriptor(rows[i], originator)); } return (
      {visibleNodes}
    ); } private renderItemContent(item: DropdownDisplayArgs, originator: CustomRenderOriginator): any { if (this.customRenderOption != null) { return this.renderCustom( this.customRenderOption, item, originator, ); } return item.text; } private renderOptionItem( item: DropdownDisplayArgs, flatIndex: number, originator: CustomRenderOriginator, ): VNode { const selected = this.isItemSelected(item); const focused = flatIndex === this.focusedFlatIndex; const cls = [ 'pd-dd-option', selected ? 'pd-dd-option--selected' : '', focused ? 'pd-dd-option--focused' : '', ].filter(Boolean).join(' '); return (
  • e.preventDefault()} onClick={(e: MouseEvent) => this.handleItemClick(item, e)} onMouseenter={() => { this.focusedFlatIndex = flatIndex; }} > {this.isMultiCheckboxes && ( /* Powerduck for full visual consistency with the * rest of the app. `pointer-events: none` on the wrapper * (`.pd-dd-cb`) makes clicks pass through to the parent
  • * which owns the toggle path — the CheckBox is purely * read-only display, its `value` prop reflects the row's * pending selection state. Material skin (project default, * `inv-md-checkbox`) has its CSS bundled in checkbox.css — * NowUi relies on Bootstrap theme CSS that isn't always * loaded downstream. */ )} {this.renderItemContent(item, originator)} {this.renderTrailingButton(item)}
  • ); } /** * Optional per-option action button (e.g. "delete this tag from the event"). * Rendered on every option row; the caller is responsible for hiding it on * rows where it does not apply (e.g. via a `:first-child` CSS rule). `clicked` * receives the option's underlying dataRow. */ private renderTrailingButton(item: DropdownDisplayArgs): VNode | null { const btn = this.trailingButton; if (btn == null) { return null; } const fire = () => btn.clicked(item.dataRow); return ( { e.preventDefault(); e.stopPropagation(); }} onClick={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); fire(); }} onKeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); fire(); } }} > {!isNullOrEmpty(btn.icon) && } {!isNullOrEmpty(btn.text) && {btn.text}} ); } // ── Public API ──────────────────────────────────────────────────────────── public openProgrammatically(): void { this.open(); } public closeProgrammatically(): void { this.close(false); } // ── Top-level render ────────────────────────────────────────────────────── render(_h: any): VNode { const cssBuilder: string[] = []; if (this.selected != null) { cssBuilder.push(`ddl-dummy-selected${String(this.selected).slice(0, 1)}`); } if (this.isInlineMode) { cssBuilder.push('dropdownlist-inline-mode'); } if (!isNullOrEmpty(this.cssClass)) { cssBuilder.push(this.cssClass); } return ( {this.renderTrigger()} {this.useModalMode && ( {this.isOpen && this.renderPanelContent('mobile')} )} ); } } const DropdownList = toNative(DropdownListComponent); export type DropdownListType = typeof DropdownList.prototype; export default DropdownList;