Popups

Dialogs keep var(--sideSpace) clearance from every viewport edge, including on narrow screens. It defaults to var(--space-3) (1em). Override it on the theme root to match your app's spacing. With size: 'auto', smaller popups fit their content on mobile just as they do on desktop. Larger popups remain constrained by the viewport. Use size: 'full' for the larger manager/editor layout.

Setup

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

/routes/+layout.svelte

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

<PopupRoot />

In case you're not happy with the default popup config, you can just set the values of the config object.

/lib/popups.ts

import { popupDefaults } from '@leon8ix/jhana-ui/popup';

// These values are the defaults:
popupDefaults.id = function genId() { return "X" + String(Math.random()).slice(-10); };
popupDefaults.size = 'full';
popupDefaults.form = false;
popupDefaults.submitOnEnter = true;
popupDefaults.class = '';
popupDefaults.style = '';
popupDefaults.closeButton = true;
popupDefaults.buttons = [];

// You might want this:
popupDefaults.buttons = [{ text: 'OK', primary: true }];

Usage

Once set up, you can just use the pop function anywhere. Either directly in the components or you might set up one file that contains all the popups your app uses.

/lib/popups/popup1.svelte

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

	function shopPopup() {
		pop({
			size: 'auto',
			heading: 'Test Popup',
			content: 'This is how a popup might look.',
			buttons: [{ text: 'OK', primary: true }],
		});
	}
</script>

<button class="btn" onclick={shopPopup}>Show popup</button>

Instead of the `pop` function you can also use Svelte syntax using the OpenPopup component. It reacts to mount and unmount.

/lib/popups/popup2.svelte

<script lang="ts">
	import { Toggle } from '@leon8ix/jhana-ui';
	import { OpenPopup } from '@leon8ix/jhana-ui/popup';

	let isOpen = $state(false);
</script>

<Toggle label="Popup open" bind:checked={isOpen} />

