srcdev-nuxt-components 9.6.17 → 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.
Files changed (59) hide show
  1. package/.claude/rules/consumer/css-relative-colour-calc.md +15 -0
  2. package/.claude/rules/consumer/vue-dynamic-arg-no-spaces.md +16 -0
  3. package/.claude/skills/component-compliance-checklist.md +6 -1
  4. package/.claude/skills/component-dynamic-slots.md +2 -2
  5. package/.claude/skills/component-naming.md +1 -1
  6. package/.claude/skills/components/accordion-chat.md +99 -0
  7. package/.claude/skills/components/accordion-group.md +98 -0
  8. package/.claude/skills/components/action-menu.md +11 -5
  9. package/.claude/skills/components/expanding-panel-classic.md +9 -7
  10. package/.claude/skills/components/expanding-panel.md +9 -8
  11. package/.claude/skills/components/responsive-header.md +1 -1
  12. package/.claude/skills/components/sparkle-effect.md +79 -0
  13. package/.claude/skills/index.md +4 -2
  14. package/.claude/skills/storybook-add-story.md +8 -0
  15. package/.claude/skills/using-component-skills.md +1 -1
  16. package/.vscode/srcdev-component-accordion-chat.code-snippets +42 -0
  17. package/.vscode/srcdev-component-accordion-group.code-snippets +38 -0
  18. package/.vscode/srcdev-component-action-menu.code-snippets +3 -3
  19. package/.vscode/srcdev-component-sparkle-effect.code-snippets +33 -0
  20. package/app/components/01.atoms/animations/sparkle-effect/CONSUMER-STYLING.md +75 -0
  21. package/app/components/01.atoms/animations/sparkle-effect/SparkleEffect.vue +213 -0
  22. package/app/components/01.atoms/animations/sparkle-effect/stories/SparkleEffect.stories.ts +303 -0
  23. package/app/components/01.atoms/animations/sparkle-effect/tests/SparkleEffect.spec.ts +150 -0
  24. package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +1 -1
  25. package/app/components/02.molecules/action-menu/ActionMenu.vue +6 -2
  26. package/app/components/02.molecules/action-menu/ActionMenuItem.vue +6 -3
  27. package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +11 -4
  28. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +130 -18
  29. package/app/components/02.molecules/expandable/accordion-chat/AccordionChat.vue +227 -0
  30. package/app/components/02.molecules/expandable/accordion-chat/CONSUMER-STYLING.md +150 -0
  31. package/app/components/02.molecules/expandable/accordion-chat/stories/AccordionChat.stories.ts +347 -0
  32. package/app/components/02.molecules/expandable/accordion-chat/tests/AccordionChat.spec.ts +205 -0
  33. package/app/components/02.molecules/expandable/accordion-chat/tests/__snapshots__/AccordionChat.spec.ts.snap +40 -0
  34. package/app/components/02.molecules/expandable/{accordian/AccordianCore.vue → accordion-group/AccordionGroup.vue} +32 -20
  35. package/app/components/02.molecules/expandable/accordion-group/CONSUMER-STYLING.md +66 -0
  36. package/app/components/02.molecules/expandable/accordion-group/stories/AccordionGroup.stories.ts +238 -0
  37. package/app/components/02.molecules/expandable/accordion-group/tests/AccordionGroup.spec.ts +172 -0
  38. package/app/components/02.molecules/expandable/accordion-group/tests/__snapshots__/AccordionGroup.spec.ts.snap +45 -0
  39. package/app/components/02.molecules/expandable/expanding-panel/CONSUMER-STYLING.md +1 -0
  40. package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +11 -6
  41. package/app/components/02.molecules/expandable/expanding-panel/stories/ExpandingPanel.stories.ts +82 -1
  42. package/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts +24 -30
  43. package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +8 -8
  44. package/app/components/02.molecules/expandable/expanding-panel-classic/CONSUMER-STYLING.md +1 -0
  45. package/app/components/02.molecules/expandable/expanding-panel-classic/ExpandingPanelClassic.vue +10 -5
  46. package/app/components/02.molecules/expandable/expanding-panel-classic/stories/ExpandingPanelClassic.stories.ts +83 -1
  47. package/app/components/02.molecules/expandable/expanding-panel-classic/tests/ExpandingPanelClassic.spec.ts +24 -30
  48. package/app/components/02.molecules/expandable/expanding-panel-classic/tests/__snapshots__/ExpandingPanelClassic.spec.ts.snap +8 -8
  49. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +6 -2
  50. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +21 -0
  51. package/app/components/03.organisms/responsive-header/tests/__snapshots__/NavigationItems.spec.ts.snap +2 -2
  52. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +2 -2
  53. package/package.json +1 -1
  54. package/.claude/skills/components/accordian-core.md +0 -180
  55. package/.vscode/srcdev-component-accordian-core.code-snippets +0 -74
  56. package/app/components/02.molecules/expandable/accordian/CONSUMER-STYLING.md +0 -100
  57. package/app/components/02.molecules/expandable/accordian/stories/AccordianCore.stories.ts +0 -272
  58. package/app/components/02.molecules/expandable/accordian/tests/AccordianCore.spec.ts +0 -257
  59. 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="`id-${name}-trigger`"
