feat: add Accordion component with customizable behavior and animations, update Checkbox and Slider components for improved functionality, and enhance package dependencies

This commit is contained in:
Cody Seibert
2025-12-15 18:57:32 -05:00
parent b66d228460
commit a3a648aef1
8 changed files with 12021 additions and 10674 deletions

View File

@@ -40,7 +40,6 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",

View File

@@ -2522,29 +2522,33 @@
z-index: 0; z-index: 0;
} }
/* Accordion animations */ /* Accordion animations - CSS-only approach */
@keyframes accordion-down { @keyframes accordion-down {
from { from {
height: 0; height: 0;
opacity: 0;
} }
to { to {
height: var(--radix-accordion-content-height); height: var(--accordion-content-height, auto);
opacity: 1;
} }
} }
@keyframes accordion-up { @keyframes accordion-up {
from { from {
height: var(--radix-accordion-content-height); height: var(--accordion-content-height, auto);
opacity: 1;
} }
to { to {
height: 0; height: 0;
opacity: 0;
} }
} }
.animate-accordion-down { .animate-accordion-down {
animation: accordion-down 0.2s ease-out; animation: accordion-down 0.2s ease-out forwards;
} }
.animate-accordion-up { .animate-accordion-up {
animation: accordion-up 0.2s ease-out; animation: accordion-up 0.2s ease-out forwards;
} }

View File

