[DevTools] Custom Scrubber Design (#34627)

Stacked on #34620.

This will let us use different color for different segments of the
timeline.

Since we're modeling discrete steps (sometimes just a couple), a
scrubber with a handle that you have to move is quite annoying and
misleading. Doesn't show you how many steps there are. Therefore I went
with a design that highlights each segment as its own step and you can
click to jump to a step.

This is still backed by an input range for accessibility and keyboard
controls.

<img width="1213" height="434" alt="Screenshot 2025-09-27 at 4 50 21 PM"
src="https://github.com/user-attachments/assets/2c81753d-1b66-4434-8b1d-0a163fa22ab3"
/>
<img width="1213" height="430" alt="Screenshot 2025-09-27 at 4 50 45 PM"
src="https://github.com/user-attachments/assets/07983978-a8f6-46ed-8c51-6ec96487af66"
/>


https://github.com/user-attachments/assets/bc725f01-f0b5-40a8-bbb5-24cc4e84e86d
This commit is contained in:
Sebastian Markbåge 2025-09-28 20:00:09 -04:00 committed by GitHub
parent 7c0fff6f2b
commit dce1f6cd5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 151 additions and 57 deletions

View File

@ -0,0 +1,57 @@
.SuspenseScrubber {
position: relative;
width: 100%;
height: 1.5rem;
border-radius: 0.75rem;
padding: 0.25rem;
box-sizing: border-box;
display: flex;
align-items: center;
}
.SuspenseScrubber:has(.SuspenseScrubberInput:focus-visible) {
outline: 2px solid var(--color-button-background-focus);
}
.SuspenseScrubberInput {
position: absolute;
width: 100%;
opacity: 0;
height: 0px;
overflow: hidden;
}
.SuspenseScrubberInput:focus {
outline: none;
}
.SuspenseScrubberStep {
cursor: pointer;
flex: 1;
height: 100%;
padding-right: 1px; /* we use this instead of flex gap to make every pixel clickable */
display: flex;
align-items: center;
}
.SuspenseScrubberStep:last-child {
padding-right: 0;
}
.SuspenseScrubberBead, .SuspenseScrubberBeadSelected {
flex: 1;
height: 0.5rem;
background: var(--color-background-selected);
border-radius: 0.5rem;
background: var(--color-selected-tree-highlight-active);
transition: all 0.3s ease-in-out;
}
.SuspenseScrubberBeadSelected {
height: 1rem;
background: var(--color-background-selected);
}
.SuspenseScrubberStep:hover > .SuspenseScrubberBead,
.SuspenseScrubberStep:hover > .SuspenseScrubberBeadSelected {
height: 0.75rem;
}

View File

@ -0,0 +1,86 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import typeof {SyntheticEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
import * as React from 'react';
import {useRef} from 'react';
import styles from './SuspenseScrubber.css';
export default function SuspenseScrubber({
min,
max,
value,
onBlur,
onChange,
onFocus,
onHoverSegment,
onHoverLeave,
}: {
min: number,
max: number,
value: number,
onBlur: () => void,
onChange: (index: number) => void,
onFocus: () => void,
onHoverSegment: (index: number) => void,
onHoverLeave: () => void,
}): React$Node {
const inputRef = useRef();
function handleChange(event: SyntheticEvent) {
const newValue = +event.currentTarget.value;
onChange(newValue);
}
function handlePress(index: number, event: SyntheticEvent) {
event.preventDefault();
if (inputRef.current == null) {
throw new Error(
'The input should always be mounted while we can click things.',
);
}
inputRef.current.focus();
onChange(index);
}
const steps = [];
for (let index = min; index <= max; index++) {
steps.push(
<div
key={index}
className={styles.SuspenseScrubberStep}
onPointerDown={handlePress.bind(null, index)}
onMouseEnter={onHoverSegment.bind(null, index)}>
<div
className={
index <= value
? styles.SuspenseScrubberBeadSelected
: styles.SuspenseScrubberBead
}
/>
</div>,
);
}
return (
<div className={styles.SuspenseScrubber} onMouseLeave={onHoverLeave}>
<input
className={styles.SuspenseScrubberInput}
type="range"
min={min}
max={max}
value={value}
onBlur={onBlur}
onChange={handleChange}
onFocus={onFocus}
ref={inputRef}
/>
{steps}
</div>
);
}

View File

@ -9,11 +9,6 @@
display: flex;
flex-direction: column;
flex-grow: 1;
/*
* `overflow: auto` will add scrollbars but the input will not actually grow beyond visible content.
* `overflow: hidden` will constrain the input to its visible content.
*/
overflow: hidden;
}
.SuspenseTimelineRootSwitcher {

View File

@ -8,7 +8,7 @@
*/
import * as React from 'react';
import {useContext, useLayoutEffect, useEffect, useRef} from 'react';
import {useContext, useEffect} from 'react';
import {BridgeContext, StoreContext} from '../context';
import {TreeDispatcherContext} from '../Components/TreeContext';
import {useHighlightHostInstance} from '../hooks';
@ -17,10 +17,7 @@ import {
SuspenseTreeStateContext,
} from './SuspenseTreeContext';
import styles from './SuspenseTimeline.css';
import typeof {
SyntheticEvent,
SyntheticPointerEvent,
} from 'react-dom-bindings/src/events/SyntheticEvent';
import SuspenseScrubber from './SuspenseScrubber';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
@ -39,29 +36,6 @@ function SuspenseTimelineInput() {
playing,
} = useContext(SuspenseTreeStateContext);
const inputRef = useRef<HTMLElement | null>(null);
const inputBBox = useRef<ClientRect | null>(null);
useLayoutEffect(() => {
if (timeline.length === 0) {
return;
}
const input = inputRef.current;
if (input === null) {
throw new Error('Expected an input HTML element to be present.');
}
inputBBox.current = input.getBoundingClientRect();
const observer = new ResizeObserver(entries => {
inputBBox.current = input.getBoundingClientRect();
});
observer.observe(input);
return () => {
inputBBox.current = null;
observer.disconnect();
};
}, [timeline.length]);
const min = 0;
const max = timeline.length > 0 ? timeline.length - 1 : 0;
@ -100,8 +74,7 @@ function SuspenseTimelineInput() {
});
}
function handleChange(event: SyntheticEvent) {
const pendingTimelineIndex = +event.currentTarget.value;
function handleChange(pendingTimelineIndex: number) {
switchSuspenseNode(pendingTimelineIndex);
}
@ -113,25 +86,11 @@ function SuspenseTimelineInput() {
switchSuspenseNode(timelineIndex);
}
function handlePointerMove(event: SyntheticPointerEvent) {
const bbox = inputBBox.current;
if (bbox === null) {
throw new Error('Bounding box of slider is unknown.');
}
const hoveredValue = Math.max(
min,
Math.min(
Math.round(
min + ((event.clientX - bbox.left) / bbox.width) * (max - min),
),
max,
),
);
function handleHoverSegment(hoveredValue: number) {
const suspenseID = timeline[hoveredValue];
if (suspenseID === undefined) {
throw new Error(
`Suspense node not found for value ${hoveredValue} in timeline when on ${event.clientX} in bounding box ${JSON.stringify(bbox)}.`,
`Suspense node not found for value ${hoveredValue} in timeline.`,
);
}
highlightHostInstance(suspenseID);
@ -239,18 +198,15 @@ function SuspenseTimelineInput() {
<div
className={styles.SuspenseTimelineInput}
title={timelineIndex + '/' + max}>
<input
className={styles.SuspenseTimelineSlider}
type="range"
<SuspenseScrubber
min={min}
max={max}
value={timelineIndex}
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
onPointerMove={handlePointerMove}
onPointerUp={clearHighlightHostInstance}
ref={inputRef}
onHoverSegment={handleHoverSegment}
onHoverLeave={clearHighlightHostInstance}
/>
</div>
</>