@codeforamerica/marcomms-design-system 1.21.2 → 1.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,9 @@ import { buttonStyles } from "./button";
4
4
  import "./tab";
5
5
 
6
6
  class TabList extends LitElement {
7
- static properties = {};
7
+ static properties = {
8
+ _idCounter: { state: true },
9
+ };
8
10
  static styles = [
9
11
  commonStyles,
10
12
  buttonStyles,
@@ -46,42 +48,72 @@ class TabList extends LitElement {
46
48
 
47
49
  constructor() {
48
50
  super();
51
+ this._idCounter = 0;
49
52
  }
50
53
 
51
54
  firstUpdated() {
52
55
  this.addEventListener("click", this._onTabClick);
56
+ this.addEventListener("keydown", this._onTabKeydown);
53
57
  this.setAttribute("role", "tablist");
54
58
  this._setInitialAriaAttributes();
55
59
  }
56
60
 
61
+ _getTabPanelId(tab) {
62
+ return tab.getAttribute("panelid") || tab.getAttribute("panel-id") || "";
63
+ }
64
+
65
+ _ensureTabId(tab) {
66
+ if (!tab.id) {
67
+ this._idCounter += 1;
68
+ tab.id = `tab-${this._idCounter}`;
69
+ }
70
+ }
71
+
57
72
  _setInitialAriaAttributes() {
58
73
  const tabs = this.querySelectorAll("cfa-tab");
59
74
  tabs.forEach((tab, index) => {
75
+ const panelId = this._getTabPanelId(tab);
76
+
77
+ this._ensureTabId(tab);
60
78
  tab.setAttribute("role", "tab");
79
+ if (panelId) {
80
+ tab.setAttribute("aria-controls", panelId);
81
+ }
61
82
  tab.setAttribute("aria-selected", "false");
62
83
  tab.setAttribute("tabindex", "-1");
84
+ tab.active = false;
63
85
  if (index === 0) {
64
86
  tab.setAttribute("aria-selected", "true");
65
87
  tab.setAttribute("tabindex", "0");
88
+ tab.active = true;
66
89
  }
67
90
  });
68
91
  }
69
92
 
70
93
  _onTabClick(event) {
71
- const tab = event.target.closest("cfa-tab");
94
+ const tab = event.composedPath().find((node) => node?.tagName === "CFA-TAB");
72
95
  if (tab) {
73
96
  this._setActiveTab(tab);
74
97
  }
75
98
  }
76
99
 
100
+ _onTabKeydown(event) {
101
+ if ((event.key === "Enter" || event.key === " ") && event.target?.tagName === "CFA-TAB") {
102
+ event.preventDefault();
103
+ this._setActiveTab(event.target);
104
+ }
105
+ }
106
+
77
107
  _setActiveTab(activeTab) {
78
108
  const tabs = this.querySelectorAll("cfa-tab");
79
109
  tabs.forEach((tab) => {
80
110
  tab.removeAttribute("active");
111
+ tab.active = false;
81
112
  tab.setAttribute("aria-selected", "false");
82
113
  tab.setAttribute("tabindex", "-1");
83
114
  });
84
115
  activeTab.setAttribute("active", "");
116
+ activeTab.active = true;
85
117
  activeTab.setAttribute("aria-selected", "true");
86
118
  activeTab.setAttribute("tabindex", "0");
87
119
 
@@ -102,7 +134,7 @@ class TabList extends LitElement {
102
134
 
103
135
  render() {
104
136
  return html`
105
- <div class="tab-list" role="tablist">
137
+ <div class="tab-list">
106
138
  <slot></slot>
107
139
  </div>
108
140
  `;
@@ -16,3 +16,39 @@ const Template = () => html`
16
16
 
17
17
  export const Default = Template.bind({});
18
18
  Default.args = {};
19
+ Default.play = async ({ canvasElement }) => {
20
+ // Locate the rendered tab-list host in the story canvas.
21
+ const tabList = canvasElement.querySelector("cfa-tab-list");
22
+ if (!tabList) {
23
+ throw new Error("Expected cfa-tab-list to render in story canvas");
24
+ }
25
+
26
+ // Wait one microtask so component lifecycle hooks can finalize ARIA setup.
27
+ await new Promise((resolve) => setTimeout(resolve, 0));
28
+
29
+ const tabs = [...tabList.querySelectorAll("cfa-tab")];
30
+ if (tabs.length !== 4) {
31
+ throw new Error(`Expected 4 tabs, found ${tabs.length}`);
32
+ }
33
+
34
+ // Story-level accessibility assertion: container should expose tablist semantics.
35
+ if (tabList.getAttribute("role") !== "tablist") {
36
+ throw new Error("Expected cfa-tab-list to expose role=tablist");
37
+ }
38
+
39
+ // Initial state should select the first tab in roving tabindex pattern.
40
+ if (tabs[0].getAttribute("aria-selected") !== "true") {
41
+ throw new Error("Expected first tab to be selected initially");
42
+ }
43
+
44
+ // Interaction assertion: clicking a second tab should move active state.
45
+ tabs[1].click();
46
+ await new Promise((resolve) => setTimeout(resolve, 0));
47
+
48
+ if (tabs[1].getAttribute("aria-selected") !== "true") {
49
+ throw new Error("Expected second tab to become selected after click");
50
+ }
51
+ if (tabs[0].getAttribute("aria-selected") !== "false") {
52
+ throw new Error("Expected first tab to become unselected after click");
53
+ }
54
+ };
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ // Import custom elements once so they are registered in the test runtime.
4
+ import "./tab-list";
5
+ import "./tab";
6
+
7
+ // Waits for microtasks and rendering work to settle.
8
+ // Lit updates asynchronously, so this helper avoids race conditions.
9
+ async function flushDomUpdates() {
10
+ await Promise.resolve();
11
+ await new Promise((resolve) => setTimeout(resolve, 0));
12
+ }
13
+
14
+ // Builds a realistic tab-list fixture with IDs matching production usage.
15
+ function createTabListFixture() {
16
+ document.body.innerHTML = `
17
+ <cfa-tab-list>
18
+ <cfa-tab id="tab-overview">Overview</cfa-tab>
19
+ <cfa-tab id="tab-services">Services</cfa-tab>
20
+ <cfa-tab id="tab-contact">Contact</cfa-tab>
21
+ </cfa-tab-list>
22
+ `;
23
+
24
+ return document.querySelector("cfa-tab-list");
25
+ }
26
+
27
+ describe("cfa-tab-list", () => {
28
+ it("sets tab semantics and activates the first tab by default", async () => {
29
+ const tabList = createTabListFixture();
30
+
31
+ // Wait for the parent component to run firstUpdated lifecycle logic.
32
+ await tabList.updateComplete;
33
+ await flushDomUpdates();
34
+
35
+ const tabs = [...tabList.querySelectorAll("cfa-tab")];
36
+
37
+ // Parent role should communicate that this is a tablist region.
38
+ expect(tabList.getAttribute("role")).toBe("tablist");
39
+
40
+ // First tab should be keyboard reachable and selected by default.
41
+ expect(tabs[0].getAttribute("aria-selected")).toBe("true");
42
+ expect(tabs[0].getAttribute("tabindex")).toBe("0");
43
+
44
+ // Non-active tabs should be unfocused in roving tabindex model.
45
+ expect(tabs[1].getAttribute("aria-selected")).toBe("false");
46
+ expect(tabs[1].getAttribute("tabindex")).toBe("-1");
47
+ expect(tabs[2].getAttribute("aria-selected")).toBe("false");
48
+ expect(tabs[2].getAttribute("tabindex")).toBe("-1");
49
+ });
50
+
51
+ it("activates clicked tab and dispatches tab-changed event with normalized ID", async () => {
52
+ const tabList = createTabListFixture();
53
+ await tabList.updateComplete;
54
+
55
+ const tabs = [...tabList.querySelectorAll("cfa-tab")];
56
+
57
+ // Capture event payload to assert the public contract for consumers.
58
+ let eventDetail = null;
59
+ tabList.addEventListener("tab-changed", (event) => {
60
+ eventDetail = event.detail;
61
+ });
62
+
63
+ // Simulate user intent by clicking a different tab.
64
+ tabs[1].click();
65
+ await flushDomUpdates();
66
+
67
+ // Clicked tab should now be selected and keyboard reachable.
68
+ expect(tabs[1].getAttribute("aria-selected")).toBe("true");
69
+ expect(tabs[1].getAttribute("tabindex")).toBe("0");
70
+ expect(tabs[1].hasAttribute("active")).toBe(true);
71
+
72
+ // Previously active tab should now be inactive.
73
+ expect(tabs[0].getAttribute("aria-selected")).toBe("false");
74
+ expect(tabs[0].getAttribute("tabindex")).toBe("-1");
75
+
76
+ // Component strips "tab-" prefix and emits clean ID for listeners.
77
+ expect(eventDetail).toEqual({ id: "services" });
78
+ });
79
+ });
80
+
81
+ describe("cfa-tab", () => {
82
+ it("moves focus to the next tab button on ArrowRight", async () => {
83
+ const tabList = createTabListFixture();
84
+ await tabList.updateComplete;
85
+ await flushDomUpdates();
86
+
87
+ const tabs = [...tabList.querySelectorAll("cfa-tab")];
88
+
89
+ // Host tabs carry roving focus semantics.
90
+ const secondFocusSpy = vi.spyOn(tabs[1], "focus");
91
+
92
+ // ArrowRight should move focus from first tab to second tab.
93
+ // We dispatch on the host because the component attaches keydown there.
94
+ tabs[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
95
+
96
+ expect(secondFocusSpy).toHaveBeenCalledTimes(1);
97
+ });
98
+
99
+ it("moves focus to the previous tab button on ArrowLeft", async () => {
100
+ const tabList = createTabListFixture();
101
+ await tabList.updateComplete;
102
+ await flushDomUpdates();
103
+
104
+ const tabs = [...tabList.querySelectorAll("cfa-tab")];
105
+
106
+ // ArrowLeft from first tab should wrap focus to the final tab.
107
+ // This verifies circular keyboard navigation behavior.
108
+ const lastFocusSpy = vi.spyOn(tabs[2], "focus");
109
+ tabs[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
110
+
111
+ expect(lastFocusSpy).toHaveBeenCalledTimes(1);
112
+ });
113
+
114
+ it("moves focus to first and last tab buttons with Home and End keys", async () => {
115
+ const tabList = createTabListFixture();
116
+ await tabList.updateComplete;
117
+ await flushDomUpdates();
118
+
119
+ const tabs = [...tabList.querySelectorAll("cfa-tab")];
120
+
121
+ const firstFocusSpy = vi.spyOn(tabs[0], "focus");
122
+ const lastFocusSpy = vi.spyOn(tabs[2], "focus");
123
+
124
+ // Home should jump focus to first tab in the tab sequence.
125
+ // We dispatch on the host because the component attaches keydown there.
126
+ tabs[1].dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
127
+
128
+ // End should jump focus to the final tab in the tab sequence.
129
+ tabs[1].dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
130
+
131
+ expect(firstFocusSpy).toHaveBeenCalledTimes(1);
132
+ expect(lastFocusSpy).toHaveBeenCalledTimes(1);
133
+ });
134
+ });
@@ -12,31 +12,39 @@ class Tab extends LitElement {
12
12
  commonStyles,
13
13
  typographyStyles,
14
14
  css`
15
- button {
15
+ .tab {
16
16
  background: none;
17
17
  border: 0;
18
18
  box-shadow: inset 0 calc(-1 * var(--hairline)) 0 0 var(--gray-40);
19
19
  color: var(--gray-80);
20
20
  cursor: pointer;
21
+ display: block;
21
22
  font-family: var(--font-family-sans-serif);
22
23
  font-size: var(--font-size-small);
23
24
  font-weight: normal;
24
25
  padding: var(--spacing-component-2) var(--spacing-component-3)
25
26
  calc(var(--spacing-component-2) + var(--medium));
26
27
  }
27
- button:focus {
28
+
29
+ :host(:focus) .tab {
28
30
  background-color: var(--focus-color);
29
31
  outline: none;
30
32
  }
31
- button:not(.active):hover {
33
+
34
+ .tab:not(.active):hover {
32
35
  box-shadow: inset 0 calc(-1 * var(--medium)) 0 0 var(--purple-40);
33
36
  color: var(--black);
34
37
  }
35
- button.active {
38
+
39
+ .tab.active {
36
40
  box-shadow: inset 0 calc(-1 * var(--medium)) 0 0 var(--purple-60);
37
41
  color: var(--black);
38
42
  font-weight: bold;
39
43
  }
44
+
45
+ :host {
46
+ display: inline-block;
47
+ }
40
48
  `,
41
49
  ];
42
50
 
@@ -57,18 +65,22 @@ class Tab extends LitElement {
57
65
 
58
66
  switch (event.key) {
59
67
  case "ArrowLeft":
68
+ event.preventDefault();
60
69
  newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
61
- tabs[newIndex].shadowRoot.querySelector("button").focus();
70
+ tabs[newIndex].focus();
62
71
  break;
63
72
  case "ArrowRight":
73
+ event.preventDefault();
64
74
  newIndex = (currentIndex + 1) % tabs.length;
65
- tabs[newIndex].shadowRoot.querySelector("button").focus();
75
+ tabs[newIndex].focus();
66
76
  break;
67
77
  case "Home":
68
- tabs[0].shadowRoot.querySelector("button").focus();
78
+ event.preventDefault();
79
+ tabs[0].focus();
69
80
  break;
70
81
  case "End":
71
- tabs[tabs.length - 1].shadowRoot.querySelector("button").focus();
82
+ event.preventDefault();
83
+ tabs[tabs.length - 1].focus();
72
84
  break;
73
85
  default:
74
86
  break;
@@ -77,15 +89,11 @@ class Tab extends LitElement {
77
89
 
78
90
  render() {
79
91
  return html`
80
- <button
81
- role="tab"
82
- aria-selected="${this.active ? "true" : "false"}"
83
- aria-controls="${this.panelId}"
84
- class="${this.active ? "active" : ""}"
85
- tabindex="${this.active ? "0" : "-1"}"
92
+ <span
93
+ class="tab ${this.active ? "active" : ""}"
86
94
  >
87
95
  <slot></slot>
88
- </button>
96
+ </span>
89
97
  `;
90
98
  }
91
99
  }
@@ -31,8 +31,8 @@ class Transcript extends LitElement {
31
31
 
32
32
  render() {
33
33
  return html`
34
- <div class="label eyebrow-with-line">Transcript</div>
35
- <div class="text small">
34
+ <div id="transcript-label" class="label eyebrow-with-line">Transcript</div>
35
+ <div class="text small" tabindex="0" role="region" aria-labelledby="transcript-label">
36
36
  <slot />
37
37
  </div>
38
38
  `;
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { axe } from "vitest-axe";
3
+
4
+ // Register component under test.
5
+ import "./transcript";
6
+
7
+ // Builds a compact transcript fixture with realistic content.
8
+ async function createTranscriptFixture() {
9
+ document.body.innerHTML = `
10
+ <cfa-transcript>
11
+ <p><b>[00:00:01]</b> Example transcript content.</p>
12
+ <p><b>[00:00:12]</b> Additional transcript line.</p>
13
+ </cfa-transcript>
14
+ `;
15
+
16
+ const transcript = document.querySelector("cfa-transcript");
17
+ await transcript.updateComplete;
18
+ await new Promise((resolve) => setTimeout(resolve, 0));
19
+
20
+ return transcript;
21
+ }
22
+
23
+ // Runs axe with a jsdom-safe configuration.
24
+ async function runAxe(container) {
25
+ return axe(container, {
26
+ rules: {
27
+ "color-contrast": { enabled: false },
28
+ },
29
+ });
30
+ }
31
+
32
+ describe("cfa-transcript", () => {
33
+ it("renders transcript label and slotted content container", async () => {
34
+ const transcript = await createTranscriptFixture();
35
+
36
+ const label = transcript.shadowRoot.querySelector(".label");
37
+ const text = transcript.shadowRoot.querySelector(".text");
38
+
39
+ // Component should present a visible transcript heading.
40
+ expect(label.textContent.trim()).toBe("Transcript");
41
+
42
+ // Scrollable text region should render to host slotted content.
43
+ expect(text).toBeTruthy();
44
+ expect(transcript.querySelectorAll("p").length).toBe(2);
45
+ });
46
+
47
+ it("has no unexpected accessibility violations", async () => {
48
+ const transcript = await createTranscriptFixture();
49
+ const results = await runAxe(transcript);
50
+
51
+ expect(results.violations).toHaveLength(0);
52
+ });
53
+ });