From f85adfa007c2fb84b68d4189c4f1d84c6d7f2a3a Mon Sep 17 00:00:00 2001 From: spinline Date: Thu, 12 Feb 2026 01:18:26 +0300 Subject: [PATCH] feat: complete advanced DataTable with search, column toggle, and bulk actions --- frontend/src/components/ui/alert_dialog.rs | 94 ++++ frontend/src/components/ui/dialog.rs | 251 +++++++++ frontend/src/components/ui/dropdown_menu.rs | 538 ++++++++++++++++++++ frontend/src/components/ui/empty.rs | 35 ++ frontend/src/components/ui/mod.rs | 25 +- frontend/src/components/ui/multi_select.rs | 317 ++++++++++++ frontend/src/components/ui/select.rs | 51 +- frontend/src/components/ui/separator.rs | 35 ++ 8 files changed, 1298 insertions(+), 48 deletions(-) create mode 100644 frontend/src/components/ui/alert_dialog.rs create mode 100644 frontend/src/components/ui/dialog.rs create mode 100644 frontend/src/components/ui/dropdown_menu.rs create mode 100644 frontend/src/components/ui/empty.rs create mode 100644 frontend/src/components/ui/multi_select.rs create mode 100644 frontend/src/components/ui/separator.rs diff --git a/frontend/src/components/ui/alert_dialog.rs b/frontend/src/components/ui/alert_dialog.rs new file mode 100644 index 0000000..fad18e0 --- /dev/null +++ b/frontend/src/components/ui/alert_dialog.rs @@ -0,0 +1,94 @@ +use leptos::prelude::*; + +use crate::components::ui::button::{ButtonSize, ButtonVariant}; +use crate::components::ui::dialog::{ + Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, + DialogTrigger, +}; + +#[component] +pub fn AlertDialog(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + view! { {children()} } +} + +#[component] +pub fn AlertDialogTrigger( + children: Children, + #[prop(optional, into)] class: String, + #[prop(default = ButtonVariant::Outline)] variant: ButtonVariant, + #[prop(default = ButtonSize::Default)] size: ButtonSize, +) -> impl IntoView { + view! { + + {children()} + + } +} + +#[component] +pub fn AlertDialogContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + view! { + + {children()} + + } +} + +#[component] +pub fn AlertDialogBody(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + view! { + + {children()} + + } +} + +#[component] +pub fn AlertDialogHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + view! { + + {children()} + + } +} + +#[component] +pub fn AlertDialogTitle(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + view! { + + {children()} + + } +} + +#[component] +pub fn AlertDialogDescription(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + view! { + + {children()} + + } +} + +#[component] +pub fn AlertDialogFooter(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + view! { + + {children()} + + } +} + +#[component] +pub fn AlertDialogClose( + children: Children, + #[prop(optional, into)] class: String, + #[prop(default = ButtonVariant::Outline)] variant: ButtonVariant, + #[prop(default = ButtonSize::Default)] size: ButtonSize, +) -> impl IntoView { + view! { + + {children()} + + } +} \ No newline at end of file diff --git a/frontend/src/components/ui/dialog.rs b/frontend/src/components/ui/dialog.rs new file mode 100644 index 0000000..22720f8 --- /dev/null +++ b/frontend/src/components/ui/dialog.rs @@ -0,0 +1,251 @@ +use icons::X; +use leptos::context::Provider; +use leptos::prelude::*; +use leptos_ui::clx; +use tw_merge::*; + +use crate::components::hooks::use_random::use_random_id_for; +use crate::components::ui::button::{Button, ButtonSize, ButtonVariant}; + +mod components { + use super::*; + clx! {DialogBody, div, "flex flex-col gap-4"} + clx! {DialogHeader, div, "flex flex-col gap-2 text-center sm:text-left"} + clx! {DialogTitle, h3, "text-lg leading-none font-semibold"} + clx! {DialogDescription, p, "text-muted-foreground text-sm"} + clx! {DialogFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"} +} + +pub use components::*; + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +#[derive(Clone)] +struct DialogContext { + target_id: String, +} + +#[component] +pub fn Dialog(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + let dialog_target_id = use_random_id_for("dialog"); + + let ctx = DialogContext { target_id: dialog_target_id.clone() }; + + let merged_class = tw_merge!("w-fit", class); + + view! { + +
+ {children()} +
+
+ } +} + +#[component] +pub fn DialogTrigger( + children: Children, + #[prop(optional, into)] class: String, + #[prop(default = ButtonVariant::Outline)] variant: ButtonVariant, + #[prop(default = ButtonSize::Default)] size: ButtonSize, +) -> impl IntoView { + let ctx = expect_context::(); + let trigger_id = format!("trigger_{}", ctx.target_id); + + view! { + + } +} + +#[component] +pub fn DialogContent( + children: Children, + #[prop(optional, into)] class: String, + #[prop(into, optional)] hide_close_button: Option, + #[prop(default = true)] close_on_backdrop_click: bool, + #[prop(default = "Dialog")] data_name_prefix: &'static str, +) -> impl IntoView { + let ctx = expect_context::(); + let merged_class = tw_merge!( + // "flex flex-col gap-4", // TODO 🐛 Bug when I try to have this.. Using DialogBody instead. + "relative bg-background border rounded-2xl shadow-lg p-6 w-full max-w-[calc(100%-2rem)] max-h-[85vh] fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-100 transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100", + class + ); + + let backdrop_data_name = format!("{}Backdrop", data_name_prefix); + let content_data_name = format!("{}Content", data_name_prefix); + + let target_id_clone = ctx.target_id.clone(); + let backdrop_id = format!("{}_backdrop", ctx.target_id); + let target_id_for_script = ctx.target_id.clone(); + let backdrop_id_for_script = backdrop_id.clone(); + let backdrop_behavior = if close_on_backdrop_click { "auto" } else { "manual" }; + + view! { + + +
+ +
+ + + {children()} +
+ + + } +} + +#[component] +pub fn DialogClose( + children: Children, + #[prop(optional, into)] class: String, + #[prop(default = ButtonVariant::Outline)] variant: ButtonVariant, + #[prop(default = ButtonSize::Default)] size: ButtonSize, +) -> impl IntoView { + let ctx = expect_context::(); + + view! { + + } +} + +#[component] +pub fn DialogAction( + children: Children, + #[prop(optional, into)] class: String, + #[prop(default = ButtonVariant::Default)] variant: ButtonVariant, + #[prop(default = ButtonSize::Default)] size: ButtonSize, +) -> impl IntoView { + let ctx = expect_context::(); + + view! { + + } +} \ No newline at end of file diff --git a/frontend/src/components/ui/dropdown_menu.rs b/frontend/src/components/ui/dropdown_menu.rs new file mode 100644 index 0000000..524cea1 --- /dev/null +++ b/frontend/src/components/ui/dropdown_menu.rs @@ -0,0 +1,538 @@ +use icons::{Check, ChevronRight}; +use leptos::context::Provider; +use leptos::prelude::*; +use leptos_ui::clx; +use tw_merge::*; + +use crate::components::hooks::use_random::use_random_id_for; +pub use crate::components::ui::separator::Separator as DropdownMenuSeparator; + +mod components { + use super::*; + clx! {DropdownMenuLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"} + clx! {DropdownMenuGroup, ul, "group"} + clx! {DropdownMenuItem, li, "inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4"} + clx! {DropdownMenuSubContent, ul, "dropdown__menu_sub_content", "rounded-md border bg-card shadow-lg p-1 absolute z-[100] min-w-[160px] opacity-0 invisible translate-x-[-8px] transition-all duration-200 ease-out pointer-events-none"} + clx! {DropdownMenuLink, a, "w-full inline-flex gap-2 items-center"} +} + +pub use components::*; + +/* ========================================================== */ +/* RADIO GROUP */ +/* ========================================================== */ + +#[derive(Clone)] +struct DropdownMenuRadioContext { + value_signal: RwSignal, +} + +/// A group of radio items where only one can be selected at a time. +#[component] +pub fn DropdownMenuRadioGroup( + children: Children, + /// The signal holding the current selected value + value: RwSignal, +) -> impl IntoView +where + T: Clone + PartialEq + Send + Sync + 'static, +{ + let ctx = DropdownMenuRadioContext { value_signal: value }; + + view! { + +
    + {children()} +
