Skip to content
kitn AI/UI

Menus & command pickers

Two interactive pickers cover most of what a chat UI needs. <kai-menu> is a cascading dropdown — the + attach menu, an effort selector, a row-actions menu. <kai-command> is a filterable, grouped picker — an @-mention palette or a command bar. Both are JSON-driven: you set an array, listen for an event. Open the menu, hover Skills, then search the palette below.

Both elements take their data as an items array set in JavaScript — arrays can’t be HTML attributes — and report selections through non-bubbling kai-* events you listen for on the element itself. That’s the whole contract.

Register the elements once, then drop the menu in. The built-in trigger is driven by props: trigger-icon for an icon button, trigger-label for a text button, trigger-icon-trailing for a select-style chevron. label gives an icon-only trigger an accessible name.

<script type="module">
import '@kitn.ai/ui/elements';
</script>
<kai-menu trigger-icon="plus" label="Add"></kai-menu>

Set items in JavaScript. Each leaf needs an id and a label; the rest is optional. Nest an items array for a submenu, drop in { separator: true } for a rule, { heading: true, label } for a non-interactive group label, checked for a toggle, disabled to grey one out, shortcut for a trailing hint, and icon for a leading glyph (a name from the icon set, an image URL, or text).

const menu = document.querySelector('kai-menu');
menu.items = [
{ heading: true, label: 'Actions' },
{ id: 'add-files', label: 'Add files or photos', icon: 'paperclip', shortcut: '⌘U' },
{ id: 'add-github', label: 'Add from GitHub', icon: 'github' },
{
label: 'Skills', icon: 'sparkles',
items: [
{ id: 'skill-creator', label: 'skill-creator', icon: 'sparkles' },
{ id: 'manage-skills', label: 'Manage skills', icon: 'settings' },
],
},
{ separator: true },
{ id: 'web-search', label: 'Web search', icon: 'globe', checked: true },
{ id: 'coming-soon', label: 'Coming soon', disabled: true },
];

kai-select fires when the user picks a leaf. Plain items carry { id }; checkbox items carry { id, checked }, where checked is the new state. A submenu’s parent doesn’t fire — only the leaf you land on does.

menu.addEventListener('kai-select', (e) => {
const { id, checked } = e.detail;
if (id === 'web-search') {
// checked is the NEW state — write a fresh array back to update the checkmark.
menu.items = menu.items.map((item) =>
item.id === 'web-search' ? { ...item, checked } : item,
);
} else {
console.log('picked:', id);
}
});

Reassign a new array to update a toggle — mutating an item in place won’t re-render. That’s the same streaming rule as everywhere else in the kit.

The trigger-icon / trigger-label props cover the common case. When you need your own button, project it into slot="trigger" — a slotted trigger replaces the built-in one.

<kai-menu>
<button slot="trigger" aria-label="More"></button>
</kai-menu>

<kai-command> renders a search box over a grouped list and handles the filtering and keyboard navigation for you. placeholder sets the input text; empty-label is shown when nothing matches.

<kai-command placeholder="Search people, files, commands…" empty-label="Nothing matches"></kai-command>

items is a flat array — no nesting. Each item needs an id and a label; icon and description are optional. Set group and the element buckets items under that heading automatically; items without a group render ungrouped at the top.

const palette = document.querySelector('kai-command');
palette.items = [
{ id: 'ana', label: 'Ana Ortiz', icon: 'message-circle', description: 'Design', group: 'People' },
{ id: 'sam', label: 'Sam Lee', icon: 'message-circle', description: 'Engineering', group: 'People' },
{ id: 'design-spec', label: 'design-spec.md', icon: 'file-text', description: '/docs', group: 'Files' },
{ id: 'roadmap', label: 'roadmap.md', icon: 'file-text', description: '/docs', group: 'Files' },
{ id: 'deploy', label: 'Deploy', icon: 'sparkles', description: 'Ship to production', group: 'Commands' },
];

kai-select fires with { id } on click or Enter. kai-query-change fires { value } on every keystroke — use it to fetch results from a server instead of filtering the static list, the standard move for an @-mention picker.

palette.addEventListener('kai-select', (e) => {
console.log('inserted:', e.detail.id);
});
palette.addEventListener('kai-query-change', (e) => {
// Filtering the static `items` happens for free. Hook this up only when the
// results come from a backend.
fetchMentions(e.detail.value).then((results) => { palette.items = results; });
});