feat(13-03): port select + form-field components with CSS and helpers (GREEN)

- select_helpers.go: 9 helper functions verbatim from go-backend
- select.templ: SelectProps/SelectOption structs, inline JS with __uiSelectInitAll
  and htmx:afterSwap re-init listener (Pitfall 6)
- select.css: .ui-select-control (min-height 44px), .ui-select-menu (max-height 16rem)
- form_field.templ: FormFieldProps with Label/For/Field/Error/Hint; conditional regions
- form-field.css: .ui-form-field/.ui-form-label/.ui-form-hint/.ui-form-error
- tailwind.input.css: add @import for select.css and form-field.css
- All 6 TestSelect/TestFormField tests passing; full go test ./... is green
This commit is contained in:
Arthur Belleville 2026-05-16 14:00:51 +02:00
parent 50e3fb0021
commit 52fb77d4f8
No known key found for this signature in database
6 changed files with 559 additions and 0 deletions

View file

@ -0,0 +1,22 @@
.ui-form-field {
display: grid;
gap: 0.5rem;
}
.ui-form-label {
color: var(--color-text-primary);
font-size: 0.95rem;
font-weight: 600;
}
.ui-form-hint {
color: var(--color-text-muted);
font-size: 0.875rem;
margin: 0;
}
.ui-form-error {
color: var(--color-status-danger-foreground);
font-size: 0.875rem;
margin: 0;
}

View file

@ -0,0 +1,26 @@
package ui
type FormFieldProps struct {
Label string
For string
Field templ.Component
Error string
Hint string
}
templ FormField(props FormFieldProps) {
<div class="ui-form-field">
if props.Label != "" {
<label for={ props.For } class="ui-form-label">{ props.Label }</label>
}
if props.Field != nil {
@props.Field
}
if props.Hint != "" {
<p class="ui-form-hint">{ props.Hint }</p>
}
if props.Error != "" {
<p class="ui-form-error">{ props.Error }</p>
}
</div>
}

View file

@ -0,0 +1,154 @@
.ui-select {
position: relative;
width: 100%;
}
.ui-select-native {
height: 0;
left: 0;
opacity: 0;
pointer-events: none;
position: absolute;
top: 0;
width: 0;
}
.ui-select-control {
align-items: center;
appearance: none;
background: var(--color-surface-default);
border: 1px solid var(--color-border-default);
border-radius: 0.75rem;
color: var(--color-text-primary);
cursor: pointer;
display: flex;
gap: 0.75rem;
justify-content: space-between;
min-height: 44px;
padding: 0.55rem 0.75rem 0.55rem 0.95rem;
text-align: left;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
width: 100%;
}
.ui-select-control:focus-visible {
border-color: var(--color-brand-focus);
box-shadow: 0 0 0 3px var(--color-focus-ring-strong);
outline: none;
}
.ui-select-control:disabled {
color: var(--color-text-faint);
cursor: not-allowed;
}
.ui-select-value-wrapper {
align-items: center;
display: flex;
flex: 1 1 auto;
flex-wrap: wrap;
gap: 0.35rem;
min-width: 0;
}
.ui-select-placeholder {
color: var(--color-text-faint);
}
.ui-select-chip {
background: var(--color-surface-muted);
border-radius: 999px;
color: var(--color-text-primary);
display: inline-flex;
font-size: 0.875rem;
line-height: 1;
padding: 0.35rem 0.6rem;
}
.ui-select-arrow-zone {
align-items: center;
color: var(--color-text-secondary);
display: inline-flex;
flex: 0 0 auto;
justify-content: center;
}
.ui-select-arrow-icon {
height: 1rem;
transition: transform 0.2s ease;
width: 1rem;
}
.ui-select.is-open .ui-select-arrow-icon {
transform: rotate(180deg);
}
.ui-select-menu {
background: var(--color-surface-default);
border: 1px solid var(--color-border-default);
border-radius: 0.9rem;
box-shadow: var(--shadow-surface-md);
display: grid;
gap: 0.25rem;
left: 0;
margin-top: 0.45rem;
max-height: 16rem;
overflow-y: auto;
padding: 0.4rem;
position: absolute;
right: 0;
top: 100%;
z-index: 30;
}
.ui-select-menu[hidden] {
display: none;
}
.ui-select-option {
align-items: center;
appearance: none;
background: transparent;
border: 0;
border-radius: 0.7rem;
color: var(--color-text-primary);
cursor: pointer;
display: flex;
font: inherit;
gap: 0.75rem;
justify-content: space-between;
padding: 0.7rem 0.8rem;
text-align: left;
width: 100%;
}
.ui-select-option:hover,
.ui-select-option:focus-visible {
background: var(--color-surface-muted);
outline: none;
}
.ui-select-option.is-selected {
background: var(--color-status-info-soft-bg);
color: var(--color-text-brand);
}
.ui-select-option:disabled {
color: var(--color-text-faint);
cursor: not-allowed;
}
.ui-select-option-text {
flex: 1 1 auto;
}
.ui-select-option-check {
color: currentColor;
opacity: 0;
}
.ui-select-option.is-selected .ui-select-option-check {
opacity: 1;
}

