diff --git a/apps/storybook/src/DomainSlider.stories.tsx b/apps/storybook/src/DomainSlider.stories.tsx index d634b0920..7eeb38133 100644 --- a/apps/storybook/src/DomainSlider.stories.tsx +++ b/apps/storybook/src/DomainSlider.stories.tsx @@ -70,10 +70,10 @@ export const Default = { isAutoMax={isAutoMax} disabled={disabled} onChange={setSliderDomain} - onAfterChange={(hasMinChanged, hasMaxChanged) => { + onAfterChange={(hasMinChanged, hasMaxChanged, newValue) => { setCustomDomain([ - hasMinChanged ? sliderDomain[0] : customDomain[0], - hasMaxChanged ? sliderDomain[1] : customDomain[1], + hasMinChanged ? newValue[0] : customDomain[0], + hasMaxChanged ? newValue[1] : customDomain[1], ]); }} /> diff --git a/cypress/e2e/app.cy.ts b/cypress/e2e/app.cy.ts index d63bf42d9..6bcec3116 100644 --- a/cypress/e2e/app.cy.ts +++ b/cypress/e2e/app.cy.ts @@ -202,8 +202,8 @@ describe('/mock', () => { cy.findByRole('slider', { name: 'D1' }) .as('d1Slider') .should('have.attr', 'aria-valuenow', 0) - .and('have.attr', 'aria-valuemin', 0) - .and('have.attr', 'aria-valuemax', 8); + .and('have.attr', 'min', 0) + .and('have.attr', 'max', 8); // Move slider up by three marks and check value cy.get('@d1Slider').focus(); diff --git a/packages/app/src/__tests__/DimensionMapper.browser.test.ts b/packages/app/src/__tests__/DimensionMapper.browser.test.ts index 1c5e5f940..acef9191e 100644 --- a/packages/app/src/__tests__/DimensionMapper.browser.test.ts +++ b/packages/app/src/__tests__/DimensionMapper.browser.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import { page } from 'vitest/browser'; +import { commands, page } from 'vitest/browser'; import { getDimMappingBtn, renderApp } from '../test-utils'; import { Vis } from '../vis-packs/core/visualizations'; @@ -25,7 +25,7 @@ test('control mapping for X axis when visualizing 2D dataset as Line', async () // Ensure that only one dimension slider for D0 is visible const d0Slider = page.getByRole('slider', { name: 'D0' }); expect(d0Slider).toBeVisible(); - expect(d0Slider).toHaveValue(0); + expect(d0Slider).toHaveValue('0'); expect(page.getByRole('slider', { name: 'D1' })).not.toBeInTheDocument(); // Change mapping from [0, 'x'] to ['x', 0] @@ -36,7 +36,7 @@ test('control mapping for X axis when visualizing 2D dataset as Line', async () // Ensure that the dimension slider is now for D1 const d1Slider = page.getByRole('slider', { name: 'D1' }); expect(d1Slider).toBeVisible(); - expect(d1Slider).toHaveValue(0); + expect(d1Slider).toHaveValue('0'); expect(page.getByRole('slider', { name: 'D0' })).not.toBeInTheDocument(); }); @@ -91,7 +91,7 @@ test('display one slider and two mappers when visualizing 3D dataset as Matrix', expect(yDimsButtons.nth(1)).toBeChecked(); // Ensure that only one dimension slider for D0 is visible - expect(page.getByRole('slider', { name: 'D0' })).toHaveValue(0); + expect(page.getByRole('slider', { name: 'D0' })).toHaveValue('0'); expect(page.getByRole('slider', { name: 'D1' })).not.toBeInTheDocument(); }); @@ -105,7 +105,29 @@ test('slice through 2D dataset', async () => { const d0Slider = page.getByRole('slider', { name: 'D0' }); await user.type(d0Slider, '{ArrowUp}'); - expect(d0Slider).toHaveValue(1); + expect(d0Slider).toHaveValue('1'); +}); + +test('slice through 2D dataset by dragging slider thumb', async () => { + await renderApp({ initialPath: '/arrays/twoD', preferredVis: Vis.Line }); + + const d0Slider = page.getByRole('slider', { name: 'D0' }); + const { left, top, width } = d0Slider.element().getBoundingClientRect(); + const [x, y] = [left + width / 2, top + 2]; // grab thumb above its centre + + // Thumb doesn't jump when grabbed off-centre + await commands.mouseDrag([ + [x, y], + [x, y - 1], + ]); + await expect.element(d0Slider).toHaveValue('0'); + + // Drag thumb to top of slider + await commands.mouseDrag([ + [x, y], + [x, y - 1000], + ]); + await expect.element(d0Slider).toHaveValue('19'); }); test('slice through 2D opaque dataset', async () => { @@ -117,7 +139,7 @@ test('slice through 2D opaque dataset', async () => { const d0Slider = page.getByRole('slider', { name: 'D0' }); await user.type(d0Slider, '{ArrowUp}'); - expect(d0Slider).toHaveValue(1); + expect(d0Slider).toHaveValue('1'); await expect.element(page.getByText('Uint8Array [ 4,5 ]')).toBeVisible(); }); diff --git a/packages/app/src/__tests__/DomainWidget.browser.test.ts b/packages/app/src/__tests__/DomainWidget.browser.test.ts index 025023eca..d9387ccb9 100644 --- a/packages/app/src/__tests__/DomainWidget.browser.test.ts +++ b/packages/app/src/__tests__/DomainWidget.browser.test.ts @@ -1,15 +1,15 @@ 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'); const thumbs = page.getByRole('slider'); expect(thumbs).toHaveLength(2); - expect(thumbs.nth(0)).toHaveValue(20); - expect(thumbs.nth(1)).toHaveValue(81); + expect(thumbs.nth(0)).toHaveValue('20'); + expect(thumbs.nth(1)).toHaveValue('81'); const editBtn = page.getByRole('button', { name: 'Edit domain' }); const popup = page.getByRole('dialog', { @@ -59,9 +59,9 @@ test('show min/max and data range in popup', async () => { test('move thumbs with keyboard to update domain', async () => { const { user } = await renderApp('/nexus/NXdata'); - // Hover min thumb to reveal tooltip + // Hover edit button to reveal tooltip const minThumb = page.getByRole('slider', { name: /min/ }); - await minThumb.hover(); + await page.getByRole('button', { name: 'Edit domain' }).hover(); const minInput = page.getByLabelText('min', { exact: true }); const maxInput = page.getByLabelText('max', { exact: true }); @@ -73,25 +73,64 @@ test('move thumbs with keyboard to update domain', async () => { // Move min thumb five steps to the left (in a single press) await user.type(minThumb, '{ArrowLeft>5/}'); // press key and hold for 5 keydown events, then release - await expect.element(minInput).toHaveValue('−2.30818e+2'); - expect(page.getByLabelText('Min: −2.308e+2')).toBeVisible(); + await expect.element(minInput).toHaveValue('−1.72037e+2'); // slider extent updated after each step + expect(page.getByLabelText('Min: −1.72e+2')).toBeVisible(); - expect(minThumb).toHaveValue(20); // still at original position + expect(minThumb).toHaveValue('20'); // still at original position expect(maxInput).toHaveValue('4e+2'); // unaffected // Move max thumb ten steps to the left const maxThumb = page.getByRole('slider', { name: /max/ }); await user.type(maxThumb, '{PageDown}'); - expect(maxInput).toHaveValue('5.72182e+1'); - expect(page.getByLabelText('Max: 5.722e+1')).toBeVisible(); + expect(maxInput).toHaveValue('6.01623e+1'); + expect(page.getByLabelText('Max: 6.016e+1')).toBeVisible(); // Move max thumb ten steps to the right await user.type(maxThumb, '{PageUp}'); - expect(maxInput).toHaveValue('3.68841e+2'); // not back to 4e+2 because 4e+2 is not exactly on a slider division - expect(page.getByLabelText('Max: 3.688e+2')).toBeInTheDocument(); + expect(maxInput).toHaveValue('3.69607e+2'); // not back to 4e+2 because 4e+2 is not exactly on a slider division + expect(page.getByLabelText('Max: 3.696e+2')).toBeInTheDocument(); + + expect(maxThumb).toHaveValue('81'); // still at original position + expect(minInput).toHaveValue('−1.72037e+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 }); - expect(maxThumb).toHaveValue(81); // still at original position - expect(minInput).toHaveValue('−2.30818e+2'); // unaffected + // 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('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 () => { @@ -149,23 +188,23 @@ test('clamp domain in symlog scale', async () => { await minInput.fill('-1e+1000'); await page.getByRole('button', { name: 'Apply min' }).click(); expect(minInput).toHaveValue('−8.98847e+307'); - expect(minThumb).toHaveValue(1); + expect(minThumb).toHaveValue('1'); await maxInput.fill('1e+1000'); await page.getByRole('button', { name: 'Apply max' }).click(); expect(maxInput).toHaveValue('8.98847e+307'); - expect(maxThumb).toHaveValue(100); + expect(maxThumb).toHaveValue('100'); await user.type(maxThumb, '{ArrowLeft}'); expect(maxInput).toHaveValue('5.40006e+301'); - expect(maxThumb).toHaveValue(99); // does not jump back to 81 + expect(maxThumb).toHaveValue('99'); // does not jump back to 81 }); test('control min/max autoscale behaviour', async () => { const { user } = await renderApp('/nexus/NXdata'); const minThumb = page.getByRole('slider', { name: /min/ }); - await minThumb.hover(); + await page.getByRole('button', { name: 'Edit domain' }).hover(); const minBtn = page.getByRole('button', { name: 'Min', exact: true }); const maxBtn = page.getByRole('button', { name: 'Max', exact: true }); @@ -205,23 +244,15 @@ test('handle empty domain', async () => { // Give min the same value as max await minInput.fill('400'); await page.getByRole('button', { name: 'Apply min' }).click(); - expect(minThumb).toHaveValue(81); - expect(maxThumb).toHaveValue(81); + expect(minThumb).toHaveValue('81'); + expect(maxThumb).toHaveValue('81'); expect(page.getByLabelText('Min: −∞')).toBeVisible(); expect(page.getByLabelText('Max: +∞')).toBeVisible(); - // Check that pearling works (i.e. that one thumb can push the other) - await user.type(maxThumb, '{ArrowLeft}'); - expect(minInput).toHaveValue('3.12772e+2'); - expect(maxInput).toHaveValue('3.12772e+2'); - expect(minThumb).toHaveValue(80); - expect(maxThumb).toHaveValue(80); - - // Ensure thumbs can be separated again + // Ensure thumbs can be separated again (with keyboard, thumbs don't push each other) await user.type(maxThumb, '{ArrowRight}'); - expect(maxInput).toHaveValue('3.71154e+2'); - expect(minThumb).toHaveValue(80); - expect(maxThumb).toHaveValue(81); + expect(minInput).toHaveValue('4e+2'); + expect(maxInput).toHaveValue('4.40398e+2'); }); test('handle min > max', async () => { diff --git a/packages/app/src/__tests__/NexusPack.browser.test.ts b/packages/app/src/__tests__/NexusPack.browser.test.ts index 2a16d6e9d..4a2d4c4c1 100644 --- a/packages/app/src/__tests__/NexusPack.browser.test.ts +++ b/packages/app/src/__tests__/NexusPack.browser.test.ts @@ -148,14 +148,14 @@ test('follow default slice on NXdata group', async () => { const { selectVisTab } = await renderApp('/nexus/default_slice'); expect(getSelectedVisTab()).toBe(NxDataVis.NxHeatmap); - expect(page.getByRole('slider', { name: 'D0' })).toHaveValue(1); - expect(page.getByRole('slider', { name: 'D2' })).toHaveValue(2); + expect(page.getByRole('slider', { name: 'D0' })).toHaveValue('1'); + expect(page.getByRole('slider', { name: 'D2' })).toHaveValue('2'); // Ignore `default_slice` meant for heatmap await selectVisTab(NxDataVis.NxLine); - expect(page.getByRole('slider', { name: 'D0' })).toHaveValue(0); - expect(page.getByRole('slider', { name: 'D1' })).toHaveValue(0); - expect(page.getByRole('slider', { name: 'D2' })).toHaveValue(0); + expect(page.getByRole('slider', { name: 'D0' })).toHaveValue('0'); + expect(page.getByRole('slider', { name: 'D1' })).toHaveValue('0'); + expect(page.getByRole('slider', { name: 'D2' })).toHaveValue('0'); }); test('follow SILX styles on NXdata group', async () => { @@ -242,16 +242,16 @@ test('ignore malformed `default_slice` attribute', async () => { // Check that `default_slice` is not applied expect(getSelectedVisTab()).toBe(NxDataVis.NxHeatmap); expect(page.getByRole('figure', { name: 'fourD' })).toBeVisible(); - expect(page.getByRole('slider', { name: 'D0' })).toHaveValue(0); - expect(page.getByRole('slider', { name: 'D1' })).toHaveValue(0); + expect(page.getByRole('slider', { name: 'D0' })).toHaveValue('0'); + expect(page.getByRole('slider', { name: 'D1' })).toHaveValue('0'); expect(warningSpy).toHaveBeenCalledWith( "Malformed 'default_slice' attribute: expected same length as signal dimensions", ); // Slicing index out of bound await selectNexusExplorerNode('default_slice_out_of_bounds'); - expect(page.getByRole('slider', { name: 'D0' })).toHaveValue(0); - expect(page.getByRole('slider', { name: 'D1' })).toHaveValue(0); + expect(page.getByRole('slider', { name: 'D0' })).toHaveValue('0'); + expect(page.getByRole('slider', { name: 'D1' })).toHaveValue('0'); expect(warningSpy).toHaveBeenCalledWith( "Malformed 'default_slice' attribute: expected indices within bounds of signal dimensions", ); diff --git a/packages/app/src/test-utils.tsx b/packages/app/src/test-utils.tsx index e304c746c..474ef9948 100644 --- a/packages/app/src/test-utils.tsx +++ b/packages/app/src/test-utils.tsx @@ -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'; @@ -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; + } +} + interface RenderAppResult extends RenderResult { user: ReturnType; selectExplorerNode: (name: string) => Promise; @@ -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 { + 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`) diff --git a/packages/app/vitest.browser.config.js b/packages/app/vitest.browser.config.js index 8fa7207b7..6b0b28075 100644 --- a/packages/app/vitest.browser.config.js +++ b/packages/app/vitest.browser.config.js @@ -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'; @@ -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({ @@ -24,6 +43,7 @@ export default mergeConfig( instances: [{ browser }], viewport: { width: 1920, height: 1080 }, ui: false, + commands: { mouseDrag }, }, }, }), diff --git a/packages/lib/package.json b/packages/lib/package.json index 3c870b2f8..5bfe90302 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -55,6 +55,7 @@ } }, "dependencies": { + "@base-ui/react": "1.9.0", "@floating-ui/react": "0.27.19", "@react-hookz/web": "25.1.1", "@tanstack/react-query": "5.102.8", @@ -65,7 +66,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", @@ -82,7 +82,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" }, diff --git a/packages/lib/src/dimension-mapper/SlicingSlider.module.css b/packages/lib/src/dimension-mapper/SlicingSlider.module.css index cb110299d..8e64050a0 100644 --- a/packages/lib/src/dimension-mapper/SlicingSlider.module.css +++ b/packages/lib/src/dimension-mapper/SlicingSlider.module.css @@ -31,14 +31,23 @@ cursor: pointer; } -.track { +.control { flex: 1; - margin: calc(var(--thumb-size) / 2) 0; - border-left: var(--track-width) solid var(--track-color); + display: flex; + padding: calc(var(--thumb-size) / 2) 0; /* make track end in middle of thumb */ + touch-action: pan-x; +} + +.track { + display: flex; + justify-content: center; + width: var(--track-width); + background-color: var(--track-color); border-radius: var(--track-width); } .thumb { + composes: sliderThumb from '../toolbar/utils.module.css'; display: flex; align-items: center; justify-content: center; @@ -57,6 +66,7 @@ } .mark { + position: absolute; width: 0.5rem; height: 0.5rem; background-color: var( @@ -66,7 +76,7 @@ border: 2px solid var(--h5w-slicingSlider-mark--borderColor, var(--track-color)); border-radius: 50%; - transform: translateY(-100%); + transform: translateY(50%); } .slicingControls { diff --git a/packages/lib/src/dimension-mapper/SlicingSlider.tsx b/packages/lib/src/dimension-mapper/SlicingSlider.tsx index 128722907..170469d8e 100644 --- a/packages/lib/src/dimension-mapper/SlicingSlider.tsx +++ b/packages/lib/src/dimension-mapper/SlicingSlider.tsx @@ -1,7 +1,7 @@ +import { Slider } from '@base-ui/react/slider'; 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'; @@ -56,41 +56,38 @@ function SlicingSlider(props: Props) { 0:{length - 1} {length > 1 && ( <> - = MIN_HEIGHT_PER_MARK - } - markClassName={styles.mark} orientation="vertical" - invert value={value} - onChange={(newValue) => { + 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) => ( -
- {state.valueNow} -
- )} - renderTrack={({ key }, { index }) => - index === 0 ?
: null - } - /> + > + + + {containerSize && + containerSize.height / length >= MIN_HEIGHT_PER_MARK && + Array.from({ length }, (_, index) => ( + + ))} + + {value} + + + + {withControls && (
diff --git a/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.module.css b/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.module.css index e86f99fef..25c57f3e2 100644 --- a/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.module.css +++ b/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.module.css @@ -9,3 +9,13 @@ --thumb-size: calc(var(--track-height) * 2.5); --thumb-size-auto: calc(var(--thumb-size) * 0.9); } + +.control { + display: flex; + padding: 0 calc(var(--thumb-size) / 2); /* make track end in middle of thumbs */ + touch-action: pan-y; +} + +.track { + display: flex; +} diff --git a/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.tsx b/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.tsx index 8445a7566..f2372aaa4 100644 --- a/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.tsx +++ b/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.tsx @@ -1,7 +1,7 @@ +import { Slider } from '@base-ui/react/slider'; import { type ColorScaleType, type Domain } from '@h5web/shared/vis-models'; -import { useState } from 'react'; +import { useRef } from 'react'; import { FiSkipBack, FiSkipForward } from 'react-icons/fi'; -import ReactSlider from 'react-slider'; import { getSafeDomain } from '../../../vis/heatmap/utils'; import { useCombinedDomain } from '../../../vis/hooks'; @@ -24,7 +24,11 @@ interface Props { isAutoMax: boolean; disabled?: boolean; onChange: (newValue: Domain) => void; - onAfterChange: (hasMinChanged: boolean, hasMaxChanged: boolean) => void; + onAfterChange: ( + hasMinChanged: boolean, + hasMaxChanged: boolean, + newValue: Domain, + ) => void; } function DomainSlider(props: Props) { @@ -52,53 +56,62 @@ function DomainSlider(props: Props) { const [safeValue] = getSafeDomain(value, dataDomain, scaleType); const scaledValue = safeValue.map(scale).map(Math.round) as Domain; - const [beforeChangeValue, setBeforeChangeValue] = useState(scaledValue); + const beforeChangeRef = useRef([value, scaledValue]); // reset when interaction starts + function startChange() { + beforeChangeRef.current = [value, scaledValue]; + } - function handleChange(newScaledValue: Domain, done = false) { + function handleChange(newScaledValue: readonly number[], done = false) { + const [beforeValue, beforeChangeValue] = beforeChangeRef.current; const [newScaledMin, newScaledMax] = newScaledValue; const hasMinChanged = newScaledMin !== beforeChangeValue[0]; const hasMaxChanged = newScaledMax !== beforeChangeValue[1]; const newValue: Domain = [ - hasMinChanged ? scale.invert(newScaledMin) : value[0], - hasMaxChanged ? scale.invert(newScaledMax) : value[1], + hasMinChanged ? scale.invert(newScaledMin) : beforeValue[0], + hasMaxChanged ? scale.invert(newScaledMax) : beforeValue[1], ]; if (done) { - onDone(hasMinChanged, hasMaxChanged); + // Base UI may commit in same event as last change, so pass new value + onDone(hasMinChanged, hasMaxChanged, newValue); } else { onChange(newValue); } } return ( - handleChange(bounds)} - onAfterChange={(bounds) => handleChange(bounds, true)} - renderThumb={({ key, ...thumbProps }, { index }) => ( - - )} - renderTrack={({ key }, { index }) => - index === 0 ? ( - - ) : null - } - /> + onPointerDownCapture={startChange} + onValueChange={(bounds, { reason }) => { + if (reason !== 'drag' && reason !== 'track-press') { + startChange(); // keyboard and assistive-technology changes are committed immediately + } + handleChange(bounds); + }} + onValueCommitted={(bounds) => handleChange(bounds, true)} + > + + + + {[0, 1].map((index) => ( + + ))} + + + ); } diff --git a/packages/lib/src/toolbar/controls/DomainWidget/DomainWidget.tsx b/packages/lib/src/toolbar/controls/DomainWidget/DomainWidget.tsx index 55e8a4f9b..3a032994f 100644 --- a/packages/lib/src/toolbar/controls/DomainWidget/DomainWidget.tsx +++ b/packages/lib/src/toolbar/controls/DomainWidget/DomainWidget.tsx @@ -85,10 +85,10 @@ function DomainWidget(props: Props) { setSliderDomain(newValue); toggleEditing(false); }} - onAfterChange={(hasMinChanged, hasMaxChanged) => { + onAfterChange={(hasMinChanged, hasMaxChanged, newValue) => { onCustomDomainChange([ - hasMinChanged ? sliderDomain[0] : customDomain[0], - hasMaxChanged ? sliderDomain[1] : customDomain[1], + hasMinChanged ? newValue[0] : customDomain[0], + hasMaxChanged ? newValue[1] : customDomain[1], ]); }} /> diff --git a/packages/lib/src/toolbar/controls/DomainWidget/Thumb.module.css b/packages/lib/src/toolbar/controls/DomainWidget/Thumb.module.css index e450f6e74..f7cfa2b4b 100644 --- a/packages/lib/src/toolbar/controls/DomainWidget/Thumb.module.css +++ b/packages/lib/src/toolbar/controls/DomainWidget/Thumb.module.css @@ -1,6 +1,5 @@ .thumb { - top: 0; - bottom: 0; + composes: sliderThumb from '../../utils.module.css'; display: flex; justify-content: center; align-items: center; diff --git a/packages/lib/src/toolbar/controls/DomainWidget/Thumb.tsx b/packages/lib/src/toolbar/controls/DomainWidget/Thumb.tsx index d362cc7bd..51e3b463d 100644 --- a/packages/lib/src/toolbar/controls/DomainWidget/Thumb.tsx +++ b/packages/lib/src/toolbar/controls/DomainWidget/Thumb.tsx @@ -1,29 +1,26 @@ -import { forwardRef, type HTMLProps } from 'react'; +import { Slider } from '@base-ui/react/slider'; import { FiAlertCircle } from 'react-icons/fi'; import { type IconType } from 'react-icons/lib'; import { type Bound } from '../../../vis/models'; import styles from './Thumb.module.css'; -interface Props extends HTMLProps { +interface Props { + index: number; bound: Bound; isAuto: boolean; hasError: boolean; - disabled: boolean; AutoIcon: IconType; } -const Thumb = forwardRef((props, ref) => { - const { bound, isAuto, hasError, disabled, AutoIcon, ...thumbProps } = props; +function Thumb(props: Props) { + const { index, bound, isAuto, hasError, AutoIcon } = props; return ( -
@@ -33,9 +30,8 @@ const Thumb = forwardRef((props, ref) => { )}
-
+ ); -}); +} -Thumb.displayName = 'Thumb'; export default Thumb; diff --git a/packages/lib/src/toolbar/controls/DomainWidget/Track.module.css b/packages/lib/src/toolbar/controls/DomainWidget/Track.module.css index dbcd3c40e..99a313e7f 100644 --- a/packages/lib/src/toolbar/controls/DomainWidget/Track.module.css +++ b/packages/lib/src/toolbar/controls/DomainWidget/Track.module.css @@ -4,7 +4,6 @@ align-self: center; width: var(--track-width); height: var(--track-height); - margin: 0 calc(var(--thumb-size) / 2); /* make track end in middle of thumbs */ background-color: var(--h5w-domainSlider-track--bgColor, lightgray); box-shadow: 1px 1px 2px inset var(--h5w-domainSlider-track--shadowColor, gray); border-radius: calc(var(--track-height) / 2); diff --git a/packages/lib/src/toolbar/utils.module.css b/packages/lib/src/toolbar/utils.module.css index c26f4e255..85b93befb 100644 --- a/packages/lib/src/toolbar/utils.module.css +++ b/packages/lib/src/toolbar/utils.module.css @@ -135,3 +135,8 @@ a.btn { rgba(0, 0, 0, 0.1) 0px 0px 0px 1px, rgba(0, 0, 0, 0.1) 0px 4px 11px; } + +/* Base UI slider thumbs: focus is on visually-hidden input */ +.sliderThumb:has(:focus-visible) { + outline: auto; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44adbe004..6baca8ea2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -506,7 +506,7 @@ importers: version: 0.185.1 zustand: specifier: 5.0.14 - version: 5.0.14(@types/react@18.3.31)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + version: 5.0.14(@types/react@18.3.31)(react@18.3.1)(use-sync-external-store@1.7.0(react@18.3.1)) devDependencies: '@esrf/eslint-config': specifier: 3.1.0 @@ -641,6 +641,9 @@ importers: packages/lib: dependencies: + '@base-ui/react': + specifier: 1.9.0 + version: 1.9.0(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@floating-ui/react': specifier: 0.27.19 version: 0.27.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -671,9 +674,6 @@ importers: '@types/ndarray': specifier: 1.1.0 version: 1.1.0 - '@types/react-slider': - specifier: ~1.3.6 - version: 1.3.6 '@visx/axis': specifier: 4.0.0 version: 4.0.0(@types/react@18.3.31)(react@18.3.1) @@ -722,15 +722,12 @@ importers: react-keyed-flatten-children: specifier: 5.1.3 version: 5.1.3(react-is@18.3.1)(react@18.3.1) - react-slider: - specifier: 2.0.6 - version: 2.0.6(react@18.3.1) react-window: specifier: 2.2.7 version: 2.2.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) zustand: specifier: 5.0.14 - version: 5.0.14(@types/react@18.3.31)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + version: 5.0.14(@types/react@18.3.31)(react@18.3.1)(use-sync-external-store@1.7.0(react@18.3.1)) devDependencies: '@esrf/eslint-config': specifier: 3.1.0 @@ -842,7 +839,7 @@ importers: version: 4.1.9(@types/node@24.13.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(esbuild@0.28.1) zustand: specifier: 5.0.14 - version: 5.0.14(@types/react@18.3.31)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + version: 5.0.14(@types/react@18.3.31)(react@18.3.1)(use-sync-external-store@1.7.0(react@18.3.1)) packages: @@ -949,6 +946,33 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@base-ui/react@1.9.0': + resolution: {integrity: sha512-S8CSYosASBKtpL8SWUfS78rFElfLijiCgA7oHOK22jCJ95FULsYyKwBNFOgudt0RA+/7yNc7F81bOvQnRgbBiQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@date-fns/tz': + optional: true + '@types/react': + optional: true + date-fns: + optional: true + + '@base-ui/utils@0.5.0': + resolution: {integrity: sha512-dYGGcEttjkYiMoa5nHm8vLaJ2UcuTWxo3BFAwOYXeRg/q7UVVvA6cacEIXEs3FgBfK1CGtW1ptElqs7wsx+t9A==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -1203,15 +1227,27 @@ packages: '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + '@floating-ui/dom@1.7.6': resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + '@floating-ui/react-dom@2.1.8': resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@floating-ui/react@0.27.19': resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} peerDependencies: @@ -1221,6 +1257,9 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@hapi/address@5.1.1': resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} engines: {node: '>=14.0.0'} @@ -2111,9 +2150,6 @@ packages: peerDependencies: '@types/react': '*' - '@types/react-slider@1.3.6': - resolution: {integrity: sha512-RS8XN5O159YQ6tu3tGZIQz1/9StMLTg/FCIPxwqh2gwVixJnlfIodtVx+fpXVMZHe7A58lAX1Q4XTgAGOQaCQg==} - '@types/react@18.3.31': resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} @@ -4585,11 +4621,6 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - react-slider@2.0.6: - resolution: {integrity: sha512-gJxG1HwmuMTJ+oWIRCmVWvgwotNCbByTwRkFZC6U4MBsHqJBmxwbYRJUmxy4Tke1ef8r9jfXjgkmY/uHOCEvbA==} - peerDependencies: - react: ^16 || ^17 || ^18 - react-use-measure@2.1.7: resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} peerDependencies: @@ -4667,6 +4698,9 @@ packages: request-progress@3.0.0: resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==} + reselect@5.3.0: + resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5159,6 +5193,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -5587,6 +5626,29 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@base-ui/react@1.9.0(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@base-ui/utils': 0.5.0(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/react-dom': 2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/utils': 0.2.12 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + use-sync-external-store: 1.7.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + + '@base-ui/utils@0.5.0(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@floating-ui/utils': 0.2.12 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + reselect: 5.3.0 + use-sync-external-store: 1.7.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@bcoe/v8-coverage@1.0.2': {} '@blazediff/core@1.9.1': {} @@ -5827,17 +5889,32 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.11 + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + '@floating-ui/dom@1.7.6': dependencies: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + '@floating-ui/react-dom@2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/dom': 1.7.6 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@floating-ui/react-dom@2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@floating-ui/react@0.27.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -5848,6 +5925,8 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@floating-ui/utils@0.2.12': {} + '@hapi/address@5.1.1': dependencies: '@hapi/hoek': 11.0.7 @@ -6541,10 +6620,6 @@ snapshots: dependencies: '@types/react': 18.3.31 - '@types/react-slider@1.3.6': - dependencies: - '@types/react': 18.3.31 - '@types/react@18.3.31': dependencies: '@types/prop-types': 15.7.15 @@ -9382,11 +9457,6 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-slider@2.0.6(react@18.3.1): - dependencies: - prop-types: 15.8.1 - react: 18.3.1 - react-use-measure@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -9494,6 +9564,8 @@ snapshots: dependencies: throttleit: 1.0.1 + reselect@5.3.0: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -10107,6 +10179,10 @@ snapshots: dependencies: react: 18.3.1 + use-sync-external-store@1.7.0(react@18.3.1): + dependencies: + react: 18.3.1 + util-deprecate@1.0.2: {} verror@1.10.0: @@ -10345,10 +10421,10 @@ snapshots: optionalDependencies: react: 18.3.1 - zustand@5.0.14(@types/react@18.3.31)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): + zustand@5.0.14(@types/react@18.3.31)(react@18.3.1)(use-sync-external-store@1.7.0(react@18.3.1)): optionalDependencies: '@types/react': 18.3.31 react: 18.3.1 - use-sync-external-store: 1.6.0(react@18.3.1) + use-sync-external-store: 1.7.0(react@18.3.1) zwitch@2.0.4: {}