Skip to main content

orbital_motion/preview/
preview_shell.rs

1//! Preview shell for motion catalog pages (avoids cyclic dependency on orbital-core-components).
2
3use leptos::prelude::*;
4use leptos::tachys::view::any_view::IntoAny;
5use orbital_style::inject_style;
6use turf::inline_style_sheet_values;
7
8use super::component_doc_markdown::ComponentDocMarkdown;
9use super::component_doc_props::ComponentDocProps;
10use super::components::preview_button_styles;
11use super::tab::{PreviewTab, PreviewTabList};
12pub use super::types::{ComponentPropDoc, PreviewRenderMode};
13
14fn ensure_preview_shell_styles() {
15    inject_style("orbital-motion-preview-button", preview_button_styles());
16}
17
18#[component]
19fn OrbitalPreviewCardBody(
20    #[prop(optional, into)] code: MaybeProp<&'static str>,
21    children: Children,
22) -> impl IntoView {
23    let (show_code, set_show_code) = signal(false);
24    let (style_sheet, class_names) = inline_style_sheet_values! {
25        .Frame {
26            width: 100%;
27            border: 1px solid var(--orb-color-border-subtle);
28            border-radius: var(--orb-radius-md);
29            background: var(--orb-color-surface-canvas);
30            box-shadow: var(--orb-elev-raised-xs);
31        }
32        .PreviewDemo {
33            display: flex;
34            justify-content: center;
35            padding: var(--orb-space-block-lg) var(--orb-space-inline-lg);
36        }
37        .Toolbar {
38            display: flex;
39            justify-content: flex-end;
40            padding: var(--orb-space-block-sm) var(--orb-space-inline-lg);
41            border-top: 1px solid var(--orb-color-border-subtle);
42        }
43        .Code {
44            padding: var(--orb-space-block-lg) var(--orb-space-inline-lg);
45            background: var(--orb-color-surface-subtle);
46            border-top: 1px solid var(--orb-color-border-subtle);
47            font-family: var(--orb-type-family-mono);
48            font-size: var(--orb-type-size-xs);
49            white-space: pre-wrap;
50            color: var(--orb-color-text-primary);
51        }
52    };
53    let code_opt = move || code.get();
54
55    view! {
56        <style>{style_sheet}</style>
57        <div class=class_names.frame>
58            <div class=class_names.preview_demo>{children()}</div>
59            <Show when=move || code_opt().is_some()>
60                <div class=class_names.toolbar>
61                    <super::components::PreviewButton
62                        on_click=Callback::new(move |_| set_show_code.update(|v| *v = !*v))
63                    >
64                        {move || if show_code.get() { "Hide code" } else { "Show code" }}
65                    </super::components::PreviewButton>
66                </div>
67                <Show when=move || show_code.get()>
68                    {move || code_opt().map(|code_str| view! {
69                        <pre class=class_names.code>{code_str}</pre>
70                    })}
71                </Show>
72            </Show>
73        </div>
74    }
75}
76
77#[component]
78pub fn ComponentPreviewCard(
79    #[prop(optional, into)] title: MaybeProp<&'static str>,
80    #[prop(optional, into)] description: MaybeProp<&'static str>,
81    #[prop(optional, into)] code: MaybeProp<&'static str>,
82    #[prop(optional, into)] example_id: MaybeProp<&'static str>,
83    children: Children,
84) -> impl IntoView {
85    let (style_sheet, class_names) = inline_style_sheet_values! {
86        .ExampleTarget {
87            scroll-margin-top: var(--orb-space-block-2xl);
88        }
89        .Stack {
90            display: flex;
91            flex-direction: column;
92            gap: var(--orb-space-block-sm);
93            width: 100%;
94        }
95        .Title {
96            font-size: var(--orb-type-size-lg);
97            font-weight: var(--orb-type-weight-semibold);
98            line-height: var(--orb-type-line-xl);
99            margin: 0;
100            color: var(--orb-color-text-primary);
101        }
102        .Description {
103            color: var(--orb-color-text-secondary);
104            font-size: var(--orb-type-size-sm);
105            line-height: var(--orb-type-line-md);
106        }
107    };
108
109    view! {
110        <style>{style_sheet}</style>
111        <div class=class_names.example_target id=move || example_id.get()>
112            <div class=class_names.stack>
113                <h3 class=class_names.title>{move || title.get().unwrap_or("Default")}</h3>
114                {move || description.get().filter(|d| !d.is_empty()).map(|text| {
115                    view! {
116                        <div class=class_names.description>
117                            <ComponentDocMarkdown source=text />
118                        </div>
119                    }
120                })}
121                <OrbitalPreviewCardBody code=code>{children()}</OrbitalPreviewCardBody>
122            </div>
123        </div>
124    }
125}
126
127#[component]
128pub fn OrbitalComponentView<IV>(
129    component_name: &'static str,
130    #[prop(optional)] component_description: Option<&'static str>,
131    #[prop(optional)] component_description_md: Option<&'static str>,
132    #[prop(optional)] component_props: Option<&'static [ComponentPropDoc]>,
133    #[prop(optional)] component_best_practices: Option<&'static str>,
134    #[prop(optional)] component_best_practices_md: Option<&'static str>,
135    default: IV,
136    #[prop(optional, into)] default_code: MaybeProp<&'static str>,
137    #[prop(optional, into)] default_example_title: MaybeProp<&'static str>,
138    #[prop(optional, into)] default_description: MaybeProp<&'static str>,
139    #[prop(optional, into)] default_example_id: MaybeProp<&'static str>,
140    #[prop(optional)] example_anchors: Option<&'static [(&'static str, &'static str)]>,
141    #[prop(optional)] children: Option<Children>,
142) -> impl IntoView
143where
144    IV: IntoView + 'static,
145{
146    ensure_preview_shell_styles();
147
148    if use_context::<PreviewRenderMode>() == Some(PreviewRenderMode::BareDefault) {
149        return view! {
150            <div data-testid="debug-bare-preview">{default}</div>
151        }
152        .into_any();
153    }
154
155    let active_tab = RwSignal::new("description".to_string());
156    let show_aside = example_anchors.is_some_and(|anchors| !anchors.is_empty());
157
158    let (style_sheet, class_names) = inline_style_sheet_values! {
159        .Page {
160            display: flex;
161            flex-direction: column;
162            gap: var(--orb-space-block-2xl);
163            width: 100%;
164            font-family: var(--orb-type-family-sans);
165            color: var(--orb-color-text-primary);
166        }
167        .InfoSection {
168            border-bottom: 1px solid var(--orb-color-border-subtle);
169            padding-bottom: var(--orb-space-block-xl);
170        }
171        .DocStack {
172            display: flex;
173            flex-direction: column;
174            gap: var(--orb-space-block-md);
175            width: 100%;
176        }
177        .Title {
178            font-size: var(--orb-type-size-2xl);
179            font-weight: var(--orb-type-weight-semibold);
180            line-height: 1.286;
181            margin: 0;
182            display: block;
183        }
184        .Body {
185            font-size: var(--orb-type-size-sm);
186            line-height: var(--orb-type-line-md);
187            margin: 0;
188        }
189        .Examples {
190            padding-top: var(--orb-space-block-xl);
191        }
192        .ExamplesStack {
193            display: flex;
194            flex-direction: column;
195            gap: 40px;
196            width: 100%;
197        }
198        .AsideLayout {
199            display: grid;
200            grid-template-columns: 1fr 200px;
201            gap: var(--orb-space-inline-xl);
202            width: 100%;
203        }
204        .AsideTitle {
205            font-size: var(--orb-type-size-md);
206            font-weight: var(--orb-type-weight-semibold);
207            color: var(--orb-color-text-primary);
208            margin: 0 0 var(--orb-space-block-sm);
209        }
210        .AsideList {
211            margin: 0;
212            padding: 0;
213            list-style: none;
214            display: flex;
215            flex-direction: column;
216            gap: var(--orb-space-block-xs);
217        }
218        .AsideLink {
219            color: var(--orb-color-brand-link);
220            text-decoration: none;
221            font-size: var(--orb-type-size-sm);
222        }
223        .AsideLink:hover {
224            text-decoration: underline;
225        }
226    };
227
228    let page = view! {
229        <style>{style_sheet}</style>
230        <div class=class_names.page>
231            <div data-testid="preview-doc-panel">
232                <div class=class_names.doc_stack>
233                    <h1 class=class_names.title data-testid="preview-page-title">{component_name}</h1>
234                    <div class=class_names.info_section>
235                        <PreviewTabList selected_value=active_tab>
236                            <PreviewTab value="description">"Description"</PreviewTab>
237                            <PreviewTab value="best_practices">"Best Practices"</PreviewTab>
238                            <PreviewTab value="properties">"Properties"</PreviewTab>
239                        </PreviewTabList>
240                        <div data-testid="preview-doc-content">
241                            {move || match active_tab.get().as_str() {
242                                "description" => {
243                                    if let Some(md) = component_description_md.filter(|s| !s.is_empty()) {
244                                        view! { <ComponentDocMarkdown source=md /> }.into_any()
245                                    } else {
246                                        view! {
247                                            <p class=class_names.body>
248                                                {component_description.unwrap_or("No description documented for this component.")}
249                                            </p>
250                                        }.into_any()
251                                    }
252                                }
253                                "best_practices" => {
254                                    if let Some(md) = component_best_practices_md.filter(|s| !s.is_empty()) {
255                                        view! { <ComponentDocMarkdown source=md /> }.into_any()
256                                    } else {
257                                        view! {
258                                            <p class=class_names.body>
259                                                {component_best_practices.unwrap_or("No best practices documented for this component.")}
260                                            </p>
261                                        }.into_any()
262                                    }
263                                }
264                                "properties" => view! {
265                                    <ComponentDocProps props=component_props.unwrap_or(&[]) />
266                                }.into_any(),
267                                _ => view! {
268                                    <p class=class_names.body>
269                                        {component_description.unwrap_or("No description documented for this component.")}
270                                    </p>
271                                }.into_any(),
272                            }}
273                        </div>
274                    </div>
275                </div>
276            </div>
277            <div data-testid="preview-examples" class=class_names.examples>
278                <div class=class_names.examples_stack>
279                    <ComponentPreviewCard
280                        title=default_example_title
281                        description=default_description
282                        code=default_code
283                        example_id=default_example_id
284                    >
285                        {default}
286                    </ComponentPreviewCard>
287                    {children.map(|children| children()).unwrap_or_else(|| ().into_any())}
288                </div>
289            </div>
290        </div>
291    };
292
293    view! {
294        {if show_aside {
295            view! {
296                <div class=class_names.aside_layout>
297                    {page}
298                    <aside data-testid="preview-example-nav">
299                        <p class=class_names.aside_title>"On this page"</p>
300                        <ul class=class_names.aside_list>
301                            {example_anchors.unwrap_or(&[]).iter().map(|(title, slug)| {
302                                let href = format!("#example-{slug}");
303                                let label = (*title).to_string();
304                                view! { <li><a class=class_names.aside_link href=href>{label}</a></li> }
305                            }).collect_view()}
306                        </ul>
307                    </aside>
308                </div>
309            }.into_any()
310        } else {
311            page.into_any()
312        }}
313    }
314    .into_any()
315}