Compare commits

..
34 Commits
Author SHA1 Message Date
Ronan Berder 7b02f64831 Bug fixes 2025-06-18 20:57:20 +08:00
Ronan Berder 9b59f43a67 Fixing more issues with popovers 2025-06-18 20:55:32 +08:00
Ronan Berder 46bf212f41 Fixing docs 2025-06-18 19:54:15 +08:00
Ronan Berder be9c6e537d Removing unused files 2025-06-18 19:42:39 +08:00
Ronan Berder 218c16ba6b Fixing bugs and docs issues 2025-06-18 19:42:26 +08:00
Ronan Berder a5c1b8df57 Fixes to docs 2025-06-17 16:25:18 +08:00
Ronan Berder fda413a5af Fixing README for basecoat-css 2025-06-16 21:28:32 +08:00
Ronan Berder 40c700b861 Bumping version number for 0.2.1 2025-06-16 21:14:28 +08:00
Ronan Berder 43673ad316 Updated Jinja templates, fixed Nunjucks 2025-06-16 21:11:36 +08:00
Ronan Berder 1513075cb6 Fixing highlight 2025-06-16 19:54:37 +08:00
Ronan Berder 7084ed25e7 Resolve merging conflict 2025-06-16 19:43:03 +08:00
Ronan Berder 0da20133f6 Final polish 2025-06-16 19:41:10 +08:00
Ronan Berder 8a7341e345 Polishing 2025-06-16 19:14:39 +08:00
Ronan Berder c066a392f4 Cleaning up installation 2025-06-16 18:28:18 +08:00
Ronan Berder 17a18c1871 Getting ready to clean up the package and release 0.2.0 2025-06-16 17:58:01 +08:00
Ronan Berder f51a03da79 Removing wrangler config 2025-06-15 10:36:04 +08:00
Ronan Berder feab320e4c Attempting deployment with Workers 2025-06-15 10:25:10 +08:00
Ronan Berder 7cd0362254 Typo 2025-06-15 09:40:08 +08:00
Ronan Berder fed3dd209f Fixing comments 2025-06-15 08:57:30 +08:00
Ronan Berder e0ff2caf3c Fixing comments 2025-06-15 08:53:35 +08:00
Ronan Berder 553612ccad Fixing wrong dependency 2025-06-15 08:39:03 +08:00
Ronan Berder 9f5ffe6f51 Updated build script 2025-06-15 08:11:00 +08:00
Ronan Berder 386d139fe2 Finalized all component docs 2025-06-14 16:48:01 +08:00
Ronan Berder 7e3773276c Revamped the select docs 2025-06-13 14:14:38 +08:00
Ronan Berder 056c025628 Working on docs 2025-06-12 18:44:27 +08:00
Ronan Berder ebf5b6e33f Working on docs 2025-06-12 18:44:17 +08:00
Ronan Berder d37dc99923 Update sidebar.njk 2025-06-12 13:37:22 +08:00
Ronan Berder 3b0b38a4cf PoC for new components, including select 2025-06-12 10:37:19 +08:00
Ronan Berder cdc52a810e Fixing bigs in macros 2025-06-08 12:32:52 +08:00
Ronan Berder 526cfab55c Merge pull request #12 from HugoDF/main
fix: typo in textarea docs title
2025-05-21 18:18:23 -07:00
HugoDF 40d468816a fix typo 2025-05-21 21:18:54 +01:00
Ronan Berder e2dcbcb4dd Merge branch 'main' into development 2025-05-19 00:15:39 -07:00
Ronan Berder ef50b3b463 Merge branch 'main' into development 2025-05-07 15:12:48 +08:00
Ronan Berder 061d0d8e48 Adding chart to dev 2025-05-07 10:07:14 +08:00
71 changed files with 4924 additions and 2927 deletions
+1
View File
@@ -5,6 +5,7 @@ import fs from "fs";
import path from "path";
export default async function(eleventyConfig) {
eleventyConfig.addWatchTarget("src/nunjucks/");
eleventyConfig.setInputDirectory("./docs/src");
eleventyConfig.addPassthroughCopy({"docs/src/assets": "assets"});
eleventyConfig.addPassthroughCopy({ "src/js": "assets/js" });
+25 -9
View File
@@ -12,22 +12,22 @@
.content {
> h2 {
@apply mt-16 mb-6 scroll-m-22 border-b pb-4 text-xl font-semibold tracking-tight first:mt-0;
@apply mt-12 lg:mt-20 mb-6 scroll-m-22 text-2xl font-semibold tracking-tight first:mt-0;
}
> h3 {
@apply mt-8 mb-6 scroll-m-22 text-lg font-semibold tracking-tight;
@apply mt-8 mb-6 scroll-m-22 text-xl font-semibold tracking-tight;
}
> h4 {
@apply my-6 scroll-m-22 font-semibold tracking-tight;
}
.prose {
@apply my-6;
a {
@apply font-medium underline underline-offset-4;
}
code:not(pre > code) {
@apply relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm;
@apply relative rounded-md bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm;
}
blockquote {
@apply border-l-4 border-border pl-4;
@@ -44,6 +44,18 @@
@apply mt-2;
}
}
dl {
@apply border-b pb-3 my-6;
dt {
@apply border-t pt-3 mt-3 first:mt-0;
}
dd {
@apply mt-2;
}
dl {
@apply mt-4 border-b-0 pb-0 pl-8 my-0;
}
}
b,
strong {
@apply font-semibold;
@@ -67,10 +79,14 @@
}
.step {
counter-increment: step;
}
.step:before {
@apply md:absolute w-8 h-8 md:w-9 md:h-9 bg-muted rounded-full font-mono font-medium text-center text-base inline-flex items-center justify-center -indent-px border-4 mr-2 border-background;
@apply md:ml-[-50px] md:mt-[-4px];
content: counter(step);
&:before {
@apply absolute w-8 h-8 md:w-9 md:h-9 bg-muted rounded-full font-mono font-semibold text-center text-sm inline-flex items-center justify-center -indent-px border-4 mr-2 border-background;
@apply md:ml-[-50px] md:mt-[-4px];
content: counter(step);
}
> h3 {
@apply max-md:ml-10 min-h-8 flex items-center;
}
}
}
+151
View File
@@ -0,0 +1,151 @@
/*
Custom Highlight.js theme with CSS variables
derived from github.min.css and github-dark.min.css
*/
:root {
/* Base */
--hljs-bg: #fff;
--hljs-color: #24292e;
/* Tokens */
--hljs-keyword: #d73a49;
--hljs-title: #6f42c1;
--hljs-literal: #005cc5;
--hljs-string: #032f62;
--hljs-symbol: #e36209;
--hljs-comment: #6a737d;
--hljs-tag: #22863a;
--hljs-section: #005cc5;
--hljs-bullet: #735c0f;
/* Diffs */
--hljs-addition-color: #22863a;
--hljs-addition-bg: #f0fff4;
--hljs-deletion-color: #b31d28;
--hljs-deletion-bg: #ffeef0;
}
html.dark {
/* Base */
--hljs-bg: #0d1117;
--hljs-color: #c9d1d9;
/* Tokens */
--hljs-keyword: #ff7b72;
--hljs-title: #d2a8ff;
--hljs-literal: #79c0ff;
--hljs-string: #a5d6ff;
--hljs-symbol: #ffa657;
--hljs-comment: #8b949e;
--hljs-tag: #7ee787;
--hljs-section: #1f6feb;
--hljs-bullet: #f2cc60;
/* Diffs */
--hljs-addition-color: #aff5b4;
--hljs-addition-bg: #033a16;
--hljs-deletion-color: #ffdcd7;
--hljs-deletion-bg: #67060c;
}
/* --- Base --- */
pre code.hljs{
display:block;
overflow-x:auto;
padding:1em
}
code.hljs{
padding:3px 5px
}
/* --- Mapped styles --- */
.hljs {
color: var(--hljs-color);
background: var(--hljs-bg);
}
.hljs-doctag,
.hljs-keyword,
.hljs-meta .hljs-keyword,
.hljs-template-tag,
.hljs-template-variable,
.hljs-type,
.hljs-variable.language_ {
color: var(--hljs-keyword);
}
.hljs-title,
.hljs-title.class_,
.hljs-title.class_.inherited__,
.hljs-title.function_ {
color: var(--hljs-title);
}
.hljs-attr,
.hljs-attribute,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-operator,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id,
.hljs-variable {
color: var(--hljs-literal);
}
.hljs-meta .hljs-string,
.hljs-regexp,
.hljs-string {
color: var(--hljs-string);
}
.hljs-built_in,
.hljs-symbol {
color: var(--hljs-symbol);
}
.hljs-code,
.hljs-comment,
.hljs-formula {
color: var(--hljs-comment);
}
.hljs-name,
.hljs-quote,
.hljs-selector-pseudo,
.hljs-selector-tag {
color: var(--hljs-tag);
}
.hljs-subst {
color: var(--hljs-color);
}
.hljs-section {
color: var(--hljs-section);
font-weight: 700;
}
.hljs-bullet {
color: var(--hljs-bullet);
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 700;
}
.hljs-addition {
color: var(--hljs-addition-color);
background-color: var(--hljs-addition-bg);
}
.hljs-deletion {
color: var(--hljs-deletion-color);
background-color: var(--hljs-deletion-bg);
}
+1 -8
View File
@@ -1,14 +1,7 @@
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Geist+Mono&display=swap");
@import "tailwindcss";
@import "../../src/css/basecoat.css";
@import "./custom.css";
@theme {
--font-sans: 'Geist';
--font-mono: 'Geist Mono';
}
@import "./highlight.css";
@import "./themes/claude.css";
@import "./themes/doom-64.css";
+2 -1
View File
@@ -6,5 +6,6 @@ export default {
name: "Ronan Berder",
x: "@hunvreus"
},
url: process.env.ELEVENTY_ENV === 'development' ? "http://localhost:8080" : "https://basecoatui.com"
url: process.env.ELEVENTY_ENV === 'development' ? "http://localhost:8080" : "https://basecoatui.com",
version: "0.2"
};
+51 -66
View File
@@ -1,62 +1,56 @@
<!DOCTYPE html>
<html>
<head>
<!-- Theme -->
{# Dark mode #}
<script>
function isDark() {
return localStorage.getItem('darkMode') === 'dark' || (!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches);
}
(() => {
try {
const stored = localStorage.getItem('themeMode');
if (stored ? stored === 'dark'
: matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
} catch (_) {}
if (isDark()) {
document.documentElement.classList.add('dark');
}
const apply = dark => {
document.documentElement.classList.toggle('dark', dark);
try { localStorage.setItem('themeMode', dark ? 'dark' : 'light'); } catch (_) {}
};
if (localStorage.getItem('themeVariant')) {
document.documentElement.classList.add(`theme-${localStorage.getItem('themeVariant')}`);
}
document.addEventListener('alpine:init', () => {
Alpine.store('darkMode', {
isDark: isDark(),
toggle() {
this.isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('darkMode', this.isDark ? 'dark' : 'light');
}
});
Alpine.store('themeVariant', {
name: localStorage.getItem('themeVariant'),
setThemeVariant(name) {
this.name = name;
localStorage.setItem('themeVariant', name);
document.documentElement.classList.forEach(className => {
if (className.startsWith('theme-')) {
document.documentElement.classList.remove(className);
}
});
document.documentElement.classList.add(`theme-${name}`);
}
});
});
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 #}
<script>
(function() {
try {
const storedTheme = localStorage.getItem('themeVariant');
if (storedTheme) document.documentElement.classList.add(`theme-${storedTheme}`);
} catch (event) {
console.error('Could not apply theme variant from localStorage', event);
}
})();
</script>
{# Meta #}
<meta charset="utf-8">
<meta http-equiv="Content-Language" content="en">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Title & Description -->
{# Title & Description #}
{% set meta_title = title ~ ' | ' ~ site.title if title else site.title %}
{% set meta_description = description or site.description %}
<title>{{ meta_title }}</title>
<meta name="description" content="{{ meta_description }}"/>
<meta name="title" content="{{ meta_title }}">
{% if site.keywords %}<meta name="keywords" content="{{ site.keywords | join(',') }}" />{% endif %}
<!-- Favicon -->
{# 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">
<!-- Open Graph / Facebook -->
{# Open Graph / Facebook #}
<meta property="og:type" content="website">
<meta property="og:url" content="{{ site.url }}{{ page.url }}">
<meta property="og:title" content="{{ meta_title }}">
@@ -65,52 +59,43 @@
<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 -->
{# 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:description" content="{{ meta_description }}">
<meta name="twitter:image" content="{{ site.url }}/assets/social-screenshot.png">
<meta name="twitter:creator" content="{{ site.author.x }}">
<!-- CSS -->
{# CSS #}
<link rel="stylesheet" href="/assets/styles.css">
<!-- JS -->
<script src="/assets/js/dialog.js" defer></script>
{# JS #}
<script src="/assets/js/dropdown-menu.js" defer></script>
<script src="/assets/js/popover.js" defer></script>
<script src="/assets/js/select.js" defer></script>
<script src="/assets/js/sidebar.js" defer></script>
<script src="/assets/js/tabs.js" defer></script>
<script src="/assets/js/toast.js" defer></script>
<script>
document.addEventListener('alpine:init', () => {
window.basecoat.registerDialog(Alpine);
window.basecoat.registerDropdownMenu(Alpine);
window.basecoat.registerPopover(Alpine);
window.basecoat.registerSelect(Alpine);
window.basecoat.registerSidebar(Alpine);
window.basecoat.registerTabs(Alpine);
window.basecoat.registerToast(Alpine);
});
</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>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
{# Highlight.js #}
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/django.min.js"></script>
<script>
const applyHighlighting = () => { if (window.hljs) hljs.highlightAll(); };
const highlight = () => {
if (!window.hljs) return;
document
.querySelectorAll('pre code:not([data-highlighted]), code.highlight:not([data-highlighted])')
.forEach(el => hljs.highlightElement(el));
};
if (!window._hljsInitialized) {
window._hljsInitialized = true;
document.addEventListener('DOMContentLoaded', applyHighlighting);
document.addEventListener('htmx:afterSwap', applyHighlighting);
if (!window._hljsInit) {
window._hljsInit = true;
document.addEventListener('DOMContentLoaded', highlight);
document.addEventListener('htmx:afterSwap', highlight);
}
</script>
{% if head %}{{ head | safe }}{% endif %}
</head>
<body>
+3 -3
View File
@@ -3,10 +3,10 @@ layout: layouts/layout.njk
---
<main class="mx-auto relative flex w-full max-w-screen-lg gap-10">
<div class="mx-auto w-full max-w-screen-md">
<div class="mx-auto w-full flex-1 max-w-screen-md">
<header class="space-y-2">
<h1 class="text-3xl font-bold tracking-tight">{{ title }}</h1>
<p class="text-muted-foreground">{{ description }}</p>
<h1 class="text-2xl font-semibold tracking-tight sm:text-3xl xl:text-4xl">{{ title }}</h1>
<p class="text-muted-foreground text-[1.05rem] sm:text-base">{{ description }}</p>
</header>
<article class="pb-12 mt-8 content">
{{ content | safe }}
+4 -4
View File
@@ -15,14 +15,14 @@
}
}"
>
<pre x-ref="code" class="text-sm max-h-[650px] overflow-y-auto rounded-xl scrollbar"><code class="language-{{ language }} !bg-neutral-900 p-0">{{- code | escape -}}</code></pre>
<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="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 z-10 h-6 w-6 text-zinc-50 hover:bg-zinc-700 hover:text-zinc-50 [&_svg]:h-3 [&_svg]:w-3 absolute right-3 top-3"
class="btn-icon-ghost size-8 absolute right-2.5 top-2 text-muted-foreground hover:text-foreground"
>
{% lucide "clipboard", { "class": "size-4", "x-show": "!copied" } %}
{% lucide "check", { "class": "size-4", "x-show": "copied" } %}
{% lucide "clipboard", {"x-show": "!copied" } %}
{% lucide "check", {"x-show": "copied" } %}
</button>
</div>
{% endmacro %}
+1 -3
View File
@@ -19,10 +19,8 @@
] %}
{{ tabs(
id="code-block-{{ id }}",
tabsets=tabsets,
default_tab_index=(2 if default_code else 1),
register_on=["alpine:init", "htmx:afterSwap"]
default_tab_index=(2 if default_code else 1)
) }}
</div>
{% endmacro %}
+29 -18
View File
@@ -1,43 +1,54 @@
<div class="flex h-14 w-full items-center gap-2 px-4">
<button
type="button"
aria-label="Hide sidebar"
data-tooltip="Hide sidebar"
:data-tooltip="sidebarOpen ? 'Hide sidebar' : 'Show sidebar'"
onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar'))"
aria-label="Toggle sidebar"
data-tooltip="Toggle sidebar"
data-side="bottom"
data-align="start"
class="btn-sm-icon-ghost mr-auto size-7 -ml-1.5"
x-init
x-data="{ sidebarOpen: true }"
@click="
sidebarOpen = !sidebarOpen;
$dispatch('sidebar:toggle');
"
>
{% lucide "panel-left" %}
</button>
<select
class="select h-8 leading-none"
x-init
@change="$store.themeVariant.setThemeVariant($event.target.value)"
:value="$store.themeVariant.name"
id="theme-select"
>
<option value="">Default</option>
<option value="claude">Claude</option>
<option value="doom-64">Doom 64</option>
<option value="supabase">Supabase</option>
</select>
<script>
(() => {
const themeSelect = document.getElementById('theme-select');
const storedTheme = localStorage.getItem('themeVariant');
if (themeSelect && storedTheme) themeSelect.value = storedTheme;
themeSelect.addEventListener('change', () => {
const newTheme = themeSelect.value;
document.documentElement.classList.forEach(c => {
if (c.startsWith('theme-')) document.documentElement.classList.remove(c);
});
if (newTheme) {
document.documentElement.classList.add(`theme-${newTheme}`);
localStorage.setItem('themeVariant', newTheme);
} else {
localStorage.removeItem('themeVariant');
}
});
})();
</script>
<button
type="button"
:aria-label="$store.darkMode.isDark ? 'Switch to light mode' : 'Switch to dark mode'"
:data-tooltip="$store.darkMode.isDark ? 'Light mode' : 'Dark mode'"
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"
x-init
@click="$store.darkMode.toggle()"
>
<span x-show="$store.darkMode.isDark">{% lucide "sun" %}</span>
<span x-show="!$store.darkMode.isDark">{% lucide "moon" %}</span>
<span class="hidden dark:block">{% lucide "sun" %}</span>
<span class="block dark:hidden">{% lucide "moon" %}</span>
</button>
<a
href="https://github.com/hunvreus/basecoat"
@@ -8,21 +8,19 @@
</a>
</header>
<div class="p-4">
<div class="flex flex-col gap-4">
{% set footer %}
<button class="btn-outline" @click="open = false">Cancel</button>
<button class="btn-primary" @click="open = false">Continue</button>
{% endset %}
{{ dialog(
id="alert-dialog-demo",
title="Are you absolutely sure?",
description="This action cannot be undone. This will permanently delete your account and remove your data from our servers.",
trigger="Open dialog",
trigger_attrs={"class": "btn-outline"},
footer=footer,
close_button=false,
close_on_overlay_click=false
) }}
</div>
{% set footer %}
<button class="btn-outline" @click="open = false">Cancel</button>
<button class="btn-primary" @click="open = false">Continue</button>
{% endset %}
{{ dialog(
id="alert-dialog-demo",
title="Are you absolutely sure?",
description="This action cannot be undone. This will permanently delete your account and remove your data from our servers.",
trigger="Open dialog",
trigger_attrs={"class": "btn-outline"},
footer=footer,
close_button=false,
close_on_overlay_click=false
) }}
</div>
</section>
@@ -15,7 +15,7 @@
<div class="alert">
{% lucide "bookmark-check" %}
<section>This is an alert with icon, title and description.</section>
<section>This is an alert with icon, description and no title.</section>
</div>
<div class="alert">
@@ -1,4 +1,4 @@
{% from "popover.njk" import popover %}
{% from "dropdown-menu.njk" import dropdown_menu %}
<section id="breadcrumb" class="w-full rounded-lg border scroll-mt-14">
<header class="border-b px-4 py-3 flex items-center justify-between">
@@ -17,17 +17,17 @@
{% set trigger %}
{% lucide "ellipsis" %}
{% endset %}
{% call popover(
{{ dropdown_menu(
id="demo-breadcrumb-menu",
trigger=trigger,
trigger_attrs={"class": "flex size-9 items-center justify-center h-4 w-4 hover:text-foreground cursor-pointer"}
) %}
<nav role="menu">
<button type="button" role="menuitem">Documentation</button>
<button type="button" role="menuitem">Themes</button>
<button type="button" role="menuitem">GitHub</button>
</nav>
{% endcall %}
trigger_attrs={"class": "flex size-9 items-center justify-center h-4 w-4 hover:text-foreground cursor-pointer"},
popover_attrs={"class": "p-1"},
items=[
{ label: "Documentation" },
{ label: "Themes" },
{ label: "GitHub" }
]
) }}
</li>
<li>{% lucide "chevron-right", { "class": "size-3.5" } %}</li>
<li class="inline-flex items-center gap-1.5">
@@ -11,7 +11,7 @@
<div class="flex flex-wrap items-start gap-4">
{% set options_frameworks = ["Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro"] %}
{% call select(
content_attrs={"class": "w-48"},
popover_attrs={"class": "w-48"},
listbox_attrs={"data-empty": "No framework found."},
is_combobox=true
) %}
@@ -56,7 +56,7 @@
}
] %}
{% call select(
content_attrs={"class": "w-72"},
popover_attrs={"class": "w-72"},
listbox_attrs={"data-empty": "No timezone found."},
is_combobox=true
) %}
@@ -17,10 +17,9 @@
id="demo-dialog-edit-profile",
title="Edit profile",
description="Make changes to your profile here. Click save when you're done.",
main_attrs={"x-ref": "myDialog"},
trigger="Edit Profile",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "w-full sm:max-w-[425px] max-h-[612px]"},
dialog_attrs={"class": "w-full sm:max-w-[425px] max-h-[612px]"},
footer=footer
) %}
<form class="form grid gap-4">
@@ -36,17 +35,16 @@
{% endcall %}
{% set footer %}
<button class="btn-outline" @click="open = false">Close</button>
<button class="btn-outline" onclick="this.closest('dialog').close()">Close</button>
{% endset %}
{% call dialog(
id="dialog-example",
title="Scrollable Content",
description="This is a dialog with scrollable content.",
main_attrs={"x-ref": "myDialog"},
trigger="Scrollable Content",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "w-full sm:max-w-[425px] max-h-[612px]"},
content_body_attrs={"class": "overflow-y-auto scrollbar"},
dialog_attrs={"class": "w-full sm:max-w-[425px] max-h-[612px]"},
body_attrs={"class": "overflow-y-auto scrollbar"},
footer=footer
) %}
<div class="space-y-4 text-sm">
@@ -62,15 +60,6 @@
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
</div>
{% endcall %}
<button
x-init
@click="$dispatch('dialog:open', { id: 'dialog-example' })"
type="button"
class="btn-outline"
>
Trigger dialog:open
</button>
</div>
</div>
</section>
@@ -10,71 +10,69 @@
<div class="p-4">
<div class="flex flex-wrap items-start gap-4">
{% call dropdown_menu(
id="dropdown-menu",
id="dropdown-menu-default",
trigger="Open",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "min-w-56"}
popover_attrs={"class": "min-w-56"}
) %}
<div role="group" aria-labelledby="account-options">
<span id="account-options" role="heading">My Account</span>
<button type="button" role="menuitem">
<div role="heading" id="account-options">My Account</div>
<div role="menuitem">
Profile
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
Billing
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘B</span>
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
Settings
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘S</span>
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
Keyboard shortcuts
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘K</span>
</button>
</div>
</div>
<hr role="separator">
<button type="button" role="menuitem">GitHub</button>
<button type="button" role="menuitem">Support</button>
<button type="button" role="menuitem" disabled>API</button>
<div role="menuitem">GitHub</div>
<div role="menuitem">Support</div>
<div role="menuitem" aria-disabled="true">API</div>
<hr role="separator">
<button type="button" role="menuitem">
<div role="menuitem">
Logout
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
</div>
{% endcall %}
{% call dropdown_menu(
id="dropdown-menu-checkboxes",
trigger="Checkboxes",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "min-w-56"},
register_on=["alpine:init", "htmx:afterSwap"]
popover_attrs={"class": "min-w-56"}
) %}
<div role="group" aria-labelledby="account-options">
<span id="account-options" role="heading">Account Options</span>
<button type="button" role="menuitem">
<div role="heading" id="account-options">Account Options</div>
<div role="menuitem">
{% lucide "user" %}
Profile
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
{% lucide "credit-card" %}
Billing
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘B</span>
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
{% lucide "settings" %}
Settings
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘S</span>
</button>
</div>
</div>
<hr role="separator">
<div role="group" aria-labelledby="appearance-options">
<span id="appearance-options" role="heading">Appearance</span>
<button
type="button"
<div role="heading" id="appearance-options">Appearance</div>
<div
role="menuitemcheckbox"
aria-checked="true"
class="group"
@@ -89,13 +87,12 @@
} %}
Status Bar
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
<button
type="button"
</div>
<div
role="menuitemcheckbox"
aria-checked="false"
class="group"
disabled
aria-disabled="true"
x-data="{ checked: false }"
@click="checked = !checked"
:aria-checked="checked"
@@ -107,9 +104,8 @@
} %}
Activity Bar
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘B</span>
</button>
<button
type="button"
</div>
<div
role="menuitemcheckbox"
aria-checked="false"
class="group"
@@ -124,18 +120,16 @@
} %}
Panel
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘S</span>
</button>
</div>
</div>
<hr role="separator">
<button type="button" role="menuitem">
<div role="menuitem">
{% lucide "log-out" %}
Logout
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
</div>
{% endcall %}
{% 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"/>
@@ -145,42 +139,39 @@
</div>
</div>
<hr role="separator">
<button type="button" role="menuitem">
<div role="menuitem">
{% lucide "star" %}
Upgrade to Pro
</button>
</div>
<hr role="separator">
<button type="button" role="menuitem">
<div role="menuitem">
{% lucide "user" %}
Account
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
{% lucide "credit-card" %}
Billing
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
{% lucide "bell" %}
Notifications
</button>
</div>
<hr role="separator">
<button type="button" role="menuitem">
<div role="menuitem">
{% lucide "log-out" %}
Signout
</button>
</div>
{% endset %}
{% call dropdown_menu(
id="dropdown-menu-radio-group",
trigger="Radio Group",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "min-w-56"},
register_on=["alpine:init", "htmx:afterSwap"]
popover_attrs={"class": "min-w-56"}
) %}
<div role="group" aria-labelledby="position-options" x-data="{ checked: 'bottom' }">
<span id="position-options" role="heading">Panel Position</span>
<hr role="separator">
<button
type="button"
<div
role="menuitemradio"
aria-checked="false"
class="group"
@@ -196,9 +187,8 @@
</div>
Status Bar
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
<button
type="button"
</div>
<div
role="menuitemradio"
aria-checked="false"
class="group"
@@ -214,9 +204,8 @@
</div>
Activity Bar
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘B</span>
</button>
<button
type="button"
</div>
<div
role="menuitemradio"
aria-checked="false"
class="group"
@@ -232,7 +221,7 @@
</div>
Panel
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘S</span>
</button>
</div>
</div>
{% endcall %}
@@ -259,7 +248,7 @@
id="dropdown-checkboxes",
trigger=trigger,
trigger_attrs={"class": "btn-icon-ghost rounded-full size-8"},
content_attrs={"data-align": "end"}
popover_attrs={"data-align": "end"}
) %}
{{ profile_menu | safe }}
{% endcall %}
@@ -271,21 +260,21 @@
id="dropdown-checkboxes",
trigger=trigger,
trigger_attrs={"class": "btn-icon-ghost"},
content_attrs={"class": "min-w-32"}
popover_attrs={"class": "min-w-32"}
) %}
<button type="button" role="menuitem">
<div role="menuitem">
{% lucide "pencil" %}
Edit
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
{% lucide "share" %}
Share
</button>
</div>
<hr role="separator">
<button type="button" role="menuitem" class="text-destructive hover:bg-destructive/10 dark:hover:bg-destructive/20 focus:bg-destructive/10 dark:focus:bg-destructive/20 focus:text-destructive [&_svg]:!text-destructive">
<div role="menuitem" class="text-destructive hover:bg-destructive/10 dark:hover:bg-destructive/20 focus:bg-destructive/10 dark:focus:bg-destructive/20 focus:text-destructive [&_svg]:!text-destructive">
{% lucide "trash-2" %}
Delete
</button>
</div>
{% endcall %}
</div>
</div>
@@ -12,7 +12,7 @@
id="demo-popover",
trigger="Open popover",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "w-80 p-4"}
popover_attrs={"class": "w-80"}
) %}
<div class="grid gap-4">
<header class="grid gap-1.5">
@@ -8,7 +8,6 @@
</a>
</header>
<div class="p-4 gap">
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2 md:flex-row">
<select class="select w-[180px]">
@@ -28,46 +27,70 @@
<div class="flex flex-wrap items-center gap-4">
<div class="flex flex-wrap items-center gap-2 md:flex-row">
{% call select(trigger_attrs={"class": "w-[180px]"}) %}
<div role="group" aria-labelledby="fruit-options-label">
<span id="fruit-options-label" role="heading">Fruits</span>
<div role="option" data-value="apple">Apple</div>
<div role="option" data-value="banana">Banana</div>
<div role="option" data-value="blueberry">Blueberry</div>
</div>
<div role="group" aria-labelledby="vegetable-options-label">
<span id="vegetable-options-label" role="heading">Grapes</span>
<div role="option" data-value="pineapple">Pineapple</div>
</div>
{% endcall %}
{{ select(
id="select-default",
trigger_attrs={"class": "w-[180px]"},
selected="blueberry",
items=[
{
type: "group",
label: "Fruits",
items: [
{ type: "item", label: "Apple", value: "apple" },
{ type: "item", label: "Banana", value: "banana" },
{ type: "item", label: "Blueberry", value: "blueberry" }
]
},
{
type: "group",
label: "Grapes",
items: [
{ type: "item", label: "Pineapple", value: "pineapple" }
]
}
]
) }}
{% call select(
id="select-scrollbar",
trigger_attrs={"class": "w-[180px]"},
listbox_attrs={"class": "scrollbar overflow-y-auto max-h-64"}
) %}
{% for i in range(0, 99) %}
<div role="option" data-value="item-{{ i }}">Item {{ i }}</div>
{% endfor %}
{% for i in range(0, 99) %}
<div role="option" data-value="item-{{ i }}">Item {{ i }}</div>
{% endfor %}
{% endcall %}
{% call select(trigger_attrs={"disabled": "disabled", "class": "w-[180px]"}) %}
{% call select(
id="select-disabled",
trigger_attrs={"disabled": "disabled", "class": "w-[180px]"}
) %}
<div role="option" data-value="disabled">Disabled</div>
{% endcall %}
{% call select(name="just-testing", trigger_attrs={'class': 'w-[180px]'}) %}
<div type="button" role="option" data-value="bar">
{% lucide "chart-bar", {"class": "text-muted-foreground"} %}
Bar
</div>
<div type="button" role="option" data-value="line">
{% lucide "chart-line", {"class": "text-muted-foreground"} %}
Line
</div>
<div type="button" role="option" data-value="pie">
{% lucide "chart-pie", {"class": "text-muted-foreground"} %}
Pie
</div>
{% call select(
id="select-with-icon",
name="chart-type",
trigger_attrs={'class': 'w-[180px]'}
) %}
<div type="button" role="option" data-value="bar">
<span class="flex items-center gap-2">
{% lucide "chart-bar", {"class": "text-muted-foreground"} %}
Bar
</span>
</div>
<div type="button" role="option" data-value="line">
<span class="flex items-center gap-2">
{% lucide "chart-line", {"class": "text-muted-foreground"} %}
Line
</span>
</div>
<div type="button" role="option" data-value="pie">
<span class="flex items-center gap-2">
{% lucide "chart-pie", {"class": "text-muted-foreground"} %}
Pie
</span>
</div>
{% endcall %}
</div>
</div>
@@ -13,18 +13,24 @@
min="0"
max="27"
value="12"
x-data="{
updatePercent(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)"
>
</div>
</div>
</section>
</section>
<script>
(() => {
const sliderEl = document.querySelector('input[type="range"].input');
if (!sliderEl) 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}%`);
};
updateSlider(sliderEl);
sliderEl.addEventListener('input', (event) => updateSlider(event.target));
})();
</script>
+18 -35
View File
@@ -12,8 +12,11 @@
{% endset %}
{% call popover(
trigger=trigger,
trigger_attrs={"class": "btn-ghost p-2 h-12 w-full flex items-center justify-start"},
content_attrs={
trigger_attrs={
"class": "btn-ghost p-2 h-12 w-full flex items-center justify-start",
"data-keep-mobile-sidebar-open": ""
},
popover_attrs={
"data-side": "right",
"data-align": "end",
"class": "w-80",
@@ -21,12 +24,12 @@
if (window.innerWidth < 768) {
$el.setAttribute('data-side', 'top');
$el.setAttribute('data-align', 'start');
$el.classList.add('w-full');
$el.classList.add('w-[271px]');
}
"
}
) %}
<div class="grid gap-4 p-4">
<div class="grid gap-4">
<header class="grid gap-1.5">
<h2 class="font-semibold">I hope you like Basecoat...</h2>
<p class="text-muted-foreground text-sm">My name is <a href="https://ronanberder.com" target="_blank">Ronan</a> and I made this (and <a class="underline underline-offset-4" href="https://pagescms.org" target="_target">other things</a>). If you find it useful, please consider sponsoring me on GitHub or following me on X.</p>
@@ -49,6 +52,7 @@
<span class="truncate text-xs">v{{ pkg.version }}</span>
</div>
</a>
{# <div class="flex flex-col gap-2 px-2 text-sm">
<form class="form relative">
<label for="search" class="sr-only">Search</label>
@@ -62,26 +66,25 @@
"hx-boost": "true",
"hx-select": "#content",
"hx-target": "#content",
"hx-swap": "outerHTML",
"@click": "if (window.innerWidth < 768) { open = false }"
"hx-swap": "outerHTML"
} %}
{% set icon_home %}{% lucide "house" %}{% endset %}
{% set icon_info %}{% lucide "info" %}{% endset %}
{% set icon_square_terminal %}{% lucide "square-terminal" %}{% endset %}
{% set icon_layout_dashboard %}{% lucide "layout-dashboard" %}{% endset %}
{% set icon_github %}{% lucide "github" %}{% endset %}
{% set icon_github %}<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>{% endset %}
{% set icon_discord %}<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>{% endset %}
{% set icon_github %}<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>{% endset %}
{% set icon_discord %}<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>{% endset %}
{% set menu = [
{ type: "group", label: "Getting started", items: [
{ label: "Introduction", url: "/introduction", icon: icon_info, attrs: link_attrs },
{ label: "Installation", url: "/installation", icon: icon_square_terminal, attrs: link_attrs },
{ label: "Kitchen sink", url: "/kitchen-sink", icon: icon_layout_dashboard, attrs: link_attrs },
{ label: "GitHub", url: "https://github.com/hunvreus/basecoat", icon: icon_github, attrs: {
"target": "_blank",
"@click": "if (window.innerWidth < 768) { open = false }"
}
}
{ label: "GitHub", url: "https://github.com/hunvreus/basecoat", icon: icon_github, attrs: { "target": "_blank" } },
{ label: "Discord", url: "https://basecoatui.com/chat", icon: icon_discord, attrs: { "target": "_blank" } }
]},
{ type: 'group', label: 'Components', items: [
{ label: "Accordion", url: "/components/accordion", attrs: link_attrs },
@@ -117,30 +120,10 @@
{{ sidebar(
id="sidebar",
label="Sidebar navigation",
header=header,
footer=footer,
content_attrs={
"x-data": "{
updateCurrentPage() {
$el.querySelectorAll('a').forEach(link => {
normalizedCurrentPathname = window.location.pathname.endsWith('/') ?
window.location.pathname.slice(0, -1) :
window.location.pathname;
normalizedLinkPathname = link.pathname.endsWith('/') ?
link.pathname.slice(0, -1) :
link.pathname;
if (normalizedCurrentPathname === normalizedLinkPathname) {
link.setAttribute('aria-current', 'page');
} else {
link.setAttribute('aria-current', 'false');
}
});
}
}",
"class": "scrollbar",
"x-init": "updateCurrentPage()",
"@htmx:pushed-into-history.window": "updateCurrentPage()"
},
content_attrs={ "class": "scrollbar" },
menu=menu
) }}
) }}
+745 -522
View File
File diff suppressed because it is too large Load Diff
+46 -49
View File
@@ -6,8 +6,8 @@ toc:
- label: Usage
id: usage
children:
- label: HTML + Javascript
id: usage-default
- label: HTML
id: usage-html
- label: Jinja and Nunjucks
id: usage-macro
---
@@ -18,8 +18,8 @@ toc:
{% set code_html %}
{% 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').close()">Cancel</button>
<button class="btn-primary" onclick="document.getElementById('alert-dialog').close()">Continue</button>
{% endset %}
{{ dialog(
id="alert-dialog",
@@ -29,64 +29,61 @@ toc:
trigger_attrs={"class": "btn-outline"},
footer=footer,
close_button=false,
close_on_overlay_click=false,
register_on=["alpine:init", "htmx:afterSwap"]
close_on_overlay_click=false
) }}
{% endset %}
{% set code_script %}
<script>
{% fetchCode "src/js/dialog.js" %}
document.addEventListener('alpine:init', () => {
window.basecoat.registerDialog(Alpine);
});
</script>
{% endset %}
{{ code_preview("alert-dialog", code_script | prettyHtml ~ code_html | prettyHtml) }}
{{ code_preview("alert-dialog", code_html | prettyHtml) }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<h3 id="usage-default"><a href="#usage-default">HTML + Javascript</a></h3>
<div class="alert my-6">
{% lucide "circle-alert" %}
<h2>This component requires Javascript.</h2>
</div>
<h3 id="usage-default"><a href="#usage-html">HTML</a></h3>
<section class="prose">
<p>The Alert Dialog component uses the same Alpine.js code as the <a href="/components/dialog" class="font-medium underline underline-offset-4">Dialog</a> component with one small difference: we set <code>initialCloseOnOverlayClick</code> to <code>false</code> to prevent the dialog from closing when the overlay is clicked.</p>
<p>You can include the Javascript code provided below, load it as an individual file or use the CLI. Some Alpine.js properties are also required on certain elements (e.g. <code class="text-xs">x-bind</code>, <code class="text-xs">x-data</code>, <code class="text-xs">@click</code>).</p>
<p>The Alert Dialog component is identical to <a href="/components/dialog" class="font-medium underline underline-offset-4">Dialog</a> except for two small differences: we do not display a close button and we do not let the user close the dialog when clicking on the backdrop (no <code>onclick</code> attribute on the <code>&lt;dialog&gt;</code> or the <code>&lt;article&gt;</code> elements).</p>
</section>
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="/installation/#install-js">
Components with Javascript
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="/installation/#install-cli">
Use the CLI
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/js/dialog.js" target="_blank">
Individual JS file (dialog.js)
{% lucide "arrow-right" %}
</a>
</div>
{{ code_block(code_html | prettyHtml, class="") }}
{% from "tabs.njk" import tabs %}
{% set tabsets = [
{ tab: "HTML", panel: code_block(code_html | prettyHtml, class="") },
{ tab: "Javascipt (Alpine.js)", panel: code_block(code_script | prettyHtml, class="") }
] %}
{{ tabs(id="alert-dialog", tabsets=tabsets) }}
<section class="prose">
<p>The component has the following HTML structure:</p>
<dl>
<dt><code class="highlight language-html">&lt;button type="button"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The trigger button to open the alert dialog (using <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal" target="_blank">the native <code>showModal()</code> method</a>).</dd>
<dt><code class="highlight language-html">&lt;dialog class="dialog" id="{ DIALOG_ID }"&gt;</code></dt>
<dd>Wraps the whole component and acts as the backdrop. It can also have the following attributes:
<ul>
<li><code>aria-labelledby="{ TITLE_ID }"</code>: linked to by the <code>aria-labelledby</code> attribute of the dialog.</li>
<li><code>aria-describedby="{ DESCRIPTION_ID }"</code>: linked to by the <code>aria-describedby</code> attribute of the <code>&lt;dialog&gt;</code>.</li>
</ul>
<dl>
<dt><code class="highlight language-html">&lt;article&gt;</code></dt>
<dd>Wraps the content of the alert dialog.
<dl>
<dt><code class="highlight language-html">&lt;header&gt;</code></dt>
<dd>Contains the header for the alert dialog:
<dl>
<dt><code class="highlight language-html">&lt;h2&gt;</code></dt>
<dd>The title of the alert dialog, must have an <code>id</code> if you use the <code>aria-labelledby</code> attribute on the <code>&lt;dialog&gt;</code>.</dd>
<dt><code class="highlight language-html">&lt;p&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The description of the alert dialog, must have an <code>id</code> if you use the <code>aria-describedby</code> attribute on the <code>&lt;dialog&gt;</code>.</dd>
</dl>
</dd>
<dt><code class="highlight language-html">&lt;section&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>For the content.</dd>
<dt><code class="highlight language-html">&lt;footer&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>Usually contains actions.</dd>
</dl>
</dd>
</dl>
</dd>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
<div class="prose">
<p>You can use the <code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs">dialog()</code> Nunjucks or Jinja macro for this component. If you use one of the macros, make sure to set <code>close_on_overlay_click</code> and <code>close_button</code> to <code>False</code> (or <code>false</code> for Nunjucks).</p>
<p>You can use the <code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs">dialog()</code> Nunjucks or Jinja macro for this component. Make sure to set <code>close_on_overlay_click</code> and <code>close_button</code> to <code>False</code> (or <code>false</code> for Nunjucks).</p>
</div>
<div class="flex flex-wrap gap-2 my-6">
@@ -105,8 +102,8 @@ document.addEventListener('alpine:init', () => {
</div>
{% set raw_code %}{% raw %}{% 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('demo-alert-dialog').close()">Cancel</button>
<button class="btn-primary" onclick="document.getElementById('demo-alert-dialog').close()">Continue</button>
{% endset %}
{{ dialog(
id="demo-alert-dialog",
+13 -6
View File
@@ -29,16 +29,23 @@ toc:
<h2 id="usage">Usage</h2>
<section class="prose">
<p>You can use one of the two classes: <code>alert</code> or <code>alert-dialog</code> with any of the following children:</p>
<ul>
<li>A svg icon,</li>
<li>A <code>&lt;h2&gt;</code> for the title.</li>
<li>A <code>&lt;section&gt;</code> for the description. This can include paragraphs, lists, etc.</li>
</ul>
<p>You can use one of the two classes: <code>alert</code> or <code>alert-dialog</code>.</p>
</section>
{{ code_block(code) }}
<section class="prose">
<p>The component has the following HTML structure:</p>
<dl>
<dt><code class="highlight language-html">&lt;svg&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The icon.</dd>
<dt><code class="highlight language-html">&lt;h2&gt;</code></dt>
<dd>The title.</dd>
<dt><code class="highlight language-html">&lt;section&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The description.</dd>
</dl>
</section>
<h2 id="examples"><a href="#examples">Examples</a></h2>
<h3 id="example-destructive"><a href="#example-destructive">Destructive</a></h3>
+4 -42
View File
@@ -16,16 +16,6 @@ toc:
<h2>There is no dedicated breadcrumb component in Basecoat.</h2>
</div>
{% set code_script %}
<script>
{% fetchCode "src/js/dropdown-menu.js" %}
document.addEventListener('alpine:init', () => {
window.basecoat.registerDropdownMenu(Alpine);
});
</script>
{% endset %}
{% set code_html %}
<ol class="text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5">
<li class="inline-flex items-center gap-1.5">
@@ -39,8 +29,7 @@ document.addEventListener('alpine:init', () => {
{% call dropdown_menu(
id="demo-breadcrumb-menu",
trigger=trigger,
trigger_attrs={"class": "flex size-9 items-center justify-center h-4 w-4 hover:text-foreground cursor-pointer"},
register_on=["alpine:init", "htmx:afterSwap"]
trigger_attrs={"class": "flex size-9 items-center justify-center h-4 w-4 hover:text-foreground cursor-pointer"}
) %}
<nav role="menu">
<button type="button" role="menuitem">Documentation</button>
@@ -60,39 +49,12 @@ document.addEventListener('alpine:init', () => {
</ol>
{% endset %}
{{ code_preview("dropdown-menu", code_script | prettyHtml ~ code_html | prettyHtml) }}
{{ code_preview("dropdown-menu", code_html | prettyHtml) }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<div class="alert my-6">
{% lucide "circle-alert" %}
<h2>This component requires Javascript.</h2>
</div>
<section class="prose">
<p>This example uses some simple Tailwind CSS styles along with the <a href="/components/dropdown-menu">Dropdown Menu</a>.</p>
<p>You can include the Javascript code provided below, load it as an individual file or use the CLI. Some Alpine.js properties are also required on certain elements (e.g. <code class="text-xs">x-bind</code>, <code class="text-xs">x-data</code>, <code class="text-xs">@click</code>).</p>
<p>This example uses some simple Tailwind CSS styles along with the <a href="/components/dropdown-menu">Dropdown Menu</a> component (which may require JavaScript).</p>
</section>
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="/installation/#install-js">
Components with Javascript
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="/installation/#install-cli">
Use the CLI
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/js/select.js" target="_blank">
Individual JS file (select.js)
{% lucide "arrow-right" %}
</a>
</div>
{% from "tabs.njk" import tabs %}
{% set tabsets = [
{ tab: "HTML", panel: code_block(code_html | prettyHtml, class="") },
{ tab: "Javascipt (Alpine.js)", panel: code_block(code_script | prettyHtml, class="") }
] %}
{{ tabs(id="alert-dialog", tabsets=tabsets) }}
{{ code_block(code_html | prettyHtml, class="") }}
+24 -9
View File
@@ -47,15 +47,6 @@ toc:
<h2 id="usage">Usage</h2>
<section class="prose">
<p>Add the <code>card</code> class to any element and then define any of the children elements:</p>
<ul>
<li>A <code>&lt;header&gt;</code> for the card header, which can contain a <code>&lt;h2&gt;</code> for the title and a <code>&lt;p&gt;</code> for the description.</li>
<li>A <code>&lt;section&gt;</code> for the card content.</li>
<li>A <code>&lt;footer&gt;</code> for the card footer.</li>
</ul>
</section>
{% set code %}<div class="card">
<header>
<h2>Card Title</h2>
@@ -70,6 +61,30 @@ toc:
</div>{% endset %}
{{ code_block(code) }}
<section class="prose">
<p>The component has the following HTML structure:</p>
<dl>
<dt><code class="highlight language-html">&lt;div class="card"&gt;</code></dt>
<dd>The card container.
<dl>
<dt><code class="highlight language-html">&lt;header&gt;</code></dt>
<dd>The header of the card.
<dl>
<dt><code class="highlight language-html">&lt;h2&gt;</code></dt>
<dd>The title of the card.</dd>
<dt><code class="highlight language-html">&lt;p&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The description of the card.</dd>
</dl>
</dd>
<dt><code class="highlight language-html">&lt;section&gt;</code></dt>
<dd>The content of the card.</dd>
<dt><code class="highlight language-html">&lt;footer&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The footer of the card.</dd>
</dl>
</dd>
</dl>
</section>
<h2 id="examples"><a href="#examples">Examples</a></h2>
<h3 id="example-with-content-and-footer"><a href="#example-with-content-and-footer">With content and footer</a></h3>
+814
View File
@@ -0,0 +1,814 @@
---
layout: layouts/page.njk
title: Chart
description: Displays the path to the current resource using a hierarchy of links.
toc:
- label: Usage
id: usage
---
<div class="grid gap-6">
<div class="grid grid-cols-2 gap-6">
<div class="card">
<header>
<h2>Area Chart</h2>
<p>Showing total visitors for the last 6 months</p>
</header>
<section>
<canvas id="chart-area-default"></canvas>
</section>
<footer>
<div class="grid gap-2 text-sm">
<div class="flex items-center gap-2 leading-none font-medium">
Trending up by 5.2% this month
{% lucide "trending-up", { "class": "size-4" } %}
</div>
<div class="text-muted-foreground flex items-center gap-2 leading-none">January - June 2024</div>
</div>
</footer>
</div>
<div class="card">
<header>
<h2>Area Chart - Stacked</h2>
<p>Showing total visitors for the last 6 months</p>
</header>
<section>
<canvas id="chart-area-stacked"></canvas>
</section>
<footer>
<div class="grid gap-2 text-sm">
<div class="flex items-center gap-2 leading-none font-medium">
Trending up by 5.2% this month
{% lucide "trending-up", { "class": "size-4" } %}
</div>
<div class="text-muted-foreground flex items-center gap-2 leading-none">January - June 2024</div>
</div>
</footer>
</div>
<div class="card">
<header>
<h2>Bar Chart - Multiple</h2>
<p>Showing total visitors for the last 6 months</p>
</header>
<section>
<canvas id="chart-bar-multiple"></canvas>
</section>
<footer>
<div class="grid gap-2 text-sm">
<div class="flex items-center gap-2 leading-none font-medium">
Trending up by 5.2% this month
{% lucide "trending-up", { "class": "size-4" } %}
</div>
<div class="text-muted-foreground flex items-center gap-2 leading-none">January - June 2024</div>
</div>
</footer>
</div>
<div class="card">
<header>
<h2>Doughnut Chart</h2>
<p>Showing total visitors for the last 6 months</p>
</header>
<section class="relative">
<div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center">
<div class="text-3xl font-semibold">1,125</div>
<div class="text-muted-foreground text-xs">Visitors</div>
</div>
<canvas id="chart-doughnut" class="max-h-[200px] mx-auto"></canvas>
</div>
</section>
</div>
</div>
<div class="card">
<header>
<h2>Bar Chart - Interactive</h2>
<p>Showing total visitors for the last 6 months</p>
</header>
<section>
<canvas id="chart-bar-interactive"></canvas>
</section>
</div>
<div class="card">
<header>
<h2>Bar Chart - Interactive</h2>
<p>Showing total visitors for the last 6 months</p>
</header>
<section class="max-h-[150px]">
<canvas id="chart-line-interactive"></canvas>
</section>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
// Helper function to MULTIPLY the alpha channel of oklch colors by a factor
function setAlpha(colorString, alphaFactor) {
colorString = colorString.trim();
if (colorString.startsWith('oklch(')) {
const match = colorString.match(/^oklch\(([^\)]+?)\s*(?:\/\s*([\d.]+)(%?)\s*)?\)$/);
if (match && match[1]) {
const lchPart = match[1];
let currentAlpha = 1.0;
if (match[2]) {
currentAlpha = parseFloat(match[2]);
if (match[3] === '%') {
currentAlpha /= 100;
}
}
let newAlpha = currentAlpha * alphaFactor;
newAlpha = Math.max(0, Math.min(1, newAlpha));
return `oklch(${lchPart} / ${newAlpha.toFixed(3)})`;
} else {
console.warn('Failed to parse oklch string:', colorString);
return colorString;
}
}
return colorString;
}
const computedStyle = getComputedStyle(document.documentElement);
const themeColors = {
background: computedStyle.getPropertyValue('--background').trim(),
border: computedStyle.getPropertyValue('--border').trim(),
mutedForeground: computedStyle.getPropertyValue('--muted-foreground').trim(),
foreground: computedStyle.getPropertyValue('--foreground').trim(),
chart1: computedStyle.getPropertyValue('--chart-1').trim(),
chart2: computedStyle.getPropertyValue('--chart-2').trim(),
chart3: computedStyle.getPropertyValue('--chart-3').trim(),
chart4: computedStyle.getPropertyValue('--chart-4').trim(),
chart5: computedStyle.getPropertyValue('--chart-5').trim(),
};
const getOrCreateTooltip = (chart) => {
let tooltipEl = chart.canvas.parentNode.querySelector('div[role="tooltip"]');
if (!tooltipEl) {
const tooltipHTML = `
<div
role="tooltip"
class="border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl opacity-0 absolute pointer-events-none transition-all duration-400 space-y-1"
>
<h3 class="font-medium"></h3>
<ul class="grid gap-1"></ul>
</div>`;
const parser = new DOMParser();
const doc = parser.parseFromString(tooltipHTML.trim(), 'text/html');
tooltipEl = doc.body.firstChild;
chart.canvas.parentNode.appendChild(tooltipEl);
}
return tooltipEl;
};
const externalTooltipHandler = (context) => {
const {chart, tooltip} = context;
const chartType = chart.config._config.type;
const tooltipEl = getOrCreateTooltip(chart);
const titleEl = tooltipEl.querySelector('h3');
const bodyEl = tooltipEl.querySelector('ul');
// Hide if no tooltip
if (tooltip.opacity === 0) {
tooltipEl.classList.remove('opacity-100');
tooltipEl.classList.add('opacity-0');
return;
}
let bodyHtml = '';
if (tooltip.body) {
const titleLines = tooltip.title || [];
const bodyLines = tooltip.body.map(b => b.lines);
if (titleEl) {
if (chartType === 'doughnut') {
titleEl.remove();
} else {
titleEl.textContent = titleLines[0] || '';
}
}
bodyLines.forEach((body, i) => {
const colors = tooltip.labelColors[i];
const parts = body[0].split(': ');
const label = chartType === 'doughnut'
? titleLines[0] || ''
: parts[0] || '';
const value = parts[1] || '';
let color;
switch (chartType) {
case 'doughnut':
case 'pie':
case 'bar':
color = colors.backgroundColor;
break;
default:
color = colors.borderColor;
}
bodyHtml += `<li class="flex w-full flex-wrap items-center justify-between leading-none gap-x-2">
<div class="flex items-center gap-x-1.5">
<div class="shrink-0 rounded-[2px] size-2.5 block" style="background:${color}"></div>
<span class="text-muted-foreground">${label}</span>
</div>
<span class="text-foreground font-medium font-mono tabular-nums">${value}</span>
</li>`;
});
}
if (bodyEl) bodyEl.innerHTML = bodyHtml;
const {offsetLeft: positionX, offsetTop: positionY} = chart.canvas;
const tooltipHeight = tooltipEl.offsetHeight;
const tooltipWidth = tooltipEl.offsetWidth;
const caretX = tooltip.caretX;
const caretY = tooltip.caretY;
const xAlign = tooltip.xAlign;
const yAlign = tooltip.yAlign;
const tooltipOffset = 8;
let left = positionX + caretX;
let top = positionY + caretY;
if (xAlign === 'center') {
left -= tooltipWidth / 2;
} else if (xAlign === 'right') {
left -= (tooltipWidth + tooltipOffset);
} else { // xAlign === 'left'
left += tooltipOffset;
}
if (yAlign === 'center') {
top -= tooltipHeight / 2;
} else if (yAlign === 'top') {
top -= (tooltipHeight + tooltipOffset);
} else {
top += tooltipOffset;
}
const canvasBounds = chart.canvas.getBoundingClientRect();
if (left < 0) {
left = 0;
} else if (left + tooltipWidth > canvasBounds.width) {
left = canvasBounds.width - tooltipWidth;
}
if (top < 0) {
top = 0;
} else if (top + tooltipHeight > window.innerHeight) {
const flippedTop = positionY + caretY - tooltipHeight - tooltipOffset;
if (flippedTop >= 0) top = flippedTop;
}
tooltipEl.classList.remove('opacity-0');
tooltipEl.classList.add('opacity-100');
tooltipEl.style.left = left + 'px';
tooltipEl.style.top = top + 'px';
};
let ctx = document.getElementById('chart-area-default');
let data = {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
datasets: [{
label: 'Desktop',
data: [186, 305, 237, 73, 209, 214],
borderWidth: 1,
backgroundColor: setAlpha(themeColors.chart1, 0.2),
borderColor: themeColors.chart1,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: themeColors.chart1,
pointHoverBorderColor: themeColors.chart1,
fill: 'origin'
}]
};
let config = {
type: 'line',
data: data,
options: {
tension: 0.4,
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
},
scales: {
y: {
border: { display: false },
grid: {
color: setAlpha(themeColors.border, 0.5),
lineWidth: 1
},
ticks: { display: false }
},
x: {
grid: { display: false },
ticks: { color: themeColors.mutedForeground }
}
},
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: false,
position: 'nearest',
external: externalTooltipHandler,
}
}
}
};
new Chart(ctx, config);
ctx = document.getElementById('chart-area-stacked');
data = {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
datasets: [{
label: 'Desktop',
data: [186, 305, 237, 73, 209, 214],
borderWidth: 1,
backgroundColor: setAlpha(themeColors.chart1, 0.2),
borderColor: themeColors.chart1,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: themeColors.chart1,
pointHoverBorderColor: themeColors.chart1,
fill: 'origin'
}, {
label: 'Mobile',
data: [80, 200, 120, 190, 130, 140],
borderWidth: 1,
backgroundColor: setAlpha(themeColors.chart2, 0.2),
borderColor: themeColors.chart2,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: themeColors.chart2,
pointHoverBorderColor: themeColors.chart2,
fill: 'origin'
}]
};
config = {
type: 'line',
data: data,
options: {
tension: 0.4,
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
},
scales: {
y: {
border: { display: false },
grid: {
color: setAlpha(themeColors.border, 0.5),
lineWidth: 1
},
ticks: { display: false }
},
x: {
grid: { display: false },
ticks: { color: themeColors.mutedForeground }
}
},
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: false,
position: 'nearest',
external: externalTooltipHandler,
}
}
}
};
new Chart(ctx, config);
ctx = document.getElementById('chart-bar-multiple');
data = {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
datasets: [{
label: 'Desktop',
data: [186, 305, 237, 73, 209, 214],
backgroundColor: themeColors.chart1,
borderWidth: 0,
borderRadius: 4,
barPercentage: 0.7,
categoryPercentage: 0.8
}, {
label: 'Mobile',
data: [80, 200, 120, 190, 130, 140],
backgroundColor: themeColors.chart2,
borderWidth: 0,
borderRadius: 4,
barPercentage: 0.7,
categoryPercentage: 0.8
}]
};
config = {
type: 'bar',
data: data,
options: {
interaction: {
mode: 'index',
axis: 'x',
intersect: false
},
scales: {
y: {
border: { display: false },
grid: {
color: setAlpha(themeColors.border, 0.5),
lineWidth: 1
},
ticks: { display: false }
},
x: {
grid: { display: false },
ticks: { color: themeColors.mutedForeground }
}
},
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: false,
position: 'nearest',
external: externalTooltipHandler,
}
}
}
};
new Chart(ctx, config);
const chartData = [
{ date: "2024-04-01", desktop: 222, mobile: 150 },
{ date: "2024-04-02", desktop: 97, mobile: 180 },
{ date: "2024-04-03", desktop: 167, mobile: 120 },
{ date: "2024-04-04", desktop: 242, mobile: 260 },
{ date: "2024-04-05", desktop: 373, mobile: 290 },
{ date: "2024-04-06", desktop: 301, mobile: 340 },
{ date: "2024-04-07", desktop: 245, mobile: 180 },
{ date: "2024-04-08", desktop: 409, mobile: 320 },
{ date: "2024-04-09", desktop: 59, mobile: 110 },
{ date: "2024-04-10", desktop: 261, mobile: 190 },
{ date: "2024-04-11", desktop: 327, mobile: 350 },
{ date: "2024-04-12", desktop: 292, mobile: 210 },
{ date: "2024-04-13", desktop: 342, mobile: 380 },
{ date: "2024-04-14", desktop: 137, mobile: 220 },
{ date: "2024-04-15", desktop: 120, mobile: 170 },
{ date: "2024-04-16", desktop: 138, mobile: 190 },
{ date: "2024-04-17", desktop: 446, mobile: 360 },
{ date: "2024-04-18", desktop: 364, mobile: 410 },
{ date: "2024-04-19", desktop: 243, mobile: 180 },
{ date: "2024-04-20", desktop: 89, mobile: 150 },
{ date: "2024-04-21", desktop: 137, mobile: 200 },
{ date: "2024-04-22", desktop: 224, mobile: 170 },
{ date: "2024-04-23", desktop: 138, mobile: 230 },
{ date: "2024-04-24", desktop: 387, mobile: 290 },
{ date: "2024-04-25", desktop: 215, mobile: 250 },
{ date: "2024-04-26", desktop: 75, mobile: 130 },
{ date: "2024-04-27", desktop: 383, mobile: 420 },
{ date: "2024-04-28", desktop: 122, mobile: 180 },
{ date: "2024-04-29", desktop: 315, mobile: 240 },
{ date: "2024-04-30", desktop: 454, mobile: 380 },
{ date: "2024-05-01", desktop: 165, mobile: 220 },
{ date: "2024-05-02", desktop: 293, mobile: 310 },
{ date: "2024-05-03", desktop: 247, mobile: 190 },
{ date: "2024-05-04", desktop: 385, mobile: 420 },
{ date: "2024-05-05", desktop: 481, mobile: 390 },
{ date: "2024-05-06", desktop: 498, mobile: 520 },
{ date: "2024-05-07", desktop: 388, mobile: 300 },
{ date: "2024-05-08", desktop: 149, mobile: 210 },
{ date: "2024-05-09", desktop: 227, mobile: 180 },
{ date: "2024-05-10", desktop: 293, mobile: 330 },
{ date: "2024-05-11", desktop: 335, mobile: 270 },
{ date: "2024-05-12", desktop: 197, mobile: 240 },
{ date: "2024-05-13", desktop: 197, mobile: 160 },
{ date: "2024-05-14", desktop: 448, mobile: 490 },
{ date: "2024-05-15", desktop: 473, mobile: 380 },
{ date: "2024-05-16", desktop: 338, mobile: 400 },
{ date: "2024-05-17", desktop: 499, mobile: 420 },
{ date: "2024-05-18", desktop: 315, mobile: 350 },
{ date: "2024-05-19", desktop: 235, mobile: 180 },
{ date: "2024-05-20", desktop: 177, mobile: 230 },
{ date: "2024-05-21", desktop: 82, mobile: 140 },
{ date: "2024-05-22", desktop: 81, mobile: 120 },
{ date: "2024-05-23", desktop: 252, mobile: 290 },
{ date: "2024-05-24", desktop: 294, mobile: 220 },
{ date: "2024-05-25", desktop: 201, mobile: 250 },
{ date: "2024-05-26", desktop: 213, mobile: 170 },
{ date: "2024-05-27", desktop: 420, mobile: 460 },
{ date: "2024-05-28", desktop: 233, mobile: 190 },
{ date: "2024-05-29", desktop: 78, mobile: 130 },
{ date: "2024-05-30", desktop: 340, mobile: 280 },
{ date: "2024-05-31", desktop: 178, mobile: 230 },
{ date: "2024-06-01", desktop: 178, mobile: 200 },
{ date: "2024-06-02", desktop: 470, mobile: 410 },
{ date: "2024-06-03", desktop: 103, mobile: 160 },
{ date: "2024-06-04", desktop: 439, mobile: 380 },
{ date: "2024-06-05", desktop: 88, mobile: 140 },
{ date: "2024-06-06", desktop: 294, mobile: 250 },
{ date: "2024-06-07", desktop: 323, mobile: 370 },
{ date: "2024-06-08", desktop: 385, mobile: 320 },
{ date: "2024-06-09", desktop: 438, mobile: 480 },
{ date: "2024-06-10", desktop: 155, mobile: 200 },
{ date: "2024-06-11", desktop: 92, mobile: 150 },
{ date: "2024-06-12", desktop: 492, mobile: 420 },
{ date: "2024-06-13", desktop: 81, mobile: 130 },
{ date: "2024-06-14", desktop: 426, mobile: 380 },
{ date: "2024-06-15", desktop: 307, mobile: 350 },
{ date: "2024-06-16", desktop: 371, mobile: 310 },
{ date: "2024-06-17", desktop: 475, mobile: 520 },
{ date: "2024-06-18", desktop: 107, mobile: 170 },
{ date: "2024-06-19", desktop: 341, mobile: 290 },
{ date: "2024-06-20", desktop: 408, mobile: 450 },
{ date: "2024-06-21", desktop: 169, mobile: 210 },
{ date: "2024-06-22", desktop: 317, mobile: 270 },
{ date: "2024-06-23", desktop: 480, mobile: 530 },
{ date: "2024-06-24", desktop: 132, mobile: 180 },
{ date: "2024-06-25", desktop: 141, mobile: 190 },
{ date: "2024-06-26", desktop: 434, mobile: 380 },
{ date: "2024-06-27", desktop: 448, mobile: 490 },
{ date: "2024-06-28", desktop: 149, mobile: 200 },
{ date: "2024-06-29", desktop: 103, mobile: 160 },
{ date: "2024-06-30", desktop: 446, mobile: 400 },
]
data = {
labels: chartData.map(d => d.date),
datasets: [{
label: 'Desktop',
data: chartData.map(d => d.desktop),
backgroundColor: themeColors.chart2,
borderWidth: 0,
borderRadius: 0, // Stacking usually looks better without individual radius
}, {
label: 'Mobile',
data: chartData.map(d => d.mobile),
backgroundColor: themeColors.chart1,
borderWidth: 0,
borderRadius: 0, // Stacking usually looks better without individual radius
}]
};
ctx = document.getElementById('chart-bar-interactive');
// Restore helper function to format date for labels/tooltips
const formatDateLabel = (dateString) => {
try {
const date = new Date(dateString + 'T00:00:00');
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); // e.g., "Apr 1"
} catch (event) {
return dateString; // Fallback
}
};
// Restore data preparation with formatted date labels
data = {
labels: chartData.map(d => formatDateLabel(d.date)),
datasets: [{
label: 'Desktop',
data: chartData.map(d => d.desktop),
backgroundColor: themeColors.chart4,
borderWidth: 0,
borderRadius: 0,
}, {
label: 'Mobile',
data: chartData.map(d => d.mobile),
backgroundColor: themeColors.chart5,
borderWidth: 0,
borderRadius: 0,
}]
};
config = {
type: 'bar',
data: data,
options: {
interaction: {
mode: 'index',
axis: 'x',
intersect: false
},
scales: {
y: {
stacked: true,
border: { display: false },
grid: {
color: setAlpha(themeColors.border, 0.5),
lineWidth: 1
},
ticks: { display: false }
},
x: {
stacked: true,
grid: { display: false },
ticks: {
color: themeColors.mutedForeground,
callback: function(value, index, ticks) {
const label = this.getLabelForValue(value);
return index % 7 === 0 ? label : null;
},
maxRotation: 0,
minRotation: 0
}
}
},
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: false,
position: 'nearest',
external: externalTooltipHandler,
}
}
}
};
new Chart(ctx, config);
// Same chart but with line chart
// We start off from the previous config and just change the type to line
ctx = document.getElementById('chart-line-interactive');
// Prepare data with formatted date labels AND offset for Mobile
const desktopData = chartData.map(d => d.desktop + 400); // Store raw desktop data
const mobileDataOffset = chartData.map(d => d.mobile); // Store offset mobile data
// Prepare data with gradient background
data = {
labels: chartData.map(d => formatDateLabel(d.date)),
datasets: [{
label: 'Desktop',
data: desktopData,
borderColor: themeColors.chart1,
// Make backgroundColor scriptable
backgroundColor: function(context) {
const chart = context.chart;
const { ctx, chartArea } = chart;
// Use the helper function defined above
return createGradient(ctx, chartArea, themeColors.chart1);
},
borderWidth: 1,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: themeColors.chart1,
pointHoverBorderColor: themeColors.chart1,
fill: 'start' // Fill from the start (usually y=0 or axis baseline)
}, {
label: 'Mobile (Offset)', // Adjusted label
data: mobileDataOffset,
borderColor: themeColors.chart2,
// Make backgroundColor scriptable
backgroundColor: function(context) {
const chart = context.chart;
const { ctx, chartArea } = chart;
// Use the helper function defined above
return createGradient(ctx, chartArea, themeColors.chart2);
},
borderWidth: 1,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: themeColors.chart2,
pointHoverBorderColor: themeColors.chart2,
fill: 'start'
}]
};
// Add the createGradient helper function before the data object
function createGradient(ctx, chartArea, color) {
if (!chartArea) {
return setAlpha(color, 0.1); // Fallback during initial setup
}
const gradient = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
gradient.addColorStop(0, setAlpha(color, 0.5)); // Start color at top
gradient.addColorStop(1, setAlpha(color, 0)); // End color (transparent) at bottom
return gradient;
}
// Calculate min/max for Y-axis scaling using the PLOTTED data
const allPlottedValues = [...desktopData, ...mobileDataOffset]; // Combine actual plotted values
const dataMin = Math.min(...allPlottedValues);
const dataMax = Math.max(...allPlottedValues);
const yPadding = (dataMax - dataMin) * 0.1; // Add 10% padding
config = {
type: 'line',
data: data,
options: {
maintainAspectRatio: false,
aspectRatio: 1,
tension: 0.4, // Keep tension from original line config
interaction: {
mode: 'index', // Use 'index' mode for better hover on multiple lines
axis: 'x',
intersect: false
},
scales: {
y: {
border: { display: false },
grid: {
color: setAlpha(themeColors.border, 0.5),
lineWidth: 1
},
ticks: { display: false },
// Set min and max for Y-axis
min: Math.max(0, dataMin - yPadding), // Ensure min is not negative
max: dataMax + yPadding
},
x: {
grid: { display: false },
ticks: {
color: themeColors.mutedForeground,
// Restore callback to reduce ticks
callback: function(value, index, ticks) {
const label = this.getLabelForValue(value);
return index % 7 === 0 ? label : null;
},
maxRotation: 0,
minRotation: 0
}
}
},
plugins: {
legend: { display: false },
tooltip: {
enabled: false,
position: 'nearest',
external: externalTooltipHandler,
}
}
}
};
new Chart(ctx, config);
const chartDataDoughnut = [
{ browser: "chrome", visitors: 275, fill: themeColors.chart1 }, // Use resolved colors
{ browser: "safari", visitors: 200, fill: themeColors.chart2 },
{ browser: "firefox", visitors: 287, fill: themeColors.chart3 },
{ browser: "edge", visitors: 173, fill: themeColors.chart4 },
{ browser: "other", visitors: 190, fill: themeColors.chart5 },
]
// Prepare data for the doughnut chart
const doughnutData = {
labels: chartDataDoughnut.map(d => d.browser),
datasets: [{
label: 'Visitors by Browser',
data: chartDataDoughnut.map(d => d.visitors),
backgroundColor: chartDataDoughnut.map(d => d.fill),
borderColor: themeColors.background, // Use background for border
borderWidth: 0, // Add border between segments
hoverBorderColor: themeColors.background,
hoverBorderWidth: 0
}]
};
// Configure the doughnut chart
const doughnutConfig = {
type: 'doughnut',
data: doughnutData,
options: {
cutout: '70%', // Adjust hole size
plugins: {
legend: {
display: false, // Hide default legend
},
tooltip: {
enabled: false, // Keep this false as we use external handler
position: 'nearest', // Keep this for external handler logic
external: externalTooltipHandler, // Use the existing handler
},
centerText: {} // Keep the custom plugin for center text
}
}
};
// Assume you have a canvas with id="chart-doughnut" in your HTML
const doughnutCtx = document.getElementById('chart-doughnut').getContext('2d');
new Chart(doughnutCtx, doughnutConfig);
</script>
+79 -44
View File
@@ -6,8 +6,15 @@ toc:
- label: Usage
id: usage
children:
- label: HTML + Javascript
id: usage-default
- label: HTML + JavaScript
id: usage-html-js
children:
- label: "Step 1: Include the JavaScript files"
id: usage-html-js-1
- label: "Step 2: Add your combobox HTML"
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: Jinja and Nunjucks
id: usage-macro
- label: Examples
@@ -29,8 +36,7 @@ toc:
trigger_attrs={"class": "w-[200px]"},
listbox_attrs={"data-empty": "No framework found."},
is_combobox=true,
search_placeholder="Search framework...",
register_on=["alpine:init", "htmx:afterSwap"]
search_placeholder="Search framework..."
) %}
{% for framework in options_frameworks %}
<div role="option" data-value="{{ framework }}">{{ framework }}</div>
@@ -38,42 +44,24 @@ toc:
{% endcall %}
{% endset %}
{% set code_script %}
<script>
{% fetchCode "src/js/select.js" %}
document.addEventListener('alpine:init', () => {
window.basecoat.registerSelect(Alpine);
});
</script>
{% endset %}
{{ code_preview("combobox", code_script | prettyHtml ~ code_html | prettyHtml) }}
{{ code_preview("combobox", code_html | prettyHtml) }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<h3 id="usage-default"><a href="#usage-default">HTML + Javascript</a></h3>
<h3 id="usage-html-js"><a href="#usage-html-js">HTML + JavaScript</a></h3>
<div class="alert my-6">
{% lucide "circle-alert" %}
<h2>This component requires Javascript.</h2>
</div>
<h4 id="usage-html-js-1"><a href="#usage-html-js-1">Step 1: Include the JavaScript files</a></h4>
<div class="prose">
<p>Combobox uses the same Alpine.js code and markup as the <a href="/components/select">Select</a> component, the only difference being the search box at the top of the listbox.</p>
<p>You can set up a few additional options on the <code>&lt;div data-popover&gt;</code> element:</p>
<ul>
<li><code>data-side</code> can be set to <code>top</code>, <code>bottom</code>, <code>left</code>, or <code>right</code> to change the side of the popover.</li>
<li><code>data-align</code> can be set to <code>start</code>, <code>center</code>, or <code>end</code> to change the alignment of the popover.</li>
<li><code>data-empty</code> to set up the message when the search is empty.</li>
</ul>
<section class="prose">
<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>
<p>You can include the Javascript code provided below, load it as an individual file or use the CLI. Some Alpine.js properties are also required on certain elements (e.g. <code class="text-xs">x-bind</code>, <code class="text-xs">x-data</code>, <code class="text-xs">@click</code>).</p>
</div>
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ site.version }}/dist/js/select.min.js" defer></script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="/installation/#install-js">
Components with Javascript
Components with JavaScript
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="/installation/#install-cli">
@@ -81,17 +69,66 @@ document.addEventListener('alpine:init', () => {
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/js/select.js" target="_blank">
Individual JS file (select.js)
select.js
{% lucide "arrow-right" %}
</a>
</div>
{% from "tabs.njk" import tabs %}
{% set tabsets = [
{ tab: "HTML", panel: code_block(code_html | prettyHtml, class="") },
{ tab: "Javascipt (Alpine.js)", panel: code_block(code_script | prettyHtml, class="") }
] %}
{{ tabs(id="combobox", tabsets=tabsets) }}
<h4 id="usage-html-js-2"><a href="#usage-html-js-2">Step 2: Add your combobox HTML</a></h4>
<section class="prose">
<p>Combobox uses the same markup and JavaScript as the <a href="/components/select#usage-html-js">Select component</a>, the only difference being the search box at the top of the listbox:</p>
</section>
{{ code_block(code_html | prettyHtml, "html") }}
<h4 id="usage-html-js-3"><a href="#usage-html-js-3">HTML structure</a></h4>
<section class="prose">
<dl>
<dt><code class="highlight language-html">&lt;div class="select"&gt;</code></dt>
<dd>Wraps around the entire component.
<dl>
<dt><code class="highlight language-html">&lt;button type="button" popovertarget="{ POPOVER_ID }"&gt;</code></dt>
<dd>
<p>The trigger to open the popover, can also have the following attributes:</p>
<ul>
<li><code>id="{BUTTON_ID}"</code>: linked to by the <code>aria-labelledby</code> attribute of the listbox.</li>
<li><code>aria-haspopup="listbox"</code>: indicates that the button opens a listbox.</li>
<li><code>aria-controls="{ LISTBOX_ID }"</code>: points to the listbox's id.</li>
<li><code>aria-expanded="false"</code>: tracks the popover's state.</li>
<li><code>aria-activedescendant="{ OPTION_ID }"</code>: points to the active option's id.</li>
</ul>
</dd>
<dt><code class="highlight language-html">&lt;div popover class="popover" id="{ POPOVER_ID }"&gt;</code></dt>
<dd>As with the <a href="/components/popover">Popover</a> component, you can set up the side and alignment of the popover using the <code>data-side</code> and <code>data-align</code> attributes.
<dl>
<dt><code class="highlight language-html">&lt;header&gt;</code></dt>
<dd>Contains the search input to filter the options in the listbox.</dd>
<dt><code class="highlight language-html">&lt;div role="listbox"&gt;</code></dt>
<dd>The listbox containing the options. Should have the following attributes:
<ul>
<li><code>id="{ LISTBOX_ID }"</code>: refered to by the <code>aria-controls</code> attribute of the trigger.</li>
<li><code>aria-labelledby="{ BUTTON_ID }"</code>: linked to by the button's <code>id</code> attribute.</li>
</ul>
<dl>
<dt><code class="highlight language-html">&lt;div role="option" data-value="{ VALUE }"&gt;</code></dt>
<dd>Option that can be selected.Should have a unique id if you use the <code>aria-activedescendant</code> attribute on the trigger.</dd>
<dt><code class="highlight language-html">&lt;hr role="separator"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>Separator between groups/options.</dd>
<dt><code class="highlight language-html">&lt;div role="group"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>Group of options, can have a <code>aria-labelledby</code> attribute to link to a heading.</dd>
<dt><code class="highlight language-html">&lt;span role="heading"&gt;</code></dt>
<dd>Group heading, must have an <code>id</code> attribute if you use the <code>aria-labelledby</code> attribute on the group.</dd>
</dl>
</dd>
</dl>
</dl>
</dd>
<dt><code class="highlight language-html">&lt;input type="hidden" name="{ NAME }" value="{ VALUE }"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The hidden input that holds the value of the field (if needed).</dd>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
@@ -168,16 +205,15 @@ document.addEventListener('alpine:init', () => {
{% set code %}
{% call select(
trigger_attrs={"class": "w-[200px]"},
content_attrs={"class": "w-72"},
popover_attrs={"class": "w-72"},
listbox_attrs={"data-empty": "No timezone found."},
is_combobox=true,
search_placeholder="Search timezone...",
register_on=["alpine:init", "htmx:afterSwap"]
search_placeholder="Search timezone..."
) %}
<div class="max-h-64 overflow-y-auto scrollbar">
{% for group in options_timezones %}
<div role="group" aria-labelledby="demo-combobox-timezones-group-{{ loop.index0 }}">
<span id="demo-combobox-timezones-group-{{ loop.index0 }}" role="heading">{{ group.label }}</span>
<div role="heading" id="demo-combobox-timezones-group-{{ loop.index0 }}">{{ group.label }}</div>
{% for timezone in group.timezones %}
<div role="option" data-value="{{ timezone.value}}">
{{ timezone.label }}
@@ -204,8 +240,7 @@ document.addEventListener('alpine:init', () => {
listbox_attrs={"data-empty": "No framework found."},
is_combobox=true,
search_placeholder="Search framework...",
register_on=["alpine:init", "htmx:afterSwap"],
content_attrs={"data-side": "top"}
popover_attrs={"data-side": "top"}
) %}
{% for framework in options_frameworks %}
<div role="option" data-value="{{ framework }}">{{ framework }}</div>
+53 -82
View File
@@ -6,8 +6,8 @@ toc:
- label: Usage
id: usage
children:
- label: HTML + Javascript
id: usage-default
- label: HTML
id: usage-html
- label: Jinja and Nunjucks
id: usage-macro
- label: Examples
@@ -23,24 +23,22 @@ toc:
{% set code_html %}
{% 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="this.closest('dialog').close()">Cancel</button>
<button class="btn" onclick="this.closest('dialog').close()">Save changes</button>
{% endset %}
{% call dialog(
id="demo-dialog-edit-profile",
title="Edit profile",
description="Make changes to your profile here. Click save when you're done.",
main_attrs={"x-ref": "myDialog"},
trigger="Edit Profile",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "w-full sm:max-w-[425px] max-h-[612px]"},
footer=footer,
register_on=["alpine:init", "htmx:afterSwap"]
dialog_attrs={"class": "w-full sm:max-w-[425px] max-h-[612px]"},
footer=footer
) %}
<form class="form grid gap-4">
<div class="grid gap-3">
<label for="demo-dialog-edit-profile-name">Name</label>
<input type="text" value="Pedro Duarte" id="demo-dialog-edit-profile-name" x-ref="focusOnOpen" />
<input type="text" value="Pedro Duarte" id="demo-dialog-edit-profile-name" autofocus/>
</div>
<div class="grid gap-3">
<label for="demo-dialog-edit-profile-username">Username</label>
@@ -50,76 +48,52 @@ toc:
{% endcall %}
{% endset %}
{% set code_script %}
<script>
{% fetchCode "src/js/dialog.js" %}
document.addEventListener('alpine:init', () => {
window.basecoat.registerDialog(Alpine);
});
</script>
{% endset %}
{{ code_preview("dialog", code_script | prettyHtml ~ code_html | prettyHtml) }}
{{ code_preview("dialog", code_html | prettyHtml) }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<h3 id="usage-default"><a href="#usage-default">HTML + Javascript</a></h3>
<h3 id="usage-html"><a href="#usage-html">HTML</a></h3>
<div class="alert my-6">
{% lucide "circle-alert" %}
<h2>This component requires Javascript.</h2>
</div>
{{ code_block(code_html | prettyHtml, class="") }}
<section class="prose">
<p>The component is structured as such:</p>
<ul>
<li>A <code>&lt;div class="dialog"&gt;</code> which wraps around the entire component and holds it state (e.g. open/close).</li>
<li>A <code>&lt;button&gt;</code> that acts as the trigger to open or close the popover.</li>
<li>A <code>&lt;div role="dialog"&gt;</code> that holds the dialog itself. It also acts as the backdrop for the dialog.</li>
<li>Inside of it, we have:
<p>The component has the following HTML structure:</p>
<dl>
<dt><code class="highlight language-html">&lt;button type="button"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The trigger button to open the dialog (using <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal" target="_blank">the native <code>showModal()</code> method</a>).</dd>
<dt><code class="highlight language-html">&lt;dialog class="dialog" id="{ DIALOG_ID }"&gt;</code></dt>
<dd>Wraps the whole component and acts as the backdrop. It can also have the following attributes:
<ul>
<li>A <code>&lt;header&gt;</code> that contains the title (<code>&lt;h2&gt;</code>) and description (<code>&lt;p&gt;</code>) of the dialog.</li>
<li>A <code>&lt;section&gt;</code> for the content.</li>
<li>A <code>&lt;footer&gt;</code> that usually contains actions.</li>
<li>A close <code>&lt;button&gt;</code>.</li>
<li><code>aria-labelledby="{ TITLE_ID }"</code>: linked to by the <code>aria-labelledby</code> attribute of the <code>&lt;dialog&gt;</code>.</li>
<li><code>aria-describedby="{ DESCRIPTION_ID }"</code>: linked to by the <code>aria-describedby</code> attribute of the <code>&lt;dialog&gt;</code>.</li>
<li><code>onclick</code>: closes the dialog when the backdrop is clicked.</li>
</ul>
</li>
</ul>
<p>You can include the Javascript code provided below, load it as an individual file or use the CLI. Some Alpine.js properties are also required on certain elements (e.g. <code class="text-xs">x-bind</code>, <code class="text-xs">x-data</code>, <code class="text-xs">@click</code>).</p>
<dl>
<dt><code class="highlight language-html">&lt;article&gt;</code></dt>
<dd>Wraps the content of the dialog. It includes an <code>onclick</code> attribute to prevent from closing the dialog if we have an <code>onclick</code> event on <code>&lt;dialog&gt;</code>.
<dl>
<dt><code class="highlight language-html">&lt;header&gt;</code></dt>
<dd>Contains the header for the dialog:
<dl>
<dt><code class="highlight language-html">&lt;h2&gt;</code></dt>
<dd>The title of the dialog, must have an <code>id</code> if you use the <code>aria-labelledby</code> attribute on the <code>&lt;dialog&gt;</code>.</dd>
<dt><code class="highlight language-html">&lt;p&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The description of the dialog, must have an <code>id</code> if you use the <code>aria-describedby</code> attribute on the <code>&lt;dialog&gt;</code>.</dd>
</dl>
</dd>
<dt><code class="highlight language-html">&lt;section&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>For the content.</dd>
<dt><code class="highlight language-html">&lt;footer&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>Usually contains actions.</dd>
<dt><code class="highlight language-html">&lt;form method="dialog"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>A form that contains a single button to close the dialog.</dd>
</dl>
</dd>
</dl>
</dd>
</dl>
</section>
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="/installation/#install-js">
Components with Javascript
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="/installation/#install-cli">
Use the CLI
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/js/dialog.js" target="_blank">
Individual JS file (dialog.js)
{% lucide "arrow-right" %}
</a>
</div>
{% from "tabs.njk" import tabs %}
{% set tabsets = [
{ tab: "HTML", panel: code_block(code_html | prettyHtml, class="") },
{ tab: "Javascipt (Alpine.js)", panel: code_block(code_script | prettyHtml, class="") }
] %}
{{ tabs(id="combobox", tabsets=tabsets) }}
<section class="prose">
<p>The component will dispatch <code>dialog:opened</code> and <code>dialog:closed</code> events when the dialog is opened or closed. You can also dispatch <code>dialog:open</code> and <code>dialog:close</code> events to open or close the dialog programmatically. You will need the element to have an <code>id</code> to be able to target it (e.g. <code>&lt;div id="my-dialog" class="dialog"&gt;</code>):</p>
</section>
{% set code_dispatch %}<button type="button" x-init @click="$dispatch('dialog:open', { id: 'my-dialog' })">
Open #my-dialog
</button>{% endset %}
{{ code_block(code_dispatch | prettyHtml, "html") }}
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
<div class="prose">
@@ -141,20 +115,19 @@ document.addEventListener('alpine:init', () => {
</a>
</div>
{% set raw_code %}{% raw %}{% set footer %}
<button class="btn-outline" @click="open = false">Cancel</button>
<button class="btn" @click="open = false">Save changes</button>
{% set raw_code %}{% raw %}{% set code_html %}
{% set footer %}
<button class="btn-outline" onclick="this.closest('dialog').close()">Cancel</button>
<button class="btn" onclick="this.closest('dialog').close()">Save changes</button>
{% endset %}
{% call dialog(
id="demo-dialog-edit-profile",
title="Edit profile",
description="Make changes to your profile here. Click save when you're done.",
main_attrs={"x-ref": "myDialog"},
trigger="Edit Profile",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "w-full sm:max-w-[425px] max-h-[612px]"},
footer=footer,
register_on=["alpine:init", "htmx:afterSwap"]
dialog_attrs={"class": "w-full sm:max-w-[425px] max-h-[612px]"},
footer=footer
) %}
<form class="form grid gap-4">
<div class="grid gap-3">
@@ -175,19 +148,17 @@ document.addEventListener('alpine:init', () => {
{% set code %}
{% set footer %}
<button class="btn-outline" @click="open = false">Close</button>
<button class="btn-outline" onclick="this.closest('dialog').close()">Close</button>
{% endset %}
{% call dialog(
id="dialog-example",
title="Scrollable Content",
description="This is a dialog with scrollable content.",
main_attrs={"x-ref": "myDialog"},
trigger="Scrollable Content",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "w-full sm:max-w-[425px] max-h-[612px]"},
content_body_attrs={"class": "overflow-y-auto scrollbar"},
footer=footer,
register_on=["alpine:init", "htmx:afterSwap"]
dialog_attrs={"class": "w-full sm:max-w-[425px] max-h-[612px]"},
dialog_attrs={"class": "overflow-y-auto scrollbar"},
footer=footer
) %}
<div class="space-y-4 text-sm">
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
+129 -119
View File
@@ -6,8 +6,15 @@ toc:
- label: Usage
id: usage
children:
- label: HTML + Javascript
id: usage-default
- label: HTML + JavaScript
id: usage-html-js
children:
- label: "Step 1: Include the JavaScript files"
id: usage-html-js-1
- label: "Step 2: Add your dropdown menu HTML"
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: Jinja and Nunjucks
id: usage-macro
- label: Examples
@@ -25,98 +32,66 @@ toc:
{% set code_html %}
{% call dropdown_menu(
id="dropdown-menu",
id="demo-dropdown-menu",
trigger="Open",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "min-w-56"},
register_on=["alpine:init", "htmx:afterSwap"]
popover_attrs={"class": "min-w-56"}
) %}
<div role="group" aria-labelledby="account-options">
<span id="account-options" role="heading">My Account</span>
<button type="button" role="menuitem">
<div role="heading" id="account-options">My Account</div>
<div role="menuitem">
Profile
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
Billing
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘B</span>
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
Settings
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘S</span>
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
Keyboard shortcuts
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘K</span>
</button>
</div>
</div>
<hr role="separator">
<button type="button" role="menuitem">
<div role="menuitem">
GitHub
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
Support
</button>
<button type="button" role="menuitem" disabled>
</div>
<div role="menuitem" aria-disabled="true">
API
</button>
</div>
<hr role="separator">
<button type="button" role="menuitem">
<div role="menuitem">
Logout
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
</div>
{% endcall %}
{% endset %}
{% set code_script %}
<script>
{% fetchCode "src/js/dropdown-menu.js" %}
document.addEventListener('alpine:init', () => {
window.basecoat.registerDropdownMenu(Alpine);
});
</script>
{% endset %}
{{ code_preview("dropdown-menu", code_script | prettyHtml ~ code_html | prettyHtml) }}
{{ code_preview("dropdown-menu", code_html | prettyHtml) }}
<h2 id="usage">Usage</h2>
<h3 id="usage-default"><a href="#usage-default">HTML + Javascript</a></h3>
<h3 id="usage-html-js"><a href="#usage-html-js">HTML + JavaScript</a></h3>
<div class="alert my-6">
{% lucide "circle-alert" %}
<h2>This component requires Javascript.</h2>
</div>
<h4 id="usage-html-js-1"><a href="#usage-html-js-1">Step 1: Include the JavaScript files</a></h4>
<section class="prose">
<p>The component is structured as such:</p>
<ul>
<li>A <code>&lt;div class="popover"&gt;</code> which wraps around the entire component and holds it state (e.g. open/close).</li>
<li>A <code>&lt;button&gt;</code> that acts as the trigger to open or close the popover.</li>
<li>A <code>&lt;div data-popover&gt;</code> that holds the content of the popover.</li>
<li>Inside of the <code>&lt;div data-popover&gt;</code> is a <code>&lt;nav role="menu"&gt;</code> that contains a combination of:
<ul>
<li><code>&lt;button role="menuitem"&gt;</code> for menu items.</li>
<li><code>&lt;button role="menuitemcheckbox"&gt;</code> for menu items with checkboxes.</li>
<li><code>&lt;button role="menuitemradio"&gt;</code> for menu items with radio buttons.</li>
<li><code>&lt;hr role="separator"&gt;</code> to display a horizontal separator.</li>
<li><code>&lt;div role="group"&gt;</code> to group options.</li>
<li><code>&lt;span role="heading"&gt;</code> for group headings.</li>
</ul>
</ul>
<p>As with the <a href="/components/popover">Popover</a> component, you can set up a few additional options on the <code>&lt;div data-popover&gt;</code> element:</p>
<ul>
<li><code>data-side</code> can be set to <code>top</code>, <code>bottom</code>, <code>left</code>, or <code>right</code> to change the side of the popover.</li>
<li><code>data-align</code> can be set to <code>start</code>, <code>center</code>, or <code>end</code> to change the alignment of the popover.</li>
</ul>
<p>You can include the Javascript code provided below, load it as an individual file or use the CLI. Some Alpine.js properties are also required on certain elements (e.g. <code class="text-xs">x-bind</code>, <code class="text-xs">x-data</code>, <code class="text-xs">@click</code>).</p>
<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@{{ site.version }}/dist/js/dropdown-menu.min.js" defer></script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="/installation/#install-js">
Components with Javascript
Components with JavaScript
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="/installation/#install-cli">
@@ -124,17 +99,61 @@ document.addEventListener('alpine:init', () => {
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/js/dropdown-menu.js" target="_blank">
Individual JS file (dropdown-menu.js)
dropdown-menu.js
{% lucide "arrow-right" %}
</a>
</div>
{% from "tabs.njk" import tabs %}
{% set tabsets = [
{ tab: "HTML", panel: code_block(code_html | prettyHtml, class="") },
{ tab: "Javascipt (Alpine.js)", panel: code_block(code_script | prettyHtml, class="") }
] %}
{{ tabs(id="dropdown-menu", tabsets=tabsets) }}
<h4 id="usage-html-js-2"><a href="#usage-html-js-2">Step 2: Add your dropdown menu HTML</a></h4>
{{ code_block(code_html | prettyHtml, "html") }}
<h4 id="usage-html-js-3"><a href="#usage-html-js-3">HTML structure</a></h4>
<section class="prose">
<dl>
<dt><code class="highlight language-html">&lt;div class="dropdown-menu"&gt;</code></dt>
<dd>Wraps around the entire component.
<dl>
<dt><code class="highlight language-html">&lt;button type="button" popovertarget="{ POPOVER_ID }"&gt;</code></dt>
<dd>
<p>The trigger to open the popover, can also have the following attributes:</p>
<ul>
<li><code>id="{BUTTON_ID}"</code>: linked to by the <code>aria-labelledby</code> attribute of the listbox.</li>
<li><code>aria-haspopup="menu"</code>: indicates that the button opens a menu.</li>
<li><code>aria-controls="{ MENU_ID }"</code>: points to the menu's id.</li>
<li><code>aria-expanded="false"</code>: tracks the popover's state.</li>
</ul>
</dd>
<dt><code class="highlight language-html">&lt;div popover class="popover" id="{ POPOVER_ID }"&gt;</code></dt>
<dd>As with the <a href="/components/popover">Popover</a> component, you can set up the side and alignment of the popover using the <code>data-side</code> and <code>data-align</code> attributes.
<dl>
<dt><code class="highlight language-html">&lt;div role="menu"&gt;</code></dt>
<dd>The menu containing the options. Should have the following attributes:
<ul>
<li><code>id="{ MENU_ID }"</code>: refered to by the <code>aria-controls</code> attribute of the trigger.</li>
<li><code>aria-labelledby="{ BUTTON_ID }"</code>: linked to by the button's <code>id</code> attribute.</li>
</ul>
<dl>
<dt><code class="highlight language-html">&lt;button role="menuitem"&gt;</code></dt>
<dd>Menu item.</dd>
<dt><code class="highlight language-html">&lt;button role="menuitemcheckbox"&gt;</code></dt>
<dd>Menu item with a checkbox.</dd>
<dt><code class="highlight language-html">&lt;button role="menuitemradio"&gt;</code></dt>
<dd>Menu item with a radio button.</dd>
<dt><code class="highlight language-html">&lt;hr role="separator"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>Separator between groups/options.</dd>
<dt><code class="highlight language-html">&lt;div role="group"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>Group of options, can have a <code>aria-labelledby</code> attribute to link to a heading.</dd>
<dt><code class="highlight language-html">&lt;span role="heading"&gt;</code></dt>
<dd>Group heading, must have an <code>id</code> attribute if you use the <code>aria-labelledby</code> attribute on the group.</dd>
</dl>
</dd>
</dl>
</dl>
</dd>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
@@ -161,43 +180,42 @@ document.addEventListener('alpine:init', () => {
id="dropdown-menu",
trigger="Open",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "min-w-56"},
register_on=["alpine:init", "htmx:afterSwap"]
popover_attrs={"class": "min-w-56"}
) %}
<div role="group" aria-labelledby="account-options">
<span id="account-options" role="heading">My Account</span>
<button type="button" role="menuitem">
<div role="heading" id="account-options">My Account</div>
<div role="menuitem">
Profile
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
Billing
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘B</span>
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
Settings
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘S</span>
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
Keyboard shortcuts
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘K</span>
</button>
</div>
</div>
<hr role="separator">
<button type="button" role="menuitem">
<div role="menuitem">
GitHub
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
Support
</button>
<button type="button" role="menuitem" disabled>
</div>
<div role="menuitem" disabled>
API
</button>
</div>
<hr role="separator">
<button type="button" role="menuitem">
<div role="menuitem">
Logout
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
</div>
{% endcall %}{% endraw %}{% endset%}
{{ code_block(raw_code, "jinja") }}
@@ -209,32 +227,30 @@ document.addEventListener('alpine:init', () => {
id="dropdown-menu-checkboxes",
trigger="Open",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "min-w-56"},
register_on=["alpine:init", "htmx:afterSwap"]
popover_attrs={"class": "min-w-56"}
) %}
<div role="group" aria-labelledby="account-options">
<span id="account-options" role="heading">Account Options</span>
<button type="button" role="menuitem">
<div role="heading" id="account-options">Account Options</div>
<div role="menuitem">
{% lucide "user" %}
Profile
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
{% lucide "credit-card" %}
Billing
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘B</span>
</button>
<button type="button" role="menuitem">
</div>
<div role="menuitem">
{% lucide "settings" %}
Settings
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘S</span>
</button>
</div>
</div>
<hr role="separator">
<div role="group" aria-labelledby="appearance-options">
<span id="appearance-options" role="heading">Appearance</span>
<button
type="button"
<div role="heading" id="appearance-options">Appearance</div>
<div
role="menuitemcheckbox"
aria-checked="true"
class="group"
@@ -249,9 +265,8 @@ document.addEventListener('alpine:init', () => {
} %}
Status Bar
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
<button
type="button"
</div>
<div
role="menuitemcheckbox"
aria-checked="false"
class="group"
@@ -267,9 +282,8 @@ document.addEventListener('alpine:init', () => {
} %}
Activity Bar
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘B</span>
</button>
<button
type="button"
</div>
<div
role="menuitemcheckbox"
aria-checked="false"
class="group"
@@ -284,14 +298,14 @@ document.addEventListener('alpine:init', () => {
} %}
Panel
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘S</span>
</button>
</div>
</div>
<hr role="separator">
<button type="button" role="menuitem">
<div role="menuitem">
{% lucide "log-out" %}
Logout
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
</div>
{% endcall %}{% endset %}
{{ code_preview("dropdown-menu-checkboxes", code | prettyHtml) }}
@@ -301,14 +315,12 @@ document.addEventListener('alpine:init', () => {
id="dropdown-menu-radio-group",
trigger="Open",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "min-w-56"},
register_on=["alpine:init", "htmx:afterSwap"]
popover_attrs={"class": "min-w-56"}
) %}
<div role="group" aria-labelledby="position-options" x-data="{ checked: 'bottom' }">
<span id="position-options" role="heading">Panel Position</span>
<hr role="separator">
<button
type="button"
<div
role="menuitemradio"
aria-checked="false"
class="group"
@@ -324,9 +336,8 @@ document.addEventListener('alpine:init', () => {
</div>
Status Bar
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
<button
type="button"
</div>
<div
role="menuitemradio"
aria-checked="false"
class="group"
@@ -342,9 +353,8 @@ document.addEventListener('alpine:init', () => {
</div>
Activity Bar
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘B</span>
</button>
<button
type="button"
</div>
<div
role="menuitemradio"
aria-checked="false"
class="group"
@@ -360,7 +370,7 @@ document.addEventListener('alpine:init', () => {
</div>
Panel
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘S</span>
</button>
</div>
</div>
{% endcall %}{% endset %}
{{ code_preview("dropdown-menu", code | prettyHtml) }}
+41 -50
View File
@@ -6,8 +6,15 @@ toc:
- label: Usage
id: usage
children:
- label: HTML + Javascript
id: usage-default
- label: HTML + JavaScript
id: usage-html-js
children:
- label: "Step 1 (Optional): Include the JavaScript file"
id: usage-html-js-1
- label: "Step 2: Add your popover HTML"
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: Jinja and Nunjucks
id: usage-macro
---
@@ -21,8 +28,7 @@ toc:
id="demo-popover",
trigger="Open popover",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "w-80 p-4"},
register_on=["alpine:init", "htmx:afterSwap"]
popover_attrs={"class": "w-80"}
) %}
<div class="grid gap-4">
<header class="grid gap-1.5">
@@ -32,7 +38,7 @@ toc:
<form class="form grid gap-2">
<div class="grid grid-cols-3 items-center gap-4">
<label for="demo-popover-width">Width</label>
<input type="text" id="demo-popover-width" value="100%" class="col-span-2 h-8"/>
<input type="text" id="demo-popover-width" value="100%" class="col-span-2 h-8" autofocus/>
</div>
<div class="grid grid-cols-3 items-center gap-4">
<label for="demo-popover-max-width">Max. width</label>
@@ -51,67 +57,53 @@ toc:
{% endcall %}
{% endset %}
{% set code_script %}
<script>
{% fetchCode "src/js/popover.js" %}
document.addEventListener('alpine:init', () => {
window.basecoat.registerPopover(Alpine);
});
</script>
{% endset %}
{{ code_preview("popover", code_script | prettyHtml ~ code_html | prettyHtml) }}
{{ code_preview("popover", code_html | prettyHtml) }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<h3 id="usage-default"><a href="#usage-default">HTML + Javascript</a></h3>
<h3 id="usage-html-js"><a href="#usage-html-js">HTML + JavaScript</a></h3>
<div class="alert my-6">
{% lucide "circle-alert" %}
<h2>This component requires Javascript.</h2>
</div>
<h4 id="usage-html-js-1"><a href="#usage-html-js-1">Step 1: Include the JavaScript file</a></h4>
<section class="prose">
<p>The component is structured as such:</p>
<ul>
<li>A <code>&lt;div class="popover"&gt;</code> which wraps around the entire component and holds it state (e.g. open/close).</li>
<li>A <code>&lt;button&gt;</code> that acts as the trigger to open or close the popover.</li>
<li>A <code>&lt;div data-popover&gt;</code> that holds the content of the popover.</li>
</ul>
<p>You can set up a few additional options on the <code>&lt;div data-popover&gt;</code> element:</p>
<ul>
<li><code>data-side</code> can be set to <code>top</code>, <code>bottom</code>, <code>left</code>, or <code>right</code> to change the side of the popover.</li>
<li><code>data-align</code> can be set to <code>start</code>, <code>center</code>, or <code>end</code> to change the alignment of the popover.</li>
</ul>
<p>You can include the Javascript code provided below, load it as an individual file or use the CLI. Some Alpine.js properties are also required on certain elements (e.g. <code class="text-xs">x-bind</code>, <code class="text-xs">x-data</code>, <code class="text-xs">@click</code>).</p>
<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@{{ site.version }}/dist/js/popover.min.js" defer></script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="/installation/#install-js">
Components with Javascript
Components with JavaScript
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="/installation/#install-cli">
Use the CLI
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/js/popover.js" target="_blank">
Individual JS file (popover.js)
{% lucide "arrow-right" %}
</a>
</div>
{% from "tabs.njk" import tabs %}
{% set tabsets = [
{ tab: "HTML", panel: code_block(code_html | prettyHtml, class="") },
{ tab: "Javascipt (Alpine.js)", panel: code_block(code_script | prettyHtml, class="") }
] %}
{{ tabs(id="popover", tabsets=tabsets) }}
<h4 id="usage-html-js-2"><a href="#usage-html-js-2">Step 2: Add your popover HTML</a></h4>
{{ code_block(code_html | prettyHtml, "html") }}
<h4 id="usage-html-js-3"><a href="#usage-html-js-3">HTML structure</a></h4>
<section class="prose">
<dl>
<dt><code class="highlight language-html">&lt;button type="button" popovertarget="{ POPOVER_ID }"&gt;</code></dt>
<dd>
<p>The trigger to open the popover, can also have the following attributes:</p>
<ul>
<li><code>id="{BUTTON_ID}"</code>: linked to by the <code>aria-labelledby</code> attribute of the listbox.</li>
<li><code>aria-expanded="false"</code>: tracks the popover's state.</li>
<li><code>onclick</code>: a simple function to update <code>aria-expanded</code>.</li>
</ul>
</dd>
<dt><code class="highlight language-html">&lt;div popover class="popover" id="{ POPOVER_ID }"&gt;</code></dt>
<dd>As with the <a href="/components/popover">Popover</a> component, you can set up the side and alignment of the popover using the <code>data-side</code> and <code>data-align</code> attributes.</dd>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
@@ -138,8 +130,7 @@ document.addEventListener('alpine:init', () => {
id="demo-popover",
trigger="Open popover",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "w-80 p-4"},
register_on=["alpine:init", "htmx:afterSwap"]
popover_attrs={"class": "w-80 p-4"}
) %}
<div class="grid gap-4">
<header class="grid gap-1.5">
+112 -80
View File
@@ -8,8 +8,15 @@ toc:
children:
- label: HTML
id: usage-html
- label: HTML + Javascript
id: usage-js
- label: HTML + JavaScript
id: usage-html-js
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: Jinja and Nunjucks
id: usage-macro
- label: Examples
@@ -34,34 +41,31 @@ toc:
<option>Blueberry</option>
<option>Grapes</option>
<option>Pineapple</option>
</optgroup>
</select>{% endset %}
{{ code_preview("select-native", code_native) }}
{% set code_html %}
{% call select(
name="select-js",
{{ select(
trigger_attrs={"class": "w-[180px]"},
register_on=["alpine:init", "htmx:afterSwap"]
) %}
<div role="group" aria-labelledby="fruit-options-label">
<span id="fruit-options-label" role="heading">Fruits</span>
<div role="option" data-value="apple">Apple</div>
<div role="option" data-value="banana">Banana</div>
<div role="option" data-value="blueberry">Blueberry</div>
<div role="option" data-value="pineapple">Grapes</div>
<div role="option" data-value="pineapple">Pineapple</div>
</div>
{% endcall %}
is_combobox=true,
items=[
{
type: "group",
label: "Fruits",
items: [
{ type: "item", value: "apple", label: "Apple" },
{ type: "item", value: "banana", label: "Banana" },
{ type: "item", value: "blueberry", label: "Blueberry" },
{ type: "item", value: "pineapple", label: "Grapes" },
{ type: "item", value: "pineapple", label: "Pineapple" }
]
}
]
) }}
{% endset %}
{% set code_script %}
<script>
{% fetchCode "src/js/select.js" %}
document.addEventListener('alpine:init', () => {
window.basecoat.registerSelect(Alpine);
});
</script>
{% endset %}
{{ code_preview("select", code_script | prettyHtml ~ code_html | prettyHtml) }}
@@ -76,39 +80,20 @@ document.addEventListener('alpine:init', () => {
{{ code_block(code_native) }}
<h3 id="usage-js"><a href="#usage-js">HTML + Javascript</a></h3>
<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 files</a></h4>
<section class="prose">
<p>If you need to do more than what <code>&lt;select&gt;</code> allows (e.g. HTML options), you can use this component. It is structured as such:</p>
<ul>
<li>A <code>&lt;div class="popover"&gt;</code> which wraps around the entire component and holds it state (e.g. open/close).</li>
<li>A <code>&lt;button&gt;</code> that acts as the trigger to open or close the popover.</li>
<li>A <code>&lt;div data-popover&gt;</code> that holds the content of the popover.</li>
<li>Inside of the <code>&lt;div data-popover&gt;</code> is a <code>&lt;nav role="listbox"&gt;</code> that contains a combination of:
<ul>
<li><code>&lt;div role="option"&gt;</code> for the options with a <code>data-value</code> attribute.</li>
<li><code>&lt;hr role="separator"&gt;</code> to display a horizontal separator.</li>
<li><code>&lt;div role="group"&gt;</code> to group options.</li>
<li><code>&lt;span role="heading"&gt;</code> for group headings.</li>
</ul>
</li>
<li>An <code>&lt;input type="hidden"&gt;</code> to hold the value of the field (if needed).</li>
</ul>
<p>As with the <a href="/components/popover">Popover</a> component, you can set up a few additional options on the <code>&lt;div data-popover&gt;</code> element:</p>
<ul>
<li><code>data-side</code> can be set to <code>top</code>, <code>bottom</code>, <code>left</code>, or <code>right</code> to change the side of the popover.</li>
<li><code>data-align</code> can be set to <code>start</code>, <code>center</code>, or <code>end</code> to change the alignment of the popover.</li>
</ul>
<p>You can include the Javascript code provided below, load it as an individual file or use the CLI. Some Alpine.js properties are also required on certain elements (e.g. <code class="text-xs">x-bind</code>, <code class="text-xs">x-data</code>, <code class="text-xs">@click</code>).</p>
<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@{{ site.version }}/dist/js/select.min.js" defer></script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="/installation/#install-js">
Components with Javascript
Components with JavaScript
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="/installation/#install-cli">
@@ -116,26 +101,65 @@ document.addEventListener('alpine:init', () => {
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/js/select.js" target="_blank">
Individual JS file (select.js)
select.js
{% lucide "arrow-right" %}
</a>
</div>
{% from "tabs.njk" import tabs %}
{% set tabsets = [
{ tab: "HTML", panel: code_block(code_html | prettyHtml, class="") },
{ tab: "Javascipt (Alpine.js)", panel: code_block(code_script | prettyHtml, class="") }
] %}
{{ tabs(id="select", tabsets=tabsets) }}
<h4 id="usage-html-js-2"><a href="#usage-html-js-2">Step 2: Add your select HTML</a></h4>
{{ code_block(code_html | prettyHtml, "html") }}
<h4 id="usage-html-js-3"><a href="#usage-html-js-3">HTML structure</a></h4>
<section class="prose">
<p>The component will dispatch a <code>select:change</code> event when a value is selected, along with the value and label of the selected option in the <code>detail</code> object.</p>
<dl>
<dt><code class="highlight language-html">&lt;div class="select"&gt;</code></dt>
<dd>Wraps around the entire component.
<dl>
<dt><code class="highlight language-html">&lt;button type="button" popovertarget="{ POPOVER_ID }"&gt;</code></dt>
<dd>
<p>The trigger to open the popover, can also have the following attributes:</p>
<ul>
<li><code>id="{BUTTON_ID}"</code>: linked to by the <code>aria-labelledby</code> attribute of the listbox.</li>
<li><code>aria-haspopup="listbox"</code>: indicates that the button opens a listbox.</li>
<li><code>aria-controls="{ LISTBOX_ID }"</code>: points to the listbox's id.</li>
<li><code>aria-expanded="false"</code>: tracks the popover's state.</li>
<li><code>aria-activedescendant="{ OPTION_ID }"</code>: points to the active option's id.</li>
</ul>
</dd>
<dt><code class="highlight language-html">&lt;div popover class="popover" id="{ POPOVER_ID }"&gt;</code></dt>
<dd>As with the <a href="/components/popover">Popover</a> component, you can set up the side and alignment of the popover using the <code>data-side</code> and <code>data-align</code> attributes.
<dl>
<dt><code class="highlight language-html">&lt;div role="listbox"&gt;</code></dt>
<dd>The listbox containing the options. Should have the following attributes:
<ul>
<li><code>id="{ LISTBOX_ID }"</code>: refered to by the <code>aria-controls</code> attribute of the trigger.</li>
<li><code>aria-labelledby="{ BUTTON_ID }"</code>: linked to by the button's <code>id</code> attribute.</li>
</ul>
<dl>
<dt><code class="highlight language-html">&lt;div role="option" data-value="{ VALUE }"&gt;</code></dt>
<dd>Option that can be selected.Should have a unique id if you use the <code>aria-activedescendant</code> attribute on the trigger.</dd>
<dt><code class="highlight language-html">&lt;hr role="separator"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>Separator between groups/options.</dd>
<dt><code class="highlight language-html">&lt;div role="group"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>Group of options, can have a <code>aria-labelledby</code> attribute to link to a heading.</dd>
<dt><code class="highlight language-html">&lt;span role="heading"&gt;</code></dt>
<dd>Group heading, must have an <code>id</code> attribute if you use the <code>aria-labelledby</code> attribute on the group.</dd>
</dl>
</dd>
</dl>
</dl>
</dd>
<dt><code class="highlight language-html">&lt;input type="hidden" name="{ NAME }" value="{ VALUE }"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The hidden input that holds the value of the field (if needed).</dd>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
<div class="prose">
<p>You can use the <code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs">dropdown_menu()</code> Nunjucks or Jinja macro for this component.</p>
<p>You can use the <code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs">select()</code> Nunjucks or Jinja macro for this component.</p>
</div>
<div class="flex flex-wrap gap-2 my-6">
@@ -153,16 +177,21 @@ document.addEventListener('alpine:init', () => {
</a>
</div>
{% set raw_code %}{% raw %}{% call select(name="select-js") %}
<div role="group" aria-labelledby="fruit-options-label">
<span id="fruit-options-label" role="heading">Fruits</span>
<div role="option" data-value="apple">Apple</div>
<div role="option" data-value="banana">Banana</div>
<div role="option" data-value="blueberry">Blueberry</div>
<div role="option" data-value="pineapple">Grapes</div>
<div role="option" data-value="pineapple">Pineapple</div>
</div>
{% endcall %}{% endraw %}{% endset %}
{% set raw_code %}{% raw %}{{ select(
items=[
{
type: "group",
label: "Fruits",
items: [
{ type: "item", value: "apple", label: "Apple" },
{ type: "item", value: "banana", label: "Banana" },
{ type: "item", value: "blueberry", label: "Blueberry" },
{ type: "item", value: "pineapple", label: "Grapes" },
{ type: "item", value: "pineapple", label: "Pineapple" }
]
}
]
) }}{% endraw %}{% endset %}
{{ code_block(raw_code, "jinja") }}
<h2 id="examples"><a href="#examples">Examples</a></h2>
@@ -171,8 +200,7 @@ document.addEventListener('alpine:init', () => {
{% set code %}
{% call select(
listbox_attrs={"class": "scrollbar overflow-y-auto max-h-64"},
register_on=["alpine:init", "htmx:afterSwap"]
listbox_attrs={"class": "scrollbar overflow-y-auto max-h-64"}
) %}
{% for i in range(0, 99) %}
<div role="option" data-value="item-{{ i }}">Item {{ i }}</div>
@@ -186,8 +214,7 @@ document.addEventListener('alpine:init', () => {
{% set code %}
{% call select(
trigger_attrs={"disabled": "disabled"},
register_on=["alpine:init", "htmx:afterSwap"]
trigger_attrs={"disabled": "disabled"}
) %}
<div role="option" data-value="disabled">Disabled</div>
{% endcall %}
@@ -199,20 +226,25 @@ document.addEventListener('alpine:init', () => {
{% set code %}
{% call select(
trigger_attrs={'class': 'w-[180px]'},
register_on=["alpine:init", "htmx:afterSwap"]
trigger_attrs={'class': 'w-[180px]'}
) %}
<div type="button" role="option" data-value="bar">
{% lucide "chart-bar", {"class": "text-muted-foreground"} %}
Bar
<span class="flex items-center gap-2">
{% lucide "chart-bar", {"class": "text-muted-foreground"} %}
Bar
</span>
</div>
<div type="button" role="option" data-value="line">
{% lucide "chart-line", {"class": "text-muted-foreground"} %}
Line
<span class="flex items-center gap-2">
{% lucide "chart-line", {"class": "text-muted-foreground"} %}
Line
</span>
</div>
<div type="button" role="option" data-value="pie">
{% lucide "chart-pie", {"class": "text-muted-foreground"} %}
Pie
<span class="flex items-center gap-2">
{% lucide "chart-pie", {"class": "text-muted-foreground"} %}
Pie
</span>
</div>
{% endcall %}
{% endset %}
+96 -42
View File
@@ -6,46 +6,38 @@ toc:
- label: Usage
id: usage
children:
- label: HTML + Javascript
id: usage-default
- label: HTML + JavaScript
id: usage-html-js
children:
- label: "Step 1: Include the JavaScript files"
id: usage-html-js-1
- label: "Step 2: Add your sidebar HTML"
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: Jinja and Nunjucks
id: usage-macro
---
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
{% from "sidebar.njk" import sidebar %}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<h3 id="usage-default"><a href="#usage-default">HTML + Javascript</a></h3>
<h3 id="usage-html-js"><a href="#usage-html-js">HTML + JavaScript</a></h3>
<div class="alert my-6">
{% lucide "circle-alert" %}
<h2>This component requires Javascript.</h2>
</div>
<h4 id="usage-html-js-1"><a href="#usage-html-js-1">Step 1: Include the JavaScript files</a></h4>
<section class="prose">
<p>To use a sidebar, you will need the sidebar itself and a sibling container for the content of the page (e.g. <code>&lt;main&gt;</code>), with the following structure for the sidebar:</p>
<ul>
<li>A <code>&lt;div class="sidebar"&gt;</code> which wraps around the entire component and holds it state (e.g. open/close). It accepts an optional <code>data-side</code> set to <code>left</code>, or <code>right</code> to specify the side of the sidebar (defaults to <code>left</code>).</li>
<li>A <code>&lt;nav&gt;</code> that contains the actual sidebar with the following children:
<ul>
<li>A <code>&lt;header&gt;</code> and <code>&lt;footer&gt;</code> for the header and footer of the sidebar (fixed position).</li>
<li>A <code>&lt;section&gt;</code> for the main navigation list. To add links, you <b>must</b> wrap them in a <code>&lt;div role="group"&gt;</code> first. These groups can contain <code>&lt;h3"&gt;</code> for group headings and lists (i.e. <code>&lt;ul&gt;</code>) of links or buttons. You can also use <code>&lt;details&gt;</code> to wrap around collapsbile sections.</li>
</ul>
</li>
</ul>
<p>Any elememt on the page can toggle, open or close the sidebar by dispatching the <code>sidebar:toggle</code>, <code>sidebar:open</code> or <code>sidebar:close</code> events.</p>
<p>You can include the Javascript code provided below, load it as an individual file or use the CLI. Some Alpine.js properties are also required on certain elements (e.g. <code class="text-xs">x-bind</code>, <code class="text-xs">x-data</code>, <code class="text-xs">@click</code>).</p>
<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@{{ site.version }}/dist/js/sidebar.min.js" defer></script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="/installation/#install-js">
Components with Javascript
Components with JavaScript
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="/installation/#install-cli">
@@ -53,16 +45,17 @@ toc:
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/js/sidebar.js" target="_blank">
Individual JS file (sidebar.js)
sidebar.js
{% lucide "arrow-right" %}
</a>
</div>
<h4 id="usage-html-js-2"><a href="#usage-html-js-2">Step 2: Add your sidebar HTML</a></h4>
{% set code_html %}
{% set icon_square_terminal %}{% lucide "square-terminal" %}{% endset %}
{% set icon_bot %}{% lucide "bot" %}{% endset %}
{% set icon_settings %}{% lucide "settings" %}{% endset %}
{% set menu = [
{ type: "group", label: "Getting started", items: [
{ label: "Playground", url: "#", icon: icon_square_terminal },
@@ -75,7 +68,6 @@ toc:
] }
]}
] %}
{{ sidebar(
label="Sidebar navigation",
content_attrs={
@@ -84,27 +76,89 @@ toc:
menu=menu
) }}
<main>
<button type="button" @click="$dispatch('sidebar:toggle')">Toggle sidebar</button>
<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar'))">Toggle sidebar</button>
<h1>Content</h1>
</main>
{% endset %}
{{ code_block(code_html | prettyHtml, "html") }}
{% set code_script %}
<script>
{% fetchCode "src/js/sidebar.js" %}
<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>
document.addEventListener('alpine:init', () => {
window.basecoat.registerSidebar(Alpine);
});
</script>
{% endset %}
{% 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") }}
{% from "tabs.njk" import tabs %}
{% set tabsets = [
{ tab: "HTML", panel: code_block(code_html | prettyHtml, class="") },
{ tab: "Javascipt (Alpine.js)", panel: code_block(code_script | prettyHtml, class="") }
] %}
{{ tabs(id="sidebar", tabsets=tabsets) }}
<h4 id="usage-html-js-3"><a href="#usage-html-js-3">HTML structure</a></h4>
<section class="prose">
<dl>
<dt><code class="highlight language-html">&lt;aside class="sidebar" aria-hidden="false"&gt;</code></dt>
<dd>Wraps around the entire component. It can have the following attributes:
<ul>
<li><code>aria-hidden="true"</code>: controls the default state of the sidebar (hidden or visible).</li>
<li><code>data-side="left"</code>: specifies the side of the sidebar (<code>left</code> or <code>right</code>, defaults to <code>left</code>).</li>
</ul>
</dd>
<dl>
<dt><code class="highlight language-html">&lt;nav&gt;</code></dt>
<dd>
<p>The navigation element that contains the sidebar's content. It can have the following attributes:</p>
<ul>
<li><code>id="{BUTTON_ID}"</code>: linked to by the <code>aria-labelledby</code> attribute of the listbox.</li>
<li><code>aria-haspopup="menu"</code>: indicates that the button opens a menu.</li>
<li><code>aria-controls="{ MENU_ID }"</code>: points to the menu's id.</li>
<li><code>aria-expanded="false"</code>: tracks the popover's state.</li>
</ul>
<dl>
<dt><code class="highlight language-html">&lt;header&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The header of the sidebar.</dd>
<dt><code class="highlight language-html">&lt;section&gt;</code></dt>
<dd>The main navigation list.
<dl>
<dt><code class="highlight language-html">&lt;div role="group"&gt;</code></dt>
<dd>Group of options, can have a <code>aria-labelledby</code> attribute to link to a heading.</dd>
<dt><code class="highlight language-html">&lt;span role="heading"&gt;</code></dt>
<dd>Group heading, must have an <code>id</code> attribute if you use the <code>aria-labelledby</code> attribute on the group.</dd>
<dt><code class="highlight language-html">&lt;ul&gt;</code></dt>
<dd>List of links or buttons.
<dl>
<dt><code class="highlight language-html">&lt;li&gt;</code></dt>
<dd>Individual item.
<dl>
<dt><code class="highlight language-html">&lt;a&gt;</code></dt>
<dd>A link. By default, clicking on a link will close the sidebar on mobile unless the <code>data-keep-mobile-sidebar-open</code> attribute is present.</dd>
<dt><code class="highlight language-html">&lt;button&gt;</code></dt>
<dd>A button. By default, clicking on a button will close the sidebar on mobile unless the <code>data-keep-mobile-sidebar-open</code> attribute is present.</dd>
<dt><code class="highlight language-html">&lt;details&gt;</code></dt>
<dd>Collapsible section.
<dl>
<dt><code class="highlight language-html">&lt;summary&gt;</code></dt>
<dd>Summary of the collapsible section.</dd>
<dt><code class="highlight language-html">&lt;ul&gt;</code></dt>
<dd>List of links or buttons.</dd>
</dl>
</dd>
</dl>
</dd>
</dl>
</dd>
</dl>
</dd>
<dt><code class="highlight language-html">&lt;footer&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The footer of the sidebar.</dd>
</dl>
</dd>
</dl>
</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>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
+4 -2
View File
@@ -10,7 +10,8 @@ toc:
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
{% set code %}<table class="table">
{% set code %}<div class="overflow-x-auto">
<table class="table">
<caption>A list of your recent invoices.</caption>
<thead>
<tr>
@@ -70,7 +71,8 @@ toc:
<td class="text-right">$2,500.00</td>
</tr>
</tfoot>
</table>{% endset %}
</table>
</div>{% endset %}
{{ code_preview("table", code, "w-full") }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
+37 -39
View File
@@ -6,7 +6,7 @@ toc:
- label: Usage
id: usage
children:
- label: HTML + Javascript
- label: HTML + JavaScript
id: usage-default
- label: Jinja and Nunjucks
id: usage-macro
@@ -71,47 +71,26 @@ toc:
id='demo-tabs-with-panels',
tabsets=tabsets_demo,
main_attrs={ "class": "w-full" },
tablist_attrs={ "class": "w-full" },
register_on=["alpine:init", "htmx:afterSwap"]
tablist_attrs={ "class": "w-full" }
) }}
{% endset %}
{% set code_script %}
<script>
{% fetchCode "src/js/tabs.js" %}
document.addEventListener('alpine:init', () => {
window.basecoat.registerTabs(Alpine);
});
</script>
{% endset %}
{{ code_preview("tabs", code_script | prettyHtml ~ code_html | prettyHtml, "max-w-[300px] w-full") }}
{{ code_preview("tabs", code_html | prettyHtml, "max-w-[300px] w-full") }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<h3 id="usage-default"><a href="#usage-default">HTML + Javascript</a></h3>
<div class="alert my-6">
{% lucide "circle-alert" %}
<h2>This component requires Javascript.</h2>
</div>
<h4 id="usage-html-js-1"><a href="#usage-html-js-1">Step 1: Include the JavaScript files</a></h4>
<section class="prose">
<p>The component is structured as such:</p>
<ul>
<li>A <code>&lt;div class="tabs"&gt;</code> which wraps around the entire component and holds it state (e.g. open/close).</li>
<li>A <code>&lt;nav role="tablist"&gt;</code> that holds the tab buttons (<code>&lt;button role="tab"&gt;</code>).</li>
<li>A series of <code>&lt;div role="tabpanel"&gt;</code> that holds the tab panels corresponding to each tab button.</li>
</ul>
<p>You can include the Javascript code provided below, load it as an individual file or use the CLI. Some Alpine.js properties are also required on certain elements (e.g. <code class="text-xs">x-bind</code>, <code class="text-xs">x-data</code>, <code class="text-xs">@click</code>).</p>
<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@{{ site.version }}/dist/js/select.min.js" defer></script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="/installation/#install-js">
Components with Javascript
Components with JavaScript
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="/installation/#install-cli">
@@ -119,17 +98,37 @@ document.addEventListener('alpine:init', () => {
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/js/tabs.js" target="_blank">
Individual JS file (tabs.js)
tabs.js
{% lucide "arrow-right" %}
</a>
</div>
{% from "tabs.njk" import tabs %}
{% set tabsets = [
{ tab: "HTML", panel: code_block(code_html | prettyHtml, class="") },
{ tab: "Javascipt (Alpine.js)", panel: code_block(code_script | prettyHtml, class="") }
] %}
{{ tabs(id="combobox", tabsets=tabsets) }}
<h4 id="usage-html-js-2"><a href="#usage-html-js-2">Step 2: Add your select HTML</a></h4>
{{ code_block(code_html | prettyHtml, "html") }}
<h4 id="usage-html-js-3"><a href="#usage-html-js-3">HTML structure</a></h4>
<section class="prose">
<dl>
<dt><code class="highlight language-html">&lt;div class="tabs"&gt;</code></dt>
<dd>Wraps around the entire component.
<dl>
<dt><code class="highlight language-html">&lt;nav role="tablist" aria-orientation="horizontal"&gt;</code></dt>
<dd>The tablist containing the tab buttons
<dl>
<dt><code class="highlight language-html">&lt;button role="tab" id="{ TAB_ID }" aria-controls="{ PANEL_ID }" aria-selected="false" tabindex="0"&gt;</code></dt>
<dd>The tab button. When active, the <code>aria-selected</code> attribute is set to <code>true</code>. <code>tabindex="0"</code> is required for keyboard navigation.</dd>
</dl>
</dd>
</dl>
</dd>
<dl>
<dt><code class="highlight language-html">&lt;div role="tabpanel" id="{ PANEL_ID }" aria-labelledby="{ TAB_ID }" tabindex="-1" aria-selected="false"&gt;</code></dt>
<dd>The tab panel. When active, the <code>aria-selected</code> attribute is set to <code>true</code>.</dd>
</dl>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
@@ -209,7 +208,6 @@ document.addEventListener('alpine:init', () => {
id='demo-tabs-with-panels',
tabsets=tabsets_demo,
main_attrs={ "class": "w-full" },
tablist_attrs={ "class": "w-full" },
register_on=["alpine:init", "htmx:afterSwap"]
tablist_attrs={ "class": "w-full" }
) }}{% endraw %}{% endset%}
{{ code_block(raw_code, "jinja") }}
+1 -1
View File
@@ -1,6 +1,6 @@
---
layout: layouts/page.njk
title: Teaxtarea
title: Textarea
description: Displays a form textarea or a component that looks like a textarea.
toc:
- label: Usage
+53
View File
@@ -0,0 +1,53 @@
---
layout: layouts/page.njk
title: Theme Switcher
description: A component that allows the user to switch between light and dark mode.
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
---
{% 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 %}
{{ code_preview("button", code) }}
<h2 id="usage">Usage</h2>
<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>
</section>
{% set code %}<button class="btn-outline" data-tooltip="Tooltip text" data-side="bottom" data-align="center">Bottom left</button>{% endset %}
{{ code_block(code) }}
<h2 id="examples"><a href="#examples">Examples</a></h2>
<h3 id="example-default"><a href="#example-default">Default</a></h3>
{% set code %}<button class="btn-outline" data-tooltip="Default tooltip">Default</button>{% endset %}
{{ code_preview("button", code) }}
<h3 id="example-right"><a href="#example-right">Right</a></h3>
{% 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) }}
+130 -120
View File
@@ -6,13 +6,19 @@ toc:
- label: Usage
id: usage
children:
- label: HTML + Javascript
id: usage-default
- label: HTML + JavaScript
id: usage-html-js
children:
- label: Append a toast
id: usage-append-toast
- label: Use $toast()
id: usage-use-magic-method
- label: "Step 1: Include the JavaScript file"
id: usage-html-js-1
- label: "Step 2: Add the toaster HTML"
id: usage-html-js-2
- label: "Step 3: Add your toasts"
id: usage-html-js-3
- label: HTML structure
id: usage-html-js-4
- label: JavaScript config object
id: usage-html-js-5
- label: Jinja and Nunjucks
id: usage-macro
---
@@ -21,7 +27,7 @@ toc:
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
{% set code_htmx %}<button
{% set code_server_side %}<button
class="btn-outline"
hx-trigger="click"
hx-get="/fragments/toast/success"
@@ -30,46 +36,43 @@ toc:
>
Toast from backend (with HTMX)
</button>{% endset %}
{{ code_preview("toast-htmx", code_htmx, "max-w-md") }}
{{ code_preview("toast-server-side", code_server_side, "max-w-md") }}
{% set code_alpine %}<button
{% set code_client_side %}<button
class="btn-outline"
@click='
$toast({
category: "success",
title: "Success",
description: "A success toast called from the front-end.",
action: {
label: "Dismiss",
click: "open =false"
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'
}
}
})
'>
}
}))"
>
Toast from front-end
</button>
{% endset %}
{{ code_preview("toast-alpine", code_alpine, "max-w-md") }}
</button>{% endset %}
{{ code_preview("toast-client-side", code_client_side, "max-w-md") }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<h3 id="usage-default"><a href="#usage-default">HTML + Javascript</a></h3>
<h3 id="usage-html-js"><a href="#usage-html-js">HTML + JavaScript</a></h3>
<div class="alert my-6">
{% lucide "circle-alert" %}
<h2>This component requires Javascript.</h2>
</div>
<h4 id="usage-html-js-1"><a href="#usage-html-js-1">Step 1: Include the JavaScript file</a></h4>
<section class="prose">
<p>To use toasts, you will first need to set up the toaster code to the end of your <code>&lt;body&gt;</code> (see HTML below).</p>
<p>The <code>&lt;template id="toast-template"&gt;</code> bit is only required if you plan on using the <code>$toast()</code> method (see below).</p>
<p>You will also need to include the Javascript code provided below, load it as an individual file or use the CLI. Some Alpine.js properties are also required on certain elements (e.g. <code class="text-xs">x-bind</code>, <code class="text-xs">x-data</code>, <code class="text-xs">@click</code>).</p>
<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@{{ site.version }}/dist/js/toast.min.js" defer></script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="/installation/#install-js">
Components with Javascript
Components with JavaScript
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="/installation/#install-cli">
@@ -77,59 +80,32 @@ toc:
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/js/toast.js" target="_blank">
Individual JS file (toast.js)
toast.js
{% lucide "arrow-right" %}
</a>
</div>
{% set code_script %}
<script>
{% fetchCode "src/js/popover.js" %}
document.addEventListener('alpine:init', () => {
window.basecoat.registerPopover(Alpine);
});
</script>
{% endset %}
{% set code_html_toaster %}{{ toaster() }}{% endset %}
{% from "tabs.njk" import tabs %}
{% set tabsets = [
{ tab: "HTML", panel: code_block(code_html_toaster | prettyHtml, class="") },
{ tab: "Javascipt (Alpine.js)", panel: code_block(code_script | prettyHtml, class="") }
] %}
{{ tabs(id="popover", tabsets=tabsets) }}
<h4 id="usage-html-js-2"><a href="#usage-html-js-2">Step 2: Add the toaster HTML</a></h4>
<section class="prose">
<p>Once that's in place, we can do one of two things:</p>
<ul>
<li>Append a <code>&lt;div class="toast"&gt;</code> to the toaster.</li>
<li>Use the <code>$toast()</code> Alpine.js "magic method".</li>
</ul>
<p>Toasts are displayed in a parent element, the "toaster". Add this to this to at the end of your <code>&lt;body&gt;</code>:</p>
</section>
<h4 id="usage-append-toast"><a href="#usage-append-toast">Append a toast</a></h4>
{% set code_toaster %}<div id="toaster" class="toaster"></div>{% endset %}
{{ code_block(code_toaster | prettyHtml, "html") }}
<section class="prose">
<p>To display a toast, just append the following to the toaster:</p>
<p>You can set up the alignment of the toaster using the <code>data-align</code> attribute: <code>data-align="start"</code>, <code>data-align="center"</code>, or <code>data-align="end"</code> (default to <code>data-align="end"</code>).</p>
</section>
{% set code_toast_simple %}<div class="toast" x-data="toast" x-bind="$toastBindings">
<div class="toast-content">
<p>This is a toast.</p>
</div>
</div>{% endset %}
{{ code_block(code_toast_simple | prettyHtml, "html") }}
<h4 id="usage-html-js-3"><a href="#usage-html-js-3">Step 3: Add your toasts</a></h4>
<section class="prose">
<p>You can implement the layout you want within the toast, but here is the standard format used in the examples above:</p>
<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>
</section>
{% set code_toast %}{{ toast(
{% set code_toast_html %}<div id="toaster" class="toaster">
{{ toast(
category="success",
title="Success",
description="A success toast called from the front-end.",
@@ -137,66 +113,100 @@ document.addEventListener('alpine:init', () => {
label: "Dismiss",
click: "close()"
}
) }}{% endset %}
{{ code_block(code_toast | prettyHtml, "html") }}
) }}
</div>{% endset %}
{{ 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">
HTML structure
{% lucide "arrow-right" %}
</a>
</div>
<section class="prose">
<p>When setting up <code>x-data</code>, you can pass a config object with `category` (e.g. "success", "error", "info", "warning") and `duration` (in milliseconds). By default, the duration will be 3 seconds, and 5 seconds for errors.</p>
<p>This approach is used in the first example with HTMX: when the button is clicked, we retrieve the a fragment that contains the markup for the toast and append it to <code>#toaster</code>.</p>
<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>
</section>
{% set code_htmx %}<button
class="btn-outline"
hx-trigger="click"
hx-get="/fragments/toast/success"
hx-target="#toaster"
hx-swap="beforeend"
>
Toast from backend (with HTMX)
</button>{% endset %}
{{ code_block(code_client_side | prettyHtml, "html") }}
<h4 id="usage-use-magic-method"><a href="#usage-use-magic-method">Use $toast()</a></h4>
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="#usage-html-js-5">
JavaScript config object
{% lucide "arrow-right" %}
</a>
</div>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">HTML structure</a></h4>
<section class="prose">
<p>If you want to display a toast directly from the front-end, you can use the <code>$toast()</code> Alpine.js magic method. This is what we're doing in the second example above:</p>
<dl>
<dt><code class="highlight language-html">&lt;div class="toast"&gt;</code></dt>
<dd>Wraps around the toast component. You can add a <code>data-duration</code> attribute to set the duration of the toast in milliseconds (e.g. <code>data-duration="5000"</code> for 5 seconds). If not provided, the default duration is 3000ms (3 seconds) or 5000ms (5 seconds) for <code>error</code> toasts.
<dl>
<dt><code class="highlight language-html">&lt;div class="toast-content"&gt;</code></dt>
<dd>The content of the toast.
<dl>
<dt><code class="highlight language-html">&lt;svg aria-hidden="true"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The toast's icon.</dd>
<dt><code class="highlight language-html">&lt;section&gt;</code></dt>
<dd>The toast's message.
<dl>
<dt><code class="highlight language-html">&lt;h2&gt;</code></dt>
<dd>The toast's title.</dd>
<dt><code class="highlight language-html">&lt;p&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The toast's description.</dd>
</dl>
</dd>
<dt><code class="highlight language-html">&lt;footer&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The toast's buttons. When clicked, the toast will be closed (unless the button's <code>onclick</code> is set to <code>e.preventDefault()</code>).
<dl>
<dt><code class="highlight language-html">&lt;button type="button" class="btn" onclick="{ ONCLICK }"&gt;</code> or <code class="highlight language-html">&lt;a href="{ URL }" class="btn" &gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The toast's action button. This can either be a link (with a <code>href</code> attribute) or a button (with an <code>onclick</code> attribute).</dd>
</dl>
<dl>
<dt><code class="highlight language-html">&lt;button type="button" class="btn-outline" onclick="{ ONCLICK }"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The toast's cancel button (with an optional <code>onclick</code> attribute).</dd>
</dl>
</dd>
</dl>
</dd>
</dl>
</dd>
</dl>
</section>
{% set code_magic_method %}<button
class="btn-outline"
@click='
$toast({
category: "success",
title: "Success",
description: "A success toast called from the front-end.",
action: {
label: "Dismiss",
click: "open =false"
}
})
'>
Toast from front-end
</button>
{% endset %}
{{ code_block(code_magic_method, "html") }}
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript config object</a></h4>
<section class="prose">
<p>The <code>$toast()</code> method accepts a config object with the following properties:</p>
<ul>
<li><code>duration</code> (optional): the duration of the toast in milliseconds. By default, the duration will be 3 seconds, or 5 seconds if <code>category</code> is "error".</li>
<li><code>category</code> (optional): "success", "error", "info", "warning" or any arbitrary string.</li>
<li><code>title</code> (optional): the title of the toast.</li>
<li><code>description</code> (optional): the description of the toast.</li>
<li><code>action</code> (optional): an object to define an action button:
<ul>
<li><code>label</code>: the label of the action button.</li>
<li><code>click</code> (optional): a string of Javascript to execute when the button is clicked.</li>
<li><code>url</code> (optional): a URL to link to when the button is clicked.</li>
</ul>
</li>
<li><code>cancel</code> (optional): an object to define a cancel button (similar structure to action).</li>
</ul>
<dl>
<dt><code>duration</code> <span class="badge-secondary">Optional</span></dt>
<dd>The duration of the toast in milliseconds. If not provided, the default duration is 3000ms (3 seconds) or 5000ms (5 seconds) for <code>error</code> toasts.</dd>
<dt><code>category</code> <span class="badge-secondary">Optional</span></dt>
<dd>Category of the toast, either <code>success</code>, <code>info</code>, <code>warning</code>, or <code>error</code>.</dd>
<dt><code>title</code></dt>
<dd>The title of the toast.</dd>
<dt><code>description</code> <span class="badge-secondary">Optional</span></dt>
<dd>The description of the toast.</dd>
<dt><code>action</code> <span class="badge-secondary">Optional</span></dt>
<dd>Action button.
<dl>
<dt><code>label</code> <span class="badge-secondary">Optional</span></dt>
<dd>The label of the cancel button. If not provided, the default label is "Dismiss".</dd>
<dt><code>onclick</code> <span class="badge-secondary">Optional</span></dt>
<dd>The onclick of the cancel button.</dd>
</dl>
</dd>
<dt><code>cancel</code> <span class="badge-secondary">Optional</span></dt>
<dd>Cancel button.
<dl>
<dt><code>label</code> <span class="badge-secondary">Optional</span></dt>
<dd>The label of the cancel button. If not provided, the default label is "Dismiss".</dd>
<dt><code>onclick</code> <span class="badge-secondary">Optional</span></dt>
<dd>The onclick of the cancel button.</dd>
</dl>
</dd>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
@@ -242,7 +252,7 @@ document.addEventListener('alpine:init', () => {
{{ toast(
title="Event has been created",
description="Sunday, December 03, 2023 at 9:00 AM",
cancel={ label: "Undo", click: "close()" }
cancel={ label: "Undo" }
) }}{% endraw %}{% endset %}
{% from "tabs.njk" import tabs %}
+2 -1
View File
@@ -2,5 +2,6 @@
{{ toast(
title="Event has been created",
description="Sunday, December 03, 2023 at 9:00 AM",
cancel={ label: "Undo", click: "close()" }
action={ label: "View" },
cancel={ label: "Undo" }
) }}
+28 -24
View File
@@ -32,16 +32,17 @@ layout: layouts/layout.njk
<p class="text-sm text-muted-foreground">m@example.com</p>
</div>
{% call select(
{{ select(
is_combobox=true,
selected="owner",
content_attrs={"data-align": "end"}
) %}
<div role="option" data-value="viewer">Viewer</div>
<div role="option" data-value="developer">Developer</div>
<div role="option" data-value="billing">Billing</div>
<div role="option" data-value="owner">Owner</div>
{% endcall %}
popover_attrs={"data-align": "end"},
items=[
{ type: "item", value: "viewer", label: "Viewer" },
{ type: "item", value: "developer", label: "Developer" },
{ type: "item", value: "billing", label: "Billing" },
{ type: "item", value: "owner", label: "Owner" }
]
) }}
</li>
<li class="flex items-center gap-4">
<img src="/assets/images/avatar-2.png" alt="Jackson Lee" class="w-10 h-10 rounded-full" />
@@ -50,15 +51,17 @@ layout: layouts/layout.njk
<p class="text-sm text-muted-foreground">p@example.com</p>
</div>
{% call select(
{{ select(
is_combobox=true,
content_attrs={"data-align": "end"}
) %}
<div role="option" data-value="viewer">Viewer</div>
<div role="option" data-value="developer">Developer</div>
<div role="option" data-value="billing">Billing</div>
<div role="option" data-value="owner">Owner</div>
{% endcall %}
popover_attrs={"data-align": "end"},
items=[
{ type: "item", value: "", label: "Empty" },
{ type: "item", value: "viewer", label: "Viewer" },
{ type: "item", value: "developer", label: "Developer" },
{ type: "item", value: "billing", label: "Billing" },
{ type: "item", value: "owner", label: "Owner" }
]
) }}
</li>
<li class="flex items-center gap-4">
<img src="/assets/images/avatar-3.png" alt="Isabella Nguyen" class="w-10 h-10 rounded-full" />
@@ -67,15 +70,16 @@ layout: layouts/layout.njk
<p class="text-sm text-muted-foreground">i@example.com</p>
</div>
{% call select(
{{ select(
is_combobox=true,
content_attrs={"data-align": "end"}
) %}
<div role="option" data-value="viewer">Viewer</div>
<div role="option" data-value="developer">Developer</div>
<div role="option" data-value="billing">Billing</div>
<div role="option" data-value="owner">Owner</div>
{% endcall %}
popover_attrs={"data-align": "end"},
items=[
{ type: "item", value: "viewer", label: "Viewer" },
{ type: "item", value: "developer", label: "Developer" },
{ type: "item", value: "billing", label: "Billing" },
{ type: "item", value: "owner", label: "Owner" }
]
) }}
</li>
</ul>
</section>
+99 -51
View File
@@ -3,18 +3,22 @@ layout: layouts/page.njk
title: Installation
description: How to install and use Basecoat in your app.
toc:
- label: Installing the CSS
id: install-css
- label: Install using the CDN
id: install-cdn
- label: Installing using NPM
id: install-npm
children:
- label: Add Tailwind CSS
id: install-css
id: install-npm-tailwind
- label: Add Basecoat
id: install-css-basecoat
id: install-npm-basecoat
- label: Import basecoat in your CSS
id: install-css-import
id: install-npm-import
- label: (Optional) Add JavaScript files
id: install-npm-js
- label: That's it
id: install-css-done
- label: Components with Javascript
id: install-npm-done
- label: Components with JavaScript
id: install-js
- label: Use the CLI
id: install-cli
@@ -27,18 +31,40 @@ toc:
{% from "macros/code_block.njk" import code_block %}
{% from "tabs.njk" import tabs %}
<h2 id="install-css">Install the CSS</h2>
<h2 id="install-cdn"><a href="#install-cdn">Install using the CDN</a></h2>
<h3 id="install-cdn-all"><a href="#install-cdn-all">Install all components</a></h3>
<section class="prose">
<p>Add the following to the <code>&lt;head&gt;</code> of your HTML file:</p>
</section>
{% set code %}<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/basecoat-css@{{ site.version }}/dist/basecoat.cdn.min.css">
<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ site.version }}/dist/js/all.min.js" defer></script>{% endset %}
{{ code_block(code, "html") }}
<h3 id="install-cdn-all"><a href="#install-cdn-all">Install specific components</a></h3>
<section class="prose">
<p>While the JavaScript file for all components is small (around 3kB gzipped), you can cherry-pick the components you need as instructed in the component's page (<a href="/components/dropdown-menu">Dropdown Menu</a>, <a href="/components/popover">Popover</a>, <a href="/components/select">Select</a>, <a href="/components/sidebar">Sidebar</a>, <a href="/components/tabs">Tabs</a> and <a href="/components/toast">Toast</a>). For example, to add the <a href="/components/dropdown-menu">Dropdown Menu</a> component, add the following to the <code>&lt;head&gt;</code> of your HTML file:</p>
</section>
{% set code %}<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/basecoat-css@{{ site.version }}/dist/basecoat.cdn.min.css">
<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ site.version }}/dist/js/dropdown-menu.min.js" defer></script>{% endset %}
{{ code_block(code, "html") }}
<h2 id="install-npm"><a href="#install-npm">Install using NPM</a></h2>
<ol class="[&>h3]:step steps mb-12 md:ml-4 md:border-l md:pl-8">
<li class="step">
<h4 class="mb-4 scroll-m-20 font-semibold tracking-tight" id="install-css-tailwind"><a href="#install-css-tailwind">Add Tailwind CSS</a></h4>
<h3 class="mb-4 scroll-m-20 font-semibold tracking-tight" id="install-npm-tailwind"><a href="#install-npm-tailwind">Add Tailwind CSS</a></h3>
<div class="prose">
<p>Basecoat uses Tailwind CSS. You need to install it in your project.</p>
<p><a href="https://tailwindcss.com/docs/installation" target="_blank">Follow the Tailwind CSS installation instructions to get started.</a></p>
</div>
</li>
<li class="step mt-8">
<h3 class="mb-4 scroll-m-20 font-semibold tracking-tight" id="install-css-basecoat"><a href="#install-css-basecoat">Add Basecoat</a></h3>
<h3 class="mb-4 scroll-m-20 font-semibold tracking-tight" id="install-npm-basecoat"><a href="#install-npm-basecoat">Add Basecoat</a></h3>
<div class="prose">
<p>Add Basecoat to your Tailwind CSS file.</p>
</div>
@@ -58,43 +84,62 @@ toc:
</div>
</li>
<li class="step mt-8">
<h3 class="mb-4 scroll-m-20 font-semibold tracking-tight" id="install-css-import"><a href="#install-css-import">Import basecoat in your CSS</a></h3>
<h3 class="mb-4 scroll-m-20 font-semibold tracking-tight" id="install-npm-import"><a href="#install-npm-import">Import basecoat in your CSS</a></h3>
{% set code %}@import "tailwindcss";
@import "basecoat-css";{% endset %}
{{ code_block(code, "css") }}
</li>
<li class="step mt-8">
<h3 class="mb-4 scroll-m-20 font-semibold tracking-tight" id="install-css-done"><a href="#install-css-done">That's it</a></h3>
<h3 class="mb-4 scroll-m-20 font-semibold tracking-tight" id="install-npm-js"><a href="#install-npm-js">(Optional) Add JavaScript files</a></h3>
<div class="prose">
<p>Some components <a href="#install-js">need some JavaScript</a> (e.g. the <a href="/components/tabs">Tabs</a> component). There are two ways to get it into your project:</p>
<h4 class="mt-6 font-semibold">Using a build tool (Vite, Webpack…)</h4>
<p>If your project is ESM-aware you can directly import the scripts. To include all components:</p>
</div>
{% set code %}{% raw %}import 'basecoat-css/all';{% endraw %}{% endset %}
{{ code_block(code, "js") }}
<div class="prose">
<p>Or cherry-pick specific components, for example:</p>
</div>
{% set code %}{% raw %}import 'basecoat-css/popover';
import 'basecoat-css/tabs';{% endraw %}{% endset %}
{{ code_block(code, "js") }}
<div class="prose">
<h4 class="mt-6 font-semibold">Without a build tool</h4>
<p>Copy the pre-built files from <code>node_modules</code> into your public directory:</p>
</div>
{% set code %}npx copyfiles -u 1 "node_modules/basecoat-css/dist/js/**/*" public/js/basecoat{% endset %}
{{ code_block(code, "bash") }}
<div class="prose">
<p>Then reference the script you need:</p>
</div>
{% set code %}<script src="/js/basecoat/tabs.min.js" defer></script>{% endset %}
{{ code_block(code, "html") }}
<div class="prose">
<p>See each component page for the minimal script required.</p>
</div>
</li>
<li class="step mt-8">
<h3 class="mb-4 scroll-m-20 font-semibold tracking-tight" id="install-npm-done"><a href="#install-npm-done">That's it</a></h3>
<div class="prose">
<p>You can now use any of the Basecoat classes in your project (e.g. <code>btn</code>, <code>card</code>, <code>input</code>, etc). Read the documentation about each component to get started (e.g. <a href="/components/button">button</a>, <a href="/components/card">card</a>, <a href="/components/input">input</a>, etc).</p>
<p>Some components (e.g. <a href="/components/select">Select</a>) require <a href="#install-js">Javascript code to work</a>.</p>
<p>Some components (e.g. <a href="/components/select">Select</a>) require <a href="#install-js">JavaScript code to work</a>.</p>
</div>
</li>
</ol>
<h2 id="install-js"><a href="#install-js">Components with Javascript</a></h2>
<h2 id="install-js"><a href="#install-js">Components with JavaScript</a></h2>
<div class="prose">
<p><b>Some components require Javascript code to work.</b></p>
<p>The documentation provides Alpine.js code snippets to cover that. <a href="https://alpinejs.dev/docs/installation" target="_blank">Make sure to install Alpine.js if you decide to use them.</a></p>
<p>For simple components like <a href="/components/accordion">Accordion</a> or <a href="/components/slider">Slider</a>, you will notice some inline Alpine.js code that you can just copy along with the rest of the HTML.</p>
<p>For more complex components (<a href="/components/dialog">Dialog</a>, <a href="/components/dropdown-menu">Dropdown Menu</a>, <a href="/components/popover">Popover</a>, <a href="/components/select">Select</a>, <a href="/components/tabs">Tabs</a> and <a href="/components/tooltip">Toast</a>), you have one of 2 options:
<p><b>A handful of components require JavaScript code to work.</b>, specifically <a href="/components/dropdown-menu">Dropdown Menu</a>, <a href="/components/popover">Popover</a>, <a href="/components/select">Select</a>, <a href="/components/sidebar">Sidebar</a>, <a href="/components/tabs">Tabs</a> and <a href="/components/toast">Toast</a></p>
<p>If a component requires JavaScript, the documentation page will provide instructions. There are 2 options to add the JavaScript code to your project:</p>
<ul>
<li>Copy the Javascript snippet provided in the documentation page into your project.</li>
<li>
<p><a href="https://github.com/hunvreus/basecoat/tree/main/src/js" target="_blank">Download the script as a separate file</a> and include it in your project (<a href="#install-cli">or let the CLI do it for you</a>). <b>If you use this approach, make sure to load the file before the Alpine.js core JS file and register the component on the <code>alpine:init</code> event.</b> For example:</p>
{% set code %}<script src="/assets/js/dialog.js" defer></script>
<script>
document.addEventListener('alpine:init', () => {
window.basecoat.registerDialog(Alpine);
});
</script>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>{% endset %}
{{ code_block(code, "html") }}
</li>
<li><b>CDN</b>: you either <a href="#install-cdn-all">add the code for all of the components</a> to the <code>&lt;head&gt;</code> of your HTML file, or just the file for the component you want to use as instructed on the component's page.</li>
<li><b>Local file</b>: you download the script as a separate file and include it in your project (<a href="#install-cli">or let the CLI do it for you</a>). Once again, you have the choice to include the fiel for all components at once (<a><code>all.min.js</code> or <code>all.js</code>).</p>
</ul>
<p>You are also welcome to write your own Javascript.</p>
<p>All of the components try and implement WAI-ARIA standards to make them accessible.</p>
</div>
<h2 id="install-cli"><a href="#install-cli">Use the CLI</a></h2>
@@ -117,15 +162,17 @@ toc:
{{ tabs(id="cli-execution", tabsets=exec_tabsets, label="Execute CLI commands to add components:") }}
<div class="prose my-6">
<p>You will be asked to confirm the template engine and destination directory for the Javascript and macros.</p>
<p>You will be asked to confirm the template engine and destination directory for the JavaScript and macros.</p>
</div>
<h2 id="install-macros"><a href="#install-macros">Use Nunjucks or Jinja macros</a></h2>
<div class="prose">
<p>For more complex components, you can use <a href="https://mozilla.github.io/nunjucks/" target="_blank">Nunjucks</a> or <a href="https://jinja.palletsprojects.com" target="_blank">Jinja</a> macros to help you generate the HTML and Javascript code.</p>
<p>For more complex components, you can use <a href="https://mozilla.github.io/nunjucks/" target="_blank">Nunjucks</a> or <a href="https://jinja.palletsprojects.com" target="_blank">Jinja</a> macros to help you generate the HTML and JavaScript code.</p>
<p>To install them, either <a href="https://github.com/hunvreus/basecoat/tree/main/src" target="_blank">copy them directly from the GitHub repository</a> or use <a href="#install-cli">the CLI</a> to do the work for you.</p>
<p>For example, here's how you could use the <code>select()</code> macro to generate the HTML and Javascript code for a <a href="/components/select">Select</a> component:</p>
<p>For example, here's how you could use the <code>select()</code> macro to generate the HTML and JavaScript code for a <a href="/components/select">Select</a> component:</p>
</div>
{% set code %}{% raw %}{% call select() %}
<div role="group" aria-labelledby="fruit-options-label">
<span id="fruit-options-label" role="heading">Fruits</span>
@@ -137,16 +184,6 @@ toc:
</div>
{% endcall %}{% endraw %}{% endset %}
{{ code_block(code, "jinja") }}
<p>Make sure to include the Javascript code <b>before</b> Alpine.js core JS file:</p>
{% set code %}<script src="/assets/js/select.js" defer></script>
<script>
document.addEventListener('alpine:init', () => {
window.basecoat.registerSelect(Alpine);
});
</script>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>{% endset %}
{{ code_block(code, "jinja") }}
</div>
<h2 id="install-theming"><a href="#install-theming">Theming</a></h2>
@@ -159,7 +196,7 @@ toc:
Import the theme in your CSS file:
{% set code %}@import "tailwindcss";
@import "basecoat-css";
@import "theme.css";{% endset %}
@import "./theme.css";{% endset %}
{{ code_block(code, "css") }}
</li>
</ul>
@@ -175,14 +212,25 @@ toc:
<h2 id="install-customization"><a href="#install-manual">Customization</a></h2>
<div class="prose">
<p>You can override default styles using Tailwind:</p>
<section class="prose">
<p>You can override default styles using Tailwind:</p>
</section>
{% set code %}<button class="btn font-normal ">Click me</button>{% endset %}
{{ code_block(code, "html") }}
<p>You can also obviously extend and/or override the existing styles with your own Tailwind files:</p>
<section class="prose">
<p>You can also extend or override the existing styles in your own Tailwind files:</p>
</section>
{% set code %}@import "tailwindcss";
@import "basecoat-css";
@import "theme.css";{% endset %}
@import "./custom.css";{% endset %}
{{ code_block(code, "css") }}
<p>You can also use use the <a href="#install-theming">theme variables</a> to customize many aspects of the components (colors, fonts, sizes, etc).</p>
<p><b>If you want to make more drastic changes</b>, I recommend <a href="https://github.com/hunvreus/basecoat/blob/main/src/css/basecoat.css" target="_blank">copying the <code>basecoat.css</code> file</a> into your project and making your changes there.</p>
<section class="prose">
<p>More importantly, you can use the <a href="#install-theming">theme variables</a> to customize many aspects of the components (colors, fonts, sizes, etc).</p>
<p><b>If you want to make more drastic changes</b>, you can <a href="https://github.com/hunvreus/basecoat/blob/main/src/css/basecoat.css" target="_blank">copy the <code>basecoat.css</code> file</a> into your project and make your changes there.</p>
</section>
</div>
+71
View File
@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anchor Positioning Test (Top, End)</title>
<script type="module">
if (!("anchorName" in document.documentElement.style)) {
console.log("Loading CSS anchor positioning polyfill...");
import("https://unpkg.com/@oddbird/css-anchor-positioning");
}
</script>
<style>
body {
font-family: sans-serif;
display: grid;
place-items: center;
height: 100vh;
margin: 0;
background: #222;
color: #eee;
}
button {
padding: 0.5em 1em;
font-size: 1rem;
border-radius: 6px;
border: 1px solid #888;
background: #444;
color: #eee;
cursor: pointer;
}
[popover] {
border: 1px solid #888;
border-radius: 6px;
background: #333;
color: #eee;
padding: 1em;
margin: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
/* The core anchor positioning logic */
#my-popover {
position: absolute;
inset: auto; /* Required to override default popover centering */
bottom: anchor(top); /* Position bottom of popover to top of anchor */
right: anchor(right); /* Align right edge of popover with right edge of anchor */
margin-bottom: 8px; /* A little space between popover and button */
}
</style>
</head>
<body>
<button
popovertarget="my-popover"
id="my-anchor-button"
style="anchor-name: --my-anchor;">
Click Me
</button>
<div
popover
id="my-popover"
style="position-anchor: --my-anchor;">
I should be anchored above the button, aligned to the right.
</div>
</body>
</html>
+135 -5
View File
@@ -1,12 +1,12 @@
{
"name": "basecoat",
"version": "0.0.1",
"version": "0.2.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "basecoat",
"version": "0.0.1",
"version": "0.2.0-beta.1",
"license": "MIT",
"workspaces": [
"packages/*"
@@ -17,7 +17,8 @@
"@tailwindcss/cli": "^4.1.3",
"concurrently": "^9.1.2",
"prettier": "^3.5.3",
"tailwindcss": "^4.1.3"
"tailwindcss": "^4.1.3",
"terser": "^5.42.0"
}
},
"node_modules/@11ty/dependency-tree": {
@@ -243,6 +244,17 @@
"slash": "^1.0.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz",
"integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@grimlink/eleventy-plugin-lucide-icons": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@grimlink/eleventy-plugin-lucide-icons/-/eleventy-plugin-lucide-icons-2.1.4.tgz",
@@ -630,6 +642,70 @@
"node": ">=12"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/set-array": "^1.2.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/set-array": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
"integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1535,6 +1611,13 @@
"node": ">=8"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3703,6 +3786,27 @@
"node": ">=8.0.0"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -3880,6 +3984,32 @@
"node": ">=6"
}
},
"node_modules/terser": {
"version": "5.42.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.42.0.tgz",
"integrity": "sha512-UYCvU9YQW2f/Vwl+P0GfhxJxbUGLwd+5QrrGgLajzWAtC/23AX0vcise32kkP7Eu0Wu9VlzzHAXkLObgjQfFlQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.14.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
},
"engines": {
"node": ">=10"
}
},
"node_modules/terser/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@@ -4207,7 +4337,7 @@
},
"packages/cli": {
"name": "basecoat-cli",
"version": "0.1.0",
"version": "0.2.0-beta.1",
"license": "MIT",
"dependencies": {
"commander": "^13.1.0",
@@ -4220,7 +4350,7 @@
},
"packages/css": {
"name": "basecoat-css",
"version": "0.1.0",
"version": "0.2.0-beta.1",
"license": "MIT"
}
}
+3 -11
View File
@@ -1,6 +1,6 @@
{
"name": "basecoat",
"version": "0.1.0",
"version": "0.2.3",
"description": "shadcn/ui for the rest of us",
"author": "hunvreus",
"type": "module",
@@ -8,15 +8,6 @@
"workspaces": [
"packages/*"
],
"files": [
"src/css",
"src/js",
"src/nunjucks",
"src/jinja"
],
"exports": {
".": "./src/css/basecoat.css"
},
"keywords": [
"components",
"component library",
@@ -55,6 +46,7 @@
"@tailwindcss/cli": "^4.1.3",
"concurrently": "^9.1.2",
"prettier": "^3.5.3",
"tailwindcss": "^4.1.3"
"tailwindcss": "^4.1.3",
"terser": "^5.42.0"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "basecoat-cli",
"version": "0.1.1",
"version": "0.2.3",
"description": "Add Basecoat components to your project",
"author": "hunvreus",
"license": "MIT",
+35 -26
View File
@@ -8,42 +8,51 @@ Your project must have [Tailwind CSS](https://tailwindcss.com/docs/installation)
## Installation
You can install `basecoat-css` using your preferred package manager:
Install with any package manager:
```bash
# npm
npm install basecoat-css
# yarn
yarn add basecoat-css
# pnpm
pnpm add basecoat-css
# bun
bun add basecoat-css
npm install basecoat-css # or pnpm add / yarn add / bun add
```
Alternatively, you can download the `basecoat.css` file directly from the [GitHub repository](https://github.com/hunvreus/basecoat/blob/main/src/css/basecoat.css) (or the `dist/basecoat.css` file from this package after installation) and include it in your project.
## Usage
After installation, import `basecoat-css` into your main CSS file. Make sure to import it **after** Tailwind CSS and **before** any custom theme overrides.
Add it just after Tailwind in your stylesheet:
```css
/* Your main CSS file (e.g., styles.css) */
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
@import "basecoat-css"; /* Or the path to basecoat.css if manually added */
/* Your custom styles or theme overrides (optional) */
/* @import "your-theme.css"; */
@import "tailwindcss";
@import "basecoat-css";
```
Once imported, you can start using Basecoat's component classes in your HTML.
That's it, you can use any Basecoat class (`btn`, `card`, `input`, etc) in your markup.
### (Optional) JavaScript files
Some interactive components (Dropdown Menu, Popover, Select, Sidebar, Tabs, Toast) need some JavaScript.
With a build tool (ESM):
```js
import 'basecoat-css/all';
```
Or cherry-pick the components you need:
```js
import 'basecoat-css/tabs';
import 'basecoat-css/popover';
```
Without a build tool, copy the files from `node_modules`:
```bash
npx copyfiles -u 1 "node_modules/basecoat-css/dist/js/**/*" public/js/basecoat
```
Then reference what you need, e.g.
```html
<script src="/js/basecoat/tabs.min.js" defer></script>
```
## Documentation
+22 -2
View File
@@ -1,12 +1,13 @@
{
"name": "basecoat-css",
"version": "0.1.2",
"version": "0.2.3",
"description": "Tailwind CSS for Basecoat components",
"author": {
"name": "hunvreus",
"url": "https://x.com/hunvreus"
},
"license": "MIT",
"type": "module",
"main": "dist/basecoat.css",
"style": "dist/basecoat.css",
"files": [
@@ -36,5 +37,24 @@
"bugs": {
"url": "https://github.com/hunvreus/basecoat/issues"
},
"homepage": "https://basecoatui.com/installation#install-css"
"homepage": "https://basecoatui.com/installation#install-npm",
"exports": {
".": "./dist/basecoat.css",
"./css": "./dist/basecoat.css",
"./all": "./dist/js/all.js",
"./all.min": "./dist/js/all.min.js",
"./dropdown-menu": "./dist/js/dropdown-menu.js",
"./dropdown-menu.min": "./dist/js/dropdown-menu.min.js",
"./popover": "./dist/js/popover.js",
"./popover.min": "./dist/js/popover.min.js",
"./select": "./dist/js/select.js",
"./select.min": "./dist/js/select.min.js",
"./sidebar": "./dist/js/sidebar.js",
"./sidebar.min": "./dist/js/sidebar.min.js",
"./tabs": "./dist/js/tabs.js",
"./tabs.min": "./dist/js/tabs.min.js",
"./toast": "./dist/js/toast.js",
"./toast.min": "./dist/js/toast.min.js",
"./package.json": "./package.json"
}
}
+60 -2
View File
@@ -1,6 +1,10 @@
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { exec } from 'child_process';
import { promisify } from 'util';
const execPromise = promisify(exec);
// Resolve project root, assuming this script is in <project_root>/scripts/
const __filename = fileURLToPath(import.meta.url);
@@ -90,8 +94,52 @@ async function build() {
await fs.copyFile(path.join(cliPackageDir, 'index.js'), path.join(cliDistDir, 'index.js'));
console.log(`Copied CLI index.js to ${cliDistDir}`);
await copyDirRecursive(srcJsDir, path.join(cliDistAssetsDir, 'js'));
console.log(`Copied JS assets to ${path.join(cliDistAssetsDir, 'js')}`);
// JS files minification and copy
const jsFiles = await fs.readdir(srcJsDir);
const cliJsDistDir = path.join(cliDistAssetsDir, 'js');
const cssJsDistDir = path.join(cssDistDir, 'js');
await ensureDir(cliJsDistDir);
await ensureDir(cssJsDistDir);
console.log('Copying and minifying JS files...');
for (const jsFile of jsFiles) {
if (path.extname(jsFile) === '.js') {
const srcFile = path.join(srcJsDir, jsFile);
const baseName = path.basename(jsFile, '.js');
const minifiedFileName = `${baseName}.min.js`;
// Copy original file to both destinations
await fs.copyFile(srcFile, path.join(cliJsDistDir, jsFile));
await fs.copyFile(srcFile, path.join(cssJsDistDir, jsFile));
// Create and copy minified file to both destinations
const cliDestMinFile = path.join(cliJsDistDir, minifiedFileName);
await execPromise(`npx terser ${srcFile} -o ${cliDestMinFile} --compress --mangle`);
await fs.copyFile(cliDestMinFile, path.join(cssJsDistDir, minifiedFileName));
}
}
// Create combined component files
console.log('Creating combined component files...');
const componentsToCombine = ['dropdown-menu.js', 'popover.js', 'select.js', 'sidebar.js', 'tabs.js', 'toast.js'];
const componentPaths = componentsToCombine.map(f => path.join(srcJsDir, f));
// Create non-minified bundle
let combinedContent = '';
for (const p of componentPaths) {
combinedContent += await fs.readFile(p, 'utf-8') + '\n';
}
const allJsPath = path.join(cliJsDistDir, 'all.js');
await fs.writeFile(allJsPath, combinedContent);
await fs.copyFile(allJsPath, path.join(cssJsDistDir, 'all.js'));
// Create minified bundle
const allMinJsPath = path.join(cliJsDistDir, 'all.min.js');
await execPromise(`npx terser ${componentPaths.join(' ')} -o ${allMinJsPath} --compress --mangle`);
await fs.copyFile(allMinJsPath, path.join(cssJsDistDir, 'all.min.js'));
console.log(`Copied and minified JS to ${cliJsDistDir} and ${cssJsDistDir}`);
await copyDirRecursive(srcNunjucksDir, path.join(cliDistAssetsDir, 'nunjucks'));
console.log(`Copied Nunjucks assets to ${path.join(cliDistAssetsDir, 'nunjucks')}`);
await copyDirRecursive(srcJinjaDir, path.join(cliDistAssetsDir, 'jinja'));
@@ -103,6 +151,16 @@ async function build() {
await fs.copyFile(path.join(srcCssDir, 'basecoat.css'), path.join(cssDistDir, 'basecoat.css'));
console.log(`Copied basecoat.css to ${cssDistDir}`);
// Create Tailwind CSS builds for the CSS package
const cdnCssSrc = path.join(srcCssDir, 'basecoat.cdn.css');
const cssDistCdnPath = path.join(cssDistDir, 'basecoat.cdn.css');
const cssDistCdnMinPath = path.join(cssDistDir, 'basecoat.cdn.min.css');
await execPromise(`npx tailwindcss -i "${cdnCssSrc}" -o "${cssDistCdnPath}"`);
console.log(`Generated non-minified CSS: ${cssDistCdnPath}`);
await execPromise(`npx tailwindcss -i "${cdnCssSrc}" -o "${cssDistCdnMinPath}" --minify`);
console.log(`Generated minified CSS: ${cssDistCdnMinPath}`);
console.log('Build process finished successfully!');
}
+2
View File
@@ -0,0 +1,2 @@
@import "tailwindcss";
@import "./basecoat.css";
+201 -126
View File
@@ -161,7 +161,6 @@
h2 {
@apply col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight;
}
section {
@apply text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed;
@@ -446,43 +445,94 @@
/* Dialog */
@layer components {
.dialog {
[role='dialog'] {
@apply fixed inset-0 z-50 bg-black/50 opacity-0 invisible transition-all;
@apply inset-y-0 opacity-0 transition-all transition-discrete;
&:not([inert]),
&[inert='false'] {
@apply opacity-100 visible;
> article {
@apply opacity-100 scale-100 visible;
}
&:is([open],:popover-open) {
@apply opacity-100;
&::backdrop {
@apply opacity-100;
}
> article {
@apply bg-background fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg sm:max-w-lg opacity-0 scale-95 invisible transition-all max-h-[calc(100%-2rem)];
@apply scale-100;
}
> header {
@apply flex flex-col gap-2 text-center sm:text-left;
@starting-style {
@apply opacity-0;
> h2 {
@apply text-lg leading-none font-semibold;
}
> p {
@apply text-muted-foreground text-sm;
}
&::backdrop {
@apply opacity-0;
}
> section {
@apply flex-1 -mx-6 px-6;
> article {
@apply scale-95;
}
> footer {
@apply flex flex-col-reverse gap-2 sm:flex-row sm:justify-end;
}
}
&::backdrop {
@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 transition-all scale-95;
> header {
@apply flex flex-col gap-2 text-center sm:text-left;
> h2 {
@apply text-lg leading-none font-semibold;
}
> p {
@apply text-muted-foreground text-sm;
}
}
> section {
@apply flex-1 -mx-6 px-6;
}
> footer {
@apply flex flex-col-reverse gap-2 sm:flex-row sm:justify-end;
}
> form[method='dialog'] {
@apply absolute top-4 right-4;
> button {
@apply ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4;
@apply ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4;
}
}
}
}
}
/* Dropdown Menu */
@layer components {
.dropdown-menu {
@apply relative inline-flex;
[data-popover] {
@apply p-1;
min-width: anchor-size(width);
[role='menuitem'],
[role='menuitemcheckbox'],
[role='menuitemradio'] {
@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;
}
&.active {
@apply bg-accent text-accent-foreground;
}
}
[role='menu'] [role='heading'] {
@apply flex px-2 py-1.5 text-sm font-medium;
}
[role='separator'] {
@apply border-border -mx-1 my-1;
}
}
}
}
/* Input */
@layer components {
.form input[type='text'],
@@ -532,121 +582,74 @@
/* Popover */
@layer components {
.popover {
@apply relative inline-flex;
[data-popover] {
@apply absolute bg-popover text-popover-foreground overflow-x-hidden overflow-y-auto rounded-md border shadow-md z-50 visible opacity-100 scale-100 min-w-full w-max transition-all;
> [data-popover] {
@apply absolute bg-popover text-popover-foreground overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md z-50 visible opacity-100 scale-100 min-w-full w-max transition-all;
&[aria-hidden='true'] {
@apply invisible opacity-0 scale-95;
&:not([data-side]),
&[data-side='bottom'] {
@apply -translate-y-2;
}
&[data-side='top'] {
@apply translate-y-2;
}
&[data-side='left'] {
@apply translate-x-2;
}
&[data-side='right'] {
@apply -translate-x-2;
}
}
&[aria-hidden='true'] {
@apply invisible opacity-0 scale-95;
&:not([data-side]),
&[data-side='bottom'] {
@apply mt-1 top-full;
@apply -translate-y-2;
}
&[data-side='top'] {
@apply mb-1 bottom-full;
@apply translate-y-2;
}
&[data-side='left'] {
@apply mr-1 right-full;
@apply translate-x-2;
}
&[data-side='right'] {
@apply ml-1 left-full;
@apply -translate-x-2;
}
&:not([data-side]),
&[data-side='bottom'],
&[data-side='top'] {
&:not([data-align]),
&[data-align='start'] {
@apply left-0;
}
&[data-align='end'] {
@apply right-0;
}
&[data-align='center'] {
@apply left-1/2 -translate-x-1/2;
}
}
&:not([data-side]),
&[data-side='bottom'] {
@apply mt-1 top-full;
}
&[data-side='top'] {
@apply mb-1 bottom-full;
}
&[data-side='left'] {
@apply mr-1 right-full;
}
&[data-side='right'] {
@apply ml-1 left-full;
}
&:not([data-side]),
&[data-side='bottom'],
&[data-side='top'] {
&:not([data-align]),
&[data-align='start'] {
@apply left-0;
}
&[data-side='left'],
&[data-side='right'] {
&:not([data-align]),
&[data-align='start'] {
@apply top-0;
}
&[data-align='end'] {
@apply bottom-0;
}
&[data-align='center'] {
@apply top-1/2 -translate-y-1/2;
}
&[data-align='end'] {
@apply right-0;
}
[role='menuitem'],
[role='menuitemcheckbox'],
[role='menuitemradio'],
[role='option'] {
@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;
&[data-align='center'] {
@apply left-1/2 -translate-x-1/2;
}
[role='menuitem'],
[role='menuitemcheckbox'],
[role='menuitemradio'] {
@apply focus-visible:bg-accent focus-visible:text-accent-foreground hover:bg-accent hover:text-accent-foreground;
}
&[data-side='left'],
&[data-side='right'] {
&:not([data-align]),
&[data-align='start'] {
@apply top-0;
}
[role='option'] {
@apply data-[focus]:bg-accent data-[focus]:text-accent-foreground pr-7.5;
&[aria-selected='true'] {
@apply bg-[image:var(--check-icon)] bg-no-repeat bg-position-[center_right_0.5rem] bg-size-[0.875rem];
}
&[data-align='end'] {
@apply bottom-0;
}
[role='menu'] [role='heading'] {
@apply flex px-2 py-1.5 text-sm font-medium;
}
[role='listbox'] [role='heading'] {
@apply flex text-muted-foreground px-2 py-1.5 text-xs;
}
[role='listbox'] [role='group']:not(:has([role='option']:not([aria-hidden='true']))) {
@apply hidden;
}
[role='separator'] {
@apply border-border -mx-1 my-1;
}
/* Combobox */
> header {
@apply flex h-9 items-center gap-2 border-b px-3 -mx-1 -mt-1 mb-1;
svg {
@apply size-4 shrink-0 opacity-50;
}
input[role='combobox'] {
@apply placeholder:text-muted-foreground flex h-10 flex-1 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50 min-w-0;
}
}
[role='listbox']:not(:has([data-value]:not([aria-hidden='true'])))::before {
@apply flex items-center justify-center p-6 text-sm truncate;
}
[role='listbox'][data-empty]:not(:has([data-value]:not([aria-hidden='true'])))::before {
@apply content-[attr(data-empty)];
}
[role='listbox']:not([data-empty]):not(:has([data-value]:not([aria-hidden='true'])))::before {
@apply content-['No_results_found'];
&[data-align='center'] {
@apply top-1/2 -translate-y-1/2;
}
}
}
.popover {
@apply relative inline-flex;
[data-popover] {
@apply p-4;
}
}
}
/* Radio */
@@ -713,7 +716,7 @@
/* Select */
@layer components {
.form select,
.select {
select.select {
@apply appearance-none border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent pl-3 pr-9 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 h-9;
@apply bg-[image:var(--chevron-down-icon-50)] bg-no-repeat bg-position-[center_right_0.75rem] bg-size-[1rem];
@@ -722,11 +725,60 @@
@apply bg-popover text-popover-foreground;
}
}
*:not(select).select {
@apply relative inline-flex;
[data-popover] {
@apply p-1;
[role='option'] {
@apply aria-hidden:hidden [&_svg]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm pl-2 py-1.5 pr-7.5 text-sm outline-hidden select-none [&_svg]:shrink-0 [&_svg]:size-4 aria-disabled:opacity-50 aria-disabled:pointer-events-none disabled:opacity-50 disabled:pointer-events-none w-full truncate;
&[aria-selected='true'] {
@apply bg-[image:var(--check-icon)] bg-no-repeat bg-position-[center_right_0.5rem] bg-size-[0.875rem];
}
}
[role='listbox'] [role='heading'] {
@apply flex text-muted-foreground px-2 py-1.5 text-xs;
}
[role='listbox'] [role='group']:not(:has([role='option']:not([aria-hidden='true']))) {
@apply hidden;
}
[role='separator'] {
@apply border-border -mx-1 my-1;
}
> header {
@apply flex h-9 items-center gap-2 border-b px-3 -mx-1 -mt-1 mb-1;
svg {
@apply size-4 shrink-0 opacity-50;
}
input[role='combobox'] {
@apply placeholder:text-muted-foreground flex h-10 flex-1 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50 min-w-0;
}
}
[role='listbox']:not(:has([data-value]:not([aria-hidden='true'])))::before {
@apply flex items-center justify-center p-6 text-sm truncate;
}
[role='listbox'][data-empty]:not(:has([data-value]:not([aria-hidden='true'])))::before {
@apply content-[attr(data-empty)];
}
[role='listbox']:not([data-empty]):not(:has([data-value]:not([aria-hidden='true'])))::before {
@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;
}
}
}
/* Sidebar */
.sidebar {
&[data-uninitialized] {
&:not([data-sidebar-initialized]) {
@apply max-md:hidden;
}
&:not([aria-hidden]),
@@ -734,7 +786,10 @@
@apply max-md:bg-black/50 max-md:fixed max-md:inset-0 max-md:z-40;
}
nav {
@apply bg-sidebar text-sidebar-foreground flex flex-col w-(--sidebar-mobile-width) md:w-(--sidebar-width) fixed inset-y-0 transition-transform ease-in-out duration-300 z-50;
@apply bg-sidebar text-sidebar-foreground flex flex-col w-(--sidebar-mobile-width) md:w-(--sidebar-width) fixed inset-y-0 z-50 transition-transform ease-in-out duration-300;
}
& + * {
@apply transition-[margin] ease-in-out duration-300;
}
&:not([data-side]),
&[data-side=left] {
@@ -742,7 +797,7 @@
@apply left-0 border-r;
}
& + * {
@apply relative md:ml-(--sidebar-width) transition-[margin] ease-in-out duration-300;
@apply relative md:ml-(--sidebar-width);
}
&[aria-hidden=true] {
nav {
@@ -758,7 +813,7 @@
@apply right-0 border-l;
}
& + * {
@apply relative md:mr-(--sidebar-width) transition-[margin] ease-in-out duration-300;
@apply relative md:mr-(--sidebar-width);
}
&[aria-hidden=true] {
nav {
@@ -930,7 +985,27 @@
@apply pointer-events-auto w-full mt-4 animate-[toast-up_0.3s_ease-in-out] grid grid-rows-[1fr] transition-[grid-template-rows,opacity,margin] duration-300 ease-in-out;
.toast-content {
@apply text-popover-foreground text-[13px] bg-popover border shadow-lg rounded-lg overflow-hidden;
@apply text-popover-foreground text-[13px] bg-popover border shadow-lg rounded-lg overflow-hidden flex gap-2.5 p-3 items-center;
svg {
@apply size-4 shrink-0;
}
section {
h2 {
@apply font-medium tracking-tight;
}
p {
@apply text-muted-foreground;
}
}
footer {
@apply ml-auto flex flex-col gap-2;
[data-toast-action],
[data-toast-cancel] {
@apply h-6 px-2.5 text-xs;
}
}
}
&[aria-hidden='true'] {
@apply grid-rows-[0fr] opacity-0 m-0 border-0 p-0 overflow-hidden;
+71 -87
View File
@@ -8,103 +8,87 @@
@param footer {string} [optional] - HTML content for the dialog footer.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
@param content_attrs {object} [optional] - Additional HTML attributes for the dialog content article.
@param content_header_attrs {object} [optional] - Additional HTML attributes for the dialog header.
@param content_body_attrs {object} [optional] - Additional HTML attributes for the dialog body section.
@param content_footer_attrs {object} [optional] - Additional HTML attributes for the dialog footer.
@param open {boolean} [optional] [default=False] - Whether the dialog should be open initially.
@param close_button {boolean} [optional] [default=True] - Whether to include a close button.
@param close_on_overlay_click {boolean} [optional] [default=True] - Whether clicking the overlay closes the dialog.
@param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog content article.
@param header_attrs {object} [optional] - Additional HTML attributes for the dialog header.
@param body_attrs {object} [optional] - Additional HTML attributes for the dialog body section.
@param footer_attrs {object} [optional] - Additional HTML attributes for the dialog footer.
@param open {boolean} [optional] [default=false] - Whether the dialog should be open initially.
@param close_button {boolean} [optional] [default=true] - Whether to include a close button.
@param close_on_overlay_click {boolean} [optional] [default=true] - Whether clicking the overlay closes the dialog.
#}
{% macro dialog(
id,
id=None,
trigger=None,
title=None,
description=None,
footer=None,
main_attrs={},
dialog_attrs={},
trigger_attrs={},
content_attrs={},
content_header_attrs={},
content_body_attrs={},
content_footer_attrs={},
open=False,
close_button=True,
close_on_overlay_click=True
header_attrs={},
body_attrs={},
footer_attrs={},
open=false,
close_button=true,
close_on_overlay_click=true
) %}
<div
id="{{ id }}"
x-data="dialog({{ 'true' if open else 'false' }}, {{ 'true' if close_on_overlay_click else 'false' }})"
x-bind="$main"
class="dialog"
{% for key, value in main_attrs.items() %}
{% set id = id or ("dialog-" + (range(100000, 999999) | random | string)) %}
{% if trigger %}
<button
type="button"
onclick="document.getElementById('{{ id }}').showModal()"
{% for key, value in trigger_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if trigger %}
<button
type="button"
aria-expanded="false"
aria-controls="{{ id }}-dialog"
x-bind="$trigger"
{% for key, value in trigger_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger }}
</button>
{% endif %}
<div
role="dialog"
id="{{ id }}-dialog"
tabindex="-1"
aria-modal="true"
aria-labelledby="{{ id }}-title"
inert
x-bind="$content"
>
<article
{% for key, value in content_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if title or description %}
<header
{% for key, value in content_header_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
<h2 id="{{ id }}-title">{{ title | safe }}</h2>
<p>{{ description | safe }}</p>
</header>
{% endif %}
{% if caller %}
<section
{% for key, value in content_body_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ caller() }}
</section>
{% endif %}
{% if footer %}
<footer
{% for key, value in content_footer_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ footer | safe }}
</footer>
{% endif %}
{% if close_button %}
<button
@click="hide()"
aria-label="Close dialog"
>
{{ trigger }}
</button>
{% endif %}
<dialog
id="{{ id }}"
class="dialog"
aria-labelledby="{{ id }}-title"
{% if description %}aria-describedby="{{ id }}-description"{% endif %}
{% if close_on_overlay_click %}onclick="this.close()"{% endif %}
{% for key, value in dialog_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
<article {% if close_on_overlay_click %}onclick="event.stopPropagation()"{% endif %}>
{% if title or description %}
<header
{% for key, value in header_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
<h2 id="{{ id }}-title">{{ title | safe }}</h2>
{% if description %}<p id="{{ id }}-description">{{ description | safe }}</p>{% endif %}
</header>
{% endif %}
{% if caller %}
<section
{% for key, value in body_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ caller() }}
</section>
{% endif %}
{% if footer %}
<footer
{% for key, value in footer_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ footer | safe }}
</footer>
{% endif %}
{% if close_button %}
<form method="dialog">
<button aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
{% endif %}
</article>
</div>
</div>
</button>
</form>
{% endif %}
</article>
</dialog>
{% endmacro %}
+47 -39
View File
@@ -3,61 +3,62 @@
@param id {string} [optional] - Unique identifier for the dropdown component.
@param trigger {string} [optional] - HTML content for the button that triggers the dropdown.
@param menu {array} [optional] - Array of menu items for the dropdown.
@param items {array} [optional] - Array of menu items for the dropdown.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
@param content_attrs {object} [optional] - Additional HTML attributes for the dropdown content div.
@param popover_attrs {object} [optional] - Additional HTML attributes for the dropdown content div.
#}
{% macro dropdown_menu(
trigger,
id=None,
trigger=None,
menu=None,
items=None,
main_attrs={},
trigger_attrs={},
content_attrs={}
popover_attrs={},
menu_attrs={}
) %}
{% set id = id or ("dropdown-menu-" + (range(100000, 999999) | random | string)) %}
<div
class="popover {{ main_attrs.class }}"
x-data="dropdownMenu"
@click.away="open = false"
{% if id %}id="{{ id }}"{% endif %}
{% for key, value in main_attrs %}
{% if key != "class" %}{{ key }}="{{ value }}"{% endif %}
class="dropdown-menu {{ main_attrs.class }}"
{% for key, value in main_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% if trigger %}
<button
type="button"
aria-haspopup="menu"
aria-expanded="false"
x-bind="$trigger"
{% if id %}
id="{{ id }}-trigger"
aria-haspopup="menu"
aria-controls="{{ id }}-menu"
{% endif %}
{% for key, value in trigger_attrs %}
aria-expanded="false"
{% for key, value in trigger_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger | safe }}
</button>
{% endif %}
<div
id="{{ id }}"
data-popover
aria-hidden="true"
x-bind="$content"
{% if id %}id="{{ id }}-menu"{% endif %}
{% for key, value in content_attrs %}
{% for key, value in popover_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
<nav role="menu">
{% if menu %}
{{ render_dropdown_items(menu, id ~ "-items" if id else "items") }}
<div
role="menu"
id="{{ id }}-menu"
aria-labelledby="{{ id }}-trigger"
{% for key, value in menu_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if items %}
{{ render_dropdown_items(items, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</nav>
</div>
</div>
</div>
{% endmacro %}
@@ -69,7 +70,7 @@
@param parent_id_prefix {string} [optional] - Prefix for generating element IDs.
#}
{% macro render_dropdown_items(items, parent_id_prefix="items") %}
{% for item in items.items() %}
{% for item in items %}
{% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
{% if item.type == "group" %}
@@ -77,9 +78,11 @@
<div
role="group"
aria-labelledby="{{ group_label_id }}"
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
{{ render_dropdown_items(item.items, item_id) if item.items }}
@@ -89,24 +92,29 @@
{% elif item.type == "item" or not item.type %}
{% if item.url %}
<a
id="{{ item_id }}"
role="menuitem"
href="{{ item.url }}"
{% for key, value in item.attrs.items() %}
{% if key != "url" %} {{ key }}="{{ value }}" {% endif %}
{% endfor %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{% if key != "url" %} {{ key }}="{{ value }}" {% endif %}
{% endfor %}
{% endif %}
>
{{ item.label | safe }}
</a>
{% else %}
<button
<div
id="{{ item_id }}"
role="menuitem"
type="button"
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ item.label | safe }}
</button>
</div>
{% endif %}
{% endif %}
{% endfor %}
+12 -20
View File
@@ -5,51 +5,43 @@
@param trigger {string} [optional] - HTML content for the element that triggers the popover.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger element.
@param content_attrs {object} [optional] - Additional HTML attributes for the popover content div.
@param popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
#}
{% macro popover(
trigger,
id=None,
trigger=None,
main_attrs={},
trigger_attrs={},
content_attrs={}
popover_attrs={}
) %}
{% set id = id or ("popover-" + (range(100000, 999999) | random | string)) %}
<div
class="popover {{ main_attrs.class }}"
x-data="popover"
@click.away="open = false"
{% if id %}id="{{ id }}"{% endif %}
{% for key, value in main_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% if trigger %}
<button
type="button"
aria-haspopup="menu"
aria-expanded="false"
x-bind="$trigger"
{% if id %}
id="{{ id }}-trigger"
aria-controls="{{ id }}-menu"
{% endif %}
type="button"
aria-expanded="false"
aria-controls="{{ id }}"
{% for key, value in trigger_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger | safe }}
</button>
{% endif %}
<div
id="{{ id }}"
data-popover
aria-hidden="true"
x-bind="$content"
{% if id %}id="{{ id }}-menu"{% endif %}
{% for key, value in content_attrs.items() %}
{% for key, value in popover_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
>
{{ caller() if caller }}
</div>
</div>
{% endmacro %}
{% endmacro %}
+69 -45
View File
@@ -6,50 +6,72 @@
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
@param content_attrs {object} [optional] - Additional HTML attributes for the popover content div.
@param popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
@param listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div.
@param input_attrs {object} [optional] - Additional HTML attributes for the hidden input.
@param search_placeholder {string} [optional] [default="Search entries..."] - Placeholder text for the search input (combobox only).
@param is_combobox {boolean} [optional] [default=False] - Renders a combobox with search functionality if True.
@param is_combobox {boolean} [optional] [default=false] - Renders a combobox with search functionality if true.
#}
{% macro select(
id=None,
selected=None,
name=None,
items=None,
items=[],
main_attrs={},
trigger_attrs={},
content_attrs={},
popover_attrs={},
listbox_attrs={},
input_attrs={},
search_placeholder="Search entries...",
is_combobox=False
is_combobox=false
) %}
{% set id = id or ("select-" + (range(100000, 999999) | random | string)) %}
{% set first_option = namespace(item=None) %}
{% set selected_option = namespace(item=None) %}
{% if items %}
{% for item in items %}
{% if item.type == "group" %}
{% for sub_item in item.items %}
{% if not first_option.item %}
{% set first_option.item = sub_item %}
{% endif %}
{% if selected and sub_item.value == selected and not selected_option.item %}
{% set selected_option.item = sub_item %}
{% endif %}
{% endfor %}
{% else %}
{% if not first_option.item %}
{% set first_option.item = item %}
{% endif %}
{% if selected and item.value == selected and not selected_option.item %}
{% set selected_option.item = item %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% set default_option = selected_option.item or first_option.item or None %}
<div
class="popover {{ main_attrs.class }}"
x-data="select('{{ name }}', '{{ selected or '' }}')"
@click.away="open = false"
{% if id %}id="{{ id }}"{% endif %}
class="select {{ main_attrs.class }}"
{% for key, value in main_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<button
type="button"
class="btn-outline justify-between font-normal {{ trigger_attrs.class }}"
id="{{ id }}-trigger"
aria-haspopup="listbox"
aria-expanded="false"
x-bind="$trigger"
{% if id %}
id="{{ id }}-trigger"
aria-controls="{{ id }}-content"
{% endif %}
class="btn-outline justify-between font-normal {{ trigger_attrs.class }}"
aria-controls="{{ id }}-listbox"
{% for key, value in trigger_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<div x-html="selectedLabel" class="flex items-center gap-x-2"
></div>
<span class="truncate">{{ default_option.label }}</span>
{% if is_combobox %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down-icon lucide-chevrons-up-down text-muted-foreground opacity-50 shrink-0"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
{% else %}
@@ -57,11 +79,10 @@
{% endif %}
</button>
<div
id="{{ id }}"
data-popover
aria-hidden="true"
x-bind="$content"
{% if id %}id="{{ id }}-content"{% endif %}
{% for key, value in content_attrs.items() %}
{% for key, value in popover_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
@@ -77,38 +98,36 @@
spellcheck="false"
aria-autocomplete="list"
role="combobox"
aria-expanded="true"
aria-controls="{{ id }}-content"
aria-expanded="false"
aria-controls="{{ id }}-listbox"
aria-labelledby="{{ id }}-trigger"
x-model="query"
x-bind="$filter"
>
</header>
{% endif %}
<div
role="listbox"
id="{{ id }}-listbox"
aria-orientation="vertical"
aria-labelledby="{{ id }}-trigger"
{% for key, value in listbox_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if items %}
{{ render_select_items(items, id ~ "-items" if id else "items") }}
{{ render_select_items(items, default_option.value, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</div>
</div>
{% if name is defined %}
<input
type="hidden"
name="{{ name }}"
x-model="selectedValue"
{% for key, value in input_attrs.items() %}
{% if key != 'name' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% endif %}
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{{ (default_option.value if default_option) or '' }}"
{% for key, value in input_attrs.items() %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
</div>
{% endmacro %}
@@ -118,31 +137,36 @@
@param items {array} - The array of items to render.
@param parent_id_prefix {string} [optional] - The prefix for the item id.
#}
{% macro render_select_items(items, parent_id_prefix="items") %}
{% for item in items.items() %}
{% macro render_select_items(items, selected, parent_id_prefix="items") %}
{% for item in items %}
{% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
{% if item.type == "group" %}
{% set group_label_id = item.id if item.id else "group-label-" + item_id %}
<div
role="group"
aria-labelledby="{{ group_label_id }}"
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
{{ render_select_items(item.items, item_id) if item.items }}
{{ render_select_items(item.items, selected, item_id) if item.items }}
</div>
{% elif item.type == "separator" %}
<hr role="separator" />
{% elif item.type == "item" or not item.type %}
<div
id="{{ item_id }}"
role="option"
data-value="{{ item.value }}"
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% if selected == item.value %}aria-selected="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ item.label | safe }}
</div>
+28 -22
View File
@@ -2,17 +2,21 @@
Renders a sidebar component.
@param id {string} [optional] - Unique identifier for the sidebar component.
@param label {string} [optional] [default="Sidebar navigation"] - Label for the sidebar navigation.
@param open {boolean} [optional] [default=True] - Whether the sidebar is open.
@param label {string} [optional] - Label for the sidebar navigation.
@param open {boolean} [optional] - Whether the sidebar is open.
@param side {string} [optional] - Side of the sidebar to display.
@param header {string} [optional] - Header content for the sidebar.
@param footer {string} [optional] - Footer content for the sidebar.
@param menu {array} [optional] - Array of menu items for the sidebar.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param header_attrs {object} [optional] - Additional HTML attributes for the header div.
@param content_attrs {object} [optional] - Additional HTML attributes for the content div.
@param footer_attrs {object} [optional] - Additional HTML attributes for the footer div.
#}
{% macro sidebar(
id=None,
label="Sidebar navigation",
open=True,
open=true,
side=None,
header=None,
footer=None,
@@ -20,21 +24,17 @@
main_attrs={},
header_attrs={},
content_attrs={},
footer_attrs={},
content_wrapper_attrs=None
footer_attrs={}
) %}
<div
<aside
{% if id %}id="{{ id }}"{% endif %}
class="sidebar {{ main_attrs.class }}"
data-uninitialized
data-side="{{ side if side else "left" }}"
aria-hidden="{{ "true" if not open else "false" }}"
{{ "inert" if not open }}
{% for key, value in main_attrs.items() %}
{% if key != "class" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
x-data="sidebar({{ "true" if open else "false" }})"
x-bind="$main"
>
<nav
aria-label="{{ label }}"
@@ -71,7 +71,7 @@
</footer>
{% endif %}
</nav>
</div>
</aside>
{% endmacro %}
{#
@@ -81,7 +81,7 @@
@param parent_id_prefix {string} [optional] - Prefix for generating element IDs.
#}
{% macro render_sidebar_content(items, parent_id_prefix="content") %}
{% for item in items.items() %}
{% for item in items %}
{% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
{% if item.type == "group" %}
@@ -89,15 +89,17 @@
<div
role="group"
{% if item.label %}aria-labelledby="{{ group_label_id }}"{% endif %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{% if item.label %}
<h3 id="{{ group_label_id }}">{{ item.label }}</h3>
{% endif %}
<ul>
{{ render_sidebar_content(item.items, item_id) if item.items }}
{{ render_sidebar_content(item["items"], item_id) if item["items"] }}
</ul>
</div>
{% elif item.type == "separator" %}
@@ -107,16 +109,18 @@
<details
id="submenu-{{ item_id }}"
{{ "open" if item.open }}
{% for key, value in item.attrs.items() %}
{% if key != "open" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{% if key != "open" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% endif %}
>
<summary aria-controls="submenu-{{ item_id }}-content">
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label }}
</summary>
<ul id="submenu-{{ item_id }}-content">
{{ render_sidebar_content(item.items, item_id) if item.items }}
{{ render_sidebar_content(item["items"], item_id) if item["items"] }}
</ul>
</details>
</li>
@@ -125,9 +129,11 @@
<a
href="{{ item.url }}"
{{ 'aria-current="page"' if item.current }}
{% for key, value in item.attrs.items() %}
{% if key != "href" and key != "aria-current" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{% if key != "href" and key != "aria-current" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
<span>{{ item.label }}</span>
+21 -18
View File
@@ -14,17 +14,17 @@
@param default_tab_index {number} [optional] [default=1] - The 1-based index of the tab to be active initially.
#}
{% macro tabs(
id,
id=None,
tabsets=[],
main_attrs=None,
tablist_attrs=None,
main_attrs={},
tablist_attrs={},
default_tab_index=1
)
%}
{% set id = id or ("tabs-" + (range(100000, 999999) | random | string)) %}
<div
class="tabs {{ main_attrs.class }}"
x-data="tabs({{ default_tab_index - 1 }})"
{% if id %}id="{{ id }}"{% endif %}
id="{{ id }}"
{% for key, value in main_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
@@ -32,40 +32,43 @@
<nav
role="tablist"
aria-orientation="horizontal"
x-bind="$tablist"
{% for key, value in tablist_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% for tabset in tabsets.items() %}
{% for tabset in tabsets %}
<button
type="button"
role="tab"
id="{{ tabset.id }}-tab-{{ loop.index }}"
aria-controls="{{ tabset.id }}-panel-{{ loop.index }}"
id="{{ id }}-tab-{{ loop.index }}"
aria-controls="{{ id }}-panel-{{ loop.index }}"
aria-selected="{{ 'true' if loop.index == default_tab_index else 'false' }}"
tabindex="0"
{% for key, value in tabset.tab_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% if tabset.tab_attrs %}
{% for key, value in tabset.tab_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ tabset.tab | safe }}
</button>
{% endfor %}
</nav>
{% for tabset in tabsets.items() %}
{% for tabset in tabsets %}
{% if tabset.panel %}
<div
role="tabpanel"
id="{{ tabset.id }}-panel-{{ loop.index }}"
aria-labelledby="{{ tabset.id }}-tab-{{ loop.index }}"
id="{{ id }}-panel-{{ loop.index }}"
aria-labelledby="{{ id }}-tab-{{ loop.index }}"
tabindex="-1"
aria-selected="{{ 'true' if loop.index == default_tab_index else 'false' }}"
{% if loop.index != default_tab_index %}hidden{% endif %}
{% for key, value in tabset.panel_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% if tabset.panel_attrs %}
{% for key, value in tabset.panel_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ tabset.panel | safe }}
</div>
+48 -137
View File
@@ -5,23 +5,21 @@
@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 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.
#}
{% macro toaster(
id="toaster",
toasts=[],
main_attrs=None,
is_fragment=False
attrs={}
) %}
<div
id="{{ id }}"
class="toaster"
{% for key, value in main_attrs.items() %}
{{ key }}="{{ value }}"
class="toaster {{ attrs.class }}"
{% for key, value in attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% if is_fragment %}hx-swap-oob="beforeend"{% endif %}
>
{% for item in toasts.items() %}
{% for item in toasts %}
{{ toast(
category=item.category,
title=item.title,
@@ -31,79 +29,6 @@
) }}
{% endfor %}
</div>
{% if not is_fragment %}
<template id="toast-template">
<div
class="toast"
role="status"
aria-atomic="true"
x-bind="$toastBindings"
>
<div class="toast-content">
<div class="flex items-center justify-between gap-x-3 p-4 [&>svg]:size-4 [&>svg]:shrink-0 [&>[role=img]]:size-4 [&>[role=img]]:shrink-0 [&>[role=img]>svg]:size-4">
<template x-if="config.icon">
<span aria-hidden="true" role="img" x-html="config.icon"></span>
</template>
<template x-if="!config.icon && config.category === 'success'">
{{ toast_icons.success | safe }}
</template>
<template x-if="!config.icon && config.category === 'error'">
{{ toast_icons.error | safe }}
</template>
<template x-if="!config.icon && config.category === 'info'">
{{ toast_icons.info | safe }}
</template>
<template x-if="!config.icon && config.category === 'warning'">
{{ toast_icons.warning | safe }}
</template>
<section class="flex-1 flex flex-col gap-0.5 items-start">
<template x-if="config.title">
<h2 class="font-medium" x-text="config.title"></h2>
</template>
<template x-if="config.description">
<p class="text-muted-foreground" x-text="config.description"></p>
</template>
</section>
<template x-if="config.action || config.cancel">
<footer class="flex flex-col gap-1 self-start">
<template x-if="config.action?.click">
<button
type="button"
class="btn h-6 text-xs px-2.5 rounded-sm"
@click="executeAction(config.action.click)"
x-text="config.action.label"
></button>
</template>
<template x-if="config.action?.url">
<a
:href="config.action.url"
class="btn h-6 text-xs px-2.5 rounded-sm"
x-text="config.action.label"
></a>
</template>
<template x-if="config.cancel?.click">
<button
type="button"
class="btn-outline h-6 text-xs px-2.5 rounded-sm"
@click="executeAction(config.cancel.click)"
x-text="config.cancel.label"
></button>
</template>
<template x-if="config.cancel?.url">
<a
:href="config.cancel.url"
class="btn-outline h-6 text-xs px-2.5 rounded-sm"
x-text="config.cancel.label"
></a>
</template>
</footer>
</template>
</div>
</div>
</div>
</template>
{% endif %}
{% endmacro %}
{#
@@ -134,65 +59,51 @@
aria-atomic="true"
aria-hidden="false"
{% if category %}data-category="{{ category }}"{% endif %}
x-data="toast({
category: '{{ category }}',
duration: {{ duration or 'null' }}
})"
x-bind="$toastBindings"
>
<div class="toast-content">
<div class="flex items-center justify-between gap-x-3 p-4 [&>svg]:size-4 [&>svg]:shrink-0 [&>[role=img]]:size-4 [&>[role=img]]:shrink-0 [&>[role=img]>svg]:size-4">
{% if category in ["error", "success", "info", "warning"] %}
{{ toast_icons[category] | safe }}
{% if category in ["error", "success", "info", "warning"] %}
{% if category == "success" %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
{% elif category == "error" %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
{% elif category == "info" %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
{% elif category == "warning" %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
{% endif %}
<section class="flex-1 flex flex-col gap-0.5 items-start">
{% if title %}
<h2 class="font-medium">{{ title }}</h2>
{% endif %}
{% if description %}
<p class="text-muted-foreground">{{ description }}</p>
{% endif %}
</section>
{% if action or cancel %}
<footer class="flex flex-col gap-1 self-start">
{% if action %}
{% if action.click %}
<button
type="button"
class="btn h-6 text-xs px-2.5 rounded-sm"
@click="{{ action.click }}"
>{{ action.label }}</button>
{% elif action.url %}
<a
href="{{ action.url }}"
class="btn h-6 text-xs px-2.5 rounded-sm"
>{{ action.label }}</a>
{% endif %}
{% endif %}
<section>
{% if title %}<h2>{{ title }}</h2>{% endif %}
{% if description %}<p>{{ description }}</p>{% endif %}
</section>
{% if action or cancel %}
<footer>
{% if action %}
{% if action.href %}
<a
href="{{ action.href }}"
class="btn-sm"
data-toast-action
>{{ action.label }}</a>
{% else %}
<button
type="button"
class="btn"
data-toast-action
{% if action.onclick %}onclick="{{ action.onclick }}"{% endif %}
>{{ action.label }}</button>
{% endif %}
{% if cancel %}
{% if cancel.click %}
<button
type="button"
class="btn-outline h-6 text-xs px-2.5 rounded-sm"
@click="{{ cancel.click }}"
>{{ cancel.label }}</button>
{% elif cancel.url %}
<a
href="{{ cancel.url }}"
class="btn-outline h-6 text-xs px-2.5 rounded-sm"
>{{ toast.cancel.label }}</a>
{% endif %}
{% endif %}
</footer>
{% endif %}
</div>
{% endif %}
{% if cancel %}
<button
type="button"
class="btn-sm-outline"
data-toast-cancel
{% if cancel.onclick %}onclick="{{ cancel.onclick }}"{% endif %}
>{{ cancel.label }}</button>
{% endif %}
</footer>
{% endif %}
</div>
</div>
{% endmacro %}
{% set toast_icons = {
'success': '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-check-icon lucide-circle-check"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',
'error': '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>',
'info': '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-info-icon lucide-info"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>',
'warning': '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert-icon lucide-triangle-alert"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>'
} %}
{% endmacro %}
-54
View File
@@ -1,54 +0,0 @@
window.basecoat = window.basecoat || {};
window.basecoat.registerDialog = function(Alpine) {
if (Alpine.components && Alpine.components.dialog) return;
Alpine.data('dialog', (initialOpen = false, initialCloseOnOverlayClick = true) => ({
id: null,
open: false,
closeOnOverlayClick: true,
init() {
this.id = this.$el.id;
if (!this.id) {
console.warn('Dialog component initialized without an `id`. This may cause issues with event targeting and accessibility.');
}
this.open = initialOpen;
this.closeOnOverlayClick = initialCloseOnOverlayClick;
},
show() {
if (!this.open) {
this.open = true;
this.$nextTick(() => {
this.$el.dispatchEvent(new CustomEvent('dialog:opened', { bubbles: true, detail: { id: this.id } }));
setTimeout(() => {
const focusTarget = this.$refs.focusOnOpen || this.$el.querySelector('[role="dialog"]');
if (focusTarget) focusTarget.focus();
}, 50);
});
}
},
hide() {
if (this.open) {
this.open = false;
this.$nextTick(() => {
this.$el.dispatchEvent(new CustomEvent('dialog:closed', { bubbles: true, detail: { id: this.id } }));
});
}
},
$main: {
'@dialog:open.window'(e) { if (e.detail && e.detail.id === this.id) this.show() },
'@dialog:close.window'(e) { if (e.detail && e.detail.id === this.id) this.hide() },
'@keydown.escape.window'() { this.open && this.hide() },
},
$trigger: {
'@click'() { this.show() },
':aria-expanded'() { return this.open }
},
$content: {
':inert'() { return !this.open },
'@click.self'() { if (this.closeOnOverlayClick) this.hide() },
'x-cloak': ''
}
}));
};
+154 -88
View File
@@ -1,91 +1,157 @@
window.basecoat = window.basecoat || {};
window.basecoat.registerDropdownMenu = function(Alpine) {
if (Alpine.components && Alpine.components.dropdownMenu) return;
(() => {
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 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);
return;
}
Alpine.data('dropdownMenu', () => ({
open: false,
focusedIndex: null,
menuItems: [],
let menuItems = [];
let activeIndex = -1;
init() {
this.$nextTick(() => {
this.menuItems = Array.from(this.$el.querySelectorAll('[role=menuitem]:not([disabled]),[role=menuitemcheckbox]:not([disabled]),[role=menuitemradio]:not([disabled])'));
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 = () => {
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 =>
!item.hasAttribute('disabled') &&
item.getAttribute('aria-disabled') !== 'true'
);
if (menuItems.length > 0) {
setActiveItem(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');
trigger.setAttribute('aria-activedescendant', activeItem.id);
} else {
trigger.removeAttribute('aria-activedescendant');
}
};
trigger.addEventListener('click', () => {
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
closePopover();
} else {
openPopover();
}
});
dropdownMenuComponent.addEventListener('keydown', (event) => {
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
if (event.key === 'Escape') {
if (isExpanded) closePopover();
return;
}
if (!isExpanded) {
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) {
event.preventDefault();
openPopover();
}
return;
}
if (menuItems.length === 0) return;
let nextIndex = activeIndex;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
nextIndex = Math.min(activeIndex + 1, menuItems.length - 1);
break;
case 'ArrowUp':
event.preventDefault();
nextIndex = 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;
}
if (nextIndex !== activeIndex) {
setActiveItem(nextIndex);
}
});
menu.addEventListener('click', (event) => {
if (event.target.closest('[role^="menuitem"]')) {
closePopover();
}
});
document.addEventListener('click', (event) => {
if (!dropdownMenuComponent.contains(event.target)) {
closePopover();
}
});
document.addEventListener('basecoat:popover', (event) => {
if (event.detail.source !== dropdownMenuComponent) {
closePopover(false);
}
});
dropdownMenuComponent.dataset.dropdownMenuInitialized = true;
};
document.querySelectorAll('.dropdown-menu:not([data-dropdown-menu-initialized])').forEach(initDropdownMenu);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.matches('.dropdown-menu:not([data-dropdown-menu-initialized])')) {
initDropdownMenu(node);
}
node.querySelectorAll('.dropdown-menu:not([data-dropdown-menu-initialized])').forEach(initDropdownMenu);
});
},
focusMenuitem() {
if (this.menuItems.length === 0) return;
if (this.focusedIndex >= this.menuItems.length) {
this.focusedIndex = this.menuItems.length - 1;
} else if (this.focusedIndex < 0 || this.focusedIndex === null) {
this.focusedIndex = 0;
}
this.menuItems.forEach(item => item.blur());
this.menuItems[this.focusedIndex].focus();
},
moveMenuitemFocus(delta) {
if (this.menuItems.length === 0) return;
let wasOpen = this.open;
if (!this.open) {
this.open = true;
this.focusedIndex = 0;
} else {
this.focusedIndex = this.focusedIndex === null
? 0
: this.focusedIndex + delta;
}
if (wasOpen) {
this.focusMenuitem();
} else {
setTimeout(() => this.focusMenuitem(), 50);
}
},
handleMenuitemClick(event) {
const menuitem = event.target.closest('[role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]');
if (menuitem && menuitem.getAttribute('aria-disabled') !== 'true' && !menuitem.disabled) {
this.$nextTick(() => {
this.$refs.trigger.focus();
this.open = false;
});
}
},
handleMenuitemMousemove(event) {
const menuitem = event.target.closest('[role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]');
if (menuitem && menuitem.getAttribute('aria-disabled') !== 'true' && !menuitem.disabled) {
this.focusedIndex = this.menuItems.indexOf(menuitem);
this.focusMenuitem();
}
},
$trigger: {
'@click'() { this.open = !this.open },
'@keydown.escape.prevent'() {
this.open = false;
this.$refs.trigger.focus();
},
'@keydown.down.prevent'() { this.moveMenuitemFocus(+1); },
'@keydown.up.prevent'() { this.moveMenuitemFocus(-1); },
'@keydown.home.prevent'() { this.focusMenuitem(0) },
'@keydown.end.prevent'() { this.focusMenuitem(this.menuItems.length - 1) },
'@keydown.enter.prevent'() { this.open = !this.open },
':aria-expanded'() { return this.open },
'x-ref': 'trigger'
},
$content: {
'@click'(e) { this.handleMenuitemClick(e) },
'@keydown.escape.prevent'() {
this.open = false;
this.$refs.trigger.focus();
},
'@keydown.down.prevent'() { this.moveMenuitemFocus(+1); },
'@keydown.up.prevent'() { this.moveMenuitemFocus(-1); },
'@keydown.home.prevent'() { this.focusMenuitem(0) },
'@keydown.end.prevent'() { this.focusMenuitem(this.menuItems.length - 1) },
'@mouseover'(e) { this.handleMenuitemMousemove(e) },
':aria-hidden'() { return !this.open },
'x-cloak': ''
},
}));
};
});
});
observer.observe(document.body, { childList: true, subtree: true });
})();
+82 -24
View File
@@ -1,26 +1,84 @@
window.basecoat = window.basecoat || {};
window.basecoat.registerPopover = function(Alpine) {
if (Alpine.components && Alpine.components.popover) return;
(() => {
const initPopover = (popoverComponent) => {
const trigger = popoverComponent.querySelector(':scope > button');
const content = popoverComponent.querySelector(':scope > [data-popover]');
Alpine.data('popover', () => ({
open: false,
if (!trigger || !content) {
const missing = [];
if (!trigger) missing.push('trigger');
if (!content) missing.push('content');
console.error(`Popover initialisation failed. Missing element(s): ${missing.join(', ')}`, popoverComponent);
return;
}
$trigger: {
'@click'() { this.open = !this.open },
'@keydown.escape.prevent'() {
this.open = false;
this.$refs.trigger.focus();
},
':aria-expanded'() { return this.open },
'x-ref': 'trigger'
},
$content: {
'@keydown.escape.prevent'() {
this.open = false;
this.$refs.trigger.focus();
},
':aria-hidden'() { return !this.open },
'x-cloak': ''
},
}));
};
const closePopover = (focusOnTrigger = true) => {
if (trigger.getAttribute('aria-expanded') === 'false') return;
trigger.setAttribute('aria-expanded', 'false');
content.setAttribute('aria-hidden', 'true');
if (focusOnTrigger) {
trigger.focus();
}
};
const openPopover = () => {
document.dispatchEvent(new CustomEvent('basecoat:popover', {
detail: { source: popoverComponent }
}));
const elementToFocus = content.querySelector('[autofocus]');
if (elementToFocus) {
content.addEventListener('transitionend', () => {
elementToFocus.focus();
}, { once: true });
}
trigger.setAttribute('aria-expanded', 'true');
content.setAttribute('aria-hidden', 'false');
};
trigger.addEventListener('click', () => {
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
closePopover();
} else {
openPopover();
}
});
popoverComponent.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closePopover();
}
});
document.addEventListener('click', (event) => {
if (!popoverComponent.contains(event.target)) {
closePopover();
}
});
document.addEventListener('basecoat:popover', (event) => {
if (event.detail.source !== popoverComponent) {
closePopover(false);
}
});
popoverComponent.dataset.popoverInitialized = true;
};
document.querySelectorAll('.popover:not([data-popover-initialized])').forEach(initPopover);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.matches('.popover:not([data-popover-initialized])')) {
initPopover(node);
}
node.querySelectorAll('.popover:not([data-popover-initialized])').forEach(initPopover);
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
})();
+249 -136
View File
@@ -1,150 +1,263 @@
window.basecoat = window.basecoat || {};
window.basecoat.registerSelect = function(Alpine) {
if (Alpine.components && Alpine.components.select) return;
Alpine.data('select', (name = null, initialValue = null) => ({
open: false,
name: null,
options: [],
disabledOptions: [],
focusedIndex: null,
selectedLabel: null,
selectedValue: null,
query: '',
(() => {
const initSelect = (selectComponent) => {
const trigger = selectComponent.querySelector(':scope > button');
const selectedValue = trigger.querySelector(':scope > span');
const popover = selectComponent.querySelector(':scope > [data-popover]');
const listbox = popover.querySelector('[role="listbox"]');
const input = selectComponent.querySelector(':scope > input[type="hidden"]');
const filter = selectComponent.querySelector('header input[type="text"]');
if (!trigger || !popover || !listbox || !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);
return;
}
init() {
this.$nextTick(() => {
if (name) this.name = name;
this.options = Array.from(this.$el.querySelectorAll('[role=option]:not([aria-disabled])'));
this.disabledOptions = Array.from(this.$el.querySelectorAll('[role=option][aria-disabled=true]'));
if (this.options.length === 0) return;
if (initialValue) {
const option = this.options.find(opt => opt.getAttribute('data-value') === initialValue);
this.selectOption(option, false);
this.focusedIndex = this.options.indexOf(option);
const options = Array.from(listbox.querySelectorAll('[role="option"]'));
let visibleOptions = [...options];
let activeIndex = -1;
const hasTransition = () => {
const style = getComputedStyle(popover);
return parseFloat(style.transitionDuration) > 0 || parseFloat(style.transitionDelay) > 0;
};
const updateValue = (option) => {
if (option) {
selectedValue.innerHTML = option.dataset.label || option.innerHTML;
input.value = option.dataset.value;
listbox.querySelector('[role="option"][aria-selected="true"]')?.removeAttribute('aria-selected');
option.setAttribute('aria-selected', 'true');
}
};
const closePopover = (focusOnTrigger = true) => {
if (popover.getAttribute('aria-hidden') === 'true') return;
if (filter) {
const resetFilter = () => {
filter.value = '';
visibleOptions = [...options];
options.forEach(opt => opt.setAttribute('aria-hidden', 'false'));
};
if (hasTransition()) {
popover.addEventListener('transitionend', resetFilter, { once: true });
} else {
this.selectOption(this.options[0], false);
resetFilter();
}
});
},
focusOption() {
if (this.options.length === 0) return;
if (this.focusedIndex >= this.options.length) {
this.focusedIndex = this.options.length - 1;
} else if (this.focusedIndex < 0 || this.focusedIndex === null) {
this.focusedIndex = 0;
}
this.options.forEach(opt => opt.removeAttribute('data-focus'));
const option = this.options[this.focusedIndex];
option.setAttribute('data-focus', '');
option.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
},
focusOnSelectedOption() {
if (this.options.length === 0 || this.selectedValue === null) return;
const option = this.options.find(opt => opt.getAttribute('data-value') === this.selectedValue);
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;
}
const selectOption = (option) => {
if (!option) return;
this.focusedIndex = this.options.indexOf(option);
this.focusOption();
},
moveOptionFocus(delta) {
if (this.options.length === 0) return;
if (option.dataset.value != null) {
updateValue(option);
}
closePopover();
const event = new CustomEvent('change', {
detail: { value: option.dataset.value },
bubbles: true
});
selectComponent.dispatchEvent(event);
};
if (!this.open) {
this.open = true;
this.focusOnSelectedOption();
} else {
this.focusedIndex = this.focusedIndex === null
? 0
: this.focusedIndex + delta;
}
this.focusOption();
},
handleOptionClick(event) {
const option = event.target.closest('[role=option]');
if (option && option.getAttribute('aria-disabled') !== 'true') {
this.selectOption(option);
this.open = false;
this.$nextTick(() => this.$refs.trigger.focus());
}
},
handleOptionMousemove(event) {
const option = event.target.closest('[role=option]');
if (option && option.getAttribute('aria-disabled') !== 'true') {
this.focusedIndex = this.options.indexOf(option);
this.focusOption();
}
},
handleOptionEnter(event) {
this.selectOption(this.options[this.focusedIndex]);
this.open = !this.open;
},
selectOption(option, dispatch = true) {
if (this.options.length === 0 || !option || option.disabled || option.getAttribute('data-value') == null) {
if (filter) {
const filterOptions = () => {
const searchTerm = filter.value.trim().toLowerCase();
if (activeIndex > -1) {
options[activeIndex].classList.remove('active');
trigger.removeAttribute('aria-activedescendant');
activeIndex = -1;
}
visibleOptions = [];
options.forEach(option => {
const optionText = (option.dataset.label || option.textContent).trim().toLowerCase();
const matches = optionText.includes(searchTerm);
option.setAttribute('aria-hidden', String(!matches));
if (matches) {
visibleOptions.push(option);
}
});
};
filter.addEventListener('input', filterOptions);
}
let initialOption = options.find(opt => opt.dataset.value === input.value);
if (!initialOption && options.length > 0) initialOption = options[0];
updateValue(initialOption);
const handleKeyNavigation = (event) => {
const isPopoverOpen = popover.getAttribute('aria-hidden') === 'false';
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End', 'Escape'].includes(event.key)) {
return;
}
this.options.forEach(opt => {
opt.setAttribute('aria-selected', opt === option);
});
this.selectedLabel = option.innerHTML;
this.selectedValue = option.getAttribute('data-value');
if (dispatch) {
this.$dispatch('select:change', {
value: this.selectedValue,
label: this.selectedLabel
});
this.$dispatch('change');
if (!isPopoverOpen) {
if (event.key !== 'Enter' && event.key !== 'Escape') {
event.preventDefault();
trigger.click();
}
return;
}
},
filterOptions(query) {
if (query.length > 0) {
this.disabledOptions.forEach(opt => { opt.setAttribute('aria-hidden', 'true'); });
event.preventDefault();
if (event.key === 'Escape') {
closePopover();
return;
}
if (event.key === 'Enter') {
if (activeIndex > -1) {
selectOption(options[activeIndex]);
}
return;
}
if (visibleOptions.length === 0) return;
const currentVisibleIndex = activeIndex > -1 ? visibleOptions.indexOf(options[activeIndex]) : -1;
let nextVisibleIndex = currentVisibleIndex;
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;
}
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);
}
newActiveOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
};
trigger.addEventListener('keydown', handleKeyNavigation);
if (filter) {
filter.addEventListener('keydown', handleKeyNavigation);
}
const openPopover = () => {
document.dispatchEvent(new CustomEvent('basecoat:popover', {
detail: { source: selectComponent }
}));
if (filter) {
if (hasTransition()) {
popover.addEventListener('transitionend', () => {
filter.focus();
}, { once: true });
} else {
filter.focus();
}
}
popover.setAttribute('aria-hidden', 'false');
trigger.setAttribute('aria-expanded', 'true');
const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
if (selectedOption) {
if (activeIndex > -1) {
options[activeIndex]?.classList.remove('active');
}
activeIndex = options.indexOf(selectedOption);
selectedOption.classList.add('active');
if (selectedOption.id) {
trigger.setAttribute('aria-activedescendant', selectedOption.id);
}
selectedOption.scrollIntoView({ block: 'nearest' });
}
};
trigger.addEventListener('click', () => {
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
closePopover();
} else {
this.disabledOptions.forEach(opt => { opt.removeAttribute('aria-hidden'); });
openPopover();
}
this.options.forEach(opt => {
opt.removeAttribute('aria-hidden');
if (opt.getAttribute('data-value') != null && !opt.innerHTML.toLowerCase().includes(query.toLowerCase())) {
opt.setAttribute('aria-hidden', 'true');
});
listbox.addEventListener('click', (event) => {
const clickedOption = event.target.closest('[role="option"]');
if (clickedOption) {
selectOption(clickedOption);
}
});
document.addEventListener('click', (event) => {
if (!selectComponent.contains(event.target)) {
closePopover(false);
}
});
document.addEventListener('basecoat:popover', (event) => {
if (event.detail.source !== selectComponent) {
closePopover(false);
}
});
popover.setAttribute('aria-hidden', 'true');
selectComponent.dataset.selectInitialized = true;
};
document.querySelectorAll('div.select:not([data-select-initialized])').forEach(initSelect);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches('div.select:not([data-select-initialized])')) {
initSelect(node);
}
node.querySelectorAll('div.select:not([data-select-initialized])').forEach(initSelect);
}
});
},
$trigger: {
'@click'() { this.open = !this.open; this.focusOnSelectedOption(); },
'@keydown.escape.prevent'() {
this.open = false;
this.$refs.trigger.focus();
},
'@keydown.down.prevent'() { this.moveOptionFocus(+1); },
'@keydown.up.prevent'() { this.moveOptionFocus(-1); },
'@keydown.home.prevent'() { this.focusOption(0) },
'@keydown.end.prevent'() { this.focusOption(this.options.length - 1) },
'@keydown.enter.prevent'() {
if (this.open) this.handleOptionEnter();
else this.open = true;
},
':aria-expanded'() { return this.open },
'x-ref': 'trigger'
},
$content: {
'@click'(e) { this.handleOptionClick(e) },
'@mouseover'(e) { this.handleOptionMousemove(e) },
':aria-hidden'() { return !this.open },
'x-cloak': ''
},
$filter: {
'@input'(e) { this.filterOptions(e.target.value) },
'@keydown.escape.prevent'() {
this.open = false;
this.$refs.trigger.focus();
},
'@keydown.down.prevent'() { this.moveOptionFocus(+1); },
'@keydown.up.prevent'() { this.moveOptionFocus(-1); },
'@keydown.home.prevent'() { this.focusOption(0) },
'@keydown.end.prevent'() { this.focusOption(this.options.length - 1) },
'@keydown.enter.prevent'() { this.handleOptionEnter() },
}
}));
};
});
});
observer.observe(document.body, { childList: true, subtree: true });
})();
+112 -21
View File
@@ -1,25 +1,116 @@
window.basecoat = window.basecoat || {};
window.basecoat.registerSidebar= function(Alpine) {
if (Alpine.components && Alpine.components.sidebar) return;
(() => {
// Monkey patching the history API to detect client-side navigation
if (!window.history.__basecoatPatched) {
const originalPushState = window.history.pushState;
window.history.pushState = function(...args) {
originalPushState.apply(this, args);
window.dispatchEvent(new Event('basecoat:locationchange'));
};
const originalReplaceState = window.history.replaceState;
window.history.replaceState = function(...args) {
originalReplaceState.apply(this, args);
window.dispatchEvent(new Event('basecoat:locationchange'));
};
Alpine.data('sidebar', (initialOpen = true, initialMobileOpen = false) => ({
open: window.innerWidth >= 768
? initialOpen
: initialMobileOpen,
window.history.__basecoatPatched = true;
}
init() {
this.$nextTick(() => {
this.$el.removeAttribute('data-uninitialized');
const initSidebar = (sidebarComponent) => {
const initialOpen = sidebarComponent.dataset.initialOpen !== 'false';
const initialMobileOpen = sidebarComponent.dataset.initialMobileOpen === 'true';
const breakpoint = parseInt(sidebarComponent.dataset.breakpoint) || 768;
let open = breakpoint > 0
? (window.innerWidth >= breakpoint ? initialOpen : initialMobileOpen)
: initialOpen;
const updateCurrentPageLinks = () => {
const currentPath = window.location.pathname.replace(/\/$/, '');
sidebarComponent.querySelectorAll('a').forEach(link => {
if (link.hasAttribute('data-ignore-current')) return;
const linkPath = new URL(link.href).pathname.replace(/\/$/, '');
if (linkPath === currentPath) {
link.setAttribute('aria-current', 'page');
} else {
link.removeAttribute('aria-current');
}
});
},
};
const updateState = () => {
sidebarComponent.setAttribute('aria-hidden', !open);
if (open) {
sidebarComponent.removeAttribute('inert');
} else {
sidebarComponent.setAttribute('inert', '');
}
};
$main: {
'@sidebar:open.window'(e) { this.open = true },
'@sidebar:close.window'(e) { this.open = false },
'@sidebar:toggle.window'(e) { this.open = !this.open },
'@click'(e) { if (e.target === this.$el) this.open = false },
':aria-hidden'() { return !this.open },
':inert'() { return !this.open }
},
}));
};
const setState = (state) => {
open = state;
updateState();
};
const sidebarId = sidebarComponent.id;
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 (document.activeElement) document.activeElement.blur();
setState(false);
return;
}
if (target === sidebarComponent || (nav && !nav.contains(target))) {
if (document.activeElement) document.activeElement.blur();
setState(false);
}
});
window.addEventListener('popstate', updateCurrentPageLinks);
window.addEventListener('basecoat:locationchange', updateCurrentPageLinks);
updateState();
updateCurrentPageLinks();
sidebarComponent.dataset.sidebarInitialized = true;
};
document.querySelectorAll('.sidebar:not([data-sidebar-initialized])').forEach(initSidebar);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches('.sidebar:not([data-sidebar-initialized])')) {
initSidebar(node);
}
node.querySelectorAll('.sidebar:not([data-sidebar-initialized])').forEach(initSidebar);
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
})();
+71 -60
View File
@@ -1,64 +1,75 @@
window.basecoat = window.basecoat || {};
window.basecoat.registerTabs = function(Alpine) {
if (Alpine.components && Alpine.components.tabs) return;
Alpine.data('tabs', (initialTabIndex = 0) => ({
activeTabIndex: initialTabIndex,
tabs: [],
panels: [],
(() => {
const initTabs = (tabsComponent) => {
const tablist = tabsComponent.querySelector('[role="tablist"]');
if (!tablist) return;
init() {
this.$nextTick(() => {
this.tabs = Array.from(this.$el.querySelectorAll(':scope > [role=tablist] [role=tab]:not([disabled])'));
this.panels = Array.from(this.$el.querySelectorAll(':scope > [role=tabpanel]'));
if (this.tabs.length > 0) {
this.selectTab(this.tabs[initialTabIndex], false);
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
const panels = tabs.map(tab => document.getElementById(tab.getAttribute('aria-controls'))).filter(Boolean);
const selectTab = (tabToSelect) => {
tabs.forEach((tab, index) => {
tab.setAttribute('aria-selected', 'false');
tab.setAttribute('tabindex', '-1');
if (panels[index]) panels[index].hidden = true;
});
tabToSelect.setAttribute('aria-selected', 'true');
tabToSelect.setAttribute('tabindex', '0');
const activePanel = document.getElementById(tabToSelect.getAttribute('aria-controls'));
if (activePanel) activePanel.hidden = false;
};
tablist.addEventListener('click', (event) => {
const clickedTab = event.target.closest('[role="tab"]');
if (clickedTab) selectTab(clickedTab);
});
tablist.addEventListener('keydown', (event) => {
const currentTab = event.target;
if (!tabs.includes(currentTab)) return;
let nextTab;
const currentIndex = tabs.indexOf(currentTab);
switch (event.key) {
case 'ArrowRight':
nextTab = tabs[(currentIndex + 1) % tabs.length];
break;
case 'ArrowLeft':
nextTab = tabs[(currentIndex - 1 + tabs.length) % tabs.length];
break;
case 'Home':
nextTab = tabs[0];
break;
case 'End':
nextTab = tabs[tabs.length - 1];
break;
default:
return;
}
event.preventDefault();
selectTab(nextTab);
nextTab.focus();
});
tabsComponent.dataset.tabsInitialized = true;
};
document.querySelectorAll('.tabs:not([data-tabs-initialized])').forEach(initTabs);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches('.tabs:not([data-tabs-initialized])')) {
initTabs(node);
}
node.querySelectorAll('.tabs:not([data-tabs-initialized])').forEach(initTabs);
}
});
},
nextTab() {
if (this.tabs.length === 0) return;
let newIndex = (this.activeTabIndex + 1) % this.tabs.length;
this.selectTab(this.tabs[newIndex]);
},
prevTab() {
if (this.tabs.length === 0) return;
let newIndex = (this.activeTabIndex - 1 + this.tabs.length) % this.tabs.length;
this.selectTab(this.tabs[newIndex]);
},
selectTab(tab, focus = true) {
if (!tab || this.tabs.length === 0) return;
});
});
this.tabs.forEach((t, index) => {
const isSelected = t === tab;
t.setAttribute('aria-selected', isSelected);
t.setAttribute('tabindex', isSelected ? '0' : '-1');
if (isSelected) {
this.activeTabIndex = index;
this.activeTab = t;
if (focus) {
t.focus();
}
}
});
const panelId = tab.getAttribute('aria-controls');
if (!panelId) return;
this.panels.forEach(panel => {
panel.hidden = (panel.getAttribute('id') !== panelId);
});
},
$tablist: {
['@click'](event) {
const clickedTab = event.target.closest('[role=tab]');
if (clickedTab) {
this.selectTab(clickedTab);
}
},
['@keydown.arrow-right.prevent']() { this.nextTab() },
['@keydown.arrow-left.prevent']() { this.prevTab() },
},
}));
};
observer.observe(document.body, { childList: true, subtree: true });
})();
+189 -68
View File
@@ -1,75 +1,196 @@
window.basecoat = window.basecoat || {};
window.basecoat.registerToast = function(Alpine) {
if (Alpine.components && Alpine.components.toast) return;
(() => {
let toaster;
const toasts = new WeakMap();
let isPaused = false;
const ICONS = {
success: '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',
error: '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>',
info: '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>',
warning: '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>'
};
Alpine.store('toaster', { isPaused: false });
Alpine.data('toast', (config={}) => ({
config: config,
open: false,
timeoutDuration: null,
timeoutId: null,
function initToaster(toasterElement) {
if (toasterElement.dataset.toasterInitialized) return;
toaster = toasterElement;
init() {
if (config.duration !== -1) {
this.timeoutDuration = config.duration || (config.category === 'error' ? 5000 : 3000);
this.timeoutId = setTimeout(() => { this.close() }, this.timeoutDuration);
toaster.addEventListener('mouseenter', pauseAllTimeouts);
toaster.addEventListener('mouseleave', resumeAllTimeouts);
toaster.addEventListener('click', (event) => {
const actionLink = event.target.closest('.toast footer a');
const actionButton = event.target.closest('.toast footer button');
if (actionLink || actionButton) {
closeToast(event.target.closest('.toast'));
}
this.open = true;
this.$watch('$store.toaster.isPaused', (isPaused) => {
if (!this.open) return;
if (isPaused) {
this.pauseTimeout();
});
toaster.querySelectorAll('.toast:not([data-toast-initialized])').forEach(initToast);
toaster.dataset.toasterInitialized = 'true';
}
function initToast(element) {
if (element.dataset.toastInitialized) return;
const duration = parseInt(element.dataset.duration);
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;
} else {
state.startTime = Date.now();
state.timeoutId = setTimeout(() => closeToast(element), timeoutDuration);
}
}
toasts.set(element, state);
element.dataset.toastInitialized = 'true';
}
function pauseAllTimeouts() {
if (isPaused) 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);
state.timeoutId = null;
state.remainingTime -= Date.now() - state.startTime;
}
});
}
function resumeAllTimeouts() {
if (!isPaused) return;
isPaused = false;
toaster.querySelectorAll('.toast:not([aria-hidden="true"])').forEach(element => {
if (!toasts.has(element)) return;
const state = toasts.get(element);
if (state.remainingTime !== -1 && !state.timeoutId) {
if (state.remainingTime > 0) {
state.startTime = Date.now();
state.timeoutId = setTimeout(() => closeToast(element), state.remainingTime);
} else {
this.resumeTimeout();
closeToast(element);
}
}
});
}
function closeToast(element) {
if (!toasts.has(element)) return;
const state = toasts.get(element);
clearTimeout(state.timeoutId);
toasts.delete(element);
if (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',
title,
description,
action,
cancel,
duration,
icon,
} = config;
const iconHtml = icon || (category && ICONS[category]) || '';
const titleHtml = title ? `<h2>${title}</h2>` : '';
const descriptionHtml = description ? `<p>${description}</p>` : '';
const actionHtml = action?.href
? `<a href="${action.href}" class="btn" data-toast-action>${action.label}</a>`
: action?.onclick
? `<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>`
: '';
const footerHtml = actionHtml || cancelHtml ? `<footer>${actionHtml}${cancelHtml}</footer>` : '';
const html = `
<div
class="toast"
role="${category === 'error' ? 'alert' : 'status'}"
aria-atomic="true"
${category ? `data-category="${category}"` : ''}
${duration !== undefined ? `data-duration="${duration}"` : ''}
>
<div class="toast-content">
${iconHtml}
<section>
${titleHtml}
${descriptionHtml}
</section>
${footerHtml}
</div>
</div>
</div>
`;
const template = document.createElement('template');
template.innerHTML = html.trim();
return template.content.firstChild;
}
const initialToaster = document.getElementById('toaster');
if (initialToaster) initToaster(initialToaster);
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);
});
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.matches('#toaster')) {
initToaster(node);
}
if (toaster && node.matches('.toast:not([data-toast-initialized])')) {
initToast(node);
}
});
},
pauseTimeout() {
clearTimeout(this.timeoutId);
this.timeoutId = null;
},
resumeTimeout(index) {
if (this.open && this.timeoutId === null) {
this.timeoutId = setTimeout(() => { this.close() }, this.timeoutDuration);
}
},
close() {
this.pauseTimeout();
this.open = false;
this.$el.blur();
},
executeAction(actionString) {
if (actionString) {
Alpine.evaluate(this.$el, actionString);
}
},
$toastBindings: {
['@mouseenter']() { this.$store.toaster.isPaused = true },
['@mouseleave']() { this.$store.toaster.isPaused = false },
['@keydown.escape.prevent']() { this.close() },
[':aria-hidden']() { return !this.open }
},
}));
Alpine.magic('toast', (el) => (config, toasterId='toaster') => {
const toaster = document.getElementById(toasterId);
const template = document.getElementById('toast-template');
if (!toaster) {
console.error(`Toaster container with id #${toasterId} not found.`);
return;
}
if (!template) {
console.error('Toast template with id #toast-template not found.');
return;
}
const clone = template.content.firstElementChild.cloneNode(true);
clone.setAttribute('x-data', `toast(${JSON.stringify(config)})`);
clone.removeAttribute('id');
toaster.appendChild(clone);
});
});
};
observer.observe(document.body, { childList: true, subtree: true });
})();
+65 -81
View File
@@ -8,103 +8,87 @@
@param footer {string} [optional] - HTML content for the dialog footer.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
@param content_attrs {object} [optional] - Additional HTML attributes for the dialog content article.
@param content_header_attrs {object} [optional] - Additional HTML attributes for the dialog header.
@param content_body_attrs {object} [optional] - Additional HTML attributes for the dialog body section.
@param content_footer_attrs {object} [optional] - Additional HTML attributes for the dialog footer.
@param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog content article.
@param header_attrs {object} [optional] - Additional HTML attributes for the dialog header.
@param body_attrs {object} [optional] - Additional HTML attributes for the dialog body section.
@param footer_attrs {object} [optional] - Additional HTML attributes for the dialog footer.
@param open {boolean} [optional] [default=false] - Whether the dialog should be open initially.
@param close_button {boolean} [optional] [default=true] - Whether to include a close button.
@param close_on_overlay_click {boolean} [optional] [default=true] - Whether clicking the overlay closes the dialog.
#}
{% macro dialog(
id,
id=None,
trigger=None,
title=None,
description=None,
footer=None,
main_attrs={},
dialog_attrs={},
trigger_attrs={},
content_attrs={},
content_header_attrs={},
content_body_attrs={},
content_footer_attrs={},
header_attrs={},
body_attrs={},
footer_attrs={},
open=false,
close_button=true,
close_on_overlay_click=true
) %}
<div
id="{{ id }}"
x-data="dialog({{ 'true' if open else 'false' }}, {{ 'true' if close_on_overlay_click else 'false' }})"
x-bind="$main"
class="dialog"
{% for key, value in main_attrs %}
{% set id = id or ("dialog-" + (range(100000, 999999) | random | string)) %}
{% if trigger %}
<button
type="button"
onclick="document.getElementById('{{ id }}').showModal()"
{% for key, value in trigger_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if trigger %}
<button
type="button"
aria-expanded="false"
aria-controls="{{ id }}-dialog"
x-bind="$trigger"
{% for key, value in trigger_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger }}
</button>
{% endif %}
<div
role="dialog"
id="{{ id }}-dialog"
tabindex="-1"
aria-modal="true"
aria-labelledby="{{ id }}-title"
inert
x-bind="$content"
>
<article
{% for key, value in content_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if title or description %}
<header
{% for key, value in content_header_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
<h2 id="{{ id }}-title">{{ title | safe }}</h2>
<p>{{ description | safe }}</p>
</header>
{% endif %}
{% if caller %}
<section
{% for key, value in content_body_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ caller() }}
</section>
{% endif %}
{% if footer %}
<footer
{% for key, value in content_footer_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ footer | safe }}
</footer>
{% endif %}
{% if close_button %}
<button
@click="hide()"
aria-label="Close dialog"
>
{{ trigger }}
</button>
{% endif %}
<dialog
id="{{ id }}"
class="dialog"
aria-labelledby="{{ id }}-title"
{% if description %}aria-describedby="{{ id }}-description"{% endif %}
{% if close_on_overlay_click %}onclick="this.close()"{% endif %}
{% for key, value in dialog_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
<article {% if close_on_overlay_click %}onclick="event.stopPropagation()"{% endif %}>
{% if title or description %}
<header
{% for key, value in header_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
<h2 id="{{ id }}-title">{{ title | safe }}</h2>
{% if description %}<p id="{{ id }}-description">{{ description | safe }}</p>{% endif %}
</header>
{% endif %}
{% if caller %}
<section
{% for key, value in body_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ caller() }}
</section>
{% endif %}
{% if footer %}
<footer
{% for key, value in footer_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ footer | safe }}
</footer>
{% endif %}
{% if close_button %}
<form method="dialog">
<button aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
{% endif %}
</article>
</div>
</div>
</button>
</form>
{% endif %}
</article>
</dialog>
{% endmacro %}
+44 -36
View File
@@ -3,61 +3,62 @@
@param id {string} [optional] - Unique identifier for the dropdown component.
@param trigger {string} [optional] - HTML content for the button that triggers the dropdown.
@param menu {array} [optional] - Array of menu items for the dropdown.
@param items {array} [optional] - Array of menu items for the dropdown.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
@param content_attrs {object} [optional] - Additional HTML attributes for the dropdown content div.
@param popover_attrs {object} [optional] - Additional HTML attributes for the dropdown content div.
#}
{% macro dropdown_menu(
trigger,
id=None,
trigger=None,
menu=None,
items=None,
main_attrs={},
trigger_attrs={},
content_attrs={}
popover_attrs={},
menu_attrs={}
) %}
{% set id = id or ("dropdown-menu-" + (range(100000, 999999) | random | string)) %}
<div
class="popover {{ main_attrs.class }}"
x-data="dropdownMenu"
@click.away="open = false"
{% if id %}id="{{ id }}"{% endif %}
class="dropdown-menu {{ main_attrs.class }}"
{% for key, value in main_attrs %}
{% if key != "class" %}{{ key }}="{{ value }}"{% endif %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% if trigger %}
<button
type="button"
aria-haspopup="menu"
aria-expanded="false"
x-bind="$trigger"
{% if id %}
id="{{ id }}-trigger"
aria-haspopup="menu"
aria-controls="{{ id }}-menu"
{% endif %}
aria-expanded="false"
{% for key, value in trigger_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger | safe }}
</button>
{% endif %}
<div
id="{{ id }}"
data-popover
aria-hidden="true"
x-bind="$content"
{% if id %}id="{{ id }}-menu"{% endif %}
{% for key, value in content_attrs %}
{% for key, value in popover_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
<nav role="menu">
{% if menu %}
{{ render_dropdown_items(menu, id ~ "-items" if id else "items") }}
<div
role="menu"
id="{{ id }}-menu"
aria-labelledby="{{ id }}-trigger"
{% for key, value in menu_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if items %}
{{ render_dropdown_items(items, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</nav>
</div>
</div>
</div>
{% endmacro %}
@@ -77,9 +78,11 @@
<div
role="group"
aria-labelledby="{{ group_label_id }}"
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
{{ render_dropdown_items(item.items, item_id) if item.items }}
@@ -89,24 +92,29 @@
{% elif item.type == "item" or not item.type %}
{% if item.url %}
<a
id="{{ item_id }}"
role="menuitem"
href="{{ item.url }}"
{% for key, value in item.attrs %}
{% if key != "url" %} {{ key }}="{{ value }}" {% endif %}
{% endfor %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{% if key != "url" %} {{ key }}="{{ value }}" {% endif %}
{% endfor %}
{% endif %}
>
{{ item.label | safe }}
</a>
{% else %}
<button
<div
id="{{ item_id }}"
role="menuitem"
type="button"
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ item.label | safe }}
</button>
</div>
{% endif %}
{% endif %}
{% endfor %}
+12 -20
View File
@@ -5,51 +5,43 @@
@param trigger {string} [optional] - HTML content for the element that triggers the popover.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger element.
@param content_attrs {object} [optional] - Additional HTML attributes for the popover content div.
@param popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
#}
{% macro popover(
trigger,
id=None,
trigger=None,
main_attrs={},
trigger_attrs={},
content_attrs={}
popover_attrs={}
) %}
{% set id = id or ("popover-" + (range(100000, 999999) | random | string)) %}
<div
class="popover {{ main_attrs.class }}"
x-data="popover"
@click.away="open = false"
{% if id %}id="{{ id }}"{% endif %}
{% for key, value in main_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% if trigger %}
<button
type="button"
aria-haspopup="menu"
aria-expanded="false"
x-bind="$trigger"
{% if id %}
id="{{ id }}-trigger"
aria-controls="{{ id }}-menu"
{% endif %}
type="button"
aria-expanded="false"
aria-controls="{{ id }}"
{% for key, value in trigger_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger | safe }}
</button>
{% endif %}
<div
id="{{ id }}"
data-popover
aria-hidden="true"
x-bind="$content"
{% if id %}id="{{ id }}-menu"{% endif %}
{% for key, value in content_attrs %}
{% for key, value in popover_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
>
{{ caller() if caller }}
</div>
</div>
{% endmacro %}
{% endmacro %}
+66 -42
View File
@@ -6,7 +6,7 @@
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
@param content_attrs {object} [optional] - Additional HTML attributes for the popover content div.
@param popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
@param listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div.
@param input_attrs {object} [optional] - Additional HTML attributes for the hidden input.
@param search_placeholder {string} [optional] [default="Search entries..."] - Placeholder text for the search input (combobox only).
@@ -19,37 +19,59 @@
items=None,
main_attrs={},
trigger_attrs={},
content_attrs={},
popover_attrs={},
listbox_attrs={},
input_attrs={},
search_placeholder="Search entries...",
is_combobox=false
) %}
{% set id = id or ("select-" + (range(100000, 999999) | random | string)) %}
{% set first_option = [] %}
{% set selected_option = [] %}
{% if items %}
{% for item in items %}
{% if item.type == "group" %}
{% for sub_item in item.items %}
{% if not first_option[0] %}
{% set first_option = (first_option.push(sub_item), first_option) %}
{% endif %}
{% if selected and sub_item.value == selected and not selected_option[0] %}
{% set selected_option = (selected_option.push(sub_item), selected_option) %}
{% endif %}
{% endfor %}
{% else %}
{% if not first_option[0] %}
{% set first_option = (first_option.push(item), first_option) %}
{% endif %}
{% if selected and item.value == selected and not selected_option[0] %}
{% set selected_option = (selected_option.push(item), selected_option) %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% set default_option = selected_option[0] or first_option[0] or None %}
<div
class="popover {{ main_attrs.class }}"
x-data="select('{{ name }}', '{{ selected or '' }}')"
@click.away="open = false"
{% if id %}id="{{ id }}"{% endif %}
class="select {{ main_attrs.class }}"
{% for key, value in main_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<button
type="button"
class="btn-outline justify-between font-normal {{ trigger_attrs.class }}"
id="{{ id }}-trigger"
aria-haspopup="listbox"
aria-expanded="false"
x-bind="$trigger"
{% if id %}
id="{{ id }}-trigger"
aria-controls="{{ id }}-content"
{% endif %}
class="btn-outline justify-between font-normal {{ trigger_attrs.class }}"
aria-controls="{{ id }}-listbox"
{% for key, value in trigger_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<div x-html="selectedLabel" class="flex items-center gap-x-2"
></div>
<span class="truncate">{{ default_option.label }}</span>
{% if is_combobox %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down-icon lucide-chevrons-up-down text-muted-foreground opacity-50 shrink-0"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
{% else %}
@@ -57,11 +79,10 @@
{% endif %}
</button>
<div
id="{{ id }}"
data-popover
aria-hidden="true"
x-bind="$content"
{% if id %}id="{{ id }}-content"{% endif %}
{% for key, value in content_attrs %}
{% for key, value in popover_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
@@ -77,38 +98,36 @@
spellcheck="false"
aria-autocomplete="list"
role="combobox"
aria-expanded="true"
aria-controls="{{ id }}-content"
aria-expanded="false"
aria-controls="{{ id }}-listbox"
aria-labelledby="{{ id }}-trigger"
x-model="query"
x-bind="$filter"
>
</header>
{% endif %}
<div
role="listbox"
id="{{ id }}-listbox"
aria-orientation="vertical"
aria-labelledby="{{ id }}-trigger"
{% for key, value in listbox_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if items %}
{{ render_select_items(items, id ~ "-items" if id else "items") }}
{% if items and items.length > 0 %}
{{ render_select_items(items, default_option.value, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</div>
</div>
{% if name is defined %}
<input
type="hidden"
name="{{ name }}"
x-model="selectedValue"
{% for key, value in input_attrs %}
{% if key != 'name' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% endif %}
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{{ (default_option.value if default_option) or '' }}"
{% for key, value in input_attrs %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
</div>
{% endmacro %}
@@ -118,31 +137,36 @@
@param items {array} - The array of items to render.
@param parent_id_prefix {string} [optional] - The prefix for the item id.
#}
{% macro render_select_items(items, parent_id_prefix="items") %}
{% macro render_select_items(items, selected, parent_id_prefix="items") %}
{% for item in items %}
{% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
{% if item.type == "group" %}
{% set group_label_id = item.id if item.id else "group-label-" + item_id %}
<div
role="group"
aria-labelledby="{{ group_label_id }}"
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
{{ render_select_items(item.items, item_id) if item.items }}
{{ render_select_items(item.items, selected, item_id) if item.items }}
</div>
{% elif item.type == "separator" %}
<hr role="separator" />
{% elif item.type == "item" or not item.type %}
<div
id="{{ item_id }}"
role="option"
data-value="{{ item.value }}"
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% if selected == item.value %}aria-selected="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ item.label | safe }}
</div>
+23 -17
View File
@@ -7,7 +7,11 @@
@param side {string} [optional] - Side of the sidebar to display.
@param header {string} [optional] - Header content for the sidebar.
@param footer {string} [optional] - Footer content for the sidebar.
@param menu {array} [optional] - Array of menu items for the sidebar.
@param items {array} [optional] - Array of menu items for the sidebar.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param header_attrs {object} [optional] - Additional HTML attributes for the header element.
@param content_attrs {object} [optional] - Additional HTML attributes for the content section.
@param footer_attrs {object} [optional] - Additional HTML attributes for the footer element.
#}
{% macro sidebar(
id=None,
@@ -20,21 +24,17 @@
main_attrs={},
header_attrs={},
content_attrs={},
footer_attrs={},
content_wrapper_attrs=None
footer_attrs={}
) %}
<div
<aside
{% if id %}id="{{ id }}"{% endif %}
class="sidebar {{ main_attrs.class }}"
data-uninitialized
data-side="{{ side if side else "left" }}"
aria-hidden="{{ "true" if not open else "false" }}"
{{ "inert" if not open }}
{% for key, value in main_attrs %}
{% if key != "class" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
x-data="sidebar({{ "true" if open else "false" }})"
x-bind="$main"
>
<nav
aria-label="{{ label }}"
@@ -71,7 +71,7 @@
</footer>
{% endif %}
</nav>
</div>
</aside>
{% endmacro %}
{#
@@ -89,9 +89,11 @@
<div
role="group"
{% if item.label %}aria-labelledby="{{ group_label_id }}"{% endif %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{% if item.label %}
<h3 id="{{ group_label_id }}">{{ item.label }}</h3>
@@ -107,9 +109,11 @@
<details
id="submenu-{{ item_id }}"
{{ "open" if item.open }}
{% for key, value in item.attrs %}
{% if key != "open" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{% if key != "open" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% endif %}
>
<summary aria-controls="submenu-{{ item_id }}-content">
{% if item.icon %}{{ item.icon | safe }}{% endif %}
@@ -125,9 +129,11 @@
<a
href="{{ item.url }}"
{{ 'aria-current="page"' if item.current }}
{% for key, value in item.attrs %}
{% if key != "href" and key != "aria-current" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{% if key != "href" and key != "aria-current" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
<span>{{ item.label }}</span>
+19 -16
View File
@@ -14,17 +14,17 @@
@param default_tab_index {number} [optional] [default=1] - The 1-based index of the tab to be active initially.
#}
{% macro tabs(
id,
id=None,
tabsets=[],
main_attrs=None,
tablist_attrs=None,
main_attrs={},
tablist_attrs={},
default_tab_index=1
)
%}
{% set id = id or ("tabs-" + (range(100000, 999999) | random | string)) %}
<div
class="tabs {{ main_attrs.class }}"
x-data="tabs({{ default_tab_index - 1 }})"
{% if id %}id="{{ id }}"{% endif %}
id="{{ id }}"
{% for key, value in main_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
@@ -32,7 +32,6 @@
<nav
role="tablist"
aria-orientation="horizontal"
x-bind="$tablist"
{% for key, value in tablist_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
@@ -41,13 +40,15 @@
<button
type="button"
role="tab"
id="{{ tabset.id }}-tab-{{ loop.index }}"
aria-controls="{{ tabset.id }}-panel-{{ loop.index }}"
id="{{ id }}-tab-{{ loop.index }}"
aria-controls="{{ id }}-panel-{{ loop.index }}"
aria-selected="{{ 'true' if loop.index == default_tab_index else 'false' }}"
tabindex="0"
{% for key, value in tabset.tab_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% if tabset.tab_attrs %}
{% for key, value in tabset.tab_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ tabset.tab | safe }}
</button>
@@ -58,14 +59,16 @@
{% if tabset.panel %}
<div
role="tabpanel"
id="{{ tabset.id }}-panel-{{ loop.index }}"
aria-labelledby="{{ tabset.id }}-tab-{{ loop.index }}"
id="{{ id }}-panel-{{ loop.index }}"
aria-labelledby="{{ id }}-tab-{{ loop.index }}"
tabindex="-1"
aria-selected="{{ 'true' if loop.index == default_tab_index else 'false' }}"
{% if loop.index != default_tab_index %}hidden{% endif %}
{% for key, value in tabset.panel_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% if tabset.panel_attrs %}
{% for key, value in tabset.panel_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ tabset.panel | safe }}
</div>
+46 -135
View File
@@ -10,16 +10,14 @@
{% macro toaster(
id="toaster",
toasts=[],
main_attrs=None,
is_fragment=false
attrs={}
) %}
<div
id="{{ id }}"
class="toaster"
{% for key, value in main_attrs %}
{{ key }}="{{ value }}"
class="toaster {{ attrs.class }}"
{% for key, value in attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% if is_fragment %}hx-swap-oob="beforeend"{% endif %}
>
{% for item in toasts %}
{{ toast(
@@ -31,79 +29,6 @@
) }}
{% endfor %}
</div>
{% if not is_fragment %}
<template id="toast-template">
<div
class="toast"
role="status"
aria-atomic="true"
x-bind="$toastBindings"
>
<div class="toast-content">
<div class="flex items-center justify-between gap-x-3 p-4 [&>svg]:size-4 [&>svg]:shrink-0 [&>[role=img]]:size-4 [&>[role=img]]:shrink-0 [&>[role=img]>svg]:size-4">
<template x-if="config.icon">
<span aria-hidden="true" role="img" x-html="config.icon"></span>
</template>
<template x-if="!config.icon && config.category === 'success'">
{{ toast_icons.success | safe }}
</template>
<template x-if="!config.icon && config.category === 'error'">
{{ toast_icons.error | safe }}
</template>
<template x-if="!config.icon && config.category === 'info'">
{{ toast_icons.info | safe }}
</template>
<template x-if="!config.icon && config.category === 'warning'">
{{ toast_icons.warning | safe }}
</template>
<section class="flex-1 flex flex-col gap-0.5 items-start">
<template x-if="config.title">
<h2 class="font-medium" x-text="config.title"></h2>
</template>
<template x-if="config.description">
<p class="text-muted-foreground" x-text="config.description"></p>
</template>
</section>
<template x-if="config.action || config.cancel">
<footer class="flex flex-col gap-1 self-start">
<template x-if="config.action?.click">
<button
type="button"
class="btn h-6 text-xs px-2.5 rounded-sm"
@click="executeAction(config.action.click)"
x-text="config.action.label"
></button>
</template>
<template x-if="config.action?.url">
<a
:href="config.action.url"
class="btn h-6 text-xs px-2.5 rounded-sm"
x-text="config.action.label"
></a>
</template>
<template x-if="config.cancel?.click">
<button
type="button"
class="btn-outline h-6 text-xs px-2.5 rounded-sm"
@click="executeAction(config.cancel.click)"
x-text="config.cancel.label"
></button>
</template>
<template x-if="config.cancel?.url">
<a
:href="config.cancel.url"
class="btn-outline h-6 text-xs px-2.5 rounded-sm"
x-text="config.cancel.label"
></a>
</template>
</footer>
</template>
</div>
</div>
</div>
</template>
{% endif %}
{% endmacro %}
{#
@@ -134,65 +59,51 @@
aria-atomic="true"
aria-hidden="false"
{% if category %}data-category="{{ category }}"{% endif %}
x-data="toast({
category: '{{ category }}',
duration: {{ duration or 'null' }}
})"
x-bind="$toastBindings"
>
<div class="toast-content">
<div class="flex items-center justify-between gap-x-3 p-4 [&>svg]:size-4 [&>svg]:shrink-0 [&>[role=img]]:size-4 [&>[role=img]]:shrink-0 [&>[role=img]>svg]:size-4">
{% if category in ["error", "success", "info", "warning"] %}
{{ toast_icons[category] | safe }}
{% if category in ["error", "success", "info", "warning"] %}
{% if category == "success" %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
{% elif category == "error" %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
{% elif category == "info" %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
{% elif category == "warning" %}
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
{% endif %}
<section class="flex-1 flex flex-col gap-0.5 items-start">
{% if title %}
<h2 class="font-medium">{{ title }}</h2>
{% endif %}
{% if description %}
<p class="text-muted-foreground">{{ description }}</p>
{% endif %}
</section>
{% if action or cancel %}
<footer class="flex flex-col gap-1 self-start">
{% if action %}
{% if action.click %}
<button
type="button"
class="btn h-6 text-xs px-2.5 rounded-sm"
@click="{{ action.click }}"
>{{ action.label }}</button>
{% elif action.url %}
<a
href="{{ action.url }}"
class="btn h-6 text-xs px-2.5 rounded-sm"
>{{ action.label }}</a>
{% endif %}
{% endif %}
<section>
{% if title %}<h2>{{ title }}</h2>{% endif %}
{% if description %}<p>{{ description }}</p>{% endif %}
</section>
{% if action or cancel %}
<footer>
{% if action %}
{% if action.href %}
<a
href="{{ action.href }}"
class="btn-sm"
data-toast-action
>{{ action.label }}</a>
{% else %}
<button
type="button"
class="btn"
data-toast-action
{% if action.onclick %}onclick="{{ action.onclick }}"{% endif %}
>{{ action.label }}</button>
{% endif %}
{% if cancel %}
{% if cancel.click %}
<button
type="button"
class="btn-outline h-6 text-xs px-2.5 rounded-sm"
@click="{{ cancel.click }}"
>{{ cancel.label }}</button>
{% elif cancel.url %}
<a
href="{{ cancel.url }}"
class="btn-outline h-6 text-xs px-2.5 rounded-sm"
>{{ toast.cancel.label }}</a>
{% endif %}
{% endif %}
</footer>
{% endif %}
</div>
{% endif %}
{% if cancel %}
<button
type="button"
class="btn-sm-outline"
data-toast-cancel
{% if cancel.onclick %}onclick="{{ cancel.onclick }}"{% endif %}
>{{ cancel.label }}</button>
{% endif %}
</footer>
{% endif %}
</div>
</div>
{% endmacro %}
{% set toast_icons = {
'success': '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-check-icon lucide-circle-check"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',
'error': '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>',
'info': '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-info-icon lucide-info"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>',
'warning': '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert-icon lucide-triangle-alert"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>'
} %}
{% endmacro %}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "basecoat-docs",
"compatibility_date": "2025-06-15",
"assets": {
"directory": "./_site"
}
}