diff --git a/src/components/ActivityTimeline/context.js b/src/components/ActivityTimeline/context.js new file mode 100644 index 000000000..85c6600e8 --- /dev/null +++ b/src/components/ActivityTimeline/context.js @@ -0,0 +1,4 @@ +import React from 'react'; + +export const ActivityTimelineContext = React.createContext(); +export const { Provider, Consumer } = ActivityTimelineContext; diff --git a/src/components/ActivityTimeline/helpers/getChildTimelineMarkersNodes.js b/src/components/ActivityTimeline/helpers/getChildTimelineMarkersNodes.js new file mode 100644 index 000000000..40aeed04b --- /dev/null +++ b/src/components/ActivityTimeline/helpers/getChildTimelineMarkersNodes.js @@ -0,0 +1,6 @@ +export default function getChildTimelineMarkersNodes(ref) { + if (ref) { + return ref.querySelectorAll('li[data-id="timeline-marker-li"]'); + } + return []; +} diff --git a/src/components/ActivityTimeline/helpers/index.js b/src/components/ActivityTimeline/helpers/index.js new file mode 100644 index 000000000..4d0eba303 --- /dev/null +++ b/src/components/ActivityTimeline/helpers/index.js @@ -0,0 +1,2 @@ +/* eslint-disable import/prefer-default-export */ +export { default as getChildTimelineMarkersNodes } from './getChildTimelineMarkersNodes'; diff --git a/src/components/ActivityTimeline/index.d.ts b/src/components/ActivityTimeline/index.d.ts index bb273668f..985a5fee9 100644 --- a/src/components/ActivityTimeline/index.d.ts +++ b/src/components/ActivityTimeline/index.d.ts @@ -1,8 +1,13 @@ -import { ReactNode } from 'react'; +import { ReactNode, MouseEvent } from 'react'; import { BaseProps } from '../types'; +type Names = string[] | string; + export interface ActivityTimelineProps extends BaseProps { children?: ReactNode; + multiple?: boolean; + onToggleSection?: (event: MouseEvent, name: Names) => void; + activeSectionNames?: Names; } export default function(props: ActivityTimelineProps): JSX.Element | null; diff --git a/src/components/ActivityTimeline/index.js b/src/components/ActivityTimeline/index.js index bf674f47d..cdda5ddd9 100644 --- a/src/components/ActivityTimeline/index.js +++ b/src/components/ActivityTimeline/index.js @@ -1,22 +1,101 @@ -import React from 'react'; +import React, { useRef, useState, useCallback, useMemo, useEffect } from 'react'; import PropTypes from 'prop-types'; +import isChildRegistered from '../InternalDropdown/helpers/isChildRegistered'; +import insertChildOrderly from '../InternalDropdown/helpers/insertChildOrderly'; +import { getChildTimelineMarkersNodes } from './helpers'; +import { Provider } from './context'; import StyledUl from './styled/ul'; /** - * The ActivityTimeline displays each of any item upcoming, current, and past activities. + * The ActivityTimeline displays each of any item's upcoming, current, and past activities in chronological order (ascending or descending). + * Notice that ActivityTimeline and TimelineMarker components are related and should be implemented together. * @category Layout */ export default function ActivityTimeline(props) { - const { children, className, style } = props; + const { + id, + children, + className, + style, + variant, + multiple, + activeSectionNames, + onToggleSection, + } = props; + const registeredTimelineMarkers = useRef([]); + const [activeNames, setActiveNames] = useState(activeSectionNames); + const containerRef = useRef(); + + useEffect(() => { + if ( + activeSectionNames && + activeSectionNames !== activeNames && + typeof onToggleSection === 'function' + ) { + setActiveNames(activeSectionNames); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeSectionNames, onToggleSection]); + + const privateRegisterMarker = useCallback((stepRef, stepProps) => { + if (isChildRegistered(stepProps.name, registeredTimelineMarkers.current)) return; + const [...nodes] = getChildTimelineMarkersNodes(containerRef.current); + const newStepsList = insertChildOrderly( + registeredTimelineMarkers.current, + { + ref: stepRef, + ...stepProps, + }, + nodes, + ); + registeredTimelineMarkers.current = newStepsList; + }, []); + + const privateUnregisterMarker = useCallback((stepRef, stepName) => { + if (!isChildRegistered(stepName, registeredTimelineMarkers.current)) return; + registeredTimelineMarkers.current = registeredTimelineMarkers.current.filter( + step => step.name !== stepName, + ); + }, []); + + const privateOnToggleMarker = useCallback( + (event, name) => { + if (typeof onToggleSection === 'function') { + return onToggleSection(event, name); + } + return setActiveNames(name); + }, + [onToggleSection], + ); + + const context = useMemo(() => { + return { + activeNames, + multiple, + isVariantAccordion: variant === 'accordion', + privateRegisterMarker, + privateUnregisterMarker, + privateOnToggleMarker, + }; + }, [ + variant, + activeNames, + multiple, + privateRegisterMarker, + privateUnregisterMarker, + privateOnToggleMarker, + ]); return ( - - {children} + + {children} ); } ActivityTimeline.propTypes = { + /** The id of the outer element. */ + id: PropTypes.string, /** * This prop that should not be visible in the documentation. * @ignore @@ -26,10 +105,32 @@ ActivityTimeline.propTypes = { className: PropTypes.string, /** An object with custom style applied to the outer element. */ style: PropTypes.object, + /** If true, expands multiples TimelineMarkers. + * This value defaults to false. */ + multiple: PropTypes.bool, + /** The variant changes the appearance of the timeline. Accepted variants include + * default and accordion. */ + variant: PropTypes.oneOf(['default', 'accordion']), + /** It contain the name of the TimelineMarker that is expanded. + * It is an array of string when multiple is true, + * or a string when when multiple is false. + * It must match the name of the TimelineMarker. */ + activeSectionNames: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.string), + PropTypes.string, + ]), + /** Action fired when a TimelineMarker is selected. + * The event params include the `name` of the selected TimelineMarker. */ + onToggleSection: PropTypes.func, }; ActivityTimeline.defaultProps = { + id: undefined, children: null, className: undefined, style: undefined, + variant: 'default', + multiple: false, + onToggleSection: undefined, + activeSectionNames: undefined, }; diff --git a/src/components/ActivityTimeline/readme.md b/src/components/ActivityTimeline/readme.md index 01c52c6bc..50eb28445 100644 --- a/src/components/ActivityTimeline/readme.md +++ b/src/components/ActivityTimeline/readme.md @@ -1,4 +1,5 @@ -##### ActivityTimeline base example: +# The basic ActivityTimeline +##### This example shows a simple activity timeline that vertically displays a list of events in chronological order. ```js import React from 'react'; @@ -37,3 +38,134 @@ const container = { width: 500, margin: 'auto', marginTop: 36, }; ``` + +# ActivityTimeline as accordion +##### ActivityTimeline can be used as an accordion. You can do so by implementing the `variant` prop. + +```js +import React, { useState, useMemo, useCallback, useEffect } from 'react'; +import { ActivityTimeline, TimelineMarker } from 'react-rainbow-components'; +import styled from 'styled-components'; + +const StyledContainer = styled.div` + margin: 36px 4rem 0; +`; + +const StyledContentContainer = styled.div.attrs(props => { + return props.theme.rainbow.palette; +})` + border-radius: 1rem; + padding: 1rem 2rem; + background: ${props => props.background.highlight}; + color: ${props => props.text.main}; +`; + +const StyledContentHeader = styled.h3` + font-weight: bold; + margin-bottom: 0.5rem; +`; + +const StyledItemsContainer = styled.h3` + display: flex; + align-items: center; +`; + +const StyledLabel = styled.span.attrs(props => { + return props.theme.rainbow.palette; +})` + color: ${props => props.text.label}; + width: 100%; + max-width: 140px; + padding: 0.5rem 0; +`; + +const StyledValue = styled.span.attrs(props => { + return props.theme.rainbow.palette; +})` + font-weight: bold; + color: ${props => props.text.main}; +`; + +const EventDetails = ({ uid, birthdate }) => { + return ( + + Details + + UID + {uid} + + + Date of Birthday + {birthdate} + + + ); +}; + +const AccordionActivityTimeline = ({ eventsList }) => { + const [activeEvents, setActiveEvents] = useState([]); + const [loading, setLoading] = useState(true); + const markers = useMemo(() => eventsList.map(event => { + const { name, user, datetime, description, details } = event; + const iconStyles = { width: 32, height: 32 }; + return ( + } + datetime={datetime} + description={description} + > + + + ); + }), [eventsList, loading]); + + useEffect(() => { + setTimeout(() => setLoading(false), 3000); + }, []); + + const handleToggleEvent = useCallback((event, name) => { + setActiveEvents(name); + }, []); + + return ( + + + {markers} + + + ); +}; + +const events = [ + { + name: 'event1', + description: 'Lorem ipsum dolor sit amet, consectetur adipiscing.', + user: 'Tahimi Leon', + datetime: '11:00 AM, Today', + details: { + uid: '1610482374420', + birthdate: 'January 12, 2021', + }, + }, + { + name: 'event2', + description: 'Lorem ipsum dolor sit amet, consectetur adipiscing.', + user: 'Tahimi Leon', + datetime: '11:00 AM, Today', + details: { + uid: '1610482374420', + birthdate: 'January 12, 2021', + }, + } +]; + +``` diff --git a/src/components/ActivityTimeline/styled/ul.js b/src/components/ActivityTimeline/styled/ul.js index 68e657d2a..2a3b0c700 100644 --- a/src/components/ActivityTimeline/styled/ul.js +++ b/src/components/ActivityTimeline/styled/ul.js @@ -8,6 +8,11 @@ const StyledUl = styled.ul` padding: 0; list-style: none; box-sizing: border-box; + ${props => + props.variant === 'accordion' && + ` + padding-left: 1.25rem; + `}; `; export default StyledUl; diff --git a/src/components/TimelineMarker/__test__/timelineMarker.spec.js b/src/components/TimelineMarker/__test__/timelineMarker.spec.js index 8aa559cbf..5161c4ba4 100644 --- a/src/components/TimelineMarker/__test__/timelineMarker.spec.js +++ b/src/components/TimelineMarker/__test__/timelineMarker.spec.js @@ -1,5 +1,6 @@ import React from 'react'; import { mount } from 'enzyme'; +import ActivityTimeline from '../../ActivityTimeline'; import TimelineMarker from '../index'; import Card from '../../Card'; import Avatar from '../../Avatar'; @@ -10,31 +11,51 @@ import StyledDescription from '../styled/description'; describe('', () => { it('should render the children passed', () => { const component = mount( - - - , + + + + + , ); expect(component.find(Card).exists()).toBe(true); }); it('should render the icon passed', () => { - const component = mount(} />); + const component = mount( + + } /> + , + ); expect(component.find(Avatar).exists()).toBe(true); }); it('should render the calendar icon by default', () => { - const component = mount(); + const component = mount( + + + , + ); expect(component.find('CalendarIcon').exists()).toBe(true); }); it('should render the label passed', () => { - const component = mount(); + const component = mount( + + + , + ); expect(component.find(StyledLabel).text()).toBe('testing label on TimelineMarker'); }); it('should render the datetime passed', () => { - const component = mount(); + const component = mount( + + + , + ); expect(component.find(StyledDatetime).text()).toBe('Yesterday'); }); it('should render the description passed', () => { const component = mount( - , + + , + , ); expect(component.find(StyledDescription).text()).toBe( 'testing description on TimelineMarker', diff --git a/src/components/TimelineMarker/expandCollapseButton.js b/src/components/TimelineMarker/expandCollapseButton.js new file mode 100644 index 000000000..56fab42d7 --- /dev/null +++ b/src/components/TimelineMarker/expandCollapseButton.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Button from './styled/button'; +import Spinner from '../Spinner'; +import SpinnerContainer from './styled/spinnerContainer'; +import RightArrow from './icons/rightArrow'; +import DownArrow from './icons/downArrow'; + +function getIcon(isExpanded) { + if (isExpanded) { + return ; + } + return ; +} + +export default function ExpandCollapseButton(props) { + const { isExpanded, isLoading, onClick } = props; + if (isLoading) { + return ( + + + + ); + } + return