diff --git a/docs/css/custom.css b/docs/css/custom.css index 44236ba..09a7b48 100644 --- a/docs/css/custom.css +++ b/docs/css/custom.css @@ -89,4 +89,26 @@ @apply max-md:ml-10 min-h-8 flex items-center; } } +} + +/* Search dialog - larger size for search results */ +#site-search > .command { + @apply sm:max-w-xl; +} +#site-search [role="menu"] { + @apply p-2 max-h-[500px]; +} + +/* Search results */ +.search-result { + @apply flex flex-col items-start gap-0.5 py-2 whitespace-normal overflow-visible text-wrap; +} +.search-result-title { + @apply font-medium text-foreground truncate; +} +.search-result-excerpt { + @apply text-sm text-muted-foreground line-clamp-2; +} +.search-result-excerpt mark { + @apply bg-yellow-200/60 dark:bg-yellow-500/30 text-inherit rounded-sm px-0.5; } \ No newline at end of file diff --git a/docs/src/_includes/layouts/base.njk b/docs/src/_includes/layouts/base.njk index d93d3c5..38a0cc0 100644 --- a/docs/src/_includes/layouts/base.njk +++ b/docs/src/_includes/layouts/base.njk @@ -77,6 +77,7 @@ + {# HTMX #} {# Highlight.js #} diff --git a/docs/src/_includes/layouts/layout.njk b/docs/src/_includes/layouts/layout.njk index b4f103a..4c3cf40 100644 --- a/docs/src/_includes/layouts/layout.njk +++ b/docs/src/_includes/layouts/layout.njk @@ -5,11 +5,11 @@ layout: layouts/base.njk {% include "partials/sidebar.njk" %}
-
+
{% include "partials/header.njk" %}
-
+
{{ content | safe }}
\ No newline at end of file diff --git a/docs/src/_includes/partials/header.njk b/docs/src/_includes/partials/header.njk index c9884bd..4df2a4e 100644 --- a/docs/src/_includes/partials/header.njk +++ b/docs/src/_includes/partials/header.njk @@ -1,3 +1,5 @@ +{% from "command.njk" import command_dialog %} +
+ + + + +
+ +{% endset %} +{{ code_block(code_async_register, "html") }} + +

API Reference

+ +
+
+
window.basecoat.commandAsync.register(id, config)
+
Register an async search handler for a command element. +
+
id string
+
The id or data-command-id of the command element. When using command_dialog macro, the dialog gets the id and the inner command gets data-command-id.
+
config.onSearch function required
+
Async function that receives (query, signal) and returns an array of items. Each item should have label (required), url (optional), icon (optional HTML), and keywords (optional).
+
config.minLength number
+
Minimum characters to trigger search. Default: 1
+
config.debounce number
+
Debounce delay in milliseconds. Default: 150
+
config.maxResults number
+
Maximum results to display. Default: 8
+
config.renderItem function
+
Optional custom renderer. Receives (item, id) and returns HTML string. Use window.basecoat.utils.escapeHtml() to sanitize user content.
+
+
+
+
+ +

Utilities

+ +
+

Basecoat exposes utility functions for use in custom renderItem implementations:

+
+
window.basecoat.utils.escapeHtml(str)
+
Escapes HTML special characters to prevent XSS. Use when rendering user-provided content.
+
window.basecoat.utils.isValidUrl(url)
+
Validates URLs to prevent javascript: protocol attacks. Returns true for http, https, relative paths, and hash links.
+
+
+ +{% set code_utils %}// Using utilities in custom renderItem +renderItem: (item, id) => { + const { escapeHtml, isValidUrl } = window.basecoat.utils; + const safeLabel = escapeHtml(item.label); + const href = isValidUrl(item.url) ? item.url : '#'; + return `${safeLabel}`; +}{% endset %} +{{ code_block(code_utils, "js") }} + +

States

+ +
+

The command element uses data-state to indicate the current state:

+
+
data-state="idle"
+
Initial state, no search active.
+
data-state="loading"
+
Search in progress. Shows "Searching..." message.
+
data-state="results"
+
Results are displayed.
+
data-state="empty"
+
No results found. Shows the data-empty message from the menu element.
+
data-state="error"
+
Search failed. Shows the error message via data-error on the menu.
+
+

