Context Menu

Setup

Jhana UI uses a singleton Svelte component. You have to include this once in your root layout. Then you can use the context menu system across your whole app.

/routes/+layout.svelte

<script lang="ts">
	import { CtxMenuRoot } from '@leon8ix/jhana-ui/ctx-menu';
	import { menuGlobal, menuTagMap } from '$lib/menus';
</script>

<CtxMenuRoot menu={menuGlobal} tags={menuTagMap} />

Global Menu

I like to have a single file that contains all context menus used by the app. However you can also write them specifically in those Svelte components or pages, where you actually need them.

/lib/menus.ts

import { iconPrint, iconReset, iconStore, iconUrl } from '@leon8ix/jhana-ui';
import type { CtxMenuConfig } from '@leon8ix/jhana-ui/ctx-menu';
import { app } from './app-state.svelte';
import { iconSwatch, iconSideCover, iconSideSpineBot, iconSideSpineTop } from './icons';

export const menuPrint: CtxMenuConfig = {
	anchor: 'right',
	items: [
		'Print side',
		{ text: 'Cover', icon: iconSideCover, action: () => app.print('cover') },
		{ text: 'Spine top', icon: iconSideSpineTop, action: () => app.print('spine-top') },
		{ text: 'Spine bot', icon: iconSideSpineBot, action: () => app.print('spine-bot') },
	],
};

export const menuStore: CtxMenuConfig = {
	anchor: 'right',
	items: [
		'Store with suffix',
		{ text: 'Silver', icon: iconSwatch, color: '#c4c4c4', action: () => app.store('Silber') },
		{ text: 'Gold', icon: iconSwatch, color: '#d0a949', action: () => app.store('Gold') },
		{ text: 'White', icon: iconSwatch, color: '#f0f0f0', action: () => app.store('Weiß') },
		'Export',
		{ text: 'Copy as URL', icon: iconUrl, action: () => app.copyAsUrl() },
	],
};

export const menuGlobal: CtxMenuConfig = {
	items: [
		{ text: 'Reset', kbd: 'R', icon: iconReset, action: () => app.reset() },
		{ text: 'Print', kbd: 'CTRL+P', icon: iconPrint, action: () => app.print(), items: menuPrint.items },
		{ text: 'Store', kbd: 'CTRL+S', icon: iconStore, action: () => app.store(), items: menuStore.items },
	],
};

Tag Map

You can also set up global behavior for specific element tags.

Browser menu

/lib/menus.ts

import { iconCopy, iconCut, iconPaste } from '@leon8ix/jhana-ui';
import type { CtxMenuConfig, CtxMenuTagMap } from '@leon8ix/jhana-ui/ctx-menu';
import { copy, cut, paste } from '@leon8ix/jhana-ui/ctx-menu';
import type { MapKey, MapValue } from '@leon8ix/utils';

export const menuInputs: CtxMenuConfig = {
	items: [
		{ text: 'Copy', icon: iconCopy, action: () => copy() },
		{ text: 'Cut', icon: iconCut, action: () => cut() },
		{ text: 'Paste', icon: iconPaste, action: () => paste() },
	],
};

export const menuTagMap: CtxMenuTagMap = new Map<MapKey<CtxMenuTagMap>, MapValue<CtxMenuTagMap>>([
	['input', menuInputs],
	['textarea', menuInputs],
	['button', false],
	['a', 'browser'],
]);

Binding to Elements

You've seen how you can set a global context menu, but of course you can bind one to any element. Left, center and right specify the preferred alignment. Opening and closing animations originate at the attached element's bottom midpoint for menus below, or its top midpoint for menus above. If the menu overlaps its source, it uses the edge facing the menu's center. Pointer-positioned menus originate at the click position. That origin follows the source even when the menu flips or shifts sideways near an edge. Menus keep at least var(--sideSpace) from every viewport edge, defaulting to var(--space-3) (1em). Panels that fit neither below nor above their source shift upward just enough to clear the bottom edge, and long menus scroll within that limit. A sideways shift preserves the vertical placement when it fits.

/routes/context-buttons/+page.svelte

