add shared unit menu components

- UnitMenuContainer for context/dropdown menu wrapper
- MenuItems for reusable action items
- GearMenuButton for hover-triggered dropdown
- shared scss styles for both menu variants
This commit is contained in:
Justin Edmund 2025-11-28 11:03:52 -08:00
parent e582629552
commit 9a4a863ccd
5 changed files with 368 additions and 0 deletions

View file

@ -0,0 +1,36 @@
<script lang="ts">
import { ContextMenu } from 'bits-ui'
import type { Snippet } from 'svelte'
interface ContextMenuWrapperProps {
trigger: Snippet
menu: Snippet
open?: boolean
}
let { trigger, menu, open = $bindable(false) }: ContextMenuWrapperProps = $props()
</script>
<ContextMenu.Root bind:open>
<ContextMenu.Trigger>
{#snippet child({ props })}
<div class="context-trigger" {...props}>
{@render trigger()}
</div>
{/snippet}
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content class="context-menu">
{@render menu()}
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
<style lang="scss">
@use './menu-styles.scss';
.context-trigger {
display: block;
}
</style>

View file

@ -0,0 +1,123 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui'
import type { Snippet } from 'svelte'
import gearIcon from '$src/assets/icons/gear.svg'
interface GearMenuButtonProps {
menu: Snippet
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
showOnHover?: boolean
open?: boolean
}
let {
menu,
position = 'top-left',
showOnHover = true,
open = $bindable(false)
}: GearMenuButtonProps = $props()
</script>
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger class="gear-button-trigger" data-position={position}>
{#snippet child({ props })}
<button
{...props}
class="gear-button"
class:show-on-hover={showOnHover}
data-position={position}
oncontextmenu={(e) => e.preventDefault()}
type="button"
>
<img src={gearIcon} alt="Options" />
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="dropdown-menu" side="bottom" align="start" sideOffset={4}>
{@render menu()}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<style lang="scss">
@use './menu-styles.scss';
@use '$src/themes/spacing' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/effects' as *;
:global(.gear-button-trigger) {
width: 32px;
height: 32px;
z-index: 10;
opacity: 0;
pointer-events: none;
@include smooth-transition($duration-standard, opacity);
&[data-state='open'] {
opacity: 1;
pointer-events: auto;
}
}
.gear-button {
position: absolute;
z-index: 99;
width: 32px;
height: 32px;
padding: 0;
border: none;
background: rgba(0, 0, 0, 0.6);
border-radius: $item-corner;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
@include smooth-transition($duration-standard, all);
// Position based on data attribute
&[data-position='top-left'] {
top: $unit;
left: $unit;
}
&[data-position='top-right'] {
top: $unit;
right: $unit;
}
&[data-position='bottom-left'] {
bottom: $unit;
left: $unit;
}
&[data-position='bottom-right'] {
bottom: $unit;
right: $unit;
}
&.show-on-hover {
opacity: 0;
pointer-events: none;
}
&[data-state='open'] {
opacity: 1;
pointer-events: auto;
}
&:hover {
background: rgba(0, 0, 0, 0.8);
transform: scale(1.05);
}
img {
width: 16px;
height: 16px;
display: block;
color: white;
filter: brightness(0) invert(1);
}
}
</style>

View file

@ -0,0 +1,52 @@
<script lang="ts">
import { ContextMenu, DropdownMenu } from 'bits-ui'
interface MenuItemsProps {
onViewDetails?: () => void
onReplace?: () => void
onRemove?: () => void
canEdit?: boolean
variant?: 'context' | 'dropdown'
viewDetailsLabel?: string
replaceLabel?: string
removeLabel?: string
}
let {
onViewDetails,
onReplace,
onRemove,
canEdit = false,
variant = 'context',
viewDetailsLabel = 'View Details',
replaceLabel = 'Replace',
removeLabel = 'Remove'
}: MenuItemsProps = $props()
// Select the appropriate component based on variant
const Item = variant === 'context' ? ContextMenu.Item : DropdownMenu.Item
const Separator = variant === 'context' ? ContextMenu.Separator : DropdownMenu.Separator
const itemClass = variant === 'context' ? 'context-menu-item' : 'dropdown-menu-item'
const separatorClass = variant === 'context' ? 'context-menu-separator' : 'dropdown-menu-separator'
</script>
{#if onViewDetails}
<Item class={itemClass} onclick={onViewDetails}>
{viewDetailsLabel}
</Item>
{/if}
{#if canEdit}
{#if onReplace}
<Item class={itemClass} onclick={onReplace}>
{replaceLabel}
</Item>
{/if}
{#if onRemove}
<Separator class={separatorClass} />
<Item class="{itemClass} danger" onclick={onRemove}>
{removeLabel}
</Item>
{/if}
{/if}

View file

@ -0,0 +1,55 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import ContextMenuWrapper from './ContextMenuWrapper.svelte'
import GearMenuButton from './GearMenuButton.svelte'
interface UnitMenuContainerProps {
trigger: Snippet
contextMenu: Snippet
dropdownMenu?: Snippet
showGearButton?: boolean
gearPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
}
let {
trigger,
contextMenu,
dropdownMenu,
showGearButton = false,
gearPosition = 'top-left'
}: UnitMenuContainerProps = $props()
let contextMenuOpen = $state(false)
let gearMenuOpen = $state(false)
// If no dropdown menu is provided, use the context menu for both
const effectiveDropdownMenu = dropdownMenu ?? contextMenu
</script>
<div class="unit-menu-container">
<ContextMenuWrapper trigger={trigger} menu={contextMenu} bind:open={contextMenuOpen} />
{#if showGearButton}
<GearMenuButton menu={effectiveDropdownMenu} position={gearPosition} bind:open={gearMenuOpen} />
{/if}
</div>
<style lang="scss">
@use './menu-styles.scss';
.unit-menu-container {
position: relative;
display: inline-block;
// Show gear button on hover of the container
&:hover :global(.gear-button-trigger) {
opacity: 1;
pointer-events: auto;
}
&:hover :global(.gear-button.show-on-hover) {
opacity: 1;
pointer-events: auto;
}
}
</style>

View file

@ -0,0 +1,102 @@
@use '$src/themes/spacing' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/effects' as *;
// Shared menu container styles
@mixin menu-container {
background: var(--menu-bg);
border: 1px solid var(--border-subtle);
border-radius: $card-corner;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: $unit-half;
min-width: calc($unit * 22.5);
z-index: 200;
animation: slideIn $duration-quick ease-out;
}
// Shared menu item styles
@mixin menu-item {
padding: $unit $unit-2x;
border-radius: $item-corner-small;
cursor: pointer;
font-size: $font-regular;
color: var(--menu-text);
display: flex;
align-items: center;
gap: $unit;
@include smooth-transition($duration-standard, background);
&:hover {
background: var(--menu-bg-item-hover);
}
&:first-child {
border-top-left-radius: $item-corner;
border-top-right-radius: $item-corner;
}
&:last-child {
border-bottom-left-radius: $item-corner;
border-bottom-right-radius: $item-corner;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.danger {
color: var(--danger);
&:hover {
background: var(--danger-bg);
}
}
}
// Shared menu separator styles
@mixin menu-separator {
border-radius: $full-corner;
height: 2px;
background: var(--menu-separator);
margin: $unit-half ($unit * 0.75);
}
// Slide in animation
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-$unit-fourth);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Global styles for context menu
:global(.context-menu) {
@include menu-container;
}
:global(.context-menu-item) {
@include menu-item;
}
:global(.context-menu-separator) {
@include menu-separator;
}
// Global styles for dropdown menu
:global(.dropdown-menu) {
@include menu-container;
}
:global(.dropdown-menu-item) {
@include menu-item;
}
:global(.dropdown-menu-separator) {
@include menu-separator;
}