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.

/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'],
]);
Browser menu

Binding to Elements

You've seen how you can set a global context menu, but of course you can bind one to any element.

/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>

<!-- 
 Since there is a global context menu system handling the contextmenu event, you can not 
 use oncontextmenu={openMenu}, as this would interfere with the global handling. The 
 attachment handles the setup for that system.
 For normal clicks, there is no such global system, so just use onclick={openMenu(menu)}
-->
<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.

/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}