srcdev-nuxt-components 9.6.0 → 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/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 -10
- 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/responsive-header.md +11 -1
- package/.claude/skills/components/select-menu.md +54 -5
- package/.claude/skills/components/site-header.md +2 -2
- package/.claude/skills/components/tabbed-content.md +300 -0
- package/.claude/skills/composable-anchor-scroll.md +16 -15
- package/.claude/skills/index.md +4 -4
- 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 +6 -0
- package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
- 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 +75 -9
- package/app/components/01.atoms/display-pill/DisplayPill.vue +57 -24
- 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/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/responsive-header/CONSUMER-STYLING.md +38 -11
- package/app/components/03.organisms/responsive-header/NavigationItems.vue +27 -12
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +35 -20
- package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +1 -1
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +1 -1
- package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
- 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/tab-navigation.md +0 -288
- package/.claude/skills/components/tabs-core.md +0 -187
- 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/tab-navigation/CONSUMER-STYLING.md +0 -191
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -550
- package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -259
- package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -379
- 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
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
-
import TabsCore from "../TabsCore.vue";
|
|
4
|
-
|
|
5
|
-
class MockResizeObserver {
|
|
6
|
-
observe = vi.fn();
|
|
7
|
-
unobserve = vi.fn();
|
|
8
|
-
disconnect = vi.fn();
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const slots = {
|
|
12
|
-
"tab-0-trigger": "Tab One",
|
|
13
|
-
"tab-0-content": "<p class='panel-0'>Content one</p>",
|
|
14
|
-
"tab-1-trigger": "Tab Two",
|
|
15
|
-
"tab-1-content": "<p class='panel-1'>Content two</p>",
|
|
16
|
-
"tab-2-trigger": "Tab Three",
|
|
17
|
-
"tab-2-content": "<p class='panel-2'>Content three</p>",
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
const fiveSlots = {
|
|
21
|
-
...slots,
|
|
22
|
-
"tab-3-trigger": "Tab Four",
|
|
23
|
-
"tab-3-content": "<p class='panel-3'>Content four</p>",
|
|
24
|
-
"tab-4-trigger": "Tab Five",
|
|
25
|
-
"tab-4-content": "<p class='panel-4'>Content five</p>",
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
describe("TabsCore", () => {
|
|
29
|
-
beforeEach(() => {
|
|
30
|
-
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
// ─── Mount ───────────────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
it("mounts without error", async () => {
|
|
36
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
37
|
-
expect(wrapper.vm).toBeTruthy();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("renders one trigger and one panel per itemCount", async () => {
|
|
41
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
42
|
-
expect(wrapper.findAll(".tabs-list-item")).toHaveLength(3);
|
|
43
|
-
expect(wrapper.findAll(".tab-content")).toHaveLength(3);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
// ─── Accessibility structure ─────────────────────────────────────────────
|
|
47
|
-
|
|
48
|
-
it("renders a tablist with the default aria-label", async () => {
|
|
49
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
50
|
-
const tablist = wrapper.find("[role='tablist']");
|
|
51
|
-
expect(tablist.exists()).toBe(true);
|
|
52
|
-
expect(tablist.attributes("aria-label")).toBe("Tabs");
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("uses a custom ariaLabel when provided", async () => {
|
|
56
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3, ariaLabel: "Settings" }, slots });
|
|
57
|
-
expect(wrapper.find("[role='tablist']").attributes("aria-label")).toBe("Settings");
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("renders each trigger as a type=button with role=tab", async () => {
|
|
61
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
62
|
-
const triggers = wrapper.findAll(".tabs-list-item");
|
|
63
|
-
triggers.forEach((trigger) => {
|
|
64
|
-
expect(trigger.attributes("type")).toBe("button");
|
|
65
|
-
expect(trigger.attributes("role")).toBe("tab");
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("renders each panel as role=tabpanel labelled by its trigger", async () => {
|
|
70
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
71
|
-
const panels = wrapper.findAll(".tab-content");
|
|
72
|
-
panels.forEach((panel, index) => {
|
|
73
|
-
expect(panel.attributes("role")).toBe("tabpanel");
|
|
74
|
-
expect(panel.attributes("aria-labelledby")).toBe(`tab-${index}-trigger`);
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
// ─── Active tab state ────────────────────────────────────────────────────
|
|
79
|
-
|
|
80
|
-
it("marks the first tab active and its panel visible/unhidden on mount", async () => {
|
|
81
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
82
|
-
const triggers = wrapper.findAll(".tabs-list-item");
|
|
83
|
-
const panels = wrapper.findAll(".tab-content");
|
|
84
|
-
|
|
85
|
-
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
86
|
-
expect(triggers[0]!.attributes("tabindex")).toBe("0");
|
|
87
|
-
expect(triggers[1]!.attributes("aria-selected")).toBe("false");
|
|
88
|
-
expect(triggers[1]!.attributes("tabindex")).toBe("-1");
|
|
89
|
-
|
|
90
|
-
expect(panels[0]!.attributes("aria-hidden")).toBe("false");
|
|
91
|
-
expect((panels[0]!.element as HTMLElement).style.display).toBe("block");
|
|
92
|
-
expect(panels[1]!.attributes("aria-hidden")).toBe("true");
|
|
93
|
-
expect((panels[1]!.element as HTMLElement).style.display).toBe("none");
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("activates a tab on click and updates aria-selected, tabindex, and panel visibility", async () => {
|
|
97
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
98
|
-
const triggers = wrapper.findAll(".tabs-list-item");
|
|
99
|
-
const panels = wrapper.findAll(".tab-content");
|
|
100
|
-
|
|
101
|
-
await triggers[1]!.trigger("click");
|
|
102
|
-
|
|
103
|
-
expect(triggers[0]!.attributes("aria-selected")).toBe("false");
|
|
104
|
-
expect(triggers[0]!.attributes("tabindex")).toBe("-1");
|
|
105
|
-
expect(triggers[1]!.attributes("aria-selected")).toBe("true");
|
|
106
|
-
expect(triggers[1]!.attributes("tabindex")).toBe("0");
|
|
107
|
-
|
|
108
|
-
expect(panels[0]!.attributes("aria-hidden")).toBe("true");
|
|
109
|
-
expect(panels[1]!.attributes("aria-hidden")).toBe("false");
|
|
110
|
-
expect((panels[1]!.element as HTMLElement).style.display).toBe("block");
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// ─── Keyboard navigation ─────────────────────────────────────────────────
|
|
114
|
-
|
|
115
|
-
it("moves focus and activates the next tab on ArrowRight (axis x)", async () => {
|
|
116
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
117
|
-
const triggers = wrapper.findAll(".tabs-list-item");
|
|
118
|
-
|
|
119
|
-
await triggers[0]!.trigger("keydown", { key: "ArrowRight" });
|
|
120
|
-
|
|
121
|
-
expect(triggers[1]!.attributes("aria-selected")).toBe("true");
|
|
122
|
-
expect(triggers[1]!.attributes("tabindex")).toBe("0");
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("wraps to the first tab on ArrowRight from the last tab", async () => {
|
|
126
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
127
|
-
const triggers = wrapper.findAll(".tabs-list-item");
|
|
128
|
-
|
|
129
|
-
await triggers[2]!.trigger("click");
|
|
130
|
-
await triggers[2]!.trigger("keydown", { key: "ArrowRight" });
|
|
131
|
-
|
|
132
|
-
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("moves focus to the previous tab on ArrowLeft (axis x)", async () => {
|
|
136
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
137
|
-
const triggers = wrapper.findAll(".tabs-list-item");
|
|
138
|
-
|
|
139
|
-
await triggers[1]!.trigger("click");
|
|
140
|
-
await triggers[1]!.trigger("keydown", { key: "ArrowLeft" });
|
|
141
|
-
|
|
142
|
-
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("uses ArrowUp/ArrowDown instead of ArrowLeft/ArrowRight when axis is y", async () => {
|
|
146
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3, axis: "y" }, slots });
|
|
147
|
-
const triggers = wrapper.findAll(".tabs-list-item");
|
|
148
|
-
|
|
149
|
-
await triggers[0]!.trigger("keydown", { key: "ArrowRight" });
|
|
150
|
-
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
151
|
-
|
|
152
|
-
await triggers[0]!.trigger("keydown", { key: "ArrowDown" });
|
|
153
|
-
expect(triggers[1]!.attributes("aria-selected")).toBe("true");
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("jumps to the last tab on End and the first tab on Home", async () => {
|
|
157
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
158
|
-
const triggers = wrapper.findAll(".tabs-list-item");
|
|
159
|
-
|
|
160
|
-
await triggers[0]!.trigger("keydown", { key: "End" });
|
|
161
|
-
expect(triggers[2]!.attributes("aria-selected")).toBe("true");
|
|
162
|
-
|
|
163
|
-
await triggers[2]!.trigger("keydown", { key: "Home" });
|
|
164
|
-
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
// ─── trackHover / trackActive / trackIndicator ──────────────────────────
|
|
168
|
-
|
|
169
|
-
it("renders all three indicator decorators by default", async () => {
|
|
170
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
171
|
-
expect(wrapper.find(".nav__hovered").exists()).toBe(true);
|
|
172
|
-
expect(wrapper.find(".nav__active").exists()).toBe(true);
|
|
173
|
-
expect(wrapper.find(".nav__active-indicator").exists()).toBe(true);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it("omits the hover decorator when trackHover is false", async () => {
|
|
177
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3, trackHover: false }, slots });
|
|
178
|
-
expect(wrapper.find(".nav__hovered").exists()).toBe(false);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("omits the active decorator when trackActive is false", async () => {
|
|
182
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3, trackActive: false }, slots });
|
|
183
|
-
expect(wrapper.find(".nav__active").exists()).toBe(false);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it("omits the underline indicator when trackIndicator is false", async () => {
|
|
187
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3, trackIndicator: false }, slots });
|
|
188
|
-
expect(wrapper.find(".nav__active-indicator").exists()).toBe(false);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
// ─── Hover indicator movement ────────────────────────────────────────────
|
|
192
|
-
|
|
193
|
-
it("moves the hover indicator position on mouseenter", async () => {
|
|
194
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
195
|
-
const tabsList = wrapper.find(".tabs-list").element as HTMLElement;
|
|
196
|
-
const triggers = wrapper.findAll(".tabs-list-item");
|
|
197
|
-
|
|
198
|
-
expect(tabsList.style.getPropertyValue("--_width-hovered")).toBe("");
|
|
199
|
-
|
|
200
|
-
await triggers[1]!.trigger("mouseenter");
|
|
201
|
-
|
|
202
|
-
expect(tabsList.style.getPropertyValue("--_width-hovered")).not.toBe("");
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it("resets the hover indicator to the active tab on mouseleave", async () => {
|
|
206
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
207
|
-
const tabsList = wrapper.find(".tabs-list");
|
|
208
|
-
const triggers = wrapper.findAll(".tabs-list-item");
|
|
209
|
-
|
|
210
|
-
await triggers[1]!.trigger("mouseenter");
|
|
211
|
-
await tabsList.trigger("mouseleave");
|
|
212
|
-
|
|
213
|
-
// Should not throw, and the hover indicator settles back onto the active (first) tab.
|
|
214
|
-
expect((tabsList.element as HTMLElement).style.getPropertyValue("--_x-hovered")).toBe("0px");
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it("clears a pending settle timeout when hovering a second tab before the first settles", async () => {
|
|
218
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
219
|
-
const triggers = wrapper.findAll(".tabs-list-item");
|
|
220
|
-
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
|
|
221
|
-
|
|
222
|
-
await triggers[1]!.trigger("mouseenter");
|
|
223
|
-
await triggers[2]!.trigger("mouseenter");
|
|
224
|
-
|
|
225
|
-
expect(clearTimeoutSpy).toHaveBeenCalled();
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
// ─── Regression: more than 3 tabs ────────────────────────────────────────
|
|
229
|
-
// The original hand-rolled version of this component had an indicator-positioning issue
|
|
230
|
-
// that only showed up with itemCount > 3 — these lock in correct behaviour at 5.
|
|
231
|
-
|
|
232
|
-
it("activates a distant tab (index 0 -> index 4) directly via click", async () => {
|
|
233
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
234
|
-
const triggers = wrapper.findAll(".tabs-list-item");
|
|
235
|
-
|
|
236
|
-
await triggers[4]!.trigger("click");
|
|
237
|
-
|
|
238
|
-
expect(triggers[0]!.attributes("aria-selected")).toBe("false");
|
|
239
|
-
expect(triggers[4]!.attributes("aria-selected")).toBe("true");
|
|
240
|
-
expect(triggers[4]!.attributes("tabindex")).toBe("0");
|
|
241
|
-
expect(wrapper.findAll(".tab-content")[4]!.attributes("aria-hidden")).toBe("false");
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it("does not recolour tabs spanned by a distant activation jump (regression: intermediate labels going invisible)", async () => {
|
|
245
|
-
// The old implementation force-applied a "transitioning" class to every tab spanned by a
|
|
246
|
-
// jump, forcing it to the active-indicator's text colour for the whole transition — even
|
|
247
|
-
// though the sliding indicator only visually reaches each spanned tab partway through, and
|
|
248
|
-
// for a forward jump doesn't reach the middle tabs until late in the transition. That made
|
|
249
|
-
// intermediate labels flash a colour matching neither their own background nor (yet) the
|
|
250
|
-
// indicator's, going invisible. Spanned tabs should only ever carry aria-selected/hover
|
|
251
|
-
// state of their own — never a class forcing them to look active.
|
|
252
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
253
|
-
const triggers = wrapper.findAll(".tabs-list-item");
|
|
254
|
-
|
|
255
|
-
// Active tab starts at index 0, so activating index 4 spans every tab in between.
|
|
256
|
-
await triggers[4]!.trigger("click");
|
|
257
|
-
|
|
258
|
-
[1, 2, 3].forEach((index) => {
|
|
259
|
-
expect(triggers[index]!.attributes("aria-selected")).toBe("false");
|
|
260
|
-
expect(triggers[index]!.classes()).not.toContain("transitioning");
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
it("wraps correctly with End then ArrowRight across 5 tabs", async () => {
|
|
265
|
-
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
266
|
-
const triggers = wrapper.findAll(".tabs-list-item");
|
|
267
|
-
|
|
268
|
-
await triggers[0]!.trigger("keydown", { key: "End" });
|
|
269
|
-
expect(triggers[4]!.attributes("aria-selected")).toBe("true");
|
|
270
|
-
|
|
271
|
-
await triggers[4]!.trigger("keydown", { key: "ArrowRight" });
|
|
272
|
-
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
273
|
-
});
|
|
274
|
-
});
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
# TabNavigation — Consumer Styling Guide
|
|
2
|
-
|
|
3
|
-
> **Deprecated (2026-10-06):** use `ResponsiveHeader`. The token mapping is in "Migrating from
|
|
4
|
-
> TabNavigation" in `.claude/skills/components/responsive-header.md`.
|
|
5
|
-
|
|
6
|
-
## Public token API
|
|
7
|
-
|
|
8
|
-
All `--tab-nav-*` tokens are the stable override surface. Set them at any scope (global, page, or
|
|
9
|
-
instance) without touching the component itself.
|
|
10
|
-
|
|
11
|
-
### Horizontal nav
|
|
12
|
-
|
|
13
|
-
| Token | Default | Controls |
|
|
14
|
-
|---|---|---|
|
|
15
|
-
| `--tab-nav-link-color` | `var(--theme-text)` | Link text colour (rest state) |
|
|
16
|
-
| `--tab-nav-link-hover-color` | `var(--theme-accent)` | Link text colour on hover |
|
|
17
|
-
| `--tab-nav-link-active-color` | the link colour | Link text colour when route is active |
|
|
18
|
-
| `--tab-nav-link-size` | `1.6rem` | Link font size |
|
|
19
|
-
| `--tab-nav-link-weight` | `400` | Link font weight |
|
|
20
|
-
| `--tab-nav-link-tracking` | `0.06em` | Link letter spacing |
|
|
21
|
-
| `--tab-nav-gap` | `2.2rem` | Gap between nav items |
|
|
22
|
-
| `--tab-nav-transition` | `250ms ease` | Colour transition on hover/active |
|
|
23
|
-
| `--tab-nav-focus-ring-width` | `2px` | Keyboard focus ring width, on bar and panel links |
|
|
24
|
-
| `--tab-nav-focus-ring-colour` | `currentColor` | Keyboard focus ring colour |
|
|
25
|
-
| `--tab-nav-focus-ring-offset` | `2px` | Focus ring gap on bar links (panel links draw it inset) |
|
|
26
|
-
|
|
27
|
-
> Changed 2026-10-06: keyboard focus now shows a ring. Before, `:focus-visible` only changed the
|
|
28
|
-
> link colour (`outline: none`).
|
|
29
|
-
|
|
30
|
-
> **Changed 2026-10-06: light defaults.** The colours used to assume a dark header (light `--slate-01`
|
|
31
|
-
> links, burger and indicator; dark `#1a1614` panel), so on a light page they were light-on-light.
|
|
32
|
-
> They now come from the light theme slots: links and burger `--theme-text`, hover and the indicator
|
|
33
|
-
> `--theme-accent`, the panel `--page-bg` (else `--theme-surface-subtle`), dividers mixed from the link
|
|
34
|
-
> colour. For a dark header, or dark mode, set the colour tokens yourself (e.g. with `light-dark()` in
|
|
35
|
-
> your own overrides); the library ships light values only. New: `--tab-nav-panel-item-hover-bg`.
|
|
36
|
-
|
|
37
|
-
### Indicator decorators
|
|
38
|
-
|
|
39
|
-
| Token | Default | Controls |
|
|
40
|
-
|---|---|---|
|
|
41
|
-
| `--tab-nav-decorator-indicator-color` | `var(--theme-accent)` | Active-item underline bar colour |
|
|
42
|
-
| `--tab-nav-decorator-hovered-bg` | `transparent` | Background fill pill that follows the pointer |
|
|
43
|
-
|
|
44
|
-
### Mobile panel
|
|
45
|
-
|
|
46
|
-
| Token | Default | Controls |
|
|
47
|
-
|---|---|---|
|
|
48
|
-
| `--tab-nav-panel-bg` | `var(--page-bg, var(--theme-surface-subtle))` | Panel background colour |
|
|
49
|
-
| `--tab-nav-panel-border-color` | the link colour at 20% | Border between nav bar and open panel |
|
|
50
|
-
| `--tab-nav-panel-item-border` | the panel link colour at 12% | Divider between panel items |
|
|
51
|
-
| `--tab-nav-panel-item-hover-bg` | the panel link colour at 5% | Panel item background on hover |
|
|
52
|
-
| `--tab-nav-panel-link-color` | the link colour | Panel link text colour |
|
|
53
|
-
| `--tab-nav-panel-link-hover-color` | the link hover colour | Panel link text colour on hover |
|
|
54
|
-
| `--tab-nav-panel-link-active-color` | the panel link colour | Panel active link colour |
|
|
55
|
-
| `--tab-nav-panel-padding-block` | `1.4rem` | Panel link vertical padding |
|
|
56
|
-
| `--tab-nav-panel-padding-inline` | `1.5rem` | Panel link horizontal padding |
|
|
57
|
-
| `--tab-nav-panel-slide-duration` | `350ms` | Panel open/close animation duration |
|
|
58
|
-
| `--tab-nav-panel-slide-easing` | `cubic-bezier(0.4, 0, 0.2, 1)` | Panel open/close easing |
|
|
59
|
-
|
|
60
|
-
### Burger button
|
|
61
|
-
|
|
62
|
-
| Token | Default | Controls |
|
|
63
|
-
|---|---|---|
|
|
64
|
-
| `--tab-nav-burger-color` | the link colour | Burger bar colour |
|
|
65
|
-
| `--tab-nav-burger-width` | `22px` | Width of each burger bar |
|
|
66
|
-
| `--tab-nav-burger-height` | `1.5px` | Height of each burger bar |
|
|
67
|
-
| `--tab-nav-burger-gap` | `5px` | Gap between burger bars |
|
|
68
|
-
| `--tab-nav-burger-transition` | `300ms ease` | Burger open/close animation |
|
|
69
|
-
|
|
70
|
-
### Backdrop
|
|
71
|
-
|
|
72
|
-
| Token | Default | Controls |
|
|
73
|
-
|---|---|---|
|
|
74
|
-
| `--tab-nav-backdrop-bg` | `oklch(0% 0 0 / 55%)` | Backdrop overlay background colour |
|
|
75
|
-
| `--tab-nav-backdrop-blur` | `3px` | Backdrop blur amount |
|
|
76
|
-
| `--tab-nav-backdrop-duration` | `350ms` | Backdrop fade duration |
|
|
77
|
-
|
|
78
|
-
---
|
|
79
|
-
|
|
80
|
-
## State hooks
|
|
81
|
-
|
|
82
|
-
| Hook | When |
|
|
83
|
-
|---|---|
|
|
84
|
-
| `.tab-navigation.is-collapsed` | The items don't fit, so the bar shows the burger instead |
|
|
85
|
-
| `.tab-navigation.menu-open`, `.tab-nav-burger.is-open`, `.tab-nav-panel.is-open`, `.tab-nav-backdrop.is-open` | The burger menu is open |
|
|
86
|
-
| `.tab-navigation.is-loaded` | First measurement done (the nav is hidden before it, to avoid a wrong-state flash) |
|
|
87
|
-
| `.tab-navigation.is-animated` | Indicator transitions are on (off for a frame during route changes) |
|
|
88
|
-
| `.tab-navigation--left` / `--center` / `--right` | The `navAlign` prop |
|
|
89
|
-
| `.tab-nav-list li.is-active` / `.is-hovered` | The active and hovered items (the indicators anchor to these) |
|
|
90
|
-
| `.tab-nav-link.router-link-exact-active` | Route link for the current page |
|
|
91
|
-
|
|
92
|
-
Inner classes: `.tab-nav-list`, `.tab-nav-link`, `.nav__hovered`, `.nav__active-indicator`, `.tab-nav-burger`,
|
|
93
|
-
`.burger-bar`, `.tab-nav-panel`, `.tab-nav-panel-inner`, `.tab-nav-panel-list`, `.tab-nav-panel-link`,
|
|
94
|
-
`.tab-nav-backdrop` (teleported to `<body>`). Each item's `cssName` is added to its `<li>`.
|
|
95
|
-
|
|
96
|
-
---
|
|
97
|
-
|
|
98
|
-
## Global theming
|
|
99
|
-
|
|
100
|
-
Create `assets/styles/setup/07.components/tab-navigation.css` in the consuming app and set tokens
|
|
101
|
-
on `:root`. These values apply to every `TabNavigation` instance across the site.
|
|
102
|
-
|
|
103
|
-
```css
|
|
104
|
-
/* assets/styles/setup/07.components/tab-navigation.css */
|
|
105
|
-
:root {
|
|
106
|
-
--tab-nav-link-color: var(--brand-text);
|
|
107
|
-
--tab-nav-link-hover-color: var(--brand-text-muted);
|
|
108
|
-
--tab-nav-link-active-color: var(--brand-accent);
|
|
109
|
-
--tab-nav-decorator-indicator-color: var(--brand-accent);
|
|
110
|
-
--tab-nav-decorator-hovered-bg: oklch(from var(--brand-accent) l c h / 0.1);
|
|
111
|
-
--tab-nav-gap: 3rem;
|
|
112
|
-
}
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
---
|
|
116
|
-
|
|
117
|
-
## Local overrides
|
|
118
|
-
|
|
119
|
-
Set the tokens above on an element you own (a page or section class, or a class added with
|
|
120
|
-
`:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
|
|
121
|
-
`@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
|
|
122
|
-
`<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
|
|
123
|
-
`:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
|
|
124
|
-
|
|
125
|
-
### Page or section
|
|
126
|
-
|
|
127
|
-
Override tokens for a single page by scoping them under a page wrapper. No `:deep()` is required
|
|
128
|
-
(component styles are unscoped).
|
|
129
|
-
|
|
130
|
-
```css
|
|
131
|
-
/* In the consuming page's unscoped <style> block */
|
|
132
|
-
.about-page {
|
|
133
|
-
.tab-navigation {
|
|
134
|
-
--tab-nav-link-color: var(--brand-warm-text);
|
|
135
|
-
--tab-nav-decorator-indicator-color: var(--brand-warm-accent);
|
|
136
|
-
--tab-nav-gap: 4rem;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
### One instance: inline style
|
|
142
|
-
|
|
143
|
-
```vue
|
|
144
|
-
<TabNavigation
|
|
145
|
-
:nav-item-data="navData"
|
|
146
|
-
style="
|
|
147
|
-
--tab-nav-decorator-indicator-color: oklch(65% 0.2 230);
|
|
148
|
-
--tab-nav-link-active-color: oklch(65% 0.2 230);
|
|
149
|
-
--tab-nav-gap: 3rem;
|
|
150
|
-
"
|
|
151
|
-
/>
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
### One instance: style-class-passthrough
|
|
155
|
-
|
|
156
|
-
```vue
|
|
157
|
-
<TabNavigation :nav-item-data="navData" :style-class-passthrough="['brand-nav']" />
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
```css
|
|
161
|
-
.tab-navigation.brand-nav {
|
|
162
|
-
--tab-nav-decorator-indicator-color: var(--brand-accent);
|
|
163
|
-
--tab-nav-decorator-hovered-bg: oklch(from var(--brand-accent) l c h / 0.1);
|
|
164
|
-
--tab-nav-link-active-color: var(--brand-accent);
|
|
165
|
-
--tab-nav-link-size: 1.4rem;
|
|
166
|
-
--tab-nav-link-tracking: 0.08em;
|
|
167
|
-
}
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
---
|
|
171
|
-
|
|
172
|
-
## Class passthrough
|
|
173
|
-
|
|
174
|
-
`style-class-passthrough` adds classes to the root `nav.tab-navigation`. Reactive after mount.
|
|
175
|
-
|
|
176
|
-
---
|
|
177
|
-
|
|
178
|
-
## Notes
|
|
179
|
-
|
|
180
|
-
- The active underline indicator and hover pill use CSS Anchor Positioning. They animate via
|
|
181
|
-
`left`/`right` transitions, so `transition` on `.nav__active-indicator` and `.nav__hovered` is
|
|
182
|
-
handled internally — only set `--tab-nav-transition` to control the link colour fade.
|
|
183
|
-
- `--tab-nav-decorator-hovered-bg: transparent` (the default) hides the hover pill entirely; set
|
|
184
|
-
a semi-transparent colour to enable it.
|
|
185
|
-
- Panel and burger tokens only take visual effect when the nav has collapsed to the burger state
|
|
186
|
-
(i.e. when the nav list overflows its container).
|
|
187
|
-
- `--tab-nav-panel-bg` should match `--page-bg` so the panel blends with the page background in
|
|
188
|
-
the collapsed state.
|
|
189
|
-
- The backdrop (`--tab-nav-backdrop-*`) appears behind the open panel and above page content at
|
|
190
|
-
`z-index: 10`. It teleports to `<body>` so it is unaffected by parent stacking contexts.
|
|
191
|
-
|