feat: gate sectioon

This commit is contained in:
2026-07-31 13:24:24 +02:00
parent 06710b68d4
commit daf2bf32aa
18 changed files with 717 additions and 68 deletions
+76 -11
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import './VerticalSlider.css';
@@ -12,13 +12,33 @@ type VerticalSliderProps = {
value: number;
onChange?: (value: number) => Promise<boolean>;
/**
* Fires synchronously on every live value change — while dragging, on each
* keypress — *before* the value is committed via onChange. Use this to
* drive UI that should track the slider in real time (e.g. a preview
* graph), without waiting for the (async) commit round-trip.
*/
onInput?: (value: number) => void;
unit?: string;
minAspectHeight?: number;
maxAspectHeight?: number;
className?: string;
style?: React.CSSProperties;
};
// Design constants: at the original 70x240 ratio the track is 210 units tall
// and holds 15 ticks (2 units tall) laid out with space-between, which works
// out to a ~13 unit gap -> a ~15 unit center-to-center pitch. We reuse that
// pitch to decide how many ticks fit in whatever track height we actually get.
const DESIGN_TRACK_WIDTH_UNITS = 70;
const TICK_HEIGHT_UNITS = 2;
const TICK_PITCH_UNITS = 15;
const MIN_TICKS = 3;
const MAX_TICKS = 15;
function getDecimals(n: number) {
const s = n.toString();
const i = s.indexOf('.');
@@ -33,19 +53,59 @@ export default function VerticalSlider({
switchStep,
value,
onChange,
onInput,
unit = '',
minAspectHeight = 180,
maxAspectHeight = 240,
className,
style,
}: VerticalSliderProps) {
const [editingText, setEditingText] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const keyboardValue = useRef<number | null>(null);
const [displayValue, setDisplayValue] = useState(value);
const dragValue = useRef(value);
const dragging = useRef(false);
const thumbRef = useRef<HTMLDivElement>(null);
const keyboardEditing = useRef(false);
const [tickCount, setTickCount] = useState(MAX_TICKS);
useEffect(() => {
if (dragging.current || keyboardEditing.current || editingText !== null) {
return;
}
setDisplayValue(value);
dragValue.current = value;
}, [value]);
useEffect(() => {
const el = trackRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const recompute = () => {
const w = el.clientWidth;
const h = el.clientHeight;
if (!w || !h) return;
// .gauge-track's width is always calc(70 * --s), so this recovers --s in px.
const s = w / DESIGN_TRACK_WIDTH_UNITS;
const heightUnits = h / s;
const raw = 1 + (heightUnits - TICK_HEIGHT_UNITS) / TICK_PITCH_UNITS;
const n = Math.max(MIN_TICKS, Math.min(MAX_TICKS, Math.round(raw)));
setTickCount(n);
};
recompute();
const observer = new ResizeObserver(recompute);
observer.observe(el);
return () => observer.disconnect();
}, []);
function getStep(value: number) {
if (switchStepValue !== undefined && switchStep !== undefined && value >= switchStepValue) {
@@ -75,6 +135,7 @@ export default function VerticalSlider({
dragValue.current = next;
setDisplayValue(next);
onInput?.(next);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
@@ -120,6 +181,7 @@ export default function VerticalSlider({
if (success === false) {
setDisplayValue(value);
dragValue.current = value;
onInput?.(value);
}
}
@@ -155,6 +217,7 @@ export default function VerticalSlider({
if (success === false) {
setDisplayValue(value);
dragValue.current = value;
onInput?.(value);
}
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
@@ -170,12 +233,14 @@ export default function VerticalSlider({
const next = normalizeValue(parsed);
setDisplayValue(next);
onInput?.(next);
const success = await onChange?.(next);
if (success === false) {
setDisplayValue(value);
dragValue.current = value;
onInput?.(value);
}
}
}
@@ -185,17 +250,17 @@ export default function VerticalSlider({
const percent = (displayValue - min) / (max - min);
const containerStyle = {
'--min-height-units': minAspectHeight,
'--max-height-units': maxAspectHeight,
} as React.CSSProperties;
return (
<div
className={['gauge-viewport', className].filter(Boolean).join(' ')}
style={style}
>
<div
className="gauge-container"
>
<div className="gauge-track">
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
<div className="gauge-container" style={containerStyle}>
<div className="gauge-track" ref={trackRef}>
<div className="ticks left">
{Array.from({ length: 15 }).map((_, i) => (
{Array.from({ length: tickCount }).map((_, i) => (
<span key={i} />
))}
</div>
@@ -216,7 +281,7 @@ export default function VerticalSlider({
</div>
<div className="ticks right">
{Array.from({ length: 15 }).map((_, i) => (
{Array.from({ length: tickCount }).map((_, i) => (
<span key={i} />
))}
</div>