adui_dioxus/components/
typography.rs

1use crate::components::icon::{Icon, IconKind};
2use crate::theme::use_theme;
3use dioxus::events::KeyboardEvent;
4use dioxus::prelude::*;
5use dioxus::prelude::{Key, Modifiers};
6
7#[cfg(target_arch = "wasm32")]
8use wasm_bindgen::{JsCast, closure::Closure};
9
10/// Text tone variants (aligned to Ant Design semantics subset).
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum TextType {
13    #[default]
14    Default,
15    Secondary,
16    Success,
17    Warning,
18    Danger,
19    Disabled,
20}
21
22/// Heading levels.
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub enum TitleLevel {
25    #[default]
26    H1,
27    H2,
28    H3,
29    H4,
30    H5,
31}
32
33/// Copy interaction configuration.
34#[derive(Clone, PartialEq)]
35pub struct TypographyCopyable {
36    pub text: String,
37    pub icon: Option<Element>,
38    pub copied_icon: Option<Element>,
39    pub tooltips: Option<(String, String)>,
40}
41
42impl TypographyCopyable {
43    pub fn new(text: impl Into<String>) -> Self {
44        Self {
45            text: text.into(),
46            icon: None,
47            copied_icon: None,
48            tooltips: None,
49        }
50    }
51}
52
53/// Ellipsis behaviour configuration.
54#[derive(Clone, PartialEq, Default)]
55pub struct TypographyEllipsis {
56    pub rows: Option<u16>,
57    pub expandable: bool,
58    pub expand_text: Option<String>,
59    pub collapse_text: Option<String>,
60    pub tooltip: Option<String>,
61}
62
63/// Inline edit configuration.
64#[derive(Clone, PartialEq, Default)]
65pub struct TypographyEditable {
66    pub text: Option<String>,
67    pub placeholder: Option<String>,
68    pub auto_focus: bool,
69    pub max_length: Option<usize>,
70    pub enter_icon: Option<Element>,
71    pub cancel_icon: Option<Element>,
72}
73
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75enum TypographyVariant {
76    Text,
77    Paragraph,
78    Title(TitleLevel),
79}
80
81#[derive(Props, Clone, PartialEq)]
82struct TypographyBaseProps {
83    #[props(default = TypographyVariant::Text)]
84    variant: TypographyVariant,
85    #[props(default)]
86    r#type: TextType,
87    #[props(default)]
88    strong: bool,
89    #[props(default)]
90    italic: bool,
91    #[props(default)]
92    underline: bool,
93    #[props(default)]
94    delete: bool,
95    #[props(default)]
96    code: bool,
97    #[props(default)]
98    mark: bool,
99    #[props(default = true)]
100    wrap: bool,
101    #[props(default)]
102    ellipsis: bool,
103    #[props(optional)]
104    ellipsis_config: Option<TypographyEllipsis>,
105    #[props(optional)]
106    copyable: Option<TypographyCopyable>,
107    #[props(optional)]
108    editable: Option<TypographyEditable>,
109    #[props(default)]
110    disabled: bool,
111    #[props(optional)]
112    on_copy: Option<EventHandler<String>>,
113    #[props(optional)]
114    on_edit: Option<EventHandler<String>>,
115    #[props(optional)]
116    on_edit_cancel: Option<EventHandler<String>>,
117    #[props(optional)]
118    on_edit_start: Option<EventHandler<()>>,
119    #[props(optional)]
120    class: Option<String>,
121    #[props(optional)]
122    style: Option<String>,
123    pub children: Element,
124}
125
126#[component]
127fn TypographyBase(props: TypographyBaseProps) -> Element {
128    let TypographyBaseProps {
129        variant,
130        r#type,
131        strong,
132        italic,
133        underline,
134        delete,
135        code,
136        mark,
137        wrap,
138        ellipsis,
139        ellipsis_config,
140        copyable,
141        editable,
142        disabled,
143        on_copy,
144        on_edit,
145        on_edit_cancel,
146        on_edit_start,
147        class,
148        style,
149        children,
150    } = props;
151
152    let theme = use_theme();
153    let tokens = theme.tokens();
154    let tone_color = resolve_color(&tokens, r#type, disabled);
155    let decoration = text_decoration(underline, delete);
156    let ellipsis_cfg = ellipsis_config.unwrap_or_default();
157    let ellipsis_rows = ellipsis_cfg.rows.unwrap_or(1);
158    let ellipsis_expandable = ellipsis_cfg.expandable;
159    let ellipsis_expand_text = ellipsis_cfg
160        .expand_text
161        .clone()
162        .unwrap_or_else(|| "展开".to_string());
163    let ellipsis_collapse_text = ellipsis_cfg
164        .collapse_text
165        .clone()
166        .unwrap_or_else(|| "收起".to_string());
167    let ellipsis_tooltip = ellipsis_cfg.tooltip.clone();
168
169    let copy_status = use_signal(|| false);
170    let editing = use_signal(|| false);
171    let ellipsis_expanded = use_signal(|| false);
172    let (ellipsis_enabled, ellipsis_active) =
173        ellipsis_flags(ellipsis, &ellipsis_cfg, *ellipsis_expanded.read());
174    let edit_value = use_signal(|| {
175        editable
176            .as_ref()
177            .and_then(|cfg| cfg.text.clone())
178            .unwrap_or_default()
179    });
180    {
181        let mut state = edit_value;
182        let source = editable.as_ref().and_then(|cfg| cfg.text.clone());
183        use_effect(move || {
184            if let Some(new_value) = source.clone() {
185                state.set(new_value);
186            }
187        });
188    }
189
190    let mut class_list = match variant {
191        TypographyVariant::Text => vec!["adui-text".to_string()],
192        TypographyVariant::Paragraph => vec!["adui-paragraph".to_string(), "adui-text".to_string()],
193        TypographyVariant::Title(level) => {
194            vec![
195                "adui-title".to_string(),
196                format!("adui-title-{}", level_index(level)),
197            ]
198        }
199    };
200    if strong {
201        class_list.push("adui-text-strong".into());
202    }
203    if italic {
204        class_list.push("adui-text-italic".into());
205    }
206    if code {
207        class_list.push("adui-text-code".into());
208    }
209    if mark {
210        class_list.push("adui-text-mark".into());
211    }
212    if !wrap {
213        class_list.push("adui-text-nowrap".into());
214    }
215    if disabled {
216        class_list.push("adui-text-disabled".into());
217    }
218    if ellipsis_active {
219        class_list.push("adui-text-ellipsis".into());
220        if ellipsis_rows > 1 {
221            class_list.push("adui-text-ellipsis-multiline".into());
222        }
223    }
224    if copyable.is_some() {
225        class_list.push("adui-text-copyable".into());
226    }
227    if editable.is_some() {
228        class_list.push("adui-text-editable".into());
229    }
230    if let Some(extra) = class {
231        class_list.push(extra);
232    }
233    let class_attr = class_list.join(" ");
234
235    let mut style_attr = format!("color:{};text-decoration:{};", tone_color, decoration);
236    if ellipsis_active && ellipsis_rows > 1 {
237        style_attr.push_str("-webkit-line-clamp:");
238        style_attr.push_str(&ellipsis_rows.to_string());
239        style_attr.push(';');
240    }
241    if let Some(extra) = style {
242        style_attr.push_str(&extra);
243    }
244
245    let tooltip_attr = if ellipsis_active {
246        ellipsis_tooltip.clone()
247    } else {
248        None
249    };
250
251    let content_node = if editable.is_some() && *editing.read() {
252        render_editing(
253            variant,
254            editable.clone().unwrap(),
255            edit_value,
256            editing,
257            on_edit,
258            on_edit_cancel,
259        )
260    } else {
261        rsx! { span {
262            class: "adui-typography-content",
263            {children}
264        } }
265    };
266
267    let copy_cfg = copyable.clone();
268    let edit_cfg = editable.clone();
269    match (variant, content_node) {
270        (TypographyVariant::Text, node) => rsx! {
271            span {
272                class: "{class_attr}",
273                style: "{style_attr}",
274                title: tooltip_attr.clone().unwrap_or_default(),
275                {node}
276                if let Some(cfg) = copy_cfg.clone() {
277                    {render_copy_control(cfg, disabled, copy_status, on_copy)}
278                }
279                if let Some(cfg) = edit_cfg.clone() {
280                    if let Some(control) = render_edit_trigger(
281                        cfg,
282                        disabled,
283                        editing,
284                        edit_value,
285                        on_edit_start,
286                    ) {
287                        {control}
288                    }
289                }
290                if let Some(btn) = render_expand_control(
291                    ellipsis_enabled,
292                    ellipsis_expandable,
293                    ellipsis_expanded,
294                    ellipsis_expand_text.as_str(),
295                    ellipsis_collapse_text.as_str(),
296                ) {
297                    {btn}
298                }
299            }
300        },
301        (TypographyVariant::Paragraph, node) => rsx! {
302            p {
303                class: "{class_attr}",
304                style: "{style_attr}",
305                title: tooltip_attr.clone().unwrap_or_default(),
306                {node}
307                if let Some(cfg) = copy_cfg.clone() {
308                    {render_copy_control(cfg, disabled, copy_status, on_copy)}
309                }
310                if let Some(cfg) = edit_cfg.clone() {
311                    if let Some(control) = render_edit_trigger(
312                        cfg,
313                        disabled,
314                        editing,
315                        edit_value,
316                        on_edit_start,
317                    ) {
318                        {control}
319                    }
320                }
321                if let Some(btn) = render_expand_control(
322                    ellipsis_enabled,
323                    ellipsis_expandable,
324                    ellipsis_expanded,
325                    ellipsis_expand_text.as_str(),
326                    ellipsis_collapse_text.as_str(),
327                ) {
328                    {btn}
329                }
330            }
331        },
332        (TypographyVariant::Title(level), node) => {
333            let tooltip = tooltip_attr.unwrap_or_default();
334            match level {
335                TitleLevel::H1 => {
336                    rsx!(h1 { class: "{class_attr}", style: "{style_attr}", title: tooltip.clone(), {node}
337                        if let Some(cfg) = copy_cfg.clone() { {render_copy_control(cfg, disabled, copy_status, on_copy)} }
338                        if let Some(cfg) = edit_cfg.clone() {
339                            if let Some(control) = render_edit_trigger(cfg, disabled, editing, edit_value, on_edit_start) { {control} }
340                        }
341                        if let Some(btn) = render_expand_control(
342                            ellipsis_enabled,
343                            ellipsis_expandable,
344                            ellipsis_expanded,
345                            ellipsis_expand_text.as_str(),
346                            ellipsis_collapse_text.as_str(),
347                        ) { {btn} }
348                    })
349                }
350                TitleLevel::H2 => {
351                    rsx!(h2 { class: "{class_attr}", style: "{style_attr}", title: tooltip.clone(), {node}
352                        if let Some(cfg) = copy_cfg.clone() { {render_copy_control(cfg, disabled, copy_status, on_copy)} }
353                        if let Some(cfg) = edit_cfg.clone() {
354                            if let Some(control) = render_edit_trigger(cfg, disabled, editing, edit_value, on_edit_start) { {control} }
355                        }
356                        if let Some(btn) = render_expand_control(
357                            ellipsis_enabled,
358                            ellipsis_expandable,
359                            ellipsis_expanded,
360                            ellipsis_expand_text.as_str(),
361                            ellipsis_collapse_text.as_str(),
362                        ) { {btn} }
363                    })
364                }
365                TitleLevel::H3 => {
366                    rsx!(h3 { class: "{class_attr}", style: "{style_attr}", title: tooltip.clone(), {node}
367                        if let Some(cfg) = copy_cfg.clone() { {render_copy_control(cfg, disabled, copy_status, on_copy)} }
368                        if let Some(cfg) = edit_cfg.clone() {
369                            if let Some(control) = render_edit_trigger(cfg, disabled, editing, edit_value, on_edit_start) { {control} }
370                        }
371                        if let Some(btn) = render_expand_control(
372                            ellipsis_enabled,
373                            ellipsis_expandable,
374                            ellipsis_expanded,
375                            ellipsis_expand_text.as_str(),
376                            ellipsis_collapse_text.as_str(),
377                        ) { {btn} }
378                    })
379                }
380                TitleLevel::H4 => {
381                    rsx!(h4 { class: "{class_attr}", style: "{style_attr}", title: tooltip.clone(), {node}
382                        if let Some(cfg) = copy_cfg.clone() { {render_copy_control(cfg, disabled, copy_status, on_copy)} }
383                        if let Some(cfg) = edit_cfg.clone() {
384                            if let Some(control) = render_edit_trigger(cfg, disabled, editing, edit_value, on_edit_start) { {control} }
385                        }
386                        if let Some(btn) = render_expand_control(
387                            ellipsis_enabled,
388                            ellipsis_expandable,
389                            ellipsis_expanded,
390                            ellipsis_expand_text.as_str(),
391                            ellipsis_collapse_text.as_str(),
392                        ) { {btn} }
393                    })
394                }
395                TitleLevel::H5 => {
396                    rsx!(h5 { class: "{class_attr}", style: "{style_attr}", title: tooltip, {node}
397                        if let Some(cfg) = copy_cfg {
398                            {render_copy_control(cfg, disabled, copy_status, on_copy)}
399                        }
400                        if let Some(cfg) = edit_cfg {
401                            if let Some(control) = render_edit_trigger(cfg, disabled, editing, edit_value, on_edit_start) { {control} }
402                        }
403                        if let Some(btn) = render_expand_control(
404                            ellipsis_enabled,
405                            ellipsis_expandable,
406                            ellipsis_expanded,
407                            ellipsis_expand_text.as_str(),
408                            ellipsis_collapse_text.as_str(),
409                        ) { {btn} }
410                    })
411                }
412            }
413        }
414    }
415}
416
417#[derive(Props, Clone, PartialEq)]
418pub struct TextProps {
419    #[props(default)]
420    pub r#type: TextType,
421    #[props(default)]
422    pub strong: bool,
423    #[props(default)]
424    pub italic: bool,
425    #[props(default)]
426    pub underline: bool,
427    #[props(default)]
428    pub delete: bool,
429    #[props(default)]
430    pub code: bool,
431    #[props(default)]
432    pub mark: bool,
433    #[props(default = true)]
434    pub wrap: bool,
435    #[props(default)]
436    pub ellipsis: bool,
437    #[props(optional)]
438    pub ellipsis_config: Option<TypographyEllipsis>,
439    #[props(optional)]
440    pub copyable: Option<TypographyCopyable>,
441    #[props(optional)]
442    pub editable: Option<TypographyEditable>,
443    #[props(default)]
444    pub disabled: bool,
445    #[props(optional)]
446    pub on_copy: Option<EventHandler<String>>,
447    #[props(optional)]
448    pub on_edit: Option<EventHandler<String>>,
449    #[props(optional)]
450    pub on_edit_cancel: Option<EventHandler<String>>,
451    #[props(optional)]
452    pub on_edit_start: Option<EventHandler<()>>,
453    #[props(optional)]
454    pub class: Option<String>,
455    #[props(optional)]
456    pub style: Option<String>,
457    pub children: Element,
458}
459
460impl From<TextProps> for TypographyBaseProps {
461    fn from(value: TextProps) -> Self {
462        Self {
463            variant: TypographyVariant::Text,
464            r#type: value.r#type,
465            strong: value.strong,
466            italic: value.italic,
467            underline: value.underline,
468            delete: value.delete,
469            code: value.code,
470            mark: value.mark,
471            wrap: value.wrap,
472            ellipsis: value.ellipsis,
473            ellipsis_config: value.ellipsis_config,
474            copyable: value.copyable,
475            editable: value.editable,
476            disabled: value.disabled,
477            on_copy: value.on_copy,
478            on_edit: value.on_edit,
479            on_edit_cancel: value.on_edit_cancel,
480            on_edit_start: value.on_edit_start,
481            class: value.class,
482            style: value.style,
483            children: value.children,
484        }
485    }
486}
487
488/// Inline text typography.
489#[component]
490pub fn Text(props: TextProps) -> Element {
491    TypographyBase(props.into())
492}
493
494#[derive(Props, Clone, PartialEq)]
495pub struct ParagraphProps {
496    #[props(default)]
497    pub r#type: TextType,
498    #[props(default)]
499    pub strong: bool,
500    #[props(default)]
501    pub italic: bool,
502    #[props(default)]
503    pub underline: bool,
504    #[props(default)]
505    pub delete: bool,
506    #[props(default)]
507    pub code: bool,
508    #[props(default)]
509    pub mark: bool,
510    #[props(default = true)]
511    pub wrap: bool,
512    #[props(default)]
513    pub ellipsis: bool,
514    #[props(optional)]
515    pub ellipsis_config: Option<TypographyEllipsis>,
516    #[props(optional)]
517    pub copyable: Option<TypographyCopyable>,
518    #[props(optional)]
519    pub editable: Option<TypographyEditable>,
520    #[props(default)]
521    pub disabled: bool,
522    #[props(optional)]
523    pub on_copy: Option<EventHandler<String>>,
524    #[props(optional)]
525    pub on_edit: Option<EventHandler<String>>,
526    #[props(optional)]
527    pub on_edit_cancel: Option<EventHandler<String>>,
528    #[props(optional)]
529    pub on_edit_start: Option<EventHandler<()>>,
530    #[props(optional)]
531    pub class: Option<String>,
532    #[props(optional)]
533    pub style: Option<String>,
534    pub children: Element,
535}
536
537impl From<ParagraphProps> for TypographyBaseProps {
538    fn from(value: ParagraphProps) -> Self {
539        Self {
540            variant: TypographyVariant::Paragraph,
541            r#type: value.r#type,
542            strong: value.strong,
543            italic: value.italic,
544            underline: value.underline,
545            delete: value.delete,
546            code: value.code,
547            mark: value.mark,
548            wrap: value.wrap,
549            ellipsis: value.ellipsis,
550            ellipsis_config: value.ellipsis_config,
551            copyable: value.copyable,
552            editable: value.editable,
553            disabled: value.disabled,
554            on_copy: value.on_copy,
555            on_edit: value.on_edit,
556            on_edit_cancel: value.on_edit_cancel,
557            on_edit_start: value.on_edit_start,
558            class: value.class,
559            style: value.style,
560            children: value.children,
561        }
562    }
563}
564
565/// Block paragraph typography.
566#[component]
567pub fn Paragraph(props: ParagraphProps) -> Element {
568    TypographyBase(props.into())
569}
570
571#[derive(Props, Clone, PartialEq)]
572pub struct TitleProps {
573    #[props(default)]
574    pub level: TitleLevel,
575    #[props(default)]
576    pub r#type: TextType,
577    #[props(default)]
578    pub strong: bool,
579    #[props(default)]
580    pub italic: bool,
581    #[props(default)]
582    pub underline: bool,
583    #[props(default)]
584    pub delete: bool,
585    #[props(default)]
586    pub code: bool,
587    #[props(default)]
588    pub mark: bool,
589    #[props(default = true)]
590    pub wrap: bool,
591    #[props(default)]
592    pub ellipsis: bool,
593    #[props(optional)]
594    pub ellipsis_config: Option<TypographyEllipsis>,
595    #[props(optional)]
596    pub copyable: Option<TypographyCopyable>,
597    #[props(optional)]
598    pub editable: Option<TypographyEditable>,
599    #[props(default)]
600    pub disabled: bool,
601    #[props(optional)]
602    pub on_copy: Option<EventHandler<String>>,
603    #[props(optional)]
604    pub on_edit: Option<EventHandler<String>>,
605    #[props(optional)]
606    pub on_edit_cancel: Option<EventHandler<String>>,
607    #[props(optional)]
608    pub on_edit_start: Option<EventHandler<()>>,
609    #[props(optional)]
610    pub class: Option<String>,
611    #[props(optional)]
612    pub style: Option<String>,
613    pub children: Element,
614}
615
616impl From<TitleProps> for TypographyBaseProps {
617    fn from(value: TitleProps) -> Self {
618        Self {
619            variant: TypographyVariant::Title(value.level),
620            r#type: value.r#type,
621            strong: value.strong,
622            italic: value.italic,
623            underline: value.underline,
624            delete: value.delete,
625            code: value.code,
626            mark: value.mark,
627            wrap: value.wrap,
628            ellipsis: value.ellipsis,
629            ellipsis_config: value.ellipsis_config,
630            copyable: value.copyable,
631            editable: value.editable,
632            disabled: value.disabled,
633            on_copy: value.on_copy,
634            on_edit: value.on_edit,
635            on_edit_cancel: value.on_edit_cancel,
636            on_edit_start: value.on_edit_start,
637            class: value.class,
638            style: value.style,
639            children: value.children,
640        }
641    }
642}
643
644/// Heading typography rendered as h1-h5.
645#[component]
646pub fn Title(props: TitleProps) -> Element {
647    TypographyBase(props.into())
648}
649
650fn ellipsis_flags(prop_enabled: bool, cfg: &TypographyEllipsis, expanded: bool) -> (bool, bool) {
651    let enabled = prop_enabled || cfg.expandable || cfg.rows.is_some();
652    let active = enabled && (!cfg.expandable || !expanded);
653    (enabled, active)
654}
655
656fn resolve_color(tokens: &crate::theme::ThemeTokens, tone: TextType, disabled: bool) -> String {
657    if disabled {
658        return tokens.color_text_disabled.clone();
659    }
660    match tone {
661        TextType::Default => tokens.color_text.clone(),
662        TextType::Secondary => tokens.color_text_secondary.clone(),
663        TextType::Success => tokens.color_success.clone(),
664        TextType::Warning => tokens.color_warning.clone(),
665        TextType::Danger => tokens.color_error.clone(),
666        TextType::Disabled => tokens.color_text_disabled.clone(),
667    }
668}
669
670fn text_decoration(underline: bool, delete: bool) -> String {
671    let mut entries = Vec::new();
672    if underline {
673        entries.push("underline");
674    }
675    if delete {
676        entries.push("line-through");
677    }
678    if entries.is_empty() {
679        "none".into()
680    } else {
681        entries.join(" ")
682    }
683}
684
685fn level_index(level: TitleLevel) -> u8 {
686    match level {
687        TitleLevel::H1 => 1,
688        TitleLevel::H2 => 2,
689        TitleLevel::H3 => 3,
690        TitleLevel::H4 => 4,
691        TitleLevel::H5 => 5,
692    }
693}
694
695fn render_copy_control(
696    cfg: TypographyCopyable,
697    disabled: bool,
698    copy_state: Signal<bool>,
699    on_copy: Option<EventHandler<String>>,
700) -> Element {
701    let idle = cfg
702        .tooltips
703        .as_ref()
704        .map(|pair| pair.0.clone())
705        .unwrap_or_else(|| "复制".into());
706    let success = cfg
707        .tooltips
708        .as_ref()
709        .map(|pair| pair.1.clone())
710        .unwrap_or_else(|| "已复制".into());
711    let idle_icon = cfg.icon.clone().unwrap_or_else(|| {
712        rsx!(Icon {
713            kind: IconKind::Copy,
714            size: 16.0
715        })
716    });
717    let copied_icon = cfg.copied_icon.clone().unwrap_or_else(|| {
718        rsx!(Icon {
719            kind: IconKind::Check,
720            size: 16.0
721        })
722    });
723    let text_click = cfg.text.clone();
724    let text_key = cfg.text.clone();
725    let handler_click = on_copy;
726    let handler_key = on_copy;
727    let state_click = copy_state;
728    let state_key = copy_state;
729    rsx! {
730        span {
731            class: "adui-typography-control adui-typography-copy",
732            tabindex: if disabled { "-1" } else { "0" },
733            role: "button",
734            title: if *copy_state.read() { success.clone() } else { idle.clone() },
735            "aria-disabled": disabled,
736            onclick: move |_| {
737                if disabled {
738                    return;
739                }
740                trigger_copy(text_click.clone(), handler_click, state_click);
741            },
742            onkeydown: move |evt: KeyboardEvent| {
743                if disabled {
744                    return;
745                }
746                if matches_key_activate(&evt) {
747                    evt.stop_propagation();
748                    evt.prevent_default();
749                    trigger_copy(text_key.clone(), handler_key, state_key);
750                }
751            },
752            if *copy_state.read() {
753                {copied_icon}
754            } else {
755                {idle_icon}
756            }
757        }
758    }
759}
760
761fn render_expand_control(
762    enabled: bool,
763    expandable: bool,
764    state: Signal<bool>,
765    expand_text: &str,
766    collapse_text: &str,
767) -> Option<Element> {
768    if !enabled || !expandable {
769        return None;
770    }
771    let label = if *state.read() {
772        collapse_text.to_owned()
773    } else {
774        expand_text.to_owned()
775    };
776    let mut toggle = state;
777    Some(rsx! {
778        button {
779            r#type: "button",
780            class: "adui-typography-control adui-typography-expand",
781            onclick: move |_| {
782                let current = { *toggle.read() };
783                toggle.set(!current);
784            },
785            {label}
786        }
787    })
788}
789
790fn render_edit_trigger(
791    cfg: TypographyEditable,
792    disabled: bool,
793    editing: Signal<bool>,
794    edit_value: Signal<String>,
795    on_edit_start: Option<EventHandler<()>>,
796) -> Option<Element> {
797    if *editing.read() {
798        return None;
799    }
800    let icon = cfg.enter_icon.clone().unwrap_or_else(|| {
801        rsx!(Icon {
802            kind: IconKind::Edit,
803            size: 16.0
804        })
805    });
806    let text_seed_click = cfg.text.clone();
807    let text_seed_key = cfg.text.clone();
808    let handler_click = on_edit_start;
809    let handler_key = on_edit_start;
810    let mut editing_click = editing;
811    let mut editing_key = editing;
812    let mut value_click = edit_value;
813    let mut value_key = edit_value;
814    Some(rsx! {
815        span {
816            class: "adui-typography-control adui-typography-edit",
817            tabindex: if disabled { "-1" } else { "0" },
818            role: "button",
819            "aria-disabled": disabled,
820            onclick: move |_| {
821                if disabled {
822                    return;
823                }
824                editing_click.set(true);
825                if let Some(default_text) = text_seed_click.clone() {
826                    value_click.set(default_text);
827                }
828                if let Some(handler) = handler_click.as_ref() {
829                    handler.call(());
830                }
831            },
832            onkeydown: move |evt: KeyboardEvent| {
833                if disabled {
834                    return;
835                }
836                if matches_key_activate(&evt) {
837                    evt.prevent_default();
838                    editing_key.set(true);
839                    if let Some(default_text) = text_seed_key.clone() {
840                        value_key.set(default_text);
841                    }
842                    if let Some(handler) = handler_key.as_ref() {
843                        handler.call(());
844                    }
845                }
846            },
847            {icon}
848        }
849    })
850}
851
852fn render_editing(
853    variant: TypographyVariant,
854    cfg: TypographyEditable,
855    edit_value: Signal<String>,
856    editing: Signal<bool>,
857    on_edit: Option<EventHandler<String>>,
858    on_edit_cancel: Option<EventHandler<String>>,
859) -> Element {
860    let placeholder = cfg
861        .placeholder
862        .clone()
863        .unwrap_or_else(|| "请输入".to_string());
864    let enter_icon = cfg.enter_icon.clone().unwrap_or_else(|| {
865        rsx!(Icon {
866            kind: IconKind::Check,
867            size: 16.0
868        })
869    });
870    let cancel_icon = cfg.cancel_icon.clone().unwrap_or_else(|| {
871        rsx!(Icon {
872            kind: IconKind::Close,
873            size: 16.0
874        })
875    });
876    let auto_focus = cfg.auto_focus;
877    let max_len = cfg.max_length.map(|len| len.to_string());
878    let submit_handler = on_edit;
879    let cancel_handler = on_edit_cancel;
880
881    let text_control = match variant {
882        TypographyVariant::Paragraph => {
883            let mut value_signal = edit_value;
884            let submit_value = edit_value;
885            let submit_editing = editing;
886            let submit_handler_clone = submit_handler;
887            let cancel_value = edit_value;
888            let cancel_editing = editing;
889            let cancel_handler_clone = cancel_handler;
890            rsx! {
891                textarea {
892                    class: "adui-typography-textarea",
893                    value: "{submit_value.read().clone()}",
894                    placeholder: "{placeholder}",
895                    autofocus: auto_focus,
896                    maxlength: max_len.clone().unwrap_or_default(),
897                    oninput: move |evt| {
898                        value_signal.set(evt.value());
899                    },
900                    onkeydown: move |evt: KeyboardEvent| {
901                        if matches!(evt.key(), Key::Enter) && evt.modifiers().contains(Modifiers::CONTROL) {
902                            evt.prevent_default();
903                            submit_edit_action(submit_value, submit_editing, submit_handler_clone);
904                        }
905                        if matches!(evt.key(), Key::Escape) {
906                            evt.prevent_default();
907                            cancel_edit_action(cancel_value, cancel_editing, cancel_handler_clone);
908                        }
909                    }
910                }
911            }
912        }
913        _ => {
914            let mut value_signal = edit_value;
915            let submit_value = edit_value;
916            let submit_editing = editing;
917            let submit_handler_clone = submit_handler;
918            let cancel_value = edit_value;
919            let cancel_editing = editing;
920            let cancel_handler_clone = cancel_handler;
921            rsx! {
922                input {
923                    class: "adui-typography-input",
924                    r#type: "text",
925                    value: "{submit_value.read().clone()}",
926                    placeholder: "{placeholder}",
927                    autofocus: auto_focus,
928                    maxlength: max_len.clone().unwrap_or_default(),
929                    oninput: move |evt| {
930                        value_signal.set(evt.value());
931                    },
932                    onkeydown: move |evt: KeyboardEvent| {
933                        if matches!(evt.key(), Key::Enter) {
934                            evt.prevent_default();
935                            submit_edit_action(submit_value, submit_editing, submit_handler_clone);
936                        }
937                        if matches!(evt.key(), Key::Escape) {
938                            evt.prevent_default();
939                            cancel_edit_action(cancel_value, cancel_editing, cancel_handler_clone);
940                        }
941                    }
942                }
943            }
944        }
945    };
946    let submit_button_value = edit_value;
947    let submit_button_editing = editing;
948    let submit_button_handler = on_edit;
949    let cancel_button_value = edit_value;
950    let cancel_button_editing = editing;
951    let cancel_button_handler = on_edit_cancel;
952    rsx! {
953        span {
954            class: "adui-text-editing",
955            {text_control}
956            button {
957                class: "adui-typography-edit-btn",
958                r#type: "button",
959                onclick: move |_| {
960                    submit_edit_action(
961                        submit_button_value,
962                        submit_button_editing,
963                        submit_button_handler,
964                    );
965                },
966                {enter_icon}
967            }
968            button {
969                class: "adui-typography-edit-btn",
970                r#type: "button",
971                onclick: move |_| {
972                    cancel_edit_action(
973                        cancel_button_value,
974                        cancel_button_editing,
975                        cancel_button_handler,
976                    );
977                },
978                {cancel_icon}
979            }
980        }
981    }
982}
983
984fn submit_edit_action(
985    value: Signal<String>,
986    editing: Signal<bool>,
987    handler: Option<EventHandler<String>>,
988) {
989    let current = value.read().clone();
990    if let Some(cb) = handler {
991        cb.call(current.clone());
992    }
993    let mut editing_signal = editing;
994    editing_signal.set(false);
995}
996
997fn cancel_edit_action(
998    value: Signal<String>,
999    editing: Signal<bool>,
1000    handler: Option<EventHandler<String>>,
1001) {
1002    let mut editing_signal = editing;
1003    editing_signal.set(false);
1004    let current = value.read().clone();
1005    if let Some(cb) = handler {
1006        cb.call(current);
1007    }
1008}
1009
1010fn trigger_copy(text: String, handler: Option<EventHandler<String>>, mut copy_state: Signal<bool>) {
1011    if let Some(cb) = handler {
1012        cb.call(text.clone());
1013    }
1014    #[cfg(target_arch = "wasm32")]
1015    {
1016        if let Some(window) = web_sys::window() {
1017            let navigator = window.navigator();
1018            let clipboard = navigator.clipboard();
1019            let _ = clipboard.write_text(&text);
1020        }
1021        copy_state.set(true);
1022        schedule_copy_reset(copy_state);
1023    }
1024    #[cfg(not(target_arch = "wasm32"))]
1025    {
1026        copy_state.set(true);
1027    }
1028}
1029
1030fn matches_key_activate(evt: &KeyboardEvent) -> bool {
1031    key_triggers_activation(&evt.key())
1032}
1033
1034fn key_triggers_activation(key: &Key) -> bool {
1035    match key {
1036        Key::Enter => true,
1037        Key::Character(text) if text == " " => true,
1038        _ => false,
1039    }
1040}
1041
1042#[cfg(target_arch = "wasm32")]
1043fn schedule_copy_reset(state: Signal<bool>) {
1044    if let Some(window) = web_sys::window() {
1045        let mut state_clone = state;
1046        let callback = Closure::once(move || {
1047            state_clone.set(false);
1048        });
1049        let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
1050            callback.as_ref().unchecked_ref(),
1051            1500,
1052        );
1053        callback.forget();
1054    }
1055}
1056
1057#[cfg(not(target_arch = "wasm32"))]
1058#[allow(dead_code)]
1059fn schedule_copy_reset(_state: Signal<bool>) {}
1060
1061#[cfg(test)]
1062mod tests {
1063    use super::*;
1064    use crate::theme::ThemeTokens;
1065
1066    #[test]
1067    fn resolve_color_respects_tone_and_disabled_state() {
1068        let tokens = ThemeTokens::light();
1069        let disabled = resolve_color(&tokens, TextType::Danger, true);
1070        assert_eq!(disabled, tokens.color_text_disabled);
1071
1072        let success = resolve_color(&tokens, TextType::Success, false);
1073        assert_eq!(success, tokens.color_success);
1074    }
1075
1076    #[test]
1077    fn text_decoration_combines_flags() {
1078        assert_eq!(text_decoration(true, false), "underline");
1079        assert_eq!(text_decoration(false, true), "line-through");
1080        assert_eq!(text_decoration(true, true), "underline line-through");
1081        assert_eq!(text_decoration(false, false), "none");
1082    }
1083
1084    #[test]
1085    fn level_index_maps_levels() {
1086        assert_eq!(level_index(TitleLevel::H1), 1);
1087        assert_eq!(level_index(TitleLevel::H4), 4);
1088    }
1089
1090    #[test]
1091    fn ellipsis_flags_follow_expand_state() {
1092        let cfg = TypographyEllipsis {
1093            rows: Some(2),
1094            expandable: true,
1095            ..Default::default()
1096        };
1097        let (enabled, active) = ellipsis_flags(false, &cfg, false);
1098        assert!(enabled);
1099        assert!(active);
1100
1101        let (_, active_after_expand) = ellipsis_flags(false, &cfg, true);
1102        assert!(!active_after_expand);
1103
1104        let cfg_disabled = TypographyEllipsis::default();
1105        let (enabled_none, active_none) = ellipsis_flags(false, &cfg_disabled, false);
1106        assert!(!enabled_none);
1107        assert!(!active_none);
1108    }
1109
1110    #[test]
1111    fn key_activation_matches_enter_and_space() {
1112        assert!(key_triggers_activation(&Key::Enter));
1113        assert!(key_triggers_activation(&Key::Character(" ".into())));
1114        assert!(!key_triggers_activation(&Key::Character("a".into())));
1115    }
1116}