Skip to main content

tessera_components/date_picker/
cell.rs

1//! Calendar day and year choices for custom date-selection layouts.
2//!
3//! ## Usage
4//!
5//! Compose selectable calendar cells in scheduling and date-entry controls.
6
7use tessera_ui::{
8    CallbackWith, Color, ComputedData, Constraint, Dp, FocusRequester, LayoutResult,
9    MeasurementError, Modifier, Px, PxPosition, PxSize, State,
10    layout::{MeasureScope, layout},
11    modifier::ModifierCapabilityExt as _,
12    remember, tessera, use_context,
13};
14
15use crate::{
16    modifier::{
17        InteractionState, InteractionVisualState, ModifierExt as _, SelectableArgs, SemanticsArgs,
18    },
19    pipelines::shape::command::ShapeCommand,
20    res::I18N,
21    shape_def::{ResolvedShape, Shape},
22    surface::record_focus_indicator,
23    text::text,
24    theme::{MaterialAlpha, MaterialTheme},
25};
26
27use super::{
28    CalendarDate, CalendarRangeBand, DATE_CELL_SIZE, DATE_VISUAL_SIZE,
29    focus::CalendarFocusIndicator, format_month_name,
30};
31
32/// Position of a date within the continuous band of an inclusive range.
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34pub enum CalendarRangePosition {
35    /// A range with the same starting and ending date.
36    #[default]
37    Isolated,
38    /// The first date, with a band extending toward the following date.
39    Start,
40    /// An interior date, with a band extending in both directions.
41    Middle,
42    /// The last date, with a band extending toward the preceding date.
43    End,
44}
45
46impl CalendarRangePosition {
47    fn band(self) -> CalendarRangeBand {
48        CalendarRangeBand {
49            start: matches!(self, Self::Isolated | Self::Start),
50            end: matches!(self, Self::Isolated | Self::End),
51        }
52    }
53}
54
55/// # date_picker_day
56///
57/// Render an interactive calendar date for custom calendars and scheduling
58/// controls.
59///
60/// ## Usage
61///
62/// Arrange real date choices in a calendar, booking flow, or compact date
63/// selector.
64///
65/// ## Parameters
66///
67/// - `date`, `on_select` — date represented by the cell and its activation
68///   callback.
69/// - `selected`, `is_today`, `in_range`, `outside_month`, `range_position` —
70///   selection and range appearance.
71/// - `enabled`, `presentation_state` — interaction availability and optional
72///   visual state.
73/// - `modifier`, `focus_requester` — layout, keyboard behavior, and external
74///   focus control.
75/// - `label_formatter` — optional visible day text; accessibility retains the
76///   actual date.
77/// - `accessibility_label`, `accessibility_description` — optional spoken date
78///   information.
79///
80/// ## Examples
81///
82/// ```
83/// use tessera_components::{
84///     date_picker::{CalendarDate, date_picker_day},
85///     theme::material_theme,
86/// };
87/// use tessera_ui::{remember, tessera};
88/// #[tessera]
89/// fn schedule() {
90///     material_theme().child(|| {
91///         let date = CalendarDate::new(2025, 8, 17).unwrap();
92///         let selected = remember(|| date);
93///         date_picker_day(date)
94///             .selected(selected.get() == date)
95///             .on_select(move |date| selected.set(date));
96///         assert_eq!(selected.get(), date);
97///     });
98/// }
99/// # tessera_ui::testing::layout_test(schedule).run();
100/// ```
101#[tessera]
102pub fn date_picker_day(
103    date: CalendarDate,
104    on_select: Option<CallbackWith<CalendarDate>>,
105    selected: Option<bool>,
106    is_today: Option<bool>,
107    in_range: Option<bool>,
108    outside_month: Option<bool>,
109    range_position: Option<CalendarRangePosition>,
110    enabled: Option<bool>,
111    presentation_state: Option<InteractionVisualState>,
112    modifier: Option<Modifier>,
113    focus_requester: Option<FocusRequester>,
114    label_formatter: Option<CallbackWith<CalendarDate, String>>,
115    #[prop(into)] accessibility_label: Option<String>,
116    #[prop(into)] accessibility_description: Option<String>,
117) {
118    let on_select = on_select.unwrap_or_else(|| CallbackWith::new(|_| {}));
119    let theme = use_context::<MaterialTheme>().unwrap().get();
120    let scheme = theme.color_scheme;
121    let selected = selected.unwrap_or(false);
122    let enabled = enabled.unwrap_or(true);
123    let is_today = is_today.unwrap_or(false);
124    let interaction = remember(InteractionState::new);
125    let internal_focus = remember(FocusRequester::new).get();
126    let focus = focus_requester.unwrap_or(internal_focus);
127    let color = if selected {
128        scheme.on_primary
129    } else if in_range.unwrap_or(false) {
130        scheme.on_secondary_container
131    } else if is_today {
132        scheme.primary
133    } else if outside_month.unwrap_or(false) {
134        scheme.on_surface_variant
135    } else {
136        scheme.on_surface
137    };
138    let color = if enabled {
139        color
140    } else {
141        color.with_alpha(MaterialAlpha::DISABLED_CONTENT)
142    };
143    let visual = CalendarCellVisual {
144        shape: Shape::Ellipse,
145        fill: if selected {
146            scheme.primary
147        } else {
148            Color::TRANSPARENT
149        },
150        outline: (!selected && is_today).then_some(scheme.primary),
151        content_color: color,
152        focus_color: scheme.secondary,
153        interaction,
154        presentation_state,
155        enabled,
156        disabled_focus: !enabled && focus.is_focused(),
157    };
158    let range_band = range_position.map(CalendarRangePosition::band);
159    let label =
160        label_formatter.map_or_else(|| date.day().to_string(), |formatter| formatter.call(date));
161    layout()
162        .modifier(
163            modifier
164                .unwrap_or_default()
165                .size(DATE_CELL_SIZE, DATE_CELL_SIZE)
166                .selectable_with(SelectableArgs {
167                    selected,
168                    on_click: tessera_ui::Callback::new(move || on_select.call(date)),
169                    enabled,
170                    interaction_state: Some(interaction),
171                    focus_requester: Some(focus),
172                    label: Some(accessibility_label.unwrap_or_else(|| {
173                        I18N.format(
174                            "md3-full-date",
175                            [
176                                ("month", format_month_name(date.month()).into()),
177                                ("day", date.day().into()),
178                                ("year", date.year().to_string().into()),
179                            ],
180                        )
181                    })),
182                    description: accessibility_description,
183                    ..Default::default()
184                }),
185        )
186        .policy(CalendarDayLayout {
187            fractional_center: false,
188            range_band,
189        })
190        .child(move || {
191            let label = label.clone();
192            if range_band.is_some() {
193                layout().modifier(Modifier::new().background(scheme.secondary_container));
194            }
195            layout()
196                .modifier(
197                    Modifier::new()
198                        .size(DATE_VISUAL_SIZE, DATE_VISUAL_SIZE)
199                        .push_draw(CalendarFocusIndicator {
200                            visible: focus.is_focus_visible(),
201                            shape: Shape::Ellipse,
202                            color: scheme.secondary,
203                        }),
204                )
205                .policy(CalendarDayLayout {
206                    fractional_center: true,
207                    range_band: None,
208                })
209                .draw(visual)
210                .child(move || {
211                    text(label.clone())
212                        .modifier(Modifier::new().semantics(SemanticsArgs {
213                            hidden: true,
214                            ..Default::default()
215                        }))
216                        .style(theme.typography.body_large)
217                        .color(color);
218                });
219        });
220}
221
222/// # date_picker_year
223///
224/// Render an interactive year choice for calendars and year-selection controls.
225///
226/// ## Usage
227///
228/// Arrange year choices in scheduling, date entry, and historical date
229/// selectors.
230///
231/// ## Parameters
232///
233/// - `year`, `on_select` — represented year and its activation callback.
234/// - `selected`, `is_today`, `enabled` — selection, current-year appearance,
235///   and availability.
236/// - `presentation_state` — optional appearance that preserves real interaction
237///   and focus.
238/// - `modifier`, `focus_requester` — layout, keyboard behavior, and external
239///   focus control.
240/// - `label_formatter` — optional visible year text; accessibility retains the
241///   actual year.
242/// - `accessibility_label`, `accessibility_description` — optional spoken year
243///   information.
244///
245/// ## Examples
246///
247/// ```
248/// use tessera_components::{date_picker::date_picker_year, theme::material_theme};
249/// use tessera_ui::{remember, tessera};
250/// #[tessera]
251/// fn years() {
252///     material_theme().child(|| {
253///         let selected = remember(|| 2025);
254///         date_picker_year(2026)
255///             .selected(selected.get() == 2026)
256///             .on_select(move |year| selected.set(year));
257///         assert_eq!(selected.get(), 2025);
258///     });
259/// }
260/// # tessera_ui::testing::layout_test(years).run();
261/// ```
262#[tessera]
263pub fn date_picker_year(
264    year: i32,
265    on_select: Option<CallbackWith<i32>>,
266    selected: Option<bool>,
267    is_today: Option<bool>,
268    enabled: Option<bool>,
269    presentation_state: Option<InteractionVisualState>,
270    modifier: Option<Modifier>,
271    focus_requester: Option<FocusRequester>,
272    label_formatter: Option<CallbackWith<i32, String>>,
273    #[prop(into)] accessibility_label: Option<String>,
274    #[prop(into)] accessibility_description: Option<String>,
275) {
276    let on_select = on_select.unwrap_or_else(|| CallbackWith::new(|_| {}));
277    let theme = use_context::<MaterialTheme>().unwrap().get();
278    let scheme = theme.color_scheme;
279    let selected = selected.unwrap_or(false);
280    let enabled = enabled.unwrap_or(true);
281    let today = is_today.unwrap_or(false);
282    let interaction = remember(InteractionState::new);
283    let internal_focus = remember(FocusRequester::new).get();
284    let focus = focus_requester.unwrap_or(internal_focus);
285    let color = if !enabled {
286        scheme
287            .on_surface
288            .with_alpha(MaterialAlpha::DISABLED_CONTENT)
289    } else if selected {
290        scheme.on_primary
291    } else if today {
292        scheme.primary
293    } else {
294        scheme.on_surface_variant
295    };
296    let visual = CalendarCellVisual {
297        shape: Shape::CAPSULE,
298        fill: if selected {
299            scheme.primary
300        } else {
301            Color::TRANSPARENT
302        },
303        outline: (!selected && today).then_some(scheme.primary),
304        content_color: color,
305        focus_color: scheme.secondary,
306        interaction,
307        presentation_state,
308        enabled,
309        disabled_focus: !enabled && focus.is_focused(),
310    };
311    let label = label_formatter.map_or_else(|| year.to_string(), |formatter| formatter.call(year));
312    layout()
313        .modifier(
314            modifier
315                .unwrap_or_default()
316                .size(Dp(112.0), Dp(52.0))
317                .selectable_with(SelectableArgs {
318                    selected,
319                    on_click: tessera_ui::Callback::new(move || on_select.call(year)),
320                    enabled,
321                    interaction_state: Some(interaction),
322                    focus_requester: Some(focus),
323                    label: Some(accessibility_label.unwrap_or_else(|| year.to_string())),
324                    description: accessibility_description,
325                    ..Default::default()
326                }),
327        )
328        .child(move || {
329            let label = label.clone();
330            layout()
331                .modifier(
332                    Modifier::new()
333                        .size(Dp(72.0), Dp(36.0))
334                        .offset(Dp(20.0), Dp(8.0))
335                        .push_draw(CalendarFocusIndicator {
336                            visible: focus.is_focus_visible(),
337                            shape: Shape::CAPSULE,
338                            color: scheme.secondary,
339                        }),
340                )
341                .draw(visual)
342                .child(move || {
343                    let label = label.clone();
344                    crate::boxed::boxed()
345                        .alignment(crate::alignment::Alignment::Center)
346                        .modifier(Modifier::new().fill_max_size())
347                        .children(move || {
348                            text(label.clone())
349                                .modifier(Modifier::new().semantics(SemanticsArgs {
350                                    hidden: true,
351                                    ..Default::default()
352                                }))
353                                .style(theme.typography.body_large)
354                                .color(color);
355                        });
356                });
357        });
358}
359
360#[derive(Clone, PartialEq)]
361struct CalendarDayLayout {
362    fractional_center: bool,
363    range_band: Option<CalendarRangeBand>,
364}
365
366impl tessera_ui::LayoutModifierNode for CalendarDayLayout {
367    fn measure(&self, scope: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
368        let width = scope
369            .parent_constraint()
370            .width()
371            .clamp(DATE_CELL_SIZE.to_px());
372        let height = scope
373            .parent_constraint()
374            .height()
375            .clamp(DATE_CELL_SIZE.to_px());
376        let mut result = LayoutResult::new(ComputedData { width, height });
377        for (index, child) in scope.children().into_iter().enumerate() {
378            if index == 0
379                && let Some(band) = self.range_band
380            {
381                let visual = DATE_VISUAL_SIZE.to_px().min(height);
382                let x = if band.start { width / 2 } else { Px::ZERO };
383                let end = if band.end { width / 2 } else { width };
384                child.measure(scope, &Constraint::new((end - x).max(Px::ZERO), visual))?;
385                result.place_child(child, PxPosition::new(x, (height - visual) / 2));
386                continue;
387            }
388            let size = child.measure(scope, &scope.parent_constraint().without_min())?;
389            let position = if self.fractional_center {
390                PxPosition::from_f32_xy(
391                    (width - size.width).0.max(0) as f32 / 2.0,
392                    (height - size.height).0.max(0) as f32 / 2.0,
393                )
394            } else {
395                PxPosition::new((width - size.width) / 2, (height - size.height) / 2)
396            };
397            result.place_child(child, position);
398        }
399        Ok(result)
400    }
401}
402
403#[derive(Clone, Copy, PartialEq)]
404struct CalendarCellVisual {
405    shape: Shape,
406    fill: Color,
407    outline: Option<Color>,
408    content_color: Color,
409    focus_color: Color,
410    interaction: State<InteractionState>,
411    presentation_state: Option<InteractionVisualState>,
412    enabled: bool,
413    disabled_focus: bool,
414}
415
416impl tessera_ui::DrawModifierNode for CalendarCellVisual {
417    fn draw(
418        &self,
419        ctx: &mut tessera_ui::DrawModifierContext<'_, '_>,
420        content: &mut dyn tessera_ui::DrawModifierContent,
421    ) {
422        let live = self.interaction.get();
423        let visual = live.with_presentation(self.presentation_state, self.enabled);
424        let alpha = visual.state_layer_alpha().max(if self.disabled_focus {
425            MaterialAlpha::FOCUSED
426        } else {
427            0.0
428        });
429        let fill = self
430            .fill
431            .blend_over(self.content_color.with_alpha(1.0), alpha);
432        if self.outline.is_some() || fill.a > 0.0 {
433            let bounds = ctx
434                .render_input
435                .metadata_mut()
436                .computed_data()
437                .expect("calendar cell bounds");
438            let command = match self
439                .shape
440                .resolve_for_size(PxSize::new(bounds.width, bounds.height))
441            {
442                ResolvedShape::Ellipse => match self.outline {
443                    Some(color) if alpha > 0.0 => ShapeCommand::FilledOutlinedEllipse {
444                        color: fill,
445                        border_color: color,
446                        border_width: Dp(1.0).to_pixels_f32(),
447                    },
448                    Some(color) => ShapeCommand::OutlinedEllipse {
449                        color,
450                        border_width: Dp(1.0).to_pixels_f32(),
451                    },
452                    None => ShapeCommand::Ellipse { color: fill },
453                },
454                ResolvedShape::Rounded {
455                    corner_radii,
456                    corner_g2,
457                } => match self.outline {
458                    Some(color) => ShapeCommand::FilledOutlinedRect {
459                        color: fill,
460                        border_color: color,
461                        corner_radii,
462                        corner_g2,
463                        border_width: Dp(1.0).to_pixels_f32(),
464                    },
465                    None => ShapeCommand::Rect {
466                        color: fill,
467                        corner_radii,
468                        corner_g2,
469                    },
470                },
471            };
472            ctx.render_input
473                .metadata_mut()
474                .fragment_mut()
475                .push_draw_command(command);
476        }
477        if visual.is_focused() && !live.is_focused() {
478            record_focus_indicator(ctx.render_input, self.shape, self.focus_color);
479        }
480        content.draw(ctx.render_input);
481    }
482}
483
484#[cfg(all(test, not(target_family = "wasm")))]
485#[path = "cell_tests.rs"]
486mod tests;