├── .nvmrc ├── demo ├── .env ├── src │ ├── index.jsx │ ├── index.css │ ├── constants.js │ ├── App.jsx │ ├── builders.js │ └── utils.js ├── public │ └── index.html ├── ops │ └── deploy.sh └── package.json ├── src ├── scss │ ├── components │ │ ├── _tracks.scss │ │ ├── _track-keys.scss │ │ ├── _grid.scss │ │ ├── _track.scss │ │ ├── _timebar-key.scss │ │ ├── _timeline.scss │ │ ├── _layout.scss │ │ ├── _timebar.scss │ │ ├── _sidebar.scss │ │ ├── _controls.scss │ │ ├── _element.scss │ │ ├── _marker.scss │ │ └── _track-key.scss │ ├── _utils.scss │ └── style.scss ├── utils │ ├── raf.js │ ├── computedStyle.js │ ├── getGrid.js │ ├── events.js │ ├── getNumericPropertyValue.js │ ├── getMouseX.js │ ├── formatDate.js │ ├── classes.js │ ├── __tests__ │ │ ├── getMouseX.js │ │ ├── classes.js │ │ ├── getNumericPropertyValue.js │ │ ├── formatDate.js │ │ ├── getGrid.js │ │ └── time.js │ └── time.js ├── components │ ├── Sidebar │ │ ├── __tests__ │ │ │ ├── Body.jsx │ │ │ ├── index.jsx │ │ │ └── Header.jsx │ │ ├── Body.jsx │ │ ├── TrackKeys │ │ │ ├── index.jsx │ │ │ ├── __tests__ │ │ │ │ ├── index.jsx │ │ │ │ └── TrackKey.jsx │ │ │ └── TrackKey.jsx │ │ ├── index.jsx │ │ └── Header.jsx │ ├── Timeline │ │ ├── Timebar │ │ │ ├── __tests__ │ │ │ │ ├── index.jsx │ │ │ │ ├── Row.jsx │ │ │ │ └── Cell.jsx │ │ │ ├── Row.jsx │ │ │ ├── index.jsx │ │ │ └── Cell.jsx │ │ ├── Tracks │ │ │ ├── __tests__ │ │ │ │ ├── index.jsx │ │ │ │ ├── Element.jsx │ │ │ │ └── Track.jsx │ │ │ ├── index.jsx │ │ │ ├── Track.jsx │ │ │ └── Element.jsx │ │ ├── Body.jsx │ │ ├── Grid │ │ │ ├── index.jsx │ │ │ └── __tests__ │ │ │ │ └── index.jsx │ │ ├── Marker │ │ │ ├── index.jsx │ │ │ ├── Pointer.jsx │ │ │ ├── Now.jsx │ │ │ └── __tests__ │ │ │ │ ├── Pointer.jsx │ │ │ │ ├── Now.jsx │ │ │ │ └── index.jsx │ │ ├── __tests__ │ │ │ ├── Body.jsx │ │ │ ├── index.jsx │ │ │ └── Header.jsx │ │ ├── index.jsx │ │ └── Header.jsx │ ├── Controls │ │ ├── __tests__ │ │ │ ├── Toggle.jsx │ │ │ └── index.jsx │ │ ├── index.jsx │ │ ├── ZoomOut.jsx │ │ ├── ZoomIn.jsx │ │ └── Toggle.jsx │ ├── Elements │ │ ├── Basic.jsx │ │ └── __tests__ │ │ │ └── Basic.jsx │ └── Layout │ │ ├── __tests__ │ │ └── index.jsx │ │ └── index.jsx ├── __tests__ │ └── index.jsx └── index.jsx ├── .vscode ├── settings.json └── extensions.json ├── .eslintignore ├── .gitignore ├── .prettierrc.js ├── jestSetup.js ├── .babelrc ├── .eslintrc ├── .github └── workflows │ └── main.yml ├── LICENSE ├── README.md ├── .editorconfig └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /demo/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /src/scss/components/_tracks.scss: -------------------------------------------------------------------------------- 1 | .rt-tracks {} 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/raf.js: -------------------------------------------------------------------------------- 1 | export default cb => window.requestAnimationFrame(cb) 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | demo/node_modules 3 | build 4 | node_modules 5 | coverage 6 | -------------------------------------------------------------------------------- /src/utils/computedStyle.js: -------------------------------------------------------------------------------- 1 | export default (node, x) => window.getComputedStyle(node, x) 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | npm-debug.log 4 | coverage 5 | .yarnclean 6 | yarn-error.log 7 | yarn.lock 8 | build 9 | -------------------------------------------------------------------------------- /src/scss/components/_track-keys.scss: -------------------------------------------------------------------------------- 1 | .rt-track-keys { 2 | margin: 0; 3 | padding-left: 0; 4 | list-style: none; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/getGrid.js: -------------------------------------------------------------------------------- 1 | const getGrid = timebar => (timebar.find(row => row.useAsGrid) || {}).cells 2 | 3 | export default getGrid 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | semi: false, 4 | singleQuote: true, 5 | trailingComma: 'es5', 6 | arrowParens: 'avoid', 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/events.js: -------------------------------------------------------------------------------- 1 | export const addListener = (e, t) => window.addEventListener(e, t) 2 | export const removeListener = (e, t) => window.removeEventListener(e, t) 3 | -------------------------------------------------------------------------------- /src/utils/getNumericPropertyValue.js: -------------------------------------------------------------------------------- 1 | import computedStyle from './computedStyle' 2 | 3 | export default (node, prop) => parseInt(computedStyle(node).getPropertyValue(prop), 10) 4 | -------------------------------------------------------------------------------- /src/scss/_utils.scss: -------------------------------------------------------------------------------- 1 | .rt-visually-hidden { 2 | position: absolute; 3 | overflow: hidden; 4 | clip: rect(0 0 0 0); 5 | height: 1px; width: 1px; 6 | margin: -1px; padding: 0; border: 0; 7 | } -------------------------------------------------------------------------------- /jestSetup.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | 3 | import { configure } from 'enzyme' 4 | import Adapter from 'enzyme-adapter-react-16' 5 | 6 | configure({ adapter: new Adapter() }) 7 | -------------------------------------------------------------------------------- /demo/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import App from './App' 5 | 6 | import './index.css' 7 | 8 | ReactDOM.render(, document.getElementById('app')) 9 | -------------------------------------------------------------------------------- /src/utils/getMouseX.js: -------------------------------------------------------------------------------- 1 | const getMouseX = e => { 2 | const target = e.currentTarget 3 | const bounds = target.getBoundingClientRect() 4 | return e.clientX - bounds.left 5 | } 6 | 7 | export default getMouseX 8 | -------------------------------------------------------------------------------- /src/utils/formatDate.js: -------------------------------------------------------------------------------- 1 | const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] 2 | 3 | export const getMonth = date => monthNames[date.getMonth()] 4 | 5 | export const getDayMonth = date => `${date.getDate()} ${getMonth(date)}` 6 | -------------------------------------------------------------------------------- /src/utils/classes.js: -------------------------------------------------------------------------------- 1 | const classes = (base, additional) => { 2 | if (!additional) { 3 | return base 4 | } 5 | if (typeof additional === 'string') { 6 | return `${base} ${additional}` 7 | } 8 | return `${base} ${additional.join(' ')}` 9 | } 10 | 11 | export default classes 12 | -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React Timelines 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /src/scss/components/_grid.scss: -------------------------------------------------------------------------------- 1 | .rt-grid, 2 | .rt-grid__cell { 3 | position: absolute; 4 | top: 0; 5 | bottom: 0; 6 | } 7 | 8 | .rt-grid { 9 | left: 0; 10 | right: 0; 11 | } 12 | 13 | .rt-grid__cell { 14 | background: #fff; 15 | border-left: 1px solid $react-timelines-keyline-color; 16 | } 17 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react", 4 | [ 5 | "@babel/env", 6 | { 7 | "targets": { 8 | "browsers": ["last 2 versions", "ie 9-11"], 9 | "node": "current" 10 | } 11 | } 12 | ] 13 | ], 14 | "plugins": ["@babel/plugin-proposal-class-properties"] 15 | } 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "jest": true 6 | }, 7 | "extends": ["airbnb", "prettier", "prettier/react"], 8 | "rules": { 9 | "semi": ["error", "never"], 10 | "react/require-default-props": "off", 11 | "import/no-cycle": "off", 12 | "react/jsx-props-no-spreading": "off" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/__tests__/getMouseX.js: -------------------------------------------------------------------------------- 1 | import getMouseX from '../getMouseX' 2 | 3 | describe('getMouseX', () => { 4 | it('gets mouse x position for a given event', () => { 5 | const event = { 6 | clientX: 200, 7 | currentTarget: { 8 | getBoundingClientRect: () => ({ left: 10 }), 9 | }, 10 | } 11 | expect(getMouseX(event)).toBe(190) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /demo/ops/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Fail on the first error, rather than continuing 4 | set -e 5 | 6 | cd "$(dirname "$0")" 7 | 8 | COMMIT_EMAIL="${GITHUB_ACTOR}@users.noreply.github.com" 9 | COMMIT_NAME="${GITHUB_ACTOR}" 10 | 11 | git config --global user.email "${COMMIT_EMAIL}" 12 | git config --global user.name "${COMMIT_NAME}" 13 | 14 | npm install 15 | npm run build 16 | npm run deploy 17 | -------------------------------------------------------------------------------- /src/scss/components/_track.scss: -------------------------------------------------------------------------------- 1 | .rt-track {} 2 | 3 | .rt-track__elements { 4 | position: relative; 5 | height: $react-timelines-track-height + $react-timelines-border-width; 6 | border-bottom: $react-timelines-border-width solid $react-timelines-keyline-color; 7 | } 8 | 9 | .rt-track__element { 10 | position: absolute; 11 | height: $react-timelines-track-height - 2 * $react-timelines-element-spacing; 12 | top: $react-timelines-element-spacing; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Sidebar/__tests__/Body.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Body from '../Body' 5 | import TrackKeys from '../TrackKeys' 6 | 7 | describe('', () => { 8 | it('renders ', () => { 9 | const props = { 10 | tracks: [{}], 11 | toggleTrackOpen: jest.fn(), 12 | } 13 | const wrapper = shallow() 14 | expect(wrapper.find(TrackKeys).exists()).toBe(true) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/scss/components/_timebar-key.scss: -------------------------------------------------------------------------------- 1 | .rt-timebar-key { 2 | height: $react-timelines-header-row-height + $react-timelines-border-width; 3 | padding-right: $react-timelines-sidebar-key-indent-width; 4 | line-height: $react-timelines-header-row-height; 5 | text-align: right; 6 | font-weight: bold; 7 | border-bottom: 1px solid $react-timelines-sidebar-separator-color; 8 | 9 | &:last-child { 10 | border-bottom-color: $react-timelines-header-separator-color; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | border: 0; 4 | padding: 0; 5 | margin: 0; 6 | } 7 | 8 | body { 9 | background-color: #f0f0f0; 10 | } 11 | 12 | .app { 13 | font-family: sans-serif; 14 | padding-top: 48px; 15 | padding-bottom: 48px; 16 | } 17 | 18 | @media screen and (min-width: 1000px) { 19 | .app { 20 | padding-left: 32px; 21 | padding-right: 32px; 22 | } 23 | } 24 | 25 | .title { 26 | text-align: center; 27 | margin-bottom: 32px; 28 | } 29 | -------------------------------------------------------------------------------- /src/scss/components/_timeline.scss: -------------------------------------------------------------------------------- 1 | .rt-timeline { 2 | position: relative; 3 | overflow: hidden; 4 | } 5 | 6 | .rt-timeline__header {} 7 | 8 | .rt-timeline__header-scroll { 9 | overflow-x: auto; 10 | 11 | &::-webkit-scrollbar { 12 | display: none; 13 | } 14 | } 15 | 16 | .rt-timeline__header.rt-is-sticky { 17 | position: fixed; 18 | top: 0; 19 | z-index: 1; 20 | overflow: hidden; 21 | } 22 | 23 | .rt-timeline__body { 24 | position: relative; 25 | background: white; 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test and build for master 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Use Node.js 10 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 10.x 18 | - run: npm ci 19 | - run: npm test 20 | env: 21 | CI: true 22 | - run: npm run build 23 | -------------------------------------------------------------------------------- /src/components/Timeline/Timebar/__tests__/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Timebar from '..' 5 | import Row from '../Row' 6 | 7 | describe('', () => { 8 | it('renders components', () => { 9 | const props = { 10 | time: {}, 11 | rows: [ 12 | { id: '1', cells: [] }, 13 | { id: '1', cells: [] }, 14 | ], 15 | } 16 | const wrapper = shallow() 17 | expect(wrapper.find(Row)).toHaveLength(2) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/components/Timeline/Tracks/__tests__/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Tracks from '..' 5 | import Track from '../Track' 6 | 7 | describe('', () => { 8 | it('renders components', () => { 9 | const props = { 10 | time: {}, 11 | tracks: [ 12 | { id: '1', elements: [] }, 13 | { id: '2', elements: [] }, 14 | ], 15 | } 16 | const wrapper = shallow() 17 | expect(wrapper.find(Track)).toHaveLength(2) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/components/Timeline/Timebar/Row.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Cell from './Cell' 5 | 6 | const Row = ({ time, cells, style }) => ( 7 |
8 | {cells.map(cell => ( 9 | 10 | ))} 11 |
12 | ) 13 | 14 | Row.propTypes = { 15 | time: PropTypes.shape({}).isRequired, 16 | cells: PropTypes.arrayOf(PropTypes.shape({})).isRequired, 17 | style: PropTypes.shape({}), 18 | } 19 | 20 | export default Row 21 | -------------------------------------------------------------------------------- /src/components/Timeline/Timebar/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Row from './Row' 5 | 6 | const Timebar = ({ time, rows }) => ( 7 |
8 | {rows.map(({ id, title, cells, style }) => ( 9 | 10 | ))} 11 |
12 | ) 13 | 14 | Timebar.propTypes = { 15 | time: PropTypes.shape({}).isRequired, 16 | rows: PropTypes.arrayOf(PropTypes.shape({})).isRequired, 17 | } 18 | 19 | export default Timebar 20 | -------------------------------------------------------------------------------- /src/components/Sidebar/Body.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import TrackKeys from './TrackKeys' 5 | 6 | const Body = ({ tracks, toggleTrackOpen, clickTrackButton }) => ( 7 |
8 | 9 |
10 | ) 11 | 12 | Body.propTypes = { 13 | tracks: PropTypes.arrayOf(PropTypes.shape({})), 14 | toggleTrackOpen: PropTypes.func, 15 | clickTrackButton: PropTypes.func, 16 | } 17 | 18 | export default Body 19 | -------------------------------------------------------------------------------- /src/components/Timeline/Timebar/Cell.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const Cell = ({ time, title, start, end }) => ( 5 |
6 | {title} 7 |
8 | ) 9 | 10 | Cell.propTypes = { 11 | time: PropTypes.shape({ 12 | toStyleLeftAndWidth: PropTypes.func, 13 | }), 14 | title: PropTypes.string.isRequired, 15 | start: PropTypes.instanceOf(Date).isRequired, 16 | end: PropTypes.instanceOf(Date).isRequired, 17 | } 18 | 19 | export default Cell 20 | -------------------------------------------------------------------------------- /src/utils/__tests__/classes.js: -------------------------------------------------------------------------------- 1 | import classes from '../classes' 2 | 3 | describe('classes', () => { 4 | it('returns the base class', () => { 5 | expect(classes('foo')).toBe('foo') 6 | }) 7 | 8 | it('returns the base class plus additional class passed as string', () => { 9 | expect(classes('bar', 'hello')).toBe('bar hello') 10 | }) 11 | 12 | it('returns the base class plus additional class passed as array', () => { 13 | expect(classes('bar', ['hello'])).toBe('bar hello') 14 | expect(classes('foo', ['hello', 'world'])).toBe('foo hello world') 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/components/Sidebar/__tests__/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Sidebar from '..' 5 | import Header from '../Header' 6 | import Body from '../Body' 7 | 8 | describe('', () => { 9 | it('renders
and ', () => { 10 | const props = { 11 | timebar: [], 12 | tracks: [{}], 13 | toggleTrackOpen: jest.fn(), 14 | } 15 | const wrapper = shallow() 16 | expect(wrapper.find(Header).exists()).toBe(true) 17 | expect(wrapper.find(Body).exists()).toBe(true) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /demo/src/constants.js: -------------------------------------------------------------------------------- 1 | export const START_YEAR = 2020 2 | export const NUM_OF_YEARS = 3 3 | export const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] 4 | export const MONTHS_PER_YEAR = 12 5 | export const QUARTERS_PER_YEAR = 4 6 | export const MONTHS_PER_QUARTER = 3 7 | export const NUM_OF_MONTHS = NUM_OF_YEARS * MONTHS_PER_YEAR 8 | export const MAX_TRACK_START_GAP = 4 9 | export const MAX_ELEMENT_GAP = 8 10 | export const MAX_MONTH_SPAN = 8 11 | export const MIN_MONTH_SPAN = 2 12 | export const NUM_OF_TRACKS = 20 13 | export const MAX_NUM_OF_SUBTRACKS = 5 14 | -------------------------------------------------------------------------------- /src/components/Sidebar/TrackKeys/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import TrackKey from './TrackKey' 5 | 6 | const TrackKeys = ({ tracks, toggleOpen, clickTrackButton }) => ( 7 |
    8 | {tracks.map(track => ( 9 | 10 | ))} 11 |
12 | ) 13 | 14 | TrackKeys.propTypes = { 15 | tracks: PropTypes.arrayOf(PropTypes.shape({})), 16 | toggleOpen: PropTypes.func, 17 | clickTrackButton: PropTypes.func, 18 | } 19 | 20 | export default TrackKeys 21 | -------------------------------------------------------------------------------- /src/components/Timeline/Tracks/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Track from './Track' 5 | 6 | const Tracks = ({ time, tracks, clickElement }) => ( 7 |
8 | {tracks.map(({ id, elements, isOpen, tracks: children }) => ( 9 | 10 | ))} 11 |
12 | ) 13 | 14 | Tracks.propTypes = { 15 | time: PropTypes.shape({}).isRequired, 16 | tracks: PropTypes.arrayOf(PropTypes.shape({})), 17 | clickElement: PropTypes.func, 18 | } 19 | 20 | export default Tracks 21 | -------------------------------------------------------------------------------- /src/components/Timeline/Body.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Tracks from './Tracks' 5 | import Grid from './Grid' 6 | 7 | const Body = ({ time, grid, tracks, clickElement }) => ( 8 |
9 | {grid && } 10 | 11 |
12 | ) 13 | 14 | Body.propTypes = { 15 | time: PropTypes.shape({}).isRequired, 16 | grid: PropTypes.arrayOf(PropTypes.shape({})), 17 | tracks: PropTypes.arrayOf(PropTypes.shape({})), 18 | clickElement: PropTypes.func, 19 | } 20 | 21 | export default Body 22 | -------------------------------------------------------------------------------- /src/components/Sidebar/TrackKeys/__tests__/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import TrackKeys from '..' 5 | import TrackKey from '../TrackKey' 6 | 7 | describe('', () => { 8 | it('renders a for each track', () => { 9 | const props = { 10 | tracks: [ 11 | { 12 | id: '1', 13 | title: 'Track 1', 14 | }, 15 | { 16 | id: '2', 17 | title: 'Track 2', 18 | }, 19 | ], 20 | toggleOpen: jest.fn(), 21 | } 22 | const wrapper = shallow() 23 | expect(wrapper.find(TrackKey)).toHaveLength(2) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/utils/__tests__/getNumericPropertyValue.js: -------------------------------------------------------------------------------- 1 | import getNumericPropertyValue from '../getNumericPropertyValue' 2 | import computedStyle from '../computedStyle' 3 | 4 | jest.mock('../computedStyle') 5 | 6 | describe('getNumericPropertyValue', () => { 7 | it('returns the numeric portion within a property value of a DOM node', () => { 8 | computedStyle.mockImplementation(node => ({ 9 | getPropertyValue(prop) { 10 | return node.style[prop] 11 | }, 12 | })) 13 | const node = { 14 | style: { 15 | height: '100px', 16 | }, 17 | } 18 | const actual = getNumericPropertyValue(node, 'height') 19 | expect(actual).toBe(100) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/scss/components/_layout.scss: -------------------------------------------------------------------------------- 1 | .rt-layout { 2 | margin-left: -($react-timelines-sidebar-width - $react-timelines-sidebar-closed-offset); 3 | 4 | @media (min-width: $react-timelines-auto-open-breakpoint) { 5 | margin-left: 0; 6 | } 7 | 8 | &.rt-is-open { 9 | margin-left: 0; 10 | } 11 | } 12 | 13 | .rt-layout__side { 14 | position: relative; 15 | z-index: 2; 16 | display: inline-block; 17 | width: $react-timelines-sidebar-width; 18 | vertical-align: top; 19 | } 20 | 21 | .rt-layout__main { 22 | display: inline-block; 23 | width: calc(100% - #{$react-timelines-sidebar-width}); 24 | vertical-align: top; 25 | } 26 | 27 | .rt-layout__timeline { 28 | overflow-x: auto; 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Timeline/Grid/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const Grid = ({ time, grid }) => ( 5 |
6 | {grid.map(({ id, start, end }) => ( 7 |
8 | ))} 9 |
10 | ) 11 | 12 | Grid.propTypes = { 13 | time: PropTypes.shape({ 14 | toStyleLeftAndWidth: PropTypes.func, 15 | }).isRequired, 16 | grid: PropTypes.arrayOf( 17 | PropTypes.shape({ 18 | start: PropTypes.instanceOf(Date).isRequired, 19 | end: PropTypes.instanceOf(Date).isRequired, 20 | }) 21 | ).isRequired, 22 | } 23 | 24 | export default Grid 25 | -------------------------------------------------------------------------------- /src/scss/components/_timebar.scss: -------------------------------------------------------------------------------- 1 | .rt-timebar { 2 | background-color: $react-timelines-keyline-color; 3 | } 4 | 5 | .rt-timebar__row { 6 | position: relative; 7 | height: $react-timelines-header-row-height + $react-timelines-border-width; 8 | overflow: hidden; 9 | line-height: $react-timelines-header-row-height; 10 | border-bottom: $react-timelines-border-width solid $react-timelines-keyline-color; 11 | &:last-child { 12 | border-bottom-color: $react-timelines-header-separator-color; 13 | } 14 | } 15 | 16 | .rt-timebar__cell { 17 | position: absolute; 18 | text-align: center; 19 | 20 | background-color: $react-timelines-timebar-cell-background-color; 21 | border-left: 1px solid $react-timelines-keyline-color; 22 | } 23 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react": "^16.13.1", 7 | "react-dom": "^16.13.1", 8 | "react-scripts": "^3.4.1", 9 | "react-timelines": "^2.6.1" 10 | }, 11 | "scripts": { 12 | "start": "react-scripts start", 13 | "build": "react-scripts build", 14 | "deploy": "gh-pages -d ./build" 15 | }, 16 | "devDependencies": { 17 | "gh-pages": "^2.2.0" 18 | }, 19 | "browserslist": { 20 | "production": [ 21 | ">0.2%", 22 | "not dead", 23 | "not op_mini all" 24 | ], 25 | "development": [ 26 | "last 1 chrome version", 27 | "last 1 firefox version", 28 | "last 1 safari version" 29 | ] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/Timeline/Timebar/__tests__/Row.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Row from '../Row' 5 | import Cell from '../Cell' 6 | 7 | describe('', () => { 8 | it('renders the components', () => { 9 | const props = { 10 | time: {}, 11 | cells: [ 12 | { 13 | title: 'test', 14 | start: new Date(), 15 | end: new Date(), 16 | id: '1', 17 | }, 18 | { 19 | title: 'test', 20 | start: new Date(), 21 | end: new Date(), 22 | id: '2', 23 | }, 24 | ], 25 | } 26 | const wrapper = shallow() 27 | expect(wrapper.find(Cell)).toHaveLength(2) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/components/Timeline/Marker/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const Marker = ({ x, modifier, children, visible, highlighted }) => ( 5 |
11 |
12 |
{children}
13 |
14 |
15 | ) 16 | 17 | Marker.propTypes = { 18 | x: PropTypes.number.isRequired, 19 | modifier: PropTypes.string.isRequired, 20 | visible: PropTypes.bool, 21 | highlighted: PropTypes.bool, 22 | children: PropTypes.node, 23 | } 24 | 25 | export default Marker 26 | -------------------------------------------------------------------------------- /src/scss/components/_sidebar.scss: -------------------------------------------------------------------------------- 1 | .rt-sidebar { 2 | background-color: $react-timelines-sidebar-background-color; 3 | box-shadow: 10px 0 10px -5px rgba(12, 12, 12, 0.1); 4 | } 5 | 6 | .rt-sidebar__header { 7 | background-color: $react-timelines-sidebar-background-color; 8 | } 9 | 10 | .rt-sidebar__header.rt-is-sticky { 11 | position: fixed; 12 | top: 0; 13 | z-index: 2; 14 | direction: rtl; 15 | margin-left: ($react-timelines-sidebar-width - $react-timelines-sidebar-closed-offset); 16 | 17 | @media (min-width: $react-timelines-auto-open-breakpoint) { 18 | margin-left: 0; 19 | direction: ltr; 20 | } 21 | } 22 | 23 | .rt-layout.rt-is-open .rt-sidebar__header.rt-is-sticky { 24 | margin-left: 0; 25 | direction: ltr; 26 | } 27 | 28 | .rt-sidebar__body {} 29 | -------------------------------------------------------------------------------- /src/utils/__tests__/formatDate.js: -------------------------------------------------------------------------------- 1 | import { getMonth, getDayMonth } from '../formatDate' 2 | 3 | describe('formatDate', () => { 4 | describe('getMonth', () => { 5 | it('returns the current month name for a given date', () => { 6 | expect(getMonth(new Date('2017-01-01'))).toEqual('Jan') 7 | expect(getMonth(new Date('2017-02-01'))).toEqual('Feb') 8 | expect(getMonth(new Date('2017-11-01'))).toEqual('Nov') 9 | }) 10 | }) 11 | 12 | describe('getDayMonth', () => { 13 | it('returns the current day and month', () => { 14 | expect(getDayMonth(new Date('2017-02-01'))).toEqual('1 Feb') 15 | expect(getDayMonth(new Date('2017-05-20'))).toEqual('20 May') 16 | expect(getDayMonth(new Date('2017-12-20'))).toEqual('20 Dec') 17 | }) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/components/Timeline/Marker/Pointer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import { getDayMonth } from '../../../utils/formatDate' 5 | import Marker from '.' 6 | 7 | const PointerMarker = ({ time, date, visible, highlighted }) => ( 8 | 9 |
10 |
11 | {getDayMonth(date)} 12 |
13 |
14 |
15 | ) 16 | 17 | PointerMarker.propTypes = { 18 | time: PropTypes.shape({ 19 | toX: PropTypes.func.isRequired, 20 | }).isRequired, 21 | date: PropTypes.instanceOf(Date).isRequired, 22 | visible: PropTypes.bool, 23 | highlighted: PropTypes.bool, 24 | } 25 | 26 | export default PointerMarker 27 | -------------------------------------------------------------------------------- /src/components/Controls/__tests__/Toggle.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Toggle from '../Toggle' 5 | 6 | describe('', () => { 7 | it('displays "Close" when open', () => { 8 | const wrapper = shallow() 9 | expect(wrapper.text()).toMatch('Close') 10 | }) 11 | 12 | it('displays "Open" when closed', () => { 13 | const wrapper = shallow() 14 | expect(wrapper.text()).toMatch('Open') 15 | }) 16 | 17 | it('calls "toggleOpen()" when clicked', () => { 18 | const toggleOpen = jest.fn() 19 | const wrapper = shallow() 20 | wrapper.simulate('click') 21 | expect(toggleOpen).toBeCalled() 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /src/components/Timeline/Marker/Now.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Marker from '.' 5 | import { getDayMonth } from '../../../utils/formatDate' 6 | 7 | class NowMarker extends PureComponent { 8 | render() { 9 | const { now, time, visible } = this.props 10 | return ( 11 | 12 |
13 |
Today
14 | {getDayMonth(now)} 15 |
16 |
17 | ) 18 | } 19 | } 20 | 21 | NowMarker.propTypes = { 22 | time: PropTypes.shape({ 23 | toX: PropTypes.func.isRequired, 24 | }).isRequired, 25 | visible: PropTypes.bool.isRequired, 26 | now: PropTypes.instanceOf(Date).isRequired, 27 | } 28 | 29 | export default NowMarker 30 | -------------------------------------------------------------------------------- /src/utils/__tests__/getGrid.js: -------------------------------------------------------------------------------- 1 | import getGrid from '../getGrid' 2 | 3 | describe('getGrid', () => { 4 | it('returns the cells from the first timebar row that has "useAsGrid" set to true', () => { 5 | const timebar = [ 6 | { 7 | cells: [{ id: 'row-1-cell-1' }], 8 | }, 9 | { 10 | useAsGrid: true, 11 | cells: [{ id: 'row-2-cell-1' }], 12 | }, 13 | { 14 | useAsGrid: true, 15 | cells: [{ id: 'row-3-cell-1' }], 16 | }, 17 | ] 18 | const actual = getGrid(timebar) 19 | const expected = [{ id: 'row-2-cell-1' }] 20 | expect(actual).toEqual(expected) 21 | }) 22 | 23 | it('returns "undefined" if none of the rows have "useAsGrid" set to true', () => { 24 | const timebar = [{ cells: [] }] 25 | const actual = getGrid(timebar) 26 | expect(actual).toEqual(undefined) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/components/Sidebar/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Header from './Header' 5 | import Body from './Body' 6 | 7 | const Sidebar = ({ timebar, tracks, toggleTrackOpen, sticky, clickTrackButton }) => ( 8 |
9 |
10 | 11 |
12 | ) 13 | 14 | Sidebar.propTypes = { 15 | timebar: PropTypes.arrayOf( 16 | PropTypes.shape({ 17 | id: PropTypes.string.isRequired, 18 | title: PropTypes.string, 19 | }).isRequired 20 | ).isRequired, 21 | tracks: PropTypes.arrayOf(PropTypes.shape({})), 22 | toggleTrackOpen: PropTypes.func, 23 | sticky: PropTypes.shape({}), 24 | clickTrackButton: PropTypes.func, 25 | } 26 | 27 | export default Sidebar 28 | -------------------------------------------------------------------------------- /src/components/Timeline/Marker/__tests__/Pointer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import PointerMarker from '../Pointer' 5 | import Marker from '..' 6 | import createTime from '../../../../utils/time' 7 | 8 | const time = createTime({ 9 | start: new Date('2017-01-01'), 10 | end: new Date('2018-01-01'), 11 | zoom: 1, 12 | }) 13 | 14 | describe('', () => { 15 | const props = { 16 | time, 17 | date: new Date('2017-01-02'), 18 | visible: false, 19 | highlighted: false, 20 | } 21 | 22 | it('renders passing down horizontal position', () => { 23 | const wrapper = shallow() 24 | expect(wrapper.find(Marker).prop('x')).toBe(1) 25 | }) 26 | 27 | it('renders "text"', () => { 28 | const wrapper = shallow() 29 | expect(wrapper.find('strong').text()).toBe('2 Jan') 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/components/Timeline/Timebar/__tests__/Cell.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Cell from '../Cell' 5 | import createTime from '../../../../utils/time' 6 | 7 | describe('', () => { 8 | const props = { 9 | time: createTime({ 10 | start: new Date('2016-01-01'), 11 | end: new Date('2019-01-01'), 12 | zoom: 1, 13 | }), 14 | title: 'test', 15 | start: new Date('2017-01-01'), 16 | end: new Date('2018-01-01'), 17 | } 18 | 19 | it('renders the "title"', () => { 20 | const wrapper = shallow() 21 | expect(wrapper.text()).toBe('test') 22 | }) 23 | 24 | it('renders with a calculated width and left position based on "start" and "end"', () => { 25 | const wrapper = shallow() 26 | expect(wrapper.prop('style')).toEqual({ 27 | left: '366px', 28 | width: '365px', 29 | }) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/components/Controls/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Toggle from './Toggle' 5 | import ZoomIn from './ZoomIn' 6 | import ZoomOut from './ZoomOut' 7 | 8 | const Controls = ({ isOpen = true, toggleOpen, zoomIn, zoomOut, zoom, zoomMin, zoomMax }) => ( 9 |
10 |
11 | {toggleOpen && } 12 | {zoomIn && } 13 | {zoomOut && } 14 |
15 |
16 | ) 17 | 18 | Controls.propTypes = { 19 | zoom: PropTypes.number.isRequired, 20 | isOpen: PropTypes.bool, 21 | toggleOpen: PropTypes.func, 22 | zoomIn: PropTypes.func, 23 | zoomOut: PropTypes.func, 24 | zoomMin: PropTypes.number, 25 | zoomMax: PropTypes.number, 26 | } 27 | 28 | export default Controls 29 | -------------------------------------------------------------------------------- /src/components/Timeline/__tests__/Body.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Body from '../Body' 5 | import Tracks from '../Tracks' 6 | import Grid from '../Grid' 7 | 8 | const defaultProps = { 9 | time: {}, 10 | grid: [], 11 | tracks: [], 12 | } 13 | 14 | describe('', () => { 15 | it('renders ', () => { 16 | const wrapper = shallow() 17 | expect(wrapper.find(Tracks).exists()).toBe(true) 18 | }) 19 | 20 | it('renders if grid prop exists', () => { 21 | const wrapper = shallow() 22 | expect(wrapper.find(Grid).exists()).toBe(true) 23 | }) 24 | 25 | it('does not render if grid prop does not exist', () => { 26 | const props = { 27 | ...defaultProps, 28 | grid: undefined, 29 | } 30 | const wrapper = shallow() 31 | expect(wrapper.find(Grid).exists()).toBe(false) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /src/scss/components/_controls.scss: -------------------------------------------------------------------------------- 1 | .rt-controls { 2 | display: inline-block; 3 | padding: 8px; 4 | margin: 0 0 $react-timelines-spacing 0; 5 | background-color: #fff; 6 | } 7 | 8 | .rt-controls__button { 9 | display: inline-block; 10 | width: $react-timelines-button-size; 11 | height: $react-timelines-button-size; 12 | overflow: hidden; 13 | background-color: $react-timelines-button-background-color; 14 | color: transparent; 15 | white-space: nowrap; 16 | padding: $react-timelines-spacing; 17 | outline: none; 18 | 19 | &:last-child { 20 | margin-right: 0; 21 | } 22 | 23 | &:hover { 24 | background-color: $react-timelines-button-background-color-hover; 25 | } 26 | 27 | &:focus, 28 | &:active { 29 | background-color: $react-timelines-button-background-color-hover; 30 | } 31 | } 32 | 33 | .rt-controls__button[disabled] { 34 | opacity: 0.5; 35 | } 36 | 37 | .rt-controls__button--toggle { 38 | @media (min-width: $react-timelines-auto-open-breakpoint) { 39 | display: none; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/Sidebar/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const Header = ({ timebar, sticky: { isSticky, sidebarWidth, headerHeight } = {} }) => ( 5 |
6 |
10 | {timebar.map(({ id, title }) => ( 11 |
12 | {title} 13 |
14 | ))} 15 |
16 |
17 | ) 18 | 19 | Header.propTypes = { 20 | sticky: PropTypes.shape({ 21 | isSticky: PropTypes.bool.isRequired, 22 | headerHeight: PropTypes.number.isRequired, 23 | sidebarWidth: PropTypes.number.isRequired, 24 | }), 25 | timebar: PropTypes.arrayOf( 26 | PropTypes.shape({ 27 | id: PropTypes.string.isRequired, 28 | title: PropTypes.string, 29 | }).isRequired 30 | ).isRequired, 31 | } 32 | 33 | export default Header 34 | -------------------------------------------------------------------------------- /src/components/Timeline/Tracks/Track.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Tracks from '.' 5 | import Element from './Element' 6 | 7 | const Track = ({ time, elements, isOpen, tracks, clickElement }) => ( 8 |
9 |
10 | {elements 11 | .filter(({ start, end }) => end > start) 12 | .map(element => ( 13 | 14 | ))} 15 |
16 | {isOpen && tracks && tracks.length > 0 && } 17 |
18 | ) 19 | 20 | Track.propTypes = { 21 | time: PropTypes.shape({}).isRequired, 22 | isOpen: PropTypes.bool, 23 | elements: PropTypes.arrayOf(PropTypes.shape({})).isRequired, 24 | tracks: PropTypes.arrayOf(PropTypes.shape({})), 25 | clickElement: PropTypes.func, 26 | } 27 | 28 | Track.defaultProps = { 29 | clickElement: undefined, 30 | } 31 | 32 | export default Track 33 | -------------------------------------------------------------------------------- /src/components/Controls/ZoomOut.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const ZoomOut = ({ zoom, zoomMin, zoomOut }) => ( 5 | 20 | ) 21 | 22 | ZoomOut.propTypes = { 23 | zoom: PropTypes.number.isRequired, 24 | zoomMin: PropTypes.number.isRequired, 25 | zoomOut: PropTypes.func.isRequired, 26 | } 27 | 28 | export default ZoomOut 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sainsbury's PLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/Controls/ZoomIn.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const ZoomIn = ({ zoom, zoomMax, zoomIn }) => ( 5 | 21 | ) 22 | 23 | ZoomIn.propTypes = { 24 | zoom: PropTypes.number.isRequired, 25 | zoomMax: PropTypes.number.isRequired, 26 | zoomIn: PropTypes.func.isRequired, 27 | } 28 | 29 | export default ZoomIn 30 | -------------------------------------------------------------------------------- /src/components/Timeline/Marker/__tests__/Now.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import NowMarker from '../Now' 5 | import Marker from '..' 6 | import createTime from '../../../../utils/time' 7 | 8 | const createProps = ({ 9 | now = new Date(), 10 | time = createTime({ 11 | start: new Date(), 12 | end: new Date(), 13 | zoom: 1, 14 | }), 15 | visible = true, 16 | }) => ({ now, time, visible }) 17 | 18 | describe('', () => { 19 | it('renders whose position is calculated from the time', () => { 20 | const props = createProps({ 21 | now: new Date('2017-01-01'), 22 | time: createTime({ 23 | start: new Date('2016-01-01'), 24 | end: new Date('2018-01-10'), 25 | zoom: 1, 26 | }), 27 | }) 28 | const wrapper = shallow() 29 | expect(wrapper.find(Marker).prop('x')).toBe(366) 30 | }) 31 | 32 | it('renders the formatted date for "now"', () => { 33 | const props = createProps({ 34 | now: new Date('2017-04-10'), 35 | }) 36 | const wrapper = shallow() 37 | expect(wrapper.find('strong').text()).toBe('10 Apr') 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Timelines 2 | 3 | [**Demo**](https://jsainsburyplc.github.io/react-timelines/) 4 | 5 | ## Install 6 | 7 | ```sh 8 | # with npm 9 | npm install react-timelines 10 | 11 | # or with Yarn 12 | yarn add react-timelines 13 | ``` 14 | 15 | ## Use 16 | 17 | ```js 18 | import Timeline from 'react-timelines' 19 | 20 | const MyWidget = () => 21 | 22 | export default MyWidget 23 | ``` 24 | 25 | ## Styling 26 | 27 | ### Using Webpack 28 | 29 | Using Webpack with CSS loader, add the following: 30 | 31 | ```js 32 | import 'react-timelines/lib/css/style.css' 33 | ``` 34 | 35 | ### Using Sass (SCSS) 36 | 37 | Using Sass you can configure the timeline with variables: 38 | 39 | ```scss 40 | $react-timelines-font-family: MaryAnn; 41 | $react-timelines-sidebar-width: 320px; 42 | 43 | @import '~/react-timelines/src/scss/style'; 44 | ``` 45 | 46 | ### Without build tools 47 | 48 | Create a CSS file with the contents of `react-timelines/lib/css/style.css` and include it in `` 49 | 50 | ## Development 51 | 52 | ```sh 53 | npm install 54 | npm run watch 55 | ``` 56 | 57 | This library is developed using VSCode - opening it in VSCode will recommend extensions, and enable linting and auto-formatting of code. 58 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent coding styles between different editors and IDEs. 2 | # Requires EditorConfig JetBrains Plugin - http://github.com/editorconfig/editorconfig-jetbrains 3 | 4 | # Set this file as the topmost .editorconfig 5 | # (multiple files can be used, and are applied starting from current document location) 6 | root = true 7 | 8 | # Use bracketed regexp to target specific file types or file locations 9 | [*.{js,json}] 10 | 11 | # Use hard or soft tabs ["tab", "space"] 12 | indent_style = space 13 | 14 | # Size of a single indent [an integer, "tab"] 15 | indent_size = tab 16 | 17 | # Number of columns representing a tab character [an integer] 18 | tab_width = 2 19 | 20 | # Line breaks representation ["lf", "cr", "crlf"] 21 | end_of_line = lf 22 | 23 | # ["latin1", "utf-8", "utf-16be", "utf-16le"] 24 | charset = utf-8 25 | 26 | # Remove any whitespace characters preceding newline characters ["true", "false"] 27 | trim_trailing_whitespace = true 28 | 29 | # Ensure file ends with a newline when saving ["true", "false"] 30 | insert_final_newline = true 31 | 32 | # Markdown files 33 | [*.md] 34 | 35 | # Trailing whitespaces are significant in Markdown. 36 | trim_trailing_whitespace = false 37 | -------------------------------------------------------------------------------- /src/components/Controls/Toggle.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const CloseSvg = () => ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | ) 12 | 13 | const OpenSvg = () => ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ) 22 | 23 | const Toggle = ({ toggleOpen, isOpen }) => ( 24 | 28 | ) 29 | 30 | Toggle.propTypes = { 31 | toggleOpen: PropTypes.func.isRequired, 32 | isOpen: PropTypes.bool.isRequired, 33 | } 34 | 35 | export default Toggle 36 | -------------------------------------------------------------------------------- /src/utils/time.js: -------------------------------------------------------------------------------- 1 | const MILLIS_IN_A_DAY = 24 * 60 * 60 * 1000 2 | 3 | const create = ({ start, end, zoom, viewportWidth = 0, minWidth = 0 }) => { 4 | const duration = end - start 5 | 6 | const days = duration / MILLIS_IN_A_DAY 7 | const daysZoomWidth = days * zoom 8 | 9 | let timelineWidth 10 | 11 | if (daysZoomWidth > viewportWidth) { 12 | timelineWidth = daysZoomWidth 13 | } else { 14 | timelineWidth = viewportWidth 15 | } 16 | 17 | if (timelineWidth < minWidth) { 18 | timelineWidth = minWidth 19 | } 20 | 21 | const timelineWidthStyle = `${timelineWidth}px` 22 | 23 | const toX = from => { 24 | const value = (from - start) / duration 25 | return Math.round(value * timelineWidth) 26 | } 27 | 28 | const toStyleLeft = from => ({ 29 | left: `${toX(from)}px`, 30 | }) 31 | 32 | const toStyleLeftAndWidth = (from, to) => { 33 | const left = toX(from) 34 | return { 35 | left: `${left}px`, 36 | width: `${toX(to) - left}px`, 37 | } 38 | } 39 | 40 | const fromX = x => new Date(start.getTime() + (x / timelineWidth) * duration) 41 | 42 | return { 43 | timelineWidth, 44 | timelineWidthStyle, 45 | start, 46 | end, 47 | zoom, 48 | toX, 49 | toStyleLeft, 50 | toStyleLeftAndWidth, 51 | fromX, 52 | } 53 | } 54 | 55 | export default create 56 | -------------------------------------------------------------------------------- /src/scss/components/_element.scss: -------------------------------------------------------------------------------- 1 | .rt-element { 2 | $height: $react-timelines-track-height - 2 * $react-timelines-element-spacing; 3 | 4 | position: relative; 5 | height: $height; 6 | line-height: $height; 7 | background: #06f; 8 | color: #fff; 9 | text-align: center; 10 | } 11 | 12 | .rt-element__content { 13 | padding: 0 10px; 14 | overflow: hidden; 15 | white-space: nowrap; 16 | font-weight: bold; 17 | text-overflow: ellipsis; 18 | } 19 | 20 | .rt-element__tooltip { 21 | position: absolute; 22 | bottom: 100%; 23 | left: 50%; 24 | z-index: 2; 25 | padding: 10px; 26 | line-height: 1.3; 27 | white-space: nowrap; 28 | text-align: left; 29 | background: $react-timelines-text-color; 30 | color: white; 31 | transform: translateX(-50%) scale(0); 32 | pointer-events: none; 33 | 34 | &::before { 35 | $size: 6px; 36 | position: absolute; 37 | top: 100%; 38 | left: 50%; 39 | border-top: $size solid $react-timelines-text-color; 40 | border-right: $size solid transparent; 41 | border-left: $size solid transparent; 42 | transform: translateX(-50%); 43 | content: ' '; 44 | } 45 | } 46 | 47 | .rt-element:hover > .rt-element__tooltip, 48 | .rt-element:focus > .rt-element__tooltip { 49 | $delay: 0.3s; 50 | transform: translateX(-50%) scale(1); 51 | transition: transform 0s $delay; 52 | } 53 | -------------------------------------------------------------------------------- /src/components/Timeline/Grid/__tests__/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Grid from '..' 5 | import createTime from '../../../../utils/time' 6 | 7 | const time = createTime({ 8 | start: new Date('2017-01-01T00:00:00.000Z'), 9 | end: new Date('2017-01-30T00:00:00.000Z'), 10 | zoom: 10, // 10px === 1 day 11 | }) 12 | 13 | const grid = [ 14 | { 15 | id: '1', 16 | start: new Date('2017-01-01T00:00:00.000Z'), 17 | end: new Date('2017-01-06T00:00:00.000Z'), 18 | }, 19 | { 20 | id: '2', 21 | start: new Date('2017-01-06T00:00:00.000Z'), 22 | end: new Date('2017-01-11T00:00:00.000Z'), 23 | }, 24 | { 25 | id: '3', 26 | start: new Date('2017-01-11T00:00:00.000Z'), 27 | end: new Date('2017-01-16T00:00:00.000Z'), 28 | }, 29 | ] 30 | 31 | describe('', () => { 32 | it('renders grid cells, the styling of each repesenting the start and end dates in the grid prop', () => { 33 | const getItemStyle = (wrapper, i) => wrapper.find('.rt-grid__cell').get(i).props.style 34 | 35 | const wrapper = shallow() 36 | expect(getItemStyle(wrapper, 0)).toEqual({ left: '0px', width: '50px' }) 37 | expect(getItemStyle(wrapper, 1)).toEqual({ left: '50px', width: '50px' }) 38 | expect(getItemStyle(wrapper, 2)).toEqual({ left: '100px', width: '50px' }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/components/Timeline/Tracks/Element.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */ 2 | import React from 'react' 3 | import PropTypes from 'prop-types' 4 | 5 | import BasicElement from '../../Elements/Basic' 6 | 7 | const Element = props => { 8 | const { time, style, title, start, end, classes, dataSet, tooltip, clickElement } = props 9 | 10 | const handleClick = () => { 11 | clickElement(props) 12 | } 13 | const elementStyle = { 14 | ...time.toStyleLeftAndWidth(start, end), 15 | ...(clickElement ? { cursor: 'pointer' } : {}), 16 | } 17 | 18 | return ( 19 |
20 | 29 |
30 | ) 31 | } 32 | 33 | Element.propTypes = { 34 | time: PropTypes.shape({ 35 | toStyleLeftAndWidth: PropTypes.func, 36 | }).isRequired, 37 | style: PropTypes.shape({}), 38 | classes: PropTypes.arrayOf(PropTypes.string.isRequired), 39 | dataSet: PropTypes.shape({}), 40 | title: PropTypes.string, 41 | start: PropTypes.instanceOf(Date).isRequired, 42 | end: PropTypes.instanceOf(Date).isRequired, 43 | tooltip: PropTypes.string, 44 | clickElement: PropTypes.func, 45 | } 46 | 47 | Element.defaultTypes = { 48 | clickElement: undefined, 49 | } 50 | 51 | export default Element 52 | -------------------------------------------------------------------------------- /src/components/Elements/Basic.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { getDayMonth } from '../../utils/formatDate' 4 | import createClasses from '../../utils/classes' 5 | 6 | const buildDataAttributes = (attributes = {}) => { 7 | const value = {} 8 | Object.keys(attributes).forEach(name => { 9 | value[`data-${name}`] = attributes[name] 10 | }) 11 | return value 12 | } 13 | 14 | const Basic = ({ title, start, end, style, classes, dataSet, tooltip }) => ( 15 |
16 | 19 |
20 | {tooltip ? ( 21 | // eslint-disable-next-line react/no-danger 22 |
') }} /> 23 | ) : ( 24 |
25 |
{title}
26 |
27 | Start {getDayMonth(start)} 28 |
29 |
30 | End {getDayMonth(end)} 31 |
32 |
33 | )} 34 |
35 |
36 | ) 37 | 38 | Basic.propTypes = { 39 | title: PropTypes.string.isRequired, 40 | start: PropTypes.instanceOf(Date).isRequired, 41 | end: PropTypes.instanceOf(Date).isRequired, 42 | style: PropTypes.shape({}), 43 | classes: PropTypes.arrayOf(PropTypes.string.isRequired), 44 | dataSet: PropTypes.shape({}), 45 | tooltip: PropTypes.string, 46 | } 47 | 48 | export default Basic 49 | -------------------------------------------------------------------------------- /src/scss/components/_marker.scss: -------------------------------------------------------------------------------- 1 | .rt-marker { 2 | position: absolute; 3 | z-index: 2; 4 | top: $react-timelines-header-row-height; 5 | bottom: 0; 6 | margin-left: -($react-timelines-marker-line-width / 2); 7 | border-left: $react-timelines-marker-line-width solid; 8 | opacity: 0; 9 | pointer-events: none; 10 | } 11 | 12 | .rt-marker.rt-is-visible { 13 | opacity: 1; 14 | } 15 | 16 | 17 | .rt-marker--now { 18 | color: $react-timelines-marker-now-background-color; 19 | border-color: rgba( 20 | $react-timelines-marker-now-background-color, 21 | $react-timelines-marker-line-transparency 22 | ); 23 | } 24 | 25 | .rt-marker--pointer { 26 | color: $react-timelines-marker-pointer-background-color; 27 | border-color: rgba( 28 | $react-timelines-marker-pointer-background-color, 29 | $react-timelines-marker-line-transparency 30 | ); 31 | } 32 | 33 | .rt-marker--pointer.rt-is-highlighted { 34 | color: darken($react-timelines-marker-pointer-background-color, 25%); 35 | border-color: rgba(darken($react-timelines-marker-pointer-background-color, 1), 25%); 36 | } 37 | 38 | .rt-marker__label { 39 | position: absolute; 40 | bottom: 100%; 41 | left: 50%; 42 | display: table; 43 | min-width: 70px; 44 | height: $react-timelines-header-row-height; 45 | padding: 0 10px; 46 | line-height: 1.1; 47 | text-align: center; 48 | background: currentColor; 49 | transform: translateX(-50%); 50 | font-size: 16px; 51 | font-weight: bold; 52 | 53 | &::before { 54 | $size: 6px; 55 | 56 | position: absolute; 57 | top: 100%; 58 | left: 50%; 59 | margin-left: -$size; 60 | transform: translateX(-($react-timelines-marker-line-width / 2)); 61 | border-left: $size solid transparent; 62 | border-right: $size solid transparent; 63 | border-top: $size solid currentColor; 64 | content: ' '; 65 | } 66 | } 67 | 68 | .rt-marker__content { 69 | display: table-cell; 70 | vertical-align: middle; 71 | white-space: nowrap; 72 | color: white; 73 | } 74 | -------------------------------------------------------------------------------- /src/components/Sidebar/TrackKeys/TrackKey.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import TrackKeys from '.' 5 | 6 | const TrackKey = ({ track, toggleOpen, clickTrackButton }) => { 7 | const { title, tracks, isOpen, hasButton, sideComponent } = track 8 | const isExpandable = isOpen !== undefined 9 | 10 | const buildSideComponent = () => { 11 | if (sideComponent) { 12 | return React.cloneElement(sideComponent) 13 | } 14 | if (hasButton && clickTrackButton) { 15 | const handleClick = () => clickTrackButton(track) 16 | // eslint-disable-next-line jsx-a11y/control-has-associated-label 17 | return 35 | )} 36 | {title} 37 | {buildSideComponent()} 38 |
39 | {isOpen && tracks && tracks.length > 0 && } 40 | 41 | ) 42 | } 43 | 44 | TrackKey.propTypes = { 45 | track: PropTypes.shape({ 46 | title: PropTypes.string.isRequired, 47 | tracks: PropTypes.arrayOf(PropTypes.shape({})), 48 | isOpen: PropTypes.bool, 49 | hasButton: PropTypes.bool, 50 | sideComponent: PropTypes.element, 51 | }), 52 | toggleOpen: PropTypes.func, 53 | clickTrackButton: PropTypes.func, 54 | } 55 | 56 | TrackKey.defaultProps = { 57 | clickTrackButton: undefined, 58 | } 59 | 60 | export default TrackKey 61 | -------------------------------------------------------------------------------- /src/components/Timeline/Marker/__tests__/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Marker from '..' 5 | 6 | const createProps = ({ x = 0, modifier = '', children =
test
, visible = false, highlighted = false }) => ({ 7 | x, 8 | modifier, 9 | children, 10 | visible, 11 | highlighted, 12 | }) 13 | 14 | describe('', () => { 15 | it('renders the className modifier', () => { 16 | const modifier = 'test-modifier' 17 | const props = createProps({ modifier }) 18 | const wrapper = shallow() 19 | expect(wrapper.prop('className')).toMatch(modifier) 20 | }) 21 | 22 | it('is visible if "visible" is truthy', () => { 23 | const visible = true 24 | const props = createProps({ visible }) 25 | const wrapper = shallow() 26 | expect(wrapper.prop('className')).toMatch('is-visible') 27 | }) 28 | 29 | it('is invisible if "visible" is falsy', () => { 30 | const visible = false 31 | const props = createProps({ visible }) 32 | const wrapper = shallow() 33 | expect(wrapper.prop('className')).not.toMatch('is-visible') 34 | }) 35 | 36 | it('is highlighted if "highlighted" is truthy', () => { 37 | const highlighted = true 38 | const props = createProps({ highlighted }) 39 | const wrapper = shallow() 40 | expect(wrapper.prop('className')).toMatch('is-highlighted') 41 | }) 42 | 43 | it('is not highlighted if "highlighted" is falsy', () => { 44 | const highlighted = false 45 | const props = createProps({ highlighted }) 46 | const wrapper = shallow() 47 | expect(wrapper.prop('className')).not.toMatch('is-highlighted') 48 | }) 49 | 50 | it('follows the horizontal mouse position', () => { 51 | const x = 100 52 | const props = createProps({ x }) 53 | const wrapper = shallow() 54 | expect(wrapper.prop('style')).toEqual({ left: '100px' }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/components/Timeline/Tracks/__tests__/Element.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Element from '../Element' 5 | import BasicElement from '../../../Elements/Basic' 6 | import createTime from '../../../../utils/time' 7 | 8 | describe('', () => { 9 | const defaultProps = { 10 | id: '1', 11 | time: createTime({ 12 | start: new Date('2016-01-01'), 13 | end: new Date('2019-01-01'), 14 | zoom: 1, 15 | }), 16 | title: 'test', 17 | start: new Date('2017-01-01'), 18 | end: new Date('2018-01-01'), 19 | } 20 | 21 | it('renders with a calculated width and left position based on "start" and "end"', () => { 22 | const wrapper = shallow() 23 | expect(wrapper.prop('style')).toEqual({ 24 | left: '366px', 25 | width: '365px', 26 | }) 27 | }) 28 | 29 | it('renders ', () => { 30 | const wrapper = shallow() 31 | expect(wrapper.find(BasicElement).exists()).toBe(true) 32 | }) 33 | 34 | describe('clickElement', () => { 35 | it('renders with a cursor pointer style if callback is passed', () => { 36 | const props = { ...defaultProps } 37 | const wrapper = shallow() 38 | expect(wrapper.prop('style')).toMatchObject({ cursor: 'pointer' }) 39 | }) 40 | 41 | it('renders without cursor pointer style if callback is not passed', () => { 42 | const wrapper = shallow() 43 | expect(wrapper.prop('style')).not.toMatchObject({ cursor: 'pointer' }) 44 | }) 45 | 46 | it('gets called with props when clicked', () => { 47 | const clickElement = jest.fn() 48 | const props = { ...defaultProps, clickElement } 49 | const wrapper = shallow() 50 | expect(clickElement).toHaveBeenCalledTimes(0) 51 | 52 | wrapper.simulate('click') 53 | expect(clickElement).toHaveBeenCalledTimes(1) 54 | expect(clickElement).toHaveBeenCalledWith(props) 55 | }) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /src/components/Timeline/Tracks/__tests__/Track.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Track from '../Track' 5 | import Tracks from '..' 6 | import Element from '../Element' 7 | 8 | const createProps = ({ time = {}, elements = [], isOpen = false, tracks = [] }) => ({ 9 | time, 10 | elements, 11 | isOpen, 12 | tracks, 13 | }) 14 | 15 | describe('', () => { 16 | it('filters out components where "start" is after "end"', () => { 17 | const props = createProps({ 18 | elements: [ 19 | { 20 | id: '1', 21 | start: new Date('2017-01-01'), 22 | end: new Date('2018-01-01'), 23 | }, 24 | { 25 | id: '2', 26 | start: new Date('2018-01-01'), 27 | end: new Date('2017-01-01'), 28 | }, 29 | ], 30 | }) 31 | const wrapper = shallow() 32 | expect(wrapper.find(Element)).toHaveLength(1) 33 | }) 34 | 35 | it('renders if is open and tracks exist', () => { 36 | const props = createProps({ 37 | isOpen: true, 38 | tracks: [{}], 39 | }) 40 | const wrapper = shallow() 41 | expect(wrapper.find(Tracks)).toHaveLength(1) 42 | }) 43 | 44 | it('renders if is open and tracks exist', () => { 45 | const props = createProps({ 46 | isOpen: true, 47 | tracks: [{}], 48 | clickElement: jest.fn(), 49 | }) 50 | const wrapper = shallow() 51 | const tracks = wrapper.find(Tracks) 52 | 53 | expect(tracks.props().clickElement).toBe(props.clickElement) 54 | }) 55 | 56 | it('does not render is is not open', () => { 57 | const props = createProps({ 58 | isOpen: false, 59 | tracks: [{}], 60 | }) 61 | const wrapper = shallow() 62 | expect(wrapper.find(Tracks)).toHaveLength(0) 63 | }) 64 | 65 | it('does not render if there are no tracks', () => { 66 | const props = createProps({ 67 | isOpen: true, 68 | tracks: [], 69 | }) 70 | const wrapper = shallow() 71 | expect(wrapper.find(Tracks)).toHaveLength(0) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /src/__tests__/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Timeline from '..' 5 | import Controls from '../components/Controls' 6 | import Layout from '../components/Layout' 7 | 8 | const defaultStart = new Date('2010-01-01') 9 | const defaultEnd = new Date('2030-01-01') 10 | 11 | const createScaleProps = ({ 12 | start = defaultStart, 13 | end = defaultEnd, 14 | zoom = 1, 15 | zoomMin = undefined, 16 | zoomMax = undefined, 17 | minWidth = undefined, 18 | } = {}) => ({ 19 | start, 20 | end, 21 | zoom, 22 | zoomMin, 23 | zoomMax, 24 | minWidth, 25 | }) 26 | 27 | const createProps = ({ 28 | now = new Date(), 29 | scale = createScaleProps(), 30 | isOpen = undefined, 31 | timebar = [], 32 | tracks = [], 33 | toggleOpen = jest.fn(), 34 | zoomIn = jest.fn(), 35 | zoomOut = jest.fn(), 36 | } = {}) => ({ 37 | now, 38 | scale, 39 | isOpen, 40 | timebar, 41 | tracks, 42 | toggleOpen, 43 | zoomIn, 44 | zoomOut, 45 | }) 46 | 47 | describe('', () => { 48 | describe('Timeline', () => { 49 | it('renders ', () => { 50 | const props = createProps() 51 | const wrapper = shallow() 52 | expect(wrapper.find(Controls).exists()).toBe(true) 53 | }) 54 | 55 | it('renders ', () => { 56 | const props = createProps() 57 | const wrapper = shallow() 58 | expect(wrapper.find(Layout).exists()).toBe(true) 59 | }) 60 | 61 | it('re-renders when zoom changes', () => { 62 | const props = { ...createProps(), scale: createScaleProps({ zoom: 1 }) } 63 | const wrapper = shallow() 64 | expect(wrapper.state('time').zoom).toBe(1) 65 | 66 | const nextProps = { ...props, scale: createScaleProps({ zoom: 2 }) } 67 | 68 | wrapper.setProps(nextProps) 69 | expect(wrapper.state('time').zoom).toBe(2) 70 | 71 | wrapper.setProps(nextProps) 72 | expect(wrapper.state('time').zoom).toBe(2) 73 | }) 74 | 75 | it('renders the sidebar open by default', () => { 76 | const props = createProps() 77 | const wrapper = shallow() 78 | expect(wrapper.find(Layout).prop('isOpen')).toBe(true) 79 | }) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /src/scss/style.scss: -------------------------------------------------------------------------------- 1 | // Common 2 | $react-timelines-spacing: 10px; 3 | $react-timelines-keyline-color: #eee !default; 4 | $react-timelines-separator-dark-color: darken($react-timelines-keyline-color, 10%) !default; 5 | 6 | $react-timelines-auto-open-breakpoint: 1000px !default; 7 | $react-timelines-font-family: sans-serif !default; 8 | $react-timelines-border-width: 1px !default; 9 | $react-timelines-text-color: #4c4c4c !default; 10 | 11 | // Header 12 | $react-timelines-header-row-height: 40px !default; 13 | $react-timelines-header-separator-color: $react-timelines-separator-dark-color !default; 14 | 15 | // Sidebar 16 | $react-timelines-sidebar-width: 240px !default; 17 | $react-timelines-sidebar-closed-offset: 40px; 18 | $react-timelines-sidebar-background-color: #fff !default; 19 | $react-timelines-sidebar-separator-color: $react-timelines-keyline-color !default; 20 | $react-timelines-sidebar-key-indent-width: 20px !default; 21 | $react-timelines-sidebar-key-icon-size: 16px !default; 22 | $react-timelines-sidebar-key-icon-hover-color: #eee !default; 23 | 24 | // Timebar 25 | $react-timelines-timebar-cell-background-color: #fff; 26 | 27 | // Track / Elements 28 | $react-timelines-track-height: 60px !default; 29 | $react-timelines-element-spacing: $react-timelines-spacing !default; 30 | 31 | // Markers 32 | $react-timelines-marker-line-width: 2px !default; 33 | $react-timelines-marker-now-background-color: #ff007f !default; 34 | $react-timelines-marker-pointer-background-color: #888 !default; 35 | $react-timelines-marker-line-transparency: 0.5 !default; 36 | 37 | // Controls 38 | $react-timelines-button-background-color: #fff !default; 39 | $react-timelines-button-background-color-hover: #f0f0f0 !default; 40 | $react-timelines-button-size: 44px !default; 41 | 42 | .rt { 43 | position: relative; 44 | z-index: 1; 45 | overflow: hidden; 46 | font-family: $react-timelines-font-family; 47 | color: $react-timelines-text-color; 48 | 49 | * { 50 | box-sizing: border-box; 51 | } 52 | } 53 | 54 | @import 'utils'; 55 | 56 | @import 'components/controls'; 57 | @import 'components/element'; 58 | @import 'components/grid'; 59 | @import 'components/layout'; 60 | @import 'components/marker'; 61 | @import 'components/sidebar'; 62 | @import 'components/timebar'; 63 | @import 'components/timebar-key'; 64 | @import 'components/timeline'; 65 | @import 'components/track'; 66 | @import 'components/tracks'; 67 | @import 'components/track-key'; 68 | @import 'components/track-keys'; 69 | -------------------------------------------------------------------------------- /src/components/Elements/__tests__/Basic.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Basic from '../Basic' 5 | 6 | const defaultProps = { 7 | title: '', 8 | start: new Date('2017-01-01'), 9 | end: new Date('2017-02-01'), 10 | style: {}, 11 | tooltip: '', 12 | classes: [], 13 | } 14 | 15 | describe('', () => { 16 | describe('Tooltip', () => { 17 | const getTooltip = node => node.find('.rt-element__tooltip') 18 | 19 | it('renders the tooltip value if it exists', () => { 20 | const tooltip = 'Test tooltip' 21 | const props = { ...defaultProps, tooltip } 22 | const wrapper = shallow() 23 | expect(getTooltip(wrapper).html()).toMatch('Test tooltip') 24 | }) 25 | 26 | it('handles multiline tooltips', () => { 27 | const tooltip = 'Test\ntooltip' 28 | const props = { ...defaultProps, tooltip } 29 | const wrapper = shallow() 30 | expect(getTooltip(wrapper).html()).toMatch('Test
tooltip') 31 | }) 32 | 33 | it('renders the title, formatted start and end date if the tooltip prop does not exist', () => { 34 | const tooltip = '' 35 | const title = 'TEST' 36 | const start = new Date('2017-03-20') 37 | const end = new Date('2017-04-15') 38 | const props = { 39 | ...defaultProps, 40 | tooltip, 41 | title, 42 | start, 43 | end, 44 | } 45 | const wrapper = shallow() 46 | expect(getTooltip(wrapper).text()).toMatch('TEST') 47 | expect(getTooltip(wrapper).text()).toMatch('Start 20 Mar') 48 | expect(getTooltip(wrapper).text()).toMatch('End 15 Apr') 49 | }) 50 | 51 | it('can take an optional list of classnames to add to the parent', () => { 52 | const props = { ...defaultProps, classes: ['foo', 'bar'] } 53 | const wrapper = shallow() 54 | expect(wrapper.find('.rt-element').hasClass('foo')).toBe(true) 55 | expect(wrapper.find('.rt-element').hasClass('bar')).toBe(true) 56 | }) 57 | }) 58 | 59 | describe('Data set', () => { 60 | it('should be able to set data-*', () => { 61 | const props = { ...defaultProps, dataSet: { foo: 'boo', bar: 'baz' } } 62 | const wrapper = shallow() 63 | expect(wrapper.props()['data-foo']).toEqual('boo') 64 | expect(wrapper.props()['data-bar']).toEqual('baz') 65 | }) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /src/components/Sidebar/__tests__/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Header from '../Header' 5 | 6 | const defaultSticky = { 7 | isSticky: false, 8 | sidebarWidth: 0, 9 | headerHeight: 0, 10 | } 11 | 12 | const defaultProps = { 13 | timebar: [], 14 | sticky: { ...defaultSticky }, 15 | } 16 | 17 | describe('
', () => { 18 | it('renders the title for each row', () => { 19 | const timebar = [ 20 | { id: '1', title: 'row-1' }, 21 | { id: '1', title: 'row-2' }, 22 | ] 23 | const props = { ...defaultProps, timebar } 24 | const wrapper = shallow(
) 25 | expect(wrapper.find('.rt-timebar-key').first().text()).toBe('row-1') 26 | expect(wrapper.find('.rt-timebar-key').last().text()).toBe('row-2') 27 | }) 28 | 29 | it('reserves the space taken up by the header when it is sticky', () => { 30 | const sticky = { 31 | ...defaultSticky, 32 | isSticky: true, 33 | headerHeight: 100, 34 | } 35 | const props = { ...defaultProps, sticky } 36 | const wrapper = shallow(
) 37 | expect(wrapper.prop('style')).toEqual({ 38 | paddingTop: 100, 39 | }) 40 | }) 41 | 42 | it('does not reserve the space taken up by the header when it is static', () => { 43 | const sticky = { 44 | ...defaultSticky, 45 | isSticky: false, 46 | headerHeight: 100, 47 | } 48 | const props = { ...defaultProps, sticky } 49 | const wrapper = shallow(
) 50 | expect(wrapper.prop('style')).toEqual({}) 51 | }) 52 | 53 | it('becomes sticky when it receives a sticky prop', () => { 54 | const sticky = { 55 | ...defaultSticky, 56 | isSticky: true, 57 | sidebarWidth: 200, 58 | } 59 | const props = { ...defaultProps, sticky } 60 | const wrapper = shallow(
) 61 | expect(wrapper.find('.rt-sidebar__header').hasClass('rt-is-sticky')).toBe(true) 62 | expect(wrapper.find('.rt-sidebar__header').prop('style')).toEqual({ width: 200 }) 63 | }) 64 | 65 | it('becomes static when it receives a falsy sticky prop', () => { 66 | const sticky = { 67 | ...defaultSticky, 68 | isSticky: false, 69 | sidebarWidth: 200, 70 | } 71 | const props = { ...defaultProps, sticky } 72 | const wrapper = shallow(
) 73 | expect(wrapper.find('.rt-sidebar__header').hasClass('rt-is-sticky')).toBe(false) 74 | expect(wrapper.find('.rt-sidebar__header').prop('style')).toEqual({}) 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /src/components/Timeline/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Header from './Header' 5 | import Body from './Body' 6 | import NowMarker from './Marker/Now' 7 | import PointerMarker from './Marker/Pointer' 8 | import getMouseX from '../../utils/getMouseX' 9 | import getGrid from '../../utils/getGrid' 10 | 11 | class Timeline extends Component { 12 | constructor(props) { 13 | super(props) 14 | 15 | this.state = { 16 | pointerDate: null, 17 | pointerVisible: false, 18 | pointerHighlighted: false, 19 | } 20 | } 21 | 22 | handleMouseMove = e => { 23 | const { time } = this.props 24 | this.setState({ pointerDate: time.fromX(getMouseX(e)) }) 25 | } 26 | 27 | handleMouseLeave = () => { 28 | this.setState({ pointerHighlighted: false }) 29 | } 30 | 31 | handleMouseEnter = () => { 32 | this.setState({ pointerVisible: true, pointerHighlighted: true }) 33 | } 34 | 35 | render() { 36 | const { now, time, timebar, tracks, sticky, clickElement } = this.props 37 | 38 | const { pointerDate, pointerVisible, pointerHighlighted } = this.state 39 | 40 | const grid = getGrid(timebar) 41 | 42 | return ( 43 |
44 | {now && } 45 | {pointerDate && ( 46 | 47 | )} 48 |
57 | 58 |
59 | ) 60 | } 61 | } 62 | 63 | Timeline.propTypes = { 64 | now: PropTypes.instanceOf(Date), 65 | time: PropTypes.shape({ 66 | fromX: PropTypes.func.isRequired, 67 | toStyleLeftAndWidth: PropTypes.func, 68 | timelineWidthStyle: PropTypes.string, 69 | }).isRequired, 70 | timebar: PropTypes.arrayOf( 71 | PropTypes.shape({ 72 | id: PropTypes.string.isRequired, 73 | title: PropTypes.string, 74 | }).isRequired 75 | ).isRequired, 76 | tracks: PropTypes.arrayOf(PropTypes.shape({})), 77 | sticky: PropTypes.shape({}), 78 | clickElement: PropTypes.func, 79 | } 80 | 81 | export default Timeline 82 | -------------------------------------------------------------------------------- /src/components/Timeline/Header.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Timebar from './Timebar' 5 | 6 | const noop = () => {} 7 | 8 | class Header extends PureComponent { 9 | constructor(props) { 10 | super(props) 11 | 12 | this.scroll = React.createRef() 13 | this.timebar = React.createRef() 14 | } 15 | 16 | componentDidMount() { 17 | const { sticky } = this.props 18 | if (sticky) { 19 | sticky.setHeaderHeight(this.timebar.current.offsetHeight) 20 | const { scrollLeft, isSticky } = sticky 21 | if (isSticky) { 22 | this.scroll.current.scrollLeft = scrollLeft 23 | } 24 | } 25 | } 26 | 27 | componentDidUpdate(prevProps) { 28 | const { sticky } = this.props 29 | if (sticky) { 30 | const { scrollLeft, isSticky } = sticky 31 | const prevScrollLeft = prevProps.sticky.scrollLeft 32 | const prevIsSticky = prevProps.sticky.isSticky 33 | if (scrollLeft !== prevScrollLeft || isSticky !== prevIsSticky) { 34 | this.scroll.current.scrollLeft = scrollLeft 35 | } 36 | } 37 | } 38 | 39 | handleScroll = () => { 40 | const { sticky } = this.props 41 | sticky.handleHeaderScrollY(this.scroll.current.scrollLeft) 42 | } 43 | 44 | render() { 45 | const { 46 | time, 47 | onMove, 48 | onEnter, 49 | onLeave, 50 | width, 51 | timebar: rows, 52 | sticky: { isSticky, headerHeight, viewportWidth } = {}, 53 | } = this.props 54 | return ( 55 |
61 |
65 |
66 |
67 | 68 |
69 |
70 |
71 |
72 | ) 73 | } 74 | } 75 | 76 | Header.propTypes = { 77 | time: PropTypes.shape({}).isRequired, 78 | timebar: PropTypes.arrayOf( 79 | PropTypes.shape({ 80 | id: PropTypes.string.isRequired, 81 | title: PropTypes.string, 82 | }).isRequired 83 | ).isRequired, 84 | onMove: PropTypes.func.isRequired, 85 | onEnter: PropTypes.func.isRequired, 86 | onLeave: PropTypes.func.isRequired, 87 | width: PropTypes.string.isRequired, 88 | sticky: PropTypes.shape({ 89 | isSticky: PropTypes.bool.isRequired, 90 | setHeaderHeight: PropTypes.func.isRequired, 91 | viewportWidth: PropTypes.number.isRequired, 92 | handleHeaderScrollY: PropTypes.func.isRequired, 93 | scrollLeft: PropTypes.number.isRequired, 94 | }), 95 | } 96 | 97 | export default Header 98 | -------------------------------------------------------------------------------- /demo/src/App.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | import React, { Component } from 'react' 3 | import Timeline from 'react-timelines' 4 | 5 | import 'react-timelines/lib/css/style.css' 6 | 7 | import { START_YEAR, NUM_OF_YEARS, NUM_OF_TRACKS } from './constants' 8 | 9 | import { buildTimebar, buildTrack } from './builders' 10 | 11 | import { fill } from './utils' 12 | 13 | const now = new Date('2021-01-01') 14 | 15 | const timebar = buildTimebar() 16 | 17 | // eslint-disable-next-line no-alert 18 | const clickElement = element => alert(`Clicked element\n${JSON.stringify(element, null, 2)}`) 19 | 20 | const MIN_ZOOM = 2 21 | const MAX_ZOOM = 20 22 | 23 | class App extends Component { 24 | constructor(props) { 25 | super(props) 26 | 27 | const tracksById = fill(NUM_OF_TRACKS).reduce((acc, i) => { 28 | const track = buildTrack(i + 1) 29 | acc[track.id] = track 30 | return acc 31 | }, {}) 32 | 33 | this.state = { 34 | open: false, 35 | zoom: 2, 36 | // eslint-disable-next-line react/no-unused-state 37 | tracksById, 38 | tracks: Object.values(tracksById), 39 | } 40 | } 41 | 42 | handleToggleOpen = () => { 43 | this.setState(({ open }) => ({ open: !open })) 44 | } 45 | 46 | handleZoomIn = () => { 47 | this.setState(({ zoom }) => ({ zoom: Math.min(zoom + 1, MAX_ZOOM) })) 48 | } 49 | 50 | handleZoomOut = () => { 51 | this.setState(({ zoom }) => ({ zoom: Math.max(zoom - 1, MIN_ZOOM) })) 52 | } 53 | 54 | handleToggleTrackOpen = track => { 55 | this.setState(state => { 56 | const tracksById = { 57 | ...state.tracksById, 58 | [track.id]: { 59 | ...track, 60 | isOpen: !track.isOpen, 61 | }, 62 | } 63 | 64 | return { 65 | tracksById, 66 | tracks: Object.values(tracksById), 67 | } 68 | }) 69 | } 70 | 71 | render() { 72 | const { open, zoom, tracks } = this.state 73 | const start = new Date(`${START_YEAR}`) 74 | const end = new Date(`${START_YEAR + NUM_OF_YEARS}`) 75 | return ( 76 |
77 |

React Timelines

78 | { 92 | // eslint-disable-next-line no-alert 93 | alert(JSON.stringify(track)) 94 | }} 95 | timebar={timebar} 96 | tracks={tracks} 97 | now={now} 98 | toggleTrackOpen={this.handleToggleTrackOpen} 99 | enableSticky 100 | scrollToNow 101 | /> 102 |
103 | ) 104 | } 105 | } 106 | 107 | export default App 108 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-timelines", 3 | "version": "2.7.2", 4 | "description": "React Timelines", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "clean": "rimraf lib coverage", 8 | "build:js": "babel src/ -d lib/", 9 | "watch:js": "babel -w src/ -d lib/", 10 | "build:css": "node-sass src/scss/style.scss lib/css/style.css", 11 | "watch:css": "node-sass -w src/scss/style.scss lib/css/style.css", 12 | "watch": "npm run watch:js & npm run watch:css", 13 | "build": "npm run clean && npm run build:js && npm run build:css", 14 | "test": "npm run lint && npm run unit", 15 | "lint": "npm run lint:prettier && npm run lint:js", 16 | "lint:js": "eslint . --ext .js,.jsx", 17 | "lint:js:fix": "eslint . --ext .js,.jsx --fix", 18 | "lint:prettier": "prettier --list-different \"{e2e,src}/**/*.{js,jsx}\"", 19 | "lint:prettier:fix": "prettier --write \"{e2e,src}/**/*.{js,jsx}\"", 20 | "unit": "jest", 21 | "coverage": "jest --coverage --collectCoverageFrom='**/*.{js,jsx}'", 22 | "prepublish": "npm run clean && npm run build", 23 | "demo:deploy": "./demo/ops/deploy.sh" 24 | }, 25 | "keywords": [ 26 | "timeline", 27 | "schedule", 28 | "history", 29 | "react", 30 | "gantt", 31 | "horizontal", 32 | "library", 33 | "scroll", 34 | "scss", 35 | "sass", 36 | "tracks", 37 | "time" 38 | ], 39 | "repository": "git@github.com:JSainsburyPLC/react-timelines.git", 40 | "author": "JSainsburyPLC", 41 | "license": "MIT", 42 | "dependencies": { 43 | "prop-types": "^15.7.2" 44 | }, 45 | "files": [ 46 | "src", 47 | "lib" 48 | ], 49 | "devDependencies": { 50 | "@babel/cli": "^7.8.4", 51 | "@babel/core": "^7.9.0", 52 | "@babel/plugin-proposal-class-properties": "^7.8.3", 53 | "@babel/preset-env": "^7.9.5", 54 | "@babel/preset-react": "^7.9.4", 55 | "babel-eslint": "^10.1.0", 56 | "babel-jest": "^25.4.0", 57 | "babel-preset-env": "^1.7.0", 58 | "babel-preset-react": "^6.23.0", 59 | "enzyme": "^3.11.0", 60 | "enzyme-adapter-react-16": "^1.15.2", 61 | "eslint": "^6.8.0", 62 | "eslint-config-airbnb": "^18.1.0", 63 | "eslint-config-prettier": "^6.10.1", 64 | "eslint-plugin-import": "^2.20.2", 65 | "eslint-plugin-jsx-a11y": "^6.2.3", 66 | "eslint-plugin-react": "^7.19.0", 67 | "jest": "^25.4.0", 68 | "node-sass": "^4.14.0", 69 | "prettier": "^2.0.5", 70 | "react": "^16.13.1", 71 | "react-addons-test-utils": "^15.4.2", 72 | "react-dom": "16.13.1", 73 | "rimraf": "^3.0.2" 74 | }, 75 | "peerDependencies": { 76 | "react": ">=16", 77 | "react-dom": ">=16" 78 | }, 79 | "jest": { 80 | "rootDir": "./src", 81 | "resetMocks": true, 82 | "resetModules": true, 83 | "moduleDirectories": [ 84 | "node_modules", 85 | "src" 86 | ], 87 | "coveragePathIgnorePatterns": [ 88 | "/node_modules/", 89 | "/utils/raf.js", 90 | "/utils/events.js", 91 | "/utils/computedStyle.js", 92 | "/propTypes.js" 93 | ], 94 | "setupFiles": [ 95 | "../jestSetup.js" 96 | ] 97 | }, 98 | "resolutions": { 99 | "**/**/minimist": "1.2.5" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Controls from './components/Controls' 5 | import Layout from './components/Layout' 6 | import createTime from './utils/time' 7 | 8 | const UNKNOWN_WIDTH = -1 9 | 10 | class Timeline extends Component { 11 | constructor(props) { 12 | super(props) 13 | const timelineViewportWidth = UNKNOWN_WIDTH 14 | const sidebarWidth = UNKNOWN_WIDTH 15 | this.state = { 16 | time: createTime({ ...props.scale, viewportWidth: timelineViewportWidth }), 17 | timelineViewportWidth, 18 | sidebarWidth, 19 | } 20 | } 21 | 22 | // eslint-disable-next-line camelcase 23 | UNSAFE_componentWillReceiveProps(nextProps) { 24 | const { scale } = this.props 25 | const { timelineViewportWidth } = this.state 26 | 27 | if (nextProps.scale !== scale) { 28 | const time = createTime({ 29 | ...nextProps.scale, 30 | viewportWidth: timelineViewportWidth, 31 | }) 32 | this.setState({ time }) 33 | } 34 | } 35 | 36 | handleLayoutChange = ({ timelineViewportWidth, sidebarWidth }, cb) => { 37 | const { scale } = this.props 38 | const time = createTime({ 39 | ...scale, 40 | viewportWidth: timelineViewportWidth, 41 | }) 42 | this.setState( 43 | { 44 | time, 45 | timelineViewportWidth, 46 | sidebarWidth, 47 | }, 48 | cb 49 | ) 50 | } 51 | 52 | render() { 53 | const { 54 | isOpen = true, 55 | toggleOpen, 56 | zoomIn, 57 | zoomOut, 58 | scale: { zoom, zoomMin, zoomMax }, 59 | tracks, 60 | now, 61 | timebar, 62 | toggleTrackOpen, 63 | enableSticky = false, 64 | scrollToNow, 65 | } = this.props 66 | 67 | const { time, timelineViewportWidth, sidebarWidth } = this.state 68 | 69 | const { clickElement, clickTrackButton } = this.props 70 | 71 | return ( 72 |
73 | 82 | 97 |
98 | ) 99 | } 100 | } 101 | 102 | Timeline.propTypes = { 103 | scale: PropTypes.shape({ 104 | start: PropTypes.instanceOf(Date).isRequired, 105 | end: PropTypes.instanceOf(Date).isRequired, 106 | zoom: PropTypes.number.isRequired, 107 | zoomMin: PropTypes.number, 108 | zoomMax: PropTypes.number, 109 | minWidth: PropTypes.number, 110 | }), 111 | isOpen: PropTypes.bool, 112 | toggleOpen: PropTypes.func, 113 | zoomIn: PropTypes.func, 114 | zoomOut: PropTypes.func, 115 | clickElement: PropTypes.func, 116 | clickTrackButton: PropTypes.func, 117 | timebar: PropTypes.arrayOf(PropTypes.shape({})).isRequired, 118 | tracks: PropTypes.arrayOf(PropTypes.shape({})).isRequired, 119 | now: PropTypes.instanceOf(Date), 120 | toggleTrackOpen: PropTypes.func, 121 | enableSticky: PropTypes.bool, 122 | scrollToNow: PropTypes.bool, 123 | } 124 | 125 | export default Timeline 126 | -------------------------------------------------------------------------------- /src/components/Timeline/__tests__/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Timeline from '..' 5 | import Header from '../Header' 6 | import Body from '../Body' 7 | import NowMarker from '../Marker/Now' 8 | import PointerMarker from '../Marker/Pointer' 9 | import createTime from '../../../utils/time' 10 | 11 | import getMouseX from '../../../utils/getMouseX' 12 | 13 | jest.mock('../../../utils/getMouseX') 14 | 15 | const time = createTime({ 16 | start: new Date('2018-01-01'), 17 | end: new Date('2019-01-01'), 18 | zoom: 1, 19 | }) 20 | 21 | const defaultTimebar = [ 22 | { 23 | useAsGrid: true, 24 | id: '1', 25 | cells: [{ id: 'cell-1' }], 26 | }, 27 | ] 28 | 29 | const createProps = ({ now = new Date(), timebar = defaultTimebar, tracks = [], isOpen } = {}) => ({ 30 | now, 31 | time, 32 | timebar, 33 | tracks, 34 | isOpen, 35 | }) 36 | 37 | describe('', () => { 38 | it('renders ,
and ', () => { 39 | const props = createProps() 40 | const wrapper = shallow() 41 | expect(wrapper.find(NowMarker).exists()).toBe(true) 42 | expect(wrapper.find(Header).exists()).toBe(true) 43 | expect(wrapper.find(Body).exists()).toBe(true) 44 | }) 45 | 46 | it('renders passing in appropriate grid cells', () => { 47 | const props = createProps() 48 | const wrapper = shallow() 49 | const expected = [{ id: 'cell-1' }] 50 | expect(wrapper.find(Body).prop('grid')).toEqual(expected) 51 | }) 52 | 53 | describe('markers', () => { 54 | it('does not render when component mounts', () => { 55 | const props = createProps() 56 | const wrapper = shallow() 57 | expect(wrapper.find(PointerMarker).exists()).not.toBe(true) 58 | }) 59 | 60 | it('renders when component mounts', () => { 61 | const props = createProps() 62 | const wrapper = shallow() 63 | wrapper.setState({ pointerDate: new Date() }) 64 | expect(wrapper.find(PointerMarker).exists()).toBe(true) 65 | }) 66 | 67 | it('does not render if "now" is "null"', () => { 68 | const props = createProps({ now: null }) 69 | const wrapper = shallow() 70 | expect(wrapper.find(NowMarker).exists()).toBe(false) 71 | }) 72 | 73 | it('updates pointerDate when the mouse moves', () => { 74 | const event = 10 75 | const props = createProps() 76 | const wrapper = shallow() 77 | expect(wrapper.state('pointerDate')).toBe(null) 78 | 79 | getMouseX.mockImplementation(e => e) 80 | wrapper.find(Header).prop('onMove')(event) 81 | expect(wrapper.state('pointerDate')).toEqual(new Date('2018-01-11')) 82 | }) 83 | 84 | it('makes the pointer visible and highlighted when the mouse enters', () => { 85 | const props = createProps() 86 | const wrapper = shallow() 87 | expect(wrapper.state('pointerVisible')).toBe(false) 88 | expect(wrapper.state('pointerHighlighted')).toBe(false) 89 | 90 | wrapper.find(Header).prop('onEnter')() 91 | expect(wrapper.state('pointerVisible')).toBe(true) 92 | expect(wrapper.state('pointerHighlighted')).toBe(true) 93 | }) 94 | 95 | it('removes the pointer highlight when the mouse leaves', () => { 96 | const props = createProps() 97 | const wrapper = shallow() 98 | expect(wrapper.state('pointerHighlighted')).toBe(false) 99 | 100 | wrapper.find(Header).prop('onEnter')() 101 | expect(wrapper.state('pointerHighlighted')).toBe(true) 102 | 103 | wrapper.find(Header).prop('onLeave')() 104 | expect(wrapper.state('pointerHighlighted')).toBe(false) 105 | }) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /demo/src/builders.js: -------------------------------------------------------------------------------- 1 | import { 2 | START_YEAR, 3 | NUM_OF_YEARS, 4 | MONTH_NAMES, 5 | MONTHS_PER_YEAR, 6 | QUARTERS_PER_YEAR, 7 | MONTHS_PER_QUARTER, 8 | NUM_OF_MONTHS, 9 | MAX_TRACK_START_GAP, 10 | MAX_ELEMENT_GAP, 11 | MAX_MONTH_SPAN, 12 | MIN_MONTH_SPAN, 13 | MAX_NUM_OF_SUBTRACKS, 14 | } from './constants' 15 | 16 | import { fill, hexToRgb, colourIsLight, addMonthsToYear, addMonthsToYearAsDate, nextColor, randomTitle } from './utils' 17 | 18 | export const buildQuarterCells = () => { 19 | const v = [] 20 | for (let i = 0; i < QUARTERS_PER_YEAR * NUM_OF_YEARS; i += 1) { 21 | const quarter = (i % 4) + 1 22 | const startMonth = i * MONTHS_PER_QUARTER 23 | const s = addMonthsToYear(START_YEAR, startMonth) 24 | const e = addMonthsToYear(START_YEAR, startMonth + MONTHS_PER_QUARTER) 25 | v.push({ 26 | id: `${s.year}-q${quarter}`, 27 | title: `Q${quarter} ${s.year}`, 28 | start: new Date(`${s.year}-${s.month}-01`), 29 | end: new Date(`${e.year}-${e.month}-01`), 30 | }) 31 | } 32 | return v 33 | } 34 | 35 | export const buildMonthCells = () => { 36 | const v = [] 37 | for (let i = 0; i < MONTHS_PER_YEAR * NUM_OF_YEARS; i += 1) { 38 | const startMonth = i 39 | const start = addMonthsToYearAsDate(START_YEAR, startMonth) 40 | const end = addMonthsToYearAsDate(START_YEAR, startMonth + 1) 41 | v.push({ 42 | id: `m${startMonth}`, 43 | title: MONTH_NAMES[i % 12], 44 | start, 45 | end, 46 | }) 47 | } 48 | return v 49 | } 50 | 51 | export const buildTimebar = () => [ 52 | { 53 | id: 'quarters', 54 | title: 'Quarters', 55 | cells: buildQuarterCells(), 56 | style: {}, 57 | }, 58 | { 59 | id: 'months', 60 | title: 'Months', 61 | cells: buildMonthCells(), 62 | useAsGrid: true, 63 | style: {}, 64 | }, 65 | ] 66 | 67 | export const buildElement = ({ trackId, start, end, i }) => { 68 | const bgColor = nextColor() 69 | const color = colourIsLight(...hexToRgb(bgColor)) ? '#000000' : '#ffffff' 70 | return { 71 | id: `t-${trackId}-el-${i}`, 72 | title: randomTitle(), 73 | start, 74 | end, 75 | style: { 76 | backgroundColor: `#${bgColor}`, 77 | color, 78 | borderRadius: '4px', 79 | boxShadow: '1px 1px 0px rgba(0, 0, 0, 0.25)', 80 | textTransform: 'capitalize', 81 | }, 82 | } 83 | } 84 | 85 | export const buildTrackStartGap = () => Math.floor(Math.random() * MAX_TRACK_START_GAP) 86 | export const buildElementGap = () => Math.floor(Math.random() * MAX_ELEMENT_GAP) 87 | 88 | export const buildElements = trackId => { 89 | const v = [] 90 | let i = 1 91 | let month = buildTrackStartGap() 92 | 93 | while (month < NUM_OF_MONTHS) { 94 | let monthSpan = Math.floor(Math.random() * (MAX_MONTH_SPAN - (MIN_MONTH_SPAN - 1))) + MIN_MONTH_SPAN 95 | 96 | if (month + monthSpan > NUM_OF_MONTHS) { 97 | monthSpan = NUM_OF_MONTHS - month 98 | } 99 | 100 | const start = addMonthsToYearAsDate(START_YEAR, month) 101 | const end = addMonthsToYearAsDate(START_YEAR, month + monthSpan) 102 | v.push( 103 | buildElement({ 104 | trackId, 105 | start, 106 | end, 107 | i, 108 | }) 109 | ) 110 | const gap = buildElementGap() 111 | month += monthSpan + gap 112 | i += 1 113 | } 114 | 115 | return v 116 | } 117 | 118 | export const buildSubtrack = (trackId, subtrackId) => ({ 119 | id: `track-${trackId}-${subtrackId}`, 120 | title: `Subtrack ${subtrackId}`, 121 | elements: buildElements(subtrackId), 122 | }) 123 | 124 | export const buildTrack = trackId => { 125 | const tracks = fill(Math.floor(Math.random() * MAX_NUM_OF_SUBTRACKS) + 1).map(i => buildSubtrack(trackId, i + 1)) 126 | return { 127 | id: `track-${trackId}`, 128 | title: `Track ${trackId}`, 129 | elements: buildElements(trackId), 130 | tracks, 131 | // hasButton: true, 132 | // link: 'www.google.com', 133 | isOpen: false, 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/utils/__tests__/time.js: -------------------------------------------------------------------------------- 1 | import createTime from '../time' 2 | 3 | describe('createTime', () => { 4 | describe('timelineWidth', () => { 5 | it('calculates timelineWidth from start, end and scale', () => { 6 | const { timelineWidth } = createTime({ 7 | start: new Date('2017-01-01T00:00:00.000Z'), 8 | end: new Date('2018-01-01T00:00:00.000Z'), 9 | zoom: 10, // 10px === 1 day 10 | }) 11 | expect(timelineWidth).toBe(3650) 12 | }) 13 | 14 | it('scale relates to pixel width of one day', () => { 15 | const newYear = new Date('2017-01-01T00:00:00.000Z') 16 | const newYearMidday = new Date('2017-01-01T12:00:00.000Z') 17 | const { timelineWidth } = createTime({ 18 | start: newYear, 19 | end: newYearMidday, 20 | zoom: 100, 21 | }) 22 | expect(timelineWidth).toBe(50) 23 | }) 24 | 25 | it('uses viewportWidth if greater than daysZoomWidth', () => { 26 | const newYear = new Date('2017-01-01T00:00:00.000Z') 27 | const newYearMidday = new Date('2017-01-01T12:00:00.000Z') 28 | const { timelineWidth } = createTime({ 29 | start: newYear, 30 | end: newYearMidday, 31 | zoom: 1, 32 | viewportWidth: 1000, 33 | }) 34 | expect(timelineWidth).toBe(1000) 35 | }) 36 | 37 | it('minTimelineWidth ensures timelineWidth does not fall below minimum', () => { 38 | const newYear = new Date('2017-01-01T00:00:00.000Z') 39 | const newYearMidday = new Date('2017-01-01T12:00:00.000Z') 40 | const { timelineWidth } = createTime({ 41 | start: newYear, 42 | end: newYearMidday, 43 | zoom: 1, 44 | viewportWidth: 500, 45 | minWidth: 800, 46 | }) 47 | expect(timelineWidth).toBe(800) 48 | }) 49 | }) 50 | 51 | describe('toX()', () => { 52 | it('calculates correct x pixel position for given date (with pixel rounding)', () => { 53 | const start = new Date('2017-01-01T00:00:00.000Z') 54 | const end = new Date('2018-01-01T00:00:00.000Z') 55 | const { toX } = createTime({ start, end, zoom: 2 }) 56 | const nearMiddle = new Date('2017-07-01') 57 | const notClamped = new Date('2020-01-01') 58 | expect(toX(end)).toBe(730) 59 | expect(toX(start)).toBe(0) 60 | expect(toX(nearMiddle)).toBe(362) 61 | expect(toX(notClamped)).toBe(2190) 62 | }) 63 | }) 64 | 65 | describe('toStyleLeft()', () => { 66 | it('returns style object with correct "left" property', () => { 67 | const start = new Date('2017-01-01T00:00:00.000Z') 68 | const firstOfJune = new Date('2017-06-01T12:34:56.000Z') 69 | const end = new Date('2018-01-01T00:00:00.000Z') 70 | const { toStyleLeft } = createTime({ start, end, zoom: 2 }) 71 | expect(toStyleLeft(start)).toEqual({ left: '0px' }) 72 | expect(toStyleLeft(firstOfJune)).toEqual({ left: '303px' }) 73 | expect(toStyleLeft(end)).toEqual({ left: '730px' }) 74 | }) 75 | }) 76 | 77 | describe('toStyleLeftAndWidth()', () => { 78 | it('returns style object with correct "left" and "width" property', () => { 79 | const start = new Date('2017-01-01T00:00:00.000Z') 80 | const firstOfJune = new Date('2017-06-01T12:34:56.000Z') 81 | const end = new Date('2018-01-01T00:00:00.000Z') 82 | const { toStyleLeftAndWidth } = createTime({ start, end, zoom: 2 }) 83 | expect(toStyleLeftAndWidth(start, end)).toEqual({ left: '0px', width: '730px' }) 84 | expect(toStyleLeftAndWidth(firstOfJune, end)).toEqual({ left: '303px', width: '427px' }) 85 | }) 86 | }) 87 | 88 | describe('fromX', () => { 89 | it('calculates the date from a given x value', () => { 90 | const start = new Date('2017-01-01') 91 | const firstOfDecember = new Date('2017-12-01') 92 | const end = new Date('2018-01-01') 93 | const { fromX, toX } = createTime({ start, end, zoom: 2 }) 94 | expect(fromX(toX(start))).toEqual(start) 95 | expect(fromX(toX(firstOfDecember))).toEqual(firstOfDecember) 96 | expect(fromX(toX(end))).toEqual(end) 97 | }) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /src/components/Controls/__tests__/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { mount } from 'enzyme' 3 | import Controls from '..' 4 | import Toggle from '../Toggle' 5 | 6 | const createProps = ({ 7 | isOpen = undefined, 8 | toggleOpen = jest.fn(), 9 | zoomIn = jest.fn(), 10 | zoomOut = jest.fn(), 11 | zoom = 2, 12 | zoomMin = 1, 13 | zoomMax = 10, 14 | } = {}) => ({ 15 | isOpen, 16 | toggleOpen, 17 | zoomIn, 18 | zoomOut, 19 | zoom, 20 | zoomMin, 21 | zoomMax, 22 | }) 23 | 24 | describe('', () => { 25 | describe('Toggle', () => { 26 | it('render ', () => { 27 | const props = createProps() 28 | const wrapper = mount() 29 | expect(wrapper.find(Toggle).exists()).toBe(true) 30 | }) 31 | 32 | it('do not render if no "toggleOpen" prop', () => { 33 | const props = { ...createProps(), toggleOpen: undefined } 34 | const wrapper = mount() 35 | expect(wrapper.find(Toggle).exists()).toBe(false) 36 | }) 37 | }) 38 | 39 | describe('Zoom in button', () => { 40 | const findButton = node => node.find('.rt-controls__button--zoom-in') 41 | 42 | it('not rendered if no "zoomIn" fn passed', () => { 43 | const props = { ...createProps(), zoomIn: undefined } 44 | const wrapper = mount() 45 | expect(findButton(wrapper).exists()).toBe(false) 46 | }) 47 | 48 | it('is disabled when "zoom" is equal to "zoomMax"', () => { 49 | const props = createProps({ zoom: 5, zoomMax: 5 }) 50 | const wrapper = mount() 51 | expect(findButton(wrapper).prop('disabled')).toBe(true) 52 | }) 53 | 54 | it('is disabled when "zoom" is greater than "zoomMax"', () => { 55 | const props = createProps({ zoom: 6, zoomMax: 5 }) 56 | const wrapper = mount() 57 | expect(findButton(wrapper).prop('disabled')).toBe(true) 58 | }) 59 | 60 | it('is not disabled when "zoom" is less than "zoomMax"', () => { 61 | const props = createProps({ zoom: 2, zoomMax: 5 }) 62 | const wrapper = mount() 63 | expect(findButton(wrapper).prop('disabled')).toBe(false) 64 | }) 65 | 66 | it('calls "zoomIn() when clicked"', () => { 67 | const zoomIn = jest.fn() 68 | const props = createProps({ zoom: 2, zoomMax: 5, zoomIn }) 69 | const wrapper = mount() 70 | findButton(wrapper).simulate('click') 71 | expect(zoomIn).toBeCalled() 72 | }) 73 | }) 74 | 75 | describe('Zoom out button', () => { 76 | const findButton = node => node.find('.rt-controls__button--zoom-out') 77 | 78 | it('not rendered if no "zoomOut" fn passed', () => { 79 | const props = { ...createProps(), zoomOut: undefined } 80 | const wrapper = mount() 81 | expect(findButton(wrapper).exists()).toBe(false) 82 | }) 83 | 84 | it('is disabled when "zoom" is equal to "zoomMin"', () => { 85 | const props = createProps({ zoom: 2, zoomMin: 2 }) 86 | const wrapper = mount() 87 | expect(findButton(wrapper).prop('disabled')).toBe(true) 88 | }) 89 | 90 | it('is disabled when "zoom" is less than "zoomMin"', () => { 91 | const props = createProps({ zoom: 1, zoomMin: 2 }) 92 | const wrapper = mount() 93 | expect(findButton(wrapper).prop('disabled')).toBe(true) 94 | }) 95 | 96 | it('is not disabled when "zoom" is greater than "zoomMin"', () => { 97 | const props = createProps({ zoom: 5, zoomMin: 2 }) 98 | const wrapper = mount() 99 | expect(findButton(wrapper).prop('disabled')).toBe(false) 100 | }) 101 | 102 | it('calls "zoomOut() when clicked"', () => { 103 | const zoomOut = jest.fn() 104 | const props = createProps({ zoom: 5, zoomMin: 2, zoomOut }) 105 | const wrapper = mount() 106 | findButton(wrapper).simulate('click') 107 | expect(zoomOut).toBeCalled() 108 | }) 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /src/components/Layout/__tests__/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { mount } from 'enzyme' 3 | 4 | import Layout from '..' 5 | import Sidebar from '../../Sidebar' 6 | import Timeline from '../../Timeline' 7 | 8 | import computedStyle from '../../../utils/computedStyle' 9 | import { addListener, removeListener } from '../../../utils/events' 10 | import raf from '../../../utils/raf' 11 | 12 | jest.mock('../../Sidebar', () => () => null) 13 | jest.mock('../../Timeline', () => () => null) 14 | jest.mock('../../../utils/computedStyle') 15 | jest.mock('../../../utils/events') 16 | jest.mock('../../../utils/raf') 17 | 18 | const createProps = ({ 19 | timebar = [], 20 | time = { 21 | fromX: jest.fn(() => new Date()), 22 | toX: jest.fn(() => 0), 23 | }, 24 | tracks = [], 25 | now = new Date(), 26 | isOpen = false, 27 | toggleTrackOpen = jest.fn(), 28 | enableSticky = true, 29 | onLayoutChange = jest.fn(), 30 | timelineViewportWidth = 1000, 31 | sidebarWidth = 200, 32 | } = {}) => ({ 33 | timebar, 34 | time, 35 | tracks, 36 | now, 37 | isOpen, 38 | toggleTrackOpen, 39 | enableSticky, 40 | onLayoutChange, 41 | timelineViewportWidth, 42 | sidebarWidth, 43 | }) 44 | 45 | describe('', () => { 46 | beforeEach(() => { 47 | computedStyle.mockImplementation(node => ({ 48 | getPropertyValue(prop) { 49 | return node.style[prop] 50 | }, 51 | })) 52 | raf.mockImplementation(fn => fn()) 53 | }) 54 | 55 | it('renders and ', () => { 56 | const props = createProps() 57 | const wrapper = mount() 58 | expect(wrapper.find(Sidebar).exists()).toBe(true) 59 | expect(wrapper.find(Timeline).exists()).toBe(true) 60 | }) 61 | 62 | it('renders in an open state', () => { 63 | const props = createProps({ isOpen: true }) 64 | const wrapper = mount() 65 | expect(wrapper.find('.rt-layout').prop('className')).toMatch('is-open') 66 | }) 67 | 68 | it('renders in a closed state', () => { 69 | const props = createProps({ isOpen: false }) 70 | const wrapper = mount() 71 | expect(wrapper.find('.rt-layout').prop('className')).not.toMatch('is-open') 72 | }) 73 | 74 | describe('sticky header', () => { 75 | it('becomes sticky when the window is within the timeline', () => { 76 | const listeners = {} 77 | addListener.mockImplementation((evt, fun) => { 78 | listeners[evt] = fun 79 | }) 80 | removeListener.mockImplementation(jest.fn()) 81 | 82 | const props = createProps() 83 | const wrapper = mount() 84 | expect(typeof listeners.scroll).toEqual('function') 85 | 86 | wrapper.instance().setHeaderHeight(50) 87 | wrapper.instance().timeline.current.getBoundingClientRect = () => ({ 88 | top: -50, 89 | bottom: 100, 90 | }) 91 | listeners.scroll() 92 | expect(wrapper.state()).toMatchObject({ 93 | isSticky: true, 94 | }) 95 | 96 | wrapper.instance().timeline.current.getBoundingClientRect = () => ({ 97 | top: 10, 98 | bottom: 100, 99 | }) 100 | listeners.scroll() 101 | expect(wrapper.state()).toMatchObject({ 102 | isSticky: false, 103 | }) 104 | 105 | wrapper.instance().timeline.current.getBoundingClientRect = () => ({ 106 | top: -60, 107 | bottom: 20, 108 | }) 109 | listeners.scroll() 110 | expect(wrapper.state()).toMatchObject({ 111 | isSticky: false, 112 | }) 113 | 114 | wrapper.unmount() 115 | expect(removeListener).toBeCalled() 116 | }) 117 | 118 | it('syncs the timeline scroll position when the header is scrolled and is sticky', () => { 119 | const props = createProps() 120 | const wrapper = mount() 121 | wrapper.setState({ isSticky: true }) 122 | wrapper.find(Timeline).prop('sticky').handleHeaderScrollY('100') 123 | expect(wrapper.find('.rt-layout__timeline').instance().scrollLeft).toBe(100) 124 | }) 125 | }) 126 | }) 127 | -------------------------------------------------------------------------------- /src/scss/components/_track-key.scss: -------------------------------------------------------------------------------- 1 | .rt-track-key {} 2 | 3 | .rt-track-key__entry { 4 | display: flex; 5 | flex-direction: row; 6 | align-items: center; 7 | 8 | height: $react-timelines-track-height + $react-timelines-border-width; 9 | line-height: $react-timelines-track-height; 10 | font-weight: bold; 11 | text-align: left; 12 | border-bottom: $react-timelines-border-width solid $react-timelines-sidebar-separator-color; 13 | } 14 | 15 | .rt-track-keys > .rt-track-key > 16 | .rt-track-key__entry { 17 | padding-left: $react-timelines-sidebar-key-indent-width; 18 | } 19 | 20 | .rt-track-keys > .rt-track-key > 21 | .rt-track-keys > .rt-track-key > 22 | .rt-track-key__entry { 23 | padding-left: $react-timelines-sidebar-key-indent-width * 2; 24 | } 25 | 26 | .rt-track-keys > .rt-track-key > 27 | .rt-track-keys > .rt-track-key > 28 | .rt-track-keys > .rt-track-key > 29 | .rt-track-key__entry { 30 | padding-left: $react-timelines-sidebar-key-indent-width * 3; 31 | } 32 | 33 | .rt-track-keys > .rt-track-key > 34 | .rt-track-keys > .rt-track-key > 35 | .rt-track-keys > .rt-track-key > 36 | .rt-track-keys > .rt-track-key > 37 | .rt-track-key__entry { 38 | padding-left: $react-timelines-sidebar-key-indent-width * 4; 39 | } 40 | 41 | .rt-track-keys > .rt-track-key > 42 | .rt-track-keys > .rt-track-key > 43 | .rt-track-keys > .rt-track-key > 44 | .rt-track-keys > .rt-track-key > 45 | .rt-track-keys > .rt-track-key > 46 | .rt-track-key__entry { 47 | padding-left: $react-timelines-sidebar-key-indent-width * 5; 48 | } 49 | 50 | .rt-track-key__toggle { 51 | $icon-size: 20px; 52 | 53 | overflow: hidden; 54 | width: $icon-size; 55 | height: $icon-size; 56 | margin-right: 10px; 57 | background: $react-timelines-text-color no-repeat center/10px; 58 | color: transparent; 59 | 60 | &:hover, 61 | &:focus { 62 | background-color: darken($react-timelines-text-color, 20%); 63 | } 64 | } 65 | 66 | .rt-track-key__toggle--close { 67 | background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iOCIgdmlld0JveD0iMTYgMjQgMjQgOCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTYgMjRoMjR2OEgxNnoiIGZpbGw9IiNmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg=='); 68 | } 69 | 70 | .rt-track-key__toggle--open { 71 | background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjUiIHZpZXdCb3g9IjE2IDE1IDI0IDI1IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxnIGZpbGw9IiNmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCI+PHBhdGggZD0iTTMyIDE2djI0aC04VjE2eiIvPjxwYXRoIGQ9Ik0xNiAyNGgyNHY4SDE2eiIvPjwvZz48L3N2Zz4='); 72 | } 73 | 74 | .rt-track-key__title { 75 | flex: 1; 76 | white-space: nowrap; 77 | text-overflow: ellipsis; 78 | overflow: hidden; 79 | } 80 | 81 | .rt-track-key__side-button { 82 | height: $react-timelines-track-height; 83 | width: $react-timelines-track-height; 84 | color: transparent; 85 | background: transparent; 86 | 87 | &:hover, 88 | &:focus { 89 | background: $react-timelines-sidebar-key-icon-hover-color; 90 | color: transparent; 91 | } 92 | 93 | &::before { 94 | position: absolute; 95 | width: $react-timelines-sidebar-key-icon-size; 96 | height: $react-timelines-sidebar-key-icon-size; 97 | margin-top: -$react-timelines-sidebar-key-icon-size / 2; 98 | margin-left: -$react-timelines-sidebar-key-icon-size / 2; 99 | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDQ4Mi4xMzYgNDgyLjEzNSI+PHBhdGggZmlsbD0iIzc2NzY3NiIgZD0iTTQ1NS40ODIgMTk4LjE4NEwzMjYuODMgMzI2LjgzMmMtMzUuNTM2IDM1LjU0LTkzLjExIDM1LjU0LTEyOC42NDcgMGwtNDIuODgtNDIuODg2IDQyLjg4LTQyLjg3NiA0Mi44ODQgNDIuODc2YzExLjg0NSAxMS44MjIgMzEuMDY0IDExLjg0NiA0Mi44ODYgMGwxMjguNjQ0LTEyOC42NDNjMTEuODE2LTExLjgzIDExLjgxNi0zMS4wNjYgMC00Mi45bC00Mi44OC00Mi44OGMtMTEuODIzLTExLjgxNS0zMS4wNjUtMTEuODE1LTQyLjg4OCAwbC00NS45MyA0NS45MzVjLTIxLjI5LTEyLjUzLTQ1LjQ5LTE3LjkwNS02OS40NS0xNi4yOWw3Mi41LTcyLjUyN2MzNS41MzYtMzUuNTIgOTMuMTM3LTM1LjUyIDEyOC42NDUgMGw0Mi44ODYgNDIuODg0YzM1LjUzNiAzNS41MjMgMzUuNTM2IDkzLjE0IDAgMTI4LjY2MnpNMjAxLjIwNiAzNjYuNjk4bC00NS45MDMgNDUuOWMtMTEuODQ1IDExLjg0Ni0zMS4wNjQgMTEuODE3LTQyLjg4IDBsLTQyLjg4NS00Mi44OGMtMTEuODQ1LTExLjgyMi0xMS44NDUtMzEuMDQyIDAtNDIuODg3bDEyOC42NDYtMTI4LjY0NWMxMS44Mi0xMS44MTQgMzEuMDctMTEuODE0IDQyLjg4NCAwbDQyLjg4NiA0Mi44ODYgNDIuODc2LTQyLjg4Ni00Mi44NzYtNDIuODhjLTM1LjU0LTM1LjUyMi05My4xMTMtMzUuNTIyLTEyOC42NSAwbC0xMjguNjUgMTI4LjY0Yy0zNS41MzcgMzUuNTQ2LTM1LjUzNyA5My4xNDcgMCAxMjguNjUzTDY5LjU0IDQ1NS40OGMzNS41MSAzNS41NCA5My4xMSAzNS41NCAxMjguNjQ2IDBsNzIuNDk2LTcyLjVjLTIzLjk1NiAxLjU5OC00OC4wOTItMy43ODMtNjkuNDc0LTE2LjI4MnoiLz48L3N2Zz4='); 100 | content: ' '; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/components/Timeline/__tests__/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow, mount } from 'enzyme' 3 | 4 | import Header from '../Header' 5 | import Timebar from '../Timebar' 6 | 7 | const createStickyProp = ({ 8 | isSticky = false, 9 | setHeaderHeight = jest.fn(), 10 | handleHeaderScrollY = jest.fn(), 11 | headerHeight = 0, 12 | viewportWidth = 0, 13 | scrollLeft = 0, 14 | } = {}) => ({ 15 | isSticky, 16 | setHeaderHeight, 17 | handleHeaderScrollY, 18 | headerHeight, 19 | viewportWidth, 20 | scrollLeft, 21 | }) 22 | 23 | const createProps = ({ 24 | time = {}, 25 | timebar = [], 26 | onMove = jest.fn(), 27 | onEnter = jest.fn(), 28 | onLeave = jest.fn(), 29 | sticky = undefined, 30 | } = {}) => ({ 31 | time, 32 | timebar, 33 | onMove, 34 | onEnter, 35 | onLeave, 36 | sticky, 37 | width: '1000px', 38 | }) 39 | 40 | describe('
', () => { 41 | it('renders ', () => { 42 | const props = createProps() 43 | const wrapper = shallow(
) 44 | expect(wrapper.find(Timebar).exists()).toBe(true) 45 | }) 46 | 47 | it('calls "onMove" on mouse move event', () => { 48 | const onMove = jest.fn() 49 | const props = createProps({ onMove }) 50 | const wrapper = shallow(
) 51 | wrapper.simulate('mouseMove') 52 | expect(onMove).toBeCalled() 53 | }) 54 | 55 | it('calls "onEnter" on mouse enter event', () => { 56 | const onEnter = jest.fn() 57 | const props = createProps({ onEnter }) 58 | const wrapper = shallow(
) 59 | wrapper.simulate('mouseEnter') 60 | expect(onEnter).toBeCalled() 61 | }) 62 | 63 | it('calls "onLeave" on mouse leave event', () => { 64 | const onLeave = jest.fn() 65 | const props = createProps({ onLeave }) 66 | const wrapper = shallow(
) 67 | wrapper.simulate('mouseLeave') 68 | expect(onLeave).toBeCalled() 69 | }) 70 | 71 | describe('sticky', () => { 72 | it('ensures the scroll left postion gets updated when a new scrollLeft prop is received', () => { 73 | let sticky = createStickyProp() 74 | const props = createProps({ sticky }) 75 | const wrapper = mount(
) 76 | expect(wrapper.find('.rt-timeline__header-scroll').instance().scrollLeft).toBe(0) 77 | 78 | sticky = createStickyProp({ scrollLeft: 100 }) 79 | const nextProps = createProps({ sticky }) 80 | wrapper.setProps(nextProps) 81 | expect(wrapper.find('.rt-timeline__header-scroll').instance().scrollLeft).toBe(100) 82 | }) 83 | 84 | it('ensures the scroll left position is correct when the header becomes sticky', () => { 85 | let sticky = createStickyProp({ isSticky: false }) 86 | const props = createProps({ sticky }) 87 | const wrapper = mount(
) 88 | expect(wrapper.find('.rt-timeline__header-scroll').instance().scrollLeft).toBe(0) 89 | 90 | sticky = createStickyProp({ isSticky: true }) 91 | const nextProps = createProps({ sticky }) 92 | wrapper.setProps(nextProps) 93 | expect(wrapper.find('.rt-timeline__header-scroll').instance().scrollLeft).toBe(0) 94 | }) 95 | 96 | it('does not update the scrollLeft position if the component updates and the scrollLeft and isSticky props have not changed', () => { 97 | const sticky = createStickyProp() 98 | const props = createProps({ sticky }) 99 | const wrapper = mount(
) 100 | expect(wrapper.find('.rt-timeline__header-scroll').instance().scrollLeft).toBe(0) 101 | 102 | const nextProps = createProps({ height: 100, sticky }) 103 | wrapper.setProps(nextProps) 104 | expect(wrapper.find('.rt-timeline__header-scroll').instance().scrollLeft).toBe(0) 105 | }) 106 | 107 | it('calls the setHeaderHeight() prop when mounted', () => { 108 | const setHeaderHeight = jest.fn() 109 | const sticky = createStickyProp({ setHeaderHeight }) 110 | const props = createProps({ sticky }) 111 | mount(
) 112 | expect(setHeaderHeight).toBeCalled() 113 | }) 114 | 115 | it('makes the header sticky if isSticky is true', () => { 116 | const sticky = createStickyProp({ isSticky: true }) 117 | const props = createProps({ sticky }) 118 | const wrapper = mount(
) 119 | expect(wrapper.find('.rt-timeline__header').prop('className')).toMatch('is-sticky') 120 | }) 121 | 122 | it('makes the header static if isSticky is false', () => { 123 | const sticky = createStickyProp({ isSticky: false }) 124 | const props = createProps({ sticky }) 125 | const wrapper = mount(
) 126 | expect(wrapper.find('.rt-timeline__header').prop('className')).not.toMatch('is-sticky') 127 | }) 128 | 129 | it('sets the viewportWidth and height of the header if sticky', () => { 130 | const sticky = createStickyProp({ isSticky: true, viewportWidth: 100, headerHeight: 20 }) 131 | const props = createProps({ sticky }) 132 | const wrapper = mount(
) 133 | expect(wrapper.find('.rt-timeline__header').prop('style')).toEqual({ 134 | width: 100, 135 | height: 20, 136 | }) 137 | }) 138 | 139 | it('handles scroll events when sticky', () => { 140 | const handleHeaderScrollY = jest.fn() 141 | const sticky = createStickyProp({ isSticky: true, handleHeaderScrollY }) 142 | const props = createProps({ sticky }) 143 | const wrapper = mount(
) 144 | wrapper.find('.rt-timeline__header-scroll').simulate('scroll') 145 | expect(handleHeaderScrollY).toBeCalled() 146 | }) 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /demo/src/utils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-bitwise */ 2 | 3 | import { MONTHS_PER_YEAR } from './constants' 4 | 5 | export const fill = n => { 6 | const arr = [] 7 | for (let i = 0; i < n; i += 1) { 8 | arr.push(i) 9 | } 10 | return arr 11 | } 12 | 13 | const COLORS = ['FF005D', '0085B6', '0BB4C1', '00D49D', 'FEDF03', '233D4D', 'FE7F2D', 'FCCA46', 'A1C181', '579C87'] 14 | 15 | export const randomColor = () => COLORS[Math.floor(Math.random() * COLORS.length)] 16 | 17 | let color = -1 18 | export const nextColor = () => { 19 | color = (color + 1) % COLORS.length 20 | return COLORS[color] 21 | } 22 | 23 | // let prevColor = null 24 | // export const nextRandomColor = () => { 25 | // let c = randomColor() 26 | // while (c === prevColor) { 27 | // c = randomColor() 28 | // } 29 | // prevColor = c 30 | // return c 31 | // } 32 | 33 | // export const randomColor = () => { 34 | // const LETTERS = '0123456789ABCDEF' 35 | // let color = '' 36 | // for (let i = 0; i < 6; i += 1) { 37 | // color += LETTERS[Math.floor(Math.random() * 16)] 38 | // } 39 | // return color 40 | // } 41 | 42 | export const hexToRgb = hex => { 43 | const v = parseInt(hex, 16) 44 | const r = (v >> 16) & 255 45 | const g = (v >> 8) & 255 46 | const b = v & 255 47 | return [r, g, b] 48 | } 49 | 50 | export const colourIsLight = (r, g, b) => { 51 | const a = 1 - (0.299 * r + 0.587 * g + 0.114 * b) / 255 52 | return a < 0.5 53 | } 54 | 55 | export const addMonthsToYear = (year, monthsToAdd) => { 56 | let y = year 57 | let m = monthsToAdd 58 | while (m >= MONTHS_PER_YEAR) { 59 | m -= MONTHS_PER_YEAR 60 | y += 1 61 | } 62 | return { year: y, month: m + 1 } 63 | } 64 | 65 | export const addMonthsToYearAsDate = (year, monthsToAdd) => { 66 | const r = addMonthsToYear(year, monthsToAdd) 67 | return new Date(`${r.year}-${r.month}`) 68 | } 69 | 70 | // Credit: https://jsfiddle.net/katowulf/3gtDf/ 71 | const ADJECTIVES = [ 72 | 'adamant', 73 | 'adroit', 74 | 'amatory', 75 | 'animistic', 76 | 'antic', 77 | 'arcadian', 78 | 'baleful', 79 | 'bellicose', 80 | 'bilious', 81 | 'boorish', 82 | 'calamitous', 83 | 'caustic', 84 | 'cerulean', 85 | 'comely', 86 | 'concomitant', 87 | 'contumacious', 88 | 'corpulent', 89 | 'crapulous', 90 | 'defamatory', 91 | 'didactic', 92 | 'dilatory', 93 | 'dowdy', 94 | 'efficacious', 95 | 'effulgent', 96 | 'egregious', 97 | 'endemic', 98 | 'equanimous', 99 | 'execrable', 100 | 'fastidious', 101 | 'feckless', 102 | 'fecund', 103 | 'friable', 104 | 'fulsome', 105 | 'garrulous', 106 | 'guileless', 107 | 'gustatory', 108 | 'heuristic', 109 | 'histrionic', 110 | 'hubristic', 111 | 'incendiary', 112 | 'insidious', 113 | 'insolent', 114 | 'intransigent', 115 | 'inveterate', 116 | 'invidious', 117 | 'irksome', 118 | 'jejune', 119 | 'jocular', 120 | 'judicious', 121 | 'lachrymose', 122 | 'limpid', 123 | 'loquacious', 124 | 'luminous', 125 | 'mannered', 126 | 'mendacious', 127 | 'meretricious', 128 | 'minatory', 129 | 'mordant', 130 | 'munificent', 131 | 'nefarious', 132 | 'noxious', 133 | 'obtuse', 134 | 'parsimonious', 135 | 'pendulous', 136 | 'pernicious', 137 | 'pervasive', 138 | 'petulant', 139 | 'platitudinous', 140 | 'precipitate', 141 | 'propitious', 142 | 'puckish', 143 | 'querulous', 144 | 'quiescent', 145 | 'rebarbative', 146 | 'recalcitant', 147 | 'redolent', 148 | 'rhadamanthine', 149 | 'risible', 150 | 'ruminative', 151 | 'sagacious', 152 | 'salubrious', 153 | 'sartorial', 154 | 'sclerotic', 155 | 'serpentine', 156 | 'spasmodic', 157 | 'strident', 158 | 'taciturn', 159 | 'tenacious', 160 | 'tremulous', 161 | 'trenchant', 162 | 'turbulent', 163 | 'turgid', 164 | 'ubiquitous', 165 | 'uxorious', 166 | 'verdant', 167 | 'voluble', 168 | 'voracious', 169 | 'wheedling', 170 | 'withering', 171 | 'zealous', 172 | ] 173 | const NOUNS = [ 174 | 'ninja', 175 | 'chair', 176 | 'pancake', 177 | 'statue', 178 | 'unicorn', 179 | 'rainbows', 180 | 'laser', 181 | 'senor', 182 | 'bunny', 183 | 'captain', 184 | 'nibblets', 185 | 'cupcake', 186 | 'carrot', 187 | 'gnomes', 188 | 'glitter', 189 | 'potato', 190 | 'salad', 191 | 'toejam', 192 | 'curtains', 193 | 'beets', 194 | 'toilet', 195 | 'exorcism', 196 | 'stick figures', 197 | 'mermaid eggs', 198 | 'sea barnacles', 199 | 'dragons', 200 | 'jellybeans', 201 | 'snakes', 202 | 'dolls', 203 | 'bushes', 204 | 'cookies', 205 | 'apples', 206 | 'ice cream', 207 | 'ukulele', 208 | 'kazoo', 209 | 'banjo', 210 | 'opera singer', 211 | 'circus', 212 | 'trampoline', 213 | 'carousel', 214 | 'carnival', 215 | 'locomotive', 216 | 'hot air balloon', 217 | 'praying mantis', 218 | 'animator', 219 | 'artisan', 220 | 'artist', 221 | 'colorist', 222 | 'inker', 223 | 'coppersmith', 224 | 'director', 225 | 'designer', 226 | 'flatter', 227 | 'stylist', 228 | 'leadman', 229 | 'limner', 230 | 'make-up artist', 231 | 'model', 232 | 'musician', 233 | 'penciller', 234 | 'producer', 235 | 'scenographer', 236 | 'set decorator', 237 | 'silversmith', 238 | 'teacher', 239 | 'auto mechanic', 240 | 'beader', 241 | 'bobbin boy', 242 | 'clerk of the chapel', 243 | 'filling station attendant', 244 | 'foreman', 245 | 'maintenance engineering', 246 | 'mechanic', 247 | 'miller', 248 | 'moldmaker', 249 | 'panel beater', 250 | 'patternmaker', 251 | 'plant operator', 252 | 'plumber', 253 | 'sawfiler', 254 | 'shop foreman', 255 | 'soaper', 256 | 'stationary engineer', 257 | 'wheelwright', 258 | 'woodworkers', 259 | ] 260 | 261 | export const randomTitle = () => 262 | `${ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]} ${NOUNS[Math.floor(Math.random() * NOUNS.length)]}` 263 | -------------------------------------------------------------------------------- /src/components/Sidebar/TrackKeys/__tests__/TrackKey.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import TrackKey from '../TrackKey' 5 | import TrackKeys from '..' 6 | 7 | describe('', () => { 8 | describe('side component', () => { 9 | const sideComponent = Component 10 | const getSideComponent = node => node.find('.side-component') 11 | 12 | it('renders the side component if "sideComponent" exists', () => { 13 | const track = { title: 'test', isOpen: true, sideComponent } 14 | const wrapper = shallow() 15 | const component = getSideComponent(wrapper) 16 | expect(component.exists()).toBe(true) 17 | expect(component.text()).toEqual('Component') 18 | }) 19 | }) 20 | 21 | describe('link button', () => { 22 | const getButton = node => node.find('.rt-track-key__side-button') 23 | 24 | it('renders a button if "hasButton" is true and "clickTrackButton" exists', () => { 25 | const track = { title: 'test', isOpen: true, hasButton: true } 26 | const wrapper = shallow() 27 | expect(getButton(wrapper).exists()).toBe(true) 28 | }) 29 | 30 | it('does not render when "hasButton" is false', () => { 31 | const track = { title: 'test', isOpen: true } 32 | const wrapper = shallow() 33 | expect(getButton(wrapper).exists()).toBe(false) 34 | }) 35 | 36 | it('does not render when "sideComponent" is present', () => { 37 | const track = { 38 | title: 'test', 39 | isOpen: true, 40 | hasButton: true, 41 | sideComponent: Component, 42 | } 43 | const wrapper = shallow() 44 | expect(getButton(wrapper).exists()).toBe(false) 45 | }) 46 | 47 | it('does not render when "clickTrackButton" does not exist', () => { 48 | const track = { title: 'test', isOpen: true, hasButton: true } 49 | const wrapper = shallow() 50 | expect(getButton(wrapper).exists()).toBe(false) 51 | }) 52 | 53 | it('calls "clickTrackButton" with the track when clicked', () => { 54 | const track = { title: 'test', isOpen: true, hasButton: true } 55 | const clickTrackButton = jest.fn() 56 | const wrapper = shallow() 57 | const button = getButton(wrapper) 58 | 59 | expect(clickTrackButton).not.toBeCalled() 60 | button.simulate('click') 61 | expect(clickTrackButton).toBeCalledWith(track) 62 | }) 63 | }) 64 | 65 | describe('toggle button', () => { 66 | const getToggleButton = node => node.find('.rt-track-key__toggle') 67 | 68 | it('renders when "track.isOpen" is defined', () => { 69 | const props = { 70 | track: { title: 'test', isOpen: true }, 71 | toggleOpen: jest.fn(), 72 | } 73 | const wrapper = shallow() 74 | expect(getToggleButton(wrapper).exists()).toBe(true) 75 | }) 76 | 77 | it('does not render when "track.isOpen" is undefined', () => { 78 | const props = { 79 | track: { title: 'test', isOpen: undefined }, 80 | toggleOpen: jest.fn(), 81 | } 82 | const wrapper = shallow() 83 | expect(getToggleButton(wrapper).exists()).toBe(false) 84 | }) 85 | 86 | it('renders with the text "Close" when "track.isOpen" is "true"', () => { 87 | const props = { 88 | track: { title: 'test', isOpen: true }, 89 | toggleOpen: jest.fn(), 90 | } 91 | const wrapper = shallow() 92 | expect(getToggleButton(wrapper).text()).toBe('Close') 93 | }) 94 | 95 | it('renders with the text "Open" when "track.isOpen" is "false"', () => { 96 | const props = { 97 | track: { title: 'test', isOpen: false }, 98 | toggleOpen: jest.fn(), 99 | } 100 | const wrapper = shallow() 101 | expect(getToggleButton(wrapper).text()).toBe('Open') 102 | }) 103 | 104 | it('calls "toggleOpen()" when clicked passing "track" as a single argument', () => { 105 | const track = { 106 | title: 'test', 107 | isOpen: false, 108 | } 109 | const toggleOpen = jest.fn() 110 | const props = { 111 | track, 112 | toggleOpen, 113 | } 114 | const wrapper = shallow() 115 | getToggleButton(wrapper).simulate('click') 116 | expect(toggleOpen).toBeCalledWith(track) 117 | }) 118 | }) 119 | 120 | describe('', () => { 121 | it('renders when "isOpen" is truthy and "tracks" is not empty', () => { 122 | const props = { 123 | track: { title: 'test', tracks: [{}], isOpen: true }, 124 | toggleOpen: jest.fn(), 125 | } 126 | const wrapper = shallow() 127 | expect(wrapper.find(TrackKeys).exists()).toBe(true) 128 | }) 129 | 130 | it('does not render when "isOpen" is falsy', () => { 131 | const props = { 132 | track: { title: 'test', tracks: [{}], isOpen: false }, 133 | toggleOpen: jest.fn(), 134 | } 135 | const wrapper = shallow() 136 | expect(wrapper.find(TrackKeys).exists()).toBe(false) 137 | }) 138 | 139 | it('does not render when "tracks" is falsy', () => { 140 | const props = { 141 | track: { title: 'test', tracks: null, isOpen: true }, 142 | toggleOpen: jest.fn(), 143 | } 144 | const wrapper = shallow() 145 | expect(wrapper.find(TrackKeys).exists()).toBe(false) 146 | }) 147 | 148 | it('does not render when "tracks" is an empty array', () => { 149 | const props = { 150 | track: { title: 'test', tracks: [], isOpen: true }, 151 | toggleOpen: jest.fn(), 152 | } 153 | const wrapper = shallow() 154 | expect(wrapper.find(TrackKeys).exists()).toBe(false) 155 | }) 156 | }) 157 | }) 158 | -------------------------------------------------------------------------------- /src/components/Layout/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Sidebar from '../Sidebar' 5 | import Timeline from '../Timeline' 6 | import { addListener, removeListener } from '../../utils/events' 7 | import raf from '../../utils/raf' 8 | import getNumericPropertyValue from '../../utils/getNumericPropertyValue' 9 | 10 | const noop = () => {} 11 | 12 | class Layout extends PureComponent { 13 | constructor(props) { 14 | super(props) 15 | 16 | this.timeline = React.createRef() 17 | this.layout = React.createRef() 18 | this.sidebar = React.createRef() 19 | 20 | this.state = { 21 | isSticky: false, 22 | headerHeight: 0, 23 | scrollLeft: 0, 24 | } 25 | } 26 | 27 | componentDidMount() { 28 | const { enableSticky } = this.props 29 | 30 | if (enableSticky) { 31 | addListener('scroll', this.handleScrollY) 32 | this.updateTimelineHeaderScroll() 33 | this.updateTimelineBodyScroll() 34 | } 35 | 36 | addListener('resize', this.handleResize) 37 | this.handleLayoutChange(() => this.scrollToNow()) 38 | } 39 | 40 | componentDidUpdate(prevProps, prevState) { 41 | const { enableSticky, isOpen } = this.props 42 | const { isSticky, scrollLeft } = this.state 43 | 44 | if (enableSticky && isSticky) { 45 | if (!prevState.isSticky) { 46 | this.updateTimelineHeaderScroll() 47 | } 48 | 49 | if (scrollLeft !== prevState.scrollLeft) { 50 | this.updateTimelineBodyScroll() 51 | } 52 | } 53 | 54 | if (isOpen !== prevProps.isOpen) { 55 | this.handleLayoutChange() 56 | } 57 | } 58 | 59 | componentWillUnmount() { 60 | const { enableSticky } = this.props 61 | 62 | if (enableSticky) { 63 | removeListener('scroll', this.handleScrollY) 64 | removeListener('resize', this.handleResize) 65 | } 66 | } 67 | 68 | setHeaderHeight = headerHeight => { 69 | this.setState({ headerHeight }) 70 | } 71 | 72 | scrollToNow = () => { 73 | const { time, scrollToNow, now, timelineViewportWidth } = this.props 74 | if (scrollToNow) { 75 | this.timeline.current.scrollLeft = time.toX(now) - 0.5 * timelineViewportWidth 76 | } 77 | } 78 | 79 | updateTimelineBodyScroll = () => { 80 | const { scrollLeft } = this.state 81 | this.timeline.current.scrollLeft = scrollLeft 82 | } 83 | 84 | updateTimelineHeaderScroll = () => { 85 | const { scrollLeft } = this.timeline.current 86 | this.setState({ scrollLeft }) 87 | } 88 | 89 | handleHeaderScrollY = scrollLeft => { 90 | raf(() => { 91 | this.setState({ scrollLeft }) 92 | }) 93 | } 94 | 95 | handleScrollY = () => { 96 | raf(() => { 97 | const { headerHeight } = this.state 98 | const markerHeight = 0 99 | const { top, bottom } = this.timeline.current.getBoundingClientRect() 100 | const isSticky = top <= -markerHeight && bottom >= headerHeight 101 | this.setState(() => ({ isSticky })) 102 | }) 103 | } 104 | 105 | handleScrollX = () => { 106 | raf(this.updateTimelineHeaderScroll) 107 | } 108 | 109 | calculateSidebarWidth = () => 110 | this.sidebar.current.offsetWidth + getNumericPropertyValue(this.layout.current, 'margin-left') 111 | 112 | calculateTimelineViewportWidth = () => this.timeline.current.offsetWidth 113 | 114 | handleLayoutChange = cb => { 115 | const { sidebarWidth, timelineViewportWidth, onLayoutChange } = this.props 116 | 117 | const nextSidebarWidth = this.calculateSidebarWidth() 118 | const nextTimelineViewportWidth = this.calculateTimelineViewportWidth() 119 | if (nextSidebarWidth !== sidebarWidth || nextTimelineViewportWidth !== timelineViewportWidth) { 120 | onLayoutChange( 121 | { 122 | sidebarWidth: this.calculateSidebarWidth(), 123 | timelineViewportWidth: this.calculateTimelineViewportWidth(), 124 | }, 125 | cb 126 | ) 127 | } 128 | } 129 | 130 | handleResize = () => this.handleLayoutChange() 131 | 132 | render() { 133 | const { 134 | isOpen, 135 | tracks, 136 | now, 137 | time, 138 | timebar, 139 | toggleTrackOpen, 140 | sidebarWidth, 141 | timelineViewportWidth, 142 | clickElement, 143 | clickTrackButton, 144 | } = this.props 145 | 146 | const { isSticky, headerHeight, scrollLeft } = this.state 147 | return ( 148 |
149 |
150 | 157 |
158 |
159 |
160 | 175 |
176 |
177 |
178 | ) 179 | } 180 | } 181 | 182 | Layout.propTypes = { 183 | enableSticky: PropTypes.bool.isRequired, 184 | timebar: PropTypes.arrayOf(PropTypes.shape({})).isRequired, 185 | time: PropTypes.shape({ 186 | toX: PropTypes.func.isRequired, 187 | }).isRequired, 188 | tracks: PropTypes.arrayOf(PropTypes.shape({})).isRequired, 189 | now: PropTypes.instanceOf(Date), 190 | isOpen: PropTypes.bool, 191 | toggleTrackOpen: PropTypes.func, 192 | scrollToNow: PropTypes.bool, 193 | onLayoutChange: PropTypes.func.isRequired, 194 | sidebarWidth: PropTypes.number, 195 | timelineViewportWidth: PropTypes.number, 196 | clickElement: PropTypes.func, 197 | clickTrackButton: PropTypes.func, 198 | } 199 | 200 | export default Layout 201 | --------------------------------------------------------------------------------