diff options
Diffstat (limited to 'apps/web/src/components/ui')
| -rw-r--r-- | apps/web/src/components/ui/button.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/components/ui/color-picker.tsx | 13 | ||||
| -rw-r--r-- | apps/web/src/components/ui/context-menu.tsx | 14 | ||||
| -rw-r--r-- | apps/web/src/components/ui/dialog.tsx | 16 | ||||
| -rw-r--r-- | apps/web/src/components/ui/dropdown-menu.tsx | 23 | ||||
| -rw-r--r-- | apps/web/src/components/ui/font-picker.tsx | 107 | ||||
| -rw-r--r-- | apps/web/src/components/ui/input-with-back.tsx | 85 | ||||
| -rw-r--r-- | apps/web/src/components/ui/input.tsx | 7 | ||||
| -rw-r--r-- | apps/web/src/components/ui/number-field.tsx | 223 | ||||
| -rw-r--r-- | apps/web/src/components/ui/popover.tsx | 20 | ||||
| -rw-r--r-- | apps/web/src/components/ui/select.tsx | 16 | ||||
| -rw-r--r-- | apps/web/src/components/ui/sheet.tsx | 17 | ||||
| -rw-r--r-- | apps/web/src/components/ui/sonner.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/components/ui/tabs.tsx | 92 | ||||
| -rw-r--r-- | apps/web/src/components/ui/tooltip.tsx | 4 | ||||
| -rw-r--r-- | apps/web/src/components/ui/use-overlay-open-change.ts | 78 |
16 files changed, 457 insertions, 262 deletions
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index aa52e228..3dff9417 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -23,7 +23,7 @@ const buttonVariants = cva( }, size: { default: "h-9 px-4 py-2", - sm: "h-7.5 p-1 px-2.5 text-sm rounded-sm", + sm: "h-7 p-1 px-2.5 text-sm rounded-sm", lg: "h-10 p-5 px-6", icon: "size-7 rounded-sm", text: "p-0", diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx index fbf9bbd7..6d911a75 100644 --- a/apps/web/src/components/ui/color-picker.tsx +++ b/apps/web/src/components/ui/color-picker.tsx @@ -270,6 +270,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( backgroundColor: "#fff", }; + return ( <Popover> <div @@ -285,10 +286,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative" type="button" > - <span - className="absolute inset-0" - style={checkerboardStyle} - /> + <span + className="absolute inset-0 dark:invert" + style={checkerboardStyle} + /> <span className="absolute inset-0" style={{ backgroundColor: `#${value}` }} @@ -298,7 +299,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( <div className="flex flex-1 items-center"> <Input className={cn( - "!border-0 bg-transparent p-0 !ring-0 !ring-offset-0", + "border-0! bg-transparent p-0 ring-0! ring-offset-0!", colorFormat === "hex" && "uppercase", )} size="sm" @@ -387,10 +388,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( <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 dark:invert" style={checkerboardStyle} /> <div className="absolute inset-0 rounded-lg" style={{ diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx index d8791bdf..ec02de6b 100644 --- a/apps/web/src/components/ui/context-menu.tsx +++ b/apps/web/src/components/ui/context-menu.tsx @@ -10,8 +10,20 @@ import { ArrowRightIcon, CircleIcon, } from "@hugeicons/core-free-icons"; +import { useOverlayOpenChange } from "./use-overlay-open-change"; -const ContextMenu = ContextMenuPrimitive.Root; +function ContextMenu({ + onOpenChange, + ...props +}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) { + const handleOpenChange = useOverlayOpenChange({ + source: "context-menu", + onOpenChange, + }); + return ( + <ContextMenuPrimitive.Root onOpenChange={handleOpenChange} {...props} /> + ); +} const ContextMenuTrigger = ContextMenuPrimitive.Trigger; diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index d124a2e9..f5045f95 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -4,8 +4,22 @@ import * as React from "react"; import { Dialog as DialogPrimitive } from "radix-ui"; import { X } from "lucide-react"; import { cn } from "@/utils/ui"; +import { useOverlayOpenChange } from "./use-overlay-open-change"; -const Dialog = DialogPrimitive.Root; +function Dialog({ + open, + onOpenChange, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Root>) { + const handleOpenChange = useOverlayOpenChange({ + source: "dialog", + open, + onOpenChange, + }); + return ( + <DialogPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} /> + ); +} const DialogTrigger = DialogPrimitive.Trigger; diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index 2d6541b8..91913e25 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -4,10 +4,27 @@ import * as React from "react"; import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; import { Check, ChevronRight, Circle } from "lucide-react"; import { cva, type VariantProps } from "class-variance-authority"; - import { cn } from "@/utils/ui"; +import { useOverlayOpenChange } from "./use-overlay-open-change"; -const DropdownMenu = DropdownMenuPrimitive.Root; +function DropdownMenu({ + open, + onOpenChange, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { + const handleOpenChange = useOverlayOpenChange({ + source: "dropdown-menu", + open, + onOpenChange, + }); + return ( + <DropdownMenuPrimitive.Root + open={open} + onOpenChange={handleOpenChange} + {...props} + /> + ); +} const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; @@ -118,7 +135,7 @@ const DropdownMenuItem = React.forwardRef< ref, ) => { const iconSlot = ( - <span className="hidden size-4 shrink-0 items-center justify-center group-has-[[data-has-icon]]/menu:flex"> + <span className="hidden size-4 shrink-0 items-center justify-center group-has-data-has-icon/menu:flex"> {icon} </span> ); diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx index 09ba0a9a..77d8ab7f 100644 --- a/apps/web/src/components/ui/font-picker.tsx +++ b/apps/web/src/components/ui/font-picker.tsx @@ -1,13 +1,6 @@ "use client"; -import { - useState, - useMemo, - useRef, - useEffect, - useCallback, - type CSSProperties, -} from "react"; +import { useState, useMemo, useRef, useEffect, useCallback, type CSSProperties } from "react"; import { List, type RowComponentProps } from "react-window"; import { Popover, @@ -16,28 +9,24 @@ import { } 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 { loadFullFont } from "@/lib/fonts/google-fonts"; +import { SYSTEM_FONTS } from "@/constants/font-constants"; +import type { FontAtlas, FontAtlasEntry } from "@/lib/fonts/types"; +import { useFontAtlas } from "@/hooks/use-font-atlas"; import { cn } from "@/utils/ui"; -import { ChevronDown, Search, Upload } from "lucide-react"; +import { ChevronDown, Search } from "lucide-react"; import { HugeiconsIcon } from "@hugeicons/react"; import { TextIcon } from "@hugeicons/core-free-icons"; const FONT_TABS = [ { key: "all", label: "All fonts" }, - { key: "favorites", label: "Favorites" }, { key: "my-fonts", label: "My fonts" }, + { key: "favorites", label: "Favorites" }, ] 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; @@ -57,18 +46,8 @@ export function FontPicker({ 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 { atlas, status, fontNames, retry: handleRetry } = useFontAtlas({ open }); const filteredFonts = useMemo(() => { if (!search) return fontNames; @@ -83,32 +62,19 @@ export function FontPicker({ const handleSelect = useCallback( async ({ family }: { family: string }) => { - try { - await loadFullFont({ family }); - onValueChange?.(family); - } catch { - onValueChange?.(family); + if (!SYSTEM_FONTS.has(family)) { + try { + await loadFullFont({ family }); + } catch { + // ignore load failure, font will fall back to system default + } } + 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(""); @@ -116,18 +82,8 @@ export function FontPicker({ } }, [open]); - const handleRetry = useCallback(() => { - clearFontAtlasCache(); - setStatus("loading"); - prefetchFontAtlas().then((data) => { - if (data) { - setAtlas(data); - setStatus("idle"); - } else { - setStatus("error"); - } - }); - }, []); + const activeTabLabel = + FONT_TABS.find((t) => t.key === activeTab)?.label.toLowerCase() ?? ""; return ( <Popover open={open} onOpenChange={setOpen}> @@ -164,11 +120,11 @@ export function FontPicker({ <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..." + placeholder={`Search ${activeTabLabel}...`} value={search} onChange={(event) => setSearch(event.target.value)} size="xs" - className="w-full pl-5 bg-transparent !border-none !shadow-none" + className="w-full pl-5 bg-transparent border-none! shadow-none!" /> </div> <div className="flex border-b px-3"> @@ -225,20 +181,6 @@ export function FontPicker({ 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"); - }} - > - <Upload className="!size-3.5" /> - Load local fonts - </Button> - </div> </PopoverContent> </Popover> ); @@ -250,7 +192,7 @@ function FontSpritePreview({ entry }: { entry: FontAtlasEntry }) { className="shrink-0" style={{ width: entry.w, - height: SPRITE_ROW_HEIGHT, + height: ROW_HEIGHT, backgroundColor: "currentColor", WebkitMaskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`, WebkitMaskPosition: `-${entry.x}px -${entry.y}px`, @@ -283,6 +225,7 @@ function FontRow({ const fontName = filteredFonts[index]; const entry = atlas.fonts[fontName]; const isSelected = fontName === selectedFont; + const isSystemFont = SYSTEM_FONTS.has(fontName); return ( <button @@ -302,7 +245,13 @@ function FontRow({ aria-label={fontName} > <div className="min-w-0 overflow-hidden"> - <FontSpritePreview entry={entry} /> + {isSystemFont ? ( + <span className="text-xl text-foreground/85" style={{ fontFamily: fontName }}> + {fontName} + </span> + ) : ( + <FontSpritePreview entry={entry} /> + )} </div> </button> ); diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx deleted file mode 100644 index 1c22fd2e..00000000 --- a/apps/web/src/components/ui/input-with-back.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"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 762cc675..1dce2f24 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -8,18 +8,19 @@ import { forwardRef, type ComponentProps } from "react"; import { useState } from "react"; const inputVariants = cva( - "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", + "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-offset-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", + default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20", + outline: "bg-background", 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", 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", + sm: "h-7 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", }, }, diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx index 48297dc0..18d71591 100644 --- a/apps/web/src/components/ui/number-field.tsx +++ b/apps/web/src/components/ui/number-field.tsx @@ -1,12 +1,15 @@ "use client"; import { cn } from "@/utils/ui"; -import { useRef, useState, type ComponentProps } from "react"; +import { clamp } from "@/utils/math"; +import { useRef, useState, useLayoutEffect, 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 SUFFIX_GAP_PX = 6; + const DRAG_SENSITIVITIES = { default: 1, slow: 0.5, @@ -14,10 +17,94 @@ const DRAG_SENSITIVITIES = { type DragSensitivity = "default" | "slow"; +type ScrubRange = { + from: number; + to: number; + pixelsPerUnit: number; +}; + +type ScrubClamp = { + min?: number; + max?: number; +}; + +function clampScrubValue({ + value, + min, + max, +}: { + value: number; + min?: number; + max?: number; +}): number { + if (min != null && max != null) return clamp({ value, min, max }); + if (min != null) return Math.max(min, value); + if (max != null) return Math.min(max, value); + return value; +} + +function getActiveRange({ + value, + direction, + ranges, +}: { + value: number; + direction: number; + ranges: readonly ScrubRange[]; +}): ScrubRange | undefined { + return ranges.find((range) => + direction > 0 + ? value >= range.from && value < range.to + : value > range.from && value <= range.to, + ); +} + +function scrubAcrossRanges({ + startValue, + pixelDelta, + ranges, + min, + max, +}: { + startValue: number; + pixelDelta: number; + ranges: readonly ScrubRange[]; + min?: number; + max?: number; +}): number { + let currentValue = clampScrubValue({ value: startValue, min, max }); + let remainingPixels = pixelDelta; + + while (remainingPixels !== 0) { + const direction = Math.sign(remainingPixels); + + const range = getActiveRange({ value: currentValue, direction, ranges }); + if (!range) break; + + const boundary = direction > 0 ? range.to : range.from; + const pixelsToBoundary = + Math.abs(boundary - currentValue) * range.pixelsPerUnit; + + if (Math.abs(remainingPixels) <= pixelsToBoundary) { + currentValue += remainingPixels / range.pixelsPerUnit; + break; + } + + currentValue = boundary; + remainingPixels -= direction * pixelsToBoundary; + } + + return clampScrubValue({ value: currentValue, min, max }); +} + interface NumberFieldProps extends Omit<ComponentProps<"input">, "size" | "type"> { icon?: React.ReactNode; + suffix?: string; + suffixClassName?: string; dragSensitivity?: DragSensitivity; + scrubRanges?: readonly ScrubRange[]; + scrubClamp?: ScrubClamp; onScrub?: (value: number) => void; onScrubEnd?: () => void; allowExpressions?: boolean; @@ -28,8 +115,12 @@ interface NumberFieldProps function NumberField({ className, icon, + suffix, + suffixClassName, disabled, dragSensitivity = "default", + scrubRanges, + scrubClamp, onScrub, onScrubEnd, value, @@ -45,9 +136,18 @@ function NumberField({ }: NumberFieldProps & { ref?: React.Ref<HTMLInputElement> }) { const iconRef = useRef<HTMLSpanElement>(null); const inputRef = useRef<HTMLInputElement>(null); + const ghostRef = useRef<HTMLSpanElement>(null); const startValueRef = useRef(0); const cumulativeDeltaRef = useRef(0); const [isInputFocused, setIsInputFocused] = useState(false); + const [suffixLeft, setSuffixLeft] = useState(0); + + useLayoutEffect(() => { + if (!suffix || !ghostRef.current || !inputRef.current) return; + const paddingLeft = + parseFloat(getComputedStyle(inputRef.current).paddingLeft) || 0; + setSuffixLeft(paddingLeft + ghostRef.current.offsetWidth); + }, [value, suffix]); const { containerRef: wrapperRef } = useFocusLock<HTMLDivElement>({ isActive: isInputFocused, @@ -71,12 +171,16 @@ function NumberField({ return; } cumulativeDeltaRef.current += moveEvent.movementX; - const sensitivity = - typeof dragSensitivity === "number" - ? dragSensitivity - : DRAG_SENSITIVITIES[dragSensitivity]; - const newValue = - startValueRef.current + cumulativeDeltaRef.current * sensitivity; + const newValue = scrubRanges + ? scrubAcrossRanges({ + startValue: startValueRef.current, + pixelDelta: cumulativeDeltaRef.current, + ranges: scrubRanges, + min: scrubClamp?.min, + max: scrubClamp?.max, + }) + : startValueRef.current + + cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity]; onScrub(newValue); }; @@ -93,11 +197,48 @@ function NumberField({ const canScrub = Boolean(icon && onScrub); + const inputNode = ( + <input + type={allowExpressions ? "text" : "number"} + inputMode={allowExpressions ? "decimal" : undefined} + ref={inputRef} + disabled={disabled} + value={value} + className="text-sm leading-none bg-transparent outline-none min-w-0 flex-1 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + 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} + /> + ); + 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", + "border-border bg-accent flex h-7 w-full min-w-0 items-center rounded-md border text-sm outline-none cursor-text 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, )} @@ -106,53 +247,45 @@ function NumberField({ <span ref={iconRef} className={cn( - "text-muted-foreground [&_svg]:!size-3.5 shrink-0 select-none pl-2.5 text-sm leading-none", + "text-muted-foreground [&_svg]:size-3.5! shrink-0 select-none pl-2.5 text-sm leading-none", canScrub && "cursor-ew-resize", )} + onMouseDown={canScrub ? (event) => event.preventDefault() : undefined} onPointerDown={canScrub ? handleIconPointerDown : undefined} > {icon} </span> )} - <input - type={allowExpressions ? "text" : "number"} - inputMode={allowExpressions ? "decimal" : undefined} - ref={inputRef} - disabled={disabled} - value={value} + <span 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", + "relative flex flex-1 min-w-0 items-center", 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} - /> + > + {inputNode} + {suffix && ( + <> + {/* Ghost mirrors value text to measure width for suffix positioning */} + <span + ref={ghostRef} + className="invisible absolute text-sm leading-none whitespace-pre pointer-events-none" + aria-hidden="true" + > + {value} + </span> + <span + className={cn( + "absolute top-1/2 -translate-y-1/2 select-none pointer-events-none text-sm leading-none", + suffixClassName, + )} + style={{ left: suffixLeft + SUFFIX_GAP_PX }} + > + {suffix} + </span> + </> + )} + </span> {onReset && !isDefault && ( <div className="shrink-0 pr-2 flex items-center"> <Button @@ -161,7 +294,7 @@ function NumberField({ aria-label="Reset to default" onClick={onReset} > - <HugeiconsIcon icon={ArrowTurnBackwardIcon} className="!size-3.5" /> + <HugeiconsIcon icon={ArrowTurnBackwardIcon} className="size-3.5!" /> </Button> </div> )} diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index 8fd511a3..cf480c8c 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -3,8 +3,26 @@ import * as React from "react"; import { Popover as PopoverPrimitive } from "radix-ui"; import { cn } from "@/utils/ui"; +import { useOverlayOpenChange } from "./use-overlay-open-change"; -const Popover = PopoverPrimitive.Root; +function Popover({ + open, + onOpenChange, + ...props +}: React.ComponentProps<typeof PopoverPrimitive.Root>) { + const handleOpenChange = useOverlayOpenChange({ + source: "popover", + open, + onOpenChange, + }); + return ( + <PopoverPrimitive.Root + open={open} + onOpenChange={handleOpenChange} + {...props} + /> + ); +} const PopoverTrigger = PopoverPrimitive.Trigger; diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 1b57ea4d..b7092b87 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -7,8 +7,22 @@ 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"; +import { useOverlayOpenChange } from "./use-overlay-open-change"; -const Select = SelectPrimitive.Root; +function Select({ + open, + onOpenChange, + ...props +}: React.ComponentProps<typeof SelectPrimitive.Root>) { + const handleOpenChange = useOverlayOpenChange({ + source: "select", + open, + onOpenChange, + }); + return ( + <SelectPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} /> + ); +} const SelectGroup = SelectPrimitive.Group; diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index 3155787d..d56cd0ac 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -4,10 +4,23 @@ import * as React from "react"; import { Dialog as SheetPrimitive } from "radix-ui"; import { cva, type VariantProps } from "class-variance-authority"; import { X } from "lucide-react"; - import { cn } from "@/utils/ui"; +import { useOverlayOpenChange } from "./use-overlay-open-change"; -const Sheet = SheetPrimitive.Root; +function Sheet({ + open, + onOpenChange, + ...props +}: React.ComponentProps<typeof SheetPrimitive.Root>) { + const handleOpenChange = useOverlayOpenChange({ + source: "sheet", + open, + onOpenChange, + }); + return ( + <SheetPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} /> + ); +} const SheetTrigger = SheetPrimitive.Trigger; diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx index 4bae2fad..f0c8197a 100644 --- a/apps/web/src/components/ui/sonner.tsx +++ b/apps/web/src/components/ui/sonner.tsx @@ -12,7 +12,7 @@ const Toaster = ({ ...props }: ToasterProps) => { <Sonner theme={theme as ToasterProps["theme"]} className="toaster group" - position="top-center" + position="bottom-right" offset={20} toastOptions={{ classNames: { diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx index d2844b63..c5cc02d7 100644 --- a/apps/web/src/components/ui/tabs.tsx +++ b/apps/web/src/components/ui/tabs.tsx @@ -5,51 +5,81 @@ import { Tabs as TabsPrimitive } from "radix-ui"; import { cn } from "@/utils/ui"; -const Tabs = TabsPrimitive.Root; +type TabsVariant = "default" | "underline"; + +const TabsVariantContext = React.createContext<TabsVariant>("default"); + +const Tabs = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> & { + variant?: TabsVariant; + } +>(({ variant = "default", ...props }, ref) => ( + <TabsVariantContext.Provider value={variant}> + <TabsPrimitive.Root ref={ref} {...props} /> + </TabsVariantContext.Provider> +)); +Tabs.displayName = TabsPrimitive.Root.displayName; const TabsList = React.forwardRef< React.ElementRef<typeof TabsPrimitive.List>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> ->(({ className, ...props }, ref) => ( - <TabsPrimitive.List - ref={ref} - className={cn( - "text-muted-foreground inline-flex h-auto items-center justify-center gap-2 rounded-lg bg-transparent p-0", - className, - )} - {...props} - /> -)); +>(({ className, ...props }, ref) => { + const variant = React.useContext(TabsVariantContext); + return ( + <TabsPrimitive.List + ref={ref} + className={cn( + "text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0", + variant === "default" && "rounded-lg", + variant === "underline" && "border-b border-border w-full gap-0 px-2", + className, + )} + {...props} + /> + ); +}); TabsList.displayName = TabsPrimitive.List.displayName; const TabsTrigger = React.forwardRef< React.ElementRef<typeof TabsPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> ->(({ className, ...props }, ref) => ( - <TabsPrimitive.Trigger - ref={ref} - className={cn( - "ring-offset-background focus-visible:ring-ring data-[state=active]:bg-accent data-[state=active]:text-foreground inline-flex cursor-pointer items-center justify-center rounded-lg px-3 py-1 text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50", - className, - )} - {...props} - /> -)); +>(({ className, ...props }, ref) => { + const variant = React.useContext(TabsVariantContext); + return ( + <TabsPrimitive.Trigger + ref={ref} + className={cn( + "ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 border border-transparent", + variant === "default" && + "data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5", + variant === "underline" && + "text-muted-foreground data-[state=active]:text-primary border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px", + className, + )} + {...props} + /> + ); +}); TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; const TabsContent = React.forwardRef< React.ElementRef<typeof TabsPrimitive.Content>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> ->(({ className, ...props }, ref) => ( - <TabsPrimitive.Content - ref={ref} - className={cn( - "ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden", - className, - )} - {...props} - /> -)); +>(({ className, ...props }, ref) => { + const variant = React.useContext(TabsVariantContext); + return ( + <TabsPrimitive.Content + ref={ref} + className={cn( + "ring-offset-background focus-visible:ring-ring mt-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden", + variant === "underline" && "px-4", + className, + )} + {...props} + /> + ); +}); TabsContent.displayName = TabsPrimitive.Content.displayName; export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 067042e6..2e7b53d4 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -11,7 +11,7 @@ const Tooltip = TooltipPrimitive.Root; const TooltipTrigger = TooltipPrimitive.Trigger; const tooltipVariants = cva( - "z-50 overflow-visible rounded-sm text-sm shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 overflow-visible rounded-sm text-sm shadow-md", { variants: { variant: { @@ -22,7 +22,7 @@ const tooltipVariants = cva( important: "bg-amber-100/90 text-amber-900 dark:bg-amber-900/20 dark:text-amber-300 border-amber-900 [border-width:0.5px]", promotions: - "bg-red-100/90 text-red-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]", + "bg-red-100/90 text-redb-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]", personal: "bg-green-100/90 text-green-900 dark:bg-green-900/20 dark:text-green-300 border-green-900 [border-width:0.5px]", updates: diff --git a/apps/web/src/components/ui/use-overlay-open-change.ts b/apps/web/src/components/ui/use-overlay-open-change.ts new file mode 100644 index 00000000..165efc79 --- /dev/null +++ b/apps/web/src/components/ui/use-overlay-open-change.ts @@ -0,0 +1,78 @@ +import { useCallback, useEffect, useId, useRef } from "react"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; + +export function useOverlayOpenChange({ + source, + open, + onOpenChange, +}: { + source: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const { openOverlay, closeOverlay } = useKeybindingsStore(); + const isTrackedRef = useRef(false); + const isControlled = typeof open === "boolean"; + const overlayId = useId(); + + useEffect(() => { + if (!isControlled) return; + + if (open && !isTrackedRef.current) { + openOverlay(overlayId, source); + isTrackedRef.current = true; + return; + } + + if (!open && isTrackedRef.current) { + closeOverlay(overlayId, source); + isTrackedRef.current = false; + } + }, [closeOverlay, isControlled, open, openOverlay, overlayId, source]); + + useEffect(() => { + return () => { + if (!isTrackedRef.current) return; + // #region agent log + fetch( + "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Debug-Session-Id": "3997d9", + }, + body: JSON.stringify({ + sessionId: "3997d9", + runId: "post-fix", + hypothesisId: "H2", + location: "use-overlay-open-change.ts:cleanup", + message: "Overlay closed during unmount cleanup", + data: { source, overlayId }, + timestamp: Date.now(), + }), + }, + ).catch(() => {}); + // #endregion + closeOverlay(overlayId, source); + isTrackedRef.current = false; + }; + }, [closeOverlay, overlayId, source]); + + return useCallback( + (nextOpen: boolean) => { + if (!isControlled) { + if (nextOpen && !isTrackedRef.current) { + openOverlay(overlayId, source); + isTrackedRef.current = true; + } else if (!nextOpen && isTrackedRef.current) { + closeOverlay(overlayId, source); + isTrackedRef.current = false; + } + } + + onOpenChange?.(nextOpen); + }, + [closeOverlay, isControlled, onOpenChange, openOverlay, overlayId, source], + ); +} |
