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