import { Accessor, Component, For, JSX, Match, ParentComponent, Setter, Show, Switch, children, createContext, createEffect, createMemo, createSignal, createUniqueId, mergeProps, onCleanup, onMount, useContext } from "solid-js"; import { Portal } from "solid-js/web"; import { createStore } from "solid-js/store"; import { CommandType, Command, useCommands } from "../command"; import css from "./index.module.css"; export interface MenuContextType { ref: Accessor; setRef: Setter; addItems(items: (Item | Separator | ItemWithChildren)[]): void; items: Accessor<(Item | Separator | ItemWithChildren)[]>; commands(): CommandType[]; }; export interface Item { kind: 'leaf'; id: string; label: string; command: CommandType; } export interface Separator { kind: 'separator'; } export interface ItemWithChildren { kind: 'node'; id: string; label: string; children: (Item | Separator)[]; } const MenuContext = createContext(); export const MenuProvider: ParentComponent<{ commands?: CommandType[] }> = (props) => { const [ref, setRef] = createSignal(); const [store, setStore] = createStore<{ items: Map }>({ items: new Map }); const ctx = { ref, setRef, addItems(items: (Item | ItemWithChildren)[]) { return setStore('items', values => { for (const item of items) { values.set(item.id, item); } return new Map(values.entries()); }) }, items() { return store.items.values(); }, commands() { return store.items.values() .flatMap(item => item.kind === 'node' ? item.children.filter(c => c.kind === 'leaf').map(c => c.command) : [item.command]) .toArray() .concat(props.commands ?? []); }, }; return {props.children} ; } const useMenu = () => { const context = useContext(MenuContext); if (context === undefined) { throw new Error(`MenuContext is called outside of a `); } return context; } type ItemProps = { label: string, children: JSX.Element } | { command: CommandType }; const Item: Component = (props) => { const id = createUniqueId(); if (props.command) { return mergeProps(props, { id, kind: 'leaf' }) as unknown as JSX.Element; } const childItems = children(() => props.children); return mergeProps(props, { id, kind: 'node', get children() { return childItems.toArray(); } }) as unknown as JSX.Element; } const Separator: Component = (props) => { return mergeProps(props, { kind: 'separator' }) as unknown as JSX.Element; } const Root: ParentComponent<{}> = (props) => { const menuContext = useMenu(); const commandContext = useCommands(); const [current, setCurrent] = createSignal(); const items = children(() => props.children).toArray() as unknown as (Item | ItemWithChildren)[]; menuContext.addItems(items) const close = () => { const el = current(); if (el) { el.hidePopover(); setCurrent(undefined); } }; const onExecute = (command?: CommandType) => { return command ? (e: Event) => { close(); return commandContext?.execute(command, e); } : () => { } }; const Child: Component<{ command: CommandType }> = (props) => { return }; return { item => { item => <>
{ if (e.newState === 'open' && e.target !== null) { return setCurrent(e.target as HTMLElement); } }} > { child => { item => }
}
}
{ item => }
}
}; const Mount: Component = (props) => { const menu = useMenu(); return
; }; export const Menu = { Mount, Root, Item, Separator } as const; export interface CommandPaletteApi { readonly open: Accessor; show(): void; hide(): void; } export const CommandPalette: Component<{ api?: (api: CommandPaletteApi) => any, onSubmit?: SubmitHandler }> = (props) => { const [open, setOpen] = createSignal(false); const [root, setRoot] = createSignal(); const [search, setSearch] = createSignal>(); 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(); } }); const onSubmit = (command: CommandType) => { setOpen(false); props.onSubmit?.(command); command(); }; return setOpen(false)}> items={context.commands()} keySelector={item => item.label} context={setSearch} onSubmit={onSubmit}>{ (item, ctx) => { (part, index) => <> {ctx.filter()} {part} } } ; }; interface SubmitHandler { (item: T): any; } interface SearchContext { readonly filter: Accessor; readonly results: Accessor; readonly value: Accessor; searchFor(term: string): void; clear(): void; } interface SearchableListProps { items: T[]; keySelector(item: T): string; filter?: (item: T, search: string) => boolean; children(item: T, context: SearchContext): JSX.Element; context?: (context: SearchContext) => any, onSubmit?: SubmitHandler; } function SearchableList(props: SearchableListProps): JSX.Element { const [term, setTerm] = createSignal(''); const [input, setInput] = createSignal(); const [selected, setSelected] = createSignal(0); 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(() => results().at(selected())); const ctx = { filter: term, results, value, searchFor(term: string) { setTerm(term); }, clear() { setTerm(''); setSelected(0); }, }; 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 => Math.max(0, current - 1)); e.preventDefault(); } if (e.key === 'ArrowDown') { setSelected(current => Math.min(results().length - 1, current + 1)); e.preventDefault(); } }; const onSubmit = (e: SubmitEvent) => { e.preventDefault(); const v = value(); if (v === undefined) { return; } ctx.clear(); props.onSubmit?.(v); }; return
setTerm(e.target.value)} placeholder="start typing for command" autofocus autocomplete="off" /> { (result, index) =>
{props.children(result, ctx)}
}
; }; declare module "solid-js" { namespace JSX { interface HTMLAttributes { anchor?: string | undefined; } } }