From 928620bde9cad7a0e22d10199070805f0d43f4e4 Mon Sep 17 00:00:00 2001 From: Dana Medical Developer Date: Tue, 20 Oct 2026 13:46:38 +0000 Subject: [PATCH] feat(web): style doctor specialty selector with accessible custom listbox --- web/src/components/SpecialtySelect.css | 165 +++++++++++++++++++++++++ web/src/components/SpecialtySelect.jsx | 149 ++++++++++++++++++++++ web/src/pages/DoctorsPage.css | 77 +----------- web/src/pages/DoctorsPage.jsx | 24 +--- 4 files changed, 318 insertions(+), 97 deletions(-) create mode 100644 web/src/components/SpecialtySelect.css create mode 100644 web/src/components/SpecialtySelect.jsx 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 ( +
+ + {expanded && ( +
+ +
    + {options.map((option, index) => ( +
  • setHighlighted(index)} + onMouseDown={(event) => event.preventDefault()} + onClick={() => choose(index)} + > + {option.label} + {option.count != null && } + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/web/src/pages/DoctorsPage.css b/web/src/pages/DoctorsPage.css index 85759df..8f4abc0 100644 --- a/web/src/pages/DoctorsPage.css +++ b/web/src/pages/DoctorsPage.css @@ -27,58 +27,7 @@ .doctors-filters__chips { display: flex; gap: 12px; -} - -.filter { - position: relative; - display: inline-flex; - height: 48px; - background: var(--surface); - border: 1px solid var(--chip-border); - border-radius: 13px; -} - -.filter select { - min-width: 230px; - height: 100%; - padding: 0 38px 0 15px; - appearance: none; - background: transparent; - border: 0; - outline: none; - font-weight: 500; - font-size: 14px; - line-height: 1.42; - color: var(--ink); - cursor: pointer; -} - -.filter select option { - color: var(--ink); - background: #fff; -} - -.filter img { - position: absolute; - right: 15px; - top: 50%; - transform: translateY(-50%); - pointer-events: none; -} - -.filter--active { - background: var(--navy); - border-color: var(--navy); -} - -.filter--active select { - font-weight: 600; - color: #fff; -} - -.filter:focus-within { - outline: 2px solid var(--coral); - outline-offset: 2px; + flex-shrink: 0; } .filter-search { @@ -161,30 +110,6 @@ } .doctors-filters__chips { gap: 8px; - overflow-x: auto; - padding-bottom: 8px; - } - .filter { - height: 37px; - border-width: 0.722px; - border-color: var(--field-border); - border-radius: var(--r-12); - } - .filter select { - min-width: 0; - padding: 0 30px 0 12px; - font-weight: 500; - line-height: 20px; - color: var(--navy); - } - .filter--active select { - font-weight: 500; - } - .filter img { - right: 12px; - width: 10px; - height: 10px; - opacity: 0.7; } .filter-search { flex-basis: auto; diff --git a/web/src/pages/DoctorsPage.jsx b/web/src/pages/DoctorsPage.jsx index 6ead01b..28c2ea7 100644 --- a/web/src/pages/DoctorsPage.jsx +++ b/web/src/pages/DoctorsPage.jsx @@ -1,32 +1,14 @@ import { useEffect, useState } from 'react'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import chevronWhite from '../assets/figma/icon-chevron-white.svg'; -import chevronDark from '../assets/figma/icon-chevron-dark.svg'; import searchIcon from '../assets/figma/icon-search-18.svg'; import { DoctorRow } from '../components/DoctorCard.jsx'; +import SpecialtySelect from '../components/SpecialtySelect.jsx'; import { api } from '../api.js'; import { useApi } from '../lib/useApi.js'; import { specialtyPath } from '../lib/links.js'; import { usePageTitle } from '../lib/usePageTitle.js'; import './DoctorsPage.css'; -function FilterSelect({ value, onChange, options, label }) { - const active = Boolean(value); - return ( - - ); -} - export default function DoctorsPage() { const { section } = useParams(); const navigate = useNavigate(); @@ -56,7 +38,7 @@ export default function DoctorsPage() { const specialtyOptions = [ { value: '', label: 'Все специализации' }, - ...(specialties.data || []).filter((s) => s.count > 0).map((s) => ({ value: s.slug, label: s.name })), + ...(specialties.data || []).filter((s) => s.count > 0).map((s) => ({ value: s.slug, label: s.name, count: s.count })), ]; const onSpecialty = (slug) => { @@ -88,7 +70,7 @@ export default function DoctorsPage() {
-