yggdrasil/components/ui/
composer.rs1use 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#[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
36pub(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#[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#[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}