Hypercode/alex/hypercodePublic

Code

  1. hypercode
  2. views
  3. components
  4. ui
  5. select.go
select.go197 lines
package ui

import (
	"github.com/hypercodehq/libhtml"
	"github.com/hypercodehq/libhtml/attr"
)

type SelectOption struct {
	Value    string
	Label    string
	Selected bool
	Icon     Icon
}

type SelectProps struct {
	Id           string
	Name         string
	Label        string
	Options      []SelectOption
	Required     bool
	Error        string
	Class        string
	ContentClass string
}

func Select(props SelectProps) html.Node {
	// Find selected option
	selectedLabel := ""
	selectedValue := ""
	selectedIcon := Icon("")
	for _, opt := range props.Options {
		if opt.Selected {
			selectedLabel = opt.Label
			selectedValue = opt.Value
			selectedIcon = opt.Icon
			break
		}
	}
	if selectedLabel == "" && len(props.Options) > 0 {
		selectedLabel = props.Options[0].Label
		selectedValue = props.Options[0].Value
		selectedIcon = props.Options[0].Icon
	}

	selectId := props.Id
	popoverId := props.Id + "-popover"
	listboxId := props.Id + "-listbox"

	labelAttrs := []html.Node{
		attr.For(selectId),
		attr.Class("label"),
		html.Text(props.Label),
	}

	wrapperClass := "space-y-2"
	if props.Class != "" {
		wrapperClass += " " + props.Class
	}

	return html.Div(
		attr.Class(wrapperClass),
		html.Label(labelAttrs...),
		html.Div(
			attr.Class("select !mb-0 "+props.ContentClass),
			// Hidden input for form submission
			html.Input(
				attr.Type("hidden"),
				attr.Id(selectId),
				attr.Name(props.Name),
				attr.Value(selectedValue),
				html.If(props.Required, attr.Required()),
			),
			// Trigger button
			html.Element("button",
				attr.Type("button"),
				attr.Class("btn-outline w-full justify-between"),
				attr.AriaHaspopup("listbox"),
				attr.AriaControls(listboxId),
				attr.AriaExpanded("false"),
				html.Div(
					attr.Class("flex items-center gap-2"),
					html.Element("span",
						attr.Id(selectId+"-icon"),
						attr.Class("flex items-center"),
						html.If(selectedIcon != "", SVGIcon(selectedIcon, "h-4 w-4")),
					),
					html.Element("span",
						attr.Id(selectId+"-value"),
						attr.Class("font-normal"),
						html.Text(selectedLabel),
					),
				),
				SVGIcon(IconChevronDown, "h-4 w-4 opacity-50"),
			),
			// Popover
			html.Div(
				attr.Id(popoverId),
				attr.DataPopover(""),
				attr.AriaHidden("true"),
				attr.Class("w-full"),
				html.Div(
					attr.Role("listbox"),
					attr.Id(listboxId),
					attr.Class("max-h-64 overflow-y-auto scrollbar"),
					html.For(props.Options, func(option SelectOption) html.Node {
						return html.Div(
							attr.Role("option"),
							attr.Attribute{Key: "data-value", Value: option.Value},
							html.If(option.Icon != "", attr.Attribute{Key: "data-icon", Value: string(option.Icon)}),
							html.If(option.Selected, attr.Attribute{Key: "aria-selected", Value: "true"}),
							attr.Class("cursor-pointer flex items-center gap-2 border-b border-border last:border-b-0"),
							html.If(option.Icon != "", SVGIcon(option.Icon, "h-4 w-4")),
							html.Text(option.Label),
						)
					}),
				),
			),
		),
		html.If(props.Error != "", html.P(
			attr.Class("text-sm text-destructive"),
			html.Text(props.Error),
		)),
		// JavaScript for select functionality
		html.Element("script",
			html.Text(`
				(function() {
					const selectId = '`+selectId+`';
					const select = document.querySelector('.select:has(#' + selectId + ')');
					if (!select || select.hasAttribute('data-select-initialized')) return;
					select.setAttribute('data-select-initialized', 'true');

					const trigger = select.querySelector('[aria-haspopup="listbox"]');
					const popover = select.querySelector('[data-popover]');
					const listbox = select.querySelector('[role="listbox"]');
					const hiddenInput = select.querySelector('#' + selectId);
					const valueSpan = select.querySelector('#' + selectId + '-value');
					const iconSpan = select.querySelector('#' + selectId + '-icon');

					if (!trigger || !popover || !listbox || !hiddenInput || !valueSpan) return;

					trigger.addEventListener('click', () => {
						const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
						trigger.setAttribute('aria-expanded', !isExpanded);
						popover.setAttribute('aria-hidden', isExpanded);
					});

					listbox.querySelectorAll('[role="option"]').forEach(option => {
						option.addEventListener('click', () => {
							const value = option.getAttribute('data-value');
							const optionIcon = option.querySelector('svg');
							const textContent = Array.from(option.childNodes)
								.filter(node => node.nodeType === Node.TEXT_NODE)
								.map(node => node.textContent.trim())
								.join('');

							// Update hidden input
							hiddenInput.value = value;

							// Update display
							valueSpan.textContent = textContent;

							// Update icon
							if (iconSpan) {
								if (optionIcon) {
									const clonedIcon = optionIcon.cloneNode(true);
									iconSpan.innerHTML = '';
									iconSpan.appendChild(clonedIcon);
								} else {
									iconSpan.innerHTML = '';
								}
							}

							// Update aria-selected
							listbox.querySelectorAll('[role="option"]').forEach(opt => {
								opt.removeAttribute('aria-selected');
							});
							option.setAttribute('aria-selected', 'true');

							// Close popover
							trigger.setAttribute('aria-expanded', 'false');
							popover.setAttribute('aria-hidden', 'true');
						});
					});

					// Close on outside click
					document.addEventListener('click', (e) => {
						if (!select.contains(e.target)) {
							trigger.setAttribute('aria-expanded', 'false');
							popover.setAttribute('aria-hidden', 'true');
						}
					});
				})();
			`),
		),
	)
}