Start a project

Artelier UI Kit · Rust · Actions

Actions

One accent, spent once per screen.

AdsButton

rust
AdsButton { variant: ButtonVariant::Accent, icon: "plus", "New crawl" }
AdsButton { variant: ButtonVariant::Quiet, "Cancel" }
// Destructive pins the button to the default weight, whatever the variant.
AdsButton { destructive: true, onclick: move |_| delete_project(), "Delete project" }
Source · ads/ads-rs/src/ui/button.rs
rust
//! The ADS button and icon button.

use dioxus::prelude::*;

use crate::icons::Icon;

/// How much weight a button carries. `Accent` is the one action per screen.
#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum ButtonVariant {
    #[default]
    Default,
    Accent,
    Quiet,
}

impl ButtonVariant {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Default => "default",
            Self::Accent => "accent",
            Self::Quiet => "quiet",
        }
    }
}

#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum ButtonSize {
    #[default]
    Md,
    Sm,
}

impl ButtonSize {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Md => "md",
            Self::Sm => "sm",
        }
    }
}

/// The native `type` of a button. `Button` by default, so a button inside a
/// form never submits it by accident.
#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum ButtonType {
    #[default]
    Button,
    Submit,
    Reset,
}

impl ButtonType {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Button => "button",
            Self::Submit => "submit",
            Self::Reset => "reset",
        }
    }
}

/// The ADS button. `.btn` is the raised soft surface; the accent variant is
/// the one action per screen, so spend it once. A destructive action never
/// gets the accent — the accent means "the thing to do", and deleting is not
/// it — so `destructive` pins the button to the default weight whatever
/// variant is asked for. The confirm that follows it says the verb, not "OK".
#[component]
pub fn AdsButton(
    #[props(default)] variant: ButtonVariant,
    #[props(default)] size: ButtonSize,
    #[props(into, optional)] icon: Option<String>,
    #[props(default = 16u32)] icon_size: u32,
    #[props(optional)] loading: bool,
    #[props(optional)] destructive: bool,
    #[props(default)] button_type: ButtonType,
    #[props(optional)] disabled: bool,
    #[props(into, optional)] class: Option<String>,
    #[props(optional)] onclick: Option<EventHandler<MouseEvent>>,
    children: Element,
) -> Element {
    let weight = if destructive && variant == ButtonVariant::Accent { ButtonVariant::Default } else { variant };
    let merged = crate::class::join(&[
        "btn",
        if weight == ButtonVariant::Accent { "btn-accent" } else { "" },
        if weight == ButtonVariant::Quiet { "btn-quiet" } else { "" },
        if size == ButtonSize::Sm { "btn-sm" } else { "" },
        class.as_deref().unwrap_or(""),
    ]);
    rsx! {
        button {
            class: "{merged}",
            r#type: button_type.as_str(),
            disabled: disabled || loading,
            "aria-busy": if loading { Some("true") } else { None },
            onclick: move |e| { if let Some(h) = &onclick { h.call(e); } },
            if loading {
                span { class: "ads-spinner ads-button__spinner", "aria-hidden": "true" }
            } else if let Some(n) = &icon {
                Icon { name: n.clone(), size: icon_size }
            }
            {children}
        }
    }
}

/// A button that holds only a glyph. Round or square, accent or soft. The
/// label is required: with no words on the face, the name is all a screen
/// reader has.
#[component]
pub fn AdsIconButton(
    #[props(into)] icon: String,
    #[props(into)] label: String,
    #[props(optional)] round: bool,
    #[props(optional)] accent: bool,
    #[props(default = 18u32)] size: u32,
    #[props(default)] button_type: ButtonType,
    #[props(optional)] onclick: Option<EventHandler<MouseEvent>>,
) -> Element {
    let merged = crate::class::join(&[
        "ads-icon-button",
        if round { "is-round" } else { "" },
        if accent { "is-accent" } else { "" },
    ]);
    rsx! {
        button {
            class: "{merged}",
            r#type: button_type.as_str(),
            "aria-label": label,
            onclick: move |e| { if let Some(h) = &onclick { h.call(e); } },
            Icon { name: icon, size }
        }
    }
}

AdsIconButton

rust
AdsIconButton { icon: "mail", label: "Email" }
Source · ads/ads-rs/src/ui/button.rs
rust
//! The ADS button and icon button.

use dioxus::prelude::*;

use crate::icons::Icon;

/// How much weight a button carries. `Accent` is the one action per screen.
#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum ButtonVariant {
    #[default]
    Default,
    Accent,
    Quiet,
}

impl ButtonVariant {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Default => "default",
            Self::Accent => "accent",
            Self::Quiet => "quiet",
        }
    }
}