You can style these states using CSS:

+
+ +{% set code_async_states %}.command[data-state="loading"] [role="menu"]::before { + content: "Searching..."; +} +.command[data-state="error"] [role="menu"]::before { + content: attr(data-error); + color: var(--destructive); +}{% endset %} +{{ code_block(code_async_states, "css") }} +

Examples

Dialog

@@ -258,4 +379,55 @@ toc: {% endset %} -{{ code_preview("command-dialog", code_dialog | prettyHtml) }} \ No newline at end of file +{{ code_preview("command-dialog", code_dialog | prettyHtml) }} + +

Async Search

+ +
+

This example shows how to integrate with Pagefind for full-text search. The search dialog in this documentation site uses this pattern.

+
+ +{% set code_pagefind %}// Lazy load Pagefind +let pagefind = null; +const loadPagefind = async () => { + if (!pagefind) { + pagefind = await import('/pagefind/pagefind.js'); + await pagefind.init(); + } + return pagefind; +}; + +// Register with command-async +window.basecoat.commandAsync.register('site-search', { + minLength: 2, + debounce: 150, + maxResults: 8, + onSearch: async (query, signal) => { + const pf = await loadPagefind(); + const search = await pf.search(query); + + if (signal?.aborted) return []; + + const results = await Promise.all( + search.results.slice(0, 8).map(r => r.data()) + ); + + return results.map(r => ({ + label: r.meta?.title || 'Untitled', + excerpt: r.excerpt || '', + url: r.url + })); + }, + renderItem: (item, id) => { + const { escapeHtml } = window.basecoat.utils; + return ` + ${escapeHtml(item.label)} + ${item.excerpt} + `; + } +});{% endset %} +{{ code_block(code_pagefind, "js") }} + +
+

The signal parameter is an AbortSignal that can be used to cancel in-flight requests when a new search is triggered.

