name: verify-bulk-action-bar description: BulkActionBar 패턴 SSOT 검증 — count chip aria-live, role=toolbar, Esc clear, indeterminate Radix, focus management, IME guard. 일괄 작업 UI 변경 시 트리거. canonical = components/common/BulkActionBar.tsx (도메인 무관 generic), components/approvals/BulkActionBar.tsx는 approvals 특화 wrapper. disable-model-invocation: true
verify-bulk-action-bar
BulkActionBar 패턴 SSOT 검증 + 도메인 일괄 작업 UX 일관성 확보.
When to Run
- 신규 일괄 작업 UI 추가 (e.g. bulk-approve, bulk-reject, bulk-export)
- BulkActionBar 또는 그 wrapper 컴포넌트 수정 시
- BulkActionBar 사용 컴포넌트의 키보드 단축키 / focus 동작 변경 시
Related Files
| 파일 | 역할 | canonical 여부 |
|------|------|---------------|
| components/common/BulkActionBar.tsx | 도메인 무관 generic — actions slot 패턴 | ✅ canonical |
| components/approvals/BulkActionBar.tsx | approvals 특화 wrapper (승인/반려 도메인 액션) | wrapper |
| lib/design-tokens/{form-field-tokens,index}.ts | BULK_ACTION_BAR_TOKENS 색상/간격/sticky-z 토큰 SSOT | 토큰 정의 |
| hooks/use-bulk-selection.ts | useBulkSelection / useRowSelection — 선택 상태 SSOT | 상태 hook |
| components/common/RowSelectCell.tsx | 행 단위 체크박스 셀 (Enter/Space toggle) | row hook |
Future Work (별도 세션):
components/approvals/BulkActionBar.tsx와components/common/BulkActionBar.tsx의 dedup. 본 SKILL은 dedup하지 않고 패턴 일관성만 보장.
Workflow Steps
Step 1 — count chip aria-live
선택 카운트 표시는 스크린리더에 변화를 알려야 합니다.
✅ Required: role="toolbar" 컨테이너 자체에 aria-live="polite" 또는 별도 sr-only 미러.
❌ aria-live="assertive" 금지 — 카운트 변화는 critical이 아님 (WCAG 2.2 SC 4.1.3 Status Messages).
grep -A 5 'role="toolbar"' apps/frontend/components/common/BulkActionBar.tsx | grep "aria-live"
# 기대: ≥1 hit
Step 2 — role="toolbar" + aria-label 필수
grep -E 'role="toolbar"' apps/frontend/components/common/BulkActionBar.tsx | wc -l # ≥1
grep -A 2 'role="toolbar"' apps/frontend/components/common/BulkActionBar.tsx | grep "aria-label" # ≥1
Step 3 — Esc 키로 onClearSelection (선택적 UX 강화 — 필수 아님)
일괄 작업 중 Esc는 선택 해제 단축키. 다만 AlertDialog 등이 열려 있으면 dialog가 먼저 닫혀야 함 (Radix 기본 동작).
✅ 권장 (선택적): 컴포넌트 외부에서 keyboard handler 연결 시 if (e.target.closest('[role="dialog"]')) return; 가드.
❌ window-level keydown listener 추가 금지 — 이중 핸들링 위험.
정책 (2026-05-06 갱신): Esc 단축키 자체는 필수 아님. WCAG 2.1 AA "Keyboard accessible" 원칙은 Tab+Enter+Space로 충족되며, ARIA Authoring Practices Guide의 Toolbar 패턴도 Esc를 권장 단축키로만 명시. 본 SKILL은 Esc 구현 시 window-level 우회 금지만 강제하고, 미구현은 위반 아님. UX enhancement sprint에서 일괄 도입 권장 (tech-debt-tracker.md mutateAsync-ux-consistency 등과 묶음 처리).
Step 4 — 0건 시 return null 또는 aria-hidden="true" + pointer-events-none
grep "selectedCount === 0" apps/frontend/components/common/BulkActionBar.tsx | grep -E "return null|aria-hidden"
# 기대: ≥1 hit
Step 5 — indeterminate 상태 Radix Checkbox
Radix <Checkbox checked='indeterminate'>은 자동으로 aria-checked="mixed"를 부여합니다 (WCAG 2.2 SC 4.1.2 Name, Role, Value).
grep -E "checked.*indeterminate|isIndeterminate" apps/frontend/components/common/BulkActionBar.tsx
# 기대: ≥1 hit
❌ HTML 기본 <input type="checkbox" indeterminate> 사용 금지 — React가 controlled로 indeterminate를 직접 지원 안 함.
Step 6 — Focus management (ESC → 다음 focusable)
선택 해제 후 focus는 toolbar 외부의 다음 focusable element로 이동해야 함. Radix Toolbar primitive 사용 시 자동 처리.
✅ Pattern: Toolbar 내 첫 focusable은 dismiss(clear) 또는 primary action.
❌ tabIndex={-1}로 toolbar 자체를 focus blackhole 만들지 않기.
Step 7 — IME composition guard (한글 입력 보호)
BulkActionBar 호출자(또는 RowSelectCell, KPI strip 등)의 onKeyDown 핸들러는 한글 IME composition 중에는 단축키를 무시해야 합니다.
✅ Required pattern (React 19):
onKeyDown={(e) => {
if (e.nativeEvent.isComposing) return; // IME 가드
if (e.key === 'Enter') { ... }
}}
❌ window-level compositionstart/compositionend listener 추가 금지 — React 합성 이벤트로 충분.
grep -rn "isComposing\|nativeEvent.isComposing" \
apps/frontend/components/common apps/frontend/components/approvals 2>/dev/null \
| grep -v "__tests__" | wc -l
# 기대: ≥1 (BulkActionBar 호출자 중 onKeyDown 가진 것은 모두)
Step 8 — Group header indeterminate (그룹 단위 마스터 체크박스)
CheckoutGroupCard 등 행을 그룹화하는 컴포넌트에서 그룹 헤더 마스터 체크박스의 none/indeterminate/all 3상태 SSOT 사용.
✅ Required pattern (lib/checkouts/group-selection.ts SSOT):
import {
getGroupRowIds,
deriveGroupSelectionState,
toCheckboxCheckedProp,
} from '@/lib/checkouts/group-selection';
const rowIds = getGroupRowIds(group);
const state = deriveGroupSelectionState(rowIds, selectedRowIds);
// state: 'none' | 'indeterminate' | 'all'
<Checkbox
checked={toCheckboxCheckedProp(state)} // boolean | 'indeterminate'
onCheckedChange={(next) => onToggleGroup(rowIds, next === true)}
aria-checked={state === 'indeterminate' ? 'mixed' : state === 'all'}
/>
Radix Checkbox는 checked='indeterminate'일 때 자동으로 data-state="indeterminate"를 부여하고 SR로 aria-checked="mixed"를 매핑한다.
❌ 안티패턴:
- 그룹 헤더가 ad-hoc
selectedRowIds.filter(id => rowIds.includes(id)).length삼항분기 —getGroupRowIds우회 <input type="checkbox" indeterminate>HTML 기본 — controlled indeterminate 미지원aria-checked={state}로 'none'/'all'/'indeterminate' 그대로 전달 — WAI-ARIA spec 위반 (true/false/mixed만 허용)
# SSOT 사용 확인
grep -rn "getGroupRowIds\|deriveGroupSelectionState\|toCheckboxCheckedProp" \
apps/frontend/components --include="*.tsx" 2>/dev/null | grep -v "__tests__"
# 기대: 그룹 체크박스 사용 컴포넌트마다 ≥1 hit
# data-state="indeterminate" Radix wiring
grep -rn 'data-state="indeterminate"' apps/frontend/components apps/frontend/app --include="*.tsx" 2>/dev/null
# 기대: 0 (Radix가 자동 부여, 직접 사용 금지)
Step 9 — 격리 fixture page로 단독 검증
부모 컴포넌트(예: CheckoutsTab) 통합 없이 그룹/체크박스 동작만 단독 검증할 때, app/(dashboard)/__visual__/<scenario>/page.tsx 격리 fixture 패턴 사용.
✅ Required pattern:
- 경로:
apps/frontend/app/(dashboard)/__visual__/<scenario>/page.tsx - 목적: 실제 데이터/권한/네트워크 의존 없이 컴포넌트 prop API만 e2e/visual 검증
- 시드 데이터는 page 내
const FIXTURE_GROUPS = [...]인라인 — 백엔드 의존 0 - E2E spec은
__visual__/<scenario>경로로 직접 navigate
# 격리 fixture page 존재 확인
ls apps/frontend/app/\(dashboard\)/__visual__/group-indeterminate/page.tsx
# 기대: 존재
# E2E spec이 fixture URL 사용
grep -rn "__visual__/group-indeterminate\|/__visual__/" tests/e2e/ 2>/dev/null
# 기대: ≥1 hit
# 격리 fixture page는 server-only fetch 또는 useQuery 사용 금지 (시드 데이터 인라인이어야)
grep -E "fetch\(|useQuery\(|getServerSession\(" \
apps/frontend/app/\(dashboard\)/__visual__/*/page.tsx 2>/dev/null
# 기대: 0 (네트워크/세션 의존 0)
❌ 안티패턴:
__visual__안에서 실제 API 호출 — fixture 격리 무력화- 시드 fixture를
lib/나__fixtures__/에 분산 — page.tsx 단일 파일 검증 원칙 깨짐 - E2E가 부모 컴포넌트 통합을 통해서만 컴포넌트 검증 — 격리 fixture 우회
Step 10 — 도메인 wrapper 신설 패턴 (2026-05-06, bulk-selection-tabs-integration sprint)
새 도메인이 일괄 작업 UI를 추가할 때 generic BulkActionBar를 직접 사용하지 말고 도메인 wrapper를 신설한다 — components/<domain>/<Domain>BulkActionBar.tsx.
✅ Required wrapper 구조 (canonical reference: components/approvals/BulkActionBar.tsx, components/checkouts/CheckoutBulkActionBar.tsx):
import { BulkActionBar as GenericBulkActionBar } from '@/components/common/BulkActionBar';
import { APPROVAL_BULK_BAR_TOKENS, getApprovalActionButtonClasses } from '@/lib/design-tokens';
export function CheckoutBulkActionBar({
selectedCount,
onBulkApprove,
onBulkReject, // optional — 도메인 권한/상태에 따라 reject 비활성
...
}: CheckoutBulkActionBarProps) {
return (
<>
{/* SR-only aria-live (DOM 항상 유지) */}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{isVisible ? t('bulkBar.selectionCount', { count }) : t('bulkBar.selectionCleared')}
</div>
{/* fixed-bottom + aria-hidden 토글 */}
<div data-testid="bulk-action-bar" aria-hidden={!isVisible}>
{isVisible && (
<GenericBulkActionBar
selectedCount={selectedCount}
actions={ // 도메인 버튼은 actions slot 주입 (하드코딩 금지)
<>
<Button onClick={() => setIsApproveDialogOpen(true)}>{t('bulk.approve')}</Button>
{onBulkReject && ( // optional prop 게이트 — 권한/상태로 분기
<Button onClick={() => setIsRejectModalOpen(true)}>{t('bulk.reject')}</Button>
)}
</>
}
/>
)}
</div>
{/* approve confirmation: AlertDialog */}
<AlertDialog>...</AlertDialog>
{/* reject confirmation: RejectModal mode='bulk' (SSOT) */}
{onBulkReject && <RejectModal mode="bulk" count={selectedCount} ... />}
</>
);
}
❌ 안티패턴:
- 도메인 컴포넌트가
<GenericBulkActionBar />를 직접 렌더 — wrapper 우회 actionsslot 미사용 + variant prop으로 도메인 분기 (<BulkActionBar variant="checkout">) — generic 오염onBulkReject를 required로 강제 → 권한 없는 사용자에게 disabled 버튼 노출- 도메인 wrapper에 RejectModal 직접 만들지 말고
components/approvals/RejectModal.tsx mode='bulk'재사용
# generic 직접 사용 + actions slot 누락 탐지 (도메인 컴포넌트 영역)
grep -rn "from '@/components/common/BulkActionBar'" apps/frontend/components --include="*.tsx" \
| grep -v "/common/" | grep -v "BulkActionBar.tsx$"
# 기대: wrapper 파일 (e.g. CheckoutBulkActionBar.tsx, ApprovalsBulkActionBar.tsx) 만 hit
# 도메인 페이지/탭이 직접 import 시 위반
Step 11 — applyGroupToggle SSOT 헬퍼 (그룹 토글 핸들러 인라인 forEach 금지, 2026-05-06)
부모 컴포넌트가 그룹 헤더 토글 핸들러를 인라인으로 작성하면 useRowSelection.setSelected API 변경 시 호출처마다 수동 동기화 필요 — lib/checkouts/group-selection.ts의 applyGroupToggle SSOT 경유 강제.
✅ Required pattern:
import { applyGroupToggle } from '@/lib/checkouts/group-selection';
const handleToggleGroup = useCallback(
(rowIds: readonly string[], allCurrentlySelected: boolean) => {
applyGroupToggle(selection, items, rowIds, allCurrentlySelected);
},
[items, selection]
);
❌ 안티패턴 (회귀):
// 인라인 forEach — SSOT 우회
const handleToggleGroup = (rowIds, allSelected) => {
rowIds.forEach((id) => {
const item = items.find((c) => c.id === id);
if (!item) return;
selection.setSelected(id, !allSelected, item);
});
};
# applyGroupToggle 사용처 (그룹 토글 활용 컴포넌트마다 ≥1 hit 필요)
grep -rn "applyGroupToggle\|onToggleGroup" apps/frontend --include="*.tsx" 2>/dev/null \
| grep -v "__tests__" | grep -v "lib/checkouts/group-selection.ts"
# 인라인 forEach 안티패턴 탐지 (rowIds.forEach + setSelected 조합)
grep -rB 1 -A 4 "rowIds\.forEach" apps/frontend --include="*.tsx" 2>/dev/null \
| grep -A 3 "setSelected" | grep -v "applyGroupToggle"
# 기대: 0 hit (모두 SSOT 경유)
Step 12 — onBulkApprove 콜백 시그니처 Promise<void> 강제 (mutateAsync UX consistency, 2026-05-06)
BulkActionBar / CheckoutBulkActionBar 의 onBulkApprove 콜백을 () => void | Promise<void> 느슨한 union 으로 두면 호출자가 mutation.mutate() (fire-and-forget) 를 전달 가능 → AlertDialog await onBulkApprove() 즉시 resolve → API 응답 전 dialog close + isPending 시각 피드백 유실. 콜백 시그니처를 () => Promise<void> 로 좁혀 컴파일타임 강제.
✅ Required pattern (호출자):
// 호출자 (ApprovalsClient / OutboundCheckoutsTab)
const handleBulkApprove = useCallback(async () => {
if (selection.count === 0) return;
await bulkApproveMutation.mutateAsync({ ids: Array.from(selection.selected) });
}, [bulkApproveMutation, selection]);
// wrapper 컴포넌트 (BulkActionBar / CheckoutBulkActionBar)
interface Props {
onBulkApprove: () => Promise<void>; // ✅ Promise 강제
// ❌ () => void | Promise<void> — fire-and-forget mutate 전달 가능
}
// internal handleBulkApprove (BulkActionBar 내부) — try/catch/finally 표준
const handleBulkApprove = async () => {
setIsProcessing(true);
try {
await onBulkApprove();
setIsApproveDialogOpen(false); // 성공 시만 close
} catch {
// mutateAsync reject (네트워크/서버 5xx) — toast는 useOptimisticMutation onError 처리.
// dialog 유지하여 사용자가 cancel/retry 결정. unhandled rejection 차단.
} finally {
setIsProcessing(false);
}
};
❌ 안티패턴 (회귀):
// 시그니처 union — 호출자가 fire-and-forget 전달 가능
onBulkApprove: () => void | Promise<void>;
// 호출자 fire-and-forget
const handleBulkApprove = () => {
bulkApproveMutation.mutate({ ids }); // ❌ mutate, await 없음
};
# Step 12-1: BulkActionBar wrapper props 시그니처 — 정확히 `() => Promise<void>`
grep -E "onBulkApprove: \(\) => Promise<void>" \
apps/frontend/components/approvals/BulkActionBar.tsx \
apps/frontend/components/checkouts/CheckoutBulkActionBar.tsx
# 기대: 양쪽 모두 hit (각 1건)
# Step 12-2: 시그니처 union 잔존 탐지
grep -rE "onBulkApprove.*=>.*void \| Promise" apps/frontend/components --include="*.tsx" 2>/dev/null
# 기대: 0 hit
# Step 12-3: 호출자 wiring — bulkApproveMutation.mutate (fire-and-forget) 금지
grep -rE "bulkApproveMutation\.mutate\(" apps/frontend \
--include="*.tsx" 2>/dev/null \
| grep -v "mutateAsync" | grep -v "__tests__" | grep -v "\.test\."
# 기대: 0 hit (모두 mutateAsync 사용)
# Step 12-4: internal handleBulkApprove try/catch — unhandled rejection 차단
grep -B 1 -A 8 "const handleBulkApprove = async" \
apps/frontend/components/approvals/BulkActionBar.tsx \
apps/frontend/components/checkouts/CheckoutBulkActionBar.tsx \
| grep -E "try \{|\} catch"
# 기대: 양쪽 모두 try + catch hit
verify 명령 (모두 실행)
cd /home/kmjkds/equipment_management_system
# Step 1 + 2: role + aria-live + aria-label
grep -E 'role="toolbar"' apps/frontend/components/common/BulkActionBar.tsx
grep -A 5 'role="toolbar"' apps/frontend/components/common/BulkActionBar.tsx | grep -E "aria-(live|label)"
# Step 4: 0건 처리
grep -E "selectedCount === 0\|return null" apps/frontend/components/common/BulkActionBar.tsx
# Step 5: indeterminate
grep -E "checked.*indeterminate|isIndeterminate" apps/frontend/components/common/BulkActionBar.tsx
# Step 7: IME guard 호출자에 적용됨
grep -rn "nativeEvent.isComposing" \
apps/frontend/components/common apps/frontend/components/approvals \
--include="*.tsx" 2>/dev/null
# Step 12: mutateAsync UX consistency
grep -E "onBulkApprove: \(\) => Promise<void>" \
apps/frontend/components/approvals/BulkActionBar.tsx \
apps/frontend/components/checkouts/CheckoutBulkActionBar.tsx
grep -rE "bulkApproveMutation\.mutate\(" apps/frontend --include="*.tsx" 2>/dev/null \
| grep -v "mutateAsync" | grep -v "__tests__"
Step 13: useRowSelection 이중 게이트 — hook-level isSelectable + UI-level isRowSelectable 동기화 (2026-05-13)
도입: 2026-05-13 inbound-bulk-receive-integration M-7 fix. hook-level과 UI-level 조건이 불일치하면 체크박스 UI는 노출되지만 Set에 항목이 추가되지 않거나, 반대로 Set에는 있지만 체크박스가 비활성 상태인 silent bug가 발생.
이중 게이트 구조:
- hook-level
isSelectable:useRowSelection({ isSelectable: (c) => ... })— 실제 Set 진입 권한 제어 (authoritative) - UI-level
isRowSelectable:CheckoutGroupCard에 전달 — 시각적 disabled/enabled 표시
두 게이트의 조건은 정확히 일치해야 한다. hook-level만 수정하면 UI가 선택 가능처럼 보이는데 실제 Set에 추가되지 않는다.
검증 명령:
# (1) useRowSelection isSelectable 콜백 확인
grep -A 5 "useRowSelection" apps/frontend/app/\(dashboard\)/checkouts/tabs/InboundCheckoutsTab.tsx \
| grep "isSelectable"
# 기대: isSelectable 콜백 존재
# (2) isRowSelectable 콜백 조건 확인 — status + purpose + canSubmitConditionCheckItem 3중 조건
grep -A 5 "isRowSelectable" apps/frontend/app/\(dashboard\)/checkouts/tabs/InboundCheckoutsTab.tsx
# 기대: LENDER_CHECKED + RENTAL + canSubmitConditionCheckItem 3개 조건 모두 포함
# (3) CheckoutGroupCard에 isRowSelectable 전달 확인
grep -n "isRowSelectable" apps/frontend/app/\(dashboard\)/checkouts/tabs/InboundCheckoutsTab.tsx
# 기대: isRowSelectable={canReceiveCheckout ? isRowSelectable : undefined} 또는 동등 패턴
# (4) CheckoutGroupCard에 canSubmitConditionCheckItem 매핑 확인
grep -n "canSubmitConditionCheckItem" apps/frontend/components/checkouts/CheckoutGroupCard.tsx
# 기대: availableActions.canSubmitConditionCheck ?? false 매핑 존재
PASS: hook-level isSelectable과 UI-level isRowSelectable이 동일 조건(status + purpose + permission) 포함.
FAIL: 한쪽만 조건 추가 후 다른 쪽 누락. 예: isRowSelectable에 rental 필터 추가했지만 useRowSelection.isSelectable에 미반영.
Why: 2026-05-13 M-7 Evaluator가 isRowSelectable에만 rental 조건이 있고 useRowSelection.isSelectable에 누락됐음을 FAIL 판정. hook-level Set이 authoritative이므로 UI가 disabled더라도 Set에 항목이 잔류해 bulk API에 포함될 수 있음.
// ✅ CORRECT — 양쪽 동일 조건
const selection = useRowSelection<Checkout>(items, (c) => c.id, {
isSelectable: (c) =>
c.status === CSVal.LENDER_CHECKED &&
c.purpose === CPVal.RENTAL &&
(c.meta?.availableActions?.canSubmitConditionCheck ?? false),
});
const isRowSelectable = useCallback(
(row: { status: string; purpose: string; canSubmitConditionCheckItem: boolean }) =>
row.status === CSVal.LENDER_CHECKED &&
row.purpose === CPVal.RENTAL &&
row.canSubmitConditionCheckItem, // hook-level 조건과 동일
[]
);
// ❌ WRONG — hook-level에 rental 조건 누락
const selection = useRowSelection<Checkout>(items, (c) => c.id, {
isSelectable: (c) => c.status === CSVal.LENDER_CHECKED && (c.meta?.availableActions?.canSubmitConditionCheck ?? false),
// rental 조건 빠짐 → 비rental checkout도 Set에 추가됨
});
Common Failures
| 증상 | 진단 | 수정 |
|------|------|------|
| 한글 입력 중 Ctrl+A로 모두 선택됨 | onKeyDown에 IME 가드 누락 | Step 7 패턴 적용 |
| 일부 선택 시 마스터 체크박스가 mixed로 안 보임 | checkedState가 boolean만 받음 | checkedState='indeterminate' 분기 추가 (Step 5) |
| 토스트와 카운트 chip 동시 발화로 SR 시끄러움 | aria-live="assertive" 잘못 사용 | polite로 강등 (Step 1) |
| Esc로 선택 해제했는데 dialog도 같이 닫힘 | window-level handler 사용 | 컴포넌트 단위 + closest('[role=dialog]') 가드 (Step 3) |
TDD Red-Green-Refactor
Testing
Skill qui guide Claude a travers le cycle TDD complet.
Audit d'Accessibilité Web
Testing
Réalise un audit d'accessibilité web complet selon les normes WCAG.
Générateur de Tests UAT
Testing
Génère des cas de test d'acceptation utilisateur structurés et complets.