Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions e2e/extend.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ test("ButtonToggle page should have h1", async ({ page }) => {
expect(await page.textContent("h1")).toBe("Svelte Button Toggle");
});

// CommandPalette
test("CommandPalette page should have h1", async ({ page }) => {
await page.goto("/docs/extend/command-palette");
expect(await page.textContent("h1")).toBe("Svelte Command Palette");
});

// flowbite-svelte-starter
test("Flowbite Svelte Starter page should have h1", async ({ page }) => {
await page.goto("/docs/extend/flowbite-svelte-starter");
Expand Down
240 changes: 240 additions & 0 deletions src/lib/command-palette/CommandPalette.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { CommandPaletteProps, CommandItem } from '$lib/types';
import { commandPalette } from './theme';
import { getTheme } from "$lib/theme/themeUtils";
import clsx from "clsx";

const styles = commandPalette();

let {
open = $bindable(false),
items = [],
placeholder = 'Type a command or search keywords ...',
emptyMessage = 'No results found.',
shortcutKey = 'k',
vim = false,
onclose,
classes
}: CommandPaletteProps = $props();

const theme = getTheme("commandPalette");

let search = $state('');
let selectedIndex = $state(0);
let inputElement = $state<HTMLInputElement>();
let containerElement = $state<HTMLDivElement>();

const filteredItems = $derived(
search.trim() === ''
? items
: items.filter((item) => {
const searchLower = search.toLowerCase();
const labelMatch = item.label.toLowerCase().includes(searchLower);
const descMatch = item.description?.toLowerCase().includes(searchLower);
const keywordMatch = item.keywords?.some((kw) =>
kw.toLowerCase().includes(searchLower)
);
return labelMatch || descMatch || keywordMatch;
})
);

$effect(() => {
if (open && inputElement) {
inputElement.focus();
selectedIndex = 0;
}
});

$effect(() => {
if (filteredItems.length > 0 && selectedIndex >= filteredItems.length) {
selectedIndex = filteredItems.length - 1;
}
});

function handleKeydown(e: KeyboardEvent) {
if (!open) return;

switch (e.key) {
case 'Escape':
e.preventDefault();
closeCommandPalette();
break;
case 'ArrowDown':
case 'j':
if (e.key === 'j' && !vim) break;
if (e.key === 'j' && e.ctrlKey) break;
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, filteredItems.length - 1);
scrollToSelected();
break;
case 'ArrowUp':
case 'k':
if (e.key === 'k' && !vim) break;
if (e.key === 'k' && e.ctrlKey) break;
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
scrollToSelected();
break;
case 'Enter':
e.preventDefault();
if (filteredItems[selectedIndex]) {
selectItem(filteredItems[selectedIndex]);
}
break;
}
}

function scrollToSelected() {
if (!containerElement) return;
const listElement = containerElement.querySelector('ul');
const selectedElement = containerElement.querySelector(
`#${CSS.escape(filteredItems[selectedIndex]?.id)}`
) as HTMLElement;

if (selectedElement && listElement) {
const listRect = listElement.getBoundingClientRect();
const elementRect = selectedElement.getBoundingClientRect();

if (elementRect.bottom > listRect.bottom) {
selectedElement.scrollIntoView({ block: 'end', behavior: 'auto' });
} else if (elementRect.top < listRect.top) {
selectedElement.scrollIntoView({ block: 'start', behavior: 'auto' });
}
}
}

function selectItem(item: CommandItem) {
item.onselect();
closeCommandPalette();
}

function closeCommandPalette() {
open = false;
search = '';
selectedIndex = 0;
onclose?.();
}

function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
closeCommandPalette();
}
}

function handleBackdropKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && e.target === e.currentTarget) {
closeCommandPalette();
}
}

onMount(() => {
const handleGlobalKeydown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === shortcutKey) {
e.preventDefault();
open = !open;
}
};

window.addEventListener('keydown', handleGlobalKeydown);
return () => window.removeEventListener('keydown', handleGlobalKeydown);
});
</script>

<svelte:window onkeydown={handleKeydown} />

