@gentleduck/registry-ui 0.2.6 → 0.2.8

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.
@@ -3,7 +3,7 @@
3
3
  import { cn } from '@gentleduck/libs/cn'
4
4
  import { type Direction, useDirection } from '@gentleduck/primitives/direction'
5
5
  import { Minus, Plus, RotateCcw } from 'lucide-react'
6
- import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
6
+ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
7
7
  import { Badge } from '../badge'
8
8
  import { Button } from '../button'
9
9
  import { ButtonGroup } from '../button-group'
@@ -89,300 +89,307 @@ const ZoomControls = memo(function ZoomControls({
89
89
  // All transforms bypass React via direct DOM writes for zero re-renders
90
90
  // during continuous interactions (drag, wheel, pinch).
91
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,
92
+ const PreviewPanel = React.forwardRef<HTMLDivElement, PreviewPanelProps>(
93
+ (
94
+ {
95
+ maxHeight,
96
+ minZoom = 0.25,
97
+ maxZoom = 4,
98
+ initialZoom = 1,
99
+ showControls = true,
100
+ html,
101
+ children,
102
+ className,
103
+ style,
104
+ onStateChange,
105
+ syncState,
106
+ dir,
107
+ ...rest
108
+ },
109
+ ref,
110
+ ) => {
111
+ const containerRef = useRef<HTMLDivElement>(null)
112
+ const contentRef = useRef<HTMLDivElement>(null)
113
+
114
+ // All mutable interaction state lives in a single ref object.
115
+ // Nothing here triggers React re-renders.
116
+ const s = useRef({
117
+ zoom: initialZoom,
118
+ x: 0,
119
+ y: 0,
120
+ dragging: false,
121
+ dragStartX: 0,
122
+ dragStartY: 0,
123
+ posStartX: 0,
124
+ posStartY: 0,
125
+ rafId: 0,
126
+ emitPending: false,
127
+ pinchDist: 0,
128
+ pinchZoom: initialZoom,
129
+ willChangeTimer: 0,
143
130
  })
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)
131
+
132
+ // Only React state: the zoom label percentage
133
+ const [displayZoom, setDisplayZoom] = useState(initialZoom)
134
+
135
+ // Stable ref for the callback so effects never re-subscribe
136
+ const onStateChangeRef = useRef(onStateChange)
137
+ onStateChangeRef.current = onStateChange
138
+
139
+ // Flush a pending state emission. Called from RAF or directly by button handlers.
140
+ const flushEmit = useCallback(() => {
141
+ if (!s.current.emitPending) return
142
+ s.current.emitPending = false
143
+ onStateChangeRef.current?.({
144
+ zoom: s.current.zoom,
145
+ x: s.current.x,
146
+ y: s.current.y,
147
+ })
148
+ }, [])
149
+
150
+ // Mark state as dirty so the next RAF tick emits it.
151
+ // For continuous interactions (drag, wheel, pinch) this batches
152
+ // multiple events into one React state update per frame.
153
+ const markDirty = useCallback(() => {
154
+ s.current.emitPending = true
155
+ }, [])
156
+
157
+ // Write transform directly to the DOM element.
158
+ const applyTransform = useCallback((animate: boolean) => {
159
+ const el = contentRef.current
160
+ if (!el) return
161
+ const { x, y, zoom } = s.current
162
+ el.style.transform = `translate3d(${x}px,${y}px,0) scale(${zoom})`
163
+ el.style.transition = animate ? 'transform 0.15s ease-out' : 'none'
164
+ // GPU-composite during interaction, then clear so browser
165
+ // re-rasterizes at the new zoom for crisp SVG text
166
+ el.style.willChange = 'transform'
167
+ clearTimeout(s.current.willChangeTimer)
168
+ s.current.willChangeTimer = window.setTimeout(() => {
169
+ if (contentRef.current && !s.current.dragging) {
170
+ contentRef.current.style.willChange = 'auto'
171
+ }
172
+ }, 200)
173
+ }, [])
174
+
175
+ // Batch DOM writes behind a single requestAnimationFrame.
176
+ // Also flushes any pending state emission in the same frame.
177
+ const scheduleApply = useCallback(() => {
178
+ if (s.current.rafId) return
179
+ s.current.rafId = requestAnimationFrame(() => {
180
+ s.current.rafId = 0
181
+ applyTransform(false)
182
+ flushEmit()
183
+ })
184
+ }, [applyTransform, flushEmit])
185
+
186
+ const syncDisplay = useCallback(() => setDisplayZoom(s.current.zoom), [])
187
+
188
+ // -- Sync from external state (receives changes from a paired panel) --
189
+
190
+ useEffect(() => {
191
+ if (!syncState) return
192
+ const { zoom, x, y } = syncState
193
+ // Epsilon check prevents applying our own emitted state back
194
+ 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)
195
+ return
196
+ s.current.zoom = zoom
197
+ s.current.x = x
198
+ s.current.y = y
199
+ // Apply silently without emitting back to avoid ping-pong
200
+ applyTransform(true)
201
+ syncDisplay()
202
+ }, [syncState, applyTransform, syncDisplay])
203
+
204
+ // -- Button handlers (discrete, emit immediately) --
205
+
206
+ const handleZoomIn = useCallback(() => {
207
+ s.current.zoom = clamp(s.current.zoom + ZOOM_STEP_BUTTON, minZoom, maxZoom)
208
+ applyTransform(true)
209
+ syncDisplay()
251
210
  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)
211
+ flushEmit()
212
+ }, [applyTransform, syncDisplay, markDirty, flushEmit, minZoom, maxZoom])
213
+
214
+ const handleZoomOut = useCallback(() => {
215
+ s.current.zoom = clamp(s.current.zoom - ZOOM_STEP_BUTTON, minZoom, maxZoom)
216
+ applyTransform(true)
217
+ syncDisplay()
291
218
  markDirty()
292
- scheduleApply()
219
+ flushEmit()
220
+ }, [applyTransform, syncDisplay, markDirty, flushEmit, minZoom, maxZoom])
221
+
222
+ const handleReset = useCallback(() => {
223
+ s.current.zoom = initialZoom
224
+ s.current.x = 0
225
+ s.current.y = 0
226
+ applyTransform(true)
293
227
  syncDisplay()
294
- }
228
+ markDirty()
229
+ flushEmit()
230
+ }, [applyTransform, syncDisplay, markDirty, flushEmit, initialZoom])
295
231
 
296
- el.addEventListener('wheel', onWheel, { passive: false })
297
- return () => el.removeEventListener('wheel', onWheel)
298
- }, [markDirty, scheduleApply, syncDisplay, minZoom, maxZoom])
232
+ // -- Pointer drag --
299
233
 