#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum ButtonSize {
    #[default]
    Md,
    Sm,
}

impl ButtonSize {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Md => "md",
            Self::Sm => "sm",
        }
    }
}

/// The native `type` of a button. `Button` by default, so a button inside a
/// form never submits it by accident.
#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum ButtonType {
    #[default]
    Button,
    Submit,
    Reset,
}

impl ButtonType {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Button => "button",
            Self::Submit => "submit",
            Self::Reset => "reset",
        }
    }
}

/// The ADS button. `.btn` is the raised soft surface; the accent variant is
/// the one action per screen, so spend it once. A destructive action never
/// gets the accent — the accent means "the thing to do", and deleting is not
/// it — so `destructive` pins the button to the default weight whatever
/// variant is asked for. The confirm that follows it says the verb, not "OK".
#[component]
pub fn AdsButton(
    #[props(default)] variant: ButtonVariant,
    #[props(default)] size: ButtonSize,
    #[props(into, optional)] icon: Option<String>,
    #[props(default = 16u32)] icon_size: u32,
    #[props(optional)] loading: bool,
    #[props(optional)] destructive: bool,
    #[props(default)] button_type: ButtonType,
    #[props(optional)] disabled: bool,
    #[props(into, optional)] class: Option<String>,
    #[props(optional)] onclick: Option<EventHandler<MouseEvent>>,
    children: Element,
) -> Element {
    let weight = if destructive && variant == ButtonVariant::Accent { ButtonVariant::Default } else { variant };
    let merged = crate::class::join(&[
        "btn",
        if weight == ButtonVariant::Accent { "btn-accent" } else { "" },
        if weight == ButtonVariant::Quiet { "btn-quiet" } else { "" },
        if size == ButtonSize::Sm { "btn-sm" } else { "" },
        class.as_deref().unwrap_or(""),
    ]);
    rsx! {
        button {
            class: "{merged}",
            r#type: button_type.as_str(),
            disabled: disabled || loading,
            "aria-busy": if loading { Some("true") } else { None },
            onclick: move |e| { if let Some(h) = &onclick { h.call(e); } },
            if loading {
                span { class: "ads-spinner ads-button__spinner", "aria-hidden": "true" }
            } else if let Some(n) = &icon {
                Icon { name: n.clone(), size: icon_size }
            }
            {children}
        }
    }
}

/// A button that holds only a glyph. Round or square, accent or soft. The
/// label is required: with no words on the face, the name is all a screen
/// reader has.
#[component]
pub fn AdsIconButton(
    #[props(into)] icon: String,
    #[props(into)] label: String,
    #[props(optional)] round: bool,
    #[props(optional)] accent: bool,
    #[props(default = 18u32)] size: u32,
    #[props(default)] button_type: ButtonType,
    #[props(optional)] onclick: Option<EventHandler<MouseEvent>>,
) -> Element {
    let merged = crate::class::join(&[
        "ads-icon-button",
        if round { "is-round" } else { "" },
        if accent { "is-accent" } else { "" },
    ]);
    rsx! {
        button {
            class: "{merged}",
            r#type: button_type.as_str(),
            "aria-label": label,
            onclick: move |e| { if let Some(h) = &onclick { h.call(e); } },
            Icon { name: icon, size }
        }
    }
}

AdsSegmented

rust
AdsSegmented {
    label: "Range",
    options: vec![SegOption::new("24h", "24h"), SegOption::new("7d", "7d"), SegOption::new("30d", "30d")],
    selected: range(),
    onselect: move |v| range.set(v),
}
Source · ads/ads-rs/src/ui/group.rs
rust
//! Segmented control, tabs, and menu.

use std::rc::Rc;

use dioxus::prelude::*;

use crate::icons::Icon;

/// An option for a segmented control or tab row.
#[derive(Clone, PartialEq)]
pub struct SegOption {
    pub label: String,
    pub value: String,
}

impl SegOption {
    #[must_use]
    pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
        Self { label: label.into(), value: value.into() }
    }
}

/// Focus a mounted element, if it has been mounted.
fn focus(el: Option<Rc<MountedData>>) {
    if let Some(el) = el {
        spawn(async move {
            let _ = el.set_focus(true).await;
        });
    }
}

