Files
vibetorrent/frontend/src/components/ui/dropdown_menu.rs
spinline 6a2952c6f3
All checks were successful
Build MIPS Binary / build (push) Successful in 5m32s
fix: resolve 404 error for lock_scroll.js by including it in Trunk build and loading globally
2026-02-12 01:51:38 +03:00

536 lines
22 KiB
Rust

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! {
<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>
}
}