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() (
-
- )
-}
+
```
+## 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', () => (
-