Skip to content
Closed
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
4 changes: 4 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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 μˆœν™˜ 탐색, λ™κΈ°ν™”λœ 선택/νŒ¨λ„ μƒνƒœ, ν‚€λ³΄λ“œλ‘œ μ ‘κ·Ό κ°€λŠ₯ν•œ νƒ­ νŒ¨λ„μ„ μΆ”κ°€ν–ˆμŠ΅λ‹ˆλ‹€.
- **λ³΄μ•ˆ κ°œμ„ **: μ»΄ν¬λ„ŒνŠΈ 가러리의 인라인 μŠ€ν¬λ¦½νŠΈμ™€ μŠ€νƒ€μΌμ„ μ™ΈλΆ€ 파일둜 λΆ„λ¦¬ν•˜κ³ , μ—„κ²©ν•œ 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'` μ§€μ‹œμ–΄ μΆ”κ°€
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/
102 changes: 91 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,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'<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