diff --git a/src/accordion/Accordion.ts b/src/accordion/Accordion.ts new file mode 100644 index 000000000..c332e10f3 --- /dev/null +++ b/src/accordion/Accordion.ts @@ -0,0 +1,35 @@ +import { createHook, createComponent } from "reakit-system"; +import { CompositeOptions, CompositeHTMLProps, useComposite } from "reakit"; + +import { ACCORDION_KEYS } from "./__keys"; + +export type AccordionOptions = CompositeOptions; + +export type AccordionHTMLProps = CompositeHTMLProps; + +export type AccordionProps = AccordionOptions & AccordionHTMLProps; + +export const useAccordion = createHook({ + name: "Accordion", + compose: useComposite, + keys: ACCORDION_KEYS, + + useComposeProps(options, htmlProps) { + const compositeHtmlProp = useComposite(options, htmlProps); + + return { + ...compositeHtmlProp, + + // When none selected i.e, selectedId={null} + // as per composite https://github.com/reakit/reakit/blob/master/packages/reakit/src/Composite/Composite.ts#L372 + // it applies tabindex={0} which we need to remove it. + tabIndex: undefined, + }; + }, +}); + +export const Accordion = createComponent({ + as: "div", + useHook: useAccordion, + memo: true, +}); diff --git a/src/accordion/AccordionItem.tsx b/src/accordion/AccordionItem.tsx index bc5c7dcbf..f5285f327 100644 --- a/src/accordion/AccordionItem.tsx +++ b/src/accordion/AccordionItem.tsx @@ -1,84 +1,150 @@ -import * as React from "react"; -import { useForkRef } from "reakit-utils"; -import { createContext } from "@chakra-ui/utils"; -import { createComponent, createHook } from "reakit-system"; import { - unstable_IdHTMLProps, - unstable_IdOptions, - unstable_useId, + useCompositeItem, + CompositeItemOptions, + CompositeItemHTMLProps, + useButton, + ButtonOptions, + ButtonHTMLProps, } from "reakit"; +import * as React from "react"; +import { useLiveRef } from "reakit-utils"; +import { ariaAttr } from "@chakra-ui/utils"; +import { createHook, createComponent } from "reakit-system"; import { ACCORDION_ITEM_KEYS } from "./__keys"; -import { AccordionStateReturn, Item } from "./AccordionState"; +import { AccordionStateReturn } from "./AccordionState"; -export type AccordionItemOptions = unstable_IdOptions & - Pick & { - isOpen?: boolean; - }; +export type AccordionItemOptions = ButtonOptions & + CompositeItemOptions & + Pick, "manual"> & + Pick< + AccordionStateReturn, + | "panels" + | "selectedId" + | "selectedIds" + | "select" + | "unSelect" + | "allowMultiple" + | "allowToggle" + >; -export type AccordionItemHTMLProps = unstable_IdHTMLProps; +export type AccordionItemHTMLProps = ButtonHTMLProps & CompositeItemHTMLProps; export type AccordionItemProps = AccordionItemOptions & AccordionItemHTMLProps; -type TAccordionItemContext = { - isOpen: boolean; - item: Item | undefined; -}; - -export const [AccordionItemProvider, useAccordionItemContext] = createContext< - TAccordionItemContext ->({ - name: "useAccordionItemContext", - errorMessage: - "The `useAccordionItem` hook must be called from a descendent of the `AccordionItemProvider`.", - strict: true, -}); +function useAccordionPanelId(options: AccordionItemOptions) { + return React.useMemo( + () => + options.panels?.find(panel => panel.groupId === options.id)?.id || + undefined, + [options.panels, options.id], + ); +} export const useAccordionItem = createHook< AccordionItemOptions, AccordionItemHTMLProps >({ - name: "AccordionItem", - compose: unstable_useId, + name: "Accordion", + compose: [useButton, useCompositeItem], keys: ACCORDION_ITEM_KEYS, + useOptions({ focusable = true, ...options }) { + return { focusable, ...options }; + }, + useProps( - { id, registerItem, activeItems, items, isOpen: isOpenOption }, - { ref: htmlRef, children: htmlChildren, ...htmlProps }, + options, + { onClick: htmlOnClick, onFocus: htmlOnFocus, ...htmlProps }, ) { - const ref = React.useRef(null); + const selected = isAccordionSelected(options); + const accordionPanelId = useAccordionPanelId(options); + const onClickRef = useLiveRef(htmlOnClick); + const onFocusRef = useLiveRef(htmlOnFocus); + + const onClick = React.useCallback( + (event: React.MouseEvent) => { + onClickRef.current?.(event); + if (event.defaultPrevented) return; + if (options.disabled) return; + if (!options.id) return; + + if (selected) { + if (options.allowToggle && !options.allowMultiple) { + // Do not send null to make the toggle because that will also reset + // the current Id in composite hence thats handled directly in state + options.select(options.id); + return; + } + + options.unSelect(options.id); + return; + } - React.useLayoutEffect(() => { - if (!id) return undefined; + options.select?.(options.id); + }, - registerItem?.({ id, ref }); - }, [id, registerItem]); + // eslint-disable-next-line react-hooks/exhaustive-deps + [options.disabled, selected, options.select, options.id], + ); + + const onFocus = React.useCallback( + (event: React.FocusEvent) => { + onFocusRef.current?.(event); + if (event.defaultPrevented) return; + if (options.disabled) return; + if (options.manual) return; + if (!options.id) return; + + if (selected) { + if (options.allowToggle && !options.allowMultiple) { + // Do not send null to make the toggle because that will also reset + // the current Id in composite hence thats handled directly in state + options.select(options.id); + return; + } + + options.unSelect(options.id); + return; + } + + options.select?.(options.id); + }, - const isOpenLocal = id ? activeItems.includes(id) : false; - const isOpen = isOpenOption ?? isOpenLocal; - const item = items.find(({ id: itemId }) => itemId === id); - const children = ( - - {htmlChildren} - + // eslint-disable-next-line react-hooks/exhaustive-deps + [options.id, options.disabled, options.manual, selected, options.select], ); return { - ref: useForkRef(ref, htmlRef), - children, + "aria-expanded": selected, + "aria-controls": accordionPanelId, + "aria-disabled": ariaAttr(!options.allowToggle && selected), + onClick, + onFocus, ...htmlProps, }; }, - useComposeProps(_, htmlProps) { - // We don't want to run `unstable_useId` hook in compose. - // So that we only use it for useOptions & use the id to register the item. - return htmlProps; + useComposeProps(options, htmlProps) { + const buttonHtmlProps = useButton(options, htmlProps); + const compositeHtmlProps = useCompositeItem(options, buttonHtmlProps); + + return { + ...compositeHtmlProps, + tabIndex: 0, + }; }, }); export const AccordionItem = createComponent({ - as: "div", + as: "button", memo: true, useHook: useAccordionItem, }); + +function isAccordionSelected(options: AccordionItemOptions) { + const { id, allowMultiple, selectedId, selectedIds } = options; + + if (!allowMultiple) return selectedId === id; + return selectedIds?.includes(id); +} diff --git a/src/accordion/AccordionPanel.ts b/src/accordion/AccordionPanel.ts index ddc9959fd..c6d8cc094 100644 --- a/src/accordion/AccordionPanel.ts +++ b/src/accordion/AccordionPanel.ts @@ -1,55 +1,140 @@ -import * as React from "react"; -import { useForkRef } from "reakit-utils"; -import { createComponent, createHook } from "reakit-system"; import { - unstable_IdHTMLProps, - unstable_IdOptions, + DisclosureContentOptions, + DisclosureContentHTMLProps, + useDisclosureContent, unstable_useId, + unstable_IdOptions, + unstable_IdHTMLProps, } from "reakit"; +import * as React from "react"; +import { useForkRef } from "reakit-utils"; +import { createHook, createComponent } from "reakit-system"; import { ACCORDION_PANEL_KEYS } from "./__keys"; import { AccordionStateReturn } from "./AccordionState"; -import { useAccordionItemContext } from "./AccordionItem"; -export type AccordionPanelOptions = unstable_IdOptions & - Pick; +export type AccordionPanelOptions = DisclosureContentOptions & + unstable_IdOptions & + Pick< + AccordionStateReturn, + | "selectedId" + | "selectedIds" + | "registerPanel" + | "unregisterPanel" + | "panels" + | "items" + | "allowMultiple" + > & { + /** + * Accordion's id + */ + accordionId?: string; + }; -export type AccordionPanelHTMLProps = unstable_IdHTMLProps; +export type AccordionPanelHTMLProps = DisclosureContentHTMLProps & + unstable_IdHTMLProps; export type AccordionPanelProps = AccordionPanelOptions & AccordionPanelHTMLProps; +function getAccordionsWithoutPanel( + accordions: AccordionPanelOptions["items"], + panels: AccordionPanelOptions["panels"], +) { + const panelsAccordionIds = panels.map(panel => panel.groupId).filter(Boolean); + + return accordions.filter( + item => panelsAccordionIds.indexOf(item.id || undefined) === -1, + ); +} + +function getPanelIndex( + panels: AccordionPanelOptions["panels"], + panel: typeof panels[number], +) { + const panelsWithoutAccordionId = panels.filter(p => !p.groupId); + return panelsWithoutAccordionId.indexOf(panel); +} + +/** + * When is used without accordionId: + * + * - First render: getAccordionId will return undefined because options.panels + * doesn't contain the current panel yet (registerPanel wasn't called yet). + * Thus registerPanel will be called without groupId (accordionId). + * + * - Second render: options.panels already contains the current panel (because + * registerPanel was called in the previous render). This means that we'll be + * able to get the related accordionId with the accordion panel index. Basically, + * we filter out all the accordions and panels that have already matched. In this + * phase, registerPanel will be called again with the proper groupId (accordionId). + * + * - In the third render, panel.groupId will be already defined, so we just + * return it. registerPanel is not called. + */ +function getAccordionId(options: AccordionPanelOptions) { + const panel = options.panels?.find(p => p.id === options.id); + const accordionId = options.accordionId || panel?.groupId; + if (accordionId || !panel || !options.panels || !options.items) { + return accordionId; + } + + const panelIndex = getPanelIndex(options.panels, panel); + const accordionsWithoutPanel = getAccordionsWithoutPanel( + options.items, + options.panels, + ); + return accordionsWithoutPanel[panelIndex]?.id || undefined; +} + export const useAccordionPanel = createHook< AccordionPanelOptions, AccordionPanelHTMLProps >({ name: "AccordionPanel", - compose: unstable_useId, + compose: [unstable_useId, useDisclosureContent], keys: ACCORDION_PANEL_KEYS, - useProps({ id, registerPanel }, { ref: htmlRef, ...htmlProps }) { + useProps(options, { ref: htmlRef, ...htmlProps }) { const ref = React.useRef(null); + const accordionId = getAccordionId(options); + const { id, registerPanel, unregisterPanel } = options; React.useEffect(() => { if (!id) return undefined; + registerPanel?.({ id, ref, groupId: accordionId }); - registerPanel?.({ id, ref }); - }, [id, registerPanel]); - - const { item, isOpen } = useAccordionItemContext(); - const buttonId = item?.button?.id; + return () => { + unregisterPanel?.(id); + }; + }, [accordionId, id, registerPanel, unregisterPanel]); return { - role: "region", - "aria-labelledby": buttonId ?? buttonId, ref: useForkRef(ref, htmlRef), - hidden: !isOpen, + role: "region", + "aria-labelledby": accordionId, ...htmlProps, }; }, + + useComposeOptions(options) { + return { + visible: isPanelVisible(options), + ...options, + }; + }, }); export const AccordionPanel = createComponent({ as: "div", useHook: useAccordionPanel, }); + +function isPanelVisible(options: AccordionPanelOptions) { + const accordionId = getAccordionId(options); + + if (!options.allowMultiple) + return accordionId ? options.selectedId === accordionId : false; + + return accordionId ? options.selectedIds?.includes(accordionId) : false; +} diff --git a/src/accordion/AccordionState.ts b/src/accordion/AccordionState.ts index 9d7c1d724..32516d9ed 100644 --- a/src/accordion/AccordionState.ts +++ b/src/accordion/AccordionState.ts @@ -1,227 +1,197 @@ -import * as React from "react"; -import { SealedInitialState, useSealedState } from "reakit-utils"; import { - unstable_IdInitialState, - unstable_IdStateReturn, - unstable_useIdState, + useCompositeState, + CompositeState, + CompositeActions, + CompositeInitialState, } from "reakit"; +import * as React from "react"; +import { + SealedInitialState, + useSealedState, +} from "reakit-utils/useSealedState"; -export type AccordionInitialState = unstable_IdInitialState & { +export type AccordionState = CompositeState & { /** - * Allow to open multiple accordion items - * @default false + * The current selected accordion's `id`. */ - allowMultiple?: boolean; + selectedId?: AccordionState["currentId"]; /** - * Allow to loop accordion items - * @default true + * Initial selected accordion's `id`. + * @default [] */ - loop?: boolean; + selectedIds?: AccordionState["currentId"][] | null; /** - * Allow to toggle accordion items + * Lists all the panels. + */ + panels: AccordionState["items"]; + /** + * Whether the accodion selection should be manual. * @default true */ - allowToggle?: boolean; + manual: boolean; /** - * Default Active Id to open by default + * Allow to open multiple accordion items + * @default false */ - defaultActiveId?: string; + allowMultiple?: boolean; /** - * Set manual to false, to navigate and open the accordion items on arrow keys movements - * @default true + * Allow to toggle accordion items + * @default false */ - manual?: boolean; -}; - -type Button = { - id: string; - ref: React.RefObject; -}; - -type Panel = Button; - -export type Item = { - id: string; - ref: React.RefObject; - button?: Button; - panel?: Panel; + allowToggle?: boolean; }; -export type AccordionState = AccordionInitialState & { - items: Item[]; - activeItems: string[]; - buttons: Button[]; +export type AccordionActions = CompositeActions & { + /** + * Moves into and selects an accordion by its `id`. + */ + select: AccordionActions["move"]; + /** + * Moves into and unSelects an accordion by its `id` if it's already selected. + */ + unSelect: AccordionActions["move"]; + /** + * Sets `selectedId`. + */ + setSelectedId: AccordionActions["setCurrentId"]; + /** + * Sets `selectedIds`. + */ + setSelectedIds: React.Dispatch< + React.SetStateAction + >; + /** + * Registers a accordion panel. + */ + registerPanel: AccordionActions["registerItem"]; + /** + * Unregisters a accordion panel. + */ + unregisterPanel: AccordionActions["unregisterItem"]; }; -export type AccordionActions = { - registerItem: (item: Item) => void; - registerButton: (button: Button) => void; - registerPanel: (panel: Panel) => void; - addActiveItem: (id: string) => void; - removeActiveItem: (id: string) => void; - next: (id: string) => void; - prev: (id: string) => void; - first: () => void; - last: () => void; -}; +export type AccordionInitialState = CompositeInitialState & + Partial< + Pick< + AccordionState, + "selectedId" | "selectedIds" | "manual" | "allowMultiple" | "allowToggle" + > + >; -export type AccordionStateReturn = unstable_IdStateReturn & - AccordionState & - AccordionActions; +export type AccordionStateReturn = AccordionState & AccordionActions; export function useAccordionState( initialState: SealedInitialState = {}, ): AccordionStateReturn { const { - loop = true, - allowToggle = true, + selectedId: initialSelectedId, + selectedIds: initialSelectedIds, allowMultiple = false, - defaultActiveId, + allowToggle: allowToggleProp = false, manual = true, ...sealed } = useSealedState(initialState); - - const [state, dispatch] = React.useReducer(reducer, { - items: [], - activeItems: defaultActiveId ? [defaultActiveId] : [], - buttons: [], - allowMultiple, - loop, - allowToggle, - defaultActiveId, - manual, + const allowToggle = useSealedState( + allowMultiple ? allowMultiple : allowToggleProp, + ); + const composite = useCompositeState({ + currentId: initialSelectedId, + orientation: "vertical", + ...sealed, }); - const idState = unstable_useIdState(sealed); - const { buttons } = state; - const total = buttons.length; - const buttonIds = buttons.map(({ id }) => id); + const [selectedId, setSelectedId] = React.useState(initialSelectedId); - const next = React.useCallback( - (id: string) => { - const currentIndex = buttonIds.indexOf(id); - const nextIndex = (currentIndex + 1) % total; + // If selectedId is not set, use the currentId. It's still possible to have + // no selected accordion with useAccordionState({ selectedId: null }); + React.useEffect(() => { + if (selectedId === null) return; - if (!loop && nextIndex === 0) return; - moveFocus(buttons[nextIndex]); - }, - [buttonIds, buttons, loop, total], - ); + const selectedItem = composite.items.find(item => item.id === selectedId); + if (selectedItem) return; - const prev = React.useCallback( - (id: string) => { - const currentIndex = buttonIds.indexOf(id); - const prevIndex = (currentIndex - 1 + total) % total; + if (composite.currentId) { + setSelectedId(composite.currentId); + } + }, [selectedId, composite.items, composite.currentId]); - if (!loop && prevIndex === total - 1) return; - moveFocus(buttons[prevIndex]); - }, - [buttonIds, buttons, loop, total], - ); + // Logic for Allow Multiple + const [selectedIds, setSelectedIds] = React.useState(initialSelectedIds); - const first = React.useCallback(() => { - moveFocus(buttons[0]); - }, [buttons]); + React.useEffect(() => { + if (!allowMultiple) return; + if (selectedIds === null) return; + if (selectedIds?.length === 0) return; - const last = React.useCallback(() => { - moveFocus(buttons[total - 1]); - }, [buttons, total]); + const selectedItem = composite.items.find(item => + selectedIds?.includes(item.id), + ); + if (selectedItem) return; - return { - ...idState, - ...state, - addActiveItem: React.useCallback(id => { - dispatch({ type: "addActiveItem", id }); - }, []), - removeActiveItem: React.useCallback(id => { - dispatch({ type: "removeActiveItem", id }); - }, []), - registerButton: React.useCallback(button => { - dispatch({ type: "registerButton", button }); - }, []), - registerPanel: React.useCallback(panel => { - dispatch({ type: "registerPanel", panel }); - }, []), - registerItem: React.useCallback(item => { - dispatch({ type: "registerItem", item }); - }, []), - next, - prev, - first, - last, - }; -} + if (composite.currentId) { + setSelectedIds([composite.currentId]); + } + }, [selectedIds, composite.items, composite.currentId, allowMultiple]); + + const select = React.useCallback( + (id: string | null) => { + composite.move(id); -export type AccordionReducerAction = - | { type: "registerItem"; item: Item } - | { type: "registerButton"; button: Button } - | { type: "registerPanel"; panel: Panel } - | { type: "addActiveItem"; id: string } - | { type: "removeActiveItem"; id: string }; - -function reducer( - state: AccordionState, - action: AccordionReducerAction, -): AccordionState { - const { items, activeItems, buttons, allowMultiple } = state; - - switch (action.type) { - case "registerItem": - return { ...state, items: [...items, action.item] }; - - case "registerButton": - return { - ...state, - items: getNextItems("button", action.button, items), - buttons: [...buttons, action.button], - }; - - case "registerPanel": - return { - ...state, - items: getNextItems("panel", action.panel, items), - }; - - case "addActiveItem": { - const { id } = action; - let nextActiveItems; - if (allowMultiple) { - nextActiveItems = [...activeItems, id]; - } else { - nextActiveItems = [id]; + if (!allowMultiple) { + if (allowToggle && id === selectedId) { + setSelectedId(null); + return; + } + + setSelectedId(id); + return; } - return { ...state, activeItems: nextActiveItems }; - } + if (id === null) return; + setSelectedIds(prevIds => [...prevIds, id]); + }, - case "removeActiveItem": { - const { id } = action; - const nextActiveItems = activeItems.filter(panelId => panelId !== id); + // eslint-disable-next-line react-hooks/exhaustive-deps + [allowMultiple, allowToggle, composite.move, selectedId], + ); - return { ...state, activeItems: nextActiveItems }; - } + const unSelect = React.useCallback( + (id: string | null) => { + if (!allowMultiple && id === null) return; - default: - throw new Error(); - } -} + composite.move(id); + setSelectedIds(prevIds => prevIds?.filter(pId => pId !== id)); + }, -function getNextItems( - type: "button" | "panel", - currentThing: Button | Panel, - items: Item[], -) { - const item = items.find(item => - item.ref.current?.contains(currentThing.ref.current), - ); - const nextItem = { ...item, [type]: currentThing } as Item; - const nextItems = items.filter( - item => !item.ref.current?.contains(currentThing.ref.current), + // eslint-disable-next-line react-hooks/exhaustive-deps + [composite.move], ); - return [...nextItems, nextItem]; -} + const panels = useCompositeState(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const registerPanel = React.useCallback(panel => panels.registerItem(panel), [ + panels.registerItem, + ]); -function moveFocus(button: Button) { - button.ref?.current?.focus(); + // eslint-disable-next-line react-hooks/exhaustive-deps + const unregisterPanel = React.useCallback(id => panels.unregisterItem(id), [ + panels.unregisterItem, + ]); + + return { + manual, + allowMultiple, + allowToggle, + selectedId, + setSelectedId, + selectedIds, + setSelectedIds, + select, + unSelect, + panels: panels.items, + registerPanel, + unregisterPanel, + ...composite, + }; } diff --git a/src/accordion/AccordionTrigger.ts b/src/accordion/AccordionTrigger.ts deleted file mode 100644 index 25c19f303..000000000 --- a/src/accordion/AccordionTrigger.ts +++ /dev/null @@ -1,168 +0,0 @@ -import * as React from "react"; -import { createComponent, createHook } from "reakit-system"; -import { useForkRef, createOnKeyDown, useLiveRef } from "reakit-utils"; -import { - ButtonHTMLProps, - ButtonOptions, - unstable_IdHTMLProps, - unstable_IdOptions, - unstable_useId, - useButton, -} from "reakit"; - -import { ACCORDION_TRIGGER_KEYS } from "./__keys"; -import { AccordionStateReturn } from "./AccordionState"; -import { useAccordionItemContext } from "./AccordionItem"; - -export type AccordionTriggerOptions = unstable_IdOptions & - ButtonOptions & - Pick< - AccordionStateReturn, - | "removeActiveItem" - | "addActiveItem" - | "registerButton" - | "items" - | "activeItems" - | "next" - | "prev" - | "first" - | "last" - | "allowToggle" - | "manual" - >; - -export type AccordionTriggerHTMLProps = ButtonHTMLProps & unstable_IdHTMLProps; - -export type AccordionTriggerProps = AccordionTriggerOptions & - AccordionTriggerHTMLProps; - -export const useAccordionTrigger = createHook< - AccordionTriggerOptions, - AccordionTriggerHTMLProps ->({ - name: "AccordionTrigger", - compose: [useButton, unstable_useId], - keys: ACCORDION_TRIGGER_KEYS, - - useProps( - options, - { - ref: htmlRef, - onClick: htmlOnClick, - onKeyDown: htmlOnKeyDown, - onFocus: htmlOnFocus, - ...htmlProps - }, - ) { - const { - id, - removeActiveItem, - addActiveItem, - registerButton, - activeItems, - next, - prev, - first, - last, - allowToggle, - manual, - } = options; - const ref = React.useRef(null); - - React.useEffect(() => { - if (!id) return undefined; - - registerButton?.({ id, ref }); - }, [id, registerButton]); - - const { isOpen, item } = useAccordionItemContext(); - const panelId = item?.panel?.id; - - const onClickRef = useLiveRef(htmlOnClick); - const onFocusRef = useLiveRef(htmlOnFocus); - const onKeyDownRef = useLiveRef(htmlOnKeyDown); - - const onClick = React.useCallback( - (event: React.MouseEvent) => { - onClickRef.current?.(event); - if (event.defaultPrevented) return; - if (!item) return; - if (!manual) return; - - if (activeItems.includes(item.id) && allowToggle) { - removeActiveItem?.(item.id); - } else { - addActiveItem?.(item.id); - } - }, - [ - activeItems, - addActiveItem, - allowToggle, - item, - manual, - onClickRef, - removeActiveItem, - ], - ); - - const onFocus = React.useCallback( - (event: React.MouseEvent) => { - onFocusRef.current?.(event); - if (event.defaultPrevented) return; - if (!item) return; - if (manual) return; - - if (!activeItems.includes(item.id)) { - addActiveItem?.(item.id); - } - }, - [activeItems, addActiveItem, item, manual, onFocusRef], - ); - - const onCharacterKeyDown = React.useCallback( - event => { - onKeyDownRef.current?.(event); - if (event.defaultPrevented) return; - }, - [onKeyDownRef], - ); - - const onKeyDown = React.useMemo(() => { - return createOnKeyDown({ - onKeyDown: onCharacterKeyDown, - stopPropagation: true, - keyMap: () => { - if (!id) return {}; - - return { - ArrowDown: () => { - next(id); - }, - ArrowUp: () => { - prev(id); - }, - Home: first, - End: last, - }; - }, - }); - }, [onCharacterKeyDown, id, next, prev, first, last]); - - return { - "aria-controls": panelId ?? panelId, - "aria-expanded": isOpen, - onClick, - onKeyDown, - onFocus, - ref: useForkRef(ref, htmlRef), - ...htmlProps, - }; - }, -}); - -export const AccordionTrigger = createComponent({ - as: "button", - memo: true, - useHook: useAccordionTrigger, -}); diff --git a/src/accordion/__keys.ts b/src/accordion/__keys.ts index 6f3ac3a41..a6c30e1ab 100644 --- a/src/accordion/__keys.ts +++ b/src/accordion/__keys.ts @@ -2,25 +2,54 @@ const ACCORDION_STATE_KEYS = [ "baseId", "unstable_idCountRef", - "setBaseId", - "allowMultiple", + "unstable_virtual", + "rtl", + "orientation", + "items", + "groups", + "currentId", "loop", - "allowToggle", - "defaultActiveId", + "wrap", + "unstable_moves", + "unstable_angular", + "unstable_hasActiveWidget", + "selectedId", + "selectedIds", + "panels", "manual", - "items", - "activeItems", - "buttons", + "allowMultiple", + "allowToggle", + "setBaseId", "registerItem", - "registerButton", - "registerPanel", - "addActiveItem", - "removeActiveItem", + "unregisterItem", + "registerGroup", + "unregisterGroup", + "move", "next", - "prev", + "previous", + "up", + "down", "first", "last", + "sort", + "unstable_setVirtual", + "setRTL", + "setOrientation", + "setCurrentId", + "setLoop", + "setWrap", + "reset", + "unstable_setHasActiveWidget", + "select", + "unSelect", + "setSelectedId", + "setSelectedIds", + "registerPanel", + "unregisterPanel", +] as const; +export const ACCORDION_KEYS = ACCORDION_STATE_KEYS; +export const ACCORDION_ITEM_KEYS = ACCORDION_KEYS; +export const ACCORDION_PANEL_KEYS = [ + ...ACCORDION_ITEM_KEYS, + "accordionId", ] as const; -export const ACCORDION_ITEM_KEYS = [...ACCORDION_STATE_KEYS, "isOpen"] as const; -export const ACCORDION_PANEL_KEYS = ACCORDION_STATE_KEYS; -export const ACCORDION_TRIGGER_KEYS = ACCORDION_PANEL_KEYS; diff --git a/src/accordion/__tests__/Accordion.test.tsx b/src/accordion/__tests__/Accordion.test.tsx index 9d61b174b..16435eb80 100644 --- a/src/accordion/__tests__/Accordion.test.tsx +++ b/src/accordion/__tests__/Accordion.test.tsx @@ -3,35 +3,31 @@ import { axe, render, press } from "reakit-test-utils"; import { AccordionPanel, + Accordion, AccordionItem, - AccordionTrigger, useAccordionState, -} from ".."; +} from "../index"; const AccordionComponent = (props: any) => { const state = useAccordionState(props); return ( -
- -

- Trigger 1 -

- Panel 1 -
- -

- Trigger 2 -

- Panel 2 -
- -

- Trigger 3 -

- Panel 3 -
-
+ +

+ Trigger 1 +

+ Panel 1 +

+ + Trigger 2 + +

+ Panel 2 +

+ Trigger 3 +

+ Panel 3 +
); }; @@ -45,15 +41,15 @@ test("Accordion should have proper keyboard navigation", () => { press.ArrowDown(); expect(text("Trigger 3")).toHaveFocus(); press.ArrowDown(); - expect(text("Trigger 1")).toHaveFocus(); - press.ArrowUp(); expect(text("Trigger 3")).toHaveFocus(); press.ArrowUp(); expect(text("Trigger 2")).toHaveFocus(); + press.ArrowUp(); + expect(text("Trigger 1")).toHaveFocus(); }); -test("Accordion should have proper keyboard navigation (loop: false)", () => { - const { getByText: text } = render(); +test("Accordion should have proper keyboard navigation when on loop", () => { + const { getByText: text } = render(); press.Tab(); expect(text("Trigger 1")).toHaveFocus(); @@ -62,11 +58,11 @@ test("Accordion should have proper keyboard navigation (loop: false)", () => { press.ArrowDown(); expect(text("Trigger 3")).toHaveFocus(); press.ArrowDown(); + expect(text("Trigger 1")).toHaveFocus(); + press.ArrowUp(); expect(text("Trigger 3")).toHaveFocus(); press.ArrowUp(); expect(text("Trigger 2")).toHaveFocus(); - press.ArrowUp(); - expect(text("Trigger 1")).toHaveFocus(); }); [true, false].forEach(toggle => { @@ -76,7 +72,6 @@ test("Accordion should have proper keyboard navigation (loop: false)", () => { ); press.Tab(); - press.Enter(); expect(text("Trigger 1")).toHaveFocus(); expect(text("Panel 1")).toBeVisible(); @@ -97,8 +92,7 @@ test("Accordion should open/close properly with AllowMultiple: false", () => { press.Tab(); expect(text("Trigger 1")).toHaveFocus(); - - expect(text("Panel 1")).not.toBeVisible(); + expect(text("Panel 1")).toBeVisible(); press.Enter(); expect(text("Panel 1")).toBeVisible(); @@ -113,16 +107,15 @@ test("Accordion should open/close properly with AllowMultiple: false", () => { }); test("Accordion should open/close properly with AllowMultiple: true", () => { - const { getByText: text } = render( - , - ); + const { getByText: text } = render(); press.Tab(); expect(text("Trigger 1")).toHaveFocus(); + expect(text("Panel 1")).toBeVisible(); + press.Enter(); expect(text("Panel 1")).not.toBeVisible(); press.Enter(); - expect(text("Panel 1")).toBeVisible(); // go to next panel press.ArrowDown(); @@ -133,9 +126,9 @@ test("Accordion should open/close properly with AllowMultiple: true", () => { expect(text("Panel 1")).toBeVisible(); }); -test("Accordion should have proper default active", () => { +test("Accordion with selectedId given to be selected properly", () => { const { getByText: text } = render( - , + , ); press.Tab(); @@ -143,6 +136,15 @@ test("Accordion should have proper default active", () => { expect(text("Panel 2")).toBeVisible(); }); +test("Accordion should have none selected when selectedId is null", () => { + const { getByText: text } = render(); + + press.Tab(); + expect(text("Panel 1")).not.toBeVisible(); + expect(text("Panel 2")).not.toBeVisible(); + expect(text("Panel 3")).not.toBeVisible(); +}); + test("Accordion manual: false", () => { const { getByText: text } = render(); diff --git a/src/accordion/__tests__/AccordionState.test.ts b/src/accordion/__tests__/AccordionState.test.ts index d3ca8283b..a841ce4bb 100644 --- a/src/accordion/__tests__/AccordionState.test.ts +++ b/src/accordion/__tests__/AccordionState.test.ts @@ -17,18 +17,27 @@ test("initial state", () => { const result = render(); expect(result.current).toMatchInlineSnapshot(` Object { - "activeItems": Array [], "allowMultiple": false, - "allowToggle": true, + "allowToggle": false, "baseId": "base", - "buttons": Array [], - "defaultActiveId": undefined, + "currentId": undefined, + "groups": Array [], "items": Array [], - "loop": true, + "loop": false, "manual": true, + "orientation": "vertical", + "panels": Array [], + "rtl": false, + "selectedId": undefined, + "selectedIds": undefined, + "unstable_angular": false, + "unstable_hasActiveWidget": false, "unstable_idCountRef": Object { "current": 0, }, + "unstable_moves": 0, + "unstable_virtual": false, + "wrap": false, } `); }); diff --git a/src/accordion/index.ts b/src/accordion/index.ts index ee05fc3f3..91fc29554 100644 --- a/src/accordion/index.ts +++ b/src/accordion/index.ts @@ -1,4 +1,4 @@ export * from "./AccordionState"; +export * from "./Accordion"; export * from "./AccordionItem"; -export * from "./AccordionTrigger"; export * from "./AccordionPanel"; diff --git a/src/accordion/stories/Accordion.stories.tsx b/src/accordion/stories/Accordion.stories.tsx index 018fd89d0..45e668c70 100644 --- a/src/accordion/stories/Accordion.stories.tsx +++ b/src/accordion/stories/Accordion.stories.tsx @@ -2,218 +2,191 @@ import React from "react"; import { Meta } from "@storybook/react"; import "./index.css"; -import { AccordionItem } from "../AccordionItem"; -import { AccordionPanel } from "../AccordionPanel"; -import { useAccordionState } from "../AccordionState"; -import { AccordionTrigger } from "../AccordionTrigger"; +import { + useAccordionState, + Accordion, + AccordionItem, + AccordionPanel, + AccordionInitialState, +} from "../index"; export default { title: "Component/Accordion", } as Meta; -const AccordionComponent = (props: any) => { +const AccordionComponent: React.FC = props => { const state = useAccordionState(props); return ( -
- -

- Trigger 1 -

- Panel 1 -
- -

- Trigger 2 -

- - {props => { - const { hidden, ...rest } = props; - return
{!hidden ? "Panel 2" : null}
; - }} -
-
- -

- Trigger 3 -

- - {props => { - const { hidden, ...rest } = props; - const isOpen = state.activeItems.includes("accordion-3"); - return ( - - ); - }} - -
- -

- Trigger 4 -

- Panel 4 -
- -

- Trigger 5 -

- {state.activeItems.includes("accordion-5") && ( - Panel 5 - )} -
-
+ +

+ Trigger 1 +

+ Panel 1 +

+ Trigger 2 +

+ Panel 2 +

+ + Trigger 3 + +

+ Panel 3 +

+ Trigger 4 +

+ Panel 4 +

+ Trigger 5 +

+ Panel 5 +
); }; export const Default = () => ; -export const AllowMultiple = () => ; -export const LoopFalse = () => ; -export const AllowToggleFalse = () => ( - -); -export const DefaultActive = () => ( - + +export const DefaultSelected = () => ( + ); -export const ManualFalse = () => ; -export const AlwaysOpen = () => ; + +export const NoneSelected = () => ; + +export const AutoSelect = () => ; + +export const Loop = () => ; + +export const AllowToggle = () => ; + +export const AllowMultiple = () => ; // Styled based on https://www.w3.org/TR/wai-aria-practices-1.2/examples/accordion/accordion.html export const Styled = () => { - const props = useAccordionState({ allowMultiple: false }); + const props = useAccordionState(); return ( -
- -

- - - Personal Information - - - -

- -
-
-

- - -

-

- - -

-

- - -

-

- - -

-

- - -

-

- - -

-
-
-
-
- -

- - - Billing Address - - - -

- -
-
-

- - -

-

- - -

-

- - -

-

- - -

-

- - -

-
-
-
-
- -

- - - Shipping Address - - - -

- -
-
-

- - -

-

- - -

-

- - -

-

- - -

-

- - -

-
-
-
-
-
+ +

+ + + Personal Information + + + +

+ +
+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+

+ + +

+

+ + +

+
+
+
+ +

+ + + Billing Address + + + +

+ +
+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+

+ + +

+
+
+
+ +

+ + + Shipping Address + + + +

+ +
+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+

+ + +

+
+
+
+
); };