Compare commits

..
26 Commits
Author SHA1 Message Date
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
PhilipdeRijk 530224c587 fix: change tooltip bg color to bg-foreground 2025-09-26 11:53:09 +02:00
25 changed files with 667 additions and 329 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 #}
+1 -1
View File
@@ -136,4 +136,4 @@
footer=footer,
content_attrs={ "class": "scrollbar [&_[data-new-link]::after]:content-['New'] [&_[data-new-link]::after]:ml-auto [&_[data-new-link]::after]:text-xs [&_[data-new-link]::after]:font-medium [&_[data-new-link]::after]:bg-sidebar-primary [&_[data-new-link]::after]:text-sidebar-primary-foreground [&_[data-new-link]::after]:px-2 [&_[data-new-link]::after]:py-0.5 [&_[data-new-link]::after]:rounded-md" },
menu=menu
) }}
) }}
+32 -8
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);
}
}
@@ -3700,7 +3701,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 +4244,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 +5273,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 +5553,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) }}
+28 -2
View File
@@ -155,9 +155,10 @@ 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>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>
@@ -221,6 +222,31 @@ toc:
{% 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>
+191 -24
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 %}
@@ -53,14 +55,15 @@ toc:
{{ select(
trigger_attrs={"class": "w-[180px]"},
is_combobox=true,
multiple=true,
items=[
{
type: "group",
label: "Fruits",
items: [
{ type: "item", value: "apple", label: "Apple", attrs: {"data-force": "true"} },
{ type: "item", value: "banana", label: "Banana", attrs: {"data-keywords": "bread"} },
{ type: "item", value: "blueberry", label: "Blueberry" },
{ type: "item", label: "Apple", attrs: {"data-force": "true"} },
{ type: "item", label: "Banana", attrs: {"data-keywords": "bread"} },
{ type: "item", label: "Blueberry" },
{ type: "item", value: "pineapple", label: "Grapes" },
{ type: "item", value: "pineapple", label: "Pineapple" }
]
@@ -120,44 +123,69 @@ 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>
</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) }}
+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.6",
"version": "0.3.10-beta.2",
"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.6",
"version": "0.3.10-beta.2",
"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.6",
"version": "0.3.10-beta.2",
"description": "Tailwind CSS for Basecoat components",
"author": {
"name": "hunvreus",
+21 -8
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;
}
}
@@ -737,14 +742,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 +941,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 +1258,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 +1307,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 %}
+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 %}
+37 -17
View File
@@ -2,8 +2,10 @@
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 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 +19,8 @@
selected=None,
name=None,
items=[],
multiple=false,
placeholder=None,
main_attrs={},
trigger_attrs={},
popover_attrs={},
@@ -27,43 +31,58 @@
) %}
{% 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 %}
{% 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 +91,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 +129,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 +144,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 +158,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 +173,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 +181,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 +193,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 %}
+6 -2
View File
@@ -55,8 +55,12 @@
}
const itemText = (item.dataset.filter || item.textContent).trim().toLowerCase();
const keywords = (item.dataset.keywords || '').toLowerCase();
const matches = itemText.includes(searchTerm) || keywords.includes(searchTerm);
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);
+196 -57
View File
@@ -3,31 +3,37 @@
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 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,68 +52,118 @@
return parseFloat(style.transitionDuration) > 0 || parseFloat(style.transitionDelay) > 0;
};
const updateValue = (option, triggerEvent = true) => {
if (option) {
selectedLabel.innerHTML = 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 = [];
@@ -121,25 +177,49 @@
}
const optionText = (option.dataset.filter || option.textContent).trim().toLowerCase();
const keywords = (option.dataset.keywords || '').toLowerCase();
const matches = optionText.includes(searchTerm) || keywords.includes(searchTerm);
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';
@@ -155,17 +235,25 @@
}
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);
} else {
if (input.value !== getValue(option)) {
updateValue(option);
}
closePopover();
}
}
return;
}
@@ -231,7 +319,7 @@
document.dispatchEvent(new CustomEvent('basecoat:popover', {
detail: { source: selectComponent }
}));
if (filter) {
if (hasTransition()) {
popover.addEventListener('transitionend', () => {
@@ -244,7 +332,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));
@@ -263,8 +351,24 @@
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);
setActiveOption(options.indexOf(option));
if (filter) {
filter.focus();
} else {
trigger.focus();
}
} else {
if (input.value !== getValue(option)) {
updateValue(option);
}
closePopover();
}
});
@@ -281,8 +385,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'));
};
@@ -290,4 +429,4 @@
if (window.basecoat) {
window.basecoat.register('select', 'div.select:not([data-select-initialized])', initSelect);
}
})();
})();
+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 %}
+36 -16
View File
@@ -2,8 +2,10 @@
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 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 +19,8 @@
selected=None,
name=None,
items=None,
multiple=false,
placeholder=None,
main_attrs={},
trigger_attrs={},
popover_attrs={},
@@ -27,8 +31,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 +48,41 @@
{% 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 %}
{% 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 +91,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 +129,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 +144,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 +158,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 +173,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 +181,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 +193,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 %}