htmx_components/server/
form.rs1use rscx::{component, html, props};
2
3use rscx_web_macros::*;
4
5use super::{attrs::Attrs, html_element::HtmlElement};
6use crate::server::yc_control::YcControl;
7
8#[rscx_web_macros::html_element]
9pub struct TextInputProps {
10 #[builder(setter(into), default="text".into())]
11 input_type: String,
12
13 #[builder(setter(into), default=None)]
14 error: Option<String>,
15}
16
17#[component]
18pub fn TextInput(props: TextInputProps) -> String {
19 let class = match props.error {
20 Some(_) => "bg-red-50 ring-red-500 text-red-500 placeholder-red-700 focus:ring-red-500 focus:border-red-500",
21 None => "text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600",
22 };
23
24 let (tag, children) = match props.input_type.as_str() {
25 "textarea" => ("textarea", props.value.clone()),
26 _ => ("input", "".to_string()),
27 };
28
29 html! {
30 <HtmlElement
31 tag=tag
32 id=props.name.clone()
33 class=format!("block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 {}", class)
34 attrs=spread_attrs!(props | omit(id, class)).set("type", props.input_type.clone())
35 children=children
36 />
37 <ErrorMessage message=props.error />
38 }
39}
40
41#[html_element]
42pub struct LabelProps {
43 #[builder(setter(into))]
44 for_input: String,
45 children: String,
46
47 #[builder(default = false)]
48 error: bool,
49}
50
51#[component]
52pub fn Label(props: LabelProps) -> String {
53 let color = if props.error {
54 "text-red-600 dark:text-red-500"
55 } else {
56 "text-gray-900"
57 };
58
59 html! {
60 <HtmlElement
61 tag="label"
62 class=format!("block text-sm font-medium leading-6 {} {}", color, props.class).trim()
63 attrs=spread_attrs!(props | omit(class)).set("for", props.for_input)
64 >
65 {props.children}
66 </HtmlElement>
67 }
68}
69
70#[html_element]
71pub struct SelectProps {
72 children: String,
73
74 #[builder(setter(into), default=None)]
75 error: Option<String>,
76}
77
78#[component]
79pub fn Select(props: SelectProps) -> String {
80 let class = match props.error {
81 Some(_) => "bg-red-50 ring-red-500 text-red-500 placeholder-red-700 focus:ring-red-500 focus:border-red-500",
82 None => "text-gray-900 ring-gray-300 focus:ring-indigo-600",
83 };
84 html! {
85 <HtmlElement
86 tag="select"
87 id=props.name.clone()
88 class=format!("block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:max-w-xs sm:text-sm sm:leading-6 {} {}", class, props.class).trim()
89 attrs=spread_attrs!(props | omit(id, class))
90 >
91 {props.children}
92 </HtmlElement>
93 <ErrorMessage message=props.error />
94 }
95}
96
97#[html_element]
98pub struct SelectOptionProps {
99 #[builder(setter(into), default)]
100 label: String,
101
102 #[builder(default = false)]
103 selected: bool,
104
105 #[builder(default)]
106 children: String,
107}
108
109#[component]
110pub fn SelectOption(props: SelectOptionProps) -> String {
111 html! {
112 <HtmlElement
113 tag="option"
114 id=props.name.clone()
115 attrs=spread_attrs!(props | omit(id))
116 .set_if("selected", "selected".into(), props.selected)
117 .set_if("label", props.label.clone(), !props.label.is_empty())
118 >
119 {props.children}
120 </HtmlElement>
121 }
122}
123
124#[component]
125fn ErrorMessage(message: Option<String>) -> String {
126 if let Some(message) = message {
127 html! {
128 <p class="text-sm text-red-600 dark:text-red-500">{message}</p>
129 }
130 } else {
131 String::new()
132 }
133}
134
135#[html_element]
136pub struct ButtonProps {
137 #[builder(setter(into), default="button".into())]
138 kind: String,
139 children: String,
140}
141
142#[component]
143pub fn Button(props: ButtonProps) -> String {
144 let button_type = props.kind.clone();
145 let css = match props.kind.as_str() {
146 "submit" => "rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600",
147 _ => "text-sm font-semibold leading-6 text-gray-900",
148 };
149
150 html! {
151 <HtmlElement
152 tag="button"
153 class=format!("{} {}", css, props.class).trim()
154 attrs=spread_attrs!(props | omit(class, name)).set("type", button_type)
155 >
156 {props.children}
157 </HtmlElement>
158 }
159}
160
161#[props]
162pub struct FileInputProps {
163 #[builder(setter(into))]
164 id: String,
165
166 #[builder(setter(into))]
167 name: String,
168
169 #[builder(setter(into), default = "Any file up to 10MB".into())]
170 file_hint_message: String,
171
172 #[builder(setter(into), default)]
173 accept: String,
174
175 #[builder(default = false)]
176 multiple: bool,
177}
178
179#[component]
180pub fn FileInput(props: FileInputProps) -> String {
181 html! {
182 <YcControl
183 control="file-input"
184 class="mt-2 group flex justify-center transition-all rounded-lg border border-dashed border-gray-900/25 px-6 py-10 data-[dragover]:border-2 data-[dragover]:border-indigo-600/50 data-[dragover]:bg-gray-900/10"
185 >
186 <div class="text-center">
187 <svg class="mx-auto h-12 w-12 text-gray-300" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
188 <path fill-rule="evenodd" d="M1.5 6a2.25 2.25 0 012.25-2.25h16.5A2.25 2.25 0 0122.5 6v12a2.25 2.25 0 01-2.25 2.25H3.75A2.25 2.25 0 011.5 18V6zM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0021 18v-1.94l-2.69-2.689a1.5 1.5 0 00-2.12 0l-.88.879.97.97a.75.75 0 11-1.06 1.06l-5.16-5.159a1.5 1.5 0 00-2.12 0L3 16.061zm10.125-7.81a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0z" clip-rule="evenodd" />
189 </svg>
190 <div class="mt-4 flex text-sm leading-6 text-gray-600">
191 <label for=props.id.as_ref() class="relative cursor-pointer rounded-md font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500">
192 <span>Upload a file</span>
193 <input
194 type="file"
195 id=props.id.as_ref()
196 name=props.name.as_ref()
197 class="sr-only"
198 {
199 String::from(
200 Attrs::default()
201 .set_if("accept", props.accept.clone(), !props.accept.is_empty())
202 .set_if("multiple", "true".into(), props.multiple)
203 )
204 }
205 />
206 </label>
207 <p class="pl-1">or drag and drop</p>
208 </div>
209 <p class="text-xs leading-5 text-gray-600">
210 <span class="group-[.file-selected]:hidden">{props.file_hint_message}</span>
211 <span class="hidden font-bold text-sm group-[.file-selected]:inline" data-file-input-selected-message>File Selected!</span>
212 </p>
213 </div>
214 </YcControl>
215 }
216}
217
218#[html_element]
221pub struct GridLayoutProps {
222 children: String,
223}
224
225#[component]
226pub fn GridLayout(props: GridLayoutProps) -> String {
227 html! {
228 <HtmlElement
229 tag="div"
230 class=format!("grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6 {}", props.class).trim()
231 attrs=spread_attrs!(props | omit(class))
232 >
233 {props.children}
234 </HtmlElement>
235 }
236}
237
238#[derive(Clone)]
239pub enum CellSpan {
240 Size(usize),
241 Full,
242}
243
244impl From<usize> for CellSpan {
245 fn from(size: usize) -> Self {
246 CellSpan::Size(size)
247 }
248}
249
250#[html_element]
251pub struct GridCellProps {
252 children: String,
253
254 #[builder(setter(into), default=CellSpan::Full)]
255 span: CellSpan,
256
257 #[builder(default = 0)]
258 start: usize,
259}
260
261#[component]
262pub fn GridCell(props: GridCellProps) -> String {
263 html! {
264 <HtmlElement
265 tag="div"
266 class={
267 let mut classes = Vec::new();
268
269 classes.push("flex flex-col gap-2".to_string());
272
273 classes.push(match props.span {
274 CellSpan::Size(size) => format!("sm:col-span-{}", size),
276 CellSpan::Full => "sm:col-span-full".to_string(),
277 });
278
279 if props.start > 0 {
280 classes.push(format!("sm:col-start-{}", props.start));
282 }
283
284 if !props.class.is_empty() {
285 classes.push(props.class);
286 }
287
288 classes.join(" ")
289 }
290 attrs=spread_attrs!(props | omit(class))
291 >
292 {props.children}
293 </HtmlElement>
294 }
295}