foldkit-command-palette
v0.1.0
Published
Command palette for Foldkit: a dialog with a query input and a sectioned listbox; ⌘K / Ctrl+K opens it, the host supplies the items.
Maintainers
Readme
foldkit-command-palette
A command palette for Foldkit: a dialog with a query input and a sectioned listbox. ⌘K (macOS) or Ctrl+K opens it from anywhere; the arrow keys move through every section, Enter or a click chooses, Escape closes.
It is a Submodel in the Elm shape (Model, Message, update, view, subscriptions) built on the
@foldkit/ui Dialog, so focus trapping, scroll lock and outside-click close come from there.
The palette owns no data. The host passes the sections and items to the view on every render and hears what happened through OutMessages. That keeps remote search, caching and stale-result handling in the host, where the data lives, and keeps the package free of any domain type. Every string on screen comes from the host, already translated.
License: MIT.
Install
pnpm add foldkit-command-palettePeer dependencies: effect (4.x), foldkit and @foldkit/ui (0.158.x). Import the default look once:
import "foldkit-command-palette/styles.css"Usage
A complete host, compiled against the published package:
import { Option, Schema } from "effect"
import { Subscription, Update } from "foldkit"
import type { Html, HtmlBuilder } from "foldkit/html"
import { defineMessageUnion } from "foldkit/message"
import { evo } from "foldkit/struct"
import * as Palette from "foldkit-command-palette"
// MODEL: the palette's own state, plus whatever the host searches.
const Model = Schema.Struct({ palette: Palette.Model, query: Schema.String })
type Model = typeof Model.Type
export const init = (): Model => ({ palette: Palette.init({ id: "quick-search" }), query: "" })
// MESSAGE
const Message = defineMessageUnion({
GotPaletteMessage: { message: Palette.Message },
ClickedSearch: {}
})
type Message = typeof Message.Type
// UPDATE
const paletteFold = {
read: (model: Model) => Option.some(model.palette),
write: (model: Model, palette: Palette.Model) => evo(model, { palette: () => palette }),
toParentMessage: (message: Palette.Message) => Message.GotPaletteMessage({ message }),
foldOutMessage: Palette.OutMessage.match<Update.Step<Model, Message>>({
// Filter the local sections; start a remote search and drop answers to older queries.
QueryChanged: ({ query }) => (model) => ({ model: evo(model, { query: () => query }) }),
// Navigate; the palette has already closed.
ItemChosen: () => (model) => ({ model }),
// Drop pending results, so a reopened palette does not flash the last search.
Closed: () => (model) => ({ model: evo(model, { query: () => "" }) })
})
}
const foldPalette = Update.foldChild({ update: Palette.update, ...paletteFold })
const openPalette = Update.foldChildStep({ update: Palette.open, ...paletteFold })
export const update = (model: Model, message: Message): Update.Return<Model, Message> =>
Message.match<Update.Return<Model, Message>>(message, {
ClickedSearch: () => openPalette(model), // the search button in the top bar
GotPaletteMessage: ({ message }) => foldPalette(model, message)
})
// SUBSCRIPTIONS: ⌘K / Ctrl+K from anywhere in the document.
export const subscriptions = Subscription.lift(Palette.subscriptions)<Model, Message>({
toChildModel: (model) => model.palette,
toParentMessage: (message) => Message.GotPaletteMessage({ message })
})
// VIEW
const sections: ReadonlyArray<Palette.Section> = [
{ title: "Pages", items: [{ id: "page:settings", label: "Settings" }] },
{ title: "People", items: [{ id: "person:7", label: "Alice Carter", detail: "+1 555 0100" }] }
]
export const view = (model: Model, h: HtmlBuilder<Message>): Html =>
h.submodel({
slotId: "quick-search",
model: model.palette,
view: Palette.view,
toParentMessage: (message) => Message.GotPaletteMessage({ message }),
viewInputs: {
title: "Quick search",
placeholder: "Pages and people",
emptyText: "Nothing found",
loadingText: "Searching…",
sections,
status: "idle",
footer: h.p([], ["↑↓ to move · Enter to open · Esc to close"]) // static markup only
}
})API
| export | shape |
|---|---|
| Model | { dialog: Dialog.Model, query: string, activeIndex: number } |
| init(config) | { id: string, isOpen?: boolean } → Model |
| update(model, message) | → { model, commands?, outMessage? }. Commands: the Dialog's own, and ScrollActiveIntoView after an arrow key |
| open(model) / close(model) | the same return, for a host's own button or route change |
| view | Submodel view taking ViewInputs |
| subscriptions | the ⌘K / Ctrl+K document listener |
| toShortcutMessage(event) | the shortcut filter, for a host that routes keys itself |
| isOpen, inputId, listboxId, optionId(model, index) | read-only helpers |
| ScrollActiveIntoView | the scroll Command, for hosts' Story tests: resolve it with Message.CompletedScrollActiveIntoView() |
ViewInputs
| field | type | |
|---|---|---|
| title | string | the dialog's accessible name (visually hidden) |
| description? | string | the dialog's accessible description (visually hidden); omit it rather than repeat the title |
| placeholder | string | |
| inputLabel? | string | the input's accessible name; defaults to the placeholder |
| emptyText | string | shown when no section has an item and nothing is loading or failed |
| loadingText | string | announced through the role="status" live region while status is "loading" |
| sections | ReadonlyArray<{ title, items: ReadonlyArray<{ id, label, detail?, icon? }> }> | empty sections are not rendered |
| status | "idle" \| "loading" \| { error: string } | loading keeps the sections on screen; an error renders as role="alert" inside the dialog |
| footer? | Html | under the list, e.g. keyboard hints. Static markup only: handlers inside it would dispatch into the palette, not the host |
Item ids must be unique across sections: ItemChosen carries the id back. icon is decorative, static
markup, rendered aria-hidden; the option is named by its label and described by its detail.
OutMessages
| OutMessage | when | the host should |
|---|---|---|
| QueryChanged{query} | every edit of the query | filter local sections; run remote searches (and drop results for older queries) |
| ItemChosen{itemId} | Enter on the active option, or a click | act on it. The palette has already closed and cleared its query, and no Closed follows |
| Closed | Escape, outside click, or the host's close | drop pending results. The query is already cleared |
Closed exists because the host keeps results for a query the palette no longer shows: without it, a
reopened palette would flash the last search.
Keyboard and ARIA
- The input is a
comboboxcontrolling alistbox;aria-activedescendantpoints at the activeoption, which carriesaria-selected="true". Focus never leaves the input. - Sections are
role="group", labelled by their title. - ↓ / ↑ move through all sections and wrap at both ends, and scroll the new active option into view when the list hides it; the pointer over an option makes it active without scrolling.
- Typing resets the active option to the first. When the host's list shrinks under the active index, the view clamps it to the last option.
- Enter chooses; Escape closes (the input claims Escape so the dialog closes once).
- ⌘K / Ctrl+K is claimed (
preventDefault) and opens the palette; with Shift or Alt, and every other key, it is left to the page. On a layout that types Latin letters the key labelled K counts (Dvorak, AZERTY); on one that does not (Cyrillic, Greek, Hebrew…) the key in the K position does, so the shortcut works without switching layouts. - "Searching…" and "Nothing found" are spoken through one
role="status"live region that stays in the dialog; an error is arole="alert".
Theming
Everything the stylesheet colours or sizes is a custom property with a neutral fallback. Set them on
:root (or any ancestor) to follow your theme:
| variable | default | used for |
|---|---|---|
| --fk-command-palette-surface | #ffffff | panel background |
| --fk-command-palette-text | #1a1a1a | text |
| --fk-command-palette-muted | #6b6b6b | section titles, details, placeholder, status |
| --fk-command-palette-line | #e5e5e5 | borders |
| --fk-command-palette-active | #f0f0f0 | active option background |
| --fk-command-palette-active-text | inherit | active option text |
| --fk-command-palette-danger | #b3261e | error text |
| --fk-command-palette-backdrop | rgba(0,0,0,.4) | backdrop |
| --fk-command-palette-radius | 0.75rem | panel corners |
| --fk-command-palette-width | 36rem | panel width |
| --fk-command-palette-top | 12vh | panel offset from the top |
Below 640 px the panel fills the screen. Class names (fk-command-palette__panel, __input, __option
with data-active, __section-title, __detail, __status, __error, __footer, …) are stable for hosts
that prefer their own stylesheet.
