Skip to content
Draft
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] ♿ **업무 그룹 색상 라디오 그룹 완결**: 색상 스와치에 일관된 `radio`/`aria-checked` 의미를 적용하고, 선택된 항목 하나만 Tab 순서에 두며, 방향키 이동·선택·순환을 동일 상태 전이로 처리합니다. 이름 입력에서 Tab으로 선택된 색상에 들어가고, Space로 포커스된 색상을 선택하며, 선택 링은 `aria-checked="true"` 토큰 그림자로 유지됩니다.
- [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
51 changes: 51 additions & 0 deletions docs/doctoring/group-color-radiogroup-keyboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Doctoring record: business-group color radiogroup

## Decision

The business-group color chooser is one composite single-selection control. Its container uses `role="radiogroup"`; every color swatch uses `role="radio"` and exposes selection through `aria-checked`.

The checked color is the only swatch in the page Tab sequence. The other swatches use `tabIndex={-1}`. Arrow Right and Arrow Down move to the next color; Arrow Left and Arrow Up move to the previous color. Movement wraps at both ends, moves DOM focus, and updates the selected color together. Pointer activation continues to select the clicked color.

When the stored color is not represented in the current palette, the first swatch becomes the safe Tab stop while no unsupported value is silently rewritten merely by rendering the modal.

## Buyer impact

Keyboard and assistive-technology users can enter the color chooser once, inspect the current selection, and traverse the palette without tabbing through every decorative swatch. The chosen state and keyboard focus cannot diverge during arrow-key operation.

## Standards rationale

The WAI-ARIA Authoring Practices radio-group pattern treats a radio group as a composite widget. In the checked state, Tab enters the group at the checked radio; arrow keys move focus and check the adjacent radio, with wrapping at the ends. Roving `tabindex` provides one sequential keyboard stop while preserving direct focus movement inside the group.

This component follows the no-toolbar radio pattern. Space activation remains provided by the native button element; the added arrow-key handling does not suppress Tab, Enter, Escape, or unrelated keys.

The implementation is a component-level accessibility contract. It does not by itself prove whole-product WCAG conformance, visual color distinguishability, forced-colors behavior, screen-reader interoperability, or the semantic suitability of each palette value.

## Verification contract

`frontend/src/components/modals/GroupModal.radiogroup.test.tsx` renders the real controlled component and proves that:

- all palette choices expose the radio role;
- exactly the checked choice has `tabindex="0"`;
- every unchecked choice has `tabindex="-1"`;
- Arrow Right and Arrow Down select and focus the next choice;
- Arrow Left and Arrow Up select and focus the previous choice;
- traversal wraps from the final choice to the first choice;
- Tab from the group-name field lands on the checked radio, and the next Tab leaves the group once;
- Space on a focused radio checks that radio; and
- the checked radio matches `.groupManager__swatch[aria-checked="true"]`, which is the selected-state selector in `GroupModal.css`.

The pre-implementation test-only head `8d81b7629081ecfbb3064467ecd1374f81391184` failed all six focused cases on hosted CI because the original implementation had no roving `tabindex` and no arrow-key behavior. The production repair was then applied in `5a9793976ef55716bd3d56ebddcc59fd8044d5db`.

## Compatibility and rollback

No database, API, persistence, palette color values, group-creation rule, or assignment behavior changes. The selected swatch now uses `aria-checked="true"` with a 3px tokenized `box-shadow` (`--color-text-strong`) in `GroupModal.css` (and the matching global selector) so the checked ring remains visible after the role change from `aria-pressed`. Palette hex values themselves are unchanged. Rollback would restore mismatched radio semantics in which every native button remained a separate Tab stop and arrow keys did not operate the group; therefore rollback must revert the radio role, the selected-state selector, and the keyboard contract together rather than retaining a partially implemented composite widget.

## References

World Wide Web Consortium Web Accessibility Initiative. (2025). *Radio group pattern*. WAI-ARIA Authoring Practices Guide. https://www.w3.org/WAI/ARIA/apg/patterns/radio/

World Wide Web Consortium Web Accessibility Initiative. (2025). *Radio group example using roving tabindex*. WAI-ARIA Authoring Practices Guide. https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio/

World Wide Web Consortium. (2023, October 5). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/

World Wide Web Consortium. (2026, June 4). *Accessible Rich Internet Applications (WAI-ARIA) 1.3* (W3C Working Draft). https://www.w3.org/TR/wai-aria-1.3/
3 changes: 3 additions & 0 deletions frontend/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# CHANGELOG

## [Unreleased]
### Changed
- **업무 그룹 색상 라디오 그룹**: 색상 스와치를 하나의 `radiogroup`으로 두고, 선택된 색상만 Tab으로 들어가며 방향키로 포커스와 선택을 함께 이동합니다. 선택 링은 `aria-checked="true"` 토큰 그림자를 사용합니다.

### Added
- **테이블 및 컬럼 편집 기능**: UI 패널을 통해 노드를 선택하고, 테이블의 이름/코멘트를 수정하며, 컬럼을 추가/수정/삭제하거나 테이블을 삭제할 수 있는 기능 추가.
- **테스트 추가**: 프론트엔드 테스트 커버리지 100% 목표 달성을 위해 `cardinality.ts`, `types.ts`, `export.ts` 의 미달성 분기 및 함수 테스트 추가 (`cardinality_extra.test.ts` 등).
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/modals/GroupModal.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.groupManager__swatch[aria-checked="true"] {
box-shadow: 0 0 0 3px var(--color-text-strong);
}
151 changes: 151 additions & 0 deletions frontend/src/components/modals/GroupModal.radiogroup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import '@testing-library/jest-dom/vitest'
import type { Node } from '@xyflow/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { BUSINESS_GROUP_COLORS, type BusinessGroup } from '../../erd/businessGroups'
import type { TableNodeData } from '../../erd/convert'
import { GroupModal } from './GroupModal'

function RadiogroupHarness({ initialColor = BUSINESS_GROUP_COLORS[0] }: { initialColor?: string }) {
const [color, setColor] = useState(initialColor)

return (
<GroupModal
isOpen
businessGroups={[] as BusinessGroup[]}
newGroupName="Design"
setNewGroupName={vi.fn()}
newGroupColor={color}
setNewGroupColor={setColor}
nodes={[] as Node<TableNodeData>[]}
onCloseGroupManager={vi.fn()}
onCreateBusinessGroup={vi.fn()}
onDeleteBusinessGroup={vi.fn()}
onAssignBusinessGroup={vi.fn()}
/>
)
}

afterEach(() => {
cleanup()
})

describe('GroupModal color radiogroup', () => {
it('exposes exactly one tab stop at the checked color', () => {
render(<RadiogroupHarness initialColor={BUSINESS_GROUP_COLORS[1]} />)

const radios = screen.getAllByRole('radio', { name: /^색상 / })
const checked = screen.getByRole('radio', { checked: true })
expect(radios).toHaveLength(BUSINESS_GROUP_COLORS.length)
expect(checked).toBe(radios[1])
expect(checked).toHaveAttribute('aria-checked', 'true')
expect(checked).toHaveAttribute('tabindex', '0')
expect(checked.matches('.groupManager__swatch[aria-checked="true"]')).toBe(true)

radios.forEach((radio, index) => {
if (index !== 1) {
expect(radio).toHaveAttribute('aria-checked', 'false')
expect(radio).toHaveAttribute('tabindex', '-1')
}
})
})

it('tabs from the name field onto the checked radio and leaves the group on the next Tab', async () => {
const user = userEvent.setup()
render(<RadiogroupHarness initialColor={BUSINESS_GROUP_COLORS[1]} />)

screen.getByLabelText('그룹 이름').focus()
await user.tab()

const checked = screen.getByRole('radio', { checked: true })
expect(checked).toHaveFocus()
expect(checked).toHaveAttribute('aria-checked', 'true')
expect(checked).toHaveAttribute('aria-label', `색상 ${BUSINESS_GROUP_COLORS[1]}`)

await user.tab()
expect(screen.getByRole('button', { name: '추가' })).toHaveFocus()
expect(screen.getByRole('radio', { checked: true })).not.toHaveFocus()
})

it('checks the focused radio with Space and keeps the selected-state selector', async () => {
const user = userEvent.setup()
render(<RadiogroupHarness />)

const target = screen.getByRole('radio', { name: `색상 ${BUSINESS_GROUP_COLORS[3]}` })
target.focus()
await user.keyboard(' ')

const checked = screen.getByRole('radio', { checked: true })
expect(checked).toHaveAttribute('aria-checked', 'true')
expect(checked).toHaveAttribute('aria-label', `색상 ${BUSINESS_GROUP_COLORS[3]}`)
expect(checked).toHaveAttribute('tabindex', '0')
expect(checked.matches('.groupManager__swatch[aria-checked="true"]')).toBe(true)
})

it.each([
['ArrowRight', 1],
['ArrowDown', 1],
['ArrowLeft', BUSINESS_GROUP_COLORS.length - 1],
['ArrowUp', BUSINESS_GROUP_COLORS.length - 1],
] as const)('moves focus and selection with %s', (key, expectedIndex) => {
render(<RadiogroupHarness />)

const radios = screen.getAllByRole('radio', { name: /^색상 / })
radios[0]?.focus()
fireEvent.keyDown(radios[0]!, { key })

expect(radios[expectedIndex]).toHaveFocus()
expect(radios[expectedIndex]).toHaveAttribute('aria-checked', 'true')
expect(radios[expectedIndex]).toHaveAttribute('tabindex', '0')
expect(radios[0]).toHaveAttribute('aria-checked', 'false')
expect(radios[0]).toHaveAttribute('tabindex', '-1')
})

it('wraps from the last color to the first color', () => {
const lastIndex = BUSINESS_GROUP_COLORS.length - 1
render(<RadiogroupHarness initialColor={BUSINESS_GROUP_COLORS[lastIndex]} />)

const radios = screen.getAllByRole('radio', { name: /^색상 / })
radios[lastIndex]?.focus()
fireEvent.keyDown(radios[lastIndex]!, { key: 'ArrowRight' })

expect(radios[0]).toHaveFocus()
expect(radios[0]).toHaveAttribute('aria-checked', 'true')
expect(radios[0]).toHaveAttribute('tabindex', '0')
})

it('keeps an unsupported stored color unchanged until the user selects a palette value', () => {
render(<RadiogroupHarness initialColor="not-in-palette" />)

const radios = screen.getAllByRole('radio', { name: /^색상 / })
expect(radios[0]).toHaveAttribute('tabindex', '0')
radios.forEach((radio) => {
expect(radio).toHaveAttribute('aria-checked', 'false')
})

radios[0]?.focus()
fireEvent.keyDown(radios[0]!, { key: 'ArrowRight' })

expect(radios[1]).toHaveFocus()
expect(radios[1]).toHaveAttribute('aria-checked', 'true')
expect(radios[1]).toHaveAttribute('tabindex', '0')
})

it('preserves native pointer activation and ignores unrelated keys', () => {
render(<RadiogroupHarness />)

const radios = screen.getAllByRole('radio', { name: /^색상 / })
radios[0]?.focus()
fireEvent.keyDown(radios[0]!, { key: 'Home' })

expect(radios[0]).toHaveFocus()
expect(radios[0]).toHaveAttribute('aria-checked', 'true')

fireEvent.click(radios[2]!)
expect(radios[2]).toHaveAttribute('aria-checked', 'true')
expect(radios[2]).toHaveAttribute('tabindex', '0')
})
})
43 changes: 41 additions & 2 deletions frontend/src/components/modals/GroupModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Node } from "@xyflow/react";
import type { TableNodeData } from "../../erd/convert";
import { BUSINESS_GROUP_COLORS, type BusinessGroup } from "../../erd/businessGroups";
import { useDialogAccessibility } from './useDialogAccessibility';
import './GroupModal.css';

