@srcroot/ui 0.0.44 → 0.0.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,7 @@ interface DropdownMenuProps {
14
14
  open?: boolean
15
15
  onOpenChange?: (open: boolean) => void
16
16
  defaultOpen?: boolean
17
+ className?: string
17
18
  }
18
19
 
19
20
  /**
@@ -32,7 +33,7 @@ interface DropdownMenuProps {
32
33
  * </DropdownMenuContent>
33
34
  * </DropdownMenu>
34
35
  */
35
- function DropdownMenu({ children, open: controlledOpen, onOpenChange, defaultOpen = false }: DropdownMenuProps) {
36
+ function DropdownMenu({ children, open: controlledOpen, onOpenChange, defaultOpen = false, className }: DropdownMenuProps) {
36
37
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
37
38
  const triggerRef = React.useRef<HTMLButtonElement>(null)
38
39
 
@@ -41,13 +42,14 @@ function DropdownMenu({ children, open: controlledOpen, onOpenChange, defaultOpe
41
42
 
42
43
  return (
43
44
  <DropdownMenuContext.Provider value={{ open, onOpenChange: setOpen, triggerRef }}>
44
- <div className="relative inline-block text-left">
45
+ <div className={cn("relative inline-block text-left", className)}>
45
46
  {children}
46
47
  </div>
47
48
  </DropdownMenuContext.Provider>
48
49
  )
49
50
  }
50
51
 
52
+
51
53
  interface DropdownMenuTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
52
54
  asChild?: boolean
53
55
  }
@@ -107,6 +109,11 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
107
109
  const context = React.useContext(DropdownMenuContext)
108
110
  if (!context) throw new Error("DropdownMenuContent must be used within DropdownMenu")
109
111
  const contentRef = React.useRef<HTMLDivElement>(null)
112
+ const [currentSide, setCurrentSide] = React.useState(side)
113
+
114
+ React.useEffect(() => {
115
+ setCurrentSide(side)
116
+ }, [side])
110
117
 
111
118
  React.useEffect(() => {
112
119
  const handleClickOutside = (e: MouseEvent) => {
@@ -129,17 +136,53 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
129
136
  }
130
137
  }
131
138
 
139
+ const checkPosition = () => {
140
+ if (context.open && contentRef.current && context.triggerRef.current) {
141
+ const triggerRect = context.triggerRef.current.getBoundingClientRect()
142
+ const contentRect = contentRef.current.getBoundingClientRect()
143
+ const viewportHeight = window.innerHeight
144
+
145
+ const spaceBelow = viewportHeight - triggerRect.bottom
146
+ const spaceAbove = triggerRect.top
147
+ const neededHeight = contentRect.height + sideOffset
148
+
149
+ if (side === 'bottom') {
150
+ if (spaceBelow < neededHeight && spaceAbove > neededHeight) {
151
+ setCurrentSide('top')
152
+ } else {
153
+ setCurrentSide('bottom')
154
+ }
155
+ } else if (side === 'top') {
156
+ if (spaceAbove < neededHeight && spaceBelow > neededHeight) {
157
+ setCurrentSide('bottom')
158
+ } else {
159
+ setCurrentSide('top')
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ if (context.open) {
166
+ // Check position immediately after render cycle (via timeout to allow ref to populate and layout to occur)
167
+ // Using requestAnimationFrame for better timing in layout cycle
168
+ requestAnimationFrame(checkPosition)
169
+ }
170
+
132
171
  const timer = setTimeout(() => {
133
172
  document.addEventListener("click", handleClickOutside)
134
173
  }, 0)
135
174
  document.addEventListener("keydown", handleEscape)
175
+ window.addEventListener("resize", checkPosition)
176
+ window.addEventListener("scroll", checkPosition, true) // Capture scroll to update pos
136
177
 
137
178
  return () => {
138
179
  clearTimeout(timer)
139
180
  document.removeEventListener("click", handleClickOutside)
140
181
  document.removeEventListener("keydown", handleEscape)
182
+ window.removeEventListener("resize", checkPosition)
183
+ window.removeEventListener("scroll", checkPosition, true)
141
184
  }
142
- }, [context.open, context])
185
+ }, [context.open, context, side, sideOffset])
143
186
 
144
187
  if (!context.open) return null
145
188
 
@@ -150,12 +193,6 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
150
193
  end: 'right-0',
151
194
  }
152
195
 
153
- // Calculate side classes
154
- const sideClasses = {
155
- bottom: `top-full mt-${sideOffset}`,
156
- top: `bottom-full mb-${sideOffset}`,
157
- }
158
-
159
196
  return (
160
197
  <div
161
198
  ref={(node) => {
@@ -169,12 +206,12 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
169
206
  "absolute z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
170
207
  "animate-in fade-in-0 zoom-in-95",
171
208
  alignmentClasses[align],
172
- side === 'bottom' ? 'top-full' : 'bottom-full',
209
+ currentSide === 'bottom' ? 'top-full' : 'bottom-full',
173
210
  className
174
211
  )}
175
212
  style={{
176
- marginTop: side === 'bottom' ? sideOffset : undefined,
177
- marginBottom: side === 'top' ? sideOffset : undefined,
213
+ marginTop: currentSide === 'bottom' ? sideOffset : undefined,
214
+ marginBottom: currentSide === 'top' ? sideOffset : undefined,
178
215
  }}
179
216
  {...props}
180
217
  />
@@ -183,16 +220,54 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
183
220
  )
184
221
  DropdownMenuContent.displayName = "DropdownMenuContent"
185
222
 
223
+ const DropdownMenuGroup = React.forwardRef<
224
+ HTMLDivElement,
225
+ React.HTMLAttributes<HTMLDivElement>
226
+ >(({ className, ...props }, ref) => (
227
+ <div ref={ref} className={cn("", className)} {...props} />
228
+ ))
229
+ DropdownMenuGroup.displayName = "DropdownMenuGroup"
230
+
231
+ const DropdownMenuPortal = ({ children }: { children: React.ReactNode }) => {
232
+ return <>{children}</>
233
+ }
234
+ DropdownMenuPortal.displayName = "DropdownMenuPortal"
235
+
186
236
  const DropdownMenuItem = React.forwardRef<
187
237
  HTMLDivElement,
188
- React.HTMLAttributes<HTMLDivElement> & { inset?: boolean; disabled?: boolean }
189
- >(({ className, inset, disabled, onClick, ...props }, ref) => {
238
+ React.HTMLAttributes<HTMLDivElement> & { inset?: boolean; disabled?: boolean; asChild?: boolean; closeOnSelect?: boolean }
239
+ >(({ className, inset, disabled, onClick, asChild = false, closeOnSelect = true, ...props }, ref) => {
190
240
  const context = React.useContext(DropdownMenuContext)
191
241
 
192
242
  const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
193
243
  if (disabled) return
194
244
  onClick?.(e)
195
- context?.onOpenChange(false)
245
+ if (closeOnSelect) {
246
+ context?.onOpenChange(false)
247
+ }
248
+ }
249
+
250
+ if (asChild) {
251
+ const child = React.Children.only(props.children) as React.ReactElement<any>
252
+ return React.cloneElement(child, {
253
+ ref,
254
+ className: cn(
255
+ "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground",
256
+ inset && "pl-8",
257
+ disabled && "pointer-events-none opacity-50",
258
+ className,
259
+ child.props.className
260
+ ),
261
+ onClick: (e: React.MouseEvent<HTMLDivElement>) => {
262
+ handleClick(e)
263
+ child.props.onClick?.(e)
264
+ },
265
+ "data-disabled": disabled || undefined,
266
+ "data-inset": inset || undefined,
267
+ tabIndex: disabled ? -1 : 0,
268
+ ...props,
269
+ children: child.props.children
270
+ })
196
271
  }
197
272
 
198
273
  return (
@@ -403,4 +478,6 @@ export {
403
478
  DropdownMenuSub,
404
479
  DropdownMenuSubTrigger,
405
480
  DropdownMenuSubContent,
481
+ DropdownMenuGroup,
482
+ DropdownMenuPortal,
406
483
  }
@@ -83,8 +83,8 @@ PopoverTrigger.displayName = "PopoverTrigger"
83
83
 
84
84
  const PopoverContent = React.forwardRef<
85
85
  HTMLDivElement,
86
- React.HTMLAttributes<HTMLDivElement>
87
- >(({ className, children, ...props }, ref) => {
86
+ React.HTMLAttributes<HTMLDivElement> & { align?: "start" | "center" | "end" }
87
+ >(({ className, children, align = "center", ...props }, ref) => {
88
88
  const context = React.useContext(PopoverContext)
89
89
  if (!context) throw new Error("PopoverContent must be used within Popover")
90
90
 
@@ -121,6 +121,7 @@ const PopoverContent = React.forwardRef<
121
121
  ref={ref}
122
122
  className={cn(
123
123
  "absolute z-50 mt-2 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none",
124
+ align === "end" ? "right-0" : align === "start" ? "left-0" : "left-1/2 -translate-x-1/2",
124
125
  className
125
126
  )}
126
127
  onClick={(e) => e.stopPropagation()}
@@ -492,6 +492,159 @@ const SidebarMenuButton = React.forwardRef<
492
492
  )
493
493
  SidebarMenuButton.displayName = "SidebarMenuButton"
494
494
 
495
+ const SidebarMenuAction = React.forwardRef<
496
+ HTMLButtonElement,
497
+ React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean; showOnHover?: boolean }
498
+ >(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
499
+ if (asChild) {
500
+ const child = React.Children.only(props.children) as React.ReactElement<any>
501
+ return React.cloneElement(child, {
502
+ ref,
503
+ "data-sidebar": "menu-action",
504
+ className: cn(
505
+ "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none 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",
506
+ // Increases the hit area of the button on mobile.
507
+ "after:absolute after:-inset-2 after:md:hidden",
508
+ "peer-data-[size=sm]/menu-button:top-1",
509
+ "peer-data-[size=default]/menu-button:top-1.5",
510
+ "peer-data-[size=lg]/menu-button:top-2.5",
511
+ "group-data-[collapsible=icon]:hidden",
512
+ showOnHover &&
513
+ "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",
514
+ className,
515
+ child.props.className
516
+ ),
517
+ ...props,
518
+ children: child.props.children
519
+ })
520
+ }
521
+
522
+ return (
523
+ <button
524
+ ref={ref}
525
+ data-sidebar="menu-action"
526
+ className={cn(
527
+ "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none 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",
528
+ // Increases the hit area of the button on mobile.
529
+ "after:absolute after:-inset-2 after:md:hidden",
530
+ "peer-data-[size=sm]/menu-button:top-1",
531
+ "peer-data-[size=default]/menu-button:top-1.5",
532
+ "peer-data-[size=lg]/menu-button:top-2.5",
533
+ "group-data-[collapsible=icon]:hidden",
534
+ showOnHover &&
535
+ "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",
536
+ className
537
+ )}
538
+ {...props}
539
+ />
540
+ )
541
+ })
542
+ SidebarMenuAction.displayName = "SidebarMenuAction"
543
+
544
+ const SidebarMenuSub = React.forwardRef<
545
+ HTMLUListElement,
546
+ React.HTMLAttributes<HTMLUListElement>
547
+ >(({ className, ...props }, ref) => (
548
+ <ul
549
+ ref={ref}
550
+ data-sidebar="menu-sub"
551
+ className={cn(
552
+ "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
553
+ "group-data-[collapsible=icon]:hidden",
554
+ className
555
+ )}
556
+ {...props}
557
+ />
558
+ ))
559
+ SidebarMenuSub.displayName = "SidebarMenuSub"
560
+
561
+ const SidebarMenuSubItem = React.forwardRef<
562
+ HTMLLIElement,
563
+ React.LiHTMLAttributes<HTMLLIElement>
564
+ >(({ ...props }, ref) => <li ref={ref} {...props} />)
565
+ SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
566
+
567
+ const SidebarMenuSubButton = React.forwardRef<
568
+ HTMLAnchorElement,
569
+ React.AnchorHTMLAttributes<HTMLAnchorElement> & {
570
+ asChild?: boolean
571
+ size?: "sm" | "md"
572
+ isActive?: boolean
573
+ }
574
+ >(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
575
+ if (asChild) {
576
+ const child = React.Children.only(props.children) as React.ReactElement<any>
577
+ return React.cloneElement(child, {
578
+ ref,
579
+ "data-sidebar": "menu-sub-button",
580
+ "data-size": size,
581
+ "data-active": isActive,
582
+ className: cn(
583
+ "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none 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 group-data-[collapsible=icon]:hidden",
584
+ size === "sm" && "text-xs",
585
+ size === "md" && "text-sm",
586
+ "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
587
+ className,
588
+ child.props.className
589
+ ),
590
+ ...props,
591
+ children: child.props.children
592
+ })
593
+ }
594
+
595
+ return (
596
+ <a
597
+ ref={ref}
598
+ data-sidebar="menu-sub-button"
599
+ data-size={size}
600
+ data-active={isActive}
601
+ className={cn(
602
+ "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none 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 group-data-[collapsible=icon]:hidden",
603
+ size === "sm" && "text-xs",
604
+ size === "md" && "text-sm",
605
+ "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
606
+ className
607
+ )}
608
+ {...props}
609
+ />
610
+ )
611
+ })
612
+ SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
613
+
614
+ const SidebarInput = React.forwardRef<
615
+ HTMLInputElement,
616
+ React.InputHTMLAttributes<HTMLInputElement>
617
+ >(({ className, ...props }, ref) => {
618
+ return (
619
+ <input
620
+ ref={ref}
621
+ data-sidebar="input"
622
+ className={cn(
623
+ "flex h-8 w-full bg-background rounded-md px-3 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 group-data-[collapsible=icon]:w-0 group-data-[collapsible=icon]:p-0 group-data-[collapsible=icon]:border-0 group-data-[collapsible=icon]:hidden",
624
+ className
625
+ )}
626
+ {...props}
627
+ />
628
+ )
629
+ })
630
+ SidebarInput.displayName = "SidebarInput"
631
+
632
+
633
+ const SidebarSeparator = React.forwardRef<
634
+ HTMLDivElement,
635
+ React.HTMLAttributes<HTMLDivElement>
636
+ >(({ className, ...props }, ref) => {
637
+ return (
638
+ <div
639
+ ref={ref}
640
+ data-sidebar="separator"
641
+ className={cn("mx-2 w-auto bg-sidebar-border h-px", className)}
642
+ {...props}
643
+ />
644
+ )
645
+ })
646
+ SidebarSeparator.displayName = "SidebarSeparator"
647
+
495
648
  export {
496
649
  Sidebar,
497
650
  SidebarContent,
@@ -500,12 +653,18 @@ export {
500
653
  SidebarGroupContent,
501
654
  SidebarGroupLabel,
502
655
  SidebarHeader,
656
+ SidebarInput,
503
657
  SidebarInset,
504
658
  SidebarMenu,
659
+ SidebarMenuAction,
505
660
  SidebarMenuButton,
506
661
  SidebarMenuItem,
662
+ SidebarMenuSub,
663
+ SidebarMenuSubButton,
664
+ SidebarMenuSubItem,
507
665
  SidebarProvider,
508
666
  SidebarRail,
667
+ SidebarSeparator,
509
668
  SidebarTrigger,
510
669
  useSidebar,
511
670
  }
@@ -0,0 +1,66 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { useTheme } from "next-themes"
5
+ import { FiSun, FiMoon, FiMonitor } from "react-icons/fi"
6
+
7
+ import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
8
+
9
+ /**
10
+ * ThemeSwitcher component for use within a DropdownMenu
11
+ * Toggles between light, dark, and system themes
12
+ *
13
+ * @example
14
+ * <DropdownMenu>
15
+ * <DropdownMenuContent>
16
+ * <ThemeSwitcher />
17
+ * </DropdownMenuContent>
18
+ * </DropdownMenu>
19
+ */
20
+ export function ThemeSwitcher() {
21
+ const { setTheme, theme, resolvedTheme } = useTheme()
22
+ const [mounted, setMounted] = React.useState(false)
23
+
24
+ // Avoid hydration mismatch
25
+ React.useEffect(() => {
26
+ setMounted(true)
27
+ }, [])
28
+
29
+ const toggleTheme = () => {
30
+ // Cycle: light -> dark -> system -> light
31
+ if (theme === "light") {
32
+ setTheme("dark")
33
+ } else if (theme === "dark") {
34
+ setTheme("system")
35
+ } else {
36
+ setTheme("light")
37
+ }
38
+ }
39
+
40
+ const getIcon = () => {
41
+ if (!mounted) {
42
+ return <FiSun className="mr-2 h-4 w-4" />
43
+ }
44
+ if (theme === "system") {
45
+ return <FiMonitor className="mr-2 h-4 w-4" />
46
+ }
47
+ if (resolvedTheme === "dark") {
48
+ return <FiMoon className="mr-2 h-4 w-4" />
49
+ }
50
+ return <FiSun className="mr-2 h-4 w-4" />
51
+ }
52
+
53
+ const getLabel = () => {
54
+ if (!mounted) return "Theme"
55
+ if (theme === "system") return "System"
56
+ if (theme === "dark") return "Dark"
57
+ return "Light"
58
+ }
59
+
60
+ return (
61
+ <DropdownMenuItem onClick={toggleTheme} closeOnSelect={false}>
62
+ {getIcon()}
63
+ <span>Theme: {getLabel()}</span>
64
+ </DropdownMenuItem>
65
+ )
66
+ }