Skip to content

Commit

Permalink
Add Input component
Browse files Browse the repository at this point in the history
Input component which supports a label, error message, optional
bool and helper text. Should provide the basis for all forms
  • Loading branch information
Richard Palmer committed Oct 20, 2016
1 parent 93dfdd6 commit b5a830f
Show file tree
Hide file tree
Showing 6 changed files with 392 additions and 1 deletion.
103 changes: 103 additions & 0 deletions components/Forms/Input.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
.root {
position: relative;
}

.root * {
box-sizing: border-box;
}

.input {
border: 1px solid var(--color-greyLighter);
padding: var(--size-small);
font: var(--font-base);
letter-spacing: 0.2px;
color: var(--color-greyDarker);
transition: border 300ms var(--animation-sharp), color 300ms var(--animation-sharp);
width: 100%;
}

.inputFocused {
border-color: var(--color-greyDarker);
color: var(--color-greyDarker);
outline: none;
}

.inputErrored {
border-color: var(--color-danger);
color: var(--color-danger);
}

.label {
font: var(--font-small);
margin-bottom: var(--size-small);
border: 1px solid transparent;
display: block;
transition: color 300ms var(--animation-sharp);
}

.labelErrored {
color: var(--color-danger);
}

.optional {
font: var(--font-micro);
color: var(--color-grey);
margin-left: var(--size-small);
}

.helperContainer {
position: relative;
z-index: -1;
}

.description {
position: relative;
width: 100%;
padding-top: var(--size-small);
padding-bottom: var(--size-small);
background-color: transparent;
color: var(--color-grey);
font: var(--font-micro);
transform: translateY(-100%);
opacity: 0;
transition: transform 300ms var(--animation-sharp), opacity 500ms var(--animation-sharp);
}

.descriptionFocused {
transform: translateY(0);
opacity: 1;
}

.error {
position: absolute;
top: 0;
width: 100%;
padding: var(--size-small);
background-color: var(--color-danger);
color: var(--color-white);
font: var(--font-micro);
transition: transform 300ms var(--animation-sharp), opacity 300ms var(--animation-sharp);
}

/* Error transitions */
.enter,
.appear {
transform: translateY(-100%);
opacity: 0;
}

.enterActive,
.appearActive {
transform: translateY(0);
opacity: 1;
}

.leave {
transform: translateY(0);
opacity: 1;
}

.leaveActive {
transform: translateY(-100%);
opacity: 0;
}
172 changes: 172 additions & 0 deletions components/Forms/Input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import React, { Component, PropTypes } from 'react';
import { findDOMNode } from 'react-dom';
import CSSTransitionGroup from 'react-addons-css-transition-group';
import cx from 'classnames';
import uniqueId from 'lodash/fp/uniqueId';

import m from '../../globals/modifiers.css';

import css from './Input.css';