/// A segmented control: two to five mutually exclusive views, all visible at
/// once, in a single pressed track. More than five, or labels that wrap, is a
/// select. The group needs a name because the options alone ("7d", "30d") do
/// not say what they choose between.
#[component]
pub fn AdsSegmented(
    #[props(into)] options: Vec<SegOption>,
    #[props(into)] label: String,
    #[props(into, optional)] selected: Option<String>,
    #[props(optional)] onselect: Option<EventHandler<String>>,
) -> Element {
    rsx! {
        div { class: "ads-segmented", role: "group", "aria-label": "{label}",
            for o in &options {
                button {
                    r#type: "button",
                    class: if selected.as_deref() == Some(o.value.as_str()) { "is-on" } else { "" },
                    "aria-pressed": if selected.as_deref() == Some(o.value.as_str()) { "true" } else { "false" },
                    onclick: {
                        let v = o.value.clone();
                        move |_| { if let Some(h) = &onselect { h.call(v.clone()); } }
                    },
                    "{o.label}"
                }
            }
        }
    }
}

/// Tabs: filtered views of one dataset, where the columns stay put — not
/// navigation between screens, which is the rail's job. A hairline with the
/// active tab underlined by the accent, the accent's only appearance in a nav
/// context. One tab stop for the row; arrow keys move between tabs, as the
/// tablist pattern expects. `controls` is the id of the view the tabs filter.
#[component]
pub fn AdsTabs(
    #[props(into)] tabs: Vec<SegOption>,
    #[props(into)] label: String,
    #[props(into, optional)] controls: Option<String>,
    #[props(into, optional)] selected: Option<String>,
    #[props(optional)] onselect: Option<EventHandler<String>>,
) -> Element {
    let mut mounted = use_signal(Vec::<Option<Rc<MountedData>>>::new);
    // With nothing selected yet, the first tab keeps the row reachable.
    let stop = tabs
        .iter()
        .position(|t| selected.as_deref() == Some(t.value.as_str()))
        .unwrap_or(0);
    let count = tabs.len();
    let values: Vec<String> = tabs.iter().map(|t| t.value.clone()).collect();

    rsx! {
        div {
            class: "ads-tabs",
            role: "tablist",
            "aria-label": "{label}",
            onkeydown: move |e: KeyboardEvent| {
                if count == 0 {
                    return;
                }
                let last = count - 1;
                let next = match e.key() {
                    Key::ArrowRight => if stop == last { 0 } else { stop + 1 },
                    Key::ArrowLeft => if stop == 0 { last } else { stop - 1 },
                    Key::Home => 0,
                    Key::End => last,
                    _ => return,
                };
                e.prevent_default();
                if let Some(h) = &onselect {
                    h.call(values[next].clone());
                }
                focus(mounted.read().get(next).cloned().flatten());
            },
            for (i, t) in tabs.iter().enumerate() {
                button {
                    key: "{t.value}",
                    r#type: "button",
                    role: "tab",
                    class: if selected.as_deref() == Some(t.value.as_str()) { "is-on" } else { "" },
                    "aria-selected": if selected.as_deref() == Some(t.value.as_str()) { "true" } else { "false" },
                    "aria-controls": controls.clone(),
                    tabindex: if i == stop { "0" } else { "-1" },
                    onmounted: move |e: MountedEvent| {
                        let mut m = mounted.write();
                        if m.len() <= i {
                            m.resize(i + 1, None);
                        }
                        m[i] = Some(e.data());
                    },
                    onclick: {
                        let v = t.value.clone();
                        move |_| { if let Some(h) = &onselect { h.call(v.clone()); } }
                    },
                    "{t.label}"
                }
            }
        }
    }
}

/// A menu item. A divider is a rule between groups of items.
#[derive(Clone, PartialEq)]
pub struct MenuItem {
    pub label: String,
    pub icon: Option<String>,
    pub shortcut: Option<String>,
    pub destructive: bool,
    pub divider: bool,
}

impl MenuItem {
    #[must_use]
    pub fn item(label: impl Into<String>) -> Self {
        Self { label: label.into(), icon: None, shortcut: None, destructive: false, divider: false }
    }
    #[must_use]
    pub fn with_icon(label: impl Into<String>, icon: impl Into<String>) -> Self {
        Self { icon: Some(icon.into()), ..Self::item(label) }
    }
    #[must_use]
    pub fn divider() -> Self {
        Self { divider: true, ..Self::item(String::new()) }
    }
    /// Show a keyboard shortcut beside the item.
    #[must_use]
    pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
        self.shortcut = Some(keys.into());
        self
    }
    /// Mark the item destructive; the menu sets it last, below a rule.
    #[must_use]
    pub const fn destructive(mut self) -> Self {
        self.destructive = true;
        self
    }
}

