Start a project

Artelier UI Kit · Rust · Patterns

Patterns

The compositions that recur in every dashboard.

KpiRow

URLs1,284up+37
Health98target 100
rust
KpiRow {
    stats: vec![
        StatItem::new("1,284", "URLs").delta("+37", DeltaDirection::Up),
        StatItem::new("98", "Health").target("100"),
    ],
}
Source · ads/ads-rs/src/blocks/kpi_row.rs
rust
//! KPI row — the top of nearly every dashboard.

use dioxus::prelude::*;

use crate::ui::{AdsStat, DeltaDirection};

/// One stat tile: the figure and its comparison.
#[derive(Clone, PartialEq)]
pub struct StatItem {
    pub value: String,
    pub label: String,
    pub unit: Option<String>,
    pub note: Option<String>,
    pub delta: Option<String>,
    pub direction: DeltaDirection,
    pub previous: Option<String>,
    pub target: Option<String>,
}

impl StatItem {
    #[must_use]
    pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
        Self {
            value: value.into(),
            label: label.into(),
            unit: None,
            note: None,
            delta: None,
            direction: DeltaDirection::Flat,
            previous: None,
            target: None,
        }
    }
    #[must_use]
    pub fn unit(mut self, unit: impl Into<String>) -> Self {
        self.unit = Some(unit.into());
        self
    }
    #[must_use]
    pub fn note(mut self, note: impl Into<String>) -> Self {
        self.note = Some(note.into());
        self
    }
    /// The change, already formatted ("12.4%"), and which way it went.
    #[must_use]
    pub fn delta(mut self, delta: impl Into<String>, direction: DeltaDirection) -> Self {
        self.delta = Some(delta.into());
        self.direction = direction;
        self
    }
    #[must_use]
    pub fn previous(mut self, previous: impl Into<String>) -> Self {
        self.previous = Some(previous.into());
        self
    }
    #[must_use]
    pub fn target(mut self, target: impl Into<String>) -> Self {
        self.target = Some(target.into());
        self
    }
}

/// The KPI row — the top of nearly every dashboard. Four figures at most,
/// ranked left to right by how often they are the reason someone opened the
/// screen. Each tile carries its comparison (a delta, the previous period or a
/// target) because a figure with nothing to compare it to is trivia.
/// `columns` is 2, 3 or 4.
#[component]
pub fn KpiRow(
    #[props(into)] stats: Vec<StatItem>,
    #[props(default = 4u8)] columns: u8,
) -> Element {
    let class = format!("ads-kpi-row is-cols-{}", columns.clamp(2, 4));
    rsx! {
        div { class: "{class}",
            for s in &stats {
                AdsStat {
                    key: "{s.label}",
                    value: s.value.clone(),
                    label: s.label.clone(),
                    unit: s.unit.clone(),
                    note: s.note.clone(),
                    delta: s.delta.clone(),
                    direction: s.direction,
                    previous: s.previous.clone(),
                    target: s.target.clone(),
                }
            }
        }
    }
}

FilterBar

30d to now
SavedExec summary
rust
FilterBar {
    query: query(),
    onquery: move |q| query.set(q),
    tags: vec!["Exec summary".into()],
    segment: segment(),
    onsegment: move |s| segment.set(s),
}
Source · ads/ads-rs/src/blocks/filter_bar.rs
rust
//! Filter bar & saved views.

use dioxus::prelude::*;

use crate::icons::Icon;
use crate::ui::{AdsSegmented, AdsTag, SegOption};

