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    classes, function_component, html, use_callback, use_state_eq, Callback, Html, Properties,
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            if let Some(d) = NaiveDate::from_ymd_opt(y, show_date.month(), show_date.day()) {
81                show_date.set(d)
82            }
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-v5-c-calendar-month">
108            <div class="pf-v5-c-calendar-month__header">
109                <div class="pf-v5-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-v5-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-v5-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                            </TextInput>
149                        </div>
150                    </InputGroupItem>
151                </InputGroup>
152                <div class="pf-v5-c-calendar-month__header-nav-control pf-m-next-month">
153                    <Button
154                        variant={ButtonVariant::Plain}
155                        aria_label="Next month"
156                        onclick={callback_next}
157                    >
158                    {Icon::AngleRight.as_html()}
159                    </Button>
160                </div>
161            </div>
162            <table class="pf-v5-c-calendar-month__calendar">
163                <thead class="pf-v5-c-calendar-month__days">
164                    <tr class="pf-v5-c-calendar-month__days-row">
165                    {
166                        weeks[0].clone().into_iter().map(|day| {
167                            html!{
168                                <th class="pf-v5-c-calendar-month__day">
169                                    <span class="pf-v5-screen-reader">{weekday_name(day.weekday())}</span>
170                                    <span aria-hidden="true">{weekday_name(day.weekday())}</span>
171                                </th>
172                            }
173                        }).collect::<Html>()
174                    }
175                    </tr>
176                </thead>
177                <tbody class="pf-v5-c-calendar-month__dates">
178                {
179                    weeks.into_iter().map(|week| {
180                        html!{
181                            <>
182                            <tr class="pf-v5-c-calendar-month__dates-row">
183                            {
184                            week.into_iter().map(|day| {
185                                let callback_date = {
186                                    let date = date.clone();
187                                    let month = month.clone();
188                                    let show_date = show_date.clone();
189                                    let onchange = props.onchange.clone();
190                                    move |day: NaiveDate| {
191                                        Callback::from(move |_| {
192                                            let new = NaiveDate::from_ymd_opt(day.year(), day.month(), day.day()).unwrap();
193                                            date.set(new);
194                                            show_date.set(new);
195                                            month.set(Month::from_u32(day.month()).unwrap());
196                                            onchange.emit(new);
197                                        })
198                                    }
199                                };
200
201                                let mut classes = classes!("pf-v5-c-calendar-month__dates-cell");
202
203                                if day == *date {
204                                    classes.extend(classes!("pf-m-selected"));
205                                }
206
207                                if day.month() != show_date.month() {
208                                    classes.extend(classes!("pf-m-adjacent-month"));
209                                }
210
211                                let before_range = if let Some(range_start) = props.rangestart {
212                                    if day < range_start {
213                                        classes.extend(classes!("pf-m-disabled"));
214                                    }
215
216                                    if day == range_start {
217                                        classes.extend(classes!("pf-m-start-range"));
218                                        classes.extend(classes!("pf-m-selected"));
219                                    }
220
221                                    if day >= range_start && day <= *date {
222                                        classes.extend(classes!("pf-m-in-range"));
223                                    }
224
225                                    if day == *date {
226                                        classes.extend(classes!("pf-m-end-range"));
227                                    }
228
229                                    day < range_start
230                                } else { false };
231
232                                html!{
233                                    <>
234                                    <td class={classes}>
235                                        <Button
236                                            class="pf-v5-c-calendar-month__date"
237                                            r#type={ButtonType::Button}
238                                            variant={if before_range {
239                                                ButtonVariant::Plain
240                                            } else {
241                                                ButtonVariant::None
242                                            }}
243                                            onclick={callback_date(day)}
244                                            disabled={before_range}
245                                        >
246                                        {day.day()}
247                                        </Button>
248                                    </td>
249                                    </>
250                                }
251                            }).collect::<Html>()
252                            }
253                            </tr>
254                            </>
255                        }
256                    }).collect::<Html>()
257                }
258                </tbody>
259            </table>
260        </div>
261    }
262}
263
264/// A wrapper around [`chrono::Month`] to extend it
265#[derive(Clone, PartialEq, Eq)]
266struct MonthLocal(Month);
267
268impl SelectItemRenderer for MonthLocal {
269    type Item = String;
270
271    #[cfg(feature = "localization")]
272    fn label(&self) -> Self::Item {
273        self.0.localized_name()
274    }
275
276    #[cfg(not(feature = "localization"))]
277    fn label(&self) -> Self::Item {
278        self.0.name().to_string()
279    }
280}
281
282#[cfg(feature = "localization")]
283trait Localized {
284    fn localized_name(&self) -> String;
285}
286
287#[cfg(feature = "localization")]
288impl Localized for Month {
289    /// Convert to text in the current system language
290    fn localized_name(&self) -> String {
291        // Build a dummy NaiveDate with month whose name I'm interested in
292        let date = NaiveDate::from_ymd_opt(2024, self.number_from_month(), 1).unwrap();
293
294        // Get a localized full month name
295        date.format_localized("%B", current_locale()).to_string()
296    }
297}
298
299#[cfg(feature = "localization")]
300fn weekday_name(weekday: Weekday) -> String {
301    localized_weekday_name(weekday)
302}
303
304#[cfg(not(feature = "localization"))]
305fn weekday_name(weekday: Weekday) -> String {
306    weekday.to_string()
307}
308
309#[cfg(feature = "localization")]
310fn localized_weekday_name(weekday: Weekday) -> String {
311    // Get today NaiveDateTime
312    let today = chrono::Local::now().naive_local();
313
314    // Calculate the distance in days between today and the next 'weekday'
315    let days_until_weekday = (7 + weekday.num_days_from_monday() as i64
316        - today.weekday().num_days_from_monday() as i64)
317        % 7;
318
319    // Calculate the date of the next 'weekday'
320    let one_day = today + chrono::Duration::days(days_until_weekday);
321
322    // Get a localized 'weekday' short name
323    one_day
324        .date()
325        .format_localized("%a", current_locale())
326        .to_string()
327}
328
329#[cfg(feature = "localization")]
330static CURRENT_LOCALE_CELL: std::sync::OnceLock<chrono::Locale> = std::sync::OnceLock::new();
331
332#[cfg(feature = "localization")]
333fn current_locale() -> chrono::Locale {
334    CURRENT_LOCALE_CELL
335        .get_or_init(|| {
336            // Get the current system locale text representation
337            let current_locale = sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"));
338
339            // Convert the locale representation to snake case
340            let current_locale_snake_case = current_locale
341                .as_str()
342                .split('.')
343                .next()
344                .map(|s| s.replace('-', "_"))
345                .unwrap_or("en_US".to_string());
346
347            // Build the chono::Locale from locale text represantation
348            chrono::Locale::try_from(current_locale_snake_case.as_str())
349                .unwrap_or(chrono::Locale::POSIX)
350        })
351        .to_owned()
352}