From b8ea05d6fdfa2e59a5241d615f11435cb3046579 Mon Sep 17 00:00:00 2001 From: Nick Thompson Date: Thu, 15 Oct 2026 08:08:19 -0700 Subject: [PATCH 1/3] Replace react-slider with built-in slider markup DomainSlider and SlicingSlider render their own thumbs and track; they share a useSliderDrag hook and a handleSliderKey util. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/DimensionMapper.browser.test.ts | 24 ++++- .../__tests__/DomainWidget.browser.test.ts | 41 +++++++- packages/app/src/test-utils.tsx | 28 +++++- packages/app/vitest.browser.config.js | 22 ++++- packages/lib/package.json | 2 - .../dimension-mapper/SlicingSlider.module.css | 12 ++- .../src/dimension-mapper/SlicingSlider.tsx | 81 +++++++++------- .../DomainWidget/DomainSlider.module.css | 4 +- .../controls/DomainWidget/DomainSlider.tsx | 89 +++++++++++++----- .../controls/DomainWidget/Thumb.module.css | 2 + .../controls/DomainWidget/Track.module.css | 1 - packages/lib/src/toolbar/controls/hooks.ts | 94 ++++++++++++++++++- packages/lib/src/toolbar/controls/utils.ts | 29 ++++++ pnpm-lock.yaml | 23 ----- pnpm-workspace.yaml | 3 - 15 files changed, 358 insertions(+), 97 deletions(-) diff --git a/packages/app/src/__tests__/DimensionMapper.browser.test.ts b/packages/app/src/__tests__/DimensionMapper.browser.test.ts index 1c5e5f940..bb4f28d3f 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'; @@ -108,6 +108,28 @@ test('slice through 2D dataset', async () => { 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 () => { const { user } = await renderApp('/arrays/twoD_opaque'); diff --git a/packages/app/src/__tests__/DomainWidget.browser.test.ts b/packages/app/src/__tests__/DomainWidget.browser.test.ts index 025023eca..2bb0afd7c 100644 --- a/packages/app/src/__tests__/DomainWidget.browser.test.ts +++ b/packages/app/src/__tests__/DomainWidget.browser.test.ts @@ -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'); @@ -94,6 +94,45 @@ 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('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'); 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 5e4c05a80..67e0e9f0e 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 reactFallbackThrottlePlugin from 'vite-plugin-react-fallback-throttle'; import { defineConfig, mergeConfig } from 'vitest/config'; @@ -8,6 +8,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({ @@ -27,6 +46,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 0b4d69072..712639447 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -65,7 +65,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 +81,6 @@ "react-icons": "5.7.0", "react-is": "^19.3.0", "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..1a3cc3b7f 100644 --- a/packages/lib/src/dimension-mapper/SlicingSlider.module.css +++ b/packages/lib/src/dimension-mapper/SlicingSlider.module.css @@ -29,16 +29,21 @@ align-items: center; min-height: 5rem; cursor: pointer; + touch-action: pan-x; } .track { + position: relative; flex: 1; + width: var(--track-width); margin: calc(var(--thumb-size) / 2) 0; - border-left: var(--track-width) solid var(--track-color); + background-color: var(--track-color); border-radius: var(--track-width); } .thumb { + position: absolute; + left: 50%; display: flex; align-items: center; justify-content: center; @@ -49,6 +54,7 @@ border-radius: calc(var(--thumb-size) / 2); height: var(--thumb-size); min-width: var(--thumb-size); + transform: translate(-50%, 50%); cursor: grab; } @@ -57,6 +63,8 @@ } .mark { + position: absolute; + left: 50%; width: 0.5rem; height: 0.5rem; background-color: var( @@ -66,7 +74,7 @@ border: 2px solid var(--h5w-slicingSlider-mark--borderColor, var(--track-color)); border-radius: 50%; - transform: translateY(-100%); + transform: translate(-50%, 50%); } .slicingControls { diff --git a/packages/lib/src/dimension-mapper/SlicingSlider.tsx b/packages/lib/src/dimension-mapper/SlicingSlider.tsx index 589ded075..5e689aeb1 100644 --- a/packages/lib/src/dimension-mapper/SlicingSlider.tsx +++ b/packages/lib/src/dimension-mapper/SlicingSlider.tsx @@ -1,9 +1,11 @@ import { useMeasure } from '@react-hookz/web'; import { useId, useState } from 'react'; import { FiChevronDown, FiChevronUp } from 'react-icons/fi'; -import ReactSlider from 'react-slider'; +import { MathUtils } from 'three'; import Btn from '../toolbar/controls/Btn'; +import { useSliderDrag } from '../toolbar/controls/hooks'; +import { handleSliderKey } from '../toolbar/controls/utils'; import { useDynamicDebouncedCallback } from './hooks'; import styles from './SlicingSlider.module.css'; @@ -54,8 +56,23 @@ function SlicingSlider(props: Props) { setLocalValue(value); } + function handleChange(newValue: number) { + if (newValue !== localValue) { + setLocalValue(newValue); + onDebouncedChange(newValue); + } + } + + const { trackRef, dragProps } = useSliderDrag( + true, + [0, length - 1], + () => 0, + handleChange, + ); + const [containerSize, containerRef] = useMeasure(); const sliderLabelId = `${ID}-${dimension}-label`; + const thumbValue = MathUtils.clamp(localValue, 0, length - 1); return (
@@ -65,41 +82,35 @@ function SlicingSlider(props: Props) { 0:{length - 1} {length > 1 && ( <> - = MIN_HEIGHT_PER_MARK - } - markClassName={styles.mark} - orientation="vertical" - invert - value={localValue} - onChange={(newValue) => { - setLocalValue(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) => { - setLocalValue(newValue); - onDebouncedChange(newValue); - }} - renderThumb={({ key, ...thumbProps }, state) => ( -
- {state.valueNow} +
+
+ {containerSize && + containerSize.height / length >= MIN_HEIGHT_PER_MARK && + Array.from({ length }, (_, index) => ( + + ))} +
+ handleSliderKey(evt, localValue, 0, length - 1, handleChange) + } + > + {localValue}
- )} - renderTrack={({ key }, { index }) => - index === 0 ?
: null - } - /> +
+
{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..8cd85e467 100644 --- a/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.module.css +++ b/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.module.css @@ -1,6 +1,7 @@ .slider { + position: relative; display: flex; - margin-right: -0.25rem; + margin: 0 calc(var(--thumb-size) / 2 - 0.25rem) 0 calc(var(--thumb-size) / 2); /* leave room for thumbs at ends of track */ font-size: 0.75rem; cursor: pointer; @@ -8,4 +9,5 @@ --track-height: var(--h5w-domainSlider-track--height, 0.625rem); --thumb-size: calc(var(--track-height) * 2.5); --thumb-size-auto: calc(var(--thumb-size) * 0.9); + touch-action: pan-y; } diff --git a/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.tsx b/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.tsx index 8445a7566..444fdafab 100644 --- a/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.tsx +++ b/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.tsx @@ -1,18 +1,20 @@ 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'; import { type DomainErrors } from '../../../vis/models'; import { clampBound, createScale, extendDomain } from '../../../vis/utils'; +import { useSliderDrag } from '../hooks'; +import { handleSliderKey } from '../utils'; import styles from './DomainSlider.module.css'; import Thumb from './Thumb'; import Track from './Track'; const SLIDER_RANGE: Domain = [1, 100]; const EXTEND_FACTOR = 0.3; +const SLIDER_SPAN = SLIDER_RANGE[1] - SLIDER_RANGE[0]; interface Props { value: Domain; @@ -52,16 +54,20 @@ 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<[Domain, Domain]>(undefined); // value and scaled value when interaction started function handleChange(newScaledValue: Domain, done = false) { + const [beforeValue, beforeChangeValue] = beforeChangeRef.current ?? [ + value, + scaledValue, + ]; 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) { @@ -71,34 +77,67 @@ function DomainSlider(props: Props) { } } + // Move thumb and let it push the other thumb if needed + function moveThumb(bound: number, index: number) { + const [min, max] = scaledValue; + const newScaledValue: Domain = + index === 0 + ? [bound, Math.max(bound, max)] + : [Math.min(min, bound), bound]; + + if (!disabled && (newScaledValue[0] !== min || newScaledValue[1] !== max)) { + beforeChangeRef.current = beforeChangeRef.current ?? [value, scaledValue]; + handleChange(newScaledValue); + } + } + + function endChange() { + if (beforeChangeRef.current) { + handleChange(scaledValue, true); + beforeChangeRef.current = undefined; + } + } + + const { trackRef, dragProps } = useSliderDrag( + false, + SLIDER_RANGE, + (val, pressedIndex) => { + const closest = val * 2 < scaledValue[0] + scaledValue[1] ? 0 : 1; + return disabled ? undefined : pressedIndex >= 0 ? pressedIndex : closest; + }, + moveThumb, + endChange, + ); + return ( - handleChange(bounds)} - onAfterChange={(bounds) => handleChange(bounds, true)} - renderThumb={({ key, ...thumbProps }, { index }) => ( +
+ + {scaledValue.map((scaledBound, index) => ( + handleSliderKey(evt, scaledBound, ...SLIDER_RANGE, (newBound) => + moveThumb(newBound, index), + ) + } + onKeyUp={endChange} + onBlur={endChange} /> - )} - renderTrack={({ key }, { index }) => - index === 0 ? ( - - ) : null - } - /> + ))} +
); } diff --git a/packages/lib/src/toolbar/controls/DomainWidget/Thumb.module.css b/packages/lib/src/toolbar/controls/DomainWidget/Thumb.module.css index e450f6e74..c1d57a5bc 100644 --- a/packages/lib/src/toolbar/controls/DomainWidget/Thumb.module.css +++ b/packages/lib/src/toolbar/controls/DomainWidget/Thumb.module.css @@ -1,10 +1,12 @@ .thumb { + position: absolute; top: 0; bottom: 0; display: flex; justify-content: center; align-items: center; width: var(--thumb-size); /* fixed size regardless of autoscale */ + transform: translateX(-50%); cursor: ew-resize; } 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/controls/hooks.ts b/packages/lib/src/toolbar/controls/hooks.ts index baf2e765e..ee819ea0a 100644 --- a/packages/lib/src/toolbar/controls/hooks.ts +++ b/packages/lib/src/toolbar/controls/hooks.ts @@ -8,8 +8,20 @@ import { useFloating, type UseFloatingReturn, } from '@floating-ui/react'; +import { assertNonNull } from '@h5web/shared/guards'; +import { type Domain } from '@h5web/shared/vis-models'; import { useClickOutside, useKeyboardEvent, useToggle } from '@react-hookz/web'; -import { type FocusEvent, useCallback, useMemo } from 'react'; +import { + type DOMAttributes, + type FocusEvent, + type PointerEvent, + type RefObject, + useCallback, + useMemo, + useRef, +} from 'react'; +import { flushSync } from 'react-dom'; +import { MathUtils } from 'three'; export const POPOVER_CLEARANCE = 6; @@ -77,3 +89,83 @@ export function useFloatingDismiss(context: FloatingContext): ElementProps { [referenceProps, floatingProps], ); } + +/* Pointer interactions for sliders: press track or thumb, then drag with pointer capture. + * Handlers receive integer value at pointer position (grabbed thumbs don't jump). */ +export function useSliderDrag( + vertical: boolean, + [min, max]: Domain, + getThumbIndex: (val: number, pressedIndex: number) => number | undefined, // `pressedIndex` is -1 when pressing track + onDrag: (val: number, index: number) => void, + onDragEnd?: () => void, +): { + trackRef: RefObject; + dragProps: DOMAttributes; +} { + const trackRef = useRef(null); + const dragRef = useRef<{ + pointerId: number; + index: number; + delta: number; // delta between pointer and thumb centre + }>(undefined); + + function getValue(evt: PointerEvent, delta: number): number { + assertNonNull(trackRef.current); + const { left, bottom, width, height } = + trackRef.current.getBoundingClientRect(); + + const ratio = vertical + ? (bottom - evt.clientY - delta) / height + : (evt.clientX - left - delta) / width; + return Math.round(min + MathUtils.clamp(ratio, 0, 1) * (max - min)); + } + + const dragProps: DOMAttributes = { + onPointerDown: (evt) => { + if (evt.button !== 0 || dragRef.current) { + return; + } + + assertNonNull(trackRef.current); + const thumbs = [ + ...trackRef.current.querySelectorAll('[role="slider"]'), + ]; + const pressedIndex = thumbs.findIndex((thumb) => + thumb.contains(evt.target as Node), + ); + const rect = + pressedIndex === -1 + ? undefined + : thumbs[pressedIndex].getBoundingClientRect(); + const delta = !rect + ? 0 + : vertical + ? rect.top + rect.height / 2 - evt.clientY + : evt.clientX - rect.left - rect.width / 2; + + const val = getValue(evt, delta); + const index = getThumbIndex(val, pressedIndex); + if (index !== undefined) { + evt.preventDefault(); // prevent text selection + thumbs[index].focus({ preventScroll: true }); + evt.currentTarget.setPointerCapture(evt.pointerId); + dragRef.current = { pointerId: evt.pointerId, index, delta }; + onDrag(val, index); + } + }, + onPointerMove: (evt) => { + if (dragRef.current?.pointerId === evt.pointerId) { + const { index, delta } = dragRef.current; + flushSync(() => onDrag(getValue(evt, delta), index)); // render before next move so handlers see latest value + } + }, + onLostPointerCapture: (evt) => { + if (dragRef.current?.pointerId === evt.pointerId) { + dragRef.current = undefined; + onDragEnd?.(); + } + }, + }; + + return { trackRef, dragProps }; +} diff --git a/packages/lib/src/toolbar/controls/utils.ts b/packages/lib/src/toolbar/controls/utils.ts index d41815a85..83b6ba188 100644 --- a/packages/lib/src/toolbar/controls/utils.ts +++ b/packages/lib/src/toolbar/controls/utils.ts @@ -1,3 +1,6 @@ +import { type KeyboardEvent } from 'react'; +import { MathUtils } from 'three'; + export async function download( url: URL | (() => Promise), filename: string, @@ -19,3 +22,29 @@ export async function download( export function getAllOptions(options: Record | T[]): T[] { return Array.isArray(options) ? options : Object.values(options).flat(); } + +const SLIDER_KEY_STEPS: Partial> = { + ArrowLeft: -1, + ArrowDown: -1, + ArrowRight: 1, + ArrowUp: 1, + PageDown: -10, + PageUp: 10, + Home: -Infinity, + End: Infinity, +}; + +// Move slider thumb with arrow keys, PageUp/PageDown, Home/End +export function handleSliderKey( + evt: KeyboardEvent, + value: number, + min: number, + max: number, + onChange: (newValue: number) => void, +): void { + const step = SLIDER_KEY_STEPS[evt.key]; + if (step !== undefined) { + evt.preventDefault(); + onChange(MathUtils.clamp(value + step, min, max)); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00f3a12ba..9dbeef16c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -689,9 +689,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@19.3.0)(react@19.3.0) @@ -740,9 +737,6 @@ importers: react-keyed-flatten-children: specifier: 5.1.3 version: 5.1.3(react-is@19.3.1)(react@19.3.0) - react-slider: - specifier: 2.0.6 - version: 2.0.6(react@19.3.0) react-window: specifier: 2.2.7 version: 2.2.7(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -2201,9 +2195,6 @@ packages: peerDependencies: '@types/react': '*' - '@types/react-slider@1.3.6': - resolution: {integrity: sha512-RS8XN5O159YQ6tu3tGZIQz1/9StMLTg/FCIPxwqh2gwVixJnlfIodtVx+fpXVMZHe7A58lAX1Q4XTgAGOQaCQg==} - '@types/react@19.3.0': resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} @@ -4769,11 +4760,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: @@ -6791,10 +6777,6 @@ snapshots: dependencies: '@types/react': 19.3.0 - '@types/react-slider@1.3.6': - dependencies: - '@types/react': 19.3.0 - '@types/react@19.3.0': dependencies: csstype: 3.2.3 @@ -9701,11 +9683,6 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - react-slider@2.0.6(react@19.3.0): - dependencies: - prop-types: 15.8.1 - react: 19.3.0 - react-use-measure@2.1.7(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: react: 19.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 744f3c27f..6f518399d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,9 +13,6 @@ pmOnFail: ignore strictPeerDependencies: true autoInstallPeers: false resolvePeersFromWorkspaceRoot: false -peerDependencyRules: - allowedVersions: - 'react-slider>react': '19' # Approve specific build scripts allowBuilds: From 110c2b558aad6fba7e30464c7ffe3c63d8605956 Mon Sep 17 00:00:00 2001 From: Axel Bocciarelli Date: Mon, 26 Oct 2026 11:27:35 +0100 Subject: [PATCH 2/3] Use `Math.min/max()` instead of Three's `MathUtils` --- packages/lib/src/dimension-mapper/SlicingSlider.tsx | 3 +-- packages/lib/src/toolbar/controls/hooks.ts | 3 +-- packages/lib/src/toolbar/controls/utils.ts | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/lib/src/dimension-mapper/SlicingSlider.tsx b/packages/lib/src/dimension-mapper/SlicingSlider.tsx index 5e689aeb1..c0d6d0ecc 100644 --- a/packages/lib/src/dimension-mapper/SlicingSlider.tsx +++ b/packages/lib/src/dimension-mapper/SlicingSlider.tsx @@ -1,7 +1,6 @@ import { useMeasure } from '@react-hookz/web'; import { useId, useState } from 'react'; import { FiChevronDown, FiChevronUp } from 'react-icons/fi'; -import { MathUtils } from 'three'; import Btn from '../toolbar/controls/Btn'; import { useSliderDrag } from '../toolbar/controls/hooks'; @@ -72,7 +71,7 @@ function SlicingSlider(props: Props) { const [containerSize, containerRef] = useMeasure(); const sliderLabelId = `${ID}-${dimension}-label`; - const thumbValue = MathUtils.clamp(localValue, 0, length - 1); + const thumbValue = Math.min(Math.max(localValue, 0), length - 1); return (
diff --git a/packages/lib/src/toolbar/controls/hooks.ts b/packages/lib/src/toolbar/controls/hooks.ts index ee819ea0a..ab1affcad 100644 --- a/packages/lib/src/toolbar/controls/hooks.ts +++ b/packages/lib/src/toolbar/controls/hooks.ts @@ -21,7 +21,6 @@ import { useRef, } from 'react'; import { flushSync } from 'react-dom'; -import { MathUtils } from 'three'; export const POPOVER_CLEARANCE = 6; @@ -117,7 +116,7 @@ export function useSliderDrag( const ratio = vertical ? (bottom - evt.clientY - delta) / height : (evt.clientX - left - delta) / width; - return Math.round(min + MathUtils.clamp(ratio, 0, 1) * (max - min)); + return Math.round(min + Math.min(Math.max(ratio, 0), 1) * (max - min)); } const dragProps: DOMAttributes = { diff --git a/packages/lib/src/toolbar/controls/utils.ts b/packages/lib/src/toolbar/controls/utils.ts index 83b6ba188..22f2030d1 100644 --- a/packages/lib/src/toolbar/controls/utils.ts +++ b/packages/lib/src/toolbar/controls/utils.ts @@ -1,5 +1,4 @@ import { type KeyboardEvent } from 'react'; -import { MathUtils } from 'three'; export async function download( url: URL | (() => Promise), @@ -45,6 +44,6 @@ export function handleSliderKey( const step = SLIDER_KEY_STEPS[evt.key]; if (step !== undefined) { evt.preventDefault(); - onChange(MathUtils.clamp(value + step, min, max)); + onChange(Math.min(Math.max(value + step, min), max)); } } From 9d05e298f3cf8071818fef45c9e59f9b2eda8653 Mon Sep 17 00:00:00 2001 From: Nick Thompson Date: Mon, 26 Oct 2026 07:37:56 -0700 Subject: [PATCH 3/3] Implement slider interactions separately in each slider Drop shared `useSliderDrag` hook and `handleSliderKey` util. Co-Authored-By: Claude Opus 5.5 --- .../src/dimension-mapper/SlicingSlider.tsx | 88 +++++++++++--- .../controls/DomainWidget/DomainSlider.tsx | 107 ++++++++++++++---- packages/lib/src/toolbar/controls/hooks.ts | 93 +-------------- packages/lib/src/toolbar/controls/utils.ts | 28 ----- 4 files changed, 164 insertions(+), 152 deletions(-) diff --git a/packages/lib/src/dimension-mapper/SlicingSlider.tsx b/packages/lib/src/dimension-mapper/SlicingSlider.tsx index c0d6d0ecc..a1406c1ef 100644 --- a/packages/lib/src/dimension-mapper/SlicingSlider.tsx +++ b/packages/lib/src/dimension-mapper/SlicingSlider.tsx @@ -1,10 +1,16 @@ +import { assertNonNull } from '@h5web/shared/guards'; import { useMeasure } from '@react-hookz/web'; -import { useId, useState } from 'react'; +import { + type KeyboardEvent, + type PointerEvent, + useId, + useRef, + useState, +} from 'react'; +import { flushSync } from 'react-dom'; import { FiChevronDown, FiChevronUp } from 'react-icons/fi'; import Btn from '../toolbar/controls/Btn'; -import { useSliderDrag } from '../toolbar/controls/hooks'; -import { handleSliderKey } from '../toolbar/controls/utils'; import { useDynamicDebouncedCallback } from './hooks'; import styles from './SlicingSlider.module.css'; @@ -13,6 +19,17 @@ const MIN_HEIGHT_PER_MARK = 25; const SHORT_DELAY = 20; const LONG_DELAY = 250; +const KEY_STEPS: Partial> = { + ArrowDown: -1, + ArrowLeft: -1, + ArrowUp: 1, + ArrowRight: 1, + PageDown: -10, + PageUp: 10, + Home: -Infinity, + End: Infinity, +}; + interface Props { dimension: number; length: number; @@ -62,12 +79,49 @@ function SlicingSlider(props: Props) { } } - const { trackRef, dragProps } = useSliderDrag( - true, - [0, length - 1], - () => 0, - handleChange, - ); + const trackRef = useRef(null); + const thumbRef = useRef(null); + const dragRef = useRef<{ pointerId: number; offset: number }>(undefined); // offset between pointer and thumb center + + function getValueAt(clientY: number): number { + assertNonNull(trackRef.current); + const { bottom, height } = trackRef.current.getBoundingClientRect(); + const ratio = Math.min(Math.max((bottom - clientY) / height, 0), 1); + return Math.round(ratio * (length - 1)); + } + + function handlePointerDown(evt: PointerEvent) { + assertNonNull(thumbRef.current); + if (evt.button !== 0 || dragRef.current) { + return; + } + + const { top, height } = thumbRef.current.getBoundingClientRect(); + const offset = thumbRef.current.contains(evt.target as Node) + ? evt.clientY - (top + height / 2) // grabbed thumb doesn't jump + : 0; + + evt.preventDefault(); // prevent text selection + thumbRef.current.focus({ preventScroll: true }); + evt.currentTarget.setPointerCapture(evt.pointerId); + dragRef.current = { pointerId: evt.pointerId, offset }; + handleChange(getValueAt(evt.clientY - offset)); + } + + function handlePointerMove(evt: PointerEvent) { + if (dragRef.current?.pointerId === evt.pointerId) { + const { offset } = dragRef.current; + flushSync(() => handleChange(getValueAt(evt.clientY - offset))); // render before next move so `localValue` is up to date + } + } + + function handleKeyDown(evt: KeyboardEvent) { + const step = KEY_STEPS[evt.key]; + if (step !== undefined) { + evt.preventDefault(); + handleChange(Math.min(Math.max(localValue + step, 0), length - 1)); + } + } const [containerSize, containerRef] = useMeasure(); const sliderLabelId = `${ID}-${dimension}-label`; @@ -81,7 +135,16 @@ function SlicingSlider(props: Props) { 0:{length - 1} {length > 1 && ( <> -
+
{ + if (dragRef.current?.pointerId === evt.pointerId) { + dragRef.current = undefined; + } + }} + >
{containerSize && containerSize.height / length >= MIN_HEIGHT_PER_MARK && @@ -93,6 +156,7 @@ function SlicingSlider(props: Props) { /> ))}
- handleSliderKey(evt, localValue, 0, length - 1, handleChange) - } + onKeyDown={handleKeyDown} > {localValue}
diff --git a/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.tsx b/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.tsx index 444fdafab..3bbedb840 100644 --- a/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.tsx +++ b/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.tsx @@ -1,13 +1,13 @@ +import { assertNonNull } from '@h5web/shared/guards'; import { type ColorScaleType, type Domain } from '@h5web/shared/vis-models'; -import { useRef } from 'react'; +import { type KeyboardEvent, type PointerEvent, useRef } from 'react'; +import { flushSync } from 'react-dom'; import { FiSkipBack, FiSkipForward } from 'react-icons/fi'; import { getSafeDomain } from '../../../vis/heatmap/utils'; import { useCombinedDomain } from '../../../vis/hooks'; import { type DomainErrors } from '../../../vis/models'; import { clampBound, createScale, extendDomain } from '../../../vis/utils'; -import { useSliderDrag } from '../hooks'; -import { handleSliderKey } from '../utils'; import styles from './DomainSlider.module.css'; import Thumb from './Thumb'; import Track from './Track'; @@ -16,6 +16,17 @@ const SLIDER_RANGE: Domain = [1, 100]; const EXTEND_FACTOR = 0.3; const SLIDER_SPAN = SLIDER_RANGE[1] - SLIDER_RANGE[0]; +const KEY_STEPS: Partial> = { + ArrowLeft: -1, + ArrowDown: -1, + ArrowRight: 1, + ArrowUp: 1, + PageDown: -10, + PageUp: 10, + Home: -Infinity, + End: Infinity, +}; + interface Props { value: Domain; dataDomain: Domain; @@ -98,23 +109,85 @@ function DomainSlider(props: Props) { } } - const { trackRef, dragProps } = useSliderDrag( - false, - SLIDER_RANGE, - (val, pressedIndex) => { - const closest = val * 2 < scaledValue[0] + scaledValue[1] ? 0 : 1; - return disabled ? undefined : pressedIndex >= 0 ? pressedIndex : closest; - }, - moveThumb, - endChange, - ); + const sliderRef = useRef(null); + const thumbsRef = useRef<(HTMLDivElement | null)[]>([]); + const dragRef = useRef<{ pointerId: number; index: number; offset: number }>( + undefined, + ); // offset between pointer and thumb center + + function getBoundAt(clientX: number): number { + assertNonNull(sliderRef.current); + const { left, width } = sliderRef.current.getBoundingClientRect(); + const ratio = Math.min(Math.max((clientX - left) / width, 0), 1); + return Math.round(SLIDER_RANGE[0] + ratio * SLIDER_SPAN); + } + + function handlePointerDown(evt: PointerEvent) { + if (disabled || evt.button !== 0 || dragRef.current) { + return; + } + + // Grab pressed thumb without making it jump, or else move closest thumb + const pressedThumb = thumbsRef.current.find((thumb) => + thumb?.contains(evt.target as Node), + ); + let index: number; + let offset = 0; + if (pressedThumb) { + const { left, width } = pressedThumb.getBoundingClientRect(); + index = thumbsRef.current.indexOf(pressedThumb); + offset = evt.clientX - (left + width / 2); + } else { + const [min, max] = scaledValue; + index = getBoundAt(evt.clientX) * 2 < min + max ? 0 : 1; + } + + evt.preventDefault(); // prevent text selection + thumbsRef.current[index]?.focus({ preventScroll: true }); + evt.currentTarget.setPointerCapture(evt.pointerId); + dragRef.current = { pointerId: evt.pointerId, index, offset }; + moveThumb(getBoundAt(evt.clientX - offset), index); + } + + function handlePointerMove(evt: PointerEvent) { + if (dragRef.current?.pointerId === evt.pointerId) { + const { index, offset } = dragRef.current; + flushSync(() => moveThumb(getBoundAt(evt.clientX - offset), index)); // render before next move so `scaledValue` is up to date + } + } + + function handleKeyDown(evt: KeyboardEvent, index: number) { + const step = KEY_STEPS[evt.key]; + if (step !== undefined) { + evt.preventDefault(); + const bound = scaledValue[index] + step; + moveThumb( + Math.min(Math.max(bound, SLIDER_RANGE[0]), SLIDER_RANGE[1]), + index, + ); + } + } return ( -
+
{ + if (dragRef.current?.pointerId === evt.pointerId) { + dragRef.current = undefined; + endChange(); + } + }} + > {scaledValue.map((scaledBound, index) => ( { + thumbsRef.current[index] = thumb; + }} bound={index === 0 ? 'min' : 'max'} isAuto={index === 0 ? isAutoMin : isAutoMax} hasError={minGreater || (index === 0 ? !!minError : !!maxError)} @@ -128,11 +201,7 @@ function DomainSlider(props: Props) { aria-valuemin={SLIDER_RANGE[0]} aria-valuemax={SLIDER_RANGE[1]} aria-valuenow={scaledBound} - onKeyDown={(evt) => - handleSliderKey(evt, scaledBound, ...SLIDER_RANGE, (newBound) => - moveThumb(newBound, index), - ) - } + onKeyDown={(evt) => handleKeyDown(evt, index)} onKeyUp={endChange} onBlur={endChange} /> diff --git a/packages/lib/src/toolbar/controls/hooks.ts b/packages/lib/src/toolbar/controls/hooks.ts index ab1affcad..baf2e765e 100644 --- a/packages/lib/src/toolbar/controls/hooks.ts +++ b/packages/lib/src/toolbar/controls/hooks.ts @@ -8,19 +8,8 @@ import { useFloating, type UseFloatingReturn, } from '@floating-ui/react'; -import { assertNonNull } from '@h5web/shared/guards'; -import { type Domain } from '@h5web/shared/vis-models'; import { useClickOutside, useKeyboardEvent, useToggle } from '@react-hookz/web'; -import { - type DOMAttributes, - type FocusEvent, - type PointerEvent, - type RefObject, - useCallback, - useMemo, - useRef, -} from 'react'; -import { flushSync } from 'react-dom'; +import { type FocusEvent, useCallback, useMemo } from 'react'; export const POPOVER_CLEARANCE = 6; @@ -88,83 +77,3 @@ export function useFloatingDismiss(context: FloatingContext): ElementProps { [referenceProps, floatingProps], ); } - -/* Pointer interactions for sliders: press track or thumb, then drag with pointer capture. - * Handlers receive integer value at pointer position (grabbed thumbs don't jump). */ -export function useSliderDrag( - vertical: boolean, - [min, max]: Domain, - getThumbIndex: (val: number, pressedIndex: number) => number | undefined, // `pressedIndex` is -1 when pressing track - onDrag: (val: number, index: number) => void, - onDragEnd?: () => void, -): { - trackRef: RefObject; - dragProps: DOMAttributes; -} { - const trackRef = useRef(null); - const dragRef = useRef<{ - pointerId: number; - index: number; - delta: number; // delta between pointer and thumb centre - }>(undefined); - - function getValue(evt: PointerEvent, delta: number): number { - assertNonNull(trackRef.current); - const { left, bottom, width, height } = - trackRef.current.getBoundingClientRect(); - - const ratio = vertical - ? (bottom - evt.clientY - delta) / height - : (evt.clientX - left - delta) / width; - return Math.round(min + Math.min(Math.max(ratio, 0), 1) * (max - min)); - } - - const dragProps: DOMAttributes = { - onPointerDown: (evt) => { - if (evt.button !== 0 || dragRef.current) { - return; - } - - assertNonNull(trackRef.current); - const thumbs = [ - ...trackRef.current.querySelectorAll('[role="slider"]'), - ]; - const pressedIndex = thumbs.findIndex((thumb) => - thumb.contains(evt.target as Node), - ); - const rect = - pressedIndex === -1 - ? undefined - : thumbs[pressedIndex].getBoundingClientRect(); - const delta = !rect - ? 0 - : vertical - ? rect.top + rect.height / 2 - evt.clientY - : evt.clientX - rect.left - rect.width / 2; - - const val = getValue(evt, delta); - const index = getThumbIndex(val, pressedIndex); - if (index !== undefined) { - evt.preventDefault(); // prevent text selection - thumbs[index].focus({ preventScroll: true }); - evt.currentTarget.setPointerCapture(evt.pointerId); - dragRef.current = { pointerId: evt.pointerId, index, delta }; - onDrag(val, index); - } - }, - onPointerMove: (evt) => { - if (dragRef.current?.pointerId === evt.pointerId) { - const { index, delta } = dragRef.current; - flushSync(() => onDrag(getValue(evt, delta), index)); // render before next move so handlers see latest value - } - }, - onLostPointerCapture: (evt) => { - if (dragRef.current?.pointerId === evt.pointerId) { - dragRef.current = undefined; - onDragEnd?.(); - } - }, - }; - - return { trackRef, dragProps }; -} diff --git a/packages/lib/src/toolbar/controls/utils.ts b/packages/lib/src/toolbar/controls/utils.ts index 22f2030d1..d41815a85 100644 --- a/packages/lib/src/toolbar/controls/utils.ts +++ b/packages/lib/src/toolbar/controls/utils.ts @@ -1,5 +1,3 @@ -import { type KeyboardEvent } from 'react'; - export async function download( url: URL | (() => Promise), filename: string, @@ -21,29 +19,3 @@ export async function download( export function getAllOptions(options: Record | T[]): T[] { return Array.isArray(options) ? options : Object.values(options).flat(); } - -const SLIDER_KEY_STEPS: Partial> = { - ArrowLeft: -1, - ArrowDown: -1, - ArrowRight: 1, - ArrowUp: 1, - PageDown: -10, - PageUp: 10, - Home: -Infinity, - End: Infinity, -}; - -// Move slider thumb with arrow keys, PageUp/PageDown, Home/End -export function handleSliderKey( - evt: KeyboardEvent, - value: number, - min: number, - max: number, - onChange: (newValue: number) => void, -): void { - const step = SLIDER_KEY_STEPS[evt.key]; - if (step !== undefined) { - evt.preventDefault(); - onChange(Math.min(Math.max(value + step, min), max)); - } -}