WIP: Accordion
This implements the Accordion pattern, with an emphasis on individual accordion items as opposed to groups. This includes a useAccordionItem hook, a useAccordionItemState hook, and a RAC AccordionItem component.
Features:
- Will support other interactive items inside the Header (i.e. Menu)
- Will implement hidden=until-found
- Will not be a collection component
Example RAC usage:
<AccordionItem>
{({isOpen}) => (
<>
<Header>
<Heading level={3}>
<Button slot="trigger">{isOpen ? '⬇️' : '➡️'} This is an accordion header</Button>
</Heading>
</Header>
<AccordionPanel>
<p>This is the content of the accordion panel.</p>
</AccordionPanel>
</>
)}
</AccordionItem>
With regards to an Accordion (or AccordionGroup) wrapper around AccordionItem elements, there is no ARIA semantics for that element, and keyboard arrow navigation is listed as optional, so we're going to prioritize the individual AccordionItem element for now. The main benefit would be having an uncontrolled mutually exclusive open state, but this can be achieved with controlled items for now.
✅ Pull Request Checklist:
- [ ] Included link to corresponding React Spectrum GitHub Issue.
- [ ] Added/updated unit tests and storybook for this change (for new code or code which already has tests).
- [ ] Filled out test instructions.
- [ ] Updated documentation (if it already exists for this component).
- [ ] Looked at the Accessibility Practices for this feature - Aria Practices
📝 Test Instructions:
🧢 Your Project:
Build successful! 🎉
Hey @reidbarber thanks getting to work on this!
I've got a couple of questions regarding the spec the team chose here and would love to provide some input from our own learnings as we implemented both <Disclosure /> and <Accordion /> pattern using react-aria.
- Is there a reason not to follow the
<...Trigger/>pattern instead of<AccordionItem>? I could imagine your spec of additional interactive elements inside the<Header />introducing this constraint?
<DisclosureTrigger isOpen>
<Heading level={3}>
<Button />
</Heading>
<Disclosure>
<Text>I am controlled.</Text>
</Disclosure>
</DisclosureTrigger>
- Since the W3C patterns do not collide in their ARIA properties or interactivity, would there be reasoning against re-cycling
<Disclosure />inside<Accordion />, similar to how<Checkbox/>works in both grouped and ungrouped scenario?
const { buttonProps, regionProps } = isNil(ctxState)
? // eslint-disable-next-line react-hooks/rules-of-hooks
useDisclosureTrigger(state)
: // eslint-disable-next-line react-hooks/rules-of-hooks
useAccordionItem(props, ctxState, objectRef);
- Have you guys considered adding something like
useDisclosureAnimation()?
export const useDisclosureAnimation = (
...option: AtomicDisclosureAnimationOptions
): AtomicDisclosureAnimation => {
const [ref, isOpen] = option;
const previous = useRef(isOpen);
const [, rerender] = useState({});
const isAnimating = previous.current !== isOpen;
const onEnd = useCallback(() => rerender({}), []);
if (isAnimating) previous.current = isOpen;
useAnimation(ref, isAnimating, onEnd);
return {
isOpening: isOpen && isAnimating,
isClosing: !isOpen && isAnimating,
};
};
- Does the team already have an idea how to work in arrow key navigation inside
<Accordion />? I suppose it would work similar to the<Tabs />pattern but i would be very interested in any guidance.
@nwidynski Thanks for the input! We are definitely still considering different API options, so I appreciate the comments.
Is there a reason not to follow the
<...Trigger/>pattern instead of<AccordionItem>?
I think the main reason is that we want to support additional interactive elements adjacent to the accordion header, and that pattern usually means the entire first child is the trigger.
would there be reasoning against re-cycling
<Disclosure />inside<Accordion />
We haven't given much thought into including a disclosure component, but as it seems like a simple subset of accordion features, I think we could consider having accordion build on top it, maybe at the hook level.
Have you guys considered adding something like
useDisclosureAnimation()
This looks great! I think we definitely want to include these states.
Does the team already have an idea how to work in arrow key navigation inside
<Accordion />?
Arrow key navigation between items is optional in ARIA, so we're still considering options for this.
Build successful! 🎉
Build successful! 🎉
Build successful! 🎉
Build successful! 🎉
Build successful! 🎉
Build successful! 🎉
Build successful! 🎉
Build successful! 🎉
Build successful! 🎉
Build successful! 🎉
Build successful! 🎉
Build successful! 🎉
Build successful! 🎉
Build successful! 🎉
Build successful! 🎉
Build successful! 🎉
## API Changes
react-aria-components
/react-aria-components:Disclosure
+Disclosure {
+ children?: ReactNode | ((DisclosureRenderProps & {
+ defaultChildren: ReactNode | undefined
+})) => ReactNode
+ className?: string | ((DisclosureRenderProps & {
+ defaultClassName: string | undefined
+})) => string
+ defaultExpanded?: boolean
+ isDisabled?: boolean
+ isExpanded?: boolean
+ onExpandedChange?: (boolean) => void
+ onHoverChange?: (boolean) => void
+ onHoverEnd?: (HoverEvent) => void
+ onHoverStart?: (HoverEvent) => void
+ slot?: string | null
+ style?: CSSProperties | ((DisclosureRenderProps & {
+ defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+}
/react-aria-components:DisclosurePanel
+DisclosurePanel {
+ children: ReactNode
+ className?: string | (({
+
+} & {
+ defaultClassName: string | undefined
+})) => string
+ role?: 'group' | 'region' = 'group'
+ style?: CSSProperties | (({
+
+} & {
+ defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+}
/react-aria-components:DisclosureStateContext
+DisclosureStateContext {
+ UNTYPED
+}
/react-aria-components:DisclosureContext
+DisclosureContext {
+ UNTYPED
+}
/react-aria-components:DisclosureProps
+DisclosureProps {
+ children?: ReactNode | ((DisclosureRenderProps & {
+ defaultChildren: ReactNode | undefined
+})) => ReactNode
+ className?: string | ((DisclosureRenderProps & {
+ defaultClassName: string | undefined
+})) => string
+ defaultExpanded?: boolean
+ isDisabled?: boolean
+ isExpanded?: boolean
+ onExpandedChange?: (boolean) => void
+ onHoverChange?: (boolean) => void
+ onHoverEnd?: (HoverEvent) => void
+ onHoverStart?: (HoverEvent) => void
+ slot?: string | null
+ style?: CSSProperties | ((DisclosureRenderProps & {
+ defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+}
/react-aria-components:DisclosurePanelProps
+DisclosurePanelProps {
+ children: ReactNode
+ className?: string | (({
+
+} & {
+ defaultClassName: string | undefined
+})) => string
+ role?: 'group' | 'region' = 'group'
+ style?: CSSProperties | (({
+
+} & {
+ defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+}
@react-spectrum/accordion
/@react-spectrum/accordion:Accordion
-Accordion <T extends {}> {
+Accordion {
UNSAFE_className?: string
UNSAFE_style?: CSSProperties
alignSelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'center' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'stretch'>
+ aria-describedby?: string
+ aria-details?: string
+ aria-label?: string
+ aria-labelledby?: string
bottom?: Responsive<DimensionValue>
- children: CollectionChildren<{}>
- defaultExpandedKeys?: Iterable<Key>
- disabledKeys?: Iterable<Key>
+ children: React.ReactNode
end?: Responsive<DimensionValue>
- expandedKeys?: Iterable<Key>
flex?: Responsive<string | number | boolean>
flexBasis?: Responsive<number | string>
flexGrow?: Responsive<number>
flexShrink?: Responsive<number>
gridArea?: Responsive<string>
gridColumn?: Responsive<string>
gridColumnEnd?: Responsive<string>
gridColumnStart?: Responsive<string>
gridRow?: Responsive<string>
gridRowEnd?: Responsive<string>
gridRowStart?: Responsive<string>
height?: Responsive<DimensionValue>
id?: string
isHidden?: Responsive<boolean>
- items?: Iterable<{}>
justifySelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'center' | 'left' | 'right' | 'stretch'>
left?: Responsive<DimensionValue>
margin?: Responsive<DimensionValue>
marginBottom?: Responsive<DimensionValue>
marginEnd?: Responsive<DimensionValue>
marginStart?: Responsive<DimensionValue>
marginTop?: Responsive<DimensionValue>
marginX?: Responsive<DimensionValue>
marginY?: Responsive<DimensionValue>
maxHeight?: Responsive<DimensionValue>
maxWidth?: Responsive<DimensionValue>
minHeight?: Responsive<DimensionValue>
minWidth?: Responsive<DimensionValue>
- onExpandedChange?: (Set<Key>) => any
order?: Responsive<number>
position?: Responsive<'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'>
right?: Responsive<DimensionValue>
start?: Responsive<DimensionValue>
width?: Responsive<DimensionValue>
zIndex?: Responsive<number>
}
/@react-spectrum/accordion:Item
-Item <T> {
- props: ItemProps<T>
- returnVal: undefined
-}
/@react-spectrum/accordion:SpectrumAccordionProps
-SpectrumAccordionProps <T> {
+SpectrumAccordionProps {
UNSAFE_className?: string
UNSAFE_style?: CSSProperties
alignSelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'center' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'stretch'>
+ aria-describedby?: string
+ aria-details?: string
+ aria-label?: string
+ aria-labelledby?: string
bottom?: Responsive<DimensionValue>
- children: CollectionChildren<T>
- defaultExpandedKeys?: Iterable<Key>
- disabledKeys?: Iterable<Key>
+ children: React.ReactNode
end?: Responsive<DimensionValue>
- expandedKeys?: Iterable<Key>
flex?: Responsive<string | number | boolean>
flexBasis?: Responsive<number | string>
flexGrow?: Responsive<number>
flexShrink?: Responsive<number>
gridArea?: Responsive<string>
gridColumn?: Responsive<string>
gridColumnEnd?: Responsive<string>
gridColumnStart?: Responsive<string>
gridRow?: Responsive<string>
gridRowEnd?: Responsive<string>
gridRowStart?: Responsive<string>
height?: Responsive<DimensionValue>
id?: string
isHidden?: Responsive<boolean>
- items?: Iterable<T>
justifySelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'center' | 'left' | 'right' | 'stretch'>
left?: Responsive<DimensionValue>
margin?: Responsive<DimensionValue>
marginBottom?: Responsive<DimensionValue>
marginEnd?: Responsive<DimensionValue>
marginStart?: Responsive<DimensionValue>
marginTop?: Responsive<DimensionValue>
marginX?: Responsive<DimensionValue>
marginY?: Responsive<DimensionValue>
maxHeight?: Responsive<DimensionValue>
maxWidth?: Responsive<DimensionValue>
minHeight?: Responsive<DimensionValue>
minWidth?: Responsive<DimensionValue>
- onExpandedChange?: (Set<Key>) => any
order?: Responsive<number>
position?: Responsive<'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'>
right?: Responsive<DimensionValue>
start?: Responsive<DimensionValue>
width?: Responsive<DimensionValue>
zIndex?: Responsive<number>
}
/@react-spectrum/accordion:Disclosure
+Disclosure {
+ aria-describedby?: string
+ aria-details?: string
+ aria-label?: string
+ aria-labelledby?: string
+ children: [ReactElement<SpectrumDisclosureHeaderProps>, ReactElement<SpectrumDisclosurePanelProps>]
+ className?: string | ((DisclosureRenderProps & {
+ defaultClassName: string | undefined
+})) => string
+ defaultExpanded?: boolean
+ id?: string
+ isDisabled?: boolean
+ isExpanded?: boolean
+ onExpandedChange?: (boolean) => void
+ onHoverChange?: (boolean) => void
+ onHoverEnd?: (HoverEvent) => void
+ onHoverStart?: (HoverEvent) => void
+ slot?: string | null
+ style?: CSSProperties | ((DisclosureRenderProps & {
+ defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+}
/@react-spectrum/accordion:DisclosureHeader
+DisclosureHeader {
+ aria-describedby?: string
+ aria-details?: string
+ aria-label?: string
+ aria-labelledby?: string
+ children: React.ReactNode
+ id?: string
+ level?: number = 3
+}
/@react-spectrum/accordion:DisclosurePanel
+DisclosurePanel {
+ aria-describedby?: string
+ aria-details?: string
+ aria-label?: string
+ aria-labelledby?: string
+ children: React.ReactNode
+ className?: string | (({
+
+} & {
+ defaultClassName: string | undefined
+})) => string
+ id?: string
+ role?: 'group' | 'region' = 'group'
+ style?: CSSProperties | (({
+
+} & {
+ defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+}
/@react-spectrum/accordion:SpectrumDisclosureProps
+SpectrumDisclosureProps {
+ aria-describedby?: string
+ aria-details?: string
+ aria-label?: string
+ aria-labelledby?: string
+ children: [ReactElement<SpectrumDisclosureHeaderProps>, ReactElement<SpectrumDisclosurePanelProps>]
+ className?: string | ((DisclosureRenderProps & {
+ defaultClassName: string | undefined
+})) => string
+ defaultExpanded?: boolean
+ id?: string
+ isDisabled?: boolean
+ isExpanded?: boolean
+ onExpandedChange?: (boolean) => void
+ onHoverChange?: (boolean) => void
+ onHoverEnd?: (HoverEvent) => void
+ onHoverStart?: (HoverEvent) => void
+ slot?: string | null
+ style?: CSSProperties | ((DisclosureRenderProps & {
+ defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+}
/@react-spectrum/accordion:SpectrumDisclosurePanelProps
+SpectrumDisclosurePanelProps {
+ aria-describedby?: string
+ aria-details?: string
+ aria-label?: string
+ aria-labelledby?: string
+ children: React.ReactNode
+ className?: string | (({
+
+} & {
+ defaultClassName: string | undefined
+})) => string
+ id?: string
+ role?: 'group' | 'region' = 'group'
+ style?: CSSProperties | (({
+
+} & {
+ defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+}
/@react-spectrum/accordion:SpectrumDisclosureHeaderProps
+SpectrumDisclosureHeaderProps {
+ aria-describedby?: string
+ aria-details?: string
+ aria-label?: string
+ aria-labelledby?: string
+ children: React.ReactNode
+ id?: string
+ level?: number = 3
+}
@react-spectrum/s2
/@react-spectrum/s2:Accordion
+Accordion {
+ UNSAFE_className?: string
+ UNSAFE_style?: CSSProperties
+ children: React.ReactNode
+ density?: 'compact' | 'regular' | 'spacious' = "regular"
+ id?: string
+ isDisabled?: boolean
+ isQuiet?: boolean
+ size?: 'S' | 'M' | 'L' | 'XL' = "M"
+ slot?: string | null
+ styles?: StylesPropWithHeight
+}
/@react-spectrum/s2:AccordionContext
+AccordionContext {
+ UNTYPED
+}
/@react-spectrum/s2:DisclosureHeader
+DisclosureHeader {
+ UNSAFE_className?: string
+ UNSAFE_style?: CSSProperties
+ children: React.ReactNode
+ id?: string
+ level?: number = 3
+}
/@react-spectrum/s2:Disclosure
+Disclosure {
+ UNSAFE_className?: string
+ UNSAFE_style?: CSSProperties
+ children: [ReactElement<DisclosureHeaderProps>, ReactElement<DisclosurePanelProps>]
+ className?: string | ((DisclosureRenderProps & {
+ defaultClassName: string | undefined
+})) => string
+ defaultExpanded?: boolean
+ density?: 'compact' | 'regular' | 'spacious' = "regular"
+ id?: string
+ isDisabled?: boolean
+ isExpanded?: boolean
+ isQuiet?: boolean
+ onExpandedChange?: (boolean) => void
+ onHoverChange?: (boolean) => void
+ onHoverEnd?: (HoverEvent) => void
+ onHoverStart?: (HoverEvent) => void
+ size?: 'S' | 'M' | 'L' | 'XL' = "M"
+ slot?: string | null
+ style?: CSSProperties | ((DisclosureRenderProps & {
+ defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+ styles?: StylesProp
+}
/@react-spectrum/s2:DisclosurePanel
+DisclosurePanel {
+ UNSAFE_className?: string
+ UNSAFE_style?: CSSProperties
+ aria-describedby?: string
+ aria-details?: string
+ aria-label?: string
+ aria-labelledby?: string
+ children: React.ReactNode
+ className?: string | (({
+
+} & {
+ defaultClassName: string | undefined
+})) => string
+ id?: string
+ role?: 'group' | 'region' = 'group'
+ style?: CSSProperties | (({
+
+} & {
+ defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+}
/@react-spectrum/s2:DisclosureContext
+DisclosureContext {
+ UNTYPED
+}
/@react-spectrum/s2:AccordionProps
+AccordionProps {
+ UNSAFE_className?: string
+ UNSAFE_style?: CSSProperties
+ children: React.ReactNode
+ density?: 'compact' | 'regular' | 'spacious' = "regular"
+ id?: string
+ isDisabled?: boolean
+ isQuiet?: boolean
+ size?: 'S' | 'M' | 'L' | 'XL' = "M"
+ slot?: string | null
+ styles?: StylesPropWithHeight
+}
/@react-spectrum/s2:DisclosureProps
+DisclosureProps {
+ UNSAFE_className?: string
+ UNSAFE_style?: CSSProperties
+ children: [ReactElement<DisclosureHeaderProps>, ReactElement<DisclosurePanelProps>]
+ className?: string | ((DisclosureRenderProps & {
+ defaultClassName: string | undefined
+})) => string
+ defaultExpanded?: boolean
+ density?: 'compact' | 'regular' | 'spacious' = "regular"
+ id?: string
+ isDisabled?: boolean
+ isExpanded?: boolean
+ isQuiet?: boolean
+ onExpandedChange?: (boolean) => void
+ onHoverChange?: (boolean) => void
+ onHoverEnd?: (HoverEvent) => void
+ onHoverStart?: (HoverEvent) => void
+ size?: 'S' | 'M' | 'L' | 'XL' = "M"
+ slot?: string | null
+ style?: CSSProperties | ((DisclosureRenderProps & {
+ defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+ styles?: StylesProp
+}
/@react-spectrum/s2:DisclosurePanelProps
+DisclosurePanelProps {
+ UNSAFE_className?: string
+ UNSAFE_style?: CSSProperties
+ aria-describedby?: string
+ aria-details?: string
+ aria-label?: string
+ aria-labelledby?: string
+ children: React.ReactNode
+ className?: string | (({
+
+} & {
+ defaultClassName: string | undefined
+})) => string
+ id?: string
+ role?: 'group' | 'region' = 'group'
+ style?: CSSProperties | (({
+
+} & {
+ defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+}
@react-aria/disclosure
/@react-aria/disclosure:useDisclosure
+useDisclosure {
+ props: AriaDisclosureProps
+ state: DisclosureState
+ ref?: RefObject<Element | null>
+ returnVal: undefined
+}
/@react-aria/disclosure:DisclosureAria
+DisclosureAria {
+ buttonProps: AriaButtonProps
+ contentProps: HTMLAttributes<HTMLElement>
+}
/@react-aria/disclosure:AriaDisclosureProps
+AriaDisclosureProps {
+ defaultExpanded?: boolean
+ isDisabled?: boolean
+ isExpanded?: boolean
+ onExpandedChange?: (boolean) => void
+}
@react-stately/disclosure
/@react-stately/disclosure:useDisclosureState
+useDisclosureState {
+ props: DisclosureProps
+ returnVal: undefined
+}
/@react-stately/disclosure:DisclosureState
+DisclosureState {
+ collapse: () => void
+ expand: () => void
+ isExpanded: boolean
+ setExpanded: (boolean) => void
+ toggle: () => void
+}
/@react-stately/disclosure:DisclosureProps
+DisclosureProps {
+ defaultExpanded?: boolean
+ isExpanded?: boolean
+ onExpandedChange?: (boolean) => void
+}