Initial commit

master
Vitaliy Filippov 2019-08-27 12:15:52 +03:00
commit bef2601391
11 changed files with 751 additions and 0 deletions

4
.babelrc Normal file
View File

@ -0,0 +1,4 @@
{
"presets": [ "env", "stage-1", "react" ],
"retainLines": true
}

42
.eslintrc.js Normal file
View File

@ -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"
]
}
};

137
Picker.js Normal file
View File

@ -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 (<div style={this.props.style}>
{this.props.renderInput({
onFocus: this.focus,
onBlur: this.blur,
focused: this.state.focused,
ref: this.setInput,
})}
{this.state.focused
? <div style={{
position: 'fixed',
background: 'white',
top: this.state.top,
width: this.state.width||'auto',
left: this.state.left,
zIndex: 100,
}} ref={this.setPicker}>
{this.props.renderPicker()}
</div>
: null}
</div>);
}
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 });
}
}
}

207
Selectbox.js Normal file
View File

@ -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 = <input
readOnly={this.props.readOnly}
ref={this.setInput}
className={autocomplete_css.inputInputElement}
style={this.props.multiple ? { height: '29px', padding: 0, display: 'inline-block', width: this.state.inputWidth+'px' } : undefined}
onFocus={p.onFocus}
value={value}
onChange={this.setQuery}
onKeyDown={this.onKeyDown}
/>;
return (<div ref={p.ref} className={autocomplete_css.input}
style={this.props.multiple ? { fontSize: '13px', display: 'flex', overflow: 'hidden', cursor: 'text', height: 'auto' } : undefined}
onClick={this.props.multiple ? this.focusInput : undefined}>
{this.props.multiple
? <div style={{overflow: 'hidden', flex: 1, margin: '0 2em 0 .5em'}}>
<span className={autocomplete_css.inputInputElement} ref={this.setSizer}
style={{display: 'inline-block', width: 'auto', position: 'absolute', padding: '0 2px', top: '-100px', whiteSpace: 'pre', visibility: 'hidden'}}>
{value}
</span>
{(this.props.value||[]).map(id => <span style={{cursor: 'pointer', display: 'inline-block', borderRadius: '2px', background: '#e0f0ff', border: '1px solid #c0e0ff', padding: '3px', margin: '2px 5px 2px 0'}}>{this.item_hash[id]}</span>)}
{input}
</div>
: input}
</div>);
}
renderSuggestions = () =>
{
return (<div className={autocomplete_css.suggestions} onMouseDown={this.onMouseDown} onMouseOver={this.onMouseOver}>
{this.filtered_items.map((e, i) => (<div key={i} id={i}
className={autocomplete_css.suggestion+(this.state.active == i ? ' '+autocomplete_css.active : '')}>
{e[this.props.labelKey||'name']}
</div>))}
</div>);
}
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 (<Picker
ref={this.setPicker}
minWidth={this.props.minWidth}
clearOnClick={true}
renderInput={this.renderInput}
renderPicker={this.renderSuggestions}
style={this.props.style}
/>);
}
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();
}
}

135
autocomplete.css Normal file
View File

@ -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;
}

26
dist/index.html vendored Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<link rel="stylesheet" type="text/css" href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" />
<title>Selectbox</title>
<style>
html { margin: 0; height: 100%; font-family: Roboto, sans-serif; font-size: 15px; }
body { margin: 0; height: 100%; line-height: 1.2em; background: white; color: black; }
input, select, textarea, button { font-size: 100%; font-family: Roboto, sans-serif; }
table { border-collapse: collapse; font-size: 100%; }
form table td, form table th { vertical-align: top; }
table.left th { text-align: left; padding-right: .5em; }
td, th { padding: 0; }
img { border: 0; }
ul, p, form { margin: 0; }
input { margin: 1px; }
* { box-sizing: border-box; }
</style>
</head>
<body>
<div id="app">
</div>
<script type="text/javascript" src="main.js"></script>
</body>
</html>

49
input.css Normal file
View File

@ -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;
}

39
main.js Normal file
View File

@ -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 <div style={{padding: '20px', width: '300px', background: '#e0e8ff'}}>
<Selectbox
source={OPTIONS}
multiple={true}
value={this.state.value}
onChange={this.onChange}
/>
</div>;
}
}
ReactDOM.render(
<Test />, document.getElementById('app')
);

40
package.json Normal file
View File

@ -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"
}
}

17
postcss.config.js Normal file
View File

@ -0,0 +1,17 @@
module.exports = {
plugins: {
'postcss-import': {
root: __dirname,
},
'postcss-mixins': {},
'postcss-each': {},
'postcss-cssnext': {
features: {
customProperties: {
variables: {
}
}
}
}
},
};

55
webpack.config.js Normal file
View File

@ -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
}
};