/// A menu: everything a reader can do to one record, in one place.
/// Destructive items always go last, below a rule — the menu sorts them there
/// itself, so a "Delete" can never land first under the pointer. Shortcuts
/// sit beside their item in the data face, because a menu is how a reader
/// learns them. `onaction` receives the chosen item's label.
#[component]
pub fn AdsMenu(
    #[props(into)] items: Vec<MenuItem>,
    #[props(into)] label: String,
    #[props(optional)] onaction: Option<EventHandler<String>>,
) -> Element {
    // Ordinary items keep their order and their rules; destructive ones are
    // lifted out and set after a single rule of their own.
    let mut safe: Vec<MenuItem> = items.iter().filter(|i| !i.destructive).cloned().collect();
    while safe.last().is_some_and(|i| i.divider) {
        safe.pop();
    }
    let danger: Vec<MenuItem> = items.iter().filter(|i| i.destructive && !i.divider).cloned().collect();
    let rule = !safe.is_empty() && !danger.is_empty();

    // Each button's place in reading order, rules skipped, for arrow-key focus.
    let mut n = 0usize;
    let safe_rows: Vec<(MenuItem, Option<usize>)> = safe
        .into_iter()
        .map(|item| {
            let idx = (!item.divider).then(|| {
                n += 1;
                n - 1
            });
            (item, idx)
        })
        .collect();
    let first_danger = n;
    let count = n + danger.len();

    let mounted = use_signal(Vec::<Option<Rc<MountedData>>>::new);
    let at = use_signal(|| None::<usize>);

    rsx! {
        div {
            class: "ads-menu",
            role: "menu",
            "aria-label": "{label}",
            // Arrow keys walk the items, as they do in every native menu.
            onkeydown: move |e: KeyboardEvent| {
                if count == 0 {
                    return;
                }
                let last = count - 1;
                let cur = at();
                let next = match e.key() {
                    Key::ArrowDown => cur.map_or(0, |c| if c == last { 0 } else { c + 1 }),
                    Key::ArrowUp => cur.map_or(last, |c| if c == 0 { last } else { c - 1 }),
                    Key::Home => 0,
                    Key::End => last,
                    _ => return,
                };
                e.prevent_default();
                focus(mounted.read().get(next).cloned().flatten());
            },
            for (item, idx) in safe_rows.iter() {
                if let Some(i) = idx {
                    {menu_button(item, *i, mounted, at, onaction)}
                } else {
                    hr {}
                }
            }
            if rule { hr {} }
            for (j, item) in danger.iter().enumerate() {
                {menu_button(item, first_danger + j, mounted, at, onaction)}
            }
        }
    }
}

fn menu_button(
    item: &MenuItem,
    i: usize,
    mut mounted: Signal<Vec<Option<Rc<MountedData>>>>,
    mut at: Signal<Option<usize>>,
    onaction: Option<EventHandler<String>>,
) -> Element {
    let l = item.label.clone();
    rsx! {
        button {
            r#type: "button",
            role: "menuitem",
            onmounted: move |e: MountedEvent| {
                let mut m = mounted.write();
                if m.len() <= i {
                    m.resize(i + 1, None);
                }
                m[i] = Some(e.data());
            },
            onfocus: move |_| at.set(Some(i)),
            onclick: move |_| { if let Some(h) = &onaction { h.call(l.clone()); } },
            if let Some(ic) = &item.icon { Icon { name: ic.clone(), size: 15 } }
            "{item.label}"
            if let Some(s) = &item.shortcut { span { class: "shortcut", "{s}" } }
        }
    }
}

AdsMenu

rust
AdsMenu {
    label: "Row actions",
    items: vec![
        MenuItem::with_icon("Duplicate", "layers").shortcut("⌘D"),
        MenuItem::with_icon("Delete", "alert").destructive(),
    ],
    onaction: move |label: String| run(label),
}
Source · ads/ads-rs/src/ui/group.rs
rust
//! Segmented control, tabs, and menu.

use std::rc::Rc;

use dioxus::prelude::*;

use crate::icons::Icon;

/// An option for a segmented control or tab row.
#[derive(Clone, PartialEq)]
pub struct SegOption {
    pub label: String,
    pub value: String,
}

impl SegOption {
    #[must_use]
    pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
        Self { label: label.into(), value: value.into() }
    }
}

/// Focus a mounted element, if it has been mounted.
fn focus(el: Option<Rc<MountedData>>) {
    if let Some(el) = el {
        spawn(async move {
            let _ = el.set_focus(true).await;
        });
    }
}

