feat(web): style doctor specialty selector with accessible custom listbox

This commit is contained in:
Dana Medical Developer 2026-10-20 13:46:38 +00:00
parent 528d9766ed
commit 928620bde9
4 changed files with 318 additions and 97 deletions

View File

@ -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;
}
}

View File

@ -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 (
<div className={`specialty-select${value ? ' specialty-select--active' : ''}`} ref={root}>
<button
ref={trigger}
type="button"
role="combobox"
className="specialty-select__trigger"
aria-label={`${label}: ${options[selected]?.label || ''}`}
aria-expanded={expanded}
aria-haspopup="listbox"
aria-controls={expanded ? `${id}-list` : undefined}
aria-activedescendant={expanded ? `${id}-option-${highlighted}` : undefined}
onClick={() => expanded ? setExpanded(false) : show()}
onKeyDown={onKeyDown}
>
<span>{options[selected]?.label}</span>
<img className={expanded ? 'specialty-select__chevron--open' : ''} src={value ? chevronWhite : chevronDark} alt="" width="14" height="14" />
</button>
{expanded && (
<div className="specialty-select__panel">
<p className="specialty-select__heading" aria-hidden="true">Выберите специализацию</p>
<ul ref={list} id={`${id}-list`} role="listbox" aria-label={label} className="specialty-select__list">
{options.map((option, index) => (
<li
key={option.value}
id={`${id}-option-${index}`}
role="option"
aria-selected={option.value === value}
className={`specialty-select__option${index === highlighted ? ' specialty-select__option--highlighted' : ''}`}
onPointerMove={() => setHighlighted(index)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => choose(index)}
>
<span className="specialty-select__name">{option.label}</span>
{option.count != null && <span className="specialty-select__count" aria-hidden="true">{option.count}</span>}
<span className="specialty-select__check" aria-hidden="true">
{option.value === value && <svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="m3 8 3 3 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@ -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;

View File

@ -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 (
<label className={`filter${active ? ' filter--active' : ''}`}>
<span className="visually-hidden">{label}</span>
<select value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<img src={active ? chevronWhite : chevronDark} alt="" width="14" height="14" />
</label>
);
}
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() {
<div className="doctors-filters">
<div className="container doctors-filters__row">
<div className="doctors-filters__chips">
<FilterSelect
<SpecialtySelect
label="Специализация"
value={section && activeSpecialty ? section : ''}
onChange={onSpecialty}