Skip to content
This repository was archived by the owner on Feb 28, 2026. It is now read-only.

Commit f229fb0

Browse files
committed
Popover menus and export functionality
1 parent add45cb commit f229fb0

File tree

11 files changed

+297
-25
lines changed

11 files changed

+297
-25
lines changed

packages/player/src/components/ConnectedQueuePanel.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,14 @@ export const QueueHeaderActions: FC = () => {
9494
}
9595
anchor="bottom end"
9696
>
97-
<button
98-
className="hover:bg-background-secondary flex w-full cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-left text-sm whitespace-nowrap"
99-
onClick={() => setSaveDialogOpen(true)}
100-
data-testid="save-queue-as-playlist"
101-
>
102-
{t('actions.saveAsPlaylist')}
103-
</button>
97+
<Popover.Menu>
98+
<Popover.Item
99+
onClick={() => setSaveDialogOpen(true)}
100+
data-testid="save-queue-as-playlist"
101+
>
102+
{t('actions.saveAsPlaylist')}
103+
</Popover.Item>
104+
</Popover.Menu>
104105
</Popover>
105106
<Dialog.Root
106107
isOpen={saveDialogOpen}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { save } from '@tauri-apps/plugin-dialog';
2+
import { writeTextFile } from '@tauri-apps/plugin-fs';
3+
import { useCallback } from 'react';
4+
5+
import { usePlaylistStore } from '../stores/playlistStore';
6+
import { reportError } from '../utils/logging';
7+
8+
export const usePlaylistExport = (playlistId: string) => {
9+
const playlist = usePlaylistStore((state) => state.playlists.get(playlistId));
10+
11+
const exportAsJson = useCallback(async () => {
12+
try {
13+
const filePath = await save({
14+
defaultPath: `${playlist?.name ?? 'playlist'}.json`,
15+
filters: [{ name: 'JSON Files', extensions: ['json'] }],
16+
});
17+
18+
if (!filePath) {
19+
return;
20+
}
21+
22+
const { items, ...rest } = playlist!;
23+
const exportData = { ...rest, tracks: items };
24+
await writeTextFile(filePath, JSON.stringify(exportData, null, 2));
25+
} catch (error) {
26+
await reportError('playlists', {
27+
userMessage: 'Failed to export playlist',
28+
error,
29+
});
30+
}
31+
}, [playlist]);
32+
33+
return { exportAsJson };
34+
};

packages/player/src/views/PlaylistDetail/components/PlaylistDetailActions.tsx

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { useNavigate } from '@tanstack/react-router';
2-
import { EllipsisVerticalIcon, PlayIcon, Trash2Icon } from 'lucide-react';
2+
import {
3+
EllipsisVerticalIcon,
4+
PlayIcon,
5+
ShareIcon,
6+
Trash2Icon,
7+
} from 'lucide-react';
38
import { useState, type FC } from 'react';
49

510
import { useTranslation } from '@nuclearplayer/i18n';
611
import type { Track } from '@nuclearplayer/model';
712
import { Button, Dialog, Popover } from '@nuclearplayer/ui';
813

