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#[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#[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#[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#[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#[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#[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#[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#[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}