srcdev-nuxt-components 9.5.7 → 9.6.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.
- package/.claude/skills/component-compliance-checklist.md +8 -2
- package/.claude/skills/component-dynamic-slots.md +3 -3
- package/.claude/skills/component-export-types.md +10 -16
- package/.claude/skills/component-naming.md +1 -1
- package/.claude/skills/components/alert-content.md +9 -1
- package/.claude/skills/components/breadcrumb.md +5 -0
- package/.claude/skills/components/container-glow.md +21 -8
- package/.claude/skills/components/display-avatar.md +13 -7
- package/.claude/skills/components/display-chip.md +37 -24
- package/.claude/skills/components/display-pill.md +58 -8
- package/.claude/skills/components/display-prompt.md +8 -5
- package/.claude/skills/components/display-toast.md +21 -4
- package/.claude/skills/components/glowing-border.md +20 -2
- package/.claude/skills/components/input-button.md +3 -0
- package/.claude/skills/components/responsive-header.md +59 -0
- package/.claude/skills/components/select-menu.md +54 -5
- package/.claude/skills/components/service-detail.md +32 -0
- package/.claude/skills/components/service-summary.md +8 -0
- package/.claude/skills/components/services-card-grid.md +5 -3
- package/.claude/skills/components/services-card.md +22 -12
- package/.claude/skills/components/site-header.md +2 -2
- package/.claude/skills/components/slider-gallery.md +10 -4
- package/.claude/skills/components/tabbed-content.md +300 -0
- package/.claude/skills/composable-anchor-scroll.md +16 -15
- package/.claude/skills/index.md +4 -5
- package/.claude/skills/storybook-add-story.md +53 -0
- package/.claude/skills/theming-status-tokens.md +60 -0
- package/.vscode/srcdev-component-display-avatar.code-snippets +2 -1
- package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
- package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
- package/.vscode/srcdev-component-responsive-header.code-snippets +16 -0
- package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
- package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
- package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
- package/.vscode/srcdev-component-services-card.code-snippets +38 -36
- package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
- package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
- package/app/assets/styles/setup/03.theming/_status.css +24 -0
- package/app/assets/styles/setup/03.theming/index.css +1 -0
- package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
- package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
- package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
- package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
- package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
- package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
- package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
- package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
- package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
- package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
- package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +78 -9
- package/app/components/01.atoms/display-pill/DisplayPill.vue +59 -23
- package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
- package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
- package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
- package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
- package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
- package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
- package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
- package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
- package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
- package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
- package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
- package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
- package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
- package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
- package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
- package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
- package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +7 -5
- package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +6 -2
- package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +24 -3
- package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +19 -0
- package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +2 -1
- package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +52 -19
- package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
- package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
- package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
- package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
- package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
- package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
- package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
- package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
- package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
- package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
- package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
- package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
- package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
- package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
- package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
- package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
- package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
- package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
- package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
- package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
- package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
- package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
- package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
- package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
- package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
- package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +42 -11
- package/app/components/03.organisms/responsive-header/NavigationItems.vue +60 -16
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +86 -24
- package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +52 -0
- package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +20 -0
- package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +40 -0
- package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
- package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
- package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
- package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +5 -4
- package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
- package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
- package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
- package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
- package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +2 -2
- package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
- package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
- package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
- package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
- package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
- package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
- package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
- package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
- package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +6 -6
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
- package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
- package/app/components/05.forms/input-button/InputButton.vue +3 -1
- package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
- package/app/composables/useAnchorScroll.ts +1 -1
- package/app/composables/useTabs.ts +148 -206
- package/app/types/components/display-chip.d.ts +8 -4
- package/app/types/components/display-toast.d.ts +2 -0
- package/app/types/components/index.ts +0 -1
- package/app/types/components/select-menu.d.ts +2 -0
- package/app/utils/pausableTimeout.ts +41 -0
- package/nuxt.config.ts +1 -1
- package/package.json +1 -1
- package/.claude/skills/components/site-navigation.md +0 -145
- package/.claude/skills/components/tab-navigation.md +0 -272
- package/.claude/skills/components/tabs-core.md +0 -187
- package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
- package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
- package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
- package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
- package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
- package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
- package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
- package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
- package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
- package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
- package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +0 -30
- package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -150
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -518
- package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -340
- package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -339
- package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
- package/app/composables/useNavCollapse.ts +0 -86
- package/app/types/components/nav-item.d.ts +0 -11
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import { defineComponent, nextTick, ref } from "vue";
|
|
4
|
+
import TabbedContent from "../TabbedContent.vue";
|
|
5
|
+
|
|
6
|
+
class MockResizeObserver {
|
|
7
|
+
observe = vi.fn();
|
|
8
|
+
unobserve = vi.fn();
|
|
9
|
+
disconnect = vi.fn();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const slots = {
|
|
13
|
+
"tab-0-trigger": "Tab One",
|
|
14
|
+
"tab-0-content": "<p class='panel-0'>Content one</p>",
|
|
15
|
+
"tab-1-trigger": "Tab Two",
|
|
16
|
+
"tab-1-content": "<p class='panel-1'>Content two</p>",
|
|
17
|
+
"tab-2-trigger": "Tab Three",
|
|
18
|
+
"tab-2-content": "<p class='panel-2'>Content three</p>",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const fiveSlots = {
|
|
22
|
+
...slots,
|
|
23
|
+
"tab-3-trigger": "Tab Four",
|
|
24
|
+
"tab-3-content": "<p class='panel-3'>Content four</p>",
|
|
25
|
+
"tab-4-trigger": "Tab Five",
|
|
26
|
+
"tab-4-content": "<p class='panel-4'>Content five</p>",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const TRIGGER = ".tabbed-content-trigger";
|
|
30
|
+
const PANEL = ".tabbed-content-panel";
|
|
31
|
+
|
|
32
|
+
describe("TabbedContent", () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ─── Mount ───────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
it("mounts without error", async () => {
|
|
40
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
41
|
+
expect(wrapper.vm).toBeTruthy();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("renders one trigger and one panel per itemCount", async () => {
|
|
45
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
46
|
+
expect(wrapper.findAll(TRIGGER)).toHaveLength(3);
|
|
47
|
+
expect(wrapper.findAll(PANEL)).toHaveLength(3);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("applies the axis class and styleClassPassthrough to the root", async () => {
|
|
51
|
+
const wrapper = await mountSuspended(TabbedContent, {
|
|
52
|
+
props: { itemCount: 3, axis: "y", styleClassPassthrough: ["my-tabs"] },
|
|
53
|
+
slots,
|
|
54
|
+
});
|
|
55
|
+
expect(wrapper.classes()).toEqual(expect.arrayContaining(["tabbed-content", "axis-y", "my-tabs"]));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ─── Accessibility structure ─────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
it("renders a tablist with the default aria-label", async () => {
|
|
61
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
62
|
+
const tablist = wrapper.find("[role='tablist']");
|
|
63
|
+
expect(tablist.exists()).toBe(true);
|
|
64
|
+
expect(tablist.attributes("aria-label")).toBe("Tabs");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("uses a custom ariaLabel when provided", async () => {
|
|
68
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3, ariaLabel: "Settings" }, slots });
|
|
69
|
+
expect(wrapper.find("[role='tablist']").attributes("aria-label")).toBe("Settings");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("sets aria-orientation from the axis", async () => {
|
|
73
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
74
|
+
expect(wrapper.find("[role='tablist']").attributes("aria-orientation")).toBe("horizontal");
|
|
75
|
+
await wrapper.setProps({ axis: "y" });
|
|
76
|
+
expect(wrapper.find("[role='tablist']").attributes("aria-orientation")).toBe("vertical");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("owns the tabs directly, with no list items in between", async () => {
|
|
80
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
81
|
+
const tablist = wrapper.find("[role='tablist']").element;
|
|
82
|
+
expect(tablist.querySelectorAll("li")).toHaveLength(0);
|
|
83
|
+
expect(tablist.querySelectorAll(":scope > [role='tab']")).toHaveLength(3);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("hides the decorative indicators from assistive tech", async () => {
|
|
87
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
88
|
+
["hover", "active", "underline", "underline-hover"].forEach((name) => {
|
|
89
|
+
expect(wrapper.find(`.tabbed-content-indicator-${name}`).attributes("aria-hidden")).toBe("true");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("renders each trigger as a type=button with role=tab", async () => {
|
|
94
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
95
|
+
wrapper.findAll(TRIGGER).forEach((trigger) => {
|
|
96
|
+
expect(trigger.attributes("type")).toBe("button");
|
|
97
|
+
expect(trigger.attributes("role")).toBe("tab");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("links each trigger and panel both ways (aria-controls / aria-labelledby)", async () => {
|
|
102
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
103
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
104
|
+
const panels = wrapper.findAll(PANEL);
|
|
105
|
+
panels.forEach((panel, index) => {
|
|
106
|
+
expect(panel.attributes("role")).toBe("tabpanel");
|
|
107
|
+
expect(panel.attributes("aria-labelledby")).toBe(triggers[index]!.attributes("id"));
|
|
108
|
+
expect(triggers[index]!.attributes("aria-controls")).toBe(panel.attributes("id"));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("gives each instance its own ids", async () => {
|
|
113
|
+
const Host = defineComponent({
|
|
114
|
+
components: { TabbedContent },
|
|
115
|
+
template: `
|
|
116
|
+
<div>
|
|
117
|
+
<TabbedContent :item-count="2"><template #tab-0-trigger>A</template><template #tab-1-trigger>B</template></TabbedContent>
|
|
118
|
+
<TabbedContent :item-count="2"><template #tab-0-trigger>A</template><template #tab-1-trigger>B</template></TabbedContent>
|
|
119
|
+
</div>
|
|
120
|
+
`,
|
|
121
|
+
});
|
|
122
|
+
const wrapper = await mountSuspended(Host);
|
|
123
|
+
// Per instance: two triggers, two panels and the More menu.
|
|
124
|
+
const ids = wrapper.findAll("[id]").map((el) => el.attributes("id"));
|
|
125
|
+
expect(ids).toHaveLength(10);
|
|
126
|
+
expect(new Set(ids).size).toBe(10);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ─── Active tab state ────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
it("marks the first tab active and only its panel visible on mount", async () => {
|
|
132
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
133
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
134
|
+
const panels = wrapper.findAll(PANEL);
|
|
135
|
+
|
|
136
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
137
|
+
expect(triggers[0]!.attributes("tabindex")).toBe("0");
|
|
138
|
+
expect(triggers[1]!.attributes("aria-selected")).toBe("false");
|
|
139
|
+
expect(triggers[1]!.attributes("tabindex")).toBe("-1");
|
|
140
|
+
|
|
141
|
+
expect(panels[0]!.attributes("hidden")).toBeUndefined();
|
|
142
|
+
expect(panels[1]!.attributes("hidden")).toBeDefined();
|
|
143
|
+
expect(panels[2]!.attributes("hidden")).toBeDefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("activates a tab on click and updates aria-selected, tabindex, and panel visibility", async () => {
|
|
147
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
148
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
149
|
+
const panels = wrapper.findAll(PANEL);
|
|
150
|
+
|
|
151
|
+
await triggers[1]!.trigger("click");
|
|
152
|
+
|
|
153
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("false");
|
|
154
|
+
expect(triggers[0]!.attributes("tabindex")).toBe("-1");
|
|
155
|
+
expect(triggers[1]!.attributes("aria-selected")).toBe("true");
|
|
156
|
+
expect(triggers[1]!.attributes("tabindex")).toBe("0");
|
|
157
|
+
|
|
158
|
+
expect(panels[0]!.attributes("hidden")).toBeDefined();
|
|
159
|
+
expect(panels[1]!.attributes("hidden")).toBeUndefined();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("activates the tab when the click lands on an element inside the trigger", async () => {
|
|
163
|
+
const wrapper = await mountSuspended(TabbedContent, {
|
|
164
|
+
props: { itemCount: 2 },
|
|
165
|
+
slots: { "tab-0-trigger": "One", "tab-1-trigger": "<span class='inner'>Two</span>" },
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await wrapper.find(".inner").trigger("click");
|
|
169
|
+
|
|
170
|
+
expect(wrapper.findAll(TRIGGER)[1]!.attributes("aria-selected")).toBe("true");
|
|
171
|
+
expect(wrapper.findAll(PANEL)[1]!.attributes("hidden")).toBeUndefined();
|
|
172
|
+
expect(wrapper.findAll(PANEL)[0]!.attributes("hidden")).toBeDefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ─── itemCount edge cases ────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
it.each([0, -2, Number.NaN])("renders no tabs for itemCount %s", async (itemCount) => {
|
|
178
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount } });
|
|
179
|
+
expect(wrapper.findAll(TRIGGER)).toHaveLength(0);
|
|
180
|
+
expect(wrapper.findAll(PANEL)).toHaveLength(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("rounds a fractional itemCount down", async () => {
|
|
184
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 2.7 }, slots });
|
|
185
|
+
expect(wrapper.findAll(TRIGGER)).toHaveLength(2);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("picks up tabs added after mount", async () => {
|
|
189
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 2 }, slots: fiveSlots });
|
|
190
|
+
await wrapper.setProps({ itemCount: 4 });
|
|
191
|
+
await nextTick();
|
|
192
|
+
|
|
193
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
194
|
+
expect(triggers).toHaveLength(4);
|
|
195
|
+
expect(triggers[3]!.attributes("tabindex")).toBe("-1");
|
|
196
|
+
|
|
197
|
+
await triggers[0]!.trigger("keydown", { key: "End" });
|
|
198
|
+
expect(triggers[3]!.attributes("aria-selected")).toBe("true");
|
|
199
|
+
expect(wrapper.findAll(PANEL)[3]!.attributes("hidden")).toBeUndefined();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("falls back to the first tab when the active tab is removed", async () => {
|
|
203
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
204
|
+
await wrapper.findAll(TRIGGER)[2]!.trigger("click");
|
|
205
|
+
|
|
206
|
+
await wrapper.setProps({ itemCount: 2 });
|
|
207
|
+
await nextTick();
|
|
208
|
+
|
|
209
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
210
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
211
|
+
expect(triggers[0]!.attributes("tabindex")).toBe("0");
|
|
212
|
+
expect(wrapper.findAll(PANEL)[0]!.attributes("hidden")).toBeUndefined();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("keeps the active tab when tabs are added after it", async () => {
|
|
216
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots: fiveSlots });
|
|
217
|
+
await wrapper.findAll(TRIGGER)[1]!.trigger("click");
|
|
218
|
+
|
|
219
|
+
await wrapper.setProps({ itemCount: 5 });
|
|
220
|
+
await nextTick();
|
|
221
|
+
|
|
222
|
+
expect(wrapper.findAll(TRIGGER)[1]!.attributes("aria-selected")).toBe("true");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ─── Keyboard navigation ─────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
it("moves focus and activates the next tab on ArrowRight (axis x)", async () => {
|
|
228
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
229
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
230
|
+
|
|
231
|
+
await triggers[0]!.trigger("keydown", { key: "ArrowRight" });
|
|
232
|
+
|
|
233
|
+
expect(triggers[1]!.attributes("aria-selected")).toBe("true");
|
|
234
|
+
expect(triggers[1]!.attributes("tabindex")).toBe("0");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("wraps to the first tab on ArrowRight from the last tab", async () => {
|
|
238
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
239
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
240
|
+
|
|
241
|
+
await triggers[2]!.trigger("click");
|
|
242
|
+
await triggers[2]!.trigger("keydown", { key: "ArrowRight" });
|
|
243
|
+
|
|
244
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("moves focus to the previous tab on ArrowLeft (axis x)", async () => {
|
|
248
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
249
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
250
|
+
|
|
251
|
+
await triggers[1]!.trigger("click");
|
|
252
|
+
await triggers[1]!.trigger("keydown", { key: "ArrowLeft" });
|
|
253
|
+
|
|
254
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("uses ArrowUp/ArrowDown instead of ArrowLeft/ArrowRight when axis is y", async () => {
|
|
258
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3, axis: "y" }, slots });
|
|
259
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
260
|
+
|
|
261
|
+
await triggers[0]!.trigger("keydown", { key: "ArrowRight" });
|
|
262
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
263
|
+
|
|
264
|
+
await triggers[0]!.trigger("keydown", { key: "ArrowDown" });
|
|
265
|
+
expect(triggers[1]!.attributes("aria-selected")).toBe("true");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("switches arrow keys when axis changes after mount", async () => {
|
|
269
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
270
|
+
await wrapper.setProps({ axis: "y" });
|
|
271
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
272
|
+
|
|
273
|
+
await triggers[0]!.trigger("keydown", { key: "ArrowRight" });
|
|
274
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
275
|
+
|
|
276
|
+
await triggers[0]!.trigger("keydown", { key: "ArrowDown" });
|
|
277
|
+
expect(triggers[1]!.attributes("aria-selected")).toBe("true");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("jumps to the last tab on End and the first tab on Home", async () => {
|
|
281
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
282
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
283
|
+
|
|
284
|
+
await triggers[0]!.trigger("keydown", { key: "End" });
|
|
285
|
+
expect(triggers[2]!.attributes("aria-selected")).toBe("true");
|
|
286
|
+
|
|
287
|
+
await triggers[2]!.trigger("keydown", { key: "Home" });
|
|
288
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ─── trackHover / trackActive / trackIndicator ──────────────────────────
|
|
292
|
+
|
|
293
|
+
it("renders all four indicators by default", async () => {
|
|
294
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
295
|
+
expect(wrapper.find(".tabbed-content-indicator-hover").exists()).toBe(true);
|
|
296
|
+
expect(wrapper.find(".tabbed-content-indicator-active").exists()).toBe(true);
|
|
297
|
+
expect(wrapper.find(".tabbed-content-indicator-underline").exists()).toBe(true);
|
|
298
|
+
expect(wrapper.find(".tabbed-content-indicator-underline-hover").exists()).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it.each([
|
|
302
|
+
{ trackHover: false, trackIndicator: true },
|
|
303
|
+
{ trackHover: true, trackIndicator: false },
|
|
304
|
+
])("omits the hover underline unless both trackHover and trackIndicator are on (%o)", async (flags) => {
|
|
305
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3, ...flags }, slots });
|
|
306
|
+
expect(wrapper.find(".tabbed-content-indicator-underline-hover").exists()).toBe(false);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("omits the hover indicator when trackHover is false", async () => {
|
|
310
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3, trackHover: false }, slots });
|
|
311
|
+
expect(wrapper.find(".tabbed-content-indicator-hover").exists()).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("omits the active indicator when trackActive is false", async () => {
|
|
315
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3, trackActive: false }, slots });
|
|
316
|
+
expect(wrapper.find(".tabbed-content-indicator-active").exists()).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("omits the underline indicator when trackIndicator is false", async () => {
|
|
320
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3, trackIndicator: false }, slots });
|
|
321
|
+
expect(wrapper.find(".tabbed-content-indicator-underline").exists()).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("adds and removes indicators when the track props change after mount", async () => {
|
|
325
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3, trackActive: false }, slots });
|
|
326
|
+
await wrapper.setProps({ trackActive: true, trackHover: false });
|
|
327
|
+
expect(wrapper.find(".tabbed-content-indicator-active").exists()).toBe(true);
|
|
328
|
+
expect(wrapper.find(".tabbed-content-indicator-hover").exists()).toBe(false);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("stops tracking hover when trackHover turns off after mount", async () => {
|
|
332
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
333
|
+
await wrapper.setProps({ trackHover: false });
|
|
334
|
+
const bar = wrapper.find(".tabbed-content-bar").element as HTMLElement;
|
|
335
|
+
const before = bar.style.getPropertyValue("--_hovered-duration");
|
|
336
|
+
|
|
337
|
+
await wrapper.findAll(TRIGGER)[2]!.trigger("mouseenter");
|
|
338
|
+
|
|
339
|
+
expect(bar.style.getPropertyValue("--_hovered-duration")).toBe(before);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// ─── Indicator movement ──────────────────────────────────────────────────
|
|
343
|
+
// jsdom has no layout: tabs are mocked 100px wide side by side in a 500px bar.
|
|
344
|
+
|
|
345
|
+
describe("indicator edges", () => {
|
|
346
|
+
const mockRow = () => {
|
|
347
|
+
vi.spyOn(HTMLElement.prototype, "offsetLeft", "get").mockImplementation(function (this: HTMLElement) {
|
|
348
|
+
return this.hasAttribute("data-nav-item") ? Number(this.dataset.tabIndex) * 100 : 0;
|
|
349
|
+
});
|
|
350
|
+
vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (this: HTMLElement) {
|
|
351
|
+
return this.hasAttribute("data-nav-item") ? 100 : 0;
|
|
352
|
+
});
|
|
353
|
+
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(function (this: HTMLElement) {
|
|
354
|
+
return this.classList.contains("tabbed-content-bar") ? 500 : 0;
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
afterEach(() => {
|
|
359
|
+
vi.restoreAllMocks();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const edges = (bar: HTMLElement, kind: "active" | "hovered") => ({
|
|
363
|
+
start: bar.style.getPropertyValue(`--_${kind}-start`),
|
|
364
|
+
end: bar.style.getPropertyValue(`--_${kind}-end`),
|
|
365
|
+
startDelay: bar.style.getPropertyValue(`--_${kind}-start-delay`),
|
|
366
|
+
endDelay: bar.style.getPropertyValue(`--_${kind}-end-delay`),
|
|
367
|
+
duration: bar.style.getPropertyValue(`--_${kind}-duration`),
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("snaps both indicators onto the first tab on mount", async () => {
|
|
371
|
+
mockRow();
|
|
372
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
373
|
+
const bar = wrapper.find(".tabbed-content-bar").element as HTMLElement;
|
|
374
|
+
expect(edges(bar, "active")).toEqual({ start: "0px", end: "400px", startDelay: "0ms", endDelay: "0ms", duration: "0ms" });
|
|
375
|
+
expect(edges(bar, "hovered")).toMatchObject({ start: "0px", end: "400px", duration: "0ms" });
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("moving right sends the end edge first and delays the start edge (regression: n to n+x jitter)", async () => {
|
|
379
|
+
mockRow();
|
|
380
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
381
|
+
const bar = wrapper.find(".tabbed-content-bar").element as HTMLElement;
|
|
382
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
383
|
+
|
|
384
|
+
await triggers[1]!.trigger("click");
|
|
385
|
+
await triggers[3]!.trigger("click");
|
|
386
|
+
|
|
387
|
+
expect(edges(bar, "active")).toEqual({
|
|
388
|
+
start: "300px",
|
|
389
|
+
end: "100px",
|
|
390
|
+
startDelay: "200ms",
|
|
391
|
+
endDelay: "0ms",
|
|
392
|
+
duration: "200ms",
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("moving left sends the start edge first and delays the end edge", async () => {
|
|
397
|
+
mockRow();
|
|
398
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
399
|
+
const bar = wrapper.find(".tabbed-content-bar").element as HTMLElement;
|
|
400
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
401
|
+
|
|
402
|
+
await triggers[4]!.trigger("click");
|
|
403
|
+
await triggers[2]!.trigger("click");
|
|
404
|
+
|
|
405
|
+
expect(edges(bar, "active")).toMatchObject({ start: "200px", end: "200px", startDelay: "0ms", endDelay: "200ms" });
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("only ever moves the hover edges forward when sweeping right", async () => {
|
|
409
|
+
mockRow();
|
|
410
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
411
|
+
const bar = wrapper.find(".tabbed-content-bar").element as HTMLElement;
|
|
412
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
413
|
+
const seen: { start: number; end: number }[] = [];
|
|
414
|
+
|
|
415
|
+
for (const index of [1, 2, 3, 4]) {
|
|
416
|
+
await triggers[index]!.trigger("mouseenter");
|
|
417
|
+
const { start, end } = edges(bar, "hovered");
|
|
418
|
+
seen.push({ start: parseFloat(start), end: parseFloat(end) });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Start insets only grow and end insets only shrink: neither edge ever heads back left.
|
|
422
|
+
seen.slice(1).forEach((step, index) => {
|
|
423
|
+
expect(step.start).toBeGreaterThan(seen[index]!.start);
|
|
424
|
+
expect(step.end).toBeLessThan(seen[index]!.end);
|
|
425
|
+
});
|
|
426
|
+
expect(edges(bar, "hovered").startDelay).toBe("200ms");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("resets the hover indicator onto the active tab on mouseleave", async () => {
|
|
430
|
+
mockRow();
|
|
431
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
432
|
+
const bar = wrapper.find(".tabbed-content-bar");
|
|
433
|
+
|
|
434
|
+
await wrapper.findAll(TRIGGER)[3]!.trigger("mouseenter");
|
|
435
|
+
await bar.trigger("mouseleave");
|
|
436
|
+
|
|
437
|
+
expect(edges(bar.element as HTMLElement, "hovered")).toMatchObject({ start: "0px", end: "400px" });
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("keeps hover and active timing separate", async () => {
|
|
441
|
+
mockRow();
|
|
442
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
443
|
+
const bar = wrapper.find(".tabbed-content-bar").element as HTMLElement;
|
|
444
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
445
|
+
|
|
446
|
+
await triggers[3]!.trigger("click");
|
|
447
|
+
await triggers[1]!.trigger("mouseenter");
|
|
448
|
+
|
|
449
|
+
expect(edges(bar, "active")).toMatchObject({ startDelay: "200ms", endDelay: "0ms" });
|
|
450
|
+
expect(edges(bar, "hovered")).toMatchObject({ startDelay: "200ms", endDelay: "0ms" });
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("gives a tab the active text colour only while the active highlight covers it", async () => {
|
|
455
|
+
// Tabs sit 100px apart; the active highlight box is moved by hand to simulate its slide.
|
|
456
|
+
let highlight = { left: 90, right: 210 };
|
|
457
|
+
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
|
|
458
|
+
const el = this as HTMLElement;
|
|
459
|
+
if (el.hasAttribute("data-active-indicator")) return { ...highlight, top: 0, bottom: 40 } as DOMRect;
|
|
460
|
+
if (el.hasAttribute("data-nav-item")) {
|
|
461
|
+
const left = Number(el.dataset.tabIndex) * 100;
|
|
462
|
+
return { left, right: left + 100, top: 0, bottom: 40 } as DOMRect;
|
|
463
|
+
}
|
|
464
|
+
return { left: 0, right: 0, top: 0, bottom: 0 } as DOMRect;
|
|
465
|
+
});
|
|
466
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
467
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
468
|
+
const underActive = () => triggers.map((trigger) => trigger.attributes("data-under-active") !== undefined);
|
|
469
|
+
|
|
470
|
+
// Mid-slide from tab 0 to tab 2: only the passed-over tab 1 is covered; the selected tab 2 isn't yet.
|
|
471
|
+
await triggers[2]!.trigger("click");
|
|
472
|
+
expect(triggers[2]!.attributes("aria-selected")).toBe("true");
|
|
473
|
+
expect(underActive()).toEqual([false, true, false]);
|
|
474
|
+
|
|
475
|
+
// The highlight arrives on tab 2.
|
|
476
|
+
highlight = { left: 200, right: 300 };
|
|
477
|
+
vi.advanceTimersByTime(16);
|
|
478
|
+
expect(underActive()).toEqual([false, false, true]);
|
|
479
|
+
vi.restoreAllMocks();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("keeps checking coverage after a refresh lands mid-move (regression: stale label colours)", async () => {
|
|
483
|
+
let highlight = { left: 90, right: 210 };
|
|
484
|
+
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
|
|
485
|
+
const el = this as HTMLElement;
|
|
486
|
+
if (el.hasAttribute("data-active-indicator")) return { ...highlight, top: 0, bottom: 40 } as DOMRect;
|
|
487
|
+
if (el.hasAttribute("data-nav-item")) {
|
|
488
|
+
const left = Number(el.dataset.tabIndex) * 100;
|
|
489
|
+
return { left, right: left + 100, top: 0, bottom: 40 } as DOMRect;
|
|
490
|
+
}
|
|
491
|
+
return { left: 0, right: 0, top: 0, bottom: 0 } as DOMRect;
|
|
492
|
+
});
|
|
493
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
494
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
495
|
+
const underActive = () => triggers.map((trigger) => trigger.attributes("data-under-active") !== undefined);
|
|
496
|
+
|
|
497
|
+
await triggers[2]!.trigger("click");
|
|
498
|
+
// A refresh mid-move (here from a prop change; in a browser, the More button resizing).
|
|
499
|
+
await wrapper.setProps({ trackHover: false });
|
|
500
|
+
await nextTick();
|
|
501
|
+
|
|
502
|
+
highlight = { left: 200, right: 300 };
|
|
503
|
+
vi.advanceTimersByTime(16);
|
|
504
|
+
expect(underActive()).toEqual([false, false, true]);
|
|
505
|
+
vi.restoreAllMocks();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("leaves text colour to aria-selected when there is no active highlight", async () => {
|
|
509
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3, trackActive: false }, slots });
|
|
510
|
+
expect(wrapper.classes()).not.toContain("tracks-active");
|
|
511
|
+
expect(wrapper.findAll("[data-under-active]")).toHaveLength(0);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("uses the current transitionDuration for indicator moves", async () => {
|
|
515
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3 }, slots });
|
|
516
|
+
await wrapper.setProps({ transitionDuration: 450 });
|
|
517
|
+
const tabsList = wrapper.find(".tabbed-content-bar").element as HTMLElement;
|
|
518
|
+
|
|
519
|
+
await wrapper.findAll(TRIGGER)[1]!.trigger("click");
|
|
520
|
+
|
|
521
|
+
expect(tabsList.style.getPropertyValue("--_active-duration")).toBe("450ms");
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("clamps a negative transitionDuration to 0ms", async () => {
|
|
525
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 3, transitionDuration: -100 }, slots });
|
|
526
|
+
const tabsList = wrapper.find(".tabbed-content-bar").element as HTMLElement;
|
|
527
|
+
|
|
528
|
+
await wrapper.findAll(TRIGGER)[1]!.trigger("click");
|
|
529
|
+
|
|
530
|
+
expect(tabsList.style.getPropertyValue("--_active-duration")).toBe("0ms");
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// ─── Regression: more than 3 tabs ────────────────────────────────────────
|
|
534
|
+
// The original hand-rolled version of this component had an indicator-positioning issue
|
|
535
|
+
// that only showed up with itemCount > 3 — these lock in correct behaviour at 5.
|
|
536
|
+
|
|
537
|
+
it("activates a distant tab (index 0 -> index 4) directly via click", async () => {
|
|
538
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
539
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
540
|
+
|
|
541
|
+
await triggers[4]!.trigger("click");
|
|
542
|
+
|
|
543
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("false");
|
|
544
|
+
expect(triggers[4]!.attributes("aria-selected")).toBe("true");
|
|
545
|
+
expect(triggers[4]!.attributes("tabindex")).toBe("0");
|
|
546
|
+
expect(wrapper.findAll(PANEL)[4]!.attributes("hidden")).toBeUndefined();
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it("does not recolour tabs spanned by a distant activation jump (regression: intermediate labels going invisible)", async () => {
|
|
550
|
+
// A removed "transitioning" class used to force every spanned tab to the active text colour
|
|
551
|
+
// for the whole transition, before the sliding indicator had reached it. Spanned tabs should
|
|
552
|
+
// only ever carry their own aria-selected/hover state.
|
|
553
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
554
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
555
|
+
|
|
556
|
+
await triggers[4]!.trigger("click");
|
|
557
|
+
|
|
558
|
+
[1, 2, 3].forEach((index) => {
|
|
559
|
+
expect(triggers[index]!.attributes("aria-selected")).toBe("false");
|
|
560
|
+
expect(triggers[index]!.classes()).not.toContain("transitioning");
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("wraps correctly with End then ArrowRight across 5 tabs", async () => {
|
|
565
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
566
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
567
|
+
|
|
568
|
+
await triggers[0]!.trigger("keydown", { key: "End" });
|
|
569
|
+
expect(triggers[4]!.attributes("aria-selected")).toBe("true");
|
|
570
|
+
|
|
571
|
+
await triggers[4]!.trigger("keydown", { key: "ArrowRight" });
|
|
572
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// ─── Overflow menu ───────────────────────────────────────────────────────
|
|
576
|
+
// jsdom has no layout, so widths are mocked: bar 300px, each tab 100px, the More button 50px.
|
|
577
|
+
|
|
578
|
+
describe("overflow menu", () => {
|
|
579
|
+
const mockLayout = (barWidth: number) => {
|
|
580
|
+
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(function (this: HTMLElement) {
|
|
581
|
+
return this.classList.contains("tabbed-content-bar") ? barWidth : 0;
|
|
582
|
+
});
|
|
583
|
+
vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (this: HTMLElement) {
|
|
584
|
+
if (this.hasAttribute("data-nav-item")) return 100;
|
|
585
|
+
if (this.hasAttribute("data-more-trigger")) return 50;
|
|
586
|
+
return 0;
|
|
587
|
+
});
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
afterEach(() => {
|
|
591
|
+
vi.restoreAllMocks();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
const hiddenIndexes = (wrapper: { findAll: (selector: string) => { attributes: (name: string) => string | undefined }[] }) =>
|
|
595
|
+
wrapper
|
|
596
|
+
.findAll(TRIGGER)
|
|
597
|
+
.map((trigger, index: number) => (trigger.attributes("hidden") !== undefined ? index : -1))
|
|
598
|
+
.filter((index: number) => index !== -1);
|
|
599
|
+
|
|
600
|
+
it("collapses the tabs that don't fit into the More menu", async () => {
|
|
601
|
+
mockLayout(300);
|
|
602
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
603
|
+
await nextTick();
|
|
604
|
+
|
|
605
|
+
expect(hiddenIndexes(wrapper)).toEqual([2, 3, 4]);
|
|
606
|
+
expect(wrapper.find(".tabbed-content-more").classes()).not.toContain("is-idle");
|
|
607
|
+
expect(wrapper.findAll(".tabbed-content-more-item").map((item) => item.text())).toEqual([
|
|
608
|
+
"Tab Three",
|
|
609
|
+
"Tab Four",
|
|
610
|
+
"Tab Five",
|
|
611
|
+
]);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("keeps the More button idle and out of the tab order when everything fits", async () => {
|
|
615
|
+
mockLayout(1000);
|
|
616
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
617
|
+
await nextTick();
|
|
618
|
+
|
|
619
|
+
expect(hiddenIndexes(wrapper)).toEqual([]);
|
|
620
|
+
expect(wrapper.find(".tabbed-content-more").classes()).toContain("is-idle");
|
|
621
|
+
expect(wrapper.find(".tabbed-content-more-trigger").attributes("tabindex")).toBe("-1");
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("gives the More button an accessible name and menu semantics", async () => {
|
|
625
|
+
mockLayout(300);
|
|
626
|
+
const wrapper = await mountSuspended(TabbedContent, {
|
|
627
|
+
props: { itemCount: 5, moreLabel: "Weitere Tabs" },
|
|
628
|
+
slots: fiveSlots,
|
|
629
|
+
});
|
|
630
|
+
const trigger = wrapper.find(".tabbed-content-more-trigger");
|
|
631
|
+
expect(trigger.attributes("aria-label")).toBeUndefined();
|
|
632
|
+
expect(trigger.find(".tabbed-content-visually-hidden").text()).toBe("Weitere Tabs");
|
|
633
|
+
expect(trigger.attributes("aria-haspopup")).toBe("menu");
|
|
634
|
+
expect(wrapper.find("[role='menu']").attributes("aria-label")).toBe("Weitere Tabs");
|
|
635
|
+
expect(wrapper.find("[role='tablist']").element.contains(trigger.element)).toBe(false);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it("activates a collapsed tab from the menu and marks the More button active", async () => {
|
|
639
|
+
mockLayout(300);
|
|
640
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
641
|
+
await nextTick();
|
|
642
|
+
|
|
643
|
+
await wrapper.findAll(".tabbed-content-more-item")[1]!.trigger("click");
|
|
644
|
+
|
|
645
|
+
expect(wrapper.findAll(TRIGGER)[3]!.attributes("aria-selected")).toBe("true");
|
|
646
|
+
expect(wrapper.findAll(PANEL)[3]!.attributes("hidden")).toBeUndefined();
|
|
647
|
+
expect(wrapper.find(".tabbed-content-more-trigger").classes()).toContain("is-active");
|
|
648
|
+
expect(wrapper.findAll(".tabbed-content-more-item")[1]!.attributes("aria-checked")).toBe("true");
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("shows the collapsed active tab's label on the More button, with the visible text inside its name", async () => {
|
|
652
|
+
mockLayout(300);
|
|
653
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
654
|
+
await nextTick();
|
|
655
|
+
const trigger = () => wrapper.find(".tabbed-content-more-trigger");
|
|
656
|
+
expect(trigger().find(".tabbed-content-more-label").exists()).toBe(false);
|
|
657
|
+
|
|
658
|
+
await wrapper.findAll(".tabbed-content-more-item")[1]!.trigger("click");
|
|
659
|
+
|
|
660
|
+
expect(trigger().find(".tabbed-content-more-label").text()).toBe("Tab Four");
|
|
661
|
+
expect(trigger().text()).toBe("Tab Four, More tabs");
|
|
662
|
+
expect(trigger().find(".tabbed-content-more-icon").exists()).toBe(false);
|
|
663
|
+
|
|
664
|
+
await wrapper.findAll(TRIGGER)[0]!.trigger("click");
|
|
665
|
+
expect(trigger().find(".tabbed-content-more-label").exists()).toBe(false);
|
|
666
|
+
expect(trigger().find(".tabbed-content-more-icon").exists()).toBe(true);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("moves the preceding tab into the menu when the More button grows to show the active label", async () => {
|
|
670
|
+
const observerCallbacks: (() => void)[] = [];
|
|
671
|
+
vi.stubGlobal(
|
|
672
|
+
"ResizeObserver",
|
|
673
|
+
class {
|
|
674
|
+
constructor(callback: () => void) {
|
|
675
|
+
observerCallbacks.push(callback);
|
|
676
|
+
}
|
|
677
|
+
observe = vi.fn();
|
|
678
|
+
unobserve = vi.fn();
|
|
679
|
+
disconnect = vi.fn();
|
|
680
|
+
}
|
|
681
|
+
);
|
|
682
|
+
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(function (this: HTMLElement) {
|
|
683
|
+
return this.classList.contains("tabbed-content-bar") ? 300 : 0;
|
|
684
|
+
});
|
|
685
|
+
vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (this: HTMLElement) {
|
|
686
|
+
if (this.hasAttribute("data-nav-item")) return 100;
|
|
687
|
+
if (this.hasAttribute("data-more-trigger")) return this.classList.contains("is-active") ? 150 : 50;
|
|
688
|
+
return 0;
|
|
689
|
+
});
|
|
690
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
691
|
+
await nextTick();
|
|
692
|
+
expect(hiddenIndexes(wrapper)).toEqual([2, 3, 4]);
|
|
693
|
+
|
|
694
|
+
await wrapper.findAll(".tabbed-content-more-item")[1]!.trigger("click");
|
|
695
|
+
await nextTick();
|
|
696
|
+
observerCallbacks.forEach((callback) => callback());
|
|
697
|
+
await nextTick();
|
|
698
|
+
|
|
699
|
+
expect(hiddenIndexes(wrapper)).toEqual([1, 2, 3, 4]);
|
|
700
|
+
|
|
701
|
+
await wrapper.findAll(TRIGGER)[0]!.trigger("click");
|
|
702
|
+
await nextTick();
|
|
703
|
+
observerCallbacks.forEach((callback) => callback());
|
|
704
|
+
await nextTick();
|
|
705
|
+
|
|
706
|
+
expect(hiddenIndexes(wrapper)).toEqual([2, 3, 4]);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it("keeps the tablist reachable by Tab when the active tab is collapsed", async () => {
|
|
710
|
+
mockLayout(300);
|
|
711
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
712
|
+
await nextTick();
|
|
713
|
+
|
|
714
|
+
await wrapper.findAll(".tabbed-content-more-item")[2]!.trigger("click");
|
|
715
|
+
|
|
716
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
717
|
+
expect(triggers[0]!.attributes("tabindex")).toBe("0");
|
|
718
|
+
expect(triggers[4]!.attributes("tabindex")).toBe("-1");
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("skips collapsed tabs with the arrow keys", async () => {
|
|
722
|
+
mockLayout(300);
|
|
723
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
724
|
+
await nextTick();
|
|
725
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
726
|
+
|
|
727
|
+
await triggers[1]!.trigger("click");
|
|
728
|
+
await triggers[1]!.trigger("keydown", { key: "ArrowRight" });
|
|
729
|
+
|
|
730
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("moves the indicators onto the More button's new box when it resizes", async () => {
|
|
734
|
+
const observerCallbacks: (() => void)[] = [];
|
|
735
|
+
vi.stubGlobal(
|
|
736
|
+
"ResizeObserver",
|
|
737
|
+
class {
|
|
738
|
+
constructor(callback: () => void) {
|
|
739
|
+
observerCallbacks.push(callback);
|
|
740
|
+
}
|
|
741
|
+
observe = vi.fn();
|
|
742
|
+
unobserve = vi.fn();
|
|
743
|
+
disconnect = vi.fn();
|
|
744
|
+
}
|
|
745
|
+
);
|
|
746
|
+
let moreLeft = 250;
|
|
747
|
+
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(function (this: HTMLElement) {
|
|
748
|
+
return this.classList.contains("tabbed-content-bar") ? 300 : 0;
|
|
749
|
+
});
|
|
750
|
+
vi.spyOn(HTMLElement.prototype, "offsetLeft", "get").mockImplementation(function (this: HTMLElement) {
|
|
751
|
+
if (this.hasAttribute("data-nav-item")) return Number(this.dataset.tabIndex) * 100;
|
|
752
|
+
return this.hasAttribute("data-more-trigger") ? moreLeft : 0;
|
|
753
|
+
});
|
|
754
|
+
vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (this: HTMLElement) {
|
|
755
|
+
if (this.hasAttribute("data-nav-item")) return 100;
|
|
756
|
+
return this.hasAttribute("data-more-trigger") ? 300 - moreLeft : 0;
|
|
757
|
+
});
|
|
758
|
+
const wrapper = await mountSuspended(TabbedContent, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
759
|
+
await nextTick();
|
|
760
|
+
const bar = wrapper.find(".tabbed-content-bar").element as HTMLElement;
|
|
761
|
+
|
|
762
|
+
await wrapper.findAll(".tabbed-content-more-item")[0]!.trigger("click");
|
|
763
|
+
expect(bar.style.getPropertyValue("--_active-start")).toBe("250px");
|
|
764
|
+
|
|
765
|
+
// The button grows leftwards to show the active label, without changing which tabs fit.
|
|
766
|
+
moreLeft = 220;
|
|
767
|
+
observerCallbacks.forEach((callback) => callback());
|
|
768
|
+
|
|
769
|
+
expect(bar.style.getPropertyValue("--_active-start")).toBe("220px");
|
|
770
|
+
expect(bar.style.getPropertyValue("--_active-end")).toBe("0px");
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it("never collapses tabs with overflowMode scroll or on axis y", async () => {
|
|
774
|
+
mockLayout(300);
|
|
775
|
+
const scroll = await mountSuspended(TabbedContent, {
|
|
776
|
+
props: { itemCount: 5, overflowMode: "scroll" },
|
|
777
|
+
slots: fiveSlots,
|
|
778
|
+
});
|
|
779
|
+
const vertical = await mountSuspended(TabbedContent, { props: { itemCount: 5, axis: "y" }, slots: fiveSlots });
|
|
780
|
+
await nextTick();
|
|
781
|
+
|
|
782
|
+
[scroll, vertical].forEach((wrapper) => {
|
|
783
|
+
expect(hiddenIndexes(wrapper)).toEqual([]);
|
|
784
|
+
expect(wrapper.find(".tabbed-content-more").exists()).toBe(false);
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// ─── Reactivity in a host ────────────────────────────────────────────────
|
|
790
|
+
|
|
791
|
+
it("follows a host's reactive itemCount", async () => {
|
|
792
|
+
const count = ref(1);
|
|
793
|
+
const Host = defineComponent({
|
|
794
|
+
components: { TabbedContent },
|
|
795
|
+
setup() {
|
|
796
|
+
return { count };
|
|
797
|
+
},
|
|
798
|
+
template: `
|
|
799
|
+
<TabbedContent :item-count="count">
|
|
800
|
+
<template #tab-0-trigger>One</template>
|
|
801
|
+
<template #tab-1-trigger>Two</template>
|
|
802
|
+
<template #tab-2-trigger>Three</template>
|
|
803
|
+
</TabbedContent>
|
|
804
|
+
`,
|
|
805
|
+
});
|
|
806
|
+
const wrapper = await mountSuspended(Host);
|
|
807
|
+
count.value = 3;
|
|
808
|
+
await nextTick();
|
|
809
|
+
await nextTick();
|
|
810
|
+
|
|
811
|
+
const triggers = wrapper.findAll(TRIGGER);
|
|
812
|
+
expect(triggers).toHaveLength(3);
|
|
813
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
814
|
+
expect(triggers[2]!.attributes("tabindex")).toBe("-1");
|
|
815
|
+
});
|
|
816
|
+
});
|