refactor(actions): combine Footer into ActionBar

Previously, the two components did basically the same thing. This commit removes Footer in favor of ActionBar. It also extracts a Toast.svelte component, which is now used to render the toast and dropdown in ActionBar.
This commit is contained in:
ByteAtATime 2025-07-05 13:57:09 -07:00
parent 6ed4af5ce7
commit ef510af02a
No known key found for this signature in database
7 changed files with 145 additions and 125 deletions

Binary file not shown.

View file

@ -68,6 +68,12 @@
selectedIndex = findNextSelectableIndex(selectedIndex, 1);
break;
case 'Enter':
if (
event.target instanceof HTMLElement &&
event.target.closest('[data-slot="dropdown-menu-content"]')
) {
return;
}
if (selectedIndex !== -1 && isItemSelectable(items[selectedIndex])) {
event.preventDefault();
onenter(items[selectedIndex]);

View file

@ -167,28 +167,24 @@
{/snippet}
{#snippet footer()}
{#if toastToShow}
<Footer toast={toastToShow} {onToastAction} />
{:else}
<ActionBar title={navigationTitle}>
{#snippet primaryAction({ props })}
{#if primaryActionObject}
<NodeRenderer
{...props}
nodeId={primaryActionObject.id}
{uiTree}
onDispatch={handleDispatch}
displayAs="button"
/>
{/if}
{/snippet}
{#snippet actions()}
{#if showActionPanelDropdown && actionPanel}
<NodeRenderer nodeId={actionPanel.id} {uiTree} onDispatch={handleDispatch} />
{/if}
{/snippet}
</ActionBar>
{/if}
<ActionBar title={navigationTitle} toast={toastToShow} {onToastAction}>
{#snippet primaryAction({ props })}
{#if primaryActionObject}
<NodeRenderer
{...props}
nodeId={primaryActionObject.id}
{uiTree}
onDispatch={handleDispatch}
displayAs="button"
/>
{/if}
{/snippet}
{#snippet actions()}
{#if showActionPanelDropdown && actionPanel}
<NodeRenderer nodeId={actionPanel.id} {uiTree} onDispatch={handleDispatch} />
{/if}
{/snippet}
</ActionBar>
{/snippet}
</MainLayout>
{/if}

View file

@ -1,99 +0,0 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Kbd } from '../ui/kbd';
import { keyEventMatches, type KeyboardShortcut as KeyboardShortcutType } from '$lib/props';
import type { Toast } from '$lib/ui.svelte';
import KeyboardShortcut from '../KeyboardShortcut.svelte';
type Props = {
toast: Toast;
onToastAction: (toastId: number, actionType: 'primary' | 'secondary') => void;
};
let { toast, onToastAction }: Props = $props();
let dropdownOpen = $state(false);
const toastActions = $derived.by(() => {
const availableActions: {
type: 'primary' | 'secondary';
title: string;
shortcut?: KeyboardShortcutType;
}[] = [];
if (toast?.primaryAction) {
availableActions.push({ type: 'primary', ...toast.primaryAction });
}
if (toast?.secondaryAction) {
availableActions.push({ type: 'secondary', ...toast.secondaryAction });
}
return availableActions;
});
function handleKeydown(e: KeyboardEvent) {
if (e.key.toLowerCase() === 't' && (e.ctrlKey || e.metaKey)) {
if (toastActions.length > 0) {
e.preventDefault();
dropdownOpen = !dropdownOpen;
}
} else if (toast?.primaryAction?.shortcut && keyEventMatches(e, toast.primaryAction.shortcut)) {
handleActionSelect('primary');
} else if (
toast?.secondaryAction?.shortcut &&
keyEventMatches(e, toast.secondaryAction.shortcut)
) {
handleActionSelect('secondary');
}
}
function handleActionSelect(actionType: 'primary' | 'secondary') {
if (toast) {
onToastAction(toast.id, actionType);
}
dropdownOpen = false;
}
</script>
<svelte:window onkeydown={handleKeydown} />
<footer class="bg-card flex h-10 shrink-0 items-center border-t px-2">
<DropdownMenu.Root bind:open={dropdownOpen}>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<div {...props} class="flex items-center gap-2">
<div class="flex size-4 items-center justify-center">
{#if toast.style === 'ANIMATED'}
<div
class="size-4 animate-spin rounded-full border-2 border-white/30 border-t-white"
></div>
{:else if toast.style === 'SUCCESS'}
<div class="shadow-glow size-2 rounded-full bg-green-500 shadow-green-500"></div>
{:else if toast.style === 'FAILURE'}
<div class="shadow-glow size-2 rounded-full bg-red-500 shadow-red-500"></div>
{/if}
</div>
<div>
<span>{toast.title}</span>
<span class="text-muted-foreground text-sm">{toast.message}</span>
</div>
{#if toast.primaryAction || toast.secondaryAction}
<Kbd>Ctrl + T</Kbd>
{/if}
</div>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content side="top" align="start" class="w-60">
<DropdownMenu.Label>Toast Actions</DropdownMenu.Label>
<DropdownMenu.Separator />
{#each toastActions as action (action.type)}
<DropdownMenu.Item onselect={() => handleActionSelect(action.type)}>
{action.title}
{#if action.shortcut}
<DropdownMenu.Shortcut>
<KeyboardShortcut shortcut={action.shortcut} />
</DropdownMenu.Shortcut>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
</footer>

View file

@ -4,19 +4,26 @@
import Icon from '$lib/components/Icon.svelte';
import { Separator } from '$lib/components/ui/separator/index.js';
import type { ButtonProps } from '$lib/components/ui/button';
import type { Toast as ToastType } from '$lib/ui.svelte';
import Toast from './Toast.svelte';
type Props = {
title?: string | Snippet;
icon?: ImageLike | null;
primaryAction?: Snippet<[{ props: ButtonProps }]>;
actions?: Snippet;
toast?: ToastType | null;
onToastAction?: (toastId: number, actionType: 'primary' | 'secondary') => void;
};
let { title, icon, primaryAction, actions }: Props = $props();
let { title, icon, primaryAction, actions, toast = null, onToastAction }: Props = $props();
</script>
<footer class="bg-card flex h-10 shrink-0 items-center border-t px-2">
{#if title || icon}
{#if toast}
<Toast {toast} {onToastAction} />
{:else if title || icon}
<div class="flex min-w-0 items-center gap-2">
{#if icon}
<Icon {icon} class="size-5 shrink-0" />

View file

@ -0,0 +1,110 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import type { Toast } from '$lib/ui.svelte';
import type { KeyboardShortcut as KeyboardShortcutType } from '$lib/props';
import KeyboardShortcut from '$lib/components/KeyboardShortcut.svelte';
import { keyEventMatches } from '$lib/props/actions';
import { focusManager } from '$lib/focus.svelte';
type Props = {
toast: Toast;
onToastAction?: (toastId: number, actionType: 'primary' | 'secondary') => void;
};
let { toast, onToastAction }: Props = $props();
let open = $state(false);
const scopeId = `toast-menu-${crypto.randomUUID()}`;
$effect(() => {
if (open) {
focusManager.requestFocus(scopeId);
} else {
focusManager.releaseFocus(scopeId);
}
});
const actions = $derived.by(() => {
if (!toast) return [];
const availableActions: {
type: 'primary' | 'secondary';
title: string;
shortcut?: KeyboardShortcutType;
}[] = [];
if (toast.primaryAction) {
availableActions.push({ type: 'primary', ...toast.primaryAction });
}
if (toast.secondaryAction) {
availableActions.push({ type: 'secondary', ...toast.secondaryAction });
}
return availableActions;
});
const handleKeydown = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === 't' && (e.ctrlKey || e.metaKey)) {
if (actions.length > 0) {
e.preventDefault();
open = !open;
}
} else if (toast?.primaryAction?.shortcut && keyEventMatches(e, toast.primaryAction.shortcut)) {
handleActionSelect('primary');
} else if (
toast?.secondaryAction?.shortcut &&
keyEventMatches(e, toast.secondaryAction.shortcut)
) {
handleActionSelect('secondary');
}
};
const handleActionSelect = (actionType: 'primary' | 'secondary') => {
if (onToastAction) {
onToastAction(toast.id, actionType);
}
open = false;
};
</script>
<svelte:window onkeydown={handleKeydown} />
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<div {...props} class="flex items-baseline gap-2">
<div class="flex size-4 items-center justify-center">
{#if toast.style === 'ANIMATED'}
<div
class="size-4 animate-spin rounded-full border-2 border-white/30 border-t-white"
></div>
{:else if toast.style === 'SUCCESS'}
<div class="shadow-glow size-2 rounded-full bg-green-500 shadow-green-500"></div>
{:else if toast.style === 'FAILURE'}
<div class="shadow-glow size-2 rounded-full bg-red-500 shadow-red-500"></div>
{/if}
</div>
<div>
<span>{toast.title}</span>
<span class="text-muted-foreground text-sm">{toast.message}</span>
</div>
{#if toast.primaryAction || toast.secondaryAction}
<KeyboardShortcut shortcut={{ key: 't', modifiers: ['ctrl'] }} />
{/if}
</div>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content side="top" align="start" class="w-60">
<DropdownMenu.Label>Toast Actions</DropdownMenu.Label>
<DropdownMenu.Separator />
{#each actions as action (action.type)}
<DropdownMenu.Item onclick={() => handleActionSelect(action.type)}>
{action.title}
{#if action.shortcut}
<DropdownMenu.Shortcut>
<KeyboardShortcut shortcut={action.shortcut} />
</DropdownMenu.Shortcut>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>

View file

@ -9,8 +9,8 @@ export type Toast = {
title: string;
message?: string;
style?: 'SUCCESS' | 'FAILURE' | 'ANIMATED';
primaryAction?: { title: string; onAction: boolean; shortcut?: KeyboardShortcut };
secondaryAction?: { title: string; onAction: boolean; shortcut?: KeyboardShortcut };
primaryAction?: { title: string; onAction: () => void; shortcut?: KeyboardShortcut };
secondaryAction?: { title: string; onAction: () => void; shortcut?: KeyboardShortcut };
};
function createUiStore() {