From c7342e34da73fb518c8341515c369ff026547c2c Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 29 Feb 2024 10:47:14 +0100 Subject: [PATCH] chore(react-combobox): rollback export of internal hooks (#30648) --- ...-4683dd24-621c-4ae8-a961-cedec640fa96.json | 7 - ...-6aa21ddd-0e42-47f0-a0a7-b624bfc21798.json | 7 + .../react-combobox/etc/react-combobox.api.md | 34 +-- .../react-combobox/src/index.ts | 5 - .../react-tag-picker-preview/package.json | 5 + .../src/utils/ComboboxBase.types.ts | 147 +++++++++++++ .../src/utils/OptionCollection.types.ts | 45 ++++ .../src/utils/Selection.types.ts | 52 +++++ .../src/utils/dropdownKeyActions.ts | 90 ++++++++ .../src/utils/useButtonTriggerSlot.ts | 135 ++++++++++++ .../src/utils/useComboboxBaseState.ts | 180 ++++++++++++++++ .../src/utils/useInputTriggerSlot.ts | 170 +++++++++++++++ .../src/utils/useListboxSlot.ts | 78 +++++++ .../src/utils/useTriggerSlot.ts | 193 ++++++++++++++++++ 14 files changed, 1103 insertions(+), 45 deletions(-) delete mode 100644 change/@fluentui-react-combobox-4683dd24-621c-4ae8-a961-cedec640fa96.json create mode 100644 change/@fluentui-react-combobox-6aa21ddd-0e42-47f0-a0a7-b624bfc21798.json create mode 100644 packages/react-components/react-tag-picker-preview/src/utils/ComboboxBase.types.ts create mode 100644 packages/react-components/react-tag-picker-preview/src/utils/OptionCollection.types.ts create mode 100644 packages/react-components/react-tag-picker-preview/src/utils/Selection.types.ts create mode 100644 packages/react-components/react-tag-picker-preview/src/utils/dropdownKeyActions.ts create mode 100644 packages/react-components/react-tag-picker-preview/src/utils/useButtonTriggerSlot.ts create mode 100644 packages/react-components/react-tag-picker-preview/src/utils/useComboboxBaseState.ts create mode 100644 packages/react-components/react-tag-picker-preview/src/utils/useInputTriggerSlot.ts create mode 100644 packages/react-components/react-tag-picker-preview/src/utils/useListboxSlot.ts create mode 100644 packages/react-components/react-tag-picker-preview/src/utils/useTriggerSlot.ts diff --git a/change/@fluentui-react-combobox-4683dd24-621c-4ae8-a961-cedec640fa96.json b/change/@fluentui-react-combobox-4683dd24-621c-4ae8-a961-cedec640fa96.json deleted file mode 100644 index 5caf37bb041ea..0000000000000 --- a/change/@fluentui-react-combobox-4683dd24-621c-4ae8-a961-cedec640fa96.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "type": "minor", - "comment": "feature: export internal hooks and types", - "packageName": "@fluentui/react-combobox", - "email": "bernardo.sunderhus@gmail.com", - "dependentChangeType": "patch" -} diff --git a/change/@fluentui-react-combobox-6aa21ddd-0e42-47f0-a0a7-b624bfc21798.json b/change/@fluentui-react-combobox-6aa21ddd-0e42-47f0-a0a7-b624bfc21798.json new file mode 100644 index 0000000000000..5d2a27a9d4c7a --- /dev/null +++ b/change/@fluentui-react-combobox-6aa21ddd-0e42-47f0-a0a7-b624bfc21798.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "chore: rollback export of internal hooks", + "packageName": "@fluentui/react-combobox", + "email": "bernardo.sunderhus@gmail.com", + "dependentChangeType": "none" +} diff --git a/packages/react-components/react-combobox/etc/react-combobox.api.md b/packages/react-components/react-combobox/etc/react-combobox.api.md index 02e600e232fe8..1fa0a736f7a37 100644 --- a/packages/react-components/react-combobox/etc/react-combobox.api.md +++ b/packages/react-components/react-combobox/etc/react-combobox.api.md @@ -7,11 +7,10 @@ /// import type { ActiveDescendantContextValue } from '@fluentui/react-aria'; -import { ActiveDescendantImperativeRef } from '@fluentui/react-aria'; +import type { ActiveDescendantImperativeRef } from '@fluentui/react-aria'; import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; import { ContextSelector } from '@fluentui/react-context-selector'; -import type { ExtractSlotProps } from '@fluentui/react-utilities'; import { FC } from 'react'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import { PortalProps } from '@fluentui/react-portal'; @@ -21,25 +20,10 @@ import { ProviderProps } from 'react'; import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; import { SlotClassNames } from '@fluentui/react-utilities'; -import type { SlotComponentType } from '@fluentui/react-utilities'; // @public export const Combobox: ForwardRefComponent; -// @public -export type ComboboxBaseState = Required> & Pick & OptionCollectionState & SelectionState & { - activeOption?: OptionValue; - focusVisible: boolean; - ignoreNextBlur: React_2.MutableRefObject; - setActiveOption: React_2.Dispatch>; - setFocusVisible(focusVisible: boolean): void; - hasFocus: boolean; - setHasFocus(hasFocus: boolean): void; - setOpen(event: ComboboxBaseOpenEvents, newState: boolean): void; - setValue(newValue: string | undefined): void; - onOptionClick: (e: React_2.MouseEvent) => void; -}; - // @public (undocumented) export const comboboxClassNames: SlotClassNames; @@ -229,19 +213,9 @@ export const renderOptionGroup_unstable: (state: OptionGroupState) => JSX.Elemen // @public (undocumented) export type SelectionEvents = React_2.ChangeEvent | React_2.KeyboardEvent | React_2.MouseEvent; -// @internal -export function useButtonTriggerSlot(triggerFromProps: NonNullable>, ref: React_2.Ref, options: UseButtonTriggerSlotOptions): SlotComponentType>>; - // @public export const useCombobox_unstable: (props: ComboboxProps, ref: React_2.Ref) => ComboboxState; -// @internal -export const useComboboxBaseState: (props: ComboboxBaseProps & { - children?: React_2.ReactNode; - editable?: boolean; - activeDescendantController: ActiveDescendantImperativeRef; -}) => ComboboxBaseState; - // @public (undocumented) export function useComboboxContextValues(state: ComboboxBaseState & Pick): ComboboxBaseContextValues; @@ -260,9 +234,6 @@ export const useDropdown_unstable: (props: DropdownProps, ref: React_2.Ref DropdownState; -// @internal -export function useInputTriggerSlot(triggerFromProps: NonNullable>, ref: React_2.Ref, options: UseInputTriggerSlotOptions): SlotComponentType>>; - // @public export const useListbox_unstable: (props: ListboxProps, ref: React_2.Ref) => ListboxState; @@ -272,9 +243,6 @@ export const useListboxContext_unstable: (selector: ContextSelector | undefined, ref: React_2.Ref, options: UseListboxSlotOptions): SlotComponentType>> | undefined; - // @public export const useListboxStyles_unstable: (state: ListboxState) => ListboxState; diff --git a/packages/react-components/react-combobox/src/index.ts b/packages/react-components/react-combobox/src/index.ts index b98b2b64f3b82..af64ed0b895c8 100644 --- a/packages/react-components/react-combobox/src/index.ts +++ b/packages/react-components/react-combobox/src/index.ts @@ -62,8 +62,3 @@ export type { OptionGroupProps, OptionGroupSlots, OptionGroupState } from './Opt export type { OptionOnSelectData, SelectionEvents } from './Selection'; export { useComboboxFilter } from './hooks/useComboboxFilter'; -export { useComboboxBaseState } from './utils/useComboboxBaseState'; -export type { ComboboxBaseState } from './utils/ComboboxBase.types'; -export { useListboxSlot } from './utils/useListboxSlot'; -export { useInputTriggerSlot } from './components/Combobox/useInputTriggerSlot'; -export { useButtonTriggerSlot } from './components/Dropdown/useButtonTriggerSlot'; diff --git a/packages/react-components/react-tag-picker-preview/package.json b/packages/react-components/react-tag-picker-preview/package.json index 14b38041d933f..c914cd537ed50 100644 --- a/packages/react-components/react-tag-picker-preview/package.json +++ b/packages/react-components/react-tag-picker-preview/package.json @@ -41,6 +41,11 @@ "@fluentui/react-shared-contexts": "^9.14.1", "@fluentui/react-theme": "^9.1.16", "@fluentui/react-utilities": "^9.18.2", + "@fluentui/react-portal": "^9.4.15", + "@fluentui/react-aria": "^9.9.1", + "@fluentui/react-combobox": "^9.9.0", + "@fluentui/keyboard-keys": "^9.0.7", + "@fluentui/react-tabster": "^9.19.2", "@griffel/react": "^1.5.14", "@swc/helpers": "^0.5.1" }, diff --git a/packages/react-components/react-tag-picker-preview/src/utils/ComboboxBase.types.ts b/packages/react-components/react-tag-picker-preview/src/utils/ComboboxBase.types.ts new file mode 100644 index 0000000000000..a2d240f81601f --- /dev/null +++ b/packages/react-components/react-tag-picker-preview/src/utils/ComboboxBase.types.ts @@ -0,0 +1,147 @@ +import * as React from 'react'; +import type { ActiveDescendantContextValue } from '@fluentui/react-aria'; +import type { PositioningShorthand } from '@fluentui/react-positioning'; +import type { ComboboxContextValue, ListboxContextValue } from '@fluentui/react-combobox'; +import type { OptionValue, OptionCollectionState } from './OptionCollection.types'; +import { SelectionProps, SelectionState } from './Selection.types'; +import { PortalProps } from '@fluentui/react-portal'; + +/** + * ComboboxBase Props + * Shared types between Combobox and Dropdown components + */ +export type ComboboxBaseProps = SelectionProps & + Pick & { + /** + * Controls the colors and borders of the combobox trigger. + * @default 'outline' + */ + appearance?: 'filled-darker' | 'filled-lighter' | 'outline' | 'underline'; + + /** + * If set, the combobox will show an icon to clear the current value. + */ + clearable?: boolean; + + /** + * The default open state when open is uncontrolled + */ + defaultOpen?: boolean; + + /** + * The default value displayed in the trigger input or button when the combobox's value is uncontrolled + */ + defaultValue?: string; + + /** + * Render the combobox's popup inline in the DOM. + * This has accessibility benefits, particularly for touch screen readers. + */ + inlinePopup?: boolean; + + /** + * Callback when the open/closed state of the dropdown changes + */ + // eslint-disable-next-line @nx/workspace-consistent-callback-type -- can't change type of existing callback + onOpenChange?: (e: ComboboxBaseOpenEvents, data: ComboboxBaseOpenChangeData) => void; + + /** + * Sets the open/closed state of the dropdown. + * Use together with onOpenChange to fully control the dropdown's visibility + */ + open?: boolean; + + /** + * If set, the placeholder will show when no value is selected + */ + placeholder?: string; + + /** + * Configure the positioning of the combobox dropdown + * + * @defaultvalue below + */ + positioning?: PositioningShorthand; + + /** + * Controls the size of the combobox faceplate + * @default 'medium' + */ + size?: 'small' | 'medium' | 'large'; + + /** + * The value displayed by the Combobox. + * Use this with `onOptionSelect` to directly control the displayed value string + */ + value?: string; + }; + +/** + * State used in rendering Combobox + */ +export type ComboboxBaseState = Required< + Pick +> & + Pick & + OptionCollectionState & + SelectionState & { + /** + * @deprecated - no longer used internally + */ + activeOption?: OptionValue; + + /** + * @deprecated - no longer used internally and handled automatically be activedescendant utilities + * @see ACTIVEDESCENDANT_FOCUSVISIBLE_ATTRIBUTE for writing styles involving focusVisible + */ + focusVisible: boolean; + + /** + * @deprecated - no longer used internally + * Whether the next blur event should be ignored, and the combobox/dropdown will not close. + */ + ignoreNextBlur: React.MutableRefObject; + + /** + * @deprecated - no longer used internally + */ + setActiveOption: React.Dispatch>; + + /** + * @deprecated - no longer used internally and handled automatically be activedescendant utilities + * @see useSetKeyboardNavigation for imperatively setting focus visible state + */ + setFocusVisible(focusVisible: boolean): void; + + /** + * whether the combobox/dropdown currently has focus + */ + hasFocus: boolean; + + setHasFocus(hasFocus: boolean): void; + + setOpen(event: ComboboxBaseOpenEvents, newState: boolean): void; + + setValue(newValue: string | undefined): void; + + onOptionClick: (e: React.MouseEvent) => void; + }; + +/** + * Data for the Combobox onOpenChange event. + */ +export type ComboboxBaseOpenChangeData = { + open: boolean; +}; + +/* Possible event types for onOpen */ +export type ComboboxBaseOpenEvents = + | React.MouseEvent + | React.KeyboardEvent + | React.FocusEvent; + +export type ComboboxBaseContextValues = { + combobox: ComboboxContextValue; + activeDescendant: ActiveDescendantContextValue; + listbox: ListboxContextValue; +}; diff --git a/packages/react-components/react-tag-picker-preview/src/utils/OptionCollection.types.ts b/packages/react-components/react-tag-picker-preview/src/utils/OptionCollection.types.ts new file mode 100644 index 0000000000000..1deb059213f02 --- /dev/null +++ b/packages/react-components/react-tag-picker-preview/src/utils/OptionCollection.types.ts @@ -0,0 +1,45 @@ +export type OptionValue = { + /** The disabled state of the option. */ + disabled?: boolean; + + /** The `id` attribute of the option. */ + id: string; + + /** The `text` string for the option. */ + text: string; + + /** The value string of the option. */ + value: string; +}; + +export type OptionCollectionState = { + /** + * @deprecated - no longer used internally + */ + getIndexOfId(id: string): number; + + /** + * @deprecated - no longer used internally + */ + getOptionAtIndex(index: number): OptionValue | undefined; + + /** + * @deprecated - no longer used internally + */ + getOptionsMatchingText(matcher: (text: string) => boolean): OptionValue[]; + + /** The total number of options in the collection. */ + getCount: () => number; + + /** 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 value string. */ + getOptionsMatchingValue(matcher: (value: string) => boolean): OptionValue[]; + + /** The unordered option data. */ + options: OptionValue[]; + + /* A function that child options call to register their values. Returns a function to unregister the option. */ + registerOption: (option: OptionValue, element: HTMLElement) => () => void; +}; diff --git a/packages/react-components/react-tag-picker-preview/src/utils/Selection.types.ts b/packages/react-components/react-tag-picker-preview/src/utils/Selection.types.ts new file mode 100644 index 0000000000000..1cbc8affdcef6 --- /dev/null +++ b/packages/react-components/react-tag-picker-preview/src/utils/Selection.types.ts @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { OptionValue } from './OptionCollection.types'; + +export type SelectionProps = { + /** + * For an uncontrolled component, sets the initial selection. + * If this is set, the `defaultValue` prop MUST also be set. + */ + defaultSelectedOptions?: string[]; + + /** + * Sets the selection type to multiselect. + * Set this to true for multiselect, even if fully controlling selection state. + * This enables styles and accessibility properties to be set. + * @default false + */ + multiselect?: boolean; + + /* Callback when an option is selected */ + // eslint-disable-next-line @nx/workspace-consistent-callback-type -- can't change type of existing callback + onOptionSelect?: (event: SelectionEvents, data: OptionOnSelectData) => void; + + /** + * An array of selected option keys. + * Use this with `onOptionSelect` to directly control the selected option(s) + * If this is set, the `value` prop MUST also be controlled. + */ + selectedOptions?: string[]; +}; + +/* Values returned by the useSelection hook */ +export type SelectionState = { + clearSelection: (event: SelectionEvents) => void; + selectedOptions: string[]; + selectOption: (event: SelectionEvents, option: OptionValue) => void; +}; + +/* + * Data for the onOptionSelect callback. + * `optionValue` and `optionText` will be undefined if multiple options are modified at once. + */ +export type OptionOnSelectData = { + optionValue: string | undefined; + optionText: string | undefined; + selectedOptions: string[]; +}; + +/* Possible event types for onOptionSelect */ +export type SelectionEvents = + | React.ChangeEvent + | React.KeyboardEvent + | React.MouseEvent; diff --git a/packages/react-components/react-tag-picker-preview/src/utils/dropdownKeyActions.ts b/packages/react-components/react-tag-picker-preview/src/utils/dropdownKeyActions.ts new file mode 100644 index 0000000000000..d6642476bccff --- /dev/null +++ b/packages/react-components/react-tag-picker-preview/src/utils/dropdownKeyActions.ts @@ -0,0 +1,90 @@ +import * as keys from '@fluentui/keyboard-keys'; +import * as React from 'react'; + +/** + * enum of actions available in any type of managed dropdown control + * e.g. combobox, select, datepicker, menu + */ +export type DropdownActions = + | 'Close' + | 'CloseSelect' + | 'First' + | 'Last' + | 'Next' + | 'None' + | 'Open' + | 'PageDown' + | 'PageUp' + | 'Previous' + | 'Select' + | 'Tab' + | 'Type'; + +export interface DropdownActionOptions { + open?: boolean; + multiselect?: boolean; +} + +/** + * Converts a keyboard interaction into a defined action + */ +export function getDropdownActionFromKey( + e: KeyboardEvent | React.KeyboardEvent, + options: DropdownActionOptions = {}, +): DropdownActions { + const { open = true, multiselect = false } = options; + const code = e.key; + const { altKey, ctrlKey, key, metaKey } = e; + + // typing action occurs whether open or closed + if (key.length === 1 && code !== keys.Space && !altKey && !ctrlKey && !metaKey) { + return 'Type'; + } + + // handle opening the dropdown if closed + if (!open) { + if (code === keys.ArrowDown || code === keys.ArrowUp || code === keys.Enter || code === keys.Space) { + return 'Open'; + } + + // if the dropdown is closed and an action did not match the above, do nothing + return 'None'; + } + + // select or close actions + if ((code === keys.ArrowUp && altKey) || code === keys.Enter || (!multiselect && code === keys.Space)) { + return 'CloseSelect'; + } + if (multiselect && code === keys.Space) { + return 'Select'; + } + if (code === keys.Escape) { + return 'Close'; + } + + // navigation interactions + if (code === keys.ArrowDown) { + return 'Next'; + } + if (code === keys.ArrowUp) { + return 'Previous'; + } + if (code === keys.Home) { + return 'First'; + } + if (code === keys.End) { + return 'Last'; + } + if (code === keys.PageUp) { + return 'PageUp'; + } + if (code === keys.PageDown) { + return 'PageDown'; + } + if (code === keys.Tab) { + return 'Tab'; + } + + // if nothing matched, return none + return 'None'; +} diff --git a/packages/react-components/react-tag-picker-preview/src/utils/useButtonTriggerSlot.ts b/packages/react-components/react-tag-picker-preview/src/utils/useButtonTriggerSlot.ts new file mode 100644 index 0000000000000..7d1f456b36856 --- /dev/null +++ b/packages/react-components/react-tag-picker-preview/src/utils/useButtonTriggerSlot.ts @@ -0,0 +1,135 @@ +import * as React from 'react'; +import { useTimeout, mergeCallbacks } from '@fluentui/react-utilities'; +import type { Slot, ExtractSlotProps, SlotComponentType } from '@fluentui/react-utilities'; +import type { ActiveDescendantImperativeRef } from '@fluentui/react-aria'; +import { useTriggerSlot, UseTriggerSlotState } from './useTriggerSlot'; +import { getDropdownActionFromKey } from './dropdownKeyActions'; + +type UseButtonTriggerSlotOptions = { + state: UseTriggerSlotState; + defaultProps: unknown; + activeDescendantController: ActiveDescendantImperativeRef; +}; + +/** + * @internal + * useButtonTriggerSlot returns a tuple of trigger/listbox shorthand, + * with the semantics and event handlers needed for the Combobox and Dropdown components. + * The element type of the ref should always match the element type used in the trigger shorthand. + */ +export function useButtonTriggerSlot( + triggerFromProps: NonNullable>, + ref: React.Ref, + options: UseButtonTriggerSlotOptions, +): SlotComponentType>> { + const { + state: { open, setOpen, getOptionById }, + defaultProps, + activeDescendantController, + } = options; + + // jump to matching option based on typing + const searchString = React.useRef(''); + const [setKeyTimeout, clearKeyTimeout] = useTimeout(); + + const moveToNextMatchingOption = ( + matcher: (optionText: string) => boolean, + opt: { startFromNext: boolean } = { startFromNext: false }, + ) => { + const { startFromNext } = opt; + const activeOptionId = activeDescendantController.active(); + + const nextInOrder = activeDescendantController.find( + id => { + const option = getOptionById(id); + return !!option && matcher(option.text); + }, + { startFrom: startFromNext ? activeDescendantController.next({ passive: true }) : activeOptionId }, + ); + + if (nextInOrder) { + return nextInOrder; + } + + // Cycle back to first match + return activeDescendantController.find(id => { + const option = getOptionById(id); + return !!option && matcher(option.text); + }); + }; + + const moveToNextMatchingOptionWithSameCharacterHandling = () => { + if ( + moveToNextMatchingOption( + optionText => { + return optionText.toLocaleLowerCase().indexOf(searchString.current) === 0; + }, + { + // Slowly pressing the same key will cycle through options + startFromNext: searchString.current.length === 1, + }, + ) + ) { + return; + } + + // if there are no direct matches, check if the search is all the same letter, e.g. "aaa" + if ( + allCharactersSame(searchString.current) && + moveToNextMatchingOption( + optionText => { + return optionText.toLocaleLowerCase().indexOf(searchString.current[0]) === 0; + }, + { + // if the search is all the same letter, cycle through options starting with that letter + startFromNext: true, + }, + ) + ) { + return; + } + + activeDescendantController.blur(); + }; + + const onTriggerKeyDown = (ev: React.KeyboardEvent) => { + // clear timeout, if it exists + clearKeyTimeout(); + + // if the key was a char key, update search string + if (getDropdownActionFromKey(ev) === 'Type') { + // update search string + searchString.current += ev.key.toLowerCase(); + setKeyTimeout(() => { + searchString.current = ''; + }, 500); + + // update state + !open && setOpen(ev, true); + moveToNextMatchingOptionWithSameCharacterHandling(); + } + }; + + const trigger = useTriggerSlot(triggerFromProps, ref, { + state: options.state, + defaultProps, + elementType: 'button', + activeDescendantController, + }); + trigger.onKeyDown = mergeCallbacks(onTriggerKeyDown, trigger.onKeyDown); + + return trigger; +} + +/** + * @returns - whether every character in the string is the same + */ +function allCharactersSame(str: string) { + for (let i = 1; i < str.length; i++) { + if (str[i] !== str[i - 1]) { + return false; + } + } + + return true; +} diff --git a/packages/react-components/react-tag-picker-preview/src/utils/useComboboxBaseState.ts b/packages/react-components/react-tag-picker-preview/src/utils/useComboboxBaseState.ts new file mode 100644 index 0000000000000..f821c9b74c8b8 --- /dev/null +++ b/packages/react-components/react-tag-picker-preview/src/utils/useComboboxBaseState.ts @@ -0,0 +1,180 @@ +import * as React from 'react'; +import { ComboboxBaseProps, ComboboxBaseState } from './ComboboxBase.types'; +import { ActiveDescendantImperativeRef } from '@fluentui/react-aria'; +import { useOptionCollection } from '../../../react-combobox/src/utils/useOptionCollection'; +import { useSelection } from '../../../react-combobox/src/utils/useSelection'; +import { useControllableState, useEventCallback, useFirstMount } from '@fluentui/react-utilities'; +import { ComboboxBaseOpenEvents } from '../../../react-combobox/src/utils/ComboboxBase.types'; +import { OptionValue } from '../../../react-combobox/src/utils/OptionCollection.types'; + +/** + * @internal + * State shared between Combobox and Dropdown components + */ +export const useComboboxBaseState = ( + props: ComboboxBaseProps & { + children?: React.ReactNode; + editable?: boolean; + activeDescendantController: ActiveDescendantImperativeRef; + }, +): ComboboxBaseState => { + const { + appearance = 'outline', + children, + clearable = false, + editable = false, + inlinePopup = false, + mountNode = undefined, + multiselect, + onOpenChange, + size = 'medium', + activeDescendantController, + } = props; + + const optionCollection = useOptionCollection(); + const { getOptionsMatchingValue } = optionCollection; + + const { getOptionById } = optionCollection; + const getActiveOption = React.useCallback(() => { + const activeOptionId = activeDescendantController.active(); + return activeOptionId ? getOptionById(activeOptionId) : undefined; + }, [activeDescendantController, getOptionById]); + + // Keeping some kind of backwards compatible functionality here + // eslint-disable-next-line @typescript-eslint/naming-convention + const UNSAFE_activeOption = getActiveOption(); + // eslint-disable-next-line @typescript-eslint/naming-convention + const UNSAFE_setActiveOption = React.useCallback( + (option: OptionValue | undefined | ((prev: OptionValue | undefined) => OptionValue | undefined)) => { + let nextOption: OptionValue | undefined = undefined; + if (typeof option === 'function') { + const activeOption = getActiveOption(); + nextOption = option(activeOption); + } + + if (nextOption) { + activeDescendantController.focus(nextOption.id); + } else { + activeDescendantController.blur(); + } + }, + [activeDescendantController, getActiveOption], + ); + + // 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); + + // track focused state to conditionally render collapsed listbox + // when the trigger is focused - the listbox should but hidden until the open state is changed + const [hasFocus, setHasFocus] = React.useState(false); + + const ignoreNextBlur = React.useRef(false); + + const selectionState = useSelection(props); + const { selectedOptions } = selectionState; + + // calculate value based on props, internal value changes, and selected options + const isFirstMount = useFirstMount(); + const [controllableValue, setValue] = useControllableState({ + state: props.value, + initialState: undefined, + }); + + const value = React.useMemo(() => { + // don't compute the value if it is defined through props or setValue, + if (controllableValue !== undefined) { + return controllableValue; + } + + // handle defaultValue here, so it is overridden by selection + if (isFirstMount && props.defaultValue !== undefined) { + return props.defaultValue; + } + + const selectedOptionsText = getOptionsMatchingValue(optionValue => { + return selectedOptions.includes(optionValue); + }).map(option => option.text); + + if (multiselect) { + // editable inputs should not display multiple selected options in the input as text + return editable ? '' : selectedOptionsText.join(', '); + } + + return selectedOptionsText[0]; + + // do not change value after isFirstMount changes, + // we do not want to accidentally override defaultValue on a second render + // unless another value is intentionally set + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controllableValue, editable, getOptionsMatchingValue, multiselect, props.defaultValue, selectedOptions]); + + // Handle open state, which is shared with options in context + const [open, setOpenState] = useControllableState({ + state: props.open, + defaultState: props.defaultOpen, + initialState: false, + }); + + const setOpen = React.useCallback( + (event: ComboboxBaseOpenEvents, newState: boolean) => { + onOpenChange?.(event, { open: newState }); + setOpenState(newState); + }, + [onOpenChange, setOpenState], + ); + + // update active option based on change in open state + React.useEffect(() => { + 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(); + if (selectedOption?.id) { + activeDescendantController.focus(selectedOption.id); + } + } + } else { + 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, activeDescendantController]); + + // Fallback focus when children are updated in an open popover results in no item being focused + React.useEffect(() => { + if (open) { + if (!activeDescendantController.active()) { + activeDescendantController.first(); + } + } + // this should only be run in response to changes in the open state or children + }, [open, children, activeDescendantController]); + + return { + ...optionCollection, + ...selectionState, + activeOption: UNSAFE_activeOption, + appearance, + clearable, + focusVisible, + ignoreNextBlur, + inlinePopup, + mountNode, + open, + hasFocus, + setActiveOption: UNSAFE_setActiveOption, + setFocusVisible, + setHasFocus, + setOpen, + setValue, + size, + value, + multiselect, + onOptionClick: useEventCallback((e: React.MouseEvent) => { + if (!multiselect) { + setOpen(e, false); + } + }), + }; +}; diff --git a/packages/react-components/react-tag-picker-preview/src/utils/useInputTriggerSlot.ts b/packages/react-components/react-tag-picker-preview/src/utils/useInputTriggerSlot.ts new file mode 100644 index 0000000000000..dbdb0deb80344 --- /dev/null +++ b/packages/react-components/react-tag-picker-preview/src/utils/useInputTriggerSlot.ts @@ -0,0 +1,170 @@ +import * as React from 'react'; +import type { ActiveDescendantImperativeRef } from '@fluentui/react-aria'; +import { mergeCallbacks, useEventCallback } from '@fluentui/react-utilities'; +import type { ExtractSlotProps, Slot, SlotComponentType } from '@fluentui/react-utilities'; +import { ArrowLeft, ArrowRight } from '@fluentui/keyboard-keys'; +import { useTriggerSlot, UseTriggerSlotState } from './useTriggerSlot'; +import { ComboboxProps, ComboboxState } from '@fluentui/react-combobox'; +import { OptionValue } from './OptionCollection.types'; +import { getDropdownActionFromKey } from './dropdownKeyActions'; + +type UsedComboboxState = UseTriggerSlotState & + Pick; + +type UseInputTriggerSlotOptions = { + state: UsedComboboxState; + freeform: boolean | undefined; + defaultProps: Partial; + activeDescendantController: ActiveDescendantImperativeRef; +}; + +/** + * @internal + * useInputTriggerSlot returns a tuple of trigger/listbox shorthand, + * with the semantics and event handlers needed for the Combobox and Dropdown components. + * The element type of the ref should always match the element type used in the trigger shorthand. + */ +export function useInputTriggerSlot( + triggerFromProps: NonNullable>, + ref: React.Ref, + options: UseInputTriggerSlotOptions, +): SlotComponentType>> { + const { + state: { + open, + value, + selectOption, + setValue, + multiselect, + selectedOptions, + clearSelection, + 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); + } + + // reset typed value when the input loses focus while collapsed, unless freeform is true + setValue(undefined); + } + }; + + const getOptionFromInput = (inputValue: string): OptionValue | undefined => { + const searchString = inputValue?.trim().toLowerCase(); + + if (!searchString || searchString.length === 0) { + activeDescendantController.blur(); + return; + } + + const matcher = (optionText: string) => optionText.toLowerCase().indexOf(searchString) === 0; + const match = activeDescendantController.find(id => { + const option = getOptionById(id); + return !!option && matcher(option.text); + }); + + if (!match) { + activeDescendantController.blur(); + return undefined; + } + + return getOptionById(match); + }; + + // update value and active option based on input + const onChange = (ev: React.ChangeEvent) => { + const inputValue = ev.target.value; + // update uncontrolled value + setValue(inputValue); + + // handle updating active option based on input + const matchingOption = getOptionFromInput(inputValue); + + // clear selection for single-select if the input value no longer matches the selection + if (!multiselect && selectedOptions.length === 1 && (inputValue.length < 1 || !matchingOption)) { + clearSelection(ev); + } + }; + + const trigger = useTriggerSlot(triggerFromProps, ref, { + state: options.state, + defaultProps, + elementType: 'input', + activeDescendantController, + }); + + trigger.onChange = mergeCallbacks(trigger.onChange, onChange); + trigger.onBlur = mergeCallbacks(trigger.onBlur, onBlur); + + // NVDA and JAWS have bugs that suppress reading the input value text when aria-activedescendant is set + // To prevent this, we clear the HTML attribute (but save the state) when a user presses left/right arrows + // ref: https://github.com/microsoft/fluentui/issues/26359#issuecomment-1397759888 + const [hideActiveDescendant, setHideActiveDescendant] = React.useState(false); + // save the typing vs. navigating options state, as the space key should behave differently in each case + // we do not want to update the combobox when this changes, just save the value between renders + const isTyping = React.useRef(false); + + /** + * Freeform combobox should not select + */ + const defaultOnKeyDown = trigger.onKeyDown; + const onKeyDown = useEventCallback((ev: React.KeyboardEvent) => { + if (!open && getDropdownActionFromKey(ev) === 'Type') { + setOpen(ev, true); + } + + // clear activedescendant when moving the text insertion cursor + if (ev.key === ArrowLeft || ev.key === ArrowRight) { + setHideActiveDescendant(true); + } else { + setHideActiveDescendant(false); + } + + // update typing state to true if the user is typing + const action = getDropdownActionFromKey(ev, { open, multiselect }); + if (action === 'Type') { + isTyping.current = true; + } + // otherwise, update the typing state to false if opening or navigating dropdown options + // other actions, like closing the dropdown, should not impact typing state. + else if ( + (action === 'Open' && ev.key !== ' ') || + action === 'Next' || + action === 'Previous' || + action === 'First' || + action === 'Last' || + action === 'PageUp' || + action === 'PageDown' + ) { + isTyping.current = false; + } + + // allow space to insert a character if freeform & the last action was typing, or if the popup is closed + if ((isTyping.current || !open) && ev.key === ' ') { + triggerFromProps?.onKeyDown?.(ev); + return; + } + + defaultOnKeyDown?.(ev); + }); + + trigger.onKeyDown = onKeyDown; + + if (hideActiveDescendant) { + trigger['aria-activedescendant'] = undefined; + } + + return trigger; +} diff --git a/packages/react-components/react-tag-picker-preview/src/utils/useListboxSlot.ts b/packages/react-components/react-tag-picker-preview/src/utils/useListboxSlot.ts new file mode 100644 index 0000000000000..b80a71c7c77ab --- /dev/null +++ b/packages/react-components/react-tag-picker-preview/src/utils/useListboxSlot.ts @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { + mergeCallbacks, + useId, + useEventCallback, + slot, + isResolvedShorthand, + useMergedRefs, +} from '@fluentui/react-utilities'; +import type { ExtractSlotProps, Slot, SlotComponentType } from '@fluentui/react-utilities'; +import type { ComboboxBaseState } from './ComboboxBase.types'; +import { Listbox, ListboxProps } from '@fluentui/react-combobox'; + +export type UseListboxSlotState = Pick; + +type UseListboxSlotOptions = { + state: UseListboxSlotState; + triggerRef: React.RefObject | React.RefObject; + defaultProps?: Partial; +}; + +/** + * @internal + * @returns listbox slot with desired behaviour and props + */ +export function useListboxSlot( + listboxSlotFromProp: Slot | undefined, + ref: React.Ref, + options: UseListboxSlotOptions, +): SlotComponentType>> | undefined { + const { + state: { multiselect }, + triggerRef, + defaultProps, + } = options; + + const listboxId = useId( + 'fluent-listbox', + isResolvedShorthand(listboxSlotFromProp) ? listboxSlotFromProp.id : undefined, + ); + + const listboxSlot = slot.optional(listboxSlotFromProp, { + renderByDefault: true, + elementType: Listbox, + defaultProps: { + id: listboxId, + multiselect, + tabIndex: undefined, + ...defaultProps, + }, + }); + + /** + * Clicking on the listbox should never blur the trigger + * in a combobox + */ + const onMouseDown = useEventCallback( + mergeCallbacks((event: React.MouseEvent) => { + event.preventDefault(); + }, listboxSlot?.onMouseDown), + ); + + const onClick = useEventCallback( + mergeCallbacks((event: React.MouseEvent) => { + event.preventDefault(); + triggerRef.current?.focus(); + }, listboxSlot?.onClick), + ); + + const listboxRef = useMergedRefs(listboxSlot?.ref, ref); + if (listboxSlot) { + listboxSlot.ref = listboxRef; + listboxSlot.onMouseDown = onMouseDown; + listboxSlot.onClick = onClick; + } + + return listboxSlot; +} diff --git a/packages/react-components/react-tag-picker-preview/src/utils/useTriggerSlot.ts b/packages/react-components/react-tag-picker-preview/src/utils/useTriggerSlot.ts new file mode 100644 index 0000000000000..3761ffb01376f --- /dev/null +++ b/packages/react-components/react-tag-picker-preview/src/utils/useTriggerSlot.ts @@ -0,0 +1,193 @@ +import * as React from 'react'; +import { useSetKeyboardNavigation } from '@fluentui/react-tabster'; +import type { ActiveDescendantImperativeRef } from '@fluentui/react-aria'; +import { mergeCallbacks, slot, useEventCallback, useMergedRefs } from '@fluentui/react-utilities'; +import type { ExtractSlotProps, Slot, SlotComponentType } from '@fluentui/react-utilities'; +import { getDropdownActionFromKey } from '../utils/dropdownKeyActions'; +import type { ComboboxBaseState } from './ComboboxBase.types'; +import { OptionValue } from './OptionCollection.types'; + +export type UseTriggerSlotState = Pick< + ComboboxBaseState, + 'open' | 'getOptionById' | 'selectOption' | 'setOpen' | 'multiselect' | 'setHasFocus' +>; + +type UseTriggerSlotOptions = { + state: UseTriggerSlotState; + defaultProps: unknown; + activeDescendantController: ActiveDescendantImperativeRef; +}; + +export function useTriggerSlot( + triggerSlotFromProp: NonNullable>, + ref: React.Ref, + options: UseTriggerSlotOptions & { elementType: 'button' }, +): SlotComponentType>>; + +export function useTriggerSlot( + triggerSlotFromProp: NonNullable>, + ref: React.Ref, + options: UseTriggerSlotOptions & { elementType: 'input' }, +): SlotComponentType>>; + +/** + * Shared trigger behaviour for combobox and dropdown + * @returns trigger slot with desired behaviour and props + */ +export function useTriggerSlot( + triggerSlotFromProp: NonNullable> | NonNullable>, + ref: React.Ref | React.Ref, + options: UseTriggerSlotOptions & { elementType: 'input' | 'button' }, +): SlotComponentType>> | SlotComponentType>> { + const { + state: { open, setOpen, setHasFocus }, + defaultProps, + elementType, + activeDescendantController, + } = options; + + const trigger = slot.always(triggerSlotFromProp, { + defaultProps: { + type: 'text', + 'aria-expanded': open, + role: 'combobox', + ...(typeof defaultProps === 'object' && defaultProps), + }, + elementType, + }); + + // handle trigger focus/blur + const triggerRef = React.useRef(null); + trigger.ref = useMergedRefs(triggerRef, trigger.ref, ref) as React.Ref; + + // the trigger should open/close the popup on click or blur + trigger.onBlur = mergeCallbacks((event: React.FocusEvent & React.FocusEvent) => { + setOpen(event, false); + setHasFocus(false); + }, trigger.onBlur); + + trigger.onFocus = mergeCallbacks( + (event: React.FocusEvent & React.FocusEvent) => { + if (event.target === event.currentTarget) { + setHasFocus(true); + } + }, + trigger.onFocus, + ); + trigger.onClick = mergeCallbacks( + (event: React.MouseEvent & React.MouseEvent) => { + setOpen(event, !open); + }, + trigger.onClick, + ); + + // handle combobox keyboard interaction + trigger.onKeyDown = mergeCallbacks( + useTriggerKeydown({ activeDescendantController, ...options.state }), + trigger.onKeyDown, + ); + + return trigger as SlotComponentType>>; +} + +function useTriggerKeydown( + options: { + activeDescendantController: ActiveDescendantImperativeRef; + } & Pick, +) { + const { activeDescendantController, getOptionById, setOpen, selectOption, multiselect, open } = options; + + const getActiveOption = React.useCallback(() => { + const activeOptionId = activeDescendantController.active(); + return activeOptionId ? getOptionById(activeOptionId) : undefined; + }, [activeDescendantController, getOptionById]); + + const first = () => { + activeDescendantController.first(); + }; + + const next = (activeOption: OptionValue | undefined) => { + if (activeOption) { + activeDescendantController.next(); + } else { + activeDescendantController.first(); + } + }; + + const previous = (activeOption: OptionValue | undefined) => { + if (activeOption) { + activeDescendantController.prev(); + } else { + activeDescendantController.first(); + } + }; + + const pageUp = () => { + for (let i = 0; i < 10; i++) { + activeDescendantController.prev(); + } + }; + + const pageDown = () => { + for (let i = 0; i < 10; i++) { + activeDescendantController.next(); + } + }; + + const setKeyboardNavigation = useSetKeyboardNavigation(); + return useEventCallback((e: React.KeyboardEvent & React.KeyboardEvent) => { + const action = getDropdownActionFromKey(e, { open, multiselect }); + const activeOption = getActiveOption(); + + switch (action) { + case 'First': + case 'Next': + case 'Previous': + case 'PageDown': + case 'PageUp': + case 'Open': + case 'Close': + case 'CloseSelect': + case 'Select': + e.preventDefault(); + break; + } + + setKeyboardNavigation(true); + + switch (action) { + case 'First': + first(); + break; + case 'Next': + next(activeOption); + break; + case 'Previous': + previous(activeOption); + break; + case 'PageDown': + pageDown(); + break; + case 'PageUp': + pageUp(); + break; + case 'Open': + setOpen(e, true); + break; + case 'Close': + // stop propagation for escape key to avoid dismissing any parent popups + e.stopPropagation(); + setOpen(e, false); + break; + case 'CloseSelect': + !multiselect && !activeOption?.disabled && setOpen(e, false); + // fallthrough + case 'Select': + activeOption && selectOption(e, activeOption); + break; + case 'Tab': + !multiselect && activeOption && selectOption(e, activeOption); + break; + } + }); +}