commit bef2601391a463648272e048132658fbf24e4953 Author: Vitaliy Filippov Date: Tue Aug 27 12:15:52 2019 +0300 Initial commit diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..b26d6e6 --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": [ "env", "stage-1", "react" ], + "retainLines": true +} diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..da76387 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,42 @@ +module.exports = { + "parser": "babel-eslint", + "env": { + "es6": true, + "browser": true + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended" + ], + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "experimentalObjectRestSpread": true, + "jsx": true + } + }, + "plugins": [ + "react" + ], + "rules": { + "indent": [ + "error", + 4 + ], + "linebreak-style": [ + "error", + "unix" + ], + "semi": [ + "error", + "always" + ], + "no-control-regex": [ + "off" + ], + "no-empty": [ + "off" + ] + } +}; diff --git a/Picker.js b/Picker.js new file mode 100644 index 0000000..efe0735 --- /dev/null +++ b/Picker.js @@ -0,0 +1,137 @@ +// "Generic dropdown component" +// Renders something and then when that "something" is focused renders a popup layer next to it +// For example, a text input with a popup selection list +// ...Or maybe a button with a popup menu +// (c) Vitaliy Filippov 2019+ +// Version 2019-08-27 + +import React from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; + +export class Picker extends React.Component +{ + static propTypes = { + direction: PropTypes.string, + clearOnClick: PropTypes.bool, + minWidth: PropTypes.number, + style: PropTypes.object, + renderInput: PropTypes.func.isRequired, + renderPicker: PropTypes.func.isRequired, + } + + state = { + focused: false, + height: 0, + width: 0, + top: 0, + left: 0, + } + + focus = () => + { + this.setState({ focused: true, height: 0 }); + this.calculateDirection(); + if (this.props.clearOnClick) + { + document.body.addEventListener('click', this.blurExt); + } + } + + blur = () => + { + this.setState({ focused: false }); + if (this.props.clearOnClick) + { + document.body.removeEventListener('click', this.blurExt); + } + } + + blurExt = (ev) => + { + let n = this.input ? ReactDOM.findDOMNode(this.input) : null; + let e = ev.target||ev.srcElement; + while (e) + { + // calendar-box is calendar.js's class + if (e == this.picker || e == n || /\bcalendar-box\b/.exec(e.className||'')) + { + return; + } + e = e.parentNode; + } + this.blur(); + } + + setInput = (e) => + { + this.input = e; + } + + setPicker = (e) => + { + this.picker = e; + } + + render() + { + return (
+ {this.props.renderInput({ + onFocus: this.focus, + onBlur: this.blur, + focused: this.state.focused, + ref: this.setInput, + })} + {this.state.focused + ?
+ {this.props.renderPicker()} +
+ : null} +
); + } + + componentDidUpdate() + { + if (this.state.focused && !this.state.height) + { + this.calculateDirection(); + } + } + + calculateDirection() + { + if (!this.input || !this.picker) + { + return; + } + const picker_height = ReactDOM.findDOMNode(this.picker).getBoundingClientRect().height; + const client = ReactDOM.findDOMNode(this.input).getBoundingClientRect(); + const screen_height = window.innerHeight || document.documentElement.offsetHeight; + let direction = this.props.direction; + if (!direction || direction === 'auto') + { + const down = client.top + picker_height < screen_height; + direction = down ? 'down' : 'up'; + } + let top = client.top + + (window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop) + - (document.documentElement.clientTop || document.body.clientTop || 0); + top = direction == 'down' ? (top + client.height) + 'px' : (top - picker_height) + 'px'; + const left = (client.left + + (window.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft) + - (document.documentElement.clientLeft || document.body.clientLeft || 0)) + 'px'; + const width = (this.props.minWidth && client.width < this.props.minWidth ? this.props.minWidth : client.width)+'px'; + if (this.state.top !== top || this.state.left !== left || + this.state.width !== width || this.state.height !== picker_height) + { + this.setState({ top, left, width, height: picker_height }); + } + } +} diff --git a/Selectbox.js b/Selectbox.js new file mode 100644 index 0000000..9916c6c --- /dev/null +++ b/Selectbox.js @@ -0,0 +1,207 @@ +// Simple Dropdown/Autocomplete with single/multiple selection and easy customisation via CSS modules +// Version 2019-08-27 +// (c) Vitaliy Filippov 2019+ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import autocomplete_css from './autocomplete.css'; +import input_css from './input.css'; +import { Picker } from './Picker.js'; + +export default class Selectbox extends React.PureComponent +{ + state = { + active: null, + query: null, + inputWidth: 20, + } + + setQuery = (ev) => + { + this.setState({ query: ev.target.value }); + } + + onKeyDown = (ev) => + { + if ((ev.which == 40 || ev.which == 38) && this.filtered_items.length) + { + // up / down + this.setState({ + active: this.state.active == null ? 0 : ( + (this.state.active + (event.which === 40 ? 1 : this.filtered_items.length-1)) % this.filtered_items.length + ), + }); + if (!this.picker.state.focused) + { + this.picker.focus(); + } + } + else if ((ev.which == 10 || ev.which == 13) && this.state.active != null && + this.state.active < this.filtered_items.length) + { + // enter + this.onMouseDown(); + } + } + + onMouseDown = () => + { + this.setState({ query: null }); + this.picker.blur(); + const sel = this.filtered_items[this.state.active][this.props.valueKey||'id']; + let value = sel; + if (this.props.multiple) + { + const already = (this.props.value||[]).indexOf(sel); + if (already < 0) + { + // add + value = [ ...(this.props.value||[]), sel ]; + } + else + { + // remove + value = [ ...this.props.value ]; + value.splice(already, 1); + } + } + this.props.onChange(value); + } + + onMouseOver = (ev) => + { + let e = ev.target; + while (e && e != ev.currentTarget && !e.id) + { + e = e.parentNode; + } + if (e && e.id) + { + this.setState({ active: e.id }); + } + } + + focusInput = () => + { + this.input.focus(); + } + + renderInput = (p) => + { + const value = this.state.query == null ? (this.props.multiple ? '' : this.item_hash[this.props.value]||'') : this.state.query; + const input = ; + return (
+ {this.props.multiple + ?
+ + {value} + + {(this.props.value||[]).map(id => {this.item_hash[id]})} + {input} +
+ : input} +
); + } + + renderSuggestions = () => + { + return (
+ {this.filtered_items.map((e, i) => (
+ {e[this.props.labelKey||'name']} +
))} +
); + } + + setSizer = (e) => + { + this.sizer = e; + } + + setPicker = (e) => + { + this.picker = e; + } + + setInput = (e) => + { + this.input = e; + } + + render() + { + if (!this.prevProps || this.props.source != this.prevProps.source) + { + if (!this.props.source) + { + this.items = []; + this.item_hash = {}; + } + else if (this.props.source instanceof Array) + { + this.items = this.props.source; + this.item_hash = this.items.reduce((a, c) => { a[c[this.props.valueKey||'id']] = c[this.props.labelKey||'name']; return a; }, {}); + } + else + { + this.items = Object.keys(this.props.source).map(id => ({ id, name: this.props.source[id] })); + this.item_hash = this.props.source; + } + } + if (!this.prevProps || this.props.suggestionMatch != this.prevProps.suggestionMatch || + this.state.query != this.prevState.query) + { + if (!this.props.suggestionMatch || this.props.suggestionMatch == 'disabled' || !this.state.query) + { + this.filtered_items = this.items; + } + else + { + const n = this.props.labelKey||'name'; + const q = this.state.query.toLowerCase(); + this.filtered_items = this.items.filter(e => e[n].toLowerCase().indexOf(q) >= 0); + } + } + this.prevProps = this.props; + this.prevState = this.state; + return (); + } + + componentDidUpdate() + { + if (this.sizer) + { + this.setState({ inputWidth: this.sizer.offsetWidth }); + if (this.prevHeight && this.picker.input.offsetHeight != this.prevHeight) + { + this.picker.calculateDirection(); + } + this.prevHeight = this.picker.input.offsetHeight; + } + } + + componentDidMount() + { + this.componentDidUpdate(); + } +} diff --git a/autocomplete.css b/autocomplete.css new file mode 100644 index 0000000..e9cdcd4 --- /dev/null +++ b/autocomplete.css @@ -0,0 +1,135 @@ +.autocomplete +{ + padding: 0; + position: relative; + font-size: inherit; +} + +.input +{ + background: white; + border: 1px solid #d8d8d8; + border-radius: 0; + font-size: inherit; + padding: 0; + height: 28px; + position: relative; +} + +.input:after +{ + border: 0; + top: 50%; + width: 0; + position: absolute; + right: 1em; + text-align: right; + font-size: 1.5em; + margin-top: -0.375em; + font-family: FontAwesome; + content: "\f107"; + color: #c0c0c0; + height: 0.75em; + cursor: pointer; +} + +.suggestions +{ + margin-top: 0; + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.12), 0 1px 4px rgba(0, 0, 0, 0.24); + overflow-x: hidden; + overflow-y: auto; + padding: 0; + transition-duration: 0.35s; + transition-property: max-height; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + width: 100%; +} + +.suggestion +{ + font-family: helvetica, arial, verdana, sans-serif; + font-size: 13px; + padding: 7px; + user-select: none; + cursor: pointer; +} + +.suggestion.active +{ + background: #d6e8f6; +} + +.clear +{ + top: 4px; + left: 6px; +} + +.values +{ + padding: 0; +} + +.inputInputElement +{ + font-size: inherit; + line-height: 200%; + border: 0; + padding: 0 2em 0 .5em; + margin: 0; + height: 100%; + display: block; + outline: none; + width: 100%; +} + +.inputInputElement::-ms-clear +{ + display: none; +} + +.inputBar +{ + display: none; +} + +.inputInputElement:focus + .inputBar +{ + content: " "; + display: block; + border: 1px solid #4196d4; + border-radius: 0; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; +} + +.inputBar:after, .inputBar:before +{ + display: none; +} + +.withclear input +{ + text-indent: 0; + padding-right: 50px; +} + +.clear +{ + color: #aaa; + width: 20px; + height: 20px; + border-radius: 10px; + border: 1px solid #aaa; + left: auto; + top: 4px; + right: 28px; + line-height: 18px; + font-size: 18px; + text-align: center; + padding: 0; +} diff --git a/dist/index.html b/dist/index.html new file mode 100644 index 0000000..1810793 --- /dev/null +++ b/dist/index.html @@ -0,0 +1,26 @@ + + + + + +Selectbox + + + +
+
+ + + diff --git a/input.css b/input.css new file mode 100644 index 0000000..9a9fac9 --- /dev/null +++ b/input.css @@ -0,0 +1,49 @@ +.input +{ + background: white; + border: 1px solid #d8d8d8; + border-radius: 0; + font-size: inherit; + padding: 0; + color: rgb(33, 33, 33); + position: relative; +} + +.inputElement +{ + font-size: inherit; + border: 0; + padding: 0 2em 0 .5em; + margin: 0; + box-sizing: border-box; + display: block; + outline: none; + width: 100%; +} + +.inputElement:focus +{ + box-shadow: inset 0 0 0 1px #4196d4; + display: block; +} + +input.inputElement +{ + line-height: 200%; + height: 28px; +} + +.inputElement::-ms-clear +{ + display: none; +} + +.bar +{ + display: none; +} + +.bar:after, .bar:before +{ + display: none; +} diff --git a/main.js b/main.js new file mode 100644 index 0000000..9a58330 --- /dev/null +++ b/main.js @@ -0,0 +1,39 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import Selectbox from './Selectbox.js'; + +const OPTIONS = { + day: 'День', + week: 'Неделя', + month: 'Месяц', + kak: 'Полный период детализации', +}; + +class Test extends React.PureComponent +{ + state = { + value: [ 'day' ], + } + + onChange = (v) => + { + this.setState({ value: v }); + } + + render() + { + return
+ +
; + } +} + +ReactDOM.render( + , document.getElementById('app') +); diff --git a/package.json b/package.json new file mode 100644 index 0000000..bc11fa6 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "selectbox", + "author": { + "name": "Vitaliy Filippov", + "email": "vitalif@yourcmc.ru", + "url": "http://yourcmc.ru/wiki/" + }, + "description": "Simple React Dropdown/Autocomplete", + "dependencies": {}, + "devDependencies": { + "babel-core": "^6.26.3", + "babel-eslint": "^10.0.1", + "babel-loader": "^7.1.5", + "babel-polyfill": "^6.26.0", + "babel-preset-env": "^1.7.0", + "babel-preset-react": "^6.24.1", + "babel-preset-stage-1": "^6.24.1", + "css-loader": "^1.0.0", + "eslint": "^5.1.0", + "eslint-plugin-react": "^7.10.0", + "postcss": "^6.0.23", + "postcss-cssnext": "^3.1.0", + "postcss-each": "^0.10.0", + "postcss-import": "^11.1.0", + "postcss-loader": "^2.1.5", + "postcss-mixins": "^6.2.0", + "react": "^16.8.6", + "react-dom": "^16.8.6", + "style-loader": "^0.21.0", + "webpack": "^4.20.2", + "webpack-bundle-analyzer": "^2.13.1", + "webpack-cli": "^3.1.2" + }, + "scripts": { + "compile": "webpack --mode=production --optimize-minimize; nodejs copy-css.js", + "stats": "NODE_ENV=production webpack --mode=production --optimize-minimize --profile --json > stats.json; webpack-bundle-analyzer stats.json -h 0.0.0.0", + "watch-dev": "NODE_ENV=development webpack --mode=development -w", + "watch": "webpack --mode=production -w --optimize-minimize" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..bb2bff1 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,17 @@ +module.exports = { + plugins: { + 'postcss-import': { + root: __dirname, + }, + 'postcss-mixins': {}, + 'postcss-each': {}, + 'postcss-cssnext': { + features: { + customProperties: { + variables: { + } + } + } + } + }, +}; diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..1d72043 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,55 @@ +const webpack = require('webpack'); +const path = require('path'); + +module.exports = { + entry: { + main: [ "babel-polyfill", './main.js' ] + }, + context: __dirname, + output: { + path: __dirname+'/dist', + filename: '[name].js' + }, + devtool: 'inline-source-map', + module: { + rules: [ + { + test: /.jsx?$/, + loader: 'babel-loader', + exclude: /node_modules(?!\/react-toolbox\/components|\/dynamic-virtual-scroll)/ + }, + { + test: /\.css$/, + use: [ + { + loader: "style-loader", + options: { + singleton: true + } + }, + { + loader: "css-loader", + options: { + modules: true, // default is false + sourceMap: true, + importLoaders: 1, + localIdentName: "[name]--[local]--[hash:base64:8]" + } + }, + "postcss-loader" + ] + } + ] + }, + plugins: [ + new webpack.DefinePlugin({ + "process.env": { + NODE_ENV: JSON.stringify(process.env.NODE_ENV || "production") + } + }) + ], + performance: { + maxEntrypointSize: 5000000, + maxAssetSize: 5000000 + } +};