mirror of
https://github.com/zebrajr/react.git
synced 2025-12-06 12:20:20 +01:00
[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:
parent
7c0fff6f2b
commit
dce1f6cd5d
|
|
@ -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;
|
||||
}
|
||||
86
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js
vendored
Normal file
86
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user