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:
parent
e582629552
commit
9a4a863ccd
5 changed files with 368 additions and 0 deletions
36
src/lib/components/ui/menu/ContextMenuWrapper.svelte
Normal file
36
src/lib/components/ui/menu/ContextMenuWrapper.svelte
Normal 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>
|
||||||
123
src/lib/components/ui/menu/GearMenuButton.svelte
Normal file
123
src/lib/components/ui/menu/GearMenuButton.svelte
Normal 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>
|
||||||
52
src/lib/components/ui/menu/MenuItems.svelte
Normal file
52
src/lib/components/ui/menu/MenuItems.svelte
Normal 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}
|
||||||
55
src/lib/components/ui/menu/UnitMenuContainer.svelte
Normal file
55
src/lib/components/ui/menu/UnitMenuContainer.svelte
Normal 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>
|
||||||
102
src/lib/components/ui/menu/menu-styles.scss
Normal file
102
src/lib/components/ui/menu/menu-styles.scss
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue