mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user