Skip to content
Closed
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
Binary file modified cypress/snapshots/app.cy.ts/auximage.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/auxspectrum.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/default_slice.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/fillvalue_1D.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/fillvalue_2D.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/heatmap_2D.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/heatmap_2D_complex.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/heatmap_2D_inverted_cmap.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/heatmap_4d_default.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/heatmap_4d_remapped.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/heatmap_4d_sliced.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/heatmap_4d_zeros.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/heatmap_domain.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/heatmap_flip.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/line_1D.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/line_1D_points.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/line_complex_1D.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/line_complex_constant.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/line_constant.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/nxheatmap.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/nxheatmap_complex_2d.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/nxline.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/nxline_complex_2d_aux.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/nxline_log.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/nxrgb.snap.png
Binary file modified cypress/snapshots/app.cy.ts/wide.snap.png
96 changes: 95 additions & 1 deletion packages/app/src/__tests__/DomainWidget.browser.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { expect, test } from 'vitest';
import { page } from 'vitest/browser';

import { renderApp } from '../test-utils';
import { dragHorizontally, renderApp } from '../test-utils';

test('show slider with two thumbs and reveal popup on hover', async () => {
const { user } = await renderApp('/nexus/NXdata');
Expand Down Expand Up @@ -94,6 +94,100 @@ test('move thumbs with keyboard to update domain', async () => {
expect(minInput).toHaveValue('−2.30818e+2'); // unaffected
});

test('move thumbs with pointer to update domain', async () => {
await renderApp('/nexus/NXdata');

const minThumb = page.getByRole('slider', { name: /min/ });
const maxThumb = page.getByRole('slider', { name: /max/ });
const minInput = page.getByLabelText('min', { exact: true });
const maxInput = page.getByLabelText('max', { exact: true });
const minBtn = page.getByRole('button', { name: 'Min', exact: true });
const maxBtn = page.getByRole('button', { name: 'Max', exact: true });

// Drag min thumb and bring it back to its initial position
await dragHorizontally(minThumb, [20, 0]);
await expect.element(minInput).toHaveValue('−9.5e+1'); // not changed
expect(minThumb).toHaveValue(20);
expect(minBtn).toHaveAttribute('aria-pressed', 'true'); // still autoscaling

// Drag min thumb past max thumb, then back a little
const { left: minLeft } = minThumb.element().getBoundingClientRect();
const { left: maxLeft } = maxThumb.element().getBoundingClientRect();
await dragHorizontally(minThumb, [maxLeft - minLeft + 20, maxLeft - minLeft]);

// Min thumb pushes max thumb and keeps focus
await expect.element(minBtn).toHaveAttribute('aria-pressed', 'false');
expect(maxBtn).toHaveAttribute('aria-pressed', 'false');
expect(minInput).toHaveValue('3.71154e+2');
expect(maxInput).toHaveValue('4.81114e+3');
expect(minThumb).toHaveFocus();
});

test('move thumb back and forth with keyboard in single interaction', async () => {
const { user } = await renderApp('/nexus/NXdata');

const minThumb = page.getByRole('slider', { name: /min/ });
const minInput = page.getByLabelText('min', { exact: true });
const minBtn = page.getByRole('button', { name: 'Min', exact: true });
await minThumb.hover(); // reveal popup

// Move min thumb back to its initial position before releasing key
minThumb.element().focus();
await user.keyboard('{ArrowLeft>}{ArrowRight}{/ArrowLeft}');
expect(minInput).toHaveValue('−9.5e+1'); // not changed
expect(minBtn).toHaveAttribute('aria-pressed', 'true'); // still autoscaling

// Releasing another key doesn't end the interaction
await user.keyboard('{ArrowLeft>}{Shift}');
expect(minInput).toHaveValue('−1.04671e+2');
expect(minBtn).toHaveAttribute('aria-pressed', 'true'); // not applied yet

await user.keyboard('{/ArrowLeft}');
expect(minBtn).toHaveAttribute('aria-pressed', 'false'); // applied
expect(minInput).toHaveValue('−1.04671e+2');
});

test('end keyboard interaction when focus moves away', async () => {
const { user } = await renderApp('/nexus/NXdata');

const minThumb = page.getByRole('slider', { name: /min/ });
const maxThumb = page.getByRole('slider', { name: /max/ });
const minBtn = page.getByRole('button', { name: 'Min', exact: true });
const maxBtn = page.getByRole('button', { name: 'Max', exact: true });
await minThumb.hover(); // reveal popup

// Focus moves to other thumb before key is released
minThumb.element().focus();
await user.keyboard('{ArrowLeft>}');
expect(minBtn).toHaveAttribute('aria-pressed', 'true'); // not applied yet

maxThumb.element().focus();
await user.keyboard('{ArrowLeft}');
expect(minBtn).toHaveAttribute('aria-pressed', 'false'); // min applied
expect(maxBtn).toHaveAttribute('aria-pressed', 'false'); // max moved and applied
await user.keyboard('{/ArrowLeft}');

// Focus leaves slider before key is released
await maxBtn.click(); // re-enable max autoscale
maxThumb.element().focus();
await user.keyboard('{ArrowLeft>}');
expect(maxBtn).toHaveAttribute('aria-pressed', 'true'); // not applied yet

await user.tab();
expect(maxBtn).toHaveAttribute('aria-pressed', 'false'); // applied
await user.keyboard('{/ArrowLeft}');
});

test('toggle popup when clicking left edge of edit button', async () => {
const { user } = await renderApp('/nexus/NXdata');

const editBtn = page.getByRole('button', { name: 'Edit domain' });
await user.click(editBtn, { position: { x: 1, y: 10 } });

expect(editBtn).toHaveAttribute('aria-pressed', 'true');
expect(page.getByRole('slider', { name: /max/ })).toHaveValue(81);
});

test('edit bounds manually', async () => {
const { user } = await renderApp('/nexus/NXdata');

Expand Down
28 changes: 27 additions & 1 deletion packages/app/src/test-utils.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { assertDefined, assertNonNull } from '@h5web/shared/guards';
import { expect, type MockInstance, vi } from 'vitest';
import { type Locator, page, userEvent } from 'vitest/browser';
import { commands, type Locator, page, userEvent } from 'vitest/browser';
import { render, type RenderResult } from 'vitest-browser-react';

import App from './App';
Expand All @@ -9,6 +9,12 @@ import { delay } from './providers/mock/utils';
import { type Vis } from './vis-packs/core/visualizations';
import { type NxDataVis } from './vis-packs/nexus/visualizations';

declare module 'vitest/browser' {
interface BrowserCommands {
mouseDrag: (path: [number, number][]) => Promise<void>;
}
}

interface RenderAppResult extends RenderResult {
user: ReturnType<typeof userEvent.setup>;
selectExplorerNode: (name: string) => Promise<void>;
Expand Down Expand Up @@ -135,6 +141,26 @@ export function getSelectedVisTab(): string {
return selectedTab.textContent;
}

/**
* Drag element with real mouse events: press mouse button at centre of element,
* move pointer horizontally through the given offsets (in px), then release.
*/
export async function dragHorizontally(
locator: Locator,
offsets: number[],
): Promise<void> {
const { left, top, width, height } = locator
.element()
.getBoundingClientRect();

const x = left + width / 2;
const y = top + height / 2;
await commands.mouseDrag([
[x, y],
...offsets.map((dx): [number, number] => [x + dx, y]),
]);
}

export function getDimMappingBtn(axis: 'x' | 'y', dim: number): Locator {
return page
.getByLabelText(`Dimension as ${axis} axis`)
Expand Down
22 changes: 21 additions & 1 deletion packages/app/vitest.browser.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { playwright } from '@vitest/browser-playwright';
import { defineBrowserCommand, playwright } from '@vitest/browser-playwright';
import { defineConfig, mergeConfig } from 'vitest/config';

import viteConfig from './vite.config';
Expand All @@ -7,6 +7,25 @@ const browser = process.env.CI
? 'chromium' // Firefox doesn't work in CI: https://github.com/microsoft/playwright/issues/11566
: process.env.VITE_TEST_BROWSER;

// Press mouse button at first point of path, move through other points, then release
// (coordinates are relative to the viewport of the test iframe)
const mouseDrag = defineBrowserCommand(async (ctx, path) => {
const iframe = ctx.iframe.owner();
const box = await iframe.boundingBox();
const scale = box.width / (await iframe.evaluate((elem) => elem.clientWidth));
const [start, ...rest] = path.map(([x, y]) => [
box.x + x * scale,
box.y + y * scale,
]);

await ctx.page.mouse.move(...start);
await ctx.page.mouse.down();
for (const point of rest) {
await ctx.page.mouse.move(...point, { steps: 5 }); // eslint-disable-line no-await-in-loop -- moves must be sequential
}
await ctx.page.mouse.up();
});

export default mergeConfig(
viteConfig,
defineConfig({
Expand All @@ -24,6 +43,7 @@ export default mergeConfig(
instances: [{ browser }],
viewport: { width: 1920, height: 1080 },
ui: false,
commands: { mouseDrag },
},
},
}),
Expand Down
4 changes: 2 additions & 2 deletions packages/lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
},
"dependencies": {
"@floating-ui/react": "0.27.19",
"@radix-ui/react-slider": "1.5.0",
"@radix-ui/react-use-size": "1.1.5",
"@react-hookz/web": "25.1.1",
"@tanstack/react-query": "5.102.8",
"@types/d3-array": "~3.2.2",
Expand All @@ -65,7 +67,6 @@
"@types/d3-scale": "~4.0.9",
"@types/d3-scale-chromatic": "~3.1.0",
"@types/ndarray": "1.1.0",
"@types/react-slider": "~1.3.6",
"@visx/axis": "4.0.0",
"@visx/drag": "4.0.0",
"@visx/grid": "4.0.0",
Expand All @@ -82,7 +83,6 @@
"react-icons": "5.4.0",
"react-is": "^18.3.1",
"react-keyed-flatten-children": "5.1.3",
"react-slider": "2.0.6",
"react-window": "2.2.7",
"zustand": "5.0.14"
},
Expand Down
20 changes: 16 additions & 4 deletions packages/lib/src/dimension-mapper/SlicingSlider.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,26 @@
}

.slider {
flex: 1 1 3rem;
position: relative;
flex: 1 1 calc(3rem - var(--thumb-size));
display: flex;
flex-direction: column;
align-items: center;
min-height: 5rem;
min-height: calc(5rem - var(--thumb-size));
margin: calc(var(--thumb-size) / 2) 0; /* make track end in middle of thumb */
cursor: pointer;
touch-action: pan-x; /* let pointer interactions along slider through, but allow scrolling horizontally */
}

/* Extend interactive area to the thumb's overflow */
.slider::before {
content: '';
position: absolute;
inset: calc(var(--thumb-size) / -2) 0;
}

.track {
flex: 1;
margin: calc(var(--thumb-size) / 2) 0;
border-left: var(--track-width) solid var(--track-color);
border-radius: var(--track-width);
}
Expand All @@ -50,13 +59,16 @@
height: var(--thumb-size);
min-width: var(--thumb-size);
cursor: grab;
touch-action: none;
}

.thumbValue {
font-size: 0.625em;
pointer-events: none; /* so Radix knows when the thumb itself is pressed */
}

.mark {
position: absolute;
width: 0.5rem;
height: 0.5rem;
background-color: var(
Expand All @@ -66,7 +78,7 @@
border: 2px solid
var(--h5w-slicingSlider-mark--borderColor, var(--track-color));
border-radius: 50%;
transform: translateY(-100%);
transform: translateY(50%);
}

.slicingControls {
Expand Down
68 changes: 39 additions & 29 deletions packages/lib/src/dimension-mapper/SlicingSlider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import {
Root as SliderRoot,
Thumb as SliderThumb,
} from '@radix-ui/react-slider';
import { useSize } from '@radix-ui/react-use-size';
import { useMeasure } from '@react-hookz/web';
import { useId, useState } from 'react';
import { FiChevronDown, FiChevronUp } from 'react-icons/fi';
import ReactSlider from 'react-slider';

import Btn from '../toolbar/controls/Btn';
import { useDynamicDebouncedCallback } from './hooks';
Expand Down Expand Up @@ -46,6 +50,12 @@ function SlicingSlider(props: Props) {
);

const [containerSize, containerRef] = useMeasure<HTMLDivElement>();

// Once Radix has measured the thumb, it offsets it to keep it within the bounds of the slider;
// measure it the same way to cancel this offset, so the thumb ends up centred on its value
const [thumb, setThumb] = useState<HTMLSpanElement | null>(null);
const thumbSize = useSize(thumb);

const sliderLabelId = `${ID}-${dimension}-label`;

return (
Expand All @@ -56,41 +66,41 @@ function SlicingSlider(props: Props) {
<span className={styles.sublabel}>0:{length - 1}</span>
{length > 1 && (
<>
<ReactSlider
<SliderRoot
className={styles.slider}
ariaLabelledby={sliderLabelId}
orientation="vertical"
min={0}
max={length - 1}
step={1}
marks={
containerSize &&
containerSize.height / length >= MIN_HEIGHT_PER_MARK
}
markClassName={styles.mark}
orientation="vertical"
invert
value={value}
onChange={(newValue) => {
value={[value]}
onValueChange={([newValue]) => {
setValue(newValue);
onDebouncedChange(newValue);
}}
/* When slicing in E2E tests, `onChange` is called with the old value.
The `onChange` event is fired via a `setState` callback in `ReactSlider`:
https://github.com/zillow/react-slider/blob/master/src/components/ReactSlider/ReactSlider.jsx#L908
Adding `onAfterChange` fixes the issue for now with react-slider@2.0.4, but not with react-slider@2.0.5+ */
onAfterChange={(newValue) => {
setValue(newValue);
onDebouncedChange(newValue);
}}
renderThumb={({ key, ...thumbProps }, state) => (
<div key={key} {...thumbProps} className={styles.thumb}>
<span className={styles.thumbValue}>{state.valueNow}</span>
</div>
)}
renderTrack={({ key }, { index }) =>
index === 0 ? <div key={key} className={styles.track} /> : null
}
/>
>
<span className={styles.track} />
{containerSize &&
containerSize.height / length >= MIN_HEIGHT_PER_MARK &&
Array.from({ length }, (_, index) => (
<span
key={index}
className={styles.mark}
style={{ bottom: `${(index / (length - 1)) * 100}%` }}
/>
))}
<SliderThumb
ref={setThumb}
className={styles.thumb}
aria-labelledby={sliderLabelId}
style={
thumbSize?.height
? { translate: `0 ${(0.5 - value / (length - 1)) * 100}%` }
: {}
}
>
<span className={styles.thumbValue}>{value}</span>
</SliderThumb>
</SliderRoot>

{withControls && (
<div className={styles.slicingControls}>
Expand Down
Loading
Loading