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,
+ )}
+
+ )
+}