diff --git a/docs/src/assets/styles.css b/docs/src/assets/styles.css index e0050c1..98a413d 100644 --- a/docs/src/assets/styles.css +++ b/docs/src/assets/styles.css @@ -251,9 +251,6 @@ .invisible { visibility: hidden; } - .visible { - visibility: visible; - } .sr-only { position: absolute; width: 1px; @@ -523,6 +520,9 @@ .max-w-\[300px\] { max-width: 300px; } + .max-w-\[450px\] { + max-width: 450px; + } .max-w-lg { max-width: var(--container-lg); } @@ -808,6 +808,9 @@ .bg-sidebar-primary { background-color: var(--color-sidebar-primary); } + .bg-transparent { + background-color: transparent; + } .object-cover { object-fit: cover; } @@ -835,9 +838,6 @@ .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); } @@ -853,9 +853,6 @@ .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); } @@ -1017,6 +1014,10 @@ .opacity-100 { opacity: 100%; } + .shadow-md { + --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px 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); + } .shadow-none { --tw-shadow: 0 0 #0000; box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); @@ -2371,7 +2372,7 @@ } } @layer components { - .command { + .command-dialog { inset-block: calc(var(--spacing) * 0); opacity: 0%; transition-property: all; @@ -2444,141 +2445,226 @@ 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 { + > button, > form[method='dialog'] { + position: absolute; + top: calc(var(--spacing) * 4); + right: calc(var(--spacing) * 4); + } + > button, > form[method='dialog'] > button { + border-radius: var(--radius-xs); + opacity: 70%; + --tw-ring-offset-color: var(--color-background); + transition-property: opacity; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + &:hover { + @media (hover: hover) { + opacity: 100%; + } + } + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + &:focus { + --tw-ring-color: var(--color-ring); + } + &:focus { + --tw-ring-offset-width: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + } + &:focus { + --tw-outline-style: none; + outline-style: none; + @media (forced-colors: active) { + outline: 2px solid transparent; + outline-offset: 2px; + } + } + &:disabled { + pointer-events: none; + } + &[data-state="open"] { + background-color: var(--color-accent); + } + &[data-state="open"] { + color: var(--color-muted-foreground); + } + & svg { + pointer-events: none; + } + & svg { + flex-shrink: 0; + } + & svg:not([class*='size-']) { 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%; - } } } + } + .command { + > header input { + height: calc(var(--spacing) * 12); + } [role='listbox'] { - max-height: 300px; - overflow-x: hidden; - overflow-y: auto; - padding: calc(var(--spacing) * 1); + padding-inline: calc(var(--spacing) * 2); [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; - } + padding-block: calc(var(--spacing) * 3); & 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); + width: calc(var(--spacing) * 5); + height: calc(var(--spacing) * 5); } } [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']))) { + } + } + } + .command { + > 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; } - [role='separator'] { - margin-inline: calc(var(--spacing) * -1); - border-color: var(--color-border); + & svg { + pointer-events: none; } - &: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)); + & svg { + flex-shrink: 0; } - &[data-empty]:not(:has([data-value]:not([aria-hidden='true'])))::before { - --tw-content: attr(data-empty); - content: var(--tw-content); + & svg:not([class*='size-']) { + width: calc(var(--spacing) * 4); + height: calc(var(--spacing) * 4); } - &:not([data-empty]):not(:has([data-value]:not([aria-hidden='true'])))::before { - --tw-content: 'No results found'; - content: var(--tw-content); + & svg:not([class*='text-']) { + 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); + margin-block: 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'] { @@ -2593,6 +2679,9 @@ } } } + &:has(> header input:not(:placeholder-shown)) [role='separator'] { + display: none; + } } } @layer components { @@ -3553,6 +3642,9 @@ content: var(--tw-content); } } + [data-popover]:has(> header input:not(:placeholder-shown)) [role='separator'] { + display: none; + } &:not([data-select-initialized]) [data-popover] [role='option'] { &:hover { @media (hover: hover) { diff --git a/docs/src/components/command.njk b/docs/src/components/command.njk index 9d61f4e..58797d4 100644 --- a/docs/src/components/command.njk +++ b/docs/src/components/command.njk @@ -1,295 +1,121 @@ --- layout: layouts/page.njk title: Command -description: Fast, composable command menu for quick navigation and actions. Triggered with ⌘K or Ctrl+K. +description: Fast, composable command menu for quick navigation and actions. 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 + - label: Dialog + id: example-dialog --- {% from "macros/code_preview.njk" import code_preview %} {% from "macros/code_block.njk" import code_block %} -{% from "command.njk" import command %} +{% from "command.njk" import command, command_dialog %} -{% set code_html %} +{% macro command_items(suggestions_id="suggestions", settings_id="settings") %} +
+ Suggestions +
+ {% lucide "calendar" %} + Calendar +
+
+ {% lucide "smile" %} + Search Emoji +
+
+ {% lucide "calculator" %} + Calculator +
+
+
+
+ Settings +
+ {% lucide "user" %} + Profile + ⌘P +
+
+ {% lucide "credit-card" %} + Billing + ⌘B +
+
+ {% lucide "settings" %} + Settings + ⌘S +
+
+{% endmacro %} + +{% set code_standalone %} {% call command( - id="demo-command", - placeholder="Type a command or search...", - empty_text="No results found." + id="demo-command-standalone", + main_attrs={ "class": "rounded-lg border shadow-md" } ) %} -
- - Calendar -
-
- - Search Emoji -
-
- - Calculator -
+{{ command_items("suggestions", "settings") }} {% endcall %} {% endset %} -{{ code_preview("command", code_html | prettyHtml) }} - -
-

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

-
+{{ code_preview("command-standalone", code_standalone | prettyHtml, class=" w-full max-w-[450px]") }}

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") }} +{{ code_block(code_standalone | prettyHtml, "html") }}

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

Dialog

-

This example includes data-keywords attributes for better search matching. Try searching for "payment" to see the Billing option appear.

+

Press ⌘J

+{% set code_dialog %} + + +{% call command_dialog(id="demo-command-dialog") %} +{{ command_items("cmd-suggestions", "cmd-settings") }} +{% endcall %} + + +{% endset %} + +{{ code_preview("command-dialog", code_dialog | prettyHtml) }} + +
+

To show the command menu in a dialog, use the command_dialog macro.

+
+ +{{ code_block(code_dialog | prettyHtml, "html") }} diff --git a/src/css/basecoat.css b/src/css/basecoat.css index f2bb74c..dfdce7a 100644 --- a/src/css/basecoat.css +++ b/src/css/basecoat.css @@ -444,7 +444,7 @@ /* Command */ @layer components { - .command { + .command-dialog { @apply inset-y-0 opacity-0 transition-all transition-discrete; &:is([open],:popover-open) { @@ -475,49 +475,79 @@ @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; + > button, + > form[method='dialog'] { + @apply absolute top-4 right-4; + } + > button, + > form[method='dialog'] > button { + @apply ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; + } + } - 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; - } + .command { + > header input { + @apply h-12; } [role='listbox'] { - @apply max-h-[300px] overflow-y-auto overflow-x-hidden p-1; + @apply px-2; [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; - } + @apply py-3 px-2 [&_svg]:size-5; } [role='heading'] { - @apply text-muted-foreground flex px-2 py-1.5 text-xs font-medium; + @apply px-2 py-1.5 font-medium text-muted-foreground; } - [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']; + } + } + } + + .command { + > 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 relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none aria-disabled:opacity-50 aria-disabled:pointer-events-none disabled:opacity-50 disabled:pointer-events-none w-full truncate [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4; + + &.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 my-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; } + &:has(> header input:not(:placeholder-shown)) [role='separator'] { + @apply hidden; + } } } @@ -659,7 +689,6 @@ @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 */ } } @@ -867,6 +896,9 @@ @apply content-['No_results_found']; } } + [data-popover]:has(> header input:not(:placeholder-shown)) [role='separator'] { + @apply hidden; + } &:not([data-select-initialized]) [data-popover] [role='option'] { @apply hover:bg-accent hover:text-accent-foreground; } diff --git a/src/jinja/command.html.jinja b/src/jinja/command.html.jinja index cb12d00..63e63e7 100644 --- a/src/jinja/command.html.jinja +++ b/src/jinja/command.html.jinja @@ -1,15 +1,13 @@ {# - Renders a command menu component with search and keyboard navigation. + Renders a standalone 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, @@ -17,26 +15,90 @@ placeholder="Type a command or search...", empty_text="No results found.", main_attrs={}, + input_attrs={}, + listbox_attrs={} +) %} +{% 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 command dialog (modal command palette). + + @param id {string} [optional] - Unique identifier for the command dialog. + @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 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 dialog should be open initially. +#} +{% macro command_dialog( + id=None, + items=None, + placeholder="Type a command or search...", + empty_text="No results found.", dialog_attrs={}, input_attrs={}, listbox_attrs={}, open=False ) %} -{% set id = id or ("command-" ~ range(100000, 999999) | random | string) %} +{% set id = id or ("command-dialog-" ~ range(100000, 999999) | random | string) %} -
+
+
-
+ + +
{% endmacro %} @@ -107,6 +173,7 @@ data-value="{{ item.value }}" {% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %} {% if item.label %}data-label="{{ item.label }}"{% endif %} + {% if item.disabled %}aria-disabled="true"{% endif %} {% if item.attrs %} {% for key, value in item.attrs.items() %} {{ key }}="{{ value }}" @@ -118,4 +185,3 @@ {% endif %} {% endfor %} {% endmacro %} - diff --git a/src/js/command.js b/src/js/command.js index 793a412..21633ee 100644 --- a/src/js/command.js +++ b/src/js/command.js @@ -1,13 +1,13 @@ (() => { - const initCommand = (dialog) => { - const input = dialog.querySelector('header input'); - const listbox = dialog.querySelector('[role="listbox"]'); + const initCommand = (container) => { + const input = container.querySelector('header input'); + const listbox = container.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); + console.error(`Command component initialization failed. Missing element(s): ${missing.join(', ')}`, container); return; } @@ -51,7 +51,10 @@ } }); - // empty state handled via CSS on [role="listbox"][data-empty] + if (visibleOptions.length > 0) { + setActiveOption(options.indexOf(visibleOptions[0])); + visibleOptions[0].scrollIntoView({ block: 'nearest' }); + } }; const selectOption = (option) => { @@ -61,9 +64,7 @@ detail: { value: option.dataset.value }, bubbles: true }); - dialog.dispatchEvent(event); - - dialog.close(); + container.dispatchEvent(event); }; input.addEventListener('input', filterOptions); @@ -92,13 +93,13 @@ 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; + } else if (currentVisibleIndex === -1) { + nextVisibleIndex = 0; } break; case 'Home': @@ -109,7 +110,7 @@ break; } - if (nextVisibleIndex !== currentVisibleIndex && nextVisibleIndex >= 0) { + if (nextVisibleIndex !== currentVisibleIndex) { const newActiveOption = visibleOptions[nextVisibleIndex]; setActiveOption(options.indexOf(newActiveOption)); newActiveOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); @@ -127,7 +128,11 @@ }); listbox.addEventListener('mouseleave', () => { - setActiveOption(-1); + if (visibleOptions.length > 0) { + setActiveOption(options.indexOf(visibleOptions[0])); + } else { + setActiveOption(-1); + } }); listbox.addEventListener('click', (event) => { @@ -139,32 +144,14 @@ 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 (visibleOptions.length > 0) { + setActiveOption(options.indexOf(visibleOptions[0])); + visibleOptions[0].scrollIntoView({ block: 'nearest' }); } - }); + + container.dataset.commandInitialized = true; + container.dispatchEvent(new CustomEvent('basecoat:initialized')); + }; 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 index edeff42..46b8e50 100644 --- a/src/nunjucks/command.njk +++ b/src/nunjucks/command.njk @@ -1,15 +1,13 @@ {# - Renders a command menu component with search and keyboard navigation. + Renders a standalone 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, @@ -17,60 +15,128 @@ placeholder="Type a command or search...", empty_text="No results found.", main_attrs={}, + input_attrs={}, + listbox_attrs={} +) %} +{% 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 command dialog (modal command palette). + + @param id {string} [optional] - Unique identifier for the command dialog. + @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 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 dialog should be open initially. +#} +{% macro command_dialog( + id=None, + items=None, + placeholder="Type a command or search...", + empty_text="No results found.", dialog_attrs={}, input_attrs={}, listbox_attrs={}, open=false ) %} -{% set id = id or ("command-" + (range(100000, 999999) | random | string)) %} +{% set id = id or ("command-dialog-" + (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 %} + {% if items and items.length > 0 %} + {{ render_command_items(items, id ~ "-items" if id else "items") }} + {% else %} + {{ caller() if caller }} + {% endif %} +
+
{% endmacro %} @@ -107,6 +173,7 @@ data-value="{{ item.value }}" {% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %} {% if item.label %}data-label="{{ item.label }}"{% endif %} + {% if item.disabled %}aria-disabled="true"{% endif %} {% if item.attrs %} {% for key, value in item.attrs %} {{ key }}="{{ value }}" @@ -118,4 +185,3 @@ {% endif %} {% endfor %} {% endmacro %} -