Skip to main content

fc_dioxus/components/
list.rs

1//! Rows of one to three lines with optional leading and trailing content, from list.css.
2use crate::{
3    core::{
4        assets,
5        attrs::{availability_attributes, clipped_attributes, component_attributes},
6    },
7    prelude::*,
8    theme::use_stylesheets,
9};
10const BEM: Bem = Bem::new("fc-list");
11fc_modifier! { enum ListRule for BEM { Plain => None, Divided => "divided" } }
12
13/// A native list of [`ListItem`]s.
14#[component]
15pub fn List(
16    #[props(default)] divided: bool,
17    /// Render `<ol>`, when the order is itself information.
18    #[props(default)]
19    ordered: bool,
20    #[props(default)] class: Option<String>,
21    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
22    children: Element,
23) -> Element {
24    let attributes = component_attributes(
25        attributes,
26        ClassList::new(BEM.block())
27            .with_modifier(
28                BEM,
29                if divided {
30                    ListRule::Divided
31                } else {
32                    ListRule::Plain
33                },
34            )
35            .extend(class.as_deref()),
36        &[],
37    );
38    rsx! {
39        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_LIST_CSS } }
40        if ordered { ol { ..attributes, {children} } } else { ul { ..attributes, {children} } }
41    }
42}
43
44/// What activating a list row does.
45#[derive(Debug, Default, Clone, PartialEq)]
46#[non_exhaustive]
47pub enum ListAction {
48    /// The row is static content.
49    #[default]
50    None,
51    /// The headline is a link covering the whole row.
52    Link(String),
53    /// The headline is a button covering the whole row.
54    Button(EventHandler<()>),
55    /// The row is a `<label>` for the control in `trailing`, such as a switch.
56    Label,
57}
58
59/// One row of a [`List`].
60#[component]
61pub fn ListItem(
62    headline: Element,
63    #[props(default)] overline: Option<Element>,
64    #[props(default)] supporting: Option<Element>,
65    /// Supporting text the CSS clamps; truncation is declared and the full text kept
66    /// in `title`. Used instead of `supporting`.
67    #[props(default)]
68    supporting_clipped: Option<Clipped>,
69    /// Decorative leading content: an avatar, an icon. Hidden from assistive technology.
70    #[props(default)]
71    leading: Option<Element>,
72    #[props(default)] trailing: Option<Element>,
73    /// Hide `trailing` from assistive technology, e.g. a chevron.
74    #[props(default)]
75    trailing_decorative: bool,
76    #[props(default)] action: ListAction,
77    /// The current page for a link, or the current item for a button.
78    #[props(default)]
79    current: bool,
80    #[props(default)] availability: Availability,
81    #[props(default)] class: Option<String>,
82    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
83) -> Element {
84    let item = BEM.element("item");
85    let headline_class = ClassList::new(&BEM.element("action"))
86        .with(BEM.element("headline"))
87        .to_string();
88    let blocked = availability != Availability::Enabled;
89    // Links have no disabled attribute, so both kinds of unavailability are ARIA there.
90    let link_state = match &availability {
91        Availability::Disabled => {
92            crate::spread![Attribute::new("aria-disabled", "true", None, false)]
93        }
94        state => availability_attributes(state),
95    };
96    let button_state = availability_attributes(&availability);
97    let headline = match action.clone() {
98        ListAction::Link(href) => {
99            let link = crate::core::link::use_link((!blocked).then_some(href));
100            let current = current || link.current;
101            let href = link.href.clone();
102            rsx! {
103                a { class: headline_class, href, aria_current: current.then_some("page"),
104                    onclick: move |event| { link.follow(&event); }, ..link_state, {headline} }
105            }
106        }
107        ListAction::Button(onclick) => rsx! {
108            button { class: headline_class, r#type: "button", aria_current: current.then_some("true"),
109                onclick: move |_| if !blocked { onclick.call(()); }, ..button_state, {headline} }
110        },
111        ListAction::None | ListAction::Label => {
112            rsx! { span { class: BEM.element("headline"), {headline} } }
113        }
114    };
115    let supporting = match (supporting_clipped, supporting) {
116        (Some(text), _) => {
117            let clipped = clipped_attributes(&text);
118            let shown = text.shown();
119            rsx! { span { class: BEM.element("supporting"), ..clipped, "{shown}" } }
120        }
121        (None, Some(supporting)) => {
122            rsx! { span { class: BEM.element("supporting"), {supporting} } }
123        }
124        (None, None) => rsx! {},
125    };
126    let body = rsx! {
127        if let Some(leading) = leading { span { class: BEM.element("leading"), aria_hidden: "true", {leading} } }
128        span { class: BEM.element("content"),
129            if let Some(overline) = overline { span { class: BEM.element("overline"), {overline} } }
130            {headline}
131            {supporting}
132        }
133        if let Some(trailing) = trailing {
134            span { class: BEM.element("trailing"), aria_hidden: trailing_decorative.then_some("true"), {trailing} }
135        }
136    };
137    if action == ListAction::Label {
138        let attributes = component_attributes(
139            attributes,
140            ClassList::new(&item).extend(class.as_deref()),
141            &[],
142        );
143        rsx! { li { label { ..attributes, {body} } } }
144    } else {
145        let attributes = component_attributes(
146            attributes,
147            ClassList::new(&item).extend(class.as_deref()),
148            &[],
149        );
150        rsx! { li { ..attributes, {body} } }
151    }
152}