From 4b35e64bb51d4c043308a2c67fa2a79f6bc4805d Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Thu, 26 Oct 2023 10:27:24 -0300 Subject: [PATCH] [base-ui][useSelect] Support browser autofill (#39595) --- docs/pages/base-ui/api/select.json | 1 + docs/pages/base-ui/api/use-select.json | 4 +- .../api-docs-base/select/select.json | 3 + packages/mui-base/src/Select/Select.test.tsx | 79 +++++++++++++++++++ packages/mui-base/src/Select/Select.tsx | 9 ++- packages/mui-base/src/Select/Select.types.ts | 6 ++ .../mui-base/src/useList/listActions.types.ts | 8 +- .../mui-base/src/useList/listReducer.test.ts | 57 +++++++++++++ packages/mui-base/src/useList/listReducer.ts | 23 +++++- .../src/useSelect/selectReducer.test.ts | 32 +++++++- .../mui-base/src/useSelect/selectReducer.ts | 14 +++- .../mui-base/src/useSelect/useSelect.test.tsx | 18 +++++ packages/mui-base/src/useSelect/useSelect.ts | 35 ++++++-- .../mui-base/src/useSelect/useSelect.types.ts | 20 ++++- 14 files changed, 290 insertions(+), 19 deletions(-) diff --git a/docs/pages/base-ui/api/select.json b/docs/pages/base-ui/api/select.json index 6cddf6bd2ab0d6..ca44f11ed47a31 100644 --- a/docs/pages/base-ui/api/select.json +++ b/docs/pages/base-ui/api/select.json @@ -1,6 +1,7 @@ { "props": { "areOptionsEqual": { "type": { "name": "func" } }, + "autoComplete": { "type": { "name": "string" } }, "autoFocus": { "type": { "name": "bool" }, "default": "false" }, "defaultListboxOpen": { "type": { "name": "bool" }, "default": "false" }, "defaultValue": { "type": { "name": "any" } }, diff --git a/docs/pages/base-ui/api/use-select.json b/docs/pages/base-ui/api/use-select.json index 20af4c0a2f7de0..5f4cd6d430a3ac 100644 --- a/docs/pages/base-ui/api/use-select.json +++ b/docs/pages/base-ui/api/use-select.json @@ -89,8 +89,8 @@ "disabled": { "type": { "name": "boolean", "description": "boolean" }, "required": true }, "dispatch": { "type": { - "name": "(action: ListAction<Value> | SelectAction) => void", - "description": "(action: ListAction<Value> | SelectAction) => void" + "name": "(action: ListAction<Value> | SelectAction<Value>) => void", + "description": "(action: ListAction<Value> | SelectAction<Value>) => void" }, "required": true }, diff --git a/docs/translations/api-docs-base/select/select.json b/docs/translations/api-docs-base/select/select.json index 3fb87260c9dfa9..ef08b5595b8943 100644 --- a/docs/translations/api-docs-base/select/select.json +++ b/docs/translations/api-docs-base/select/select.json @@ -4,6 +4,9 @@ "areOptionsEqual": { "description": "A function used to determine if two options' values are equal. By default, reference equality is used.
There is a performance impact when using the areOptionsEqual prop (proportional to the number of options). Therefore, it's recommented to use the default reference equality comparison whenever possible." }, + "autoComplete": { + "description": "This prop helps users to fill forms faster, especially on mobile devices. The name can be confusing, as it's more like an autofill. You can learn more about it following the specification." + }, "autoFocus": { "description": "If true, the select element is focused during the first mount" }, diff --git a/packages/mui-base/src/Select/Select.test.tsx b/packages/mui-base/src/Select/Select.test.tsx index 145bfe0ae4e002..e1e24a6ee03cc6 100644 --- a/packages/mui-base/src/Select/Select.test.tsx +++ b/packages/mui-base/src/Select/Select.test.tsx @@ -1261,4 +1261,83 @@ describe(' + + + + , + ); + + const hiddenInput = container.querySelector('[autocomplete="country"]'); + + expect(hiddenInput).not.to.eq(null); + expect(hiddenInput).to.have.value('germany'); + + fireEvent.change(hiddenInput!, { + target: { + value: 'france', + }, + }); + + expect(onChangeHandler.calledOnce).to.equal(true); + expect(onChangeHandler.firstCall.args[1]).to.equal('france'); + expect(hiddenInput).to.have.value('france'); + }); + + it('does not set value when browser autofills invalid value', () => { + const onChangeHandler = spy(); + const { container } = render( + , + ); + + const hiddenInput = container.querySelector('[autocomplete="country"]'); + + expect(hiddenInput).not.to.eq(null); + expect(hiddenInput).to.have.value('germany'); + + fireEvent.change(hiddenInput!, { + target: { + value: 'portugal', + }, + }); + + expect(onChangeHandler.called).to.equal(false); + expect(hiddenInput).to.have.value('germany'); + }); + + it('clears value and calls external onChange when browser clears autofill', () => { + const onChangeHandler = spy(); + const { container } = render( + , + ); + + const hiddenInput = container.querySelector('[autocomplete="country"]'); + + expect(hiddenInput).not.to.eq(null); + expect(hiddenInput).to.have.value('germany'); + + fireEvent.change(hiddenInput!, { + target: { + value: '', + }, + }); + + expect(onChangeHandler.calledOnce).to.equal(true); + expect(onChangeHandler.firstCall.args[1]).to.equal(null); + expect(hiddenInput).to.have.value(''); + }); + }); }); diff --git a/packages/mui-base/src/Select/Select.tsx b/packages/mui-base/src/Select/Select.tsx index 9eb8efc904dd6b..e43968bd4eccfc 100644 --- a/packages/mui-base/src/Select/Select.tsx +++ b/packages/mui-base/src/Select/Select.tsx @@ -71,6 +71,7 @@ const Select = React.forwardRef(function Select< ) { const { areOptionsEqual, + autoComplete, autoFocus, children, defaultValue, @@ -225,7 +226,7 @@ const Select = React.forwardRef(function Select< )} - + ); }) as SelectType; @@ -243,6 +244,12 @@ Select.propTypes /* remove-proptypes */ = { * Therefore, it's recommented to use the default reference equality comparison whenever possible. */ areOptionsEqual: PropTypes.func, + /** + * This prop helps users to fill forms faster, especially on mobile devices. + * The name can be confusing, as it's more like an autofill. + * You can learn more about it [following the specification](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill). + */ + autoComplete: PropTypes.string, /** * If `true`, the select element is focused during the first mount * @default false diff --git a/packages/mui-base/src/Select/Select.types.ts b/packages/mui-base/src/Select/Select.types.ts index f2d2be2c5cb93b..fd6bd089ca08b3 100644 --- a/packages/mui-base/src/Select/Select.types.ts +++ b/packages/mui-base/src/Select/Select.types.ts @@ -18,6 +18,12 @@ export interface SelectOwnProps boolean; + /** + * This prop helps users to fill forms faster, especially on mobile devices. + * The name can be confusing, as it's more like an autofill. + * You can learn more about it [following the specification](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill). + */ + autoComplete?: string; /** * If `true`, the select element is focused during the first mount * @default false diff --git a/packages/mui-base/src/useList/listActions.types.ts b/packages/mui-base/src/useList/listActions.types.ts index 253315b7f07314..e9ff658d5bc0bb 100644 --- a/packages/mui-base/src/useList/listActions.types.ts +++ b/packages/mui-base/src/useList/listActions.types.ts @@ -7,6 +7,7 @@ export const ListActionTypes = { keyDown: 'list:keyDown', resetHighlight: 'list:resetHighlight', textNavigation: 'list:textNavigation', + clearSelection: 'list:clearSelection', } as const; interface ItemClickAction { @@ -55,6 +56,10 @@ interface ResetHighlightAction { event: React.SyntheticEvent | null; } +interface ClearSelectionAction { + type: typeof ListActionTypes.clearSelection; +} + /** * A union of all standard actions that can be dispatched to the list reducer. */ @@ -66,4 +71,5 @@ export type ListAction = | ItemsChangeAction | KeyDownAction | ResetHighlightAction - | TextNavigationAction; + | TextNavigationAction + | ClearSelectionAction; diff --git a/packages/mui-base/src/useList/listReducer.test.ts b/packages/mui-base/src/useList/listReducer.test.ts index c34a475ed90faa..047f719b30b169 100644 --- a/packages/mui-base/src/useList/listReducer.test.ts +++ b/packages/mui-base/src/useList/listReducer.test.ts @@ -63,6 +63,35 @@ describe('listReducer', () => { expect(result.selectedValues).to.deep.equal(['two']); }); + it('does not select a disabled item', () => { + const state: ListState = { + highlightedValue: null, + selectedValues: [], + }; + + const action: ListReducerAction = { + type: ListActionTypes.itemClick, + event: {} as any, // not relevant + context: { + items: ['one', 'two', 'three'], + disableListWrap: false, + disabledItemsFocusable: false, + focusManagement: 'activeDescendant', + isItemDisabled: (item) => item === 'two', + itemComparer: (o, v) => o === v, + getItemAsString: (option) => option, + orientation: 'vertical', + pageSize: 5, + selectionMode: 'single', + }, + item: 'two', + }; + + const result = listReducer(state, action); + expect(result.highlightedValue).to.equal(null); + expect(result.selectedValues).to.deep.equal([]); + }); + it('replaces the selectedValues with the clicked value if selectionMode = "single"', () => { const state: ListState = { highlightedValue: 'a', @@ -1113,4 +1142,32 @@ describe('listReducer', () => { expect(result.highlightedValue).to.equal('two'); }); }); + + describe('action: clearSelection', () => { + it('clears the selection', () => { + const state: ListState = { + highlightedValue: null, + selectedValues: ['one', 'two'], + }; + + const action: ListReducerAction = { + type: ListActionTypes.clearSelection, + context: { + items: ['one', 'two', 'three'], + disableListWrap: false, + disabledItemsFocusable: false, + focusManagement: 'DOM', + isItemDisabled: () => false, + itemComparer: (o, v) => o === v, + getItemAsString: (option) => option, + orientation: 'vertical', + pageSize: 5, + selectionMode: 'none', + }, + }; + + const result = listReducer(state, action); + expect(result.selectedValues).to.deep.equal([]); + }); + }); }); diff --git a/packages/mui-base/src/useList/listReducer.ts b/packages/mui-base/src/useList/listReducer.ts index bfce751beb5567..ad32bdbafa8c50 100644 --- a/packages/mui-base/src/useList/listReducer.ts +++ b/packages/mui-base/src/useList/listReducer.ts @@ -200,7 +200,15 @@ export function toggleSelection( return [...selectedValues, item]; } -function handleItemSelection>( +/** + * Handles item selection in a list. + * + * @param item - The item to be selected. + * @param state - The current state of the list. + * @param context - The context of the list action. + * @returns The new state of the list after the item has been selected, or the original state if the item is disabled. + */ +export function handleItemSelection>( item: ItemValue, state: State, context: ListActionContext, @@ -423,6 +431,17 @@ function handleResetHighlight>( }; } +function handleClearSelection>( + state: State, + context: ListActionContext, +) { + return { + ...state, + selectedValues: [], + highlightedValue: moveHighlight(null, 'reset', context), + }; +} + export function listReducer>( state: State, action: ListReducerAction & { context: ListActionContext }, @@ -442,6 +461,8 @@ export function listReducer>( return handleItemsChange(action.items, action.previousItems, state, context); case ListActionTypes.resetHighlight: return handleResetHighlight(state, context); + case ListActionTypes.clearSelection: + return handleClearSelection(state, context); default: return state; } diff --git a/packages/mui-base/src/useSelect/selectReducer.test.ts b/packages/mui-base/src/useSelect/selectReducer.test.ts index 7ebf9174b61355..01386955226d29 100644 --- a/packages/mui-base/src/useSelect/selectReducer.test.ts +++ b/packages/mui-base/src/useSelect/selectReducer.test.ts @@ -26,7 +26,7 @@ describe('selectReducer', () => { open: false, }; - const action: ActionWithContext> = { + const action: ActionWithContext, ListActionContext> = { type: SelectActionTypes.buttonClick, event: {} as any, // not relevant context: irrelevantConfig, @@ -43,7 +43,7 @@ describe('selectReducer', () => { open: true, }; - const action: ActionWithContext> = { + const action: ActionWithContext, ListActionContext> = { type: SelectActionTypes.buttonClick, event: {} as any, // not relevant context: { @@ -62,7 +62,7 @@ describe('selectReducer', () => { open: false, }; - const action: ActionWithContext> = { + const action: ActionWithContext, ListActionContext> = { type: SelectActionTypes.buttonClick, event: {} as any, // not relevant context: { @@ -82,7 +82,7 @@ describe('selectReducer', () => { open: false, }; - const action: ActionWithContext> = { + const action: ActionWithContext, ListActionContext> = { type: SelectActionTypes.buttonClick, event: {} as any, // not relevant context: { @@ -95,4 +95,28 @@ describe('selectReducer', () => { expect(result.highlightedValue).to.equal('1'); }); }); + + describe('action: browserAutoFill', () => { + it('selects the item and highlights it', () => { + const state: SelectInternalState = { + highlightedValue: null, + selectedValues: [], + open: false, + }; + + const action: ActionWithContext, ListActionContext> = { + type: SelectActionTypes.browserAutoFill, + event: {} as any, // not relevant + item: '1', + context: { + ...irrelevantConfig, + items: ['1', '2', '3'], + }, + }; + + const result = selectReducer(state, action); + expect(result.highlightedValue).to.equal('1'); + expect(result.selectedValues).to.deep.equal(['1']); + }); + }); }); diff --git a/packages/mui-base/src/useSelect/selectReducer.ts b/packages/mui-base/src/useSelect/selectReducer.ts index 1fdb0eb9a15c90..f6cea5dd1659b5 100644 --- a/packages/mui-base/src/useSelect/selectReducer.ts +++ b/packages/mui-base/src/useSelect/selectReducer.ts @@ -4,13 +4,17 @@ import { moveHighlight, listReducer, ListActionTypes, + handleItemSelection, } from '../useList'; import { ActionWithContext } from '../utils/useControllableReducer.types'; import { SelectAction, SelectActionTypes, SelectInternalState } from './useSelect.types'; export function selectReducer( state: SelectInternalState, - action: ActionWithContext | SelectAction, ListActionContext>, + action: ActionWithContext< + ListAction | SelectAction, + ListActionContext + >, ) { const { open } = state; const { @@ -28,6 +32,14 @@ export function selectReducer( }; } + if (action.type === SelectActionTypes.browserAutoFill) { + return handleItemSelection>( + action.item, + state, + action.context, + ); + } + const newState: SelectInternalState = listReducer( state, action as ActionWithContext, ListActionContext>, diff --git a/packages/mui-base/src/useSelect/useSelect.test.tsx b/packages/mui-base/src/useSelect/useSelect.test.tsx index 424bda3cdd1a1f..30e95059075ec7 100644 --- a/packages/mui-base/src/useSelect/useSelect.test.tsx +++ b/packages/mui-base/src/useSelect/useSelect.test.tsx @@ -107,5 +107,23 @@ describe('useSelect', () => { value: JSON.stringify([{ name: 'a' }, { name: 'b' }]), }); }); + + describe('onChange handler', () => { + it('calls external onChange handler', () => { + const externalOnChangeSpy = sinon.spy(); + + const { result } = renderHook(() => useSelect({})); + + const { getHiddenInputProps } = result.current; + const { onChange: hiddenInputOnChange } = getHiddenInputProps({ + onChange: externalOnChangeSpy, + }); + + // @ts-ignore We only need the target value for this test + hiddenInputOnChange({ target: { value: 'foo' } }); + expect(externalOnChangeSpy.calledOnce).to.equal(true); + expect(externalOnChangeSpy.calledWith({ target: { value: 'foo' } })).to.equal(true); + }); + }); }); }); diff --git a/packages/mui-base/src/useSelect/useSelect.ts b/packages/mui-base/src/useSelect/useSelect.ts index 129af312e77556..e7e4ea46bf5907 100644 --- a/packages/mui-base/src/useSelect/useSelect.ts +++ b/packages/mui-base/src/useSelect/useSelect.ts @@ -18,7 +18,7 @@ import { UseSelectParameters, UseSelectReturnValue, } from './useSelect.types'; -import { useList, UseListParameters } from '../useList'; +import { ListActionTypes, useList, UseListParameters } from '../useList'; import { EventHandlers } from '../utils/types'; import { defaultOptionStringifier } from './defaultOptionStringifier'; import { SelectProviderValue } from './SelectProvider'; @@ -43,8 +43,6 @@ const visuallyHiddenStyle: React.CSSProperties = { bottom: 0, // to display the native browser validation error at the bottom of the Select. }; -const noop = () => {}; - function defaultFormValueProvider( selectedOption: SelectOption | SelectOption[] | null, ) { @@ -268,7 +266,7 @@ function useSelect( const useListParameters: UseListParameters< OptionValue, SelectInternalState, - SelectAction, + SelectAction, { multiple: boolean } > = { getInitialState: () => ({ @@ -408,18 +406,45 @@ function useSelect( null) as SelectValue, Multiple>; } + const createHandleHiddenInputChange = + (externalEventHandlers?: EventHandlers) => + (event: React.ChangeEvent & MuiCancellableEvent) => { + externalEventHandlers?.onChange?.(event); + + if (event.defaultMuiPrevented) { + return; + } + + const option = options.get(event.target.value as OptionValue); + + // support autofill + if (event.target.value === '') { + dispatch({ + type: ListActionTypes.clearSelection, + }); + } else if (option !== undefined) { + dispatch({ + type: SelectActionTypes.browserAutoFill, + item: option.value, + event, + }); + } + }; + const getHiddenInputProps = >( externalProps: ExternalProps = {} as ExternalProps, ): UseSelectHiddenInputSlotProps => { + const externalEventHandlers = extractEventHandlers(externalProps); + return { name, tabIndex: -1, 'aria-hidden': true, required: required ? true : undefined, value: getSerializedValue(selectedOptionsMetadata), - onChange: noop, style: visuallyHiddenStyle, ...externalProps, + onChange: createHandleHiddenInputChange(externalEventHandlers), }; }; diff --git a/packages/mui-base/src/useSelect/useSelect.types.ts b/packages/mui-base/src/useSelect/useSelect.types.ts index 33c7653608ae7f..217bd98c3c00fa 100644 --- a/packages/mui-base/src/useSelect/useSelect.types.ts +++ b/packages/mui-base/src/useSelect/useSelect.types.ts @@ -139,8 +139,13 @@ export type UseSelectButtonSlotProps = UseListRootSlotProps< ref: React.RefCallback | null; }; -export type UseSelectHiddenInputSlotProps = - React.InputHTMLAttributes & TOther; +interface UseSelectHiddenInputSlotEventHandlers { + onChange: MuiCancellableEventHandler>; +} + +export type UseSelectHiddenInputSlotProps = UseSelectHiddenInputSlotEventHandlers & + React.InputHTMLAttributes & + TOther; interface UseSelectListboxSlotEventHandlers { onMouseDown: React.MouseEventHandler; @@ -178,7 +183,7 @@ export interface UseSelectReturnValue { * Action dispatcher for the select component. * Allows to programmatically control the select. */ - dispatch: (action: ListAction | SelectAction) => void; + dispatch: (action: ListAction | SelectAction) => void; /** * Resolver for the button slot's props. * @param externalProps event handlers for the button slot @@ -238,6 +243,7 @@ export interface UseSelectReturnValue { export const SelectActionTypes = { buttonClick: 'buttonClick', + browserAutoFill: 'browserAutoFill', } as const; export interface ButtonClickAction { @@ -245,7 +251,13 @@ export interface ButtonClickAction { event: React.MouseEvent; } -export type SelectAction = ButtonClickAction; +export interface BrowserAutofillAction { + type: typeof SelectActionTypes.browserAutoFill; + item: OptionValue; + event: React.ChangeEvent; +} + +export type SelectAction = ButtonClickAction | BrowserAutofillAction; export interface SelectInternalState extends ListState { open: boolean;