diff --git a/packages/list/ListItem.tsx b/packages/list/ListItem.tsx index 221891953..fd534683c 100644 --- a/packages/list/ListItem.tsx +++ b/packages/list/ListItem.tsx @@ -29,7 +29,9 @@ import {MDCListFoundation} from '@material/list/foundation'; import {ListItemContext, ListItemContextShape} from './index'; export interface ListItemProps - extends React.HTMLProps, ListItemContextShape, InjectedProps { + extends React.HTMLProps, + ListItemContextShape, + InjectedProps { checkboxList?: boolean; radioList?: boolean; tag?: string; diff --git a/packages/menu/MenuListItem.tsx b/packages/menu/MenuListItem.tsx index 5e8d4025e..fdd7b48d6 100644 --- a/packages/menu/MenuListItem.tsx +++ b/packages/menu/MenuListItem.tsx @@ -36,9 +36,9 @@ class MenuListItem extends React.Component< const { role = 'menuitem', children, - /* eslint-disable no-unused-vars */ + /* eslint-disable @typescript-eslint/no-unused-vars */ computeBoundingRect, - /* eslint-disable no-unused-vars */ + /* eslint-disable @typescript-eslint/no-unused-vars */ ...otherProps } = this.props; diff --git a/packages/text-field/character-counter/README.md b/packages/text-field/character-counter/README.md new file mode 100644 index 000000000..8f235d646 --- /dev/null +++ b/packages/text-field/character-counter/README.md @@ -0,0 +1,59 @@ +# React Text Field Character Counter + +MDC React Text Field Character Counter is a React Component which uses [MDC Text Field Character Counter](https://github.com/material-components/material-components-web/tree/master/packages/mdc-textfield/character-counter)'s Sass and Foundational JavaScript logic. + +## Usage + +```js +import CharacterCounter from '@material/react-text-field/character-counter/index.js'; + +const MyComponent = () => { + return ( + + ); +} +``` + +## Props + +Prop Name | Type | Description +--- | --- | --- +className | String | CSS classes for element. +template | String | You can set custom template. [See below](#custom-template) + +## Custom Template + +CharacterCounter provides customization with the `template` prop in CharacterCounter. +The `template` prop accepts the `${count}` and `${maxLength}` arguments. +The default template is `${count} / ${maxLength}`, so it appears `0 / 140`. +If you set template as `${count} : ${maxLength}`, it appears as `0 : 140`. + +### Sample + +``` js +import React from 'react'; +import TextField, {CharacterCounter, Input} from '@material/react-text-field'; + +class MyApp extends React.Component { + state = {value: 'Happy Coding!'}; + + render() { + return ( + }> + this.setState({value: e.target.value})} + /> + + ); + } +} +``` + +## Sass Mixins + +Sass mixins may be available to customize various aspects of the Components. Please refer to the +MDC Web repository for more information on what mixins are available, and how to use them. + +[Advanced Sass Mixins](https://github.com/material-components/material-components-web/tree/master/packages/mdc-textfield/character-counter#sass-mixins) diff --git a/packages/text-field/character-counter/index.scss b/packages/text-field/character-counter/index.scss new file mode 100644 index 000000000..8b3766187 --- /dev/null +++ b/packages/text-field/character-counter/index.scss @@ -0,0 +1,23 @@ +// 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 "@material/textfield/character-counter/mdc-text-field-character-counter"; diff --git a/packages/text-field/character-counter/index.tsx b/packages/text-field/character-counter/index.tsx new file mode 100644 index 000000000..2edbf7a4c --- /dev/null +++ b/packages/text-field/character-counter/index.tsx @@ -0,0 +1,93 @@ +// 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 React from 'react'; +import classnames from 'classnames'; +import {MDCTextFieldCharacterCounterAdapter} from '@material/textfield/character-counter/adapter'; +import {MDCTextFieldCharacterCounterFoundation} from '@material/textfield/character-counter/foundation'; + +const cssClasses = MDCTextFieldCharacterCounterFoundation.cssClasses; + +const TEMPLATE = { + COUNT: '${count}', + MAX_LENGTH: '${maxLength}', +}; + +export interface CharacterCounterProps extends React.HTMLProps { + count?: number; + maxLength?: number; + template?: string; +} + +export default class CharacterCounter extends React.Component< + CharacterCounterProps +> { + foundation = new MDCTextFieldCharacterCounterFoundation(this.adapter); + + componentWillUnmount() { + this.foundation.destroy(); + } + + get adapter(): MDCTextFieldCharacterCounterAdapter { + return { + // Please manage content through JSX + setContent: () => undefined, + }; + } + + renderTemplate(template: string) { + const {count = 0, maxLength = 0} = this.props; + + return template + .replace(TEMPLATE.COUNT, count.toString()) + .replace(TEMPLATE.MAX_LENGTH, maxLength.toString()); + } + + get classes() { + return classnames(cssClasses.ROOT, this.props.className); + } + + get otherProps() { + const { + /* eslint-disable @typescript-eslint/no-unused-vars */ + className, + count, + maxLength, + template, + /* eslint-disable @typescript-eslint/no-unused-vars */ + ...otherProps + } = this.props; + + return otherProps; + } + + render() { + const {template} = this.props; + + return ( +
+ {this.renderTemplate( + template ? template : `${TEMPLATE.COUNT} / ${TEMPLATE.MAX_LENGTH}` + )} +
+ ); + } +} diff --git a/packages/text-field/index.tsx b/packages/text-field/index.tsx index ae22c7508..613e3ff2d 100644 --- a/packages/text-field/index.tsx +++ b/packages/text-field/index.tsx @@ -31,8 +31,9 @@ import { } from '@material/textfield/adapter'; import {MDCTextFieldFoundation} from '@material/textfield/foundation'; import Input, {InputProps} from './Input'; -import Icon, {IconProps} from './icon/index'; -import HelperText, {HelperTextProps} from './helper-text/index'; +import Icon, {IconProps} from './icon'; +import HelperText, {HelperTextProps} from './helper-text'; +import CharacterCounter, {CharacterCounterProps} from './character-counter'; import FloatingLabel from '@material/react-floating-label'; import LineRipple from '@material/react-line-ripple'; import NotchedOutline from '@material/react-notched-outline'; @@ -48,7 +49,7 @@ export interface Props { floatingLabelClassName?: string; fullWidth?: boolean; helperText?: React.ReactElement; - characterCounter?: React.ReactElement; + characterCounter?: React.ReactElement; label?: React.ReactNode; leadingIcon?: React.ReactElement>; lineRippleClassName?: string; @@ -82,6 +83,7 @@ interface TextFieldState { class TextField< T extends HTMLElement = HTMLInputElement > extends React.Component, TextFieldState> { + textFieldElement: React.RefObject = React.createRef(); floatingLabelElement: React.RefObject = React.createRef(); inputComponent_: null | Input = null; @@ -175,6 +177,7 @@ class TextField< floatingLabelClassName, fullWidth, helperText, + characterCounter, label, leadingIcon, lineRippleClassName, @@ -278,10 +281,11 @@ class TextField< }; } - inputProps(child: React.ReactElement>) { + get inputProps() { // ref does exist on React.ReactElement> // @ts-ignore - const {props} = child; + const {props} = React.Children.only(this.props.children); + return Object.assign({}, props, { foundation: this.state.foundation, handleFocusChange: (isFocused: boolean) => { @@ -300,6 +304,14 @@ class TextField< }); } + get characterCounterProps() { + const {value, maxLength} = this.inputProps; + return { + count: value ? value.length : 0, + maxLength: maxLength ? parseInt(maxLength) : 0, + }; + } + /** * render methods */ @@ -323,11 +335,15 @@ class TextField< className={this.classes} onClick={() => foundation!.handleTextFieldInteraction()} onKeyDown={() => foundation!.handleTextFieldInteraction()} + ref={this.textFieldElement} key='text-field-container' > {leadingIcon ? this.renderIcon(leadingIcon, onLeadingIconSelect) : null} + {textarea && + characterCounter && + this.renderCharacterCounter(characterCounter)} {this.renderInput()} {this.notchedOutlineAdapter.hasOutline() ? ( this.renderNotchedOutline() @@ -352,8 +368,7 @@ class TextField< const child: React.ReactElement> = React.Children.only( this.props.children ); - const props = this.inputProps(child); - return React.cloneElement(child, props); + return React.cloneElement(child, this.inputProps); } renderLabel() { @@ -402,12 +417,14 @@ class TextField< renderHelperLine( helperText?: React.ReactElement, - characterCounter?: React.ReactElement + characterCounter?: React.ReactElement ) { return (
{helperText && this.renderHelperText(helperText)} - {characterCounter} + {characterCounter && + !this.props.textarea && + this.renderCharacterCounter(characterCounter)}
); } @@ -436,7 +453,25 @@ class TextField< ); } + + renderCharacterCounter( + characterCounter: React.ReactElement + ) { + return React.cloneElement( + characterCounter, + Object.assign(this.characterCounterProps, characterCounter.props) + ); + } } -export {Icon, HelperText, Input, IconProps, HelperTextProps, InputProps}; +export { + Icon, + HelperText, + CharacterCounter, + Input, + IconProps, + HelperTextProps, + CharacterCounterProps, + InputProps, +}; export default TextField; diff --git a/test/screenshot/golden.json b/test/screenshot/golden.json index fc5ba0683..ed3fa3818 100644 --- a/test/screenshot/golden.json +++ b/test/screenshot/golden.json @@ -22,6 +22,7 @@ "tab-bar": "6c28ec268b2baf308459e7df9d7471fb7907b6473240b9a28a81be54a335f932", "tab-indicator": "7ce7ce8fd50301c67d7ebfb0ba953208260ce2881bee0c7e640c46bf60dc90b6", "tab-scroller": "468866dd0c222b36b55485ab44a5760133a4ddfb2a6cf81e6ae4672d7e02a447", + "text-field/character-counter": "b6c744bd58b76dd7d3794fa84dae98e44a612b11f7e6dab895e91aceae8aba73", "text-field/helper-text": "59604d0f39e0846fc97aae7573d317dded215282a677e4641c5e33426e3a2a1e", "text-field/icon": "0bbc8c762d27071e55983e5742548d166864b6fcebc0b9f1e413523fb93b7075", "text-field/textArea": "dde78e3f154a8b910a989f8ce96e320e7ad2b3e199e6e7a81034174c598cbd9d", diff --git a/test/screenshot/screenshot-test-urls.tsx b/test/screenshot/screenshot-test-urls.tsx index 532b2e5d1..4fdf6af60 100644 --- a/test/screenshot/screenshot-test-urls.tsx +++ b/test/screenshot/screenshot-test-urls.tsx @@ -25,6 +25,7 @@ const urls = [ 'tab-bar', 'tab-indicator', 'tab-scroller', + 'text-field/character-counter', 'text-field/helper-text', 'text-field/icon', 'typography', diff --git a/test/screenshot/text-field/TestTextField.tsx b/test/screenshot/text-field/TestTextField.tsx index 45d1c7a5b..920dff95c 100644 --- a/test/screenshot/text-field/TestTextField.tsx +++ b/test/screenshot/text-field/TestTextField.tsx @@ -54,6 +54,7 @@ class TestField extends React.Component { required={required} disabled={disabled} onChange={this.onChange} + maxLength={140} /> diff --git a/test/screenshot/text-field/character-counter/index.tsx b/test/screenshot/text-field/character-counter/index.tsx new file mode 100644 index 000000000..02d15d598 --- /dev/null +++ b/test/screenshot/text-field/character-counter/index.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import TextField, { + CharacterCounter, + Input, +} from '../../../../packages/text-field'; + +import '../../../../packages/text-field/character-counter/index.scss'; + +const Container = ( + props: React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + > +) => ( +
+
{props.children}
+
+); + +const TextFieldCharacterCounterScreenshotTest = () => { + return ( + + + + + }> + + + + + }> + + + + + + } + > + + + + + + } + > + + + + + ); +}; +export default TextFieldCharacterCounterScreenshotTest; diff --git a/test/screenshot/text-field/refTest.tsx b/test/screenshot/text-field/refTest.tsx index 4a18096ee..79de12c1c 100644 --- a/test/screenshot/text-field/refTest.tsx +++ b/test/screenshot/text-field/refTest.tsx @@ -2,7 +2,7 @@ import React from 'react'; import TextField, {Input} from '../../../packages/text-field'; import Button from '../../../packages/button/index'; -class OutlinedTextField extends React.Component<{}, {value: string}> { +class TextFieldRefTest extends React.Component<{}, {value: string}> { inputEl: Input | null = null; state = {value: ''}; @@ -36,4 +36,4 @@ class OutlinedTextField extends React.Component<{}, {value: string}> { ); } } -export default OutlinedTextField; +export default TextFieldRefTest; diff --git a/test/unit/select/index.test.tsx b/test/unit/select/index.test.tsx index 68da92b82..c89668acf 100644 --- a/test/unit/select/index.test.tsx +++ b/test/unit/select/index.test.tsx @@ -5,7 +5,7 @@ import {mount, shallow} from 'enzyme'; import Select, {SelectHelperText} from '../../../packages/select/index'; import {MDCSelectFoundation} from '@material/select/foundation'; import {BaseSelect} from '../../../packages/select/BaseSelect'; -import {SelectIcon} from '../../../packages/select/index'; +import {SelectIcon} from '../../../packages/select'; import {coerceForTesting} from '../helpers/types'; import FloatingLabel from '../../../packages/floating-label/index'; import LineRipple from '../../../packages/line-ripple/index'; @@ -414,13 +414,29 @@ test('renders BaseSelect for select', () => { }); test('does not pass className to BaseSelect', () => { - const wrapper = shallow( + ); + assert.equal( + wrapper + .childAt(0) + .childAt(1) + .prop('className'), + '' + ); }); test('pass selectClassName to BaseSelect', () => { - const wrapper = shallow( + ); + assert.equal( + wrapper + .childAt(0) + .childAt(1) + .prop('className'), + 'select-class' + ); }); test('renders FloatingLabel after BaseSelect if props.label exists', () => { diff --git a/test/unit/text-field/character-counter/index.test.tsx b/test/unit/text-field/character-counter/index.test.tsx new file mode 100644 index 000000000..2bb872393 --- /dev/null +++ b/test/unit/text-field/character-counter/index.test.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import {shallow, mount} from 'enzyme'; +import {assert} from 'chai'; +import TextField, { + Input, + CharacterCounter, +} from '../../../../packages/text-field'; + +suite('Text Field Character Counter'); + +test('classNames adds classes', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.hasClass('test-class-name')); + assert.isTrue(wrapper.hasClass('mdc-text-field-character-counter')); +}); + +test('default props test', () => { + const wrapper = shallow(); + assert.equal('0 / 0', wrapper.text()); +}); + +test('maxLength test', () => { + const maxLength = 100; + const wrapper = mount( + }> + + + ); + assert.equal( + `0 / ${maxLength}`, + wrapper.find('.mdc-text-field-character-counter').text() + ); +}); + +test('count test', () => { + const value = 'test value'; + const wrapper = mount( + }> + + + ); + assert.equal( + `${value.length} / 0`, + wrapper.find('.mdc-text-field-character-counter').text() + ); +}); + +test('template test', () => { + const value = 'test value'; + const maxLength = 100; + const wrapper = mount( + } + > + + + ); + assert.equal( + `${value.length}|${maxLength}`, + wrapper.find('.mdc-text-field-character-counter').text() + ); +}); + +test('dynamic count test', () => { + class TestComponent extends React.Component { + state = {value: ''}; + render() { + return ( + }> + + + ); + } + } + const wrapper = mount(); + const counter = wrapper.find('.mdc-text-field-character-counter'); + assert.equal('0 / 250', counter.text()); + wrapper.instance().setState({value: 'Test Value'}); + assert.equal('10 / 250', counter.text()); +}); + +test('Character counter renders in front of input when tag is textarea', () => { + const wrapper = mount( + }> + + + ); + assert.equal( + wrapper + .childAt(0) + .childAt(0) + .getDOMNode(), + wrapper.find('.mdc-text-field-character-counter').getDOMNode() + ); +}); + +test(`MDC React doesn't need to implement this`, () => { + const wrapper = shallow(); + wrapper.instance().adapter.setContent(''); + wrapper.unmount(); +}); diff --git a/test/unit/text-field/helper-text/index.test.tsx b/test/unit/text-field/helper-text/index.test.tsx index a85e2872a..0a271cc2a 100644 --- a/test/unit/text-field/helper-text/index.test.tsx +++ b/test/unit/text-field/helper-text/index.test.tsx @@ -191,7 +191,7 @@ test('#componentWillUnmount destroys foundation', () => { td.verify(foundation.destroy()); }); -test('Useless test for code coverage', () => { +test(`MDC React doesn't need to implement this`, () => { const wrapper = shallow(Helper Text); wrapper.instance().adapter.setContent(''); }); diff --git a/test/unit/text-field/icon/index.test.tsx b/test/unit/text-field/icon/index.test.tsx index 51c7425ed..03df45b85 100644 --- a/test/unit/text-field/icon/index.test.tsx +++ b/test/unit/text-field/icon/index.test.tsx @@ -332,7 +332,7 @@ test('#componentWillUnmount destroys foundation', () => { td.verify(foundation.destroy()); }); -test('Useless test for code coverage', () => { +test(`MDC React doesn't need to implement this`, () => { const wrapper = shallow( diff --git a/test/unit/text-field/index.test.tsx b/test/unit/text-field/index.test.tsx index 235c79d7a..397424b3e 100644 --- a/test/unit/text-field/index.test.tsx +++ b/test/unit/text-field/index.test.tsx @@ -4,7 +4,6 @@ import {assert} from 'chai'; import {mount, shallow} from 'enzyme'; import TextField, {HelperText, Input} from '../../../packages/text-field'; import {coerceForTesting} from '../helpers/types'; -import {InputProps} from '../../../packages/text-field/Input'; // eslint-disable-line @typescript-eslint/no-unused-vars /* eslint-disable */ import FloatingLabel from '../../../packages/floating-label'; /* eslint-enable */ @@ -640,12 +639,7 @@ test('#inputProps.handleFocusChange updates state.isFocused', () => { ); - wrapper - .instance() - .inputProps( - coerceForTesting>>({}) - ) - .handleFocusChange(true); + wrapper.instance().inputProps.handleFocusChange(true); assert.isTrue(wrapper.state().isFocused); }); @@ -658,12 +652,7 @@ test('#inputProps.handleFocusChange calls foundation.activateFocus if isFocused const activateFocus = td.func(); const foundation = {activateFocus} as any; wrapper.setState({foundation}); - wrapper - .instance() - .inputProps( - coerceForTesting>>({}) - ) - .handleFocusChange(true); + wrapper.instance().inputProps.handleFocusChange(true); td.verify(activateFocus(), {times: 1}); }); @@ -676,12 +665,7 @@ test('#inputProps.handleFocusChange calls foundation.deactivateFocus if isFocuse const deactivateFocus = td.func(); const foundation = {deactivateFocus} as any; wrapper.setState({foundation}); - wrapper - .instance() - .inputProps( - coerceForTesting>>({}) - ) - .handleFocusChange(false); + wrapper.instance().inputProps.handleFocusChange(false); td.verify(deactivateFocus(), {times: 1}); }); @@ -691,12 +675,7 @@ test('#inputProps.setDisabled updates state.disabled', () => { ); - wrapper - .instance() - .inputProps( - coerceForTesting>>({}) - ) - .setDisabled(true); + wrapper.instance().inputProps.setDisabled(true); assert.isTrue(wrapper.state().disabled); }); @@ -706,12 +685,7 @@ test('#inputProps.setInputId updates state.disabled', () => { ); - wrapper - .instance() - .inputProps( - coerceForTesting>>({}) - ) - .setInputId('my-id'); + wrapper.instance().inputProps.setInputId('my-id'); assert.equal(wrapper.state().inputId, 'my-id'); }); @@ -762,7 +736,7 @@ test('#adapter.getNativeInput throws error when no inputComponent', () => { } }); -test('Useless test for code coverage', () => { +test(`MDC React doesn't need to implement this`, () => { const wrapper = mount>(