Skip to main content

dioxus_nox_tag_input/
hook.rs

1use std::cmp::Ordering;
2use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
3
4use dioxus::prelude::*;
5
6use crate::tag::TagLike;
7
8static INSTANCE_COUNTER: AtomicU32 = AtomicU32::new(0);
9
10/// A group of suggestions sharing a label.
11#[derive(Clone, PartialEq, Debug)]
12pub struct SuggestionGroup<T: TagLike> {
13    /// Group name. Empty string for ungrouped items (tags where `group()` returns `None`).
14    pub label: String,
15    /// Items visible in this group (may be truncated by `max_items_per_group`).
16    pub items: Vec<T>,
17    /// Total matching items before `max_items_per_group` truncation.
18    pub total_count: usize,
19}
20
21/// Configuration for the grouped tag input hook.
22///
23/// Uses `fn` pointers (not closures) because they are `Copy` and trivially
24/// captured by `use_memo`.
25pub struct TagInputGroupConfig<T: TagLike> {
26    pub available_tags: Vec<T>,
27    pub initial_selected: Vec<T>,
28    /// Custom filter: receives `(tag, lowercase_query)`. Default: substring match on `name()`.
29    pub filter: Option<fn(&T, &str) -> bool>,
30    /// Sort items within each group. Default: no sort (insertion order).
31    pub sort_items: Option<fn(&T, &T) -> Ordering>,
32    /// Sort group headers. Default: no sort (first-seen order).
33    pub sort_groups: Option<fn(&str, &str) -> Ordering>,
34    /// Max items shown per group. `None` = unlimited. `total_count` still reflects all matches.
35    pub max_items_per_group: Option<usize>,
36    /// Parent-owned signal for selected tags (controlled mode). `initial_selected` ignored when set.
37    pub value: Option<Signal<Vec<T>>>,
38    /// Parent-owned signal for search query (controlled mode).
39    pub query: Option<Signal<String>>,
40}
41
42/// Configuration for the simple tag input hook.
43pub struct TagInputConfig<T: TagLike> {
44    pub available_tags: Vec<T>,
45    pub initial_selected: Vec<T>,
46    /// Parent-owned signal for selected tags (controlled mode). `initial_selected` ignored when set.
47    pub value: Option<Signal<Vec<T>>>,
48    /// Parent-owned signal for search query (controlled mode).
49    pub query: Option<Signal<String>>,
50}
51
52impl<T: TagLike> TagInputConfig<T> {
53    pub fn new(available_tags: Vec<T>, initial_selected: Vec<T>) -> Self {
54        Self {
55            available_tags,
56            initial_selected,
57            value: None,
58            query: None,
59        }
60    }
61}
62
63/// Find byte-offset ranges in `text` that match `query` (case-insensitive substring).
64///
65/// Returns `Vec<(start, end)>` pairs suitable for slicing `text` and wrapping
66/// matched portions in highlight markup.
67pub fn find_match_ranges(text: &str, query: &str) -> Vec<(usize, usize)> {
68    if query.is_empty() {
69        return Vec::new();
70    }
71    let text_lower = text.to_lowercase();
72    let query_lower = query.to_lowercase();
73    let mut ranges = Vec::new();
74    let mut start = 0;
75    while let Some(pos) = text_lower[start..].find(&query_lower) {
76        let abs_start = start + pos;
77        let abs_end = abs_start + query.len();
78        ranges.push((abs_start, abs_end));
79        start = abs_end;
80    }
81    ranges
82}
83
84/// Headless state for the tag input component.
85///
86/// All fields are `Signal` or `Memo`, which are `Copy` in Dioxus 0.7,
87/// so `TagInputState` manually implements `Clone`, `Copy`, and `PartialEq`
88/// without requiring `T: Copy` or `T: PartialEq`.
89#[allow(clippy::type_complexity)]
90pub struct TagInputState<T: TagLike> {
91    /// The current search/filter query text.
92    pub search_query: Signal<String>,
93    /// The tags currently selected by the user.
94    pub selected_tags: Signal<Vec<T>>,
95    /// All tags available for selection.
96    pub available_tags: Signal<Vec<T>>,
97    /// Index of the keyboard-selected pill, or `None` when the cursor is in the text input.
98    pub active_pill: Signal<Option<usize>>,
99    /// Index of the pill whose popover is open, or `None` when no popover is shown.
100    pub popover_pill: Signal<Option<usize>>,
101    /// Optional callback for creating new tags on Enter when no suggestion is highlighted.
102    ///
103    /// When `Some`, pressing Enter with a non-empty query and no highlighted suggestion
104    /// will call this callback with the query text. Return `Some(tag)` to accept the
105    /// new tag, or `None` to reject it.
106    ///
107    /// Default: `None` (feature off — Enter just opens the dropdown).
108    pub on_create: Signal<Option<Callback<String, Option<T>>>>,
109    /// Optional callback fired when a tag is about to be removed.
110    ///
111    /// Called with the tag being removed, before it is actually removed from `selected_tags`.
112    /// Fires from `remove_tag()` and `remove_last_tag()`.
113    ///
114    /// Default: `None`
115    pub on_remove: Signal<Option<Callback<T>>>,
116    /// Optional callback fired when a tag is added.
117    ///
118    /// Called with the newly added tag after it has been pushed to `selected_tags`.
119    /// Fires from `add_tag()` (and by extension `create_tag()`).
120    ///
121    /// Default: `None`
122    pub on_add: Signal<Option<Callback<T>>>,
123    /// Optional callback fired when the input text (search query) changes.
124    ///
125    /// Consumers can use this for external filtering, async fetching, or
126    /// debounced search. Replaces the old `on_search` callback.
127    ///
128    /// Default: `None`
129    pub on_query_change: Signal<Option<EventHandler<String>>>,
130    /// Optional callback fired when the user presses Enter/delimiter with text
131    /// and no `on_create` handler is set.
132    ///
133    /// This allows consumers to handle the "commit" action externally (e.g.,
134    /// to open a dropdown, trigger a search, or perform a custom action).
135    ///
136    /// Default: `None`
137    pub on_commit: Signal<Option<EventHandler<String>>>,
138    /// Whether the tag input is disabled (no interaction allowed).
139    ///
140    /// When `true`, `handle_keydown`, `set_query`, `add_tag`, `remove_tag`, and
141    /// `handle_click` become no-ops. Consumers should also apply `disabled` /
142    /// `aria-disabled="true"` attributes and visual styling based on this signal.
143    pub is_disabled: Signal<bool>,
144    /// Screen-reader status message for `aria-live` announcements.
145    ///
146    /// Updated automatically when tags are added/removed and when the suggestion
147    /// count changes. Consumers render this inside a `<div role="status" aria-live="polite">`
148    /// element to provide announcements to assistive technology.
149    ///
150    /// Example messages:
151    /// - `"Apple added. 3 tags selected."`
152    /// - `"Cherry removed. 2 tags selected."`
153    /// - `"5 suggestions available."`
154    /// - `"No suggestions found."`
155    /// - `"Maximum of 5 tags reached."`
156    pub status_message: Signal<String>,
157    /// Optional callback fired when text is pasted into the input.
158    ///
159    /// Called with the raw clipboard text. Return a `Vec<T>` of tags to add.
160    /// If the callback returns an empty vec, no tags are added.
161    ///
162    /// Takes priority over `paste_delimiters` when set.
163    ///
164    /// Default: `None`
165    pub on_paste: Signal<Option<Callback<String, Vec<T>>>>,
166    /// Delimiter characters for splitting pasted text into tags.
167    ///
168    /// When set and `on_paste` is `None`, pasted text is split by these delimiters.
169    /// Each non-empty token is passed to `on_create` (if set) to create a tag.
170    /// Common delimiters: `[',', '\n', '\t']`.
171    ///
172    /// Default: `None` (paste behaves normally — text enters the input field)
173    pub paste_delimiters: Signal<Option<Vec<char>>>,
174    // ── Phase 2: Editing & Reorder ──────────────────────────────────────
175    /// Index of the pill currently being edited inline, or `None`.
176    ///
177    /// When `Some(idx)`, the consumer should render an `<input>` instead of a
178    /// `<span>` for that pill. Use `start_editing(idx)` to enter edit mode,
179    /// `commit_edit(new_name)` to apply changes, and `cancel_edit()` to discard.
180    pub editing_pill: Signal<Option<usize>>,
181    /// Optional callback for applying an inline edit to a tag.
182    ///
183    /// Called with `(current_tag, new_name_string)`. Return `Some(updated_tag)` to
184    /// accept the edit, or `None` to reject it. The consumer is responsible for
185    /// constructing the updated tag (the library doesn't know your tag's internals).
186    ///
187    /// Default: `None` (editing disabled)
188    pub on_edit: Signal<Option<Callback<(T, String), Option<T>>>>,
189    /// Optional callback fired after a tag is reordered via `move_tag`.
190    ///
191    /// Called with `(from_index, to_index)` after the move completes.
192    ///
193    /// Default: `None`
194    pub on_reorder: Signal<Option<Callback<(usize, usize)>>>,
195    // ── Phase 3: Validation & Limits ────────────────────────────────────
196    /// Delimiter characters that commit the current query as a tag.
197    ///
198    /// When set, typing any of these characters commits the current query:
199    /// if a suggestion is highlighted, it's selected; otherwise `on_create`
200    /// is called (if set). Common delimiters: `[',', ';', '\t']`.
201    /// `Enter` is always a commit key and doesn't need to be in this list.
202    ///
203    /// Default: `None` (only Enter commits)
204    pub delimiters: Signal<Option<Vec<char>>>,
205    /// Maximum number of tags that can be selected.
206    ///
207    /// When set and the limit is reached, `add_tag` becomes a no-op and
208    /// `status_message` announces "Maximum of N tags reached."
209    ///
210    /// Default: `None` (unlimited)
211    pub max_tags: Signal<Option<usize>>,
212    /// Whether the maximum tag limit has been reached.
213    ///
214    /// Reactive memo derived from `max_tags` and `selected_tags.len()`.
215    /// Consumers can use this to disable the input or hide suggestions.
216    pub is_at_limit: Memo<bool>,
217    /// Optional validation callback called before a tag is committed.
218    ///
219    /// Called with the tag about to be added. Return `Ok(())` to accept,
220    /// or `Err("message")` to reject. The rejection message is stored in
221    /// `validation_error` for the consumer to render.
222    ///
223    /// Default: `None` (no validation — all tags accepted)
224    pub validate: Signal<Option<Callback<T, Result<(), String>>>>,
225    /// The most recent validation error message, or `None` if valid.
226    ///
227    /// Set by `add_tag` when `validate` returns `Err(msg)`. Cleared on the
228    /// next successful `add_tag` or when `set_query` is called.
229    pub validation_error: Signal<Option<String>>,
230    // ── Phase 4: Production Guards ─────────────────────────────────────
231    /// Whether duplicate tags are allowed.
232    ///
233    /// When `false` (default), `add_tag` rejects tags whose ID already exists
234    /// in `selected_tags`. When `true`, the duplicate check is skipped entirely.
235    ///
236    /// Default: `false`
237    pub allow_duplicates: Signal<bool>,
238    /// Optional callback fired when a duplicate tag is rejected.
239    ///
240    /// Only fires when `allow_duplicates` is `false` and a duplicate is attempted.
241    ///
242    /// Default: `None`
243    pub on_duplicate: Signal<Option<Callback<T>>>,
244    /// Whether to restrict tag selection to only items in `available_tags`.
245    ///
246    /// When `true`, `on_create` is blocked and only tags present in `available_tags`
247    /// can be added. Pasted tags not in the allow list are also rejected.
248    ///
249    /// Default: `false`
250    pub enforce_allow_list: Signal<bool>,
251    /// List of forbidden tag names (case-insensitive).
252    ///
253    /// Tags whose `name()` matches any entry (case-insensitive) are rejected by
254    /// `add_tag` and filtered out of suggestions.
255    ///
256    /// Default: `None` (no deny list)
257    pub deny_list: Signal<Option<Vec<String>>>,
258    /// Minimum number of required tags (informational for form validation).
259    ///
260    /// Does NOT prevent removal — `is_below_minimum` is a reactive memo that
261    /// consumers can use to show validation warnings or disable form submission.
262    ///
263    /// Default: `None` (no minimum)
264    pub min_tags: Signal<Option<usize>>,
265    /// Whether the selected tag count is below `min_tags`.
266    ///
267    /// Reactive memo: `true` when `min_tags` is `Some(n)` and `selected_tags.len() < n`.
268    pub is_below_minimum: Memo<bool>,
269    /// Whether the tag input is in read-only mode.
270    ///
271    /// When `true`, tags are displayed but cannot be added, removed, or edited.
272    /// Pill navigation (ArrowLeft/Right) and Escape still work.
273    ///
274    /// Default: `false`
275    pub is_readonly: Signal<bool>,
276    // ── Phase 6: UX Polish ─────────────────────────────────────────────
277    /// Maximum character length for tag names.
278    ///
279    /// When set, `add_tag` and `create_tag` reject tags whose `name().len()`
280    /// exceeds this limit, setting `validation_error`.
281    ///
282    /// Default: `None` (unlimited)
283    pub max_tag_length: Signal<Option<usize>>,
284    /// Custom filter function for `use_tag_input()`.
285    ///
286    /// When `Some`, used instead of the default case-insensitive substring match.
287    /// Receives `(tag, lowercase_query)`. Provides parity with the grouped hook's
288    /// `TagInputGroupConfig::filter`.
289    ///
290    /// Default: `None`
291    pub filter: Signal<Option<fn(&T, &str) -> bool>>,
292    /// Maximum number of tag pills to display before collapsing.
293    ///
294    /// When set, consumers should render only `visible_tags` and show an
295    /// "+N more" badge using `overflow_count`.
296    ///
297    /// Default: `None` (show all)
298    pub max_visible_tags: Signal<Option<usize>>,
299    /// Count of tags hidden by `max_visible_tags`.
300    ///
301    /// `selected_tags.len() - max_visible_tags` when limit is active, else `0`.
302    pub overflow_count: Memo<usize>,
303    /// The truncated slice of selected tags for rendering.
304    ///
305    /// When `max_visible_tags` is set, contains only the first N tags.
306    /// Otherwise contains all selected tags.
307    pub visible_tags: Memo<Vec<T>>,
308    /// Optional sort function applied to `selected_tags` after add/remove.
309    ///
310    /// When `Some`, `selected_tags` is automatically sorted in place after
311    /// each mutation.
312    ///
313    /// Default: `None` (insertion order preserved)
314    pub sort_selected: Signal<Option<fn(&T, &T) -> Ordering>>,
315    // ── Phase 7: Form Helpers ────────────────────────────────────────────
316    /// JSON-serialized selected tag IDs for hidden form inputs.
317    ///
318    /// Format: `["id1","id2","id3"]`. Empty array `[]` when no tags selected.
319    pub form_value: Memo<String>,
320    /// Whether to operate in single-value select mode.
321    ///
322    /// When `true` and `max_tags` is `Some(1)`, adding a new tag replaces the
323    /// existing one instead of rejecting.
324    ///
325    /// Default: `false`
326    pub select_mode: Signal<bool>,
327    /// Unique instance ID for scoping DOM element IDs when multiple tag inputs coexist.
328    instance_id: u32,
329}
330
331impl<T: TagLike> Clone for TagInputState<T> {
332    fn clone(&self) -> Self {
333        *self
334    }
335}
336
337impl<T: TagLike> Copy for TagInputState<T> {}
338
339impl<T: TagLike> PartialEq for TagInputState<T> {
340    fn eq(&self, other: &Self) -> bool {
341        self.search_query == other.search_query
342            && self.selected_tags == other.selected_tags
343            && self.available_tags == other.available_tags
344            && self.active_pill == other.active_pill
345            && self.popover_pill == other.popover_pill
346            && self.on_create == other.on_create
347            && self.on_remove == other.on_remove
348            && self.on_add == other.on_add
349            && self.on_query_change == other.on_query_change
350            && self.on_commit == other.on_commit
351            && self.is_disabled == other.is_disabled
352            && self.status_message == other.status_message
353            && self.on_paste == other.on_paste
354            && self.paste_delimiters == other.paste_delimiters
355            && self.editing_pill == other.editing_pill
356            && self.on_edit == other.on_edit
357            && self.on_reorder == other.on_reorder
358            && self.delimiters == other.delimiters
359            && self.max_tags == other.max_tags
360            && self.is_at_limit == other.is_at_limit
361            && self.validate == other.validate
362            && self.validation_error == other.validation_error
363            // Phase 4
364            && self.allow_duplicates == other.allow_duplicates
365            && self.on_duplicate == other.on_duplicate
366            && self.enforce_allow_list == other.enforce_allow_list
367            && self.deny_list == other.deny_list
368            && self.min_tags == other.min_tags
369            && self.is_below_minimum == other.is_below_minimum
370            && self.is_readonly == other.is_readonly
371            // Phase 6
372            && self.max_tag_length == other.max_tag_length
373            && self.filter == other.filter
374            && self.max_visible_tags == other.max_visible_tags
375            && self.overflow_count == other.overflow_count
376            && self.visible_tags == other.visible_tags
377            && self.sort_selected == other.sort_selected
378            // Phase 7
379            && self.form_value == other.form_value
380            && self.select_mode == other.select_mode
381            && self.instance_id == other.instance_id
382    }
383}
384
385impl<T: TagLike> TagInputState<T> {
386    /// Update the search query.
387    ///
388    /// Clears any `validation_error` from a previous rejected `add_tag`.
389    /// Fires `on_query_change` callback (if set) so consumers can react to input changes.
390    pub fn set_query(&mut self, query: String) {
391        if *self.is_disabled.read() || *self.is_readonly.read() {
392            return;
393        }
394        self.search_query.set(query.clone());
395        self.active_pill.set(None);
396        self.popover_pill.set(None);
397        self.validation_error.set(None);
398
399        // Fire query change callback
400        let cb = *self.on_query_change.read();
401        if let Some(handler) = cb {
402            handler.call(query);
403        }
404    }
405
406    /// Add a tag to the selected list and clear the search query.
407    ///
408    /// Guards: disabled, readonly, duplicate, allow list, deny list, max_tags limit,
409    /// max_tag_length, validation callback, select_mode replacement.
410    /// Fires `on_add` callback (if set) after the tag is added.
411    /// Updates `status_message` with an announcement like "Apple added. 3 tags selected."
412    pub fn add_tag(&mut self, tag: T) {
413        if *self.is_disabled.read() || *self.is_readonly.read() {
414            return;
415        }
416
417        // Allow list enforcement guard
418        if *self.enforce_allow_list.read() {
419            let in_allow_list = self
420                .available_tags
421                .read()
422                .iter()
423                .any(|t| t.id() == tag.id());
424            if !in_allow_list {
425                self.status_message
426                    .set("Only suggestions can be selected.".to_string());
427                return;
428            }
429        }
430
431        // Deny list guard
432        if let Some(ref bl) = *self.deny_list.read() {
433            let tag_name_lower = tag.name().to_lowercase();
434            if bl.iter().any(|b| b.to_lowercase() == tag_name_lower) {
435                let name = tag.name().to_string();
436                self.status_message.set(format_status_denied(&name));
437                self.validation_error.set(Some(format_status_denied(&name)));
438                return;
439            }
440        }
441
442        // Max tag length guard
443        if let Some(max_len) = *self.max_tag_length.read()
444            && tag.name().len() > max_len
445        {
446            self.validation_error
447                .set(Some(format_error_max_length(max_len)));
448            return;
449        }
450
451        // Select mode: replace existing tag when max_tags=1
452        if *self.select_mode.read()
453            && let Some(1) = *self.max_tags.read()
454            && self.selected_tags.read().len() == 1
455        {
456            let old_id = self.selected_tags.read()[0].id().to_string();
457            self.selected_tags.write().retain(|t| t.id() != old_id);
458        }
459
460        // Max tags guard
461        if let Some(max) = *self.max_tags.read()
462            && self.selected_tags.read().len() >= max
463        {
464            self.status_message
465                .set(format!("Maximum of {max} tags reached."));
466            self.search_query.set(String::new());
467            return;
468        }
469
470        // Duplicate guard
471        let already_selected = self.selected_tags.read().iter().any(|t| t.id() == tag.id());
472        if already_selected && !*self.allow_duplicates.read() {
473            let name = tag.name().to_string();
474            self.status_message.set(format_status_duplicate(&name));
475            if let Some(cb) = *self.on_duplicate.read() {
476                cb.call(tag);
477            }
478            self.search_query.set(String::new());
479            self.active_pill.set(None);
480            self.popover_pill.set(None);
481            return;
482        }
483
484        if !already_selected || *self.allow_duplicates.read() {
485            // Validation guard
486            let validate_cb = *self.validate.read();
487            if let Some(cb) = validate_cb
488                && let Err(msg) = cb.call(tag.clone())
489            {
490                self.validation_error.set(Some(msg));
491                return;
492            }
493            self.validation_error.set(None);
494
495            let name = tag.name().to_string();
496            self.selected_tags.write().push(tag.clone());
497
498            // Auto-sort selected tags if sort function is set
499            if let Some(sort_fn) = *self.sort_selected.read() {
500                self.selected_tags.write().sort_by(sort_fn);
501            }
502
503            let count = self.selected_tags.read().len();
504            self.status_message.set(format_status_added(&name, count));
505
506            if let Some(cb) = *self.on_add.read() {
507                cb.call(tag);
508            }
509        }
510        self.search_query.set(String::new());
511        self.active_pill.set(None);
512        self.popover_pill.set(None);
513    }
514
515    /// Remove a tag from the selected list by its id.
516    ///
517    /// No-op if the tag is locked (`is_locked() == true`), disabled, or readonly.
518    /// Fires `on_remove` callback (if set) before removal.
519    /// Updates `status_message` with an announcement like "Cherry removed. 2 tags selected."
520    pub fn remove_tag(&mut self, id: &str) {
521        if *self.is_disabled.read() || *self.is_readonly.read() {
522            return;
523        }
524        let is_locked = self
525            .selected_tags
526            .read()
527            .iter()
528            .any(|t| t.id() == id && t.is_locked());
529        if is_locked {
530            return;
531        }
532
533        let name = self
534            .selected_tags
535            .read()
536            .iter()
537            .find(|t| t.id() == id)
538            .map(|t| t.name().to_string());
539
540        if let Some(cb) = *self.on_remove.read()
541            && let Some(tag) = self
542                .selected_tags
543                .read()
544                .iter()
545                .find(|t| t.id() == id)
546                .cloned()
547        {
548            cb.call(tag);
549        }
550
551        self.selected_tags.write().retain(|t| t.id() != id);
552        if let Some(name) = name {
553            let count = self.selected_tags.read().len();
554            self.status_message.set(format_status_removed(&name, count));
555        }
556        self.popover_pill.set(None);
557    }
558
559    /// Remove the last *unlocked* selected tag (used for Backspace on empty input).
560    ///
561    /// Walks backwards from the end, skipping locked tags. If all tags are locked, no-op.
562    /// Fires `on_remove` callback (if set) before removal.
563    /// Updates `status_message` with an announcement.
564    pub fn remove_last_tag(&mut self) {
565        let tags = self.selected_tags.read();
566        if let Some(pos) = tags.iter().rposition(|t| !t.is_locked()) {
567            let tag = tags[pos].clone();
568            let name = tag.name().to_string();
569            drop(tags);
570
571            if let Some(cb) = *self.on_remove.read() {
572                cb.call(tag);
573            }
574
575            self.selected_tags.write().remove(pos);
576            let count = self.selected_tags.read().len();
577            self.status_message.set(format_status_removed(&name, count));
578        }
579    }
580
581    /// Handle click/tap on the input area — clears pill selection.
582    ///
583    /// Attach this to `onclick` on the text `<input>` to clear pill mode
584    /// when the user taps back into the text input.
585    pub fn handle_click(&mut self) {
586        if *self.is_disabled.read() || *self.is_readonly.read() {
587            return;
588        }
589        self.active_pill.set(None);
590        self.popover_pill.set(None);
591    }
592
593    /// Toggle the popover for the pill at `index`.
594    ///
595    /// If the popover is already showing for this pill, it closes.
596    pub fn toggle_popover(&mut self, index: usize) {
597        let current = *self.popover_pill.read();
598        if current == Some(index) {
599            self.popover_pill.set(None);
600        } else {
601            self.popover_pill.set(Some(index));
602        }
603    }
604
605    /// Close any open pill popover.
606    pub fn close_popover(&mut self) {
607        self.popover_pill.set(None);
608    }
609
610    /// Return a stable DOM `id` for the suggestion at `index`.
611    ///
612    /// Use this as the `id` attribute on each suggestion element so that
613    /// keyboard navigation can scroll the highlighted item into view.
614    /// The ID is scoped by `instance_id` so multiple tag inputs on the
615    /// same page won't collide.
616    pub fn suggestion_id(&self, index: usize) -> String {
617        format!("dti-{}-s-{}", self.instance_id, index)
618    }
619
620    /// Returns the DOM ID for the suggestion listbox container.
621    ///
622    /// Use this as the `id` on the `<ul>` / `<div role="listbox">` element and as
623    /// the value of `aria-controls` / `aria-owns` on the combobox `<input>`.
624    pub fn listbox_id(&self) -> String {
625        format!("dti-{}-listbox", self.instance_id)
626    }
627
628    /// Returns a stable DOM `id` for the selected pill at `index`.
629    ///
630    /// Use this as the `id` attribute on each pill element for focus management
631    /// and ARIA relationships. The ID is scoped by `instance_id` so multiple
632    /// tag inputs on the same page won't collide.
633    pub fn pill_id(&self, index: usize) -> String {
634        format!("dti-{}-p-{}", self.instance_id, index)
635    }
636
637    /// Create a tag and add it to both selected and available tags.
638    ///
639    /// The tag is appended to `available_tags` so it appears in future suggestions
640    /// if the user removes and re-types it. Then it is added to `selected_tags`
641    /// via `add_tag`.
642    ///
643    /// Used internally by `handle_keydown`; also available for consumers who want
644    /// to trigger creation programmatically.
645    pub fn create_tag(&mut self, tag: T) {
646        self.available_tags.write().push(tag.clone());
647        self.add_tag(tag);
648    }
649
650    /// Handle pasted text by splitting it into tags.
651    ///
652    /// Call this from the consumer's `onpaste` handler after extracting the clipboard
653    /// text. The method processes the text according to these rules (in priority order):
654    ///
655    /// 1. If `on_paste` callback is set: calls it with the raw text. The callback
656    ///    returns `Vec<T>` of tags to add.
657    /// 2. If `paste_delimiters` is set: splits by delimiters, trims whitespace,
658    ///    and passes each non-empty token to `on_create` (if set) to create tags.
659    /// 3. Otherwise: no-op (normal paste into the input).
660    ///
661    /// Updates `status_message` with a summary of how many tags were added.
662    pub fn handle_paste(&mut self, text: String) {
663        if *self.is_disabled.read() || *self.is_readonly.read() {
664            return;
665        }
666        if text.is_empty() {
667            return;
668        }
669
670        // Priority 1: on_paste callback
671        let paste_cb = *self.on_paste.read();
672        if let Some(cb) = paste_cb {
673            let tags = cb.call(text);
674            let added = tags.len();
675            for tag in tags {
676                self.add_tag(tag);
677            }
678            if added > 0 {
679                let count = self.selected_tags.read().len();
680                self.status_message.set(format_status_pasted(added, count));
681            }
682            return;
683        }
684
685        // Priority 2: delimiter splitting + on_create
686        let delimiters = self.paste_delimiters.read().clone();
687        let create_cb = *self.on_create.read();
688        if let Some(delimiters) = delimiters
689            && let Some(cb) = create_cb
690        {
691            let tokens = split_by_delimiters(&text, &delimiters);
692            let mut added = 0;
693            for token in tokens {
694                if let Some(tag) = cb.call(token) {
695                    self.create_tag(tag);
696                    added += 1;
697                }
698            }
699            if added > 0 {
700                let count = self.selected_tags.read().len();
701                self.status_message.set(format_status_pasted(added, count));
702            }
703        }
704
705        // Priority 3: no-op, let normal paste happen
706    }
707
708    /// Update the status message with a custom announcement.
709    ///
710    /// Consumers can call this to announce arbitrary messages to screen readers
711    /// via the `status_message` signal (rendered in an `aria-live` region).
712    pub fn announce(&mut self, message: String) {
713        self.status_message.set(message);
714    }
715
716    // ── Phase 2: Editing methods ────────────────────────────────────────
717
718    /// Enter inline editing mode for the pill at `index`.
719    ///
720    /// Sets `editing_pill` to `Some(index)` and closes popover/dropdown.
721    /// The consumer should render an `<input>` for this pill and call
722    /// `commit_edit` or `cancel_edit` when done.
723    ///
724    /// No-op if `on_edit` callback is not set or if `is_disabled`.
725    pub fn start_editing(&mut self, index: usize) {
726        if *self.is_disabled.read() || *self.is_readonly.read() {
727            return;
728        }
729        if self.on_edit.read().is_none() {
730            return;
731        }
732        if index >= self.selected_tags.read().len() {
733            return;
734        }
735        // Don't allow editing locked tags
736        if self.selected_tags.read()[index].is_locked() {
737            return;
738        }
739        self.editing_pill.set(Some(index));
740        self.popover_pill.set(None);
741        self.active_pill.set(Some(index));
742    }
743
744    /// Commit an inline edit, replacing the tag at the editing index.
745    ///
746    /// Calls `on_edit` with `(current_tag, new_name)`. If the callback returns
747    /// `Some(updated_tag)`, the tag is replaced in `selected_tags`. If it returns
748    /// `None`, the edit is rejected and the original tag remains.
749    ///
750    /// Always exits edit mode afterward.
751    pub fn commit_edit(&mut self, new_name: String) {
752        let idx = match *self.editing_pill.read() {
753            Some(i) => i,
754            None => return,
755        };
756        let edit_cb = *self.on_edit.read();
757        if let Some(cb) = edit_cb {
758            let current = self.selected_tags.read().get(idx).cloned();
759            if let Some(tag) = current
760                && let Some(updated) = cb.call((tag, new_name))
761            {
762                self.selected_tags.write()[idx] = updated;
763            }
764        }
765        self.editing_pill.set(None);
766    }
767
768    /// Cancel inline editing without applying changes.
769    pub fn cancel_edit(&mut self) {
770        self.editing_pill.set(None);
771    }
772
773    // ── Phase 2: Reorder method ─────────────────────────────────────────
774
775    /// Move a tag from one position to another in the selected list.
776    ///
777    /// Performs `Vec::remove(from)` then `Vec::insert(to, tag)`.
778    /// Fires `on_reorder` callback (if set) with `(from, to)` after the move.
779    /// Updates `status_message`.
780    pub fn move_tag(&mut self, from: usize, to: usize) {
781        if *self.is_disabled.read() || *self.is_readonly.read() {
782            return;
783        }
784        let len = self.selected_tags.read().len();
785        if from >= len || to >= len || from == to {
786            return;
787        }
788        let tag = self.selected_tags.write().remove(from);
789        let name = tag.name().to_string();
790        self.selected_tags.write().insert(to, tag);
791        self.status_message
792            .set(format!("{name} moved to position {}.", to + 1));
793
794        if let Some(cb) = *self.on_reorder.read() {
795            cb.call((from, to));
796        }
797    }
798
799    // ── Phase 3: Select / Clear all ─────────────────────────────────────
800
801    /// Remove all unlocked tags from the selection.
802    ///
803    /// Locked tags are preserved. Fires `on_remove` for each removed tag.
804    /// Updates `status_message` with a summary.
805    pub fn clear_all(&mut self) {
806        if *self.is_disabled.read() || *self.is_readonly.read() {
807            return;
808        }
809        let tags = self.selected_tags.read().clone();
810        let to_remove: Vec<T> = tags.into_iter().filter(|t| !t.is_locked()).collect();
811        let removed_count = to_remove.len();
812
813        let remove_cb = *self.on_remove.read();
814        for tag in &to_remove {
815            if let Some(cb) = remove_cb {
816                cb.call(tag.clone());
817            }
818        }
819
820        self.selected_tags.write().retain(|t| t.is_locked());
821        self.active_pill.set(None);
822        self.popover_pill.set(None);
823        self.editing_pill.set(None);
824
825        let locked_count = self.selected_tags.read().len();
826        if locked_count > 0 {
827            self.status_message.set(format!(
828                "All tags cleared. {locked_count} locked tag{} remain{}.",
829                if locked_count == 1 { "" } else { "s" },
830                if locked_count == 1 { "s" } else { "" }
831            ));
832        } else {
833            self.status_message.set(format!(
834                "{removed_count} tag{} cleared.",
835                if removed_count == 1 { "" } else { "s" }
836            ));
837        }
838    }
839
840    /// Add all available (unselected) tags to the selection.
841    ///
842    /// Respects `max_tags` limit — stops adding when the limit is reached.
843    /// Updates `status_message` with the count added.
844    pub fn select_all(&mut self) {
845        if *self.is_disabled.read() || *self.is_readonly.read() {
846            return;
847        }
848        let available = self.available_tags.read().clone();
849        let mut added = 0;
850        for tag in available {
851            if let Some(max) = *self.max_tags.read()
852                && self.selected_tags.read().len() >= max
853            {
854                break;
855            }
856            let already = self.selected_tags.read().iter().any(|t| t.id() == tag.id());
857            if !already {
858                self.selected_tags.write().push(tag.clone());
859                added += 1;
860                if let Some(cb) = *self.on_add.read() {
861                    cb.call(tag);
862                }
863            }
864        }
865        if added > 0 {
866            let count = self.selected_tags.read().len();
867            self.status_message.set(format!(
868                "{added} tag{} added. {count} tag{} selected.",
869                if added == 1 { "" } else { "s" },
870                if count == 1 { "" } else { "s" }
871            ));
872        }
873    }
874
875    /// Handle keyboard events for navigating suggestions and pills.
876    pub fn handle_keydown(&mut self, event: Event<KeyboardData>) {
877        if *self.is_disabled.read() {
878            return;
879        }
880        let pill = *self.active_pill.read();
881        if let Some(i) = pill {
882            self.handle_pill_keydown(event, i);
883        } else {
884            self.handle_input_keydown(event);
885        }
886    }
887
888    /// Handle keyboard events when a pill is keyboard-selected.
889    ///
890    /// Called by `handle_keydown` when `active_pill` is `Some(i)`, or directly
891    /// by compound components that manage their own pill keydown.
892    pub fn handle_pill_keydown(&mut self, event: Event<KeyboardData>, pill_index: usize) {
893        let key = event.key();
894        let readonly = *self.is_readonly.read();
895
896        match key {
897            Key::Enter => {
898                if readonly {
899                    return;
900                }
901                event.prevent_default();
902                self.toggle_popover(pill_index);
903            }
904            Key::ArrowLeft => {
905                event.prevent_default();
906                self.popover_pill.set(None);
907                if pill_index > 0 {
908                    self.active_pill.set(Some(pill_index - 1));
909                }
910            }
911            Key::ArrowRight => {
912                event.prevent_default();
913                self.popover_pill.set(None);
914                let len = self.selected_tags.read().len();
915                if pill_index < len - 1 {
916                    self.active_pill.set(Some(pill_index + 1));
917                } else {
918                    self.active_pill.set(None); // back to input
919                }
920            }
921            Key::Backspace | Key::Delete => {
922                if readonly {
923                    return;
924                }
925                event.prevent_default();
926                if self.popover_pill.read().is_some() {
927                    // First press: close popover only (same layered pattern as Escape)
928                    self.popover_pill.set(None);
929                } else {
930                    // Second press (no popover open): delete the pill if not locked
931                    let is_locked = self
932                        .selected_tags
933                        .read()
934                        .get(pill_index)
935                        .is_some_and(|t| t.is_locked());
936                    if !is_locked {
937                        let id = self.selected_tags.read()[pill_index].id().to_string();
938                        self.remove_tag(&id);
939                        let new_len = self.selected_tags.read().len();
940                        if new_len == 0 {
941                            self.active_pill.set(None);
942                        } else if pill_index >= new_len {
943                            self.active_pill.set(Some(new_len - 1));
944                        }
945                        // else: keep same index (now points to the next pill)
946                    }
947                }
948            }
949            Key::Home => {
950                event.prevent_default();
951                self.popover_pill.set(None);
952                self.active_pill.set(Some(0));
953            }
954            Key::End => {
955                event.prevent_default();
956                self.popover_pill.set(None);
957                let len = self.selected_tags.read().len();
958                if len > 0 {
959                    self.active_pill.set(Some(len - 1));
960                }
961            }
962            Key::Escape => {
963                // Layered escape: popover → pill mode
964                if self.popover_pill.read().is_some() {
965                    self.popover_pill.set(None);
966                } else {
967                    self.active_pill.set(None);
968                }
969            }
970            _ => {
971                if readonly {
972                    return;
973                }
974                // Any typing key exits pill mode so the character goes into the input
975                self.active_pill.set(None);
976                self.popover_pill.set(None);
977            }
978        }
979    }
980
981    /// Handle keyboard events for the text input.
982    ///
983    /// Called by `handle_keydown` when no pill is active, or directly by
984    /// compound components that manage their own input keydown.
985    pub fn handle_input_keydown(&mut self, event: Event<KeyboardData>) {
986        let key = event.key();
987        let readonly = *self.is_readonly.read();
988
989        // ── Readonly mode: only allow pill entry and escape ─────────────
990        if readonly {
991            match key {
992                Key::ArrowLeft => {
993                    if self.search_query.read().is_empty() {
994                        let len = self.selected_tags.read().len();
995                        if len > 0 {
996                            event.prevent_default();
997                            self.active_pill.set(Some(len - 1));
998                        }
999                    }
1000                }
1001                Key::Escape => {
1002                    // Just close pill mode
1003                    self.active_pill.set(None);
1004                }
1005                _ => {}
1006            }
1007            return;
1008        }
1009
1010        // ── Input mode (normal) ─────────────────────────────────────────
1011        match key {
1012            Key::ArrowLeft => {
1013                // Enter pill mode from the right when query is empty
1014                if self.search_query.read().is_empty() {
1015                    let len = self.selected_tags.read().len();
1016                    if len > 0 {
1017                        event.prevent_default();
1018                        self.active_pill.set(Some(len - 1));
1019                    }
1020                }
1021            }
1022            Key::Enter => {
1023                event.prevent_default();
1024                let query = self.search_query.read().clone();
1025                let callback = *self.on_create.read();
1026                if !query.is_empty() {
1027                    // enforce_allow_list blocks on_create
1028                    if *self.enforce_allow_list.read() {
1029                        // Do nothing — only suggestions can be selected
1030                    } else if let Some(cb) = callback {
1031                        if let Some(tag) = cb.call(query) {
1032                            self.create_tag(tag);
1033                        }
1034                    } else {
1035                        // No on_create handler — fire on_commit if set
1036                        let commit_cb = *self.on_commit.read();
1037                        if let Some(handler) = commit_cb {
1038                            handler.call(query);
1039                        }
1040                    }
1041                }
1042            }
1043            Key::Backspace => {
1044                // On empty input, select last *unlocked* pill instead of immediately deleting
1045                if self.search_query.read().is_empty() {
1046                    let tags = self.selected_tags.read();
1047                    if let Some(pos) = tags.iter().rposition(|t| !t.is_locked()) {
1048                        drop(tags);
1049                        self.active_pill.set(Some(pos));
1050                    }
1051                }
1052            }
1053            Key::Escape => {
1054                // Just close pill mode
1055                self.active_pill.set(None);
1056            }
1057            Key::Character(ref c) => {
1058                // Custom delimiter: commit query when a delimiter char is typed
1059                let delims = self.delimiters.read().clone();
1060                if let Some(delimiters) = delims
1061                    && let Some(ch) = c.chars().next()
1062                    && delimiters.contains(&ch)
1063                {
1064                    event.prevent_default();
1065                    // enforce_allow_list blocks on_create via delimiter too
1066                    if !*self.enforce_allow_list.read() {
1067                        let query = self.search_query.read().clone();
1068                        let callback = *self.on_create.read();
1069                        if !query.is_empty() {
1070                            if let Some(cb) = callback {
1071                                if let Some(tag) = cb.call(query) {
1072                                    self.create_tag(tag);
1073                                }
1074                            } else {
1075                                // No on_create handler — fire on_commit if set
1076                                let commit_cb = *self.on_commit.read();
1077                                if let Some(handler) = commit_cb {
1078                                    handler.call(query);
1079                                }
1080                            }
1081                        }
1082                    }
1083                }
1084            }
1085            _ => {}
1086        }
1087    }
1088}
1089
1090/// Create a headless tag input state.
1091///
1092/// `available_tags` is the full set of tags the user can choose from.
1093/// `initial_selected` is the set of tags already selected on mount.
1094///
1095/// Returns a `TagInputState<T>` with reactive signals and a memo for filtered suggestions.
1096#[allow(clippy::type_complexity)]
1097pub fn use_tag_input<T: TagLike>(
1098    available_tags: Vec<T>,
1099    initial_selected: Vec<T>,
1100) -> TagInputState<T> {
1101    use_tag_input_with(TagInputConfig::new(available_tags, initial_selected))
1102}
1103
1104/// Create a headless tag input state with optional controlled signals.
1105///
1106/// This is the configurable version of `use_tag_input`. When `value` or `query`
1107/// signals are provided in the config, the hook uses those directly instead of creating
1108/// internal ones. All mutations (`add_tag`, `remove_tag`, etc.) write to the provided
1109/// signal automatically — no callbacks needed.
1110#[allow(clippy::type_complexity)]
1111pub fn use_tag_input_with<T: TagLike>(config: TagInputConfig<T>) -> TagInputState<T> {
1112    let instance_id = use_hook(|| INSTANCE_COUNTER.fetch_add(1, AtomicOrdering::Relaxed));
1113
1114    // Always create internal signals unconditionally (hook ordering rules).
1115    // Use parent signal if provided, otherwise internal.
1116    let internal_query = use_signal(String::new);
1117    let internal_selected = use_signal(|| config.initial_selected);
1118    let internal_available = use_signal(|| config.available_tags);
1119
1120    let search_query = config.query.unwrap_or(internal_query);
1121    let selected_tags = config.value.unwrap_or(internal_selected);
1122    let available_tags = internal_available;
1123
1124    // Phase 4
1125    let deny_list: Signal<Option<Vec<String>>> = use_signal(|| None);
1126    // Phase 6
1127    let filter: Signal<Option<fn(&T, &str) -> bool>> = use_signal(|| None);
1128
1129    let active_pill = use_signal(|| None);
1130    let popover_pill = use_signal(|| None);
1131    let on_create = use_signal(|| None);
1132    let on_remove = use_signal(|| None);
1133    let on_add = use_signal(|| None);
1134    let on_query_change: Signal<Option<EventHandler<String>>> = use_signal(|| None);
1135    let on_commit: Signal<Option<EventHandler<String>>> = use_signal(|| None);
1136    let is_disabled = use_signal(|| false);
1137    let status_message = use_signal(String::new);
1138    let on_paste = use_signal(|| None);
1139    let paste_delimiters = use_signal(|| None);
1140    // Phase 2
1141    let editing_pill = use_signal(|| None);
1142    let on_edit = use_signal(|| None);
1143    let on_reorder = use_signal(|| None);
1144    // Phase 3
1145    let delimiters = use_signal(|| None);
1146    let max_tags: Signal<Option<usize>> = use_signal(|| None);
1147    let is_at_limit = use_memo(move || match *max_tags.read() {
1148        Some(max) => selected_tags.read().len() >= max,
1149        None => false,
1150    });
1151    let validate = use_signal(|| None);
1152    let validation_error = use_signal(|| None);
1153    // Phase 4
1154    let allow_duplicates = use_signal(|| false);
1155    let on_duplicate = use_signal(|| None);
1156    let enforce_allow_list = use_signal(|| false);
1157    let min_tags: Signal<Option<usize>> = use_signal(|| None);
1158    let is_below_minimum = use_memo(move || match *min_tags.read() {
1159        Some(min) => selected_tags.read().len() < min,
1160        None => false,
1161    });
1162    let is_readonly = use_signal(|| false);
1163    // Phase 6
1164    let max_tag_length = use_signal(|| None);
1165    let max_visible_tags: Signal<Option<usize>> = use_signal(|| None);
1166    let overflow_count = use_memo(move || match *max_visible_tags.read() {
1167        Some(max) => {
1168            let len = selected_tags.read().len();
1169            len.saturating_sub(max)
1170        }
1171        None => 0,
1172    });
1173    let visible_tags = use_memo(move || {
1174        let tags = selected_tags.read().clone();
1175        match *max_visible_tags.read() {
1176            Some(max) => tags.into_iter().take(max).collect(),
1177            None => tags,
1178        }
1179    });
1180    let sort_selected: Signal<Option<fn(&T, &T) -> Ordering>> = use_signal(|| None);
1181    // Phase 7
1182    let form_value = use_memo(move || {
1183        let tags = selected_tags.read();
1184        let ids: Vec<String> = tags.iter().map(|t| format!("\"{}\"", t.id())).collect();
1185        format!("[{}]", ids.join(","))
1186    });
1187    let select_mode = use_signal(|| false);
1188
1189    TagInputState {
1190        search_query,
1191        selected_tags,
1192        available_tags,
1193        active_pill,
1194        popover_pill,
1195        on_create,
1196        on_remove,
1197        on_add,
1198        on_query_change,
1199        on_commit,
1200        is_disabled,
1201        status_message,
1202        on_paste,
1203        paste_delimiters,
1204        editing_pill,
1205        on_edit,
1206        on_reorder,
1207        delimiters,
1208        max_tags,
1209        is_at_limit,
1210        validate,
1211        validation_error,
1212        // Phase 4
1213        allow_duplicates,
1214        on_duplicate,
1215        enforce_allow_list,
1216        deny_list,
1217        min_tags,
1218        is_below_minimum,
1219        is_readonly,
1220        // Phase 6
1221        max_tag_length,
1222        filter,
1223        max_visible_tags,
1224        overflow_count,
1225        visible_tags,
1226        sort_selected,
1227        // Phase 7
1228        form_value,
1229        select_mode,
1230        instance_id,
1231    }
1232}
1233
1234/// Create a headless tag input state with grouped configuration.
1235///
1236/// This is the full-featured version of `use_tag_input`. It accepts custom filter/sort
1237/// functions via `TagInputGroupConfig`. Note: grouped suggestions are no longer built
1238/// internally — consumers can use the `build_groups` helper to organize tags externally.
1239#[allow(clippy::type_complexity)]
1240pub fn use_tag_input_grouped<T: TagLike>(config: TagInputGroupConfig<T>) -> TagInputState<T> {
1241    let instance_id = use_hook(|| INSTANCE_COUNTER.fetch_add(1, AtomicOrdering::Relaxed));
1242
1243    // Always create internal signals unconditionally (hook ordering rules).
1244    // Use parent signal if provided, otherwise internal.
1245    let internal_query = use_signal(String::new);
1246    let internal_selected = use_signal(|| config.initial_selected);
1247    let internal_available = use_signal(|| config.available_tags);
1248
1249    let search_query = config.query.unwrap_or(internal_query);
1250    let selected_tags = config.value.unwrap_or(internal_selected);
1251    let available_tags = internal_available;
1252
1253    // Phase 4
1254    let deny_list: Signal<Option<Vec<String>>> = use_signal(|| None);
1255
1256    let active_pill = use_signal(|| None);
1257    let popover_pill = use_signal(|| None);
1258    let on_create = use_signal(|| None);
1259    let on_remove = use_signal(|| None);
1260    let on_add = use_signal(|| None);
1261    let on_query_change: Signal<Option<EventHandler<String>>> = use_signal(|| None);
1262    let on_commit: Signal<Option<EventHandler<String>>> = use_signal(|| None);
1263    let is_disabled = use_signal(|| false);
1264    let status_message = use_signal(String::new);
1265    let on_paste = use_signal(|| None);
1266    let paste_delimiters = use_signal(|| None);
1267    // Phase 2
1268    let editing_pill = use_signal(|| None);
1269    let on_edit = use_signal(|| None);
1270    let on_reorder = use_signal(|| None);
1271    // Phase 3
1272    let delimiters = use_signal(|| None);
1273    let max_tags: Signal<Option<usize>> = use_signal(|| None);
1274    let is_at_limit = use_memo(move || match *max_tags.read() {
1275        Some(max) => selected_tags.read().len() >= max,
1276        None => false,
1277    });
1278    let validate = use_signal(|| None);
1279    let validation_error = use_signal(|| None);
1280    // Phase 4
1281    let allow_duplicates = use_signal(|| false);
1282    let on_duplicate = use_signal(|| None);
1283    let enforce_allow_list = use_signal(|| false);
1284    let min_tags: Signal<Option<usize>> = use_signal(|| None);
1285    let is_below_minimum = use_memo(move || match *min_tags.read() {
1286        Some(min) => selected_tags.read().len() < min,
1287        None => false,
1288    });
1289    let is_readonly = use_signal(|| false);
1290    // Phase 6
1291    let max_tag_length = use_signal(|| None);
1292    let filter: Signal<Option<fn(&T, &str) -> bool>> = use_signal(|| None);
1293    let max_visible_tags: Signal<Option<usize>> = use_signal(|| None);
1294    let overflow_count = use_memo(move || match *max_visible_tags.read() {
1295        Some(max) => {
1296            let len = selected_tags.read().len();
1297            len.saturating_sub(max)
1298        }
1299        None => 0,
1300    });
1301    let visible_tags = use_memo(move || {
1302        let tags = selected_tags.read().clone();
1303        match *max_visible_tags.read() {
1304            Some(max) => tags.into_iter().take(max).collect(),
1305            None => tags,
1306        }
1307    });
1308    let sort_selected: Signal<Option<fn(&T, &T) -> Ordering>> = use_signal(|| None);
1309    // Phase 7
1310    let form_value = use_memo(move || {
1311        let tags = selected_tags.read();
1312        let ids: Vec<String> = tags.iter().map(|t| format!("\"{}\"", t.id())).collect();
1313        format!("[{}]", ids.join(","))
1314    });
1315    let select_mode = use_signal(|| false);
1316
1317    TagInputState {
1318        search_query,
1319        selected_tags,
1320        available_tags,
1321        active_pill,
1322        popover_pill,
1323        on_create,
1324        on_remove,
1325        on_add,
1326        on_query_change,
1327        on_commit,
1328        is_disabled,
1329        status_message,
1330        on_paste,
1331        paste_delimiters,
1332        editing_pill,
1333        on_edit,
1334        on_reorder,
1335        delimiters,
1336        max_tags,
1337        is_at_limit,
1338        validate,
1339        validation_error,
1340        // Phase 4
1341        allow_duplicates,
1342        on_duplicate,
1343        enforce_allow_list,
1344        deny_list,
1345        min_tags,
1346        is_below_minimum,
1347        is_readonly,
1348        // Phase 6
1349        max_tag_length,
1350        filter,
1351        max_visible_tags,
1352        overflow_count,
1353        visible_tags,
1354        sort_selected,
1355        // Phase 7
1356        form_value,
1357        select_mode,
1358        instance_id,
1359    }
1360}
1361
1362// ---------------------------------------------------------------------------
1363// Status message formatting helpers (pure functions, testable)
1364// ---------------------------------------------------------------------------
1365
1366pub(crate) fn format_status_added(name: &str, total: usize) -> String {
1367    format!(
1368        "{name} added. {total} tag{} selected.",
1369        if total == 1 { "" } else { "s" }
1370    )
1371}
1372
1373pub(crate) fn format_status_removed(name: &str, total: usize) -> String {
1374    format!(
1375        "{name} removed. {total} tag{} selected.",
1376        if total == 1 { "" } else { "s" }
1377    )
1378}
1379
1380pub(crate) fn format_status_pasted(added: usize, total: usize) -> String {
1381    format!(
1382        "{added} tag{} pasted. {total} tag{} selected.",
1383        if added == 1 { "" } else { "s" },
1384        if total == 1 { "" } else { "s" }
1385    )
1386}
1387
1388#[cfg(test)]
1389pub(crate) fn format_status_suggestions(count: usize) -> String {
1390    format!(
1391        "{count} suggestion{} available.",
1392        if count == 1 { "" } else { "s" }
1393    )
1394}
1395
1396/// Split a string by delimiter characters, trim whitespace, and return non-empty tokens.
1397pub(crate) fn split_by_delimiters(text: &str, delimiters: &[char]) -> Vec<String> {
1398    text.split(|c: char| delimiters.contains(&c))
1399        .map(|s| s.trim().to_string())
1400        .filter(|s| !s.is_empty())
1401        .collect()
1402}
1403
1404// ---------------------------------------------------------------------------
1405// Pure helper functions used in production code
1406// ---------------------------------------------------------------------------
1407
1408/// Format the status message for duplicate rejection.
1409pub(crate) fn format_status_duplicate(name: &str) -> String {
1410    format!("{name} already exists.")
1411}
1412
1413/// Format the status message for deny list rejection.
1414pub(crate) fn format_status_denied(name: &str) -> String {
1415    format!("{name} is not allowed.")
1416}
1417
1418/// Format the validation error for max tag length.
1419pub(crate) fn format_error_max_length(max_len: usize) -> String {
1420    format!("Tag must be {max_len} characters or fewer.")
1421}
1422
1423// ---------------------------------------------------------------------------
1424// Pure helper functions
1425// ---------------------------------------------------------------------------
1426
1427/// Returns `true` if the given name appears in the deny list (case-insensitive).
1428pub fn is_denied(name: &str, deny_list: &[String]) -> bool {
1429    let name_lower = name.to_lowercase();
1430    deny_list.iter().any(|b| b.to_lowercase() == name_lower)
1431}
1432
1433#[cfg(test)]
1434pub(crate) fn is_in_allow_list<T: TagLike>(id: &str, available: &[T]) -> bool {
1435    available.iter().any(|t| t.id() == id)
1436}
1437
1438#[cfg(test)]
1439pub(crate) fn filter_denied<T: TagLike>(items: &[T], deny_list: &[String]) -> Vec<T> {
1440    items
1441        .iter()
1442        .filter(|tag| !is_denied(tag.name(), deny_list))
1443        .cloned()
1444        .collect()
1445}
1446
1447#[cfg(test)]
1448pub(crate) fn compute_auto_complete_text(query: &str, suggestion_name: &str) -> String {
1449    if query.is_empty() {
1450        return String::new();
1451    }
1452    if suggestion_name
1453        .to_lowercase()
1454        .starts_with(&query.to_lowercase())
1455    {
1456        suggestion_name[query.len()..].to_string()
1457    } else {
1458        String::new()
1459    }
1460}
1461
1462#[cfg(test)]
1463pub(crate) fn format_form_value(ids: &[&str]) -> String {
1464    let quoted: Vec<String> = ids.iter().map(|id| format!("\"{}\"", id)).collect();
1465    format!("[{}]", quoted.join(","))
1466}
1467
1468#[cfg(test)]
1469pub(crate) fn compute_overflow(total: usize, max_visible: Option<usize>) -> usize {
1470    match max_visible {
1471        Some(max) => total.saturating_sub(max),
1472        None => 0,
1473    }
1474}
1475
1476#[cfg(test)]
1477pub(crate) fn is_below_min(count: usize, min_tags: Option<usize>) -> bool {
1478    match min_tags {
1479        Some(min) => count < min,
1480        None => false,
1481    }
1482}
1483
1484#[cfg(test)]
1485pub(crate) fn format_status_truncated(shown: usize, total: usize) -> String {
1486    format!("Showing {shown} of {total} suggestions. Type to refine.")
1487}
1488
1489// ---------------------------------------------------------------------------
1490// Internal helpers
1491// ---------------------------------------------------------------------------
1492
1493/// Extract the clipboard text from a paste `Event<ClipboardData>` on WASM targets.
1494///
1495/// Uses `web-sys` to cast the underlying event to a `ClipboardEvent` and read
1496/// `clipboardData.getData("text/plain")`. Returns `None` on non-WASM targets
1497/// or if the clipboard data is unavailable.
1498///
1499/// Typical usage in consumer RSX:
1500/// ```ignore
1501/// onpaste: move |evt: Event<ClipboardData>| {
1502///     if let Some(text) = extract_clipboard_text(&evt) {
1503///         evt.prevent_default();
1504///         state.handle_paste(text);
1505///     }
1506/// }
1507/// ```
1508#[cfg(target_arch = "wasm32")]
1509pub fn extract_clipboard_text(
1510    event: &dioxus::prelude::Event<dioxus::prelude::ClipboardData>,
1511) -> Option<String> {
1512    use wasm_bindgen::JsCast;
1513    let clip: &dioxus::prelude::ClipboardData = &event.data();
1514    let web_event: web_sys::Event = clip.downcast::<web_sys::Event>()?.clone();
1515    let clipboard_event: web_sys::ClipboardEvent = web_event.dyn_into().ok()?;
1516    let data_transfer = clipboard_event.clipboard_data()?;
1517    data_transfer.get_data("text/plain").ok()
1518}
1519
1520#[cfg(not(target_arch = "wasm32"))]
1521pub fn extract_clipboard_text(
1522    _event: &dioxus::prelude::Event<dioxus::prelude::ClipboardData>,
1523) -> Option<String> {
1524    None
1525}
1526
1527/// Build `SuggestionGroup`s from a flat list of tags, preserving first-seen group order.
1528#[cfg(test)]
1529pub(crate) fn build_groups<T: TagLike>(
1530    items: &[T],
1531    sort_items: Option<fn(&T, &T) -> Ordering>,
1532    sort_groups: Option<fn(&str, &str) -> Ordering>,
1533    max_items_per_group: Option<usize>,
1534) -> Vec<SuggestionGroup<T>> {
1535    // Collect items into groups, preserving first-seen order via Vec of (label, items).
1536    let mut group_order: Vec<String> = Vec::new();
1537    let mut group_map: Vec<(String, Vec<T>)> = Vec::new();
1538
1539    for item in items {
1540        let label = item.group().unwrap_or("").to_string();
1541        if let Some(pos) = group_order.iter().position(|l| l == &label) {
1542            group_map[pos].1.push(item.clone());
1543        } else {
1544            group_order.push(label.clone());
1545            group_map.push((label, vec![item.clone()]));
1546        }
1547    }
1548
1549    // Sort groups if requested
1550    if let Some(cmp) = sort_groups {
1551        group_map.sort_by(|(a, _), (b, _)| cmp(a, b));
1552    }
1553
1554    // Sort items within each group and apply max_items truncation
1555    group_map
1556        .into_iter()
1557        .map(|(label, mut items)| {
1558            if let Some(cmp) = sort_items {
1559                items.sort_by(cmp);
1560            }
1561            let total_count = items.len();
1562            if let Some(max) = max_items_per_group {
1563                items.truncate(max);
1564            }
1565            SuggestionGroup {
1566                label,
1567                items,
1568                total_count,
1569            }
1570        })
1571        .collect()
1572}