Select
Dropdown control for choosing from predefined options.
TailwindCSS
Vanilla JS
package showcase
import "github.com/axzilla/templui/components"
templ SelectDefault() {
<div class="w-full max-w-sm">
@components.Select() {
@components.SelectTrigger() {
@components.SelectValue(components.SelectValueProps{
Placeholder: "Select a fruit",
})
}
@components.SelectContent() {
@components.SelectGroup() {
@components.SelectLabel() {
Fruits
}
@components.SelectItem(components.SelectItemProps{
Value: "apple",
}) {
Apple
}
@components.SelectItem(components.SelectItemProps{
Value: "banana",
}) {
Banana
}
@components.SelectItem(components.SelectItemProps{
Value: "blueberry",
}) {
Blueberry
}
@components.SelectItem(components.SelectItemProps{
Value: "grapes",
}) {
Grapes
}
@components.SelectItem(components.SelectItemProps{
Value: "pineapple",
}) {
Pineapple
}
}
}
}
</div>
}
package components
import (
"github.com/axzilla/templui/icons"
"github.com/axzilla/templui/utils"
"strconv"
)
type SelectProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SelectTriggerProps struct {
ID string
Class string
Attributes templ.Attributes
Name string
Required bool
Disabled bool
HasError bool
}
type SelectValueProps struct {
ID string
Class string
Attributes templ.Attributes
Placeholder string
}
type SelectContentProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SelectGroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SelectLabelProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SelectItemProps struct {
ID string
Class string
Attributes templ.Attributes
Value string
Selected bool
Disabled bool
}
templ Select(props ...SelectProps) {
{{ var p SelectProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID + "-container" }
}
class={ utils.TwMerge("w-full select-container relative", p.Class) }
data-select-id={ p.ID }
{ p.Attributes... }
>
{ children... }
</div>
}
templ SelectTrigger(props ...SelectTriggerProps) {
{{ var p SelectTriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
@Button(ButtonProps{
ID: p.ID,
Type: "button",
Variant: ButtonVariantOutline,
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{
"aria-haspopup": "listbox",
"aria-expanded": "false",
"data-select-trigger": "true",
"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">
@icons.ChevronDown(icons.IconProps{
Size: 16,
Class: "text-muted-foreground",
})
</span>
}
}
templ SelectValue(props ...SelectValueProps) {
{{ var p SelectValueProps }}
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 SelectContent(props ...SelectContentProps) {
{{ var p SelectContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"p-1 select-content absolute z-50 w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
"transition-all ease-out duration-100",
"transform opacity-0 -translate-y-1 scale-95", // initial condition
p.Class,
),
}
style="display: none;"
role="listbox"
tabindex="-1"
{ p.Attributes... }
>
{ children... }
</div>
}
templ SelectGroup(props ...SelectGroupProps) {
{{ var p SelectGroupProps }}
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 SelectLabel(props ...SelectLabelProps) {
{{ var p SelectLabelProps }}
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 SelectItem(props ...SelectItemProps) {
{{ var p SelectItemProps }}
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"),
),
}
>
@icons.Check(icons.IconProps{Size: 16})
</span>
</div>
}
templ SelectScript() {
{{ handle := templ.NewOnceHandle() }}
@handle.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('DOMContentLoaded', function() {
// Helper function to position dropdown based on available space
function updateDropdownPosition(trigger, content) {
// Get necessary measurements
const triggerRect = trigger.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
// First make the content visible but off-screen to measure its natural size
content.style.display = 'block';
content.style.visibility = 'hidden';
content.style.maxHeight = 'none'; // Remove max-height to get natural height
// Force a reflow to make sure we get accurate measurements
void content.offsetHeight;
// Get content dimensions
const contentHeight = content.scrollHeight;
const contentWidth = content.scrollWidth;
// Calculate available space in different directions
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
const spaceRight = viewportWidth - triggerRect.left;
// Reset visibility
content.style.visibility = '';
// Vertical positioning
let needsVerticalScroll = false;
let maxHeight;
if (spaceBelow >= contentHeight) {
// Enough space below
content.style.top = "100%";
content.style.bottom = "auto";
content.style.marginTop = "0.25rem";
content.style.marginBottom = "0";
maxHeight = spaceBelow - 10; // Add some padding
} else if (spaceAbove >= contentHeight) {
// Enough space above
content.style.bottom = "100%";
content.style.top = "auto";
content.style.marginBottom = "0.25rem";
content.style.marginTop = "0";
maxHeight = spaceAbove - 10; // Add some padding
} else {
// Not enough space in either direction - use the larger space and add scrolling
needsVerticalScroll = true;
if (spaceBelow >= spaceAbove) {
content.style.top = "100%";
content.style.bottom = "auto";
content.style.marginTop = "0.25rem";
content.style.marginBottom = "0";
maxHeight = spaceBelow - 10;
} else {
content.style.bottom = "100%";
content.style.top = "auto";
content.style.marginBottom = "0.25rem";
content.style.marginTop = "0";
maxHeight = spaceAbove - 10;
}
}
// Apply vertical scroll if needed
if (needsVerticalScroll) {
content.style.maxHeight = maxHeight + "px";
content.style.overflowY = "auto";
} else {
content.style.maxHeight = "none";
content.style.overflowY = "visible";
}
// Handle horizontal positioning if needed (for very wide dropdowns)
if (contentWidth > triggerRect.width && contentWidth > spaceRight) {
content.style.right = "0";
content.style.left = "auto";
} else {
content.style.left = "0";
content.style.right = "auto";
}
}
// Find all Select containers
document.querySelectorAll('.select-container').forEach(function(selectContainer) {
const id = selectContainer.getAttribute('data-select-id');
const trigger = selectContainer.querySelector('.select-trigger');
const content = selectContainer.querySelector('.select-content');
const valueEl = selectContainer.querySelector('.select-value');
const hiddenInput = selectContainer.querySelector('input[type="hidden"]');
let isOpen = false;
// Initialize selected value
let selectedItem = selectContainer.querySelector('.select-item[data-selected="true"]');
let hoverApplied = false; // Track if hover effect is applied
// If no item is selected, mark the first one as pre-selected (just visually)
if (!selectedItem) {
const firstItem = selectContainer.querySelector('.select-item');
if (firstItem) {
firstItem.classList.add('bg-muted'); // Add a subtle gray background
}
}
// Update values if an item is actually selected
if (selectedItem && valueEl) {
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');
}
}
// Handle hover effect on mouse movement
content.addEventListener('mouseover', function(e) {
const item = e.target.closest('.select-item');
if (!item) return;
// Reset all items to normal state
selectContainer.querySelectorAll('.select-item').forEach(el => {
if (el.getAttribute('data-selected') === 'true') {
// For selected item, keep check mark but remove background
el.classList.remove('bg-accent', 'text-accent-foreground', 'bg-muted');
} else {
el.classList.remove('bg-accent', 'text-accent-foreground', 'bg-muted');
}
});
// Apply hover effect to current item
if (item.getAttribute('data-disabled') !== 'true') {
item.classList.add('bg-accent', 'text-accent-foreground');
hoverApplied = true;
}
});
// Reset hover effect when mouse leaves content area
content.addEventListener('mouseleave', function() {
// Remove hover effect
selectContainer.querySelectorAll('.select-item').forEach(el => {
if (el.getAttribute('data-selected') === 'true') {
// Restore background to selected item
el.classList.add('bg-accent', 'text-accent-foreground');
} else {
el.classList.remove('bg-accent', 'text-accent-foreground', 'bg-muted');
}
});
hoverApplied = false;
});
// Toggle dropdown with elegant transitions
if (trigger) {
// Prevent focus ring on mousedown
trigger.addEventListener('mousedown', function(e) {
if (e.button === 0) {
this.style.outline = 'none';
this.style.boxShadow = 'none';
}
});
trigger.addEventListener('click', function() {
if (this.disabled) return;
isOpen = !isOpen;
this.setAttribute('aria-expanded', isOpen.toString());
if (isOpen) {
// Update position before showing
updateDropdownPosition(this, content);
// Show dropdown
content.style.display = 'block';
// Force reflow
void content.offsetHeight;
// Apply animation end state
content.classList.remove('opacity-0', '-translate-y-1', 'scale-95');
content.classList.add('opacity-100', 'translate-y-0', 'scale-100');
} else {
// Start closing animation
content.classList.remove('opacity-100', 'translate-y-0', 'scale-100');
content.classList.add('opacity-0', '-translate-y-1', 'scale-95');
// Hide after animation completes
setTimeout(function() {
if (!isOpen) { // Double-check state hasn't changed
content.style.display = 'none';
}
}, 100); // Match duration from CSS
// Reset focus styles
this.style.outline = '';
this.style.boxShadow = '';
this.focus();
}
});
// Keyboard navigation
trigger.addEventListener('keydown', function(e) {
if ((e.key === 'Enter' || e.key === ' ') && !this.disabled) {
e.preventDefault();
this.click();
} else if ((e.key === 'Escape' || e.key === 'Tab') && isOpen) {
e.preventDefault();
this.click(); // Close dropdown
}
});
}
// Handle item selection
selectContainer.querySelectorAll('.select-item').forEach(function(item) {
item.addEventListener('click', function() {
if (this.getAttribute('data-disabled') === 'true') return;
// Get data
const value = this.getAttribute('data-value');
const itemText = this.querySelector('.select-item-text');
// Reset selection state
selectContainer.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');
});
// Set new selection
this.setAttribute('data-selected', 'true');
this.classList.add('bg-accent', 'text-accent-foreground');
const check = this.querySelector('.select-check');
if (check) check.classList.replace('opacity-0', 'opacity-100');
// Update UI
if (valueEl && itemText) {
valueEl.textContent = itemText.textContent;
valueEl.classList.remove('text-muted-foreground');
}
// Update hidden input
if (hiddenInput && value) {
hiddenInput.value = value;
hiddenInput.dispatchEvent(new Event('change', {bubbles: true}));
}
// Close dropdown elegantly
isOpen = false;
content.classList.remove('opacity-100', 'translate-y-0', 'scale-100');
content.classList.add('opacity-0', '-translate-y-1', 'scale-95');
setTimeout(function() {
if (!isOpen) {
content.style.display = 'none';
}
}, 100);
trigger.setAttribute('aria-expanded', 'false');
trigger.style.outline = '';
trigger.style.boxShadow = '';
trigger.focus();
});
// Keyboard selection
item.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.click();
}
});
});
// Close on outside click
document.addEventListener('click', function(e) {
if (isOpen && !selectContainer.contains(e.target)) {
isOpen = false;
// Close with animation
content.classList.remove('opacity-100', 'translate-y-0', 'scale-100');
content.classList.add('opacity-0', '-translate-y-1', 'scale-95');
setTimeout(function() {
if (!isOpen) {
content.style.display = 'none';
}
}, 100);
trigger.setAttribute('aria-expanded', 'false');
trigger.style.outline = '';
trigger.style.boxShadow = '';
}
});
});
});
</script>
}
}
Usage
@components.Select(components.SelectProps{...})
Examples
With Label
package showcase
import "github.com/axzilla/templui/components"
templ SelectWithLabel() {
<div class="space-y-2 w-full max-w-sm">
@components.Label(components.LabelProps{
For: "select-with-label",
}) {
Fruit
}
@components.Select() {
@components.SelectTrigger(components.SelectTriggerProps{
ID: "select-with-label",
}) {
@components.SelectValue(components.SelectValueProps{
Placeholder: "Select a fruit",
})
}
@components.SelectContent() {
@components.SelectLabel() {
Fruits
}
@components.SelectItem(components.SelectItemProps{
Value: "apple",
}) {
Apple
}
@components.SelectItem(components.SelectItemProps{
Value: "banana",
}) {
Banana
}
@components.SelectItem(components.SelectItemProps{
Value: "orange",
}) {
Orange
}
}
}
</div>
}
package components
import (
"github.com/axzilla/templui/icons"
"github.com/axzilla/templui/utils"
"strconv"
)
type SelectProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SelectTriggerProps struct {
ID string
Class string
Attributes templ.Attributes
Name string
Required bool
Disabled bool
HasError bool
}
type SelectValueProps struct {
ID string
Class string
Attributes templ.Attributes
Placeholder string
}
type SelectContentProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SelectGroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SelectLabelProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SelectItemProps struct {
ID string
Class string
Attributes templ.Attributes
Value string
Selected bool
Disabled bool
}
templ Select(props ...SelectProps) {
{{ var p SelectProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID + "-container" }
}
class={ utils.TwMerge("w-full select-container relative", p.Class) }
data-select-id={ p.ID }
{ p.Attributes... }
>
{ children... }
</div>
}
templ SelectTrigger(props ...SelectTriggerProps) {
{{ var p SelectTriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
@Button(ButtonProps{
ID: p.ID,
Type: "button",
Variant: ButtonVariantOutline,
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{
"aria-haspopup": "listbox",
"aria-expanded": "false",
"data-select-trigger": "true",
"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">
@icons.ChevronDown(icons.IconProps{
Size: 16,
Class: "text-muted-foreground",
})
</span>
}
}
templ SelectValue(props ...SelectValueProps) {
{{ var p SelectValueProps }}
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 SelectContent(props ...SelectContentProps) {
{{ var p SelectContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"p-1 select-content absolute z-50 w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
"transition-all ease-out duration-100",
"transform opacity-0 -translate-y-1 scale-95", // initial condition
p.Class,
),
}
style="display: none;"
role="listbox"
tabindex="-1"
{ p.Attributes... }
>
{ children... }
</div>
}
templ SelectGroup(props ...SelectGroupProps) {
{{ var p SelectGroupProps }}
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 SelectLabel(props ...SelectLabelProps) {
{{ var p SelectLabelProps }}
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 SelectItem(props ...SelectItemProps) {
{{ var p SelectItemProps }}
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"),
),
}
>
@icons.Check(icons.IconProps{Size: 16})
</span>
</div>
}
templ SelectScript() {
{{ handle := templ.NewOnceHandle() }}
@handle.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('DOMContentLoaded', function() {
// Helper function to position dropdown based on available space
function updateDropdownPosition(trigger, content) {
// Get necessary measurements
const triggerRect = trigger.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
// First make the content visible but off-screen to measure its natural size
content.style.display = 'block';
content.style.visibility = 'hidden';
content.style.maxHeight = 'none'; // Remove max-height to get natural height
// Force a reflow to make sure we get accurate measurements
void content.offsetHeight;
// Get content dimensions
const contentHeight = content.scrollHeight;
const contentWidth = content.scrollWidth;
// Calculate available space in different directions
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
const spaceRight = viewportWidth - triggerRect.left;
// Reset visibility
content.style.visibility = '';
// Vertical positioning
let needsVerticalScroll = false;
let maxHeight;
if (spaceBelow >= contentHeight) {
// Enough space below
content.style.top = "100%";
content.style.bottom = "auto";
content.style.marginTop = "0.25rem";
content.style.marginBottom = "0";
maxHeight = spaceBelow - 10; // Add some padding
} else if (spaceAbove >= contentHeight) {
// Enough space above
content.style.bottom = "100%";
content.style.top = "auto";
content.style.marginBottom = "0.25rem";
content.style.marginTop = "0";
maxHeight = spaceAbove - 10; // Add some padding
} else {
// Not enough space in either direction - use the larger space and add scrolling
needsVerticalScroll = true;
if (spaceBelow >= spaceAbove) {
content.style.top = "100%";
content.style.bottom = "auto";
content.style.marginTop = "0.25rem";
content.style.marginBottom = "0";
maxHeight = spaceBelow - 10;
} else {
content.style.bottom = "100%";
content.style.top = "auto";
content.style.marginBottom = "0.25rem";
content.style.marginTop = "0";
maxHeight = spaceAbove - 10;
}
}
// Apply vertical scroll if needed
if (needsVerticalScroll) {
content.style.maxHeight = maxHeight + "px";
content.style.overflowY = "auto";
} else {
content.style.maxHeight = "none";
content.style.overflowY = "visible";
}
// Handle horizontal positioning if needed (for very wide dropdowns)
if (contentWidth > triggerRect.width && contentWidth > spaceRight) {
content.style.right = "0";
content.style.left = "auto";
} else {
content.style.left = "0";
content.style.right = "auto";
}
}
// Find all Select containers
document.querySelectorAll('.select-container').forEach(function(selectContainer) {
const id = selectContainer.getAttribute('data-select-id');
const trigger = selectContainer.querySelector('.select-trigger');
const content = selectContainer.querySelector('.select-content');
const valueEl = selectContainer.querySelector('.select-value');
const hiddenInput = selectContainer.querySelector('input[type="hidden"]');
let isOpen = false;
// Initialize selected value
let selectedItem = selectContainer.querySelector('.select-item[data-selected="true"]');
let hoverApplied = false; // Track if hover effect is applied
// If no item is selected, mark the first one as pre-selected (just visually)
if (!selectedItem) {
const firstItem = selectContainer.querySelector('.select-item');
if (firstItem) {
firstItem.classList.add('bg-muted'); // Add a subtle gray background
}
}
// Update values if an item is actually selected
if (selectedItem && valueEl) {
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');
}
}
// Handle hover effect on mouse movement
content.addEventListener('mouseover', function(e) {
const item = e.target.closest('.select-item');
if (!item) return;
// Reset all items to normal state
selectContainer.querySelectorAll('.select-item').forEach(el => {
if (el.getAttribute('data-selected') === 'true') {
// For selected item, keep check mark but remove background
el.classList.remove('bg-accent', 'text-accent-foreground', 'bg-muted');
} else {
el.classList.remove('bg-accent', 'text-accent-foreground', 'bg-muted');
}
});
// Apply hover effect to current item
if (item.getAttribute('data-disabled') !== 'true') {
item.classList.add('bg-accent', 'text-accent-foreground');
hoverApplied = true;
}
});
// Reset hover effect when mouse leaves content area
content.addEventListener('mouseleave', function() {
// Remove hover effect
selectContainer.querySelectorAll('.select-item').forEach(el => {
if (el.getAttribute('data-selected') === 'true') {
// Restore background to selected item
el.classList.add('bg-accent', 'text-accent-foreground');
} else {
el.classList.remove('bg-accent', 'text-accent-foreground', 'bg-muted');
}
});
hoverApplied = false;
});
// Toggle dropdown with elegant transitions
if (trigger) {
// Prevent focus ring on mousedown
trigger.addEventListener('mousedown', function(e) {
if (e.button === 0) {
this.style.outline = 'none';
this.style.boxShadow = 'none';
}
});
trigger.addEventListener('click', function() {
if (this.disabled) return;
isOpen = !isOpen;
this.setAttribute('aria-expanded', isOpen.toString());
if (isOpen) {
// Update position before showing
updateDropdownPosition(this, content);
// Show dropdown
content.style.display = 'block';
// Force reflow
void content.offsetHeight;
// Apply animation end state
content.classList.remove('opacity-0', '-translate-y-1', 'scale-95');
content.classList.add('opacity-100', 'translate-y-0', 'scale-100');
} else {
// Start closing animation
content.classList.remove('opacity-100', 'translate-y-0', 'scale-100');
content.classList.add('opacity-0', '-translate-y-1', 'scale-95');
// Hide after animation completes
setTimeout(function() {
if (!isOpen) { // Double-check state hasn't changed
content.style.display = 'none';
}
}, 100); // Match duration from CSS
// Reset focus styles
this.style.outline = '';
this.style.boxShadow = '';
this.focus();
}
});
// Keyboard navigation
trigger.addEventListener('keydown', function(e) {
if ((e.key === 'Enter' || e.key === ' ') && !this.disabled) {
e.preventDefault();
this.click();
} else if ((e.key === 'Escape' || e.key === 'Tab') && isOpen) {
e.preventDefault();
this.click(); // Close dropdown
}
});
}
// Handle item selection
selectContainer.querySelectorAll('.select-item').forEach(function(item) {
item.addEventListener('click', function() {
if (this.getAttribute('data-disabled') === 'true') return;
// Get data
const value = this.getAttribute('data-value');
const itemText = this.querySelector('.select-item-text');
// Reset selection state
selectContainer.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');
});
// Set new selection
this.setAttribute('data-selected', 'true');
this.classList.add('bg-accent', 'text-accent-foreground');
const check = this.querySelector('.select-check');
if (check) check.classList.replace('opacity-0', 'opacity-100');
// Update UI
if (valueEl && itemText) {
valueEl.textContent = itemText.textContent;
valueEl.classList.remove('text-muted-foreground');
}
// Update hidden input
if (hiddenInput && value) {
hiddenInput.value = value;
hiddenInput.dispatchEvent(new Event('change', {bubbles: true}));
}
// Close dropdown elegantly
isOpen = false;
content.classList.remove('opacity-100', 'translate-y-0', 'scale-100');
content.classList.add('opacity-0', '-translate-y-1', 'scale-95');
setTimeout(function() {
if (!isOpen) {
content.style.display = 'none';
}
}, 100);
trigger.setAttribute('aria-expanded', 'false');
trigger.style.outline = '';
trigger.style.boxShadow = '';
trigger.focus();
});
// Keyboard selection
item.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.click();
}
});
});
// Close on outside click
document.addEventListener('click', function(e) {
if (isOpen && !selectContainer.contains(e.target)) {
isOpen = false;
// Close with animation
content.classList.remove('opacity-100', 'translate-y-0', 'scale-100');
content.classList.add('opacity-0', '-translate-y-1', 'scale-95');
setTimeout(function() {
if (!isOpen) {
content.style.display = 'none';
}
}, 100);
trigger.setAttribute('aria-expanded', 'false');
trigger.style.outline = '';
trigger.style.boxShadow = '';
}
});
});
});
</script>
}
}
Disabled
package showcase
import "github.com/axzilla/templui/components"
templ SelectDisabled() {
<div class="space-y-2 w-full max-w-sm">
@components.Select() {
@components.SelectTrigger(components.SelectTriggerProps{
Disabled: true,
}) {
@components.SelectValue(components.SelectValueProps{
Placeholder: "Select a fruit",
})
}
@components.SelectContent() {
@components.SelectLabel() {
Fruits
}
@components.SelectItem(components.SelectItemProps{
Value: "apple",
}) {
Apple
}
@components.SelectItem(components.SelectItemProps{
Value: "banana",
}) {
Banana
}
}
}
</div>
}
package components
import (
"github.com/axzilla/templui/icons"
"github.com/axzilla/templui/utils"
"strconv"
)
type SelectProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SelectTriggerProps struct {
ID string
Class string
Attributes templ.Attributes
Name string
Required bool
Disabled bool
HasError bool
}
type SelectValueProps struct {
ID string
Class string
Attributes templ.Attributes
Placeholder string
}
type SelectContentProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SelectGroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SelectLabelProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SelectItemProps struct {
ID string
Class string
Attributes templ.Attributes
Value string
Selected bool
Disabled bool
}
templ Select(props ...SelectProps) {
{{ var p SelectProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID + "-container" }
}
class={ utils.TwMerge("w-full select-container relative", p.Class) }
data-select-id={ p.ID }
{ p.Attributes... }
>
{ children... }
</div>
}
templ SelectTrigger(props ...SelectTriggerProps) {
{{ var p SelectTriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
@Button(ButtonProps{
ID: p.ID,
Type: "button",
Variant: ButtonVariantOutline,
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{
"aria-haspopup": "listbox",
"aria-expanded": "false",
"data-select-trigger": "true",
"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">
@icons.ChevronDown(icons.IconProps{
Size: 16,
Class: "text-muted-foreground",
})
</span>
}
}
templ SelectValue(props ...SelectValueProps) {
{{ var p SelectValueProps }}
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 SelectContent(props ...SelectContentProps) {
{{ var p SelectContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"p-1 select-content absolute z-50 w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
"transition-all ease-out duration-100",
"transform opacity-0 -translate-y-1 scale-95", // initial condition
p.Class,
),
}
style="display: none;"
role="listbox"
tabindex="-1"
{ p.Attributes... }
>
{ children... }
</div>
}
templ SelectGroup(props ...SelectGroupProps) {
{{ var p SelectGroupProps }}
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 SelectLabel(props ...SelectLabelProps) {
{{ var p SelectLabelProps }}
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 SelectItem(props ...SelectItemProps) {
{{ var p SelectItemProps }}
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"),
),
}
>
@icons.Check(icons.IconProps{Size: 16})
</span>
</div>
}
templ SelectScript() {
{{ handle := templ.NewOnceHandle() }}
@handle.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('DOMContentLoaded', function() {
// Helper function to position dropdown based on available space
function updateDropdownPosition(trigger, content) {
// Get necessary measurements
const triggerRect = trigger.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
// First make the content visible but off-screen to measure its natural size
content.style.display = 'block';
content.style.visibility = 'hidden';
content.style.maxHeight = 'none'; // Remove max-height to get natural height
// Force a reflow to make sure we get accurate measurements
void content.offsetHeight;
// Get content dimensions
const contentHeight = content.scrollHeight;
const contentWidth = content.scrollWidth;
// Calculate available space in different directions
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
const spaceRight = viewportWidth - triggerRect.left;
// Reset visibility
content.style.visibility = '';
// Vertical positioning
let needsVerticalScroll = false;
let maxHeight;
if (spaceBelow >= contentHeight) {
// Enough space below
content.style.top = "100%";
content.style.bottom = "auto";
content.style.marginTop = "0.25rem";
content.style.marginBottom = "0";
maxHeight = spaceBelow - 10; // Add some padding
} else if (spaceAbove >= contentHeight) {
// Enough space above
content.style.bottom = "100%";
content.style.top = "auto";
content.style.marginBottom = "0.25rem";
content.style.marginTop = "0";
maxHeight = spaceAbove - 10; // Add some padding
} else {
// Not enough space in either direction - use the larger space and add scrolling
needsVerticalScroll = true;
if (spaceBelow >= spaceAbove) {
content.style.top = "100%";
content.style.bottom = "auto";
content.style.marginTop = "0.25rem";
content.style.marginBottom = "0";
maxHeight = spaceBelow - 10;
} else {
content.style.bottom = "100%";
content.style.top = "auto";
content.style.marginBottom = "0.25rem";
content.style.marginTop = "0";
maxHeight = spaceAbove - 10;
}
}
// Apply vertical scroll if needed
if (needsVerticalScroll) {
content.style.maxHeight = maxHeight + "px";
content.style.overflowY = "auto";
} else {
content.style.maxHeight = "none";
content.style.overflowY = "visible";
}
// Handle horizontal positioning if needed (for very wide dropdowns)
if (contentWidth > triggerRect.width && contentWidth > spaceRight) {
content.style.right = "0";
content.style.left = "auto";
} else {
content.style.left = "0";
content.style.right = "auto";
}
}
// Find all Select containers
document.querySelectorAll('.select-container').forEach(function(selectContainer) {
const id = selectContainer.getAttribute('data-select-id');
const trigger = selectContainer.querySelector('.select-trigger');
const content = selectContainer.querySelector('.select-content');
const valueEl = selectContainer.querySelector('.select-value');
const hiddenInput = selectContainer.querySelector('input[type="hidden"]');
let isOpen = false;
// Initialize selected value
let selectedItem = selectContainer.querySelector('.select-item[data-selected="true"]');
let hoverApplied = false; // Track if hover effect is applied
// If no item is selected, mark the first one as pre-selected (just visually)
if (!selectedItem) {
const firstItem = selectContainer.querySelector('.select-item');
if (firstItem) {
firstItem.classList.add('bg-muted'); // Add a subtle gray background
}
}
// Update values if an item is actually selected
if (selectedItem && valueEl) {
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');
}
}
// Handle hover effect on mouse movement
content.addEventListener('mouseover', function(e) {
const item = e.target.closest('.select-item');
if (!item) return;
// Reset all items to normal state
selectContainer.querySelectorAll('.select-item').forEach(el => {
if (el.getAttribute('data-selected') === 'true') {
// For selected item, keep check mark but remove background
el.classList.remove('bg-accent', 'text-accent-foreground', 'bg-muted');
} else {
el.classList.remove('bg-accent', 'text-accent-foreground', 'bg-muted');
}
});
// Apply hover effect to current item
if (item.getAttribute('data-disabled') !== 'true') {
item.classList.add('bg-accent', 'text-accent-foreground');
hoverApplied = true;
}
});
// Reset hover effect when mouse leaves content area
content.addEventListener('mouseleave', function() {
// Remove hover effect
selectContainer.querySelectorAll('.select-item').forEach(el => {
if (el.getAttribute('data-selected') === 'true') {
// Restore background to selected item
el.classList.add('bg-accent', 'text-accent-foreground');
} else {
el.classList.remove('bg-accent', 'text-accent-foreground', 'bg-muted');
}
});
hoverApplied = false;
});
// Toggle dropdown with elegant transitions
if (trigger) {
// Prevent focus ring on mousedown
trigger.addEventListener('mousedown', function(e) {
if (e.button === 0) {
this.style.outline = 'none';
this.style.boxShadow = 'none';
}
});
trigger.addEventListener('click', function() {
if (this.disabled) return;
isOpen = !isOpen;
this.setAttribute('aria-expanded', isOpen.toString());
if (isOpen) {
// Update position before showing
updateDropdownPosition(this, content);
// Show dropdown
content.style.display = 'block';
// Force reflow
void content.offsetHeight;
// Apply animation end state
content.classList.remove('opacity-0', '-translate-y-1', 'scale-95');
content.classList.add('opacity-100', 'translate-y-0', 'scale-100');
} else {
// Start closing animation
content.classList.remove('opacity-100', 'translate-y-0', 'scale-100');
content.classList.add('opacity-0', '-translate-y-1', 'scale-95');
// Hide after animation completes
setTimeout(function() {
if (!isOpen) { // Double-check state hasn't changed
content.style.display = 'none';
}
}, 100); // Match duration from CSS
// Reset focus styles
this.style.outline = '';
this.style.boxShadow = '';
this.focus();
}
});
// Keyboard navigation
trigger.addEventListener('keydown', function(e) {
if ((e.key === 'Enter' || e.key === ' ') && !this.disabled) {
e.preventDefault();
this.click();
} else if ((e.key === 'Escape' || e.key === 'Tab') && isOpen) {
e.preventDefault();
this.click(); // Close dropdown
}
});
}
// Handle item selection
selectContainer.querySelectorAll('.select-item').forEach(function(item) {
item.addEventListener('click', function() {
if (this.getAttribute('data-disabled') === 'true') return;
// Get data
const value = this.getAttribute('data-value');
const itemText = this.querySelector('.select-item-text');
// Reset selection state
selectContainer.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');
});
// Set new selection
this.setAttribute('data-selected', 'true');
this.classList.add('bg-accent', 'text-accent-foreground');
const check = this.querySelector('.select-check');
if (check) check.classList.replace('opacity-0', 'opacity-100');
// Update UI
if (valueEl && itemText) {
valueEl.textContent = itemText.textContent;
valueEl.classList.remove('text-muted-foreground');
}
// Update hidden input
if (hiddenInput && value) {
hiddenInput.value = value;
hiddenInput.dispatchEvent(new Event('change', {bubbles: true}));
}
// Close dropdown elegantly
isOpen = false;
content.classList.remove('opacity-100', 'translate-y-0', 'scale-100');
content.classList.add('opacity-0', '-translate-y-1', 'scale-95');
setTimeout(function() {
if (!isOpen) {
content.style.display = 'none';
}
}, 100);
trigger.setAttribute('aria-expanded', 'false');
trigger.style.outline = '';
trigger.style.boxShadow = '';
trigger.focus();
});
// Keyboard selection
item.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.click();
}
});
});
// Close on outside click
document.addEventListener('click', function(e) {
if (isOpen && !selectContainer.contains(e.target)) {
isOpen = false;
// Close with animation
content.classList.remove('opacity-100', 'translate-y-0', 'scale-100');
content.classList.add('opacity-0', '-translate-y-1', 'scale-95');
setTimeout(function() {
if (!isOpen) {
content.style.display = 'none';
}
}, 100);
trigger.setAttribute('aria-expanded', 'false');
trigger.style.outline = '';
trigger.style.boxShadow = '';
}
});
});
});
</script>
}
}
Form
Select a fruit category.
A fruit selection is required.
package showcase
import "github.com/axzilla/templui/components"
templ SelectForm() {
<div class="w-full max-w-sm">
@components.FormItem() {
@components.FormLabel(components.FormLabelProps{
For: "select-form",
}) {
Fruit
}
@components.Select() {
@components.SelectTrigger(components.SelectTriggerProps{
ID: "select-form",
Name: "fruit",
Required: true,
HasError: true,
}) {
@components.SelectValue(components.SelectValueProps{
Placeholder: "Select a fruit",
})
}
@components.SelectContent() {
@components.SelectItem(components.SelectItemProps{
Value: "apple",
}) {
Apple
}
@components.SelectItem(components.SelectItemProps{
Value: "banana",
}) {
Banana
}
@components.SelectItem(components.SelectItemProps{
Value: "blueberry",
Selected: true,
}) {
Blueberry
}
@components.SelectItem(components.SelectItemProps{
Value: "grapes",
}) {
Grapes
}
@components.SelectItem(components.SelectItemProps{
Value: "pineapple",
Disabled: true,
}) {
Pineapple (out of stock)
}
}
}
@components.FormDescription() {
Select a fruit category.
}
@components.FormMessage(components.FormMessageProps{
Variant: components.FormMessageVariantError,
}) {
A fruit selection is required.
}
}
</div>
}
package components
import (
"github.com/axzilla/templui/icons"
"github.com/axzilla/templui/utils"
"strconv"
)
type SelectProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SelectTriggerProps struct {
ID string
Class string
Attributes templ.Attributes
Name string
Required bool
Disabled bool
HasError bool
}
type SelectValueProps struct {
ID string
Class string
Attributes templ.Attributes
Placeholder string
}
type SelectContentProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SelectGroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SelectLabelProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SelectItemProps struct {
ID string
Class string
Attributes templ.Attributes
Value string
Selected bool
Disabled bool
}
templ Select(props ...SelectProps) {
{{ var p SelectProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID + "-container" }
}
class={ utils.TwMerge("w-full select-container relative", p.Class) }
data-select-id={ p.ID }
{ p.Attributes... }
>
{ children... }
</div>
}
templ SelectTrigger(props ...SelectTriggerProps) {
{{ var p SelectTriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
@Button(ButtonProps{
ID: p.ID,
Type: "button",
Variant: ButtonVariantOutline,
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{
"aria-haspopup": "listbox",
"aria-expanded": "false",
"data-select-trigger": "true",
"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">
@icons.ChevronDown(icons.IconProps{
Size: 16,
Class: "text-muted-foreground",
})
</span>
}
}
templ SelectValue(props ...SelectValueProps) {
{{ var p SelectValueProps }}
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 SelectContent(props ...SelectContentProps) {
{{ var p SelectContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"p-1 select-content absolute z-50 w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
"transition-all ease-out duration-100",
"transform opacity-0 -translate-y-1 scale-95", // initial condition
p.Class,
),
}
style="display: none;"
role="listbox"
tabindex="-1"
{ p.Attributes... }
>
{ children... }
</div>
}
templ SelectGroup(props ...SelectGroupProps) {
{{ var p SelectGroupProps }}
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 SelectLabel(props ...SelectLabelProps) {
{{ var p SelectLabelProps }}
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 SelectItem(props ...SelectItemProps) {
{{ var p SelectItemProps }}
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"),
),
}
>
@icons.Check(icons.IconProps{Size: 16})
</span>
</div>
}
templ SelectScript() {
{{ handle := templ.NewOnceHandle() }}
@handle.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('DOMContentLoaded', function() {
// Helper function to position dropdown based on available space
function updateDropdownPosition(trigger, content) {
// Get necessary measurements
const triggerRect = trigger.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
// First make the content visible but off-screen to measure its natural size
content.style.display = 'block';
content.style.visibility = 'hidden';
content.style.maxHeight = 'none'; // Remove max-height to get natural height
// Force a reflow to make sure we get accurate measurements
void content.offsetHeight;
// Get content dimensions
const contentHeight = content.scrollHeight;
const contentWidth = content.scrollWidth;
// Calculate available space in different directions
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
const spaceRight = viewportWidth - triggerRect.left;
// Reset visibility
content.style.visibility = '';
// Vertical positioning
let needsVerticalScroll = false;
let maxHeight;
if (spaceBelow >= contentHeight) {
// Enough space below
content.style.top = "100%";
content.style.bottom = "auto";
content.style.marginTop = "0.25rem";
content.style.marginBottom = "0";
maxHeight = spaceBelow - 10; // Add some padding
} else if (spaceAbove >= contentHeight) {
// Enough space above
content.style.bottom = "100%";
content.style.top = "auto";
content.style.marginBottom = "0.25rem";
content.style.marginTop = "0";
maxHeight = spaceAbove - 10; // Add some padding
} else {
// Not enough space in either direction - use the larger space and add scrolling
needsVerticalScroll = true;
if (spaceBelow >= spaceAbove) {
content.style.top = "100%";
content.style.bottom = "auto";
content.style.marginTop = "0.25rem";
content.style.marginBottom = "0";
maxHeight = spaceBelow - 10;
} else {
content.style.bottom = "100%";
content.style.top = "auto";
content.style.marginBottom = "0.25rem";
content.style.marginTop = "0";
maxHeight = spaceAbove - 10;
}
}
// Apply vertical scroll if needed
if (needsVerticalScroll) {
content.style.maxHeight = maxHeight + "px";
content.style.overflowY = "auto";
} else {
content.style.maxHeight = "none";
content.style.overflowY = "visible";
}
// Handle horizontal positioning if needed (for very wide dropdowns)
if (contentWidth > triggerRect.width && contentWidth > spaceRight) {
content.style.right = "0";
content.style.left = "auto";
} else {
content.style.left = "0";
content.style.right = "auto";
}
}
// Find all Select containers
document.querySelectorAll('.select-container').forEach(function(selectContainer) {
const id = selectContainer.getAttribute('data-select-id');
const trigger = selectContainer.querySelector('.select-trigger');
const content = selectContainer.querySelector('.select-content');
const valueEl = selectContainer.querySelector('.select-value');
const hiddenInput = selectContainer.querySelector('input[type="hidden"]');
let isOpen = false;
// Initialize selected value
let selectedItem = selectContainer.querySelector('.select-item[data-selected="true"]');
let hoverApplied = false; // Track if hover effect is applied
// If no item is selected, mark the first one as pre-selected (just visually)
if (!selectedItem) {
const firstItem = selectContainer.querySelector('.select-item');
if (firstItem) {
firstItem.classList.add('bg-muted'); // Add a subtle gray background
}
}
// Update values if an item is actually selected
if (selectedItem && valueEl) {
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');
}
}
// Handle hover effect on mouse movement
content.addEventListener('mouseover', function(e) {
const item = e.target.closest('.select-item');
if (!item) return;
// Reset all items to normal state
selectContainer.querySelectorAll('.select-item').forEach(el => {
if (el.getAttribute('data-selected') === 'true') {
// For selected item, keep check mark but remove background
el.classList.remove('bg-accent', 'text-accent-foreground', 'bg-muted');
} else {
el.classList.remove('bg-accent', 'text-accent-foreground', 'bg-muted');
}
});
// Apply hover effect to current item
if (item.getAttribute('data-disabled') !== 'true') {
item.classList.add('bg-accent', 'text-accent-foreground');
hoverApplied = true;
}
});
// Reset hover effect when mouse leaves content area
content.addEventListener('mouseleave', function() {
// Remove hover effect
selectContainer.querySelectorAll('.select-item').forEach(el => {
if (el.getAttribute('data-selected') === 'true') {
// Restore background to selected item
el.classList.add('bg-accent', 'text-accent-foreground');
} else {
el.classList.remove('bg-accent', 'text-accent-foreground', 'bg-muted');
}
});
hoverApplied = false;
});
// Toggle dropdown with elegant transitions
if (trigger) {
// Prevent focus ring on mousedown
trigger.addEventListener('mousedown', function(e) {
if (e.button === 0) {
this.style.outline = 'none';
this.style.boxShadow = 'none';
}
});
trigger.addEventListener('click', function() {
if (this.disabled) return;
isOpen = !isOpen;
this.setAttribute('aria-expanded', isOpen.toString());
if (isOpen) {
// Update position before showing
updateDropdownPosition(this, content);
// Show dropdown
content.style.display = 'block';
// Force reflow
void content.offsetHeight;
// Apply animation end state
content.classList.remove('opacity-0', '-translate-y-1', 'scale-95');
content.classList.add('opacity-100', 'translate-y-0', 'scale-100');
} else {
// Start closing animation
content.classList.remove('opacity-100', 'translate-y-0', 'scale-100');
content.classList.add('opacity-0', '-translate-y-1', 'scale-95');
// Hide after animation completes
setTimeout(function() {
if (!isOpen) { // Double-check state hasn't changed
content.style.display = 'none';
}
}, 100); // Match duration from CSS
// Reset focus styles
this.style.outline = '';
this.style.boxShadow = '';
this.focus();
}
});
// Keyboard navigation
trigger.addEventListener('keydown', function(e) {
if ((e.key === 'Enter' || e.key === ' ') && !this.disabled) {
e.preventDefault();
this.click();
} else if ((e.key === 'Escape' || e.key === 'Tab') && isOpen) {
e.preventDefault();
this.click(); // Close dropdown
}
});
}
// Handle item selection
selectContainer.querySelectorAll('.select-item').forEach(function(item) {
item.addEventListener('click', function() {
if (this.getAttribute('data-disabled') === 'true') return;
// Get data
const value = this.getAttribute('data-value');
const itemText = this.querySelector('.select-item-text');
// Reset selection state
selectContainer.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');
});
// Set new selection
this.setAttribute('data-selected', 'true');
this.classList.add('bg-accent', 'text-accent-foreground');
const check = this.querySelector('.select-check');
if (check) check.classList.replace('opacity-0', 'opacity-100');
// Update UI
if (valueEl && itemText) {
valueEl.textContent = itemText.textContent;
valueEl.classList.remove('text-muted-foreground');
}
// Update hidden input
if (hiddenInput && value) {
hiddenInput.value = value;
hiddenInput.dispatchEvent(new Event('change', {bubbles: true}));
}
// Close dropdown elegantly
isOpen = false;
content.classList.remove('opacity-100', 'translate-y-0', 'scale-100');
content.classList.add('opacity-0', '-translate-y-1', 'scale-95');
setTimeout(function() {
if (!isOpen) {
content.style.display = 'none';
}
}, 100);
trigger.setAttribute('aria-expanded', 'false');
trigger.style.outline = '';
trigger.style.boxShadow = '';
trigger.focus();
});
// Keyboard selection
item.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.click();
}
});
});
// Close on outside click
document.addEventListener('click', function(e) {
if (isOpen && !selectContainer.contains(e.target)) {
isOpen = false;
// Close with animation
content.classList.remove('opacity-100', 'translate-y-0', 'scale-100');
content.classList.add('opacity-0', '-translate-y-1', 'scale-95');
setTimeout(function() {
if (!isOpen) {
content.style.display = 'none';
}
}, 100);
trigger.setAttribute('aria-expanded', 'false');
trigger.style.outline = '';
trigger.style.boxShadow = '';
}
});
});
});
</script>
}
}