{#if isOpen}
	<OpenPopup
		heading="I'm open now"
		size="auto"
		onreject={() => (isOpen = false)}
		onresolve={() => (isOpen = false)}
		buttons={[{ text: 'OK', primary: true }]}
	>
		{#snippet content()}
			<Toggle label="Popup open" bind:checked={isOpen} />
		{/snippet}
	</OpenPopup>
{/if}

Here you can see nested popups in use. This shows a way to organize a collection of popups that work together.

/lib/popups/popup3.svelte

<script lang="ts">
	import { pop, PopupButton } from '@leon8ix/jhana-ui/popup';
	import { delay } from '@leon8ix/utils/dom';

	const defaultNote = 'Hello Universe!';
	let notes = $state([defaultNote]);
	let newNote = $state('');

	const addBtn = new PopupButton({ text: 'Add', primary: true, state: 'disabled' });

	$effect(() => {
		addBtn.state = notes.length ? 'active' : 'disabled';
	});

	function showNoteManager() {
		return pop({
			heading: 'Note Manager',
			size: 'full',
			content: [{ snippet: noteManagerContent }],
			buttons: [
				{ text: 'Add', onclick: showAddNote },
				{ text: 'Reset', onclick: showConfirmReset },
				{ text: 'Save', primary: true },
			],
		});
	}

	function showAddNote() {
		return pop({
			heading: 'New Note',
			size: 'auto',
			form: true,
			content: [{ snippet: addNoteContent }],
			buttons: [{ text: 'Cancel' }, addBtn],
			onresolve: async () => {
				await delay(500);
				if (newNote) notes.push(newNote);
				newNote = '';
			},
			onreject: () => {
				newNote = '';
			},
		});
	}

	function showConfirmReset() {
		return pop({
			heading: 'Reset all Notes?',
			size: 'auto',
			content: 'This action is irreversible. Do you truely want to reset all your notes?',
			class: 'theme-error',
			buttons: [{ text: 'Cancel' }, { text: 'Reset', primary: true }],
			onresolve: () => {
				notes = [defaultNote];
			},
		});
	}
</script>

<button class="btn" onclick={showNoteManager}>Open note manager</button>

{#snippet noteManagerContent()}
	<ul>
		{#each notes as note}
			<li class="note">{note}</li>
		{/each}
	</ul>
{/snippet}

{#snippet addNoteContent()}
	<input type="text" class="input" bind:value={newNote} />
{/snippet}

<style>
	li {
		display: block;
		padding: 0.5em 0.9em;
		margin-bottom: 1em;
	}
</style>

Enter and the primary action

Plain Enter runs the last visible primary footer action by default, without a form or focus on that button. This example uses Cover Designer's account-save confirmation. Open it and press Enter to confirm, or Tab to Cancel and press Enter to cancel. No extra keyboard configuration is needed.

No action yet

PopupEnterDemo.svelte

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

	let outcome = $state('No action yet');
	async function confirm() {
		const result = await pop({
			heading: 'Save logo to account?',
			size: 'auto',
			content: 'The logo, name, size and alignment will be saved to your account.',
			buttons: [{ text: 'Cancel' }, { text: 'Save', primary: true }],
		}).promise;
		outcome = result.ok ? 'Save confirmed' : 'Cancelled';
	}
</script>

<div class="stack" style="align-items: start">
	<button type="button" class="btn" onclick={confirm}>Open confirmation</button>
	<p class="hint" role="status">{outcome}</p>
</div>

The initial focus is on the dialog itself, unless content requests autofocus. Tab order is unchanged. Enter on a focused button or link keeps its normal action. Textareas, contenteditable, selects, open ComboBoxes and nested dialogs/popovers keep their own Enter behavior. Control handlers can consume Enter with preventDefault(). Held-key repeats, composition and modifier keys do not run the shortcut. Closing returns focus to the previous control or parent popup when it is still available.

Disabled/loading defaults do nothing. Hidden primary alternatives are skipped, and a split button uses its main action. Custom footer buttons participate through their primary class and existing click/disabled behavior. A primary-colored control in the body does not become the default. A popup without a primary has no new Enter action.

Use form: true when you need native validation and form submission. Enter validates the wrapping form before running its primary action, including when it contains several inputs. Button clicks retain their existing handlers. The form's existing no-primary submission fallback still resolves its current value.

Optional opt-out

import { pop, popupDefaults } from '@leon8ix/jhana-ui/popup';

pop({
	heading: 'Review changes',
	size: 'auto',
	content: 'Choose an action explicitly to continue.',
	submitOnEnter: false,
	buttons: [{ text: 'Cancel' }, { text: 'Apply', primary: true }],
});

// Optional app default for subsequently created popups:
popupDefaults.submitOnEnter = false;

OpenPopup accepts the same submitOnEnter prop. Opting out disables the popup shortcut and its wrapping form's implicit Enter submission. Clicking, Enter on an explicitly focused button and explicit requestSubmit() still work. Initial focus follows the browser's existing behavior for opted-out popups. Full guidance ships in docs/popups.md.

Footer Buttons with Options

Add menu to a popup button to give it a separate options arrow. The main section keeps its onclick action; clicking the arrow or right-clicking either section opens the menu inside the dialog. A menu factory receives the popup, so an alternate action can resolve it with a different value. A plain context-menu configuration works too.

Mount CtxMenuRoot alongside PopupRoot. The arrow label defaults to the button text followed by “options”; set menuLabel to customize or translate it. Disabled and loading states block both sections. The popup's default Enter action invokes the main button; Enter on the focused arrow opens its options without submitting.

No action selected

PopupSplitButtonDemo.svelte

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

	let action = $state('No action selected');
	async function open() {
		const result = await pop<{ value: string }>({
			heading: 'Save document',
			size: 'auto',
			content: 'Save the document or choose another save action.',
			buttons: [
				{ text: 'Cancel' },
				{
					text: 'Save',
					primary: true,
					onclick: popup => popup.resolve('Save'),
					menu: popup => ({
						items: [{ text: 'Save a copy', action: () => popup.resolve('Save a copy') }],
					}),
				},
			],
		}).promise;
		if (result.ok) action = result.value;
	}
</script>

<div class="stack" style="align-items: start">
	<button type="button" class="btn" onclick={open}>Open save dialog</button>
	<p class="hint" role="status">{action}</p>
</div>