View file

@ -0,0 +1,251 @@
package ui
type SelectOption struct {
Value string
Label string
Disabled bool
}
type SelectProps struct {
ID string
Name string
Placeholder string
Value string
Values []string
Multiple bool
Options []SelectOption
Attrs templ.Attributes
}
templ Select(props SelectProps) {
<div
class="ui-select"
data-ui-select-root
data-ui-select-multiple={ selectBoolData(props.Multiple) }
data-placeholder={ selectPlaceholder(props) }
data-selected-label={ selectSelectedLabel(props) }
>
<select
id={ selectNativeID(props.ID, props.Name) }
name={ props.Name }
class="ui-select-native"
data-ui-select-native
if props.Multiple {
multiple
}
{ props.Attrs... }
>
if !props.Multiple && selectPlaceholder(props) != "" {
<option value="">{ selectPlaceholder(props) }</option>
}
for _, option := range props.Options {
<option
value={ option.Value }
if option.Disabled {
disabled
}
if selectOptionSelected(props, option.Value) {
selected
}
>{ option.Label }</option>
}
</select>
<button
id={ inputID(props.ID, props.Name) }
type="button"
class="ui-select-control"
data-ui-select-trigger
aria-haspopup="listbox"
aria-expanded="false"
aria-controls={ selectMenuID(props.ID, props.Name) }
if selectIsDisabled(props.Attrs) {
disabled
}
>
<span class="ui-select-value-wrapper" data-ui-select-label>
if props.Multiple {
if len(selectSelectedLabels(props)) == 0 {
<span class="ui-select-placeholder">{ selectPlaceholder(props) }</span>
} else {
for _, label := range selectSelectedLabels(props) {
<span class="ui-select-chip">{ label }</span>
}
}
} else if selectSelectedLabel(props) != "" {
{ selectSelectedLabel(props) }
} else {
<span class="ui-select-placeholder">{ selectPlaceholder(props) }</span>
}
</span>
<span class="ui-select-arrow-zone">
<svg class="ui-select-arrow-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6"></path>
</svg>
</span>
</button>
<div
id={ selectMenuID(props.ID, props.Name) }
class="ui-select-menu"
data-ui-select-menu
role="listbox"
if props.Multiple {
aria-multiselectable="true"
}
hidden
>
for _, option := range props.Options {
<button
type="button"
class={ selectMenuOptionClass(selectOptionSelected(props, option.Value), option.Disabled) }
data-ui-select-option
data-value={ option.Value }
data-label={ option.Label }
role="option"
aria-selected={ selectBoolData(selectOptionSelected(props, option.Value)) }
if option.Disabled {
disabled
}
>
<span class="ui-select-option-text">{ option.Label }</span>
<span class="ui-select-option-check" aria-hidden="true">✓</span>
</button>
}
</div>
<script>
(function () {
if (!window.__uiSelectInitAll) {
window.__uiSelectSetOpen = function (root, open) {
var trigger = root.querySelector("[data-ui-select-trigger]");
var menu = root.querySelector("[data-ui-select-menu]");
if (!trigger || !menu) {
return;
}
root.classList.toggle("is-open", open);
trigger.setAttribute("aria-expanded", open ? "true" : "false");
menu.hidden = !open;
};
window.__uiSelectCloseAll = function (exceptRoot) {
document.querySelectorAll("[data-ui-select-root].is-open").forEach(function (root) {
if (root !== exceptRoot) {
window.__uiSelectSetOpen(root, false);
}
});
};
window.__uiSelectSync = function (root) {
var nativeSelect = root.querySelector("[data-ui-select-native]");
var outlet = root.querySelector("[data-ui-select-label]");
var placeholder = root.getAttribute("data-placeholder") || "";
var multiple = root.getAttribute("data-ui-select-multiple") === "true";
if (!nativeSelect || !outlet) {
return;
}
var labels = Array.from(nativeSelect.options).filter(function (option) {
return option.selected && option.value !== "";
}).map(function (option) {
return option.textContent;
});
root.setAttribute("data-selected-label", labels.join(", "));
outlet.innerHTML = "";
if (labels.length === 0) {
var placeholderNode = document.createElement("span");
placeholderNode.className = "ui-select-placeholder";
placeholderNode.textContent = placeholder;
outlet.appendChild(placeholderNode);
} else if (multiple) {
labels.forEach(function (label) {
var chip = document.createElement("span");
chip.className = "ui-select-chip";
chip.textContent = label;
outlet.appendChild(chip);
});
} else {
outlet.textContent = labels[0];
}
root.querySelectorAll("[data-ui-select-option]").forEach(function (optionButton) {
var selected = Array.from(nativeSelect.options).some(function (option) {
return option.value === optionButton.getAttribute("data-value") && option.selected;
});
optionButton.classList.toggle("is-selected", selected);
optionButton.setAttribute("aria-selected", selected ? "true" : "false");
});
};
window.__uiSelectToggleValue = function (root, optionButton) {
var nativeSelect = root.querySelector("[data-ui-select-native]");
var multiple = root.getAttribute("data-ui-select-multiple") === "true";
var value = optionButton.getAttribute("data-value");
if (!nativeSelect || value === null) {
return;
}
Array.from(nativeSelect.options).forEach(function (option) {
if (option.value !== value && !multiple) {
option.selected = false;
}
if (option.value === value) {
option.selected = multiple ? !option.selected : true;
}
});
window.__uiSelectSync(root);
nativeSelect.dispatchEvent(new Event("change", { bubbles: true }));
if (!multiple) {
window.__uiSelectSetOpen(root, false);
}
};
window.__uiSelectInitAll = function (scope) {
(scope || document).querySelectorAll("[data-ui-select-root]").forEach(function (root) {
window.__uiSelectSync(root);
});
};
document.addEventListener("click", function (event) {
var optionButton = event.target.closest("[data-ui-select-option]");
if (optionButton) {
var optionRoot = optionButton.closest("[data-ui-select-root]");
if (optionRoot) {
window.__uiSelectToggleValue(optionRoot, optionButton);
}
return;
}
var trigger = event.target.closest("[data-ui-select-trigger]");
if (trigger) {
var root = trigger.closest("[data-ui-select-root]");
var shouldOpen = root && !root.classList.contains("is-open");
window.__uiSelectCloseAll(root);
if (root) {
window.__uiSelectSetOpen(root, shouldOpen);
}
return;
}
if (!event.target.closest("[data-ui-select-root]")) {
window.__uiSelectCloseAll(null);
}
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
window.__uiSelectCloseAll(null);
}
});
document.addEventListener("htmx:afterSwap", function (event) {
window.__uiSelectInitAll(event.target);
});
}
window.__uiSelectInitAll(document);
})();
</script>
</div>
}

