Skip to content

Commit

Permalink
[base-ui][useSelect] Support browser autofill (#39595)
Browse files Browse the repository at this point in the history
  • Loading branch information
DiegoAndai authored Oct 26, 2023
1 parent d9dcb55 commit 4b35e64
Show file tree
Hide file tree
Showing 14 changed files with 290 additions and 19 deletions.
1 change: 1 addition & 0 deletions docs/pages/base-ui/api/select.json
Original file line number Diff line number Diff line change
@@ -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" } },
Expand Down
4 changes: 2 additions & 2 deletions docs/pages/base-ui/api/use-select.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
3 changes: 3 additions & 0 deletions docs/translations/api-docs-base/select/select.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"areOptionsEqual": {
"description": "A function used to determine if two options&#39; values are equal. By default, reference equality is used.<br>There is a performance impact when using the <code>areOptionsEqual</code> prop (proportional to the number of options). Therefore, it&#39;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&#39;s more like an autofill. You can learn more about it <a href=\"https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill\">following the specification</a>."
},
"autoFocus": {
"description": "If <code>true</code>, the select element is focused during the first mount"
},
Expand Down
79 changes: 79 additions & 0 deletions packages/mui-base/src/Select/Select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1261,4 +1261,83 @@ describe('<Select />', () => {
expect(renderOption3Spy.callCount).to.equal(0);
expect(renderOption4Spy.callCount).to.equal(0);
});

describe('browser autofill', () => {
it('sets value and calls external onChange when browser autofills', () => {
const onChangeHandler = spy();
const { container } = render(
<Select onChange={onChangeHandler} defaultValue="germany" autoComplete="country">
<Option value="france">France</Option>
<Option value="germany">Germany</Option>
<Option value="china">China</Option>
</Select>,
);

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(
<Select onChange={onChangeHandler} defaultValue="germany" autoComplete="country">
<Option value="france">France</Option>
<Option value="germany">Germany</Option>
<Option value="china">China</Option>
</Select>,
);

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(
<Select onChange={onChangeHandler} defaultValue="germany" autoComplete="country">
<Option value="france">France</Option>
<Option value="germany">Germany</Option>
<Option value="china">China</Option>
</Select>,
);

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('');
});
});
});
9 changes: 8 additions & 1 deletion packages/mui-base/src/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const Select = React.forwardRef(function Select<
) {
const {
areOptionsEqual,
autoComplete,
autoFocus,
children,
defaultValue,
Expand Down Expand Up @@ -225,7 +226,7 @@ const Select = React.forwardRef(function Select<
</PopperComponent>
)}

<input {...getHiddenInputProps()} />
<input {...getHiddenInputProps()} autoComplete={autoComplete} />
</React.Fragment>
);
}) as SelectType;
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/mui-base/src/Select/Select.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export interface SelectOwnProps<OptionValue extends {}, Multiple extends boolean
* Therefore, it's recommented to use the default reference equality comparison whenever possible.
*/
areOptionsEqual?: (a: OptionValue, b: OptionValue) => 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
Expand Down
8 changes: 7 additions & 1 deletion packages/mui-base/src/useList/listActions.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const ListActionTypes = {
keyDown: 'list:keyDown',
resetHighlight: 'list:resetHighlight',
textNavigation: 'list:textNavigation',
clearSelection: 'list:clearSelection',
} as const;

