Skip to content

Commit cc2fa8d

Browse files
author
Ignacio Van Droogenbroeck
committed
fix(admin): replace bits-ui dropdown with dependency-free ActionMenu
The bits-ui/melt-ui DropdownMenu never opened in Safari/WebKit (the kebab menus on Orgs and Team did nothing). Replaced with a simple, self-contained ActionMenu (boolean-toggled positioned div + click-outside) that has no external dependencies to fail. Same items, same actions.
1 parent 6c97b07 commit cc2fa8d

6 files changed

Lines changed: 165 additions & 76 deletions

File tree

package-lock.json

Lines changed: 49 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@types/qrcode": "^1.5.6",
2626
"@types/uuid": "^10.0.0",
2727
"autoprefixer": "^10.4.22",
28+
"playwright": "^1.61.1",
2829
"postcss": "^8.5.6",
2930
"svelte": "^4.0.0",
3031
"svelte-check": "^3.6.0",
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<script lang="ts">
2+
import { MoreHorizontal } from 'lucide-svelte';
3+
import { tick } from 'svelte';
4+
5+
// A minimal action (kebab) menu: a button that toggles a positioned panel.
6+
// Deliberately dependency-free (no bits-ui/melt-ui/floating-ui) so it can't
7+
// be broken by their interactions — the items are just rendered via a slot.
8+
export let align: 'start' | 'end' = 'end';
9+
10+
let open = false;
11+
let root: HTMLDivElement;
12+
13+
async function toggle() {
14+
open = !open;
15+
if (open) {
16+
await tick();
17+
}
18+
}
19+
20+
function close() {
21+
open = false;
22+
}
23+
24+
function onWindowClick(e: MouseEvent) {
25+
if (open && root && !root.contains(e.target as Node)) close();
26+
}
27+
28+
function onKey(e: KeyboardEvent) {
29+
if (e.key === 'Escape') close();
30+
}
31+
</script>
32+
33+
<svelte:window on:click={onWindowClick} on:keydown={onKey} />
34+
35+
<div class="relative inline-block" bind:this={root}>
36+
<button
37+
type="button"
38+
aria-haspopup="menu"
39+
aria-expanded={open}
40+
on:click|stopPropagation={toggle}
41+
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
42+
>
43+
<MoreHorizontal class="h-4 w-4" />
44+
<span class="sr-only">Open menu</span>
45+
</button>
46+
47+
{#if open}
48+
<!-- Close after any item is activated. Items are <button>s, so this
49+
click delegation is keyboard-accessible via the buttons themselves. -->
50+
<div
51+
role="menu"
52+
tabindex="-1"
53+
class="absolute z-50 mt-1 min-w-[10rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md {align === 'end' ? 'right-0' : 'left-0'}"
54+
on:click={close}
55+
on:keydown={(e) => e.key === 'Enter' && close()}
56+
>
57+
<slot />
58+
</div>
59+
{/if}
60+
</div>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<script lang="ts">
2+
// A single clickable row inside ActionMenu. `destructive` styles it red.
3+
export let destructive = false;
4+
export let disabled = false;
5+
</script>
6+
7+
<button
8+
type="button"
9+
role="menuitem"
10+
{disabled}
11+
on:click
12+
class="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 {destructive
13+
? 'text-destructive'
14+
: ''}"
15+
>
16+
<slot />
17+
</button>

src/routes/(app)/orgs/+page.svelte

