import { NavigationChevronDown, NavigationChevronRight } from '@repay/cactus-icons' import { border, CactusTheme, radius, shadow, space } from '@repay/cactus-theme' import PropTypes from 'prop-types' import React, { createContext, ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react' import { Transition } from 'react-transition-group' import styled, { StyledComponentBase } from 'styled-components' import { margin, MarginProps, maxWidth, MaxWidthProps, width, WidthProps } from 'styled-system' import { flexItem, FlexItemProps } from '../helpers/flexItem' import KeyCodes from '../helpers/keyCodes' import { omitProps } from '../helpers/omit' import useId from '../helpers/useId' import IconButton from '../IconButton/IconButton' export type AccordionVariants = 'simple' | 'outline' interface AccordionProps extends MarginProps, MaxWidthProps, WidthProps, FlexItemProps, React.HTMLAttributes { /** Does not apply when Accordion descends from a controlled Provider. If true, the * Accordion will begin in the open state when first rendered. */ defaultOpen?: boolean variant?: AccordionVariants useBoxShadows?: boolean } interface AccordionHeaderProps extends React.HTMLAttributes { render?: (opts: { isOpen: boolean; headerId: string }) => JSX.Element } interface AccordionBodyProps extends MarginProps, React.HTMLAttributes {} interface AccordionContextType { isOpen: boolean variant?: string bodyId?: string headerId?: string handleToggle?: () => void handleFocus?: (offset: number) => void focusFirst?: () => void focusLast?: () => void } interface AccordionProviderProps { /** Only used in uncontrolled variant. Determines maximum number of Accordions that * can be open at one time. */ maxOpen?: number /** Only used in controlled variant. Contains the IDs of all Accordions that should be * in an open state. */ openId?: string | string[] /** Called whenever an Accordion is toggled from open to closed or vice versa. The ID * of the toggled Accordion is passed to the function. */ onChange?: (changedId: string) => void children?: React.ReactNode } interface ManagedAccordions { [key: string]: { order: number } } interface AccordionProviderContext { manageOpen?: (name: string, open: boolean) => void manageFocus?: (id: string, offset: number) => void focusFirst?: () => void focusLast?: () => void isManaged: boolean isControlled: boolean openAccordions?: string[] registerAccordion?: (id: string, defaultOpen: boolean) => void unregisterAccordion?: (id: string) => void } const AccordionContext = createContext({ isOpen: false, variant: undefined, bodyId: undefined, headerId: undefined, handleToggle: undefined, handleFocus: undefined, focusFirst: undefined, focusLast: undefined, }) const AccordionHeaderBase = (props: AccordionHeaderProps): ReactElement => { const { children, render, ...rest } = props const { isOpen, bodyId, headerId, handleToggle, handleFocus, focusFirst, focusLast } = useContext(AccordionContext) const handleHeaderClick = (event: React.MouseEvent): void => { let element: HTMLElement | null = event.target as HTMLElement do { if (element.tagName === 'BUTTON') return element = element.parentElement } while (element !== null && element !== event.currentTarget) if (handleToggle && typeof handleToggle === 'function') { handleToggle() } } // Used to prevent default behavior/propagation const handleHeaderKeyDown = (event: React.KeyboardEvent): void => { const key = event.key if ( [ KeyCodes.UP, KeyCodes.DOWN, KeyCodes.HOME, KeyCodes.END, KeyCodes.RETURN, KeyCodes.SPACE, ].includes(key) ) { event.preventDefault() event.stopPropagation() } } const handleHeaderKeyUp = (event: React.KeyboardEvent): void => { const key = event.key switch (key) { case KeyCodes.SPACE: case KeyCodes.RETURN: { event.preventDefault() event.stopPropagation() if (handleToggle && typeof handleToggle === 'function') { handleToggle() } break } case KeyCodes.UP: case KeyCodes.DOWN: { event.preventDefault() event.stopPropagation() if (handleFocus && typeof handleFocus === 'function') { const offset = key === KeyCodes.UP ? -1 : 1 handleFocus(offset) } break } case KeyCodes.HOME: { event.preventDefault() event.stopPropagation() if (focusFirst && typeof focusFirst === 'function') { focusFirst() } break } case KeyCodes.END: { event.preventDefault() event.stopPropagation() if (focusLast && typeof focusLast === 'function') { focusLast() } break } } } return (
{isOpen ? ( {typeof render === 'function' && headerId !== undefined ? render({ isOpen, headerId }) : children}
) } // Not sure we actually need the min-height now I've added padding, but don't want to risk it. export const AccordionHeader = styled(AccordionHeaderBase)` box-sizing: border-box; width: 100%; min-height: 48px; display: flex; align-items: center; cursor: pointer; outline: none; word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; padding: ${space(3)} 0; > :not(:first-child) { min-width: 1px; flex: 1; } &::-moz-focus-inner { border: 0; } p, h1, h2, h3, h4 { margin: 0; } ` const TIMEOUT = 200 // Accessing the `scrollTop` property forces a page reflow. const forceReflow = (node: HTMLElement) => node.scrollTop // CSSTransition does some of this for you, but unfortunately it can't do // transitions with `auto` height; we have to get the height dynamically. const TRANSITION = { timeout: TIMEOUT, style: { overflow: 'hidden', width: '100%', transition: `height ${TIMEOUT}ms ease-in` }, onEnter: function (this: { height: number }, node: HTMLElement) { this.height = node.getBoundingClientRect().height node.style.height = '0' forceReflow(node) }, onEntering: function (this: { height: number }, node: HTMLElement) { node.style.height = `${this.height}px` }, onEntered: (node: HTMLElement) => { node.style.height = '' }, onExit: (node: HTMLElement) => { node.style.height = `${node.getBoundingClientRect().height}px` forceReflow(node) }, onExiting: (node: HTMLElement) => { node.style.height = '0' }, onExited: (node: HTMLElement) => { node.style.height = '' }, } const useTransition = () => { const ref = useRef(TRANSITION) if (ref.current === TRANSITION) { const height = { height: 0 } ref.current = { ...ref.current, onEnter: TRANSITION.onEnter.bind(height), onEntering: TRANSITION.onEntering.bind(height), } } return ref.current } const AccordionBodyBase = (props: AccordionBodyProps): ReactElement | null => { const { isOpen, bodyId, headerId } = useContext(AccordionContext) const transition = useTransition() return (
) } export const AccordionBody = styled(AccordionBodyBase).withConfig( omitProps(margin, 'width') )` margin-top: ${space(5)}; margin-bottom: ${space(7)}; // The extra specificity is to override the defaults in variantStyles. &&& { ${margin} } ` const ProviderContext = createContext({ manageOpen: undefined, manageFocus: undefined, focusFirst: undefined, focusLast: undefined, isManaged: false, isControlled: false, openAccordions: undefined, registerAccordion: undefined, unregisterAccordion: undefined, }) let order = 0 export const AccordionProvider = (props: AccordionProviderProps): ReactElement => { const { maxOpen = 1, onChange, openId } = props const isControlled = typeof openId !== 'undefined' const [uncontrolledOpen, setUncontrolledOpen] = useState([]) const openAccordions = useMemo(() => { if (isControlled) { const openIds = Array.isArray(openId) ? openId : [openId] return openIds as string[] } return uncontrolledOpen }, [isControlled, openId, uncontrolledOpen]) const managedAccordions = useRef({}) useEffect(() => { const closeOldestOpenAccordion = () => { const accordions = managedAccordions.current let allOpen = uncontrolledOpen.filter((o) => Object.keys(accordions).includes(o)) allOpen.sort((a, b): number => { if (accordions[a].order < accordions[b].order) { return -1 } else if (accordions[a].order > accordions[b].order) { return 1 } return 0 }) const accordionToClose = allOpen[0] allOpen = allOpen.slice(1) onChange?.(accordionToClose) setUncontrolledOpen(allOpen) } if (!isControlled) { if (uncontrolledOpen.length > maxOpen) { closeOldestOpenAccordion() } } }, [isControlled, maxOpen, onChange, uncontrolledOpen]) const registerAccordion = useCallback( (id: string, defaultOpen: boolean) => { managedAccordions.current[id] = { order: order++ } if (!isControlled) { setUncontrolledOpen((previousOpen) => { if (!defaultOpen || previousOpen.includes(id)) { return previousOpen } return [...previousOpen, id] }) } }, [isControlled] ) const unregisterAccordion = useCallback((id: string) => { setUncontrolledOpen((previousOpen) => previousOpen.filter((o) => o !== id)) delete managedAccordions.current[id] }, []) const manageOpen = useCallback( (id: string, open: boolean): void => { onChange?.(id) if (!isControlled) { managedAccordions.current[id] = { order: order++ } setUncontrolledOpen((previousOpen) => { if (open) { return [...previousOpen, id] } return previousOpen.filter((existingId) => existingId !== id) }) } }, [isControlled, onChange] ) const focusById = (id: string): void => { const focusAccordion = document.getElementById(id) if (focusAccordion) { const toFocus = focusAccordion.querySelector( 'button[data-role="accordion-button"]' ) as HTMLElement toFocus.focus() } } const getOrderedIds = (): string[] => Array.from( document.querySelectorAll(`#${Object.keys(managedAccordions.current).join(',#')}`) ).map((el): string => el.id) const manageFocus = (id: string, offset: number): void => { if (Object.keys(managedAccordions.current).length > 1) { const orderedIds = getOrderedIds() const currentIndex = orderedIds.indexOf(id) let nextIndex = currentIndex + offset if (nextIndex < 0) { nextIndex = orderedIds.length - 1 } else if (nextIndex >= orderedIds.length) { nextIndex = 0 } focusById(orderedIds[nextIndex]) } } const focusFirst = (): void => { if (Object.keys(managedAccordions.current).length > 1) { const orderedIds = getOrderedIds() focusById(orderedIds[0]) } } const focusLast = (): void => { if (Object.keys(managedAccordions.current).length > 1) { const orderedIds = getOrderedIds() focusById(orderedIds[orderedIds.length - 1]) } } return ( {props.children} ) } AccordionProvider.propTypes = { maxOpen: ( props: AccordionProviderProps, name: keyof AccordionProviderProps, compName: string ) => { const val = props[name] if (val) { if (props.openId) { return new Error( 'You provided a maxOpen prop to a controlled AccordionProvider. This prop only has an effect on uncontrolled providers.' ) } if (typeof val !== 'number') { return new Error( `Invalid prop "${name}" supplied to "${compName}". Expected "number", received "${ Array.isArray(val) ? 'array' : typeof val }".` ) } } return null }, onChange: PropTypes.func, openId: (props: AccordionProviderProps, name: keyof AccordionProviderProps, compName: string) => { const val = props[name] if (val) { if (!props.onChange) { return new Error( 'You provided an openId prop to AccordionProvider without an onChange handler. This will render a read-only accordion element. If the accordion should be functional, remove the index value to render an uncontrolled accordion or set an onChange handler to set an index when a change occurs.' ) } if (Array.isArray(val)) { return val.some((i: any) => typeof i !== 'string') ? new Error( 'You provided an array as an openId in AccordionProvider but one or more of the values are not strings. Please check to make sure all openId values are valid strings.' ) : null } else if (typeof val !== 'string') { return new Error( `Invalid prop "${name}" supplied to "${compName}". Expected "string" or "string[]", received "${ Array.isArray(val) ? 'array' : typeof val }".` ) } } return null }, } interface AccordionComponent extends StyledComponentBase<'div', CactusTheme, AccordionProps> { Header: React.ComponentType Body: React.ComponentType Provider: React.ComponentType } const toggle = (state: boolean) => !state const AccordionBase = (props: AccordionProps): ReactElement => { const { manageOpen, manageFocus, focusFirst, focusLast, isManaged, isControlled, openAccordions, registerAccordion, unregisterAccordion, } = useContext(ProviderContext) const { defaultOpen = false, variant, ...restProps } = props const id = useId(props.id) const headerId = `${id}-header` const bodyId = `${id}-body` const [unmanagedState, toggleUnmanagedState] = React.useReducer(toggle, defaultOpen) const isInitialRender = useRef(true) useEffect((): (() => void) => { isInitialRender.current = true if (isManaged) { registerAccordion?.(id, defaultOpen) } return (): void => { unregisterAccordion?.(id) } }, [defaultOpen, id, isManaged, registerAccordion, unregisterAccordion]) useEffect(() => { isInitialRender.current = false }) if (process.env.NODE_ENV !== 'production' && isControlled && defaultOpen) { console.warn( 'The defaultOpen prop on Accordion has no effect when the state of the component is controlled.' ) } const getManagedOpen = () => { if (!isControlled && isInitialRender.current) { return defaultOpen } return openAccordions ? openAccordions.includes(id) : false } const toggleOpen = (): void => { if (isManaged) { manageOpen?.(id, !getManagedOpen()) } else { toggleUnmanagedState() } } const shiftFocus = (offset: number): void => { if (isManaged) { manageFocus?.(id, offset) } } const focusFirstAccordion = (): void => { if (isManaged) { focusFirst?.() } } const focusLastAccordion = (): void => { if (isManaged) { focusLast?.() } } const open = isManaged ? getManagedOpen() : unmanagedState return (
{props.children}
) } const variantStyles = (props: AccordionProps & { theme: CactusTheme }): string => { const borderCSS = border(props, 'lightContrast') if (props.variant === 'outline') { let shadowCSS = (props.useBoxShadows && shadow(props, 1)) || '' if (shadowCSS) shadowCSS += 'border: none;' return ` border: ${borderCSS}; border-radius: ${radius(props, 8)}; &[aria-expanded='true'] { ${shadowCSS} ${AccordionHeader} { border-bottom: ${borderCSS}; } } & + ${Accordion} { margin-top: ${space(props, 3)}; } ${AccordionHeader} { padding-left: ${space(props, 4)}; padding-right: ${space(props, 4)}; } ${AccordionBody} { margin-left: ${space(props, 7)}; margin-right: ${space(props, 7)}; } ` } return ` border-bottom: ${borderCSS}; &:first-of-type { border-top: ${borderCSS}; } ` } export const Accordion = styled(AccordionBase).withConfig( omitProps(flexItem, margin, width, maxWidth, 'useBoxShadows') )` box-sizing: border-box; width: 100%; ${variantStyles} ${margin} ${width} ${maxWidth} ${flexItem} ` as any Accordion.defaultProps = { defaultOpen: false, variant: 'simple', useBoxShadows: true, } Accordion.propTypes = { defaultOpen: PropTypes.bool, variant: PropTypes.oneOf(['simple', 'outline']), useBoxShadows: PropTypes.bool, } Accordion.Header = AccordionHeader Accordion.Body = AccordionBody Accordion.Provider = AccordionProvider export default Accordion as AccordionComponent