|  | 
|  | 1 | +import clsx from 'clsx'; | 
|  | 2 | +import { GessoComponent } from 'gesso'; | 
|  | 3 | +import { KeyboardEvent, createRef, useId, useMemo, useState } from 'react'; | 
|  | 4 | +import styles from './accordion.module.css'; | 
|  | 5 | +import getCssVar from '../../06-utility/getCssVar'; | 
|  | 6 | +import { KEYCODE } from '../../00-config/constants'; | 
|  | 7 | +import AccordionItem, { AccordionItemProps } from './AccordionItem'; | 
|  | 8 | + | 
|  | 9 | +interface AccordionProps extends GessoComponent { | 
|  | 10 | +  accordionItems: AccordionItemProps[]; | 
|  | 11 | +  accordionSpeed?: string; | 
|  | 12 | +  allowMultiple?: boolean; | 
|  | 13 | +  allowToggle?: boolean; | 
|  | 14 | +} | 
|  | 15 | + | 
|  | 16 | +function Accordion({ | 
|  | 17 | +  accordionItems, | 
|  | 18 | +  accordionSpeed = getCssVar('duration-standard'), | 
|  | 19 | +  allowMultiple, | 
|  | 20 | +  allowToggle, | 
|  | 21 | +  modifierClasses, | 
|  | 22 | +}: AccordionProps): JSX.Element { | 
|  | 23 | +  const accordionId = useId(); | 
|  | 24 | +  const [accordionItemsStatus, setAccordionItemsStatus] = useState( | 
|  | 25 | +    accordionItems.map((item, index) => ({ | 
|  | 26 | +      ...item, | 
|  | 27 | +      id: `${accordionId}-${index}`, | 
|  | 28 | +    })), | 
|  | 29 | +  ); | 
|  | 30 | +  const accordionItemRefs = useMemo(() => { | 
|  | 31 | +    const refs: { [key: string]: React.RefObject<HTMLButtonElement> } = {}; | 
|  | 32 | +    accordionItemsStatus.forEach(item => (refs[item.id] = createRef())); | 
|  | 33 | +    return refs; | 
|  | 34 | +  }, [accordionItemsStatus]); | 
|  | 35 | + | 
|  | 36 | +  const openAccordionItem = (items: AccordionItemProps[], index: number) => { | 
|  | 37 | +    return items.with(index, { | 
|  | 38 | +      ...items[index], | 
|  | 39 | +      isOpen: true, | 
|  | 40 | +    }); | 
|  | 41 | +  }; | 
|  | 42 | + | 
|  | 43 | +  const closeAccordionItem = (items: AccordionItemProps[], index: number) => { | 
|  | 44 | +    return items.with(index, { | 
|  | 45 | +      ...items[index], | 
|  | 46 | +      isOpen: false, | 
|  | 47 | +    }); | 
|  | 48 | +  }; | 
|  | 49 | + | 
|  | 50 | +  const handleClick = (id: string, isOpen = false) => { | 
|  | 51 | +    const toggleAllowed = allowMultiple ? true : allowToggle; | 
|  | 52 | +    const active = accordionItemsStatus.findIndex(item => item.isOpen); | 
|  | 53 | +    const itemIndex = accordionItemsStatus.findIndex(item => item.id === id); | 
|  | 54 | +    let itemStatusUpdated = [...accordionItemsStatus]; | 
|  | 55 | + | 
|  | 56 | +    // Without allowMultiple, close the open accordion | 
|  | 57 | +    if (!allowMultiple && active !== -1 && active !== itemIndex) { | 
|  | 58 | +      itemStatusUpdated = closeAccordionItem(itemStatusUpdated, active); | 
|  | 59 | +    } | 
|  | 60 | + | 
|  | 61 | +    if (!isOpen) { | 
|  | 62 | +      itemStatusUpdated = openAccordionItem(itemStatusUpdated, itemIndex); | 
|  | 63 | +    } else if (toggleAllowed && isOpen) { | 
|  | 64 | +      itemStatusUpdated = closeAccordionItem(itemStatusUpdated, itemIndex); | 
|  | 65 | +    } | 
|  | 66 | + | 
|  | 67 | +    return setAccordionItemsStatus(itemStatusUpdated); | 
|  | 68 | +  }; | 
|  | 69 | + | 
|  | 70 | +  const handleKeydown = (event: KeyboardEvent) => { | 
|  | 71 | +    const currentTarget = event.target as HTMLButtonElement; | 
|  | 72 | + | 
|  | 73 | +    // Create the array of toggle elements for the accordion group | 
|  | 74 | +    const triggers = Object.values(accordionItemRefs).map(ref => ref.current); | 
|  | 75 | + | 
|  | 76 | +    // Is this coming from an accordion header? | 
|  | 77 | +    if (triggers && currentTarget.tagName === 'BUTTON') { | 
|  | 78 | +      // Up/ Down arrow and Control + Page Up/ Page Down keyboard operations | 
|  | 79 | +      if ( | 
|  | 80 | +        event.code === KEYCODE.UP || | 
|  | 81 | +        event.code === KEYCODE.DOWN || | 
|  | 82 | +        event.code === KEYCODE.PAGEUP || | 
|  | 83 | +        event.code === KEYCODE.PAGEDOWN | 
|  | 84 | +      ) { | 
|  | 85 | +        const index = triggers.indexOf(currentTarget); | 
|  | 86 | +        let direction; | 
|  | 87 | +        if (event.code === KEYCODE.DOWN || event.code === KEYCODE.PAGEDOWN) { | 
|  | 88 | +          direction = 1; | 
|  | 89 | +        } else { | 
|  | 90 | +          direction = -1; | 
|  | 91 | +        } | 
|  | 92 | +        const triggerLength = triggers.length; | 
|  | 93 | +        const newIndex = (index + triggerLength + direction) % triggerLength; | 
|  | 94 | +        triggers[newIndex]?.focus(); | 
|  | 95 | +        event.preventDefault(); | 
|  | 96 | +      } else if (event.code === KEYCODE.HOME || event.code === KEYCODE.END) { | 
|  | 97 | +        switch (event.code) { | 
|  | 98 | +          // Go to first accordion | 
|  | 99 | +          case KEYCODE.HOME: | 
|  | 100 | +            triggers[0]?.focus(); | 
|  | 101 | +            break; | 
|  | 102 | +          // Go to last accordion | 
|  | 103 | +          case KEYCODE.END: | 
|  | 104 | +            triggers[triggers.length - 1]?.focus(); | 
|  | 105 | +            break; | 
|  | 106 | +          default: | 
|  | 107 | +            triggers[0]?.focus(); | 
|  | 108 | +            break; | 
|  | 109 | +        } | 
|  | 110 | +        event.preventDefault(); | 
|  | 111 | +      } | 
|  | 112 | +    } | 
|  | 113 | +  }; | 
|  | 114 | + | 
|  | 115 | +  return ( | 
|  | 116 | +    <> | 
|  | 117 | +      <div | 
|  | 118 | +        className={clsx(styles.accordion, modifierClasses)} | 
|  | 119 | +        id={accordionId} | 
|  | 120 | +        onKeyDown={handleKeydown} | 
|  | 121 | +      > | 
|  | 122 | +        <div className={styles.content}> | 
|  | 123 | +          {accordionItemsStatus.map(item => { | 
|  | 124 | +            return ( | 
|  | 125 | +              <AccordionItem | 
|  | 126 | +                key={item.id} | 
|  | 127 | +                {...item} | 
|  | 128 | +                accordionSpeed={accordionSpeed} | 
|  | 129 | +                toggleRef={accordionItemRefs[item.id]} | 
|  | 130 | +                handleClick={() => handleClick(item.id, item.isOpen)} | 
|  | 131 | +              /> | 
|  | 132 | +            ); | 
|  | 133 | +          })} | 
|  | 134 | +        </div> | 
|  | 135 | +      </div> | 
|  | 136 | +    </> | 
|  | 137 | +  ); | 
|  | 138 | +} | 
|  | 139 | + | 
|  | 140 | +export default Accordion; | 
0 commit comments