{#if open}
<div
class={styles.backdrop({ class: clsx(theme?.backdrop, classes?.backdrop) })}
onclick={handleBackdropClick}
onkeydown={handleBackdropKeydown}
role="dialog"
aria-modal="true"
aria-labelledby="command-palette-label"
tabindex="-1"
>
<div class={styles.panel({ class: clsx(theme?.panel, classes?.panel)})} bind:this={containerElement}>
<!-- Search Input -->
<div class={styles.inputWrapper({ class: clsx(theme?.inputWrapper, classes?.inputWrapper)})}>
<svg class={styles.icon({ class: clsx(theme?.icon, classes?.icon)})} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
bind:this={inputElement}
bind:value={search}
type="text"
class={styles.input({ class: clsx(theme?.input, classes?.input)})}
placeholder={placeholder}
role="combobox"
aria-expanded="true"
aria-controls="command-palette-options"
aria-activedescendant={filteredItems[selectedIndex]?.id}
/>
</div>

<!-- Results -->
{#if filteredItems.length > 0}
<ul id="command-palette-options" class={styles.list({ class: clsx(theme?.list, classes?.list)})} role="listbox">
{#each filteredItems as item, index (item.id)}
<li
data-index={index}
id={item.id}
role="option"
aria-selected={index === selectedIndex}
class={styles.item({ selected: index === selectedIndex, class: clsx(theme?.item, classes?.item)})}
onclick={() => selectItem(item)}
onkeydown={(e) => e.key === 'Enter' && selectItem(item)}
onmouseenter={() => (selectedIndex = index)}
tabindex="-1"
>
<div class="flex items-center gap-3">
{#if item.icon}
<span class="text-lg">{item.icon}</span>
{/if}
<div class="flex-1 min-w-0">
<div class="font-medium text-sm truncate">{item.label}</div>
{#if item.description}
<div class={styles.itemDescription()}>
{item.description}
</div>
{/if}
</div>
</div>
</li>
{/each}
</ul>
{:else if search}
<div class={styles.empty({ class: clsx(theme?.empty, classes?.empty)})}>
<p>{emptyMessage}</p>
</div>
{/if}

<!-- Footer -->
<div class={styles.footer({ class: clsx(theme?.footer, classes?.footer)})}>
<div class="flex items-center gap-4">
<kbd class={styles.kbd({ class: clsx(theme?.kbd, classes?.kbd)})}>
{#if vim}
<span>j/k</span>
{:else}
<span>↑↓</span>
{/if}
<span>Navigate</span>
</kbd>
<kbd class={styles.kbd({ class: clsx(theme?.kbd, classes?.kbd)})}>
<span>↡</span>
<span>Select</span>
</kbd>
</div>
<kbd class={styles.kbd({ class: clsx(theme?.kbd, classes?.kbd)})}>
<span>ESC</span>
<span>Close</span>
</kbd>
</div>
</div>
</div>
{/if}
1 change: 1 addition & 0 deletions src/lib/command-palette/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as CommandPalette } from "./CommandPalette.svelte";
37 changes: 37 additions & 0 deletions src/lib/command-palette/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { tv, type VariantProps } from "tailwind-variants";
import type { Classes } from "$lib/theme/themeUtils";

export type CommandPaletteVariants = VariantProps<typeof commandPalette> & Classes<typeof commandPalette>;

export const commandPalette = tv({
slots: {
backdrop:
"fixed inset-0 z-50 flex items-start justify-center bg-gray-900/50 dark:bg-gray-900/80 p-4 sm:p-6 md:p-20",
panel:
"w-full max-w-2xl bg-white dark:bg-gray-800 rounded-lg shadow-2xl ring-1 ring-black/5 dark:ring-white/10 overflow-hidden transform transition-all",
inputWrapper: "relative",
icon:
"pointer-events-none absolute left-4 top-3.5 h-5 w-5 text-gray-400 dark:text-gray-500",
input:
"w-full border-0 bg-transparent pl-11 pr-4 py-3 text-gray-900 dark:text-white " +
"placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-primary-500 focus:ring-offset-0 text-sm",
list:
"max-h-80 scroll-py-2 overflow-y-auto border-t border-gray-200 dark:border-gray-700",
item:
"cursor-pointer select-none px-4 py-2 text-sm text-gray-900 dark:text-gray-100 " +
"aria-selected:bg-primary-600 aria-selected:text-white",
itemDescription:
"text-xs truncate text-gray-500 dark:text-gray-400 aria-selected:text-primary-100",
empty:
"px-4 py-14 text-center border-t border-gray-200 dark:border-gray-700 text-sm text-gray-500 dark:text-gray-400",
footer:
"flex flex-wrap items-center justify-between gap-2 bg-gray-50 dark:bg-gray-900/50 " +
"px-4 py-2.5 text-xs text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700",
kbd:
"inline-flex items-center gap-1 rounded border border-gray-300 dark:border-gray-600 " +
"bg-white dark:bg-gray-800 px-2 py-1 font-sans text-xs"
},

variants: {},
defaultVariants: {}
});
1 change: 1 addition & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export * from "./utils";
export * from "./types";

// extend
export * from "./command-palette";
export * from "./virtuallist";
export * from "./kanban";
export * from "./split-pane";
Expand Down
1 change: 1 addition & 0 deletions src/lib/theme/themes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export { secondary } from "../typography/secondary";
export { span } from "../typography/span";

// extend
export { commandPalette } from "$lib/command-palette/theme";
export { virtualList } from "$lib/virtuallist";
export { kanbanBoard, kanbanCard } from "$lib/kanban/theme";
export { splitpane, pane, divider, dividerHitArea } from "$lib/split-pane/theme";
Expand Down
Loading