/// A segmented control: two to five mutually exclusive views, all visible at
/// once, in a single pressed track. More than five, or labels that wrap, is a
/// select. The group needs a name because the options alone ("7d", "30d") do
/// not say what they choose between.
#[component]
pub fn AdsSegmented(
    #[props(into)] options: Vec<SegOption>,
    #[props(into)] label: String,
    #[props(into, optional)] selected: Option<String>,
    #[props(optional)] onselect: Option<EventHandler<String>>,
) -> Element {
    rsx! {
        div { class: "ads-segmented", role: "group", "aria-label": "{label}",
            for o in &options {
                button {
                    r#type: "button",
                    class: if selected.as_deref() == Some(o.value.as_str()) { "is-on" } else { "" },
                    "aria-pressed": if selected.as_deref() == Some(o.value.as_str()) { "true" } else { "false" },
                    onclick: {
                        let v = o.value.clone();
                        move |_| { if let Some(h) = &onselect { h.call(v.clone()); } }
                    },
                    "{o.label}"
                }
            }
        }
    }
}

/// Tabs: filtered views of one dataset, where the columns stay put — not
/// navigation between screens, which is the rail's job. A hairline with the
/// active tab underlined by the accent, the accent's only appearance in a nav
/// context. One tab stop for the row; arrow keys move between tabs, as the
/// tablist pattern expects. `controls` is the id of the view the tabs filter.
#[component]
pub fn AdsTabs(
    #[props(into)] tabs: Vec<SegOption>,
    #[props(into)] label: String,
    #[props(into, optional)] controls: Option<String>,
    #[props(into, optional)] selected: Option<String>,
    #[props(optional)] onselect: Option<EventHandler<String>>,
) -> Element {
    let mut mounted = use_signal(Vec::<Option<Rc<MountedData>>>::new);
    // With nothing selected yet, the first tab keeps the row reachable.
    let stop = tabs
        .iter()
        .position(|t| selected.as_deref() == Some(t.value.as_str()))
        .unwrap_or(0);
    let count = tabs.len();
    let values: Vec<String> = tabs.iter().map(|t| t.value.clone()).collect();

    rsx! {
        div {
            class: "ads-tabs",
            role: "tablist",
            "aria-label": "{label}",
            onkeydown: move |e: KeyboardEvent| {
                if count == 0 {
                    return;
                }
                let last = count - 1;
                let next = match e.key() {
                    Key::ArrowRight => if stop == last { 0 } else { stop + 1 },
                    Key::ArrowLeft => if stop == 0 { last } else { stop - 1 },
                    Key::Home => 0,
                    Key::End => last,
                    _ => return,
                };
                e.prevent_default();
                if let Some(h) = &onselect {
                    h.call(values[next].clone());
                }
                focus(mounted.read().get(next).cloned().flatten());
            },
            for (i, t) in tabs.iter().enumerate() {
                button {
                    key: "{t.value}",
                    r#type: "button",
                    role: "tab",
                    class: if selected.as_deref() == Some(t.value.as_str()) { "is-on" } else { "" },
                    "aria-selected": if selected.as_deref() == Some(t.value.as_str()) { "true" } else { "false" },
                    "aria-controls": controls.clone(),
                    tabindex: if i == stop { "0" } else { "-1" },
                    onmounted: move |e: MountedEvent| {
                        let mut m = mounted.write();
                        if m.len() <= i {
                            m.resize(i + 1, None);
                        }
                        m[i] = Some(e.data());
                    },
                    onclick: {
                        let v = t.value.clone();
                        move |_| { if let Some(h) = &onselect { h.call(v.clone()); } }
                    },
                    "{t.label}"
                }
            }
        }
    }
}

/// A menu item. A divider is a rule between groups of items.
#[derive(Clone, PartialEq)]
pub struct MenuItem {
    pub label: String,
    pub icon: Option<String>,
    pub shortcut: Option<String>,
    pub destructive: bool,
    pub divider: bool,
}

impl MenuItem {
    #[must_use]
    pub fn item(label: impl Into<String>) -> Self {
        Self { label: label.into(), icon: None, shortcut: None, destructive: false, divider: false }
    }
    #[must_use]
    pub fn with_icon(label: impl Into<String>, icon: impl Into<String>) -> Self {
        Self { icon: Some(icon.into()), ..Self::item(label) }
    }
    #[must_use]
    pub fn divider() -> Self {
        Self { divider: true, ..Self::item(String::new()) }
    }
    /// Show a keyboard shortcut beside the item.
    #[must_use]
    pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
        self.shortcut = Some(keys.into());
        self
    }
    /// Mark the item destructive; the menu sets it last, below a rule.
    #[must_use]
    pub const fn destructive(mut self) -> Self {
        self.destructive = true;
        self
    }
}

