summaryrefslogtreecommitdiff
path: root/apps/web/src/components/ui
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/ui')
-rw-r--r--apps/web/src/components/ui/button.tsx2
-rw-r--r--apps/web/src/components/ui/color-picker.tsx13
-rw-r--r--apps/web/src/components/ui/context-menu.tsx14
-rw-r--r--apps/web/src/components/ui/dialog.tsx16
-rw-r--r--apps/web/src/components/ui/dropdown-menu.tsx23
-rw-r--r--apps/web/src/components/ui/font-picker.tsx107
-rw-r--r--apps/web/src/components/ui/input-with-back.tsx85
-rw-r--r--apps/web/src/components/ui/input.tsx7
-rw-r--r--apps/web/src/components/ui/number-field.tsx223
-rw-r--r--apps/web/src/components/ui/popover.tsx20
-rw-r--r--apps/web/src/components/ui/select.tsx16
-rw-r--r--apps/web/src/components/ui/sheet.tsx17
-rw-r--r--apps/web/src/components/ui/sonner.tsx2
-rw-r--r--apps/web/src/components/ui/tabs.tsx92
-rw-r--r--apps/web/src/components/ui/tooltip.tsx4
-rw-r--r--apps/web/src/components/ui/use-overlay-open-change.ts78
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],
+ );
+}