Skip to main content

logicaffeine_web/ui/components/
learn_roadmap.rs

1//! The Learn roadmap: the course as one route, era by era, a stop for every module and, inside the
2//! open module, a stop for every lesson.
3//!
4//! Each module is a native disclosure: the module holding the open lesson starts unfolded, the rest
5//! folded, and any can be unfolded by hand. Every lesson is a button whatever its state: nothing on
6//! the route is locked. A finished lesson carries `data-done="true"`, the open one
7//! `aria-current="step"`. Each module's element carries the module id, where `/learn#<module>` and
8//! the site nav's links land.
9
10use dioxus::prelude::*;
11use crate::content::ContentEngine;
12
13const ROADMAP_STYLE: &str = r#"
14.learn-roadmap {
15    --route-line: rgba(var(--accent-secondary-rgb), 0.35);
16    scroll-margin-top: 112px;
17}
18
19.roadmap-era + .roadmap-era {
20    margin-top: var(--spacing-xl);
21}
22
23.roadmap-era-title {
24    font-size: var(--font-body-md);
25    font-weight: 700;
26    color: var(--text-secondary);
27    margin: 0 0 var(--spacing-sm);
28}
29
30.roadmap-modules {
31    list-style: none;
32    margin: 0;
33    padding: 0;
34    position: relative;
35}
36
37/* The route: one line through every stop of an era. */
38.roadmap-modules::before {
39    content: "";
40    position: absolute;
41    left: 11px;
42    top: 14px;
43    bottom: 14px;
44    width: 2px;
45    background: var(--route-line);
46    border-radius: 1px;
47}
48
49.roadmap-module {
50    position: relative;
51}
52
53.roadmap-module > summary {
54    list-style: none;
55    display: grid;
56    grid-template-columns: 24px minmax(0, 1fr) auto;
57    align-items: center;
58    column-gap: var(--spacing-md);
59    padding: 8px 4px 8px 0;
60    cursor: pointer;
61    border-radius: var(--radius-md);
62}
63
64.roadmap-module > summary::-webkit-details-marker {
65    display: none;
66}
67
68.roadmap-module > summary:focus-visible {
69    outline: 2px solid var(--accent-primary);
70    outline-offset: 2px;
71}
72
73/* A module's stop on the route: hollow ahead, ringed while open, filled once finished. */
74.roadmap-stop {
75    position: relative;
76    z-index: 1;
77    width: 24px;
78    height: 24px;
79    border-radius: 50%;
80    border: 2px solid var(--route-line);
81    background: var(--bg-gradient-start);
82    display: grid;
83    place-items: center;
84    font-size: 11px;
85    font-weight: 700;
86    color: var(--text-tertiary);
87}
88
89.roadmap-module[data-state="current"] .roadmap-stop {
90    border-color: var(--accent-primary);
91    color: var(--accent-primary);
92    box-shadow: 0 0 0 4px rgba(var(--accent-primary-rgb), 0.14);
93}
94
95.roadmap-module[data-state="done"] .roadmap-stop {
96    border-color: var(--accent-secondary);
97    background: var(--accent-secondary);
98    color: var(--bg-gradient-start);
99}
100
101.roadmap-module-title {
102    margin: 0;
103    font-size: var(--font-body-md);
104    font-weight: 600;
105    line-height: 1.35;
106    color: var(--text-primary);
107}
108
109.roadmap-module-count {
110    font-size: var(--font-caption-md);
111    color: var(--text-tertiary);
112    white-space: nowrap;
113}
114
115.roadmap-lessons {
116    list-style: none;
117    margin: 2px 0 var(--spacing-sm);
118    padding: 0 0 0 36px;
119}
120
121.roadmap-lesson {
122    position: relative;
123    display: grid;
124    grid-template-columns: 2.6em minmax(0, 1fr);
125    align-items: baseline;
126    width: 100%;
127    min-height: 40px;
128    padding: 8px 10px;
129    border: 1px solid transparent;
130    border-radius: var(--radius-md);
131    background: none;
132    color: var(--text-secondary);
133    font: inherit;
134    font-size: var(--font-body-sm);
135    line-height: 1.35;
136    text-align: left;
137    cursor: pointer;
138}
139
140/* A lesson's stop: a small dot on the route, filled once finished. */
141.roadmap-lesson::before {
142    content: "";
143    position: absolute;
144    left: -30px;
145    top: 50%;
146    width: 10px;
147    height: 10px;
148    margin-top: -5px;
149    border-radius: 50%;
150    border: 2px solid var(--route-line);
151    background: var(--bg-gradient-start);
152}
153
154.roadmap-lesson[data-done="true"]::before {
155    border-color: var(--accent-secondary);
156    background: var(--accent-secondary);
157}
158
159.roadmap-lesson:hover {
160    background: rgba(255, 255, 255, 0.04);
161    color: var(--text-primary);
162}
163
164.roadmap-lesson:focus-visible {
165    outline: 2px solid var(--accent-primary);
166    outline-offset: 1px;
167}
168
169.roadmap-lesson[aria-current="step"] {
170    background: rgba(var(--accent-primary-rgb), 0.1);
171    border-color: rgba(var(--accent-primary-rgb), 0.35);
172    color: var(--text-primary);
173    font-weight: 600;
174}
175
176.roadmap-lesson[aria-current="step"]::before {
177    border-color: var(--accent-primary);
178    background: var(--accent-primary);
179}
180
181.roadmap-lesson-number {
182    font-variant-numeric: tabular-nums;
183    color: var(--text-tertiary);
184}
185
186.roadmap-sr {
187    position: absolute;
188    width: 1px;
189    height: 1px;
190    margin: -1px;
191    padding: 0;
192    overflow: hidden;
193    clip: rect(0 0 0 0);
194    white-space: nowrap;
195    border: 0;
196}
197"#;
198
199/// The route through every era, module and lesson. `open` is the lesson on screen, `done` the
200/// lessons finished; a click on a lesson calls `on_open` with its id.
201#[component]
202pub fn LearnRoadmap(open: Option<String>, done: Vec<String>, on_open: EventHandler<String>) -> Element {
203    let is_done = |id: &str| done.iter().any(|d| d == id);
204    let mut number = 0usize;
205    let eras: Vec<_> = ContentEngine::shared()
206        .eras()
207        .iter()
208        .map(|era| {
209            let modules: Vec<_> = era.modules.iter().map(|m| {
210                number += 1;
211                (number, m)
212            }).collect();
213            (era, modules)
214        })
215        .collect();
216    rsx! {
217        style { "{ROADMAP_STYLE}" }
218        nav { class: "learn-roadmap", id: "roadmap", aria_label: "Roadmap",
219            for (era, modules) in eras {
220                section { class: "roadmap-era", key: "{era.meta.id}", aria_labelledby: "roadmap-era-{era.meta.id}",
221                    h2 { class: "roadmap-era-title", id: "roadmap-era-{era.meta.id}", "{era.meta.title}" }
222                    ol { class: "roadmap-modules",
223                        for (n, module) in modules {
224                            {
225                                let lessons = &module.sections;
226                                let finished = lessons.iter().filter(|s| is_done(&s.id)).count();
227                                let holds_open = lessons.iter().any(|s| open.as_deref() == Some(s.id.as_str()));
228                                let state = if holds_open {
229                                    "current"
230                                } else if finished == lessons.len() && !lessons.is_empty() {
231                                    "done"
232                                } else {
233                                    "ahead"
234                                };
235                                rsx! {
236                                    li { key: "{module.meta.id}",
237                                        details {
238                                            class: "roadmap-module",
239                                            id: "{module.meta.id}",
240                                            "data-state": "{state}",
241                                            open: holds_open,
242                                            summary {
243                                                span { class: "roadmap-stop", aria_hidden: "true", "{n}" }
244                                                h3 { class: "roadmap-module-title", "{module.meta.title}" }
245                                                span { class: "roadmap-module-count",
246                                                    "{finished} of {lessons.len()}"
247                                                    span { class: "roadmap-sr", " lessons done" }
248                                                }
249                                            }
250                                            ol { class: "roadmap-lessons",
251                                                for section in lessons.iter() {
252                                                    {
253                                                        let id = section.id.clone();
254                                                        let current = open.as_deref() == Some(id.as_str());
255                                                        let finished = is_done(&id);
256                                                        rsx! {
257                                                            li { key: "{id}",
258                                                                button {
259                                                                    class: "roadmap-lesson",
260                                                                    r#type: "button",
261                                                                    "data-lesson": "{id}",
262                                                                    "data-done": "{finished}",
263                                                                    aria_current: if current { "step" } else { "false" },
264                                                                    onclick: {
265                                                                        let id = id.clone();
266                                                                        move |_| on_open.call(id.clone())
267                                                                    },
268                                                                    span { class: "roadmap-lesson-number", "{id}" }
269                                                                    span { class: "roadmap-lesson-title",
270                                                                        "{section.title}"
271                                                                        if finished {
272                                                                            span { class: "roadmap-sr", ", done" }
273                                                                        }
274                                                                    }
275                                                                }
276                                                            }
277                                                        }
278                                                    }
279                                                }
280                                            }
281                                        }
282                                    }
283                                }
284                            }
285                        }
286                    }
287                }
288            }
289        }
290    }
291}