1use leptos::{prelude::*, tachys::view::any_view::IntoAny};
42use orbital_base_components::ThemeColor;
43use orbital_style::inject_style;
44use turf::inline_style_sheet_values;
45
46#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
47pub enum TextTag {
48 B,
49 Code,
50 Div,
51 Em,
52 H1,
53 H2,
54 H3,
55 H4,
56 H5,
57 H6,
58 I,
59 Label,
60 P,
61 Pre,
62 #[default]
63 Span,
64 Strong,
65}
66
67#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
68pub enum TextAlign {
69 Center,
70 End,
71 Justify,
72 #[default]
73 Start,
74}
75
76#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
77pub enum TextFont {
78 #[default]
79 Base,
80 Monospace,
81 Numeric,
82}
83
84#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
85pub enum TextWeight {
86 Bold,
87 Medium,
88 #[default]
89 Regular,
90 Semibold,
91}
92
93#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
94pub enum TextSize {
95 S100,
96 S200,
97 #[default]
98 S300,
99 S400,
100 S500,
101 S600,
102 S700,
103 S800,
104 S900,
105 S1000,
106}
107
108#[component]
135pub fn Text(
136 #[prop(optional)]
138 tag: TextTag,
139 #[prop(optional)]
141 align: TextAlign,
142 #[prop(optional)]
144 block: bool,
145 #[prop(optional)]
147 font: TextFont,
148 #[prop(optional)]
150 italic: bool,
151 #[prop(optional)]
153 size: TextSize,
154 #[prop(optional)]
156 strikethrough: bool,
157 #[prop(optional)]
159 truncate: bool,
160 #[prop(optional)]
162 underline: bool,
163 #[prop(optional)]
165 weight: TextWeight,
166 #[prop(default = true)]
170 wrap: bool,
171 #[prop(optional, into)]
173 color: MaybeProp<ThemeColor>,
174 #[prop(optional, into)] class: MaybeProp<String>,
175 #[prop(optional, into)] style: MaybeProp<String>,
176 #[prop(optional, into)]
178 test_id: MaybeProp<String>,
179 children: Children,
180) -> impl IntoView {
181 let (style_sheet, class_names) = inline_style_sheet_values! {
182 .Root {
183 display: inline;
184 font-family: inherit;
185 margin: 0;
186 color: var(--orb-color-text-primary);
187 }
188
189 .Block {
190 display: block;
191 }
192
193 .AlignStart {
194 text-align: start;
195 }
196 .AlignCenter {
197 text-align: center;
198 }
199 .AlignEnd {
200 text-align: end;
201 }
202 .AlignJustify {
203 text-align: justify;
204 }
205
206 .FontBase {
207 font-family: var(--orb-type-family-sans);
208 }
209 .FontNumeric {
210 font-family: var(--orb-type-family-numeric);
211 font-variant-numeric: tabular-nums;
212 }
213 .FontMonospace {
214 font-family: var(--orb-type-family-mono);
215 }
216
217 .Italic {
218 font-style: italic;
219 }
220
221 .Underline {
222 text-decoration-line: underline;
223 }
224
225 .Strikethrough {
226 text-decoration-line: line-through;
227 }
228
229 .Underline.Strikethrough {
230 text-decoration-line: underline line-through;
231 }
232
233 .Wrap {
234 white-space: normal;
235 overflow-wrap: break-word;
236 }
237
238 .NoWrap {
239 white-space: nowrap;
240 }
241
242 .Truncate {
243 overflow: hidden;
244 text-overflow: ellipsis;
245 white-space: nowrap;
246 max-width: 100%;
247 display: inline-block;
248 }
249
250 .Block.Truncate {
251 display: block;
252 }
253
254 .Size100 { font-size: var(--orb-type-size-2xs); line-height: var(--orb-type-line-sm); }
256 .Size200 { font-size: var(--orb-type-size-xs); line-height: var(--orb-type-line-sm); }
257 .Size300 { font-size: var(--orb-type-size-sm); line-height: var(--orb-type-line-md); }
258 .Size400 { font-size: var(--orb-type-size-md); line-height: var(--orb-type-line-lg); }
259 .Size500 { font-size: var(--orb-type-size-lg); line-height: var(--orb-type-line-xl); }
260 .Size600 { font-size: var(--orb-type-size-xl); line-height: 1.333; }
261 .Size700 { font-size: var(--orb-type-size-2xl); line-height: 1.286; }
262 .Size800 { font-size: var(--orb-type-size-3xl); line-height: 1.25; }
263 .Size900 { font-size: var(--orb-type-size-4xl); line-height: 1.3; }
264 .Size1000 { font-size: var(--orb-type-size-5xl); line-height: 1.35; }
265
266 .WeightRegular { font-weight: var(--orb-type-weight-regular); }
267 .WeightMedium { font-weight: 500; }
268 .WeightSemibold { font-weight: var(--orb-type-weight-semibold); }
269 .WeightBold { font-weight: var(--orb-type-weight-bold); }
270 };
271 inject_style("orbital-text", style_sheet);
272
273 let class = Signal::derive(move || {
274 let mut classes: Vec<String> = vec![class_names.root.to_string()];
275
276 if block {
277 classes.push(class_names.block.to_string());
278 }
279
280 classes.push(
281 match align {
282 TextAlign::Start => class_names.align_start,
283 TextAlign::Center => class_names.align_center,
284 TextAlign::End => class_names.align_end,
285 TextAlign::Justify => class_names.align_justify,
286 }
287 .to_string(),
288 );
289
290 classes.push(
291 match font {
292 TextFont::Base => class_names.font_base,
293 TextFont::Numeric => class_names.font_numeric,
294 TextFont::Monospace => class_names.font_monospace,
295 }
296 .to_string(),
297 );
298
299 if italic {
300 classes.push(class_names.italic.to_string());
301 }
302 if underline {
303 classes.push(class_names.underline.to_string());
304 }
305 if strikethrough {
306 classes.push(class_names.strikethrough.to_string());
307 }
308
309 classes.push(
310 match size {
311 TextSize::S100 => class_names.size_100,
312 TextSize::S200 => class_names.size_200,
313 TextSize::S300 => class_names.size_300,
314 TextSize::S400 => class_names.size_400,
315 TextSize::S500 => class_names.size_500,
316 TextSize::S600 => class_names.size_600,
317 TextSize::S700 => class_names.size_700,
318 TextSize::S800 => class_names.size_800,
319 TextSize::S900 => class_names.size_900,
320 TextSize::S1000 => class_names.size_1000,
321 }
322 .to_string(),
323 );
324
325 classes.push(
326 match weight {
327 TextWeight::Regular => class_names.weight_regular,
328 TextWeight::Medium => class_names.weight_medium,
329 TextWeight::Semibold => class_names.weight_semibold,
330 TextWeight::Bold => class_names.weight_bold,
331 }
332 .to_string(),
333 );
334
335 if truncate {
336 classes.push(class_names.truncate.to_string());
337 }
338
339 classes.push(if wrap {
340 class_names.wrap.to_string()
341 } else {
342 class_names.no_wrap.to_string()
343 });
344
345 if let Some(extra) = class.get() {
346 if !extra.trim().is_empty() {
347 classes.push(extra);
348 }
349 }
350
351 classes.join(" ")
352 });
353
354 let merged_style = move || {
355 let mut parts = Vec::new();
356 if let Some(c) = color.get() {
357 parts.push(format!("color: {}", c.css_var()));
358 }
359 if let Some(extra) = style.get() {
360 if !extra.is_empty() {
361 parts.push(extra);
362 }
363 }
364 if parts.is_empty() {
365 None
366 } else {
367 Some(parts.join("; "))
368 }
369 };
370
371 let rendered = match tag {
372 TextTag::B => {
373 view! { <b class=class style=merged_style data-testid=test_id>{children()}</b> }.into_any()
374 }
375 TextTag::Code => {
376 view! { <code class=class style=merged_style data-testid=test_id>{children()}</code> }
377 .into_any()
378 }
379 TextTag::Div => {
380 view! { <div class=class style=merged_style data-testid=test_id>{children()}</div> }.into_any()
381 }
382 TextTag::Em => {
383 view! { <em class=class style=merged_style data-testid=test_id>{children()}</em> }.into_any()
384 }
385 TextTag::H1 => {
386 view! { <h1 class=class style=merged_style data-testid=test_id>{children()}</h1> }.into_any()
387 }
388 TextTag::H2 => {
389 view! { <h2 class=class style=merged_style data-testid=test_id>{children()}</h2> }.into_any()
390 }
391 TextTag::H3 => {
392 view! { <h3 class=class style=merged_style data-testid=test_id>{children()}</h3> }.into_any()
393 }
394 TextTag::H4 => {
395 view! { <h4 class=class style=merged_style data-testid=test_id>{children()}</h4> }.into_any()
396 }
397 TextTag::H5 => {
398 view! { <h5 class=class style=merged_style data-testid=test_id>{children()}</h5> }.into_any()
399 }
400 TextTag::H6 => {
401 view! { <h6 class=class style=merged_style data-testid=test_id>{children()}</h6> }.into_any()
402 }
403 TextTag::I => {
404 view! { <i class=class style=merged_style data-testid=test_id>{children()}</i> }.into_any()
405 }
406 TextTag::Label => {
407 view! { <label class=class style=merged_style data-testid=test_id>{children()}</label> }
408 .into_any()
409 }
410 TextTag::P => {
411 view! { <p class=class style=merged_style data-testid=test_id>{children()}</p> }.into_any()
412 }
413 TextTag::Pre => {
414 view! { <pre class=class style=merged_style data-testid=test_id>{children()}</pre> }.into_any()
415 }
416 TextTag::Span => {
417 view! { <span class=class style=merged_style data-testid=test_id>{children()}</span> }
418 .into_any()
419 }
420 TextTag::Strong => {
421 view! { <strong class=class style=merged_style data-testid=test_id>{children()}</strong> }
422 .into_any()
423 }
424 };
425
426 view! {
427 {rendered}
428 }
429}
430
431macro_rules! text_preset {
432 ($name:ident, size: $size:expr, weight: $weight:expr) => {
433 #[component]
434 pub fn $name(
435 #[prop(optional)] tag: TextTag,
436 #[prop(optional)] align: TextAlign,
437 #[prop(optional)] block: bool,
438 #[prop(default = true)] wrap: bool,
439 #[prop(optional)] italic: bool,
440 #[prop(optional)] underline: bool,
441 #[prop(optional)] strikethrough: bool,
442 #[prop(optional)] truncate: bool,
443 #[prop(optional, into)] color: MaybeProp<ThemeColor>,
444 #[prop(optional, into)] class: MaybeProp<String>,
445 #[prop(optional, into)] style: MaybeProp<String>,
446 #[prop(optional, into)] test_id: MaybeProp<String>,
447 children: Children,
448 ) -> impl IntoView {
449 view! {
450 <Text
451 tag=tag
452 align=align
453 block=block
454 wrap=wrap
455 italic=italic
456 underline=underline
457 strikethrough=strikethrough
458 truncate=truncate
459 color=color
460 class=class
461 style=style
462 test_id=test_id
463 size=$size
464 weight=$weight
465 >
466 {children()}
467 </Text>
468 }
469 }
470 };
471}
472
473text_preset!(Caption2, size: TextSize::S200, weight: TextWeight::Regular);
477text_preset!(Caption2Strong, size: TextSize::S200, weight: TextWeight::Semibold);
478text_preset!(Caption1, size: TextSize::S100, weight: TextWeight::Regular);
479text_preset!(Caption1Strong, size: TextSize::S100, weight: TextWeight::Semibold);
480text_preset!(Caption1Stronger, size: TextSize::S100, weight: TextWeight::Bold);
481
482text_preset!(Body1, size: TextSize::S300, weight: TextWeight::Regular);
483text_preset!(Body1Strong, size: TextSize::S300, weight: TextWeight::Semibold);
484text_preset!(Body1Stronger, size: TextSize::S300, weight: TextWeight::Bold);
485text_preset!(Body2, size: TextSize::S400, weight: TextWeight::Regular);
486
487text_preset!(Subtitle2, size: TextSize::S400, weight: TextWeight::Semibold);
489text_preset!(Subtitle2Stronger, size: TextSize::S400, weight: TextWeight::Bold);
490text_preset!(Subtitle1, size: TextSize::S400, weight: TextWeight::Semibold);
493
494text_preset!(Title3, size: TextSize::S500, weight: TextWeight::Semibold);
495text_preset!(Title2, size: TextSize::S600, weight: TextWeight::Semibold);
496text_preset!(Title1, size: TextSize::S700, weight: TextWeight::Semibold);
497text_preset!(LargeTitle, size: TextSize::S800, weight: TextWeight::Semibold);
498text_preset!(Display, size: TextSize::S1000, weight: TextWeight::Semibold);
499
500#[component]
505pub fn FormLabel(
506 #[prop(optional, into)] class: MaybeProp<String>,
507 #[prop(optional, into)] style: MaybeProp<String>,
508 #[prop(optional, into)] test_id: MaybeProp<String>,
509 children: Children,
510) -> impl IntoView {
511 view! {
512 <Caption2 tag=TextTag::Label block=true class=class style=style test_id=test_id>
513 {children()}
514 </Caption2>
515 }
516}
517
518#[component]
522pub fn FormHint(
523 #[prop(optional, into)] class: MaybeProp<String>,
524 #[prop(optional, into)] style: MaybeProp<String>,
525 #[prop(optional, into)] test_id: MaybeProp<String>,
526 children: Children,
527) -> impl IntoView {
528 let (style_sheet, class_names) = inline_style_sheet_values! {
529 .FormHint {
530 color: var(--orb-color-text-tertiary);
531 margin-top: 4px;
532 }
533 };
534 inject_style("orbital-form-hint", style_sheet);
535 let base = class_names.form_hint.to_string();
537 let class_val = match class.get() {
538 None => base.clone(),
539 Some(ref s) if s.trim().is_empty() => base.clone(),
540 Some(ref s) => format!("{} {}", base, s),
541 };
542 view! {
543 <Caption2 block=true class=class_val style=style test_id=test_id>
544 {children()}
545 </Caption2>
546 }
547}
548
549#[component]
553pub fn SectionTitle(
554 #[prop(optional, into)] class: MaybeProp<String>,
555 #[prop(optional, into)] style: MaybeProp<String>,
556 #[prop(optional, into)] test_id: MaybeProp<String>,
557 children: Children,
558) -> impl IntoView {
559 let (style_sheet, class_names) = inline_style_sheet_values! {
560 .SectionTitle {
561 color: var(--orb-color-text-primary);
562 }
563 };
564 inject_style("orbital-section-title", style_sheet);
565 let base = class_names.section_title.to_string();
566 let class_val = match class.get() {
567 None => base.clone(),
568 Some(ref s) if s.trim().is_empty() => base.clone(),
569 Some(ref s) => format!("{} {}", base, s),
570 };
571 view! {
572 <Caption1Strong block=true class=class_val style=style test_id=test_id>
573 {children()}
574 </Caption1Strong>
575 }
576}