feat: enhance Accordion and Collapse components with improved type definitions and context management

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-04-30 21:27:33 +08:00
parent 94f440ef22
commit 333c64b1ad
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
4 changed files with 177 additions and 45 deletions

View File

@ -3,6 +3,7 @@ import { cn } from "@follow/utils/utils"
import { atom, useAtom, useStore } from "jotai"
import type { Variants } from "motion/react"
import { AnimatePresence, m } from "motion/react"
import type { FC } from "react"
import * as React from "react"
import { useEffect } from "react"
@ -18,10 +19,12 @@ interface CollapseProps {
contentClassName?: string
}
export const CollapseGroup: Component<{
defaultOpenId?: string
onOpenChange?: (state: Record<string, boolean>) => void
}> = ({ children, defaultOpenId, onOpenChange }) => {
export const CollapseGroup: FC<
{
defaultOpenId?: string
onOpenChange?: (state: Record<string, boolean>) => void
} & React.PropsWithChildren
> = ({ children, defaultOpenId, onOpenChange }) => {
const ctxValue = React.useMemo<CollapseContextValue>(
() => ({
currentOpenCollapseIdAtom: atom<string | null>(defaultOpenId ?? null),

View File

@ -1,49 +1,178 @@
"use client"
import { Spring } from "@follow/components/constants/spring"
import { cn } from "@follow/utils/utils"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import type { HTMLMotionProps, Transition } from "motion/react"
import { AnimatePresence, m } from "motion/react"
import * as React from "react"
const Accordion = AccordionPrimitive.Root
type AccordionItemContextType = {
isOpen: boolean
setIsOpen: (open: boolean) => void
}
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
))
AccordionItem.displayName = "AccordionItem"
const AccordionItemContext = React.createContext<AccordionItemContextType | undefined>(undefined)
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-left text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
const useAccordionItem = (): AccordionItemContextType => {
const context = React.useContext(AccordionItemContext)
if (!context) {
throw new Error("useAccordionItem must be used within an AccordionItem")
}
return context
}
type AccordionProps = React.ComponentProps<typeof AccordionPrimitive.Root>
function Accordion(props: AccordionProps) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
type AccordionItemProps = React.ComponentProps<typeof AccordionPrimitive.Item> & {
children: React.ReactNode
}
function AccordionItem({ className, children, ...props }: AccordionItemProps) {
const [isOpen, setIsOpen] = React.useState(false)
return (
<AccordionItemContext.Provider
value={React.useMemo(
() => ({
isOpen,
setIsOpen,
}),
[isOpen, setIsOpen],
)}
{...props}
>
{children}
<i className="i-mgc-chevron-down-cute-re text-muted-foreground size-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b", className)}
{...props}
>
{children}
</AccordionPrimitive.Item>
</AccordionItemContext.Provider>
)
}
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
type AccordionTriggerProps = React.ComponentProps<typeof AccordionPrimitive.Trigger> & {
transition?: Transition
chevron?: boolean
}
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }
function AccordionTrigger({
ref,
className,
children,
transition = Spring.presets.snappy,
chevron = true,
...props
}: AccordionTriggerProps) {
const triggerRef = React.useRef<HTMLButtonElement | null>(null)
// @ts-expect-error
React.useImperativeHandle(ref, () => triggerRef.current as HTMLButtonElement)
const { isOpen, setIsOpen } = useAccordionItem()
React.useEffect(() => {
const node = triggerRef.current
if (!node) return
const observer = new MutationObserver((mutationsList) => {
mutationsList.forEach((mutation) => {
if (mutation.attributeName === "data-state") {
const currentState = node.dataset.state
setIsOpen(currentState === "open")
}
})
})
observer.observe(node, {
attributes: true,
attributeFilter: ["data-state"],
})
const initialState = node.dataset.state
setIsOpen(initialState === "open")
return () => {
observer.disconnect()
}
}, [setIsOpen])
return (
<AccordionPrimitive.Header data-slot="accordion-header" className="flex">
<AccordionPrimitive.Trigger
ref={triggerRef}
data-slot="accordion-trigger"
className={cn(
"flex flex-1 items-center justify-between py-4 text-start font-medium hover:underline",
className,
)}
{...props}
>
{children}
{chevron && (
<m.div
data-slot="accordion-trigger-chevron"
animate={{ rotate: isOpen ? 90 : 0 }}
transition={transition}
className="ml-auto"
>
<i className="i-mgc-right-cute-re size-4 shrink-0" />
</m.div>
)}
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
type AccordionContentProps = React.ComponentProps<typeof AccordionPrimitive.Content> &
HTMLMotionProps<"div"> & {
transition?: Transition
}
function AccordionContent({
className,
children,
transition = Spring.presets.snappy,
...props
}: AccordionContentProps) {
const { isOpen } = useAccordionItem()
return (
<AnimatePresence>
{isOpen && (
<AccordionPrimitive.Content forceMount {...props}>
<m.div
key="accordion-content"
data-slot="accordion-content"
initial={{ height: 0, opacity: 0, "--mask-stop": "0%" }}
animate={{ height: "auto", opacity: 1, "--mask-stop": "100%" }}
exit={{ height: 0, opacity: 0, "--mask-stop": "0%" }}
transition={transition}
style={{
maskImage: "linear-gradient(black var(--mask-stop), transparent var(--mask-stop))",
WebkitMaskImage:
"linear-gradient(black var(--mask-stop), transparent var(--mask-stop))",
}}
className="overflow-hidden"
{...props}
>
<div className={cn("pb-4 pt-0 text-sm", className)}>{children}</div>
</m.div>
</AccordionPrimitive.Content>
)}
</AnimatePresence>
)
}
export {
Accordion,
AccordionContent,
type AccordionContentProps,
AccordionItem,
type AccordionItemContextType,
type AccordionItemProps,
type AccordionProps,
AccordionTrigger,
type AccordionTriggerProps,
}

View File

@ -28,7 +28,7 @@ function Checkbox({ className, onCheckedChange, ...props }: CheckboxProps) {
<m.button
data-slot="checkbox"
className={cn(
"bg-material-opaque cursor-checkbox focus-visible:ring-border data-[state=checked]:bg-accent peer flex size-5 shrink-0 items-center justify-center rounded-sm transition-colors duration-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:text-white",
"bg-fill cursor-checkbox focus-visible:ring-border data-[state=checked]:bg-accent peer flex size-5 shrink-0 items-center justify-center rounded-sm transition-colors duration-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:text-white",
className,
)}
whileTap={{ scale: 0.95 }}

View File

@ -50,7 +50,7 @@ function Switch({
checked={isChecked}
onChange={handleChange}
className={cn(
"focus-visible:ring-border cursor-switch data-[checked]:bg-accent bg-material-opaque relative flex h-6 w-10 shrink-0 items-center justify-start rounded-full p-[3px] transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[checked]:justify-end",
"focus-visible:ring-border cursor-switch data-[checked]:bg-accent bg-fill relative flex h-6 w-10 shrink-0 items-center justify-start rounded-full p-[3px] transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[checked]:justify-end",
className,
)}
as={as}