interface ItemClickAction<ItemValue> {
Expand Down Expand Up @@ -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.
*/
Expand All @@ -66,4 +71,5 @@ export type ListAction<ItemValue> =
| ItemsChangeAction<ItemValue>
| KeyDownAction
| ResetHighlightAction
| TextNavigationAction;
| TextNavigationAction
| ClearSelectionAction;
57 changes: 57 additions & 0 deletions packages/mui-base/src/useList/listReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,35 @@ describe('listReducer', () => {
expect(result.selectedValues).to.deep.equal(['two']);
});

it('does not select a disabled item', () => {
const state: ListState<string> = {
highlightedValue: null,
selectedValues: [],
};

const action: ListReducerAction<string> = {
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<string> = {
highlightedValue: 'a',
Expand Down Expand Up @@ -1113,4 +1142,32 @@ describe('listReducer', () => {
expect(result.highlightedValue).to.equal('two');
});
});

describe('action: clearSelection', () => {
it('clears the selection', () => {
const state: ListState<string> = {
highlightedValue: null,
selectedValues: ['one', 'two'],
};

const action: ListReducerAction<string> = {
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([]);
});
});
});
23 changes: 22 additions & 1 deletion packages/mui-base/src/useList/listReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,15 @@ export function toggleSelection<ItemValue>(
return [...selectedValues, item];
}

function handleItemSelection<ItemValue, State extends ListState<ItemValue>>(
/**
* 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<ItemValue, State extends ListState<ItemValue>>(
item: ItemValue,
state: State,
context: ListActionContext<ItemValue>,
Expand Down Expand Up @@ -423,6 +431,17 @@ function handleResetHighlight<ItemValue, State extends ListState<ItemValue>>(
};
}

function handleClearSelection<ItemValue, State extends ListState<ItemValue>>(
state: State,
context: ListActionContext<ItemValue>,
) {
return {
...state,
selectedValues: [],
highlightedValue: moveHighlight(null, 'reset', context),
};
}

export function listReducer<ItemValue, State extends ListState<ItemValue>>(
state: State,
action: ListReducerAction<ItemValue> & { context: ListActionContext<ItemValue> },
Expand All @@ -442,6 +461,8 @@ export function listReducer<ItemValue, State extends ListState<ItemValue>>(
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;
}
Expand Down
32 changes: 28 additions & 4 deletions packages/mui-base/src/useSelect/selectReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('selectReducer', () => {
open: false,
};

const action: ActionWithContext<SelectAction, ListActionContext<unknown>> = {
const action: ActionWithContext<SelectAction<string>, ListActionContext<unknown>> = {
type: SelectActionTypes.buttonClick,
event: {} as any, // not relevant
context: irrelevantConfig,
Expand All @@ -43,7 +43,7 @@ describe('selectReducer', () => {
open: true,
};

const action: ActionWithContext<SelectAction, ListActionContext<unknown>> = {
const action: ActionWithContext<SelectAction<string>, ListActionContext<unknown>> = {
type: SelectActionTypes.buttonClick,
event: {} as any, // not relevant
context: {
Expand All @@ -62,7 +62,7 @@ describe('selectReducer', () => {
open: false,
};

const action: ActionWithContext<SelectAction, ListActionContext<string>> = {
const action: ActionWithContext<SelectAction<string>, ListActionContext<string>> = {
type: SelectActionTypes.buttonClick,
event: {} as any, // not relevant
context: {
Expand All @@ -82,7 +82,7 @@ describe('selectReducer', () => {
open: false,
};

const action: ActionWithContext<SelectAction, ListActionContext<string>> = {
const action: ActionWithContext<SelectAction<string>, ListActionContext<string>> = {
type: SelectActionTypes.buttonClick,
event: {} as any, // not relevant
context: {
Expand All @@ -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<string> = {
highlightedValue: null,
selectedValues: [],
open: false,
};

const action: ActionWithContext<SelectAction<string>, ListActionContext<string>> = {
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']);
});
});
});
14 changes: 13 additions & 1 deletion packages/mui-base/src/useSelect/selectReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OptionValue>(
state: SelectInternalState<OptionValue>,
action: ActionWithContext<ListAction<OptionValue> | SelectAction, ListActionContext<OptionValue>>,
action: ActionWithContext<
ListAction<OptionValue> | SelectAction<OptionValue>,
ListActionContext<OptionValue>
>,
) {
const { open } = state;
const {
Expand All @@ -28,6 +32,14 @@ export function selectReducer<OptionValue>(
};
}

if (action.type === SelectActionTypes.browserAutoFill) {
return handleItemSelection<OptionValue, SelectInternalState<OptionValue>>(
action.item,
state,
action.context,
);
}

const newState: SelectInternalState<OptionValue> = listReducer(
state,
action as ActionWithContext<ListAction<OptionValue>, ListActionContext<OptionValue>>,
Expand Down
Loading

0 comments on commit 4b35e64

Please sign in to comment.