htmx_components/server/
form.rs

1use 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// FormLayouts ////////////////////////////////////////////////
219
220#[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                // For now hardcode this layout of cells (col w/ .5rem gap)
270                // If we have other cell layouts, we can create new enum
271                classes.push("flex flex-col gap-2".to_string());
272
273                classes.push(match props.span {
274                    // generates classes (for tailwind) in tailwind.config.js safelist
275                    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                    // generates classes (for tailwind) in tailwind.config.js safelist
281                    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}