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