feat: grouped options

This commit is contained in:
tglide committed 2023-06-20 18:47:44 +01:00
1 parent 92032f537a
commit 09df2013e3
6 files changed
+152 -51

No files matched your search

+27
View File
@@ -3,6 +3,18 @@ import { isMac } from '$lib/helpers/platform';
import { derived, writable } from 'svelte/store';
// Store
export type CommandGroup =
| 'ungrouped'
| 'navigation'
| 'project'
| 'auth'
| 'help'
| 'account'
| 'platforms'
| 'databases'
| 'functions'
| 'storage';
export type Command = {
keys: string[];
/* Ctrl on Windows/Linux, Meta on Mac */
@@ -14,6 +26,7 @@ export type Command = {
label?: string;
disabled?: boolean;
forceEnable?: boolean;
group?: CommandGroup;
};
export const commandMap = writable<Map<string, Command[]>>(new Map());
@@ -24,6 +37,20 @@ export const commands = derived(commandMap, ($commandMap) => {
return Array.from($commandMap.values()).flat();
});
export const groupedCommands = derived(commands, ($commands) => {
const res = new Map<string, Command[]>();
for (const command of $commands) {
if (!command.group) {
res.set('ungrouped', [...(res.get('ungrouped') || []), command]);
} else {
res.set(command.group, [...(res.get(command.group) || []), command]);
}
}
return res;
});
const commandsEnabled = derived(disabledMap, ($disabledMap) => {
// If there's an item on the disabledMap that's true, then disable the command center
return Array.from($disabledMap.values()).every((disabled) => !disabled);
+100 -36
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import type { CommandGroup } from '../commands';
// This is the template for all panels used in the command center.
// Use this component when you want to create a new panel.
@@ -11,7 +13,7 @@
import { quadOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition';
type BaseOption = { callback: () => void };
type BaseOption = { callback: () => void; group?: CommandGroup };
type Option = $$Generic<BaseOption>;
export let options: Option[] | null = null;
export let search = '';
@@ -54,20 +56,20 @@
event.preventDefault();
selected = options.length - 1;
}
tick().then(() => {
if (!cardEl) return;
const selectedEl = cardEl.querySelector('[data-selected]');
if (selectedEl) {
selectedEl.scrollIntoView({
block: 'nearest',
behavior: 'smooth'
});
}
});
}
tick().then(() => {
if (!cardEl) return;
const selectedEl = cardEl.querySelector('[data-selected]');
if (selectedEl) {
selectedEl.scrollIntoView({
block: 'nearest',
behavior: 'smooth'
});
}
});
if (event.key === 'Escape') {
event.preventDefault();
popSubPanel();
@@ -87,6 +89,56 @@
const commandCenterCtx = getCommandCenterCtx();
let cardEl: HTMLElement;
type Group = { type: 'group'; name: string };
type IndexedOption = Option & { index: number };
function isGroup(item: Option | Group): item is Group {
return 'type' in item && item.type === 'group';
}
const getGroupsAndOptions = (options: Option[]) => {
if (!options) return null;
const groupedOptions = new Map<string, Option[]>();
groupedOptions.set('ungrouped', []);
for (const option of options) {
if (!option.group) {
groupedOptions.set('ungrouped', [...groupedOptions.get('ungrouped'), option]);
} else {
groupedOptions.set(option.group, [
...(groupedOptions.get(option.group) || []),
option
]);
}
}
// return a flat array of groups and indexed options
let optionIndex = 0;
const groupsAndOptions: (Group | IndexedOption)[] = [];
for (const [groupName, options] of groupedOptions) {
if (groupName !== 'ungrouped') {
groupsAndOptions.push({ type: 'group', name: groupName });
}
for (const option of options) {
groupsAndOptions.push({ ...option, index: optionIndex++ });
}
}
return groupsAndOptions;
};
$: groupsAndOptions = getGroupsAndOptions(options);
const getOptionClickHandler = (option: IndexedOption) => () => {
triggerOption(option);
};
const getOptionFocusHandler = (option: IndexedOption) => () => {
selected = option.index;
};
const castOption = (option: IndexedOption) => option as Option;
</script>
<svelte:window on:keydown={handleKeyDown} />
@@ -97,36 +149,41 @@
class:press={!$commandCenterCtx.isInitialPanel}
class:scale-up={$commandCenterCtx.isInitialPanel && $commandCenterCtx.open}>
<div class="u-flex u-flex-vertical u-width-full-line u-overflow-hidden">
<div class="u-flex search-wrapper">
<slot name="search">
<slot name="search">
<div class="u-flex search-wrapper">
<input
type="text"
placeholder="Search for commands..."
autofocus
bind:value={search} />
</slot>
</div>
</div>
</slot>
{#if options}
{#if groupsAndOptions}
<ul class="options u-margin-block-start-16 u-flex u-flex-vertical u-gap-8">
{#each options as option, i}
<li class="result" data-selected={selected === i ? true : undefined}>
{#if selected === i}
<div
class="bg"
in:send|local={{ key: 'bg' }}
out:receive|local={{ key: 'bg' }} />
{/if}
<button
class="option"
on:click={() => {
triggerOption(option);
}}
on:mouseover={() => (selected = i)}
on:focus={() => (selected = i)}>
<slot name="option" {option} />
</button>
</li>
{#each groupsAndOptions as item}
{@const isSelected = !isGroup(item) && item.index === selected}
{#if isGroup(item)}
<li class="group eyebrow-heading-3">
{item.name}
</li>
{:else}
<li class="result" data-selected={isSelected ? true : undefined}>
{#if isSelected}
<div
class="bg"
in:send|local={{ key: 'bg' }}
out:receive|local={{ key: 'bg' }} />
{/if}
<button
class="option"
on:click={getOptionClickHandler(item)}
on:mouseover={getOptionFocusHandler(item)}
on:focus={getOptionFocusHandler(item)}>
<slot name="option" option={castOption(item)} />
</button>
</li>
{/if}
{:else}
<li class="result">
<slot name="no-options">
@@ -209,6 +266,13 @@
background-color: transparent;
}
.group {
color: hsl(var(--color-neutral-70));
margin-inline-start: 0.25rem;
position: relative;
z-index: 10;
}
.result {
transition: 150ms;
position: relative;
+4 -2
View File
@@ -39,7 +39,8 @@
},
keys: ['a'],
ctrl: true,
shift: true
shift: true,
group: 'navigation'
},
{
label: 'Go to Home',
@@ -48,7 +49,8 @@
},
keys: ['g', 'h'],
// Check if pathname not like /console/organization-6374c26ba90067a6315d
disabled: $page.url.pathname.includes('/console/organization-')
disabled: $page.url.pathname.includes('/console/organization-'),
group: 'navigation'
}
]);
@@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { page } from '$app/stores';
import { registerCommands } from '$lib/commandCenter';
import { Command, registerCommands } from '$lib/commandCenter';
import { CardContainer, Empty, GridItem1, Heading, PaginationWithLimit } from '$lib/components';
import { Pill } from '$lib/elements';
import { Button } from '$lib/elements/forms';
@@ -48,12 +48,13 @@
}
$: $registerCommands([
...data.projects.projects.map((project, i) => ({
...data.projects.projects.map<Command>((project, i) => ({
label: `Project ${project.name}`,
callback: () => {
goto(`/console/project-${project.$id}`);
},
keys: ['p', `${i + 1}`]
keys: ['p', `${i + 1}`],
group: 'project'
})),
{
label: 'Create project',
@@ -61,7 +62,8 @@
showCreate = true;
},
keys: ['c'],
disabled: showCreate
disabled: showCreate,
group: 'project'
}
]);
</script>
@@ -20,11 +20,11 @@
$: $registerCommands([
{
label: 'Go to Overview',
keys: ['g', 'o'],
callback: () => {
goto(`/console/project-${$project.$id}`);
}
},
keys: ['g', 'o'],
group: 'navigation'
},
{
@@ -32,28 +32,32 @@
callback: () => {
goto(`/console/project-${$project.$id}/auth`);
},
keys: ['g', 'a']
keys: ['g', 'a'],
group: 'navigation'
},
{
label: 'Go to Databases',
callback: () => {
goto(`/console/project-${$project.$id}/databases`);
},
keys: ['g', 'd']
keys: ['g', 'd'],
group: 'navigation'
},
{
label: 'Go to Functions',
callback: () => {
goto(`/console/project-${$project.$id}/functions`);
},
keys: ['g', 'f']
keys: ['g', 'f'],
group: 'navigation'
},
{
label: 'Go to Storage',
callback: () => {
goto(`/console/project-${$project.$id}/storage`);
},
keys: ['g', 's']
keys: ['g', 's'],
group: 'navigation'
}
]);
</script>
@@ -44,14 +44,16 @@
showCreate = true;
},
keys: ['c'],
disabled: showCreate
disabled: showCreate,
group: 'auth'
},
{
label: 'Go to Overview',
callback: () => {
goto(`${base}/console/project-${$project.$id}/overview`);
},
keys: ['o']
keys: ['o'],
group: 'navigation'
}
]);
</script>