View file

@ -0,0 +1,104 @@
package ui
import (
"strings"
"github.com/a-h/templ"
)
func selectPlaceholder(props SelectProps) string {
if props.Placeholder != "" {
return props.Placeholder
}
if props.Multiple {
return "Select values"
}
return "Select an option"
}
func selectNativeID(id string, name string) string {
baseID := inputID(id, name)
if baseID == "" {
return "ui-select-native"
}
return baseID + "-native"
}
func selectMenuID(id string, name string) string {
baseID := inputID(id, name)
if baseID == "" {
return "ui-select-menu"
}
return baseID + "-menu"
}
func selectBoolData(value bool) string {
if value {
return "true"
}
return "false"
}
func selectSelectedValues(props SelectProps) []string {
if props.Multiple {
return props.Values
}
if props.Value == "" {
return nil
}
return []string{props.Value}
}
func selectOptionSelected(props SelectProps, value string) bool {
for _, selected := range selectSelectedValues(props) {
if selected == value {
return true
}
}
return false
}
func selectSelectedLabels(props SelectProps) []string {
var labels []string
for _, option := range props.Options {
if selectOptionSelected(props, option.Value) {
labels = append(labels, option.Label)
}
}
return labels
}
func selectSelectedLabel(props SelectProps) string {
return strings.Join(selectSelectedLabels(props), ", ")
}
func selectMenuOptionClass(selected bool, disabled bool) string {
className := "ui-select-option"
if selected {
className += " is-selected"
}
if disabled {
className += " is-disabled"
}
return className
}
func selectIsDisabled(attrs templ.Attributes) bool {
if attrs == nil {
return false
}
value, ok := attrs["disabled"]
if !ok {
return false
}
switch typed := value.(type) {
case bool:
return typed
case string:
return typed != "" && typed != "false"
default:
return true
}
}

View file

@ -11,3 +11,5 @@
@import "./internal/web/ui/card.css";
@import "./internal/web/ui/input.css";
@import "./internal/web/ui/textarea.css";
@import "./internal/web/ui/select.css";
@import "./internal/web/ui/form-field.css";