diff --git a/change/@fluentui-react-combobox-a8acb294-e1c7-46f6-b291-196ddda0ffe4.json b/change/@fluentui-react-combobox-a8acb294-e1c7-46f6-b291-196ddda0ffe4.json new file mode 100644 index 00000000000000..140ce73530d34b --- /dev/null +++ b/change/@fluentui-react-combobox-a8acb294-e1c7-46f6-b291-196ddda0ffe4.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: move freeform and disabled to ComboboxBase types", + "packageName": "@fluentui/react-combobox", + "email": "bernardo.sunderhus@gmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-tag-picker-preview-74ebb8dc-b7e3-4f37-90dc-a5780fc43f0a.json b/change/@fluentui-react-tag-picker-preview-74ebb8dc-b7e3-4f37-90dc-a5780fc43f0a.json new file mode 100644 index 00000000000000..242e65778c5870 --- /dev/null +++ b/change/@fluentui-react-tag-picker-preview-74ebb8dc-b7e3-4f37-90dc-a5780fc43f0a.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feature: adds freeform support for TagPicker", + "packageName": "@fluentui/react-tag-picker-preview", + "email": "bernardo.sunderhus@gmail.com", + "dependentChangeType": "patch" +} 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 1fa0a736f7a378..58f616e75ce6a4 100644 --- a/packages/react-components/react-combobox/etc/react-combobox.api.md +++ b/packages/react-components/react-combobox/etc/react-combobox.api.md @@ -44,7 +44,6 @@ export type ComboboxOpenEvents = ComboboxBaseOpenEvents; // @public export type ComboboxProps = Omit, 'input'>, 'children' | 'size'> & ComboboxBaseProps & { - freeform?: boolean; children?: React_2.ReactNode; }; 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 8c3b08ac9e9dc7..49ed0916bcd4bd 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 @@ -32,11 +32,6 @@ export type ComboboxSlots = { */ export type ComboboxProps = Omit, 'input'>, 'children' | 'size'> & ComboboxBaseProps & { - /* - * Whether the ComboBox allows freeform user input, rather than restricting to the provided options. - */ - freeform?: boolean; - /* * The primary slot, ``, does not support children so we need to explicitly include it here. */ 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 4c6731353c8ec9..0240410b10c6a7 100644 --- a/packages/react-components/react-combobox/src/components/Combobox/useCombobox.tsx +++ b/packages/react-components/react-combobox/src/components/Combobox/useCombobox.tsx @@ -13,8 +13,6 @@ import { import { useComboboxBaseState } from '../../utils/useComboboxBaseState'; import { useComboboxPositioning } from '../../utils/useComboboxPositioning'; import { Listbox } from '../Listbox/Listbox'; -import type { SelectionEvents } from '../../utils/Selection.types'; -import type { OptionValue } from '../../utils/OptionCollection.types'; import type { ComboboxProps, ComboboxState } from './Combobox.types'; import { useListboxSlot } from '../../utils/useListboxSlot'; import { useInputTriggerSlot } from './useInputTriggerSlot'; @@ -41,20 +39,9 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref { - setValue(undefined); - selectOption(ev, option); - }; - - baseState.setOpen = (ev, newState: boolean) => { - if (disabled) { - return; - } - - if (!newState && !freeform) { - setValue(undefined); - } - - setOpen(ev, newState); - }; - const triggerRef = React.useRef(null); const listbox = useListboxSlot(props.listbox, useMergedRefs(comboboxPopupRef, activeDescendantListboxRef), { 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 bfbc0dbde29f37..e5ab9eac62450b 100644 --- a/packages/react-components/react-combobox/src/utils/ComboboxBase.types.ts +++ b/packages/react-components/react-combobox/src/utils/ComboboxBase.types.ts @@ -75,13 +75,18 @@ export type ComboboxBaseProps = SelectionProps & * Use this with `onOptionSelect` to directly control the displayed value string */ value?: string; + /* + * Whether the ComboBox allows freeform user input, rather than restricting to the provided options. + */ + freeform?: boolean; + disabled?: boolean; }; /** * State used in rendering Combobox */ export type ComboboxBaseState = Required< - Pick + Pick > & Pick & OptionCollectionState & diff --git a/packages/react-components/react-combobox/src/utils/useComboboxBaseState.ts b/packages/react-components/react-combobox/src/utils/useComboboxBaseState.ts index 33d5ef9853ab3b..1bc72c7e4e5f0f 100644 --- a/packages/react-components/react-combobox/src/utils/useComboboxBaseState.ts +++ b/packages/react-components/react-combobox/src/utils/useComboboxBaseState.ts @@ -1,10 +1,12 @@ import * as React from 'react'; +import * as ReactDOM from 'react-dom'; import { useControllableState, useEventCallback, 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'; import type { ComboboxBaseProps, ComboboxBaseOpenEvents, ComboboxBaseState } from './ComboboxBase.types'; +import { SelectionEvents } from './Selection.types'; /** * @internal @@ -28,6 +30,8 @@ export const useComboboxBaseState = ( onOpenChange, size = 'medium', activeDescendantController, + freeform = false, + disabled = false, } = props; const optionCollection = useOptionCollection(); @@ -70,9 +74,6 @@ export const useComboboxBaseState = ( 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({ @@ -80,6 +81,19 @@ export const useComboboxBaseState = ( initialState: undefined, }); + const { selectedOptions, selectOption: baseSelectOption, clearSelection } = useSelection(props); + + // reset any typed value when an option is selected + const selectOption = React.useCallback( + (ev: SelectionEvents, option: OptionValue) => { + ReactDOM.unstable_batchedUpdates(() => { + setValue(undefined); + baseSelectOption(ev, option); + }); + }, + [setValue, baseSelectOption], + ); + const value = React.useMemo(() => { // don't compute the value if it is defined through props or setValue, if (controllableValue !== undefined) { @@ -117,10 +131,18 @@ export const useComboboxBaseState = ( const setOpen = React.useCallback( (event: ComboboxBaseOpenEvents, newState: boolean) => { + if (disabled) { + return; + } onOpenChange?.(event, { open: newState }); - setOpenState(newState); + ReactDOM.unstable_batchedUpdates(() => { + if (!newState && !freeform) { + setValue(undefined); + } + setOpenState(newState); + }); }, - [onOpenChange, setOpenState], + [onOpenChange, setOpenState, setValue, freeform, disabled], ); // update active option based on change in open state @@ -152,7 +174,11 @@ export const useComboboxBaseState = ( return { ...optionCollection, - ...selectionState, + freeform, + disabled, + selectOption, + clearSelection, + selectedOptions, activeOption: UNSAFE_activeOption, appearance, clearable, diff --git a/packages/react-components/react-tag-picker-preview/etc/react-tag-picker-preview.api.md b/packages/react-components/react-tag-picker-preview/etc/react-tag-picker-preview.api.md index d5dc3286579b6b..b020b13e085ffb 100644 --- a/packages/react-components/react-tag-picker-preview/etc/react-tag-picker-preview.api.md +++ b/packages/react-components/react-tag-picker-preview/etc/react-tag-picker-preview.api.md @@ -133,7 +133,6 @@ export const tagPickerInputClassNames: SlotClassNames; // @public export type TagPickerInputProps = Omit>, 'children' | 'size' | 'defaultValue'> & Pick & { - freeform?: boolean; disabled?: boolean; value?: string; }; @@ -201,7 +200,7 @@ export type TagPickerOptionSlots = Pick & { export type TagPickerOptionState = ComponentState & Pick; // @public -export type TagPickerProps = ComponentProps & Pick & Pick, 'size' | 'appearance'> & { +export type TagPickerProps = ComponentProps & Pick & Pick, 'size' | 'appearance'> & { onOpenChange?: EventHandler; onOptionSelect?: EventHandler; children: [JSX.Element, JSX.Element] | JSX.Element; @@ -211,7 +210,7 @@ export type TagPickerProps = ComponentProps & Pick & Pick & Pick & { +export type TagPickerState = ComponentState & Pick & Pick & { trigger: React_2.ReactNode; popover?: React_2.ReactNode; }; diff --git a/packages/react-components/react-tag-picker-preview/src/components/TagPicker/TagPicker.types.ts b/packages/react-components/react-tag-picker-preview/src/components/TagPicker/TagPicker.types.ts index 0b1ae6b2b7824c..f2883224a32fb3 100644 --- a/packages/react-components/react-tag-picker-preview/src/components/TagPicker/TagPicker.types.ts +++ b/packages/react-components/react-tag-picker-preview/src/components/TagPicker/TagPicker.types.ts @@ -34,7 +34,7 @@ export type TagPickerOnOpenChangeData = { open: boolean } & ( export type TagPickerProps = ComponentProps & Pick< ComboboxProps, - 'positioning' | 'disabled' | 'defaultOpen' | 'selectedOptions' | 'defaultSelectedOptions' | 'open' + 'positioning' | 'disabled' | 'defaultOpen' | 'selectedOptions' | 'defaultSelectedOptions' | 'open' | 'freeform' > & Pick, 'size' | 'appearance'> & { onOpenChange?: EventHandler; @@ -67,10 +67,12 @@ export type TagPickerState = ComponentState & | 'appearance' | 'clearSelection' | 'getOptionById' + | 'freeform' + | 'disabled' > & Pick< TagPickerContextValue, - 'triggerRef' | 'secondaryActionRef' | 'popoverId' | 'popoverRef' | 'targetRef' | 'size' | 'disabled' + 'triggerRef' | 'secondaryActionRef' | 'popoverId' | 'popoverRef' | 'targetRef' | 'size' > & { trigger: React.ReactNode; popover?: React.ReactNode; diff --git a/packages/react-components/react-tag-picker-preview/src/components/TagPicker/useTagPicker.ts b/packages/react-components/react-tag-picker-preview/src/components/TagPicker/useTagPicker.ts index 5703713ddb9663..3018b12483a5d2 100644 --- a/packages/react-components/react-tag-picker-preview/src/components/TagPicker/useTagPicker.ts +++ b/packages/react-components/react-tag-picker-preview/src/components/TagPicker/useTagPicker.ts @@ -24,7 +24,7 @@ export const useTagPicker_unstable = (props: TagPickerProps): TagPickerState => const popoverId = useId('picker-listbox'); const triggerInnerRef = React.useRef(null); const secondaryActionRef = React.useRef(null); - const { positioning, size = 'medium', disabled = false } = props; + const { positioning, size = 'medium' } = props; // Set a default set of fallback positions to try if the dropdown does not fit on screen const fallbackPositions: PositioningShorthandValue[] = ['above', 'after', 'after-top', 'before', 'before-top']; @@ -75,9 +75,6 @@ export const useTagPicker_unstable = (props: TagPickerProps): TagPickerState => if (isHTMLElement(event.target) && elementContains(secondaryActionRef.current, event.target)) { return; } - if (disabled) { - return; - } comboboxState.setOpen(event, newValue); }); @@ -87,7 +84,7 @@ export const useTagPicker_unstable = (props: TagPickerProps): TagPickerState => trigger, popover: comboboxState.open || comboboxState.hasFocus ? popover : undefined, popoverId, - disabled, + disabled: comboboxState.disabled, triggerRef: useMergedRefs(triggerInnerRef, activeParentRef), popoverRef: useMergedRefs(listboxRef, containerRef), secondaryActionRef, @@ -110,6 +107,7 @@ export const useTagPicker_unstable = (props: TagPickerProps): TagPickerState => setValue: comboboxState.setValue, multiselect: comboboxState.multiselect, value: comboboxState.value, + freeform: comboboxState.freeform, }; }; diff --git a/packages/react-components/react-tag-picker-preview/src/components/TagPicker/useTagPickerContextValues.ts b/packages/react-components/react-tag-picker-preview/src/components/TagPicker/useTagPickerContextValues.ts index 647a24404fe16d..8756e1370e4d7f 100644 --- a/packages/react-components/react-tag-picker-preview/src/components/TagPicker/useTagPickerContextValues.ts +++ b/packages/react-components/react-tag-picker-preview/src/components/TagPicker/useTagPickerContextValues.ts @@ -23,6 +23,7 @@ export function useTagPickerContextValues(state: TagPickerState): TagPickerConte open, popoverId, disabled, + freeform, } = state; return { activeDescendant: React.useMemo( @@ -57,6 +58,7 @@ export function useTagPickerContextValues(state: TagPickerState): TagPickerConte open, popoverId, disabled, + freeform, }, }; } diff --git a/packages/react-components/react-tag-picker-preview/src/components/TagPickerInput/TagPickerInput.types.ts b/packages/react-components/react-tag-picker-preview/src/components/TagPickerInput/TagPickerInput.types.ts index c277e4f58d843a..a94a810f0372b2 100644 --- a/packages/react-components/react-tag-picker-preview/src/components/TagPickerInput/TagPickerInput.types.ts +++ b/packages/react-components/react-tag-picker-preview/src/components/TagPickerInput/TagPickerInput.types.ts @@ -14,7 +14,6 @@ export type TagPickerInputProps = Omit< 'children' | 'size' | 'defaultValue' > & Pick & { - freeform?: boolean; disabled?: boolean; value?: string; }; diff --git a/packages/react-components/react-tag-picker-preview/src/components/TagPickerInput/useTagPickerInput.tsx b/packages/react-components/react-tag-picker-preview/src/components/TagPickerInput/useTagPickerInput.tsx index 614d4f503e095a..035bc2358dd0e1 100644 --- a/packages/react-components/react-tag-picker-preview/src/components/TagPickerInput/useTagPickerInput.tsx +++ b/packages/react-components/react-tag-picker-preview/src/components/TagPickerInput/useTagPickerInput.tsx @@ -22,6 +22,7 @@ export const useTagPickerInput_unstable = ( ): TagPickerInputState => { const { controller: activeDescendantController } = useActiveDescendantContext(); const size = useTagPickerContext_unstable(ctx => ctx.size); + const freeform = useTagPickerContext_unstable(ctx => ctx.freeform); const contextDisabled = useTagPickerContext_unstable(ctx => ctx.disabled); const { triggerRef, @@ -36,7 +37,7 @@ export const useTagPickerInput_unstable = ( multiselect, popoverId, value: contextValue, - } = usePickerContext(); + } = useTagPickerContexts(); const { value = contextValue, disabled = contextDisabled } = props; @@ -71,7 +72,7 @@ export const useTagPickerInput_unstable = ( useMergedRefs(triggerRef, ref), { activeDescendantController, - freeform: props.freeform, + freeform, state: { clearSelection, getOptionById, @@ -99,7 +100,7 @@ export const useTagPickerInput_unstable = ( return state; }; -function usePickerContext() { +function useTagPickerContexts() { return { triggerRef: useTagPickerContext_unstable(ctx => ctx.triggerRef), clearSelection: useTagPickerContext_unstable(ctx => ctx.clearSelection), diff --git a/packages/react-components/react-tag-picker-preview/src/contexts/TagPickerContext.ts b/packages/react-components/react-tag-picker-preview/src/contexts/TagPickerContext.ts index cdaa6bbe07c2c5..cf9f154ba976ac 100644 --- a/packages/react-components/react-tag-picker-preview/src/contexts/TagPickerContext.ts +++ b/packages/react-components/react-tag-picker-preview/src/contexts/TagPickerContext.ts @@ -18,6 +18,8 @@ export interface TagPickerContextValue | 'setValue' | 'value' | 'appearance' + | 'disabled' + | 'freeform' > { triggerRef: React.RefObject; popoverRef: React.RefObject; @@ -25,7 +27,6 @@ export interface TagPickerContextValue targetRef: React.RefObject; secondaryActionRef: React.RefObject; size: TagPickerSize; - disabled: boolean; } /** @@ -50,6 +51,7 @@ export const tagPickerContextDefaultValue: TagPickerContextValue = { size: 'medium', appearance: 'outline', disabled: false, + freeform: false, }; const TagPickerContext = createContext(undefined); 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 index a2d240f81601fe..eb791502fd840c 100644 --- 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 @@ -74,13 +74,18 @@ export type ComboboxBaseProps = SelectionProps & * Use this with `onOptionSelect` to directly control the displayed value string */ value?: string; + /* + * Whether the ComboBox allows freeform user input, rather than restricting to the provided options. + */ + freeform?: boolean; + disabled?: boolean; }; /** * State used in rendering Combobox */ export type ComboboxBaseState = Required< - Pick + Pick > & Pick & OptionCollectionState & 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 index 5285a1932e10b1..e2c18d091595b8 100644 --- a/packages/react-components/react-tag-picker-preview/src/utils/useComboboxBaseState.ts +++ b/packages/react-components/react-tag-picker-preview/src/utils/useComboboxBaseState.ts @@ -1,10 +1,12 @@ import * as React from 'react'; +import * as ReactDOM from 'react-dom'; import { ComboboxBaseOpenEvents, ComboboxBaseProps, ComboboxBaseState } from './ComboboxBase.types'; import { ActiveDescendantImperativeRef } from '@fluentui/react-aria'; import { useControllableState, useEventCallback, useFirstMount } from '@fluentui/react-utilities'; import { OptionValue } from './OptionCollection.types'; import { useOptionCollection } from './useOptionCollection'; import { useSelection } from './useSelection'; +import { SelectionEvents } from './Selection.types'; /** * @internal @@ -23,6 +25,8 @@ export const useComboboxBaseState = ( clearable = false, editable = false, inlinePopup = false, + freeform = false, + disabled = false, mountNode = undefined, multiselect, onOpenChange, @@ -70,8 +74,7 @@ export const useComboboxBaseState = ( const ignoreNextBlur = React.useRef(false); - const selectionState = useSelection(props); - const { selectedOptions } = selectionState; + const { selectedOptions, selectOption: baseSelectOption, clearSelection } = useSelection(props); // calculate value based on props, internal value changes, and selected options const isFirstMount = useFirstMount(); @@ -80,6 +83,17 @@ export const useComboboxBaseState = ( initialState: undefined, }); + // reset any typed value when an option is selected + const selectOption = React.useCallback( + (ev: SelectionEvents, option: OptionValue) => { + ReactDOM.unstable_batchedUpdates(() => { + setValue(undefined); + baseSelectOption(ev, option); + }); + }, + [setValue, baseSelectOption], + ); + const value = React.useMemo(() => { // don't compute the value if it is defined through props or setValue, if (controllableValue !== undefined) { @@ -152,7 +166,11 @@ export const useComboboxBaseState = ( return { ...optionCollection, - ...selectionState, + selectedOptions, + selectOption, + clearSelection, + freeform, + disabled, activeOption: UNSAFE_activeOption, appearance, clearable, 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 index f013860299f29f..507e2526eccc58 100644 --- a/packages/react-components/react-tag-picker-preview/src/utils/useInputTriggerSlot.ts +++ b/packages/react-components/react-tag-picker-preview/src/utils/useInputTriggerSlot.ts @@ -1,15 +1,16 @@ import * as React from 'react'; import type { ActiveDescendantImperativeRef } from '@fluentui/react-aria'; -import { useEventCallback } from '@fluentui/react-utilities'; +import { mergeCallbacks, useEventCallback } from '@fluentui/react-utilities'; import type { ExtractSlotProps, Slot, SlotComponentType } from '@fluentui/react-utilities'; -import { useTriggerSlot, UseTriggerSlotState } from './useTriggerSlot'; -import { ComboboxProps, ComboboxState } from '@fluentui/react-combobox'; +import { ArrowLeft, ArrowRight } from '@fluentui/keyboard-keys'; +import { UseTriggerSlotState, useTriggerSlot } from './useTriggerSlot'; +import { ComboboxBaseState } from './ComboboxBase.types'; +import { ComboboxProps } from '@fluentui/react-combobox'; import { OptionValue } from './OptionCollection.types'; import { getDropdownActionFromKey } from './dropdownKeyActions'; -import { ArrowLeft, ArrowRight } from '@fluentui/keyboard-keys'; type UsedComboboxState = UseTriggerSlotState & - Pick; + Pick; type UseInputTriggerSlotOptions = { state: UsedComboboxState; @@ -46,6 +47,21 @@ export function useInputTriggerSlot( activeDescendantController, } = options; + const onBlur = (event: 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(event, 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(); @@ -68,90 +84,88 @@ export function useInputTriggerSlot( return getOptionById(match); }; + // update value and active option based on input + const onChange = (event: React.ChangeEvent) => { + const inputValue = event.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(event); + } + }; + + 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); - const trigger = useTriggerSlot( - { - ...triggerFromProps, - 'aria-activedescendant': undefined, - // update value and active option based on input - onChange: useEventCallback((event: React.ChangeEvent) => { - triggerFromProps.onChange?.(event); - const inputValue = event.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(event); - } - }), - onBlur: useEventCallback((event: React.FocusEvent) => { - triggerFromProps.onBlur?.(event); - // 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(event, activeOption); - } - - // reset typed value when the input loses focus while collapsed, unless freeform is true - setValue(undefined); - } - }), - onKeyDown: useEventCallback((event: React.KeyboardEvent) => { - if (!open && getDropdownActionFromKey(event) === 'Type') { - setOpen(event, true); - } - // clear activedescendant when moving the text insertion cursor - if (event.key === ArrowLeft || event.key === ArrowRight) { - activeDescendantController.hideAttributes(); - } else { - activeDescendantController.showAttributes(); - } - - // update typing state to true if the user is typing - const action = getDropdownActionFromKey(event, { 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' && event.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) && event.key === ' ') { - triggerFromProps?.onKeyDown?.(event); - return; - } - - triggerFromProps.onKeyDown?.(event); - }), - }, - ref, - { - state: options.state, - defaultProps, - elementType: 'input', - activeDescendantController, - }, - ); + /** + * Freeform combobox should not select + */ + const defaultOnKeyDown = trigger.onKeyDown; + const onKeyDown = useEventCallback((event: React.KeyboardEvent) => { + if (!open && getDropdownActionFromKey(event) === 'Type') { + setOpen(event, true); + } + + // clear activedescendant when moving the text insertion cursor + if (event.key === ArrowLeft || event.key === ArrowRight) { + setHideActiveDescendant(true); + } else { + setHideActiveDescendant(false); + } + + // update typing state to true if the user is typing + const action = getDropdownActionFromKey(event, { 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' && event.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) && event.key === ' ') { + triggerFromProps?.onKeyDown?.(event); + return; + } + + defaultOnKeyDown?.(event); + }); + + trigger.onKeyDown = onKeyDown; + + if (hideActiveDescendant) { + trigger['aria-activedescendant'] = undefined; + } + return trigger; } diff --git a/packages/react-components/react-tag-picker-preview/stories/TagPicker/TagPickerFreeform.stories.tsx b/packages/react-components/react-tag-picker-preview/stories/TagPicker/TagPickerFreeform.stories.tsx new file mode 100644 index 00000000000000..9f70d93a545517 --- /dev/null +++ b/packages/react-components/react-tag-picker-preview/stories/TagPicker/TagPickerFreeform.stories.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { + TagPicker, + TagPickerList, + TagPickerInput, + TagPickerControl, + TagPickerProps, + TagPickerOption, + TagPickerGroup, +} from '@fluentui/react-tag-picker-preview'; +import { Tag, Avatar } from '@fluentui/react-components'; + +const options = [ + 'John Doe', + 'Jane Doe', + 'Max Mustermann', + 'Erika Mustermann', + 'Pierre Dupont', + 'Amelie Dupont', + 'Mario Rossi', + 'Maria Rossi', +]; + +export const Freeform = () => { + const [selectedOptions, setSelectedOptions] = React.useState([]); + const onOptionSelect: TagPickerProps['onOptionSelect'] = (e, data) => { + setSelectedOptions(data.selectedOptions); + }; + + return ( +
+ + + + {selectedOptions.map(option => ( + } value={option}> + {option} + + ))} + + + + + {options + .filter(option => !selectedOptions.includes(option)) + .map(option => ( + } + value={option} + key={option} + > + {option} + + ))} + + +
+ ); +}; diff --git a/packages/react-components/react-tag-picker-preview/stories/TagPicker/index.stories.tsx b/packages/react-components/react-tag-picker-preview/stories/TagPicker/index.stories.tsx index a50c19195fe467..712dd2b349a613 100644 --- a/packages/react-components/react-tag-picker-preview/stories/TagPicker/index.stories.tsx +++ b/packages/react-components/react-tag-picker-preview/stories/TagPicker/index.stories.tsx @@ -20,6 +20,7 @@ export { Disabled } from './TagPickerDisabled.stories'; export { ExpandIcon } from './TagPickerExpandIcon.stories'; export { SecondaryAction } from './TagPickerSecondaryAction.stories'; export { Grouped } from './TagPickerGrouped.stories'; +export { Freeform } from './TagPickerFreeform.stories'; export default { title: 'Preview Components/Tag Picker',