Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stepper-slider-remeasure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sourceacademy/web-stepper": patch
---

stepper: clicking the step slider goes to the step clicked, also after the tab was resized
24 changes: 24 additions & 0 deletions src/web/stepper/src/SubstVisualizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,35 @@ 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<HTMLElement | null>(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
* `SideContentSubstVisualizer`, minus its redux/i18n/js-slang couplings.
*/
export default function StepperView(props: StepperViewProps) {
const [stepValue, setStepValue] = useState(1);
const [rootRef, width] = useWidth();
const lastStepValue = props.content.length;
const hasRunCode = lastStepValue !== 0;

Expand Down Expand Up @@ -178,13 +200,15 @@ export default function StepperView(props: StepperViewProps) {

return (
<div
ref={rootRef}
className={classNames("sa-substituter", Classes.DARK)}
onKeyDown={hotkeyHandler}
tabIndex={-1} // tab index necessary to fire keydown events on div element
>
{/* The slider counts the steps taken: 0 is the start, the last is the total number. A run of
one step has nothing to slide between: a valid range, disabled. */}
<Slider
key={width}
disabled={lastStepValue < 2}
min={0}
max={Math.max(1, lastStepValue - 1)}
Expand Down
34 changes: 34 additions & 0 deletions src/web/stepper/src/__tests__/SubstVisualizer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,37 @@ describe("sliderLabels", () => {
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(<StepperView content={run(5)} />, {
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();
});
});
Loading