Skip to content

Commit 34208f0

Browse files
frankieyanclaude
andcommitted
fix(sidebar): derive the unmountOnHide fallback from the transition duration
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent f37e767 commit 34208f0

3 files changed

Lines changed: 71 additions & 2 deletions

File tree

src/sidebar/sidebar.stories.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,7 @@ type PlaygroundArgs = {
618618
width: number
619619
resizable: boolean
620620
dismissOverlayOnEscape: boolean
621+
unmountOnHide: boolean
621622
}
622623

623624
export const Playground = {
@@ -629,6 +630,7 @@ export const Playground = {
629630
width: 280,
630631
resizable: true,
631632
dismissOverlayOnEscape: true,
633+
unmountOnHide: false,
632634
},
633635
argTypes: {
634636
align: { control: { type: 'inline-radio' }, options: ['start', 'end'] },
@@ -638,6 +640,7 @@ export const Playground = {
638640
width: { control: { type: 'range', min: 210, max: 400, step: 10 } },
639641
resizable: { control: { type: 'boolean' } },
640642
dismissOverlayOnEscape: { control: { type: 'boolean' } },
643+
unmountOnHide: { control: { type: 'boolean' } },
641644
},
642645
render: function Playground({
643646
align,
@@ -647,6 +650,7 @@ export const Playground = {
647650
width: widthArg,
648651
resizable,
649652
dismissOverlayOnEscape,
653+
unmountOnHide,
650654
}: PlaygroundArgs) {
651655
const [isOpen, setIsOpen] = React.useState(isOpenArg)
652656
const [width, setWidth] = React.useState(widthArg)
@@ -671,6 +675,7 @@ export const Playground = {
671675
overlayMode={overlayMode}
672676
isOpen={isOpen}
673677
dismissOverlayOnEscape={dismissOverlayOnEscape}
678+
unmountOnHide={unmountOnHide}
674679
onDismiss={() => setIsOpen(false)}
675680
width={width}
676681
onWidthChange={setWidth}

src/sidebar/sidebar.test.tsx

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react'
22

3-
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
3+
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
44
import { axe } from 'jest-axe'
55

66
import { Sidebar, SidebarContent, SidebarResizeHandle } from './sidebar'
@@ -288,6 +288,50 @@ describe('focus management', () => {
288288
})
289289
})
290290

291+
describe('unmountOnHide', () => {
292+
const tree = (isOpen: boolean) => (
293+
<Sidebar align="start" isOpen={isOpen} unmountOnHide>
294+
<SidebarContent data-testid="sidebar-panel" aria-label="Nav">
295+
<nav aria-label="Primary">Panel body</nav>
296+
</SidebarContent>
297+
</Sidebar>
298+
)
299+
300+
it('keeps children through the exit, then drops them on transitionend', () => {
301+
const { rerender } = render(tree(true))
302+
expect(screen.getByText('Panel body')).toBeInTheDocument()
303+
304+
rerender(tree(false))
305+
expect(screen.getByText('Panel body')).toBeInTheDocument()
306+
307+
fireEvent.transitionEnd(screen.getByTestId('sidebar-panel'))
308+
expect(screen.queryByText('Panel body')).not.toBeInTheDocument()
309+
})
310+
311+
it('cancels a pending unmount when reopened mid-exit', () => {
312+
const { rerender } = render(tree(true))
313+
rerender(tree(false))
314+
rerender(tree(true))
315+
fireEvent.transitionEnd(screen.getByTestId('sidebar-panel'))
316+
expect(screen.getByText('Panel body')).toBeInTheDocument()
317+
})
318+
319+
it('unmounts without a transitionend when there is no transition (reduced motion)', () => {
320+
jest.useFakeTimers()
321+
try {
322+
const { rerender } = render(tree(true))
323+
rerender(tree(false))
324+
expect(screen.getByText('Panel body')).toBeInTheDocument()
325+
act(() => {
326+
jest.runOnlyPendingTimers()
327+
})
328+
expect(screen.queryByText('Panel body')).not.toBeInTheDocument()
329+
} finally {
330+
jest.useRealTimers()
331+
}
332+
})
333+
})
334+
291335
describe('SidebarResizeHandle', () => {
292336
function renderResizable(props: Partial<SidebarProps> = {}) {
293337
const onWidthChange = jest.fn()

src/sidebar/sidebar.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,10 @@ function useDeferredUnmount({
392392

393393
const panel = panelRef.current
394394
// setExited only fires from async callbacks below, never synchronously here.
395-
const fallbackTimeout = window.setTimeout(() => setExited(true), 500)
395+
const fallbackTimeout = window.setTimeout(
396+
() => setExited(true),
397+
getExitTimeoutMs(panel),
398+
)
396399

397400
function handleTransitionEnd(event: TransitionEvent) {
398401
if (event.target === panel) {
@@ -413,6 +416,23 @@ function useDeferredUnmount({
413416
return isOpen || !unmountOnHide || !exited
414417
}
415418

419+
function parseCssDurationMs(value: string): number {
420+
return value.split(',').reduce((max, part) => {
421+
const trimmed = part.trim()
422+
const numeric = Number.parseFloat(trimmed)
423+
if (!Number.isFinite(numeric)) return max
424+
return Math.max(max, trimmed.endsWith('ms') ? numeric : numeric * 1000)
425+
}, 0)
426+
}
427+
428+
function getExitTimeoutMs(panel: HTMLElement | null): number {
429+
if (!panel) return 0
430+
const style = window.getComputedStyle(panel)
431+
const durationMs = parseCssDurationMs(style.transitionDuration)
432+
if (durationMs === 0) return 0
433+
return durationMs + parseCssDurationMs(style.transitionDelay) + 50
434+
}
435+
416436
//
417437
// SidebarResizeHandle
418438
//

0 commit comments

Comments
 (0)