diff --git a/src/components/Accordion.tsx b/src/components/Accordion.tsx index e346dd4a..7cd12b1b 100644 --- a/src/components/Accordion.tsx +++ b/src/components/Accordion.tsx @@ -4,62 +4,27 @@ import { type ReactElement, type ReactNode, type Ref, - createContext, forwardRef, - useCallback, - useContext, - useMemo, - useState, + useId, } from 'react' -import styled, { css, keyframes } from 'styled-components' +import styled, { + type DefaultTheme, + css, + keyframes, + useTheme, +} from 'styled-components' import { DropdownArrowIcon } from '../icons' import Card from './Card' -export type AccordionProps = ComponentProps -type AccordionContextT = { - type: AccordionProps['type'] - openItems: AccordionProps['value'] - setOpenItems: (openItems: AccordionProps['value']) => void -} - -const AccordionContext = createContext(undefined) -const useAccordionContext = () => { - const ctx = useContext(AccordionContext) - - if (!ctx) throw Error('AccordionContext must be used inside an ') - - return ctx -} - -export function useIsItemOpen(itemValue: string) { - const { openItems } = useAccordionContext() - - if (!openItems) return false - - return typeof openItems === 'string' - ? openItems === itemValue - : (openItems as string[]).includes(itemValue) -} - -export function useCloseItem(itemValue: string) { - const { openItems, setOpenItems } = useAccordionContext() - - return useCallback(() => { - if (typeof openItems === 'string' && openItems === itemValue) { - setOpenItems('') - } else { - setOpenItems((openItems as string[]).filter((v) => v !== itemValue)) - } - }, [itemValue, openItems, setOpenItems]) -} +export type AccordionProps = ComponentProps & + ComponentProps function AccordionRef( { children, - onValueChange: valueChangePropFunc, ...props }: { children?: @@ -69,68 +34,57 @@ function AccordionRef( } & AccordionProps, ref: Ref ) { - const [openItems, setOpenItems] = useState( - props.value - ) - - // for both keeping track of current open items, and still allowing user-specified function - const onValueChange = useCallback( - (val: AccordionProps['value']) => { - setOpenItems(val) - valueChangePropFunc?.(val as string & string[]) - }, - [valueChangePropFunc] - ) - - const context = useMemo( - () => ({ - type: props.type, - openItems, - setOpenItems, - }), - [openItems, props.type] - ) - return ( - - - {children} - - + + {children} + ) } -export const Accordion = forwardRef(AccordionRef) function AccordionItemRef( { - hideDefaultIcon = false, + value, + padding = 'relaxed', + paddingArea = 'all', + caret = 'right', trigger, children, ...props }: { - hideDefaultIcon?: boolean + value?: string + padding?: 'none' | 'compact' | 'relaxed' + paddingArea?: 'trigger-only' | 'all' + caret?: 'none' | 'left' | 'right' trigger: ReactNode children: ReactNode - } & ComponentProps, + } & Omit, 'value'>, ref: Ref ) { + const theme = useTheme() + const paddingSize = getPaddingSize(theme, padding) + // if value is not provided, use a random persisted id + const defaultValue = useId() + return ( - + {trigger} - {!hideDefaultIcon && ( + {caret !== 'none' && ( - {children} + +
+ {children} +
+
) } -export const AccordionItem = forwardRef(AccordionItemRef) -const ItemSC: typeof RadixAccordion.Item = styled(RadixAccordion.Item)((_) => ({ +function getPaddingSize( + theme: DefaultTheme, + size: 'none' | 'compact' | 'relaxed' +) { + switch (size) { + case 'relaxed': + return theme.spacing.medium + case 'compact': + return theme.spacing.small + default: + return 0 + } +} + +const ItemSC = styled(RadixAccordion.Item)({ display: 'flex', height: '100%', width: '100%', @@ -154,18 +135,21 @@ const ItemSC: typeof RadixAccordion.Item = styled(RadixAccordion.Item)((_) => ({ '&[data-orientation="horizontal"]': { flexDirection: 'row', }, -})) -const TriggerSC = styled(RadixAccordion.Trigger)(({ theme }) => ({ +}) + +const TriggerSC = styled(RadixAccordion.Trigger)<{ + $caret: 'none' | 'left' | 'right' + $padding?: number +}>(({ theme, $caret, $padding }) => ({ + ...theme.partials.reset.button, + ...($padding ? { padding: $padding } : {}), display: 'flex', + flexDirection: $caret === 'left' ? 'row-reverse' : 'row', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', ...theme.partials.text.body2Bold, color: theme.colors.text, - // reset default button styles - background: 'transparent', - border: 'none', - padding: 0, '.icon': { transform: 'scaleY(100%)', transition: 'transform 0.3s ease', @@ -219,4 +203,8 @@ const ContentSC = styled(RadixAccordion.Content)` } ` +const Accordion = forwardRef(AccordionRef) +const AccordionItem = forwardRef(AccordionItemRef) + export default Accordion +export { AccordionItem } diff --git a/src/components/AccordionOLD.tsx b/src/components/AccordionOLD.tsx deleted file mode 100644 index 76463c4a..00000000 --- a/src/components/AccordionOLD.tsx +++ /dev/null @@ -1,266 +0,0 @@ -import { - type ComponentProps, - type HTMLAttributes, - type ReactElement, - type ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' -import React from 'react' -import { useSpring } from '@react-spring/web' -import styled, { useTheme } from 'styled-components' - -import useResizeObserver from '../hooks/useResizeObserver' -import { type UseDisclosureProps, useDisclosure } from '../hooks/useDisclosure' -import { DropdownArrowIcon } from '../icons' - -import Card from './Card' -import { AnimatedDiv } from './AnimatedDiv' - -const paddingTransition = '0.2s ease' - -function AccordionTriggerUnstyled({ - children, - isOpen: _isOpen, - unstyled: _unstyled, - ...props -}: { isOpen?: boolean; unstyled?: boolean } & ComponentProps<'div'>) { - return ( -
-
{children}
-
- -
-
- ) -} - -const AccordionTrigger = styled(AccordionTriggerUnstyled)(({ theme }) => { - const focusInset = theme.spacing.xsmall - const padding = theme.spacing.medium - - return { - display: 'flex', - gap: theme.spacing.medium, - padding, - ...theme.partials.text.body2Bold, - color: theme.colors.text, - '.label': { - flexGrow: 1, - }, - '.icon > *:first-child': { - transform: 'scale(100%)', - transition: 'transform 0.4 ease', - }, - '&:focus-visible': { - outline: 'none', - position: 'relative', - '&::after': { - ...theme.partials.focus.insetAbsolute, - borderRadius: theme.borderRadiuses.medium, - top: focusInset, - bottom: focusInset, - left: focusInset, - right: focusInset, - }, - }, - '&:hover': { - '.icon > *:first-child': { - transform: 'scale(115%)', - }, - }, - '.icon': { - display: 'flex', - alignItems: 'center', - position: 'relative', - marginLeft: theme.spacing.xsmall, - transformOrigin: '50% 50%', - transform: 'scaleY(100%)', - transition: 'transform 0.2s ease', - }, - '&[aria-expanded="true"] .icon': { - transform: 'scaleY(-100%)', - }, - } -}) - -function AccordionContentUnstyled({ - isOpen, - className, - children, - pad: _pad, - unstyled: _unstyled, - ...props -}: { - isOpen?: boolean - pad?: boolean - unstyled?: boolean -} & HTMLAttributes) { - const eltRef = useRef(null) - const [contentHeight, setContentHeight] = useState(0) - const onResize = useCallback(() => { - if (eltRef.current?.offsetHeight) { - setContentHeight(eltRef.current?.offsetHeight) - } - }, []) - const theme = useTheme() - - useEffect(() => { - onResize() - }, [onResize]) - - useResizeObserver(eltRef, onResize) - const springs = useSpring({ - to: { height: isOpen ? contentHeight || 'auto' : 0 }, - config: isOpen - ? { - clamp: true, - mass: 0.6, - tension: 280, - velocity: 0.02, - } - : { - clamp: true, - mass: 0.6, - tension: 400, - velocity: 0.02, - }, - }) - const mOffset = theme.spacing.medium - theme.spacing.small - - return ( - -
- {children} -
-
- ) -} -const AccordionContent = styled(AccordionContentUnstyled)( - ({ theme, pad, unstyled }) => - unstyled - ? {} - : { - transition: `marginTop ${paddingTransition}`, - ...(pad - ? { - paddingLeft: theme.spacing.medium, - paddingRight: theme.spacing.medium, - paddingBottom: theme.spacing.medium, - } - : {}), - } -) - -type AccordionPropsBase = { - textValue?: string - padContent?: boolean - unstyled?: boolean - children?: ReactNode | ((props: { isOpen: boolean }) => ReactNode) -} & UseDisclosureProps - -type AccordionPropsWithTrigger = AccordionPropsBase & { - triggerButton: ReactElement - label?: never -} - -type AccordionPropsWithLabel = AccordionPropsBase & { - label: ReactNode - triggerButton?: never -} - -export type AccordionProps = AccordionPropsWithTrigger | AccordionPropsWithLabel - -export default function Accordion({ - textValue, - label, - triggerButton, - padContent = true, - unstyled = false, - children, - isOpen: isOpenProp, - onOpenChange, - defaultOpen, - id, - ...props -}: AccordionProps) { - if (!textValue && typeof label === 'string') { - textValue = label - } - - const { triggerProps, contentProps, isOpen } = useDisclosure({ - defaultOpen, - isOpen: isOpenProp, - onOpenChange, - id, - }) - - useEffect(() => {}, [isOpen]) - - const kids = useMemo( - () => - typeof children === 'function' - ? children({ isOpen: !!isOpen }) - : children, - [children, isOpen] - ) - - const Wrapper = useMemo(() => { - if (unstyled) { - return function Div(props: ComponentProps<'div'>) { - return
- } - } - - return Card - }, [unstyled]) - const finalTriggerProps = { - isOpen, - ...triggerProps, - } - - const trigger = triggerButton ? ( - React.cloneElement(triggerButton, finalTriggerProps) - ) : ( - - {label} - - ) - - return ( - - {trigger} - - {kids} - - - ) -} diff --git a/src/index.ts b/src/index.ts index 7a4894d9..e9a702f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,14 +7,8 @@ export * from './icons' export * from './plural-logos' // Components -export { default as AccordionOLD } from './components/AccordionOLD' export type { AccordionProps } from './components/Accordion' -export { - default as Accordion, - AccordionItem, - useIsItemOpen, - useCloseItem, -} from './components/Accordion' +export { default as Accordion, AccordionItem } from './components/Accordion' export { default as ArrowScroll } from './components/ArrowScroll' export { default as Banner } from './components/Banner' export { default as Button } from './components/Button' diff --git a/src/stories/Accordion.stories.tsx b/src/stories/Accordion.stories.tsx index e2e6a162..2c4877ca 100644 --- a/src/stories/Accordion.stories.tsx +++ b/src/stories/Accordion.stories.tsx @@ -5,13 +5,26 @@ export default { title: 'Accordion', component: Accordion, argTypes: { - hideDefaultIcon: { + type: { + options: ['single', 'multiple'], control: { - type: 'boolean', + type: 'select', }, }, - type: { - options: ['single', 'multiple'], + padding: { + options: ['none', 'compact', 'relaxed'], + control: { + type: 'select', + }, + }, + paddingArea: { + options: ['all', 'trigger-only'], + control: { + type: 'select', + }, + }, + caret: { + options: ['none', 'left', 'right'], control: { type: 'select', }, @@ -27,32 +40,47 @@ export default { export const Default = Template.bind({}) Default.args = { - hideDefaultIcon: false, type: 'single', + padding: 'relaxed', + paddingArea: 'all', + caret: 'right', trigger: 'Title', children: 'Children', } -function Template({ hideDefaultIcon, trigger, children, ...args }: any) { +function Template({ + padding, + paddingArea, + caret, + trigger, + children, + ...args +}: any) { return ( {children} {children} {children}