feat(web): style doctor specialty selector with accessible custom listbox
This commit is contained in:
parent
528d9766ed
commit
928620bde9
165
web/src/components/SpecialtySelect.css
Normal file
165
web/src/components/SpecialtySelect.css
Normal 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;
|
||||
}
|
||||
}
|
||||
149
web/src/components/SpecialtySelect.jsx
Normal file
149
web/src/components/SpecialtySelect.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user