1use dioxus::prelude::*;
10use dioxus::router::components::Link;
11
12use crate::components::forms::FormInput;
13
14pub const ADMIN_CARD_CLASS: &str = "bg-[var(--color-paper-entry)] rounded-2xl shadow-sm border border-transparent hover:border-[var(--color-paper-border)] transition";
24
25pub const ADMIN_TABLE_CLASS: &str = "bg-[var(--color-paper-entry)] rounded-2xl shadow-sm border border-transparent hover:border-[var(--color-paper-border)] transition overflow-hidden";
27
28pub const SPINNER_SVG: &str = r#"<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="yggSpinnerGrad"><stop offset="0%" stop-color="currentColor" stop-opacity="1"/><stop offset="100%" stop-color="currentColor" stop-opacity="0.25"/></linearGradient></defs><style>@keyframes yggSpin { to { transform: rotate(360deg); } } .ygg-spinner-circle { transform-origin: 50% 50%; stroke: url(#yggSpinnerGrad); fill: none; animation: yggSpin .5s infinite linear; }</style><circle cx="10" cy="10" r="8" class="ygg-spinner-circle" stroke-width="2"/></svg>"#;
33
34#[allow(dead_code)]
35pub const BADGE_BASE: &str =
36 "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap";
37pub const MEDIA_BADGE_BASE: &str =
38 "inline-flex items-center px-2 py-0.5 rounded-lg text-[10px] font-mono font-medium whitespace-nowrap";
39
40pub const BTN_SOLID_GREEN: &str =
44 "px-4 py-1.5 text-sm font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full hover:bg-green-500/20 transition-colors cursor-pointer";
45pub const BTN_SOLID_AMBER: &str =
47 "px-4 py-1.5 text-sm font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 rounded-full hover:bg-amber-500/20 transition-colors cursor-pointer";
48pub const BTN_SOLID_RED: &str =
50 "px-4 py-1.5 text-sm font-medium bg-red-500/10 text-red-600 dark:text-red-400 rounded-full hover:bg-red-500/20 transition-colors cursor-pointer";
51
52#[allow(dead_code)]
55pub const BTN_TEXT_AMBER: &str = "text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 transition-colors cursor-pointer";
56#[allow(dead_code)]
57pub const BTN_TEXT_RED: &str =
58 "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer";
59
60pub const BTN_GHOST: &str =
62 "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer";
63
64pub const BTN_SECONDARY: &str =
68 "px-6 py-2.5 rounded-full text-sm font-medium text-center text-[var(--color-paper-secondary)] bg-[var(--color-paper-entry)] hover:bg-[var(--color-paper-border)] hover:text-[var(--color-paper-primary)] active:scale-[0.98] transition-all cursor-pointer";
69
70pub const BTN_PRIMARY: &str =
74 "inline-flex items-center justify-center px-5 py-2 text-sm font-medium text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] rounded-full shadow-sm hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer";
75
76pub const BTN_PRIMARY_SM: &str =
78 "inline-flex items-center justify-center px-4 py-1.5 text-sm font-medium text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] rounded-full hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer";
79
80pub const BTN_OUTLINE: &str =
84 "relative px-4 py-2 rounded-full text-sm font-medium text-paper-primary border border-paper-border hover:border-paper-accent hover:text-paper-accent transition-all cursor-pointer";
85
86pub const BTN_DANGER_OUTLINE: &str =
88 "px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-900/50 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors cursor-pointer";
89
90pub const BTN_CLOSE_ICON: &str =
94 "shrink-0 text-red-400 hover:text-red-600 cursor-pointer text-lg leading-none";
95
96pub const BTN_ICON: &str =
98 "w-9 h-9 flex items-center justify-center text-sm text-paper-secondary hover:text-paper-primary hover:bg-paper-theme cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40";
99
100#[component]
129pub fn Pagination<R: Routable + Clone + PartialEq + 'static>(
130 variant: &'static str,
131 current_page: i32,
132 total: i64,
133 per_page: i32,
134 #[props(default)] prev_route: Option<R>,
135 #[props(default)] next_route: Option<R>,
136 unit: &'static str,
137 #[props(default)] on_prev: Option<EventHandler<()>>,
138 #[props(default)] on_next: Option<EventHandler<()>>,
139 #[props(default)] on_jump: Option<EventHandler<i32>>,
140 #[props(default)] compact: bool,
141) -> Element {
142 let has_prev = current_page > 1;
143 let total_pages = ((total + per_page as i64 - 1) / per_page as i64).max(1) as i32;
144 let has_next = current_page < total_pages;
145
146 let is_admin = variant == "admin";
149 let nav_class = if is_admin {
150 if compact {
151 "flex justify-between"
152 } else {
153 "flex mt-6 justify-between"
154 }
155 } else {
156 "frontend-pagination"
157 };
158 let (link_class, link_extra_next): (String, &'static str) = if is_admin {
159 (
160 format!("{BTN_OUTLINE} inline-flex items-center active:scale-[0.98]"),
161 "",
162 )
163 } else {
164 (
165 format!("{BTN_OUTLINE} inline-flex items-center justify-center whitespace-nowrap focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-paper-primary"),
166 "col-start-3 row-start-1 justify-self-end",
167 )
168 };
169 let disabled_class =
170 "inline-flex items-center px-4 py-2 text-sm font-medium text-paper-secondary border border-paper-border rounded-full cursor-not-allowed";
171
172 let prev_inner = rsx! {
175 span { class: "mr-1", "«" }
176 "上一页"
177 };
178 let next_inner = rsx! {
179 "下一页"
180 span { class: "ml-1", "»" }
181 };
182
183 let mut jump_editing: Signal<bool> = use_signal(|| false);
188 let mut jump_draft: Signal<String> = use_signal(String::new);
189 rsx! {
190 nav { class: nav_class, aria_label: "分页导航",
191 if has_prev {
192 if let Some(on_prev) = on_prev {
193 button {
194 class: "{link_class}",
195 onclick: move |_| on_prev.call(()),
196 {prev_inner}
197 }
198 } else if let Some(pr) = prev_route.clone() {
199 Link { class: "{link_class}", to: pr, {prev_inner} }
200 }
201 } else if is_admin {
202 span { class: "{disabled_class}",
203 span { class: "mr-1", "«" }
204 "上一页"
205 }
206 }
207
208 if is_admin {
210 span { class: "flex items-center gap-1.5 self-center text-sm text-paper-secondary",
211 if total_pages > 1 && on_jump.is_some() {
212 FormInput {
213 r#type: "text",
214 placeholder: "",
215 value: if jump_editing() { jump_draft() } else { current_page.to_string() },
216 class: Some(
217 "w-11 px-1 py-0.5 text-sm text-center bg-transparent text-paper-primary border border-paper-border rounded-full hover:border-paper-accent/60 focus:outline-none focus:border-paper-accent transition-colors",
218 ),
219 inputmode: Some("numeric"),
220 title: Some("输入页码,回车跳转"),
221 oninput: move |v: String| jump_draft.set(v),
222 onfocus: move |_| {
223 jump_draft.set(current_page.to_string());
224 jump_editing.set(true);
225 },
226 onblur: move |_| jump_editing.set(false),
227 onkeydown: move |e: KeyboardEvent| {
228 if e.key() == Key::Enter {
229 if let Some(on_jump) = on_jump {
230 if let Ok(p) = jump_draft().trim().parse::<i32>() {
233 let p = p.clamp(1, total_pages);
234 on_jump.call(p);
235 jump_draft.set(p.to_string());
236 } else {
237 jump_draft.set(current_page.to_string());
238 }
239 }
240 }
241 },
242 }
243 } else {
244 "{current_page}"
245 }
246 " / {total_pages} 页 (共 {total} {unit})"
247 }
248 } else {
249 span {
250 class: "col-start-2 row-start-1 text-xs text-paper-secondary whitespace-nowrap tabular-nums",
251 aria_label: "第 {current_page} 页,共 {total_pages} 页",
252 aria_current: "page",
253 "{current_page} / {total_pages}"
254 }
255 }
256
257 if has_next {
258 if let Some(on_next) = on_next {
259 button {
260 class: "{link_class} {link_extra_next}",
261 onclick: move |_| on_next.call(()),
262 {next_inner}
263 }
264 } else if let Some(nr) = next_route.clone() {
265 Link {
266 class: "{link_class} {link_extra_next}",
267 to: nr,
268 {next_inner}
269 }
270 }
271 } else if is_admin {
272 span { class: "{disabled_class}",
273 "下一页"
274 span { class: "ml-1", "»" }
275 }
276 }
277 }
278 }
279}
280
281#[component]
291pub fn StatusBadge(color_class: &'static str, label: String) -> Element {
292 rsx! {
293 span { class: "{BADGE_BASE} {color_class}", "{label}" }
294 }
295}
296
297#[component]
299pub fn SproutPlaceholder(class: &'static str) -> Element {
300 rsx! {
301 svg { class, view_box: "0 0 32 32", fill: "none", "aria-hidden": "true",
302 path { d: "M16 27V15M16 21C7 21 4 15 5 8C12 8 17 12 16 21ZM16 16C16 7 21 4 28 5C28 12 23 17 16 16M10 27H22", stroke: "currentColor", stroke_width: "1.2", stroke_linecap: "round", stroke_linejoin: "round" }
303 }
304 }
305}
306
307#[component]
319pub fn UserAvatar(name: String, avatar_url: Option<String>, class: &'static str) -> Element {
320 let mut failed_url = use_signal(|| None::<String>);
322 let initial = name
323 .chars()
324 .next()
325 .map(|c| c.to_uppercase().collect::<String>())
326 .unwrap_or_else(|| "?".to_string());
327 match avatar_url.filter(|u| !u.trim().is_empty()) {
328 Some(url) if failed_url.read().as_ref() == Some(&url) => rsx! {
329 span {
330 class: "{class} flex items-center justify-center bg-paper-entry text-paper-tertiary",
331 role: "img",
332 aria_label: "{name} 的头像",
333 SproutPlaceholder { class: "w-2/3 h-2/3" }
334 }
335 },
336 Some(url) => {
337 #[cfg(target_arch = "wasm32")]
338 let url_for_mount = url.clone();
339 rsx! {
340 img {
341 class: "{class} object-cover",
342 src: "{url}",
343 alt: "{name} 的头像",
344 loading: "lazy",
345 decoding: "async",
346 onerror: move |_| failed_url.set(Some(url.clone())),
347 onmounted: move |_event| {
349 #[cfg(target_arch = "wasm32")]
350 {
351 use wasm_bindgen::JsCast;
352 if let Some(img) = _event.data().downcast::<web_sys::Element>()
353 .and_then(|element| element.dyn_ref::<web_sys::HtmlImageElement>())
354 {
355 if img.complete() && img.natural_width() == 0 {
356 failed_url.set(Some(url_for_mount.clone()));
357 }
358 }
359 }
360 },
361 }
362 }
363 }
364 None => rsx! {
365 span { class: "{class} flex items-center justify-center bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-accent)] font-bold select-none",
366 "{initial}"
367 }
368 },
369 }
370}
371
372const TOOLTIP_STYLE: &str =
375 "pointer-events-none absolute px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover/tooltip:opacity-100 transition-opacity duration-200 bg-paper-primary text-paper-theme shadow-lg z-50";
376
377#[component]
395pub fn Tooltip(
396 tip: String,
397 children: Element,
398 #[props(default = "top")] placement: &'static str,
399 #[props(default = "center")] align: &'static str,
400) -> Element {
401 let position_class = if placement == "bottom" {
403 "top-full mt-2"
404 } else {
405 "bottom-full mb-2"
406 };
407 let align_class = match align {
409 "start" => "left-0",
410 "end" => "right-0",
411 _ => "left-1/2 -translate-x-1/2",
412 };
413 rsx! {
414 div { class: "group/tooltip relative inline-flex",
415 {children}
416 div { class: "{TOOLTIP_STYLE} {position_class} {align_class}", "{tip}" }
417 }
418 }
419}
420#[component]
435pub fn CollapsibleSettingsCard(
436 title: String,
437 summary: String,
438 enabled: bool,
439 children: Element,
440 #[props(default)] on_toggle: Option<EventHandler<()>>,
441 #[props(default)] default_open: bool,
442 #[props(default)] class: String,
443 #[props(default)] panel_id: Option<String>,
444) -> Element {
445 let mut open = use_signal(|| default_open);
446 let chevron_rotate = if open() { "rotate-180" } else { "" };
447 let dot_class = if enabled {
448 "w-2 h-2 rounded-full bg-paper-accent shadow-[0_0_0_3px_rgba(64,160,43,0.15)]"
449 } else {
450 "w-2 h-2 rounded-full bg-paper-tertiary"
451 };
452
453 rsx! {
454 div {
455 class: "collapsible-card rounded-2xl border border-paper-border overflow-hidden bg-paper-entry {class}",
456 "data-open": "{open()}",
457 button {
458 r#type: "button",
459 class: "collapsible-trigger w-full flex items-center gap-3 px-5 py-4 text-left cursor-pointer hover:bg-paper-theme focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40",
460 aria_expanded: "{open()}",
461 aria_controls: panel_id.clone(),
462 onclick: move |_| {
463 open.set(!open());
464 if let Some(on_toggle) = on_toggle {
465 on_toggle.call(());
466 }
467 },
468 div { class: "collapsible-indicator w-2 flex-shrink-0 flex items-center justify-center", aria_hidden: "true",
469 div { class: "{dot_class}" }
470 }
471 div { class: "collapsible-heading flex-1 min-w-0",
472 div { class: "collapsible-title text-sm font-medium text-paper-primary", "{title}" }
473 div { class: "collapsible-summary text-xs text-paper-secondary mt-0.5 truncate", "{summary}" }
474 }
475 svg {
476 class: "collapsible-chevron w-4 h-4 text-paper-secondary transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
477 "aria-hidden": "true",
478 view_box: "0 0 24 24",
479 fill: "none",
480 stroke: "currentColor",
481 stroke_width: "2",
482 path {
483 stroke_linecap: "round",
484 stroke_linejoin: "round",
485 d: "M19 9l-7 7-7-7",
486 }
487 }
488 }
489 div {
490 id: panel_id,
491 class: "collapsible-panel",
492 inert: if open() { None } else { Some("") },
494 div { class: "overflow-hidden min-h-0", {children} }
495 }
496 }
497 }
498}
499
500const POPOVER_OVERLAY_CLASS: &str = "fixed inset-0 z-40";
502const POPOVER_PANEL_CLASS: &str =
507 "fixed z-50 bg-[var(--color-paper-entry)] rounded-2xl shadow-lg border border-[var(--color-paper-border)] p-4 animate-popover-enter";
508const POPOVER_PANEL_EDGE_CLASS: &str =
509 "fixed z-50 bg-[var(--color-paper-entry)] rounded-2xl shadow-lg border border-[var(--color-paper-border)] p-4 animate-popover-enter-edge";
510
511#[component]
548#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
549pub fn Popover(
550 open: bool,
551 anchor_x: i32,
552 anchor_y: i32,
553 children: Element,
554 on_close: EventHandler<()>,
555 #[props(default = "top")] placement: &'static str,
556 #[props(default = "center")] align: &'static str,
557) -> Element {
558 #[cfg(target_arch = "wasm32")]
562 {
563 use dioxus::prelude::{use_drop, use_effect, use_hook};
564 use std::cell::RefCell;
565 use std::rc::Rc;
566 type EscState =
567 Rc<RefCell<Option<wasm_bindgen::prelude::Closure<dyn FnMut(web_sys::KeyboardEvent)>>>>;
568 let state: EscState = use_hook(|| Rc::new(RefCell::new(None)));
569 let state_for_drop = state.clone();
570 let open_for_effect = open;
571 let on_close_for_esc = on_close;
572 use_effect(move || {
573 if !open_for_effect {
574 return;
575 }
576 let Some(window) = web_sys::window() else {
577 return;
578 };
579 let on_close_for_esc = on_close_for_esc;
580 let closure =
583 wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::KeyboardEvent| {
584 if ev.key() == "Escape" {
585 on_close_for_esc.call(());
586 }
587 })
588 as Box<dyn FnMut(web_sys::KeyboardEvent)>);
589 let _ = window.add_event_listener_with_callback(
590 "keydown",
591 wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
592 );
593 *state.borrow_mut() = Some(closure);
594 });
595 use_drop(move || {
596 if let Some(closure) = state_for_drop.borrow_mut().take() {
597 if let Some(window) = web_sys::window() {
598 let _ = window.remove_event_listener_with_callback(
599 "keydown",
600 wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
601 );
602 }
603 }
604 });
605 }
606
607 if !open {
608 return rsx! {};
609 }
610
611 let horizontal = match align {
615 "start" => format!("left: {x}px;", x = anchor_x),
616 "end" => format!("right: calc(100vw - {x}px);", x = anchor_x),
617 _ => format!("left: {x}px; transform: translateX(-50%);", x = anchor_x),
618 };
619 let style = if placement == "bottom" {
620 format!("top: {y}px; {horizontal}", y = anchor_y + 8)
621 } else {
622 format!(
625 "bottom: calc(100vh - {y}px + 8px); {horizontal}",
626 y = anchor_y
627 )
628 };
629 let panel_class = if align == "center" {
630 POPOVER_PANEL_CLASS
631 } else {
632 POPOVER_PANEL_EDGE_CLASS
633 };
634
635 rsx! {
636 div {
638 class: "{POPOVER_OVERLAY_CLASS}",
639 onclick: move |_| on_close.call(()),
640 }
641 div { class: "{panel_class}", style: "{style}", {children} }
643 }
644}
645
646pub const EXIT_ANIM_MS: u32 = 200;
649
650const MODAL_OVERLAY_BASE: &str = "fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm modal-overlay animate-modal-overlay-enter";
652const MODAL_PANEL_BASE: &str = "flex flex-col max-h-[80vh] rounded-[2rem] border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] shadow-xl overflow-hidden modal-panel animate-modal-panel-enter";
654
655#[component]
680pub fn ModalShell(
681 mut visible: Signal<bool>,
682 mut closing: Signal<bool>,
683 title: &'static str,
684 #[props(default = "p-6")] overlay_padding: &'static str,
685 #[props(default = "w-full max-w-lg")] panel_class: &'static str,
686 children: Element,
687) -> Element {
688 let mut opened = use_signal(|| false);
691
692 use_effect(move || {
696 if visible() {
697 opened.set(true);
698 closing.set(false);
699 } else if *opened.peek() {
700 if !*closing.peek() {
702 closing.set(true);
703 }
704 spawn(async move {
705 crate::utils::time::sleep_ms(EXIT_ANIM_MS).await;
706 closing.set(false);
707 });
708 }
709 });
710
711 let is_closing = closing();
713 if !visible() && !is_closing {
714 return rsx! {};
715 }
716
717 rsx! {
718 div {
720 class: "{MODAL_OVERLAY_BASE} {overlay_padding}",
721 class: if is_closing { "is-closing" } else { "" },
722 onclick: move |_| {
723 closing.set(true);
724 visible.set(false);
725 },
726 div {
728 class: "{MODAL_PANEL_BASE} {panel_class}",
729 role: "dialog",
730 aria_modal: "true",
731 aria_label: "{title}",
732 onclick: move |evt| evt.stop_propagation(),
733 {children}
734 }
735 }
736 }
737}
738
739static TAB_GROUP_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
740
741#[component]
751pub fn FilterTabs(
752 items: Vec<(&'static str, &'static str)>,
753 active_value: String,
754 on_change: EventHandler<String>,
755) -> Element {
756 #[allow(unused_mut)]
757 let mut indicator_style = use_signal(|| "left: 0px; width: 0px; opacity: 0;".to_string());
758 let id_prefix = use_hook(|| TAB_GROUP_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
759
760 #[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
761 let update_indicator = move |active: String| {
762 spawn(async move {
763 #[cfg(target_arch = "wasm32")]
764 {
765 use wasm_bindgen::JsCast;
766
767 crate::utils::time::sleep_ms(50).await;
769
770 if let Some(window) = web_sys::window() {
771 if let Some(doc) = window.document() {
772 let element_id = format!("tab-{}-{}", id_prefix, active);
773 if let Some(el) = doc.get_element_by_id(&element_id) {
774 if let Ok(html_el) = el.dyn_into::<web_sys::HtmlElement>() {
775 let left = html_el.offset_left();
776 let width = html_el.offset_width();
777 indicator_style.set(format!(
778 "left: {}px; width: {}px; opacity: 1;",
779 left, width
780 ));
781 }
782 }
783 }
784 }
785 }
786 });
787 };
788
789 use_effect({
790 let active_value = active_value.clone();
791 move || {
792 update_indicator(active_value.clone());
793 }
794 });
795
796 rsx! {
797 div { class: "relative flex gap-4 border-b border-paper-border mb-6",
798 for (value, label) in items {
799 button {
800 id: "tab-{id_prefix}-{value}",
801 key: "{value}",
802 class: if active_value == *value { "cursor-pointer px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-primary transition-colors" } else { "cursor-pointer px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-secondary hover:text-paper-primary transition-colors" },
803 onclick: {
804 let v = value.to_string();
805 move |_| {
806 on_change.call(v.clone());
807 update_indicator(v.clone());
808 }
809 },
810 "{label}"
811 }
812 }
813 div {
815 class: "absolute bottom-[-1px] h-[2px] bg-paper-primary transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] pointer-events-none",
816 style: "{indicator_style}",
817 }
818 }
819 }
820}
821
822#[component]
839pub fn LoadingButton(
840 label: String,
841 loading: bool,
842 #[props(default = false)] disabled: bool,
843 #[props(default = "primary")] variant: &'static str,
844 onclick: EventHandler<()>,
845) -> Element {
846 let base = if variant == "sm" {
847 BTN_PRIMARY_SM
848 } else {
849 BTN_PRIMARY
850 };
851 let class = if disabled && !loading {
854 let size = if variant == "sm" {
855 "px-4 py-1.5"
856 } else {
857 "px-5 py-2"
858 };
859 format!(
860 "relative inline-flex items-center justify-center {size} rounded-full text-sm font-medium transition-all bg-[var(--color-paper-tertiary)] text-[var(--color-paper-secondary)] cursor-not-allowed"
861 )
862 } else {
863 format!("relative {base}")
864 };
865
866 rsx! {
867 button {
868 class: "{class}",
869 disabled: loading || disabled,
870 onclick: move |_| onclick.call(()),
871 span { class: if loading { "opacity-0" } else { "" }, "{label}" }
872 if loading {
873 span {
874 class: "absolute inset-0 flex items-center justify-center",
875 dangerous_inner_html: SPINNER_SVG,
876 }
877 }
878 }
879 }
880}
881
882#[component]
895pub fn TagChip<R: Routable + Clone + PartialEq + 'static>(
896 label: String,
897 to: R,
898 #[props(default = "outline")] variant: &'static str,
899 #[props(default)] count: Option<i64>,
900 #[props(default)] stop_propagation: bool,
901) -> Element {
902 let class = match variant {
903 "archive" => "archive-tag-chip",
904 "solid" => "inline-flex items-center px-3 py-1.5 text-base font-medium bg-paper-accent-soft text-paper-accent rounded-lg hover:bg-paper-accent hover:text-white transition-all duration-200",
905 "text" => "inline-flex items-center py-1 text-paper-secondary hover:text-paper-primary underline decoration-paper-border underline-offset-4 hover:decoration-paper-accent transition-colors focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-paper-accent",
906 _ => "inline-flex items-center px-3 py-1 rounded-full border border-paper-border hover:bg-paper-accent hover:border-paper-accent hover:text-white transition-all duration-200",
907 };
908 rsx! {
909 Link {
910 class: "{class}",
911 to,
912 onclick: move |evt: dioxus::events::MouseEvent| {
913 if stop_propagation {
914 evt.stop_propagation();
915 }
916 },
917 if variant == "archive" {
918 span { class: "archive-tag-mark", aria_hidden: "true", "#" }
919 span { class: "archive-tag-name", "{label}" }
920 } else {
921 "{label}"
922 }
923 if let Some(c) = count {
924 if variant == "archive" {
925 span { class: "archive-tag-count", aria_label: "{c} 篇文章", "{c}" }
926 } else {
927 sup { class: "ml-1 text-sm text-paper-secondary", "{c}" }
928 }
929 }
930 }
931 }
932}
933
934#[component]
949pub fn Checkbox(
950 checked: bool,
951 onchange: EventHandler<bool>,
952 #[props(default)] danger: bool,
953) -> Element {
954 let wrap = if danger {
955 "ygg-cb ygg-cb-danger"
956 } else {
957 "ygg-cb"
958 };
959 rsx! {
960 span { class: "{wrap}",
961 input {
962 r#type: "checkbox",
963 checked,
964 onchange: move |e: Event<FormData>| onchange.call(e.checked()),
965 }
966 svg { class: "ygg-cb-mark", view_box: "0 0 16 16",
967 path { class: "ygg-cb-check", d: "M3.5 8.5l3 3 6-6.5" }
968 }
969 }
970 }
971}
972
973#[cfg(test)]
974mod tests {
975 use super::*;
976
977 #[test]
978 fn tooltip_uses_named_group_to_prevent_ancestor_trigger() {
979 assert!(
980 TOOLTIP_STYLE.contains("group-hover/tooltip:opacity-100"),
981 "Tooltip 必须使用专属命名空间 group-hover/tooltip:opacity-100,避免被外层祖先 group 误触"
982 );
983 }
984}