@@ -1,57 +1,243 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root; type AccordionType = "single" | "multiple";
const AccordionItem = React.forwardRef< interface AccordionContextValue {
React.ElementRef<typeof AccordionPrimitive.Item>, type: AccordionType;
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> value: string | string[];
>(({ className, ...props }, ref) => ( onValueChange: (value: string) => void;
<AccordionPrimitive.Item collapsible?: boolean;
ref={ref} }
className={cn("border-b border-border", className)}
{...props} const AccordionContext = React.createContext<AccordionContextValue | null>(
/> null
)); );
interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
type?: "single" | "multiple";
value?: string | string[];
defaultValue?: string | string[];
onValueChange?: (value: string | string[]) => void;
collapsible?: boolean;
}
const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
(
{
type = "single",
value,
defaultValue,
onValueChange,
collapsible = false,
className,
children,
...props
},
ref
) => {
const [internalValue, setInternalValue] = React.useState<string | string[]>(
() => {
if (value !== undefined) return value;
if (defaultValue !== undefined) return defaultValue;
return type === "single" ? "" : [];
}
);
const currentValue = value !== undefined ? value : internalValue;
const handleValueChange = React.useCallback(
(itemValue: string) => {
let newValue: string | string[];
if (type === "single") {
if (currentValue === itemValue && collapsible) {
newValue = "";
} else if (currentValue === itemValue && !collapsible) {
return;
} else {
newValue = itemValue;
}
} else {
const currentArray = Array.isArray(currentValue)
? currentValue
: [currentValue].filter(Boolean);
if (currentArray.includes(itemValue)) {
newValue = currentArray.filter((v) => v !== itemValue);
} else {
newValue = [...currentArray, itemValue];
}
}
if (value === undefined) {
setInternalValue(newValue);
}
onValueChange?.(newValue);
},
[type, currentValue, collapsible, value, onValueChange]
);
const contextValue = React.useMemo(
() => ({
type,
value: currentValue,
onValueChange: handleValueChange,
collapsible,
}),
[type, currentValue, handleValueChange, collapsible]
);
return (
<AccordionContext.Provider value={contextValue}>
<div
ref={ref}
data-slot="accordion"
className={cn("w-full", className)}
{...props}
>
{children}
</div>
</AccordionContext.Provider>
);
}
);
Accordion.displayName = "Accordion";
interface AccordionItemContextValue {
value: string;
isOpen: boolean;
}
const AccordionItemContext =
React.createContext<AccordionItemContextValue | null>(null);
interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
}
const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
({ className, value, children, ...props }, ref) => {
const accordionContext = React.useContext(AccordionContext);
if (!accordionContext) {
throw new Error("AccordionItem must be used within an Accordion");
}
const isOpen = Array.isArray(accordionContext.value)
? accordionContext.value.includes(value)
: accordionContext.value === value;
const contextValue = React.useMemo(
() => ({ value, isOpen }),
[value, isOpen]
);
return (
<AccordionItemContext.Provider value={contextValue}>
<div
ref={ref}
data-slot="accordion-item"
data-state={isOpen ? "open" : "closed"}
className={cn("border-b border-border", className)}
{...props}
>
{children}
</div>
</AccordionItemContext.Provider>
);
}
);
AccordionItem.displayName = "AccordionItem"; AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef< interface AccordionTriggerProps
React.ElementRef<typeof AccordionPrimitive.Trigger>, extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
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 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef< const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>, HTMLButtonElement,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> AccordionTriggerProps
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => {
<AccordionPrimitive.Content const accordionContext = React.useContext(AccordionContext);
ref={ref} const itemContext = React.useContext(AccordionItemContext);
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props} if (!accordionContext || !itemContext) {
> throw new Error("AccordionTrigger must be used within an AccordionItem");
<div className={cn("pb-4 pt-0", className)}>{children}</div> }
</AccordionPrimitive.Content>
)); const { onValueChange } = accordionContext;
AccordionContent.displayName = AccordionPrimitive.Content.displayName; const { value, isOpen } = itemContext;
return (
<div data-slot="accordion-header" className="flex">
<button
ref={ref}
type="button"
data-slot="accordion-trigger"
data-state={isOpen ? "open" : "closed"}
aria-expanded={isOpen}
onClick={() => onValueChange(value)}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</button>
</div>
);
});
AccordionTrigger.displayName = "AccordionTrigger";
interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {}
const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>(
({ className, children, ...props }, ref) => {
const itemContext = React.useContext(AccordionItemContext);
const contentRef = React.useRef<HTMLDivElement>(null);
const [height, setHeight] = React.useState<number | undefined>(undefined);
if (!itemContext) {
throw new Error("AccordionContent must be used within an AccordionItem");
}
const { isOpen } = itemContext;
React.useEffect(() => {
if (contentRef.current) {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setHeight(entry.contentRect.height);
}
});
resizeObserver.observe(contentRef.current);
return () => resizeObserver.disconnect();
}
}, []);
return (
<div
data-slot="accordion-content"
data-state={isOpen ? "open" : "closed"}
className="overflow-hidden text-sm transition-all duration-200 ease-out"
style={{
height: isOpen ? (height !== undefined ? `${height}px` : "auto") : 0,
opacity: isOpen ? 1 : 0,
}}
{...props}
>
<div ref={contentRef}>
<div ref={ref} className={cn("pb-4 pt-0", className)}>
{children}
</div>
</div>
</div>
);
}
);
AccordionContent.displayName = "AccordionContent";
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -6,25 +6,37 @@ import { Check } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef< interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "checked" | "defaultChecked"> {
React.ElementRef<typeof CheckboxPrimitive.Root>, checked?: boolean | "indeterminate";
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> defaultChecked?: boolean | "indeterminate";
>(({ className, ...props }, ref) => ( onCheckedChange?: (checked: boolean) => void;
<CheckboxPrimitive.Root required?: boolean;
ref={ref} }
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80", const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
className ({ className, onCheckedChange, ...props }, ref) => (
)} <CheckboxPrimitive.Root
{...props} ref={ref}
> className={cn(
<CheckboxPrimitive.Indicator "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
className={cn("flex items-center justify-center text-current")} className
)}
onCheckedChange={(checked) => {
// Handle indeterminate state by treating it as false for consumers expecting boolean
if (onCheckedChange) {
onCheckedChange(checked === true);
}
}}
{...props}
> >
<Check className="h-4 w-4" /> <CheckboxPrimitive.Indicator
</CheckboxPrimitive.Indicator> className={cn("flex items-center justify-center text-current")}
</CheckboxPrimitive.Root> >
)); <Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
);
Checkbox.displayName = CheckboxPrimitive.Root.displayName; Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox }; export { Checkbox };

View File

