Basecoat animates <details> elements by default. The example above uses Alpine.js to handle the open/close state along with some Tailwind CSS classes.
\ No newline at end of file
+
Basecoat already animates <details> elements by default. The example add some Tailwind CSS utility classes for style and a bit of vanilla JavaScript to handle the open/close state.
\ No newline at end of file
diff --git a/docs/src/components/alert-dialog.njk b/docs/src/components/alert-dialog.njk
index 2b0d83c..dde193d 100644
--- a/docs/src/components/alert-dialog.njk
+++ b/docs/src/components/alert-dialog.njk
@@ -43,7 +43,7 @@ toc:
The Alert Dialog component is identical to Dialog except for two small differences: we do not display a close button and we do not let the user close the dialog when clicking on the backdrop (no onclick attribute on the <dialog> or the <article> elements).
Avatars are fairly simple to implement with Tailwind. The example above shows how to implement one with Tailwind and an <img> element. More examples can be found in the kitchen sink.
\ No newline at end of file
+
+
Avatars are just <img> elements styled with Tailwind utility classes.
+
+
+{% set code_simple %}
+
+{% endset %}
+{{ code_block(code_simple | prettyHtml, "html") }}
\ No newline at end of file
diff --git a/docs/src/components/badge.njk b/docs/src/components/badge.njk
index ef1f67a..5eff349 100644
--- a/docs/src/components/badge.njk
+++ b/docs/src/components/badge.njk
@@ -25,7 +25,25 @@ toc:
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
-{% set code %}Badge{% endset %}
+{% set code %}
+
This example uses some simple Tailwind CSS styles along with the Dropdown Menu component (which may require JavaScript).
+
You can style an ordered list of links combined with chevron icons as show below. The example above demonstrates a more complex use case using the Dropdown Menu component.
-{{ code_block(code_html | prettyHtml, class="") }}
\ No newline at end of file
+{% set code_simple %}
+
+
Simply wrap buttons you want to group together in a <div role="group" class="button-group"> element. You can also use the data-orientation="vertical" attribute to stack buttons vertically. You can also group buttons with dropdown menus or popovers (provided the trigger is a button). Finally, you can use a separator to visually divide buttons within a group (for secondary buttons for example).
For dialog variant, wraps a <div class="command"> to display as a modal. Uses native <dialog> behavior and requires aria-label or aria-labelledby for accessibility.
+
+
<div class="command">
+
Wraps around the entire command component. Can be used standalone or inside a dialog.
+
+
<header>
+
Contains the search input to filter command options.
+
+
<input type="text">
+
The search input. Should have:
+
+
role="combobox": indicates the input controls a menu with filtering
+
aria-expanded="true": the menu is always expanded
+
aria-controls="{ MENU_ID }": points to the menu's id
+
+
+
+
+
<div role="menu">
+
The menu containing the command items. Should have id="{ MENU_ID }" refered to by the input's aria-controls attribute.
+
+
<div role="menuitem">
+
Command item that triggers an action when clicked. In a command-dialog, clicking a menuitem automatically closes the dialog unless the data-keep-command-open attribute is present. Optional attributes:
+
+
data-label: custom label for filtering (if different from text content)
+
data-keywords: additional keywords for enhanced filtering
+
aria-disabled="true": marks item as disabled (excluded from keyboard navigation and filtering)
+
+
+
<hr role="separator">Optional
+
Separator between groups/items. Hidden when filtering is active.
+
<div role="group">Optional
+
Group of items, can have aria-labelledby attribute to link to a heading.
+
<span role="heading">
+
Group heading, must have an id attribute if you use the aria-labelledby attribute on the group.
Empty states are pure HTML composition using Tailwind utility classes. Use flex layout utilities to center content, and compose with semantic elements like <header>, <h3>, <p>, and <button>.
+
Browse the examples below and copy the pattern that fits your needs.
Field uses semantic HTML with minimal classes. Two main patterns can be used independently or composed together:
+
+
<div role="group" class="field"> for individual field containers (label, input, helper text, errors)
+
<fieldset class="fieldset"> for grouping multiple related fields together
+
+
Elements inside these containers are styled automatically based on their purpose. Use aria-describedby to connect helper text and errors to inputs for accessibility.
+
Fields have the following HTML structure:
+
+
<div role="group" class="field">
+
Field container. Add data-orientation="horizontal" to align controls horizontally. For responsive layouts, use standard Tailwind responsive classes.
+
+
<label>
+
Label for the input.
+
<input>, <select> or <textarea>
+
Form input.
+
<p>Optional
+
Helper text. Use aria-describedby on the input to reference this element's id.
+
<p role="alert">Optional
+
Error message. Use aria-describedby on the input and aria-invalid="true" to reference this element's id.
+
<section>Optional
+
Wraps label and description when the label sits beside the input (e.g. data-orientation="horizontal"). Not required if you have no description.
+
+
+
+
Fieldsets have the following HTML structure:
+
+
<fieldset class="fieldset">
+
Container for grouping related fields.
+
+
<legend>
+
Heading for the fieldset.
+
<p>Optional
+
Description text. When placed directly after <legend>, automatically styled as muted text.
+
<div role="group" class="field">Optional
+
Individual field containers (see structure above). You can include multiple .field elements, or place form controls directly in the fieldset.
For responsive layouts, use standard Tailwind responsive classes directly on .field. This example stacks vertically on mobile and switches to horizontal at the md breakpoint.
+
+
+{% set code_responsive %}
+
+{% endset %}
+
+{{ code_preview("field-responsive", code_responsive, class="w-full max-w-3xl") }}
\ No newline at end of file
diff --git a/docs/src/components/input-group.njk b/docs/src/components/input-group.njk
new file mode 100644
index 0000000..590bd9d
--- /dev/null
+++ b/docs/src/components/input-group.njk
@@ -0,0 +1,372 @@
+---
+layout: layouts/page.njk
+title: Input Group
+description: Display additional information or actions to an input or textarea.
+toc:
+ - label: Usage
+ id: usage
+ - label: Examples
+ id: examples
+ children:
+ - label: Icon
+ id: example-icon
+ - label: Text
+ id: example-text
+ - label: Button
+ id: example-button
+ - label: Tooltip
+ id: example-tooltip
+ - label: Textarea
+ id: example-textarea
+ - label: Spinner
+ id: example-spinner
+ - label: Dropdown
+ id: example-dropdown
+ - label: Group
+ id: example-group
+---
+
+{% from "macros/code_preview.njk" import code_preview %}
+{% from "macros/code_block.njk" import code_block %}
+{% from "dropdown-menu.njk" import dropdown_menu %}
+{% from "popover.njk" import popover %}
+
+
+ {% lucide "circle-alert" %}
+
There is no dedicated Input Group component in Basecoat.
Input groups are pure HTML composition using Tailwind utility classes. There is no dedicated component: use relative positioning on a wrapper, add your content with absolute positioning, and adjust input padding to make room.
+
Browse the examples below and copy the pattern that fits your needs.
Items are pure HTML composition using Tailwind utility classes. Use semantic elements like <article> or <a>, apply flex layout, and compose with standard HTML elements like <h3>, <p>, <img>, or <button>.
+
Browse the examples below and copy the pattern that fits your needs.
Progress bars are pure HTML composition using Tailwind utility classes. Use a container div for the track and an inner div for the indicator. Set the indicator's width with inline styles or via JavaScript.
+
+
+{% set code_usage %}
+
+
+
+{% endset %}
+
+{{ code_block(code_usage) }}
+
diff --git a/docs/src/components/spinner.njk b/docs/src/components/spinner.njk
new file mode 100644
index 0000000..70bc2fe
--- /dev/null
+++ b/docs/src/components/spinner.njk
@@ -0,0 +1,214 @@
+---
+layout: layouts/page.njk
+title: Spinner
+description: An indicator that can be used to show a loading state.
+toc:
+ - label: Usage
+ id: usage
+ - label: Examples
+ id: examples
+ children:
+ - label: Size
+ id: example-size
+ - label: Color
+ id: example-color
+ - label: Button
+ id: example-button
+ - label: Badge
+ id: example-badge
+ - label: Input Group
+ id: example-input-group
+ - label: Empty
+ id: example-empty
+ - label: Item
+ id: example-item
+---
+
+{% from "macros/code_preview.njk" import code_preview %}
+{% from "macros/code_block.njk" import code_block %}
+
+
+ {% lucide "circle-alert" %}
+
There is no dedicated Spinner component in Basecoat.
Spinners are pure HTML using the loader-circle Lucide icon with the animate-spin Tailwind utility. Add role="status" and aria-label="Loading" for accessibility.
+
Browse the examples below and copy the pattern that fits your needs.
Basecoat uses Lucide icons. You have three options:
+
+
+
Copy SVG code: Visit lucide.dev/icons, click any icon to copy the SVG, and paste it directly in your HTML. Best for simple projects or occasional icon usage.
+
Install lucide: Follow the installation guide to install the lucide package and dynamically render icons. Best for projects that need many icons or want tree-shaking.
+
Use framework packages: Browse framework-specific packages (React, Vue, Angular, etc.) for component-based icon usage. Best if you're using a supported framework.
+ {% 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 menu_attrs {object} [optional] - Additional HTML attributes for the menu div.
+ @param open {boolean} [optional] [default=False] - Whether the command dialog should be open initially.
+#}
+{% macro command_dialog(
+ id=None,
+ items=None,
+ placeholder="Type a command or search...",
+ empty_text="No results found.",
+ dialog_attrs={},
+ input_attrs={},
+ menu_attrs={},
+ open=False
+) %}
+{% set id = id or ("command-dialog-" ~ range(100000, 999999) | random | string) %}
+
+{% endmacro %}
+
+{#
+ Renders a list of items for the command component.
+
+ @param items {array} - Array of items to render. Each item can be:
+ - { type: "item", label: "Text", content: "HTML", keywords: "search terms", disabled: true, attrs: {} }
+ - { type: "group", label: "Group Title", items: [...] }
+ - { type: "separator" }
+ Note: Use attrs to add data-label for alternative search text if needed.
+ @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 %}
+
+ {% endif %}
+ {% endfor %}
+{% endmacro %}
diff --git a/src/jinja/dialog.html.jinja b/src/jinja/dialog.html.jinja
index 02f523e..74737b7 100644
--- a/src/jinja/dialog.html.jinja
+++ b/src/jinja/dialog.html.jinja
@@ -8,7 +8,7 @@
@param footer {string} [optional] - HTML content for the dialog footer.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
- @param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog content article.
+ @param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog content container.
@param header_attrs {object} [optional] - Additional HTML attributes for the dialog header.
@param body_attrs {object} [optional] - Additional HTML attributes for the dialog body section.
@param footer_attrs {object} [optional] - Additional HTML attributes for the dialog footer.
@@ -53,7 +53,7 @@
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
-
+
{% if title or description %}
{% endif %}
-
+
{% endmacro %}
\ No newline at end of file
diff --git a/src/js/command.js b/src/js/command.js
new file mode 100644
index 0000000..c4970ea
--- /dev/null
+++ b/src/js/command.js
@@ -0,0 +1,148 @@
+(() => {
+ const initCommand = (container) => {
+ const input = container.querySelector('header input');
+ const menu = container.querySelector('[role="menu"]');
+
+ if (!input || !menu) {
+ const missing = [];
+ if (!input) missing.push('input');
+ if (!menu) missing.push('menu');
+ console.error(`Command component initialization failed. Missing element(s): ${missing.join(', ')}`, container);
+ return;
+ }
+
+ const allMenuItems = Array.from(menu.querySelectorAll('[role="menuitem"]'));
+ const menuItems = allMenuItems.filter(item =>
+ !item.hasAttribute('disabled') &&
+ item.getAttribute('aria-disabled') !== 'true'
+ );
+ let visibleMenuItems = [...menuItems];
+ let activeIndex = -1;
+
+ const setActiveItem = (index) => {
+ if (activeIndex > -1 && menuItems[activeIndex]) {
+ menuItems[activeIndex].classList.remove('active');
+ }
+
+ activeIndex = index;
+
+ if (activeIndex > -1) {
+ const activeItem = menuItems[activeIndex];
+ activeItem.classList.add('active');
+ if (activeItem.id) {
+ input.setAttribute('aria-activedescendant', activeItem.id);
+ } else {
+ input.removeAttribute('aria-activedescendant');
+ }
+ } else {
+ input.removeAttribute('aria-activedescendant');
+ }
+ };
+
+ const filterMenuItems = () => {
+ const searchTerm = input.value.trim().toLowerCase();
+
+ setActiveItem(-1);
+
+ visibleMenuItems = [];
+ allMenuItems.forEach(item => {
+ const itemText = (item.dataset.label || item.textContent).trim().toLowerCase();
+ const keywords = (item.dataset.keywords || '').toLowerCase();
+ const matches = itemText.includes(searchTerm) || keywords.includes(searchTerm);
+ item.setAttribute('aria-hidden', String(!matches));
+ if (matches && menuItems.includes(item)) {
+ visibleMenuItems.push(item);
+ }
+ });
+
+ if (visibleMenuItems.length > 0) {
+ setActiveItem(menuItems.indexOf(visibleMenuItems[0]));
+ visibleMenuItems[0].scrollIntoView({ block: 'nearest' });
+ }
+ };
+
+ input.addEventListener('input', filterMenuItems);
+
+ const handleKeyNavigation = (event) => {
+ if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End'].includes(event.key)) {
+ return;
+ }
+
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ if (activeIndex > -1) {
+ menuItems[activeIndex]?.click();
+ }
+ return;
+ }
+
+ if (visibleMenuItems.length === 0) return;
+
+ event.preventDefault();
+
+ const currentVisibleIndex = activeIndex > -1 ? visibleMenuItems.indexOf(menuItems[activeIndex]) : -1;
+ let nextVisibleIndex = currentVisibleIndex;
+
+ switch (event.key) {
+ case 'ArrowDown':
+ if (currentVisibleIndex < visibleMenuItems.length - 1) {
+ nextVisibleIndex = currentVisibleIndex + 1;
+ }
+ break;
+ case 'ArrowUp':
+ if (currentVisibleIndex > 0) {
+ nextVisibleIndex = currentVisibleIndex - 1;
+ } else if (currentVisibleIndex === -1) {
+ nextVisibleIndex = 0;
+ }
+ break;
+ case 'Home':
+ nextVisibleIndex = 0;
+ break;
+ case 'End':
+ nextVisibleIndex = visibleMenuItems.length - 1;
+ break;
+ }
+
+ if (nextVisibleIndex !== currentVisibleIndex) {
+ const newActiveItem = visibleMenuItems[nextVisibleIndex];
+ setActiveItem(menuItems.indexOf(newActiveItem));
+ newActiveItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+ }
+ };
+
+ menu.addEventListener('mousemove', (event) => {
+ const menuItem = event.target.closest('[role="menuitem"]');
+ if (menuItem && visibleMenuItems.includes(menuItem)) {
+ const index = menuItems.indexOf(menuItem);
+ if (index !== activeIndex) {
+ setActiveItem(index);
+ }
+ }
+ });
+
+ menu.addEventListener('click', (event) => {
+ const clickedItem = event.target.closest('[role="menuitem"]');
+ if (clickedItem && visibleMenuItems.includes(clickedItem)) {
+ const dialog = container.closest('dialog.command-dialog');
+ if (dialog && !clickedItem.hasAttribute('data-keep-command-open')) {
+ dialog.close();
+ }
+ }
+ });
+
+ input.addEventListener('keydown', handleKeyNavigation);
+
+ if (visibleMenuItems.length > 0) {
+ setActiveItem(menuItems.indexOf(visibleMenuItems[0]));
+ visibleMenuItems[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/js/select.js b/src/js/select.js
index 8e10dc8..352dcb8 100644
--- a/src/js/select.js
+++ b/src/js/select.js
@@ -16,7 +16,8 @@
return;
}
- const options = Array.from(listbox.querySelectorAll('[role="option"]'));
+ const allOptions = Array.from(listbox.querySelectorAll('[role="option"]'));
+ const options = allOptions.filter(opt => opt.getAttribute('aria-disabled') !== 'true');
let visibleOptions = [...options];
let activeIndex = -1;
@@ -69,7 +70,7 @@
const resetFilter = () => {
filter.value = '';
visibleOptions = [...options];
- options.forEach(opt => opt.setAttribute('aria-hidden', 'false'));
+ allOptions.forEach(opt => opt.setAttribute('aria-hidden', 'false'));
};
if (hasTransition()) {
@@ -110,11 +111,11 @@
setActiveOption(-1);
visibleOptions = [];
- options.forEach(option => {
+ allOptions.forEach(option => {
const optionText = (option.dataset.label || option.textContent).trim().toLowerCase();
const matches = optionText.includes(searchTerm);
option.setAttribute('aria-hidden', String(!matches));
- if (matches) {
+ if (matches && options.includes(option)) {
visibleOptions.push(option);
}
});
diff --git a/src/nunjucks/command.njk b/src/nunjucks/command.njk
new file mode 100644
index 0000000..f3b6b84
--- /dev/null
+++ b/src/nunjucks/command.njk
@@ -0,0 +1,187 @@
+{#
+ 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 input_attrs {object} [optional] - Additional HTML attributes for the search input.
+ @param menu_attrs {object} [optional] - Additional HTML attributes for the menu div.
+#}
+{% macro command(
+ id=None,
+ items=None,
+ placeholder="Type a command or search...",
+ empty_text="No results found.",
+ main_attrs={},
+ input_attrs={},
+ menu_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 menu_attrs {object} [optional] - Additional HTML attributes for the menu div.
+ @param open {boolean} [optional] [default=false] - Whether the command dialog should be open initially.
+#}
+{% macro command_dialog(
+ id=None,
+ items=None,
+ placeholder="Type a command or search...",
+ empty_text="No results found.",
+ dialog_attrs={},
+ input_attrs={},
+ menu_attrs={},
+ open=false
+) %}
+{% set id = id or ("command-dialog-" + (range(100000, 999999) | random | string)) %}
+
+{% endmacro %}
+
+{#
+ Renders a list of items for the command component.
+
+ @param items {array} - Array of items to render. Each item can be:
+ - { type: "item", label: "Text", content: "HTML", keywords: "search terms", disabled: true, attrs: {} }
+ - { type: "group", label: "Group Title", items: [...] }
+ - { type: "separator" }
+ Note: Use attrs to add data-label for alternative search text if needed.
+ @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 %}
+