├── .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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------