300
- // -- Pinch to zoom (two-finger touch) --
234
+ useEffect(() => {
235
+ const el = containerRef.current
236
+ if (!el) return
301
237
 
302
- useEffect(() => {
303
- const el = containerRef.current
304
- if (!el) return
238
+ const onDown = (e: PointerEvent) => {
239
+ if (e.button !== 0) return
240
+ if ((e.target as HTMLElement).closest('[data-slot="preview-panel-controls"]')) return
241
+ e.preventDefault()
242
+ el.setPointerCapture(e.pointerId)
243
+ s.current.dragging = true
244
+ s.current.dragStartX = e.clientX
245
+ s.current.dragStartY = e.clientY
246
+ s.current.posStartX = s.current.x
247
+ s.current.posStartY = s.current.y
248
+ el.style.cursor = 'grabbing'
249
+ }
305
250
 
306
- const dist = (e: TouchEvent) => {
307
- const a = e.touches[0] ?? { clientX: 0, clientY: 0 }
308
- const b = e.touches[1] ?? { clientX: 0, clientY: 0 }
309
- return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY)
310
- }
251
+ const onMove = (e: PointerEvent) => {
252
+ if (!s.current.dragging) return
253
+ s.current.x = s.current.posStartX + (e.clientX - s.current.dragStartX)
254
+ s.current.y = s.current.posStartY + (e.clientY - s.current.dragStartY)
255
+ markDirty()
256
+ scheduleApply()
257
+ }
311
258
 
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
259
+ const onUp = (e: PointerEvent) => {
260
+ if (!s.current.dragging) return
261
+ s.current.dragging = false
262
+ el.releasePointerCapture(e.pointerId)
263
+ el.style.cursor = 'grab'
264
+ }
265
+
266
+ const onLeave = () => {
267
+ if (!s.current.dragging) return
268
+ s.current.dragging = false
269
+ el.style.cursor = 'grab'
270
+ }
271
+
272
+ el.addEventListener('pointerdown', onDown)
273
+ el.addEventListener('pointermove', onMove)
274
+ el.addEventListener('pointerup', onUp)
275
+ el.addEventListener('pointerleave', onLeave)
276
+ return () => {
277
+ el.removeEventListener('pointerdown', onDown)
278
+ el.removeEventListener('pointermove', onMove)
279
+ el.removeEventListener('pointerup', onUp)
280
+ el.removeEventListener('pointerleave', onLeave)
317
281
  }
318
- }
282
+ }, [markDirty, scheduleApply])
319
283
 
