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:
부모
9dfa8f5422
커밋
c51873ab85
@ -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) => {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user