number input and select

This commit is contained in:
2026-01-23 20:02:00 +02:00
parent d69fde8902
commit 9fab5f220d
2 changed files with 105 additions and 0 deletions
@@ -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 (
<div className={`cv-number ${className || ''}`} style={style}
role="spinbutton" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max} aria-label={ariaLabel}>
<button className="cv-number-btn" onClick={() => onChange(clamp(value - 1))} type="button" disabled={value <= min} aria-label="Decrease">-</button>
<span className="cv-number-value">{value}</span>
<button className="cv-number-btn" onClick={() => onChange(clamp(value + 1))} type="button" disabled={value >= max} aria-label="Increase">+</button>
</div>
)
}
+83
View File
@@ -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<HTMLButtonElement>(null)
const dropRef = useRef<HTMLDivElement>(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 (
<div className={`cv-select ${className || ''}`} style={style}>
<button className="cv-select-trigger" ref={triggerRef} onClick={() => setOpen(!open)} type="button"
aria-haspopup="listbox" aria-expanded={open} aria-controls={open ? listboxId : undefined}>
<span>{selected?.label || String(value)}</span>
<IconChevronDownFilled size={10} style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} />
</button>
{open && createPortal(
<div className="cv-select-dropdown" ref={dropRef} id={listboxId} role="listbox"
style={{ top: pos.top, left: pos.left, minWidth: pos.width }}>
{options.map(o => (
<button
key={String(o.value)}
role="option"
aria-selected={String(o.value) === String(value)}
className={`cv-select-option ${String(o.value) === String(value) ? 'cv-select-option-active' : ''}`}
onClick={() => { onChange(o.value); setOpen(false) }}
type="button"
>
{o.label}
</button>
))}
</div>,
document.body,
)}
</div>
)
}