├── .eslintrc.js ├── .gitignore ├── .storybook ├── addons.js ├── config.js └── webpack.config.js ├── README.md ├── now.json ├── package.json ├── scripts └── deploy └── src ├── components ├── CounterButton │ ├── CounterButton.css │ ├── CounterButton.js │ ├── README.md │ └── stories │ │ ├── basic.story.js │ │ ├── todos.story.js │ │ └── todos.style.css ├── DragSelect │ ├── DragSelect.js │ ├── DragSelect.scss │ ├── Item.js │ ├── Item.scss │ ├── README.md │ └── stories │ │ └── index.story.js ├── IconInput │ ├── IconInput.css │ ├── IconInput.js │ ├── README.md │ └── stories │ │ ├── index.story.js │ │ └── index.style.scss └── TabSwitcher │ ├── README.md │ ├── TabSwitcher.css │ ├── TabSwitcher.js │ └── stories │ └── index.story.js └── index.css /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['airbnb', 'prettier'], 3 | parser: 'babel-eslint', 4 | 5 | env: { 6 | browser: true 7 | }, 8 | 9 | rules: { 10 | 'react/prop-types': 'off', 11 | 'react/jsx-filename-extension': 'off', 12 | 'jsx-a11y/no-noninteractive-element-interactions': 'off', 13 | 'react/destructuring-assignment': 'off' 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | .vscode 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | yarn.lock -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-options/register'; 2 | import '@storybook/addon-notes/register'; 3 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { addDecorator, configure } from '@storybook/react'; 2 | import { withOptions } from '@storybook/addon-options'; 3 | import { withNotes } from '@storybook/addon-notes'; 4 | 5 | import '../src/index.css'; 6 | 7 | addDecorator(withNotes); 8 | 9 | addDecorator( 10 | withOptions({ 11 | url: 'https://github.com/wsmd/ui-sketchbook', 12 | name: "@wsmd's ui-sketchbook", 13 | showStoriesPanel: true, 14 | enableShortcuts: false 15 | }) 16 | ); 17 | 18 | const req = require.context('../src/components', true, /.stor(y|ies).js$/); 19 | 20 | function loadStories() { 21 | req.keys().forEach(filename => req(filename)); 22 | } 23 | 24 | configure(loadStories, module); 25 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | module: { 5 | rules: [ 6 | { 7 | test: /\.css$/, 8 | loaders: ['style-loader', 'css-loader'], 9 | include: path.resolve(__dirname, '../') 10 | }, 11 | { 12 | test: /\.scss$/, 13 | loaders: ['style-loader', 'css-loader', 'sass-loader'], 14 | include: path.resolve(__dirname, '../') 15 | } 16 | ] 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ui-sketchbook 2 | 3 | This repo contains personal experiments and proof-of-concepts for various UI components and interactions. 4 | 5 | All experiments can be previewed by clicking the link below: 6 | 7 | 8 | 9 | 10 | 11 | ## Components 12 | 13 | The Source code for all components is available under [`src/components`](https://github.com/wsmd/ui-sketchbook/tree/master/src/components/). 14 | 15 | | Name | Preview | Code | Description | 16 | | --------------- | :-----------------------------------------------------------------: | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | 17 | | `CounterButton` | [Preview](https://ui-sketchbook.now.sh/?selectedKind=CounterButton) | [Code](https://github.com/wsmd/ui-sketchbook/tree/master/src/components/CounterButton) | A custom button component with an animated counter. | 18 | | `IconInput` | [Preview](https://ui-sketchbook.now.sh/?selectedKind=IconInput) | [Code](https://github.com/wsmd/ui-sketchbook/tree/master/src/components/IconInput) | A icon that transitions into a text input on focus with various interactive states such as loading, success, and error. | 19 | | `DragSelect` | [Preview](https://ui-sketchbook.now.sh/?selectedKind=DragSelect) | [Code](https://github.com/wsmd/ui-sketchbook/tree/master/src/components/DragSelect) | A drag & select interaction. | 20 | | `TabSwitcher` | [Preview](https://ui-sketchbook.now.sh/?selectedKind=TabSwitcher) | [Code](https://github.com/wsmd/ui-sketchbook/tree/master/src/components/TabSwitcher) | A tabs UI with an active tab indicator that re-positions and resizes itself based on the selected tab. | 21 | 22 | ## Notes 23 | 24 | - The code in this repo is only a proof-of-concept around certain ideas and purely experimental. It is not optimized or ready for production use. 25 | - All components were developed with Google Chrome and not guaranteed to be fully functional in other browsers. For best results, Preview the components in Google Chrome. -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui-sketchbook", 3 | "alias": "ui-sketchbook" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "buttons", 3 | "private": true, 4 | "description": "Personal experiments with various UI components and interactions", 5 | "prettier": { 6 | "singleQuote": true, 7 | "printWidth": 80 8 | }, 9 | "scripts": { 10 | "start": "start-storybook --port 3000", 11 | "build": "build-storybook -c .storybook -o build", 12 | "deploy": "./scripts/deploy" 13 | }, 14 | "dependencies": { 15 | "@babel/core": "7.0.0", 16 | "@storybook/addon-notes": "^4.0.1", 17 | "@storybook/addon-options": "^4.0.1", 18 | "@storybook/addons": "^4.0.1", 19 | "@storybook/react": "^4.0.1", 20 | "babel-eslint": "10.0.1", 21 | "babel-loader": "8.0.0", 22 | "classnames": "^2.2.6", 23 | "css-loader": "^1.0.0", 24 | "eslint": "^4.19.1", 25 | "eslint-config-airbnb": "^17.1.0", 26 | "eslint-config-prettier": "^4.1.0", 27 | "eslint-plugin-import": "^2.13.0", 28 | "eslint-plugin-jsx-a11y": "^6.1.1", 29 | "eslint-plugin-react": "^7.11.0", 30 | "lodash.difference": "^4.5.0", 31 | "lodash.intersection": "^4.4.0", 32 | "lodash.union": "^4.6.0", 33 | "node-sass": "^4.11.0", 34 | "react": "16.3", 35 | "react-dom": "16.3", 36 | "react-feather": "1.1.0", 37 | "sass-loader": "^7.1.0", 38 | "style-loader": "^0.22.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /scripts/deploy: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | rm -rf build 4 | npm run build 5 | cp now.json build/now.json 6 | cd build 7 | now --public 8 | now alias -------------------------------------------------------------------------------- /src/components/CounterButton/CounterButton.css: -------------------------------------------------------------------------------- 1 | .CounterButton { 2 | color: white; 3 | background: #0076ff; 4 | box-shadow: 0 4px 8px -3px rgba(0, 118, 255, 0.5), 5 | 0 1px 1px rgba(0, 118, 255, 0.25); 6 | padding: 8px 12px; 7 | line-height: 16px; 8 | border-radius: 4px; 9 | transition: all 0.15s ease; 10 | transition-property: width, background, opacity; 11 | font-size: 14px; 12 | box-sizing: border-box; 13 | cursor: pointer; 14 | text-align: left; 15 | border: 0; 16 | position: relative; 17 | overflow: hidden; 18 | } 19 | 20 | .CounterButton-counter { 21 | margin-left: 8px; 22 | position: absolute; 23 | display: inline-block; 24 | text-align: center; 25 | opacity: 0.75; 26 | } 27 | 28 | .CounterButton-count { 29 | backface-visibility: hidden; 30 | transition: all 0.2s ease; 31 | display: inline-block; 32 | animation-duration: 250ms; 33 | animation-timing-function: ease; 34 | animation-fill-mode: forwards; 35 | transform: translate3d(0, 0, 0); 36 | } 37 | 38 | .CounterButton-count--prev, 39 | .CounterButton-count--next { 40 | position: absolute; 41 | left: 0; 42 | opacity: 0; 43 | } 44 | 45 | .CounterButton-count--prev { 46 | transform: translate3d(0, 25px, 0); 47 | } 48 | 49 | .CounterButton-count--next { 50 | transform: translate3d(0, -25px, 0); 51 | } 52 | 53 | .CounterButton.is-incrementing .CounterButton-count--next { 54 | animation-name: incrementingNext; 55 | } 56 | 57 | .CounterButton.is-incrementing .CounterButton-count--active { 58 | animation-name: incrementingActive; 59 | } 60 | 61 | .CounterButton.is-decrementing .CounterButton-count--prev { 62 | animation-name: decrementingPrev; 63 | } 64 | 65 | .CounterButton.is-decrementing .CounterButton-count--active { 66 | animation-name: decrementingActive; 67 | } 68 | 69 | @keyframes incrementingNext { 70 | to { 71 | transform: translate3d(0, 0, 0); 72 | opacity: 1; 73 | } 74 | } 75 | 76 | @keyframes incrementingActive { 77 | 60% { 78 | opacity: 0; 79 | } 80 | to { 81 | transform: translate3d(0, 25px, 0); 82 | opacity: 0; 83 | } 84 | } 85 | 86 | @keyframes decrementingPrev { 87 | to { 88 | transform: translate3d(0, 0, 0); 89 | opacity: 1; 90 | } 91 | } 92 | 93 | @keyframes decrementingActive { 94 | 60% { 95 | opacity: 0; 96 | } 97 | to { 98 | transform: translate3d(0, -25px, 0); 99 | opacity: 0; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/components/CounterButton/CounterButton.js: -------------------------------------------------------------------------------- 1 | import React, { Component, createRef } from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import './CounterButton.css'; 5 | 6 | const INCREMENT = 'inc'; 7 | const DECREMENT = 'dec'; 8 | const NUMERIC_PROPERTIES = ['margin-left', 'animation-duration']; 9 | 10 | function getComputedProperty(node, property) { 11 | const value = window.getComputedStyle(node)[property]; 12 | if (NUMERIC_PROPERTIES.includes(property)) { 13 | return parseFloat(value); 14 | } 15 | return value; 16 | } 17 | 18 | class CounterButton extends Component { 19 | state = { 20 | count: this.props.count, 21 | next: this.props.count, 22 | prev: this.props.count, 23 | animationState: null, 24 | width: null 25 | }; 26 | 27 | updateQueue = []; 28 | 29 | buttonRef = createRef(); 30 | 31 | counterRef = createRef(); 32 | 33 | currentCountRef = createRef(); 34 | 35 | nextCountRef = createRef(); 36 | 37 | prevCountRef = createRef(); 38 | 39 | componentDidMount() { 40 | this.animationDuration = 41 | getComputedProperty(this.currentCountRef.current, 'animation-duration') * 42 | 1000; 43 | this.setState({ width: this.getInitialWidth() }); 44 | } 45 | 46 | componentWillReceiveProps(nextProps) { 47 | if (this.state.animationState === null) { 48 | this.animateNumber(nextProps.count); 49 | } else { 50 | this.updateQueue.push(nextProps.count); 51 | } 52 | } 53 | 54 | componentDidUpdate(prevProps, prevState) { 55 | clearTimeout(this.timeout); 56 | if ( 57 | prevState.animationState !== null && 58 | this.state.animationState === null 59 | ) { 60 | this.timeout = setTimeout(this.handleDeferredItems, 25); 61 | } 62 | } 63 | 64 | getWidth(countNode) { 65 | const totalWidth = this.buttonRef.current.getBoundingClientRect().width; 66 | const currentCountWidth = this.currentCountRef.current.getBoundingClientRect() 67 | .width; 68 | const nextCountWidth = countNode.getBoundingClientRect().width; 69 | return totalWidth - currentCountWidth + nextCountWidth; 70 | } 71 | 72 | getInitialWidth() { 73 | const margin = getComputedProperty(this.counterRef.current, 'margin-left'); 74 | const initial = this.buttonRef.current.getBoundingClientRect().width; 75 | const current = this.currentCountRef.current.getBoundingClientRect().width; 76 | return initial + margin + current; 77 | } 78 | 79 | handleDeferredItems = () => { 80 | if (this.updateQueue.length > 0) { 81 | const lastDeferredNumber = this.updateQueue.pop(); 82 | if (lastDeferredNumber !== this.state.count) { 83 | this.animateNumber(lastDeferredNumber); 84 | } 85 | this.updateQueue = []; 86 | } 87 | }; 88 | 89 | updateCurrentCount(number) { 90 | setTimeout(() => { 91 | this.setState({ count: number, animationState: null }); 92 | }, this.animationDuration); 93 | } 94 | 95 | animateNumber(nextCount) { 96 | const incrementing = nextCount > this.state.count; 97 | const animationState = incrementing ? INCREMENT : DECREMENT; 98 | const nextCountKey = incrementing ? 'next' : 'prev'; 99 | const nextCountRef = incrementing ? this.nextCountRef : this.prevCountRef; 100 | // update the next/prev count node and start animations 101 | this.setState({ animationState, [nextCountKey]: nextCount }, () => { 102 | // update the button width 103 | this.setState({ width: this.getWidth(nextCountRef.current) }, () => 104 | // update the current count 105 | this.updateCurrentCount(nextCount) 106 | ); 107 | }); 108 | } 109 | 110 | render() { 111 | const { width, next, count, prev, animationState } = this.state; 112 | return ( 113 | 144 | ); 145 | } 146 | } 147 | 148 | export default CounterButton; 149 | -------------------------------------------------------------------------------- /src/components/CounterButton/README.md: -------------------------------------------------------------------------------- 1 | # `CounterButton` 2 | 3 | A custom button component with an animated counter. 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/CounterButton/stories/basic.story.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | 4 | import CounterButton from '../CounterButton'; 5 | 6 | class CounterButtonExample extends Component { 7 | state = { 8 | count: 10 9 | }; 10 | 11 | static defaultProps = { 12 | min: 0, 13 | max: 2000 14 | }; 15 | 16 | increment = number => { 17 | const { max } = this.props; 18 | this.setState(state => ({ count: Math.min(max, state.count + number) })); 19 | }; 20 | 21 | decrement = number => { 22 | const { min } = this.props; 23 | this.setState(state => ({ 24 | count: Math.max(min, state.count - number) 25 | })); 26 | }; 27 | 28 | randomize = () => { 29 | const { min, max } = this.props; 30 | this.setState({ count: Math.floor(Math.random() * max) + min }); 31 | }; 32 | 33 | render() { 34 | const isMax = this.state.count >= this.props.max; 35 | const isMin = this.state.count <= this.props.min; 36 | return ( 37 |
38 | Mark as Read 39 |
40 | 43 | 50 | 57 |
58 | ); 59 | } 60 | } 61 | 62 | storiesOf('CounterButton', module).add( 63 | 'Basic Usage', 64 | () => , 65 | { 66 | notes: 67 | 'Counter Button basic usage. Try incrementing and decrementing the ' + 68 | 'counter value or clicking the Random button.' 69 | } 70 | ); 71 | -------------------------------------------------------------------------------- /src/components/CounterButton/stories/todos.story.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import CounterButton from '../CounterButton'; 4 | 5 | import './todos.style.css'; 6 | 7 | const TodoList = ({ todos, onToggle }) => ( 8 |
9 | {todos.map(todo => ( 10 | 18 | ))} 19 |
20 | ); 21 | 22 | const todos = [ 23 | 'Pick apples and make something scrumptious with them', 24 | 'Drink pumpkin flavored beverages such as pumpkin ale or beer', 25 | 'Have a campfire or bonfire and make S’mores', 26 | 'Visit a Pumpkin Patch', 27 | 'Decorate a Haunted Gingerbread House', 28 | 'Give someone a friendly scare!' 29 | ]; 30 | 31 | class Todos extends Component { 32 | state = { 33 | counter: 0 34 | }; 35 | 36 | handleToggle = checked => { 37 | this.setState(state => ({ counter: state.counter + (checked ? 1 : -1) })); 38 | }; 39 | 40 | render() { 41 | const { counter } = this.state; 42 | return ( 43 |
44 |

45 | Halloween Checklist 46 | 47 | 👻 🎃 48 | 49 |

50 | 51 |
0 ? 1 : 0 56 | }} 57 | > 58 | 59 | Archive 60 | 61 |
62 |
63 | ); 64 | } 65 | } 66 | 67 | storiesOf('CounterButton', module).add('Todo List', () => , { 68 | notes: 69 | 'Toggle todo-list items to show a counter action button at the bottom of ' + 70 | 'the list with the number of items selected.' 71 | }); 72 | -------------------------------------------------------------------------------- /src/components/CounterButton/stories/todos.style.css: -------------------------------------------------------------------------------- 1 | .Todo { 2 | display: flex; 3 | align-items: flex-start; 4 | padding: 6px 8px; 5 | max-width: 400px; 6 | cursor: pointer; 7 | transition: all 0.15s; 8 | border-radius: 4px; 9 | line-height: 1.5; 10 | user-select: none; 11 | } 12 | 13 | .Todo:hover { 14 | background: #eff2f7; 15 | box-shadow: 0 1px 1px 0 rgba(188, 188, 188, 0.12), 16 | 0 1px 3px 0 rgba(0, 0, 0, 0.03); 17 | } 18 | 19 | .Todo input + span { 20 | transition: all 0.15s; 21 | } 22 | 23 | .Todo input { 24 | margin-right: 10px; 25 | top: 4px; 26 | position: relative; 27 | } 28 | 29 | .Todo input:checked + span { 30 | opacity: 0.4; 31 | } 32 | -------------------------------------------------------------------------------- /src/components/DragSelect/DragSelect.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import classNames from 'classnames'; 3 | import union from 'lodash.union'; 4 | import difference from 'lodash.difference'; 5 | import intersection from 'lodash.intersection'; 6 | import Item from './Item'; 7 | 8 | import './DragSelect.scss'; 9 | 10 | const initialSelectionStyle = [ 11 | 'width', 12 | 'height', 13 | 'top', 14 | 'left', 15 | 'initialLeft', 16 | 'initialTop' 17 | ].reduce((obj, key) => ({ ...obj, [key]: undefined }), {}); 18 | 19 | class Selection extends Component { 20 | items = {}; 21 | 22 | state = { 23 | isSelecting: false, 24 | stoppedSelecting: false, 25 | selection: initialSelectionStyle, 26 | selectedIds: [], 27 | items: Array.from({ length: 12 }, (v, i) => i) 28 | }; 29 | 30 | handleMouseDown = e => { 31 | clearTimeout(this.mouseDownTimer); 32 | clearTimeout(this.selectionTimer); 33 | const { pageY, pageX } = e; 34 | this.mouseDownTimer = setTimeout(() => { 35 | this.setState({ 36 | stoppedSelecting: false, 37 | selection: { 38 | ...initialSelectionStyle, 39 | top: pageY, 40 | left: pageX, 41 | initialLeft: pageX, 42 | initialTop: pageY 43 | } 44 | }); 45 | window.addEventListener('mousemove', this.handleMouseMove); 46 | }, 100); 47 | window.addEventListener('mouseup', this.handleMouseUp); 48 | }; 49 | 50 | handleMouseUp = () => { 51 | const { selection } = this.state; 52 | clearTimeout(this.mouseDownTimer); 53 | clearTimeout(this.selectionTimer); 54 | if (this.hasSelectedItems) { 55 | this.handleSelection(selection); 56 | this.hasSelectedItems = false; 57 | } 58 | this.setState({ stoppedSelecting: true }); 59 | this.selectionTimer = setTimeout(() => { 60 | this.setState({ 61 | isSelecting: false, 62 | stoppedSelecting: false, 63 | selection: initialSelectionStyle 64 | }); 65 | }, 250); 66 | window.removeEventListener('mousemove', this.handleMouseMove); 67 | window.removeEventListener('mouseup', this.handleMouseUp); 68 | }; 69 | 70 | handleMouseMove = e => { 71 | const { selection } = this.state; 72 | const style = {}; 73 | if (selection.initialLeft > e.clientX) { 74 | style.width = selection.initialLeft - e.clientX; 75 | style.left = selection.initialLeft - style.width; 76 | } else if (selection.initialLeft < e.clientX) { 77 | style.width = e.clientX - selection.initialLeft; 78 | style.left = selection.initialLeft; 79 | } 80 | 81 | if (selection.initialTop > e.pageY) { 82 | style.height = selection.initialTop - e.pageY; 83 | style.top = selection.initialTop - style.height; 84 | } else if (selection.initialTop < e.pageY) { 85 | style.height = e.pageY - selection.initialTop; 86 | style.top = selection.initialTop; 87 | } 88 | 89 | const { id } = e.target.dataset; 90 | if (!this.hasSelectedItems && e.target === this.items[id]) { 91 | this.hasSelectedItems = true; 92 | } 93 | 94 | this.setState(state => ({ 95 | isSelecting: true, 96 | selection: { ...state.selection, ...style } 97 | })); 98 | }; 99 | 100 | toggleItem = itemId => { 101 | this.setState(state => { 102 | const id = parseInt(itemId, 10); 103 | const selectedIds = new Set(state.selectedIds); 104 | if (selectedIds.has(id)) { 105 | selectedIds.delete(id); 106 | } else { 107 | selectedIds.add(id); 108 | } 109 | return { 110 | ...state, 111 | selectedIds: Array.from(selectedIds) 112 | }; 113 | }); 114 | }; 115 | 116 | handleSelection() { 117 | const { selectedIds } = this.state; 118 | const selectionRect = this.selectionEl.getBoundingClientRect(); 119 | const previousSelectionItems = new Set(selectedIds); 120 | const currentSelectionItems = new Set(); 121 | 122 | Object.entries(this.items).forEach(([, item]) => { 123 | const id = parseInt(item.dataset.id, 10); 124 | const { left, top, right, bottom } = item.getBoundingClientRect(); 125 | const includedInSelection = !( 126 | selectionRect.right < left || 127 | selectionRect.left > right || 128 | selectionRect.bottom < top || 129 | selectionRect.top > bottom 130 | ); 131 | if (includedInSelection) { 132 | currentSelectionItems.add(id); 133 | } 134 | }); 135 | 136 | const previous = Array.from(previousSelectionItems); 137 | const current = Array.from(currentSelectionItems); 138 | const intersect = intersection(previous, current); 139 | 140 | if (previous.length && intersect.length === current.length) { 141 | this.setState({ 142 | selectedIds: difference(previous, intersect) 143 | }); 144 | } else if (!intersect.length && current.length) { 145 | this.setState({ 146 | selectedIds: Array.from(new Set([...previous, ...current])) 147 | }); 148 | } else if (union(current, intersect).length === current.length) { 149 | this.setState({ 150 | selectedIds: Array.from( 151 | new Set(previous.concat(union(current, intersect))) 152 | ) 153 | }); 154 | } 155 | } 156 | 157 | render() { 158 | const { 159 | isSelecting, 160 | stoppedSelecting, 161 | selection, 162 | selectedIds, 163 | items 164 | } = this.state; 165 | const { layout } = this.props; 166 | return ( 167 | <> 168 | {isSelecting && ( 169 |
{ 173 | this.selectionEl = n; 174 | }} 175 | /> 176 | )} 177 |
178 |
179 | 187 | 194 |
195 |
196 |
{ 204 | this.container = n; 205 | }} 206 | > 207 | {items.map(item => ( 208 | { 214 | this.items[item] = n; 215 | }} 216 | onClick={() => this.toggleItem(item)} 217 | /> 218 | ))} 219 |
220 | 221 | ); 222 | } 223 | } 224 | 225 | export default Selection; 226 | -------------------------------------------------------------------------------- /src/components/DragSelect/DragSelect.scss: -------------------------------------------------------------------------------- 1 | .Wrapper { 2 | width: 650px; 3 | 4 | &.is-grid { 5 | display: grid; 6 | grid-template-columns: 1fr 1fr 1fr 1fr 1fr; 7 | grid-template-rows: repeat(4, 126px); 8 | grid-gap: 5px; 9 | } 10 | } 11 | 12 | .Header { 13 | display: flex; 14 | justify-content: space-between; 15 | flex-direction: column; 16 | margin-bottom: 8px; 17 | margin-top: 30px; 18 | } 19 | 20 | .Selection { 21 | transition: opacity 250ms ease; 22 | position: absolute; 23 | z-index: 9999; 24 | background: #0080ff0f; 25 | border: solid 1px #2196f382; 26 | pointer-events: none; 27 | } 28 | 29 | .ButtonGroup { 30 | align-self: flex-end; 31 | .Button:not(:last-child) { 32 | margin-right: 4px; 33 | } 34 | } 35 | 36 | .Button { 37 | color: white; 38 | background: #1782ff; 39 | box-shadow: 0 4px 8px -3px rgba(0, 118, 255, 0.25), 40 | 0 1px 1px rgba(0, 118, 255, 0.2); 41 | padding: 0 12px; 42 | line-height: 32px; 43 | display: inline-block; 44 | border-radius: 4px; 45 | transition: all 0.15s ease; 46 | font-size: 14px; 47 | font-weight: 500; 48 | border: 0; 49 | outline: 0; 50 | 51 | &:disabled { 52 | cursor: default; 53 | opacity: 0.5; 54 | } 55 | 56 | &:active { 57 | background: rgb(24, 122, 234); 58 | box-shadow: 0 0 0 0 rgba(0, 118, 255, 0.25), 59 | 0 1px 1px rgba(0, 118, 255, 0.2); 60 | } 61 | } 62 | 63 | .fade-out { 64 | opacity: 0; 65 | } 66 | -------------------------------------------------------------------------------- /src/components/DragSelect/Item.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import './Item.scss'; 5 | 6 | const CheckIcon = () => ( 7 | 13 | 14 | 15 | ); 16 | 17 | class Item extends Component { 18 | render() { 19 | const { children, style, short, selected, innerRef, ...props } = this.props; 20 | return ( 21 |
30 | {children} 31 |
35 | 36 |
37 |
38 | ); 39 | } 40 | } 41 | 42 | export default Item; 43 | -------------------------------------------------------------------------------- /src/components/DragSelect/Item.scss: -------------------------------------------------------------------------------- 1 | .Item { 2 | user-select: none; 3 | background: white; 4 | border-radius: 4px; 5 | position: relative; 6 | transition: box-shadow 150ms ease; 7 | background-size: cover; 8 | background-position: center; 9 | box-shadow: 0 3px 6px #00000008, 0 0 0 1px #00000005; 10 | 11 | &--selected { 12 | box-shadow: 0 3px 6px #00000008, 0 0 0 1px #00000005, 13 | inset 0 0 0 2px #1782ff; 14 | } 15 | 16 | .is-grid & { 17 | height: auto; 18 | margin: unset; 19 | 20 | &:nth-child(1), 21 | &:nth-child(3), 22 | &:nth-child(5), 23 | &:nth-child(8), 24 | &:nth-child(10) { 25 | grid-column: span 2; 26 | } 27 | 28 | &:nth-child(4) { 29 | grid-column: span 2; 30 | grid-row: span 2; 31 | } 32 | } 33 | 34 | .is-list & { 35 | height: 48px; 36 | margin-bottom: 4px; 37 | } 38 | } 39 | 40 | .Check { 41 | display: flex; 42 | align-items: center; 43 | justify-content: center; 44 | width: 24px; 45 | height: 24px; 46 | position: absolute; 47 | right: 0; 48 | bottom: 0; 49 | transition: 150ms ease-out; 50 | transition-property: opacity; 51 | background: #1782ff; 52 | border-top-left-radius: 4px; 53 | border-bottom-right-radius: 4px; 54 | opacity: 0; 55 | 56 | &--selected { 57 | opacity: 1; 58 | } 59 | 60 | svg { 61 | height: 12px; 62 | width: 12px; 63 | fill: white; 64 | } 65 | 66 | .is-list & { 67 | border-radius: 100%; 68 | width: 20px; 69 | height: 20px; 70 | background-position: -2px -1px; 71 | bottom: auto; 72 | top: 50%; 73 | right: 12px; 74 | transform: translateY(-50%); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/components/DragSelect/README.md: -------------------------------------------------------------------------------- 1 | # `DragSelect` 2 | 3 | A drag & select interaction. 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/DragSelect/stories/index.story.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | 4 | import Selection from '../DragSelect'; 5 | 6 | storiesOf('DragSelect', module) 7 | .add('Grid', () => , { 8 | notes: 9 | 'Move the mouse pointer over one of the tiles, and then hold-click and ' + 10 | 'drag to another point to select tiles. Try a few times with different ' + 11 | 'combinations of selected and selected tiles.' 12 | }) 13 | .add('List', () => , { 14 | notes: 15 | 'Move the mouse pointer over one of the items, and then hold-click and ' + 16 | 'drag to another point to select items. Try a few times with different ' + 17 | 'combinations of selected and selected items.' 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/IconInput/IconInput.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --add-new-width: 240px; 3 | --add-new-padding: 8px; 4 | } 5 | .IconInput { 6 | display: inline-block; 7 | height: 36px; 8 | position: relative; 9 | width: 24px; 10 | height: 24px; 11 | transition: all ease 300ms; 12 | } 13 | .IconInput.active { 14 | width: calc(var(--add-new-width) - var(--add-new-padding) * 2); 15 | } 16 | .IconInput-input { 17 | position: absolute; 18 | width: 0; 19 | overflow: hidden; 20 | right: calc(var(--add-new-padding) * -1); 21 | top: calc(var(--add-new-padding) * -1); 22 | opacity: 0; 23 | transition: all ease 300ms; 24 | } 25 | .IconInput-input input { 26 | border: 0; 27 | width: 100%; 28 | margin: 0; 29 | padding: var(--add-new-padding) 3em var(--add-new-padding) 30 | var(--add-new-padding); 31 | line-height: 24px; 32 | font-size: 14px; 33 | background: #1f1f1f; 34 | color: white; 35 | outline: 0; 36 | border-radius: 4px; 37 | } 38 | .IconInput-input input:disabled { 39 | -webkit-text-fill-color: rgb(102, 102, 102); 40 | } 41 | .IconInput-input input::placeholder { 42 | -webkit-text-fill-color: rgb(102, 102, 102); 43 | } 44 | .IconInput.active input:disabled { 45 | color: #5d5d5d; 46 | } 47 | .IconInput.active .IconInput-input { 48 | width: var(--add-new-width); 49 | opacity: 1; 50 | } 51 | 52 | .IconInput button { 53 | background: transparent; 54 | border: 0; 55 | color: white; 56 | display: block; 57 | height: 24px; 58 | opacity: 0.66; 59 | outline: 0; 60 | padding: 0; 61 | position: absolute; 62 | right: 0; 63 | transition: all ease 300ms; 64 | width: 24px; 65 | } 66 | .IconInput button:not(:disabled) { 67 | cursor: pointer; 68 | } 69 | .IconInput button:hover, 70 | .IconInput button:focus { 71 | opacity: 1; 72 | } 73 | .IconInput.stateful button { 74 | opacity: 0; 75 | } 76 | .IconInput.active button { 77 | pointer-events: none; 78 | color: gray; 79 | z-index: 1; 80 | } 81 | 82 | .IconInput svg.Spinner { 83 | animation: spinner linear 2s infinite; 84 | } 85 | .IconInput-status { 86 | color: gray; 87 | position: absolute; 88 | right: 0; 89 | top: 0; 90 | z-index: 1; 91 | pointer-events: none; 92 | width: 24px; 93 | height: 24px; 94 | } 95 | .IconInput-status svg { 96 | animation: zoom 300ms cubic-bezier(0.68, -0.55, 0.265, 1.55); 97 | } 98 | @keyframes spinner { 99 | from { 100 | transform: rotate(0deg); 101 | } 102 | to { 103 | transform: rotate(360deg); 104 | } 105 | } 106 | @keyframes zoom { 107 | 0% { 108 | opacity: 0; 109 | transform: scale(2); 110 | } 111 | 100% { 112 | opacity: 1; 113 | transform: scale(1); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/components/IconInput/IconInput.js: -------------------------------------------------------------------------------- 1 | import React, { createRef } from 'react'; 2 | import { Loader, Check, X } from 'react-feather'; 3 | import classNames from 'classnames'; 4 | import './IconInput.css'; 5 | 6 | class IconInput extends React.Component { 7 | state = { 8 | value: '', 9 | active: false, 10 | success: false, 11 | error: false 12 | }; 13 | 14 | inputRef = createRef(); 15 | 16 | componentWillReceiveProps(nextProps) { 17 | const finishedLoading = this.props.loading && !nextProps.loading; 18 | if (finishedLoading && nextProps.error) { 19 | this.setState({ success: false, error: true }); 20 | } 21 | if (finishedLoading && !nextProps.error && nextProps.success) { 22 | this.setState({ active: false, error: false, success: true, value: '' }); 23 | setTimeout(() => { 24 | this.setState({ success: false }); 25 | }, 1000); 26 | } 27 | } 28 | 29 | componentDidUpdate() { 30 | if (this.state.active) { 31 | this.inputRef.current.focus(); 32 | } 33 | } 34 | 35 | handleChange = e => { 36 | this.setState({ value: e.target.value, error: false }); 37 | }; 38 | 39 | handleSubmit = e => { 40 | if (this.props.loading) return; 41 | e.preventDefault(); 42 | this.props.onSubmit(this.state.value); 43 | }; 44 | 45 | setActive = () => { 46 | this.setState({ active: true }); 47 | }; 48 | 49 | setInactive = () => { 50 | if (this.state.value || this.state.loading || this.state.success) return; 51 | this.setState({ active: false, error: false }); 52 | }; 53 | 54 | render() { 55 | const { active, value, success, error } = this.state; 56 | const { loading } = this.props; 57 | const disabled = loading || success; 58 | return ( 59 |
66 | 75 |
76 | {!loading && error && } 77 | {!loading && success && } 78 | {loading && } 79 |
80 |
81 | 91 |
92 |
93 | ); 94 | } 95 | } 96 | 97 | export default IconInput; 98 | -------------------------------------------------------------------------------- /src/components/IconInput/README.md: -------------------------------------------------------------------------------- 1 | # `IconInput` 2 | 3 | A icon that transitions into a text input on focus with various interactive states such as loading, success, and error. 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/IconInput/stories/index.story.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import classNames from 'classnames'; 4 | import { UserPlus } from 'react-feather'; 5 | import IconInput from '../IconInput'; 6 | 7 | import './index.style.scss'; 8 | 9 | const Wrapper = ({ children, leftAligned }) => ( 10 |
11 |
17 | {children} 18 |
19 |
20 | ); 21 | 22 | class Example extends Component { 23 | state = { 24 | loading: false, 25 | success: false, 26 | error: false 27 | }; 28 | 29 | handleSubmit = () => { 30 | this.setState({ loading: true }, () => { 31 | setTimeout(() => { 32 | this.setState({ 33 | loading: false, 34 | [this.props.error ? 'error' : 'success']: true 35 | }); 36 | }, 750); 37 | }); 38 | }; 39 | 40 | render() { 41 | return ( 42 | 43 | 50 | 51 | 52 | 53 | ); 54 | } 55 | } 56 | 57 | storiesOf('IconInput', module) 58 | .add('Success State', () => , { 59 | notes: 60 | 'Click or focus the icon to transition into a text input. Type something' + 61 | 'and hit the enter key to submit.' 62 | }) 63 | .add('Error State', () => , { 64 | notes: 65 | 'Click or focus the icon to transition into a text input. Type something' + 66 | 'and hit the enter key to submit.' 67 | }) 68 | .add('Fixed Position (Left)', () => , { 69 | notes: 70 | 'Click or focus the icon to transition into a text input. Type something' + 71 | 'and hit the enter key to submit.' 72 | }); 73 | -------------------------------------------------------------------------------- /src/components/IconInput/stories/index.style.scss: -------------------------------------------------------------------------------- 1 | .FullScreen { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | display: flex; 6 | background: black; 7 | width: 100vw; 8 | height: 100vh; 9 | align-items: center; 10 | justify-content: center; 11 | 12 | &-content { 13 | display: flex; 14 | flex-direction: column; 15 | align-items: flex-end; 16 | min-width: 58px; 17 | max-width: 600px; 18 | border-radius: 4px; 19 | padding: 1em; 20 | 21 | &--leftAligned { 22 | padding: 4em; 23 | width: 100%; 24 | max-width: 100vw; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/TabSwitcher/README.md: -------------------------------------------------------------------------------- 1 | # `TabSwitcher` 2 | 3 | A tabs UI with an active tab indicator that re-positions and resizes itself based on the selected tab. 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/TabSwitcher/TabSwitcher.css: -------------------------------------------------------------------------------- 1 | .TabSwitcher { 2 | color: white; 3 | font-weight: 400; 4 | position: relative; 5 | white-space: nowrap; 6 | } 7 | 8 | .TabSwitcher-items { 9 | padding: 0; 10 | margin: 0; 11 | list-style: none; 12 | border-bottom: 2px #2c2c2c solid; 13 | } 14 | 15 | @keyframes slideInUp { 16 | from { 17 | transform: translate3d(0, 10px, 0); 18 | opacity: 0; 19 | } 20 | to { 21 | transform: translate3d(0, 0, 0); 22 | opacity: 1; 23 | } 24 | } 25 | 26 | .TabSwitcher-item { 27 | display: inline-block; 28 | position: relative; 29 | animation: slideInUp ease 0.5s; 30 | } 31 | 32 | .TabSwitcher-item a { 33 | backface-visibility: hidden; 34 | position: relative; 35 | transform: translateZ(0px); 36 | -webkit-transform: translateZ(0px); 37 | color: #9b9b9b; 38 | text-decoration: none; 39 | padding: 10px 0; 40 | display: inline-block; 41 | transition: all ease-in-out 0.15s; 42 | } 43 | 44 | .TabSwitcher-item.is-active a { 45 | color: white; 46 | } 47 | 48 | .TabSwitcher-item a:hover { 49 | color: white; 50 | } 51 | 52 | .TabSwitcher-item:not(:last-child) { 53 | margin-right: 1.5em; 54 | } 55 | 56 | .TabSwitcher-indicator { 57 | position: absolute; 58 | height: 2px; 59 | background: white; 60 | bottom: 0; 61 | transition: all cubic-bezier(0.175, 0.885, 0.32, 1.05) 0.3s; 62 | } 63 | -------------------------------------------------------------------------------- /src/components/TabSwitcher/TabSwitcher.js: -------------------------------------------------------------------------------- 1 | import React, { Component, createRef } from 'react'; 2 | import classNames from 'classnames'; 3 | import './TabSwitcher.css'; 4 | 5 | class TabSwitcher extends Component { 6 | containerRef = createRef(); 7 | 8 | indicatorRef = createRef(); 9 | 10 | activeItemRef = createRef(); 11 | 12 | state = { activeItem: 0 }; 13 | 14 | componentDidMount() { 15 | this.updateIndicatorPosition(); 16 | } 17 | 18 | componentDidUpdate(prevState) { 19 | if (prevState.activeItem !== this.state.activeItem) { 20 | this.updateIndicatorPosition(); 21 | } 22 | } 23 | 24 | handleItemClick = e => { 25 | if (e) { 26 | e.preventDefault(); 27 | } 28 | this.setState({ activeItem: Number(e.target.dataset.tabIndex) }); 29 | }; 30 | 31 | updateIndicatorPosition() { 32 | const { current: activeItem } = this.activeItemRef; 33 | const { current: container } = this.containerRef; 34 | const { current: indicator } = this.indicatorRef; 35 | const menuRect = container.getBoundingClientRect(); 36 | const activeItemRect = activeItem.getBoundingClientRect(); 37 | const x = Math.round(activeItemRect.left - menuRect.left); 38 | indicator.style.width = `${activeItemRect.width}px`; 39 | indicator.style.transform = `translateX(${x}px)`; 40 | } 41 | 42 | render() { 43 | const { items } = this.props; 44 | const { activeItem } = this.state; 45 | return ( 46 |
47 | 66 | 67 |
68 | ); 69 | } 70 | } 71 | 72 | export default TabSwitcher; 73 | -------------------------------------------------------------------------------- /src/components/TabSwitcher/stories/index.story.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import { storiesOf } from '@storybook/react'; 4 | 5 | import TabSwitcher from '../TabSwitcher'; 6 | 7 | const Wrapper = ({ children, leftAligned }) => ( 8 |
9 |
15 | {children} 16 |
17 |
18 | ); 19 | 20 | storiesOf('TabSwitcher', module).add( 21 | 'Example', 22 | () => ( 23 | 24 | 34 | 35 | ), 36 | { 37 | notes: 38 | 'Click on one of the tabs listed above. The active tab indicator will ' + 39 | 'slide to the selected tab position and resize itself accordingly.' 40 | } 41 | ); 42 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | padding: 0; 8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, 9 | Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | min-height: 100vh; 14 | background: #e8edf3; 15 | padding: 32px; 16 | } 17 | 18 | hr { 19 | border: 0; 20 | border-top: 1px solid rgba(0, 0, 0, 0.05); 21 | margin: 1em 0 0.5em; 22 | } 23 | --------------------------------------------------------------------------------