polish chat input and sidebar interactions (#99)

* Polish chat input and sidebar interactions.

Standardize keyboard focus behavior and tighten sidebar and composer controls so the compact UI feels more consistent while preserving drag-and-drop image workflows.

* Fix lint follow-ups for UI polish.

Extract the chat image drop-target logic and tighten the shared focus selector so CI passes without changing the polished behavior.

* Fix keyboard focus detection for non-Tab navigation.

Preserve hidden pointer focus rings while restoring visible focus for keyboard shortcuts and other keyboard-driven flows that move focus without Tab.
This commit is contained in:
morgmart
2026-04-03 14:27:38 -07:00
committed by GitHub
parent 481af65328
commit d12acb05ff
27 changed files with 312 additions and 115 deletions
+3
View File
@@ -1,8 +1,11 @@
import { useEffect } from "react";
import { AppShell } from "@/app/AppShell";
import { useKeyboardNavDetection } from "@/shared/hooks/useKeyboardNavDetection";
export function App() {
useKeyboardNavDetection();
useEffect(() => {
const preventWindowFileNavigation = (event: DragEvent) => {
event.preventDefault();
@@ -0,0 +1,98 @@
import { useCallback, useRef, useState, type DragEvent } from "react";
interface UseImageDropTargetOptions {
disabled: boolean;
isStreaming: boolean;
onDropFile: (file: File) => void;
}
function hasDraggedFiles(dataTransfer: DataTransfer) {
return (
Array.from(dataTransfer.items).some(
(item) => item.kind === "file" || item.type.startsWith("image/"),
) || Array.from(dataTransfer.types).includes("Files")
);
}
export function useImageDropTarget({
disabled,
isStreaming,
onDropFile,
}: UseImageDropTargetOptions) {
const [isImageDragOver, setIsImageDragOver] = useState(false);
const dragDepthRef = useRef(0);
const handleDragEnter = useCallback(
(e: DragEvent<HTMLDivElement>) => {
if (disabled || isStreaming || !hasDraggedFiles(e.dataTransfer)) {
return;
}
e.preventDefault();
dragDepthRef.current += 1;
setIsImageDragOver(true);
},
[disabled, isStreaming],
);
const handleDragOver = useCallback(
(e: DragEvent<HTMLDivElement>) => {
if (disabled || isStreaming || !hasDraggedFiles(e.dataTransfer)) {
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsImageDragOver(true);
},
[disabled, isStreaming],
);
const handleDragLeave = useCallback(
(e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (!isImageDragOver) {
return;
}
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) {
setIsImageDragOver(false);
}
},
[isImageDragOver],
);
const handleDrop = useCallback(
(e: DragEvent<HTMLDivElement>) => {
dragDepthRef.current = 0;
setIsImageDragOver(false);
if (disabled || isStreaming) {
return;
}
const files = Array.from(e.dataTransfer.files).filter((file) =>
file.type.startsWith("image/"),
);
if (files.length === 0) {
return;
}
e.preventDefault();
for (const file of files) {
onDropFile(file);
}
},
[disabled, isStreaming, onDropFile],
);
return {
isImageDragOver,
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop,
};
}
+30 -22
View File
@@ -3,6 +3,7 @@ import { X } from "lucide-react";
import type { AcpProvider } from "@/shared/api/acp";
import type { Persona } from "@/shared/types/agents";
import { cn } from "@/shared/lib/cn";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import {
MentionAutocomplete,
@@ -15,6 +16,7 @@ import { PersonaAvatar } from "./PersonaPicker";
import { ImageLightbox } from "@/shared/ui/ImageLightbox";
import type { PastedImage } from "@/shared/types/messages";
import { resizeImage } from "../lib/resizeImage";
import { useImageDropTarget } from "../hooks/useImageDropTarget";
export interface ModelOption {
id: string;
@@ -83,7 +85,7 @@ function PastedImageThumb({
<button
type="button"
onClick={() => setLightboxOpen(true)}
className="block cursor-pointer rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="block cursor-pointer rounded-lg"
aria-label={`View attachment ${index + 1}`}
>
<img
@@ -308,26 +310,17 @@ export function ChatInput({
[addImageFile],
);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
const hasImages = Array.from(e.dataTransfer.items).some((item) =>
item.type.startsWith("image/"),
);
if (hasImages) e.preventDefault();
}, []);
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
const files = Array.from(e.dataTransfer.files).filter((f) =>
f.type.startsWith("image/"),
);
if (files.length === 0) return;
e.preventDefault();
for (const file of files) {
addImageFile(file);
}
},
[addImageFile],
);
const {
isImageDragOver,
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop,
} = useImageDropTarget({
disabled,
isStreaming,
onDropFile: addImageFile,
});
const removeImage = useCallback((index: number) => {
setImages((prev) => {
@@ -352,10 +345,25 @@ export function ChatInput({
<div className="mx-auto max-w-3xl">
{/* biome-ignore lint/a11y/noStaticElementInteractions: drop zone for image files */}
<div
className="relative rounded-2xl border border-border bg-background px-4 pb-3 pt-4"
className={cn(
"relative rounded-2xl border border-border bg-background px-4 pb-3 pt-4 transition-colors",
isImageDragOver && "bg-muted/20",
)}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isImageDragOver && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-2xl border border-dashed border-border bg-background/70">
<Badge
variant="secondary"
className="px-3 py-1 text-sm shadow-sm"
>
Drop images to attach
</Badge>
</div>
)}
<MentionAutocomplete
personas={personas}
query={mentionQuery}
@@ -103,30 +103,30 @@ export function ChatInputSelector({
key={item.value}
disabled={item.disabled}
onSelect={() => onValueChange(item.value)}
className={cn(
"gap-2 justify-between",
item.description ? "items-start" : "items-center",
)}
className="justify-between gap-2"
>
{item.icon ? (
<span
className={cn("shrink-0", item.description && "mt-1.5")}
>
{item.icon}
</span>
) : null}
<div className="min-w-0 flex-1">
<span className="block truncate font-normal">
{item.label}
</span>
{item.description ? (
<span className="block truncate text-xs text-muted-foreground">
{item.description}
</span>
<div
className={cn(
"min-w-0 flex flex-1 gap-2",
item.description ? "items-start" : "items-center",
)}
>
{item.icon ? (
<span className="shrink-0">{item.icon}</span>
) : null}
<div className="min-w-0 flex-1">
<span className="block truncate font-normal">
{item.label}
</span>
{item.description ? (
<span className="block truncate text-xs text-muted-foreground">
{item.description}
</span>
) : null}
</div>
</div>
{item.value === value ? (
<Check className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<Check className="size-4 shrink-0 self-center text-muted-foreground" />
) : null}
</DropdownMenuItem>
))}
@@ -1,11 +1,4 @@
import {
Paperclip,
Mic,
ChevronDown,
Check,
ArrowUp,
Square,
} from "lucide-react";
import { Mic, ChevronDown, Check, ArrowUp, Square } from "lucide-react";
import {
formatProviderLabel,
getProviderIcon,
@@ -277,16 +270,6 @@ export function ChatInputToolbar({
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Attach file"
title="Attach file"
>
<Paperclip />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<span>
@@ -8,7 +8,7 @@ export function ClickableImage({ src, alt }: { src: string; alt: string }) {
<button
type="button"
onClick={() => setOpen(true)}
className="cursor-pointer rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="cursor-pointer rounded-lg"
aria-label={`View ${alt}`}
>
<img
+51 -24
View File
@@ -1,5 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { BookOpen, Bot, ChevronLeft, ChevronRight, Home } from "lucide-react";
import {
IconLayoutSidebar,
IconLayoutSidebarFilled,
} from "@tabler/icons-react";
import { BookOpen, Bot, Home } from "lucide-react";
import { GooseIcon } from "@/shared/ui/icons/GooseIcon";
import { cn } from "@/shared/lib/cn";
import type { AppView } from "@/app/AppShell";
@@ -7,6 +11,7 @@ import type { ProjectInfo } from "@/features/projects/api/projects";
import { useChatStore } from "@/features/chat/stores/chatStore";
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
import { isSessionRunning } from "@/features/chat/lib/sessionActivity";
import { Button } from "@/shared/ui/button";
import { SidebarProjectsSection } from "./SidebarProjectsSection";
import { useSidebarHighlight } from "./useSidebarHighlight";
@@ -196,6 +201,10 @@ export function Sidebar({
// Update active rect when activeView/activeSessionId changes
useEffect(() => {
if (activeSessionId) {
return;
}
if (activeView === "home") {
updateActiveRect(homeRef.current);
} else if (activeView && navItemRefs.current[activeView]) {
@@ -203,7 +212,7 @@ export function Sidebar({
} else {
updateActiveRect(null);
}
}, [activeView, updateActiveRect]);
}, [activeSessionId, activeView, updateActiveRect]);
// Callback for SidebarProjectsSection to register active session refs
const activeSessionRefCallback = useCallback(
@@ -224,30 +233,35 @@ export function Sidebar({
)}
style={{ width: collapsed ? 54 : width }}
>
{/* Collapse toggle — vertically centered, half outside the right edge */}
<button
type="button"
onClick={onCollapse}
className={cn(
"absolute top-1/2 -translate-y-1/2 -right-3 z-50",
"flex items-center justify-center size-6 rounded-full",
"bg-background border border-border",
"text-muted-foreground hover:text-foreground hover:scale-110",
"transition-transform duration-200",
)}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed ? (
<ChevronRight className="size-3" />
) : (
<ChevronLeft className="size-3" />
)}
</button>
<div className="flex flex-col h-full overflow-hidden bg-background border border-border rounded-xl">
{/* Goose logo — pinned top */}
<div className="flex-shrink-0 px-3 pt-3 pb-1">
<GooseIcon className="text-muted-foreground" />
<div
className={cn(
"flex-shrink-0 pt-3",
collapsed ? "px-1.5 pb-1.5" : "px-3 pb-1",
)}
>
<div
className={cn(
"flex items-center",
collapsed ? "justify-center" : "justify-between",
)}
>
<GooseIcon className="text-muted-foreground" />
{!collapsed && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onCollapse}
className="text-muted-foreground hover:text-foreground"
aria-label="Collapse sidebar"
title="Collapse sidebar"
>
<IconLayoutSidebarFilled className="size-4" />
</Button>
)}
</div>
</div>
{/* Navigation (scrollable) */}
@@ -271,6 +285,19 @@ export function Sidebar({
)}
<div className="relative z-10 space-y-0.5">
{collapsed && (
<button
type="button"
onClick={onCollapse}
title="Expand sidebar"
className="flex w-full items-center gap-2.5 rounded-md p-3 text-[13px] text-muted-foreground transition-colors duration-200 hover:text-foreground"
aria-label="Expand sidebar"
>
<IconLayoutSidebar className="size-4 flex-shrink-0" />
<span className="sr-only">Expand sidebar</span>
</button>
)}
{/* TODO: Search bar — uncomment when onSearchClick is wired up */}
{/* <button
type="button"
@@ -7,7 +7,8 @@ import { SessionActivityIndicator } from "@/shared/ui/SessionActivityIndicator";
const INACTIVE_CHAT_ROW_CLASS =
"text-muted-foreground hover:bg-transparent hover:text-foreground group-hover:text-foreground";
const ACTIVE_CHAT_ROW_CLASS = "text-foreground";
const ACTIVE_CHAT_ROW_CLASS =
"text-foreground hover:bg-transparent hover:text-foreground";
interface SidebarChatRowProps {
id: string;
@@ -41,6 +42,7 @@ export function SidebarChatRow({
const [draftTitle, setDraftTitle] = useState(title);
const menuRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
@@ -64,6 +66,20 @@ export function SidebarChatRow({
inputRef.current?.select();
}, [editing]);
useEffect(() => {
if (!activeRef || !isActive || !rowRef.current) {
return;
}
const frame = requestAnimationFrame(() => {
if (rowRef.current) {
activeRef(rowRef.current);
}
});
return () => cancelAnimationFrame(frame);
}, [activeRef, isActive]);
const startRename = () => {
setDraftTitle(title);
setMenuOpen(false);
@@ -116,7 +132,7 @@ export function SidebarChatRow({
return (
// biome-ignore lint/a11y/noStaticElementInteractions: wrapper div for hover detection, interactive content is the inner Button
<div
ref={activeRef}
ref={rowRef}
className={cn(
"flex items-center group rounded-md pr-0.5 transition-colors duration-200",
className,
@@ -0,0 +1,53 @@
import { useEffect } from "react";
const ATTR = "data-keyboard-nav";
const NAVIGATION_KEYS = new Set([
"Tab",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"Enter",
" ",
"Escape",
"Home",
"End",
"PageUp",
"PageDown",
]);
function isKeyboardNavigationEvent(event: KeyboardEvent) {
if (event.key === "Shift" || event.key === "Meta") {
return false;
}
if (event.ctrlKey || event.metaKey) {
return true;
}
return NAVIGATION_KEYS.has(event.key);
}
export function useKeyboardNavDetection() {
useEffect(() => {
const root = document.documentElement;
function onKeyDown(e: KeyboardEvent) {
if (isKeyboardNavigationEvent(e)) {
root.setAttribute(ATTR, "");
}
}
function onPointerDown() {
root.removeAttribute(ATTR);
}
window.addEventListener("keydown", onKeyDown, true);
window.addEventListener("pointerdown", onPointerDown, true);
return () => {
window.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("pointerdown", onPointerDown, true);
};
}, []);
}
+10 -1
View File
@@ -755,13 +755,22 @@ body,
@layer components {
*:not(body):not(.focus-override) {
outline: none !important;
}
html[data-keyboard-nav] *:not(body):not(.focus-override) {
&:focus-visible {
@apply focus-visible:ring-ring focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:outline-hidden;
}
}
.link {
@apply hover:text-foreground/80 focus-visible:ring-ring focus-visible:ring-offset-background inline-flex items-center gap-1 rounded-xs font-medium underline underline-offset-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden;
@apply hover:text-foreground/80 inline-flex items-center gap-1 rounded-xs font-medium underline underline-offset-4;
}
html[data-keyboard-nav] *:not(body):not(.focus-override).link {
&:focus-visible {
@apply ring-ring ring-offset-background ring-2 ring-offset-2 outline-hidden;
}
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ function AccordionTrigger({
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm transition-all outline-none hover:underline focus-visible:ring-[1px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
"flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm transition-all outline-none hover:underline disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
+2 -2
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/cn";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-pill border px-2 py-0.5 text-xs w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[1px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
"inline-flex items-center justify-center rounded-pill border px-2 py-0.5 text-xs w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
@@ -14,7 +14,7 @@ const badgeVariants = cva(
secondary:
"border-transparent bg-muted text-foreground [a&]:hover:bg-muted/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-muted [a&]:hover:text-muted-foreground",
},
+2 -2
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/cn";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-left text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
@@ -19,7 +19,7 @@ const buttonVariants = cva(
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
toolbar:
"justify-start bg-transparent font-normal text-foreground shadow-none hover:bg-accent hover:text-accent-foreground active:bg-accent active:text-accent-foreground focus-visible:ring-0 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground aria-expanded:bg-accent aria-expanded:text-accent-foreground",
"justify-start bg-transparent font-normal text-foreground shadow-none hover:bg-accent hover:text-accent-foreground active:bg-accent active:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground aria-expanded:bg-accent aria-expanded:text-accent-foreground",
link: "text-brand underline-offset-4 hover:underline",
},
size: {
+1 -1
View File
@@ -12,7 +12,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-border-inverse focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border transition-shadow outline-none focus-visible:ring-[1px] disabled:cursor-not-allowed disabled:opacity-50",
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-border-inverse aria-invalid:ring-destructive/20 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border transition-shadow outline-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
+4 -4
View File
@@ -72,7 +72,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-muted focus:text-muted-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-muted data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -90,7 +90,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-muted focus:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-muted relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
@@ -126,7 +126,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-muted focus:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-muted relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -206,7 +206,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-muted focus:text-muted-foreground data-[state=open]:bg-muted data-[state=open]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
"focus:bg-muted data-[state=open]:bg-muted flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
+1 -1
View File
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input hover:border-border-input-hover flex h-9 w-full min-w-0 rounded-input border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[1px]",
"",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
+2 -2
View File
@@ -59,7 +59,7 @@ function NavigationMenuItem({
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-full bg-background px-4 py-2 text-sm hover:bg-muted focus:bg-muted disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-muted data-[state=open]:focus:bg-muted data-[state=open]:bg-muted/50 focus-visible:ring-ring/50 outline-none transition-[color] focus-visible:ring-[1px] focus-visible:outline-1",
"group inline-flex h-9 w-max items-center justify-center rounded-full bg-background px-4 py-2 text-sm hover:bg-muted focus:bg-muted disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-muted data-[state=open]:focus:bg-muted data-[state=open]:bg-muted/50 outline-none transition-[color]",
);
function NavigationMenuTrigger({
@@ -129,7 +129,7 @@ function NavigationMenuLink({
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-muted data-[active=true]:hover:bg-muted data-[active=true]:bg-muted/50 hover:bg-muted focus:bg-muted focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[1px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
"data-[active=true]:focus:bg-muted data-[active=true]:hover:bg-muted data-[active=true]:bg-muted/50 hover:bg-muted focus:bg-muted [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
+1 -1
View File
@@ -25,7 +25,7 @@ function RadioGroupItem({
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary-foreground data-[state=checked]:bg-primary data-[state=checked]:border-none focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive aspect-square size-4 shrink-0 rounded-full border transition-[color,box-shadow] outline-none focus-visible:ring-[1px] disabled:cursor-not-allowed disabled:opacity-50",
"border-input text-primary-foreground data-[state=checked]:bg-primary data-[state=checked]:border-none aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive aspect-square size-4 shrink-0 rounded-full border transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
+1 -1
View File
@@ -37,7 +37,7 @@ function ResizableHandle({
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border-default focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
"bg-border-default relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
+1 -1
View File
@@ -16,7 +16,7 @@ function ScrollArea({
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[1px] focus-visible:outline-1"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none"
>
{children}
</ScrollAreaPrimitive.Viewport>
+1 -1
View File
@@ -35,7 +35,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit items-center justify-between gap-2 rounded-input border bg-transparent px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[1px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit items-center justify-between gap-2 rounded-input border bg-transparent px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
+5 -5
View File
@@ -404,7 +404,7 @@ function SidebarGroupLabel({
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 outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs outline-hidden transition-[margin,opacity] duration-200 ease-linear [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
@@ -425,7 +425,7 @@ function SidebarGroupAction({
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",
"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 [&>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",
@@ -473,7 +473,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
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]: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",
"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 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]: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: {
@@ -560,7 +560,7 @@ function SidebarMenuAction({
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",
"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 [&>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",
@@ -685,7 +685,7 @@ function SidebarMenuSubButton({
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",
"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 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",
+1 -1
View File
@@ -51,7 +51,7 @@ function Slider({
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border transition-[color,box-shadow] hover:ring-4 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
+1 -1
View File
@@ -11,7 +11,7 @@ function Switch({
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-background-medium focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-muted/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:ring-[1px] disabled:cursor-not-allowed disabled:opacity-50",
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-background-medium dark:data-[state=unchecked]:bg-muted/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent transition-all outline-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
+1 -1
View File
@@ -20,7 +20,7 @@ const tabsListVariants = cva(
);
const tabsTriggerVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring inline-flex flex-1 items-center justify-center gap-1.5 whitespace-nowrap transition-[color,box-shadow,background-color,border-color] focus-visible:ring-[1px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"inline-flex flex-1 items-center justify-center gap-1.5 whitespace-nowrap transition-[color,box-shadow,background-color,border-color] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
+1 -1
View File
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base transition-[color,box-shadow] outline-none focus-visible:ring-[1px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"border-input placeholder:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
+1 -1
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/cn";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-full text-sm hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-muted data-[state=on]:text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[1px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
"inline-flex items-center justify-center gap-2 rounded-full text-sm hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-muted data-[state=on]:text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {