summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock15
-rw-r--r--Cargo.toml1
-rw-r--r--rocie-macros/.gitignore1
-rw-r--r--rocie-macros/Cargo.lock58
-rw-r--r--rocie-macros/Cargo.toml13
-rw-r--r--rocie-macros/src/form/generate.rs322
-rw-r--r--rocie-macros/src/form/mod.rs37
-rw-r--r--rocie-macros/src/form/parse.rs132
-rw-r--r--rocie-macros/src/lib.rs11
-rw-r--r--src/components/buy.rs23
-rw-r--r--src/components/form.rs120
-rw-r--r--src/components/input_placeholder.rs7
-rw-r--r--src/components/mod.rs11
-rw-r--r--src/components/select_placeholder.rs93
-rw-r--r--tailwind.config.js2
15 files changed, 709 insertions, 137 deletions
diff --git a/Cargo.lock b/Cargo.lock
index ad62a73..2d545c0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1576,9 +1576,9 @@ dependencies = [
 
 [[package]]
 name = "quote"
-version = "1.0.40"
+version = "1.0.41"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
+checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
 dependencies = [
  "proc-macro2",
 ]
@@ -1770,6 +1770,16 @@ dependencies = [
 ]
 
 [[package]]
+name = "rocie-macros"
+version = "0.1.0"
+dependencies = [
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
 name = "rocie-mobile"
 version = "0.1.0"
 dependencies = [
@@ -1784,6 +1794,7 @@ dependencies = [
  "log",
  "reactive_stores",
  "rocie-client",
+ "rocie-macros",
  "uuid",
  "wasm-bindgen",
  "wasm-bindgen-test",
diff --git a/Cargo.toml b/Cargo.toml
index d1579b1..eb9e795 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,6 +17,7 @@ wasm-bindgen-test = "0.3"
 web-sys = { version = "0.3", features = ["Document", "Window"] }
 
 [dependencies]
+rocie-macros = { path = "./rocie-macros/" }
 rocie-client = { path = "../rocie-server/crates/rocie-client" }
 leptos = { version = "0.8", features = ["csr"] }
 reactive_stores = { version = "0.2" }
diff --git a/rocie-macros/.gitignore b/rocie-macros/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/rocie-macros/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/rocie-macros/Cargo.lock b/rocie-macros/Cargo.lock
new file mode 100644
index 0000000..dbce2cb
--- /dev/null
+++ b/rocie-macros/Cargo.lock
@@ -0,0 +1,58 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.101"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rocie-macros"
+version = "0.1.0"
+dependencies = [
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
diff --git a/rocie-macros/Cargo.toml b/rocie-macros/Cargo.toml
new file mode 100644
index 0000000..01e9e5c
--- /dev/null
+++ b/rocie-macros/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "rocie-macros"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+proc-macro2 = "1.0.101"
+quote = "1.0.41"
+syn = {version = "2.0.106", features = []}
+prettyplease = "0.2"
+
+[lib]
+proc-macro = true
diff --git a/rocie-macros/src/form/generate.rs b/rocie-macros/src/form/generate.rs
new file mode 100644
index 0000000..6673ba5
--- /dev/null
+++ b/rocie-macros/src/form/generate.rs
@@ -0,0 +1,322 @@
+use proc_macro::TokenStream;
+use proc_macro2::TokenStream as TokenStream2;
+use quote::{format_ident, quote};
+use syn::{Ident, parse_macro_input};
+
+use crate::form::{ParsedChild, ParsedInput};
+
+pub fn form_impl(item: TokenStream) -> TokenStream {
+    let input = parse_macro_input!(item as ParsedInput);
+
+    let node_refs = input.children.iter().map(node_ref);
+    let signals = input.children.iter().map(signal);
+
+    let type_verifications = input.children.iter().map(type_verification);
+
+    let output_struct_definition = output_struct_definition(&input.children);
+    let output_struct_construction = output_struct_construction(&input.children);
+
+    let fetch_values = input.children.iter().map(fetch_value);
+
+    let bounds = input.on_submit.inputs;
+    let on_submit_block = input.on_submit.block;
+
+    let input_htmls = input.children.iter().map(input_html);
+
+    let output = quote!({
+        use crate::components::{
+            input_placeholder::InputPlaceholder,
+            select_placeholder::SelectPlaceholder
+        };
+
+        use leptos::{
+            view,
+            prelude::{
+                Get,
+                NodeRef,
+                ElementChild,
+                ClassAttribute,
+                OnAttribute,
+                signal,
+                Set,
+                Show,
+            },
+            html::{Input, Select},
+            web_sys::SubmitEvent
+        };
+
+        use log::{info, error};
+
+
+        #(
+            #node_refs
+        )*
+        #(
+            #signals
+        )*
+        #(
+            #type_verifications
+        )*
+
+        let on_submit = move |ev: SubmitEvent| {
+            #output_struct_definition
+
+            // stop the page from reloading!
+            ev.prevent_default();
+
+            #(
+                #fetch_values
+            )*
+
+            let real_on_submit = |Inputs {#(#bounds),*}| #on_submit_block;
+            real_on_submit(
+                    #output_struct_construction
+            )
+        };
+
+
+        view! {
+            <form class="flex flex-col contents-start m-2 g-2" on:submit=on_submit>
+                #(
+                    #input_htmls
+                )*
+
+                <div class="static">
+                    <input
+                        type="submit"
+                        value="Submit"
+                        class="absolute bottom-0 right-0 h-20 w-20 rounded-lg bg-green-300 m-2"
+                    />
+                </div>
+            </form>
+        }
+    });
+
+    // {
+    //     match syn::parse_file(quote! {fn main() { #output }}.to_string().as_str()) {
+    //         Ok(tree) => {
+    //             let formatted = prettyplease::unparse(&tree);
+    //             eprint!("{}", formatted);
+    //         }
+    //         Err(err) => {
+    //             eprintln!("Error: {err}\n{output}");
+    //         }
+    //     };
+    // }
+
+    output.into()
+}
+
+fn node_ref_name(name: &Ident) -> Ident {
+    format_ident!("{name}_node_ref")
+}
+
+fn node_ref(child: &ParsedChild) -> TokenStream2 {
+    let (name, ty) = match child {
+        ParsedChild::Input { name, .. } => (name, quote! {Input}),
+        ParsedChild::Select { name, .. } => (name, quote! {Select}),
+    };
+
+    let node_ref_name = node_ref_name(name);
+
+    quote! {
+        let #node_ref_name: NodeRef<#ty> = NodeRef::new();
+    }
+}
+
+fn signal_names(name: &Ident) -> (Ident, Ident) {
+    (format_ident!("{name}_get"), format_ident!("{name}_set"))
+}
+fn signal(child: &ParsedChild) -> TokenStream2 {
+    match child {
+        ParsedChild::Input { name, .. } => {
+            let (signal_name_get, signal_name_set) = signal_names(name);
+
+            quote! {
+                let (#signal_name_get, #signal_name_set) = signal(None);
+            }
+        }
+        ParsedChild::Select { .. } => quote! {},
+    }
+}
+fn type_verification(child: &ParsedChild) -> TokenStream2 {
+    match child {
+        ParsedChild::Input { .. } => quote! {},
+        ParsedChild::Select {
+            rust_type, options, ..
+        } => {
+            let names: Vec<_> = options
+                .0
+                .iter()
+                .enumerate()
+                .map(|(index, _)| format_ident!("name_{index}"))
+                .collect();
+            let values: Vec<_> = options.0.iter().map(|option| option.1.clone()).collect();
+
+            quote! {
+                {
+                    #(
+                        let #names: #rust_type = #values;
+                    )*
+                }
+            }
+        }
+    }
+}
+
+fn output_struct_definition(children: &[ParsedChild]) -> TokenStream2 {
+    let names = children.iter().map(|child| match child {
+        ParsedChild::Input { name, .. } => name,
+        ParsedChild::Select { name, .. } => name,
+    });
+    let rust_types = children.iter().map(|child| match child {
+        ParsedChild::Input { rust_type, .. } => rust_type,
+        ParsedChild::Select { rust_type, .. } => rust_type,
+    });
+
+    quote! {
+        struct Inputs {
+            #(
+                #names: #rust_types
+            ),*
+        }
+    }
+}
+fn output_struct_construction(children: &[ParsedChild]) -> TokenStream2 {
+    let names = children.iter().map(|child| match child {
+        ParsedChild::Input { name, .. } => name,
+        ParsedChild::Select { name, .. } => name,
+    });
+
+    quote! {
+        Inputs {
+            #(
+                #names
+            ),*
+        }
+    }
+}
+
+fn fetch_value(child: &ParsedChild) -> TokenStream2 {
+    match child {
+        ParsedChild::Input {
+            name, rust_type, ..
+        } => {
+            let node_ref_name = node_ref_name(name);
+            let (_, signal_name_set) = signal_names(name);
+
+            quote! {
+                let #name: #rust_type = {
+                    let value = {
+                        let output = #node_ref_name
+                            .get()
+                            // event handlers can only fire after the view
+                            // is mounted to the DOM, so the `NodeRef` will be `Some`
+                            .expect("<input> to exist")
+                            .value();
+
+                        let fin: Result<#rust_type, leptos::error::Error> = output
+                                    .parse()
+                                    .map_err(Into::<leptos::error::Error>::into);
+                        fin
+                    };
+
+                    match value {
+                        Ok(ok) => {
+                            // Reset the signal
+                            #signal_name_set.set(None);
+
+                            ok
+                        } ,
+                        Err(err) => {
+                            #signal_name_set.set(Some(err));
+
+                            // Skip running the real `on_submit`
+                            return
+                        }
+                    }
+                };
+            }
+        }
+        ParsedChild::Select {
+            name, rust_type, ..
+        } => {
+            let node_ref_name = node_ref_name(name);
+
+            quote! {
+                let #name: #rust_type = {
+                    let value = {
+                        let output = #node_ref_name
+                            .get()
+                            // event handlers can only fire after the view
+                            // is mounted to the DOM, so the `NodeRef` will be `Some`
+                            .expect("<input> to exist")
+                            .value();
+
+                        let fin: Result<#rust_type, leptos::error::Error> = output
+                                    .parse()
+                                    .map_err(Into::<leptos::error::Error>::into);
+                        fin
+                    };
+
+                    match value {
+                        Ok(ok) => {
+                            ok
+                        } ,
+                        Err(err) => {
+                            unreachable!("Should be ruled out at compile time: {err}")
+                        }
+                    }
+                };
+            }
+        }
+    }
+}
+
+fn input_html(child: &ParsedChild) -> TokenStream2 {
+    match child {
+        ParsedChild::Input {
+            name,
+            label,
+            rust_type,
+            html_type,
+            ..
+        } => {
+            let node_ref_name = node_ref_name(name);
+            let (signal_name_get, _) = signal_names(name);
+
+            quote! {
+                <InputPlaceholder input_type=#html_type label=#label node_ref=#node_ref_name />
+                <Show
+                   when=move || #signal_name_get.get().is_some()
+                   fallback=|| ()
+                >
+                    <p class="ps-2 text-red-300">{move ||
+                        format!(
+                            "Input is invalid for type {}: {}",
+                            stringify!(#rust_type),
+                            #signal_name_get.get().expect("Was `is_some`")
+                        )
+                    }</p>
+                </Show>
+            }
+        }
+        ParsedChild::Select {
+            name,
+            label,
+            options,
+            ..
+        } => {
+            let node_ref_name = node_ref_name(name);
+            let options = options.0.iter().map(|(lit, expr)| {
+                quote! {
+                    (#lit, #expr.to_string())
+                }
+            });
+
+            quote! {
+                <SelectPlaceholder label=#label node_ref=#node_ref_name options=vec![#(#options),*] />
+            }
+        }
+    }
+}
diff --git a/rocie-macros/src/form/mod.rs b/rocie-macros/src/form/mod.rs
new file mode 100644
index 0000000..2719826
--- /dev/null
+++ b/rocie-macros/src/form/mod.rs
@@ -0,0 +1,37 @@
+use syn::{Expr, Ident, LitStr, Type};
+
+mod parse;
+mod generate;
+
+pub use generate::form_impl;
+
+#[derive(Debug)]
+pub struct ParsedOnSubmit {
+    inputs: Vec<Ident>,
+    block: Expr,
+}
+
+#[derive(Debug)]
+pub enum ParsedChild {
+    Input {
+        name: Ident,
+        rust_type: Type,
+        html_type: LitStr,
+        label: LitStr,
+    },
+    Select {
+        name: Ident,
+        label: LitStr,
+        rust_type: Type,
+        options: SelectOptions,
+    },
+}
+
+#[derive(Debug)]
+pub struct SelectOptions(Vec<(LitStr, Expr)>);
+
+#[derive(Debug)]
+pub struct ParsedInput {
+    on_submit: ParsedOnSubmit,
+    children: Vec<ParsedChild>,
+}
diff --git a/rocie-macros/src/form/parse.rs b/rocie-macros/src/form/parse.rs
new file mode 100644
index 0000000..ef2087b
--- /dev/null
+++ b/rocie-macros/src/form/parse.rs
@@ -0,0 +1,132 @@
+use quote::format_ident;
+use syn::{bracketed, parenthesized, parse::{Parse, ParseStream}, Expr, Ident, LitStr, Token, Type};
+
+use crate::form::{ParsedChild, ParsedInput, ParsedOnSubmit, SelectOptions};
+
+macro_rules! parse_key_value {
+    ($input:expr, $name:ident as $ty:ty) => {{
+        let key = $input.parse::<Ident>()?;
+        $input.parse::<Token![=]>()?;
+        let value = $input.parse::<$ty>()?;
+
+        if key != format_ident!(stringify!($name)) {
+            panic!("Expected key name to be {}, but found: {key}", stringify!($name));
+        }
+
+        if $input.peek(Token![,]) {
+            $input.parse::<Token![,]>()?;
+        }
+
+        value
+    }};
+}
+
+impl Parse for SelectOptions {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        let content;
+        bracketed!(content in input);
+        let inner = content.parse_terminated(
+            |arg| {
+                let paren;
+                parenthesized!(paren in arg);
+                let lit = paren.parse::<LitStr>()?;
+                paren.parse::<Token![,]>()?;
+                let expr = paren.parse::<Expr>()?;
+
+                Ok((lit, expr))
+            },
+            Token![,],
+        )?;
+
+        Ok(Self(inner.into_iter().collect()))
+    }
+}
+
+impl Parse for ParsedInput {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        let on_submit = input.parse::<syn::Ident>()?;
+
+        if on_submit != format_ident!("on_submit") {
+            panic!("Did not find correct `on_submit`: {on_submit}")
+        }
+
+        input.parse::<Token![=]>()?;
+
+        let on_submit = input.parse::<ParsedOnSubmit>()?;
+
+        let mut children = Vec::new();
+        while !input.is_empty() {
+            children.push(input.parse::<ParsedChild>()?);
+        }
+
+        Ok(Self {
+            on_submit,
+            children,
+        })
+    }
+}
+
+impl Parse for ParsedChild {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        input.parse::<Token![<]>()?;
+
+        let variant = input.parse::<Ident>()?;
+
+        let output = match variant {
+            variant if variant == format_ident!("Input") => {
+                let name = parse_key_value!(input, name as Ident);
+                let rust_type = parse_key_value!(input, rust_type as Type);
+                let html_type = parse_key_value!(input, html_type as LitStr);
+                let label = parse_key_value!(input, label as LitStr);
+
+                ParsedChild::Input {
+                    name,
+                    rust_type,
+                    html_type,
+                    label,
+                }
+            }
+            variant if variant == format_ident!("Select") => {
+                let name = parse_key_value!(input, name as Ident);
+                let rust_type = parse_key_value!(input, rust_type as Type);
+                let label = parse_key_value!(input, label as LitStr);
+                let options = parse_key_value!(input, options as SelectOptions);
+
+                ParsedChild::Select {
+                    name,
+                    label,
+                    options,
+                    rust_type,
+                }
+            }
+            _ => panic!("Unkown form child variant: {variant}"),
+        };
+
+        input.parse::<Token![/]>()?;
+        input.parse::<Token![>]>()?;
+
+        Ok(output)
+    }
+}
+
+impl Parse for ParsedOnSubmit {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        let mut inputs = Vec::new();
+
+        input.parse::<Token![|]>()?;
+        while !input.peek(Token![|]) {
+            inputs.push(input.parse::<Ident>()?);
+
+            if input.peek(Token![,]) {
+                input.parse::<Token![,]>()?;
+            }
+        }
+        input.parse::<Token![|]>()?;
+
+        let block = input.parse::<Expr>()?;
+
+        input.parse::<Token![;]>()?;
+
+        Ok(Self { inputs, block })
+    }
+}
diff --git a/rocie-macros/src/lib.rs b/rocie-macros/src/lib.rs
new file mode 100644
index 0000000..4fb4340
--- /dev/null
+++ b/rocie-macros/src/lib.rs
@@ -0,0 +1,11 @@
+use proc_macro::TokenStream;
+
+use crate::form::form_impl;
+
+mod form;
+
+#[proc_macro]
+#[allow(non_snake_case)]
+pub fn Form(input: TokenStream) -> TokenStream {
+    form_impl(input)
+}
diff --git a/src/components/buy.rs b/src/components/buy.rs
index 0c294ee..6d9402e 100644
--- a/src/components/buy.rs
+++ b/src/components/buy.rs
@@ -1,7 +1,9 @@
 use leptos::{IntoView, component, view};
 use log::info;
+use rocie_client::models::UnitId;
+use uuid::Uuid;
 
-use crate::components::{form::Form, input_placeholder::InputPlaceholder, site_header::SiteHeader};
+use crate::components::{form::Form, site_header::SiteHeader};
 
 #[component]
 pub fn Buy() -> impl IntoView {
@@ -9,22 +11,27 @@ pub fn Buy() -> impl IntoView {
         <SiteHeader logo=icondata_io::IoPricetag back_location="/" name="Buy" />
 
         {Form! {
-            on_submit = |Inputs {product_barcode, amount}| {
-                info!("Got product barcode: {product_barcode} with amount: {amount}");
-            }
+            on_submit = |product_barcode, amount, unit_id| {
+                info!("Got product barcode: {product_barcode} with amount: {amount}, {unit_id}");
+            };
 
             <Input
                 name=product_barcode,
-                signal_name_get=product_barcode_get,
-                signal_name_set=product_barcode_set,
                 rust_type=u32,
                 html_type="number",
                 label="Product Barcode"
             />
+            <Select
+                name=unit_id,
+                rust_type=Uuid,
+                label="Unit",
+                options=[
+                    ("Kilogram", Uuid::new_v4()),
+                    ("Gram",  Uuid::new_v4())
+                ]
+            />
             <Input
                 name=amount,
-                signal_name_get=amount_get,
-                signal_name_set=amount_set,
                 rust_type=u16,
                 html_type="number",
                 label="Amount"
diff --git a/src/components/form.rs b/src/components/form.rs
index fd55897..9c371ad 100644
--- a/src/components/form.rs
+++ b/src/components/form.rs
@@ -1,119 +1 @@
-macro_rules! Form {
-    (
-        on_submit = |$bound:pat_param| $on_submit:block
-        $(
-            <Input
-                name=$name:ident,
-                signal_name_get=$signal_name_get:ident,
-                signal_name_set=$signal_name_set:ident,
-                rust_type=$rust_type:ty,
-                html_type=$input_type:literal,
-                label=$label:literal $(,)*
-            />
-        )*
-    ) => {{
-        use leptos::{
-            view,
-            prelude::{
-                Get,
-                NodeRef,
-                ElementChild,
-                ClassAttribute,
-                OnAttribute,
-                signal,
-                Set,
-                Show,
-            },
-            html::Input,
-            web_sys::SubmitEvent
-        };
-
-        use log::info;
-
-
-        $(
-            let ($signal_name_get, $signal_name_set) = signal(None);
-            let $name: NodeRef<Input> = NodeRef::new();
-        )*
-
-        let on_submit = move |ev: SubmitEvent| {
-            struct Inputs {
-                $(
-                    $name: $rust_type
-                ),*
-            }
-
-            // stop the page from reloading!
-            ev.prevent_default();
-
-            $(
-                let value = {
-                    let output = $name
-                        .get()
-                        // event handlers can only fire after the view
-                        // is mounted to the DOM, so the `NodeRef` will be `Some`
-                        .expect("<input> to exist")
-                        .value();
-
-                    let fin: Result<$rust_type, leptos::error::Error> = output
-                                .parse()
-                                .map_err(Into::<leptos::error::Error>::into);
-                    fin
-                };
-
-                let $name = match value {
-                    Ok(ok) => {
-                        // Reset the signal
-                        $signal_name_set.set(None);
-
-                        ok
-                    } ,
-                    Err(err) => {
-                        $signal_name_set.set(Some(err));
-
-                        // Skip running the real `on_submit`
-                        return
-                    }
-                };
-            )*
-
-            let real_on_submit = |$bound| $on_submit;
-            real_on_submit(Inputs {
-                $(
-                    $name
-                ),*
-            })
-        };
-
-
-        view! {
-            <form class="flex flex-col contents-start m-2 g-2" on:submit=on_submit>
-                $(
-                     <InputPlaceholder input_type=$input_type label=$label node_ref=$name />
-                     <Show
-                        when=move || $signal_name_get.get().is_some()
-                        fallback=|| ()
-                     >
-                         <p class="ps-2 text-red-300">{move ||
-                             format!(
-                                 "Input is invalid for type {}: {}",
-                                 stringify!($rust_type),
-                                 $signal_name_get.get().expect("Was `is_some`")
-                             )
-                         }</p>
-                     </Show>
-                )*
-
-                <div class="static">
-                    <input
-                        type="submit"
-                        value="Submit"
-                        class="absolute bottom-0 right-0 h-20 w-20 rounded-lg bg-green-300 m-2"
-                    />
-                </div>
-            </form>
-        }
-    }};
-}
-
-pub(crate) use Form;
+pub(crate) use rocie_macros::Form;
diff --git a/src/components/input_placeholder.rs b/src/components/input_placeholder.rs
index 05b9509..aeef838 100644
--- a/src/components/input_placeholder.rs
+++ b/src/components/input_placeholder.rs
@@ -1,5 +1,3 @@
-use std::sync::atomic::{AtomicU32, Ordering};
-
 use leptos::{
     IntoView, component,
     html::Input,
@@ -7,11 +5,8 @@ use leptos::{
     view,
 };
 
-fn get_id() -> u32 {
-    static ID: AtomicU32 = AtomicU32::new(0);
+use crate::components::get_id;
 
-    ID.fetch_add(1, Ordering::Relaxed)
-}
 
 #[component]
 pub fn InputPlaceholder(
diff --git a/src/components/mod.rs b/src/components/mod.rs
index f7b8dba..1ee37d5 100644
--- a/src/components/mod.rs
+++ b/src/components/mod.rs
@@ -1,9 +1,12 @@
+use std::sync::atomic::{AtomicU32, Ordering};
+
 // Generic
 pub mod async_fetch;
 pub mod container;
+pub mod form;
 pub mod icon_p;
 pub mod input_placeholder;
-pub mod form;
+pub mod select_placeholder;
 
 // Specific
 pub mod buy;
@@ -11,3 +14,9 @@ pub mod inventory;
 pub mod product_overview;
 pub mod recipies;
 pub mod site_header;
+
+fn get_id() -> u32 {
+    static ID: AtomicU32 = AtomicU32::new(0);
+
+    ID.fetch_add(1, Ordering::Relaxed)
+}
diff --git a/src/components/select_placeholder.rs b/src/components/select_placeholder.rs
new file mode 100644
index 0000000..947931c
--- /dev/null
+++ b/src/components/select_placeholder.rs
@@ -0,0 +1,93 @@
+use leptos::{
+    IntoView,
+    attr::{AttributeValue, IntoAttributeValue},
+    component,
+    html::Select,
+    prelude::{
+        ClassAttribute, CollectView, ElementChild, GlobalAttributes, NodeRef, NodeRefAttribute,
+    },
+    view,
+};
+
+use crate::components::get_id;
+
+#[component]
+pub fn SelectPlaceholder<T>(
+    label: &'static str,
+    node_ref: NodeRef<Select>,
+    options: Vec<(&'static str, T)>,
+) -> impl IntoView
+where
+    T: IntoAttributeValue,
+    <T as IntoAttributeValue>::Output: Send + AttributeValue,
+{
+    let id = get_id();
+
+    let options = options
+        .into_iter()
+        .map(|(label, value)| {
+            view! {
+                <option value=value>{label}</option>
+            }
+        })
+        .collect_view();
+
+    view! {
+        <div class="relative h-14">
+            <select
+                id=id.to_string()
+                class="\
+                absolute \
+                bottom-0 \
+                bg-gray-200 \
+                border-8 \
+                border-b-2 \
+                border-b-trasparent \
+                border-gray-200 \
+                focus:outline-none \
+                h-10 \
+                peer \
+                placeholder-transparent \
+                rounded-t-lg \
+                text-gray-900 \
+                w-full \
+                "
+                node_ref=node_ref
+            >
+                {options}
+            </select>
+
+            // TODO: Reference `var(--tw-border-2)` instead of the `2 px` <2025-10-01>
+            <div class="
+            absolute \
+            bottom-0 \
+            h-[2px] \
+            w-full \
+            bg-gray-300 \
+            peer-focus:bg-indigo-600 \
+            " />
+
+            <label
+                for=id.to_string()
+                class="\
+                bottom-10 \
+                absolute \
+                left-0 \
+                text-gray-700 \
+                text-sm \
+                transition-all \
+                peer-focus:bottom-10 \
+                peer-focus:left-0 \
+                peer-focus:text-gray-700 \
+                peer-focus:text-sm \
+                peer-placeholder-shown:text-base \
+                peer-placeholder-shown:text-gray-400 \
+                peer-placeholder-shown:bottom-2 \
+                peer-placeholder-shown:left-2 \
+                "
+            >
+                {label}
+            </label>
+        </div>
+    }
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index d4be7aa..d752bbe 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,6 +1,6 @@
 /** @type {import('tailwindcss').Config} */
 module.exports = {
-  content: ["*.html", "./src/**/*.rs"],
+  content: ["*.html", "./**/*.rs"],
   theme: {
     screens: {
       sm: "480px",