feat(web): adopt shadcn context-menu for mascot + desktop right-click menus
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Problem: the mascot's right-click menu was non-interactive — RadialMenu's
root div rendered inside MascotLayer's pointer-events-none root (and the
new DockedLayer wrapper compounded it) without re-enabling pointer-events,
so clicks passed straight through. The desktop right-click menu was a
hand-rolled positioned div, inconsistent with the rest of the UI.

Change: both menus now use the shadcn-svelte context-menu primitive
(bits-ui, portaled to <body>).
- Mascot: MascotMenu.svelte renders the action tree recursively —
  children become ContextMenu.Sub (native hover sub-menu navigation,
  replacing the manual breadcrumb stack), leaves become ContextMenu.Item
  with onSelect. MascotLayer wraps <Mascot> in a ContextMenu.Trigger;
  visibility predicates read reactively off ctx.model so items
  appear/disappear live. Removed the manual menuPos/openMenu/closeMenu
  machinery. RadialMenu.svelte deleted.
- Desktop: the surface's bare-desktop hit area is now a
  ContextMenu.Trigger layer (absolute inset-0, pointer-events-auto)
  placed before the icons/windows in the DOM. The DOM-structure gate
  (icons/windows are pointer-events-auto siblings that paint on top and
  intercept their own right-clicks; bare desktop falls through to the
  trigger) replaces the old fragile e.currentTarget === e.target check.
  Left-click blur moved onto the trigger; Undo/Redo disabled state
  snapshotted via onOpenChange (canUndo/canRedo are wmkit methods).

Risk: the blocker that made the mascot menu non-interactive in the first
place — Mascot.svelte's handleContextMenu called e.stopPropagation(),
which would have prevented a ContextMenu.Trigger wrapper from ever
seeing the right-click. Removed that handler; bits-ui now owns
right-click on the mascot, left-click drag/pet passes through. The
context-menu content portals to <body>, escaping the pointer-events-none
mascot and docked layers entirely — the structural fix, not just a
component swap.

