import * as React from 'react' import * as PropTypes from 'prop-types' import randomId from '../lib/randomId' import logOrThrowError from '../lib/logOrThrowError' import Item from './Item' import ItemWrapper from './ItemWrapper' const DEFAULT_CLASSPREFIX = 'Accordion' const DEFAULT_IDPREFIX = 'accordion__' interface Props extends React.HTMLAttributes { children: React.ReactNode classPrefix?: string idPrefix?: string } interface State { selectedItemId: string | null } class Accordion extends React.Component { public static displayName = 'Accordion' public static propTypes = { classPrefix: PropTypes.string, idPrefix: PropTypes.string, // Validate child component types. children(props: Record, propName: string): Error | null { const children = props[propName] let hasInvalidChild = false if (!React.Children.count(children)) { return new Error( 'Accordion expects at least a single AccordionItem child' ) } React.Children.forEach( children as React.ReactElement, (child: React.ReactElement) => { if (child.type !== Item || !child.props.children) { hasInvalidChild = true return } } ) if (hasInvalidChild) { return new Error( 'Accordion expects children to be of type AccordionItem' ) } return null } } public readonly state: State = { selectedItemId: null } // A map of child components to their IDs. private idMap = new WeakMap() // Child ID -> item button ref map. private itemRefMap = new Map() constructor(props: Props) { super(props) // Set the initial IDs. React.Children.forEach(props.children, child => { const id = randomId(props.idPrefix || DEFAULT_IDPREFIX) this.idMap.set(child, id) }) } public render(): React.ReactElement { const { children, classPrefix = DEFAULT_CLASSPREFIX, // eslint-disable-next-line @typescript-eslint/no-unused-vars idPrefix, ...props } = this.props return (
{React.Children.map(children, this.renderChild)}
) } private renderChild = (child: any) => { // istanbul ignore if if (child.type !== Item) { logOrThrowError( 'Accordion: invalid child provided. Expecting AccordionItem only' ) return } // Ensure we update the ID map. if (!this.idMap.has(child)) { this.idMap.set(child, randomId(this.props.idPrefix || DEFAULT_IDPREFIX)) } const { classPrefix = DEFAULT_CLASSPREFIX } = this.props const { title, children } = child.props const childId = this.idMap.get(child) as string const isOpen = this.state.selectedItemId === childId return ( this.itemRefMap.set(childId, el) } > {children} ) } private handleToggleItem = (itemId: string) => { if (this.state.selectedItemId === itemId) { this.setState({ selectedItemId: null }) } else { this.setState({ selectedItemId: itemId }) } } private handleFocusShift = ( direction: 'start' | 'end' | 'forwards' | 'backwards', id: string ) => { const ids = Array.from(this.itemRefMap.keys()) const index = ids.indexOf(id) let nextIndex = -1 if (direction === 'start') { nextIndex = 0 } else if (direction === 'end') { nextIndex = ids.length - 1 } else if (direction === 'backwards') { if (index === 0) { nextIndex = ids.length - 1 } else { nextIndex = index - 1 } } else { if (index === ids.length - 1) { nextIndex = 0 } else { nextIndex = index + 1 } } const nextId = ids[nextIndex] const button = this.itemRefMap.get(nextId) if (!button) { logOrThrowError(`Accordion: unable to locate button "${nextId}"`) return } button.focus() } } export default Accordion