Skip to content

Commit

Permalink
Add required prop (#4882)
Browse files Browse the repository at this point in the history
* Add `required` prop

* Add changeset

* Add braces around `if`

* Extend test cases

* Add `aria-required`

* Remove `requiredMessage` prop

* Remove `requiredMessage` from default props
  • Loading branch information
Rall3n authored Nov 2, 2022
1 parent aa11bd7 commit c37e86d
Show file tree
Hide file tree
Showing 6 changed files with 116 additions and 3 deletions.
5 changes: 5 additions & 0 deletions .changeset/violet-readers-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'react-select': minor
---

Add `required` prop
23 changes: 20 additions & 3 deletions packages/react-select/src/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { MenuPlacer } from './components/Menu';
import LiveRegion from './components/LiveRegion';

import { createFilter, FilterOptionOption } from './filters';
import { DummyInput, ScrollManager } from './internal/index';
import { DummyInput, ScrollManager, RequiredInput } from './internal/index';
import { AriaLiveMessages, AriaSelection } from './accessibility/index';

import {
Expand Down Expand Up @@ -262,6 +262,8 @@ export interface Props<
value: PropsValue<Option>;
/** Sets the form attribute on the input */
form?: string;
/** Marks the value-holding input as required for form validation */
required?: boolean;
}

export const defaultProps = {
Expand Down Expand Up @@ -1421,6 +1423,15 @@ export default class Select<
return shouldHideSelectedOptions(this.props);
};

// If the hidden input gets focus through form submit,
// redirect focus to focusable input.
onValueInputFocus: FocusEventHandler = (e) => {
e.preventDefault();
e.stopPropagation();

this.focus();
};

// ==============================
// Keyboard Handlers
// ==============================
Expand Down Expand Up @@ -1574,6 +1585,7 @@ export default class Select<
tabIndex,
form,
menuIsOpen,
required,
} = this.props;
const { Input } = this.getComponents();
const { inputIsHidden, ariaSelection } = this.state;
Expand All @@ -1590,6 +1602,7 @@ export default class Select<
'aria-invalid': this.props['aria-invalid'],
'aria-label': this.props['aria-label'],
'aria-labelledby': this.props['aria-labelledby'],
'aria-required': required,
role: 'combobox',
...(menuIsOpen && {
'aria-controls': this.getElementId('listbox'),
Expand Down Expand Up @@ -1986,11 +1999,15 @@ export default class Select<
);
}
renderFormField() {
const { delimiter, isDisabled, isMulti, name } = this.props;
const { delimiter, isDisabled, isMulti, name, required } = this.props;
const { selectValue } = this.state;

if (!name || isDisabled) return;

if (required && !this.hasValue()) {
return <RequiredInput name={name} onFocus={this.onValueInputFocus} />;
}

if (isMulti) {
if (delimiter) {
const value = selectValue
Expand All @@ -2009,7 +2026,7 @@ export default class Select<
/>
))
) : (
<input name={name} type="hidden" />
<input name={name} type="hidden" value="" />
);

return <div>{input}</div>;
Expand Down
40 changes: 40 additions & 0 deletions packages/react-select/src/__tests__/Select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3119,3 +3119,43 @@ test('renders with custom theme', () => {
window.getComputedStyle(firstOption!).getPropertyValue('background-color')
).toEqual(primary);
});

cases(
'`required` prop',
({ props = BASIC_PROPS }) => {
const components = (value: Option | null | undefined = null) => (
<form id="formTest">
<Select {...props} required value={value} />
</form>
);

const { container, rerender } = render(components());

expect(
container.querySelector<HTMLFormElement>('#formTest')?.checkValidity()
).toEqual(false);
rerender(components(props.options[0]));
expect(
container.querySelector<HTMLFormElement>('#formTest')?.checkValidity()
).toEqual(true);
},
{
'single select > should validate with value': {
props: {
...BASIC_PROPS,
},
},
'single select (isSearchable is false) > should validate with value': {
props: {
...BASIC_PROPS,
isSearchable: false,
},
},
'multi select > should validate with value': {
props: {
...BASIC_PROPS,
isMulti: true,
},
},
}
);
20 changes: 20 additions & 0 deletions packages/react-select/src/__tests__/StateManaged.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -475,3 +475,23 @@ cases<KeyboardInteractionOpts>(
},
}
);

test('`required` prop > should validate', () => {
const { container } = render(
<form id="formTest">
<Select {...BASIC_PROPS} menuIsOpen required />
</form>
);

expect(
container.querySelector<HTMLFormElement>('#formTest')?.checkValidity()
).toEqual(false);

let selectOption = container.querySelectorAll('div.react-select__option')[3];

userEvent.click(selectOption);

expect(
container.querySelector<HTMLFormElement>('#formTest')?.checkValidity()
).toEqual(true);
});
30 changes: 30 additions & 0 deletions packages/react-select/src/internal/RequiredInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/** @jsx jsx */
import { FocusEventHandler, FunctionComponent } from 'react';
import { jsx } from '@emotion/react';

const RequiredInput: FunctionComponent<{
readonly name: string;
readonly onFocus: FocusEventHandler<HTMLInputElement>;
}> = ({ name, onFocus }) => (
<input
required
name={name}
tabIndex={-1}
onFocus={onFocus}
css={{
label: 'requiredInput',
opacity: 0,
pointerEvents: 'none',
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
width: '100%',
}}
// Prevent `Switching from uncontrolled to controlled` error
value=""
onChange={() => {}}
/>
);

export default RequiredInput;
1 change: 1 addition & 0 deletions packages/react-select/src/internal/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as A11yText } from './A11yText';
export { default as DummyInput } from './DummyInput';
export { default as ScrollManager } from './ScrollManager';
export { default as RequiredInput } from './RequiredInput';

0 comments on commit c37e86d

Please sign in to comment.