@@ -1,39 +1,43 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog" import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />;
} }
function SheetTrigger({ function SheetTrigger({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) { }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
} }
function SheetClose({ function SheetClose({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) { }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
} }
function SheetPortal({ function SheetPortal({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) { }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
} }
function SheetOverlay({ interface SheetOverlayProps extends React.HTMLAttributes<HTMLDivElement> {
className, forceMount?: true;
...props }
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => {
const Overlay = SheetPrimitive.Overlay as React.ComponentType<
SheetOverlayProps & { "data-slot": string }
>;
return ( return (
<SheetPrimitive.Overlay <Overlay
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
@@ -41,23 +45,35 @@ function SheetOverlay({
)} )}
{...props} {...props}
/> />
) );
};
interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> {
side?: "top" | "right" | "bottom" | "left";
forceMount?: true;
onEscapeKeyDown?: (event: KeyboardEvent) => void;
onPointerDownOutside?: (event: PointerEvent) => void;
onInteractOutside?: (event: Event) => void;
} }
function SheetContent({ const SheetContent = ({
className, className,
children, children,
side = "right", side = "right",
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: SheetContentProps) => {
side?: "top" | "right" | "bottom" | "left" const Content = SheetPrimitive.Content as React.ComponentType<
children?: React.ReactNode SheetContentProps & { "data-slot": string }
className?: string >;
}) { const Close = SheetPrimitive.Close as React.ComponentType<{
className: string;
children: React.ReactNode;
}>;
return ( return (
<SheetPortal> <SheetPortal>
<SheetOverlay /> <SheetOverlay />
<SheetPrimitive.Content <Content
data-slot="sheet-content" data-slot="sheet-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
@@ -74,14 +90,14 @@ function SheetContent({
{...props} {...props}
> >
{children} {children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> <Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" /> <XIcon className="size-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </Close>
</SheetPrimitive.Content> </Content>
</SheetPortal> </SheetPortal>
) );
} };
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@@ -90,7 +106,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-1.5 p-4", className)} className={cn("flex flex-col gap-1.5 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -100,34 +116,39 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetTitle({ interface SheetTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
className,
...props const SheetTitle = ({ className, ...props }: SheetTitleProps) => {
}: React.ComponentProps<typeof SheetPrimitive.Title>) { const Title = SheetPrimitive.Title as React.ComponentType<
SheetTitleProps & { "data-slot": string }
>;
return ( return (
<SheetPrimitive.Title <Title
data-slot="sheet-title" data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) );
} };
function SheetDescription({ interface SheetDescriptionProps
className, extends React.HTMLAttributes<HTMLParagraphElement> {}
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) { const SheetDescription = ({ className, ...props }: SheetDescriptionProps) => {
const Description = SheetPrimitive.Description as React.ComponentType<
SheetDescriptionProps & { "data-slot": string }
>;
return ( return (
<SheetPrimitive.Description <Description
data-slot="sheet-description" data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} };
export { export {
Sheet, Sheet,
@@ -138,4 +159,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} };

View File

@@ -4,24 +4,38 @@ import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider"; import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const Slider = React.forwardRef< interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "defaultValue" | "dir"> {
React.ComponentRef<typeof SliderPrimitive.Root>, value?: number[];
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> defaultValue?: number[];
>(({ className, ...props }, ref) => ( onValueChange?: (value: number[]) => void;
<SliderPrimitive.Root onValueCommit?: (value: number[]) => void;
ref={ref} min?: number;
className={cn( max?: number;
"relative flex w-full touch-none select-none items-center", step?: number;
className disabled?: boolean;
)} orientation?: "horizontal" | "vertical";
{...props} dir?: "ltr" | "rtl";
> inverted?: boolean;
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer"> minStepsBetweenThumbs?: number;
<SliderPrimitive.Range className="slider-range absolute h-full bg-primary" /> }
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" /> const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
</SliderPrimitive.Root> ({ className, ...props }, ref) => (
)); <SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
<SliderPrimitive.Range className="slider-range absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
</SliderPrimitive.Root>
)
);
Slider.displayName = SliderPrimitive.Root.displayName; Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider }; export { Slider };

22194
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,5 +29,8 @@
"test:headed": "npm run test:headed --workspace=apps/app", "test:headed": "npm run test:headed --workspace=apps/app",
"test:server": "npm run test --workspace=apps/server", "test:server": "npm run test --workspace=apps/server",
"test:server:coverage": "npm run test:cov --workspace=apps/server" "test:server:coverage": "npm run test:cov --workspace=apps/server"
},
"dependencies": {
"cross-spawn": "^7.0.6"
} }
} }