@gentleduck/registry-ui 0.2.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 (175) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/index.css +3 -0
  3. package/package.json +59 -0
  4. package/src/_old/_table/index.ts +5 -0
  5. package/src/_old/_table/table-advanced.constants.tsx +24 -0
  6. package/src/_old/_table/table-advanced.tsx +311 -0
  7. package/src/_old/_table/table-advanced.types.ts +272 -0
  8. package/src/_old/_table/table.constants.ts +2 -0
  9. package/src/_old/_table/table.hook.tsx +115 -0
  10. package/src/_old/_table/table.lib.ts +85 -0
  11. package/src/_old/_table/table.tsx +916 -0
  12. package/src/_old/_table/table.types.ts +118 -0
  13. package/src/_old/_table/todo.md +11 -0
  14. package/src/_old/_upload/index.ts +9 -0
  15. package/src/_old/_upload/todo.md +38 -0
  16. package/src/_old/_upload/upload-advanced-chunks.tsx +1624 -0
  17. package/src/_old/_upload/upload-advanced.tsx +507 -0
  18. package/src/_old/_upload/upload-sonner.tsx +58 -0
  19. package/src/_old/_upload/upload.assets.tsx +239 -0
  20. package/src/_old/_upload/upload.constants.tsx +75 -0
  21. package/src/_old/_upload/upload.dto.ts +19 -0
  22. package/src/_old/_upload/upload.lib.tsx +630 -0
  23. package/src/_old/_upload/upload.tsx +491 -0
  24. package/src/_old/_upload/upload.types.ts +436 -0
  25. package/src/accordion/accordion.tsx +247 -0
  26. package/src/accordion/index.ts +1 -0
  27. package/src/alert/alert.constants.ts +17 -0
  28. package/src/alert/alert.tsx +52 -0
  29. package/src/alert/index.ts +2 -0
  30. package/src/alert-dialog/alert-dialog.tsx +107 -0
  31. package/src/alert-dialog/index.ts +1 -0
  32. package/src/aspect-ratio/aspect-ratio.tsx +33 -0
  33. package/src/aspect-ratio/index.ts +1 -0
  34. package/src/audio/audio-record.tsx +776 -0
  35. package/src/audio/audio-visualizer.tsx +377 -0
  36. package/src/audio/audio.libs.ts +5 -0
  37. package/src/audio/audio.types.ts +50 -0
  38. package/src/audio/index.ts +2 -0
  39. package/src/avatar/avatar.tsx +78 -0
  40. package/src/avatar/index.ts +1 -0
  41. package/src/badge/badge.constants.ts +38 -0
  42. package/src/badge/badge.tsx +19 -0
  43. package/src/badge/index.ts +2 -0
  44. package/src/breadcrumb/breadcrumb.tsx +119 -0
  45. package/src/breadcrumb/index.ts +1 -0
  46. package/src/button/button.constants.ts +44 -0
  47. package/src/button/button.tsx +79 -0
  48. package/src/button/button.types.ts +38 -0
  49. package/src/button/index.ts +3 -0
  50. package/src/button-group/button-group.constants.ts +26 -0
  51. package/src/button-group/button-group.tsx +65 -0
  52. package/src/button-group/index.ts +2 -0
  53. package/src/calendar/calendar.tsx +191 -0
  54. package/src/calendar/index.ts +1 -0
  55. package/src/card/card.tsx +81 -0
  56. package/src/card/index.ts +1 -0
  57. package/src/carousel/carousel.tsx +211 -0
  58. package/src/carousel/carousel.types.ts +23 -0
  59. package/src/carousel/index.ts +2 -0
  60. package/src/chart/chart.libs.ts +27 -0
  61. package/src/chart/chart.tsx +260 -0
  62. package/src/chart/chart.types.ts +38 -0
  63. package/src/chart/index.ts +3 -0
  64. package/src/checkbox/checkbox.tsx +144 -0
  65. package/src/checkbox/checkbox.types.ts +24 -0
  66. package/src/checkbox/index.ts +2 -0
  67. package/src/collapsible/collapsible.tsx +151 -0
  68. package/src/collapsible/index.ts +1 -0
  69. package/src/combobox/combobox.tsx +132 -0
  70. package/src/combobox/index.ts +1 -0
  71. package/src/command/command.tsx +192 -0
  72. package/src/command/command.types.ts +11 -0
  73. package/src/command/index.ts +2 -0
  74. package/src/context-menu/context-menu.tsx +178 -0
  75. package/src/context-menu/index.ts +1 -0
  76. package/src/dialog/dialog-responsive.tsx +137 -0
  77. package/src/dialog/dialog.tsx +97 -0
  78. package/src/dialog/index.ts +2 -0
  79. package/src/direction/direction.tsx +13 -0
  80. package/src/direction/index.ts +1 -0
  81. package/src/drawer/drawer.tsx +185 -0
  82. package/src/drawer/index.ts +1 -0
  83. package/src/dropdown-menu/dropdown-menu.tsx +181 -0
  84. package/src/dropdown-menu/index.ts +1 -0
  85. package/src/empty/empty.constants.ts +15 -0
  86. package/src/empty/empty.tsx +73 -0
  87. package/src/empty/index.ts +2 -0
  88. package/src/field/field.constants.ts +22 -0
  89. package/src/field/field.tsx +203 -0
  90. package/src/field/index.ts +2 -0
  91. package/src/hover-card/hover-card.tsx +79 -0
  92. package/src/hover-card/index.ts +1 -0
  93. package/src/input/index.ts +1 -0
  94. package/src/input/input.tsx +45 -0
  95. package/src/input-group/index.ts +1 -0
  96. package/src/input-group/input-group.tsx +170 -0
  97. package/src/input-otp/index.ts +1 -0
  98. package/src/input-otp/input-otp.tsx +66 -0
  99. package/src/item/index.ts +2 -0
  100. package/src/item/item.constants.ts +22 -0
  101. package/src/item/item.tsx +185 -0
  102. package/src/json-editor/index.ts +4 -0
  103. package/src/json-editor/json-editor.hooks.ts +21 -0
  104. package/src/json-editor/json-editor.libs.ts +34 -0
  105. package/src/json-editor/json-editor.tsx +425 -0
  106. package/src/json-editor/json-editor.types.ts +80 -0
  107. package/src/json-editor/json-editor.view.tsx +110 -0
  108. package/src/json-editor/json-text-area.tsx +7 -0
  109. package/src/kbd/index.ts +1 -0
  110. package/src/kbd/kbd.tsx +39 -0
  111. package/src/label/index.ts +1 -0
  112. package/src/label/label.tsx +28 -0
  113. package/src/menubar/index.ts +1 -0
  114. package/src/menubar/menubar.tsx +213 -0
  115. package/src/navigation-menu/index.ts +1 -0
  116. package/src/navigation-menu/navigation-menu.tsx +152 -0
  117. package/src/pagination/index.ts +2 -0
  118. package/src/pagination/pagination.tsx +191 -0
  119. package/src/pagination/pagination.types.ts +17 -0
  120. package/src/popover/index.ts +1 -0
  121. package/src/popover/popover.tsx +35 -0
  122. package/src/preview-panel/index.ts +3 -0
  123. package/src/preview-panel/preview-panel-dialog.tsx +99 -0
  124. package/src/preview-panel/preview-panel.tsx +389 -0
  125. package/src/preview-panel/preview-panel.types.ts +49 -0
  126. package/src/progress/index.ts +1 -0
  127. package/src/progress/progress.tsx +32 -0
  128. package/src/radio-group/index.ts +1 -0
  129. package/src/radio-group/radio-group.tsx +92 -0
  130. package/src/resizable/index.ts +1 -0
  131. package/src/resizable/resizable.tsx +52 -0
  132. package/src/scroll-area/index.ts +1 -0
  133. package/src/scroll-area/scroll-area.tsx +30 -0
  134. package/src/select/index.ts +1 -0
  135. package/src/select/select.tsx +138 -0
  136. package/src/separator/index.ts +1 -0
  137. package/src/separator/separator.tsx +28 -0
  138. package/src/sheet/index.ts +2 -0
  139. package/src/sheet/sheet.constants.tsx +20 -0
  140. package/src/sheet/sheet.tsx +92 -0
  141. package/src/sidebar/index.ts +4 -0
  142. package/src/sidebar/sidebar.constants.ts +30 -0
  143. package/src/sidebar/sidebar.hooks.ts +13 -0
  144. package/src/sidebar/sidebar.tsx +676 -0
  145. package/src/sidebar/sidebar.types.ts +28 -0
  146. package/src/skeleton/index.ts +1 -0
  147. package/src/skeleton/skeleton.tsx +22 -0
  148. package/src/slider/index.ts +1 -0
  149. package/src/slider/slider.tsx +57 -0
  150. package/src/sonner/index.ts +4 -0
  151. package/src/sonner/sonner.chunks.tsx +80 -0
  152. package/src/sonner/sonner.libs.ts +13 -0
  153. package/src/sonner/sonner.tsx +31 -0
  154. package/src/sonner/sonner.types.ts +9 -0
  155. package/src/switch/index.ts +1 -0
  156. package/src/switch/switch.tsx +63 -0
  157. package/src/table/index.ts +1 -0
  158. package/src/table/table.tsx +95 -0
  159. package/src/tabs/index.ts +1 -0
  160. package/src/tabs/tabs.tsx +151 -0
  161. package/src/textarea/index.ts +1 -0
  162. package/src/textarea/textarea.tsx +24 -0
  163. package/src/toggle/index.ts +2 -0
  164. package/src/toggle/toggle.constants.ts +22 -0
  165. package/src/toggle/toggle.tsx +24 -0
  166. package/src/toggle-group/index.ts +1 -0
  167. package/src/toggle-group/toggle-group.tsx +69 -0
  168. package/src/tooltip/index.ts +1 -0
  169. package/src/tooltip/tooltip.tsx +32 -0
  170. package/src/upload/index.ts +1 -0
  171. package/src/upload/upload.constants.tsx +19 -0
  172. package/src/upload/upload.libs.ts +97 -0
  173. package/src/upload/upload.tsx +340 -0
  174. package/src/upload/upload.types.ts +44 -0
  175. package/tsconfig.json +25 -0
