@@ -209,7 +208,6 @@ toc:
id='demo-tabs-with-panels',
tabsets=tabsets_demo,
main_attrs={ "class": "w-full" },
- tablist_attrs={ "class": "w-full" },
- register_on=["alpine:init", "htmx:afterSwap"]
+ tablist_attrs={ "class": "w-full" }
) }}{% endraw %}{% endset%}
{{ code_block(raw_code, "jinja") }}
\ No newline at end of file
diff --git a/docs/src/components/toast.njk b/docs/src/components/toast.njk
index 20b2125..864929f 100644
--- a/docs/src/components/toast.njk
+++ b/docs/src/components/toast.njk
@@ -64,10 +64,10 @@ toc:
-{% set code_script %}{% endset %}
+{% set code_script %}{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
diff --git a/docs/src/index.njk b/docs/src/index.njk
index e36248b..3eb3b85 100644
--- a/docs/src/index.njk
+++ b/docs/src/index.njk
@@ -53,7 +53,7 @@ layout: layouts/layout.njk
{% call select(
is_combobox=true,
- content_attrs={"data-align": "end"}
+ popover_attrs={"data-align": "end"}
) %}
Viewer
Developer
@@ -70,7 +70,7 @@ layout: layouts/layout.njk
{% call select(
is_combobox=true,
- content_attrs={"data-align": "end"}
+ popover_attrs={"data-align": "end"}
) %}
Viewer
Developer
diff --git a/docs/src/installation.njk b/docs/src/installation.njk
index c9a7d32..79abef0 100644
--- a/docs/src/installation.njk
+++ b/docs/src/installation.njk
@@ -3,8 +3,6 @@ layout: layouts/page.njk
title: Installation
description: How to install and use Basecoat in your app.
toc:
- - label: Quickstart
- id: install-quickstart
- label: Install using the CDN
id: install-cdn
- label: Installing using NPM
@@ -33,39 +31,26 @@ toc:
{% from "macros/code_block.njk" import code_block %}
{% from "tabs.njk" import tabs %}
-
+
+
+
- The fastest way to get up and running is to load the CSS and all of the components' JavaScript using the CDN. Add the following to the <head> of your HTML file:
+ Add the following to the <head> of your HTML file:
{% set code %}
-
-{% endset %}
+{% endset %}
{{ code_block(code, "html") }}
-
- If you're not using all of the components requiring JavaScript (Dropdown Menu, Select, Sidebar, Tabs and Toast), follow the instructions below to only include the JavaScript files you actually need.
-
-
-
+
- The easiest way to use Basecoat is use the CDN. First, add the CSS to the <head> of your HTML file:
+ While the JavaScript file for all components is small (around 3kB gzipped), you can cherry-pick the components you need as instructed in the component's page (Dropdown Menu, Popover, Select, Sidebar, Tabs and Toast). For example, to add the Dropdown Menu component, add the following to the <head> of your HTML file:
-{% set code %}
{% endset %}
-{{ code_block(code, "html") }}
-
-
- Depending on the components you want to use, you may also need to add JavaScript files as instructed on the component's page. For example, for the Tabs component, you would add the following:
-
-
-{% set code %}{% endset %}
+{% set code %}
+{% endset %}
{{ code_block(code, "html") }}
@@ -122,20 +107,13 @@ toc:
-
Some components may require JavaScript code to work.
+
A handful of components require JavaScript code to work., specifically Dropdown Menu, Popover, Select, Sidebar, Tabs and Toast
If a component requires JavaScript, the documentation page will provide instructions. There are 2 options to add the JavaScript code to your project:
- - CDN: you add the
<script> tag provided on the component's page to the <head> of your HTML file.
- - Local file: you download the script as a separate file and include it in your project (or let the CLI do it for you). Make sure keep the
defer attribute on the <script> tag when doing so.
-
-
-
A few things to keep in mind:
-
diff --git a/docs/src/test.njk b/docs/src/test.njk
new file mode 100644
index 0000000..4e32864
--- /dev/null
+++ b/docs/src/test.njk
@@ -0,0 +1,71 @@
+
+
+
+
+
+
Anchor Positioning Test (Top, End)
+
+
+
+
+
+
+
+
+ I should be anchored above the button, aligned to the right.
+
+
+
+
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index b9df4af..525ffcb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,7 +14,6 @@
"devDependencies": {
"@11ty/eleventy": "^3.0.0",
"@grimlink/eleventy-plugin-lucide-icons": "^2.1.4",
- "@oddbird/css-anchor-positioning": "^0.6.0",
"@tailwindcss/cli": "^4.1.3",
"concurrently": "^9.1.2",
"prettier": "^3.5.3",
@@ -256,34 +255,6 @@
"tslib": "^2.4.0"
}
},
- "node_modules/@floating-ui/core": {
- "version": "1.7.1",
- "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz",
- "integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@floating-ui/utils": "^0.2.9"
- }
- },
- "node_modules/@floating-ui/dom": {
- "version": "1.7.1",
- "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz",
- "integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@floating-ui/core": "^1.7.1",
- "@floating-ui/utils": "^0.2.9"
- }
- },
- "node_modules/@floating-ui/utils": {
- "version": "0.2.9",
- "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
- "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@grimlink/eleventy-plugin-lucide-icons": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@grimlink/eleventy-plugin-lucide-icons/-/eleventy-plugin-lucide-icons-2.1.4.tgz",
@@ -773,19 +744,6 @@
"node": ">= 8"
}
},
- "node_modules/@oddbird/css-anchor-positioning": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/@oddbird/css-anchor-positioning/-/css-anchor-positioning-0.6.0.tgz",
- "integrity": "sha512-5r6AhCHSW/bU45gCwOdaei04WPn2E7oeztC8o0z6euyAPBbAeiYQda5ByIiMRfOf13sa8rVqiCsKpwx/QNyHbw==",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "@floating-ui/dom": "^1.6.13",
- "@types/css-tree": "^2.3.10",
- "css-tree": "^3.1.0",
- "nanoid": "^5.1.5"
- }
- },
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
@@ -1412,13 +1370,6 @@
"node": ">= 10"
}
},
- "node_modules/@types/css-tree": {
- "version": "2.3.10",
- "resolved": "https://registry.npmjs.org/@types/css-tree/-/css-tree-2.3.10.tgz",
- "integrity": "sha512-WcaBazJ84RxABvRttQjjFWgTcHvZR9jGr0Y3hccPkHjFyk/a3N8EuxjKr+QfrwjoM5b1yI1Uj1i7EzOAAwBwag==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/a-sync-waterfall": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz",
@@ -1891,20 +1842,6 @@
"node": ">= 8"
}
},
- "node_modules/css-tree": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
- "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mdn-data": "2.12.2",
- "source-map-js": "^1.0.1"
- },
- "engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
- }
- },
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@@ -3158,13 +3095,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/mdn-data": {
- "version": "2.12.2",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
- "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
- "dev": true,
- "license": "CC0-1.0"
- },
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
@@ -3321,25 +3251,6 @@
"node": "^18.17.0 || >=20.5.0"
}
},
- "node_modules/nanoid": {
- "version": "5.1.5",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
- "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.js"
- },
- "engines": {
- "node": "^18 || >=20"
- }
- },
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
@@ -3885,16 +3796,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
diff --git a/package.json b/package.json
index 9768e80..3656826 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,6 @@
"devDependencies": {
"@11ty/eleventy": "^3.0.0",
"@grimlink/eleventy-plugin-lucide-icons": "^2.1.4",
- "@oddbird/css-anchor-positioning": "^0.6.0",
"@tailwindcss/cli": "^4.1.3",
"concurrently": "^9.1.2",
"prettier": "^3.5.3",
diff --git a/scripts/build.js b/scripts/build.js
index 01899e9..c4fdb1e 100644
--- a/scripts/build.js
+++ b/scripts/build.js
@@ -119,23 +119,9 @@ async function build() {
}
}
- // Handle anchor positioning polyfill
- const polyfillSrc = path.join(projectRoot, 'node_modules', '@oddbird', 'css-anchor-positioning', 'dist', 'css-anchor-positioning.js');
- const polyfillDest = 'css-anchor-positioning.js';
- const polyfillMinDest = 'css-anchor-positioning.min.js';
-
- // Copy original polyfill
- await fs.copyFile(polyfillSrc, path.join(cliJsDistDir, polyfillDest));
- await fs.copyFile(polyfillSrc, path.join(cssJsDistDir, polyfillDest));
-
- // Minify and copy polyfill
- const cliPolyfillMinDest = path.join(cliJsDistDir, polyfillMinDest);
- await execPromise(`npx terser ${polyfillSrc} -o ${cliPolyfillMinDest} --compress --mangle`);
- await fs.copyFile(cliPolyfillMinDest, path.join(cssJsDistDir, polyfillMinDest));
-
// Create combined component files
console.log('Creating combined component files...');
- const componentsToCombine = ['dropdown-menu.js', 'select.js', 'sidebar.js', 'tabs.js', 'toast.js'];
+ const componentsToCombine = ['dropdown-menu.js', 'popover.js', 'select.js', 'sidebar.js', 'tabs.js', 'toast.js'];
const componentPaths = componentsToCombine.map(f => path.join(srcJsDir, f));
// Create non-minified bundle
diff --git a/src/css/basecoat.css b/src/css/basecoat.css
index e587a99..e84b085 100644
--- a/src/css/basecoat.css
+++ b/src/css/basecoat.css
@@ -504,7 +504,9 @@
/* Dropdown Menu */
@layer components {
.dropdown-menu {
- [popover].popover {
+ @apply relative inline-flex p-1;
+
+ [data-popover] {
@apply p-1;
min-width: anchor-size(width);
@@ -517,7 +519,7 @@
@apply bg-accent text-accent-foreground;
}
}
- [role='menu'] [role='heading'] {
+ [role='menu'] [role="presentation"] {
@apply flex px-2 py-1.5 text-sm font-medium;
}
[role='separator'] {
@@ -576,85 +578,74 @@
/* Popover */
@layer components {
- [popover].popover {
- @apply absolute inset-auto bg-popover text-popover-foreground overflow-x-hidden overflow-y-auto p-4 rounded-md border shadow-md z-50 visible opacity-0 scale-95 w-max transition-all transition-discrete;
+ [data-popover] {
+ @apply absolute bg-popover text-popover-foreground overflow-x-hidden overflow-y-auto rounded-md border shadow-md z-50 visible opacity-100 scale-100 min-w-full w-max transition-all;
- &:not([data-side]),
- &[data-side='bottom'] {
- @apply -translate-y-2;
- }
- &[data-side='top'] {
- @apply translate-y-2;
- }
- &[data-side='left'] {
- @apply translate-x-2;
- }
- &[data-side='right'] {
- @apply -translate-x-2;
+ &[aria-hidden='true'] {
+ @apply invisible opacity-0 scale-95;
+
+ &:not([data-side]),
+ &[data-side='bottom'] {
+ @apply -translate-y-2;
+ }
+ &[data-side='top'] {
+ @apply translate-y-2;
+ }
+ &[data-side='left'] {
+ @apply translate-x-2;
+ }
+ &[data-side='right'] {
+ @apply -translate-x-2;
+ }
}
&:not([data-side]),
&[data-side='bottom'] {
- @apply mt-1 top-[anchor(bottom)];
+ @apply mt-1 top-full;
}
&[data-side='top'] {
- @apply mb-1 bottom-[anchor(top)];
+ @apply mb-1 bottom-full;
}
&[data-side='left'] {
- @apply mr-1 right-[anchor(left)];
+ @apply mr-1 right-full;
}
&[data-side='right'] {
- @apply ml-1 left-[anchor(right)];
+ @apply ml-1 left-full;
}
&:not([data-side]),
&[data-side='bottom'],
&[data-side='top'] {
&:not([data-align]),
&[data-align='start'] {
- @apply left-[anchor(left)];
+ @apply left-0;
}
&[data-align='end'] {
- @apply right-[anchor(right)];
+ @apply right-0;
}
&[data-align='center'] {
- @apply left-[anchor(center)] -translate-x-1/2;
+ @apply left-1/2 -translate-x-1/2;
}
}
&[data-side='left'],
&[data-side='right'] {
&:not([data-align]),
&[data-align='start'] {
- @apply top-[anchor(top)];
+ @apply top-0;
}
&[data-align='end'] {
- @apply bottom-[anchor(bottom)];
+ @apply bottom-0;
}
&[data-align='center'] {
- @apply top-[anchor(center)] -translate-y-1/2;
+ @apply top-1/2 -translate-y-1/2;
}
}
- &:is(:popover-open) {
- @apply opacity-100 scale-100 translate-x-0 translate-y-0;
-
- @starting-style {
- @apply opacity-0 scale-95;
-
- &:not([data-side]),
- &[data-side='bottom'] {
- @apply -translate-y-2;
- }
- &[data-side='top'] {
- @apply translate-y-2;
- }
- &[data-side='left'] {
- @apply translate-x-2;
- }
- &[data-side='right'] {
- @apply -translate-x-2;
- }
- }
- }
-
}
+ .popover {
+ @apply relative inline-flex;
+
+ [data-popover] {
+ @apply p-4;
+ }
+ }
}
/* Radio */
@@ -731,8 +722,10 @@
}
}
*:not(select).select {
- [popover].popover {
- @apply p-1 min-w-[anchor-size(width)];
+ @apply relative inline-flex;
+
+ [data-popover] {
+ @apply p-1;
[role='option'] {
@apply aria-hidden:hidden [&_svg]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm pl-2 py-1.5 pr-7.5 text-sm outline-hidden select-none [&_svg]:shrink-0 [&_svg]:size-4 aria-disabled:opacity-50 aria-disabled:pointer-events-none disabled:opacity-50 disabled:pointer-events-none w-full truncate;
@@ -741,7 +734,7 @@
@apply bg-[image:var(--check-icon)] bg-no-repeat bg-position-[center_right_0.5rem] bg-size-[0.875rem];
}
}
- [role='listbox'] [role='heading'] {
+ [role='listbox'] [role="presentation"] {
@apply flex text-muted-foreground px-2 py-1.5 text-xs;
}
[role='listbox'] [role='group']:not(:has([role='option']:not([aria-hidden='true']))) {
diff --git a/src/jinja/select.html.jinja b/src/jinja/select.html.jinja
index 92f968c..90f0dd5 100644
--- a/src/jinja/select.html.jinja
+++ b/src/jinja/select.html.jinja
@@ -4,53 +4,66 @@
@param id {string} [optional] - Unique identifier for the select component.
@param selected {string} [optional] - The initially selected value.
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
- @param items {array} [optional] - An array of items (objects with type, label, value, attrs, and items) to render.
@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 content_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.
@param search_placeholder {string} [optional] [default="Search entries..."] - Placeholder text for the search input (combobox only).
- @param is_combobox {boolean} [optional] [default=False] - Renders a combobox with search functionality if True.
+ @param is_combobox {boolean} [optional] [default=false] - Renders a combobox with search functionality if true.
#}
{% macro select(
id=None,
selected=None,
name=None,
items=None,
- main_attrs={},
trigger_attrs={},
- content_attrs={},
+ popover_attrs={},
listbox_attrs={},
- input_attrs={},
search_placeholder="Search entries...",
- is_combobox=False
+ is_combobox=false
) %}
-
+{% set id = id or ("select-" + (range(100000, 999999) | random | string)) %}
+
+{% set first_option = [] %}
+{% set selected_option = [] %}
+
+{% for item in items.items() %}
+ {% if item.type == "group" %}
+ {% for sub_item in item.items.items() %}
+ {% if not first_option[0] %}
+ {% set first_option = (first_option.push(sub_item), first_option) %}
+ {% endif %}
+ {% if selected and sub_item.value == selected and not selected_option[0] %}
+ {% set selected_option = (selected_option.push(sub_item), selected_option) %}
+ {% endif %}
+ {% endfor %}
+ {% else %}
+ {% if not first_option[0] %}
+ {% set first_option = (first_option.push(item), first_option) %}
+ {% endif %}
+ {% if selected and item.value == selected and not selected_option[0] %}
+ {% set selected_option = (selected_option.push(item), selected_option) %}
+ {% endif %}
+ {% endif %}
+{% endfor %}
+
+{% set default_option = selected_option[0] or first_option[0] or None %}
+
+
{% if is_combobox %}
@@ -81,35 +93,33 @@
aria-expanded="true"
aria-controls="{{ id }}-content"
aria-labelledby="{{ id }}-trigger"
- x-model="query"
- x-bind="$filter"
>
{% endif %}
- {% if items %}
- {{ render_select_items(items, id ~ "-items" if id else "items") }}
+ {% if items.length > 0 %}
+ {{ render_select_items(items, default_option.value, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
- {% if name is defined %}
-
- {% endif %}
+
{% endmacro %}
@@ -119,29 +129,30 @@
@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, parent_id_prefix="items") %}
+{% macro render_select_items(items, selected, parent_id_prefix="items") %}
{% for item in items.items() %}
{% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
-
{% if item.type == "group" %}
{% set group_label_id = item.id if item.id else "group-label-" + item_id %}
{{ item.label }}
- {{ render_select_items(item.items, item_id) if item.items }}
+ {{ render_select_items(item.items, selected, item_id) if item.items }}
{% elif item.type == "separator" %}
{% elif item.type == "item" or not item.type %}
diff --git a/src/js/dropdown-menu.js b/src/js/dropdown-menu.js
index dbb2b6f..4a4dc1f 100644
--- a/src/js/dropdown-menu.js
+++ b/src/js/dropdown-menu.js
@@ -1,13 +1,35 @@
(() => {
const initDropdownMenu = (dropdownMenuComponent) => {
- const trigger = dropdownMenuComponent.querySelector('[popovertarget]');
- const popover = dropdownMenuComponent.querySelector('[popover]');
- const menu = popover?.querySelector('[role="menu"]');
- if (!trigger || !popover || !menu) return;
+ const trigger = dropdownMenuComponent.querySelector(':scope > button');
+ const popover = dropdownMenuComponent.querySelector(':scope > [data-popover]');
+ const menu = popover.querySelector('[role="menu"]');
+
+ if (!trigger || !menu || !popover) {
+ console.error('Dropdown menu component is missing a trigger, a menu, or a popover content element.', dropdownMenuComponent);
+ return;
+ }
let menuItems = [];
let activeIndex = -1;
+ const closeMenu = () => {
+ if (trigger.getAttribute('aria-expanded') === 'false') return;
+ trigger.setAttribute('aria-expanded', 'false');
+ trigger.removeAttribute('aria-activedescendant');
+ popover.setAttribute('aria-hidden', 'true');
+ trigger.focus();
+ activeIndex = -1;
+ };
+
+ const openMenu = () => {
+ trigger.setAttribute('aria-expanded', 'true');
+ popover.setAttribute('aria-hidden', 'false');
+ menuItems = Array.from(menu.querySelectorAll('[role^="menuitem"]:not([disabled])'));
+ if (menuItems.length > 0) {
+ setActiveItem(0);
+ }
+ };
+
const setActiveItem = (index) => {
if (activeIndex > -1 && menuItems[activeIndex]) {
menuItems[activeIndex].classList.remove('active');
@@ -17,36 +39,48 @@
const activeItem = menuItems[activeIndex];
activeItem.classList.add('active');
trigger.setAttribute('aria-activedescendant', activeItem.id);
- activeItem.scrollIntoView({ block: 'nearest' });
} else {
trigger.removeAttribute('aria-activedescendant');
}
};
- const handleKeyDown = (e) => {
- if (!popover.matches(':popover-open')) {
+ trigger.addEventListener('click', () => {
+ const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
+ if (isExpanded) {
+ closeMenu();
+ } else {
+ openMenu();
+ }
+ });
+
+ dropdownMenuComponent.addEventListener('keydown', (e) => {
+ const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
+
+ if (e.key === 'Escape') {
+ if (isExpanded) closeMenu();
+ return;
+ }
+
+ if (!isExpanded) {
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
e.preventDefault();
- trigger.click();
+ openMenu();
}
return;
}
- let nextIndex = activeIndex;
if (menuItems.length === 0) return;
+ let nextIndex = activeIndex;
+
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
- if (activeIndex < menuItems.length - 1) {
- nextIndex = activeIndex + 1;
- }
+ nextIndex = activeIndex < menuItems.length - 1 ? activeIndex + 1 : 0;
break;
case 'ArrowUp':
e.preventDefault();
- if (activeIndex > 0) {
- nextIndex = activeIndex - 1;
- }
+ nextIndex = activeIndex > 0 ? activeIndex - 1 : menuItems.length - 1;
break;
case 'Home':
e.preventDefault();
@@ -60,42 +94,24 @@
case ' ':
e.preventDefault();
menuItems[activeIndex]?.click();
- break;
+ closeMenu();
+ return;
}
if (nextIndex !== activeIndex) {
setActiveItem(nextIndex);
}
- };
-
- trigger.addEventListener('keydown', handleKeyDown);
-
- popover.addEventListener('toggle', (e) => {
- trigger.setAttribute('aria-expanded', e.newState === 'open');
- if (e.newState === 'open') {
- menuItems = Array.from(menu.querySelectorAll('[role^="menuitem"]:not([disabled])'));
- menuItems.forEach((item, index) => {
- if (!item.id) item.id = `${menu.id}-item-${index}`;
- });
- setActiveItem(0);
- } else {
- setActiveItem(-1);
- }
});
menu.addEventListener('click', (e) => {
if (e.target.closest('[role^="menuitem"]')) {
- popover.hidePopover();
+ closeMenu();
}
});
- menu.addEventListener('mouseover', (e) => {
- const item = e.target.closest('[role^="menuitem"]:not([disabled])');
- if (item) {
- const index = menuItems.indexOf(item);
- if (index > -1 && index !== activeIndex) {
- setActiveItem(index);
- }
+ document.addEventListener('click', (e) => {
+ if (!dropdownMenuComponent.contains(e.target)) {
+ closeMenu();
}
});
@@ -107,15 +123,14 @@
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
- if (node.nodeType === Node.ELEMENT_NODE) {
- if (node.matches('.dropdown-menu:not([data-dropdown-menu-initialized])')) {
- initDropdownMenu(node);
- }
- node.querySelectorAll('.dropdown-menu:not([data-dropdown-menu-initialized])').forEach(initDropdownMenu);
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
+ if (node.matches('.dropdown-menu:not([data-dropdown-menu-initialized])')) {
+ initDropdownMenu(node);
}
+ node.querySelectorAll('.dropdown-menu:not([data-dropdown-menu-initialized])').forEach(initDropdownMenu);
});
});
});
-
+
observer.observe(document.body, { childList: true, subtree: true });
})();
\ No newline at end of file
diff --git a/src/js/popover.js b/src/js/popover.js
new file mode 100644
index 0000000..e7eb695
--- /dev/null
+++ b/src/js/popover.js
@@ -0,0 +1,69 @@
+(() => {
+ const initPopover = (popoverComponent) => {
+ const trigger = popoverComponent.querySelector(':scope > button');
+ const content = popoverComponent.querySelector(':scope > [data-popover]');
+
+ if (!trigger || !content) {
+ console.error('Popover component is missing a trigger button or a content element.', popoverComponent);
+ return;
+ }
+
+ const closePopover = () => {
+ if (trigger.getAttribute('aria-expanded') === 'false') return;
+ trigger.setAttribute('aria-expanded', 'false');
+ content.setAttribute('aria-hidden', 'true');
+ trigger.focus();
+ };
+
+ const openPopover = () => {
+ const elementToFocus = content.querySelector('[autofocus]');
+ if (elementToFocus) {
+ content.addEventListener('transitionend', () => {
+ elementToFocus.focus();
+ }, { once: true });
+ }
+
+ trigger.setAttribute('aria-expanded', 'true');
+ content.setAttribute('aria-hidden', 'false');
+ };
+
+ trigger.addEventListener('click', () => {
+ const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
+ if (isExpanded) {
+ closePopover();
+ } else {
+ openPopover();
+ }
+ });
+
+ popoverComponent.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ closePopover();
+ }
+ });
+
+ document.addEventListener('click', (e) => {
+ if (!popoverComponent.contains(e.target)) {
+ closePopover();
+ }
+ });
+
+ popoverComponent.dataset.popoverInitialized = true;
+ };
+
+ document.querySelectorAll('.popover:not([data-popover-initialized])').forEach(initPopover);
+
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ mutation.addedNodes.forEach((node) => {
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
+ if (node.matches('.popover:not([data-popover-initialized])')) {
+ initPopover(node);
+ }
+ node.querySelectorAll('.popover:not([data-popover-initialized])').forEach(initPopover);
+ });
+ });
+ });
+
+ observer.observe(document.body, { childList: true, subtree: true });
+})();
diff --git a/src/js/select.js b/src/js/select.js
index 8a3fdb3..1b02ff4 100644
--- a/src/js/select.js
+++ b/src/js/select.js
@@ -1,8 +1,8 @@
(() => {
const initSelect = (selectComponent) => {
- const trigger = selectComponent.querySelector(':scope > [popovertarget]');
+ const trigger = selectComponent.querySelector(':scope > button');
const selectedValue = trigger.querySelector(':scope > span');
- const popover = selectComponent.querySelector(':scope > [popover]');
+ const popover = selectComponent.querySelector(':scope > [data-popover]');
const listbox = popover.querySelector('[role="listbox"]');
const input = selectComponent.querySelector(':scope > input[type="hidden"]');
const filter = selectComponent.querySelector('header input[type="text"]');
@@ -21,15 +21,26 @@
}
};
+ const closePopover = () => {
+ popover.setAttribute('aria-hidden', 'true');
+ trigger.setAttribute('aria-expanded', 'false');
+ if (filter) {
+ filter.value = '';
+ visibleOptions = [...options];
+ options.forEach(opt => opt.setAttribute('aria-hidden', 'false'));
+ }
+ trigger.removeAttribute('aria-activedescendant');
+ if (activeIndex > -1) options[activeIndex]?.classList.remove('active');
+ activeIndex = -1;
+ }
+
const selectOption = (option) => {
if (!option) return;
- updateValue(option);
-
- trigger.removeAttribute('aria-activedescendant');
- options.forEach(opt => opt.classList.remove('active'));
- activeIndex = -1;
- popover.hidePopover();
+ if (option.dataset.value) {
+ updateValue(option);
+ }
+ closePopover();
};
if (filter) {
@@ -62,12 +73,14 @@
updateValue(initialOption);
const handleKeyNavigation = (e) => {
- if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End'].includes(e.key)) {
+ const isPopoverOpen = popover.getAttribute('aria-hidden') === 'false';
+
+ if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End', 'Escape'].includes(e.key)) {
return;
}
- if (!popover.matches(':popover-open')) {
- if (e.currentTarget === trigger && e.key !== 'Enter') {
+ if (!isPopoverOpen) {
+ if (e.key !== 'Enter' && e.key !== 'Escape') {
e.preventDefault();
trigger.click();
}
@@ -76,9 +89,14 @@
e.preventDefault();
+ if (e.key === 'Escape') {
+ closePopover();
+ return;
+ }
+
if (e.key === 'Enter') {
if (activeIndex > -1) {
- selectOption(options[activeIndex]);
+ selectOption(visibleOptions[activeIndex]);
}
return;
}
@@ -130,6 +148,31 @@
filter.addEventListener('keydown', handleKeyNavigation);
}
+ trigger.addEventListener('click', () => {
+ const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
+
+ if (isExpanded) {
+ closePopover();
+ } else {
+ popover.setAttribute('aria-hidden', 'false');
+ trigger.setAttribute('aria-expanded', 'true');
+ if (filter) filter.focus();
+
+ const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
+ if (selectedOption) {
+ if (activeIndex > -1) {
+ options[activeIndex]?.classList.remove('active');
+ }
+ activeIndex = options.indexOf(selectedOption);
+ selectedOption.classList.add('active');
+ if (selectedOption.id) {
+ trigger.setAttribute('aria-activedescendant', selectedOption.id);
+ }
+ selectedOption.scrollIntoView({ block: 'nearest' });
+ }
+ }
+ });
+
listbox.addEventListener('click', (e) => {
const clickedOption = e.target.closest('[role="option"]');
if (clickedOption) {
@@ -137,46 +180,13 @@
}
});
- popover.addEventListener('toggle', (e) => {
- trigger.setAttribute('aria-expanded', e.newState === 'open');
-
- if (e.newState === 'open') {
- if (filter) filter.focus();
-
- const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
- let startingOption = null;
-
- if (selectedOption && visibleOptions.includes(selectedOption)) {
- startingOption = selectedOption;
- } else if (visibleOptions.length > 0) {
- startingOption = visibleOptions[0];
- }
-
- if (activeIndex > -1) options[activeIndex]?.classList.remove('active');
-
- if (startingOption) {
- activeIndex = options.indexOf(startingOption);
- startingOption.classList.add('active');
- if (startingOption.id) {
- trigger.setAttribute('aria-activedescendant', startingOption.id);
- }
- startingOption.scrollIntoView({ block: 'nearest' });
- } else {
- activeIndex = -1;
- }
- } else if (e.newState === 'closed') {
- if (filter) {
- filter.value = '';
- visibleOptions = [...options];
- options.forEach(opt => opt.setAttribute('aria-hidden', 'false'));
- }
-
- trigger.removeAttribute('aria-activedescendant');
- if (activeIndex > -1) options[activeIndex]?.classList.remove('active');
- activeIndex = -1;
+ document.addEventListener('click', (e) => {
+ if (!selectComponent.contains(e.target)) {
+ closePopover();
}
});
+ popover.setAttribute('aria-hidden', 'true');
selectComponent.dataset.selectInitialized = true;
};
diff --git a/src/nunjucks/dialog.njk b/src/nunjucks/dialog.njk
index 369d3aa..31cacac 100644
--- a/src/nunjucks/dialog.njk
+++ b/src/nunjucks/dialog.njk
@@ -8,10 +8,10 @@
@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 content_attrs {object} [optional] - Additional HTML attributes for the dialog content article.
- @param content_header_attrs {object} [optional] - Additional HTML attributes for the dialog header.
- @param content_body_attrs {object} [optional] - Additional HTML attributes for the dialog body section.
- @param content_footer_attrs {object} [optional] - Additional HTML attributes for the dialog footer.
+ @param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog content article.
+ @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.
diff --git a/src/nunjucks/dropdown-menu.njk b/src/nunjucks/dropdown-menu.njk
index afc1a66..205f558 100644
--- a/src/nunjucks/dropdown-menu.njk
+++ b/src/nunjucks/dropdown-menu.njk
@@ -6,23 +6,28 @@
@param menu {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 content_attrs {object} [optional] - Additional HTML attributes for the dropdown content div.
+ @param popover_attrs {object} [optional] - Additional HTML attributes for the dropdown content div.
#}
{% macro dropdown_menu(
id=None,
trigger,
items=None,
+ main_attrs={},
trigger_attrs={},
popover_attrs={},
menu_attrs={}
) %}
{% set id = id or ("dropdown-menu-" + (range(100000, 999999) | random | string)) %}
-
{% endmacro %}
@@ -77,7 +82,7 @@
{{ key }}="{{ value }}"
{% endfor %}
>
-
{{ item.label }}
+
{{ item.label }}
{{ render_dropdown_items(item.items, item_id) if item.items }}
{% elif item.type == "separator" %}
@@ -85,6 +90,7 @@
{% elif item.type == "item" or not item.type %}
{% if item.url %}
{% else %}
-
+
{% endif %}
{% endif %}
{% endfor %}
diff --git a/src/nunjucks/popover.njk b/src/nunjucks/popover.njk
index b700b24..239d2ce 100644
--- a/src/nunjucks/popover.njk
+++ b/src/nunjucks/popover.njk
@@ -5,35 +5,43 @@
@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 content_attrs {object} [optional] - Additional HTML attributes for the popover content div.
+ @param popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
#}
{% macro popover(
id=None,
trigger,
+ main_attrs={},
trigger_attrs={},
popover_attrs={}
) %}
{% set id = id or ("popover-" + (range(100000, 999999) | random | string)) %}
-
+
- {{ caller() if caller }}
+>
+
+
+ {{ caller() if caller }}
+
{% endmacro %}
\ No newline at end of file
diff --git a/src/nunjucks/select.njk b/src/nunjucks/select.njk
index 0d75439..a4e027b 100644
--- a/src/nunjucks/select.njk
+++ b/src/nunjucks/select.njk
@@ -6,7 +6,7 @@
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
@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 content_attrs {object} [optional] - Additional HTML attributes for the popover content div.
+ @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.
@param search_placeholder {string} [optional] [default="Search entries..."] - Placeholder text for the search input (combobox only).
@@ -17,9 +17,11 @@
selected=None,
name=None,
items=None,
+ main_attrs={},
trigger_attrs={},
popover_attrs={},
listbox_attrs={},
+ input_attrs={},
search_placeholder="Search entries...",
is_combobox=false
) %}
@@ -28,34 +30,40 @@
{% set first_option = [] %}
{% set selected_option = [] %}
-{% for item in items %}
- {% if item.type == "group" %}
- {% for sub_item in item.items %}
+{% 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 selected and sub_item.value == selected and not selected_option[0] %}
+ {% set selected_option = (selected_option.push(sub_item), selected_option) %}
+ {% endif %}
+ {% endfor %}
+ {% else %}
{% if not first_option[0] %}
- {% set first_option = (first_option.push(sub_item), first_option) %}
+ {% set first_option = (first_option.push(item), first_option) %}
{% endif %}
- {% if selected and sub_item.value == selected and not selected_option[0] %}
- {% set selected_option = (selected_option.push(sub_item), selected_option) %}
+ {% if selected and item.value == selected and not selected_option[0] %}
+ {% set selected_option = (selected_option.push(item), selected_option) %}
{% endif %}
- {% endfor %}
- {% else %}
- {% if not first_option[0] %}
- {% set first_option = (first_option.push(item), first_option) %}
{% endif %}
- {% if selected and item.value == selected and not selected_option[0] %}
- {% set selected_option = (selected_option.push(item), selected_option) %}
- {% endif %}
- {% endif %}
-{% endfor %}
+ {% endfor %}
+{% endif %}
{% set default_option = selected_option[0] or first_option[0] or None %}
-
+