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
@@ -0,0 +1,150 @@
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import SparkleEffect from "../SparkleEffect.vue";
4
+
5
+ type Wrapper = Awaited<ReturnType<typeof mountSuspended<typeof SparkleEffect>>>;
6
+
7
+ const sparkStyle = (el: Element) => (el as HTMLElement).style;
8
+
9
+ describe("SparkleEffect", () => {
10
+ let wrapper: Wrapper | undefined;
11
+
12
+ afterEach(() => {
13
+ wrapper?.unmount();
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ // ─── Mount and root ───────────────────────────────────────────────────────
18
+
19
+ it("mounts without error", async () => {
20
+ wrapper = await mountSuspended(SparkleEffect);
21
+ expect(wrapper!.vm).toBeTruthy();
22
+ });
23
+
24
+ it("renders as <span> by default with the sparkle-effect class", async () => {
25
+ wrapper = await mountSuspended(SparkleEffect);
26
+ expect(wrapper!.element.tagName).toBe("SPAN");
27
+ expect(wrapper!.classes()).toContain("sparkle-effect");
28
+ });
29
+
30
+ it("renders as <div> when tag='div'", async () => {
31
+ wrapper = await mountSuspended(SparkleEffect, { props: { tag: "div" } });
32
+ expect(wrapper!.element.tagName).toBe("DIV");
33
+ });
34
+
35
+ // ─── Slot ─────────────────────────────────────────────────────────────────
36
+
37
+ it("renders the slotted content unchanged", async () => {
38
+ wrapper = await mountSuspended(SparkleEffect, {
39
+ slots: { default: "<button type='button' class='slotted'>Whimsy</button>" },
40
+ });
41
+ const button = wrapper!.find(".slotted");
42
+ expect(button.exists()).toBe(true);
43
+ expect(button.text()).toBe("Whimsy");
44
+ expect(button.attributes("type")).toBe("button");
45
+ });
46
+
47
+ // ─── Sparks: count ────────────────────────────────────────────────────────
48
+
49
+ it("renders five sparks by default", async () => {
50
+ wrapper = await mountSuspended(SparkleEffect);
51
+ expect(wrapper!.findAll(".sparkle-effect-spark")).toHaveLength(5);
52
+ });
53
+
54
+ it.each([
55
+ [1, 1],
56
+ [7, 7],
57
+ [12, 12],
58
+ [99, 12],
59
+ [0, 1],
60
+ [-3, 1],
61
+ [2.5, 2],
62
+ [Number.NaN, 5],
63
+ [Number.POSITIVE_INFINITY, 5],
64
+ ])("renders count=%s as %s sparks", async (count, expected) => {
65
+ wrapper = await mountSuspended(SparkleEffect, { props: { count } });
66
+ expect(wrapper!.findAll(".sparkle-effect-spark")).toHaveLength(expected);
67
+ });
68
+
69
+ it("updates the spark count when the count prop changes", async () => {
70
+ wrapper = await mountSuspended(SparkleEffect, { props: { count: 3 } });
71
+ await wrapper!.setProps({ count: 8 });
72
+ expect(wrapper!.findAll(".sparkle-effect-spark")).toHaveLength(8);
73
+ });
74
+
75
+ // ─── Sparks: presets ──────────────────────────────────────────────────────
76
+
77
+ it("gives the first five sparks the tuned presets", async () => {
78
+ wrapper = await mountSuspended(SparkleEffect);
79
+ const sparks = wrapper!.findAll(".sparkle-effect-spark");
80
+ const offsets = sparks.map((s) => sparkStyle(s.element).getPropertyValue("--_sparkle-effect-spark-offset"));
81
+ const colours = sparks.map((s) => sparkStyle(s.element).getPropertyValue("--_sparkle-effect-spark-colour"));
82
+ expect(offsets).toEqual(["80%", "60%", "70%", "75%", "70%"]);
83
+ expect(colours[1]).toBe("oklch(from var(--_sparkle-effect-colour) 80% c calc(h + 50))");
84
+ expect(colours[2]).toBe("oklch(from var(--_sparkle-effect-colour) 70% c calc(h + 20))");
85
+ expect(colours[4]).toContain("calc(h + -200)");
86
+ expect(sparkStyle(sparks[3]!.element).getPropertyValue("--_sparkle-effect-spark-translate")).toBe("-2rem 5rem");
87
+ });
88
+
89
+ it("nudges hue and offset and mirrors drift for sparks past the fifth", async () => {
90
+ wrapper = await mountSuspended(SparkleEffect, { props: { count: 6 } });
91
+ const sixth = sparkStyle(wrapper!.findAll(".sparkle-effect-spark")[5]!.element);
92
+ expect(sixth.getPropertyValue("--_sparkle-effect-spark-colour")).toContain("calc(h + 35)");
93
+ expect(sixth.getPropertyValue("--_sparkle-effect-spark-offset")).toBe("93%");
94
+ expect(sixth.getPropertyValue("--_sparkle-effect-spark-translate")).toBe("0rem -3rem");
95
+ });
96
+
97
+ // ─── Radius ───────────────────────────────────────────────────────────────
98
+
99
+ it("sets --sparkle-effect-radius inline when radius is given", async () => {
100
+ wrapper = await mountSuspended(SparkleEffect, { props: { radius: "1.2rem" } });
101
+ expect((wrapper!.element as HTMLElement).style.getPropertyValue("--sparkle-effect-radius")).toBe("1.2rem");
102
+ });
103
+
104
+ it("leaves the radius token unset by default so an ancestor value applies", async () => {
105
+ wrapper = await mountSuspended(SparkleEffect);
106
+ expect((wrapper!.element as HTMLElement).style.getPropertyValue("--sparkle-effect-radius")).toBe("");
107
+ });
108
+
109
+ // ─── Continuous ───────────────────────────────────────────────────────────
110
+
111
+ it("is one-shot by default (no data-continuous)", async () => {
112
+ wrapper = await mountSuspended(SparkleEffect);
113
+ expect(wrapper!.attributes("data-continuous")).toBeUndefined();
114
+ });
115
+
116
+ it("sets data-continuous when continuous is on, and clears it when turned off", async () => {
117
+ wrapper = await mountSuspended(SparkleEffect, { props: { continuous: true } });
118
+ expect(wrapper!.attributes("data-continuous")).toBe("");
119
+ await wrapper!.setProps({ continuous: false });
120
+ expect(wrapper!.attributes("data-continuous")).toBeUndefined();
121
+ });
122
+
123
+ // ─── Accessibility ────────────────────────────────────────────────────────
124
+
125
+ it("hides the sparks from assistive tech and keeps them out of the tab order", async () => {
126
+ wrapper = await mountSuspended(SparkleEffect);
127
+ const sparks = wrapper!.find(".sparkle-effect-sparks");
128
+ expect(sparks.attributes("aria-hidden")).toBe("true");
129
+ expect(wrapper!.findAll("[tabindex]")).toHaveLength(0);
130
+ });
131
+
132
+ // ─── styleClassPassthrough ────────────────────────────────────────────────
133
+
134
+ it("applies styleClassPassthrough classes", async () => {
135
+ wrapper = await mountSuspended(SparkleEffect, {
136
+ props: { styleClassPassthrough: ["promo", "featured"] },
137
+ });
138
+ expect(wrapper!.classes()).toContain("promo");
139
+ expect(wrapper!.classes()).toContain("featured");
140
+ });
141
+
142
+ it("updates classes when styleClassPassthrough prop changes", async () => {
143
+ wrapper = await mountSuspended(SparkleEffect, {
144
+ props: { styleClassPassthrough: ["promo"] },
145
+ });
146
+ await wrapper!.setProps({ styleClassPassthrough: ["featured"] });
147
+ expect(wrapper!.classes()).not.toContain("promo");
148
+ expect(wrapper!.classes()).toContain("featured");
149
+ });
150
+ });
@@ -106,7 +106,7 @@ const props = withDefaults(defineProps<Props>(), {
106
106
  docsPageNavAriaLabel: "On this page",
107
107
  // Defaults to "classic" — the docs/admin nav toggle relies on contentIsOnTop on mobile, and
108
108
  // ExpandingPanel's ::details-content-based positioning silently fails on WebKit (Safari/iOS),
109
- // making the nav unreachable there. See AccordianCore's own variant prop and CLAUDE.md
109
+ // making the nav unreachable there. See AccordionGroup's own variant prop and CLAUDE.md
110
110
  // pitfall #19 for the full mechanism. "modern" is opt-in.
111
111
  panelVariant: "classic",
112
112
  styleClassPassthrough: () => [],
@@ -174,6 +174,7 @@ watch(
174
174
  background-color: var(--action-menu-popover-background, var(--slate-00));
175
175
  border-radius: var(--action-menu-popover-border-radius, 0.8rem);
176
176
  min-width: var(--action-menu-popover-min-width, 20rem);
177
+ max-inline-size: var(--action-menu-popover-max-width, min(32rem, calc(100vw - 3.2rem)));
177
178
  box-shadow: var(--action-menu-popover-shadow, 0 0.4rem 1.6rem rgba(0, 0, 0, 0.1));
178
179
  overflow: hidden;
179
180
 
@@ -181,7 +182,10 @@ watch(
181
182
  top: calc(anchor(bottom) + var(--action-menu-block-distance, 0.4rem));
182
183
  right: anchor(right);
183
184
  left: auto;
184
- position-try-fallbacks: flip-block;
185
+ position-try-fallbacks:
186
+ flip-block,
187
+ flip-inline,
188
+ flip-block flip-inline;
185
189
 
186
190
  opacity: 0;
187
191
  display: none;
@@ -221,7 +225,7 @@ watch(
221
225
 
222
226
  .action-menu-list {
223
227
  display: grid;
224
- grid-template-columns: auto 1fr auto;
228
+ grid-template-columns: auto minmax(0, 1fr) auto;
225
229
 
226
230
  list-style: none;
227
231
  padding: 0;
@@ -66,7 +66,7 @@ watch(
66
66
  box-sizing: border-box;
67
67
  cursor: pointer;
68
68
  display: grid;
69
- grid-template-columns: auto 1fr auto;
69
+ grid-template-columns: auto minmax(0, 1fr) auto;
70
70
  align-items: center;
71
71
 
72
72
  width: 100%;
@@ -105,9 +105,12 @@ watch(
105
105
 
106
106
  .action-menu-item-label {
107
107
  grid-column: 2;
108
- white-space: nowrap;
108
+ display: -webkit-box;
109
+ -webkit-box-orient: vertical;
109
110
  overflow: hidden;
110
- text-overflow: ellipsis;
111
+ overflow-wrap: anywhere;
112
+ -webkit-line-clamp: var(--action-menu-item-label-line-clamp, none);
113
+ line-clamp: var(--action-menu-item-label-line-clamp, none);
111
114
  }
112
115
 
113
116
  .action-menu-item-arrow {
@@ -29,6 +29,7 @@ All `--action-menu-*` tokens are the stable override surface. Action menus repea
29
29
  | `--action-menu-popover-border` | `0.1rem solid var(--slate-03)` | Menu panel border shorthand |
30
30
  | `--action-menu-popover-border-radius` | `0.8rem` | Menu panel corner rounding |
31
31
  | `--action-menu-popover-min-width` | `20rem` | Minimum menu width |
32
+ | `--action-menu-popover-max-width` | `min(32rem, calc(100vw - 3.2rem))` | Maximum menu width; longer labels wrap |
32
33
  | `--action-menu-popover-shadow` | `0 0.4rem 1.6rem rgba(0, 0, 0, 0.1)` | Menu panel drop shadow |
33
34
  | `--action-menu-popover-transition-duration` | `200ms` | Open/close fade duration |
34
35
  | `--action-menu-popover-z-index` | `999999` | Stacking order in browsers without CSS anchor positioning (see Positioning). Ignored where the popover renders in the top layer |
@@ -45,7 +46,13 @@ All `--action-menu-*` tokens are the stable override surface. Action menus repea
45
46
  | `--action-menu-item-gap` | `1.2rem` | Space between icon, label and arrow |
46
47
  | `--action-menu-item-padding-inline` | `1.6rem` | Row horizontal padding |
47
48
  | `--action-menu-item-padding-block` | `1.2rem` | Row vertical padding |
49
+ | `--action-menu-item-label-line-clamp` | `none` | Caps the label at this many lines with an ellipsis (`1` is single-line ellipsis, `none` shows it all) |
48
50
 
51
+ > Changed 2026-10-20: labels used to stay on one line and widen the menu without limit, so a long
52
+ > label pushed it past a phone screen. The menu now stops at `--action-menu-popover-max-width` and
53
+ > labels wrap; set `--action-menu-item-label-line-clamp: 1` for the old single-line look with an
54
+ > ellipsis. `--action-menu-popover-max-width` and `--action-menu-item-label-line-clamp` are new.
55
+ >
49
56
  > Changed 2026-09-27: item `light-dark()` defaults replaced by their light values (older iPad
50
57
  > Safari lacks `light-dark()`), matching the already-light panel. Icon sizes are applied as
51
58
  > `font-size`; the old `width`/`height` on the trigger and arrow icons was silently overridden by
@@ -79,8 +86,8 @@ Inner classes: `.action-menu-trigger`, `.action-menu-trigger-icon`, `.action-men
79
86
  ## Positioning
80
87
 
81
88
  The menu uses the Popover API plus CSS anchor positioning: it opens below the trigger,
82
- right-aligned, and flips above near the bottom of the viewport (`position-try-fallbacks:
83
- flip-block`).
89
+ right-aligned, and flips above near the bottom of the viewport and to open rightwards near the left
90
+ edge (`position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline`).
84
91
 
85
92
  Fallbacks, via the shared `useAnchoredPopover` composable:
86
93
 
@@ -148,5 +155,5 @@ doesn't rely on it for its defaults.
148
155
  ## Notes
149
156
 
150
157
  - Negative `--action-menu-block-distance` values make the menu overlap the trigger.
151
- - `--action-menu-popover-min-width` is a floor; long labels widen the menu. Set
152
- `width: max-content` on `.action-menu-popover` to change that.
158
+ - `--action-menu-popover-min-width` is a floor and `--action-menu-popover-max-width` a ceiling;
159
+ between them the menu sizes to its longest label, and labels wrap at the ceiling.
@@ -2,11 +2,14 @@ import { computed, ref } from "vue";
2
2
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
3
  import ActionMenu from "../ActionMenu.vue";
4
4
  import ActionMenuItem from "../ActionMenuItem.vue";
5
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
6
+ import type { MediaCanvas } from "~/types/components";
5
7
 
6
8
  // ─── Meta ─────────────────────────────────────────────────────────────────────
7
9
 
8
10
  interface StoryArgs {
9
11
  itemCount?: number;
12
+ labelLineClamp?: string;
10
13
  label?: string;
11
14
  triggerIcon?: string;
12
15
  styleClassPassthrough?: string | string[];
@@ -17,8 +20,8 @@ const meta: Meta<StoryArgs> = {
17
20
  component: ActionMenu,
18
21
  argTypes: {
19
22
  itemCount: {
20
- control: { type: "number", min: 0, max: 8, step: 1 },
21
- description: "Number of items to show (story control — not a component prop)",
23
+ control: { type: "number", min: 0, max: 5, step: 1 },
24
+ description: "**Story control, not a prop.** Number of items to show (0 to 5, the size of this data set).",
22
25
  table: { category: "Content" },
23
26
  },
24
27
  label: {
@@ -31,6 +34,13 @@ const meta: Meta<StoryArgs> = {
31
34
  description: "Icon name for the trigger button",
32
35
  table: { category: "Appearance" },
33
36
  },
37
+ labelLineClamp: {
38
+ control: { type: "select" },
39
+ options: ["none", "1", "2", "3"],
40
+ description:
41
+ "**Story control, not a prop.** Sets `--action-menu-item-label-line-clamp`. In your CSS: `.my-page { --action-menu-item-label-line-clamp: 1; }`",
42
+ table: { category: "CSS tokens (story only, set in your CSS)" },
43
+ },
34
44
  styleClassPassthrough: {
35
45
  table: { disable: true },
36
46
  },
@@ -38,6 +48,7 @@ const meta: Meta<StoryArgs> = {
38
48
  args: {
39
49
  itemCount: 5,
40
50
  label: "Open actions menu",
51
+ labelLineClamp: "none",
41
52
  triggerIcon: "lucide:ellipsis",
42
53
  },
43
54
  parameters: {
@@ -67,6 +78,17 @@ const actionItems = [
67
78
  { label: "Delete", icon: "lucide:trash-2", href: undefined },
68
79
  ] as const;
69
80
 
81
+ function useStorySetup(args: StoryArgs) {
82
+ const componentArgs = computed(() => {
83
+ const { itemCount: _itemCount, labelLineClamp: _labelLineClamp, ...rest } = args;
84
+ return rest;
85
+ });
86
+ const tokenStyle = computed(() => ({
87
+ "--action-menu-item-label-line-clamp": args.labelLineClamp || undefined,
88
+ }));
89
+ return { args, componentArgs, tokenStyle };
90
+ }
91
+
70
92
  // ─── Stories ──────────────────────────────────────────────────────────────────
71
93
 
72
94
  /**
@@ -81,13 +103,7 @@ export const Default: Story = {
81
103
  render: (args) => ({
82
104
  components: { ActionMenu, ActionMenuItem },
83
105
  setup() {
84
- // `args` is Storybook's own reactive object — read from it directly (`args.x`) inside
85
- // these computeds rather than destructuring it into local variables at setup-time, which
86
- // would snapshot the values once and stop reflecting later Controls-panel changes.
87
- const componentArgs = computed(() => {
88
- const { itemCount, ...rest } = args;
89
- return rest;
90
- });
106
+ const { componentArgs, tokenStyle } = useStorySetup(args);
91
107
  const lastAction = ref<string | null>(null);
92
108
  const items = computed(() =>
93
109
  actionItems.slice(0, args.itemCount ?? 5).map((item, i) => ({
@@ -95,11 +111,11 @@ export const Default: Story = {
95
111
  slotName: `item-${i}`,
96
112
  }))
97
113
  );
98
- return { componentArgs, items, lastAction };
114
+ return { componentArgs, tokenStyle, items, lastAction };
99
115
  },
100
116
  template: `
101
117
  <div style="padding: 4rem 8rem; display: flex; flex-direction: column; align-items: flex-end; gap: 2rem;">
102
- <ActionMenu v-bind="componentArgs">
118
+ <ActionMenu v-bind="componentArgs" :style="tokenStyle">
103
119
  <template v-for="item in items" :key="item.slotName" #[item.slotName]>
104
120
  <ActionMenuItem :label="item.label" @click="lastAction = item.label">
105
121
  <template #icon>
@@ -128,11 +144,11 @@ export const MixedItems: Story = {
128
144
  render: (args) => ({
129
145
  components: { ActionMenu, ActionMenuItem },
130
146
  setup() {
131
- return { args };
147
+ return useStorySetup(args);
132
148
  },
133
149
  template: `
134
150
  <div style="padding: 4rem 8rem; display: flex; justify-content: flex-end;">
135
- <ActionMenu v-bind="args">
151
+ <ActionMenu v-bind="componentArgs" :style="tokenStyle">
136
152
  <template #item-0>
137
153
  <ActionMenuItem label="Edit record" @click="() => {}">
138
154
  <template #icon><Icon name="lucide:pencil" /></template>
@@ -168,11 +184,11 @@ export const NoIcons: Story = {
168
184
  render: (args) => ({
169
185
  components: { ActionMenu, ActionMenuItem },
170
186
  setup() {
171
- return { args };
187
+ return useStorySetup(args);
172
188
  },
173
189
  template: `
174
190
  <div style="padding: 4rem 8rem; display: flex; justify-content: flex-end;">
175
- <ActionMenu v-bind="args">
191
+ <ActionMenu v-bind="componentArgs" :style="tokenStyle">
176
192
  <template #item-0>
177
193
  <ActionMenuItem label="Approve" @click="() => {}" />
178
194
  </template>
@@ -206,7 +222,7 @@ export const InContext: Story = {
206
222
  { name: "Sam Patel", email: "sam@example.com", role: "Editor" },
207
223
  { name: "Jordan Clarke", email: "jordan@example.com", role: "Viewer" },
208
224
  ];
209
- return { args, users };
225
+ return { ...useStorySetup(args), users };
210
226
  },
211
227
  template: `
212
228
  <div style="padding: 4rem; max-width: 56rem; margin: 0 auto;">
@@ -234,7 +250,7 @@ export const InContext: Story = {
234
250
  flex-shrink: 0;
235
251
  "
236
252
  >
237
- {{ user.name[0] }}
253
+ {{ Array.from(user.name)[0] }}
238
254
  </div>
239
255
  <div style="flex: 1; min-width: 0;">
240
256
  <p style="margin: 0; font-size: 1.4rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
@@ -245,7 +261,7 @@ export const InContext: Story = {
245
261
  </p>
246
262
  </div>
247
263
  <span style="font-size: 1.2rem; opacity: 0.5; white-space: nowrap;">{{ user.role }}</span>
248
- <ActionMenu v-bind="args">
264
+ <ActionMenu v-bind="componentArgs" :style="tokenStyle">
249
265
  <template #item-0>
250
266
  <ActionMenuItem label="Edit user" @click="() => {}">
251
267
  <template #icon><Icon name="lucide:pencil" /></template>
@@ -337,3 +353,99 @@ export const ItemCoreStandalone: Story = {
337
353
  `,
338
354
  }),
339
355
  };
356
+
357
+ /**
358
+ * Stress test: hostile labels and item counts, with triggers at both inline edges.
359
+ */
360
+ export const StressTest: Story = {
361
+ name: "Stress Test (Worst-Case Data)",
362
+ args: {
363
+ label: "Aktionen für diesen Eintrag öffnen",
364
+ },
365
+ render: (args) => ({
366
+ components: { ActionMenu, ActionMenuItem },
367
+ setup() {
368
+ const hostileLabels = [
369
+ "Donaudampfschifffahrtselektrizitätenhauptbetriebswerkbauunterbeamtengesellschaft",
370
+ "https://example.com/" + "a-very-long-path-segment-without-spaces".repeat(3),
371
+ "Diesen Eintrag dauerhaft löschen, einschließlich aller zugehörigen Anhänge und Kommentare",
372
+ "🎉 Celebrate",
373
+ "<b>Not bold</b>",
374
+ "حذف هذا السجل نهائيا",
375
+ "A",
376
+ ];
377
+ const manyItems = Array.from({ length: 12 }, (_, i) => `Action ${i + 1}`);
378
+ return { ...useStorySetup(args), hostileLabels, manyItems };
379
+ },
380
+ template: `
381
+ <div style="padding: 2.4rem 1.6rem; display: grid; gap: 4rem;">
382
+ <div style="display: flex; justify-content: space-between; align-items: center; gap: 1.6rem;">
383
+ <ActionMenu v-bind="componentArgs" :style="tokenStyle">
384
+ <template v-for="(text, i) in hostileLabels" :key="i" #[\`item-\${i}\`]>
385
+ <ActionMenuItem :label="text" @click="() => {}">
386
+ <template #icon><Icon name="lucide:pencil" /></template>
387
+ </ActionMenuItem>
388
+ </template>
389
+ </ActionMenu>
390
+ <span>Hostile labels: left-edge trigger and right-edge trigger</span>
391
+ <ActionMenu v-bind="componentArgs" :style="tokenStyle">
392
+ <template v-for="(text, i) in hostileLabels" :key="i" #[\`item-\${i}\`]>
393
+ <ActionMenuItem :label="text" href="https://example.com"></ActionMenuItem>
394
+ </template>
395
+ </ActionMenu>
396
+ </div>
397
+
398
+ <div style="display: flex; justify-content: space-between; align-items: center; gap: 1.6rem;">
399
+ <span>Twelve items</span>
400
+ <ActionMenu v-bind="componentArgs" :style="tokenStyle">
401
+ <template v-for="(text, i) in manyItems" :key="i" #[\`item-\${i}\`]>
402
+ <ActionMenuItem :label="text" @click="() => {}"></ActionMenuItem>
403
+ </template>
404
+ </ActionMenu>
405
+ </div>
406
+
407
+ <div style="display: flex; justify-content: space-between; align-items: center; gap: 1.6rem;">
408
+ <span>One item</span>
409
+ <ActionMenu v-bind="componentArgs" :style="tokenStyle">
410
+ <template #item-0><ActionMenuItem label="Only action" @click="() => {}"></ActionMenuItem></template>
411
+ </ActionMenu>
412
+ </div>
413
+
414
+ <div style="display: flex; justify-content: space-between; align-items: center; gap: 1.6rem;">
415
+ <span>No items (opens an empty panel, nothing focusable)</span>
416
+ <ActionMenu v-bind="componentArgs" :style="tokenStyle"></ActionMenu>
417
+ </div>
418
+ </div>
419
+ `,
420
+ }),
421
+ decorators: [
422
+ (story, context) => ({
423
+ components: { story, CanvasSwitcher },
424
+ setup() {
425
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
426
+ return { canvasName };
427
+ },
428
+ template: `
429
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
430
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
431
+ </div>
432
+ <div :class="canvasName" style="margin-inline: auto; outline: 1px dashed currentColor;">
433
+ <story />
434
+ </div>
435
+ `,
436
+ }),
437
+ ],
438
+ parameters: {
439
+ initialCanvas: "mobileCanvas",
440
+ docs: {
441
+ story: { inline: false, height: "640px" },
442
+ description: {
443
+ story:
444
+ "Open each menu at every canvas width. Long unbroken words and URLs should wrap inside the menu, which never grows past " +
445
+ "`--action-menu-popover-max-width` (32rem, or the viewport less 3.2rem). The left-edge menu should flip to open rightwards " +
446
+ "rather than off-screen (CSS anchor positioning browsers). Emoji and HTML-like text render as text, the Arabic label wraps like the rest, " +
447
+ "twelve items stay keyboard-navigable, and the empty menu opens without errors. Try the line-clamp control to cap labels at 1 to 3 lines.",
448
+ },
449
+ },
450
+ },
451
+ };