* Tree: add isLast prop & draw connectors Introduce an optional isLast prop across all site panel tree node components and TreeNode so children know when they're the last sibling. TreeNodeWrapper now renders vertical and horizontal connector lines (adjusted by depth and isLast) and keeps the toggle button clickable (z-index). Children are passed isLast when mapped, so the UI can stop the vertical line for final siblings. Also bump several deps in apps/editor package.json and update the lockfile accordingly. * chore: clean up monorepo dependencies and update packages Move app/library dependencies out of root package.json into their respective workspaces. Root now only contains monorepo tooling (turbo, biome, supabase CLI, typescript, portless, ultracite). Updates: - next 16.1.0 → 16.1.6 - drizzle-orm ^0.39.0 → ^0.45.1 - drizzle-kit ^0.30.0 → ^0.31.9 - drizzle-zod ^0.5.1 → ^0.8.3 - three-bvh-csg ^0.0.17 → ^0.0.18 - supabase 2.75.3 → 2.76.15 - typescript 5.9.2 → 5.9.3 (all workspaces) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use semantic theme tokens and enable dark mode Replace hardcoded zinc color classes with semantic design tokens (text-muted-foreground, text-foreground, border, bg-background, bg-border) across the action menu, camera controls, control modes, and view toggles for consistent theming. Update separators and borders to use bg-border/border-border. Add useEffect in the editor root to add/remove the 'dark' class on document.body and apply 'dark text-foreground' to the root container so the UI renders in dark mode; also import useEffect. * Persistable sidebar width & resizer Introduce a zustand store to persist sidebar width (key: `sidebar-preferences`) and track dragging state. Default width is 288px (18rem) and is clamped between 288 and 800px. Wire the store into SidebarProvider to drive the CSS variable for width and expose a data-dragging attribute. Add a SidebarResizer component that handles pointer events to resize the sidebar, toggles dragging state, updates cursor/user-select, and disables transitions while dragging. Small class and structure adjustments ensure the resizer is rendered and resizing behaves smoothly. * Editor: floating action menu, selection/phase tweaks Add a FloatingActionMenu (move/duplicate/delete) that attaches to selected 3D nodes and integrates with editor/viewer state; includes new cursor SVG and layout wrapper to use the custom cursor. Improve selection and phase behavior: auto-select first building+level on scene load (moved/centralized in project scene hook and editor init), add resolveBuildingId helper, and enhance SelectionManager to auto-switch between structure/furnish based on clicked nodes and to preserve building/level context when changing selection. Update sidebar tree node click handlers to switch phases for structural vs item nodes and stop hiding nodes by phase (keep tree items visible across phases). Add keyboard escape behavior to clear selections while keeping building/level context and reset selected reference. Misc: register unified event listeners for selection, position floating menu over objects using three.js Box3, and various small integration fixes. * Add SceneLoader, loading states & editor fixes Add a SceneLoader component and associated CSS loader styles and wire it into Editor, Viewer and page-level flows so a loading UI displays while project/scene data loads. Replace useLayoutEffect in editor page with a client-mounted useEffect guard to avoid hydration issues. Introduce isSceneLoading state and setter in the project store and set/clear it in useProjectScene; include sceneGraph when creating a new project and clear the scene beforehand. Other changes: type-guard moving/duplicating logic in FloatingActionMenu and remove duplicated id on clones; refactor auto-selection logic for site/building/level and auto-select build/wall for empty levels in editor index and useProjectScene; optimize ZoneSystem to run per-frame (useFrame) and reduce unnecessary updates to visibility/label positions; add animated active background for layer toggle (motion) in site panel; add keyboard shortcuts to jump between levels (Ctrl/Cmd+ArrowUp/Down); simplify community-hub project-created handler to navigate straight to editor. These changes improve UX during load, enforce safer node operations, and optimize runtime rendering. * Improve selection logic and refine UI components Multiple editor and viewer updates: - Selection: enhance selection-manager to support modifier (Meta/Ctrl) multi-select, compute next selection logic, track modifier keys, prevent grid deselect races, and add debug logs. Adapted selection handlers throughout scene/editor components to pass native events and modifier state. - Sidebar / Site panel: refactor site panel to inline a LevelReferences UI (replacing the removed references dialog). Add upload flow for scan (.glb/.gltf) and guide images, handling file size/type checks, project asset upload/delete calls, and per-level reference lists. Add MultiSelectionBadge for showing/clearing multi-selection and animated expand/collapse for levels. Several tree-node components now use a shared handleTreeSelection helper and propagate click events properly. - UI polish & icons: change dark theme color tokens for better contrast; increase button sizes and icon sizes across action menus; swap some lucide icons for image assets (rotate, topview, select, build, mesh, floorplan, level, etc.) and add new public icons. - PascalRadio: replace popover with a framer-motion expandable panel, add outside-click handling, animated transitions, and minor UI tweaks. - Other: minor viewer hook/selection-manager updates and tweaks to control modes and view toggles to support image icons and consistent sizing. These changes improve multi-select reliability, user interactions in the sidebar, and overall visual polish with new icons and animations. * Fix TypeScript build errors in selection-manager and scene-loader Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
875 lines
25 KiB
TypeScript
875 lines
25 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 { create } from "zustand";
|
|
import { persist } from "zustand/middleware";
|
|
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 SidebarStore = {
|
|
width: number;
|
|
setWidth: (width: number) => void;
|
|
isDragging: boolean;
|
|
setIsDragging: (isDragging: boolean) => void;
|
|
};
|
|
|
|
export const useSidebarStore = create<SidebarStore>()(
|
|
persist(
|
|
(set) => ({
|
|
width: 288, // 18rem = 288px
|
|
setWidth: (width) => set({ width: Math.max(288, Math.min(width, 800)) }),
|
|
isDragging: false,
|
|
setIsDragging: (isDragging) => set({ isDragging }),
|
|
}),
|
|
{
|
|
name: "sidebar-preferences",
|
|
partialize: (state) => ({ width: state.width }), // Only persist width
|
|
}
|
|
)
|
|
);
|
|
|
|
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);
|
|
const sidebarWidth = useSidebarStore((state) => state.width);
|
|
const isDragging = useSidebarStore((state) => state.isDragging);
|
|
|
|
// 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"
|
|
data-dragging={isDragging}
|
|
style={
|
|
{
|
|
"--sidebar-width": `${sidebarWidth}px`,
|
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
|
...style,
|
|
} as React.CSSProperties
|
|
}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
</TooltipProvider>
|
|
</SidebarContext.Provider>
|
|
);
|
|
}
|
|
|
|
function SidebarResizer({ side }: { side: "left" | "right" }) {
|
|
const setWidth = useSidebarStore((state) => state.setWidth);
|
|
const setIsDragging = useSidebarStore((state) => state.setIsDragging);
|
|
const isResizing = React.useRef(false);
|
|
|
|
const handlePointerDown = (e: React.PointerEvent) => {
|
|
isResizing.current = true;
|
|
setIsDragging(true);
|
|
document.body.style.cursor = "col-resize";
|
|
document.body.style.userSelect = "none";
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
const handlePointerMove = (e: PointerEvent) => {
|
|
if (!isResizing.current) return;
|
|
const newWidth = side === "left" ? e.clientX : window.innerWidth - e.clientX;
|
|
setWidth(Math.max(288, Math.min(newWidth, 800)));
|
|
};
|
|
|
|
const handlePointerUp = () => {
|
|
isResizing.current = false;
|
|
setIsDragging(false);
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
};
|
|
|
|
window.addEventListener("pointermove", handlePointerMove);
|
|
window.addEventListener("pointerup", handlePointerUp);
|
|
|
|
return () => {
|
|
window.removeEventListener("pointermove", handlePointerMove);
|
|
window.removeEventListener("pointerup", handlePointerUp);
|
|
};
|
|
}, [setWidth, side]);
|
|
|
|
return (
|
|
<div
|
|
onPointerDown={handlePointerDown}
|
|
className={cn(
|
|
"absolute top-0 bottom-0 w-2 cursor-col-resize z-50 hover:bg-primary/50 transition-colors",
|
|
side === "left" ? "-right-1" : "-left-1"
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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-[dragging=true]/sidebar-wrapper:transition-none",
|
|
"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",
|
|
"group-data-[dragging=true]/sidebar-wrapper:transition-none",
|
|
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 relative"
|
|
data-sidebar="sidebar"
|
|
data-slot="sidebar-inner"
|
|
>
|
|
{children}
|
|
<SidebarResizer side={side} />
|
|
</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,
|
|
};
|