From e0cf5273043d0e5e8c0e2ecf6b43122e5238dd85 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 20:19:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20RowActionsMenu=20generic=20primary?= =?UTF-8?q?=20+=20=E2=8B=AE=20for=20admin=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/components/RowActionsMenu.svelte | 114 ++++++++++++++++++ web/src/lib/components/RowActionsMenu.test.ts | 70 +++++++++++ 2 files changed, 184 insertions(+) create mode 100644 web/src/lib/components/RowActionsMenu.svelte create mode 100644 web/src/lib/components/RowActionsMenu.test.ts diff --git a/web/src/lib/components/RowActionsMenu.svelte b/web/src/lib/components/RowActionsMenu.svelte new file mode 100644 index 00000000..68886e2c --- /dev/null +++ b/web/src/lib/components/RowActionsMenu.svelte @@ -0,0 +1,114 @@ + + + + + { menuOpen = false; }} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} /> + +
+ + + + + + + + {#if secondary.length > 0} +
+ + + {#if menuOpen} + + {/if} +
+ {/if} +
diff --git a/web/src/lib/components/RowActionsMenu.test.ts b/web/src/lib/components/RowActionsMenu.test.ts new file mode 100644 index 00000000..53696512 --- /dev/null +++ b/web/src/lib/components/RowActionsMenu.test.ts @@ -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(); + }); +});