implemented a quick and dirty command palette
This commit is contained in:
parent
a6fc5720d4
commit
e363ee1844
4 changed files with 302 additions and 36 deletions
|
@ -55,4 +55,48 @@
|
||||||
|
|
||||||
:popover-open + .item {
|
:popover-open + .item {
|
||||||
background-color: var(--surface-4);
|
background-color: var(--surface-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.commandPalette {
|
||||||
|
display: none;
|
||||||
|
background-color: var(--surface-1);
|
||||||
|
color: var(--text-1);
|
||||||
|
gap: var(--padding-m);
|
||||||
|
padding: var(--padding-l);
|
||||||
|
border: 1px solid var(--surface-3);
|
||||||
|
|
||||||
|
&[open] {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::backdrop {
|
||||||
|
background-color: color(from var(--surface-1) xyz x y z / .2);
|
||||||
|
backdrop-filter: blur(.25em);
|
||||||
|
pointer-events: all !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--padding-m);
|
||||||
|
|
||||||
|
& > input {
|
||||||
|
background-color: var(--surface-2);
|
||||||
|
color: var(--text-1);
|
||||||
|
border: none;
|
||||||
|
padding: var(--padding-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > output {
|
||||||
|
display: contents;
|
||||||
|
color: var(--text-2);
|
||||||
|
|
||||||
|
& > .selected {
|
||||||
|
background-color: color(from var(--info) xyz x y z / .5);
|
||||||
|
}
|
||||||
|
|
||||||
|
& b {
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,12 +1,13 @@
|
||||||
import { Accessor, Component, For, JSX, Match, ParentComponent, Setter, Show, Switch, children, createContext, createEffect, createMemo, createSignal, createUniqueId, mergeProps, onCleanup, onMount, splitProps, useContext } from "solid-js";
|
import { Accessor, Component, For, JSX, ParentComponent, Setter, Show, children, createContext, createEffect, createMemo, createSignal, createUniqueId, mergeProps, onCleanup, onMount, useContext } from "solid-js";
|
||||||
import { Portal } from "solid-js/web";
|
import { Portal } from "solid-js/web";
|
||||||
import { createStore } from "solid-js/store";
|
import { createStore } from "solid-js/store";
|
||||||
import { CommandType, Command } from "../command";
|
import { CommandType, Command } from "../command";
|
||||||
import css from "./index.module.css";
|
import css from "./index.module.css";
|
||||||
|
import { join } from "vinxi/dist/types/lib/path";
|
||||||
|
|
||||||
export interface MenuContextType {
|
export interface MenuContextType {
|
||||||
ref: Accessor<JSX.Element | undefined>;
|
ref: Accessor<Node | undefined>;
|
||||||
setRef: Setter<JSX.Element | undefined>;
|
setRef: Setter<Node | undefined>;
|
||||||
|
|
||||||
addItems(items: (Item | ItemWithChildren)[]): void;
|
addItems(items: (Item | ItemWithChildren)[]): void;
|
||||||
items: Accessor<(Item | ItemWithChildren)[]>;
|
items: Accessor<(Item | ItemWithChildren)[]>;
|
||||||
|
@ -14,12 +15,14 @@ export interface MenuContextType {
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
|
kind: 'leaf';
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
command: CommandType;
|
command: CommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemWithChildren {
|
export interface ItemWithChildren {
|
||||||
|
kind: 'node';
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
children: Item[];
|
children: Item[];
|
||||||
|
@ -27,23 +30,34 @@ export interface ItemWithChildren {
|
||||||
|
|
||||||
const MenuContext = createContext<MenuContextType>();
|
const MenuContext = createContext<MenuContextType>();
|
||||||
|
|
||||||
export const MenuProvider: ParentComponent = (props) => {
|
export const MenuProvider: ParentComponent<{ commands?: CommandType[] }> = (props) => {
|
||||||
const [ref, setRef] = createSignal<JSX.Element | undefined>();
|
const [ref, setRef] = createSignal<Node | undefined>();
|
||||||
const [_items, setItems] = createSignal<Map<string, Item & { children?: Map<string, Item> }>>(new Map());
|
|
||||||
|
|
||||||
const [store, setStore] = createStore<{ items: Record<string, Item | ItemWithChildren> }>({ items: {} });
|
const [store, setStore] = createStore<{ items: Record<string, Item | ItemWithChildren> }>({ items: {} });
|
||||||
|
|
||||||
const addItems = (items: (Item | ItemWithChildren)[]) => setStore('items', values => {
|
const ctx = {
|
||||||
for (const item of items) {
|
ref,
|
||||||
values[item.id] = item;
|
setRef,
|
||||||
}
|
addItems(items: (Item | ItemWithChildren)[]) {
|
||||||
|
return setStore('items', values => {
|
||||||
|
for (const item of items) {
|
||||||
|
values[item.id] = item;
|
||||||
|
}
|
||||||
|
|
||||||
return values;
|
return values;
|
||||||
});
|
})
|
||||||
const items = () => Object.values(store.items);
|
},
|
||||||
const commands = () => Object.values(store.items).map(item => item.children?.map(c => c.command) ?? item.command).flat();
|
items() {
|
||||||
|
return Object.values(store.items);
|
||||||
|
},
|
||||||
|
commands() {
|
||||||
|
return Object.values(store.items)
|
||||||
|
.map(item => item.kind === 'node' ? item.children.map(c => c.command) : item.command)
|
||||||
|
.flat()
|
||||||
|
.concat(props.commands ?? []);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return <MenuContext.Provider value={{ ref, setRef, addItems, items, commands }}>{props.children}</MenuContext.Provider>;
|
return <MenuContext.Provider value={ctx}>{props.children}</MenuContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useMenu = () => {
|
const useMenu = () => {
|
||||||
|
@ -62,13 +76,14 @@ const Item: Component<ItemProps> = (props) => {
|
||||||
const id = createUniqueId();
|
const id = createUniqueId();
|
||||||
|
|
||||||
if (props.command) {
|
if (props.command) {
|
||||||
return mergeProps(props, { id }) as unknown as JSX.Element;
|
return mergeProps(props, { id, kind: 'leaf' }) as unknown as JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
const childItems = children(() => props.children);
|
const childItems = children(() => props.children);
|
||||||
|
|
||||||
return mergeProps(props, {
|
return mergeProps(props, {
|
||||||
id,
|
id,
|
||||||
|
kind: 'node',
|
||||||
get children() {
|
get children() {
|
||||||
return childItems.toArray();
|
return childItems.toArray();
|
||||||
}
|
}
|
||||||
|
@ -192,4 +207,184 @@ export const asMenuRoot = (element: Element) => {
|
||||||
menu.setRef(element);
|
menu.setRef(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Menu = { Root, Item } as const;
|
export const Menu = { Root, Item } as const;
|
||||||
|
|
||||||
|
export interface CommandPaletteApi {
|
||||||
|
readonly open: Accessor<boolean>;
|
||||||
|
show(): void;
|
||||||
|
hide(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommandPalette: Component<{ api?: (api: CommandPaletteApi) => any, onSubmit?: SubmitHandler<CommandType> }> = (props) => {
|
||||||
|
const [open, setOpen] = createSignal<boolean>(false);
|
||||||
|
const [root, setRoot] = createSignal<HTMLDialogElement>();
|
||||||
|
const [search, setSearch] = createSignal<SearchContext<CommandType>>();
|
||||||
|
const context = useMenu();
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
open,
|
||||||
|
show() {
|
||||||
|
setOpen(true);
|
||||||
|
},
|
||||||
|
hide() {
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
props.api?.(api);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const isOpen = open();
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
search()?.clear();
|
||||||
|
root()?.showModal();
|
||||||
|
} else {
|
||||||
|
root()?.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// temp debug code
|
||||||
|
createEffect(() => {
|
||||||
|
search()?.searchFor('c');
|
||||||
|
setOpen(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (command: CommandType) => {
|
||||||
|
setOpen(false);
|
||||||
|
props.onSubmit?.(command);
|
||||||
|
|
||||||
|
command();
|
||||||
|
};
|
||||||
|
|
||||||
|
return <dialog ref={setRoot} class={css.commandPalette} onClose={() => setOpen(false)}>
|
||||||
|
<SearchableList<CommandType> items={context.commands()} keySelector={item => item.label} context={setSearch} onSubmit={onSubmit}>{
|
||||||
|
(item, ctx) => <For each={item.label.split(ctx.filter())}>{
|
||||||
|
(part, index) => <>
|
||||||
|
<Show when={index() !== 0}><b>{ctx.filter()}</b></Show>
|
||||||
|
{part}
|
||||||
|
</>
|
||||||
|
}</For>
|
||||||
|
}</SearchableList>
|
||||||
|
</dialog>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SubmitHandler<T> {
|
||||||
|
(item: T): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchContext<T> {
|
||||||
|
readonly filter: Accessor<string>;
|
||||||
|
readonly results: Accessor<T[]>;
|
||||||
|
readonly value: Accessor<T | undefined>;
|
||||||
|
searchFor(term: string): void;
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchableListProps<T> {
|
||||||
|
items: T[];
|
||||||
|
keySelector(item: T): string;
|
||||||
|
filter?: (item: T, search: string) => boolean;
|
||||||
|
children(item: T, context: SearchContext<T>): JSX.Element;
|
||||||
|
context?: (context: SearchContext<T>) => any,
|
||||||
|
onSubmit?: SubmitHandler<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchableList<T>(props: SearchableListProps<T>): JSX.Element {
|
||||||
|
const [term, setTerm] = createSignal<string>('');
|
||||||
|
const [input, setInput] = createSignal<HTMLInputElement>();
|
||||||
|
const [selected, setSelected] = createSignal<number>();
|
||||||
|
const id = createUniqueId();
|
||||||
|
|
||||||
|
const results = createMemo(() => {
|
||||||
|
const search = term();
|
||||||
|
|
||||||
|
if (search === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.items.filter(item => props.filter ? props.filter(item, search) : props.keySelector(item).includes(search));
|
||||||
|
});
|
||||||
|
|
||||||
|
const value = createMemo(() => {
|
||||||
|
const index = selected();
|
||||||
|
|
||||||
|
if (index === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results().at(index);
|
||||||
|
});
|
||||||
|
const inputValue = createMemo(() => {
|
||||||
|
const v = value();
|
||||||
|
|
||||||
|
return v !== undefined ? props.keySelector(v) : term();
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
filter: term,
|
||||||
|
results,
|
||||||
|
value,
|
||||||
|
searchFor(term: string) {
|
||||||
|
setTerm(term);
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
setTerm('');
|
||||||
|
setSelected(undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
props.context?.(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const length = results().length - 1;
|
||||||
|
|
||||||
|
setSelected(current => current !== undefined ? Math.min(current, length) : undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
setSelected(current => current !== undefined && current > 0 ? current - 1 : undefined);
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
setSelected(current => current !== undefined ? Math.min(results().length - 1, current + 1) : 0);
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (e: SubmitEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (selected() === undefined && term() !== '') {
|
||||||
|
setSelected(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = value();
|
||||||
|
|
||||||
|
if (v === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clear();
|
||||||
|
props.onSubmit?.(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <form method="dialog" class={css.search} onkeydown={onKeyDown} onsubmit={onSubmit}>
|
||||||
|
<input id={`search-${id}`} ref={setInput} value={inputValue()} oninput={(e) => setTerm(e.target.value)} placeholder="start typing for command" autofocus />
|
||||||
|
|
||||||
|
<output for={`search-${id}`}>
|
||||||
|
<For each={results()}>{
|
||||||
|
(result, index) => <div classList={{ [css.selected]: index() === selected() }}>{props.children(result, ctx)}</div>
|
||||||
|
}</For>
|
||||||
|
</output>
|
||||||
|
</form>;
|
||||||
|
};
|
||||||
|
|
|
@ -1,27 +1,39 @@
|
||||||
import { Title } from "@solidjs/meta";
|
import { Title } from "@solidjs/meta";
|
||||||
import { ParentProps, Show } from "solid-js";
|
import { Component, createEffect, createMemo, createSignal, For, ParentProps, Show } from "solid-js";
|
||||||
import { BsTranslate } from "solid-icons/bs";
|
import { BsTranslate } from "solid-icons/bs";
|
||||||
import { FilesProvider } from "~/features/file";
|
import { FilesProvider } from "~/features/file";
|
||||||
import { MenuProvider, asMenuRoot } from "~/features/menu";
|
import { CommandPalette, CommandPaletteApi, MenuProvider, asMenuRoot, useMenu } from "~/features/menu";
|
||||||
import { isServer } from "solid-js/web";
|
import { isServer } from "solid-js/web";
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
|
import { createCommand, Modifier } from "~/features/command";
|
||||||
|
|
||||||
asMenuRoot // prevents removal of import
|
asMenuRoot // prevents removal of import
|
||||||
|
|
||||||
export default function Editor(props: ParentProps) {
|
export default function Editor(props: ParentProps) {
|
||||||
const supported = isServer || typeof window.showDirectoryPicker === 'function';
|
const [commandPalette, setCommandPalette] = createSignal<CommandPaletteApi>();
|
||||||
|
|
||||||
return <MenuProvider>
|
const supported = isServer || typeof window.showDirectoryPicker === 'function';
|
||||||
|
const commands = [
|
||||||
|
createCommand('open command palette', () => {
|
||||||
|
commandPalette()?.show();
|
||||||
|
}, { key: 'p', modifier: Modifier.Control | Modifier.Shift }),
|
||||||
|
];
|
||||||
|
|
||||||
|
return <MenuProvider commands={commands}>
|
||||||
<Title>Translation-Tool</Title>
|
<Title>Translation-Tool</Title>
|
||||||
|
|
||||||
<nav use:asMenuRoot>
|
<main inert={commandPalette()?.open()}>
|
||||||
<A class="logo" href="/"><BsTranslate /></A>
|
<nav use:asMenuRoot>
|
||||||
</nav>
|
<A class="logo" href="/"><BsTranslate /></A>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<Show when={supported} fallback={<span>too bad, so sad. Your browser does not support the File Access API</span>}>
|
<Show when={supported} fallback={<span>too bad, so sad. Your browser does not support the File Access API</span>}>
|
||||||
<FilesProvider>
|
<FilesProvider>
|
||||||
{props.children}
|
{props.children}
|
||||||
</FilesProvider>
|
</FilesProvider>
|
||||||
</Show>
|
</Show>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<CommandPalette api={setCommandPalette} />
|
||||||
</MenuProvider>
|
</MenuProvider>
|
||||||
}
|
}
|
||||||
|
|
|
@ -158,12 +158,8 @@ export default function Edit(props: ParentProps) {
|
||||||
const entry = _entries.get(key);
|
const entry = _entries.get(key);
|
||||||
const localEntry = entry?.[lang];
|
const localEntry = entry?.[lang];
|
||||||
|
|
||||||
console.log(entry, localEntry);
|
// TODO :: try to resolve to a file
|
||||||
|
//
|
||||||
// TODO :: this is not really a matrix, we should resolve the file when one does not exist
|
|
||||||
//
|
|
||||||
// happy path :: When we do have both an entry and localEntry and the localEntry has an id and that file is found
|
|
||||||
|
|
||||||
// | | entry | localEntry | id | file |
|
// | | entry | localEntry | id | file |
|
||||||
// |---|-------!------------|----!------!
|
// |---|-------!------------|----!------!
|
||||||
// | 1 | x | x | x | x |
|
// | 1 | x | x | x | x |
|
||||||
|
@ -171,11 +167,30 @@ export default function Edit(props: ParentProps) {
|
||||||
// | 3 | x | x | | |
|
// | 3 | x | x | | |
|
||||||
// | 4 | x | | | |
|
// | 4 | x | | | |
|
||||||
// | 5 | | | | |
|
// | 5 | | | | |
|
||||||
|
//
|
||||||
|
// 1. happy path
|
||||||
|
// 2. weird edge case
|
||||||
|
// 3. this language never had a file before. peek at at another language and create a new file with a mathing path
|
||||||
|
// 4. error?
|
||||||
|
// 5. error?
|
||||||
|
|
||||||
if (!localEntry) {
|
if (!localEntry) {
|
||||||
throw new Error('invalid edge case???');
|
throw new Error('invalid edge case???');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (localEntry.id === undefined) {
|
||||||
|
const [, alternativeLocalEntry] = Object.entries(entry).find(([l, e]) => l !== lang && e.id !== undefined) ?? [];
|
||||||
|
|
||||||
|
if (alternativeLocalEntry === undefined) {
|
||||||
|
// unable to find alternative. show a picker instead?
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = findFile(tree(), alternativeLocalEntry.id);
|
||||||
|
|
||||||
|
console.log('alt', file);
|
||||||
|
}
|
||||||
|
|
||||||
const file = findFile(tree(), localEntry.id);
|
const file = findFile(tree(), localEntry.id);
|
||||||
const fileExists = file !== undefined;
|
const fileExists = file !== undefined;
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue