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;

Reply via email to