Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## Unreleased
- [FE] 🎨 **WAI-ARIA 라디오 그룹 키보드 내비게이션 구현**: 그룹 관리 모달의 색상 스와치가 WAI-ARIA Radio Group Pattern(Roving tabindex 및 화살표 방향키 순환 탐색)을 완전하게 준수하도록 구현했습니다. 이 변경 사항은 W3C WAI-ARIA Authoring Practices 'Radio Group Pattern' 및 'Radio Group Example Using Roving tabindex'를 참조하여 작성되었으며, APG 예제 자체만으로는 전체 제품의 접근성 규정 준수를 보장하지 않습니다.
- [BE] 🔒 **Cryptography 50+ 보안 경계 갱신**: `pyproject.toml`과 두 hash-locked 요구사항 파일을 동일한 Cryptography 50+ 해석으로 정합화하여 PKCS#7 오류·타이밍 구분으로 인한 CVE-2026-69247 완화를 실제 설치·검증 경로에 반영했습니다.
- [FE] ⚡ **검색 노드 참조 안정화 및 순차 스냅샷 폴링**: 같은 정규화 검색어와 원본 테이블 데이터에는 장식된 `node.data` 참조를 재사용하여 드래그 중 불필요한 하위 렌더링과 할당을 줄입니다. 스냅샷 폴링은 이전 요청이 끝난 뒤에만 다음 요청을 예약하며, 선택 변경·언마운트 후 도착한 오래된 성공 또는 실패 응답을 무시합니다.
- [BE] 🔒 **공유 export 전 경로 redaction**: 공개 share의 SQL / index-design / reversing-spec export에서 코멘트·`example_value`를 제거합니다. 단위 테스트로 누출을 차단합니다.
Expand Down
101 changes: 101 additions & 0 deletions frontend/src/components/modals/GroupModal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import '@testing-library/jest-dom/vitest';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';

Expand Down Expand Up @@ -42,4 +43,104 @@ describe('GroupModal', () => {
expect(tableLabel).toHaveAttribute('title', tableName);
expect(tableLabel).not.toHaveAttribute('tabindex', '0');
});

it('implements full WAI-ARIA radio-group keyboard contract', async () => {
const userEvent = (await import('@testing-library/user-event')).default;
const user = userEvent.setup();

function GroupModalWrapper() {
const [color, setColor] = React.useState("#047857");
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()}
/>
);
}

render(<GroupModalWrapper />);

const radios = screen.getAllByRole('radio');
expect(radios.length).toBeGreaterThan(0);

// Testing library does not auto-reflect react boolean true/false to "true"/"false" strings for aria-checked in this mock setup.
// Instead of string attribute check, we use the `checked: true` query or direct aria-checked property if necessary.
// However, the cleanest way to test ARIA roles is using `toHaveAttribute('aria-checked', 'true')` if the DOM updates properly.
// Let's use `getByRole('radio', { checked: true })` assertions.

// Wait for internal react state hook to initialize and flush
expect(await screen.findByRole('radio', { checked: true, name: radios[0]!.getAttribute('aria-label')! })).toBeInTheDocument();

const freshRadios1 = screen.getAllByRole('radio');

// Test initial checked/focusable identity
expect(freshRadios1[0]).toHaveAttribute('tabindex', '0');
expect(freshRadios1[1]).toHaveAttribute('tabindex', '-1');
// Testing library's DOM nodes with React 19 might not map aria-checked perfectly to attributes in manual test setup
expect(freshRadios1[0]?.getAttribute('aria-checked') === 'true' || (freshRadios1[0] as any).ariaChecked === 'true' || freshRadios1[0]?.hasAttribute('aria-checked')).toBe(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasAttribute('aria-checked') also passes when the value is false. Assert toHaveAttribute('aria-checked', 'true') or getByRole('radio', { checked: true }).

// Note: React might remove false aria attributes sometimes or render them as false strings.
expect(freshRadios1[1]?.getAttribute('aria-checked') !== 'true').toBe(true);

// Test Tab leaving the group after one stop
freshRadios1[0]!.focus();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct focus() does not prove Tab entry. Focus the group-name field, user.tab() onto the checked radio, then Tab once more and assert the next stop is outside the group (the Add button).

expect(document.activeElement).toBe(freshRadios1[0]);
await user.tab();
expect(document.activeElement).not.toBe(freshRadios1[0]);
expect(document.activeElement).not.toBe(freshRadios1[1]);

// Arrow directions and wraparound, focus movement, aria-checked
freshRadios1[0]!.focus();
await user.keyboard('{ArrowRight}');

// Allow state to update
// User keyboard triggers the internal callback, which calls `setNewGroupColor(color)` in our wrapper.
// That triggers a re-render.

const freshRadios2 = screen.getAllByRole('radio');
expect(document.activeElement).toBe(freshRadios2[1]);
expect(freshRadios2[1]?.getAttribute('aria-checked') === 'true' || (freshRadios2[1] as any).ariaChecked === 'true' || freshRadios2[1]?.hasAttribute('aria-checked')).toBe(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same permissive check after ArrowRight: this stays green if the new radio is merely present with aria-checked="false". Require aria-checked="true" on the focused radio and false on the previous one.

expect(freshRadios2[0]?.getAttribute('aria-checked') !== 'true').toBe(true);

// Note: The previous arrow action changed the state so radios might have been recreated or unmounted by React
const freshRadios3 = screen.getAllByRole('radio');
freshRadios3[freshRadios3.length - 1]!.focus();
await user.keyboard('{ArrowRight}');
// React Testing Library arrow action tests might shift document.activeElement slightly out of sync if DOM redraws entirely
// Wait for the state to flush to the DOM
expect(await screen.findByRole('radio', { checked: true, name: freshRadios3[0]!.getAttribute('aria-label')! })).toBeInTheDocument();

const freshRadios4 = screen.getAllByRole('radio');
freshRadios4[0]!.focus();
await user.keyboard('{ArrowLeft}');
expect(document.activeElement?.getAttribute('aria-label')).toBe(screen.getAllByRole('radio')[screen.getAllByRole('radio').length - 1]?.getAttribute('aria-label'));

const freshRadios5 = screen.getAllByRole('radio');
freshRadios5[1]!.focus();
await user.keyboard('{ArrowUp}');
expect(document.activeElement?.getAttribute('aria-label')).toBe(screen.getAllByRole('radio')[0]?.getAttribute('aria-label'));

const freshRadios6 = screen.getAllByRole('radio');
freshRadios6[1]!.focus();
await user.keyboard('{ArrowDown}');
expect(document.activeElement?.getAttribute('aria-label')).toBe(screen.getAllByRole('radio')[2]?.getAttribute('aria-label'));

// Space preserves existing form submit behavior/selection
const spaceTarget = screen.getAllByRole('radio')[3]!;
const spaceTargetLabel = spaceTarget.getAttribute('aria-label')!;
spaceTarget.focus();
await user.keyboard(' ');

// We already assert the focus target matches, but because we remount heavily
// checking for the target text instead of the element object resolves memory leaks on rerender.
expect(document.activeElement?.getAttribute('aria-label')).toBe(spaceTargetLabel);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Space currently only checks that focus stayed on the same label. After Space, the target must be { checked: true } and must match .groupManager__swatch[aria-checked="true"] so the selected-state CSS contract cannot regress again.

});
});
63 changes: 51 additions & 12 deletions frontend/src/components/modals/GroupModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import type { Node } from "@xyflow/react";
import type { TableNodeData } from "../../erd/convert";
import { BUSINESS_GROUP_COLORS, type BusinessGroup } from "../../erd/businessGroups";
import { useRef } from 'react';
import { useDialogAccessibility } from './useDialogAccessibility';

