@codeforamerica/marcomms-design-system 1.21.2 → 1.22.0

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.
@@ -0,0 +1,177 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ // Register the custom element once for the test runtime.
4
+ import "./carousel";
5
+
6
+ // Produces a full matchMedia-like object shape used by component code.
7
+ // Keeping this helper centralized makes reduced-motion tests explicit and readable.
8
+ function createMatchMediaResult(matches) {
9
+ return {
10
+ matches,
11
+ media: "(prefers-reduced-motion: reduce)",
12
+ onchange: null,
13
+ addListener: () => {},
14
+ removeListener: () => {},
15
+ addEventListener: () => {},
16
+ removeEventListener: () => {},
17
+ dispatchEvent: () => false,
18
+ };
19
+ }
20
+
21
+ // Lit + slotchange initialization in this component involves async phases:
22
+ // 1) initial render
23
+ // 2) slotchange debounce
24
+ // 3) follow-up updates
25
+ // This helper waits through those phases so assertions target stable DOM state.
26
+ async function waitForCarouselReady(carousel) {
27
+ await carousel.updateComplete;
28
+ await new Promise((resolve) => setTimeout(resolve, 75));
29
+ await carousel.updateComplete;
30
+ }
31
+
32
+ // Creates a minimal carousel fixture with plain div slides.
33
+ // Using simple slides keeps these tests focused on carousel behavior itself.
34
+ async function createCarouselFixture({
35
+ slideCount = 5,
36
+ itemsPerView = 2,
37
+ autoPlayDuration = 250,
38
+ reducedMotion = false,
39
+ } = {}) {
40
+ const originalMatchMedia = window.matchMedia;
41
+
42
+ // Set reduced-motion mode before constructing the element,
43
+ // because the constructor reads matchMedia immediately.
44
+ window.matchMedia = vi.fn().mockImplementation(() => {
45
+ return createMatchMediaResult(reducedMotion);
46
+ });
47
+
48
+ const carousel = document.createElement("cfa-carousel");
49
+ carousel.setAttribute("items-per-view", String(itemsPerView));
50
+ carousel.autoPlayDuration = autoPlayDuration;
51
+
52
+ for (let index = 0; index < slideCount; index += 1) {
53
+ const slide = document.createElement("div");
54
+ slide.textContent = `Slide ${index + 1}`;
55
+ slide.setAttribute("data-test-slide", String(index));
56
+ carousel.appendChild(slide);
57
+ }
58
+
59
+ document.body.appendChild(carousel);
60
+ await waitForCarouselReady(carousel);
61
+
62
+ // Restore the original global to avoid leaking state into other tests.
63
+ window.matchMedia = originalMatchMedia;
64
+
65
+ return carousel;
66
+ }
67
+
68
+ function getVisibleSlideIndexes(carousel) {
69
+ return [...carousel.querySelectorAll("[data-slide-active]")].map((element) => {
70
+ return Number(element.getAttribute("data-test-slide"));
71
+ });
72
+ }
73
+
74
+ describe("cfa-carousel", () => {
75
+ it("initializes pagination and marks the first page of slides as active", async () => {
76
+ const carousel = await createCarouselFixture({ slideCount: 5, itemsPerView: 2 });
77
+
78
+ // With 5 slides and 2 per page, we expect 3 pages.
79
+ expect(carousel.totalSlides).toBe(5);
80
+ expect(carousel.totalPages).toBe(3);
81
+ expect(carousel.currentPage).toBe(0);
82
+
83
+ // Page 1 should show slide indexes 0 and 1.
84
+ expect(getVisibleSlideIndexes(carousel)).toEqual([0, 1]);
85
+
86
+ // Pagination controls should render one dot per page.
87
+ const dots = carousel.shadowRoot.querySelectorAll(".carousel-dot");
88
+ expect(dots.length).toBe(3);
89
+ expect(dots[0].getAttribute("aria-current")).toBe("true");
90
+ });
91
+
92
+ it("moves forward and backward with control buttons, including wrap-around", async () => {
93
+ const carousel = await createCarouselFixture({ slideCount: 5, itemsPerView: 2 });
94
+
95
+ const previousButton = carousel.shadowRoot.querySelector("button[aria-label='Previous page']");
96
+ const nextButton = carousel.shadowRoot.querySelector("button[aria-label='Next page']");
97
+
98
+ // Move forward once: page 0 -> page 1 (slides 2 and 3).
99
+ nextButton.click();
100
+ await carousel.updateComplete;
101
+ expect(carousel.currentPage).toBe(1);
102
+ expect(getVisibleSlideIndexes(carousel)).toEqual([2, 3]);
103
+
104
+ // Move backward once: page 1 -> page 0 (slides 0 and 1).
105
+ previousButton.click();
106
+ await carousel.updateComplete;
107
+ expect(carousel.currentPage).toBe(0);
108
+ expect(getVisibleSlideIndexes(carousel)).toEqual([0, 1]);
109
+
110
+ // Move backward at page 0 should wrap to last page (page 2, slide 4 only).
111
+ previousButton.click();
112
+ await carousel.updateComplete;
113
+ expect(carousel.currentPage).toBe(2);
114
+ expect(getVisibleSlideIndexes(carousel)).toEqual([4]);
115
+ });
116
+
117
+ it("jumps to a specific page when a pagination dot is clicked", async () => {
118
+ const carousel = await createCarouselFixture({ slideCount: 5, itemsPerView: 2 });
119
+
120
+ const dots = carousel.shadowRoot.querySelectorAll(".carousel-dot");
121
+
122
+ // Click the final dot (page index 2).
123
+ dots[2].click();
124
+ await carousel.updateComplete;
125
+
126
+ expect(carousel.currentPage).toBe(2);
127
+ expect(getVisibleSlideIndexes(carousel)).toEqual([4]);
128
+ expect(dots[2].getAttribute("aria-current")).toBe("true");
129
+ });
130
+
131
+ it("pauses and resumes autoplay by clearing and recreating timers", async () => {
132
+ const carousel = await createCarouselFixture({
133
+ slideCount: 5,
134
+ itemsPerView: 2,
135
+ autoPlayDuration: 200,
136
+ reducedMotion: false,
137
+ });
138
+
139
+ // Initialization starts autoplay when reduced-motion is not requested.
140
+ expect(carousel.isPaused).toBe(false);
141
+ expect(carousel.autoPlayTimer).toBeTruthy();
142
+ expect(carousel.autoPlayTimeout).toBeTruthy();
143
+
144
+ // Pause should fully stop autoplay and clear timer handles.
145
+ carousel.pause();
146
+ expect(carousel.isPaused).toBe(true);
147
+ expect(carousel.autoPlayTimer).toBeNull();
148
+ expect(carousel.autoPlayTimeout).toBeNull();
149
+
150
+ // Play should re-enable autoplay and recreate timer handles.
151
+ carousel.play();
152
+ expect(carousel.isPaused).toBe(false);
153
+ expect(carousel.autoPlayTimer).toBeTruthy();
154
+ expect(carousel.autoPlayTimeout).toBeTruthy();
155
+ });
156
+
157
+ it("disables autoplay when reduced motion is preferred", async () => {
158
+ const carousel = await createCarouselFixture({
159
+ slideCount: 5,
160
+ itemsPerView: 2,
161
+ autoPlayDuration: 200,
162
+ reducedMotion: true,
163
+ });
164
+
165
+ // Reduced motion mode should prevent autoplay from starting.
166
+ expect(carousel.prefersReducedMotion).toBe(true);
167
+ expect(carousel.isPaused).toBe(true);
168
+ expect(carousel.autoPlayTimer).toBeNull();
169
+ expect(carousel.autoPlayTimeout).toBeNull();
170
+
171
+ // Calling play() should still respect reduced motion and stay paused.
172
+ carousel.play();
173
+ expect(carousel.isPaused).toBe(true);
174
+ expect(carousel.autoPlayTimer).toBeNull();
175
+ expect(carousel.autoPlayTimeout).toBeNull();
176
+ });
177
+ });
@@ -18,8 +18,11 @@ const Template = () => html`
18
18
  </div>
19
19
  </div>
20
20
  <div class="cfa-form-group">
21
+ <label for="single-select-example" class="cfa-label"
22
+ >Choose one option</label
23
+ >
21
24
  <div class="cfa-input-container">
22
- <select class="cfa-select">
25
+ <select id="single-select-example" class="cfa-select">
23
26
  <option value="" disabled selected>Select your option</option>
24
27
  <option value="1">Option 1</option>
25
28
  <option value="2">Option 2</option>
@@ -27,8 +30,11 @@ const Template = () => html`
27
30
  </div>
