Skip to content

Commit d5d51a7

Browse files
authored
fix(eds-core-react): 🐛 Autocomplete - should start at selected index and preserve scroll position (#3996)
* fix(eds-core-react): 🐛 Autocomplete - enhance component with controlled highlighted index and improved scrolling behavior starting at selected index * test(eds-core-react): ✅ Update Autocomplete snapshot to reflect changes in selected item styling and structure * fix(eds-core-react): 🚨 Fixed lint error, missing brackets * fix(eds-core-react): ♻️ re-added existing update for selected items on controlled prop change after copilot review * test(eds-core-react): ✅ Add comprehensive tests for autocomplete scroll position and navigation memory - Add 5 new test cases covering scroll position restoration for single-select mode - Test selected option visibility when reopening dropdown - Test keyboard navigation from preselected option position - Test empty selection handling and multiselect compatibility - Test behavior preservation across open/close cycles - All tests pass and validate PR #3996 functionality * test(eds-core-react): 📸 Update Autocomplete snapshot after dependency updates - Update snapshot to reflect changes from main branch dependency updates - All 27 tests passing including 5 new scroll position tests - Resolves snapshot conflicts from rebase * style(eds-core-react): 💄 Fix Prettier formatting in Autocomplete tests - Fix formatting issues identified by lint:all - Resolve all Prettier errors in test file - No functional changes to tests * test(eds-core-react): 📸 Update Autocomplete snapshot for Jest URL and attribute order - Update Jest snapshot header URL - Fix minor HTML attribute ordering differences - All 27 tests passing including scroll position tests * test(eds-core-react): 📸 Update Autocomplete snapshot after rebase to latest main - Rebase onto latest main (3 new commits) - Update snapshot to match latest dependencies - All 27 tests passing including scroll position tests
1 parent 3950502 commit d5d51a7

3 files changed

Lines changed: 270 additions & 25 deletions

File tree

packages/eds-core-react/src/components/Autocomplete/Autocomplete.test.tsx

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,3 +735,179 @@ describe('Autocomplete: Add new options feature', () => {
735735
expect(addOption).not.toBeInTheDocument()
736736
})
737737
})
738+
739+
describe('Autocomplete: Scroll position and navigation memory', () => {
740+
const longItemsList = Array.from({ length: 20 }, (_, i) => `Option ${i + 1}`)
741+
const uniqueLabelText = 'Scroll test autocomplete'
742+
743+
it('Maintains selected option when reopening single-select', async () => {
744+
const AutocompleteWithState = () => {
745+
const [selectedItem, setSelectedItem] = useState<string | null>(null)
746+
747+
return (
748+
<Autocomplete
749+
label={uniqueLabelText}
750+
options={longItemsList}
751+
multiple={false}
752+
selectedOptions={selectedItem ? [selectedItem] : []}
753+
onOptionsChange={(changes) => {
754+
if (changes.selectedItems.length > 0) {
755+
setSelectedItem(changes.selectedItems[0])
756+
}
757+
}}
758+
/>
759+
)
760+
}
761+
762+
render(<AutocompleteWithState />)
763+
const input = screen.getByRole('combobox')
764+
765+
// Open autocomplete and select option 5
766+
fireEvent.click(input)
767+
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(20))
768+
769+
for (let i = 0; i < 4; i++) {
770+
fireEvent.keyDown(input, { key: 'ArrowDown' })
771+
}
772+
fireEvent.keyDown(input, { key: 'Enter' })
773+
774+
await waitFor(() =>
775+
expect(screen.queryByRole('option')).not.toBeInTheDocument(),
776+
)
777+
expect(input).toHaveValue('Option 5')
778+
779+
// Reopen - should maintain selection
780+
fireEvent.click(input)
781+
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(20))
782+
expect(input).toHaveValue('Option 5')
783+
})
784+
785+
it('Keyboard navigation works correctly with preselected option', async () => {
786+
render(
787+
<Autocomplete
788+
label={uniqueLabelText}
789+
options={longItemsList}
790+
multiple={false}
791+
selectedOptions={['Option 5']}
792+
/>,
793+
)
794+
795+
const input = screen.getByRole('combobox')
796+
expect(input).toHaveValue('Option 5')
797+
798+
fireEvent.click(input)
799+
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(20))
800+
801+
// Wait for scroll restoration (component uses 10ms setTimeout for scroll positioning)
802+
await new Promise((resolve) => setTimeout(resolve, 50))
803+
804+
// Get current highlighted option ID
805+
const currentHighlightedId = input.getAttribute('aria-activedescendant')
806+
expect(currentHighlightedId).toBeTruthy()
807+
808+
// Verify the selected option (Option 5) is visible in the dropdown
809+
expect(screen.getByRole('option', { name: 'Option 5' })).toBeDefined()
810+
811+
// Test arrow down navigation from current position
812+
fireEvent.keyDown(input, { key: 'ArrowDown' })
813+
814+
await waitFor(() => {
815+
const newHighlightedId = input.getAttribute('aria-activedescendant')
816+
expect(newHighlightedId).not.toBe(currentHighlightedId)
817+
})
818+
819+
// Test arrow up navigation goes back
820+
fireEvent.keyDown(input, { key: 'ArrowUp' })
821+
await waitFor(() => {
822+
expect(input.getAttribute('aria-activedescendant')).toBe(
823+
currentHighlightedId,
824+
)
825+
})
826+
})
827+
828+
it('Handles empty selection correctly', async () => {
829+
render(
830+
<Autocomplete
831+
label={uniqueLabelText}
832+
options={longItemsList}
833+
multiple={false}
834+
/>,
835+
)
836+
837+
const input = screen.getByRole('combobox')
838+
839+
fireEvent.click(input)
840+
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(20))
841+
expect(input).toHaveAttribute('aria-activedescendant')
842+
843+
fireEvent.keyDown(input, { key: 'Escape' })
844+
await waitFor(() =>
845+
expect(screen.queryByRole('option')).not.toBeInTheDocument(),
846+
)
847+
848+
fireEvent.click(input)
849+
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(20))
850+
expect(input).toHaveAttribute('aria-activedescendant')
851+
})
852+
853+
it('Multiselect behavior remains unchanged', async () => {
854+
render(
855+
<Autocomplete
856+
label={uniqueLabelText}
857+
options={longItemsList}
858+
multiple={true}
859+
/>,
860+
)
861+
862+
const input = screen.getByRole('combobox')
863+
864+
fireEvent.click(input)
865+
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(20))
866+
867+
fireEvent.keyDown(input, { key: 'ArrowDown' })
868+
fireEvent.keyDown(input, { key: 'Enter' })
869+
fireEvent.keyDown(input, { key: 'ArrowDown' })
870+
fireEvent.keyDown(input, { key: 'Enter' })
871+
872+
expect(screen.getAllByRole('option')).toHaveLength(20)
873+
expect(input).toHaveAttribute('placeholder', '2/20 selected')
874+
})
875+
876+
it('Preserves behavior across open/close cycles', async () => {
877+
const AutocompleteWithState = () => {
878+
const [selectedItem, setSelectedItem] = useState<string>('Option 8')
879+
880+
return (
881+
<Autocomplete
882+
label={uniqueLabelText}
883+
options={longItemsList}
884+
multiple={false}
885+
selectedOptions={[selectedItem]}
886+
onOptionsChange={(changes) => {
887+
if (changes.selectedItems.length > 0) {
888+
setSelectedItem(changes.selectedItems[0])
889+
}
890+
}}
891+
/>
892+
)
893+
}
894+
895+
render(<AutocompleteWithState />)
896+
const input = screen.getByRole('combobox')
897+
898+
expect(input).toHaveValue('Option 8')
899+
900+
fireEvent.click(input)
901+
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(20))
902+
903+
fireEvent.keyDown(input, { key: 'Escape' })
904+
await waitFor(() =>
905+
expect(screen.queryByRole('option')).not.toBeInTheDocument(),
906+
)
907+
908+
fireEvent.click(input)
909+
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(20))
910+
911+
expect(input).toHaveValue('Option 8')
912+
})
913+
})

