srcdev-nuxt-components 9.1.51 → 9.1.53

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 (35) hide show
  1. package/.claude/settings.json +29 -26
  2. package/.claude/settings.local.json +24 -52
  3. package/.claude/skills/components/action-menu.md +141 -0
  4. package/.claude/skills/components/stepper-list.md +52 -18
  5. package/.claude/skills/css-nesting-conventions.md +121 -0
  6. package/.claude/skills/index.md +4 -0
  7. package/.claude/skills/pull-request-description.md +48 -0
  8. package/.claude/skills/testing-add-unit-test.md +40 -2
  9. package/.claude/skills/vercel-node-version.md +48 -0
  10. package/README.md +26 -5
  11. package/app/components/01.atoms/page-row/PageRow.vue +5 -1
  12. package/app/components/02.molecules/action-menu/ActionMenu.vue +218 -0
  13. package/app/components/02.molecules/action-menu/ActionMenuItemCore.vue +123 -0
  14. package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +125 -0
  15. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +326 -0
  16. package/app/components/02.molecules/action-menu/tests/ActionMenu.spec.ts +458 -0
  17. package/app/components/02.molecules/action-menu/tests/ActionMenuItemCore.spec.ts +199 -0
  18. package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenu.spec.ts.snap +28 -0
  19. package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenuItemCore.spec.ts.snap +27 -0
  20. package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +5 -1
  21. package/app/components/02.molecules/stepper-list/CONSUMER-STYLING.md +138 -0
  22. package/app/components/02.molecules/stepper-list/StepperList.vue +50 -24
  23. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +5 -1
  24. package/app/components/05.forms/input-button/InputButtonCore.vue +1 -1
  25. package/app/components/carousels/tests/CarouselFlip.spec.ts +1 -0
  26. package/app/components/responsive-header/NavigationItems.vue +302 -81
  27. package/app/components/responsive-header/ResponsiveHeader.vue +360 -56
  28. package/app/layouts/default.vue +43 -352
  29. package/app/layouts/site-navigation-demo.vue +36 -34
  30. package/app/pages/page-hero-highlights.vue +3 -3
  31. package/app/pages/ui/scroll-reveal-image.vue +3 -3
  32. package/app/pages/ui/simple-grid.vue +9 -20
  33. package/modules/colour-scheme.ts +1 -1
  34. package/nuxt.config.ts +11 -2
  35. package/package.json +18 -21
