Skip to content

Commit

Permalink
feat(select): add icon (#825)
Browse files Browse the repository at this point in the history
  • Loading branch information
Matt Goo committed Apr 30, 2019
1 parent e22ac2a commit beedb23
Show file tree
Hide file tree
Showing 3 changed files with 222 additions and 0 deletions.
23 changes: 23 additions & 0 deletions packages/select/icon/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# React Select Icon

MDC React Select Icon is a React Component which uses MDC [MDC Select Icon](https://github.com/material-components/material-components-web/tree/master/packages/mdc-select/icon/)'s CSS and foundation JavaScript.

## Usage

```js
import {SelectIcon} from '@material/react-select/icon/index';

const MyComponent = () => {
return (
<SelectIcon className='material-icons'>
favorite
</SelectIcon>
);
}
```

## Props

Prop Name | Type | Description
--- | --- | ---
tag | string (keyof React.ReactHTML) | Sets the element tag. Defaults to i which becomes`<i />`.
120 changes: 120 additions & 0 deletions packages/select/icon/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// 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 {MDCSelectIconAdapter} from '@material/select/icon/adapter';
import {MDCSelectIconFoundation} from '@material/select/icon/foundation';

export interface SelectIconProps extends React.HTMLProps<HTMLElement> {
setIconFoundation?: (foundation?: MDCSelectIconFoundation) => void;
tag?: keyof React.ReactHTML;
}

interface ElementAttributes {
'tabindex'?: number;
role?: string;
};

interface SelectIconState extends ElementAttributes {};

export class SelectIcon extends React.Component<SelectIconProps, SelectIconState> {
foundation?: MDCSelectIconFoundation;

state: SelectIconState = {
'tabindex': undefined,
'role': undefined,
};

static defaultProps = {
tag: 'i',
};

componentDidMount() {
const {setIconFoundation} = this.props;
this.foundation = new MDCSelectIconFoundation(this.adapter);
this.foundation.init();
setIconFoundation && setIconFoundation(this.foundation);
}

componentWillUnmount() {
const {setIconFoundation} = this.props;
if (this.foundation) {
this.foundation.destroy();
setIconFoundation && setIconFoundation(undefined);
}
}

get adapter(): MDCSelectIconAdapter {
return {
getAttr: (attr: keyof ElementAttributes) => {
if (this.state[attr] !== undefined) {
return (this.state[attr] as ElementAttributes[keyof ElementAttributes])!.toString();
}
const reactAttr = attr === 'tabindex' ? 'tabIndex' : attr;
if (this.props[reactAttr] !== undefined) {
return (this.props[reactAttr])!.toString();
}
return null;
},
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()`
},
// the adapter methods below are effectively useless since React
// handles events and width differently
registerInteractionHandler: () => undefined,
deregisterInteractionHandler: () => undefined,
notifyIconAction: () => undefined,
};
}

render() {
const {
tag: Tag,
setIconFoundation, // eslint-disable-line no-unused-vars
children,
className,
...otherProps
} = this.props;
const {tabindex: tabIndex, role} = this.state;
return (
// @ts-ignore https://github.com/Microsoft/TypeScript/issues/28892
<Tag
className={classnames('mdc-select__icon', className)}
role={role}
tabIndex={tabIndex}
{...otherProps}
>
{children}
</Tag>
);
}
}
79 changes: 79 additions & 0 deletions test/unit/select/icon/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import * as React from 'react';
import * as td from 'testdouble';
import {assert} from 'chai';
import {shallow, mount} from 'enzyme';
import {SelectIcon} from '../../../../packages/select/icon/index';
import {MDCSelectIconFoundation} from '@material/select';

suite('Select Icon');

test('renders with mdc-select-helper-text class', () => {
const wrapper = shallow(<SelectIcon />);
assert.isTrue(wrapper.hasClass('mdc-select__icon'));
});

test('renders with a test class name', () => {
const wrapper = shallow(<SelectIcon className='test-class' />);
assert.isTrue(wrapper.hasClass('test-class'));
});

test('calls setIconFoundation with foundation', () => {
const setIconFoundation = td.func<(foundation?: MDCSelectIconFoundation) => void>();
shallow(<SelectIcon setIconFoundation={setIconFoundation} />);
// TODO: change Object to MDCSelectHelperTextFoundation in PR 823
td.verify(setIconFoundation(td.matchers.isA(Object)), {times: 1});
});

test('#componentWillUnmount destroys foundation', () => {
const wrapper = mount<SelectIcon>(<SelectIcon />);
const foundation = wrapper.instance().foundation!;
foundation.destroy = td.func<() => void>();
wrapper.unmount();
td.verify(foundation.destroy(), {times: 1});
});

test('#adapter.setAttr should update state', () => {
const wrapper = shallow<SelectIcon>(<SelectIcon />);
wrapper.instance().adapter.setAttr('role', 'menu');
assert.equal(wrapper.state().role, 'menu');
});

test('#adapter.removeAttr should update state', () => {
const wrapper = shallow<SelectIcon>(<SelectIcon />);
wrapper.setState({role: 'menu'});
wrapper.instance().adapter.removeAttr('role');
assert.equal(wrapper.state().role, null);
});

test('renders with tabindex from state.tabindex', () => {
const wrapper = mount<SelectIcon>(<SelectIcon />);
wrapper.setState({'tabindex': 1});
assert.equal(wrapper.getDOMNode().getAttribute('tabindex'), '1');
});

test('#adapter.getAttr returns the correct value of role', () => {
const wrapper = mount<SelectIcon>(<SelectIcon role='menu'/>);
assert.equal(wrapper.instance().adapter.getAttr('role'), 'menu');
});

test('#adapter.getAttr returns the correct value of tabindex', () => {
const wrapper = mount<SelectIcon>(<SelectIcon tabIndex={1}/>);
assert.equal(wrapper.instance().adapter.getAttr('tabindex'), '1');
});

test('#adapter.getAttr returns the correct value of role if it exists on state.role', () => {
const wrapper = mount<SelectIcon>(<SelectIcon />);
wrapper.setState({role: 'menu'});
assert.equal(wrapper.instance().adapter.getAttr('role'), 'menu');
});

test('renders with role from state.role', () => {
const wrapper = mount<SelectIcon>(<SelectIcon />);
wrapper.setState({'role': 'true'});
assert.equal(wrapper.getDOMNode().getAttribute('role'), 'true');
});

test('renders children', () => {
const wrapper = mount<SelectIcon>(<SelectIcon>MEOW</SelectIcon>);
assert.equal(wrapper.text(), 'MEOW');
});

0 comments on commit beedb23

Please sign in to comment.