This commit is contained in:
2026-06-10 19:07:29 +03:00
parent 01538b7e69
commit 2fdefeeadd
21 changed files with 883 additions and 43 deletions

View File

@@ -0,0 +1,101 @@
.field {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.08em;
}
.trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
height: 48px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: var(--text-primary);
font-size: 14px;
font-family: var(--font-sans);
text-align: left;
padding: 0 16px;
cursor: pointer;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.trigger:focus-visible,
.triggerOpen {
border-color: var(--interactive);
box-shadow: 0 0 0 3px rgba(74, 109, 255, 0.15);
}
.value {
color: var(--text-primary);
}
.placeholder {
color: var(--text-secondary);
}
.arrow {
flex-shrink: 0;
color: var(--text-secondary);
font-size: 12px;
transition: transform 0.2s;
}
.arrowOpen {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 20;
margin: 0;
padding: 6px;
list-style: none;
background: var(--bg-mid);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
max-height: 280px;
overflow-y: auto;
}
.option {
padding: 11px 12px;
border-radius: 8px;
font-size: 14px;
color: var(--text-primary);
cursor: pointer;
transition: background-color 0.15s;
}
.option:hover {
background: rgba(255, 255, 255, 0.08);
}
.optionSelected {
background: rgba(91, 61, 184, 0.35);
}
.optionSelected:hover {
background: rgba(91, 61, 184, 0.45);
}

View File

@@ -0,0 +1,92 @@
import { useEffect, useRef, useState } from 'react'
import styles from './Select.module.css'
export interface SelectOption<T extends string | number = string> {
value: T
label: string
}
interface Props<T extends string | number> {
value: T
options: SelectOption<T>[]
onChange: (value: T) => void
label?: string
placeholder?: string
id?: string
disabled?: boolean
}
export function Select<T extends string | number>({
value,
options,
onChange,
label,
placeholder = 'Выберите',
id,
disabled,
}: Props<T>) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const selected = options.find((o) => o.value === value)
useEffect(() => {
if (!open) return
const handlePointer = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handlePointer)
document.addEventListener('keydown', handleKey)
return () => {
document.removeEventListener('mousedown', handlePointer)
document.removeEventListener('keydown', handleKey)
}
}, [open])
return (
<div className={styles.field} ref={ref}>
{label && (
<label className={styles.label} htmlFor={id}>
{label}
</label>
)}
<button
type="button"
id={id}
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`}
onClick={() => !disabled && setOpen((v) => !v)}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className={selected ? styles.value : styles.placeholder}>
{selected ? selected.label : placeholder}
</span>
<span className={`${styles.arrow} ${open ? styles.arrowOpen : ''}`} aria-hidden>
</span>
</button>
{open && (
<ul className={styles.dropdown} role="listbox">
{options.map((o) => (
<li
key={String(o.value)}
role="option"
aria-selected={o.value === value}
className={`${styles.option} ${o.value === value ? styles.optionSelected : ''}`}
onClick={() => {
onChange(o.value)
setOpen(false)
}}
>
{o.label}
</li>
))}
</ul>
)}
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { Select } from './Select'
export type { SelectOption } from './Select'