/// A menu: everything a reader can do to one record, in one place.
/// Destructive items always go last, below a rule — the menu sorts them there
/// itself, so a "Delete" can never land first under the pointer. Shortcuts
/// sit beside their item in the data face, because a menu is how a reader
/// learns them. `onaction` receives the chosen item's label.
#[component]
pub fn AdsMenu(
    #[props(into)] items: Vec<MenuItem>,
    #[props(into)] label: String,
    #[props(optional)] onaction: Option<EventHandler<String>>,
) -> Element {
    // Ordinary items keep their order and their rules; destructive ones are
    // lifted out and set after a single rule of their own.
    let mut safe: Vec<MenuItem> = items.iter().filter(|i| !i.destructive).cloned().collect();
    while safe.last().is_some_and(|i| i.divider) {
        safe.pop();
    }
    let danger: Vec<MenuItem> = items.iter().filter(|i| i.destructive && !i.divider).cloned().collect();
    let rule = !safe.is_empty() && !danger.is_empty();

    // Each button's place in reading order, rules skipped, for arrow-key focus.
    let mut n = 0usize;
    let safe_rows: Vec<(MenuItem, Option<usize>)> = safe
        .into_iter()
        .map(|item| {
            let idx = (!item.divider).then(|| {
                n += 1;
                n - 1
            });
            (item, idx)
        })
        .collect();
    let first_danger = n;
    let count = n + danger.len();

    let mounted = use_signal(Vec::<Option<Rc<MountedData>>>::new);
    let at = use_signal(|| None::<usize>);

    rsx! {
        div {
            class: "ads-menu",
            role: "menu",
            "aria-label": "{label}",
            // Arrow keys walk the items, as they do in every native menu.
            onkeydown: move |e: KeyboardEvent| {
                if count == 0 {
                    return;
                }
                let last = count - 1;
                let cur = at();
                let next = match e.key() {
                    Key::ArrowDown => cur.map_or(0, |c| if c == last { 0 } else { c + 1 }),
                    Key::ArrowUp => cur.map_or(last, |c| if c == 0 { last } else { c - 1 }),
                    Key::Home => 0,
                    Key::End => last,
                    _ => return,
                };
                e.prevent_default();
                focus(mounted.read().get(next).cloned().flatten());
            },
            for (item, idx) in safe_rows.iter() {
                if let Some(i) = idx {
                    {menu_button(item, *i, mounted, at, onaction)}
                } else {
                    hr {}
                }
            }
            if rule { hr {} }
            for (j, item) in danger.iter().enumerate() {
                {menu_button(item, first_danger + j, mounted, at, onaction)}
            }
        }
    }
}

fn menu_button(
    item: &MenuItem,
    i: usize,
    mut mounted: Signal<Vec<Option<Rc<MountedData>>>>,
    mut at: Signal<Option<usize>>,
    onaction: Option<EventHandler<String>>,
) -> Element {
    let l = item.label.clone();
    rsx! {
        button {
            r#type: "button",
            role: "menuitem",
            onmounted: move |e: MountedEvent| {
                let mut m = mounted.write();
                if m.len() <= i {
                    m.resize(i + 1, None);
                }
                m[i] = Some(e.data());
            },
            onfocus: move |_| at.set(Some(i)),
            onclick: move |_| { if let Some(h) = &onaction { h.call(l.clone()); } },
            if let Some(ic) = &item.icon { Icon { name: ic.clone(), size: 15 } }
            "{item.label}"
            if let Some(s) = &item.shortcut { span { class: "shortcut", "{s}" } }
        }
    }
}

AdsThemeToggle

rust
AdsThemeToggle { dark: dark(), onclick: move |_| dark.toggle() }
Source · ads/ads-rs/src/ui/brand.rs
rust
//! The Artelier mark and the light/dark toggle.

use dioxus::prelude::*;

/// The mark: a lowercase a in a high-contrast display cut — hairline entry
/// stroke and bowl top, a heavy bowl and stem, and a hairline tail. One filled
/// path in currentColor, so it follows the theme like the text. A bare `svg`,
/// identical to the site's canonical mark; the name, where it is needed, is
/// `Wordmark` beside it.
///
/// The hairlines are about 1% of the glyph's height, so below roughly 24px
/// they disappear and the mark becomes its heavy strokes alone. That is
/// expected: a favicon will not show them either.
#[component]
pub fn BrandMark(
    #[props(default = 32u32)] size: u32,
    #[props(into, optional)] class: Option<String>,
) -> Element {
    rsx! {
        svg {
            class: class,
            width: "{size}",
            height: "{size}",
            view_box: "38 31 350 350",
            fill: "currentColor",
            "aria-hidden": "true",
            "focusable": "false",
            path { fill_rule: "evenodd", d: MARK }
        }
    }
}

