Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
742da8c
🎨 Palette: 커스텀 라디오 그룹(색상 선택기) 접근성 개선
seonghobae Aug 3, 2026
a18ef31
Merge branch 'main' into palette/color-swatch-a11y-10094327760965026187
seonghobae Aug 4, 2026
64ecff1
chore: drop stray pnpm-lock.yaml (repo uses npm; breaks central cover…
seonghobae Aug 4, 2026
8793882
Merge branch 'main' into palette/color-swatch-a11y-10094327760965026187
opencode-agent[bot] Aug 4, 2026
9da2ea9
🎨 Palette: 커스텀 라디오 그룹(색상 선택기) 접근성 개선
seonghobae Aug 4, 2026
85de93d
Merge branch 'main' into palette/color-swatch-a11y-10094327760965026187
opencode-agent[bot] Aug 4, 2026
30991c5
chore(frontend): remove unrelated package-manager drift
seonghobae Aug 4, 2026
cbb049c
chore(docs): preserve canonical palette history
seonghobae Aug 4, 2026
881114a
fix(a11y): implement radio-group roving focus
seonghobae Aug 4, 2026
a55bb33
test(a11y): cover radio roving tabindex and arrow navigation
seonghobae Aug 4, 2026
f8be5e1
docs(a11y): record radio-group evidence in APA 7
seonghobae Aug 4, 2026
4ce05b3
test(a11y): assert current radio nodes after rerender
seonghobae Aug 4, 2026
cac4d09
docs: record color radio keyboard contract
seonghobae Aug 11, 2026
33098bf
test(a11y): close radio review and coverage gaps
seonghobae Aug 11, 2026
e34178e
chore: merge protected main into radio-group branch
seonghobae Aug 14, 2026
44377be
🎨 Palette: jsdom 포커스 테스트 타이밍 문제 해결
seonghobae Aug 14, 2026
dfb9fc5
Merge branch 'main' into palette/color-swatch-a11y-10094327760965026187
opencode-agent[bot] Aug 14, 2026
3432c41
🎨 Palette: jsdom 테스트 포커스 이동 안정화 (setTimeout 교체)
seonghobae Aug 14, 2026
2a46e1c
fix(deps): preserve protected-main Vite version
seonghobae Aug 14, 2026
9c4dd2f
fix(deps): restore canonical frontend lock
seonghobae Aug 14, 2026
cb6ead2
chore(frontend): exact-head dependency drift remediation
seonghobae Aug 14, 2026
91e73d5
chore(frontend): exact-head dependency drift remediation
seonghobae Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 반영).
Expand Down
17 changes: 17 additions & 0 deletions docs/doctoring/accessibility-radio-group.md
Original file line number Diff line number Diff line change
@@ -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/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
98 changes: 98 additions & 0 deletions frontend/src/components/modals/GroupModal.radioKeyboard.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<GroupModal
isOpen
businessGroups={[]}
newGroupName=""
setNewGroupName={vi.fn()}
newGroupColor={color}
setNewGroupColor={setColor}
nodes={[]}
onCloseGroupManager={vi.fn()}
onCreateBusinessGroup={vi.fn()}
onDeleteBusinessGroup={vi.fn()}
onAssignBusinessGroup={vi.fn()}
/>
)
}

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(<RadioGroupHarness initialColor={BUSINESS_GROUP_COLORS[1]} />)
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(<RadioGroupHarness initialColor={BUSINESS_GROUP_COLORS[0]} />)
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(<RadioGroupHarness initialColor="not-in-palette" />)
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')
})
})
})
Comment thread
seonghobae marked this conversation as resolved.
Resolved
45 changes: 43 additions & 2 deletions frontend/src/components/modals/GroupModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>,
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<HTMLElement>(
'[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<HTMLButtonElement>(
'[role="radio"]',
);
radioButtons?.[nextIndex]?.focus();
}, 0);
}

if (!isOpen) return null;

Expand Down Expand Up @@ -72,14 +105,22 @@ export function GroupModal({
role="radiogroup"
aria-label="그룹 색상"
>
{BUSINESS_GROUP_COLORS.map((color) => (
{BUSINESS_GROUP_COLORS.map((color, index) => (
<button
type="button"
role="radio"
aria-label={`색상 ${color}`}
aria-pressed={newGroupColor === color}
aria-checked={newGroupColor === color}
tabIndex={
newGroupColor === color ||
(selectedColorIndex === -1 && index === 0)
? 0
: -1
}
className="groupManager__swatch"
key={color}
onClick={() => setNewGroupColor(color)}
onKeyDown={(event) => onColorKeyDown(event, index)}
style={{ background: color }}
/>
))}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/modals/ModalCoverage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ describe('modal behavior coverage', () => {
/>,
)
fireEvent.change(screen.getByLabelText('그룹 이름'), { target: { value: 'New' } })
fireEvent.click(screen.getAllByRole('button', { name: /^색상 / })[1]!)
fireEvent.click(screen.getAllByRole('radio', { name: /^색상 / })[1]!)
fireEvent.click(screen.getByRole('button', { name: '추가' }))
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
fireEvent.click(screen.getByRole('button', { name: 'Billing 그룹 삭제' }))
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -943,7 +943,7 @@ button:disabled {
box-shadow: 0 0 0 1px var(--color-border);
}

.groupManager__swatch[aria-pressed="true"] {
.groupManager__swatch[aria-checked="true"] {
box-shadow: 0 0 0 3px var(--color-text-strong);
}

Expand Down
Loading