Skip to main content

yggdrasil/components/ui/
controls.rs

1//! Buttons and native selection controls.
2use super::display::Tooltip;
3use super::icons::{Icon, IconName};
4use leptos::ev::MouseEvent;
5use leptos::prelude::*;
6
7/// 按钮样式,对应 styles/components/ui.css 的 `.btn-*`。
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9pub enum ButtonVariant {
10    /// 陶土底:一屏只放一个的主操作。
11    #[default]
12    Primary,
13    /// 浅灰底:并列的次要操作。
14    Secondary,
15    /// 透明底:取消、工具栏里的弱操作。
16    Ghost,
17    /// 发丝线描边:与主操作并排、需要一点分量的操作。
18    Outline,
19    /// 红底:不可撤销的删除。
20    Danger,
21    /// 浅红底:危险但可撤销,或危险操作的入口。
22    DangerSoft,
23}
24
25impl ButtonVariant {
26    const fn class(self) -> &'static str {
27        match self {
28            ButtonVariant::Primary => "btn-primary",
29            ButtonVariant::Secondary => "btn-secondary",
30            ButtonVariant::Ghost => "btn-ghost",
31            ButtonVariant::Outline => "btn-outline",
32            ButtonVariant::Danger => "btn-danger",
33            ButtonVariant::DangerSoft => "btn-danger-soft",
34        }
35    }
36}
37
38/// 按钮尺寸:XS 26px、SM 30px、MD 34px、LG 40px。
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40pub enum ButtonSize {
41    Xs,
42    Sm,
43    #[default]
44    Md,
45    Lg,
46}
47
48impl ButtonSize {
49    const fn class(self) -> &'static str {
50        match self {
51            ButtonSize::Xs => "btn-xs",
52            ButtonSize::Sm => "btn-sm",
53            ButtonSize::Md => "",
54            ButtonSize::Lg => "btn-lg",
55        }
56    }
57
58    const fn icon_px(self) -> u32 {
59        match self {
60            ButtonSize::Xs => 14,
61            ButtonSize::Sm | ButtonSize::Md => 16,
62            ButtonSize::Lg => 18,
63        }
64    }
65}
66
67/// 组合按钮类名,供 `<a>`、`label` 等需要按钮外观的非按钮元素使用。
68pub fn button_class(variant: ButtonVariant, size: ButtonSize) -> String {
69    format!("btn {} {}", size.class(), variant.class())
70}
71
72/// 通用按钮。
73///
74/// - `icon` 放在文字前;`loading` 时文字隐去、转圈居中,按钮宽度不变,并标 `aria-busy`。
75/// - `loading` 或 `disabled` 时不响应点击。
76/// - `variant`、`loading`、`disabled`、`class` 可传常量、信号或闭包,随状态更新;
77///   `icon` 是 `MaybeProp`:可传 `IconName`、`Some(..)` 或信号,随状态切换时用
78///   `Signal::derive(move || ..)`(闭包不能直接转换)。
79#[component]
80pub fn Button(
81    #[prop(optional, into)] variant: Signal<ButtonVariant>,
82    #[prop(optional)] size: ButtonSize,
83    #[prop(optional, into)] icon: MaybeProp<IconName>,
84    #[prop(optional, into)] loading: Signal<bool>,
85    #[prop(optional, into)] disabled: Signal<bool>,
86    #[prop(default = "button")] r#type: &'static str,
87    #[prop(optional, into)] class: Signal<String>,
88    #[prop(optional, into)] onclick: Option<Callback<MouseEvent>>,
89    children: Children,
90) -> impl IntoView {
91    let px = size.icon_px();
92    view! {
93        <button
94            class=move || format!("{} {}", button_class(variant.get(), size), class.get())
95            type=r#type
96            disabled=move || disabled.get() || loading.get()
97            aria-busy=move || loading.get().then_some("true")
98            on:click=move |e| {
99                if let Some(handler) = onclick {
100                    handler.run(e);
101                }
102            }
103        >
104            <span class="btn-label inline-flex items-center gap-[inherit]">
105                {move || icon.get().map(|icon| view! { <Icon name=icon size=px/> })}
106                {children()}
107            </span>
108            <Show when=move || loading.get()>
109                <span class="btn-spinner">
110                    <Icon name=IconName::LoaderCircle size=px/>
111                </span>
112            </Show>
113        </button>
114    }
115}
116
117/// 只有图标的方形按钮。`label` 必填:作为读屏名称,同时显示为悬浮提示;
118/// `shortcut` 会跟在提示文字后面。
119#[component]
120pub fn IconButton(
121    #[prop(into)] icon: Signal<IconName>,
122    #[prop(into)] label: Signal<String>,
123    #[prop(default = ButtonVariant::Ghost.into(), into)] variant: Signal<ButtonVariant>,
124    #[prop(optional)] size: ButtonSize,
125    #[prop(optional, into)] disabled: Signal<bool>,
126    #[prop(optional, into)] pressed: Signal<Option<bool>>,
127    #[prop(optional, into)] shortcut: MaybeProp<&'static str>,
128    #[prop(default = "top".into(), into)] tooltip_placement: Signal<String>,
129    #[prop(default = "center".into(), into)] tooltip_align: Signal<String>,
130    #[prop(optional, into)] class: Signal<String>,
131    #[prop(optional, into)] onclick: Option<Callback<MouseEvent>>,
132) -> impl IntoView {
133    let px = size.icon_px();
134    view! {
135        <Tooltip tip=label shortcut=shortcut placement=tooltip_placement align=tooltip_align>
136            <button
137                class=move || {
138                    format!("{} btn-icon {}", button_class(variant.get(), size), class.get())
139                }
140                type="button"
141                disabled=move || disabled.get()
142                aria-label=move || label.get()
143                aria-pressed=move || pressed.get().map(|p| if p { "true" } else { "false" })
144                on:click=move |e| {
145                    if let Some(handler) = onclick {
146                        handler.run(e);
147                    }
148                }
149            >
150                {move || view! { <Icon name=icon.get() size=px/> }}
151            </button>
152        </Tooltip>
153    }
154}
155
156/// 带勾画动画的复选框:对勾 SVG 用 stroke-dashoffset 描边绘制 + scale 弹入。
157///
158/// 原生 `<input type="checkbox">` 是替换元素,吃不到 `::after`/`::before`,
159/// 无法做 transform 描边动画。本组件用透明 input 做命中/无障碍层,同级 SVG
160/// path 做对勾视觉层;勾选时 path 的 stroke-dashoffset 归零「画出」对勾,
161/// 同时 SVG scale 从 0.6 弹入(弹跳曲线)——双层动画带来盖戳质感。
162///
163/// 与旧的 `.ygg-checkbox`(纯 background-size 缩放)相比,描边动画更像手写勾画,
164/// 且 SVG 是矢量合成层,比光栅 background-image 缩放更锐利。
165///
166/// Props:
167/// - `checked`:受控勾选态
168/// - `onchange`:状态变化回调,返回新的 bool
169/// - `danger`:危险态(红色语义,SQL 控制台「我了解后果」),缺省 `false`
170/// - `label`:没有可见文字关联时的读屏名称,如表格行的「选择《标题》」
171#[component]
172pub fn Checkbox(
173    #[prop(into)] checked: Signal<bool>,
174    #[prop(into)] onchange: Callback<bool>,
175    #[prop(optional, into)] danger: Signal<bool>,
176    #[prop(optional, into)] label: Option<String>,
177) -> impl IntoView {
178    view! {
179        <span class=move || {
180            if danger.get() { "ygg-cb ygg-cb-danger" } else { "ygg-cb" }
181        }>
182            <input
183                type="checkbox"
184                prop:checked=move || checked.get()
185                aria-label=label
186                on:change=move |e| onchange.run(event_target_checked(&e))
187            />
188            <svg class="ygg-cb-mark" viewBox="0 0 16 16">
189                <path class="ygg-cb-check" d="M3.5 8.5l3 3 6-6.5"></path>
190            </svg>
191        </span>
192    }
193}
194
195/// 主题化单选框,与 Checkbox 使用相同尺寸、主题色和焦点描边。
196///
197/// 同组使用相同 `name`,每项提供独立 `value`;用外层 `label` 关联可见文案。
198/// 保留原生 radio 的互斥、方向键和表单语义,`onchange` 返回选中项的值。
199#[component]
200pub fn Radio(
201    #[prop(into)] name: String,
202    #[prop(into)] value: String,
203    #[prop(into)] checked: Signal<bool>,
204    #[prop(into)] onchange: Callback<String>,
205    #[prop(optional, into)] disabled: Signal<bool>,
206) -> impl IntoView {
207    let current = value.clone();
208    view! {
209        <input
210            class="ygg-radio"
211            type="radio"
212            name=name
213            value=value
214            prop:checked=move || checked.get()
215            disabled=move || disabled.get()
216            on:change=move |_| onchange.run(current.clone())
217        />
218    }
219}