* Remove .env.local guard; add portless dep
Remove the check that blocked a local .env.local in apps/editor's dev script and simplify the dev startup to source the root .env. Add portless@^0.4.2 to the project dependencies in package.json. Lockfile updated accordingly.
* Improve tool cursors, previews, and contextual tools
Revamp editor tool visuals and behaviour: replace mesh cursors with a new CursorSphere Group (adds ground marker, vertical indicator and optional tool icon tooltip), unify preview colors to #818cf8, add ground-level previews and ghost lines for ceiling and roof tools, and update slab/zone/wall visuals to match. Add automatic build-mode switching and contextual highlighting for structure tools via a new useContextualTools hook, and relax action-menu visibility checks so tool rows are shown even when not in build mode. Minor typing/import adjustments and small opacity/placement tweaks across multiple tool components.
* Keep tools active and refine action-menu styles
Stop auto-deactivating tools in RoofTool and ZoneTool by removing setTool(null) (and setMode('select') in roof). Add hasActiveTool checks and small layout padding to FurnishTools and StructureTools, and update button classes to improve visual emphasis: smoother transitions, stronger active rings/scale, and dimming/grayscale of non-active tools when another tool is active. These changes improve UX by keeping the selected tool active and giving clearer visual feedback in the action menu.
* Improve selection behavior and refresh UI styling
SelectionManager: add global enter/leave/double-click handlers to support cross-phase hovering and auto-switching into structure/furnish on double-click; normalize click handling (explicitly coalesce shiftKey). Remove per-strategy enter/leave listeners.
UI panels & controls: unify and modernize styles across many panels (ceiling, door, item, reference, roof, slab, wall, window) and action buttons — rounded-md, neutral borders, subtle shadows, translucent headers and consistent hover/focus states. Update view toggles icons and toggle styling.
NumberInput: integrate @number-flow/react NumberFlow for formatted value display, add visual drag progress indicator and refine input/label interactions and styling.
Sidebar: replace rename-popover with inline-rename-input and update tree nodes to use inline editing. Add slider-demo and tweak slider implementation. Update package.json accordingly.
* Use Barlow font across editor UI
Add the Google Barlow font and expose it as a CSS variable, update theme font variables in globals.css, and apply the new font across the editor UI. Imports: add Barlow in app/layout.tsx and include its variable on <html>, set body to use font-sans. Apply font-barlow or font-mono classes to headings, labels, numeric readouts and buttons across multiple panels and primitives to standardize typography. Also update button base variant to include font-barlow. Remove the PhaseSwitcher component and its import/usage from the action menu.
* Replace button wrapper with div in LevelItem
Replace the outer <button> around the LevelItem content with a <div> to avoid nesting interactive button elements (and the resulting invalid HTML/interaction issues). Preserves the original classes and onClick/onDoubleClick handlers so behavior remains the same and allows the PopoverTrigger/button children to function correctly.
* Refine site panel list styles and spacing
Update the SitePanel and TreeNode UI to use compact, border-bottom list rows instead of rounded, card-like items. Simplified active/hover states to use bg-accent variants and reduced shadow/ring styles for a cleaner look. Adjusted paddings (pl/px/py) and container layouts (many sections switched to flex-col) and increased tree node indent/paddingRight for consistent alignment. These changes unify spacing and visual hierarchy across levels, buildings, zones and tree nodes.
* Add auto-scroll to selected items and refine icon styles
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
799 lines
23 KiB
TypeScript
799 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { Slot } from "@radix-ui/react-slot";
|
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
import { PanelLeftIcon } from "lucide-react";
|
|
import * as React from "react";
|
|
import { Button } from "@/components/ui/primitives/button";
|
|
import { Input } from "@/components/ui/primitives/input";
|
|
import { Separator } from "@/components/ui/primitives/separator";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/components/ui/primitives/sheet";
|
|
import { Skeleton } from "@/components/ui/primitives/skeleton";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/primitives/tooltip";
|
|
import { useIsMobile } from "@/hooks/use-mobile";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
|
const SIDEBAR_WIDTH = "18rem";
|
|
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
|
const SIDEBAR_WIDTH_ICON = "3rem";
|
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
|
|
|
type SidebarContextProps = {
|
|
state: "expanded" | "collapsed";
|
|
open: boolean;
|
|
setOpen: (open: boolean) => void;
|
|
openMobile: boolean;
|
|
setOpenMobile: (open: boolean) => void;
|
|
isMobile: boolean;
|
|
toggleSidebar: () => void;
|
|
};
|
|
|
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
|
|
|
function useSidebar() {
|
|
const context = React.useContext(SidebarContext);
|
|
if (!context) {
|
|
throw new Error("useSidebar must be used within a SidebarProvider.");
|
|
}
|
|
|
|
return context;
|
|
}
|
|
|
|
function SidebarProvider({
|
|
defaultOpen = true,
|
|
open: openProp,
|
|
onOpenChange: setOpenProp,
|
|
className,
|
|
style,
|
|
children,
|
|
...props
|
|
}: React.ComponentProps<"div"> & {
|
|
defaultOpen?: boolean;
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
}) {
|
|
const isMobile = useIsMobile();
|
|
const [openMobile, setOpenMobile] = React.useState(false);
|
|
|
|
// This is the internal state of the sidebar.
|
|
// We use openProp and setOpenProp for control from outside the component.
|
|
const [_open, _setOpen] = React.useState(defaultOpen);
|
|
const open = openProp ?? _open;
|
|
const setOpen = React.useCallback(
|
|
(value: boolean | ((value: boolean) => boolean)) => {
|
|
const openState = typeof value === "function" ? value(open) : value;
|
|
if (setOpenProp) {
|
|
setOpenProp(openState);
|
|
} else {
|
|
_setOpen(openState);
|
|
}
|
|
|
|
// This sets the cookie to keep the sidebar state.
|
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
|
},
|
|
[setOpenProp, open],
|
|
);
|
|
|
|
// Helper to toggle the sidebar.
|
|
const toggleSidebar = React.useCallback(
|
|
() =>
|
|
isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open),
|
|
[isMobile, setOpen],
|
|
);
|
|
|
|
// Adds a keyboard shortcut to toggle the sidebar.
|
|
React.useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (
|
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
|
(event.metaKey || event.ctrlKey)
|
|
) {
|
|
event.preventDefault();
|
|
toggleSidebar();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [toggleSidebar]);
|
|
|
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
|
// This makes it easier to style the sidebar with Tailwind classes.
|
|
const state = open ? "expanded" : "collapsed";
|
|
|
|
const contextValue = React.useMemo<SidebarContextProps>(
|
|
() => ({
|
|
state,
|
|
open,
|
|
setOpen,
|
|
isMobile,
|
|
openMobile,
|
|
setOpenMobile,
|
|
toggleSidebar,
|
|
}),
|
|
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
|
|
);
|
|
|
|
return (
|
|
<SidebarContext.Provider value={contextValue}>
|
|
<TooltipProvider delayDuration={0}>
|
|
<div
|
|
className={cn(
|
|
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar pointer-events-none",
|
|
className,
|
|
)}
|
|
data-slot="sidebar-wrapper"
|
|
style={
|
|
{
|
|
"--sidebar-width": SIDEBAR_WIDTH,
|
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
|
...style,
|
|
} as React.CSSProperties
|
|
}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
</TooltipProvider>
|
|
</SidebarContext.Provider>
|
|
);
|
|
}
|
|
|
|
function Sidebar({
|
|
side = "left",
|
|
variant = "sidebar",
|
|
collapsible = "offcanvas",
|
|
className,
|
|
children,
|
|
...props
|
|
}: React.ComponentProps<"div"> & {
|
|
side?: "left" | "right";
|
|
variant?: "sidebar" | "floating" | "inset";
|
|
collapsible?: "offcanvas" | "icon" | "none";
|
|
}) {
|
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
|
|
|
if (collapsible === "none") {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
|
className,
|
|
)}
|
|
data-slot="sidebar"
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>
|
|
<SheetContent
|
|
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
|
data-mobile="true"
|
|
data-sidebar="sidebar"
|
|
data-slot="sidebar"
|
|
side={side}
|
|
style={
|
|
{
|
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
<SheetHeader className="sr-only">
|
|
<SheetTitle>Sidebar</SheetTitle>
|
|
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
|
</SheetHeader>
|
|
<div className="flex h-full w-full flex-col">{children}</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="group peer hidden text-sidebar-foreground md:block "
|
|
data-collapsible={state === "collapsed" ? collapsible : ""}
|
|
data-side={side}
|
|
data-slot="sidebar"
|
|
data-state={state}
|
|
data-variant={variant}
|
|
>
|
|
{/* This is what handles the sidebar gap on desktop */}
|
|
<div
|
|
className={cn(
|
|
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
|
"group-data-[collapsible=offcanvas]:w-0",
|
|
"group-data-[side=right]:rotate-180",
|
|
variant === "floating" || variant === "inset"
|
|
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
|
)}
|
|
data-slot="sidebar-gap"
|
|
/>
|
|
<div
|
|
className={cn(
|
|
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex pointer-events-auto",
|
|
side === "left"
|
|
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
|
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
|
// Adjust the padding for floating and inset variants.
|
|
variant === "floating" || variant === "inset"
|
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
|
className,
|
|
)}
|
|
data-slot="sidebar-container"
|
|
{...props}
|
|
>
|
|
<div
|
|
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm pointer-events-auto"
|
|
data-sidebar="sidebar"
|
|
data-slot="sidebar-inner"
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SidebarTrigger({
|
|
className,
|
|
onClick,
|
|
...props
|
|
}: React.ComponentProps<typeof Button>) {
|
|
const { toggleSidebar } = useSidebar();
|
|
|
|
return (
|
|
<Button
|
|
className={cn("size-7", className)}
|
|
data-sidebar="trigger"
|
|
data-slot="sidebar-trigger"
|
|
onClick={(event) => {
|
|
onClick?.(event);
|
|
toggleSidebar();
|
|
}}
|
|
size="icon"
|
|
variant="ghost"
|
|
{...props}
|
|
>
|
|
<PanelLeftIcon />
|
|
<span className="sr-only">Toggle Sidebar</span>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
|
const { toggleSidebar } = useSidebar();
|
|
|
|
return (
|
|
<button
|
|
aria-label="Toggle Sidebar"
|
|
className={cn(
|
|
"-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex",
|
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
|
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
|
"group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
|
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
|
className,
|
|
)}
|
|
data-sidebar="rail"
|
|
data-slot="sidebar-rail"
|
|
onClick={toggleSidebar}
|
|
tabIndex={-1}
|
|
title="Toggle Sidebar"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
|
return (
|
|
<main
|
|
className={cn(
|
|
"relative flex w-full flex-1 flex-col bg-background",
|
|
"md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
|
|
className,
|
|
)}
|
|
data-slot="sidebar-inset"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarInput({
|
|
className,
|
|
...props
|
|
}: React.ComponentProps<typeof Input>) {
|
|
return (
|
|
<Input
|
|
className={cn("h-8 w-full bg-background shadow-none", className)}
|
|
data-sidebar="input"
|
|
data-slot="sidebar-input"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
return (
|
|
<div
|
|
className={cn("flex flex-col gap-2 p-2", className)}
|
|
data-sidebar="header"
|
|
data-slot="sidebar-header"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
return (
|
|
<div
|
|
className={cn("flex flex-col gap-2 p-2", className)}
|
|
data-sidebar="footer"
|
|
data-slot="sidebar-footer"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarSeparator({
|
|
className,
|
|
...props
|
|
}: React.ComponentProps<typeof Separator>) {
|
|
return (
|
|
<Separator
|
|
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
|
data-sidebar="separator"
|
|
data-slot="sidebar-separator"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
|
className,
|
|
)}
|
|
data-sidebar="content"
|
|
data-slot="sidebar-content"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
return (
|
|
<div
|
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
|
data-sidebar="group"
|
|
data-slot="sidebar-group"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarGroupLabel({
|
|
className,
|
|
asChild = false,
|
|
ref,
|
|
...props
|
|
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
|
if (asChild) {
|
|
return (
|
|
<Slot
|
|
className={cn(
|
|
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium font-barlow text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
|
className,
|
|
)}
|
|
data-sidebar="group-label"
|
|
data-slot="sidebar-group-label"
|
|
ref={ref as never}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium font-barlow text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
|
className,
|
|
)}
|
|
data-sidebar="group-label"
|
|
data-slot="sidebar-group-label"
|
|
ref={ref}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarGroupAction({
|
|
className,
|
|
asChild = false,
|
|
ref,
|
|
...props
|
|
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
|
const classes = cn(
|
|
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
|
"after:-inset-2 after:absolute md:after:hidden",
|
|
"group-data-[collapsible=icon]:hidden",
|
|
className,
|
|
);
|
|
|
|
if (asChild) {
|
|
return (
|
|
<Slot
|
|
className={classes}
|
|
data-sidebar="group-action"
|
|
data-slot="sidebar-group-action"
|
|
ref={ref as never}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
className={classes}
|
|
data-sidebar="group-action"
|
|
data-slot="sidebar-group-action"
|
|
ref={ref}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarGroupContent({
|
|
className,
|
|
...props
|
|
}: React.ComponentProps<"div">) {
|
|
return (
|
|
<div
|
|
className={cn("w-full text-sm", className)}
|
|
data-sidebar="group-content"
|
|
data-slot="sidebar-group-content"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
|
return (
|
|
<ul
|
|
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
|
data-sidebar="menu"
|
|
data-slot="sidebar-menu"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
|
return (
|
|
<li
|
|
className={cn("group/menu-item relative", className)}
|
|
data-sidebar="menu-item"
|
|
data-slot="sidebar-menu-item"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const sidebarMenuButtonVariants = cva(
|
|
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left font-barlow text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
|
{
|
|
variants: {
|
|
variant: {
|
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
|
outline:
|
|
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
|
},
|
|
size: {
|
|
default: "h-8 text-sm",
|
|
sm: "h-7 text-xs",
|
|
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
variant: "default",
|
|
size: "default",
|
|
},
|
|
},
|
|
);
|
|
|
|
function SidebarMenuButton({
|
|
asChild = false,
|
|
isActive = false,
|
|
variant = "default",
|
|
size = "default",
|
|
tooltip,
|
|
className,
|
|
ref,
|
|
...props
|
|
}: React.ComponentProps<"button"> & {
|
|
asChild?: boolean;
|
|
isActive?: boolean;
|
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
|
const { isMobile, state } = useSidebar();
|
|
const classes = cn(sidebarMenuButtonVariants({ variant, size }), className);
|
|
|
|
const button = asChild ? (
|
|
<Slot
|
|
className={classes}
|
|
data-active={isActive}
|
|
data-sidebar="menu-button"
|
|
data-size={size}
|
|
data-slot="sidebar-menu-button"
|
|
ref={ref as never}
|
|
{...props}
|
|
/>
|
|
) : (
|
|
<button
|
|
className={classes}
|
|
data-active={isActive}
|
|
data-sidebar="menu-button"
|
|
data-size={size}
|
|
data-slot="sidebar-menu-button"
|
|
ref={ref}
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
if (!tooltip) {
|
|
return button;
|
|
}
|
|
|
|
if (typeof tooltip === "string") {
|
|
tooltip = {
|
|
children: tooltip,
|
|
};
|
|
}
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
|
<TooltipContent
|
|
align="center"
|
|
hidden={state !== "collapsed" || isMobile}
|
|
side="right"
|
|
{...tooltip}
|
|
/>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function SidebarMenuAction({
|
|
className,
|
|
asChild = false,
|
|
showOnHover = false,
|
|
ref,
|
|
...props
|
|
}: React.ComponentProps<"button"> & {
|
|
asChild?: boolean;
|
|
showOnHover?: boolean;
|
|
}) {
|
|
const classes = cn(
|
|
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
|
"after:-inset-2 after:absolute md:after:hidden",
|
|
"peer-data-[size=sm]/menu-button:top-1",
|
|
"peer-data-[size=default]/menu-button:top-1.5",
|
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
|
"group-data-[collapsible=icon]:hidden",
|
|
showOnHover &&
|
|
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
|
className,
|
|
);
|
|
|
|
if (asChild) {
|
|
return (
|
|
<Slot
|
|
className={classes}
|
|
data-sidebar="menu-action"
|
|
data-slot="sidebar-menu-action"
|
|
ref={ref as never}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
className={classes}
|
|
data-sidebar="menu-action"
|
|
data-slot="sidebar-menu-action"
|
|
ref={ref}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarMenuBadge({
|
|
className,
|
|
...props
|
|
}: React.ComponentProps<"div">) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 font-medium text-sidebar-foreground text-xs tabular-nums",
|
|
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
|
"peer-data-[size=sm]/menu-button:top-1",
|
|
"peer-data-[size=default]/menu-button:top-1.5",
|
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
|
"group-data-[collapsible=icon]:hidden",
|
|
className,
|
|
)}
|
|
data-sidebar="menu-badge"
|
|
data-slot="sidebar-menu-badge"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarMenuSkeleton({
|
|
className,
|
|
showIcon = false,
|
|
...props
|
|
}: React.ComponentProps<"div"> & {
|
|
showIcon?: boolean;
|
|
}) {
|
|
// Random width between 50 to 90%.
|
|
const width = React.useMemo(
|
|
() => `${Math.floor(Math.random() * 40) + 50}%`,
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
|
data-sidebar="menu-skeleton"
|
|
data-slot="sidebar-menu-skeleton"
|
|
{...props}
|
|
>
|
|
{showIcon && (
|
|
<Skeleton
|
|
className="size-4 rounded-md"
|
|
data-sidebar="menu-skeleton-icon"
|
|
/>
|
|
)}
|
|
<Skeleton
|
|
className="h-4 max-w-(--skeleton-width) flex-1"
|
|
data-sidebar="menu-skeleton-text"
|
|
style={
|
|
{
|
|
"--skeleton-width": width,
|
|
} as React.CSSProperties
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
|
return (
|
|
<ul
|
|
className={cn(
|
|
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5",
|
|
"group-data-[collapsible=icon]:hidden",
|
|
className,
|
|
)}
|
|
data-sidebar="menu-sub"
|
|
data-slot="sidebar-menu-sub"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarMenuSubItem({
|
|
className,
|
|
...props
|
|
}: React.ComponentProps<"li">) {
|
|
return (
|
|
<li
|
|
className={cn("group/menu-sub-item relative", className)}
|
|
data-sidebar="menu-sub-item"
|
|
data-slot="sidebar-menu-sub-item"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SidebarMenuSubButton({
|
|
asChild = false,
|
|
size = "md",
|
|
isActive = false,
|
|
className,
|
|
ref,
|
|
...props
|
|
}: React.ComponentProps<"a"> & {
|
|
asChild?: boolean;
|
|
size?: "sm" | "md";
|
|
isActive?: boolean;
|
|
}) {
|
|
const classes = cn(
|
|
"-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 font-barlow text-sidebar-foreground outline-hidden ring-sidebar-ring 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 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
|
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
|
size === "sm" && "text-xs",
|
|
size === "md" && "text-sm",
|
|
"group-data-[collapsible=icon]:hidden",
|
|
className,
|
|
);
|
|
|
|
if (asChild) {
|
|
return (
|
|
<Slot
|
|
className={classes}
|
|
data-active={isActive}
|
|
data-sidebar="menu-sub-button"
|
|
data-size={size}
|
|
data-slot="sidebar-menu-sub-button"
|
|
ref={ref as never}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<a
|
|
className={classes}
|
|
data-active={isActive}
|
|
data-sidebar="menu-sub-button"
|
|
data-size={size}
|
|
data-slot="sidebar-menu-sub-button"
|
|
ref={ref}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarGroup,
|
|
SidebarGroupAction,
|
|
SidebarGroupContent,
|
|
SidebarGroupLabel,
|
|
SidebarHeader,
|
|
SidebarInput,
|
|
SidebarInset,
|
|
SidebarMenu,
|
|
SidebarMenuAction,
|
|
SidebarMenuBadge,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarMenuSkeleton,
|
|
SidebarMenuSub,
|
|
SidebarMenuSubButton,
|
|
SidebarMenuSubItem,
|
|
SidebarProvider,
|
|
SidebarRail,
|
|
SidebarSeparator,
|
|
SidebarTrigger,
|
|
useSidebar,
|
|
};
|