From 2e8daaef0380b5730792c08f341edd0f687ef3b9 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Sat, 6 Jun 2026 16:41:18 +0800 Subject: [PATCH] Fix sidebar nested item styling --- CHANGELOG.md | 23 + docs/src/_data/docs.json | 21 + docs/src/_includes/layouts/base.njk | 55 +- docs/src/_includes/layouts/page.njk | 123 +- docs/src/_includes/partials/header.njk | 9 +- docs/src/_includes/partials/sidebar.njk | 4 +- docs/src/assets/styles-luma.css | 9089 ++++++++++++++++++++++- docs/src/assets/styles-lyra.css | 9030 +++++++++++++++++++++- docs/src/assets/styles-maia.css | 9026 +++++++++++++++++++++- docs/src/assets/styles-mira.css | 9084 +++++++++++++++++++++- docs/src/assets/styles-nova.css | 9085 +++++++++++++++++++++- docs/src/assets/styles-rhea.css | 2 +- docs/src/assets/styles-sera.css | 2 +- docs/src/assets/styles-vega.css | 9038 +++++++++++++++++++++- docs/src/assets/styles.css | 2 +- docs/src/components/combobox.md | 4 + docs/src/components/command.md | 8 +- docs/src/components/dropdown-menu.md | 14 +- docs/src/components/native-select.md | 2 +- docs/src/components/select.md | 4 + docs/src/components/sidebar.md | 37 +- docs/src/components/tabs.md | 10 +- docs/src/components/theme-switcher.md | 79 +- docs/src/components/toast.md | 38 +- docs/src/kitchen-sink.njk | 43 +- docs/src/robots.njk | 4 +- docs/src/sitemap.njk | 6 +- docs/src/test.njk | 3 +- src/css/styles/luma.css | 32 +- src/css/styles/lyra.css | 32 +- src/css/styles/maia.css | 32 +- src/css/styles/mira.css | 32 +- src/css/styles/nova.css | 32 +- src/css/styles/rhea.css | 32 +- src/css/styles/sera.css | 32 +- src/css/styles/vega.css | 32 +- src/eleventy/navigation.js | 7 +- src/js/basecoat.js | 99 +- src/js/combobox.js | 610 +- src/js/command.js | 313 +- src/js/dropdown-menu.js | 248 +- src/js/select.js | 612 +- src/js/sidebar.js | 45 +- src/js/tabs.js | 108 +- src/js/toast.js | 55 +- 45 files changed, 55860 insertions(+), 1338 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d577d02..2ebf1bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Changed Combobox markup and behavior to an input-first structure. The visible input now filters options, the hidden input stores the submitted value, single select stores the selected value, and multiple select stores a JSON array. - Changed Command markup to the migrated Basecoat structure: `.command-dialog` wraps `.command`, the search input lives in the command header, and items use role-based menu markup with `role="menuitem"`. - Removed Combobox-specific search-header behavior from Select. Use the dedicated Combobox component for editable/filterable selection. +- Removed built-in document command events for Toast, Sidebar, and Theme. Use element methods instead: `toaster.toast(config)`, `sidebar.open()`, `sidebar.close()`, `sidebar.toggle()`, and `window.basecoat.theme.*`. - Reworked style loading for style packs. Non-default styles are standalone bundles and should not be loaded on top of the default/Vega bundle. ### Added @@ -18,6 +19,9 @@ - Added dedicated Input Group component styles and docs. - Added dedicated Spinner docs and examples using `animate-spin` and `size-4` patterns. - Added style-specific package entrypoints such as `basecoat-css/nova` and styleless base entrypoints for custom themes. +- Added `window.basecoat.refresh(element)` as a generic dispatcher for components that expose `refresh()`. +- Added `refresh()` methods to Command, Select, Combobox, Dropdown Menu, and Tabs for dynamic child lists. +- Added method APIs for Sidebar, Toast, and Theme. ### Changed @@ -41,3 +45,22 @@ To keep the previous `.form` wrapper behavior, define it in your own Tailwind CS .form textarea { @apply textarea; } .form select { @apply select; } ``` + +To keep the previous document-event command APIs, add bridge listeners in your app: + +```js +document.addEventListener('basecoat:toast', (event) => { + document.getElementById('toaster')?.toast(event.detail?.config || {}); +}); + +document.addEventListener('basecoat:sidebar', (event) => { + const sidebar = document.getElementById(event.detail?.id || 'sidebar'); + const action = event.detail?.action || 'toggle'; + if (['open', 'close', 'toggle'].includes(action)) sidebar?.[action](); +}); + +document.addEventListener('basecoat:theme', (event) => { + const mode = event.detail?.mode; + mode ? window.basecoat.theme.set(mode) : window.basecoat.theme.toggle(); +}); +``` diff --git a/docs/src/_data/docs.json b/docs/src/_data/docs.json index 697f9d4..f63a128 100644 --- a/docs/src/_data/docs.json +++ b/docs/src/_data/docs.json @@ -36,6 +36,27 @@ "type": "group", "label": "Components", "items": [ + { + "type": "submenu", + "label": "Parent item", + "icon": "folder", + "items": [ + { + "type": "item", + "label": "Item 1", + "icon": "file", + "url": "#", + "external": true + }, + { + "type": "item", + "label": "Item 2", + "icon": "file", + "url": "#", + "external": true + } + ] + }, "components/accordion", "components/alert", "components/alert-dialog", diff --git a/docs/src/_includes/layouts/base.njk b/docs/src/_includes/layouts/base.njk index 66e7254..98c0ec5 100644 --- a/docs/src/_includes/layouts/base.njk +++ b/docs/src/_includes/layouts/base.njk @@ -1,5 +1,5 @@ - + {# Dark mode #} {# Theme variant #} @@ -36,17 +25,6 @@ } })(); - {# Style variant #} - {# Meta #} @@ -58,34 +36,43 @@ {% if site.keywords %}{% endif %} + {% if siteUrl %}{% endif %} {# Favicon #} - - + + {# Open Graph / Facebook #} - + - + {# X/Twitter #} - - + + - + {# CSS #} - + {# JS #} diff --git a/docs/src/_includes/layouts/page.njk b/docs/src/_includes/layouts/page.njk index ed8a128..5fe2990 100644 --- a/docs/src/_includes/layouts/page.njk +++ b/docs/src/_includes/layouts/page.njk @@ -4,69 +4,74 @@ layout: layouts/layout.njk {% set docsMenu = docs.menu if docs and docs.menu else menu %} {% set navigation = docsMenu | getNavigation(collections) %} -
-
-
-
-

{{ title }}

-
- -
- {% if navigation.prev %} - - {% endif %} - {% if navigation.prev and navigation.next %} -
- {% endif %} - {% if navigation.next %} - - {% endif %} +
+
-
- {{ content | safe }} -
- {% if navigation.prev or navigation.next %} - - {% endif %} -
- {% if toc %} - - {% endif %} -
+ + + {% endset %} -{{ code_block(code) }} +{{ code_block(code_head | prettyHtml, "html") }} + +
+

Load basecoat.js or the bundled JavaScript before calling window.basecoat.theme.

+

Step 2: Add your switcher

-
-

To change the theme, you need to dispatch a basecoat:theme event:

-
+{{ code_block(code_switcher | prettyHtml, "html") }} -{% set code %}{% endset %} -{{ code_block(code) }} - -

JavaScript events

+

JavaScript API

-
basecoat:theme
-
-

By default, the event will toggle the theme, but you can also add a mode to the detail to set the theme explicitly:

- {% set code_trigger %} - - -{% endset %} - {{ code_block(code_trigger | prettyHtml, "html") }} -
+
window.basecoat.theme.get()
+
Returns "dark" or "light".
+
window.basecoat.theme.set(mode)
+
Sets the mode to "dark" or "light" and stores it in localStorage.themeMode.
+
window.basecoat.theme.toggle()
+
Toggles between dark and light mode.
-
\ No newline at end of file + + +{% set code_methods %} +{% endset %} +{{ code_block(code_methods | prettyHtml, "html") }} diff --git a/docs/src/components/toast.md b/docs/src/components/toast.md index 960268a..ab54be3 100644 --- a/docs/src/components/toast.md +++ b/docs/src/components/toast.md @@ -18,7 +18,7 @@ toc: id: usage-html-js-3 - label: HTML structure id: usage-html-js-4 - - label: JavaScript events + - label: JavaScript API id: usage-html-js-5 - label: Toast config object id: usage-html-js-6 @@ -44,18 +44,14 @@ toc: {% set code_client_side %}{% endset %} @@ -106,7 +102,7 @@ toc:

Step 3: Add your toasts

-

If you decide to server-render your toasts, or load them using asynchronoulsy with something like HTMX, you can just add the toast's markup to the toaster:

+

If you decide to server-render your toasts, or load them asynchronously with something like HTMX, you can just add the toast's markup to the toaster:

{% set code_toast_html %}
@@ -123,14 +119,14 @@ toc: {{ code_block(code_toast_html | prettyHtml, "html") }}
-

If you need to create a toast from the front-end, you can dispatch a custom basecoat:toast event as such:

+

If you need to create a toast from the front-end, call the toaster element's toast(config) method:

{{ code_block(code_client_side | prettyHtml, "html") }} @@ -181,16 +177,18 @@ toc: -

JavaScript events

+

JavaScript API

basecoat:initialized
Once the component is fully initialized, it dispatches a custom (non-bubbling) basecoat:initialized event on the toaster element.
-
basecoat:toast
-
-

The toaster listens for basecoat:toast events on document to create toasts. The event's detail object must contain a config object (see JavaScript config object below).

-
+
toaster.toast(config)
+
Creates a toast from a config object and returns the inserted .toast element. See Toast config object.
+
toaster.closeAll()
+
Closes every toast inside the toaster.
+
toast.close()
+
Closes one initialized toast element.
diff --git a/docs/src/kitchen-sink.njk b/docs/src/kitchen-sink.njk index 915ac1c..46b6259 100644 --- a/docs/src/kitchen-sink.njk +++ b/docs/src/kitchen-sink.njk @@ -33,20 +33,33 @@ description: A collection of all the components available in Basecoat. { label: "Tooltip", id: "tooltip" } ] %} -
-
-
-

{{ title }}

-

{{ description }}

-
-
- {% for component in components %} - {% include "partials/kitchen-sink/" ~ component.id ~ ".njk" %} - {% endfor %} +
+
+
+
+

{{ title }}

+

{{ description }}

