diff --git a/web/src/components/SpecialtySelect.css b/web/src/components/SpecialtySelect.css new file mode 100644 index 0000000..f4c07cf --- /dev/null +++ b/web/src/components/SpecialtySelect.css @@ -0,0 +1,165 @@ +.specialty-select { + position: relative; + width: 260px; + max-width: 100%; +} + +.specialty-select__trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: 100%; + min-height: 48px; + padding: 12px 16px; + border: 1px solid var(--chip-border); + border-radius: var(--r-14); + background: var(--surface); + color: var(--navy); + font-size: 14px; + font-weight: 600; + text-align: left; + line-height: 1.45; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.specialty-select__trigger:hover { + border-color: var(--navy); + background: var(--field); +} + +.specialty-select--active .specialty-select__trigger { + border-color: var(--navy); + background: var(--navy); + color: white; +} + +.specialty-select--active .specialty-select__trigger:hover { + background: var(--navy-deep); +} + +.specialty-select__trigger img { + flex-shrink: 0; + transition: transform 0.15s ease; +} + +.specialty-select__chevron--open { + transform: rotate(180deg); +} + +.specialty-select__trigger:focus-visible { + outline: 2px solid var(--coral); + outline-offset: 3px; +} + +.specialty-select__panel { + position: absolute; + top: calc(100% + 10px); + left: 0; + z-index: 35; + width: 340px; + max-width: calc(100vw - 32px); + padding: 8px; + border: 1px solid var(--border-soft); + border-radius: var(--r-20); + background: var(--surface); + box-shadow: 0 12px 40px rgba(44, 73, 95, 0.15), 0 2px 6px rgba(44, 73, 95, 0.04); +} + +.specialty-select__heading { + margin: 0 6px 6px; + padding: 10px 6px 12px; + border-bottom: 1px solid var(--soft-2); + color: var(--text-muted-2); + font-size: 12px; + font-weight: 500; + line-height: 1.4; +} + +.specialty-select__list { + position: relative; + max-height: min(330px, 55dvh); + margin: 0; + padding: 0 3px 0 0; + overflow-y: auto; + overscroll-behavior: contain; + list-style: none; + scrollbar-width: thin; + scrollbar-color: var(--chip-border) transparent; +} + +.specialty-select__option { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 10px 12px; + border-radius: var(--r-12); + color: var(--navy); + font-size: 14px; + font-weight: 500; + line-height: 1.45; + cursor: pointer; +} + +.specialty-select__option + .specialty-select__option { + margin-top: 3px; +} + +.specialty-select__option--highlighted { + background: var(--field); +} + +.specialty-select__option[aria-selected='true'] { + background: #fff0ed; + font-weight: 600; +} + +.specialty-select__name { + flex: 1; + min-width: 0; +} + +.specialty-select__count { + min-width: 24px; + padding: 2px 6px; + border-radius: var(--r-8); + background: var(--soft-2); + color: var(--text-muted); + text-align: center; + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.specialty-select__check { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; + border-radius: 50%; +} + +[aria-selected='true'] > .specialty-select__check { + background: var(--coral); + color: white; +} + +@media (max-width: 767px) { + .specialty-select, + .specialty-select__panel { + width: 100%; + } + + .specialty-select__option { + min-height: 46px; + } +} + +@media (prefers-reduced-motion: reduce) { + .specialty-select__trigger, + .specialty-select__trigger img { + transition: none; + } +} diff --git a/web/src/components/SpecialtySelect.jsx b/web/src/components/SpecialtySelect.jsx new file mode 100644 index 0000000..abcc1a1 --- /dev/null +++ b/web/src/components/SpecialtySelect.jsx @@ -0,0 +1,149 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import chevronWhite from '../assets/figma/icon-chevron-white.svg'; +import chevronDark from '../assets/figma/icon-chevron-dark.svg'; +import './SpecialtySelect.css'; + +export default function SpecialtySelect({ value, onChange, options, label }) { + const [expanded, setExpanded] = useState(false); + const [highlighted, setHighlighted] = useState(0); + const root = useRef(null); + const trigger = useRef(null); + const list = useRef(null); + const typed = useRef({ text: '', time: 0 }); + const id = useId(); + const selected = Math.max(0, options.findIndex((option) => option.value === value)); + + useEffect(() => { + if (!expanded) return undefined; + const closeOutside = (event) => { + if (!root.current?.contains(event.target)) setExpanded(false); + }; + document.addEventListener('pointerdown', closeOutside); + document.addEventListener('focusin', closeOutside); + return () => { + document.removeEventListener('pointerdown', closeOutside); + document.removeEventListener('focusin', closeOutside); + }; + }, [expanded]); + + useEffect(() => { + if (!expanded) return; + const option = list.current?.children[highlighted]; + if (option) { + const top = option.offsetTop; + const bottom = top + option.offsetHeight; + const scroll = list.current.scrollTop; + if (top < scroll) list.current.scrollTop = top; + else if (bottom > scroll + list.current.clientHeight) list.current.scrollTop = bottom - list.current.clientHeight; + } + }, [expanded, highlighted]); + + // A route change (including Back/Forward) must never leave an old menu open. + useEffect(() => setExpanded(false), [value]); + + const show = (index = selected) => { + setHighlighted(index); + setExpanded(true); + typed.current = { text: '', time: 0 }; + }; + const choose = (index) => { + const option = options[index]; + if (!option) return; + setExpanded(false); + trigger.current?.focus(); + if (option.value !== value) onChange(option.value); + }; + + const onKeyDown = (event) => { + if (event.key === 'Escape') { + if (expanded) { + event.preventDefault(); + event.stopPropagation(); + setExpanded(false); + } + return; + } + if (event.key === 'Tab') { + setExpanded(false); + return; + } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + if (!expanded) show(); + else setHighlighted((index) => (index + (event.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length); + return; + } + if (event.key === 'Home' || event.key === 'End') { + event.preventDefault(); + show(event.key === 'Home' ? 0 : options.length - 1); + return; + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (expanded) choose(highlighted); + else show(); + return; + } + if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { + event.preventDefault(); + const now = Date.now(); + const letter = event.key.toLocaleLowerCase('ru'); + const prefix = now - typed.current.time < 700 ? typed.current.text + letter : letter; + typed.current = { text: prefix, time: now }; + const start = expanded ? highlighted : selected; + const match = options.findIndex((_, offset) => { + const index = (start + offset + 1) % options.length; + return options[index].label.toLocaleLowerCase('ru').startsWith(prefix); + }); + setExpanded(true); + if (match !== -1) setHighlighted((start + match + 1) % options.length); + else if (!expanded) setHighlighted(selected); + } + }; + + return ( +