feat: complete advanced DataTable with search, column toggle, and bulk actions
Some checks failed
Build MIPS Binary / build (push) Failing after 1m30s

This commit is contained in:
spinline
2026-02-12 01:18:26 +03:00
parent 88c3cd57c1
commit f85adfa007
8 changed files with 1298 additions and 48 deletions

View 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>
}
}

View 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>
}
}

View 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>
}
}

View 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
}
}
}

View File

@@ -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;

View 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>
}
}

View File

@@ -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()
} }

View 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,
}