interface GroupModalProps {
isOpen: boolean;
Expand Down Expand Up @@ -32,6 +33,38 @@ export function GroupModal({
onAssignBusinessGroup,
}: GroupModalProps) {
const dialogRef = useDialogAccessibility(isOpen, onCloseGroupManager);
const swatchRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
const checkedColorIndex = BUSINESS_GROUP_COLORS.findIndex(
(color) => color === newGroupColor,
);
const tabStopIndex = checkedColorIndex >= 0 ? checkedColorIndex : 0;

const selectColorAt = (index: number) => {
const color = BUSINESS_GROUP_COLORS[index];
if (color === undefined) return;

setNewGroupColor(color);
swatchRefs.current[index]?.focus();
};

const handleColorKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>,
index: number,
) => {
let nextIndex: number | null = null;

if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
nextIndex = (index + 1) % BUSINESS_GROUP_COLORS.length;
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
nextIndex =
(index - 1 + BUSINESS_GROUP_COLORS.length) % BUSINESS_GROUP_COLORS.length;
}

if (nextIndex === null) return;

event.preventDefault();
selectColorAt(nextIndex);
};

if (!isOpen) return null;

Expand Down Expand Up @@ -72,15 +105,21 @@ 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}
className="groupManager__swatch"
key={color}
onClick={() => setNewGroupColor(color)}
onKeyDown={(event) => handleColorKeyDown(event, index)}
ref={(element) => {
swatchRefs.current[index] = element;
}}
style={{ background: color }}
tabIndex={tabStopIndex === index ? 0 : -1}
/>
))}
</div>
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