diff --git a/packages/autocomplete-core/src/__tests__/getInputProps.test.ts b/packages/autocomplete-core/src/__tests__/getInputProps.test.ts index 23befd84d..2f2bb2101 100644 --- a/packages/autocomplete-core/src/__tests__/getInputProps.test.ts +++ b/packages/autocomplete-core/src/__tests__/getInputProps.test.ts @@ -89,11 +89,21 @@ describe('getInputProps', () => { test('returns aria-controls with list ID when panel is open', () => { const { getInputProps, inputElement } = createPlayground( createAutocomplete, - { id: 'autocomplete', initialState: { isOpen: true } } + { + id: 'autocomplete', + initialState: { + isOpen: true, + collections: [ + createCollection({ + source: { sourceId: 'testSource' }, + }), + ], + }, + } ); const inputProps = getInputProps({ inputElement }); - expect(inputProps['aria-controls']).toEqual('autocomplete-list'); + expect(inputProps['aria-controls']).toEqual('autocomplete-testSource-list'); }); test('returns aria-labelledby with label ID', () => { @@ -669,6 +679,7 @@ describe('getInputProps', () => { initialState: { collections: [ createCollection({ + source: { sourceId: 'testSource' }, items: [{ label: '1' }], }), ], @@ -676,7 +687,7 @@ describe('getInputProps', () => { ...props, }); const item = document.createElement('div'); - item.setAttribute('id', 'autocomplete-item-0'); + item.setAttribute('id', 'autocomplete-testSource-item-0'); document.body.appendChild(item); return { ...playground, item }; diff --git a/packages/autocomplete-core/src/__tests__/getItemProps.test.ts b/packages/autocomplete-core/src/__tests__/getItemProps.test.ts index c1ef1c24e..7e46c082b 100644 --- a/packages/autocomplete-core/src/__tests__/getItemProps.test.ts +++ b/packages/autocomplete-core/src/__tests__/getItemProps.test.ts @@ -29,7 +29,7 @@ describe('getItemProps', () => { ...defaultItemProps, }); - expect(itemProps.id).toEqual('autocomplete-item-0'); + expect(itemProps.id).toEqual('autocomplete-testSource-item-0'); }); test('returns the role to option', () => { diff --git a/packages/autocomplete-core/src/__tests__/getRootProps.test.ts b/packages/autocomplete-core/src/__tests__/getRootProps.test.ts index 228f6afca..5a5fee2c6 100644 --- a/packages/autocomplete-core/src/__tests__/getRootProps.test.ts +++ b/packages/autocomplete-core/src/__tests__/getRootProps.test.ts @@ -1,3 +1,4 @@ +import { createCollection } from '../../../../test/utils'; import { createAutocomplete } from '../createAutocomplete'; describe('getRootProps', () => { @@ -60,11 +61,16 @@ describe('getRootProps', () => { id: 'autocomplete', initialState: { isOpen: true, + collections: [ + createCollection({ + source: { sourceId: 'testSource' }, + }), + ], }, }); const rootProps = autocomplete.getRootProps({}); - expect(rootProps['aria-owns']).toEqual('autocomplete-list'); + expect(rootProps['aria-owns']).toEqual('autocomplete-testSource-list'); }); test('returns label id in aria-labelledby', () => { diff --git a/packages/autocomplete-core/src/getPropGetters.ts b/packages/autocomplete-core/src/getPropGetters.ts index 0ac247456..90a03b1f9 100644 --- a/packages/autocomplete-core/src/getPropGetters.ts +++ b/packages/autocomplete-core/src/getPropGetters.ts @@ -16,7 +16,12 @@ import { GetRootProps, InternalAutocompleteOptions, } from './types'; -import { getActiveItem, isOrContainsNode, isSamsung } from './utils'; +import { + getActiveItem, + getAutocompleteElementId, + isOrContainsNode, + isSamsung, +} from './utils'; interface GetPropGettersOptions extends AutocompleteScopeApi { @@ -104,8 +109,15 @@ export function getPropGetters< role: 'combobox', 'aria-expanded': store.getState().isOpen, 'aria-haspopup': 'listbox', - 'aria-owns': store.getState().isOpen ? `${props.id}-list` : undefined, - 'aria-labelledby': `${props.id}-label`, + 'aria-owns': store.getState().isOpen + ? store + .getState() + .collections.map(({ source }) => + getAutocompleteElementId(props.id, 'list', source) + ) + .join(' ') + : undefined, + 'aria-labelledby': getAutocompleteElementId(props.id, 'label'), ...rest, }; }; @@ -180,12 +192,23 @@ export function getPropGetters< 'aria-autocomplete': 'both', 'aria-activedescendant': store.getState().isOpen && store.getState().activeItemId !== null - ? `${props.id}-item-${store.getState().activeItemId}` + ? getAutocompleteElementId( + props.id, + `item-${store.getState().activeItemId}`, + activeItem?.source + ) : undefined, - 'aria-controls': store.getState().isOpen ? `${props.id}-list` : undefined, - 'aria-labelledby': `${props.id}-label`, + 'aria-controls': store.getState().isOpen + ? store + .getState() + .collections.map(({ source }) => + getAutocompleteElementId(props.id, 'list', source) + ) + .join(' ') + : undefined, + 'aria-labelledby': getAutocompleteElementId(props.id, 'label'), value: store.getState().completion || store.getState().query, - id: `${props.id}-input`, + id: getAutocompleteElementId(props.id, 'input'), autoComplete: 'off', autoCorrect: 'off', autoCapitalize: 'off', @@ -241,29 +264,21 @@ export function getPropGetters< }; }; - const getAutocompleteId = (instanceId: string, sourceId?: number) => { - return typeof sourceId !== 'undefined' - ? `${instanceId}-${sourceId}` - : instanceId; - }; - - const getLabelProps: GetLabelProps = (providedProps) => { - const { sourceIndex, ...rest } = providedProps || {}; - + const getLabelProps: GetLabelProps = (rest) => { return { - htmlFor: `${getAutocompleteId(props.id, sourceIndex)}-input`, - id: `${getAutocompleteId(props.id, sourceIndex)}-label`, + htmlFor: getAutocompleteElementId(props.id, 'input'), + id: getAutocompleteElementId(props.id, 'label'), ...rest, }; }; const getListProps: GetListProps = (providedProps) => { - const { sourceIndex, ...rest } = providedProps || {}; + const { source, ...rest } = providedProps || {}; return { role: 'listbox', - 'aria-labelledby': `${getAutocompleteId(props.id, sourceIndex)}-label`, - id: `${getAutocompleteId(props.id, sourceIndex)}-list`, + 'aria-labelledby': getAutocompleteElementId(props.id, 'label'), + id: getAutocompleteElementId(props.id, 'list', source), ...rest, }; }; @@ -284,12 +299,14 @@ export function getPropGetters< }; const getItemProps: GetItemProps = (providedProps) => { - const { item, source, sourceIndex, ...rest } = providedProps; + const { item, source, ...rest } = providedProps; return { - id: `${getAutocompleteId(props.id, sourceIndex as number)}-item-${ - item.__autocomplete_id - }`, + id: getAutocompleteElementId( + props.id, + `item-${item.__autocomplete_id}`, + source + ), role: 'option', 'aria-selected': store.getState().activeItemId === item.__autocomplete_id, onMouseMove(event) { diff --git a/packages/autocomplete-core/src/onKeyDown.ts b/packages/autocomplete-core/src/onKeyDown.ts index c0b50a34c..64a1d4944 100644 --- a/packages/autocomplete-core/src/onKeyDown.ts +++ b/packages/autocomplete-core/src/onKeyDown.ts @@ -6,7 +6,7 @@ import { BaseItem, InternalAutocompleteOptions, } from './types'; -import { getActiveItem } from './utils'; +import { getActiveItem, getAutocompleteElementId } from './utils'; interface OnKeyDownOptions extends AutocompleteScopeApi { @@ -25,8 +25,14 @@ export function onKeyDown({ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { // eslint-disable-next-line no-inner-declarations function triggerScrollIntoView() { + const highlightedItem = getActiveItem(store.getState()); + const nodeItem = props.environment.document.getElementById( - `${props.id}-item-${store.getState().activeItemId}` + getAutocompleteElementId( + props.id, + `item-${store.getState().activeItemId}`, + highlightedItem?.source + ) ); if (nodeItem) { diff --git a/packages/autocomplete-core/src/utils/getAutocompleteElementId.ts b/packages/autocomplete-core/src/utils/getAutocompleteElementId.ts new file mode 100644 index 000000000..5dc69e01b --- /dev/null +++ b/packages/autocomplete-core/src/utils/getAutocompleteElementId.ts @@ -0,0 +1,19 @@ +import type { InternalAutocompleteSource } from '../types'; + +/** + * Returns a full element id for an autocomplete element. + * + * @param autocompleteInstanceId The id of the autocomplete instance + * @param elementId The specific element id + * @param source The source of the element, when it needs to be scoped + */ +export function getAutocompleteElementId( + autocompleteInstanceId: string, + elementId: string, + source?: InternalAutocompleteSource +) { + return [autocompleteInstanceId, source?.sourceId, elementId] + .filter(Boolean) + .join('-') + .replace(/\s/g, ''); +} diff --git a/packages/autocomplete-core/src/utils/index.ts b/packages/autocomplete-core/src/utils/index.ts index d30313d58..9f96652b5 100644 --- a/packages/autocomplete-core/src/utils/index.ts +++ b/packages/autocomplete-core/src/utils/index.ts @@ -4,6 +4,7 @@ export * from './createConcurrentSafePromise'; export * from './getNextActiveItemId'; export * from './getNormalizedSources'; export * from './getActiveItem'; +export * from './getAutocompleteElementId'; export * from './isOrContainsNode'; export * from './isSamsung'; export * from './mapToAlgoliaResponse'; diff --git a/packages/autocomplete-js/src/__tests__/detached.test.ts b/packages/autocomplete-js/src/__tests__/detached.test.ts index 01005d638..d39559210 100644 --- a/packages/autocomplete-js/src/__tests__/detached.test.ts +++ b/packages/autocomplete-js/src/__tests__/detached.test.ts @@ -80,7 +80,7 @@ describe('detached', () => { }); const firstItem = document.querySelector( - '#autocomplete-0-item-0' + '#autocomplete-testSource-item-0' )!; // Select the first item diff --git a/packages/autocomplete-js/src/__tests__/renderer.test.ts b/packages/autocomplete-js/src/__tests__/renderer.test.ts index cca9ed416..62697bcd8 100644 --- a/packages/autocomplete-js/src/__tests__/renderer.test.ts +++ b/packages/autocomplete-js/src/__tests__/renderer.test.ts @@ -221,15 +221,15 @@ describe('renderer', () => { data-autocomplete-source-id="testSource" >
  • 1 diff --git a/packages/autocomplete-js/src/__tests__/templates.test.tsx b/packages/autocomplete-js/src/__tests__/templates.test.tsx index 7a30cc91f..f5ba1a36f 100644 --- a/packages/autocomplete-js/src/__tests__/templates.test.tsx +++ b/packages/autocomplete-js/src/__tests__/templates.test.tsx @@ -92,15 +92,15 @@ describe('templates', () => { expect(within(panelContainer).getByRole('listbox')) .toMatchInlineSnapshot(`
    • { expect(within(panelContainer).getByRole('listbox')) .toMatchInlineSnapshot(`
      • {
        • {
          • div diff --git a/packages/autocomplete-js/src/render.tsx b/packages/autocomplete-js/src/render.tsx index 8b50cc80f..fac840d09 100644 --- a/packages/autocomplete-js/src/render.tsx +++ b/packages/autocomplete-js/src/render.tsx @@ -138,7 +138,7 @@ export function renderPanel( {...propGetters.getListProps({ state, props: autocomplete.getListProps({ - sourceIndex, + source, }), ...autocompleteScopeApi, })} @@ -147,7 +147,6 @@ export function renderPanel( const itemProps = autocomplete.getItemProps({ item, source, - sourceIndex, }); return ( diff --git a/packages/autocomplete-shared/src/core/AutocompletePropGetters.ts b/packages/autocomplete-shared/src/core/AutocompletePropGetters.ts index 3b84b410f..ecd3593de 100644 --- a/packages/autocomplete-shared/src/core/AutocompletePropGetters.ts +++ b/packages/autocomplete-shared/src/core/AutocompletePropGetters.ts @@ -57,10 +57,7 @@ export type GetFormProps = (props: { onReset(event: TEvent): void; }; -export type GetLabelProps = (props?: { - [key: string]: unknown; - sourceIndex?: number; -}) => { +export type GetLabelProps = (props?: { [key: string]: unknown }) => { htmlFor: string; id: string; }; @@ -101,7 +98,7 @@ export type GetPanelProps = (props?: { export type GetListProps = (props?: { [key: string]: unknown; - sourceIndex?: number; + source: InternalAutocompleteSource; }) => { role: 'listbox'; 'aria-labelledby': string;