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:
parent
50e3fb0021
commit
52fb77d4f8
6 changed files with 559 additions and 0 deletions
22
backend/internal/web/ui/form-field.css
Normal file
22
backend/internal/web/ui/form-field.css
Normal 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;
|
||||
}
|
||||
26
backend/internal/web/ui/form_field.templ
Normal file
26
backend/internal/web/ui/form_field.templ
Normal 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>
|
||||
}
|
||||
154
backend/internal/web/ui/select.css
Normal file
154
backend/internal/web/ui/select.css
Normal 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;
|
||||
}
|
||||
251
backend/internal/web/ui/select.templ
Normal file
251
backend/internal/web/ui/select.templ
Normal 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>
|
||||
}
|
||||
104
backend/internal/web/ui/select_helpers.go
Normal file
104
backend/internal/web/ui/select_helpers.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue