mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-12 09:47:51 +02:00
refactor: ui cleanup
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { Switch as SwitchPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MotionThumb = motion.create(SwitchPrimitive.Thumb);
|
||||
|
||||
type AnimatedSwitchProps = React.ComponentProps<typeof SwitchPrimitive.Root>;
|
||||
|
||||
/**
|
||||
* Toggle switch with a thumb that slides between the off (left) and on
|
||||
* (right) positions and squashes wider while pressed. Animated via Framer
|
||||
* Motion — no layout shift when the parent's width changes, and the
|
||||
* pressed state is purely visual so external onCheckedChange semantics
|
||||
* stay identical to a Radix Switch.
|
||||
*/
|
||||
function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="animated-switch"
|
||||
className={cn(
|
||||
"peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent",
|
||||
"bg-input data-[state=checked]:bg-primary",
|
||||
"transition-colors duration-200 ease-out",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<MotionThumb
|
||||
data-slot="animated-switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block size-4 rounded-full shadow-sm ring-0",
|
||||
"bg-background data-[state=checked]:bg-primary-foreground",
|
||||
)}
|
||||
layout
|
||||
transition={{ type: "spring", stiffness: 700, damping: 32, mass: 0.5 }}
|
||||
whileTap={{ width: 22 }}
|
||||
style={{ marginLeft: 2, marginRight: 2 }}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export type { AnimatedSwitchProps };
|
||||
export { AnimatedSwitch };
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { motion } from "motion/react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AnimatedTabsContextValue {
|
||||
activeValue: string | undefined;
|
||||
hoveredValue: string | null;
|
||||
setHoveredValue: (value: string | null) => void;
|
||||
indicatorId: string;
|
||||
}
|
||||
|
||||
const AnimatedTabsContext =
|
||||
React.createContext<AnimatedTabsContextValue | null>(null);
|
||||
|
||||
function useAnimatedTabs() {
|
||||
const ctx = React.useContext(AnimatedTabsContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"AnimatedTabsTrigger must be rendered inside <AnimatedTabs>",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
type AnimatedTabsProps = React.ComponentProps<typeof TabsPrimitive.Root>;
|
||||
|
||||
function AnimatedTabs({
|
||||
value: valueProp,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
children,
|
||||
...props
|
||||
}: AnimatedTabsProps) {
|
||||
const [activeValue, setActiveValue] = useControlledState({
|
||||
value: valueProp,
|
||||
defaultValue,
|
||||
onChange: onValueChange,
|
||||
});
|
||||
const [hoveredValue, setHoveredValue] = React.useState<string | null>(null);
|
||||
const indicatorId = React.useId();
|
||||
|
||||
return (
|
||||
<AnimatedTabsContext.Provider
|
||||
value={{
|
||||
activeValue,
|
||||
hoveredValue,
|
||||
setHoveredValue,
|
||||
indicatorId,
|
||||
}}
|
||||
>
|
||||
<TabsPrimitive.Root
|
||||
data-slot="animated-tabs"
|
||||
value={activeValue}
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={setActiveValue}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TabsPrimitive.Root>
|
||||
</AnimatedTabsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type AnimatedTabsListProps = React.ComponentProps<typeof TabsPrimitive.List>;
|
||||
|
||||
function AnimatedTabsList({
|
||||
className,
|
||||
onMouseLeave,
|
||||
...props
|
||||
}: AnimatedTabsListProps) {
|
||||
const { setHoveredValue } = useAnimatedTabs();
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="animated-tabs-list"
|
||||
className={cn(
|
||||
"relative inline-flex items-center gap-1 rounded-md p-0",
|
||||
className,
|
||||
)}
|
||||
onMouseLeave={(event) => {
|
||||
setHoveredValue(null);
|
||||
onMouseLeave?.(event);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type AnimatedTabsTriggerProps = React.ComponentProps<
|
||||
typeof TabsPrimitive.Trigger
|
||||
>;
|
||||
|
||||
function AnimatedTabsTrigger({
|
||||
value,
|
||||
className,
|
||||
children,
|
||||
onMouseEnter,
|
||||
...props
|
||||
}: AnimatedTabsTriggerProps) {
|
||||
const { activeValue, hoveredValue, setHoveredValue, indicatorId } =
|
||||
useAnimatedTabs();
|
||||
// The visible pill follows hover when present, otherwise sits on the
|
||||
// active tab. Framer's `layoutId` handles the slide animation between
|
||||
// mounted instances; only the trigger whose `value` matches `shownValue`
|
||||
// renders the indicator, so the transition is a single-element move.
|
||||
const shownValue = hoveredValue ?? activeValue;
|
||||
const showIndicator = shownValue === value;
|
||||
const isActive = activeValue === value;
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="animated-tabs-trigger"
|
||||
value={value}
|
||||
onMouseEnter={(event) => {
|
||||
setHoveredValue(value);
|
||||
onMouseEnter?.(event);
|
||||
}}
|
||||
className={cn(
|
||||
"relative isolate inline-flex h-7 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-sm font-medium transition-colors duration-150",
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
isActive && "text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{showIndicator && (
|
||||
<motion.span
|
||||
layoutId={`animated-tabs-indicator-${indicatorId}`}
|
||||
className="absolute inset-0 -z-10 rounded-md bg-accent"
|
||||
transition={{ type: "spring", stiffness: 360, damping: 32 }}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
const AnimatedTabsContent = TabsPrimitive.Content;
|
||||
|
||||
export type {
|
||||
AnimatedTabsListProps,
|
||||
AnimatedTabsProps,
|
||||
AnimatedTabsTriggerProps,
|
||||
};
|
||||
export {
|
||||
AnimatedTabs,
|
||||
AnimatedTabsContent,
|
||||
AnimatedTabsList,
|
||||
AnimatedTabsTrigger,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { type HTMLAttributes, useRef } from "react";
|
||||
import { useScrollFade } from "@/hooks/use-scroll-fade";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type FadingScrollAreaProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
/**
|
||||
* Scrollable container with top/bottom fade overlays. The fades only become
|
||||
* visible when the matching direction is actually scrollable. Use in place
|
||||
* of `<div className="border rounded-md max-h-[...] overflow-auto">` for
|
||||
* lists that should match the borderless aesthetic of the profile table.
|
||||
*/
|
||||
export function FadingScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FadingScrollAreaProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useScrollFade(ref);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("overflow-y-auto scroll-fade", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user