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:
parent
94f440ef22
commit
333c64b1ad
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue