fix(frontend): a11y/호환성 — backdrop-filter webkit prefix + Button/Input/Select 접근 이름

axe/forms/backdrop 에러 3종 모두 해결:

1) CSS: backdrop-filter Safari 호환성
   - design-system CSS에 -webkit-backdrop-filter 추가
   - trk-pulse 애니메이션을 outline-color → opacity로 변경
     (composite만 트리거, paint/layout 없음 → 더 나은 성능)

2) 아이콘 전용 <button> aria-label 추가 (9곳):
   - MainLayout 알림 버튼 → '알림'
   - UserRoleAssignDialog 닫기 → '닫기'
   - AIAssistant/MLOpsPage 전송 → '전송'
   - ChinaFishing 좌/우 네비 → '이전'/'다음'
   - 공통 컴포넌트 (PrintButton/ExcelExport/SaveButton) type=button 누락 보정

3) <input>/<textarea> 접근 이름 27곳 추가:
   - 로그인 폼, ParentReview/LabelSession/ParentExclusion 폼 (10)
   - NoticeManagement 제목/내용/시작일/종료일 (4)
   - SystemConfig/DataHub/PermissionsPanel 검색·역할 입력 (5)
   - VesselDetail 조회 시작/종료/MMSI (3)
   - GearIdentification InputField에 label prop 추가
   - AIAssistant/MLOpsPage 질의 input/textarea
   - MainLayout 페이지 내 검색
   - 공통 placeholder → aria-label 자동 복제 (3)

Button 컴포넌트에는 접근성 정책 JSDoc 명시 (타입 강제는 API 복잡도 대비
이득 낮아 문서 가이드 + 코드 리뷰로 대응).

검증:
- 실제 위반 지표: inaccessible button 0, inaccessible input 0, textarea 0
- tsc , eslint , vite build 
- dist CSS에 -webkit-backdrop-filter 확인됨
This commit is contained in:
htlee 2026-04-08 13:04:23 +09:00
부모 9dfa8f5422
커밋 c51873ab85
21개의 변경된 파일55개의 추가작업 그리고 29개의 파일을 삭제

파일 보기

