S6: refactor UI to shadcn components, design system enforcement

- App: use Sheet for mobile sidebar (proper shadcn component)
- ComposeBox: use ToggleGroup + Button + Input (no raw HTML)
- Use Tailwind text scale (text-xs, text-sm) instead of arbitrary text-[10px]
- Design system rule expanded with color palette, forbidden patterns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 19:56:44 +02:00
parent 791d7e2f69
commit af183abc42
7 changed files with 514 additions and 96 deletions

View File

@@ -1,27 +1,28 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { Channel } from "@/types/Channel"; import type { Channel } from "@/types/Channel";
import type { Message } from "@/types/Message"; import type { Message } from "@/types/Message";
import { getChannels, getMessages, getCurrentUsername } from "@/api"; import { getChannels, getMessages } from "@/api";
import { ChannelSidebar } from "@/components/ChannelSidebar"; import { ChannelSidebar } from "@/components/ChannelSidebar";
import { MessageItem } from "@/components/MessageItem"; import { MessageItem } from "@/components/MessageItem";
import { ComposeBox } from "@/components/ComposeBox"; import { ComposeBox } from "@/components/ComposeBox";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
export default function App() { export default function App() {
const [channels, setChannels] = useState<Channel[]>([]); const [channels, setChannels] = useState<Channel[]>([]);
const [activeChannelId, setActiveChannelId] = useState<string | null>(null); const [activeChannelId, setActiveChannelId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [replyTo, setReplyTo] = useState<string | null>(null); const [replyTo, setReplyTo] = useState<string | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const prevMsgCountRef = useRef(0); const prevMsgCountRef = useRef(0);
const activeChannelRef = useRef(activeChannelId); const activeChannelRef = useRef(activeChannelId);
// Keep ref in sync
activeChannelRef.current = activeChannelId; activeChannelRef.current = activeChannelId;
const loadChannels = useCallback(async () => { const loadChannels = useCallback(async () => {
const chs = await getChannels(); const chs = await getChannels();
setChannels(chs); setChannels(chs);
// Auto-select first channel only if none selected
setActiveChannelId((prev) => (prev ? prev : chs[0]?.id ?? null)); setActiveChannelId((prev) => (prev ? prev : chs[0]?.id ?? null));
}, []); }, []);
@@ -30,7 +31,6 @@ export default function App() {
if (!channelId) return; if (!channelId) return;
try { try {
const msgs = await getMessages(channelId); const msgs = await getMessages(channelId);
// Only update if we're still on the same channel (prevent race)
if (activeChannelRef.current === channelId) { if (activeChannelRef.current === channelId) {
setMessages(msgs); setMessages(msgs);
} }
@@ -39,20 +39,15 @@ export default function App() {
} }
}, []); }, []);
// Initial channel load useEffect(() => { loadChannels(); }, [loadChannels]);
useEffect(() => {
loadChannels();
}, [loadChannels]);
// Load messages on channel switch
useEffect(() => { useEffect(() => {
setMessages([]); // Clear immediately on switch setMessages([]);
setReplyTo(null); setReplyTo(null);
prevMsgCountRef.current = 0; prevMsgCountRef.current = 0;
loadMessages(); loadMessages();
}, [activeChannelId, loadMessages]); }, [activeChannelId, loadMessages]);
// Auto-scroll only when NEW messages arrive (not on every poll)
useEffect(() => { useEffect(() => {
if (messages.length > prevMsgCountRef.current && scrollRef.current) { if (messages.length > prevMsgCountRef.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
@@ -60,7 +55,6 @@ export default function App() {
prevMsgCountRef.current = messages.length; prevMsgCountRef.current = messages.length;
}, [messages]); }, [messages]);
// Poll until WebSocket (S5)
useEffect(() => { useEffect(() => {
const interval = setInterval(loadMessages, 3000); const interval = setInterval(loadMessages, 3000);
return () => clearInterval(interval); return () => clearInterval(interval);
@@ -68,69 +62,63 @@ export default function App() {
const messagesById = new Map(messages.map((m) => [m.id, m])); const messagesById = new Map(messages.map((m) => [m.id, m]));
const activeChannel = channels.find((c) => c.id === activeChannelId); const activeChannel = channels.find((c) => c.id === activeChannelId);
const [sidebarOpen, setSidebarOpen] = useState(false);
return ( const sidebar = (
<div className="flex h-full relative">
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={() => setSidebarOpen(false)}
onKeyDown={() => {}}
role="button"
tabIndex={-1}
/>
)}
<div className={`${sidebarOpen ? "fixed inset-y-0 left-0 z-50" : "hidden"} md:relative md:block`}>
<ChannelSidebar <ChannelSidebar
channels={channels} channels={channels}
activeId={activeChannelId} activeId={activeChannelId}
onSelect={(id) => { onSelect={(id) => {
setActiveChannelId(id); setActiveChannelId(id);
setSidebarOpen(false); setSheetOpen(false);
}} }}
onChannelCreated={loadChannels} onChannelCreated={loadChannels}
/> />
);
return (
<div className="flex h-full">
{/* Desktop sidebar */}
<div className="hidden md:block">
{sidebar}
</div> </div>
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
{/* Channel header */} {/* Channel header */}
<div className="px-4 py-2 border-b border-border flex items-center gap-2"> <div className="px-3 py-2 md:px-4 border-b border-border flex items-center gap-2">
<button {/* Mobile: Sheet trigger */}
type="button" <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
onClick={() => setSidebarOpen(true)} <SheetTrigger asChild>
className="md:hidden text-muted-foreground hover:text-foreground text-[14px] mr-1" <Button variant="ghost" size="sm" className="md:hidden p-1 h-8 w-8 text-muted-foreground">
>
= =
</button> </Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-64 bg-background">
{sidebar}
</SheetContent>
</Sheet>
{activeChannel ? ( {activeChannel ? (
<> <>
<span className="text-muted-foreground">#</span> <span className="text-muted-foreground">#</span>
<span className="font-bold text-[14px]"> <span className="font-bold text-sm">{activeChannel.name}</span>
{activeChannel.name}
</span>
{activeChannel.description && ( {activeChannel.description && (
<span className="text-[11px] text-muted-foreground ml-2"> <span className="text-xs text-muted-foreground ml-2 hidden md:inline">
{activeChannel.description} {activeChannel.description}
</span> </span>
)} )}
<span className="ml-auto text-[10px] text-muted-foreground tabular-nums"> <span className="ml-auto text-xs text-muted-foreground tabular-nums">
{messages.length} msg {messages.length} msg
</span> </span>
</> </>
) : ( ) : (
<span className="text-muted-foreground text-[12px]"> <span className="text-muted-foreground text-xs">select a channel</span>
select a channel
</span>
)} )}
</div> </div>
{/* Messages */} {/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto"> <div ref={scrollRef} className="flex-1 overflow-y-auto">
{messages.length === 0 && activeChannelId && ( {messages.length === 0 && activeChannelId && (
<div className="flex items-center justify-center h-full text-muted-foreground text-[12px]"> <div className="flex items-center justify-center h-full text-muted-foreground text-xs">
no messages yet no messages yet
</div> </div>
)} )}
@@ -138,15 +126,12 @@ export default function App() {
<MessageItem <MessageItem
key={msg.id} key={msg.id}
message={msg} message={msg}
replyTarget={ replyTarget={msg.reply_to ? messagesById.get(msg.reply_to) : undefined}
msg.reply_to ? messagesById.get(msg.reply_to) : undefined
}
onReply={setReplyTo} onReply={setReplyTo}
/> />
))} ))}
</div> </div>
{/* Compose — key forces reset on channel switch */}
{activeChannelId && ( {activeChannelId && (
<ComposeBox <ComposeBox
key={activeChannelId} key={activeChannelId}

View File

@@ -1,6 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import type { MessageType } from "@/types/MessageType"; import type { MessageType } from "@/types/MessageType";
import { postMessage } from "@/api"; import { postMessage } from "@/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
interface Props { interface Props {
channelId: string; channelId: string;
@@ -9,12 +12,12 @@ interface Props {
onMessageSent: () => void; onMessageSent: () => void;
} }
const TYPES: { value: MessageType; label: string; shortLabel: string }[] = [ const MSG_TYPES: { value: MessageType; label: string }[] = [
{ value: "text", label: "TXT", shortLabel: "T" }, { value: "text", label: "T" },
{ value: "code", label: "CODE", shortLabel: "C" }, { value: "code", label: "C" },
{ value: "result", label: "RES", shortLabel: "R" }, { value: "result", label: "R" },
{ value: "error", label: "ERR", shortLabel: "E" }, { value: "error", label: "E" },
{ value: "plan", label: "PLAN", shortLabel: "P" }, { value: "plan", label: "P" },
]; ];
export function ComposeBox({ export function ComposeBox({
@@ -48,42 +51,33 @@ export function ComposeBox({
return ( return (
<div className="border-t border-border bg-card px-3 py-2 md:px-4 md:py-3 pb-[env(safe-area-inset-bottom,8px)]"> <div className="border-t border-border bg-card px-3 py-2 md:px-4 md:py-3 pb-[env(safe-area-inset-bottom,8px)]">
{replyTo && ( {replyTo && (
<div className="flex items-center gap-2 mb-1.5 text-[10px] md:text-[11px] text-muted-foreground"> <div className="flex items-center gap-2 mb-1.5 text-xs text-muted-foreground">
<span>^ #{replyTo.slice(0, 8)}</span> <span>^ #{replyTo.slice(0, 8)}</span>
<button <Button variant="ghost" size="sm" onClick={onClearReply} className="h-6 px-1 text-xs">
type="button"
onClick={onClearReply}
className="hover:text-foreground min-w-[44px] min-h-[32px] flex items-center justify-center md:min-w-0 md:min-h-0"
>
[x] [x]
</button> </Button>
</div> </div>
)} )}
<div className="flex items-center gap-1.5 md:gap-2"> <div className="flex items-center gap-1.5 md:gap-2">
{/* Type selector — compact on mobile */} <ToggleGroup
<div className="flex gap-0.5"> type="single"
{TYPES.map((t) => ( value={msgType}
<button onValueChange={(v) => { if (v) setMsgType(v as MessageType); }}
type="button" className="gap-0.5"
key={t.value}
onClick={() => setMsgType(t.value)}
className={`px-1 md:px-1.5 py-1 md:py-0.5 text-[9px] md:text-[10px] font-bold rounded-sm transition-colors min-w-[28px] md:min-w-0 min-h-[36px] md:min-h-0 flex items-center justify-center ${
msgType === t.value
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
> >
{/* Short label on mobile, full on desktop */} {MSG_TYPES.map((t) => (
<span className="md:hidden">{t.shortLabel}</span> <ToggleGroupItem
<span className="hidden md:inline">{t.label}</span> key={t.value}
</button> value={t.value}
className="h-8 w-8 md:h-7 md:w-auto md:px-2 text-xs font-bold data-[state=on]:bg-primary data-[state=on]:text-primary-foreground"
>
{t.label}
</ToggleGroupItem>
))} ))}
</div> </ToggleGroup>
{/* Input — larger touch target on mobile */} <Input
<input
type="text"
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -91,23 +85,20 @@ export function ComposeBox({
e.preventDefault(); e.preventDefault();
handleSend(); handleSend();
} }
if (e.altKey && e.key >= "1" && e.key <= "5") {
setMsgType(TYPES[parseInt(e.key) - 1].value);
}
}} }}
placeholder="message..." placeholder="message..."
disabled={sending} disabled={sending}
className="flex-1 bg-input text-[13px] md:text-[13px] text-foreground placeholder:text-muted-foreground px-3 py-2 md:py-1.5 rounded-sm border border-border focus:outline-none focus:border-blue-500/30" className="flex-1 h-9 md:h-8 text-sm"
/> />
<button <Button
type="button"
onClick={handleSend} onClick={handleSend}
disabled={sending || !content.trim()} disabled={sending || !content.trim()}
className="px-3 py-2 md:py-1.5 text-[11px] font-bold bg-primary text-primary-foreground rounded-sm hover:opacity-80 disabled:opacity-30 transition-opacity min-w-[44px] min-h-[36px] md:min-h-0 flex items-center justify-center" size="sm"
className="h-9 md:h-8 px-3 text-xs font-bold"
> >
{sending ? "..." : "SEND"} {sending ? "..." : "SEND"}
</button> </Button>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,107 @@
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -0,0 +1,136 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 0,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,44 @@
"use client"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default: "h-8 min-w-8 px-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-1.5 text-[0.8rem]",
lg: "h-9 min-w-9 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }