From 44d6eb5aa9fb71cade5c4cb5aacab88ae3539eef Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:06:36 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20=ED=83=AD=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=82=A4=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=ED=83=90=EC=83=89(Roving=20tabindex)=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WAI-ARIA 탭 디자인 패턴에 따라 탭 컴포넌트에 Roving tabindex 지원을 추가했습니다. - 선택된 탭만 `tabindex="0"`을 가지고 나머지는 `-1`을 가져 탭 키로 패널에 바로 접근할 수 있습니다. - 방향키(`ArrowRight`, `ArrowLeft`)를 사용하여 탭을 이동하고 선택할 수 있도록 `keydown` 이벤트를 처리했습니다. - 스크린 리더 사용자를 위해 `tablist`에 `aria-label`을 추가했습니다. --- .Jules/palette.md | 4 ++++ components/index.html | 8 ++++---- components/krds-gallery.js | 28 +++++++++++++++++++++------- 3 files changed, 29 insertions(+), 11 deletions(-) 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/components/index.html b/components/index.html index 615c223..51295a5 100644 --- a/components/index.html +++ b/components/index.html @@ -101,10 +101,10 @@

Badge & Tag

Tabs

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

-
- - - +
+ + +
개요 패널 내용입니다.
diff --git a/components/krds-gallery.js b/components/krds-gallery.js index 672e72f..8ed28df 100644 --- a/components/krds-gallery.js +++ b/components/krds-gallery.js @@ -1,13 +1,27 @@ // 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; - }); + + const selectTab = (tab) => { + tabList.forEach((t) => { + const sel = t === tab; + t.setAttribute("aria-selected", sel); + t.setAttribute("tabindex", sel ? "0" : "-1"); + document.getElementById(t.getAttribute("aria-controls")).hidden = !sel; + }); + tab.focus(); + }; + + tabList.forEach((tab, index) => { + tab.addEventListener("click", () => selectTab(tab)); + + tab.addEventListener("keydown", (e) => { + if (e.key === "ArrowRight" || e.key === "ArrowLeft") { + e.preventDefault(); + const direction = e.key === "ArrowRight" ? 1 : -1; + const newIndex = (index + direction + tabList.length) % tabList.length; + selectTab(tabList[newIndex]); + } }); }); }); From 9ff407a7501b2cf88693070e856f3610e5dcc8f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 00:19:31 +0900 Subject: [PATCH 2/6] test: specify accessible tab keyboard contracts --- tests/test_component_gallery_security.py | 102 ++++++++++++++++++++--- 1 file changed, 91 insertions(+), 11 deletions(-) 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 From daa739e14674b866f21b2f86c5536a7ed3074f1c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 00:20:30 +0900 Subject: [PATCH 3/6] fix: make tab panels keyboard reachable --- components/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/index.html b/components/index.html index 51295a5..c53953c 100644 --- a/components/index.html +++ b/components/index.html @@ -106,9 +106,9 @@

Tabs

-
개요 패널 내용입니다.
- - +
개요 패널 내용입니다.
+ +
From 527ce9b15eebdea28fcd6e62667604c55274f24d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 00:20:58 +0900 Subject: [PATCH 4/6] fix: complete horizontal tab keyboard navigation --- components/krds-gallery.js | 85 ++++++++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 30 deletions(-) diff --git a/components/krds-gallery.js b/components/krds-gallery.js index 8ed28df..fdc30b2 100644 --- a/components/krds-gallery.js +++ b/components/krds-gallery.js @@ -1,31 +1,56 @@ - // Tabs: minimal roving behavior. ponytail: native buttons + aria, no framework. - document.querySelectorAll(".krds-tabs").forEach((tabs) => { - const tabList = [...tabs.querySelectorAll('[role="tab"]')]; - - const selectTab = (tab) => { - tabList.forEach((t) => { - const sel = t === tab; - t.setAttribute("aria-selected", sel); - t.setAttribute("tabindex", sel ? "0" : "-1"); - document.getElementById(t.getAttribute("aria-controls")).hidden = !sel; - }); - tab.focus(); - }; - - tabList.forEach((tab, index) => { - tab.addEventListener("click", () => selectTab(tab)); - - tab.addEventListener("keydown", (e) => { - if (e.key === "ArrowRight" || e.key === "ArrowLeft") { - e.preventDefault(); - const direction = e.key === "ArrowRight" ? 1 : -1; - const newIndex = (index + direction + tabList.length) % tabList.length; - selectTab(tabList[newIndex]); - } - }); - }); +// 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 ad118db9264b990979cf2c9f8d7d354cb1a5596d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 00:21:32 +0900 Subject: [PATCH 5/6] 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 4652c4857e95fd19c06eae83de82301ac3946dbd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 00:21:54 +0900 Subject: [PATCH 6/6] docs: record tab keyboard accessibility change --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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'` 지시어 추가