Skip to main content

dioxus_nox_select/
components.rs

1use dioxus::prelude::*;
2
3use crate::context::{SelectContext, init_select_context};
4use crate::types::*;
5
6// ── ItemContext (inner) ─────────────────────────────────────────────────────
7
8/// Inner context provided by [`Item`] to child components like [`ItemIndicator`].
9#[derive(Clone)]
10pub(crate) struct ItemContext {
11    pub value: String,
12}
13
14// ── GroupContext (inner) ─────────────────────────────────────────────────────
15
16/// Inner context provided by [`Group`] so child items can associate with a group.
17#[derive(Clone)]
18pub(crate) struct GroupContext {
19    pub id: String,
20}
21
22// ── Root ────────────────────────────────────────────────────────────────────
23
24/// Context provider for the select compound component.
25///
26/// Wraps a [`Trigger`] (or [`Input`]) and a [`Content`] popup. Ships **zero
27/// visual styles** — all state is expressed through `data-*` attributes.
28///
29/// ## Variants
30///
31/// - **Select-only**: Compose `Trigger` + `Value` inside Root (no `Input`).
32/// - **Combobox**: Compose `Input` inside Root (enables search/filter).
33/// - **Multiselect**: Set `multiple: true`.
34///
35/// ## Data attributes
36///
37/// - `data-select-state="open|closed"`
38/// - `data-select-disabled="true"` (when disabled)
39#[component]
40pub fn Root(
41    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
42    /// Initial value (uncontrolled, single-select).
43    #[props(default)]
44    default_value: Option<String>,
45    /// Controlled single-select value.
46    #[props(default)]
47    value: Option<Signal<String>>,
48    /// Fires when single-select value changes.
49    #[props(default)]
50    on_value_change: Option<EventHandler<String>>,
51    /// Initial values (uncontrolled, multi-select).
52    #[props(default)]
53    default_values: Option<Vec<String>>,
54    /// Controlled multi-select values.
55    #[props(default)]
56    values: Option<Signal<Vec<String>>>,
57    /// Fires when multi-select values change.
58    #[props(default)]
59    on_values_change: Option<EventHandler<Vec<String>>>,
60    /// Enable multi-select mode.
61    #[props(default)]
62    multiple: bool,
63    /// Disable the entire select.
64    #[props(default)]
65    disabled: bool,
66    /// Whether popup starts open.
67    #[props(default)]
68    default_open: bool,
69    /// Controlled open state.
70    #[props(default)]
71    open: Option<Signal<bool>>,
72    /// Fires when open state changes.
73    #[props(default)]
74    on_open_change: Option<EventHandler<bool>>,
75    /// Autocomplete mode (only relevant when `Input` child is present).
76    #[props(default)]
77    autocomplete: AutoComplete,
78    /// Auto-open dropdown when input receives focus (combobox variant).
79    #[props(default = true)]
80    open_on_focus: bool,
81    /// Custom filter function. Overrides built-in nucleo fuzzy matching.
82    #[props(default)]
83    filter: Option<CustomFilter>,
84    children: Element,
85) -> Element {
86    let ctx = init_select_context(
87        default_value,
88        value,
89        on_value_change,
90        default_values,
91        values,
92        on_values_change,
93        multiple,
94        disabled,
95        default_open,
96        open,
97        on_open_change,
98        autocomplete,
99        open_on_focus,
100        filter,
101    );
102
103    let state = if ctx.is_open() { "open" } else { "closed" };
104
105    rsx! {
106        div {
107            "data-select-state": state,
108            "data-select-disabled": disabled.then_some("true"),
109            ..attributes,
110            {children}
111        }
112    }
113}
114
115// ── Trigger ─────────────────────────────────────────────────────────────────
116
117/// The button that opens/closes the select popup.
118///
119/// For the **select-only** variant (no `Input` child). Renders a `<button>`
120/// with `role="combobox"` and full keyboard handling per the WAI-ARIA
121/// select-only combobox pattern.
122///
123/// ## Data attributes
124///
125/// - `data-state="open|closed"`
126/// - `data-disabled="true"` (when disabled)
127#[component]
128pub fn Trigger(
129    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
130    /// Disable the trigger.
131    #[props(default)]
132    disabled: bool,
133    children: Element,
134) -> Element {
135    let ctx: SelectContext = use_context();
136
137    let is_open = ctx.is_open();
138    let state = if is_open { "open" } else { "closed" };
139    let trigger_id = ctx.trigger_id();
140    let listbox_id = ctx.listbox_id();
141    let active_desc = ctx.active_descendant();
142    let is_disabled = disabled || ctx.disabled;
143
144    let onkeydown = move |event: KeyboardEvent| {
145        if is_disabled {
146            return;
147        }
148        let mut ctx: SelectContext = consume_context();
149        let was_open = ctx.is_open();
150
151        match event.key() {
152            Key::Enter => {
153                event.prevent_default();
154                if was_open {
155                    ctx.confirm_highlighted();
156                } else {
157                    ctx.set_open(true);
158                    let current = ctx.current_value();
159                    if !current.is_empty() {
160                        ctx.highlighted.set(Some(current));
161                    } else {
162                        ctx.highlight_first();
163                    }
164                }
165            }
166            Key::Character(ref c) if c == " " => {
167                event.prevent_default();
168                if was_open {
169                    ctx.confirm_highlighted();
170                } else {
171                    ctx.set_open(true);
172                    let current = ctx.current_value();
173                    if !current.is_empty() {
174                        ctx.highlighted.set(Some(current));
175                    } else {
176                        ctx.highlight_first();
177                    }
178                }
179            }
180            Key::ArrowDown => {
181                event.prevent_default();
182                if !was_open {
183                    ctx.set_open(true);
184                    let current = ctx.current_value();
185                    if !current.is_empty() {
186                        ctx.highlighted.set(Some(current));
187                    }
188                    ctx.highlight_next();
189                } else {
190                    ctx.highlight_next();
191                }
192            }
193            Key::ArrowUp => {
194                event.prevent_default();
195                if !was_open {
196                    ctx.set_open(true);
197                    let current = ctx.current_value();
198                    if !current.is_empty() {
199                        ctx.highlighted.set(Some(current));
200                    }
201                    ctx.highlight_prev();
202                } else {
203                    ctx.highlight_prev();
204                }
205            }
206            Key::Home => {
207                event.prevent_default();
208                if !was_open {
209                    ctx.set_open(true);
210                }
211                ctx.highlight_first();
212            }
213            Key::End => {
214                event.prevent_default();
215                if !was_open {
216                    ctx.set_open(true);
217                }
218                ctx.highlight_last();
219            }
220            Key::Escape => {
221                if was_open {
222                    event.prevent_default();
223                    ctx.set_open(false);
224                }
225            }
226            Key::Tab => {
227                // Close on Tab (do NOT prevent default — let focus move)
228                if was_open {
229                    ctx.confirm_highlighted();
230                }
231            }
232            // Type-ahead for printable characters
233            Key::Character(ref c) if c != " " => {
234                event.prevent_default();
235                if !was_open {
236                    ctx.set_open(true);
237                }
238                ctx.type_ahead(c);
239            }
240            _ => {}
241        }
242    };
243
244    let onclick = move |_: MouseEvent| {
245        if !is_disabled {
246            let mut ctx: SelectContext = consume_context();
247            ctx.toggle_open();
248            if ctx.is_open() {
249                let current = ctx.current_value();
250                if !current.is_empty() {
251                    ctx.highlighted.set(Some(current));
252                }
253            }
254        }
255    };
256
257    // Close on blur (click-outside).
258    // Content uses `onmousedown: prevent_default()` which prevents the browser
259    // from moving focus away when clicking inside the listbox. So blur only
260    // fires when focus genuinely leaves the select — exactly when we want to close.
261    let onblur = move |_: FocusEvent| {
262        let mut ctx: SelectContext = consume_context();
263        ctx.set_open(false);
264    };
265
266    rsx! {
267        button {
268            id: "{trigger_id}",
269            role: "combobox",
270            r#type: "button",
271            aria_expanded: if is_open { "true" } else { "false" },
272            aria_haspopup: "listbox",
273            aria_controls: "{listbox_id}",
274            aria_activedescendant: if !active_desc.is_empty() { "{active_desc}" },
275            tabindex: "0",
276            "data-state": state,
277            "data-disabled": is_disabled.then_some("true"),
278            disabled: is_disabled,
279            onkeydown,
280            onclick,
281            onblur,
282            ..attributes,
283            {children}
284        }
285    }
286}
287
288// ── Value ───────────────────────────────────────────────────────────────────
289
290/// Displays the current selected value text, or a placeholder.
291///
292/// In single-select mode, looks up the selected item's label.
293/// In multi-select mode, consumers should provide their own rendering via
294/// children (the component provides context access for selected values).
295///
296/// ## Data attributes
297///
298/// - `data-select-placeholder` — present when showing placeholder text
299#[component]
300pub fn Value(
301    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
302    /// Placeholder text shown when no value is selected.
303    #[props(default)]
304    placeholder: Option<String>,
305    #[props(default)] children: Element,
306) -> Element {
307    let ctx: SelectContext = use_context();
308
309    let has_children = children != VNode::empty();
310
311    // Determine display text for single-select mode
312    let (display_text, is_placeholder) = if !has_children {
313        if ctx.multiple {
314            let vals = ctx.current_values();
315            if vals.is_empty() {
316                (placeholder.clone().unwrap_or_default(), true)
317            } else {
318                // Show comma-separated labels
319                let items = ctx.items.read();
320                let labels: Vec<String> = vals
321                    .iter()
322                    .filter_map(|v| {
323                        items
324                            .iter()
325                            .find(|e| &e.value == v)
326                            .map(|e| e.label.clone())
327                    })
328                    .collect();
329                if labels.is_empty() {
330                    (vals.join(", "), false)
331                } else {
332                    (labels.join(", "), false)
333                }
334            }
335        } else {
336            let current = ctx.current_value();
337            if current.is_empty() {
338                (placeholder.clone().unwrap_or_default(), true)
339            } else {
340                // Look up label from registered items
341                let items = ctx.items.read();
342                let label = items
343                    .iter()
344                    .find(|e| e.value == current)
345                    .map(|e| e.label.clone())
346                    .unwrap_or(current);
347                (label, false)
348            }
349        }
350    } else {
351        (String::new(), false)
352    };
353
354    rsx! {
355        span {
356            "data-select-placeholder": is_placeholder.then_some("true"),
357            ..attributes,
358            if has_children {
359                {children}
360            } else {
361                "{display_text}"
362            }
363        }
364    }
365}
366
367// ── Input ───────────────────────────────────────────────────────────────────
368
369/// Search input for the combobox variant.
370///
371/// Its presence inside `Root` switches the component from select-only to
372/// combobox mode. Renders an `<input>` with `role="combobox"` and full
373/// ARIA attributes.
374///
375/// ## Data attributes
376///
377/// - `data-select-input` — always present
378#[component]
379pub fn Input(
380    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
381    /// Placeholder text for the input.
382    #[props(default)]
383    placeholder: Option<String>,
384) -> Element {
385    let mut ctx: SelectContext = use_context();
386
387    // Mark that we have an input (switches to combobox mode)
388    use_hook(|| {
389        ctx.mark_has_input();
390    });
391
392    let is_open = ctx.is_open();
393    let input_id = ctx.input_id();
394    let listbox_id = ctx.listbox_id();
395    let active_desc = ctx.active_descendant();
396    let autocomplete_attr = ctx.autocomplete.as_aria_attr();
397    let is_disabled = ctx.disabled;
398
399    let oninput = move |evt: Event<FormData>| {
400        let mut ctx: SelectContext = consume_context();
401        ctx.search_query.set(evt.value());
402        if !ctx.is_open() {
403            ctx.set_open(true);
404        }
405        // Reset highlight to first match
406        ctx.highlight_first();
407    };
408
409    let onkeydown = move |event: KeyboardEvent| {
410        if is_disabled {
411            return;
412        }
413        let mut ctx: SelectContext = consume_context();
414        let was_open = ctx.is_open();
415
416        match event.key() {
417            Key::ArrowDown => {
418                event.prevent_default();
419                if event.modifiers().alt() {
420                    // Alt+ArrowDown: open without highlighting
421                    if !was_open {
422                        ctx.set_open(true);
423                    }
424                } else if !was_open {
425                    ctx.set_open(true);
426                    ctx.highlight_first();
427                } else {
428                    ctx.highlight_next();
429                }
430            }
431            Key::ArrowUp => {
432                if was_open {
433                    event.prevent_default();
434                    ctx.highlight_prev();
435                }
436            }
437            Key::Enter => {
438                if was_open && ctx.highlighted.read().is_some() {
439                    event.prevent_default();
440                    ctx.confirm_highlighted();
441                }
442            }
443            Key::Escape => {
444                if was_open {
445                    event.prevent_default();
446                    ctx.set_open(false);
447                }
448            }
449            // Home/End: let the input handle cursor movement (no preventDefault)
450            Key::Tab => {
451                if was_open {
452                    ctx.set_open(false);
453                }
454            }
455            _ => {}
456        }
457    };
458
459    let onfocus = move |_: FocusEvent| {
460        let mut ctx: SelectContext = consume_context();
461        if ctx.open_on_focus && !ctx.disabled && !ctx.is_open() {
462            ctx.set_open(true);
463            ctx.highlight_first();
464        }
465    };
466
467    let onblur = move |_: FocusEvent| {
468        let mut ctx: SelectContext = consume_context();
469        ctx.set_open(false);
470    };
471
472    rsx! {
473        input {
474            id: "{input_id}",
475            r#type: "text",
476            role: "combobox",
477            aria_expanded: if is_open { "true" } else { "false" },
478            aria_haspopup: "listbox",
479            aria_controls: "{listbox_id}",
480            aria_activedescendant: if !active_desc.is_empty() { "{active_desc}" },
481            aria_autocomplete: "{autocomplete_attr}",
482            disabled: is_disabled,
483            placeholder: placeholder,
484            value: "{ctx.search_query}",
485            "data-select-input": "true",
486            oninput,
487            onkeydown,
488            onfocus,
489            onblur,
490            ..attributes,
491        }
492    }
493}
494
495// ── ClearButton ─────────────────────────────────────────────────────────────
496
497/// Button to clear the search query.
498///
499/// ## Data attributes
500///
501/// - `data-select-clear` — always present
502#[component]
503pub fn ClearButton(
504    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
505    children: Element,
506) -> Element {
507    let onclick = move |evt: MouseEvent| {
508        evt.prevent_default();
509        let mut ctx: SelectContext = consume_context();
510        ctx.search_query.set(String::new());
511        ctx.focus_combobox();
512    };
513
514    rsx! {
515        button {
516            r#type: "button",
517            aria_label: "Clear",
518            tabindex: "-1",
519            "data-select-clear": "true",
520            onclick,
521            ..attributes,
522            {children}
523        }
524    }
525}
526
527// ── Content ─────────────────────────────────────────────────────────────────
528
529/// The popup containing the listbox of options.
530///
531/// Only renders when the select is open.
532///
533/// ## Data attributes
534///
535/// - `data-select-content` — always present
536/// - `data-state="open|closed"`
537#[component]
538pub fn Content(
539    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
540    /// Optional accessible label for the listbox.
541    #[props(default)]
542    aria_label: Option<String>,
543    children: Element,
544) -> Element {
545    let ctx: SelectContext = use_context();
546
547    if !ctx.is_open() {
548        return rsx! {};
549    }
550
551    let listbox_id = ctx.listbox_id();
552    let multi = ctx.multiple;
553
554    rsx! {
555        div {
556            id: "{listbox_id}",
557            role: "listbox",
558            aria_label: aria_label,
559            aria_multiselectable: if multi { "true" } else { "false" },
560            "data-select-content": "true",
561            "data-state": "open",
562            // Prevent focus leaving the combobox when clicking inside content
563            onmousedown: |evt: MouseEvent| { evt.prevent_default(); },
564            ..attributes,
565            {children}
566        }
567    }
568}
569
570// ── Item ────────────────────────────────────────────────────────────────────
571
572/// A single selectable option in the listbox.
573///
574/// Registers itself with the context on mount and deregisters on unmount.
575/// Only renders if it passes the current filter.
576///
577/// ## Data attributes
578///
579/// - `data-state="checked|unchecked"`
580/// - `data-highlighted` — present when this item has visual focus
581/// - `data-disabled="true"` — when disabled
582#[component]
583pub fn Item(
584    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
585    /// Unique value identifying this option.
586    value: String,
587    /// Searchable text label. Falls back to `value` if not provided.
588    #[props(default)]
589    label: Option<String>,
590    /// Additional keywords for fuzzy matching (space-separated).
591    #[props(default)]
592    keywords: Option<String>,
593    /// Prevent selection and skip in keyboard navigation.
594    #[props(default)]
595    disabled: bool,
596    children: Element,
597) -> Element {
598    let mut ctx: SelectContext = use_context();
599    let display_label = label.clone().unwrap_or_else(|| value.clone());
600    let val = value.clone();
601
602    // Get group context if nested inside a Group
603    let group_id = try_use_context::<GroupContext>().map(|g| g.id.clone());
604
605    // Register on mount
606    use_hook(|| {
607        ctx.register_item(ItemEntry {
608            value: val.clone(),
609            label: display_label.clone(),
610            keywords: keywords.clone().unwrap_or_default(),
611            disabled,
612            group_id: group_id.clone(),
613        });
614    });
615
616    // Sync disabled prop changes to registered item (use_hook only runs on mount)
617    {
618        let val_sync = value.clone();
619        use_effect(move || {
620            let disabled = disabled; // subscribe to prop
621            let mut ctx: SelectContext = consume_context();
622            let mut items = ctx.items.write();
623            if let Some(item) = items.iter_mut().find(|e| e.value == val_sync) {
624                item.disabled = disabled;
625            }
626        });
627    }
628
629    let val_drop = value.clone();
630    use_drop(move || {
631        let mut ctx: SelectContext = consume_context();
632        ctx.deregister_item(&val_drop);
633    });
634
635    // Check if this item is visible (passes filter)
636    let visible = ctx.visible_values.read();
637    if !visible.iter().any(|v| v == &value) {
638        return rsx! {};
639    }
640
641    let is_selected = ctx.is_selected(&value);
642    let is_highlighted = ctx.highlighted.read().as_deref() == Some(value.as_str());
643    let item_id = ctx.item_id(&value);
644    let state = if is_selected { "checked" } else { "unchecked" };
645
646    // Provide inner context for ItemIndicator
647    let item_ctx = ItemContext {
648        value: value.clone(),
649    };
650    use_context_provider(|| item_ctx);
651
652    let val_click = value.clone();
653    let onmousedown = move |evt: MouseEvent| {
654        evt.prevent_default();
655        if !disabled {
656            let mut ctx: SelectContext = consume_context();
657            if ctx.multiple {
658                ctx.toggle_value(&val_click);
659            } else {
660                ctx.select_single(&val_click);
661            }
662        }
663    };
664
665    let val_enter = value.clone();
666    let onpointerenter = move |_| {
667        let mut ctx: SelectContext = consume_context();
668        ctx.highlighted.set(Some(val_enter.clone()));
669    };
670
671    rsx! {
672        div {
673            id: "{item_id}",
674            role: "option",
675            aria_selected: if is_selected { "true" } else { "false" },
676            aria_disabled: disabled.then_some("true"),
677            "data-state": state,
678            "data-highlighted": is_highlighted.then_some("true"),
679            "data-disabled": disabled.then_some("true"),
680            onmousedown,
681            onpointerenter,
682            ..attributes,
683            {children}
684        }
685    }
686}
687
688// ── ItemText ────────────────────────────────────────────────────────────────
689
690/// The text content of an option item.
691///
692/// ## Data attributes
693///
694/// - `data-select-item-text` — always present
695#[component]
696pub fn ItemText(
697    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
698    children: Element,
699) -> Element {
700    rsx! {
701        span {
702            "data-select-item-text": "true",
703            ..attributes,
704            {children}
705        }
706    }
707}
708
709// ── ItemIndicator ───────────────────────────────────────────────────────────
710
711/// Renders its children only when the parent [`Item`] is selected.
712///
713/// Requires being nested inside an [`Item`] component.
714///
715/// ## Data attributes
716///
717/// - `data-select-item-indicator` — always present
718#[component]
719pub fn ItemIndicator(
720    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
721    children: Element,
722) -> Element {
723    let ctx: SelectContext = use_context();
724    let item_ctx: ItemContext = use_context();
725
726    if !ctx.is_selected(&item_ctx.value) {
727        return rsx! {};
728    }
729
730    rsx! {
731        span {
732            "data-select-item-indicator": "true",
733            ..attributes,
734            {children}
735        }
736    }
737}
738
739// ── Group ───────────────────────────────────────────────────────────────────
740
741/// Groups related options with an optional label.
742///
743/// ## Data attributes
744///
745/// - `data-select-group` — always present
746#[component]
747pub fn Group(
748    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
749    /// Unique group identifier.
750    id: String,
751    /// Optional heading for the group (used for `aria-labelledby`).
752    #[props(default)]
753    label: Option<String>,
754    children: Element,
755) -> Element {
756    let mut ctx: SelectContext = use_context();
757    let group_id = id.clone();
758
759    // Register on mount
760    use_hook(|| {
761        ctx.register_group(GroupEntry {
762            id: group_id.clone(),
763            label: label.clone(),
764        });
765    });
766    let id_drop = id.clone();
767    use_drop(move || {
768        let mut ctx: SelectContext = consume_context();
769        ctx.deregister_group(&id_drop);
770    });
771
772    // Provide group context for child items
773    let group_ctx = GroupContext { id: id.clone() };
774    use_context_provider(|| group_ctx);
775
776    let label_id = if label.is_some() {
777        Some(ctx.group_label_id(&id))
778    } else {
779        None
780    };
781
782    rsx! {
783        div {
784            role: "group",
785            aria_labelledby: label_id,
786            "data-select-group": "true",
787            ..attributes,
788            {children}
789        }
790    }
791}
792
793// ── Label ───────────────────────────────────────────────────────────────────
794
795/// Heading label for a [`Group`].
796///
797/// ## Data attributes
798///
799/// - `data-select-label` — always present
800#[component]
801pub fn Label(
802    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
803    children: Element,
804) -> Element {
805    let ctx: SelectContext = use_context();
806    let group_ctx: GroupContext = use_context();
807    let label_id = ctx.group_label_id(&group_ctx.id);
808
809    rsx! {
810        div {
811            id: "{label_id}",
812            "data-select-label": "true",
813            ..attributes,
814            {children}
815        }
816    }
817}
818
819// ── Separator ───────────────────────────────────────────────────────────────
820
821/// Visual separator between items or groups.
822///
823/// ## Data attributes
824///
825/// - `data-select-separator` — always present
826#[component]
827pub fn Separator(#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>) -> Element {
828    rsx! {
829        div {
830            role: "separator",
831            aria_orientation: "horizontal",
832            "data-select-separator": "true",
833            ..attributes,
834        }
835    }
836}
837
838// ── Empty ───────────────────────────────────────────────────────────────────
839
840/// Rendered when no items match the current filter query.
841///
842/// ## Data attributes
843///
844/// - `data-select-empty` — always present
845#[component]
846pub fn Empty(
847    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
848    children: Element,
849) -> Element {
850    let ctx: SelectContext = use_context();
851
852    if !ctx.visible_values.read().is_empty() {
853        return rsx! {};
854    }
855
856    // Don't show when there's no query (all items are visible when unfiltered)
857    if ctx.search_query.read().is_empty() {
858        return rsx! {};
859    }
860
861    // Don't show if no items have registered yet — they may still be mounting
862    if ctx.items.read().is_empty() {
863        return rsx! {};
864    }
865
866    rsx! {
867        div {
868            role: "status",
869            "data-select-empty": "true",
870            ..attributes,
871            {children}
872        }
873    }
874}