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.
- package/.claude/settings.json +29 -26
- package/.claude/settings.local.json +24 -52
- package/.claude/skills/components/action-menu.md +141 -0
- package/.claude/skills/components/stepper-list.md +52 -18
- package/.claude/skills/css-nesting-conventions.md +121 -0
- package/.claude/skills/index.md +4 -0
- package/.claude/skills/pull-request-description.md +48 -0
- package/.claude/skills/testing-add-unit-test.md +40 -2
- package/.claude/skills/vercel-node-version.md +48 -0
- package/README.md +26 -5
- package/app/components/01.atoms/page-row/PageRow.vue +5 -1
- package/app/components/02.molecules/action-menu/ActionMenu.vue +218 -0
- package/app/components/02.molecules/action-menu/ActionMenuItemCore.vue +123 -0
- package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +125 -0
- package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +326 -0
- package/app/components/02.molecules/action-menu/tests/ActionMenu.spec.ts +458 -0
- package/app/components/02.molecules/action-menu/tests/ActionMenuItemCore.spec.ts +199 -0
- package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenu.spec.ts.snap +28 -0
- package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenuItemCore.spec.ts.snap +27 -0
- package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +5 -1
- package/app/components/02.molecules/stepper-list/CONSUMER-STYLING.md +138 -0
- package/app/components/02.molecules/stepper-list/StepperList.vue +50 -24
- package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +5 -1
- package/app/components/05.forms/input-button/InputButtonCore.vue +1 -1
- package/app/components/carousels/tests/CarouselFlip.spec.ts +1 -0
- package/app/components/responsive-header/NavigationItems.vue +302 -81
- package/app/components/responsive-header/ResponsiveHeader.vue +360 -56
- package/app/layouts/default.vue +43 -352
- package/app/layouts/site-navigation-demo.vue +36 -34
- package/app/pages/page-hero-highlights.vue +3 -3
- package/app/pages/ui/scroll-reveal-image.vue +3 -3
- package/app/pages/ui/simple-grid.vue +9 -20
- package/modules/colour-scheme.ts +1 -1
- package/nuxt.config.ts +11 -2
- 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 `
|
|
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
|
-
|
|
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
|
|
107
|
+
## Known Build / Production Issues
|
|
108
108
|
|
|
109
|
-
### `
|
|
109
|
+
### `ERR_MODULE_NOT_FOUND: vue/index.mjs` — 500 on every request (Node 22)
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
**Symptom**: After `npm run build`, running `node .output/server/index.mjs` returns HTTP 500 on every page. The terminal shows:
|
|
112
112
|
|
|
113
|
-
|
|
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
|
-
**
|
|
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:
|
|
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.
|