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": "minor",
"comment": "feat: allow space character insertion while typing in freeform combobox",
"packageName": "@fluentui/react-combobox",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,58 @@ describe('Combobox', () => {
});
});

/* Freeform space key behavior */
it('inserts space character when typing in a freeform combobox', () => {
const onOptionSelect = jest.fn();

const { getByRole } = render(
<Combobox onOptionSelect={onOptionSelect} freeform>
<Option>Red</Option>
<Option>Green</Option>
<Option>Blue</Option>
</Combobox>,
);

userEvent.type(getByRole('combobox'), 're ');

expect(onOptionSelect).not.toHaveBeenCalled();
expect((getByRole('combobox') as HTMLInputElement).value).toEqual('re ');
});

it('uses space to select after arrowing through options in a freeform combobox', () => {
const onOptionSelect = jest.fn();

const { getByRole } = render(
<Combobox onOptionSelect={onOptionSelect} freeform>
<Option>Red</Option>
<Option>Green</Option>
<Option>Blue</Option>
</Combobox>,
);

userEvent.type(getByRole('combobox'), 're{ArrowDown} ');

expect(onOptionSelect).toHaveBeenCalledTimes(1);
expect((getByRole('combobox') as HTMLInputElement).value).toEqual('Green');
});

it('inserts space character in closed freeform combobox', () => {
const onOptionSelect = jest.fn();

const { getByRole } = render(
<Combobox onOptionSelect={onOptionSelect} freeform>
<Option>Red</Option>
<Option>Green</Option>
<Option>Blue</Option>
</Combobox>,
);

userEvent.type(getByRole('combobox'), 'r{ArrowDown}{Escape} ');

expect(onOptionSelect).not.toHaveBeenCalled();
expect((getByRole('combobox') as HTMLInputElement).value).toEqual('r ');
});

/* Active option */
it('should set active option on click', () => {
const { getByTestId } = render(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref<HTMLIn
// 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);

// calculate listbox width style based on trigger width
const [popupDimensions, setPopupDimensions] = React.useState<{ width: string }>();
React.useEffect(() => {
Expand Down Expand Up @@ -146,20 +150,6 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref<HTMLIn
}
};

// open Combobox when typing
const onTriggerKeyDown = (ev: React.KeyboardEvent<HTMLInputElement>) => {
if (!open && getDropdownActionFromKey(ev) === 'Type') {
baseState.setOpen(ev, true);
}

// clear activedescendant when moving the text insertion cursor
if (ev.key === ArrowLeft || ev.key === ArrowRight) {
setHideActiveDescendant(true);
} else {
setHideActiveDescendant(false);
}
};

// resolve input and listbox slot props
let triggerSlot: Slot<'input'>;
let listboxSlot: Slot<typeof Listbox> | undefined;
Expand All @@ -174,9 +164,9 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref<HTMLIn
},
});

const resolvedPropsOnKeyDown = triggerSlot.onKeyDown;
triggerSlot.onChange = mergeCallbacks(triggerSlot.onChange, onTriggerChange);
triggerSlot.onBlur = mergeCallbacks(triggerSlot.onBlur, onTriggerBlur);
triggerSlot.onKeyDown = mergeCallbacks(triggerSlot.onKeyDown, onTriggerKeyDown);

// only resolve listbox slot if needed
listboxSlot =
Expand Down Expand Up @@ -226,6 +216,49 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref<HTMLIn

state.root.ref = useMergedRefs(state.root.ref, rootRef);

/* Set input.onKeyDown here, so we can override the default behavior for spacebar */
const defaultOnTriggerKeyDown = state.input.onKeyDown;
state.input.onKeyDown = useEventCallback((ev: React.KeyboardEvent<HTMLInputElement>) => {
if (!open && getDropdownActionFromKey(ev) === 'Type') {
baseState.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 (freeform && (isTyping.current || !open) && ev.key === ' ') {
resolvedPropsOnKeyDown?.(ev);
return;
}

// if we're not allowing space to type, continue with default behavior
defaultOnTriggerKeyDown?.(ev);
});

/* handle open/close + focus change when clicking expandIcon */
const { onMouseDown: onIconMouseDown, onClick: onIconClick } = state.expandIcon || {};
const onExpandIconMouseDown = useEventCallback(
Expand Down