From 9fab5f220da4a15eeefb4823ec9df0a6ee010f4b Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 23 Jan 2026 20:02:00 +0200 Subject: [PATCH] number input and select --- frontend/src/components/ui/NumberInput.tsx | 22 ++++++ frontend/src/components/ui/Select.tsx | 83 ++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 frontend/src/components/ui/NumberInput.tsx create mode 100644 frontend/src/components/ui/Select.tsx diff --git a/frontend/src/components/ui/NumberInput.tsx b/frontend/src/components/ui/NumberInput.tsx new file mode 100644 index 0000000..17aa280 --- /dev/null +++ b/frontend/src/components/ui/NumberInput.tsx @@ -0,0 +1,22 @@ +interface Props { + value: number + min?: number + max?: number + onChange: (value: number) => void + className?: string + style?: React.CSSProperties + 'aria-label'?: string +} + +export default function NumberInput({ value, min = 0, max = 999, onChange, className, style, 'aria-label': ariaLabel }: Props) { + const clamp = (n: number) => Math.min(max, Math.max(min, n)) + + return ( +
+ + {value} + +
+ ) +} diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx new file mode 100644 index 0000000..edb0aa8 --- /dev/null +++ b/frontend/src/components/ui/Select.tsx @@ -0,0 +1,83 @@ +import { useState, useRef, useEffect, useCallback, useId } from 'react' +import { createPortal } from 'react-dom' +import { IconChevronDownFilled } from '@tabler/icons-react' + +interface Option { + value: string | number + label: string +} + +interface Props { + value: string | number + options: Option[] + onChange: (value: string | number) => void + className?: string + style?: React.CSSProperties +} + +export default function Select({ value, options, onChange, className, style }: Props) { + const [open, setOpen] = useState(false) + const triggerRef = useRef(null) + const dropRef = useRef(null) + const [pos, setPos] = useState({ top: 0, left: 0, width: 0 }) + const listboxId = useId() + + const selected = options.find(o => String(o.value) === String(value)) + + const updatePos = useCallback(() => { + if (!triggerRef.current) return + const rect = triggerRef.current.getBoundingClientRect() + setPos({ top: rect.bottom, left: rect.left, width: rect.width }) + }, []) + + useEffect(() => { + if (!open) return + updatePos() + function onDown(e: MouseEvent) { + const target = e.target + if (target instanceof Node && dropRef.current && !dropRef.current.contains(target) && + triggerRef.current && !triggerRef.current.contains(target)) { + setOpen(false) + } + } + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false) + } + window.addEventListener('scroll', updatePos, true) + document.addEventListener('mousedown', onDown) + document.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('scroll', updatePos, true) + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey) + } + }, [open, updatePos]) + + return ( +
+ + {open && createPortal( +
+ {options.map(o => ( + + ))} +
, + document.body, + )} +
+ ) +}