@djangocfg/ui-core 1.0.1

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.
Files changed (88) hide show
  1. package/README.md +135 -0
  2. package/package.json +111 -0
  3. package/src/components/accordion.tsx +56 -0
  4. package/src/components/alert-dialog.tsx +142 -0
  5. package/src/components/alert.tsx +59 -0
  6. package/src/components/aspect-ratio.tsx +7 -0
  7. package/src/components/avatar.tsx +50 -0
  8. package/src/components/badge.tsx +36 -0
  9. package/src/components/button-group.tsx +85 -0
  10. package/src/components/button.tsx +111 -0
  11. package/src/components/calendar.tsx +213 -0
  12. package/src/components/card.tsx +76 -0
  13. package/src/components/carousel.tsx +261 -0
  14. package/src/components/chart.tsx +369 -0
  15. package/src/components/checkbox.tsx +29 -0
  16. package/src/components/collapsible.tsx +11 -0
  17. package/src/components/combobox.tsx +182 -0
  18. package/src/components/command.tsx +170 -0
  19. package/src/components/context-menu.tsx +200 -0
  20. package/src/components/copy.tsx +144 -0
  21. package/src/components/dialog.tsx +122 -0
  22. package/src/components/drawer.tsx +137 -0
  23. package/src/components/empty.tsx +104 -0
  24. package/src/components/field.tsx +244 -0
  25. package/src/components/form.tsx +178 -0
  26. package/src/components/hover-card.tsx +29 -0
  27. package/src/components/image-with-fallback.tsx +170 -0
  28. package/src/components/index.ts +86 -0
  29. package/src/components/input-group.tsx +170 -0
  30. package/src/components/input-otp.tsx +81 -0
  31. package/src/components/input.tsx +22 -0
  32. package/src/components/item.tsx +195 -0
  33. package/src/components/kbd.tsx +28 -0
  34. package/src/components/label.tsx +26 -0
  35. package/src/components/multi-select.tsx +222 -0
  36. package/src/components/og-image.tsx +47 -0
  37. package/src/components/popover.tsx +33 -0
  38. package/src/components/portal.tsx +106 -0
  39. package/src/components/preloader.tsx +250 -0
  40. package/src/components/progress.tsx +28 -0
  41. package/src/components/radio-group.tsx +43 -0
  42. package/src/components/resizable.tsx +111 -0
  43. package/src/components/scroll-area.tsx +102 -0
  44. package/src/components/section.tsx +58 -0
  45. package/src/components/select.tsx +158 -0
  46. package/src/components/separator.tsx +31 -0
  47. package/src/components/sheet.tsx +140 -0
  48. package/src/components/skeleton.tsx +15 -0
  49. package/src/components/slider.tsx +28 -0
  50. package/src/components/spinner.tsx +16 -0
  51. package/src/components/sticky.tsx +117 -0
  52. package/src/components/switch.tsx +29 -0
  53. package/src/components/table.tsx +120 -0
  54. package/src/components/tabs.tsx +238 -0
  55. package/src/components/textarea.tsx +22 -0
  56. package/src/components/toast.tsx +129 -0
  57. package/src/components/toaster.tsx +41 -0
  58. package/src/components/toggle-group.tsx +61 -0
  59. package/src/components/toggle.tsx +45 -0
  60. package/src/components/token-icon.tsx +156 -0
  61. package/src/components/tooltip-provider-safe.tsx +43 -0
  62. package/src/components/tooltip.tsx +32 -0
  63. package/src/hooks/index.ts +15 -0
  64. package/src/hooks/useCopy.ts +41 -0
  65. package/src/hooks/useCountdown.ts +73 -0
  66. package/src/hooks/useDebounce.ts +25 -0
  67. package/src/hooks/useDebouncedCallback.ts +58 -0
  68. package/src/hooks/useDebugTools.ts +52 -0
  69. package/src/hooks/useEventsBus.ts +53 -0
  70. package/src/hooks/useImageLoader.ts +95 -0
  71. package/src/hooks/useMediaQuery.ts +40 -0
  72. package/src/hooks/useMobile.tsx +22 -0
  73. package/src/hooks/useToast.ts +194 -0
  74. package/src/index.ts +14 -0
  75. package/src/lib/index.ts +2 -0
  76. package/src/lib/og-image.ts +151 -0
  77. package/src/lib/utils.ts +6 -0
  78. package/src/styles/base.css +20 -0
  79. package/src/styles/globals.css +12 -0
  80. package/src/styles/index.css +25 -0
  81. package/src/styles/sources.css +11 -0
  82. package/src/styles/theme/animations.css +65 -0
  83. package/src/styles/theme/dark.css +49 -0
  84. package/src/styles/theme/light.css +50 -0
  85. package/src/styles/theme/tokens.css +134 -0
  86. package/src/styles/theme.css +22 -0
  87. package/src/styles/utilities.css +187 -0
  88. package/src/types/index.ts +0 -0
