Displays a list of options for the user to pick from, triggered by a button.
package examples import selectcomp "github.com/axadrn/shadcn-templ/v2/components/select"package examples import selectcomp "github.com/axadrn/shadcn-templ/v2/components/select" templ SelectDemo() { @selectcomp.Select() { @selectcomp.Trigger(selectcomp.TriggerProps{Class: "w-full max-w-48"}) { @selectcomp.Value(selectcomp.ValueProps{Placeholder: "Select a fruit"}) } @selectcomp.Content() { @selectcomp.Group() { @selectcomp.Label() { Fruits } @selectcomp.Item(selectcomp.ItemProps{Value: "apple"}) { Apple } @selectcomp.Item(selectcomp.ItemProps{Value: "banana"}) { Banana } @selectcomp.Item(selectcomp.ItemProps{Value: "blueberry"}) { Blueberry } @selectcomp.Item(selectcomp.ItemProps{Value: "grapes"}) { Grapes } @selectcomp.Item(selectcomp.ItemProps{Value: "pineapple"}) { Pineapple } } } }}Installation
shadcn-templ add selectCopy and paste the following code into your project.
package selectcomp import ( "context" "github.com/axadrn/shadcn-templ/v2/components/icon" "github.com/axadrn/shadcn-templ/v2/utils") type Size string const ( SizeDefault Size = "default" SizeSm Size = "sm") type Align string const ( AlignStart Align = "start" AlignCenter Align = "center" AlignEnd Align = "end") type ctxKey string const stateKey ctxKey = "selectState" type ctxState struct { id string name string value string disabled bool} func state(ctx context.Context) ctxState { if s, ok := ctx.Value(stateKey).(ctxState); ok { return s } return ctxState{}} type Props struct { ID string // Name makes the select submit its value in forms (hidden input). Name string // Value preselects the item with this value. Value string Disabled bool} type TriggerProps struct { // ID for the trigger button (e.g. to point a label at it). ID string Class string Attributes templ.Attributes // Size of the trigger. Options: 'default' (h-8), 'sm' (h-7). Size Size} type ValueProps struct { Class string Attributes templ.Attributes // Placeholder is shown until an item is selected. Placeholder string} type ContentProps struct { Class string Attributes templ.Attributes // DisableAlignItemWithTrigger opens the popup below the trigger like a // dropdown instead of overlaying the selected item (Base UI // alignItemWithTrigger, default true). DisableAlignItemWithTrigger bool // Align along the trigger edge in popper mode. Defaults to center. Align Align} 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 // Label overrides the text shown on the trigger when this item is // selected (Base UI Select.Item label). Label string} type SeparatorProps struct { ID string Class string Attributes templ.Attributes} // Select renders no element: it only carries the id, name and value that link// Trigger, Content and the hidden form input (via ctx).templ Select(props ...Props) { {{ var p Props }} if len(props) > 0 { {{ p = props[0] }} } if p.ID == "" { {{ p.ID = utils.RandomID() }} } {{ ctx = context.WithValue(ctx, stateKey, ctxState{id: p.ID, name: p.Name, value: p.Value, disabled: p.Disabled}) }} { children... }} templ Trigger(props ...TriggerProps) { {{ var p TriggerProps }} if len(props) > 0 { {{ p = props[0] }} } if p.Size == "" { {{ p.Size = SizeDefault }} } {{ s := state(ctx) }} if s.name != "" { <input type="hidden" name={ s.name } value={ s.value } data-tui-select-input aria-hidden="true" tabindex="-1"/> } <button type="button" if p.ID != "" { id={ p.ID } } data-slot="select-trigger" data-tui-select-trigger data-size={ string(p.Size) } role="combobox" aria-controls={ s.id } aria-haspopup="listbox" aria-expanded="false" if s.value == "" { data-placeholder } disabled?={ s.disabled } class={ utils.CN( // 1:1 base/ui/select.tsx SelectTrigger, the look comes from // cn-select-trigger. "cn-select-trigger flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0", p.Class, ), } { p.Attributes... } > { children... } @icon.ChevronDown(icon.Props{Class: "cn-select-trigger-icon pointer-events-none"}) </button>} templ Value(props ...ValueProps) { {{ var p ValueProps }} if len(props) > 0 { {{ p = props[0] }} } <span data-slot="select-value" data-tui-select-value data-tui-select-placeholder={ p.Placeholder } class={ utils.CN("cn-select-value", p.Class) } { p.Attributes... } > { p.Placeholder } </span>} templ Content(props ...ContentProps) { {{ var p ContentProps }} if len(props) > 0 { {{ p = props[0] }} } if p.Align == "" { {{ p.Align = AlignCenter }} } {{ s := state(ctx) }} // Inert until the script portals it to <body> at init, so the SSRd // content never participates in layout or sibling CSS. <template data-tui-select-portal> <div id={ s.id } data-tui-select-content data-tui-select-disable-align-item-with-trigger?={ p.DisableAlignItemWithTrigger } data-tui-select-align={ string(p.Align) } data-state="closed" popover="manual" class="pointer-events-none isolate fixed inset-auto z-50 m-0 overflow-visible border-0 bg-transparent p-0" > <div data-slot="select-content" data-tui-select-popup if p.DisableAlignItemWithTrigger { data-align-trigger="false" } else { data-align-trigger="true" } data-state="closed" role="listbox" class={ utils.CN( // 1:1 base/ui/select.tsx SelectContent, the look comes from // cn-select-content (animations key on our data-state attribute). // The --available-height/--anchor-width/--transform-origin // variables become our JS variable names. "cn-select-content cn-select-content-logical cn-menu-target cn-menu-translucent relative isolate z-50 max-h-(--tui-select-available-height) w-(--tui-select-anchor-width) origin-(--tui-select-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none", // JS wiring: the positioner wrapper is pointer-events-none, the // scroll arrows/viewport need a flex column, and the popup stays // mounted after animate-out until hidePopover runs. "pointer-events-auto flex flex-col data-closed:fill-mode-forwards", p.Class, ), } { p.Attributes... } > <div data-slot="select-scroll-up-button" data-tui-select-scroll-up class="cn-select-scroll-up-button top-0 w-full absolute hidden"> @icon.ChevronUp() </div> <div data-tui-select-viewport class="relative flex-1 overflow-x-hidden overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> { children... } </div> <div data-slot="select-scroll-down-button" data-tui-select-scroll-down class="cn-select-scroll-down-button bottom-0 w-full absolute hidden"> @icon.ChevronDown() </div> </div> </div> </template>} templ Group(props ...GroupProps) { {{ var p GroupProps }} if len(props) > 0 { {{ p = props[0] }} } <div if p.ID != "" { id={ p.ID } } role="group" data-slot="select-group" class={ utils.CN("cn-select-group", p.Class) } { 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 } } data-slot="select-label" class={ utils.CN("cn-select-label", p.Class) } { p.Attributes... } > { children... } </div>} templ Item(props ...ItemProps) { {{ var p ItemProps }} if len(props) > 0 { {{ p = props[0] }} } {{ s := state(ctx) }} {{ selected := p.Selected || (s.value != "" && s.value == p.Value) }} <div if p.ID != "" { id={ p.ID } } role="option" tabindex="-1" data-slot="select-item" data-tui-select-item data-tui-select-value={ p.Value } if p.Label != "" { data-tui-select-label={ p.Label } } if selected { data-state="checked" aria-selected="true" } else { data-state="unchecked" aria-selected="false" } if p.Disabled { data-disabled aria-disabled="true" } class={ utils.CN( // 1:1 base/ui/select.tsx SelectItem, the look comes from // cn-select-item (items highlight via real focus, our JS moves focus // to the item under the pointer like Base UI). "cn-select-item relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", // Native wiring: Base UI unmounts the indicator when unchecked, our // indicator toggles on the checked state via the group instead. "group/select-item", p.Class, ), } { p.Attributes... } > <span data-tui-select-item-text class="cn-select-item-text shrink-0 whitespace-nowrap"> { children... } </span> <span class="cn-select-item-indicator opacity-0 group-data-[state=checked]/select-item:opacity-100"> @icon.Check(icon.Props{Class: "cn-select-item-indicator-icon pointer-events-none"}) </span> </div>} templ Separator(props ...SeparatorProps) { {{ var p SeparatorProps }} if len(props) > 0 { {{ p = props[0] }} } <div if p.ID != "" { id={ p.ID } } role="separator" data-slot="select-separator" class={ utils.CN("cn-select-separator pointer-events-none", p.Class) } { p.Attributes... } ></div>}// Uses window.FloatingUIDOM from components/floatingui (loaded in the same bundle).(function () { // Constants from Base UI's select, shadcn's reference implementation. const EXIT_MS = 120; // popper exit animation (duration-100) + slack const SIDE_OFFSET = 4; const COLLISION_PADDING = 5; const MARGIN = 10; // aligned mode: minimum distance to the viewport edges const MIN_HEIGHT = 100; // less room than this -> fall back to popper const TRIGGER_COLLISION = 20; // trigger this close to an edge -> popper const TOL = 1; // scroll edge tolerance const ARROW_TICK_MS = 40; // hovering a scroll arrow scrolls one item per tick const SELECTED_DELAY = 400; // mouseup selection stays disabled this long after open function allContents() { return document.querySelectorAll("[data-tui-select-content]"); } function triggerFor(content) { return document.querySelector( '[data-tui-select-trigger][aria-controls="' + content.id + '"]', ); } function contentFor(trigger) { return document.getElementById(trigger.getAttribute("aria-controls")); } // The hidden form input sits right before the trigger button. function inputFor(trigger) { const prev = trigger.previousElementSibling; return prev && prev.hasAttribute("data-tui-select-input") ? prev : null; } function valueSpanFor(trigger) { return trigger.querySelector("[data-tui-select-value]"); } function popupFor(content) { return content.querySelector("[data-tui-select-popup]"); } function viewportFor(content) { return content.querySelector("[data-tui-select-viewport]"); } function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function maxScrollTop(el) { return Math.max(0, el.scrollHeight - el.clientHeight); } function isAlignMode(content) { return !content.hasAttribute("data-tui-select-disable-align-item-with-trigger"); } function setState(content, state) { content.setAttribute("data-state", state); const popup = popupFor(content); if (popup) popup.setAttribute("data-state", state); } function setSide(content, side) { content.setAttribute("data-side", side); const popup = popupFor(content); if (popup) popup.setAttribute("data-side", side); } // Base UI zooms the popup out of the anchor's center point (e.g. // "96px -4px"), not out of a placement corner. function anchorOrigin(result, anchorRect) { const side = result.placement.split("-")[0]; const centerX = anchorRect.left + anchorRect.width / 2 - result.x + "px"; const centerY = anchorRect.top + anchorRect.height / 2 - result.y + "px"; if (side === "bottom") return centerX + " " + -SIDE_OFFSET + "px"; if (side === "top") return centerX + " calc(100% + " + SIDE_OFFSET + "px)"; if (side === "right") return -SIDE_OFFSET + "px " + centerY; return "calc(100% + " + SIDE_OFFSET + "px) " + centerY; } // Moves the content to <body> (shadcn portals it the same way). Also removes // contents whose trigger is gone (leftovers from swapped-out pages). function portal(content) { document.querySelectorAll("body > [data-tui-select-content]").forEach((c) => { if (c !== content && !triggerFor(c)) c.remove(); }); if (content.parentElement !== document.body) { document.body.appendChild(content); } } // Clears everything a previous open left behind on the positioner and popup. function resetInlineStyles(content) { ["left", "right", "top", "bottom", "height", "maxHeight", "marginTop", "marginBottom"].forEach( (prop) => (content.style[prop] = ""), ); const popup = popupFor(content); if (popup) popup.style.height = ""; } // Regular anchored placement below/above the trigger (Base UI's positioner). function positionPopper(content, trigger) { const { computePosition, offset, flip, shift, size } = window.FloatingUIDOM; const align = content.getAttribute("data-tui-select-align") || "center"; const placement = align === "center" ? "bottom" : "bottom-" + align; return computePosition(trigger, content, { placement: placement, strategy: "fixed", middleware: [ offset(SIDE_OFFSET), flip({ padding: COLLISION_PADDING }), shift({ padding: COLLISION_PADDING }), size({ padding: COLLISION_PADDING, apply(args) { content.style.setProperty( "--tui-select-available-height", args.availableHeight + "px", ); content.style.setProperty( "--tui-select-anchor-width", args.rects.reference.width + "px", ); }, }), ], }).then((result) => { content.style.left = result.x + "px"; content.style.top = result.y + "px"; setSide(content, result.placement.split("-")[0]); const popup = popupFor(content); if (popup) { popup.style.setProperty( "--tui-select-transform-origin", anchorOrigin(result, trigger.getBoundingClientRect()), ); } }); } // Overlays the menu so the selected item sits on the trigger with its text // aligned to the trigger text. Port of Base UI's SelectPopup align logic. // Runs after the popper pass (which sets the CSS vars and fallback coords); // returns false when Base UI would fall back to popper positioning. function positionAligned(content, trigger) { const popup = popupFor(content); const viewport = viewportFor(content); const valueEl = valueSpanFor(trigger); const textEl = content.querySelector('[data-tui-select-item][data-state="checked"] [data-tui-select-item-text]') || content.querySelector("[data-tui-select-item] [data-tui-select-item-text]"); const docEl = document.documentElement; const triggerRect = trigger.getBoundingClientRect(); const positionerRect = content.getBoundingClientRect(); // natural size from the popper pass // The list's natural height, measured before the aligned styles stretch // the popup (scrollHeight can never report less than the client height). const naturalScrollHeight = viewport.scrollHeight; const popupStyles = window.getComputedStyle(popup); const borderBottom = parseFloat(popupStyles.borderBottomWidth) || 0; const maxPopupHeight = parseFloat(popupStyles.maxHeight) || Infinity; const viewportHeight = docEl.clientHeight - MARGIN * 2; const viewportWidth = docEl.clientWidth; const availableSpaceBeneathTrigger = viewportHeight - triggerRect.bottom + triggerRect.height; let alignedLeft = triggerRect.left; let offsetY = 0; let textRect = null; if (textEl && valueEl) { const valueRect = valueEl.getBoundingClientRect(); textRect = textEl.getBoundingClientRect(); alignedLeft = positionerRect.left + (valueRect.left - textRect.left); offsetY = textRect.top - positionerRect.top + textRect.height / 2 - (valueRect.top - triggerRect.top + valueRect.height / 2); } const idealHeight = availableSpaceBeneathTrigger + offsetY + MARGIN + borderBottom; let height = Math.min(viewportHeight, idealHeight); const maxHeight = viewportHeight - MARGIN * 2; const scrollTop = idealHeight - height; content.style.left = clamp( alignedLeft, COLLISION_PADDING, Math.max(COLLISION_PADDING, viewportWidth - COLLISION_PADDING - positionerRect.width), ) + "px"; content.style.height = height + "px"; content.style.maxHeight = "none"; content.style.marginTop = MARGIN + "px"; content.style.marginBottom = MARGIN + "px"; popup.style.height = "100%"; const max = maxScrollTop(viewport); const isTopPositioned = scrollTop >= max - TOL; if (isTopPositioned) { height = Math.min(viewportHeight, positionerRect.height) - (scrollTop - max); } if ( triggerRect.top < TRIGGER_COLLISION || triggerRect.bottom > viewportHeight - TRIGGER_COLLISION || Math.ceil(height) + TOL < Math.min(naturalScrollHeight, MIN_HEIGHT) ) { return false; } content._tuiReachedMax = false; if (isTopPositioned) { const topOffset = Math.max(0, viewportHeight - idealHeight); content.style.top = (positionerRect.height >= maxHeight ? 0 : topOffset) + "px"; content.style.height = height + "px"; viewport.scrollTop = maxScrollTop(viewport); } else { content.style.top = "auto"; content.style.bottom = "0px"; viewport.scrollTop = scrollTop; } if (textRect) { const clampedY = clamp( positionerRect.height > 0 ? ((textRect.top + textRect.height / 2 - positionerRect.top) / positionerRect.height) * 100 : 50, 0, 100, ); popup.style.setProperty("--tui-select-transform-origin", "50% " + clampedY + "%"); } setSide(content, "none"); if (height >= viewportHeight || height >= maxPopupHeight) { content._tuiReachedMax = true; } return true; } function position(content, trigger) { const popup = popupFor(content); const viewport = viewportFor(content); if (!popup || !viewport) return Promise.resolve(); const alignMode = isAlignMode(content); popup.setAttribute("data-align-trigger", alignMode ? "true" : "false"); resetInlineStyles(content); content._tuiAligned = false; return positionPopper(content, trigger) .then(() => { if (!alignMode) return undefined; if (positionAligned(content, trigger)) { content._tuiAligned = true; return undefined; } // Not enough room: redo the plain popper pass (the aligned attempt // dirtied the inline styles). resetInlineStyles(content); return positionPopper(content, trigger); }) .then(() => updateScrollArrows(content)); } // ----- scroll arrows + capped grow-on-scroll (Base UI behavior) ----------- function updateScrollArrows(content) { const viewport = viewportFor(content); const up = content.querySelector("[data-tui-select-scroll-up]"); const down = content.querySelector("[data-tui-select-scroll-down]"); if (!viewport || !up || !down) return; const max = maxScrollTop(viewport); up.classList.toggle("hidden", max <= 0 || viewport.scrollTop <= TOL); down.classList.toggle("hidden", max <= 0 || viewport.scrollTop >= max - TOL); } // In aligned mode scrolling first consumes the remaining space toward the // viewport edge (capped by the popup's max-height), then scrolls the list. function handleAlignedScroll(content) { const viewport = viewportFor(content); const popup = popupFor(content); if (!viewport || !popup) return; const isTopPositioned = content.style.top === "0px"; const isBottomPositioned = content.style.bottom === "0px"; if (content._tuiReachedMax || !content._tuiAligned || (!isTopPositioned && !isBottomPositioned)) { updateScrollArrows(content); return; } const currentHeight = content.getBoundingClientRect().height; const maxPopupHeight = parseFloat(window.getComputedStyle(popup).maxHeight) || Infinity; const maxAvailableHeight = Math.min( document.documentElement.clientHeight - MARGIN * 2, maxPopupHeight, ); const scrollTop = viewport.scrollTop; const max = maxScrollTop(viewport); let nextScrollTop = null; const diff = isTopPositioned ? max - scrollTop : scrollTop; const nextHeight = Math.min(currentHeight + diff, maxAvailableHeight); if (diff <= TOL) { const heightDelta = clamp(diff, 0, maxAvailableHeight - currentHeight); if (heightDelta > 0) { content.style.height = currentHeight + heightDelta + "px"; } viewport.scrollTop = isTopPositioned ? maxScrollTop(viewport) : 0; if (maxAvailableHeight - (currentHeight + heightDelta) <= TOL) { content._tuiReachedMax = true; } updateScrollArrows(content); return; } if (maxAvailableHeight - nextHeight > TOL) { nextScrollTop = isTopPositioned ? Infinity : 0; } else if (isBottomPositioned && scrollTop < max) { const overshoot = currentHeight + diff - maxAvailableHeight; nextScrollTop = scrollTop - (diff - overshoot); } const nextPositionerHeight = Math.ceil(nextHeight); if (nextPositionerHeight !== 0) { content.style.height = nextPositionerHeight + "px"; } if (nextScrollTop != null) { const target = clamp(nextScrollTop, 0, maxScrollTop(viewport)); if (Math.abs(viewport.scrollTop - target) > TOL) { viewport.scrollTop = target; } } if (nextPositionerHeight >= maxAvailableHeight - TOL) { content._tuiReachedMax = true; } updateScrollArrows(content); } // Hovering a scroll arrow scrolls one item per tick, keeping the next item // clear of the arrow overlay (Base UI's getTargetScrollTop). function targetScrollTop(items, isUp, scrollTop, clientHeight, arrowHeight, max) { if (isUp) { let firstVisibleIndex = 0; const visibleTop = scrollTop + arrowHeight - TOL; for (let i = 0; i < items.length; i += 1) { if (items[i].offsetTop >= visibleTop) { firstVisibleIndex = i; break; } } const targetIndex = Math.max(0, firstVisibleIndex - 1); const target = items[targetIndex]; return targetIndex < firstVisibleIndex && target ? clamp(target.offsetTop - arrowHeight, 0, max) : 0; } let lastVisibleIndex = items.length - 1; const visibleBottom = scrollTop + clientHeight - arrowHeight + TOL; for (let i = 0; i < items.length; i += 1) { if (items[i].offsetTop + items[i].offsetHeight > visibleBottom) { lastVisibleIndex = Math.max(0, i - 1); break; } } const targetIndex = Math.min(items.length - 1, lastVisibleIndex + 1); const target = items[targetIndex]; return targetIndex > lastVisibleIndex && target ? clamp(target.offsetTop + target.offsetHeight - clientHeight + arrowHeight, 0, max) : max; } let arrowTimer = null; function stopArrowScroll() { clearTimeout(arrowTimer); arrowTimer = null; } function arrowScrollStep(content, isUp, arrow) { const viewport = viewportFor(content); if (!viewport) return; updateScrollArrows(content); const max = maxScrollTop(viewport); const scrollTop = clamp(viewport.scrollTop, 0, max); if (scrollTop === (isUp ? 0 : max)) { stopArrowScroll(); return; } const items = [...content.querySelectorAll("[data-tui-select-item]")]; viewport.scrollTop = targetScrollTop( items, isUp, scrollTop, viewport.clientHeight, arrow.offsetHeight || 0, max, ); arrowTimer = setTimeout(() => arrowScrollStep(content, isUp, arrow), ARROW_TICK_MS); } document.addEventListener("mouseover", (e) => { if (!(e.target instanceof Element)) return; const arrow = e.target.closest("[data-tui-select-scroll-up], [data-tui-select-scroll-down]"); if (!arrow || arrowTimer) return; const content = arrow.closest("[data-tui-select-content]"); if (content) arrowScrollStep(content, arrow.hasAttribute("data-tui-select-scroll-up"), arrow); }); document.addEventListener("mouseout", (e) => { if (!(e.target instanceof Element)) return; if (e.target.closest("[data-tui-select-scroll-up], [data-tui-select-scroll-down]")) { stopArrowScroll(); } }); // ----- open / close ------------------------------------------------------- // The select is modal (Base UI default): the background scroll is locked // while open, with the body padded by the scrollbar width so the page // does not shift. function lockScroll() { if (document.body.hasAttribute("data-tui-scroll-locked")) return; const scrollbar = window.innerWidth - document.documentElement.clientWidth; document.body.setAttribute("data-tui-scroll-locked", ""); document.body.style.overflow = "hidden"; if (scrollbar > 0) document.body.style.paddingRight = scrollbar + "px"; } function unlockScroll() { if ([...allContents()].some((c) => c.getAttribute("data-state") === "open")) return; document.body.removeAttribute("data-tui-scroll-locked"); document.body.style.overflow = ""; document.body.style.paddingRight = ""; } function open(content, trigger) { allContents().forEach((c) => { if (c !== content) close(c); }); clearTimeout(content._tuiHide); // A press on the trigger can open the popup under the pointer (aligned // mode). Mouseup selection stays disabled briefly so releasing over the // selected item or a neighboring item doesn't commit an accidental // selection (Base UI's selectionRef + SELECTED_DELAY). Dragging can // re-arm unselected mouseup sooner, see the pointermove handler. content._tuiSelection = { allowSelectedMouseUp: false, allowUnselectedMouseUp: false, dragY: 0, }; clearTimeout(content._tuiSelectedDelay); content._tuiSelectedDelay = setTimeout(() => { content._tuiSelection.allowSelectedMouseUp = true; content._tuiSelection.allowUnselectedMouseUp = true; }, SELECTED_DELAY); portal(content); lockScroll(); // Base UI's select is modal by default: no page scroll while open if (!content.matches(":popover-open")) { content.showPopover(); // native top layer } // Position it invisibly first, then play the enter animation in place. content.style.visibility = "hidden"; const finish = () => { content.style.visibility = ""; if (!content.matches(":popover-open")) return; setState(content, "open"); trigger.setAttribute("aria-expanded", "true"); // Base UI moves focus to the selected item when the listbox opens. const selected = content.querySelector('[data-tui-select-item][data-state="checked"]') || content.querySelector("[data-tui-select-item]"); if (selected) selected.focus({ preventScroll: true }); }; position(content, trigger).then(finish, finish); } function close(content) { if (!content.matches(":popover-open")) return; stopArrowScroll(); clearTimeout(content._tuiSelectedDelay); content._tuiSelection = { allowSelectedMouseUp: false, allowUnselectedMouseUp: false, dragY: 0, }; content.style.visibility = ""; setState(content, "closed"); unlockScroll(); const trigger = triggerFor(content); if (trigger) trigger.setAttribute("aria-expanded", "false"); clearTimeout(content._tuiHide); // Aligned mode has no exit animation (animate-none, like shadcn) — hide // immediately instead of waiting for one. const popup = popupFor(content); if (popup && popup.getAttribute("data-align-trigger") === "true") { content.hidePopover(); return; } content._tuiHide = setTimeout(() => { if (content.getAttribute("data-state") === "closed" && content.matches(":popover-open")) { content.hidePopover(); } }, EXIT_MS); } function closeAll() { allContents().forEach(close); } function selectItem(content, item) { const trigger = triggerFor(content); if (!trigger) return; const value = item.getAttribute("data-tui-select-value") || ""; const label = item.getAttribute("data-tui-select-label") || (item.querySelector("[data-tui-select-item-text]") || item).textContent.trim(); content.querySelectorAll("[data-tui-select-item]").forEach((i) => { i.setAttribute("data-state", "unchecked"); i.setAttribute("aria-selected", "false"); }); item.setAttribute("data-state", "checked"); item.setAttribute("aria-selected", "true"); const span = valueSpanFor(trigger); if (span) span.textContent = label; trigger.removeAttribute("data-placeholder"); const input = inputFor(trigger); if (input && input.value !== value) { input.value = value; input.dispatchEvent(new Event("change", { bubbles: true })); } trigger.dispatchEvent( new CustomEvent("select-change", { bubbles: true, detail: { value: value, label: label } }), ); close(content); trigger.focus(); } // Shows the selected item's label in the trigger (server only knows the // value, the label lives in the item). Runs on load and whenever new selects // appear in the DOM (e.g. content swapped in by a library like htmx) — the // MutationObserver keeps this framework-agnostic. // Lift SSR'd contents out of their inert <template> wrappers into <body>, // replacing a stale portaled copy on re-swaps (e.g. htmx). function liftTemplates() { document.querySelectorAll("template[data-tui-select-portal]").forEach((tpl) => { const content = tpl.content.querySelector("[data-tui-select-content]"); if (content) { const stale = document.getElementById(content.id); if (stale) stale.remove(); document.body.appendChild(content); } tpl.remove(); }); } function syncTriggers() { liftTemplates(); document.querySelectorAll("[data-tui-select-trigger]").forEach((trigger) => { const content = contentFor(trigger); if (!content) return; portal(content); // portal up front, like React does on mount const checked = content.querySelector('[data-tui-select-item][data-state="checked"]'); if (!checked) return; const label = checked.getAttribute("data-tui-select-label") || (checked.querySelector("[data-tui-select-item-text]") || checked).textContent.trim(); const span = valueSpanFor(trigger); if (span && span.textContent.trim() !== label) span.textContent = label; if (trigger.hasAttribute("data-placeholder")) trigger.removeAttribute("data-placeholder"); }); } let syncQueued = false; function queueSync() { if (syncQueued) return; syncQueued = true; requestAnimationFrame(() => { syncQueued = false; syncTriggers(); }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", syncTriggers); } else { syncTriggers(); } new MutationObserver((mutations) => { for (const m of mutations) { if (m.addedNodes.length) { queueSync(); break; } } }).observe(document.documentElement, { childList: true, subtree: true }); // ----- events ------------------------------------------------------------- // Pointer interactions toggle and dismiss on PRESS, exactly like Base UI. // Click is never used for open/close, so the stray click the browser fires // on <body> after the menu opened over the trigger is naturally harmless. function toggle(trigger) { const content = contentFor(trigger); if (!content) return; if (content.getAttribute("data-state") === "open") { close(content); } else { open(content, trigger); } } // Pendant of floating-ui useClick's pointerTypeRef: pointerdown marks the // trigger, the click that follows the same press is skipped. A click // without the mark (a <label for> forward, keyboard activation, or a // programmatic .click()) toggles instead. const pressedTriggers = new WeakSet(); function isMouseWithinBounds(e, el) { const rect = el.getBoundingClientRect(); return ( e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom ); } // Pendant of SelectTrigger's mousedown handler: the press that opened the // popup cancels the open again when released outside the trigger and the // popup positioner. function armCancelOpen(trigger, content) { // Firefox can fire the mouseup upon mousedown, hence the deferred attach. setTimeout(() => { document.addEventListener( "mouseup", (e) => { const target = e.target instanceof Element ? e.target : null; // Don't treat the release as an outside press when it lands on the // trigger or inside the popup (or their children). if (target && (trigger.contains(target) || content.contains(target))) return; if (isMouseWithinBounds(e, trigger)) return; close(content); }, { once: true }, ); }, 0); } document.addEventListener("pointerdown", (e) => { if (e.button !== 0 || !(e.target instanceof Element)) return; const trigger = e.target.closest("[data-tui-select-trigger]"); if (trigger) { // Touch opens on the click that fires at release (Base UI opens on // the compat mousedown, which for touch also fires post-touchend). // Opening at press would put the aligned popup under the still-down // finger, and the tap's click, hit-tested at the release point, // would land on the item above the trigger and instantly commit it. if (e.pointerType === "touch") return; pressedTriggers.add(trigger); // Keep the browser from focusing the trigger button, focus lives on // the selected item while the listbox is open (Base UI focus scope). e.preventDefault(); if (!trigger.disabled) { const content = contentFor(trigger); if (content) { if (content.getAttribute("data-state") === "open") { close(content); } else { open(content, trigger); armCancelOpen(trigger, content); } } } return; } // Pendant of SelectItem's allowMouseSelectionRef: a real pointer click // only commits when its press started on the item. The stray click the // browser hit-tests onto the popup that just opened over the trigger // (touch fires its compatibility click at the tap position) never did. const item = e.target.closest("[data-tui-select-item]"); if (item) { item._tuiPointerType = e.pointerType; item._tuiAllowMouseSelection = true; const content = item.closest("[data-tui-select-content]"); if (content && content._tuiSelection) content._tuiSelection.dragY = 0; } if (!e.target.closest("[data-tui-select-content]")) closeAll(); }); document.addEventListener("pointerover", (e) => { if (!(e.target instanceof Element)) return; const item = e.target.closest("[data-tui-select-item]"); if (item) item._tuiPointerType = e.pointerType; }); document.addEventListener("click", (e) => { if (!(e.target instanceof Element)) return; const trigger = e.target.closest("[data-tui-select-trigger]"); if (trigger) { if (pressedTriggers.has(trigger)) { pressedTriggers.delete(trigger); return; } if (!trigger.disabled) toggle(trigger); return; } const item = e.target.closest("[data-tui-select-item]"); if (item) { const content = item.closest("[data-tui-select-content]"); if (!content) return; // Virtual clicks (detail 0: keyboard, assistive technology, .click()) // represent explicit activation and always commit; so do touch clicks, // whose press necessarily started on the item. const isMouseClick = (item._tuiPointerType || "mouse") !== "touch"; const isVirtualClick = e.detail === 0; const isInvalidMouseClick = isMouseClick && !isVirtualClick && !item._tuiAllowMouseSelection; item._tuiAllowMouseSelection = false; if (item.hasAttribute("data-disabled") || isInvalidMouseClick) return; selectItem(content, item); } }); // Pendant of SelectItem's mouseup: releasing a press that started on the // trigger commits the item under the pointer (press trigger, drag, release // to select), once the SELECTED_DELAY / drag guards allow it. Touch never // selects on mouseup, only on click. document.addEventListener("mouseup", (e) => { if (!(e.target instanceof Element)) return; const item = e.target.closest("[data-tui-select-item]"); if (!item) return; const content = item.closest("[data-tui-select-content]"); const selection = content && content._tuiSelection; if (!selection) return; selection.dragY = 0; if (item.hasAttribute("data-disabled") || item._tuiPointerType === "touch") return; // Regular clicks are committed by the click event. if (item._tuiAllowMouseSelection) return; const selected = item.getAttribute("data-state") === "checked"; if ( (!selection.allowSelectedMouseUp && selected) || (!selection.allowUnselectedMouseUp && !selected) ) { return; } item._tuiAllowMouseSelection = true; item.click(); item._tuiAllowMouseSelection = false; }); let typeBuffer = ""; let typeTimer; document.addEventListener("keydown", (e) => { if (!(e.target instanceof Element)) return; if (e.key === "Escape") { allContents().forEach((content) => { if (content.getAttribute("data-state") !== "open") return; const trigger = triggerFor(content); close(content); if (trigger) trigger.focus(); }); return; } // Closed trigger: arrow keys open the listbox (Enter/Space go through // the native button click path). const trigger = e.target.closest("[data-tui-select-trigger]"); if (trigger && !trigger.disabled) { pressedTriggers.delete(trigger); // like useClick's onKeyDown reset if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); const content = contentFor(trigger); if (content && content.getAttribute("data-state") !== "open") open(content, trigger); } return; } // Open listbox: roving focus on the items. const item = e.target.closest("[data-tui-select-item]"); if (!item) return; const content = item.closest("[data-tui-select-content]"); if (!content) return; const items = [...content.querySelectorAll("[data-tui-select-item]")].filter( (i) => !i.hasAttribute("data-disabled"), ); const index = items.indexOf(item); if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); const next = items[index + (e.key === "ArrowDown" ? 1 : -1)]; if (next) next.focus(); } else if (e.key === "Home" || e.key === "End") { e.preventDefault(); const edge = e.key === "Home" ? items[0] : items[items.length - 1]; if (edge) edge.focus(); } else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectItem(content, item); } else if (e.key === "Tab") { close(content); } else if (e.key.length === 1) { clearTimeout(typeTimer); typeBuffer += e.key.toLowerCase(); typeTimer = setTimeout(() => { typeBuffer = ""; }, 500); const match = items.find((i) => i.textContent.trim().toLowerCase().startsWith(typeBuffer)); if (match) match.focus(); } }); // The highlight follows the pointer, one highlighted item at a time. document.addEventListener("pointermove", (e) => { if (!(e.target instanceof Element)) return; const item = e.target.closest("[data-tui-select-item]"); if (!item) return; // Dragging with the button held re-arms unselected mouseup selection // before SELECTED_DELAY has elapsed, once the drag covers >= 8px. if (e.pointerType === "mouse" && e.buttons === 1) { const content = item.closest("[data-tui-select-content]"); if (content && content._tuiSelection) { content._tuiSelection.dragY += e.movementY; if (content._tuiSelection.dragY ** 2 >= 64) { content._tuiSelection.allowUnselectedMouseUp = true; } } } if (!item.hasAttribute("data-disabled") && document.activeElement !== item) { item.focus({ preventScroll: true }); } }); window.addEventListener( "scroll", (e) => { const inMenu = e.target instanceof Element && e.target.closest("[data-tui-select-content]"); if (inMenu) { if (inMenu._tuiAligned) { handleAlignedScroll(inMenu); } else { updateScrollArrows(inMenu); } return; } // Page scroll: keep open popper menus attached to their trigger // (aligned menus lock page scroll instead). allContents().forEach((content) => { if (content.getAttribute("data-state") !== "open" || content._tuiAligned) return; const trigger = triggerFor(content); if (trigger) positionPopper(content, trigger).then(() => updateScrollArrows(content)); }); }, true, ); window.addEventListener("resize", () => { allContents().forEach((content) => { if (content.getAttribute("data-state") !== "open") return; const trigger = triggerFor(content); if (trigger) position(content, trigger); }); });})();// https://cdn.jsdelivr.net/npm/@floating-ui/[email protected]!(function (t, e) { "object" == typeof exports && "undefined" != typeof module ? e(exports) : "function" == typeof define && define.amd ? define(["exports"], e) : e( ((t = "undefined" != typeof globalThis ? globalThis : t || self).FloatingUICore = {}) );})(this, function (t) { "use strict"; const e = ["top", "right", "bottom", "left"], n = ["start", "end"], i = e.reduce((t, e) => t.concat(e, e + "-" + n[0], e + "-" + n[1]), []), o = Math.min, r = Math.max, a = { left: "right", right: "left", bottom: "top", top: "bottom" }, l = { start: "end", end: "start" }; function s(t, e, n) { return r(t, o(e, n)); } function f(t, e) { return "function" == typeof t ? t(e) : t; } function c(t) { return t.split("-")[0]; } function u(t) { return t.split("-")[1]; } function m(t) { return "x" === t ? "y" : "x"; } function d(t) { return "y" === t ? "height" : "width"; } function g(t) { return ["top", "bottom"].includes(c(t)) ? "y" : "x"; } function p(t) { return m(g(t)); } function h(t, e, n) { void 0 === n && (n = !1); const i = u(t), o = p(t), r = d(o); let a = "x" === o ? i === (n ? "end" : "start") ? "right" : "left" : "start" === i ? "bottom" : "top"; return e.reference[r] > e.floating[r] && (a = w(a)), [a, w(a)]; } function y(t) { return t.replace(/start|end/g, (t) => l[t]); } function w(t) { return t.replace(/left|right|bottom|top/g, (t) => a[t]); } function x(t) { return "number" != typeof t ? (function (t) { return { top: 0, right: 0, bottom: 0, left: 0, ...t }; })(t) : { top: t, right: t, bottom: t, left: t }; } function v(t) { const { x: e, y: n, width: i, height: o } = t; return { width: i, height: o, top: n, left: e, right: e + i, bottom: n + o, x: e, y: n, }; } function b(t, e, n) { let { reference: i, floating: o } = t; const r = g(e), a = p(e), l = d(a), s = c(e), f = "y" === r, m = i.x + i.width / 2 - o.width / 2, h = i.y + i.height / 2 - o.height / 2, y = i[l] / 2 - o[l] / 2; let w; switch (s) { case "top": w = { x: m, y: i.y - o.height }; break; case "bottom": w = { x: m, y: i.y + i.height }; break; case "right": w = { x: i.x + i.width, y: h }; break; case "left": w = { x: i.x - o.width, y: h }; break; default: w = { x: i.x, y: i.y }; } switch (u(e)) { case "start": w[a] -= y * (n && f ? -1 : 1); break; case "end": w[a] += y * (n && f ? -1 : 1); } return w; } async function A(t, e) { var n; void 0 === e && (e = {}); const { x: i, y: o, platform: r, rects: a, elements: l, strategy: s } = t, { boundary: c = "clippingAncestors", rootBoundary: u = "viewport", elementContext: m = "floating", altBoundary: d = !1, padding: g = 0, } = f(e, t), p = x(g), h = l[d ? ("floating" === m ? "reference" : "floating") : m], y = v( await r.getClippingRect({ element: null == (n = await (null == r.isElement ? void 0 : r.isElement(h))) || n ? h : h.contextElement || (await (null == r.getDocumentElement ? void 0 : r.getDocumentElement(l.floating))), boundary: c, rootBoundary: u, strategy: s, }) ), w = "floating" === m ? { x: i, y: o, width: a.floating.width, height: a.floating.height } : a.reference, b = await (null == r.getOffsetParent ? void 0 : r.getOffsetParent(l.floating)), A = ((await (null == r.isElement ? void 0 : r.isElement(b))) && (await (null == r.getScale ? void 0 : r.getScale(b)))) || { x: 1, y: 1, }, R = v( r.convertOffsetParentRelativeRectToViewportRelativeRect ? await r.convertOffsetParentRelativeRectToViewportRelativeRect({ elements: l, rect: w, offsetParent: b, strategy: s, }) : w ); return { top: (y.top - R.top + p.top) / A.y, bottom: (R.bottom - y.bottom + p.bottom) / A.y, left: (y.left - R.left + p.left) / A.x, right: (R.right - y.right + p.right) / A.x, }; } function R(t, e) { return { top: t.top - e.height, right: t.right - e.width, bottom: t.bottom - e.height, left: t.left - e.width, }; } function P(t) { return e.some((e) => t[e] >= 0); } function D(t) { const e = o(...t.map((t) => t.left)), n = o(...t.map((t) => t.top)); return { x: e, y: n, width: r(...t.map((t) => t.right)) - e, height: r(...t.map((t) => t.bottom)) - n, }; } (t.arrow = (t) => ({ name: "arrow", options: t, async fn(e) { const { x: n, y: i, placement: r, rects: a, platform: l, elements: c, middlewareData: m, } = e, { element: g, padding: h = 0 } = f(t, e) || {}; if (null == g) return {}; const y = x(h), w = { x: n, y: i }, v = p(r), b = d(v), A = await l.getDimensions(g), R = "y" === v, P = R ? "top" : "left", D = R ? "bottom" : "right", T = R ? "clientHeight" : "clientWidth", O = a.reference[b] + a.reference[v] - w[v] - a.floating[b], E = w[v] - a.reference[v], L = await (null == l.getOffsetParent ? void 0 : l.getOffsetParent(g)); let k = L ? L[T] : 0; (k && (await (null == l.isElement ? void 0 : l.isElement(L)))) || (k = c.floating[T] || a.floating[b]); const C = O / 2 - E / 2, B = k / 2 - A[b] / 2 - 1, H = o(y[P], B), S = o(y[D], B), F = H, j = k - A[b] - S, z = k / 2 - A[b] / 2 + C, M = s(F, z, j), V = !m.arrow && null != u(r) && z !== M && a.reference[b] / 2 - (z < F ? H : S) - A[b] / 2 < 0, W = V ? (z < F ? z - F : z - j) : 0; return { [v]: w[v] + W, data: { [v]: M, centerOffset: z - M - W, ...(V && { alignmentOffset: W }), }, reset: V, }; }, })), (t.autoPlacement = function (t) { return ( void 0 === t && (t = {}), { name: "autoPlacement", options: t, async fn(e) { var n, o, r; const { rects: a, middlewareData: l, placement: s, platform: m, elements: d, } = e, { crossAxis: g = !1, alignment: p, allowedPlacements: w = i, autoAlignment: x = !0, ...v } = f(t, e), b = void 0 !== p || w === i ? (function (t, e, n) { return ( t ? [ ...n.filter((e) => u(e) === t), ...n.filter((e) => u(e) !== t), ] : n.filter((t) => c(t) === t) ).filter((n) => !t || u(n) === t || (!!e && y(n) !== n)); })(p || null, x, w) : w, R = await A(e, v), P = (null == (n = l.autoPlacement) ? void 0 : n.index) || 0, D = b[P]; if (null == D) return {}; const T = h( D, a, await (null == m.isRTL ? void 0 : m.isRTL(d.floating)) ); if (s !== D) return { reset: { placement: b[0] } }; const O = [R[c(D)], R[T[0]], R[T[1]]], E = [ ...((null == (o = l.autoPlacement) ? void 0 : o.overflows) || []), { placement: D, overflows: O }, ], L = b[P + 1]; if (L) return { data: { index: P + 1, overflows: E }, reset: { placement: L }, }; const k = E.map((t) => { const e = u(t.placement); return [ t.placement, e && g ? t.overflows.slice(0, 2).reduce((t, e) => t + e, 0) : t.overflows[0], t.overflows, ]; }).sort((t, e) => t[1] - e[1]), C = (null == (r = k.filter((t) => t[2].slice(0, u(t[0]) ? 2 : 3).every((t) => t <= 0) )[0]) ? void 0 : r[0]) || k[0][0]; return C !== s ? { data: { index: P + 1, overflows: E }, reset: { placement: C }, } : {}; }, } ); }), (t.computePosition = async (t, e, n) => { const { placement: i = "bottom", strategy: o = "absolute", middleware: r = [], platform: a, } = n, l = r.filter(Boolean), s = await (null == a.isRTL ? void 0 : a.isRTL(e)); let f = await a.getElementRects({ reference: t, floating: e, strategy: o, }), { x: c, y: u } = b(f, i, s), m = i, d = {}, g = 0; for (let n = 0; n < l.length; n++) { const { name: r, fn: p } = l[n], { x: h, y: y, data: w, reset: x, } = await p({ x: c, y: u, initialPlacement: i, placement: m, strategy: o, middlewareData: d, rects: f, platform: a, elements: { reference: t, floating: e }, }); (c = null != h ? h : c), (u = null != y ? y : u), (d = { ...d, [r]: { ...d[r], ...w } }), x && g <= 50 && (g++, "object" == typeof x && (x.placement && (m = x.placement), x.rects && (f = !0 === x.rects ? await a.getElementRects({ reference: t, floating: e, strategy: o, }) : x.rects), ({ x: c, y: u } = b(f, m, s))), (n = -1)); } return { x: c, y: u, placement: m, strategy: o, middlewareData: d }; }), (t.detectOverflow = A), (t.flip = function (t) { return ( void 0 === t && (t = {}), { name: "flip", options: t, async fn(e) { var n, i; const { placement: o, middlewareData: r, rects: a, initialPlacement: l, platform: s, elements: m, } = e, { mainAxis: d = !0, crossAxis: p = !0, fallbackPlacements: x, fallbackStrategy: v = "bestFit", fallbackAxisSideDirection: b = "none", flipAlignment: R = !0, ...P } = f(t, e); if (null != (n = r.arrow) && n.alignmentOffset) return {}; const D = c(o), T = g(l), O = c(l) === l, E = await (null == s.isRTL ? void 0 : s.isRTL(m.floating)), L = x || (O || !R ? [w(l)] : (function (t) { const e = w(t); return [y(t), e, y(e)]; })(l)), k = "none" !== b; !x && k && L.push( ...(function (t, e, n, i) { const o = u(t); let r = (function (t, e, n) { const i = ["left", "right"], o = ["right", "left"], r = ["top", "bottom"], a = ["bottom", "top"]; switch (t) { case "top": case "bottom": return n ? (e ? o : i) : e ? i : o; case "left": case "right": return e ? r : a; default: return []; } })(c(t), "start" === n, i); return ( o && ((r = r.map((t) => t + "-" + o)), e && (r = r.concat(r.map(y)))), r ); })(l, R, b, E) ); const C = [l, ...L], B = await A(e, P), H = []; let S = (null == (i = r.flip) ? void 0 : i.overflows) || []; if ((d && H.push(B[D]), p)) { const t = h(o, a, E); H.push(B[t[0]], B[t[1]]); } if ( ((S = [...S, { placement: o, overflows: H }]), !H.every((t) => t <= 0)) ) { var F, j; const t = ((null == (F = r.flip) ? void 0 : F.index) || 0) + 1, e = C[t]; if (e) { var z; const n = "alignment" === p && T !== g(e), i = (null == (z = S[0]) ? void 0 : z.overflows[0]) > 0; if (!n || i) return { data: { index: t, overflows: S }, reset: { placement: e }, }; } let n = null == (j = S.filter((t) => t.overflows[0] <= 0).sort( (t, e) => t.overflows[1] - e.overflows[1] )[0]) ? void 0 : j.placement; if (!n) switch (v) { case "bestFit": { var M; const t = null == (M = S.filter((t) => { if (k) { const e = g(t.placement); return e === T || "y" === e; } return !0; }) .map((t) => [ t.placement, t.overflows .filter((t) => t > 0) .reduce((t, e) => t + e, 0), ]) .sort((t, e) => t[1] - e[1])[0]) ? void 0 : M[0]; t && (n = t); break; } case "initialPlacement": n = l; } if (o !== n) return { reset: { placement: n } }; } return {}; }, } ); }), (t.hide = function (t) { return ( void 0 === t && (t = {}), { name: "hide", options: t, async fn(e) { const { rects: n } = e, { strategy: i = "referenceHidden", ...o } = f(t, e); switch (i) { case "referenceHidden": { const t = R( await A(e, { ...o, elementContext: "reference" }), n.reference ); return { data: { referenceHiddenOffsets: t, referenceHidden: P(t) }, }; } case "escaped": { const t = R(await A(e, { ...o, altBoundary: !0 }), n.floating); return { data: { escapedOffsets: t, escaped: P(t) } }; } default: return {}; } }, } ); }), (t.inline = function (t) { return ( void 0 === t && (t = {}), { name: "inline", options: t, async fn(e) { const { placement: n, elements: i, rects: a, platform: l, strategy: s, } = e, { padding: u = 2, x: m, y: d } = f(t, e), p = Array.from( (await (null == l.getClientRects ? void 0 : l.getClientRects(i.reference))) || [] ), h = (function (t) { const e = t.slice().sort((t, e) => t.y - e.y), n = []; let i = null; for (let t = 0; t < e.length; t++) { const o = e[t]; !i || o.y - i.y > i.height / 2 ? n.push([o]) : n[n.length - 1].push(o), (i = o); } return n.map((t) => v(D(t))); })(p), y = v(D(p)), w = x(u); const b = await l.getElementRects({ reference: { getBoundingClientRect: function () { if ( 2 === h.length && h[0].left > h[1].right && null != m && null != d ) return ( h.find( (t) => m > t.left - w.left && m < t.right + w.right && d > t.top - w.top && d < t.bottom + w.bottom ) || y ); if (h.length >= 2) { if ("y" === g(n)) { const t = h[0], e = h[h.length - 1], i = "top" === c(n), o = t.top, r = e.bottom, a = i ? t.left : e.left, l = i ? t.right : e.right; return { top: o, bottom: r, left: a, right: l, width: l - a, height: r - o, x: a, y: o, }; } const t = "left" === c(n), e = r(...h.map((t) => t.right)), i = o(...h.map((t) => t.left)), a = h.filter((n) => (t ? n.left === i : n.right === e)), l = a[0].top, s = a[a.length - 1].bottom; return { top: l, bottom: s, left: i, right: e, width: e - i, height: s - l, x: i, y: l, }; } return y; }, }, floating: i.floating, strategy: s, }); return a.reference.x !== b.reference.x || a.reference.y !== b.reference.y || a.reference.width !== b.reference.width || a.reference.height !== b.reference.height ? { reset: { rects: b } } : {}; }, } ); }), (t.limitShift = function (t) { return ( void 0 === t && (t = {}), { options: t, fn(e) { const { x: n, y: i, placement: o, rects: r, middlewareData: a } = e, { offset: l = 0, mainAxis: s = !0, crossAxis: u = !0 } = f(t, e), d = { x: n, y: i }, p = g(o), h = m(p); let y = d[h], w = d[p]; const x = f(l, e), v = "number" == typeof x ? { mainAxis: x, crossAxis: 0 } : { mainAxis: 0, crossAxis: 0, ...x }; if (s) { const t = "y" === h ? "height" : "width", e = r.reference[h] - r.floating[t] + v.mainAxis, n = r.reference[h] + r.reference[t] - v.mainAxis; y < e ? (y = e) : y > n && (y = n); } if (u) { var b, A; const t = "y" === h ? "width" : "height", e = ["top", "left"].includes(c(o)), n = r.reference[p] - r.floating[t] + ((e && (null == (b = a.offset) ? void 0 : b[p])) || 0) + (e ? 0 : v.crossAxis), i = r.reference[p] + r.reference[t] + (e ? 0 : (null == (A = a.offset) ? void 0 : A[p]) || 0) - (e ? v.crossAxis : 0); w < n ? (w = n) : w > i && (w = i); } return { [h]: y, [p]: w }; }, } ); }), (t.offset = function (t) { return ( void 0 === t && (t = 0), { name: "offset", options: t, async fn(e) { var n, i; const { x: o, y: r, placement: a, middlewareData: l } = e, s = await (async function (t, e) { const { placement: n, platform: i, elements: o } = t, r = await (null == i.isRTL ? void 0 : i.isRTL(o.floating)), a = c(n), l = u(n), s = "y" === g(n), m = ["left", "top"].includes(a) ? -1 : 1, d = r && s ? -1 : 1, p = f(e, t); let { mainAxis: h, crossAxis: y, alignmentAxis: w, } = "number" == typeof p ? { mainAxis: p, crossAxis: 0, alignmentAxis: null } : { mainAxis: p.mainAxis || 0, crossAxis: p.crossAxis || 0, alignmentAxis: p.alignmentAxis, }; return ( l && "number" == typeof w && (y = "end" === l ? -1 * w : w), s ? { x: y * d, y: h * m } : { x: h * m, y: y * d } ); })(e, t); return a === (null == (n = l.offset) ? void 0 : n.placement) && null != (i = l.arrow) && i.alignmentOffset ? {} : { x: o + s.x, y: r + s.y, data: { ...s, placement: a } }; }, } ); }), (t.rectToClientRect = v), (t.shift = function (t) { return ( void 0 === t && (t = {}), { name: "shift", options: t, async fn(e) { const { x: n, y: i, placement: o } = e, { mainAxis: r = !0, crossAxis: a = !1, limiter: l = { fn: (t) => { let { x: e, y: n } = t; return { x: e, y: n }; }, }, ...u } = f(t, e), d = { x: n, y: i }, p = await A(e, u), h = g(c(o)), y = m(h); let w = d[y], x = d[h]; if (r) { const t = "y" === y ? "bottom" : "right"; w = s(w + p["y" === y ? "top" : "left"], w, w - p[t]); } if (a) { const t = "y" === h ? "bottom" : "right"; x = s(x + p["y" === h ? "top" : "left"], x, x - p[t]); } const v = l.fn({ ...e, [y]: w, [h]: x }); return { ...v, data: { x: v.x - n, y: v.y - i, enabled: { [y]: r, [h]: a } }, }; }, } ); }), (t.size = function (t) { return ( void 0 === t && (t = {}), { name: "size", options: t, async fn(e) { var n, i; const { placement: a, rects: l, platform: s, elements: m } = e, { apply: d = () => {}, ...p } = f(t, e), h = await A(e, p), y = c(a), w = u(a), x = "y" === g(a), { width: v, height: b } = l.floating; let R, P; "top" === y || "bottom" === y ? ((R = y), (P = w === ((await (null == s.isRTL ? void 0 : s.isRTL(m.floating))) ? "start" : "end") ? "left" : "right")) : ((P = y), (R = "end" === w ? "top" : "bottom")); const D = b - h.top - h.bottom, T = v - h.left - h.right, O = o(b - h[R], D), E = o(v - h[P], T), L = !e.middlewareData.shift; let k = O, C = E; if ( (null != (n = e.middlewareData.shift) && n.enabled.x && (C = T), null != (i = e.middlewareData.shift) && i.enabled.y && (k = D), L && !w) ) { const t = r(h.left, 0), e = r(h.right, 0), n = r(h.top, 0), i = r(h.bottom, 0); x ? (C = v - 2 * (0 !== t || 0 !== e ? t + e : r(h.left, h.right))) : (k = b - 2 * (0 !== n || 0 !== i ? n + i : r(h.top, h.bottom))); } await d({ ...e, availableWidth: C, availableHeight: k }); const B = await s.getDimensions(m.floating); return v !== B.width || b !== B.height ? { reset: { rects: !0 } } : {}; }, } ); });});// https://cdn.jsdelivr.net/npm/@floating-ui/[email protected]!(function (t, e) { "object" == typeof exports && "undefined" != typeof module ? e(exports, require("./floating_ui_core")) : "function" == typeof define && define.amd ? define(["exports", "./floatingUICore"], e) : e( ((t = "undefined" != typeof globalThis ? globalThis : t || self).FloatingUIDOM = {}), t.FloatingUICore );})(this, function (t, e) { "use strict"; const n = Math.min, o = Math.max, i = Math.round, r = Math.floor, c = (t) => ({ x: t, y: t }); function l() { return "undefined" != typeof window; } function s(t) { return a(t) ? (t.nodeName || "").toLowerCase() : "#document"; } function f(t) { var e; return ( (null == t || null == (e = t.ownerDocument) ? void 0 : e.defaultView) || window ); } function u(t) { var e; return null == (e = (a(t) ? t.ownerDocument : t.document) || window.document) ? void 0 : e.documentElement; } function a(t) { return !!l() && (t instanceof Node || t instanceof f(t).Node); } function d(t) { return !!l() && (t instanceof Element || t instanceof f(t).Element); } function h(t) { return !!l() && (t instanceof HTMLElement || t instanceof f(t).HTMLElement); } function p(t) { return ( !(!l() || "undefined" == typeof ShadowRoot) && (t instanceof ShadowRoot || t instanceof f(t).ShadowRoot) ); } function g(t) { const { overflow: e, overflowX: n, overflowY: o, display: i } = b(t); return ( /auto|scroll|overlay|hidden|clip/.test(e + o + n) && !["inline", "contents"].includes(i) ); } function m(t) { return ["table", "td", "th"].includes(s(t)); } function y(t) { return [":popover-open", ":modal"].some((e) => { try { return t.matches(e); } catch (t) { return !1; } }); } function w(t) { const e = x(), n = d(t) ? b(t) : t; return ( ["transform", "translate", "scale", "rotate", "perspective"].some( (t) => !!n[t] && "none" !== n[t] ) || (!!n.containerType && "normal" !== n.containerType) || (!e && !!n.backdropFilter && "none" !== n.backdropFilter) || (!e && !!n.filter && "none" !== n.filter) || [ "transform", "translate", "scale", "rotate", "perspective", "filter", ].some((t) => (n.willChange || "").includes(t)) || ["paint", "layout", "strict", "content"].some((t) => (n.contain || "").includes(t) ) ); } function x() { return ( !("undefined" == typeof CSS || !CSS.supports) && CSS.supports("-webkit-backdrop-filter", "none") ); } function v(t) { return ["html", "body", "#document"].includes(s(t)); } function b(t) { return f(t).getComputedStyle(t); } function T(t) { return d(t) ? { scrollLeft: t.scrollLeft, scrollTop: t.scrollTop } : { scrollLeft: t.scrollX, scrollTop: t.scrollY }; } function L(t) { if ("html" === s(t)) return t; const e = t.assignedSlot || t.parentNode || (p(t) && t.host) || u(t); return p(e) ? e.host : e; } function R(t) { const e = L(t); return v(e) ? t.ownerDocument ? t.ownerDocument.body : t.body : h(e) && g(e) ? e : R(e); } function C(t, e, n) { var o; void 0 === e && (e = []), void 0 === n && (n = !0); const i = R(t), r = i === (null == (o = t.ownerDocument) ? void 0 : o.body), c = f(i); if (r) { const t = E(c); return e.concat( c, c.visualViewport || [], g(i) ? i : [], t && n ? C(t) : [] ); } return e.concat(i, C(i, [], n)); } function E(t) { return t.parent && Object.getPrototypeOf(t.parent) ? t.frameElement : null; } function S(t) { const e = b(t); let n = parseFloat(e.width) || 0, o = parseFloat(e.height) || 0; const r = h(t), c = r ? t.offsetWidth : n, l = r ? t.offsetHeight : o, s = i(n) !== c || i(o) !== l; return s && ((n = c), (o = l)), { width: n, height: o, $: s }; } function F(t) { return d(t) ? t : t.contextElement; } function O(t) { const e = F(t); if (!h(e)) return c(1); const n = e.getBoundingClientRect(), { width: o, height: r, $: l } = S(e); let s = (l ? i(n.width) : n.width) / o, f = (l ? i(n.height) : n.height) / r; return ( (s && Number.isFinite(s)) || (s = 1), (f && Number.isFinite(f)) || (f = 1), { x: s, y: f } ); } const D = c(0); function H(t) { const e = f(t); return x() && e.visualViewport ? { x: e.visualViewport.offsetLeft, y: e.visualViewport.offsetTop } : D; } function P(t, n, o, i) { void 0 === n && (n = !1), void 0 === o && (o = !1); const r = t.getBoundingClientRect(), l = F(t); let s = c(1); n && (i ? d(i) && (s = O(i)) : (s = O(t))); const u = (function (t, e, n) { return void 0 === e && (e = !1), !(!n || (e && n !== f(t))) && e; })(l, o, i) ? H(l) : c(0); let a = (r.left + u.x) / s.x, h = (r.top + u.y) / s.y, p = r.width / s.x, g = r.height / s.y; if (l) { const t = f(l), e = i && d(i) ? f(i) : i; let n = t, o = E(n); for (; o && i && e !== n; ) { const t = O(o), e = o.getBoundingClientRect(), i = b(o), r = e.left + (o.clientLeft + parseFloat(i.paddingLeft)) * t.x, c = e.top + (o.clientTop + parseFloat(i.paddingTop)) * t.y; (a *= t.x), (h *= t.y), (p *= t.x), (g *= t.y), (a += r), (h += c), (n = f(o)), (o = E(n)); } } return e.rectToClientRect({ width: p, height: g, x: a, y: h }); } function W(t, e) { const n = T(t).scrollLeft; return e ? e.left + n : P(u(t)).left + n; } function M(t, e, n) { void 0 === n && (n = !1); const o = t.getBoundingClientRect(); return { x: o.left + e.scrollLeft - (n ? 0 : W(t, o)), y: o.top + e.scrollTop, }; } function z(t, n, i) { let r; if ("viewport" === n) r = (function (t, e) { const n = f(t), o = u(t), i = n.visualViewport; let r = o.clientWidth, c = o.clientHeight, l = 0, s = 0; if (i) { (r = i.width), (c = i.height); const t = x(); (!t || (t && "fixed" === e)) && ((l = i.offsetLeft), (s = i.offsetTop)); } return { width: r, height: c, x: l, y: s }; })(t, i); else if ("document" === n) r = (function (t) { const e = u(t), n = T(t), i = t.ownerDocument.body, r = o(e.scrollWidth, e.clientWidth, i.scrollWidth, i.clientWidth), c = o(e.scrollHeight, e.clientHeight, i.scrollHeight, i.clientHeight); let l = -n.scrollLeft + W(t); const s = -n.scrollTop; return ( "rtl" === b(i).direction && (l += o(e.clientWidth, i.clientWidth) - r), { width: r, height: c, x: l, y: s } ); })(u(t)); else if (d(n)) r = (function (t, e) { const n = P(t, !0, "fixed" === e), o = n.top + t.clientTop, i = n.left + t.clientLeft, r = h(t) ? O(t) : c(1); return { width: t.clientWidth * r.x, height: t.clientHeight * r.y, x: i * r.x, y: o * r.y, }; })(n, i); else { const e = H(t); r = { x: n.x - e.x, y: n.y - e.y, width: n.width, height: n.height }; } return e.rectToClientRect(r); } function A(t, e) { const n = L(t); return ( !(n === e || !d(n) || v(n)) && ("fixed" === b(n).position || A(n, e)) ); } function B(t, e, n) { const o = h(e), i = u(e), r = "fixed" === n, l = P(t, !0, r, e); let f = { scrollLeft: 0, scrollTop: 0 }; const a = c(0); function d() { a.x = W(i); } if (o || (!o && !r)) if ((("body" !== s(e) || g(i)) && (f = T(e)), o)) { const t = P(e, !0, r, e); (a.x = t.x + e.clientLeft), (a.y = t.y + e.clientTop); } else i && d(); r && !o && i && d(); const p = !i || o || r ? c(0) : M(i, f); return { x: l.left + f.scrollLeft - a.x - p.x, y: l.top + f.scrollTop - a.y - p.y, width: l.width, height: l.height, }; } function V(t) { return "static" === b(t).position; } function N(t, e) { if (!h(t) || "fixed" === b(t).position) return null; if (e) return e(t); let n = t.offsetParent; return u(t) === n && (n = n.ownerDocument.body), n; } function I(t, e) { const n = f(t); if (y(t)) return n; if (!h(t)) { let e = L(t); for (; e && !v(e); ) { if (d(e) && !V(e)) return e; e = L(e); } return n; } let o = N(t, e); for (; o && m(o) && V(o); ) o = N(o, e); return o && v(o) && V(o) && !w(o) ? n : o || (function (t) { let e = L(t); for (; h(e) && !v(e); ) { if (w(e)) return e; if (y(e)) return null; e = L(e); } return null; })(t) || n; } const k = { convertOffsetParentRelativeRectToViewportRelativeRect: function (t) { let { elements: e, rect: n, offsetParent: o, strategy: i } = t; const r = "fixed" === i, l = u(o), f = !!e && y(e.floating); if (o === l || (f && r)) return n; let a = { scrollLeft: 0, scrollTop: 0 }, d = c(1); const p = c(0), m = h(o); if ( (m || (!m && !r)) && (("body" !== s(o) || g(l)) && (a = T(o)), h(o)) ) { const t = P(o); (d = O(o)), (p.x = t.x + o.clientLeft), (p.y = t.y + o.clientTop); } const w = !l || m || r ? c(0) : M(l, a, !0); return { width: n.width * d.x, height: n.height * d.y, x: n.x * d.x - a.scrollLeft * d.x + p.x + w.x, y: n.y * d.y - a.scrollTop * d.y + p.y + w.y, }; }, getDocumentElement: u, getClippingRect: function (t) { let { element: e, boundary: i, rootBoundary: r, strategy: c } = t; const l = [ ...("clippingAncestors" === i ? y(e) ? [] : (function (t, e) { const n = e.get(t); if (n) return n; let o = C(t, [], !1).filter((t) => d(t) && "body" !== s(t)), i = null; const r = "fixed" === b(t).position; let c = r ? L(t) : t; for (; d(c) && !v(c); ) { const e = b(c), n = w(c); n || "fixed" !== e.position || (i = null), ( r ? !n && !i : (!n && "static" === e.position && i && ["absolute", "fixed"].includes(i.position)) || (g(c) && !n && A(t, c)) ) ? (o = o.filter((t) => t !== c)) : (i = e), (c = L(c)); } return e.set(t, o), o; })(e, this._c) : [].concat(i)), r, ], f = l[0], u = l.reduce((t, i) => { const r = z(e, i, c); return ( (t.top = o(r.top, t.top)), (t.right = n(r.right, t.right)), (t.bottom = n(r.bottom, t.bottom)), (t.left = o(r.left, t.left)), t ); }, z(e, f, c)); return { width: u.right - u.left, height: u.bottom - u.top, x: u.left, y: u.top, }; }, getOffsetParent: I, getElementRects: async function (t) { const e = this.getOffsetParent || I, n = this.getDimensions, o = await n(t.floating); return { reference: B(t.reference, await e(t.floating), t.strategy), floating: { x: 0, y: 0, width: o.width, height: o.height }, }; }, getClientRects: function (t) { return Array.from(t.getClientRects()); }, getDimensions: function (t) { const { width: e, height: n } = S(t); return { width: e, height: n }; }, getScale: O, isElement: d, isRTL: function (t) { return "rtl" === b(t).direction; }, }; function q(t, e) { return ( t.x === e.x && t.y === e.y && t.width === e.width && t.height === e.height ); } const U = e.detectOverflow, j = e.offset, X = e.autoPlacement, Y = e.shift, $ = e.flip, _ = e.size, G = e.hide, J = e.arrow, K = e.inline, Q = e.limitShift; (t.arrow = J), (t.autoPlacement = X), (t.autoUpdate = function (t, e, i, c) { void 0 === c && (c = {}); const { ancestorScroll: l = !0, ancestorResize: s = !0, elementResize: f = "function" == typeof ResizeObserver, layoutShift: a = "function" == typeof IntersectionObserver, animationFrame: d = !1, } = c, h = F(t), p = l || s ? [...(h ? C(h) : []), ...C(e)] : []; p.forEach((t) => { l && t.addEventListener("scroll", i, { passive: !0 }), s && t.addEventListener("resize", i); }); const g = h && a ? (function (t, e) { let i, c = null; const l = u(t); function s() { var t; clearTimeout(i), null == (t = c) || t.disconnect(), (c = null); } return ( (function f(u, a) { void 0 === u && (u = !1), void 0 === a && (a = 1), s(); const d = t.getBoundingClientRect(), { left: h, top: p, width: g, height: m } = d; if ((u || e(), !g || !m)) return; const y = { rootMargin: -r(p) + "px " + -r(l.clientWidth - (h + g)) + "px " + -r(l.clientHeight - (p + m)) + "px " + -r(h) + "px", threshold: o(0, n(1, a)) || 1, }; let w = !0; function x(e) { const n = e[0].intersectionRatio; if (n !== a) { if (!w) return f(); n ? f(!1, n) : (i = setTimeout(() => { f(!1, 1e-7); }, 1e3)); } 1 !== n || q(d, t.getBoundingClientRect()) || f(), (w = !1); } try { c = new IntersectionObserver(x, { ...y, root: l.ownerDocument, }); } catch (t) { c = new IntersectionObserver(x, y); } c.observe(t); })(!0), s ); })(h, i) : null; let m, y = -1, w = null; f && ((w = new ResizeObserver((t) => { let [n] = t; n && n.target === h && w && (w.unobserve(e), cancelAnimationFrame(y), (y = requestAnimationFrame(() => { var t; null == (t = w) || t.observe(e); }))), i(); })), h && !d && w.observe(h), w.observe(e)); let x = d ? P(t) : null; return ( d && (function e() { const n = P(t); x && !q(x, n) && i(); (x = n), (m = requestAnimationFrame(e)); })(), i(), () => { var t; p.forEach((t) => { l && t.removeEventListener("scroll", i), s && t.removeEventListener("resize", i); }), null == g || g(), null == (t = w) || t.disconnect(), (w = null), d && cancelAnimationFrame(m); } ); }), (t.computePosition = (t, n, o) => { const i = new Map(), r = { platform: k, ...o }, c = { ...r.platform, _c: i }; return e.computePosition(t, n, { ...r, platform: c }); }), (t.detectOverflow = U), (t.flip = $), (t.getOverflowAncestors = C), (t.hide = G), (t.inline = K), (t.limitShift = Q), (t.offset = j), (t.platform = k), (t.shift = Y), (t.size = _); // We put this manually here because we need to make sure it's available // before the popover component is initialized. window.FloatingUIDOM = t; return t;});Component scripts are loaded through the shared script bundle, see JavaScript.
Update the import paths to match your project setup.
Usage
import selectcomp "github.com/axadrn/shadcn-templ/v2/components/select"@selectcomp.Select() { @selectcomp.Trigger(selectcomp.TriggerProps{Class: "w-[180px]"}) { @selectcomp.Value(selectcomp.ValueProps{Placeholder: "Theme"}) } @selectcomp.Content() { @selectcomp.Group() { @selectcomp.Item(selectcomp.ItemProps{Value: "light"}) { Light } @selectcomp.Item(selectcomp.ItemProps{Value: "dark"}) { Dark } @selectcomp.Item(selectcomp.ItemProps{Value: "system"}) { System } } }}Composition
Use the following composition to build a Select:
selectcomp.Select├── selectcomp.Trigger│ └── selectcomp.Value└── selectcomp.Content ├── selectcomp.Group │ ├── selectcomp.Label │ ├── selectcomp.Item │ └── selectcomp.Item ├── selectcomp.Separator └── selectcomp.Group ├── selectcomp.Label ├── selectcomp.Item └── selectcomp.ItemAlign Item With Trigger
By default the popup positions so the selected item appears over the trigger (Base UI’s alignItemWithTrigger). Set DisableAlignItemWithTrigger on selectcomp.Content to open it below the trigger edge like a dropdown instead.
Toggle to align the item with the trigger.
package examples import (package examples import ( "github.com/axadrn/shadcn-templ/v2/components/field" selectcomp "github.com/axadrn/shadcn-templ/v2/components/select" switchcomp "github.com/axadrn/shadcn-templ/v2/components/switch") templ SelectAlignItem() { @field.Group(field.GroupProps{Class: "w-full max-w-xs"}) { @field.Field(field.Props{Orientation: field.OrientationHorizontal}) { @field.Content() { @field.Label(field.LabelProps{For: "align-item-switch"}) { Align Item } @field.Description() { Toggle to align the item with the trigger. } } @switchcomp.Switch(switchcomp.Props{ ID: "align-item-switch", Checked: true, }) } @field.Field() { @selectcomp.Select(selectcomp.Props{ ID: "align-item-select", Value: "banana", }) { @selectcomp.Trigger() { @selectcomp.Value() } @selectcomp.Content() { @selectcomp.Group() { @selectcomp.Item(selectcomp.ItemProps{Value: "apple"}) { Apple } @selectcomp.Item(selectcomp.ItemProps{Value: "banana"}) { Banana } @selectcomp.Item(selectcomp.ItemProps{Value: "blueberry"}) { Blueberry } @selectcomp.Item(selectcomp.ItemProps{Value: "grapes"}) { Grapes } @selectcomp.Item(selectcomp.ItemProps{Value: "pineapple"}) { Pineapple } } } } } } <script nonce={ templ.GetNonce(ctx) }> (() => { const sw = document.getElementById("align-item-switch"); if (!sw) return; sw.addEventListener("change", () => { // Looked up on toggle: the content is portaled to <body> at init. const content = document.getElementById("align-item-select"); if (!content) return; content.setAttribute("data-tui-select-position", sw.checked ? "item-aligned" : "popper"); }); })(); </script>}Groups
Use selectcomp.Group, selectcomp.Label, and selectcomp.Separator to organize items.
package examples import selectcomp "github.com/axadrn/shadcn-templ/v2/components/select"package examples import selectcomp "github.com/axadrn/shadcn-templ/v2/components/select" templ SelectGroupsExample() { @selectcomp.Select() { @selectcomp.Trigger(selectcomp.TriggerProps{Class: "w-full max-w-48"}) { @selectcomp.Value(selectcomp.ValueProps{Placeholder: "Select a fruit"}) } @selectcomp.Content() { @selectcomp.Group() { @selectcomp.Label() { Fruits } @selectcomp.Item(selectcomp.ItemProps{Value: "apple"}) { Apple } @selectcomp.Item(selectcomp.ItemProps{Value: "banana"}) { Banana } @selectcomp.Item(selectcomp.ItemProps{Value: "blueberry"}) { Blueberry } } @selectcomp.Separator() @selectcomp.Group() { @selectcomp.Label() { Vegetables } @selectcomp.Item(selectcomp.ItemProps{Value: "carrot"}) { Carrot } @selectcomp.Item(selectcomp.ItemProps{Value: "broccoli"}) { Broccoli } @selectcomp.Item(selectcomp.ItemProps{Value: "spinach"}) { Spinach } } } }}Scrollable
A select with many items that scrolls.
package examples import selectcomp "github.com/axadrn/shadcn-templ/v2/components/select"package examples import selectcomp "github.com/axadrn/shadcn-templ/v2/components/select" templ SelectScrollable() { @selectcomp.Select() { @selectcomp.Trigger(selectcomp.TriggerProps{Class: "w-full max-w-64"}) { @selectcomp.Value(selectcomp.ValueProps{Placeholder: "Select a timezone"}) } @selectcomp.Content() { @selectcomp.Group() { @selectcomp.Label() { North America } @selectcomp.Item(selectcomp.ItemProps{Value: "est"}) { Eastern Standard Time } @selectcomp.Item(selectcomp.ItemProps{Value: "cst"}) { Central Standard Time } @selectcomp.Item(selectcomp.ItemProps{Value: "mst"}) { Mountain Standard Time } @selectcomp.Item(selectcomp.ItemProps{Value: "pst"}) { Pacific Standard Time } @selectcomp.Item(selectcomp.ItemProps{Value: "akst"}) { Alaska Standard Time } @selectcomp.Item(selectcomp.ItemProps{Value: "hst"}) { Hawaii Standard Time } } @selectcomp.Group() { @selectcomp.Label() { Europe & Africa } @selectcomp.Item(selectcomp.ItemProps{Value: "gmt"}) { Greenwich Mean Time } @selectcomp.Item(selectcomp.ItemProps{Value: "cet"}) { Central European Time } @selectcomp.Item(selectcomp.ItemProps{Value: "eet"}) { Eastern European Time } @selectcomp.Item(selectcomp.ItemProps{Value: "west"}) { Western European Summer Time } @selectcomp.Item(selectcomp.ItemProps{Value: "cat"}) { Central Africa Time } @selectcomp.Item(selectcomp.ItemProps{Value: "eat"}) { East Africa Time } } @selectcomp.Group() { @selectcomp.Label() { Asia } @selectcomp.Item(selectcomp.ItemProps{Value: "msk"}) { Moscow Time } @selectcomp.Item(selectcomp.ItemProps{Value: "ist"}) { India Standard Time } @selectcomp.Item(selectcomp.ItemProps{Value: "cst_china"}) { China Standard Time } @selectcomp.Item(selectcomp.ItemProps{Value: "jst"}) { Japan Standard Time } @selectcomp.Item(selectcomp.ItemProps{Value: "kst"}) { Korea Standard Time } @selectcomp.Item(selectcomp.ItemProps{Value: "ist_indonesia"}) { Indonesia Central Standard Time } } @selectcomp.Group() { @selectcomp.Label() { Australia & Pacific } @selectcomp.Item(selectcomp.ItemProps{Value: "awst"}) { Australian Western Standard Time } @selectcomp.Item(selectcomp.ItemProps{Value: "acst"}) { Australian Central Standard Time } @selectcomp.Item(selectcomp.ItemProps{Value: "aest"}) { Australian Eastern Standard Time } @selectcomp.Item(selectcomp.ItemProps{Value: "nzst"}) { New Zealand Standard Time } @selectcomp.Item(selectcomp.ItemProps{Value: "fjt"}) { Fiji Time } } @selectcomp.Group() { @selectcomp.Label() { South America } @selectcomp.Item(selectcomp.ItemProps{Value: "art"}) { Argentina Time } @selectcomp.Item(selectcomp.ItemProps{Value: "bot"}) { Bolivia Time } @selectcomp.Item(selectcomp.ItemProps{Value: "brt"}) { Brasilia Time } @selectcomp.Item(selectcomp.ItemProps{Value: "clt"}) { Chile Standard Time } } } }}Disabled
package examples import selectcomp "github.com/axadrn/shadcn-templ/v2/components/select"package examples import selectcomp "github.com/axadrn/shadcn-templ/v2/components/select" templ SelectDisabled() { @selectcomp.Select(selectcomp.Props{Disabled: true}) { @selectcomp.Trigger(selectcomp.TriggerProps{Class: "w-full max-w-48"}) { @selectcomp.Value(selectcomp.ValueProps{Placeholder: "Select a fruit"}) } @selectcomp.Content() { @selectcomp.Group() { @selectcomp.Item(selectcomp.ItemProps{Value: "apple"}) { Apple } @selectcomp.Item(selectcomp.ItemProps{Value: "banana"}) { Banana } @selectcomp.Item(selectcomp.ItemProps{Value: "blueberry"}) { Blueberry } @selectcomp.Item(selectcomp.ItemProps{Value: "grapes", Disabled: true}) { Grapes } @selectcomp.Item(selectcomp.ItemProps{Value: "pineapple"}) { Pineapple } } } }}Invalid
Set the Invalid prop on the field.Field component and aria-invalid on the selectcomp.Trigger component to show an error state.
@field.Field(field.Props{Attributes: templ.Attributes{"data-invalid": "true"}}) { @field.Label() { Fruit } @selectcomp.Trigger(selectcomp.TriggerProps{Attributes: templ.Attributes{"aria-invalid": "true"}}) { @selectcomp.Value() }}package examples import (package examples import ( "github.com/axadrn/shadcn-templ/v2/components/field" selectcomp "github.com/axadrn/shadcn-templ/v2/components/select") templ SelectInvalid() { @field.Field(field.Props{ Class: "w-full max-w-48", Attributes: templ.Attributes{"data-invalid": "true"}}) { @field.Label() { Fruit } @selectcomp.Select() { @selectcomp.Trigger(selectcomp.TriggerProps{Attributes: templ.Attributes{"aria-invalid": "true"}}) { @selectcomp.Value(selectcomp.ValueProps{Placeholder: "Select a fruit"}) } @selectcomp.Content() { @selectcomp.Group() { @selectcomp.Item(selectcomp.ItemProps{Value: "apple"}) { Apple } @selectcomp.Item(selectcomp.ItemProps{Value: "banana"}) { Banana } @selectcomp.Item(selectcomp.ItemProps{Value: "blueberry"}) { Blueberry } } } } @field.Error() { Please select a fruit. } }}API Reference
Select
The selectcomp.Select component is the root that carries the selection and the form value.
| Prop | Type | Default |
|---|---|---|
Name |
string |
- |
Value |
string |
- |
Disabled |
bool |
false |
SelectTrigger
The selectcomp.Trigger component is the button that opens the listbox.
| Prop | Type | Default |
|---|---|---|
Size |
SizeDefault | SizeSm |
SizeDefault |
Class |
string |
- |
SelectValue
The selectcomp.Value component shows the selected label inside the trigger.
| Prop | Type | Default |
|---|---|---|
Placeholder |
string |
- |
Class |
string |
- |
SelectContent
The selectcomp.Content component is the listbox popup.
| Prop | Type | Default |
|---|---|---|
DisableAlignItemWithTrigger |
bool |
false |
Align |
AlignStart | AlignCenter | AlignEnd |
AlignCenter |
Class |
string |
- |
SelectGroup
The selectcomp.Group component wraps related items.
| Prop | Type | Default |
|---|---|---|
Class |
string |
- |
SelectLabel
The selectcomp.Label component titles a group.
| Prop | Type | Default |
|---|---|---|
Class |
string |
- |
SelectItem
The selectcomp.Item component is a selectable option.
| Prop | Type | Default |
|---|---|---|
Value |
string |
- |
Label |
string |
- |
Disabled |
bool |
false |
Class |
string |
- |
SelectSeparator
The selectcomp.Separator component divides groups.
| Prop | Type | Default |
|---|---|---|
Class |
string |
- |