320
- const onTouchMove = (e: TouchEvent) => {
321
- if (e.touches.length === 2) {
284
+ // -- Wheel zoom (passive: false to preventDefault page scroll) --
285
+
286
+ useEffect(() => {
287
+ const el = containerRef.current
288
+ if (!el) return
289
+
290
+ const onWheel = (e: WheelEvent) => {
322
291
  e.preventDefault()
323
- const scale = dist(e) / s.current.pinchDist
324
- s.current.zoom = clamp(s.current.pinchZoom * scale, minZoom, maxZoom)
292
+ e.stopPropagation()
293
+ const delta = e.deltaY > 0 ? -ZOOM_STEP_WHEEL : ZOOM_STEP_WHEEL
294
+ s.current.zoom = clamp(s.current.zoom + delta, minZoom, maxZoom)
325
295
  markDirty()
326
296
  scheduleApply()
327
297
  syncDisplay()
328
298
  }
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
299
 
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
- )
300
+ el.addEventListener('wheel', onWheel, { passive: false })
301
+ return () => el.removeEventListener('wheel', onWheel)
302
+ }, [markDirty, scheduleApply, syncDisplay, minZoom, maxZoom])
352
303
 
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)
304
+ // -- Pinch to zoom (two-finger touch) --
359
305
 
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
- )}
306
+ useEffect(() => {
307
+ const el = containerRef.current
308
+ if (!el) return
309
+
310
+ const dist = (e: TouchEvent) => {
311
+ const a = e.touches[0] ?? { clientX: 0, clientY: 0 }
312
+ const b = e.touches[1] ?? { clientX: 0, clientY: 0 }
313
+ return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY)
314
+ }
315
+
316
+ const onTouchStart = (e: TouchEvent) => {
317
+ if (e.touches.length === 2) {
318
+ e.preventDefault()
319
+ s.current.pinchDist = dist(e)
320
+ s.current.pinchZoom = s.current.zoom
321
+ }
322
+ }
323
+
324
+ const onTouchMove = (e: TouchEvent) => {
325
+ if (e.touches.length === 2) {
326
+ e.preventDefault()
327
+ const scale = dist(e) / s.current.pinchDist
328
+ s.current.zoom = clamp(s.current.pinchZoom * scale, minZoom, maxZoom)
329
+ markDirty()
330
+ scheduleApply()
331
+ syncDisplay()
332
+ }
333
+ }
334
+
335
+ el.addEventListener('touchstart', onTouchStart, { passive: false })
336
+ el.addEventListener('touchmove', onTouchMove, { passive: false })
337
+ return () => {
338
+ el.removeEventListener('touchstart', onTouchStart)
339
+ el.removeEventListener('touchmove', onTouchMove)
340
+ }
341
+ }, [markDirty, scheduleApply, syncDisplay, minZoom, maxZoom])
342
+
343
+ // Cleanup on unmount
344
+ useEffect(() => {
345
+ return () => {
346
+ if (s.current.rafId) cancelAnimationFrame(s.current.rafId)
347
+ clearTimeout(s.current.willChangeTimer)
348
+ }
349
+ }, [])
350
+
351
+ // Stable content props - only changes when html/children identity changes
352
+ const contentProps = useMemo(
353
+ () => (html ? { dangerouslySetInnerHTML: { __html: html } } : { children }),
354
+ [html, children],
355
+ )
356
+
357
+ // Stable inline style for the container
358
+ const containerStyle = useMemo(
359
+ () => ({ maxHeight, cursor: 'grab' as const, touchAction: 'none' as const }),
360
+ [maxHeight],
361
+ )
362
+ const direction = useDirection(dir as Direction)
363
+
364
+ return (
372
365
  <div
373
- ref={containerRef}
374
- className="flex flex-1 items-center justify-center overflow-hidden"
375
- style={containerStyle}>
366
+ ref={ref}
367
+ data-slot="preview-panel"
368
+ className={cn('relative flex flex-col', className)}
369
+ dir={direction}
370
+ style={style}
371
+ {...rest}>
372
+ {showControls && (
373
+ <div className="absolute end-3 top-3 z-10">
374
+ <ZoomControls onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} onReset={handleReset} zoom={displayZoom} />
375
+ </div>
376
+ )}
376
377
  <div
