Compare commits

...
33 Commits
Author SHA1 Message Date
Ronan Berder a54d5fb4eb Bumping version to 0.3.11 2026-02-05 18:01:54 +08:00
Ronan Berder 8445f6d150 Removing current page code from sidebar.js 2026-02-05 17:57:07 +08:00
Ronan Berder 713c163fb8 Preparing 0.3.10 2026-01-13 19:33:59 +08:00
Ronan Berder a7f686e71b Fixing Jinja macro and removing button-group isolate 2026-01-13 16:37:14 +08:00
Ronan Berder 128f79c2e4 Fixing docs 2026-01-05 12:47:44 +08:00
Ronan Berder ee63071f87 Polishing 2026-01-05 12:15:43 +08:00
Ronan Berder def943d74e Moving to serialized array 2026-01-05 11:28:05 +08:00
Ronan Berder f43c20948b Forcing assets refresh on new version 2026-01-04 21:58:42 +08:00
Ronan Berder 3ec06205a0 Preparing 0.3.10-beta.1 2026-01-04 21:22:53 +08:00
Ronan Berder 06e7c1ec84 Refined multi-select 2026-01-04 19:21:07 +08:00
Ronan Berder 09f868d474 Stable multi-select implementation 2026-01-04 14:39:32 +08:00
Ronan Berder b9274702d3 Fixing text color in tooltip (see #97) 2026-01-03 14:24:08 +08:00
Ronan Berder 83c09c1197 Merge branch 'development' of github.com:hunvreus/basecoat into development 2026-01-03 14:21:35 +08:00
Ronan Berder 36bca0c6a7 Merge pull request #97 from PhilipdeRijk/fix/bg-tooltip
fix: change tooltip bg color to bg-foreground
2026-01-03 14:20:18 +08:00
Ronan Berder 167b98e573 Merge pull request #126 from tainguyenphat74/main
Correct typo
2025-12-30 18:41:15 +08:00
Ronan Berder 4035870578 Merge pull request #125 from wangbinyq/patch-1
fix links for select.njk documentation
2025-12-30 17:13:29 +08:00
Ronan Berder dea0602b03 Merge pull request #128 from hrbonz/wip/btn_groups_fix_typo
docs: fix small word mistake in button group
2025-12-30 17:12:55 +08:00
Stefan 'hr' Berder 19941561d3 docs: fix small word mistake in button group
The large button example was using "Small" "button" "group" list of
words. It makes more sense to use "Large" as the first word to stay
consistent with the other example.
2025-12-30 14:46:35 +07:00
tainguyenphat74 f708994df9 Correct typo 2025-12-28 00:16:38 +07:00
wangb 6fcbb31e30 Fix code preview rendering in select.njk 2025-12-24 15:07:51 +08:00
wangb 0dfc5e061c fix links for select.njk documentation 2025-12-24 15:05:06 +08:00
Ronan Berder c771c8256b Compiling CSS for site 2025-12-21 14:43:07 +08:00
Ronan Berder 2adb4c4667 Bumping up version unmber to 0.3.9 2025-12-21 14:39:44 +08:00
Ronan Berder cbb1119699 Addressing regression bug 2025-12-21 14:38:47 +08:00
Ronan Berder 88db2e84ce Bumping up version unmber to 0.3.8 2025-12-21 14:36:58 +08:00
Ronan Berder a7f6f717d1 Fix #112 and #61 2025-12-21 14:20:09 +08:00
Ronan Berder 91c3932fe7 Cleaning up 2025-12-18 09:00:05 +08:00
Ronan Berder f977492bf6 Removing FUNDING.yml 2025-12-18 08:57:48 +08:00
Ronan Berder 21ba8ecb9e Bumping version number 2025-12-10 15:00:14 +08:00
Ronan Berder c0bf373890 Fixing edge case for command and select with data-keywords filtering, updating macros for command and sidebar 2025-12-10 14:49:32 +08:00
Ronan Berder 77680f9e2b Bumping version number 2025-11-17 10:53:40 +08:00
Ronan Berder 2afa2ae02e Fixing and extending combobox and command 2025-11-17 10:45:39 +08:00
PhilipdeRijk 530224c587 fix: change tooltip bg color to bg-foreground 2025-09-26 11:53:09 +02:00
29 changed files with 775 additions and 389 deletions
-1
View File
@@ -1 +0,0 @@
github: [hunvreus]
+20
View File
@@ -0,0 +1,20 @@
## Now
- [ ] Add Drawer component
- [ ] Make utility components (e.g., Item) into proper components (i.e., .item)
- [ ] Split CSS into components
## Next
- [ ] Make Command load dynamic items (e.g. search results from remote endpoint)
- [ ] Make Combobox load dynamic items (e.g. search results from remote endpoint)
- [ ] Add Toggle component
- [ ] Add Carousel component
- [ ] Add Sheet component
- [ ] Add Separator component
- [ ] Adding Menubar component
## Later
- [ ] Add Calendar component (https://flatpickr.js.org/)
- [ ] Finish Chart component (https://www.chartjs.org/)
+1 -1
View File
@@ -50,7 +50,7 @@
@apply border-t pt-3 mt-3 first:mt-0;
}
dd {
@apply mt-2;
@apply mt-2 space-y-2;
}
dl {
@apply mt-4 border-b-0 pb-0 pl-8 my-0;
+1 -137
View File
@@ -5,140 +5,4 @@
@import "./themes/claude.css";
@import "./themes/doom-64.css";
@import "./themes/supabase.css";
/* @theme inline {
--breakpoint-3xl: 1600px;
--breakpoint-4xl: 2000px;
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-surface: var(--surface);
--color-surface-foreground: var(--surface-foreground);
--color-code: var(--code);
--color-code-foreground: var(--code-foreground);
--color-code-highlight: var(--code-highlight);
--color-code-number: var(--code-number);
--color-selection: var(--selection);
--color-selection-foreground: var(--selection-foreground);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: var(--color-blue-300);
--chart-2: var(--color-blue-500);
--chart-3: var(--color-blue-600);
--chart-4: var(--color-blue-700);
--chart-5: var(--color-blue-800);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--surface: oklch(0.98 0 0);
--surface-foreground: var(--foreground);
--code: var(--surface);
--code-foreground: var(--surface-foreground);
--code-highlight: oklch(0.96 0 0);
--code-number: oklch(0.56 0 0);
--selection: oklch(0.145 0 0);
--selection-foreground: oklch(1 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.269 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: var(--color-blue-300);
--chart-2: var(--color-blue-500);
--chart-3: var(--color-blue-600);
--chart-4: var(--color-blue-700);
--chart-5: var(--color-blue-800);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
--surface: oklch(0.2 0 0);
--surface-foreground: oklch(0.708 0 0);
--code: var(--surface);
--code-foreground: var(--surface-foreground);
--code-highlight: oklch(0.27 0 0);
--code-number: oklch(0.72 0 0);
--selection: oklch(0.922 0 0);
--selection-foreground: oklch(0.205 0 0);
} */
@import "./themes/supabase.css";
+8 -8
View File
@@ -69,14 +69,14 @@
{# CSS #}
<link rel="stylesheet" href="/assets/styles.css">
{# JS #}
<script src="/assets/js/basecoat.js" defer></script>
<script src="/assets/js/command.js" defer></script>
<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 src="/assets/js/basecoat.js?v={{ pkg.version }}" defer></script>
<script src="/assets/js/command.js?v={{ pkg.version }}" defer></script>
<script src="/assets/js/dropdown-menu.js?v={{ pkg.version }}" defer></script>
<script src="/assets/js/popover.js?v={{ pkg.version }}" defer></script>
<script src="/assets/js/select.js?v={{ pkg.version }}" defer></script>
<script src="/assets/js/sidebar.js?v={{ pkg.version }}" defer></script>
<script src="/assets/js/tabs.js?v={{ pkg.version }}" defer></script>
<script src="/assets/js/toast.js?v={{ pkg.version }}" defer></script>
{# HTMX #}
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
{# Highlight.js #}
+37
View File
@@ -1,6 +1,43 @@
{% from "sidebar.njk" import sidebar %}
{% from "popover.njk" import popover %}
<script>
(() => {
document.addEventListener('DOMContentLoaded', () => {
const sidebar = document.getElementById('sidebar');
if (!sidebar) return;
const updateCurrentLinks = () => {
const currentPath = window.location.pathname.replace(/\/$/, '');
const currentSearch = window.location.search;
const currentKey = `${currentPath}${currentSearch}`;
sidebar.querySelectorAll('a, button').forEach((item) => {
const href = item.getAttribute('href');
if (!href) {
item.removeAttribute('aria-current');
return;
}
const url = new URL(href, window.location.origin);
const itemPath = url.pathname.replace(/\/$/, '');
const itemKey = `${itemPath}${url.search}`;
if (itemKey === currentKey) {
item.setAttribute('aria-current', 'page');
} else {
item.removeAttribute('aria-current');
}
});
};
document.addEventListener('htmx:afterSettle', updateCurrentLinks);
window.addEventListener('popstate', updateCurrentLinks);
updateCurrentLinks();
});
})();
</script>
{% set footer %}
{% set trigger %}
<img src="https://github.com/hunvreus.png" class="rounded-lg shrink-0 size-8">
+35 -12
View File
@@ -19,11 +19,9 @@
--color-green-900: oklch(39.3% 0.095 152.535);
--color-green-950: oklch(26.6% 0.065 152.934);
--color-blue-50: oklch(97% 0.014 254.604);
--color-blue-300: oklch(80.9% 0.105 251.813);
--color-blue-500: oklch(62.3% 0.214 259.815);
--color-blue-600: oklch(54.6% 0.245 262.881);
--color-blue-700: oklch(48.8% 0.243 264.376);
--color-blue-800: oklch(42.4% 0.199 265.638);
--color-blue-900: oklch(37.9% 0.146 265.522);
--color-blue-950: oklch(28.2% 0.091 267.935);
--color-purple-500: oklch(62.7% 0.265 303.9);
@@ -585,6 +583,9 @@
.w-\[200px\] {
width: 200px;
}
.w-\[220px\] {
width: 220px;
}
.w-\[271px\] {
width: 271px;
}
@@ -2379,7 +2380,7 @@
&>svg {
color: currentcolor;
}
h2, h3 {
> h2, > h3, > h4, > h5, > h6, > strong, > [data-title] {
grid-column-start: 2;
overflow: hidden;
display: -webkit-box;
@@ -2391,7 +2392,7 @@
--tw-tracking: var(--tracking-tight);
letter-spacing: var(--tracking-tight);
}
section {
> section {
grid-column-start: 2;
display: grid;
justify-items: start;
@@ -2421,7 +2422,7 @@
&>svg {
color: currentcolor;
}
section {
> section {
color: var(--color-destructive);
}
}
@@ -2793,7 +2794,6 @@
}
@layer components {
.button-group {
isolation: isolate;
display: inline-flex;
width: fit-content;
align-items: stretch;
@@ -3279,7 +3279,7 @@
margin-block: calc(var(--spacing) * 1);
border-color: var(--color-border);
}
&:not(:has([data-value]:not([aria-hidden='true'])))::before {
&:not(:has([role='menuitem']:not([aria-hidden='true'])))::before {
margin: calc(var(--spacing) * -1);
display: flex;
align-items: center;
@@ -3292,11 +3292,11 @@
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
}
&[data-empty]:not(:has([data-value]:not([aria-hidden='true'])))::before {
&[data-empty]:not(:has([role='menuitem']:not([aria-hidden='true'])))::before {
--tw-content: attr(data-empty);
content: var(--tw-content);
}
&:not([data-empty]):not(:has([data-value]:not([aria-hidden='true'])))::before {
&:not([data-empty]):not(:has([role='menuitem']:not([aria-hidden='true'])))::before {
--tw-content: 'No results found';
content: var(--tw-content);
}
@@ -3700,7 +3700,7 @@
}
}
@layer components {
:is(.form, .field) input[type='text'], :is(.form, .field) input[type='email'], :is(.form, .field) input[type='password'], :is(.form, .field) input[type='number'], :is(.form, .field) input[type='file'], :is(.form, .field) input[type='tel'], :is(.form, .field) input[type='url'], :is(.form, .field) input[type='search'], :is(.form, .field) input[type='date'], :is(.form, .field) input[type='datetime-local'], :is(.form, .field) input[type='month'], :is(.form, .field) input[type='week'], :is(.form, .field) input[type='time'], .input[type='text'], .input[type='email'], .input[type='password'], .input[type='number'], .input[type='file'], .input[type='tel'], .input[type='url'], .input[type='search'], .input[type='date'], .input[type='datetime-local'], .input[type='month'], .input[type='week'], .input[type='time'] {
:is(.form, .field) input[type='text']:not(:is(.select [data-popover] > header input[role='combobox'], .command > header input, .dialog > * > header input)), :is(.form, .field) input[type='email'], :is(.form, .field) input[type='password'], :is(.form, .field) input[type='number'], :is(.form, .field) input[type='file'], :is(.form, .field) input[type='tel'], :is(.form, .field) input[type='url'], :is(.form, .field) input[type='search']:not(:is(.select [data-popover] > header input[role='combobox'], .command > header input, .dialog > * > header input)), :is(.form, .field) input[type='date'], :is(.form, .field) input[type='datetime-local'], :is(.form, .field) input[type='month'], :is(.form, .field) input[type='week'], :is(.form, .field) input[type='time'], .input[type='text'], .input[type='email'], .input[type='password'], .input[type='number'], .input[type='file'], .input[type='tel'], .input[type='url'], .input[type='search'], .input[type='date'], .input[type='datetime-local'], .input[type='month'], .input[type='week'], .input[type='time'] {
display: flex;
height: calc(var(--spacing) * 9);
width: 100%;
@@ -4243,6 +4243,24 @@
*:not(select).select {
position: relative;
display: inline-flex;
& > button {
justify-content: space-between;
--tw-font-weight: var(--font-weight-normal);
font-weight: var(--font-weight-normal);
&[aria-invalid='true'] {
border-color: var(--color-destructive);
--tw-ring-color: var(--color-destructive);
@supports (color: color-mix(in lab, red, red)) {
--tw-ring-color: color-mix(in oklab, var(--color-destructive) 20%, transparent);
}
&:is(.dark *) {
--tw-ring-color: var(--color-destructive);
@supports (color: color-mix(in lab, red, red)) {
--tw-ring-color: color-mix(in oklab, var(--color-destructive) 40%, transparent);
}
}
}
}
[data-popover] {
padding: calc(var(--spacing) * 1);
[role='option'] {
@@ -5254,12 +5272,12 @@
text-overflow: ellipsis;
white-space: nowrap;
border-radius: var(--radius-md);
background-color: var(--color-primary);
background-color: var(--color-foreground);
padding-inline: calc(var(--spacing) * 3);
padding-block: calc(var(--spacing) * 1.5);
font-size: var(--text-xs);
line-height: var(--tw-leading, var(--text-xs--line-height));
color: var(--color-primary-foreground);
color: var(--color-background);
opacity: 0%;
transition-property: all;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -5534,6 +5552,11 @@
}
dd {
margin-top: calc(var(--spacing) * 2);
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
}
}
dl {
margin-block: calc(var(--spacing) * 0);
+3 -3
View File
@@ -76,7 +76,7 @@ toc:
Trash
</div>
{% endcall %}
</div>
</div>
{% endset %}
{{ code_preview("button-group-default", code_default | prettyHtml) }}
@@ -131,7 +131,7 @@ toc:
<button type="button" class="btn-icon-outline">{% lucide "plus" %}</button>
</div>
<div role="group" class="button-group">
<button type="button" class="btn-lg-outline">Small</button>
<button type="button" class="btn-lg-outline">Large</button>
<button type="button" class="btn-lg-outline">Button</button>
<button type="button" class="btn-lg-outline">Group</button>
<button type="button" class="btn-lg-icon-outline">{% lucide "plus" %}</button>
@@ -287,4 +287,4 @@ toc:
</div>
{% endset %}
{{ code_preview("button-group-popover", code_popover | prettyHtml) }}
{{ code_preview("button-group-popover", code_popover | prettyHtml) }}
+8 -1
View File
@@ -118,7 +118,14 @@ toc:
</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>
<dd>Option that can be selected. Should have a unique id if you use the <code>aria-activedescendant</code> attribute on the trigger. Optional attributes:
<ul>
<li><code>data-filter</code>: custom text used for filtering (if different from displayed content, e.g., <code>data-filter="united states"</code>)</li>
<li><code>data-keywords</code>: additional keywords for enhanced filtering (e.g., <code>data-keywords="bread fruit"</code>)</li>
<li><code>data-force</code>: forces the option to always display, regardless of filter (e.g., <code>data-force</code>)</li>
<li><code>aria-disabled="true"</code>: marks option as disabled (excluded from selection and keyboard navigation)</li>
</ul>
</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>
+39 -12
View File
@@ -33,15 +33,15 @@ toc:
{% macro command_items(suggestions_id="suggestions", settings_id="settings") %}
<div role="group" aria-labelledby="{{ suggestions_id }}">
<span role="heading" id="{{ suggestions_id }}">Suggestions</span>
<div role="menuitem" data-label="calendar" onclick="console.log('Open calendar')">
<div role="menuitem" onclick="console.log('Open calendar')">
{% lucide "calendar" %}
<span>Calendar</span>
</div>
<div role="menuitem" data-label="search-emoji" onclick="console.log('Search emoji')">
<div role="menuitem" onclick="console.log('Search emoji')">
{% lucide "smile" %}
<span>Search Emoji</span>
</div>
<div role="menuitem" data-label="calculator" aria-disabled="true">
<div role="menuitem" aria-disabled="true">
{% lucide "calculator" %}
<span>Calculator</span>
</div>
@@ -49,17 +49,17 @@ toc:
<hr role="separator" />
<div role="group" aria-labelledby="{{ settings_id }}">
<span role="heading" id="{{ settings_id }}">Settings</span>
<div role="menuitem" data-label="profile" onclick="console.log('Open profile')">
<div role="menuitem" onclick="console.log('Open profile')" data-filter="Profile">
{% lucide "user" %}
<span>Profile</span>
<kbd class="ml-auto text-muted-foreground bg-transparent tracking-widest">⌘P</kbd>
</div>
<div role="menuitem" data-label="billing" onclick="console.log('Open billing')">
<div role="menuitem" onclick="console.log('Open billing')" data-filter="Billing">
{% lucide "credit-card" %}
<span>Billing</span>
<kbd class="ml-auto text-muted-foreground bg-transparent tracking-widest">⌘B</kbd>
</div>
<div role="menuitem" data-label="settings" onclick="console.log('Open settings')">
<div role="menuitem" onclick="console.log('Open settings')" data-filter="Settings">
{% lucide "settings" %}
<span>Settings</span>
<kbd class="ml-auto text-muted-foreground bg-transparent tracking-widest">⌘S</kbd>
@@ -155,11 +155,13 @@ toc:
<dt><code class="highlight language-html">&lt;div role="menu"&gt;</code></dt>
<dd>The menu containing the command items. Should have <code>id="{ MENU_ID }"</code> refered to by the input's <code>aria-controls</code> attribute.
<dl>
<dt><code class="highlight language-html">&lt;div role="menuitem"&gt;</code></dt>
<dd>Command item that triggers an action when clicked. In a command-dialog, clicking a menuitem automatically closes the dialog unless the <code>data-keep-command-open</code> attribute is present. Optional attributes:
<dt><code class="highlight language-html">&lt;div role="menuitem"&gt;</code> or <code class="highlight language-html">&lt;a role="menuitem"&gt;</code></dt>
<dd>Command item that triggers an action when clicked. Use <code>&lt;a&gt;</code> for navigation links, or <code>&lt;div&gt;</code> for actions. In a command-dialog, clicking a menuitem automatically closes the dialog unless the <code>data-keep-command-open</code> attribute is present. Optional attributes:
<ul>
<li><code>data-label</code>: custom label for filtering (if different from text content)</li>
<li><code>data-keywords</code>: additional keywords for enhanced filtering</li>
<li><code>href</code>: If present on an <code>&lt;a&gt;</code>, the item navigates to that URL</li>
<li><code>data-filter</code>: custom text used for filtering (if different from displayed content, e.g., <code>data-filter="calendar event"</code>)</li>
<li><code>data-keywords</code>: additional keywords for enhanced filtering (e.g., <code>data-keywords="calendar event date"</code>)</li>
<li><code>data-force</code>: forces the item to always display, regardless of filter (e.g., <code>data-force</code>)</li>
<li><code>aria-disabled="true"</code>: marks item as disabled (excluded from keyboard navigation and filtering)</li>
</ul>
</dd>
@@ -209,17 +211,42 @@ toc:
</div>
{% set raw_code %}{% raw %}{% call command() %}
<div role="menuitem" data-label="calendar">
<div role="menuitem">
<svg>...</svg>
<span>Calendar</span>
</div>
<div role="menuitem" data-label="settings">
<div role="menuitem" data-keywords="config preferences">
<svg>...</svg>
<span>Settings</span>
</div>
{% endcall %}{% endraw %}{% endset %}
{{ code_block(raw_code, "jinja") }}
<section class="prose">
<p>You can also use the <code>items</code> parameter to pass an array of items:</p>
</section>
{% set icon_calendar %}{% lucide "calendar" %}{% endset %}
{% set icon_settings %}{% lucide "settings" %}{% endset %}
{% set raw_code_items %}{% raw %}{% set icon_calendar %}{% lucide "calendar" %}{% endset %}
{% set icon_settings %}{% lucide "settings" %}{% endset %}
{% set items = [
{ type: "group", label: "Suggestions", items: [
{ label: "Calendar", icon: icon_calendar, keywords: "date event" },
{ label: "Search Emoji", icon: icon_settings },
{ label: "Calculator", disabled: true }
]},
{ type: "separator" },
{ type: "group", label: "Settings", items: [
{ label: "Profile", url: "/profile", icon: icon_settings, keywords: "user account" },
{ label: "Billing", url: "/billing", icon: icon_settings },
{ label: "Settings", url: "/settings", icon: icon_settings }
]}
] %}
{% call command(items=items) %}{% endcall %}{% endraw %}{% endset %}
{{ code_block(raw_code_items, "jinja") }}
<h2 id="examples"><a href="#examples">Examples</a></h2>
<h3 id="example-dialog"><a href="#example-dialog">Dialog</a></h3>
+189 -22
View File
@@ -32,6 +32,8 @@ toc:
id: example-disabled
- label: With icon
id: example-with-icon
- label: Multiple
id: example-multiple
---
{% from "macros/code_preview.njk" import code_preview %}
@@ -61,7 +63,7 @@ toc:
{ 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: "grapes", label: "Grapes" },
{ type: "item", value: "pineapple", label: "Pineapple" }
]
}
@@ -120,44 +122,70 @@ toc:
<section class="prose">
<dl>
<dt><code class="highlight language-html">&lt;div class="select"&gt;</code></dt>
<dd>Wraps around the entire component.
<dd>
<p>Wraps around the entire component. Can have the following attributes:</p>
<ul>
<li><code>data-placeholder="{ TEXT }"</code> <span class="badge-secondary">Optional</span>: placeholder text shown when no options are selected (multiselect only).</li>
<li><code>data-close-on-select="true"</code> <span class="badge-secondary">Optional</span>: closes the popover when selecting an option in multiselect mode.</li>
</ul>
<dl>
<dt><code class="highlight language-html">&lt;button type="button" popovertarget="{ POPOVER_ID }"&gt;</code></dt>
<dt><code class="highlight language-html">&lt;button type="button"&gt;</code></dt>
<dd>
<p>The trigger to open the popover, can also have the following attributes:</p>
<p>The trigger to open the popover. Should 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>
<li><code>aria-activedescendant="{ OPTION_ID }"</code> <span class="badge-secondary">Optional</span>: 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.
<dt><code class="highlight language-html">&lt;div data-popover aria-hidden="true" id="{ POPOVER_ID }"&gt;</code></dt>
<dd>The popover content. You can set up the side and alignment using the <code>data-side</code> and <code>data-align</code> attributes (see <a href="/components/popover">Popover</a> component).
<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:
<dd>
<p>The listbox containing the options. Should have the following attributes:</p>
<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>
<li><code>aria-multiselectable="true"</code> <span class="badge-secondary">Optional</span>: enables multiple selection mode.</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>
<dd>
<p>Option that can be selected. Should have the following attributes:</p>
<ul>
<li><code>id="{ OPTION_ID }"</code> <span class="badge-secondary">Optional</span>: unique id for this option (needed if you use <code>aria-activedescendant</code> on the trigger).</li>
<li><code>data-value="{ VALUE }"</code>: the value for this option.</li>
<li><code>data-label="{ LABEL }"</code> <span class="badge-secondary">Optional</span>: the text label to use for this option when selected in multiple mode. If not provided, the option's text content will be used (HTML stripped).</li>
<li><code>aria-selected="true"</code> <span class="badge-secondary">Optional</span>: indicates this option is selected.</li>
</ul>
</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>
<dt><code class="highlight language-html">&lt;span role="heading"&gt;</code> <span class="badge-secondary">Optional</span></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>
<dt><code class="highlight language-html">&lt;input type="hidden" name="{ NAME }" value="{ VALUE }"&gt;</code></dt>
<dd>
<p>The hidden input that holds the selected value.</p>
<p>For single-select: contains the selected option's value as a string.</p>
<p>For multiselect: contains a <strong>JSON array</strong> of selected values (e.g., <code>["apple","banana"]</code>). When no options are selected, contains an empty array (<code>[]</code>).</p>
<p><strong>Backend handling:</strong> Parse the JSON value on the server side. For example:</p>
<ul>
<li><strong>Python/Flask:</strong> <code>values = json.loads(request.form.get('field', '[]'))</code></li>
<li><strong>PHP:</strong> <code>$values = json_decode($_POST['field'] ?? '[]', true);</code></li>
<li><strong>Node.js:</strong> <code>const values = JSON.parse(req.body.field || '[]');</code></li>
<li><strong>Ruby/Rails:</strong> <code>values = JSON.parse(params[:field] || '[]')</code></li>
</ul>
</dd>
</dl>
</section>
@@ -170,25 +198,103 @@ toc:
<dt><code>basecoat:popover</code></dt>
<dd>When the popover opens, the component dispatches a custom (non-bubbling) <code>basecoat:popover</code> event on <code>document</code>. Other popover components (Combobox, Dropdown Menu, Popover and Select) listen for this to close any open popovers.</dd>
<dt><code>change</code></dt>
<dd>When the selected value changes, the component dispatches a custom (bubbling) <code>change</code> event on itself, with the selected value in <code>event.detail</code> (e.g. <code>{ detail: { value: "something" }}</code>).</dd>
<dd>
<p>When the selected value changes, the component dispatches a custom (bubbling) <code>change</code> event on itself, with the selected value in <code>event.detail.value</code>:</p>
<ul>
<li>Single select: <code>{ detail: { value: "something" }}</code> (string)</li>
<li>Multiple select: <code>{ detail: { value: ["item1", "item2"] }}</code> (array)</li>
</ul>
</dd>
</dl>
</section>
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript methods</a></h4>
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript methods and properties</a></h4>
<section class="prose">
<dl>
<dt><code>selectByValue</code></dt>
<dt><code>value</code> (property)</dt>
<dd>
<p>You can call this method on the component after it is initialized to select an option by value (i.e. the option with the matching <code>data-value</code> attribute):</p>
<p>Get or set the current value. For single-select, this is a string. For multiselect, this is an array. The multiselect setter accepts both strings and arrays.</p>
{% set code_script %}<script>
{% set code_value %}<script>
const selectComponent = document.querySelector('#my-select');
selectComponent.addEventListener('basecoat:initialized', () => {
selectComponent.selectByValue('apple');
// Get value
console.log(selectComponent.value); // 'apple'
// Set value (single-select)
selectComponent.value = 'banana';
// Set value (multiselect - accepts string or array)
selectComponent.value = ['apple', 'banana'];
selectComponent.value = 'apple'; // normalized to ['apple']
});
</script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
{{ code_block(code_value | prettyHtml, "html") }}
</dd>
<dt><code>select(value)</code></dt>
<dd>
<p>Selects an option by value (i.e. the option with the matching <code>data-value</code> attribute). For single-select, this will close the popover. For multiselect, this adds the value to the selection if not already selected.</p>
{% set code_select %}<script>
const selectComponent = document.querySelector('#my-select');
selectComponent.addEventListener('basecoat:initialized', () => {
selectComponent.select('apple');
});
</script>{% endset %}
{{ code_block(code_select | prettyHtml, "html") }}
</dd>
<dt><code>deselect(value)</code> <span class="badge-secondary">Multiselect only</span></dt>
<dd>
<p>Removes a specific value from the selection. Only available when the component is in multiselect mode (<code>aria-multiselectable="true"</code>).</p>
{% set code_deselect %}<script>
const selectComponent = document.querySelector('#my-multiselect');
selectComponent.addEventListener('basecoat:initialized', () => {
selectComponent.deselect('apple');
});
</script>{% endset %}
{{ code_block(code_deselect | prettyHtml, "html") }}
</dd>
<dt><code>toggle(value)</code> <span class="badge-secondary">Multiselect only</span></dt>
<dd>
<p>Toggles a specific value in the selection (adds if not present, removes if present). Only available when the component is in multiselect mode (<code>aria-multiselectable="true"</code>).</p>
{% set code_toggle %}<script>
const selectComponent = document.querySelector('#my-multiselect');
selectComponent.addEventListener('basecoat:initialized', () => {
selectComponent.toggle('apple'); // adds if not selected, removes if selected
});
</script>{% endset %}
{{ code_block(code_toggle | prettyHtml, "html") }}
</dd>
<dt><code>selectAll()</code> <span class="badge-secondary">Multiselect only</span></dt>
<dd>
<p>Selects all available options. Only available when the component is in multiselect mode (<code>aria-multiselectable="true"</code>).</p>
{% set code_script_all %}<script>
const selectComponent = document.querySelector('#my-multiselect');
selectComponent.addEventListener('basecoat:initialized', () => {
selectComponent.selectAll();
});
</script>{% endset %}
{{ code_block(code_script_all | prettyHtml, "html") }}
</dd>
<dt><code>selectNone()</code> <span class="badge-secondary">Multiselect only</span></dt>
<dd>
<p>Deselects all options. Only available when the component is in multiselect mode (<code>aria-multiselectable="true"</code>).</p>
{% set code_script_none %}<script>
const selectComponent = document.querySelector('#my-multiselect');
selectComponent.addEventListener('basecoat:initialized', () => {
selectComponent.selectNone();
});
</script>{% endset %}
{{ code_block(code_script_none | prettyHtml, "html") }}
</dd>
<dt><code>selectByValue(value)</code></dt>
<dd>
<p><strong>Deprecated:</strong> Alias for <code>select(value)</code>. Kept for backward compatibility.</p>
</dd>
</dl>
</section>
@@ -204,11 +310,11 @@ toc:
Use Nunjucks or Jinja macros
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/jinja/dialog.html.jinja" target="_blank">
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/jinja/select.html.jinja" target="_blank">
Jinja macro
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/nunjucks/dialog.njk" target="_blank">
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/nunjucks/select.njk" target="_blank">
Nunjucks macro
{% lucide "arrow-right" %}
</a>
@@ -259,6 +365,23 @@ toc:
{{ code_preview("select-disabled", code | prettyHtml) }}
<h3 id="example-invalid-state"><a href="#example-invalid-state">Invalid State</a></h3>
{% set code %}
{{ select(
trigger_attrs={"aria-invalid": "true"},
items=[
{ value: "", label: "Select a role" },
{ value: "admin", label: "Admin" },
{ value: "editor", label: "Editor" },
{ value: "viewer", label: "Viewer" },
{ value: "guest", label: "Guest" }
]
) }}
{% endset %}
{{ code_preview("select-disabled", code | prettyHtml) }}
<h3 id="example-with-icon"><a href="#example-with-icon">With icon</a></h3>
{% set code %}
@@ -287,4 +410,48 @@ toc:
{% endcall %}
{% endset %}
{{ code_preview("select-scrollable", code | prettyHtml) }}
{{ code_preview("select-with-icons", code | prettyHtml) }}
<h3 id="example-multiple"><a href="#example-multiple">Multiple</a></h3>
<section class="prose">
<p>Enable multiple selection by setting adding <code>aria-multiselectable="true"</code> to the <code>listbox</code>. The selected options are displayed as a comma-separated list in the trigger button. Use <code>data-label</code> to specify clean text labels for options that contain HTML (like icons), otherwise the text content will be extracted automatically.</p>
</section>
{% set code %}
{% call select(
id="select-multiple",
name="chart-types",
multiple=true,
placeholder="Select chart types...",
selected=["bar", "line"],
trigger_attrs={"class": "w-[220px]"}
) %}
<div role="option" data-value="bar" data-label="Bar">
<span class="flex items-center gap-2">
{% lucide "chart-bar", {"class": "text-muted-foreground"} %}
Bar
</span>
</div>
<div role="option" data-value="line" data-label="Line">
<span class="flex items-center gap-2">
{% lucide "chart-line", {"class": "text-muted-foreground"} %}
Line
</span>
</div>
<div role="option" data-value="pie" data-label="Pie">
<span class="flex items-center gap-2">
{% lucide "chart-pie", {"class": "text-muted-foreground"} %}
Pie
</span>
</div>
<div role="option" data-value="area" data-label="Area">
<span class="flex items-center gap-2">
{% lucide "chart-area", {"class": "text-muted-foreground"} %}
Area
</span>
</div>
{% endcall %}
{% endset %}
{{ code_preview("select-multiple", code | prettyHtml) }}
+11 -2
View File
@@ -123,9 +123,9 @@ toc:
<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>
<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. Set <code>aria-current="page"</code> on the active link.</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>
<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. If your app uses buttons for navigation, set <code>aria-current="page"</code> on the active item.</dd>
<dt><code class="highlight language-html">&lt;details&gt;</code></dt>
<dd>Collapsible section.
<dl>
@@ -154,6 +154,15 @@ toc:
</dl>
</section>
{% set code_current %}<!-- Server-side or router-driven current link -->
<nav>
<ul>
<li><a href="/my-path?page=overview">Overview</a></li>
<li><a href="/my-path?page=settings" aria-current="page">Settings</a></li>
</ul>
</nav>{% endset %}
{{ code_block(code_current | prettyHtml, "html") }}
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</a></h4>
<section class="prose">
+1 -1
View File
@@ -144,7 +144,7 @@ import 'basecoat-css/tabs';{% endraw %}{% endset %}
<h2 id="install-js"><a href="#install-js">Components with JavaScript</a></h2>
<div class="prose">
<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><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>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "basecoat",
"version": "0.3.5",
"version": "0.3.11",
"description": "shadcn/ui for the rest of us",
"author": "hunvreus",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "basecoat-cli",
"version": "0.3.5",
"version": "0.3.11",
"description": "Add Basecoat components to your project",
"author": "hunvreus",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "basecoat-css",
"version": "0.3.5",
"version": "0.3.11",
"description": "Tailwind CSS for Basecoat components",
"author": {
"name": "hunvreus",
+26 -12
View File
@@ -154,11 +154,16 @@
.alert-destructive {
@apply relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current;
h2,
h3 {
> h2,
> h3,
> h4,
> h5,
> h6,
> strong,
> [data-title] {
@apply col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight;
}
section {
> section {
@apply text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed;
ul {
@@ -172,7 +177,7 @@
.alert-destructive {
@apply text-destructive bg-card [&>svg]:text-current;
section {
> section {
@apply text-destructive;
}
}
@@ -383,7 +388,8 @@
/* Button Group */
@layer components {
.button-group {
@apply inline-flex w-fit items-stretch isolate;
@apply inline-flex w-fit items-stretch;
/* isolate */
> *:focus-visible,
> :is(.dropdown-menu, .popover, .select) > button:focus-visible {
@@ -572,13 +578,13 @@
[role='separator'] {
@apply border-border -mx-1 my-1;
}
&:not(:has([data-value]:not([aria-hidden='true'])))::before {
&:not(:has([role='menuitem']:not([aria-hidden='true'])))::before {
@apply flex items-center justify-center py-6 px-3 text-sm truncate -m-1;
}
&[data-empty]:not(:has([data-value]:not([aria-hidden='true'])))::before {
&[data-empty]:not(:has([role='menuitem']:not([aria-hidden='true'])))::before {
@apply content-[attr(data-empty)];
}
&:not([data-empty]):not(:has([data-value]:not([aria-hidden='true'])))::before {
&:not([data-empty]):not(:has([role='menuitem']:not([aria-hidden='true'])))::before {
@apply content-['No_results_found'];
}
}
@@ -737,14 +743,14 @@
/* Input */
@layer components {
:is(.form, .field) input[type='text'],
:is(.form, .field) input[type='text']:not(:is(.select [data-popover] > header input[role='combobox'], .command > header input, .dialog > * > header input)),
:is(.form, .field) input[type='email'],
:is(.form, .field) input[type='password'],
:is(.form, .field) input[type='number'],
:is(.form, .field) input[type='file'],
:is(.form, .field) input[type='tel'],
:is(.form, .field) input[type='url'],
:is(.form, .field) input[type='search'],
:is(.form, .field) input[type='search']:not(:is(.select [data-popover] > header input[role='combobox'], .command > header input, .dialog > * > header input)),
:is(.form, .field) input[type='date'],
:is(.form, .field) input[type='datetime-local'],
:is(.form, .field) input[type='month'],
@@ -936,6 +942,14 @@
}
*:not(select).select {
@apply relative inline-flex;
& > button {
@apply justify-between font-normal;
&[aria-invalid='true'] {
@apply ring-destructive/20 dark:ring-destructive/40 border-destructive;
}
}
[data-popover] {
@apply p-1;
@@ -1245,7 +1259,7 @@
@apply relative;
&:before {
@apply absolute content-[attr(data-tooltip)] bg-primary text-primary-foreground z-[60] truncate max-w-xs w-fit rounded-md px-3 py-1.5 text-xs invisible opacity-0 scale-95 transition-all pointer-events-none;
@apply absolute content-[attr(data-tooltip)] bg-foreground text-background z-[60] truncate max-w-xs w-fit rounded-md px-3 py-1.5 text-xs invisible opacity-0 scale-95 transition-all pointer-events-none;
}
&:hover:before {
@apply visible opacity-100 scale-100;
@@ -1294,4 +1308,4 @@
}
}
}
}
}
+36 -17
View File
@@ -143,10 +143,10 @@
Renders a list of items for the command component.
@param items {array} - Array of items to render. Each item can be:
- { type: "item", label: "Text", content: "HTML", keywords: "search terms", disabled: true, attrs: {} }
- { type: "item", label: "Text or HTML", icon: "HTML", url: "href", keywords: "search terms", disabled: true, attrs: {} }
- { type: "group", label: "Group Title", items: [...] }
- { type: "separator" }
Note: Use attrs to add data-label for alternative search text if needed.
Note: Use attrs to add data-label for alternative search text if needed. If url is provided, renders as <a role="menuitem"> instead of <div role="menuitem">. Label supports HTML (marked as safe).
@param parent_id_prefix {string} [optional] - The prefix for the item id.
#}
{% macro render_command_items(items, parent_id_prefix="items") %}
@@ -163,25 +163,44 @@
{% endfor %}
{% endif %}
>
<span role="heading" id="{{ group_label_id }}">{{ item.label }}</span>
{{ render_command_items(item.items, item_id) if item.items }}
<span role="heading" id="{{ group_label_id }}">{{ item.label | safe }}</span>
{{ render_command_items(item['items'], 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="menuitem"
{% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
{% if item.disabled %}aria-disabled="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ item.content | safe if item.content else item.label }}
</div>
{% if item.url %}
<a
id="{{ item_id }}"
href="{{ item.url }}"
role="menuitem"
{% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
{% if item.disabled %}aria-disabled="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{% if key != "href" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label | safe }}
</a>
{% else %}
<div
id="{{ item_id }}"
role="menuitem"
{% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
{% if item.disabled %}aria-disabled="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label | safe }}
</div>
{% endif %}
{% endif %}
{% endfor %}
{% endmacro %}
+3 -5
View File
@@ -83,12 +83,10 @@
</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 type="button" aria-label="Close dialog" onclick="this.closest('dialog').close()">
<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>
</form>
{% endif %}
</div>
</dialog>
{% endmacro %}
{% endmacro %}
+4 -2
View File
@@ -86,7 +86,7 @@
{% endif %}
>
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
{{ render_dropdown_items(item.items, item_id) if item.items }}
{{ render_dropdown_items(item['items'], item_id) if item['items'] }}
</div>
{% elif item.type == "separator" %}
<hr role="separator" />
@@ -102,6 +102,7 @@
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label | safe }}
</a>
{% else %}
@@ -114,9 +115,10 @@
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label | safe }}
</div>
{% endif %}
{% endif %}
{% endfor %}
{% endmacro %}
{% endmacro %}
+40 -17
View File
@@ -2,8 +2,11 @@
Renders a select or combobox component.
@param id {string} [optional] - Unique identifier for the select component.
@param selected {string} [optional] - The initially selected value.
@param selected {string|array} [optional] - The initially selected value(s).
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
@param multiple {boolean} [optional] [default=false] - Enables multiple selection mode.
@param placeholder {string} [optional] - Placeholder text shown when no options are selected (multiple mode only).
@param close_on_select {boolean} [optional] [default=false] - Closes the popover when selecting an option in multiple mode.
@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 popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
@@ -17,6 +20,9 @@
selected=None,
name=None,
items=[],
multiple=false,
placeholder=None,
close_on_select=false,
main_attrs={},
trigger_attrs={},
popover_attrs={},
@@ -27,43 +33,59 @@
) %}
{% set id = id or ("select-" + (range(100000, 999999) | random | string)) %}
{% if selected is defined and selected is iterable and selected is not string %}
{% set selectedArray = selected %}
{% elif selected is defined %}
{% set selectedArray = [selected] %}
{% else %}
{% set selectedArray = [] %}
{% endif %}
{% set first_option = namespace(item=None) %}
{% set selected_option = namespace(item=None) %}
{% set selected_options = namespace(items=[]) %}
{% if items %}
{% for item in items %}
{% if item.type == "group" %}
{% for sub_item in item.items %}
{% 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 %}
{% if sub_item.value in selectedArray %}
{% set selected_options.items = selected_options.items + [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 %}
{% if item.value in selectedArray %}
{% set selected_options.items = selected_options.items + [item] %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% set default_option = selected_option.item or first_option.item or None %}
{% set default_option = (selected_options.items[0] if selected_options.items else first_option.item) %}
{% set labels = [] %}
{% if multiple and selected_options.items %}
{% for opt in selected_options.items %}
{% set labels = labels + [opt.label] %}
{% endfor %}
{% endif %}
{% set default_label = (', '.join(labels) if (multiple and selected_options.items) else ((placeholder or '') if (multiple and not selected_options.items) else (default_option.label if default_option else ''))) %}
<div
id="{{ id }}"
class="select {{ main_attrs.class }}"
{% if multiple and placeholder %}data-placeholder="{{ placeholder }}"{% endif %}
{% if multiple and close_on_select %}data-close-on-select="true"{% endif %}
{% 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 }}"
class="btn-outline {{ trigger_attrs.class }}"
id="{{ id }}-trigger"
aria-haspopup="listbox"
aria-expanded="false"
@@ -72,7 +94,7 @@
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<span class="truncate">{{ default_option.label }}</span>
<span class="truncate">{{ default_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 %}
@@ -110,12 +132,13 @@
id="{{ id }}-listbox"
aria-orientation="vertical"
aria-labelledby="{{ id }}-trigger"
{% if multiple %}aria-multiselectable="true"{% endif %}
{% for key, value in listbox_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if items %}
{{ render_select_items(items, default_option.value, id ~ "-items" if id else "items") }}
{{ render_select_items(items, selectedArray, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
@@ -124,7 +147,7 @@
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{{ (default_option.value if default_option) or '' }}"
value="{% if multiple %}{{ selectedArray | tojson }}{% else %}{{ (default_option.value if default_option) or '' }}{% endif %}"
{% for key, value in input_attrs.items() %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
@@ -138,7 +161,7 @@
@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, selected, parent_id_prefix="items") %}
{% macro render_select_items(items, selectedArray, parent_id_prefix="items") %}
{% for item in items %}
{% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
{% if item.type == "group" %}
@@ -153,7 +176,7 @@
{% endif %}
>
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
{{ render_select_items(item.items, selected, item_id) if item.items }}
{{ render_select_items(item['items'], selectedArray, item_id) if item['items'] }}
</div>
{% elif item.type == "separator" %}
<hr role="separator" />
@@ -161,8 +184,8 @@
<div
id="{{ item_id }}"
role="option"
data-value="{{ item.value }}"
{% if selected == item.value %}aria-selected="true"{% endif %}
{% if item.value is defined and item.value is not none %}data-value="{{ item.value }}"{% endif %}
{% if item.value in selectedArray %}aria-selected="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
@@ -173,4 +196,4 @@
</div>
{% endif %}
{% endfor %}
{% endmacro %}
{% endmacro %}
+2 -2
View File
@@ -117,7 +117,7 @@
>
<summary aria-controls="submenu-{{ item_id }}-content">
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label }}
{{ item.label | safe }}
</summary>
<ul id="submenu-{{ item_id }}-content">
{{ render_sidebar_content(item["items"], item_id) if item["items"] }}
@@ -136,7 +136,7 @@
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
<span>{{ item.label }}</span>
<span>{{ item.label | safe }}</span>
</a>
</li>
{% endif %}
+15 -3
View File
@@ -46,9 +46,21 @@
visibleMenuItems = [];
allMenuItems.forEach(item => {
const itemText = (item.dataset.label || item.textContent).trim().toLowerCase();
const keywords = (item.dataset.keywords || '').toLowerCase();
const matches = itemText.includes(searchTerm) || keywords.includes(searchTerm);
if (item.hasAttribute('data-force')) {
item.setAttribute('aria-hidden', 'false');
if (menuItems.includes(item)) {
visibleMenuItems.push(item);
}
return;
}
const itemText = (item.dataset.filter || item.textContent).trim().toLowerCase();
const keywordList = (item.dataset.keywords || '')
.toLowerCase()
.split(/[\s,]+/)
.filter(Boolean);
const matchesKeyword = keywordList.some(keyword => keyword.includes(searchTerm));
const matches = itemText.includes(searchTerm) || matchesKeyword;
item.setAttribute('aria-hidden', String(!matches));
if (matches && menuItems.includes(item)) {
visibleMenuItems.push(item);
+213 -57
View File
@@ -3,31 +3,38 @@
const trigger = selectComponent.querySelector(':scope > button');
const selectedLabel = trigger.querySelector(':scope > span');
const popover = selectComponent.querySelector(':scope > [data-popover]');
const listbox = popover.querySelector('[role="listbox"]');
const listbox = popover ? popover.querySelector('[role="listbox"]') : null;
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');
if (!input) missing.push('input');
console.error(`Select component initialisation failed. Missing element(s): ${missing.join(', ')}`, selectComponent);
return;
}
const allOptions = Array.from(listbox.querySelectorAll('[role="option"]'));
const options = allOptions.filter(opt => opt.getAttribute('aria-disabled') !== 'true');
let visibleOptions = [...options];
let activeIndex = -1;
const isMultiple = listbox.getAttribute('aria-multiselectable') === 'true';
const selectedOptions = isMultiple ? new Set() : null;
const placeholder = isMultiple ? (selectComponent.dataset.placeholder || '') : null;
const closeOnSelect = selectComponent.dataset.closeOnSelect === 'true';
const getValue = (opt) => opt.dataset.value ?? opt.textContent.trim();
const setActiveOption = (index) => {
if (activeIndex > -1 && options[activeIndex]) {
options[activeIndex].classList.remove('active');
}
activeIndex = index;
if (activeIndex > -1) {
const activeOption = options[activeIndex];
activeOption.classList.add('active');
@@ -46,91 +53,174 @@
return parseFloat(style.transitionDuration) > 0 || parseFloat(style.transitionDelay) > 0;
};
const updateValue = (option, triggerEvent = true) => {
if (option) {
selectedLabel.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');
if (triggerEvent) {
const event = new CustomEvent('change', {
detail: { value: option.dataset.value },
bubbles: true
});
selectComponent.dispatchEvent(event);
const updateValue = (optionOrOptions, triggerEvent = true) => {
let value;
if (isMultiple) {
const opts = Array.isArray(optionOrOptions) ? optionOrOptions : [];
selectedOptions.clear();
opts.forEach(opt => selectedOptions.add(opt));
// Get selected options in DOM order
const selected = options.filter(opt => selectedOptions.has(opt));
if (selected.length === 0) {
selectedLabel.textContent = placeholder;
selectedLabel.classList.add('text-muted-foreground');
} else {
selectedLabel.textContent = selected.map(opt => opt.dataset.label || opt.textContent.trim()).join(', ');
selectedLabel.classList.remove('text-muted-foreground');
}
value = selected.map(getValue);
input.value = JSON.stringify(value);
} else {
const option = optionOrOptions;
if (!option) return;
selectedLabel.innerHTML = option.innerHTML;
value = getValue(option);
input.value = value;
}
options.forEach(opt => {
const isSelected = isMultiple ? selectedOptions.has(opt) : opt === optionOrOptions;
if (isSelected) {
opt.setAttribute('aria-selected', 'true');
} else {
opt.removeAttribute('aria-selected');
}
});
if (triggerEvent) {
selectComponent.dispatchEvent(new CustomEvent('change', {
detail: { value },
bubbles: true
}));
}
};
const closePopover = (focusOnTrigger = true) => {
if (popover.getAttribute('aria-hidden') === 'true') return;
if (filter) {
const resetFilter = () => {
filter.value = '';
visibleOptions = [...options];
allOptions.forEach(opt => opt.setAttribute('aria-hidden', 'false'));
};
if (hasTransition()) {
popover.addEventListener('transitionend', resetFilter, { once: true });
} else {
resetFilter();
}
}
if (focusOnTrigger) trigger.focus();
popover.setAttribute('aria-hidden', 'true');
trigger.setAttribute('aria-expanded', 'false');
setActiveOption(-1);
}
const selectOption = (option) => {
if (!option) return;
const oldValue = input.value;
const newValue = option.dataset.value;
if (newValue != null && newValue !== oldValue) {
updateValue(option);
}
closePopover();
};
const selectByValue = (value) => {
const option = options.find(opt => opt.dataset.value === value);
selectOption(option);
const toggleMultipleValue = (option) => {
if (selectedOptions.has(option)) {
selectedOptions.delete(option);
} else {
selectedOptions.add(option);
}
updateValue(options.filter(opt => selectedOptions.has(opt)));
};
const select = (value) => {
if (isMultiple) {
const option = options.find(opt => getValue(opt) === value && !selectedOptions.has(opt));
if (!option) return;
selectedOptions.add(option);
updateValue(options.filter(opt => selectedOptions.has(opt)));
} else {
const option = options.find(opt => getValue(opt) === value);
if (!option) return;
if (input.value !== value) {
updateValue(option);
}
closePopover();
}
};
const deselect = (value) => {
if (!isMultiple) return;
const option = options.find(opt => getValue(opt) === value && selectedOptions.has(opt));
if (!option) return;
selectedOptions.delete(option);
updateValue(options.filter(opt => selectedOptions.has(opt)));
};
const toggle = (value) => {
if (!isMultiple) return;
const option = options.find(opt => getValue(opt) === value);
if (!option) return;
toggleMultipleValue(option);
};
if (filter) {
const filterOptions = () => {
const searchTerm = filter.value.trim().toLowerCase();
setActiveOption(-1);
visibleOptions = [];
allOptions.forEach(option => {
const optionText = (option.dataset.label || option.textContent).trim().toLowerCase();
const matches = optionText.includes(searchTerm);
if (option.hasAttribute('data-force')) {
option.setAttribute('aria-hidden', 'false');
if (options.includes(option)) {
visibleOptions.push(option);
}
return;
}
const optionText = (option.dataset.filter || option.textContent).trim().toLowerCase();
const keywordList = (option.dataset.keywords || '')
.toLowerCase()
.split(/[\s,]+/)
.filter(Boolean);
const matchesKeyword = keywordList.some(keyword => keyword.includes(searchTerm));
const matches = optionText.includes(searchTerm) || matchesKeyword;
option.setAttribute('aria-hidden', String(!matches));
if (matches && options.includes(option)) {
visibleOptions.push(option);
}
});
};
filter.addEventListener('input', filterOptions);
}
let initialOption = options.find(opt => opt.dataset.value === input.value);
if (!initialOption) {
initialOption = options.find(opt => opt.dataset.value !== undefined) ?? options[0];
}
// Initialization
if (isMultiple) {
const ariaSelected = options.filter(opt => opt.getAttribute('aria-selected') === 'true');
try {
const parsed = JSON.parse(input.value || '[]');
const validValues = new Set(options.map(getValue));
const initialValues = Array.isArray(parsed) ? parsed.filter(v => validValues.has(v)) : [];
updateValue(initialOption, false);
const initialOptions = [];
if (initialValues.length > 0) {
// Match values to options in order, allowing duplicates
initialValues.forEach(val => {
const opt = options.find(o => getValue(o) === val && !initialOptions.includes(o));
if (opt) initialOptions.push(opt);
});
} else {
initialOptions.push(...ariaSelected);
}
updateValue(initialOptions, false);
} catch (e) {
updateValue(ariaSelected, false);
}
} else {
const initialOption = options.find(opt => getValue(opt) === input.value) || options[0];
if (initialOption) updateValue(initialOption, false);
}
const handleKeyNavigation = (event) => {
const isPopoverOpen = popover.getAttribute('aria-hidden') === 'false';
@@ -146,17 +236,28 @@
}
return;
}
event.preventDefault();
if (event.key === 'Escape') {
closePopover();
return;
}
if (event.key === 'Enter') {
if (activeIndex > -1) {
selectOption(options[activeIndex]);
const option = options[activeIndex];
if (isMultiple) {
toggleMultipleValue(option);
if (closeOnSelect) {
closePopover();
}
} else {
if (input.value !== getValue(option)) {
updateValue(option);
}
closePopover();
}
}
return;
}
@@ -222,7 +323,7 @@
document.dispatchEvent(new CustomEvent('basecoat:popover', {
detail: { source: selectComponent }
}));
if (filter) {
if (hasTransition()) {
popover.addEventListener('transitionend', () => {
@@ -235,7 +336,7 @@
popover.setAttribute('aria-hidden', 'false');
trigger.setAttribute('aria-expanded', 'true');
const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
if (selectedOption) {
setActiveOption(options.indexOf(selectedOption));
@@ -254,8 +355,28 @@
listbox.addEventListener('click', (event) => {
const clickedOption = event.target.closest('[role="option"]');
if (clickedOption) {
selectOption(clickedOption);
if (!clickedOption) return;
const option = options.find(opt => opt === clickedOption);
if (!option) return;
if (isMultiple) {
toggleMultipleValue(option);
if (closeOnSelect) {
closePopover();
} else {
setActiveOption(options.indexOf(option));
if (filter) {
filter.focus();
} else {
trigger.focus();
}
}
} else {
if (input.value !== getValue(option)) {
updateValue(option);
}
closePopover();
}
});
@@ -272,8 +393,43 @@
});
popover.setAttribute('aria-hidden', 'true');
selectComponent.selectByValue = selectByValue;
// Public API
Object.defineProperty(selectComponent, 'value', {
get: () => {
if (isMultiple) {
return options.filter(opt => selectedOptions.has(opt)).map(getValue);
} else {
return input.value;
}
},
set: (val) => {
if (isMultiple) {
const values = Array.isArray(val) ? val : (val != null ? [val] : []);
const opts = [];
values.forEach(v => {
const opt = options.find(o => getValue(o) === v && !opts.includes(o));
if (opt) opts.push(opt);
});
updateValue(opts);
} else {
const option = options.find(opt => getValue(opt) === val);
if (option) {
updateValue(option);
closePopover();
}
}
}
});
selectComponent.select = select;
selectComponent.selectByValue = select; // Backward compatibility alias
if (isMultiple) {
selectComponent.deselect = deselect;
selectComponent.toggle = toggle;
selectComponent.selectAll = () => updateValue(options);
selectComponent.selectNone = () => updateValue([]);
}
selectComponent.dataset.selectInitialized = true;
selectComponent.dispatchEvent(new CustomEvent('basecoat:initialized'));
};
@@ -281,4 +437,4 @@
if (window.basecoat) {
window.basecoat.register('select', 'div.select:not([data-select-initialized])', initSelect);
}
})();
})();
+1 -36
View File
@@ -1,21 +1,4 @@
(() => {
// 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'));
};
window.history.__basecoatPatched = true;
}
const initSidebar = (sidebarComponent) => {
const initialOpen = sidebarComponent.dataset.initialOpen !== 'false';
const initialMobileOpen = sidebarComponent.dataset.initialMobileOpen === 'true';
@@ -25,20 +8,6 @@
? (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) {
@@ -89,11 +58,7 @@
}
});
window.addEventListener('popstate', updateCurrentPageLinks);
window.addEventListener('basecoat:locationchange', updateCurrentPageLinks);
updateState();
updateCurrentPageLinks();
sidebarComponent.dataset.sidebarInitialized = true;
sidebarComponent.dispatchEvent(new CustomEvent('basecoat:initialized'));
};
@@ -101,4 +66,4 @@
if (window.basecoat) {
window.basecoat.register('sidebar', '.sidebar:not([data-sidebar-initialized])', initSidebar);
}
})();
})();
+35 -16
View File
@@ -143,10 +143,10 @@
Renders a list of items for the command component.
@param items {array} - Array of items to render. Each item can be:
- { type: "item", label: "Text", content: "HTML", keywords: "search terms", disabled: true, attrs: {} }
- { type: "item", label: "Text or HTML", icon: "HTML", url: "href", keywords: "search terms", disabled: true, attrs: {} }
- { type: "group", label: "Group Title", items: [...] }
- { type: "separator" }
Note: Use attrs to add data-label for alternative search text if needed.
Note: Use attrs to add data-label for alternative search text if needed. If url is provided, renders as <a role="menuitem"> instead of <div role="menuitem">. Label supports HTML (marked as safe).
@param parent_id_prefix {string} [optional] - The prefix for the item id.
#}
{% macro render_command_items(items, parent_id_prefix="items") %}
@@ -163,25 +163,44 @@
{% endfor %}
{% endif %}
>
<span role="heading" id="{{ group_label_id }}">{{ item.label }}</span>
<span role="heading" id="{{ group_label_id }}">{{ item.label | safe }}</span>
{{ render_command_items(item.items, 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="menuitem"
{% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
{% if item.disabled %}aria-disabled="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ item.content | safe if item.content else item.label }}
</div>
{% if item.url %}
<a
id="{{ item_id }}"
href="{{ item.url }}"
role="menuitem"
{% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
{% if item.disabled %}aria-disabled="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{% if key != "href" %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label | safe }}
</a>
{% else %}
<div
id="{{ item_id }}"
role="menuitem"
{% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
{% if item.disabled %}aria-disabled="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label | safe }}
</div>
{% endif %}
{% endif %}
{% endfor %}
{% endmacro %}
+3 -1
View File
@@ -102,6 +102,7 @@
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label | safe }}
</a>
{% else %}
@@ -114,9 +115,10 @@
{% endfor %}
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label | safe }}
</div>
{% endif %}
{% endif %}
{% endfor %}
{% endmacro %}
{% endmacro %}
+39 -16
View File
@@ -2,8 +2,11 @@
Renders a select or combobox component.
@param id {string} [optional] - Unique identifier for the select component.
@param selected {string} [optional] - The initially selected value.
@param selected {string|array} [optional] - The initially selected value(s).
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
@param multiple {boolean} [optional] [default=false] - Enables multiple selection mode.
@param placeholder {string} [optional] - Placeholder text shown when no options are selected (multiple mode only).
@param close_on_select {boolean} [optional] [default=false] - Closes the popover when selecting an option in multiple mode.
@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 popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
@@ -17,6 +20,9 @@
selected=None,
name=None,
items=None,
multiple=false,
placeholder=None,
close_on_select=false,
main_attrs={},
trigger_attrs={},
popover_attrs={},
@@ -27,8 +33,15 @@
) %}
{% set id = id or ("select-" + (range(100000, 999999) | random | string)) %}
{% if selected is defined and selected is iterable and selected is not string %}
{% set selectedArray = selected %}
{% elif selected is defined %}
{% set selectedArray = [selected] %}
{% else %}
{% set selectedArray = [] %}
{% endif %}
{% set first_option = [] %}
{% set selected_option = [] %}
{% set selected_options = [] %}
{% if items %}
{% for item in items %}
@@ -37,33 +50,42 @@
{% 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) %}
{% if sub_item.value in selectedArray %}
{% set selected_options = (selected_options.push(sub_item), selected_options) %}
{% 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) %}
{% if item.value in selectedArray %}
{% set selected_options = (selected_options.push(item), selected_options) %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% set default_option = selected_option[0] or first_option[0] or None %}
{% set default_option = (selected_options[0] if selected_options[0] else (first_option[0] if first_option[0] else None)) %}
{% set labels = [] %}
{% if multiple and selected_options %}
{% for opt in selected_options %}
{% set labels = (labels.push(opt.label), labels) %}
{% endfor %}
{% endif %}
{% set default_label = (labels | join(', ') if (multiple and selected_options) else ((placeholder or '') if (multiple and not selected_options) else (default_option.label if default_option else ''))) %}
<div
id="{{ id }}"
class="select {{ main_attrs.class }}"
{% if multiple and placeholder %}data-placeholder="{{ placeholder }}"{% endif %}
{% if multiple and close_on_select %}data-close-on-select="true"{% endif %}
{% 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 }}"
class="btn-outline {{ trigger_attrs.class }}"
id="{{ id }}-trigger"
aria-haspopup="listbox"
aria-expanded="false"
@@ -72,7 +94,7 @@
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<span class="truncate">{{ default_option.label }}</span>
<span class="truncate">{{ default_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 %}
@@ -110,12 +132,13 @@
id="{{ id }}-listbox"
aria-orientation="vertical"
aria-labelledby="{{ id }}-trigger"
{% if multiple %}aria-multiselectable="true"{% endif %}
{% for key, value in listbox_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if items and items.length > 0 %}
{{ render_select_items(items, default_option.value, id ~ "-items" if id else "items") }}
{{ render_select_items(items, selectedArray, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
@@ -124,7 +147,7 @@
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{{ (default_option.value if default_option) or '' }}"
value="{% if multiple %}{{ selectedArray | dump }}{% else %}{{ (default_option.value if default_option) or '' }}{% endif %}"
{% for key, value in input_attrs %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
@@ -138,7 +161,7 @@
@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, selected, parent_id_prefix="items") %}
{% macro render_select_items(items, selectedArray, parent_id_prefix="items") %}
{% for item in items %}
{% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
{% if item.type == "group" %}
@@ -153,7 +176,7 @@
{% endif %}
>
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
{{ render_select_items(item.items, selected, item_id) if item.items }}
{{ render_select_items(item.items, selectedArray, item_id) if item.items }}
</div>
{% elif item.type == "separator" %}
<hr role="separator" />
@@ -161,8 +184,8 @@
<div
id="{{ item_id }}"
role="option"
data-value="{{ item.value }}"
{% if selected == item.value %}aria-selected="true"{% endif %}
{% if item.value is defined and item.value is not none %}data-value="{{ item.value }}"{% endif %}
{% if item.value in selectedArray %}aria-selected="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
@@ -173,4 +196,4 @@
</div>
{% endif %}
{% endfor %}
{% endmacro %}
{% endmacro %}
+2 -2
View File
@@ -117,7 +117,7 @@
>
<summary aria-controls="submenu-{{ item_id }}-content">
{% if item.icon %}{{ item.icon | safe }}{% endif %}
{{ item.label }}
{{ item.label | safe }}
</summary>
<ul id="submenu-{{ item_id }}-content">
{{ render_sidebar_content(item.items, item_id) if item.items }}
@@ -136,7 +136,7 @@
{% endif %}
>
{% if item.icon %}{{ item.icon | safe }}{% endif %}
<span>{{ item.label }}</span>
<span>{{ item.label | safe }}</span>
</a>
</li>
{% endif %}