@@ -37,6 +37,7 @@ describe("ComponentName", () => {
37
37
 
38
38
  afterEach(() => {
39
39
  wrapper?.unmount();
40
+ vi.restoreAllMocks(); // always restore vi.spyOn() stubs after each test
40
41
  });
41
42
 
42
43
  // -------------------------
@@ -162,7 +163,7 @@ it("exposes headingId via scoped slot", async () => {
162
163
  ## Key rules
163
164
 
164
165
  - Always `mountSuspended` — never `mount` or `shallowMount` from `@vue/test-utils` directly.
165
- - Always `afterEach(() => wrapper?.unmount())` to prevent test leaks.
166
+ - Always call both `wrapper?.unmount()` and `vi.restoreAllMocks()` in `afterEach`. The unmount cleans up Vue; the restore cleans up any `vi.spyOn()` stubs so they don't leak into later test files.
166
167
  - Use a `createWrapper` helper to keep individual tests short.
167
168
  - Include at least one snapshot test per meaningful visual state.
168
169
  - `nextTick` is **not** auto-imported in test files — always import it explicitly: `import { nextTick } from "vue"`.
@@ -285,7 +286,9 @@ Import the child component directly in the test file — it is not auto-imported
285
286
 
286
287
  ## Mocking browser APIs
287
288
 
288
- Mock before the `describe` block if the component uses ResizeObserver, IntersectionObserver, etc.:
289
+ ### Global constructors (ResizeObserver, IntersectionObserver, etc.)
290
+
291
+ Use `vi.stubGlobal` before the `describe` block. Do **not** call `vi.unstubAllGlobals()` in `afterEach` — it removes stubs from `vitest.setup.ts` (`$fetch`, etc.):
289
292
 
290
293
  ```ts
291
294
  const mockResizeObserver = vi.fn(() => ({
@@ -296,6 +299,41 @@ const mockResizeObserver = vi.fn(() => ({
296
299
  vi.stubGlobal("ResizeObserver", mockResizeObserver);
297
300
  ```
298
301
 
302
+ ### Prototype methods (Popover API, Canvas, etc.)
303
+
304
+ When an API is missing from jsdom entirely (e.g. `hidePopover`, `showPopover`) use `Object.defineProperty` in `beforeEach`. **`vi.restoreAllMocks()` does not clean these up** — delete them explicitly in `afterEach`:
305
+
306
+ ```ts
307
+ beforeEach(() => {
308
+ // vi.spyOn stubs are cleaned by vi.restoreAllMocks() in afterEach
309
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({} as never);
310
+
311
+ // Object.defineProperty stubs are NOT cleaned by vi.restoreAllMocks() —
312
+ // must be deleted explicitly to prevent leaking into other test files
313
+ Object.defineProperty(HTMLElement.prototype, "hidePopover", {
314
+ value: vi.fn(),
315
+ writable: true,
316
+ configurable: true,
317
+ });
318
+ Object.defineProperty(HTMLElement.prototype, "showPopover", {
319
+ value: vi.fn(),
320
+ writable: true,
321
+ configurable: true,
322
+ });
323
+ });
324
+
325
+ afterEach(() => {
326
+ wrapper?.unmount();
327
+ vi.restoreAllMocks(); // cleans up vi.spyOn stubs
328
+ // Remove Object.defineProperty prototype stubs — vi.restoreAllMocks() won't touch these
329
+ delete (HTMLElement.prototype as unknown as Record<string, unknown>)["hidePopover"];
330
+ delete (HTMLElement.prototype as unknown as Record<string, unknown>)["showPopover"];
331
+ });
332
+ ```
333
+
334
+ The `as unknown as Record<string, unknown>` double-cast is required because TypeScript's
335
+ `HTMLElement` type has no index signature — cast through `unknown` first.
336
+
299
337
  ## Describe section conventions
300
338
 
301
339
  Use these section names consistently so tests are easy to scan:
@@ -0,0 +1,48 @@
1
+ # Vercel Node Version — .nvmrc Required
2
+
3
+ ## Overview
4
+
5
+ Every project deploying to Vercel must have a `.nvmrc` file pinned to Node 24. Without it, Vercel defaults to Node 22 (npm 10), which crashes with `npm error Invalid Version:` on platform-specific optional packages that npm 11 (Node 24) handles gracefully.
6
+
7
+ ## The problem
8
+
9
+ When `npm install` runs on macOS with Node 24, it writes stub entries into `package-lock.json` for platform-specific optional packages that don't apply to the current OS. For example:
10
+
11
+ ```json
12
+ "node_modules/oxc-transform/node_modules/@oxc-transform/binding-linux-arm-gnueabihf": {
13
+ "optional": true
14
+ }
15
+ ```
16
+
17
+ This stub has no `version` field. npm 11 (Node 24) skips it silently. npm 10 (Node 22) calls `semver.valid()` on the empty version string and throws:
18
+
19
+ ```
20
+ npm error Invalid Version:
21
+ npm error A complete log of this run can be found in: ...
22
+ Error: Command "npm install" exited with 1
23
+ ```
24
+
25
+ The build fails even though the package is optional and not needed on Vercel's Linux x86_64 runtime.
26
+
27
+ ## Fix
28
+
29
+ ### 1. Add `.nvmrc` to the project root
30
+
31
+ ```
32
+ 24
33
+ ```
34
+
35
+ Vercel reads `.nvmrc` and provisions Node 24 (npm 11), which handles the versionless stubs without error.
36
+
37
+ ### 2. Verify the file exists before every deployment
38
+
39
+ If `.nvmrc` is ever accidentally deleted, recreate it immediately. This applies to:
40
+ - `nuxt-components` (the layer)
41
+ - Every consumer app (`instepreflexology`, `luxury-locs-by-natasha-nuxt3`, etc.)
42
+
43
+ ## Notes
44
+
45
+ - The specific stub that first surfaced this was `@oxc-transform/binding-linux-arm-gnueabihf` — a Linux ARM native binary written as a versionless placeholder on macOS.
46
+ - The build works locally on Node 24 regardless, so the failure is silent until Vercel runs.
47
+ - Vercel also respects the `engines.node` field in `package.json`, but `.nvmrc` is simpler and doesn't require a valid semver range.
48
+ - Do **not** attempt to fix by deleting stubs from `package-lock.json` alone — they reappear on the next `npm install`. The `.nvmrc` file is the durable fix.
package/README.md CHANGED
@@ -104,15 +104,36 @@ When disabled, no `data-color-scheme` attribute is set on `<html>` and `useColou
104
104
 
105
105
  ---
106
106
 
107
- ## Known Dev Server Warnings
107
+ ## Known Build / Production Issues
108
108
 
109
- ### `[request error] [GET] http://localhost:3000/_nuxt/` (404)
109
+ ### `ERR_MODULE_NOT_FOUND: vue/index.mjs` 500 on every request (Node 22)
110
110
 
111
- This error appears in the terminal when running `npm run dev` and is **harmless** it does not affect dev server operation.
111
+ **Symptom**: After `npm run build`, running `node .output/server/index.mjs` returns HTTP 500 on every page. The terminal shows:
112
112
 
113
- **Cause**: The Vue/Nuxt browser DevTools extension probes `/_nuxt/` to detect if the page is a Nuxt app. Since `/_nuxt/` is a directory (not a file), the server returns 404 and logs it.
113
+ ```text
114
+ Error [ERR_MODULE_NOT_FOUND]: Cannot find module
115
+ '.output/server/node_modules/vue/index.mjs'
116
+ Did you mean to import
117
+ '.output/server/node_modules/.nitro/vue@3.5.34/dist/vue.cjs.prod.js'?
118
+ ```
119
+
120
+ **Root cause**: Nitro's dependency tracer externalises `vue` and copies it to `.output/server/node_modules/vue/`, but only traces the CJS build (`dist/vue.cjs.prod.js`). Vue's `package.json` exports map resolves the `"import"` + `"node"` condition (used by Node 22 when importing from an ES module) to `./index.mjs` — a file that was never copied. This is a Nitro tracing bug exposed by Node 22's stricter ESM condition matching, made more likely by `vue.runtimeCompiler: true` (which changes how Nuxt aliases Vue at build time, causing Nitro to fall back to externalising it as a node_modules package).
121
+
122
+ **Fix** (already applied in this repo's `nuxt.config.ts`):
123
+
124
+ ```ts
125
+ nitro: {
126
+ externals: {
127
+ inline: ["vue", "@vue/runtime-core", "@vue/runtime-dom", "@vue/reactivity", "@vue/shared", "@vue/server-renderer"],
128
+ },
129
+ },
130
+ ```
131
+
132
+ This tells Nitro to bundle these packages inline rather than externalise them, which sidesteps the runtime resolution entirely.
133
+
134
+ **Consumer apps**: This config is not gated behind `isStandalone`, so it is inherited automatically by any app that extends this layer. No consumer-side action is required.
114
135
 
115
- **Resolution**: It cannot be suppressed without disabling the browser extension. Since the DevTools extension is useful for inspecting Pinia stores and component state, the recommended approach is to ignore this warning.
136
+ **When it's safe to remove**: If a future Nitro release fixes the dependency tracer so that all export-condition files are copied correctly, the `inline` list can be removed. Verify by checking that `.output/server/node_modules/vue/index.mjs` exists after a clean build without the config.
116
137
 
117
138
  ---
118
139
 
@@ -48,7 +48,11 @@ const { headingId, ariaLabelledby } = useAriaLabelledById(props.tag);
48
48
 
49
49
  --full: minmax(var(--_minimum-content-padding), 1fr);
50
50
  --popout: minmax(0, calc((var(--_popout-max-width) - var(--_content-max-width)) * 0.5));
51
- --content: minmax(0, calc((var(--_content-max-width) - var(--_inset-content-max-width)) * 0.5));
51
+ --content: clamp(
52
+ 0px,
53
+ calc((100% - var(--_minimum-content-padding) * 2 - var(--_inset-content-max-width)) * 0.5),
54
+ calc((var(--_content-max-width) - var(--_inset-content-max-width)) * 0.5)
55
+ );
52
56
  --inset-content: min(var(--_inset-content-max-width), 100% - var(--_minimum-content-padding) * 2);
53
57
 
54
58
  display: grid;
@@ -0,0 +1,218 @@
1
+ <template>
2
+ <div class="action-menu" :class="[elementClasses]" :style="`--_anchor-name: ${anchorName}`">
3
+ <button
4
+ ref="triggerRef"
5
+ :popovertarget="menuId"
6
+ popovertargetaction="toggle"
7
+ type="button"
8
+ class="action-menu-trigger"
9
+ :aria-label="label"
10
+ aria-haspopup="menu"
11
+ >
12
+ <Icon name="lucide:ellipsis" class="action-menu-trigger-icon" aria-hidden="true" />
13
+ </button>
14
+
15
+ <div
16
+ :id="menuId"
17
+ ref="popoverRef"
18
+ popover
19
+ class="action-menu-popover"
20
+ @toggle="handleToggle"
21
+ @keydown="handleKeydown"
22
+ >
23
+ <ul class="action-menu-list" role="menu" :aria-label="label">
24
+ <li v-for="n in itemCount" :key="n - 1" class="action-menu-list-item" role="none" @click="closeMenu">
25
+ <slot :name="`item-${n - 1}`"></slot>
26
+ </li>
27
+ </ul>
28
+ </div>
29
+ </div>
30
+ </template>
31
+
32
+ <script setup lang="ts">
33
+ interface Props {
34
+ label?: string;
35
+ styleClassPassthrough?: string | string[];
36
+ }
37
+
38
+ const props = withDefaults(defineProps<Props>(), {
39
+ label: "Open actions menu",
40
+ styleClassPassthrough: () => [],
41
+ });
42
+
43
+ const slots = useSlots();
44
+ const itemCount = computed(() => Object.keys(slots).filter((name) => /^item-\d+$/.test(name)).length);
45
+
46
+ const id = useId();
47
+ const menuId = `action-menu-${id}`;
48
+ const anchorName = `--action-menu-anchor-${id}`;
49
+
50
+ const triggerRef = ref<HTMLButtonElement | null>(null);
51
+ const popoverRef = ref<HTMLDivElement | null>(null);
52
+
53
+ /** Returns all focusable menuitems in DOM order. */
54
+ const getMenuItems = (): HTMLElement[] =>
55
+ Array.from(popoverRef.value?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []);
56
+
57
+ /**
58
+ * Close the menu and return focus to the trigger.
59
+ * Called on item click — Escape is handled natively by the Popover API
60
+ * (which also restores focus to the trigger automatically).
61
+ */
62
+ const closeMenu = () => {
63
+ popoverRef.value?.hidePopover();
64
+ triggerRef.value?.focus();
65
+ };
66
+
67
+ const handleToggle = (event: Event) => {
68
+ const toggleEvent = event as ToggleEvent;
69
+ if (toggleEvent.newState === "open") {
70
+ getMenuItems()[0]?.focus();
71
+ }
72
+ };
73
+
74
+ /**
75
+ * Keyboard navigation following the WAI-ARIA menu pattern.
76
+ *
77
+ * ArrowDown / ArrowUp — move between items (wraps around).
78
+ * Home / End — jump to first / last item.
79
+ * Tab — close the menu; let the browser Tab naturally
80
+ * (do NOT focus the trigger — Tab should advance
81
+ * to the next element in the page).
82
+ * Escape — handled natively by the Popover API.
83
+ */
84
+ const handleKeydown = (event: KeyboardEvent) => {
85
+ const items = getMenuItems();
86
+ if (!items.length) return;
87
+
88
+ const currentIndex = items.indexOf(document.activeElement as HTMLElement);
89
+
90
+ switch (event.key) {
91
+ case "ArrowDown":
92
+ event.preventDefault();
93
+ items[currentIndex === -1 ? 0 : (currentIndex + 1) % items.length]?.focus();
94
+ break;
95
+ case "ArrowUp":
96
+ event.preventDefault();
97
+ items[currentIndex === -1 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length]?.focus();
98
+ break;
99
+ case "Home":
100
+ event.preventDefault();
101
+ items[0]?.focus();
102
+ break;
103
+ case "End":
104
+ event.preventDefault();
105
+ items[items.length - 1]?.focus();
106
+ break;
107
+ case "Tab":
108
+ // Close without stealing focus — Tab exits to the next DOM element naturally.
109
+ popoverRef.value?.hidePopover();
110
+ break;
111
+ }
112
+ };
113
+
114
+ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
115
+ </script>
116
+
117
+ <style lang="css">
118
+ @layer components {
119
+ .action-menu {
120
+ --_block-distance: var(--action-menu-block-distance, 0.4rem);
121
+ --_trigger-size: var(--action-menu-trigger-size, 3.2rem);
122
+ --_trigger-border-radius: var(--action-menu-trigger-border-radius, var(--button-border-radius-icon-only, 50%));
123
+ --_trigger-surface: var(--action-menu-trigger-surface, transparent);
124
+ --_trigger-surface-hover: var(--action-menu-trigger-surface-hover, var(--slate-01));
125
+ --_trigger-icon-size: var(--action-menu-trigger-icon-size, 2rem);
126
+ --_trigger-icon-color: var(--action-menu-trigger-icon-color, var(--slate-07));
127
+
128
+ --_popover-background: var(--action-menu-popover-background, var(--slate-00));
129
+ --_popover-border: var(--action-menu-popover-border, 0.1rem solid var(--slate-03));
130
+ --_popover-border-radius: var(--action-menu-popover-border-radius, 0.8rem);
131
+ --_popover-min-width: var(--action-menu-popover-min-width, 20rem);
132
+ --_popover-shadow: var(--action-menu-popover-shadow, 0 0.4rem 1.6rem rgba(0, 0, 0, 0.1));
133
+ --_popover-transition-duration: var(--action-menu-popover-transition-duration, 200ms);
134
+ --_item-divider: var(--action-menu-item-divider, 0.1rem solid var(--slate-02));
135
+
136
+ position: relative;
137
+ display: inline-block;
138
+
139
+ .action-menu-trigger {
140
+ all: unset;
141
+ cursor: pointer;
142
+ display: grid;
143
+ place-items: center;
144
+ width: var(--_trigger-size);
145
+ height: var(--_trigger-size);
146
+ border-radius: var(--_trigger-border-radius);
147
+ background-color: var(--_trigger-surface);
148
+ color: var(--_trigger-icon-color);
149
+ anchor-name: var(--_anchor-name);
150
+ transition: background-color var(--control-transition-duration, 200ms) var(--control-transition-ease, ease);
151
+
152
+ &:hover,
153
+ &:focus-visible {
154
+ background-color: var(--_trigger-surface-hover);
155
+ }
156
+
157
+ &:focus-visible {
158
+ outline: var(--button-outline-width, 0.2rem) solid var(--theme-button-primary-outline, currentcolor);
159
+ outline-offset: 0.2rem;
160
+ }
161
+
162
+ .action-menu-trigger-icon {
163
+ display: block;
164
+ width: var(--_trigger-icon-size);
165
+ height: var(--_trigger-icon-size);
166
+ }
167
+ }
168
+
169
+ .action-menu-popover {
170
+ border: var(--_popover-border);
171
+ margin: 0;
172
+ padding: 0;
173
+ inset: auto;
174
+ background-color: var(--_popover-background);
175
+ border-radius: var(--_popover-border-radius);
176
+ min-width: var(--_popover-min-width);
177
+ box-shadow: var(--_popover-shadow);
178
+ overflow: hidden;
179
+
180
+ position-anchor: var(--_anchor-name);
181
+ top: calc(anchor(bottom) + var(--_block-distance));
182
+ right: anchor(right);
183
+ left: auto;
184
+ position-try-fallbacks: flip-block;
185
+
186
+ opacity: 0;
187
+ display: none;
188
+ transition:
189
+ opacity var(--_popover-transition-duration),
190
+ display var(--_popover-transition-duration),
191
+ overlay var(--_popover-transition-duration);
192
+ transition-behavior: allow-discrete;
193
+
194
+ &:popover-open {
195
+ display: block;
196
+ opacity: 1;
197
+
198
+ @starting-style {
199
+ display: block;
200
+ opacity: 0;
201
+ }
202
+ }
203
+
204
+ .action-menu-list {
205
+ list-style: none;
206
+ padding: 0;
207
+ margin: 0;
208
+
209
+ .action-menu-list-item {
210
+ &:not(:last-child) {
211
+ border-bottom: var(--_item-divider);
212
+ }
213
+ }
214
+ }
215
+ }
216
+ }
217
+ }
218
+ </style>
@@ -0,0 +1,123 @@
1
+ <template>
2
+ <component
3
+ :is="tag"
4
+ :type="!isLink ? 'button' : undefined"
5
+ :href="isLink ? href : undefined"
6
+ class="action-menu-item-core"
7
+ :class="[elementClasses]"
8
+ role="menuitem"
9
+ @click="emit('click', $event)"
10
+ >
11
+ <span v-if="slots.icon" class="action-menu-item-icon" aria-hidden="true">
12
+ <slot name="icon"></slot>
13
+ </span>
14
+ <span class="action-menu-item-label">{{ label }}</span>
15
+ <span class="action-menu-item-arrow" aria-hidden="true">
16
+ <Icon name="lucide:arrow-right" class="action-menu-item-arrow-icon" />
17
+ </span>
18
+ </component>
19
+ </template>
20
+
21
+ <script setup lang="ts">
22
+ interface Props {
23
+ label: string;
24
+ href?: string;
25
+ styleClassPassthrough?: string | string[];
26
+ }
27
+
28
+ const props = withDefaults(defineProps<Props>(), {
29
+ href: undefined,
30
+ styleClassPassthrough: () => [],
31
+ });
32
+
33
+ const emit = defineEmits<{
34
+ click: [event: MouseEvent];
35
+ }>();
36
+
37
+ const NuxtLink = resolveComponent("NuxtLink");
38
+ const isLink = computed(() => Boolean(props.href));
39
+ const isInternalLink = computed(() => isLink.value && props.href?.startsWith("/"));
40
+ const tag = computed(() => {
41
+ if (isInternalLink.value) return NuxtLink;
42
+ if (isLink.value) return "a";
43
+ return "button";
44
+ });
45
+
46
+ const slots = useSlots();
47
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
48
+
49
+ watch(
50
+ () => props.styleClassPassthrough,
51
+ () => {
52
+ resetElementClasses(props.styleClassPassthrough);
53
+ }
54
+ );
55
+ </script>
56
+
57
+ <style lang="css">
58
+ @layer components {
59
+ .action-menu-item-core {
60
+ --_surface-hover: var(--action-menu-item-surface-hover, light-dark(var(--slate-01), var(--slate-09)));
61
+ --_text-color: var(--action-menu-item-text-color, light-dark(var(--slate-09), var(--slate-01)));
62
+ --_icon-size: var(--action-menu-item-icon-size, 2rem);
63
+ --_padding-inline: var(--action-menu-item-padding-inline, 1.6rem);
64
+ --_padding-block: var(--action-menu-item-padding-block, 1.2rem);
65
+
66
+ all: unset;
67
+ box-sizing: border-box;
68
+ cursor: pointer;
69
+ display: grid;
70
+ grid-template-columns: auto 1fr auto;
71
+ align-items: center;
72
+ gap: 1.2rem;
73
+ width: 100%;
74
+ padding-inline: var(--_padding-inline);
75
+ padding-block: var(--_padding-block);
76
+ color: var(--_text-color);
77
+ font-family: var(--font-family);
78
+ font-size: var(--button-font-size, 1.4rem);
79
+ font-weight: var(--button-font-weight, 500);
80
+ line-height: var(--button-line-height, 1.2);
81
+ transition: background-color var(--control-transition-duration, 200ms) var(--control-transition-ease, ease);
82
+ text-decoration: none;
83
+
84
+ &:hover,
85
+ &:focus-visible {
86
+ background-color: var(--_surface-hover);
87
+ }
88
+
89
+ &:focus-visible {
90
+ outline: var(--button-outline-width, 0.2rem) solid var(--theme-button-primary-outline, currentcolor);
91
+ outline-offset: -0.2rem;
92
+ }
93
+
94
+ .action-menu-item-icon {
95
+ display: flex;
96
+ align-items: center;
97
+ justify-content: center;
98
+ width: var(--_icon-size);
99
+ height: var(--_icon-size);
100
+ flex-shrink: 0;
101
+ }
102
+
103
+ .action-menu-item-label {
104
+ white-space: nowrap;
105
+ overflow: hidden;
106
+ text-overflow: ellipsis;
107
+ }
108
+
109
+ .action-menu-item-arrow {
110
+ display: flex;
111
+ align-items: center;
112
+ flex-shrink: 0;
113
+ opacity: 0.5;
114
+
115
+ .action-menu-item-arrow-icon {
116
+ display: block;
117
+ width: 1.6rem;
118
+ height: 1.6rem;
119
+ }
120
+ }
121
+ }
122
+ }
123
+ </style>
@@ -0,0 +1,125 @@
1
+ # ActionMenu — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ All `--action-menu-*` tokens are the stable override surface. Because action menus appear
6
+ repeatedly across the UI (tables, cards, list rows) the recommended approach is to set tokens
7
+ once in a **global CSS file** rather than per-instance via `styleClassPassthrough`.
8
+
9
+ ### Trigger button
10
+
11
+ | Token | Default | Controls |
12
+ |---|---|---|
13
+ | `--action-menu-trigger-size` | `3.2rem` | Trigger button width and height |
14
+ | `--action-menu-trigger-border-radius` | `var(--button-border-radius-icon-only, 50%)` | Trigger corner rounding |
15
+ | `--action-menu-trigger-surface` | `transparent` | Trigger background (rest state) |
16
+ | `--action-menu-trigger-surface-hover` | `light-dark(var(--slate-01), var(--slate-09))` | Trigger background on hover/focus |
17
+ | `--action-menu-trigger-icon-size` | `2rem` | Ellipsis icon size |
18
+ | `--action-menu-trigger-icon-color` | `light-dark(var(--slate-07), var(--slate-03))` | Ellipsis icon colour |
19
+
20
+ ### Menu popover
21
+
22
+ | Token | Default | Controls |
23
+ |---|---|---|
24
+ | `--action-menu-block-distance` | `0.4rem` | Gap between trigger bottom and menu top |
25
+ | `--action-menu-popover-background` | `light-dark(var(--slate-00), var(--slate-10))` | Menu panel background |
26
+ | `--action-menu-popover-border` | `0.1rem solid light-dark(var(--slate-03), var(--slate-07))` | Menu panel border shorthand |
27
+ | `--action-menu-popover-border-radius` | `0.8rem` | Menu panel corner rounding |
28
+ | `--action-menu-popover-min-width` | `20rem` | Minimum menu width |
29
+ | `--action-menu-popover-shadow` | `0 0.4rem 1.6rem light-dark(rgba(0,0,0,0.1), rgba(0,0,0,0.4))` | Menu panel drop shadow |
30
+ | `--action-menu-popover-transition-duration` | `200ms` | Open/close fade duration |
31
+
32
+ ### Menu items (`ActionMenuItemCore`)
33
+
34
+ | Token | Default | Controls |
35
+ |---|---|---|
36
+ | `--action-menu-item-divider` | `0.1rem solid light-dark(var(--slate-02), var(--slate-08))` | Divider line between items |
37
+ | `--action-menu-item-surface-hover` | `light-dark(var(--slate-01), var(--slate-09))` | Item row background on hover/focus |
38
+ | `--action-menu-item-text-color` | `light-dark(var(--slate-09), var(--slate-01))` | Item label and icon colour |
39
+ | `--action-menu-item-icon-size` | `2rem` | Left icon container size |
40
+ | `--action-menu-item-padding-inline` | `1.6rem` | Item horizontal padding |
41
+ | `--action-menu-item-padding-block` | `1.2rem` | Item vertical padding |
42
+
43
+ ---
44
+
45
+ ## Global theming — recommended approach
46
+
47
+ Create `assets/styles/setup/07.components/action-menu.css` in the consuming app and set tokens
48
+ on `:root`. This applies to every `ActionMenu` across the site.
49
+
50
+ ```css
51
+ /* assets/styles/setup/07.components/action-menu.css */
52
+ :root {
53
+ --action-menu-trigger-border-radius: 0.4rem;
54
+ --action-menu-trigger-surface-hover: var(--brand-surface-subtle);
55
+ --action-menu-trigger-icon-color: var(--brand-text-muted);
56
+
57
+ --action-menu-popover-background: var(--brand-surface);
58
+ --action-menu-popover-border: 0.1rem solid var(--brand-border);
59
+ --action-menu-popover-border-radius: 0.6rem;
60
+ --action-menu-popover-shadow: 0 0.8rem 2.4rem rgba(0, 0, 0, 0.15);
61
+
62
+ --action-menu-item-surface-hover: var(--brand-surface-subtle);
63
+ --action-menu-item-text-color: var(--brand-text);
64
+ --action-menu-item-divider: 0.1rem solid var(--brand-border);
65
+ --action-menu-block-distance: 0.6rem;
66
+ }
67
+ ```
68
+
69
+ ---
70
+
71
+ ## Page-scoped overrides
72
+
73
+ Override tokens for a specific section by scoping them under the page or layout wrapper.
74
+ No `:deep()` is required (component styles are unscoped).
75
+
76
+ ```css
77
+ /* In the consuming page's unscoped <style> block */
78
+ .admin-table {
79
+ .action-menu {
80
+ --action-menu-trigger-size: 2.8rem;
81
+ --action-menu-trigger-icon-size: 1.6rem;
82
+ --action-menu-popover-min-width: 16rem;
83
+ --action-menu-item-padding-block: 0.8rem;
84
+ }
85
+ }
86
+ ```
87
+
88
+ ---
89
+
90
+ ## Per-instance overrides via styleClassPassthrough
91
+
92
+ Use sparingly — prefer global or page-scoped CSS. When a single instance needs a distinct
93
+ visual style, pass a modifier class:
94
+
95
+ ```vue
96
+ <ActionMenu
97
+ :style-class-passthrough="['danger-actions']"
98
+ >
99
+ ...
100
+ </ActionMenu>
101
+ ```
102
+
103
+ ```css
104
+ .action-menu.danger-actions {
105
+ --action-menu-trigger-icon-color: var(--color-danger);
106
+ --action-menu-trigger-surface-hover: light-dark(var(--red-01), var(--red-09));
107
+ --action-menu-item-surface-hover: light-dark(var(--red-01), var(--red-09));
108
+ --action-menu-item-text-color: light-dark(var(--red-09), var(--red-01));
109
+ --action-menu-item-divider: 0.1rem solid light-dark(var(--red-02), var(--red-08));
110
+ }
111
+ ```
112
+
113
+ ---
114
+
115
+ ## Notes
116
+
117
+ - `--action-menu-block-distance` accepts any valid `<length>`. Negative values will cause the
118
+ menu to overlap the trigger.
119
+ - The menu opens **below** the trigger and right-aligns with it by default. It flips above
120
+ when near the bottom of the viewport (`position-try-fallbacks: flip-block`).
121
+ - `--action-menu-popover-min-width` sets the floor — long labels will naturally expand the
122
+ menu wider. Set `width: max-content` on `.action-menu-popover` in a consumer override if
123
+ you want to suppress that.
124
+ - The `--action-menu-item-*` tokens resolve on `.action-menu-item-core` elements, so they
125
+ take effect even when items are used in other contexts.