Skip to content

Commit

Permalink
[feature] class-based-select (#284)
Browse files Browse the repository at this point in the history
* WIP. Converting Select to React PureComponent.

* Adding focusTrigger to select, textfield, autocomplete.

* Adding autocomplete override to TextField for Chrome

* Updating autoComplete and removing yarn.lock from changeset.

* Removing yarn.lock from feature branch.

* Adding back yarn.lock file.

* Adding back the master version of packages/core/yarn.lock

* Adding a final tweak to the autoComplete attribute.

* Changing autoComplete to nope to correct Chrome issue.

* Adding updated comments for Autocomplete.

* Bumping Downshift and lockfile.

* Comment refactor to rebuild.
  • Loading branch information
1Copenut authored Oct 23, 2018
1 parent 1a1c40d commit 0237a20
Show file tree
Hide file tree
Showing 8 changed files with 149 additions and 58 deletions.
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"@cmsgov/design-system-support": "^1.27.0",
"classnames": "^2.2.5",
"core-js": "^2.5.3",
"downshift": "^1.28.2",
"downshift": "1.31.16",
"ev-emitter": "^1.1.1",
"lodash.uniqueid": "^4.0.1",
"react-aria-modal": "^2.11.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ ReactDOM.render(
name: 'Cook County, OR'
}
]}
focusTrigger
label="Select from the options below:"
onChange={selectedItem => console.log(selectedItem)}
onInputValueChange={inputVal =>
Expand Down
28 changes: 25 additions & 3 deletions packages/core/src/components/Autocomplete/Autocomplete.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ export class Autocomplete extends React.PureComponent {
constructor(props) {
super(props);

this.id = uniqueId('autocomplete_');
this.id = this.props.id || uniqueId('autocomplete_');
this.labelId = uniqueId('autocomplete_header_');
this.listboxId = uniqueId('autocomplete_owned_listbox_');
this.loader = null;
}

filterItems(
Expand Down Expand Up @@ -69,12 +70,14 @@ export class Autocomplete extends React.PureComponent {
}

renderChildren(getInputProps) {
// Extend props on the TextField, by passing them through
// Downshift's `getInputProps` method
// Extend props on the TextField, by passing them
// through Downshift's `getInputProps` method
return React.Children.map(this.props.children, child => {
if (isTextField(child)) {
const propOverrides = {
'aria-controls': this.listboxId,
autoComplete: this.props.autoCompleteLabel,
focusTrigger: this.props.focusTrigger,
id: this.id,
onBlur: child.props.onBlur,
onChange: child.props.onChange,
Expand Down Expand Up @@ -167,6 +170,7 @@ export class Autocomplete extends React.PureComponent {

Autocomplete.defaultProps = {
ariaClearLabel: 'Clear typeahead and search again',
autoCompleteLabel: 'nope',
clearInputText: 'Clear search',
itemToString: item => (item ? item.name : ''),
loadingMessage: 'Loading...',
Expand All @@ -178,6 +182,13 @@ Autocomplete.propTypes = {
* Screenreader-specific label for the Clear search `<button>`. Intended to provide a longer, more descriptive explanation of the button's behavior.
*/
ariaClearLabel: PropTypes.string,
/**
* Control the `TextField` autocomplete attribute. Defaults to 'nope' to prevent Chrome
* from autofilling user presets.
*
* https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion
*/
autoCompleteLabel: PropTypes.string,
children: PropTypes.node,
/**
* Additional classes to be added to the root element.
Expand All @@ -188,6 +199,17 @@ Autocomplete.propTypes = {
* Clear search text that will appear on the page as part of the rendered `<button>` component
*/
clearInputText: PropTypes.node,
/**
* Used to focus child `TextField` on `componentDidMount()`
*/
focusTrigger: PropTypes.bool,
/**
* A unique id to be passed to the child `TextField`. If no id is passed as a prop,
* the `Autocomplete` component will auto-generate one. This prop was provided in cases
* where an id might need to be passed to multiple components, such as the `htmlFor`
* attribute on a label and the id of an input.
*/
id: PropTypes.string,
/**
* Used to determine the string value for the selected item (which is used to compute the `inputValue`).
*
Expand Down
83 changes: 56 additions & 27 deletions packages/core/src/components/ChoiceList/Select.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,58 @@ import uniqueId from 'lodash.uniqueid';
* Any _undocumented_ props that you pass to this component will be passed
* to the `select` element, so you can use this to set additional attributes if
* necessary.
*
* Class-based component gives flexibility for active focus management
* by allowing refs to be passed.
*/
export const Select = function(props) {
/* eslint-disable prefer-const */
let {
// Using let rather than const since we sometimes rewrite id
children,
className,
id,
inversed,
size,
...selectProps
} = props;
/* eslint-enable prefer-const */

const classes = classNames(
'ds-c-field',
{ 'ds-c-field--inverse': inversed },
className,
size && `ds-c-field--${size}`
);

if (!id) {
id = uniqueId(`select_${selectProps.name}_`);
export class Select extends React.PureComponent {
componentDidMount() {
if (this.props.focusTrigger) {
this.loader && this.loader.focus();
}
}

return (
<select className={classes} id={id} {...selectProps}>
{children}
</select>
);
};
render() {
/* eslint-disable prefer-const */
let {
// Using let rather than const since we sometimes rewrite id
children,
className,
focusTrigger,
id,
inversed,
selectRef,
size,
...selectProps
} = this.props;
/* eslint-enable prefer-const */

const classes = classNames(
'ds-c-field',
{ 'ds-c-field--inverse': inversed },
className,
size && `ds-c-field--${size}`
);

if (!id) {
id = uniqueId(`select_${selectProps.name}_`);
}

return (
<select
className={classes}
id={id}
/* eslint-disable no-return-assign */
ref={focusTrigger ? loader => (this.loader = loader) : selectRef}
/* eslint-enable no-return-assign */
{...selectProps}
>
{children}
</select>
);
}
}

Select.propTypes = {
children: PropTypes.node.isRequired,
Expand All @@ -52,6 +73,10 @@ Select.propTypes = {
*/
defaultValue: PropTypes.string,
disabled: PropTypes.bool,
/**
* Used to focus `select` on `componentDidMount()`
*/
focusTrigger: PropTypes.bool,
/**
* A unique ID to be used for the select field. A unique ID will be generated
* if one isn't provided.
Expand Down Expand Up @@ -82,6 +107,10 @@ Select.propTypes = {
name: PropTypes.string.isRequired,
onBlur: PropTypes.func,
onChange: PropTypes.func,
/**
* Access a reference to the `select` element
*/
selectRef: PropTypes.func,
/**
* Set the max-width of the input either to `'small'` or `'medium'`.
*/
Expand Down
50 changes: 32 additions & 18 deletions packages/core/src/components/ChoiceList/Select.test.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mount, shallow } from 'enzyme';
import React from 'react';
import Select from './Select';
import { shallow } from 'enzyme';

/**
* Generate <option> elements
Expand All @@ -27,44 +27,43 @@ function generateOptions(count) {
* @param {number} optionsCount - Total number of <option>'s
* @return {object}
*/
function shallowRender(customProps = {}, optionsCount = 1) {
function render(customProps = {}, optionsCount = 1, deep = false) {
const props = Object.assign(
{
name: 'presidents'
},
customProps
);
const component = <Select {...props}>{generateOptions(optionsCount)}</Select>;

return {
props: props,
wrapper: shallow(
<Select {...props}>{generateOptions(optionsCount)}</Select>
)
wrapper: deep ? mount(component) : shallow(component)
};
}

describe('Select', () => {
it('renders a select menu', () => {
const data = shallowRender();
const data = render();

expect(data.wrapper.is('select')).toBe(true);
expect(data.wrapper.prop('name')).toBe(data.props.name);
});

it('has correct class names', () => {
const data = shallowRender();
const data = render();

expect(data.wrapper.hasClass('ds-c-field')).toBe(true);
});

it("renders <option>'s as children", () => {
const data = shallowRender({}, 10);
const data = render({}, 10);

expect(data.wrapper.children('option').length).toBe(10);
});

it('has a selected <option>', () => {
const data = shallowRender(
const data = render(
{
defaultValue: '2' // the second generated option
},
Expand All @@ -83,58 +82,73 @@ describe('Select', () => {
});

it('applies additional classNames to root element', () => {
const data = shallowRender({ className: 'foo' });
const data = render({ className: 'foo' });

expect(data.wrapper.hasClass(data.props.className)).toBe(true);
// Make sure we're not replacing the other class names
expect(data.wrapper.hasClass('ds-c-field')).toBe(true);
});

it('adds size classes to root element', () => {
const mediumData = shallowRender({ size: 'medium' });
const smallData = shallowRender({ size: 'small' });
const mediumData = render({ size: 'medium' });
const smallData = render({ size: 'small' });

expect(mediumData.wrapper.hasClass('ds-c-field--medium')).toBe(true);
expect(smallData.wrapper.hasClass('ds-c-field--small')).toBe(true);
});

it('is disabled', () => {
const data = shallowRender({ disabled: true });
const data = render({ disabled: true });

expect(data.wrapper.prop('disabled')).toBe(true);
});

it('is not disabled', () => {
const data = shallowRender();
const data = render();

expect(data.wrapper.prop('disabled')).toBeUndefined();
});

it('is required', () => {
const data = shallowRender({ required: true });
const data = render({ required: true });

expect(data.wrapper.prop('required')).toBe(true);
});

it('is inversed', () => {
const data = shallowRender({ inversed: true });
const data = render({ inversed: true });

expect(data.wrapper.hasClass('ds-c-field--inverse')).toBe(true);
});

it('accepts a custom id', () => {
const data = shallowRender({ id: 'custom_id' });
const data = render({ id: 'custom_id' });

expect(data.wrapper.prop('id')).toBe(data.props.id);
});

it('generates a unique id', () => {
const data = shallowRender();
const data = render();
const idRegex = new RegExp(`select_${data.props.name}_[0-9]+`);

expect(data.wrapper.prop('id')).toMatch(idRegex);
});

it('focuses the select when focusTrigger is passed', () => {
const data = render(
{
id: 'focus',
focusTrigger: true
},
null,
true
);

expect(data.wrapper.find('select').props().id).toEqual(
document.activeElement.id
);
});

describe('event handlers', () => {
let wrapper;
let onBlurMock;
Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/components/TextField/TextField.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export class TextField extends React.PureComponent {
this.id = props.id || uniqueId('textfield_');
}

componentDidMount() {
if (this.props.focusTrigger) {
this.loader && this.loader.focus();
}
}

ariaLabel() {
if (this.props.ariaLabel) {
return this.props.ariaLabel;
Expand Down Expand Up @@ -70,6 +76,7 @@ export class TextField extends React.PureComponent {
className,
labelClassName,
fieldClassName,
focusTrigger,
errorMessage,
hint,
id,
Expand Down Expand Up @@ -109,7 +116,9 @@ export class TextField extends React.PureComponent {
aria-label={this.ariaLabel()}
className={fieldClasses}
id={this.id}
ref={fieldRef}
/* eslint-disable no-return-assign */
ref={focusTrigger ? loader => (this.loader = loader) : fieldRef}
/* eslint-enable no-return-assign */
rows={_rows}
type={multiline ? undefined : type}
{...fieldProps}
Expand Down Expand Up @@ -164,6 +173,10 @@ TextField.propTypes = {
* Access a reference to the `input` or `textarea` element
*/
fieldRef: PropTypes.func,
/**
* Used to focus `input` on `componentDidMount()`
*/
focusTrigger: PropTypes.bool,
/**
* Additional hint text to display
*/
Expand Down
Loading

0 comments on commit 0237a20

Please sign in to comment.