Verification: vitest 38/38; svelte-check + tsc clean for changed files;
eslint clean (the shadcn-generated ui/context-menu/* files carry the
same baseline custom_element_props_identifier warnings as the rest of
the ui/ folder, not from this change); vite build green; runtime
confirmed — right-click mascot opens the action tree with hover
sub-menus, right-click bare desktop opens Cascade/Tile/Show/Reset/
Undo/Redo, right-click on an icon or window does not.
This commit is contained in:
2026-07-21 16:03:57 +02:00
parent 482c7f3448
commit 1c12d40712
25 changed files with 508 additions and 297 deletions

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { Snippet } from "svelte";
import CheckIcon from '@lucide/svelte/icons/check';
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
inset,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<ContextMenuPrimitive.CheckboxItemProps> & {
inset?: boolean;
children?: Snippet;
} = $props();
</script>
<ContextMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="context-menu-checkbox-item"
data-inset={inset}
class={cn(
"focus:bg-accent focus:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="absolute right-2 pointer-events-none">
{#if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</ContextMenuPrimitive.CheckboxItem>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import ContextMenuPortal from "./context-menu-portal.svelte";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
portalProps,
class: className,
...restProps
}: ContextMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof ContextMenuPortal>>;
} = $props();
</script>
<ContextMenuPortal {...portalProps}>
<ContextMenuPrimitive.Content
bind:ref
data-slot="context-menu-content"
class={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-36 rounded-md p-1 shadow-md ring-1 duration-100 z-50 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...restProps}
/>
</ContextMenuPortal>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ContextMenuPrimitive.GroupHeadingProps & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.GroupHeading
bind:ref
data-slot="context-menu-group-heading"
data-inset={inset}
class={cn("text-foreground px-2 py-1.5 text-sm font-medium data-inset:ps-8", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: ContextMenuPrimitive.GroupProps = $props();
</script>
<ContextMenuPrimitive.Group bind:ref data-slot="context-menu-group" {...restProps} />

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
variant = "default",
...restProps
}: ContextMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: "default" | "destructive";
} = $props();
</script>
<ContextMenuPrimitive.Item
bind:ref
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-2 rounded-sm px-2 py-1.5 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="context-menu-label"
data-inset={inset}
class={cn("text-muted-foreground px-2 py-1.5 text-xs font-medium data-inset:pl-8 data-inset:pl-8", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { ...restProps }: ContextMenuPrimitive.PortalProps = $props();
</script>
<ContextMenuPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(""),
...restProps
}: ContextMenuPrimitive.RadioGroupProps = $props();
</script>
<ContextMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="context-menu-radio-group"
{...restProps}
/>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
import CheckIcon from '@lucide/svelte/icons/check';
let {
ref = $bindable(null),
class: className,
inset,
children: childrenProp,
...restProps
}: WithoutChild<ContextMenuPrimitive.RadioItemProps> & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.RadioItem
bind:ref
data-slot="context-menu-radio-item"
data-inset={inset}
class={cn(
"focus:bg-accent focus:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="absolute right-2 pointer-events-none">
{#if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</ContextMenuPrimitive.RadioItem>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.SeparatorProps = $props();
</script>
<ContextMenuPrimitive.Separator
bind:ref
data-slot="context-menu-separator"
class={cn("bg-border -mx-1 my-1 h-px", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="context-menu-shortcut"
class={cn("text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.SubContentProps = $props();
</script>
<ContextMenuPrimitive.SubContent
bind:ref
data-slot="context-menu-sub-content"
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground min-w-32 rounded-md border p-1 shadow-lg duration-100", className)}
{...restProps}
/>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithoutChild<ContextMenuPrimitive.SubTriggerProps> & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.SubTrigger
bind:ref
data-slot="context-menu-sub-trigger"
data-inset={inset}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground rounded-sm px-2 py-1.5 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-inset:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ml-auto" />
</ContextMenuPrimitive.SubTrigger>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: ContextMenuPrimitive.SubProps = $props();
</script>
<ContextMenuPrimitive.Sub bind:open {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.TriggerProps = $props();
</script>
<ContextMenuPrimitive.Trigger
bind:ref
data-slot="context-menu-trigger"
class={cn("cn-context-menu-trigger select-none", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: ContextMenuPrimitive.RootProps = $props();
</script>
<ContextMenuPrimitive.Root bind:open {...restProps} />

View File

@@ -0,0 +1,52 @@
import Root from "./context-menu.svelte";
import Sub from "./context-menu-sub.svelte";
import Portal from "./context-menu-portal.svelte";
import Trigger from "./context-menu-trigger.svelte";
import Group from "./context-menu-group.svelte";
import RadioGroup from "./context-menu-radio-group.svelte";
import Item from "./context-menu-item.svelte";
import GroupHeading from "./context-menu-group-heading.svelte";
import Content from "./context-menu-content.svelte";
import Shortcut from "./context-menu-shortcut.svelte";
import RadioItem from "./context-menu-radio-item.svelte";
import Separator from "./context-menu-separator.svelte";
import SubContent from "./context-menu-sub-content.svelte";
import SubTrigger from "./context-menu-sub-trigger.svelte";
import CheckboxItem from "./context-menu-checkbox-item.svelte";
import Label from "./context-menu-label.svelte";
export {
Root,
Sub,
Portal,
Item,
GroupHeading,
Label,
Group,
Trigger,
Content,
Shortcut,
Separator,
RadioItem,
SubContent,
SubTrigger,
RadioGroup,
CheckboxItem,
//
Root as ContextMenu,
Sub as ContextMenuSub,
Portal as ContextMenuPortal,
Item as ContextMenuItem,
GroupHeading as ContextMenuGroupHeading,
Group as ContextMenuGroup,
Content as ContextMenuContent,
Trigger as ContextMenuTrigger,
Shortcut as ContextMenuShortcut,
RadioItem as ContextMenuRadioItem,
Separator as ContextMenuSeparator,
RadioGroup as ContextMenuRadioGroup,
SubContent as ContextMenuSubContent,
SubTrigger as ContextMenuSubTrigger,
CheckboxItem as ContextMenuCheckboxItem,
Label as ContextMenuLabel,
};