diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..754cfdc
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,69 @@
+# Basecoat – Agent Guide
+
+## What is Basecoat?
+
+Basecoat is a vanilla JavaScript/HTML/CSS port of shadcn/ui components. It reuses shadcn/ui's design patterns and Tailwind CSS classes while maintaining semantic HTML, native browser elements, and full accessibility (ARIA attributes, proper hierarchies, keyboard navigation). The goal is to provide shadcn-quality components without React dependencies.
+
+## Quickstart
+
+- Install deps: `npm i`
+- Dev docs server: `npm run docs:dev`
+- Build CSS/JS: `npm run build`
+- Build static docs: `npm run docs:build`
+
+## What to edit (and what not)
+
+- Edit sources only:
+ - `src/css/basecoat.css` – All component CSS classes (Tailwind + custom)
+ - `src/js/*.js` – Individual component JS files (kebab-case, ESM) + `basecoat.js` (component registry)
+ - `src/nunjucks/*.njk` and `src/jinja/*.html.jinja` – Component template macros
+ - `docs/src/components/*.njk` – Component documentation pages with examples
+ - `docs/src/_includes/` – Layout templates, partials, and navigation
+- Do not edit build outputs:
+ - `packages/*/dist` – built CSS/JS bundles
+ - `_site/` – generated docs site
+
+## Project layout
+
+- Workspaces: root npm workspace with publishable packages in `packages/*`.
+- Packages:
+ - `packages/css` – built CSS and JS bundles under `dist/`.
+ - `packages/cli` – CLI; distributed assets live under `dist/`.
+- Docs site: `docs/src` (Eleventy); output goes to `_site/`.
+
+## Conventions
+
+- ESM everywhere (`import`/`export`).
+- Prettier defaults (2 spaces; single quotes per file preference).
+- Filenames for assets: kebab-case (e.g., `dropdown-menu.js`, `basecoat.cdn.css`).
+- JS naming: `camelCase` for vars/functions; `PascalCase` for DOM classes and exported component names when applicable.
+
+## Common tasks
+
+- Update a component's CSS/JS: Edit `src/css/basecoat.css` and/or `src/js/*.js`.
+- Update a component template: Edit `src/nunjucks/*.njk` and/or `src/jinja/*.html.jinja`.
+- Add a new component: Create JS, CSS, templates, and docs page under `docs/src/components/*.njk`.
+- Update navigation: Edit files in `docs/src/_includes/partials/`.
+
+## Testing
+
+No automated testing configured. User validates manually via the docs site (`npm run docs:dev`).
+
+## Commits and PRs
+
+- Commits: concise, imperative (≤72 chars), optional scope.
+ - Example: `select: prevent change event on init`.
+- PRs: include description, linked issues (e.g., `Closes #123`), and before/after screenshots or code snippets for visual/behavioral changes.
+- Keep PRs focused; update docs examples when components or APIs change.
+
+## Environment and tooling
+
+- Node 18+ recommended.
+- Eleventy config: `.eleventy.js`.
+- Tailwind via `@tailwindcss/cli` v4.
+- Build uses Tailwind CLI and `terser`; both are provided via devDependencies.
+
+## Package references
+
+- `packages/css` usage and details: see `packages/css/README.md`.
+- `packages/cli` usage and details: see `packages/cli/README.md`.
diff --git a/docs/src/_includes/layouts/base.njk b/docs/src/_includes/layouts/base.njk
index 444b061..fac8ac7 100644
--- a/docs/src/_includes/layouts/base.njk
+++ b/docs/src/_includes/layouts/base.njk
@@ -70,6 +70,7 @@
{# JS #}
+
diff --git a/docs/src/_includes/partials/sidebar.njk b/docs/src/_includes/partials/sidebar.njk
index 3d69bd9..14b11c8 100644
--- a/docs/src/_includes/partials/sidebar.njk
+++ b/docs/src/_includes/partials/sidebar.njk
@@ -88,6 +88,8 @@
{ label: "Button", url: "/components/button", attrs: link_attrs },
{ label: "Card", url: "/components/card", attrs: link_attrs },
{ label: "Checkbox", url: "/components/checkbox", attrs: link_attrs },
+ { label: "Kbd", url: "/components/kbd", attrs: link_attrs },
+ { label: "Command", url: "/components/command", attrs: link_attrs },
{ label: "Combobox", url: "/components/combobox", attrs: link_attrs },
{ label: "Dialog", url: "/components/dialog", attrs: link_attrs },
{ label: "Dropdown Menu", url: "/components/dropdown-menu", attrs: link_attrs },
diff --git a/docs/src/assets/styles.css b/docs/src/assets/styles.css
index ec436dc..e0050c1 100644
--- a/docs/src/assets/styles.css
+++ b/docs/src/assets/styles.css
@@ -251,6 +251,9 @@
.invisible {
visibility: hidden;
}
+ .visible {
+ visibility: visible;
+ }
.sr-only {
position: absolute;
width: 1px;
@@ -268,6 +271,9 @@
.relative {
position: relative;
}
+ .static {
+ position: static;
+ }
.sticky {
position: sticky;
}
@@ -686,6 +692,9 @@
.gap-x-2 {
column-gap: calc(var(--spacing) * 2);
}
+ .gap-x-4 {
+ column-gap: calc(var(--spacing) * 4);
+ }
.gap-x-10 {
column-gap: calc(var(--spacing) * 10);
}
@@ -826,6 +835,9 @@
.px-1 {
padding-inline: calc(var(--spacing) * 1);
}
+ .px-1\.5 {
+ padding-inline: calc(var(--spacing) * 1.5);
+ }
.px-2 {
padding-inline: calc(var(--spacing) * 2);
}
@@ -841,6 +853,9 @@
.px-\[0\.3rem\] {
padding-inline: 0.3rem;
}
+ .py-0\.5 {
+ padding-block: calc(var(--spacing) * 0.5);
+ }
.py-1\.5 {
padding-block: calc(var(--spacing) * 1.5);
}
@@ -859,6 +874,9 @@
.pt-2 {
padding-top: calc(var(--spacing) * 2);
}
+ .pr-2 {
+ padding-right: calc(var(--spacing) * 2);
+ }
.pb-4 {
padding-bottom: calc(var(--spacing) * 4);
}
@@ -2352,6 +2370,231 @@
display: none;
}
}
+@layer components {
+ .command {
+ inset-block: calc(var(--spacing) * 0);
+ opacity: 0%;
+ transition-property: all;
+ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
+ transition-duration: var(--tw-duration, var(--default-transition-duration));
+ transition-behavior: allow-discrete;
+ &:is([open],:popover-open) {
+ opacity: 100%;
+ &::backdrop {
+ opacity: 100%;
+ }
+ > article {
+ --tw-scale-x: 100%;
+ --tw-scale-y: 100%;
+ --tw-scale-z: 100%;
+ scale: var(--tw-scale-x) var(--tw-scale-y);
+ }
+ @starting-style {
+ opacity: 0%;
+ &::backdrop {
+ opacity: 0%;
+ }
+ > article {
+ --tw-scale-x: 95%;
+ --tw-scale-y: 95%;
+ --tw-scale-z: 95%;
+ scale: var(--tw-scale-x) var(--tw-scale-y);
+ }
+ }
+ }
+ &::backdrop {
+ background-color: color-mix(in srgb, #000 50%, transparent);
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: color-mix(in oklab, var(--color-black) 50%, transparent);
+ }
+ opacity: 0%;
+ transition-property: all;
+ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
+ transition-duration: var(--tw-duration, var(--default-transition-duration));
+ transition-behavior: allow-discrete;
+ }
+ > article {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ z-index: 50;
+ display: flex;
+ max-height: min(640px, calc(100% - 2rem));
+ width: 100%;
+ max-width: calc(100% - 2rem);
+ --tw-translate-x: calc(calc(1/2 * 100%) * -1);
+ translate: var(--tw-translate-x) var(--tw-translate-y);
+ --tw-translate-y: calc(calc(1/2 * 100%) * -1);
+ translate: var(--tw-translate-x) var(--tw-translate-y);
+ flex-direction: column;
+ overflow: hidden;
+ border-radius: var(--radius-lg);
+ border-style: var(--tw-border-style);
+ border-width: 1px;
+ background-color: var(--color-background);
+ --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
+ @media (width >= 40rem) {
+ max-width: var(--container-lg);
+ }
+ --tw-scale-x: 95%;
+ --tw-scale-y: 95%;
+ --tw-scale-z: 95%;
+ scale: var(--tw-scale-x) var(--tw-scale-y);
+ transition-property: all;
+ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
+ transition-duration: var(--tw-duration, var(--default-transition-duration));
+ > header {
+ display: flex;
+ align-items: center;
+ gap: calc(var(--spacing) * 2);
+ border-bottom-style: var(--tw-border-style);
+ border-bottom-width: 1px;
+ padding-inline: calc(var(--spacing) * 3);
+ svg {
+ width: calc(var(--spacing) * 4);
+ height: calc(var(--spacing) * 4);
+ flex-shrink: 0;
+ opacity: 50%;
+ }
+ input {
+ display: flex;
+ height: calc(var(--spacing) * 10);
+ width: 100%;
+ border-radius: var(--radius-md);
+ background-color: transparent;
+ padding-block: calc(var(--spacing) * 3);
+ font-size: var(--text-sm);
+ line-height: var(--tw-leading, var(--text-sm--line-height));
+ --tw-outline-style: none;
+ outline-style: none;
+ @media (forced-colors: active) {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ }
+ &::placeholder {
+ color: var(--color-muted-foreground);
+ }
+ &:disabled {
+ cursor: not-allowed;
+ }
+ &:disabled {
+ opacity: 50%;
+ }
+ }
+ }
+ [role='listbox'] {
+ max-height: 300px;
+ overflow-x: hidden;
+ overflow-y: auto;
+ padding: calc(var(--spacing) * 1);
+ [role='option'] {
+ position: relative;
+ display: flex;
+ width: 100%;
+ cursor: default;
+ align-items: center;
+ gap: calc(var(--spacing) * 2);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ border-radius: var(--radius-sm);
+ padding-inline: calc(var(--spacing) * 2);
+ padding-block: calc(var(--spacing) * 1.5);
+ font-size: var(--text-sm);
+ line-height: var(--tw-leading, var(--text-sm--line-height));
+ --tw-outline-style: none;
+ outline-style: none;
+ @media (forced-colors: active) {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ }
+ -webkit-user-select: none;
+ user-select: none;
+ &:disabled {
+ pointer-events: none;
+ }
+ &:disabled {
+ opacity: 50%;
+ }
+ &[aria-disabled="true"] {
+ pointer-events: none;
+ }
+ &[aria-disabled="true"] {
+ opacity: 50%;
+ }
+ &[aria-hidden="true"] {
+ display: none;
+ }
+ & svg {
+ width: calc(var(--spacing) * 4);
+ height: calc(var(--spacing) * 4);
+ }
+ & svg {
+ flex-shrink: 0;
+ }
+ & svg {
+ color: var(--color-muted-foreground);
+ }
+ &.active {
+ background-color: var(--color-accent);
+ color: var(--color-accent-foreground);
+ }
+ }
+ [role='heading'] {
+ display: flex;
+ padding-inline: calc(var(--spacing) * 2);
+ padding-block: calc(var(--spacing) * 1.5);
+ font-size: var(--text-xs);
+ line-height: var(--tw-leading, var(--text-xs--line-height));
+ --tw-font-weight: var(--font-weight-medium);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-muted-foreground);
+ }
+ [role='group']:not(:has([role='option']:not([aria-hidden='true']))) {
+ display: none;
+ }
+ [role='separator'] {
+ margin-inline: calc(var(--spacing) * -1);
+ border-color: var(--color-border);
+ }
+ &:not(:has([data-value]:not([aria-hidden='true'])))::before {
+ margin: calc(var(--spacing) * -1);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ padding-inline: calc(var(--spacing) * 3);
+ padding-block: calc(var(--spacing) * 6);
+ font-size: var(--text-sm);
+ line-height: var(--tw-leading, var(--text-sm--line-height));
+ }
+ &[data-empty]:not(:has([data-value]:not([aria-hidden='true'])))::before {
+ --tw-content: attr(data-empty);
+ content: var(--tw-content);
+ }
+ &:not([data-empty]):not(:has([data-value]:not([aria-hidden='true'])))::before {
+ --tw-content: 'No results found';
+ content: var(--tw-content);
+ }
+ }
+ }
+ &:not([data-command-initialized]) [role='option'] {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--color-accent);
+ }
+ }
+ &:hover {
+ @media (hover: hover) {
+ color: var(--color-accent-foreground);
+ }
+ }
+ }
+ }
+}
@layer components {
.dialog {
inset-block: calc(var(--spacing) * 0);
@@ -2736,6 +2979,33 @@
}
}
}
+@layer components {
+ kbd {
+ pointer-events: none;
+ display: inline-flex;
+ height: calc(var(--spacing) * 5);
+ width: fit-content;
+ min-width: calc(var(--spacing) * 5);
+ align-items: center;
+ justify-content: center;
+ gap: calc(var(--spacing) * 1);
+ border-radius: var(--radius-sm);
+ background-color: var(--color-muted);
+ padding-inline: calc(var(--spacing) * 1);
+ font-family: var(--font-sans);
+ font-size: var(--text-xs);
+ line-height: var(--tw-leading, var(--text-xs--line-height));
+ --tw-font-weight: var(--font-weight-medium);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-muted-foreground);
+ -webkit-user-select: none;
+ user-select: none;
+ & svg:not([class*='size-']) {
+ width: calc(var(--spacing) * 3);
+ height: calc(var(--spacing) * 3);
+ }
+ }
+}
@layer components {
.form label, .label {
display: flex;
diff --git a/docs/src/components/command.njk b/docs/src/components/command.njk
new file mode 100644
index 0000000..9d61f4e
--- /dev/null
+++ b/docs/src/components/command.njk
@@ -0,0 +1,295 @@
+---
+layout: layouts/page.njk
+title: Command
+description: Fast, composable command menu for quick navigation and actions. Triggered with ⌘K or Ctrl+K.
+toc:
+ - label: Usage
+ id: usage
+ children:
+ - label: HTML + JavaScript
+ id: usage-html-js
+ children:
+ - label: "Step 1: Include the JavaScript files"
+ id: usage-html-js-1
+ - label: "Step 2: Add your command HTML"
+ id: usage-html-js-2
+ - label: HTML structure
+ id: usage-html-js-3
+ - label: JavaScript events
+ id: usage-html-js-4
+ - label: JavaScript methods
+ id: usage-html-js-5
+ - label: Jinja and Nunjucks
+ id: usage-macro
+ - label: Examples
+ id: examples
+ children:
+ - label: With groups
+ id: example-groups
+---
+
+{% from "macros/code_preview.njk" import code_preview %}
+{% from "macros/code_block.njk" import code_block %}
+{% from "command.njk" import command %}
+
+{% set code_html %}
+{% call command(
+ id="demo-command",
+ placeholder="Type a command or search...",
+ empty_text="No results found."
+) %}
+
+
+
+{% endcall %}
+{% endset %}
+
+{{ code_preview("command", code_html | prettyHtml) }}
+
+
+ Press ⌘K (or Ctrl+K on Windows/Linux) to open the command menu.
+
+
+
+
+
+
+
+
+
+
+{% set code_script %}
+{% endset %}
+{{ code_block(code_script | prettyHtml, "html") }}
+
+
+
+
+
+{{ code_block(code_html | prettyHtml, "html") }}
+
+
+
+
+
+ <div class="command">
+ Wraps around the entire component.
+
+ <dialog class="command">
+ The native dialog element that creates the modal backdrop and handles focus trapping. Automatically opens with ⌘K/Ctrl+K keyboard shortcut.
+
+ <article>
+ Contains the command menu content.
+
+ <header>
+ Contains the search input with icon:
+
+ <input type="text" role="combobox">
+ The search input that filters commands. Attributes:
+
+ placeholder: Placeholder text
+ aria-autocomplete="list": Indicates autocomplete behavior
+ aria-expanded="true": Listbox is always visible
+ aria-controls="{ LISTBOX_ID }": Points to the listbox id
+ aria-activedescendant="{ OPTION_ID }": Points to the active option id
+
+
+
+
+ <div role="listbox">
+ The listbox containing the command options. Supports groups, separators, and headings:
+
+ <div role="option" data-value="{ VALUE }">
+ Command option that can be selected. Must have:
+
+ data-value: Unique value for the command
+ data-keywords Optional : Additional search keywords (comma-separated)
+ data-label Optional : Alternative label for filtering (defaults to text content)
+ id Optional : For aria-activedescendant
+
+
+ <div role="group"> Optional
+ Groups related commands together. Can have aria-labelledby pointing to a heading.
+ <span role="heading"> Optional
+ Group heading. Should have an id if referenced by aria-labelledby.
+ <hr role="separator"> Optional
+ Visual separator between groups.
+
+
+ <footer>
+ Empty state message, shown when no results match the search.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ basecoat:initialized
+ Once the component is fully initialized, it dispatches a custom (non-bubbling) basecoat:initialized event on itself.
+ command:select
+ When a command is selected (via click or Enter key), the component dispatches a custom (bubbling) command:select event on itself, with the selected value in event.detail (e.g. {detail: {value: "calendar"}}).
+
+
+
+{% set code_event %}
+
+{% endset %}
+{{ code_block(code_event | prettyHtml, "html") }}
+
+
+
+
+ The command component uses the native <dialog> element, so you can use the standard dialog methods:
+
+ showModal()
+ Opens the command menu as a modal dialog:
+
+
+
+{% set code_open %}
+
+{% endset %}
+{{ code_block(code_open | prettyHtml, "html") }}
+
+
+
+ close()
+ Closes the command menu:
+
+
+
+{% set code_close %}
+
+{% endset %}
+{{ code_block(code_close | prettyHtml, "html") }}
+
+
+
+
+
You can use the command() Nunjucks or Jinja macro for this component.
+
+
+
+
+{% set raw_code %}{% raw %}{% call command(
+ id="my-command",
+ placeholder="Type a command or search..."
+) %}
+ Calendar
+ Search Emoji
+ Calculator
+{% endcall %}{% endraw %}{% endset %}
+{{ code_block(raw_code, "jinja") }}
+
+
+
+
+
+{% set code_groups %}
+{% call command(
+ id="demo-command-groups",
+ placeholder="Type a command or search..."
+) %}
+
+
+
+
Settings
+
+
+
+ Billing
+
+
+
+{% endcall %}
+{% endset %}
+{{ code_preview("command-groups", code_groups | prettyHtml) }}
+
+
+ This example includes data-keywords attributes for better search matching. Try searching for "payment" to see the Billing option appear.
+
+
diff --git a/docs/src/components/kbd.njk b/docs/src/components/kbd.njk
new file mode 100644
index 0000000..e545a65
--- /dev/null
+++ b/docs/src/components/kbd.njk
@@ -0,0 +1,68 @@
+---
+layout: layouts/page.njk
+title: Kbd
+description: Used to display textual user input from keyboard.
+toc:
+ - label: Usage
+ id: usage
+ - label: Examples
+ id: examples
+ children:
+ - label: Group
+ id: example-group
+ - label: Button
+ id: example-button
+---
+
+{% from "macros/code_preview.njk" import code_preview %}
+{% from "macros/code_block.njk" import code_block %}
+
+{% set code_html %}
+
+
+ ⌘
+ ⇧
+ ⌥
+ ⌃
+
+
+ Ctrl
+ +
+ B
+
+
+{% endset %}
+
+{{ code_preview("kbd", code_html | prettyHtml) }}
+
+
+
+{{ code_block(code_html | prettyHtml, "html") }}
+
+
+
+
+
+{% set group_html %}
+
+ Use
+
+ Ctrl + B
+ Ctrl + K
+
+ to open the command palette
+
+{% endset %}
+
+{{ code_preview("kbd-group", group_html | prettyHtml) }}
+
+
+
+{% set button_html %}
+
+ Accept ⏎
+ Cancel Esc
+
+{% endset %}
+
+{{ code_preview("kbd-button", button_html | prettyHtml) }}
\ No newline at end of file
diff --git a/src/css/basecoat.css b/src/css/basecoat.css
index 4800630..f2bb74c 100644
--- a/src/css/basecoat.css
+++ b/src/css/basecoat.css
@@ -442,6 +442,85 @@
}
}
+/* Command */
+@layer components {
+ .command {
+ @apply inset-y-0 opacity-0 transition-all transition-discrete;
+
+ &:is([open],:popover-open) {
+ @apply opacity-100;
+
+ &::backdrop {
+ @apply opacity-100;
+ }
+ > article {
+ @apply scale-100;
+ }
+
+ @starting-style {
+ @apply opacity-0;
+
+ &::backdrop {
+ @apply opacity-0;
+ }
+ > article {
+ @apply scale-95;
+ }
+ }
+ }
+ &::backdrop {
+ @apply bg-black/50 opacity-0 transition-all transition-discrete;
+ }
+ > article {
+ @apply bg-background fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%_-_2rem)] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-lg border shadow-lg sm:max-w-lg max-h-[min(640px,calc(100%_-_2rem))];
+ @apply transition-all scale-95;
+
+ > header {
+ @apply flex items-center border-b px-3 gap-2;
+
+ svg {
+ @apply size-4 shrink-0 opacity-50;
+ }
+ input {
+ @apply placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50;
+ }
+ }
+ [role='listbox'] {
+ @apply max-h-[300px] overflow-y-auto overflow-x-hidden p-1;
+
+ [role='option'] {
+ @apply aria-hidden:hidden [&_svg]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none [&_svg]:shrink-0 [&_svg]:size-4 aria-disabled:opacity-50 aria-disabled:pointer-events-none disabled:opacity-50 disabled:pointer-events-none w-full truncate;
+
+ &.active {
+ @apply bg-accent text-accent-foreground;
+ }
+ }
+ [role='heading'] {
+ @apply text-muted-foreground flex px-2 py-1.5 text-xs font-medium;
+ }
+ [role='group']:not(:has([role='option']:not([aria-hidden='true']))) {
+ @apply hidden;
+ }
+ [role='separator'] {
+ @apply border-border -mx-1;
+ }
+ &:not(:has([data-value]: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 {
+ @apply content-[attr(data-empty)];
+ }
+ &:not([data-empty]):not(:has([data-value]:not([aria-hidden='true'])))::before {
+ @apply content-['No_results_found'];
+ }
+ }
+ }
+ &:not([data-command-initialized]) [role='option'] {
+ @apply hover:bg-accent hover:text-accent-foreground;
+ }
+ }
+}
+
/* Dialog */
@layer components {
.dialog {
@@ -576,6 +655,14 @@
}
}
+/* Kbd */
+@layer components {
+ kbd {
+ @apply bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none [&_svg:not([class*='size-'])]:size-3;
+ /* [[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10 */
+ }
+}
+
/* Label */
@layer components {
.form label,
@@ -1036,6 +1123,8 @@
/* Tooltip */
@layer components {
+
+
[data-tooltip] {
@apply relative;
diff --git a/src/jinja/command.html.jinja b/src/jinja/command.html.jinja
new file mode 100644
index 0000000..cb12d00
--- /dev/null
+++ b/src/jinja/command.html.jinja
@@ -0,0 +1,121 @@
+{#
+ Renders a command menu component with search and keyboard navigation.
+
+ @param id {string} [optional] - Unique identifier for the command component.
+ @param items {array} [optional] - Array of items to render (alternative to using caller).
+ @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 main_attrs {object} [optional] - Additional HTML attributes for the main container div.
+ @param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog element.
+ @param input_attrs {object} [optional] - Additional HTML attributes for the search input.
+ @param listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div.
+ @param open {boolean} [optional] [default=False] - Whether the command menu should be open initially.
+#}
+{% macro command(
+ id=None,
+ items=None,
+ placeholder="Type a command or search...",
+ empty_text="No results found.",
+ main_attrs={},
+ dialog_attrs={},
+ input_attrs={},
+ listbox_attrs={},
+ open=False
+) %}
+{% set id = id or ("command-" ~ range(100000, 999999) | random | string) %}
+
+
+
+
+ {% if items and items|length > 0 %}
+ {{ render_command_items(items, id ~ "-items" if id else "items") }}
+ {% else %}
+ {{ caller() if caller }}
+ {% endif %}
+
+
+
+{% endmacro %}
+
+{#
+ Renders a list of items for the command component.
+
+ @param items {array} - The array of items to render.
+ @param parent_id_prefix {string} [optional] - The prefix for the item id.
+#}
+{% macro render_command_items(items, parent_id_prefix="items") %}
+ {% for item in items %}
+ {% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
+ {% if item.type == "group" %}
+ {% set group_label_id = item.id if item.id else "group-label-" + item_id %}
+
+ {{ item.label }}
+ {{ render_command_items(item.items, item_id) if item.items }}
+
+ {% elif item.type == "separator" %}
+
+ {% elif item.type == "item" or not item.type %}
+
+ {{ item.content | safe if item.content else item.label }}
+
+ {% endif %}
+ {% endfor %}
+{% endmacro %}
+
diff --git a/src/js/command.js b/src/js/command.js
new file mode 100644
index 0000000..793a412
--- /dev/null
+++ b/src/js/command.js
@@ -0,0 +1,172 @@
+(() => {
+ const initCommand = (dialog) => {
+ const input = dialog.querySelector('header input');
+ const listbox = dialog.querySelector('[role="listbox"]');
+
+ if (!input || !listbox) {
+ const missing = [];
+ if (!input) missing.push('input');
+ if (!listbox) missing.push('listbox');
+ console.error(`Command component initialization failed. Missing element(s): ${missing.join(', ')}`, dialog);
+ return;
+ }
+
+ const options = Array.from(listbox.querySelectorAll('[role="option"]'));
+ let visibleOptions = [...options];
+ let activeIndex = -1;
+
+ const setActiveOption = (index) => {
+ if (activeIndex > -1 && options[activeIndex]) {
+ options[activeIndex].classList.remove('active');
+ }
+
+ activeIndex = index;
+
+ if (activeIndex > -1) {
+ const activeOption = options[activeIndex];
+ activeOption.classList.add('active');
+ if (activeOption.id) {
+ input.setAttribute('aria-activedescendant', activeOption.id);
+ } else {
+ input.removeAttribute('aria-activedescendant');
+ }
+ } else {
+ input.removeAttribute('aria-activedescendant');
+ }
+ };
+
+ const filterOptions = () => {
+ const searchTerm = input.value.trim().toLowerCase();
+
+ setActiveOption(-1);
+
+ visibleOptions = [];
+ options.forEach(option => {
+ const optionText = (option.dataset.label || option.textContent).trim().toLowerCase();
+ const keywords = (option.dataset.keywords || '').toLowerCase();
+ const matches = optionText.includes(searchTerm) || keywords.includes(searchTerm);
+ option.setAttribute('aria-hidden', String(!matches));
+ if (matches) {
+ visibleOptions.push(option);
+ }
+ });
+
+ // empty state handled via CSS on [role="listbox"][data-empty]
+ };
+
+ const selectOption = (option) => {
+ if (!option) return;
+
+ const event = new CustomEvent('command:select', {
+ detail: { value: option.dataset.value },
+ bubbles: true
+ });
+ dialog.dispatchEvent(event);
+
+ dialog.close();
+ };
+
+ input.addEventListener('input', filterOptions);
+
+ const handleKeyNavigation = (event) => {
+ if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End'].includes(event.key)) {
+ return;
+ }
+
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ if (activeIndex > -1) {
+ selectOption(options[activeIndex]);
+ }
+ return;
+ }
+
+ if (visibleOptions.length === 0) return;
+
+ event.preventDefault();
+
+ const currentVisibleIndex = activeIndex > -1 ? visibleOptions.indexOf(options[activeIndex]) : -1;
+ let nextVisibleIndex = currentVisibleIndex;
+
+ switch (event.key) {
+ case 'ArrowDown':
+ if (currentVisibleIndex < visibleOptions.length - 1) {
+ nextVisibleIndex = currentVisibleIndex + 1;
+ } else if (currentVisibleIndex === -1) {
+ nextVisibleIndex = 0;
+ }
+ break;
+ case 'ArrowUp':
+ if (currentVisibleIndex > 0) {
+ nextVisibleIndex = currentVisibleIndex - 1;
+ }
+ break;
+ case 'Home':
+ nextVisibleIndex = 0;
+ break;
+ case 'End':
+ nextVisibleIndex = visibleOptions.length - 1;
+ break;
+ }
+
+ if (nextVisibleIndex !== currentVisibleIndex && nextVisibleIndex >= 0) {
+ const newActiveOption = visibleOptions[nextVisibleIndex];
+ setActiveOption(options.indexOf(newActiveOption));
+ newActiveOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+ }
+ };
+
+ listbox.addEventListener('mousemove', (event) => {
+ const option = event.target.closest('[role="option"]');
+ if (option && visibleOptions.includes(option)) {
+ const index = options.indexOf(option);
+ if (index !== activeIndex) {
+ setActiveOption(index);
+ }
+ }
+ });
+
+ listbox.addEventListener('mouseleave', () => {
+ setActiveOption(-1);
+ });
+
+ listbox.addEventListener('click', (event) => {
+ const clickedOption = event.target.closest('[role="option"]');
+ if (clickedOption && visibleOptions.includes(clickedOption)) {
+ selectOption(clickedOption);
+ }
+ });
+
+ input.addEventListener('keydown', handleKeyNavigation);
+
+ dialog.addEventListener('close', () => {
+ input.value = '';
+ visibleOptions = [...options];
+ options.forEach(opt => opt.setAttribute('aria-hidden', 'false'));
+ setActiveOption(-1);
+ });
+
+ dialog.dataset.commandInitialized = true;
+ dialog.dispatchEvent(new CustomEvent('basecoat:initialized'));
+ };
+
+ document.addEventListener('keydown', (event) => {
+ if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
+ event.preventDefault();
+ const firstCommand = document.querySelector('.command');
+ if (firstCommand) {
+ if (firstCommand.open) {
+ firstCommand.close();
+ } else {
+ firstCommand.showModal();
+ const input = firstCommand.querySelector('header input');
+ if (input) input.focus();
+ }
+ }
+ }
+ });
+
+ if (window.basecoat) {
+ window.basecoat.register('command', '.command:not([data-command-initialized])', initCommand);
+ }
+})();
diff --git a/src/nunjucks/command.njk b/src/nunjucks/command.njk
new file mode 100644
index 0000000..edeff42
--- /dev/null
+++ b/src/nunjucks/command.njk
@@ -0,0 +1,121 @@
+{#
+ Renders a command menu component with search and keyboard navigation.
+
+ @param id {string} [optional] - Unique identifier for the command component.
+ @param items {array} [optional] - Array of items to render (alternative to using caller).
+ @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 main_attrs {object} [optional] - Additional HTML attributes for the main container div.
+ @param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog element.
+ @param input_attrs {object} [optional] - Additional HTML attributes for the search input.
+ @param listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div.
+ @param open {boolean} [optional] [default=false] - Whether the command menu should be open initially.
+#}
+{% macro command(
+ id=None,
+ items=None,
+ placeholder="Type a command or search...",
+ empty_text="No results found.",
+ main_attrs={},
+ dialog_attrs={},
+ input_attrs={},
+ listbox_attrs={},
+ open=false
+) %}
+{% set id = id or ("command-" + (range(100000, 999999) | random | string)) %}
+
+
+
+
+ {% if items and items.length > 0 %}
+ {{ render_command_items(items, id ~ "-items" if id else "items") }}
+ {% else %}
+ {{ caller() if caller }}
+ {% endif %}
+
+
+
+{% endmacro %}
+
+{#
+ Renders a list of items for the command component.
+
+ @param items {array} - The array of items to render.
+ @param parent_id_prefix {string} [optional] - The prefix for the item id.
+#}
+{% macro render_command_items(items, parent_id_prefix="items") %}
+ {% for item in items %}
+ {% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
+ {% if item.type == "group" %}
+ {% set group_label_id = item.id if item.id else "group-label-" + item_id %}
+
+ {{ item.label }}
+ {{ render_command_items(item.items, item_id) if item.items }}
+
+ {% elif item.type == "separator" %}
+
+ {% elif item.type == "item" or not item.type %}
+
+ {{ item.content | safe if item.content else item.label }}
+
+ {% endif %}
+ {% endfor %}
+{% endmacro %}
+