+
+ } +} + +/// A radio item that shows a checkmark when selected. +#[component] +pub fn DropdownMenuRadioItem( + children: Children, + /// The value this item represents + value: T, + #[prop(optional, into)] class: String, +) -> impl IntoView +where + T: Clone + PartialEq + Send + Sync + 'static, +{ + let ctx = expect_context::>(); + + let value_for_check = value.clone(); + let value_for_click = value.clone(); + let is_selected = move || ctx.value_signal.get() == value_for_check; + + let merged_class = tw_merge!( + "group inline-flex gap-2 items-center w-full rounded-sm pl-2 pr-2 py-1.5 text-sm cursor-pointer no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4", + class + ); + + view! { +
  • + {children()} + +
  • + } +} + +/// An action item in a dropdown menu (no checkmark, just triggers an action). +#[component] +pub fn DropdownMenuAction( + children: Children, + #[prop(optional, into)] class: String, + #[prop(optional, into)] href: Option, +) -> impl IntoView { + let _ctx = expect_context::(); + + let class = tw_merge!( + "inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground", + class + ); + + if let Some(href) = href { + // Render as tag when href is provided + view! { + + {children()} + + + + } + .into_any() + } else { + // Render as + } + .into_any() + } +} + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum DropdownMenuAlign { + #[default] + Start, + StartOuter, + End, + EndOuter, + Center, +} + +#[derive(Clone)] +struct DropdownMenuContext { + target_id: String, + align: DropdownMenuAlign, +} + +#[component] +pub fn DropdownMenu( + children: Children, + #[prop(default = DropdownMenuAlign::default())] align: DropdownMenuAlign, +) -> impl IntoView { + let dropdown_target_id = use_random_id_for("dropdown"); + + let ctx = DropdownMenuContext { target_id: dropdown_target_id.clone(), align }; + + view! { + + + +
    {children()}
    +
    + } +} + +#[component] +pub fn DropdownMenuTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + let ctx = expect_context::(); + let button_class = tw_merge!( + "px-4 py-2 h-9 inline-flex justify-center items-center text-sm font-medium whitespace-nowrap rounded-md transition-colors w-fit focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground", + class + ); + + view! { + + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum DropdownMenuPosition { + #[default] + Auto, + Top, + Bottom, +} + +#[component] +pub fn DropdownMenuContent( + children: Children, + #[prop(optional, into)] class: String, + #[prop(default = DropdownMenuPosition::default())] position: DropdownMenuPosition, +) -> impl IntoView { + let ctx = expect_context::(); + + let base_classes = "z-50 p-1 rounded-md border bg-card shadow-md h-fit fixed transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100"; + let width_class = match ctx.align { + DropdownMenuAlign::Center => "min-w-full", + _ => "w-[180px]", + }; + + let class = tw_merge!(width_class, base_classes, class); + + let target_id_for_script = ctx.target_id.clone(); + let align_for_script = match ctx.align { + DropdownMenuAlign::Start => "start", + DropdownMenuAlign::StartOuter => "start-outer", + DropdownMenuAlign::End => "end", + DropdownMenuAlign::EndOuter => "end-outer", + DropdownMenuAlign::Center => "center", + }; + + let position_for_script = match position { + DropdownMenuPosition::Auto => "auto", + DropdownMenuPosition::Top => "top", + DropdownMenuPosition::Bottom => "bottom", + }; + + view! { + + +
    + {children()} +
    + + + } +} + +#[component] +pub fn DropdownMenuSub(children: Children) -> impl IntoView { + // TODO. Find a better way for dropdown__menu_sub_trigger. + clx! {DropdownMenuSubRoot, li, "dropdown__menu_sub_trigger", " relative inline-flex relative gap-2 items-center py-1.5 px-2 w-full text-sm no-underline rounded-sm transition-colors duration-200 cursor-pointer text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground"} + + view! { {children()} } +} + +#[component] +pub fn DropdownMenuSubTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + let class = tw_merge!("flex items-center justify-between w-full", class); + + view! { + + {children()} + + + } +} + +#[component] +pub fn DropdownMenuSubItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + let class = tw_merge!( + "inline-flex gap-2 items-center w-full rounded-sm px-3 py-2 text-sm transition-all duration-150 ease text-popover-foreground hover:bg-accent hover:text-accent-foreground cursor-pointer hover:translate-x-[2px]", + class + ); + + view! { +
  • + {children()} +
  • + } +} \ No newline at end of file diff --git a/frontend/src/components/ui/empty.rs b/frontend/src/components/ui/empty.rs new file mode 100644 index 0000000..805ba89 --- /dev/null +++ b/frontend/src/components/ui/empty.rs @@ -0,0 +1,35 @@ +use leptos::prelude::*; +use leptos_ui::{clx, variants}; + +mod components { + use super::*; + clx! {Empty, div, "flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8 text-center"} + clx! {EmptyHeader, div, "flex flex-col items-center gap-2"} + clx! {EmptyTitle, h3, "text-lg font-semibold leading-none"} + clx! {EmptyDescription, p, "text-muted-foreground text-sm"} + clx! {EmptyContent, div, "flex items-center justify-center gap-2"} +} + +pub use components::*; + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +variants! { + EmptyMedia { + base: "flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0", + variants: { + variant: { + Default: "bg-transparent", + Icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6", + }, + size: { + Default: "", + } + }, + component: { + element: div + } + } +} \ No newline at end of file diff --git a/frontend/src/components/ui/mod.rs b/frontend/src/components/ui/mod.rs index 56b5a2b..3f8a65d 100644 --- a/frontend/src/components/ui/mod.rs +++ b/frontend/src/components/ui/mod.rs @@ -1,17 +1,18 @@ +pub mod alert_dialog; pub mod button; pub mod card; -pub mod input; -pub mod toast; +pub mod checkbox; pub mod context_menu; -pub mod theme_toggle; +pub mod data_table; +pub mod dialog; +pub mod dropdown_menu; +pub mod empty; +pub mod input; +pub mod multi_select; +pub mod select; +pub mod separator; +pub mod sonner; pub mod svg_icon; pub mod table; -pub mod data_table; -pub mod checkbox; -pub mod empty; -pub mod multi_select; -pub mod dropdown_menu; -pub mod alert_dialog; -pub mod dialog; -pub mod select; -pub mod separator; \ No newline at end of file +pub mod theme_toggle; +pub mod toast; diff --git a/frontend/src/components/ui/multi_select.rs b/frontend/src/components/ui/multi_select.rs new file mode 100644 index 0000000..4425afe --- /dev/null +++ b/frontend/src/components/ui/multi_select.rs @@ -0,0 +1,317 @@ +use std::collections::HashSet; + +use icons::{Check, ChevronDown, ChevronUp}; +use leptos::context::Provider; +use leptos::prelude::*; +use tw_merge::*; + +use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical; +use crate::components::hooks::use_random::use_random_id_for; +// * Reuse @select.rs +pub use crate::components::ui::select::{ + SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem, SelectLabel as MultiSelectLabel, +}; + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum MultiSelectAlign { + Start, + #[default] + Center, + End, +} + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum MultiSelectPosition { + #[default] + Below, + Above, +} + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +#[component] +pub fn MultiSelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView { + let multi_select_ctx = expect_context::(); + + view! { + + {move || { + let values = multi_select_ctx.values_signal.get(); + if values.is_empty() { + placeholder.clone() + } else { + let count = values.len(); + if count == 1 { "1 selected".to_string() } else { format!("{} selected", count) } + } + }} + + } +} + +#[component] +pub fn MultiSelectOption( + children: Children, + #[prop(optional, into)] class: String, + #[prop(optional, into)] value: Option, +) -> impl IntoView { + let multi_select_ctx = expect_context::(); + + let value_clone = value.clone(); + let is_selected = Signal::derive(move || { + if let Some(ref val) = value_clone { + multi_select_ctx.values_signal.with(|values| values.contains(val)) + } else { + false + } + }); + + let class = tw_merge!( + "group inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50", + class + ); + + view! { + + } +} + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +#[derive(Clone)] +struct MultiSelectContext { + target_id: String, + values_signal: RwSignal>, + align: MultiSelectAlign, +} + +#[component] +pub fn MultiSelect( + children: Children, + #[prop(optional, into)] values: Option>>, + #[prop(default = MultiSelectAlign::default())] align: MultiSelectAlign, +) -> impl IntoView { + let multi_select_target_id = use_random_id_for("multi_select"); + let values_signal = values.unwrap_or_else(|| RwSignal::new(HashSet::::new())); + + let multi_select_ctx = MultiSelectContext { target_id: multi_select_target_id.clone(), values_signal, align }; + + view! { + +
    + {children()} +
    +
    + } +} + +#[component] +pub fn MultiSelectTrigger( + children: Children, + #[prop(optional, into)] class: String, + #[prop(optional, into)] id: String, +) -> impl IntoView { + let multi_select_ctx = expect_context::(); + + let peer_class = if !id.is_empty() { format!("peer/{}", id) } else { String::new() }; + + let button_class = tw_merge!( + "w-full p-2 h-9 inline-flex items-center justify-between text-sm font-medium whitespace-nowrap rounded-md transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not(:last-child)]:mr-2 [&_svg:not(:first-child)]:ml-2 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground", + &peer_class, + class + ); + + let button_id = if !id.is_empty() { id } else { format!("trigger_{}", multi_select_ctx.target_id) }; + + view! { + + } +} + +#[component] +pub fn MultiSelectContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + let multi_select_ctx = expect_context::(); + + let align_str = match multi_select_ctx.align { + MultiSelectAlign::Start => "start", + MultiSelectAlign::Center => "center", + MultiSelectAlign::End => "end", + }; + + let class = tw_merge!( + "w-[150px] overflow-auto z-50 p-1 rounded-md border bg-card shadow-md h-fit max-h-[300px] absolute top-[calc(100%+4px)] transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[align=start]:left-0 data-[align=center]:left-1/2 data-[align=center]:-translate-x-1/2 data-[align=end]:right-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", + class + ); + + let target_id_for_script = multi_select_ctx.target_id.clone(); + + // Scroll indicator signals + let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical(); + + view! { + + +
    +
    + +
    + {children()} +
    + +
    +
    + + + } +} \ No newline at end of file diff --git a/frontend/src/components/ui/select.rs b/frontend/src/components/ui/select.rs index 2260670..82dd1a8 100644 --- a/frontend/src/components/ui/select.rs +++ b/frontend/src/components/ui/select.rs @@ -49,10 +49,6 @@ pub fn SelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView } } -/* ========================================================== */ -/* ✨ FUNCTIONS ✨ */ -/* ========================================================== */ - #[component] pub fn SelectOption( children: Children, @@ -92,10 +88,6 @@ pub fn SelectOption( } } -/* ========================================================== */ -/* ✨ FUNCTIONS ✨ */ -/* ========================================================== */ - #[derive(Clone)] struct SelectContext { target_id: String, @@ -164,6 +156,7 @@ pub fn SelectContent( children: Children, #[prop(optional, into)] class: String, #[prop(default = SelectPosition::default())] position: SelectPosition, + #[prop(optional)] on_close: Option>, ) -> impl IntoView { let ctx = expect_context::(); @@ -178,11 +171,17 @@ pub fn SelectContent( let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical(); view! { - +
    spaceBelow) {{ select.setAttribute('data-position', 'Above'); }} else {{ select.setAttribute('data-position', 'Below'); }} - // Set min-width to match trigger select.style.minWidth = `${{triggerRect.width}}px`; }}; const openSelect = () => {{ isOpen = true; - - // Lock scrolling - window.ScrollLock.lock(); - - // Update position and open + if (window.ScrollLock) window.ScrollLock.lock(); updatePosition(); select.setAttribute('data-state', 'open'); select.style.pointerEvents = 'auto'; - - // Trigger scroll event to update indicators select.dispatchEvent(new Event('scroll')); - - // Close on click outside setTimeout(() => {{ document.addEventListener('click', handleClickOutside); }}, 0); @@ -279,9 +268,8 @@ pub fn SelectContent( select.setAttribute('data-state', 'closed'); select.style.pointerEvents = 'none'; document.removeEventListener('click', handleClickOutside); - - // Unlock scrolling after animation - window.ScrollLock.unlock(200); + select.dispatchEvent(new CustomEvent('selectclose', {{ bubbles: false }})); + if (window.ScrollLock) window.ScrollLock.unlock(200); }}; const handleClickOutside = (e) => {{ @@ -290,25 +278,16 @@ pub fn SelectContent( }} }}; - // Toggle select when trigger is clicked trigger.addEventListener('click', (e) => {{ e.stopPropagation(); - if (isOpen) {{ - closeSelect(); - }} else {{ - openSelect(); - }} + if (isOpen) closeSelect(); else openSelect(); }}); - // Close when option is selected const options = select.querySelectorAll('[data-select-option]'); options.forEach(option => {{ - option.addEventListener('click', () => {{ - closeSelect(); - }}); + option.addEventListener('click', () => closeSelect()); }}); - // Handle ESC key to close document.addEventListener('keydown', (e) => {{ if (e.key === 'Escape' && isOpen) {{ e.preventDefault(); @@ -328,5 +307,5 @@ pub fn SelectContent( target_id_for_script, )} - } -} \ No newline at end of file + }.into_any() +} diff --git a/frontend/src/components/ui/separator.rs b/frontend/src/components/ui/separator.rs new file mode 100644 index 0000000..0809cdc --- /dev/null +++ b/frontend/src/components/ui/separator.rs @@ -0,0 +1,35 @@ +use leptos::prelude::*; +use tw_merge::*; + +#[component] +pub fn Separator( + #[prop(into, optional)] orientation: Signal, + #[prop(into, optional)] class: String, + // children: Children, +) -> impl IntoView { + let merged_class = Memo::new(move |_| { + let orientation = orientation.get(); + let separator = SeparatorClass { orientation }; + separator.with_class(class.clone()) + }); + + view! {