mirror of
https://github.com/tabler/tabler.git
synced 2026-08-27 12:36:28 +04:00
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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tabler/preview": minor
|
||||
---
|
||||
|
||||
Added a `⌘K` command palette for pages and actions, opened from the navbar and sidebar search.
|
||||
@@ -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><ThemeSettings /></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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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>
|
||||
Reference in New Issue
Block a user