377
- ref={contentRef}
378
- className="flex w-full items-center justify-center p-6"
379
- style={CONTENT_STYLE}
380
- {...contentProps}
381
- />
378
+ ref={containerRef}
379
+ className="flex flex-1 items-center justify-center overflow-hidden"
380
+ style={containerStyle}>
381
+ <div
382
+ ref={contentRef}
383
+ className="flex w-full items-center justify-center p-6"
384
+ style={CONTENT_STYLE}
385
+ {...contentProps}
386
+ />
387
+ </div>
382
388
  </div>
383
- </div>
384
- )
385
- }
389
+ )
390
+ },
391
+ )
392
+ PreviewPanel.displayName = 'PreviewPanel'
386
393
 
387
394
  const CONTENT_STYLE = { transformOrigin: 'center center' } as const
388
395
 
@@ -3,35 +3,36 @@
3
3
  import { cn } from '@gentleduck/libs/cn'
4
4
  import { type Direction, useDirection } from '@gentleduck/primitives/direction'
5
5
  import { GripVertical } from 'lucide-react'
6
- import type * as React from 'react'
6
+ import React from 'react'
7
7
  import * as ResizablePrimitive from 'react-resizable-panels'
8
8
 
9
- const ResizablePanelGroup = ({
10
- className,
11
- dir,
12
- ...props
13
- }: React.ComponentProps<typeof ResizablePrimitive.Group> & { dir?: Direction }) => {
9
+ const ResizablePanelGroup = React.forwardRef<
10
+ HTMLDivElement,
11
+ React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Group> & { dir?: Direction }
12
+ >(({ className, dir, ...props }, ref) => {
14
13
  const direction = useDirection(dir as Direction)
15
14
  return (
16
15
  <ResizablePrimitive.Group
17
16
  className={cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', className)}
18
17
  data-slot="panel-group"
19
18
  dir={direction}
19
+ elementRef={ref}
20
20
  {...props}
21
21
  />
22
22
  )
23
- }
23
+ })
24
+ ResizablePanelGroup.displayName = 'ResizablePanelGroup'
24
25
 
25
26
  const ResizablePanel = ResizablePrimitive.Panel
26
27
 
27
- const ResizableHandle = ({
28
- withHandle,
29
- className,
30
- ...props
31
- }: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
32
- withHandle?: boolean
33
- }) => (
28
+ const ResizableHandle = React.forwardRef<
29
+ HTMLDivElement,
30
+ React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Separator> & {
31
+ withHandle?: boolean
32
+ }
33
+ >(({ withHandle, className, ...props }, ref) => (
34
34
  <ResizablePrimitive.Separator
35
+ elementRef={ref}
35
36
  aria-label="Resize panels"
36
37
  className={cn(
37
38
  'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
@@ -47,6 +48,7 @@ const ResizableHandle = ({
47
48
  </div>
48
49
  )}
49
50
  </ResizablePrimitive.Separator>
50
- )
51
+ ))
52
+ ResizableHandle.displayName = 'ResizableHandle'
51
53
 
52
54
  export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
@@ -6,10 +6,13 @@ import { Check, ChevronDown, ChevronUp } from 'lucide-react'
6
6
  import * as React from 'react'
7
7
 
8
8
  const Select = SelectPrimitive.Root
9
+ Select.displayName = 'Select'
9
10
 
10
11
  const SelectGroup = SelectPrimitive.Group
12
+ SelectGroup.displayName = 'SelectGroup'
11
13
 
12
14
  const SelectValue = SelectPrimitive.Value
15
+ SelectValue.displayName = 'SelectValue'
13
16
 
14
17
  const SelectTrigger = React.forwardRef<
15
18
  React.ComponentRef<typeof SelectPrimitive.Trigger>,