Skip to main content

yggdrasil/components/ui/
composer.rs

1//! 输入栏:圆角外壳 + 输入框 + 陶土发送键。
2use super::icons::{Icon, IconName};
3use leptos::ev::{KeyboardEvent, MouseEvent};
4use leptos::prelude::*;
5
6#[derive(Clone, Copy, PartialEq, Default)]
7pub enum ComposerSize {
8    #[default]
9    Md,
10    Sm,
11}
12
13/// 输入栏外壳:20px(Sm 为 14px)圆角、surface-3、发丝线 + composer 阴影,
14/// 聚焦时 Clay 双环。
15///
16/// Props:
17/// - `size`:Md(默认)或 Sm
18/// - `stacked`:块级排列(评论框、快速记录):不做横排,也不加内边距
19/// - `class`:外层附加类
20#[component]
21pub fn Composer(
22    #[prop(optional)] size: ComposerSize,
23    #[prop(optional)] stacked: bool,
24    #[prop(optional, into)] class: String,
25    children: Children,
26) -> impl IntoView {
27    let size_class = if size == ComposerSize::Sm {
28        " composer-sm"
29    } else {
30        ""
31    };
32    let stacked_class = if stacked { " composer-stacked" } else { "" };
33    view! { <div class=format!("composer{size_class}{stacked_class} {class}")>{children()}</div> }
34}
35
36/// 输入法组合中:composition 状态、isComposing、Process 键,或 keyCode 229
37/// (Safari 确认候选的回车)。全仓输入法守卫只有这一种写法。
38pub(crate) fn ime_composing(composing: bool, e: &KeyboardEvent) -> bool {
39    if composing || e.is_composing() || e.key() == "Process" {
40        return true;
41    }
42    #[cfg(target_arch = "wasm32")]
43    {
44        if e.key_code() == 229 {
45            return true;
46        }
47    }
48    false
49}
50
51/// 输入栏里的输入框:单行 input 或多行 textarea(`field-sizing: content`),
52/// 自带输入法守卫。
53///
54/// Props:
55/// - `value` / `oninput`:受控值
56/// - `placeholder`、`id`、`aria_label`、`maxlength`、`autofocus`、`disabled`
57/// - `multiline`:多行 textarea;否则单行 input
58/// - `search`:单行搜索框,`type=search`、`name=q`、`enterkeyhint=search`
59///   (无脚本时随表单 GET 提交)
60/// - `on_submit`:多行时 ⌘/Ctrl+Enter 调用;单行时给了就由 Enter 调用并阻止默认,
61///   否则交给所在表单原生提交
62/// - `onkeydown`:调用方的其余按键(Escape、↓ 等);输入法组合期间不转发
63/// - `oncomposition`:组合输入开始(true)/ 结束(false)
64/// - `input_ref` / `textarea_ref`:单行 input / 多行 textarea 的 `NodeRef`,供调用方聚焦
65#[component]
66pub fn ComposerInput(
67    #[prop(into)] value: Signal<String>,
68    #[prop(into)] oninput: Callback<String>,
69    #[prop(into)] placeholder: String,
70    #[prop(optional, into)] id: Option<String>,
71    #[prop(optional, into)] aria_label: Option<String>,
72    #[prop(optional)] multiline: bool,
73    #[prop(optional)] search: bool,
74    #[prop(optional)] maxlength: Option<u32>,
75    #[prop(optional)] autofocus: bool,
76    #[prop(optional, into)] disabled: Signal<bool>,
77    #[prop(optional, into)] on_submit: Option<Callback<()>>,
78    #[prop(optional, into)] onkeydown: Option<Callback<KeyboardEvent>>,
79    #[prop(optional, into)] oncomposition: Option<Callback<bool>>,
80    #[prop(optional)] input_ref: Option<NodeRef<leptos::html::Input>>,
81    #[prop(optional)] textarea_ref: Option<NodeRef<leptos::html::Textarea>>,
82) -> impl IntoView {
83    let composing = RwSignal::new(false);
84    let maxlength = maxlength.map(|n| n.to_string());
85
86    let on_keydown = move |e: KeyboardEvent| {
87        if ime_composing(composing.get_untracked(), &e) {
88            return;
89        }
90        let submit = if multiline {
91            (e.meta_key() || e.ctrl_key()) && e.key() == "Enter"
92        } else {
93            e.key() == "Enter" && on_submit.is_some()
94        };
95        if submit {
96            if let Some(on_submit) = on_submit {
97                e.prevent_default();
98                on_submit.run(());
99                return;
100            }
101        }
102        if let Some(handler) = onkeydown {
103            handler.run(e);
104        }
105    };
106    let on_composition_start = move |_| {
107        composing.set(true);
108        if let Some(handler) = oncomposition {
109            handler.run(true);
110        }
111    };
112    let on_composition_end = move |_| {
113        composing.set(false);
114        if let Some(handler) = oncomposition {
115            handler.run(false);
116        }
117    };
118
119    if multiline {
120        view! {
121            <textarea
122                node_ref=textarea_ref.unwrap_or_default()
123                id=id
124                class="composer-input"
125                rows="2"
126                aria-label=aria_label
127                placeholder=placeholder
128                maxlength=maxlength
129                autofocus=autofocus
130                disabled=move || disabled.get()
131                prop:value=move || value.get()
132                on:input=move |e| oninput.run(event_target_value(&e))
133                on:keydown=on_keydown
134                on:compositionstart=on_composition_start
135                on:compositionend=on_composition_end
136            >
137                {value.get_untracked()}
138            </textarea>
139        }
140        .into_any()
141    } else {
142        view! {
143            <input
144                node_ref=input_ref.unwrap_or_default()
145                id=id
146                class="composer-input"
147                type=if search { "search" } else { "text" }
148                name=search.then_some("q")
149                enterkeyhint=search.then_some("search")
150                autocomplete="off"
151                aria-label=aria_label
152                placeholder=placeholder
153                maxlength=maxlength
154                autofocus=autofocus
155                disabled=move || disabled.get()
156                value=value.get_untracked()
157                prop:value=move || value.get()
158                on:input=move |e| oninput.run(event_target_value(&e))
159                on:keydown=on_keydown
160                on:compositionstart=on_composition_start
161                on:compositionend=on_composition_end
162            />
163        }
164        .into_any()
165    }
166}
167
168/// 陶土发送键。不给 `onclick` 时为 `type=submit`。
169#[component]
170pub fn ComposerSend(
171    #[prop(into)] label: Signal<String>,
172    #[prop(default = IconName::ArrowRight)] icon: IconName,
173    #[prop(optional, into)] disabled: Signal<bool>,
174    #[prop(optional, into)] onclick: Option<Callback<MouseEvent>>,
175) -> impl IntoView {
176    view! {
177        <button
178            class="composer-send"
179            type=if onclick.is_some() { "button" } else { "submit" }
180            aria-label=move || label.get()
181            disabled=move || disabled.get()
182            on:click=move |e| {
183                if let Some(handler) = onclick {
184                    handler.run(e);
185                }
186            }
187        >
188            <Icon name=icon size=16/>
189        </button>
190    }
191}