diff --git a/.storybook/storybook.scss b/.storybook/storybook.scss index a996d1c..6d0a4f6 100644 --- a/.storybook/storybook.scss +++ b/.storybook/storybook.scss @@ -2,10 +2,17 @@ html, body { - box-sizing: border-box; font-family: 'Roboto', sans-serif; } +html { + box-sizing: border-box; +} + +*, *:before, *:after { + box-sizing: inherit; +} + #root { padding: 20px; } diff --git a/README.md b/README.md index 8a43c1b..14a7412 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # React-MDL SelectField Extra components for [React Material Design Lite](https://github.com/tleunen/react-mdl) +Uses [react-portal](https://github.com/tajo/react-portal) to render to `body`, to prevent `overflow` issues. +Uses [Tether](tether.io) for dropdown positioning. ## Installation @@ -10,7 +12,7 @@ npm install --save react-mdl-extra ## Examples -https://hribb.github.io/react-mdl-extra/ +https://hribb.github.io/react-mdl-extra/?down=0 ``` git clone https://github.com/HriBB/react-mdl-extra @@ -27,17 +29,13 @@ open http://localhost:9002/ ``` import { SelectField, Option } from 'react-mdl-extra'; -render() { - return() ( - - - - - - - - ); -} + + + + + + + ``` ### MultiSelectField @@ -45,17 +43,13 @@ render() { ``` import { MultiSelectField, Option } from 'react-mdl-extra'; -render() { - return() ( - - - - - - - - ); -} + + + + + + + ``` ### Menu @@ -63,25 +57,33 @@ render() { ``` import { Menu, MenuItem } from 'react-mdl-extra'; -render() { - return() ( - Open menu}> - One - Two - Three - - ) -} +Open menu}> + One + Two + Three + ``` +## Positioning Dropdown + +See [tether](http://tether.io/). Uses shorthand declaration. First two letter are the `attachment` property, followed by a space and second two letters, which are the `targetAttachment` property. + +Examples: + +**align="tl bl"** + +Attach **t**op **l**eft edge of the dropdown to the **b**ottom **l**eft edge of the target. + +**align="br tr"** + +Attach **b**ottom **r**ight edge of the dropdown to the **t**op **r**ight edge of the target. + ## TODO -- [ ] Remove sass dependency from the project -- [x] Use Tether for dropdown positioning -- [ ] Improve position declaration +- [ ] Remove sass dependency +- [x] Improve position declaration - [x] Create `MultiSelectField` - [ ] Create `AutoCompleteField` - [ ] Create `DatePickerField` -- [ ] Fix focus handling -- [ ] Add key bindings +- [ ] Key and focus handling - [ ] Add tests diff --git a/src/Dropdown/Dropdown.js b/src/Dropdown/Dropdown.js index 3cb203e..a815c94 100644 --- a/src/Dropdown/Dropdown.js +++ b/src/Dropdown/Dropdown.js @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from 'react' +import { setValueForStyles as applyStyles } from 'react/lib/CSSPropertyOperations' import { findDOMNode } from 'react-dom' import classnames from 'classnames' import Portal from 'react-portal' @@ -6,27 +7,52 @@ import Tether from 'tether' import './Dropdown.scss' +/** + * Position shorthand refs + * + * @type {Object} + */ +const POS = { + t: 'top', + b: 'bottom', + l: 'left', + r: 'right', + m: 'middle', + c: 'center', +} + +/** + * Generic dropdown component + */ export default class Dropdown extends Component { static propTypes = { - align: PropTypes.string, + align: function(props, propName, componentName) { + if (!/[a-z][a-z]\ [a-z][a-z]/.test(props[propName])) { + return new Error( + `Invalid prop ${propName} (${props[propName]}) supplied to ${componentName}. Validation failed.` + ) + } + }, + animate: PropTypes.bool, children: PropTypes.any.isRequired, className: PropTypes.string, closeOnEsc: PropTypes.bool, closeOnOutsideClick: PropTypes.bool, + fade: PropTypes.bool, offset: PropTypes.string, target: PropTypes.element.isRequired, targetNode: PropTypes.any, useTargetWidth: PropTypes.bool, - talign: PropTypes.string, } static defaultProps = { - align: 'top left', + align: 'tl tl', + animate: false, closeOnEsc: true, closeOnOutsideClick: true, + fade: true, offset: '0 0', - talign: 'top left', } constructor(props) { @@ -37,155 +63,89 @@ export default class Dropdown extends Component { } open(portalNode) { - const { align, offset, useTargetWidth, talign } = this.props + const { align, animate, fade, offset, useTargetWidth } = this.props // get target node - let targetNode = this.props.targetNode || findDOMNode(this) + const targetNode = this.props.targetNode || findDOMNode(this) + const targetRect = targetNode.getBoundingClientRect() - // set portal max height - portalNode.firstChild.style.maxHeight = `${innerHeight}px` + // get position + const [ay,ax,ty,tx] = align.split('').map(a => a && POS[a]).filter(a => a) + const attachment = `${ay} ${ax}` + const targetAttachment = `${ty} ${tx}` // use target width if (useTargetWidth) { - portalNode.style.width = `${targetNode.getBoundingClientRect().width}px` + applyStyles(portalNode, { + width: `${targetRect.width}px`, + }, this._reactInternalInstance) } + // constrain portal height + applyStyles(portalNode.firstChild, { + maxHeight: `${400}px`, + minHeight: `${targetRect.height}px`, + }, this._reactInternalInstance) + + // tether options const options = { element: portalNode, target: targetNode, - attachment: align, - targetAttachment: talign, + attachment, + targetAttachment, offset, constraints: [{ to: 'window', - attachment: 'together', - pin: true - }] + attachment: 'together together', + pin: true, + }], } + // run tether if (!this.tether) { this.tether = new Tether(options) } else { - this.tether.enable() this.tether.setOptions(options) } - /* - - let targetNode = this.props.targetNode || findDOMNode(this) - let target = targetNode.getBoundingClientRect() - let portal = portalNode.getBoundingClientRect() + // fade in + if (fade) { + applyStyles(portalNode, { + opacity: `1`, + transition: `opacity .3s cubic-bezier(0.25,0.8,0.25,1)` + }, this._reactInternalInstance) + } - // lets calculate menu position and transform origin - let menuX, menuY, originX, originY, constrain + // run animation + if (animate) { + const transform = portalNode.style.transform - // set max height to original portal height - let maxHeight = portal.height + const prect = portalNode.getBoundingClientRect() + const trect = targetNode.getBoundingClientRect() - // calculate space above/below target - let above = target.top - let below = innerHeight - target.bottom + const fixLeft = ax === 'left' && tx === 'left' && prect.left + 1 < trect.left + const fixRight = ax === 'right' && tx === 'right' && trect.right + 1 < prect.right - // NOTE: the code below is fugly, but it works - // if you want, you can make it better ;) + const originX = prect.bottom + 1 <= trect.top ? 'bottom': 'top' + const originY = fixLeft && 'right' || fixRight && 'left' || ax - // - // vertical align - // - if (valign === 'top') { - // show at the top - menuY = target.top - portal.height + scrollY + offsetY + 5 - originY = 'bottom' - // out of bounds, move to bottom if there is more space - if ((menuY - scrollY) < 0 && below > above) { - menuY = target.bottom + scrollY + offsetY - originY = 'top' + const from = { + transform: `${transform} scale(0.01, 0.01)`, + transformOrigin: `${originX} ${originY}` } - } else { - // show at the bottom - menuY = target.bottom + scrollY + offsetY - originY = 'top' - // out of bounds, move to top if there is more space - if ((menuY + portal.height) > (innerHeight + scrollY) && above > below) { - menuY = target.top - portal.height + scrollY + offsetY + 5 - originY = 'bottom' - } - } - // - // vertical constraint - // - if (originY === 'top') { - // originY is top, show menu at the bottom - if (portal.height > (innerHeight - target.bottom)) { - maxHeight = innerHeight - target.bottom - padding - offsetY - constrain = true - } - } else { - // originY is bottom, show menu at the top - if (portal.height > target.top) { - maxHeight = target.top - padding + offsetY - menuY = scrollY + padding // adjust menu position - constrain = true + const to = { + opacity: `1`, + transform: `${transform} scale(1, 1)`, + transition: `transform .2s cubic-bezier(0.25,0.8,0.25,1)` } - } - // - // apply constraint and recalculate portal and target rect - // - if (constrain) { - portalNode.firstChild.style.maxHeight = `${maxHeight}px` - portal = portalNode.getBoundingClientRect() - target = targetNode.getBoundingClientRect() - } + applyStyles(portalNode, from, this._reactInternalInstance) - // - // horizontal align - // - if (align === 'left') { - // align left - menuX = target.left + scrollX + offsetX - originX = 'left' - // out of bounds, move to right - if ((menuX + portal.width) > (innerWidth + scrollX)) { - menuX = target.right + scrollX - portal.width + offsetX - originX = 'right' - } - } else { - // align right - menuX = target.right + scrollX - portal.width + offsetX - originX = 'right' - // out of bounds, move to left - if (menuX < 0) { - menuX = target.left + scrollX + offsetX - originX = 'left' - } - } - - // - // set initial style - // - if (useTargetWidth) { - portalNode.style.width = `${target.width}px` + setTimeout(() => { + applyStyles(portalNode, to, this._reactInternalInstance) + }, 20) } - portalNode.style.height = `0px` - portalNode.style.left = `${menuX}px` - portalNode.style.top = `${menuY}px` - portalNode.style.transform = `scale3d(0.01, 0.01, 1)` - portalNode.style.transformOrigin = `${originX} ${originY}` - - // - // set final style with a slight delay so that it animates - // TODO: figure out how to wait for initial styles - // - setTimeout(() => { - portalNode.style.opacity = `1` - portalNode.style.height = `${maxHeight}px` - portalNode.style.transform = `scale3d(1, 1, 1)` - portalNode.style.transition = `transform 0.2s ease` - }, 20) - - */ } close() { @@ -194,15 +154,21 @@ export default class Dropdown extends Component { } } + componentWillUnmount() { + if (this.tether) { + this.tether.destroy() + } + } + render() { const { children, className, closeOnEsc, closeOnOutsideClick, target } = this.props const portalClass = classnames('mdl-dropdown', className) return ( diff --git a/src/Dropdown/Dropdown.scss b/src/Dropdown/Dropdown.scss index e32c4c0..d104477 100644 --- a/src/Dropdown/Dropdown.scss +++ b/src/Dropdown/Dropdown.scss @@ -3,4 +3,5 @@ position: absolute; z-index: 9999; display: inline-block; + opacity: 0; } diff --git a/src/Menu/Menu.js b/src/Menu/Menu.js index 646fc5b..f76e417 100644 --- a/src/Menu/Menu.js +++ b/src/Menu/Menu.js @@ -10,12 +10,16 @@ export default class Menu extends Component { static propTypes = { align: PropTypes.string, + animate: PropTypes.bool, children: PropTypes.any.isRequired, className: PropTypes.string, offset: PropTypes.string, target: PropTypes.element.isRequired, useTargetWidth: PropTypes.bool, - talign: PropTypes.string, + } + + static defaultProps = { + //align: 'tl bl', } render() { diff --git a/src/Menu/MenuItem.js b/src/Menu/MenuItem.js index ef300fc..30d3e4c 100644 --- a/src/Menu/MenuItem.js +++ b/src/Menu/MenuItem.js @@ -10,7 +10,7 @@ export default class MenuItem extends Component { static propTypes = { children: PropTypes.any.isRequired, - className: PropTypes.string.isRequired, + className: PropTypes.string, closeMenu: PropTypes.func, onClick: PropTypes.func, tabIndex: PropTypes.number, diff --git a/src/Menu/MenuItem.scss b/src/Menu/MenuItem.scss index 620afab..c153579 100644 --- a/src/Menu/MenuItem.scss +++ b/src/Menu/MenuItem.scss @@ -3,6 +3,9 @@ height: 48px; line-height: 48px; padding: 0 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; &:first-child { margin-top: 5px; diff --git a/src/MultiSelectField/MultiSelectField.js b/src/MultiSelectField/MultiSelectField.js index cf5730c..368d277 100644 --- a/src/MultiSelectField/MultiSelectField.js +++ b/src/MultiSelectField/MultiSelectField.js @@ -6,11 +6,13 @@ import './MultiSelectField.scss' import Dropdown from '../Dropdown' import OptionList from '../SelectField/OptionList' +import Option from '../SelectField/Option' export default class MultiSelectField extends Component { static propTypes = { align: PropTypes.string, + animate: PropTypes.bool, children: PropTypes.arrayOf(PropTypes.element).isRequired, className: PropTypes.string, error: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), @@ -21,7 +23,6 @@ export default class MultiSelectField extends Component { onBlur: PropTypes.func, onChange: PropTypes.func, readOnly: PropTypes.bool, - talign: PropTypes.string, value: PropTypes.array, } @@ -67,8 +68,8 @@ export default class MultiSelectField extends Component { render() { const { - align, className, error, label, - offset, readOnly, talign, value, + align, animate, className, error, label, + offset, readOnly, value, } = this.props const { focused } = this.state @@ -79,6 +80,8 @@ export default class MultiSelectField extends Component { const children = allChildren .filter(c => value && value.indexOf(c.props.value) === -1) + const options = children.length ? children : + const chips = allChildren .filter(c => value && value.indexOf(c.props.value) > -1) .map(c => ({ value: c.props.value, text: c.props.children })) @@ -113,8 +116,8 @@ export default class MultiSelectField extends Component { const dropdownProps = { align, + animate, offset, - talign, target: input, targetNode: this.container, useTargetWidth: true, @@ -131,7 +134,7 @@ export default class MultiSelectField extends Component { - {children} + {options} diff --git a/src/MultiSelectField/MultiSelectField.scss b/src/MultiSelectField/MultiSelectField.scss index 08e4ee2..9499098 100644 --- a/src/MultiSelectField/MultiSelectField.scss +++ b/src/MultiSelectField/MultiSelectField.scss @@ -42,6 +42,7 @@ .mdl-multiselect__input { display: block; width: 100%; + height: 27px; border: none; padding: 4px 0; background: none; @@ -68,7 +69,7 @@ .mdl-multiselect__arrow { position: absolute; - bottom: 12px; + bottom: 10px; right: 10px; width: 0; height: 0; diff --git a/src/SelectField/Option.scss b/src/SelectField/Option.scss index aeac19a..7de9444 100644 --- a/src/SelectField/Option.scss +++ b/src/SelectField/Option.scss @@ -3,6 +3,9 @@ height: 32px; line-height: 32px; padding: 0 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; &.mdl-option--selected { color: #ff4081; diff --git a/src/SelectField/OptionList.js b/src/SelectField/OptionList.js index 2c90193..db41d5f 100644 --- a/src/SelectField/OptionList.js +++ b/src/SelectField/OptionList.js @@ -26,7 +26,10 @@ export default class OptionList extends Component { const [ selected ] = this.list.getElementsByClassName('mdl-option--selected') if (!selected) return - setTimeout(() => selected.scrollIntoView()) + setTimeout(() => { + selected.scrollIntoView() + this.list.scrollTop -= (this.list.getBoundingClientRect().height / 2) - 10; + }) } render() { diff --git a/src/SelectField/SelectField.js b/src/SelectField/SelectField.js index 875cdec..84c1db1 100644 --- a/src/SelectField/SelectField.js +++ b/src/SelectField/SelectField.js @@ -11,6 +11,7 @@ export default class SelectField extends Component { static propTypes = { align: PropTypes.string, + animate: PropTypes.bool, children: PropTypes.arrayOf(PropTypes.element).isRequired, className: PropTypes.string, error: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), @@ -22,7 +23,6 @@ export default class SelectField extends Component { onChange: PropTypes.func, readOnly: PropTypes.bool, showMenuBelow: PropTypes.bool, - talign: PropTypes.string, value: PropTypes.any, } @@ -63,8 +63,8 @@ export default class SelectField extends Component { render() { const { - align, className, error, floatingLabel, label, - offset, readOnly, talign, value, + align, animate, className, error, floatingLabel, label, + offset, readOnly, value, } = this.props const { focused } = this.state @@ -98,8 +98,8 @@ export default class SelectField extends Component { const dropdownProps = { align, + animate, offset, - talign, target: , useTargetWidth: true, } diff --git a/stories/Menu.story.js b/stories/Menu.story.js index 72671ed..5d68834 100644 --- a/stories/Menu.story.js +++ b/stories/Menu.story.js @@ -8,7 +8,7 @@ import { Menu, MenuItem } from '../src' storiesOf('Menu', module) .add('default', () => ( - Open menu} align={'top left'} talign={'bottom left'}> + Open menu}> console.log('select one')}>One console.log('select two')}>Two console.log('select three')}>Three @@ -21,82 +21,103 @@ storiesOf('Menu', module) display: 'flex', justifyContent: 'center', alignItems: 'center', }, wrap: { - textAlign: 'center', + //textAlign: 'center', }, button: { margin: '10px', + textTransform: 'none', }, } return (
- a=br t=tr} align={'bottom right'} talign={'top right'}> +

