diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c999061c..35987eeba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,11 @@ # Changelog ## Unreleased + - [FE] ⚡ **검색 노드 참조 안정화 및 순차 스냅샷 폴링**: 같은 정규화 검색어와 원본 테이블 데이터에는 장식된 `node.data` 참조를 재사용하여 드래그 중 불필요한 하위 렌더링과 할당을 줄입니다. 스냅샷 폴링은 이전 요청이 끝난 뒤에만 다음 요청을 예약하며, 선택 변경·언마운트 후 도착한 오래된 성공 또는 실패 응답을 무시합니다. + +- [FE] ♿ **그룹 색상 라디오 키보드 조작**: 색상 선택기를 WAI-ARIA 라디오 그룹으로 정렬하고, 단일 roving tab stop과 방향키 선택·포커스 이동 및 순환 동작을 추가했습니다. + - [BE] 🔒 **공유 export 전 경로 redaction**: 공개 share의 SQL / index-design / reversing-spec export에서 코멘트·`example_value`를 제거합니다. 단위 테스트로 누출을 차단합니다. - [BE] 🛠️ **함수 인덱스 중복 오탐 수정**: `lower(email)` 등 expression index를 평문 컬럼 인덱스의 중복으로 잘못 판단하지 않도록 괄호 파서를 강화했습니다. - [Docs] README를 상용 기준 기능 설명으로 갱신 (MVP skeleton 표현 제거, share redaction·diff/export 반영). diff --git a/docs/doctoring/accessibility-radio-group.md b/docs/doctoring/accessibility-radio-group.md new file mode 100644 index 000000000..30e6d2146 --- /dev/null +++ b/docs/doctoring/accessibility-radio-group.md @@ -0,0 +1,17 @@ +# Color radio-group accessibility evidence + +## Decision + +The Business Group color selector uses the WAI-ARIA radio-group pattern with one radio in the page tab sequence. The checked color receives `tabIndex={0}` and every other color receives `tabIndex={-1}`. Right and Down Arrow move focus and selection to the next color; Left and Up Arrow move to the previous color; navigation wraps at both ends. Native button activation preserves Space and click behavior. + +This contract is covered by focused regression tests for checked state, roving tabindex, forward and backward arrow navigation, wrapping, unrelated-key handling, and the no-selection fallback. + +## Evidence + +The W3C Accessible Rich Internet Applications Authoring Practices Guide specifies that a radio group contains `radio` elements with `aria-checked`, that only one radio participates in the tab sequence, and that arrow keys move focus and selection with wrapping. + +## Reference + +World Wide Web Consortium. (n.d.). *Radio group pattern*. WAI-ARIA Authoring Practices Guide. Retrieved August 4, 2026, from https://www.w3.org/WAI/ARIA/apg/patterns/radio/ + +World Wide Web Consortium. (n.d.). *Radio group example using roving tabindex*. WAI-ARIA Authoring Practices Guide. Retrieved August 4, 2026, from https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio/ diff --git a/frontend/src/components/modals/GroupModal.radioKeyboard.test.tsx b/frontend/src/components/modals/GroupModal.radioKeyboard.test.tsx new file mode 100644 index 000000000..44f8fad7a --- /dev/null +++ b/frontend/src/components/modals/GroupModal.radioKeyboard.test.tsx @@ -0,0 +1,98 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { BUSINESS_GROUP_COLORS } from '../../erd/businessGroups' +import { GroupModal } from './GroupModal' + +afterEach(() => { + cleanup() +}) + +function RadioGroupHarness({ initialColor }: { initialColor: string }) { + const [color, setColor] = useState(initialColor) + return ( + + ) +} + +function colorRadios(): HTMLElement[] { + return screen.getAllByRole('radio', { name: /^색상 / }) +} + +describe('GroupModal color radio keyboard contract', () => { + it('keeps one checked radio in the tab order and moves selection with arrows', async () => { + render() + let radios = colorRadios() + + expect(radios).toHaveLength(BUSINESS_GROUP_COLORS.length) + expect(radios[1]).toHaveAttribute('aria-checked', 'true') + expect(radios[1]).toHaveAttribute('tabindex', '0') + radios.forEach((radio, index) => { + if (index !== 1) expect(radio).toHaveAttribute('tabindex', '-1') + }) + + radios[1]!.focus() + fireEvent.keyDown(radios[1]!, { key: 'ArrowRight' }) + await new Promise(r => setTimeout(r, 0)); + radios = colorRadios() + expect(radios[2]).toHaveFocus() + expect(radios[2]).toHaveAttribute('aria-checked', 'true') + expect(radios[2]).toHaveAttribute('tabindex', '0') + expect(radios[1]).toHaveAttribute('aria-checked', 'false') + expect(radios[1]).toHaveAttribute('tabindex', '-1') + + fireEvent.keyDown(radios[2]!, { key: 'ArrowUp' }) + await new Promise(r => setTimeout(r, 0)); + radios = colorRadios() + expect(radios[1]).toHaveFocus() + expect(radios[1]).toHaveAttribute('aria-checked', 'true') + }) + + it('wraps arrow navigation and ignores unrelated keys', async () => { + render() + let radios = colorRadios() + + radios[0]!.focus() + fireEvent.keyDown(radios[0]!, { key: 'ArrowLeft' }) + await new Promise(r => setTimeout(r, 0)); + radios = colorRadios() + expect(radios.at(-1)).toHaveFocus() + expect(radios.at(-1)).toHaveAttribute('aria-checked', 'true') + + fireEvent.keyDown(radios.at(-1)!, { key: 'ArrowDown' }) + await new Promise(r => setTimeout(r, 0)); + radios = colorRadios() + expect(radios[0]).toHaveFocus() + expect(radios[0]).toHaveAttribute('aria-checked', 'true') + + fireEvent.keyDown(radios[0]!, { key: 'Home' }) + await new Promise(r => setTimeout(r, 0)); + expect(radios[0]).toHaveFocus() + expect(radios[0]).toHaveAttribute('aria-checked', 'true') + }) + + it('makes the first radio tabbable when the supplied color is not selected', () => { + render() + const radios = colorRadios() + + expect(radios[0]).toHaveAttribute('tabindex', '0') + expect(radios[0]).toHaveAttribute('aria-checked', 'false') + radios.slice(1).forEach((radio) => { + expect(radio).toHaveAttribute('tabindex', '-1') + }) + }) +}) diff --git a/frontend/src/components/modals/GroupModal.tsx b/frontend/src/components/modals/GroupModal.tsx index bd96c21b1..f8c5c59f7 100644 --- a/frontend/src/components/modals/GroupModal.tsx +++ b/frontend/src/components/modals/GroupModal.tsx @@ -32,6 +32,39 @@ export function GroupModal({ onAssignBusinessGroup, }: GroupModalProps) { const dialogRef = useDialogAccessibility(isOpen, onCloseGroupManager); + const selectedColorIndex = BUSINESS_GROUP_COLORS.indexOf( + newGroupColor as (typeof BUSINESS_GROUP_COLORS)[number], + ); + + function onColorKeyDown( + event: React.KeyboardEvent, + currentIndex: number, + ): void { + const isNext = event.key === "ArrowRight" || event.key === "ArrowDown"; + const isPrevious = event.key === "ArrowLeft" || event.key === "ArrowUp"; + if (!isNext && !isPrevious) return; + + event.preventDefault(); + const direction = isNext ? 1 : -1; + const nextIndex = + (currentIndex + direction + BUSINESS_GROUP_COLORS.length) % + BUSINESS_GROUP_COLORS.length; + const nextColor = BUSINESS_GROUP_COLORS[nextIndex]; + setNewGroupColor(nextColor); + + const radioGroup = event.currentTarget.closest( + '[role="radiogroup"]', + ); + // Defer the focus shifting logic using setTimeout to ensure + // the React state update cycle has completed. jsdom requestAnimationFrame + // doesn't reliably let state settle before execution. + setTimeout(() => { + const radioButtons = radioGroup?.querySelectorAll( + '[role="radio"]', + ); + radioButtons?.[nextIndex]?.focus(); + }, 0); + } if (!isOpen) return null; @@ -72,14 +105,22 @@ export function GroupModal({ role="radiogroup" aria-label="그룹 색상" > - {BUSINESS_GROUP_COLORS.map((color) => ( + {BUSINESS_GROUP_COLORS.map((color, index) => (