use std::{
collections::BTreeMap,
rc::Rc,
sync::atomic::{AtomicUsize, Ordering},
};
use dioxus::prelude::*;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, strum::AsRefStr)]
#[strum(serialize_all = "kebab-case")]
pub enum Side {
Top,
Right,
#[default]
Bottom,
Left,
}
pub struct Controllable<T: Clone + PartialEq + 'static> {
signal: Signal<T>,
controlled: bool,
on_change: Option<EventHandler<T>>,
}
impl<T: Clone + PartialEq + 'static> Controllable<T> {
pub fn get(&self) -> T {
self.signal.read().clone()
}
pub fn set(&self, next: T) {
if !self.controlled {
let mut sig = self.signal;
sig.set(next.clone());
}
if let Some(handler) = &self.on_change {
handler.call(next);
}
}
}
impl<T: Clone + PartialEq + 'static> Clone for Controllable<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T: Clone + PartialEq + 'static> Copy for Controllable<T> {}
pub fn use_controllable<T: Clone + PartialEq + 'static>(controlled: Option<T>, default: T, on_change: Option<EventHandler<T>>) -> Controllable<T> {
let mut signal = use_signal(|| controlled.clone().unwrap_or(default));
if let Some(value) = controlled.clone()
&& *signal.peek() != value
{
signal.set(value);
}
Controllable {
signal,
controlled: controlled.is_some(),
on_change,
}
}
#[derive(Clone, Copy, Default, PartialEq)]
pub enum RovingOrientation {
Horizontal,
#[default]
Vertical,
Both,
}
#[derive(Clone, Copy)]
pub struct RovingFocus {
items: Signal<BTreeMap<usize, RovingItem>>,
orientation: RovingOrientation,
}
impl RovingFocus {
pub fn next(&self, e: &KeyboardEvent, from: &str) -> Option<String> {
let items = self.items.read();
let keys: Vec<&str> = items.values().map(|i| i.key.as_str()).collect();
let last = keys.len().checked_sub(1)?;
let at = keys.iter().position(|k| *k == from).unwrap_or(0);
let horizontal = matches!(self.orientation, RovingOrientation::Horizontal | RovingOrientation::Both);
let vertical = matches!(self.orientation, RovingOrientation::Vertical | RovingOrientation::Both);
let forward = |at: usize| if at == last { 0 } else { at + 1 };
let back = |at: usize| if at == 0 { last } else { at - 1 };
let to = match e.key() {
Key::ArrowDown if vertical => forward(at),
Key::ArrowUp if vertical => back(at),
Key::ArrowRight if horizontal => forward(at),
Key::ArrowLeft if horizontal => back(at),
Key::Home => 0,
Key::End => last,
_ => return None,
};
Some(keys[to].to_string())
}
pub fn is_tab_stop(&self, key: &str, selected: &str) -> bool {
if key == selected {
return true;
}
let items = self.items.read();
let mut keys = items.values().map(|i| i.key.as_str());
!keys.clone().any(|k| k == selected) && keys.next() == Some(key)
}
pub fn focus(&self, key: &str) {
let el = self.items.read().values().find(|i| i.key == key).and_then(|i| i.el.clone());
if let Some(el) = el {
spawn(async move {
let _ = el.set_focus(true).await;
});
}
}
pub fn attach(&self, id: usize, el: Rc<MountedData>) {
let mut items = self.items;
if let Some(item) = items.write().get_mut(&id) {
item.el = Some(el);
}
}
}
pub fn use_roving_focus(orientation: RovingOrientation) -> RovingFocus {
RovingFocus {
items: use_signal(BTreeMap::new),
orientation,
}
}
pub fn use_roving_item(roving: RovingFocus, key: String) -> usize {
let id = use_hook({
let key = key.clone();
move || {
let id = NEXT_ROVING_ITEM_ID.fetch_add(1, Ordering::Relaxed);
let mut items = roving.items;
items.write().insert(id, RovingItem { key, el: None });
id
}
});
use_effect(use_reactive!(|key| {
let mut items = roving.items;
if let Some(item) = items.write().get_mut(&id) {
item.key = key;
}
}));
use_drop(move || {
let mut items = roving.items;
items.write().remove(&id);
});
id
}
static NEXT_ROVING_ITEM_ID: AtomicUsize = AtomicUsize::new(0);
struct RovingItem {
key: String,
el: Option<Rc<MountedData>>,
}
#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
pub(crate) fn is_transform_transition(e: &Event<TransitionData>) -> bool {
e.downcast::<web_sys::TransitionEvent>().is_none_or(|t| {
let own = t.target().is_some_and(|target| t.current_target().is_some_and(|current| current == target));
own && t.property_name() == "transform"
})
}
#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))]
pub(crate) fn is_transform_transition(_: &Event<TransitionData>) -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
use crate::uikit::test_util::render;
#[test]
fn uncontrolled_uses_default_then_updates() {
fn app() -> Element {
let state = use_controllable::<bool>(None, false, None);
let label = if state.get() { "on" } else { "off" };
rsx! {
button {
onclick: move |_| state.set(true),
{label}
}
}
}
assert!(render(app).contains("off"));
}
#[test]
fn items_register_during_render_so_ssr_markup_has_a_tab_stop() {
fn app() -> Element {
let roving = use_roving_focus(RovingOrientation::Vertical);
use_context_provider(|| roving);
rsx! {
Item { value: "a" }
Item { value: "b" }
}
}
#[component]
fn Item(value: String) -> Element {
let roving = use_context::<RovingFocus>();
use_roving_item(roving, value.clone());
let stop = roving.is_tab_stop(&value, "");
rsx! {
button { tabindex: if stop { "0" } else { "-1" } }
}
}
let html = render(app);
assert_eq!(html.matches("tabindex=\"0\"").count(), 1, "{html}");
assert!(html.starts_with("<button tabindex=\"0\""), "the first item takes the stop: {html}");
}
#[test]
fn controlled_reflects_external_value() {
fn app() -> Element {
let state = use_controllable::<bool>(Some(true), false, None);
let label = if state.get() { "on" } else { "off" };
rsx! { span { {label} } }
}
assert!(render(app).contains("on"));
}
}