Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ export type ComboboxOpenEvents = ComboboxBaseOpenEvents;

// @public
export type ComboboxProps = Omit<ComponentProps<Partial<ComboboxSlots>, 'input'>, 'children' | 'size'> & ComboboxBaseProps & {
freeform?: boolean;
children?: React_2.ReactNode;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,6 @@ export type ComboboxSlots = {
*/
export type ComboboxProps = Omit<ComponentProps<Partial<ComboboxSlots>, 'input'>, 'children' | 'size'> &
ComboboxBaseProps & {
/*
* Whether the ComboBox allows freeform user input, rather than restricting to the provided options.
*/
freeform?: boolean;

/*
* The primary slot, `<input>`, does not support children so we need to explicitly include it here.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -41,20 +39,9 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref<HTMLIn
});
const baseState = useComboboxBaseState({ ...props, editable: true, activeDescendantController });

const {
clearable,
clearSelection,
multiselect,
open,
selectedOptions,
selectOption,
setOpen,
setValue,
value,
hasFocus,
} = baseState;
const { clearable, clearSelection, multiselect, open, selectedOptions, value, hasFocus } = baseState;
const [comboboxPopupRef, comboboxTargetRef] = useComboboxPositioning(props);
const { disabled, freeform, inlinePopup } = props;
const { freeform, inlinePopup } = props;
const comboId = useId('combobox-');

const { primary: triggerNativeProps, root: rootNativeProps } = getPartitionedNativeProps({
Expand All @@ -63,24 +50,6 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref<HTMLIn
excludedPropNames: ['children', 'size'],
});

// reset any typed value when an option is selected
baseState.selectOption = (ev: SelectionEvents, option: OptionValue) => {
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<HTMLInputElement>(null);

const listbox = useListboxSlot(props.listbox, useMergedRefs(comboboxPopupRef, activeDescendantListboxRef), {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComboboxBaseProps, 'appearance' | 'open' | 'clearable' | 'inlinePopup' | 'size'>
Pick<ComboboxBaseProps, 'appearance' | 'open' | 'clearable' | 'inlinePopup' | 'size' | 'freeform' | 'disabled'>
> &
Pick<ComboboxBaseProps, 'mountNode' | 'placeholder' | 'value' | 'multiselect'> &
OptionCollectionState &
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -28,6 +30,8 @@ export const useComboboxBaseState = (
onOpenChange,
size = 'medium',
activeDescendantController,
freeform = false,
disabled = false,
} = props;

const optionCollection = useOptionCollection();
Expand Down Expand Up @@ -70,16 +74,26 @@ 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({
state: props.value,
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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -152,7 +174,11 @@ export const useComboboxBaseState = (

return {
...optionCollection,
...selectionState,
freeform,
disabled,
selectOption,
clearSelection,
selectedOptions,
activeOption: UNSAFE_activeOption,
appearance,
clearable,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@ export const tagPickerInputClassNames: SlotClassNames<TagPickerInputSlots>;

// @public
export type TagPickerInputProps = Omit<ComponentProps<Partial<TagPickerInputSlots>>, 'children' | 'size' | 'defaultValue'> & Pick<ComboboxProps, 'clearable' | 'appearance'> & {
freeform?: boolean;
disabled?: boolean;
value?: string;
};
Expand Down Expand Up @@ -201,7 +200,7 @@ export type TagPickerOptionSlots = Pick<OptionSlots, 'root'> & {
export type TagPickerOptionState = ComponentState<TagPickerOptionSlots> & Pick<OptionState, 'components' | 'multiselect' | 'root' | 'selected'>;

// @public
export type TagPickerProps = ComponentProps<TagPickerSlots> & Pick<ComboboxProps, 'positioning' | 'disabled' | 'defaultOpen' | 'selectedOptions' | 'defaultSelectedOptions' | 'open'> & Pick<Partial<TagPickerContextValue>, 'size' | 'appearance'> & {
export type TagPickerProps = ComponentProps<TagPickerSlots> & Pick<ComboboxProps, 'positioning' | 'disabled' | 'defaultOpen' | 'selectedOptions' | 'defaultSelectedOptions' | 'open' | 'freeform'> & Pick<Partial<TagPickerContextValue>, 'size' | 'appearance'> & {
onOpenChange?: EventHandler<TagPickerOnOpenChangeData>;
onOptionSelect?: EventHandler<TagPickerOnOptionSelectData>;
children: [JSX.Element, JSX.Element] | JSX.Element;
Expand All @@ -211,7 +210,7 @@ export type TagPickerProps = ComponentProps<TagPickerSlots> & Pick<ComboboxProps
export type TagPickerSlots = {};

// @public
export type TagPickerState = ComponentState<TagPickerSlots> & Pick<ComboboxState, 'open' | 'activeDescendantController' | 'mountNode' | 'onOptionClick' | 'registerOption' | 'selectedOptions' | 'selectOption' | 'multiselect' | 'value' | 'setValue' | 'setOpen' | 'setHasFocus' | 'appearance' | 'clearSelection' | 'getOptionById'> & Pick<TagPickerContextValue, 'triggerRef' | 'secondaryActionRef' | 'popoverId' | 'popoverRef' | 'targetRef' | 'size' | 'disabled'> & {
export type TagPickerState = ComponentState<TagPickerSlots> & Pick<ComboboxState, 'open' | 'activeDescendantController' | 'mountNode' | 'onOptionClick' | 'registerOption' | 'selectedOptions' | 'selectOption' | 'multiselect' | 'value' | 'setValue' | 'setOpen' | 'setHasFocus' | 'appearance' | 'clearSelection' | 'getOptionById' | 'freeform' | 'disabled'> & Pick<TagPickerContextValue, 'triggerRef' | 'secondaryActionRef' | 'popoverId' | 'popoverRef' | 'targetRef' | 'size'> & {
trigger: React_2.ReactNode;
popover?: React_2.ReactNode;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export type TagPickerOnOpenChangeData = { open: boolean } & (
export type TagPickerProps = ComponentProps<TagPickerSlots> &
Pick<
ComboboxProps,
'positioning' | 'disabled' | 'defaultOpen' | 'selectedOptions' | 'defaultSelectedOptions' | 'open'
'positioning' | 'disabled' | 'defaultOpen' | 'selectedOptions' | 'defaultSelectedOptions' | 'open' | 'freeform'
> &
Pick<Partial<TagPickerContextValue>, 'size' | 'appearance'> & {
onOpenChange?: EventHandler<TagPickerOnOpenChangeData>;
Expand Down Expand Up @@ -67,10 +67,12 @@ export type TagPickerState = ComponentState<TagPickerSlots> &
| '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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const useTagPicker_unstable = (props: TagPickerProps): TagPickerState =>
const popoverId = useId('picker-listbox');
const triggerInnerRef = React.useRef<HTMLInputElement>(null);
const secondaryActionRef = React.useRef<HTMLSpanElement>(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'];
Expand Down Expand Up @@ -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);
});

Expand All @@ -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,
Expand All @@ -110,6 +107,7 @@ export const useTagPicker_unstable = (props: TagPickerProps): TagPickerState =>
setValue: comboboxState.setValue,
multiselect: comboboxState.multiselect,
value: comboboxState.value,
freeform: comboboxState.freeform,
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function useTagPickerContextValues(state: TagPickerState): TagPickerConte
open,
popoverId,
disabled,
freeform,
} = state;
return {
activeDescendant: React.useMemo(
Expand Down Expand Up @@ -57,6 +58,7 @@ export function useTagPickerContextValues(state: TagPickerState): TagPickerConte
open,
popoverId,
disabled,
freeform,
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export type TagPickerInputProps = Omit<
'children' | 'size' | 'defaultValue'
> &
Pick<ComboboxProps, 'clearable' | 'appearance'> & {
freeform?: boolean;
disabled?: boolean;
value?: string;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -36,7 +37,7 @@ export const useTagPickerInput_unstable = (
multiselect,
popoverId,
value: contextValue,
} = usePickerContext();
} = useTagPickerContexts();

const { value = contextValue, disabled = contextDisabled } = props;

Expand Down Expand Up @@ -71,7 +72,7 @@ export const useTagPickerInput_unstable = (
useMergedRefs(triggerRef, ref),
{
activeDescendantController,
freeform: props.freeform,
freeform,
state: {
clearSelection,
getOptionById,
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ export interface TagPickerContextValue
| 'setValue'
| 'value'
| 'appearance'
| 'disabled'
| 'freeform'
> {
triggerRef: React.RefObject<HTMLInputElement>;
popoverRef: React.RefObject<HTMLDivElement>;
popoverId: string;
targetRef: React.RefObject<HTMLElement>;
secondaryActionRef: React.RefObject<HTMLSpanElement>;
size: TagPickerSize;
disabled: boolean;
}

/**
Expand All @@ -50,6 +51,7 @@ export const tagPickerContextDefaultValue: TagPickerContextValue = {
size: 'medium',
appearance: 'outline',
disabled: false,
freeform: false,
};

const TagPickerContext = createContext<TagPickerContextValue | undefined>(undefined);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComboboxBaseProps, 'appearance' | 'open' | 'clearable' | 'inlinePopup' | 'size'>
Pick<ComboboxBaseProps, 'appearance' | 'open' | 'clearable' | 'inlinePopup' | 'size' | 'freeform' | 'disabled'>
> &
Pick<ComboboxBaseProps, 'mountNode' | 'placeholder' | 'value' | 'multiselect'> &
OptionCollectionState &
Expand Down
Loading