const MARK: &str = "M 169.5 37 C 113.2 41.7 75.3 80.4 75.7 133 C 75.9 156 81.7 174 94.5 190.7 C 97.5 194.7 100 198.2 100 198.5 C 100 198.7 97.6 200.2 94.8 201.6 C 62.7 217.7 46.3 250.3 49.9 290.7 C 54.8 345.1 90.8 374.6 152 374.4 C 179.5 374.4 195 370.1 212.5 357.6 C 234.1 342.1 253.8 307.7 259.3 275.5 C 260.7 267.5 260.7 267.6 261.2 294 C 261.6 317.7 261.9 321.3 263.9 328 C 272.2 355.7 293.5 373.3 320.5 374.7 C 352.3 376.4 373.2 359.7 378 329 C 379.4 319.8 379.3 315 377.6 315 C 376.7 315 376.1 317.2 375.6 322.6 C 373.6 345.1 360.7 364.5 347 365.8 C 332.2 367.2 322.6 360.6 316.9 345 L 314.6 338.5 313.9 237 C 313.4 147.9 313.1 134.6 311.7 128 C 299.6 73.3 259.4 41.9 195 37 C 187.6 36.5 180.8 36.1 180 36.1 C 179.2 36.2 174.5 36.6 169.5 37 M 165 40.7 C 111.8 48.8 78.5 84 78.7 132 C 78.7 155.4 86 175.1 101.3 193.3 L 103.7 196.1 115.3 192.6 C 143.5 184.1 155.5 182.8 212.3 182.3 L 261 181.8 261 146.7 C 261 110.1 260.4 102 257 92 C 245.5 57.9 218.6 39.8 180 40.1 C 173.1 40.2 166.4 40.4 165 40.7 M 179.5 186.1 C 122 193.5 95.3 229.2 100.1 292.4 C 103.9 342.7 126.9 367.3 170 367.2 C 191.3 367.1 205 361.3 220 346.1 C 241.8 324.2 253.9 296.8 258 260.5 C 259.2 249.9 260.9 211 261 194.8 L 261 185 223.8 185.1 C 203.3 185.2 183.4 185.7 179.5 186.1";

/// The theme switch. One glyph that changes shape rather than two icons that
/// swap: the sun's rays draw in, and a second disc slides across the body to
/// carve it into a crescent. Pass `dark` when the dark theme is showing; the
/// name says what pressing it will do, and flipping `data-theme` on the root
/// is the caller's job. Two toggles can be on screen at once (app bar and
/// menu sheet), so give each its own `id` to keep the mask ids unique.
#[component]
pub fn AdsThemeToggle(
    #[props(optional)] dark: bool,
    #[props(into, default = "ads-theme-moon".to_string())] id: String,
    #[props(optional)] onclick: Option<EventHandler<MouseEvent>>,
) -> Element {
    let class = crate::class::join(&["ads-theme-toggle", if dark { "is-dark" } else { "" }]);
    rsx! {
        button {
            class: "{class}",
            r#type: "button",
            "aria-label": if dark { "Switch to light theme" } else { "Switch to dark theme" },
            title: if dark { "Light theme" } else { "Dark theme" },
            onclick: move |e| { if let Some(h) = &onclick { h.call(e); } },
            svg { class: "ads-theme-toggle__svg", width: "20", height: "20", view_box: "0 0 24 24", "aria-hidden": "true",
                mask { id: "{id}",
                    rect { width: "24", height: "24", fill: "#fff" }
                    circle { class: "ads-theme-toggle__bite", cx: "24", cy: "2", r: "7", fill: "#000" }
                }
                circle { class: "ads-theme-toggle__body", cx: "12", cy: "12", r: "5", fill: "currentColor", mask: "url(#{id})" }
                g { class: "ads-theme-toggle__rays", stroke: "currentColor", stroke_width: "1.6", stroke_linecap: "round",
                    path { d: "M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M5.3 18.7l1.4-1.4M17.3 6.7l1.4-1.4" }
                }
            }
        }
    }
}

BrandMark

rust
BrandMark { size: 40 }
Source · ads/ads-rs/src/ui/brand.rs
rust
//! The Artelier mark and the light/dark toggle.

use dioxus::prelude::*;

