Skip to content

Commit 878da4f

Browse files
committed
Add custom scrubber design
1 parent a7c0c06 commit 878da4f

File tree

4 files changed

+154
-57
lines changed

4 files changed

+154
-57
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
.SuspenseScrubber {
2+
position: relative;
3+
width: 100%;
4+
height: 1.5rem;
5+
border-radius: 0.75rem;
6+
padding: 0.25rem;
7+
box-sizing: border-box;
8+
display: flex;
9+
align-items: center;
10+
}
11+
12+
.SuspenseScrubber:has(.SuspenseScrubberInput:focus-visible) {
13+
outline: 2px solid var(--color-button-background-focus);
14+
}
15+
16+
.SuspenseScrubberInput {
17+
position: absolute;
18+
width: 100%;
19+
opacity: 0;
20+
height: 0px;
21+
overflow: hidden;
22+
}
23+
24+
.SuspenseScrubberInput:focus {
25+
outline: none;
26+
}
27+
28+
.SuspenseScrubberStep {
29+
cursor: pointer;
30+
flex: 1;
31+
height: 100%;
32+
padding-right: 1px; /* we use this instead of flex gap to make every pixel clickable */
33+
display: flex;
34+
align-items: center;
35+
}
36+
.SuspenseScrubberStep:last-child {
37+
padding-right: 0;
38+
}
39+
40+
.SuspenseScrubberBead, .SuspenseScrubberBeadSelected {
41+
flex: 1;
42+
height: 0.5rem;
43+
background: var(--color-background-selected);
44+
border-radius: 0.5rem;
45+
background: var(--color-selected-tree-highlight-active);
46+
transition: all 0.3s ease-in-out;
47+
}
48+
49+
.SuspenseScrubberBeadSelected {
50+
height: 1rem;
51+
background: var(--color-background-selected);
52+
}
53+
54+
.SuspenseScrubberBeadSelected:hover {
55+
height: 0.75rem;
56+
}
57+
58+
.SuspenseScrubberBead:hover {
59+
height: 0.75rem;
60+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import typeof {SyntheticEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
11+
12+
import * as React from 'react';
13+
import {useRef} from 'react';
14+
15+
import styles from './SuspenseScrubber.css';
16+
17+
export default function SuspenseScrubber({
18+
min,
19+
max,
20+
value,
21+
onBlur,
22+
onChange,
23+
onFocus,
24+
onHoverSegment,
25+
onHoverLeave,
26+
}: {
27+
min: number,
28+
max: number,
29+
value: number,
30+
onBlur: () => void,
31+
onChange: (index: number) => void,
32+
onFocus: () => void,
33+
onHoverSegment: (index: number) => void,
34+
onHoverLeave: () => void,
35+
}): React$Node {
36+
const inputRef = useRef();
37+
function handleChange(event: SyntheticEvent) {
38+
const newValue = +event.currentTarget.value;
39+
onChange(newValue);
40+
}
41+
function handlePress(index: number, event: SyntheticEvent) {
42+
event.preventDefault();
43+
if (inputRef.current == null) {
44+
throw new Error(
45+
'The input should always be mounted while we can click things.',
46+
);
47+
}
48+
inputRef.current.focus();
49+
onChange(index);
50+
}
51+
const steps = [];
52+
for (let index = min; index <= max; index++) {
53+
steps.push(
54+
<div
55+
key={index}
56+
className={styles.SuspenseScrubberStep}
57+
onPointerDown={handlePress.bind(null, index)}
58+
onMouseEnter={onHoverSegment.bind(null, index)}>
59+
<div
60+
className={
61+
index <= value
62+
? styles.SuspenseScrubberBeadSelected
63+
: styles.SuspenseScrubberBead
64+
}
65+
/>
66+
</div>,
67+
);
68+
}
69+
70+
return (
71+
<div className={styles.SuspenseScrubber} onMouseLeave={onHoverLeave}>
72+
<input
73+
className={styles.SuspenseScrubberInput}
74+
type="range"
75+
min={min}
76+
max={max}
77+
value={value}
78+
onBlur={onBlur}
79+
onChange={handleChange}
80+
onFocus={onFocus}
81+
ref={inputRef}
82+
/>
83+
{steps}
84+
</div>
85+
);
86+
}

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,6 @@
99
display: flex;
1010
flex-direction: column;
1111
flex-grow: 1;
12-
/*
13-
* `overflow: auto` will add scrollbars but the input will not actually grow beyond visible content.
14-
* `overflow: hidden` will constrain the input to its visible content.
15-
*/
16-
overflow: hidden;
1712
}
1813

1914
.SuspenseTimelineRootSwitcher {

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js

Lines changed: 8 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import * as React from 'react';
11-
import {useContext, useLayoutEffect, useEffect, useRef} from 'react';
11+
import {useContext, useEffect} from 'react';
1212
import {BridgeContext, StoreContext} from '../context';
1313
import {TreeDispatcherContext} from '../Components/TreeContext';
1414
import {useHighlightHostInstance} from '../hooks';
@@ -17,10 +17,7 @@ import {
1717
SuspenseTreeStateContext,
1818
} from './SuspenseTreeContext';
1919
import styles from './SuspenseTimeline.css';
20-
import typeof {
21-
SyntheticEvent,
22-
SyntheticPointerEvent,
23-
} from 'react-dom-bindings/src/events/SyntheticEvent';
20+
import SuspenseScrubber from './SuspenseScrubber';
2421
import Button from '../Button';
2522
import ButtonIcon from '../ButtonIcon';
2623

@@ -39,29 +36,6 @@ function SuspenseTimelineInput() {
3936
playing,
4037
} = useContext(SuspenseTreeStateContext);
4138

42-
const inputRef = useRef<HTMLElement | null>(null);
43-
const inputBBox = useRef<ClientRect | null>(null);
44-
useLayoutEffect(() => {
45-
if (timeline.length === 0) {
46-
return;
47-
}
48-
49-
const input = inputRef.current;
50-
if (input === null) {
51-
throw new Error('Expected an input HTML element to be present.');
52-
}
53-
54-
inputBBox.current = input.getBoundingClientRect();
55-
const observer = new ResizeObserver(entries => {
56-
inputBBox.current = input.getBoundingClientRect();
57-
});
58-
observer.observe(input);
59-
return () => {
60-
inputBBox.current = null;
61-
observer.disconnect();
62-
};
63-
}, [timeline.length]);
64-
6539
const min = 0;
6640
const max = timeline.length > 0 ? timeline.length - 1 : 0;
6741

@@ -100,8 +74,7 @@ function SuspenseTimelineInput() {
10074
});
10175
}
10276

