orbital_motion/preview/
preview_shell.rs1use 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}