align

+

+ Uses Tether for positioning. +

+

+ br-tr attachment bottom right targetAttachment top right
+ bl-tl attachment bottom left targetAttachment top left +

+ br tr} align={'br tr'}> One Two Three - a=bl t=tl} align={'bottom left'} talign={'top left'}> + bl tl} align={'bl tl'}> One Two Three
- a=tr t=br} align={'top right'} talign={'bottom right'}> + tr br} align={'tr br'}> One Two Three - a=tl t=bl} align={'top left'} talign={'bottom left'}> + tl bl} align={'tl bl'}> One Two Three +

+
+ tr-br attachment top right targetAttachment bottom right
+ tl-bl attachment top left targetAttachment bottom left
+

) }) .add('constrain to viewport', () => { const styles = { - bottomLeft: { position: 'absolute', bottom: '20px', left: '20px' }, - bottomRight: { position: 'absolute', bottom: '20px', right: '20px' }, - topLeft: { position: 'absolute', top: '20px', left: '20px' }, - topRight: { position: 'absolute', top: '20px', right: '20px' }, + bottomLeft: { position: 'absolute', bottom: '10px', left: '10px' }, + bottomRight: { position: 'absolute', bottom: '10px', right: '10px' }, + topLeft: { position: 'absolute', top: '10px', left: '10px' }, + topRight: { position: 'absolute', top: '10px', right: '10px' }, options: { padding: '0 10px' }, center: { position: 'absolute', top: '50%', left: '50%', width: '400px', marginLeft: '-200px', marginTop: '-20px', textAlign: 'center', lineHeight: '24px', }, + button: { + textTransform: 'none', + }, } return (
- Menu is always constrained to the viewport,
- Even if "valign" and "align" are set. +

+ Menu is always constrained to the window. +

+

+ tl-bl attachment top left targetAttachment bottom left
+

- a=tl t=bl} align={'top right'} talign={'bottom right'}> + tl bl} align={'tl bl'}> One Two Three
- a=tr t=br} align={'top left'} talign={'bottom left'}> + tl bl} align={'tl bl'}> One Two Three
- a=bl t=tl} align={'bottom right'} talign={'top right'}> + tr br} align={'tr br'}> One Two Three
- a=br t=tr} align={'bottom left'} talign={'top left'}> + tr br} align={'tr br'}> One Two Three @@ -109,38 +130,36 @@ storiesOf('Menu', module) const styles = { center: { margin: '0 auto', textAlign: 'center' }, info: { margin: '40px auto', maxWidth: '400px', lineHeight: '24px' }, - buttons: { margin: '200px 0 1000px 0' }, - button: { margin: '0 10px' } + buttons: { margin: '300px 0 1000px 0' }, + button: { margin: '0 10px', textTransform: 'none' }, } return (

- Long menus are constrained to the viewport.
- Menu will always be shown above or below,
- depending on which area has more space.

+ Long menus are constrained to the viewport.

Try scrolling up/down and opening menu.

-

Normal Menu

- a=bl t=tl} align={'bottom right'} talign={'top right'}> +