103-
function handleChange(event: SyntheticEvent) {
104-
const pendingTimelineIndex = +event.currentTarget.value;
77+
function handleChange(pendingTimelineIndex: number) {
10578
switchSuspenseNode(pendingTimelineIndex);
10679
}
10780

@@ -113,25 +86,11 @@ function SuspenseTimelineInput() {
11386
switchSuspenseNode(timelineIndex);
11487
}
11588

116-
function handlePointerMove(event: SyntheticPointerEvent) {
117-
const bbox = inputBBox.current;
118-
if (bbox === null) {
119-
throw new Error('Bounding box of slider is unknown.');
120-
}
121-
122-
const hoveredValue = Math.max(
123-
min,
124-
Math.min(
125-
Math.round(
126-
min + ((event.clientX - bbox.left) / bbox.width) * (max - min),
127-
),
128-
max,
129-
),
130-
);
89+
function handleHoverSegment(hoveredValue: number) {
13190
const suspenseID = timeline[hoveredValue];
13291
if (suspenseID === undefined) {
13392
throw new Error(
134-
`Suspense node not found for value ${hoveredValue} in timeline when on ${event.clientX} in bounding box ${JSON.stringify(bbox)}.`,
93+
`Suspense node not found for value ${hoveredValue} in timeline.`,
13594
);
13695
}
13796
highlightHostInstance(suspenseID);
@@ -239,18 +198,15 @@ function SuspenseTimelineInput() {
239198
<div
240199
className={styles.SuspenseTimelineInput}
241200
title={timelineIndex + '/' + max}>
242-
<input
243-
className={styles.SuspenseTimelineSlider}
244-
type="range"
201+
<SuspenseScrubber
245202
min={min}
246203
max={max}
247204
value={timelineIndex}
248205
onBlur={handleBlur}
249206
onChange={handleChange}
250207
onFocus={handleFocus}
251-
onPointerMove={handlePointerMove}
252-
onPointerUp={clearHighlightHostInstance}
253-
ref={inputRef}
208+
onHoverSegment={handleHoverSegment}
209+
onHoverLeave={clearHighlightHostInstance}
254210
/>
255211
</div>
256212
</>

0 commit comments

Comments
 (0)