Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions packages/leva/src/hooks/useToggle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,5 +126,67 @@ export function useToggle(toggled: boolean) {
}
}, [toggled])

// Watch for content size changes when panel is expanded
useEffect(() => {
if (!toggled || !contentRef.current || !wrapperRef.current) return

const wrapper = wrapperRef.current
let rafId: number | null = null
let currentTransitionHandler: (() => void) | null = null

const resizeObserver = new ResizeObserver(() => {
const content = contentRef.current
if (!content) return

// Cancel any pending animation
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}

// Remove any existing transition handler
if (currentTransitionHandler) {
wrapper.removeEventListener('transitionend', currentTransitionHandler)
currentTransitionHandler = null
}

// Get the current and target heights
const currentHeight = wrapper.getBoundingClientRect().height
const targetHeight = content.getBoundingClientRect().height

// Only update if there's a meaningful difference
if (Math.abs(currentHeight - targetHeight) > 1) {
// Set explicit height to enable transition
wrapper.style.height = currentHeight + 'px'

// Use requestAnimationFrame to ensure the height is set before changing it
rafId = requestAnimationFrame(() => {
rafId = null
wrapper.style.height = targetHeight + 'px'

// Remove fixed height after transition completes
const handleTransitionEnd = () => {
wrapper.style.removeProperty('height')
currentTransitionHandler = null
}
currentTransitionHandler = handleTransitionEnd
wrapper.addEventListener('transitionend', handleTransitionEnd, { once: true })
})
Comment on lines +168 to +174
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The transitionend event may not fire in certain edge cases (e.g., if the transition is interrupted, the element is hidden, or the browser skips very small transitions), which would leave the explicit height style set on the wrapper and prevent it from properly auto-sizing.

Consider adding a fallback timeout to ensure the height property is eventually removed:

const handleTransitionEnd = () => {
  wrapper.style.removeProperty('height')
  currentTransitionHandler = null
  if (timeoutId !== null) {
    clearTimeout(timeoutId)
    timeoutId = null
  }
}
currentTransitionHandler = handleTransitionEnd
wrapper.addEventListener('transitionend', handleTransitionEnd, { once: true })

// Fallback timeout (transition is 300ms)
const timeoutId = setTimeout(handleTransitionEnd, 400)
Suggested change
const handleTransitionEnd = () => {
wrapper.style.removeProperty('height')
currentTransitionHandler = null
}
currentTransitionHandler = handleTransitionEnd
wrapper.addEventListener('transitionend', handleTransitionEnd, { once: true })
})
let timeoutId: number | null = null
const handleTransitionEnd = () => {
wrapper.style.removeProperty('height')
currentTransitionHandler = null
if (timeoutId !== null) {
clearTimeout(timeoutId)
timeoutId = null
}
}
currentTransitionHandler = handleTransitionEnd
wrapper.addEventListener('transitionend', handleTransitionEnd, { once: true })
// Fallback timeout (transition is 300ms)
timeoutId = window.setTimeout(handleTransitionEnd, 400)

Copilot uses AI. Check for mistakes.
}
})

resizeObserver.observe(contentRef.current)

return () => {
resizeObserver.disconnect()
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
if (currentTransitionHandler) {
wrapper.removeEventListener('transitionend', currentTransitionHandler)
}
}
}, [toggled])
Comment on lines +130 to +189
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential race condition when toggling from closed to open. Both the toggle animation effect (lines 96-127) and this ResizeObserver effect will be active simultaneously. During the expansion animation, the ResizeObserver may fire and attempt to adjust the height while the toggle animation is still in progress, causing conflicting height manipulations.

Consider one of these approaches:

  1. Add a delay before observing (e.g., using a timeout matching the transition duration)
  2. Track animation state with a ref and skip ResizeObserver adjustments during toggle animations
  3. Use a flag to temporarily disable ResizeObserver callbacks during toggle transitions

Example:

const isAnimating = useRef(false)

// In first useEffect, set isAnimating.current = true and clear in transitionend
// In ResizeObserver callback, check if (!isAnimating.current) before adjusting

Copilot uses AI. Check for mistakes.

return { wrapperRef, contentRef }
}
45 changes: 45 additions & 0 deletions packages/leva/stories/input-options.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,51 @@ export const Render = () => {
)
}

export const ConditionalRenderingPanelHeight = () => {
const values = useControls({
showBasicFields: { value: true, label: 'Show Basic Fields' },
name: { value: 'John Doe', render: (get) => get('showBasicFields') },
age: { value: 25, render: (get) => get('showBasicFields') },

showAdvanced: { value: false, label: 'Show Advanced Settings' },
advancedSettings: folder(
{
apiEndpoint: { value: 'https://api.example.com', render: (get) => get('advancedSettings.enableAPI') },
enableAPI: true,
timeout: { value: 5000, min: 1000, max: 30000 },
retries: { value: 3, min: 0, max: 10 },
},
{ render: (get) => get('showAdvanced') }
),

showDebug: { value: false, label: 'Show Debug Options' },
debugOptions: folder(
{
verbose: false,
logLevel: { value: 'info', options: ['debug', 'info', 'warn', 'error'] },
showTimestamps: true,
colorOutput: { value: true, render: (get) => get('debugOptions.verbose') },
},
{ render: (get) => get('showDebug') }
),
})

return (
<div style={{ padding: 20 }}>
<h3 style={{ marginTop: 0 }}>Panel Height Auto-Adjusts Demo</h3>
<p style={{ marginBottom: 20, color: '#666' }}>
Toggle the checkboxes to show/hide different sections.
The panel height will smoothly animate to accommodate the content.
</p>
<pre style={{ background: '#f5f5f5', padding: 15, borderRadius: 8, overflow: 'auto' }}>
{JSON.stringify(values, null, 2)}
</pre>
</div>
)
}

ConditionalRenderingPanelHeight.storyName = 'Conditional Rendering - Panel Height Fix'

export const Optional = () => {
const values = useControls({
color: { value: '#f00', optional: true },
Expand Down
Loading