Prepare 1.0.0 release

This commit is contained in:
Ronan Berder
2026-06-27 08:55:48 +08:00
parent 2be9606ce4
commit c81900a58d
52 changed files with 2504 additions and 1124 deletions
-55
View File
@@ -1,55 +0,0 @@
# basecoat-cli
This package provides a Command Line Interface (CLI) to help you quickly scaffold [Basecoat](https://basecoatui.com) components into your project.
## Prerequisites
- Ensure you have Node.js and npm (or your preferred package manager) installed.
- For the components to function correctly, your project should have:
- [Tailwind CSS](https://tailwindcss.com/docs/installation) installed and configured.
- The `basecoat-css` package installed and imported (or `basecoat.css` manually added).
## Usage
Use the `add` command to add components to your project:
```bash
# npx
npx basecoat-cli add [component]
# pnpm
pnpm dlx basecoat-cli add [component]
# bun
bunx basecoat-cli add [component]
# yarn
yarn dlx basecoat-cli add [component]
```
Replace `[component]` with the name of the component you wish to add (e.g., `dialog`, `select`) or leave it out if you want to pick from the list of available components
or install all of them at once.
The CLI will prompt you for:
1. **Template Engine:** Choose between Nunjucks (`.njk`) or Jinja (`.html.jinja`).
2. **Assets Directory:** The path where component assets (like JavaScript files) should be placed (e.g., `src/assets/js/components`).
3. **Macros Directory:** The path where component macros should be placed (e.g., `src/_includes/macros/components`).
The CLI will then copy the necessary JavaScript files and Nunjucks/Jinja macros for the selected component into the specified directories.
### Example
To add the `dialog` component:
```bash
npx basecoat-cli add dialog
```
## Documentation
For more detailed information on components, their usage, and customization options, please refer to the [Basecoat documentation](https://basecoatui.com/installation/#install-cli).
## License
[MIT](https://github.com/hunvreus/basecoat/blob/main/LICENSE.md)
-206
View File
@@ -1,206 +0,0 @@
#!/usr/bin/env node
import { Command } from 'commander';
import inquirer from 'inquirer';
import fs from 'fs-extra';
import path from 'path';
import { fileURLToPath } from 'url';
const program = new Command();
// Attempt to read version from the CLI's own package.json
let packageVersion = 'unknown';
try {
const cliScriptRunningDir = path.dirname(fileURLToPath(import.meta.url)); // .../packages/cli/dist
const cliPackageRootDir = path.resolve(cliScriptRunningDir, '..'); // .../packages/cli
const packageJsonPath = path.join(cliPackageRootDir, 'package.json');
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
packageVersion = JSON.parse(packageJsonContent).version;
} catch (error) {
console.warn('Could not read CLI package version:', error.message);
}
const DEFAULT_CONFIG = {
templateEngine: null,
templateDest: './components/basecoat',
scriptDest: './static/js/basecoat'
};
let config = { ...DEFAULT_CONFIG }; // User-specific config, potentially updated by prompts
const SCRIPT_ONLY_COMPONENTS = new Set(['chart', 'range']);
// Discovers available components from the CLI's bundled assets.
async function getAvailableComponents() {
try {
const cliScriptRunningDir = path.dirname(fileURLToPath(import.meta.url));
const assetsDir = path.resolve(cliScriptRunningDir, 'assets');
const jsSourceDir = path.join(assetsDir, 'js');
const nunjucksSourceDir = path.join(assetsDir, 'nunjucks');
const jinjaSourceDir = path.join(assetsDir, 'jinja');
const jsFiles = await fs.readdir(jsSourceDir);
const nunjucksFiles = await fs.readdir(nunjucksSourceDir).catch(() => []);
const jinjaFiles = await fs.readdir(jinjaSourceDir).catch(() => []);
const templateNames = new Set([
...nunjucksFiles.filter(file => file.endsWith('.njk')).map(file => path.basename(file, '.njk')),
...jinjaFiles.filter(file => file.endsWith('.html.jinja')).map(file => path.basename(file, '.html.jinja')),
]);
return jsFiles
.filter(file => file.endsWith('.js') && !file.endsWith('.min.js'))
.map(file => path.basename(file, '.js'))
.filter(name => name !== 'all' && name !== 'basecoat')
.filter(name => templateNames.has(name) || SCRIPT_ONLY_COMPONENTS.has(name));
} catch (error) {
console.error("Error reading component source directories from CLI assets:", error);
return [];
}
}
// Ensures necessary configuration (template engine, destination paths) is set, prompting the user if needed.
async function ensureConfiguration() {
if (!config.templateEngine) {
const engineChoice = await inquirer.prompt([
{
type: 'list',
name: 'engine',
message: 'Which template engine are you using?',
choices: ['nunjucks', 'jinja'],
}
]);
config.templateEngine = engineChoice.engine;
}
const pathPrompts = [];
if (!config.templateDest || config.templateDest === DEFAULT_CONFIG.templateDest) {
pathPrompts.push({
type: 'input',
name: 'templateDest',
message: 'Where should template files be placed?',
default: DEFAULT_CONFIG.templateDest,
});
}
if (!config.scriptDest || config.scriptDest === DEFAULT_CONFIG.scriptDest) {
pathPrompts.push({
type: 'input',
name: 'scriptDest',
message: 'Where should script files be placed?',
default: DEFAULT_CONFIG.scriptDest,
});
}
if (pathPrompts.length > 0) {
const pathChoices = await inquirer.prompt(pathPrompts);
if (pathChoices.templateDest) config.templateDest = pathChoices.templateDest;
if (pathChoices.scriptDest) config.scriptDest = pathChoices.scriptDest;
}
}
// Copies a component's template and script files to the user's project.
async function addComponent(componentName) {
console.log(`\nProcessing component: ${componentName}...`);
const cliScriptRunningDir = path.dirname(fileURLToPath(import.meta.url));
const assetsDir = path.resolve(cliScriptRunningDir, 'assets');
const templateExt = config.templateEngine === 'jinja' ? '.html.jinja' : '.njk';
const templateSource = path.join(assetsDir, config.templateEngine, `${componentName}${templateExt}`);
const scriptSource = path.join(assetsDir, 'js', `${componentName}.js`);
const templateDestPath = path.resolve(process.cwd(), config.templateDest, `${componentName}${templateExt}`);
const scriptDestPath = path.resolve(process.cwd(), config.scriptDest, `${componentName}.js`);
try {
const templateExists = await fs.pathExists(templateSource);
const scriptExists = await fs.pathExists(scriptSource);
if (!templateExists && !SCRIPT_ONLY_COMPONENTS.has(componentName)) {
console.error(` Error: Template file for component '${componentName}' not found in CLI assets. Searched: ${templateSource}`);
// Optionally, list available template engines or files for that component if helpful
}
if (!scriptExists) {
console.error(` Error: Script file for component '${componentName}' not found in CLI assets. Searched: ${scriptSource}`);
}
if ((!templateExists && !SCRIPT_ONLY_COMPONENTS.has(componentName)) || !scriptExists) return; // Skip this component if sources are missing
await fs.ensureDir(path.dirname(templateDestPath));
await fs.ensureDir(path.dirname(scriptDestPath));
if (templateExists) {
await fs.copyFile(templateSource, templateDestPath);
console.log(` -> Copied template to: ${templateDestPath}`);
}
await fs.copyFile(scriptSource, scriptDestPath);
console.log(` -> Copied script to: ${scriptDestPath}`);
} catch (error) {
console.error(` Error processing component ${componentName}:`, error);
}
}
// Setup main CLI program
program
.name('basecoat-cli')
.description('Add Basecoat components to your project')
.version(packageVersion);
// Define the 'add' command
program
.command('add')
.description('Add one or more Basecoat components to your project')
.argument('[components...]', 'Names of components to add (e.g., dialog select)')
.action(async (componentsArg) => {
let componentsToAdd = []; // Initialize as an empty array
try {
if (!componentsArg || componentsArg.length === 0) {
const availableComponents = await getAvailableComponents();
if (availableComponents.length === 0) {
console.error('Error: No components available in the CLI. Build might be corrupted.');
return;
}
const allComponentsChoice = 'All components';
const choices = [allComponentsChoice, ...availableComponents];
const answers = await inquirer.prompt([
{
type: 'list', // Changed from checkbox to list
name: 'selectedComponent', // Changed name for clarity
message: 'Which component(s) would you like to add?',
choices: choices,
}
]);
if (answers.selectedComponent === allComponentsChoice) {
componentsToAdd = availableComponents; // Add all
} else {
componentsToAdd = [answers.selectedComponent]; // Add the single selected one
}
} else {
// If components were provided as arguments, use them directly
// (This part of the logic remains, ensuring componentsArg is an array)
componentsToAdd = Array.isArray(componentsArg) ? componentsArg : [componentsArg];
}
if (!componentsToAdd || componentsToAdd.length === 0) {
console.log('No components selected. Exiting.');
return;
}
await ensureConfiguration(); // Confirm/gather destination paths
for (const componentName of componentsToAdd) {
await addComponent(componentName);
}
console.log('\nComponent addition process finished.');
} catch (error) {
if (error.isTtyError || error.constructor?.name === 'ExitPromptError') { // Handle inquirer cancellation
console.log('\nOperation cancelled by user.');
} else {
console.error('\nAn unexpected error occurred during the add command:', error);
}
process.exit(1); // Exit with error code for unexpected errors
}
});
program.parse(process.argv);
-44
View File
@@ -1,44 +0,0 @@
{
"name": "basecoat-cli",
"version": "1.0.0-beta.7",
"description": "Add Basecoat components to your project",
"author": "hunvreus",
"license": "MIT",
"type": "module",
"bin": {
"basecoat-cli": "dist/index.js"
},
"files": [
"dist/"
],
"dependencies": {
"commander": "^13.1.0",
"fs-extra": "^11.3.0",
"inquirer": "^12.6.0"
},
"keywords": [
"cli",
"components",
"component library",
"component system",
"ui",
"ui kit",
"shadcn",
"shadcn/ui",
"tailwind",
"tailwindcss",
"css",
"html",
"jinja",
"nunjucks"
],
"repository": {
"type": "git",
"url": "git+https://github.com/hunvreus/basecoat.git",
"directory": "packages/cli"
},
"bugs": {
"url": "https://github.com/hunvreus/basecoat/issues"
},
"homepage": "https://basecoatui.com/installation#install-cli"
}
+15 -4
View File
@@ -1,6 +1,6 @@
# basecoat-css
This package provides the core CSS styles for [Basecoat](https://basecoatui.com), a component library built with Tailwind CSS.
This package provides CSS, JavaScript helpers, and optional Nunjucks/Jinja templates for [Basecoat](https://basecoatui.com).
## Prerequisites
@@ -32,9 +32,9 @@ To use a specific style, import its standalone bundle instead:
@import "basecoat-css/sera";
```
### (Optional) JavaScript files
### (Optional) JavaScript
Some interactive components (Dropdown Menu, Popover, Select, Sidebar, Tabs, Toast) need some JavaScript.
Some interactive components need JavaScript. Import all component scripts, or cherry-pick only the entrypoints you use.
With a build tool (ESM):
@@ -61,9 +61,20 @@ Then reference what you need, e.g.
<script src="/js/basecoat/tabs.min.js" defer></script>
```
### (Optional) Templates
Nunjucks and Jinja templates ship with the package. Copy the template folder for your engine into your app:
```bash
cp -R node_modules/basecoat-css/templates/nunjucks ./templates/basecoat
cp -R node_modules/basecoat-css/templates/jinja ./templates/basecoat
```
Templates are meant to be copied into your app and customized.
## Documentation
For more detailed information on components, their usage, and customization options, please refer to the [Basecoat documentation](https://basecoatui.com/installation/#install-css).
For more detailed information on components, their usage, and customization options, see the [Basecoat documentation](https://basecoatui.com/installation).
## License
+9 -4
View File
@@ -1,6 +1,6 @@
{
"name": "basecoat-css",
"version": "1.0.0-beta.7",
"version": "1.0.0",
"description": "Tailwind CSS for Basecoat components",
"author": {
"name": "hunvreus",
@@ -11,7 +11,8 @@
"main": "dist/basecoat.css",
"style": "dist/basecoat.css",
"files": [
"dist/"
"dist/",
"templates/"
],
"keywords": [
"components",
@@ -36,7 +37,7 @@
"bugs": {
"url": "https://github.com/hunvreus/basecoat/issues"
},
"homepage": "https://basecoatui.com/installation#install-npm",
"homepage": "https://basecoatui.com/installation#npm",
"exports": {
".": "./dist/basecoat.css",
"./css": "./dist/basecoat.css",
@@ -44,6 +45,8 @@
"./basecoat.min": "./dist/js/basecoat.min.js",
"./all": "./dist/js/all.js",
"./all.min": "./dist/js/all.min.js",
"./accordion": "./dist/js/accordion.js",
"./accordion.min": "./dist/js/accordion.min.js",
"./chart": "./dist/js/chart.js",
"./chart.min": "./dist/js/chart.min.js",
"./combobox": "./dist/js/combobox.js",
@@ -114,6 +117,8 @@
"./styles/style-luma.css": "./dist/styles/luma.css",
"./styles/style-sera.css": "./dist/styles/sera.css",
"./styles/style-rhea.css": "./dist/styles/rhea.css",
"./styles/style-*.css": "./dist/styles/*.css"
"./styles/style-*.css": "./dist/styles/*.css",
"./templates/nunjucks/*": "./templates/nunjucks/*",
"./templates/jinja/*": "./templates/jinja/*"
}
}
@@ -0,0 +1,142 @@
{#
Renders an input-first combobox component.
#}
{% macro combobox(
id=None,
selected=None,
name=None,
multiple=false,
placeholder=None,
close_on_select=false,
clear=false,
auto_highlight=false,
format="value",
main_attrs={},
input_attrs={},
popover_attrs={},
listbox_attrs={},
hidden_input_attrs={}
) %}
{% set id = id or ("combobox-" + (range(100000, 999999) | random | string)) %}
{% set selected_entries = namespace(items=[]) %}
{% if selected is defined and selected is iterable and selected is not string %}
{% set selected_values = selected %}
{% for item in selected %}
{% if item.value is defined %}
{% set selected_entries.items = selected_entries.items + [{'value': item.value, 'label': item.label if item.label is defined else item.value}] %}
{% else %}
{% set selected_entries.items = selected_entries.items + [{'value': item, 'label': item}] %}
{% endif %}
{% endfor %}
{% elif selected is defined and selected %}
{% if selected.value is defined %}
{% set selected_values = [selected.value] %}
{% set selected_entries.items = [{'value': selected.value, 'label': selected.label if selected.label is defined else selected.value}] %}
{% else %}
{% set selected_values = [selected] %}
{% set selected_entries.items = [{'value': selected, 'label': selected}] %}
{% endif %}
{% else %}
{% set selected_values = [] %}
{% endif %}
{% set hidden_value = ((selected_entries.items | tojson if multiple else ((selected_entries.items[0] if selected_entries.items else none) | tojson)) if format == "object" else (selected_values | tojson if multiple else (selected_values[0] if selected_values else ''))) %}
<div
id="{{ id }}"
class="combobox {{ main_attrs.class }}"
{% if format == "object" %}data-format="object"{% endif %}
{% if multiple and close_on_select %}data-close-on-select="true"{% endif %}
{% if auto_highlight %}data-auto-highlight="true"{% endif %}
{% for key, value in main_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% if multiple %}
<input
type="text"
role="combobox"
class="{{ input_attrs.class }}"
placeholder="{{ placeholder or '' }}"
autocomplete="off"
autocorrect="off"
spellcheck="false"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="{{ id }}-listbox"
{% for key, value in input_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% elif clear %}
<div class="input-group {{ input_attrs.class }}">
<input
type="text"
role="combobox"
placeholder="{{ placeholder or '' }}"
autocomplete="off"
autocorrect="off"
spellcheck="false"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="{{ id }}-listbox"
{% for key, value in input_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<span role="group" data-align="inline-end">
<button type="button" data-clear aria-label="Clear selection">
<svg class="size-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
<button type="button" class="btn" data-variant="ghost" data-size="icon-xs" aria-label="Open suggestions" aria-haspopup="listbox" aria-expanded="false" aria-controls="{{ id }}-listbox">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</button>
</span>
</div>
{% else %}
<input
type="text"
role="combobox"
placeholder="{{ placeholder or '' }}"
autocomplete="off"
autocorrect="off"
spellcheck="false"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="{{ id }}-listbox"
class="{{ input_attrs.class }}"
{% for key, value in input_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="combobox-trigger-icon"><path d="m6 9 6 6 6-6"/></svg>
{% endif %}
<div
id="{{ id }}-popover"
data-popover
aria-hidden="true"
{% for key, value in popover_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
<div
role="listbox"
id="{{ id }}-listbox"
aria-orientation="vertical"
{% if multiple %}aria-multiselectable="true"{% endif %}
{% for key, value in listbox_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ caller() if caller else '' }}
</div>
</div>
<input
type="hidden"
{% if name %}name="{{ name }}"{% endif %}
value='{{ hidden_value }}'
{% for key, value in hidden_input_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
</div>
{% endmacro %}
@@ -0,0 +1,203 @@
{#
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) %}
<div
id="{{ id }}"
class="command {{ main_attrs.class }}"
aria-label="Command menu"
{% for key, value in main_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<header>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input
type="text"
id="{{ id }}-input"
placeholder="{{ placeholder }}"
autocomplete="off"
autocorrect="off"
spellcheck="false"
aria-autocomplete="list"
role="combobox"
aria-expanded="true"
aria-controls="{{ id }}-menu"
{% for key, value in input_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
</header>
<div
role="menu"
id="{{ id }}-menu"
aria-orientation="vertical"
data-empty="{{ empty_text }}"
class="{{ menu_attrs.class }}"
{% for key, value in menu_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% if items and items|length > 0 %}
{{ render_command_items(items, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</div>
</div>
{% 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) %}
<dialog
id="{{ id }}"
class="command-dialog {{ dialog_attrs.class }}"
aria-label="Command menu"
{% if open %}open{% endif %}
onclick="if (event.target === this) this.close()"
{% for key, value in dialog_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<div class="command">
<header>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input
type="text"
id="{{ id }}-input"
placeholder="{{ placeholder }}"
autocomplete="off"
autocorrect="off"
spellcheck="false"
aria-autocomplete="list"
role="combobox"
aria-expanded="true"
aria-controls="{{ id }}-menu"
{% for key, value in input_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
</header>
<div
role="menu"
id="{{ id }}-menu"
aria-orientation="vertical"
data-empty="{{ empty_text }}"
class="{{ menu_attrs.class }}"
{% for key, value in menu_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% if items and items|length > 0 %}
{{ render_command_items(items, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</div>
</div>
</dialog>
{% 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 or HTML", icon: "HTML", url: "href", 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. If url is provided, renders as <a role="menuitem"> instead of <div role="menuitem">. Label supports HTML (marked as safe).
@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 %}
<div
role="group"
aria-labelledby="{{ group_label_id }}"
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
<span role="heading" id="{{ group_label_id }}">{{ item.label | safe }}</span>
{{ render_command_items(item['items'], item_id) if item['items'] }}
</div>
{% elif item.type == "separator" %}
<hr role="separator" />
{% elif item.type == "item" or not item.type %}
{% if item.url %}
<a
id="{{ item_id }}"
href="{{ item.url }}"
role="menuitem"
{% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
{% if item.disabled %}aria-disabled="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{% if key != "href" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label | safe }}
</a>
{% else %}
<div
id="{{ item_id }}"
role="menuitem"
{% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
{% if item.disabled %}aria-disabled="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label | safe }}
</div>
{% endif %}
{% endif %}
{% endfor %}
{% endmacro %}
@@ -0,0 +1,92 @@
{#
Renders a dialog component with optional trigger, header, body, and footer.
@param id {string} - Unique identifier for the dialog component.
@param trigger {string} [optional] - Text or HTML for the button that triggers the dialog.
@param title {string} [optional] - Title text displayed in the dialog header.
@param description {string} [optional] - Description text displayed below the title.
@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 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.
@param open {boolean} [optional] [default=false] - Whether the dialog should be open initially.
@param close_button {boolean} [optional] [default=true] - Whether to include a close button.
@param close_on_overlay_click {boolean} [optional] [default=true] - Whether clicking the overlay closes the dialog.
#}
{% macro dialog(
id=None,
trigger=None,
title=None,
description=None,
footer=None,
dialog_attrs={},
trigger_attrs={},
header_attrs={},
body_attrs={},
footer_attrs={},
open=false,
close_button=true,
close_on_overlay_click=true
) %}
{% set id = id or ("dialog-" + (range(100000, 999999) | random | string)) %}
{% if trigger %}
<button
type="button"
onclick="document.getElementById('{{ id }}').showModal()"
{% for key, value in trigger_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger | safe }}
</button>
{% endif %}
<dialog
id="{{ id }}"
class="dialog {{ dialog_attrs.class }}"
aria-labelledby="{{ id }}-title"
{% if description %}aria-describedby="{{ id }}-description"{% endif %}
{% if close_on_overlay_click %}onclick="if (event.target === this) this.close()"{% endif %}
{% for key, value in dialog_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<div>
{% if title or description %}
<header
{% for key, value in header_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
<h2 id="{{ id }}-title">{{ title | safe }}</h2>
{% if description %}<p id="{{ id }}-description">{{ description | safe }}</p>{% endif %}
</header>
{% endif %}
{% if caller %}
<section
{% for key, value in body_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ caller() }}
</section>
{% endif %}
{% if footer %}
<footer
{% for key, value in footer_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ footer | safe }}
</footer>
{% endif %}
{% if close_button %}
<button type="button" class="btn" data-variant="ghost" data-size="icon-sm" aria-label="Close dialog" onclick="this.closest('dialog').close()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
{% endif %}
</div>
</dialog>
{% endmacro %}
@@ -0,0 +1,124 @@
{#
Renders a dropdown menu component.
@param id {string} [optional] - Unique identifier for the dropdown component.
@param trigger {string} [optional] - HTML content for the button that triggers the dropdown.
@param items {array} [optional] - Array of menu items for the dropdown.
@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 popover_attrs {object} [optional] - Additional HTML attributes for the dropdown content div.
#}
{% macro dropdown_menu(
trigger,
id=None,
items=None,
main_attrs={},
trigger_attrs={},
popover_attrs={},
menu_attrs={}
) %}
{% set id = id or ("dropdown-menu-" + (range(100000, 999999) | random | string)) %}
<div
id="{{ id }}"
class="dropdown-menu {{ main_attrs.class }}"
{% for key, value in main_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<button
type="button"
id="{{ id }}-trigger"
aria-haspopup="menu"
aria-controls="{{ id }}-menu"
aria-expanded="false"
{% for key, value in trigger_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger | safe }}
</button>
<div
id="{{ id }}-popover"
data-popover
aria-hidden="true"
{% for key, value in popover_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
<div
role="menu"
id="{{ id }}-menu"
aria-labelledby="{{ id }}-trigger"
{% for key, value in menu_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if items %}
{{ render_dropdown_items(items, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</div>
</div>
</div>
{% endmacro %}
{#
Renders dropdown menu items recursively.
@param items {array} - The array of items to render.
@param parent_id_prefix {string} [optional] - Prefix for generating element IDs.
#}
{% macro render_dropdown_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 %}
<div
role="group"
aria-labelledby="{{ group_label_id }}"
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
{{ render_dropdown_items(item['items'], item_id) if item['items'] }}
</div>
{% elif item.type == "separator" %}
<hr role="separator" />
{% elif item.type == "item" or not item.type %}
{% if item.url %}
<a
id="{{ item_id }}"
role="menuitem"
href="{{ item.url }}"
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{% if key != "url" %} {{ key }}="{{ value }}" {% endif %}
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label | safe }}
</a>
{% else %}
<div
id="{{ item_id }}"
role="menuitem"
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label | safe }}
</div>
{% endif %}
{% endif %}
{% endfor %}
{% endmacro %}
@@ -0,0 +1,48 @@
{#
Renders a generic popover component, often used internally by other components like dropdowns or selects.
@param id {string} [optional] - Unique identifier for the popover component.
@param trigger {string} [optional] - HTML content for the element that triggers the popover.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger element.
@param popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
#}
{% macro popover(
trigger,
id=None,
main_attrs={},
trigger_attrs={},
popover_attrs={}
) %}
{% set id = id or ("popover-" + (range(100000, 999999) | random | string)) %}
<div
id="{{ id }}"
class="popover {{ main_attrs.class }}"
{% for key, value in main_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<button
id="{{ id }}-trigger"
type="button"
aria-expanded="false"
aria-controls="{{ id }}-popover"
{% for key, value in trigger_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger | safe }}
</button>
<div
id="{{ id }}-popover"
data-popover
aria-hidden="true"
{% for key, value in popover_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ caller() if caller }}
</div>
</div>
{% endmacro %}
@@ -0,0 +1,183 @@
{#
Renders a select component.
@param id {string} [optional] - Unique identifier for the select component.
@param selected {string|array} [optional] - The initially selected value(s).
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
@param multiple {boolean} [optional] [default=false] - Enables multiple selection mode.
@param placeholder {string} [optional] - Placeholder text shown when no option is selected.
@param close_on_select {boolean} [optional] [default=false] - Closes the popover when selecting an option in multiple mode.
@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 popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
@param listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div.
@param input_attrs {object} [optional] - Additional HTML attributes for the hidden input.
#}
{% macro select(
id=None,
selected=None,
name=None,
items=[],
multiple=false,
placeholder=None,
close_on_select=false,
format="value",
main_attrs={},
trigger_attrs={},
popover_attrs={},
listbox_attrs={},
input_attrs={}
) %}
{% set id = id or ("select-" + (range(100000, 999999) | random | string)) %}
{% if selected is defined and selected is iterable and selected is not string %}
{% set selectedArray = selected %}
{% elif selected is defined %}
{% set selectedArray = [selected] %}
{% else %}
{% set selectedArray = [] %}
{% endif %}
{% set first_option = namespace(item=None) %}
{% set selected_options = namespace(items=[]) %}
{% if items %}
{% for item in items %}
{% if item.type == "group" %}
{% for sub_item in item['items'] %}
{% if not first_option.item %}
{% set first_option.item = sub_item %}
{% endif %}
{% if sub_item.value in selectedArray %}
{% set selected_options.items = selected_options.items + [sub_item] %}
{% endif %}
{% endfor %}
{% else %}
{% if not first_option.item %}
{% set first_option.item = item %}
{% endif %}
{% if item.value in selectedArray %}
{% set selected_options.items = selected_options.items + [item] %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% set default_option = (selected_options.items[0] if selected_options.items else (none if placeholder else first_option.item)) %}
{% set labels = [] %}
{% if multiple and selected_options.items %}
{% for opt in selected_options.items %}
{% set labels = labels + [opt.label] %}
{% endfor %}
{% endif %}
{% set default_label = (', '.join(labels) if (multiple and selected_options.items) else ((placeholder or '') if (placeholder and not selected_options.items) else (default_option.label if default_option else ''))) %}
{% set selected_payload = namespace(items=[]) %}
{% if multiple and selected_options.items %}
{% for opt in selected_options.items %}
{% set selected_payload.items = selected_payload.items + [{'value': opt.value, 'label': opt.label}] %}
{% endfor %}
{% elif default_option %}
{% set selected_payload.items = [{'value': default_option.value, 'label': default_option.label}] %}
{% endif %}
<div
id="{{ id }}"
class="select {{ main_attrs.class }}"
{% if format == "object" %}data-format="object"{% endif %}
{% if placeholder %}data-placeholder="{{ placeholder }}"{% endif %}
{% if multiple and close_on_select %}data-close-on-select="true"{% endif %}
{% for key, value in main_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<button
type="button"
class="{{ trigger_attrs.class }}"
id="{{ id }}-trigger"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="{{ id }}-listbox"
{% for key, value in trigger_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<span class="truncate">{{ default_label }}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down text-muted-foreground opacity-50 shrink-0"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div
id="{{ id }}-popover"
data-popover
aria-hidden="true"
{% for key, value in popover_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
<div
role="listbox"
id="{{ id }}-listbox"
aria-orientation="vertical"
aria-labelledby="{{ id }}-trigger"
{% if multiple %}aria-multiselectable="true"{% endif %}
{% for key, value in listbox_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if items %}
{{ render_select_items(items, selectedArray, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</div>
</div>
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{% if format == 'object' %}{{ (selected_payload.items if multiple else (selected_payload.items[0] if selected_payload.items else none)) | tojson }}{% elif multiple %}{{ selectedArray | tojson }}{% else %}{{ (default_option.value if default_option) or '' }}{% endif %}"
{% for key, value in input_attrs.items() %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
</div>
{% endmacro %}
{#
Renders a list of items for the select component.
@param items {array} - The array of items to render.
@param parent_id_prefix {string} [optional] - The prefix for the item id.
#}
{% macro render_select_items(items, selectedArray, 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 %}
<div
role="group"
aria-labelledby="{{ group_label_id }}"
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
{{ render_select_items(item['items'], selectedArray, item_id) if item['items'] }}
</div>
{% elif item.type == "separator" %}
<hr role="separator" />
{% elif item.type == "item" or not item.type %}
<div
id="{{ item_id }}"
role="option"
{% if item.value is defined and item.value is not none %}data-value="{{ item.value }}"{% endif %}
{% if item.value in selectedArray %}aria-selected="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ item.label | safe }}
</div>
{% endif %}
{% endfor %}
{% endmacro %}
@@ -0,0 +1,144 @@
{#
Renders a sidebar component.
@param id {string} [optional] - Unique identifier for the sidebar component.
@param label {string} [optional] - Label for the sidebar navigation.
@param open {boolean} [optional] - Whether the sidebar is open.
@param side {string} [optional] - Side of the sidebar to display.
@param header {string} [optional] - Header content for the sidebar.
@param footer {string} [optional] - Footer content for the sidebar.
@param menu {array} [optional] - Array of menu items for the sidebar.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param header_attrs {object} [optional] - Additional HTML attributes for the header div.
@param content_attrs {object} [optional] - Additional HTML attributes for the content div.
@param footer_attrs {object} [optional] - Additional HTML attributes for the footer div.
#}
{% macro sidebar(
id=None,
label="Sidebar navigation",
open=true,
side=None,
header=None,
footer=None,
menu=None,
main_attrs={},
header_attrs={},
content_attrs={},
footer_attrs={}
) %}
<aside
{% if id %}id="{{ id }}"{% endif %}
class="sidebar {{ main_attrs.class }}"
data-side="{{ side if side else "left" }}"
aria-hidden="{{ "true" if not open else "false" }}"
{{ "inert" if not open }}
{% for key, value in main_attrs.items() %}
{% if key != "class" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<nav
aria-label="{{ label }}"
>
{% if header %}
<header
{% for key, value in header_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ header | safe }}
</header>
{% endif %}
<section
{% for key, value in content_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if menu %}
{{ render_sidebar_content(menu, id ~ "-content" if id else "content") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</section>
{% if footer %}
<footer
{% for key, value in footer_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ footer | safe }}
</footer>
{% endif %}
</nav>
</aside>
{% endmacro %}
{#
Renders sidebar content recursively (groups, items, submenus, separators).
@param items {array} - The array of items to render.
@param parent_id_prefix {string} [optional] - Prefix for generating element IDs.
#}
{% macro render_sidebar_content(items, parent_id_prefix="content") %}
{% 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 %}
<div
role="group"
{% if item.label %}aria-labelledby="{{ group_label_id }}"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{% if item.label %}
<h3 id="{{ group_label_id }}">{{ item.label }}</h3>
{% endif %}
<ul>
{{ render_sidebar_content(item["items"], item_id) if item["items"] }}
</ul>
</div>
{% elif item.type == "separator" %}
<hr role="separator"/>
{% elif item.type == "submenu" %}
<li>
<details
id="submenu-{{ item_id }}"
{{ "open" if item.open }}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{% if key != "open" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% endif %}
>
<summary aria-controls="submenu-{{ item_id }}-content">
{% if item.icon %}{{ item.icon | safe }}{% endif %}
<span>{{ item.label | safe }}</span>
</summary>
<ul id="submenu-{{ item_id }}-content">
{{ render_sidebar_content(item["items"], item_id) if item["items"] }}
</ul>
</details>
</li>
{% elif item.type == "item" or not item.type %}
<li>
<a
href="{{ item.url }}"
{% if item.current %}aria-current="page"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{% if key != "href" and key != "aria-current" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
<span>{{ item.label | safe }}</span>
</a>
</li>
{% endif %}
{% endfor %}
{% endmacro %}
@@ -0,0 +1,80 @@
{#
Renders a tabs component.
@param id {string} - Unique identifier for the tabs component.
@param tabsets {array} - An array of objects, each representing a tab and its panel.
Each object should have:
- id {string}: Unique identifier prefix for the tab and panel.
- tab {string}: HTML content for the tab button.
- panel {string} [optional]: HTML content for the tab panel.
- tab_attrs {object} [optional]: Additional HTML attributes for the tab button.
- panel_attrs {object} [optional]: Additional HTML attributes for the tab panel div.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param tablist_attrs {object} [optional] - Additional HTML attributes for the tablist nav element.
Set aria-orientation="vertical" for vertical tabs or data-variant="line" for line tabs.
@param default_tab_index {number} [optional] [default=1] - The 1-based index of the tab to be active initially.
#}
{% macro tabs(
id=None,
tabsets=[],
main_attrs={},
tablist_attrs={},
default_tab_index=1
)
%}
{% set id = id or ("tabs-" + (range(100000, 999999) | random | string)) %}
{% set orientation = tablist_attrs['aria-orientation'] or 'horizontal' %}
<div
class="tabs {{ main_attrs.class }}"
id="{{ id }}"
{% for key, value in main_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<nav
role="tablist"
aria-orientation="{{ orientation }}"
{% for key, value in tablist_attrs.items() %}
{% if key != 'aria-orientation' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% for tabset in tabsets %}
<button
type="button"
role="tab"
id="{{ id }}-tab-{{ loop.index }}"
aria-controls="{{ id }}-panel-{{ loop.index }}"
aria-selected="{{ 'true' if loop.index == default_tab_index else 'false' }}"
tabindex="{{ '0' if loop.index == default_tab_index and not tabset.tab_attrs.disabled and tabset.tab_attrs['aria-disabled'] != 'true' else '-1' }}"
{% if tabset.tab_attrs %}
{% for key, value in tabset.tab_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ tabset.tab | safe }}
</button>
{% endfor %}
</nav>
{% for tabset in tabsets %}
{% if tabset.panel %}
<div
role="tabpanel"
id="{{ id }}-panel-{{ loop.index }}"
aria-labelledby="{{ id }}-tab-{{ loop.index }}"
tabindex="-1"
aria-selected="{{ 'true' if loop.index == default_tab_index else 'false' }}"
{% if loop.index != default_tab_index %}hidden{% endif %}
{% if tabset.panel_attrs %}
{% for key, value in tabset.panel_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ tabset.panel | safe }}
</div>
{% endif %}
{% endfor %}
</div>
{% endmacro %}
@@ -0,0 +1,117 @@
{#
Renders a toaster container and individual toast messages.
Can render the initial container or be used with htmx OOB swaps to add toasts dynamically.
@param id {string} [optional] [default="toaster"] - Unique identifier for the toaster container.
@param toasts {array} [optional] - An array of toast objects to render initially. See the <code>toast()</code> macro for more details.
@param attrs {object} [optional] - Additional HTML attributes for the main toaster container div.
Supports caller() for custom toast markup.
#}
{% macro toaster(
id="toaster",
toasts=[],
attrs={}
) %}
<div
id="{{ id }}"
class="toaster {{ attrs.class }}"
{% for key, value in attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% for item in toasts %}
{{ toast(
category=item.category,
title=item.title,
description=item.description,
action=item.action,
cancel=item.cancel,
attrs=item.attrs
) }}
{% endfor %}
{{ caller() if caller }}
</div>
{% endmacro %}
{#
Renders a single toast message.
@param category {string} [optional] [default="success"] - Type of toast ('success', 'error', 'info', 'warning'). Determines icon and ARIA role.
@param title {string} [optional] - The main title text of the toast.
@param description {string} [optional] - The secondary description text.
@param action {object} [optional] - Defines an action button.
- label {string}: Button text.
- onclick {string}: JavaScript code to execute on click.
- url {string}: URL for an anchor link button.
@param cancel {object} [optional] - Defines a cancel/dismiss button (similar structure to action).
- label {string}: Button text.
- onclick {string}: JavaScript code to execute on click.
- url {string}: URL for an anchor link button.
@param attrs {object} [optional] - Additional HTML attributes for the toast div.
#}
{% macro toast(
category="success",
title="",
description="",
action=None,
cancel=None,
attrs={}
) %}
<div
class="toast {{ attrs.class }}"
role="{{ 'alert' if category == 'error' else 'status' }}"
aria-atomic="true"
aria-hidden="false"
{% if category %}data-category="{{ category }}"{% endif %}
{% for key, value in attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<div class="toast-content">
{% if category in ["error", "success", "info", "warning"] %}
{% if category == "success" %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
{% elif category == "error" %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
{% elif category == "info" %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
{% elif category == "warning" %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
{% endif %}
{% endif %}
<section>
{% if title %}<h2>{{ title }}</h2>{% endif %}
{% if description %}<p>{{ description }}</p>{% endif %}
</section>
{% if action or cancel %}
<footer>
{% if action %}
{% if action.href %}
<a
href="{{ action.href }}"
class="btn" data-size="sm"
data-toast-action
>{{ action.label }}</a>
{% else %}
<button
type="button"
class="btn"
data-toast-action
{% if action.onclick %}onclick="{{ action.onclick }}"{% endif %}
>{{ action.label }}</button>
{% endif %}
{% endif %}
{% if cancel %}
<button
type="button"
class="btn" data-variant="outline" data-size="sm"
data-toast-cancel
{% if cancel.onclick %}onclick="{{ cancel.onclick }}"{% endif %}
>{{ cancel.label }}</button>
{% endif %}
</footer>
{% endif %}
</div>
</div>
{% endmacro %}
@@ -0,0 +1,157 @@
{#
Renders an input-first combobox component.
@param id {string} [optional] - Unique identifier for the combobox component.
@param selected {string|array} [optional] - The initially selected value(s).
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
@param multiple {boolean} [optional] [default=false] - Enables multiple selection mode with chips.
@param placeholder {string} [optional] - Placeholder text for the visible input.
@param close_on_select {boolean} [optional] [default=false] - Closes the popover when selecting an option in multiple mode.
@param clear {boolean} [optional] [default=false] - Shows a clear button in single-select mode.
@param auto_highlight {boolean} [optional] [default=false] - Highlights the first matching option while filtering.
@param format {string} [optional] [default="value"] - Hidden input serialization format: "value" or "object".
@param main_attrs {object} [optional] - Additional HTML attributes for the root div.
@param input_attrs {object} [optional] - Additional HTML attributes for the visible input.
@param popover_attrs {object} [optional] - Additional HTML attributes for the popover div.
@param listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div.
@param hidden_input_attrs {object} [optional] - Additional HTML attributes for the hidden input.
#}
{% macro combobox(
id=None,
selected=None,
name=None,
multiple=false,
placeholder=None,
close_on_select=false,
clear=false,
auto_highlight=false,
format="value",
main_attrs={},
input_attrs={},
popover_attrs={},
listbox_attrs={},
hidden_input_attrs={}
) %}
{% set id = id or ("combobox-" + (range(100000, 999999) | random | string)) %}
{% set selected_values = [] %}
{% set selected_entries = [] %}
{% if selected is defined and selected is iterable and selected is not string %}
{% for item in selected %}
{% if item.value is defined %}
{% set selected_values = (selected_values.push(item.value), selected_values) %}
{% set selected_entries = (selected_entries.push({ value: item.value, label: (item.label if item.label is defined else item.value) }), selected_entries) %}
{% else %}
{% set selected_values = (selected_values.push(item), selected_values) %}
{% set selected_entries = (selected_entries.push({ value: item, label: item }), selected_entries) %}
{% endif %}
{% endfor %}
{% elif selected is defined and selected %}
{% if selected.value is defined %}
{% set selected_values = [selected.value] %}
{% set selected_entries = [{ value: selected.value, label: (selected.label if selected.label is defined else selected.value) }] %}
{% else %}
{% set selected_values = [selected] %}
{% set selected_entries = [{ value: selected, label: selected }] %}
{% endif %}
{% endif %}
{% set hidden_value = ((selected_entries | dump if multiple else ((selected_entries[0] if selected_entries[0] else None) | dump)) if format == "object" else (selected_values | dump if multiple else (selected_values[0] if selected_values[0] else ''))) %}
<div
id="{{ id }}"
class="combobox {{ main_attrs.class }}"
{% if format == "object" %}data-format="object"{% endif %}
{% if multiple and close_on_select %}data-close-on-select="true"{% endif %}
{% if auto_highlight %}data-auto-highlight="true"{% endif %}
{% for key, value in main_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% if multiple %}
<input
type="text"
role="combobox"
class="{{ input_attrs.class }}"
placeholder="{{ placeholder or '' }}"
autocomplete="off"
autocorrect="off"
spellcheck="false"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="{{ id }}-listbox"
{% for key, value in input_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% elif clear %}
<div class="input-group {{ input_attrs.class }}">
<input
type="text"
role="combobox"
placeholder="{{ placeholder or '' }}"
autocomplete="off"
autocorrect="off"
spellcheck="false"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="{{ id }}-listbox"
{% for key, value in input_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<span role="group" data-align="inline-end">
<button type="button" data-clear aria-label="Clear selection">
<svg class="size-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
<button type="button" class="btn" data-variant="ghost" data-size="icon-xs" aria-label="Open suggestions" aria-haspopup="listbox" aria-expanded="false" aria-controls="{{ id }}-listbox">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</button>
</span>
</div>
{% else %}
<input
type="text"
role="combobox"
placeholder="{{ placeholder or '' }}"
autocomplete="off"
autocorrect="off"
spellcheck="false"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="{{ id }}-listbox"
class="{{ input_attrs.class }}"
{% for key, value in input_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="combobox-trigger-icon"><path d="m6 9 6 6 6-6"/></svg>
{% endif %}
<div
id="{{ id }}-popover"
data-popover
aria-hidden="true"
{% for key, value in popover_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
<div
role="listbox"
id="{{ id }}-listbox"
aria-orientation="vertical"
{% if multiple %}aria-multiselectable="true"{% endif %}
{% for key, value in listbox_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ caller() if caller else '' }}
</div>
</div>
<input
type="hidden"
{% if name %}name="{{ name }}"{% endif %}
value='{{ hidden_value }}'
{% for key, value in hidden_input_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
</div>
{% endmacro %}
+203
View File
@@ -0,0 +1,203 @@
{#
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)) %}
<div
id="{{ id }}"
class="command {{ main_attrs.class }}"
aria-label="Command menu"
{% for key, value in main_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<header>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input
type="text"
id="{{ id }}-input"
placeholder="{{ placeholder }}"
autocomplete="off"
autocorrect="off"
spellcheck="false"
aria-autocomplete="list"
role="combobox"
aria-expanded="true"
aria-controls="{{ id }}-menu"
{% for key, value in input_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
</header>
<div
role="menu"
id="{{ id }}-menu"
aria-orientation="vertical"
data-empty="{{ empty_text }}"
class="{{ menu_attrs.class }}"
{% for key, value in menu_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% if items and items.length > 0 %}
{{ render_command_items(items, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</div>
</div>
{% 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)) %}
<dialog
id="{{ id }}"
class="command-dialog {{ dialog_attrs.class }}"
aria-label="Command menu"
{% if open %}open{% endif %}
onclick="if (event.target === this) this.close()"
{% for key, value in dialog_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<div class="command">
<header>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input
type="text"
id="{{ id }}-input"
placeholder="{{ placeholder }}"
autocomplete="off"
autocorrect="off"
spellcheck="false"
aria-autocomplete="list"
role="combobox"
aria-expanded="true"
aria-controls="{{ id }}-menu"
{% for key, value in input_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
</header>
<div
role="menu"
id="{{ id }}-menu"
aria-orientation="vertical"
data-empty="{{ empty_text }}"
class="{{ menu_attrs.class }}"
{% for key, value in menu_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% if items and items.length > 0 %}
{{ render_command_items(items, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</div>
</div>
</dialog>
{% 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 or HTML", icon: "HTML", url: "href", 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. If url is provided, renders as <a role="menuitem"> instead of <div role="menuitem">. Label supports HTML (marked as safe).
@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 %}
<div
role="group"
aria-labelledby="{{ group_label_id }}"
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
<span role="heading" id="{{ group_label_id }}">{{ item.label | safe }}</span>
{{ render_command_items(item.items, item_id) if item.items }}
</div>
{% elif item.type == "separator" %}
<hr role="separator" />
{% elif item.type == "item" or not item.type %}
{% if item.url %}
<a
id="{{ item_id }}"
href="{{ item.url }}"
role="menuitem"
{% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
{% if item.disabled %}aria-disabled="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{% if key != "href" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label | safe }}
</a>
{% else %}
<div
id="{{ item_id }}"
role="menuitem"
{% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
{% if item.disabled %}aria-disabled="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label | safe }}
</div>
{% endif %}
{% endif %}
{% endfor %}
{% endmacro %}
@@ -0,0 +1,92 @@
{#
Renders a dialog component with optional trigger, header, body, and footer.
@param id {string} - Unique identifier for the dialog component.
@param trigger {string} [optional] - Text or HTML for the button that triggers the dialog.
@param title {string} [optional] - Title text displayed in the dialog header.
@param description {string} [optional] - Description text displayed below the title.
@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 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.
@param open {boolean} [optional] [default=false] - Whether the dialog should be open initially.
@param close_button {boolean} [optional] [default=true] - Whether to include a close button.
@param close_on_overlay_click {boolean} [optional] [default=true] - Whether clicking the overlay closes the dialog.
#}
{% macro dialog(
id=None,
trigger=None,
title=None,
description=None,
footer=None,
dialog_attrs={},
trigger_attrs={},
header_attrs={},
body_attrs={},
footer_attrs={},
open=false,
close_button=true,
close_on_overlay_click=true
) %}
{% set id = id or ("dialog-" + (range(100000, 999999) | random | string)) %}
{% if trigger %}
<button
type="button"
onclick="document.getElementById('{{ id }}').showModal()"
{% for key, value in trigger_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger | safe }}
</button>
{% endif %}
<dialog
id="{{ id }}"
class="dialog {{ dialog_attrs.class }}"
aria-labelledby="{{ id }}-title"
{% if description %}aria-describedby="{{ id }}-description"{% endif %}
{% if close_on_overlay_click %}onclick="if (event.target === this) this.close()"{% endif %}
{% for key, value in dialog_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<div>
{% if title or description %}
<header
{% for key, value in header_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
<h2 id="{{ id }}-title">{{ title | safe }}</h2>
{% if description %}<p id="{{ id }}-description">{{ description | safe }}</p>{% endif %}
</header>
{% endif %}
{% if caller %}
<section
{% for key, value in body_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ caller() }}
</section>
{% endif %}
{% if footer %}
<footer
{% for key, value in footer_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ footer | safe }}
</footer>
{% endif %}
{% if close_button %}
<button type="button" class="btn" data-variant="ghost" data-size="icon-sm" aria-label="Close dialog" onclick="this.closest('dialog').close()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
{% endif %}
</div>
</dialog>
{% endmacro %}
@@ -0,0 +1,124 @@
{#
Renders a dropdown menu component.
@param id {string} [optional] - Unique identifier for the dropdown component.
@param trigger {string} [optional] - HTML content for the button that triggers the dropdown.
@param items {array} [optional] - Array of menu items for the dropdown.
@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 popover_attrs {object} [optional] - Additional HTML attributes for the dropdown content div.
#}
{% macro dropdown_menu(
trigger,
id=None,
items=None,
main_attrs={},
trigger_attrs={},
popover_attrs={},
menu_attrs={}
) %}
{% set id = id or ("dropdown-menu-" + (range(100000, 999999) | random | string)) %}
<div
id="{{ id }}"
class="dropdown-menu {{ main_attrs.class }}"
{% for key, value in main_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<button
type="button"
id="{{ id }}-trigger"
aria-haspopup="menu"
aria-controls="{{ id }}-menu"
aria-expanded="false"
{% for key, value in trigger_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger | safe }}
</button>
<div
id="{{ id }}-popover"
data-popover
aria-hidden="true"
{% for key, value in popover_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
<div
role="menu"
id="{{ id }}-menu"
aria-labelledby="{{ id }}-trigger"
{% for key, value in menu_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if items %}
{{ render_dropdown_items(items, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</div>
</div>
</div>
{% endmacro %}
{#
Renders dropdown menu items recursively.
@param items {array} - The array of items to render.
@param parent_id_prefix {string} [optional] - Prefix for generating element IDs.
#}
{% macro render_dropdown_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 %}
<div
role="group"
aria-labelledby="{{ group_label_id }}"
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
{{ render_dropdown_items(item.items, item_id) if item.items }}
</div>
{% elif item.type == "separator" %}
<hr role="separator" />
{% elif item.type == "item" or not item.type %}
{% if item.url %}
<a
id="{{ item_id }}"
role="menuitem"
href="{{ item.url }}"
{% if item.attrs %}
{% for key, value in item.attrs %}
{% if key != "url" %} {{ key }}="{{ value }}" {% endif %}
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label | safe }}
</a>
{% else %}
<div
id="{{ item_id }}"
role="menuitem"
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label | safe }}
</div>
{% endif %}
{% endif %}
{% endfor %}
{% endmacro %}
@@ -0,0 +1,48 @@
{#
Renders a generic popover component, often used internally by other components like dropdowns or selects.
@param id {string} [optional] - Unique identifier for the popover component.
@param trigger {string} [optional] - HTML content for the element that triggers the popover.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger element.
@param popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
#}
{% macro popover(
trigger,
id=None,
main_attrs={},
trigger_attrs={},
popover_attrs={}
) %}
{% set id = id or ("popover-" + (range(100000, 999999) | random | string)) %}
<div
id="{{ id }}"
class="popover {{ main_attrs.class }}"
{% for key, value in main_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<button
id="{{ id }}-trigger"
type="button"
aria-expanded="false"
aria-controls="{{ id }}-popover"
{% for key, value in trigger_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger | safe }}
</button>
<div
id="{{ id }}-popover"
data-popover
aria-hidden="true"
{% for key, value in popover_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ caller() if caller }}
</div>
</div>
{% endmacro %}
+184
View File
@@ -0,0 +1,184 @@
{#
Renders a select component.
@param id {string} [optional] - Unique identifier for the select component.
@param selected {string|array} [optional] - The initially selected value(s).
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
@param multiple {boolean} [optional] [default=false] - Enables multiple selection mode.
@param placeholder {string} [optional] - Placeholder text shown when no option is selected.
@param close_on_select {boolean} [optional] [default=false] - Closes the popover when selecting an option in multiple mode.
@param format {string} [optional] [default="value"] - Hidden input serialization format: "value" or "object".
@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 popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
@param listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div.
@param input_attrs {object} [optional] - Additional HTML attributes for the hidden input.
#}
{% macro select(
id=None,
selected=None,
name=None,
items=None,
multiple=false,
placeholder=None,
close_on_select=false,
format="value",
main_attrs={},
trigger_attrs={},
popover_attrs={},
listbox_attrs={},
input_attrs={}
) %}
{% set id = id or ("select-" + (range(100000, 999999) | random | string)) %}
{% if selected is defined and selected is iterable and selected is not string %}
{% set selectedArray = selected %}
{% elif selected is defined %}
{% set selectedArray = [selected] %}
{% else %}
{% set selectedArray = [] %}
{% endif %}
{% set first_option = [] %}
{% set selected_options = [] %}
{% if items %}
{% for item in items %}
{% if item.type == "group" %}
{% for sub_item in item.items %}
{% if not first_option[0] %}
{% set first_option = (first_option.push(sub_item), first_option) %}
{% endif %}
{% if sub_item.value in selectedArray %}
{% set selected_options = (selected_options.push(sub_item), selected_options) %}
{% endif %}
{% endfor %}
{% else %}
{% if not first_option[0] %}
{% set first_option = (first_option.push(item), first_option) %}
{% endif %}
{% if item.value in selectedArray %}
{% set selected_options = (selected_options.push(item), selected_options) %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% set default_option = (selected_options[0] if selected_options[0] else (None if placeholder else (first_option[0] if first_option[0] else None))) %}
{% set labels = [] %}
{% if multiple and selected_options %}
{% for opt in selected_options %}
{% set labels = (labels.push(opt.label), labels) %}
{% endfor %}
{% endif %}
{% set default_label = (labels | join(', ') if (multiple and selected_options) else ((placeholder or '') if (placeholder and not selected_options) else (default_option.label if default_option else ''))) %}
{% set selected_payload = [] %}
{% if multiple and selected_options %}
{% for opt in selected_options %}
{% set selected_payload = (selected_payload.push({ value: opt.value, label: opt.label }), selected_payload) %}
{% endfor %}
{% elif default_option %}
{% set selected_payload = [{ value: default_option.value, label: default_option.label }] %}
{% endif %}
<div
id="{{ id }}"
class="select {{ main_attrs.class }}"
{% if format == "object" %}data-format="object"{% endif %}
{% if placeholder %}data-placeholder="{{ placeholder }}"{% endif %}
{% if multiple and close_on_select %}data-close-on-select="true"{% endif %}
{% for key, value in main_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<button
type="button"
class="{{ trigger_attrs.class }}"
id="{{ id }}-trigger"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="{{ id }}-listbox"
{% for key, value in trigger_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<span class="truncate">{{ default_label }}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down text-muted-foreground opacity-50 shrink-0"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div
id="{{ id }}-popover"
data-popover
aria-hidden="true"
{% for key, value in popover_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
<div
role="listbox"
id="{{ id }}-listbox"
aria-orientation="vertical"
aria-labelledby="{{ id }}-trigger"
{% if multiple %}aria-multiselectable="true"{% endif %}
{% for key, value in listbox_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if items and items.length > 0 %}
{{ render_select_items(items, selectedArray, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</div>
</div>
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{% if format == 'object' %}{{ (selected_payload if multiple else (selected_payload[0] if selected_payload[0] else None)) | dump }}{% elif multiple %}{{ selectedArray | dump }}{% else %}{{ (default_option.value if default_option) or '' }}{% endif %}"
{% for key, value in input_attrs %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
</div>
{% endmacro %}
{#
Renders a list of items for the select component.
@param items {array} - The array of items to render.
@param parent_id_prefix {string} [optional] - The prefix for the item id.
#}
{% macro render_select_items(items, selectedArray, 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 %}
<div
role="group"
aria-labelledby="{{ group_label_id }}"
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
{{ render_select_items(item.items, selectedArray, item_id) if item.items }}
</div>
{% elif item.type == "separator" %}
<hr role="separator" />
{% elif item.type == "item" or not item.type %}
<div
id="{{ item_id }}"
role="option"
{% if item.value is defined and item.value is not none %}data-value="{{ item.value }}"{% endif %}
{% if item.value in selectedArray %}aria-selected="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ item.label | safe }}
</div>
{% endif %}
{% endfor %}
{% endmacro %}
+144
View File
@@ -0,0 +1,144 @@
{#
Renders a sidebar component.
@param id {string} [optional] - Unique identifier for the sidebar component.
@param label {string} [optional] - Label for the sidebar navigation.
@param open {boolean} [optional] - Whether the sidebar is open.
@param side {string} [optional] - Side of the sidebar to display.
@param header {string} [optional] - Header content for the sidebar.
@param footer {string} [optional] - Footer content for the sidebar.
@param items {array} [optional] - Array of menu items for the sidebar.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param header_attrs {object} [optional] - Additional HTML attributes for the header element.
@param content_attrs {object} [optional] - Additional HTML attributes for the content section.
@param footer_attrs {object} [optional] - Additional HTML attributes for the footer element.
#}
{% macro sidebar(
id=None,
label="Sidebar navigation",
open=true,
side=None,
header=None,
footer=None,
menu=None,
main_attrs={},
header_attrs={},
content_attrs={},
footer_attrs={}
) %}
<aside
{% if id %}id="{{ id }}"{% endif %}
class="sidebar {{ main_attrs.class }}"
data-side="{{ side if side else "left" }}"
aria-hidden="{{ "true" if not open else "false" }}"
{{ "inert" if not open }}
{% for key, value in main_attrs %}
{% if key != "class" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<nav
aria-label="{{ label }}"
>
{% if header %}
<header
{% for key, value in header_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ header | safe }}
</header>
{% endif %}
<section
{% for key, value in content_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if menu %}
{{ render_sidebar_content(menu, id ~ "-content" if id else "content") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</section>
{% if footer %}
<footer
{% for key, value in footer_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ footer | safe }}
</footer>
{% endif %}
</nav>
</aside>
{% endmacro %}
{#
Renders sidebar content recursively (groups, items, submenus, separators).
@param items {array} - The array of items to render.
@param parent_id_prefix {string} [optional] - Prefix for generating element IDs.
#}
{% macro render_sidebar_content(items, parent_id_prefix="content") %}
{% 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 %}
<div
role="group"
{% if item.label %}aria-labelledby="{{ group_label_id }}"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{% if item.label %}
<h3 id="{{ group_label_id }}">{{ item.label }}</h3>
{% endif %}
<ul>
{{ render_sidebar_content(item.items, item_id) if item.items }}
</ul>
</div>
{% elif item.type == "separator" %}
<hr role="separator"/>
{% elif item.type == "submenu" %}
<li>
<details
id="submenu-{{ item_id }}"
{{ "open" if item.open }}
{% if item.attrs %}
{% for key, value in item.attrs %}
{% if key != "open" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% endif %}
>
<summary aria-controls="submenu-{{ item_id }}-content">
{% if item.icon %}{{ item.icon | safe }}{% endif %}
<span>{{ item.label | safe }}</span>
</summary>
<ul id="submenu-{{ item_id }}-content">
{{ render_sidebar_content(item.items, item_id) if item.items }}
</ul>
</details>
</li>
{% elif item.type == "item" or not item.type %}
<li>
<a
href="{{ item.url }}"
{% if item.current %}aria-current="page"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{% if key != "href" and key != "aria-current" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
<span>{{ item.label | safe }}</span>
</a>
</li>
{% endif %}
{% endfor %}
{% endmacro %}
+27
View File
@@ -0,0 +1,27 @@
{#
Renders a tabs component.
@param id {string} - Unique identifier for the tabs component.
@param tabsets {array} - An array of objects, each representing a tab and panel.
Each object should have:
- tab {string}: HTML content for the tab button.
- panel {string} [optional]: HTML content for the tab panel.
- tab_attrs {object} [optional]: Additional HTML attributes for the tab button.
- panel_attrs {object} [optional]: Additional HTML attributes for the tab panel div.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param tablist_attrs {object} [optional] - Additional HTML attributes for the tablist nav element.
Set aria-orientation="vertical" for vertical tabs or data-variant="line" for line tabs.
@param default_tab_index {number} [optional] [default=1] - The 1-based index of the tab to be active initially.
#}
{%- macro tabs(
id=None,
tabsets=[],
main_attrs={},
tablist_attrs={},
default_tab_index=1
)
-%}
{%- set id = id or ("tabs-" + (range(100000, 999999) | random | string)) -%}
{%- set orientation = tablist_attrs['aria-orientation'] or 'horizontal' -%}
<div class="tabs {{ main_attrs.class }}" id="{{ id }}"{% for key, value in main_attrs %}{% if key != 'class' %} {{ key }}="{{ value }}"{% endif %}{% endfor %}><nav role="tablist" aria-orientation="{{ orientation }}"{% for key, value in tablist_attrs %}{% if key != 'aria-orientation' %} {{ key }}="{{ value }}"{% endif %}{% endfor %}>{% for tabset in tabsets %}<button type="button" role="tab" id="{{ id }}-tab-{{ loop.index }}" aria-controls="{{ id }}-panel-{{ loop.index }}" aria-selected="{{ 'true' if loop.index == default_tab_index else 'false' }}" tabindex="{{ '0' if loop.index == default_tab_index and not tabset.tab_attrs.disabled and tabset.tab_attrs['aria-disabled'] != 'true' else '-1' }}"{% if tabset.tab_attrs %}{% for key, value in tabset.tab_attrs %} {{ key }}="{{ value }}"{% endfor %}{% endif %}>{{ tabset.tab | safe }}</button>{% endfor %}</nav>{% for tabset in tabsets %}{% if tabset.panel %}<div role="tabpanel" id="{{ id }}-panel-{{ loop.index }}" aria-labelledby="{{ id }}-tab-{{ loop.index }}" tabindex="-1" aria-selected="{{ 'true' if loop.index == default_tab_index else 'false' }}"{% if loop.index != default_tab_index %} hidden{% endif %}{% if tabset.panel_attrs %}{% for key, value in tabset.panel_attrs %} {{ key }}="{{ value }}"{% endfor %}{% endif %}>{{ tabset.panel | safe }}</div>{% endif %}{% endfor %}</div>
{%- endmacro -%}
+117
View File
@@ -0,0 +1,117 @@
{#
Renders a toaster container and individual toast messages.
Can render the initial container or be used with htmx OOB swaps to add toasts dynamically.
@param id {string} [optional] [default="toaster"] - Unique identifier for the toaster container.
@param toasts {array} [optional] - An array of toast objects to render initially. See the <code>toast()</code> macro for more details.
@param attrs {object} [optional] - Additional HTML attributes for the main toaster container div.
Supports caller() for custom toast markup.
#}
{% macro toaster(
id="toaster",
toasts=[],
attrs={}
) %}
<div
id="{{ id }}"
class="toaster {{ attrs.class }}"
{% for key, value in attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% for item in toasts %}
{{ toast(
category=item.category,
title=item.title,
description=item.description,
action=item.action,
cancel=item.cancel,
attrs=item.attrs
) }}
{% endfor %}
{{ caller() if caller }}
</div>
{% endmacro %}
{#
Renders a single toast message.
@param category {string} [optional] [default="success"] - Type of toast ('success', 'error', 'info', 'warning'). Determines icon and ARIA role.
@param title {string} [optional] - The main title text of the toast.
@param description {string} [optional] - The secondary description text.
@param action {object} [optional] - Defines an action button.
- label {string}: Button text.
- onclick {string}: JavaScript code to execute on click.
- url {string}: URL for an anchor link button.
@param cancel {object} [optional] - Defines a cancel/dismiss button (similar structure to action).
- label {string}: Button text.
- onclick {string}: JavaScript code to execute on click.
- url {string}: URL for an anchor link button.
@param attrs {object} [optional] - Additional HTML attributes for the toast div.
#}
{% macro toast(
category="success",
title="",
description="",
action=None,
cancel=None,
attrs={}
) %}
<div
class="toast {{ attrs.class }}"
role="{{ 'alert' if category == 'error' else 'status' }}"
aria-atomic="true"
aria-hidden="false"
{% if category %}data-category="{{ category }}"{% endif %}
{% for key, value in attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<div class="toast-content">
{% if category in ["error", "success", "info", "warning"] %}
{% if category == "success" %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
{% elif category == "error" %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
{% elif category == "info" %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
{% elif category == "warning" %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
{% endif %}
{% endif %}
<section>
{% if title %}<h2>{{ title }}</h2>{% endif %}
{% if description %}<p>{{ description }}</p>{% endif %}
</section>
{% if action or cancel %}
<footer>
{% if action %}
{% if action.href %}
<a
href="{{ action.href }}"
class="btn" data-size="sm"
data-toast-action
>{{ action.label }}</a>
{% else %}
<button
type="button"
class="btn"
data-toast-action
{% if action.onclick %}onclick="{{ action.onclick }}"{% endif %}
>{{ action.label }}</button>
{% endif %}
{% endif %}
{% if cancel %}
<button
type="button"
class="btn" data-variant="outline" data-size="sm"
data-toast-cancel
{% if cancel.onclick %}onclick="{{ cancel.onclick }}"{% endif %}
>{{ cancel.label }}</button>
{% endif %}
</footer>
{% endif %}
</div>
</div>
{% endmacro %}