<script lang="ts">
	import { iconStore } from '@leon8ix/jhana-ui';
	import { ctxMenu, openMenu, type CtxMenuConfig, type CtxMenuItem } from '@leon8ix/jhana-ui/ctx-menu';
	import { delay } from '@leon8ix/utils/dom';

	const menuItems: CtxMenuItem[] = [
		{ text: 'Basic Action Sync', icon: iconStore, action: () => null },
		{ text: 'Async Action 5s', icon: iconStore, action: () => delay(5000) },
		{ text: 'Async Action 500ms', icon: iconStore, action: () => delay(500) },
	];

	const menuL: CtxMenuConfig = { items: menuItems, anchor: 'left' };
	const menuC: CtxMenuConfig = { items: menuItems, anchor: 'center' };
	const menuR: CtxMenuConfig = { items: menuItems, anchor: 'right' };
</script>

<!-- Attach for right-click lookup (including descendants), and use openMenu for normal clicks.
 A direct oncontextmenu={openMenu(menu)} handler also works and stops global handling. -->
<div class="btn-group">
	<button class="btn" onclick={openMenu(menuL)}>Left click me</button>
	<button class="btn" {@attach ctxMenu(menuC)}>Right click me</button>
	<button class="btn" onclick={openMenu(menuR)} {@attach ctxMenu(menuR)}>Any click me</button>
</div>

Custom Content

Beyond basic text, you can also provide a custom RenderingContent<CtxMenuItemProps>. This means { snippet: Snippet<[CtxMenuItemProps]> } | { component: Component<CtxMenuItemProps> }. Of course, you can put any content inside, but this is how to do it, if you want it to look like the native context menu. Your RenderingContent can contain multiple context menu entries. You wrap each one in a <li> and put a <button> inside – or multiple, if you want a horizontal button group. As long as you wrap with <li>, everything is auto-styled for you. No need for any classes.

Settings content grows to its natural height. The menu keeps its viewport clearance and scrolls when the content no longer fits. Avoid an extra max-height such as 70dvh or an inner vertical scroller on the settings content: that makes dialogs scroll while usable viewport space remains.

/routes/custom-menu/+page.svelte

<script lang="ts">
	import { iconIcons } from '$lib';
	import { cm, ctxMenu, openMenu, type CtxMenuConfig, type CtxMenuItemProps } from '@leon8ix/jhana-ui/ctx-menu';
	import { iconRotate180, iconRotateLeft, iconRotateRight } from '$lib/icons';

	const menu: CtxMenuConfig = {
		anchor: 'left',
		items: [{ snippet: content }],
	};
</script>

<div class="btn-group">
	<button class="btn" onclick={openMenu(menu)} {@attach ctxMenu(menu)}>Click me</button>
</div>