export default class Input extends Component {
static propTypes = {
label: PropTypes.node.isRequired,
optionalLabel: PropTypes.string,
onChange: PropTypes.func,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
optional: PropTypes.bool,
error: PropTypes.string,
placeholder: PropTypes.string,
name: PropTypes.string,
/**
* Subset of the HTML5 spec, as other types will most likely have their
* own, bespoke component
*/
type: PropTypes.oneOf([
'text',
'email',
'password',
'search',
'url',
]),
};

static defaultProps = {
onChange: () => {},
onFocus: () => {},
onBlur: () => {},
optional: false,
optionalLabel: 'optional',
type: 'text',
error: '',
value: '',
};

constructor(props) {
super(props);

this.id = uniqueId('forminput');
}

state = {
hasFocus: false,
};

focus = () => {
findDOMNode(this.input).focus();
this.handleFocus();
};

blur = () => {
findDOMNode(this.input).blur();
this.handleBlur();
};

handleFocus = () => {
const { onFocus } = this.props;
this.setState({
hasFocus: true,
}, onFocus);
};

handleBlur = () => {
const { onBlur } = this.props;
this.setState({
hasFocus: false,
}, onBlur);
};

handleChange = (e) => {
const { onChange } = this.props;
onChange(e, e.target.value);
};

render() {
const { hasFocus } = this.state;
const {
label,
value,
description,
optional,
optionalLabel,
error,
placeholder,
name,
type,
className,
...rest,
} = this.props;

const labelClasses = cx(
css.label,
hasFocus || value.length > 0 ? css.labelFocused : null,
error ? css.labelErrored : null,
);

const inputClasses = cx(
css.input,
hasFocus ? css.inputFocused : null,
error ? css.inputErrored : null,
);

const descriptionClasses = cx(
css.description,
hasFocus || value.length > 0 ? css.descriptionFocused : null,
);

const describedBy = `${this.id}-description`;

return (
<div className={ cx(css.root, className) }>
<label htmlFor={ this.id } className={ labelClasses }>
{ label } { optional && (
<span className={ css.optional }>
{ optionalLabel }
</span>
) }
</label>
<input
{ ...rest }
id={ this.id }
ref={ (c) => { this.input = c; } }
className={ inputClasses }
onFocus={ this.handleFocus }
onBlur={ this.handleBlur }
onChange={ this.handleChange }
value={ value }
aria-describedby={ describedBy }
placeholder={ placeholder }
required={ !optional }
name={ name }
type={ type }
/>
<div id={ describedBy } className={ css.helperContainer }>
{ description && (
<div
className={ descriptionClasses }
id={ describedBy }
>
{ description }
</div>
) }
<CSSTransitionGroup
transitionName={ css }
transitionEnterTimeout={ 500 }
transitionLeaveTimeout={ 300 }
transitionAppearTimeout={ 500 }
transitionAppear={ true }
>
{ error.length > 0 && (
<div
className={ css.error }
>
{ error }
</div>
) }
</CSSTransitionGroup>
</div>
</div>
);
}
}
75 changes: 75 additions & 0 deletions components/Forms/Input.story.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, { Component } from 'react';
import { storiesOf, action } from '@kadira/storybook';
import Input from './Input';

class StateManager extends Component {
state = {
value: '',
error: '',
};

handleChange = (e, value) => {
this.setState({
value,
error: value === 'error' ? 'error message' : '',
});
}

render() {
return React.cloneElement(this.props.children, {
onChange: this.handleChange,
value: this.state.value,
error: this.state.error,
});
}
}

storiesOf('Input', module)
.add('base', () => (
<Input
label="Name"
onChange={ action('Change') }
/>
)).add('optional', () => (
<Input
label="Name"
onChange={ action('Change') }
optional
/>
)).add('with description', () => (
<Input
label="Name"
description="What shall we call you?"
onChange={ action('Change') }
value=" "
/>
)).add('with custom optional label', () => (
<Input
label="Name"
optionalLabel="optionnel"
onChange={ action('Change') }
optional
/>
)).add('with error', () => (
<Input
label="Name"
error="Uh oh, something went wrong"
onChange={ action('Change') }
/>
)).add('with placeholder', () => (
<Input
label="Name"
placeholder="First and last name"
onChange={ action('Change') }
/>
)).add('with everything', () => (
<StateManager>
<Input
label="Name"
placeholder="First and last name"
optionalLabel="optionnel"
description="Type 'error' to generate an error message"
optional
/>
</StateManager>
))
37 changes: 37 additions & 0 deletions components/Forms/Input.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';
import ReactDOM, { findDOMNode } from 'react-dom';

import Input from './Input';

it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(
<Input
label=""
/>,
div
);
});

it('handles external focusing and blurring', () => {
const div = document.createElement('div');
let component;

ReactDOM.render(
<Input
ref={ (c) => { component = c; }}
label=""
/>,
div
);

const input = findDOMNode(component).querySelector('input');
component.focus();

expect(input === document.activeElement).toBe(true);
expect(component.state.hasFocus).toBe(true);

component.blur();
expect(input === document.activeElement).toBe(false);
expect(component.state.hasFocus).toBe(false);
});
3 changes: 3 additions & 0 deletions globals/animation-timing-functions.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
:root {
--animation-sharp: cubic-bezier(0,1,0.75,1);
}
Loading

0 comments on commit b5a830f

Please sign in to comment.