@@ -0,0 +1,99 @@
1
+ 'use client'
2
+
3
+ import { cn } from '@gentleduck/libs/cn'
4
+ import { Maximize2 } from 'lucide-react'
5
+ import { useCallback, useMemo, useRef, useState } from 'react'
6
+ import { Button } from '../button'
7
+ import { Dialog, DialogContent, DialogTrigger } from '../dialog'
8
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../tooltip'
9
+ import { PreviewPanel } from './preview-panel'
10
+ import type { PreviewPanelDialogProps, PreviewPanelState } from './preview-panel.types'
11
+
12
+ function PreviewPanelDialog({
13
+ children,
14
+ html,
15
+ className,
16
+ panelClassName,
17
+ maxHeight,
18
+ minZoom = 0.25,
19
+ maxZoom = 4,
20
+ initialZoom = 1,
21
+ showControls = true,
22
+ syncPanels = true,
23
+ fullscreenText = 'Open fullscreen',
24
+ }: PreviewPanelDialogProps & { fullscreenText?: string }) {
25
+ const [sharedState, setSharedState] = useState<PreviewPanelState | undefined>(undefined)
26
+
27
+ // Ref tracks whether a state update is already scheduled this frame.
28
+ // Prevents multiple setState calls per animation frame when both
29
+ // panels emit state changes simultaneously.
30
+ const pendingRef = useRef(false)
31
+
32
+ const handleStateChange = useCallback(
33
+ (state: PreviewPanelState) => {
34
+ if (!syncPanels) return
35
+ if (pendingRef.current) return
36
+ pendingRef.current = true
37
+ requestAnimationFrame(() => {
38
+ pendingRef.current = false
39
+ setSharedState(state)
40
+ })
41
+ },
42
+ [syncPanels],
43
+ )
44
+
45
+ const contentProps = useMemo(() => (html ? { html } : { children }), [html, children])
46
+
47
+ const dialogPanelClassName = useMemo(() => cn('min-h-[70vh]', panelClassName), [panelClassName])
48
+
49
+ return (
50
+ <div className={cn('group relative', className)}>
51
+ <div className="relative overflow-hidden rounded-lg border bg-card">
52
+ <PreviewPanel
53
+ {...contentProps}
54
+ maxHeight={maxHeight}
55
+ minZoom={minZoom}
56
+ maxZoom={maxZoom}
57
+ initialZoom={initialZoom}
58
+ showControls={showControls}
59
+ className={panelClassName}
60
+ onStateChange={handleStateChange}
61
+ syncState={syncPanels ? sharedState : undefined}
62
+ />
63
+
64
+ <TooltipProvider>
65
+ <Dialog>
66
+ <Tooltip>
67
+ <TooltipTrigger asChild>
68
+ <DialogTrigger asChild>
69
+ <Button
70
+ variant="ghost"
71
+ size="icon-sm"
72
+ icon={<Maximize2 aria-hidden="true" />}
73
+ aria-label={fullscreenText}
74
+ className="absolute end-3 bottom-3 z-10 border bg-background/80 backdrop-blur-sm"
75
+ />
76
+ </DialogTrigger>
77
+ </TooltipTrigger>
78
+ <TooltipContent>{fullscreenText}</TooltipContent>
79
+ </Tooltip>
80
+ <DialogContent className="max-h-[85vh] max-w-[90vw] overflow-auto p-0">
81
+ <PreviewPanel
82
+ {...contentProps}
83
+ minZoom={minZoom}
84
+ maxZoom={maxZoom}
85
+ initialZoom={initialZoom}
86
+ showControls={showControls}
87
+ className={dialogPanelClassName}
88
+ onStateChange={handleStateChange}
89
+ syncState={syncPanels ? sharedState : undefined}
90
+ />
91
+ </DialogContent>
92
+ </Dialog>
93
+ </TooltipProvider>
94
+ </div>
95
+ </div>
96
+ )
97
+ }
98
+
99
+ export { PreviewPanelDialog }
@@ -0,0 +1,389 @@
1
+ 'use client'
2
+
3
+ import { cn } from '@gentleduck/libs/cn'
4
+ import { type Direction, useDirection } from '@gentleduck/primitives/direction'
5
+ import { Minus, Plus, RotateCcw } from 'lucide-react'
6
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
7
+ import { Badge } from '../badge'
8
+ import { Button } from '../button'
9
+ import { ButtonGroup } from '../button-group'
10
+ import { Separator } from '../separator'
11
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../tooltip'
12
+ import type { PreviewPanelProps } from './preview-panel.types'
13
+
14
+ const ZOOM_STEP_BUTTON = 0.25
15
+ const ZOOM_STEP_WHEEL = 0.1
16
+
17
+ function clamp(value: number, min: number, max: number) {
18
+ return Math.max(min, Math.min(max, value))
19
+ }
20
+
21
+ const ZoomControls = memo(function ZoomControls({
22
+ onZoomIn,
23
+ onZoomOut,
24
+ onReset,
25
+ zoom,
26
+ zoomInText = 'Zoom in',
27
+ zoomOutText = 'Zoom out',
28
+ resetText = 'Reset view',
29
+ }: {
30
+ onZoomIn: () => void
31
+ onZoomOut: () => void
32
+ onReset: () => void
33
+ zoom: number
34
+ zoomInText?: string
35
+ zoomOutText?: string
36
+ resetText?: string
37
+ }) {
38
+ return (
39
+ <TooltipProvider>
40
+ <ButtonGroup data-slot="preview-panel-controls" className="rounded-md border bg-background/80 backdrop-blur-sm">
41
+ <Tooltip>
42
+ <TooltipTrigger asChild>
43
+ <Button
44
+ variant="ghost"
45
+ size="icon-sm"
46
+ onClick={onZoomIn}
47
+ icon={<Plus aria-hidden="true" />}
48
+ aria-label={zoomInText}
49
+ />
50
+ </TooltipTrigger>
51
+ <TooltipContent>{zoomInText}</TooltipContent>
52
+ </Tooltip>
53
+ <Separator orientation="vertical" />
54
+ <Badge variant="secondary" size="sm" className="rounded-none tabular-nums">
55
+ {Math.round(zoom * 100)}%
56
+ </Badge>
57
+ <Separator orientation="vertical" />
58
+ <Tooltip>
59
+ <TooltipTrigger asChild>
60
+ <Button
61
+ variant="ghost"
62
+ size="icon-sm"
63
+ onClick={onZoomOut}
64
+ icon={<Minus aria-hidden="true" />}
65
+ aria-label={zoomOutText}
66
+ />
67
+ </TooltipTrigger>
68
+ <TooltipContent>{zoomOutText}</TooltipContent>
69
+ </Tooltip>
70
+ <Separator orientation="vertical" />
71
+ <Tooltip>
72
+ <TooltipTrigger asChild>
73
+ <Button
74
+ variant="ghost"
75
+ size="icon-sm"
76
+ onClick={onReset}
77
+ icon={<RotateCcw aria-hidden="true" />}
78
+ aria-label={resetText}
79
+ />
80
+ </TooltipTrigger>
81
+ <TooltipContent>{resetText}</TooltipContent>
82
+ </Tooltip>
83
+ </ButtonGroup>
84
+ </TooltipProvider>
85
+ )
86
+ })
87
+
88
+ // A generic pan-zoom container. Accepts children or an html string.
89
+ // All transforms bypass React via direct DOM writes for zero re-renders
90
+ // during continuous interactions (drag, wheel, pinch).
91
+
92
+ function PreviewPanel({
93
+ maxHeight,
94
+ minZoom = 0.25,
95
+ maxZoom = 4,
96
+ initialZoom = 1,
97
+ showControls = true,
98
+ html,
99
+ children,
100
+ className,
101
+ style,
102
+ onStateChange,
103
+ syncState,
104
+ dir,
105
+ ...rest
106
+ }: PreviewPanelProps) {
107
+ const containerRef = useRef<HTMLDivElement>(null)
108
+ const contentRef = useRef<HTMLDivElement>(null)
109
+
110
+ // All mutable interaction state lives in a single ref object.
111
+ // Nothing here triggers React re-renders.
112
+ const s = useRef({
113
+ zoom: initialZoom,
114
+ x: 0,
115
+ y: 0,
116
+ dragging: false,
117
+ dragStartX: 0,
118
+ dragStartY: 0,
119
+ posStartX: 0,
120
+ posStartY: 0,
121
+ rafId: 0,
122
+ emitPending: false,
123
+ pinchDist: 0,
124
+ pinchZoom: initialZoom,
125
+ willChangeTimer: 0,
126
+ })
127
+
128
+ // Only React state: the zoom label percentage
129
+ const [displayZoom, setDisplayZoom] = useState(initialZoom)
130
+
131
+ // Stable ref for the callback so effects never re-subscribe
132
+ const onStateChangeRef = useRef(onStateChange)
133
+ onStateChangeRef.current = onStateChange
134
+
135
+ // Flush a pending state emission. Called from RAF or directly by button handlers.
136
+ const flushEmit = useCallback(() => {
137
+ if (!s.current.emitPending) return
138
+ s.current.emitPending = false
139
+ onStateChangeRef.current?.({
140
+ zoom: s.current.zoom,
141
+ x: s.current.x,
142
+ y: s.current.y,
143
+ })
144
+ }, [])
145
+
146
+ // Mark state as dirty so the next RAF tick emits it.
147
+ // For continuous interactions (drag, wheel, pinch) this batches
148
+ // multiple events into one React state update per frame.
149
+ const markDirty = useCallback(() => {
150
+ s.current.emitPending = true
151
+ }, [])
152
+
153
+ // Write transform directly to the DOM element.
154
+ const applyTransform = useCallback((animate: boolean) => {
155
+ const el = contentRef.current
156
+ if (!el) return
157
+ const { x, y, zoom } = s.current
158
+ el.style.transform = `translate3d(${x}px,${y}px,0) scale(${zoom})`
159
+ el.style.transition = animate ? 'transform 0.15s ease-out' : 'none'
160
+ // GPU-composite during interaction, then clear so browser
161
+ // re-rasterizes at the new zoom for crisp SVG text
162
+ el.style.willChange = 'transform'
163
+ clearTimeout(s.current.willChangeTimer)
164
+ s.current.willChangeTimer = window.setTimeout(() => {
165
+ if (contentRef.current && !s.current.dragging) {
166
+ contentRef.current.style.willChange = 'auto'
167
+ }
168
+ }, 200)
169
+ }, [])
170
+
171
+ // Batch DOM writes behind a single requestAnimationFrame.
172
+ // Also flushes any pending state emission in the same frame.
173
+ const scheduleApply = useCallback(() => {
174
+ if (s.current.rafId) return
175
+ s.current.rafId = requestAnimationFrame(() => {
176
+ s.current.rafId = 0
177
+ applyTransform(false)
178
+ flushEmit()
179
+ })
180
+ }, [applyTransform, flushEmit])
181
+
182
+ const syncDisplay = useCallback(() => setDisplayZoom(s.current.zoom), [])
183
+
184
+ // -- Sync from external state (receives changes from a paired panel) --
185
+
186
+ useEffect(() => {
187
+ if (!syncState) return
188
+ const { zoom, x, y } = syncState
189
+ // Epsilon check prevents applying our own emitted state back
190
+ if (Math.abs(s.current.zoom - zoom) < 0.001 && Math.abs(s.current.x - x) < 0.5 && Math.abs(s.current.y - y) < 0.5)
191
+ return
192
+ s.current.zoom = zoom
193
+ s.current.x = x
194
+ s.current.y = y
195
+ // Apply silently without emitting back to avoid ping-pong
196
+ applyTransform(true)
197
+ syncDisplay()
198
+ }, [syncState, applyTransform, syncDisplay])
199
+
200
+ // -- Button handlers (discrete, emit immediately) --
201
+
202
+ const handleZoomIn = useCallback(() => {
203
+ s.current.zoom = clamp(s.current.zoom + ZOOM_STEP_BUTTON, minZoom, maxZoom)
204
+ applyTransform(true)
205
+ syncDisplay()
206
+ markDirty()
207
+ flushEmit()
208
+ }, [applyTransform, syncDisplay, markDirty, flushEmit, minZoom, maxZoom])
209
+
210
+ const handleZoomOut = useCallback(() => {
211
+ s.current.zoom = clamp(s.current.zoom - ZOOM_STEP_BUTTON, minZoom, maxZoom)
212
+ applyTransform(true)
213
+ syncDisplay()
214
+ markDirty()
215
+ flushEmit()
216
+ }, [applyTransform, syncDisplay, markDirty, flushEmit, minZoom, maxZoom])
217
+
218
+ const handleReset = useCallback(() => {
219
+ s.current.zoom = initialZoom
220
+ s.current.x = 0
221
+ s.current.y = 0
222
+ applyTransform(true)
223
+ syncDisplay()
224
+ markDirty()
225
+ flushEmit()
226
+ }, [applyTransform, syncDisplay, markDirty, flushEmit, initialZoom])
227
+
228
+ // -- Pointer drag --
229
+
230
+ useEffect(() => {
231
+ const el = containerRef.current
232
+ if (!el) return
233
+
234
+ const onDown = (e: PointerEvent) => {
235
+ if (e.button !== 0) return
236
+ if ((e.target as HTMLElement).closest('[data-slot="preview-panel-controls"]')) return
237
+ e.preventDefault()
238
+ el.setPointerCapture(e.pointerId)
239
+ s.current.dragging = true
240
+ s.current.dragStartX = e.clientX
241
+ s.current.dragStartY = e.clientY
242
+ s.current.posStartX = s.current.x
243
+ s.current.posStartY = s.current.y
244
+ el.style.cursor = 'grabbing'
245
+ }
246
+
247
+ const onMove = (e: PointerEvent) => {
248
+ if (!s.current.dragging) return
249
+ s.current.x = s.current.posStartX + (e.clientX - s.current.dragStartX)
250
+ s.current.y = s.current.posStartY + (e.clientY - s.current.dragStartY)
251
+ markDirty()
252
+ scheduleApply()
253
+ }
254
+
255
+ const onUp = (e: PointerEvent) => {
256
+ if (!s.current.dragging) return
257
+ s.current.dragging = false
258
+ el.releasePointerCapture(e.pointerId)
259
+ el.style.cursor = 'grab'
260
+ }
261
+
262
+ const onLeave = () => {
263
+ if (!s.current.dragging) return
264
+ s.current.dragging = false
265
+ el.style.cursor = 'grab'
266
+ }
267
+
268
+ el.addEventListener('pointerdown', onDown)
269
+ el.addEventListener('pointermove', onMove)
270
+ el.addEventListener('pointerup', onUp)
271
+ el.addEventListener('pointerleave', onLeave)
272
+ return () => {
273
+ el.removeEventListener('pointerdown', onDown)
274
+ el.removeEventListener('pointermove', onMove)
275
+ el.removeEventListener('pointerup', onUp)
276
+ el.removeEventListener('pointerleave', onLeave)
277
+ }
278
+ }, [markDirty, scheduleApply])
279
+
280
+ // -- Wheel zoom (passive: false to preventDefault page scroll) --
281
+
282
+ useEffect(() => {
283
+ const el = containerRef.current
284
+ if (!el) return
285
+
286
+ const onWheel = (e: WheelEvent) => {
287
+ e.preventDefault()
288
+ e.stopPropagation()
289
+ const delta = e.deltaY > 0 ? -ZOOM_STEP_WHEEL : ZOOM_STEP_WHEEL
290
+ s.current.zoom = clamp(s.current.zoom + delta, minZoom, maxZoom)
291
+ markDirty()
292
+ scheduleApply()
293
+ syncDisplay()
294
+ }
295
+
296
+ el.addEventListener('wheel', onWheel, { passive: false })
297
+ return () => el.removeEventListener('wheel', onWheel)
298
+ }, [markDirty, scheduleApply, syncDisplay, minZoom, maxZoom])
299
+
300
+ // -- Pinch to zoom (two-finger touch) --
301
+
302
+ useEffect(() => {
303
+ const el = containerRef.current
304
+ if (!el) return
305
+
306
+ const dist = (e: TouchEvent) => {
307
+ const a = e.touches[0]!
308
+ const b = e.touches[1]!
309
+ return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY)
310
+ }
311
+
312
+ const onTouchStart = (e: TouchEvent) => {
313
+ if (e.touches.length === 2) {
314
+ e.preventDefault()
315
+ s.current.pinchDist = dist(e)
316
+ s.current.pinchZoom = s.current.zoom
317
+ }
318
+ }
319
+
320
+ const onTouchMove = (e: TouchEvent) => {
321
+ if (e.touches.length === 2) {
322
+ e.preventDefault()
323
+ const scale = dist(e) / s.current.pinchDist
324
+ s.current.zoom = clamp(s.current.pinchZoom * scale, minZoom, maxZoom)
325
+ markDirty()
326
+ scheduleApply()
327
+ syncDisplay()
328
+ }
329
+ }
330
+
331
+ el.addEventListener('touchstart', onTouchStart, { passive: false })
332
+ el.addEventListener('touchmove', onTouchMove, { passive: false })
333
+ return () => {
334
+ el.removeEventListener('touchstart', onTouchStart)
335
+ el.removeEventListener('touchmove', onTouchMove)
336
+ }
337
+ }, [markDirty, scheduleApply, syncDisplay, minZoom, maxZoom])
338
+
339
+ // Cleanup on unmount
340
+ useEffect(() => {
341
+ return () => {
342
+ if (s.current.rafId) cancelAnimationFrame(s.current.rafId)
343
+ clearTimeout(s.current.willChangeTimer)
344
+ }
345
+ }, [])
346
+
347
+ // Stable content props - only changes when html/children identity changes
348
+ const contentProps = useMemo(
349
+ () => (html ? { dangerouslySetInnerHTML: { __html: html } } : { children }),
350
+ [html, children],
351
+ )
352
+
353
+ // Stable inline style for the container
354
+ const containerStyle = useMemo(
355
+ () => ({ maxHeight, cursor: 'grab' as const, touchAction: 'none' as const }),
356
+ [maxHeight],
357
+ )
358
+ const direction = useDirection(dir as Direction)
359
+
360
+ return (
361
+ <div
362
+ data-slot="preview-panel"
363
+ className={cn('relative flex flex-col', className)}
364
+ dir={direction}
365
+ style={style}
366
+ {...rest}>
367
+ {showControls && (
368
+ <div className="absolute end-3 top-3 z-10">
369
+ <ZoomControls onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} onReset={handleReset} zoom={displayZoom} />
370
+ </div>
371
+ )}
372
+ <div
373
+ ref={containerRef}
374
+ className="flex flex-1 items-center justify-center overflow-hidden"
375
+ style={containerStyle}>
376
+ <div
377
+ ref={contentRef}
378
+ className="flex w-full items-center justify-center p-6"
379
+ style={CONTENT_STYLE}
380
+ {...contentProps}
381
+ />
382
+ </div>
383
+ </div>
384
+ )
385
+ }
386
+
387
+ const CONTENT_STYLE = { transformOrigin: 'center center' } as const
388
+
389
+ export { PreviewPanel, ZoomControls }
@@ -0,0 +1,49 @@
1
+ import type React from 'react'
2
+
3
+ export type PreviewPanelState = {
4
+ zoom: number
5
+ x: number
6
+ y: number
7
+ }
8
+
9
+ export interface PreviewPanelProps extends React.HTMLProps<HTMLDivElement> {
10
+ /** Maximum height of the panel container. */
11
+ maxHeight?: string
12
+ /** Minimum zoom level. Default 0.25. */
13
+ minZoom?: number
14
+ /** Maximum zoom level. Default 4. */
15
+ maxZoom?: number
16
+ /** Initial zoom level. Default 1. */
17
+ initialZoom?: number
18
+ /** Whether to show zoom controls. Default true. */
19
+ showControls?: boolean
20
+ /** Raw HTML string to render inside the panel. Takes priority over children. */
21
+ html?: string
22
+ /** Called whenever zoom or position changes. Use to sync with another panel. */
23
+ onStateChange?: (state: PreviewPanelState) => void
24
+ /** External state to apply. When set, the panel syncs to this state. */
25
+ syncState?: PreviewPanelState
26
+ }
27
+
28
+ export interface PreviewPanelDialogProps {
29
+ /** Content to render in both the inline panel and the dialog panel. */
30
+ children?: React.ReactNode
31
+ /** Raw HTML string to render. Takes priority over children. */
32
+ html?: string
33
+ /** Class name for the inline panel wrapper. */
34
+ className?: string
35
+ /** Class name applied to both PreviewPanel instances. */
36
+ panelClassName?: string
37
+ /** Maximum height of the inline panel. */
38
+ maxHeight?: string
39
+ /** Minimum zoom level. Default 0.25. */
40
+ minZoom?: number
41
+ /** Maximum zoom level. Default 4. */
42
+ maxZoom?: number
43
+ /** Initial zoom level. Default 1. */
44
+ initialZoom?: number
45
+ /** Whether to show zoom controls. Default true. */
46
+ showControls?: boolean
47
+ /** Whether to sync zoom and position between inline and dialog panels. Default true. */
48
+ syncPanels?: boolean
49
+ }
@@ -0,0 +1 @@
1
+ export * from './progress'
@@ -0,0 +1,32 @@
1
+ 'use client'
2
+
3
+ import { cn } from '@gentleduck/libs/cn'
4
+ import { type Direction, useDirection } from '@gentleduck/primitives/direction'
5
+ import * as React from 'react'
6
+
7
+ const Progress = React.forwardRef<
8
+ HTMLDivElement,
9
+ Omit<React.HTMLProps<HTMLDivElement>, 'value' | 'ref'> & { value: number }
10
+ >(({ className, value, dir, ...props }, ref) => {
11
+ const direction = useDirection(dir as Direction)
12
+ return (
13
+ <div
14
+ className={cn('relative h-2 w-full overflow-hidden rounded-full bg-secondary', className)}
15
+ ref={ref}
16
+ {...props}
17
+ aria-valuemax={100}
18
+ aria-valuemin={0}
19
+ aria-valuenow={value}
20
+ dir={direction}
21
+ data-slot="progress"
22
+ role="progressbar">
23
+ <div
24
+ className="h-full w-full flex-1 bg-primary transition-all"
25
+ style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
26
+ />
27
+ </div>
28
+ )
29
+ })
30
+ Progress.displayName = 'Progress'
31
+
32
+ export { Progress }
@@ -0,0 +1 @@
1
+ export * from './radio-group'
@@ -0,0 +1,92 @@
1
+ 'use client'
2
+
3
+ import { cn } from '@gentleduck/libs/cn'
4
+ import { useSvgIndicator } from '@gentleduck/primitives/checkers'
5
+ import * as RadioGroupPrimitive from '@gentleduck/primitives/radio-group'
6
+ import * as React from 'react'
7
+
8
+ const RadioGroup = React.forwardRef<
9
+ React.ComponentRef<typeof RadioGroupPrimitive.Root>,
10
+ React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
11
+ >(({ className, ...props }, ref) => {
12
+ return (
13
+ <RadioGroupPrimitive.Root
14
+ className={cn('flex flex-col gap-2', className)}
15
+ data-slot="radio-group"
16
+ ref={ref}
17
+ {...props}
18
+ />
19
+ )
20
+ })
21
+ RadioGroup.displayName = 'RadioGroup'
22
+
23
+ const RadioGroupItem = React.forwardRef<
24
+ React.ComponentRef<typeof RadioGroupPrimitive.Item>,
25
+ React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> & {
26
+ indicator?: React.ReactElement
27
+ checkedIndicator?: React.ReactElement
28
+ textValue?: string
29
+ }
30
+ >(({ className, indicator, checkedIndicator, children, textValue, ...props }, ref) => {
31
+ const { indicatorReady, checkedIndicatorReady, inputStyle, SvgIndicator } = useSvgIndicator({
32
+ checkedIndicator,
33
+ indicator,
34
+ })
35
+ const itemRef = React.useRef<HTMLButtonElement>(null)
36
+ const itemId = React.useId()
37
+ const resolvedTextValue =
38
+ textValue ?? (typeof children === 'string' || typeof children === 'number' ? String(children) : undefined)
39
+
40
+ const indicatorStateClass =
41
+ indicatorReady && checkedIndicatorReady
42
+ ? 'after:mask-[var(--svg-off)] data-[state=checked]:after:mask-[var(--svg-on)]'
43
+ : indicatorReady
44
+ ? 'after:mask-[var(--svg-off)]'
45
+ : checkedIndicatorReady
46
+ ? 'data-[state=checked]:after:mask-[var(--svg-on)]'
47
+ : ''
48
+
49
+ return (
50
+ <div className="flex items-center gap-2">
51
+ <RadioGroupPrimitive.Item
52
+ id={itemId}
53
+ className={cn(
54
+ // Base radio styles (uses data-[state=checked]: instead of checked: for button elements)
55
+ 'relative m-0 flex size-[1em] appearance-none items-center rounded-full p-2',
56
+ 'border border-border bg-border text-primary-foreground data-[state=checked]:border-primary data-[state=checked]:bg-primary',
57
+ 'ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
58
+ 'data-disabled:cursor-not-allowed data-disabled:opacity-50',
59
+ 'after:mask-type-alpha after:mask-contain after:absolute after:block after:size-[1em] after:rounded-[inherit] after:bg-current after:drop-shadow',
60
+ 'after:opacity-0 data-[state=checked]:after:opacity-100',
61
+ // Radio-specific indicator
62
+ 'justify-center after:text-[10px]',
63
+ 'after:scale-0 data-[state=checked]:after:scale-100',
64
+ indicatorStateClass,
65
+ // Animation
66
+ 'transition-all transition-discrete duration-[200ms,150ms] ease-(--duck-motion-ease)',
67
+ '[&:before,&:after]:transition-gpu [&:before,&:after]:duration-[inherit] [&:before,&:after]:ease-[inherit] [&:before,&:after]:will-change-[inherit]',
68
+ 'rounded-full',
69
+ className,
70
+ )}
71
+ data-slot="radio-group-item"
72
+ ref={(node) => {
73
+ itemRef.current = node
74
+ if (typeof ref === 'function') ref(node)
75
+ else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node
76
+ }}
77
+ style={inputStyle}
78
+ data-text-value={resolvedTextValue}
79
+ {...props}
80
+ />
81
+ <SvgIndicator className="sr-only" />
82
+ {children && (
83
+ <label className="cursor-pointer font-normal text-base" data-slot="radio-label" htmlFor={itemId}>
84
+ {children}
85
+ </label>
86
+ )}
87
+ </div>
88
+ )
89
+ })
90
+ RadioGroupItem.displayName = 'RadioGroupItem'
91
+
92
+ export { RadioGroup, RadioGroupItem }
@@ -0,0 +1 @@
1
+ export * from './resizable'