feat(web): RowActionsMenu generic primary + ⋮ for admin tables

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 20:19:09 -04:00
parent ed6d0936ca
commit e0cf527304
2 changed files with 184 additions and 0 deletions
@@ -0,0 +1,114 @@
<script lang="ts" module>
import type { ComponentType, SvelteComponent } from 'svelte';
type IconProp = ComponentType<SvelteComponent>;
export interface RowAction {
icon: IconProp;
label: string;
onclick: () => void;
danger?: boolean;
disabled?: boolean;
}
</script>
<script lang="ts">
import { MoreVertical } from 'lucide-svelte';
import TrackMenuItem from './TrackMenuItem.svelte';
let {
primary,
secondary
}: {
/** The most-likely action; rendered inline as a primary button. */
primary: RowAction;
/** Other actions; rendered inline above md, in a popover below md. */
secondary: RowAction[];
} = $props();
let menuOpen = $state(false);
function toggleMenu(e: MouseEvent) {
e.stopPropagation();
menuOpen = !menuOpen;
}
function fire(action: RowAction) {
if (action.disabled) return;
menuOpen = false;
action.onclick();
}
</script>
<svelte:window onclick={() => { menuOpen = false; }} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
<div class="flex items-center gap-2">
<!-- Primary action: always inline -->
<button
type="button"
disabled={primary.disabled}
onclick={() => fire(primary)}
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm
text-action-fg disabled:opacity-50 min-h-[44px] md:min-h-0"
>
<primary.icon size={14} strokeWidth={1} />
{primary.label}
</button>
<!-- Secondary actions: inline above md, popover below md -->
<div class="hidden md:flex items-center gap-2">
{#each secondary as action (action.label)}
<button
type="button"
disabled={action.disabled}
onclick={() => fire(action)}
class="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm
disabled:opacity-50
{action.danger
? 'border-action-destructive text-action-destructive hover:bg-action-destructive/10'
: 'border-border text-text-secondary hover:text-text-primary hover:bg-surface-hover'}"
>
<action.icon size={14} strokeWidth={1} />
{action.label}
</button>
{/each}
</div>
<!-- Mobile overflow trigger -->
{#if secondary.length > 0}
<div class="relative md:hidden inline-block">
<button
type="button"
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={menuOpen}
onclick={toggleMenu}
class="rounded p-1 text-text-secondary hover:text-text-primary
min-h-[44px] min-w-[44px] flex items-center justify-center"
>
<MoreVertical size={18} strokeWidth={1} />
</button>
{#if menuOpen}
<div
role="menu"
tabindex="-1"
class="absolute right-0 top-full z-20 mt-1 w-56 rounded-md border border-border
bg-surface p-1 shadow-lg"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
{#each secondary as action (action.label)}
<TrackMenuItem
icon={action.icon}
label={action.label}
onclick={() => fire(action)}
danger={action.danger}
disabled={action.disabled}
/>
{/each}
</div>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { Check, X, Trash2 } from 'lucide-svelte';
import RowActionsMenu from './RowActionsMenu.svelte';
describe('RowActionsMenu', () => {
it('renders the primary action inline', () => {
const onApprove = vi.fn();
render(RowActionsMenu, {
props: {
primary: { icon: Check, label: 'Approve', onclick: onApprove },
secondary: []
}
});
expect(screen.getByText('Approve')).toBeInTheDocument();
});
it('fires the primary action onclick', async () => {
const onApprove = vi.fn();
render(RowActionsMenu, {
props: {
primary: { icon: Check, label: 'Approve', onclick: onApprove },
secondary: []
}
});
await fireEvent.click(screen.getByText('Approve'));
expect(onApprove).toHaveBeenCalledOnce();
});
it('renders secondary action labels and exposes the overflow trigger', () => {
render(RowActionsMenu, {
props: {
primary: { icon: Check, label: 'Approve', onclick: vi.fn() },
secondary: [
{ icon: X, label: 'Reassign', onclick: vi.fn() },
{ icon: Trash2, label: 'Delete', onclick: vi.fn(), danger: true }
]
}
});
// Both inline (md:flex) and overflow-trigger (md:hidden) markup are
// present in jsdom; the test confirms labels exist and the overflow
// trigger is in the DOM.
expect(screen.getAllByText('Reassign').length).toBeGreaterThan(0);
expect(screen.getAllByText('Delete').length).toBeGreaterThan(0);
expect(screen.getByLabelText('More actions')).toBeInTheDocument();
});
it('opens the overflow menu when ⋮ is clicked', async () => {
render(RowActionsMenu, {
props: {
primary: { icon: Check, label: 'Approve', onclick: vi.fn() },
secondary: [{ icon: X, label: 'Reassign', onclick: vi.fn() }]
}
});
const trigger = screen.getByLabelText('More actions');
expect(trigger.getAttribute('aria-expanded')).toBe('false');
await fireEvent.click(trigger);
expect(trigger.getAttribute('aria-expanded')).toBe('true');
});
it('omits the overflow trigger when secondary is empty', () => {
render(RowActionsMenu, {
props: {
primary: { icon: Check, label: 'Approve', onclick: vi.fn() },
secondary: []
}
});
expect(screen.queryByLabelText('More actions')).not.toBeInTheDocument();
});
});