Skip to main content

patternfly_yew/components/
calendar.rs

1use crate::prelude::{
2    Button, ButtonType, ButtonVariant, Icon, InputGroup, InputGroupItem, SimpleSelect, TextInput,
3    TextInputType,
4};
5use chrono::{Datelike, Days, Local, Month, Months, NaiveDate, Weekday};
6use num_traits::cast::FromPrimitive;
7use std::str::FromStr;
8
9use yew::{
10    Callback, Html, Properties, classes, function_component, html, use_callback, use_state_eq,
11};
12
13use super::select::SelectItemRenderer;
14
15#[derive(Clone, PartialEq, Properties)]
16pub struct CalendarMonthProperties {
17    #[prop_or(Local::now().date_naive())]
18    pub date: NaiveDate,
19    #[prop_or_default]
20    pub onchange: Callback<NaiveDate>,
21    #[prop_or_default]
22    pub rangestart: Option<NaiveDate>,
23    #[prop_or(Weekday::Mon)]
24    pub weekday_start: Weekday,
25}
26
27// Build a vec (month) which contains vecs (weeks) of a month with the first
28// and last day of week, even if they aren't in the same month.
29//
30// The month is set by `date` and the first day of the week by `weekday_start`.
31fn build_calendar(date: NaiveDate, weekday_start: Weekday) -> Vec<Vec<NaiveDate>> {
32    const ONE_DAY: Days = Days::new(1);
33    let mut ret: Vec<Vec<NaiveDate>> = Vec::new();
34    // first day of the week. It's initialized first at the first day of the month
35    let mut first_day = date.with_day(1).unwrap();
36    let mut day = first_day.week(weekday_start).first_day();
37    let mut week: Vec<NaiveDate>;
38
39    while first_day.month() == date.month() {
40        week = Vec::new();
41        while first_day.week(weekday_start).days().contains(&day) {
42            week.push(day);
43            day = day + ONE_DAY;
44        }
45
46        first_day = first_day.week(weekday_start).last_day() + ONE_DAY;
47        ret.push(week);
48    }
49
50    ret
51}
52
53#[function_component(CalendarView)]
54pub fn calendar(props: &CalendarMonthProperties) -> Html {
55    // the date which is selected by user
56    let date = use_state_eq(|| props.date);
57    // the date which is showed when the user changes month or year without selecting a new date
58    let show_date = use_state_eq(|| props.date);
59    // an array which contains the week of the selected date
60    let weeks = build_calendar(*show_date, props.weekday_start);
61    // the month of the selected date, used for selector
62    let month = use_state_eq(|| Month::from_u32(props.date.month()).unwrap());
63
64    let callback_month_select = use_callback(
65        (show_date.clone(), month.clone()),
66        move |new_month: MonthLocal, (show_date, month)| {
67            if let Some(d) = NaiveDate::from_ymd_opt(
68                show_date.year(),
69                new_month.0.number_from_month(),
70                show_date.day(),
71            ) {
72                show_date.set(d);
73                month.set(new_month.0);
74            }
75        },
76    );
77
78    let callback_years = use_callback(show_date.clone(), move |new_year: String, show_date| {
79        if let Ok(y) = i32::from_str(&new_year)
80            && let Some(d) = NaiveDate::from_ymd_opt(y, show_date.month(), show_date.day())
81        {
82            show_date.set(d)
83        }
84    });
85
86    let callback_prev = use_callback(
87        (show_date.clone(), month.clone()),
88        move |_, (show_date, month)| {
89            if let Some(d) = show_date.checked_sub_months(Months::new(1)) {
90                show_date.set(d);
91                month.set(month.pred());
92            }
93        },
94    );
95
96    let callback_next = use_callback(
97        (show_date.clone(), month.clone()),
98        move |_, (show_date, month)| {
99            if let Some(d) = show_date.checked_add_months(Months::new(1)) {
100                show_date.set(d);
101                month.set(month.succ());
102            }
103        },
104    );
105
106    html! {
107        <div class="pf-v6-c-calendar-month">
108            <div class="pf-v6-c-calendar-month__header">
109                <div class="pf-v6-c-calendar-month__header-nav-control pf-m-prev-month">
110                    <Button
111                        variant={ButtonVariant::Plain}
112                        aria_label="Previous month"
113                        onclick={callback_prev}
114                    >
115                        { Icon::AngleLeft.as_html() }
116                    </Button>
117                </div>
118                <InputGroup>
119                    <InputGroupItem>
120                        <div class="pf-v6-c-calendar-month__header-month">
121                            <SimpleSelect<MonthLocal>
122                                entries={vec![
123                                    MonthLocal(Month::January),
124                                    MonthLocal(Month::February),
125                                    MonthLocal(Month::March),
126                                    MonthLocal(Month::April),
127                                    MonthLocal(Month::May),
128                                    MonthLocal(Month::June),
129                                    MonthLocal(Month::July),
130                                    MonthLocal(Month::August),
131                                    MonthLocal(Month::September),
132                                    MonthLocal(Month::October),
133                                    MonthLocal(Month::November),
134                                    MonthLocal(Month::December)
135                                ]}
136                                selected={MonthLocal(*month)}
137                                onselect={callback_month_select}
138                            />
139                        </div>
140                    </InputGroupItem>
141                    <InputGroupItem>
142                        <div class="pf-v6-c-calendar-month__header-year">
143                            <TextInput
144                                value={show_date.year().to_string()}
145                                r#type={TextInputType::Number}
146                                onchange={callback_years}
147                            />
148                        </div>
149                    </InputGroupItem>
150                </InputGroup>
151                <div class="pf-v6-c-calendar-month__header-nav-control pf-m-next-month">
152                    <Button
153                        variant={ButtonVariant::Plain}
154                        aria_label="Next month"
155                        onclick={callback_next}
156                    >
157                        { Icon::AngleRight.as_html() }
158                    </Button>
159                </div>
160            </div>
161            <table class="pf-v6-c-calendar-month__calendar">
162                <thead class="pf-v6-c-calendar-month__days">
163                    <tr class="pf-v6-c-calendar-month__days-row">
164                        { weeks[0].clone().into_iter().map(|day| {
165                            html!{
166                                <th class="pf-v6-c-calendar-month__day">
167                                    <span class="pf-v6-screen-reader">{weekday_name(day.weekday())}</span>
168                                    <span aria-hidden="true">{weekday_name(day.weekday())}</span>
169                                </th>
170                            }
171                        }).collect::<Html>() }
172                    </tr>
173                </thead>
174                <tbody class="pf-v6-c-calendar-month__dates">
175                    { weeks.into_iter().map(|week| {
176                        html!{
177                            <>
178                            <tr class="pf-v6-c-calendar-month__dates-row">
179                            {
180                            week.into_iter().map(|day| {
181                                let callback_date = {
182                                    let date = date.clone();
183                                    let month = month.clone();
184                                    let show_date = show_date.clone();
185                                    let onchange = props.onchange.clone();
186                                    move |day: NaiveDate| {
187                                        Callback::from(move |_| {
188                                            let new = NaiveDate::from_ymd_opt(day.year(), day.month(), day.day()).unwrap();
189                                            date.set(new);
190                                            show_date.set(new);
191                                            month.set(Month::from_u32(day.month()).unwrap());
192                                            onchange.emit(new);
193                                        })
194                                    }
195                                };
196
197                                let mut classes = classes!("pf-v6-c-calendar-month__dates-cell");
198
199                                if day == *date {
200                                    classes.extend(classes!("pf-m-selected"));
201                                }
202
203                                if day.month() != show_date.month() {
204                                    classes.extend(classes!("pf-m-adjacent-month"));
205                                }
206
207                                let before_range = if let Some(range_start) = props.rangestart {
208                                    if day < range_start {
209                                        classes.extend(classes!("pf-m-disabled"));
210                                    }
211
212                                    if day == range_start {
213                                        classes.extend(classes!("pf-m-start-range"));
214                                        classes.extend(classes!("pf-m-selected"));
215                                    }
216
217                                    if day >= range_start && day <= *date {
218                                        classes.extend(classes!("pf-m-in-range"));
219                                    }
220
221                                    if day == *date {
222                                        classes.extend(classes!("pf-m-end-range"));
223                                    }
224
225                                    day < range_start
226                                } else { false };
227
228                                html!{
229                                    <>
230                                    <td class={classes}>
231                                        <Button
232                                            class="pf-v6-c-calendar-month__date"
233                                            r#type={ButtonType::Button}
234                                            variant={if before_range {
235                                                ButtonVariant::Plain
236                                            } else {
237                                                ButtonVariant::None
238                                            }}
239                                            onclick={callback_date(day)}
240                                            disabled={before_range}
241                                        >
242                                        {day.day()}
243                                        </Button>
244                                    </td>
245                                    </>
246                                }
247                            }).collect::<Html>()
248                            }
249                            </tr>
250                            </>
251                        }
252                    }).collect::<Html>() }
253                </tbody>
254            </table>
255        </div>
256    }
257}
258
259/// A wrapper around [`chrono::Month`] to extend it
260#[derive(Clone, PartialEq, Eq)]
261struct MonthLocal(Month);
262
263impl SelectItemRenderer for MonthLocal {
264    type Item = String;
265
266    #[cfg(feature = "localization")]
267    fn label(&self) -> Self::Item {
268        self.0.localized_name()
269    }
270
271    #[cfg(not(feature = "localization"))]
272    fn label(&self) -> Self::Item {
273        self.0.name().to_string()
274    }
275}
276
277#[cfg(feature = "localization")]
278trait Localized {
279    fn localized_name(&self) -> String;
280}
281
282#[cfg(feature = "localization")]
283impl Localized for Month {
284    /// Convert to text in the current system language
285    fn localized_name(&self) -> String {
286        // Build a dummy NaiveDate with month whose name I'm interested in
287        let date = NaiveDate::from_ymd_opt(2024, self.number_from_month(), 1).unwrap();
288
289        // Get a localized full month name
290        date.format_localized("%B", current_locale()).to_string()
291    }
292}
293
294#[cfg(feature = "localization")]
295fn weekday_name(weekday: Weekday) -> String {
296    localized_weekday_name(weekday)
297}
298
299#[cfg(not(feature = "localization"))]
300fn weekday_name(weekday: Weekday) -> String {
301    weekday.to_string()
302}
303
304#[cfg(feature = "localization")]
305fn localized_weekday_name(weekday: Weekday) -> String {
306    // Get today NaiveDateTime
307    let today = chrono::Local::now().naive_local();
308
309    // Calculate the distance in days between today and the next 'weekday'
310    let days_until_weekday = (7 + weekday.num_days_from_monday() as i64
311        - today.weekday().num_days_from_monday() as i64)
312        % 7;
313
314    // Calculate the date of the next 'weekday'
315    let one_day = today + chrono::Duration::days(days_until_weekday);
316
317    // Get a localized 'weekday' short name
318    one_day
319        .date()
320        .format_localized("%a", current_locale())
321        .to_string()
322}
323
324#[cfg(feature = "localization")]
325static CURRENT_LOCALE_CELL: std::sync::OnceLock<chrono::Locale> = std::sync::OnceLock::new();
326
327#[cfg(feature = "localization")]
328fn current_locale() -> chrono::Locale {
329    CURRENT_LOCALE_CELL
330        .get_or_init(|| {
331            // Get the current system locale text representation
332            let current_locale = sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"));
333
334            // Convert the locale representation to snake case
335            let current_locale_snake_case = current_locale
336                .as_str()
337                .split('.')
338                .next()
339                .map(|s| s.replace('-', "_"))
340                .unwrap_or("en_US".to_string());
341
342            // Build the chono::Locale from locale text represantation
343            chrono::Locale::try_from(current_locale_snake_case.as_str())
344                .unwrap_or(chrono::Locale::POSIX)
345        })
346        .to_owned()
347}