Verify BulkActionBar

Verify BulkActionBar pattern SSOT and UX consistency for bulk actions. Ensures accessibility (aria-live, role=toolbar, indeterminate checkbox) and focus management.

Sby Skills Guide Bot
TestingIntermediate
107/24/2026
#bulk-action-bar#accessibility#verification#radix#focus-management

Recommended for


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.tsxcomponents/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 우회
  • actions slot 미사용 + 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.tsapplyGroupToggle 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 / CheckoutBulkActionBaronBulkApprove 콜백을 () => 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) |

Related skills