Skip to content
Draft
61 changes: 61 additions & 0 deletions apps/visr/src/components/HeightHandle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { fireEvent, render, screen } from "@atlas/vitest-conf";
import HeightHandle from "./HeightHandle";

/** A handle under a box that currently measures `height` pixels */
function setup(height: number, value: number | null = null) {
const onChange = vi.fn();
const box = document.createElement("div");
box.getBoundingClientRect = () => ({ height }) as DOMRect;
render(
<HeightHandle
target={{ current: box }}
value={value}
onChange={onChange}
min={100}
max={600}
label="Height of the plots"
/>,
);
return { onChange, handle: screen.getByRole("separator") };
}

// jsdom has no PointerEvent, so pointer events would lose their coordinates
class TestPointerEvent extends MouseEvent {
pointerId: number;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerId = init.pointerId ?? 0;
}
}

describe("HeightHandle", () => {
beforeAll(() => vi.stubGlobal("PointerEvent", TestPointerEvent));
afterAll(() => vi.unstubAllGlobals());

it("sets the height by how far it is dragged", () => {
const { onChange, handle } = setup(300);
handle.setPointerCapture = () => {};
handle.releasePointerCapture = () => {};
fireEvent.pointerDown(handle, { clientY: 400, pointerId: 1 });
fireEvent.pointerMove(handle, { clientY: 450, pointerId: 1 });
expect(onChange).toHaveBeenLastCalledWith(350);
fireEvent.pointerMove(handle, { clientY: 0, pointerId: 1 });
expect(onChange).toHaveBeenLastCalledWith(100); // no lower than min
fireEvent.pointerUp(handle, { pointerId: 1 });
fireEvent.pointerMove(handle, { clientY: 500, pointerId: 1 });
expect(onChange).toHaveBeenCalledTimes(2); // not after letting go
});

it("moves with the arrow keys, and Escape or a double click resets it", () => {
const { onChange, handle } = setup(300, 300);
fireEvent.keyDown(handle, { key: "ArrowDown" });
expect(onChange).toHaveBeenLastCalledWith(320);
fireEvent.keyDown(handle, { key: "ArrowUp" });
expect(onChange).toHaveBeenLastCalledWith(280);
fireEvent.keyDown(handle, { key: "Escape" });
expect(onChange).toHaveBeenLastCalledWith(null);
fireEvent.doubleClick(handle);
expect(onChange).toHaveBeenCalledTimes(4);
expect(handle).toHaveAttribute("aria-valuenow", "300");
});
});
97 changes: 97 additions & 0 deletions apps/visr/src/components/HeightHandle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Box } from "@mui/material";
import { useRef, type RefObject } from "react";

interface HeightHandleProps {
/** The element whose height the handle sets (it sits on its bottom edge) */
target: RefObject<HTMLElement | null>;
/** The height set, or null while it is automatic */
value: number | null;
/** The new height in pixels, or null to go back to the automatic height */
onChange: (height: number | null) => void;
min?: number;
max?: number;
label: string;
}

/** Pixels moved by each arrow key press */
const KEY_STEP = 20;

/**
* A horizontal line that can be dragged up and down to set the height of the
* element above it. Arrow keys move it too, and a double click (or Escape)
* goes back to the automatic height.
*/
function HeightHandle({
target,
value,
onChange,
min = 150,
max = 2000,
label,
}: HeightHandleProps) {
const drag = useRef<{ y: number; height: number } | null>(null);
const clamp = (h: number) => Math.round(Math.min(max, Math.max(min, h)));
const current = () => target.current?.getBoundingClientRect().height ?? min;

return (
<Box
role="separator"
aria-orientation="horizontal"
aria-label={label}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value ?? undefined}
tabIndex={0}
title="Drag to resize; double-click to reset"
onPointerDown={e => {
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { y: e.clientY, height: current() };
e.preventDefault(); // no text selection while dragging
}}
onPointerMove={e => {
if (!drag.current) return;
onChange(clamp(drag.current.height + e.clientY - drag.current.y));
}}
onPointerUp={e => {
e.currentTarget.releasePointerCapture(e.pointerId);
drag.current = null;
}}
onDoubleClick={() => onChange(null)}
onKeyDown={e => {
const step = { ArrowUp: -KEY_STEP, ArrowDown: KEY_STEP }[e.key];
if (step) {
onChange(clamp(current() + step));
e.preventDefault();
} else if (e.key === "Escape") {
onChange(null);
}
}}
sx={{
// straddles the line below it, so it is easy to grab
height: 10,
my: "-5px",
position: "relative",
zIndex: 1,
flexShrink: 0,
cursor: "row-resize",
touchAction: "none",
"&::after": {
content: '""',
position: "absolute",
left: 0,
right: 0,
top: 4,
height: 2,
bgcolor: "transparent",
transition: "background-color 0.15s",
},
"&:hover::after, &:focus-visible::after, &:active::after": {
bgcolor: "primary.main",
},
outline: "none",
}}
/>
);
}

export default HeightHandle;
32 changes: 32 additions & 0 deletions apps/visr/src/components/spectroscopy/RegionSelectMode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {
InteractionModeType,
SelectionType,
usePlotCustomizationContext,
} from "@diamondlightsource/davidia";
import { useEffect } from "react";

/**
* Lets a rectangle be drawn on the plot it is rendered in (as the plot's
* toolbar children) while `active`, and otherwise pans and zooms as usual,
* clearing the drawn rectangle. It draws nothing itself.
*/
function RegionSelectMode({ active }: { active: boolean }) {
const { setMode, setSelectionType, setSelections } =
usePlotCustomizationContext();

useEffect(() => {
if (active) {
setSelectionType(SelectionType.rectangle);
setMode(InteractionModeType.selectRegion);
} else {
setMode(InteractionModeType.panAndWheelZoom);
setSelections([]);
}
// the setters change identity with the plot's state; only `active` matters
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);

return null;
}

export default RegionSelectMode;
Loading
Loading