28
31
  </div>
29
32
  <div class="cfa-form-group">
33
+ <label for="multi-select-example" class="cfa-label"
34
+ >Choose one or more options</label
35
+ >
30
36
  <div class="cfa-input-container">
31
- <select class="cfa-multiselect" multiple>
37
+ <select id="multi-select-example" class="cfa-multiselect" multiple>
32
38
  <option value="" disabled selected>Select your option</option>
33
39
  <option value="1">Option 1</option>
34
40
  <option value="2">Option 2</option>
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { axe } from "vitest-axe";
3
+
4
+ import "./label";
5
+
6
+ async function createLabelFixture() {
7
+ document.body.innerHTML = `
8
+ <cfa-label color="green">News</cfa-label>
9
+ `;
10
+
11
+ const label = document.querySelector("cfa-label");
12
+ await label.updateComplete;
13
+ await new Promise((resolve) => setTimeout(resolve, 0));
14
+
15
+ return label;
16
+ }
17
+
18
+ async function runAxe(container) {
19
+ return axe(container, {
20
+ rules: {
21
+ "color-contrast": { enabled: false },
22
+ },
23
+ });
24
+ }
25
+
26
+ describe("cfa-label", () => {
27
+ it("renders label text and applies color attribute", async () => {
28
+ const label = await createLabelFixture();
29
+
30
+ const rootLabel = label.shadowRoot.querySelector(".label");
31
+ const slot = rootLabel.querySelector("slot");
32
+
33
+ expect(slot).toBeTruthy();
34
+ expect(label.textContent.trim()).toBe("News");
35
+ expect(rootLabel.getAttribute("color")).toBe("green");
36
+ });
37
+
38
+ it("has no unexpected accessibility violations", async () => {
39
+ const label = await createLabelFixture();
40
+ const results = await runAxe(label);
41
+
42
+ expect(results.violations).toHaveLength(0);
43
+ });
44
+ });
@@ -8,7 +8,7 @@ ul.is-style-link-list, /* WordPress list style class */
8
8
  --border-hover-color: var(--purple-60);
