From e22ac2a310f0cdc3ac3de08a66caaacb7c9a7100 Mon Sep 17 00:00:00 2001 From: Matt Goo Date: Mon, 29 Apr 2019 13:35:45 -0700 Subject: [PATCH] feat(select): add helper text (#824) --- packages/select/helper-text/README.md | 23 ++++ packages/select/helper-text/index.tsx | 130 ++++++++++++++++++++ test/unit/select/helper-text/index.test.tsx | 98 +++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 packages/select/helper-text/README.md create mode 100644 packages/select/helper-text/index.tsx create mode 100644 test/unit/select/helper-text/index.test.tsx diff --git a/packages/select/helper-text/README.md b/packages/select/helper-text/README.md new file mode 100644 index 000000000..a630823eb --- /dev/null +++ b/packages/select/helper-text/README.md @@ -0,0 +1,23 @@ +# React Select Helper Text + +MDC React Select Helper Text is a React Component which uses MDC [MDC Select Helper Text](https://github.com/material-components/material-components-web/tree/master/packages/mdc-select/helper-text/)'s CSS and foundation JavaScript. + +## Usage + +```js +import {SelectHelperText} from '@material/react-select/helper-text/index'; + +const MyComponent = () => { + return ( + + Really fun helper text + + ); +} +``` + +## Props + +Prop Name | Type | Description +--- | --- | --- +persistent | boolean | Adds the `.mdc-select-helper-text--persistent` class to keep the helper text always visible. diff --git a/packages/select/helper-text/index.tsx b/packages/select/helper-text/index.tsx new file mode 100644 index 000000000..90dda5a3e --- /dev/null +++ b/packages/select/helper-text/index.tsx @@ -0,0 +1,130 @@ +// The MIT License +// +// Copyright (c) 2019 Google, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import * as React from 'react'; +import classnames from 'classnames'; +import {MDCSelectHelperTextAdapter} from '@material/select/helper-text/adapter'; +import {MDCSelectHelperTextFoundation} from '@material/select/helper-text/foundation'; + +export interface SelectHelperTextProps extends React.HTMLProps { + persistent?: boolean; + setHelperTextFoundation?: (foundation?: MDCSelectHelperTextFoundation) => void; +} + +interface ElementAttributes { + 'aria-hidden'?: boolean | 'false' | 'true'; + role?: string; +}; + +interface SelectHelperTextState extends ElementAttributes { + classList: Set; +}; + +export class SelectHelperText extends React.Component { + foundation?: MDCSelectHelperTextFoundation; + + state: SelectHelperTextState = { + 'aria-hidden': undefined, + 'role': undefined, + 'classList': new Set(), + }; + + componentDidMount() { + const {setHelperTextFoundation} = this.props; + this.foundation = new MDCSelectHelperTextFoundation(this.adapter); + this.foundation.init(); + setHelperTextFoundation && setHelperTextFoundation(this.foundation); + } + + componentWillUnmount() { + const {setHelperTextFoundation} = this.props; + if (this.foundation) { + this.foundation.destroy(); + setHelperTextFoundation && setHelperTextFoundation(undefined); + } + } + + get classes() { + const {className, persistent} = this.props; + const {classList} = this.state; + return classnames('mdc-select-helper-text', Array.from(classList), className, { + 'mdc-select-helper-text--persistent': persistent, + }); + } + + get adapter(): MDCSelectHelperTextAdapter { + return { + addClass: (className: string) => { + const classList = new Set(this.state.classList); + classList.add(className); + this.setState({classList}); + }, + removeClass: (className: string) => { + const classList = new Set(this.state.classList); + classList.delete(className); + this.setState({classList}); + }, + hasClass: (className: string) => { + return this.classes.split(' ').includes(className); + }, + setAttr: (attr: keyof ElementAttributes, value: ElementAttributes[keyof ElementAttributes]) => { + this.setState((prevState) => ({ + ...prevState, + [attr]: value, + })); + }, + removeAttr: (attr: keyof ElementAttributes) => { + this.setState((prevState) => ({...prevState, [attr]: null})); + }, + setContent: () => { + // not implmenting because developer should would never call `setContent()` + }, + }; + } + + render() { + const { + children, + /* eslint-disable no-unused-vars */ + persistent, + className, + setHelperTextFoundation, + /* eslint-enable no-unused-vars */ + ...otherProps + } = this.props; + const { + 'aria-hidden': ariaHidden, + role, + } = this.state; + + return ( +

+ {children} +

+ ); + } +} diff --git a/test/unit/select/helper-text/index.test.tsx b/test/unit/select/helper-text/index.test.tsx new file mode 100644 index 000000000..f54839f51 --- /dev/null +++ b/test/unit/select/helper-text/index.test.tsx @@ -0,0 +1,98 @@ +import * as React from 'react'; +import * as td from 'testdouble'; +import {assert} from 'chai'; +import {shallow, mount} from 'enzyme'; +import {SelectHelperText} from '../../../../packages/select/helper-text/index'; +import {MDCSelectHelperTextFoundation} from '@material/select/helper-text/foundation'; + +suite('Select Helper Text'); + +test('renders with mdc-select-helper-text class', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.hasClass('mdc-select-helper-text')); +}); + +test('renders with a test class name', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.hasClass('test-class')); +}); + +test('renders with class from state.classList', () => { + const wrapper = shallow(); + wrapper.setState({classList: new Set(['test-class'])}); + assert.isTrue(wrapper.hasClass('test-class')); +}); + +test('renders with persistent class when props.persistent is true', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.hasClass('mdc-select-helper-text--persistent')); +}); + +test.only('calls setHelperTextFoundation with foundation', () => { + const setHelperTextFoundation = td.func<(foundation?: MDCSelectHelperTextFoundation) => void>(); + shallow(); + // TODO: change Object to MDCSelectHelperTextFoundation in PR 823 + td.verify(setHelperTextFoundation(td.matchers.isA(Object)), {times: 1}); +}); + +test('#componentWillUnmount destroys foundation', () => { + const wrapper = mount(); + const foundation = wrapper.instance().foundation!; + foundation.destroy = td.func<() => void>(); + wrapper.unmount(); + td.verify(foundation.destroy(), {times: 1}); +}); + +test('#adapter.addClass should add a class to state.classList', () => { + const wrapper = shallow(); + wrapper.instance().adapter.addClass('test-class'); + assert.isTrue(wrapper.state().classList.has('test-class')); +}); + +test('#adapter.removeClass should remove a class to state.classList', () => { + const wrapper = shallow(); + wrapper.setState({classList: new Set(['test-class'])}); + wrapper.instance().adapter.removeClass('test-class'); + assert.isFalse(wrapper.state().classList.has('test-class')); +}); + +test('#adapter.hasClass should return true if state.classList has class', () => { + const wrapper = shallow(); + wrapper.setState({classList: new Set(['test-class'])}); + assert.isTrue(wrapper.instance().adapter.hasClass('test-class')); +}); + +test('#adapter.hasClass should return true if className has class', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.instance().adapter.hasClass('test-class')); +}); + +test('#adapter.setAttr should update state', () => { + const wrapper = shallow(); + wrapper.instance().adapter.setAttr('role', 'menu'); + assert.equal(wrapper.state().role, 'menu'); +}); + +test('#adapter.removeAttr should update state', () => { + const wrapper = shallow(); + wrapper.setState({role: 'menu'}); + wrapper.instance().adapter.removeAttr('role'); + assert.equal(wrapper.state().role, null); +}); + +test('renders with aria-hidden from state.aria-hidden', () => { + const wrapper = mount(); + wrapper.setState({'aria-hidden': 'true'}); + assert.equal(wrapper.getDOMNode().getAttribute('aria-hidden'), 'true'); +}); + +test('renders with role from state.role', () => { + const wrapper = mount(); + wrapper.setState({'role': 'true'}); + assert.equal(wrapper.getDOMNode().getAttribute('role'), 'true'); +}); + +test('renders children', () => { + const wrapper = mount(MEOW); + assert.equal(wrapper.text(), 'MEOW'); +});