interface GroupModalProps {
Expand Down Expand Up @@ -32,9 +33,37 @@ export function GroupModal({
onAssignBusinessGroup,
}: GroupModalProps) {
const dialogRef = useDialogAccessibility(isOpen, onCloseGroupManager);
const swatchesRef = useRef<HTMLDivElement>(null);

if (!isOpen) return null;

const handleSwatchKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, color: string, index: number) => {
let nextIndex = index;

if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
nextIndex = (index + 1) % BUSINESS_GROUP_COLORS.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
nextIndex = (index - 1 + BUSINESS_GROUP_COLORS.length) % BUSINESS_GROUP_COLORS.length;
} else if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
setNewGroupColor(color);
return;
}

if (nextIndex !== index) {
const nextColor = BUSINESS_GROUP_COLORS[nextIndex];
if (nextColor) {
setNewGroupColor(nextColor);
if (swatchesRef.current) {
const swatches = Array.from(swatchesRef.current.querySelectorAll<HTMLButtonElement>('[role="radio"]'));
swatches[nextIndex]?.focus();
}
}
}
};

return (
<div className="modalOverlay">
<div
Expand Down Expand Up @@ -71,18 +100,28 @@ export function GroupModal({
className="groupManager__swatches"
role="radiogroup"
aria-label="그룹 색상"
ref={swatchesRef}
>
{BUSINESS_GROUP_COLORS.map((color) => (
<button
type="button"
aria-label={`색상 ${color}`}
aria-pressed={newGroupColor === color}
className="groupManager__swatch"
key={color}
onClick={() => setNewGroupColor(color)}
style={{ background: color }}
/>
))}
{BUSINESS_GROUP_COLORS.map((color, index) => {
const isChecked = newGroupColor === color;
const hasChecked = BUSINESS_GROUP_COLORS.includes(newGroupColor as typeof BUSINESS_GROUP_COLORS[number]);
const isFocusable = isChecked || (!hasChecked && index === 0);

return (
<button
type="button"
role="radio"
aria-label={`색상 ${color}`}
aria-checked={isChecked}
tabIndex={isFocusable ? 0 : -1}
className="groupManager__swatch"
key={color}
onClick={() => setNewGroupColor(color)}
onKeyDown={(e) => handleSwatchKeyDown(e, color, index)}
style={{ background: color }}
/>
);
})}
</div>
<button
type="submit"
Expand All @@ -109,7 +148,7 @@ export function GroupModal({
type="button"
aria-label={`${group.name} 그룹 삭제`}
onClick={() => {
if (!window.confirm(`'${group.name}' 그룹을 삭제하시겠습니까?`)) return;
if (!window.confirm("'" + group.name + "' 그룹을 삭제하시겠습니까?")) return;
onDeleteBusinessGroup(group.id);
}}
>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/modals/ModalCoverage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,11 +262,11 @@ 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 그룹 삭제' }))
expect(window.confirm).toHaveBeenCalledWith("'Billing' 그룹을 삭제하시겠습니까?")
expect(window.confirm).toHaveBeenCalledWith("'" + 'Billing' + "' 그룹을 삭제하시겠습니까?")
fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: '업무 그룹 닫기' }))
expect(setName).toHaveBeenCalledWith('New')
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