Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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/
93 changes: 93 additions & 0 deletions frontend/src/components/modals/GroupModal.radioKeyboard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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', () => {
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' })
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' })
radios = colorRadios()
expect(radios[1]).toHaveFocus()
expect(radios[1]).toHaveAttribute('aria-checked', 'true')
})

it('wraps arrow navigation and ignores unrelated keys', () => {
render(<RadioGroupHarness initialColor={BUSINESS_GROUP_COLORS[0]} />)
let radios = colorRadios()

radios[0]!.focus()
fireEvent.keyDown(radios[0]!, { key: 'ArrowLeft' })
radios = colorRadios()
expect(radios.at(-1)).toHaveFocus()
expect(radios.at(-1)).toHaveAttribute('aria-checked', 'true')

fireEvent.keyDown(radios.at(-1)!, { key: 'ArrowDown' })
radios = colorRadios()
expect(radios[0]).toHaveFocus()
expect(radios[0]).toHaveAttribute('aria-checked', 'true')

fireEvent.keyDown(radios[0]!, { key: 'Home' })
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')
})
})
})
40 changes: 38 additions & 2 deletions frontend/src/components/modals/GroupModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,34 @@ 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"]',
);
const radioButtons = radioGroup?.querySelectorAll<HTMLButtonElement>(
'[role="radio"]',
);
radioButtons?.[nextIndex]?.focus();
}

if (!isOpen) return null;

Expand Down Expand Up @@ -72,14 +100,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