Skip to main content

orbital_core_components/
text.rs

1//! Orbital typography — [`Text`] and scale presets.
2//!
3//! Pick a typography preset (`Body1`, `Title2`, `Display`, …) for consistent hierarchy across
4//! your app. Drop to [`Text`] when you need explicit [`TextSize`], [`TextWeight`], font family,
5//! or decoration props. Sizes and weights come from the active theme — avoid raw CSS font values.
6//! For forms, prefer [`FormLabel`], [`FormHint`], and [`SectionTitle`] inside a
7//! [`Field`](crate::Field).
8//!
9//! # Preset roles
10//!
11//! | Role | Preset | Token | Use for |
12//! | --- | --- | --- | --- |
13//! | Page / shell title | [`Title3`]–[`Title1`] | `lg`–`2xl` | App bar, dialog title, panel primary heading |
14//! | Content section / card title | [`Subtitle1`] | `md` + semibold | Card headers, preview sections, entity display names |
15//! | Form / utility group label | [`SectionTitle`] | `2xs` + semibold | Form clusters, TOC rail headings, dense settings |
16//! | Body copy | [`Body1`] / [`Body2`] | `sm` / `md` regular | Paragraphs, descriptions |
17//! | Metadata | [`Caption1`] / [`Caption2`] | `2xs` / `xs` | Timestamps, badges, hints |
18//!
19//! **Floor rule:** Named section labels (nav groups, card titles, author names, persona primary
20//! line) must not render below **Body1** (`--orb-type-size-sm`, 14px).
21//!
22//! **Naming rule:** [`Subtitle1`] and [`Subtitle2`] are identical (S400 / semibold). Prefer
23//! [`Subtitle1`] for section and card titles; reserve [`Subtitle2`] for empty-state headlines.
24//!
25//! # CSS-backed components
26//!
27//! Some components style titles in CSS rather than presets. Align wrapper tokens with the preset
28//! equivalents — when slot content uses a preset (e.g. [`PersonaPrimaryText`](crate::PersonaPrimaryText)),
29//! the wrapper CSS should match so styles do not fight.
30//!
31//! | Class | Token | Preset equivalent |
32//! | --- | --- | --- |
33//! | `.orbital-dialog-title` | `lg` semibold | [`Title3`] |
34//! | `.orbital-data-table__list-card-title` | `md` semibold | [`Subtitle1`] |
35//! | `.orbital-persona__primary-text` | `md` semibold | [`Subtitle1`] |
36//! | `.orbital-discussion__author-name` | `md` semibold | [`Subtitle1`] |
37//! | `.SectionHeader` (navigation) | `md` semibold | [`Subtitle1`] |
38//! | `.SectionHeaderBand` (navigation) | `sm` semibold | [`Body1Strong`] |
39//! | `.orbital-card-header__description` | `xs` | [`Caption2`] |
40
41use 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/// Low-level typography control when presets do not match your need.
109///
110/// Prefer named presets ([`Body1`], [`Title2`], [`Display`], …) for hierarchy — each locks `size` and `weight` to a step on Orbital's typography scale. Use `Text` when you need a custom token combination, a semantic [`TextTag`], or decorations without a matching preset.
111///
112/// There is no bundled `variant` prop — pick a preset or set [`TextSize`] and [`TextWeight`] explicitly. Use [`tag`] to choose the rendered HTML element; use [`truncate`] for single-line ellipsis inside a bounded container.
113///
114/// # When to use
115///
116/// - Explicit size/weight/font combinations outside the preset matrix - Semantic HTML via [`TextTag`] (`H1`, `P`, `Code`, …) with token styling - One-off decorations (`italic`, `underline`, `strikethrough`) on any scale step - For field labels, hints, and settings headings, use [`FormLabel`], [`FormHint`], or [`SectionTitle`] instead
117///
118/// # Examples
119///
120/// ## Preset vs low-level
121///
122/// ```
123/// use leptos::prelude::*;
124/// use orbital_core_components::{Body1, Text, TextSize, TextWeight};
125///
126/// #[component]
127/// fn Example() -> impl IntoView {
128///     view! {
129///         <Body1>"Default body copy from the typography scale."</Body1>
130///         <Text size=TextSize::S400 weight=TextWeight::Semibold>"Custom emphasis step."</Text>
131///     }
132/// }
133/// ```
134#[component]
135pub fn Text(
136    /// HTML element to render — default `Span`. Use `H1`–`H6`, `P`, `Label`, or `Code` for document structure.
137    #[prop(optional)]
138    tag: TextTag,
139    /// Horizontal alignment inside a block container: `Start`, `Center`, `End`, or `Justify`.
140    #[prop(optional)]
141    align: TextAlign,
142    /// Render as a block-level box (`display: block`) instead of inline.
143    #[prop(optional)]
144    block: bool,
145    /// Font family preset: `Base`, `Numeric` (tabular figures), or `Monospace`.
146    #[prop(optional)]
147    font: TextFont,
148    /// Italic emphasis.
149    #[prop(optional)]
150    italic: bool,
151    /// Typography scale step from theme tokens (`S100` smallest through `S1000` largest).
152    #[prop(optional)]
153    size: TextSize,
154    /// Line-through decoration for deleted or superseded copy.
155    #[prop(optional)]
156    strikethrough: bool,
157    /// Single-line ellipsis overflow — pair with `block=true` inside a bounded-width container.
158    #[prop(optional)]
159    truncate: bool,
160    /// Underline decoration — use for inline links or emphasized phrases.
161    #[prop(optional)]
162    underline: bool,
163    /// Font weight preset: `Regular`, `Medium`, `Semibold`, or `Bold`.
164    #[prop(optional)]
165    weight: TextWeight,
166    /// Allow line breaks at whitespace. Set `false` to keep copy on one line (may overflow).
167    ///
168    /// Note: use `#[prop(default = true)]`, not `#[prop(optional = true)]`. In Leptos, `optional` only means the prop may be omitted; omitted `bool` still defaults to `false`.
169    #[prop(default = true)]
170    wrap: bool,
171    /// Foreground color from the active theme palette.
172    #[prop(optional, into)]
173    color: MaybeProp<ThemeColor>,
174    #[prop(optional, into)] class: MaybeProp<String>,
175    #[prop(optional, into)] style: MaybeProp<String>,
176    /// Test id hook (maps to `data-testid`).
177    #[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        // Typography scale tracks theme tokens on the active OrbitalThemeProvider scope.
255        .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
473// Typography presets — each locks `size` + `weight` to a scale step.
474// Pass `tag`, decorations, `color`, and other layout props through; `size`, `weight`, and `font`
475// are fixed per preset.
476text_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
487/// Empty-state headlines and ramp specimens — S400 / semibold. Same size as [`Subtitle1`].
488text_preset!(Subtitle2, size: TextSize::S400, weight: TextWeight::Semibold);
489text_preset!(Subtitle2Stronger, size: TextSize::S400, weight: TextWeight::Bold);
490/// Section and card titles — S400 / semibold. Prefer for card headers, preview sections,
491/// and in-page doc sections.
492text_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// Form-specific convenience components (Orbital-only).
501/// Accessible field label styled as `Caption2` with a native `<label>` element.
502///
503/// Prefer inside a [`Field`](crate::Field) over hand-rolling `Caption2` with [`TextTag::Label`].
504#[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/// Supplementary hint below a form control — format guidance, validation context, or privacy notes.
519///
520/// Renders muted `Caption2` copy. Pair with [`FormLabel`] inside a [`Field`](crate::Field).
521#[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    // Combine base class with optional user class (evaluated at init)
536    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/// Grouped settings heading — a section label within a form or panel, not a page-level heading.
550///
551/// Renders `Caption1Strong`. Use for clusters like "Account settings" or "Notification preferences".
552#[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}