9
9
  --text-color: var(--black);
10
10
  --text-hover-color: var(--purple-80);
11
- --icon-color: var(--gray-40);
11
+ --icon-color: var(--gray-60);
12
12
  --icon-hover-color: var(--purple-60);
13
13
 
14
14
  list-style: none;
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ // Register component under test.
4
+ import "./nav";
5
+
6
+ // Builds a fixture with realistic list content.
7
+ async function createNavFixture(fontSize = "base") {
8
+ document.body.innerHTML = `
9
+ <cfa-nav fontSize="${fontSize}">
10
+ <ul>
11
+ <li><a href="#actions">Actions</a></li>
12
+ <li><a href="#schedule">Schedule</a></li>
13
+ <li><a href="#faq">FAQs</a></li>
14
+ </ul>
15
+ </cfa-nav>
16
+ `;
17
+
18
+ const nav = document.querySelector("cfa-nav");
19
+ await nav.updateComplete;
20
+ await new Promise((resolve) => setTimeout(resolve, 0));
21
+
22
+ return nav;
23
+ }
24
+
25
+ describe("cfa-nav", () => {
26
+ it("clones slotted list items into the internal navigation list", async () => {
27
+ const nav = await createNavFixture();
28
+
29
+ const internalItems = nav.shadowRoot.querySelectorAll("nav ul li");
30
+
31
+ // Component should replicate slotted items into shadow DOM nav structure.
32
+ expect(internalItems.length).toBe(3);
33
+ expect(internalItems[0].textContent.trim()).toBe("Actions");
34
+ expect(internalItems[1].textContent.trim()).toBe("Schedule");
35
+ expect(internalItems[2].textContent.trim()).toBe("FAQs");
36
+ });
37
+
38
+ it("applies configured size and keeps links keyboard-focusable", async () => {
39
+ const nav = await createNavFixture("large");
40
+
41
+ const navElement = nav.shadowRoot.querySelector("nav");
42
+ const firstLink = nav.shadowRoot.querySelector("nav ul li a");
43
+
44
+ // Font size option should be reflected by data attribute contract.
45
+ expect(navElement.getAttribute("data-font-size")).toBe("large");
46
+
47
+ // Anchor elements should remain default keyboard stops.
48
+ expect(firstLink.tabIndex).toBe(0);
49
+ });
50
+ });
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ // Register component under test.
4
+ import "./pagination";
5
+ import "./icon";
6
+
7
+ // Builds a pagination fixture with configurable page state.
8
+ async function createPaginationFixture({ page = 1, totalPages = 3 } = {}) {
9
+ document.body.innerHTML = `
10
+ <cfa-pagination page="${page}" totalPages="${totalPages}" previousLabel="Previous" nextLabel="Next"></cfa-pagination>
11
+ `;
12
+
13
+ const pagination = document.querySelector("cfa-pagination");
14
+ await pagination.updateComplete;
15
+ await new Promise((resolve) => setTimeout(resolve, 0));
16
+
17
+ return pagination;
18
+ }
19
+
20
+ describe("cfa-pagination", () => {
21
+ it("shows status text and only the Next button on first page", async () => {
22
+ const pagination = await createPaginationFixture({ page: 1, totalPages: 3 });
23
+
24
+ const status = pagination.shadowRoot.querySelector(".status");
25
+ const previousButton = pagination.shadowRoot.querySelector(".previous button");
26
+ const nextButton = pagination.shadowRoot.querySelector(".next button");
27
+
28
+ // First page should not show Previous button.
29
+ expect(previousButton).toBeNull();
30
+ expect(nextButton).toBeTruthy();
31
+ expect(status.textContent.trim()).toBe("Page 1 of 3");
32
+ });
33
+
34
+ it("emits page-change and advances when Next is clicked", async () => {
35
+ const pagination = await createPaginationFixture({ page: 1, totalPages: 3 });
36
+
37
+ let emittedDetail = null;
38
+ pagination.addEventListener("page-change", (event) => {
39
+ emittedDetail = event.detail;
40
+ });
41
+
42
+ const nextButton = pagination.shadowRoot.querySelector(".next button");
43
+ nextButton.click();
44
+ await pagination.updateComplete;
45
+
46
+ // Component should increment page and emit new page number.
47
+ expect(pagination.page).toBe(2);
48
+ expect(emittedDetail).toBe(2);
49
+ });
50
+
51
+ it("emits page-change and goes back when Previous is clicked", async () => {
52
+ const pagination = await createPaginationFixture({ page: 2, totalPages: 3 });
53
+
54
+ let emittedDetail = null;
55
+ pagination.addEventListener("page-change", (event) => {
56
+ emittedDetail = event.detail;
57
+ });
58
+
59
+ const previousButton = pagination.shadowRoot.querySelector(".previous button");
60
+ previousButton.click();
61
+ await pagination.updateComplete;
62
+
63
+ // Component should decrement page and emit new page number.
64
+ expect(pagination.page).toBe(1);
65
+ expect(emittedDetail).toBe(1);
66
+ });
67
+ });
@@ -143,6 +143,8 @@ class PersonCard extends LitElement {
143
143
  target="${this.linkTarget || "_self"}"
144
144
  rel="${this.linkTarget == "_blank" ? "noopener" : nothing}"
145
145
  class="link-overlay"
146
+ aria-label="View profile for ${this.name?.trim() || "this person"}"
147
+ title="View profile for ${this.name?.trim() || "this person"}"
146
148
  tabindex="-1"
147
149
  >
148
150
  </a>
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { axe } from "vitest-axe";
3
+
4
+ import "./pullquote";
5
+
6
+ async function createPullquoteFixture() {
7
+ document.body.innerHTML = `
8
+ <cfa-pullquote>
9
+ <p>Accessible defaults reduce long-term maintenance overhead.</p>
10
+ </cfa-pullquote>
11
+ `;
12
+
13
+ const pullquote = document.querySelector("cfa-pullquote");
14
+ await pullquote.updateComplete;
15
+ await new Promise((resolve) => setTimeout(resolve, 0));
16
+
17
+ return pullquote;
18
+ }
19
+
20
+ async function runAxe(container) {
21
+ return axe(container, {
22
+ rules: {
23
+ "color-contrast": { enabled: false },
24
+ },
25
+ });
26
+ }
27
+
28
+ describe("cfa-pullquote", () => {
29
+ it("renders blockquote wrapper for slotted content", async () => {
30
+ const pullquote = await createPullquoteFixture();
31
+
32
+ const blockquote = pullquote.shadowRoot.querySelector("blockquote");
33
+
34
+ expect(blockquote).toBeTruthy();
35
+ expect(pullquote.querySelector("p").textContent).toContain("Accessible defaults");
36
+ });
37
+
38
+ it("has no unexpected accessibility violations", async () => {
39
+ const pullquote = await createPullquoteFixture();
40
+ const results = await runAxe(pullquote);
41
+
42
+ expect(results.violations).toHaveLength(0);
43
+ });
44
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { axe } from "vitest-axe";
3
+
4
+ import "./quote";
5
+
6
+ async function createQuoteFixture() {
7
+ document.body.innerHTML = `
8
+ <cfa-quote>
9
+ <span slot="text">Design systems should improve consistency and speed.</span>
10
+ <span slot="attribution">Team Marcomms</span>
11
+ </cfa-quote>
12
+ `;
13
+
14
+ const quote = document.querySelector("cfa-quote");
15
+ await quote.updateComplete;
16
+ await new Promise((resolve) => setTimeout(resolve, 0));
17
+
18
+ return quote;
19
+ }
20
+
21
+ async function runAxe(container) {
22
+ return axe(container, {
23
+ rules: {
24
+ "color-contrast": { enabled: false },
25
+ },
26
+ });
27
+ }
28
+
29
+ describe("cfa-quote", () => {
30
+ it("renders quote semantics and attribution slot", async () => {
31
+ const quote = await createQuoteFixture();
32
+
33
+ const blockquote = quote.shadowRoot.querySelector("blockquote");
34
+ const figcaption = quote.shadowRoot.querySelector("figcaption");
35
+
36
+ expect(blockquote).toBeTruthy();
37
+ expect(figcaption).toBeTruthy();
38
+ expect(quote.querySelector('[slot="attribution"]').textContent).toContain("Team Marcomms");
39
+ });
40
+
41
+ it("has no unexpected accessibility violations", async () => {
42
+ const quote = await createQuoteFixture();
43
+ const results = await runAxe(quote);
44
+
45
+ expect(results.violations).toHaveLength(0);
46
+ });
47
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { axe } from "vitest-axe";
3
+
4
+ // Register component dependencies for this test module.
5
+ import "./reveal";
6
+ import "./icon";
7
+
8
+ // Builds a minimal reveal fixture with realistic slotted content.
9
+ async function createRevealFixture() {
10
+ document.body.innerHTML = `
11
+ <cfa-reveal showLabel="Show more" hideLabel="Show less">
12
+ <p>Hidden details text</p>
13
+ </cfa-reveal>
14
+ `;
15
+
16
+ const reveal = document.querySelector("cfa-reveal");
17
+ await reveal.updateComplete;
18
+ await new Promise((resolve) => setTimeout(resolve, 0));
19
+
20
+ return reveal;
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-reveal", () => {
33
+ it("toggles expanded state and button label on click", async () => {
34
+ const reveal = await createRevealFixture();
35
+
36
+ const button = reveal.shadowRoot.querySelector("button");
37
+ const slot = reveal.shadowRoot.querySelector("slot#content");
38
+
39
+ // Initial state should be collapsed.
40
+ expect(reveal.expanded).toBe(false);
41
+ expect(button.getAttribute("aria-expanded")).toBe("false");
42
+ expect(button.textContent).toContain("Show more");
43
+ expect(slot.hidden).toBe(true);
44
+
45
+ // Clicking should expand and update visible control text/state.
46
+ button.click();
47
+ await reveal.updateComplete;
48
+
49
+ expect(reveal.expanded).toBe(true);
50
+ expect(button.getAttribute("aria-expanded")).toBe("true");
51
+ expect(button.textContent).toContain("Show less");
52
+ expect(slot.hidden).toBe(false);
53
+ });
54
+
55
+ it("has no unexpected accessibility violations", async () => {
56
+ const reveal = await createRevealFixture();
57
+ const results = await runAxe(reveal);
58
+
59
+ expect(results.violations).toHaveLength(0);
60
+ });
61
+ });
@@ -43,7 +43,3 @@ Default.args = {
43
43
  linkUrl: "#",
44
44
  };
45
45
 
46
- export const SlideWithoutImage = Template.bind({});
47
- SlideWithoutImage.args = {
48
- linkUrl: "#",
49
- };
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { axe } from "vitest-axe";
3
+
4
+ // Register components so fixtures can instantiate custom elements.
5
+ import "./tab-list";
6
+ import "./tab";
7
+
8
+ // Creates a representative tab interface with explicit panel links.
9
+ // This mirrors realistic component usage and gives axe meaningful semantics to inspect.
10
+ async function createA11yFixture() {
11
+ document.body.innerHTML = `
12
+ <section aria-label="News tabs example">
13
+ <cfa-tab-list>
14
+ <cfa-tab id="tab-overview" panelid="panel-overview">Overview</cfa-tab>
15
+ <cfa-tab id="tab-updates" panelid="panel-updates">Updates</cfa-tab>
16
+ <cfa-tab id="tab-events" panelid="panel-events">Events</cfa-tab>
17
+ </cfa-tab-list>
18
+
19
+ <div id="panel-overview" role="tabpanel" aria-labelledby="tab-overview">Overview content</div>
20
+ <div id="panel-updates" role="tabpanel" aria-labelledby="tab-updates">Updates content</div>
21
+ <div id="panel-events" role="tabpanel" aria-labelledby="tab-events">Events content</div>
22
+ </section>
23
+ `;
24
+
25
+ const tabList = document.querySelector("cfa-tab-list");
26
+ await tabList.updateComplete;
27
+ await new Promise((resolve) => setTimeout(resolve, 0));
28
+
29
+ return tabList;
30
+ }
31
+
32
+ // Runs axe with a jsdom-safe configuration.
33
+ // The color-contrast rule depends on canvas APIs not implemented in jsdom,
34
+ // so we disable that single rule in unit tests and keep semantic checks active.
35
+ async function runAxe(container) {
36
+ return axe(container, {
37
+ rules: {
38
+ "color-contrast": { enabled: false },
39
+ },
40
+ });
41
+ }
42
+
43
+ describe("cfa-tab-list accessibility", () => {
44
+ it("applies expected tab semantics and ARIA state on startup", async () => {
45
+ const tabList = await createA11yFixture();
46
+ const tabs = [...tabList.querySelectorAll("cfa-tab")];
47
+
48
+ // Parent tablist semantics are required for assistive technologies.
49
+ expect(tabList.getAttribute("role")).toBe("tablist");
50
+
51
+ // Roving tabindex and selected state should start on the first tab.
52
+ expect(tabs[0].getAttribute("aria-selected")).toBe("true");
53
+ expect(tabs[0].getAttribute("tabindex")).toBe("0");
54
+
55
+ // Remaining tabs should be inactive until user interaction.
56
+ expect(tabs[1].getAttribute("aria-selected")).toBe("false");
57
+ expect(tabs[1].getAttribute("tabindex")).toBe("-1");
58
+ expect(tabs[2].getAttribute("aria-selected")).toBe("false");
59
+ expect(tabs[2].getAttribute("tabindex")).toBe("-1");
60
+ });
61
+
62
+ it("maintains a predictable roving focus order when active tab changes", async () => {
63
+ const tabList = await createA11yFixture();
64
+ const tabs = [...tabList.querySelectorAll("cfa-tab")];
65
+
66
+ // Before interaction, first tab should be the only keyboard stop.
67
+ expect(tabs[0].getAttribute("tabindex")).toBe("0");
68
+ expect(tabs[1].getAttribute("tabindex")).toBe("-1");
69
+ expect(tabs[2].getAttribute("tabindex")).toBe("-1");
70
+
71
+ // After activating the second tab, focus order should move with it.
72
+ tabs[1].click();
73
+ await new Promise((resolve) => setTimeout(resolve, 0));
74
+
75
+ expect(tabs[0].getAttribute("tabindex")).toBe("-1");
76
+ expect(tabs[1].getAttribute("tabindex")).toBe("0");
77
+ expect(tabs[2].getAttribute("tabindex")).toBe("-1");
78
+
79
+ });
80
+
81
+ it("has no unexpected accessibility violations for tab and tabpanel structure", async () => {
82
+ const tabList = await createA11yFixture();
83
+ const results = await runAxe(tabList.parentElement);
84
+
85
+ expect(results.violations).toHaveLength(0);
86
+ });
87
+ });