- 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
251 lines
7.4 KiB
Text
251 lines
7.4 KiB
Text
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>
|
|
}
|