a simple demo of redux-form's FieldArrays, validations, formatting, and parsing
based on designs by @sbelous
A Pen by not important on CodePen.
<div id="js-app"></div> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/6.20.0/polyfill.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.3.2/react.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.3.2/react-dom.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/3.5.2/redux.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/4.4.5/react-redux.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux-saga/0.14.2/redux-saga.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux-form/6.4.3/redux-form.js"></script> | |
<script src="https://unpkg.com/react-router/umd/ReactRouter.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-router-redux/4.0.7/ReactRouterRedux.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/classnames/2.2.5/dedupe.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script> <!-- this is just for the credit card validation library, not sure why it even needs jQuery --> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.payment/3.0.0/jquery.payment.js"></script> | |
<script src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/292951/redux-devtools.js"></script> | |
<script src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/292951/redux-devtools-dock-monitor.js"></script> | |
<script src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/292951/redux-devtools-log-monitor.js"></script> | |
<script src="//codepen.io/clindsey/pen/LbyNre.js"></script> |
a simple demo of redux-form's FieldArrays, validations, formatting, and parsing
based on designs by @sbelous
A Pen by not important on CodePen.
const { | |
call, | |
takeEvery | |
} = ReduxSaga.effects; | |
const { | |
routerMiddleware, | |
routerReducer, | |
syncHistoryWithStore | |
} = ReactRouterRedux; | |
const { | |
Field, | |
FieldArray, | |
Fields, | |
FormSection, | |
reduxForm | |
} = ReduxForm; | |
const { | |
IndexRoute, | |
Link, | |
Route, | |
Router, | |
hashHistory | |
} = ReactRouter; | |
const DockMonitor = ReduxDevToolsDockMonitor.default; | |
const LogMonitor = ReduxDevToolsLogMonitor.default; | |
setTimeout(() => { | |
const {Provider} = ReactRedux; | |
const store = configureStore(); | |
const history = syncHistoryWithStore(hashHistory, store); | |
ReactDOM.render(( | |
<Provider {...{store}}> | |
<Router {...{history}}> | |
<Route | |
component={AppContainer} | |
path="/" | |
> | |
<Route | |
component={CreateContainer} | |
path="create" | |
/> | |
<Route | |
component={EditContainer} | |
path="edit/:id" | |
/> | |
<IndexRoute component={ListContainer} /> | |
</Route> | |
</Router> | |
</Provider> | |
), document.getElementById('js-app')); | |
}, 0); | |
function configureStore (initialState) { | |
const reducers = Redux.combineReducers({ | |
form: ReduxForm.reducer, | |
routing: routerReducer, | |
records: recordsReducer | |
}); | |
const router = routerMiddleware(hashHistory); | |
const sagaMiddleware = ReduxSaga.default(); | |
const enhancer = Redux.compose(Redux.applyMiddleware(router, sagaMiddleware), DevTools.instrument()); | |
const store = Redux.createStore(reducers, initialState, enhancer); | |
sagaMiddleware.run(rootSaga); | |
return store; | |
}; | |
function *rootSaga () { | |
yield [ | |
createRedirect(), | |
updateRedirect() | |
]; | |
} | |
function *createRedirect () { | |
yield takeEvery('RECORD_ADD', listRedirectEffect) | |
} | |
function *updateRedirect () { | |
yield takeEvery('RECORD_UPDATE', listRedirectEffect) | |
} | |
function *listRedirectEffect () { | |
yield call(hashHistory.push, '/'); | |
} | |
function recordsReducer (state = {maxId: 0, records: {}}, action) { | |
if (action.type === 'RECORD_ADD') { | |
return { | |
...state, | |
maxId: state.maxId + 1, | |
records: { | |
...state.records, | |
[state.maxId]: action.record | |
} | |
}; | |
} | |
if (action.type === 'RECORD_UPDATE') { | |
return { | |
...state, | |
records: { | |
...state.records, | |
[action.id]: action.record | |
} | |
}; | |
} | |
return state; | |
} | |
const recordsActions = { | |
addRecord: record => ({type: 'RECORD_ADD', record}), | |
updateRecord: (id, record) => ({type: 'RECORD_UPDATE', id, record}) | |
}; | |
class AppComponent extends React.Component { | |
render () { | |
return ( | |
<div className="o-wrapper"> | |
{this.props.children} | |
<DevTools /> | |
</div> | |
); | |
} | |
} | |
const AppContainer = ReactRedux.connect(() => { | |
return { | |
}; | |
}, { | |
})(AppComponent); | |
class CreateComponent extends React.Component { | |
static propTypes = { | |
addRecord: React.PropTypes.func.isRequired | |
}; | |
constructor (props) { | |
super(props); | |
this.handleSubmit = this.onSubmit(); | |
} | |
onSubmit () { | |
return values => { | |
this.props.addRecord(values); | |
}; | |
} | |
render () { | |
const initialValues = {}; | |
return ( | |
<div className="o-layout"> | |
<div className="o-layout__item u-1/4@tablet"> | |
</div> | |
<div className="o-layout__item u-1/2@tablet"> | |
<h1 className="c-h1">{'New Person'}</h1> | |
<CreateForm | |
onSubmit={this.handleSubmit} | |
{...{initialValues}} | |
/> | |
<Link to="/">{'List'}</Link> | |
</div> | |
</div> | |
); | |
} | |
} | |
const CreateContainer = ReactRedux.connect(() => { | |
return { | |
}; | |
}, { | |
addRecord: recordsActions.addRecord | |
})(CreateComponent); | |
class EditComponent extends React.Component { | |
static propTypes = { | |
id: React.PropTypes.string.isRequired, | |
record: React.PropTypes.object.isRequired, | |
updateRecord: React.PropTypes.func.isRequired | |
}; | |
constructor (props) { | |
super(props); | |
this.handleSubmit = this.onSubmit(); | |
} | |
onSubmit () { | |
const {id} = this.props; | |
return values => { | |
this.props.updateRecord(id, values); | |
}; | |
} | |
render () { | |
const {record} = this.props; | |
const initialValues = record; | |
return ( | |
<div className="o-layout"> | |
<div className="o-layout__item u-1/4@tablet"> | |
</div> | |
<div className="o-layout__item u-1/2@tablet"> | |
<h1 className="c-h1">{'Update Person'}</h1> | |
<EditForm | |
onSubmit={this.handleSubmit} | |
{...{initialValues}} | |
/> | |
<Link to="/">{'List'}</Link> | |
</div> | |
</div> | |
); | |
} | |
} | |
const EditContainer = ReactRedux.connect((state, ownProps) => { | |
const { | |
params: { | |
id | |
} | |
} = ownProps; | |
const { | |
records: { | |
records | |
} | |
} = state; | |
return { | |
id, | |
record: records[id] | |
}; | |
}, { | |
updateRecord: recordsActions.updateRecord | |
})(EditComponent); | |
class ListComponent extends React.Component { | |
render () { | |
const {records} = this.props; | |
return ( | |
<div className="o-layout"> | |
<div className="o-layout__item u-1/4@tablet"> | |
</div> | |
<div className="o-layout__item u-1/2@tablet"> | |
<h1 className="c-h1">{'People List'}</h1> | |
<ul className="o-list-bare"> | |
{Object.keys(records).map((recordKey, key) => { | |
const record = records[recordKey]; | |
return ( | |
<li {...{key}}> | |
<table className="o-table"> | |
<tbody> | |
<tr> | |
<td colSpan="3"><Link to={`/edit/${recordKey}`}>{record.profile.firstLastName}</Link></td> | |
</tr> | |
<tr> | |
<td colSpan="3">{record.profile.phoneNumber}</td> | |
</tr> | |
</tbody> | |
</table> | |
</li> | |
); | |
})} | |
</ul> | |
<Link to="/create">{'Add a new person'}</Link> | |
</div> | |
</div> | |
); | |
} | |
} | |
const ListContainer = ReactRedux.connect(state => { | |
const { | |
records: { | |
records | |
} | |
} = state; | |
return { | |
records | |
}; | |
}, { | |
})(ListComponent); | |
class PersonForm extends React.Component { | |
render () { | |
const { | |
handleSubmit, | |
invalid | |
} = this.props; | |
return ( | |
<form onSubmit={handleSubmit}> | |
<FormSection name="profile"> | |
<ProfileFields /> | |
</FormSection> | |
<RelationsFields /> | |
<FormButton | |
className="c-form-button--primary c-form-button--block" | |
label="Submit" | |
type="submit" | |
/> | |
</form> | |
); | |
} | |
} | |
function validateProfile (values) { | |
const errors = {}; | |
if (!values || !values.firstLastName) { | |
errors.firstLastName = 'Required'; | |
} else if (values.firstLastName.split(' ').length < 2) { | |
errors.firstLastName = 'Both first and last name are required'; | |
} | |
if (!values || !values.phoneNumber) { | |
errors.phoneNumber = 'Required'; | |
} else if (!values.phoneNumber.match(/^\d{10}$/)) { | |
errors.phoneNumber = 'Invalid'; | |
} | |
if (!values || !values.type) { | |
errors.type = 'Required'; | |
} | |
return errors; | |
} | |
function validateRelations (values) { | |
if (!values) { | |
return []; | |
} | |
const errors = []; | |
values.forEach(((relation, index) => { | |
const memberErrors = validateProfile(relation); | |
if (Object.keys(memberErrors).length) { | |
errors[index] = memberErrors; | |
} | |
})); | |
return errors; | |
} | |
const validateSignUp = validationEngine({ | |
relations: validateRelations, | |
profile: validateProfile | |
}); | |
const CreateForm = reduxForm({ | |
form: 'createForm', | |
validate: validateSignUp | |
})(PersonForm); | |
const EditForm = reduxForm({ | |
form: 'editForm', | |
validate: validateSignUp | |
})(PersonForm); | |
class ProfileFields extends React.Component { | |
render () { | |
return ( | |
<div> | |
<Field | |
component={FormRadio} | |
label="Type" | |
name="type" | |
options={[ | |
{ | |
classes: 'u-1/3', | |
icon: 'Password', | |
label: 'Human', | |
value: 'human' | |
}, { | |
classes: 'u-1/3', | |
icon: 'Password', | |
label: 'Alien', | |
value: 'alien' | |
}, { | |
classes: 'u-1/3', | |
icon: 'Password', | |
label: 'Cyborg', | |
value: 'cyborg' | |
} | |
]} | |
/> | |
<FormField | |
fields={[ | |
{ | |
name: 'firstLastName', | |
placeholder: 'First and Last Name', | |
type: 'text' | |
} | |
]} | |
icon="Password" | |
/> | |
<FormField | |
fields={[ | |
{ | |
name: 'phoneNumber', | |
format: formatPhone, | |
parse: parsePhone, | |
placeholder: 'Phone Number', | |
type: 'tel' | |
} | |
]} | |
icon="Password" | |
/> | |
</div> | |
); | |
} | |
} | |
class RelationsFields extends React.Component { | |
renderRelations ({fields}) { | |
return ( | |
<ul className="o-list-bare"> | |
{fields.map((member, key) => ( | |
<li | |
className="u-margin-bottom" | |
{...{key}} | |
> | |
<div className="o-media"> | |
<div className="o-media__img"> | |
<FormButton | |
className="c-form-button--destructive" | |
label="X" | |
onClick={() => fields.remove(key)} | |
type="button" | |
/> | |
</div> | |
<div className="o-media__body"> | |
<h4 className="c-h4 u-margin-bottom-small">{`Relation ${key + 1}`}</h4> | |
<FormSection name={member}> | |
<ProfileFields /> | |
</FormSection> | |
</div> | |
</div> | |
</li> | |
))} | |
<li> | |
<FormButton | |
className="c-form-button--primary c-form-button--inverse c-form-button--block" | |
label="+ Add a relation" | |
onClick={() => fields.push({})} | |
type="button" | |
/> | |
</li> | |
</ul> | |
); | |
} | |
render () { | |
return ( | |
<FieldArray | |
name="relations" | |
component={this.renderRelations} | |
/> | |
); | |
} | |
} | |
class FormField extends React.Component { // refactor, missing propTypes | |
static propTypes = { | |
icon: React.PropTypes.string, | |
fields: React.PropTypes.arrayOf(React.PropTypes.shape({ | |
className: React.PropTypes.string, | |
format: React.PropTypes.func, | |
name: React.PropTypes.string.isRequired, | |
normalize: React.PropTypes.func, | |
parse: React.PropTypes.func, | |
placeholder: React.PropTypes.string.isRequired, // refactor, is this really required? Is this used for making checkboxes/radios? | |
type: React.PropTypes.string.isRequired | |
})), | |
hint: React.PropTypes.string | |
}; | |
renderFields (props) { | |
const { | |
fields, | |
hint, | |
icon | |
} = props; | |
const errors = fields.map(({name}) => { | |
const { | |
meta: { | |
error, | |
touched | |
} // refactor, ¯\_(ツ)_/¯ @ next line | |
} = eval(`props.${name}`); // eslint-disable-line no-eval | |
return touched && error; // refactor, `touched` might not be behaving as expected? | |
}).filter(i => i); | |
const error = errors[0] || props.error; | |
const message = error || hint; | |
const className = classNames({ | |
'c-form-field': true, | |
'c-form-field--error': !!error | |
}); | |
const Icon = SVGIcon[icon]; | |
return ( | |
<div {...{className}}> | |
<div className="o-media"> | |
<div className="o-media__img c-form-field__img"> | |
{icon && (<Icon />)} | |
</div> | |
<div className="o-media__body"> | |
<div className="c-form-field__control"> | |
{fields.map((field, key) => { | |
const { | |
format, | |
name, | |
normalize, | |
parse, | |
placeholder, | |
type | |
} = field; // refactor, what else is in `props.${name}`? | |
// refactor, I had to comment out the line below to make this work with FormSection | |
// const {input} = eval(`props.${name}`); // eslint-disable-line no-eval | |
const inputClassName = classNames({ | |
'c-form-field__input': true, | |
[field.className]: field.className && true // refactor, `&& true`? I don't get it | |
}); | |
return ( | |
<Field | |
className={inputClassName} | |
component="input" | |
{...{key, placeholder, type, format, normalize, parse, name}} | |
/> | |
); | |
})} | |
</div> | |
<div className="c-form-field__hint">{message}</div> | |
</div> | |
</div> | |
</div> | |
); | |
} | |
render () { | |
const { | |
fields, | |
hint, | |
icon | |
} = this.props; | |
return ( | |
<Fields | |
component={this.renderFields} | |
names={fields.map(({name}) => name)} | |
{...{fields, hint, icon}} | |
/> | |
); | |
} | |
} | |
class FormRadio extends React.Component { | |
handleChange (value) { | |
return () => { | |
this.props.input.onChange(value); | |
} | |
} | |
render () { | |
const { | |
hint, | |
input: { | |
value, | |
name | |
}, | |
meta: { | |
error, | |
touched | |
}, | |
options | |
} = this.props; | |
const message = (touched && error) || hint; | |
const className = classNames({ | |
'c-form-radio': true, | |
'c-form-radio--error': (touched && !!error) | |
}); | |
return ( | |
<div {...{className}}> | |
<div className="o-layout"> | |
{options.map((field, key) => { | |
const Icon = SVGIcon[field.icon]; | |
const fieldClasses = field.classes || ''; | |
return ( | |
<div | |
className={`c-form-radio__item o-layout__item ${fieldClasses}`} | |
{...{key}} | |
> | |
<input | |
checked={value === field.value} | |
className="c-form-radio__field u-hidden-visually" | |
id={`${name}-${key}`} | |
onChange={this.handleChange(field.value)} | |
type="radio" | |
value={field.value} | |
{...{name}} | |
/> | |
<label | |
className="c-form-radio__label" | |
htmlFor={`${name}-${key}`} | |
> | |
{field.icon && Icon && (<Icon active={value === field.value} />)} | |
{field.label} | |
</label> | |
</div> | |
); | |
})} | |
<div className="o-layout__item u-1/1 c-form-radio__message"> | |
<div className="c-form-radio__hint">{message}</div> | |
</div> | |
</div> | |
</div> | |
); | |
} | |
} | |
class FormButton extends React.Component { | |
static propTypes = { | |
className: React.PropTypes.string, | |
disabled: React.PropTypes.bool, | |
onClick: React.PropTypes.func, | |
type: React.PropTypes.string.isRequired | |
}; | |
render () { | |
const { | |
disabled, | |
label, | |
onClick, | |
type | |
} = this.props; | |
const className = classNames({ | |
'c-form-button': true, | |
[this.props.className]: this.props.className && true | |
}); | |
return ( | |
<button {...{ | |
className, | |
disabled, | |
onClick, | |
type | |
}} | |
>{label}</button> | |
); | |
} | |
} | |
const DevTools = ReduxDevTools.createDevTools( | |
<DockMonitor | |
changePositionKey="ctrl-q" | |
defaultIsVisible={false} | |
toggleVisibilityKey="ctrl-h" | |
> | |
<LogMonitor theme="tomorrow" /> | |
</DockMonitor> | |
); | |
function formatPhone (input) { // refactor, deleting a phone number doesn't work so well | |
if (!input) { | |
return ''; | |
} // logic taken from https://github.com/omarshammas/jquery.formance/ | |
const results = input.replace(/\D/g, '').match(/^(\d{0,3})?(\d{0,3})?(\d{0,4})?$/); | |
if (!results) { | |
return input; | |
} | |
const [_phoneNumber, areaCode, first3, last4] = results.filter(i => i).map(i => `${i}`); // eslint-disable-line no-unused-vars | |
let output = ''; | |
if (areaCode) { | |
output += `(${areaCode}`; | |
} | |
if (areaCode && areaCode.length === 3) { | |
output += ') '; | |
} | |
if (first3) { | |
output += first3; | |
} | |
if (first3 && first3.length === 3) { | |
output += ' - '; | |
} | |
if (last4) { | |
output += last4; | |
} | |
return output; | |
} | |
function parsePhone (input) { | |
if (/\d/.test(input) === false) { | |
return input; | |
} | |
return input.match(/\d+/g).join(''); | |
} | |
function formatExpirationDate (input) { // refactor, doesn't work very well when deleting | |
if (!input) { | |
return ''; | |
} // logic taken from https://github.com/omarshammas/jquery.formance/ | |
let output = input; | |
if (/^\d$/.test(input) && input !== '0' && input !== '1') { | |
output = `0${output} / `; | |
} else if (/^\d\d$/.test(input)) { | |
output = `${output} / `; | |
} | |
return output; | |
} | |
function validationEngine (validators) { | |
return values => { | |
return Object.keys(validators).map(name => ({ | |
name, // 5625463739 | |
error: validators[name](values[name]) | |
})).reduce((p, {name, error}) => ( | |
Object.keys(name).length ? {...p, [name]: error} : p | |
), {}); | |
}; | |
} |
// begin colors | |
$brand-primary: #60c1da; | |
$brand-secondary: #0698bd; | |
$brand-red: #e90c27; | |
$brand-green: #4ab043; | |
$brand-yellow: #ffd305; | |
$brand-black: #4a4a4a; | |
$brand-gray: #9b9b9b; | |
$brand-lighter-gray: #b0b0b0; | |
$brand-disabled-gray: #d0d0d0; | |
$brand-border-gray: #e0e0e0; | |
$brand-primary-muted: #a0dae9; | |
$brand-success: #5cb85c; | |
$brand-info: #5bc0de; // stylelint-disable-line no-indistinguishable-colors | |
$brand-warning: #f0ad4e; | |
$brand-danger: #d9534f; | |
$brand-inverse: $gray-dark; | |
// end colors | |
html { | |
color: $text-base-color; | |
font-family: 'Open Sans', sans-serif; | |
font-size: 14px; | |
@include mq($from: tablet) { | |
font-size: 16px; | |
} | |
} | |
body { | |
margin-top: $inuit-global-spacing-unit; | |
margin-bottom: $inuit-global-spacing-unit; | |
} | |
// begin FormField | |
.c-form-field { | |
margin-bottom: $inuit-global-spacing-unit; | |
} | |
.c-form-field--error { | |
.c-form-field__control { | |
border-bottom: solid 1px $border-color; | |
} | |
.c-form-field__hint { | |
color: $brand-danger; | |
} | |
} | |
.c-form-field__hint { | |
font-size: 0.75rem; | |
color: $text-muted-color; | |
font-weight: 300; | |
min-height: $inuit-global-spacing-unit; | |
} | |
.c-form-field__img { | |
height: 1.375rem; // 22px | |
margin-right: 0; | |
text-align: center; | |
width: 2.125rem; // 34px | |
> img { | |
margin: 0 auto; | |
height: 1.375rem; // 22px | |
} | |
} | |
.c-form-field__control { | |
border-bottom: solid 1px $border-color; | |
} | |
.c-form-field__input { | |
-webkit-backface-visibility: hidden; | |
border: none; | |
color: $text-base-color; | |
font-family: inherit; | |
font-weight: 300; | |
padding: 0; | |
width: 100%; | |
} | |
// end FormField | |
// begin FormButton | |
.c-form-button { | |
border: solid 1px $white; | |
border-radius: $control-radius; | |
color: $white; | |
cursor: pointer; | |
display: inline-block; | |
font: inherit; | |
font-weight: 600; | |
margin: $inuit-global-spacing-unit-small 0; | |
padding: round($inuit-global-spacing-unit-small * 0.5) $inuit-global-spacing-unit; | |
text-align: center; | |
vertical-align: middle; | |
} | |
.c-form-button--primary { | |
background-color: $brand-primary; | |
border-color: $brand-primary; | |
&.c-form-button--disabled, | |
&:disabled { | |
background-color: $brand-primary-muted; | |
border-color: $brand-primary-muted; | |
} | |
&.c-form-button--inverse { | |
background-color: $white; | |
color: $brand-primary; | |
border-color: $brand-primary; | |
} | |
} | |
.c-form-button--block { | |
width: 100%; | |
} | |
.c-form-button--destructive { | |
background-color: $brand-danger; | |
border-color: $brand-danger; | |
} | |
// end FormButton | |
// begin FormRadio | |
.c-form-radio__label { | |
border-radius: $control-radius; | |
border: solid 1px $border-color; | |
color: $text-muted-color; | |
cursor: pointer; | |
display: block; | |
margin: 0 auto; | |
overflow-x: hidden; | |
padding: $inuit-global-spacing-unit-tiny; | |
text-overflow: ellipsis; | |
white-space: nowrap; | |
width: 100%; | |
} | |
.c-form-radio__item { | |
margin-bottom: $inuit-global-spacing-unit-tiny; | |
text-align: center; | |
} | |
.c-form-radio__field:checked + label { | |
border-color: $brand-primary; | |
color: $brand-primary; | |
} | |
.c-form-radio--error { | |
.c-form-radio__hint { | |
color: $brand-danger; | |
} | |
} | |
.c-form-radio__hint { | |
color: $text-muted-color; | |
font-size: 0.75rem; | |
min-height: 2rem; | |
} | |
// end FormRadio | |
// begin Typography | |
.c-h1 { | |
font-size: 2rem; | |
} | |
.c-h2 { | |
font-size: 1.5rem; | |
font-weight: 600; | |
} | |
.c-h3 { | |
font-size: 1.25rem; | |
} | |
.c-h4 { | |
font-size: 0.875rem; | |
font-weight: 600; | |
text-transform: uppercase; | |
} | |
.c-h5 { | |
font-size: 0.9375rem; | |
font-weight: 600; | |
} | |
.c-text-strong { | |
color: $brand-secondary; | |
} | |
.c-text-strong--inverse { | |
color: $white; | |
} | |
.c-text-strong--stronger { | |
font-weight: 600; | |
color: $text-base-color; | |
} | |
.c-text-strong--super-strong { | |
font-weight: 600; | |
font-size: 1.25rem; | |
} | |
.c-text-small { | |
font-size: 0.875rem; | |
} | |
.c-text-small--muted { | |
color: $brand-gray; | |
} | |
.c-text-small--strong { | |
color: $brand-secondary; | |
} | |
.c-text-small--stronger { | |
font-weight: 600; | |
} | |
// end Typography |
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,600" rel="stylesheet" /> | |
<link href="http://codepen.io/clindsey/pen/XNKwXY" rel="stylesheet" /> | |
<link href="http://codepen.io/clindsey/pen/jVMoKL" rel="stylesheet" /> | |
<link href="http://codepen.io/clindsey/pen/LbyNre" rel="stylesheet" /> |