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." +) %} +
+ + Calendar +
+
+ + Search Emoji +
+
+ + Calculator +
+{% endcall %} +{% endset %} + +{{ code_preview("command", code_html | prettyHtml) }} + +
+

Press ⌘K (or Ctrl+K on Windows/Linux) to open the command menu.

+
+ +

Usage

+ +

HTML + JavaScript

+ +

Step 1: Include the JavaScript files

+ +
+

You can either include the JavaScript file for all the components, or just the one for this component by adding this to the <head> of your page:

+
+ +{% set code_script %} +{% endset %} +{{ code_block(code_script | prettyHtml, "html") }} + +
+ + Components with JavaScript + {% lucide "arrow-right" %} + + + Use the CLI + {% lucide "arrow-right" %} + + + command.js + {% lucide "arrow-right" %} + +
+ +

Step 2: Add your command HTML

+ +{{ code_block(code_html | prettyHtml, "html") }} + +

HTML structure

+ +
+
+
<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.
+
+
+
+
+
+
+
+
+ +

JavaScript events

+ +
+
+
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") }} + +

JavaScript methods

+ +
+

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") }} + +

Jinja and Nunjucks

+ +
+

You can use the command() Nunjucks or Jinja macro for this component.

+
+ +
+ + Use Nunjucks or Jinja macros + {% lucide "arrow-right" %} + + + Jinja macro + {% lucide "arrow-right" %} + + + Nunjucks macro + {% lucide "arrow-right" %} + +
+ +{% 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") }} + +

Examples

+ +

With groups

+ +{% set code_groups %} +{% call command( + id="demo-command-groups", + placeholder="Type a command or search..." +) %} +
+ Suggestions +
+ + Calendar +
+
+ + Search Emoji +
+
+ + Calculator +
+
+
+
+ Settings +
+ + Profile +
+
+ + Billing +
+
+ + Settings +
+
+{% 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) }} + +

Usage

+ +{{ code_block(code_html | prettyHtml, "html") }} + +

Examples

+ +

Group

+ +{% set group_html %} +

+ Use + + Ctrl + B + Ctrl + K + + to open the command palette +

+{% endset %} + +{{ code_preview("kbd-group", group_html | prettyHtml) }} + +

Button

+ +{% set button_html %} +
+ + +
+{% 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 %} +