fix(frontend): Select 접근성 — aria-label 필수 + 네이티브 <select> 보완
이슈: "Select element must have an accessible name" — 스크린 리더가 용도를 인지할 수 없어 WCAG 2.1 Level A 위반. 수정: - Select 공통 컴포넌트 타입을 union으로 강제 - aria-label | aria-labelledby | title 중 하나는 TypeScript 컴파일 타임에 필수 - 누락 시 tsc 단계에서 즉시 실패 → 회귀 방지 - 네이티브 <select> 5곳 aria-label 추가: - admin/SystemConfig: 대분류 필터 - detection/RealVesselAnalysis: 해역 필터 - detection/RealGearGroups: 그룹 유형 필터 - detection/ChinaFishing: 관심영역 선택 - detection/GearIdentification: SelectField에 label prop 추가 - 쇼케이스 FormSection Select 샘플에 aria-label 추가 이제 모든 Select 사용처가 접근 이름을 가지며, 향후 신규 Select 사용 시 tsc가 누락을 차단함.
This commit is contained in:
부모
da4dc86e90
커밋
9dfa8f5422
@ -63,7 +63,7 @@ export function FormSection() {
|
||||
{(['sm', 'md', 'lg'] as const).map((size) => (
|
||||
<Trk key={size} id={`TRK-FORM-select-${size}`} className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">size={size}</label>
|
||||
<Select size={size} defaultValue="">
|
||||
<Select aria-label={`쇼케이스 샘플 Select ${size}`} size={size} defaultValue="">
|
||||
<option value="">전체 선택</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
<option value="HIGH">HIGH</option>
|
||||
|
||||
@ -232,6 +232,7 @@ export function SystemConfig() {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Filter className="w-3.5 h-3.5 text-hint" />
|
||||
<select
|
||||
aria-label="대분류 필터"
|
||||
value={majorFilter}
|
||||
onChange={(e) => { setMajorFilter(e.target.value); setPage(0); }}
|
||||
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-label focus:outline-none focus:border-cyan-500/50"
|
||||
|
||||
@ -428,7 +428,7 @@ export function ChinaFishing() {
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-heading">관심영역 안전도</span>
|
||||
<select className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none">
|
||||
<select aria-label="관심영역 선택" className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none">
|
||||
<option>영역 A</option>
|
||||
<option>영역 B</option>
|
||||
</select>
|
||||
|
||||
@ -470,13 +470,15 @@ function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v:
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField({ value, onChange, options }: {
|
||||
function SelectField({ label, value, onChange, options }: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
aria-label={label}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full bg-surface-overlay border border-slate-700/50 rounded-md px-2.5 py-1.5 text-xs text-heading focus:border-blue-500/50 focus:outline-none"
|
||||
@ -692,7 +694,7 @@ export function GearIdentification() {
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<SectionHeader icon={Anchor} title="어구 물리적 특성" color="#a855f7" />
|
||||
<FormField label="어구 유형 (추정)">
|
||||
<SelectField value={input.gearCategory} onChange={(v) => update('gearCategory', v as GearType)} options={[
|
||||
<SelectField label="어구 유형 (추정)" value={input.gearCategory} onChange={(v) => update('gearCategory', v as GearType)} options={[
|
||||
{ value: 'unknown', label: '미확인 / 모름' },
|
||||
{ value: 'trawl', label: '트롤 (저인망) — 끌고 다니는 어망' },
|
||||
{ value: 'gillnet', label: '자망 (유자망) — 세워놓는 어망' },
|
||||
@ -746,7 +748,7 @@ export function GearIdentification() {
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="궤적 패턴">
|
||||
<SelectField value={input.trajectoryPattern} onChange={(v) => update('trajectoryPattern', v as GearInput['trajectoryPattern'])} options={[
|
||||
<SelectField label="궤적 패턴" value={input.trajectoryPattern} onChange={(v) => update('trajectoryPattern', v as GearInput['trajectoryPattern'])} options={[
|
||||
{ value: 'unknown', label: '미확인' },
|
||||
{ value: 'lawnmowing', label: '지그재그 반복 (Lawn-mowing) → 트롤' },
|
||||
{ value: 'stationary', label: '정지/극저속 (Stationary) → 자망' },
|
||||
|
||||
@ -62,7 +62,7 @@ export function RealGearGroups() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={filterType} onChange={(e) => setFilterType(e.target.value)}
|
||||
<select aria-label="그룹 유형 필터" value={filterType} onChange={(e) => setFilterType(e.target.value)}
|
||||
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
|
||||
<option value="">전체</option>
|
||||
<option value="FLEET">FLEET</option>
|
||||
|
||||
@ -85,7 +85,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={zoneFilter} onChange={(e) => setZoneFilter(e.target.value)}
|
||||
<select aria-label="해역 필터" value={zoneFilter} onChange={(e) => setZoneFilter(e.target.value)}
|
||||
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
|
||||
<option value="">전체 해역</option>
|
||||
<option value="TERRITORIAL_SEA">영해</option>
|
||||
|
||||
@ -2,12 +2,32 @@ import { forwardRef, type SelectHTMLAttributes } from 'react';
|
||||
import { inputVariants, type InputSize, type InputState } from '@lib/theme/variants';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
|
||||
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
|
||||
/**
|
||||
* Select — 네이티브 select 래퍼.
|
||||
*
|
||||
* **접근성 정책 (타입으로 강제)**:
|
||||
* 스크린 리더가 select의 용도를 읽을 수 있도록 아래 3개 중 하나는 필수:
|
||||
* - `aria-label`: 짧은 텍스트 라벨 (예: "등급 필터")
|
||||
* - `aria-labelledby`: 다른 요소의 ID 참조
|
||||
* - `title`: 툴팁 (접근 이름 폴백)
|
||||
*
|
||||
* 사용 예:
|
||||
* <Select aria-label="상태 필터" value={v} onChange={...}>...</Select>
|
||||
* <Select title="등급 필터" value={v} onChange={...}>...</Select>
|
||||
*/
|
||||
|
||||
type BaseSelectProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size'> & {
|
||||
size?: InputSize;
|
||||
state?: InputState;
|
||||
}
|
||||
};
|
||||
|
||||
type SelectWithAccessibleName =
|
||||
| (BaseSelectProps & { 'aria-label': string })
|
||||
| (BaseSelectProps & { 'aria-labelledby': string })
|
||||
| (BaseSelectProps & { title: string });
|
||||
|
||||
export type SelectProps = SelectWithAccessibleName;
|
||||
|
||||
/** Select — Input과 동일한 스타일 토큰 공유 */
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, size, state, children, ...props }, ref) => {
|
||||
return (
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user