diff options
Diffstat (limited to 'apps/web/src/components/ui')
27 files changed, 1361 insertions, 2596 deletions
diff --git a/apps/web/src/components/ui/alert.tsx b/apps/web/src/components/ui/alert.tsx index e941ca43..dcc190d3 100644 --- a/apps/web/src/components/ui/alert.tsx +++ b/apps/web/src/components/ui/alert.tsx @@ -1,6 +1,5 @@ import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; -import { AlertTriangle } from "lucide-react"; import { cn } from "@/utils/ui"; import { HugeiconsIcon } from "@hugeicons/react"; import { Alert02Icon } from "@hugeicons/core-free-icons"; @@ -32,7 +31,10 @@ const Alert = React.forwardRef< {...props} > {variant === "destructive" && ( - <HugeiconsIcon icon={Alert02Icon} className="size-5 text-destructive mt-0.5" /> + <HugeiconsIcon + icon={Alert02Icon} + className="size-5 text-destructive mt-0.5" + /> )} {children} </div> @@ -45,7 +47,10 @@ const AlertTitle = React.forwardRef< >(({ className, ...props }, ref) => ( <h5 ref={ref} - className={cn("mb-2 text-base leading-none font-semibold tracking-tight", className)} + className={cn( + "mb-2 text-base leading-none font-semibold tracking-tight", + className, + )} {...props} /> )); diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx index 0c2fb4c4..a3b59233 100644 --- a/apps/web/src/components/ui/avatar.tsx +++ b/apps/web/src/components/ui/avatar.tsx @@ -38,10 +38,10 @@ const AvatarFallback = React.forwardRef< >(({ className, ...props }, ref) => ( <AvatarPrimitive.Fallback ref={ref} - className={cn( - "bg-muted flex size-full items-center justify-center rounded-full", - className, - )} + className={cn( + "bg-muted flex size-full items-center justify-center rounded-full", + className, + )} {...props} /> )); diff --git a/apps/web/src/components/ui/breadcrumb.tsx b/apps/web/src/components/ui/breadcrumb.tsx index e95f2965..927cca89 100644 --- a/apps/web/src/components/ui/breadcrumb.tsx +++ b/apps/web/src/components/ui/breadcrumb.tsx @@ -1,108 +1,99 @@ -import * as React from "react"; -import { Slot as SlotPrimitive } from "radix-ui"; import { ChevronRight, MoreHorizontal } from "lucide-react"; +import { Slot } from "radix-ui"; import { cn } from "@/utils/ui"; -const Breadcrumb = React.forwardRef< - HTMLElement, - React.ComponentPropsWithoutRef<"nav"> & { - separator?: React.ReactNode; - } ->(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />); -Breadcrumb.displayName = "Breadcrumb"; +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />; +} -const BreadcrumbList = React.forwardRef< - HTMLOListElement, - React.ComponentPropsWithoutRef<"ol"> ->(({ className, ...props }, ref) => ( - <ol - ref={ref} - className={cn( - "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5", - className, - )} - {...props} - /> -)); -BreadcrumbList.displayName = "BreadcrumbList"; +function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) { + return ( + <ol + data-slot="breadcrumb-list" + className={cn( + "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5", + className, + )} + {...props} + /> + ); +} -const BreadcrumbItem = React.forwardRef< - HTMLLIElement, - React.ComponentPropsWithoutRef<"li"> ->(({ className, ...props }, ref) => ( - <li - ref={ref} - className={cn("inline-flex items-center gap-1.5", className)} - {...props} - /> -)); -BreadcrumbItem.displayName = "BreadcrumbItem"; +function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { + return ( + <li + data-slot="breadcrumb-item" + className={cn("inline-flex items-center gap-1.5", className)} + {...props} + /> + ); +} -const BreadcrumbLink = React.forwardRef< - HTMLAnchorElement, - React.ComponentPropsWithoutRef<"a"> & { - asChild?: boolean; - } ->(({ asChild, className, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "a"; +function BreadcrumbLink({ + asChild, + className, + ...props +}: React.ComponentProps<"a"> & { + asChild?: boolean; +}) { + const Comp = asChild ? Slot.Root : "a"; return ( <Comp - ref={ref} - className={cn("hover:text-foreground", className)} + data-slot="breadcrumb-link" + className={cn("hover:text-foreground transition-colors", className)} {...props} /> ); -}); -BreadcrumbLink.displayName = "BreadcrumbLink"; +} -const BreadcrumbPage = React.forwardRef< - HTMLSpanElement, - React.ComponentPropsWithoutRef<"span"> ->(({ className, ...props }, ref) => ( - <span - ref={ref} - role="link" - aria-disabled="true" - aria-current="page" - className={cn("text-foreground font-normal", className)} - {...props} - /> -)); -BreadcrumbPage.displayName = "BreadcrumbPage"; +function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { + return ( + <span + data-slot="breadcrumb-page" + aria-current="page" + className={cn("text-foreground font-normal", className)} + {...props} + /> + ); +} -const BreadcrumbSeparator = ({ +function BreadcrumbSeparator({ children, className, ...props -}: React.ComponentProps<"li">) => ( - <li - role="presentation" - aria-hidden="true" - className={cn("[&>svg]:size-3.5", className)} - {...props} - > - {children ?? <ChevronRight />} - </li> -); -BreadcrumbSeparator.displayName = "BreadcrumbSeparator"; +}: React.ComponentProps<"li">) { + return ( + <li + data-slot="breadcrumb-separator" + role="presentation" + aria-hidden="true" + className={cn("[&>svg]:size-3.5", className)} + {...props} + > + {children ?? <ChevronRight />} + </li> + ); +} -const BreadcrumbEllipsis = ({ +function BreadcrumbEllipsis({ className, ...props -}: React.ComponentProps<"span">) => ( - <span - role="presentation" - aria-hidden="true" - className={cn("flex size-9 items-center justify-center", className)} - {...props} - > - <MoreHorizontal className="size-4" /> - <span className="sr-only">More</span> - </span> -); -BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"; +}: React.ComponentProps<"span">) { + return ( + <span + data-slot="breadcrumb-ellipsis" + role="presentation" + aria-hidden="true" + className={cn("flex size-9 items-center justify-center", className)} + {...props} + > + <MoreHorizontal className="size-4" /> + <span className="sr-only">More</span> + </span> + ); +} export { Breadcrumb, diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 2fc2c2e6..fba3109f 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { Slot as SlotPrimitive } from "radix-ui"; import { cva, type VariantProps } from "class-variance-authority"; - import { cn } from "@/utils/ui"; const buttonVariants = cva( @@ -9,18 +8,14 @@ const buttonVariants = cva( { variants: { variant: { - default: - "bg-primary text-primary-foreground hover:bg-primary/90", - background: - "bg-background text-foreground hover:bg-background/90", - foreground: - "bg-foreground text-background hover:bg-foreground/90", + default: "bg-primary text-primary-foreground hover:bg-primary/90", + background: "bg-background text-foreground hover:bg-background/90", + foreground: "bg-foreground text-background hover:bg-foreground/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/80", "destructive-foreground": "border bg-background hover:bg-destructive/15 text-destructive", - outline: - "border border-border bg-transparent hover:bg-accent/50", + outline: "border border-border bg-transparent hover:bg-accent/50", secondary: "bg-secondary text-secondary-foreground border border-secondary-border", text: "bg-transparent rounded-none opacity-100 hover:opacity-75", @@ -28,10 +23,10 @@ const buttonVariants = cva( link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto", }, size: { - default: "h-9.5 px-4 py-2", - sm: "h-8 p-1 px-2 text-xs rounded-sm", + default: "h-9 px-4 py-2", + sm: "h-7.5 p-1 px-2.5 text-sm rounded-sm", lg: "h-10 p-5 px-6", - icon: "size-7", + icon: "size-7 rounded-sm", text: "p-0", }, }, @@ -54,8 +49,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const effectiveSize = size ?? (variant === "text" ? "text" : "default"); return ( <Comp - className={cn(buttonVariants({ variant, size: effectiveSize, className }))} + className={cn( + buttonVariants({ variant, size: effectiveSize, className }), + )} ref={ref} + type="button" {...props} /> ); diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx index a0664cc3..3d088b27 100644 --- a/apps/web/src/components/ui/calendar.tsx +++ b/apps/web/src/components/ui/calendar.tsx @@ -60,8 +60,8 @@ function Calendar({ ...classNames, }} components={{ - IconLeft: ({ ...props }) => <ChevronLeft className="size-4" />, - IconRight: ({ ...props }) => <ChevronRight className="size-4" />, + IconLeft: () => <ChevronLeft className="size-4" />, + IconRight: () => <ChevronRight className="size-4" />, }} {...props} /> diff --git a/apps/web/src/components/ui/carousel.tsx b/apps/web/src/components/ui/carousel.tsx deleted file mode 100644 index 9018b9ea..00000000 --- a/apps/web/src/components/ui/carousel.tsx +++ /dev/null @@ -1,262 +0,0 @@ -"use client"; - -import useEmblaCarousel, { - type UseEmblaCarouselType, -} from "embla-carousel-react"; -import { ArrowLeft, ArrowRight } from "lucide-react"; -import * as React from "react"; - -import { cn } from "@/utils/ui"; -import { Button } from "./button"; - -type CarouselApi = UseEmblaCarouselType[1]; -type UseCarouselParameters = Parameters<typeof useEmblaCarousel>; -type CarouselOptions = UseCarouselParameters[0]; -type CarouselPlugin = UseCarouselParameters[1]; - -type CarouselProps = { - opts?: CarouselOptions; - plugins?: CarouselPlugin; - orientation?: "horizontal" | "vertical"; - setApi?: (api: CarouselApi) => void; -}; - -type CarouselContextProps = { - carouselRef: ReturnType<typeof useEmblaCarousel>[0]; - api: ReturnType<typeof useEmblaCarousel>[1]; - scrollPrev: () => void; - scrollNext: () => void; - canScrollPrev: boolean; - canScrollNext: boolean; -} & CarouselProps; - -const CarouselContext = React.createContext<CarouselContextProps | null>(null); - -function useCarousel() { - const context = React.useContext(CarouselContext); - - if (!context) { - throw new Error("useCarousel must be used within a <Carousel />"); - } - - return context; -} - -const Carousel = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> & CarouselProps ->( - ( - { - orientation = "horizontal", - opts, - setApi, - plugins, - className, - children, - ...props - }, - ref, - ) => { - const [carouselRef, api] = useEmblaCarousel( - { - ...opts, - axis: orientation === "horizontal" ? "x" : "y", - }, - plugins, - ); - const [canScrollPrev, setCanScrollPrev] = React.useState(false); - const [canScrollNext, setCanScrollNext] = React.useState(false); - - const onSelect = React.useCallback((api: CarouselApi) => { - if (!api) { - return; - } - - setCanScrollPrev(api.canScrollPrev()); - setCanScrollNext(api.canScrollNext()); - }, []); - - const scrollPrev = React.useCallback(() => { - api?.scrollPrev(); - }, [api]); - - const scrollNext = React.useCallback(() => { - api?.scrollNext(); - }, [api]); - - const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent<HTMLDivElement>) => { - if (event.key === "ArrowLeft") { - event.preventDefault(); - scrollPrev(); - } else if (event.key === "ArrowRight") { - event.preventDefault(); - scrollNext(); - } - }, - [scrollPrev, scrollNext], - ); - - React.useEffect(() => { - if (!api || !setApi) { - return; - } - - setApi(api); - }, [api, setApi]); - - React.useEffect(() => { - if (!api) { - return; - } - - onSelect(api); - api.on("reInit", onSelect); - api.on("select", onSelect); - - return () => { - api?.off("select", onSelect); - }; - }, [api, onSelect]); - - return ( - <CarouselContext.Provider - value={{ - carouselRef, - api, - opts, - orientation: - orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), - scrollPrev, - scrollNext, - canScrollPrev, - canScrollNext, - }} - > - <div - ref={ref} - onKeyDownCapture={handleKeyDown} - className={cn("relative", className)} - role="region" - aria-roledescription="carousel" - {...props} - > - {children} - </div> - </CarouselContext.Provider> - ); - }, -); -Carousel.displayName = "Carousel"; - -const CarouselContent = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> ->(({ className, ...props }, ref) => { - const { carouselRef, orientation } = useCarousel(); - - return ( - <div ref={carouselRef} className="overflow-hidden"> - <div - ref={ref} - className={cn( - "flex", - orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", - className, - )} - {...props} - /> - </div> - ); -}); -CarouselContent.displayName = "CarouselContent"; - -const CarouselItem = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> ->(({ className, ...props }, ref) => { - const { orientation } = useCarousel(); - - return ( - <div - ref={ref} - role="group" - aria-roledescription="slide" - className={cn( - "min-w-0 shrink-0 grow-0 basis-full", - orientation === "horizontal" ? "pl-4" : "pt-4", - className, - )} - {...props} - /> - ); -}); -CarouselItem.displayName = "CarouselItem"; - -const CarouselPrevious = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<typeof Button> ->(({ className, variant = "outline", size = "icon", ...props }, ref) => { - const { orientation, scrollPrev, canScrollPrev } = useCarousel(); - - return ( - <Button - ref={ref} - variant={variant} - size={size} - className={cn( - "absolute size-8 rounded-full", - orientation === "horizontal" - ? "top-1/2 -left-12 -translate-y-1/2" - : "-top-12 left-1/2 -translate-x-1/2 rotate-90", - className, - )} - disabled={!canScrollPrev} - onClick={scrollPrev} - {...props} - > - <ArrowLeft className="size-4" /> - <span className="sr-only">Previous slide</span> - </Button> - ); -}); -CarouselPrevious.displayName = "CarouselPrevious"; - -const CarouselNext = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<typeof Button> ->(({ className, variant = "outline", size = "icon", ...props }, ref) => { - const { orientation, scrollNext, canScrollNext } = useCarousel(); - - return ( - <Button - ref={ref} - variant={variant} - size={size} - className={cn( - "absolute size-8 rounded-full", - orientation === "horizontal" - ? "top-1/2 -right-12 -translate-y-1/2" - : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", - className, - )} - disabled={!canScrollNext} - onClick={scrollNext} - {...props} - > - <ArrowRight className="size-4" /> - <span className="sr-only">Next slide</span> - </Button> - ); -}); -CarouselNext.displayName = "CarouselNext"; - -export { - type CarouselApi, - Carousel, - CarouselContent, - CarouselItem, - CarouselPrevious, - CarouselNext, -}; diff --git a/apps/web/src/components/ui/chart.tsx b/apps/web/src/components/ui/chart.tsx deleted file mode 100644 index 2bd04b20..00000000 --- a/apps/web/src/components/ui/chart.tsx +++ /dev/null @@ -1,370 +0,0 @@ -"use client"; - -import * as React from "react"; -import * as RechartsPrimitive from "recharts"; -import { - NameType, - Payload, - ValueType, -} from "recharts/types/component/DefaultTooltipContent"; - -import { cn } from "@/utils/ui"; - -// Format: { THEME_NAME: CSS_SELECTOR } -const THEMES = { light: "", dark: ".dark" } as const; - -export type ChartConfig = { - [k in string]: { - label?: React.ReactNode; - icon?: React.ComponentType; - } & ( - | { color?: string; theme?: never } - | { color?: never; theme: Record<keyof typeof THEMES, string> } - ); -}; - -type ChartContextProps = { - config: ChartConfig; -}; - -const ChartContext = React.createContext<ChartContextProps | null>(null); - -function useChart() { - const context = React.useContext(ChartContext); - - if (!context) { - throw new Error("useChart must be used within a <ChartContainer />"); - } - - return context; -} - -const ChartContainer = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - config: ChartConfig; - children: React.ComponentProps< - typeof RechartsPrimitive.ResponsiveContainer - >["children"]; - } ->(({ id, className, children, config, ...props }, ref) => { - const uniqueId = React.useId(); - const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; - - return ( - <ChartContext.Provider value={{ config }}> - <div - data-chart={chartId} - ref={ref} - className={cn( - "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", - className, - )} - {...props} - > - <ChartStyle id={chartId} config={config} /> - <RechartsPrimitive.ResponsiveContainer> - {children} - </RechartsPrimitive.ResponsiveContainer> - </div> - </ChartContext.Provider> - ); -}); -ChartContainer.displayName = "Chart"; - -const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { - const colorConfig = Object.entries(config).filter( - ([_, config]) => config.theme || config.color, - ); - - if (!colorConfig.length) { - return null; - } - - return ( - <style - dangerouslySetInnerHTML={{ - __html: Object.entries(THEMES) - .map( - ([theme, prefix]) => ` -${prefix} [data-chart=${id}] { -${colorConfig - .map(([key, itemConfig]) => { - const color = - itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || - itemConfig.color; - return color ? ` --color-${key}: ${color};` : null; - }) - .join("\n")} -} -`, - ) - .join("\n"), - }} - /> - ); -}; - -const ChartTooltip = RechartsPrimitive.Tooltip; - -const ChartTooltipContent = React.forwardRef< - HTMLDivElement, - React.ComponentProps<typeof RechartsPrimitive.Tooltip> & - React.ComponentProps<"div"> & { - hideLabel?: boolean; - hideIndicator?: boolean; - indicator?: "line" | "dot" | "dashed"; - nameKey?: string; - labelKey?: string; - } ->( - ( - { - active, - payload, - className, - indicator = "dot", - hideLabel = false, - hideIndicator = false, - label, - labelFormatter, - labelClassName, - formatter, - color, - nameKey, - labelKey, - }, - ref, - ) => { - const { config } = useChart(); - - const tooltipLabel = React.useMemo(() => { - if (hideLabel || !payload?.length) { - return null; - } - - const [item] = payload; - const key = `${labelKey || item.dataKey || item.name || "value"}`; - const itemConfig = getPayloadConfigFromPayload(config, item, key); - const value = - !labelKey && typeof label === "string" - ? config[label as keyof typeof config]?.label || label - : itemConfig?.label; - - if (labelFormatter) { - return ( - <div className={cn("font-medium", labelClassName)}> - {labelFormatter(value, payload)} - </div> - ); - } - - if (!value) { - return null; - } - - return <div className={cn("font-medium", labelClassName)}>{value}</div>; - }, [ - label, - labelFormatter, - payload, - hideLabel, - labelClassName, - config, - labelKey, - ]); - - if (!active || !payload?.length) { - return null; - } - - const nestLabel = payload.length === 1 && indicator !== "dot"; - - return ( - <div - ref={ref} - className={cn( - "border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl", - className, - )} - > - {nestLabel ? null : tooltipLabel} - <div className="grid gap-1.5"> - {payload.map((item, index) => { - const key = `${nameKey || item.name || item.dataKey || "value"}`; - const itemConfig = getPayloadConfigFromPayload(config, item, key); - const indicatorColor = color || item.payload.fill || item.color; - - return ( - <div - key={item.dataKey} - className={cn( - "[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5", - indicator === "dot" && "items-center", - )} - > - {formatter && item?.value !== undefined && item.name ? ( - formatter(item.value, item.name, item, index, item.payload) - ) : ( - <> - {itemConfig?.icon ? ( - <itemConfig.icon /> - ) : ( - !hideIndicator && ( - <div - className={cn( - "shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", - { - "size-2.5": indicator === "dot", - "w-1": indicator === "line", - "w-0 border-[1.5px] border-dashed bg-transparent": - indicator === "dashed", - "my-0.5": nestLabel && indicator === "dashed", - }, - )} - style={ - { - "--color-bg": indicatorColor, - "--color-border": indicatorColor, - } as React.CSSProperties - } - /> - ) - )} - <div - className={cn( - "flex flex-1 justify-between leading-none", - nestLabel ? "items-end" : "items-center", - )} - > - <div className="grid gap-1.5"> - {nestLabel ? tooltipLabel : null} - <span className="text-muted-foreground"> - {itemConfig?.label || item.name} - </span> - </div> - {item.value && ( - <span className="text-foreground font-mono font-medium tabular-nums"> - {item.value.toLocaleString()} - </span> - )} - </div> - </> - )} - </div> - ); - })} - </div> - </div> - ); - }, -); -ChartTooltipContent.displayName = "ChartTooltip"; - -const ChartLegend = RechartsPrimitive.Legend; - -const ChartLegendContent = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & - Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { - hideIcon?: boolean; - nameKey?: string; - } ->( - ( - { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, - ref, - ) => { - const { config } = useChart(); - - if (!payload?.length) { - return null; - } - - return ( - <div - ref={ref} - className={cn( - "flex items-center justify-center gap-4", - verticalAlign === "top" ? "pb-3" : "pt-3", - className, - )} - > - {payload.map((item) => { - const key = `${nameKey || item.dataKey || "value"}`; - const itemConfig = getPayloadConfigFromPayload(config, item, key); - - return ( - <div - key={item.value} - className={cn( - "[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:size-3", - )} - > - {itemConfig?.icon && !hideIcon ? ( - <itemConfig.icon /> - ) : ( - <div - className="size-2 shrink-0 rounded-[2px]" - style={{ - backgroundColor: item.color, - }} - /> - )} - {itemConfig?.label} - </div> - ); - })} - </div> - ); - }, -); -ChartLegendContent.displayName = "ChartLegend"; - -// Helper to extract item config from a payload. -function getPayloadConfigFromPayload( - config: ChartConfig, - payload: unknown, - key: string, -) { - if (typeof payload !== "object" || payload === null) { - return; - } - - const payloadPayload = - "payload" in payload && - typeof payload.payload === "object" && - payload.payload !== null - ? payload.payload - : undefined; - - let configLabelKey: string = key; - - if ( - key in payload && - typeof payload[key as keyof typeof payload] === "string" - ) { - configLabelKey = payload[key as keyof typeof payload] as string; - } else if ( - payloadPayload && - key in payloadPayload && - typeof payloadPayload[key as keyof typeof payloadPayload] === "string" - ) { - configLabelKey = payloadPayload[ - key as keyof typeof payloadPayload - ] as string; - } - - return configLabelKey in config - ? config[configLabelKey] - : config[key as keyof typeof config]; -} - -export { - ChartContainer, - ChartTooltip, - ChartTooltipContent, - ChartLegend, - ChartLegendContent, - ChartStyle, -}; diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx index 0ea1a14d..d58b6144 100644 --- a/apps/web/src/components/ui/color-picker.tsx +++ b/apps/web/src/components/ui/color-picker.tsx @@ -1,133 +1,80 @@ import { forwardRef, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; import { cn } from "@/utils/ui"; import { Input } from "./input"; +import { + Popover, + PopoverClose, + PopoverContent, + PopoverTrigger, +} from "./popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./select"; +import { Button } from "./button"; +import { Cancel01Icon, ColorPickerIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + type ColorFormat, + appendAlpha, + extractColorFromText, + formatColorValue, + hexToHsv, + hsvToHex, + parseColorInput, + parseHexAlpha, +} from "@/utils/color"; interface ColorPickerProps { value?: string; onChange?: (value: string) => void; onChangeEnd?: (value: string) => void; className?: string; - containerRef?: React.RefObject<HTMLDivElement | null>; } -const hexToHsv = (hex: string) => { - const r = parseInt(hex.slice(0, 2), 16) / 255; - const g = parseInt(hex.slice(2, 4), 16) / 255; - const b = parseInt(hex.slice(4, 6), 16) / 255; - - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - const diff = max - min; - - let h = 0; - const s = max === 0 ? 0 : diff / max; - const v = max; - - if (diff !== 0) { - switch (max) { - case r: - h = ((g - b) / diff) % 6; - break; - case g: - h = (b - r) / diff + 2; - break; - case b: - h = (r - g) / diff + 4; - break; - } - } - - h = (h * 60 + 360) % 360; - if (Number.isNaN(h)) h = 0; - - return [h, s, v]; -}; - -const hsvToHex = (h: number, s: number, v: number) => { - const c = v * s; - const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); - const m = v - c; - - let r = 0, - g = 0, - b = 0; - - if (h >= 0 && h < 60) { - r = c; - g = x; - b = 0; - } else if (h >= 60 && h < 120) { - r = x; - g = c; - b = 0; - } else if (h >= 120 && h < 180) { - r = 0; - g = c; - b = x; - } else if (h >= 180 && h < 240) { - r = 0; - g = x; - b = c; - } else if (h >= 240 && h < 300) { - r = x; - g = 0; - b = c; - } else if (h >= 300 && h < 360) { - r = c; - g = 0; - b = x; - } - - r = Math.round((r + m) * 255); - g = Math.round((g + m) * 255); - b = Math.round((b + m) * 255); - - return [r, g, b].map((x) => x.toString(16).padStart(2, "0")).join(""); -}; - const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( - ({ className, value = "FFFFFF", onChange, onChangeEnd, containerRef, ...props }, ref) => { - const [isOpen, setIsOpen] = useState(false); - const [isDragging, setIsDragging] = useState<"saturation" | "hue" | null>( - null, - ); - const [pickerPosition, setPickerPosition] = useState({ - right: 0, - bottom: 0, - }); + ({ className, value = "FFFFFF", onChange, onChangeEnd, ...props }, ref) => { + const [isDragging, setIsDragging] = useState< + "saturation" | "hue" | "opacity" | null + >(null); const [internalHue, setInternalHue] = useState(0); const [inputValue, setInputValue] = useState(value); + const [colorFormat, setColorFormat] = useState<ColorFormat>("hex"); - const pickerRef = useRef<HTMLDivElement>(null); const saturationRef = useRef<HTMLButtonElement>(null); const hueRef = useRef<HTMLButtonElement>(null); - const triggerRef = useRef<HTMLButtonElement>(null); + const opacityRef = useRef<HTMLButtonElement>(null); const latestDragColorRef = useRef<string | null>(null); - const [h, s, v] = hexToHsv(value); - const displayHue = s > 0 ? h : internalHue; + const isEyeDropperSupported = + typeof window !== "undefined" && "EyeDropper" in window; - useEffect(() => { - setInputValue(value); - }, [value]); + const { rgb: rgbValue, alpha } = parseHexAlpha({ hex: value }); + const [h, s, v] = hexToHsv({ hex: rgbValue }); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - pickerRef.current && - !pickerRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - }; - - if (isOpen) { - document.addEventListener("mousedown", handleClickOutside); - return () => - document.removeEventListener("mousedown", handleClickOutside); + const handleEyeDropper = async () => { + if (!isEyeDropperSupported || !EyeDropper) return; + try { + const dropper = new EyeDropper(); + const result = await dropper.open(); + const hex = result.sRGBHex.replace("#", "").toLowerCase(); + const finalHex = appendAlpha({ rgbHex: hex, alpha }); + onChange?.(finalHex); + onChangeEnd?.(finalHex); + } catch { + // user cancelled the picker } - }, [isOpen]); + }; + const hueDiff = Math.abs(h - internalHue); + const isSameHueWrapped = hueDiff < 1 || Math.abs(hueDiff - 360) < 1; + const displayHue = s === 0 || isSameHueWrapped ? internalHue : h; + + useEffect(() => { + setInputValue(formatColorValue({ hex: value, format: colorFormat })); + }, [value, colorFormat]); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { @@ -143,9 +90,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( 0, Math.min(1, (e.clientY - rect.top) / rect.height), ); - const newS = x; - const newV = 1 - y; - const newHex = hsvToHex(displayHue, newS, newV); + const newHex = appendAlpha({ + rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }), + alpha, + }); latestDragColorRef.current = newHex; onChange?.(newHex); } @@ -159,11 +107,25 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const newH = x * 360; setInternalHue(newH); if (s > 0) { - const newHex = hsvToHex(newH, s, v); + const newHex = appendAlpha({ + rgbHex: hsvToHex({ h: newH, s, v }), + alpha, + }); latestDragColorRef.current = newHex; onChange?.(newHex); } } + + if (isDragging === "opacity" && opacityRef.current) { + const rect = opacityRef.current.getBoundingClientRect(); + const x = Math.max( + 0, + Math.min(1, (e.clientX - rect.left) / rect.width), + ); + const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x }); + latestDragColorRef.current = newHex; + onChange?.(newHex); + } }; const handleMouseUp = () => { @@ -182,7 +144,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( document.removeEventListener("mouseup", handleMouseUp); }; } - }, [isDragging, displayHue, s, v, onChange]); + }, [isDragging, displayHue, s, v, alpha, rgbValue, onChange, onChangeEnd]); const handleSaturationMouseDown = (e: React.MouseEvent) => { e.preventDefault(); @@ -192,9 +154,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const rect = saturationElement.getBoundingClientRect(); const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); - const newS = x; - const newV = 1 - y; - const newHex = hsvToHex(displayHue, newS, newV); + const newHex = appendAlpha({ + rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }), + alpha, + }); latestDragColorRef.current = newHex; onChange?.(newHex); }; @@ -209,28 +172,83 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const newH = x * 360; setInternalHue(newH); if (s > 0) { - const newHex = hsvToHex(newH, s, v); + const newHex = appendAlpha({ + rgbHex: hsvToHex({ h: newH, s, v }), + alpha, + }); latestDragColorRef.current = newHex; onChange?.(newHex); } }; + const handleOpacityMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + const opacityElement = opacityRef.current; + if (!opacityElement) return; + setIsDragging("opacity"); + const rect = opacityElement.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x }); + latestDragColorRef.current = newHex; + onChange?.(newHex); + }; + const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { - const hex = e.target.value.replace("#", ""); - setInputValue(hex); + setInputValue( + colorFormat === "hex" + ? e.target.value.replace("#", "") + : e.target.value, + ); + }; + + const commitInputValue = () => { + const parsed = parseColorInput({ + input: inputValue, + format: colorFormat, + }); + if (parsed) { + const nextHex = appendAlpha({ rgbHex: parsed, alpha }); + onChange?.(nextHex); + onChangeEnd?.(nextHex); + return; + } + + const extracted = extractColorFromText({ text: inputValue }); + if (extracted) { + const hasExplicitAlpha = extracted.length > 6; + const finalHex = hasExplicitAlpha + ? extracted + : appendAlpha({ rgbHex: extracted, alpha }); + onChange?.(finalHex); + onChangeEnd?.(finalHex); + } }; const handleInputBlur = () => { - onChange?.(inputValue); + commitInputValue(); }; const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === "Enter") { - onChange?.(inputValue); + commitInputValue(); e.currentTarget.blur(); } }; + const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => { + const pastedText = event.clipboardData.getData("text"); + const extractedHex = extractColorFromText({ text: pastedText }); + if (!extractedHex) return; + + event.preventDefault(); + const hasExplicitAlpha = extractedHex.length > 6; + const finalHex = hasExplicitAlpha + ? extractedHex + : appendAlpha({ rgbHex: extractedHex, alpha }); + onChange?.(finalHex); + onChangeEnd?.(finalHex); + }; + const saturationStyle = { background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`, }; @@ -240,90 +258,178 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)", }; + const checkerboardStyle = { + backgroundImage: ` + linear-gradient(45deg, rgba(0,0,0,0.1) 25%, transparent 25%), + linear-gradient(-45deg, rgba(0,0,0,0.1) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.1) 75%), + linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.1) 75%) + `, + backgroundSize: "8px 8px", + backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px", + backgroundColor: "#fff", + }; + return ( - <div className="relative flex-1"> + <Popover> <div ref={ref} className={cn( - "bg-accent flex h-8 items-center gap-2 rounded-md px-[0.45rem]", + "bg-accent flex h-8 flex-1 items-center gap-2 rounded-md px-[0.45rem]", className, )} {...props} > - <button - ref={triggerRef} - className="size-4.5 cursor-pointer border rounded-sm hover:ring-2 hover:ring-white/20" - style={{ backgroundColor: `#${value}` }} - type="button" - onClick={() => { - if (!isOpen && triggerRef.current && containerRef?.current) { - const containerRect = - containerRef.current.getBoundingClientRect(); - setPickerPosition({ - right: window.innerWidth - containerRect.left - 8, - bottom: window.innerHeight - containerRect.bottom, - }); - } - setIsOpen(!isOpen); - }} - /> + <PopoverTrigger asChild> + <button + className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20" + style={{ backgroundColor: `#${value}` }} + type="button" + /> + </PopoverTrigger> <div className="flex flex-1 items-center"> <Input - className="!border-0 bg-transparent p-0 !ring-0 !ring-offset-0 uppercase" + className={cn( + "!border-0 bg-transparent p-0 !ring-0 !ring-offset-0", + colorFormat === "hex" && "uppercase", + )} size="sm" containerClassName="w-full" value={inputValue} onChange={handleInputChange} onBlur={handleInputBlur} onKeyDown={handleInputKeyDown} + onPaste={handlePaste} /> </div> </div> + <PopoverContent + className="w-64 px-0 select-none flex flex-col gap-3 py-2" + side="left" + sideOffset={8} + onOpenAutoFocus={(event) => { + event.preventDefault(); + }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + }} + onInteractOutside={(event) => { + if (isDragging) event.preventDefault(); + }} + > + <header className="border-b flex justify-between items-center pb-2 px-2"> + <Select defaultValue="custom"> + <SelectTrigger variant="outline"> + <SelectValue placeholder="Select a mode" /> + </SelectTrigger> + <SelectContent position="popper"> + <SelectItem value="custom">Custom</SelectItem> + <SelectItem value="saved">Saved</SelectItem> + </SelectContent> + </Select> + <div> + {isEyeDropperSupported && ( + <Button + variant="ghost" + size="icon" + type="button" + onClick={handleEyeDropper} + > + <HugeiconsIcon icon={ColorPickerIcon} /> + </Button> + )} + <PopoverClose asChild> + <Button variant="ghost" size="icon" type="button"> + <HugeiconsIcon icon={Cancel01Icon} /> + </Button> + </PopoverClose> + </div> + </header> + <div className="px-2 flex flex-col gap-3"> + <button + ref={saturationRef} + className="relative h-44 aspect-square w-full appearance-none border-0 bg-transparent p-0" + style={saturationStyle} + type="button" + onMouseDown={handleSaturationMouseDown} + > + <ColorCircle + size="sm" + position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }} + color={`#${value}`} + /> + </button> - {isOpen && - createPortal( - <div - ref={pickerRef} - className="bg-popover border-border fixed z-50 rounded-lg border p-4 shadow-lg select-none" - style={{ - right: pickerPosition.right, - bottom: pickerPosition.bottom, - }} + <button + ref={hueRef} + className="relative h-4 w-full rounded-lg appearance-none border-0 bg-transparent p-0" + style={hueStyle} + type="button" + onMouseDown={handleHueMouseDown} > - <button - ref={saturationRef} - className="relative mb-3 h-32 w-48 cursor-crosshair appearance-none border-0 bg-transparent p-0" - style={saturationStyle} - type="button" - onMouseDown={handleSaturationMouseDown} - > - <ColorCircle - size="sm" - position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }} - color={`#${value}`} - /> - </button> + <ColorCircle + size="md" + position={{ + left: `calc(0.5rem + (100% - 1rem) * ${displayHue / 360})`, + top: "50%", + }} + /> + </button> + + <button + ref={opacityRef} + className="relative h-4 w-full overflow-hidden rounded-lg appearance-none border-0 p-0" + style={checkerboardStyle} + type="button" + onMouseDown={handleOpacityMouseDown} + > + <div + className="absolute inset-0 rounded-lg" + style={{ + background: `linear-gradient(to right, transparent, #${rgbValue})`, + }} + /> + <ColorCircle + size="md" + position={{ + left: `calc(0.5rem + (100% - 1rem) * ${alpha})`, + top: "50%", + }} + /> + </button> - <button - ref={hueRef} - className="relative h-4 w-48 cursor-pointer rounded-lg appearance-none border-0 bg-transparent p-0" - style={hueStyle} - type="button" - onMouseDown={handleHueMouseDown} + <div className="flex items-center gap-2"> + <Select + value={colorFormat} + onValueChange={(value) => setColorFormat(value as ColorFormat)} > - <ColorCircle - size="md" - position={{ - left: `${(displayHue / 360) * 100}%`, - top: "50%", - }} - color={`#${value}`} - /> - </button> - </div>, - document.body, - )} - </div> + <SelectTrigger variant="outline" className="min-w-18 max-w-18"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="hex">HEX</SelectItem> + <SelectItem value="rgb">RGB</SelectItem> + <SelectItem value="hsl">HSL</SelectItem> + <SelectItem value="hsv">HSV</SelectItem> + </SelectContent> + </Select> + + <Input + className={cn( + "h-7 rounded-sm p-2.5", + colorFormat === "hex" && "uppercase", + )} + type="text" + value={inputValue} + onChange={handleInputChange} + onBlur={handleInputBlur} + onKeyDown={handleInputKeyDown} + onPaste={handlePaste} + /> + </div> + </div> + </PopoverContent> + </Popover> ); }, ); @@ -336,7 +442,7 @@ const ColorCircle = ({ }: { size: "sm" | "md"; position: { left: string; top: string }; - color: string; + color?: string; }) => ( <div className={`pointer-events-none absolute rounded-full border-3 border-white shadow-lg ${ diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx deleted file mode 100644 index f5d96125..00000000 --- a/apps/web/src/components/ui/command.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client"; - -import * as React from "react"; -import type { DialogProps } from "@radix-ui/react-dialog"; -import { Command as CommandPrimitive } from "cmdk"; -import { Search } from "lucide-react"; -import { cn } from "@/utils/ui"; -import { Dialog, DialogContent } from "./dialog"; - -const Command = React.forwardRef< - React.ElementRef<typeof CommandPrimitive>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive> ->(({ className, ...props }, ref) => ( - <CommandPrimitive - ref={ref} - className={cn( - "bg-popover text-popover-foreground flex size-full flex-col overflow-hidden rounded-md", - className, - )} - {...props} - /> -)); -Command.displayName = CommandPrimitive.displayName; - -const CommandDialog = ({ children, ...props }: DialogProps) => { - return ( - <Dialog {...props}> - <DialogContent className="overflow-hidden p-0"> - <Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-5"> - {children} - </Command> - </DialogContent> - </Dialog> - ); -}; - -const CommandInput = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Input>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> ->(({ className, ...props }, ref) => ( - <div className="flex items-center border-b px-3" cmdk-input-wrapper=""> - <Search className="mr-2 size-4 shrink-0 opacity-50" /> - <CommandPrimitive.Input - ref={ref} - className={cn( - "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", - className, - )} - {...props} - /> - </div> -)); - -CommandInput.displayName = CommandPrimitive.Input.displayName; - -const CommandList = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.List>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> ->(({ className, ...props }, ref) => ( - <CommandPrimitive.List - ref={ref} - className={cn("max-h-[300px] overflow-x-hidden overflow-y-auto", className)} - {...props} - /> -)); - -CommandList.displayName = CommandPrimitive.List.displayName; - -const CommandEmpty = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Empty>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> ->((props, ref) => ( - <CommandPrimitive.Empty - ref={ref} - className="py-6 text-center text-sm" - {...props} - /> -)); - -CommandEmpty.displayName = CommandPrimitive.Empty.displayName; - -const CommandGroup = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Group>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> ->(({ className, ...props }, ref) => ( - <CommandPrimitive.Group - ref={ref} - className={cn( - "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium", - className, - )} - {...props} - /> -)); - -CommandGroup.displayName = CommandPrimitive.Group.displayName; - -const CommandSeparator = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Separator>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> ->(({ className, ...props }, ref) => ( - <CommandPrimitive.Separator - ref={ref} - className={cn("bg-border -mx-1 h-px", className)} - {...props} - /> -)); -CommandSeparator.displayName = CommandPrimitive.Separator.displayName; - -const CommandItem = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> ->(({ className, ...props }, ref) => ( - <CommandPrimitive.Item - ref={ref} - className={cn( - "data-[selected=true]:bg-popover-hover data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - className, - )} - {...props} - /> -)); - -CommandItem.displayName = CommandPrimitive.Item.displayName; - -const CommandShortcut = ({ - className, - ...props -}: React.HTMLAttributes<HTMLSpanElement>) => { - return ( - <span - className={cn( - "text-muted-foreground ml-auto text-xs tracking-widest", - className, - )} - {...props} - /> - ); -}; -CommandShortcut.displayName = "CommandShortcut"; - -export { - Command, - CommandDialog, - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandShortcut, - CommandSeparator, -}; diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx index 4af92764..a49d8a88 100644 --- a/apps/web/src/components/ui/context-menu.tsx +++ b/apps/web/src/components/ui/context-menu.tsx @@ -5,7 +5,11 @@ import { ContextMenu as ContextMenuPrimitive } from "radix-ui"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/utils/ui"; import { HugeiconsIcon } from "@hugeicons/react"; -import { Tick02Icon, ArrowRightIcon, CircleIcon } from "@hugeicons/core-free-icons" +import { + Tick02Icon, + ArrowRightIcon, + CircleIcon, +} from "@hugeicons/core-free-icons"; const ContextMenu = ContextMenuPrimitive.Root; @@ -20,12 +24,14 @@ const ContextMenuSub = ContextMenuPrimitive.Sub; const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; const contextMenuItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 last:pb-1 text-base outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0", + "relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 text-sm outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { - default: "focus:bg-accent/35 focus:text-accent-foreground [&_svg]:text-muted-foreground", - destructive: "text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive", + default: + "focus:bg-accent focus:text-accent-foreground [&_svg]:text-muted-foreground", + destructive: + "text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive", }, }, defaultVariants: { @@ -41,22 +47,32 @@ const ContextMenuSubTrigger = React.forwardRef< variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; icon?: React.ReactNode; } ->(({ className, inset, children, variant = "default", icon, ...props }, ref) => ( - <ContextMenuPrimitive.SubTrigger - ref={ref} - className={cn( - contextMenuItemVariants({ variant }), - "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", - inset && "pl-8", - className, - )} - {...props} - > - {icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>} - {children} - <HugeiconsIcon icon={ArrowRightIcon} className="ml-auto text-muted-foreground/80" /> - </ContextMenuPrimitive.SubTrigger> -)); +>( + ( + { className, inset, children, variant = "default", icon, ...props }, + ref, + ) => ( + <ContextMenuPrimitive.SubTrigger + ref={ref} + className={cn( + contextMenuItemVariants({ variant }), + "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + inset && "pl-8", + className, + )} + {...props} + > + {icon && ( + <span className="size-4 shrink-0 text-muted-foreground">{icon}</span> + )} + {children} + <HugeiconsIcon + icon={ArrowRightIcon} + className="ml-auto text-muted-foreground/80" + /> + </ContextMenuPrimitive.SubTrigger> + ), +); ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; const ContextMenuSubContent = React.forwardRef< @@ -76,13 +92,15 @@ ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; const ContextMenuContent = React.forwardRef< React.ElementRef<typeof ContextMenuPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> ->(({ className, ...props }, ref) => ( - <ContextMenuPrimitive.Portal> + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & { + container?: HTMLElement | null; + } +>(({ className, container, ...props }, ref) => ( + <ContextMenuPrimitive.Portal container={container ?? undefined}> <ContextMenuPrimitive.Content ref={ref} className={cn( - "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-2.5", + "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-1.5", className, )} {...props} @@ -99,25 +117,46 @@ const ContextMenuItem = React.forwardRef< icon?: React.ReactNode; textRight?: string; } ->(({ className, inset, variant = "default", icon, children, textRight, ...props }, ref) => ( - <ContextMenuPrimitive.Item - ref={ref} - className={cn( - contextMenuItemVariants({ variant }), - inset && "pl-8", +>( + ( + { className, - )} - {...props} - > - {icon && <span className="[&_svg]:size-4 [&_svg]:shrink-0">{icon}</span>} - {children} - {textRight && ( - <span className="ml-auto text-[0.60rem] tracking-widest text-muted-foreground/80 mb-0.5"> - {textRight} - </span> - )} - </ContextMenuPrimitive.Item> -)); + inset, + variant = "default", + icon, + children, + textRight, + ...props + }, + ref, + ) => { + const shouldInsetContent = inset || Boolean(icon); + + return ( + <ContextMenuPrimitive.Item + ref={ref} + className={cn( + contextMenuItemVariants({ variant }), + shouldInsetContent && "pl-8", + className, + )} + {...props} + > + {icon && ( + <span className="absolute left-3 flex size-3.5 items-center justify-center text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0"> + {icon} + </span> + )} + {children} + {textRight && ( + <span className="ml-auto text-[0.60rem] tracking-widest text-muted-foreground/80 mb-0.5"> + {textRight} + </span> + )} + </ContextMenuPrimitive.Item> + ); + }, +); ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; const ContextMenuCheckboxItem = React.forwardRef< @@ -126,22 +165,33 @@ const ContextMenuCheckboxItem = React.forwardRef< variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; icon?: React.ReactNode; } ->(({ className, children, checked, variant = "default", icon, ...props }, ref) => ( - <ContextMenuPrimitive.CheckboxItem - ref={ref} - className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)} - checked={checked} - {...props} - > - <span className="absolute left-2 flex size-3.5 items-center justify-center"> - <ContextMenuPrimitive.ItemIndicator> - <HugeiconsIcon icon={Tick02Icon} className="size-4" /> - </ContextMenuPrimitive.ItemIndicator> - </span> - {icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>} - {children} - </ContextMenuPrimitive.CheckboxItem> -)); +>( + ( + { className, children, checked, variant = "default", icon, ...props }, + ref, + ) => ( + <ContextMenuPrimitive.CheckboxItem + ref={ref} + className={cn( + contextMenuItemVariants({ variant }), + "pr-2 pl-8", + className, + )} + checked={checked} + {...props} + > + <span className="absolute left-3 flex size-3.5 items-center justify-center"> + <ContextMenuPrimitive.ItemIndicator> + <HugeiconsIcon icon={Tick02Icon} className="size-4" /> + </ContextMenuPrimitive.ItemIndicator> + </span> + {icon && ( + <span className="size-4 shrink-0 text-muted-foreground">{icon}</span> + )} + {children} + </ContextMenuPrimitive.CheckboxItem> + ), +); ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName; @@ -162,7 +212,9 @@ const ContextMenuRadioItem = React.forwardRef< <HugeiconsIcon icon={CircleIcon} className="size-2 fill-current" /> </ContextMenuPrimitive.ItemIndicator> </span> - {icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>} + {icon && ( + <span className="size-4 shrink-0 text-muted-foreground">{icon}</span> + )} {children} </ContextMenuPrimitive.RadioItem> )); @@ -184,7 +236,9 @@ const ContextMenuLabel = React.forwardRef< )} {...props} > - {icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>} + {icon && ( + <span className="size-4 shrink-0 text-muted-foreground">{icon}</span> + )} {children} </ContextMenuPrimitive.Label> )); @@ -208,7 +262,10 @@ const ContextMenuShortcut = ({ }: React.HTMLAttributes<HTMLSpanElement>) => { return ( <span - className={cn("ml-auto text-xs tracking-widest text-muted-foreground opacity-60", className)} + className={cn( + "ml-auto text-xs tracking-widest text-muted-foreground opacity-60", + className, + )} {...props} /> ); diff --git a/apps/web/src/components/ui/drawer.tsx b/apps/web/src/components/ui/drawer.tsx deleted file mode 100644 index d5752d62..00000000 --- a/apps/web/src/components/ui/drawer.tsx +++ /dev/null @@ -1,118 +0,0 @@ -"use client"; - -import * as React from "react"; -import { Drawer as DrawerPrimitive } from "vaul"; - -import { cn } from "@/utils/ui"; - -const Drawer = ({ - shouldScaleBackground = true, - ...props -}: React.ComponentProps<typeof DrawerPrimitive.Root>) => ( - <DrawerPrimitive.Root - shouldScaleBackground={shouldScaleBackground} - {...props} - /> -); -Drawer.displayName = "Drawer"; - -const DrawerTrigger = DrawerPrimitive.Trigger; - -const DrawerPortal = DrawerPrimitive.Portal; - -const DrawerClose = DrawerPrimitive.Close; - -const DrawerOverlay = React.forwardRef< - React.ElementRef<typeof DrawerPrimitive.Overlay>, - React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> ->(({ className, ...props }, ref) => ( - <DrawerPrimitive.Overlay - ref={ref} - className={cn("fixed inset-0 z-50 bg-black/80", className)} - {...props} - /> -)); -DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; - -const DrawerContent = React.forwardRef< - React.ElementRef<typeof DrawerPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> ->(({ className, children, ...props }, ref) => ( - <DrawerPortal> - <DrawerOverlay /> - <DrawerPrimitive.Content - ref={ref} - className={cn( - "bg-background fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border", - className, - )} - {...props} - > - <div className="bg-muted mx-auto mt-4 h-2 w-[100px] rounded-full" /> - {children} - </DrawerPrimitive.Content> - </DrawerPortal> -)); -DrawerContent.displayName = "DrawerContent"; - -const DrawerHeader = ({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} - {...props} - /> -); -DrawerHeader.displayName = "DrawerHeader"; - -const DrawerFooter = ({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn("mt-auto flex flex-col gap-2 p-4", className)} - {...props} - /> -); -DrawerFooter.displayName = "DrawerFooter"; - -const DrawerTitle = React.forwardRef< - React.ElementRef<typeof DrawerPrimitive.Title>, - React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title> ->(({ className, ...props }, ref) => ( - <DrawerPrimitive.Title - ref={ref} - className={cn( - "text-lg leading-none font-semibold tracking-tight", - className, - )} - {...props} - /> -)); -DrawerTitle.displayName = DrawerPrimitive.Title.displayName; - -const DrawerDescription = React.forwardRef< - React.ElementRef<typeof DrawerPrimitive.Description>, - React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description> ->(({ className, ...props }, ref) => ( - <DrawerPrimitive.Description - ref={ref} - className={cn("text-muted-foreground text-sm", className)} - {...props} - /> -)); -DrawerDescription.displayName = DrawerPrimitive.Description.displayName; - -export { - Drawer, - DrawerPortal, - DrawerOverlay, - DrawerTrigger, - DrawerClose, - DrawerContent, - DrawerHeader, - DrawerFooter, - DrawerTitle, - DrawerDescription, -}; diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index d8a9f594..2d6541b8 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -20,7 +20,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const dropdownMenuItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + "relative flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-sm text-foreground outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", { variants: { variant: { @@ -66,7 +66,7 @@ const DropdownMenuSubContent = React.forwardRef< <DropdownMenuPrimitive.SubContent ref={ref} className={cn( - "bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg", + "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg", className, )} {...props} @@ -88,7 +88,7 @@ const DropdownMenuContent = React.forwardRef< e.preventDefault(); }} className={cn( - "bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-2 shadow-lg", + "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-1.5 shadow-lg", className, )} {...props} @@ -101,19 +101,61 @@ const DropdownMenuItem = React.forwardRef< React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean; + icon?: React.ReactNode; variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; } ->(({ className, inset, variant = "default", ...props }, ref) => ( - <DropdownMenuPrimitive.Item - ref={ref} - className={cn( - dropdownMenuItemVariants({ variant }), - inset && "pl-8", +>( + ( + { className, - )} - {...props} - /> -)); + inset, + icon, + variant = "default", + children, + asChild, + ...props + }, + ref, + ) => { + const iconSlot = ( + <span className="hidden size-4 shrink-0 items-center justify-center group-has-[[data-has-icon]]/menu:flex"> + {icon} + </span> + ); + + const renderedChildren = + asChild && React.isValidElement(children) ? ( + React.cloneElement( + children as React.ReactElement<{ children?: React.ReactNode }>, + {}, + iconSlot, + (children as React.ReactElement<{ children?: React.ReactNode }>).props + .children, + ) + ) : ( + <> + {iconSlot} + {children} + </> + ); + + return ( + <DropdownMenuPrimitive.Item + ref={ref} + asChild={asChild} + data-has-icon={icon ? "" : undefined} + className={cn( + dropdownMenuItemVariants({ variant }), + inset && "pl-8", + className, + )} + {...props} + > + {renderedChildren} + </DropdownMenuPrimitive.Item> + ); + }, +); DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx index 4b8e364a..d5d56255 100644 --- a/apps/web/src/components/ui/font-picker.tsx +++ b/apps/web/src/components/ui/font-picker.tsx @@ -1,16 +1,51 @@ +"use client"; + +import { + useState, + useMemo, + useRef, + useEffect, + useCallback, + type CSSProperties, +} from "react"; +import { List, type RowComponentProps } from "react-window"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { FONT_OPTIONS, type FontFamily } from "@/constants/font-constants"; + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + getCachedFontAtlas, + loadFullFont, + prefetchFontAtlas, + clearFontAtlasCache, +} from "@/lib/fonts/google-fonts"; +import type { FontAtlas, FontAtlasEntry } from "@/types/fonts"; import { cn } from "@/utils/ui"; +import { ChevronDown, Search, Upload } from "lucide-react"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { TextFontIcon } from "@hugeicons/core-free-icons"; + +const FONT_TABS = [ + { key: "all", label: "All fonts" }, + { key: "favorites", label: "Favorites" }, + { key: "my-fonts", label: "My fonts" }, +] as const; + +type FontTab = (typeof FONT_TABS)[number]["key"]; + +const ROW_HEIGHT = 40; +const SPRITE_ROW_HEIGHT = 40; +const PREVIEW_SCALE = 0.8; +const LIST_WIDTH = 288; +const MAX_LIST_HEIGHT = 288; +const OVERSCAN = 15; interface FontPickerProps { - defaultValue?: FontFamily; - onValueChange?: (value: FontFamily) => void; + defaultValue?: string; + onValueChange?: (value: string) => void; className?: string; } @@ -19,24 +54,256 @@ export function FontPicker({ onValueChange, className, }: FontPickerProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [activeTab, setActiveTab] = useState<FontTab>("all"); + const [atlas, setAtlas] = useState<FontAtlas | null>(() => + getCachedFontAtlas(), + ); + const [status, setStatus] = useState<"idle" | "loading" | "error">(() => + getCachedFontAtlas() ? "idle" : "loading", + ); + const searchInputRef = useRef<HTMLInputElement>(null); + + const fontNames = useMemo(() => { + if (!atlas) return []; + return Object.keys(atlas.fonts).sort(); + }, [atlas]); + + const filteredFonts = useMemo(() => { + if (!search) return fontNames; + const query = search.toLowerCase(); + return fontNames.filter((name) => name.toLowerCase().includes(query)); + }, [fontNames, search]); + + const listHeight = Math.min( + MAX_LIST_HEIGHT, + filteredFonts.length * ROW_HEIGHT, + ); + + const handleSelect = useCallback( + async ({ family }: { family: string }) => { + try { + await loadFullFont({ family }); + onValueChange?.(family); + } catch { + onValueChange?.(family); + } + setOpen(false); + }, + [onValueChange], + ); + + // Load atlas on first open if cache is empty (fallback when prefetch hasn't completed) + useEffect(() => { + if (!open || atlas) return; + + setStatus("loading"); + prefetchFontAtlas().then((data) => { + if (data) { + setAtlas(data); + setStatus("idle"); + } else { + setStatus("error"); + } + }); + }, [open, atlas]); + + useEffect(() => { + if (!open) { + setSearch(""); + setActiveTab("all"); + } + }, [open]); + + const handleRetry = useCallback(() => { + clearFontAtlasCache(); + setStatus("loading"); + prefetchFontAtlas().then((data) => { + if (data) { + setAtlas(data); + setStatus("idle"); + } else { + setStatus("error"); + } + }); + }, []); + return ( - <Select defaultValue={defaultValue} onValueChange={onValueChange}> - <SelectTrigger - className={cn("w-full", className)} + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger + className={cn( + "border-border bg-accent flex h-7 w-full cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap focus-visible:border-primary focus-visible:ring-0 focus:outline-hidden", + className, + )} > - <SelectValue placeholder="Select a font" /> - </SelectTrigger> - <SelectContent> - {FONT_OPTIONS.map((font) => ( - <SelectItem - key={font.value} - value={font.value} - style={{ fontFamily: font.value }} + <div className="flex min-w-0 items-center gap-1.5"> + <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0"> + <HugeiconsIcon icon={TextFontIcon} /> + </span> + <span className="truncate" style={{ fontFamily: defaultValue }}> + {defaultValue ?? "Select a font"} + </span> + </div> + <ChevronDown className="size-3 shrink-0 opacity-50" /> + </PopoverTrigger> + <PopoverContent + className="w-72 p-0 overflow-hidden" + align="start" + side="left" + onOpenAutoFocus={(event) => { + event.preventDefault(); + searchInputRef.current?.focus(); + }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + > + <div className="relative px-3 py-1.5"> + <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 shrink-0 opacity-50" /> + <Input + ref={searchInputRef} + placeholder="Search fonts..." + value={search} + onChange={(event) => setSearch(event.target.value)} + size="xs" + className="w-full pl-5 bg-transparent !border-none !shadow-none" + /> + </div> + <div className="flex border-b px-3"> + {FONT_TABS.map((tab) => ( + <button + key={tab.key} + type="button" + className={cn( + "px-3 py-1.5 text-xs border-b-2 -mb-px", + activeTab === tab.key + ? "border-foreground text-foreground" + : "border-transparent text-muted-foreground hover:text-foreground", + )} + onClick={() => setActiveTab(tab.key)} + > + {tab.label} + </button> + ))} + </div> + {status === "loading" && ( + <div className="py-8 text-center text-sm text-muted-foreground"> + Loading fonts... + </div> + )} + {status === "error" && ( + <div className="flex flex-col items-center gap-3 py-8 px-4"> + <p className="text-sm text-muted-foreground text-center"> + Failed to load font previews. + </p> + <Button variant="outline" size="sm" onClick={handleRetry}> + Retry + </Button> + </div> + )} + {status === "idle" && + fontNames.length > 0 && + filteredFonts.length === 0 && ( + <div className="py-6 text-center text-sm text-muted-foreground"> + No fonts found. + </div> + )} + {status === "idle" && atlas && filteredFonts.length > 0 && ( + <List + rowCount={filteredFonts.length} + rowHeight={ROW_HEIGHT} + overscanCount={OVERSCAN} + rowComponent={FontRow} + rowProps={{ + atlas, + filteredFonts, + selectedFont: defaultValue, + onFontSelect: handleSelect, + }} + style={{ height: listHeight, width: LIST_WIDTH }} + /> + )} + <div className="border-t p-1"> + <Button + variant="ghost" + size="sm" + className="w-full justify-start text-muted-foreground h-8 font-normal" + onClick={() => { + // TODO: Implement local font loading + console.log("Load local fonts clicked"); + }} > - {font.label} - </SelectItem> - ))} - </SelectContent> - </Select> + <Upload className="!size-3.5" /> + Load local fonts + </Button> + </div> + </PopoverContent> + </Popover> + ); +} + +function FontSpritePreview({ entry }: { entry: FontAtlasEntry }) { + return ( + <div + className="shrink-0" + style={{ + width: entry.w, + height: SPRITE_ROW_HEIGHT, + backgroundColor: "currentColor", + WebkitMaskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`, + WebkitMaskPosition: `-${entry.x}px -${entry.y}px`, + WebkitMaskRepeat: "no-repeat", + maskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`, + maskPosition: `-${entry.x}px -${entry.y}px`, + maskRepeat: "no-repeat", + transform: `scale(${PREVIEW_SCALE})`, + transformOrigin: "left center", + }} + /> + ); +} + +type FontRowProps = { + atlas: FontAtlas; + filteredFonts: string[]; + selectedFont: string | undefined; + onFontSelect: (params: { family: string }) => void; +}; + +function FontRow({ + index, + style, + atlas, + filteredFonts, + selectedFont, + onFontSelect, +}: RowComponentProps<FontRowProps>) { + const fontName = filteredFonts[index]; + const entry = atlas.fonts[fontName]; + const isSelected = fontName === selectedFont; + + return ( + <button + type="button" + style={style as CSSProperties} + className={cn( + "flex w-full cursor-pointer items-center gap-2 px-3 outline-hidden hover:bg-popover-hover", + isSelected && "bg-popover-hover", + )} + onClick={() => onFontSelect({ family: fontName })} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onFontSelect({ family: fontName }); + } + }} + aria-label={fontName} + > + <div className="min-w-0 overflow-hidden"> + <FontSpritePreview entry={entry} /> + </div> + </button> ); } diff --git a/apps/web/src/components/ui/input-otp.tsx b/apps/web/src/components/ui/input-otp.tsx deleted file mode 100644 index ec71f96a..00000000 --- a/apps/web/src/components/ui/input-otp.tsx +++ /dev/null @@ -1,75 +0,0 @@ -"use client"; - -import * as React from "react"; -import { OTPInput, OTPInputContext } from "input-otp"; -import { Minus } from "lucide-react"; - -import { cn } from "@/utils/ui"; - -const InputOTP = React.forwardRef< - React.ElementRef<typeof OTPInput>, - React.ComponentPropsWithoutRef<typeof OTPInput> ->(({ className, containerClassName, ...props }, ref) => ( - <OTPInput - ref={ref} - containerClassName={cn( - "flex items-center justify-between gap-2 w-full has-disabled:opacity-50", - containerClassName, - )} - className={cn("disabled:cursor-not-allowed", className)} - {...props} - /> -)); -InputOTP.displayName = "InputOTP"; - -const InputOTPGroup = React.forwardRef< - React.ElementRef<"div">, - React.ComponentPropsWithoutRef<"div"> ->(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("flex flex-1 items-center gap-2", className)} - {...props} - /> -)); -InputOTPGroup.displayName = "InputOTPGroup"; - -const InputOTPSlot = React.forwardRef< - React.ElementRef<"div">, - React.ComponentPropsWithoutRef<"div"> & { index: number } ->(({ index, className, ...props }, ref) => { - const inputOTPContext = React.useContext(OTPInputContext); - const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; - - return ( - <div - ref={ref} - className={cn( - "border-input relative flex aspect-square min-w-[36px] flex-1 items-center justify-center border text-lg shadow-xs first:rounded-l-md first:border-l last:rounded-r-md", - isActive && "ring-ring z-10 ring-1", - className, - )} - {...props} - > - {char} - {hasFakeCaret && ( - <div className="pointer-events-none absolute inset-0 flex items-center justify-center"> - <div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" /> - </div> - )} - </div> - ); -}); -InputOTPSlot.displayName = "InputOTPSlot"; - -const InputOTPSeparator = React.forwardRef< - React.ElementRef<"div">, - React.ComponentPropsWithoutRef<"div"> ->(({ ...props }, ref) => ( - <div ref={ref} role="separator" {...props}> - <Minus /> - </div> -)); -InputOTPSeparator.displayName = "InputOTPSeparator"; - -export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx index f8265779..1c22fd2e 100644 --- a/apps/web/src/components/ui/input-with-back.tsx +++ b/apps/web/src/components/ui/input-with-back.tsx @@ -1,88 +1,85 @@ -"use client";
-
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { ArrowLeft, Search } from "lucide-react";
-import { motion } from "motion/react";
-import { useState, useEffect } from "react";
-
-interface InputWithBackProps {
- isExpanded: boolean;
- setIsExpanded: (isExpanded: boolean) => void;
- placeholder?: string;
- value?: string;
- onChange?: (value: string) => void;
- disableAnimation?: boolean;
-}
-
-export function InputWithBack({
- isExpanded,
- setIsExpanded,
- placeholder = "Search anything",
- value,
- onChange,
- disableAnimation = false,
-}: InputWithBackProps) {
- const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
- const [buttonOffset, setButtonOffset] = useState(-60);
-
- const smoothTransition = {
- duration: disableAnimation ? 0 : 0.35,
- ease: [0.25, 0.1, 0.25, 1] as const,
- };
-
- useEffect(() => {
- if (containerRef) {
- const rect = containerRef.getBoundingClientRect();
- setButtonOffset(-rect.left - 48);
- }
- }, [containerRef]);
-
- return (
- <div ref={setContainerRef} className="relative w-full">
- <motion.div
- className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75"
- initial={{
- x: isExpanded ? 0 : buttonOffset,
- opacity: isExpanded ? 1 : 0.5,
- }}
- animate={{
- x: isExpanded ? 0 : buttonOffset,
- opacity: isExpanded ? 1 : 0.5,
- }}
- transition={smoothTransition}
- onClick={() => setIsExpanded(!isExpanded)}
- >
- <Button
- variant="outline"
- className="bg-accent !size-9 rounded-full"
- >
- <ArrowLeft />
- </Button>
- </motion.div>
- <div
- className="relative flex-1"
- style={{ marginLeft: "0px", paddingLeft: "0px" }}
- >
- <motion.div
- className="relative"
- initial={{
- marginLeft: isExpanded ? 50 : 0,
- }}
- animate={{
- marginLeft: isExpanded ? 50 : 0,
- }}
- transition={smoothTransition}
- >
- <Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
- <Input
- placeholder={placeholder}
- className="bg-accent w-full pl-9"
- value={value}
- onChange={(e) => onChange?.(e.target.value)}
- />
- </motion.div>
- </div>
- </div>
- );
-}
+"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ArrowLeft, Search } from "lucide-react"; +import { motion } from "motion/react"; +import { useState, useEffect } from "react"; + +interface InputWithBackProps { + isExpanded: boolean; + setIsExpanded: (isExpanded: boolean) => void; + placeholder?: string; + value?: string; + onChange?: (value: string) => void; + disableAnimation?: boolean; +} + +export function InputWithBack({ + isExpanded, + setIsExpanded, + placeholder = "Search anything", + value, + onChange, + disableAnimation = false, +}: InputWithBackProps) { + const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null); + const [buttonOffset, setButtonOffset] = useState(-60); + + const smoothTransition = { + duration: disableAnimation ? 0 : 0.35, + ease: [0.25, 0.1, 0.25, 1] as const, + }; + + useEffect(() => { + if (containerRef) { + const rect = containerRef.getBoundingClientRect(); + setButtonOffset(-rect.left - 48); + } + }, [containerRef]); + + return ( + <div ref={setContainerRef} className="relative w-full"> + <motion.div + className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75" + initial={{ + x: isExpanded ? 0 : buttonOffset, + opacity: isExpanded ? 1 : 0.5, + }} + animate={{ + x: isExpanded ? 0 : buttonOffset, + opacity: isExpanded ? 1 : 0.5, + }} + transition={smoothTransition} + onClick={() => setIsExpanded(!isExpanded)} + > + <Button variant="outline" className="bg-accent !size-9 rounded-full"> + <ArrowLeft /> + </Button> + </motion.div> + <div + className="relative flex-1" + style={{ marginLeft: "0px", paddingLeft: "0px" }} + > + <motion.div + className="relative" + initial={{ + marginLeft: isExpanded ? 50 : 0, + }} + animate={{ + marginLeft: isExpanded ? 50 : 0, + }} + transition={smoothTransition} + > + <Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" /> + <Input + placeholder={placeholder} + className="bg-accent w-full pl-9" + value={value} + onChange={(e) => onChange?.(e.target.value)} + /> + </motion.div> + </div> + </div> + ); +} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 9be4bca2..762cc675 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -8,17 +8,18 @@ import { forwardRef, type ComponentProps } from "react"; import { useState } from "react"; const inputVariants = cva( - "file:text-foreground placeholder:text-muted-foreground border-border bg-background flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-4 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { - default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary focus-visible:ring-primary/10", - destructive: "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10", + default: "selection:bg-primary selection:text-primary-foreground", + destructive: + "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10", }, size: { - default: - "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm", - sm: "h-8 px-3 text-xs file:h-6 file:text-xs", + default: "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm", + xs: "h-7 px-3 text-xs file:h-6 file:text-xs", + sm: "h-8 px-3 text-sm file:h-6 file:text-xs", lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm", }, }, @@ -102,7 +103,6 @@ const Input = forwardRef<HTMLInputElement, InputProps>( /> {showClear && ( <Button - type="button" variant="text" size="icon" onMouseDown={(e) => { @@ -117,7 +117,6 @@ const Input = forwardRef<HTMLInputElement, InputProps>( )} {showPasswordToggle && ( <Button - type="button" variant="text" size="icon" onClick={() => onShowPasswordChange?.(!showPassword)} diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx new file mode 100644 index 00000000..35cce81b --- /dev/null +++ b/apps/web/src/components/ui/number-field.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { cn } from "@/utils/ui"; +import { useRef, useState, type ComponentProps } from "react"; +import { useFocusLock } from "@/hooks/use-focus-lock"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowTurnBackwardIcon } from "@hugeicons/core-free-icons"; + +const DRAG_SENSITIVITIES = { + default: 1, + slow: 0.5, +} as const; + +type DragSensitivity = "default" | "slow"; + +interface NumberFieldProps + extends Omit<ComponentProps<"input">, "size" | "type"> { + icon?: React.ReactNode; + dragSensitivity?: DragSensitivity; + onScrub?: (value: number) => void; + onScrubEnd?: () => void; + allowExpressions?: boolean; + onReset?: () => void; + isDefault?: boolean; +} + +function NumberField({ + className, + icon, + disabled, + dragSensitivity = "default", + onScrub, + onScrubEnd, + value, + allowExpressions = true, + onKeyDown, + onFocus, + onBlur, + onMouseDown, + onReset, + isDefault = false, + ref, + ...props +}: NumberFieldProps & { ref?: React.Ref<HTMLInputElement> }) { + const iconRef = useRef<HTMLSpanElement>(null); + const inputRef = useRef<HTMLInputElement>(null); + const startValueRef = useRef(0); + const cumulativeDeltaRef = useRef(0); + const [isInputFocused, setIsInputFocused] = useState(false); + + const { containerRef: wrapperRef } = useFocusLock<HTMLDivElement>({ + isActive: isInputFocused, + onDismiss: () => inputRef.current?.blur(), + cursor: "text", + allowSelector: "input, textarea, [contenteditable]", + }); + + const handleIconPointerDown = (event: React.PointerEvent) => { + if (!onScrub || disabled || event.button !== 0) return; + const parsed = parseFloat(String(value ?? "0")); + startValueRef.current = Number.isNaN(parsed) ? 0 : parsed; + cumulativeDeltaRef.current = 0; + let hasReceivedFirstMove = false; + iconRef.current?.requestPointerLock(); + + const handlePointerMove = (moveEvent: PointerEvent) => { + // first movementX after pointer lock often contains a bogus warp delta + if (!hasReceivedFirstMove) { + hasReceivedFirstMove = true; + return; + } + cumulativeDeltaRef.current += moveEvent.movementX; + const newValue = + startValueRef.current + + cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity]; + onScrub(newValue); + }; + + const handlePointerUp = () => { + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerUp); + document.exitPointerLock(); + onScrubEnd?.(); + }; + + document.addEventListener("pointermove", handlePointerMove); + document.addEventListener("pointerup", handlePointerUp); + }; + + const canScrub = Boolean(icon && onScrub); + + return ( + <div + ref={wrapperRef} + className={cn( + "border-border bg-accent flex h-7 w-full min-w-0 items-center rounded-md border text-sm outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-within:border-primary focus-within:ring-0 focus-within:ring-primary/10 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40", + disabled && "pointer-events-none cursor-not-allowed opacity-50", + className, + )} + > + {icon && ( + <span + ref={iconRef} + className={cn( + "text-muted-foreground [&_svg]:!size-3.5 shrink-0 select-none pl-2.5 text-sm leading-none", + canScrub && "cursor-ew-resize", + )} + onPointerDown={canScrub ? handleIconPointerDown : undefined} + > + {icon} + </span> + )} + <input + type={allowExpressions ? "text" : "number"} + inputMode={allowExpressions ? "decimal" : undefined} + ref={inputRef} + disabled={disabled} + value={value} + className={cn( + "min-w-0 flex-1 text-sm leading-none bg-transparent outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", + icon ? "px-1.5" : "pl-2.5", + onReset ? "pr-0" : "pr-2.5", + )} + onMouseDown={(event) => { + const inputElement = event.currentTarget; + const shouldPreventNativeCaretPlacement = + event.button === 0 && document.activeElement !== inputElement; + if (shouldPreventNativeCaretPlacement) { + event.preventDefault(); + inputElement.focus(); + inputElement.select(); + } + onMouseDown?.(event); + }} + onFocus={(event) => { + setIsInputFocused(true); + event.currentTarget.select(); + onFocus?.(event); + }} + onKeyDown={(event) => { + const shouldBlurInput = + event.key === "Enter" || event.key === "Escape"; + if (shouldBlurInput) event.currentTarget.blur(); + onKeyDown?.(event); + }} + onBlur={(event) => { + setIsInputFocused(false); + onBlur?.(event); + }} + {...props} + /> + {onReset && !isDefault && ( + <div className="shrink-0 pr-2 flex items-center"> + <Button + variant="text" + size="text" + aria-label="Reset to default" + onClick={onReset} + > + <HugeiconsIcon icon={ArrowTurnBackwardIcon} className="!size-3.5" /> + </Button> + </div> + )} + </div> + ); +} + +export { NumberField }; diff --git a/apps/web/src/components/ui/pagination.tsx b/apps/web/src/components/ui/pagination.tsx deleted file mode 100644 index 96eec969..00000000 --- a/apps/web/src/components/ui/pagination.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import * as React from "react"; -import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"; - -import { cn } from "@/utils/ui"; -import { type ButtonProps, buttonVariants } from "./button"; - -const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( - <nav - role="navigation" - aria-label="pagination" - className={cn("mx-auto flex w-full justify-center", className)} - {...props} - /> -); -Pagination.displayName = "Pagination"; - -const PaginationContent = React.forwardRef< - HTMLUListElement, - React.ComponentProps<"ul"> ->(({ className, ...props }, ref) => ( - <ul - ref={ref} - className={cn("flex flex-row items-center gap-1", className)} - {...props} - /> -)); -PaginationContent.displayName = "PaginationContent"; - -const PaginationItem = React.forwardRef< - HTMLLIElement, - React.ComponentProps<"li"> ->(({ className, ...props }, ref) => ( - <li ref={ref} className={cn("", className)} {...props} /> -)); -PaginationItem.displayName = "PaginationItem"; - -type PaginationLinkProps = { - isActive?: boolean; -} & Pick<ButtonProps, "size"> & - React.ComponentProps<"a">; - -const PaginationLink = ({ - className, - isActive, - size = "icon", - ...props -}: PaginationLinkProps) => ( - <a - aria-current={isActive ? "page" : undefined} - className={cn( - buttonVariants({ - variant: isActive ? "outline" : "text", - size, - }), - className, - )} - {...props} - /> -); -PaginationLink.displayName = "PaginationLink"; - -const PaginationPrevious = ({ - className, - ...props -}: React.ComponentProps<typeof PaginationLink>) => ( - <PaginationLink - aria-label="Go to previous page" - size="default" - className={cn("gap-1 pl-2.5", className)} - {...props} - > - <ChevronLeft className="size-4" /> - <span>Previous</span> - </PaginationLink> -); -PaginationPrevious.displayName = "PaginationPrevious"; - -const PaginationNext = ({ - className, - ...props -}: React.ComponentProps<typeof PaginationLink>) => ( - <PaginationLink - aria-label="Go to next page" - size="default" - className={cn("gap-1 pr-2.5", className)} - {...props} - > - <span>Next</span> - <ChevronRight className="size-4" /> - </PaginationLink> -); -PaginationNext.displayName = "PaginationNext"; - -const PaginationEllipsis = ({ - className, - ...props -}: React.ComponentProps<"span">) => ( - <span - aria-hidden - className={cn("flex size-9 items-center justify-center", className)} - {...props} - > - <MoreHorizontal className="size-4" /> - <span className="sr-only">More pages</span> - </span> -); -PaginationEllipsis.displayName = "PaginationEllipsis"; - -export { - Pagination, - PaginationContent, - PaginationLink, - PaginationItem, - PaginationPrevious, - PaginationNext, - PaginationEllipsis, -}; diff --git a/apps/web/src/components/ui/phone-input.tsx b/apps/web/src/components/ui/phone-input.tsx deleted file mode 100644 index 48b002ca..00000000 --- a/apps/web/src/components/ui/phone-input.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import * as React from "react"; -import { CheckIcon, ChevronsUpDown } from "lucide-react"; -import * as RPNInput from "react-phone-number-input"; -import flags from "react-phone-number-input/flags"; - -import { Button } from "./button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "./command"; -import { Input } from "./input"; -import { Popover, PopoverContent, PopoverTrigger } from "./popover"; -import { ScrollArea } from "./scroll-area"; -import { cn } from "@/utils/ui"; - -type PhoneInputProps = Omit< - React.ComponentProps<"input">, - "onChange" | "value" | "ref" -> & - Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & { - onChange?: (value: RPNInput.Value) => void; - }; - -const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> = - React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>( - ({ className, onChange, defaultCountry = "US", ...props }, ref) => { - return ( - <RPNInput.default - ref={ref} - className={cn("flex", className)} - flagComponent={FlagComponent} - countrySelectComponent={CountrySelect} - inputComponent={InputComponent} - defaultCountry={defaultCountry} - international - addInternationalOption - smartCaret={false} - /** - * Handles the onChange event. - * - * react-phone-number-input might trigger the onChange event as undefined - * when a valid phone number is not entered. To prevent this, - * the value is coerced to an empty string. - * - * @param {E164Number | undefined} value - The entered value - */ - onChange={(value) => onChange?.(value || ("" as RPNInput.Value))} - {...props} - /> - ); - }, - ); -PhoneInput.displayName = "PhoneInput"; - -const InputComponent = React.forwardRef< - HTMLInputElement, - React.ComponentProps<"input"> ->(({ className, size: _size, ...props }, ref) => ( - <Input - className={cn("rounded-s-none rounded-e-lg", className)} - {...props} - ref={ref} - /> -)); -InputComponent.displayName = "InputComponent"; - -type CountryEntry = { label: string; value: RPNInput.Country | undefined }; - -type CountrySelectProps = { - disabled?: boolean; - value: RPNInput.Country; - options: CountryEntry[]; - onChange: (country: RPNInput.Country) => void; -}; - -const CountrySelect = ({ - disabled, - value: selectedCountry, - options: countryList, - onChange, -}: CountrySelectProps) => { - return ( - <Popover> - <PopoverTrigger asChild> - <Button - type="button" - variant="outline" - className="flex gap-1 rounded-s-lg rounded-e-none border-r-0 px-3 focus:z-10" - disabled={disabled} - > - <FlagComponent - country={selectedCountry} - countryName={selectedCountry} - /> - <ChevronsUpDown - className={cn( - "-mr-2 opacity-50", - disabled ? "hidden" : "opacity-100", - )} - /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-[300px] p-0"> - <Command> - <CommandInput placeholder="Search country..." /> - <CommandList> - <ScrollArea className="h-72"> - <CommandEmpty>No country found.</CommandEmpty> - <CommandGroup> - {countryList.map(({ value, label }) => - value ? ( - <CountrySelectOption - key={value} - country={value} - countryName={label} - selectedCountry={selectedCountry} - onChange={onChange} - /> - ) : null, - )} - </CommandGroup> - </ScrollArea> - </CommandList> - </Command> - </PopoverContent> - </Popover> - ); -}; - -interface CountrySelectOptionProps extends RPNInput.FlagProps { - selectedCountry: RPNInput.Country; - onChange: (country: RPNInput.Country) => void; -} - -const CountrySelectOption = ({ - country, - countryName, - selectedCountry, - onChange, -}: CountrySelectOptionProps) => { - return ( - <CommandItem className="gap-2" onSelect={() => onChange(country)}> - <FlagComponent country={country} countryName={countryName} /> - <span className="flex-1 text-sm">{countryName}</span> - <span className="text-foreground/50 text-sm">{`+${RPNInput.getCountryCallingCode(country)}`}</span> - <CheckIcon - className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`} - /> - </CommandItem> - ); -}; - -const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => { - const Flag = flags[country]; - - return ( - <span className="bg-foreground/20 flex h-4 w-6 overflow-hidden rounded-sm [&_svg]:size-full"> - {Flag && <Flag title={countryName} />} - </span> - ); -}; - -export { PhoneInput }; diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index 36ae5a69..8fd511a3 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -10,6 +10,8 @@ const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverAnchor = PopoverPrimitive.Anchor; +const PopoverClose = PopoverPrimitive.Close; + const PopoverContent = React.forwardRef< React.ElementRef<typeof PopoverPrimitive.Content>, React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> @@ -20,7 +22,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden", + "bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden", className, )} {...props} @@ -29,4 +31,4 @@ const PopoverContent = React.forwardRef< )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose }; diff --git a/apps/web/src/components/ui/prose.tsx b/apps/web/src/components/ui/prose.tsx index 4e526b7e..88d017fe 100644 --- a/apps/web/src/components/ui/prose.tsx +++ b/apps/web/src/components/ui/prose.tsx @@ -1,4 +1,7 @@ import { cn } from "@/utils/ui"; +import rehypeParse from "rehype-parse"; +import { unified } from "unified"; +import { createElement } from "react"; import type React from "react"; type ProseProps = React.HTMLAttributes<HTMLElement> & { @@ -6,6 +9,92 @@ type ProseProps = React.HTMLAttributes<HTMLElement> & { html: string; }; +type HastTextNode = { + type: "text"; + value: string; +}; + +type HastElementNode = { + type: "element"; + tagName: string; + properties?: Record<string, unknown>; + children?: HastNode[]; +}; + +type HastRootNode = { + type: "root"; + children?: HastNode[]; +}; + +type HastNode = HastTextNode | HastElementNode | HastRootNode; + +function toReactProps({ + properties, +}: { + properties: HastElementNode["properties"]; +}): Record<string, unknown> { + if (!properties) { + return {}; + } + + const props: Record<string, unknown> = {}; + + for (const [propertyName, propertyValue] of Object.entries(properties)) { + if (propertyValue === null || propertyValue === undefined) { + continue; + } + + if (propertyName.startsWith("on")) { + continue; + } + + if (propertyName === "class") { + props.className = Array.isArray(propertyValue) + ? propertyValue.join(" ") + : propertyValue; + continue; + } + + if (propertyName === "for") { + props.htmlFor = propertyValue; + continue; + } + + props[propertyName] = propertyValue; + } + + return props; +} + +function renderHastNode({ + node, + key, +}: { + node: HastNode; + key: string; +}): React.ReactNode { + if (node.type === "text") { + return node.value; + } + + if (node.type !== "element") { + return null; + } + + const children = (node.children ?? []).map((childNode, index) => + renderHastNode({ node: childNode, key: `${key}-${index}` }), + ); + + return createElement(node.tagName, { ...toReactProps({ properties: node.properties }), key }, children); +} + +function renderHtmlNodes({ html }: { html: string }): React.ReactNode { + const rootNode = unified().use(rehypeParse, { fragment: true }).parse(html) as HastRootNode; + return (rootNode.children ?? []).map((childNode, index) => + renderHastNode({ node: childNode, key: `prose-node-${index}` }), + ); +} + function Prose({ children, html, className }: ProseProps) { return ( <article @@ -14,7 +103,7 @@ function Prose({ children, html, className }: ProseProps) { className, )} > - {html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : children} + {html ? renderHtmlNodes({ html }) : children} </article> ); } diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index fd9c7ac5..311f6edb 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -2,10 +2,11 @@ import * as React from "react"; import { Select as SelectPrimitive } from "radix-ui"; -import { Check, ChevronDown, ChevronUp } from "lucide-react"; +import { Check } from "lucide-react"; +import { ArrowUpIcon, ArrowDownIcon } from "@hugeicons/core-free-icons"; import { cva, type VariantProps } from "class-variance-authority"; - import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; const Select = SelectPrimitive.Root; @@ -14,36 +15,63 @@ const SelectGroup = SelectPrimitive.Group; const SelectValue = SelectPrimitive.Value; const selectItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2 rounded-xl px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + "relative flex cursor-pointer select-none items-center gap-1.5 rounded-sm px-2 py-1 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "", + destructive: + "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const selectTriggerVariants = cva( + "border-border ring-offset-background placeholder:text-muted-foreground flex h-7 w-auto cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap transition-none focus:border-primary focus:ring-0 focus:ring-primary/10 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", { variants: { variant: { + default: "bg-accent", + outline: "bg-background hover:bg-accent/50", + }, + size: { default: "", - destructive: "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive", + sm: "", }, }, defaultVariants: { variant: "default", + size: "default", }, }, ); const SelectTrigger = React.forwardRef< React.ElementRef<typeof SelectPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & + VariantProps<typeof selectTriggerVariants> & { + icon?: React.ReactNode; + } +>(({ className, children, icon, variant, size, ...props }, ref) => ( <SelectPrimitive.Trigger ref={ref} - className={cn( - "bg-accent ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-auto cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-2 text-sm whitespace-nowrap focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", - "focus:border-primary focus:ring-4 focus:ring-primary/10 border-transparent transition-none", - className, - )} + className={cn(selectTriggerVariants({ variant, size }), className)} {...props} > - {children} + <div className="flex items-center gap-1.5"> + {icon && ( + <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0"> + {icon} + </span> + )} + {children} + </div> <SelectPrimitive.Icon asChild> - <ChevronDown className="size-3 opacity-50" /> + <HugeiconsIcon icon={ArrowDownIcon} className="size-4" /> </SelectPrimitive.Icon> </SelectPrimitive.Trigger> )); @@ -61,7 +89,7 @@ const SelectScrollUpButton = React.forwardRef< )} {...props} > - <ChevronUp className="size-4" /> + <HugeiconsIcon icon={ArrowUpIcon} className="size-4" /> </SelectPrimitive.ScrollUpButton> )); SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; @@ -78,7 +106,7 @@ const SelectScrollDownButton = React.forwardRef< )} {...props} > - <ChevronDown className="size-4" /> + <HugeiconsIcon icon={ArrowDownIcon} className="size-4" /> </SelectPrimitive.ScrollDownButton> )); SelectScrollDownButton.displayName = @@ -92,7 +120,7 @@ const SelectContent = React.forwardRef< <SelectPrimitive.Content ref={ref} className={cn( - "bg-popover text-popover-foreground z-50 max-h-96 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg", + "bg-popover text-popover-foreground z-50 max-h-(--radix-select-content-available-height) min-w-32 overflow-hidden rounded-md border p-1 shadow-lg", className, )} position={position} @@ -124,7 +152,7 @@ const SelectLabel = React.forwardRef< <SelectPrimitive.Label ref={ref} className={cn( - "px-3 pb-2 pt-1 text-[11px] font-bold uppercase tracking-wider text-muted-foreground", + "px-2 pb-1 pt-0.5 text-[11px] font-bold uppercase tracking-wider text-muted-foreground", className, )} {...props} @@ -140,12 +168,12 @@ const SelectItem = React.forwardRef< >(({ className, children, variant = "default", ...props }, ref) => ( <SelectPrimitive.Item ref={ref} - className={cn(selectItemVariants({ variant }), "pr-8 pl-2", className)} + className={cn(selectItemVariants({ variant }), "pl-6 pr-2", className)} {...props} > - <span className="absolute right-2 flex size-3.5 items-center justify-center"> + <span className="absolute left-1.5 flex size-3.5 items-center justify-center"> <SelectPrimitive.ItemIndicator> - <Check className="size-4" /> + <Check className="size-3.5" /> </SelectPrimitive.ItemIndicator> </span> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> @@ -159,7 +187,7 @@ const SelectSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( <SelectPrimitive.Separator ref={ref} - className={cn("bg-border mx-1 my-2 h-px", className)} + className={cn("bg-border mx-1 my-1 h-px", className)} {...props} /> )); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx deleted file mode 100644 index a41e25c7..00000000 --- a/apps/web/src/components/ui/sidebar.tsx +++ /dev/null @@ -1,725 +0,0 @@ -"use client"; - -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; -import { PanelLeftIcon } from "lucide-react"; -import * as React from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useIsMobile } from "@/hooks/use-mobile"; -import { cn } from "@/utils/ui"; - -const SIDEBAR_COOKIE_NAME = "sidebar_state"; -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; -const SIDEBAR_WIDTH = "16rem"; -const SIDEBAR_WIDTH_MOBILE = "18rem"; -const SIDEBAR_WIDTH_ICON = "3rem"; -const SIDEBAR_KEYBOARD_SHORTCUT = "b"; - -type SidebarContextProps = { - state: "expanded" | "collapsed"; - open: boolean; - setOpen: (open: boolean) => void; - openMobile: boolean; - setOpenMobile: (open: boolean) => void; - isMobile: boolean; - toggleSidebar: () => void; -}; - -const SidebarContext = React.createContext<SidebarContextProps | null>(null); - -function useSidebar() { - const context = React.useContext(SidebarContext); - if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider."); - } - - return context; -} - -function SidebarProvider({ - defaultOpen = true, - open: openProp, - onOpenChange: setOpenProp, - className, - style, - children, - ...props -}: React.ComponentProps<"div"> & { - defaultOpen?: boolean; - open?: boolean; - onOpenChange?: (open: boolean) => void; -}) { - const isMobile = useIsMobile(); - const [openMobile, setOpenMobile] = React.useState(false); - - // This is the internal state of the sidebar. - // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(defaultOpen); - const open = openProp ?? _open; - const setOpen = React.useCallback( - (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === "function" ? value(open) : value; - if (setOpenProp) { - setOpenProp(openState); - } else { - _setOpen(openState); - } - - // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; - }, - [setOpenProp, open], - ); - - // Helper to toggle the sidebar. - const toggleSidebar = React.useCallback(() => { - return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); - }, [isMobile, setOpen, setOpenMobile]); - - // Adds a keyboard shortcut to toggle the sidebar. - React.useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.key === SIDEBAR_KEYBOARD_SHORTCUT && - (event.metaKey || event.ctrlKey) - ) { - event.preventDefault(); - toggleSidebar(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [toggleSidebar]); - - // We add a state so that we can do data-state="expanded" or "collapsed". - // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed"; - - const contextValue = React.useMemo<SidebarContextProps>( - () => ({ - state, - open, - setOpen, - isMobile, - openMobile, - setOpenMobile, - toggleSidebar, - }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], - ); - - return ( - <SidebarContext.Provider value={contextValue}> - <TooltipProvider delayDuration={0}> - <div - data-slot="sidebar-wrapper" - style={ - { - "--sidebar-width": SIDEBAR_WIDTH, - "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, - ...style, - } as React.CSSProperties - } - className={cn( - "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", - className, - )} - {...props} - > - {children} - </div> - </TooltipProvider> - </SidebarContext.Provider> - ); -} - -function Sidebar({ - side = "left", - variant = "sidebar", - collapsible = "offcanvas", - className, - children, - ...props -}: React.ComponentProps<"div"> & { - side?: "left" | "right"; - variant?: "sidebar" | "floating" | "inset"; - collapsible?: "offcanvas" | "icon" | "none"; -}) { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); - - if (collapsible === "none") { - return ( - <div - data-slot="sidebar" - className={cn( - "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", - className, - )} - {...props} - > - {children} - </div> - ); - } - - if (isMobile) { - return ( - <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> - <SheetContent - data-sidebar="sidebar" - data-slot="sidebar" - data-mobile="true" - className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" - style={ - { - "--sidebar-width": SIDEBAR_WIDTH_MOBILE, - } as React.CSSProperties - } - side={side} - > - <SheetHeader className="sr-only"> - <SheetTitle>Sidebar</SheetTitle> - <SheetDescription>Displays the mobile sidebar.</SheetDescription> - </SheetHeader> - <div className="flex size-full flex-col">{children}</div> - </SheetContent> - </Sheet> - ); - } - - return ( - <div - className="group peer text-sidebar-foreground hidden md:block" - data-state={state} - data-collapsible={state === "collapsed" ? collapsible : ""} - data-variant={variant} - data-side={side} - data-slot="sidebar" - > - {/* This is what handles the sidebar gap on desktop */} - <div - data-slot="sidebar-gap" - className={cn( - "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear", - "group-data-[collapsible=offcanvas]:w-0", - "group-data-[side=right]:rotate-180", - variant === "floating" || variant === "inset" - ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" - : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)", - )} - /> - <div - data-slot="sidebar-container" - className={cn( - "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex", - side === "left" - ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" - : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", - // Adjust the padding for floating and inset variants. - variant === "floating" || variant === "inset" - ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" - : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", - className, - )} - {...props} - > - <div - data-sidebar="sidebar" - data-slot="sidebar-inner" - className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex size-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm" - > - {children} - </div> - </div> - </div> - ); -} - -function SidebarTrigger({ - className, - onClick, - ...props -}: React.ComponentProps<typeof Button>) { - const { toggleSidebar } = useSidebar(); - - return ( - <Button - data-sidebar="trigger" - data-slot="sidebar-trigger" - variant="text" - size="icon" - className={cn("size-7", className)} - onClick={(event) => { - onClick?.(event); - toggleSidebar(); - }} - {...props} - > - <PanelLeftIcon /> - <span className="sr-only">Toggle Sidebar</span> - </Button> - ); -} - -function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { - const { toggleSidebar } = useSidebar(); - - return ( - <button - data-sidebar="rail" - data-slot="sidebar-rail" - aria-label="Toggle Sidebar" - tabIndex={-1} - onClick={toggleSidebar} - title="Toggle Sidebar" - className={cn( - "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex", - "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", - "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", - "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full", - "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", - "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", - className, - )} - {...props} - /> - ); -} - -function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { - return ( - <main - data-slot="sidebar-inset" - className={cn( - "bg-background relative flex w-full flex-1 flex-col", - "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", - className, - )} - {...props} - /> - ); -} - -function SidebarInput({ - className, - ...props -}: React.ComponentProps<typeof Input>) { - return ( - <Input - data-slot="sidebar-input" - data-sidebar="input" - className={cn("bg-background h-8 w-full shadow-none", className)} - {...props} - /> - ); -} - -function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-header" - data-sidebar="header" - className={cn("flex flex-col gap-2 p-2", className)} - {...props} - /> - ); -} - -function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-footer" - data-sidebar="footer" - className={cn("flex flex-col gap-2 p-2", className)} - {...props} - /> - ); -} - -function SidebarSeparator({ - className, - ...props -}: React.ComponentProps<typeof Separator>) { - return ( - <Separator - data-slot="sidebar-separator" - data-sidebar="separator" - className={cn("bg-sidebar-border mx-2 w-auto", className)} - {...props} - /> - ); -} - -function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-content" - data-sidebar="content" - className={cn( - "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", - className, - )} - {...props} - /> - ); -} - -function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-group" - data-sidebar="group" - className={cn("relative flex w-full min-w-0 flex-col p-2", className)} - {...props} - /> - ); -} - -function SidebarGroupLabel({ - className, - asChild = false, - ...props -}: React.ComponentProps<"div"> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "div"; - - return ( - <Comp - data-slot="sidebar-group-label" - data-sidebar="group-label" - className={cn( - "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", - className, - )} - {...props} - /> - ); -} - -function SidebarGroupAction({ - className, - asChild = false, - ...props -}: React.ComponentProps<"button"> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "button"; - - return ( - <Comp - data-slot="sidebar-group-action" - data-sidebar="group-action" - className={cn( - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 md:after:hidden", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> - ); -} - -function SidebarGroupContent({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-group-content" - data-sidebar="group-content" - className={cn("w-full text-sm", className)} - {...props} - /> - ); -} - -function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { - return ( - <ul - data-slot="sidebar-menu" - data-sidebar="menu" - className={cn("flex w-full min-w-0 flex-col gap-1", className)} - {...props} - /> - ); -} - -function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { - return ( - <li - data-slot="sidebar-menu-item" - data-sidebar="menu-item" - className={cn("group/menu-item relative", className)} - {...props} - /> - ); -} - -const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", - { - variants: { - variant: { - default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", - outline: - "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", - }, - size: { - default: "h-8 text-sm", - sm: "h-7 text-xs", - lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, -); - -function SidebarMenuButton({ - asChild = false, - isActive = false, - variant = "default", - size = "default", - tooltip, - className, - ...props -}: React.ComponentProps<"button"> & { - asChild?: boolean; - isActive?: boolean; - tooltip?: string | React.ComponentProps<typeof TooltipContent>; -} & VariantProps<typeof sidebarMenuButtonVariants>) { - const Comp = asChild ? Slot : "button"; - const { isMobile, state } = useSidebar(); - - const button = ( - <Comp - data-slot="sidebar-menu-button" - data-sidebar="menu-button" - data-size={size} - data-active={isActive} - className={cn(sidebarMenuButtonVariants({ variant, size }), className)} - {...props} - /> - ); - - if (!tooltip) { - return button; - } - - if (typeof tooltip === "string") { - tooltip = { - children: tooltip, - }; - } - - return ( - <Tooltip> - <TooltipTrigger asChild>{button}</TooltipTrigger> - <TooltipContent - side="right" - align="center" - hidden={state !== "collapsed" || isMobile} - {...tooltip} - /> - </Tooltip> - ); -} - -function SidebarMenuAction({ - className, - asChild = false, - showOnHover = false, - ...props -}: React.ComponentProps<"button"> & { - asChild?: boolean; - showOnHover?: boolean; -}) { - const Comp = asChild ? Slot : "button"; - - return ( - <Comp - data-slot="sidebar-menu-action" - data-sidebar="menu-action" - className={cn( - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 md:after:hidden", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - showOnHover && - "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", - className, - )} - {...props} - /> - ); -} - -function SidebarMenuBadge({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-menu-badge" - data-sidebar="menu-badge" - className={cn( - "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none", - "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> - ); -} - -function SidebarMenuSkeleton({ - className, - showIcon = false, - ...props -}: React.ComponentProps<"div"> & { - showIcon?: boolean; -}) { - // Random width between 50 to 90%. - const width = React.useMemo(() => { - return `${Math.floor(Math.random() * 40) + 50}%`; - }, []); - - return ( - <div - data-slot="sidebar-menu-skeleton" - data-sidebar="menu-skeleton" - className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)} - {...props} - > - {showIcon && ( - <Skeleton - className="size-4 rounded-md" - data-sidebar="menu-skeleton-icon" - /> - )} - <Skeleton - className="h-4 max-w-(--skeleton-width) flex-1" - data-sidebar="menu-skeleton-text" - style={ - { - "--skeleton-width": width, - } as React.CSSProperties - } - /> - </div> - ); -} - -function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { - return ( - <ul - data-slot="sidebar-menu-sub" - data-sidebar="menu-sub" - className={cn( - "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> - ); -} - -function SidebarMenuSubItem({ - className, - ...props -}: React.ComponentProps<"li">) { - return ( - <li - data-slot="sidebar-menu-sub-item" - data-sidebar="menu-sub-item" - className={cn("group/menu-sub-item relative", className)} - {...props} - /> - ); -} - -function SidebarMenuSubButton({ - asChild = false, - size = "md", - isActive = false, - className, - ...props -}: React.ComponentProps<"a"> & { - asChild?: boolean; - size?: "sm" | "md"; - isActive?: boolean; -}) { - const Comp = asChild ? Slot : "a"; - - return ( - <Comp - data-slot="sidebar-menu-sub-button" - data-sidebar="menu-sub-button" - data-size={size} - data-active={isActive} - className={cn( - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", - "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", - size === "sm" && "text-xs", - size === "md" && "text-sm", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> - ); -} - -export { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupAction, - SidebarGroupContent, - SidebarGroupLabel, - SidebarHeader, - SidebarInput, - SidebarInset, - SidebarMenu, - SidebarMenuAction, - SidebarMenuBadge, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSkeleton, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - SidebarProvider, - SidebarRail, - SidebarSeparator, - SidebarTrigger, - useSidebar, -}; diff --git a/apps/web/src/components/ui/spinner.tsx b/apps/web/src/components/ui/spinner.tsx index a46792f7..5546d539 100644 --- a/apps/web/src/components/ui/spinner.tsx +++ b/apps/web/src/components/ui/spinner.tsx @@ -3,15 +3,15 @@ import { Loading03Icon } from "@hugeicons/core-free-icons"; import { cn } from "@/utils/ui"; function Spinner({ className, ...props }: Omit<HugeiconsIconProps, "icon">) { - return ( - <HugeiconsIcon - icon={Loading03Icon} - role="status" - aria-label="Loading" - className={cn("size-4 animate-spin", className)} - {...props} - /> - ) + return ( + <HugeiconsIcon + icon={Loading03Icon} + role="status" + aria-label="Loading" + className={cn("size-4 animate-spin", className)} + {...props} + /> + ); } -export { Spinner } +export { Spinner }; diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx index aa8b331a..dfc4cdfe 100644 --- a/apps/web/src/components/ui/split-button.tsx +++ b/apps/web/src/components/ui/split-button.tsx @@ -1,90 +1,90 @@ -import { Button, type ButtonProps } from "@/components/ui/button";
-import { Separator } from "@/components/ui/separator";
-import { type ReactNode, forwardRef } from "react";
-import { cn } from "@/utils/ui";
-
-interface SplitButtonProps {
- children: ReactNode;
- className?: string;
-}
-
-interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> {
- children: ReactNode;
-}
-
-const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
- ({ children, className, ...props }, ref) => {
- return (
- <div
- ref={ref}
- className={cn(
- "border-input bg-accent inline-flex h-7 overflow-hidden rounded-lg border",
- className,
- )}
- {...props}
- >
- {children}
- </div>
- );
- },
-);
-SplitButton.displayName = "SplitButton";
-
-const SplitButtonSide = forwardRef<
- HTMLButtonElement,
- SplitButtonSideProps & { paddingClass: string }
->(({ children, className, paddingClass, onClick, ...props }, ref) => {
- return (
- <Button
- ref={ref}
- variant="text"
- className={cn(
- "bg-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
- onClick
- ? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
- : "cursor-default select-text",
- paddingClass,
- className,
- )}
- onClick={onClick}
- {...props}
- >
- {typeof children === "string" ? (
- <span className="cursor-text font-normal">{children}</span>
- ) : (
- children
- )}
- </Button>
- );
-});
-SplitButtonSide.displayName = "SplitButtonSide";
-
-const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
- ({ ...props }, ref) => {
- return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />;
- },
-);
-SplitButtonLeft.displayName = "SplitButtonLeft";
-
-const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
- ({ ...props }, ref) => {
- return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />;
- },
-);
-SplitButtonRight.displayName = "SplitButtonRight";
-
-const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>(
- ({ className, ...props }, ref) => {
- return (
- <Separator
- ref={ref}
- orientation="vertical"
- className={cn("bg-foreground/15 h-full", className)}
- {...props}
- />
- );
- },
-);
-SplitButtonSeparator.displayName = "SplitButtonSeparator";
-
-export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator };
+import { Button, type ButtonProps } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { type ReactNode, forwardRef } from "react"; +import { cn } from "@/utils/ui"; + +interface SplitButtonProps { + children: ReactNode; + className?: string; +} + +interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> { + children: ReactNode; +} + +const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>( + ({ children, className, ...props }, ref) => { + return ( + <div + ref={ref} + className={cn( + "border-input bg-accent inline-flex h-7 overflow-hidden rounded-lg border", + className, + )} + {...props} + > + {children} + </div> + ); + }, +); +SplitButton.displayName = "SplitButton"; + +const SplitButtonSide = forwardRef< + HTMLButtonElement, + SplitButtonSideProps & { paddingClass: string } +>(({ children, className, paddingClass, onClick, ...props }, ref) => { + return ( + <Button + ref={ref} + variant="text" + className={cn( + "bg-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100", + onClick + ? "hover:bg-foreground/10 cursor-pointer hover:opacity-100" + : "cursor-default select-text", + paddingClass, + className, + )} + onClick={onClick} + {...props} + > + {typeof children === "string" ? ( + <span className="cursor-text font-normal">{children}</span> + ) : ( + children + )} + </Button> + ); +}); +SplitButtonSide.displayName = "SplitButtonSide"; + +const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>( + ({ ...props }, ref) => { + return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />; + }, +); +SplitButtonLeft.displayName = "SplitButtonLeft"; + +const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>( + ({ ...props }, ref) => { + return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />; + }, +); +SplitButtonRight.displayName = "SplitButtonRight"; + +const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>( + ({ className, ...props }, ref) => { + return ( + <Separator + ref={ref} + orientation="vertical" + className={cn("bg-foreground/15 h-full", className)} + {...props} + /> + ); + }, +); +SplitButtonSeparator.displayName = "SplitButtonSeparator"; + +export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator }; diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx index ca60a809..2ae59697 100644 --- a/apps/web/src/components/ui/textarea.tsx +++ b/apps/web/src/components/ui/textarea.tsx @@ -9,8 +9,8 @@ const Textarea = React.forwardRef< return ( <textarea className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent/50 flex min-h-[60px] w-full rounded-md border px-3 py-2 resize-none text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", - "focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/10", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent flex min-h-[60px] w-full rounded-md border px-3 py-2 resize-none text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "focus-visible:border-primary focus-visible:ring-0 focus-visible:ring-primary/10", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className, )} diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 769ac360..067042e6 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -60,6 +60,7 @@ const TooltipContent = React.forwardRef< fill="none" xmlns="http://www.w3.org/2000/svg" className="absolute top-1/2 left-[-6px] -translate-y-1/2" + aria-hidden="true" > <path d="M6 0L0 5L6 10V0Z" |
