srcdev-nuxt-components 9.6.18 → 9.6.19
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/rules/consumer/css-relative-colour-calc.md +15 -0
- package/.claude/rules/consumer/vue-dynamic-arg-no-spaces.md +16 -0
- package/.claude/skills/component-compliance-checklist.md +6 -1
- package/.claude/skills/component-dynamic-slots.md +2 -2
- package/.claude/skills/component-naming.md +1 -1
- package/.claude/skills/components/accordion-chat.md +99 -0
- package/.claude/skills/components/accordion-group.md +98 -0
- package/.claude/skills/components/action-menu.md +11 -5
- package/.claude/skills/components/expanding-panel-classic.md +9 -7
- package/.claude/skills/components/expanding-panel.md +9 -8
- package/.claude/skills/components/sparkle-effect.md +79 -0
- package/.claude/skills/index.md +4 -2
- package/.claude/skills/storybook-add-story.md +8 -0
- package/.claude/skills/using-component-skills.md +1 -1
- package/.vscode/srcdev-component-accordion-chat.code-snippets +42 -0
- package/.vscode/srcdev-component-accordion-group.code-snippets +38 -0
- package/.vscode/srcdev-component-action-menu.code-snippets +3 -3
- package/.vscode/srcdev-component-sparkle-effect.code-snippets +33 -0
- package/app/components/01.atoms/animations/sparkle-effect/CONSUMER-STYLING.md +75 -0
- package/app/components/01.atoms/animations/sparkle-effect/SparkleEffect.vue +213 -0
- package/app/components/01.atoms/animations/sparkle-effect/stories/SparkleEffect.stories.ts +303 -0
- package/app/components/01.atoms/animations/sparkle-effect/tests/SparkleEffect.spec.ts +150 -0
- package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +1 -1
- package/app/components/02.molecules/action-menu/ActionMenu.vue +6 -2
- package/app/components/02.molecules/action-menu/ActionMenuItem.vue +6 -3
- package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +11 -4
- package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +130 -18
- package/app/components/02.molecules/expandable/accordion-chat/AccordionChat.vue +227 -0
- package/app/components/02.molecules/expandable/accordion-chat/CONSUMER-STYLING.md +150 -0
- package/app/components/02.molecules/expandable/accordion-chat/stories/AccordionChat.stories.ts +347 -0
- package/app/components/02.molecules/expandable/accordion-chat/tests/AccordionChat.spec.ts +205 -0
- package/app/components/02.molecules/expandable/accordion-chat/tests/__snapshots__/AccordionChat.spec.ts.snap +40 -0
- package/app/components/02.molecules/expandable/{accordian/AccordianCore.vue → accordion-group/AccordionGroup.vue} +32 -20
- package/app/components/02.molecules/expandable/accordion-group/CONSUMER-STYLING.md +66 -0
- package/app/components/02.molecules/expandable/accordion-group/stories/AccordionGroup.stories.ts +238 -0
- package/app/components/02.molecules/expandable/accordion-group/tests/AccordionGroup.spec.ts +172 -0
- package/app/components/02.molecules/expandable/accordion-group/tests/__snapshots__/AccordionGroup.spec.ts.snap +45 -0
- package/app/components/02.molecules/expandable/expanding-panel/CONSUMER-STYLING.md +1 -0
- package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +11 -6
- package/app/components/02.molecules/expandable/expanding-panel/stories/ExpandingPanel.stories.ts +82 -1
- package/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts +24 -30
- package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +8 -8
- package/app/components/02.molecules/expandable/expanding-panel-classic/CONSUMER-STYLING.md +1 -0
- package/app/components/02.molecules/expandable/expanding-panel-classic/ExpandingPanelClassic.vue +10 -5
- package/app/components/02.molecules/expandable/expanding-panel-classic/stories/ExpandingPanelClassic.stories.ts +83 -1
- package/app/components/02.molecules/expandable/expanding-panel-classic/tests/ExpandingPanelClassic.spec.ts +24 -30
- package/app/components/02.molecules/expandable/expanding-panel-classic/tests/__snapshots__/ExpandingPanelClassic.spec.ts.snap +8 -8
- package/app/components/03.organisms/responsive-header/tests/__snapshots__/NavigationItems.spec.ts.snap +2 -2
- package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +2 -2
- package/package.json +1 -1
- package/.claude/skills/components/accordian-core.md +0 -180
- package/.vscode/srcdev-component-accordian-core.code-snippets +0 -74
- package/app/components/02.molecules/expandable/accordian/CONSUMER-STYLING.md +0 -100
- package/app/components/02.molecules/expandable/accordian/stories/AccordianCore.stories.ts +0 -272
- package/app/components/02.molecules/expandable/accordian/tests/AccordianCore.spec.ts +0 -257
- package/app/components/02.molecules/expandable/accordian/tests/__snapshots__/AccordianCore.spec.ts.snap +0 -95
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
<div ref="rootEl" 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
|
-
:id="
|
|
5
|
+
:id="`${panelId}-trigger`"
|
|
6
6
|
class="expanding-panel-summary"
|
|
7
|
-
:aria-controls="
|
|
7
|
+
:aria-controls="`${panelId}-content`"
|
|
8
8
|
:aria-expanded="open"
|
|
9
9
|
@click.prevent.stop="handleToggle"
|
|
10
10
|
>
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
</div>
|
|
19
19
|
</summary>
|
|
20
20
|
<div
|
|
21
|
-
:id="
|
|
21
|
+
:id="`${panelId}-content`"
|
|
22
22
|
class="expanding-panel-content"
|
|
23
|
-
:aria-labelledby="
|
|
23
|
+
:aria-labelledby="`${panelId}-trigger`"
|
|
24
24
|
role="region"
|
|
25
25
|
>
|
|
26
26
|
<slot name="content"></slot>
|
|
@@ -49,6 +49,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
const fallbackName = useId();
|
|
52
|
+
const panelId = useId();
|
|
52
53
|
const name = computed(() => props.name || fallbackName);
|
|
53
54
|
const isPanelOpen = defineModel<boolean>({ default: false });
|
|
54
55
|
const animationDurationStr = computed(() => `${props.animationDuration}ms`);
|
|
@@ -69,7 +70,7 @@ if (import.meta.dev) {
|
|
|
69
70
|
// reproducible in Chrome's device-emulation mode since that's still the Blink engine. An
|
|
70
71
|
// earlier @supports-based fallback attempt (2026-08-22) turned out not to reliably kick in
|
|
71
72
|
// either. Prefer ExpandingPanelClassic (or a consumer's own `classic` prop, where offered —
|
|
72
|
-
// see
|
|
73
|
+
// see AccordionGroup) for anything that needs to work correctly on real Apple devices today.
|
|
73
74
|
console.warn(
|
|
74
75
|
`ExpandingPanel${props.name ? ` "${props.name}"` : ""}: this variant depends on ` +
|
|
75
76
|
"::details-content, which WebKit (Safari/iOS) does not support — the animation and, more " +
|
|
@@ -110,6 +111,8 @@ const onDetailsToggle = (event: Event) => {
|
|
|
110
111
|
<style lang="css">
|
|
111
112
|
@layer components {
|
|
112
113
|
.expanding-panel {
|
|
114
|
+
overflow-wrap: anywhere;
|
|
115
|
+
|
|
113
116
|
.expanding-panel-details {
|
|
114
117
|
&:hover {
|
|
115
118
|
cursor: pointer;
|
|
@@ -117,7 +120,7 @@ const onDetailsToggle = (event: Event) => {
|
|
|
117
120
|
.expanding-panel-summary {
|
|
118
121
|
display: grid;
|
|
119
122
|
align-items: center;
|
|
120
|
-
grid-template-columns: 1fr auto;
|
|
123
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
121
124
|
gap: var(--expanding-panel-summary-gap, 1rem);
|
|
122
125
|
list-style: none;
|
|
123
126
|
user-select: none;
|
|
@@ -133,6 +136,8 @@ const onDetailsToggle = (event: Event) => {
|
|
|
133
136
|
|
|
134
137
|
.label-wrapper {
|
|
135
138
|
display: inline-block;
|
|
139
|
+
min-inline-size: 0;
|
|
140
|
+
overflow-wrap: anywhere;
|
|
136
141
|
}
|
|
137
142
|
/* inline-size is reserved explicitly — the grid's align-items:center
|
|
138
143
|
doesn't stretch this item to row height, so with only aspect-ratio:1
|
package/app/components/02.molecules/expandable/expanding-panel/stories/ExpandingPanel.stories.ts
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import ExpandingPanel from "../ExpandingPanel.vue";
|
|
2
2
|
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
import { ref } from "vue";
|
|
4
|
+
import CanvasSwitcher from "../../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
|
|
5
|
+
import type { MediaCanvas } from "~/types/components";
|
|
3
6
|
|
|
4
7
|
const meta: Meta<typeof ExpandingPanel> = {
|
|
5
8
|
title: "Molecules/Expandable/Expanding Panel",
|
|
6
9
|
component: ExpandingPanel,
|
|
10
|
+
parameters: {
|
|
11
|
+
docs: {
|
|
12
|
+
description: {
|
|
13
|
+
component:
|
|
14
|
+
"A single expand/collapse panel on native <details>/<summary>. To render a set of these from slots, optionally exclusive-open, use " +
|
|
15
|
+
"[AccordionGroup](?path=/story/molecules-expandable-accordiongroup--exclusive-open), which renders this panel for every item when `variant=\"modern\"`.",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
7
19
|
argTypes: {
|
|
8
20
|
name: {
|
|
9
21
|
control: { type: "text" },
|
|
10
|
-
description: "
|
|
22
|
+
description: "The <details> name: panels sharing it form an exclusive-open group. Element ids come from useId(), not from this.",
|
|
11
23
|
},
|
|
12
24
|
animationDuration: {
|
|
13
25
|
control: { type: "number", min: 0, step: 50 },
|
|
@@ -290,3 +302,72 @@ export const WithStyleClassPassthrough: Story = {
|
|
|
290
302
|
`,
|
|
291
303
|
}),
|
|
292
304
|
};
|
|
305
|
+
|
|
306
|
+
export const StressTest: Story = {
|
|
307
|
+
name: "Stress Test (Worst-Case Data)",
|
|
308
|
+
render: (args) => ({
|
|
309
|
+
components: { ExpandingPanel },
|
|
310
|
+
setup() {
|
|
311
|
+
const longUnbroken = "Donaudampfschifffahrtselektrizitätenhauptbetriebswerkbauunterbeamtengesellschaft";
|
|
312
|
+
const longUrl = "https://example.com/" + "a-very-long-path-segment-without-spaces".repeat(4);
|
|
313
|
+
const longCopy = "Unsere Behandlungen werden individuell auf Ihre Bedürfnisse abgestimmt, damit Sie sich rundum wohlfühlen. ".repeat(6);
|
|
314
|
+
return { args, longUnbroken, longUrl, longCopy };
|
|
315
|
+
},
|
|
316
|
+
template: `
|
|
317
|
+
<div style="padding: 1.6rem; display: grid; gap: 1.6rem;">
|
|
318
|
+
<ExpandingPanel v-bind="args" name="stress-a">
|
|
319
|
+
<template #summary><span>{{ longUnbroken }}</span></template>
|
|
320
|
+
<template #content><p style="margin: 0; padding-block: 0.8rem;">{{ longUrl }}</p></template>
|
|
321
|
+
</ExpandingPanel>
|
|
322
|
+
<ExpandingPanel v-bind="args" name="stress-a">
|
|
323
|
+
<template #summary><span>{{ longCopy.slice(0, 220) }}</span></template>
|
|
324
|
+
<template #content><p style="margin: 0; padding-block: 0.8rem;">{{ longCopy }}</p></template>
|
|
325
|
+
</ExpandingPanel>
|
|
326
|
+
<ExpandingPanel v-bind="args">
|
|
327
|
+
<template #summary><span>🎉 Emoji first, then <b>html</b> as text</span></template>
|
|
328
|
+
<template #content><p style="margin: 0; padding-block: 0.8rem;"><script>alert(1)</script></p></template>
|
|
329
|
+
</ExpandingPanel>
|
|
330
|
+
<ExpandingPanel v-bind="args" dir="rtl">
|
|
331
|
+
<template #summary><span>سؤال طويل من اليمين إلى اليسار للتحقق من الاتجاه</span></template>
|
|
332
|
+
<template #content><p style="margin: 0; padding-block: 0.8rem;">إجابة قصيرة.</p></template>
|
|
333
|
+
</ExpandingPanel>
|
|
334
|
+
<ExpandingPanel v-bind="args">
|
|
335
|
+
<template #summary><span>A</span></template>
|
|
336
|
+
</ExpandingPanel>
|
|
337
|
+
<ExpandingPanel v-bind="args">
|
|
338
|
+
<template #content><p style="margin: 0; padding-block: 0.8rem;">No summary slot: only the icon is clickable.</p></template>
|
|
339
|
+
</ExpandingPanel>
|
|
340
|
+
</div>
|
|
341
|
+
`,
|
|
342
|
+
}),
|
|
343
|
+
decorators: [
|
|
344
|
+
(story, context) => ({
|
|
345
|
+
components: { story, CanvasSwitcher },
|
|
346
|
+
setup() {
|
|
347
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
|
|
348
|
+
return { canvasName };
|
|
349
|
+
},
|
|
350
|
+
template: `
|
|
351
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
352
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
353
|
+
</div>
|
|
354
|
+
<div :class="canvasName" style="margin-inline: auto; outline: 1px dashed currentColor;">
|
|
355
|
+
<story />
|
|
356
|
+
</div>
|
|
357
|
+
`,
|
|
358
|
+
}),
|
|
359
|
+
],
|
|
360
|
+
parameters: {
|
|
361
|
+
initialCanvas: "mobileCanvas",
|
|
362
|
+
docs: {
|
|
363
|
+
story: { inline: false, height: "640px" },
|
|
364
|
+
description: {
|
|
365
|
+
story:
|
|
366
|
+
"Open each panel at every canvas width. The long unbroken summary wraps and the caret stays visible (until 2026-10-20 it pushed the " +
|
|
367
|
+
"caret out of the clipped summary); long URLs and copy wrap in the content; the first two panels share a name, so opening one closes " +
|
|
368
|
+
"the other, and each keeps its own ids; emoji and HTML-like text render as text; the RTL panel puts the caret on the left; the " +
|
|
369
|
+
"single-letter, no-content and no-summary panels keep their shape.",
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { nextTick } from "vue";
|
|
2
|
+
import { nextTick, defineComponent } from "vue";
|
|
3
3
|
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
4
4
|
import type { ComponentPublicInstance } from "vue";
|
|
5
5
|
import ExpandingPanel from "../ExpandingPanel.vue";
|
|
@@ -156,18 +156,11 @@ describe("ExpandingPanel", () => {
|
|
|
156
156
|
|
|
157
157
|
// ─── ARIA & IDs ───────────────────────────────────────────────────────────
|
|
158
158
|
|
|
159
|
-
it("
|
|
159
|
+
it("points the summary's aria-controls at the content region", async () => {
|
|
160
160
|
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
161
161
|
props: { name: "aria-panel" },
|
|
162
162
|
});
|
|
163
|
-
expect(wrapper.find("summary").attributes("
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("sets aria-controls on summary to id-{name}-content", async () => {
|
|
167
|
-
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
168
|
-
props: { name: "aria-panel" },
|
|
169
|
-
});
|
|
170
|
-
expect(wrapper.find("summary").attributes("aria-controls")).toBe("id-aria-panel-content");
|
|
163
|
+
expect(wrapper.find("summary").attributes("aria-controls")).toBe(wrapper.find(".expanding-panel-content").attributes("id"));
|
|
171
164
|
});
|
|
172
165
|
|
|
173
166
|
it("sets aria-expanded to false when closed", async () => {
|
|
@@ -185,20 +178,11 @@ describe("ExpandingPanel", () => {
|
|
|
185
178
|
expect(wrapper.find("summary").attributes("aria-expanded")).toBe("true");
|
|
186
179
|
});
|
|
187
180
|
|
|
188
|
-
it("
|
|
181
|
+
it("labels the content region by its summary", async () => {
|
|
189
182
|
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
190
183
|
props: { name: "aria-panel" },
|
|
191
184
|
});
|
|
192
|
-
expect(wrapper.find(".expanding-panel-content").attributes("
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it("sets aria-labelledby on content region to id-{name}-trigger", async () => {
|
|
196
|
-
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
197
|
-
props: { name: "aria-panel" },
|
|
198
|
-
});
|
|
199
|
-
expect(wrapper.find(".expanding-panel-content").attributes("aria-labelledby")).toBe(
|
|
200
|
-
"id-aria-panel-trigger",
|
|
201
|
-
);
|
|
185
|
+
expect(wrapper.find(".expanding-panel-content").attributes("aria-labelledby")).toBe(wrapper.find("summary").attributes("id"));
|
|
202
186
|
});
|
|
203
187
|
|
|
204
188
|
it("sets role='region' on the content div", async () => {
|
|
@@ -482,33 +466,43 @@ describe("ExpandingPanel", () => {
|
|
|
482
466
|
|
|
483
467
|
// ─── name prop / useId fallback ───────────────────────────────────────────
|
|
484
468
|
|
|
485
|
-
it("
|
|
469
|
+
it("builds ids from useId, not from the name", async () => {
|
|
486
470
|
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
487
471
|
props: { name: "my-panel" },
|
|
488
472
|
});
|
|
489
|
-
expect(wrapper.find("summary").attributes("id")).
|
|
490
|
-
expect(wrapper.find(".expanding-panel-content").attributes("id")).
|
|
473
|
+
expect(wrapper.find("summary").attributes("id")).not.toContain("my-panel");
|
|
474
|
+
expect(wrapper.find(".expanding-panel-content").attributes("id")).not.toContain("my-panel");
|
|
491
475
|
});
|
|
492
476
|
|
|
493
477
|
it("generates ARIA ids when no name prop is supplied", async () => {
|
|
494
478
|
const wrapper = await mountSuspended(ExpandingPanel);
|
|
495
479
|
const summaryId = wrapper.find("summary").attributes("id") ?? "";
|
|
496
480
|
const contentId = wrapper.find(".expanding-panel-content").attributes("id") ?? "";
|
|
497
|
-
expect(summaryId).toMatch(
|
|
498
|
-
expect(contentId).toMatch(
|
|
481
|
+
expect(summaryId).toMatch(/^.+-trigger$/);
|
|
482
|
+
expect(contentId).toMatch(/^.+-content$/);
|
|
499
483
|
});
|
|
500
484
|
|
|
501
|
-
it("updates the details name
|
|
485
|
+
it("updates the details name when the name prop changes after mount, keeping its ids", async () => {
|
|
502
486
|
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
503
487
|
props: { name: "panel-a" },
|
|
504
488
|
});
|
|
489
|
+
const summaryId = wrapper.find("summary").attributes("id");
|
|
505
490
|
expect(wrapper.find("details").attributes("name")).toBe("panel-a");
|
|
506
|
-
expect(wrapper.find("summary").attributes("id")).toBe("id-panel-a-trigger");
|
|
507
491
|
|
|
508
492
|
await wrapper.setProps({ name: "panel-b" });
|
|
509
493
|
|
|
510
494
|
expect(wrapper.find("details").attributes("name")).toBe("panel-b");
|
|
511
|
-
expect(wrapper.find("summary").attributes("id")).toBe(
|
|
512
|
-
|
|
495
|
+
expect(wrapper.find("summary").attributes("id")).toBe(summaryId);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("gives panels that share a name distinct ids", async () => {
|
|
499
|
+
const Host = defineComponent({
|
|
500
|
+
components: { ExpandingPanel },
|
|
501
|
+
template: `<div><ExpandingPanel name="shared"></ExpandingPanel><ExpandingPanel name="shared"></ExpandingPanel></div>`,
|
|
502
|
+
});
|
|
503
|
+
const wrapper = await mountSuspended(Host);
|
|
504
|
+
const ids = wrapper.findAll("[id]").map((el) => el.attributes("id"));
|
|
505
|
+
expect(ids).toHaveLength(4);
|
|
506
|
+
expect(new Set(ids).size).toBe(4);
|
|
513
507
|
});
|
|
514
508
|
});
|
|
@@ -3,11 +3,11 @@
|
|
|
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="
|
|
6
|
+
<summary id="v-0-1-trigger" class="expanding-panel-summary" aria-controls="v-0-1-content" aria-expanded="true">
|
|
7
7
|
<div class="label-wrapper"></div>
|
|
8
8
|
<div class="icon-wrapper icon-wrapper--hidden"><span class="iconify i-bi:caret-down-fill icon" aria-hidden="true"></span></div>
|
|
9
9
|
</summary>
|
|
10
|
-
<div id="
|
|
10
|
+
<div id="v-0-1-content" class="expanding-panel-content" aria-labelledby="v-0-1-trigger" role="region"></div>
|
|
11
11
|
</details>
|
|
12
12
|
</div>"
|
|
13
13
|
`;
|
|
@@ -15,11 +15,11 @@ exports[`ExpandingPanel > renders correct HTML structure with all props set 1`]
|
|
|
15
15
|
exports[`ExpandingPanel > renders correct HTML structure with contentIsOnTop true 1`] = `
|
|
16
16
|
"<div class="expanding-panel content-is-on-top">
|
|
17
17
|
<details class="expanding-panel-details" name="snap-content-on-top">
|
|
18
|
-
<summary id="
|
|
18
|
+
<summary id="v-0-1-trigger" class="expanding-panel-summary" aria-controls="v-0-1-content" aria-expanded="false">
|
|
19
19
|
<div class="label-wrapper"></div>
|
|
20
20
|
<div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon" aria-hidden="true"></span></div>
|
|
21
21
|
</summary>
|
|
22
|
-
<div id="
|
|
22
|
+
<div id="v-0-1-content" class="expanding-panel-content" aria-labelledby="v-0-1-trigger" role="region"></div>
|
|
23
23
|
</details>
|
|
24
24
|
</div>"
|
|
25
25
|
`;
|
|
@@ -27,11 +27,11 @@ exports[`ExpandingPanel > renders correct HTML structure with contentIsOnTop tru
|
|
|
27
27
|
exports[`ExpandingPanel > renders correct HTML structure with default props 1`] = `
|
|
28
28
|
"<div class="expanding-panel">
|
|
29
29
|
<details class="expanding-panel-details" name="snap-default">
|
|
30
|
-
<summary id="
|
|
30
|
+
<summary id="v-0-1-trigger" class="expanding-panel-summary" aria-controls="v-0-1-content" aria-expanded="false">
|
|
31
31
|
<div class="label-wrapper"></div>
|
|
32
32
|
<div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon" aria-hidden="true"></span></div>
|
|
33
33
|
</summary>
|
|
34
|
-
<div id="
|
|
34
|
+
<div id="v-0-1-content" class="expanding-panel-content" aria-labelledby="v-0-1-trigger" role="region"></div>
|
|
35
35
|
</details>
|
|
36
36
|
</div>"
|
|
37
37
|
`;
|
|
@@ -39,11 +39,11 @@ exports[`ExpandingPanel > renders correct HTML structure with default props 1`]
|
|
|
39
39
|
exports[`ExpandingPanel > renders correct HTML structure with populated slots 1`] = `
|
|
40
40
|
"<div class="expanding-panel">
|
|
41
41
|
<details class="expanding-panel-details" name="snap-slots">
|
|
42
|
-
<summary id="
|
|
42
|
+
<summary id="v-0-1-trigger" class="expanding-panel-summary" aria-controls="v-0-1-content" aria-expanded="false">
|
|
43
43
|
<div class="label-wrapper"><span>Summary text</span></div>
|
|
44
44
|
<div class="icon-wrapper"><span>▸</span></div>
|
|
45
45
|
</summary>
|
|
46
|
-
<div id="
|
|
46
|
+
<div id="v-0-1-content" class="expanding-panel-content" aria-labelledby="v-0-1-trigger" role="region">
|
|
47
47
|
<p>Content text</p>
|
|
48
48
|
</div>
|
|
49
49
|
</details>
|
|
@@ -93,6 +93,7 @@ visual style, pass a modifier class:
|
|
|
93
93
|
|
|
94
94
|
## Notes
|
|
95
95
|
|
|
96
|
+
- Long unbroken summaries wrap (`overflow-wrap: anywhere` on the root, summary track `minmax(0, 1fr) auto`) rather than pushing the icon out of the clipped summary. Changed 2026-10-20.
|
|
96
97
|
- `--expanding-panel-classic-content-gap` and `--expanding-panel-classic-content-z-index` only take effect when
|
|
97
98
|
`contentIsOnTop` is `true` — they're no-ops for the default (in-flow) layout.
|
|
98
99
|
- Never set `background-color`, `padding`, `border`, or a shadow directly on `.inner`. `.inner`
|
package/app/components/02.molecules/expandable/expanding-panel-classic/ExpandingPanelClassic.vue
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
<div ref="rootEl" class="expanding-panel-classic" :class="[elementClasses, { 'content-is-on-top': contentIsOnTop }]">
|
|
3
3
|
<details class="expanding-panel-classic-details" :name :open @toggle="onDetailsToggle">
|
|
4
4
|
<summary
|
|
5
|
-
:id="
|
|
5
|
+
:id="`${panelId}-trigger`"
|
|
6
6
|
class="expanding-panel-classic-summary"
|
|
7
|
-
:aria-controls="
|
|
7
|
+
:aria-controls="`${panelId}-content`"
|
|
8
8
|
:aria-expanded="open"
|
|
9
9
|
@click.prevent.stop="handleToggle"
|
|
10
10
|
>
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
</summary>
|
|
20
20
|
</details>
|
|
21
21
|
<div
|
|
22
|
-
:id="
|
|
22
|
+
:id="`${panelId}-content`"
|
|
23
23
|
class="expanding-panel-classic-content"
|
|
24
|
-
:aria-labelledby="
|
|
24
|
+
:aria-labelledby="`${panelId}-trigger`"
|
|
25
25
|
role="region"
|
|
26
26
|
>
|
|
27
27
|
<div class="inner">
|
|
@@ -51,6 +51,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
const fallbackName = useId();
|
|
54
|
+
const panelId = useId();
|
|
54
55
|
const name = computed(() => props.name || fallbackName);
|
|
55
56
|
const isPanelOpen = defineModel<boolean>({ default: false });
|
|
56
57
|
const animationDurationStr = computed(() => `${props.animationDuration}ms`);
|
|
@@ -98,6 +99,8 @@ const onDetailsToggle = (event: Event) => {
|
|
|
98
99
|
<style lang="css">
|
|
99
100
|
@layer components {
|
|
100
101
|
.expanding-panel-classic {
|
|
102
|
+
overflow-wrap: anywhere;
|
|
103
|
+
|
|
101
104
|
.expanding-panel-classic-details {
|
|
102
105
|
&:hover {
|
|
103
106
|
cursor: pointer;
|
|
@@ -105,7 +108,7 @@ const onDetailsToggle = (event: Event) => {
|
|
|
105
108
|
.expanding-panel-classic-summary {
|
|
106
109
|
display: grid;
|
|
107
110
|
align-items: center;
|
|
108
|
-
grid-template-columns: 1fr auto;
|
|
111
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
109
112
|
gap: var(--expanding-panel-classic-summary-gap, 1rem);
|
|
110
113
|
list-style: none;
|
|
111
114
|
user-select: none;
|
|
@@ -121,6 +124,8 @@ const onDetailsToggle = (event: Event) => {
|
|
|
121
124
|
|
|
122
125
|
.label-wrapper {
|
|
123
126
|
display: inline-block;
|
|
127
|
+
min-inline-size: 0;
|
|
128
|
+
overflow-wrap: anywhere;
|
|
124
129
|
}
|
|
125
130
|
/* inline-size is reserved explicitly — the grid's align-items:center
|
|
126
131
|
doesn't stretch this item to row height, so with only aspect-ratio:1
|
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
import ExpandingPanelClassic from "../ExpandingPanelClassic.vue";
|
|
2
2
|
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
import { ref } from "vue";
|
|
4
|
+
import CanvasSwitcher from "../../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
|
|
5
|
+
import type { MediaCanvas } from "~/types/components";
|
|
3
6
|
|
|
4
7
|
const meta: Meta<typeof ExpandingPanelClassic> = {
|
|
5
8
|
title: "Molecules/Expandable/Expanding Panel Classic",
|
|
6
9
|
component: ExpandingPanelClassic,
|
|
10
|
+
parameters: {
|
|
11
|
+
docs: {
|
|
12
|
+
description: {
|
|
13
|
+
component:
|
|
14
|
+
"A single expand/collapse panel on native <details>/<summary>. To render a set of these from slots, optionally exclusive-open, use " +
|
|
15
|
+
"[AccordionGroup](?path=/story/molecules-expandable-accordiongroup--exclusive-open), which renders this panel for every item when `variant=\"classic\"`. " +
|
|
16
|
+
"[AccordionChat](?path=/story/molecules-expandable-accordionchat--default) restyles it as a chat: the summary becomes a question bubble and the content an answer bubble.",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
7
20
|
argTypes: {
|
|
8
21
|
name: {
|
|
9
22
|
control: { type: "text" },
|
|
10
|
-
description: "
|
|
23
|
+
description: "The <details> name: panels sharing it form an exclusive-open group. Element ids come from useId(), not from this.",
|
|
11
24
|
},
|
|
12
25
|
animationDuration: {
|
|
13
26
|
control: { type: "number", min: 0, step: 50 },
|
|
@@ -291,3 +304,72 @@ export const WithStyleClassPassthrough: Story = {
|
|
|
291
304
|
`,
|
|
292
305
|
}),
|
|
293
306
|
};
|
|
307
|
+
|
|
308
|
+
export const StressTest: Story = {
|
|
309
|
+
name: "Stress Test (Worst-Case Data)",
|
|
310
|
+
render: (args) => ({
|
|
311
|
+
components: { ExpandingPanelClassic },
|
|
312
|
+
setup() {
|
|
313
|
+
const longUnbroken = "Donaudampfschifffahrtselektrizitätenhauptbetriebswerkbauunterbeamtengesellschaft";
|
|
314
|
+
const longUrl = "https://example.com/" + "a-very-long-path-segment-without-spaces".repeat(4);
|
|
315
|
+
const longCopy = "Unsere Behandlungen werden individuell auf Ihre Bedürfnisse abgestimmt, damit Sie sich rundum wohlfühlen. ".repeat(6);
|
|
316
|
+
return { args, longUnbroken, longUrl, longCopy };
|
|
317
|
+
},
|
|
318
|
+
template: `
|
|
319
|
+
<div style="padding: 1.6rem; display: grid; gap: 1.6rem;">
|
|
320
|
+
<ExpandingPanelClassic v-bind="args" name="stress-a">
|
|
321
|
+
<template #summary><span>{{ longUnbroken }}</span></template>
|
|
322
|
+
<template #content><p style="margin: 0; padding-block: 0.8rem;">{{ longUrl }}</p></template>
|
|
323
|
+
</ExpandingPanelClassic>
|
|
324
|
+
<ExpandingPanelClassic v-bind="args" name="stress-a">
|
|
325
|
+
<template #summary><span>{{ longCopy.slice(0, 220) }}</span></template>
|
|
326
|
+
<template #content><p style="margin: 0; padding-block: 0.8rem;">{{ longCopy }}</p></template>
|
|
327
|
+
</ExpandingPanelClassic>
|
|
328
|
+
<ExpandingPanelClassic v-bind="args">
|
|
329
|
+
<template #summary><span>🎉 Emoji first, then <b>html</b> as text</span></template>
|
|
330
|
+
<template #content><p style="margin: 0; padding-block: 0.8rem;"><script>alert(1)</script></p></template>
|
|
331
|
+
</ExpandingPanelClassic>
|
|
332
|
+
<ExpandingPanelClassic v-bind="args" dir="rtl">
|
|
333
|
+
<template #summary><span>سؤال طويل من اليمين إلى اليسار للتحقق من الاتجاه</span></template>
|
|
334
|
+
<template #content><p style="margin: 0; padding-block: 0.8rem;">إجابة قصيرة.</p></template>
|
|
335
|
+
</ExpandingPanelClassic>
|
|
336
|
+
<ExpandingPanelClassic v-bind="args">
|
|
337
|
+
<template #summary><span>A</span></template>
|
|
338
|
+
</ExpandingPanelClassic>
|
|
339
|
+
<ExpandingPanelClassic v-bind="args">
|
|
340
|
+
<template #content><p style="margin: 0; padding-block: 0.8rem;">No summary slot: only the icon is clickable.</p></template>
|
|
341
|
+
</ExpandingPanelClassic>
|
|
342
|
+
</div>
|
|
343
|
+
`,
|
|
344
|
+
}),
|
|
345
|
+
decorators: [
|
|
346
|
+
(story, context) => ({
|
|
347
|
+
components: { story, CanvasSwitcher },
|
|
348
|
+
setup() {
|
|
349
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
|
|
350
|
+
return { canvasName };
|
|
351
|
+
},
|
|
352
|
+
template: `
|
|
353
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
354
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
355
|
+
</div>
|
|
356
|
+
<div :class="canvasName" style="margin-inline: auto; outline: 1px dashed currentColor;">
|
|
357
|
+
<story />
|
|
358
|
+
</div>
|
|
359
|
+
`,
|
|
360
|
+
}),
|
|
361
|
+
],
|
|
362
|
+
parameters: {
|
|
363
|
+
initialCanvas: "mobileCanvas",
|
|
364
|
+
docs: {
|
|
365
|
+
story: { inline: false, height: "640px" },
|
|
366
|
+
description: {
|
|
367
|
+
story:
|
|
368
|
+
"Open each panel at every canvas width. The long unbroken summary wraps and the caret stays visible (until 2026-10-20 it pushed the " +
|
|
369
|
+
"caret out of the clipped summary); long URLs and copy wrap in the content; the first two panels share a name, so opening one closes " +
|
|
370
|
+
"the other, and each keeps its own ids; emoji and HTML-like text render as text; the RTL panel puts the caret on the left; the " +
|
|
371
|
+
"single-letter, no-content and no-summary panels keep their shape.",
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { nextTick } from "vue";
|
|
2
|
+
import { nextTick, defineComponent } from "vue";
|
|
3
3
|
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
4
4
|
import type { ComponentPublicInstance } from "vue";
|
|
5
5
|
import ExpandingPanelClassic from "../ExpandingPanelClassic.vue";
|
|
@@ -156,18 +156,11 @@ describe("ExpandingPanelClassic", () => {
|
|
|
156
156
|
|
|
157
157
|
// ─── ARIA & IDs ───────────────────────────────────────────────────────────
|
|
158
158
|
|
|
159
|
-
it("
|
|
159
|
+
it("points the summary's aria-controls at the content region", async () => {
|
|
160
160
|
const wrapper = await mountSuspended(ExpandingPanelClassic, {
|
|
161
161
|
props: { name: "aria-panel" },
|
|
162
162
|
});
|
|
163
|
-
expect(wrapper.find("summary").attributes("
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("sets aria-controls on summary to id-{name}-content", async () => {
|
|
167
|
-
const wrapper = await mountSuspended(ExpandingPanelClassic, {
|
|
168
|
-
props: { name: "aria-panel" },
|
|
169
|
-
});
|
|
170
|
-
expect(wrapper.find("summary").attributes("aria-controls")).toBe("id-aria-panel-content");
|
|
163
|
+
expect(wrapper.find("summary").attributes("aria-controls")).toBe(wrapper.find(".expanding-panel-classic-content").attributes("id"));
|
|
171
164
|
});
|
|
172
165
|
|
|
173
166
|
it("sets aria-expanded to false when closed", async () => {
|
|
@@ -185,20 +178,11 @@ describe("ExpandingPanelClassic", () => {
|
|
|
185
178
|
expect(wrapper.find("summary").attributes("aria-expanded")).toBe("true");
|
|
186
179
|
});
|
|
187
180
|
|
|
188
|
-
it("
|
|
181
|
+
it("labels the content region by its summary", async () => {
|
|
189
182
|
const wrapper = await mountSuspended(ExpandingPanelClassic, {
|
|
190
183
|
props: { name: "aria-panel" },
|
|
191
184
|
});
|
|
192
|
-
expect(wrapper.find(".expanding-panel-classic-content").attributes("
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it("sets aria-labelledby on content region to id-{name}-trigger", async () => {
|
|
196
|
-
const wrapper = await mountSuspended(ExpandingPanelClassic, {
|
|
197
|
-
props: { name: "aria-panel" },
|
|
198
|
-
});
|
|
199
|
-
expect(wrapper.find(".expanding-panel-classic-content").attributes("aria-labelledby")).toBe(
|
|
200
|
-
"id-aria-panel-trigger",
|
|
201
|
-
);
|
|
185
|
+
expect(wrapper.find(".expanding-panel-classic-content").attributes("aria-labelledby")).toBe(wrapper.find("summary").attributes("id"));
|
|
202
186
|
});
|
|
203
187
|
|
|
204
188
|
it("sets role='region' on the content div", async () => {
|
|
@@ -482,33 +466,43 @@ describe("ExpandingPanelClassic", () => {
|
|
|
482
466
|
|
|
483
467
|
// ─── name prop / useId fallback ───────────────────────────────────────────
|
|
484
468
|
|
|
485
|
-
it("
|
|
469
|
+
it("builds ids from useId, not from the name", async () => {
|
|
486
470
|
const wrapper = await mountSuspended(ExpandingPanelClassic, {
|
|
487
471
|
props: { name: "my-panel" },
|
|
488
472
|
});
|
|
489
|
-
expect(wrapper.find("summary").attributes("id")).
|
|
490
|
-
expect(wrapper.find(".expanding-panel-classic-content").attributes("id")).
|
|
473
|
+
expect(wrapper.find("summary").attributes("id")).not.toContain("my-panel");
|
|
474
|
+
expect(wrapper.find(".expanding-panel-classic-content").attributes("id")).not.toContain("my-panel");
|
|
491
475
|
});
|
|
492
476
|
|
|
493
477
|
it("generates ARIA ids when no name prop is supplied", async () => {
|
|
494
478
|
const wrapper = await mountSuspended(ExpandingPanelClassic);
|
|
495
479
|
const summaryId = wrapper.find("summary").attributes("id") ?? "";
|
|
496
480
|
const contentId = wrapper.find(".expanding-panel-classic-content").attributes("id") ?? "";
|
|
497
|
-
expect(summaryId).toMatch(
|
|
498
|
-
expect(contentId).toMatch(
|
|
481
|
+
expect(summaryId).toMatch(/^.+-trigger$/);
|
|
482
|
+
expect(contentId).toMatch(/^.+-content$/);
|
|
499
483
|
});
|
|
500
484
|
|
|
501
|
-
it("updates the details name
|
|
485
|
+
it("updates the details name when the name prop changes after mount, keeping its ids", async () => {
|
|
502
486
|
const wrapper = await mountSuspended(ExpandingPanelClassic, {
|
|
503
487
|
props: { name: "panel-a" },
|
|
504
488
|
});
|
|
489
|
+
const summaryId = wrapper.find("summary").attributes("id");
|
|
505
490
|
expect(wrapper.find("details").attributes("name")).toBe("panel-a");
|
|
506
|
-
expect(wrapper.find("summary").attributes("id")).toBe("id-panel-a-trigger");
|
|
507
491
|
|
|
508
492
|
await wrapper.setProps({ name: "panel-b" });
|
|
509
493
|
|
|
510
494
|
expect(wrapper.find("details").attributes("name")).toBe("panel-b");
|
|
511
|
-
expect(wrapper.find("summary").attributes("id")).toBe(
|
|
512
|
-
|
|
495
|
+
expect(wrapper.find("summary").attributes("id")).toBe(summaryId);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("gives panels that share a name distinct ids", async () => {
|
|
499
|
+
const Host = defineComponent({
|
|
500
|
+
components: { ExpandingPanelClassic },
|
|
501
|
+
template: `<div><ExpandingPanelClassic name="shared"></ExpandingPanelClassic><ExpandingPanelClassic name="shared"></ExpandingPanelClassic></div>`,
|
|
502
|
+
});
|
|
503
|
+
const wrapper = await mountSuspended(Host);
|
|
504
|
+
const ids = wrapper.findAll("[id]").map((el) => el.attributes("id"));
|
|
505
|
+
expect(ids).toHaveLength(4);
|
|
506
|
+
expect(new Set(ids).size).toBe(4);
|
|
513
507
|
});
|
|
514
508
|
});
|