A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: reorganize the nav

Trezy ffa5dff9 b44cb3a4

+796 -226
+223 -102
web/src/components/app-sidebar.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 3 import { 4 4 IconDashboard, ··· 7 7 IconTable, 8 8 IconClipboardList, 9 9 IconUsers, 10 - IconSettings, 11 10 IconLogout, 12 11 IconKey, 13 12 IconVariable, 14 13 IconTag, 15 - IconChevronRight, 16 14 IconLink, 17 15 IconPuzzle, 18 - IconLockAccess, 16 + IconSettings, 19 17 IconInfoCircle, 20 18 IconApps, 21 - } from "@tabler/icons-react" 22 - import Image from "next/image" 23 - import Link from "next/link" 24 - import { usePathname } from "next/navigation" 19 + } from "@tabler/icons-react"; 20 + import Image from "next/image"; 21 + import Link from "next/link"; 22 + import { usePathname } from "next/navigation"; 25 23 26 - import { useAuth } from "@/lib/auth-context" 27 - import { useConfig } from "@/lib/config-context" 28 - import { useCurrentUser } from "@/hooks/use-current-user" 29 - import { 30 - Collapsible, 31 - CollapsibleContent, 32 - CollapsibleTrigger, 33 - } from "@/components/ui/collapsible" 24 + import { useAuth } from "@/lib/auth-context"; 25 + import { useConfig } from "@/lib/config-context"; 26 + import { useCurrentUser } from "@/hooks/use-current-user"; 27 + import { Scroller } from "@/components/ui/scroller"; 34 28 import { 35 29 Sidebar, 36 30 SidebarContent, 37 31 SidebarFooter, 38 32 SidebarGroup, 39 33 SidebarGroupContent, 34 + SidebarGroupLabel, 40 35 SidebarHeader, 41 36 SidebarMenu, 42 37 SidebarMenuButton, 43 38 SidebarMenuItem, 44 - SidebarMenuSub, 45 - SidebarMenuSubButton, 46 - SidebarMenuSubItem, 47 - } from "@/components/ui/sidebar" 39 + SidebarSeparator, 40 + } from "@/components/ui/sidebar"; 48 41 49 - const navItems = [ 50 - { title: "Dashboard", url: "/dashboard", icon: IconDashboard }, 42 + type NavItem = { 43 + title: string; 44 + url: string; 45 + icon: React.ComponentType; 46 + requiredPermissions?: string[]; 47 + }; 48 + 49 + const dataItems: NavItem[] = [ 51 50 { title: "Lexicons", url: "/dashboard/lexicons", icon: IconFileDescription }, 51 + { title: "Records", url: "/dashboard/records", icon: IconTable }, 52 52 { title: "Backfill", url: "/dashboard/backfill", icon: IconDatabase }, 53 - { title: "Records", url: "/dashboard/records", icon: IconTable }, 54 - { title: "Event Logs", url: "/dashboard/events", icon: IconClipboardList, requiredPermissions: ["events:read"] }, 55 - ] as const 53 + ]; 54 + 55 + const accessItems: NavItem[] = [ 56 + { 57 + title: "Users", 58 + url: "/dashboard/settings/users", 59 + icon: IconUsers, 60 + requiredPermissions: ["users:read"], 61 + }, 62 + { 63 + title: "API Keys", 64 + url: "/dashboard/settings/api-keys", 65 + icon: IconKey, 66 + requiredPermissions: ["api-keys:read"], 67 + }, 68 + { 69 + title: "API Clients", 70 + url: "/dashboard/settings/api-clients", 71 + icon: IconApps, 72 + requiredPermissions: ["api-clients:view"], 73 + }, 74 + ]; 75 + 76 + const integrationItems: NavItem[] = [ 77 + { 78 + title: "Plugins", 79 + url: "/dashboard/settings/plugins", 80 + icon: IconPuzzle, 81 + requiredPermissions: ["plugins:read"], 82 + }, 83 + { 84 + title: "Linked Accounts", 85 + url: "/dashboard/settings/accounts", 86 + icon: IconLink, 87 + }, 88 + { 89 + title: "Labelers", 90 + url: "/dashboard/settings/labelers", 91 + icon: IconTag, 92 + requiredPermissions: ["labelers:read"], 93 + }, 94 + ]; 56 95 57 - const settingsSubItems = [ 58 - { title: "Users", url: "/dashboard/settings/users", icon: IconUsers, requiredPermissions: ["users:read"] }, 59 - { title: "Linked Accounts", url: "/dashboard/settings/accounts", icon: IconLink, requiredPermissions: [] as string[] }, 60 - { title: "Plugins", url: "/dashboard/settings/plugins", icon: IconPuzzle, requiredPermissions: ["plugins:read"] }, 61 - { title: "ENV Variables", url: "/dashboard/settings/env-variables", icon: IconVariable, requiredPermissions: ["script-variables:read"] }, 62 - { title: "API Keys", url: "/dashboard/settings/api-keys", icon: IconKey, requiredPermissions: ["api-keys:read"] }, 63 - { title: "API Clients", url: "/dashboard/settings/api-clients", icon: IconApps, requiredPermissions: ["api-clients:view"] }, 64 - { title: "Labelers", url: "/dashboard/settings/labelers", icon: IconTag, requiredPermissions: ["labelers:read"] }, 65 - { title: "General", url: "/dashboard/settings/general", icon: IconLockAccess, requiredPermissions: ["settings:manage"] }, 66 - ] as const 96 + const systemItems: NavItem[] = [ 97 + { 98 + title: "General", 99 + url: "/dashboard/settings/general", 100 + icon: IconSettings, 101 + requiredPermissions: ["settings:manage"], 102 + }, 103 + { 104 + title: "ENV Variables", 105 + url: "/dashboard/settings/env-variables", 106 + icon: IconVariable, 107 + requiredPermissions: ["script-variables:read"], 108 + }, 109 + { 110 + title: "Event Logs", 111 + url: "/dashboard/events", 112 + icon: IconClipboardList, 113 + requiredPermissions: ["events:read"], 114 + }, 115 + ]; 67 116 68 - export function AppSidebar({ 69 - ...props 70 - }: React.ComponentProps<typeof Sidebar>) { 71 - const pathname = usePathname() 72 - const { logout } = useAuth() 73 - const { app_name, logo_url } = useConfig() 74 - const { hasPermission } = useCurrentUser() 117 + export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { 118 + const pathname = usePathname(); 119 + const { logout } = useAuth(); 120 + const { app_name, logo_url } = useConfig(); 121 + const { hasPermission } = useCurrentUser(); 75 122 76 - const visibleNavItems = navItems.filter((item) => { 77 - if (!("requiredPermissions" in item)) return true 78 - return item.requiredPermissions.some((perm) => hasPermission(perm)) 79 - }) 123 + function filterByPermission(items: NavItem[]) { 124 + return items.filter( 125 + (item) => 126 + !item.requiredPermissions || 127 + item.requiredPermissions.some((perm) => hasPermission(perm)), 128 + ); 129 + } 80 130 81 - const visibleSettingsItems = settingsSubItems.filter((item) => 82 - item.requiredPermissions.length === 0 || 83 - item.requiredPermissions.some((perm) => hasPermission(perm)) 84 - ) 131 + function isActive(url: string) { 132 + return url === "/dashboard" 133 + ? pathname === "/dashboard" 134 + : pathname.startsWith(url); 135 + } 85 136 86 - const isSettingsActive = pathname.startsWith("/dashboard/settings") 137 + const visibleData = filterByPermission(dataItems); 138 + const visibleAccess = filterByPermission(accessItems); 139 + const visibleIntegrations = filterByPermission(integrationItems); 140 + const visibleSystem = filterByPermission(systemItems); 87 141 88 142 return ( 89 143 <Sidebar collapsible="offcanvas" {...props}> ··· 116 170 </> 117 171 )} 118 172 </SidebarHeader> 119 - <SidebarContent> 120 - <SidebarGroup> 121 - <SidebarGroupContent className="flex flex-col gap-2"> 122 - <SidebarMenu> 123 - {visibleNavItems.map((item) => ( 124 - <SidebarMenuItem key={item.title}> 173 + <SidebarSeparator className="!mx-0" /> 174 + <Scroller asChild hideScrollbar> 175 + <SidebarContent> 176 + <SidebarGroup> 177 + <SidebarGroupContent> 178 + <SidebarMenu> 179 + <SidebarMenuItem> 125 180 <SidebarMenuButton 126 181 asChild 127 - tooltip={item.title} 128 - isActive={ 129 - item.url === "/dashboard" 130 - ? pathname === "/dashboard" 131 - : pathname.startsWith(item.url) 132 - } 182 + tooltip="Dashboard" 183 + isActive={isActive("/dashboard")} 133 184 > 134 - <Link href={item.url}> 135 - <item.icon /> 136 - <span>{item.title}</span> 185 + <Link href="/dashboard"> 186 + <IconDashboard /> 187 + <span>Dashboard</span> 137 188 </Link> 138 189 </SidebarMenuButton> 139 190 </SidebarMenuItem> 140 - ))} 191 + </SidebarMenu> 192 + </SidebarGroupContent> 193 + </SidebarGroup> 141 194 142 - {visibleSettingsItems.length > 0 && ( 143 - <Collapsible defaultOpen={isSettingsActive} className="group/collapsible"> 144 - <SidebarMenuItem> 145 - <CollapsibleTrigger asChild> 146 - <SidebarMenuButton tooltip="Settings" isActive={isSettingsActive}> 147 - <IconSettings /> 148 - <span>Settings</span> 149 - <IconChevronRight className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90" /> 195 + {visibleData.length > 0 && ( 196 + <SidebarGroup> 197 + <SidebarGroupLabel>Data</SidebarGroupLabel> 198 + <SidebarGroupContent> 199 + <SidebarMenu> 200 + {visibleData.map((item) => ( 201 + <SidebarMenuItem key={item.title}> 202 + <SidebarMenuButton 203 + asChild 204 + tooltip={item.title} 205 + isActive={isActive(item.url)} 206 + > 207 + <Link href={item.url}> 208 + <item.icon /> 209 + <span>{item.title}</span> 210 + </Link> 150 211 </SidebarMenuButton> 151 - </CollapsibleTrigger> 152 - <CollapsibleContent> 153 - <SidebarMenuSub> 154 - {visibleSettingsItems.map((item) => ( 155 - <SidebarMenuSubItem key={item.title}> 156 - <SidebarMenuSubButton 157 - asChild 158 - isActive={pathname.startsWith(item.url)} 159 - > 160 - <Link href={item.url}> 161 - <item.icon /> 162 - <span>{item.title}</span> 163 - </Link> 164 - </SidebarMenuSubButton> 165 - </SidebarMenuSubItem> 166 - ))} 167 - </SidebarMenuSub> 168 - </CollapsibleContent> 169 - </SidebarMenuItem> 170 - </Collapsible> 171 - )} 172 - </SidebarMenu> 173 - </SidebarGroupContent> 174 - </SidebarGroup> 175 - </SidebarContent> 212 + </SidebarMenuItem> 213 + ))} 214 + </SidebarMenu> 215 + </SidebarGroupContent> 216 + </SidebarGroup> 217 + )} 218 + 219 + {visibleAccess.length > 0 && ( 220 + <SidebarGroup> 221 + <SidebarGroupLabel>Access</SidebarGroupLabel> 222 + <SidebarGroupContent> 223 + <SidebarMenu> 224 + {visibleAccess.map((item) => ( 225 + <SidebarMenuItem key={item.title}> 226 + <SidebarMenuButton 227 + asChild 228 + tooltip={item.title} 229 + isActive={isActive(item.url)} 230 + > 231 + <Link href={item.url}> 232 + <item.icon /> 233 + <span>{item.title}</span> 234 + </Link> 235 + </SidebarMenuButton> 236 + </SidebarMenuItem> 237 + ))} 238 + </SidebarMenu> 239 + </SidebarGroupContent> 240 + </SidebarGroup> 241 + )} 242 + 243 + {visibleIntegrations.length > 0 && ( 244 + <SidebarGroup> 245 + <SidebarGroupLabel>Integrations</SidebarGroupLabel> 246 + <SidebarGroupContent> 247 + <SidebarMenu> 248 + {visibleIntegrations.map((item) => ( 249 + <SidebarMenuItem key={item.title}> 250 + <SidebarMenuButton 251 + asChild 252 + tooltip={item.title} 253 + isActive={isActive(item.url)} 254 + > 255 + <Link href={item.url}> 256 + <item.icon /> 257 + <span>{item.title}</span> 258 + </Link> 259 + </SidebarMenuButton> 260 + </SidebarMenuItem> 261 + ))} 262 + </SidebarMenu> 263 + </SidebarGroupContent> 264 + </SidebarGroup> 265 + )} 266 + 267 + {visibleSystem.length > 0 && ( 268 + <SidebarGroup> 269 + <SidebarGroupLabel>System</SidebarGroupLabel> 270 + <SidebarGroupContent> 271 + <SidebarMenu> 272 + {visibleSystem.map((item) => ( 273 + <SidebarMenuItem key={item.title}> 274 + <SidebarMenuButton 275 + asChild 276 + tooltip={item.title} 277 + isActive={isActive(item.url)} 278 + > 279 + <Link href={item.url}> 280 + <item.icon /> 281 + <span>{item.title}</span> 282 + </Link> 283 + </SidebarMenuButton> 284 + </SidebarMenuItem> 285 + ))} 286 + </SidebarMenu> 287 + </SidebarGroupContent> 288 + </SidebarGroup> 289 + )} 290 + </SidebarContent> 291 + </Scroller> 292 + <SidebarSeparator className="!mx-0" /> 176 293 <SidebarFooter> 177 294 <SidebarMenu> 178 295 <SidebarMenuItem> 179 - <SidebarMenuButton asChild tooltip="About" isActive={pathname === "/dashboard/about"}> 296 + <SidebarMenuButton 297 + asChild 298 + tooltip="About" 299 + isActive={pathname === "/dashboard/about"} 300 + > 180 301 <Link href="/dashboard/about"> 181 302 <IconInfoCircle /> 182 303 <span>About</span> ··· 192 313 </SidebarMenu> 193 314 </SidebarFooter> 194 315 </Sidebar> 195 - ) 316 + ); 196 317 }
+387
web/src/components/ui/scroller.tsx
··· 1 + "use client"; 2 + 3 + import { cva, type VariantProps } from "class-variance-authority"; 4 + import { 5 + ChevronDown, 6 + ChevronLeft, 7 + ChevronRight, 8 + ChevronUp, 9 + } from "lucide-react"; 10 + import { Slot as SlotPrimitive } from "radix-ui"; 11 + import * as React from "react"; 12 + import { useComposedRefs } from "@/lib/compose-refs"; 13 + import { cn } from "@/lib/utils"; 14 + 15 + const DATA_TOP_SCROLL = "data-top-scroll"; 16 + const DATA_BOTTOM_SCROLL = "data-bottom-scroll"; 17 + const DATA_LEFT_SCROLL = "data-left-scroll"; 18 + const DATA_RIGHT_SCROLL = "data-right-scroll"; 19 + const DATA_TOP_BOTTOM_SCROLL = "data-top-bottom-scroll"; 20 + const DATA_LEFT_RIGHT_SCROLL = "data-left-right-scroll"; 21 + 22 + const scrollerVariants = cva("", { 23 + variants: { 24 + orientation: { 25 + vertical: [ 26 + "overflow-y-auto", 27 + "data-[top-scroll=true]:[mask-image:linear-gradient(0deg,#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]", 28 + "data-[bottom-scroll=true]:[mask-image:linear-gradient(180deg,#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]", 29 + "data-[top-bottom-scroll=true]:[mask-image:linear-gradient(#000,#000,transparent_0,#000_var(--scroll-shadow-size),#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]", 30 + ], 31 + horizontal: [ 32 + "overflow-x-auto", 33 + "data-[left-scroll=true]:[mask-image:linear-gradient(270deg,#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]", 34 + "data-[right-scroll=true]:[mask-image:linear-gradient(90deg,#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]", 35 + "data-[left-right-scroll=true]:[mask-image:linear-gradient(to_right,#000,#000,transparent_0,#000_var(--scroll-shadow-size),#000_calc(100%_-_var(--scroll-shadow-size)),transparent)]", 36 + ], 37 + }, 38 + hideScrollbar: { 39 + true: "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", 40 + false: "", 41 + }, 42 + }, 43 + defaultVariants: { 44 + orientation: "vertical", 45 + hideScrollbar: false, 46 + }, 47 + }); 48 + 49 + type ScrollDirection = "up" | "down" | "left" | "right"; 50 + 51 + type ScrollVisibility = { 52 + [key in ScrollDirection]: boolean; 53 + }; 54 + 55 + interface ScrollerProps 56 + extends VariantProps<typeof scrollerVariants>, 57 + React.ComponentProps<"div"> { 58 + size?: number; 59 + offset?: number; 60 + asChild?: boolean; 61 + withNavigation?: boolean; 62 + scrollStep?: number; 63 + scrollTriggerMode?: "press" | "hover" | "click"; 64 + } 65 + 66 + function Scroller(props: ScrollerProps) { 67 + const { 68 + orientation = "vertical", 69 + hideScrollbar, 70 + className, 71 + size = 40, 72 + offset = 0, 73 + scrollStep = 40, 74 + style, 75 + asChild, 76 + withNavigation = false, 77 + scrollTriggerMode = "press", 78 + ref, 79 + ...scrollerProps 80 + } = props; 81 + 82 + const containerRef = React.useRef<HTMLDivElement | null>(null); 83 + const composedRef = useComposedRefs(ref, containerRef); 84 + const [scrollVisibility, setScrollVisibility] = 85 + React.useState<ScrollVisibility>({ 86 + up: false, 87 + down: false, 88 + left: false, 89 + right: false, 90 + }); 91 + 92 + const onScrollBy = React.useCallback( 93 + (direction: ScrollDirection) => { 94 + const container = containerRef.current; 95 + if (!container) return; 96 + 97 + const scrollMap: Record<ScrollDirection, () => void> = { 98 + up: () => (container.scrollTop -= scrollStep), 99 + down: () => (container.scrollTop += scrollStep), 100 + left: () => (container.scrollLeft -= scrollStep), 101 + right: () => (container.scrollLeft += scrollStep), 102 + }; 103 + 104 + scrollMap[direction](); 105 + }, 106 + [scrollStep], 107 + ); 108 + 109 + const scrollHandlers = React.useMemo( 110 + () => ({ 111 + up: () => onScrollBy("up"), 112 + down: () => onScrollBy("down"), 113 + left: () => onScrollBy("left"), 114 + right: () => onScrollBy("right"), 115 + }), 116 + [onScrollBy], 117 + ); 118 + 119 + React.useLayoutEffect(() => { 120 + const container = containerRef.current; 121 + if (!container) return; 122 + 123 + function onScroll() { 124 + if (!container) return; 125 + 126 + const isVertical = orientation === "vertical"; 127 + 128 + if (isVertical) { 129 + const scrollTop = container.scrollTop; 130 + const clientHeight = container.clientHeight; 131 + const scrollHeight = container.scrollHeight; 132 + 133 + if (withNavigation) { 134 + setScrollVisibility((prev) => { 135 + const newUp = scrollTop > offset; 136 + const newDown = scrollTop + clientHeight < scrollHeight; 137 + 138 + if (prev.up !== newUp || prev.down !== newDown) { 139 + return { 140 + ...prev, 141 + up: newUp, 142 + down: newDown, 143 + }; 144 + } 145 + return prev; 146 + }); 147 + } 148 + 149 + const hasTopScroll = scrollTop > offset; 150 + const hasBottomScroll = 151 + scrollTop + clientHeight + offset < scrollHeight; 152 + const isVerticallyScrollable = scrollHeight > clientHeight; 153 + 154 + if (hasTopScroll && hasBottomScroll && isVerticallyScrollable) { 155 + container.setAttribute(DATA_TOP_BOTTOM_SCROLL, "true"); 156 + container.removeAttribute(DATA_TOP_SCROLL); 157 + container.removeAttribute(DATA_BOTTOM_SCROLL); 158 + } else { 159 + container.removeAttribute(DATA_TOP_BOTTOM_SCROLL); 160 + if (hasTopScroll) container.setAttribute(DATA_TOP_SCROLL, "true"); 161 + else container.removeAttribute(DATA_TOP_SCROLL); 162 + if (hasBottomScroll && isVerticallyScrollable) 163 + container.setAttribute(DATA_BOTTOM_SCROLL, "true"); 164 + else container.removeAttribute(DATA_BOTTOM_SCROLL); 165 + } 166 + } 167 + 168 + const scrollLeft = container.scrollLeft; 169 + const clientWidth = container.clientWidth; 170 + const scrollWidth = container.scrollWidth; 171 + 172 + if (withNavigation) { 173 + setScrollVisibility((prev) => { 174 + const newLeft = scrollLeft > offset; 175 + const newRight = scrollLeft + clientWidth < scrollWidth; 176 + 177 + if (prev.left !== newLeft || prev.right !== newRight) { 178 + return { 179 + ...prev, 180 + left: newLeft, 181 + right: newRight, 182 + }; 183 + } 184 + return prev; 185 + }); 186 + } 187 + 188 + const hasLeftScroll = scrollLeft > offset; 189 + const hasRightScroll = scrollLeft + clientWidth + offset < scrollWidth; 190 + const isHorizontallyScrollable = scrollWidth > clientWidth; 191 + 192 + if (hasLeftScroll && hasRightScroll && isHorizontallyScrollable) { 193 + container.setAttribute(DATA_LEFT_RIGHT_SCROLL, "true"); 194 + container.removeAttribute(DATA_LEFT_SCROLL); 195 + container.removeAttribute(DATA_RIGHT_SCROLL); 196 + } else { 197 + container.removeAttribute(DATA_LEFT_RIGHT_SCROLL); 198 + if (hasLeftScroll) container.setAttribute(DATA_LEFT_SCROLL, "true"); 199 + else container.removeAttribute(DATA_LEFT_SCROLL); 200 + if (hasRightScroll && isHorizontallyScrollable) 201 + container.setAttribute(DATA_RIGHT_SCROLL, "true"); 202 + else container.removeAttribute(DATA_RIGHT_SCROLL); 203 + } 204 + } 205 + 206 + onScroll(); 207 + container.addEventListener("scroll", onScroll); 208 + window.addEventListener("resize", onScroll); 209 + 210 + return () => { 211 + container.removeEventListener("scroll", onScroll); 212 + window.removeEventListener("resize", onScroll); 213 + }; 214 + }, [orientation, offset, withNavigation]); 215 + 216 + const composedStyle = React.useMemo<React.CSSProperties>( 217 + () => ({ 218 + "--scroll-shadow-size": `${size}px`, 219 + ...style, 220 + }), 221 + [size, style], 222 + ); 223 + 224 + const activeDirections = React.useMemo<ScrollDirection[]>(() => { 225 + if (!withNavigation) return []; 226 + return orientation === "vertical" ? ["up", "down"] : ["left", "right"]; 227 + }, [orientation, withNavigation]); 228 + 229 + const ScrollerPrimitive = asChild ? SlotPrimitive.Slot : "div"; 230 + 231 + const ScrollerImpl = ( 232 + <ScrollerPrimitive 233 + data-slot="scroller" 234 + {...scrollerProps} 235 + ref={composedRef} 236 + style={composedStyle} 237 + className={cn( 238 + scrollerVariants({ orientation, hideScrollbar, className }), 239 + )} 240 + /> 241 + ); 242 + 243 + const navigationButtons = React.useMemo(() => { 244 + if (!withNavigation) return null; 245 + 246 + return activeDirections 247 + .filter((direction) => scrollVisibility[direction]) 248 + .map((direction) => ( 249 + <ScrollButton 250 + key={direction} 251 + data-slot="scroll-button" 252 + direction={direction} 253 + onClick={scrollHandlers[direction]} 254 + triggerMode={scrollTriggerMode} 255 + /> 256 + )); 257 + }, [ 258 + activeDirections, 259 + scrollVisibility, 260 + scrollHandlers, 261 + scrollTriggerMode, 262 + withNavigation, 263 + ]); 264 + 265 + if (withNavigation) { 266 + return ( 267 + <div className="relative w-full"> 268 + {navigationButtons} 269 + {ScrollerImpl} 270 + </div> 271 + ); 272 + } 273 + 274 + return ScrollerImpl; 275 + } 276 + 277 + const scrollButtonVariants = cva( 278 + "absolute z-10 transition-opacity [&>svg]:size-4 [&>svg]:opacity-80 hover:[&>svg]:opacity-100", 279 + { 280 + variants: { 281 + direction: { 282 + up: "top-2 left-1/2 -translate-x-1/2", 283 + down: "bottom-2 left-1/2 -translate-x-1/2", 284 + left: "top-1/2 left-2 -translate-y-1/2", 285 + right: "top-1/2 right-2 -translate-y-1/2", 286 + }, 287 + }, 288 + defaultVariants: { 289 + direction: "up", 290 + }, 291 + }, 292 + ); 293 + 294 + const directionToIcon: Record<ScrollDirection, React.ElementType> = { 295 + up: ChevronUp, 296 + down: ChevronDown, 297 + left: ChevronLeft, 298 + right: ChevronRight, 299 + } as const; 300 + 301 + interface ScrollButtonProps extends React.ComponentProps<"button"> { 302 + direction: ScrollDirection; 303 + triggerMode?: "press" | "hover" | "click"; 304 + } 305 + 306 + function ScrollButton(props: ScrollButtonProps) { 307 + const { 308 + direction, 309 + className, 310 + triggerMode = "press", 311 + onClick, 312 + ref, 313 + ...buttonProps 314 + } = props; 315 + 316 + const [autoScrollTimer, setAutoScrollTimer] = React.useState<number | null>( 317 + null, 318 + ); 319 + 320 + const onAutoScrollStart = React.useCallback( 321 + (event?: React.MouseEvent<HTMLButtonElement>) => { 322 + if (autoScrollTimer !== null) return; 323 + 324 + if (triggerMode === "press") { 325 + const timer = window.setInterval(onClick ?? (() => {}), 50); 326 + setAutoScrollTimer(timer); 327 + } else if (triggerMode === "hover") { 328 + const timer = window.setInterval(() => { 329 + if (event) onClick?.(event); 330 + }, 50); 331 + setAutoScrollTimer(timer); 332 + } 333 + }, 334 + [autoScrollTimer, onClick, triggerMode], 335 + ); 336 + 337 + const onAutoScrollStop = React.useCallback(() => { 338 + if (autoScrollTimer === null) return; 339 + 340 + window.clearInterval(autoScrollTimer); 341 + setAutoScrollTimer(null); 342 + }, [autoScrollTimer]); 343 + 344 + const eventHandlers = React.useMemo(() => { 345 + const triggerModeHandlers: Record< 346 + NonNullable<ScrollerProps["scrollTriggerMode"]>, 347 + React.ComponentProps<"button"> 348 + > = { 349 + press: { 350 + onPointerDown: onAutoScrollStart, 351 + onPointerUp: onAutoScrollStop, 352 + onPointerLeave: onAutoScrollStop, 353 + onClick: () => {}, 354 + }, 355 + hover: { 356 + onPointerEnter: onAutoScrollStart, 357 + onPointerLeave: onAutoScrollStop, 358 + onClick: () => {}, 359 + }, 360 + click: { 361 + onClick, 362 + }, 363 + } as const; 364 + 365 + return triggerModeHandlers[triggerMode] ?? {}; 366 + }, [triggerMode, onAutoScrollStart, onAutoScrollStop, onClick]); 367 + 368 + React.useEffect(() => { 369 + return () => onAutoScrollStop(); 370 + }, [onAutoScrollStop]); 371 + 372 + const Icon = directionToIcon[direction]; 373 + 374 + return ( 375 + <button 376 + type="button" 377 + {...buttonProps} 378 + {...eventHandlers} 379 + ref={ref} 380 + className={cn(scrollButtonVariants({ direction, className }))} 381 + > 382 + <Icon /> 383 + </button> 384 + ); 385 + } 386 + 387 + export { Scroller };
+124 -124
web/src/components/ui/sidebar.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import * as React from "react" 4 - import { cva, type VariantProps } from "class-variance-authority" 5 - import { PanelLeftIcon } from "lucide-react" 6 - import { Slot } from "radix-ui" 3 + import * as React from "react"; 4 + import { cva, type VariantProps } from "class-variance-authority"; 5 + import { PanelLeftIcon } from "lucide-react"; 6 + import { Slot } from "radix-ui"; 7 7 8 - import { useIsMobile } from "@/hooks/use-mobile" 9 - import { cn } from "@/lib/utils" 10 - import { Button } from "@/components/ui/button" 11 - import { Input } from "@/components/ui/input" 12 - import { Separator } from "@/components/ui/separator" 8 + import { useIsMobile } from "@/hooks/use-mobile"; 9 + import { cn } from "@/lib/utils"; 10 + import { Button } from "@/components/ui/button"; 11 + import { Input } from "@/components/ui/input"; 12 + import { Separator } from "@/components/ui/separator"; 13 13 import { 14 14 Sheet, 15 15 SheetContent, 16 16 SheetDescription, 17 17 SheetHeader, 18 18 SheetTitle, 19 - } from "@/components/ui/sheet" 20 - import { Skeleton } from "@/components/ui/skeleton" 19 + } from "@/components/ui/sheet"; 20 + import { Skeleton } from "@/components/ui/skeleton"; 21 21 import { 22 22 Tooltip, 23 23 TooltipContent, 24 24 TooltipProvider, 25 25 TooltipTrigger, 26 - } from "@/components/ui/tooltip" 26 + } from "@/components/ui/tooltip"; 27 27 28 - const SIDEBAR_COOKIE_NAME = "sidebar_state" 29 - const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 30 - const SIDEBAR_WIDTH = "16rem" 31 - const SIDEBAR_WIDTH_MOBILE = "18rem" 32 - const SIDEBAR_WIDTH_ICON = "3rem" 33 - const SIDEBAR_KEYBOARD_SHORTCUT = "b" 28 + const SIDEBAR_COOKIE_NAME = "sidebar_state"; 29 + const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; 30 + const SIDEBAR_WIDTH = "16rem"; 31 + const SIDEBAR_WIDTH_MOBILE = "18rem"; 32 + const SIDEBAR_WIDTH_ICON = "3rem"; 33 + const SIDEBAR_KEYBOARD_SHORTCUT = "b"; 34 34 35 35 type SidebarContextProps = { 36 - state: "expanded" | "collapsed" 37 - open: boolean 38 - setOpen: (open: boolean) => void 39 - openMobile: boolean 40 - setOpenMobile: (open: boolean) => void 41 - isMobile: boolean 42 - toggleSidebar: () => void 43 - } 36 + state: "expanded" | "collapsed"; 37 + open: boolean; 38 + setOpen: (open: boolean) => void; 39 + openMobile: boolean; 40 + setOpenMobile: (open: boolean) => void; 41 + isMobile: boolean; 42 + toggleSidebar: () => void; 43 + }; 44 44 45 - const SidebarContext = React.createContext<SidebarContextProps | null>(null) 45 + const SidebarContext = React.createContext<SidebarContextProps | null>(null); 46 46 47 47 function useSidebar() { 48 - const context = React.useContext(SidebarContext) 48 + const context = React.useContext(SidebarContext); 49 49 if (!context) { 50 - throw new Error("useSidebar must be used within a SidebarProvider.") 50 + throw new Error("useSidebar must be used within a SidebarProvider."); 51 51 } 52 52 53 - return context 53 + return context; 54 54 } 55 55 56 56 function SidebarProvider({ ··· 62 62 children, 63 63 ...props 64 64 }: React.ComponentProps<"div"> & { 65 - defaultOpen?: boolean 66 - open?: boolean 67 - onOpenChange?: (open: boolean) => void 65 + defaultOpen?: boolean; 66 + open?: boolean; 67 + onOpenChange?: (open: boolean) => void; 68 68 }) { 69 - const isMobile = useIsMobile() 70 - const [openMobile, setOpenMobile] = React.useState(false) 69 + const isMobile = useIsMobile(); 70 + const [openMobile, setOpenMobile] = React.useState(false); 71 71 72 72 // This is the internal state of the sidebar. 73 73 // We use openProp and setOpenProp for control from outside the component. 74 - const [_open, _setOpen] = React.useState(defaultOpen) 75 - const open = openProp ?? _open 74 + const [_open, _setOpen] = React.useState(defaultOpen); 75 + const open = openProp ?? _open; 76 76 const setOpen = React.useCallback( 77 77 (value: boolean | ((value: boolean) => boolean)) => { 78 - const openState = typeof value === "function" ? value(open) : value 78 + const openState = typeof value === "function" ? value(open) : value; 79 79 if (setOpenProp) { 80 - setOpenProp(openState) 80 + setOpenProp(openState); 81 81 } else { 82 - _setOpen(openState) 82 + _setOpen(openState); 83 83 } 84 84 85 85 // This sets the cookie to keep the sidebar state. 86 - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` 86 + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; 87 87 }, 88 - [setOpenProp, open] 89 - ) 88 + [setOpenProp, open], 89 + ); 90 90 91 91 // Helper to toggle the sidebar. 92 92 const toggleSidebar = React.useCallback(() => { 93 - return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) 94 - }, [isMobile, setOpen, setOpenMobile]) 93 + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); 94 + }, [isMobile, setOpen, setOpenMobile]); 95 95 96 96 // Adds a keyboard shortcut to toggle the sidebar. 97 97 React.useEffect(() => { ··· 100 100 event.key === SIDEBAR_KEYBOARD_SHORTCUT && 101 101 (event.metaKey || event.ctrlKey) 102 102 ) { 103 - event.preventDefault() 104 - toggleSidebar() 103 + event.preventDefault(); 104 + toggleSidebar(); 105 105 } 106 - } 106 + }; 107 107 108 - window.addEventListener("keydown", handleKeyDown) 109 - return () => window.removeEventListener("keydown", handleKeyDown) 110 - }, [toggleSidebar]) 108 + window.addEventListener("keydown", handleKeyDown); 109 + return () => window.removeEventListener("keydown", handleKeyDown); 110 + }, [toggleSidebar]); 111 111 112 112 // We add a state so that we can do data-state="expanded" or "collapsed". 113 113 // This makes it easier to style the sidebar with Tailwind classes. 114 - const state = open ? "expanded" : "collapsed" 114 + const state = open ? "expanded" : "collapsed"; 115 115 116 116 const contextValue = React.useMemo<SidebarContextProps>( 117 117 () => ({ ··· 123 123 setOpenMobile, 124 124 toggleSidebar, 125 125 }), 126 - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] 127 - ) 126 + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], 127 + ); 128 128 129 129 return ( 130 130 <SidebarContext.Provider value={contextValue}> ··· 140 140 } 141 141 className={cn( 142 142 "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", 143 - className 143 + className, 144 144 )} 145 145 {...props} 146 146 > ··· 148 148 </div> 149 149 </TooltipProvider> 150 150 </SidebarContext.Provider> 151 - ) 151 + ); 152 152 } 153 153 154 154 function Sidebar({ ··· 159 159 children, 160 160 ...props 161 161 }: React.ComponentProps<"div"> & { 162 - side?: "left" | "right" 163 - variant?: "sidebar" | "floating" | "inset" 164 - collapsible?: "offcanvas" | "icon" | "none" 162 + side?: "left" | "right"; 163 + variant?: "sidebar" | "floating" | "inset"; 164 + collapsible?: "offcanvas" | "icon" | "none"; 165 165 }) { 166 - const { isMobile, state, openMobile, setOpenMobile } = useSidebar() 166 + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); 167 167 168 168 if (collapsible === "none") { 169 169 return ( ··· 171 171 data-slot="sidebar" 172 172 className={cn( 173 173 "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", 174 - className 174 + className, 175 175 )} 176 176 {...props} 177 177 > 178 178 {children} 179 179 </div> 180 - ) 180 + ); 181 181 } 182 182 183 183 if (isMobile) { ··· 202 202 <div className="flex h-full w-full flex-col">{children}</div> 203 203 </SheetContent> 204 204 </Sheet> 205 - ) 205 + ); 206 206 } 207 207 208 208 return ( ··· 223 223 "group-data-[side=right]:rotate-180", 224 224 variant === "floating" || variant === "inset" 225 225 ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" 226 - : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)" 226 + : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)", 227 227 )} 228 228 /> 229 229 <div ··· 237 237 variant === "floating" || variant === "inset" 238 238 ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" 239 239 : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", 240 - className 240 + className, 241 241 )} 242 242 {...props} 243 243 > ··· 250 250 </div> 251 251 </div> 252 252 </div> 253 - ) 253 + ); 254 254 } 255 255 256 256 function SidebarTrigger({ ··· 258 258 onClick, 259 259 ...props 260 260 }: React.ComponentProps<typeof Button>) { 261 - const { toggleSidebar } = useSidebar() 261 + const { toggleSidebar } = useSidebar(); 262 262 263 263 return ( 264 264 <Button ··· 268 268 size="icon" 269 269 className={cn("size-7", className)} 270 270 onClick={(event) => { 271 - onClick?.(event) 272 - toggleSidebar() 271 + onClick?.(event); 272 + toggleSidebar(); 273 273 }} 274 274 {...props} 275 275 > 276 276 <PanelLeftIcon /> 277 277 <span className="sr-only">Toggle Sidebar</span> 278 278 </Button> 279 - ) 279 + ); 280 280 } 281 281 282 282 function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { 283 - const { toggleSidebar } = useSidebar() 283 + const { toggleSidebar } = useSidebar(); 284 284 285 285 return ( 286 286 <button ··· 297 297 "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full", 298 298 "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", 299 299 "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", 300 - className 300 + className, 301 301 )} 302 302 {...props} 303 303 /> 304 - ) 304 + ); 305 305 } 306 306 307 307 function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { ··· 311 311 className={cn( 312 312 "bg-background relative flex min-w-0 w-full flex-1 flex-col", 313 313 "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 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", 314 - className 314 + className, 315 315 )} 316 316 {...props} 317 317 /> 318 - ) 318 + ); 319 319 } 320 320 321 321 function SidebarInput({ ··· 329 329 className={cn("bg-background h-8 w-full shadow-none", className)} 330 330 {...props} 331 331 /> 332 - ) 332 + ); 333 333 } 334 334 335 335 function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { ··· 340 340 className={cn("flex flex-col gap-2 p-2", className)} 341 341 {...props} 342 342 /> 343 - ) 343 + ); 344 344 } 345 345 346 346 function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { ··· 351 351 className={cn("flex flex-col gap-2 p-2", className)} 352 352 {...props} 353 353 /> 354 - ) 354 + ); 355 355 } 356 356 357 357 function SidebarSeparator({ ··· 365 365 className={cn("bg-sidebar-border mx-2 w-auto", className)} 366 366 {...props} 367 367 /> 368 - ) 368 + ); 369 369 } 370 370 371 371 function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { ··· 375 375 data-sidebar="content" 376 376 className={cn( 377 377 "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", 378 - className 378 + className, 379 379 )} 380 380 {...props} 381 381 /> 382 - ) 382 + ); 383 383 } 384 384 385 385 function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { ··· 390 390 className={cn("relative flex w-full min-w-0 flex-col p-2", className)} 391 391 {...props} 392 392 /> 393 - ) 393 + ); 394 394 } 395 395 396 396 function SidebarGroupLabel({ ··· 398 398 asChild = false, 399 399 ...props 400 400 }: React.ComponentProps<"div"> & { asChild?: boolean }) { 401 - const Comp = asChild ? Slot.Root : "div" 401 + const Comp = asChild ? Slot.Root : "div"; 402 402 403 403 return ( 404 404 <Comp ··· 407 407 className={cn( 408 408 "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", 409 409 "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", 410 - className 410 + className, 411 411 )} 412 412 {...props} 413 413 /> 414 - ) 414 + ); 415 415 } 416 416 417 417 function SidebarGroupAction({ ··· 419 419 asChild = false, 420 420 ...props 421 421 }: React.ComponentProps<"button"> & { asChild?: boolean }) { 422 - const Comp = asChild ? Slot.Root : "button" 422 + const Comp = asChild ? Slot.Root : "button"; 423 423 424 424 return ( 425 425 <Comp ··· 430 430 // Increases the hit area of the button on mobile. 431 431 "after:absolute after:-inset-2 md:after:hidden", 432 432 "group-data-[collapsible=icon]:hidden", 433 - className 433 + className, 434 434 )} 435 435 {...props} 436 436 /> 437 - ) 437 + ); 438 438 } 439 439 440 440 function SidebarGroupContent({ ··· 448 448 className={cn("w-full text-sm", className)} 449 449 {...props} 450 450 /> 451 - ) 451 + ); 452 452 } 453 453 454 454 function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { ··· 459 459 className={cn("flex w-full min-w-0 flex-col gap-1", className)} 460 460 {...props} 461 461 /> 462 - ) 462 + ); 463 463 } 464 464 465 465 function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { ··· 470 470 className={cn("group/menu-item relative", className)} 471 471 {...props} 472 472 /> 473 - ) 473 + ); 474 474 } 475 475 476 476 const sidebarMenuButtonVariants = cva( ··· 492 492 variant: "default", 493 493 size: "default", 494 494 }, 495 - } 496 - ) 495 + }, 496 + ); 497 497 498 498 function SidebarMenuButton({ 499 499 asChild = false, ··· 504 504 className, 505 505 ...props 506 506 }: React.ComponentProps<"button"> & { 507 - asChild?: boolean 508 - isActive?: boolean 509 - tooltip?: string | React.ComponentProps<typeof TooltipContent> 507 + asChild?: boolean; 508 + isActive?: boolean; 509 + tooltip?: string | React.ComponentProps<typeof TooltipContent>; 510 510 } & VariantProps<typeof sidebarMenuButtonVariants>) { 511 - const Comp = asChild ? Slot.Root : "button" 512 - const { isMobile, state } = useSidebar() 511 + const Comp = asChild ? Slot.Root : "button"; 512 + const { isMobile, state } = useSidebar(); 513 513 514 514 const button = ( 515 515 <Comp ··· 520 520 className={cn(sidebarMenuButtonVariants({ variant, size }), className)} 521 521 {...props} 522 522 /> 523 - ) 523 + ); 524 524 525 525 if (!tooltip) { 526 - return button 526 + return button; 527 527 } 528 528 529 529 if (typeof tooltip === "string") { 530 530 tooltip = { 531 531 children: tooltip, 532 - } 532 + }; 533 533 } 534 534 535 535 return ( ··· 542 542 {...tooltip} 543 543 /> 544 544 </Tooltip> 545 - ) 545 + ); 546 546 } 547 547 548 548 function SidebarMenuAction({ ··· 551 551 showOnHover = false, 552 552 ...props 553 553 }: React.ComponentProps<"button"> & { 554 - asChild?: boolean 555 - showOnHover?: boolean 554 + asChild?: boolean; 555 + showOnHover?: boolean; 556 556 }) { 557 - const Comp = asChild ? Slot.Root : "button" 557 + const Comp = asChild ? Slot.Root : "button"; 558 558 559 559 return ( 560 560 <Comp ··· 570 570 "group-data-[collapsible=icon]:hidden", 571 571 showOnHover && 572 572 "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", 573 - className 573 + className, 574 574 )} 575 575 {...props} 576 576 /> 577 - ) 577 + ); 578 578 } 579 579 580 580 function SidebarMenuBadge({ ··· 592 592 "peer-data-[size=default]/menu-button:top-1.5", 593 593 "peer-data-[size=lg]/menu-button:top-2.5", 594 594 "group-data-[collapsible=icon]:hidden", 595 - className 595 + className, 596 596 )} 597 597 {...props} 598 598 /> 599 - ) 599 + ); 600 600 } 601 601 602 602 function SidebarMenuSkeleton({ ··· 604 604 showIcon = false, 605 605 ...props 606 606 }: React.ComponentProps<"div"> & { 607 - showIcon?: boolean 607 + showIcon?: boolean; 608 608 }) { 609 609 // Random width between 50 to 90%. 610 610 const width = React.useMemo(() => { 611 - return `${Math.floor(Math.random() * 40) + 50}%` 612 - }, []) 611 + return `${Math.floor(Math.random() * 40) + 50}%`; 612 + }, []); 613 613 614 614 return ( 615 615 <div ··· 634 634 } 635 635 /> 636 636 </div> 637 - ) 637 + ); 638 638 } 639 639 640 640 function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { ··· 645 645 className={cn( 646 646 "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", 647 647 "group-data-[collapsible=icon]:hidden", 648 - className 648 + className, 649 649 )} 650 650 {...props} 651 651 /> 652 - ) 652 + ); 653 653 } 654 654 655 655 function SidebarMenuSubItem({ ··· 663 663 className={cn("group/menu-sub-item relative", className)} 664 664 {...props} 665 665 /> 666 - ) 666 + ); 667 667 } 668 668 669 669 function SidebarMenuSubButton({ ··· 673 673 className, 674 674 ...props 675 675 }: React.ComponentProps<"a"> & { 676 - asChild?: boolean 677 - size?: "sm" | "md" 678 - isActive?: boolean 676 + asChild?: boolean; 677 + size?: "sm" | "md"; 678 + isActive?: boolean; 679 679 }) { 680 - const Comp = asChild ? Slot.Root : "a" 680 + const Comp = asChild ? Slot.Root : "a"; 681 681 682 682 return ( 683 683 <Comp ··· 691 691 size === "sm" && "text-xs", 692 692 size === "md" && "text-sm", 693 693 "group-data-[collapsible=icon]:hidden", 694 - className 694 + className, 695 695 )} 696 696 {...props} 697 697 /> 698 - ) 698 + ); 699 699 } 700 700 701 701 export { ··· 723 723 SidebarSeparator, 724 724 SidebarTrigger, 725 725 useSidebar, 726 - } 726 + };
+62
web/src/lib/compose-refs.ts
··· 1 + import * as React from "react"; 2 + 3 + type PossibleRef<T> = React.Ref<T> | undefined; 4 + 5 + /** 6 + * Set a given ref to a given value 7 + * This utility takes care of different types of refs: callback refs and RefObject(s) 8 + */ 9 + function setRef<T>(ref: PossibleRef<T>, value: T) { 10 + if (typeof ref === "function") { 11 + return ref(value); 12 + } 13 + 14 + if (ref !== null && ref !== undefined) { 15 + ref.current = value; 16 + } 17 + } 18 + 19 + /** 20 + * A utility to compose multiple refs together 21 + * Accepts callback refs and RefObject(s) 22 + */ 23 + function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> { 24 + return (node) => { 25 + let hasCleanup = false; 26 + const cleanups = refs.map((ref) => { 27 + const cleanup = setRef(ref, node); 28 + if (!hasCleanup && typeof cleanup === "function") { 29 + hasCleanup = true; 30 + } 31 + return cleanup; 32 + }); 33 + 34 + // React <19 will log an error to the console if a callback ref returns a 35 + // value. We don't use ref cleanups internally so this will only happen if a 36 + // user's ref callback returns a value, which we only expect if they are 37 + // using the cleanup functionality added in React 19. 38 + if (hasCleanup) { 39 + return () => { 40 + for (let i = 0; i < cleanups.length; i++) { 41 + const cleanup = cleanups[i]; 42 + if (typeof cleanup === "function") { 43 + cleanup(); 44 + } else { 45 + setRef(refs[i], null); 46 + } 47 + } 48 + }; 49 + } 50 + }; 51 + } 52 + 53 + /** 54 + * A custom hook that composes multiple refs 55 + * Accepts callback refs and RefObject(s) 56 + */ 57 + function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> { 58 + // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values 59 + return React.useCallback(composeRefs(...refs), refs); 60 + } 61 + 62 + export { composeRefs, useComposedRefs };