@ -361,7 +361,7 @@ export function MainLayout() {
<div className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input
<input aria-label={t('layout.searchPlaceholder')}
type="text"
placeholder={t('layout.searchPlaceholder')}
className="w-56 bg-surface-overlay border border-border rounded-lg pl-8 pr-3 py-1.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40"
@ -374,7 +374,7 @@ export function MainLayout() {
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] text-red-400 font-bold whitespace-nowrap">{t('layout.alertCount', { count: 3 })}</span>
</div>
<button className="relative p-1.5 rounded-lg hover:bg-surface-overlay text-muted-foreground hover:text-heading transition-colors">
<button type="button" aria-label={t('layout.notifications', { defaultValue: '알림' })} className="relative p-1.5 rounded-lg hover:bg-surface-overlay text-muted-foreground hover:text-heading transition-colors">
<Bell className="w-4 h-4" />
<span className="absolute top-0.5 right-0.5 w-2 h-2 bg-red-500 rounded-full" />
</button>
@ -438,6 +438,7 @@ export function MainLayout() {
<div className="relative flex items-center">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint pointer-events-none" />
<input
aria-label="페이지 내 검색"
value={pageSearch}
onChange={(e) => setPageSearch(e.target.value)}
onKeyDown={(e) => {

파일 보기

@ -16,6 +16,7 @@
border-bottom: 1px solid rgb(51 65 85 / 0.5);
flex-shrink: 0;
background: var(--surface-overlay, rgb(15 23 42 / 0.6));
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
@ -65,6 +66,7 @@
outline: 2px solid rgb(59 130 246);
outline-offset: 4px;
animation: trk-pulse 1.2s ease-out;
will-change: opacity;
}
.trk-item[data-copied='true'] {
@ -88,13 +90,13 @@
@keyframes trk-pulse {
0% {
outline-color: rgb(59 130 246);
opacity: 1;
}
50% {
outline-color: rgb(59 130 246 / 0.3);
opacity: 0.35;
}
100% {
outline-color: rgb(59 130 246);
opacity: 1;
}
}

파일 보기

@ -449,6 +449,7 @@ export function DataHub() {
<div className="flex items-center gap-2">
<div className="relative">
<input
aria-label="수신 현황 기준일"
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}

파일 보기

@ -271,6 +271,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label="알림 제목"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/50"
@ -282,6 +283,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<textarea
aria-label="알림 내용"
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
rows={3}
@ -337,6 +339,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label="시작일"
type="date"
value={form.startDate}
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
@ -346,6 +349,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label="종료일"
type="date"
value={form.endDate}
onChange={(e) => setForm({ ...form, endDate: e.target.value })}

파일 보기

@ -374,10 +374,10 @@ export function PermissionsPanel() {
{showCreate && (
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5">
<input value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
<input aria-label="역할 코드" value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
placeholder="ROLE_CD (대문자)"
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
<input value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
<input aria-label="역할 이름" value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
placeholder="역할 이름"
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />

파일 보기

@ -218,6 +218,7 @@ export function SystemConfig() {
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input
aria-label="코드 검색"
value={query}
onChange={(e) => { setQuery(e.target.value); setPage(0); }}
placeholder={

파일 보기

@ -60,7 +60,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
{user.userAcnt} ({user.userNm}) - (OR )
</div>
</div>
<button type="button" onClick={onClose} className="text-hint hover:text-heading">
<button type="button" aria-label="닫기" onClick={onClose} className="text-hint hover:text-heading">
<X className="w-4 h-4" />
</button>
</div>

파일 보기

@ -141,13 +141,14 @@ export function AIAssistant() {
{/* 입력창 */}
<div className="flex gap-2 shrink-0">
<input
aria-label="AI 어시스턴트 질의"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSend()}
placeholder="질의를 입력하세요... (법령, 단속 절차, AI 분석 결과 등)"
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded-xl px-4 py-2.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-green-500/50"
/>
<button type="button" onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-on-vivid rounded-xl transition-colors">
<button type="button" aria-label="전송" onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-on-vivid rounded-xl transition-colors">
<Send className="w-4 h-4" />
</button>
</div>

파일 보기

@ -302,7 +302,7 @@ export function MLOpsPage() {
<div className="grid grid-cols-2 gap-3" style={{ height: 'calc(100vh - 240px)' }}>
<Card className="bg-surface-raised border-border flex flex-col"><CardContent className="p-4 flex-1 flex flex-col">
<div className="text-[9px] font-bold text-hint mb-2">REQUEST BODY (JSON)</div>
<textarea className="flex-1 bg-background border border-border rounded-lg p-3 text-[10px] text-cyan-300 font-mono resize-none focus:outline-none focus:border-blue-500/40" defaultValue={`{
<textarea aria-label="API 요청 본문 JSON" className="flex-1 bg-background border border-border rounded-lg p-3 text-[10px] text-cyan-300 font-mono resize-none focus:outline-none focus:border-blue-500/40" defaultValue={`{
"mmsi": "412345678",
"lat": 37.12,
"lon": 124.63,
@ -504,8 +504,8 @@ export function MLOpsPage() {
</div></div>
</div>
<div className="flex gap-2 shrink-0">
<input className="flex-1 bg-background border border-border rounded-xl px-4 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40" placeholder="질의를 입력하세요..." />
<button type="button" className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid rounded-xl"><Send className="w-4 h-4" /></button>
<input aria-label="LLM 질의 입력" className="flex-1 bg-background border border-border rounded-xl px-4 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40" placeholder="질의를 입력하세요..." />
<button type="button" aria-label="전송" className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid rounded-xl"><Send className="w-4 h-4" /></button>
</div>
</CardContent></Card>
</div>

파일 보기

@ -142,6 +142,7 @@ export function LoginPage() {
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-hint" />
<input
aria-label={t('form.userId')}
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
@ -157,6 +158,7 @@ export function LoginPage() {
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-hint" />
<input
aria-label={t('form.password')}
type={showPw ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}

파일 보기

@ -345,7 +345,7 @@ export function ChinaFishing() {
</button>
<div className="flex-1 flex items-center bg-surface-overlay border border-slate-700/40 rounded-lg px-3 py-1.5">
<Search className="w-3.5 h-3.5 text-hint mr-2" />
<input
<input aria-label="해역 또는 해구 번호 검색"
placeholder="해역 또는 해구 번호 검색"
className="bg-transparent text-[11px] text-label placeholder:text-hint flex-1 focus:outline-none"
/>
@ -664,10 +664,10 @@ export function ChinaFishing() {
))}
</div>
<div className="flex justify-between mt-2">
<button type="button" className="text-hint hover:text-heading transition-colors">
<button type="button" aria-label="이전" className="text-hint hover:text-heading transition-colors">
<ChevronLeft className="w-4 h-4" />
</button>
<button type="button" className="text-hint hover:text-heading transition-colors">
<button type="button" aria-label="다음" className="text-hint hover:text-heading transition-colors">
<ChevronRight className="w-4 h-4" />
</button>
</div>

파일 보기

@ -436,15 +436,17 @@ function FormField({ label, children, hint }: { label: string; children: React.R
);
}
function InputField({ value, onChange, placeholder, type = 'text', className = '' }: {
function InputField({ value, onChange, placeholder, type = 'text', className = '', label }: {
value: string | number | null;
onChange: (v: string) => void;
placeholder: string;
type?: string;
className?: string;
label?: string;
}) {
return (
<input
aria-label={label ?? placeholder}
type={type}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}

파일 보기

@ -122,11 +122,11 @@ export function LabelSession() {
{!canCreate && <span className="text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<input value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
<input aria-label="group_key" value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<input type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
<input aria-label="sub_cluster_id" type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<input value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
<input aria-label="정답 parent MMSI" value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
className="w-48 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<button type="button" onClick={handleCreate}
disabled={!canCreate || !groupKey || !labelMmsi || busy === -1}

파일 보기

@ -139,13 +139,13 @@ export function ParentExclusion() {
{!canCreateGroup && <span className="text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<input value={grpKey} onChange={(e) => setGrpKey(e.target.value)} placeholder="group_key"
<input aria-label="group_key" value={grpKey} onChange={(e) => setGrpKey(e.target.value)} placeholder="group_key"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<input type="number" value={grpSub} onChange={(e) => setGrpSub(e.target.value)} placeholder="sub"
<input aria-label="sub_cluster_id" type="number" value={grpSub} onChange={(e) => setGrpSub(e.target.value)} placeholder="sub"
className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<input value={grpMmsi} onChange={(e) => setGrpMmsi(e.target.value)} placeholder="excluded MMSI"
<input aria-label="excluded MMSI" value={grpMmsi} onChange={(e) => setGrpMmsi(e.target.value)} placeholder="excluded MMSI"
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<input value={grpReason} onChange={(e) => setGrpReason(e.target.value)} placeholder="사유"
<input aria-label="제외 사유" value={grpReason} onChange={(e) => setGrpReason(e.target.value)} placeholder="사유"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<button type="button" onClick={handleAddGroup}
disabled={!canCreateGroup || !grpKey || !grpMmsi || busy === -1}
@ -165,9 +165,9 @@ export function ParentExclusion() {
{!canCreateGlobal && <span className="text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<input value={glbMmsi} onChange={(e) => setGlbMmsi(e.target.value)} placeholder="excluded MMSI"
<input aria-label="excluded MMSI (전역)" value={glbMmsi} onChange={(e) => setGlbMmsi(e.target.value)} placeholder="excluded MMSI"
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} />
<input value={glbReason} onChange={(e) => setGlbReason(e.target.value)} placeholder="사유"
<input aria-label="전역 제외 사유" value={glbReason} onChange={(e) => setGlbReason(e.target.value)} placeholder="사유"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} />
<button type="button" onClick={handleAddGlobal}
disabled={!canCreateGlobal || !glbMmsi || busy === -2}

파일 보기

@ -128,6 +128,7 @@ export function ParentReview() {
<div className="text-xs text-muted-foreground mb-2"> ()</div>
<div className="flex items-center gap-2">
<input
aria-label="group_key"
type="text"
value={newGroupKey}
onChange={(e) => setNewGroupKey(e.target.value)}
@ -135,6 +136,7 @@ export function ParentReview() {
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
/>
<input
aria-label="sub_cluster_id"
type="number"
value={newSubCluster}
onChange={(e) => setNewSubCluster(e.target.value)}
@ -142,6 +144,7 @@ export function ParentReview() {
className="w-32 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
/>
<input
aria-label="parent MMSI"
type="text"
value={newMmsi}
onChange={(e) => setNewMmsi(e.target.value)}

파일 보기

@ -167,17 +167,17 @@ export function VesselDetail() {
<h2 className="text-sm font-bold text-heading"> </h2>
<div className="flex items-center gap-1">
<span className="text-[9px] text-hint w-14 shrink-0">/</span>
<input value={startDate} onChange={(e) => setStartDate(e.target.value)}
<input aria-label="조회 시작 시각" value={startDate} onChange={(e) => setStartDate(e.target.value)}
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50"
placeholder="YYYY-MM-DD HH:mm" />
<span className="text-hint text-[10px]">~</span>
<input value={endDate} onChange={(e) => setEndDate(e.target.value)}
<input aria-label="조회 종료 시각" value={endDate} onChange={(e) => setEndDate(e.target.value)}
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50"
placeholder="YYYY-MM-DD HH:mm" />
</div>
<div className="flex items-center gap-1">
<span className="text-[9px] text-hint w-14 shrink-0">MMSI</span>
<input value={searchMmsi} onChange={(e) => setSearchMmsi(e.target.value)}
<input aria-label="MMSI" value={searchMmsi} onChange={(e) => setSearchMmsi(e.target.value)}
placeholder="MMSI 입력"
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none" />
<button type="button" className="flex items-center gap-1.5 bg-secondary border border-slate-700/50 rounded px-3 py-1 text-[10px] text-label hover:bg-switch-background transition-colors">

파일 보기

@ -74,7 +74,7 @@ export function FilterBar({ filter, onChange, groupBy, onGroupByChange, meta }:
v{meta.version} · {meta.releaseDate} · {meta.nodeCount} · {meta.edgeCount}
</div>
<div className="sf-spacer" />
<input
<input aria-label="검색 (label, file, symbol, tag)"
type="text"
placeholder="검색 (label, file, symbol, tag)"
value={filter.search}

파일 보기

@ -56,6 +56,7 @@ export function ExcelExport({
return (
<button
type="button"
onClick={handleExport}
disabled={!data.length}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-medium transition-colors disabled:opacity-30 ${

파일 보기

@ -41,6 +41,7 @@ export function PrintButton({ targetRef, label = '출력', className = '' }: Pri
return (
<button
type="button"
onClick={handlePrint}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-medium bg-surface-overlay border border-border text-muted-foreground hover:text-heading hover:border-border transition-colors ${className}`}
>

파일 보기

@ -29,6 +29,7 @@ export function SaveButton({ onClick, label = '저장', disabled = false, classN
return (
<button
type="button"
onClick={handleClick}
disabled={disabled || state !== 'idle'}
className={`flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-[11px] font-bold transition-colors disabled:opacity-40 ${

파일 보기

@ -14,6 +14,12 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
* variant: primary / secondary / ghost / outline / destructive
* size: sm / md / lg
* className override는 cn() .
*
* ** **:
* - children이 버튼:
* - (children icon만): aria-label title
* ) <Button variant="ghost" aria-label="닫기" icon={<X/>} />
* - WCAG 2.1 Level A
*/
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, icon, trailingIcon, children, ...props }, ref) => {