/// One row, above the data, holding every control that changes what is shown.
/// Saved views turn a repeated set of filters into a named thing a team can
/// share. Serialise the whole bar into the URL, so a view can be bookmarked.
///
/// Every control is named: the search field by `search_label` (its placeholder
/// is an example, never the label), the segmented control by `segments_label`.
/// Each saved view is a tag, and a tag is removable — that is what a tag means.
/// `query` and `segment` are controlled: pass them back from `onquery` and
/// `onsegment`.
#[component]
pub fn FilterBar(
    #[props(into, default = "Filter by URL…".to_string())] placeholder: String,
    #[props(into, default = "Filter by URL".to_string())] search_label: String,
    #[props(into, optional)] query: Option<String>,
    #[props(optional)] onquery: Option<EventHandler<String>>,
    #[props(into, default)] tags: Vec<String>,
    #[props(into, optional)] segments: Option<Vec<String>>,
    #[props(into, default = "All".to_string())] segment: String,
    #[props(into, default = "Status".to_string())] segments_label: String,
    #[props(into, default = "30d".to_string())] from: String,
    #[props(into, default = "now".to_string())] to: String,
    #[props(optional)] onsegment: Option<EventHandler<String>>,
    #[props(optional)] onremovetag: Option<EventHandler<String>>,
    #[props(optional)] onsave: Option<EventHandler<MouseEvent>>,
) -> Element {
    let segments = segments
        .unwrap_or_else(|| vec!["All".into(), "2xx".into(), "4xx".into()])
        .into_iter()
        .map(|s| SegOption::new(s.clone(), s))
        .collect::<Vec<_>>();
    rsx! {
        div { class: "ads-filter-bar stack gap-3",
            div { class: "ads-filter-bar__controls",
                div { class: "ads-search",
                    Icon { name: "compass", size: 16 }
                    input {
                        class: "ads-field",
                        r#type: "search",
                        placeholder: "{placeholder}",
                        "aria-label": "{search_label}",
                        value: query,
                        oninput: move |e| {
                            if let Some(h) = &onquery {
                                h.call(e.value());
                            }
                        },
                    }
                }
                div { class: "ads-daterange",
                    span { class: "num", "{from}" }
                    span { class: "sep", "aria-hidden": "true", "→" }
                    span { class: "sr-only", " to " }
                    span { class: "num", "{to}" }
                }
                AdsSegmented {
                    options: segments,
                    label: segments_label,
                    selected: segment,
                    onselect: move |v: String| {
                        if let Some(h) = &onsegment {
                            h.call(v);
                        }
                    },
                }
            }
            div { class: "ads-filter-bar__saved",
                span { class: "ads-label", "Saved" }
                for t in tags {
                    AdsTag {
                        key: "{t}",
                        label: t.clone(),
                        onremove: move |_| {
                            if let Some(h) = &onremovetag {
                                h.call(t.clone());
                            }
                        },
                    }
                }
                button {
                    class: "btn btn-quiet",
                    r#type: "button",
                    onclick: move |e| {
                        if let Some(h) = &onsave {
                            h.call(e);
                        }
                    },
                    Icon { name: "plus", size: 15 }
                    "Save this view"
                }
            }
        }
    }
}

TableScreen

Results

733 rows
Results
/overview18,240
/pricing9,312
rust
TableScreen {
    title: "Results",
    count: "733 rows",
    tabs: vec!["Internal".into(), "External".into()],
    tab: tab(),
    ontab: move |t| tab.set(t),
    columns: cols,
    rows: rows,
    onselect: move |(row, i): (Vec<String>, usize)| open_drawer(row, i),
}
Source · ads/ads-rs/src/blocks/table_screen.rs
rust
//! Table screen — header, tabs, filter bar, grid, pagination.

use std::rc::Rc;
use std::sync::atomic::{AtomicUsize, Ordering};

use dioxus::prelude::*;

use crate::icons::Icon;
use crate::ui::{AdsDataGrid, GridColumn};