@@ -0,0 +1,195 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Slot } from "@radix-ui/react-slot"
5
+ import { cva, type VariantProps } from "class-variance-authority"
6
+
7
+ import { cn } from "../lib/utils"
8
+ import { Separator } from "./separator"
9
+
10
+ function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
11
+ return (
12
+ <div
13
+ role="list"
14
+ data-slot="item-group"
15
+ className={cn("group/item-group flex flex-col", className)}
16
+ {...props}
17
+ />
18
+ )
19
+ }
20
+
21
+ function ItemSeparator({
22
+ className,
23
+ ...props
24
+ }: React.ComponentProps<typeof Separator>) {
25
+ return (
26
+ <Separator
27
+ data-slot="item-separator"
28
+ orientation="horizontal"
29
+ className={cn("my-0", className)}
30
+ {...props}
31
+ />
32
+ )
33
+ }
34
+
35
+ const itemVariants = cva(
36
+ "group/item [a]:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-ring/50 [a]:transition-colors flex flex-wrap items-center rounded-md border border-transparent text-sm outline-none transition-colors duration-100 focus-visible:ring-[3px]",
37
+ {
38
+ variants: {
39
+ variant: {
40
+ default: "bg-transparent",
41
+ outline: "border-border",
42
+ muted: "bg-muted/50",
43
+ },
44
+ size: {
45
+ default: "gap-4 p-4 ",
46
+ sm: "gap-2.5 px-4 py-3",
47
+ },
48
+ },
49
+ defaultVariants: {
50
+ variant: "default",
51
+ size: "default",
52
+ },
53
+ }
54
+ )
55
+
56
+ function Item({
57
+ className,
58
+ variant = "default",
59
+ size = "default",
60
+ asChild = false,
61
+ ...props
62
+ }: React.ComponentProps<"div"> &
63
+ VariantProps<typeof itemVariants> & { asChild?: boolean }) {
64
+ const Comp = asChild ? Slot : "div"
65
+ return (
66
+ <Comp
67
+ data-slot="item"
68
+ data-variant={variant}
69
+ data-size={size}
70
+ className={cn(itemVariants({ variant, size, className }))}
71
+ {...props}
72
+ />
73
+ )
74
+ }
75
+
76
+ const itemMediaVariants = cva(
77
+ "flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none",
78
+ {
79
+ variants: {
80
+ variant: {
81
+ default: "bg-transparent",
82
+ icon: "bg-muted size-8 rounded-sm border [&_svg:not([class*='size-'])]:size-4",
83
+ image:
84
+ "size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover",
85
+ },
86
+ },
87
+ defaultVariants: {
88
+ variant: "default",
89
+ },
90
+ }
91
+ )
92
+
93
+ function ItemMedia({
94
+ className,
95
+ variant = "default",
96
+ ...props
97
+ }: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
98
+ return (
99
+ <div
100
+ data-slot="item-media"
101
+ data-variant={variant}
102
+ className={cn(itemMediaVariants({ variant, className }))}
103
+ {...props}
104
+ />
105
+ )
106
+ }
107
+
108
+ function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
109
+ return (
110
+ <div
111
+ data-slot="item-content"
112
+ className={cn(
113
+ "flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
114
+ className
115
+ )}
116
+ {...props}
117
+ />
118
+ )
119
+ }
120
+
121
+ function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
122
+ return (
123
+ <div
124
+ data-slot="item-title"
125
+ className={cn(
126
+ "flex w-fit items-center gap-2 text-sm font-medium leading-snug",
127
+ className
128
+ )}
129
+ {...props}
130
+ />
131
+ )
132
+ }
133
+
134
+ function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
135
+ return (
136
+ <p
137
+ data-slot="item-description"
138
+ className={cn(
139
+ "text-muted-foreground line-clamp-2 text-balance text-sm font-normal leading-normal",
140
+ "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
141
+ className
142
+ )}
143
+ {...props}
144
+ />
145
+ )
146
+ }
147
+
148
+ function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
149
+ return (
150
+ <div
151
+ data-slot="item-actions"
152
+ className={cn("flex items-center gap-2", className)}
153
+ {...props}
154
+ />
155
+ )
156
+ }
157
+
158
+ function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
159
+ return (
160
+ <div
161
+ data-slot="item-header"
162
+ className={cn(
163
+ "flex basis-full items-center justify-between gap-2",
164
+ className
165
+ )}
166
+ {...props}
167
+ />
168
+ )
169
+ }
170
+
171
+ function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
172
+ return (
173
+ <div
174
+ data-slot="item-footer"
175
+ className={cn(
176
+ "flex basis-full items-center justify-between gap-2",
177
+ className
178
+ )}
179
+ {...props}
180
+ />
181
+ )
182
+ }
183
+
184
+ export {
185
+ Item,
186
+ ItemMedia,
187
+ ItemContent,
188
+ ItemActions,
189
+ ItemGroup,
190
+ ItemSeparator,
191
+ ItemTitle,
192
+ ItemDescription,
193
+ ItemHeader,
194
+ ItemFooter,
195
+ }
@@ -0,0 +1,28 @@
1
+ import { cn } from "../lib/utils"
2
+
3
+ function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
4
+ return (
5
+ <kbd
6
+ data-slot="kbd"
7
+ className={cn(
8
+ "bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 select-none items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium",
9
+ "[&_svg:not([class*='size-'])]:size-3",
10
+ "[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
11
+ className
12
+ )}
13
+ {...props}
14
+ />
15
+ )
16
+ }
17
+
18
+ function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
19
+ return (
20
+ <kbd
21
+ data-slot="kbd-group"
22
+ className={cn("inline-flex items-center gap-1", className)}
23
+ {...props}
24
+ />
25
+ )
26
+ }
27
+
28
+ export { Kbd, KbdGroup }
@@ -0,0 +1,26 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as LabelPrimitive from "@radix-ui/react-label"
5
+ import { cva, type VariantProps } from "class-variance-authority"
6
+
7
+ import { cn } from "../lib/utils"
8
+
9
+ const labelVariants = cva(
10
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11
+ )
12
+
13
+ const Label = React.forwardRef<
14
+ React.ElementRef<typeof LabelPrimitive.Root>,
15
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
16
+ VariantProps<typeof labelVariants>
17
+ >(({ className, ...props }, ref) => (
18
+ <LabelPrimitive.Root
19
+ ref={ref}
20
+ className={cn(labelVariants(), className)}
21
+ {...props}
22
+ />
23
+ ))
24
+ Label.displayName = LabelPrimitive.Root.displayName
25
+
26
+ export { Label }
@@ -0,0 +1,222 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Check, ChevronsUpDown, X } from "lucide-react"
5
+ import { cn } from "../lib/utils"
6
+ import { Button } from "./button"
7
+ import {
8
+ Command,
9
+ CommandEmpty,
10
+ CommandGroup,
11
+ CommandInput,
12
+ CommandItem,
13
+ CommandList,
14
+ } from "./command"
15
+ import {
16
+ Popover,
17
+ PopoverContent,
18
+ PopoverTrigger,
19
+ } from "./popover"
20
+ import { Badge } from "./badge"
21
+
22
+ export interface MultiSelectOption {
23
+ value: string
24
+ label: string
25
+ description?: string
26
+ disabled?: boolean
27
+ }
28
+
29
+ export interface MultiSelectProps {
30
+ options: MultiSelectOption[]
31
+ value?: string[]
32
+ onChange?: (value: string[]) => void
33
+ placeholder?: string
34
+ searchPlaceholder?: string
35
+ emptyText?: string
36
+ className?: string
37
+ disabled?: boolean
38
+ maxDisplay?: number
39
+ }
40
+
41
+ export function MultiSelect({
42
+ options,
43
+ value = [],
44
+ onChange,
45
+ placeholder = "Select options...",
46
+ searchPlaceholder = "Search...",
47
+ emptyText = "No results found.",
48
+ className,
49
+ disabled = false,
50
+ maxDisplay = 3,
51
+ }: MultiSelectProps) {
52
+ const [open, setOpen] = React.useState(false)
53
+ const [search, setSearch] = React.useState("")
54
+ const scrollRef = React.useRef<HTMLDivElement>(null)
55
+
56
+ React.useEffect(() => {
57
+ if (scrollRef.current && open) {
58
+ const el = scrollRef.current
59
+ el.style.cssText = `
60
+ max-height: 300px !important;
61
+ overflow-y: scroll !important;
62
+ overflow-x: hidden !important;
63
+ -webkit-overflow-scrolling: touch !important;
64
+ overscroll-behavior: contain !important;
65
+ `
66
+ }
67
+ }, [open])
68
+
69
+ const selectedOptions = React.useMemo(
70
+ () => options.filter((option) => value.includes(option.value)),
71
+ [options, value]
72
+ )
73
+
74
+ const filteredOptions = React.useMemo(() => {
75
+ if (!search) return options
76
+ const searchLower = search.toLowerCase()
77
+ return options.filter(
78
+ (option) =>
79
+ option.label.toLowerCase().includes(searchLower) ||
80
+ option.value.toLowerCase().includes(searchLower) ||
81
+ option.description?.toLowerCase().includes(searchLower)
82
+ )
83
+ }, [options, search])
84
+
85
+ const handleSelect = (optionValue: string) => {
86
+ const newValue = value.includes(optionValue)
87
+ ? value.filter((v) => v !== optionValue)
88
+ : [...value, optionValue]
89
+ onChange?.(newValue)
90
+ }
91
+
92
+ const handleRemove = (optionValue: string, e: React.MouseEvent) => {
93
+ e.stopPropagation()
94
+ onChange?.(value.filter((v) => v !== optionValue))
95
+ }
96
+
97
+ const displayValue = React.useMemo(() => {
98
+ if (selectedOptions.length === 0) {
99
+ return <span className="text-muted-foreground">{placeholder}</span>
100
+ }
101
+
102
+ const displayed = selectedOptions.slice(0, maxDisplay)
103
+ const remaining = selectedOptions.length - maxDisplay
104
+
105
+ return (
106
+ <div className="flex flex-wrap gap-1">
107
+ {displayed.map((option) => (
108
+ <Badge
109
+ key={option.value}
110
+ variant="secondary"
111
+ className="mr-1 text-xs"
112
+ >
113
+ {option.label}
114
+ <button
115
+ className="ml-1 rounded-full hover:bg-muted-foreground/20"
116
+ onClick={(e) => handleRemove(option.value, e)}
117
+ disabled={disabled}
118
+ >
119
+ <X className="h-3 w-3" />
120
+ </button>
121
+ </Badge>
122
+ ))}
123
+ {remaining > 0 && (
124
+ <Badge variant="outline" className="text-xs">
125
+ +{remaining} more
126
+ </Badge>
127
+ )}
128
+ </div>
129
+ )
130
+ }, [selectedOptions, maxDisplay, placeholder, disabled])
131
+
132
+ return (
133
+ <Popover
134
+ open={open}
135
+ onOpenChange={(isOpen) => {
136
+ setOpen(isOpen)
137
+ if (!isOpen) {
138
+ setSearch("")
139
+ }
140
+ }}
141
+ >
142
+ <PopoverTrigger asChild>
143
+ <Button
144
+ variant="outline"
145
+ role="combobox"
146
+ aria-expanded={open}
147
+ className={cn(
148
+ "w-full justify-between min-h-10 h-auto py-2",
149
+ className
150
+ )}
151
+ disabled={disabled}
152
+ >
153
+ <div className="flex-1 text-left overflow-hidden">
154
+ {displayValue}
155
+ </div>
156
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
157
+ </Button>
158
+ </PopoverTrigger>
159
+ <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
160
+ <Command shouldFilter={false} className="flex flex-col">
161
+ <CommandInput
162
+ placeholder={searchPlaceholder}
163
+ className="shrink-0"
164
+ value={search}
165
+ onValueChange={setSearch}
166
+ />
167
+ <div
168
+ ref={scrollRef}
169
+ tabIndex={-1}
170
+ className="overflow-y-scroll overflow-x-hidden"
171
+ style={{
172
+ maxHeight: '300px',
173
+ minHeight: '100px',
174
+ }}
175
+ onWheel={(e) => {
176
+ e.stopPropagation()
177
+ }}
178
+ >
179
+ <CommandList className="!max-h-none !overflow-visible" style={{ pointerEvents: 'auto' }}>
180
+ {filteredOptions.length === 0 ? (
181
+ <CommandEmpty>{emptyText}</CommandEmpty>
182
+ ) : (
183
+ <CommandGroup className="!overflow-visible" style={{ pointerEvents: 'auto' }}>
184
+ {filteredOptions.map((option) => {
185
+ const isSelected = value.includes(option.value)
186
+ return (
187
+ <CommandItem
188
+ key={option.value}
189
+ value={option.value}
190
+ onSelect={() => {
191
+ if (!option.disabled) {
192
+ handleSelect(option.value)
193
+ }
194
+ }}
195
+ disabled={option.disabled}
196
+ >
197
+ <Check
198
+ className={cn(
199
+ "mr-2 h-4 w-4 shrink-0",
200
+ isSelected ? "opacity-100" : "opacity-0"
201
+ )}
202
+ />
203
+ <div className="flex flex-col flex-1 min-w-0">
204
+ <span className="truncate">{option.label}</span>
205
+ {option.description && (
206
+ <span className="text-xs text-muted-foreground truncate">
207
+ {option.description}
208
+ </span>
209
+ )}
210
+ </div>
211
+ </CommandItem>
212
+ )
213
+ })}
214
+ </CommandGroup>
215
+ )}
216
+ </CommandList>
217
+ </div>
218
+ </Command>
219
+ </PopoverContent>
220
+ </Popover>
221
+ )
222
+ }
@@ -0,0 +1,47 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { generateOgImageUrl, type OgImageUrlParams, type GenerateOgImageUrlOptions } from '../lib/og-image'
5
+ import { cn } from '../lib/utils'
6
+
7
+ export interface OgImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
8
+ /** OG Image parameters */
9
+ params: OgImageUrlParams
10
+ /** Generation options (baseUrl, useBase64) */
11
+ options?: GenerateOgImageUrlOptions
12
+ }
13
+
14
+ /**
15
+ * OgImage Component
16
+ *
17
+ * Renders an image using the OG Image API with auto-generated URL.
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * <OgImage
22
+ * params={{ title: 'My Page', description: 'Description' }}
23
+ * className="rounded-lg"
24
+ * />
25
+ * ```
26
+ */
27
+ const OgImage = React.forwardRef<HTMLImageElement, OgImageProps>(
28
+ ({ params, options, className, alt, ...props }, ref) => {
29
+ const src = React.useMemo(
30
+ () => generateOgImageUrl(params, options),
31
+ [params, options]
32
+ )
33
+
34
+ return (
35
+ <img
36
+ ref={ref}
37
+ src={src}
38
+ alt={alt || params.title}
39
+ className={cn('w-full', className)}
40
+ {...props}
41
+ />
42
+ )
43
+ }
44
+ )
45
+ OgImage.displayName = 'OgImage'
46
+
47
+ export { OgImage }
@@ -0,0 +1,33 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as PopoverPrimitive from "@radix-ui/react-popover"
5
+
6
+ import { cn } from "../lib/utils"
7
+
8
+ const Popover = PopoverPrimitive.Root
9
+
10
+ const PopoverTrigger = PopoverPrimitive.Trigger
11
+
12
+ const PopoverAnchor = PopoverPrimitive.Anchor
13
+
14
+ const PopoverContent = React.forwardRef<
15
+ React.ElementRef<typeof PopoverPrimitive.Content>,
16
+ React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
17
+ >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
18
+ <PopoverPrimitive.Portal>
19
+ <PopoverPrimitive.Content
20
+ ref={ref}
21
+ align={align}
22
+ sideOffset={sideOffset}
23
+ className={cn(
24
+ "z-200 w-72 rounded-sm border bg-popover backdrop-blur-xl p-4 text-popover-foreground shadow-sm outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
25
+ className
26
+ )}
27
+ {...props}
28
+ />
29
+ </PopoverPrimitive.Portal>
30
+ ))
31
+ PopoverContent.displayName = PopoverPrimitive.Content.displayName
32
+
33
+ export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { createPortal } from 'react-dom';
5
+
6
+ export interface PortalProps {
7
+ /**
8
+ * The content to be rendered inside the portal.
9
+ */
10
+ children: React.ReactNode;
11
+ /**
12
+ * The container element to render the portal into.
13
+ * Defaults to document.body.
14
+ * Can be a DOM element, a function returning a DOM element, or a ref.
15
+ */
16
+ container?: Element | (() => Element | null) | React.RefObject<Element | null> | null;
17
+ /**
18
+ * Disable the portal behavior.
19
+ * The children stay within the normal DOM hierarchy.
20
+ * @default false
21
+ */
22
+ disablePortal?: boolean;
23
+ }
24
+
25
+ /**
26
+ * Portal component renders children into a different part of the DOM.
27
+ * Similar to MUI Portal - useful for modals, tooltips, floating elements.
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * // Render to document.body (default)
32
+ * <Portal>
33
+ * <div className="fixed inset-0 z-50">Modal content</div>
34
+ * </Portal>
35
+ *
36
+ * // Render to custom container
37
+ * <Portal container={document.getElementById('modal-root')}>
38
+ * <div>Custom container content</div>
39
+ * </Portal>
40
+ *
41
+ * // Disable portal (render in place)
42
+ * <Portal disablePortal>
43
+ * <div>Rendered normally</div>
44
+ * </Portal>
45
+ *
46
+ * // With ref container
47
+ * const containerRef = useRef<HTMLDivElement>(null);
48
+ * <div ref={containerRef} />
49
+ * <Portal container={containerRef}>
50
+ * <div>Content in ref container</div>
51
+ * </Portal>
52
+ * ```
53
+ */
54
+ export const Portal = React.forwardRef<HTMLDivElement, PortalProps>(
55
+ function Portal(props, ref) {
56
+ const { children, container, disablePortal = false } = props;
57
+ const [mountNode, setMountNode] = React.useState<Element | null>(null);
58
+
59
+ React.useEffect(() => {
60
+ if (!disablePortal) {
61
+ setMountNode(getContainer(container) || document.body);
62
+ }
63
+ }, [container, disablePortal]);
64
+
65
+ if (disablePortal) {
66
+ if (React.isValidElement(children)) {
67
+ return React.cloneElement(children as React.ReactElement<{ ref?: React.Ref<HTMLDivElement> }>, {
68
+ ref: ref,
69
+ });
70
+ }
71
+ return <>{children}</>;
72
+ }
73
+
74
+ if (mountNode) {
75
+ return createPortal(children, mountNode);
76
+ }
77
+
78
+ // SSR: return null until mounted
79
+ return null;
80
+ }
81
+ );
82
+
83
+ Portal.displayName = 'Portal';
84
+
85
+ /**
86
+ * Resolves the container from various input types
87
+ */
88
+ function getContainer(
89
+ container: PortalProps['container']
90
+ ): Element | null {
91
+ if (container === null || container === undefined) {
92
+ return null;
93
+ }
94
+
95
+ if (typeof container === 'function') {
96
+ return container();
97
+ }
98
+
99
+ if ('current' in container) {
100
+ return container.current;
101
+ }
102
+
103
+ return container;
104
+ }
105
+
106
+ export type { PortalProps as PortalPropsType };