From 6db530a5cd7a80e0bc16d11b0d8cc2587697ed81 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 00:23:15 +0900 Subject: [PATCH 1/5] test: specify accessible tab keyboard contracts --- tests/test_component_gallery_security.py | 99 +++++++++++++++++++++--- 1 file changed, 88 insertions(+), 11 deletions(-) diff --git a/tests/test_component_gallery_security.py b/tests/test_component_gallery_security.py index 8eaf9e4..5502e62 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,62 @@ 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 From aa70fc3330afe4b8fe60e8cb49351a9638df855a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 00:24:03 +0900 Subject: [PATCH 2/5] fix: make tab panels keyboard reachable --- components/index.html | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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

-
- - - +
+ + +
-
개요 패널 내용입니다.
- - +
개요 패널 내용입니다.
+ +
From d273c4f6975e314acebc433251e293c958af302a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 00:24:29 +0900 Subject: [PATCH 3/5] fix: complete horizontal tab keyboard navigation --- components/krds-gallery.js | 71 +++++++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 16 deletions(-) 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()) +); From f5e8fc74ea97105a3275c3052e89ce3fddd3482b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 00:24:44 +0900 Subject: [PATCH 4/5] docs: record tab accessibility design decision --- docs/doctoring/tab-keyboard-interaction.md | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/doctoring/tab-keyboard-interaction.md 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/ From 144239e3cc14f69abd8d843273acd1d0fd5e2d39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 00:25:05 +0900 Subject: [PATCH 5/5] docs: record tab keyboard accessibility change --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca1847a..d34a6e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # CHANGELOG ## [Unreleased] +- **UX/접근성 개선**: KRDS 탭 예제에 단일 roving `tabindex`, 좌우 방향키·Home·End 순환 탐색, 동기화된 선택/패널 상태, 키보드로 접근 가능한 탭 패널을 추가했습니다. - **UX/접근성 개선**: 프로젝트 카드의 클릭 영역을 카드 전체로 확장하여 사용자 편의성을 높였습니다. 태그를 확장하는 대신 가상 요소(pseudo-element) 겹침 방식을 사용하여 스크린 리더 접근성을 유지했습니다. - **보안 개선**: 컴포넌트 갤러리의 인라인 스크립트와 스타일을 외부 파일로 분리하고, 엄격한 Content-Security-Policy를 적용해 XSS 방어를 강화했습니다. - **성능 회귀 복원**: 오프스크린 `.section` 렌더링을 `content-visibility: auto`로 지연하고, 일반 섹션은 600px·콘텐츠가 큰 DIKW/projects 섹션은 1000px의 `contain-intrinsic-size` placeholder를 유지해 초기 렌더링 비용과 스크롤바 이동을 함께 줄였습니다.