Dropdown
Floating menu for displaying a list of actions or options.
TailwindCSS
Vanilla JS
package showcase
import (
"github.com/axzilla/templui/component/button"
"github.com/axzilla/templui/component/dropdown"
"github.com/axzilla/templui/icon"
)
templ DropdownDefault() {
@dropdown.Dropdown() {
@dropdown.Trigger() {
@button.Button(button.Props{
Variant: button.VariantOutline,
}) {
Open
}
}
@dropdown.Content(dropdown.ContentProps{
Width: "w-56",
}) {
@dropdown.Label() {
My Account
}
@dropdown.Separator()
@dropdown.Group() {
@dropdown.Item() {
Team
}
@dropdown.Sub() {
@dropdown.SubTrigger() {
<span class="flex items-center">
@icon.Users(icon.Props{Size: 16, Class: "mr-2"})
Invite users
</span>
}
@dropdown.SubContent() {
@dropdown.Item() {
<span class="flex items-center">
@icon.Mail(icon.Props{Size: 16, Class: "mr-2"})
Email
</span>
}
@dropdown.Item() {
<span class="flex items-center">
@icon.MessageSquare(icon.Props{Size: 16, Class: "mr-2"})
Message
</span>
}
@dropdown.Separator()
@dropdown.Item() {
More...
}
}
}
@dropdown.Item() {
New Team
@dropdown.Shortcut() {
⌘+T
}
}
}
@dropdown.Separator()
@dropdown.Item(dropdown.ItemProps{
Href: "https://github.com",
Target: "_blank",
}) {
<span class="flex items-center">
@icon.Github(icon.Props{Size: 16, Class: "mr-2"})
GitHub
</span>
}
@dropdown.Item() {
<span class="flex items-center">
@icon.LifeBuoy(icon.Props{Size: 16, Class: "mr-2"})
Support
</span>
}
@dropdown.Item(dropdown.ItemProps{
Disabled: true,
}) {
<span class="flex items-center">
@icon.Code(icon.Props{Size: 16, Class: "mr-2"})
API
</span>
}
@dropdown.Separator()
@dropdown.Item() {
<span class="flex items-center">
@icon.LogOut(icon.Props{Size: 16, Class: "mr-2"})
Log out
</span>
@dropdown.Shortcut() {
⇧⌘Q
}
}
}
}
}
package dropdown
import (
"context"
"fmt"
"github.com/axzilla/templui/component/popover"
"github.com/axzilla/templui/util"
)
type contextKey string
var (
contentIDKey contextKey = "contentID"
subContentIDKey contextKey = "subContentID"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
}
type TriggerProps struct {
ID string
Class string
Attributes templ.Attributes
}
type ContentProps struct {
ID string
Class string
Attributes templ.Attributes
Width string
MaxHeight string
Align string
Side string
}
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
Disabled bool
Href string
Target string
}
type SeparatorProps struct {
ID string
Class string
Attributes templ.Attributes
}
type ShortcutProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SubProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SubTriggerProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SubContentProps struct {
ID string
Class string
Attributes templ.Attributes
}
type PortalProps struct {
ID string
Class string
Attributes templ.Attributes
}
templ Dropdown(props ...Props) {
@Script()
{{
var p Props
if len(props) > 0 {
p = props[0]
}
contentID := p.ID
if contentID == "" {
contentID = util.RandomID()
}
ctx = context.WithValue(ctx, contentIDKey, contentID)
}}
@popover.Popover(popover.Props{
Class: p.Class,
}) {
{ children... }
}
}
templ Trigger(props ...TriggerProps) {
{{
var p TriggerProps
if len(props) > 0 {
p = props[0]
}
contentID, ok := ctx.Value(contentIDKey).(string)
if !ok {
contentID = "fallback-content-id"
}
}}
@popover.Trigger(popover.TriggerProps{
ID: p.ID,
For: contentID, TriggerType: popover.TriggerTypeClick,
}) {
<span
class={ util.TwMerge("inline-block", p.Class) }
{ p.Attributes... }
>
{ 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-content-id" }} // Must match fallback in Trigger
}
{{
var maxHeight string = "300px"
if p.MaxHeight != "" {
maxHeight = p.MaxHeight
}
maxHeightClass := fmt.Sprintf("max-h-[%s]", maxHeight)
}}
@popover.Content(popover.ContentProps{
ID: contentID,
Placement: popover.PlacementBottomStart,
Offset: 4,
Class: util.TwMerge(
"z-50 rounded-md bg-popover p-1 shadow-md focus:outline-none overflow-auto",
"border border-border",
"min-w-[8rem]",
maxHeightClass,
p.Width,
p.Class,
),
Attributes: 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={ util.TwMerge("py-1", p.Class) }
role="group"
{ p.Attributes... }
>
{ children... }
</div>
}
templ Label(props ...LabelProps) {
{{ var p LabelProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ util.TwMerge("px-2 py-1.5 text-sm font-semibold", p.Class) }
{ p.Attributes... }
>
{ children... }
</div>
}
templ Item(props ...ItemProps) {
{{ var p ItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.ID == "" {
{{ p.ID = util.RandomID() }}
}
if p.Href != "" {
<a
id={ p.ID }
if p.Href != "" {
href={ templ.SafeURL(p.Href) }
}
if p.Target != "" {
target={ p.Target }
}
class={
util.TwMerge(
"flex text-left items-center px-2 py-1.5 text-sm rounded-sm",
util.If(!p.Disabled, "focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground cursor-default"),
util.If(p.Disabled, "opacity-50 pointer-events-none"),
p.Class,
),
}
role="menuitem"
data-dropdown-item
{ p.Attributes... }
>
{ children... }
</a>
} else {
<button
id={ p.ID }
class={
util.TwMerge(
"w-full text-left flex items-center justify-between px-2 py-1.5 text-sm rounded-sm",
util.If(!p.Disabled, "focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground cursor-default"),
util.If(p.Disabled, "opacity-50 pointer-events-none"),
p.Class,
),
}
role="menuitem"
data-dropdown-item
disabled?={ p.Disabled }
{ p.Attributes... }
>
{ children... }
</button>
}
}
templ Separator(props ...SeparatorProps) {
{{ var p SeparatorProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ util.TwMerge("h-px my-1 -mx-1 bg-muted", p.Class) }
role="separator"
{ p.Attributes... }
></div>
}
templ Shortcut(props ...ShortcutProps) {
{{ var p ShortcutProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<span
if p.ID != "" {
id={ p.ID }
}
class={ util.TwMerge("ml-auto text-xs tracking-widest opacity-60", p.Class) }
{ p.Attributes... }
>
{ children... }
</span>
}
templ Sub(props ...SubProps) {
{{
var p SubProps
if len(props) > 0 {
p = props[0]
}
subContentID := p.ID
if subContentID == "" {
subContentID = util.RandomID()
}
ctx = context.WithValue(ctx, subContentIDKey, subContentID)
}}
<div
if p.ID != "" {
id={ p.ID }
}
data-dropdown-submenu
class={ util.TwMerge("relative", p.Class) }
{ p.Attributes... }
>
{ children... }
</div>
}
templ SubTrigger(props ...SubTriggerProps) {
{{
var p SubTriggerProps
if len(props) > 0 {
p = props[0]
}
subContentID, ok := ctx.Value(subContentIDKey).(string)
if !ok {
subContentID = "fallback-subcontent-id"
}
}}
@popover.Trigger(popover.TriggerProps{
ID: p.ID,
For: subContentID,
TriggerType: popover.TriggerTypeHover,
}) {
<button
type="button"
data-dropdown-submenu-trigger
class={
util.TwMerge(
"w-full text-left flex items-center justify-between px-2 py-1.5 text-sm rounded-sm",
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground cursor-default",
p.Class,
),
}
{ p.Attributes... }
>
<span>
{ children... }
</span>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-auto">
<path d="M6.5 3L11.5 8L6.5 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
}
}
templ SubContent(props ...SubContentProps) {
{{
var p SubContentProps
if len(props) > 0 {
p = props[0]
}
subContentID, ok := ctx.Value(subContentIDKey).(string)
if !ok {
subContentID = "fallback-subcontent-id"
}
}}
@popover.Content(popover.ContentProps{
ID: subContentID,
Placement: popover.PlacementRightStart,
Offset: -4, // Adjust as needed
HoverDelay: 100, // ms
HoverOutDelay: 200, // ms
Class: util.TwMerge(
"z-[9999] min-w-[8rem] rounded-md border bg-popover p-1 shadow-lg",
p.Class,
),
Attributes: p.Attributes,
}) {
{ children... }
}
}
var dropdownHandle = templ.NewOnceHandle()
templ Script() {
<script nonce={ templ.GetNonce(ctx) }>
(function() { // IIFE
function handleDropdownItemClick(event) {
const item = event.currentTarget;
const popoverContent = item.closest('[data-popover-id]');
if (popoverContent) {
const popoverId = popoverContent.dataset.popoverId;
if (window.closePopover) {
window.closePopover(popoverId, true);
} else {
console.warn("popover.Script's closePopover function not found.");
document.body.click(); // Fallback
}
}
}
function initAllComponents(root = document) {
// Select items with 'data-dropdown-item' but not 'data-dropdown-submenu-trigger'
const items = root.querySelectorAll('[data-dropdown-item]:not([data-dropdown-submenu-trigger])');
items.forEach(item => {
item.removeEventListener('click', handleDropdownItemClick);
item.addEventListener('click', handleDropdownItemClick);
});
}
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>
}