diff --git a/cypress/snapshots/app.cy.ts/auximage.snap.png b/cypress/snapshots/app.cy.ts/auximage.snap.png index b68445d9d..7b53ff656 100644 Binary files a/cypress/snapshots/app.cy.ts/auximage.snap.png and b/cypress/snapshots/app.cy.ts/auximage.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/auxspectrum.snap.png b/cypress/snapshots/app.cy.ts/auxspectrum.snap.png index 6030c1c60..851346b4a 100644 Binary files a/cypress/snapshots/app.cy.ts/auxspectrum.snap.png and b/cypress/snapshots/app.cy.ts/auxspectrum.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/default_slice.snap.png b/cypress/snapshots/app.cy.ts/default_slice.snap.png index 0e2b6cac6..b0a36322c 100644 Binary files a/cypress/snapshots/app.cy.ts/default_slice.snap.png and b/cypress/snapshots/app.cy.ts/default_slice.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/fillvalue_1D.snap.png b/cypress/snapshots/app.cy.ts/fillvalue_1D.snap.png index 06d5ce379..38ab8e83b 100644 Binary files a/cypress/snapshots/app.cy.ts/fillvalue_1D.snap.png and b/cypress/snapshots/app.cy.ts/fillvalue_1D.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/fillvalue_2D.snap.png b/cypress/snapshots/app.cy.ts/fillvalue_2D.snap.png index fca644b6c..c982305ee 100644 Binary files a/cypress/snapshots/app.cy.ts/fillvalue_2D.snap.png and b/cypress/snapshots/app.cy.ts/fillvalue_2D.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_2D.snap.png b/cypress/snapshots/app.cy.ts/heatmap_2D.snap.png index fc340b83a..e0d2fd611 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_2D.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_2D.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_2D_complex.snap.png b/cypress/snapshots/app.cy.ts/heatmap_2D_complex.snap.png index da4710487..af013da60 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_2D_complex.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_2D_complex.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_2D_inverted_cmap.snap.png b/cypress/snapshots/app.cy.ts/heatmap_2D_inverted_cmap.snap.png index 0f2561acc..aa74c41f1 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_2D_inverted_cmap.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_2D_inverted_cmap.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_4d_default.snap.png b/cypress/snapshots/app.cy.ts/heatmap_4d_default.snap.png index ac95f6ad2..b6b8c377f 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_4d_default.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_4d_default.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_4d_remapped.snap.png b/cypress/snapshots/app.cy.ts/heatmap_4d_remapped.snap.png index dc600c562..fe45b9f3e 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_4d_remapped.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_4d_remapped.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_4d_sliced.snap.png b/cypress/snapshots/app.cy.ts/heatmap_4d_sliced.snap.png index 098b63993..9de17e8c3 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_4d_sliced.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_4d_sliced.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_4d_zeros.snap.png b/cypress/snapshots/app.cy.ts/heatmap_4d_zeros.snap.png index 826e32a28..e7a7d53b1 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_4d_zeros.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_4d_zeros.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_domain.snap.png b/cypress/snapshots/app.cy.ts/heatmap_domain.snap.png index d82690362..79ae59abf 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_domain.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_domain.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_flip.snap.png b/cypress/snapshots/app.cy.ts/heatmap_flip.snap.png index a3815e301..7664d8821 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_flip.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_flip.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/line_1D.snap.png b/cypress/snapshots/app.cy.ts/line_1D.snap.png index 701f806d7..e063f8b24 100644 Binary files a/cypress/snapshots/app.cy.ts/line_1D.snap.png and b/cypress/snapshots/app.cy.ts/line_1D.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/line_1D_points.snap.png b/cypress/snapshots/app.cy.ts/line_1D_points.snap.png index 80bce1db8..216ff0727 100644 Binary files a/cypress/snapshots/app.cy.ts/line_1D_points.snap.png and b/cypress/snapshots/app.cy.ts/line_1D_points.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/line_complex_1D.snap.png b/cypress/snapshots/app.cy.ts/line_complex_1D.snap.png index b9382239b..b840fb931 100644 Binary files a/cypress/snapshots/app.cy.ts/line_complex_1D.snap.png and b/cypress/snapshots/app.cy.ts/line_complex_1D.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/line_complex_constant.snap.png b/cypress/snapshots/app.cy.ts/line_complex_constant.snap.png index e16068307..650806ec2 100644 Binary files a/cypress/snapshots/app.cy.ts/line_complex_constant.snap.png and b/cypress/snapshots/app.cy.ts/line_complex_constant.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/line_constant.snap.png b/cypress/snapshots/app.cy.ts/line_constant.snap.png index 0d432c64c..33a4f04d4 100644 Binary files a/cypress/snapshots/app.cy.ts/line_constant.snap.png and b/cypress/snapshots/app.cy.ts/line_constant.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/nxheatmap.snap.png b/cypress/snapshots/app.cy.ts/nxheatmap.snap.png index 7860fd779..e52241c91 100644 Binary files a/cypress/snapshots/app.cy.ts/nxheatmap.snap.png and b/cypress/snapshots/app.cy.ts/nxheatmap.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/nxheatmap_complex_2d.snap.png b/cypress/snapshots/app.cy.ts/nxheatmap_complex_2d.snap.png index d9e40dbc4..577b92a60 100644 Binary files a/cypress/snapshots/app.cy.ts/nxheatmap_complex_2d.snap.png and b/cypress/snapshots/app.cy.ts/nxheatmap_complex_2d.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/nxheatmap_complex_2d_float_aux.snap.png b/cypress/snapshots/app.cy.ts/nxheatmap_complex_2d_float_aux.snap.png index ba1f3e255..414cee4d7 100644 Binary files a/cypress/snapshots/app.cy.ts/nxheatmap_complex_2d_float_aux.snap.png and b/cypress/snapshots/app.cy.ts/nxheatmap_complex_2d_float_aux.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/nxline.snap.png b/cypress/snapshots/app.cy.ts/nxline.snap.png index 8785ef9da..89d9dcc7e 100644 Binary files a/cypress/snapshots/app.cy.ts/nxline.snap.png and b/cypress/snapshots/app.cy.ts/nxline.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/nxline_complex_2d_aux.snap.png b/cypress/snapshots/app.cy.ts/nxline_complex_2d_aux.snap.png index 2dc1f8601..2b333a627 100644 Binary files a/cypress/snapshots/app.cy.ts/nxline_complex_2d_aux.snap.png and b/cypress/snapshots/app.cy.ts/nxline_complex_2d_aux.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/nxline_log.snap.png b/cypress/snapshots/app.cy.ts/nxline_log.snap.png index abdc7ea44..97ba06c89 100644 Binary files a/cypress/snapshots/app.cy.ts/nxline_log.snap.png and b/cypress/snapshots/app.cy.ts/nxline_log.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/nxrgb.snap.png b/cypress/snapshots/app.cy.ts/nxrgb.snap.png index ba72dd5d8..d2d11d289 100644 Binary files a/cypress/snapshots/app.cy.ts/nxrgb.snap.png and b/cypress/snapshots/app.cy.ts/nxrgb.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/wide.snap.png b/cypress/snapshots/app.cy.ts/wide.snap.png index ae333174a..8dc61204a 100644 Binary files a/cypress/snapshots/app.cy.ts/wide.snap.png and b/cypress/snapshots/app.cy.ts/wide.snap.png differ diff --git a/packages/app/src/__tests__/DomainWidget.browser.test.ts b/packages/app/src/__tests__/DomainWidget.browser.test.ts index 025023eca..200fe4d7f 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,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'); 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..f7f2b3706 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -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", @@ -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", @@ -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" }, diff --git a/packages/lib/src/dimension-mapper/SlicingSlider.module.css b/packages/lib/src/dimension-mapper/SlicingSlider.module.css index cb110299d..ebd07e0b2 100644 --- a/packages/lib/src/dimension-mapper/SlicingSlider.module.css +++ b/packages/lib/src/dimension-mapper/SlicingSlider.module.css @@ -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); } @@ -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( @@ -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 { diff --git a/packages/lib/src/dimension-mapper/SlicingSlider.tsx b/packages/lib/src/dimension-mapper/SlicingSlider.tsx index 128722907..81db07693 100644 --- a/packages/lib/src/dimension-mapper/SlicingSlider.tsx +++ b/packages/lib/src/dimension-mapper/SlicingSlider.tsx @@ -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'; @@ -46,6 +50,12 @@ function SlicingSlider(props: Props) { ); const [containerSize, containerRef] = useMeasure(); + + // 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(null); + const thumbSize = useSize(thumb); + const sliderLabelId = `${ID}-${dimension}-label`; return ( @@ -56,41 +66,41 @@ function SlicingSlider(props: Props) { 0:{length - 1} {length > 1 && ( <> - = 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) => ( -
- {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..d61d27242 100644 --- a/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.module.css +++ b/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.module.css @@ -1,11 +1,32 @@ .slider { + position: relative; display: flex; - margin-right: -0.25rem; + /* Make track end in middle of thumbs */ + margin: 0 calc(var(--thumb-size) / 2 - 0.25rem) 0 calc(var(--thumb-size) / 2); font-size: 0.75rem; cursor: pointer; + touch-action: pan-y; /* let pointer interactions along slider through, but allow scrolling vertically */ + isolation: isolate; --track-width: var(--h5w-domainSlider-track--width, 8rem); --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); } + +/* Extend interactive area to the thumbs' overflow, but not over the adjacent element */ +.slider::before { + content: ''; + position: absolute; + inset: 0 calc(0.25rem - var(--thumb-size) / 2) 0 calc(var(--thumb-size) / -2); +} + +/* Absolutely-positioned wrapper of each thumb (rendered by Radix) */ +.slider > span { + top: 0; + bottom: 0; +} + +.slider > span:focus-within { + z-index: 1; /* keep focused thumb on top when thumbs overlap */ +} diff --git a/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.tsx b/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.tsx index 8445a7566..ad859f66f 100644 --- a/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.tsx +++ b/packages/lib/src/toolbar/controls/DomainWidget/DomainSlider.tsx @@ -1,7 +1,7 @@ import { type ColorScaleType, type Domain } from '@h5web/shared/vis-models'; -import { useState } from 'react'; +import { Root as SliderRoot } from '@radix-ui/react-slider'; +import { type KeyboardEvent, 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'; @@ -13,6 +13,25 @@ import Track from './Track'; const SLIDER_RANGE: Domain = [1, 100]; const EXTEND_FACTOR = 0.3; +const SLIDER_KEYS = new Set([ + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'PageUp', + 'PageDown', + 'Home', + 'End', +]); + +interface Interaction { + thumbIndex: number; // thumb being moved + isKeyboard: boolean; + pressedKeys: Set; + scaledValueBefore: Domain; + valueBefore: Domain; + hasMoved: boolean; +} interface Props { value: Domain; @@ -52,53 +71,167 @@ 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 rootRef = useRef(null); + const interactionRef = useRef(); + + function getThumbs(): HTMLElement[] { + const root = rootRef.current; + return root + ? [...root.querySelectorAll('[role="slider"]')] + : []; + } + + function endInteraction() { + const interaction = interactionRef.current; + interactionRef.current = undefined; + + if (interaction?.hasMoved) { + const [scaledMinBefore, scaledMaxBefore] = interaction.scaledValueBefore; + onDone( + scaledValue[0] !== scaledMinBefore, + scaledValue[1] !== scaledMaxBefore, + ); + } + } + + function startInteraction(thumbIndex: number, isKeyboard: boolean) { + endInteraction(); // in case previous interaction didn't end properly + + const interaction: Interaction = { + thumbIndex, + isKeyboard, + pressedKeys: new Set(), + scaledValueBefore: scaledValue, + valueBefore: value, + hasMoved: false, + }; + + interactionRef.current = interaction; + return interaction; + } + + function handleChange(newBound: number) { + const interaction = interactionRef.current ?? startInteraction(0, false); + const { thumbIndex, scaledValueBefore, valueBefore } = interaction; + + // Move active thumb and let it push the other thumb if needed ("pearling") + const [scaledMin, scaledMax] = scaledValue; + const [newScaledMin, newScaledMax] = + thumbIndex === 0 + ? [newBound, Math.max(scaledMax, newBound)] + : [Math.min(scaledMin, newBound), newBound]; + + if (newScaledMin === scaledMin && newScaledMax === scaledMax) { + return; + } + + interaction.hasMoved = true; + onChange([ + // Restore initial bound if thumb is back to its initial position + newScaledMin === scaledValueBefore[0] + ? valueBefore[0] + : scale.invert(newScaledMin), + newScaledMax === scaledValueBefore[1] + ? valueBefore[1] + : scale.invert(newScaledMax), + ]); + } + + function handleThumbKeyDown(evt: KeyboardEvent, thumbIndex: number) { + if (!SLIDER_KEYS.has(evt.key)) { + return; + } - function handleChange(newScaledValue: Domain, done = false) { - const [newScaledMin, newScaledMax] = newScaledValue; - const hasMinChanged = newScaledMin !== beforeChangeValue[0]; - const hasMaxChanged = newScaledMax !== beforeChangeValue[1]; + const { current } = interactionRef; + const interaction = + current?.isKeyboard && current.thumbIndex === thumbIndex + ? current + : startInteraction(thumbIndex, true); - const newValue: Domain = [ - hasMinChanged ? scale.invert(newScaledMin) : value[0], - hasMaxChanged ? scale.invert(newScaledMax) : value[1], - ]; + interaction.pressedKeys.add(evt.key); - if (done) { - onDone(hasMinChanged, hasMaxChanged); - } else { - onChange(newValue); + // Radix always moves the first/last thumb on Home/End + if (evt.key === 'Home' || evt.key === 'End') { + evt.preventDefault(); // prevent Radix from handling the key + handleChange(SLIDER_RANGE[evt.key === 'Home' ? 0 : 1]); } } return ( - handleChange(bounds)} - onAfterChange={(bounds) => handleChange(bounds, true)} - renderThumb={({ key, ...thumbProps }, { index }) => ( + onPointerDown={(evt) => { + // Find thumb that Radix is about to move: pressed thumb or thumb closest to pointer + const distances = getThumbs().map((thumb) => { + if (thumb.contains(evt.target as Node)) { + return -1; + } + + const { left, width } = thumb.getBoundingClientRect(); + return Math.abs(evt.clientX - (left + width / 2)); + }); + + startInteraction(distances.indexOf(Math.min(...distances)), false); + }} + onLostPointerCapture={() => { + // Pointer released, or interaction interrupted (e.g. by browser panning) + if (!interactionRef.current?.isKeyboard) { + endInteraction(); + } + }} + onKeyUp={(evt) => { + const interaction = interactionRef.current; + if (!interaction?.isKeyboard) { + return; + } + + // End interaction once all slider keys are released + interaction.pressedKeys.delete(evt.key); + if (interaction.pressedKeys.size === 0) { + endInteraction(); + } + }} + onBlur={(evt) => { + // End interaction when focus leaves slider (including when window loses focus) + if (!evt.currentTarget.contains(evt.relatedTarget)) { + endInteraction(); + } + }} + onValueChange={(newValues) => { + // Radix sorts the values, so the thumbs swap when one is moved past the other: + // find the new value of the active thumb + const [first, second] = newValues; + handleChange(scaledValue.includes(first) ? second : first); + + // When thumbs swap, Radix moves the focus to the other thumb; move it back + const thumb = getThumbs().at(interactionRef.current?.thumbIndex ?? 0); + if (thumb && thumb !== document.activeElement) { + thumb.focus({ preventScroll: true }); + } + }} + > + + {(['min', 'max'] as const).map((bound, index) => ( handleThumbKeyDown(evt, index)} /> - )} - 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..3bf4903f0 100644 --- a/packages/lib/src/toolbar/controls/DomainWidget/Thumb.module.css +++ b/packages/lib/src/toolbar/controls/DomainWidget/Thumb.module.css @@ -1,16 +1,17 @@ .thumb { - top: 0; - bottom: 0; display: flex; justify-content: center; align-items: center; width: var(--thumb-size); /* fixed size regardless of autoscale */ + height: 100%; cursor: ew-resize; + touch-action: none; } .thumbBtnLike { position: relative; padding: 0; + pointer-events: none; /* so Radix knows when the thumb itself is pressed */ border-radius: 50%; box-shadow: -1px -1px 2px inset var(--h5w-btnRaised-hover--shadowColor, dimgray); diff --git a/packages/lib/src/toolbar/controls/DomainWidget/Thumb.tsx b/packages/lib/src/toolbar/controls/DomainWidget/Thumb.tsx index d362cc7bd..a9b9f1a96 100644 --- a/packages/lib/src/toolbar/controls/DomainWidget/Thumb.tsx +++ b/packages/lib/src/toolbar/controls/DomainWidget/Thumb.tsx @@ -1,41 +1,51 @@ -import { forwardRef, type HTMLProps } from 'react'; +import { Thumb as SliderThumb } from '@radix-ui/react-slider'; +import { useSize } from '@radix-ui/react-use-size'; +import { type HTMLAttributes, useState } from 'react'; 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 extends HTMLAttributes { bound: Bound; + ratio: number; // position of thumb along slider, between 0 and 1 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 { bound, ratio, isAuto, hasError, disabled, AutoIcon, ...thumbProps } = + props; + + // 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(null); + const size = useSize(thumb); return ( -
-
- {isAuto && } - {!isAuto && hasError && ( - - )} + +
+
+ {isAuto && } + {!isAuto && hasError && ( + + )} +
-
+ ); -}); +} -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/pnpm-lock.yaml b/pnpm-lock.yaml index 44adbe004..852290513 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -644,6 +644,12 @@ importers: '@floating-ui/react': specifier: 0.27.19 version: 0.27.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slider': + specifier: 1.5.0 + version: 1.5.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-size': + specifier: 1.1.5 + version: 1.1.5(@types/react@18.3.31)(react@18.3.1) '@react-hookz/web': specifier: 25.1.1 version: 25.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -671,9 +677,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,9 +725,6 @@ 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) @@ -1538,6 +1538,132 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@radix-ui/number@1.1.3': + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + + '@radix-ui/react-collection@1.1.16': + resolution: {integrity: sha512-yEQrjfI5eBbeYEJXOASpm+gs9eNHY8+UjkZ81GzcuwdLrSMOlEM8jnGoGdeGo92oiBMkuwZUhHTsnsrDiJLcyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-direction@1.1.5': + resolution: {integrity: sha512-/NlO/rOqQabmg+GGr86vCmbJVhqTv85NfM9g6Gknf/JVygXeZ6e6HM/RW1omerW9HsFtecwNqgEAKV6UqDsvpA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-primitive@2.1.11': + resolution: {integrity: sha512-RzcaSw5aKl0OPhBJ1wbolKSF6nVvN+0o/QLeIVq+09npiPdnCkPggy2k6gI6yetxl4ZyrlBPYFgCB8BQJZwXcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.5.0': + resolution: {integrity: sha512-ZOBLK7KZvGL02V+4VwVbj8NYM4vPrAtyZdrgHSv+AMS9VL+5yAdnQF3DZT4Y8cbCp1Yn4xdp92orvez8fJa77w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.4.0': + resolution: {integrity: sha512-IuMVHZ+Ah5fkctu3X3qeFrV/ZrBcuWuDTFUUPfS/L5hvoTjzhp/HdWGE14CsQombPdEidsa33HVlYZXi49PZ1Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.4': + resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.5': + resolution: {integrity: sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@react-hookz/web@25.1.1': resolution: {integrity: sha512-o1BA+5Z8PCuAnxF7+2TZI+xGBtzyLw8z/flD8AMeJXILYTM8HkI0g41oM7IW/qjUDKx30HKceQQpCKqFGj+iIw==} engines: {node: '>=18.0.0'} @@ -2111,9 +2237,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 +4708,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: @@ -6065,6 +6183,110 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@radix-ui/number@1.1.3': {} + + '@radix-ui/primitive@1.1.7': {} + + '@radix-ui/react-collection@1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.4.0(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-compose-refs@1.1.5(@types/react@18.3.31)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-context@1.2.2(@types/react@18.3.31)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-direction@1.1.5(@types/react@18.3.31)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-primitive@2.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.4.0(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-slider@1.5.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-slot@1.4.0(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@18.3.31)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-previous@1.1.4(@types/react@18.3.31)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-size@1.1.5(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + '@react-hookz/web@25.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@ver0/deep-equal': 1.0.1 @@ -6541,10 +6763,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 +9600,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