srcdev-nuxt-components 9.2.5 → 9.2.7
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/01.atoms/page-row/PageRow.vue +2 -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/05.forms/input-button/InputButtonCore.vue +12 -1
- package/app/components/05.forms/input-button/tests/InputButtonCore.spec.ts +28 -0
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="expanding-panel" :class="[elementClasses]">
|
|
2
|
+
<div class="expanding-panel" :class="[elementClasses, { 'content-is-on-top': contentIsOnTop }]">
|
|
3
3
|
<details class="expanding-panel-details" :name :open @toggle="onDetailsToggle">
|
|
4
4
|
<summary
|
|
5
5
|
:id="`id-${name}-trigger`"
|
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
:aria-expanded="open"
|
|
9
9
|
@click.prevent.stop="handleToggle"
|
|
10
10
|
>
|
|
11
|
-
<
|
|
11
|
+
<div class="label-wrapper">
|
|
12
12
|
<slot name="summary"></slot>
|
|
13
|
-
</
|
|
14
|
-
<
|
|
13
|
+
</div>
|
|
14
|
+
<div class="icon-wrapper" :class="{ 'icon-wrapper--hidden': forceOpened }">
|
|
15
15
|
<slot name="icon">
|
|
16
16
|
<Icon name="bi:caret-down-fill" class="icon mi-12" />
|
|
17
17
|
</slot>
|
|
18
|
-
</
|
|
18
|
+
</div>
|
|
19
19
|
</summary>
|
|
20
20
|
</details>
|
|
21
21
|
<div
|
|
@@ -36,6 +36,7 @@ interface Props {
|
|
|
36
36
|
name?: string;
|
|
37
37
|
animationDuration?: number;
|
|
38
38
|
forceOpened?: boolean;
|
|
39
|
+
contentIsOnTop?: boolean;
|
|
39
40
|
styleClassPassthrough?: string | string[];
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -43,16 +44,35 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
43
44
|
name: undefined,
|
|
44
45
|
animationDuration: 400,
|
|
45
46
|
forceOpened: false,
|
|
47
|
+
contentIsOnTop: false,
|
|
46
48
|
styleClassPassthrough: () => [],
|
|
47
49
|
});
|
|
48
50
|
|
|
49
|
-
const
|
|
51
|
+
const fallbackName = useId();
|
|
52
|
+
const name = computed(() => props.name || fallbackName);
|
|
50
53
|
const isPanelOpen = defineModel<boolean>({ default: false });
|
|
51
54
|
const animationDurationStr = computed(() => `${props.animationDuration}ms`);
|
|
52
55
|
const open = computed(() => props.forceOpened || isPanelOpen.value);
|
|
53
56
|
|
|
54
57
|
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
55
58
|
|
|
59
|
+
if (import.meta.dev) {
|
|
60
|
+
watch(
|
|
61
|
+
() => props.contentIsOnTop,
|
|
62
|
+
(value) => {
|
|
63
|
+
if (!value) return;
|
|
64
|
+
console.warn(
|
|
65
|
+
`ExpandingPanel${props.name ? ` "${props.name}"` : ""}: contentIsOnTop is enabled. ` +
|
|
66
|
+
"The content overlay is absolutely positioned and does not reserve space, so a sibling " +
|
|
67
|
+
"element (e.g. another ExpandingPanel) placed directly after this one will be visually " +
|
|
68
|
+
"covered when this panel opens. Intended for a single panel overlaying unrelated trailing " +
|
|
69
|
+
"page content — avoid stacking multiple contentIsOnTop panels as direct siblings."
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
{ immediate: true }
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
56
76
|
const handleToggle = (event: Event) => {
|
|
57
77
|
if (props.forceOpened) {
|
|
58
78
|
event.preventDefault();
|
|
@@ -61,80 +81,101 @@ const handleToggle = (event: Event) => {
|
|
|
61
81
|
};
|
|
62
82
|
|
|
63
83
|
const onDetailsToggle = (event: Event) => {
|
|
84
|
+
// forceOpened drives the native `open` attribute directly, which fires its own
|
|
85
|
+
// 'toggle' event — ignore it here or it leaks into isPanelOpen and gets stuck
|
|
86
|
+
// once forceOpened later reverts to false.
|
|
87
|
+
if (props.forceOpened) return;
|
|
64
88
|
isPanelOpen.value = (event.target as HTMLDetailsElement).open;
|
|
65
89
|
};
|
|
66
90
|
</script>
|
|
67
91
|
|
|
68
92
|
<style lang="css">
|
|
69
93
|
@layer components {
|
|
70
|
-
.expanding-panel {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
.expanding-panel-summary {
|
|
76
|
-
display: flex;
|
|
77
|
-
align-items: center;
|
|
78
|
-
justify-content: space-between;
|
|
79
|
-
flex-direction: row;
|
|
80
|
-
gap: 1rem;
|
|
81
|
-
list-style: none;
|
|
82
|
-
|
|
83
|
-
padding-block: 0.5rem;
|
|
84
|
-
|
|
85
|
-
&::-webkit-details-marker,
|
|
86
|
-
&::marker {
|
|
87
|
-
display: none;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
overflow: hidden;
|
|
91
|
-
|
|
92
|
-
.label-wrapper {
|
|
93
|
-
display: inline-block;
|
|
94
|
+
.expanding-panel {
|
|
95
|
+
.expanding-panel-details {
|
|
96
|
+
&:hover {
|
|
97
|
+
cursor: pointer;
|
|
94
98
|
}
|
|
95
|
-
.
|
|
99
|
+
.expanding-panel-summary {
|
|
96
100
|
display: flex;
|
|
97
101
|
align-items: center;
|
|
98
102
|
justify-content: space-between;
|
|
103
|
+
flex-direction: row;
|
|
104
|
+
gap: var(--expanding-panel-summary-gap, 1rem);
|
|
105
|
+
list-style: none;
|
|
99
106
|
|
|
100
|
-
|
|
101
|
-
overflow: hidden;
|
|
107
|
+
padding-block: var(--expanding-panel-summary-padding-block, 0.5rem);
|
|
102
108
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
transition: transform v-bind(animationDurationStr) ease-in-out;
|
|
107
|
-
font-size: 1.2rem;
|
|
108
|
-
will-change: transform;
|
|
109
|
+
&::-webkit-details-marker,
|
|
110
|
+
&::marker {
|
|
111
|
+
display: none;
|
|
109
112
|
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
113
|
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
overflow: hidden;
|
|
115
|
+
|
|
116
|
+
.label-wrapper {
|
|
117
|
+
display: inline-block;
|
|
118
|
+
}
|
|
115
119
|
.icon-wrapper {
|
|
120
|
+
display: flex;
|
|
121
|
+
align-items: center;
|
|
122
|
+
justify-content: space-between;
|
|
123
|
+
|
|
124
|
+
aspect-ratio: 1;
|
|
125
|
+
overflow: hidden;
|
|
126
|
+
|
|
127
|
+
/* Space is always reserved (not v-if'd away) so a forceOpened panel's summary
|
|
128
|
+
row stays the same height as a toggleable sibling's — only the glyph hides. */
|
|
129
|
+
&.icon-wrapper--hidden {
|
|
130
|
+
visibility: hidden;
|
|
131
|
+
}
|
|
132
|
+
|
|
116
133
|
.icon {
|
|
117
|
-
|
|
134
|
+
display: block;
|
|
135
|
+
transform: scaleY(1);
|
|
136
|
+
transition: transform v-bind(animationDurationStr) ease-in-out;
|
|
137
|
+
font-size: var(--expanding-panel-icon-size, 1.2rem);
|
|
138
|
+
will-change: transform;
|
|
118
139
|
}
|
|
119
140
|
}
|
|
120
141
|
}
|
|
121
|
-
|
|
122
|
-
|
|
142
|
+
|
|
143
|
+
&[open] {
|
|
144
|
+
.expanding-panel-summary {
|
|
145
|
+
.icon-wrapper {
|
|
146
|
+
.icon {
|
|
147
|
+
transform: scaleY(-1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
+ .expanding-panel-content {
|
|
152
|
+
grid-template-rows: 1fr;
|
|
153
|
+
}
|
|
123
154
|
}
|
|
124
155
|
}
|
|
125
|
-
}
|
|
126
156
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
157
|
+
.expanding-panel-content {
|
|
158
|
+
display: grid;
|
|
159
|
+
grid-template-rows: 0fr;
|
|
160
|
+
transition: grid-template-rows v-bind(animationDurationStr) ease-in-out;
|
|
161
|
+
will-change: grid-template-rows;
|
|
132
162
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
163
|
+
.inner {
|
|
164
|
+
overflow: hidden;
|
|
165
|
+
margin-top: 0;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
&.content-is-on-top {
|
|
170
|
+
position: relative;
|
|
171
|
+
|
|
172
|
+
.expanding-panel-content {
|
|
173
|
+
position: absolute;
|
|
174
|
+
top: calc(100% + var(--expanding-panel-content-gap, 0px));
|
|
175
|
+
inset-inline: 0;
|
|
176
|
+
z-index: var(--expanding-panel-content-z-index, 10);
|
|
177
|
+
}
|
|
136
178
|
}
|
|
137
179
|
}
|
|
138
180
|
}
|
|
139
|
-
}
|
|
140
181
|
</style>
|
package/app/components/02.molecules/expandable/expanding-panel/stories/ExpandingPanel.stories.ts
CHANGED
|
@@ -7,8 +7,7 @@ const meta: Meta<typeof ExpandingPanel> = {
|
|
|
7
7
|
argTypes: {
|
|
8
8
|
name: {
|
|
9
9
|
control: { type: "text" },
|
|
10
|
-
description:
|
|
11
|
-
"Unique name used for ARIA attributes and to group panels in a native <details> accordion",
|
|
10
|
+
description: "Unique name used for ARIA attributes and to group panels in a native <details> accordion",
|
|
12
11
|
},
|
|
13
12
|
animationDuration: {
|
|
14
13
|
control: { type: "number", min: 0, step: 50 },
|
|
@@ -18,6 +17,11 @@ const meta: Meta<typeof ExpandingPanel> = {
|
|
|
18
17
|
control: { type: "boolean" },
|
|
19
18
|
description: "When true, the panel is always open and the toggle icon is hidden",
|
|
20
19
|
},
|
|
20
|
+
contentIsOnTop: {
|
|
21
|
+
control: { type: "boolean" },
|
|
22
|
+
description:
|
|
23
|
+
"When true, the content region is positioned absolutely below the summary and raised above surrounding page content via z-index, instead of pushing layout down",
|
|
24
|
+
},
|
|
21
25
|
styleClassPassthrough: {
|
|
22
26
|
control: "object",
|
|
23
27
|
description: "Additional CSS classes applied to the root element",
|
|
@@ -27,6 +31,7 @@ const meta: Meta<typeof ExpandingPanel> = {
|
|
|
27
31
|
name: "panel-demo",
|
|
28
32
|
animationDuration: 400,
|
|
29
33
|
forceOpened: false,
|
|
34
|
+
contentIsOnTop: false,
|
|
30
35
|
styleClassPassthrough: [],
|
|
31
36
|
},
|
|
32
37
|
};
|
|
@@ -218,6 +223,49 @@ export const RichContent: Story = {
|
|
|
218
223
|
}),
|
|
219
224
|
};
|
|
220
225
|
|
|
226
|
+
export const ContentIsOnTop: Story = {
|
|
227
|
+
name: "Content Is On Top",
|
|
228
|
+
args: {
|
|
229
|
+
name: "content-is-on-top",
|
|
230
|
+
contentIsOnTop: true,
|
|
231
|
+
},
|
|
232
|
+
render: (args) => ({
|
|
233
|
+
components: { ExpandingPanel },
|
|
234
|
+
setup() {
|
|
235
|
+
return { args };
|
|
236
|
+
},
|
|
237
|
+
template: `
|
|
238
|
+
<div style="position:relative;padding-bottom:4rem">
|
|
239
|
+
<ExpandingPanel v-bind="args">
|
|
240
|
+
<template #summary>
|
|
241
|
+
<span>Open me — content overlays what's below</span>
|
|
242
|
+
</template>
|
|
243
|
+
<template #content>
|
|
244
|
+
<!--
|
|
245
|
+
The background/border/shadow live on this wrapper — never on .inner
|
|
246
|
+
itself. .inner has no explicit height when collapsed (grid-template-rows: 0fr)
|
|
247
|
+
and relies on overflow:hidden to clip its children to 0px; but padding/border/
|
|
248
|
+
background set directly on .inner are part of ITS OWN box, not overflow content,
|
|
249
|
+
so they'd still render as a visible gap under the summary while closed. Styling
|
|
250
|
+
a wrapper *inside* the slot keeps it clipped correctly by .inner's overflow:hidden.
|
|
251
|
+
-->
|
|
252
|
+
<div style="color: #808080; background-color:#fff;border:1px solid #ddd;border-radius:0.4rem;padding:1rem;box-shadow:0 4px 12px rgb(0 0 0 / 15%)">
|
|
253
|
+
<p style="margin:0">
|
|
254
|
+
With <code>contentIsOnTop</code>, this content is absolutely positioned below the
|
|
255
|
+
summary and raised above surrounding page content via <code>z-index</code>,
|
|
256
|
+
instead of pushing the "Page content below" text further down.
|
|
257
|
+
</p>
|
|
258
|
+
</div>
|
|
259
|
+
</template>
|
|
260
|
+
</ExpandingPanel>
|
|
261
|
+
<p style="margin-top:1rem;padding:1rem;">
|
|
262
|
+
Page content below — stays in place when the panel opens.
|
|
263
|
+
</p>
|
|
264
|
+
</div>
|
|
265
|
+
`,
|
|
266
|
+
}),
|
|
267
|
+
};
|
|
268
|
+
|
|
221
269
|
export const WithStyleClassPassthrough: Story = {
|
|
222
270
|
name: "With styleClassPassthrough",
|
|
223
271
|
args: {
|
|
@@ -41,6 +41,13 @@ describe("ExpandingPanel", () => {
|
|
|
41
41
|
expect(wrapper.html()).toMatchSnapshot();
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
it("renders correct HTML structure with contentIsOnTop true", async () => {
|
|
45
|
+
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
46
|
+
props: { name: "snap-content-on-top", contentIsOnTop: true },
|
|
47
|
+
});
|
|
48
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
49
|
+
});
|
|
50
|
+
|
|
44
51
|
it("renders correct HTML structure with populated slots", async () => {
|
|
45
52
|
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
46
53
|
props: { name: "snap-slots" },
|
|
@@ -91,6 +98,7 @@ describe("ExpandingPanel", () => {
|
|
|
91
98
|
});
|
|
92
99
|
expect(wrapper.props("animationDuration")).toBe(400);
|
|
93
100
|
expect(wrapper.props("forceOpened")).toBe(false);
|
|
101
|
+
expect(wrapper.props("contentIsOnTop")).toBe(false);
|
|
94
102
|
expect(wrapper.props("styleClassPassthrough")).toEqual([]);
|
|
95
103
|
});
|
|
96
104
|
|
|
@@ -286,11 +294,14 @@ describe("ExpandingPanel", () => {
|
|
|
286
294
|
|
|
287
295
|
// ─── forceOpened ──────────────────────────────────────────────────────────
|
|
288
296
|
|
|
289
|
-
it("hides the icon-wrapper when forceOpened is true", async () => {
|
|
297
|
+
it("visually hides the icon-wrapper when forceOpened is true, without removing it from layout", async () => {
|
|
290
298
|
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
291
299
|
props: { name: "force-icon", forceOpened: true },
|
|
292
300
|
});
|
|
293
|
-
|
|
301
|
+
// Kept in the DOM (not v-if'd away) so the summary row height stays consistent
|
|
302
|
+
// with a toggleable sibling panel's — only visually hidden via the modifier class.
|
|
303
|
+
expect(wrapper.find(".icon-wrapper").exists()).toBe(true);
|
|
304
|
+
expect(wrapper.find(".icon-wrapper").classes()).toContain("icon-wrapper--hidden");
|
|
294
305
|
});
|
|
295
306
|
|
|
296
307
|
it("shows the icon-wrapper when forceOpened is false", async () => {
|
|
@@ -298,6 +309,7 @@ describe("ExpandingPanel", () => {
|
|
|
298
309
|
props: { name: "icon-visible", forceOpened: false },
|
|
299
310
|
});
|
|
300
311
|
expect(wrapper.find(".icon-wrapper").exists()).toBe(true);
|
|
312
|
+
expect(wrapper.find(".icon-wrapper").classes()).not.toContain("icon-wrapper--hidden");
|
|
301
313
|
});
|
|
302
314
|
|
|
303
315
|
it("keeps open true after click when forceOpened is true", async () => {
|
|
@@ -309,6 +321,23 @@ describe("ExpandingPanel", () => {
|
|
|
309
321
|
expect(vm.open).toBe(true);
|
|
310
322
|
});
|
|
311
323
|
|
|
324
|
+
it("does not leak forceOpened into isPanelOpen, so open reverts to false when forceOpened turns back off", async () => {
|
|
325
|
+
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
326
|
+
props: { name: "force-then-unforce", forceOpened: true },
|
|
327
|
+
});
|
|
328
|
+
const vm = wrapper.vm as unknown as ExpandingPanelInstance;
|
|
329
|
+
expect(vm.open).toBe(true);
|
|
330
|
+
|
|
331
|
+
// The <details> element's `open` attribute changing (driven by forceOpened) fires
|
|
332
|
+
// its own native 'toggle' event, independent of any user click.
|
|
333
|
+
await wrapper.find("details").trigger("toggle");
|
|
334
|
+
await nextTick();
|
|
335
|
+
expect(vm.isPanelOpen).toBe(false);
|
|
336
|
+
|
|
337
|
+
await wrapper.setProps({ forceOpened: false });
|
|
338
|
+
expect(vm.open).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
|
|
312
341
|
// ─── Slots ────────────────────────────────────────────────────────────────
|
|
313
342
|
|
|
314
343
|
it("renders summary slot content inside .label-wrapper", async () => {
|
|
@@ -364,6 +393,40 @@ describe("ExpandingPanel", () => {
|
|
|
364
393
|
expect(wrapper.find(".expanding-panel").classes()).toContain("single-class");
|
|
365
394
|
});
|
|
366
395
|
|
|
396
|
+
// ─── contentIsOnTop ───────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
it("does not apply content-is-on-top class to root by default", async () => {
|
|
399
|
+
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
400
|
+
props: { name: "content-on-top-default" },
|
|
401
|
+
});
|
|
402
|
+
expect(wrapper.find(".expanding-panel").classes()).not.toContain("content-is-on-top");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("applies content-is-on-top class to root when contentIsOnTop is true", async () => {
|
|
406
|
+
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
407
|
+
props: { name: "content-on-top-true", contentIsOnTop: true },
|
|
408
|
+
});
|
|
409
|
+
expect(wrapper.find(".expanding-panel").classes()).toContain("content-is-on-top");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("does not apply content-is-on-top class to the content region itself", async () => {
|
|
413
|
+
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
414
|
+
props: { name: "content-on-top-scope", contentIsOnTop: true },
|
|
415
|
+
});
|
|
416
|
+
expect(wrapper.find(".expanding-panel-content").classes()).not.toContain("content-is-on-top");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("still toggles open state when summary is clicked with contentIsOnTop true", async () => {
|
|
420
|
+
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
421
|
+
props: { name: "content-on-top-click", contentIsOnTop: true },
|
|
422
|
+
});
|
|
423
|
+
const vm = wrapper.vm as unknown as ExpandingPanelInstance;
|
|
424
|
+
expect(vm.isPanelOpen).toBe(false);
|
|
425
|
+
|
|
426
|
+
await wrapper.find("summary").trigger("click");
|
|
427
|
+
expect(vm.isPanelOpen).toBe(true);
|
|
428
|
+
});
|
|
429
|
+
|
|
367
430
|
// ─── name prop / useId fallback ───────────────────────────────────────────
|
|
368
431
|
|
|
369
432
|
it("uses the provided name in ARIA attributes", async () => {
|
|
@@ -381,4 +444,18 @@ describe("ExpandingPanel", () => {
|
|
|
381
444
|
expect(summaryId).toMatch(/^id-.+-trigger$/);
|
|
382
445
|
expect(contentId).toMatch(/^id-.+-content$/);
|
|
383
446
|
});
|
|
447
|
+
|
|
448
|
+
it("updates the details name and ARIA ids when the name prop changes after mount", async () => {
|
|
449
|
+
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
450
|
+
props: { name: "panel-a" },
|
|
451
|
+
});
|
|
452
|
+
expect(wrapper.find("details").attributes("name")).toBe("panel-a");
|
|
453
|
+
expect(wrapper.find("summary").attributes("id")).toBe("id-panel-a-trigger");
|
|
454
|
+
|
|
455
|
+
await wrapper.setProps({ name: "panel-b" });
|
|
456
|
+
|
|
457
|
+
expect(wrapper.find("details").attributes("name")).toBe("panel-b");
|
|
458
|
+
expect(wrapper.find("summary").attributes("id")).toBe("id-panel-b-trigger");
|
|
459
|
+
expect(wrapper.find(".expanding-panel-content").attributes("id")).toBe("id-panel-b-content");
|
|
460
|
+
});
|
|
384
461
|
});
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
exports[`ExpandingPanel > renders correct HTML structure with all props set 1`] = `
|
|
4
4
|
"<div class="expanding-panel extra-class">
|
|
5
5
|
<details class="expanding-panel-details" name="snap-all" open="">
|
|
6
|
-
<summary id="id-snap-all-trigger" class="expanding-panel-summary" aria-controls="id-snap-all-content" aria-expanded="true"
|
|
7
|
-
|
|
6
|
+
<summary id="id-snap-all-trigger" class="expanding-panel-summary" aria-controls="id-snap-all-content" aria-expanded="true">
|
|
7
|
+
<div class="label-wrapper"></div>
|
|
8
|
+
<div class="icon-wrapper icon-wrapper--hidden"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></div>
|
|
8
9
|
</summary>
|
|
9
10
|
</details>
|
|
10
11
|
<div id="id-snap-all-content" class="expanding-panel-content" aria-labelledby="id-snap-all-trigger" role="region">
|
|
@@ -13,10 +14,27 @@ exports[`ExpandingPanel > renders correct HTML structure with all props set 1`]
|
|
|
13
14
|
</div>"
|
|
14
15
|
`;
|
|
15
16
|
|
|
17
|
+
exports[`ExpandingPanel > renders correct HTML structure with contentIsOnTop true 1`] = `
|
|
18
|
+
"<div class="expanding-panel content-is-on-top">
|
|
19
|
+
<details class="expanding-panel-details" name="snap-content-on-top">
|
|
20
|
+
<summary id="id-snap-content-on-top-trigger" class="expanding-panel-summary" aria-controls="id-snap-content-on-top-content" aria-expanded="false">
|
|
21
|
+
<div class="label-wrapper"></div>
|
|
22
|
+
<div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></div>
|
|
23
|
+
</summary>
|
|
24
|
+
</details>
|
|
25
|
+
<div id="id-snap-content-on-top-content" class="expanding-panel-content" aria-labelledby="id-snap-content-on-top-trigger" role="region">
|
|
26
|
+
<div class="inner"></div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>"
|
|
29
|
+
`;
|
|
30
|
+
|
|
16
31
|
exports[`ExpandingPanel > renders correct HTML structure with default props 1`] = `
|
|
17
32
|
"<div class="expanding-panel">
|
|
18
33
|
<details class="expanding-panel-details" name="snap-default">
|
|
19
|
-
<summary id="id-snap-default-trigger" class="expanding-panel-summary" aria-controls="id-snap-default-content" aria-expanded="false"
|
|
34
|
+
<summary id="id-snap-default-trigger" class="expanding-panel-summary" aria-controls="id-snap-default-content" aria-expanded="false">
|
|
35
|
+
<div class="label-wrapper"></div>
|
|
36
|
+
<div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></div>
|
|
37
|
+
</summary>
|
|
20
38
|
</details>
|
|
21
39
|
<div id="id-snap-default-content" class="expanding-panel-content" aria-labelledby="id-snap-default-trigger" role="region">
|
|
22
40
|
<div class="inner"></div>
|
|
@@ -27,7 +45,10 @@ exports[`ExpandingPanel > renders correct HTML structure with default props 1`]
|
|
|
27
45
|
exports[`ExpandingPanel > renders correct HTML structure with populated slots 1`] = `
|
|
28
46
|
"<div class="expanding-panel">
|
|
29
47
|
<details class="expanding-panel-details" name="snap-slots">
|
|
30
|
-
<summary id="id-snap-slots-trigger" class="expanding-panel-summary" aria-controls="id-snap-slots-content" aria-expanded="false"
|
|
48
|
+
<summary id="id-snap-slots-trigger" class="expanding-panel-summary" aria-controls="id-snap-slots-content" aria-expanded="false">
|
|
49
|
+
<div class="label-wrapper"><span>Summary text</span></div>
|
|
50
|
+
<div class="icon-wrapper"><span>▸</span></div>
|
|
51
|
+
</summary>
|
|
31
52
|
</details>
|
|
32
53
|
<div id="id-snap-slots-content" class="expanding-panel-content" aria-labelledby="id-snap-slots-trigger" role="region">
|
|
33
54
|
<div class="inner">
|
|
@@ -109,7 +109,8 @@ describe("PricingCard", () => {
|
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
const button = wrapper.find("button");
|
|
112
|
-
expect(button.attributes("
|
|
112
|
+
expect(button.attributes("readonly")).toBeDefined();
|
|
113
|
+
expect(button.attributes("aria-disabled")).toBe("true");
|
|
113
114
|
});
|
|
114
115
|
|
|
115
116
|
it("renders custom features via slot", () => {
|
|
@@ -30,6 +30,14 @@ import type { InputTypesButton, FormUiTheme, InputButtonVariant } from "~/types/
|
|
|
30
30
|
interface Props {
|
|
31
31
|
type?: InputTypesButton;
|
|
32
32
|
href?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Force a real browser navigation instead of client-side routing, even for a same-origin
|
|
35
|
+
* href starting with "/". Needed for hrefs that aren't Vue Router pages — e.g. a Nitro
|
|
36
|
+
* server route like "/api/auth/github" — since NuxtLink otherwise treats any leading-"/"
|
|
37
|
+
* href as an internal route and does a client-side router.push, which fails silently
|
|
38
|
+
* (no matching route) instead of hitting the server.
|
|
39
|
+
*/
|
|
40
|
+
external?: boolean;
|
|
33
41
|
theme?: FormUiTheme;
|
|
34
42
|
variant?: InputButtonVariant;
|
|
35
43
|
isPill?: boolean;
|
|
@@ -43,6 +51,7 @@ interface Props {
|
|
|
43
51
|
const props = withDefaults(defineProps<Props>(), {
|
|
44
52
|
type: "button",
|
|
45
53
|
href: undefined,
|
|
54
|
+
external: false,
|
|
46
55
|
theme: "default",
|
|
47
56
|
variant: "primary",
|
|
48
57
|
isPill: false,
|
|
@@ -58,7 +67,9 @@ const NuxtLink = resolveComponent("NuxtLink");
|
|
|
58
67
|
|
|
59
68
|
// If href is undefined tag is button, else it's a link
|
|
60
69
|
const isLink = computed(() => Boolean(props.href));
|
|
61
|
-
const isInternalLink = computed(
|
|
70
|
+
const isInternalLink = computed(
|
|
71
|
+
() => isLink.value && props.href && props.href.startsWith("/") && !props.external
|
|
72
|
+
);
|
|
62
73
|
const tag = computed(() => {
|
|
63
74
|
if (isInternalLink.value) return NuxtLink;
|
|
64
75
|
if (isLink.value) return "a";
|
|
@@ -242,6 +242,34 @@ describe("InputButtonCore", () => {
|
|
|
242
242
|
});
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
+
// -------------------------
|
|
246
|
+
// Link rendering
|
|
247
|
+
// -------------------------
|
|
248
|
+
describe("Link rendering", () => {
|
|
249
|
+
it("renders a plain anchor for a non-internal href", async () => {
|
|
250
|
+
wrapper = await createWrapper({ href: "https://example.com" });
|
|
251
|
+
const link = wrapper.find("a");
|
|
252
|
+
expect(link.exists()).toBe(true);
|
|
253
|
+
expect(link.attributes("href")).toBe("https://example.com");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("renders a real anchor with the exact href for a '/'-prefixed href when external is true", async () => {
|
|
257
|
+
// Regression test: InputButtonCore used to always route a leading-"/" href through
|
|
258
|
+
// NuxtLink (client-side router.push), which silently fails for non-page paths like a
|
|
259
|
+
// Nitro server route ("/api/auth/github") since no Vue Router route matches it. The
|
|
260
|
+
// `external` prop forces a real anchor so the browser makes an actual request instead.
|
|
261
|
+
wrapper = await createWrapper({ href: "/api/auth/github", external: true });
|
|
262
|
+
const link = wrapper.find("a");
|
|
263
|
+
expect(link.exists()).toBe(true);
|
|
264
|
+
expect(link.attributes("href")).toBe("/api/auth/github");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("applies the is-link class for a link", async () => {
|
|
268
|
+
wrapper = await createWrapper({ href: "/api/auth/github", external: true });
|
|
269
|
+
expect(wrapper.find("a").classes()).toContain("is-link");
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
245
273
|
// -------------------------
|
|
246
274
|
// Computed (vm internals)
|
|
247
275
|
// -------------------------
|