pib-components-dx-base 0.0.0-upstream-snapshot.2026-09-19.1

temporary fork for Politik im Blick - Dioxus components
Documentation
use dioxus::prelude::*;
use dioxus_icons::lucide::{Check, ChevronDown};
use dioxus_primitives::select as primitive_select;

use crate::components::avatar::{AvatarImageSize, AvatarShape, ImageAvatar};
use crate::components::button::{Button, ButtonVariant};
use crate::components::item::{
    Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemMediaVariant, ItemTitle,
};
use crate::components::tabs::component::{TabList, TabTrigger, Tabs};
use crate::components::virtual_list::VirtualList;
use crate::dashboard::common::{
    lookup_message, IconKind, LucideIcon, MessageState, MessageStateStoreExt, MessageTag, TabId,
    LOREM_IPSUM, TABS,
};

use super::avatars::avatar_profile_for_key;
use super::state::{EmailClientState, EmailClientStateStoreExt, EmailClientStateStoreImplExt};

#[css_module("/src/components/select/style.css")]
struct SelectStyles;

#[derive(Clone, PartialEq)]
pub(super) enum ListRow {
    DayHeader(&'static str),
    Message(String),
}

pub(super) fn flatten_rows(state: Store<EmailClientState>, message_ids: &[String]) -> Vec<ListRow> {
    let messages_store = state.messages();
    let messages = messages_store.read();
    let mut out = Vec::with_capacity(message_ids.len() + 4);
    let mut last_day: Option<&'static str> = None;
    for uid in message_ids {
        let Some(message) = messages.get(uid.as_str()) else {
            continue;
        };
        let day = lookup_message(message.source_index).day;
        if last_day != Some(day) {
            out.push(ListRow::DayHeader(day));
            last_day = Some(day);
        }
        out.push(ListRow::Message(uid.clone()));
    }
    out
}

#[component]
pub(super) fn ListPane(
    mut state: Store<EmailClientState>,
    visible_ids: ReadSignal<Vec<String>>,
    selected_uid: ReadSignal<Option<String>>,
) -> Element {
    let rows = use_memo(move || flatten_rows(state, &visible_ids.read()));
    let row_count = rows.read().len();
    let query = state.search_query().cloned();
    let tags = state.selected_tags().cloned();

    rsx! {
        section { class: "ec-list-pane",
            div { class: "ec-list-toolbar",
                Tabs {
                    class: "ec-mail-tabs",
                    default_value: TabId::All.as_str().to_string(),
                    horizontal: true,
                    on_value_change: move |v: String| {
                        if let Some(tab) = TabId::from_str(&v) {
                            state.set_active_tab(tab);
                        }
                    },
                    TabList { class: "ec-mail-tabs-list",
                        for (idx, tab) in TABS.iter().enumerate() {
                            TabTrigger {
                                class: "ec-mail-tab",
                                key: "{tab.id.as_str()}",
                                value: tab.id.as_str().to_string(),
                                index: idx,
                                {tab.label}
                                span { class: "ec-muted",
                                    " {state.tab_count(tab.id, query.as_str(), &tags)}"
                                }
                            }
                        }
                    }
                }
                primitive_select::SelectMulti::<MessageTag> {
                    class: SelectStyles::dx_select,
                    values: Some(tags.clone()),
                    default_values: vec![],
                    on_values_change: move |values| {
                        state.set_selected_tags(values);
                    },
                    primitive_select::SelectTrigger {
                        class: format!("{} ec-filter-trigger", SelectStyles::dx_select_trigger),
                        aria_label: "Filter by tag",
                        LucideIcon { kind: IconKind::Filter }
                        if !tags.is_empty() {
                            span { class: "ec-filter-count", "{tags.len()}" }
                        }
                        ChevronDown {
                            class: "dx-select-expand-icon",
                            size: "20px",
                            stroke: "var(--primary-color-7)",
                        }
                    }
                    primitive_select::SelectList {
                        class: format!("{} ec-filter-list", SelectStyles::dx_select_list),
                        aria_label: "Filter by tag",
                        primitive_select::SelectGroup {
                            primitive_select::SelectGroupLabel { class: SelectStyles::dx_select_group_label, "Tags" }
                            for (index, tag) in MessageTag::ALL.iter().enumerate() {
                                primitive_select::SelectOption::<MessageTag> {
                                    class: SelectStyles::dx_select_option,
                                    key: "{tag.label()}",
                                    index,
                                    value: *tag,
                                    text_value: "{tag.label()}",
                                    {tag.label()}
                                    primitive_select::SelectItemIndicator {
                                        Check {
                                            size: "1rem",
                                            stroke: "var(--secondary-color-5)",
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }

            VirtualList {
                class: "ec-list-scroll",
                count: row_count,
                buffer: 6usize,
                estimate_size: move |idx: usize| match &rows.read()[idx] {
                    ListRow::DayHeader(_) => 34,
                    ListRow::Message(_) => 130,
                },
                render_item: move |idx: usize| {
                    let row = rows.read()[idx].clone();
                    match row {
                        ListRow::DayHeader(day) => rsx! {
                            div { class: "ec-day", {day} }
                        },
                        ListRow::Message(uid) => {
                            match state.messages().get(uid.clone()) {
                                Some(message) => rsx! {
                                    MessageRow {
                                        key: "{uid}",
                                        state,
                                        message,
                                        selected_uid: selected_uid.read().clone(),
                                    }
                                },
                                None => rsx! {},
                            }
                        }
                    }
                },
            }
        }
    }
}

#[component]
fn MessageRow(
    mut state: Store<EmailClientState>,
    message: Store<MessageState>,
    selected_uid: Option<String>,
) -> Element {
    let uid = message.uid().cloned();
    let is_selected = selected_uid.as_deref() == Some(uid.as_str());
    let uid_for_click = uid.clone();
    let uid_for_key = uid.clone();
    let uid_for_trash = uid.clone();
    let uid_for_star = uid.clone();
    let m = lookup_message(message.source_index().cloned());
    let starred = message.starred().cloned();
    let unread = message.unread().cloned();
    let flagged = message.flagged().cloned();
    let tags = message.tags().cloned();
    let mut classes = String::from("ec-row");
    if unread {
        classes.push_str(" ec-unread");
    }
    if starred {
        classes.push_str(" ec-starred");
    }
    if flagged {
        classes.push_str(" ec-flagged");
    }

    rsx! {
        Item {
            class: classes,
            role: "button",
            tabindex: 0,
            onclick: move |_| {
                state.select_message(uid_for_click.clone());
            },
            onkeydown: move |event: KeyboardEvent| {
                let key = event.key().to_string();
                if key == "Enter" || key == " " {
                    event.prevent_default();
                    state.select_message(uid_for_key.clone());
                }
            },
            "data-selected": is_selected,

            ItemMedia { variant: ItemMediaVariant::Icon,
                ImageAvatar {
                    size: AvatarImageSize::Small,
                    shape: AvatarShape::Circle,
                    src: "{avatar_profile_for_key(m.sender.addr).src}",
                    alt: "{m.sender.name}",
                    {m.sender.initials}
                }
            }
            ItemContent {
                ItemTitle {
                    span { class: "ec-row-subject", "{m.subject}" }
                }
                div { class: "ec-row-sender",
                    span { class: "ec-row-from", {m.sender.name} }
                }
                ItemDescription { class: "ec-row-snippet", {LOREM_IPSUM} }
                if !tags.is_empty() || m.has_attachment || flagged {
                    div { class: "ec-muted ec-row-tags",
                        if flagged {
                            LucideIcon { kind: IconKind::Flag, size: 12 }
                        }
                        for (i, tag) in tags.iter().enumerate() {
                            span { key: "{tag.label()}",
                                if i > 0 {
                                    " ยท "
                                }
                                {tag.label()}
                            }
                        }
                        if m.has_attachment {
                            LucideIcon { kind: IconKind::Paperclip, size: 12 }
                        }
                    }
                }
            }
            ItemActions {
                span { class: "ec-muted ec-row-time", "{m.time}" }
                div { class: "ec-row-action-group",
                    Button {
                        variant: ButtonVariant::Ghost,
                        r#type: "button",
                        class: "ec-row-action ec-row-action-trash",
                        aria_label: "Move to trash",
                        onkeydown: move |e: KeyboardEvent| e.stop_propagation(),
                        onclick: move |e: Event<MouseData>| {
                            e.stop_propagation();
                            state.move_message_to_trash(uid_for_trash.clone());
                        },
                        LucideIcon { kind: IconKind::Trash, size: 16 }
                    }
                    Button {
                        variant: ButtonVariant::Ghost,
                        r#type: "button",
                        class: "ec-row-action ec-row-action-star",
                        "data-active": starred,
                        aria_label: if starred { "Unstar message" } else { "Star message" },
                        onkeydown: move |e: KeyboardEvent| e.stop_propagation(),
                        onclick: move |e: Event<MouseData>| {
                            e.stop_propagation();
                            state.toggle_message_star(uid_for_star.clone());
                        },
                        LucideIcon {
                            kind: if starred { IconKind::StarFilled } else { IconKind::StarOutline },
                            size: 16,
                        }
                    }
                }
            }
        }
    }
}