From 4d127a038bf768eded68aae3cfa5c1b12241bdac Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Mar 2026 00:55:15 +0100 Subject: chore: normalize line endings --- apps/web/src/components/ui/number-field.tsx | 636 ++++++++++++++-------------- 1 file changed, 318 insertions(+), 318 deletions(-) (limited to 'apps/web/src/components/ui/number-field.tsx') diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx index acedff10..eae16b9b 100644 --- a/apps/web/src/components/ui/number-field.tsx +++ b/apps/web/src/components/ui/number-field.tsx @@ -1,318 +1,318 @@ -"use client"; - -import { cn } from "@/utils/ui"; -import { clamp } from "@/utils/math"; -import { useRef, useState, useLayoutEffect, type ComponentProps } from "react"; -import { useFocusLock } from "@/hooks/use-focus-lock"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { ArrowTurnBackwardIcon } from "@hugeicons/core-free-icons"; - -const SUFFIX_GAP_PX = 6; - -const DRAG_SENSITIVITIES = { - default: 1, - slow: 0.5, -} as const; - -type DragSensitivity = "default" | "slow"; - -type ScrubRange = { - from: number; - to: number; - pixelsPerUnit: number; -}; - -type ScrubClamp = { - min?: number; - max?: number; -}; - -function clampScrubValue({ - value, - min, - max, -}: { - value: number; - min?: number; - max?: number; -}): number { - if (min != null && max != null) return clamp({ value, min, max }); - if (min != null) return Math.max(min, value); - if (max != null) return Math.min(max, value); - return value; -} - -function getActiveRange({ - value, - direction, - ranges, -}: { - value: number; - direction: number; - ranges: readonly ScrubRange[]; -}): ScrubRange | undefined { - return ranges.find((range) => - direction > 0 - ? value >= range.from && value < range.to - : value > range.from && value <= range.to, - ); -} - -function scrubAcrossRanges({ - startValue, - pixelDelta, - ranges, - min, - max, -}: { - startValue: number; - pixelDelta: number; - ranges: readonly ScrubRange[]; - min?: number; - max?: number; -}): number { - let currentValue = clampScrubValue({ value: startValue, min, max }); - let remainingPixels = pixelDelta; - - while (remainingPixels !== 0) { - const direction = Math.sign(remainingPixels); - - const range = getActiveRange({ value: currentValue, direction, ranges }); - if (!range) break; - - const boundary = direction > 0 ? range.to : range.from; - const pixelsToBoundary = - Math.abs(boundary - currentValue) * range.pixelsPerUnit; - - if (Math.abs(remainingPixels) <= pixelsToBoundary) { - currentValue += remainingPixels / range.pixelsPerUnit; - break; - } - - currentValue = boundary; - remainingPixels -= direction * pixelsToBoundary; - } - - return clampScrubValue({ value: currentValue, min, max }); -} - -interface NumberFieldProps - extends Omit, "size" | "type"> { - icon?: React.ReactNode; - suffix?: string; - suffixClassName?: string; - dragSensitivity?: DragSensitivity; - scrubRanges?: readonly ScrubRange[]; - scrubClamp?: ScrubClamp; - onScrub?: (value: number) => void; - onScrubEnd?: () => void; - allowExpressions?: boolean; - onReset?: () => void; - isDefault?: boolean; -} - -function NumberField({ - className, - icon, - suffix, - suffixClassName, - disabled, - dragSensitivity = "default", - scrubRanges, - scrubClamp, - onScrub, - onScrubEnd, - value, - allowExpressions = true, - onKeyDown, - onFocus, - onBlur, - onMouseDown, - onReset, - isDefault = false, - ref, - ...props -}: NumberFieldProps & { ref?: React.Ref }) { - const iconRef = useRef(null); - const inputRef = useRef(null); - const ghostRef = useRef(null); - const startValueRef = useRef(0); - const cumulativeDeltaRef = useRef(0); - const [isInputFocused, setIsInputFocused] = useState(false); - const [suffixLeft, setSuffixLeft] = useState(0); - const ghostValue = Array.isArray(value) ? value.join(", ") : String(value ?? ""); - - useLayoutEffect(() => { - if (!suffix) { - setSuffixLeft(0); - return; - } - if (!ghostRef.current || !inputRef.current) return; - if (ghostRef.current.textContent !== ghostValue) { - ghostRef.current.textContent = ghostValue; - } - const paddingLeft = - parseFloat(getComputedStyle(inputRef.current).paddingLeft) || 0; - setSuffixLeft(paddingLeft + ghostRef.current.offsetWidth); - }, [ghostValue, suffix]); - - const { containerRef: wrapperRef } = useFocusLock({ - isActive: isInputFocused, - onDismiss: () => inputRef.current?.blur(), - cursor: "text", - allowSelector: "input, textarea, [contenteditable]", - }); - - const handleIconPointerDown = (event: React.PointerEvent) => { - if (!onScrub || disabled || event.button !== 0) return; - const parsed = parseFloat(String(value ?? "0")); - startValueRef.current = Number.isNaN(parsed) ? 0 : parsed; - cumulativeDeltaRef.current = 0; - let hasReceivedFirstMove = false; - iconRef.current?.requestPointerLock(); - - const handlePointerMove = (moveEvent: PointerEvent) => { - // first movementX after pointer lock often contains a bogus warp delta - if (!hasReceivedFirstMove) { - hasReceivedFirstMove = true; - return; - } - cumulativeDeltaRef.current += moveEvent.movementX; - const newValue = scrubRanges - ? scrubAcrossRanges({ - startValue: startValueRef.current, - pixelDelta: cumulativeDeltaRef.current, - ranges: scrubRanges, - min: scrubClamp?.min, - max: scrubClamp?.max, - }) - : startValueRef.current + - cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity]; - onScrub(newValue); - }; - - const handlePointerUp = () => { - document.removeEventListener("pointermove", handlePointerMove); - document.removeEventListener("pointerup", handlePointerUp); - document.exitPointerLock(); - onScrubEnd?.(); - }; - - document.addEventListener("pointermove", handlePointerMove); - document.addEventListener("pointerup", handlePointerUp); - }; - - const canScrub = Boolean(icon && onScrub); - - const inputNode = ( - { - const inputElement = event.currentTarget; - const shouldPreventNativeCaretPlacement = - event.button === 0 && document.activeElement !== inputElement; - if (shouldPreventNativeCaretPlacement) { - event.preventDefault(); - inputElement.focus(); - inputElement.select(); - } - onMouseDown?.(event); - }} - onFocus={(event) => { - setIsInputFocused(true); - event.currentTarget.select(); - onFocus?.(event); - }} - onKeyDown={(event) => { - const shouldBlurInput = event.key === "Enter" || event.key === "Escape"; - if (shouldBlurInput) event.currentTarget.blur(); - onKeyDown?.(event); - }} - onBlur={(event) => { - setIsInputFocused(false); - onBlur?.(event); - }} - {...props} - /> - ); - - return ( -
- {icon && - (canScrub ? ( - - ) : ( - - {icon} - - ))} - - {inputNode} - {suffix && ( - <> - {/* Ghost mirrors value text to measure width for suffix positioning */} - - - {suffix} - - - )} - - {onReset && !isDefault && ( -
- -
- )} -
- ); -} - -export { NumberField }; +"use client"; + +import { cn } from "@/utils/ui"; +import { clamp } from "@/utils/math"; +import { useRef, useState, useLayoutEffect, type ComponentProps } from "react"; +import { useFocusLock } from "@/hooks/use-focus-lock"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowTurnBackwardIcon } from "@hugeicons/core-free-icons"; + +const SUFFIX_GAP_PX = 6; + +const DRAG_SENSITIVITIES = { + default: 1, + slow: 0.5, +} as const; + +type DragSensitivity = "default" | "slow"; + +type ScrubRange = { + from: number; + to: number; + pixelsPerUnit: number; +}; + +type ScrubClamp = { + min?: number; + max?: number; +}; + +function clampScrubValue({ + value, + min, + max, +}: { + value: number; + min?: number; + max?: number; +}): number { + if (min != null && max != null) return clamp({ value, min, max }); + if (min != null) return Math.max(min, value); + if (max != null) return Math.min(max, value); + return value; +} + +function getActiveRange({ + value, + direction, + ranges, +}: { + value: number; + direction: number; + ranges: readonly ScrubRange[]; +}): ScrubRange | undefined { + return ranges.find((range) => + direction > 0 + ? value >= range.from && value < range.to + : value > range.from && value <= range.to, + ); +} + +function scrubAcrossRanges({ + startValue, + pixelDelta, + ranges, + min, + max, +}: { + startValue: number; + pixelDelta: number; + ranges: readonly ScrubRange[]; + min?: number; + max?: number; +}): number { + let currentValue = clampScrubValue({ value: startValue, min, max }); + let remainingPixels = pixelDelta; + + while (remainingPixels !== 0) { + const direction = Math.sign(remainingPixels); + + const range = getActiveRange({ value: currentValue, direction, ranges }); + if (!range) break; + + const boundary = direction > 0 ? range.to : range.from; + const pixelsToBoundary = + Math.abs(boundary - currentValue) * range.pixelsPerUnit; + + if (Math.abs(remainingPixels) <= pixelsToBoundary) { + currentValue += remainingPixels / range.pixelsPerUnit; + break; + } + + currentValue = boundary; + remainingPixels -= direction * pixelsToBoundary; + } + + return clampScrubValue({ value: currentValue, min, max }); +} + +interface NumberFieldProps + extends Omit, "size" | "type"> { + icon?: React.ReactNode; + suffix?: string; + suffixClassName?: string; + dragSensitivity?: DragSensitivity; + scrubRanges?: readonly ScrubRange[]; + scrubClamp?: ScrubClamp; + onScrub?: (value: number) => void; + onScrubEnd?: () => void; + allowExpressions?: boolean; + onReset?: () => void; + isDefault?: boolean; +} + +function NumberField({ + className, + icon, + suffix, + suffixClassName, + disabled, + dragSensitivity = "default", + scrubRanges, + scrubClamp, + onScrub, + onScrubEnd, + value, + allowExpressions = true, + onKeyDown, + onFocus, + onBlur, + onMouseDown, + onReset, + isDefault = false, + ref, + ...props +}: NumberFieldProps & { ref?: React.Ref }) { + const iconRef = useRef(null); + const inputRef = useRef(null); + const ghostRef = useRef(null); + const startValueRef = useRef(0); + const cumulativeDeltaRef = useRef(0); + const [isInputFocused, setIsInputFocused] = useState(false); + const [suffixLeft, setSuffixLeft] = useState(0); + const ghostValue = Array.isArray(value) ? value.join(", ") : String(value ?? ""); + + useLayoutEffect(() => { + if (!suffix) { + setSuffixLeft(0); + return; + } + if (!ghostRef.current || !inputRef.current) return; + if (ghostRef.current.textContent !== ghostValue) { + ghostRef.current.textContent = ghostValue; + } + const paddingLeft = + parseFloat(getComputedStyle(inputRef.current).paddingLeft) || 0; + setSuffixLeft(paddingLeft + ghostRef.current.offsetWidth); + }, [ghostValue, suffix]); + + const { containerRef: wrapperRef } = useFocusLock({ + isActive: isInputFocused, + onDismiss: () => inputRef.current?.blur(), + cursor: "text", + allowSelector: "input, textarea, [contenteditable]", + }); + + const handleIconPointerDown = (event: React.PointerEvent) => { + if (!onScrub || disabled || event.button !== 0) return; + const parsed = parseFloat(String(value ?? "0")); + startValueRef.current = Number.isNaN(parsed) ? 0 : parsed; + cumulativeDeltaRef.current = 0; + let hasReceivedFirstMove = false; + iconRef.current?.requestPointerLock(); + + const handlePointerMove = (moveEvent: PointerEvent) => { + // first movementX after pointer lock often contains a bogus warp delta + if (!hasReceivedFirstMove) { + hasReceivedFirstMove = true; + return; + } + cumulativeDeltaRef.current += moveEvent.movementX; + const newValue = scrubRanges + ? scrubAcrossRanges({ + startValue: startValueRef.current, + pixelDelta: cumulativeDeltaRef.current, + ranges: scrubRanges, + min: scrubClamp?.min, + max: scrubClamp?.max, + }) + : startValueRef.current + + cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity]; + onScrub(newValue); + }; + + const handlePointerUp = () => { + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerUp); + document.exitPointerLock(); + onScrubEnd?.(); + }; + + document.addEventListener("pointermove", handlePointerMove); + document.addEventListener("pointerup", handlePointerUp); + }; + + const canScrub = Boolean(icon && onScrub); + + const inputNode = ( + { + const inputElement = event.currentTarget; + const shouldPreventNativeCaretPlacement = + event.button === 0 && document.activeElement !== inputElement; + if (shouldPreventNativeCaretPlacement) { + event.preventDefault(); + inputElement.focus(); + inputElement.select(); + } + onMouseDown?.(event); + }} + onFocus={(event) => { + setIsInputFocused(true); + event.currentTarget.select(); + onFocus?.(event); + }} + onKeyDown={(event) => { + const shouldBlurInput = event.key === "Enter" || event.key === "Escape"; + if (shouldBlurInput) event.currentTarget.blur(); + onKeyDown?.(event); + }} + onBlur={(event) => { + setIsInputFocused(false); + onBlur?.(event); + }} + {...props} + /> + ); + + return ( +
+ {icon && + (canScrub ? ( + + ) : ( + + {icon} + + ))} + + {inputNode} + {suffix && ( + <> + {/* Ghost mirrors value text to measure width for suffix positioning */} + + + {suffix} + + + )} + + {onReset && !isDefault && ( +
+ +
+ )} +
+ ); +} + +export { NumberField }; -- cgit v1.3.1