packages/eds-core-react/src/components/Autocomplete/Autocomplete.tsx

Lines changed: 93 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,9 @@ function AutocompleteInner<T>(
340340
props: AutocompleteProps<T>,
341341
ref: React.ForwardedRef<HTMLInputElement>,
342342
) {
343+
const [controlledHighlightedIndex, setControlledHighlightedIndex] =
344+
useState<number>(0)
345+
const [lastScrollOffset, setLastScrollOffset] = useState<number>(0)
343346
const {
344347
options = [],
345348
totalOptions,
@@ -435,6 +438,14 @@ function AutocompleteInner<T>(
435438
return _availableItems
436439
}, [_availableItems, showSelectAll, onAddNewOption])
437440

441+
const getSelectedIndex = useCallback(
442+
(selectedItem: (typeof availableItems)[0] | null) =>
443+
availableItems.findIndex((item) =>
444+
itemCompare ? itemCompare(item, selectedItem) : item === selectedItem,
445+
),
446+
[availableItems, itemCompare],
447+
)
448+
438449
//issue 2304, update dataset when options are added dynamically
439450
useEffect(() => {
440451
const availableHash = JSON.stringify(inputOptions)
@@ -606,32 +617,38 @@ function AutocompleteInner<T>(
606617
}),
607618
)
608619
},
609-
onHighlightedIndexChange({ highlightedIndex, type }) {
610-
if (
611-
type == useCombobox.stateChangeTypes.InputClick ||
612-
(type == useCombobox.stateChangeTypes.InputKeyDownArrowDown &&
613-
!isOpen) ||
614-
(type == useCombobox.stateChangeTypes.InputKeyDownArrowUp && !isOpen)
615-
) {
616-
//needs delay for dropdown to render before calling scroll
617-
setTimeout(() => {
620+
onHighlightedIndexChange({ highlightedIndex }) {
621+
if (highlightedIndex >= 0 && rowVirtualizer.getVirtualItems) {
622+
const visibleIndexes = rowVirtualizer
623+
.getVirtualItems()
624+
.map((v) => v.index)
625+
if (!visibleIndexes.includes(highlightedIndex)) {
618626
rowVirtualizer.scrollToIndex(highlightedIndex, {
619627
align: allowSelectAll ? 'center' : 'auto',
620628
})
621-
}, 1)
622-
} else if (
623-
type !== useCombobox.stateChangeTypes.ItemMouseMove &&
624-
type !== useCombobox.stateChangeTypes.MenuMouseLeave &&
625-
highlightedIndex >= 0
626-
) {
627-
rowVirtualizer.scrollToIndex(highlightedIndex, {
628-
align: allowSelectAll ? 'center' : 'auto',
629-
})
629+
}
630+
}
631+
if (typeof rowVirtualizer.scrollOffset === 'number') {
632+
setLastScrollOffset(rowVirtualizer.scrollOffset)
630633
}
631634
},
632635
onIsOpenChange: ({ selectedItem }) => {
633636
if (!multiple && selectedItem !== null) {
634637
setAvailableItems(options)
638+
setTimeout(() => {
639+
if (controlledHighlightedIndex === 0) {
640+
rowVirtualizer.scrollToOffset?.(0)
641+
} else if (rowVirtualizer.scrollToOffset && lastScrollOffset > 0) {
642+
rowVirtualizer.scrollToOffset(lastScrollOffset)
643+
}
644+
const visibleIndexes =
645+
rowVirtualizer.getVirtualItems?.().map((v) => v.index) || []
646+
if (!visibleIndexes.includes(controlledHighlightedIndex)) {
647+
rowVirtualizer.scrollToIndex(controlledHighlightedIndex, {
648+
align: allowSelectAll ? 'center' : 'auto',
649+
})
650+
}
651+
}, 10)
635652
}
636653
},
637654
onStateChange: ({ type, selectedItem }) => {
@@ -673,6 +690,8 @@ function AutocompleteInner<T>(
673690
...comboBoxProps,
674691
onSelectedItemChange: (changes) => {
675692
if (changes.selectedItem === AddSymbol) return
693+
const idx = getSelectedIndex(changes.selectedItem)
694+
setControlledHighlightedIndex(idx >= 0 ? idx : 0)
676695
if (onOptionsChange) {
677696
let { selectedItem } = changes
678697
if (itemCompare) {
@@ -685,25 +704,30 @@ function AutocompleteInner<T>(
685704
})
686705
}
687706
},
688-
stateReducer: (_, actionAndChanges) => {
707+
stateReducer: (state, actionAndChanges) => {
689708
const { changes, type } = actionAndChanges
690709
switch (type) {
691710
case useCombobox.stateChangeTypes.InputClick:
692711
return {
693712
...changes,
694713
isOpen: !(disabled || readOnly),
714+
highlightedIndex: controlledHighlightedIndex,
695715
}
696716
case useCombobox.stateChangeTypes.InputKeyDownEnter:
697-
case useCombobox.stateChangeTypes.ItemClick:
717+
case useCombobox.stateChangeTypes.ItemClick: {
698718
if (changes.selectedItem === AddSymbol) {
699719
return {
700720
...changes,
701721
inputValue: '',
702722
}
703723
}
724+
const idx = getSelectedIndex(changes.selectedItem)
725+
setControlledHighlightedIndex(idx >= 0 ? idx : 0)
704726
return {
705727
...changes,
728+
highlightedIndex: idx >= 0 ? idx : 0,
706729
}
730+
}
707731
case useCombobox.stateChangeTypes.InputBlur:
708732
return {
709733
...changes,
@@ -717,43 +741,88 @@ function AutocompleteInner<T>(
717741
...changes,
718742
}
719743
case useCombobox.stateChangeTypes.InputKeyDownArrowDown:
720-
case useCombobox.stateChangeTypes.InputKeyDownHome:
721744
if (readOnly) {
722745
return {
723746
...changes,
724747
isOpen: false,
725748
}
726749
}
750+
if (state.isOpen === false) {
751+
return {
752+
...changes,
753+
isOpen: true,
754+
highlightedIndex: controlledHighlightedIndex,
755+
}
756+
}
727757
return {
728758
...changes,
729-
highlightedIndex: findNextIndex<T>({
759+
highlightedIndex: findNextIndex({
730760
index: changes.highlightedIndex,
731761
availableItems,
732762
optionDisabled,
733763
allDisabled,
734764
}),
735765
}
766+
case useCombobox.stateChangeTypes.InputKeyDownHome:
767+
if (readOnly) {
768+
return {
769+
...changes,
770+
isOpen: false,
771+
}
772+
}
773+
return {
774+
...changes,
775+
highlightedIndex: findNextIndex({
776+
index: 0,
777+
availableItems,
778+
optionDisabled,
779+
allDisabled,
780+
}),
781+
}
736782
case useCombobox.stateChangeTypes.InputKeyDownArrowUp:
737-
case useCombobox.stateChangeTypes.InputKeyDownEnd:
738783
if (readOnly) {
739784
return {
740785
...changes,
741786
isOpen: false,
742787
}
743788
}
789+
if (state.isOpen === false) {
790+
return {
791+
...changes,
792+
isOpen: true,
793+
highlightedIndex: controlledHighlightedIndex,
794+
}
795+
}
744796
return {
745797
...changes,
746-
highlightedIndex: findPrevIndex<T>({
798+
highlightedIndex: findPrevIndex({
747799
index: changes.highlightedIndex,
748800
availableItems,
749801
optionDisabled,
750802
allDisabled,
751803
}),
752804
}
805+
case useCombobox.stateChangeTypes.InputKeyDownEnd:
806+
if (readOnly) {
807+
return {
808+
...changes,
809+
isOpen: false,
810+
}
811+
}
812+
return {
813+
...changes,
814+
highlightedIndex: findPrevIndex({
815+
index: availableItems.length - 1,
816+
availableItems,
817+
optionDisabled,
818+
allDisabled,
819+
}),
820+
}
753821
case useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem:
754822
setSelectedItems([changes.selectedItem])
755823
return {
756824
...changes,
825+
highlightedIndex: controlledHighlightedIndex,
757826
}
758827
default:
759828
return changes

0 commit comments

Comments
 (0)