|
| 1 | +import { Tokens } from "../theme/tokens.slint"; |
| 2 | +import { Icon } from "icon.slint"; |
| 3 | +import { LucidePaths } from "lucide-paths.slint"; |
| 4 | + |
| 5 | +// Month-grid calendar. Slint 1.16 has no Date type or date arithmetic, so the |
| 6 | +// consumer provides `days-in-month` + `first-day-offset` (0 = Sun … 6 = Sat) |
| 7 | +// computed from `year`/`month` in your model — the component renders the grid, |
| 8 | +// handles selection, and fires prev / next so you can flip the page. |
| 9 | +// |
| 10 | +// Wire it like: |
| 11 | +// Calendar { |
| 12 | +// month-label: "May 2026"; |
| 13 | +// days-in-month: 31; |
| 14 | +// first-day-offset: 5; // 1 May 2026 is a Friday |
| 15 | +// selected-day <=> day; |
| 16 | +// day-selected(d) => { day = d } |
| 17 | +// prev-month => { /* roll your model back one month */ } |
| 18 | +// next-month => { /* roll your model forward one month */ } |
| 19 | +// } |
| 20 | +export component Calendar inherits Rectangle { |
| 21 | + // Header text, e.g. "May 2026". The component does no date formatting. |
| 22 | + in property <string> month-label; |
| 23 | + // Number of days in the displayed month — 28 / 29 / 30 / 31. |
| 24 | + in property <int> days-in-month: 30; |
| 25 | + // Day-of-week of the first day of the month (0 = Sun, 6 = Sat). Leading |
| 26 | + // empty cells render before day 1. |
| 27 | + in property <int> first-day-offset: 0; |
| 28 | + // Two-way; the selected day-of-month (1 … days-in-month), or -1 for none. |
| 29 | + in-out property <int> selected-day: -1; |
| 30 | + // Fired when the user clicks a valid day cell. |
| 31 | + callback day-selected(int); |
| 32 | + // Fired when the previous-month chevron is clicked. |
| 33 | + callback prev-month(); |
| 34 | + // Fired when the next-month chevron is clicked. |
| 35 | + callback next-month(); |
| 36 | + |
| 37 | + background: transparent; |
| 38 | + preferred-width: layout.preferred-width; |
| 39 | + preferred-height: layout.preferred-height; |
| 40 | + |
| 41 | + layout := VerticalLayout { |
| 42 | + padding: 12px; |
| 43 | + spacing: 8px; |
| 44 | + |
| 45 | + // Header: ◀ Month Year ▶ |
| 46 | + HorizontalLayout { |
| 47 | + spacing: 8px; |
| 48 | + // Prev-month chevron — same tap target as the next-month one. |
| 49 | + Rectangle { |
| 50 | + width: 28px; |
| 51 | + height: 28px; |
| 52 | + border-radius: Tokens.radius-sm; |
| 53 | + background: prev-touch.has-hover |
| 54 | + ? Tokens.color-surface-1 : transparent; |
| 55 | + Icon { |
| 56 | + commands: LucidePaths.chevron-left; |
| 57 | + size: 16px; |
| 58 | + tint: Tokens.color-foreground; |
| 59 | + } |
| 60 | + prev-touch := TouchArea { |
| 61 | + mouse-cursor: pointer; |
| 62 | + clicked => { root.prev-month(); } |
| 63 | + } |
| 64 | + } |
| 65 | + VerticalLayout { |
| 66 | + horizontal-stretch: 1; |
| 67 | + alignment: center; |
| 68 | + Text { |
| 69 | + text: root.month-label; |
| 70 | + color: Tokens.color-foreground; |
| 71 | + font-size: Tokens.typography-body-size; |
| 72 | + font-weight: Tokens.typography-weight-semibold; |
| 73 | + horizontal-alignment: center; |
| 74 | + } |
| 75 | + } |
| 76 | + Rectangle { |
| 77 | + width: 28px; |
| 78 | + height: 28px; |
| 79 | + border-radius: Tokens.radius-sm; |
| 80 | + background: next-touch.has-hover |
| 81 | + ? Tokens.color-surface-1 : transparent; |
| 82 | + Icon { |
| 83 | + commands: LucidePaths.chevron-right; |
| 84 | + size: 16px; |
| 85 | + tint: Tokens.color-foreground; |
| 86 | + } |
| 87 | + next-touch := TouchArea { |
| 88 | + mouse-cursor: pointer; |
| 89 | + clicked => { root.next-month(); } |
| 90 | + } |
| 91 | + } |
| 92 | + } |
| 93 | + |
| 94 | + // Day-of-week labels — Sun-first; localize in the consumer. |
| 95 | + HorizontalLayout { |
| 96 | + spacing: 0; |
| 97 | + for label in ["S", "M", "T", "W", "T", "F", "S"]: Rectangle { |
| 98 | + horizontal-stretch: 1; |
| 99 | + height: 28px; |
| 100 | + Text { |
| 101 | + text: label; |
| 102 | + color: Tokens.color-muted-foreground; |
| 103 | + font-size: Tokens.typography-body-sm-size; |
| 104 | + horizontal-alignment: center; |
| 105 | + vertical-alignment: center; |
| 106 | + } |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + // 6-row × 7-col day grid. Each cell computes its day number from its |
| 111 | + // grid index minus the leading offset; cells outside the month render |
| 112 | + // muted (no day text, no click). |
| 113 | + for w in 6: HorizontalLayout { |
| 114 | + spacing: 0; |
| 115 | + for d in 7: Rectangle { |
| 116 | + property <int> cell-idx: w * 7 + d; |
| 117 | + property <int> day-num: cell-idx - root.first-day-offset + 1; |
| 118 | + property <bool> in-month: day-num >= 1 && day-num <= root.days-in-month; |
| 119 | + property <bool> selected: in-month && day-num == root.selected-day; |
| 120 | + horizontal-stretch: 1; |
| 121 | + height: 36px; |
| 122 | + border-radius: Tokens.radius-sm; |
| 123 | + background: selected |
| 124 | + ? Tokens.color-foreground |
| 125 | + : (cell-touch.has-hover && in-month |
| 126 | + ? Tokens.color-surface-1 |
| 127 | + : transparent); |
| 128 | + Text { |
| 129 | + text: in-month ? day-num : ""; |
| 130 | + color: selected |
| 131 | + ? Tokens.color-background |
| 132 | + : (in-month |
| 133 | + ? Tokens.color-foreground |
| 134 | + : Tokens.color-muted-foreground); |
| 135 | + font-size: Tokens.typography-body-sm-size; |
| 136 | + horizontal-alignment: center; |
| 137 | + vertical-alignment: center; |
| 138 | + } |
| 139 | + cell-touch := TouchArea { |
| 140 | + enabled: in-month; |
| 141 | + mouse-cursor: pointer; |
| 142 | + clicked => { |
| 143 | + root.selected-day = day-num; |
| 144 | + root.day-selected(day-num); |
| 145 | + } |
| 146 | + } |
| 147 | + animate background { |
| 148 | + duration: Tokens.motion-fast; |
| 149 | + easing: Tokens.motion-easing-standard; |
| 150 | + } |
| 151 | + } |
| 152 | + } |
| 153 | + } |
| 154 | +} |
0 commit comments