5
+ :id="`${panelId}-trigger`"
6
6
  class="expanding-panel-summary"
7
- :aria-controls="`id-${name}-content`"
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="`id-${name}-content`"
21
+ :id="`${panelId}-content`"
22
22
  class="expanding-panel-content"
23
- :aria-labelledby="`id-${name}-trigger`"
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 AccordianCore) for anything that needs to work correctly on real Apple devices today.
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
@@ -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: "Unique name used for ARIA attributes and to group panels in a native <details> accordion",
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 &lt;b&gt;html&lt;/b&gt; as text</span></template>
328
+ <template #content><p style="margin: 0; padding-block: 0.8rem;">&lt;script&gt;alert(1)&lt;/script&gt;</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("sets id on summary to id-{name}-trigger", async () => {
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("id")).toBe("id-aria-panel-trigger");
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("sets id on content region to id-{name}-content", async () => {
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("id")).toBe("id-aria-panel-content");
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("uses the provided name in ARIA attributes", async () => {
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")).toBe("id-my-panel-trigger");
490
- expect(wrapper.find(".expanding-panel-content").attributes("id")).toBe("id-my-panel-content");
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(/^id-.+-trigger$/);
498
- expect(contentId).toMatch(/^id-.+-content$/);
481
+ expect(summaryId).toMatch(/^.+-trigger$/);
482
+ expect(contentId).toMatch(/^.+-content$/);
499
483
  });
500
484
 
501
- it("updates the details name and ARIA ids when the name prop changes after mount", async () => {
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("id-panel-b-trigger");
512
- expect(wrapper.find(".expanding-panel-content").attributes("id")).toBe("id-panel-b-content");
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="id-snap-all-trigger" class="expanding-panel-summary" aria-controls="id-snap-all-content" aria-expanded="true">
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="id-snap-all-content" class="expanding-panel-content" aria-labelledby="id-snap-all-trigger" role="region"></div>
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="id-snap-content-on-top-trigger" class="expanding-panel-summary" aria-controls="id-snap-content-on-top-content" aria-expanded="false">
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="id-snap-content-on-top-content" class="expanding-panel-content" aria-labelledby="id-snap-content-on-top-trigger" role="region"></div>
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="id-snap-default-trigger" class="expanding-panel-summary" aria-controls="id-snap-default-content" aria-expanded="false">
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="id-snap-default-content" class="expanding-panel-content" aria-labelledby="id-snap-default-trigger" role="region"></div>
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="id-snap-slots-trigger" class="expanding-panel-summary" aria-controls="id-snap-slots-content" aria-expanded="false">
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="id-snap-slots-content" class="expanding-panel-content" aria-labelledby="id-snap-slots-trigger" role="region">
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`
@@ -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="`id-${name}-trigger`"
5
+ :id="`${panelId}-trigger`"
6
6
  class="expanding-panel-classic-summary"
7
- :aria-controls="`id-${name}-content`"
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="`id-${name}-content`"
22
+ :id="`${panelId}-content`"
23
23
  class="expanding-panel-classic-content"
24
- :aria-labelledby="`id-${name}-trigger`"
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: "Unique name used for ARIA attributes and to group panels in a native <details> accordion",
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 &lt;b&gt;html&lt;/b&gt; as text</span></template>
330
+ <template #content><p style="margin: 0; padding-block: 0.8rem;">&lt;script&gt;alert(1)&lt;/script&gt;</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("sets id on summary to id-{name}-trigger", async () => {
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("id")).toBe("id-aria-panel-trigger");
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("sets id on content region to id-{name}-content", async () => {
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("id")).toBe("id-aria-panel-content");
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("uses the provided name in ARIA attributes", async () => {
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")).toBe("id-my-panel-trigger");
490
- expect(wrapper.find(".expanding-panel-classic-content").attributes("id")).toBe("id-my-panel-content");
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(/^id-.+-trigger$/);
498
- expect(contentId).toMatch(/^id-.+-content$/);
481
+ expect(summaryId).toMatch(/^.+-trigger$/);
482
+ expect(contentId).toMatch(/^.+-content$/);
499
483
  });
500
484
 
501
- it("updates the details name and ARIA ids when the name prop changes after mount", async () => {
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("id-panel-b-trigger");
512
- expect(wrapper.find(".expanding-panel-classic-content").attributes("id")).toBe("id-panel-b-content");
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
  });