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:
htlee 2026-04-08 12:50:51 +09:00
부모 da4dc86e90
커밋 9dfa8f5422
7개의 변경된 파일33개의 추가작업 그리고 10개의 파일을 삭제

파일 보기

@ -63,7 +63,7 @@ export function FormSection() {
{(['sm', 'md', 'lg'] as const).map((size) => ( {(['sm', 'md', 'lg'] as const).map((size) => (
<Trk key={size} id={`TRK-FORM-select-${size}`} className="ds-sample"> <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> <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=""> </option>
<option value="CRITICAL">CRITICAL</option> <option value="CRITICAL">CRITICAL</option>
<option value="HIGH">HIGH</option> <option value="HIGH">HIGH</option>

파일 보기

@ -232,6 +232,7 @@ export function SystemConfig() {
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Filter className="w-3.5 h-3.5 text-hint" /> <Filter className="w-3.5 h-3.5 text-hint" />
<select <select
aria-label="대분류 필터"
value={majorFilter} value={majorFilter}
onChange={(e) => { setMajorFilter(e.target.value); setPage(0); }} 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" 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"> <CardContent className="p-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-heading"> </span> <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> A</option>
<option> B</option> <option> B</option>
</select> </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; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
options: { value: string; label: string }[]; options: { value: string; label: string }[];
}) { }) {
return ( return (
<select <select
aria-label={label}
value={value} value={value}
onChange={(e) => onChange(e.target.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" 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"> <CardContent className="p-4 space-y-3">
<SectionHeader icon={Anchor} title="어구 물리적 특성" color="#a855f7" /> <SectionHeader icon={Anchor} title="어구 물리적 특성" color="#a855f7" />
<FormField label="어구 유형 (추정)"> <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: 'unknown', label: '미확인 / 모름' },
{ value: 'trawl', label: '트롤 (저인망) — 끌고 다니는 어망' }, { value: 'trawl', label: '트롤 (저인망) — 끌고 다니는 어망' },
{ value: 'gillnet', label: '자망 (유자망) — 세워놓는 어망' }, { value: 'gillnet', label: '자망 (유자망) — 세워놓는 어망' },
@ -746,7 +748,7 @@ export function GearIdentification() {
</FormField> </FormField>
</div> </div>
<FormField label="궤적 패턴"> <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: 'unknown', label: '미확인' },
{ value: 'lawnmowing', label: '지그재그 반복 (Lawn-mowing) → 트롤' }, { value: 'lawnmowing', label: '지그재그 반복 (Lawn-mowing) → 트롤' },
{ value: 'stationary', label: '정지/극저속 (Stationary) → 자망' }, { value: 'stationary', label: '정지/극저속 (Stationary) → 자망' },

파일 보기

@ -62,7 +62,7 @@ export function RealGearGroups() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <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"> className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
<option value=""></option> <option value=""></option>
<option value="FLEET">FLEET</option> <option value="FLEET">FLEET</option>

파일 보기

@ -85,7 +85,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <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"> className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
<option value=""> </option> <option value=""> </option>
<option value="TERRITORIAL_SEA"></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 { inputVariants, type InputSize, type InputState } from '@lib/theme/variants';
import { cn } from '@lib/utils/cn'; 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; size?: InputSize;
state?: InputState; 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>( export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className, size, state, children, ...props }, ref) => { ({ className, size, state, children, ...props }, ref) => {
return ( return (