+
\ No newline at end of file diff --git a/package-lock.json b/package-lock.json index df412f2..a15ea06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "basecoat", - "version": "0.3.2", + "version": "0.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "basecoat", - "version": "0.3.2", + "version": "0.3.4", "license": "MIT", "workspaces": [ "packages/*" @@ -16,6 +16,7 @@ "@grimlink/eleventy-plugin-lucide-icons": "^2.1.4", "@tailwindcss/cli": "^4.1.3", "concurrently": "^9.1.2", + "pagefind": "^1.4.0", "prettier": "^3.5.3", "tailwindcss": "^4.1.3", "terser": "^5.42.0" @@ -744,6 +745,90 @@ "node": ">= 8" } }, + "node_modules/@pagefind/darwin-arm64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.4.0.tgz", + "integrity": "sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/darwin-x64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.4.0.tgz", + "integrity": "sha512-e7JPIS6L9/cJfow+/IAqknsGqEPjJnVXGjpGm25bnq+NPdoD3c/7fAwr1OXkG4Ocjx6ZGSCijXEV4ryMcH2E3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/freebsd-x64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.4.0.tgz", + "integrity": "sha512-WcJVypXSZ+9HpiqZjFXMUobfFfZZ6NzIYtkhQ9eOhZrQpeY5uQFqNWLCk7w9RkMUwBv1HAMDW3YJQl/8OqsV0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@pagefind/linux-arm64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.4.0.tgz", + "integrity": "sha512-PIt8dkqt4W06KGmQjONw7EZbhDF+uXI7i0XtRLN1vjCUxM9vGPdtJc2mUyVPevjomrGz5M86M8bqTr6cgDp1Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/linux-x64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.4.0.tgz", + "integrity": "sha512-z4oddcWwQ0UHrTHR8psLnVlz6USGJ/eOlDPTDYZ4cI8TK8PgwRUPQZp9D2iJPNIPcS6Qx/E4TebjuGJOyK8Mmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/windows-x64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.4.0.tgz", + "integrity": "sha512-NkT+YAdgS2FPCn8mIA9bQhiBs+xmniMGq1LFPDhcFn0+2yIUEiIG06t7bsZlhdjknEQRTSdT7YitP6fC5qwP0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -3345,6 +3430,24 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pagefind": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.4.0.tgz", + "integrity": "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g==", + "dev": true, + "license": "MIT", + "bin": { + "pagefind": "lib/runner/bin.cjs" + }, + "optionalDependencies": { + "@pagefind/darwin-arm64": "1.4.0", + "@pagefind/darwin-x64": "1.4.0", + "@pagefind/freebsd-x64": "1.4.0", + "@pagefind/linux-arm64": "1.4.0", + "@pagefind/linux-x64": "1.4.0", + "@pagefind/windows-x64": "1.4.0" + } + }, "node_modules/parse-srcset": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", @@ -4337,7 +4440,7 @@ }, "packages/cli": { "name": "basecoat-cli", - "version": "0.3.2", + "version": "0.3.3", "license": "MIT", "dependencies": { "commander": "^13.1.0", @@ -4350,7 +4453,7 @@ }, "packages/css": { "name": "basecoat-css", - "version": "0.3.2", + "version": "0.3.3", "license": "MIT" } } diff --git a/package.json b/package.json index 450f439..834c2ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "basecoat", - "version": "0.3.3", + "version": "0.3.4", "description": "shadcn/ui for the rest of us", "author": "hunvreus", "type": "module", @@ -34,7 +34,7 @@ "homepage": "https://github.com/hunvreus/basecoat#readme", "scripts": { "build": "node ./scripts/build.js", - "docs:build": "tailwindcss -i ./docs/css/styles.css -o ./docs/src/assets/styles.css --minify && eleventy", + "docs:build": "tailwindcss -i ./docs/css/styles.css -o ./docs/src/assets/styles.css --minify && eleventy && npx pagefind --site _site", "docs:dev:css": "tailwindcss -i ./docs/css/styles.css -o ./docs/src/assets/styles.css --watch", "docs:dev:eleventy": "ELEVENTY_ENV=prod eleventy --serve", "docs:dev": "concurrently \"npm:docs:dev:css\" \"npm:docs:dev:eleventy\"" @@ -44,6 +44,7 @@ "@grimlink/eleventy-plugin-lucide-icons": "^2.1.4", "@tailwindcss/cli": "^4.1.3", "concurrently": "^9.1.2", + "pagefind": "^1.4.0", "prettier": "^3.5.3", "tailwindcss": "^4.1.3", "terser": "^5.42.0" diff --git a/src/css/basecoat.css b/src/css/basecoat.css index 6b2b05a..95d2a29 100644 --- a/src/css/basecoat.css +++ b/src/css/basecoat.css @@ -570,13 +570,16 @@ [role='separator'] { @apply border-border -mx-1 my-1; } - &:not(:has([data-value]:not([aria-hidden='true'])))::before { + } + /* Sync command empty state - only when NOT async (no data-state) */ + &:not([data-state]) [role='menu'] { + &:not(:has([role='menuitem']:not([aria-hidden='true'])))::before { @apply flex items-center justify-center py-6 px-3 text-sm truncate -m-1; } - &[data-empty]:not(:has([data-value]:not([aria-hidden='true'])))::before { + &[data-empty]:not(:has([role='menuitem']:not([aria-hidden='true'])))::before { @apply content-[attr(data-empty)]; } - &:not([data-empty]):not(:has([data-value]:not([aria-hidden='true'])))::before { + &:not([data-empty]):not(:has([role='menuitem']:not([aria-hidden='true'])))::before { @apply content-['No_results_found']; } } @@ -586,6 +589,28 @@ &:has(> header input:not(:placeholder-shown)) [role='separator'] { @apply hidden; } + + /* Async command states */ + &[data-state='idle'] [role='menu']::before { + @apply content-[attr(data-idle,'Type_to_search...')] flex items-center justify-center py-6 px-3 text-sm text-muted-foreground; + } + &[data-state='loading'] [role='menu']::before { + @apply content-['Searching...'] flex items-center justify-center py-6 px-3 text-sm text-muted-foreground; + } + &[data-state='empty'] [role='menu']::before { + @apply content-[attr(data-empty,'No_results_found')] flex items-center justify-center py-6 px-3 text-sm text-muted-foreground; + } + &[data-state='error'] [role='menu']::before { + @apply content-[attr(data-error,'Search_failed')] flex items-center justify-center py-6 px-3 text-sm text-destructive; + } + &[data-state]:not([data-state='results']) [role='menu'] [role='menuitem'] { + @apply hidden; + } + + /* Search result highlight marks */ + [role='menuitem'] mark { + @apply bg-yellow-200 dark:bg-yellow-800 text-inherit rounded-xs px-0.5; + } } } diff --git a/src/jinja/command.html.jinja b/src/jinja/command.html.jinja index b05feca..61791a3 100644 --- a/src/jinja/command.html.jinja +++ b/src/jinja/command.html.jinja @@ -72,6 +72,7 @@ @param placeholder {string} [optional] [default="Type a command or search..."] - Placeholder text for the search input. @param empty_text {string} [optional] [default="No results found."] - Text displayed when no results match the search. @param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog element. + @param main_attrs {object} [optional] - Additional HTML attributes for the command container div. @param input_attrs {object} [optional] - Additional HTML attributes for the search input. @param menu_attrs {object} [optional] - Additional HTML attributes for the menu div. @param open {boolean} [optional] [default=False] - Whether the command dialog should be open initially. @@ -82,6 +83,7 @@ placeholder="Type a command or search...", empty_text="No results found.", dialog_attrs={}, + main_attrs={}, input_attrs={}, menu_attrs={}, open=False @@ -97,7 +99,13 @@ {% if key != 'class' %}{{ key }}="{{ value }}"{% endif %} {% endfor %} > -
+
{ + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + }; + + // Utility: Validate URL to prevent javascript: protocol attacks + const isValidUrl = (url) => { + if (!url) return false; + try { + const parsed = new URL(url, window.location.origin); + return ['http:', 'https:', ''].includes(parsed.protocol) || url.startsWith('/'); + } catch { + return url.startsWith('/') || url.startsWith('#'); + } + }; + const registerComponent = (name, selector, initFunction) => { componentRegistry[name] = { selector, @@ -89,7 +108,11 @@ init: reinitComponent, initAll: reinitAll, start: startObserver, - stop: stopObserver + stop: stopObserver, + utils: { + escapeHtml, + isValidUrl + } }; document.addEventListener('DOMContentLoaded', () => { diff --git a/src/js/command.js b/src/js/command.js index c4970ea..9ef8bb0 100644 --- a/src/js/command.js +++ b/src/js/command.js @@ -1,4 +1,48 @@ (() => { + const asyncRegistry = {}; + const searchControllers = {}; + + const defaultAsyncConfig = { + minLength: 1, + debounce: 150, + maxResults: 8, + renderItem: null + }; + + // Use utilities from basecoat core + const getUtils = () => window.basecoat?.utils || {}; + + const defaultRenderItem = (item, id) => { + const { escapeHtml, isValidUrl } = getUtils(); + const tag = item.url && isValidUrl?.(item.url) ? 'a' : 'div'; + const href = tag === 'a' ? ` href="${escapeHtml?.(item.url) || ''}"` : ''; + const keywords = item.keywords ? ` data-keywords="${escapeHtml?.(item.keywords) || ''}"` : ''; + const label = escapeHtml?.(item.label) || item.label || ''; + // Icon is expected to be trusted HTML from the onSearch callback (user-controlled) + const icon = item.icon || ''; + return `<${tag} id="${id}" role="menuitem"${href}${keywords}>${icon}${label}`; + }; + + const setState = (container, state, message = '') => { + container.dataset.state = state; + const menu = container.querySelector('[role="menu"]'); + if (menu) { + if (state === 'error' && message) { + menu.dataset.error = message; + } else { + delete menu.dataset.error; + } + } + }; + + const debounce = (fn, ms) => { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }; + }; + const initCommand = (container) => { const input = container.querySelector('header input'); const menu = container.querySelector('[role="menu"]'); @@ -11,23 +55,32 @@ return; } - const allMenuItems = Array.from(menu.querySelectorAll('[role="menuitem"]')); - const menuItems = allMenuItems.filter(item => - !item.hasAttribute('disabled') && + // Check if this is an async command + const isAsync = container.dataset.commandAsync === 'true'; + const commandId = container.id || container.dataset.commandId; + const asyncConfig = isAsync && commandId ? asyncRegistry[commandId] : null; + + // For async mode, we need a registered config + if (isAsync && !asyncConfig) { + return; + } + + let allMenuItems = Array.from(menu.querySelectorAll('[role="menuitem"]')); + let menuItems = allMenuItems.filter(item => + !item.hasAttribute('disabled') && item.getAttribute('aria-disabled') !== 'true' ); let visibleMenuItems = [...menuItems]; let activeIndex = -1; const setActiveItem = (index) => { - if (activeIndex > -1 && menuItems[activeIndex]) { - menuItems[activeIndex].classList.remove('active'); - } + // Clear previous active + menu.querySelector('[role="menuitem"].active')?.classList.remove('active'); activeIndex = index; - if (activeIndex > -1) { - const activeItem = menuItems[activeIndex]; + if (activeIndex > -1 && visibleMenuItems[activeIndex]) { + const activeItem = visibleMenuItems[activeIndex]; activeItem.classList.add('active'); if (activeItem.id) { input.setAttribute('aria-activedescendant', activeItem.id); @@ -39,6 +92,16 @@ } }; + const refreshMenuItems = () => { + allMenuItems = Array.from(menu.querySelectorAll('[role="menuitem"]')); + menuItems = allMenuItems.filter(item => + !item.hasAttribute('disabled') && + item.getAttribute('aria-disabled') !== 'true' + ); + visibleMenuItems = [...menuItems]; + }; + + // Sync filtering for static items const filterMenuItems = () => { const searchTerm = input.value.trim().toLowerCase(); @@ -56,12 +119,83 @@ }); if (visibleMenuItems.length > 0) { - setActiveItem(menuItems.indexOf(visibleMenuItems[0])); + setActiveItem(0); visibleMenuItems[0].scrollIntoView({ block: 'nearest' }); } }; - input.addEventListener('input', filterMenuItems); + // Async search for dynamic items + const performAsyncSearch = async (query) => { + const id = commandId; + + // Cancel previous search + if (searchControllers[id]) { + searchControllers[id].abort(); + } + searchControllers[id] = new AbortController(); + + if (query.length < asyncConfig.minLength) { + menu.innerHTML = ''; + setState(container, 'idle'); + return; + } + + setState(container, 'loading'); + + try { + const results = await asyncConfig.onSearch(query, searchControllers[id].signal); + + // Check if aborted + if (searchControllers[id]?.signal.aborted) return; + + if (!results || results.length === 0) { + menu.innerHTML = ''; + setState(container, 'empty'); + } else { + const items = results.slice(0, asyncConfig.maxResults); + const renderFn = asyncConfig.renderItem || defaultRenderItem; + menu.innerHTML = items.map((item, i) => renderFn(item, `${id}-result-${i}`)).join(''); + setState(container, 'results'); + + // Refresh menu items after rendering new results + refreshMenuItems(); + + // Set first item as active + if (visibleMenuItems.length > 0) { + setActiveItem(0); + visibleMenuItems[0].scrollIntoView({ block: 'nearest' }); + } + } + + container.dispatchEvent(new CustomEvent('command:search', { + bubbles: true, + detail: { query, results: results || [] } + })); + } catch (error) { + if (error.name === 'AbortError') return; + console.error('Command async search error:', error); + menu.innerHTML = ''; + setState(container, 'error', error.message || 'Search failed'); + } finally { + delete searchControllers[id]; + } + }; + + // Set up input handler based on mode + if (isAsync && asyncConfig) { + const debounceMs = parseInt(container.dataset.commandDebounce, 10) || asyncConfig.debounce; + const debouncedSearch = debounce(performAsyncSearch, debounceMs); + + setState(container, 'idle'); + + input.addEventListener('input', () => { + const query = input.value.trim(); + debouncedSearch(query); + }); + } else { + // Sync filtering + input.addEventListener('input', filterMenuItems); + } const handleKeyNavigation = (event) => { if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End'].includes(event.key)) { @@ -70,8 +204,8 @@ if (event.key === 'Enter') { event.preventDefault(); - if (activeIndex > -1) { - menuItems[activeIndex]?.click(); + if (activeIndex > -1 && visibleMenuItems[activeIndex]) { + visibleMenuItems[activeIndex].click(); } return; } @@ -80,41 +214,41 @@ event.preventDefault(); - const currentVisibleIndex = activeIndex > -1 ? visibleMenuItems.indexOf(menuItems[activeIndex]) : -1; - let nextVisibleIndex = currentVisibleIndex; + let nextIndex = activeIndex; switch (event.key) { case 'ArrowDown': - if (currentVisibleIndex < visibleMenuItems.length - 1) { - nextVisibleIndex = currentVisibleIndex + 1; + if (activeIndex < visibleMenuItems.length - 1) { + nextIndex = activeIndex + 1; + } else if (activeIndex === -1) { + nextIndex = 0; } break; case 'ArrowUp': - if (currentVisibleIndex > 0) { - nextVisibleIndex = currentVisibleIndex - 1; - } else if (currentVisibleIndex === -1) { - nextVisibleIndex = 0; + if (activeIndex > 0) { + nextIndex = activeIndex - 1; + } else if (activeIndex === -1) { + nextIndex = visibleMenuItems.length - 1; } break; case 'Home': - nextVisibleIndex = 0; + nextIndex = 0; break; case 'End': - nextVisibleIndex = visibleMenuItems.length - 1; + nextIndex = visibleMenuItems.length - 1; break; } - if (nextVisibleIndex !== currentVisibleIndex) { - const newActiveItem = visibleMenuItems[nextVisibleIndex]; - setActiveItem(menuItems.indexOf(newActiveItem)); - newActiveItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + if (nextIndex !== activeIndex && nextIndex >= 0) { + setActiveItem(nextIndex); + visibleMenuItems[nextIndex].scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } }; menu.addEventListener('mousemove', (event) => { const menuItem = event.target.closest('[role="menuitem"]'); if (menuItem && visibleMenuItems.includes(menuItem)) { - const index = menuItems.indexOf(menuItem); + const index = visibleMenuItems.indexOf(menuItem); if (index !== activeIndex) { setActiveItem(index); } @@ -133,16 +267,46 @@ input.addEventListener('keydown', handleKeyNavigation); - if (visibleMenuItems.length > 0) { - setActiveItem(menuItems.indexOf(visibleMenuItems[0])); + // Initial active item for sync mode + if (!isAsync && visibleMenuItems.length > 0) { + setActiveItem(0); visibleMenuItems[0].scrollIntoView({ block: 'nearest' }); } - container.dataset.commandInitialized = true; + container.dataset.commandInitialized = 'true'; container.dispatchEvent(new CustomEvent('basecoat:initialized')); }; + // Find command container by ID (supports both id and data-command-id) + const findCommandById = (id) => { + return document.getElementById(id) || document.querySelector(`[data-command-id="${id}"]`); + }; + + // Register async command configuration + const registerAsync = (id, config) => { + asyncRegistry[id] = { ...defaultAsyncConfig, ...config }; + + // Initialize if element already exists and not yet initialized + const container = findCommandById(id); + if (container && container.dataset.commandAsync === 'true' && !container.dataset.commandInitialized) { + initCommand(container); + } + }; + + // Expose API if (window.basecoat) { window.basecoat.register('command', '.command:not([data-command-initialized])', initCommand); + + // Async API + window.basecoat.commandAsync = { + register: registerAsync, + initAll: () => { + document.querySelectorAll('.command[data-command-async="true"]:not([data-command-initialized])').forEach(container => { + if (container.id && asyncRegistry[container.id]) { + initCommand(container); + } + }); + } + }; } })(); diff --git a/src/nunjucks/command.njk b/src/nunjucks/command.njk index f3b6b84..033b8eb 100644 --- a/src/nunjucks/command.njk +++ b/src/nunjucks/command.njk @@ -31,7 +31,7 @@ -
+