Skip to content

Commit

Permalink
feat(react-combobox): add clearable prop for Combobox & Dropdown (#30033
Browse files Browse the repository at this point in the history
)
  • Loading branch information
layershifter authored Jan 12, 2024
1 parent 03e0cc5 commit f2a9a69
Show file tree
Hide file tree
Showing 26 changed files with 478 additions and 21 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: add \"clearable\" prop to Combobox & Dropdown",
"packageName": "@fluentui/react-combobox",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: update types in styles to exclude \"clearIcon\" slot",
"packageName": "@fluentui/react-timepicker-compat",
"email": "[email protected]",
"dependentChangeType": "patch"
}
2 changes: 2 additions & 0 deletions packages/react-components/react-combobox/config/tests.js
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
/** Jest test setup file. */

require('@testing-library/jest-dom');
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,15 @@ export const ComboboxProvider: Provider<ComboboxContextValue> & FC<ProviderProps
export type ComboboxSlots = {
root: NonNullable<Slot<'div'>>;
expandIcon: Slot<'span'>;
clearIcon?: Slot<'span'>;
input: NonNullable<Slot<'input'>>;
listbox?: Slot<typeof Listbox>;
};

// @public
export type ComboboxState = ComponentState<ComboboxSlots> & ComboboxBaseState;
export type ComboboxState = ComponentState<ComboboxSlots> & ComboboxBaseState & {
showClearIcon?: boolean;
};

// @public
export const Dropdown: ForwardRefComponent<DropdownProps>;
Expand All @@ -78,13 +81,15 @@ export type DropdownProps = ComponentProps<Partial<DropdownSlots>, 'button'> & C
export type DropdownSlots = {
root: NonNullable<Slot<'div'>>;
expandIcon: Slot<'span'>;
clearButton?: Slot<'button'>;
button: NonNullable<Slot<'button'>>;
listbox?: Slot<typeof Listbox>;
};

// @public
export type DropdownState = ComponentState<DropdownSlots> & ComboboxBaseState & {
placeholderVisible: boolean;
showClearButton?: boolean;
};

// @public
Expand Down
1 change: 1 addition & 0 deletions packages/react-components/react-combobox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@fluentui/react-portal": "^9.4.7",
"@fluentui/react-positioning": "^9.12.1",
"@fluentui/react-shared-contexts": "^9.13.2",
"@fluentui/react-tabster": "^9.17.0",
"@fluentui/react-theme": "^9.1.16",
"@fluentui/react-utilities": "^9.15.6",
"@griffel/react": "^1.5.14",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import * as React from 'react';
import { render, act } from '@testing-library/react';
import { act, fireEvent, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Field } from '@fluentui/react-field';
import { Combobox } from './Combobox';
import { Option } from '../Option/index';
import { isConformant } from '../../testing/isConformant';
import { resetIdsForTests } from '@fluentui/react-utilities';
import { comboboxClassNames } from './useComboboxStyles.styles';

