Fix sidebar nested item styling

This commit is contained in:
Ronan Berder
2026-06-06 16:41:18 +08:00
parent 1509cdadef
commit 2e8daaef03
45 changed files with 55860 additions and 1338 deletions
+23
View File
@@ -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();
});
```
+21
View File
@@ -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",
+21 -34
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
{# Dark mode #}
<script>
@@ -12,17 +12,6 @@
}
} catch (_) {}
const apply = dark => {
document.documentElement.classList.toggle('dark', dark);
try { localStorage.setItem('themeMode', dark ? 'dark' : 'light'); } catch (_) {}
};
document.addEventListener('basecoat:theme', (event) => {
const mode = event.detail?.mode;
apply(mode === 'dark' ? true
: mode === 'light' ? false
: !document.documentElement.classList.contains('dark'));
});
})();
</script>
{# Theme variant #}
@@ -36,17 +25,6 @@
}
})();
</script>
{# Style variant #}
<script>
(function() {
try {
const storedStyle = localStorage.getItem('styleVariant') || 'nova';
document.documentElement.dataset.styleVariant = storedStyle;
} catch (event) {
console.error('Could not apply style variant from localStorage', event);
}
})();
</script>
{# Meta #}
<meta charset="utf-8">
<meta http-equiv="Content-Language" content="en">
@@ -58,34 +36,43 @@
<meta name="description" content="{{ meta_description }}"/>
<meta name="title" content="{{ meta_title }}">
{% if site.keywords %}<meta name="keywords" content="{{ site.keywords | join(',') }}" />{% endif %}
{% if siteUrl %}<link rel="canonical" href="{{ siteUrl }}{{ page.url }}">{% endif %}
{# Favicon #}
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png">
<link rel="icon" type="image/svg+xml" href="{{ siteUrl }}/assets/favicon.svg">
<link rel="apple-touch-icon" sizes="180x180" href="{{ siteUrl }}/assets/apple-touch-icon.png">
{# Open Graph / Facebook #}
<meta property="og:type" content="website">
<meta property="og:url" content="{{ site.url }}{{ page.url }}">
<meta property="og:url" content="{{ siteUrl }}{{ page.url }}">
<meta property="og:title" content="{{ meta_title }}">
<meta property="og:description" content="{{ meta_description }}">
<meta property="og:image" content="{{ site.url }}/assets/social-screenshot.png">
<meta property="og:image" content="{{ siteUrl }}/assets/social.png">
<meta property="og:site_name" content="{{ site.title }}">
<meta property="og:locale" content="en_US">
<meta property="og:author" content="{{ site.author.name }}">
{# X/Twitter #}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="{{ site.url }}{{ page.url }}">
<meta name="twitter:title" content="{{ title if title else site.title }}">
<meta name="twitter:url" content="{{ siteUrl }}{{ page.url }}">
<meta name="twitter:title" content="{{ meta_title }}">
<meta name="twitter:description" content="{{ meta_description }}">
<meta name="twitter:image" content="{{ site.url }}/assets/social-screenshot.png">
<meta name="twitter:image" content="{{ siteUrl }}/assets/social.png">
<meta name="twitter:creator" content="{{ site.author.x }}">
{# CSS #}
<link rel="stylesheet" id="style-variant-stylesheet" href="/assets/styles-nova.css">
<script>
(() => {
const styleLink = document.getElementById('style-variant-stylesheet');
const styleVariant = document.documentElement.dataset.styleVariant || 'nova';
styleLink.href = `/assets/styles-${styleVariant}.css`;
const styles = new Set(['vega', 'nova', 'maia', 'lyra', 'mira', 'luma', 'sera', 'rhea']);
let styleVariant = 'nova';
try {
const storedStyle = localStorage.getItem('styleVariant');
if (styles.has(storedStyle)) styleVariant = storedStyle;
} catch (event) {
console.error('Could not apply style variant from localStorage', event);
}
document.documentElement.dataset.styleVariant = styleVariant;
document.write(`<link rel="stylesheet" id="style-variant-stylesheet" href="/assets/styles-${styleVariant}.css">`);
})();
</script>
<noscript><link rel="stylesheet" id="style-variant-stylesheet" href="/assets/styles-nova.css"></noscript>
{# JS #}
<script src="/assets/js/basecoat.js?v={{ pkg.version }}" defer></script>
<script src="/assets/js/command.js?v={{ pkg.version }}" defer></script>
+64 -59
View File
@@ -4,69 +4,74 @@ layout: layouts/layout.njk
{% set docsMenu = docs.menu if docs and docs.menu else menu %}
{% set navigation = docsMenu | getNavigation(collections) %}
<main class="mx-auto relative flex w-full max-w-screen-lg gap-10">
<div class="mx-auto w-full flex-1 max-w-screen-md">
<header class="space-y-2">
<div class="flex items-start gap-x-2">
<h1 class="text-2xl font-semibold tracking-tight sm:text-3xl xl:text-4xl mr-auto">{{ title }}</h1>
<div class="flex items-center gap-x-2 pt-1">
<button
type="button"
class="btn-sm-secondary h-7 px-2.5 text-[13px] max-sm:size-7"
data-md-url="{{ page.url | markdownUrl }}"
onclick="copyPageMarkdown(this)"
>
{% lucide "copy" %}
<span class="hidden sm:block">Copy page</span>
</button>
<div role="group" class="button-group">
{% if navigation.prev %}
<a href="{{ navigation.prev.url }}" class="btn-sm-icon-secondary hidden sm:flex size-7">
<span class="sr-only">{{ navigation.prev.label }}</span>
{% lucide "arrow-left" %}
</a>
{% endif %}
{% if navigation.prev and navigation.next %}
<hr role="separator">
{% endif %}
{% if navigation.next %}
<a href="{{ navigation.next.url }}" class="btn-sm-icon-secondary hidden sm:flex size-7">
<span class="sr-only">{{ navigation.next.label }}</span>
{% lucide "arrow-right" %}
</a>
{% endif %}
<div class="mx-auto flex w-full max-w-screen-lg gap-10">
<article class="min-w-0 flex-1 wrap-break-word">
<div class="mx-auto w-full max-w-screen-md">
<header class="space-y-2">
<div class="flex items-start gap-x-2">
<h1 class="text-2xl font-semibold tracking-tight sm:text-3xl xl:text-4xl mr-auto">{{ title }}</h1>
<div class="flex items-center gap-x-2 pt-1">
<button
type="button"
class="btn-sm-secondary h-7 px-2.5 text-[13px] max-sm:size-7"
data-md-url="{{ page.url | markdownUrl }}"
onclick="copyPageMarkdown(this)"
>
{% lucide "copy" %}
<span class="hidden sm:block">Copy page</span>
</button>
<div role="group" class="button-group">
{% if navigation.prev %}
<a href="{{ navigation.prev.url }}" class="btn-sm-icon-secondary hidden sm:flex size-7">
<span class="sr-only">{{ navigation.prev.label }}</span>
{% lucide "arrow-left" %}
</a>
{% endif %}
{% if navigation.prev and navigation.next %}
<hr role="separator">
{% endif %}
{% if navigation.next %}
<a href="{{ navigation.next.url }}" class="btn-sm-icon-secondary hidden sm:flex size-7">
<span class="sr-only">{{ navigation.next.label }}</span>
{% lucide "arrow-right" %}
</a>
{% endif %}
</div>
</div>
</div>
<p class="text-muted-foreground text-[1.05rem] sm:text-base">{{ description }}</p>
</header>
<div class="pb-12 mt-8 content">
{{ content | safe }}
</div>
<p class="text-muted-foreground text-[1.05rem] sm:text-base">{{ description }}</p>
</header>
<article class="pb-12 mt-8 content">
{{ content | safe }}
</article>
{% if navigation.prev or navigation.next %}
<footer class="flex items-center gap-2 border-t pt-6 mt-12">
{% if navigation.prev %}
<a href="{{ navigation.prev.url }}" class="btn-sm-secondary mr-auto">
{% lucide "arrow-left" %}
{{ navigation.prev.label }}
</a>
{% endif %}
{% if navigation.next %}
<a href="{{ navigation.next.url }}" class="btn-sm-secondary ml-auto">
{{ navigation.next.label }}
{% lucide "arrow-right" %}
</a>
{% endif %}
</footer>
{% endif %}
</div>
{% if toc %}
<div class="hidden text-sm xl:block w-full max-w-[300px]">
{% from "macros/toc.njk" import toc_nav %}
{{ toc_nav(toc) }}
{% if navigation.prev or navigation.next %}
<footer class="flex items-center gap-2 border-t pt-6 mt-12">
{% if navigation.prev %}
<a href="{{ navigation.prev.url }}" class="btn-sm-secondary mr-auto">
{% lucide "arrow-left" %}
{{ navigation.prev.label }}
</a>
{% endif %}
{% if navigation.next %}
<a href="{{ navigation.next.url }}" class="btn-sm-secondary ml-auto">
{{ navigation.next.label }}
{% lucide "arrow-right" %}
</a>
{% endif %}
</footer>
{% endif %}
</div>
{% endif %}
</main>
</article>
<aside class="hidden xl:block w-56 shrink-0">
<div class="sticky top-20 text-sm [&_a]:text-muted-foreground [&_a]:hover:text-primary space-y-2 [&_ol]:space-y-2 [&_ol_ol]:pl-3 [&_ol_ol]:mt-2">
<h2 class="font-medium flex items-center gap-1">
{% lucide "table-of-contents", { class: "size-4" } %}
On this page
</h2>
{{ content | toc | safe }}
</div>
</aside>
</div>
<script>
async function copyPageMarkdown(button) {
+6 -3
View File
@@ -21,7 +21,7 @@
<div class="flex h-14 w-full items-center gap-2 px-4">
<button
type="button"
onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar'))"
onclick="document.getElementById('sidebar')?.toggle()"
aria-label="Toggle sidebar"
data-tooltip="Toggle sidebar"
data-side="bottom"
@@ -83,8 +83,11 @@
const styleLink = document.getElementById('style-variant-stylesheet');
const applyStyle = (styleName) => {
const nextStyle = styleName || 'nova';
const nextHref = `/assets/styles-${nextStyle}.css`;
document.documentElement.dataset.styleVariant = nextStyle;
if (styleLink) styleLink.href = `/assets/styles-${nextStyle}.css`;
if (styleLink && styleLink.getAttribute('href') !== nextHref) {
styleLink.href = nextHref;
}
};
const storedStyle = localStorage.getItem('styleVariant') || 'nova';
@@ -104,7 +107,7 @@
aria-label="Toggle dark mode"
data-tooltip="Toggle dark mode"
data-side="bottom"
onclick="document.dispatchEvent(new CustomEvent('basecoat:theme'))"
onclick="window.basecoat?.theme.toggle()"
class="btn-icon-outline size-8"
>
<span class="hidden dark:block">{% lucide "sun" %}</span>
+2 -2
View File
@@ -44,8 +44,8 @@
{% endif %}
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ site.title }}</span>
<span class="truncate text-xs">v{{ pkg.version }}</span>
<span class="truncate font-semibold">{{ site.title }}</span>
<span class="truncate text-xs text-muted-foreground">v{{ pkg.version }}</span>
</div>
</a>
{% endset %}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4
View File
@@ -147,6 +147,10 @@ toc:
<dd>Removes a selected value.</dd>
<dt><code>toggle(value)</code> <span class="badge-secondary">Multiple only</span></dt>
<dd>Toggles a selected value.</dd>
<dt><code>refresh()</code></dt>
<dd>Rescans options after changing children inside the existing <code>role="listbox"</code> element.</dd>
<dt><code>window.basecoat.refresh(combobox)</code></dt>
<dd>Calls the component refresh method through the global dispatcher.</dd>
</dl>
</section>
+6 -2
View File
@@ -18,7 +18,7 @@ toc:
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: JavaScript events
- label: JavaScript API
id: usage-html-js-4
- label: Jinja and Nunjucks
id: usage-macro
@@ -183,12 +183,16 @@ toc:
</dl>
</section>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</a></h4>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript API</a></h4>
<section class="prose">
<dl>
<dt><code>basecoat:initialized</code></dt>
<dd>Once the component is initialized, it dispatches a custom non-bubbling <code>basecoat:initialized</code> event on itself.</dd>
<dt><code>command.refresh()</code></dt>
<dd>Rescans command items after changing children inside the existing <code>role="menu"</code> list.</dd>
<dt><code>window.basecoat.refresh(command)</code></dt>
<dd>Calls the component refresh method through the global dispatcher.</dd>
</dl>
</section>
+12 -2
View File
@@ -16,7 +16,7 @@ toc:
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: JavaScript events
- label: JavaScript API
id: usage-html-js-4
- label: Jinja and Nunjucks
id: usage-macro
@@ -151,7 +151,7 @@ toc:
</dl>
</section>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</a></h4>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript API</a></h4>
<section class="prose">
<dl>
@@ -159,6 +159,16 @@ toc:
<dd>Once the component is initialized, it dispatches a custom non-bubbling <code>basecoat:initialized</code> event on itself.</dd>
<dt><code>basecoat:popover</code></dt>
<dd>When the menu opens, the component dispatches a custom event on <code>document</code>. Other popover-based components listen for this to close any open popovers.</dd>
<dt><code>dropdown.open()</code></dt>
<dd>Opens the menu.</dd>
<dt><code>dropdown.close()</code></dt>
<dd>Closes the menu.</dd>
<dt><code>dropdown.toggle()</code></dt>
<dd>Toggles the menu.</dd>
<dt><code>dropdown.refresh()</code></dt>
<dd>Rescans menu items after changing children inside the existing <code>role="menu"</code> element.</dd>
<dt><code>window.basecoat.refresh(dropdown)</code></dt>
<dd>Calls the component refresh method through the global dispatcher.</dd>
</dl>
</section>
+1 -1
View File
@@ -123,6 +123,6 @@ toc:
<section class="prose">
<ul>
<li>Use <strong>Native Select</strong> for native browser behavior, better performance, form integration, and mobile-optimized pickers.</li>
<li>Use <strong>Select</strong> for a custom popover, richer option content, multi-select behavior, or custom JavaScript events.</li>
<li>Use <strong>Select</strong> for a custom popover, richer option content, multi-select behavior, or custom JavaScript APIs.</li>
</ul>
</section>
+4
View File
@@ -283,6 +283,10 @@ toc:
<dd>
<p><strong>Deprecated:</strong> Alias for <code>select(value)</code>. Kept for backward compatibility.</p>
</dd>
<dt><code>refresh()</code></dt>
<dd>Rescans options after changing children inside the existing <code>role="listbox"</code> element.</dd>
<dt><code>window.basecoat.refresh(selectComponent)</code></dt>
<dd>Calls the component refresh method through the global dispatcher.</dd>
</dl>
</section>
+20 -17
View File
@@ -18,7 +18,7 @@ toc:
id: usage-html-js-3
- label: Basecoat differences
id: usage-html-js-4
- label: JavaScript events
- label: JavaScript API
id: usage-html-js-5
- label: Jinja and Nunjucks
id: usage-macro
@@ -82,7 +82,7 @@ toc:
menu=menu
) }}
<main>
<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar'))">Toggle sidebar</button>
<button type="button" onclick="document.getElementById('sidebar')?.toggle()">Toggle sidebar</button>
<h1>Content</h1>
</main>
{% endset %}
@@ -95,7 +95,7 @@ toc:
<dt><code class="highlight language-html">&lt;aside class="sidebar" aria-hidden="false"&gt;</code></dt>
<dd>Wraps the sidebar component. Supported attributes:
<ul>
<li><code>id</code>: target a specific sidebar from the <code>basecoat:sidebar</code> event.</li>
<li><code>id</code>: target a specific sidebar from JavaScript methods.</li>
<li><code>aria-hidden</code>: current open state. <code>false</code> means visible, <code>true</code> means hidden.</li>
<li><code>data-side</code>: physical side of the viewport. Use <code>left</code> or <code>right</code>. Defaults to <code>left</code>.</li>
<li><code>data-initial-open</code>: set to <code>false</code> to start closed on desktop.</li>
@@ -123,8 +123,8 @@ toc:
<dd>Bottom area for account controls, settings, or secondary actions.</dd>
<dt><code class="highlight language-html">&lt;main&gt;</code></dt>
<dd>The sibling content wrapper. Basecoat applies the desktop margin to the sibling that follows the sidebar.</dd>
<dt><code class="highlight language-html">&lt;button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar'))"&gt;</code></dt>
<dd>Dispatches the event used to toggle, open, or close the sidebar.</dd>
<dt><code class="highlight language-html">&lt;button type="button" onclick="document.getElementById('sidebar')?.toggle()"&gt;</code></dt>
<dd>Calls the sidebar method used to toggle, open, or close the sidebar.</dd>
</dl>
</section>
@@ -146,26 +146,29 @@ toc:
<p>RTL content is supported through logical spacing and borders. The <code>data-side</code> value remains physical: <code>left</code> means the left side of the viewport and <code>right</code> means the right side.</p>
</section>
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript events</a></h4>
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript API</a></h4>
<section class="prose">
<dl>
<dt><code>basecoat:initialized</code></dt>
<dd>Once the component is fully initialized, it dispatches a custom (non-bubbling) <code>basecoat:initialized</code> event on itself.</dd>
<dt><code>basecoat:sidebar</code></dt>
<dd>
<p>Sidebars listen for this event on <code>document</code> to open, close or toggle themselves. By default, the event will toggle the sidebar, but can be used to open or close if you add an <code>action</code> to the detail. Additionally, if you have multiple sidebars on the page, you can target a specific sidebar by adding its <code>id</code> to the detail:</p>
{% set code_trigger %}<!-- Toggles the sidebar -->
<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar'));">Toggle sidebar</button>
<!-- Opens the `#main-navigation` sidebar -->
<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar', { detail: { id: 'main-navigation', action: 'open' } }));">Open sidebar</button>
<!-- Closes the sidebar -->
<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar', { detail: { action: 'close' } }));">Close sidebar</button>{% endset %}
{{ code_block(code_trigger | prettyHtml, "html") }}
</dd>
<dt><code>sidebar.open()</code></dt>
<dd>Opens the specific sidebar element.</dd>
<dt><code>sidebar.close()</code></dt>
<dd>Closes the specific sidebar element.</dd>
<dt><code>sidebar.toggle()</code></dt>
<dd>Toggles the specific sidebar element.</dd>
</dl>
</section>
{% set code_trigger %}<!-- Toggles the sidebar -->
<button type="button" onclick="document.getElementById('sidebar')?.toggle()">Toggle sidebar</button>
<!-- Opens the `#main-navigation` sidebar -->
<button type="button" onclick="document.getElementById('main-navigation')?.open()">Open sidebar</button>
<!-- Closes the sidebar -->
<button type="button" onclick="document.getElementById('sidebar')?.close()">Close sidebar</button>{% endset %}
{{ code_block(code_trigger | prettyHtml, "html") }}
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
<div class="prose">
+8 -2
View File
@@ -16,7 +16,7 @@ toc:
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: JavaScript events
- label: JavaScript API
id: usage-html-js-4
- label: Jinja and Nunjucks
id: usage-macro
@@ -159,12 +159,18 @@ toc:
</dl>
</section>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</a></h4>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript API</a></h4>
<section class="prose">
<dl>
<dt><code>basecoat:initialized</code></dt>
<dd>Once the component is initialized, it dispatches a custom non-bubbling <code>basecoat:initialized</code> event on itself.</dd>
<dt><code>tabs.select(tab)</code></dt>
<dd>Selects a tab button in the tablist.</dd>
<dt><code>tabs.refresh()</code></dt>
<dd>Rescans tabs and panels after changing children inside the existing tablist/panel structure.</dd>
<dt><code>window.basecoat.refresh(tabs)</code></dt>
<dd>Calls the component refresh method through the global dispatcher.</dd>
</dl>
</section>
+28 -51
View File
@@ -2,7 +2,7 @@
templateEngineOverride: njk
layout: layouts/page.njk
title: Theme Switcher
description: A component that allows the user to switch between light and dark mode.
description: A simple button pattern for switching between light and dark mode.
toc:
- label: Usage
id: usage
@@ -10,48 +10,47 @@ toc:
- label: HTML + JavaScript
id: usage-html-js
children:
- label: "Step 1: Include the JavaScript script"
- label: "Step 1: Avoid the initial flash"
id: usage-html-js-1
- label: "Step 2: Add your switcher"
id: usage-html-js-2
- label: JavaScript events
- label: JavaScript API
id: usage-html-js-3
---
<div class="alert mb-6">
{% lucide "circle-alert" %}
<h2>There is no dedicated theme switcher component in Basecoat.</h2>
<section><p>Instead, we offer a short JavaScript <code>&lt;script&gt;</code> block you can insert in the <code>&lt;head&gt;</code> of your page.</p></section>
<section><p>Theme switching is handled by <code>window.basecoat.theme</code> and a small inline script that applies the stored mode before styles render.</p></section>
</div>
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
{% set code %}<button
{% set code_switcher %}<button
type="button"
aria-label="Toggle dark mode"
data-tooltip="Toggle dark mode"
data-side="bottom"
onclick="document.dispatchEvent(new CustomEvent('basecoat:theme'))"
onclick="window.basecoat.theme.toggle()"
class="btn-icon-outline size-8"
>
<span class="hidden dark:block">{% lucide "sun" %}</span>
<span class="block dark:hidden">{% lucide "moon" %}</span>
</button>{% endset %}
{{ code_preview("button", code) }}
{{ code_preview("button", code_switcher) }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<h3 id="usage-html-js"><a href="#usage-html-js">HTML + JavaScript</a></h3>
<h4 id="usage-html-js-1"><a href="#usage-html-js-1">Step 1: Include the JavaScript script</a></h4>
<h4 id="usage-html-js-1"><a href="#usage-html-js-1">Step 1: Avoid the initial flash</a></h4>
<section class="prose">
<p>Insert the following JavaScript block right at the beginning of the <code>&lt;head&gt;</code> your page. We do this instead of using a separate file to ensure the theme is defined before the page loads, avoiding a flash of unstyled or mist-styled content.</p>
<p>Add this inline script before loading your styles. It applies the stored mode before the page renders.</p>
</section>
{% set code %}<script>
{% set code_head %}<script>
(() => {
try {
const stored = localStorage.getItem('themeMode');
@@ -60,53 +59,31 @@ toc:
document.documentElement.classList.add('dark');
}
} catch (_) {}
const apply = dark => {
document.documentElement.classList.toggle('dark', dark);
try { localStorage.setItem('themeMode', dark ? 'dark' : 'light'); } catch (_) {}
};
document.addEventListener('basecoat:theme', (event) => {
const mode = event.detail?.mode;
apply(mode === 'dark' ? true
: mode === 'light' ? false
: !document.documentElement.classList.contains('dark'));
});
})();
</script>{% endset %}
{{ code_block(code) }}
{{ code_block(code_head | prettyHtml, "html") }}
<section class="prose">
<p>Load <code>basecoat.js</code> or the bundled JavaScript before calling <code>window.basecoat.theme</code>.</p>
</section>
<h4 id="usage-html-js-2"><a href="#usage-html-js-2">Step 2: Add your switcher</a></h4>
<section class="prose">
<p>To change the theme, you need to dispatch a <code>basecoat:theme</code> event:</p>
</section>
{{ code_block(code_switcher | prettyHtml, "html") }}
{% set code %}<button
type="button"
aria-label="Toggle dark mode"
data-tooltip="Toggle dark mode"
data-side="bottom"
onclick="document.dispatchEvent(new CustomEvent('basecoat:theme'))"
class="btn-icon-outline size-8"
>
<span class="hidden dark:block">{% lucide "sun" %}</span>
<span class="block dark:hidden">{% lucide "moon" %}</span>
</button>{% endset %}
{{ code_block(code) }}
<h4 id="usage-html-js-3"><a href="#usage-html-js-3">JavaScript events</a></h4>
<h4 id="usage-html-js-3"><a href="#usage-html-js-3">JavaScript API</a></h4>
<section class="prose">
<dl>
<dt><code>basecoat:theme</code></dt>
<dd>
<p>By default, the event will toggle the theme, but you can also add a <code>mode</code> to the detail to set the theme explicitly:</p>
{% set code_trigger %}<!-- Toggles the theme -->
<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:theme'));">Toggle theme</button>
<!-- Sets the theme to dark -->
<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:theme', { detail: { mode: 'dark' } }));">Set dark theme</button>{% endset %}
{{ code_block(code_trigger | prettyHtml, "html") }}
</dd>
<dt><code>window.basecoat.theme.get()</code></dt>
<dd>Returns <code>"dark"</code> or <code>"light"</code>.</dd>
<dt><code>window.basecoat.theme.set(mode)</code></dt>
<dd>Sets the mode to <code>"dark"</code> or <code>"light"</code> and stores it in <code>localStorage.themeMode</code>.</dd>
<dt><code>window.basecoat.theme.toggle()</code></dt>
<dd>Toggles between dark and light mode.</dd>
</dl>
</section>
</section>
{% set code_methods %}<button type="button" onclick="window.basecoat.theme.toggle()">Toggle theme</button>
<button type="button" onclick="window.basecoat.theme.set('dark')">Set dark theme</button>{% endset %}
{{ code_block(code_methods | prettyHtml, "html") }}
+18 -20
View File
@@ -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 %}<button
class="btn-outline"
onclick="document.dispatchEvent(new CustomEvent('basecoat:toast', {
detail: {
config: {
category: 'success',
title: 'Success',
description: 'A success toast called from the front-end.',
cancel: {
label: 'Dismiss'
}
}
onclick="document.getElementById('toaster').toast({
category: 'success',
title: 'Success',
description: 'A success toast called from the front-end.',
cancel: {
label: 'Dismiss'
}
}))"
})"
>
Toast from front-end
</button>{% endset %}
@@ -106,7 +102,7 @@ toc:
<h4 id="usage-html-js-3"><a href="#usage-html-js-3">Step 3: Add your toasts</a></h4>
<section class="prose">
<p>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:</p>
<p>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:</p>
</section>
{% set code_toast_html %}<div id="toaster" class="toaster">
@@ -123,14 +119,14 @@ toc:
{{ code_block(code_toast_html | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="#usage-html-js-5">
<a class="badge-outline" href="#usage-html-js-4">
HTML structure
{% lucide "arrow-right" %}
</a>
</div>
<section class="prose">
<p>If you need to create a toast from the front-end, you can dispatch a custom <code>basecoat:toast</code> event as such:</p>
<p>If you need to create a toast from the front-end, call the toaster element's <code>toast(config)</code> method:</p>
</section>
{{ code_block(code_client_side | prettyHtml, "html") }}
@@ -181,16 +177,18 @@ toc:
</dl>
</section>
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript events</a></h4>
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript API</a></h4>
<section class="prose">
<dl>
<dt><code>basecoat:initialized</code></dt>
<dd>Once the component is fully initialized, it dispatches a custom (non-bubbling) <code>basecoat:initialized</code> event on the <code>toaster</code> element.</dd>
<dt><code>basecoat:toast</code></dt>
<dd>
<p>The <code>toaster</code> listens for <code>basecoat:toast</code> events on <code>document</code> to create toasts. The event's <code>detail</code> object must contain a <code>config</code> object (see <a href="#usage-html-js-6">JavaScript config object</a> below).</p>
</dd>
<dt><code>toaster.toast(config)</code></dt>
<dd>Creates a toast from a config object and returns the inserted <code>.toast</code> element. See <a href="#usage-html-js-6">Toast config object</a>.</dd>
<dt><code>toaster.closeAll()</code></dt>
<dd>Closes every toast inside the toaster.</dd>
<dt><code>toast.close()</code></dt>
<dd>Closes one initialized toast element.</dd>
</dl>
</section>
+28 -15
View File
@@ -33,20 +33,33 @@ description: A collection of all the components available in Basecoat.
{ label: "Tooltip", id: "tooltip" }
] %}
<div class="flex gap-x-10 justify-center">
<div>
<header class="space-y-2 mb-8">
<h1 class="text-3xl font-semibold tracking-tight">{{ title }}</h1>
<p class="text-muted-foreground">{{ description }}</p>
</header>
<div class="grid gap-4 flex-1">
{% for component in components %}
{% include "partials/kitchen-sink/" ~ component.id ~ ".njk" %}
{% endfor %}
<div class="mx-auto flex w-full max-w-screen-lg gap-10">
<article class="min-w-0 flex-1 wrap-break-word">
<div class="mx-auto w-full max-w-screen-md">
<header class="space-y-2 mb-8">
<h1 class="text-3xl font-semibold tracking-tight">{{ title }}</h1>
<p class="text-muted-foreground">{{ description }}</p>
</header>
<div class="grid gap-4 flex-1">
{% for component in components %}
{% include "partials/kitchen-sink/" ~ component.id ~ ".njk" %}
{% endfor %}
</div>
</div>
</div>
<div class="hidden text-sm xl:block w-full max-w-[200px]">
{% from "macros/toc.njk" import toc_nav %}
{{ toc_nav(components) }}
</div>
</article>
<aside class="hidden xl:block w-56 shrink-0">
<div class="sticky top-20 text-sm [&_a]:text-muted-foreground [&_a]:hover:text-primary space-y-2 [&_ol]:space-y-2">
<h2 class="font-medium flex items-center gap-1">
{% lucide "table-of-contents", { class: "size-4" } %}
On this page
</h2>
<nav class="toc">
<ol>
{% for component in components %}
<li><a href="#{{ component.id }}">{{ component.label }}</a></li>
{% endfor %}
</ol>
</nav>
</div>
</aside>
</div>
+2 -2
View File
@@ -2,6 +2,6 @@
permalink: /robots.txt
eleventyExcludeFromCollections: true
---
Sitemap: {{ site.url }}/sitemap.xml
Sitemap: {{ siteUrl }}/sitemap.xml
User-agent: *
Disallow:
Disallow:
+3 -3
View File
@@ -5,11 +5,11 @@ eleventyExcludeFromCollections: true
<?xml version="1.0" encoding="utf-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{% for page in collections.all %}
{% if not page.url.startsWith('/fragments/') %}
{% if page.url and not page.url.startsWith('/fragments/') %}
<url>
<loc>{{ site.url }}{{ page.url | url }}</loc>
<loc>{{ siteUrl }}{{ page.url | url }}</loc>
<lastmod>{{ page.date.toISOString() }}</lastmod>
</url>
{% endif %}
{% endfor %}
</urlset>
</urlset>
+2 -1
View File
@@ -1,5 +1,6 @@
---
layout: layouts/layout.njk
eleventyExcludeFromCollections: true
---
{% from "dialog.njk" import dialog %}
@@ -36,4 +37,4 @@ footer=footer
]
) }}
</form>
{% endcall %}
{% endcall %}
+21 -11
View File
@@ -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;
}
+21 -11
View File
@@ -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;
}
+21 -11
View File
@@ -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;
}
+21 -11
View File
@@ -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;
}
+21 -11
View File
@@ -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;
}
+21 -11
View File
@@ -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;
}
+21 -11
View File
@@ -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;
}
+21 -11
View File
@@ -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;
}
+4 -3
View File
@@ -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,
};
}
+66 -33
View File
@@ -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();
});
})();
})();
+327 -283
View File
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>';
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 = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>';
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,
});
}
})();
+159 -154
View File
@@ -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,
});
}
})();
+123 -125
View File
@@ -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,
});
}
})();
+294 -318
View File
@@ -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,
});
}
})();
+16 -29
View File
@@ -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'));
};
+59 -49
View File
@@ -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,
});
}
})();
+22 -33
View File
@@ -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 @@
? `<button type="button" class="btn" data-toast-action onclick="${action.onclick}">${action.label}</button>`
: '';
const cancelHtml = cancel
? `<button type="button" class="btn-outline h-6 text-xs px-2.5 rounded-sm" data-toast-cancel onclick="${cancel?.onclick}">${cancel.label}</button>`
? `<button type="button" class="btn-outline h-6 text-xs px-2.5 rounded-sm" data-toast-cancel onclick="${cancel?.onclick || ''}">${cancel.label}</button>`
: '';
const footerHtml = actionHtml || cancelHtml ? `<footer>${actionHtml}${cancelHtml}</footer>` : '';
@@ -155,7 +155,6 @@
${descriptionHtml}
</section>
${footerHtml}
</div>
</div>
</div>
`;
@@ -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);
}
})();
})();