Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 0 additions & 33 deletions .claude/settings.json

This file was deleted.

141 changes: 141 additions & 0 deletions .claude/skills/components/action-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# ActionMenu

## Overview

`ActionMenu` is a trigger-and-popover component that shows a compact ellipsis button (`lucide:ellipsis`).
Clicking it opens an anchored menu list populated via indexed dynamic slots (`item-{n}`). Each slot
should contain a single `ActionMenuItemCore` — either a `<button>` (for actions) or a link (for
navigation). The popover API and CSS anchor positioning handle positioning and dismiss behaviour
natively; no JavaScript click-outside logic is needed.

**Location**: `app/components/02.molecules/action-menu/`

---

## Components

### ActionMenu

| Prop | Type | Default | Notes |
|---|---|---|---|
| `itemCount` | `number` | `0` | Number of `item-{n}` slots to render. |
| `label` | `string` | `"Open actions menu"` | Used as `aria-label` on the trigger and `aria-label` on the menu list. |
| `styleClassPassthrough` | `string \| string[]` | `[]` | Extra classes on the root `<div>`. |

**Slots**

| Slot | When used |
|---|---|
| `item-{n}` | One per item, where `n` is 0-indexed up to `itemCount - 1`. Should contain one `ActionMenuItemCore`. |

---

### ActionMenuItemCore

| Prop | Type | Default | Notes |
|---|---|---|---|
| `label` | `string` | — | **Required.** Visible text for the row. |
| `href` | `string` | `undefined` | If set, renders as `<a>` (external) or `NuxtLink` (internal `/…` path). Omit for a `<button>`. |
| `styleClassPassthrough` | `string \| string[]` | `[]` | Extra classes on the root element. |

**Slots**

| Slot | Content |
|---|---|
| `#icon` | Optional left icon (e.g. `<Icon name="lucide:pencil" />`). Wrapped in `aria-hidden` span. |

**Emits**

| Event | Payload | Notes |
|---|---|---|
| `click` | `MouseEvent` | Fired on every click regardless of whether the item is a button or link. |

**Notes on routing**
- Internal paths (`/…`) resolve to `<NuxtLink>` via `resolveComponent`.
- External URLs or relative paths without a leading `/` render as plain `<a>`.
- `type="button"` is set automatically on `<button>` elements to prevent accidental form submission.

---

## Basic usage

```vue
<ActionMenu :item-count="3" label="Row actions">
<template #item-0>
<ActionMenuItemCore label="Edit" @click="handleEdit">
<template #icon><Icon name="lucide:pencil" /></template>
</ActionMenuItemCore>
</template>
<template #item-1>
<ActionMenuItemCore label="View detail" href="/records/123">
<template #icon><Icon name="lucide:eye" /></template>
</ActionMenuItemCore>
</template>
<template #item-2>
<ActionMenuItemCore label="Delete" @click="handleDelete">
<template #icon><Icon name="lucide:trash-2" /></template>
</ActionMenuItemCore>
</template>
</ActionMenu>
```

---

## Link vs button items

| Scenario | Use |
|---|---|
| Triggers a JS handler (delete, share, copy…) | Omit `href` — renders as `<button>` |
| Navigates to an internal Nuxt route | `href="/path"` — renders as `<NuxtLink>` |
| Navigates to an external URL | `href="https://…"` — renders as `<a>` |

---

## CSS token API

See `CONSUMER-STYLING.md` in the component folder for the full token reference and override
examples. Prefer global CSS for action menus — they appear site-wide in tables, cards, and lists.

Quick reference:

```css
/* assets/styles/setup/07.components/action-menu.css */
:root {
--action-menu-block-distance: 0.6rem;
--action-menu-trigger-border-radius: 0.4rem;
--action-menu-trigger-surface-hover: var(--brand-surface-subtle);
--action-menu-trigger-icon-color: var(--brand-text-muted);

--action-menu-popover-background: var(--brand-surface);
--action-menu-popover-border: 0.1rem solid var(--brand-border);
--action-menu-popover-border-radius: 0.6rem;

--action-menu-item-surface-hover: var(--brand-surface-subtle);
--action-menu-item-text-color: var(--brand-text);
}
```

---

## Notes