/// The arrangement that carries most of the working hours in any data product:
/// header, tabs, filter bar, grid, pagination.
///
/// The tabs filter the one table below them — they never swap in a different
/// screen — so they are a real tablist whose panel is the grid, with the arrow
/// keys moving between tabs. The header's action is the screen's single
/// accent; pass an empty `action_label` when the page already spends it
/// elsewhere. A selected row is forwarded as `onselect`, for the drawer beside
/// it. `tab` and `query` are controlled: pass them back from `ontab` and
/// `onquery`. `filters` sits beside the search field; `footer` holds the pager.
#[component]
pub fn TableScreen(
    #[props(into)] title: String,
    #[props(into, optional)] count: Option<String>,
    #[props(into, default = "Export CSV".to_string())] action_label: String,
    #[props(into, default)] tabs: Vec<String>,
    #[props(into, default)] tab: String,
    #[props(into)] columns: Vec<GridColumn>,
    #[props(into)] rows: Vec<Vec<String>>,
    #[props(into, default = "Filter by URL…".to_string())] placeholder: String,
    #[props(into, default = "Filter by URL".to_string())] search_label: String,
    #[props(into, optional)] query: Option<String>,
    #[props(optional)] onquery: Option<EventHandler<String>>,
    #[props(optional)] ontab: Option<EventHandler<String>>,
    #[props(optional)] onaction: Option<EventHandler<MouseEvent>>,
    #[props(optional)] onselect: Option<EventHandler<(Vec<String>, usize)>>,
    #[props(default = VNode::empty())] filters: Element,
    #[props(default = VNode::empty())] footer: Element,
) -> Element {
    let uid = use_hook(|| {
        static NEXT: AtomicUsize = AtomicUsize::new(0);
        format!("ads-table-screen-{}", NEXT.fetch_add(1, Ordering::Relaxed))
    });
    let mut mounted = use_signal(Vec::<Option<Rc<MountedData>>>::new);

    let panel_id = format!("{uid}-panel");
    let tab_ids = (0..tabs.len()).map(|i| format!("{uid}-tab-{i}")).collect::<Vec<_>>();
    let selected = tabs.iter().position(|t| *t == tab);
    // With no tab selected, the first keeps the row reachable.
    let stop = selected.unwrap_or(0);
    let has_tabs = !tabs.is_empty();
    let labelled_by = if has_tabs { tab_ids.get(stop).cloned() } else { None };

    // Roving focus: only the selected tab is in the tab order; the arrows move
    // between tabs and select as they go, as a tablist is expected to.
    let keyed_tabs = tabs.clone();
    let on_tab_key = move |e: KeyboardEvent| {
        let n = keyed_tabs.len();
        if n == 0 {
            return;
        }
        let next = match e.key() {
            Key::ArrowRight => (stop + 1) % n,
            Key::ArrowLeft => (stop + n - 1) % n,
            Key::Home => 0,
            Key::End => n - 1,
            _ => return,
        };
        e.prevent_default();
        if let Some(h) = &ontab {
            h.call(keyed_tabs[next].clone());
        }
        if let Some(el) = mounted.read().get(next).cloned().flatten() {
            spawn(async move {
                let _ = el.set_focus(true).await;
            });
        }
    };

    rsx! {
        div { class: "card ads-table-screen",
            header { class: "ads-table-screen__head",
                div { class: "ads-table-screen__heading",
                    h4 { class: "ads-table-screen__title ads-display", "{title}" }
                    if let Some(c) = count.as_deref().filter(|c| !c.is_empty()) {
                        span { class: "dim num", "{c}" }
                    }
                }
                if !action_label.is_empty() {
                    button {
                        class: "btn btn-accent",
                        r#type: "button",
                        onclick: move |e| {
                            if let Some(h) = &onaction {
                                h.call(e);
                            }
                        },
                        "{action_label}"
                    }
                }
            }

            if has_tabs {
                div { class: "ads-tabs", role: "tablist", "aria-label": "{title}", onkeydown: on_tab_key,
                    for (i, t) in tabs.iter().enumerate() {
                        button {
                            key: "{t}",
                            id: "{tab_ids[i]}",
                            r#type: "button",
                            role: "tab",
                            "aria-selected": if selected == Some(i) { "true" } else { "false" },
                            "aria-controls": "{panel_id}",
                            tabindex: if i == stop { "0" } else { "-1" },
                            class: if selected == Some(i) { "is-on" } else { "" },
                            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.clone();
                                move |_| {
                                    if let Some(h) = &ontab {
                                        h.call(v.clone());
                                    }
                                }
                            },
                            "{t}"
                        }
                    }
                }
            }

            div {
                id: "{panel_id}",
                class: "ads-table-screen__panel",
                role: if has_tabs { Some("tabpanel") } else { None },
                "aria-labelledby": labelled_by,
                div { class: "ads-table-screen__controls",
                    div { class: "ads-search",
                        Icon { name: "compass", size: 16 }
                        input {
                            class: "ads-field",
                            r#type: "search",
                            placeholder: "{placeholder}",
                            "aria-label": "{search_label}",
                            value: query,
                            oninput: move |e| {
                                if let Some(h) = &onquery {
                                    h.call(e.value());
                                }
                            },
                        }
                    }
                    {filters}
                }
                AdsDataGrid {
                    columns,
                    rows,
                    caption: title.clone(),
                    onselect: move |sel: (Vec<String>, usize)| {
                        if let Some(h) = &onselect {
                            h.call(sel);
                        }
                    },
                }
            }

            footer { class: "ads-table-screen__foot", {footer} }
        }
    }
}

SummaryDetail

  1. Answer. One ranked list. "Fix the alt text on the blog template — 823 pages, two changes."

  2. Reasoning. An accordion or drawer: why this ranks first, what the score is made of.

  3. Evidence. The grid, filtered to the 823 rows, with the filter visible as a tag.

rust
SummaryDetail {}
Source · ads/ads-rs/src/blocks/summary_detail.rs
rust
//! Summary → detail — the pattern the whole system is arranged around.

use dioxus::prelude::*;

/// One rung of the ladder (answer → reasoning → evidence).
#[derive(Clone, PartialEq)]
pub struct Rung {
    pub title: String,
    pub body: String,
}

impl Rung {
    #[must_use]
    pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
        Self { title: title.into(), body: body.into() }
    }
}

/// Summary → detail: the pattern the whole system is arranged around. A ranked
/// answer at the top, the reasoning one level down, the raw rows two levels
/// down — and the reader never loses their place.
#[component]
pub fn SummaryDetail(
    #[props(into, optional)] rungs: Option<Vec<Rung>>,
) -> Element {
    let rungs = rungs.unwrap_or_else(|| {
        vec![
            Rung::new("Answer.", "One ranked list. \"Fix the alt text on the blog template — 823 pages, two changes.\""),
            Rung::new("Reasoning.", "An accordion or drawer: why this ranks first, what the score is made of."),
            Rung::new("Evidence.", "The grid, filtered to the 823 rows, with the filter visible as a tag."),
        ]
    });
    rsx! {
        ol { class: "ads-summary-detail",
            for (i, r) in rungs.iter().enumerate() {
                li { key: "{i}", class: "ads-summary-detail__rung",
                    span { class: "num ads-summary-detail__step", "aria-hidden": "true", "{i + 1}" }
                    p { class: "ads-summary-detail__text",
                        strong { "{r.title}" }
                        " {r.body}"
                    }
                }
            }
        }
    }
}