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.
This commit is contained in:
simoleo89
2026-05-24 21:27:22 +02:00
parent b540b163c6
commit abf43d86c3
2 changed files with 236 additions and 51 deletions
@@ -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(<FloorplanHeightPicker selectedH={ 0 } onSelect={ () => {} } />);
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(<FloorplanHeightPicker selectedH={ 12 } onSelect={ () => 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(<FloorplanHeightPicker selectedH={ 0 } onSelect={ onSelect } />);
fireEvent.click(container.querySelector('[data-testid="swatch-5"]') as Element);
expect(onSelect).toHaveBeenCalledWith(5);
render(<FloorplanHeightPicker selectedH={ 0 } onSelect={ onSelect } />);
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(<FloorplanHeightPicker selectedH={ 12 } onSelect={ () => {} } />);
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(<FloorplanHeightPicker selectedH={ 26 } onSelect={ onSelect } />);
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(<FloorplanHeightPicker selectedH={ 0 } onSelect={ onSelect } />);
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(<FloorplanHeightPicker selectedH={ 26 } onSelect={ onSelect } />);
const track = screen.getByTestId('height-track');
fireEvent.pointerDown(track, { clientY: 0, button: 0 });
expect(onSelect).not.toHaveBeenCalled();
restore();
});
});
@@ -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<Props> = ({ selectedH, onSelect }) =>
{
const count = HEIGHT_BRUSH_MAX - HEIGHT_BRUSH_MIN + 1;
const totalH = count * SWATCH_H;
const trackRef = useRef<HTMLDivElement>(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<HTMLDivElement>) =>
{
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 (
<div className="flex flex-col items-center">
<span className="text-xs">{ selectedH }</span>
<svg
width={ SWATCH_W }
height={ totalH }
viewBox={ `0 0 ${ SWATCH_W } ${ totalH }` }
className="shrink-0 select-none"
role="listbox"
aria-label="Brush height"
<div
className="relative shrink-0 select-none touch-none"
style={ { width: THUMB_DIAM + 4, height: TRACK_H } }
role="slider"
aria-label="Altezza pennello"
aria-valuemin={ HEIGHT_BRUSH_MIN }
aria-valuemax={ HEIGHT_BRUSH_MAX }
aria-valuenow={ selectedH }
>
<div
ref={ trackRef }
data-testid="height-track"
className="absolute left-1/2 -translate-x-1/2 rounded-full border border-zinc-400 shadow-inner cursor-pointer overflow-hidden"
style={ {
width: TRACK_W,
height: TRACK_H,
background: gradient
} }
onPointerDown={ onPointerDown }
/>
<div
data-testid="height-thumb"
className={ `absolute left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-yellow-300 border-2 ${ isDragging ? 'border-zinc-800' : 'border-zinc-700' } shadow-md flex items-center justify-center text-[10px] font-bold text-zinc-900 tabular-nums pointer-events-none` }
style={ {
width: THUMB_DIAM,
height: THUMB_DIAM,
top: `${ thumbPct }%`
} }
>
{ 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 (
<rect
key={ h }
data-testid={ `swatch-${ h }` }
data-selected={ isSelected ? 'true' : 'false' }
x={ 0 }
y={ y }
width={ SWATCH_W }
height={ SWATCH_H }
fill={ fill }
stroke={ isSelected ? '#fff' : 'rgba(0,0,0,0.3)' }
strokeWidth={ isSelected ? 2 : 0.5 }
onClick={ () => onSelect(h) }
style={ { cursor: 'pointer' } }
/>
);
}) }
</svg>
{ selectedH }
</div>
</div>
);
};