mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-21 07:56:20 +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';
|
/* @vitest-environment jsdom */
|
||||||
import { fireEvent, render } from '@testing-library/react';
|
|
||||||
|
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { FloorplanHeightPicker } from './FloorplanHeightPicker';
|
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', () =>
|
describe('FloorplanHeightPicker', () =>
|
||||||
{
|
{
|
||||||
it('renders 27 swatches', () =>
|
afterEach(() =>
|
||||||
{
|
{
|
||||||
const { container } = render(<FloorplanHeightPicker selectedH={ 0 } onSelect={ () => {} } />);
|
cleanup();
|
||||||
const swatches = container.querySelectorAll('[data-testid^="swatch-"]');
|
|
||||||
expect(swatches).toHaveLength(27);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 onSelect = vi.fn();
|
||||||
const { container } = render(<FloorplanHeightPicker selectedH={ 0 } onSelect={ onSelect } />);
|
|
||||||
fireEvent.click(container.querySelector('[data-testid="swatch-5"]') as Element);
|
render(<FloorplanHeightPicker selectedH={ 0 } onSelect={ onSelect } />);
|
||||||
expect(onSelect).toHaveBeenCalledWith(5);
|
|
||||||
|
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={ () => {} } />);
|
const restore = stubTrackGeometry();
|
||||||
expect(container.querySelector('[data-testid="swatch-12"]')?.getAttribute('data-selected')).toBe('true');
|
const onSelect = vi.fn();
|
||||||
expect(container.querySelector('[data-testid="swatch-0"]')?.getAttribute('data-selected')).toBe('false');
|
|
||||||
|
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 { HEIGHT_BRUSH_MAX, HEIGHT_BRUSH_MIN } from '../state/constants';
|
||||||
import { tileFill } from '../state/selectors';
|
import { tileFill } from '../state/selectors';
|
||||||
|
|
||||||
@@ -7,48 +7,137 @@ type Props = {
|
|||||||
onSelect: (h: number) => void;
|
onSelect: (h: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SWATCH_W = 20;
|
const TRACK_W = 14;
|
||||||
const SWATCH_H = 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 }) =>
|
export const FloorplanHeightPicker: FC<Props> = ({ selectedH, onSelect }) =>
|
||||||
{
|
{
|
||||||
const count = HEIGHT_BRUSH_MAX - HEIGHT_BRUSH_MIN + 1;
|
const count = HEIGHT_BRUSH_MAX - HEIGHT_BRUSH_MIN + 1;
|
||||||
const totalH = count * SWATCH_H;
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
return (
|
const [ isDragging, setIsDragging ] = useState(false);
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<span className="text-xs">{ selectedH }</span>
|
// Top of the gradient is HEIGHT_BRUSH_MAX (matches the legacy
|
||||||
<svg
|
// swatch column layout). Each step is `100 / (count - 1)` % of
|
||||||
width={ SWATCH_W }
|
// the track height. Building hard stops gives a discrete-step
|
||||||
height={ totalH }
|
// gradient (clear band per height) rather than a smooth blend
|
||||||
viewBox={ `0 0 ${ SWATCH_W } ${ totalH }` }
|
// — closer to the original swatches, easier for users to read.
|
||||||
className="shrink-0 select-none"
|
const gradient = useMemo(() =>
|
||||||
role="listbox"
|
{
|
||||||
aria-label="Brush height"
|
const stops: string[] = [];
|
||||||
>
|
for(let i = 0; i < count; i++)
|
||||||
{ Array.from({ length: count }, (_, i) =>
|
|
||||||
{
|
{
|
||||||
const h = HEIGHT_BRUSH_MAX - i;
|
const h = HEIGHT_BRUSH_MAX - i;
|
||||||
const y = i * SWATCH_H;
|
|
||||||
const fill = tileFill({ h, blocked: false });
|
const fill = tileFill({ h, blocked: false });
|
||||||
const isSelected = selectedH === h;
|
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 (
|
return (
|
||||||
<rect
|
<div
|
||||||
key={ h }
|
className="relative shrink-0 select-none touch-none"
|
||||||
data-testid={ `swatch-${ h }` }
|
style={ { width: THUMB_DIAM + 4, height: TRACK_H } }
|
||||||
data-selected={ isSelected ? 'true' : 'false' }
|
role="slider"
|
||||||
x={ 0 }
|
aria-label="Altezza pennello"
|
||||||
y={ y }
|
aria-valuemin={ HEIGHT_BRUSH_MIN }
|
||||||
width={ SWATCH_W }
|
aria-valuemax={ HEIGHT_BRUSH_MAX }
|
||||||
height={ SWATCH_H }
|
aria-valuenow={ selectedH }
|
||||||
fill={ fill }
|
>
|
||||||
stroke={ isSelected ? '#fff' : 'rgba(0,0,0,0.3)' }
|
<div
|
||||||
strokeWidth={ isSelected ? 2 : 0.5 }
|
ref={ trackRef }
|
||||||
onClick={ () => onSelect(h) }
|
data-testid="height-track"
|
||||||
style={ { cursor: 'pointer' } }
|
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"
|
||||||
</svg>
|
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 }%`
|
||||||
|
} }
|
||||||
|
>
|
||||||
|
{ selectedH }
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user