diff --git a/change/@fluentui-react-combobox-0207f64c-2f2c-4b8f-b386-397ce38c1b95.json b/change/@fluentui-react-combobox-0207f64c-2f2c-4b8f-b386-397ce38c1b95.json new file mode 100644 index 00000000000000..6fb344ea7d380e --- /dev/null +++ b/change/@fluentui-react-combobox-0207f64c-2f2c-4b8f-b386-397ce38c1b95.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Exposed `activeDescendantImperativeRef` on Combobox, Dropdown, and Listbox", + "packageName": "@fluentui/react-combobox", + "email": "personal_dev@mkurr.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-combobox/library/etc/react-combobox.api.md b/packages/react-components/react-combobox/library/etc/react-combobox.api.md index fa49c97753a481..c6d4cdc298b5bb 100644 --- a/packages/react-components/react-combobox/library/etc/react-combobox.api.md +++ b/packages/react-components/react-combobox/library/etc/react-combobox.api.md @@ -49,6 +49,7 @@ export type ComboboxBaseProps = SelectionProps & HighlightedOptionProps & Pick

; }; // @public @@ -174,6 +175,7 @@ export type ListboxContextValues = { // @public export type ListboxProps = ComponentProps & SelectionProps & { disableAutoFocus?: boolean; + activeDescendantImperativeRef?: React_2.RefObject; }; // @public (undocumented) diff --git a/packages/react-components/react-combobox/library/src/components/Combobox/Combobox.test.tsx b/packages/react-components/react-combobox/library/src/components/Combobox/Combobox.test.tsx index bb9054a841c8a3..defae58103af28 100644 --- a/packages/react-components/react-combobox/library/src/components/Combobox/Combobox.test.tsx +++ b/packages/react-components/react-combobox/library/src/components/Combobox/Combobox.test.tsx @@ -8,6 +8,7 @@ import { isConformant } from '../../testing/isConformant'; import { resetIdsForTests } from '@fluentui/react-utilities'; import { comboboxClassNames } from './useComboboxStyles.styles'; import type { ComboboxProps } from '@fluentui/react-combobox'; +import type { ActiveDescendantImperativeRef } from '@fluentui/react-aria'; describe('Combobox', () => { beforeEach(() => { @@ -1197,4 +1198,26 @@ describe('Combobox', () => { expect(container.querySelector(`.${comboboxClassNames.expandIcon}`)).not.toBeInTheDocument(); }); }); + + describe('activeDescendantImperativeRef', () => { + it('passes activeDescendantImperativeRef through to useActiveDescendant', () => { + const imperativeRef = React.createRef(); + + const { getByRole } = render( + + + + + , + ); + + const firstId = imperativeRef.current?.first(); + const lastId = imperativeRef.current?.last(); + + expect(firstId).toBeTruthy(); + expect(lastId).toBeTruthy(); + expect(lastId).not.toBe(firstId); + expect(getByRole('combobox').getAttribute('aria-activedescendant')).toBe(lastId); + }); + }); }); diff --git a/packages/react-components/react-combobox/library/src/components/Combobox/useCombobox.tsx b/packages/react-components/react-combobox/library/src/components/Combobox/useCombobox.tsx index 3b133a321a05e8..2fa5ac1803ca63 100644 --- a/packages/react-components/react-combobox/library/src/components/Combobox/useCombobox.tsx +++ b/packages/react-components/react-combobox/library/src/components/Combobox/useCombobox.tsx @@ -46,6 +46,7 @@ export const useComboboxBase_unstable = ( controller: activeDescendantController, } = useActiveDescendant({ matchOption: isComboboxOptionElement, + imperativeRef: props.activeDescendantImperativeRef, }); const comboboxInternalState = useComboboxBaseState({ ...props, editable: true, activeDescendantController }); const { appearance: _appearance, size: _size, ...baseState } = comboboxInternalState; diff --git a/packages/react-components/react-combobox/library/src/components/Dropdown/Dropdown.test.tsx b/packages/react-components/react-combobox/library/src/components/Dropdown/Dropdown.test.tsx index 3897440821022c..b73debba41e909 100644 --- a/packages/react-components/react-combobox/library/src/components/Dropdown/Dropdown.test.tsx +++ b/packages/react-components/react-combobox/library/src/components/Dropdown/Dropdown.test.tsx @@ -8,6 +8,7 @@ import { isConformant } from '../../testing/isConformant'; import { resetIdsForTests } from '@fluentui/react-utilities'; import { dropdownClassNames } from './useDropdownStyles.styles'; import type { DropdownProps } from '@fluentui/react-combobox'; +import type { ActiveDescendantImperativeRef } from '@fluentui/react-aria'; describe('Dropdown', () => { beforeEach(() => { @@ -807,4 +808,26 @@ describe('Dropdown', () => { expect(activeOptionText).toBe('Red'); }); }); + + describe('activeDescendantImperativeRef', () => { + it('passes activeDescendantImperativeRef through to useActiveDescendant', () => { + const imperativeRef = React.createRef(); + + const { getByRole } = render( + + + + + , + ); + + const firstId = imperativeRef.current?.first(); + const lastId = imperativeRef.current?.last(); + + expect(firstId).toBeTruthy(); + expect(lastId).toBeTruthy(); + expect(lastId).not.toBe(firstId); + expect(getByRole('combobox').getAttribute('aria-activedescendant')).toBe(lastId); + }); + }); }); diff --git a/packages/react-components/react-combobox/library/src/components/Dropdown/useDropdown.tsx b/packages/react-components/react-combobox/library/src/components/Dropdown/useDropdown.tsx index eba6fd354d89b9..c581bd3c343743 100644 --- a/packages/react-components/react-combobox/library/src/components/Dropdown/useDropdown.tsx +++ b/packages/react-components/react-combobox/library/src/components/Dropdown/useDropdown.tsx @@ -40,6 +40,7 @@ export const useDropdownBase_unstable = ( controller: activeDescendantController, } = useActiveDescendant({ matchOption: isComboboxOptionElement, + imperativeRef: props.activeDescendantImperativeRef, }); const dropdownInternalState = useComboboxBaseState({ ...props, activeDescendantController, freeform: false }); diff --git a/packages/react-components/react-combobox/library/src/components/Listbox/Listbox.test.tsx b/packages/react-components/react-combobox/library/src/components/Listbox/Listbox.test.tsx index 2c5b2edd8c56e4..855e0d066d2502 100644 --- a/packages/react-components/react-combobox/library/src/components/Listbox/Listbox.test.tsx +++ b/packages/react-components/react-combobox/library/src/components/Listbox/Listbox.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render } from '@testing-library/react'; import { Listbox } from './Listbox'; import { Option } from '../Option/index'; import { isConformant } from '../../testing/isConformant'; +import type { ActiveDescendantImperativeRef } from '@fluentui/react-aria'; describe('Listbox', () => { isConformant({ @@ -376,4 +377,26 @@ describe('Listbox', () => { expect(getByTestId('red').getAttribute('aria-selected')).toEqual('true'); expect(onOptionSelect).toHaveBeenCalled(); }); + + describe('activeDescendantImperativeRef', () => { + it('passes activeDescendantImperativeRef through to useActiveDescendant', () => { + const imperativeRef = React.createRef(); + + const { getByRole } = render( + + + + + , + ); + + const firstId = imperativeRef.current?.first(); + const lastId = imperativeRef.current?.last(); + + expect(firstId).toBeTruthy(); + expect(lastId).toBeTruthy(); + expect(lastId).not.toBe(firstId); + expect(getByRole('listbox').getAttribute('aria-activedescendant')).toBe(lastId); + }); + }); }); diff --git a/packages/react-components/react-combobox/library/src/components/Listbox/Listbox.types.ts b/packages/react-components/react-combobox/library/src/components/Listbox/Listbox.types.ts index db505cbd6a0ece..c06759f313ed6a 100644 --- a/packages/react-components/react-combobox/library/src/components/Listbox/Listbox.types.ts +++ b/packages/react-components/react-combobox/library/src/components/Listbox/Listbox.types.ts @@ -1,3 +1,4 @@ +import type * as React from 'react'; import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; import type { ActiveDescendantChangeEvent, @@ -24,6 +25,13 @@ export type ListboxProps = ComponentProps & * @default false */ disableAutoFocus?: boolean; + + /** + * Imperative ref that lets you manually control the active descendant associated + * with the Listbox. Typical use case for this is if you need to programmatically + * focus an active descendant. + */ + activeDescendantImperativeRef?: React.RefObject; }; /** diff --git a/packages/react-components/react-combobox/library/src/components/Listbox/useListbox.ts b/packages/react-components/react-combobox/library/src/components/Listbox/useListbox.ts index 1cf0067c484999..083023b2afa9db 100644 --- a/packages/react-components/react-combobox/library/src/components/Listbox/useListbox.ts +++ b/packages/react-components/react-combobox/library/src/components/Listbox/useListbox.ts @@ -40,7 +40,7 @@ const UNSAFE_noLongerUsed = { * @param ref - reference to root HTMLElement of Listbox */ export const useListbox_unstable = (props: ListboxProps, ref: React.Ref): ListboxState => { - const { multiselect, disableAutoFocus = false } = props; + const { multiselect, disableAutoFocus = false, activeDescendantImperativeRef } = props; const optionCollection = useOptionCollection(); const { @@ -49,6 +49,7 @@ export const useListbox_unstable = (props: ListboxProps, ref: React.Ref({ matchOption: isComboboxOptionElement, + imperativeRef: activeDescendantImperativeRef, }); const hasListboxContext = useHasParentContext(ListboxContext); diff --git a/packages/react-components/react-combobox/library/src/utils/ComboboxBase.types.ts b/packages/react-components/react-combobox/library/src/utils/ComboboxBase.types.ts index d700c6100a9d47..0be184f3bf5155 100644 --- a/packages/react-components/react-combobox/library/src/utils/ComboboxBase.types.ts +++ b/packages/react-components/react-combobox/library/src/utils/ComboboxBase.types.ts @@ -1,5 +1,9 @@ import type * as React from 'react'; -import type { ActiveDescendantChangeEvent, ActiveDescendantContextValue } from '@fluentui/react-aria'; +import type { + ActiveDescendantChangeEvent, + ActiveDescendantContextValue, + ActiveDescendantImperativeRef, +} from '@fluentui/react-aria'; import type { PositioningShorthand } from '@fluentui/react-positioning'; import type { EventData, EventHandler } from '@fluentui/react-utilities'; import type { ComboboxContextValue } from '../contexts/ComboboxContext'; @@ -86,6 +90,13 @@ export type ComboboxBaseProps = SelectionProps & * Use this with `onOptionSelect` to directly control the displayed value string */ value?: string; + + /** + * Imperative ref that lets you manually control the active descendant associated + * with the Combobox. Typical use case for this is if you need to programmatically + * focus an active descendant. + */ + activeDescendantImperativeRef?: React.RefObject; }; /**