describe('Combobox', () => {
beforeEach(() => {
Expand All @@ -24,6 +25,13 @@ describe('Combobox', () => {
// Portal messes with the classNames test, so rendering the listbox inline here
inlinePopup: true,
},
// Classes are defined manually as there is no way to render "expandIcon" and "clearIcon" and the same time
expectedClassNames: {
root: comboboxClassNames.root,
expandIcon: comboboxClassNames.expandIcon,
listbox: comboboxClassNames.listbox,
input: comboboxClassNames.input,
},
},
],
},
Expand Down Expand Up @@ -945,4 +953,48 @@ describe('Combobox', () => {
expect(combobox.getAttribute('aria-invalid')).toEqual('true');
expect(combobox.required).toBe(true);
});

describe('clearable', () => {
it('clears the selection on a button click', () => {
const { getByText, getByRole } = render(
<Combobox
clearable
defaultSelectedOptions={['Red']}
defaultValue="Red"
clearIcon={{ children: 'CLEAR BUTTON' }}
>
<Option>Red</Option>
<Option>Green</Option>
<Option>Blue</Option>
</Combobox>,
);

const combobox = getByRole('combobox');
const clearButton = getByText('CLEAR BUTTON');

expect(clearButton).not.toHaveStyle({ display: 'none' });
expect(combobox).toHaveValue('Red');

act(() => {
fireEvent.click(clearButton);
});

expect(clearButton).toHaveStyle({ display: 'none' });
expect(combobox).toHaveValue('');
});

it('is not visible when there is no selection', () => {
const { getByText } = render(
<Combobox clearable clearIcon={{ children: 'CLEAR BUTTON' }}>
<Option>Red</Option>
<Option>Green</Option>
<Option>Blue</Option>
</Combobox>,
);
const clearButton = getByText('CLEAR BUTTON');

expect(clearButton).toHaveStyle({ display: 'none' });
expect(clearButton).toHaveAttribute('aria-hidden', 'true');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export type ComboboxSlots = {
/* The dropdown arrow icon */
expandIcon: Slot<'span'>;

/* The dropdown clear icon */
clearIcon?: Slot<'span'>;

/* The primary slot, an input with role="combobox" */
input: NonNullable<Slot<'input'>>;

Expand All @@ -42,7 +45,10 @@ export type ComboboxProps = Omit<ComponentProps<Partial<ComboboxSlots>, 'input'>
/**
* State used in rendering Combobox
*/
export type ComboboxState = ComponentState<ComboboxSlots> & ComboboxBaseState;
export type ComboboxState = ComponentState<ComboboxSlots> &
ComboboxBaseState & {
showClearIcon?: boolean;
};

/* Export types defined in ComboboxBase */
export type ComboboxContextValues = ComboboxBaseContextValues;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@ exports[`Combobox renders a default state 1`] = `
type="text"
value=""
/>
<span
aria-hidden="true"
class="fui-Combobox__clearIcon"
>
<svg
aria-hidden="true"
class=""
fill="currentColor"
height="1em"
viewBox="0 0 20 20"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m4.09 4.22.06-.07a.5.5 0 0 1 .63-.06l.07.06L10 9.29l5.15-5.14a.5.5 0 0 1 .63-.06l.07.06c.18.17.2.44.06.63l-.06.07L10.71 10l5.14 5.15c.18.17.2.44.06.63l-.06.07a.5.5 0 0 1-.63.06l-.07-.06L10 10.71l-5.15 5.14a.5.5 0 0 1-.63.06l-.07-.06a.5.5 0 0 1-.06-.63l.06-.07L9.29 10 4.15 4.85a.5.5 0 0 1-.06-.63l.06-.07-.06.07Z"
fill="currentColor"
/>
</svg>
</span>
<span
aria-expanded="false"
aria-label="Open"
Expand Down Expand Up @@ -50,6 +69,25 @@ exports[`Combobox renders an open listbox 1`] = `
type="text"
value=""
/>
<span
aria-hidden="true"
class="fui-Combobox__clearIcon"
>
<svg
aria-hidden="true"
class=""
fill="currentColor"
height="1em"
viewBox="0 0 20 20"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m4.09 4.22.06-.07a.5.5 0 0 1 .63-.06l.07.06L10 9.29l5.15-5.14a.5.5 0 0 1 .63-.06l.07.06c.18.17.2.44.06.63l-.06.07L10.71 10l5.14 5.15c.18.17.2.44.06.63l-.06.07a.5.5 0 0 1-.63.06l-.07-.06L10 10.71l-5.15 5.14a.5.5 0 0 1-.63.06l-.07-.06a.5.5 0 0 1-.06-.63l.06-.07L9.29 10 4.15 4.85a.5.5 0 0 1-.06-.63l.06-.07-.06.07Z"
fill="currentColor"
/>
</svg>
</span>
<span
aria-expanded="true"
aria-label="Open"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export const renderCombobox_unstable = (state: ComboboxState, contextValues: Com
<state.root>
<ComboboxContext.Provider value={contextValues.combobox}>
<state.input />
{state.expandIcon && <state.expandIcon />}
{state.clearIcon && <state.clearIcon />}
<state.expandIcon />
{state.listbox &&
(state.inlinePopup ? (
<state.listbox />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import { useFieldControlProps_unstable } from '@fluentui/react-field';
import { ChevronDownRegular as ChevronDownIcon } from '@fluentui/react-icons';
import { ChevronDownRegular as ChevronDownIcon, DismissRegular as DismissIcon } from '@fluentui/react-icons';
import {
getPartitionedNativeProps,
mergeCallbacks,
Expand Down Expand Up @@ -32,7 +32,18 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref<HTMLIn
props = useFieldControlProps_unstable(props, { supportsLabelFor: true, supportsRequired: true, supportsSize: true });

const baseState = useComboboxBaseState({ ...props, editable: true });
const { open, selectOption, setOpen, setValue, value, hasFocus } = baseState;
const {
clearable,
clearSelection,
multiselect,
open,
selectedOptions,
selectOption,
setOpen,
setValue,
value,
hasFocus,
} = baseState;
const [comboboxPopupRef, comboboxTargetRef] = useComboboxPositioning(props);
const { disabled, freeform, inlinePopup } = props;
const comboId = useId('combobox-');
Expand Down Expand Up @@ -90,11 +101,20 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref<HTMLIn
});
rootSlot.ref = useMergedRefs(rootSlot.ref, comboboxTargetRef);

const showClearIcon = selectedOptions.length > 0 && clearable && !multiselect;
const state: ComboboxState = {
components: { root: 'div', input: 'input', expandIcon: 'span', listbox: Listbox },
components: { root: 'div', input: 'input', expandIcon: 'span', listbox: Listbox, clearIcon: 'span' },
root: rootSlot,
input: triggerSlot,
listbox: open || hasFocus ? listbox : undefined,
clearIcon: slot.optional(props.clearIcon, {
defaultProps: {
'aria-hidden': 'true',
children: <DismissIcon />,
},
elementType: 'span',
renderByDefault: true,
}),
expandIcon: slot.optional(props.expandIcon, {
renderByDefault: true,
defaultProps: {
Expand All @@ -104,6 +124,7 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref<HTMLIn
},
elementType: 'span',
}),
showClearIcon,
...baseState,
};

Expand Down Expand Up @@ -145,5 +166,36 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref<HTMLIn
}
}

const onClearIconMouseDown = useEventCallback(
mergeCallbacks(state.clearIcon?.onMouseDown, (ev: React.MouseEvent<HTMLSpanElement>) => {
ev.preventDefault();
}),
);
const onClearIconClick = useEventCallback(
mergeCallbacks(state.clearIcon?.onClick, (ev: React.MouseEvent<HTMLSpanElement>) => {
clearSelection(ev);
}),
);

if (state.clearIcon) {
state.clearIcon.onMouseDown = onClearIconMouseDown;
state.clearIcon.onClick = onClearIconClick;
}

// Heads up! We don't support "clearable" in multiselect mode, so we should never display a slot
if (multiselect) {
state.clearIcon = undefined;
}

if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks -- "process.env" does not change in runtime
React.useEffect(() => {
if (clearable && multiselect) {
// eslint-disable-next-line no-console
console.error(`[@fluentui/react-combobox] "clearable" prop is not supported in multiselect mode.`);
}
}, [clearable, multiselect]);
}

return state;
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const comboboxClassNames: SlotClassNames<ComboboxSlots> = {
root: 'fui-Combobox',
input: 'fui-Combobox__input',
expandIcon: 'fui-Combobox__expandIcon',
clearIcon: 'fui-Combobox__clearIcon',
listbox: 'fui-Combobox__listbox',
};

Expand Down Expand Up @@ -212,6 +213,18 @@ const useIconStyles = makeStyles({
display: 'block',
},
},
hidden: {
display: 'none',
},
visuallyHidden: {
clip: 'rect(0px, 0px, 0px, 0px)',
height: '1px',
...shorthands.margin('-1px'),
...shorthands.overflow('hidden'),
...shorthands.padding('0px'),
width: '1px',
position: 'absolute',
},

// icon size variants
small: {
Expand All @@ -236,7 +249,7 @@ const useIconStyles = makeStyles({
* Apply styling to the Combobox slots based on the state
*/
export const useComboboxStyles_unstable = (state: ComboboxState): ComboboxState => {
const { appearance, open, size } = state;
const { appearance, open, size, showClearIcon } = state;
const invalid = `${state.input['aria-invalid']}` === 'true';
const disabled = state.input.disabled;
const styles = useStyles();
Expand Down Expand Up @@ -278,9 +291,21 @@ export const useComboboxStyles_unstable = (state: ComboboxState): ComboboxState
iconStyles.icon,
iconStyles[size],
disabled && iconStyles.disabled,
showClearIcon && iconStyles.visuallyHidden,
state.expandIcon.className,
);
}

if (state.clearIcon) {
state.clearIcon.className = mergeClasses(
comboboxClassNames.clearIcon,
iconStyles.icon,
iconStyles[size],
disabled && iconStyles.disabled,
!showClearIcon && iconStyles.hidden,
state.clearIcon.className,
);
}

return state;
};
Loading

0 comments on commit f2a9a69

Please sign in to comment.