Select Box
Dropdown control for choosing from predefined options.
TailwindCSS
Vanilla JS
Fruits
Apple
Banana
Blueberry
Grapes
Pineapple
package showcase
import "github.com/axzilla/templui/internal/components/selectbox"
templ SelectBoxDefault() {
<div class="w-full max-w-sm">
@selectbox.SelectBox() {
@selectbox.Trigger() {
@selectbox.Value(selectbox.ValueProps{
Placeholder: "Select a fruit",
})
}
@selectbox.Content() {
@selectbox.Group() {
@selectbox.Label() {
Fruits
}
@selectbox.Item(selectbox.ItemProps{
Value: "apple",
}) {
Apple
}
@selectbox.Item(selectbox.ItemProps{
Value: "banana",
}) {
Banana
}
@selectbox.Item(selectbox.ItemProps{
Value: "blueberry",
}) {
Blueberry
}
@selectbox.Item(selectbox.ItemProps{
Value: "grapes",
}) {
Grapes
}
@selectbox.Item(selectbox.ItemProps{
Value: "pineapple",
}) {
Pineapple
}
}
}
}
</div>
}
Installation
templui add selectbox
Copy and paste the following code into your project:
package selectbox import ( "context" "fmt" "github.com/axzilla/templui/internal/components/button" "github.com/axzilla/templui/internal/components/icon" "github.com/axzilla/templui/internal/components/popover" "github.com/axzilla/templui/internal/utils" "strconv" ) type contextKey string var contentIDKey contextKey = "contentID" type Props struct { ID string Class string Attributes templ.Attributes } type TriggerProps struct { ID string Class string Attributes templ.Attributes Name string Required bool Disabled bool HasError bool } type ValueProps struct { ID string Class string Attributes templ.Attributes Placeholder string } type ContentProps struct { ID string Class string Attributes templ.Attributes } type GroupProps struct { ID string Class string Attributes templ.Attributes } type LabelProps struct { ID string Class string Attributes templ.Attributes } type ItemProps struct { ID string Class string Attributes templ.Attributes Value string Selected bool Disabled bool } templ SelectBox(props ...Props) { @Script() {{ var p Props if len(props) > 0 { p = props[0] } wrapperID := p.ID if wrapperID == "" { wrapperID = utils.RandomID() } contentID := fmt.Sprintf("%s-content", wrapperID) ctx = context.WithValue(ctx, contentIDKey, contentID) }} <div id={ wrapperID } class={ utils.TwMerge("select-container w-full relative", p.Class) } { p.Attributes... } > @popover.Popover() { { children... } } </div> } templ Trigger(props ...TriggerProps) { {{ var p TriggerProps if len(props) > 0 { p = props[0] } contentID, ok := ctx.Value(contentIDKey).(string) if !ok { contentID = "fallback-select-content-id" } }} @popover.Trigger(popover.TriggerProps{ For: contentID, TriggerType: popover.TriggerTypeClick, }) { @button.Button(button.Props{ ID: p.ID, Type: "button", Variant: button.VariantOutline, Class: utils.TwMerge( "w-full select-trigger flex items-center justify-between focus:ring-2 focus:ring-offset-2", utils.If(p.HasError, "border-destructive ring-destructive"), p.Class, ), Disabled: p.Disabled, Attributes: utils.MergeAttributes( templ.Attributes{ "data-content-id": contentID, "tabindex": "0", "required": strconv.FormatBool(p.Required), }, p.Attributes, ), }) { <input type="hidden" if p.Name != "" { name={ p.Name } } required?={ p.Required } /> { children... } <span class="pointer-events-none ml-1"> @icon.ChevronDown(icon.Props{ Size: 16, Class: "text-muted-foreground", }) </span> } } } templ Value(props ...ValueProps) { {{ var p ValueProps }} if len(props) > 0 { {{ p = props[0] }} } <span if p.ID != "" { id={ p.ID } } class={ utils.TwMerge("block truncate select-value text-muted-foreground", p.Class) } { p.Attributes... } > if p.Placeholder != "" { { p.Placeholder } } { children... } </span> } templ Content(props ...ContentProps) { {{ var p ContentProps if len(props) > 0 { p = props[0] } contentID, ok := ctx.Value(contentIDKey).(string) if !ok { contentID = "fallback-select-content-id" } }} @popover.Content(popover.ContentProps{ ID: contentID, Placement: popover.PlacementBottomStart, Offset: 4, MatchWidth: true, Class: utils.TwMerge( "p-1 select-content z-50 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md", "min-w-[var(--popover-trigger-width)] w-[var(--popover-trigger-width)]", p.Class, ), Attributes: utils.MergeAttributes( templ.Attributes{ "role": "listbox", "tabindex": "-1", }, p.Attributes, ), }) { { children... } } } templ Group(props ...GroupProps) { {{ var p GroupProps }} if len(props) > 0 { {{ p = props[0] }} } <div if p.ID != "" { id={ p.ID } } class={ utils.TwMerge("p-1", p.Class) } role="group" { p.Attributes... } > { children... } </div> } templ Label(props ...LabelProps) { {{ var p LabelProps }} if len(props) > 0 { {{ p = props[0] }} } <span if p.ID != "" { id={ p.ID } } class={ utils.TwMerge("px-2 py-1.5 text-sm font-medium", p.Class) } { p.Attributes... } > { children... } </span> } templ Item(props ...ItemProps) { {{ var p ItemProps }} if len(props) > 0 { {{ p = props[0] }} } <div if p.ID != "" { id={ p.ID } } class={ utils.TwMerge( "select-item relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 px-2 text-sm font-light outline-none", "hover:bg-accent hover:text-accent-foreground", "focus:bg-accent focus:text-accent-foreground", utils.If(p.Selected, "bg-accent text-accent-foreground"), utils.If(p.Disabled, "pointer-events-none opacity-50"), p.Class, ), } role="option" data-value={ p.Value } data-selected={ strconv.FormatBool(p.Selected) } data-disabled={ strconv.FormatBool(p.Disabled) } tabindex="0" { p.Attributes... } > <span class="truncate select-item-text"> { children... } </span> <span class={ utils.TwMerge( "select-check absolute right-2 flex h-3.5 w-3.5 items-center justify-center", utils.IfElse(p.Selected, "opacity-100", "opacity-0"), ), } > @icon.Check(icon.Props{Size: 16}) </span> </div> } var handle = templ.NewOnceHandle() templ Script() { @handle.Once() { <script defer nonce={ templ.GetNonce(ctx) }> (function() { // IIFE function initSelect(wrapper) { if (!wrapper || wrapper.hasAttribute('data-initialized')) return; wrapper.setAttribute('data-initialized', 'true'); const triggerButton = wrapper.querySelector('button.select-trigger'); if (!triggerButton) { console.error("Select box: Trigger button (.select-trigger) not found in wrapper", wrapper); return; } const contentID = triggerButton.dataset.contentId; const content = contentID ? document.getElementById(contentID) : null; const valueEl = triggerButton.querySelector('.select-value'); const hiddenInput = triggerButton.querySelector('input[type="hidden"]'); if (!content || !valueEl || !hiddenInput) { console.error("Select box: Missing required elements for initialization.", { wrapper, contentID, contentExists: !!content, valueElExists: !!valueEl, hiddenInputExists: !!hiddenInput }); return; } // Initialize display value if an item is pre-selected const selectedItem = content.querySelector('.select-item[data-selected="true"]'); if (selectedItem) { const itemText = selectedItem.querySelector('.select-item-text'); if (itemText) { valueEl.textContent = itemText.textContent; valueEl.classList.remove('text-muted-foreground'); } if (hiddenInput) { hiddenInput.value = selectedItem.getAttribute('data-value') || ''; } } // Reset visual state of items function resetItemStyles() { content.querySelectorAll('.select-item').forEach(item => { if (item.getAttribute('data-selected') === 'true') { item.classList.add('bg-accent', 'text-accent-foreground'); item.classList.remove('bg-muted'); } else { item.classList.remove('bg-accent', 'text-accent-foreground', 'bg-muted'); } }); } // Select an item function selectItem(item) { if (!item || item.getAttribute('data-disabled') === 'true') return; const value = item.getAttribute('data-value'); const itemText = item.querySelector('.select-item-text'); // Reset all items in this content content.querySelectorAll('.select-item').forEach(el => { el.setAttribute('data-selected', 'false'); el.classList.remove('bg-accent', 'text-accent-foreground', 'bg-muted'); const check = el.querySelector('.select-check'); if (check) check.classList.replace('opacity-100', 'opacity-0'); }); // Mark new selection item.setAttribute('data-selected', 'true'); item.classList.add('bg-accent', 'text-accent-foreground'); const check = item.querySelector('.select-check'); if (check) check.classList.replace('opacity-0', 'opacity-100'); // Update display value if (valueEl && itemText) { // Check if valueEl exists valueEl.textContent = itemText.textContent; valueEl.classList.remove('text-muted-foreground'); } // Update hidden input & trigger change event if (hiddenInput && value !== null) { // Check if hiddenInput exists hiddenInput.value = value; hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); } // Close the popover using the correct contentID if (window.closePopover) { window.closePopover(contentID, true); } else { console.warn("closePopover function not found"); } } // Event Listeners for Items (delegated from content for robustness) content.addEventListener('click', (e) => { const item = e.target.closest('.select-item'); if (item) selectItem(item); }); content.addEventListener('keydown', (e) => { const item = e.target.closest('.select-item'); if (item && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); selectItem(item); } // Add other keyboard navigation (Up/Down/Home/End) if desired }); // Event: Mouse hover on items (delegated) content.addEventListener('mouseover', e => { const item = e.target.closest('.select-item'); if (!item || item.getAttribute('data-disabled') === 'true') return; // Reset all others first content.querySelectorAll('.select-item').forEach(el => { el.classList.remove('bg-accent', 'text-accent-foreground', 'bg-muted'); }); // Apply hover style only if not selected if (item.getAttribute('data-selected') !== 'true') { item.classList.add('bg-accent', 'text-accent-foreground'); } }); // Reset hover styles when mouse leaves the content area content.addEventListener('mouseleave', resetItemStyles); } function initAllComponents(root = document) { const containers = root.querySelectorAll('.select-container:not([data-initialized])'); if (root instanceof Element && root.matches('.select-container') && !root.hasAttribute('data-initialized')) { initSelect(root); } else { containers.forEach(initSelect); } } const handleHtmxSwap = (event) => { const target = event.detail.elt if (target instanceof Element) { requestAnimationFrame(() => initAllComponents(target)); } }; initAllComponents(); document.addEventListener('DOMContentLoaded', () => initAllComponents()); document.body.addEventListener('htmx:afterSwap', handleHtmxSwap); document.body.addEventListener('htmx:oobAfterSwap', handleHtmxSwap); })(); // End of IIFE </script> } }
Update the import paths to match your project setup.
Examples
With Label
Fruits
Apple
Banana
Orange
package showcase
import (
"github.com/axzilla/templui/internal/components/label"
"github.com/axzilla/templui/internal/components/selectbox"
)
templ SelectBoxWithLabel() {
<div class="space-y-2 w-full max-w-sm">
@label.Label(label.Props{
For: "select-with-label",
}) {
Fruit
}
@selectbox.SelectBox() {
@selectbox.Trigger(selectbox.TriggerProps{
ID: "select-with-label",
}) {
@selectbox.Value(selectbox.ValueProps{
Placeholder: "Select a fruit",
})
}
@selectbox.Content() {
@selectbox.Label() {
Fruits
}
@selectbox.Item(selectbox.ItemProps{
Value: "apple",
}) {
Apple
}
@selectbox.Item(selectbox.ItemProps{
Value: "banana",
}) {
Banana
}
@selectbox.Item(selectbox.ItemProps{
Value: "orange",
}) {
Orange
}
}
}
</div>
}
Disabled
Fruits
Apple
Banana
package showcase
import "github.com/axzilla/templui/internal/components/selectbox"
templ SelectBoxDisabled() {
<div class="space-y-2 w-full max-w-sm">
@selectbox.SelectBox() {
@selectbox.Trigger(selectbox.TriggerProps{
Disabled: true,
}) {
@selectbox.Value(selectbox.ValueProps{
Placeholder: "Select a fruit",
})
}
@selectbox.Content() {
@selectbox.Label() {
Fruits
}
@selectbox.Item(selectbox.ItemProps{
Value: "apple",
}) {
Apple
}
@selectbox.Item(selectbox.ItemProps{
Value: "banana",
}) {
Banana
}
}
}
</div>
}
Form
Apple
Banana
Blueberry
Grapes
Pineapple (out of stock)
Select a fruit category.
A fruit selection is required.
package showcase
import (
"github.com/axzilla/templui/internal/components/form"
"github.com/axzilla/templui/internal/components/selectbox"
)
templ SelectBoxForm() {
<div class="w-full max-w-sm">
@form.Item() {
@form.Label(form.LabelProps{
For: "select-form",
}) {
Fruit
}
@selectbox.SelectBox() {
@selectbox.Trigger(selectbox.TriggerProps{
ID: "select-form",
Name: "fruit",
Required: true,
HasError: true,
}) {
@selectbox.Value(selectbox.ValueProps{
Placeholder: "Select a fruit",
})
}
@selectbox.Content() {
@selectbox.Item(selectbox.ItemProps{
Value: "apple",
}) {
Apple
}
@selectbox.Item(selectbox.ItemProps{
Value: "banana",
}) {
Banana
}
@selectbox.Item(selectbox.ItemProps{
Value: "blueberry",
Selected: true,
}) {
Blueberry
}
@selectbox.Item(selectbox.ItemProps{
Value: "grapes",
}) {
Grapes
}
@selectbox.Item(selectbox.ItemProps{
Value: "pineapple",
Disabled: true,
}) {
Pineapple (out of stock)
}
}
}
@form.Description() {
Select a fruit category.
}
@form.Message(form.MessageProps{
Variant: form.MessageVariantError,
}) {
A fruit selection is required.
}
}
</div>
}