Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# CHANGELOG

## [Unreleased]
- **UX/접근성 개선**: KRDS 탭 예제에 단일 roving `tabindex`, 좌우 방향키·Home·End 순환 탐색, 동기화된 선택/패널 상태, 키보드로 접근 가능한 탭 패널을 추가했습니다.
- **UX/접근성 개선**: 프로젝트 카드의 클릭 영역을 카드 전체로 확장하여 사용자 편의성을 높였습니다. <a> 태그를 확장하는 대신 가상 요소(pseudo-element) 겹침 방식을 사용하여 스크린 리더 접근성을 유지했습니다.
- **보안 개선**: 컴포넌트 갤러리의 인라인 스크립트와 스타일을 외부 파일로 분리하고, 엄격한 Content-Security-Policy를 적용해 XSS 방어를 강화했습니다.
- **성능 회귀 복원**: 오프스크린 `.section` 렌더링을 `content-visibility: auto`로 지연하고, 일반 섹션은 600px·콘텐츠가 큰 DIKW/projects 섹션은 1000px의 `contain-intrinsic-size` placeholder를 유지해 초기 렌더링 비용과 스크롤바 이동을 함께 줄였습니다.
Expand Down
14 changes: 7 additions & 7 deletions components/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,14 @@ <h2>Badge &amp; Tag</h2>
<h2>Tabs</h2>
<p class="src">.krds-tabs · Figma Layout/Tabs 59:11</p>
<div class="krds-tabs">
<div class="krds-tabs__list" role="tablist">
<button class="krds-tab" role="tab" aria-selected="true" aria-controls="tp1" id="t1">개요</button>
<button class="krds-tab" role="tab" aria-selected="false" aria-controls="tp2" id="t2">근거</button>
<button class="krds-tab" role="tab" aria-selected="false" aria-controls="tp3" id="t3">참고</button>
<div class="krds-tabs__list" role="tablist" aria-label="상세 정보">
<button class="krds-tab" role="tab" aria-selected="true" aria-controls="tp1" id="t1" tabindex="0">개요</button>
<button class="krds-tab" role="tab" aria-selected="false" aria-controls="tp2" id="t2" tabindex="-1">근거</button>
<button class="krds-tab" role="tab" aria-selected="false" aria-controls="tp3" id="t3" tabindex="-1">참고</button>
</div>
<div class="krds-tabpanel" role="tabpanel" id="tp1" aria-labelledby="t1">개요 패널 내용입니다.</div>
<div class="krds-tabpanel" role="tabpanel" id="tp2" aria-labelledby="t2" hidden>근거 패널 내용입니다.</div>
<div class="krds-tabpanel" role="tabpanel" id="tp3" aria-labelledby="t3" hidden>참고 패널 내용입니다.</div>
<div class="krds-tabpanel" role="tabpanel" id="tp1" aria-labelledby="t1" tabindex="0">개요 패널 내용입니다.</div>
<div class="krds-tabpanel" role="tabpanel" id="tp2" aria-labelledby="t2" tabindex="0" hidden>근거 패널 내용입니다.</div>
<div class="krds-tabpanel" role="tabpanel" id="tp3" aria-labelledby="t3" tabindex="0" hidden>참고 패널 내용입니다.</div>
</div>
</section>

Expand Down
71 changes: 55 additions & 16 deletions components/krds-gallery.js
Original file line number Diff line number Diff line change
@@ -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())
);
49 changes: 49 additions & 0 deletions docs/doctoring/tab-keyboard-interaction.md
Original file line number Diff line number Diff line change
@@ -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/
99 changes: 88 additions & 11 deletions tests/test_component_gallery_security.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,48 @@
"""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:
"""Return the component gallery HTML source."""
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'<meta\s+http-equiv="Content-Security-Policy"\s+content="([^"]+)"',
html,
Expand All @@ -23,6 +51,13 @@ def _csp_content(html: str) -> 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())
Expand Down Expand Up @@ -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'<input[^>]+>', html)
for inp in inputs:
if 'type="checkbox"' in inp or 'type="radio"' in inp:
inputs = re.findall(r"<input[^>]+>", 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
Loading