//! 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}" } }
}
}
}
//! 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}" } }
}
}
}
//! 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" }
}
}
}
}
}