14+
import { usePlaylistExport } from '../../../hooks/usePlaylistExport';
915
import { useQueueActions } from '../../../hooks/useQueueActions';
1016
import { usePlaylistStore } from '../../../stores/playlistStore';
1117
import { useSoundStore } from '../../../stores/soundStore';
@@ -24,6 +30,7 @@ export const PlaylistDetailActions: FC<PlaylistDetailActionsProps> = ({
2430
const { addToQueue, clearQueue } = useQueueActions();
2531
const deletePlaylist = usePlaylistStore((state) => state.deletePlaylist);
2632
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
33+
const { exportAsJson } = usePlaylistExport(playlistId);
2734

2835
const handlePlayAll = () => {
2936
clearQueue();
@@ -50,31 +57,37 @@ export const PlaylistDetailActions: FC<PlaylistDetailActionsProps> = ({
5057
</Button>
5158
<Popover
5259
className="relative"
53-
panelClassName="bg-background px-0 py-1"
60+
panelClassName="bg-background px-0 py-0"
5461
trigger={
5562
<Button size="icon" data-testid="playlist-actions-button">
5663
<EllipsisVerticalIcon size={16} />
5764
</Button>
5865
}
5966
anchor="bottom start"
6067
>
61-
<div className="flex flex-col">
62-
<button
63-
className="hover:border-border hover:bg-background-secondary flex w-full cursor-pointer items-center gap-3 border-t border-transparent px-3 py-2 text-left text-sm not-last:border-b"
68+
<Popover.Menu>
69+
<Popover.Item
6470
onClick={handleAddToQueue}
6571
data-testid="add-to-queue-action"
6672
>
6773
{t('addToQueue')}
68-
</button>
69-
<button
70-
className="text-accent-red hover:border-border hover:bg-background-secondary flex w-full cursor-pointer items-center gap-3 border-t border-transparent px-3 py-2 text-left text-sm not-last:border-b"
74+
</Popover.Item>
75+
<Popover.Item
76+
icon={<ShareIcon size={16} />}
77+
onClick={exportAsJson}
78+
data-testid="export-json-action"
79+
>
80+
{t('exportJson')}
81+
</Popover.Item>
82+
<Popover.Item
83+
intent="danger"
84+
icon={<Trash2Icon size={16} />}
7185
onClick={() => setIsDeleteDialogOpen(true)}
7286
data-testid="delete-playlist-action"
7387
>
74-
<Trash2Icon size={16} />
7588
{t('delete')}
76-
</button>
77-
</div>
89+
</Popover.Item>
90+
</Popover.Menu>
7891
</Popover>
7992
</div>
8093
<Dialog.Root

packages/player/src/views/Playlists/Playlists.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,23 +34,22 @@ const PlaylistsContent: FC = () => {
3434
</Button>
3535
<Popover
3636
className="relative"
37-
panelClassName="bg-background px-0 py-1"
37+
panelClassName="bg-background px-0 py-0"
3838
trigger={
3939
<Button size="icon" data-testid="import-playlist-button">
4040
<Import size={16} />
4141
</Button>
4242
}
4343
anchor="bottom start"
4444
>
45-
<div className="flex flex-col">
46-
<button
47-
className="hover:border-border hover:bg-background-secondary flex w-full cursor-pointer items-center gap-3 border-t border-transparent px-3 py-2 text-left text-sm not-last:border-b"
45+
<Popover.Menu>
46+
<Popover.Item
4847
onClick={importFromJson}
4948
data-testid="import-json-option"
5049
>
5150
{t('importJson')}
52-
</button>
53-
</div>
51+
</Popover.Item>
52+
</Popover.Menu>
5453
</Popover>
5554
</div>
5655

packages/player/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export default defineConfig(() => ({
4343
},
4444
test: {
4545
globals: true,
46+
clearMocks: true,
4647
environment: 'jsdom',
4748
setupFiles: ['./src/test/setup.ts'],
4849
coverage: {

packages/storybook/src/Popover.stories.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Meta, StoryObj } from '@storybook/react-vite';
2+
import { PencilIcon, Trash2Icon } from 'lucide-react';
23

34
import { Button, Popover } from '@nuclearplayer/ui';
45

@@ -52,3 +53,33 @@ export const AllAnchors: Story = {
5253
</div>
5354
),
5455
};
56+
57+
export const DropdownMenu: Story = {
58+
render: () => (
59+
<Popover
60+
className="relative"
61+
panelClassName="bg-background px-0 py-0"
62+
trigger={<Button>Actions</Button>}
63+
anchor="bottom"
64+
>
65+
<Popover.Menu>
66+
<Popover.Item
67+
icon={<PencilIcon size={16} />}
68+
onClick={() => console.log('edit')}
69+
>
70+
Edit
71+
</Popover.Item>
72+
<Popover.Item onClick={() => console.log('duplicate')}>
73+
Duplicate
74+
</Popover.Item>
75+
<Popover.Item
76+
intent="danger"
77+
icon={<Trash2Icon size={16} />}
78+
onClick={() => console.log('delete')}
79+
>
80+
Delete
81+
</Popover.Item>
82+
</Popover.Menu>
83+
</Popover>
84+
),
85+
};

packages/ui/src/components/Popover/Popover.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,33 @@ describe('Popover', () => {
3030
await screen.findByText('Popover Content');
3131
expect(document.body).toMatchSnapshot();
3232
});
33+
34+
it('(Snapshot) renders menu with items', async () => {
35+
render(
36+
<Popover trigger={<Button>Open</Button>} anchor="bottom">
37+
<Popover.Menu>
38+
<Popover.Item>Action One</Popover.Item>
39+
<Popover.Item>Action Two</Popover.Item>
40+
<Popover.Item intent="danger">Delete</Popover.Item>
41+
</Popover.Menu>
42+
</Popover>,
43+
);
44+
await userEvent.click(screen.getByText('Open'));
45+
await screen.findByText('Action One');
46+
expect(document.body).toMatchSnapshot();
47+
});
48+
49+
it('calls onClick when a menu item is clicked', async () => {
50+
const handleClick = vi.fn();
51+
render(
52+
<Popover trigger={<Button>Open</Button>} anchor="bottom">
53+
<Popover.Menu>
54+
<Popover.Item onClick={handleClick}>Action</Popover.Item>
55+
</Popover.Menu>
56+
</Popover>,
57+
);
58+
await userEvent.click(screen.getByText('Open'));
59+
await userEvent.click(screen.getByText('Action'));
60+
expect(handleClick).toHaveBeenCalledOnce();
61+
});
3362
});

packages/ui/src/components/Popover/Popover.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { AnimatePresence, motion } from 'framer-motion';
99
import { FC, ReactNode } from 'react';
1010

1111
import { cn } from '../../utils';
12+
import { PopoverItem } from './PopoverItem';
13+
import { PopoverMenu } from './PopoverMenu';
1214

1315
export type PopoverProps = {
1416
trigger: ReactNode;
@@ -19,7 +21,12 @@ export type PopoverProps = {
1921
backdrop?: boolean;
2022
};
2123

22-
export const Popover: FC<PopoverProps> = ({
24+
type PopoverComponent = FC<PopoverProps> & {
25+
Item: typeof PopoverItem;
26+
Menu: typeof PopoverMenu;
27+
};
28+
29+
const PopoverImpl: FC<PopoverProps> = ({
2330
trigger,
2431
children,
2532
className,
@@ -71,3 +78,7 @@ export const Popover: FC<PopoverProps> = ({
7178
</HeadlessPopover>
7279
);
7380
};
81+
82+
export const Popover = PopoverImpl as PopoverComponent;
83+
Popover.Item = PopoverItem;
84+
Popover.Menu = PopoverMenu;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { cva, VariantProps } from 'class-variance-authority';
2+
import { ComponentProps, FC, ReactNode } from 'react';
3+
4+
import { cn } from '../../utils';
5+
6+
const popoverItemVariants = cva(
7+
'hover:bg-background-secondary hover:border-border flex w-full cursor-pointer items-center gap-3 border-t border-transparent px-3 py-2 text-left text-sm outline-none not-last:border-b first:border-t-0 last:border-b-0',
8+
{
9+
variants: {
10+
intent: {
11+
default: '',
12+
danger: 'text-accent-red',
13+
},
14+
},
15+
defaultVariants: {
16+
intent: 'default',
17+
},
18+
},
19+
);
20+
21+
type PopoverItemProps = Omit<ComponentProps<'button'>, 'children'> &
22+
VariantProps<typeof popoverItemVariants> & {
23+
icon?: ReactNode;
24+
children: ReactNode;
25+
};
26+
27+
export const PopoverItem: FC<PopoverItemProps> = ({
28+
className,
29+
intent,
30+
icon,
31+
children,
32+
...props
33+
}) => (
34+
<button className={cn(popoverItemVariants({ intent, className }))} {...props}>
35+
<span className="shrink-0">{icon}</span>
36+
<span>{children}</span>
37+
</button>
38+
);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { ComponentProps, FC } from 'react';
2+
3+
import { cn } from '../../utils';
4+
5+
type PopoverMenuProps = ComponentProps<'div'>;
6+
7+
export const PopoverMenu: FC<PopoverMenuProps> = ({ className, ...props }) => (
8+
<div
9+
className={cn('flex min-w-48 flex-col overflow-hidden', className)}
10+
{...props}
11+
/>
12+
);

0 commit comments

Comments
 (0)