Normal Menu

+ bl tl} align={'br tr'}> {[...Array(3).keys()].map(i => Menu Item {i} )} - a=tl t=bl} align={'top left'} talign={'bottom left'}> + tl bl} align={'tl bl'}> {[...Array(3).keys()].map(i => Menu Item {i} )} -

-

Big Menu

- a=br t=tr} align={'bottom right'} talign={'top right'}> - {[...Array(15).keys()].map(i => +


+

Big Menu

+ br tr} align={'br tr'}> + {[...Array(35).keys()].map(i => Menu Item {i} )} - a=tl t=bl} align={'top left'} talign={'bottom left'}> - {[...Array(15).keys()].map(i => + tl bl} align={'tl bl'}> + {[...Array(35).keys()].map(i => Menu Item {i} )} diff --git a/stories/MultiSelectField.story.js b/stories/MultiSelectField.story.js index 198fc82..0d8281b 100644 --- a/stories/MultiSelectField.story.js +++ b/stories/MultiSelectField.story.js @@ -3,7 +3,8 @@ import { storiesOf, action } from '@kadira/storybook' import faker from 'faker' import { MultiSelectField, Option } from '../src' -import StatefulMultiSelectField from './StatefulMultiSelectField' + +import StatefulMultiSelectField from './helpers/StatefulMultiSelectField' storiesOf('MultiSelectField', module) .add('default', () => ( @@ -16,7 +17,7 @@ storiesOf('MultiSelectField', module) )) .add('preselected', () => ( - + diff --git a/stories/SelectField.story.js b/stories/SelectField.story.js index 74a8a94..3c4a56e 100644 --- a/stories/SelectField.story.js +++ b/stories/SelectField.story.js @@ -4,7 +4,7 @@ import faker from 'faker' import { SelectField, Option } from '../src' -import StatefulSelectField from './StatefulSelectField' +import StatefulSelectField from './helpers/StatefulSelectField' storiesOf('SelectField', module) .add('default', () => ( diff --git a/stories/StatefulMultiSelectField.js b/stories/helpers/StatefulMultiSelectField.js similarity index 94% rename from stories/StatefulMultiSelectField.js rename to stories/helpers/StatefulMultiSelectField.js index 66947c5..07ddeee 100644 --- a/stories/StatefulMultiSelectField.js +++ b/stories/helpers/StatefulMultiSelectField.js @@ -1,6 +1,6 @@ import React, { Component } from 'react' -import { MultiSelectField } from '../src' +import { MultiSelectField } from '../../src' export default class StatefulMultiSelectField extends Component { diff --git a/stories/StatefulSelectField.js b/stories/helpers/StatefulSelectField.js similarity index 94% rename from stories/StatefulSelectField.js rename to stories/helpers/StatefulSelectField.js index 9c5a20b..d2a801b 100644 --- a/stories/StatefulSelectField.js +++ b/stories/helpers/StatefulSelectField.js @@ -1,6 +1,6 @@ import React, { Component } from 'react' -import { SelectField } from '../src' +import { SelectField } from '../../src' export default class StatefulSelectField extends Component { diff --git a/stories/fashion.jpg b/stories/helpers/fashion.jpg similarity index 100% rename from stories/fashion.jpg rename to stories/helpers/fashion.jpg