srcdev-nuxt-components 9.2.5 → 9.2.6
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/settings.json +35 -1
- package/.claude/skills/components/content-docs.md +165 -0
- package/.claude/skills/components/expanding-panel.md +55 -0
- package/.claude/skills/index.md +4 -2
- package/.claude/skills/qa-panel.md +5 -6
- package/.claude/skills/theming-typography-tokens.md +109 -0
- package/.vscode/srcdev-component-content-docs.code-snippets +129 -0
- package/.vscode/srcdev-component-expanding-panel.code-snippets +123 -0
- package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +410 -0
- package/app/components/01.atoms/content-wrappers/docs-pages/playwright/content-docs.playwright.ts +53 -0
- package/app/components/01.atoms/content-wrappers/docs-pages/playwright/content-docs.playwright.ts-snapshots/default-chromium-darwin.png +0 -0
- package/app/components/01.atoms/content-wrappers/docs-pages/playwright/content-docs.playwright.ts-snapshots/state-desktop-chromium-darwin.png +0 -0
- package/app/components/01.atoms/content-wrappers/docs-pages/playwright/content-docs.playwright.ts-snapshots/state-mobile-chromium-darwin.png +0 -0
- package/app/components/01.atoms/content-wrappers/docs-pages/playwright/content-docs.playwright.ts-snapshots/state-tablet-chromium-darwin.png +0 -0
- package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +172 -0
- package/app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts +218 -0
- package/app/components/02.molecules/expandable/expanding-panel/CONSUMER-STYLING.md +103 -0
- package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +97 -56
- package/app/components/02.molecules/expandable/expanding-panel/stories/ExpandingPanel.stories.ts +50 -2
- package/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts +79 -2
- package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +25 -4
- package/app/components/02.molecules/pricing-card/tests/PricingCard.spec.ts +2 -1
- package/app/components/layout-grids/LayoutGridA.vue +59 -59
- package/app/composables/tests/useContainerBreakpoints.spec.ts +91 -0
- package/app/composables/useContainerBreakpoints.ts +71 -0
- package/app/layouts/default.vue +1 -0
- package/app/pages/ui/expanding-panel.vue +266 -129
- package/app/pages/ui/layout-content-docs.vue +72 -0
- package/app/pages/ui/layout-grid-a.vue +5 -58
- package/app/types/components/content-docs.d.ts +5 -0
- package/app/types/components/index.ts +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
|
+
import ContentDocs from "../ContentDocs.vue";
|
|
3
|
+
import type { DocsNavItem } from "~/types/components";
|
|
4
|
+
|
|
5
|
+
interface ContentDocsStoryArgs {
|
|
6
|
+
tag: "div" | "section" | "article" | "main";
|
|
7
|
+
docsNavItems: DocsNavItem[];
|
|
8
|
+
docsPageNavItems: DocsNavItem[];
|
|
9
|
+
docsNavLabel: string;
|
|
10
|
+
docsPageNavLabel: string;
|
|
11
|
+
styleClassPassthrough: string[];
|
|
12
|
+
activeNavItem: string;
|
|
13
|
+
activePageNavItem: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const defaultNavItems: DocsNavItem[] = [
|
|
17
|
+
{ label: "Getting started", to: "/docs", icon: "lucide:rocket" },
|
|
18
|
+
{ label: "Installation", to: "/docs/install", icon: "lucide:download" },
|
|
19
|
+
{ label: "Configuration", to: "/docs/config" },
|
|
20
|
+
{ label: "Theming", to: "/docs/theming", icon: "lucide:palette" },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const defaultPageNavItems: DocsNavItem[] = [
|
|
24
|
+
{ label: "Overview", to: "/docs#overview", icon: "lucide:eye" },
|
|
25
|
+
{ label: "Examples", to: "/docs#examples" },
|
|
26
|
+
{ label: "Props", to: "/docs#props", icon: "lucide:settings-2" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export default {
|
|
30
|
+
title: "Atoms/Content Wrappers/ContentDocs",
|
|
31
|
+
component: ContentDocs,
|
|
32
|
+
argTypes: {
|
|
33
|
+
tag: {
|
|
34
|
+
control: { type: "select" },
|
|
35
|
+
options: ["div", "section", "article", "main"],
|
|
36
|
+
description: "Root element tag",
|
|
37
|
+
table: { category: "Basic" },
|
|
38
|
+
},
|
|
39
|
+
docsNavItems: {
|
|
40
|
+
control: "object",
|
|
41
|
+
description: "Items rendered in the docsNav panel. The panel is omitted entirely when empty.",
|
|
42
|
+
table: { category: "Nav items" },
|
|
43
|
+
},
|
|
44
|
+
docsPageNavItems: {
|
|
45
|
+
control: "object",
|
|
46
|
+
description: "Items rendered in the docsPageNav panel. The panel is omitted entirely when empty.",
|
|
47
|
+
table: { category: "Nav items" },
|
|
48
|
+
},
|
|
49
|
+
docsNavLabel: {
|
|
50
|
+
control: "text",
|
|
51
|
+
description: "Heading text for the docsNav panel",
|
|
52
|
+
table: { category: "Basic" },
|
|
53
|
+
},
|
|
54
|
+
docsPageNavLabel: {
|
|
55
|
+
control: "text",
|
|
56
|
+
description: "Heading text for the docsPageNav panel",
|
|
57
|
+
table: { category: "Basic" },
|
|
58
|
+
},
|
|
59
|
+
styleClassPassthrough: {
|
|
60
|
+
control: "object",
|
|
61
|
+
description: "Additional CSS classes applied to the root element",
|
|
62
|
+
table: { category: "Basic" },
|
|
63
|
+
},
|
|
64
|
+
activeNavItem: {
|
|
65
|
+
control: "text",
|
|
66
|
+
description: "The `to` of the currently-active docsNav item",
|
|
67
|
+
table: { category: "Model" },
|
|
68
|
+
},
|
|
69
|
+
activePageNavItem: {
|
|
70
|
+
control: "text",
|
|
71
|
+
description: "The `to` of the currently-active docsPageNav item",
|
|
72
|
+
table: { category: "Model" },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
args: {
|
|
76
|
+
tag: "div",
|
|
77
|
+
docsNavItems: defaultNavItems,
|
|
78
|
+
docsPageNavItems: defaultPageNavItems,
|
|
79
|
+
docsNavLabel: "Navigation",
|
|
80
|
+
docsPageNavLabel: "On this page",
|
|
81
|
+
styleClassPassthrough: [],
|
|
82
|
+
activeNavItem: defaultNavItems[0]?.to,
|
|
83
|
+
activePageNavItem: "",
|
|
84
|
+
},
|
|
85
|
+
} as Meta<typeof ContentDocs>;
|
|
86
|
+
|
|
87
|
+
// ─── Stories ─────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
const Template: StoryFn<ContentDocsStoryArgs> = (args) => ({
|
|
90
|
+
components: { ContentDocs },
|
|
91
|
+
setup() {
|
|
92
|
+
const { activeNavItem, activePageNavItem, ...rest } = args;
|
|
93
|
+
const activeNav = ref(activeNavItem);
|
|
94
|
+
const activePageNav = ref(activePageNavItem);
|
|
95
|
+
return { args: rest, activeNav, activePageNav };
|
|
96
|
+
},
|
|
97
|
+
template: `
|
|
98
|
+
<ContentDocs
|
|
99
|
+
v-bind="args"
|
|
100
|
+
v-model:active-nav-item="activeNav"
|
|
101
|
+
v-model:active-page-nav-item="activePageNav"
|
|
102
|
+
>
|
|
103
|
+
<template #docsContent>
|
|
104
|
+
<h3 style="margin-top:0">Docs Content</h3>
|
|
105
|
+
<p>
|
|
106
|
+
Resize this story's preview panel to see the layout switch between mobile, tablet,
|
|
107
|
+
and desktop — thresholds are based on the component's own measured width, not the
|
|
108
|
+
browser viewport, so page decoration elsewhere on a real page won't throw them off.
|
|
109
|
+
</p>
|
|
110
|
+
</template>
|
|
111
|
+
</ContentDocs>
|
|
112
|
+
`,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
export const Default = Template.bind({});
|
|
116
|
+
Default.args = {};
|
|
117
|
+
|
|
118
|
+
export const NoIcons = Template.bind({});
|
|
119
|
+
NoIcons.args = {
|
|
120
|
+
docsNavItems: defaultNavItems.map(({ icon: _icon, ...item }) => item),
|
|
121
|
+
docsPageNavItems: defaultPageNavItems.map(({ icon: _icon, ...item }) => item),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const NavOnly = Template.bind({});
|
|
125
|
+
NavOnly.args = {
|
|
126
|
+
docsPageNavItems: [],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const PageNavOnly = Template.bind({});
|
|
130
|
+
PageNavOnly.args = {
|
|
131
|
+
docsNavItems: [],
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const CustomLabels = Template.bind({});
|
|
135
|
+
CustomLabels.args = {
|
|
136
|
+
docsNavLabel: "Sections",
|
|
137
|
+
docsPageNavLabel: "Contents",
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const IconAtEnd: StoryFn<ContentDocsStoryArgs> = (args) => ({
|
|
141
|
+
components: { ContentDocs },
|
|
142
|
+
setup() {
|
|
143
|
+
const { activeNavItem, activePageNavItem, ...rest } = args;
|
|
144
|
+
const activeNav = ref(activeNavItem);
|
|
145
|
+
const activePageNav = ref(activePageNavItem);
|
|
146
|
+
return { args: { ...rest, styleClassPassthrough: ["icon-at-end-demo"] }, activeNav, activePageNav };
|
|
147
|
+
},
|
|
148
|
+
template: `
|
|
149
|
+
<div>
|
|
150
|
+
<style>
|
|
151
|
+
.content-docs.icon-at-end-demo {
|
|
152
|
+
--docs-nav-link-icon-order: rtl;
|
|
153
|
+
--docs-page-nav-link-icon-order: rtl;
|
|
154
|
+
}
|
|
155
|
+
</style>
|
|
156
|
+
<ContentDocs
|
|
157
|
+
v-bind="args"
|
|
158
|
+
v-model:active-nav-item="activeNav"
|
|
159
|
+
v-model:active-page-nav-item="activePageNav"
|
|
160
|
+
>
|
|
161
|
+
<template #docsContent>
|
|
162
|
+
<h3 style="margin-top:0">Docs Content</h3>
|
|
163
|
+
<p>
|
|
164
|
+
<code>--docs-nav-link-icon-order: rtl</code> moves the icon to the end of each
|
|
165
|
+
link instead of the start (default <code>ltr</code>).
|
|
166
|
+
</p>
|
|
167
|
+
</template>
|
|
168
|
+
</ContentDocs>
|
|
169
|
+
</div>
|
|
170
|
+
`,
|
|
171
|
+
});
|
|
172
|
+
IconAtEnd.args = {};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { nextTick } from "vue";
|
|
3
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
4
|
+
import ContentDocs from "../ContentDocs.vue";
|
|
5
|
+
|
|
6
|
+
let resizeCallback: ResizeObserverCallback | null = null;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
resizeCallback = null;
|
|
10
|
+
vi.stubGlobal(
|
|
11
|
+
"ResizeObserver",
|
|
12
|
+
vi.fn((callback: ResizeObserverCallback) => {
|
|
13
|
+
resizeCallback = callback;
|
|
14
|
+
return {
|
|
15
|
+
observe: vi.fn(),
|
|
16
|
+
unobserve: vi.fn(),
|
|
17
|
+
disconnect: vi.fn(),
|
|
18
|
+
};
|
|
19
|
+
})
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function triggerResize(el: HTMLElement, width: number) {
|
|
24
|
+
Object.defineProperty(el, "offsetWidth", { value: width, configurable: true });
|
|
25
|
+
resizeCallback?.(
|
|
26
|
+
[{ contentRect: { width, height: 0 } } as unknown as ResizeObserverEntry],
|
|
27
|
+
{} as ResizeObserver
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const navItems = [
|
|
32
|
+
{ label: "One", to: "/one" },
|
|
33
|
+
{ label: "Two", to: "/two" },
|
|
34
|
+
];
|
|
35
|
+
const pageNavItems = [{ label: "Overview", to: "/one#overview" }];
|
|
36
|
+
|
|
37
|
+
describe("ContentDocs", () => {
|
|
38
|
+
it("mounts without error", async () => {
|
|
39
|
+
const wrapper = await mountSuspended(ContentDocs);
|
|
40
|
+
expect(wrapper.vm).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ─── docsNav / docsPageNav visibility ──────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
it("does not render .docs-nav when docsNavItems is empty", async () => {
|
|
46
|
+
const wrapper = await mountSuspended(ContentDocs);
|
|
47
|
+
expect(wrapper.find(".docs-nav").exists()).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("renders .docs-nav with a link per item when docsNavItems is provided", async () => {
|
|
51
|
+
const wrapper = await mountSuspended(ContentDocs, { props: { docsNavItems: navItems } });
|
|
52
|
+
const links = wrapper.find(".docs-nav").findAll("a");
|
|
53
|
+
expect(links).toHaveLength(2);
|
|
54
|
+
expect(links[0]?.text()).toBe("One");
|
|
55
|
+
expect(links[0]?.attributes("href")).toBe("/one");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does not render .docs-page-nav when docsPageNavItems is empty", async () => {
|
|
59
|
+
const wrapper = await mountSuspended(ContentDocs);
|
|
60
|
+
expect(wrapper.find(".docs-page-nav").exists()).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("renders .docs-page-nav with a link per item when docsPageNavItems is provided", async () => {
|
|
64
|
+
const wrapper = await mountSuspended(ContentDocs, { props: { docsPageNavItems: pageNavItems } });
|
|
65
|
+
const links = wrapper.find(".docs-page-nav").findAll("a");
|
|
66
|
+
expect(links).toHaveLength(1);
|
|
67
|
+
expect(links[0]?.text()).toBe("Overview");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("renders the docsContent slot", async () => {
|
|
71
|
+
const wrapper = await mountSuspended(ContentDocs, {
|
|
72
|
+
slots: { docsContent: '<p data-testid="body">Body</p>' },
|
|
73
|
+
});
|
|
74
|
+
expect(wrapper.find('[data-testid="body"]').text()).toBe("Body");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ─── labels ─────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
it("defaults docsNavLabel to 'Navigation' and docsPageNavLabel to 'On this page'", async () => {
|
|
80
|
+
const wrapper = await mountSuspended(ContentDocs, {
|
|
81
|
+
props: { docsNavItems: navItems, docsPageNavItems: pageNavItems },
|
|
82
|
+
});
|
|
83
|
+
expect(wrapper.find(".docs-nav-heading").text()).toBe("Navigation");
|
|
84
|
+
expect(wrapper.find(".docs-page-nav-heading").text()).toBe("On this page");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("uses custom docsNavLabel / docsPageNavLabel when provided", async () => {
|
|
88
|
+
const wrapper = await mountSuspended(ContentDocs, {
|
|
89
|
+
props: {
|
|
90
|
+
docsNavItems: navItems,
|
|
91
|
+
docsPageNavItems: pageNavItems,
|
|
92
|
+
docsNavLabel: "Sections",
|
|
93
|
+
docsPageNavLabel: "Contents",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
expect(wrapper.find(".docs-nav-heading").text()).toBe("Sections");
|
|
97
|
+
expect(wrapper.find(".docs-page-nav-heading").text()).toBe("Contents");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ─── icons ──────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
it("renders an icon on a docsNav item when item.icon is set", async () => {
|
|
103
|
+
const wrapper = await mountSuspended(ContentDocs, {
|
|
104
|
+
props: { docsNavItems: [{ label: "One", to: "/one", icon: "lucide:home" }] },
|
|
105
|
+
});
|
|
106
|
+
expect(wrapper.find(".docs-nav-link-icon").exists()).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("does not render an icon on a docsNav item when item.icon is omitted", async () => {
|
|
110
|
+
const wrapper = await mountSuspended(ContentDocs, { props: { docsNavItems: navItems } });
|
|
111
|
+
expect(wrapper.find(".docs-nav-link-icon").exists()).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("renders an icon on a docsPageNav item when item.icon is set", async () => {
|
|
115
|
+
const wrapper = await mountSuspended(ContentDocs, {
|
|
116
|
+
props: { docsPageNavItems: [{ label: "Overview", to: "/one#overview", icon: "lucide:list" }] },
|
|
117
|
+
});
|
|
118
|
+
expect(wrapper.find(".docs-page-nav-link-icon").exists()).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ─── active item ────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
it("applies is-active and aria-current to the link matching activeNavItem", async () => {
|
|
124
|
+
const wrapper = await mountSuspended(ContentDocs, {
|
|
125
|
+
props: { docsNavItems: navItems, activeNavItem: "/two" },
|
|
126
|
+
});
|
|
127
|
+
const links = wrapper.find(".docs-nav").findAll("a");
|
|
128
|
+
expect(links[0]?.classes()).not.toContain("is-active");
|
|
129
|
+
expect(links[1]?.classes()).toContain("is-active");
|
|
130
|
+
expect(links[1]?.attributes("aria-current")).toBe("page");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("emits update:activeNavItem when a docsNav link is clicked", async () => {
|
|
134
|
+
const wrapper = await mountSuspended(ContentDocs, { props: { docsNavItems: navItems } });
|
|
135
|
+
await wrapper.find(".docs-nav").findAll("a")[1]?.trigger("click");
|
|
136
|
+
expect(wrapper.emitted("update:activeNavItem")?.[0]).toEqual(["/two"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("emits update:activePageNavItem when a docsPageNav link is clicked", async () => {
|
|
140
|
+
const wrapper = await mountSuspended(ContentDocs, { props: { docsPageNavItems: pageNavItems } });
|
|
141
|
+
await wrapper.find(".docs-page-nav").find("a").trigger("click");
|
|
142
|
+
expect(wrapper.emitted("update:activePageNavItem")?.[0]).toEqual(["/one#overview"]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ─── container-width-driven forceOpened ────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
describe("breakpoint-driven forceOpened", () => {
|
|
148
|
+
it("neither panel is forceOpened below 768px (mobile)", async () => {
|
|
149
|
+
const wrapper = await mountSuspended(ContentDocs, {
|
|
150
|
+
props: { docsNavItems: navItems, docsPageNavItems: pageNavItems },
|
|
151
|
+
});
|
|
152
|
+
triggerResize(wrapper.find(".content-docs").element as HTMLElement, 500);
|
|
153
|
+
await nextTick();
|
|
154
|
+
|
|
155
|
+
expect(wrapper.find(".docs-nav .icon-wrapper").exists()).toBe(true);
|
|
156
|
+
expect(wrapper.find(".docs-page-nav .icon-wrapper").exists()).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("only docsPageNav is forceOpened between 768px and 1023px (tablet)", async () => {
|
|
160
|
+
const wrapper = await mountSuspended(ContentDocs, {
|
|
161
|
+
props: { docsNavItems: navItems, docsPageNavItems: pageNavItems },
|
|
162
|
+
});
|
|
163
|
+
triggerResize(wrapper.find(".content-docs").element as HTMLElement, 900);
|
|
164
|
+
await nextTick();
|
|
165
|
+
|
|
166
|
+
expect(wrapper.find(".docs-nav .icon-wrapper").classes()).not.toContain("icon-wrapper--hidden");
|
|
167
|
+
expect(wrapper.find(".docs-page-nav .icon-wrapper").classes()).toContain("icon-wrapper--hidden");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("both panels are forceOpened at 1024px and above (desktop)", async () => {
|
|
171
|
+
const wrapper = await mountSuspended(ContentDocs, {
|
|
172
|
+
props: { docsNavItems: navItems, docsPageNavItems: pageNavItems },
|
|
173
|
+
});
|
|
174
|
+
triggerResize(wrapper.find(".content-docs").element as HTMLElement, 1200);
|
|
175
|
+
await nextTick();
|
|
176
|
+
|
|
177
|
+
expect(wrapper.find(".docs-nav .icon-wrapper").classes()).toContain("icon-wrapper--hidden");
|
|
178
|
+
expect(wrapper.find(".docs-page-nav .icon-wrapper").classes()).toContain("icon-wrapper--hidden");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ─── native <details> name grouping ────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
describe("panel name grouping", () => {
|
|
185
|
+
it("shares one details name between docsNav and docsPageNav on mobile", async () => {
|
|
186
|
+
const wrapper = await mountSuspended(ContentDocs, {
|
|
187
|
+
props: { docsNavItems: navItems, docsPageNavItems: pageNavItems },
|
|
188
|
+
});
|
|
189
|
+
triggerResize(wrapper.find(".content-docs").element as HTMLElement, 500);
|
|
190
|
+
await nextTick();
|
|
191
|
+
|
|
192
|
+
const navName = wrapper.find(".docs-nav details").attributes("name");
|
|
193
|
+
const pageNavName = wrapper.find(".docs-page-nav details").attributes("name");
|
|
194
|
+
expect(navName).toBe(pageNavName);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("uses distinct details names on desktop, where both must stay open simultaneously", async () => {
|
|
198
|
+
const wrapper = await mountSuspended(ContentDocs, {
|
|
199
|
+
props: { docsNavItems: navItems, docsPageNavItems: pageNavItems },
|
|
200
|
+
});
|
|
201
|
+
triggerResize(wrapper.find(".content-docs").element as HTMLElement, 1200);
|
|
202
|
+
await nextTick();
|
|
203
|
+
|
|
204
|
+
const navName = wrapper.find(".docs-nav details").attributes("name");
|
|
205
|
+
const pageNavName = wrapper.find(".docs-page-nav details").attributes("name");
|
|
206
|
+
expect(navName).not.toBe(pageNavName);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ─── styleClassPassthrough ────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
it("applies styleClassPassthrough classes to the root element", async () => {
|
|
213
|
+
const wrapper = await mountSuspended(ContentDocs, {
|
|
214
|
+
props: { styleClassPassthrough: ["custom-class"] },
|
|
215
|
+
});
|
|
216
|
+
expect(wrapper.find(".content-docs").classes()).toContain("custom-class");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# ExpandingPanel — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## Public token API
|
|
4
|
+
|
|
5
|
+
All `--expanding-panel-*` tokens are the stable override surface. Set them at any scope
|
|
6
|
+
(global, page, or instance) without touching the component itself.
|
|
7
|
+
|
|
8
|
+
| Token | Default | Controls |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| `--expanding-panel-summary-gap` | `1rem` | Gap between the summary label and the toggle icon |
|
|
11
|
+
| `--expanding-panel-summary-padding-block` | `0.5rem` | Vertical padding on the clickable summary row |
|
|
12
|
+
| `--expanding-panel-icon-size` | `1.2rem` | Toggle icon `font-size` |
|
|
13
|
+
| `--expanding-panel-content-z-index` | `10` | Stacking order of the content region when `contentIsOnTop` is `true` |
|
|
14
|
+
| `--expanding-panel-content-gap` | `0px` | Space between the summary and the content region when `contentIsOnTop` is `true` |
|
|
15
|
+
|
|
16
|
+
Note: `background-color`, `padding`, and shadow are **not** tokenised, and must never be applied
|
|
17
|
+
to `.inner` itself — see [expanding-panel.md](../../../../.claude/skills/components/expanding-panel.md#styling-the-content-when-contentisontop)
|
|
18
|
+
for why, and style a wrapper *inside* the `#content` slot instead.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Global theming — recommended approach
|
|
23
|
+
|
|
24
|
+
Create `assets/styles/setup/07.components/expanding-panel.css` in the consuming app and set
|
|
25
|
+
tokens on `:root`. This applies to every `ExpandingPanel` across the site.
|
|
26
|
+
|
|
27
|
+
```css
|
|
28
|
+
/* assets/styles/setup/07.components/expanding-panel.css */
|
|
29
|
+
:root {
|
|
30
|
+
--expanding-panel-summary-gap: 1.6rem;
|
|
31
|
+
--expanding-panel-summary-padding-block: 1.2rem;
|
|
32
|
+
--expanding-panel-icon-size: 1.4rem;
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Page-scoped overrides
|
|
39
|
+
|
|
40
|
+
Override tokens for a specific section by scoping them under the page or layout wrapper.
|
|
41
|
+
No `:deep()` is required (component styles are unscoped).
|
|
42
|
+
|
|
43
|
+
```css
|
|
44
|
+
/* In the consuming page's unscoped <style> block */
|
|
45
|
+
.faq-section {
|
|
46
|
+
.expanding-panel {
|
|
47
|
+
--expanding-panel-summary-padding-block: 1.6rem;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Per-instance overrides via styleClassPassthrough
|
|
55
|
+
|
|
56
|
+
Use sparingly — prefer global or page-scoped CSS. When a single instance needs a distinct
|
|
57
|
+
visual style, pass a modifier class:
|
|
58
|
+
|
|
59
|
+
```vue
|
|
60
|
+
<ExpandingPanel
|
|
61
|
+
name="promo"
|
|
62
|
+
:content-is-on-top="true"
|
|
63
|
+
:style-class-passthrough="['promo-panel']"
|
|
64
|
+
>
|
|
65
|
+
<template #summary>...</template>
|
|
66
|
+
<template #content>
|
|
67
|
+
<!-- Wrapper INSIDE the slot carries the visual styling — never .inner itself -->
|
|
68
|
+
<div class="promo-panel-body">...</div>
|
|
69
|
+
</template>
|
|
70
|
+
</ExpandingPanel>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```css
|
|
74
|
+
.expanding-panel.promo-panel {
|
|
75
|
+
--expanding-panel-content-gap: 0.4rem;
|
|
76
|
+
--expanding-panel-content-z-index: 20;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.promo-panel-body {
|
|
80
|
+
background-color: white;
|
|
81
|
+
padding: 1rem;
|
|
82
|
+
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Notes
|
|
89
|
+
|
|
90
|
+
- `--expanding-panel-content-gap` and `--expanding-panel-content-z-index` only take effect when
|
|
91
|
+
`contentIsOnTop` is `true` — they're no-ops for the default (in-flow) layout.
|
|
92
|
+
- Never set `background-color`, `padding`, `border`, or a shadow directly on `.inner`. `.inner`
|
|
93
|
+
has no explicit height when collapsed (`grid-template-rows: 0fr`) and relies on
|
|
94
|
+
`overflow: hidden` to clip its *children* to 0px — but padding/border/background on `.inner`'s
|
|
95
|
+
own box are not "overflow content", so they'd still render as a visible gap under the summary
|
|
96
|
+
while closed. Always style a wrapper element placed *inside* the `#content` slot instead; that
|
|
97
|
+
wrapper is a child of `.inner` and gets clipped correctly.
|
|
98
|
+
- Don't stack multiple `contentIsOnTop` panels as direct siblings (linked via a shared `name` or
|
|
99
|
+
not). The overlay is absolutely positioned so it doesn't push the next element down — which
|
|
100
|
+
means a sibling `ExpandingPanel` placed right after it sits exactly where the overlay renders,
|
|
101
|
+
and gets visually covered when the first panel opens. `contentIsOnTop` is for a single panel
|
|
102
|
+
overlaying unrelated trailing page content, not for grouped/stacked accordion panels — use the
|
|
103
|
+
default in-flow layout for those.
|