diff --git a/.Jules/palette.md b/.Jules/palette.md index 7fb6a23..954af5f 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -17,3 +17,7 @@ ## 2024-07-10 - prefers-reduced-motion 지원 추가 **Learning:** 시스템 레벨에서 애니메이션 줄이기(prefers-reduced-motion)를 설정한 사용자를 위해 과도한 애니메이션과 부드러운 스크롤을 비활성화하는 것이 필요합니다. 이때 `0s` 대신 `0.01ms`를 사용하여 `transitionend`와 같은 브라우저 이벤트가 정상적으로 발생하도록 해야 자바스크립트 콜백이 멈추는(hanging) 문제를 방지할 수 있습니다. **Action:** 항상 `styles.css` 하단에 `prefers-reduced-motion: reduce` 미디어 쿼리를 추가하여 모든 요소의 `animation-duration`과 `transition-duration`을 `0.01ms`로 설정하고 `scroll-behavior: auto`를 적용합니다. + +## 2024-07-17 - Roving tabindex and Keyboard Navigation for Tabs +**Learning:** ARIA tablists require roving `tabindex` and arrow key navigation for proper keyboard accessibility. Without it, users have to tab through every single tab to get to the panels, which is inefficient. +**Action:** When creating custom tabs using ARIA `role="tablist"` and `role="tab"`, ensure only the selected tab is in the natural tab order (`tabindex="0"`), while others are removed (`tabindex="-1"`). Handle `ArrowLeft` and `ArrowRight` to switch focus and selection simultaneously. diff --git a/CHANGELOG.md b/CHANGELOG.md index 56ad628..41779fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # CHANGELOG ## [Unreleased] +- **UX/접근성 개선**: KRDS 탭 예제에 단일 roving `tabindex`, 좌우 방향키·Home·End 순환 탐색, 동기화된 선택/패널 상태, 키보드로 접근 가능한 탭 패널을 추가했습니다. - **보안 개선**: 컴포넌트 갤러리의 인라인 스크립트와 스타일을 외부 파일로 분리하고, 엄격한 Content-Security-Policy를 적용해 XSS 방어를 강화했습니다. - **성능 회귀 복원**: 오프스크린 `.section` 렌더링을 `content-visibility: auto`로 지연하고, 일반 섹션은 600px·콘텐츠가 큰 DIKW/projects 섹션은 1000px의 `contain-intrinsic-size` placeholder를 유지해 초기 렌더링 비용과 스크롤바 이동을 함께 줄였습니다. - **보안 개선**: Trusted Types 기반 CSP 강화: 잠재적인 DOM 기반 XSS 공격을 방지하기 위해 `require-trusted-types-for 'script'` 지시어 추가 diff --git a/components/index.html b/components/index.html index 615c223..c53953c 100644 --- a/components/index.html +++ b/components/index.html @@ -101,14 +101,14 @@

Badge & Tag

Tabs

.krds-tabs · Figma Layout/Tabs 59:11

