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 Widget + 'static) -> Self {
98        self.pending_header = Some(PendingChild::Deferred(Box::new(widget)));
99        self
100    }
101
102    /// Set the header slot to a pre-registered `WidgetId`.
103    pub fn header_id(mut self, id: WidgetId) -> Self {
104        self.pending_header = Some(PendingChild::Id(id));
105        self
106    }
107
108    /// Set the main content slot (middle section) to an inline widget.
109    pub fn content(mut self, widget: impl Widget + 'static) -> Self {
110        self.pending_content = Some(PendingChild::Deferred(Box::new(widget)));
111        self
112    }
113
114    /// Set the main content slot to a pre-registered `WidgetId`.
115    pub fn content_id(mut self, id: WidgetId) -> Self {
116        self.pending_content = Some(PendingChild::Id(id));
117        self
118    }
119
120    /// Set the footer slot (bottommost section) to an inline widget.
121    pub fn footer(mut self, widget: impl Widget + 'static) -> Self {
122        self.pending_footer = Some(PendingChild::Deferred(Box::new(widget)));
123        self
124    }
125
126    /// Set the footer slot to a pre-registered `WidgetId`.
127    pub fn footer_id(mut self, id: WidgetId) -> Self {
128        self.pending_footer = Some(PendingChild::Id(id));
129        self
130    }
131
132    /// Override the drop shadow. Accepts a `Shadow` token (see
133    /// `teksilo_tokens::Shadow`). The default shadow comes from the active
134    /// `CardStyle` for the chosen `CardVariant`.
135    pub fn shadow(mut self, shadow: Shadow) -> Self {
136        self.shadow = Some(shadow);
137        self
138    }
139
140    /// Override the background. Default (unset) is the variant's default
141    /// (`SurfaceRole::Main` for Plain/Outlined/Elevated, `SurfaceRole::Raised`
142    /// for Filled). Accepts `Color`, a role, or `Signal<Color>`.
143    pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
144        self.background = Some(color.into());
145        self
146    }
147
148    /// Override the corner radius (default: theme `components.card.corner_radius`).
149    /// Accepts a static `f32` or a reactive `Signal<f32>`.
150    pub fn corner_radius(mut self, radius: impl Into<Prop<f32>>) -> Self {
151        self.corner_radius = Some(radius.into());
152        self
153    }
154
155    /// Override the padding (default: theme `components.card.padding`).
156    /// Accepts a static `f32` or a reactive `Signal<f32>`.
157    pub fn padding(mut self, padding: impl Into<Prop<f32>>) -> Self {
158        self.padding = Some(padding.into());
159        self
160    }
161
162    /// Pick the design-language variant. Default `Plain`. The active
163    /// `CardStyle` decides what each variant means visually (the IntUI
164    /// default maps Plain → no shadow + surface_main, Elevated →
165    /// shadow_md + surface_main, Outlined → border + surface_main,
166    /// Filled → shadow_md + surface_raised).
167    pub fn variant(mut self, variant: CardVariant) -> Self {
168        self.variant = variant;
169        self
170    }
171
172    /// Per-call style override. Replaces the theme-wide default
173    /// `CardStyle` for just this Card instance.
174    pub fn style(mut self, style: impl teksilo_core::styles::CardStyle) -> Self {
175        self.style_override = Some(Rc::new(style));
176        self
177    }
178
179    fn resolve_padding(&self, _theme: &teksilo_core::Theme) -> f32 {
180        self.padding
181            .as_ref()
182            .map(|p| p.get())
183            .unwrap_or(crate::styles::recipe_card_style::CARD_PADDING)
184    }
185}
186
187impl Default for Card {
188    fn default() -> Self {
189        Self::new()
190    }
191}
192
193impl Widget for Card {
194    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
195        if let Some(h) = self.pending_header.take() {
196            self.header_id = Some(match h {
197                PendingChild::Id(id) => id,
198                PendingChild::Deferred(w) => ctx.add_boxed(w),
199            });
200        }
201        if let Some(c) = self.pending_content.take() {
202            self.content_id = Some(match c {
203                PendingChild::Id(id) => id,
204                PendingChild::Deferred(w) => ctx.add_boxed(w),
205            });
206        }
207        if let Some(f) = self.pending_footer.take() {
208            self.footer_id = Some(match f {
209                PendingChild::Id(id) => id,
210                PendingChild::Deferred(w) => ctx.add_boxed(w),
211            });
212        }
213
214        // Compose the three slots into a single content widget — a VStack
215        // with `padding/2` spacing between sections (mirrors the
216        // pre-refactor in-card section spacing). Empty if all three are
217        // None (the style still gets a `content: WidgetId` to wrap).
218        let pad = self.resolve_padding(ctx.theme());
219        let spacing = pad * 0.5;
220        let mut stack = VStack::new().spacing(spacing);
221        for slot in [self.header_id, self.content_id, self.footer_id]
222            .into_iter()
223            .flatten()
224        {
225            stack = stack.add_child(slot);
226        }
227        let content = ctx.add(stack);
228
229        let style: SharedCardStyle = self
230            .style_override
231            .clone()
232            .or_else(|| ctx.theme().style_slots.card.clone())
233            .unwrap_or_else(|| Rc::new(crate::styles::RecipeCardStyle::default()));
234        let cfg = CardStyleConfig {
235            content,
236            is_hovered: None,
237            variant: self.variant,
238            background_override: self.background.clone(),
239            corner_radius_override: self.corner_radius.clone(),
240            padding_override: self.padding.clone(),
241            shadow_override: self.shadow,
242        };
243        let root_id = style.make_body(&cfg, ctx);
244        self.root_child_id = Some(root_id);
245        vec![root_id]
246    }
247
248    fn layout_response(
249        &self,
250        proposal: SizeProposal,
251        ctx: &LayoutContext,
252    ) -> teksilo_core::widget::LayoutResponse {
253        if let Some(root) = self.root_child_id
254            && let Some(size) = ctx.child_size(root, proposal)
255        {
256            return (size).into();
257        }
258        proposal.resolve(0.0, 0.0).into()
259    }
260
261    fn place_children(
262        &self,
263        bounds: Rect,
264        _proposal: SizeProposal,
265        children: &mut [WidgetPlacement],
266        _ctx: &LayoutContext,
267    ) {
268        for child in children.iter_mut() {
269            child.origin = Point::new(bounds.x, bounds.y);
270            child.size = Size::new(bounds.width, bounds.height);
271        }
272    }
273
274    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
275        builder.set_role(teksilo_core::accesskit::Role::Group);
276    }
277
278    fn children(&self) -> Vec<WidgetId> {
279        self.root_child_id.into_iter().collect()
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use teksilo_core::widget_tree::WidgetTree;
287
288    #[derive(Debug)]
289    struct FixedLeaf(f32, f32);
290    impl Widget for FixedLeaf {
291        fn layout_response(
292            &self,
293            _proposal: SizeProposal,
294            _ctx: &LayoutContext,
295        ) -> teksilo_core::widget::LayoutResponse {
296            Size::new(self.0, self.1).into()
297        }
298    }
299
300    #[test]
301    fn card_renders_shadow_and_background() {
302        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
303        let _content = tree.add(FixedLeaf(100.0, 50.0));
304        tree.add(
305            Card::new()
306                .variant(CardVariant::Elevated)
307                .content(FixedLeaf(100.0, 50.0)),
308        );
309        tree.layout(SizeProposal::exact(200.0, 200.0));
310        let frame = tree.render();
311        // Should have shapes for shadow + background
312        assert!(
313            !frame.shapes.is_empty() || !frame.shadows.is_empty(),
314            "card should render shadow and/or background"
315        );
316    }
317}