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
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
108
|
+
display: -webkit-box;
|
|
109
|
+
-webkit-box-orient: vertical;
|
|
109
110
|
overflow: hidden;
|
|
110
|
-
|
|
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
|
|
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
|
|
152
|
-
|
|
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:
|
|
21
|
-
description: "Number of items to show (
|
|
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
|
-
|
|
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
|
|
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="
|
|
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
|
|
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="
|
|
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="
|
|
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
|
+
};
|