diff --git a/packages/react-components/react-aria/src/activedescendant/ActiveDescendantContext.ts b/packages/react-components/react-aria/src/activedescendant/ActiveDescendantContext.ts new file mode 100644 index 00000000000000..0e368e948cb9b8 --- /dev/null +++ b/packages/react-components/react-aria/src/activedescendant/ActiveDescendantContext.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { ActiveDescendantImperativeRef } from './types'; + +export type ActiveDescendantContextValue = { + controller: ActiveDescendantImperativeRef; +}; + +const noop = () => undefined; + +const activeDescendantContextDefaultValue: ActiveDescendantContextValue = { + controller: { + active: noop, + blur: noop, + find: noop, + first: noop, + focus: noop, + last: noop, + next: noop, + prev: noop, + }, +}; + +const ActiveDescendantContext = React.createContext(undefined); + +export const ActiveDescendantContextProvider = ActiveDescendantContext.Provider; +export const useActiveDescendantContext = () => + React.useContext(ActiveDescendantContext) ?? activeDescendantContextDefaultValue; diff --git a/packages/react-components/react-aria/src/activedescendant/index.ts b/packages/react-components/react-aria/src/activedescendant/index.ts index 7ef2e12290c938..11a98c7f525236 100644 --- a/packages/react-components/react-aria/src/activedescendant/index.ts +++ b/packages/react-components/react-aria/src/activedescendant/index.ts @@ -1,3 +1,4 @@ +export * from './ActiveDescendantContext'; export * from './useActiveDescendant'; export * from './constants'; export * from './types'; diff --git a/packages/react-components/react-aria/src/activedescendant/useActiveDescendant.ts b/packages/react-components/react-aria/src/activedescendant/useActiveDescendant.ts index 728ab8543e6fe5..4786682e23c8f1 100644 --- a/packages/react-components/react-aria/src/activedescendant/useActiveDescendant.ts +++ b/packages/react-components/react-aria/src/activedescendant/useActiveDescendant.ts @@ -29,7 +29,7 @@ export function useActiveDescendant({ matchOption }); + const { listboxRef, optionWalker, listboxCallbackRef } = useOptionWalker({ matchOption }); const getActiveDescendant = React.useCallback(() => { return listboxRef.current?.querySelector(`#${activeIdRef.current}`); }, [listboxRef]); @@ -144,5 +144,5 @@ export function useActiveDescendant controller); - return { listboxRef, activeParentRef, controller }; + return { listboxRef: listboxCallbackRef, activeParentRef, controller }; } diff --git a/packages/react-components/react-aria/src/activedescendant/useOptionWalker.ts b/packages/react-components/react-aria/src/activedescendant/useOptionWalker.ts index 8dbd0c84cb415b..83638c74153e19 100644 --- a/packages/react-components/react-aria/src/activedescendant/useOptionWalker.ts +++ b/packages/react-components/react-aria/src/activedescendant/useOptionWalker.ts @@ -23,6 +23,18 @@ export function useOptionWalker(options: Us [matchOption], ); + const setListbox = React.useCallback( + (el: TListboxElement) => { + if (el && targetDocument) { + listboxRef.current = el; + treeWalkerRef.current = targetDocument.createTreeWalker(el, NodeFilter.SHOW_ELEMENT, optionFilter); + } else { + listboxRef.current = null; + } + }, + [targetDocument, optionFilter], + ); + useIsomorphicLayoutEffect(() => { if (!targetDocument || !listboxRef.current) { return; @@ -88,6 +100,7 @@ export function useOptionWalker(options: Us return { optionWalker, + listboxCallbackRef: setListbox, listboxRef, }; } diff --git a/packages/react-components/react-aria/src/index.ts b/packages/react-components/react-aria/src/index.ts index 2cfe971e0f45bb..dc45ceed11525d 100644 --- a/packages/react-components/react-aria/src/index.ts +++ b/packages/react-components/react-aria/src/index.ts @@ -3,8 +3,17 @@ export { useARIAButtonShorthand, useARIAButtonProps, } from './button/index'; -export { useActiveDescendant, ACTIVEDESCENDANT_FOCUSVISIBLE_ATTRIBUTE } from './activedescendant'; -export type { ActiveDescendantImperativeRef, ActiveDescendantOptions } from './activedescendant'; +export { + useActiveDescendant, + ACTIVEDESCENDANT_FOCUSVISIBLE_ATTRIBUTE, + ActiveDescendantContextProvider, + useActiveDescendantContext, +} from './activedescendant'; +export type { + ActiveDescendantImperativeRef, + ActiveDescendantOptions, + ActiveDescendantContextValue, +} from './activedescendant'; export type { ARIAButtonSlotProps, ARIAButtonProps, diff --git a/packages/react-components/react-combobox/package.json b/packages/react-components/react-combobox/package.json index 175aade6ac600f..f3cab379744b97 100644 --- a/packages/react-components/react-combobox/package.json +++ b/packages/react-components/react-combobox/package.json @@ -1,6 +1,6 @@ { "name": "@fluentui/react-combobox", - "version": "9.5.40", + "version": "9.5.39", "description": "Fluent UI React Combobox component", "main": "lib-commonjs/index.js", "module": "lib/index.js", @@ -34,14 +34,14 @@ }, "dependencies": { "@fluentui/keyboard-keys": "^9.0.7", + "@fluentui/react-aria": "^9.7.0", "@fluentui/react-context-selector": "^9.1.46", "@fluentui/react-field": "^9.1.47", "@fluentui/react-icons": "^2.0.224", "@fluentui/react-jsx-runtime": "^9.0.24", "@fluentui/react-portal": "^9.4.7", - "@fluentui/react-positioning": "^9.12.1", + "@fluentui/react-positioning": "^9.12.0", "@fluentui/react-shared-contexts": "^9.13.2", - "@fluentui/react-tabster": "^9.17.0", "@fluentui/react-theme": "^9.1.16", "@fluentui/react-utilities": "^9.15.6", "@griffel/react": "^1.5.14", @@ -49,7 +49,7 @@ }, "peerDependencies": { "@types/react": ">=16.14.0 <19.0.0", - "@types/react-dom": ">=16.9.0 <19.0.0", + "@types/react-dom": ">=16.14.0 <19.0.0", "react": ">=16.14.0 <19.0.0", "react-dom": ">=16.14.0 <19.0.0", "scheduler": "^0.19.0 || ^0.20.0" diff --git a/packages/react-components/react-combobox/src/components/Combobox/Combobox.types.ts b/packages/react-components/react-combobox/src/components/Combobox/Combobox.types.ts index 3c94e7c20e244f..1c4f30465c4bcd 100644 --- a/packages/react-components/react-combobox/src/components/Combobox/Combobox.types.ts +++ b/packages/react-components/react-combobox/src/components/Combobox/Combobox.types.ts @@ -1,5 +1,6 @@ import * as React from 'react'; import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import { ActiveDescendantImperativeRef } from '@fluentui/react-aria'; import type { ComboboxBaseContextValues, ComboboxBaseOpenChangeData, @@ -48,6 +49,7 @@ export type ComboboxProps = Omit, 'input'> export type ComboboxState = ComponentState & ComboboxBaseState & { showClearIcon?: boolean; + activeDescendantController: ActiveDescendantImperativeRef; }; /* Export types defined in ComboboxBase */ diff --git a/packages/react-components/react-combobox/src/components/Combobox/renderCombobox.tsx b/packages/react-components/react-combobox/src/components/Combobox/renderCombobox.tsx index 8d9b9730a916f4..a5195ebdc88c56 100644 --- a/packages/react-components/react-combobox/src/components/Combobox/renderCombobox.tsx +++ b/packages/react-components/react-combobox/src/components/Combobox/renderCombobox.tsx @@ -1,6 +1,7 @@ /** @jsxRuntime automatic */ /** @jsxImportSource @fluentui/react-jsx-runtime */ import { Portal } from '@fluentui/react-portal'; +import { ActiveDescendantContextProvider } from '@fluentui/react-aria'; import { assertSlots } from '@fluentui/react-utilities'; import { ComboboxContext } from '../../contexts/ComboboxContext'; @@ -14,19 +15,21 @@ export const renderCombobox_unstable = (state: ComboboxState, contextValues: Com return ( - - - {state.clearIcon && } - - {state.listbox && - (state.inlinePopup ? ( - - ) : ( - + + + + {state.clearIcon && } + + {state.listbox && + (state.inlinePopup ? ( - - ))} - + ) : ( + + + + ))} + + ); }; diff --git a/packages/react-components/react-combobox/src/components/Combobox/useCombobox.tsx b/packages/react-components/react-combobox/src/components/Combobox/useCombobox.tsx index c92522a0ad6f38..2ed65e46ace697 100644 --- a/packages/react-components/react-combobox/src/components/Combobox/useCombobox.tsx +++ b/packages/react-components/react-combobox/src/components/Combobox/useCombobox.tsx @@ -9,6 +9,7 @@ import { useMergedRefs, slot, } from '@fluentui/react-utilities'; +import { useActiveDescendant } from '@fluentui/react-aria'; import { useComboboxBaseState } from '../../utils/useComboboxBaseState'; import { useComboboxPositioning } from '../../utils/useComboboxPositioning'; import { Listbox } from '../Listbox/Listbox'; @@ -17,6 +18,7 @@ import type { OptionValue } from '../../utils/OptionCollection.types'; import type { ComboboxProps, ComboboxState } from './Combobox.types'; import { useListboxSlot } from '../../utils/useListboxSlot'; import { useInputTriggerSlot } from './useInputTriggerSlot'; +import { optionClassNames } from '../Option/useOptionStyles.styles'; /** * Create the state required to render Combobox. @@ -31,7 +33,14 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref, if any props = useFieldControlProps_unstable(props, { supportsLabelFor: true, supportsRequired: true, supportsSize: true }); - const baseState = useComboboxBaseState({ ...props, editable: true }); + const { + listboxRef: activeDescendantListboxRef, + activeParentRef, + controller: activeDescendantController, + } = useActiveDescendant({ + matchOption: el => el.classList.contains(optionClassNames.root), + }); + const baseState = useComboboxBaseState({ ...props, editable: true, activeDescendantController }); const { clearable, clearSelection, @@ -74,7 +83,7 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref(null); - const listbox = useListboxSlot(props.listbox, comboboxPopupRef, { + const listbox = useListboxSlot(props.listbox, useMergedRefs(comboboxPopupRef, activeDescendantListboxRef), { state: baseState, triggerRef, defaultProps: { @@ -82,7 +91,7 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref; + Pick; type UseInputTriggerSlotOptions = { state: UsedComboboxState; freeform: boolean | undefined; defaultProps: Partial; + activeDescendantController: ActiveDescendantImperativeRef; }; /* @@ -30,25 +32,24 @@ export function useInputTriggerSlot( state: { open, value, - activeOption, selectOption, setValue, - setActiveOption, - setFocusVisible, multiselect, selectedOptions, clearSelection, - getOptionsMatchingText, - getIndexOfId, + getOptionById, setOpen, }, freeform, defaultProps, + activeDescendantController, } = options; const onBlur = (ev: React.FocusEvent) => { // handle selection and updating value if freeform is false if (!open && !freeform) { + const activeOptionId = activeDescendantController.active(); + const activeOption = activeOptionId ? getOptionById(activeOptionId) : null; // select matching option, if the value fully matches if (value && activeOption && value.trim().toLowerCase() === activeOption?.text.toLowerCase()) { selectOption(ev, activeOption); @@ -63,20 +64,22 @@ export function useInputTriggerSlot( const searchString = inputValue?.trim().toLowerCase(); if (!searchString || searchString.length === 0) { + activeDescendantController.blur(); return; } const matcher = (optionText: string) => optionText.toLowerCase().indexOf(searchString) === 0; - const matches = getOptionsMatchingText(matcher); - - // return first matching option after the current active option, looping back to the top - if (matches.length > 1 && activeOption) { - const startIndex = getIndexOfId(activeOption.id); - const nextMatch = matches.find(option => getIndexOfId(option.id) >= startIndex); - return nextMatch ?? matches[0]; + const match = activeDescendantController.find(id => { + const option = getOptionById(id); + return !!option && matcher(option.text); + }); + + if (!match) { + activeDescendantController.blur(); + return undefined; } - return matches[0] ?? undefined; + return getOptionById(match); }; // update value and active option based on input @@ -87,9 +90,6 @@ export function useInputTriggerSlot( // handle updating active option based on input const matchingOption = getOptionFromInput(inputValue); - setActiveOption(matchingOption); - - setFocusVisible(true); // clear selection for single-select if the input value no longer matches the selection if (!multiselect && selectedOptions.length === 1 && (inputValue.length < 1 || !matchingOption)) { @@ -101,6 +101,7 @@ export function useInputTriggerSlot( state: options.state, defaultProps, elementType: 'input', + activeDescendantController, }); trigger.onChange = mergeCallbacks(trigger.onChange, onChange); diff --git a/packages/react-components/react-combobox/src/components/Dropdown/Dropdown.types.ts b/packages/react-components/react-combobox/src/components/Dropdown/Dropdown.types.ts index feb49c0ed25675..b598d06e52f4bb 100644 --- a/packages/react-components/react-combobox/src/components/Dropdown/Dropdown.types.ts +++ b/packages/react-components/react-combobox/src/components/Dropdown/Dropdown.types.ts @@ -1,4 +1,5 @@ import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import { ActiveDescendantImperativeRef } from '@fluentui/react-aria'; import type { ComboboxBaseContextValues, ComboboxBaseOpenChangeData, @@ -39,6 +40,8 @@ export type DropdownState = ComponentState & placeholderVisible: boolean; showClearButton?: boolean; + + activeDescendantController: ActiveDescendantImperativeRef; }; /* Export types defined in ComboboxBase */ diff --git a/packages/react-components/react-combobox/src/components/Dropdown/renderDropdown.tsx b/packages/react-components/react-combobox/src/components/Dropdown/renderDropdown.tsx index 0682494c5626fd..b991597c8cffe0 100644 --- a/packages/react-components/react-combobox/src/components/Dropdown/renderDropdown.tsx +++ b/packages/react-components/react-combobox/src/components/Dropdown/renderDropdown.tsx @@ -4,6 +4,7 @@ import { Portal } from '@fluentui/react-portal'; import { assertSlots } from '@fluentui/react-utilities'; +import { ActiveDescendantContextProvider } from '@fluentui/react-aria'; import { ComboboxContext } from '../../contexts/ComboboxContext'; import type { DropdownContextValues, DropdownState, DropdownSlots } from './Dropdown.types'; @@ -15,21 +16,23 @@ export const renderDropdown_unstable = (state: DropdownState, contextValues: Dro return ( - - - {state.button.children} - - - {state.clearButton && } - {state.listbox && - (state.inlinePopup ? ( - - ) : ( - + + + + {state.button.children} + + + {state.clearButton && } + {state.listbox && + (state.inlinePopup ? ( - - ))} - + ) : ( + + + + ))} + + ); }; diff --git a/packages/react-components/react-combobox/src/components/Dropdown/useButtonTriggerSlot.ts b/packages/react-components/react-combobox/src/components/Dropdown/useButtonTriggerSlot.ts index 8b28b053e85c21..b83c92b07d5fd2 100644 --- a/packages/react-components/react-combobox/src/components/Dropdown/useButtonTriggerSlot.ts +++ b/packages/react-components/react-combobox/src/components/Dropdown/useButtonTriggerSlot.ts @@ -1,5 +1,6 @@ import * as React from 'react'; import { useTimeout, mergeCallbacks } from '@fluentui/react-utilities'; +import { ActiveDescendantImperativeRef } from '@fluentui/react-aria'; import type { Slot, ExtractSlotProps, SlotComponentType } from '@fluentui/react-utilities'; import { useTriggerSlot, UseTriggerSlotState } from '../../utils/useTriggerSlot'; import { OptionValue } from '../../utils/OptionCollection.types'; @@ -10,6 +11,7 @@ type UsedDropdownState = UseTriggerSlotState & Pick>> { const { - state: { open, activeOption, setOpen, getOptionsMatchingText, getIndexOfId, setActiveOption, setFocusVisible }, + state: { open, setOpen, getOptionById }, defaultProps, + activeDescendantController, } = options; // jump to matching option based on typing @@ -34,37 +37,40 @@ export function useButtonTriggerSlot( const getNextMatchingOption = (): OptionValue | undefined => { // first check for matches for the full searchString let matcher = (optionText: string) => optionText.toLowerCase().indexOf(searchString.current) === 0; - let matches = getOptionsMatchingText(matcher); - let startIndex = activeOption ? getIndexOfId(activeOption.id) : 0; - - // if the dropdown is already open and the searchstring is a single character, - // then look after the current activeOption for letters - // this is so slowly typing the same letter will cycle through matches - if (open && searchString.current.length === 1) { - startIndex++; - } + const activeOptionId = activeDescendantController.active(); + let match: string | undefined; + + // TODO slowly pressing same key + match = activeDescendantController.find(id => { + const option = getOptionById(id); + return !!option && matcher(option.text); + }); // if there are no direct matches, check if the search is all the same letter, e.g. "aaa" - if (!matches.length) { + if (!match) { const letters = searchString.current.split(''); const allSameLetter = letters.length && letters.every(letter => letter === letters[0]); // if the search is all the same letter, cycle through options starting with that letter if (allSameLetter) { - startIndex++; matcher = (optionText: string) => optionText.toLowerCase().indexOf(letters[0]) === 0; - matches = getOptionsMatchingText(matcher); + match = activeDescendantController.find(id => { + if (id === activeOptionId) { + return false; + } + + const option = getOptionById(id); + return !!option && matcher(option.text); + }); } } - // if there is an active option and multiple matches, - // return first matching option after the current active option, looping back to the top - if (matches.length > 1 && activeOption) { - const nextMatch = matches.find(option => getIndexOfId(option.id) >= startIndex); - return nextMatch ?? matches[0]; + if (!match) { + activeDescendantController.blur(); + return undefined; } - return matches[0] ?? undefined; + return getOptionById(match); }; const onTriggerKeyDown = (ev: React.KeyboardEvent) => { @@ -83,12 +89,18 @@ export function useButtonTriggerSlot( !open && setOpen(ev, true); const nextOption = getNextMatchingOption(); - setActiveOption(nextOption); - setFocusVisible(true); + if (nextOption?.id) { + activeDescendantController.focus(nextOption.id); + } } }; - const trigger = useTriggerSlot(triggerFromProps, ref, { state: options.state, defaultProps, elementType: 'button' }); + const trigger = useTriggerSlot(triggerFromProps, ref, { + state: options.state, + defaultProps, + elementType: 'button', + activeDescendantController, + }); trigger.onKeyDown = mergeCallbacks(onTriggerKeyDown, trigger.onKeyDown); return trigger; diff --git a/packages/react-components/react-combobox/src/components/Dropdown/useDropdown.tsx b/packages/react-components/react-combobox/src/components/Dropdown/useDropdown.tsx index 4dfbe390115d30..838811564a94e8 100644 --- a/packages/react-components/react-combobox/src/components/Dropdown/useDropdown.tsx +++ b/packages/react-components/react-combobox/src/components/Dropdown/useDropdown.tsx @@ -8,12 +8,14 @@ import { slot, useEventCallback, } from '@fluentui/react-utilities'; +import { useActiveDescendant } from '@fluentui/react-aria'; import { useComboboxBaseState } from '../../utils/useComboboxBaseState'; import { useComboboxPositioning } from '../../utils/useComboboxPositioning'; import { Listbox } from '../Listbox/Listbox'; import type { DropdownProps, DropdownState } from './Dropdown.types'; import { useListboxSlot } from '../../utils/useListboxSlot'; import { useButtonTriggerSlot } from './useButtonTriggerSlot'; +import { optionClassNames } from '../Option/useOptionStyles.styles'; /** * Create the state required to render Dropdown. @@ -27,8 +29,15 @@ import { useButtonTriggerSlot } from './useButtonTriggerSlot'; export const useDropdown_unstable = (props: DropdownProps, ref: React.Ref): DropdownState => { // Merge props from surrounding , if any props = useFieldControlProps_unstable(props, { supportsLabelFor: true, supportsSize: true }); + const { + listboxRef: activeDescendantListboxRef, + activeParentRef, + controller: activeDescendantController, + } = useActiveDescendant({ + matchOption: el => el.classList.contains(optionClassNames.root), + }); - const baseState = useComboboxBaseState(props); + const baseState = useComboboxBaseState({ ...props, activeDescendantController }); const { clearable, clearSelection, hasFocus, multiselect, open, selectedOptions } = baseState; const { primary: triggerNativeProps, root: rootNativeProps } = getPartitionedNativeProps({ @@ -40,7 +49,7 @@ export const useDropdown_unstable = (props: DropdownProps, ref: React.Ref(null); - const listbox = useListboxSlot(props.listbox, comboboxPopupRef, { + const listbox = useListboxSlot(props.listbox, useMergedRefs(comboboxPopupRef, activeDescendantListboxRef), { state: baseState, triggerRef, defaultProps: { @@ -48,7 +57,7 @@ export const useDropdown_unstable = (props: DropdownProps, ref: React.Ref & OptionCollectionState & Pick & SelectionState & { - /* Option data for the currently highlighted option (not the selected option) */ + /** + * @deprecated + */ activeOption?: OptionValue; - // Whether the keyboard focus outline style should be visible + /** + * @deprecated + */ focusVisible: boolean; selectOption(event: SelectionEvents, option: OptionValue): void; + /** + * @deprecated + */ setActiveOption(option?: OptionValue): void; + + activeDescendantController: ActiveDescendantImperativeRef; }; export type ListboxContextValues = { listbox: ListboxContextValue; + activeDescendant: ActiveDescendantContextValue; }; diff --git a/packages/react-components/react-combobox/src/components/Listbox/renderListbox.tsx b/packages/react-components/react-combobox/src/components/Listbox/renderListbox.tsx index 230c77f1f4ac3a..d3fa6c3d90c059 100644 --- a/packages/react-components/react-combobox/src/components/Listbox/renderListbox.tsx +++ b/packages/react-components/react-combobox/src/components/Listbox/renderListbox.tsx @@ -4,6 +4,7 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { ListboxContextValues, ListboxState, ListboxSlots } from './Listbox.types'; import { ListboxContext } from '../../contexts/ListboxContext'; +import { ActiveDescendantContextProvider } from '../../../../react-aria/src/index'; /** * Render the final JSX of Listbox @@ -12,8 +13,10 @@ export const renderListbox_unstable = (state: ListboxState, contextValues: Listb assertSlots(state); return ( - - - + + + + + ); }; diff --git a/packages/react-components/react-combobox/src/components/Listbox/useListbox.ts b/packages/react-components/react-combobox/src/components/Listbox/useListbox.ts index 29b77cf5653a00..09f74eb2813e1e 100644 --- a/packages/react-components/react-combobox/src/components/Listbox/useListbox.ts +++ b/packages/react-components/react-combobox/src/components/Listbox/useListbox.ts @@ -3,17 +3,17 @@ import { getIntrinsicElementProps, mergeCallbacks, useEventCallback, - useMergedRefs, slot, + useMergedRefs, } from '@fluentui/react-utilities'; import { useContextSelector, useHasParentContext } from '@fluentui/react-context-selector'; -import { getDropdownActionFromKey, getIndexFromAction } from '../../utils/dropdownKeyActions'; -import type { OptionValue } from '../../utils/OptionCollection.types'; +import { useActiveDescendant } from '@fluentui/react-aria'; +import { getDropdownActionFromKey } from '../../utils/dropdownKeyActions'; import { useOptionCollection } from '../../utils/useOptionCollection'; -import { useScrollOptionsIntoView } from '../../utils/useScrollOptionsIntoView'; import { useSelection } from '../../utils/useSelection'; import { ComboboxContext } from '../../contexts/ComboboxContext'; import type { ListboxProps, ListboxState } from './Listbox.types'; +import { optionClassNames } from '../Option/useOptionStyles.styles'; /** * Create the state required to render Listbox. @@ -27,66 +27,73 @@ import type { ListboxProps, ListboxState } from './Listbox.types'; export const useListbox_unstable = (props: ListboxProps, ref: React.Ref): ListboxState => { const { multiselect } = props; const optionCollection = useOptionCollection(); - const { getCount, getOptionAtIndex, getIndexOfId } = optionCollection; - - const { clearSelection, selectedOptions, selectOption } = useSelection(props); + const { getOptionById } = optionCollection; - const [activeOption, setActiveOption] = React.useState(); + const { + listboxRef: activeDescendantListboxRef, + activeParentRef, + controller: activeDescendantController, + } = useActiveDescendant({ + matchOption: el => el.classList.contains(optionClassNames.root), + }); - // track whether keyboard focus outline should be shown - // tabster/keyborg doesn't work here, since the actual keyboard focus target doesn't move - const [focusVisible, setFocusVisible] = React.useState(false); + const { clearSelection, selectedOptions, selectOption } = useSelection(props); const onKeyDown = (event: React.KeyboardEvent) => { const action = getDropdownActionFromKey(event, { open: true }); - const maxIndex = getCount() - 1; - const activeIndex = activeOption ? getIndexOfId(activeOption.id) : -1; - let newIndex = activeIndex; + const activeOptionId = activeDescendantController.active(); + const activeOption = activeOptionId ? getOptionById(activeOptionId) : null; switch (action) { + case 'Next': + if (activeOption) { + activeDescendantController.next(); + } else { + activeDescendantController.first(); + } + break; + case 'Previous': + if (activeOption) { + activeDescendantController.prev(); + } else { + activeDescendantController.first(); + } + break; + case 'PageUp': + case 'First': + activeDescendantController.first(); + break; + case 'PageDown': + case 'Last': + activeDescendantController.last(); + break; case 'Select': case 'CloseSelect': activeOption && selectOption(event, activeOption); break; - default: - newIndex = getIndexFromAction(action, activeIndex, maxIndex); - } - - if (newIndex !== activeIndex) { - // prevent default page scroll/keyboard action if the index changed - event.preventDefault(); - setActiveOption(getOptionAtIndex(newIndex)); - setFocusVisible(true); } }; - const onMouseOver = (event: React.MouseEvent) => { - setFocusVisible(false); - }; - // get state from parent combobox, if it exists const hasComboboxContext = useHasParentContext(ComboboxContext); - const comboboxActiveOption = useContextSelector(ComboboxContext, ctx => ctx.activeOption); - const comboboxFocusVisible = useContextSelector(ComboboxContext, ctx => ctx.focusVisible); const comboboxSelectedOptions = useContextSelector(ComboboxContext, ctx => ctx.selectedOptions); const comboboxSelectOption = useContextSelector(ComboboxContext, ctx => ctx.selectOption); - const comboboxSetActiveOption = useContextSelector(ComboboxContext, ctx => ctx.setActiveOption); // without a parent combobox context, provide values directly from Listbox const optionContextValues = hasComboboxContext ? { - activeOption: comboboxActiveOption, - focusVisible: comboboxFocusVisible, + activeOption: undefined, + focusVisible: false, selectedOptions: comboboxSelectedOptions, selectOption: comboboxSelectOption, - setActiveOption: comboboxSetActiveOption, + setActiveOption: () => null, } : { - activeOption, - focusVisible, + activeOption: undefined, + focusVisible: false, selectedOptions, selectOption, - setActiveOption, + setActiveOption: () => null, }; const state: ListboxState = { @@ -98,9 +105,8 @@ export const useListbox_unstable = (props: ListboxProps, ref: React.Ref, + ref: useMergedRefs(ref as React.Ref, activeParentRef, activeDescendantListboxRef), role: multiselect ? 'menu' : 'listbox', - 'aria-activedescendant': hasComboboxContext ? undefined : activeOption?.id, 'aria-multiselectable': multiselect, tabIndex: 0, ...props, @@ -109,15 +115,12 @@ export const useListbox_unstable = (props: ListboxProps, ref: React.Ref> & { */ export type OptionState = ComponentState & Pick & { - /* If true, this is the currently highlighted option */ + /** + * @deprecated + */ active: boolean; - // Whether the keyboard focus outline style should be visible + /** + * @deprecated + */ focusVisible: boolean; /* If true, the option is part of a multiselect combobox or listbox */ diff --git a/packages/react-components/react-combobox/src/components/Option/useOption.tsx b/packages/react-components/react-combobox/src/components/Option/useOption.tsx index 36483f64ca3432..7f1ae385097254 100644 --- a/packages/react-components/react-combobox/src/components/Option/useOption.tsx +++ b/packages/react-components/react-combobox/src/components/Option/useOption.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { getIntrinsicElementProps, useId, useMergedRefs, slot } from '@fluentui/react-utilities'; import { useContextSelector } from '@fluentui/react-context-selector'; +import { useActiveDescendantContext } from '@fluentui/react-aria'; import { CheckmarkFilled, Checkmark12Filled } from '@fluentui/react-icons'; import { ComboboxContext } from '../../contexts/ComboboxContext'; import { ListboxContext } from '../../contexts/ListboxContext'; @@ -56,7 +57,7 @@ export const useOption_unstable = (props: OptionProps, ref: React.Ref ctx.focusVisible); + const { controller: activeDescendantController } = useActiveDescendantContext(); const multiselect = useContextSelector(ListboxContext, ctx => ctx.multiselect); const registerOption = useContextSelector(ListboxContext, ctx => ctx.registerOption); const selected = useContextSelector(ListboxContext, ctx => { @@ -65,14 +66,8 @@ export const useOption_unstable = (props: OptionProps, ref: React.Ref o === optionValue); }); const selectOption = useContextSelector(ListboxContext, ctx => ctx.selectOption); - const setActiveOption = useContextSelector(ListboxContext, ctx => ctx.setActiveOption); const setOpen = useContextSelector(ComboboxContext, ctx => ctx.setOpen); - // current active option? - const active = useContextSelector(ListboxContext, ctx => { - return ctx.activeOption?.id !== undefined && ctx.activeOption?.id === id; - }); - // check icon let CheckIcon: React.ReactNode = ; if (multiselect) { @@ -85,8 +80,7 @@ export const useOption_unstable = (props: OptionProps, ref: React.Ref { - const { active, disabled, focusVisible, multiselect, selected } = state; + const { disabled, multiselect, selected } = state; const styles = useStyles(); state.root.className = mergeClasses( optionClassNames.root, styles.root, - active && focusVisible && styles.active, + styles.active, disabled && styles.disabled, selected && styles.selected, state.root.className, diff --git a/packages/react-components/react-combobox/src/contexts/useComboboxContextValues.ts b/packages/react-components/react-combobox/src/contexts/useComboboxContextValues.ts index ffd4ed3d46cccd..2d8254a07308e5 100644 --- a/packages/react-components/react-combobox/src/contexts/useComboboxContextValues.ts +++ b/packages/react-components/react-combobox/src/contexts/useComboboxContextValues.ts @@ -1,10 +1,13 @@ +import * as React from 'react'; +import { ComboboxState } from '../Combobox'; import { ComboboxBaseContextValues, ComboboxBaseState } from '../utils/ComboboxBase.types'; -export function useComboboxContextValues(state: ComboboxBaseState): ComboboxBaseContextValues { +export function useComboboxContextValues( + state: ComboboxBaseState & Pick, +): ComboboxBaseContextValues { const { activeOption, appearance, - focusVisible, open, registerOption, selectedOptions, @@ -12,12 +15,13 @@ export function useComboboxContextValues(state: ComboboxBaseState): ComboboxBase setActiveOption, setOpen, size, + activeDescendantController, } = state; const combobox = { activeOption, appearance, - focusVisible, + focusVisible: false, open, registerOption, selectedOptions, @@ -27,5 +31,12 @@ export function useComboboxContextValues(state: ComboboxBaseState): ComboboxBase size, }; - return { combobox }; + const activeDescendant = React.useMemo( + () => ({ + controller: activeDescendantController, + }), + [activeDescendantController], + ); + + return { combobox, activeDescendant }; } diff --git a/packages/react-components/react-combobox/src/contexts/useListboxContextValues.ts b/packages/react-components/react-combobox/src/contexts/useListboxContextValues.ts index 29c27f1cb071de..2d014c2e2b088b 100644 --- a/packages/react-components/react-combobox/src/contexts/useListboxContextValues.ts +++ b/packages/react-components/react-combobox/src/contexts/useListboxContextValues.ts @@ -1,11 +1,11 @@ +import * as React from 'react'; import { useContextSelector, useHasParentContext } from '@fluentui/react-context-selector'; import { ListboxContextValues, ListboxState } from '../components/Listbox/Listbox.types'; import { ComboboxContext } from './ComboboxContext'; export function useListboxContextValues(state: ListboxState): ListboxContextValues { const hasComboboxContext = useHasParentContext(ComboboxContext); - const { activeOption, focusVisible, multiselect, registerOption, selectedOptions, selectOption, setActiveOption } = - state; + const { multiselect, registerOption, selectedOptions, selectOption, activeDescendantController } = state; // get register/unregister functions from parent combobox context const comboboxRegisterOption = useContextSelector(ComboboxContext, ctx => ctx.registerOption); @@ -13,14 +13,21 @@ export function useListboxContextValues(state: ListboxState): ListboxContextValu const registerOptionValue = hasComboboxContext ? comboboxRegisterOption : registerOption; const listbox = { - activeOption, - focusVisible, + activeOption: undefined, + focusVisible: false, multiselect, registerOption: registerOptionValue, selectedOptions, selectOption, - setActiveOption, + setActiveOption: () => undefined, }; - return { listbox }; + const activeDescendant = React.useMemo( + () => ({ + controller: activeDescendantController, + }), + [activeDescendantController], + ); + + return { listbox, activeDescendant }; } diff --git a/packages/react-components/react-combobox/src/utils/ComboboxBase.types.ts b/packages/react-components/react-combobox/src/utils/ComboboxBase.types.ts index f5ba8d38639109..6a1709c019243a 100644 --- a/packages/react-components/react-combobox/src/utils/ComboboxBase.types.ts +++ b/packages/react-components/react-combobox/src/utils/ComboboxBase.types.ts @@ -1,5 +1,6 @@ import * as React from 'react'; import type { PositioningShorthand } from '@fluentui/react-positioning'; +import { ActiveDescendantContextValue } from '@fluentui/react-aria'; import type { ComboboxContextValue } from '../contexts/ComboboxContext'; import type { OptionValue, OptionCollectionState } from '../utils/OptionCollection.types'; import { SelectionProps, SelectionState } from '../utils/Selection.types'; @@ -86,7 +87,9 @@ export type ComboboxBaseState = Required< /* Option data for the currently highlighted option (not the selected option) */ activeOption?: OptionValue; - // Whether the keyboard focus outline style should be visible + /** + * @deprecated + */ focusVisible: boolean; /** @@ -102,6 +105,9 @@ export type ComboboxBaseState = Required< setActiveOption: React.Dispatch>; + /** + * @deprecated + */ setFocusVisible(focusVisible: boolean): void; setHasFocus(hasFocus: boolean): void; @@ -126,4 +132,5 @@ export type ComboboxBaseOpenEvents = export type ComboboxBaseContextValues = { combobox: ComboboxContextValue; + activeDescendant: ActiveDescendantContextValue; }; diff --git a/packages/react-components/react-combobox/src/utils/OptionCollection.types.ts b/packages/react-components/react-combobox/src/utils/OptionCollection.types.ts index 795194220466e5..f5091dc3654cb1 100644 --- a/packages/react-components/react-combobox/src/utils/OptionCollection.types.ts +++ b/packages/react-components/react-combobox/src/utils/OptionCollection.types.ts @@ -16,16 +16,22 @@ export type OptionCollectionState = { /** The total number of options in the collection. */ getCount: () => number; - /** Returns the index of an option by key. */ + /** + * @deprecated + */ getIndexOfId(id: string): number; - /** Returns the option data for the nth option. */ + /** + * @deprecated + */ getOptionAtIndex(index: number): OptionValue | undefined; /** Returns the option data by key. */ getOptionById(id: string): OptionValue | undefined; - /** Returns an array of options filtered by a value matching function against the option's text string. */ + /** + * @deprecated + */ getOptionsMatchingText(matcher: (text: string) => boolean): OptionValue[]; /** Returns an array of options filtered by a value matching function against the option's value string. */ diff --git a/packages/react-components/react-combobox/src/utils/useComboboxBaseState.ts b/packages/react-components/react-combobox/src/utils/useComboboxBaseState.ts index dc64e879ead78e..b9948f2a225ede 100644 --- a/packages/react-components/react-combobox/src/utils/useComboboxBaseState.ts +++ b/packages/react-components/react-combobox/src/utils/useComboboxBaseState.ts @@ -1,5 +1,6 @@ import * as React from 'react'; import { useControllableState, useFirstMount } from '@fluentui/react-utilities'; +import { ActiveDescendantImperativeRef } from '@fluentui/react-aria'; import { useOptionCollection } from '../utils/useOptionCollection'; import { OptionValue } from '../utils/OptionCollection.types'; import { useSelection } from '../utils/useSelection'; @@ -9,7 +10,11 @@ import type { ComboboxBaseProps, ComboboxBaseOpenEvents, ComboboxBaseState } fro * State shared between Combobox and Dropdown components */ export const useComboboxBaseState = ( - props: ComboboxBaseProps & { children?: React.ReactNode; editable?: boolean }, + props: ComboboxBaseProps & { + children?: React.ReactNode; + editable?: boolean; + activeDescendantController: ActiveDescendantImperativeRef; + }, ): ComboboxBaseState => { const { appearance = 'outline', @@ -21,10 +26,11 @@ export const useComboboxBaseState = ( multiselect, onOpenChange, size = 'medium', + activeDescendantController, } = props; const optionCollection = useOptionCollection(); - const { getOptionAtIndex, getOptionsMatchingValue } = optionCollection; + const { getOptionsMatchingValue } = optionCollection; const [activeOption, setActiveOption] = React.useState(); @@ -93,23 +99,25 @@ export const useComboboxBaseState = ( // update active option based on change in open state or children React.useEffect(() => { - if (open && !activeOption) { + if (open) { // if it is single-select and there is a selected option, start at the selected option if (!multiselect && selectedOptions.length > 0) { const selectedOption = getOptionsMatchingValue(v => v === selectedOptions[0]).pop(); - selectedOption && setActiveOption(selectedOption); + if (selectedOption?.id) { + activeDescendantController.focus(selectedOption.id); + } } // default to starting at the first option else { - setActiveOption(getOptionAtIndex(0)); + activeDescendantController.first(); } } else if (!open) { // reset the active option when closing - setActiveOption(undefined); + activeDescendantController.blur(); } // this should only be run in response to changes in the open state or children // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, children]); + }, [open, children, activeDescendantController]); return { ...optionCollection, diff --git a/packages/react-components/react-combobox/src/utils/useOptionCollection.ts b/packages/react-components/react-combobox/src/utils/useOptionCollection.ts index 9bff10806e5254..aabb0200a15113 100644 --- a/packages/react-components/react-combobox/src/utils/useOptionCollection.ts +++ b/packages/react-components/react-combobox/src/utils/useOptionCollection.ts @@ -5,21 +5,31 @@ import type { OptionCollectionState, OptionValue } from './OptionCollection.type * A hook for managing a collection of child Options */ export const useOptionCollection = (): OptionCollectionState => { - const nodes = React.useRef<{ option: OptionValue; element: HTMLElement }[]>([]); + const optionsById = React.useRef(new Map()); const collectionAPI = React.useMemo(() => { - const getCount = () => nodes.current.length; - const getOptionAtIndex = (index: number) => nodes.current[index]?.option; - const getIndexOfId = (id: string) => nodes.current.findIndex(node => node.option.id === id); + const getCount = () => optionsById.current.size; + + // index searches are no longer used + const getOptionAtIndex = () => undefined; + const getIndexOfId = () => -1; + const getOptionById = (id: string) => { - const item = nodes.current.find(node => node.option.id === id); - return item?.option; + return optionsById.current.get(id); }; const getOptionsMatchingText = (matcher: (text: string) => boolean) => { - return nodes.current.filter(node => matcher(node.option.text)).map(node => node.option); + return Array.from(optionsById.current.values()).filter(({ text }) => matcher(text)); }; + const getOptionsMatchingValue = (matcher: (value: string) => boolean) => { - return nodes.current.filter(node => matcher(node.option.value)).map(node => node.option); + const matches: OptionValue[] = []; + for (const option of optionsById.current.values()) { + if (matcher(option.value)) { + matches.push(option); + } + } + + return matches; }; return { @@ -32,45 +42,15 @@ export const useOptionCollection = (): OptionCollectionState => { }; }, []); - const registerOption = React.useCallback((option: OptionValue, element: HTMLElement) => { - const index = nodes.current.findIndex(node => { - if (!node.element || !element) { - return false; - } + const registerOption = React.useCallback((option: OptionValue) => { + optionsById.current.set(option.id, option); - if (node.option.id === option.id) { - return true; - } - - // use the DOM method compareDocumentPosition to order the current node against registered nodes - // eslint-disable-next-line no-bitwise - return node.element.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING; - }); - - // do not register the option if it already exists - if (nodes.current[index]?.option.id !== option.id) { - const newItem = { - element, - option, - }; - - // If an index is not found we will push the element to the end. - if (index === -1) { - nodes.current = [...nodes.current, newItem]; - } else { - nodes.current.splice(index, 0, newItem); - } - } - - // return the unregister function - return () => { - nodes.current = nodes.current.filter(node => node.option.id !== option.id); - }; + return () => optionsById.current.delete(option.id); }, []); return { ...collectionAPI, - options: nodes.current.map(node => node.option), + options: Array.from(optionsById.current.values()), registerOption, }; }; diff --git a/packages/react-components/react-combobox/src/utils/useScrollOptionsIntoView.ts b/packages/react-components/react-combobox/src/utils/useScrollOptionsIntoView.ts deleted file mode 100644 index de31be3a14c3db..00000000000000 --- a/packages/react-components/react-combobox/src/utils/useScrollOptionsIntoView.ts +++ /dev/null @@ -1,36 +0,0 @@ -import * as React from 'react'; -import { canUseDOM } from '@fluentui/react-utilities'; -import { ListboxState } from '../Listbox'; - -export function useScrollOptionsIntoView(state: ListboxState): React.Ref { - const { activeOption } = state; - const scrollContainerRef = React.useRef(null); - - React.useEffect(() => { - if (scrollContainerRef.current && activeOption && canUseDOM()) { - const activeOptionElement = scrollContainerRef.current.querySelector(`#${activeOption.id}`) as HTMLElement; - - if (!activeOptionElement) { - return; - } - - const { offsetHeight, offsetTop } = activeOptionElement; - const { offsetHeight: parentOffsetHeight, scrollTop } = scrollContainerRef.current; - - const isAbove = offsetTop < scrollTop; - const isBelow = offsetTop + offsetHeight > scrollTop + parentOffsetHeight; - - // add a small buffer for general visual nicety - // it looks slightly better if the option has some space from the top/bottom while arrowing - const buffer = 2; - - if (isAbove) { - scrollContainerRef.current.scrollTo(0, offsetTop - buffer); - } else if (isBelow) { - scrollContainerRef.current.scrollTo(0, offsetTop - parentOffsetHeight + offsetHeight + buffer); - } - } - }, [activeOption]); - - return scrollContainerRef; -} diff --git a/packages/react-components/react-combobox/src/utils/useTriggerSlot.ts b/packages/react-components/react-combobox/src/utils/useTriggerSlot.ts index f4e49e395c4b0f..f9c749dd180e3c 100644 --- a/packages/react-components/react-combobox/src/utils/useTriggerSlot.ts +++ b/packages/react-components/react-combobox/src/utils/useTriggerSlot.ts @@ -1,28 +1,19 @@ import * as React from 'react'; +import { ActiveDescendantImperativeRef } from '@fluentui/react-aria'; import { mergeCallbacks, slot, useMergedRefs } from '@fluentui/react-utilities'; import type { ExtractSlotProps, Slot, SlotComponentType } from '@fluentui/react-utilities'; -import { getDropdownActionFromKey, getIndexFromAction } from '../utils/dropdownKeyActions'; +import { getDropdownActionFromKey } from '../utils/dropdownKeyActions'; import type { ComboboxBaseState } from './ComboboxBase.types'; export type UseTriggerSlotState = Pick< ComboboxBaseState, - | 'activeOption' - | 'getCount' - | 'getIndexOfId' - | 'getOptionAtIndex' - | 'open' - | 'selectOption' - | 'setActiveOption' - | 'setFocusVisible' - | 'setOpen' - | 'multiselect' - | 'value' - | 'setHasFocus' + 'open' | 'getOptionById' | 'selectOption' | 'setOpen' | 'multiselect' | 'setHasFocus' >; type UseTriggerSlotOptions = { state: UseTriggerSlotState; defaultProps: unknown; + activeDescendantController: ActiveDescendantImperativeRef; }; export function useTriggerSlot( @@ -47,28 +38,16 @@ export function useTriggerSlot( options: UseTriggerSlotOptions & { elementType: 'input' | 'button' }, ): SlotComponentType>> | SlotComponentType>> { const { - state: { - activeOption, - getCount, - getIndexOfId, - getOptionAtIndex, - open, - selectOption, - setActiveOption, - setFocusVisible, - setOpen, - multiselect, - setHasFocus, - }, + state: { open, selectOption, setOpen, multiselect, setHasFocus, getOptionById }, defaultProps, elementType, + activeDescendantController, } = options; const trigger = slot.always(triggerSlotFromProp, { defaultProps: { type: 'text', 'aria-expanded': open, - 'aria-activedescendant': open ? activeOption?.id : undefined, role: 'combobox', ...(typeof defaultProps === 'object' && defaultProps), }, @@ -104,14 +83,24 @@ export function useTriggerSlot( trigger.onKeyDown = mergeCallbacks( (event: React.KeyboardEvent & React.KeyboardEvent) => { const action = getDropdownActionFromKey(event, { open, multiselect }); - const maxIndex = getCount() - 1; - const activeIndex = activeOption ? getIndexOfId(activeOption.id) : -1; - let newIndex = activeIndex; + const activeOptionId = activeDescendantController.active(); + const activeOption = activeOptionId ? getOptionById(activeOptionId) : null; switch (action) { + case 'First': + activeDescendantController.first(); + event.preventDefault(); + break; + case 'Next': + activeDescendantController.next(); + event.preventDefault(); + break; + case 'Previous': + activeDescendantController.prev(); + event.preventDefault(); + break; case 'Open': event.preventDefault(); - setFocusVisible(true); setOpen(event, true); break; case 'Close': @@ -130,26 +119,10 @@ export function useTriggerSlot( case 'Tab': !multiselect && activeOption && selectOption(event, activeOption); break; - default: - newIndex = getIndexFromAction(action, activeIndex, maxIndex); - } - if (newIndex !== activeIndex) { - // prevent default page scroll/keyboard action if the index changed - event.preventDefault(); - setActiveOption(getOptionAtIndex(newIndex)); - setFocusVisible(true); } }, trigger.onKeyDown, ); - trigger.onMouseOver = mergeCallbacks( - (event: React.MouseEvent & React.MouseEvent) => { - setFocusVisible(false); - }, - trigger.onMouseOver, - ); - - // TODO fix cast return trigger as SlotComponentType>>; }