Add ⌘K command palette

- Mounted in BaseLayout, so the shortcut works on every page
- Navbar and sidebar search now open the palette instead of a dead form
This commit is contained in:
codecalm
2026-08-12 01:19:01 +02:00
parent 90f074eae1
commit f9230dc5a8
10 changed files with 601 additions and 15 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/preview": minor
---
Added a `⌘K` command palette for pages and actions, opened from the navbar and sidebar search.
+99
View File
@@ -0,0 +1,99 @@
---
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
import Card from '@ui/Card.astro'
import CardHeader from '@ui/CardHeader.astro'
import CardBody from '@ui/CardBody.astro'
import Icon from '@ui/Icon.astro'
const shortcuts = [
{ keys: ['⌘', 'K'], description: 'Open or close the palette (Ctrl + K on Windows and Linux)' },
{ keys: ['↑', '↓'], description: 'Move between results' },
{ keys: ['↵'], description: 'Open the highlighted page or run the highlighted action' },
{ keys: ['esc'], description: 'Close the palette' },
]
const features = [
{ icon: 'file-text', title: 'Every page', description: 'Results are built from the demo menu, so any page in the sidebar can be reached without opening it.' },
{ icon: 'bolt', title: 'Actions', description: 'Commands sit next to pages — toggling dark mode or opening theme settings takes two keystrokes.' },
{ icon: 'history', title: 'Recent', description: 'The last five things you picked are shown first when the search field is empty.' },
{ icon: 'keyboard', title: 'Keyboard only', description: 'Combobox roles and aria-activedescendant, so screen readers announce the highlighted result.' },
]
---
<DefaultLayout title="Command palette" pageHeader="Command palette" description="A ⌘K launcher for pages and actions." pageMenu="base.command-palette">
<div class="row row-cards">
<div class="col-12">
<Card>
<CardBody class="text-center py-5">
<p class="text-secondary">Press</p>
<p class="h1 mb-4">
<kbd>⌘</kbd> + <kbd>K</kbd>
</p>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modal-command-palette">
<Icon name="search" />
Or open it here
</button>
</CardBody>
</Card>
</div>
{
features.map((feature) => (
<div class="col-sm-6 col-lg-3">
<Card>
<CardBody>
<Icon name={feature.icon} class="mb-2 text-secondary" />
<h3 class="card-title">{feature.title}</h3>
<p class="text-secondary mb-0">{feature.description}</p>
</CardBody>
</Card>
</div>
))
}
<div class="col-lg-6">
<Card>
<CardHeader title="Keyboard shortcuts" />
<div class="table-responsive">
<table class="table table-vcenter card-table">
<tbody>
{
shortcuts.map((shortcut) => (
<tr>
<td class="w-1 text-nowrap">
{shortcut.keys.map((key, index) => (
<>
{index > 0 && ' '}
<kbd>{key}</kbd>
</>
))}
</td>
<td class="text-secondary">{shortcut.description}</td>
</tr>
))
}
</tbody>
</table>
</div>
</Card>
</div>
<div class="col-lg-6">
<Card>
<CardHeader title="How it works" />
<CardBody>
<p>
The palette is a Bootstrap modal with a search field and a listbox. Its items come from <code>shared/data/menu.json</code>, so pages added to the menu show up without any extra wiring.
</p>
<p>
Matching is a scored subsequence search: every typed character has to appear in order, with bonuses for consecutive characters and word starts. Typing <code>sgnin</code> finds <em>Sign in</em>, and matched characters are highlighted in the result.
</p>
<p class="mb-0">
The palette lives in <code>BaseLayout.astro</code>, next to <code>&lt;ThemeSettings /&gt;</code>, so <kbd>⌘</kbd>
<kbd>K</kbd> works on every page of the demo — this page only describes it.
</p>
</CardBody>
</Card>
</div>
</div>
</DefaultLayout>
+100
View File
@@ -77,3 +77,103 @@ $demo-icon-size: 4rem;
text-decoration: none;
}
}
//
// Command palette prototype (see shared/ui/CommandPalette.astro).
// Demo-only styles — nothing here is part of the core CSS API yet.
//
.command-palette-search {
display: flex;
align-items: center;
gap: 0.5rem;
padding-inline: 1rem;
border-bottom: var(--border-width) var(--border-style) var(--border-color);
.form-control {
flex: 1 1 auto;
width: auto;
min-width: 0;
padding-inline: 0;
padding-block: 0.875rem;
font-size: 1rem;
}
}
.command-palette-search-icon {
display: flex;
color: var(--secondary);
}
.command-palette-list {
max-height: 60vh;
margin: 0;
padding: 0.5rem;
list-style: none;
}
.command-palette-group {
padding: 0.5rem 0.75rem 0.25rem;
font-size: 0.625rem;
font-weight: var(--font-weight-medium);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--secondary);
}
.command-palette-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--border-radius);
cursor: pointer;
&.active {
background: var(--active-bg);
}
mark {
padding: 0;
background: none;
font-weight: var(--font-weight-bold);
color: inherit;
}
}
.command-palette-item-icon {
display: flex;
color: var(--secondary);
}
.command-palette-item-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-item-hint,
.command-palette-item-section {
font-size: 0.75rem;
color: var(--secondary);
white-space: nowrap;
}
.command-palette-item-section {
margin-inline-start: auto;
}
.command-palette-footer {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 0.5rem 1rem;
border-top: var(--border-width) var(--border-style) var(--border-color);
font-size: 0.75rem;
color: var(--secondary);
}
.command-palette-kbd {
font-size: 0.625rem;
}
+10 -7
View File
@@ -1,11 +1,14 @@
---
// Condensed and non-condensed navbar branches. DefaultLayout passes condensed,
// overlap, dark, hideBrand, sticky, transparent, class, hideSearch; navigation
// showcase pages also use sample, personId, hideLogo, smallLogo, showTitle,
// hideIcons, hideUsername, background, backgroundColor, fluidSearch.
// overlap, dark, hideBrand, sticky, transparent, class; navigation showcase
// pages also use sample, personId, hideLogo, smallLogo, showTitle, hideIcons,
// hideUsername, background, backgroundColor, fluidSearch, hideSearch.
//
// fluidSearch has no effect on output (search in the condensed branch is dead
// code); the prop is kept for call-site compatibility.
// Both branches render NavbarSearch, which opens the command palette. In the
// non-condensed branch it is xxl-only (the second row has no space for it
// below 1400px); ⌘K works at every width regardless.
//
// fluidSearch has no effect on output; the prop is kept for call-site compatibility.
import Icon from '@ui/Icon.astro';
import Logo from './Logo.astro';
import NavbarToggler from './NavbarToggler.astro';
@@ -14,7 +17,7 @@ import NavbarMenu from './NavbarMenu.astro';
import NavbarSearch from './NavbarSearch.astro';
interface Props {
hideSearch?: boolean;
hideSearch?: boolean | undefined;
/** equivalent of the page-menu front matter (active menu entry), e.g. "dashboards.default" */
pageMenu?: string | undefined;
condensed?: boolean | undefined;
@@ -132,7 +135,7 @@ const headerClass = [
<NavbarMenu sample={sample} hideIcons={hideIcons} pageMenu={pageMenu} />
</nav>
<!-- END NAVBAR MENU -->
{/* Search block omitted when condensed — nothing to render */}
<NavbarSearch class="my-2 my-md-0 ms-md-auto" />
</div>
)}
</div>
+8 -6
View File
@@ -1,7 +1,7 @@
---
// breakpoint param (default 'lg') is unused.
// Form action is "./" for root-level pages.
import InputIcon from '@ui/form/InputIcon.astro'
// Opens the command palette (see @ui/CommandPalette.astro, mounted in BaseLayout)
// rather than submitting a search form: one search surface, one result list.
import Icon from '@ui/Icon.astro'
interface Props {
class?: string
@@ -12,8 +12,10 @@ const { class: className } = Astro.props
<!-- BEGIN NAVBAR SEARCH -->
<div class={className}>
<form action="./" method="get" autocomplete="off" novalidate role="search">
<InputIcon prepend ariaLabel="Search in website" />
</form>
<button type="button" class="btn w-100 justify-content-start text-secondary" data-bs-toggle="modal" data-bs-target="#modal-command-palette" aria-label="Search pages and actions">
<Icon name="search" />
Search
<kbd class="ms-auto" data-command-palette-shortcut>⌘K</kbd>
</button>
</div>
<!-- END NAVBAR SEARCH -->
+4
View File
@@ -3,6 +3,7 @@ import NavbarToggler from './NavbarToggler.astro'
import Logo from './Logo.astro'
import NavbarSide from './NavbarSide.astro'
import NavbarMenu from './NavbarMenu.astro'
import NavbarSearch from './NavbarSearch.astro'
import type { Breakpoint } from './NavbarSide.astro'
interface Props {
@@ -32,6 +33,9 @@ const asideClass = ['navbar navbar-vertical', end && 'navbar-end', `navbar-expan
<NavbarSide class="d-lg-none" breakpoint={breakpoint} />
<nav class="collapse navbar-collapse" id="sidebar-menu" aria-label="Sidebar">
{/* Sidebar layouts hide the topbar, so this is their only search affordance. */}
<NavbarSearch class="mt-3" />
<NavbarMenu autoOpen class="pt-lg-3" keepOpen pageMenu={pageMenu} />
</nav>
</div>
+6 -1
View File
@@ -130,6 +130,11 @@
"url": "colors.html",
"title": "Colors"
},
"command-palette": {
"url": "command-palette.html",
"title": "Command palette",
"badge": "New"
},
"datagrid": {
"url": "datagrid.html",
"title": "Data grid"
@@ -554,4 +559,4 @@
}
}
}
}
}
+3
View File
@@ -1,5 +1,6 @@
---
import ThemeSettings from '@ui/ThemeSettings.astro'
import CommandPalette from '@ui/CommandPalette.astro'
import { site } from '@shared/lib/site'
import PageModals from '@shared/components/PageModals.astro'
import PageScripts from '@shared/components/PageScripts.astro'
@@ -106,6 +107,8 @@ const libJsFiles = (head: boolean) => pageLibEntries.filter(([, lib]) => Boolean
<ThemeSettings />
<CommandPalette />
{
libJsFiles(false).length > 0 && (
<Fragment>
+1 -1
View File
@@ -76,7 +76,7 @@ const { title, pageLibs, pageHeader, pageHeaderFile, pretitle, description, acti
!hideTopbar && (
<Fragment>
<Fragment set:html={'<!-- BEGIN NAVBAR -->'} />
<Navbar hideSearch pageMenu={pageMenu} condensed={navbarCondensed} dark={navbarDark} overlap={navbarOverlap} sticky={navbarSticky} hideBrand={navbarHideBrand} class={navbarClass} />
<Navbar hideSearch={sidebar} pageMenu={pageMenu} condensed={navbarCondensed} dark={navbarDark} overlap={navbarOverlap} sticky={navbarSticky} hideBrand={navbarHideBrand} class={navbarClass} />
<Fragment set:html={'<!-- END NAVBAR -->'} />
</Fragment>
)
+365
View File
@@ -0,0 +1,365 @@
---
// Prototype: a ⌘K command palette built on the Bootstrap modal.
// The item list is derived from shared/data/menu.json, so every page in the
// demo menu is reachable without touching this component.
import menu from '@data/menu.json'
import icons from '@data/icons.json'
import Icon from './Icon.astro'
import CaptureScript from '../components/CaptureScript.astro'
interface MenuNode {
title: string
url?: string
icon?: string
badge?: string
children?: Record<string, MenuNode>
}
// The palette ships on every page (BaseLayout), so this payload is inlined
// ~120 times over a build: anything derivable client-side (the recent-list key,
// whether a url is external) is left out on purpose.
interface PaletteItem {
title: string
/** Parent entry title for nested items, e.g. "Authentication" */
hint?: string | undefined
url?: string
action?: string
section: string
icon: string
}
const pages: PaletteItem[] = []
for (const group of Object.values(menu as Record<string, MenuNode>)) {
const icon = group.icon ?? 'file'
const walk = (nodes: Record<string, MenuNode>, hint?: string) => {
for (const node of Object.values(nodes)) {
if (node.children) {
walk(node.children, node.title)
} else if (node.url) {
pages.push({ title: node.title, hint, url: node.url, section: group.title, icon })
}
}
}
walk(group.children ?? {})
}
const actions: PaletteItem[] = [
{ title: 'Toggle dark mode', action: 'toggle-theme', section: 'Actions', icon: 'moon' },
{ title: 'Open theme settings', action: 'open-settings', section: 'Actions', icon: 'brush' },
{ title: 'Copy page URL', action: 'copy-url', section: 'Actions', icon: 'link' },
]
const items: PaletteItem[] = [...actions, ...pages]
// Items are rendered client-side, so their icons ship as SVG strings instead of
// <Icon> markup (same approach as Rating.astro's star).
const iconSvg: Record<string, string> = {}
for (const name of new Set(items.map((item) => item.icon))) {
const icon = (icons as unknown as Record<string, { svg: Record<string, string> }>)[name]
let svg = icon?.svg?.outline ?? ''
svg = svg.replace(/<path stroke="none" d="M0 0h24v24H0z" fill="none"\s*\/>/, '')
svg = svg.replace(/class="[^"]+"/, 'aria-hidden="true" focusable="false" class="icon"')
iconSvg[name] = svg
}
---
<!-- BEGIN COMMAND PALETTE -->
<div class="modal modal-blur fade" id="modal-command-palette" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="command-palette-title" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
<div class="modal-content command-palette">
<h2 class="visually-hidden" id="command-palette-title">Command palette</h2>
<div class="command-palette-search">
<span class="command-palette-search-icon"><Icon name="search" /></span>
<input type="text" class="form-control form-control-flush" id="command-palette-input" placeholder="Search pages and actions…" aria-label="Search pages and actions" role="combobox" aria-expanded="true" aria-controls="command-palette-list" aria-autocomplete="list" autocomplete="off" spellcheck="false" />
<kbd class="command-palette-kbd">esc</kbd>
</div>
<div class="modal-body p-0">
<ul class="command-palette-list" id="command-palette-list" role="listbox" aria-label="Results"></ul>
<div class="empty py-4" id="command-palette-empty" hidden>
<p class="empty-title">No results found</p>
<p class="empty-subtitle text-secondary">Try a different phrase.</p>
</div>
</div>
<div class="command-palette-footer">
<span><kbd class="command-palette-kbd">↑</kbd> <kbd class="command-palette-kbd">↓</kbd> to navigate</span>
<span><kbd class="command-palette-kbd">↵</kbd> to select</span>
<span><kbd class="command-palette-kbd">esc</kbd> to close</span>
</div>
</div>
</div>
</div>
<!-- END COMMAND PALETTE -->
<CaptureScript>
<!-- BEGIN COMMAND PALETTE SCRIPT -->
<script is:inline define:vars={{ items, iconSvg }}>
function initCommandPalette() {
const modalEl = document.getElementById('modal-command-palette')
const input = document.getElementById('command-palette-input')
const list = document.getElementById('command-palette-list')
const empty = document.getElementById('command-palette-empty')
if (!modalEl || !input || !list || !empty) return
const Modal = window.tabler && window.tabler.Modal
if (!Modal) return
const modal = Modal.getOrCreateInstance(modalEl)
const recentKey = 'tabler-command-palette-recent'
const recentLimit = 5
// Derived rather than serialized — see the note above the payload.
const keyOf = (item) => (item.url ? `url:${item.url}` : `action:${item.action}`)
const isExternal = (url) => /^https?:\/\//.test(url)
// Entry queued on select and executed once the modal is hidden, so an
// action (e.g. opening the settings offcanvas) never fights the modal's
// own backdrop/focus teardown.
let pending = null
let matches = []
let activeIndex = 0
const escapeHtml = (value) => value.replace(/[&<>"]/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[char])
// Subsequence match: every query character has to appear in order, with
// bonuses for consecutive hits and word starts so "sgnin" ranks "Sign in"
// above "Sign in with cover".
function fuzzy(text, query) {
const haystack = text.toLowerCase()
const indices = []
let score = 0
let from = 0
let previous = -2
for (const char of query) {
const index = haystack.indexOf(char, from)
if (index === -1) return null
if (index === previous + 1) score += 8
if (index === 0 || /[\s\-/]/.test(haystack[index - 1])) score += 6
score += 1
indices.push(index)
previous = index
from = index + 1
}
// Prefer matches that start early in a short title.
return { score: score - indices[0] * 0.4 - text.length * 0.05, indices }
}
function search(query) {
const normalized = query.trim().toLowerCase()
if (!normalized) {
const recent = readRecent()
return [...recent.map((item) => ({ item, indices: [], section: 'Recent' })), ...items.map((item) => ({ item, indices: [], section: item.section }))]
}
const found = []
for (const item of items) {
// Fall back to the section/hint so "forms" also finds pages that never
// repeat the word in their own title — but only the title is highlighted.
const match = fuzzy(item.title, normalized) ?? (fuzzy(`${item.section} ${item.hint ?? ''} ${item.title}`, normalized) ? { score: -50, indices: [] } : null)
if (match) found.push({ item, indices: match.indices, section: item.section, score: match.score })
}
found.sort((a, b) => b.score - a.score)
// Keep the sections in "best match first" order without breaking them apart.
const sections = []
for (const entry of found) if (!sections.includes(entry.section)) sections.push(entry.section)
return sections.flatMap((section) => found.filter((entry) => entry.section === section))
}
function readRecent() {
try {
const keys = JSON.parse(window.localStorage.getItem(recentKey) || '[]')
return keys.map((key) => items.find((item) => keyOf(item) === key)).filter(Boolean)
} catch {
return []
}
}
function remember(item) {
try {
const key = keyOf(item)
const keys = readRecent().map(keyOf)
window.localStorage.setItem(recentKey, JSON.stringify([key, ...keys.filter((entry) => entry !== key)].slice(0, recentLimit)))
} catch {
// Ignore — private mode or a full storage quota just means no history.
}
}
function highlight(title, indices) {
if (!indices.length) return escapeHtml(title)
return [...title].map((char, index) => (indices.includes(index) ? `<mark>${escapeHtml(char)}</mark>` : escapeHtml(char))).join('')
}
function render(query) {
matches = search(query)
list.innerHTML = ''
empty.hidden = matches.length > 0
let group = null
let section = null
matches.forEach((entry, index) => {
if (entry.section !== section) {
section = entry.section
group = document.createElement('li')
group.setAttribute('role', 'group')
group.setAttribute('aria-label', section)
group.innerHTML = `<div class="command-palette-group" aria-hidden="true">${escapeHtml(section)}</div>`
list.append(group)
}
const option = document.createElement('div')
option.className = 'command-palette-item'
option.id = `command-palette-item-${index}`
option.setAttribute('role', 'option')
option.setAttribute('aria-selected', 'false')
option.dataset.index = String(index)
option.innerHTML = [
`<span class="command-palette-item-icon">${iconSvg[entry.item.icon] ?? ''}</span>`,
`<span class="command-palette-item-title">${highlight(entry.item.title, entry.indices)}</span>`,
entry.item.hint ? `<span class="command-palette-item-hint">${escapeHtml(entry.item.hint)}</span>` : '',
`<span class="command-palette-item-section">${escapeHtml(entry.item.section)}</span>`,
].join('')
group.append(option)
})
setActive(0)
}
function setActive(index, scroll = true) {
if (!matches.length) return
activeIndex = (index + matches.length) % matches.length
list.querySelectorAll('.command-palette-item').forEach((option, i) => {
const active = i === activeIndex
option.classList.toggle('active', active)
option.setAttribute('aria-selected', active ? 'true' : 'false')
if (active && scroll) option.scrollIntoView({ block: 'nearest' })
})
input.setAttribute('aria-activedescendant', `command-palette-item-${activeIndex}`)
}
function select(entry) {
if (!entry) return
remember(entry.item)
pending = entry.item
modal.hide()
}
const actions = {
'toggle-theme': () => {
const next = document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'light' : 'dark'
document.documentElement.setAttribute('data-bs-theme', next)
window.localStorage.setItem('tabler-theme', next)
},
'open-settings': () => {
const offcanvasEl = document.getElementById('offcanvas-settings')
const Offcanvas = window.tabler && window.tabler.Offcanvas
if (offcanvasEl && Offcanvas) Offcanvas.getOrCreateInstance(offcanvasEl).show()
},
'copy-url': () => navigator.clipboard && navigator.clipboard.writeText(window.location.href),
}
input.addEventListener('input', () => render(input.value))
input.addEventListener('keydown', (event) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setActive(activeIndex + 1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setActive(activeIndex - 1)
} else if (event.key === 'Home') {
event.preventDefault()
setActive(0)
} else if (event.key === 'End') {
event.preventDefault()
setActive(matches.length - 1)
} else if (event.key === 'Enter') {
event.preventDefault()
select(matches[activeIndex])
}
})
list.addEventListener('mousemove', (event) => {
const option = event.target.closest('.command-palette-item')
if (option) setActive(Number(option.dataset.index), false)
})
list.addEventListener('click', (event) => {
const option = event.target.closest('.command-palette-item')
if (option) select(matches[Number(option.dataset.index)])
})
document.addEventListener('keydown', (event) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
modalEl.classList.contains('show') ? modal.hide() : modal.show()
}
})
modalEl.addEventListener('shown.bs.modal', () => {
input.focus()
input.select()
})
modalEl.addEventListener('show.bs.modal', () => {
// A select() that raced the close animation leaves `pending` set without
// a matching hidden.bs.modal — drop it so it can't fire on the next close.
pending = null
render(input.value)
})
modalEl.addEventListener('hidden.bs.modal', () => {
const item = pending
pending = null
if (!item) return
if (item.url) {
if (isExternal(item.url)) window.open(item.url, '_blank', 'noopener')
else window.location.href = `./${item.url}`
return
}
const action = actions[item.action]
if (action) action()
})
// Shortcut hints (navbar search, docs) are authored as ⌘K — rewrite them
// off Apple platforms, where the palette answers to Ctrl instead.
if (!/Mac|iPhone|iPad|iPod/.test(navigator.userAgent)) {
document.querySelectorAll('[data-command-palette-shortcut]').forEach((hint) => {
hint.textContent = 'Ctrl K'
})
}
render('')
}
document.readyState !== 'loading' ? initCommandPalette() : document.addEventListener('DOMContentLoaded', initCommandPalette, { once: true })
</script>
<!-- END COMMAND PALETTE SCRIPT -->
</CaptureScript>