use std::cmp::Ordering;
use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
use dioxus::prelude::*;
use crate::tag::TagLike;
static INSTANCE_COUNTER: AtomicU32 = AtomicU32::new(0);
#[derive(Clone, PartialEq, Debug)]
pub struct SuggestionGroup<T: TagLike> {
pub label: String,
pub items: Vec<T>,
pub total_count: usize,
}
pub struct TagInputGroupConfig<T: TagLike> {
pub available_tags: Vec<T>,
pub initial_selected: Vec<T>,
pub filter: Option<fn(&T, &str) -> bool>,
pub sort_items: Option<fn(&T, &T) -> Ordering>,
pub sort_groups: Option<fn(&str, &str) -> Ordering>,
pub max_items_per_group: Option<usize>,
pub value: Option<Signal<Vec<T>>>,
pub query: Option<Signal<String>>,
}
pub struct TagInputConfig<T: TagLike> {
pub available_tags: Vec<T>,
pub initial_selected: Vec<T>,
pub value: Option<Signal<Vec<T>>>,
pub query: Option<Signal<String>>,
}
impl<T: TagLike> TagInputConfig<T> {
pub fn new(available_tags: Vec<T>, initial_selected: Vec<T>) -> Self {
Self {
available_tags,
initial_selected,
value: None,
query: None,
}
}
}
pub fn find_match_ranges(text: &str, query: &str) -> Vec<(usize, usize)> {
if query.is_empty() {
return Vec::new();
}
let text_lower = text.to_lowercase();
let query_lower = query.to_lowercase();
let mut ranges = Vec::new();
let mut start = 0;
while let Some(pos) = text_lower[start..].find(&query_lower) {
let abs_start = start + pos;
let abs_end = abs_start + query.len();
ranges.push((abs_start, abs_end));
start = abs_end;
}
ranges
}
#[allow(clippy::type_complexity)]
pub struct TagInputState<T: TagLike> {
pub search_query: Signal<String>,
pub selected_tags: Signal<Vec<T>>,
pub available_tags: Signal<Vec<T>>,
pub active_pill: Signal<Option<usize>>,
pub popover_pill: Signal<Option<usize>>,
pub on_create: Signal<Option<Callback<String, Option<T>>>>,
pub on_remove: Signal<Option<Callback<T>>>,
pub on_add: Signal<Option<Callback<T>>>,
pub on_query_change: Signal<Option<EventHandler<String>>>,
pub on_commit: Signal<Option<EventHandler<String>>>,
pub is_disabled: Signal<bool>,
pub status_message: Signal<String>,
pub on_paste: Signal<Option<Callback<String, Vec<T>>>>,
pub paste_delimiters: Signal<Option<Vec<char>>>,
pub editing_pill: Signal<Option<usize>>,
pub on_edit: Signal<Option<Callback<(T, String), Option<T>>>>,
pub on_reorder: Signal<Option<Callback<(usize, usize)>>>,
pub delimiters: Signal<Option<Vec<char>>>,
pub max_tags: Signal<Option<usize>>,
pub is_at_limit: Memo<bool>,
pub validate: Signal<Option<Callback<T, Result<(), String>>>>,
pub validation_error: Signal<Option<String>>,
pub allow_duplicates: Signal<bool>,
pub on_duplicate: Signal<Option<Callback<T>>>,
pub enforce_allow_list: Signal<bool>,
pub deny_list: Signal<Option<Vec<String>>>,
pub min_tags: Signal<Option<usize>>,
pub is_below_minimum: Memo<bool>,
pub is_readonly: Signal<bool>,
pub max_tag_length: Signal<Option<usize>>,
pub filter: Signal<Option<fn(&T, &str) -> bool>>,
pub max_visible_tags: Signal<Option<usize>>,
pub overflow_count: Memo<usize>,
pub visible_tags: Memo<Vec<T>>,
pub sort_selected: Signal<Option<fn(&T, &T) -> Ordering>>,
pub form_value: Memo<String>,
pub select_mode: Signal<bool>,
instance_id: u32,
}
impl<T: TagLike> Clone for TagInputState<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T: TagLike> Copy for TagInputState<T> {}
impl<T: TagLike> PartialEq for TagInputState<T> {
fn eq(&self, other: &Self) -> bool {
self.search_query == other.search_query
&& self.selected_tags == other.selected_tags
&& self.available_tags == other.available_tags
&& self.active_pill == other.active_pill
&& self.popover_pill == other.popover_pill
&& self.on_create == other.on_create
&& self.on_remove == other.on_remove
&& self.on_add == other.on_add
&& self.on_query_change == other.on_query_change
&& self.on_commit == other.on_commit
&& self.is_disabled == other.is_disabled
&& self.status_message == other.status_message
&& self.on_paste == other.on_paste
&& self.paste_delimiters == other.paste_delimiters
&& self.editing_pill == other.editing_pill
&& self.on_edit == other.on_edit
&& self.on_reorder == other.on_reorder
&& self.delimiters == other.delimiters
&& self.max_tags == other.max_tags
&& self.is_at_limit == other.is_at_limit
&& self.validate == other.validate
&& self.validation_error == other.validation_error
&& self.allow_duplicates == other.allow_duplicates
&& self.on_duplicate == other.on_duplicate
&& self.enforce_allow_list == other.enforce_allow_list
&& self.deny_list == other.deny_list
&& self.min_tags == other.min_tags
&& self.is_below_minimum == other.is_below_minimum
&& self.is_readonly == other.is_readonly
&& self.max_tag_length == other.max_tag_length
&& self.filter == other.filter
&& self.max_visible_tags == other.max_visible_tags
&& self.overflow_count == other.overflow_count
&& self.visible_tags == other.visible_tags
&& self.sort_selected == other.sort_selected
&& self.form_value == other.form_value
&& self.select_mode == other.select_mode
&& self.instance_id == other.instance_id
}
}
impl<T: TagLike> TagInputState<T> {
pub fn set_query(&mut self, query: String) {
if *self.is_disabled.read() || *self.is_readonly.read() {
return;
}
self.search_query.set(query.clone());
self.active_pill.set(None);
self.popover_pill.set(None);
self.validation_error.set(None);
let cb = *self.on_query_change.read();
if let Some(handler) = cb {
handler.call(query);
}
}
pub fn add_tag(&mut self, tag: T) {
if *self.is_disabled.read() || *self.is_readonly.read() {
return;
}
if *self.enforce_allow_list.read() {
let in_allow_list = self
.available_tags
.read()
.iter()
.any(|t| t.id() == tag.id());
if !in_allow_list {
self.status_message
.set("Only suggestions can be selected.".to_string());
return;
}
}
if let Some(ref bl) = *self.deny_list.read() {
let tag_name_lower = tag.name().to_lowercase();
if bl.iter().any(|b| b.to_lowercase() == tag_name_lower) {
let name = tag.name().to_string();
self.status_message.set(format_status_denied(&name));
self.validation_error.set(Some(format_status_denied(&name)));
return;
}
}
if let Some(max_len) = *self.max_tag_length.read()
&& tag.name().len() > max_len
{
self.validation_error
.set(Some(format_error_max_length(max_len)));
return;
}
if *self.select_mode.read()
&& let Some(1) = *self.max_tags.read()
&& self.selected_tags.read().len() == 1
{
let old_id = self.selected_tags.read()[0].id().to_string();
self.selected_tags.write().retain(|t| t.id() != old_id);
}
if let Some(max) = *self.max_tags.read()
&& self.selected_tags.read().len() >= max
{
self.status_message
.set(format!("Maximum of {max} tags reached."));
self.search_query.set(String::new());
return;
}
let already_selected = self.selected_tags.read().iter().any(|t| t.id() == tag.id());
if already_selected && !*self.allow_duplicates.read() {
let name = tag.name().to_string();
self.status_message.set(format_status_duplicate(&name));
if let Some(cb) = *self.on_duplicate.read() {
cb.call(tag);
}
self.search_query.set(String::new());
self.active_pill.set(None);
self.popover_pill.set(None);
return;
}
if !already_selected || *self.allow_duplicates.read() {
let validate_cb = *self.validate.read();
if let Some(cb) = validate_cb
&& let Err(msg) = cb.call(tag.clone())
{
self.validation_error.set(Some(msg));
return;
}
self.validation_error.set(None);
let name = tag.name().to_string();
self.selected_tags.write().push(tag.clone());
if let Some(sort_fn) = *self.sort_selected.read() {
self.selected_tags.write().sort_by(sort_fn);
}
let count = self.selected_tags.read().len();
self.status_message.set(format_status_added(&name, count));
if let Some(cb) = *self.on_add.read() {
cb.call(tag);
}
}
self.search_query.set(String::new());
self.active_pill.set(None);
self.popover_pill.set(None);
}
pub fn remove_tag(&mut self, id: &str) {
if *self.is_disabled.read() || *self.is_readonly.read() {
return;
}
let is_locked = self
.selected_tags
.read()
.iter()
.any(|t| t.id() == id && t.is_locked());
if is_locked {
return;
}
let name = self
.selected_tags
.read()
.iter()
.find(|t| t.id() == id)
.map(|t| t.name().to_string());
if let Some(cb) = *self.on_remove.read()
&& let Some(tag) = self
.selected_tags
.read()
.iter()
.find(|t| t.id() == id)
.cloned()
{
cb.call(tag);
}
self.selected_tags.write().retain(|t| t.id() != id);
if let Some(name) = name {
let count = self.selected_tags.read().len();
self.status_message.set(format_status_removed(&name, count));
}
self.popover_pill.set(None);
}
pub fn remove_last_tag(&mut self) {
let tags = self.selected_tags.read();
if let Some(pos) = tags.iter().rposition(|t| !t.is_locked()) {
let tag = tags[pos].clone();
let name = tag.name().to_string();
drop(tags);
if let Some(cb) = *self.on_remove.read() {
cb.call(tag);
}
self.selected_tags.write().remove(pos);
let count = self.selected_tags.read().len();
self.status_message.set(format_status_removed(&name, count));
}
}
pub fn handle_click(&mut self) {
if *self.is_disabled.read() || *self.is_readonly.read() {
return;
}
self.active_pill.set(None);
self.popover_pill.set(None);
}
pub fn toggle_popover(&mut self, index: usize) {
let current = *self.popover_pill.read();
if current == Some(index) {
self.popover_pill.set(None);
} else {
self.popover_pill.set(Some(index));
}
}
pub fn close_popover(&mut self) {
self.popover_pill.set(None);
}
pub fn suggestion_id(&self, index: usize) -> String {
format!("dti-{}-s-{}", self.instance_id, index)
}
pub fn listbox_id(&self) -> String {
format!("dti-{}-listbox", self.instance_id)
}
pub fn pill_id(&self, index: usize) -> String {
format!("dti-{}-p-{}", self.instance_id, index)
}
pub fn create_tag(&mut self, tag: T) {
self.available_tags.write().push(tag.clone());
self.add_tag(tag);
}
pub fn handle_paste(&mut self, text: String) {
if *self.is_disabled.read() || *self.is_readonly.read() {
return;
}
if text.is_empty() {
return;
}
let paste_cb = *self.on_paste.read();
if let Some(cb) = paste_cb {
let tags = cb.call(text);
let added = tags.len();
for tag in tags {
self.add_tag(tag);
}
if added > 0 {
let count = self.selected_tags.read().len();
self.status_message.set(format_status_pasted(added, count));
}
return;
}
let delimiters = self.paste_delimiters.read().clone();
let create_cb = *self.on_create.read();
if let Some(delimiters) = delimiters
&& let Some(cb) = create_cb
{
let tokens = split_by_delimiters(&text, &delimiters);
let mut added = 0;
for token in tokens {
if let Some(tag) = cb.call(token) {
self.create_tag(tag);
added += 1;
}
}
if added > 0 {
let count = self.selected_tags.read().len();
self.status_message.set(format_status_pasted(added, count));
}
}
}
pub fn announce(&mut self, message: String) {
self.status_message.set(message);
}
pub fn start_editing(&mut self, index: usize) {
if *self.is_disabled.read() || *self.is_readonly.read() {
return;
}
if self.on_edit.read().is_none() {
return;
}
if index >= self.selected_tags.read().len() {
return;
}
if self.selected_tags.read()[index].is_locked() {
return;
}
self.editing_pill.set(Some(index));
self.popover_pill.set(None);
self.active_pill.set(Some(index));
}
pub fn commit_edit(&mut self, new_name: String) {
let idx = match *self.editing_pill.read() {
Some(i) => i,
None => return,
};
let edit_cb = *self.on_edit.read();
if let Some(cb) = edit_cb {
let current = self.selected_tags.read().get(idx).cloned();
if let Some(tag) = current
&& let Some(updated) = cb.call((tag, new_name))
{
self.selected_tags.write()[idx] = updated;
}
}
self.editing_pill.set(None);
}
pub fn cancel_edit(&mut self) {
self.editing_pill.set(None);
}
pub fn move_tag(&mut self, from: usize, to: usize) {
if *self.is_disabled.read() || *self.is_readonly.read() {
return;
}
let len = self.selected_tags.read().len();
if from >= len || to >= len || from == to {
return;
}
let tag = self.selected_tags.write().remove(from);
let name = tag.name().to_string();
self.selected_tags.write().insert(to, tag);
self.status_message
.set(format!("{name} moved to position {}.", to + 1));
if let Some(cb) = *self.on_reorder.read() {
cb.call((from, to));
}
}
pub fn clear_all(&mut self) {
if *self.is_disabled.read() || *self.is_readonly.read() {
return;
}
let tags = self.selected_tags.read().clone();
let to_remove: Vec<T> = tags.into_iter().filter(|t| !t.is_locked()).collect();
let removed_count = to_remove.len();
let remove_cb = *self.on_remove.read();
for tag in &to_remove {
if let Some(cb) = remove_cb {
cb.call(tag.clone());
}
}
self.selected_tags.write().retain(|t| t.is_locked());
self.active_pill.set(None);
self.popover_pill.set(None);
self.editing_pill.set(None);
let locked_count = self.selected_tags.read().len();
if locked_count > 0 {
self.status_message.set(format!(
"All tags cleared. {locked_count} locked tag{} remain{}.",
if locked_count == 1 { "" } else { "s" },
if locked_count == 1 { "s" } else { "" }
));
} else {
self.status_message.set(format!(
"{removed_count} tag{} cleared.",
if removed_count == 1 { "" } else { "s" }
));
}
}
pub fn select_all(&mut self) {
if *self.is_disabled.read() || *self.is_readonly.read() {
return;
}
let available = self.available_tags.read().clone();
let mut added = 0;
for tag in available {
if let Some(max) = *self.max_tags.read()
&& self.selected_tags.read().len() >= max
{
break;
}
let already = self.selected_tags.read().iter().any(|t| t.id() == tag.id());
if !already {
self.selected_tags.write().push(tag.clone());
added += 1;
if let Some(cb) = *self.on_add.read() {
cb.call(tag);
}
}
}
if added > 0 {
let count = self.selected_tags.read().len();
self.status_message.set(format!(
"{added} tag{} added. {count} tag{} selected.",
if added == 1 { "" } else { "s" },
if count == 1 { "" } else { "s" }
));
}
}
pub fn handle_keydown(&mut self, event: Event<KeyboardData>) {
if *self.is_disabled.read() {
return;
}
let pill = *self.active_pill.read();
if let Some(i) = pill {
self.handle_pill_keydown(event, i);
} else {
self.handle_input_keydown(event);
}
}
pub fn handle_pill_keydown(&mut self, event: Event<KeyboardData>, pill_index: usize) {
let key = event.key();
let readonly = *self.is_readonly.read();
match key {
Key::Enter => {
if readonly {
return;
}
event.prevent_default();
self.toggle_popover(pill_index);
}
Key::ArrowLeft => {
event.prevent_default();
self.popover_pill.set(None);
if pill_index > 0 {
self.active_pill.set(Some(pill_index - 1));
}
}
Key::ArrowRight => {
event.prevent_default();
self.popover_pill.set(None);
let len = self.selected_tags.read().len();
if pill_index < len - 1 {
self.active_pill.set(Some(pill_index + 1));
} else {
self.active_pill.set(None); }
}
Key::Backspace | Key::Delete => {
if readonly {
return;
}
event.prevent_default();
if self.popover_pill.read().is_some() {
self.popover_pill.set(None);
} else {
let is_locked = self
.selected_tags
.read()
.get(pill_index)
.is_some_and(|t| t.is_locked());
if !is_locked {
let id = self.selected_tags.read()[pill_index].id().to_string();
self.remove_tag(&id);
let new_len = self.selected_tags.read().len();
if new_len == 0 {
self.active_pill.set(None);
} else if pill_index >= new_len {
self.active_pill.set(Some(new_len - 1));
}
}
}
}
Key::Home => {
event.prevent_default();
self.popover_pill.set(None);
self.active_pill.set(Some(0));
}
Key::End => {
event.prevent_default();
self.popover_pill.set(None);
let len = self.selected_tags.read().len();
if len > 0 {
self.active_pill.set(Some(len - 1));
}
}
Key::Escape => {
if self.popover_pill.read().is_some() {
self.popover_pill.set(None);
} else {
self.active_pill.set(None);
}
}
_ => {
if readonly {
return;
}
self.active_pill.set(None);
self.popover_pill.set(None);
}
}
}
pub fn handle_input_keydown(&mut self, event: Event<KeyboardData>) {
let key = event.key();
let readonly = *self.is_readonly.read();
if readonly {
match key {
Key::ArrowLeft => {
if self.search_query.read().is_empty() {
let len = self.selected_tags.read().len();
if len > 0 {
event.prevent_default();
self.active_pill.set(Some(len - 1));
}
}
}
Key::Escape => {
self.active_pill.set(None);
}
_ => {}
}
return;
}
match key {
Key::ArrowLeft => {
if self.search_query.read().is_empty() {
let len = self.selected_tags.read().len();
if len > 0 {
event.prevent_default();
self.active_pill.set(Some(len - 1));
}
}
}
Key::Enter => {
event.prevent_default();
let query = self.search_query.read().clone();
let callback = *self.on_create.read();
if !query.is_empty() {
if *self.enforce_allow_list.read() {
} else if let Some(cb) = callback {
if let Some(tag) = cb.call(query) {
self.create_tag(tag);
}
} else {
let commit_cb = *self.on_commit.read();
if let Some(handler) = commit_cb {
handler.call(query);
}
}
}
}
Key::Backspace => {
if self.search_query.read().is_empty() {
let tags = self.selected_tags.read();
if let Some(pos) = tags.iter().rposition(|t| !t.is_locked()) {
drop(tags);
self.active_pill.set(Some(pos));
}
}
}
Key::Escape => {
self.active_pill.set(None);
}
Key::Character(ref c) => {
let delims = self.delimiters.read().clone();
if let Some(delimiters) = delims
&& let Some(ch) = c.chars().next()
&& delimiters.contains(&ch)
{
event.prevent_default();
if !*self.enforce_allow_list.read() {
let query = self.search_query.read().clone();
let callback = *self.on_create.read();
if !query.is_empty() {
if let Some(cb) = callback {
if let Some(tag) = cb.call(query) {
self.create_tag(tag);
}
} else {
let commit_cb = *self.on_commit.read();
if let Some(handler) = commit_cb {
handler.call(query);
}
}
}
}
}
}
_ => {}
}
}
}
#[allow(clippy::type_complexity)]
pub fn use_tag_input<T: TagLike>(
available_tags: Vec<T>,
initial_selected: Vec<T>,
) -> TagInputState<T> {
use_tag_input_with(TagInputConfig::new(available_tags, initial_selected))
}
#[allow(clippy::type_complexity)]
pub fn use_tag_input_with<T: TagLike>(config: TagInputConfig<T>) -> TagInputState<T> {
let instance_id = use_hook(|| INSTANCE_COUNTER.fetch_add(1, AtomicOrdering::Relaxed));
let internal_query = use_signal(String::new);
let internal_selected = use_signal(|| config.initial_selected);
let internal_available = use_signal(|| config.available_tags);
let search_query = config.query.unwrap_or(internal_query);
let selected_tags = config.value.unwrap_or(internal_selected);
let available_tags = internal_available;
let deny_list: Signal<Option<Vec<String>>> = use_signal(|| None);
let filter: Signal<Option<fn(&T, &str) -> bool>> = use_signal(|| None);
let active_pill = use_signal(|| None);
let popover_pill = use_signal(|| None);
let on_create = use_signal(|| None);
let on_remove = use_signal(|| None);
let on_add = use_signal(|| None);
let on_query_change: Signal<Option<EventHandler<String>>> = use_signal(|| None);
let on_commit: Signal<Option<EventHandler<String>>> = use_signal(|| None);
let is_disabled = use_signal(|| false);
let status_message = use_signal(String::new);
let on_paste = use_signal(|| None);
let paste_delimiters = use_signal(|| None);
let editing_pill = use_signal(|| None);
let on_edit = use_signal(|| None);
let on_reorder = use_signal(|| None);
let delimiters = use_signal(|| None);
let max_tags: Signal<Option<usize>> = use_signal(|| None);
let is_at_limit = use_memo(move || match *max_tags.read() {
Some(max) => selected_tags.read().len() >= max,
None => false,
});
let validate = use_signal(|| None);
let validation_error = use_signal(|| None);
let allow_duplicates = use_signal(|| false);
let on_duplicate = use_signal(|| None);
let enforce_allow_list = use_signal(|| false);
let min_tags: Signal<Option<usize>> = use_signal(|| None);
let is_below_minimum = use_memo(move || match *min_tags.read() {
Some(min) => selected_tags.read().len() < min,
None => false,
});
let is_readonly = use_signal(|| false);
let max_tag_length = use_signal(|| None);
let max_visible_tags: Signal<Option<usize>> = use_signal(|| None);
let overflow_count = use_memo(move || match *max_visible_tags.read() {
Some(max) => {
let len = selected_tags.read().len();
len.saturating_sub(max)
}
None => 0,
});
let visible_tags = use_memo(move || {
let tags = selected_tags.read().clone();
match *max_visible_tags.read() {
Some(max) => tags.into_iter().take(max).collect(),
None => tags,
}
});
let sort_selected: Signal<Option<fn(&T, &T) -> Ordering>> = use_signal(|| None);
let form_value = use_memo(move || {
let tags = selected_tags.read();
let ids: Vec<String> = tags.iter().map(|t| format!("\"{}\"", t.id())).collect();
format!("[{}]", ids.join(","))
});
let select_mode = use_signal(|| false);
TagInputState {
search_query,
selected_tags,
available_tags,
active_pill,
popover_pill,
on_create,
on_remove,
on_add,
on_query_change,
on_commit,
is_disabled,
status_message,
on_paste,
paste_delimiters,
editing_pill,
on_edit,
on_reorder,
delimiters,
max_tags,
is_at_limit,
validate,
validation_error,
allow_duplicates,
on_duplicate,
enforce_allow_list,
deny_list,
min_tags,
is_below_minimum,
is_readonly,
max_tag_length,
filter,
max_visible_tags,
overflow_count,
visible_tags,
sort_selected,
form_value,
select_mode,
instance_id,
}
}
#[allow(clippy::type_complexity)]
pub fn use_tag_input_grouped<T: TagLike>(config: TagInputGroupConfig<T>) -> TagInputState<T> {
let instance_id = use_hook(|| INSTANCE_COUNTER.fetch_add(1, AtomicOrdering::Relaxed));
let internal_query = use_signal(String::new);
let internal_selected = use_signal(|| config.initial_selected);
let internal_available = use_signal(|| config.available_tags);
let search_query = config.query.unwrap_or(internal_query);
let selected_tags = config.value.unwrap_or(internal_selected);
let available_tags = internal_available;
let deny_list: Signal<Option<Vec<String>>> = use_signal(|| None);
let active_pill = use_signal(|| None);
let popover_pill = use_signal(|| None);
let on_create = use_signal(|| None);
let on_remove = use_signal(|| None);
let on_add = use_signal(|| None);
let on_query_change: Signal<Option<EventHandler<String>>> = use_signal(|| None);
let on_commit: Signal<Option<EventHandler<String>>> = use_signal(|| None);
let is_disabled = use_signal(|| false);
let status_message = use_signal(String::new);
let on_paste = use_signal(|| None);
let paste_delimiters = use_signal(|| None);
let editing_pill = use_signal(|| None);
let on_edit = use_signal(|| None);
let on_reorder = use_signal(|| None);
let delimiters = use_signal(|| None);
let max_tags: Signal<Option<usize>> = use_signal(|| None);
let is_at_limit = use_memo(move || match *max_tags.read() {
Some(max) => selected_tags.read().len() >= max,
None => false,
});
let validate = use_signal(|| None);
let validation_error = use_signal(|| None);
let allow_duplicates = use_signal(|| false);
let on_duplicate = use_signal(|| None);
let enforce_allow_list = use_signal(|| false);
let min_tags: Signal<Option<usize>> = use_signal(|| None);
let is_below_minimum = use_memo(move || match *min_tags.read() {
Some(min) => selected_tags.read().len() < min,
None => false,
});
let is_readonly = use_signal(|| false);
let max_tag_length = use_signal(|| None);
let filter: Signal<Option<fn(&T, &str) -> bool>> = use_signal(|| None);
let max_visible_tags: Signal<Option<usize>> = use_signal(|| None);
let overflow_count = use_memo(move || match *max_visible_tags.read() {
Some(max) => {
let len = selected_tags.read().len();
len.saturating_sub(max)
}
None => 0,
});
let visible_tags = use_memo(move || {
let tags = selected_tags.read().clone();
match *max_visible_tags.read() {
Some(max) => tags.into_iter().take(max).collect(),
None => tags,
}
});
let sort_selected: Signal<Option<fn(&T, &T) -> Ordering>> = use_signal(|| None);
let form_value = use_memo(move || {
let tags = selected_tags.read();
let ids: Vec<String> = tags.iter().map(|t| format!("\"{}\"", t.id())).collect();
format!("[{}]", ids.join(","))
});
let select_mode = use_signal(|| false);
TagInputState {
search_query,
selected_tags,
available_tags,
active_pill,
popover_pill,
on_create,
on_remove,
on_add,
on_query_change,
on_commit,
is_disabled,
status_message,
on_paste,
paste_delimiters,
editing_pill,
on_edit,
on_reorder,
delimiters,
max_tags,
is_at_limit,
validate,
validation_error,
allow_duplicates,
on_duplicate,
enforce_allow_list,
deny_list,
min_tags,
is_below_minimum,
is_readonly,
max_tag_length,
filter,
max_visible_tags,
overflow_count,
visible_tags,
sort_selected,
form_value,
select_mode,
instance_id,
}
}
pub(crate) fn format_status_added(name: &str, total: usize) -> String {
format!(
"{name} added. {total} tag{} selected.",
if total == 1 { "" } else { "s" }
)
}
pub(crate) fn format_status_removed(name: &str, total: usize) -> String {
format!(
"{name} removed. {total} tag{} selected.",
if total == 1 { "" } else { "s" }
)
}
pub(crate) fn format_status_pasted(added: usize, total: usize) -> String {
format!(
"{added} tag{} pasted. {total} tag{} selected.",
if added == 1 { "" } else { "s" },
if total == 1 { "" } else { "s" }
)
}
#[cfg(test)]
pub(crate) fn format_status_suggestions(count: usize) -> String {
format!(
"{count} suggestion{} available.",
if count == 1 { "" } else { "s" }
)
}
pub(crate) fn split_by_delimiters(text: &str, delimiters: &[char]) -> Vec<String> {
text.split(|c: char| delimiters.contains(&c))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub(crate) fn format_status_duplicate(name: &str) -> String {
format!("{name} already exists.")
}
pub(crate) fn format_status_denied(name: &str) -> String {
format!("{name} is not allowed.")
}
pub(crate) fn format_error_max_length(max_len: usize) -> String {
format!("Tag must be {max_len} characters or fewer.")
}
pub fn is_denied(name: &str, deny_list: &[String]) -> bool {
let name_lower = name.to_lowercase();
deny_list.iter().any(|b| b.to_lowercase() == name_lower)
}
#[cfg(test)]
pub(crate) fn is_in_allow_list<T: TagLike>(id: &str, available: &[T]) -> bool {
available.iter().any(|t| t.id() == id)
}
#[cfg(test)]
pub(crate) fn filter_denied<T: TagLike>(items: &[T], deny_list: &[String]) -> Vec<T> {
items
.iter()
.filter(|tag| !is_denied(tag.name(), deny_list))
.cloned()
.collect()
}
#[cfg(test)]
pub(crate) fn compute_auto_complete_text(query: &str, suggestion_name: &str) -> String {
if query.is_empty() {
return String::new();
}
if suggestion_name
.to_lowercase()
.starts_with(&query.to_lowercase())
{
suggestion_name[query.len()..].to_string()
} else {
String::new()
}
}
#[cfg(test)]
pub(crate) fn format_form_value(ids: &[&str]) -> String {
let quoted: Vec<String> = ids.iter().map(|id| format!("\"{}\"", id)).collect();
format!("[{}]", quoted.join(","))
}
#[cfg(test)]
pub(crate) fn compute_overflow(total: usize, max_visible: Option<usize>) -> usize {
match max_visible {
Some(max) => total.saturating_sub(max),
None => 0,
}
}
#[cfg(test)]
pub(crate) fn is_below_min(count: usize, min_tags: Option<usize>) -> bool {
match min_tags {
Some(min) => count < min,
None => false,
}
}
#[cfg(test)]
pub(crate) fn format_status_truncated(shown: usize, total: usize) -> String {
format!("Showing {shown} of {total} suggestions. Type to refine.")
}
#[cfg(target_arch = "wasm32")]
pub fn extract_clipboard_text(
event: &dioxus::prelude::Event<dioxus::prelude::ClipboardData>,
) -> Option<String> {
use wasm_bindgen::JsCast;
let clip: &dioxus::prelude::ClipboardData = &event.data();
let web_event: web_sys::Event = clip.downcast::<web_sys::Event>()?.clone();
let clipboard_event: web_sys::ClipboardEvent = web_event.dyn_into().ok()?;
let data_transfer = clipboard_event.clipboard_data()?;
data_transfer.get_data("text/plain").ok()
}
#[cfg(not(target_arch = "wasm32"))]
pub fn extract_clipboard_text(
_event: &dioxus::prelude::Event<dioxus::prelude::ClipboardData>,
) -> Option<String> {
None
}
#[cfg(test)]
pub(crate) fn build_groups<T: TagLike>(
items: &[T],
sort_items: Option<fn(&T, &T) -> Ordering>,
sort_groups: Option<fn(&str, &str) -> Ordering>,
max_items_per_group: Option<usize>,
) -> Vec<SuggestionGroup<T>> {
let mut group_order: Vec<String> = Vec::new();
let mut group_map: Vec<(String, Vec<T>)> = Vec::new();
for item in items {
let label = item.group().unwrap_or("").to_string();
if let Some(pos) = group_order.iter().position(|l| l == &label) {
group_map[pos].1.push(item.clone());
} else {
group_order.push(label.clone());
group_map.push((label, vec![item.clone()]));
}
}
if let Some(cmp) = sort_groups {
group_map.sort_by(|(a, _), (b, _)| cmp(a, b));
}
group_map
.into_iter()
.map(|(label, mut items)| {
if let Some(cmp) = sort_items {
items.sort_by(cmp);
}
let total_count = items.len();
if let Some(max) = max_items_per_group {
items.truncate(max);
}
SuggestionGroup {
label,
items,
total_count,
}
})
.collect()
}