PoC for new components, including select

This commit is contained in:
Ronan Berder
2025-06-12 10:37:19 +08:00
parent cdc52a810e
commit 3b0b38a4cf
38 changed files with 1551 additions and 1041 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" });
-8
View File
@@ -1,15 +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 "./themes/claude.css";
@import "./themes/doom-64.css";
@import "./themes/supabase.css";
+3 -15
View File
@@ -78,24 +78,12 @@
<link rel="stylesheet" href="/assets/styles.css">
<!-- JS -->
<script src="/assets/js/dialog.js" defer></script>
<script src="/assets/js/dropdown-menu.js" defer></script>
<script src="/assets/js/popover.js" defer></script>
<script src="/assets/js/toast.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>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<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">
@@ -19,7 +19,6 @@
] %}
{{ tabs(
id="code-block-{{ id }}",
tabsets=tabsets,
default_tab_index=(2 if default_code else 1),
register_on=["alpine:init", "htmx:afterSwap"]
+3 -9
View File
@@ -1,18 +1,12 @@
<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="window.dispatchEvent(new CustomEvent('sidebar:toggle'))"
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>
@@ -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">
@@ -10,7 +10,7 @@
<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"}
@@ -134,8 +134,6 @@
</button>
{% 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"/>
@@ -170,7 +168,6 @@
{% endset %}
{% call dropdown_menu(
id="dropdown-menu-radio-group",
trigger="Radio Group",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "min-w-56"},
@@ -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 Blueberry Blueberry Blueberry 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', (e) => updateSlider(e.target));
})();
</script>
+3 -2
View File
@@ -63,7 +63,7 @@
"hx-select": "#content",
"hx-target": "#content",
"hx-swap": "outerHTML",
"@click": "if (window.innerWidth < 768) { open = false }"
"data-sidebar-close-on-mobile": ""
} %}
{% set icon_home %}{% lucide "house" %}{% endset %}
@@ -79,7 +79,7 @@
{ 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 }"
"data-sidebar-close-on-mobile": ""
}
}
]},
@@ -117,6 +117,7 @@
{{ sidebar(
id="sidebar",
label="Sidebar navigation",
header=header,
footer=footer,
+341 -448
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -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",
@@ -105,8 +105,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",
+3 -3
View File
@@ -23,8 +23,8 @@ 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",
@@ -175,7 +175,7 @@ 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",
+3 -4
View File
@@ -21,8 +21,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">
@@ -52,13 +51,13 @@ toc:
{% endset %}
{% set code_script %}
<script>
{# <script>
{% fetchCode "src/js/popover.js" %}
document.addEventListener('alpine:init', () => {
window.basecoat.registerPopover(Alpine);
});
</script>
</script> #}
{% endset %}
{{ code_preview("popover", code_script | prettyHtml ~ code_html | prettyHtml) }}
+31 -30
View File
@@ -34,34 +34,30 @@ 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 %}
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) }}
@@ -153,16 +149,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>
+17
View File
@@ -0,0 +1,17 @@
---
layout: layouts/base.njk
---
<div id="my-popover" open popover="manual">
SOMETHING
SOMETHING
SOMETHING
SOMETHING
SOMETHING
</div>
<button popovertarget="my-popover">
TOGGLE SIDEBAR
</button>
+2 -2
View File
@@ -84,13 +84,13 @@ toc:
{% set code_script %}
<script>
{# <script>
{% fetchCode "src/js/popover.js" %}
document.addEventListener('alpine:init', () => {
window.basecoat.registerPopover(Alpine);
});
</script>
</script> #}
{% endset %}
{% set code_html_toaster %}{{ toaster() }}{% endset %}
+9 -8
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" />
+54 -30
View File
@@ -446,37 +446,56 @@
/* 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;
}
}
}
@@ -531,7 +550,9 @@
}
/* Popover */
@layer components {
@import "./components/popover.css";
/* @layer components {
.popover {
@apply relative inline-flex;
@@ -625,7 +646,7 @@
[role='separator'] {
@apply border-border -mx-1 my-1;
}
/* Combobox */
// Combobox
> header {
@apply flex h-9 items-center gap-2 border-b px-3 -mx-1 -mt-1 mb-1;
@@ -647,7 +668,7 @@
}
}
}
}
} */
/* Radio */
@layer components {
@@ -713,7 +734,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];
@@ -726,7 +747,7 @@
/* Sidebar */
.sidebar {
&[data-uninitialized] {
&:not([data-sidebar-initialized]) {
@apply max-md:hidden;
}
&:not([aria-hidden]),
@@ -734,7 +755,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 +766,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 +782,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 {
+59
View File
@@ -0,0 +1,59 @@
@layer components {
.dialog {
@apply inset-y-0 opacity-0 transition-all transition-discrete;
&:is([open],:popover-open) {
@apply opacity-100;
&::backdrop {
@apply opacity-100;
}
> article {
@apply scale-100;
}
@starting-style {
@apply opacity-0;
&::backdrop {
@apply opacity-0;
}
> article {
@apply scale-95;
}
}
}
&::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 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;
}
}
}
}
}
+155
View File
@@ -0,0 +1,155 @@
@layer components {
div.select {
[popover].popover {
@apply p-1 min-w-[anchor-size(width)];
[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;
&[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'];
}
}
&:not([data-select-initialized]) [role='option']:hover,
&[data-select-initialized] [role='option'].active,
&[data-select-initialized] [role='option']:focus-visible {
@apply bg-accent text-accent-foreground;
}
}
.dropdown-menu {
[popover].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;
@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;
}
}
}
}
@layer components {
[popover].popover {
@apply absolute inset-auto bg-popover text-popover-foreground overflow-x-hidden overflow-y-auto p-4 rounded-md border shadow-md z-50 visible opacity-0 scale-95 w-max transition-all transition-discrete;
&:not([data-side]),
&[data-side='bottom'] {
@apply -translate-y-2;
}
&[data-side='top'] {
@apply translate-y-2;
}
&[data-side='left'] {
@apply translate-x-2;
}
&[data-side='right'] {
@apply -translate-x-2;
}
&:not([data-side]),
&[data-side='bottom'] {
@apply mt-1 top-[anchor(bottom)];
}
&[data-side='top'] {
@apply mb-1 bottom-[anchor(top)];
}
&[data-side='left'] {
@apply mr-1 right-[anchor(left)];
}
&[data-side='right'] {
@apply ml-1 left-[anchor(right)];
}
&:not([data-side]),
&[data-side='bottom'],
&[data-side='top'] {
&:not([data-align]),
&[data-align='start'] {
@apply left-[anchor(left)];
}
&[data-align='end'] {
@apply right-[anchor(right)];
}
&[data-align='center'] {
@apply left-[anchor(center)] -translate-x-1/2;
}
}
&[data-side='left'],
&[data-side='right'] {
&:not([data-align]),
&[data-align='start'] {
@apply top-[anchor(top)];
}
&[data-align='end'] {
@apply bottom-[anchor(bottom)];
}
&[data-align='center'] {
@apply top-[anchor(center)] -translate-y-1/2;
}
}
&:is(:popover-open) {
@apply opacity-100 scale-100 translate-x-0 translate-y-0;
@starting-style {
@apply opacity-0 scale-95;
&:not([data-side]),
&[data-side='bottom'] {
@apply -translate-y-2;
}
&[data-side='top'] {
@apply translate-y-2;
}
&[data-side='left'] {
@apply translate-x-2;
}
&[data-side='right'] {
@apply -translate-x-2;
}
}
}
}
}
+91
View File
@@ -0,0 +1,91 @@
window.basecoat = window.basecoat || {};
window.basecoat.registerDropdownMenu = function(Alpine) {
if (Alpine.components && Alpine.components.dropdownMenu) return;
Alpine.data('dropdownMenu', () => ({
open: false,
focusedIndex: null,
menuItems: [],
init() {
this.$nextTick(() => {
this.menuItems = Array.from(this.$el.querySelectorAll('[role=menuitem]:not([disabled]),[role=menuitemcheckbox]:not([disabled]),[role=menuitemradio]:not([disabled])'));
});
},
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': ''
},
}));
};
+156
View File
@@ -0,0 +1,156 @@
window.basecoat = window.basecoat || {};
window.basecoat.registerSelect = function(Alpine) {
if (Alpine.components && Alpine.components.select) return;
Alpine.data('select', (initialValue = null) => ({
open: false,
options: [],
disabledOptions: [],
focusedIndex: null,
selectedLabel: null,
selectedValue: null,
query: '',
init() {
this.$nextTick(() => {
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);
} else {
this.selectOption(this.options[0], false);
}
});
},
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 (!option) return;
this.focusedIndex = this.options.indexOf(option);
this.focusOption();
},
moveOptionFocus(delta) {
if (this.options.length === 0) return;
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;
},
selectValue(value, dispatch = true) {
if (this.options.length === 0 || value == null) return;
const option = this.options.find(opt => opt.getAttribute('data-value') === String(value));
if (option) {
this.selectOption(option, dispatch);
}
},
selectOption(option, dispatch = true) {
if (this.options.length === 0 || !option || option.disabled || option.getAttribute('data-value') == null) {
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');
}
},
filterOptions(query) {
if (query.length > 0) {
this.disabledOptions.forEach(opt => { opt.setAttribute('aria-hidden', 'true'); });
} else {
this.disabledOptions.forEach(opt => { opt.removeAttribute('aria-hidden'); });
}
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');
}
});
},
$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() },
}
}));
};
+25
View File
@@ -0,0 +1,25 @@
window.basecoat = window.basecoat || {};
window.basecoat.registerSidebar= function(Alpine) {
if (Alpine.components && Alpine.components.sidebar) return;
Alpine.data('sidebar', (initialOpen = true, initialMobileOpen = false) => ({
open: window.innerWidth >= 768
? initialOpen
: initialMobileOpen,
init() {
this.$nextTick(() => {
this.$el.removeAttribute('data-uninitialized');
});
},
$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 }
},
}));
};
+64
View File
@@ -0,0 +1,64 @@
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: [],
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);
}
});
},
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() },
},
}));
};
+75
View File
@@ -0,0 +1,75 @@
window.basecoat = window.basecoat || {};
window.basecoat.registerToast = function(Alpine) {
if (Alpine.components && Alpine.components.toast) return;
Alpine.store('toaster', { isPaused: false });
Alpine.data('toast', (config={}) => ({
config: config,
open: false,
timeoutDuration: null,
timeoutId: null,
init() {
if (config.duration !== -1) {
this.timeoutDuration = config.duration || (config.category === 'error' ? 5000 : 3000);
this.timeoutId = setTimeout(() => { this.close() }, this.timeoutDuration);
}
this.open = true;
this.$watch('$store.toaster.isPaused', (isPaused) => {
if (!this.open) return;
if (isPaused) {
this.pauseTimeout();
} else {
this.resumeTimeout();
}
});
},
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);
});
};
+85 -146
View File
@@ -1,156 +1,95 @@
window.basecoat = window.basecoat || {};
window.basecoat.registerSelect = function(Alpine) {
if (Alpine.components && Alpine.components.select) return;
Alpine.data('select', (initialValue = null) => ({
open: false,
options: [],
disabledOptions: [],
focusedIndex: null,
selectedLabel: null,
selectedValue: null,
query: '',
(() => {
const initSelect = (select) => {
const trigger = select.querySelector(':scope > [popovertarget]');
const selectedValue = trigger.querySelector(':scope > span');
const popover = select.querySelector(':scope > [popover]');
const listbox = popover.querySelector('[role="listbox"]');
const input = select.querySelector(':scope > input[type="hidden"]');
if (!trigger || !popover || !listbox || !input) return;
init() {
this.$nextTick(() => {
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);
} else {
this.selectOption(this.options[0], false);
}
});
},
focusOption() {
if (this.options.length === 0) return;
const options = Array.from(listbox.querySelectorAll('[role="option"]'));
let activeIndex = -1;
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 (!option) return;
this.focusedIndex = this.options.indexOf(option);
this.focusOption();
},
moveOptionFocus(delta) {
if (this.options.length === 0) return;
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;
},
selectValue(value, dispatch = true) {
if (this.options.length === 0 || value == null) return;
const option = this.options.find(opt => opt.getAttribute('data-value') === String(value));
const updateValue = (option) => {
if (option) {
this.selectOption(option, dispatch);
}
},
selectOption(option, dispatch = true) {
if (this.options.length === 0 || !option || option.disabled || option.getAttribute('data-value') == null) {
return;
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');
}
};
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');
const selectOption = (option) => {
if (!option) return;
updateValue(option);
trigger.removeAttribute('aria-activedescendant');
options.forEach(opt => opt.classList.remove('active'));
activeIndex = -1;
popover.hidePopover();
};
let initialOption = options.find(opt => input.value && opt.dataset.value === input.value);
if (!initialOption && options.length > 0) initialOption = options[0];
updateValue(initialOption);
trigger.addEventListener('keydown', (e) => {
if (!popover.matches(':popover-open')) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (activeIndex > -1) options[activeIndex]?.classList.remove('active');
if (e.key === 'ArrowDown') activeIndex = (activeIndex + 1) % options.length;
else activeIndex = (activeIndex - 1 + options.length) % options.length;
const activeOption = options[activeIndex];
if (activeOption) {
activeOption.classList.add('active');
if (activeOption.id) {
trigger.setAttribute('aria-activedescendant', activeOption.id);
}
activeOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
} else if (e.key === 'Enter') {
e.preventDefault();
selectOption(options[activeIndex]);
}
},
filterOptions(query) {
if (query.length > 0) {
this.disabledOptions.forEach(opt => { opt.setAttribute('aria-hidden', 'true'); });
} else {
this.disabledOptions.forEach(opt => { opt.removeAttribute('aria-hidden'); });
});
listbox.addEventListener('click', (e) => {
const clickedOption = e.target.closest('[role="option"]');
if (clickedOption) selectOption(clickedOption);
});
popover.addEventListener('toggle', (e) => {
if (e.newState === 'closed') {
trigger.removeAttribute('aria-activedescendant');
if (activeIndex > -1) options[activeIndex]?.classList.remove('active');
activeIndex = -1;
}
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');
});
select.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() },
}
}));
};
// Start observing the whole document
observer.observe(document.body, { childList: true, subtree: true });
})();
+64 -21
View File
@@ -1,25 +1,68 @@
window.basecoat = window.basecoat || {};
window.basecoat.registerSidebar= function(Alpine) {
if (Alpine.components && Alpine.components.sidebar) return;
(() => {
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 updateState = () => {
sidebarComponent.setAttribute('aria-hidden', !open);
if (open) {
sidebarComponent.removeAttribute('inert');
} else {
sidebarComponent.setAttribute('inert', '');
}
};
Alpine.data('sidebar', (initialOpen = true, initialMobileOpen = false) => ({
open: window.innerWidth >= 768
? initialOpen
: initialMobileOpen,
const setState = (state) => {
open = state;
updateState();
};
init() {
this.$nextTick(() => {
this.$el.removeAttribute('data-uninitialized');
const sidebarId = sidebarComponent.id;
window.addEventListener('sidebar:open', (e) => {
if (!e.detail?.id || e.detail.id === sidebarId) setState(true);
});
window.addEventListener('sidebar:close', (e) => {
if (!e.detail?.id || e.detail.id === sidebarId) setState(false);
});
window.addEventListener('sidebar:toggle', (e) => {
if (!e.detail?.id || e.detail.id === sidebarId) setState(!open);
});
sidebarComponent.addEventListener('click', (e) => {
const nav = sidebarComponent.querySelector('nav');
const closeTarget = e.target.closest('[data-sidebar-close-on-mobile]');
if (closeTarget && window.innerWidth < breakpoint) {
setState(false);
} else if (e.target === sidebarComponent || (nav && !nav.contains(e.target))) {
setState(false);
}
});
updateState();
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);
}
});
},
});
});
$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 }
},
}));
};
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', (e) => {
const clickedTab = e.target.closest('[role="tab"]');
if (clickedTab) selectTab(clickedTab);
});
tablist.addEventListener('keydown', (e) => {
const currentTab = e.target;
if (!tabs.includes(currentTab)) return;
let nextTab;
const currentIndex = tabs.indexOf(currentTab);
switch (e.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;
}
e.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 });
})();
+51 -68
View File
@@ -17,7 +17,7 @@
@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,
@@ -32,79 +32,62 @@
close_button=true,
close_on_overlay_click=true
) %}
<div
{% 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 %}
>
{{ trigger }}
</button>
{% endif %}
<dialog
id="{{ id }}"
x-data="dialog({{ 'true' if open else 'false' }}, {{ 'true' if close_on_overlay_click else 'false' }})"
x-bind="$main"
class="dialog"
onClick="this.close()"
{% for key, value in main_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>
{% if description %}<p>{{ description | safe }}</p>{% endif %}
</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"
>
<article onClick="event.stopPropagation()">
{% if title or description %}
<header
{% for key, value in content_header_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
<h2 id="{{ id }}-title">{{ title | safe }}</h2>
{% if description %}<p>{{ description | safe }}</p>{% endif %}
</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 %}
<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>
</form>
{% endif %}
</article>
</dialog>
{% endmacro %}
+13 -30
View File
@@ -10,50 +10,33 @@
#}
{% macro dropdown_menu(
id=None,
trigger=None,
menu=None,
main_attrs={},
trigger,
items=None,
trigger_attrs={},
content_attrs={}
popover_attrs={}
) %}
<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 %}
{% endfor %}
>
{% if trigger %}
{% set id = id or ("dropdown-menu-" + (range(100000, 999999) | random | string)) %}
<div class="dropdown-menu">
<button
type="button"
aria-haspopup="menu"
aria-expanded="false"
x-bind="$trigger"
{% if id %}
id="{{ id }}-trigger"
aria-controls="{{ id }}-menu"
{% endif %}
popovertarget="{{ id }}"
{% for key, value in trigger_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger | safe }}
</button>
{% endif %}
<div
data-popover
aria-hidden="true"
x-bind="$content"
{% if id %}id="{{ id }}-menu"{% endif %}
{% for key, value in content_attrs %}
{{ key }}="{{ value }}"
popover
class="popover p-1 {% if popover_attrs.class %} {{ popover_attrs.class }}{% endif %}"
id="{{ id }}"
{% for key, value in popover_attrs %}
{% if key != "class" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<nav role="menu">
{% if menu %}
{{ render_dropdown_items(menu, id ~ "-items" if id else "items") }}
{% if items %}
{{ render_dropdown_items(items, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
+20 -38
View File
@@ -9,47 +9,29 @@
#}
{% macro popover(
id=None,
trigger=None,
main_attrs={},
trigger,
trigger_attrs={},
content_attrs={}
popover_attrs={}
) %}
<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 %}
{% set id = id or ("popover-" + (range(100000, 999999) | random | string)) %}
<button
type="button"
popovertarget="{{ id }}"
id="{{ id }}-trigger"
{% for key, value in trigger_attrs %}
{{ key }}="{{ value }}"
{% 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 %}
{% for key, value in trigger_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger | safe }}
</button>
{% endif %}
<div
data-popover
aria-hidden="true"
x-bind="$content"
{% if id %}id="{{ id }}-menu"{% endif %}
{% for key, value in content_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{{ caller() if caller }}
</div>
{{ trigger | safe }}
</button>
<div
popover
class="popover {{ popover_attrs.class }}"
id="{{ id }}"
{% for key, value in popover_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{{ caller() if caller }}
</div>
{% endmacro %}
+55 -43
View File
@@ -17,39 +17,53 @@
selected=None,
name=None,
items=None,
main_attrs={},
trigger_attrs={},
content_attrs={},
popover_attrs={},
listbox_attrs={},
input_attrs={},
search_placeholder="Search entries...",
is_combobox=false
) %}
<div
class="popover {{ main_attrs.class }}"
x-data="select('{{ name }}', '{{ selected or '' }}')"
@click.away="open = false"
{% if id %}id="{{ id }}"{% endif %}
{% for key, value in main_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% set id = id or ("select-" + (range(100000, 999999) | random | string)) %}
{% set first_option = [] %}
{% set selected_option = [] %}
{% 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 %}
{% set default_option = selected_option[0] or first_option[0] or None %}
<div class="select">
<button
type="button"
class="btn-outline justify-between font-normal {{ trigger_attrs.class }}"
id="{{ id }}-trigger"
popovertarget="{{ id }}"
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,12 +71,11 @@
{% endif %}
</button>
<div
data-popover
aria-hidden="true"
x-bind="$content"
{% if id %}id="{{ id }}-content"{% endif %}
{% for key, value in content_attrs %}
{{ key }}="{{ value }}"
popover
id="{{ id }}"
class="popover {{ popover_attrs.class }}"
{% for key, value in popover_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% if is_combobox %}
@@ -80,35 +93,33 @@
aria-expanded="true"
aria-controls="{{ id }}-content"
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.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="{{ selected or '' }}"
{% for key, value in input_attrs %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
</div>
{% endmacro %}
@@ -118,10 +129,9 @@
@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
@@ -132,14 +142,16 @@
{% endfor %}
>
<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 }}"
{% if selected == item.value %}aria-selected="true"{% endif %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
+2 -5
View File
@@ -23,18 +23,15 @@
footer_attrs={},
content_wrapper_attrs=None
) %}
<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 +68,7 @@
</footer>
{% endif %}
</nav>
</div>
</aside>
{% endmacro %}
{#
+7 -8
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,
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,8 +40,8 @@
<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 %}
@@ -58,8 +57,8 @@
{% 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 %}