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'` 지시어 추가