refactor: ui cleanup

This commit is contained in:
zhom
2026-05-15 15:44:20 +04:00
parent 56b0da990b
commit c8a43b43f1
35 changed files with 3792 additions and 1674 deletions
+50
View File
@@ -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 };
+156
View File
@@ -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,
};
+32
View File
@@ -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>
);
}