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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user