From abf43d86c33353e00011539fd7085c010170f605 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 24 May 2026 21:27:22 +0200 Subject: [PATCH] feat(floorplan-editor): swap the height swatch column for a vertical slider Replaces the SVG column of 27 colour swatches with a vertical slider that fills the same role (pick a brush height 0-26) but much faster to scrub: - Track is a discrete-step linear gradient built from the real tile-fill colours, top = HEIGHT_BRUSH_MAX, bottom = HEIGHT_BRUSH_MIN. Each height occupies a clear band so the user still reads colour-to-height at a glance. - Yellow circular thumb shows the current value as a number, centred at the picked height's band, with a darker border while dragging so the drag affordance is obvious. - Click anywhere on the track to jump; the same gesture starts a drag (pointermove on window) so users can scrub up/down without releasing. Pointer-cancel + button-other-than-0 are handled. - ARIA: slider role + valuemin / valuemax / valuenow, plus a touch-none style so mobile scrolling doesn't fight the drag. Tests rewritten around the new contract (5 cases): - thumb renders with the current value; - click at top -> picks 26; - click at bottom -> picks 0; - click at middle -> picks 13; - click at the band that's already selected -> no onSelect call (idempotent). Track geometry is stubbed via getBoundingClientRect so the pointer math is reproducible under jsdom. afterEach(cleanup) keeps multiple renders from colliding on the data-testid lookup. --- .../views/FloorplanHeightPicker.test.tsx | 124 +++++++++++-- .../views/FloorplanHeightPicker.tsx | 163 ++++++++++++++---- 2 files changed, 236 insertions(+), 51 deletions(-) diff --git a/src/components/floorplan-editor/views/FloorplanHeightPicker.test.tsx b/src/components/floorplan-editor/views/FloorplanHeightPicker.test.tsx index 951435e..9c047fe 100644 --- a/src/components/floorplan-editor/views/FloorplanHeightPicker.test.tsx +++ b/src/components/floorplan-editor/views/FloorplanHeightPicker.test.tsx @@ -1,28 +1,124 @@ -import { describe, it, expect, vi } from 'vitest'; -import { fireEvent, render } from '@testing-library/react'; +/* @vitest-environment jsdom */ + +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { FloorplanHeightPicker } from './FloorplanHeightPicker'; +// Force a fixed track size into getBoundingClientRect so the +// pointer-y -> height math is reproducible regardless of jsdom's +// layout (which would otherwise hand back zeroes). +const TRACK_HEIGHT = 260; + +const stubTrackGeometry = (top = 0) => +{ + const original = HTMLDivElement.prototype.getBoundingClientRect; + + HTMLDivElement.prototype.getBoundingClientRect = function () + { + if(this.getAttribute('data-testid') === 'height-track') + { + return { + top, + left: 0, + right: 14, + bottom: top + TRACK_HEIGHT, + width: 14, + height: TRACK_HEIGHT, + x: 0, + y: top, + toJSON: () => '' + } as DOMRect; + } + + return original.call(this); + }; + + return () => + { + HTMLDivElement.prototype.getBoundingClientRect = original; + }; +}; + describe('FloorplanHeightPicker', () => { - it('renders 27 swatches', () => + afterEach(() => { - const { container } = render( {} } />); - const swatches = container.querySelectorAll('[data-testid^="swatch-"]'); - expect(swatches).toHaveLength(27); + cleanup(); }); - it('clicking a swatch fires onSelect with its height index', () => + it('renders the track + thumb with the current value', () => { + render( undefined } />); + + const thumb = screen.getByTestId('height-thumb'); + + expect(thumb).toBeInTheDocument(); + expect(thumb.textContent).toBe('12'); + }); + + it('clicking near the top of the track picks HEIGHT_BRUSH_MAX', () => + { + const restore = stubTrackGeometry(); const onSelect = vi.fn(); - const { container } = render(); - fireEvent.click(container.querySelector('[data-testid="swatch-5"]') as Element); - expect(onSelect).toHaveBeenCalledWith(5); + + render(); + + const track = screen.getByTestId('height-track'); + + fireEvent.pointerDown(track, { clientY: 0, button: 0 }); + + expect(onSelect).toHaveBeenCalledWith(26); + + restore(); }); - it('marks the selected swatch with data-selected', () => + it('clicking near the bottom of the track picks HEIGHT_BRUSH_MIN', () => { - const { container } = render( {} } />); - expect(container.querySelector('[data-testid="swatch-12"]')?.getAttribute('data-selected')).toBe('true'); - expect(container.querySelector('[data-testid="swatch-0"]')?.getAttribute('data-selected')).toBe('false'); + const restore = stubTrackGeometry(); + const onSelect = vi.fn(); + + render(); + + const track = screen.getByTestId('height-track'); + + fireEvent.pointerDown(track, { clientY: TRACK_HEIGHT, button: 0 }); + + expect(onSelect).toHaveBeenCalledWith(0); + + restore(); + }); + + it('clicking at the middle picks roughly the middle height', () => + { + const restore = stubTrackGeometry(); + const onSelect = vi.fn(); + + render(); + + const track = screen.getByTestId('height-track'); + + fireEvent.pointerDown(track, { clientY: TRACK_HEIGHT / 2, button: 0 }); + + // (1 - 0.5) * 26 = 13. The exact value depends on Math.round, + // which here lands on 13 for a half-track click. + expect(onSelect).toHaveBeenCalledWith(13); + + restore(); + }); + + it('does not fire onSelect when the picked height equals the current selection', () => + { + const restore = stubTrackGeometry(); + const onSelect = vi.fn(); + + render(); + + const track = screen.getByTestId('height-track'); + + fireEvent.pointerDown(track, { clientY: 0, button: 0 }); + + expect(onSelect).not.toHaveBeenCalled(); + + restore(); }); }); diff --git a/src/components/floorplan-editor/views/FloorplanHeightPicker.tsx b/src/components/floorplan-editor/views/FloorplanHeightPicker.tsx index 58762cc..f1341f4 100644 --- a/src/components/floorplan-editor/views/FloorplanHeightPicker.tsx +++ b/src/components/floorplan-editor/views/FloorplanHeightPicker.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react'; +import { FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { HEIGHT_BRUSH_MAX, HEIGHT_BRUSH_MIN } from '../state/constants'; import { tileFill } from '../state/selectors'; @@ -7,48 +7,137 @@ type Props = { onSelect: (h: number) => void; }; -const SWATCH_W = 20; -const SWATCH_H = 14; +const TRACK_W = 14; +const TRACK_H = 260; +const THUMB_DIAM = 24; +/** + * Vertical brush-height slider. The track is a top-down gradient + * built from the real tile-fill colours, so the user still sees + * which colour maps to which height — the swatch column it + * replaces communicated the same mapping a swatch at a time. + * The thumb shows the currently picked height as a number and is + * fully drag-aware (click anywhere on the track to jump, then + * drag without releasing). + */ export const FloorplanHeightPicker: FC = ({ selectedH, onSelect }) => { const count = HEIGHT_BRUSH_MAX - HEIGHT_BRUSH_MIN + 1; - const totalH = count * SWATCH_H; + const trackRef = useRef(null); + const [ isDragging, setIsDragging ] = useState(false); + + // Top of the gradient is HEIGHT_BRUSH_MAX (matches the legacy + // swatch column layout). Each step is `100 / (count - 1)` % of + // the track height. Building hard stops gives a discrete-step + // gradient (clear band per height) rather than a smooth blend + // — closer to the original swatches, easier for users to read. + const gradient = useMemo(() => + { + const stops: string[] = []; + for(let i = 0; i < count; i++) + { + const h = HEIGHT_BRUSH_MAX - i; + const fill = tileFill({ h, blocked: false }); + const startPct = (i / count) * 100; + const endPct = ((i + 1) / count) * 100; + + stops.push(`${ fill } ${ startPct.toFixed(2) }%`); + stops.push(`${ fill } ${ endPct.toFixed(2) }%`); + } + + return `linear-gradient(to bottom, ${ stops.join(', ') })`; + }, [ count ]); + + const heightFromClientY = useCallback((clientY: number): number | null => + { + const track = trackRef.current; + + if(!track) return null; + + const rect = track.getBoundingClientRect(); + + if(rect.height === 0) return null; + + const local = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height)); + const idx = Math.round(local * (count - 1)); + + return HEIGHT_BRUSH_MAX - idx; + }, [ count ]); + + const onPointerDown = useCallback((e: ReactPointerEvent) => + { + if(e.button !== 0) return; + + const next = heightFromClientY(e.clientY); + + if(next !== null && next !== selectedH) onSelect(next); + + setIsDragging(true); + }, [ heightFromClientY, onSelect, selectedH ]); + + // While dragging, listen on window so the slider keeps tracking + // even when the pointer leaves the narrow track strip. + useEffect(() => + { + if(!isDragging) return; + + const onMove = (e: PointerEvent) => + { + const next = heightFromClientY(e.clientY); + if(next !== null && next !== selectedH) onSelect(next); + }; + const onUp = () => setIsDragging(false); + + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + + return () => + { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + }; + }, [ isDragging, heightFromClientY, onSelect, selectedH ]); + + // Thumb centre as a % of the track height. At selectedH == + // HEIGHT_BRUSH_MAX the thumb sits at 0 % (top), at the min it + // sits at 100 % (bottom). Translating Y by -50 % then re- + // centres the circle on that point. + const thumbPct = ((HEIGHT_BRUSH_MAX - selectedH) / (count - 1)) * 100; + return ( -
- { selectedH } - +
+
- { Array.from({ length: count }, (_, i) => - { - const h = HEIGHT_BRUSH_MAX - i; - const y = i * SWATCH_H; - const fill = tileFill({ h, blocked: false }); - const isSelected = selectedH === h; - return ( - onSelect(h) } - style={ { cursor: 'pointer' } } - /> - ); - }) } - + { selectedH } +
); };