Compare commits

..
15 Commits
33 changed files with 590 additions and 255 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ A collection of modern UI components built with Tailwind CSS that works with any
## Features
- **Lightweight**: no runtime JS, just CSS and tiny Alpine.js scripts for the more interactive components.
- **Lightweight**: no runtime JS, just CSS and a tiny bit of vanilla JavaScript for the more interactive components.
- **Easy to use**: add classes like `btn` or `input` and you're done.
- **Framework-agnostic**: works with any backend or frontend stack.
- **Accessible**: components follow accessibility best practices.
+1 -1
View File
@@ -1,7 +1,7 @@
export default {
title: "Basecoat",
description: "A components library built with Tailwind CSS that works with any web stack.",
keywords: ["components", "component library", "component system", "UI", "UI kit", "shadcn", "shadcn/ui", "Tailwind CSS", "Tailwind", "CSS", "HTML", "Jinja", "Nunjucks", "Alpine.js"],
keywords: ["components", "component library", "component system", "UI", "UI kit", "shadcn", "shadcn/ui", "Tailwind CSS", "Tailwind", "CSS", "HTML", "Jinja", "Nunjucks", "JS", "JavaScript", "vanilla JS", "vanilla JavaScript"],
author: {
name: "Ronan Berder",
x: "@hunvreus"
-2
View File
@@ -75,8 +75,6 @@
<script src="/assets/js/sidebar.js" defer></script>
<script src="/assets/js/tabs.js" defer></script>
<script src="/assets/js/toast.js" defer></script>
{# Alpine.js #}
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
{# HTMX #}
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
{# Highlight.js #}
+15 -20
View File
@@ -1,28 +1,23 @@
{% macro code_block(code, language="html", class="my-4") %}
<div
class="relative {{ class }}"
x-data="{
copied: false,
copy() {
const codeElement = $refs.code;
if (!codeElement) return;
navigator.clipboard.writeText(codeElement.textContent || '').then(() => {
this.copied = true;
setTimeout(() => { this.copied = false }, 2000);
<div class="relative {{ class }}">
<pre class="grid text-sm max-h-[650px] overflow-y-auto rounded-xl scrollbar"><code class="language-{{ language }} !bg-muted !p-3.5">{{- code | escape -}}</code></pre>
<button
onclick="
const button = this;
const code = this.parentElement.querySelector('pre code');
if (!code) return;
navigator.clipboard.writeText(code.textContent || '').then(() => {
button.classList.add('copied');
setTimeout(() => { button.classList.remove('copied') }, 2000);
}).catch(err => {
console.error('Failed to copy text: ', err);
});
}
}"
>
<pre x-ref="code" class="grid text-sm max-h-[650px] overflow-y-auto rounded-xl scrollbar"><code class="language-{{ language }} !bg-muted !p-3.5">{{- code | escape -}}</code></pre>
<button
@click="copy()"
class="btn-icon-ghost size-8 absolute right-2.5 top-2 text-muted-foreground hover:text-foreground"
"
class="btn-icon-ghost size-8 absolute right-2.5 top-2 text-muted-foreground hover:text-foreground group"
>
{% lucide "clipboard", {"x-show": "!copied" } %}
{% lucide "check", {"x-show": "copied" } %}
{% lucide "clipboard", {"class": "group-[.copied]:hidden" } %}
{% lucide "check", {"class": "hidden group-[.copied]:block" } %}
</button>
</div>
{% endmacro %}
@@ -7,20 +7,7 @@
</header>
<div class="p-4">
<div class="group grid w-full max-w-xl gap-4">
<section
x-init
@click="
const clickedSummary = $event.target.closest('summary');
if (!clickedSummary) return;
const clickedDetails = clickedSummary.closest('details');
if (!clickedDetails) return;
$el.querySelectorAll('details').forEach(detailsEl => {
if (detailsEl !== clickedDetails) {
detailsEl.removeAttribute('open');
}
});
"
>
<section class="accordion">
<details class="group border-b last:border-b-0">
<summary class="w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] transition-all outline-none rounded-md">
<h2 class="flex flex-1 items-start justify-between gap-4 py-4 text-left text-sm font-medium hover:underline ">
@@ -55,20 +42,7 @@
</section>
</details>
</section>
<section
x-init
@click="
const clickedSummary = $event.target.closest('summary');
if (!clickedSummary) return;
const clickedDetails = clickedSummary.closest('details');
if (!clickedDetails) return;
$el.querySelectorAll('details').forEach(detailsEl => {
if (detailsEl !== clickedDetails) {
detailsEl.removeAttribute('open');
}
});
"
>
<section class="accordion">
<details class="group border-b last:border-b-0">
<summary class="w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] transition-all outline-none rounded-md">
<h2 class="flex flex-1 items-start justify-between gap-4 py-4 text-left text-sm font-medium hover:underline ">
@@ -92,6 +66,24 @@
</section>
</details>
</section>
<script>
(() => {
const accordions = document.querySelectorAll('.accordion');
accordions.forEach(accordion => {
accordion.addEventListener('click', (event) => {
const summary = event.target.closest('summary');
if (!summary) return;
const details = summary.closest('details');
if (!details) return;
accordion.querySelectorAll('details').forEach(detailsEl => {
if (detailsEl !== details) {
detailsEl.removeAttribute('open');
}
});
});
});
})();
</script>
</div>
</div>
</section>
@@ -9,8 +9,8 @@
</header>
<div class="p-4">
{% set footer %}
<button class="btn-outline" @click="open = false">Cancel</button>
<button class="btn-primary" @click="open = false">Continue</button>
<button class="btn-outline" onclick="document.getElementById('alert-dialog-demo').close()">Cancel</button>
<button class="btn-primary" onclick="document.getElementById('alert-dialog-demo').close()">Continue</button>
{% endset %}
{{ dialog(
id="alert-dialog-demo",
@@ -10,8 +10,8 @@
<div class="p-4">
<div class="flex flex-wrap items-center gap-4">
{% set footer %}
<button class="btn-outline" @click="open = false">Cancel</button>
<button class="btn" @click="open = false">Save changes</button>
<button class="btn-outline" onclick="document.getElementById('demo-dialog-edit-profile').close()">Cancel</button>
<button class="btn" onclick="document.getElementById('demo-dialog-edit-profile').close()">Save changes</button>
{% endset %}
{% call dialog(
id="demo-dialog-edit-profile",
@@ -76,9 +76,6 @@
role="menuitemcheckbox"
aria-checked="true"
class="group"
x-data="{ checked: true }"
@click="checked = !checked"
:aria-checked="checked"
>
{% lucide "check", {
"class": "invisible group-aria-checked:visible",
@@ -93,9 +90,6 @@
aria-checked="false"
class="group"
aria-disabled="true"
x-data="{ checked: false }"
@click="checked = !checked"
:aria-checked="checked"
>
{% lucide "check", {
"class": "invisible group-aria-checked:visible",
@@ -109,9 +103,6 @@
role="menuitemcheckbox"
aria-checked="false"
class="group"
x-data="{ checked: false }"
@click="checked = !checked"
:aria-checked="checked"
>
{% lucide "check", {
"class": "invisible group-aria-checked:visible",
@@ -130,6 +121,19 @@
</div>
{% endcall %}
<script>
(() => {
const dropdownMenu = document.querySelector('#dropdown-menu-checkboxes');
const checkboxes = dropdownMenu.querySelectorAll('div[role="menuitemcheckbox"]');
checkboxes.forEach(checkbox => {
checkbox.addEventListener('click', () => {
const isChecked = checkbox.getAttribute('aria-checked') === 'true';
checkbox.setAttribute('aria-checked', !isChecked);
});
});
})();
</script>
{% set profile_menu %}
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<img alt="@hunvreus" src="https://github.com/hunvreus.png" class="size-8 shrink-0 rounded-full" alt="Ronan Berder"/>
@@ -164,19 +168,18 @@
{% endset %}
{% call dropdown_menu(
id="dropdown-menu-radio-group",
trigger="Radio Group",
trigger_attrs={"class": "btn-outline"},
popover_attrs={"class": "min-w-56"}
) %}
<div role="group" aria-labelledby="position-options" x-data="{ checked: 'bottom' }">
<div role="group" aria-labelledby="position-options">
<span id="position-options" role="heading">Panel Position</span>
<hr role="separator">
<div
role="menuitemradio"
aria-checked="false"
class="group"
@click="checked = 'top'"
:aria-checked="checked == 'top'"
>
<div class="size-4 flex items-center justify-center">
<div
@@ -190,10 +193,8 @@
</div>
<div
role="menuitemradio"
aria-checked="false"
aria-checked="true"
class="group"
@click="checked = 'bottom'"
:aria-checked="checked == 'bottom'"
>
<div class="size-4 flex items-center justify-center">
<div
@@ -209,8 +210,6 @@
role="menuitemradio"
aria-checked="false"
class="group"
@click="checked = 'right'"
:aria-checked="checked == 'right'"
>
<div class="size-4 flex items-center justify-center">
<div
@@ -224,6 +223,20 @@
</div>
</div>
{% endcall %}
<script>
(() => {
const dropdownMenu = document.querySelector('#dropdown-menu-radio-group');
const radioButtons = dropdownMenu.querySelectorAll('div[role="menuitemradio"]');
radioButtons.forEach(radioButton => {
radioButton.addEventListener('click', () => {
radioButtons.forEach(radioButton => {
radioButton.setAttribute('aria-checked', 'false');
});
radioButton.setAttribute('aria-checked', 'true');
});
});
})();
</script>
{% set trigger %}
<img alt="@hunvreus" src="https://github.com/hunvreus.png" class="size-8 shrink-0 rounded-full" alt="Ronan Berder"/>
@@ -19,8 +19,8 @@
</section>
<script>
(() => {
const sliderEl = document.querySelector('input[type="range"].input');
if (!sliderEl) return;
const sliders = document.querySelectorAll('input[type="range"].input');
if (!sliders) return;
const updateSlider = (el) => {
const min = parseFloat(el.min || 0);
@@ -30,7 +30,9 @@
el.style.setProperty('--slider-value', `${percent}%`);
};
updateSlider(sliderEl);
sliderEl.addEventListener('input', (event) => updateSlider(event.target));
sliders.forEach(slider => {
updateSlider(slider);
slider.addEventListener('input', (event) => updateSlider(event.target));
});
})();
</script>
+3 -10
View File
@@ -17,16 +17,8 @@
"data-keep-mobile-sidebar-open": ""
},
popover_attrs={
"data-side": "right",
"data-align": "end",
"class": "w-80",
"x-init": "
if (window.innerWidth < 768) {
$el.setAttribute('data-side', 'top');
$el.setAttribute('data-align', 'start');
$el.classList.add('w-[271px]');
}
"
"data-side": "top",
"class": "w-[271px] md:w-[239px]"
}
) %}
<div class="grid gap-4">
@@ -113,6 +105,7 @@
{ label: "Table", url: "/components/table", attrs: link_attrs },
{ label: "Tabs", url: "/components/tabs", attrs: link_attrs },
{ label: "Textarea", url: "/components/textarea", attrs: link_attrs },
{ label: "Theme Switcher", url: "/components/theme-switcher", attrs: link_attrs },
{ label: "Toast", url: "/components/toast", attrs: link_attrs },
{ label: "Tooltip", url: "/components/tooltip", attrs: link_attrs }
]}
+80 -13
View File
@@ -550,6 +550,9 @@
.flex-1 {
flex: 1;
}
.flex-shrink {
flex-shrink: 1;
}
.shrink-0 {
flex-shrink: 0;
}
@@ -565,6 +568,9 @@
--tw-translate-y: calc(var(--spacing) * 0.5);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
.animate-pulse {
animation: var(--animate-pulse);
}
@@ -1015,6 +1021,11 @@
.filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
.transition {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.transition-all {
transition-property: all;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -1056,6 +1067,16 @@
visibility: visible;
}
}
.group-\[\.copied\]\:block {
&:is(:where(.group):is(.copied) *) {
display: block;
}
}
.group-\[\.copied\]\:hidden {
&:is(:where(.group):is(.copied) *) {
display: none;
}
}
.peer-checked\:border-primary {
&:is(:where(.peer):checked ~ *) {
border-color: var(--color-primary);
@@ -1273,6 +1294,11 @@
margin-left: calc(var(--spacing) * 4);
}
}
.md\:w-\[239px\] {
@media (width >= 48rem) {
width: 239px;
}
}
.md\:max-w-\[200px\] {
@media (width >= 48rem) {
max-width: 200px;
@@ -2554,16 +2580,6 @@
color: var(--color-muted-foreground);
}
&:not([aria-disabled='true']) {
&:hover {
@media (hover: hover) {
background-color: var(--color-accent);
}
}
&:hover {
@media (hover: hover) {
color: var(--color-accent-foreground);
}
}
&:focus-visible {
background-color: var(--color-accent);
}
@@ -2591,6 +2607,20 @@
border-color: var(--color-border);
}
}
&:not([data-dropdown-menu-initialized]) [data-popover] {
[role='menuitem'], [role='menuitemcheckbox'], [role='menuitemradio'] {
&:hover {
@media (hover: hover) {
background-color: var(--color-accent);
}
}
&:hover {
@media (hover: hover) {
color: var(--color-accent-foreground);
}
}
}
}
}
}
@layer components {
@@ -3167,6 +3197,10 @@
background-position: center right 0.5rem;
background-repeat: no-repeat;
}
&.active, &:focus-visible {
background-color: var(--color-accent);
color: var(--color-accent-foreground);
}
}
[role='listbox'] [role='heading'] {
display: flex;
@@ -3249,9 +3283,17 @@
content: var(--tw-content);
}
}
[role='option']:hover, &[data-select-initialized] [role='option'].active, &[data-select-initialized] [role='option']:focus-visible {
background-color: var(--color-accent);
color: var(--color-accent-foreground);
&:not([data-select-initialized]) [data-popover] [role='option'] {
&:hover {
@media (hover: hover) {
background-color: var(--color-accent);
}
}
&:hover {
@media (hover: hover) {
color: var(--color-accent-foreground);
}
}
}
}
}
@@ -4842,6 +4884,26 @@ code.hljs {
inherits: false;
initial-value: 0;
}
@property --tw-rotate-x {
syntax: "*";
inherits: false;
}
@property --tw-rotate-y {
syntax: "*";
inherits: false;
}
@property --tw-rotate-z {
syntax: "*";
inherits: false;
}
@property --tw-skew-x {
syntax: "*";
inherits: false;
}
@property --tw-skew-y {
syntax: "*";
inherits: false;
}
@property --tw-space-y-reverse {
syntax: "*";
inherits: false;
@@ -5056,6 +5118,11 @@ code.hljs {
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-translate-z: 0;
--tw-rotate-x: initial;
--tw-rotate-y: initial;
--tw-rotate-z: initial;
--tw-skew-x: initial;
--tw-skew-y: initial;
--tw-space-y-reverse: 0;
--tw-space-x-reverse: 0;
--tw-border-style: solid;
+19 -14
View File
@@ -14,20 +14,7 @@ toc:
<h2>There is no dedicated accordion component in Basecoat.</h2>
</div>
{% set code %}<section
x-init
@click="
const clickedSummary = $event.target.closest('summary');
if (!clickedSummary) return;
const clickedDetails = clickedSummary.closest('details');
if (!clickedDetails) return;
$el.querySelectorAll('details').forEach(detailsEl => {
if (detailsEl !== clickedDetails) {
detailsEl.removeAttribute('open');
}
});
"
>
{% set code %}<section class="accordion">
<details class="group border-b last:border-b-0 ">
<summary class="w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] transition-all outline-none rounded-md">
<h2 class="flex flex-1 items-start justify-between gap-4 py-4 text-left text-sm font-medium hover:underline ">
@@ -62,6 +49,24 @@ toc:
</section>
</details>
</section>
<script>
(() => {
const accordions = document.querySelectorAll('.accordion');
accordions.forEach(accordion => {
accordion.addEventListener('click', (event) => {
const summary = event.target.closest('summary');
if (!summary) return;
const details = summary.closest('details');
if (!details) return;
accordion.querySelectorAll('details').forEach(detailsEl => {
if (detailsEl !== details) {
detailsEl.removeAttribute('open');
}
});
});
});
})();
</script>
{% endset %}
{{ code_preview("accordion", code | prettyHtml, "w-full max-w-md") }}
+36
View File
@@ -15,6 +15,10 @@ toc:
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: JavaScript events
id: usage-html-js-4
- label: JavaScript methods
id: usage-html-js-5
- label: Jinja and Nunjucks
id: usage-macro
- label: Examples
@@ -130,6 +134,38 @@ toc:
</dl>
</section>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</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:popover</code></dt>
<dd>When the popover opens, the component dispatches a custom (non-bubbling) <code>basecoat:popover</code> event on <code>document</code>. Other popover components (Combobox, Dropdown Menu, Popover and Select) listen for this to close any open popovers.</dd>
<dt><code>change</code></dt>
<dd>When the selected value changes, the component dispatches a custom (bubbling) <code>change</code> event on itself, with the selected value in <code>event.detail</code> (e.g. <code>{detail: {value: "something"}}</code>).</dd>
</dl>
</section>
<h4 id="usage-html-js-5"><a href="#usage-html-js-4">JavaScript methods</a></h4>
<section class="prose">
<dl>
<dt><code>selectByValue</code></dt>
<dd>
<p>You can call this method on the component after it is initialized to select an option by value (i.e. the option with the matching <code>data-value</code> attribute):</p>
{% set code_script %}<script>
const comboboxComponent = document.querySelector('#my-combobox');
comboboxComponent.addEventListener('basecoat:initialized', () => {
comboboxComponent.selectByValue('apple');
});
</script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
</dd>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
<div class="prose">
+46 -20
View File
@@ -15,6 +15,8 @@ toc:
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: JavaScript events
id: usage-html-js-4
- label: Jinja and Nunjucks
id: usage-macro
- label: Examples
@@ -155,6 +157,17 @@ toc:
</dl>
</section>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</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:popover</code></dt>
<dd>When the popover opens, the component dispatches a custom (non-bubbling) <code>basecoat:popover</code> event on <code>document</code>. Other popover components (Combobox, Dropdown Menu, Popover and Select) listen for this to close any open popovers.</dd>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
<div class="prose">
@@ -254,9 +267,6 @@ toc:
role="menuitemcheckbox"
aria-checked="true"
class="group"
x-data="{ checked: true }"
@click="checked = !checked"
:aria-checked="checked"
>
{% lucide "check", {
"class": "invisible group-aria-checked:visible",
@@ -270,10 +280,7 @@ toc:
role="menuitemcheckbox"
aria-checked="false"
class="group"
disabled
x-data="{ checked: false }"
@click="checked = !checked"
:aria-checked="checked"
aria-disabled="true"
>
{% lucide "check", {
"class": "invisible group-aria-checked:visible",
@@ -287,9 +294,6 @@ toc:
role="menuitemcheckbox"
aria-checked="false"
class="group"
x-data="{ checked: false }"
@click="checked = !checked"
:aria-checked="checked"
>
{% lucide "check", {
"class": "invisible group-aria-checked:visible",
@@ -306,7 +310,20 @@ toc:
Logout
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</div>
{% endcall %}{% endset %}
{% endcall %}
<script>
(() => {
const dropdownMenu = document.querySelector('#dropdown-menu-checkboxes');
const checkboxes = dropdownMenu.querySelectorAll('div[role="menuitemcheckbox"]');
checkboxes.forEach(checkbox => {
checkbox.addEventListener('click', () => {
const isChecked = checkbox.getAttribute('aria-checked') === 'true';
checkbox.setAttribute('aria-checked', !isChecked);
});
});
})();
</script>
{% endset %}
{{ code_preview("dropdown-menu-checkboxes", code | prettyHtml) }}
<h3 id="example-radio-group"><a href="#example-radio-group">Radio Group</a></h3>
@@ -317,15 +334,13 @@ toc:
trigger_attrs={"class": "btn-outline"},
popover_attrs={"class": "min-w-56"}
) %}
<div role="group" aria-labelledby="position-options" x-data="{ checked: 'bottom' }">
<div role="group" aria-labelledby="position-options">
<span id="position-options" role="heading">Panel Position</span>
<hr role="separator">
<div
role="menuitemradio"
aria-checked="false"
class="group"
@click="checked = 'top'"
:aria-checked="checked == 'top'"
>
<div class="size-4 flex items-center justify-center">
<div
@@ -339,10 +354,8 @@ toc:
</div>
<div
role="menuitemradio"
aria-checked="false"
aria-checked="true"
class="group"
@click="checked = 'bottom'"
:aria-checked="checked == 'bottom'"
>
<div class="size-4 flex items-center justify-center">
<div
@@ -358,8 +371,6 @@ toc:
role="menuitemradio"
aria-checked="false"
class="group"
@click="checked = 'right'"
:aria-checked="checked == 'right'"
>
<div class="size-4 flex items-center justify-center">
<div
@@ -372,5 +383,20 @@ toc:
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘S</span>
</div>
</div>
{% endcall %}{% endset %}
{% endcall %}
<script>
(() => {
const dropdownMenu = document.querySelector('#dropdown-menu-radio-group');
const radioButtons = dropdownMenu.querySelectorAll('div[role="menuitemradio"]');
radioButtons.forEach(radioButton => {
radioButton.addEventListener('click', () => {
radioButtons.forEach(radioButton => {
radioButton.setAttribute('aria-checked', 'false');
});
radioButton.setAttribute('aria-checked', 'true');
});
});
})();
</script>
{% endset %}
{{ code_preview("dropdown-menu", code | prettyHtml) }}
+2 -2
View File
@@ -1,7 +1,7 @@
---
layout: layouts/page.njk
title: Input
description: Displays a form input field or a component that looks like an input field.
title: Form
description: Building forms with Basecoat components.
toc:
- label: Usage
id: usage
+13
View File
@@ -15,6 +15,8 @@ toc:
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: JavaScript events
id: usage-html-js-4
- label: Jinja and Nunjucks
id: usage-macro
---
@@ -105,6 +107,17 @@ toc:
</dl>
</section>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</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:popover</code></dt>
<dd>When the popover opens, the component dispatches a custom (non-bubbling) <code>basecoat:popover</code> event on <code>document</code>. Other popover components (Combobox, Dropdown Menu, Popover and Select) listen for this to close any open popovers.</dd>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
<div class="prose">
+37 -7
View File
@@ -17,6 +17,10 @@ toc:
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: JavaScript events
id: usage-html-js-4
- label: JavaScript methods
id: usage-html-js-5
- label: Jinja and Nunjucks
id: usage-macro
- label: Examples
@@ -156,6 +160,38 @@ toc:
</dl>
</section>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</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:popover</code></dt>
<dd>When the popover opens, the component dispatches a custom (non-bubbling) <code>basecoat:popover</code> event on <code>document</code>. Other popover components (Combobox, Dropdown Menu, Popover and Select) listen for this to close any open popovers.</dd>
<dt><code>change</code></dt>
<dd>When the selected value changes, the component dispatches a custom (bubbling) <code>change</code> event on itself, with the selected value in <code>event.detail</code> (e.g. <code>{ detail: { value: "something" }}</code>).</dd>
</dl>
</section>
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript methods</a></h4>
<section class="prose">
<dl>
<dt><code>selectByValue</code></dt>
<dd>
<p>You can call this method on the component after it is initialized to select an option by value (i.e. the option with the matching <code>data-value</code> attribute):</p>
{% set code_script %}<script>
const selectComponent = document.querySelector('#my-select');
selectComponent.addEventListener('basecoat:initialized', () => {
selectComponent.selectByValue('apple');
});
</script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
</dd>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
<div class="prose">
@@ -250,10 +286,4 @@ toc:
{% endcall %}
{% endset %}
{{ code_preview("select-scrollable", code | prettyHtml) }}
<script>
const select = document.querySelector('#select-with-icons');
select.addEventListener('basecoat:initialized', () => {
select.selectByValue('pie');
});
</script>
{{ code_preview("select-scrollable", code | prettyHtml) }}
+24 -11
View File
@@ -15,6 +15,8 @@ toc:
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: JavaScript events
id: usage-html-js-4
- label: Jinja and Nunjucks
id: usage-macro
---
@@ -82,15 +84,6 @@ toc:
{% endset %}
{{ code_block(code_html | prettyHtml, "html") }}
<section class="prose">
<p>Any element on the page can toggle, open or close the sidebar by dispatching the <code>basecoat:sidebar</code>. 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:</p>
</section>
{% set code_trigger %}<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar'));">Toggle sidebar</button>
<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar', { detail: { action: 'open' } }));">Open sidebar</button>
<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar', { detail: { action: 'close' } }));">Close sidebar</button>{% endset %}
{{ code_block(code_trigger | prettyHtml, "html") }}
<h4 id="usage-html-js-3"><a href="#usage-html-js-3">HTML structure</a></h4>
<section class="prose">
@@ -155,8 +148,28 @@ toc:
</dd>
<dt><code class="highlight language-html">&lt;main&gt;</code></dt>
<dd>A wrapper for the content of the page.</dd>
<dt><code class="highlight language-html">&lt;button type="button" onclick="document.dispatchEvent(new CustomEvent('sidebar:toggle'))"&gt;</code></dt>
<dd>A button to toggle the sidebar. If you want to use multiple sidebars you will need to add unique ids to the sidebars (i.e. <code>&lt;aside class="sidebar" id="{SIDEBAR_ID}"&gt;</code>) and refer to them in the event <code>detail</code> (i.e. <code>document.dispatchEvent(new CustomEvent('sidebar:toggle', { detail: { id: '{SIDEBAR_ID}' } }));</code>).</dd>
<dt><code class="highlight language-html">&lt;button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar'))"&gt;</code></dt>
<dd>A button to toggle the sidebar. If you want to use multiple sidebars you will need to add unique ids to the sidebars (i.e. <code>&lt;aside class="sidebar" id="{SIDEBAR_ID}"&gt;</code>) and refer to them in the event <code>detail</code> (i.e. <code>document.dispatchEvent(new CustomEvent('basecoat:sidebar', { detail: { id: '{SIDEBAR_ID}' } }));</code>).</dd>
</dl>
</section>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</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: 'close' } }));">Close 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>
</dl>
</section>
+15 -7
View File
@@ -16,18 +16,26 @@ toc:
min="0"
max="100"
value="50"
x-data="{
updatePercent(el) {
>
<script>
(() => {
const sliders = document.querySelectorAll('input[type="range"].input');
if (!sliders) return;
const updateSlider = (el) => {
const min = parseFloat(el.min || 0);
const max = parseFloat(el.max || 100);
const value = parseFloat(el.value);
const percent = (max === min) ? 0 : ((value - min) / (max - min)) * 100;
el.style.setProperty('--slider-value', `${percent}%`);
}
}"
x-init="updatePercent($el)"
@input="updatePercent($el)"
>{% endset %}
};
sliders.forEach(slider => {
updateSlider(slider);
slider.addEventListener('input', (event) => updateSlider(event.target));
});
})();
</script>{% endset %}
{{ code_preview("slider", code, "w-full max-w-sm") }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
+19 -1
View File
@@ -8,6 +8,15 @@ toc:
children:
- label: HTML + JavaScript
id: usage-default
children:
- label: "Step 1: Include the JavaScript files"
id: usage-html-js-1
- label: "Step 2: Add your select HTML"
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: JavaScript events
id: usage-html-js-4
- label: Jinja and Nunjucks
id: usage-macro
---
@@ -85,7 +94,7 @@ toc:
<p>You can either <a href="/installation/#install-cdn-all">include the JavaScript file for all the components</a>, or just the one for this component by adding this to the <code>&lt;head&gt;</code> of your page:</p>
</section>
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/select.min.js" defer></script>{% endset %}
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/tabs.min.js" defer></script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
@@ -130,6 +139,15 @@ toc:
</dl>
</section>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</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>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
<div class="prose">
+86 -28
View File
@@ -5,49 +5,107 @@ description: A component that allows the user to switch between light and dark m
toc:
- label: Usage
id: usage
- label: Examples
id: examples
children:
- label: Default
id: example-default
- label: Right
id: example-right
- label: Bottom + Start
id: example-bottom-start
- label: HTML + JavaScript
id: usage-html-js
children:
- label: "Step 1: Include the JavaScript script"
id: usage-html-js-1
- label: "Step 2: Add your switcher"
id: usage-html-js-2
- label: JavaScript events
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>
</div>
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
{% set code %}<button class="btn-outline" data-tooltip="Add to library">Hover</button>{% endset %}
{% 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_preview("button", code) }}
<h2 id="usage">Usage</h2>
<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>
<section class="prose">
<p>On any element, set the value of the <code>data-tooltip</code> attribute to the text you want to display in the tooltip. Additionally, you can set:</p>
<ul>
<li><code>data-side</code> attribute to <code>top</code>, <code>bottom</code>, <code>left</code>, or <code>right</code> to change the side of the tooltip.</li>
<li><code>data-align</code> attribute to <code>start</code>, <code>center</code>, or <code>end</code> to change the alignment of the tooltip.</li>
</ul>
<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>
</section>
{% set code %}<button class="btn-outline" data-tooltip="Tooltip text" data-side="bottom" data-align="center">Bottom left</button>{% endset %}
{% set code %}<script>
(() => {
try {
const stored = localStorage.getItem('themeMode');
if (stored ? stored === 'dark'
: matchMedia('(prefers-color-scheme: dark)').matches) {
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) }}
<h2 id="examples"><a href="#examples">Examples</a></h2>
<h4 id="usage-html-js-2"><a href="#usage-html-js-2">Step 2: Add your switcher</a></h4>
<h3 id="example-default"><a href="#example-default">Default</a></h3>
<section class="prose">
<p>To change the theme, you need to dispatch a <code>basecoat:theme</code> event:</p>
</section>
{% set code %}<button class="btn-outline" data-tooltip="Default tooltip">Default</button>{% endset %}
{{ code_preview("button", code) }}
{% 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) }}
<h3 id="example-right"><a href="#example-right">Right</a></h3>
<h4 id="usage-html-js-3"><a href="#usage-html-js-3">JavaScript events</a></h4>
{% set code %}<button class="btn-outline" data-tooltip="Right side tooltip" data-side="right">Right</button>{% endset %}
{{ code_preview("button", code) }}
<h3 id="example-bottom-start"><a href="#example-bottom-start">Bottom + Start</a></h3>
{% set code %}<button class="btn-outline" data-tooltip="Bottom side and start aligned tooltip" data-side="bottom" data-align="start">Bottom + Start</button>{% endset %}
{{ code_preview("button", code) }}
<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>
</dl>
</section>
+20 -5
View File
@@ -17,8 +17,10 @@ toc:
id: usage-html-js-3
- label: HTML structure
id: usage-html-js-4
- label: JavaScript config object
- label: JavaScript events
id: usage-html-js-5
- label: Toast config object
id: usage-html-js-6
- label: Jinja and Nunjucks
id: usage-macro
---
@@ -118,7 +120,7 @@ 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-4">
<a class="badge-outline" href="#usage-html-js-5">
HTML structure
{% lucide "arrow-right" %}
</a>
@@ -131,8 +133,8 @@ toc:
{{ code_block(code_client_side | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="#usage-html-js-5">
JavaScript config object
<a class="badge-outline" href="#usage-html-js-6">
Toast config object
{% lucide "arrow-right" %}
</a>
</div>
@@ -176,7 +178,20 @@ toc:
</dl>
</section>
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript config object</a></h4>
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript events</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>
</dl>
</section>
<h4 id="usage-html-js-6"><a href="#usage-html-js-6">Toast config object</a></h4>
<section class="prose">
<dl>
+9 -3
View File
@@ -232,9 +232,15 @@ layout: layouts/layout.njk
I can't log in.
</div>
</section>
<footer class="flex items-center gap-2" x-data="{ message: '' }">
<input type="text" class="input w-full" placeholder="Type your message here..." x-model="message" />
<button type="button" class="btn-icon" :disabled="!message">
<footer class="flex items-center gap-2">
<input type="text" class="input w-full" placeholder="Type your message here..." oninput="
if (this.value) {
this.parentElement.querySelector('button').removeAttribute('disabled');
} else {
this.parentElement.querySelector('button').setAttribute('disabled', 'true');
}
"/>
<button type="button" class="btn-icon" disabled>
{% lucide "send"%}
</button>
</footer>
+2 -2
View File
@@ -25,7 +25,7 @@ toc:
<p><b>That's where Basecoat comes in.</b> It gives you modern, accessible components with the simplicity of plain HTML and Tailwind. Basecoat is:</p>
<ul>
<li><b>Lightweight</b>: no runtime JS, just CSS and tiny Alpine.js scripts for the more interactive components.</li>
<li><b>Lightweight</b>: no runtime JS, just CSS and a tiny bit of vanilla JavaScript for the more interactive components.</li>
<li><b>Easy to use</b>: add classes like <code>btn</code> or <code>input</code> and you're done.</li>
<li><b>Framework-agnostic</b>: works with any backend or frontend stack.</li>
<li><b>Accessible</b>: components follow accessibility best practices.</li>
@@ -42,7 +42,7 @@ toc:
<div class="prose">
<p>Add a single CSS file to your Tailwind setup. Use components by dropping in simple classes like <code>btn</code>, <code>form</code>, or <code>card</code>.</p>
<p>Some components (like modals or dropdowns) use a tiny bit of Alpine.js. You can skip it if you don't need interactivity.</p>
<p>Some components (like modals or dropdowns) use a tiny bit of vanilla JavaScript. You can skip it if you don't need interactivity.</p>
<p>There's also a CLI to help you scaffold components and get set up fast.</p>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "basecoat",
"version": "0.2.4",
"version": "0.2.7",
"description": "shadcn/ui for the rest of us",
"author": "hunvreus",
"type": "module",
-1
View File
@@ -8,7 +8,6 @@ This package provides a Command Line Interface (CLI) to help you quickly scaffol
- For the components to function correctly, your project should have:
- [Tailwind CSS](https://tailwindcss.com/docs/installation) installed and configured.
- The `basecoat-css` package installed and imported (or `basecoat.css` manually added).
- [Alpine.js](https://alpinejs.dev/docs/installation) installed and initialized, as Basecoat components rely on it for interactivity.
## Usage
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "basecoat-cli",
"version": "0.2.4",
"version": "0.2.7",
"description": "Add Basecoat components to your project",
"author": "hunvreus",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "basecoat-css",
"version": "0.2.4",
"version": "0.2.7",
"description": "Tailwind CSS for Basecoat components",
"author": {
"name": "hunvreus",
+17 -9
View File
@@ -366,7 +366,7 @@
.badge-secondary,
.badge-destructive,
.badge-outline {
@apply inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden
@apply inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden;
}
.badge,
.badge-primary {
@@ -472,7 +472,7 @@
@apply bg-black/50 opacity-0 transition-all transition-discrete;
}
> article {
@apply bg-background fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border p-6 shadow-lg sm:max-w-lg max-h-[calc(100%-2rem)];
@apply bg-background fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%_-_2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border p-6 shadow-lg sm:max-w-lg max-h-[calc(100%_-_2rem)];
@apply transition-all scale-95;
> header {
@@ -516,7 +516,7 @@
@apply aria-hidden:hidden [&_svg]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none [&_svg]:shrink-0 [&_svg]:size-4 aria-disabled:opacity-50 aria-disabled:pointer-events-none disabled:opacity-50 disabled:pointer-events-none w-full truncate;
&:not([aria-disabled='true']) {
@apply focus-visible:bg-accent focus-visible:text-accent-foreground hover:bg-accent hover:text-accent-foreground;
@apply focus-visible:bg-accent focus-visible:text-accent-foreground;
}
&.active {
@@ -530,6 +530,13 @@
@apply border-border -mx-1 my-1;
}
}
&:not([data-dropdown-menu-initialized]) [data-popover] {
[role='menuitem'],
[role='menuitemcheckbox'],
[role='menuitemradio'] {
@apply hover:bg-accent hover:text-accent-foreground;
}
}
}
}
@@ -737,6 +744,10 @@
&[aria-selected='true'] {
@apply bg-[image:var(--check-icon)] bg-no-repeat bg-position-[center_right_0.5rem] bg-size-[0.875rem];
}
&.active,
&:focus-visible {
@apply bg-accent text-accent-foreground;
}
}
[role='listbox'] [role='heading'] {
@apply flex text-muted-foreground px-2 py-1.5 text-xs;
@@ -767,11 +778,8 @@
@apply content-['No_results_found'];
}
}
[role='option']:hover,
&[data-select-initialized] [role='option'].active,
&[data-select-initialized] [role='option']:focus-visible {
@apply bg-accent text-accent-foreground;
&:not([data-select-initialized]) [data-popover] [role='option'] {
@apply hover:bg-accent hover:text-accent-foreground;
}
}
}
@@ -945,7 +953,7 @@
@apply bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px];
[role='tab'] {
@apply focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4;
@apply focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground dark:text-muted-foreground inline-flex h-[calc(100%_-_1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4;
&[aria-selected='true'] {
@apply bg-background dark:text-foreground dark:border-input dark:bg-input/30 shadow-sm;
+3 -4
View File
@@ -4,8 +4,7 @@
@param id {string} [optional] [default="toaster"] - Unique identifier for the toaster container.
@param toasts {array} [optional] - An array of toast objects to render initially. See the <code>toast()</code> macro for more details.
@param main_attrs {object} [optional] - Additional HTML attributes for the main toaster container div.
@param is_fragment {boolean} [optional] [default=false] - If true, renders only the toast elements with hx-swap-oob="beforeend", suitable for htmx responses. Skips script and template inclusion.
@param attrs {object} [optional] - Additional HTML attributes for the main toaster container div.
#}
{% macro toaster(
id="toaster",
@@ -39,11 +38,11 @@
@param description {string} [optional] - The secondary description text.
@param action {object} [optional] - Defines an action button.
- label {string}: Button text.
- click {string}: JavaScript code to execute on click (e.g., '$dispatch(\'custom-event\')').
- onclick {string}: JavaScript code to execute on click.
- url {string}: URL for an anchor link button.
@param cancel {object} [optional] - Defines a cancel/dismiss button (similar structure to action).
- label {string}: Button text.
- click {string}: JavaScript code to execute on click (e.g., '$dispatch(\'custom-event\')').
- onclick {string}: JavaScript code to execute on click.
- url {string}: URL for an anchor link button.
#}
{% macro toast(
+33 -8
View File
@@ -29,7 +29,7 @@
setActiveItem(-1);
};
const openPopover = () => {
const openPopover = (initialSelection = false) => {
document.dispatchEvent(new CustomEvent('basecoat:popover', {
detail: { source: dropdownMenuComponent }
}));
@@ -40,8 +40,13 @@
!item.hasAttribute('disabled') &&
item.getAttribute('aria-disabled') !== 'true'
);
if (menuItems.length > 0) {
setActiveItem(0);
if (menuItems.length > 0 && initialSelection) {
if (initialSelection === 'first') {
setActiveItem(0);
} else if (initialSelection === 'last') {
setActiveItem(menuItems.length - 1);
}
}
};
@@ -64,7 +69,7 @@
if (isExpanded) {
closePopover();
} else {
openPopover();
openPopover(false);
}
});
@@ -77,9 +82,15 @@
}
if (!isExpanded) {
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) {
if (['Enter', ' '].includes(event.key)) {
event.preventDefault();
openPopover();
openPopover(false);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
openPopover('first');
} else if (event.key === 'ArrowUp') {
event.preventDefault();
openPopover('last');
}
return;
}
@@ -91,11 +102,11 @@
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
nextIndex = Math.min(activeIndex + 1, menuItems.length - 1);
nextIndex = activeIndex === -1 ? 0 : Math.min(activeIndex + 1, menuItems.length - 1);
break;
case 'ArrowUp':
event.preventDefault();
nextIndex = Math.max(activeIndex - 1, 0);
nextIndex = activeIndex === -1 ? menuItems.length - 1 : Math.max(activeIndex - 1, 0);
break;
case 'Home':
event.preventDefault();
@@ -118,6 +129,20 @@
}
});
menu.addEventListener('mousemove', (event) => {
const menuItem = event.target.closest('[role^="menuitem"]');
if (menuItem && menuItems.includes(menuItem)) {
const index = menuItems.indexOf(menuItem);
if (index !== activeIndex) {
setActiveItem(index);
}
}
});
menu.addEventListener('mouseleave', () => {
setActiveItem(-1);
});
menu.addEventListener('click', (event) => {
if (event.target.closest('[role^="menuitem"]')) {
closePopover();
+43 -26
View File
@@ -20,6 +20,26 @@
let visibleOptions = [...options];
let activeIndex = -1;
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 hasTransition = () => {
const style = getComputedStyle(popover);
return parseFloat(style.transitionDuration) > 0 || parseFloat(style.transitionDelay) > 0;
@@ -54,9 +74,7 @@
if (focusOnTrigger) trigger.focus();
popover.setAttribute('aria-hidden', 'true');
trigger.setAttribute('aria-expanded', 'false');
trigger.removeAttribute('aria-activedescendant');
if (activeIndex > -1) options[activeIndex]?.classList.remove('active');
activeIndex = -1;
setActiveOption(-1);
}
const selectOption = (option) => {
@@ -92,11 +110,7 @@
const filterOptions = () => {
const searchTerm = filter.value.trim().toLowerCase();
if (activeIndex > -1) {
options[activeIndex].classList.remove('active');
trigger.removeAttribute('aria-activedescendant');
activeIndex = -1;
}
setActiveOption(-1);
visibleOptions = [];
options.forEach(option => {
@@ -173,21 +187,31 @@
}
if (nextVisibleIndex !== currentVisibleIndex) {
if (currentVisibleIndex > -1) {
visibleOptions[currentVisibleIndex].classList.remove('active');
}
const newActiveOption = visibleOptions[nextVisibleIndex];
newActiveOption.classList.add('active');
activeIndex = options.indexOf(newActiveOption);
if (newActiveOption.id) {
trigger.setAttribute('aria-activedescendant', newActiveOption.id);
}
setActiveOption(options.indexOf(newActiveOption));
newActiveOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
};
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);
}
}
});
listbox.addEventListener('mouseleave', () => {
const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
if (selectedOption) {
setActiveOption(options.indexOf(selectedOption));
} else {
setActiveOption(-1);
}
});
trigger.addEventListener('keydown', handleKeyNavigation);
if (filter) {
filter.addEventListener('keydown', handleKeyNavigation);
@@ -213,14 +237,7 @@
const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
if (selectedOption) {
if (activeIndex > -1) {
options[activeIndex]?.classList.remove('active');
}
activeIndex = options.indexOf(selectedOption);
selectedOption.classList.add('active');
if (selectedOption.id) {
trigger.setAttribute('aria-activedescendant', selectedOption.id);
}
setActiveOption(options.indexOf(selectedOption));
selectedOption.scrollIntoView({ block: 'nearest' });
}
};
+3 -4
View File
@@ -4,8 +4,7 @@
@param id {string} [optional] [default="toaster"] - Unique identifier for the toaster container.
@param toasts {array} [optional] - An array of toast objects to render initially. See the <code>toast()</code> macro for more details.
@param main_attrs {object} [optional] - Additional HTML attributes for the main toaster container div.
@param is_fragment {boolean} [optional] [default=false] - If true, renders only the toast elements with hx-swap-oob="beforeend", suitable for htmx responses. Skips script and template inclusion.
@param attrs {object} [optional] - Additional HTML attributes for the main toaster container div.
#}
{% macro toaster(
id="toaster",
@@ -39,11 +38,11 @@
@param description {string} [optional] - The secondary description text.
@param action {object} [optional] - Defines an action button.
- label {string}: Button text.
- click {string}: JavaScript code to execute on click (e.g., '$dispatch(\'custom-event\')').
- onclick {string}: JavaScript code to execute on click.
- url {string}: URL for an anchor link button.
@param cancel {object} [optional] - Defines a cancel/dismiss button (similar structure to action).
- label {string}: Button text.
- click {string}: JavaScript code to execute on click (e.g., '$dispatch(\'custom-event\')').
- onclick {string}: JavaScript code to execute on click.
- url {string}: URL for an anchor link button.
#}
{% macro toast(