+
+
+ {% for component in components %} + {% include "partials/kitchen-sink/" ~ component.id ~ ".njk" %} + {% endfor %} +
-
- + +
diff --git a/docs/src/robots.njk b/docs/src/robots.njk index 9fd990e..abd3ad8 100644 --- a/docs/src/robots.njk +++ b/docs/src/robots.njk @@ -2,6 +2,6 @@ permalink: /robots.txt eleventyExcludeFromCollections: true --- -Sitemap: {{ site.url }}/sitemap.xml +Sitemap: {{ siteUrl }}/sitemap.xml User-agent: * -Disallow: \ No newline at end of file +Disallow: diff --git a/docs/src/sitemap.njk b/docs/src/sitemap.njk index 0fa1068..8839564 100644 --- a/docs/src/sitemap.njk +++ b/docs/src/sitemap.njk @@ -5,11 +5,11 @@ eleventyExcludeFromCollections: true {% for page in collections.all %} - {% if not page.url.startsWith('/fragments/') %} + {% if page.url and not page.url.startsWith('/fragments/') %} - {{ site.url }}{{ page.url | url }} + {{ siteUrl }}{{ page.url | url }} {{ page.date.toISOString() }} {% endif %} {% endfor %} - \ No newline at end of file + diff --git a/docs/src/test.njk b/docs/src/test.njk index cae0a59..366c25b 100644 --- a/docs/src/test.njk +++ b/docs/src/test.njk @@ -1,5 +1,6 @@ --- layout: layouts/layout.njk +eleventyExcludeFromCollections: true --- {% from "dialog.njk" import dialog %} @@ -36,4 +37,4 @@ footer=footer ] ) }} -{% endcall %} \ No newline at end of file +{% endcall %} diff --git a/src/css/styles/luma.css b/src/css/styles/luma.css index 47bf5bc..69482f8 100644 --- a/src/css/styles/luma.css +++ b/src/css/styles/luma.css @@ -1155,33 +1155,42 @@ @apply text-sm; } - .sidebar nav li > :is(a, button, details > summary) { + .sidebar nav li > :is(a, button), + .sidebar nav li > details > summary { @apply ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground gap-2 rounded-xl px-3 py-2 text-sm focus-visible:ring-2 text-left focus-visible:outline-hidden data-[open]:hover:bg-sidebar-accent data-[open]:hover:text-sidebar-accent-foreground [&>svg]:shrink-0 [&>svg]:size-4; } - .sidebar nav li > :is(a, button, details > summary):is([aria-current=page], [data-active=true]) { + .sidebar nav li > :is(a, button):is([aria-current=page], [data-active=true]), + .sidebar nav li > details > summary:is([aria-current=page], [data-active=true]) { @apply bg-sidebar-accent text-sidebar-accent-foreground font-medium; } - .sidebar nav li > :is(a, button, details > summary):not([data-variant]), - .sidebar nav li > :is(a, button, details > summary)[data-variant=default] { + .sidebar nav li > :is(a, button):not([data-variant]), + .sidebar nav li > details > summary:not([data-variant]), + .sidebar nav li > :is(a, button)[data-variant=default], + .sidebar nav li > details > summary[data-variant=default] { @apply hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; } - .sidebar nav li > :is(a, button, details > summary)[data-variant=outline] { + .sidebar nav li > :is(a, button)[data-variant=outline], + .sidebar nav li > details > summary[data-variant=outline] { @apply bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; } - .sidebar nav li > :is(a, button, details > summary):not([data-size]), - .sidebar nav li > :is(a, button, details > summary)[data-size=default] { + .sidebar nav li > :is(a, button):not([data-size]), + .sidebar nav li > details > summary:not([data-size]), + .sidebar nav li > :is(a, button)[data-size=default], + .sidebar nav li > details > summary[data-size=default] { @apply h-9 text-sm; } - .sidebar nav li > :is(a, button, details > summary)[data-size=sm] { + .sidebar nav li > :is(a, button)[data-size=sm], + .sidebar nav li > details > summary[data-size=sm] { @apply h-8 text-xs; } - .sidebar nav li > :is(a, button, details > summary)[data-size=lg] { + .sidebar nav li > :is(a, button)[data-size=lg], + .sidebar nav li > details > summary[data-size=lg] { @apply h-14 px-3 text-sm; } @@ -1194,10 +1203,11 @@ } .sidebar nav ul ul { - @apply border-sidebar-border mx-3.5 translate-x-px gap-1 border-s px-2.5 py-0.5 rtl:-translate-x-px; + @apply border-sidebar-border translate-x-px gap-1 border-s px-2.5 py-0.5 rtl:-translate-x-px; } - .sidebar nav ul ul > li > :is(a, button, details > summary) { + .sidebar nav ul ul > li > :is(a, button), + .sidebar nav ul ul > li > details > summary { @apply text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground h-7 gap-2 rounded-xl px-3 text-sm focus-visible:ring-2 [&>svg]:size-4; } diff --git a/src/css/styles/lyra.css b/src/css/styles/lyra.css index b0a8d23..8919ccd 100644 --- a/src/css/styles/lyra.css +++ b/src/css/styles/lyra.css @@ -1136,33 +1136,42 @@ @apply text-xs; } - .sidebar nav li > :is(a, button, details > summary) { + .sidebar nav li > :is(a, button), + .sidebar nav li > details > summary { @apply ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground gap-2 rounded-none p-2 text-xs focus-visible:ring-2 text-left focus-visible:outline-hidden data-[open]:hover:bg-sidebar-accent data-[open]:hover:text-sidebar-accent-foreground [&>svg]:shrink-0 [&>svg]:size-4; } - .sidebar nav li > :is(a, button, details > summary):is([aria-current=page], [data-active=true]) { + .sidebar nav li > :is(a, button):is([aria-current=page], [data-active=true]), + .sidebar nav li > details > summary:is([aria-current=page], [data-active=true]) { @apply bg-sidebar-accent text-sidebar-accent-foreground font-medium; } - .sidebar nav li > :is(a, button, details > summary):not([data-variant]), - .sidebar nav li > :is(a, button, details > summary)[data-variant=default] { + .sidebar nav li > :is(a, button):not([data-variant]), + .sidebar nav li > details > summary:not([data-variant]), + .sidebar nav li > :is(a, button)[data-variant=default], + .sidebar nav li > details > summary[data-variant=default] { @apply hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; } - .sidebar nav li > :is(a, button, details > summary)[data-variant=outline] { + .sidebar nav li > :is(a, button)[data-variant=outline], + .sidebar nav li > details > summary[data-variant=outline] { @apply bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; } - .sidebar nav li > :is(a, button, details > summary):not([data-size]), - .sidebar nav li > :is(a, button, details > summary)[data-size=default] { + .sidebar nav li > :is(a, button):not([data-size]), + .sidebar nav li > details > summary:not([data-size]), + .sidebar nav li > :is(a, button)[data-size=default], + .sidebar nav li > details > summary[data-size=default] { @apply h-8 text-xs; } - .sidebar nav li > :is(a, button, details > summary)[data-size=sm] { + .sidebar nav li > :is(a, button)[data-size=sm], + .sidebar nav li > details > summary[data-size=sm] { @apply h-7 text-xs; } - .sidebar nav li > :is(a, button, details > summary)[data-size=lg] { + .sidebar nav li > :is(a, button)[data-size=lg], + .sidebar nav li > details > summary[data-size=lg] { @apply h-12 text-xs; } @@ -1175,10 +1184,11 @@ } .sidebar nav ul ul { - @apply border-sidebar-border mx-3.5 translate-x-px gap-1 border-s px-2.5 py-0.5 rtl:-translate-x-px; + @apply border-sidebar-border translate-x-px gap-1 border-s px-2.5 py-0.5 rtl:-translate-x-px; } - .sidebar nav ul ul > li > :is(a, button, details > summary) { + .sidebar nav ul ul > li > :is(a, button), + .sidebar nav ul ul > li > details > summary { @apply text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground h-7 gap-2 rounded-none px-2 text-xs focus-visible:ring-2 [&>svg]:size-4; } diff --git a/src/css/styles/maia.css b/src/css/styles/maia.css index c3a9e45..062e9b7 100644 --- a/src/css/styles/maia.css +++ b/src/css/styles/maia.css @@ -1143,33 +1143,42 @@ @apply text-sm; } - .sidebar nav li > :is(a, button, details > summary) { + .sidebar nav li > :is(a, button), + .sidebar nav li > details > summary { @apply ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground gap-2 rounded-lg px-3 py-2 text-sm focus-visible:ring-2 text-left focus-visible:outline-hidden data-[open]:hover:bg-sidebar-accent data-[open]:hover:text-sidebar-accent-foreground [&>svg]:shrink-0 [&>svg]:size-4; } - .sidebar nav li > :is(a, button, details > summary):is([aria-current=page], [data-active=true]) { + .sidebar nav li > :is(a, button):is([aria-current=page], [data-active=true]), + .sidebar nav li > details > summary:is([aria-current=page], [data-active=true]) { @apply bg-sidebar-accent text-sidebar-accent-foreground font-medium; } - .sidebar nav li > :is(a, button, details > summary):not([data-variant]), - .sidebar nav li > :is(a, button, details > summary)[data-variant=default] { + .sidebar nav li > :is(a, button):not([data-variant]), + .sidebar nav li > details > summary:not([data-variant]), + .sidebar nav li > :is(a, button)[data-variant=default], + .sidebar nav li > details > summary[data-variant=default] { @apply hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; } - .sidebar nav li > :is(a, button, details > summary)[data-variant=outline] { + .sidebar nav li > :is(a, button)[data-variant=outline], + .sidebar nav li > details > summary[data-variant=outline] { @apply bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; } - .sidebar nav li > :is(a, button, details > summary):not([data-size]), - .sidebar nav li > :is(a, button, details > summary)[data-size=default] { + .sidebar nav li > :is(a, button):not([data-size]), + .sidebar nav li > details > summary:not([data-size]), + .sidebar nav li > :is(a, button)[data-size=default], + .sidebar nav li > details > summary[data-size=default] { @apply h-9 text-sm; } - .sidebar nav li > :is(a, button, details > summary)[data-size=sm] { + .sidebar nav li > :is(a, button)[data-size=sm], + .sidebar nav li > details > summary[data-size=sm] { @apply h-8 text-xs; } - .sidebar nav li > :is(a, button, details > summary)[data-size=lg] { + .sidebar nav li > :is(a, button)[data-size=lg], + .sidebar nav li > details > summary[data-size=lg] { @apply h-14 px-3 text-sm; } @@ -1182,10 +1191,11 @@ } .sidebar nav ul ul { - @apply border-sidebar-border mx-3.5 translate-x-px gap-1 border-s px-2.5 py-0.5 rtl:-translate-x-px; + @apply border-sidebar-border translate-x-px gap-1 border-s px-2.5 py-0.5 rtl:-translate-x-px; } - .sidebar nav ul ul > li > :is(a, button, details > summary) { + .sidebar nav ul ul > li > :is(a, button), + .sidebar nav ul ul > li > details > summary { @apply text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 text-sm focus-visible:ring-2 [&>svg]:size-4; } diff --git a/src/css/styles/mira.css b/src/css/styles/mira.css index 50adc31..f46c1b3 100644 --- a/src/css/styles/mira.css +++ b/src/css/styles/mira.css @@ -1144,33 +1144,42 @@ @apply text-xs; } - .sidebar nav li > :is(a, button, details > summary) { + .sidebar nav li > :is(a, button), + .sidebar nav li > details > summary { @apply ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground gap-2 rounded-[calc(var(--radius-sm)+2px)] p-2 text-xs focus-visible:ring-2 text-left focus-visible:outline-hidden data-[open]:hover:bg-sidebar-accent data-[open]:hover:text-sidebar-accent-foreground [&>svg]:shrink-0 [&>svg]:size-4; } - .sidebar nav li > :is(a, button, details > summary):is([aria-current=page], [data-active=true]) { + .sidebar nav li > :is(a, button):is([aria-current=page], [data-active=true]), + .sidebar nav li > details > summary:is([aria-current=page], [data-active=true]) { @apply bg-sidebar-accent text-sidebar-accent-foreground font-medium; } - .sidebar nav li > :is(a, button, details > summary):not([data-variant]), - .sidebar nav li > :is(a, button, details > summary)[data-variant=default] { + .sidebar nav li > :is(a, button):not([data-variant]), + .sidebar nav li > details > summary:not([data-variant]), + .sidebar nav li > :is(a, button)[data-variant=default], + .sidebar nav li > details > summary[data-variant=default] { @apply hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; } - .sidebar nav li > :is(a, button, details > summary)[data-variant=outline] { + .sidebar nav li > :is(a, button)[data-variant=outline], + .sidebar nav li > details > summary[data-variant=outline] { @apply bg-muted/20 dark:bg-muted/30 border-input shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; } - .sidebar nav li > :is(a, button, details > summary):not([data-size]), - .sidebar nav li > :is(a, button, details > summary)[data-size=default] { + .sidebar nav li > :is(a, button):not([data-size]), + .sidebar nav li > details > summary:not([data-size]), + .sidebar nav li > :is(a, button)[data-size=default], + .sidebar nav li > details > summary[data-size=default] { @apply h-8 text-xs; } - .sidebar nav li > :is(a, button, details > summary)[data-size=sm] { + .sidebar nav li > :is(a, button)[data-size=sm], + .sidebar nav li > details > summary[data-size=sm] { @apply h-7 text-xs; } - .sidebar nav li > :is(a, button, details > summary)[data-size=lg] { + .sidebar nav li > :is(a, button)[data-size=lg], + .sidebar nav li > details > summary[data-size=lg] { @apply h-12 text-xs; } @@ -1183,10 +1192,11 @@ } .sidebar nav ul ul { - @apply border-sidebar-border mx-3.5 translate-x-px gap-1 border-s px-2.5 py-0.5 rtl:-translate-x-px; + @apply border-sidebar-border translate-x-px gap-1 border-s px-2.5 py-0.5 rtl:-translate-x-px; } - .sidebar nav ul ul > li > :is(a, button, details > summary) { + .sidebar nav ul ul > li > :is(a, button), + .sidebar nav ul ul > li > details > summary { @apply text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 text-xs focus-visible:ring-2 [&>svg]:size-4; } diff --git a/src/css/styles/nova.css b/src/css/styles/nova.css index 77964d8..96c90fc 100644 --- a/src/css/styles/nova.css +++ b/src/css/styles/nova.css @@ -1140,33 +1140,42 @@ @apply text-sm; } - .sidebar nav li > :is(a, button, details > summary) { + .sidebar nav li > :is(a, button), + .sidebar nav li > details > summary { @apply ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-sm focus-visible:ring-2 text-left focus-visible:outline-hidden data-[open]:hover:bg-sidebar-accent data-[open]:hover:text-sidebar-accent-foreground [&>svg]:shrink-0 [&>svg]:size-4; } - .sidebar nav li > :is(a, button, details > summary):is([aria-current=page], [data-active=true]) { + .sidebar nav li > :is(a, button):is([aria-current=page], [data-active=true]), + .sidebar nav li > details > summary:is([aria-current=page], [data-active=true]) { @apply bg-sidebar-accent text-sidebar-accent-foreground font-medium; } - .sidebar nav li > :is(a, button, details > summary):not([data-variant]), - .sidebar nav li > :is(a, button, details > summary)[data-variant=default] { + .sidebar nav li > :is(a, button):not([data-variant]), + .sidebar nav li > details > summary:not([data-variant]), + .sidebar nav li > :is(a, button)[data-variant=default], + .sidebar nav li > details > summary[data-variant=default] { @apply hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; } - .sidebar nav li > :is(a, button, details > summary)[data-variant=outline] { + .sidebar nav li > :is(a, button)[data-variant=outline], + .sidebar nav li > details > summary[data-variant=outline] { @apply bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; } - .sidebar nav li > :is(a, button, details > summary):not([data-size]), - .sidebar nav li > :is(a, button, details > summary)[data-size=default] { + .sidebar nav li > :is(a, button):not([data-size]), + .sidebar nav li > details > summary:not([data-size]), + .sidebar nav li > :is(a, button)[data-size=default], + .sidebar nav li > details > summary[data-size=default] { @apply h-8 text-sm; } - .sidebar nav li > :is(a, button, details > summary)[data-size=sm] { + .sidebar nav li > :is(a, button)[data-size=sm], + .sidebar nav li > details > summary[data-size=sm] { @apply h-7 text-xs; } - .sidebar nav li > :is(a, button, details > summary)[data-size=lg] { + .sidebar nav li > :is(a, button)[data-size=lg], + .sidebar nav li > details > summary[data-size=lg] { @apply h-12 text-sm; } @@ -1179,10 +1188,11 @@ } .sidebar nav ul ul { - @apply border-sidebar-border mx-3.5 translate-x-px gap-1 border-s px-2.5 py-0.5 rtl:-translate-x-px; + @apply border-sidebar-border translate-x-px gap-1 border-s px-2.5 py-0.5 rtl:-translate-x-px; } - .sidebar nav ul ul > li > :is(a, button, details > summary) { + .sidebar nav ul ul > li > :is(a, button), + .sidebar nav ul ul > li > details > summary { @apply text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 text-sm focus-visible:ring-2 [&>svg]:size-4; } diff --git a/src/css/styles/rhea.css b/src/css/styles/rhea.css index 7180087..2f816db 100644 --- a/src/css/styles/rhea.css +++ b/src/css/styles/rhea.css @@ -1152,33 +1152,42 @@ @apply text-sm; } - .sidebar nav li > :is(a, button, details > summary) { + .sidebar nav li > :is(a, button), + .sidebar nav li > details > summary { @apply ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground gap-2 rounded-xl px-3 py-2 text-sm duration-200 focus-visible:ring-3 whitespace-nowrap has-[>svg:first-child]:ps-2.5 has-[>svg:last-child]:pe-2.5 text-left focus-visible:outline-hidden data-[open]:hover:bg-sidebar-accent data-[open]:hover:text-sidebar-accent-foreground [&>svg]:shrink-0 [&>svg]:size-4; } - .sidebar nav li > :is(a, button, details > summary):is([aria-current=page], [data-active=true]) { + .sidebar nav li > :is(a, button):is([aria-current=page], [data-active=true]), + .sidebar nav li > details > summary:is([aria-current=page], [data-active=true]) { @apply bg-sidebar-accent text-sidebar-accent-foreground font-medium; } - .sidebar nav li > :is(a, button, details > summary):not([data-variant]), - .sidebar nav li > :is(a, button, details > summary)[data-variant=default] { + .sidebar nav li > :is(a, button):not([data-variant]), + .sidebar nav li > details > summary:not([data-variant]), + .sidebar nav li > :is(a, button)[data-variant=default], + .sidebar nav li > details > summary[data-variant=default] { @apply hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; } - .sidebar nav li > :is(a, button, details > summary)[data-variant=outline] { + .sidebar nav li > :is(a, button)[data-variant=outline], + .sidebar nav li > details > summary[data-variant=outline] { @apply bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; } - .sidebar nav li > :is(a, button, details > summary):not([data-size]), - .sidebar nav li > :is(a, button, details > summary)[data-size=default] { + .sidebar nav li > :is(a, button):not([data-size]), + .sidebar nav li > details > summary:not([data-size]), + .sidebar nav li > :is(a, button)[data-size=default], + .sidebar nav li > details > summary[data-size=default] { @apply h-8 text-sm; } - .sidebar nav li > :is(a, button, details > summary)[data-size=sm] { + .sidebar nav li > :is(a, button)[data-size=sm], + .sidebar nav li > details > summary[data-size=sm] { @apply h-7 text-xs; } - .sidebar nav li > :is(a, button, details > summary)[data-size=lg] { + .sidebar nav li > :is(a, button)[data-size=lg], + .sidebar nav li > details > summary[data-size=lg] { @apply h-12 px-3 text-sm; } @@ -1191,10 +1200,11 @@ } .sidebar nav ul ul { - @apply border-sidebar-border mx-3.5 translate-x-px gap-1 border-s px-2.5 py-0.5 rtl:-translate-x-px; + @apply border-sidebar-border translate-x-px gap-1 border-s px-2.5 py-0.5 rtl:-translate-x-px; } - .sidebar nav ul ul > li > :is(a, button, details > summary) { + .sidebar nav ul ul > li > :is(a, button), + .sidebar nav ul ul > li > details > summary { @apply text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground h-7 gap-2 rounded-xl px-3 text-sm focus-visible:ring-3 [&>svg]:size-4; } diff --git a/src/css/styles/sera.css b/src/css/styles/sera.css index 881bc45..da7515f 100644 --- a/src/css/styles/sera.css +++ b/src/css/styles/sera.css @@ -1163,33 +1163,42 @@ @apply text-sm; } - .sidebar nav li > :is(a, button, details > summary) { + .sidebar nav li > :is(a, button), + .sidebar nav li > details > summary { @apply ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground gap-2 rounded-none px-3 py-2 text-sm focus-visible:ring-2 text-left focus-visible:outline-hidden data-[open]:hover:bg-sidebar-accent data-[open]:hover:text-sidebar-accent-foreground [&>svg]:shrink-0 [&>svg]:size-3.5; } - .sidebar nav li > :is(a, button, details > summary):is([aria-current=page], [data-active=true]) { + .sidebar nav li > :is(a, button):is([aria-current=page], [data-active=true]), + .sidebar nav li > details > summary:is([aria-current=page], [data-active=true]) { @apply bg-sidebar-accent text-sidebar-accent-foreground font-medium; } - .sidebar nav li > :is(a, button, details > summary):not([data-variant]), - .sidebar nav li > :is(a, button, details > summary)[data-variant=default] { + .sidebar nav li > :is(a, button):not([data-variant]), + .sidebar nav li > details > summary:not([data-variant]), + .sidebar nav li > :is(a, button)[data-variant=default], + .sidebar nav li > details > summary[data-variant=default] { @apply hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; } - .sidebar nav li > :is(a, button, details > summary)[data-variant=outline] { + .sidebar nav li > :is(a, button)[data-variant=outline], + .sidebar nav li > details > summary[data-variant=outline] { @apply bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; } - .sidebar nav li > :is(a, button, details > summary):not([data-size]), - .sidebar nav li > :is(a, button, details > summary)[data-size=default] { + .sidebar nav li > :is(a, button):not([data-size]), + .sidebar nav li > details > summary:not([data-size]), + .sidebar nav li > :is(a, button)[data-size=default], + .sidebar nav li > details > summary[data-size=default] { @apply h-9 text-sm; } - .sidebar nav li > :is(a, button, details > summary)[data-size=sm] { + .sidebar nav li > :is(a, button)[data-size=sm], + .sidebar nav li > details > summary[data-size=sm] { @apply h-8 text-xs; } - .sidebar nav li > :is(a, button, details > summary)[data-size=lg] { + .sidebar nav li > :is(a, button)[data-size=lg], + .sidebar nav li > details > summary[data-size=lg] { @apply h-14 px-3 text-sm; } @@ -1202,10 +1211,11 @@ } .sidebar nav ul ul { - @apply border-sidebar-border mx-3.5 translate-x-px gap-1 border-s px-2.5 py-0.5 rtl:-translate-x-px; + @apply border-sidebar-border translate-x-px gap-1 border-s px-2.5 py-0.5 rtl:-translate-x-px; } - .sidebar nav ul ul > li > :is(a, button, details > summary) { + .sidebar nav ul ul > li > :is(a, button), + .sidebar nav ul ul > li > details > summary { @apply text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground h-7 gap-2 rounded-none px-3 text-sm focus-visible:ring-2 [&>svg]:size-3.5; } diff --git a/src/css/styles/vega.css b/src/css/styles/vega.css index d367ebf..d4e18f4 100644 --- a/src/css/styles/vega.css +++ b/src/css/styles/vega.css @@ -1140,33 +1140,42 @@ @apply text-sm; } - .sidebar nav li > :is(a, button, details > summary) { + .sidebar nav li > :is(a, button), + .sidebar nav li > details > summary { @apply ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-sm focus-visible:ring-2 text-left focus-visible:outline-hidden data-[open]:hover:bg-sidebar-accent data-[open]:hover:text-sidebar-accent-foreground [&>svg]:shrink-0 [&>svg]:size-4; } - .sidebar nav li > :is(a, button, details > summary):is([aria-current=page], [data-active=true]) { + .sidebar nav li > :is(a, button):is([aria-current=page], [data-active=true]), + .sidebar nav li > details > summary:is([aria-current=page], [data-active=true]) { @apply bg-sidebar-accent text-sidebar-accent-foreground font-medium; } - .sidebar nav li > :is(a, button, details > summary):not([data-variant]), - .sidebar nav li > :is(a, button, details > summary)[data-variant=default] { + .sidebar nav li > :is(a, button):not([data-variant]), + .sidebar nav li > details > summary:not([data-variant]), + .sidebar nav li > :is(a, button)[data-variant=default], + .sidebar nav li > details > summary[data-variant=default] { @apply hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; } - .sidebar nav li > :is(a, button, details > summary)[data-variant=outline] { + .sidebar nav li > :is(a, button)[data-variant=outline], + .sidebar nav li > details > summary[data-variant=outline] { @apply bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground; } - .sidebar nav li > :is(a, button, details > summary):not([data-size]), - .sidebar nav li > :is(a, button, details > summary)[data-size=default] { + .sidebar nav li > :is(a, button):not([data-size]), + .sidebar nav li > details > summary:not([data-size]), + .sidebar nav li > :is(a, button)[data-size=default], + .sidebar nav li > details > summary[data-size=default] { @apply h-8 text-sm; } - .sidebar nav li > :is(a, button, details > summary)[data-size=sm] { + .sidebar nav li > :is(a, button)[data-size=sm], + .sidebar nav li > details > summary[data-size=sm] { @apply h-7 text-xs; } - .sidebar nav li > :is(a, button, details > summary)[data-size=lg] { + .sidebar nav li > :is(a, button)[data-size=lg], + .sidebar nav li > details > summary[data-size=lg] { @apply h-12 text-sm; } @@ -1179,10 +1188,11 @@ } .sidebar nav ul ul { - @apply border-sidebar-border mx-3.5 translate-x-px gap-1 border-s px-2.5 py-0.5 rtl:-translate-x-px; + @apply border-sidebar-border translate-x-px gap-1 border-s px-2.5 py-0.5 rtl:-translate-x-px; } - .sidebar nav ul ul > li > :is(a, button, details > summary) { + .sidebar nav ul ul > li > :is(a, button), + .sidebar nav ul ul > li > details > summary { @apply text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 text-sm focus-visible:ring-2 [&>svg]:size-4; } diff --git a/src/eleventy/navigation.js b/src/eleventy/navigation.js index aedd841..8eb11b1 100644 --- a/src/eleventy/navigation.js +++ b/src/eleventy/navigation.js @@ -70,6 +70,7 @@ export const registerNavigationFilters = (eleventyConfig, lucideIcons) => { if (item?.type === 'submenu') { const submenuIcon = resolveIcon(item.icon) || null; const processed = (item.items || []).map((sub) => processItem(sub)); + const commandItems = processed.map((entry) => entry.command).filter(Boolean); return { sidebar: { ...item, @@ -77,11 +78,11 @@ export const registerNavigationFilters = (eleventyConfig, lucideIcons) => { open: item.open ?? processed.some((entry) => entry.sidebar?.current), items: processed.map((entry) => entry.sidebar).filter(Boolean), }, - command: { + command: commandItems.length ? { type: 'group', label: item.label, - items: processed.map((entry) => entry.command).filter(Boolean), - }, + items: commandItems, + } : null, }; } diff --git a/src/js/basecoat.js b/src/js/basecoat.js index 1855b4b..02939a9 100644 --- a/src/js/basecoat.js +++ b/src/js/basecoat.js @@ -2,58 +2,77 @@ const componentRegistry = {}; let observer = null; - const registerComponent = (name, selector, initFunction) => { + const getInitFlag = (name) => `data-${name}-initialized`; + + const registerComponent = (name, selectorOrOptions, initFunction) => { + const options = typeof selectorOrOptions === 'object' + ? selectorOrOptions + : { selector: selectorOrOptions, init: initFunction }; + componentRegistry[name] = { - selector, - init: initFunction + selector: options.selector, + init: options.init, + refresh: options.refresh, }; }; const initComponent = (element, componentName) => { const component = componentRegistry[componentName]; if (!component) return; - + try { component.init(element); + element.dataset.basecoatComponent = componentName; } catch (error) { console.error(`Failed to initialize ${componentName}:`, error); } }; const initAllComponents = () => { - Object.entries(componentRegistry).forEach(([name, { selector, init }]) => { - document.querySelectorAll(selector).forEach(init); + Object.entries(componentRegistry).forEach(([name, { selector }]) => { + document.querySelectorAll(selector).forEach(element => initComponent(element, name)); }); }; const initNewComponents = (node) => { if (node.nodeType !== Node.ELEMENT_NODE) return; - - Object.entries(componentRegistry).forEach(([name, { selector, init }]) => { - if (node.matches(selector)) { - init(node); - } - node.querySelectorAll(selector).forEach(init); + + Object.entries(componentRegistry).forEach(([name, { selector }]) => { + if (node.matches(selector)) initComponent(node, name); + node.querySelectorAll(selector).forEach(element => initComponent(element, name)); }); }; + const refreshComponent = (element) => { + if (!element) return; + if (typeof element.refresh === 'function') { + element.refresh(); + return; + } + + const componentName = element.dataset?.basecoatComponent; + const component = componentName ? componentRegistry[componentName] : null; + if (component?.refresh) { + component.refresh(element); + } + }; + const startObserver = () => { if (observer) return; - + observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach(initNewComponents); }); }); - + observer.observe(document.body, { childList: true, subtree: true }); }; const stopObserver = () => { - if (observer) { - observer.disconnect(); - observer = null; - } + if (!observer) return; + observer.disconnect(); + observer = null; }; const reinitComponent = (componentName) => { @@ -62,38 +81,52 @@ console.warn(`Component '${componentName}' not found in registry`); return; } - - // Clear initialization flag for this component - const flag = `data-${componentName}-initialized`; - document.querySelectorAll(`[${flag}]`).forEach(el => { - el.removeAttribute(flag); + + const flag = getInitFlag(componentName); + document.querySelectorAll(`[${flag}]`).forEach(element => { + element.removeAttribute(flag); + delete element.dataset.basecoatComponent; }); - - document.querySelectorAll(component.selector).forEach(component.init); + + document.querySelectorAll(component.selector).forEach(element => initComponent(element, componentName)); }; const reinitAll = () => { - // Clear all initialization flags using the registry - Object.entries(componentRegistry).forEach(([name, { selector }]) => { - const flag = `data-${name}-initialized`; - document.querySelectorAll(`[${flag}]`).forEach(el => { - el.removeAttribute(flag); + Object.entries(componentRegistry).forEach(([name]) => { + const flag = getInitFlag(name); + document.querySelectorAll(`[${flag}]`).forEach(element => { + element.removeAttribute(flag); + delete element.dataset.basecoatComponent; }); }); - + initAllComponents(); }; + const setTheme = (mode) => { + const dark = mode === 'dark'; + document.documentElement.classList.toggle('dark', dark); + try { localStorage.setItem('themeMode', dark ? 'dark' : 'light'); } catch (_) {} + }; + + const getTheme = () => document.documentElement.classList.contains('dark') ? 'dark' : 'light'; + window.basecoat = { register: registerComponent, init: reinitComponent, initAll: reinitAll, + refresh: refreshComponent, start: startObserver, - stop: stopObserver + stop: stopObserver, + theme: { + get: getTheme, + set: setTheme, + toggle: () => setTheme(getTheme() === 'dark' ? 'light' : 'dark'), + }, }; document.addEventListener('DOMContentLoaded', () => { initAllComponents(); startObserver(); }); -})(); \ No newline at end of file +})(); diff --git a/src/js/combobox.js b/src/js/combobox.js index dbdd8e5..40942d3 100644 --- a/src/js/combobox.js +++ b/src/js/combobox.js @@ -1,334 +1,378 @@ (() => { - const initCombobox = (combobox) => { - const input = combobox.querySelector(':scope > input[role="combobox"], :scope > .combobox-chips input[role="combobox"]'); - const chips = combobox.querySelector(':scope > .combobox-chips'); - const popover = combobox.querySelector(':scope > [data-popover]'); - const listbox = popover ? popover.querySelector('[role="listbox"]') : null; - const hiddenInput = combobox.querySelector(':scope > input[type="hidden"]'); + const states = new WeakMap(); - if (!input || !popover || !listbox || !hiddenInput) { + const getElements = (root) => { + const input = root.querySelector(':scope > input[role="combobox"], :scope > .combobox-chips input[role="combobox"]'); + const chips = root.querySelector(':scope > .combobox-chips'); + const popover = root.querySelector(':scope > [data-popover]'); + const listbox = popover ? popover.querySelector('[role="listbox"]') : null; + const hiddenInput = root.querySelector(':scope > input[type="hidden"]'); + return { input, chips, popover, listbox, hiddenInput }; + }; + + const getValue = option => option.dataset.value ?? option.textContent.trim(); + const getLabel = option => option.dataset.label || option.textContent.trim(); + const isDisabled = option => option.getAttribute('aria-disabled') === 'true'; + + const getOptions = (listbox) => { + const allOptions = Array.from(listbox.querySelectorAll('[role="option"]')); + return { + allOptions, + options: allOptions.filter(option => !isDisabled(option)), + }; + }; + + const scrollOptionIntoListbox = (state, option) => { + const optionRect = option.getBoundingClientRect(); + const listboxRect = state.listbox.getBoundingClientRect(); + + if (optionRect.top < listboxRect.top) { + state.listbox.scrollTop -= listboxRect.top - optionRect.top; + } else if (optionRect.bottom > listboxRect.bottom) { + state.listbox.scrollTop += optionRect.bottom - listboxRect.bottom; + } + }; + + const setActiveOption = (state, index) => { + if (state.activeIndex > -1 && state.options[state.activeIndex]) { + state.options[state.activeIndex].classList.remove('active'); + } + + state.activeIndex = index; + + if (state.activeIndex > -1) { + const activeOption = state.options[state.activeIndex]; + activeOption.classList.add('active'); + if (!activeOption.id) activeOption.id = `${state.listbox.id || state.root.id || 'combobox'}-option-${state.activeIndex}`; + state.input.setAttribute('aria-activedescendant', activeOption.id); + } else { + state.input.removeAttribute('aria-activedescendant'); + } + }; + + const syncEmptyState = (state) => { + state.popover.dataset.empty = String(state.visibleOptions.length === 0); + }; + + const filterOptions = (state, { preserveActive = false } = {}) => { + const previousActive = state.activeIndex > -1 ? state.options[state.activeIndex] : null; + const search = state.input.value.trim().toLowerCase(); + state.visibleOptions = []; + + state.allOptions.forEach(option => { + if (option.hasAttribute('data-force')) { + option.setAttribute('aria-hidden', 'false'); + if (state.options.includes(option)) state.visibleOptions.push(option); + return; + } + + const optionText = (option.dataset.filter || option.dataset.label || option.textContent).trim().toLowerCase(); + const keywords = (option.dataset.keywords || '').toLowerCase().split(/[\s,]+/).filter(Boolean); + const matches = !search || optionText.includes(search) || keywords.some(keyword => keyword.includes(search)); + option.setAttribute('aria-hidden', String(!matches)); + if (matches && state.options.includes(option)) state.visibleOptions.push(option); + }); + + if (preserveActive && previousActive && state.visibleOptions.includes(previousActive)) { + setActiveOption(state, state.options.indexOf(previousActive)); + } else { + setActiveOption(state, state.autoHighlight && state.visibleOptions.length > 0 ? state.options.indexOf(state.visibleOptions[0]) : -1); + } + syncEmptyState(state); + }; + + const renderChips = (root) => { + const state = states.get(root); + if (!state.chips) return; + + state.chips.querySelectorAll('.combobox-chip').forEach(chip => chip.remove()); + const selected = state.options.filter(option => state.selectedOptions.has(option)); + + selected.forEach(option => { + const chip = document.createElement('span'); + chip.className = 'combobox-chip'; + chip.dataset.value = getValue(option); + chip.textContent = getLabel(option); + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'combobox-chip-remove'; + remove.setAttribute('aria-label', `Remove ${getLabel(option)}`); + remove.innerHTML = ''; + remove.addEventListener('click', () => root.deselect(getValue(option))); + + chip.appendChild(remove); + state.chips.insertBefore(chip, state.input); + }); + }; + + const updateValue = (root, optionOrOptions, triggerEvent = true) => { + const state = states.get(root); + let value; + + if (state.isMultiple) { + const selected = Array.isArray(optionOrOptions) ? optionOrOptions : []; + state.selectedOptions.clear(); + selected.forEach(option => state.selectedOptions.add(option)); + state.options.forEach(option => { + if (state.selectedOptions.has(option)) { + option.setAttribute('aria-selected', 'true'); + } else { + option.removeAttribute('aria-selected'); + } + }); + value = state.options.filter(option => state.selectedOptions.has(option)).map(getValue); + state.hiddenInput.value = JSON.stringify(value); + state.input.value = ''; + renderChips(root); + filterOptions(state, { preserveActive: true }); + } else { + const option = optionOrOptions; + if (!option) return; + state.options.forEach(opt => { + if (opt === option) { + opt.setAttribute('aria-selected', 'true'); + } else { + opt.removeAttribute('aria-selected'); + } + }); + value = getValue(option); + state.hiddenInput.value = value; + state.input.value = getLabel(option); + } + + if (triggerEvent) { + root.dispatchEvent(new CustomEvent('change', { + detail: { value }, + bubbles: true, + })); + } + }; + + const closePopover = (state, focusInput = false) => { + if (state.popover.getAttribute('aria-hidden') === 'true') return; + state.popover.setAttribute('aria-hidden', 'true'); + state.input.setAttribute('aria-expanded', 'false'); + setActiveOption(state, -1); + if (focusInput) state.input.focus(); + }; + + const refreshCombobox = (root) => { + const state = states.get(root); + if (!state) return; + + const elements = getElements(root); + if (!elements.input || !elements.popover || !elements.listbox || !elements.hiddenInput) { const missing = []; - if (!input) missing.push('input'); - if (!popover) missing.push('popover'); - if (!listbox) missing.push('listbox'); - if (!hiddenInput) missing.push('hidden input'); - console.error(`Combobox initialisation failed. Missing element(s): ${missing.join(', ')}`, combobox); + if (!elements.input) missing.push('input'); + if (!elements.popover) missing.push('popover'); + if (!elements.listbox) missing.push('listbox'); + if (!elements.hiddenInput) missing.push('hidden input'); + console.error(`Combobox refresh failed. Missing element(s): ${missing.join(', ')}`, root); return; } - const allOptions = Array.from(listbox.querySelectorAll('[role="option"]')); - const options = allOptions.filter(option => option.getAttribute('aria-disabled') !== 'true'); - const isMultiple = listbox.getAttribute('aria-multiselectable') === 'true' || combobox.dataset.multiple === 'true'; - const closeOnSelect = combobox.dataset.closeOnSelect === 'true'; - const autoHighlight = combobox.dataset.autoHighlight === 'true'; - const selectedOptions = isMultiple ? new Set() : null; - let visibleOptions = [...options]; - let activeIndex = -1; + const previousValue = elements.hiddenInput.value; + const previousInputValue = elements.input.value; + Object.assign(state, elements, getOptions(elements.listbox)); + state.isMultiple = state.listbox.getAttribute('aria-multiselectable') === 'true' || root.dataset.multiple === 'true'; + state.closeOnSelect = root.dataset.closeOnSelect === 'true'; + state.autoHighlight = root.dataset.autoHighlight === 'true'; + if (state.isMultiple && !state.selectedOptions) state.selectedOptions = new Set(); - const getValue = option => option.dataset.value ?? option.textContent.trim(); - const getLabel = option => option.dataset.label || option.textContent.trim(); - - const scrollOptionIntoListbox = (option) => { - const optionRect = option.getBoundingClientRect(); - const listboxRect = listbox.getBoundingClientRect(); - - if (optionRect.top < listboxRect.top) { - listbox.scrollTop -= listboxRect.top - optionRect.top; - } else if (optionRect.bottom > listboxRect.bottom) { - listbox.scrollTop += optionRect.bottom - listboxRect.bottom; + if (state.isMultiple) { + let values = []; + try { + const parsed = JSON.parse(previousValue || '[]'); + values = Array.isArray(parsed) ? parsed : []; + } catch (_) { + values = []; } - }; - - const setActiveOption = (index) => { - if (activeIndex > -1 && options[activeIndex]) { - options[activeIndex].classList.remove('active'); - } - - activeIndex = index; - - if (activeIndex > -1) { - const activeOption = options[activeIndex]; - activeOption.classList.add('active'); - if (!activeOption.id) activeOption.id = `${listbox.id || combobox.id || 'combobox'}-option-${activeIndex}`; - input.setAttribute('aria-activedescendant', activeOption.id); + const ariaSelected = state.options.filter(option => option.getAttribute('aria-selected') === 'true').map(getValue); + const selectedValues = values.length ? values : ariaSelected; + updateValue(root, state.options.filter(option => selectedValues.includes(getValue(option))), false); + } else { + const selected = state.options.find(option => getValue(option) === previousValue) + || state.options.find(option => option.getAttribute('aria-selected') === 'true'); + if (selected) { + updateValue(root, selected, false); } else { - input.removeAttribute('aria-activedescendant'); + state.input.value = previousInputValue; + state.options.forEach(option => option.removeAttribute('aria-selected')); + filterOptions(state, { preserveActive: true }); } - }; + } + }; - const syncEmptyState = () => { - popover.dataset.empty = String(visibleOptions.length === 0); - }; + const selectValue = (root, value) => { + const state = states.get(root); + const option = state.options.find(opt => getValue(opt) === value); + if (!option) return; - const filterOptions = ({ preserveActive = false } = {}) => { - const previousActive = activeIndex > -1 ? options[activeIndex] : null; - const search = input.value.trim().toLowerCase(); - visibleOptions = []; + if (state.isMultiple) { + const selected = new Set(state.selectedOptions); + selected.add(option); + updateValue(root, state.options.filter(opt => selected.has(opt))); + if (state.closeOnSelect) root.close(true); + } else { + updateValue(root, option); + root.close(true); + } + }; - allOptions.forEach(option => { - if (option.hasAttribute('data-force')) { - option.setAttribute('aria-hidden', 'false'); - if (options.includes(option)) visibleOptions.push(option); - return; - } + const deselectValue = (root, value) => { + const state = states.get(root); + if (!state.isMultiple) return; + const option = state.options.find(opt => getValue(opt) === value); + if (!option || !state.selectedOptions.has(option)) return; + const selected = new Set(state.selectedOptions); + selected.delete(option); + updateValue(root, state.options.filter(opt => selected.has(opt))); + }; - const optionText = (option.dataset.filter || option.dataset.label || option.textContent).trim().toLowerCase(); - const keywords = (option.dataset.keywords || '').toLowerCase().split(/[\s,]+/).filter(Boolean); - const matches = !search || optionText.includes(search) || keywords.some(keyword => keyword.includes(search)); - option.setAttribute('aria-hidden', String(!matches)); - if (matches && options.includes(option)) visibleOptions.push(option); - }); + const handleKeydown = (event, root) => { + const state = states.get(root); + if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End', 'Escape', 'Backspace'].includes(event.key)) return; - if (preserveActive && previousActive && visibleOptions.includes(previousActive)) { - setActiveOption(options.indexOf(previousActive)); - } else { - setActiveOption(autoHighlight && visibleOptions.length > 0 ? options.indexOf(visibleOptions[0]) : -1); - } - syncEmptyState(); - }; + const isOpen = state.popover.getAttribute('aria-hidden') === 'false'; - const openPopover = () => { - if (popover.getAttribute('aria-hidden') === 'false') return; + if (event.key === 'Backspace' && state.isMultiple && state.input.value === '') { + const selected = state.options.filter(option => state.selectedOptions.has(option)); + const last = selected[selected.length - 1]; + if (last) root.deselect(getValue(last)); + return; + } - document.dispatchEvent(new CustomEvent('basecoat:popover', { - detail: { source: combobox } - })); + if (event.key === 'Escape') { + root.close(true); + return; + } - filterOptions(); - popover.setAttribute('aria-hidden', 'false'); - input.setAttribute('aria-expanded', 'true'); - }; - - const closePopover = (focusInput = false) => { - if (popover.getAttribute('aria-hidden') === 'true') return; - popover.setAttribute('aria-hidden', 'true'); - input.setAttribute('aria-expanded', 'false'); - setActiveOption(-1); - if (focusInput) input.focus(); - }; - - const renderChips = () => { - if (!chips) return; - chips.querySelectorAll('.combobox-chip').forEach(chip => chip.remove()); - const selected = options.filter(option => selectedOptions.has(option)); - - selected.forEach(option => { - const chip = document.createElement('span'); - chip.className = 'combobox-chip'; - chip.dataset.value = getValue(option); - chip.textContent = getLabel(option); - - const remove = document.createElement('button'); - remove.type = 'button'; - remove.className = 'combobox-chip-remove'; - remove.setAttribute('aria-label', `Remove ${getLabel(option)}`); - remove.innerHTML = ''; - remove.addEventListener('click', () => deselect(getValue(option))); - - chip.appendChild(remove); - chips.insertBefore(chip, input); - }); - }; - - const updateValue = (optionOrOptions, triggerEvent = true) => { - let value; - - if (isMultiple) { - const selected = Array.isArray(optionOrOptions) ? optionOrOptions : []; - selectedOptions.clear(); - selected.forEach(option => selectedOptions.add(option)); - options.forEach(option => { - if (selectedOptions.has(option)) { - option.setAttribute('aria-selected', 'true'); - } else { - option.removeAttribute('aria-selected'); - } - }); - value = options.filter(option => selectedOptions.has(option)).map(getValue); - hiddenInput.value = JSON.stringify(value); - input.value = ''; - renderChips(); - filterOptions({ preserveActive: true }); - } else { - const option = optionOrOptions; - if (!option) return; - options.forEach(opt => { - if (opt === option) { - opt.setAttribute('aria-selected', 'true'); - } else { - opt.removeAttribute('aria-selected'); - } - }); - value = getValue(option); - hiddenInput.value = value; - input.value = getLabel(option); - } - - if (triggerEvent) { - combobox.dispatchEvent(new CustomEvent('change', { - detail: { value }, - bubbles: true - })); - } - }; - - const select = (value) => { - const option = options.find(opt => getValue(opt) === value); - if (!option) return; - - if (isMultiple) { - const selected = new Set(selectedOptions); - selected.add(option); - updateValue(options.filter(opt => selected.has(opt))); - if (closeOnSelect) closePopover(true); - } else { - updateValue(option); - closePopover(true); - } - }; - - const deselect = (value) => { - if (!isMultiple) return; - const option = options.find(opt => getValue(opt) === value); - if (!option || !selectedOptions.has(option)) return; - const selected = new Set(selectedOptions); - selected.delete(option); - updateValue(options.filter(opt => selected.has(opt))); - }; - - const toggle = (value) => { - if (!isMultiple) return; - const option = options.find(opt => getValue(opt) === value); - if (!option) return; - selectedOptions.has(option) ? deselect(value) : select(value); - }; - - const handleKeydown = (event) => { - if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End', 'Escape', 'Backspace'].includes(event.key)) return; - - const isOpen = popover.getAttribute('aria-hidden') === 'false'; - - if (event.key === 'Backspace' && isMultiple && input.value === '') { - const selected = options.filter(option => selectedOptions.has(option)); - const last = selected[selected.length - 1]; - if (last) deselect(getValue(last)); - return; - } - - if (event.key === 'Escape') { - closePopover(true); - return; - } - - if (!isOpen && ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { - event.preventDefault(); - openPopover(); - } - - if (popover.getAttribute('aria-hidden') === 'true') return; - - if (event.key === 'Enter') { - if (activeIndex > -1) { - event.preventDefault(); - const option = options[activeIndex]; - isMultiple ? toggle(getValue(option)) : select(getValue(option)); - } - return; - } - - if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key) || visibleOptions.length === 0) return; + if (!isOpen && ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { event.preventDefault(); + root.open(); + } - const currentVisibleIndex = activeIndex > -1 ? visibleOptions.indexOf(options[activeIndex]) : -1; - let nextVisibleIndex = currentVisibleIndex; + if (state.popover.getAttribute('aria-hidden') === 'true') return; - if (event.key === 'ArrowDown') nextVisibleIndex = Math.min(currentVisibleIndex + 1, visibleOptions.length - 1); - if (event.key === 'ArrowUp') nextVisibleIndex = currentVisibleIndex <= 0 ? 0 : currentVisibleIndex - 1; - if (event.key === 'Home') nextVisibleIndex = 0; - if (event.key === 'End') nextVisibleIndex = visibleOptions.length - 1; + if (event.key === 'Enter') { + if (state.activeIndex > -1) { + event.preventDefault(); + const option = state.options[state.activeIndex]; + state.isMultiple ? root.toggle(getValue(option)) : root.select(getValue(option)); + } + return; + } - const nextOption = visibleOptions[nextVisibleIndex]; - setActiveOption(options.indexOf(nextOption)); - scrollOptionIntoListbox(nextOption); + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key) || state.visibleOptions.length === 0) return; + event.preventDefault(); + + const currentVisibleIndex = state.activeIndex > -1 ? state.visibleOptions.indexOf(state.options[state.activeIndex]) : -1; + let nextVisibleIndex = currentVisibleIndex; + + if (event.key === 'ArrowDown') nextVisibleIndex = Math.min(currentVisibleIndex + 1, state.visibleOptions.length - 1); + if (event.key === 'ArrowUp') nextVisibleIndex = currentVisibleIndex <= 0 ? 0 : currentVisibleIndex - 1; + if (event.key === 'Home') nextVisibleIndex = 0; + if (event.key === 'End') nextVisibleIndex = state.visibleOptions.length - 1; + + const nextOption = state.visibleOptions[nextVisibleIndex]; + setActiveOption(state, state.options.indexOf(nextOption)); + scrollOptionIntoListbox(state, nextOption); + }; + + const initCombobox = (root) => { + if (root.dataset.comboboxInitialized) return; + + const state = { root, activeIndex: -1, allOptions: [], options: [], visibleOptions: [], selectedOptions: null }; + states.set(root, state); + root.refresh = () => refreshCombobox(root); + + refreshCombobox(root); + if (!state.input || !state.popover || !state.listbox || !state.hiddenInput) { + states.delete(root); + return; + } + + root.open = () => { + if (state.popover.getAttribute('aria-hidden') === 'false') return; + document.dispatchEvent(new CustomEvent('basecoat:popover', { detail: { source: root } })); + root.refresh(); + filterOptions(state); + state.popover.setAttribute('aria-hidden', 'false'); + state.input.setAttribute('aria-expanded', 'true'); }; + root.close = (focusInput = false) => closePopover(state, focusInput); - input.addEventListener('focus', openPopover); - input.addEventListener('click', openPopover); - input.addEventListener('input', () => { - openPopover(); - filterOptions(); - if (!isMultiple) { - hiddenInput.value = ''; - options.forEach(option => option.removeAttribute('aria-selected')); + root.select = (value) => selectValue(root, value); + root.selectByValue = root.select; + if (state.isMultiple) { + root.deselect = (value) => deselectValue(root, value); + root.toggle = (value) => { + const option = state.options.find(opt => getValue(opt) === value); + if (!option) return; + state.selectedOptions.has(option) ? root.deselect(value) : root.select(value); + }; + root.selectAll = () => updateValue(root, state.options); + root.selectNone = () => updateValue(root, []); + } + + state.input.addEventListener('focus', root.open); + state.input.addEventListener('click', root.open); + state.input.addEventListener('input', () => { + root.open(); + filterOptions(state); + if (!state.isMultiple) { + state.hiddenInput.value = ''; + state.options.forEach(option => option.removeAttribute('aria-selected')); } }); - input.addEventListener('keydown', handleKeydown); + state.input.addEventListener('keydown', (event) => handleKeydown(event, root)); - listbox.addEventListener('mousemove', (event) => { + state.listbox.addEventListener('mousemove', (event) => { const option = event.target.closest('[role="option"]'); - if (option && visibleOptions.includes(option)) setActiveOption(options.indexOf(option)); + if (option && state.visibleOptions.includes(option)) setActiveOption(state, state.options.indexOf(option)); }); - listbox.addEventListener('click', (event) => { + state.listbox.addEventListener('click', (event) => { const option = event.target.closest('[role="option"]'); - if (!option || !options.includes(option)) return; - isMultiple ? toggle(getValue(option)) : select(getValue(option)); - if (isMultiple && !closeOnSelect) input.focus(); + if (!option || !state.options.includes(option)) return; + state.isMultiple ? root.toggle(getValue(option)) : root.select(getValue(option)); + if (state.isMultiple && !state.closeOnSelect) state.input.focus(); }); document.addEventListener('click', (event) => { - if (!combobox.contains(event.target)) closePopover(false); + if (!root.contains(event.target)) root.close(false); }); document.addEventListener('basecoat:popover', (event) => { - if (event.detail.source !== combobox) closePopover(false); + if (event.detail.source !== root) root.close(false); }); - if (isMultiple) { - let initialValues = []; - try { - initialValues = JSON.parse(hiddenInput.value || '[]'); - } catch (_) { - initialValues = []; - } - if (!Array.isArray(initialValues)) initialValues = []; - const ariaSelected = options.filter(option => option.getAttribute('aria-selected') === 'true').map(getValue); - const values = initialValues.length ? initialValues : ariaSelected; - updateValue(options.filter(option => values.includes(getValue(option))), false); - } else { - const initialOption = options.find(option => getValue(option) === hiddenInput.value || option.getAttribute('aria-selected') === 'true'); - if (initialOption) updateValue(initialOption, false); - } + state.popover.setAttribute('aria-hidden', 'true'); + state.input.setAttribute('aria-expanded', 'false'); - popover.setAttribute('aria-hidden', 'true'); - input.setAttribute('aria-expanded', 'false'); - - Object.defineProperty(combobox, 'value', { - get: () => isMultiple ? options.filter(option => selectedOptions.has(option)).map(getValue) : hiddenInput.value, + Object.defineProperty(root, 'value', { + configurable: true, + get: () => state.isMultiple ? state.options.filter(option => state.selectedOptions.has(option)).map(getValue) : state.hiddenInput.value, set: (value) => { - if (isMultiple) { + if (state.isMultiple) { const values = Array.isArray(value) ? value : (value == null ? [] : [value]); - updateValue(options.filter(option => values.includes(getValue(option)))); + updateValue(root, state.options.filter(option => values.includes(getValue(option)))); } else { - select(value); + root.select(value); } - } + }, }); - combobox.select = select; - combobox.selectByValue = select; - if (isMultiple) { - combobox.deselect = deselect; - combobox.toggle = toggle; - combobox.selectAll = () => updateValue(options); - combobox.selectNone = () => updateValue([]); - } - - combobox.dataset.comboboxInitialized = true; - combobox.dispatchEvent(new CustomEvent('basecoat:initialized')); + root.dataset.comboboxInitialized = 'true'; + root.dispatchEvent(new CustomEvent('basecoat:initialized')); }; if (window.basecoat) { - window.basecoat.register('combobox', '.combobox:not([data-combobox-initialized])', initCombobox); + window.basecoat.register('combobox', { + selector: '.combobox:not([data-combobox-initialized])', + init: initCombobox, + refresh: refreshCombobox, + }); } })(); diff --git a/src/js/command.js b/src/js/command.js index eab1804..658b2a8 100644 --- a/src/js/command.js +++ b/src/js/command.js @@ -1,171 +1,176 @@ (() => { - const initCommand = (container) => { - const input = container.querySelector('header input'); - const menu = container.querySelector('[role="menu"]'); + const states = new WeakMap(); - if (!input || !menu) { + const isDisabled = (item) => + item.hasAttribute('disabled') || item.getAttribute('aria-disabled') === 'true'; + + const getElements = (container) => ({ + input: container.querySelector('header input'), + menu: container.querySelector('[role="menu"]'), + }); + + const getItems = (menu) => { + const allItems = Array.from(menu.querySelectorAll('[role="menuitem"]')); + return { + allItems, + items: allItems.filter(item => !isDisabled(item)), + }; + }; + + const scrollItemIntoMenu = (state, item) => { + const itemRect = item.getBoundingClientRect(); + const menuRect = state.menu.getBoundingClientRect(); + + if (itemRect.top < menuRect.top) { + state.menu.scrollTop -= menuRect.top - itemRect.top; + } else if (itemRect.bottom > menuRect.bottom) { + state.menu.scrollTop += itemRect.bottom - menuRect.bottom; + } + }; + + const setActiveItem = (state, index) => { + if (state.activeIndex > -1 && state.items[state.activeIndex]) { + state.items[state.activeIndex].classList.remove('active'); + } + + state.activeIndex = index; + + if (state.activeIndex > -1) { + const activeItem = state.items[state.activeIndex]; + activeItem.classList.add('active'); + if (activeItem.id) { + state.input.setAttribute('aria-activedescendant', activeItem.id); + } else { + state.input.removeAttribute('aria-activedescendant'); + } + } else { + state.input.removeAttribute('aria-activedescendant'); + } + }; + + const filterItems = (state) => { + const searchTerm = state.input.value.trim().toLowerCase(); + + setActiveItem(state, -1); + state.visibleItems = []; + + state.allItems.forEach(item => { + if (item.hasAttribute('data-force')) { + item.setAttribute('aria-hidden', 'false'); + if (state.items.includes(item)) state.visibleItems.push(item); + return; + } + + const itemText = (item.dataset.filter || item.textContent).trim().toLowerCase(); + const keywordList = (item.dataset.keywords || '') + .toLowerCase() + .split(/[\s,]+/) + .filter(Boolean); + const matchesKeyword = keywordList.some(keyword => keyword.includes(searchTerm)); + const matches = itemText.includes(searchTerm) || matchesKeyword; + item.setAttribute('aria-hidden', String(!matches)); + if (matches && state.items.includes(item)) state.visibleItems.push(item); + }); + + if (state.visibleItems.length > 0) { + setActiveItem(state, state.items.indexOf(state.visibleItems[0])); + scrollItemIntoMenu(state, state.visibleItems[0]); + } + }; + + const refreshCommand = (container) => { + const state = states.get(container); + if (!state) return; + + const elements = getElements(container); + if (!elements.input || !elements.menu) { const missing = []; - if (!input) missing.push('input'); - if (!menu) missing.push('menu'); - console.error(`Command component initialization failed. Missing element(s): ${missing.join(', ')}`, container); + if (!elements.input) missing.push('input'); + if (!elements.menu) missing.push('menu'); + console.error(`Command component refresh failed. Missing element(s): ${missing.join(', ')}`, container); return; } - const allMenuItems = Array.from(menu.querySelectorAll('[role="menuitem"]')); - const menuItems = allMenuItems.filter(item => - !item.hasAttribute('disabled') && - item.getAttribute('aria-disabled') !== 'true' - ); - let visibleMenuItems = [...menuItems]; - let activeIndex = -1; + Object.assign(state, elements, getItems(elements.menu)); + filterItems(state); + }; - const scrollItemIntoMenu = (item) => { - const itemRect = item.getBoundingClientRect(); - const menuRect = menu.getBoundingClientRect(); - - if (itemRect.top < menuRect.top) { - menu.scrollTop -= menuRect.top - itemRect.top; - } else if (itemRect.bottom > menuRect.bottom) { - menu.scrollTop += itemRect.bottom - menuRect.bottom; - } - }; - - const setActiveItem = (index) => { - if (activeIndex > -1 && menuItems[activeIndex]) { - menuItems[activeIndex].classList.remove('active'); - } - - activeIndex = index; - - if (activeIndex > -1) { - const activeItem = menuItems[activeIndex]; - activeItem.classList.add('active'); - if (activeItem.id) { - input.setAttribute('aria-activedescendant', activeItem.id); - } else { - input.removeAttribute('aria-activedescendant'); - } - } else { - input.removeAttribute('aria-activedescendant'); - } - }; - - const filterMenuItems = () => { - const searchTerm = input.value.trim().toLowerCase(); - - setActiveItem(-1); - - visibleMenuItems = []; - allMenuItems.forEach(item => { - if (item.hasAttribute('data-force')) { - item.setAttribute('aria-hidden', 'false'); - if (menuItems.includes(item)) { - visibleMenuItems.push(item); - } - return; - } - - const itemText = (item.dataset.filter || item.textContent).trim().toLowerCase(); - const keywordList = (item.dataset.keywords || '') - .toLowerCase() - .split(/[\s,]+/) - .filter(Boolean); - const matchesKeyword = keywordList.some(keyword => keyword.includes(searchTerm)); - const matches = itemText.includes(searchTerm) || matchesKeyword; - item.setAttribute('aria-hidden', String(!matches)); - if (matches && menuItems.includes(item)) { - visibleMenuItems.push(item); - } - }); - - if (visibleMenuItems.length > 0) { - setActiveItem(menuItems.indexOf(visibleMenuItems[0])); - scrollItemIntoMenu(visibleMenuItems[0]); - } - }; - - input.addEventListener('input', filterMenuItems); - - const handleKeyNavigation = (event) => { - if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End'].includes(event.key)) { - return; - } - - if (event.key === 'Enter') { - event.preventDefault(); - if (activeIndex > -1) { - menuItems[activeIndex]?.click(); - } - return; - } - - if (visibleMenuItems.length === 0) return; + const handleKeyNavigation = (event, state) => { + if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End'].includes(event.key)) return; + if (event.key === 'Enter') { event.preventDefault(); - - const currentVisibleIndex = activeIndex > -1 ? visibleMenuItems.indexOf(menuItems[activeIndex]) : -1; - let nextVisibleIndex = currentVisibleIndex; - - switch (event.key) { - case 'ArrowDown': - if (currentVisibleIndex < visibleMenuItems.length - 1) { - nextVisibleIndex = currentVisibleIndex + 1; - } - break; - case 'ArrowUp': - if (currentVisibleIndex > 0) { - nextVisibleIndex = currentVisibleIndex - 1; - } else if (currentVisibleIndex === -1) { - nextVisibleIndex = 0; - } - break; - case 'Home': - nextVisibleIndex = 0; - break; - case 'End': - nextVisibleIndex = visibleMenuItems.length - 1; - break; - } - - if (nextVisibleIndex !== currentVisibleIndex) { - const newActiveItem = visibleMenuItems[nextVisibleIndex]; - setActiveItem(menuItems.indexOf(newActiveItem)); - scrollItemIntoMenu(newActiveItem); - } - }; - - menu.addEventListener('mousemove', (event) => { - const menuItem = event.target.closest('[role="menuitem"]'); - if (menuItem && visibleMenuItems.includes(menuItem)) { - const index = menuItems.indexOf(menuItem); - if (index !== activeIndex) { - setActiveItem(index); - } - } - }); - - menu.addEventListener('click', (event) => { - const clickedItem = event.target.closest('[role="menuitem"]'); - if (clickedItem && visibleMenuItems.includes(clickedItem)) { - const dialog = container.closest('dialog.command-dialog'); - if (dialog && !clickedItem.hasAttribute('data-keep-command-open')) { - dialog.close(); - } - } - }); - - input.addEventListener('keydown', handleKeyNavigation); - - if (visibleMenuItems.length > 0) { - setActiveItem(menuItems.indexOf(visibleMenuItems[0])); - scrollItemIntoMenu(visibleMenuItems[0]); + if (state.activeIndex > -1) state.items[state.activeIndex]?.click(); + return; } - container.dataset.commandInitialized = true; + if (state.visibleItems.length === 0) return; + + event.preventDefault(); + + const currentVisibleIndex = state.activeIndex > -1 ? state.visibleItems.indexOf(state.items[state.activeIndex]) : -1; + let nextVisibleIndex = currentVisibleIndex; + + if (event.key === 'ArrowDown' && currentVisibleIndex < state.visibleItems.length - 1) nextVisibleIndex = currentVisibleIndex + 1; + if (event.key === 'ArrowUp') nextVisibleIndex = currentVisibleIndex > 0 ? currentVisibleIndex - 1 : 0; + if (event.key === 'Home') nextVisibleIndex = 0; + if (event.key === 'End') nextVisibleIndex = state.visibleItems.length - 1; + + if (nextVisibleIndex !== currentVisibleIndex) { + const newActiveItem = state.visibleItems[nextVisibleIndex]; + setActiveItem(state, state.items.indexOf(newActiveItem)); + scrollItemIntoMenu(state, newActiveItem); + } + }; + + const initCommand = (container) => { + if (container.dataset.commandInitialized) return; + + const state = { activeIndex: -1, allItems: [], items: [], visibleItems: [] }; + states.set(container, state); + + container.refresh = () => refreshCommand(container); + + const elements = getElements(container); + if (!elements.input || !elements.menu) { + const missing = []; + if (!elements.input) missing.push('input'); + if (!elements.menu) missing.push('menu'); + console.error(`Command component initialization failed. Missing element(s): ${missing.join(', ')}`, container); + states.delete(container); + return; + } + Object.assign(state, elements); + + state.input.addEventListener('input', () => filterItems(state)); + state.input.addEventListener('keydown', (event) => handleKeyNavigation(event, state)); + + state.menu.addEventListener('mousemove', (event) => { + const menuItem = event.target.closest('[role="menuitem"]'); + if (menuItem && state.visibleItems.includes(menuItem)) { + const index = state.items.indexOf(menuItem); + if (index !== state.activeIndex) setActiveItem(state, index); + } + }); + + state.menu.addEventListener('click', (event) => { + const clickedItem = event.target.closest('[role="menuitem"]'); + if (clickedItem && state.visibleItems.includes(clickedItem)) { + const dialog = container.closest('dialog.command-dialog'); + if (dialog && !clickedItem.hasAttribute('data-keep-command-open')) dialog.close(); + } + }); + + refreshCommand(container); + container.dataset.commandInitialized = 'true'; container.dispatchEvent(new CustomEvent('basecoat:initialized')); }; if (window.basecoat) { - window.basecoat.register('command', '.command:not([data-command-initialized])', initCommand); + window.basecoat.register('command', { + selector: '.command:not([data-command-initialized])', + init: initCommand, + refresh: refreshCommand, + }); } })(); diff --git a/src/js/dropdown-menu.js b/src/js/dropdown-menu.js index 519b37e..66ef1d5 100644 --- a/src/js/dropdown-menu.js +++ b/src/js/dropdown-menu.js @@ -1,174 +1,172 @@ (() => { - const initDropdownMenu = (dropdownMenuComponent) => { - const trigger = dropdownMenuComponent.querySelector(':scope > button'); - const popover = dropdownMenuComponent.querySelector(':scope > [data-popover]'); - const menu = popover.querySelector('[role="menu"]'); - - if (!trigger || !menu || !popover) { + const states = new WeakMap(); + + const isDisabled = (item) => + item.hasAttribute('disabled') || item.getAttribute('aria-disabled') === 'true'; + + const getElements = (root) => { + const trigger = root.querySelector(':scope > button'); + const popover = root.querySelector(':scope > [data-popover]'); + const menu = popover ? popover.querySelector('[role="menu"]') : null; + return { trigger, popover, menu }; + }; + + const getItems = (menu) => Array.from(menu.querySelectorAll('[role^="menuitem"]')).filter(item => !isDisabled(item)); + + const setActiveItem = (state, index) => { + if (state.activeIndex > -1 && state.items[state.activeIndex]) { + state.items[state.activeIndex].classList.remove('active'); + } + state.activeIndex = index; + if (state.activeIndex > -1 && state.items[state.activeIndex]) { + const activeItem = state.items[state.activeIndex]; + activeItem.classList.add('active'); + if (activeItem.id) state.trigger.setAttribute('aria-activedescendant', activeItem.id); + } else { + state.trigger.removeAttribute('aria-activedescendant'); + } + }; + + const refreshDropdownMenu = (root) => { + const state = states.get(root); + if (!state) return; + + const elements = getElements(root); + if (!elements.trigger || !elements.popover || !elements.menu) { const missing = []; - if (!trigger) missing.push('trigger'); - if (!menu) missing.push('menu'); - if (!popover) missing.push('popover'); - console.error(`Dropdown menu initialisation failed. Missing element(s): ${missing.join(', ')}`, dropdownMenuComponent); + if (!elements.trigger) missing.push('trigger'); + if (!elements.popover) missing.push('popover'); + if (!elements.menu) missing.push('menu'); + console.error(`Dropdown menu refresh failed. Missing element(s): ${missing.join(', ')}`, root); return; } - let menuItems = []; - let activeIndex = -1; + Object.assign(state, elements); + state.items = getItems(state.menu); + if (state.activeIndex > -1 && !state.items[state.activeIndex]) setActiveItem(state, -1); + }; - const isDisabled = (item) => - item.hasAttribute('disabled') || item.getAttribute('aria-disabled') === 'true'; + const closePopover = (state, focusOnTrigger = true) => { + if (state.trigger.getAttribute('aria-expanded') === 'false') return; + state.trigger.setAttribute('aria-expanded', 'false'); + state.trigger.removeAttribute('aria-activedescendant'); + state.popover.setAttribute('aria-hidden', 'true'); + if (focusOnTrigger) state.trigger.focus(); + setActiveItem(state, -1); + }; - const closePopover = (focusOnTrigger = true) => { - if (trigger.getAttribute('aria-expanded') === 'false') return; - trigger.setAttribute('aria-expanded', 'false'); - trigger.removeAttribute('aria-activedescendant'); - popover.setAttribute('aria-hidden', 'true'); - - if (focusOnTrigger) { - trigger.focus(); - } - - setActiveItem(-1); - }; + const openPopover = (root, state, initialSelection = false) => { + document.dispatchEvent(new CustomEvent('basecoat:popover', { detail: { source: root } })); + root.refresh(); + state.trigger.setAttribute('aria-expanded', 'true'); + state.popover.setAttribute('aria-hidden', 'false'); - const openPopover = (initialSelection = false) => { - document.dispatchEvent(new CustomEvent('basecoat:popover', { - detail: { source: dropdownMenuComponent } - })); - - trigger.setAttribute('aria-expanded', 'true'); - popover.setAttribute('aria-hidden', 'false'); - menuItems = Array.from(menu.querySelectorAll('[role^="menuitem"]')).filter(item => !isDisabled(item)); - - if (menuItems.length > 0 && initialSelection) { - if (initialSelection === 'first') { - setActiveItem(0); - } else if (initialSelection === 'last') { - setActiveItem(menuItems.length - 1); - } - } - }; + if (state.items.length > 0 && initialSelection) { + setActiveItem(state, initialSelection === 'last' ? state.items.length - 1 : 0); + } + }; - const setActiveItem = (index) => { - if (activeIndex > -1 && menuItems[activeIndex]) { - menuItems[activeIndex].classList.remove('active'); - } - activeIndex = index; - if (activeIndex > -1 && menuItems[activeIndex]) { - const activeItem = menuItems[activeIndex]; - activeItem.classList.add('active'); - if (activeItem.id) { - trigger.setAttribute('aria-activedescendant', activeItem.id); - } - } else { - trigger.removeAttribute('aria-activedescendant'); - } - }; + const initDropdownMenu = (root) => { + if (root.dataset.dropdownMenuInitialized) return; - trigger.addEventListener('click', () => { - const isExpanded = trigger.getAttribute('aria-expanded') === 'true'; - if (isExpanded) { - closePopover(); - } else { - openPopover(false); - } - }); + const state = { activeIndex: -1, items: [] }; + states.set(root, state); + root.refresh = () => refreshDropdownMenu(root); - dropdownMenuComponent.addEventListener('keydown', (event) => { - const isExpanded = trigger.getAttribute('aria-expanded') === 'true'; + refreshDropdownMenu(root); + if (!state.trigger || !state.popover || !state.menu) { + states.delete(root); + return; + } + + root.open = (initialSelection = false) => openPopover(root, state, initialSelection); + root.close = (focusOnTrigger = true) => closePopover(state, focusOnTrigger); + root.toggle = () => state.trigger.getAttribute('aria-expanded') === 'true' ? root.close() : root.open(false); + + state.trigger.addEventListener('click', root.toggle); + + root.addEventListener('keydown', (event) => { + const isExpanded = state.trigger.getAttribute('aria-expanded') === 'true'; if (event.key === 'Escape') { - if (isExpanded) closePopover(); + if (isExpanded) root.close(); return; } - + if (!isExpanded) { if (['Enter', ' '].includes(event.key)) { event.preventDefault(); - openPopover(false); + root.open(false); } else if (event.key === 'ArrowDown') { event.preventDefault(); - openPopover('first'); + root.open('first'); } else if (event.key === 'ArrowUp') { event.preventDefault(); - openPopover('last'); + root.open('last'); } return; } - if (menuItems.length === 0) return; + if (state.items.length === 0) return; - let nextIndex = activeIndex; - - switch (event.key) { - case 'ArrowDown': - event.preventDefault(); - nextIndex = activeIndex === -1 ? 0 : Math.min(activeIndex + 1, menuItems.length - 1); - break; - case 'ArrowUp': - event.preventDefault(); - nextIndex = activeIndex === -1 ? menuItems.length - 1 : Math.max(activeIndex - 1, 0); - break; - case 'Home': - event.preventDefault(); - nextIndex = 0; - break; - case 'End': - event.preventDefault(); - nextIndex = menuItems.length - 1; - break; - case 'Enter': - case ' ': - event.preventDefault(); - menuItems[activeIndex]?.click(); - closePopover(); - return; + let nextIndex = state.activeIndex; + if (event.key === 'ArrowDown') { + event.preventDefault(); + nextIndex = state.activeIndex === -1 ? 0 : Math.min(state.activeIndex + 1, state.items.length - 1); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + nextIndex = state.activeIndex === -1 ? state.items.length - 1 : Math.max(state.activeIndex - 1, 0); + } else if (event.key === 'Home') { + event.preventDefault(); + nextIndex = 0; + } else if (event.key === 'End') { + event.preventDefault(); + nextIndex = state.items.length - 1; + } else if (['Enter', ' '].includes(event.key)) { + event.preventDefault(); + state.items[state.activeIndex]?.click(); + root.close(); + return; + } else { + return; } - if (nextIndex !== activeIndex) { - setActiveItem(nextIndex); - } + if (nextIndex !== state.activeIndex) setActiveItem(state, nextIndex); }); - menu.addEventListener('mousemove', (event) => { + state.menu.addEventListener('mousemove', (event) => { const menuItem = event.target.closest('[role^="menuitem"]'); - if (menuItem && !isDisabled(menuItem) && menuItems.includes(menuItem)) { - const index = menuItems.indexOf(menuItem); - if (index !== activeIndex) { - setActiveItem(index); - } + if (menuItem && !isDisabled(menuItem) && state.items.includes(menuItem)) { + const index = state.items.indexOf(menuItem); + if (index !== state.activeIndex) setActiveItem(state, index); } }); - menu.addEventListener('mouseleave', () => { - setActiveItem(-1); - }); - - menu.addEventListener('click', (event) => { + state.menu.addEventListener('mouseleave', () => setActiveItem(state, -1)); + state.menu.addEventListener('click', (event) => { const menuItem = event.target.closest('[role^="menuitem"]'); - if (menuItem && !isDisabled(menuItem)) { - closePopover(); - } + if (menuItem && !isDisabled(menuItem)) root.close(); }); document.addEventListener('click', (event) => { - if (!dropdownMenuComponent.contains(event.target)) { - closePopover(); - } + if (!root.contains(event.target)) root.close(false); }); document.addEventListener('basecoat:popover', (event) => { - if (event.detail.source !== dropdownMenuComponent) { - closePopover(false); - } + if (event.detail.source !== root) root.close(false); }); - dropdownMenuComponent.dataset.dropdownMenuInitialized = true; - dropdownMenuComponent.dispatchEvent(new CustomEvent('basecoat:initialized')); + state.trigger.setAttribute('aria-expanded', 'false'); + state.popover.setAttribute('aria-hidden', 'true'); + root.dataset.dropdownMenuInitialized = 'true'; + root.dispatchEvent(new CustomEvent('basecoat:initialized')); }; if (window.basecoat) { - window.basecoat.register('dropdown-menu', '.dropdown-menu:not([data-dropdown-menu-initialized])', initDropdownMenu); + window.basecoat.register('dropdown-menu', { + selector: '.dropdown-menu:not([data-dropdown-menu-initialized])', + init: initDropdownMenu, + refresh: refreshDropdownMenu, + }); } })(); diff --git a/src/js/select.js b/src/js/select.js index 4884ac1..d95c960 100644 --- a/src/js/select.js +++ b/src/js/select.js @@ -1,380 +1,356 @@ (() => { - const initSelect = (selectComponent) => { - const trigger = selectComponent.querySelector(':scope > button'); - const selectedLabel = trigger.querySelector(':scope > span'); - const popover = selectComponent.querySelector(':scope > [data-popover]'); - const listbox = popover ? popover.querySelector('[role="listbox"]') : null; - const input = selectComponent.querySelector(':scope > input[type="hidden"]'); + const states = new WeakMap(); - if (!trigger || !popover || !listbox || !input) { + const getElements = (root) => { + const trigger = root.querySelector(':scope > button'); + const selectedLabel = trigger?.querySelector(':scope > span') || null; + const popover = root.querySelector(':scope > [data-popover]'); + const listbox = popover ? popover.querySelector('[role="listbox"]') : null; + const input = root.querySelector(':scope > input[type="hidden"]'); + return { trigger, selectedLabel, popover, listbox, input }; + }; + + const getValue = (option) => option.dataset.value ?? option.textContent.trim(); + const isDisabled = (option) => option.getAttribute('aria-disabled') === 'true'; + + const getOptions = (listbox) => { + const allOptions = Array.from(listbox.querySelectorAll('[role="option"]')); + return { + allOptions, + options: allOptions.filter(option => !isDisabled(option)), + }; + }; + + const scrollOptionIntoListbox = (state, option) => { + const optionRect = option.getBoundingClientRect(); + const listboxRect = state.listbox.getBoundingClientRect(); + + if (optionRect.top < listboxRect.top) { + state.listbox.scrollTop -= listboxRect.top - optionRect.top; + } else if (optionRect.bottom > listboxRect.bottom) { + state.listbox.scrollTop += optionRect.bottom - listboxRect.bottom; + } + }; + + const setActiveOption = (state, index) => { + if (state.activeIndex > -1 && state.options[state.activeIndex]) { + state.options[state.activeIndex].classList.remove('active'); + } + + state.activeIndex = index; + + if (state.activeIndex > -1) { + const activeOption = state.options[state.activeIndex]; + activeOption.classList.add('active'); + if (activeOption.id) { + state.trigger.setAttribute('aria-activedescendant', activeOption.id); + } else { + state.trigger.removeAttribute('aria-activedescendant'); + } + } else { + state.trigger.removeAttribute('aria-activedescendant'); + } + }; + + const updateValue = (root, optionOrOptions, triggerEvent = true) => { + const state = states.get(root); + let value; + + if (state.isMultiple) { + const selected = Array.isArray(optionOrOptions) ? optionOrOptions : []; + state.selectedOptions.clear(); + selected.forEach(option => state.selectedOptions.add(option)); + + const selectedInOrder = state.options.filter(option => state.selectedOptions.has(option)); + if (selectedInOrder.length === 0) { + state.selectedLabel.textContent = state.placeholder; + state.selectedLabel.classList.add('text-muted-foreground'); + } else { + state.selectedLabel.textContent = selectedInOrder.map(option => option.dataset.label || option.textContent.trim()).join(', '); + state.selectedLabel.classList.remove('text-muted-foreground'); + } + + value = selectedInOrder.map(getValue); + state.input.value = JSON.stringify(value); + } else { + const option = optionOrOptions; + if (!option) return; + state.selectedLabel.innerHTML = option.innerHTML; + value = getValue(option); + state.input.value = value; + } + + state.options.forEach(option => { + const isSelected = state.isMultiple ? state.selectedOptions.has(option) : option === optionOrOptions; + if (isSelected) { + option.setAttribute('aria-selected', 'true'); + } else { + option.removeAttribute('aria-selected'); + } + }); + + if (triggerEvent) { + root.dispatchEvent(new CustomEvent('change', { + detail: { value }, + bubbles: true, + })); + } + }; + + const closePopover = (state, focusOnTrigger = true) => { + if (state.popover.getAttribute('aria-hidden') === 'true') return; + if (focusOnTrigger) state.trigger.focus(); + state.popover.setAttribute('aria-hidden', 'true'); + state.trigger.setAttribute('aria-expanded', 'false'); + setActiveOption(state, -1); + }; + + const refreshSelect = (root) => { + const state = states.get(root); + if (!state) return; + + const elements = getElements(root); + if (!elements.trigger || !elements.selectedLabel || !elements.popover || !elements.listbox || !elements.input) { const missing = []; - if (!trigger) missing.push('trigger'); - if (!popover) missing.push('popover'); - if (!listbox) missing.push('listbox'); - if (!input) missing.push('input'); - console.error(`Select component initialisation failed. Missing element(s): ${missing.join(', ')}`, selectComponent); + if (!elements.trigger) missing.push('trigger'); + if (!elements.selectedLabel) missing.push('selected label'); + if (!elements.popover) missing.push('popover'); + if (!elements.listbox) missing.push('listbox'); + if (!elements.input) missing.push('input'); + console.error(`Select component refresh failed. Missing element(s): ${missing.join(', ')}`, root); return; } - const allOptions = Array.from(listbox.querySelectorAll('[role="option"]')); - const options = allOptions.filter(opt => opt.getAttribute('aria-disabled') !== 'true'); - let visibleOptions = [...options]; - let activeIndex = -1; - const isMultiple = listbox.getAttribute('aria-multiselectable') === 'true'; - const selectedOptions = isMultiple ? new Set() : null; - const placeholder = isMultiple ? (selectComponent.dataset.placeholder || '') : null; - const closeOnSelect = selectComponent.dataset.closeOnSelect === 'true'; + const previousValue = elements.input.value; + Object.assign(state, elements, getOptions(elements.listbox)); + state.visibleOptions = [...state.options]; + state.isMultiple = state.listbox.getAttribute('aria-multiselectable') === 'true'; + state.placeholder = state.isMultiple ? (root.dataset.placeholder || '') : null; + state.closeOnSelect = root.dataset.closeOnSelect === 'true'; - const getValue = (opt) => opt.dataset.value ?? opt.textContent.trim(); - - const scrollOptionIntoListbox = (option) => { - const optionRect = option.getBoundingClientRect(); - const listboxRect = listbox.getBoundingClientRect(); - - if (optionRect.top < listboxRect.top) { - listbox.scrollTop -= listboxRect.top - optionRect.top; - } else if (optionRect.bottom > listboxRect.bottom) { - listbox.scrollTop += optionRect.bottom - listboxRect.bottom; - } - }; - - const setActiveOption = (index) => { - if (activeIndex > -1 && options[activeIndex]) { - options[activeIndex].classList.remove('active'); - } - - activeIndex = index; - - if (activeIndex > -1) { - const activeOption = options[activeIndex]; - activeOption.classList.add('active'); - if (activeOption.id) { - trigger.setAttribute('aria-activedescendant', activeOption.id); - } else { - trigger.removeAttribute('aria-activedescendant'); - } - } else { - trigger.removeAttribute('aria-activedescendant'); - } - }; - - const updateValue = (optionOrOptions, triggerEvent = true) => { - let value; - - if (isMultiple) { - const opts = Array.isArray(optionOrOptions) ? optionOrOptions : []; - selectedOptions.clear(); - opts.forEach(opt => selectedOptions.add(opt)); - - // Get selected options in DOM order - const selected = options.filter(opt => selectedOptions.has(opt)); - if (selected.length === 0) { - selectedLabel.textContent = placeholder; - selectedLabel.classList.add('text-muted-foreground'); - } else { - selectedLabel.textContent = selected.map(opt => opt.dataset.label || opt.textContent.trim()).join(', '); - selectedLabel.classList.remove('text-muted-foreground'); - } - - value = selected.map(getValue); - input.value = JSON.stringify(value); - } else { - const option = optionOrOptions; - if (!option) return; - selectedLabel.innerHTML = option.innerHTML; - value = getValue(option); - input.value = value; - } - - options.forEach(opt => { - const isSelected = isMultiple ? selectedOptions.has(opt) : opt === optionOrOptions; - if (isSelected) { - opt.setAttribute('aria-selected', 'true'); - } else { - opt.removeAttribute('aria-selected'); - } - }); - - if (triggerEvent) { - selectComponent.dispatchEvent(new CustomEvent('change', { - detail: { value }, - bubbles: true - })); - } - }; - - const closePopover = (focusOnTrigger = true) => { - if (popover.getAttribute('aria-hidden') === 'true') return; - - if (focusOnTrigger) trigger.focus(); - popover.setAttribute('aria-hidden', 'true'); - trigger.setAttribute('aria-expanded', 'false'); - setActiveOption(-1); - }; - - const toggleMultipleValue = (option) => { - if (selectedOptions.has(option)) { - selectedOptions.delete(option); - } else { - selectedOptions.add(option); - } - updateValue(options.filter(opt => selectedOptions.has(opt))); - }; - - const select = (value) => { - if (isMultiple) { - const option = options.find(opt => getValue(opt) === value && !selectedOptions.has(opt)); - if (!option) return; - selectedOptions.add(option); - updateValue(options.filter(opt => selectedOptions.has(opt))); - } else { - const option = options.find(opt => getValue(opt) === value); - if (!option) return; - if (input.value !== value) { - updateValue(option); - } - closePopover(); - } - }; - - const deselect = (value) => { - if (!isMultiple) return; - const option = options.find(opt => getValue(opt) === value && selectedOptions.has(opt)); - if (!option) return; - selectedOptions.delete(option); - updateValue(options.filter(opt => selectedOptions.has(opt))); - }; - - const toggle = (value) => { - if (!isMultiple) return; - const option = options.find(opt => getValue(opt) === value); - if (!option) return; - toggleMultipleValue(option); - }; - - // Initialization - if (isMultiple) { - const ariaSelected = options.filter(opt => opt.getAttribute('aria-selected') === 'true'); + if (state.isMultiple) { + if (!state.selectedOptions) state.selectedOptions = new Set(); + let values = []; try { - const parsed = JSON.parse(input.value || '[]'); - const validValues = new Set(options.map(getValue)); - const initialValues = Array.isArray(parsed) ? parsed.filter(v => validValues.has(v)) : []; - - const initialOptions = []; - if (initialValues.length > 0) { - // Match values to options in order, allowing duplicates - initialValues.forEach(val => { - const opt = options.find(o => getValue(o) === val && !initialOptions.includes(o)); - if (opt) initialOptions.push(opt); - }); - } else { - initialOptions.push(...ariaSelected); - } - - updateValue(initialOptions, false); - } catch (e) { - updateValue(ariaSelected, false); + const parsed = JSON.parse(previousValue || '[]'); + values = Array.isArray(parsed) ? parsed : []; + } catch (_) { + values = []; } + const selected = values.length + ? values.map(value => state.options.find(option => getValue(option) === value)).filter(Boolean) + : state.options.filter(option => option.getAttribute('aria-selected') === 'true'); + updateValue(root, selected, false); } else { - const initialOption = options.find(opt => getValue(opt) === input.value) || options[0]; - if (initialOption) updateValue(initialOption, false); + const selected = state.options.find(option => getValue(option) === previousValue) + || state.options.find(option => option.getAttribute('aria-selected') === 'true'); + state.options.forEach(option => option.removeAttribute('aria-selected')); + if (selected) updateValue(root, selected, false); } - const handleKeyNavigation = (event) => { - const isPopoverOpen = popover.getAttribute('aria-hidden') === 'false'; + const selectedOption = state.listbox.querySelector('[role="option"][aria-selected="true"]'); + setActiveOption(state, selectedOption ? state.options.indexOf(selectedOption) : -1); + }; - if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End', 'Escape'].includes(event.key)) { - return; + const toggleMultipleValue = (root, option) => { + const state = states.get(root); + if (state.selectedOptions.has(option)) { + state.selectedOptions.delete(option); + } else { + state.selectedOptions.add(option); + } + updateValue(root, state.options.filter(opt => state.selectedOptions.has(opt))); + }; + + const selectValue = (root, value) => { + const state = states.get(root); + if (state.isMultiple) { + const option = state.options.find(opt => getValue(opt) === value && !state.selectedOptions.has(opt)); + if (!option) return; + state.selectedOptions.add(option); + updateValue(root, state.options.filter(opt => state.selectedOptions.has(opt))); + } else { + const option = state.options.find(opt => getValue(opt) === value); + if (!option) return; + if (state.input.value !== value) updateValue(root, option); + closePopover(state); + } + }; + + const deselectValue = (root, value) => { + const state = states.get(root); + if (!state.isMultiple) return; + const option = state.options.find(opt => getValue(opt) === value && state.selectedOptions.has(opt)); + if (!option) return; + state.selectedOptions.delete(option); + updateValue(root, state.options.filter(opt => state.selectedOptions.has(opt))); + }; + + const handleKeyNavigation = (event, root) => { + const state = states.get(root); + const isPopoverOpen = state.popover.getAttribute('aria-hidden') === 'false'; + + if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End', 'Escape'].includes(event.key)) return; + + if (!isPopoverOpen) { + if (event.key !== 'Enter' && event.key !== 'Escape') { + event.preventDefault(); + root.open(); } + return; + } - if (!isPopoverOpen) { - if (event.key !== 'Enter' && event.key !== 'Escape') { - event.preventDefault(); - trigger.click(); + event.preventDefault(); + + if (event.key === 'Escape') { + root.close(); + return; + } + + if (event.key === 'Enter') { + if (state.activeIndex > -1) { + const option = state.options[state.activeIndex]; + if (state.isMultiple) { + toggleMultipleValue(root, option); + if (state.closeOnSelect) root.close(); + } else { + if (state.input.value !== getValue(option)) updateValue(root, option); + root.close(); } - return; } + return; + } - event.preventDefault(); + if (state.visibleOptions.length === 0) return; - if (event.key === 'Escape') { - closePopover(); - return; - } + const currentVisibleIndex = state.activeIndex > -1 ? state.visibleOptions.indexOf(state.options[state.activeIndex]) : -1; + let nextVisibleIndex = currentVisibleIndex; - if (event.key === 'Enter') { - if (activeIndex > -1) { - const option = options[activeIndex]; - if (isMultiple) { - toggleMultipleValue(option); - if (closeOnSelect) { - closePopover(); - } - } else { - if (input.value !== getValue(option)) { - updateValue(option); - } - closePopover(); - } - } - return; - } + if (event.key === 'ArrowDown' && currentVisibleIndex < state.visibleOptions.length - 1) nextVisibleIndex = currentVisibleIndex + 1; + if (event.key === 'ArrowUp') nextVisibleIndex = currentVisibleIndex > 0 ? currentVisibleIndex - 1 : 0; + if (event.key === 'Home') nextVisibleIndex = 0; + if (event.key === 'End') nextVisibleIndex = state.visibleOptions.length - 1; - if (visibleOptions.length === 0) return; + if (nextVisibleIndex !== currentVisibleIndex) { + const newActiveOption = state.visibleOptions[nextVisibleIndex]; + setActiveOption(state, state.options.indexOf(newActiveOption)); + scrollOptionIntoListbox(state, newActiveOption); + } + }; - const currentVisibleIndex = activeIndex > -1 ? visibleOptions.indexOf(options[activeIndex]) : -1; - let nextVisibleIndex = currentVisibleIndex; + const initSelect = (root) => { + if (root.dataset.selectInitialized) return; - switch (event.key) { - case 'ArrowDown': - if (currentVisibleIndex < visibleOptions.length - 1) { - nextVisibleIndex = currentVisibleIndex + 1; - } - break; - case 'ArrowUp': - if (currentVisibleIndex > 0) { - nextVisibleIndex = currentVisibleIndex - 1; - } else if (currentVisibleIndex === -1) { - nextVisibleIndex = 0; - } - break; - case 'Home': - nextVisibleIndex = 0; - break; - case 'End': - nextVisibleIndex = visibleOptions.length - 1; - break; - } + const state = { activeIndex: -1, selectedOptions: null, options: [], allOptions: [], visibleOptions: [] }; + states.set(root, state); + root.refresh = () => refreshSelect(root); - if (nextVisibleIndex !== currentVisibleIndex) { - const newActiveOption = visibleOptions[nextVisibleIndex]; - setActiveOption(options.indexOf(newActiveOption)); - scrollOptionIntoListbox(newActiveOption); + refreshSelect(root); + if (!state.trigger || !state.selectedLabel || !state.popover || !state.listbox || !state.input) { + states.delete(root); + return; + } + + root.open = () => { + document.dispatchEvent(new CustomEvent('basecoat:popover', { detail: { source: root } })); + root.refresh(); + state.popover.setAttribute('aria-hidden', 'false'); + state.trigger.setAttribute('aria-expanded', 'true'); + + const selectedOption = state.listbox.querySelector('[role="option"][aria-selected="true"]'); + if (selectedOption) { + setActiveOption(state, state.options.indexOf(selectedOption)); + scrollOptionIntoListbox(state, selectedOption); } }; + root.close = (focusOnTrigger = true) => closePopover(state, focusOnTrigger); + root.togglePopover = () => state.trigger.getAttribute('aria-expanded') === 'true' ? root.close() : root.open(); - listbox.addEventListener('mousemove', (event) => { + state.trigger.addEventListener('keydown', (event) => handleKeyNavigation(event, root)); + state.trigger.addEventListener('click', root.togglePopover); + + state.listbox.addEventListener('mousemove', (event) => { const option = event.target.closest('[role="option"]'); - if (option && visibleOptions.includes(option)) { - const index = options.indexOf(option); - if (index !== activeIndex) { - setActiveOption(index); - } + if (option && state.visibleOptions.includes(option)) { + const index = state.options.indexOf(option); + if (index !== state.activeIndex) setActiveOption(state, index); } }); - listbox.addEventListener('mouseleave', () => { - const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]'); - if (selectedOption) { - setActiveOption(options.indexOf(selectedOption)); - } else { - setActiveOption(-1); - } + state.listbox.addEventListener('mouseleave', () => { + const selectedOption = state.listbox.querySelector('[role="option"][aria-selected="true"]'); + setActiveOption(state, selectedOption ? state.options.indexOf(selectedOption) : -1); }); - trigger.addEventListener('keydown', handleKeyNavigation); - const openPopover = () => { - document.dispatchEvent(new CustomEvent('basecoat:popover', { - detail: { source: selectComponent } - })); - - popover.setAttribute('aria-hidden', 'false'); - trigger.setAttribute('aria-expanded', 'true'); - - const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]'); - if (selectedOption) { - setActiveOption(options.indexOf(selectedOption)); - scrollOptionIntoListbox(selectedOption); - } - }; - - trigger.addEventListener('click', () => { - const isExpanded = trigger.getAttribute('aria-expanded') === 'true'; - if (isExpanded) { - closePopover(); - } else { - openPopover(); - } - }); - - listbox.addEventListener('click', (event) => { + state.listbox.addEventListener('click', (event) => { const clickedOption = event.target.closest('[role="option"]'); if (!clickedOption) return; - const option = options.find(opt => opt === clickedOption); + const option = state.options.find(opt => opt === clickedOption); if (!option) return; - if (isMultiple) { - toggleMultipleValue(option); - if (closeOnSelect) { - closePopover(); + if (state.isMultiple) { + toggleMultipleValue(root, option); + if (state.closeOnSelect) { + root.close(); } else { - setActiveOption(options.indexOf(option)); - trigger.focus(); + setActiveOption(state, state.options.indexOf(option)); + state.trigger.focus(); } } else { - if (input.value !== getValue(option)) { - updateValue(option); - } - closePopover(); + if (state.input.value !== getValue(option)) updateValue(root, option); + root.close(); } }); document.addEventListener('click', (event) => { - if (!selectComponent.contains(event.target)) { - closePopover(false); - } + if (!root.contains(event.target)) root.close(false); }); document.addEventListener('basecoat:popover', (event) => { - if (event.detail.source !== selectComponent) { - closePopover(false); - } + if (event.detail.source !== root) root.close(false); }); - popover.setAttribute('aria-hidden', 'true'); - - // Public API - Object.defineProperty(selectComponent, 'value', { - get: () => { - if (isMultiple) { - return options.filter(opt => selectedOptions.has(opt)).map(getValue); + Object.defineProperty(root, 'value', { + configurable: true, + get: () => state.isMultiple ? state.options.filter(option => state.selectedOptions.has(option)).map(getValue) : state.input.value, + set: (value) => { + if (state.isMultiple) { + const values = Array.isArray(value) ? value : (value != null ? [value] : []); + updateValue(root, values.map(v => state.options.find(option => getValue(option) === v)).filter(Boolean)); } else { - return input.value; - } - }, - set: (val) => { - if (isMultiple) { - const values = Array.isArray(val) ? val : (val != null ? [val] : []); - const opts = []; - values.forEach(v => { - const opt = options.find(o => getValue(o) === v && !opts.includes(o)); - if (opt) opts.push(opt); - }); - updateValue(opts); - } else { - const option = options.find(opt => getValue(opt) === val); + const option = state.options.find(opt => getValue(opt) === value); if (option) { - updateValue(option); - closePopover(); + updateValue(root, option); + root.close(); } } - } + }, }); - selectComponent.select = select; - selectComponent.selectByValue = select; // Backward compatibility alias - if (isMultiple) { - selectComponent.deselect = deselect; - selectComponent.toggle = toggle; - selectComponent.selectAll = () => updateValue(options); - selectComponent.selectNone = () => updateValue([]); + root.select = (value) => selectValue(root, value); + root.selectByValue = root.select; + if (state.isMultiple) { + root.deselect = (value) => deselectValue(root, value); + root.toggle = (value) => { + const option = state.options.find(opt => getValue(opt) === value); + if (option) toggleMultipleValue(root, option); + }; + root.selectAll = () => updateValue(root, state.options); + root.selectNone = () => updateValue(root, []); } - selectComponent.dataset.selectInitialized = true; - selectComponent.dispatchEvent(new CustomEvent('basecoat:initialized')); + + state.popover.setAttribute('aria-hidden', 'true'); + state.trigger.setAttribute('aria-expanded', 'false'); + root.dataset.selectInitialized = 'true'; + root.dispatchEvent(new CustomEvent('basecoat:initialized')); }; if (window.basecoat) { - window.basecoat.register('select', 'div.select:not([data-select-initialized])', initSelect); + window.basecoat.register('select', { + selector: 'div.select:not([data-select-initialized])', + init: initSelect, + refresh: refreshSelect, + }); } })(); diff --git a/src/js/sidebar.js b/src/js/sidebar.js index fc4a007..7284c5a 100644 --- a/src/js/sidebar.js +++ b/src/js/sidebar.js @@ -1,15 +1,17 @@ (() => { const initSidebar = (sidebarComponent) => { + if (sidebarComponent.dataset.sidebarInitialized) return; + const initialOpen = sidebarComponent.dataset.initialOpen !== 'false'; const initialMobileOpen = sidebarComponent.dataset.initialMobileOpen === 'true'; const breakpoint = parseInt(sidebarComponent.dataset.breakpoint) || 768; - - let open = breakpoint > 0 + + let open = breakpoint > 0 ? (window.innerWidth >= breakpoint ? initialOpen : initialMobileOpen) : initialOpen; - + const updateState = () => { - sidebarComponent.setAttribute('aria-hidden', !open); + sidebarComponent.setAttribute('aria-hidden', String(!open)); if (open) { sidebarComponent.removeAttribute('inert'); } else { @@ -18,48 +20,33 @@ }; const setState = (state) => { - open = state; + open = Boolean(state); updateState(); }; - const sidebarId = sidebarComponent.id; + sidebarComponent.open = () => setState(true); + sidebarComponent.close = () => setState(false); + sidebarComponent.toggle = () => setState(!open); - document.addEventListener('basecoat:sidebar', (event) => { - if (event.detail?.id && event.detail.id !== sidebarId) return; - - switch (event.detail?.action) { - case 'open': - setState(true); - break; - case 'close': - setState(false); - break; - default: - setState(!open); - break; - } - }); - sidebarComponent.addEventListener('click', (event) => { const target = event.target; const nav = sidebarComponent.querySelector('nav'); - const isMobile = window.innerWidth < breakpoint; - - if (isMobile && (target.closest('a, button') && !target.closest('[data-keep-mobile-sidebar-open]'))) { + + if (isMobile && target.closest('a, button') && !target.closest('[data-keep-mobile-sidebar-open]')) { if (document.activeElement) document.activeElement.blur(); - setState(false); + sidebarComponent.close(); return; } - + if (target === sidebarComponent || (nav && !nav.contains(target))) { if (document.activeElement) document.activeElement.blur(); - setState(false); + sidebarComponent.close(); } }); updateState(); - sidebarComponent.dataset.sidebarInitialized = true; + sidebarComponent.dataset.sidebarInitialized = 'true'; sidebarComponent.dispatchEvent(new CustomEvent('basecoat:initialized')); }; diff --git a/src/js/tabs.js b/src/js/tabs.js index 3865800..ead80dd 100644 --- a/src/js/tabs.js +++ b/src/js/tabs.js @@ -1,81 +1,91 @@ (() => { - const initTabs = (tabsComponent) => { - const tablist = tabsComponent.querySelector('[role="tablist"]'); - if (!tablist) return; + const states = new WeakMap(); - const tabs = Array.from(tablist.querySelectorAll('[role="tab"]')); + const isDisabled = (tab) => tab.disabled || tab.getAttribute('aria-disabled') === 'true'; + + const getElements = (root) => { + const tablist = root.querySelector('[role="tablist"]'); + const tabs = tablist ? Array.from(tablist.querySelectorAll('[role="tab"]')) : []; const panels = tabs.map(tab => document.getElementById(tab.getAttribute('aria-controls'))).filter(Boolean); + return { tablist, tabs, panels }; + }; - const isDisabled = (tab) => tab.disabled || tab.getAttribute('aria-disabled') === 'true'; - const getEnabledTabs = () => tabs.filter(tab => !isDisabled(tab)); + const refreshTabs = (root) => { + const state = states.get(root); + if (!state) return; - const selectTab = (tabToSelect) => { - if (isDisabled(tabToSelect)) return; + Object.assign(state, getElements(root)); + if (!state.tablist) return; - tabs.forEach((tab, index) => { + const selected = state.tabs.find(tab => tab.getAttribute('aria-selected') === 'true' && !isDisabled(tab)) + || state.tabs.find(tab => !isDisabled(tab)); + if (selected) root.select(selected, false); + }; + + const initTabs = (root) => { + if (root.dataset.tabsInitialized) return; + + const state = {}; + states.set(root, state); + root.refresh = () => refreshTabs(root); + + const selectTab = (tabToSelect, focus = false) => { + if (!tabToSelect || isDisabled(tabToSelect)) return; + + state.tabs.forEach((tab) => { tab.setAttribute('aria-selected', 'false'); tab.setAttribute('tabindex', '-1'); - if (panels[index]) panels[index].hidden = true; + const panel = document.getElementById(tab.getAttribute('aria-controls')); + if (panel) panel.hidden = true; }); tabToSelect.setAttribute('aria-selected', 'true'); tabToSelect.setAttribute('tabindex', '0'); const activePanel = document.getElementById(tabToSelect.getAttribute('aria-controls')); if (activePanel) activePanel.hidden = false; + if (focus) tabToSelect.focus(); }; - tablist.addEventListener('click', (event) => { + root.select = selectTab; + refreshTabs(root); + if (!state.tablist) return; + + state.tablist.addEventListener('click', (event) => { const clickedTab = event.target.closest('[role="tab"]'); - if (clickedTab) selectTab(clickedTab); + if (clickedTab) root.select(clickedTab); }); - tablist.addEventListener('keydown', (event) => { + state.tablist.addEventListener('keydown', (event) => { const currentTab = event.target; - if (!tabs.includes(currentTab)) return; + if (!state.tabs.includes(currentTab)) return; - let nextTab; - const enabledTabs = getEnabledTabs(); + const enabledTabs = state.tabs.filter(tab => !isDisabled(tab)); const currentIndex = enabledTabs.indexOf(currentTab); - const orientation = tablist.getAttribute('aria-orientation') || 'horizontal'; + const orientation = state.tablist.getAttribute('aria-orientation') || 'horizontal'; if (currentIndex === -1) return; - switch (event.key) { - case 'ArrowRight': - if (orientation !== 'horizontal') return; - nextTab = enabledTabs[(currentIndex + 1) % enabledTabs.length]; - break; - case 'ArrowLeft': - if (orientation !== 'horizontal') return; - nextTab = enabledTabs[(currentIndex - 1 + enabledTabs.length) % enabledTabs.length]; - break; - case 'ArrowDown': - if (orientation !== 'vertical') return; - nextTab = enabledTabs[(currentIndex + 1) % enabledTabs.length]; - break; - case 'ArrowUp': - if (orientation !== 'vertical') return; - nextTab = enabledTabs[(currentIndex - 1 + enabledTabs.length) % enabledTabs.length]; - break; - case 'Home': - nextTab = enabledTabs[0]; - break; - case 'End': - nextTab = enabledTabs[enabledTabs.length - 1]; - break; - default: - return; - } + let nextTab; + if (event.key === 'ArrowRight' && orientation === 'horizontal') nextTab = enabledTabs[(currentIndex + 1) % enabledTabs.length]; + if (event.key === 'ArrowLeft' && orientation === 'horizontal') nextTab = enabledTabs[(currentIndex - 1 + enabledTabs.length) % enabledTabs.length]; + if (event.key === 'ArrowDown' && orientation === 'vertical') nextTab = enabledTabs[(currentIndex + 1) % enabledTabs.length]; + if (event.key === 'ArrowUp' && orientation === 'vertical') nextTab = enabledTabs[(currentIndex - 1 + enabledTabs.length) % enabledTabs.length]; + if (event.key === 'Home') nextTab = enabledTabs[0]; + if (event.key === 'End') nextTab = enabledTabs[enabledTabs.length - 1]; + if (!nextTab) return; event.preventDefault(); - selectTab(nextTab); - nextTab.focus(); + root.select(nextTab, true); }); - - tabsComponent.dataset.tabsInitialized = true; - tabsComponent.dispatchEvent(new CustomEvent('basecoat:initialized')); + + root.dataset.tabsInitialized = 'true'; + root.dispatchEvent(new CustomEvent('basecoat:initialized')); }; if (window.basecoat) { - window.basecoat.register('tabs', '.tabs:not([data-tabs-initialized])', initTabs); + window.basecoat.register('tabs', { + selector: '.tabs:not([data-tabs-initialized])', + init: initTabs, + refresh: refreshTabs, + }); } })(); diff --git a/src/js/toast.js b/src/js/toast.js index 511b069..a3264da 100644 --- a/src/js/toast.js +++ b/src/js/toast.js @@ -23,6 +23,16 @@ } }); + toasterElement.toast = (config = {}) => { + const toastElement = createToast(config); + toasterElement.appendChild(toastElement); + initToast(toastElement); + return toastElement; + }; + toasterElement.closeAll = () => { + toasterElement.querySelectorAll('.toast:not([aria-hidden="true"])').forEach(closeToast); + }; + toaster.querySelectorAll('.toast:not([data-toast-initialized])').forEach(initToast); toaster.dataset.toasterInitialized = 'true'; toaster.dispatchEvent(new CustomEvent('basecoat:initialized')); @@ -35,13 +45,13 @@ const timeoutDuration = duration !== -1 ? duration || (element.dataset.category === 'error' ? 5000 : 3000) : -1; - + const state = { remainingTime: timeoutDuration, timeoutId: null, startTime: null, }; - + if (timeoutDuration !== -1) { if (isPaused) { state.timeoutId = null; @@ -51,18 +61,19 @@ } } toasts.set(element, state); - + + element.close = () => closeToast(element); element.dataset.toastInitialized = 'true'; } function pauseAllTimeouts() { - if (isPaused) return; + if (isPaused || !toaster) return; isPaused = true; - + toaster.querySelectorAll('.toast:not([aria-hidden="true"])').forEach(element => { if (!toasts.has(element)) return; - + const state = toasts.get(element); if (state.timeoutId) { clearTimeout(state.timeoutId); @@ -73,7 +84,7 @@ } function resumeAllTimeouts() { - if (!isPaused) return; + if (!isPaused || !toaster) return; isPaused = false; @@ -93,28 +104,17 @@ } function closeToast(element) { - if (!toasts.has(element)) return; + if (!element || !toasts.has(element)) return; const state = toasts.get(element); clearTimeout(state.timeoutId); toasts.delete(element); - + if (element.contains(document.activeElement)) document.activeElement.blur(); element.setAttribute('aria-hidden', 'true'); element.addEventListener('transitionend', () => element.remove(), { once: true }); } - function executeAction(button, toast) { - const actionString = button.dataset.toastAction; - if (!actionString) return; - try { - const func = new Function('close', actionString); - func(() => closeToast(toast)); - } catch (event) { - console.error('Error executing toast action:', event); - } - } - function createToast(config) { const { category = 'info', @@ -135,7 +135,7 @@ ? `` : ''; const cancelHtml = cancel - ? `` + ? `` : ''; const footerHtml = actionHtml || cancelHtml ? `` : ''; @@ -155,7 +155,6 @@ ${descriptionHtml} ${footerHtml} -
`; @@ -164,18 +163,8 @@ return template.content.firstChild; } - document.addEventListener('basecoat:toast', (event) => { - if (!toaster) { - console.error('Cannot create toast: toaster container not found on page.'); - return; - } - const config = event.detail?.config || {}; - const toastElement = createToast(config); - toaster.appendChild(toastElement); - }); - if (window.basecoat) { window.basecoat.register('toaster', '#toaster:not([data-toaster-initialized])', initToaster); window.basecoat.register('toast', '.toast:not([data-toast-initialized])', initToast); } -})(); \ No newline at end of file +})();