Lines changed: 22 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
import { Badge } from '$lib/components/ui/badge';
44
import { Button } from '$lib/components/ui/button';
55
import * as AlertDialog from '$lib/components/ui/alert-dialog';
6-
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
6+
import ActionMenu from '$lib/components/ActionMenu.svelte';
7+
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
78
import { toast } from 'svelte-sonner';
8-
import { MoreHorizontal, Mail, Copy, Check, ChevronRight, ChevronDown, Plus, Building2 } from 'lucide-svelte';
9+
import { Mail, Copy, Check, ChevronRight, ChevronDown, Plus, Building2 } from 'lucide-svelte';
910
import { timezone, formatDateTime } from '$lib/stores/timezone';
1011
1112
export let data: any;
@@ -361,16 +362,10 @@
361362
</button>
362363
<div class="flex items-center gap-3">
363364
<span class="hidden text-xs text-muted-foreground sm:inline">{org.owner_email ?? ''}</span>
364-
<DropdownMenu.Root>
365-
<DropdownMenu.Trigger asChild let:builder>
366-
<Button builders={[builder]} variant="ghost" size="sm"><MoreHorizontal class="h-4 w-4" /></Button>
367-
</DropdownMenu.Trigger>
368-
<DropdownMenu.Content align="end">
369-
<DropdownMenu.Item on:click={() => renameOrg(org)}>Rename</DropdownMenu.Item>
370-
<DropdownMenu.Separator />
371-
<DropdownMenu.Item class="text-destructive" on:click={() => deleteOrg(org)}>Delete</DropdownMenu.Item>
372-
</DropdownMenu.Content>
373-
</DropdownMenu.Root>
365+
<ActionMenu align="end">
366+
<ActionMenuItem on:click={() => renameOrg(org)}>Rename</ActionMenuItem>
367+
<ActionMenuItem destructive on:click={() => deleteOrg(org)}>Delete</ActionMenuItem>
368+
</ActionMenu>
374369
</div>
375370
</div>
376371

