This is an automated email from the ASF dual-hosted git repository.
scottyaslan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 1c6794e9c75 NIFI-16222 Improved searchable select keyboard navigation
for grouped options (#11561)
1c6794e9c75 is described below
commit 1c6794e9c7563523c5cfc36c4a3e8cfaef659f96
Author: Rob Fellows <[email protected]>
AuthorDate: Mon Aug 24 17:05:39 2026 -0400
NIFI-16222 Improved searchable select keyboard navigation for grouped
options (#11561)
* NIFI-16222 Improved searchable select keyboard navigation for grouped
options
Grouped, non-virtual option lists rendered a per-group option id, so the
active option id used for scrolling and Enter selection never resolved.
Tag the active option with a stable active id driven by reference identity
for grouped lists and by the active template index for ungrouped lists,
scroll and select through that id, and select through the MatSelect
MatOption instances so multi-select toggling and duplicate values across
groups resolve to the highlighted row.
Also disarm the pending value-change permission on Escape, on panel close,
and after an Enter that re-selects the already-selected option, so a
subsequent closed-panel arrow auto-selection is not accepted.
* address review feedback
---
.../searchable-select.component.html | 24 +-
.../searchable-select.component.spec.ts | 750 ++++++++++++++++++++-
.../searchable-select.component.ts | 163 +++--
3 files changed, 874 insertions(+), 63 deletions(-)
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/searchable-select/searchable-select.component.html
b/nifi-frontend/src/main/frontend/libs/shared/src/components/searchable-select/searchable-select.component.html
index 8d3c02d83d4..6b33c6456e1 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/searchable-select/searchable-select.component.html
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/searchable-select/searchable-select.component.html
@@ -79,7 +79,7 @@
</div>
<!-- Unified ghost options for both virtual and non-virtual modes
-->
- @for (ghostOption of getGhostOptions(); track ghostOption.value) {
+ @for (ghostOption of getGhostOptions(); track $index) {
<multi-select-option
[value]="ghostOption.value"
[disabled]="disabled || ghostOption.disabled"
@@ -156,13 +156,9 @@
[value]="getItemValue(item)"
[disabled]="disabled ||
getItemDisabled(item)"
[virtuallySelected]="isVirtualOptionSelected(getItemValue(item))"
- [id]="
-
isOptionActiveByValue(getItemValue(item))
- ? activeOptionId
- : getOptionId(virtualIndex)
- "
+ [id]="isVirtualOptionActive(item) ?
activeOptionId : getOptionId(virtualIndex)"
[attr.aria-selected]="isVirtualOptionSelected(getItemValue(item))"
-
[class.mat-mdc-option-active]="isOptionActiveByValue(getItemValue(item))"
+
[class.mat-mdc-option-active]="isVirtualOptionActive(item)"
role="option">
@if (getItemSvgIcon(item)) {
<mat-icon class="shrink-0"
[svgIcon]="getItemSvgIcon(item)"></mat-icon>
@@ -208,12 +204,18 @@
{{ group.groupLabel }}
</div>
}
- @for (option of group.options; track
option.value; let optIdx = $index) {
+ <!-- Track by object identity: values may
not be unique within a group.
+ option.value as track would collide
and break @for reconciliation. -->
+ @for (option of group.options; track
option; let optIdx = $index) {
<multi-select-option
[value]="option.value"
[disabled]="disabled ||
option.disabled"
- [id]="getOptionId(group.groupId +
'-' + optIdx)"
-
[class.mat-mdc-option-active]="isOptionActiveByValue(option.value)"
+ [id]="
+ activeOptionRef() === option
+ ? activeOptionId
+ :
getOptionId(group.groupId + '-' + optIdx)
+ "
+
[class.mat-mdc-option-active]="activeOptionRef() === option"
role="option">
@if (option.svgIcon) {
<mat-icon class="shrink-0"
[svgIcon]="option.svgIcon"></mat-icon>
@@ -242,7 +244,7 @@
<multi-select-option
[value]="option.value"
[disabled]="disabled ||
option.disabled"
- [id]="getOptionId(i)"
+ [id]="activeTemplateIndex() === i ?
activeOptionId : getOptionId(i)"
[class.mat-mdc-option-active]="activeTemplateIndex() === i"
role="option">
@if (option.svgIcon) {
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/searchable-select/searchable-select.component.spec.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/components/searchable-select/searchable-select.component.spec.ts
index 463a996f06d..291d4e7feff 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/searchable-select/searchable-select.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/searchable-select/searchable-select.component.spec.ts
@@ -317,6 +317,184 @@ describe('SearchableSelect', () => {
expect(component['_activeOptionIndex']).toBe(-1);
expect(component['_isNavigating']).toBe(false);
});
+
+ it('should scroll and select ungrouped options via keyboard
navigation', async () => {
+ const mockOptions: SearchableSelectOption<string>[] = [
+ { value: 'one', label: 'one test' },
+ { value: 'two', label: 'two test' },
+ { value: 'three', label: 'three test' }
+ ];
+ const { component, fixture } = await setup({ options: mockOptions
});
+ const onChange = vi.fn();
+ component.registerOnChange(onChange);
+ const scrollSpy = vi.spyOn(HTMLElement.prototype,
'scrollIntoView').mockImplementation(vi.fn());
+
+ component.selectionPanelToggled(true);
+ component.select().open();
+ fixture.detectChanges();
+ await new Promise((r) => setTimeout(r, 0));
+ fixture.detectChanges();
+
+ const navEvent = (key: string) =>
+ ({
+ key,
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ stopImmediatePropagation: vi.fn()
+ }) as unknown as KeyboardEvent;
+
+ component.onSearchInputKeydown(navEvent('ArrowDown'));
+ component.onSearchInputKeydown(navEvent('ArrowDown'));
+
expect(document.getElementById(component.activeOptionId)).toBeTruthy();
+ expect(scrollSpy).toHaveBeenCalled();
+
+ component.onSearchInputKeydown(navEvent('Enter'));
+ fixture.detectChanges();
+
+ expect(onChange).toHaveBeenCalledWith('two');
+
+ scrollSpy.mockRestore();
+ });
+
+ it('should resolve activeOptionId for the first visible option when
earlier options are filtered out', async () => {
+ const mockOptions: SearchableSelectOption<string>[] = [
+ { value: 'alpha', label: 'alpha option' },
+ { value: 'beta', label: 'beta option' },
+ { value: 'gamma', label: 'gamma option' }
+ ];
+ const { component, fixture } = await setup({ options: mockOptions,
searchString: 'beta' });
+ const onChange = vi.fn();
+ component.registerOnChange(onChange);
+
+ component.selectionPanelToggled(true);
+ component.select().open();
+ fixture.detectChanges();
+ await new Promise((r) => setTimeout(r, 0));
+ fixture.detectChanges();
+
+ const navEvent = (key: string) =>
+ ({
+ key,
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ stopImmediatePropagation: vi.fn()
+ }) as unknown as KeyboardEvent;
+
+ component.onSearchInputKeydown(navEvent('ArrowDown'));
+
+ const activeElement =
document.getElementById(component.activeOptionId);
+ expect(activeElement).toBeTruthy();
+
expect(component.getVisibleOptions()[component['_activeOptionIndex']].value).toBe('beta');
+
+ // Enter must select the visible (non-hidden) option, not a
filtered-out one
+ component.onSearchInputKeydown(navEvent('Enter'));
+ fixture.detectChanges();
+ expect(onChange).toHaveBeenCalledWith('beta');
+ });
+
+ it('should not leave the value-change permission flag armed when no
option resolves', async () => {
+ const mockOptions: SearchableSelectOption<string>[] = [
+ { value: 'one', label: 'one test' },
+ { value: 'two', label: 'two test' }
+ ];
+ const { component, fixture } = await setup({ options: mockOptions
});
+ const onChange = vi.fn();
+ component.registerOnChange(onChange);
+
+ component.selectionPanelToggled(true);
+ component.select().open();
+ fixture.detectChanges();
+ await new Promise((r) => setTimeout(r, 0));
+ fixture.detectChanges();
+
+ // Simulate handleEnterKey arming the flag, then a toggle that
resolves nothing
+ // (value not present in the rendered options).
+ component['_allowNextValueChange'] = true;
+ component['toggleOption']('does-not-exist' as unknown as string);
+
+ expect(onChange).not.toHaveBeenCalled();
+ expect(component['_allowNextValueChange']).toBe(false);
+ });
+
+ it('should not accept closed-panel arrow auto-select after Enter on
the already-selected option', async () => {
+ // Re-selecting the current value emits no mat-select valueChange,
so onValueChanged
+ // never consumes _allowNextValueChange. handleEnterKey must
disarm the flag; otherwise
+ // the next closed-panel Material arrow auto-selection is ACCEPTed
once.
+ const mockOptions: SearchableSelectOption<string>[] = [
+ { value: 'one', label: 'one test' },
+ { value: 'two', label: 'two test' },
+ { value: 'three', label: 'three test' }
+ ];
+ const { component, fixture } = await setup({ options: mockOptions
});
+ const onChange = vi.fn();
+ component.registerOnChange(onChange);
+
+ const navEvent = (key: string) =>
+ ({
+ key,
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ stopImmediatePropagation: vi.fn()
+ }) as unknown as KeyboardEvent;
+
+ const openPanel = async () => {
+ component.selectionPanelToggled(true);
+ component.select().open();
+ fixture.detectChanges();
+ await new Promise((r) => setTimeout(r, 0));
+ fixture.detectChanges();
+ };
+
+ // Select "two"
+ await openPanel();
+ component.onSearchInputKeydown(navEvent('ArrowDown'));
+ component.onSearchInputKeydown(navEvent('ArrowDown'));
+
expect(component.getVisibleOptions()[component['_activeOptionIndex']].value).toBe('two');
+ component.onSearchInputKeydown(navEvent('Enter'));
+ fixture.detectChanges();
+ expect(onChange).toHaveBeenCalledWith('two');
+ expect(component['_lastIntentionalValue']).toBe('two');
+
+ // Re-open and Enter on the same already-selected option
+ await openPanel();
+ component.onSearchInputKeydown(navEvent('ArrowDown'));
+ component.onSearchInputKeydown(navEvent('ArrowDown'));
+
expect(component.getVisibleOptions()[component['_activeOptionIndex']].value).toBe('two');
+ component.onSearchInputKeydown(navEvent('Enter'));
+ fixture.detectChanges();
+
+ expect(component['_allowNextValueChange']).toBe(false);
+
+ // Panel close must also leave the permission flag disarmed
(defense in depth)
+ component.select().close();
+ component.selectionPanelToggled(false);
+ fixture.detectChanges();
+ expect(component.select().panelOpen).toBe(false);
+ expect(component['_allowNextValueChange']).toBe(false);
+
+ // Simulate Material closed-panel arrow auto-select of an adjacent
value
+ const writeValueSpy = vi.spyOn(component.select(), 'writeValue');
+ component.onValueChanged('three');
+ await new Promise((r) => setTimeout(r, 0));
+
+ expect(onChange).not.toHaveBeenCalledWith('three');
+ expect(component['_lastIntentionalValue']).toBe('two');
+ expect(writeValueSpy).toHaveBeenCalledWith('two');
+ });
+
+ it('should clear the value-change permission flag when the panel
closes', async () => {
+ const { component, fixture } = await setup();
+
+ component.selectionPanelToggled(true);
+ component.select().open();
+ fixture.detectChanges();
+
+ component['_allowNextValueChange'] = true;
+ component.selectionPanelToggled(false);
+ fixture.detectChanges();
+
+ expect(component['_allowNextValueChange']).toBe(false);
+ });
});
describe('Focus Management', () => {
@@ -1204,6 +1382,183 @@ describe('SearchableSelect', () => {
});
});
+ describe('Keyboard navigation with async option batches', () => {
+ const batchA: SearchableSelectOption<string>[] = [
+ { value: 'a1', label: 'Alpha One' },
+ { value: 'a2', label: 'Alpha Two' },
+ { value: 'a3', label: 'Alpha Three' }
+ ];
+ const batchB: SearchableSelectOption<string>[] = [
+ { value: 'b1', label: 'Beta One' },
+ { value: 'b2', label: 'Beta Two' }
+ ];
+
+ function createNavKeyEvent(key: string): KeyboardEvent {
+ return {
+ key,
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ stopImmediatePropagation: vi.fn()
+ } as unknown as KeyboardEvent;
+ }
+
+ async function openAsyncPanel(
+ fixture: Awaited<ReturnType<typeof setup>>['fixture'],
+ component: Awaited<ReturnType<typeof setup>>['component'],
+ options: SearchableSelectOption<string>[]
+ ) {
+ fixture.componentRef.setInput('asyncSearchEnabled', true);
+ fixture.componentRef.setInput('options', options);
+ fixture.detectChanges();
+
+ component.selectionPanelToggled(true);
+ component.select().open();
+ fixture.detectChanges();
+ await new Promise((r) => setTimeout(r, 0));
+ fixture.detectChanges();
+ }
+
+ it('should clear navigation state and activeOptionRef when a new
options batch arrives', async () => {
+ const { component, fixture } = await setup({ options: batchA
});
+ await openAsyncPanel(fixture, component, batchA);
+
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ expect(component['_activeOptionIndex']).toBe(1);
+ expect(component['_isNavigating']).toBe(true);
+
expect(document.getElementById(component.activeOptionId)).toBeTruthy();
+
+ // Simulate remote search results replacing the batch
+ fixture.componentRef.setInput('options', batchB);
+ fixture.detectChanges();
+
+ expect(component['_activeOptionIndex']).toBe(-1);
+ expect(component['_isNavigating']).toBe(false);
+ expect(component['activeOptionRef']()).toBeNull();
+
expect(document.getElementById(component.activeOptionId)).toBeNull();
+ });
+
+ it('should no-op Enter after an options batch replace wipes the
highlight', async () => {
+ const { component, fixture } = await setup({ options: batchA
});
+ const onChange = vi.fn();
+ component.registerOnChange(onChange);
+ await openAsyncPanel(fixture, component, batchA);
+
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ expect(component['_activeOptionIndex']).toBe(0);
+
+ fixture.componentRef.setInput('options', batchB);
+ fixture.detectChanges();
+
+ component.onSearchInputKeydown(createNavKeyEvent('Enter'));
+ fixture.detectChanges();
+
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it('should select the highlighted option when Enter is pressed
before the next batch arrives', async () => {
+ const { component, fixture } = await setup({ options: batchA
});
+ const onChange = vi.fn();
+ component.registerOnChange(onChange);
+ await openAsyncPanel(fixture, component, batchA);
+
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+
expect(component.getVisibleOptions()[component['_activeOptionIndex']].value).toBe('a2');
+
+ component.onSearchInputKeydown(createNavKeyEvent('Enter'));
+ fixture.detectChanges();
+
+ expect(onChange).toHaveBeenCalledWith('a2');
+ });
+
+ it('should not select a ghost via the value fallback when the
selected value is missing from the batch', async () => {
+ // Async ghosts exist only while the selected value is absent
from the current
+ // options batch. The value fallback must refuse them (they
render first in the
+ // QueryList and are hidden, not disabled).
+ const { component, fixture } = await setup({
+ options: [{ value: 'kept', label: 'Kept' }]
+ });
+ await openAsyncPanel(fixture, component, [{ value: 'kept',
label: 'Kept' }]);
+
+ component.writeValue('kept');
+ fixture.detectChanges();
+
+ // Drop "kept" from the batch -> ghost is created for the
still-selected value
+ fixture.componentRef.setInput('options', [{ value: 'other',
label: 'Other' }]);
+ fixture.detectChanges();
+
+ const ghosts = component.getGhostOptions();
+ expect(ghosts.some((g) => g.value === 'kept')).toBe(true);
+
+ const ghostOption = component
+ .select()
+ .options.find(
+ (opt) => opt.value === 'kept' &&
opt._getHostElement().classList.contains('ghost-option')
+ );
+ expect(ghostOption).toBeTruthy();
+ const ghostSpy = vi.spyOn(ghostOption!,
'_selectViaInteraction');
+
+ // Force the value-fallback path: clear nav state and flush so
no option carries
+ // activeOptionId. Only the ghost matches value "kept";
fallback must refuse it.
+ component['_isNavigating'] = false;
+ component['updateActiveTemplateIndex']();
+ fixture.detectChanges();
+
expect(document.getElementById(component.activeOptionId)).toBeNull();
+
+ component['_allowNextValueChange'] = true;
+ component['toggleOption']('kept');
+ fixture.detectChanges();
+
+ expect(ghostSpy).not.toHaveBeenCalled();
+ expect(component['_allowNextValueChange']).toBe(false);
+ });
+
+ it('should select a real async option via keyboard when a
previously selected value returns in a new batch', async () => {
+ const { component, fixture } = await setup({
+ options: [
+ { value: 'other', label: 'Other' },
+ { value: 'kept', label: 'Kept' }
+ ]
+ });
+ const onChange = vi.fn();
+ component.registerOnChange(onChange);
+ await openAsyncPanel(fixture, component, [
+ { value: 'other', label: 'Other' },
+ { value: 'kept', label: 'Kept' }
+ ]);
+
+ component.writeValue('kept');
+ fixture.detectChanges();
+
+ // Temporarily missing -> ghost, then returned in the next
batch
+ fixture.componentRef.setInput('options', [{ value: 'other',
label: 'Other' }]);
+ fixture.detectChanges();
+ fixture.componentRef.setInput('options', [
+ { value: 'other', label: 'Other' },
+ { value: 'kept', label: 'Kept' }
+ ]);
+ fixture.detectChanges();
+
+ // Batch replace clears nav; re-navigate to the real "kept"
row and Enter
+ component.resetEmissionTracking();
+ onChange.mockClear();
+
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+
expect(component.getVisibleOptions()[component['_activeOptionIndex']].value).toBe('kept');
+
+ const activeMatOption = component.select().options.find((opt)
=> opt.id === component.activeOptionId);
+ expect(activeMatOption).toBeTruthy();
+
expect(activeMatOption!._getHostElement().classList.contains('ghost-option')).toBe(false);
+
+ component.onSearchInputKeydown(createNavKeyEvent('Enter'));
+ fixture.detectChanges();
+
+ expect(onChange).toHaveBeenCalledWith('kept');
+ });
+ });
+
describe('Virtual Scrolling with Async Search', () => {
it('should include "more options" footer when
asyncSearchOptionsHaveMore is true', async () => {
const { component, fixture } = await setup({
enableVirtualScrolling: true });
@@ -1795,7 +2150,264 @@ describe('SearchableSelect', () => {
});
});
+ describe('Keyboard navigation with groups', () => {
+ function createNavKeyEvent(key: string): KeyboardEvent {
+ return {
+ key,
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ stopImmediatePropagation: vi.fn()
+ } as unknown as KeyboardEvent;
+ }
+
+ async function openGroupedPanel(
+ fixture: Awaited<ReturnType<typeof setupGrouped>>['fixture'],
+ component: Awaited<ReturnType<typeof
setupGrouped>>['component']
+ ) {
+ component.selectionPanelToggled(true);
+ component.select().open();
+ fixture.detectChanges();
+ await new Promise((r) => setTimeout(r, 0));
+ fixture.detectChanges();
+ }
+
+ it('should scroll the active grouped option into view when
navigating past the visible area', async () => {
+ const { component, fixture } = await setupGrouped();
+ const scrollSpy = vi.spyOn(HTMLElement.prototype,
'scrollIntoView').mockImplementation(vi.fn());
+
+ await openGroupedPanel(fixture, component);
+
+ for (let i = 0; i < 4; i++) {
+
component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ }
+
+ const activeElement =
document.getElementById(component.activeOptionId);
+ expect(activeElement).toBeTruthy();
+
expect(component.getVisibleOptions()[component['_activeOptionIndex']].value).toBe('az-1');
+ expect(scrollSpy).toHaveBeenCalled();
+
+ scrollSpy.mockRestore();
+ });
+
+ it('should select the highlighted grouped option when Enter is
pressed', async () => {
+ const { component, fixture } = await setupGrouped();
+ const onChange = vi.fn();
+ component.registerOnChange(onChange);
+
+ await openGroupedPanel(fixture, component);
+
+ for (let i = 0; i < 4; i++) {
+
component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ }
+
+ component.onSearchInputKeydown(createNavKeyEvent('Enter'));
+ fixture.detectChanges();
+
+ expect(onChange).toHaveBeenCalledWith('az-1');
+ });
+
+ it('should select a grouped option via MatSelect options even
after navigation highlight is cleared', async () => {
+ // Guards the Enter path against relying on activeOptionId
still being in the DOM
+ // after _isNavigating is cleared (the failure mode of the
prior id-based click).
+ // After the highlight is flushed from the DOM, toggleOption
must fall through to
+ // the non-ghost value match.
+ const { component, fixture } = await setupGrouped();
+ const onChange = vi.fn();
+ component.registerOnChange(onChange);
+
+ await openGroupedPanel(fixture, component);
+
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+
expect(component.getVisibleOptions()[component['_activeOptionIndex']].value).toBe('aws-2');
+
expect(document.getElementById(component.activeOptionId)).toBeTruthy();
+
+ const matOption = component.select().options.find((opt) =>
opt.value === 'aws-2');
+ expect(matOption).toBeTruthy();
+ const selectViaSpy = vi.spyOn(matOption!,
'_selectViaInteraction');
+
+ // Flush the active id off the DOM so id-find misses and
value-fallback runs
+ component['_allowNextValueChange'] = true;
+ component['_isNavigating'] = false;
+ component['updateActiveTemplateIndex']();
+ fixture.detectChanges();
+
expect(document.getElementById(component.activeOptionId)).toBeNull();
+
+ component['toggleOption']('aws-2');
+ fixture.detectChanges();
+
+ expect(selectViaSpy).toHaveBeenCalled();
+ expect(onChange).toHaveBeenCalledWith('aws-2');
+ });
+
+ it('should assign activeOptionId to exactly one rendered grouped
option', async () => {
+ const { component, fixture } = await setupGrouped();
+
+ await openGroupedPanel(fixture, component);
+
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+
+ const overlayContainer =
document.querySelector('.cdk-overlay-container');
+ const activeOptions =
overlayContainer?.querySelectorAll(`#${component.activeOptionId}`) ?? [];
+
+ expect(activeOptions.length).toBe(1);
+ });
+
+ it('should assign activeOptionId to exactly one option when two
groups share a value', async () => {
+ // Reference-identity (not value equality) must drive the
active id so duplicate
+ // values across groups can never both receive activeOptionId.
+ const duplicateValueOptions = [
+ { value: 'dup', label: 'In AWS', group: 'AWS Secrets
Manager' },
+ { value: 'dup', label: 'In Azure', group: 'Azure Key
Vault' }
+ ];
+ const { component, fixture } = await setupGrouped({ options:
duplicateValueOptions });
+ const onChange = vi.fn();
+ component.registerOnChange(onChange);
+
+ await openGroupedPanel(fixture, component);
+
+ // Groups sort alphabetically: AWS first, then Azure.
+ // ArrowDown x2 highlights the Azure row (same value,
different identity).
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+
+ const overlayContainer =
document.querySelector('.cdk-overlay-container');
+ const activeOptions =
overlayContainer?.querySelectorAll(`#${component.activeOptionId}`) ?? [];
+ expect(activeOptions.length).toBe(1);
+
+ // Enter must select the highlighted duplicate (Azure), not
the first QueryList match (AWS).
+ const highlighted = component['activeOptionRef']();
+ expect(highlighted?.label).toBe('In Azure');
+
+ const azureOption = component.select().options.find((opt) =>
opt.id === component.activeOptionId);
+ expect(azureOption).toBeTruthy();
+ const selectViaSpy = vi.spyOn(azureOption!,
'_selectViaInteraction');
+
+ component.onSearchInputKeydown(createNavKeyEvent('Enter'));
+ fixture.detectChanges();
+
+ expect(selectViaSpy).toHaveBeenCalled();
+ expect(onChange).toHaveBeenCalledWith('dup');
+ });
+
+ it('should toggle the same duplicate-value option twice in
multi-select via Enter', async () => {
+ // After the first Enter, highlight identity must remain so a
second Enter hits the
+ // same MatOption (Azure), not the first QueryList match with
the same value (AWS).
+ // Material's multi-select model is value-based, so two
MatOptions sharing a value can
+ // still produce odd selection arrays; what we lock here is
Enter identity.
+ const duplicateValueOptions = [
+ { value: 'dup', label: 'In AWS', group: 'AWS Secrets
Manager' },
+ { value: 'dup', label: 'In Azure', group: 'Azure Key
Vault' }
+ ];
+ const { component, fixture } = await setupGrouped({
+ options: duplicateValueOptions,
+ multiple: true
+ });
+ const onChange = vi.fn();
+ component.registerOnChange(onChange);
+
+ await openGroupedPanel(fixture, component);
+
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ expect(component['activeOptionRef']()?.label).toBe('In Azure');
+
+ const azureOption = component.select().options.find((opt) =>
opt.id === component.activeOptionId);
+ expect(azureOption).toBeTruthy();
+ const awsOption = component.select().options.find((opt) =>
opt.value === 'dup' && opt !== azureOption);
+ expect(awsOption).toBeTruthy();
+
+ const azureSpy = vi.spyOn(azureOption!,
'_selectViaInteraction');
+ const awsSpy = vi.spyOn(awsOption!, '_selectViaInteraction');
+
+ component.onSearchInputKeydown(createNavKeyEvent('Enter'));
+ fixture.detectChanges();
+ expect(azureSpy).toHaveBeenCalledTimes(1);
+ expect(awsSpy).not.toHaveBeenCalled();
+ expect(onChange).toHaveBeenLastCalledWith(['dup']);
+ expect(component['_isNavigating']).toBe(true);
+
expect(document.getElementById(component.activeOptionId)).toBe(azureOption!._getHostElement());
+
+ component.onSearchInputKeydown(createNavKeyEvent('Enter'));
+ fixture.detectChanges();
+ expect(azureSpy).toHaveBeenCalledTimes(2);
+ expect(awsSpy).not.toHaveBeenCalled();
+ expect(component.select().panelOpen).toBe(true);
+ });
+
+ it('should clear all navigation state when Escape closes the
panel', async () => {
+ const { component, fixture } = await setupGrouped();
+
+ await openGroupedPanel(fixture, component);
+
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ expect(component['_isNavigating']).toBe(true);
+ expect(component['activeOptionRef']()).not.toBeNull();
+
expect(component['_activeOptionIndex']).toBeGreaterThanOrEqual(0);
+ component['_allowNextValueChange'] = true;
+
+ component.onSearchInputKeydown(createNavKeyEvent('Escape'));
+ fixture.detectChanges();
+
+ expect(component.select().panelOpen).toBe(false);
+ expect(component['_isNavigating']).toBe(false);
+ expect(component['activeOptionRef']()).toBeNull();
+ expect(component['_activeOptionIndex']).toBe(-1);
+ // Escape must disarm directly; this test does not call
selectionPanelToggled(false).
+ expect(component['_allowNextValueChange']).toBe(false);
+ });
+
+ it('should toggle grouped options in multi-select mode via Enter
without closing the panel', async () => {
+ const { component, fixture } = await setupGrouped({ multiple:
true });
+ const onChange = vi.fn();
+ component.registerOnChange(onChange);
+
+ await openGroupedPanel(fixture, component);
+
+ // Navigate to the second option (aws-2) and select it
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+
expect(component.getVisibleOptions()[component['_activeOptionIndex']].value).toBe('aws-2');
+
+ component.onSearchInputKeydown(createNavKeyEvent('Enter'));
+ fixture.detectChanges();
+
+ expect(onChange).toHaveBeenLastCalledWith(['aws-2']);
+ // Multi-select keeps the panel open for further selection
+ expect(component.select().panelOpen).toBe(true);
+
+ // Enter again while the same option remains the active nav
index -- toggles it off
+ component.onSearchInputKeydown(createNavKeyEvent('Enter'));
+ fixture.detectChanges();
+
+ expect(onChange).toHaveBeenLastCalledWith([]);
+ expect(component.select().panelOpen).toBe(true);
+ });
+ });
+
describe('Virtual scrolling with groups', () => {
+ function createNavKeyEvent(key: string): KeyboardEvent {
+ return {
+ key,
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ stopImmediatePropagation: vi.fn()
+ } as unknown as KeyboardEvent;
+ }
+
+ async function openVirtualGroupedPanel(
+ fixture: Awaited<ReturnType<typeof setupGrouped>>['fixture'],
+ component: Awaited<ReturnType<typeof
setupGrouped>>['component']
+ ) {
+ component.selectionPanelToggled(true);
+ component.select().open();
+ fixture.detectChanges();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ fixture.detectChanges();
+ }
+
it('should include group headers in virtual items', async () => {
const { component } = await setupGrouped({
enableVirtualScrolling: true });
@@ -1833,7 +2445,143 @@ describe('SearchableSelect', () => {
expect(component.trackByVirtualItem(0,
header)).toBe('__group-header-AWS Secrets Manager');
- expect(component.trackByVirtualItem(1, option)).toBe('aws-1');
+ expect(component.trackByVirtualItem(1, option)).toBe(option);
+ });
+
+ it('should navigate grouped virtual options in rendered group
order', async () => {
+ const interleavedOptions = [
+ { value: 'az-1', label: 'Azure 1', group: 'Azure Key
Vault' },
+ { value: 'aws-1', label: 'AWS 1', group: 'AWS Secrets
Manager' },
+ { value: 'plain', label: 'Ungrouped' },
+ { value: 'az-2', label: 'Azure 2', group: 'Azure Key
Vault' }
+ ];
+ const { component, fixture } = await setupGrouped({
+ options: interleavedOptions,
+ enableVirtualScrolling: true
+ });
+
+ await openVirtualGroupedPanel(fixture, component);
+
+ // Rendering puts ungrouped options first, then groups
alphabetically.
+ expect(component.getVisibleOptions().map((option) =>
option.value)).toEqual([
+ 'plain',
+ 'aws-1',
+ 'az-1',
+ 'az-2'
+ ]);
+
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ expect(component['activeOptionRef']()?.value).toBe('plain');
+
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ expect(component['activeOptionRef']()?.value).toBe('aws-1');
+
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ expect(component['activeOptionRef']()?.value).toBe('az-1');
+ });
+
+ it('should assign activeOptionId to exactly one virtual row when
groups share a value', async () => {
+ const duplicateValueOptions = [
+ { value: 'dup', label: 'In AWS', group: 'AWS Secrets
Manager' },
+ { value: 'dup', label: 'In Azure', group: 'Azure Key
Vault' }
+ ];
+ const { component, fixture } = await setupGrouped({
+ options: duplicateValueOptions,
+ enableVirtualScrolling: true
+ });
+
+ await openVirtualGroupedPanel(fixture, component);
+
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+
+ expect(component['activeOptionRef']()?.label).toBe('In Azure');
+ const overlayContainer =
document.querySelector('.cdk-overlay-container');
+ const activeOptions =
overlayContainer?.querySelectorAll(`#${component.activeOptionId}`) ?? [];
+ expect(activeOptions.length).toBe(1);
+ expect(activeOptions[0].textContent).toContain('In Azure');
+ });
+
+ it('should select the highlighted duplicate-value virtual row with
Enter', async () => {
+ const duplicateValueOptions = [
+ { value: 'dup', label: 'In AWS', group: 'AWS Secrets
Manager' },
+ { value: 'dup', label: 'In Azure', group: 'Azure Key
Vault' }
+ ];
+ const { component, fixture } = await setupGrouped({
+ options: duplicateValueOptions,
+ enableVirtualScrolling: true
+ });
+ const onChange = vi.fn();
+ component.registerOnChange(onChange);
+
+ await openVirtualGroupedPanel(fixture, component);
+
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ expect(component['activeOptionRef']()?.label).toBe('In Azure');
+
+ component.onSearchInputKeydown(createNavKeyEvent('Enter'));
+ fixture.detectChanges();
+
+ expect(onChange).toHaveBeenCalledWith('dup');
+ });
+
+ it('should toggle the same duplicate-value virtual row twice in
multi-select via Enter', async () => {
+ const duplicateValueOptions = [
+ { value: 'dup', label: 'In AWS', group: 'AWS Secrets
Manager' },
+ { value: 'dup', label: 'In Azure', group: 'Azure Key
Vault' }
+ ];
+ const { component, fixture } = await setupGrouped({
+ options: duplicateValueOptions,
+ enableVirtualScrolling: true,
+ multiple: true
+ });
+ const onChange = vi.fn();
+ component.registerOnChange(onChange);
+
+ await openVirtualGroupedPanel(fixture, component);
+
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ const highlighted = component['activeOptionRef']();
+ expect(highlighted?.label).toBe('In Azure');
+
+ component.onSearchInputKeydown(createNavKeyEvent('Enter'));
+ fixture.detectChanges();
+ expect(onChange).toHaveBeenLastCalledWith(['dup']);
+ expect(component['activeOptionRef']()).toBe(highlighted);
+
+ component.onSearchInputKeydown(createNavKeyEvent('Enter'));
+ fixture.detectChanges();
+ expect(onChange).toHaveBeenLastCalledWith([]);
+ expect(component['activeOptionRef']()).toBe(highlighted);
+ expect(component.select().panelOpen).toBe(true);
+ });
+
+ it('should retain active identity while scrolling a grouped
virtual option outside the rendered buffer', async () => {
+ const manyOptions = Array.from({ length: 30 }, (_, index) => ({
+ value: `value-${index}`,
+ label: `Option ${index}`,
+ group: index % 2 === 0 ? 'AWS Secrets Manager' : 'Azure
Key Vault'
+ }));
+ const { component, fixture } = await setupGrouped({
+ options: manyOptions,
+ enableVirtualScrolling: true
+ });
+
+ await openVirtualGroupedPanel(fixture, component);
+
+ const viewport = component.virtualScrollViewport();
+ expect(viewport).toBeTruthy();
+ const scrollToOffsetSpy = vi.spyOn(viewport!,
'scrollToOffset');
+
+ for (let i = 0; i < 20; i++) {
+
component.onSearchInputKeydown(createNavKeyEvent('ArrowDown'));
+ }
+
+ const activeOption =
component.getVisibleOptions()[component['_activeOptionIndex']];
+ expect(component['activeOptionRef']()).toBe(activeOption);
+ expect(scrollToOffsetSpy).toHaveBeenCalled();
});
});
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/searchable-select/searchable-select.component.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/components/searchable-select/searchable-select.component.ts
index 64c2cbf28b8..222385a81fb 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/searchable-select/searchable-select.component.ts
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/searchable-select/searchable-select.component.ts
@@ -208,6 +208,11 @@ export class SearchableSelect<T = never> implements
ControlValueAccessor, OnInit
protected activeTemplateIndex = signal<number>(-1);
+ // Reference identity of the currently active option, used by grouped
rendering to mark exactly
+ // one option active. Reference-based (not value-based) so two options
that share a value can
+ // never both receive activeOptionId, which would produce a duplicate DOM
id.
+ protected activeOptionRef = signal<FilteredSearchableSelectOption<T> |
null>(null);
+
private filteredOptionsEffect = effect(() => {
void this.filteredOptions();
this.updateActiveTemplateIndex();
@@ -360,8 +365,11 @@ export class SearchableSelect<T = never> implements
ControlValueAccessor, OnInit
this.searchInput().nativeElement.focus();
this.startOverlayAutoReposition();
}, 0);
+ // Reset keyboard navigation state (including activeOptionRef via
updateActiveTemplateIndex)
this._activeOptionIndex = -1;
this._isNavigating = false;
+ this.updateActiveDescendant();
+ this.updateActiveTemplateIndex();
}
if (!open) {
this.stopOverlayAutoReposition();
@@ -372,8 +380,13 @@ export class SearchableSelect<T = never> implements
ControlValueAccessor, OnInit
this.resetFilter();
}
this.select().focus();
+ // Reset navigation state when closing
this._activeOptionIndex = -1;
this._isNavigating = false;
+ // Defense in depth: any arm-without-consume path (e.g. Enter on
an already-selected
+ // option, which emits no valueChange) must not leak across panel
close into the
+ // closed-panel Material arrow auto-select ACCEPT path.
+ this._allowNextValueChange = false;
this.updateActiveDescendant();
this.updateActiveTemplateIndex();
}
@@ -525,8 +538,16 @@ export class SearchableSelect<T = never> implements
ControlValueAccessor, OnInit
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
- this.select().close();
+ // Clear nav state here (not only on openedChange): close() may
not emit openedChange
+ // in all test/harness paths, and a partial index-only reset left
_isNavigating /
+ // activeOptionRef stale. Disarm the value-change permission for
the same reason so it
+ // cannot leak into Material's closed-panel arrow auto-select path.
this._activeOptionIndex = -1;
+ this._isNavigating = false;
+ this._allowNextValueChange = false;
+ this.updateActiveDescendant();
+ this.updateActiveTemplateIndex();
+ this.select().close();
}
}
@@ -682,9 +703,10 @@ export class SearchableSelect<T = never> implements
ControlValueAccessor, OnInit
}
this._isNavigating = true;
- this.scrollToActiveOption();
this.updateActiveDescendant();
this.updateActiveTemplateIndex();
+ this.cdr.detectChanges();
+ this.scrollToActiveOption();
}
private handlePageNavigation(key: string) {
@@ -722,9 +744,10 @@ export class SearchableSelect<T = never> implements
ControlValueAccessor, OnInit
}
this._isNavigating = true;
- this.scrollToActiveOption();
this.updateActiveDescendant();
this.updateActiveTemplateIndex();
+ this.cdr.detectChanges();
+ this.scrollToActiveOption();
}
private handleEnterKey() {
@@ -732,9 +755,24 @@ export class SearchableSelect<T = never> implements
ControlValueAccessor, OnInit
const visibleOptions = this.getVisibleOptions();
const activeOption = visibleOptions[this._activeOptionIndex];
if (activeOption && !activeOption.disabled) {
+ // When toggleOption() triggers mat-select to change the
value, onValueChanged() will fire.
+ // Since we set this flag, onValueChanged() will know this is
an intentional change.
this._allowNextValueChange = true;
- this._isNavigating = false;
this.toggleOption(activeOption.value);
+ // Re-selecting the already-selected option emits no
mat-select value change, so
+ // onValueChanged never runs to consume the permission.
Leaving it armed would let
+ // the next arrow-key auto-selection be accepted as
intentional. onValueChanged
+ // already disarms it when a real change fires, so clearing
here is idempotent.
+ this._allowNextValueChange = false;
+ // Multi-select keeps the panel open and must retain highlight
identity so a second
+ // Enter (toggle-off) still resolves via activeOptionId /
activeOptionRef rather than
+ // an ambiguous value-based QueryList find when duplicates
exist across groups.
+ // Single-select closes the panel;
selectionPanelToggled(false) clears nav state.
+ if (!this.multiple()) {
+ this._isNavigating = false;
+ this.updateActiveDescendant();
+ this.updateActiveTemplateIndex();
+ }
}
}
}
@@ -761,7 +799,8 @@ export class SearchableSelect<T = never> implements
ControlValueAccessor, OnInit
}
}
} else {
- const activeOptionElement =
document.getElementById(this.getOptionId(this._activeOptionIndex));
+ // Non-virtual mode - scroll the active option into view using
standard DOM scrolling
+ const activeOptionElement =
document.getElementById(this.activeOptionId);
if (activeOptionElement) {
activeOptionElement.scrollIntoView({
behavior: 'smooth',
@@ -791,14 +830,18 @@ export class SearchableSelect<T = never> implements
ControlValueAccessor, OnInit
private toggleOption(value: T) {
if (this.enableVirtualScrolling()) {
+ // Grouped virtual lists navigate by option reference so duplicate
values still resolve
+ // to the highlighted row. The form value remains the option
value, as with every other
+ // searchable-select mode.
+ const activeValue = this.activeOptionRef()?.value ?? value;
if (this.multiple()) {
const currentValues = [...this._virtualSelectedValues];
- const index = currentValues.indexOf(value);
+ const index = currentValues.indexOf(activeValue);
if (index > -1) {
currentValues.splice(index, 1);
} else {
- currentValues.push(value);
+ currentValues.push(activeValue);
}
this.updateVirtualSelectedValues(currentValues);
@@ -806,7 +849,7 @@ export class SearchableSelect<T = never> implements
ControlValueAccessor, OnInit
this.emitIfChanged(formValue);
this.syncMatSelectValue();
} else {
- const newValue = this._virtualSelectedValues.includes(value) ?
null : value;
+ const newValue =
this._virtualSelectedValues.includes(activeValue) ? null : activeValue;
this.updateVirtualSelectedValues(newValue ? [newValue] : []);
this.emitIfChanged(newValue);
this.syncMatSelectValue();
@@ -815,51 +858,40 @@ export class SearchableSelect<T = never> implements
ControlValueAccessor, OnInit
this.cdr.detectChanges();
} else {
- const visibleOptions = this.getVisibleOptions();
- const activeOptionWithIndex = visibleOptions[
- this._activeOptionIndex
- ] as FilteredSearchableSelectOption<T> & { templateIndex?: number
};
-
- const templateIndex = activeOptionWithIndex?.templateIndex ??
this._activeOptionIndex;
- const activeOptionId = this.getOptionId(templateIndex);
- let optionElement = document.getElementById(activeOptionId);
-
- if (!optionElement) {
- const allOptions =
document.querySelectorAll('multi-select-option');
- allOptions.forEach((element) => {
- const htmlElement = element as HTMLElement & { value: T };
- if (htmlElement.value === value) {
- optionElement = element as HTMLElement;
- }
- });
- }
-
- if (optionElement) {
- const clickEvent = new MouseEvent('click', {
- view: window,
- bubbles: true,
- cancelable: true
- });
- optionElement.dispatchEvent(clickEvent);
+ // Non-virtual mode: select through MatSelect's MatOption
instances. This mirrors a
+ // real user click (MatOption binds
`(click)="_selectViaInteraction()"`), so it toggles
+ // correctly for multi-select and drives the same (valueChange) ->
onValueChanged() flow,
+ // without depending on our own flat/group id scheme.
+ //
+ // Resolve order (duplicate-value safe):
+ // 1. Unique active id (`opt.id === activeOptionId`) while
navigating
+ // 2. activeOptionRef -> visible-index -> non-ghost MatOption
(reference identity)
+ // 3. Enabled, non-ghost value match (last resort; ambiguous when
values duplicate)
+ //
+ // `_selectViaInteraction` is a private Angular Material API, but
it is the canonical
+ // "user selected this option" entry point; the public
`select()`/`deselect()` skip the
+ // isUserInput signal and the multi-select toggle semantics we
need here.
+ // Material/CDK bumps must re-verify this Enter path;
searchable-select Enter specs are
+ // the tripwire if the private method disappears or changes.
+ const options = this.select().options;
+ const nonGhostOptions = options.filter((opt) =>
!opt._getHostElement().classList.contains('ghost-option'));
+ const activeRef = this.activeOptionRef();
+ const activeRefIndex = activeRef ?
this.getVisibleOptions().indexOf(activeRef) : -1;
+ const match =
+ options.find((opt) => opt.id === this.activeOptionId) ??
+ (activeRefIndex >= 0 ? nonGhostOptions[activeRefIndex] :
undefined) ??
+ nonGhostOptions.find((opt) => opt.value === value &&
!opt.disabled);
+
+ if (match) {
+ match._selectViaInteraction();
+ } else {
+ // Nothing resolved (e.g. options not yet rendered): don't
leave the permission
+ // flag armed, or a later value change could be accepted
unintentionally.
+ this._allowNextValueChange = false;
}
}
}
- isOptionActive(index: number): boolean {
- if (!this._isNavigating) return false;
-
- if (this.enableVirtualScrolling()) {
- return this._activeOptionIndex === index;
- } else {
- const visibleOptions = this.getVisibleOptions();
- const activeOption = visibleOptions[this._activeOptionIndex] as
FilteredSearchableSelectOption<T> & {
- templateIndex?: number;
- };
- const templateIndex = activeOption?.templateIndex;
- return templateIndex === index;
- }
- }
-
isOptionActiveByValue(value: T): boolean {
if (!this._isNavigating || this._activeOptionIndex < 0) {
return false;
@@ -875,6 +907,11 @@ export class SearchableSelect<T = never> implements
ControlValueAccessor, OnInit
*/
getVisibleOptions(): FilteredSearchableSelectOption<T>[] {
if (this.enableVirtualScrolling()) {
+ if (this.hasGroupedOptions()) {
+ const groups = this.groupedOptions();
+ return groups ? groups.flatMap((group) => group.options) : [];
+ }
+
const searchTerm = this._searchString?.toLowerCase();
if (!searchTerm || this.asyncSearchEnabled()) {
@@ -987,14 +1024,16 @@ export class SearchableSelect<T = never> implements
ControlValueAccessor, OnInit
return option.value;
};
- trackByVirtualItem = (index: number, item: VirtualItem<T>): T | string => {
+ trackByVirtualItem = (index: number, item: VirtualItem<T>):
FilteredSearchableSelectOption<T> | T | string => {
if (this.isFooterItem(item)) {
return `__footer-${(item as FooterItem).__kind}`;
}
if (this.isGroupHeaderItem(item)) {
return `__group-header-${(item as GroupHeaderItem).groupId}`;
}
- return (item as FilteredSearchableSelectOption<T>).value;
+ // Option values are not guaranteed unique across groups. The option
objects are stable
+ // within a rendered batch and preserve row identity for CDK
virtual-scroll diffing.
+ return item as FilteredSearchableSelectOption<T>;
};
isFooterItem(item: VirtualItem<T>): item is FooterItem {
@@ -1017,6 +1056,19 @@ export class SearchableSelect<T = never> implements
ControlValueAccessor, OnInit
return item as FilteredSearchableSelectOption<T>;
}
+ isVirtualOptionActive(item: VirtualItem<T>): boolean {
+ const option = this.asOptionItem(item);
+ if (!option) {
+ return false;
+ }
+
+ if (this.hasGroupedOptions()) {
+ return this._isNavigating && this.activeOptionRef() === option;
+ }
+
+ return this.isOptionActiveByValue(option.value);
+ }
+
getItemValue(item: VirtualItem<T>): T {
const opt = this.asOptionItem(item);
return opt ? opt.value : (undefined as unknown as T);
@@ -1092,6 +1144,15 @@ export class SearchableSelect<T = never> implements
ControlValueAccessor, OnInit
}
private updateActiveTemplateIndex(): void {
+ // Maintain the active option reference used by grouped rendering.
Reference identity marks
+ // exactly one option active even if two options share a value.
+ if (this._isNavigating && this._activeOptionIndex >= 0 &&
this.hasGroupedOptions()) {
+ const visible = this.getVisibleOptions();
+ this.activeOptionRef.set(visible[this._activeOptionIndex] ?? null);
+ } else {
+ this.activeOptionRef.set(null);
+ }
+
if (!this._isNavigating) {
this.activeTemplateIndex.set(-1);
return;