-
- - - +
+ + +
-
개요 패널 내용입니다.
- - +
개요 패널 내용입니다.
+ +
diff --git a/components/krds-gallery.js b/components/krds-gallery.js index 672e72f..fdc30b2 100644 --- a/components/krds-gallery.js +++ b/components/krds-gallery.js @@ -1,17 +1,56 @@ - // Tabs: minimal roving behavior. ponytail: native buttons + aria, no framework. - document.querySelectorAll(".krds-tabs").forEach((tabs) => { - const tabList = [...tabs.querySelectorAll('[role="tab"]')]; - tabList.forEach((tab) => { - tab.addEventListener("click", () => { - tabList.forEach((t) => { - const sel = t === tab; - t.setAttribute("aria-selected", sel); - document.getElementById(t.getAttribute("aria-controls")).hidden = !sel; - }); - }); - }); +// Tabs: WAI-ARIA roving tabindex with automatic activation for local content. +document.querySelectorAll(".krds-tabs").forEach((tabs) => { + const tabList = [...tabs.querySelectorAll('[role="tab"]')]; + + const activateTab = (nextTab, moveFocus = false) => { + tabList.forEach((tab) => { + const isSelected = tab === nextTab; + const panelId = tab.getAttribute("aria-controls"); + const panel = panelId === null ? null : document.getElementById(panelId); + + tab.setAttribute("aria-selected", String(isSelected)); + tab.setAttribute("tabindex", isSelected ? "0" : "-1"); + if (panel !== null) { + panel.hidden = !isSelected; + } }); - // Tag remove - document.querySelectorAll(".krds-tag__remove").forEach((btn) => - btn.addEventListener("click", () => btn.closest(".krds-tag").remove()) - ); + + if (moveFocus) { + nextTab.focus(); + } + }; + + tabList.forEach((tab, index) => { + tab.addEventListener("click", () => activateTab(tab)); + + tab.addEventListener("keydown", (event) => { + let nextIndex; + + switch (event.key) { + case "ArrowRight": + nextIndex = (index + 1) % tabList.length; + break; + case "ArrowLeft": + nextIndex = (index - 1 + tabList.length) % tabList.length; + break; + case "Home": + nextIndex = 0; + break; + case "End": + nextIndex = tabList.length - 1; + break; + default: + return; + } + + event.preventDefault(); + activateTab(tabList[nextIndex], true); + }); + }); +}); + +// Tag remove +// Native buttons preserve keyboard activation and accessible names. +document.querySelectorAll(".krds-tag__remove").forEach((button) => + button.addEventListener("click", () => button.closest(".krds-tag").remove()) +); diff --git a/docs/doctoring/tab-keyboard-interaction.md b/docs/doctoring/tab-keyboard-interaction.md new file mode 100644 index 0000000..c19efbb --- /dev/null +++ b/docs/doctoring/tab-keyboard-interaction.md @@ -0,0 +1,49 @@ +# Tab keyboard interaction decision + +## Scope + +This decision applies to the standalone KRDS component gallery in +`components/index.html` and `components/krds-gallery.js`. The gallery uses +native `button` elements with ARIA `tab`, `tablist`, and `tabpanel` roles. + +## Implemented contract + +- The `tablist` has an accessible name. +- Exactly one selected tab participates in the page tab sequence with + `tabindex="0"`; inactive tabs use `tabindex="-1"`. +- `ArrowLeft` and `ArrowRight` move through tabs with wraparound. +- `Home` moves to the first tab and `End` moves to the last tab. +- Focus movement automatically activates the corresponding panel because the + local panel content is already available and activation has no network or + rendering latency. +- Selection, roving `tabindex`, panel visibility, and focus are updated as one + state transition. +- Each panel is keyboard reachable with `tabindex="0"` because the example + panels contain plain text rather than a naturally focusable first element. + +## Limitations and validation + +The Python regression suite validates the markup relationships, the single +roving tab stop, the accessible tab-list name, panel reachability, and the +presence of all supported keyboard transitions. Browser and assistive- +technology interoperability still requires manual validation on representative +browser and screen-reader combinations before treating the gallery as a +conformance demonstration. + +Automatic activation is appropriate only while panel display remains +instantaneous. If a future panel requires remote loading or expensive +rendering, use manual activation with `Enter` and `Space` instead so arrow-key +navigation remains responsive. + +## References + +World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet +Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/ + +World Wide Web Consortium. (n.d.). *Tabs pattern*. WAI-ARIA Authoring Practices +Guide. Retrieved August 7, 2026, from +https://www.w3.org/WAI/ARIA/apg/patterns/tabs/ + +World Wide Web Consortium. (n.d.). *Developing a keyboard interface*. WAI-ARIA +Authoring Practices Guide. Retrieved August 7, 2026, from +https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/ diff --git a/tests/test_component_gallery_security.py b/tests/test_component_gallery_security.py index 8eaf9e4..0c95c11 100644 --- a/tests/test_component_gallery_security.py +++ b/tests/test_component_gallery_security.py @@ -1,11 +1,34 @@ -"""Security regression tests for the standalone component gallery.""" +"""Security and accessibility regression tests for the component gallery.""" import re +from html.parser import HTMLParser from pathlib import Path ROOT = Path(__file__).resolve().parents[1] GALLERY = ROOT / "components" / "index.html" +GALLERY_SCRIPT = ROOT / "components" / "krds-gallery.js" + + +class _RoleCollector(HTMLParser): + """Collect element attributes for ARIA roles used by the gallery.""" + + def __init__(self) -> None: + """Initialize an empty mapping from ARIA role to attribute dictionaries.""" + super().__init__() + self.elements_by_role: dict[str, list[dict[str, str | None]]] = {} + + def handle_starttag( + self, + tag: str, + attrs: list[tuple[str, str | None]], + ) -> None: + """Record attributes for elements that declare an explicit ARIA role.""" + del tag + attributes = dict(attrs) + role = attributes.get("role") + if role is not None: + self.elements_by_role.setdefault(role, []).append(attributes) def _gallery_html() -> str: @@ -13,8 +36,13 @@ def _gallery_html() -> str: return GALLERY.read_text(encoding="utf-8") +def _gallery_script() -> str: + """Return the component gallery interaction script.""" + return GALLERY_SCRIPT.read_text(encoding="utf-8") + + def _csp_content(html: str) -> str: - """Extract the CSP meta policy from the gallery HTML.""" + """Extract the CSP meta policy from the HTML.""" match = re.search( r' str: return match.group(1) +def _role_elements(html: str) -> dict[str, list[dict[str, str | None]]]: + """Return gallery elements grouped by their explicit ARIA role.""" + collector = _RoleCollector() + collector.feed(html) + return collector.elements_by_role + + def test_component_gallery_declares_strict_csp() -> None: """The standalone gallery limits active content to same-origin assets.""" policy = _csp_content(_gallery_html()) @@ -65,20 +100,65 @@ def test_component_gallery_has_no_inline_active_content() -> None: def test_component_gallery_script_avoids_unsafe_dom_sinks() -> None: """The extracted interaction script keeps Trusted Types enforcement viable.""" - script_path = ROOT / "components" / "krds-gallery.js" - - assert script_path.is_file() - script = script_path.read_text(encoding="utf-8") + assert GALLERY_SCRIPT.is_file() + script = _gallery_script() assert "innerHTML" not in script assert "outerHTML" not in script assert "eval(" not in script assert "new Function" not in script + def test_component_gallery_inputs_have_length_limits() -> None: - """Ensure all text-based inputs have maxlength defined to mitigate DoS risks.""" + """Ensure text-based inputs have length limits to bound browser work.""" html = _gallery_html() - inputs = re.findall(r']+>', html) - for inp in inputs: - if 'type="checkbox"' in inp or 'type="radio"' in inp: + inputs = re.findall(r"]+>", html) + for input_element in inputs: + if 'type="checkbox"' in input_element or 'type="radio"' in input_element: continue - assert 'maxlength=' in inp, f"Input missing maxlength: {inp}" + assert "maxlength=" in input_element, ( + f"Input missing maxlength: {input_element}" + ) + + +def test_tab_markup_uses_one_roving_tab_stop() -> None: + """Exactly one tab is initially keyboard reachable and selected.""" + roles = _role_elements(_gallery_html()) + tablists = roles.get("tablist", []) + tabs = roles.get("tab", []) + panels = roles.get("tabpanel", []) + + assert len(tablists) == 1 + assert tablists[0].get("aria-label"), "tablist needs an accessible name" + assert len(tabs) >= 2 + assert len(panels) == len(tabs) + + selected_tabs = [tab for tab in tabs if tab.get("aria-selected") == "true"] + keyboard_tabs = [tab for tab in tabs if tab.get("tabindex") == "0"] + assert len(selected_tabs) == 1 + assert keyboard_tabs == selected_tabs + assert all( + tab.get("tabindex") in {"0", "-1"} + for tab in tabs + ) + + panel_ids = {panel.get("id") for panel in panels} + tab_ids = {tab.get("id") for tab in tabs} + assert all(tab.get("aria-controls") in panel_ids for tab in tabs) + assert all(panel.get("aria-labelledby") in tab_ids for panel in panels) + assert all(panel.get("tabindex") == "0" for panel in panels) + + +def test_tab_script_supports_complete_horizontal_keyboard_navigation() -> None: + """Tabs support APG horizontal navigation and synchronized state changes.""" + script = _gallery_script() + + for key in ("ArrowLeft", "ArrowRight", "Home", "End"): + assert f'"{key}"' in script + for required_operation in ( + "preventDefault()", + 'setAttribute("aria-selected"', + 'setAttribute("tabindex"', + ".hidden =", + ".focus()", + ): + assert required_operation in script