{#snippet content({ ctxIcon, ctxIconMore }: CtxMenuItemProps)}
	<!-- # Basic structure -->
	<li>
		<button>No action</button>
	</li>
	<!-- # Running actions -->
	<li>
		<!-- !!! Always wrap any action in cm.run() - this ensures the menu closes 
		 and also handles the async spinner, if you use and implement that -->
		<button onclick={cm.run(() => console.log('...'))}>Do something</button>
	</li>
	<!-- # Separator -->
	<li>
		<hr />
	</li>
	<!-- # Icon -->
	<li>
		<!-- Don't forget the icon-root class -->
		<button class="icon-root">
			<span>{@render ctxIcon(iconIcons)}</span>
			<span>Icon entry</span>
		</button>
	</li>
	<!-- # Align iconless with icon -->
	<li>
		<button class="icon-root">
			<span></span>
			<span>Iconless</span>
		</button>
	</li>
	<!-- # Only submenu -->
	<li>
		<button class="icon-root">
			<span></span>
			<span>Only submenu</span>
			<span>{@render ctxIconMore()}</span>
		</button>
	</li>
	<!-- # Extra submenu -->
	<li>
		<button>
			<span></span>
			<span>Extra submenu</span>
		</button>
		<button onclick={cm.openSub([{ text: "I'm the submenu" }])}>
			{@render ctxIconMore()}
		</button>
	</li>
	<li><hr /></li>
	<!-- # Horizontal icon+text -->
	<li>
		<button class="flex-1 icon-root">
			<span>{@render ctxIcon(iconRotateLeft)}</span>
			<span>Left</span>
		</button>
		<button class="flex-1 icon-root">
			<span>{@render ctxIcon(iconRotateRight)}</span>
			<span>Right</span>
		</button>
	</li>
	<li><hr /></li>
	<!-- # Horizontal only icon -->
	<li>
		<h4 class="h5">Rotate</h4>
	</li>
	<li>
		<button class="flex-1 icon-root" style="justify-content: center">
			{@render ctxIcon(iconRotateLeft)}
		</button>
		<button class="flex-1 icon-root" style="justify-content: center">
			{@render ctxIcon(iconRotate180)}
		</button>
		<button class="flex-1 icon-root" style="justify-content: center">
			{@render ctxIcon(iconRotateRight)}
		</button>
	</li>
{/snippet}

Interactive Anchors

Edit the count, then click the plus button or right-click the input to reset it. The menu is centered on the whole box, keeping the input and its options together.

Attach a menu to a complete control group while keeping its inputs and buttons interactive. Right-clicking a child finds the nearest attached parent. A child button using openMenu with the same configuration anchors to that parent too. Without an attachment, openMenu anchors to the element whose handler runs, even when its icon or text is clicked. No synthetic events or pointer-event overrides are needed.

Copies

CtxMenuControlsDemo.svelte

<script lang="ts">
	import { Icon, iconActionAdd, NumberInput } from '@leon8ix/jhana-ui';
	import { ctxMenu, openMenu, type CtxMenuConfig } from '@leon8ix/jhana-ui/ctx-menu';

	let copies = $state<number | null>(4);
	const menu: CtxMenuConfig = {
		anchor: 'center',
		items: [{ text: 'Reset copies', action: () => (copies = 1) }],
	};
</script>

<fieldset class="field-group" style="width: fit-content">
	<legend>Copies</legend>
	<div class="copies-input ctrl-group" {@attach ctxMenu(menu)}>
		<NumberInput aria-label="Copies" bind:value={copies} min={1} />
		<button
			type="button"
			class="btn plus-btn icon-root"
			aria-label="Copy options"
			aria-haspopup="dialog"
			onclick={openMenu(menu)}
		>
			<Icon icon={iconActionAdd} width="1.5em" height="1.5em" />
		</button>
	</div>
</fieldset>

<style>
	/* Joined input and more-button layout from Infinipose's Copies toolbar. */
	.copies-input.ctrl-group {
		height: auto;
		padding-block: 0;
		padding-left: 0;
		gap: 0.4em;

		:global(.number-input) {
			flex: none;
			margin-block: calc(-1 * var(--borderWidth));
			margin-left: calc(-1 * var(--borderWidth));
		}

		:global(input.input) {
			border-top-right-radius: 0;
			border-bottom-right-radius: 0;
		}
	}
	.plus-btn {
		padding: 0.1em 0.6em;
		margin-left: 0.2em;
		margin-right: -0.4em;
	}
</style>

Inside Dialogs and Popovers

Keep one CtxMenuRoot in the app layout. Menus opened from a popup, native dialog or popover automatically join that layer so their controls remain interactive. Opening a menu keeps its parent popover open; Escape dismisses the menu before its parent. Closing or removing the parent also dismisses its menu. PopupRoot needs no children or additional menu root.

This popup reuses the boxed Copies toolbar above. Click its plus button or right-click the input, then choose Reset copies. The menu uses the popup's theme and viewport spacing.

CtxMenuPopupDemo.svelte

<script lang="ts">
	import { pop } from '@leon8ix/jhana-ui/popup';
	import CtxMenuControlsDemo from './CtxMenuControlsDemo.svelte';
</script>

{#snippet popupContent()}
	<CtxMenuControlsDemo />
{/snippet}

<button
	type="button"
	class="btn"
	onclick={() =>
		pop({
			heading: 'Copy settings',
			size: 'auto',
			content: [{ snippet: popupContent }],
		})}>Open popup with context menu</button
>