@@ -435,22 +430,16 @@
435430
<Badge variant={m.role === 'owner' ? 'default' : m.role === 'admin' ? 'secondary' : 'outline'}>{m.role}</Badge>
436431
</td>
437432
<td class="py-2 text-right">
438-
<DropdownMenu.Root>
439-
<DropdownMenu.Trigger asChild let:builder>
440-
<Button builders={[builder]} variant="ghost" size="sm"><MoreHorizontal class="h-4 w-4" /></Button>
441-
</DropdownMenu.Trigger>
442-
<DropdownMenu.Content align="end">
443-
{#each ['owner', 'admin', 'member', 'viewer'] as r}
444-
<DropdownMenu.Item disabled={m.role === r} on:click={() => setMemberRole(org.id, m, r)}>
445-
Make {r}
446-
</DropdownMenu.Item>
447-
{/each}
448-
<DropdownMenu.Separator />
449-
<DropdownMenu.Item class="text-destructive" disabled={m.role === 'owner'} on:click={() => removeMember(org.id, m)}>
450-
Remove
451-
</DropdownMenu.Item>
452-
</DropdownMenu.Content>
453-
</DropdownMenu.Root>
433+
<ActionMenu align="end">
434+
{#each ['owner', 'admin', 'member', 'viewer'] as r}
435+
<ActionMenuItem disabled={m.role === r} on:click={() => setMemberRole(org.id, m, r)}>
436+
Make {r}
437+
</ActionMenuItem>
438+
{/each}
439+
<ActionMenuItem destructive disabled={m.role === 'owner'} on:click={() => removeMember(org.id, m)}>
440+
Remove
441+
</ActionMenuItem>
442+
</ActionMenu>
454443
</td>
455444
</tr>
456445
{/each}
@@ -541,17 +530,11 @@
541530
<td class="px-4 py-3 text-sm text-muted-foreground">{formatDateTime(user.created_at, $timezone)}</td>
542531
<td class="px-4 py-3 text-right">
543532
{#if user.id !== data.currentUserId}
544-
<DropdownMenu.Root>
545-
<DropdownMenu.Trigger asChild let:builder>
546-
<Button builders={[builder]} variant="ghost" size="sm"><MoreHorizontal class="h-4 w-4" /></Button>
547-
</DropdownMenu.Trigger>
548-
<DropdownMenu.Content align="end">
549-
<DropdownMenu.Item on:click={() => toggleSuperAdmin(user)}>{user.is_operator ? 'Revoke super-admin' : 'Make super-admin'}</DropdownMenu.Item>
550-
<DropdownMenu.Item on:click={() => toggleSuspend(user)}>{user.suspended_at ? 'Reactivate' : 'Suspend'}</DropdownMenu.Item>
551-
<DropdownMenu.Separator />
552-
<DropdownMenu.Item class="text-destructive" on:click={() => deleteUser(user)}>Delete</DropdownMenu.Item>
553-
</DropdownMenu.Content>
554-
</DropdownMenu.Root>
533+
<ActionMenu align="end">
534+
<ActionMenuItem on:click={() => toggleSuperAdmin(user)}>{user.is_operator ? 'Revoke super-admin' : 'Make super-admin'}</ActionMenuItem>
535+
<ActionMenuItem on:click={() => toggleSuspend(user)}>{user.suspended_at ? 'Reactivate' : 'Suspend'}</ActionMenuItem>
536+
<ActionMenuItem destructive on:click={() => deleteUser(user)}>Delete</ActionMenuItem>
537+
</ActionMenu>
555538
{:else}
556539
<span class="text-xs text-muted-foreground">You</span>
557540
{/if}

src/routes/(app)/team/+page.svelte

Lines changed: 16 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
import { Badge } from '$lib/components/ui/badge';
33
import { Button } from '$lib/components/ui/button';
44
import * as AlertDialog from '$lib/components/ui/alert-dialog';
5-
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
5+
import ActionMenu from '$lib/components/ActionMenu.svelte';
6+
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
67
import * as Tooltip from '$lib/components/ui/tooltip';
78
import { toast } from 'svelte-sonner';
8-
import { MoreHorizontal, Mail, Shield, User, Crown, Eye, X, Info } from 'lucide-svelte';
9+
import { Mail, Shield, User, Crown, Eye, X, Info } from 'lucide-svelte';
910
import { onMount } from 'svelte';
1011
import { timezone, formatDateTime } from '$lib/stores/timezone';
1112
@@ -268,41 +269,19 @@
268269
<div class="flex items-center gap-2">
269270
<Badge variant={roleBadgeVariant(member.role)}>{member.role}</Badge>
270271
{#if isOwner && member.role !== 'owner' && member.id !== data.user.id}
271-
<DropdownMenu.Root>
272-
<DropdownMenu.Trigger>
273-
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground">
274-
<MoreHorizontal class="h-4 w-4" />
275-
</button>
276-
</DropdownMenu.Trigger>
277-
<DropdownMenu.Content align="end">
278-
{#if member.role === 'viewer'}
279-
<DropdownMenu.Item on:click={() => changeRole(member, 'member')}>
280-
Make member
281-
</DropdownMenu.Item>
282-
<DropdownMenu.Item on:click={() => changeRole(member, 'admin')}>
283-
Make admin
284-
</DropdownMenu.Item>
285-
{:else if member.role === 'member'}
286-
<DropdownMenu.Item on:click={() => changeRole(member, 'viewer')}>
287-
Make viewer
288-
</DropdownMenu.Item>
289-
<DropdownMenu.Item on:click={() => changeRole(member, 'admin')}>
290-
Make admin
291-
</DropdownMenu.Item>
292-
{:else if member.role === 'admin'}
293-
<DropdownMenu.Item on:click={() => changeRole(member, 'viewer')}>
294-
Make viewer
295-
</DropdownMenu.Item>
296-
<DropdownMenu.Item on:click={() => changeRole(member, 'member')}>
297-
Make member
298-
</DropdownMenu.Item>
299-
{/if}
300-
<DropdownMenu.Separator />
301-
<DropdownMenu.Item class="text-destructive" on:click={() => confirmRemove(member)}>
302-
Remove from org
303-
</DropdownMenu.Item>
304-
</DropdownMenu.Content>
305-
</DropdownMenu.Root>
272+
<ActionMenu align="end">
273+
{#if member.role === 'viewer'}
274+
<ActionMenuItem on:click={() => changeRole(member, 'member')}>Make member</ActionMenuItem>
275+
<ActionMenuItem on:click={() => changeRole(member, 'admin')}>Make admin</ActionMenuItem>
276+
{:else if member.role === 'member'}
277+
<ActionMenuItem on:click={() => changeRole(member, 'viewer')}>Make viewer</ActionMenuItem>
278+
<ActionMenuItem on:click={() => changeRole(member, 'admin')}>Make admin</ActionMenuItem>
279+
{:else if member.role === 'admin'}
280+
<ActionMenuItem on:click={() => changeRole(member, 'viewer')}>Make viewer</ActionMenuItem>
281+
<ActionMenuItem on:click={() => changeRole(member, 'member')}>Make member</ActionMenuItem>
282+
{/if}
283+
<ActionMenuItem destructive on:click={() => confirmRemove(member)}>Remove from org</ActionMenuItem>
284+
</ActionMenu>
306285
{:else if data.currentRole === 'admin' && (member.role === 'member' || member.role === 'viewer') && member.id !== data.user.id}
307286
<button
308287
on:click={() => confirmRemove(member)}

0 commit comments

Comments
 (0)