diff --git a/.changeset/stepper-slider-remeasure.md b/.changeset/stepper-slider-remeasure.md new file mode 100644 index 0000000..525f6ee --- /dev/null +++ b/.changeset/stepper-slider-remeasure.md @@ -0,0 +1,5 @@ +--- +"@sourceacademy/web-stepper": patch +--- + +stepper: clicking the step slider goes to the step clicked, also after the tab was resized diff --git a/src/web/stepper/src/SubstVisualizer.tsx b/src/web/stepper/src/SubstVisualizer.tsx index bf5162e..0b39ed6 100644 --- a/src/web/stepper/src/SubstVisualizer.tsx +++ b/src/web/stepper/src/SubstVisualizer.tsx @@ -83,6 +83,27 @@ type StepperViewProps = { profile?: SyntaxProfile; }; +/** + * Tracks an element's width; attach the returned callback as the element's `ref`. Blueprint's + * slider measures its track only when it mounts, and converts a click to a step with that width, so + * the slider is keyed by this width to measure again after a resize. + */ +function useWidth(): [(element: HTMLElement | null) => void, number] { + const [element, setElement] = useState(null); + const [width, setWidth] = useState(0); + useEffect(() => { + if (!element) return; + setWidth(Math.floor(element.getBoundingClientRect().width)); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(entries => + setWidth(Math.floor(entries[0].contentRect.width)), + ); + observer.observe(element); + return () => observer.disconnect(); + }, [element]); + return [setElement, width]; +} + /** * The presentational stepper: a slider + breakpoint controls over a list of serialized steps, with * a custom AST renderer and an explanation panel. A faithful port of the frontend's legacy @@ -90,6 +111,7 @@ type StepperViewProps = { */ export default function StepperView(props: StepperViewProps) { const [stepValue, setStepValue] = useState(1); + const [rootRef, width] = useWidth(); const lastStepValue = props.content.length; const hasRunCode = lastStepValue !== 0; @@ -178,6 +200,7 @@ export default function StepperView(props: StepperViewProps) { return (
{ expect(sliderLabels(37)).toEqual([0, 4, 8, 12, 16, 20, 24, 28, 32, 37]); }); }); + +describe("the step slider's track", () => { + test("is measured again when the tab is resized (a click maps to the right step)", () => { + const observers: ((entries: { contentRect: { width: number } }[]) => void)[] = []; + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: (typeof observers)[number]) { + observers.push(callback); + } + observe() {} + disconnect() {} + }, + ); + // A Blueprint slider takes its listeners off the document when it goes away. + vi.stubGlobal("document", { + addEventListener() {}, + removeEventListener() {}, + getElementById: () => ({ textContent: "" }), + }); + let view!: TestRenderer.ReactTestRenderer; + act(() => { + view = TestRenderer.create(, { + createNodeMock: () => ({ getBoundingClientRect: () => ({ width: 600 }) }), + }); + }); + const before = view.root.findByType(Slider); + act(() => observers.forEach(cb => cb([{ contentRect: { width: 600 } }]))); + expect(view.root.findByType(Slider)).toBe(before); + act(() => observers.forEach(cb => cb([{ contentRect: { width: 900 } }]))); + expect(view.root.findByType(Slider)).not.toBe(before); + vi.unstubAllGlobals(); + }); +});