Skip to main content

dioxus_nox_select/
context.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3
4use dioxus::prelude::*;
5use nucleo_matcher::{Config, Matcher};
6
7use crate::filter;
8use crate::navigation::{self, Direction};
9use crate::types::*;
10
11/// Shared context for the select compound component tree.
12///
13/// Provided by [`super::select::Root`] and consumed by all child components.
14#[derive(Clone, Copy)]
15#[allow(dead_code)]
16pub struct SelectContext {
17    // ── Value state (single-select) ──────────────────────────
18    pub(crate) value: Signal<String>,
19    pub(crate) controlled_value: Option<Signal<String>>,
20
21    // ── Value state (multi-select) ───────────────────────────
22    pub(crate) values: Signal<Vec<String>>,
23    pub(crate) controlled_values: Option<Signal<Vec<String>>>,
24
25    // ── Open state ───────────────────────────────────────────
26    pub(crate) open: Signal<bool>,
27    pub(crate) controlled_open: Option<Signal<bool>>,
28
29    // ── Search / filter ──────────────────────────────────────
30    pub(crate) search_query: Signal<String>,
31    pub(crate) scored_items: Memo<Vec<ScoredItem>>,
32    pub(crate) visible_values: Memo<Vec<String>>,
33
34    // ── Highlight (visual focus in listbox) ──────────────────
35    pub(crate) highlighted: Signal<Option<String>>,
36
37    // ── Registration ─────────────────────────────────────────
38    pub(crate) items: Signal<Vec<ItemEntry>>,
39    pub(crate) groups: Signal<Vec<GroupEntry>>,
40
41    // ── Configuration ────────────────────────────────────────
42    pub(crate) multiple: bool,
43    pub(crate) disabled: bool,
44    pub(crate) autocomplete: AutoComplete,
45    pub(crate) open_on_focus: bool,
46    pub(crate) custom_filter: Signal<Option<CustomFilter>>,
47
48    // ── Callbacks ────────────────────────────────────────────
49    pub(crate) on_value_change: Option<EventHandler<String>>,
50    pub(crate) on_values_change: Option<EventHandler<Vec<String>>>,
51    pub(crate) on_open_change: Option<EventHandler<bool>>,
52
53    // ── Identity ─────────────────────────────────────────────
54    pub(crate) instance_id: u32,
55
56    /// Whether an `Input` child has been mounted (makes this a combobox).
57    pub(crate) has_input: Signal<bool>,
58}
59
60impl SelectContext {
61    // ── Value read/write ─────────────────────────────────────
62
63    /// Current single-select value.
64    pub fn current_value(&self) -> String {
65        match self.controlled_value {
66            Some(sig) => (sig)(),
67            None => (self.value)(),
68        }
69    }
70
71    /// Current multi-select values.
72    pub fn current_values(&self) -> Vec<String> {
73        match self.controlled_values {
74            Some(sig) => (sig)(),
75            None => (self.values)(),
76        }
77    }
78
79    /// Current multi-select values without subscribing (peek).
80    pub fn current_values_peek(&self) -> Vec<String> {
81        match self.controlled_values {
82            Some(sig) => sig.peek().clone(),
83            None => self.values.peek().clone(),
84        }
85    }
86
87    /// Select a value in single-select mode: sets value, closes popup, fires callback.
88    pub fn select_single(&mut self, val: &str) {
89        if self.disabled {
90            return;
91        }
92        if let Some(mut controlled) = self.controlled_value {
93            controlled.set(val.to_string());
94        } else {
95            self.value.set(val.to_string());
96        }
97        if let Some(handler) = &self.on_value_change {
98            handler.call(val.to_string());
99        }
100        self.set_open(false);
101        self.search_query.set(String::new());
102    }
103
104    /// Toggle a value in multi-select mode: adds if absent, removes if present.
105    /// Does NOT close the popup.
106    pub fn toggle_value(&mut self, val: &str) {
107        if self.disabled {
108            return;
109        }
110        let mut current = self.current_values();
111        if let Some(pos) = current.iter().position(|v| v == val) {
112            current.remove(pos);
113        } else {
114            current.push(val.to_string());
115        }
116        if let Some(mut controlled) = self.controlled_values {
117            controlled.set(current.clone());
118        } else {
119            self.values.set(current.clone());
120        }
121        if let Some(handler) = &self.on_values_change {
122            handler.call(current);
123        }
124    }
125
126    /// Check if a value is currently selected.
127    pub fn is_selected(&self, val: &str) -> bool {
128        if self.multiple {
129            self.current_values().iter().any(|v| v == val)
130        } else {
131            self.current_value() == val
132        }
133    }
134
135    // ── Open/close ───────────────────────────────────────────
136
137    /// Whether the popup is currently open.
138    pub fn is_open(&self) -> bool {
139        match self.controlled_open {
140            Some(sig) => (sig)(),
141            None => (self.open)(),
142        }
143    }
144
145    /// Set the open state.
146    pub fn set_open(&mut self, is_open: bool) {
147        if let Some(mut controlled) = self.controlled_open {
148            controlled.set(is_open);
149        } else {
150            self.open.set(is_open);
151        }
152        if let Some(handler) = &self.on_open_change {
153            handler.call(is_open);
154        }
155        if !is_open {
156            self.highlighted.set(None);
157        }
158    }
159
160    /// Toggle the open state.
161    pub fn toggle_open(&mut self) {
162        let current = self.is_open();
163        self.set_open(!current);
164    }
165
166    // ── Highlight navigation ─────────────────────────────────
167
168    /// Move highlight to the next visible non-disabled item.
169    pub fn highlight_next(&mut self) {
170        let visible = self.visible_values.read();
171        let items = self.items.read();
172        let current = self.highlighted.read();
173        let next = navigation::navigate(&items, &visible, current.as_deref(), Direction::Forward);
174        drop(visible);
175        drop(items);
176        drop(current);
177        self.highlighted.set(next.clone());
178        if let Some(ref val) = next {
179            self.scroll_item_into_view(val);
180        }
181    }
182
183    /// Move highlight to the previous visible non-disabled item.
184    pub fn highlight_prev(&mut self) {
185        let visible = self.visible_values.read();
186        let items = self.items.read();
187        let current = self.highlighted.read();
188        let prev = navigation::navigate(&items, &visible, current.as_deref(), Direction::Backward);
189        drop(visible);
190        drop(items);
191        drop(current);
192        self.highlighted.set(prev.clone());
193        if let Some(ref val) = prev {
194            self.scroll_item_into_view(val);
195        }
196    }
197
198    /// Move highlight to the first visible non-disabled item.
199    pub fn highlight_first(&mut self) {
200        let visible = self.visible_values.read();
201        let items = self.items.read();
202        let target = navigation::first(&items, &visible);
203        drop(visible);
204        drop(items);
205        self.highlighted.set(target.clone());
206        if let Some(ref val) = target {
207            self.scroll_item_into_view(val);
208        }
209    }
210
211    /// Move highlight to the last visible non-disabled item.
212    pub fn highlight_last(&mut self) {
213        let visible = self.visible_values.read();
214        let items = self.items.read();
215        let target = navigation::last(&items, &visible);
216        drop(visible);
217        drop(items);
218        self.highlighted.set(target.clone());
219        if let Some(ref val) = target {
220            self.scroll_item_into_view(val);
221        }
222    }
223
224    /// Type-ahead: find the first matching item by prefix.
225    pub fn type_ahead(&mut self, prefix: &str) {
226        let visible = self.visible_values.read();
227        let items = self.items.read();
228        let current = self.highlighted.read();
229        let target = navigation::type_ahead(&items, &visible, current.as_deref(), prefix);
230        drop(visible);
231        drop(items);
232        drop(current);
233        if let Some(ref val) = target {
234            self.highlighted.set(Some(val.clone()));
235            self.scroll_item_into_view(val);
236        }
237    }
238
239    /// Confirm the currently highlighted item (select or toggle).
240    pub fn confirm_highlighted(&mut self) {
241        let highlighted = self.highlighted.read().clone();
242        if let Some(val) = highlighted {
243            // Check disabled
244            let is_disabled = self
245                .items
246                .read()
247                .iter()
248                .any(|e| e.value == val && e.disabled);
249            if is_disabled {
250                return;
251            }
252            if self.multiple {
253                self.toggle_value(&val);
254            } else {
255                self.select_single(&val);
256            }
257        }
258    }
259
260    // ── Registration ─────────────────────────────────────────
261
262    /// Register an item. Called on mount.
263    pub fn register_item(&mut self, entry: ItemEntry) {
264        let mut items = self.items.write();
265        if !items.iter().any(|e| e.value == entry.value) {
266            items.push(entry);
267        }
268    }
269
270    /// Deregister an item. Called on unmount.
271    pub fn deregister_item(&mut self, value: &str) {
272        let mut items = self.items.write();
273        items.retain(|e| e.value != value);
274    }
275
276    /// Register a group. Called on mount.
277    pub fn register_group(&mut self, entry: GroupEntry) {
278        let mut groups = self.groups.write();
279        if !groups.iter().any(|g| g.id == entry.id) {
280            groups.push(entry);
281        }
282    }
283
284    /// Deregister a group. Called on unmount.
285    pub fn deregister_group(&mut self, id: &str) {
286        let mut groups = self.groups.write();
287        groups.retain(|g| g.id != id);
288    }
289
290    /// Mark that an `Input` child has been mounted (switches to combobox mode).
291    pub fn mark_has_input(&mut self) {
292        self.has_input.set(true);
293    }
294
295    /// Check if this select has a search input (combobox variant).
296    pub fn has_search_input(&self) -> bool {
297        (self.has_input)()
298    }
299
300    // ── ID generation ────────────────────────────────────────
301
302    /// ID for the trigger button element.
303    pub fn trigger_id(&self) -> String {
304        format!("nox-select-{}-trigger", self.instance_id)
305    }
306
307    /// ID for the listbox popup element.
308    pub fn listbox_id(&self) -> String {
309        format!("nox-select-{}-listbox", self.instance_id)
310    }
311
312    /// ID for a specific option element.
313    pub fn item_id(&self, value: &str) -> String {
314        format!("nox-select-{}-item-{}", self.instance_id, value)
315    }
316
317    /// ID for the search input element.
318    pub fn input_id(&self) -> String {
319        format!("nox-select-{}-input", self.instance_id)
320    }
321
322    /// ID for a group label element.
323    pub fn group_label_id(&self, group_id: &str) -> String {
324        format!("nox-select-{}-group-{}", self.instance_id, group_id)
325    }
326
327    /// The `aria-activedescendant` value (highlighted item ID, or empty).
328    pub fn active_descendant(&self) -> String {
329        match self.highlighted.read().as_ref() {
330            Some(val) => self.item_id(val),
331            None => String::new(),
332        }
333    }
334
335    // ── Public accessors for cross-crate use ────────────────
336
337    /// Current autocomplete mode.
338    pub fn autocomplete(&self) -> AutoComplete {
339        self.autocomplete
340    }
341
342    /// Whether dropdown opens on focus.
343    pub fn open_on_focus(&self) -> bool {
344        self.open_on_focus
345    }
346
347    /// Whether this is a multi-select.
348    pub fn is_multiple(&self) -> bool {
349        self.multiple
350    }
351
352    /// Whether an item is currently highlighted.
353    pub fn has_highlighted(&self) -> bool {
354        self.highlighted.read().is_some()
355    }
356
357    /// Get the currently highlighted value (if any).
358    pub fn highlighted_value(&self) -> Option<String> {
359        self.highlighted.read().clone()
360    }
361
362    /// Set the search query text.
363    pub fn set_search_query(&mut self, query: String) {
364        self.search_query.set(query);
365    }
366
367    // ── DOM helpers (WASM only) ──────────────────────────────
368
369    /// Scroll the highlighted item into view.
370    fn scroll_item_into_view(&self, value: &str) {
371        #[cfg(target_arch = "wasm32")]
372        {
373            let id = self.item_id(value);
374            spawn(async move {
375                let js = format!(
376                    "document.getElementById('{}')?.scrollIntoView({{block:'nearest'}})",
377                    id.replace('\'', "\\'")
378                );
379                _ = document::eval(&js).await;
380            });
381        }
382        #[cfg(not(target_arch = "wasm32"))]
383        {
384            _ = value;
385        }
386    }
387
388    /// Focus the combobox element (trigger or input).
389    pub(crate) fn focus_combobox(&self) {
390        #[cfg(target_arch = "wasm32")]
391        {
392            let id = if self.has_search_input() {
393                self.input_id()
394            } else {
395                self.trigger_id()
396            };
397            spawn(async move {
398                let js = format!(
399                    "document.getElementById('{}')?.focus()",
400                    id.replace('\'', "\\'")
401                );
402                _ = document::eval(&js).await;
403            });
404        }
405    }
406}
407
408/// Initialise the [`SelectContext`] and provide it via Dioxus context.
409///
410/// Called inside [`super::select::Root`].
411#[allow(clippy::too_many_arguments)]
412pub(crate) fn init_select_context(
413    default_value: Option<String>,
414    controlled_value: Option<Signal<String>>,
415    on_value_change: Option<EventHandler<String>>,
416    default_values: Option<Vec<String>>,
417    controlled_values: Option<Signal<Vec<String>>>,
418    on_values_change: Option<EventHandler<Vec<String>>>,
419    multiple: bool,
420    disabled: bool,
421    default_open: bool,
422    controlled_open: Option<Signal<bool>>,
423    on_open_change: Option<EventHandler<bool>>,
424    autocomplete: AutoComplete,
425    open_on_focus: bool,
426    custom_filter: Option<CustomFilter>,
427) -> SelectContext {
428    let instance_id = use_hook(next_instance_id);
429
430    let value = use_signal(|| default_value.unwrap_or_default());
431    let values = use_signal(|| default_values.unwrap_or_default());
432    let open = use_signal(|| default_open);
433    let search_query = use_signal(String::new);
434    let highlighted = use_signal(|| None::<String>);
435    let items: Signal<Vec<ItemEntry>> = use_signal(Vec::new);
436    let groups: Signal<Vec<GroupEntry>> = use_signal(Vec::new);
437    let has_input = use_signal(|| false);
438    let custom_filter_sig = use_signal(|| custom_filter);
439
440    // Persist the nucleo Matcher across renders — allocated once (same pattern as cmdk).
441    let matcher = use_hook(|| Rc::new(RefCell::new(Matcher::new(Config::DEFAULT))));
442
443    // Reactive memo for scored items (recalculates on items/query/filter change).
444    let scored_items = use_memo(move || {
445        let query = search_query.read().clone();
446        let all_items = items.read();
447        let cf = custom_filter_sig.read();
448        let mut m = matcher.borrow_mut();
449        filter::score_items(&all_items, &query, cf.as_ref(), &mut m)
450    });
451
452    let visible_values = use_memo(move || filter::visible_values(&scored_items.read()));
453
454    let ctx = SelectContext {
455        value,
456        controlled_value,
457        values,
458        controlled_values,
459        open,
460        controlled_open,
461        search_query,
462        scored_items,
463        visible_values,
464        highlighted,
465        items,
466        groups,
467        multiple,
468        disabled,
469        autocomplete,
470        open_on_focus,
471        custom_filter: custom_filter_sig,
472        on_value_change,
473        on_values_change,
474        on_open_change,
475        instance_id,
476        has_input,
477    };
478
479    use_context_provider(|| ctx);
480
481    ctx
482}