Skip to content

Commit 625e066

Browse files
committed
sticky row width calc sharing
1 parent aadd1c4 commit 625e066

2 files changed

Lines changed: 137 additions & 1 deletion

File tree

packages/react/src/dropdown-menu/root/root.test.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,52 @@ function DropdownMenuWithNestedSubpages() {
717717
)
718718
}
719719

720+
function DropdownMenuWithMeasuredNestedSubpages() {
721+
return (
722+
<DropdownMenu.Root>
723+
<DropdownMenu.Trigger data-testid="trigger">
724+
Open Menu
725+
</DropdownMenu.Trigger>
726+
<DropdownMenu.Portal>
727+
<DropdownMenu.Positioner>
728+
<DropdownMenu.Popup>
729+
<DropdownMenu.Surface data-testid="root-surface">
730+
<DropdownMenu.List data-testid="root-list">
731+
<DropdownMenu.SubpageTrigger
732+
data-testid="subpage-trigger-1"
733+
targetPageId="page-1"
734+
data-measure-width="420"
735+
>
736+
Root Wide Item
737+
</DropdownMenu.SubpageTrigger>
738+
</DropdownMenu.List>
739+
</DropdownMenu.Surface>
740+
741+
<DropdownMenu.Subpage pageId="page-1">
742+
<DropdownMenu.Surface data-testid="subpage-surface-1">
743+
<DropdownMenu.List data-testid="subpage-list-1">
744+
<DropdownMenu.SubpageBackItem
745+
data-testid="subpage-back-item-1"
746+
data-measure-width="180"
747+
>
748+
Back
749+
</DropdownMenu.SubpageBackItem>
750+
<DropdownMenu.Item
751+
data-testid="subpage-item-1"
752+
data-measure-width="200"
753+
>
754+
Narrow Item
755+
</DropdownMenu.Item>
756+
</DropdownMenu.List>
757+
</DropdownMenu.Surface>
758+
</DropdownMenu.Subpage>
759+
</DropdownMenu.Popup>
760+
</DropdownMenu.Positioner>
761+
</DropdownMenu.Portal>
762+
</DropdownMenu.Root>
763+
)
764+
}
765+
720766
function DropdownMenuWithAsyncSubpageBackItem({
721767
onSelectAsync,
722768
}: {
@@ -2396,6 +2442,69 @@ describe('<DropdownMenu.Root />', () => {
23962442
})
23972443
})
23982444

2445+
it('keeps sticky row width between subpage navigations in the same popup', async () => {
2446+
const user = userEvent.setup()
2447+
2448+
const getMockWidth = (element: HTMLElement) => {
2449+
const rawWidth = element.getAttribute('data-measure-width')
2450+
if (!rawWidth) {
2451+
return 0
2452+
}
2453+
2454+
const parsedWidth = Number.parseFloat(rawWidth)
2455+
return Number.isFinite(parsedWidth) ? parsedWidth : 0
2456+
}
2457+
2458+
const requestAnimationFrameSpy = vi
2459+
.spyOn(window, 'requestAnimationFrame')
2460+
.mockImplementation((callback: FrameRequestCallback) => {
2461+
callback(0)
2462+
return 1
2463+
})
2464+
const cancelAnimationFrameSpy = vi
2465+
.spyOn(window, 'cancelAnimationFrame')
2466+
.mockImplementation(() => {})
2467+
const scrollWidthSpy = vi
2468+
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
2469+
.mockImplementation(function (this: HTMLElement) {
2470+
return getMockWidth(this)
2471+
})
2472+
const offsetWidthSpy = vi
2473+
.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
2474+
.mockImplementation(function (this: HTMLElement) {
2475+
return getMockWidth(this)
2476+
})
2477+
2478+
try {
2479+
render(<DropdownMenuWithMeasuredNestedSubpages />)
2480+
2481+
await user.click(screen.getByTestId('trigger'))
2482+
2483+
await waitFor(() => {
2484+
expect(screen.getByTestId('root-surface')).toBeInTheDocument()
2485+
})
2486+
2487+
const popup = screen.getByRole('dialog')
2488+
2489+
await waitFor(() => {
2490+
expect(popup.style.getPropertyValue('--row-width')).toBe('421px')
2491+
})
2492+
2493+
await user.click(screen.getByTestId('subpage-trigger-1'))
2494+
2495+
await waitFor(() => {
2496+
expect(screen.getByTestId('subpage-surface-1')).toBeInTheDocument()
2497+
})
2498+
2499+
expect(popup.style.getPropertyValue('--row-width')).toBe('421px')
2500+
} finally {
2501+
requestAnimationFrameSpy.mockRestore()
2502+
cancelAnimationFrameSpy.mockRestore()
2503+
scrollWidthSpy.mockRestore()
2504+
offsetWidthSpy.mockRestore()
2505+
}
2506+
})
2507+
23992508
it('goes back one page with ArrowLeft', async () => {
24002509
const user = userEvent.setup()
24012510
render(<DropdownMenuWithNestedSubpages />)

packages/react/src/internal/listbox/hooks/use-sticky-row-width.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ function px(n: number) {
1919
return `${Math.ceil(n)}px`
2020
}
2121

22+
function parseCssPixelValue(value: string): number | null {
23+
const parsed = Number.parseFloat(value)
24+
return Number.isFinite(parsed) ? parsed : null
25+
}
26+
2227
interface MeasurementEntry {
2328
element: HTMLElement
2429
id: string
@@ -71,7 +76,7 @@ export interface UseStickyRowWidthReturn {
7176
* The hook:
7277
* 1. Measures each row's natural width (using `max-content`)
7378
* 2. Tracks the maximum width seen across all rows
74-
* 3. Applies `--row-width` CSS variable to the list element
79+
* 3. Applies `--row-width` CSS variable to the target element
7580
* 4. Only grows the width, never shrinks (until reset)
7681
*
7782
* @example
@@ -109,6 +114,28 @@ export function useStickyRowWidth(
109114
// Track the maximum width seen so far
110115
const maxSeenRef = React.useRef(0)
111116

117+
// Hydrate max width from an existing popup-level CSS variable.
118+
// This keeps row width sticky when List remounts during subpage navigation.
119+
React.useLayoutEffect(() => {
120+
if (!enabled) return
121+
122+
const el = getTargetElement()
123+
if (!el) return
124+
125+
const existingWidth = parseCssPixelValue(
126+
el.style.getPropertyValue('--row-width').trim(),
127+
)
128+
129+
if (!existingWidth || existingWidth <= maxSeenRef.current) {
130+
return
131+
}
132+
133+
maxSeenRef.current = existingWidth
134+
debugLog('Hydrated max width from existing --row-width:', {
135+
hydratedWidth: existingWidth,
136+
})
137+
}, [enabled, getTargetElement])
138+
112139
// RAF scheduler state
113140
const readQueue = React.useRef<MeasurementEntry[]>([])
114141
const writeQueue = React.useRef<Array<() => void>>([])

0 commit comments

Comments
 (0)