#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum SearchMethod {
#[default]
Get,
Post,
}
#[derive(Debug, Clone)]
pub struct ActiveSearchConfig<'a> {
pub action: &'a str,
pub method: SearchMethod,
pub target: &'a str,
pub indicator: Option<&'a str>,
pub debounce_ms: u32,
pub min_length: u32,
pub param_name: &'a str,
pub initial_load: bool,
pub placeholder: Option<&'a str>,
}
impl<'a> ActiveSearchConfig<'a> {
#[must_use]
pub const fn new(action: &'a str, target: &'a str) -> Self {
Self {
action,
method: SearchMethod::Get,
target,
indicator: None,
debounce_ms: 300,
min_length: 1,
param_name: "q",
initial_load: false,
placeholder: None,
}
}
#[must_use]
pub const fn post(mut self) -> Self {
self.method = SearchMethod::Post;
self
}
#[must_use]
pub const fn debounce(mut self, ms: u32) -> Self {
self.debounce_ms = ms;
self
}
#[must_use]
pub const fn min_length(mut self, n: u32) -> Self {
self.min_length = n;
self
}
#[must_use]
pub const fn indicator(mut self, selector: &'a str) -> Self {
self.indicator = Some(selector);
self
}
#[must_use]
pub const fn initial_load(mut self) -> Self {
self.initial_load = true;
self
}
#[must_use]
pub const fn placeholder(mut self, text: &'a str) -> Self {
self.placeholder = Some(text);
self
}
#[must_use]
pub const fn param_name(mut self, name: &'a str) -> Self {
self.param_name = name;
self
}
}
#[derive(Debug, Clone)]
pub struct AutocompleteConfig<'a> {
pub action: &'a str,
pub indicator: Option<&'a str>,
pub debounce_ms: u32,
pub min_length: u32,
pub query_param: &'a str,
pub value_name: &'a str,
pub placeholder: Option<&'a str>,
pub fallback_options: Option<&'a [(&'a str, &'a str)]>,
pub free_text: bool,
}
impl<'a> AutocompleteConfig<'a> {
#[must_use]
pub const fn new(action: &'a str, value_name: &'a str) -> Self {
Self {
action,
indicator: None,
debounce_ms: 300,
min_length: 1,
query_param: "q",
value_name,
placeholder: None,
fallback_options: None,
free_text: false,
}
}
#[must_use]
pub const fn debounce(mut self, ms: u32) -> Self {
self.debounce_ms = ms;
self
}
#[must_use]
pub const fn min_length(mut self, n: u32) -> Self {
self.min_length = n;
self
}
#[must_use]
pub const fn indicator(mut self, selector: &'a str) -> Self {
self.indicator = Some(selector);
self
}
#[must_use]
pub const fn query_param(mut self, name: &'a str) -> Self {
self.query_param = name;
self
}
#[must_use]
pub const fn placeholder(mut self, text: &'a str) -> Self {
self.placeholder = Some(text);
self
}
#[must_use]
pub const fn fallback_options(mut self, options: &'a [(&'a str, &'a str)]) -> Self {
self.fallback_options = Some(options);
self
}
#[must_use]
pub const fn free_text(mut self) -> Self {
self.free_text = true;
self
}
}
fn build_trigger(debounce_ms: u32, initial_load: bool) -> String {
let mut trigger = format!("input changed delay:{debounce_ms}ms");
if initial_load {
trigger.push_str(", load");
}
trigger
}
fn selector_to_id(selector: &str) -> &str {
selector.strip_prefix('#').unwrap_or(selector)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn active_search_input(id: &str, label: &str, config: &ActiveSearchConfig<'_>) -> maud::Markup {
let trigger = build_trigger(config.debounce_ms, config.initial_load);
let aria_controls = selector_to_id(config.target);
let (hx_get, hx_post) = match config.method {
SearchMethod::Get => (Some(config.action), None::<&str>),
SearchMethod::Post => (None, Some(config.action)),
};
maud::html! {
div class="autumn-search" {
label for=(id) class="autumn-search__label" { (label) }
input
type="search"
id=(id)
name=(config.param_name)
autocomplete="off"
aria-controls=(aria_controls)
placeholder=[config.placeholder]
class="autumn-search__input"
data-ac-min-length=(config.min_length)
hx-get=[hx_get]
hx-post=[hx_post]
hx-trigger=(trigger)
hx-target=(config.target)
hx-indicator=[config.indicator];
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn active_search_results(id: &str) -> maud::Markup {
maud::html! {
div
id=(id)
role="status"
aria-live="polite"
aria-atomic="true" {}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn active_search(id: &str, label: &str, config: &ActiveSearchConfig<'_>) -> maud::Markup {
debug_assert!(
config.target.starts_with('#'),
"active_search: config.target must be a #id selector (e.g. \"#my-results\"), got {:?}. \
Use active_search_input + active_search_results directly for other selectors.",
config.target
);
let results_id = selector_to_id(config.target).to_string();
let noscript_method = match config.method {
SearchMethod::Get => "get",
SearchMethod::Post => "post",
};
maud::html! {
div id=(format!("{id}-wrapper")) {
(active_search_input(id, label, config))
(active_search_results(&results_id))
noscript {
form action=(config.action) method=(noscript_method) {
label for=(format!("{id}-noscript")) { (label) }
input
type="search"
id=(format!("{id}-noscript"))
name=(config.param_name)
placeholder=[config.placeholder];
button type="submit" { "Search" }
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn active_search_empty_state(message: &str) -> maud::Markup {
maud::html! {
div
role="status"
aria-live="polite"
class="search-empty" {
(message)
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn autocomplete_input(id: &str, label: &str, config: &AutocompleteConfig<'_>) -> maud::Markup {
let query_id = format!("{id}-query");
let value_id = format!("{id}-value");
let options_id = format!("{id}-options");
let trigger = build_trigger(config.debounce_ms, false);
let target = format!("#{options_id}");
maud::html! {
div
id=(format!("{id}-wrapper"))
class="autumn-autocomplete"
data-ac-value-id=(value_id)
data-ac-value-name=(config.value_name)
data-ac-free-text[config.free_text] {
label for=(query_id) class="autumn-autocomplete__label" { (label) }
input
type="search"
id=(query_id)
name=(config.query_param)
autocomplete="off"
role="combobox"
aria-expanded="false"
aria-autocomplete="list"
aria-controls=(options_id)
placeholder=[config.placeholder]
class="autumn-autocomplete__input"
data-ac-query
data-ac-min-length=(config.min_length)
hx-get=(config.action)
hx-trigger=(trigger)
hx-target=(target)
hx-indicator=[config.indicator];
input
type="hidden"
id=(value_id)
value="";
div
id=(options_id)
role="listbox"
aria-label=(label)
aria-live="polite"
class="autumn-autocomplete__options" {}
noscript {
select name=(config.value_name) aria-label=(label) {
option value="" { "— select —" }
@if let Some(opts) = config.fallback_options {
@for (val, lbl) in opts {
option value=(val) { (lbl) }
}
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn autocomplete_option(value: &str, label: &str) -> maud::Markup {
maud::html! {
div
role="option"
tabindex="0"
class="autumn-autocomplete__option"
data-value=(value) {
(label)
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn autocomplete_empty_state(message: &str) -> maud::Markup {
maud::html! {
div
role="status"
aria-live="polite"
class="autocomplete-empty" {
(message)
}
}
}
#[cfg(all(test, feature = "maud"))]
mod tests {
use super::*;
#[test]
fn trigger_has_debounce() {
let t = build_trigger(300, false);
assert!(t.contains("delay:300ms"), "{t}");
}
#[test]
fn trigger_has_changed_modifier() {
let t = build_trigger(300, false);
assert!(t.contains("changed"), "{t}");
}
#[test]
fn trigger_has_no_filter_expressions() {
let t = build_trigger(300, false);
assert!(!t.contains("this.value.length"), "{t}");
assert!(!t.contains('['), "{t}");
}
#[test]
fn trigger_initial_load_appends_load() {
let t = build_trigger(300, true);
assert!(t.contains(", load"), "{t}");
}
#[test]
fn trigger_no_initial_load_by_default() {
let t = build_trigger(300, false);
assert!(!t.contains("load"), "{t}");
}
#[test]
fn trigger_custom_debounce() {
let t = build_trigger(750, false);
assert!(t.contains("delay:750ms"), "{t}");
}
#[test]
fn selector_to_id_strips_hash() {
assert_eq!(selector_to_id("#my-results"), "my-results");
}
#[test]
fn selector_to_id_passthrough_without_hash() {
assert_eq!(selector_to_id("results"), "results");
}
#[test]
fn config_defaults() {
let c = ActiveSearchConfig::new("/s", "#r");
assert_eq!(c.method, SearchMethod::Get);
assert_eq!(c.debounce_ms, 300);
assert_eq!(c.min_length, 1);
assert_eq!(c.param_name, "q");
assert!(!c.initial_load);
assert!(c.indicator.is_none());
assert!(c.placeholder.is_none());
}
#[test]
fn config_post_builder() {
assert_eq!(
ActiveSearchConfig::new("/s", "#r").post().method,
SearchMethod::Post
);
}
#[test]
fn config_debounce_builder() {
assert_eq!(
ActiveSearchConfig::new("/s", "#r")
.debounce(500)
.debounce_ms,
500
);
}
#[test]
fn config_min_length_builder() {
assert_eq!(
ActiveSearchConfig::new("/s", "#r").min_length(3).min_length,
3
);
}
#[test]
fn config_initial_load_builder() {
assert!(
ActiveSearchConfig::new("/s", "#r")
.initial_load()
.initial_load
);
}
#[test]
fn config_indicator_builder() {
assert_eq!(
ActiveSearchConfig::new("/s", "#r")
.indicator("#spin")
.indicator,
Some("#spin")
);
}
#[test]
fn config_placeholder_builder() {
assert_eq!(
ActiveSearchConfig::new("/s", "#r")
.placeholder("hint")
.placeholder,
Some("hint")
);
}
#[test]
fn config_param_name_builder() {
assert_eq!(
ActiveSearchConfig::new("/s", "#r")
.param_name("query")
.param_name,
"query"
);
}
#[test]
fn input_defaults_to_hx_get() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains(r#"hx-get="/search""#), "{html}");
assert!(!html.contains("hx-post"), "{html}");
}
#[test]
fn input_uses_hx_post_when_configured() {
let config = ActiveSearchConfig::new("/search", "#results").post();
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains(r#"hx-post="/search""#), "{html}");
assert!(!html.contains("hx-get"), "{html}");
}
#[test]
fn input_trigger_has_default_debounce() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains("delay:300ms"), "{html}");
}
#[test]
fn input_configurable_debounce() {
let config = ActiveSearchConfig::new("/search", "#results").debounce(500);
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains("delay:500ms"), "{html}");
}
#[test]
fn input_configurable_min_length() {
let config = ActiveSearchConfig::new("/search", "#results").min_length(3);
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains("hx-trigger"), "{html}");
assert!(!html.contains("this.value.length"), "{html}");
}
#[test]
fn input_no_filter_when_min_length_zero() {
let config = ActiveSearchConfig::new("/search", "#results").min_length(0);
let html = active_search_input("q", "Search", &config).into_string();
assert!(!html.contains("this.value.length"), "{html}");
}
#[test]
fn input_initial_load_in_trigger() {
let config = ActiveSearchConfig::new("/search", "#results").initial_load();
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains(", load"), "{html}");
}
#[test]
fn input_no_initial_load_by_default() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search_input("q", "Search", &config).into_string();
assert!(!html.contains(", load"), "{html}");
}
#[test]
fn input_target_selector() {
let config = ActiveSearchConfig::new("/search", "#my-results");
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains("hx-target=\"#my-results\""), "{html}");
}
#[test]
fn input_indicator_when_configured() {
let config = ActiveSearchConfig::new("/search", "#results").indicator("#spinner");
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains("hx-indicator=\"#spinner\""), "{html}");
}
#[test]
fn input_no_indicator_by_default() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search_input("q", "Search", &config).into_string();
assert!(!html.contains("hx-indicator"), "{html}");
}
#[test]
fn input_renders_label() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search_input("q", "Search Posts", &config).into_string();
assert!(html.contains("Search Posts"), "{html}");
assert!(html.contains("<label"), "{html}");
}
#[test]
fn input_label_for_matches_id() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search_input("my-search", "Search", &config).into_string();
assert!(html.contains(r#"for="my-search""#), "{html}");
assert!(html.contains(r#"id="my-search""#), "{html}");
}
#[test]
fn input_has_aria_controls() {
let config = ActiveSearchConfig::new("/search", "#my-results");
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains("aria-controls"), "{html}");
assert!(html.contains("my-results"), "{html}");
}
#[test]
fn input_type_is_search() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains(r#"type="search""#), "{html}");
}
#[test]
fn input_placeholder_when_configured() {
let config = ActiveSearchConfig::new("/search", "#results").placeholder("Type to search…");
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains("Type to search"), "{html}");
}
#[test]
fn input_no_placeholder_by_default() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search_input("q", "Search", &config).into_string();
assert!(!html.contains("placeholder"), "{html}");
}
#[test]
fn input_custom_param_name() {
let config = ActiveSearchConfig::new("/search", "#results").param_name("query");
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains(r#"name="query""#), "{html}");
}
#[test]
fn results_correct_id() {
let html = active_search_results("my-results").into_string();
assert!(html.contains(r#"id="my-results""#), "{html}");
}
#[test]
fn results_role_status() {
let html = active_search_results("r").into_string();
assert!(html.contains(r#"role="status""#), "{html}");
}
#[test]
fn results_aria_live_polite() {
let html = active_search_results("r").into_string();
assert!(html.contains(r#"aria-live="polite""#), "{html}");
}
#[test]
fn results_aria_atomic() {
let html = active_search_results("r").into_string();
assert!(html.contains(r#"aria-atomic="true""#), "{html}");
}
#[test]
fn widget_includes_input_and_results() {
let config = ActiveSearchConfig::new("/search", "#s-results");
let html = active_search("s", "Search", &config).into_string();
assert!(html.contains(r#"type="search""#), "{html}");
assert!(html.contains(r#"id="s-results""#), "{html}");
}
#[test]
fn widget_results_id_matches_target_selector() {
let config = ActiveSearchConfig::new("/search", "#custom-results");
let html = active_search("search-widget", "Search", &config).into_string();
assert!(html.contains(r#"id="custom-results""#), "{html}");
}
#[test]
fn widget_has_noscript_fallback() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search("s", "Search", &config).into_string();
assert!(html.contains("<noscript>"), "{html}");
assert!(html.contains("<form"), "{html}");
assert!(html.contains(r#"type="submit""#), "{html}");
}
#[test]
fn widget_noscript_get_by_default() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search("s", "Search", &config).into_string();
assert!(html.contains(r#"method="get""#), "{html}");
}
#[test]
fn widget_noscript_post_when_configured() {
let config = ActiveSearchConfig::new("/search", "#results").post();
let html = active_search("s", "Search", &config).into_string();
assert!(html.contains(r#"method="post""#), "{html}");
}
#[test]
fn autocomplete_visible_search_input() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"type="search""#), "{html}");
assert!(html.contains(r#"name="q""#), "{html}");
}
#[test]
fn autocomplete_visible_input_uses_query_param() {
let config = AutocompleteConfig::new("/ac", "value_field").query_param("search");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"name="search""#), "{html}");
}
#[test]
fn autocomplete_hidden_value_field() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"type="hidden""#), "{html}");
assert!(html.contains(r#"id="x-value""#), "{html}");
}
#[test]
fn autocomplete_hidden_field_empty_initial_value() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"type="hidden""#), "{html}");
assert!(html.contains(r#"value="""#), "{html}");
}
#[test]
fn autocomplete_listbox_container() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"role="listbox""#), "{html}");
}
#[test]
fn autocomplete_wrapper_has_data_attributes_for_runtime() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"data-ac-value-id="x-value""#), "{html}");
assert!(
html.contains(r#"data-ac-value-name="value_field""#),
"{html}"
);
assert!(html.contains("data-ac-query"), "{html}");
assert!(html.contains("data-ac-min-length"), "{html}");
}
#[test]
fn autocomplete_free_text_mode_sets_data_attribute() {
let config = AutocompleteConfig::new("/ac", "value_field").free_text();
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains("data-ac-free-text"), "{html}");
}
#[test]
fn autocomplete_id_mode_no_free_text_attribute() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(!html.contains("data-ac-free-text"), "{html}");
}
#[test]
fn autocomplete_combobox_role() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"role="combobox""#), "{html}");
}
#[test]
fn autocomplete_aria_expanded_false() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"aria-expanded="false""#), "{html}");
}
#[test]
fn autocomplete_aria_autocomplete_list() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"aria-autocomplete="list""#), "{html}");
}
#[test]
fn autocomplete_has_aria_controls() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains("aria-controls"), "{html}");
}
#[test]
fn autocomplete_renders_label() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "My Label", &config).into_string();
assert!(html.contains("My Label"), "{html}");
assert!(html.contains("<label"), "{html}");
}
#[test]
fn autocomplete_label_for_matches_query_input_id() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("tag", "Tag", &config).into_string();
assert!(html.contains(r#"for="tag-query""#), "{html}");
assert!(html.contains(r#"id="tag-query""#), "{html}");
}
#[test]
fn autocomplete_has_noscript_fallback() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains("<noscript>"), "{html}");
assert!(html.contains("<select"), "{html}");
}
#[test]
fn autocomplete_noscript_select_uses_value_name() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"name="value_field""#), "{html}");
}
#[test]
fn autocomplete_fallback_options_rendered_in_noscript() {
let opts: &[(&str, &str)] = &[("1", "Alpha"), ("2", "Beta")];
let config = AutocompleteConfig::new("/ac", "value_field").fallback_options(opts);
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains("Alpha"), "{html}");
assert!(html.contains("Beta"), "{html}");
assert!(html.contains(r#"value="1""#), "{html}");
assert!(html.contains(r#"value="2""#), "{html}");
}
#[test]
fn autocomplete_has_hx_get() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"hx-get="/ac""#), "{html}");
}
#[test]
fn autocomplete_hx_trigger_has_debounce() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains("hx-trigger"), "{html}");
assert!(html.contains("delay:300ms"), "{html}");
}
#[test]
fn autocomplete_configurable_debounce() {
let config = AutocompleteConfig::new("/ac", "value_field").debounce(600);
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains("delay:600ms"), "{html}");
}
#[test]
fn autocomplete_configurable_min_length() {
let config = AutocompleteConfig::new("/ac", "value_field").min_length(2);
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"data-ac-min-length="2""#), "{html}");
assert!(!html.contains("this.value.length"), "{html}");
}
#[test]
fn autocomplete_indicator_when_configured() {
let config = AutocompleteConfig::new("/ac", "value_field").indicator("#ld");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains("hx-indicator=\"#ld\""), "{html}");
}
#[test]
fn autocomplete_no_indicator_by_default() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(!html.contains("hx-indicator"), "{html}");
}
#[test]
fn autocomplete_listbox_has_aria_live() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains("aria-live"), "{html}");
}
#[test]
fn autocomplete_listbox_has_no_inline_handlers() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(!html.contains("hx-on:keydown"), "{html}");
assert!(!html.contains("hx-on:click"), "{html}");
assert!(!html.contains("hx-on:input"), "{html}");
assert!(!html.contains("oninput"), "{html}");
}
#[test]
fn option_renders_label_and_value() {
let html = autocomplete_option("42", "My Tag").into_string();
assert!(html.contains("My Tag"), "{html}");
assert!(html.contains(r#"data-value="42""#), "{html}");
}
#[test]
fn option_has_role_option() {
let html = autocomplete_option("1", "Option").into_string();
assert!(html.contains(r#"role="option""#), "{html}");
}
#[test]
fn option_is_keyboard_focusable() {
let html = autocomplete_option("1", "Option").into_string();
assert!(html.contains("tabindex"), "{html}");
}
#[test]
fn ac_empty_state_renders_message() {
let html = autocomplete_empty_state("No results found").into_string();
assert!(html.contains("No results found"), "{html}");
}
#[test]
fn ac_empty_state_announced_to_screen_readers() {
let html = autocomplete_empty_state("No results").into_string();
assert!(
html.contains(r#"role="status""#) || html.contains("aria-live"),
"{html}"
);
}
#[test]
fn search_empty_state_renders_message() {
let html = active_search_empty_state("No matching posts").into_string();
assert!(html.contains("No matching posts"), "{html}");
}
#[test]
fn search_empty_state_announced_to_screen_readers() {
let html = active_search_empty_state("Nothing found").into_string();
assert!(
html.contains(r#"role="status""#) || html.contains("aria-live"),
"{html}"
);
}
}