From 52fb77d4f8d91abbb5ce06b1801bbd50237c2d23 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 16 May 2026 14:00:51 +0200 Subject: [PATCH] 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 --- backend/internal/web/ui/form-field.css | 22 ++ backend/internal/web/ui/form_field.templ | 26 +++ backend/internal/web/ui/select.css | 154 +++++++++++++ backend/internal/web/ui/select.templ | 251 ++++++++++++++++++++++ backend/internal/web/ui/select_helpers.go | 104 +++++++++ backend/tailwind.input.css | 2 + 6 files changed, 559 insertions(+) create mode 100644 backend/internal/web/ui/form-field.css create mode 100644 backend/internal/web/ui/form_field.templ create mode 100644 backend/internal/web/ui/select.css create mode 100644 backend/internal/web/ui/select.templ create mode 100644 backend/internal/web/ui/select_helpers.go diff --git a/backend/internal/web/ui/form-field.css b/backend/internal/web/ui/form-field.css new file mode 100644 index 0000000..922a3c8 --- /dev/null +++ b/backend/internal/web/ui/form-field.css @@ -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; +} diff --git a/backend/internal/web/ui/form_field.templ b/backend/internal/web/ui/form_field.templ new file mode 100644 index 0000000..01f2efb --- /dev/null +++ b/backend/internal/web/ui/form_field.templ @@ -0,0 +1,26 @@ +package ui + +type FormFieldProps struct { + Label string + For string + Field templ.Component + Error string + Hint string +} + +templ FormField(props FormFieldProps) { +
+ if props.Label != "" { + + } + if props.Field != nil { + @props.Field + } + if props.Hint != "" { +

{ props.Hint }

+ } + if props.Error != "" { +

{ props.Error }

+ } +
+} diff --git a/backend/internal/web/ui/select.css b/backend/internal/web/ui/select.css new file mode 100644 index 0000000..aa24802 --- /dev/null +++ b/backend/internal/web/ui/select.css @@ -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; +} diff --git a/backend/internal/web/ui/select.templ b/backend/internal/web/ui/select.templ new file mode 100644 index 0000000..befd82e --- /dev/null +++ b/backend/internal/web/ui/select.templ @@ -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) { +
+ + + + +
+} diff --git a/backend/internal/web/ui/select_helpers.go b/backend/internal/web/ui/select_helpers.go new file mode 100644 index 0000000..5cf941e --- /dev/null +++ b/backend/internal/web/ui/select_helpers.go @@ -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 + } +} diff --git a/backend/tailwind.input.css b/backend/tailwind.input.css index 7e17912..6581cc5 100644 --- a/backend/tailwind.input.css +++ b/backend/tailwind.input.css @@ -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";