feat: complete advanced DataTable with search, column toggle, and bulk actions
Some checks failed
Build MIPS Binary / build (push) Failing after 1m30s
Some checks failed
Build MIPS Binary / build (push) Failing after 1m30s
This commit is contained in:
94
frontend/src/components/ui/alert_dialog.rs
Normal file
94
frontend/src/components/ui/alert_dialog.rs
Normal file
@@ -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! { <Dialog class=class>{children()}</Dialog> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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! {
|
||||||
|
<DialogTrigger class=class variant=variant size=size>
|
||||||
|
{children()}
|
||||||
|
</DialogTrigger>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlertDialogContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<DialogContent class=class close_on_backdrop_click=false data_name_prefix="AlertDialog">
|
||||||
|
{children()}
|
||||||
|
</DialogContent>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlertDialogBody(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<DialogBody class=class attr:data-name="AlertDialogBody">
|
||||||
|
{children()}
|
||||||
|
</DialogBody>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlertDialogHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<DialogHeader class=class attr:data-name="AlertDialogHeader">
|
||||||
|
{children()}
|
||||||
|
</DialogHeader>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlertDialogTitle(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<DialogTitle class=class attr:data-name="AlertDialogTitle">
|
||||||
|
{children()}
|
||||||
|
</DialogTitle>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlertDialogDescription(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<DialogDescription class=class attr:data-name="AlertDialogDescription">
|
||||||
|
{children()}
|
||||||
|
</DialogDescription>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AlertDialogFooter(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<DialogFooter class=class attr:data-name="AlertDialogFooter">
|
||||||
|
{children()}
|
||||||
|
</DialogFooter>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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! {
|
||||||
|
<DialogClose class=class variant=variant size=size>
|
||||||
|
{children()}
|
||||||
|
</DialogClose>
|
||||||
|
}
|
||||||
|
}
|
||||||
251
frontend/src/components/ui/dialog.rs
Normal file
251
frontend/src/components/ui/dialog.rs
Normal file
@@ -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! {
|
||||||
|
<Provider value=ctx>
|
||||||
|
<div class=merged_class data-name="__Dialog">
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
</Provider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::<DialogContext>();
|
||||||
|
let trigger_id = format!("trigger_{}", ctx.target_id);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Button
|
||||||
|
class=class
|
||||||
|
attr:id=trigger_id
|
||||||
|
attr:tabindex="0"
|
||||||
|
attr:data-dialog-trigger=ctx.target_id
|
||||||
|
variant=variant
|
||||||
|
size=size
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn DialogContent(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(into, optional)] hide_close_button: Option<bool>,
|
||||||
|
#[prop(default = true)] close_on_backdrop_click: bool,
|
||||||
|
#[prop(default = "Dialog")] data_name_prefix: &'static str,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let ctx = expect_context::<DialogContext>();
|
||||||
|
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! {
|
||||||
|
<script src="/hooks/lock_scroll.js"></script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-name=backdrop_data_name
|
||||||
|
id=backdrop_id
|
||||||
|
class="fixed inset-0 transition-opacity duration-200 pointer-events-none z-60 bg-black/50 data-[state=closed]:opacity-0 data-[state=open]:opacity-100"
|
||||||
|
data-state="closed"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-name=content_data_name
|
||||||
|
class=merged_class
|
||||||
|
id=ctx.target_id
|
||||||
|
data-target="target__dialog"
|
||||||
|
data-state="closed"
|
||||||
|
data-backdrop=backdrop_behavior
|
||||||
|
style="pointer-events: none;"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=format!(
|
||||||
|
"absolute top-4 right-4 p-1 rounded-sm focus:ring-2 focus:ring-offset-2 focus:outline-none [&_svg:not([class*='size-'])]:size-4 focus:ring-ring{}",
|
||||||
|
if hide_close_button.unwrap_or(false) { " hidden" } else { "" },
|
||||||
|
)
|
||||||
|
data-dialog-close=target_id_clone.clone()
|
||||||
|
aria-label="Close dialog"
|
||||||
|
>
|
||||||
|
<span class="hidden">"Close Dialog"</span>
|
||||||
|
<X />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{format!(
|
||||||
|
r#"
|
||||||
|
(function() {{
|
||||||
|
const setupDialog = () => {{
|
||||||
|
const dialog = document.querySelector('#{}');
|
||||||
|
const backdrop = document.querySelector('#{}');
|
||||||
|
const trigger = document.querySelector('[data-dialog-trigger="{}"]');
|
||||||
|
|
||||||
|
if (!dialog || !backdrop || !trigger) {{
|
||||||
|
setTimeout(setupDialog, 50);
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (dialog.hasAttribute('data-initialized')) {{
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
dialog.setAttribute('data-initialized', 'true');
|
||||||
|
|
||||||
|
const openDialog = () => {{
|
||||||
|
// Lock scrolling
|
||||||
|
window.ScrollLock.lock();
|
||||||
|
|
||||||
|
dialog.setAttribute('data-state', 'open');
|
||||||
|
backdrop.setAttribute('data-state', 'open');
|
||||||
|
dialog.style.pointerEvents = 'auto';
|
||||||
|
backdrop.style.pointerEvents = 'auto';
|
||||||
|
}};
|
||||||
|
|
||||||
|
const closeDialog = () => {{
|
||||||
|
dialog.setAttribute('data-state', 'closed');
|
||||||
|
backdrop.setAttribute('data-state', 'closed');
|
||||||
|
dialog.style.pointerEvents = 'none';
|
||||||
|
backdrop.style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
// Unlock scrolling after animation
|
||||||
|
window.ScrollLock.unlock(200);
|
||||||
|
}};
|
||||||
|
|
||||||
|
// Open dialog when trigger is clicked
|
||||||
|
trigger.addEventListener('click', openDialog);
|
||||||
|
|
||||||
|
// Close buttons
|
||||||
|
const closeButtons = dialog.querySelectorAll('[data-dialog-close]');
|
||||||
|
closeButtons.forEach(btn => {{
|
||||||
|
btn.addEventListener('click', closeDialog);
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Close on backdrop click (if data-backdrop="auto")
|
||||||
|
backdrop.addEventListener('click', () => {{
|
||||||
|
if (dialog.getAttribute('data-backdrop') === 'auto') {{
|
||||||
|
closeDialog();
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Handle ESC key to close
|
||||||
|
document.addEventListener('keydown', (e) => {{
|
||||||
|
if (e.key === 'Escape' && dialog.getAttribute('data-state') === 'open') {{
|
||||||
|
e.preventDefault();
|
||||||
|
closeDialog();
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
}};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {{
|
||||||
|
document.addEventListener('DOMContentLoaded', setupDialog);
|
||||||
|
}} else {{
|
||||||
|
setupDialog();
|
||||||
|
}}
|
||||||
|
}})();
|
||||||
|
"#,
|
||||||
|
target_id_for_script,
|
||||||
|
backdrop_id_for_script,
|
||||||
|
target_id_for_script,
|
||||||
|
)}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::<DialogContext>();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Button
|
||||||
|
class=class
|
||||||
|
attr:data-dialog-close=ctx.target_id
|
||||||
|
attr:aria-label="Close dialog"
|
||||||
|
variant=variant
|
||||||
|
size=size
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::<DialogContext>();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Button
|
||||||
|
class=class
|
||||||
|
attr:data-dialog-close=ctx.target_id
|
||||||
|
attr:aria-label="Close dialog"
|
||||||
|
variant=variant
|
||||||
|
size=size
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
}
|
||||||
538
frontend/src/components/ui/dropdown_menu.rs
Normal file
538
frontend/src/components/ui/dropdown_menu.rs
Normal file
@@ -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<T: Clone + PartialEq + Send + Sync + 'static> {
|
||||||
|
value_signal: RwSignal<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group of radio items where only one can be selected at a time.
|
||||||
|
#[component]
|
||||||
|
pub fn DropdownMenuRadioGroup<T>(
|
||||||
|
children: Children,
|
||||||
|
/// The signal holding the current selected value
|
||||||
|
value: RwSignal<T>,
|
||||||
|
) -> impl IntoView
|
||||||
|
where
|
||||||
|
T: Clone + PartialEq + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let ctx = DropdownMenuRadioContext { value_signal: value };
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Provider value=ctx>
|
||||||
|
<ul data-name="DropdownMenuRadioGroup" role="group" class="group">
|
||||||
|
{children()}
|
||||||
|
</ul>
|
||||||
|
</Provider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A radio item that shows a checkmark when selected.
|
||||||
|
#[component]
|
||||||
|
pub fn DropdownMenuRadioItem<T>(
|
||||||
|
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::<DropdownMenuRadioContext<T>>();
|
||||||
|
|
||||||
|
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! {
|
||||||
|
<li
|
||||||
|
data-name="DropdownMenuRadioItem"
|
||||||
|
class=merged_class
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked=move || is_selected().to_string()
|
||||||
|
data-dropdown-close="true"
|
||||||
|
on:click=move |_| {
|
||||||
|
ctx.value_signal.set(value_for_click.clone());
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-checked:opacity-100" />
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let _ctx = expect_context::<DropdownMenuContext>();
|
||||||
|
|
||||||
|
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 <a> tag when href is provided
|
||||||
|
view! {
|
||||||
|
<a data-name="DropdownMenuAction" class=class href=href data-dropdown-close="true">
|
||||||
|
{children()}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{r#"
|
||||||
|
(function() {
|
||||||
|
const link = document.currentScript.previousElementSibling;
|
||||||
|
if (!link) return;
|
||||||
|
|
||||||
|
link.addEventListener('click', function() {
|
||||||
|
// Close dropdown on route change after navigation
|
||||||
|
let currentPath = window.location.pathname;
|
||||||
|
const checkRouteChange = () => {
|
||||||
|
if (window.location.pathname !== currentPath) {
|
||||||
|
currentPath = window.location.pathname;
|
||||||
|
|
||||||
|
// Find and close the dropdown
|
||||||
|
const dropdown = link.closest('[data-target="target__dropdown"]');
|
||||||
|
if (dropdown) {
|
||||||
|
dropdown.setAttribute('data-state', 'closed');
|
||||||
|
dropdown.style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
// Unlock scroll
|
||||||
|
if (window.ScrollLock) {
|
||||||
|
window.ScrollLock.unlock(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(routeCheckInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeCheckInterval = setInterval(checkRouteChange, 50);
|
||||||
|
|
||||||
|
// Clear interval after 2 seconds to prevent memory leaks
|
||||||
|
setTimeout(() => clearInterval(routeCheckInterval), 2000);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
"#}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
} else {
|
||||||
|
// Render as <button> tag when no href
|
||||||
|
view! {
|
||||||
|
<button type="button" data-name="DropdownMenuAction" class=class data-dropdown-close="true">
|
||||||
|
{children()}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
.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! {
|
||||||
|
<Provider value=ctx>
|
||||||
|
<style>
|
||||||
|
"
|
||||||
|
/* Submenu Styles */
|
||||||
|
.dropdown__menu_sub_content {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: calc(100% + 8px);
|
||||||
|
inset-block-start: -4px;
|
||||||
|
z-index: 100;
|
||||||
|
min-inline-size: 160px;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateX(-8px);
|
||||||
|
transition: all 0.2s ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown__menu_sub_trigger:hover .dropdown__menu_sub_content {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateX(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
"
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div data-name="DropdownMenu">{children()}</div>
|
||||||
|
</Provider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn DropdownMenuTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let ctx = expect_context::<DropdownMenuContext>();
|
||||||
|
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! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=button_class
|
||||||
|
data-name="DropdownMenuTrigger"
|
||||||
|
data-dropdown-trigger=ctx.target_id
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::<DropdownMenuContext>();
|
||||||
|
|
||||||
|
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! {
|
||||||
|
<script src="/hooks/lock_scroll.js"></script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-name="DropdownMenuContent"
|
||||||
|
class=class
|
||||||
|
id=ctx.target_id
|
||||||
|
data-target="target__dropdown"
|
||||||
|
data-state="closed"
|
||||||
|
data-align=align_for_script
|
||||||
|
data-position=position_for_script
|
||||||
|
style="pointer-events: none;"
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{format!(
|
||||||
|
r#"
|
||||||
|
(function() {{
|
||||||
|
const setupDropdown = () => {{
|
||||||
|
const dropdown = document.querySelector('#{}');
|
||||||
|
const trigger = document.querySelector('[data-dropdown-trigger="{}"]');
|
||||||
|
|
||||||
|
if (!dropdown || !trigger) {{
|
||||||
|
setTimeout(setupDropdown, 50);
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (dropdown.hasAttribute('data-initialized')) {{
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
dropdown.setAttribute('data-initialized', 'true');
|
||||||
|
|
||||||
|
let isOpen = false;
|
||||||
|
|
||||||
|
const updatePosition = () => {{
|
||||||
|
const triggerRect = trigger.getBoundingClientRect();
|
||||||
|
const dropdownRect = dropdown.getBoundingClientRect();
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const spaceBelow = viewportHeight - triggerRect.bottom;
|
||||||
|
const spaceAbove = triggerRect.top;
|
||||||
|
|
||||||
|
const align = dropdown.getAttribute('data-align') || 'start';
|
||||||
|
const position = dropdown.getAttribute('data-position') || 'auto';
|
||||||
|
|
||||||
|
// Determine if we should position above
|
||||||
|
let shouldPositionAbove = false;
|
||||||
|
if (position === 'top') {{
|
||||||
|
shouldPositionAbove = true;
|
||||||
|
}} else if (position === 'bottom') {{
|
||||||
|
shouldPositionAbove = false;
|
||||||
|
}} else {{
|
||||||
|
// Auto: position above if there's space above AND not enough space below
|
||||||
|
shouldPositionAbove = spaceAbove >= dropdownRect.height && spaceBelow < dropdownRect.height;
|
||||||
|
}}
|
||||||
|
|
||||||
|
switch (align) {{
|
||||||
|
case 'start':
|
||||||
|
if (shouldPositionAbove) {{
|
||||||
|
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'left bottom';
|
||||||
|
}} else {{
|
||||||
|
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'left top';
|
||||||
|
}}
|
||||||
|
dropdown.style.left = `${{triggerRect.left}}px`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'end':
|
||||||
|
if (shouldPositionAbove) {{
|
||||||
|
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'right bottom';
|
||||||
|
}} else {{
|
||||||
|
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'right top';
|
||||||
|
}}
|
||||||
|
dropdown.style.left = `${{triggerRect.right - dropdownRect.width}}px`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'start-outer':
|
||||||
|
if (shouldPositionAbove) {{
|
||||||
|
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'right bottom';
|
||||||
|
}} else {{
|
||||||
|
dropdown.style.top = `${{triggerRect.top}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'right top';
|
||||||
|
}}
|
||||||
|
dropdown.style.left = `${{triggerRect.left - dropdownRect.width - 16}}px`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'end-outer':
|
||||||
|
if (shouldPositionAbove) {{
|
||||||
|
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'left bottom';
|
||||||
|
}} else {{
|
||||||
|
dropdown.style.top = `${{triggerRect.top}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'left top';
|
||||||
|
}}
|
||||||
|
dropdown.style.left = `${{triggerRect.right + 8}}px`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'center':
|
||||||
|
if (shouldPositionAbove) {{
|
||||||
|
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'center bottom';
|
||||||
|
}} else {{
|
||||||
|
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
|
||||||
|
dropdown.style.transformOrigin = 'center top';
|
||||||
|
}}
|
||||||
|
dropdown.style.left = `${{triggerRect.left}}px`;
|
||||||
|
dropdown.style.minWidth = `${{triggerRect.width}}px`;
|
||||||
|
break;
|
||||||
|
}}
|
||||||
|
}};
|
||||||
|
|
||||||
|
const openDropdown = () => {{
|
||||||
|
isOpen = true;
|
||||||
|
|
||||||
|
// Set state to open first to remove scale transform for accurate measurements
|
||||||
|
dropdown.setAttribute('data-state', 'open');
|
||||||
|
|
||||||
|
// Make dropdown invisible but rendered to measure true height
|
||||||
|
dropdown.style.visibility = 'hidden';
|
||||||
|
dropdown.style.pointerEvents = 'auto';
|
||||||
|
|
||||||
|
// Force reflow to ensure height is calculated
|
||||||
|
dropdown.offsetHeight;
|
||||||
|
|
||||||
|
// Calculate position with accurate height
|
||||||
|
updatePosition();
|
||||||
|
|
||||||
|
// Now make it visible
|
||||||
|
dropdown.style.visibility = 'visible';
|
||||||
|
|
||||||
|
// Lock all scrollable elements
|
||||||
|
window.ScrollLock.lock();
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
setTimeout(() => {{
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
}}, 0);
|
||||||
|
}};
|
||||||
|
|
||||||
|
const closeDropdown = () => {{
|
||||||
|
isOpen = false;
|
||||||
|
dropdown.setAttribute('data-state', 'closed');
|
||||||
|
dropdown.style.pointerEvents = 'none';
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
|
||||||
|
// Unlock scroll after animation (200ms delay)
|
||||||
|
window.ScrollLock.unlock(200);
|
||||||
|
}};
|
||||||
|
|
||||||
|
const handleClickOutside = (e) => {{
|
||||||
|
if (!dropdown.contains(e.target) && !trigger.contains(e.target)) {{
|
||||||
|
closeDropdown();
|
||||||
|
}}
|
||||||
|
}};
|
||||||
|
|
||||||
|
// Toggle dropdown when trigger is clicked
|
||||||
|
trigger.addEventListener('click', (e) => {{
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Check if any other dropdown is open
|
||||||
|
const allDropdowns = document.querySelectorAll('[data-target=\"target__dropdown\"]');
|
||||||
|
let otherDropdownOpen = false;
|
||||||
|
allDropdowns.forEach(dd => {{
|
||||||
|
if (dd !== dropdown && dd.getAttribute('data-state') === 'open') {{
|
||||||
|
otherDropdownOpen = true;
|
||||||
|
dd.setAttribute('data-state', 'closed');
|
||||||
|
dd.style.pointerEvents = 'none';
|
||||||
|
// Unlock scroll
|
||||||
|
if (window.ScrollLock) {{
|
||||||
|
window.ScrollLock.unlock(200);
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
|
||||||
|
// If another dropdown was open, just close it and don't open this one
|
||||||
|
if (otherDropdownOpen) {{
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Normal toggle behavior
|
||||||
|
if (isOpen) {{
|
||||||
|
closeDropdown();
|
||||||
|
}} else {{
|
||||||
|
openDropdown();
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Close when action is clicked
|
||||||
|
const actions = dropdown.querySelectorAll('[data-dropdown-close]');
|
||||||
|
actions.forEach(action => {{
|
||||||
|
action.addEventListener('click', () => {{
|
||||||
|
closeDropdown();
|
||||||
|
}});
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Handle ESC key to close
|
||||||
|
document.addEventListener('keydown', (e) => {{
|
||||||
|
if (e.key === 'Escape' && isOpen) {{
|
||||||
|
e.preventDefault();
|
||||||
|
closeDropdown();
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
}};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {{
|
||||||
|
document.addEventListener('DOMContentLoaded', setupDropdown);
|
||||||
|
}} else {{
|
||||||
|
setupDropdown();
|
||||||
|
}}
|
||||||
|
}})();
|
||||||
|
"#,
|
||||||
|
target_id_for_script,
|
||||||
|
target_id_for_script,
|
||||||
|
)}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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! { <DropdownMenuSubRoot>{children()}</DropdownMenuSubRoot> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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! {
|
||||||
|
<span attr:data-name="DropdownMenuSubTrigger" class=class>
|
||||||
|
<span class="flex gap-2 items-center">{children()}</span>
|
||||||
|
<ChevronRight class="opacity-70 size-4" />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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! {
|
||||||
|
<li data-name="DropdownMenuSubItem" class=class data-dropdown-close="true">
|
||||||
|
{children()}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
35
frontend/src/components/ui/empty.rs
Normal file
35
frontend/src/components/ui/empty.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
|
pub mod alert_dialog;
|
||||||
pub mod button;
|
pub mod button;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod input;
|
pub mod checkbox;
|
||||||
pub mod toast;
|
|
||||||
pub mod context_menu;
|
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 svg_icon;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
pub mod data_table;
|
pub mod theme_toggle;
|
||||||
pub mod checkbox;
|
pub mod toast;
|
||||||
pub mod empty;
|
|
||||||
pub mod multi_select;
|
|
||||||
pub mod dropdown_menu;
|
|
||||||
pub mod alert_dialog;
|
|
||||||
pub mod dialog;
|
|
||||||
pub mod select;
|
|
||||||
pub mod separator;
|
|
||||||
|
|||||||
317
frontend/src/components/ui/multi_select.rs
Normal file
317
frontend/src/components/ui/multi_select.rs
Normal file
@@ -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::<MultiSelectContext>();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<span data-name="MultiSelectValue" class="text-sm text-muted-foreground truncate">
|
||||||
|
{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) }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn MultiSelectOption(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(optional, into)] value: Option<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||||
|
|
||||||
|
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! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-name="MultiSelectOption"
|
||||||
|
class=class
|
||||||
|
role="option"
|
||||||
|
aria-selected=move || is_selected.get().to_string()
|
||||||
|
on:click=move |ev: web_sys::MouseEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
ev.stop_propagation();
|
||||||
|
if let Some(val) = value.clone() {
|
||||||
|
multi_select_ctx
|
||||||
|
.values_signal
|
||||||
|
.update(|values| {
|
||||||
|
if values.contains(&val) {
|
||||||
|
values.remove(&val);
|
||||||
|
} else {
|
||||||
|
values.insert(val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-selected:opacity-100" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================== */
|
||||||
|
/* ✨ FUNCTIONS ✨ */
|
||||||
|
/* ========================================================== */
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct MultiSelectContext {
|
||||||
|
target_id: String,
|
||||||
|
values_signal: RwSignal<HashSet<String>>,
|
||||||
|
align: MultiSelectAlign,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn MultiSelect(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] values: Option<RwSignal<HashSet<String>>>,
|
||||||
|
#[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::<String>::new()));
|
||||||
|
|
||||||
|
let multi_select_ctx = MultiSelectContext { target_id: multi_select_target_id.clone(), values_signal, align };
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Provider value=multi_select_ctx>
|
||||||
|
<div data-name="MultiSelect" class="relative w-fit">
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
</Provider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn MultiSelectTrigger(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(optional, into)] id: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||||
|
|
||||||
|
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! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-name="MultiSelectTrigger"
|
||||||
|
class=button_class
|
||||||
|
id=button_id
|
||||||
|
tabindex="0"
|
||||||
|
data-multi-select-trigger=multi_select_ctx.target_id
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
<ChevronDown class="text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn MultiSelectContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||||
|
|
||||||
|
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! {
|
||||||
|
<script src="/hooks/lock_scroll.js"></script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-name="MultiSelectContent"
|
||||||
|
class=class
|
||||||
|
id=multi_select_ctx.target_id
|
||||||
|
data-target="target__multi_select"
|
||||||
|
data-state="closed"
|
||||||
|
data-align=align_str
|
||||||
|
style="pointer-events: none;"
|
||||||
|
on:scroll=on_scroll
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-scroll-up="true"
|
||||||
|
class=move || {
|
||||||
|
if can_scroll_up_signal.get() {
|
||||||
|
"sticky -top-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||||
|
} else {
|
||||||
|
"hidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronUp class="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
{children()}
|
||||||
|
<div
|
||||||
|
data-scroll-down="true"
|
||||||
|
class=move || {
|
||||||
|
if can_scroll_down_signal.get() {
|
||||||
|
"sticky -bottom-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||||
|
} else {
|
||||||
|
"hidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronDown class="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{format!(
|
||||||
|
r#"
|
||||||
|
(function() {{
|
||||||
|
const setupMultiSelect = () => {{
|
||||||
|
const multiSelect = document.querySelector('#{}');
|
||||||
|
const trigger = document.querySelector('[data-multi-select-trigger="{}"]');
|
||||||
|
|
||||||
|
if (!multiSelect || !trigger) {{
|
||||||
|
setTimeout(setupMultiSelect, 50);
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (multiSelect.hasAttribute('data-initialized')) {{
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
multiSelect.setAttribute('data-initialized', 'true');
|
||||||
|
|
||||||
|
let isOpen = false;
|
||||||
|
|
||||||
|
const openMultiSelect = () => {{
|
||||||
|
isOpen = true;
|
||||||
|
|
||||||
|
// Lock all scrollable elements
|
||||||
|
window.ScrollLock.lock();
|
||||||
|
|
||||||
|
multiSelect.setAttribute('data-state', 'open');
|
||||||
|
multiSelect.style.pointerEvents = 'auto';
|
||||||
|
|
||||||
|
// Set min-width to match trigger
|
||||||
|
const triggerRect = trigger.getBoundingClientRect();
|
||||||
|
multiSelect.style.minWidth = `${{triggerRect.width}}px`;
|
||||||
|
|
||||||
|
// Trigger scroll event to update indicators
|
||||||
|
multiSelect.dispatchEvent(new Event('scroll'));
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
setTimeout(() => {{
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
}}, 0);
|
||||||
|
}};
|
||||||
|
|
||||||
|
const closeMultiSelect = () => {{
|
||||||
|
isOpen = false;
|
||||||
|
multiSelect.setAttribute('data-state', 'closed');
|
||||||
|
multiSelect.style.pointerEvents = 'none';
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
|
||||||
|
// Unlock scroll after animation (200ms delay)
|
||||||
|
window.ScrollLock.unlock(200);
|
||||||
|
}};
|
||||||
|
|
||||||
|
const handleClickOutside = (e) => {{
|
||||||
|
if (!multiSelect.contains(e.target) && !trigger.contains(e.target)) {{
|
||||||
|
closeMultiSelect();
|
||||||
|
}}
|
||||||
|
}};
|
||||||
|
|
||||||
|
// Toggle multi-select when trigger is clicked
|
||||||
|
trigger.addEventListener('click', (e) => {{
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isOpen) {{
|
||||||
|
closeMultiSelect();
|
||||||
|
}} else {{
|
||||||
|
openMultiSelect();
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Handle ESC key to close
|
||||||
|
document.addEventListener('keydown', (e) => {{
|
||||||
|
if (e.key === 'Escape' && isOpen) {{
|
||||||
|
e.preventDefault();
|
||||||
|
closeMultiSelect();
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
}};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {{
|
||||||
|
document.addEventListener('DOMContentLoaded', setupMultiSelect);
|
||||||
|
}} else {{
|
||||||
|
setupMultiSelect();
|
||||||
|
}}
|
||||||
|
}})();
|
||||||
|
"#,
|
||||||
|
target_id_for_script,
|
||||||
|
target_id_for_script,
|
||||||
|
)}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,10 +49,6 @@ pub fn SelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SelectOption(
|
pub fn SelectOption(
|
||||||
children: Children,
|
children: Children,
|
||||||
@@ -92,10 +88,6 @@ pub fn SelectOption(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct SelectContext {
|
struct SelectContext {
|
||||||
target_id: String,
|
target_id: String,
|
||||||
@@ -164,6 +156,7 @@ pub fn SelectContent(
|
|||||||
children: Children,
|
children: Children,
|
||||||
#[prop(optional, into)] class: String,
|
#[prop(optional, into)] class: String,
|
||||||
#[prop(default = SelectPosition::default())] position: SelectPosition,
|
#[prop(default = SelectPosition::default())] position: SelectPosition,
|
||||||
|
#[prop(optional)] on_close: Option<Callback<()>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let ctx = expect_context::<SelectContext>();
|
let ctx = expect_context::<SelectContext>();
|
||||||
|
|
||||||
@@ -178,11 +171,17 @@ pub fn SelectContent(
|
|||||||
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<script src="/hooks/lock_scroll.js"></script>
|
<script src="/lock_scroll.js"></script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-name="SelectContent"
|
data-name="SelectContent"
|
||||||
class=merged_class
|
class=merged_class
|
||||||
|
// Listen for custom 'selectclose' event dispatched by JS
|
||||||
|
on:selectclose=move |_: web_sys::CustomEvent| {
|
||||||
|
if let Some(cb) = on_close {
|
||||||
|
cb.run(());
|
||||||
|
}
|
||||||
|
}
|
||||||
id=ctx.target_id
|
id=ctx.target_id
|
||||||
data-target="target__select"
|
data-target="target__select"
|
||||||
data-state="closed"
|
data-state="closed"
|
||||||
@@ -243,32 +242,22 @@ pub fn SelectContent(
|
|||||||
const spaceBelow = viewportHeight - triggerRect.bottom;
|
const spaceBelow = viewportHeight - triggerRect.bottom;
|
||||||
const spaceAbove = triggerRect.top;
|
const spaceAbove = triggerRect.top;
|
||||||
|
|
||||||
// Determine if dropdown should go above or below
|
|
||||||
if (spaceBelow < 200 && spaceAbove > spaceBelow) {{
|
if (spaceBelow < 200 && spaceAbove > spaceBelow) {{
|
||||||
select.setAttribute('data-position', 'Above');
|
select.setAttribute('data-position', 'Above');
|
||||||
}} else {{
|
}} else {{
|
||||||
select.setAttribute('data-position', 'Below');
|
select.setAttribute('data-position', 'Below');
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Set min-width to match trigger
|
|
||||||
select.style.minWidth = `${{triggerRect.width}}px`;
|
select.style.minWidth = `${{triggerRect.width}}px`;
|
||||||
}};
|
}};
|
||||||
|
|
||||||
const openSelect = () => {{
|
const openSelect = () => {{
|
||||||
isOpen = true;
|
isOpen = true;
|
||||||
|
if (window.ScrollLock) window.ScrollLock.lock();
|
||||||
// Lock scrolling
|
|
||||||
window.ScrollLock.lock();
|
|
||||||
|
|
||||||
// Update position and open
|
|
||||||
updatePosition();
|
updatePosition();
|
||||||
select.setAttribute('data-state', 'open');
|
select.setAttribute('data-state', 'open');
|
||||||
select.style.pointerEvents = 'auto';
|
select.style.pointerEvents = 'auto';
|
||||||
|
|
||||||
// Trigger scroll event to update indicators
|
|
||||||
select.dispatchEvent(new Event('scroll'));
|
select.dispatchEvent(new Event('scroll'));
|
||||||
|
|
||||||
// Close on click outside
|
|
||||||
setTimeout(() => {{
|
setTimeout(() => {{
|
||||||
document.addEventListener('click', handleClickOutside);
|
document.addEventListener('click', handleClickOutside);
|
||||||
}}, 0);
|
}}, 0);
|
||||||
@@ -279,9 +268,8 @@ pub fn SelectContent(
|
|||||||
select.setAttribute('data-state', 'closed');
|
select.setAttribute('data-state', 'closed');
|
||||||
select.style.pointerEvents = 'none';
|
select.style.pointerEvents = 'none';
|
||||||
document.removeEventListener('click', handleClickOutside);
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
select.dispatchEvent(new CustomEvent('selectclose', {{ bubbles: false }}));
|
||||||
// Unlock scrolling after animation
|
if (window.ScrollLock) window.ScrollLock.unlock(200);
|
||||||
window.ScrollLock.unlock(200);
|
|
||||||
}};
|
}};
|
||||||
|
|
||||||
const handleClickOutside = (e) => {{
|
const handleClickOutside = (e) => {{
|
||||||
@@ -290,25 +278,16 @@ pub fn SelectContent(
|
|||||||
}}
|
}}
|
||||||
}};
|
}};
|
||||||
|
|
||||||
// Toggle select when trigger is clicked
|
|
||||||
trigger.addEventListener('click', (e) => {{
|
trigger.addEventListener('click', (e) => {{
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (isOpen) {{
|
if (isOpen) closeSelect(); else openSelect();
|
||||||
closeSelect();
|
|
||||||
}} else {{
|
|
||||||
openSelect();
|
|
||||||
}}
|
|
||||||
}});
|
}});
|
||||||
|
|
||||||
// Close when option is selected
|
|
||||||
const options = select.querySelectorAll('[data-select-option]');
|
const options = select.querySelectorAll('[data-select-option]');
|
||||||
options.forEach(option => {{
|
options.forEach(option => {{
|
||||||
option.addEventListener('click', () => {{
|
option.addEventListener('click', () => closeSelect());
|
||||||
closeSelect();
|
|
||||||
}});
|
|
||||||
}});
|
}});
|
||||||
|
|
||||||
// Handle ESC key to close
|
|
||||||
document.addEventListener('keydown', (e) => {{
|
document.addEventListener('keydown', (e) => {{
|
||||||
if (e.key === 'Escape' && isOpen) {{
|
if (e.key === 'Escape' && isOpen) {{
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -328,5 +307,5 @@ pub fn SelectContent(
|
|||||||
target_id_for_script,
|
target_id_for_script,
|
||||||
)}
|
)}
|
||||||
</script>
|
</script>
|
||||||
}
|
}.into_any()
|
||||||
}
|
}
|
||||||
|
|||||||
35
frontend/src/components/ui/separator.rs
Normal file
35
frontend/src/components/ui/separator.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use tw_merge::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Separator(
|
||||||
|
#[prop(into, optional)] orientation: Signal<SeparatorOrientation>,
|
||||||
|
#[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! { <div class=merged_class role="separator" /> }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================== */
|
||||||
|
/* 🧬 STRUCT 🧬 */
|
||||||
|
/* ========================================================== */
|
||||||
|
|
||||||
|
#[derive(TwClass, Default)]
|
||||||
|
#[tw(class = "shrink-0 bg-border")]
|
||||||
|
pub struct SeparatorClass {
|
||||||
|
orientation: SeparatorOrientation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(TwVariant)]
|
||||||
|
pub enum SeparatorOrientation {
|
||||||
|
#[tw(default, class = "w-full h-[1px]")]
|
||||||
|
Default,
|
||||||
|
#[tw(class = "h-full w-[1px]")]
|
||||||
|
Vertical,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user