From f2a9a6992c1967554dd0b18f830be60ea0d61771 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Fri, 12 Jan 2024 12:11:39 +0100 Subject: [PATCH] feat(react-combobox): add clearable prop for Combobox & Dropdown (#30033) --- ...-057ab96a-491a-422f-8b6b-ee92954175ce.json | 7 +++ ...-d303579a-543d-44b3-91c9-6902acb99b4e.json | 7 +++ .../react-combobox/config/tests.js | 2 + .../react-combobox/etc/react-combobox.api.md | 7 ++- .../react-combobox/package.json | 1 + .../src/components/Combobox/Combobox.test.tsx | 54 ++++++++++++++++- .../src/components/Combobox/Combobox.types.ts | 8 ++- .../__snapshots__/Combobox.test.tsx.snap | 38 ++++++++++++ .../components/Combobox/renderCombobox.tsx | 3 +- .../src/components/Combobox/useCombobox.tsx | 58 ++++++++++++++++++- .../Combobox/useComboboxStyles.styles.ts | 27 ++++++++- .../src/components/Dropdown/Dropdown.test.tsx | 48 ++++++++++++++- .../src/components/Dropdown/Dropdown.types.ts | 5 ++ .../__snapshots__/Dropdown.test.tsx.snap | 40 +++++++++++++ .../components/Dropdown/renderDropdown.tsx | 3 +- .../src/components/Dropdown/useDropdown.tsx | 52 +++++++++++++++-- .../Dropdown/useDropdownStyles.styles.ts | 47 ++++++++++++++- .../src/utils/ComboboxBase.types.ts | 9 ++- .../src/utils/useComboboxBaseState.ts | 2 + .../Combobox/ComboboxClearable.stories.tsx | 36 ++++++++++++ .../stories/Combobox/index.stories.tsx | 1 + .../Dropdown/DropdownClearable.stories.tsx | 37 ++++++++++++ .../stories/Dropdown/index.stories.tsx | 1 + .../react-combobox/tsconfig.spec.json | 2 +- .../etc/react-timepicker-compat.api.md | 2 +- .../components/TimePicker/TimePicker.types.ts | 2 +- 26 files changed, 478 insertions(+), 21 deletions(-) create mode 100644 change/@fluentui-react-combobox-057ab96a-491a-422f-8b6b-ee92954175ce.json create mode 100644 change/@fluentui-react-timepicker-compat-d303579a-543d-44b3-91c9-6902acb99b4e.json create mode 100644 packages/react-components/react-combobox/stories/Combobox/ComboboxClearable.stories.tsx create mode 100644 packages/react-components/react-combobox/stories/Dropdown/DropdownClearable.stories.tsx diff --git a/change/@fluentui-react-combobox-057ab96a-491a-422f-8b6b-ee92954175ce.json b/change/@fluentui-react-combobox-057ab96a-491a-422f-8b6b-ee92954175ce.json new file mode 100644 index 00000000000000..2c6881ee736b0a --- /dev/null +++ b/change/@fluentui-react-combobox-057ab96a-491a-422f-8b6b-ee92954175ce.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add \"clearable\" prop to Combobox & Dropdown", + "packageName": "@fluentui/react-combobox", + "email": "olfedias@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-timepicker-compat-d303579a-543d-44b3-91c9-6902acb99b4e.json b/change/@fluentui-react-timepicker-compat-d303579a-543d-44b3-91c9-6902acb99b4e.json new file mode 100644 index 00000000000000..d6213dcce565a8 --- /dev/null +++ b/change/@fluentui-react-timepicker-compat-d303579a-543d-44b3-91c9-6902acb99b4e.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: update types in styles to exclude \"clearIcon\" slot", + "packageName": "@fluentui/react-timepicker-compat", + "email": "olfedias@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-combobox/config/tests.js b/packages/react-components/react-combobox/config/tests.js index 2e211ae9e21420..c6c67de97059e8 100644 --- a/packages/react-components/react-combobox/config/tests.js +++ b/packages/react-components/react-combobox/config/tests.js @@ -1 +1,3 @@ /** Jest test setup file. */ + +require('@testing-library/jest-dom'); 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 d2d98d67ef7367..a3419a8c325ed1 100644 --- a/packages/react-components/react-combobox/etc/react-combobox.api.md +++ b/packages/react-components/react-combobox/etc/react-combobox.api.md @@ -49,12 +49,15 @@ export const ComboboxProvider: Provider & FC>; expandIcon: Slot<'span'>; + clearIcon?: Slot<'span'>; input: NonNullable>; listbox?: Slot; }; // @public -export type ComboboxState = ComponentState & ComboboxBaseState; +export type ComboboxState = ComponentState & ComboboxBaseState & { + showClearIcon?: boolean; +}; // @public export const Dropdown: ForwardRefComponent; @@ -78,6 +81,7 @@ export type DropdownProps = ComponentProps, 'button'> & C export type DropdownSlots = { root: NonNullable>; expandIcon: Slot<'span'>; + clearButton?: Slot<'button'>; button: NonNullable>; listbox?: Slot; }; @@ -85,6 +89,7 @@ export type DropdownSlots = { // @public export type DropdownState = ComponentState & ComboboxBaseState & { placeholderVisible: boolean; + showClearButton?: boolean; }; // @public diff --git a/packages/react-components/react-combobox/package.json b/packages/react-components/react-combobox/package.json index 1911ca7f5c18e4..175aade6ac600f 100644 --- a/packages/react-components/react-combobox/package.json +++ b/packages/react-components/react-combobox/package.json @@ -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", diff --git a/packages/react-components/react-combobox/src/components/Combobox/Combobox.test.tsx b/packages/react-components/react-combobox/src/components/Combobox/Combobox.test.tsx index 4bf037e9bbc40f..be757cd58ef857 100644 --- a/packages/react-components/react-combobox/src/components/Combobox/Combobox.test.tsx +++ b/packages/react-components/react-combobox/src/components/Combobox/Combobox.test.tsx @@ -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(() => { @@ -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, + }, }, ], }, @@ -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( + + + + + , + ); + + 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( + + + + + , + ); + const clearButton = getByText('CLEAR BUTTON'); + + expect(clearButton).toHaveStyle({ display: 'none' }); + expect(clearButton).toHaveAttribute('aria-hidden', 'true'); + }); + }); }); 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 5c366cb48454b1..3c94e7c20e244f 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 @@ -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>; @@ -42,7 +45,10 @@ export type ComboboxProps = Omit, 'input'> /** * State used in rendering Combobox */ -export type ComboboxState = ComponentState & ComboboxBaseState; +export type ComboboxState = ComponentState & + ComboboxBaseState & { + showClearIcon?: boolean; + }; /* Export types defined in ComboboxBase */ export type ComboboxContextValues = ComboboxBaseContextValues; diff --git a/packages/react-components/react-combobox/src/components/Combobox/__snapshots__/Combobox.test.tsx.snap b/packages/react-components/react-combobox/src/components/Combobox/__snapshots__/Combobox.test.tsx.snap index 49769c8f87502b..97f91053a0bf57 100644 --- a/packages/react-components/react-combobox/src/components/Combobox/__snapshots__/Combobox.test.tsx.snap +++ b/packages/react-components/react-combobox/src/components/Combobox/__snapshots__/Combobox.test.tsx.snap @@ -12,6 +12,25 @@ exports[`Combobox renders a default state 1`] = ` type="text" value="" /> + +
{state.button.children} - {state.expandIcon && } + + {state.clearButton && } {state.listbox && (state.inlinePopup ? ( diff --git a/packages/react-components/react-combobox/src/components/Dropdown/useDropdown.tsx b/packages/react-components/react-combobox/src/components/Dropdown/useDropdown.tsx index eadd238a0ebd4e..4dfbe390115d30 100644 --- a/packages/react-components/react-combobox/src/components/Dropdown/useDropdown.tsx +++ b/packages/react-components/react-combobox/src/components/Dropdown/useDropdown.tsx @@ -1,7 +1,13 @@ import * as React from 'react'; import { useFieldControlProps_unstable } from '@fluentui/react-field'; -import { ChevronDownRegular as ChevronDownIcon } from '@fluentui/react-icons'; -import { getPartitionedNativeProps, useMergedRefs, slot } from '@fluentui/react-utilities'; +import { ChevronDownRegular as ChevronDownIcon, DismissRegular as DismissIcon } from '@fluentui/react-icons'; +import { + getPartitionedNativeProps, + mergeCallbacks, + useMergedRefs, + slot, + useEventCallback, +} from '@fluentui/react-utilities'; import { useComboboxBaseState } from '../../utils/useComboboxBaseState'; import { useComboboxPositioning } from '../../utils/useComboboxPositioning'; import { Listbox } from '../Listbox/Listbox'; @@ -23,7 +29,7 @@ export const useDropdown_unstable = (props: DropdownProps, ref: React.Ref 0 && clearable && !multiselect; const state: DropdownState = { - components: { root: 'div', button: 'button', expandIcon: 'span', listbox: Listbox }, + components: { root: 'div', button: 'button', clearButton: 'button', expandIcon: 'span', listbox: Listbox }, root: rootSlot, button: trigger, listbox: open || hasFocus ? listbox : undefined, + clearButton: slot.optional(props.clearButton, { + defaultProps: { + 'aria-label': 'Clear selection', + children: , + // Safari doesn't allow to focus an element with this + tabIndex: 0, + }, + elementType: 'button', + renderByDefault: true, + }), expandIcon: slot.optional(props.expandIcon, { renderByDefault: true, defaultProps: { @@ -75,8 +92,35 @@ export const useDropdown_unstable = (props: DropdownProps, ref: React.Ref) => { + clearSelection(ev); + triggerRef.current?.focus(); + }), + ); + + if (state.clearButton) { + state.clearButton.onClick = onClearButtonClick; + } + + // Heads up! We don't support "clearable" in multiselect mode, so we should never display a slot + if (multiselect) { + state.clearButton = 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; }; diff --git a/packages/react-components/react-combobox/src/components/Dropdown/useDropdownStyles.styles.ts b/packages/react-components/react-combobox/src/components/Dropdown/useDropdownStyles.styles.ts index 282f4943ca9860..1fec9f48c9a65a 100644 --- a/packages/react-components/react-combobox/src/components/Dropdown/useDropdownStyles.styles.ts +++ b/packages/react-components/react-combobox/src/components/Dropdown/useDropdownStyles.styles.ts @@ -1,12 +1,14 @@ +import { createFocusOutlineStyle } from '@fluentui/react-tabster'; import { tokens, typographyStyles } from '@fluentui/react-theme'; import { SlotClassNames } from '@fluentui/react-utilities'; -import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import { makeResetStyles, makeStyles, mergeClasses, shorthands } from '@griffel/react'; import { iconSizes } from '../../utils/internalTokens'; import type { DropdownSlots, DropdownState } from './Dropdown.types'; export const dropdownClassNames: SlotClassNames = { root: 'fui-Dropdown', button: 'fui-Dropdown__button', + clearButton: 'fui-Dropdown__clearButton', expandIcon: 'fui-Dropdown__expandIcon', listbox: 'fui-Dropdown__listbox', }; @@ -18,7 +20,7 @@ const useStyles = makeStyles({ root: { ...shorthands.borderRadius(tokens.borderRadiusMedium), boxSizing: 'border-box', - display: 'inline-block', + display: 'inline-flex', minWidth: '250px', position: 'relative', @@ -66,6 +68,13 @@ const useStyles = makeStyles({ ':focus-within:active::after': { borderBottomColor: tokens.colorCompoundBrandStrokePressed, }, + + '@supports selector(:has(*))': { + [`:has(.${dropdownClassNames.clearButton}:focus)::after`]: { + borderBottomColor: 'initial', + transform: 'scaleX(0)', + }, + }, }, listbox: { @@ -186,6 +195,10 @@ const useStyles = makeStyles({ color: tokens.colorNeutralForegroundDisabled, cursor: 'not-allowed', }, + + hidden: { + display: 'none', + }, }); const useIconStyles = makeStyles({ @@ -223,15 +236,30 @@ const useIconStyles = makeStyles({ }, }); +const useBaseClearButtonStyle = makeResetStyles({ + alignSelf: 'center', + backgroundColor: tokens.colorTransparentBackground, + border: 'none', + cursor: 'pointer', + height: 'fit-content', + margin: 0, + marginRight: tokens.spacingHorizontalMNudge, + padding: 0, + position: 'relative', + + ...createFocusOutlineStyle(), +}); + /** * Apply styling to the Dropdown slots based on the state */ export const useDropdownStyles_unstable = (state: DropdownState): DropdownState => { - const { appearance, open, placeholderVisible, size } = state; + const { appearance, open, placeholderVisible, showClearButton, size } = state; const invalid = `${state.button['aria-invalid']}` === 'true'; const disabled = state.button.disabled; const styles = useStyles(); const iconStyles = useIconStyles(); + const clearButtonStyle = useBaseClearButtonStyle(); state.root.className = mergeClasses( dropdownClassNames.root, @@ -268,9 +296,22 @@ export const useDropdownStyles_unstable = (state: DropdownState): DropdownState iconStyles.icon, iconStyles[size], disabled && iconStyles.disabled, + showClearButton && styles.hidden, state.expandIcon.className, ); } + if (state.clearButton) { + state.clearButton.className = mergeClasses( + dropdownClassNames.clearButton, + clearButtonStyle, + iconStyles.icon, + iconStyles[size], + disabled && iconStyles.disabled, + !showClearButton && styles.hidden, + state.clearButton.className, + ); + } + return state; }; 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 e6354249cf1447..f5ba8d38639109 100644 --- a/packages/react-components/react-combobox/src/utils/ComboboxBase.types.ts +++ b/packages/react-components/react-combobox/src/utils/ComboboxBase.types.ts @@ -17,6 +17,11 @@ export type ComboboxBaseProps = SelectionProps & */ 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 */ @@ -72,7 +77,9 @@ export type ComboboxBaseProps = SelectionProps & /** * State used in rendering Combobox */ -export type ComboboxBaseState = Required> & +export type ComboboxBaseState = Required< + Pick +> & Pick & OptionCollectionState & SelectionState & { diff --git a/packages/react-components/react-combobox/src/utils/useComboboxBaseState.ts b/packages/react-components/react-combobox/src/utils/useComboboxBaseState.ts index 384a71005dcb10..dc64e879ead78e 100644 --- a/packages/react-components/react-combobox/src/utils/useComboboxBaseState.ts +++ b/packages/react-components/react-combobox/src/utils/useComboboxBaseState.ts @@ -14,6 +14,7 @@ export const useComboboxBaseState = ( const { appearance = 'outline', children, + clearable = false, editable = false, inlinePopup = false, mountNode = undefined, @@ -115,6 +116,7 @@ export const useComboboxBaseState = ( ...selectionState, activeOption, appearance, + clearable, focusVisible, hasFocus, ignoreNextBlur, diff --git a/packages/react-components/react-combobox/stories/Combobox/ComboboxClearable.stories.tsx b/packages/react-components/react-combobox/stories/Combobox/ComboboxClearable.stories.tsx new file mode 100644 index 00000000000000..284dbb16ca5f32 --- /dev/null +++ b/packages/react-components/react-combobox/stories/Combobox/ComboboxClearable.stories.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { Combobox, Label, makeStyles, Option, shorthands, useId } from '@fluentui/react-components'; + +const useStyles = makeStyles({ + root: { + display: 'grid', + gridTemplateRows: 'auto auto', + justifyItems: 'start', + ...shorthands.gap('2px'), + }, +}); + +export const Clearable = () => { + const comboboxId = useId('combobox'); + const styles = useStyles(); + + return ( +
+ + + + + + +
+ ); +}; + +Clearable.parameters = { + docs: { + description: { + story: + 'A Combobox can be clearable and let users remove their selection. Note: this is not supported in multiselect mode yet.', + }, + }, +}; diff --git a/packages/react-components/react-combobox/stories/Combobox/index.stories.tsx b/packages/react-components/react-combobox/stories/Combobox/index.stories.tsx index 30291bfde8767a..f075f8328a0e70 100644 --- a/packages/react-components/react-combobox/stories/Combobox/index.stories.tsx +++ b/packages/react-components/react-combobox/stories/Combobox/index.stories.tsx @@ -8,6 +8,7 @@ export { Default } from './ComboboxDefault.stories'; export { ComplexOptions } from './ComboboxComplexOptions.stories'; export { CustomOptions } from './ComboboxCustomOptions.stories'; export { Controlled } from './ComboboxControlled.stories'; +export { Clearable } from './ComboboxClearable.stories'; export { Filtering } from './ComboboxFiltering.stories'; export { Freeform } from './ComboboxFreeform.stories'; export { Multiselect } from './ComboboxMultiselect.stories'; diff --git a/packages/react-components/react-combobox/stories/Dropdown/DropdownClearable.stories.tsx b/packages/react-components/react-combobox/stories/Dropdown/DropdownClearable.stories.tsx new file mode 100644 index 00000000000000..6495a6617654af --- /dev/null +++ b/packages/react-components/react-combobox/stories/Dropdown/DropdownClearable.stories.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { Dropdown, Label, makeStyles, Option, shorthands, useId } from '@fluentui/react-components'; + +const useStyles = makeStyles({ + root: { + display: 'grid', + gridTemplateRows: 'repeat(1fr)', + justifyItems: 'start', + ...shorthands.gap('2px'), + maxWidth: '400px', + }, +}); + +export const Clearable = () => { + const dropdownId = useId(''); + const styles = useStyles(); + + return ( +
+ + + + + + +
+ ); +}; + +Clearable.parameters = { + docs: { + description: { + story: + 'A Dropdown can be clearable and let users remove their selection. Note: this is not supported in multiselect mode yet.', + }, + }, +}; diff --git a/packages/react-components/react-combobox/stories/Dropdown/index.stories.tsx b/packages/react-components/react-combobox/stories/Dropdown/index.stories.tsx index a77dc68a1d0c5a..345f4153d57f14 100644 --- a/packages/react-components/react-combobox/stories/Dropdown/index.stories.tsx +++ b/packages/react-components/react-combobox/stories/Dropdown/index.stories.tsx @@ -8,6 +8,7 @@ import accessibilityMd from './DropdownAccessibility.md'; export { Default } from './DropdownDefault.stories'; export { Appearance } from './DropdownAppearance.stories'; export { Grouped } from './DropdownGrouped.stories'; +export { Clearable } from './DropdownClearable.stories'; export { ComplexOptions } from './DropdownComplexOptions.stories'; export { CustomOptions } from './DropdownCustomOptions.stories'; export { Controlled } from './DropdownControlled.stories'; diff --git a/packages/react-components/react-combobox/tsconfig.spec.json b/packages/react-components/react-combobox/tsconfig.spec.json index 911456fe4b4d91..0e881941843de8 100644 --- a/packages/react-components/react-combobox/tsconfig.spec.json +++ b/packages/react-components/react-combobox/tsconfig.spec.json @@ -3,7 +3,7 @@ "compilerOptions": { "module": "CommonJS", "outDir": "dist", - "types": ["jest", "node"] + "types": ["jest", "node", "@testing-library/jest-dom"] }, "include": [ "**/*.spec.ts", diff --git a/packages/react-components/react-timepicker-compat/etc/react-timepicker-compat.api.md b/packages/react-components/react-timepicker-compat/etc/react-timepicker-compat.api.md index 4a978b4faa0bc3..db6e60bc470214 100644 --- a/packages/react-components/react-timepicker-compat/etc/react-timepicker-compat.api.md +++ b/packages/react-components/react-timepicker-compat/etc/react-timepicker-compat.api.md @@ -39,7 +39,7 @@ export type TimePickerProps = Omit, 'input }; // @public (undocumented) -export type TimePickerSlots = ComboboxSlots; +export type TimePickerSlots = Omit; // @public export type TimePickerState = ComboboxState & Required> & { diff --git a/packages/react-components/react-timepicker-compat/src/components/TimePicker/TimePicker.types.ts b/packages/react-components/react-timepicker-compat/src/components/TimePicker/TimePicker.types.ts index c46c6de4937b13..684feac8813fb0 100644 --- a/packages/react-components/react-timepicker-compat/src/components/TimePicker/TimePicker.types.ts +++ b/packages/react-components/react-timepicker-compat/src/components/TimePicker/TimePicker.types.ts @@ -59,7 +59,7 @@ export type TimeStringValidationResult = { errorType?: TimePickerErrorType; }; -export type TimePickerSlots = ComboboxSlots; +export type TimePickerSlots = Omit; export type TimeSelectionEvents = SelectionEvents | React.FocusEvent; export type TimeSelectionData = {