2025-08-20 04:12:49 -06:00

205 lines
5.6 KiB
TypeScript

'use client';
import * as React from 'react';
import { ToggleGroup as ToggleGroupPrimitive } from '@base-ui-components/react/toggle-group';
import { Toggle as TogglePrimitive } from '@base-ui-components/react/toggle';
import {
type HTMLMotionProps,
type Transition,
motion,
AnimatePresence,
} from 'motion/react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@workspace/ui/lib/utils';
const toggleVariants = cva(
"cursor-pointer inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:text-muted-foreground text-accent-foreground transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 data-[pressed]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none focus:outline-none aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
type: {
single: '',
multiple: 'data-[pressed]:bg-accent',
},
variant: {
default: 'bg-transparent',
outline: 'border border-input bg-transparent shadow-xs',
},
size: {
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
type ToggleGroupContextProps = VariantProps<typeof toggleVariants> & {
type?: 'single' | 'multiple';
transition?: Transition;
activeClassName?: string;
globalId: string;
};
const ToggleGroupContext = React.createContext<
ToggleGroupContextProps | undefined
>(undefined);
const useToggleGroup = (): ToggleGroupContextProps => {
const context = React.useContext(ToggleGroupContext);
if (!context) {
throw new Error('useToggleGroup must be used within a ToggleGroup');
}
return context;
};
type ToggleGroupProps = React.ComponentProps<typeof ToggleGroupPrimitive> &
Omit<VariantProps<typeof toggleVariants>, 'type'> & {
transition?: Transition;
activeClassName?: string;
};
function ToggleGroup({
className,
variant,
size,
children,
transition = { type: 'spring', bounce: 0, stiffness: 200, damping: 25 },
activeClassName,
...props
}: ToggleGroupProps) {
const globalId = React.useId();
return (
<ToggleGroupContext.Provider
value={{
variant,
size,
type: props.toggleMultiple ? 'multiple' : 'single',
transition,
activeClassName,
globalId,
}}
>
<ToggleGroupPrimitive
data-slot="toggle-group"
className={cn(
'flex items-center justify-center gap-1 relative',
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive>
</ToggleGroupContext.Provider>
);
}
type ToggleGroupItemProps = Omit<
React.ComponentProps<typeof TogglePrimitive>,
'render'
> &
Omit<VariantProps<typeof toggleVariants>, 'type'> & {
children?: React.ReactNode;
buttonProps?: HTMLMotionProps<'button'>;
spanProps?: React.ComponentProps<'span'>;
};
function ToggleGroupItem({
ref,
className,
children,
variant,
size,
buttonProps,
spanProps,
...props
}: ToggleGroupItemProps) {
const {
activeClassName,
transition,
type,
variant: contextVariant,
size: contextSize,
globalId,
} = useToggleGroup();
const itemRef = React.useRef<HTMLButtonElement | null>(null);
React.useImperativeHandle(ref, () => itemRef.current as HTMLButtonElement);
const [isActive, setIsActive] = React.useState(false);
React.useEffect(() => {
const node = itemRef.current;
if (!node) return;
const observer = new MutationObserver(() => {
setIsActive(node.getAttribute('data-pressed') === '');
});
observer.observe(node, {
attributes: true,
attributeFilter: ['data-pressed'],
});
setIsActive(node.getAttribute('data-pressed') === '');
return () => observer.disconnect();
}, [setIsActive]);
return (
<TogglePrimitive
ref={itemRef}
{...props}
render={
<motion.button
data-slot="toggle-group-item"
initial={{ scale: 1 }}
whileTap={{ scale: 0.9 }}
{...buttonProps}
className={cn('relative', buttonProps?.className)}
>
<span
{...spanProps}
{...(isActive ? { 'data-pressed': '' } : {})}
className={cn(
'relative z-[1]',
toggleVariants({
variant: variant || contextVariant,
size: size || contextSize,
type,
}),
className,
spanProps?.className,
)}
>
{children}
</span>
<AnimatePresence initial={false}>
{isActive && type === 'single' && (
<motion.span
layoutId={`active-toggle-group-item-${globalId}`}
data-slot="active-toggle-group-item"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={transition}
className={cn(
'absolute inset-0 z-0 rounded-md bg-muted',
activeClassName,
)}
/>
)}
</AnimatePresence>
</motion.button>
}
/>
);
}
export {
ToggleGroup,
ToggleGroupItem,
type ToggleGroupProps,
type ToggleGroupItemProps,
};