/// The mark: a lowercase a in a high-contrast display cut — hairline entry
/// stroke and bowl top, a heavy bowl and stem, and a hairline tail. One filled
/// path in currentColor, so it follows the theme like the text. A bare `svg`,
/// identical to the site's canonical mark; the name, where it is needed, is
/// `Wordmark` beside it.
///
/// The hairlines are about 1% of the glyph's height, so below roughly 24px
/// they disappear and the mark becomes its heavy strokes alone. That is
/// expected: a favicon will not show them either.
#[component]
pub fn BrandMark(
    #[props(default = 32u32)] size: u32,
    #[props(into, optional)] class: Option<String>,
) -> Element {
    rsx! {
        svg {
            class: class,
            width: "{size}",
            height: "{size}",
            view_box: "38 31 350 350",
            fill: "currentColor",
            "aria-hidden": "true",
            "focusable": "false",
            path { fill_rule: "evenodd", d: MARK }
        }
    }
}

const MARK: &str = "M 169.5 37 C 113.2 41.7 75.3 80.4 75.7 133 C 75.9 156 81.7 174 94.5 190.7 C 97.5 194.7 100 198.2 100 198.5 C 100 198.7 97.6 200.2 94.8 201.6 C 62.7 217.7 46.3 250.3 49.9 290.7 C 54.8 345.1 90.8 374.6 152 374.4 C 179.5 374.4 195 370.1 212.5 357.6 C 234.1 342.1 253.8 307.7 259.3 275.5 C 260.7 267.5 260.7 267.6 261.2 294 C 261.6 317.7 261.9 321.3 263.9 328 C 272.2 355.7 293.5 373.3 320.5 374.7 C 352.3 376.4 373.2 359.7 378 329 C 379.4 319.8 379.3 315 377.6 315 C 376.7 315 376.1 317.2 375.6 322.6 C 373.6 345.1 360.7 364.5 347 365.8 C 332.2 367.2 322.6 360.6 316.9 345 L 314.6 338.5 313.9 237 C 313.4 147.9 313.1 134.6 311.7 128 C 299.6 73.3 259.4 41.9 195 37 C 187.6 36.5 180.8 36.1 180 36.1 C 179.2 36.2 174.5 36.6 169.5 37 M 165 40.7 C 111.8 48.8 78.5 84 78.7 132 C 78.7 155.4 86 175.1 101.3 193.3 L 103.7 196.1 115.3 192.6 C 143.5 184.1 155.5 182.8 212.3 182.3 L 261 181.8 261 146.7 C 261 110.1 260.4 102 257 92 C 245.5 57.9 218.6 39.8 180 40.1 C 173.1 40.2 166.4 40.4 165 40.7 M 179.5 186.1 C 122 193.5 95.3 229.2 100.1 292.4 C 103.9 342.7 126.9 367.3 170 367.2 C 191.3 367.1 205 361.3 220 346.1 C 241.8 324.2 253.9 296.8 258 260.5 C 259.2 249.9 260.9 211 261 194.8 L 261 185 223.8 185.1 C 203.3 185.2 183.4 185.7 179.5 186.1";

/// The theme switch. One glyph that changes shape rather than two icons that
/// swap: the sun's rays draw in, and a second disc slides across the body to
/// carve it into a crescent. Pass `dark` when the dark theme is showing; the
/// name says what pressing it will do, and flipping `data-theme` on the root
/// is the caller's job. Two toggles can be on screen at once (app bar and
/// menu sheet), so give each its own `id` to keep the mask ids unique.
#[component]
pub fn AdsThemeToggle(
    #[props(optional)] dark: bool,
    #[props(into, default = "ads-theme-moon".to_string())] id: String,
    #[props(optional)] onclick: Option<EventHandler<MouseEvent>>,
) -> Element {
    let class = crate::class::join(&["ads-theme-toggle", if dark { "is-dark" } else { "" }]);
    rsx! {
        button {
            class: "{class}",
            r#type: "button",
            "aria-label": if dark { "Switch to light theme" } else { "Switch to dark theme" },
            title: if dark { "Light theme" } else { "Dark theme" },
            onclick: move |e| { if let Some(h) = &onclick { h.call(e); } },
            svg { class: "ads-theme-toggle__svg", width: "20", height: "20", view_box: "0 0 24 24", "aria-hidden": "true",
                mask { id: "{id}",
                    rect { width: "24", height: "24", fill: "#fff" }
                    circle { class: "ads-theme-toggle__bite", cx: "24", cy: "2", r: "7", fill: "#000" }
                }
                circle { class: "ads-theme-toggle__body", cx: "12", cy: "12", r: "5", fill: "currentColor", mask: "url(#{id})" }
                g { class: "ads-theme-toggle__rays", stroke: "currentColor", stroke_width: "1.6", stroke_linecap: "round",
                    path { d: "M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M5.3 18.7l1.4-1.4M17.3 6.7l1.4-1.4" }
                }
            }
        }
    }
}