Skip to main content

teksilo_widgets/
card.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Card — a surface container with optional header, content, and footer slots.
5//!
6//! `Card` renders an opaque or tinted rounded-rectangle backdrop, an optional
7//! drop shadow, and up to three stacked content slots (header / content /
8//! footer). It is the standard building block for list-item cards, dashboard
9//! tiles, onboarding panels, and any widget that needs a visually distinct
10//! raised or outlined surface. Chrome (shadow, background, corner radius,
11//! padding) is delegated to the active [`CardStyle`](teksilo_core::styles::CardStyle)
12//! so the visual language can be changed per-call (`.style(...)`) or
13//! theme-wide via `theme.style_slots.card`.
14//!
15//! ## When to use
16//!
17//! - `CardVariant::Elevated` — a dashboard tile or list card that should
18//!   "float" above the page surface.
19//! - `CardVariant::Outlined` — a bordered grouping box without shadow.
20//! - `CardVariant::Plain` — the content sits on the default surface; no
21//!   visible chrome (useful for spacing only).
22//!
23//! ## Accessibility
24//!
25//! Announces as `Role::Group`. The slots' own accessibility nodes are
26//! included in the subtree; the card itself carries no additional AT name.
27//!
28//! ```rust
29//! # use teksilo_widgets::Card;
30//! # use teksilo_core::styles::CardVariant;
31//! # use teksilo_widgets::primitives::TextWidget;
32//! # use teksilo_i18n::lit;
33//! let _card = Card::new()
34//!     .variant(CardVariant::Elevated)
35//!     .content(TextWidget::new(lit!("Hello, card!")));
36//! ```
37
38use std::rc::Rc;
39
40use teksilo_canvas::{Point, Rect, Size, SizeProposal};
41use teksilo_core::accessibility::AccessNodeBuilder;
42use teksilo_core::color_prop::ColorProp;
43use teksilo_core::signal::Prop;
44use teksilo_core::styles::{CardStyleConfig, CardVariant, SharedCardStyle};
45use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
46use teksilo_core::widget_id::WidgetId;
47use teksilo_tokens::Shadow;
48
49use crate::primitives::VStack;
50
51/// A card container with shadow, background, and optional header/content/footer.
52pub struct Card {
53    header_id: Option<WidgetId>,
54    content_id: Option<WidgetId>,
55    footer_id: Option<WidgetId>,
56    pending_header: Option<PendingChild>,
57    pending_content: Option<PendingChild>,
58    pending_footer: Option<PendingChild>,
59    shadow: Option<Shadow>,
60    background: Option<ColorProp>,
61    corner_radius: Option<Prop<f32>>,
62    padding: Option<Prop<f32>>,
63    variant: CardVariant,
64    style_override: Option<SharedCardStyle>,
65    root_child_id: Option<WidgetId>,
66}
67
68impl std::fmt::Debug for Card {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        f.debug_struct("Card")
71            .field("variant", &self.variant)
72            .finish()
73    }
74}
75
76impl Card {
77    /// Construct an empty card with no slots and the default `CardVariant::Plain`.
78    pub fn new() -> Self {
79        Self {
80            header_id: None,
81            content_id: None,
82            footer_id: None,
83            pending_header: None,
84            pending_content: None,
85            pending_footer: None,
86            shadow: None,
87            background: None,
88            corner_radius: None,
89            padding: None,
90            variant: CardVariant::default(),
91            style_override: None,
92            root_child_id: None,
93        }
94    }
95
96    /// Set the header slot (topmost section) to an inline widget.
97    pub fn header(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
98        self.pending_header = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
99        self
100    }
101
102    /// Set the main content slot (middle section) to an inline widget.
103    pub fn content(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
104        self.pending_content = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
105        self
106    }
107
108    /// Set the footer slot (bottommost section) to an inline widget.
109    pub fn footer(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
110        self.pending_footer = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
111        self
112    }
113
114    /// Override the drop shadow. Accepts a `Shadow` token (see
115    /// `teksilo_tokens::Shadow`). The default shadow comes from the active
116    /// `CardStyle` for the chosen `CardVariant`.
117    pub fn shadow(mut self, shadow: Shadow) -> Self {
118        self.shadow = Some(shadow);
119        self
120    }
121
122    /// Override the background. Default (unset) is the variant's default
123    /// (`SurfaceRole::Main` for Plain/Outlined/Elevated, `SurfaceRole::Raised`
124    /// for Filled). Accepts `Color`, a role, or `Signal<Color>`.
125    pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
126        self.background = Some(color.into());
127        self
128    }
129
130    /// Override the corner radius (default: `CARD_CORNER_RADIUS` in the active `CardStyle`'s recipe — see `recipe_card_style.rs`).
131    /// Accepts a static `f32` or a reactive `Signal<f32>`.
132    pub fn corner_radius(mut self, radius: impl Into<Prop<f32>>) -> Self {
133        self.corner_radius = Some(radius.into());
134        self
135    }
136
137    /// Override the padding (default: `CARD_PADDING` in the active `CardStyle`'s recipe — see `recipe_card_style.rs`).
138    /// Accepts a static `f32` or a reactive `Signal<f32>`.
139    pub fn padding(mut self, padding: impl Into<Prop<f32>>) -> Self {
140        self.padding = Some(padding.into());
141        self
142    }
143
144    /// Pick the design-language variant. Default `Plain`. The active
145    /// `CardStyle` decides what each variant means visually (the IntUI
146    /// default maps Plain → no shadow + surface_main, Elevated →
147    /// shadow_md + surface_main, Outlined → border + surface_main,
148    /// Filled → shadow_md + surface_raised).
149    pub fn variant(mut self, variant: CardVariant) -> Self {
150        self.variant = variant;
151        self
152    }
153
154    /// Per-call style override. Replaces the theme-wide default
155    /// `CardStyle` for just this Card instance.
156    pub fn style(mut self, style: impl teksilo_core::styles::CardStyle) -> Self {
157        self.style_override = Some(Rc::new(style));
158        self
159    }
160
161    fn resolve_padding(&self, _theme: &teksilo_core::Theme) -> f32 {
162        self.padding
163            .as_ref()
164            .map(|p| p.get())
165            .unwrap_or(crate::styles::recipe_card_style::CARD_PADDING)
166    }
167}
168
169impl Default for Card {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175impl Widget for Card {
176    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
177        if let Some(h) = self.pending_header.take() {
178            self.header_id = Some(match h {
179                PendingChild::Id(id) => id,
180                PendingChild::Deferred(w) => ctx.add_boxed(w),
181            });
182        }
183        if let Some(c) = self.pending_content.take() {
184            self.content_id = Some(match c {
185                PendingChild::Id(id) => id,
186                PendingChild::Deferred(w) => ctx.add_boxed(w),
187            });
188        }
189        if let Some(f) = self.pending_footer.take() {
190            self.footer_id = Some(match f {
191                PendingChild::Id(id) => id,
192                PendingChild::Deferred(w) => ctx.add_boxed(w),
193            });
194        }
195
196        // Compose the three slots into a single content widget — a VStack
197        // with `padding/2` spacing between sections (mirrors the
198        // pre-refactor in-card section spacing). Empty if all three are
199        // None (the style still gets a `content: WidgetId` to wrap).
200        let pad = self.resolve_padding(ctx.theme());
201        let spacing = pad * 0.5;
202        let mut stack = VStack::new().spacing(spacing);
203        for slot in [self.header_id, self.content_id, self.footer_id]
204            .into_iter()
205            .flatten()
206        {
207            stack = stack.child(slot);
208        }
209        let content = ctx.add(stack);
210
211        let style: SharedCardStyle = self
212            .style_override
213            .clone()
214            .or_else(|| ctx.theme().style_slots.card.clone())
215            .unwrap_or_else(|| {
216                Rc::new(crate::styles::RecipeCardStyle::for_tokens(
217                    &ctx.theme().input,
218                ))
219            });
220        let cfg = CardStyleConfig {
221            content,
222            is_hovered: None,
223            variant: self.variant,
224            background_override: self.background.clone(),
225            corner_radius_override: self.corner_radius.clone(),
226            padding_override: self.padding.clone(),
227            shadow_override: self.shadow,
228        };
229        let root_id = style.make_body(&cfg, ctx);
230        self.root_child_id = Some(root_id);
231        vec![root_id]
232    }
233
234    fn layout_response(
235        &self,
236        proposal: SizeProposal,
237        ctx: &LayoutContext,
238    ) -> teksilo_core::widget::LayoutResponse {
239        if let Some(root) = self.root_child_id
240            && let Some(size) = ctx.child_size(root, proposal)
241        {
242            return (size).into();
243        }
244        proposal.resolve(0.0, 0.0).into()
245    }
246
247    fn place_children(
248        &self,
249        bounds: Rect,
250        _proposal: SizeProposal,
251        children: &mut [WidgetPlacement],
252        _ctx: &LayoutContext,
253    ) {
254        for child in children.iter_mut() {
255            child.origin = Point::new(bounds.x, bounds.y);
256            child.size = Size::new(bounds.width, bounds.height);
257        }
258    }
259
260    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
261        builder.set_role(teksilo_core::accesskit::Role::Group);
262    }
263
264    fn children(&self) -> Vec<WidgetId> {
265        self.root_child_id.into_iter().collect()
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use teksilo_core::widget_tree::WidgetTree;
273
274    #[derive(Debug)]
275    struct FixedLeaf(f32, f32);
276    impl Widget for FixedLeaf {
277        fn layout_response(
278            &self,
279            _proposal: SizeProposal,
280            _ctx: &LayoutContext,
281        ) -> teksilo_core::widget::LayoutResponse {
282            Size::new(self.0, self.1).into()
283        }
284    }
285
286    #[test]
287    fn card_renders_shadow_and_background() {
288        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
289        let _content = tree.add(FixedLeaf(100.0, 50.0));
290        tree.add(
291            Card::new()
292                .variant(CardVariant::Elevated)
293                .content(FixedLeaf(100.0, 50.0)),
294        );
295        tree.layout(SizeProposal::exact(200.0, 200.0));
296        let frame = tree.render();
297        // Should have shapes for shadow + background
298        assert!(
299            !frame.shapes.is_empty() || !frame.shadows.is_empty(),
300            "card should render shadow and/or background"
301        );
302    }
303}