- **Popover API + CSS anchor positioning** — the menu uses `popover` attribute and `position-anchor`.
Both are broadly supported (Chrome 114+, Firefox 125+, Safari 17+). No polyfill is included.
- **Auto-close** — clicking any `<li>` row fires `hidePopover()` on the menu. The `ActionMenuItemCore`
emitting `click` triggers normally before the menu closes.
- **Focus management** — on open the `toggle` event fires `handleToggle`, which moves focus to the
first `[role="menuitem"]` inside the popover.
- **Keyboard navigation `currentIndex === -1` guard** — `handleKeydown` computes the current
position via `items.indexOf(document.activeElement)`. When focus is outside the menu this returns
`-1`. Always guard explicitly before applying wrap-around math: `ArrowDown` should focus
`items[0]`; `ArrowUp` should focus `items[items.length - 1]`. Without the guard, the modulo
formula gives `items[n-2]` for `ArrowUp` — the second-to-last item instead of the last.
- **Right-aligned by default** — the menu's right edge aligns with the trigger's right edge
(`right: anchor(right)`). Flips above the trigger near the bottom of the viewport
(`position-try-fallbacks: flip-block`).
- **`anchorName` format** — internally generated as `--action-menu-anchor-{id}` (a valid CSS
`<dashed-ident>`). Set via a CSS custom property on the root element so both the trigger's
`anchor-name` and the popover's `position-anchor` can reference the same value.
- **Dynamic slots stability** — `item-{n}` slots enforce that only `ActionMenuItemCore` content
enters the list; arbitrary HTML inside the popover is not supported and will break the ARIA
`menu` / `menuitem` pattern.
1 change: 1 addition & 0 deletions .claude/skills/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
├── auto-grid.md — AutoGrid: auto-fit responsive grid, $slots iteration, --auto-grid-min-col-size/gap tokens, semantic tag + aria
├── display-avatar.md — DisplayAvatar: circular avatar with image/initials fallback, size variants, chip badge, icon slot, styleClassPassthrough
├── card-core.md — CardCore: generic card container, dynamic named slots as rows, 4 variants, blurred backdrop layer, full CSS token API
├── action-menu.md — ActionMenu + ActionMenuItemCore: ellipsis trigger + anchored popover menu, indexed item-{n} slots, link/button items, full CSS token API
├── display-dialog.md — DisplayDialog: native <dialog> overlay, 5 variants (dialog/modal/confirm/alert/fullscreen), useDialogControls integration, CSS token API
├── display-chip.md — DisplayChip: status indicator chip overlay, CSS trig positioning, circle/square shapes, status colours, icon/label content
├── display-pill.md — DisplayPill: pill/badge label with icon slot, 6 variants, 3 sizes, reversible order, full CSS token API for border/outline/colour
Expand Down
42 changes: 40 additions & 2 deletions .claude/skills/testing-add-unit-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe("ComponentName", () => {

afterEach(() => {
wrapper?.unmount();
vi.restoreAllMocks(); // always restore vi.spyOn() stubs after each test
});

// -------------------------
Expand Down Expand Up @@ -162,7 +163,7 @@ it("exposes headingId via scoped slot", async () => {
## Key rules

- Always `mountSuspended` — never `mount` or `shallowMount` from `@vue/test-utils` directly.
- Always `afterEach(() => wrapper?.unmount())` to prevent test leaks.
- 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.
- Use a `createWrapper` helper to keep individual tests short.
- Include at least one snapshot test per meaningful visual state.
- `nextTick` is **not** auto-imported in test files — always import it explicitly: `import { nextTick } from "vue"`.
Expand Down Expand Up @@ -285,7 +286,9 @@ Import the child component directly in the test file — it is not auto-imported

## Mocking browser APIs

Mock before the `describe` block if the component uses ResizeObserver, IntersectionObserver, etc.:
### Global constructors (ResizeObserver, IntersectionObserver, etc.)

Use `vi.stubGlobal` before the `describe` block. Do **not** call `vi.unstubAllGlobals()` in `afterEach` — it removes stubs from `vitest.setup.ts` (`$fetch`, etc.):

```ts
const mockResizeObserver = vi.fn(() => ({
Expand All @@ -296,6 +299,41 @@ const mockResizeObserver = vi.fn(() => ({
vi.stubGlobal("ResizeObserver", mockResizeObserver);
```

### Prototype methods (Popover API, Canvas, etc.)

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`:

```ts
beforeEach(() => {
// vi.spyOn stubs are cleaned by vi.restoreAllMocks() in afterEach
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({} as never);

// Object.defineProperty stubs are NOT cleaned by vi.restoreAllMocks() —
// must be deleted explicitly to prevent leaking into other test files
Object.defineProperty(HTMLElement.prototype, "hidePopover", {
value: vi.fn(),
writable: true,
configurable: true,
});
Object.defineProperty(HTMLElement.prototype, "showPopover", {
value: vi.fn(),
writable: true,
configurable: true,
});
});

afterEach(() => {
wrapper?.unmount();
vi.restoreAllMocks(); // cleans up vi.spyOn stubs
// Remove Object.defineProperty prototype stubs — vi.restoreAllMocks() won't touch these
delete (HTMLElement.prototype as unknown as Record<string, unknown>)["hidePopover"];
delete (HTMLElement.prototype as unknown as Record<string, unknown>)["showPopover"];
});
```

The `as unknown as Record<string, unknown>` double-cast is required because TypeScript's
`HTMLElement` type has no index signature — cast through `unknown` first.

## Describe section conventions

Use these section names consistently so tests are easy to scan:
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ coverage/
# Storybook
storybook-static/

# Claude Code local settings (machine-specific)
# Claude Code settings (both are machine-specific, neither belongs in the repo)
.claude/settings.json
.claude/settings.local.json
Loading
Loading