diff --git a/packages/radio/NativeControl.js b/packages/radio/NativeControl.tsx similarity index 76% rename from packages/radio/NativeControl.js rename to packages/radio/NativeControl.tsx index 4b408eadc..a1d35dee6 100644 --- a/packages/radio/NativeControl.js +++ b/packages/radio/NativeControl.tsx @@ -20,17 +20,17 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import React from 'react'; -import classnames from 'classnames'; -import PropTypes from 'prop-types'; +import * as React from 'react'; +import * as classnames from 'classnames'; -const NativeControl = (props) => { - const { - rippleActivatorRef, - className, - ...otherProps - } = props; +export interface NativeControlProps extends React.HTMLProps { + className?: string, + rippleActivatorRef?: React.RefObject +}; +const NativeControl: React.FunctionComponent = ({ + rippleActivatorRef, className = '', ...otherProps // eslint-disable-line react/prop-types +}) => { return ( { ); }; -NativeControl.propTypes = { - className: PropTypes.string, - rippleActivatorRef: PropTypes.object, -}; - -NativeControl.defaultProps = { - className: '', - rippleActivatorRef: null, -}; - export default NativeControl; diff --git a/packages/radio/index.js b/packages/radio/index.tsx similarity index 58% rename from packages/radio/index.js rename to packages/radio/index.tsx index 83031253c..e67391cc0 100644 --- a/packages/radio/index.js +++ b/packages/radio/index.tsx @@ -20,53 +20,83 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import React from 'react'; -import PropTypes from 'prop-types'; -import classnames from 'classnames'; +import * as React from 'react'; +import * as classnames from 'classnames'; +// @ts-ignore no .d.ts file import {MDCRadioFoundation} from '@material/radio/dist/mdc.radio'; -import {withRipple} from '@material/react-ripple'; -import NativeControl from './NativeControl'; +import * as Ripple from '@material/react-ripple'; +import NativeControl, {NativeControlProps} from './NativeControl'; // eslint-disable-line no-unused-vars -class Radio extends React.Component { - foundation_ = null; - radioElement_ = React.createRef(); - rippleActivatorRef = React.createRef(); +export interface RadioProps + extends Ripple.InjectedProps, React.HTMLProps { + label?: string; + initRipple: (surface: HTMLDivElement, rippleActivatorRef?: HTMLInputElement) => void; + wrapperClasses?: string; + children: React.ReactElement; +} + +interface RadioState { + nativeControlId: string; + classList: Set; + disabled: boolean; +} + +class Radio extends React.Component { + foundation: MDCRadioFoundation; + private radioElement: React.RefObject = React.createRef(); + rippleActivatorRef: React.RefObject = React.createRef(); - state = { + state: RadioState = { classList: new Set(), disabled: false, nativeControlId: '', }; - constructor(props) { + constructor(props: RadioProps) { super(props); - this.foundation_ = new MDCRadioFoundation(this.adapter); + this.foundation = new MDCRadioFoundation(this.adapter); } + static defaultProps: Partial = { + label: '', + initRipple: () => {}, + className: '', + wrapperClasses: '', + unbounded: true, + }; + componentDidMount() { - this.foundation_.init(); + this.foundation.init(); const childProps = this.props.children.props; if (childProps.disabled) { - this.foundation_.setDisabled(childProps.disabled); + this.foundation.setDisabled(childProps.disabled); } if (childProps.id) { this.setState({nativeControlId: childProps.id}); } if (this.rippleActivatorRef && this.rippleActivatorRef.current) { - this.props.initRipple(this.radioElement_.current, this.rippleActivatorRef.current); + this.props.initRipple( + this.radioElement.current as HTMLDivElement, + this.rippleActivatorRef.current + ); } } componentWillUnmount() { - if (this.foundation_) { - this.foundation_.destroy(); + if (this.foundation) { + this.foundation.destroy(); } } - componentDidUpdate(prevProps, prevState) { - const childProps = this.props.children.props; + componentDidUpdate(prevProps: RadioProps) { + const {children} = this.props; + if (!children) { + React.Children.only(children); + return; + } + const childProps = children.props; if (childProps.disabled !== prevProps.children.props.disabled) { - this.foundation_.setDisabled(childProps.disabled); + this.foundation.setDisabled(childProps.disabled); } if (childProps.id !== prevProps.children.props.id) { this.setState({nativeControlId: childProps.id}); @@ -81,17 +111,17 @@ class Radio extends React.Component { get adapter() { return { - addClass: (className) => { + addClass: (className: string) => { const classList = new Set(this.state.classList); classList.add(className); this.setState({classList}); }, - removeClass: (className) => { + removeClass: (className: string) => { const classList = new Set(this.state.classList); classList.delete(className); this.setState({classList}); }, - setNativeControlDisabled: (disabled) => this.setState({disabled}), + setNativeControlDisabled: (disabled: boolean) => this.setState({disabled}), }; } @@ -107,14 +137,13 @@ class Radio extends React.Component { wrapperClasses, ...otherProps } = this.props; - return (
-
+
{this.renderNativeControl()}
-
-
+
+
{label ? : null} @@ -128,29 +157,9 @@ class Radio extends React.Component { disabled: this.state.disabled, rippleActivatorRef: this.rippleActivatorRef, }); - return ( - React.cloneElement(children, updatedProps) - ); + return React.cloneElement(children, updatedProps); } } -Radio.propTypes = { - label: PropTypes.string, - initRipple: PropTypes.func, - className: PropTypes.string, - wrapperClasses: PropTypes.string, - unbounded: PropTypes.bool, - children: PropTypes.element.isRequired, -}; - -Radio.defaultProps = { - label: '', - initRipple: () => {}, - className: '', - wrapperClasses: '', - unbounded: true, - children: null, -}; - -export default withRipple(Radio); +export default Ripple.withRipple(Radio); export {Radio, NativeControl as NativeRadioControl}; diff --git a/test/screenshot/radio/index.js b/test/screenshot/radio/index.tsx similarity index 58% rename from test/screenshot/radio/index.js rename to test/screenshot/radio/index.tsx index dddf52c39..d096e007d 100644 --- a/test/screenshot/radio/index.js +++ b/test/screenshot/radio/index.tsx @@ -1,29 +1,41 @@ -import React from 'react'; +import * as React from 'react'; import './index.scss'; import '../../../packages/list/index.scss'; - import Radio, {NativeRadioControl} from '../../../packages/radio/index'; -class PetsRadio extends React.Component { - constructor(props) { +type PetsRadioProps = { + name: string, + disabled?: boolean, + petValue?: string +}; + +type PetsRadioState = { + petValue?: string +}; + +class PetsRadio extends React.Component { + constructor(props: PetsRadioProps) { super(props); this.state = { - petValue: props.petValue, // eslint-disable-line react/prop-types + petValue: props.petValue, }; } render() { const {petValue} = this.state; const {name, disabled} = this.props; // eslint-disable-line react/prop-types - const pets = [{ - value: 'dogs', - label: 'Dogs', - id: 'radio-dogs', - }, { - value: 'cats', - label: 'Cats', - id: 'radio-cats', - }]; + const pets = [ + { + value: 'dogs', + label: 'Dogs', + id: 'radio-dogs', + }, + { + value: 'cats', + label: 'Cats', + id: 'radio-cats', + }, + ]; return (
@@ -35,7 +47,7 @@ class PetsRadio extends React.Component { checked={petValue === pet.value} value={pet.value} id={`${pet.id}-${name}`} - onChange={(e) => this.setState({petValue: e.target.value})} + onChange={(e: React.ChangeEvent) => this.setState({petValue: e.target.value})} /> ))} @@ -44,14 +56,15 @@ class PetsRadio extends React.Component { ); } } + const RadioScreenshotTest = () => { return (

Pet Radio Buttons

- +

Preselected Radio Buttons

- +

Disabled Radio Buttons

diff --git a/test/unit/radio/NativeControl.test.js b/test/unit/radio/NativeControl.test.tsx similarity index 96% rename from test/unit/radio/NativeControl.test.js rename to test/unit/radio/NativeControl.test.tsx index a849636a0..945be5aa2 100644 --- a/test/unit/radio/NativeControl.test.js +++ b/test/unit/radio/NativeControl.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import * as React from 'react'; import {assert} from 'chai'; import {shallow} from 'enzyme'; import {NativeRadioControl} from '../../../packages/radio/index'; diff --git a/test/unit/radio/index.test.js b/test/unit/radio/index.test.js deleted file mode 100644 index d32aff78e..000000000 --- a/test/unit/radio/index.test.js +++ /dev/null @@ -1,140 +0,0 @@ -import React from 'react'; -import {assert} from 'chai'; -import td from 'testdouble'; -import {mount, shallow} from 'enzyme'; -import {Radio, NativeRadioControl} from '../../../packages/radio/index'; - -const NativeControlUpdate = ({ - disabled, id, // eslint-disable-line react/prop-types -}) => { - return ( - - ); -}; - -suite('Radio'); - -test('renders wrapper mdc-form-field element', () => { - const wrapper = shallow(); - assert.isTrue(wrapper.hasClass('mdc-form-field')); -}); -test('classNames adds classes', () => { - const wrapper = shallow(); - assert.isTrue(wrapper.childAt(0).hasClass('test-class-name')); -}); - -test('classNames has mdc-radio class', () => { - const wrapper = shallow(); - assert.isTrue(wrapper.childAt(0).hasClass('mdc-radio')); -}); - -test('classNames adds classes from state.classList', () => { - const wrapper = shallow(); - wrapper.setState({classList: new Set(['test-class'])}); - assert.isTrue(wrapper.childAt(0).hasClass('test-class')); -}); - -test('renders label if props.label is provided', () => { - const wrapper = shallow(); - assert.equal(wrapper.childAt(1).text(), 'meow'); - assert.equal(wrapper.childAt(1).type(), 'label'); -}); - -test('does not render a label if props.label is missing', () => { - const wrapper = shallow(); - assert.equal(wrapper.children().length, 1); -}); - -test('initializes foundation', () => { - const wrapper = shallow(); - assert.exists(wrapper.instance().foundation_); -}); - -test('calls foundation.setDisabled if child.props.disabled is true', () => { - const setDisabled = td.func(); - const wrapper = mount(); - wrapper.instance().foundation_ = {init: () => {}, setDisabled}; - wrapper.instance().componentDidMount(); - td.verify(setDisabled(true), {times: 1}); -}); - -test('sets state.nativeControlId if child has props.id', () => { - const wrapper = shallow(); - assert.equal(wrapper.state().nativeControlId, '123'); -}); - -test('calls props.initRipple', () => { - const initRipple = td.func(); - const wrapper = mount(); - const input = wrapper.childAt(0).childAt(0).childAt(0).getDOMNode(); - const radio = wrapper.childAt(0).childAt(0).getDOMNode(); - td.verify(initRipple(radio, input), {times: 1}); -}); - -test('renders label with for attribute tied to native control id', () => { - const wrapper = shallow(); - assert.equal(wrapper.childAt(1).props().htmlFor, '123'); -}); - -test('calls foundation.setDisabled if children.props.disabled updates', () => { - const wrapper = mount(); - wrapper.children().instance().foundation_.setDisabled = td.func(); - wrapper.setProps({disabled: true}); - td.verify(wrapper.children().instance().foundation_.setDisabled(true), {times: 1}); -}); - -test('calls foundation.setDisabled if children.props.disabled updates to false', () => { - const wrapper = mount(); - wrapper.children().instance().foundation_.setDisabled = td.func(); - wrapper.setProps({disabled: false}); - td.verify(wrapper.children().instance().foundation_.setDisabled(false), {times: 1}); -}); - -test('updates state.nativeControlId if children.props.id updates', () => { - const wrapper = mount(); - wrapper.setProps({id: '321'}); - assert.equal(wrapper.find('label').getDOMNode().getAttribute('for'), '321'); -}); - -test('#adapter.addClass adds to state.classList', () => { - const wrapper = shallow(); - wrapper.instance().adapter.addClass('test-class'); - assert.isTrue(wrapper.state().classList.has('test-class')); -}); - -test('#adapter.removeClass removes from state.classList', () => { - const wrapper = shallow(); - wrapper.setState({classList: new Set(['test-class'])}); - assert.isTrue(wrapper.state().classList.has('test-class')); - wrapper.instance().adapter.removeClass('test-class'); - assert.isFalse(wrapper.state().classList.has('test-class')); -}); - -test('#adapter.setNativeControlDisabled sets state.disabled to true', () => { - const wrapper = shallow(); - wrapper.instance().adapter.setNativeControlDisabled(true); - assert.isTrue(wrapper.state().disabled); -}); - -test('#adapter.setNativeControlDisabled sets state.disabled to false', () => { - const wrapper = shallow(); - wrapper.instance().adapter.setNativeControlDisabled(false); - assert.isFalse(wrapper.state().disabled); -}); - -test('renders nativeControl with updated disabled prop', () => { - const wrapper = mount(); - wrapper.setState({disabled: true}); - assert.isTrue(wrapper.children().children().childAt(0).props().disabled); -}); - -test('#componentWillUnmount destroys foundation', () => { - const wrapper = shallow(); - const foundation = wrapper.instance().foundation_; - foundation.destroy = td.func(); - wrapper.unmount(); - td.verify(foundation.destroy(), {times: 1}); -}); diff --git a/test/unit/radio/index.test.tsx b/test/unit/radio/index.test.tsx new file mode 100644 index 000000000..a75abd41e --- /dev/null +++ b/test/unit/radio/index.test.tsx @@ -0,0 +1,237 @@ +import * as React from 'react'; +import {assert} from 'chai'; +import * as td from 'testdouble'; +import {mount, shallow, ReactWrapper} from 'enzyme'; +import {Radio, NativeRadioControl, RadioProps} from '../../../packages/radio/index'; + +const NativeControlUpdate: React.FunctionComponent> = ({ + disabled, id, // eslint-disable-line react/prop-types +}) => { + return ( + + + + ); +}; + +suite('Radio'); + +test('renders wrapper mdc-form-field element', () => { + const wrapper = shallow( + + + + ); + assert.isTrue(wrapper.hasClass('mdc-form-field')); +}); + +test('classNames adds classes', () => { + const wrapper = shallow( + + + + ); + assert.isTrue(wrapper.childAt(0).hasClass('test-class-name')); +}); + +test('classNames has mdc-radio class', () => { + const wrapper = shallow( + + + + ); + assert.isTrue(wrapper.childAt(0).hasClass('mdc-radio')); +}); + +test('classNames adds classes from state.classList', () => { + const wrapper = shallow( + + + + ); + wrapper.setState({classList: new Set(['test-class'])}); + assert.isTrue(wrapper.childAt(0).hasClass('test-class')); +}); + +test('renders label if props.label is provided', () => { + const wrapper = shallow( + + + + ); + assert.equal(wrapper.childAt(1).text(), 'meow'); + assert.equal(wrapper.childAt(1).type(), 'label'); +}); + +test('does not render a label if props.label is missing', () => { + const wrapper = shallow( + + + + ); + assert.equal(wrapper.children().length, 1); +}); + +test('initializes foundation', () => { + const wrapper = shallow( + + + + ); + assert.exists(wrapper.instance().foundation); +}); + +test('calls foundation.setDisabled if child.props.disabled is true', () => { + const setDisabled = td.func(); + const wrapper = mount( + + + + ); + wrapper.instance().foundation = {init: () => {}, setDisabled}; + wrapper.instance().componentDidMount(); + td.verify(setDisabled(true), {times: 1}); +}); + +test('sets state.nativeControlId if child has props.id', () => { + const wrapper = shallow( + + + + ); + assert.equal(wrapper.state().nativeControlId, '123'); +}); + +test('calls props.initRipple', () => { + const initRipple = td.func() as (surface: HTMLDivElement, activator?: HTMLInputElement) => void; + const wrapper = mount( + + + + ); + const input = wrapper + .childAt(0) + .childAt(0) + .childAt(0) + .getDOMNode() as HTMLInputElement; + const radio = wrapper + .childAt(0) + .childAt(0) + .getDOMNode() as HTMLDivElement; + td.verify(initRipple(radio, input), {times: 1}); +}); + +test('renders label with for attribute tied to native control id', () => { + const wrapper = shallow( + + + + ); + assert.equal(wrapper.childAt(1).props().htmlFor, '123'); +}); + +test('calls foundation.setDisabled if children.props.disabled updates', () => { + const wrapper = mount(); + (wrapper.children() as ReactWrapper).instance().foundation.setDisabled = td.func(); + wrapper.setProps({disabled: true}); + td.verify( + (wrapper.children() as ReactWrapper) + .instance() + .foundation.setDisabled(true), + {times: 1} + ); +}); + +test('calls foundation.setDisabled if children.props.disabled updates to false', () => { + const wrapper = mount(); + (wrapper.children() as ReactWrapper).instance().foundation.setDisabled = td.func(); + wrapper.setProps({disabled: false}); + td.verify( + (wrapper.children() as ReactWrapper) + .instance() + .foundation.setDisabled(false), + {times: 1} + ); +}); + +test('updates state.nativeControlId if children.props.id updates', () => { + const wrapper = mount(); + wrapper.setProps({id: '321'}); + assert.equal( + wrapper + .find('label') + .getDOMNode() + .getAttribute('for'), + '321' + ); +}); + +test('#adapter.addClass adds to state.classList', () => { + const wrapper = shallow( + + + + ); + wrapper.instance().adapter.addClass('test-class'); + assert.isTrue(wrapper.state().classList.has('test-class')); +}); + +test('#adapter.removeClass removes from state.classList', () => { + const wrapper = shallow( + + + + ); + wrapper.setState({classList: new Set(['test-class'])}); + assert.isTrue(wrapper.state().classList.has('test-class')); + wrapper.instance().adapter.removeClass('test-class'); + assert.isFalse(wrapper.state().classList.has('test-class')); +}); + +test('#adapter.setNativeControlDisabled sets state.disabled to true', () => { + const wrapper = shallow( + + + + ); + wrapper.instance().adapter.setNativeControlDisabled(true); + assert.isTrue(wrapper.state().disabled); +}); + +test('#adapter.setNativeControlDisabled sets state.disabled to false', () => { + const wrapper = shallow( + + + + ); + wrapper.instance().adapter.setNativeControlDisabled(false); + assert.isFalse(wrapper.state().disabled); +}); + +test('renders nativeControl with updated disabled prop', () => { + const wrapper = mount( + + + + ); + wrapper.setState({disabled: true}); + assert.isTrue( + wrapper + .children() + .children() + .childAt(0) + .props().disabled + ); +}); + +test('#componentWillUnmount destroys foundation', () => { + const wrapper = shallow( + + + + ); + const foundation = wrapper.instance().foundation; + foundation.destroy = td.func(); + wrapper.unmount(); + td.verify(foundation.destroy(), {times: 1}); +});