Select
A dropdown selection component for choosing from a list of options.
rsjs Controlled Selects
Two controlled selects update parent-owned signals and summary text.
Basic Usage
A basic select with options.
With Default Value
A select with a pre-selected value.
Sizes
Select comes in three sizes.
Small
Default
Large
Disabled Options
Individual options can be disabled.
Disabled State
The entire select can be disabled.
Form Example
Select within a form with labels.
With ARIA Label
Accessible select with aria-label for screen readers.
Custom Class
Select with custom styling via className.
FreeSelect
Native select styling and form integration.
Install with the Proa CLI
$ proa ui add selectInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Select recipe - type-safe styling system for Select component
//!
//! Provides size variants and base styling for the Select component.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Base styles for the select
mod base {
use super::*;
pub const SELECT_BASE: &str = tw_join!(
// Layout
"flex w-full items-center justify-between",
// Typography
"text-base md:text-sm",
// Colors
"bg-transparent",
Select source
Browse and copy the reviewed source included with this Free component.
controlled.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! rsjs-controlled select pilot.
use proa_core::{DataLoader, WebContext, WebRenderSync, WriteBuf, WriteError};
use rsjs::Handler;
use super::{SelectOption, SelectRecipeProps, SelectSize, SELECT_ANATOMY, SELECT_RECIPE};
/// Minimal controlled select for proving rsjs component composition over
/// repeated options.
///
/// This intentionally stays narrower than [`super::Select`]. It exercises the
/// `value` + `on_change` shape, repeated option markup, and loop-local option
/// payloads while the parent island owns the selected value.
pub struct ControlledSelect {
pub value: String,
pub options: &'static [SelectOption],
pub size: SelectSize,
pub class: Option<&'static str>,
pub on_change: Handler<String>,
}
impl ControlledSelect {
pub fn new(
value: String,
options: &'static [SelectOption],
on_change: Handler<String>,
) -> Self {
Self {
value,
options,
size: SelectSize::Default,
class: None,
on_change,
}
}
}
#[rsjs::rsjs(component, client)]
impl<B: WriteBuf, L: DataLoader> WebRenderSync<L, B> for ControlledSelect {
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
let value = rsjs::signal(self.value);
let style = SELECT_RECIPE.resolve(SelectRecipeProps {
size: Some(self.size),
});
let size = style.size().as_str();
proa_macros::html_sync! {
<select
data_controlled_select
data_select
data_scope={SELECT_ANATOMY.scope()}
data_part="root"
data_slot="select"
data_size={size}
class={rsjs::server_attr(proa_macros::text!("{} {}", style, self.class))}
value={value.get()}
on_change={|event: rsjs::ChangeEvent<rsjs::elem::Select>| self.on_change.call(event.current_value())}
>
{for option in self.options { proa_macros::html_sync! {
<option data_scope={SELECT_ANATOMY.scope()} data_part="option" value={option.value} disabled={option.disabled} selected={value.get() == option.value}>
{option.label}
</option>
} }}
</select>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
const OPTIONS: [SelectOption; 3] = [
SelectOption::new("standard", "Standard"),
SelectOption::new("express", "Express"),
SelectOption::disabled("archived", "Archived"),
];
#[test]
fn controlled_select_renders() {
let html = proa_core::render_to_string(ControlledSelect {
value: "express".to_string(),
options: &OPTIONS,
size: SelectSize::Default,
class: Some("extra-select"),
on_change: rsjs::noop_handler(),
});
assert!(html.contains("data-controlled-select"));
assert!(html.contains(r#"data-scope="select""#));
assert!(html.contains(r#"data-part="root""#));
assert_eq!(html.matches(r#"data-part="option""#).count(), 3);
assert!(html.contains("value=\"express\""));
assert!(html.contains("<option"));
assert!(html.contains(">Standard</option>"));
assert!(html.contains(">Express</option>"));
assert!(html.contains("selected"));
assert!(html.contains("disabled"));
assert!(html.contains("extra-select"));
}
#[test]
fn controlled_select_uses_resolved_recipe_size() {
let html = proa_core::render_to_string(ControlledSelect {
value: "standard".to_string(),
options: &OPTIONS,
size: SelectSize::Sm,
class: None,
on_change: rsjs::noop_handler(),
});
assert!(html.contains(r#"data-size="sm""#));
assert!(html.contains(SelectSize::Sm.classes()));
}
}
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::Select;
use crate::ListCollection;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
/// Select renders its current value or placeholder text in brackets.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B> for Select {
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
// Show the selected option's label, or the placeholder
if let Some(val) = self.value {
if let Some(opt) = ListCollection::new(&self.options).item(val) {
md_sync! { "[" { opt.label } "]" }.render_md(cx)?;
return Ok(());
}
}
if let Some(placeholder) = self.placeholder {
md_sync! { "[" { placeholder } "]" }.render_md(cx)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::super::SelectOption;
use super::*;
#[test]
fn select_renders_placeholder() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Select {
placeholder: Some("Choose one"),
options: vec![SelectOption::new("a", "Apple")],
..Select::new()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "[Choose one]");
}
#[test]
fn select_renders_selected_value() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Select {
value: Some("a"),
options: vec![
SelectOption::new("a", "Apple"),
SelectOption::new("b", "Banana"),
],
..Select::new()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "[Apple]");
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
#![allow(clippy::module_inception)]
//! Select component - form dropdown selection field
//!
//! The Select component provides a styled dropdown selection with support for
//! various sizes, placeholder text, and form integration.
use crate::Anatomy;
#[cfg(feature = "rsjs")]
mod controlled;
mod md;
mod recipe;
mod select;
/// Stable DOM parts shared by static, Markdown, and rsjs Select targets.
pub const SELECT_ANATOMY: Anatomy = Anatomy::new("select", &["root", "option"]);
// Re-export select types
#[cfg(feature = "rsjs")]
pub use controlled::ControlledSelect;
pub use select::{Select, SelectOption};
// Re-export recipe types
pub use recipe::{
select_classes, select_recipe, SelectRecipe, SelectRecipeProps, SelectSize, SelectStyle,
SELECT_RECIPE,
};
#[cfg(test)]
mod anatomy_tests {
use super::*;
#[test]
fn anatomy_declares_native_select_parts() {
assert_eq!(SELECT_ANATOMY.scope(), "select");
assert_eq!(SELECT_ANATOMY.parts(), &["root", "option"]);
}
}
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Select recipe - type-safe styling system for Select component
//!
//! Provides size variants and base styling for the Select component.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Base styles for the select
mod base {
use super::*;
pub const SELECT_BASE: &str = tw_join!(
// Layout
"flex w-full items-center justify-between",
// Typography
"text-base md:text-sm",
// Colors
"bg-transparent",
"text-foreground",
// Border (shadcn select style)
"border border-input",
"rounded-md",
// Shadow
"shadow-xs",
// Focus visible (shadcn style)
"focus-visible:outline-hidden",
"focus-visible:border-ring",
"focus-visible:ring-ring/50",
"focus-visible:ring-[3px]",
// Disabled
"disabled:cursor-not-allowed",
"disabled:opacity-50",
// Cursor
"cursor-pointer",
// Selection
"selection:bg-primary",
"selection:text-primary-foreground",
// Aria invalid (error state)
"aria-invalid:border-destructive",
"aria-invalid:ring-destructive/20",
"dark:aria-invalid:ring-destructive/40",
// Appearance - custom arrow
"appearance-none",
// Background image for dropdown arrow
"bg-no-repeat",
"bg-[length:16px_16px]",
"bg-[position:right_0.5rem_center]",
"[background-image:url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%236b7280%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22m6%209%206%206%206-6%22%2F%3E%3C%2Fsvg%3E')]",
// Padding to account for arrow
"pr-8"
);
}
/// Size variants for the select
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "size")]
pub enum SelectSize {
Sm,
#[default]
Default,
Lg,
}
impl SelectSize {
/// Get the Tailwind classes for this size
pub const fn classes(self) -> &'static str {
match self {
SelectSize::Sm => "h-8 px-2.5 py-1.5 text-xs",
SelectSize::Default => "h-9 px-3 py-1",
SelectSize::Lg => "h-10 px-4 py-2 text-base",
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Sm => "sm",
Self::Default => "default",
Self::Lg => "lg",
}
}
}
/// Optional inputs accepted by [`SelectRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SelectRecipeProps {
pub size: Option<SelectSize>,
}
/// Fully typed select recipe with compile-time defaults.
#[derive(Debug, Clone, Copy)]
pub struct SelectRecipe {
base: &'static str,
size_default: SelectSize,
}
/// Global select recipe instance
pub const SELECT_RECIPE: SelectRecipe = SelectRecipe {
base: base::SELECT_BASE,
size_default: SelectSize::Default,
};
impl SelectRecipe {
pub const fn resolve(&'static self, props: SelectRecipeProps) -> SelectStyle {
let size = match props.size {
Some(size) => size,
None => self.size_default,
};
SelectStyle { recipe: self, size }
}
}
/// Fully resolved, streamable Select style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct SelectStyle {
recipe: &'static SelectRecipe,
size: SelectSize,
}
impl SelectStyle {
pub const fn size(self) -> SelectSize {
self.size
}
}
impl AttrValue for SelectStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.base.as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.size.classes().as_bytes())
}
}
/// Get the global select recipe
pub const fn select_recipe() -> &'static SelectRecipe {
&SELECT_RECIPE
}
/// Compatibility resolver for callers of the former helper.
pub const fn select_classes(size: SelectSize) -> SelectStyle {
SELECT_RECIPE.resolve(SelectRecipeProps { size: Some(size) })
}
impl crate::variant_spec::ComponentSpec for SelectRecipe {
const NAME: &'static str = "Select";
fn base_classes(&self) -> &'static str {
self.base
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![crate::variant_spec::variant_metadata::<SelectSize>()]
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::SELECT_ANATOMY)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn select_recipe_generates_default() {
let style = select_recipe().resolve(SelectRecipeProps::default());
assert!(select_recipe().base.contains("flex"));
assert!(select_recipe().base.contains("rounded-md"));
assert_eq!(style.size(), SelectSize::Default);
assert!(style.size().classes().contains("h-9"));
}
#[test]
fn select_recipe_supports_sizes() {
for (size, expected) in [(SelectSize::Sm, "h-8"), (SelectSize::Lg, "h-10")] {
let style = select_recipe().resolve(SelectRecipeProps { size: Some(size) });
assert_eq!(style.size(), size);
assert!(style.size().classes().contains(expected));
}
}
#[test]
fn recipe_is_fully_const() {
const RECIPE: &SelectRecipe = &SELECT_RECIPE;
const STYLE: SelectStyle = RECIPE.resolve(SelectRecipeProps {
size: Some(SelectSize::Lg),
});
assert_eq!(STYLE.size(), SelectSize::Lg);
}
}
select.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Select component implementation
use super::recipe::{SelectRecipeProps, SelectSize, SELECT_RECIPE};
use super::SELECT_ANATOMY;
use crate::free_web::{binding_to_js_attr, JsonAttr};
#[cfg(test)]
use proa_core::ctx::Ctx;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_ir::{FieldRegistration, ReactiveAttr};
use proa_macros::html_sync;
/// A single option for the Select component
#[cfg_attr(feature = "rsjs", derive(rsjs::ClientProps))]
#[derive(Debug, Clone, PartialEq)]
pub struct SelectOption {
/// The value attribute (submitted with form)
pub value: &'static str,
/// The display label for this option
pub label: &'static str,
/// Whether this option is disabled
pub disabled: bool,
}
impl SelectOption {
/// Create a new option
pub const fn new(value: &'static str, label: &'static str) -> Self {
Self {
value,
label,
disabled: false,
}
}
/// Create a disabled option
pub const fn disabled(value: &'static str, label: &'static str) -> Self {
Self {
value,
label,
disabled: true,
}
}
}
impl crate::collection::ListCollectionItem for SelectOption {
type Value = str;
fn value(&self) -> &Self::Value {
self.value
}
fn text(&self) -> &str {
self.label
}
fn disabled(&self) -> bool {
self.disabled
}
}
/// Type-safe Select component.
///
/// A form select/dropdown field with support for various sizes and states.
/// Follows the shadcn/ui select design with Tailwind CSS styling.
///
/// # Example
///
/// ```ignore
/// use proa_ui::{Select, SelectOption, SelectSize};
/// use proa_macros::html_sync;
///
/// // Basic usage
/// html_sync! {
/// <div>
/// {Select {
/// options: vec![
/// SelectOption::new("apple", "Apple"),
/// SelectOption::new("banana", "Banana"),
/// SelectOption::new("orange", "Orange"),
/// ],
/// placeholder: Some("Select a fruit"),
/// ..Select::new()
/// }}
/// </div>
/// }.render(cx);
///
/// // With form registration (React Hook Form style)
/// let form = SignUpForm::form();
/// html_sync! {
/// <div>
/// {Select {
/// registration: Some(form.register_country()),
/// options: vec![
/// SelectOption::new("us", "United States"),
/// SelectOption::new("uk", "United Kingdom"),
/// SelectOption::new("ca", "Canada"),
/// ],
/// placeholder: Some("Select a country"),
/// aria_invalid: form.field_country().has_error(),
/// ..Select::new()
/// }}
/// </div>
/// }.render(cx);
/// ```
pub struct Select {
pub size: Option<SelectSize>,
/// The options to display in the dropdown
pub options: Vec<SelectOption>,
/// The name attribute for form submission
pub name: Option<&'static str>,
/// The currently selected value (if any)
pub value: Option<&'static str>,
/// Placeholder text shown when no value is selected
pub placeholder: Option<&'static str>,
/// Whether the select is disabled
pub disabled: ReactiveAttr<bool>,
/// Whether the select is required
pub required: bool,
/// Autofocus the select on page load
pub autofocus: bool,
pub aria_label: Option<&'static str>,
pub aria_describedby: Option<&'static str>,
pub aria_invalid: ReactiveAttr<bool>,
/// Field registration data (from form.register_*)
pub registration: Option<FieldRegistration>,
pub class: Option<&'static str>,
pub id: Option<&'static str>,
}
impl Select {
/// Create a new select with default settings
pub const fn new() -> Self {
Self {
size: None,
options: Vec::new(),
name: None,
value: None,
placeholder: None,
disabled: ReactiveAttr::static_value(false),
required: false,
autofocus: false,
aria_label: None,
aria_describedby: None,
aria_invalid: ReactiveAttr::static_value(false),
registration: None,
class: None,
id: None,
}
}
}
impl Default for Select {
fn default() -> Self {
Self::new()
}
}
impl<B: WriteBuf, L: ::proa_core::DataLoader> WebRenderSync<L, B> for Select {
#[inline(always)]
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = SELECT_RECIPE.resolve(SelectRecipeProps { size: self.size });
let is_disabled = self.disabled.default;
let is_aria_invalid = self.aria_invalid.default;
let state = crate::FormControlState {
disabled: is_disabled,
invalid: is_aria_invalid,
required: self.required,
read_only: false,
};
let disabled_binding_string = binding_to_js_attr(&self.disabled.binding);
let aria_invalid_binding_string = binding_to_js_attr(&self.aria_invalid.binding);
let disabled_binding = disabled_binding_string.as_deref();
let aria_invalid_binding = aria_invalid_binding_string.as_deref();
let registration = self.registration.as_ref();
let rules_attr = JsonAttr(registration.map(|reg| reg.rules.as_slice()));
let data_field = registration.map(|reg| reg.data_field);
let name_attr = registration.map(|reg| reg.name).or(self.name);
let validate_on = registration.map(|reg| match reg.validate_on {
proa_ir::ValidationTrigger::Blur => "blur",
proa_ir::ValidationTrigger::Input => "input",
proa_ir::ValidationTrigger::Change => "change",
proa_ir::ValidationTrigger::Submit => "submit",
});
let placeholder_is_selected = self.placeholder.is_some() && self.value.is_none();
let current_value = self.value;
html_sync! {
<select
class={proa_macros::text!("{} {}", style, self.class)}
id={self.id}
name={name_attr}
disabled={is_disabled}
required={self.required}
autofocus={self.autofocus}
aria-label={self.aria_label}
aria-describedby={self.aria_describedby}
aria-invalid={state.aria_invalid()}
aria-disabled={state.aria_disabled()}
aria-required={state.aria_required()}
data-select
data-scope={SELECT_ANATOMY.scope()}
data-part="root"
data-slot="select"
data-size={style.size().as_str()}
data-disabled={state.data_disabled()}
data-invalid={state.data_invalid()}
data-required={state.data_required()}
data-field={data_field}
data-rules={rules_attr}
data-validate-on={validate_on}
data-bind-disabled={disabled_binding}
data-bind-aria-invalid={aria_invalid_binding}
>
{if let Some(placeholder) = self.placeholder { html_sync! {
<option data-scope={SELECT_ANATOMY.scope()} data-part="option" data-placeholder="true" value="" disabled selected={placeholder_is_selected}>
{placeholder}
</option>
}.render(cx) ? ; }}
{for opt in self.options { let is_selected = current_value == Some(opt.value) ; html_sync! {
<option data-scope={SELECT_ANATOMY.scope()} data-part="option" value={opt.value} disabled={opt.disabled} selected={is_selected}>
{opt.label}
</option>
}.render(cx) ? ; }}
</select>
}
.render(cx)
}
}
/// Convert a Binding to a JavaScript expression string
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn select_renders_with_defaults() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Select::new().render(&mut cx).unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<select"));
assert!(html.contains("class=\""));
assert!(html.contains("h-9"));
assert!(html.contains("data-slot=\"select\""));
assert!(html.contains("data-scope=\"select\""));
assert!(html.contains("data-part=\"root\""));
assert!(html.contains("data-size=\"default\""));
}
#[test]
fn select_renders_with_options() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Select {
options: vec![
SelectOption::new("apple", "Apple"),
SelectOption::new("banana", "Banana"),
],
..Select::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<option"));
assert!(html.contains("value=\"apple\""));
assert!(html.contains(">Apple</option>"));
assert!(html.contains("value=\"banana\""));
assert!(html.contains(">Banana</option>"));
assert_eq!(html.matches("data-part=\"option\"").count(), 2);
}
#[test]
fn select_renders_with_placeholder() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Select {
placeholder: Some("Choose a fruit"),
options: vec![SelectOption::new("apple", "Apple")],
..Select::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("Choose a fruit"));
assert!(html.contains("value=\"\""));
assert!(html.contains(" disabled"));
}
#[test]
fn select_renders_with_selected_value() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Select {
value: Some("banana"),
options: vec![
SelectOption::new("apple", "Apple"),
SelectOption::new("banana", "Banana"),
],
..Select::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("value=\"banana\"") && html.contains(" selected"));
}
#[test]
fn select_applies_sizes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Select {
size: Some(SelectSize::Lg),
..Select::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("h-10"));
}
#[test]
fn select_handles_disabled_state() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Select {
disabled: true.into(),
..Select::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains(" disabled"));
}
#[test]
fn select_handles_required_state() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Select {
required: true,
..Select::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains(" required"));
}
#[test]
fn select_renders_aria_attributes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Select {
aria_label: Some("Country selection"),
aria_describedby: Some("country-help"),
aria_invalid: true.into(),
..Select::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("aria-label=\"Country selection\""));
assert!(html.contains("aria-describedby=\"country-help\""));
assert!(html.contains("aria-invalid=\"true\""));
assert!(html.contains("aria-disabled=\"false\""));
}
#[test]
fn select_renders_with_custom_class() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Select {
class: Some("custom-select"),
..Select::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("custom-select"));
}
#[test]
fn select_renders_disabled_option() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Select {
options: vec![
SelectOption::new("active", "Active"),
SelectOption::disabled("inactive", "Inactive (unavailable)"),
],
..Select::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("value=\"inactive\""));
}
#[test]
fn select_size_is_reasonable() {
assert!(std::mem::size_of::<Select>() < 512);
const _SELECT: Select = Select::new();
}
#[test]
fn select_with_reactive_disabled() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
let disabled = !ReactiveAttr::form_state(false, "isValid");
Select {
disabled,
..Select::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("disabled"));
assert!(html.contains("data-bind-disabled"));
assert!(html.contains("formState.isValid"));
}
#[test]
fn select_with_reactive_aria_invalid() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
let aria_invalid = ReactiveAttr::field_state(false, "country", "hasError");
Select {
name: Some("country"),
aria_invalid,
..Select::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-bind-aria-invalid"));
assert!(html.contains("fields.country.hasError"));
}
}