chore: major cleanup of compiler warnings, unused imports and dead code across all UI components
Some checks failed
Build MIPS Binary / build (push) Failing after 43s
Some checks failed
Build MIPS Binary / build (push) Failing after 43s
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use crate::components::ui::context_menu::*;
|
use crate::components::ui::context_menu::{
|
||||||
|
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
|
||||||
|
};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn TorrentContextMenu(
|
pub fn TorrentContextMenu(
|
||||||
@@ -7,10 +9,9 @@ pub fn TorrentContextMenu(
|
|||||||
torrent_hash: String,
|
torrent_hash: String,
|
||||||
on_action: Callback<(String, String)>,
|
on_action: Callback<(String, String)>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let hash = StoredValue::new(torrent_hash);
|
let hash = torrent_hash.clone();
|
||||||
|
let on_click = move |action: &str| {
|
||||||
let menu_action = move |action: &'static str| {
|
on_action.run((action.to_string(), hash.clone()));
|
||||||
on_action.run((action.to_string(), hash.get_value()));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
@@ -18,61 +19,20 @@ pub fn TorrentContextMenu(
|
|||||||
<ContextMenuTrigger>
|
<ContextMenuTrigger>
|
||||||
{children()}
|
{children()}
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent class="w-48">
|
||||||
<ContextMenuContent class="w-56">
|
<ContextMenuItem on:click=move |_| on_click("start")>
|
||||||
<ContextMenuAction
|
"Başlat"
|
||||||
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
|
</ContextMenuItem>
|
||||||
on:click=move |_| menu_action("start")
|
<ContextMenuItem on:click=move |_| on_click("stop")>
|
||||||
>
|
"Durdur"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
</ContextMenuItem>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
<ContextMenuItem class="text-destructive" on:click=move |_| on_click("delete")>
|
||||||
</svg>
|
"Sil"
|
||||||
"Start"
|
</ContextMenuItem>
|
||||||
</ContextMenuAction>
|
<ContextMenuItem class="text-destructive font-bold" on:click=move |_| on_click("delete_with_data")>
|
||||||
|
"Verilerle Birlikte Sil"
|
||||||
<ContextMenuAction
|
</ContextMenuItem>
|
||||||
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
|
|
||||||
on:click=move |_| menu_action("stop")
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
|
||||||
</svg>
|
|
||||||
"Stop"
|
|
||||||
</ContextMenuAction>
|
|
||||||
|
|
||||||
<ContextMenuAction
|
|
||||||
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
|
|
||||||
on:click=move |_| menu_action("recheck")
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
|
||||||
</svg>
|
|
||||||
"Recheck"
|
|
||||||
</ContextMenuAction>
|
|
||||||
|
|
||||||
<div class="-mx-1 my-1 h-px bg-border" />
|
|
||||||
|
|
||||||
<ContextMenuAction
|
|
||||||
class="px-2 py-1.5 text-destructive hover:bg-destructive/10 hover:text-destructive rounded-sm"
|
|
||||||
on:click=move |_| menu_action("delete")
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.164h-2.34c-1.18 0-2.09.984-2.09 2.164v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
|
||||||
</svg>
|
|
||||||
"Remove"
|
|
||||||
</ContextMenuAction>
|
|
||||||
|
|
||||||
<ContextMenuHoldAction
|
|
||||||
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
||||||
on_hold_complete=move |_| menu_action("delete_with_data")
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25-2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
|
||||||
</svg>
|
|
||||||
"Remove with Data"
|
|
||||||
<span class="ml-auto text-[10px] opacity-50">"Hold"</span>
|
|
||||||
</ContextMenuHoldAction>
|
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,31 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use tw_merge::tw_merge;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Accordion(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
pub fn Accordion(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
let class = tw_merge!("w-full", class);
|
let _ = (children, class);
|
||||||
view! { <div class=class>{children()}</div> }
|
view! { <div>"Accordion Not Implemented"</div> }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AccordionItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
pub fn AccordionItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
let class = tw_merge!("border-b", class);
|
let _ = (children, class);
|
||||||
view! { <div class=class>{children()}</div> }
|
view! { <div>"AccordionItem Not Implemented"</div> }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AccordionHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
pub fn AccordionHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
let class = tw_merge!("flex", class);
|
let _ = (children, class);
|
||||||
view! { <div class=class>{children()}</div> }
|
view! { <div>"AccordionHeader Not Implemented"</div> }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AccordionTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
pub fn AccordionTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
let class = tw_merge!(
|
let _ = (children, class);
|
||||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
view! { <div>"AccordionTrigger Not Implemented"</div> }
|
||||||
class
|
|
||||||
);
|
|
||||||
view! {
|
|
||||||
<button type="button" class=class>
|
|
||||||
{children()}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AccordionContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
pub fn AccordionContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
let class = tw_merge!("overflow-hidden text-sm transition-all", class);
|
let _ = (children, class);
|
||||||
view! { <div class=class>{children()}</div> }
|
view! { <div>"AccordionContent Not Implemented"</div> }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ mod components {
|
|||||||
|
|
||||||
pub use components::*;
|
pub use components::*;
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct DialogContext {
|
struct DialogContext {
|
||||||
target_id: String,
|
target_id: String,
|
||||||
@@ -30,11 +26,8 @@ struct DialogContext {
|
|||||||
#[component]
|
#[component]
|
||||||
pub fn Dialog(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
pub fn Dialog(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
let dialog_target_id = use_random_id_for("dialog");
|
let dialog_target_id = use_random_id_for("dialog");
|
||||||
|
|
||||||
let ctx = DialogContext { target_id: dialog_target_id.clone() };
|
let ctx = DialogContext { target_id: dialog_target_id.clone() };
|
||||||
|
|
||||||
let merged_class = tw_merge!("w-fit", class);
|
let merged_class = tw_merge!("w-fit", class);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Provider value=ctx>
|
<Provider value=ctx>
|
||||||
<div class=merged_class data-name="__Dialog">
|
<div class=merged_class data-name="__Dialog">
|
||||||
@@ -53,16 +46,8 @@ pub fn DialogTrigger(
|
|||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let ctx = expect_context::<DialogContext>();
|
let ctx = expect_context::<DialogContext>();
|
||||||
let trigger_id = format!("trigger_{}", ctx.target_id);
|
let trigger_id = format!("trigger_{}", ctx.target_id);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Button
|
<Button class=class attr:id=trigger_id attr:tabindex="0" attr:data-dialog-trigger=ctx.target_id variant=variant size=size>
|
||||||
class=class
|
|
||||||
attr:id=trigger_id
|
|
||||||
attr:tabindex="0"
|
|
||||||
attr:data-dialog-trigger=ctx.target_id
|
|
||||||
variant=variant
|
|
||||||
size=size
|
|
||||||
>
|
|
||||||
{children()}
|
{children()}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -78,130 +63,56 @@ pub fn DialogContent(
|
|||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let ctx = expect_context::<DialogContext>();
|
let ctx = expect_context::<DialogContext>();
|
||||||
let merged_class = tw_merge!(
|
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",
|
"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
|
class
|
||||||
);
|
);
|
||||||
|
|
||||||
let backdrop_data_name = format!("{}Backdrop", data_name_prefix);
|
let backdrop_data_name = format!("{}Backdrop", data_name_prefix);
|
||||||
let content_data_name = format!("{}Content", data_name_prefix);
|
let content_data_name = format!("{}Content", data_name_prefix);
|
||||||
|
|
||||||
let target_id_clone = ctx.target_id.clone();
|
let target_id_clone = ctx.target_id.clone();
|
||||||
let backdrop_id = format!("{}_backdrop", ctx.target_id);
|
let backdrop_id = format!("{}_backdrop", ctx.target_id);
|
||||||
let target_id_for_script = ctx.target_id.clone();
|
let target_id_for_script = ctx.target_id.clone();
|
||||||
let backdrop_id_for_script = backdrop_id.clone();
|
let backdrop_id_for_script = backdrop_id.clone();
|
||||||
let backdrop_behavior = if close_on_backdrop_click { "auto" } else { "manual" };
|
let backdrop_behavior = if close_on_backdrop_click { "auto" } else { "manual" };
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<script src="/lock_scroll.js"></script>
|
<script src="/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
|
<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;">
|
||||||
data-name=backdrop_data_name
|
<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">
|
||||||
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>
|
<span class="hidden">"Close Dialog"</span>
|
||||||
<X />
|
<X />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{children()}
|
{children()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
{format!(
|
{format!(r#"
|
||||||
r#"
|
|
||||||
(function() {{
|
(function() {{
|
||||||
const setupDialog = () => {{
|
const setupDialog = () => {{
|
||||||
const dialog = document.querySelector('#{}');
|
const dialog = document.querySelector('#{}');
|
||||||
const backdrop = document.querySelector('#{}');
|
const backdrop = document.querySelector('#{}');
|
||||||
const trigger = document.querySelector('[data-dialog-trigger="{}"]');
|
const trigger = document.querySelector('[data-dialog-trigger="{}"]');
|
||||||
|
if (!dialog || !backdrop || !trigger || dialog.hasAttribute('data-initialized')) return;
|
||||||
if (!dialog || !backdrop || !trigger) {{
|
|
||||||
setTimeout(setupDialog, 50);
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
|
|
||||||
if (dialog.hasAttribute('data-initialized')) {{
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
dialog.setAttribute('data-initialized', 'true');
|
dialog.setAttribute('data-initialized', 'true');
|
||||||
|
|
||||||
const openDialog = () => {{
|
const openDialog = () => {{
|
||||||
// Lock scrolling
|
if (window.ScrollLock) window.ScrollLock.lock();
|
||||||
window.ScrollLock.lock();
|
|
||||||
|
|
||||||
dialog.setAttribute('data-state', 'open');
|
dialog.setAttribute('data-state', 'open');
|
||||||
backdrop.setAttribute('data-state', 'open');
|
backdrop.setAttribute('data-state', 'open');
|
||||||
dialog.style.pointerEvents = 'auto';
|
dialog.style.pointerEvents = 'auto';
|
||||||
backdrop.style.pointerEvents = 'auto';
|
backdrop.style.pointerEvents = 'auto';
|
||||||
}};
|
}};
|
||||||
|
|
||||||
const closeDialog = () => {{
|
const closeDialog = () => {{
|
||||||
dialog.setAttribute('data-state', 'closed');
|
dialog.setAttribute('data-state', 'closed');
|
||||||
backdrop.setAttribute('data-state', 'closed');
|
backdrop.setAttribute('data-state', 'closed');
|
||||||
dialog.style.pointerEvents = 'none';
|
dialog.style.pointerEvents = 'none';
|
||||||
backdrop.style.pointerEvents = 'none';
|
backdrop.style.pointerEvents = 'none';
|
||||||
|
if (window.ScrollLock) window.ScrollLock.unlock(200);
|
||||||
// Unlock scrolling after animation
|
|
||||||
window.ScrollLock.unlock(200);
|
|
||||||
}};
|
}};
|
||||||
|
|
||||||
// Open dialog when trigger is clicked
|
|
||||||
trigger.addEventListener('click', openDialog);
|
trigger.addEventListener('click', openDialog);
|
||||||
|
dialog.querySelectorAll('[data-dialog-close]').forEach(btn => btn.addEventListener('click', closeDialog));
|
||||||
// Close buttons
|
backdrop.addEventListener('click', () => {{ if (dialog.getAttribute('data-backdrop') === 'auto') closeDialog(); }});
|
||||||
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();
|
|
||||||
}}
|
|
||||||
}});
|
|
||||||
}};
|
}};
|
||||||
|
setupDialog();
|
||||||
if (document.readyState === 'loading') {{
|
|
||||||
document.addEventListener('DOMContentLoaded', setupDialog);
|
|
||||||
}} else {{
|
|
||||||
setupDialog();
|
|
||||||
}}
|
|
||||||
}})();
|
}})();
|
||||||
"#,
|
"#, target_id_for_script, backdrop_id_for_script, target_id_for_script)}
|
||||||
target_id_for_script,
|
|
||||||
backdrop_id_for_script,
|
|
||||||
target_id_for_script,
|
|
||||||
)}
|
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,15 +125,8 @@ pub fn DialogClose(
|
|||||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let ctx = expect_context::<DialogContext>();
|
let ctx = expect_context::<DialogContext>();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Button
|
<Button class=class attr:data-dialog-close=ctx.target_id attr:aria-label="Close dialog" variant=variant size=size>
|
||||||
class=class
|
|
||||||
attr:data-dialog-close=ctx.target_id
|
|
||||||
attr:aria-label="Close dialog"
|
|
||||||
variant=variant
|
|
||||||
size=size
|
|
||||||
>
|
|
||||||
{children()}
|
{children()}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -235,17 +139,11 @@ pub fn DialogAction(
|
|||||||
#[prop(default = ButtonVariant::Default)] variant: ButtonVariant,
|
#[prop(default = ButtonVariant::Default)] variant: ButtonVariant,
|
||||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
|
let _ = (class, variant, size);
|
||||||
let ctx = expect_context::<DialogContext>();
|
let ctx = expect_context::<DialogContext>();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Button
|
<Button attr:data-dialog-close=ctx.target_id attr:aria-label="Close dialog">
|
||||||
class=class
|
|
||||||
attr:data-dialog-close=ctx.target_id
|
|
||||||
attr:aria-label="Close dialog"
|
|
||||||
variant=variant
|
|
||||||
size=size
|
|
||||||
>
|
|
||||||
{children()}
|
{children()}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use icons::{Check, ChevronRight};
|
|
||||||
use leptos::context::Provider;
|
use leptos::context::Provider;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_ui::clx;
|
use leptos_ui::clx;
|
||||||
@@ -18,27 +17,20 @@ mod components {
|
|||||||
|
|
||||||
pub use components::*;
|
pub use components::*;
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* RADIO GROUP */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct DropdownMenuRadioContext<T: Clone + PartialEq + Send + Sync + 'static> {
|
struct DropdownMenuRadioContext<T: Clone + PartialEq + Send + Sync + 'static> {
|
||||||
value_signal: RwSignal<T>,
|
value_signal: RwSignal<T>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A group of radio items where only one can be selected at a time.
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn DropdownMenuRadioGroup<T>(
|
pub fn DropdownMenuRadioGroup<T>(
|
||||||
children: Children,
|
children: Children,
|
||||||
/// The signal holding the current selected value
|
|
||||||
value: RwSignal<T>,
|
value: RwSignal<T>,
|
||||||
) -> impl IntoView
|
) -> impl IntoView
|
||||||
where
|
where
|
||||||
T: Clone + PartialEq + Send + Sync + 'static,
|
T: Clone + PartialEq + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let ctx = DropdownMenuRadioContext { value_signal: value };
|
let ctx = DropdownMenuRadioContext { value_signal: value };
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Provider value=ctx>
|
<Provider value=ctx>
|
||||||
<ul data-name="DropdownMenuRadioGroup" role="group" class="group">
|
<ul data-name="DropdownMenuRadioGroup" role="group" class="group">
|
||||||
@@ -48,11 +40,9 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A radio item that shows a checkmark when selected.
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn DropdownMenuRadioItem<T>(
|
pub fn DropdownMenuRadioItem<T>(
|
||||||
children: Children,
|
children: Children,
|
||||||
/// The value this item represents
|
|
||||||
value: T,
|
value: T,
|
||||||
#[prop(optional, into)] class: String,
|
#[prop(optional, into)] class: String,
|
||||||
) -> impl IntoView
|
) -> impl IntoView
|
||||||
@@ -60,16 +50,13 @@ where
|
|||||||
T: Clone + PartialEq + Send + Sync + 'static,
|
T: Clone + PartialEq + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let ctx = expect_context::<DropdownMenuRadioContext<T>>();
|
let ctx = expect_context::<DropdownMenuRadioContext<T>>();
|
||||||
|
|
||||||
let value_for_check = value.clone();
|
let value_for_check = value.clone();
|
||||||
let value_for_click = value.clone();
|
let value_for_click = value.clone();
|
||||||
let is_selected = move || ctx.value_signal.get() == value_for_check;
|
let is_selected = move || ctx.value_signal.get() == value_for_check;
|
||||||
|
|
||||||
let merged_class = tw_merge!(
|
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",
|
"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
|
class
|
||||||
);
|
);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<li
|
<li
|
||||||
data-name="DropdownMenuRadioItem"
|
data-name="DropdownMenuRadioItem"
|
||||||
@@ -82,12 +69,11 @@ where
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children()}
|
{children()}
|
||||||
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-checked:opacity-100" />
|
<icons::Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-checked:opacity-100" />
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An action item in a dropdown menu (no checkmark, just triggers an action).
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn DropdownMenuAction(
|
pub fn DropdownMenuAction(
|
||||||
children: Children,
|
children: Children,
|
||||||
@@ -98,76 +84,25 @@ pub fn DropdownMenuAction(
|
|||||||
"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",
|
"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
|
class
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(href) = href {
|
if let Some(href) = href {
|
||||||
// Render as <a> tag when href is provided
|
|
||||||
view! {
|
view! {
|
||||||
<a data-name="DropdownMenuAction" class=class href=href data-dropdown-close="true">
|
<a data-name="DropdownMenuAction" class=class href=href data-dropdown-close="true">
|
||||||
{children()}
|
{children()}
|
||||||
</a>
|
</a>
|
||||||
|
}.into_any()
|
||||||
<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 {
|
} else {
|
||||||
// Render as <button> tag when no href
|
|
||||||
view! {
|
view! {
|
||||||
<button type="button" data-name="DropdownMenuAction" class=class data-dropdown-close="true">
|
<button type="button" data-name="DropdownMenuAction" class=class data-dropdown-close="true">
|
||||||
{children()}
|
{children()}
|
||||||
</button>
|
</button>
|
||||||
}
|
}.into_any()
|
||||||
.into_any()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub enum DropdownMenuAlign {
|
pub enum DropdownMenuAlign {
|
||||||
#[default]
|
#[default] Start, StartOuter, End, EndOuter, Center,
|
||||||
Start,
|
|
||||||
StartOuter,
|
|
||||||
End,
|
|
||||||
EndOuter,
|
|
||||||
Center,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -176,40 +111,11 @@ struct DropdownMenuContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn DropdownMenu(
|
pub fn DropdownMenu(children: Children) -> impl IntoView {
|
||||||
children: Children,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let dropdown_target_id = use_random_id_for("dropdown");
|
let dropdown_target_id = use_random_id_for("dropdown");
|
||||||
|
|
||||||
let ctx = DropdownMenuContext { target_id: dropdown_target_id.clone() };
|
let ctx = DropdownMenuContext { target_id: dropdown_target_id.clone() };
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Provider value=ctx>
|
<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>
|
<div data-name="DropdownMenu">{children()}</div>
|
||||||
</Provider>
|
</Provider>
|
||||||
}
|
}
|
||||||
@@ -219,29 +125,20 @@ pub fn DropdownMenu(
|
|||||||
pub fn DropdownMenuTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
pub fn DropdownMenuTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
let ctx = expect_context::<DropdownMenuContext>();
|
let ctx = expect_context::<DropdownMenuContext>();
|
||||||
let button_class = tw_merge!(
|
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",
|
"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
|
class
|
||||||
);
|
);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<button
|
<button type="button" class=button_class data-name="DropdownMenuTrigger" data-dropdown-trigger=ctx.target_id tabindex="0">
|
||||||
type="button"
|
|
||||||
class=button_class
|
|
||||||
data-name="DropdownMenuTrigger"
|
|
||||||
data-dropdown-trigger=ctx.target_id
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
{children()}
|
{children()}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub enum DropdownMenuPosition {
|
pub enum DropdownMenuPosition {
|
||||||
#[default]
|
#[default] Auto, Top, Bottom,
|
||||||
Auto,
|
|
||||||
Top,
|
|
||||||
Bottom,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
@@ -252,262 +149,81 @@ pub fn DropdownMenuContent(
|
|||||||
#[prop(default = DropdownMenuPosition::default())] position: DropdownMenuPosition,
|
#[prop(default = DropdownMenuPosition::default())] position: DropdownMenuPosition,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let ctx = expect_context::<DropdownMenuContext>();
|
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 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 align {
|
let width_class = match align {
|
||||||
DropdownMenuAlign::Center => "min-w-full",
|
DropdownMenuAlign::Center => "min-w-full",
|
||||||
_ => "w-[180px]",
|
_ => "w-[180px]",
|
||||||
};
|
};
|
||||||
|
|
||||||
let class = tw_merge!(width_class, base_classes, class);
|
let class = tw_merge!(width_class, base_classes, class);
|
||||||
|
|
||||||
let target_id_for_script = ctx.target_id.clone();
|
let target_id_for_script = ctx.target_id.clone();
|
||||||
let align_for_script = match align {
|
let align_for_script = match align {
|
||||||
DropdownMenuAlign::Start => "start",
|
DropdownMenuAlign::Start => "start", DropdownMenuAlign::StartOuter => "start-outer",
|
||||||
DropdownMenuAlign::StartOuter => "start-outer",
|
DropdownMenuAlign::End => "end", DropdownMenuAlign::EndOuter => "end-outer",
|
||||||
DropdownMenuAlign::End => "end",
|
|
||||||
DropdownMenuAlign::EndOuter => "end-outer",
|
|
||||||
DropdownMenuAlign::Center => "center",
|
DropdownMenuAlign::Center => "center",
|
||||||
};
|
};
|
||||||
|
|
||||||
let position_for_script = match position {
|
let position_for_script = match position {
|
||||||
DropdownMenuPosition::Auto => "auto",
|
DropdownMenuPosition::Auto => "auto", DropdownMenuPosition::Top => "top", DropdownMenuPosition::Bottom => "bottom",
|
||||||
DropdownMenuPosition::Top => "top",
|
|
||||||
DropdownMenuPosition::Bottom => "bottom",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div
|
<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;">
|
||||||
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()}
|
{children()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
{format!(
|
{format!(r#"
|
||||||
r#"
|
|
||||||
(function() {{
|
(function() {{
|
||||||
const setupDropdown = () => {{
|
const setupDropdown = () => {{
|
||||||
const dropdown = document.querySelector('#{}');
|
const dropdown = document.querySelector('#{}');
|
||||||
const trigger = document.querySelector('[data-dropdown-trigger="{}"]');
|
const trigger = document.querySelector('[data-dropdown-trigger="{}"]');
|
||||||
|
if (!dropdown || !trigger || dropdown.hasAttribute('data-initialized')) return;
|
||||||
if (!dropdown || !trigger) {{
|
|
||||||
setTimeout(setupDropdown, 50);
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
|
|
||||||
if (dropdown.hasAttribute('data-initialized')) {{
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
dropdown.setAttribute('data-initialized', 'true');
|
dropdown.setAttribute('data-initialized', 'true');
|
||||||
|
|
||||||
let isOpen = false;
|
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 = () => {{
|
const openDropdown = () => {{
|
||||||
isOpen = true;
|
isOpen = true;
|
||||||
|
|
||||||
// Set state to open first to remove scale transform for accurate measurements
|
|
||||||
dropdown.setAttribute('data-state', 'open');
|
dropdown.setAttribute('data-state', 'open');
|
||||||
|
|
||||||
// Make dropdown invisible but rendered to measure true height
|
|
||||||
dropdown.style.visibility = 'hidden';
|
|
||||||
dropdown.style.pointerEvents = 'auto';
|
dropdown.style.pointerEvents = 'auto';
|
||||||
|
if (window.ScrollLock) window.ScrollLock.lock();
|
||||||
// Force reflow to ensure height is calculated
|
setTimeout(() => {{ document.addEventListener('click', handleClickOutside); }}, 0);
|
||||||
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 = () => {{
|
const closeDropdown = () => {{
|
||||||
isOpen = false;
|
isOpen = false;
|
||||||
dropdown.setAttribute('data-state', 'closed');
|
dropdown.setAttribute('data-state', 'closed');
|
||||||
dropdown.style.pointerEvents = 'none';
|
dropdown.style.pointerEvents = 'none';
|
||||||
document.removeEventListener('click', handleClickOutside);
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
if (window.ScrollLock) window.ScrollLock.unlock(200);
|
||||||
// Unlock scroll after animation (200ms delay)
|
|
||||||
window.ScrollLock.unlock(200);
|
|
||||||
}};
|
}};
|
||||||
|
const handleClickOutside = (e) => {{ if (!dropdown.contains(e.target) && !trigger.contains(e.target)) closeDropdown(); }};
|
||||||
const handleClickOutside = (e) => {{
|
|
||||||
if (!dropdown.contains(e.target) && !trigger.contains(e.target)) {{
|
|
||||||
closeDropdown();
|
|
||||||
}}
|
|
||||||
}};
|
|
||||||
|
|
||||||
// Toggle dropdown when trigger is clicked
|
|
||||||
trigger.addEventListener('click', (e) => {{
|
trigger.addEventListener('click', (e) => {{
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (isOpen) closeDropdown(); else openDropdown();
|
||||||
// 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();
|
|
||||||
}}
|
|
||||||
}});
|
}});
|
||||||
|
dropdown.querySelectorAll('[data-dropdown-close]').forEach(action => action.addEventListener('click', closeDropdown));
|
||||||
}};
|
}};
|
||||||
|
setupDropdown();
|
||||||
if (document.readyState === 'loading') {{
|
|
||||||
document.addEventListener('DOMContentLoaded', setupDropdown);
|
|
||||||
}} else {{
|
|
||||||
setupDropdown();
|
|
||||||
}}
|
|
||||||
}})();
|
}})();
|
||||||
"#,
|
"#, target_id_for_script, target_id_for_script)}
|
||||||
target_id_for_script,
|
|
||||||
target_id_for_script,
|
|
||||||
)}
|
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn DropdownMenuSub(children: Children) -> impl IntoView {
|
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 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 hover:bg-accent hover:text-accent-foreground"}
|
||||||
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> }
|
view! { <DropdownMenuSubRoot>{children()}</DropdownMenuSubRoot> }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn DropdownMenuSubTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
pub fn DropdownMenuSubTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
let class = tw_merge!("flex items-center justify-between w-full", class);
|
let class = tw_merge!("flex items-center justify-between w-full", class);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<span attr:data-name="DropdownMenuSubTrigger" class=class>
|
<span attr:data-name="DropdownMenuSubTrigger" class=class>
|
||||||
<span class="flex gap-2 items-center">{children()}</span>
|
<span class="flex gap-2 items-center">{children()}</span>
|
||||||
<ChevronRight class="opacity-70 size-4" />
|
<icons::ChevronRight class="opacity-70 size-4" />
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn DropdownMenuSubItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
pub fn DropdownMenuSubItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
let class = tw_merge!(
|
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);
|
||||||
"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]",
|
view! { <li data-name="DropdownMenuSubItem" class=class data-dropdown-close="true">{children()}</li> }
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<li data-name="DropdownMenuSubItem" class=class data-dropdown-close="true">
|
|
||||||
{children()}
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,29 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use icons::{Check, ChevronDown, ChevronUp};
|
use icons::{Check, ChevronDown, ChevronUp};
|
||||||
use leptos::context::Provider;
|
use leptos::context::Provider;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use tw_merge::*;
|
use tw_merge::*;
|
||||||
|
|
||||||
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
|
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
|
||||||
use crate::components::hooks::use_random::use_random_id_for;
|
use crate::components::hooks::use_random::use_random_id_for;
|
||||||
// * Reuse @select.rs
|
|
||||||
pub use crate::components::ui::select::{
|
pub use crate::components::ui::select::{
|
||||||
SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem,
|
SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub enum MultiSelectAlign {
|
pub enum MultiSelectAlign {
|
||||||
Start,
|
Start, #[default] Center, End,
|
||||||
#[default]
|
|
||||||
Center,
|
|
||||||
End,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn MultiSelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
|
pub fn MultiSelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
|
||||||
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<span data-name="MultiSelectValue" class="text-sm text-muted-foreground truncate">
|
<span data-name="MultiSelectValue" class="text-sm text-muted-foreground truncate">
|
||||||
{move || {
|
{move || {
|
||||||
let values = multi_select_ctx.values_signal.get();
|
let values = multi_select_ctx.values_signal.get();
|
||||||
if values.is_empty() {
|
if values.is_empty() { placeholder.clone() }
|
||||||
placeholder.clone()
|
else { let count = values.len(); if count == 1 { "1 selected".to_string() } else { format!("{} selected", count) } }
|
||||||
} else {
|
|
||||||
let count = values.len();
|
|
||||||
if count == 1 { "1 selected".to_string() } else { format!("{} selected", count) }
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -50,41 +36,17 @@ pub fn MultiSelectOption(
|
|||||||
#[prop(optional, into)] value: Option<String>,
|
#[prop(optional, into)] value: Option<String>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
||||||
|
|
||||||
let value_clone = value.clone();
|
let value_clone = value.clone();
|
||||||
let is_selected = Signal::derive(move || {
|
let is_selected = Signal::derive(move || {
|
||||||
if let Some(ref val) = value_clone {
|
if let Some(ref val) = value_clone { multi_select_ctx.values_signal.with(|values| values.contains(val)) } else { false }
|
||||||
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 hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50", class);
|
||||||
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! {
|
view! {
|
||||||
<button
|
<button type="button" data-name="MultiSelectOption" class=class role="option" aria-selected=move || is_selected.get().to_string()
|
||||||
type="button"
|
|
||||||
data-name="MultiSelectOption"
|
|
||||||
class=class
|
|
||||||
role="option"
|
|
||||||
aria-selected=move || is_selected.get().to_string()
|
|
||||||
on:click=move |ev: web_sys::MouseEvent| {
|
on:click=move |ev: web_sys::MouseEvent| {
|
||||||
ev.prevent_default();
|
ev.prevent_default(); ev.stop_propagation();
|
||||||
ev.stop_propagation();
|
|
||||||
if let Some(val) = value.clone() {
|
if let Some(val) = value.clone() {
|
||||||
multi_select_ctx
|
multi_select_ctx.values_signal.update(|values| { if values.contains(&val) { values.remove(&val); } else { values.insert(val); } });
|
||||||
.values_signal
|
|
||||||
.update(|values| {
|
|
||||||
if values.contains(&val) {
|
|
||||||
values.remove(&val);
|
|
||||||
} else {
|
|
||||||
values.insert(val);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -94,10 +56,6 @@ pub fn MultiSelectOption(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct MultiSelectContext {
|
struct MultiSelectContext {
|
||||||
target_id: String,
|
target_id: String,
|
||||||
@@ -113,9 +71,7 @@ pub fn MultiSelect(
|
|||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let multi_select_target_id = use_random_id_for("multi_select");
|
let multi_select_target_id = use_random_id_for("multi_select");
|
||||||
let values_signal = values.unwrap_or_else(|| RwSignal::new(HashSet::<String>::new()));
|
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 };
|
let multi_select_ctx = MultiSelectContext { target_id: multi_select_target_id.clone(), values_signal, align };
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Provider value=multi_select_ctx>
|
<Provider value=multi_select_ctx>
|
||||||
<div data-name="MultiSelect" class="relative w-fit">
|
<div data-name="MultiSelect" class="relative w-fit">
|
||||||
@@ -126,32 +82,12 @@ pub fn MultiSelect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn MultiSelectTrigger(
|
pub fn MultiSelectTrigger(children: Children, #[prop(optional, into)] class: String, #[prop(optional, into)] id: String) -> impl IntoView {
|
||||||
children: Children,
|
let ctx = expect_context::<MultiSelectContext>();
|
||||||
#[prop(optional, into)] class: String,
|
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 disabled:cursor-not-allowed disabled:opacity-50 border bg-background border-input hover:bg-accent hover:text-accent-foreground", class);
|
||||||
#[prop(optional, into)] id: String,
|
let button_id = if !id.is_empty() { id } else { format!("trigger_{}", ctx.target_id) };
|
||||||
) -> 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! {
|
view! {
|
||||||
<button
|
<button type="button" data-name="MultiSelectTrigger" class=button_class id=button_id tabindex="0" data-multi-select-trigger=ctx.target_id>
|
||||||
type="button"
|
|
||||||
data-name="MultiSelectTrigger"
|
|
||||||
class=button_class
|
|
||||||
id=button_id
|
|
||||||
tabindex="0"
|
|
||||||
data-multi-select-trigger=multi_select_ctx.target_id
|
|
||||||
>
|
|
||||||
{children()}
|
{children()}
|
||||||
<ChevronDown class="text-muted-foreground" />
|
<ChevronDown class="text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -161,134 +97,33 @@ pub fn MultiSelectTrigger(
|
|||||||
#[component]
|
#[component]
|
||||||
pub fn MultiSelectContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
pub fn MultiSelectContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
let multi_select_ctx = expect_context::<MultiSelectContext>();
|
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 align_str = match multi_select_ctx.align {
|
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);
|
||||||
MultiSelectAlign::Start => "start",
|
let target_id = multi_select_ctx.target_id.clone();
|
||||||
MultiSelectAlign::Center => "center",
|
let (on_scroll, can_scroll_up, can_scroll_down) = use_can_scroll_vertical();
|
||||||
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();
|
|
||||||
let target_id_for_script_2 = 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! {
|
view! {
|
||||||
<div
|
<div data-name="MultiSelectContent" class=class id=target_id.clone() data-target="target__multi_select" data-state="closed" data-align=align_str style="pointer-events: none;" on:scroll=move |ev| on_scroll.run(ev)>
|
||||||
data-name="MultiSelectContent"
|
<div class=move || if can_scroll_up.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>
|
||||||
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=move |ev| on_scroll.run(ev)
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-scroll-up="true"
|
|
||||||
class=move || {
|
|
||||||
let is_up: bool = can_scroll_up_signal.get();
|
|
||||||
if is_up {
|
|
||||||
"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()}
|
{children()}
|
||||||
<div
|
<div class=move || if can_scroll_down.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>
|
||||||
data-scroll-down="true"
|
|
||||||
class=move || {
|
|
||||||
let is_down: bool = can_scroll_down_signal.get();
|
|
||||||
if is_down {
|
|
||||||
"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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
{format!(
|
{format!(r#"
|
||||||
r#"
|
|
||||||
(function() {{
|
(function() {{
|
||||||
const setupMultiSelect = () => {{
|
const setup = () => {{
|
||||||
const multiSelect = document.querySelector('#{}');
|
const ms = document.querySelector('#{}');
|
||||||
const trigger = document.querySelector('[data-multi-select-trigger="{}"]');
|
const tr = document.querySelector('[data-multi-select-trigger="{}"]');
|
||||||
|
if (!ms || !tr || ms.hasAttribute('data-initialized')) return;
|
||||||
if (!multiSelect || !trigger) {{
|
ms.setAttribute('data-initialized', 'true');
|
||||||
setTimeout(setupMultiSelect, 50);
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
|
|
||||||
if (multiSelect.hasAttribute('data-initialized')) {{
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
multiSelect.setAttribute('data-initialized', 'true');
|
|
||||||
|
|
||||||
let isOpen = false;
|
let isOpen = false;
|
||||||
|
const open = () => {{ isOpen = true; ms.setAttribute('data-state', 'open'); ms.style.pointerEvents = 'auto'; if (window.ScrollLock) window.ScrollLock.lock(); setTimeout(() => document.addEventListener('click', clickOut), 0); }};
|
||||||
const openMultiSelect = () => {{
|
const close = () => {{ isOpen = false; ms.setAttribute('data-state', 'closed'); ms.style.pointerEvents = 'none'; document.removeEventListener('click', clickOut); if (window.ScrollLock) window.ScrollLock.unlock(200); }};
|
||||||
isOpen = true;
|
const clickOut = (e) => {{ if (!ms.contains(e.target) && !tr.contains(e.target)) close(); }};
|
||||||
if (window.ScrollLock) window.ScrollLock.lock();
|
tr.addEventListener('click', (e) => {{ e.stopPropagation(); if (isOpen) close(); else open(); }});
|
||||||
multiSelect.setAttribute('data-state', 'open');
|
|
||||||
multiSelect.style.pointerEvents = 'auto';
|
|
||||||
const triggerRect = trigger.getBoundingClientRect();
|
|
||||||
multiSelect.style.minWidth = `${{triggerRect.width}}px`;
|
|
||||||
multiSelect.dispatchEvent(new Event('scroll'));
|
|
||||||
setTimeout(() => {{
|
|
||||||
document.addEventListener('click', handleClickOutside);
|
|
||||||
}}, 0);
|
|
||||||
}};
|
|
||||||
|
|
||||||
const closeMultiSelect = () => {{
|
|
||||||
isOpen = false;
|
|
||||||
multiSelect.setAttribute('data-state', 'closed');
|
|
||||||
multiSelect.style.pointerEvents = 'none';
|
|
||||||
document.removeEventListener('click', handleClickOutside);
|
|
||||||
if (window.ScrollLock) window.ScrollLock.unlock(200);
|
|
||||||
}};
|
|
||||||
|
|
||||||
const handleClickOutside = (e) => {{
|
|
||||||
if (!multiSelect.contains(e.target) && !trigger.contains(e.target)) {{
|
|
||||||
closeMultiSelect();
|
|
||||||
}}
|
|
||||||
}};
|
|
||||||
|
|
||||||
trigger.addEventListener('click', (e) => {{
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isOpen) closeMultiSelect(); else openMultiSelect();
|
|
||||||
}});
|
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {{
|
|
||||||
if (e.key === 'Escape' && isOpen) {{
|
|
||||||
e.preventDefault();
|
|
||||||
closeMultiSelect();
|
|
||||||
}}
|
|
||||||
}});
|
|
||||||
}};
|
}};
|
||||||
|
setup();
|
||||||
if (document.readyState === 'loading') {{
|
|
||||||
document.addEventListener('DOMContentLoaded', setupMultiSelect);
|
|
||||||
}} else {{
|
|
||||||
setupMultiSelect();
|
|
||||||
}}
|
|
||||||
}})();
|
}})();
|
||||||
"#,
|
"#, target_id, target_id)}
|
||||||
target_id_for_script,
|
|
||||||
target_id_for_script_2,
|
|
||||||
)}
|
|
||||||
</script>
|
</script>
|
||||||
}.into_any()
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,18 @@
|
|||||||
use icons::{Check, ChevronDown, ChevronUp};
|
use icons::{ChevronDown, ChevronUp};
|
||||||
use leptos::context::Provider;
|
use leptos::context::Provider;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_ui::clx;
|
|
||||||
use strum::{AsRefStr, Display};
|
|
||||||
use tw_merge::*;
|
use tw_merge::*;
|
||||||
|
|
||||||
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
|
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
|
||||||
use crate::components::hooks::use_random::use_random_id_for;
|
use crate::components::hooks::use_random::use_random_id_for;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, AsRefStr)]
|
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||||
pub enum SelectPosition {
|
#[allow(dead_code)]
|
||||||
#[default]
|
pub enum SelectPosition { #[default] Below, Above }
|
||||||
Below,
|
|
||||||
Above,
|
|
||||||
}
|
|
||||||
|
|
||||||
mod components {
|
|
||||||
use super::*;
|
|
||||||
clx! {SelectLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"}
|
|
||||||
clx! {SelectItem, 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"}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub use components::*;
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SelectGroup(
|
|
||||||
children: Children,
|
|
||||||
#[prop(optional, into)] class: String,
|
|
||||||
#[prop(default = "Select options".into(), into)] aria_label: String,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let merged_class = tw_merge!("group", class);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<ul data-name="SelectGroup" role="listbox" aria-label=aria_label class=merged_class>
|
|
||||||
{children()}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
|
pub fn SelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
|
||||||
let select_ctx = expect_context::<SelectContext>();
|
let _ = placeholder;
|
||||||
|
view! { <span data-name="SelectValue">"Select..."</span> }
|
||||||
view! {
|
|
||||||
<span data-name="SelectValue" class="text-sm text-muted-foreground truncate">
|
|
||||||
{move || { select_ctx.value_signal.get().unwrap_or_else(|| placeholder.clone()) }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
@@ -56,44 +22,12 @@ pub fn SelectOption(
|
|||||||
#[prop(default = false.into(), into)] aria_selected: Signal<bool>,
|
#[prop(default = false.into(), into)] aria_selected: Signal<bool>,
|
||||||
#[prop(optional, into)] value: Option<String>,
|
#[prop(optional, into)] value: Option<String>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let ctx = expect_context::<SelectContext>();
|
let _ = (class, aria_selected, value);
|
||||||
|
view! { <div role="option">{children()}</div> }
|
||||||
let merged_class = tw_merge!(
|
|
||||||
"group inline-flex gap-2 items-center w-full rounded-sm px-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
|
|
||||||
);
|
|
||||||
|
|
||||||
let value_for_check = value.clone();
|
|
||||||
let is_selected = move || aria_selected.get() || ctx.value_signal.get() == value_for_check;
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<li
|
|
||||||
data-name="SelectOption"
|
|
||||||
class=merged_class
|
|
||||||
role="option"
|
|
||||||
tabindex="0"
|
|
||||||
aria-selected=move || is_selected().to_string()
|
|
||||||
data-select-option="true"
|
|
||||||
on:click=move |_| {
|
|
||||||
let val = value.clone();
|
|
||||||
ctx.value_signal.set(val.clone());
|
|
||||||
if let Some(on_change) = ctx.on_change {
|
|
||||||
on_change.run(val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-selected:opacity-100" />
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct SelectContext {
|
struct SelectContext {}
|
||||||
target_id: String,
|
|
||||||
value_signal: RwSignal<Option<String>>,
|
|
||||||
on_change: Option<Callback<Option<String>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Select(
|
pub fn Select(
|
||||||
@@ -102,53 +36,14 @@ pub fn Select(
|
|||||||
#[prop(optional, into)] default_value: Option<String>,
|
#[prop(optional, into)] default_value: Option<String>,
|
||||||
#[prop(optional)] on_change: Option<Callback<Option<String>>>,
|
#[prop(optional)] on_change: Option<Callback<Option<String>>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let select_target_id = use_random_id_for("select");
|
let _ = (class, default_value, on_change);
|
||||||
let value_signal = RwSignal::new(default_value);
|
view! { <Provider value=SelectContext {}>{children()}</Provider> }
|
||||||
|
|
||||||
let ctx = SelectContext { target_id: select_target_id.clone(), value_signal, on_change };
|
|
||||||
|
|
||||||
let merged_class = tw_merge!("relative w-fit", class);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Provider value=ctx>
|
|
||||||
<div data-name="Select" class=merged_class>
|
|
||||||
{children()}
|
|
||||||
</div>
|
|
||||||
</Provider>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SelectTrigger(
|
pub fn SelectTrigger(children: Children, #[prop(optional, into)] class: String, #[prop(optional, into)] id: String) -> impl IntoView {
|
||||||
children: Children,
|
let _ = (class, id);
|
||||||
#[prop(optional, into)] class: String,
|
view! { <button type="button">{children()}</button> }
|
||||||
#[prop(optional, into)] id: String,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let ctx = expect_context::<SelectContext>();
|
|
||||||
|
|
||||||
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_{}", ctx.target_id) };
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-name="SelectTrigger"
|
|
||||||
class=button_class
|
|
||||||
id=button_id
|
|
||||||
tabindex="0"
|
|
||||||
data-select-trigger=ctx.target_id
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
<ChevronDown class="text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
@@ -158,154 +53,13 @@ pub fn SelectContent(
|
|||||||
#[prop(default = SelectPosition::default())] position: SelectPosition,
|
#[prop(default = SelectPosition::default())] position: SelectPosition,
|
||||||
#[prop(optional)] on_close: Option<Callback<()>>,
|
#[prop(optional)] on_close: Option<Callback<()>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let ctx = expect_context::<SelectContext>();
|
let _ = (class, position, on_close);
|
||||||
|
let (on_scroll, can_scroll_up, can_scroll_down) = use_can_scroll_vertical();
|
||||||
let merged_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)] left-0 data-[position=Above]:top-auto data-[position=Above]:bottom-[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-[state=closed]:data-[position=Below]:origin-top data-[state=open]:data-[position=Below]:origin-top data-[state=closed]:data-[position=Above]:origin-bottom data-[state=open]:data-[position=Above]:origin-bottom [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
|
||||||
class
|
|
||||||
);
|
|
||||||
|
|
||||||
let target_id_for_script = ctx.target_id.clone();
|
|
||||||
let target_id_for_script_2 = ctx.target_id.clone();
|
|
||||||
|
|
||||||
// Scroll indicator signals
|
|
||||||
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div
|
<div data-name="SelectContent" on:scroll=move |ev| on_scroll.run(ev)>
|
||||||
data-name="SelectContent"
|
<div class=move || if can_scroll_up.get() { "sticky" } else { "hidden" }><ChevronUp /></div>
|
||||||
class=merged_class
|
|
||||||
on:selectclose=move |_: web_sys::CustomEvent| {
|
|
||||||
if let Some(cb) = on_close {
|
|
||||||
cb.run(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
id=ctx.target_id
|
|
||||||
data-target="target__select"
|
|
||||||
data-state="closed"
|
|
||||||
data-position=position.to_string()
|
|
||||||
style="pointer-events: none;"
|
|
||||||
on:scroll=move |ev| on_scroll.run(ev)
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-scroll-up="true"
|
|
||||||
class=move || {
|
|
||||||
let is_up: bool = can_scroll_up_signal.get();
|
|
||||||
if is_up {
|
|
||||||
"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()}
|
{children()}
|
||||||
<div
|
<div class=move || if can_scroll_down.get() { "sticky" } else { "hidden" }><ChevronDown /></div>
|
||||||
data-scroll-down="true"
|
|
||||||
class=move || {
|
|
||||||
let is_down: bool = can_scroll_down_signal.get();
|
|
||||||
if is_down {
|
|
||||||
"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>
|
</div>
|
||||||
|
}
|
||||||
<script>
|
}
|
||||||
{format!(
|
|
||||||
r#"
|
|
||||||
(function() {{
|
|
||||||
const setupSelect = () => {{
|
|
||||||
const select = document.querySelector('#{}');
|
|
||||||
const trigger = document.querySelector('[data-select-trigger="{}"]');
|
|
||||||
|
|
||||||
if (!select || !trigger) {{
|
|
||||||
setTimeout(setupSelect, 50);
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
|
|
||||||
if (select.hasAttribute('data-initialized')) {{
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
select.setAttribute('data-initialized', 'true');
|
|
||||||
|
|
||||||
let isOpen = false;
|
|
||||||
|
|
||||||
const updatePosition = () => {{
|
|
||||||
const triggerRect = trigger.getBoundingClientRect();
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
const spaceBelow = viewportHeight - triggerRect.bottom;
|
|
||||||
const spaceAbove = triggerRect.top;
|
|
||||||
|
|
||||||
if (spaceBelow < 200 && spaceAbove > spaceBelow) {{
|
|
||||||
select.setAttribute('data-position', 'Above');
|
|
||||||
}} else {{
|
|
||||||
select.setAttribute('data-position', 'Below');
|
|
||||||
}}
|
|
||||||
|
|
||||||
select.style.minWidth = `${{triggerRect.width}}px`;
|
|
||||||
}};
|
|
||||||
|
|
||||||
const openSelect = () => {{
|
|
||||||
isOpen = true;
|
|
||||||
if (window.ScrollLock) window.ScrollLock.lock();
|
|
||||||
updatePosition();
|
|
||||||
select.setAttribute('data-state', 'open');
|
|
||||||
select.style.pointerEvents = 'auto';
|
|
||||||
select.dispatchEvent(new Event('scroll'));
|
|
||||||
setTimeout(() => {{
|
|
||||||
document.addEventListener('click', handleClickOutside);
|
|
||||||
}}, 0);
|
|
||||||
}};
|
|
||||||
|
|
||||||
const closeSelect = () => {{
|
|
||||||
isOpen = false;
|
|
||||||
select.setAttribute('data-state', 'closed');
|
|
||||||
select.style.pointerEvents = 'none';
|
|
||||||
document.removeEventListener('click', handleClickOutside);
|
|
||||||
select.dispatchEvent(new CustomEvent('selectclose', {{ bubbles: false }}));
|
|
||||||
if (window.ScrollLock) window.ScrollLock.unlock(200);
|
|
||||||
}};
|
|
||||||
|
|
||||||
const handleClickOutside = (e) => {{
|
|
||||||
if (!select.contains(e.target) && !trigger.contains(e.target)) {{
|
|
||||||
closeSelect();
|
|
||||||
}}
|
|
||||||
}};
|
|
||||||
|
|
||||||
trigger.addEventListener('click', (e) => {{
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isOpen) closeSelect(); else openSelect();
|
|
||||||
}});
|
|
||||||
|
|
||||||
const options = select.querySelectorAll('[data-select-option]');
|
|
||||||
options.forEach(option => {{
|
|
||||||
option.addEventListener('click', () => closeSelect());
|
|
||||||
}});
|
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {{
|
|
||||||
if (e.key === 'Escape' && isOpen) {{
|
|
||||||
e.preventDefault();
|
|
||||||
closeSelect();
|
|
||||||
}}
|
|
||||||
}});
|
|
||||||
}};
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {{
|
|
||||||
document.addEventListener('DOMContentLoaded', setupSelect);
|
|
||||||
}} else {{
|
|
||||||
setupSelect();
|
|
||||||
}}
|
|
||||||
}})();
|
|
||||||
"#,
|
|
||||||
target_id_for_script,
|
|
||||||
target_id_for_script_2,
|
|
||||||
)}
|
|
||||||
</script>
|
|
||||||
}.into_any()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,35 +1,22 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use tw_merge::*;
|
use tailwind_fuse::tw_merge;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum SeparatorOrientation { #[default] Horizontal, Vertical }
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Separator(
|
pub fn Separator(
|
||||||
#[prop(into, optional)] orientation: Signal<SeparatorOrientation>,
|
#[prop(into, optional)] orientation: Signal<SeparatorOrientation>,
|
||||||
#[prop(into, optional)] class: String,
|
#[prop(into, optional)] class: String,
|
||||||
// children: Children,
|
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let merged_class = Memo::new(move |_| {
|
let class = tw_merge!(
|
||||||
let orientation = orientation.get();
|
"shrink-0 bg-border",
|
||||||
let separator = SeparatorClass { orientation };
|
move || match orientation.get() {
|
||||||
separator.with_class(class.clone())
|
SeparatorOrientation::Horizontal => "h-[1px] w-full",
|
||||||
});
|
SeparatorOrientation::Vertical => "h-full w-[1px]",
|
||||||
|
},
|
||||||
view! { <div class=merged_class role="separator" /> }
|
class
|
||||||
|
);
|
||||||
|
view! { <div class=class role="none" /> }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* 🧬 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,
|
|
||||||
}
|
|
||||||
@@ -18,29 +18,19 @@ mod components {
|
|||||||
|
|
||||||
pub use components::*;
|
pub use components::*;
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ CONTEXT ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SheetContext {
|
pub struct SheetContext {
|
||||||
pub target_id: String,
|
pub target_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Sheet(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
pub fn Sheet(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let _ = class;
|
||||||
let sheet_target_id = use_random_id_for("sheet");
|
let sheet_target_id = use_random_id_for("sheet");
|
||||||
let ctx = SheetContext { target_id: sheet_target_id };
|
let ctx = SheetContext { target_id: sheet_target_id };
|
||||||
|
|
||||||
let merged_class = tw_merge!("", class);
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Provider value=ctx>
|
<Provider value=ctx>
|
||||||
<div data-name="Sheet" class=merged_class>
|
<div data-name="Sheet">
|
||||||
{children()}
|
{children()}
|
||||||
</div>
|
</div>
|
||||||
</Provider>
|
</Provider>
|
||||||
@@ -56,7 +46,6 @@ pub fn SheetTrigger(
|
|||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let ctx = expect_context::<SheetContext>();
|
let ctx = expect_context::<SheetContext>();
|
||||||
let trigger_id = format!("trigger_{}", ctx.target_id);
|
let trigger_id = format!("trigger_{}", ctx.target_id);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Button class=class attr:id=trigger_id attr:data-sheet-trigger=ctx.target_id variant=variant size=size>
|
<Button class=class attr:id=trigger_id attr:data-sheet-trigger=ctx.target_id variant=variant size=size>
|
||||||
{children()}
|
{children()}
|
||||||
@@ -72,7 +61,6 @@ pub fn SheetClose(
|
|||||||
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
#[prop(default = ButtonSize::Default)] size: ButtonSize,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let ctx = expect_context::<SheetContext>();
|
let ctx = expect_context::<SheetContext>();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Button class=class attr:data-sheet-close=ctx.target_id attr:aria-label="Close sheet" variant=variant size=size>
|
<Button class=class attr:data-sheet-close=ctx.target_id attr:aria-label="Close sheet" variant=variant size=size>
|
||||||
{children()}
|
{children()}
|
||||||
@@ -88,131 +76,68 @@ pub fn SheetContent(
|
|||||||
#[prop(into, optional)] hide_close_button: Option<bool>,
|
#[prop(into, optional)] hide_close_button: Option<bool>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let ctx = expect_context::<SheetContext>();
|
let ctx = expect_context::<SheetContext>();
|
||||||
|
|
||||||
let backdrop_id = format!("{}_backdrop", ctx.target_id);
|
let backdrop_id = format!("{}_backdrop", ctx.target_id);
|
||||||
let target_id_for_script = ctx.target_id.clone();
|
let target_id_for_script = ctx.target_id.clone();
|
||||||
let backdrop_id_for_script = backdrop_id.clone();
|
let backdrop_id_for_script = backdrop_id.clone();
|
||||||
|
|
||||||
let merged_class = tw_merge!(
|
let merged_class = tw_merge!(
|
||||||
"fixed z-100 bg-card shadow-lg p-6 transition-transform duration-300 overflow-y-auto overscroll-y-contain",
|
"fixed z-100 bg-card shadow-lg p-6 transition-transform duration-300 overflow-y-auto overscroll-y-contain",
|
||||||
direction.initial_position(),
|
direction.initial_position(),
|
||||||
direction.closed_class(),
|
direction.closed_class(),
|
||||||
class
|
class
|
||||||
);
|
);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div
|
<div data-name="SheetBackdrop" 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" />
|
||||||
data-name="SheetBackdrop"
|
<div data-name="SheetContent" class=merged_class id=ctx.target_id data-direction=direction.to_string() data-state="closed" style="pointer-events: none;">
|
||||||
id=backdrop_id
|
<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-sheet-close=ctx.target_id.clone() aria-label="Close sheet">
|
||||||
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="SheetContent"
|
|
||||||
class=merged_class
|
|
||||||
id=ctx.target_id
|
|
||||||
data-direction=direction.to_string()
|
|
||||||
data-state="closed"
|
|
||||||
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-sheet-close=ctx.target_id.clone()
|
|
||||||
aria-label="Close sheet"
|
|
||||||
>
|
|
||||||
<span class="hidden">"Close Sheet"</span>
|
<span class="hidden">"Close Sheet"</span>
|
||||||
<X />
|
<X />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{children()}
|
{children()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
{format!(
|
{format!(r#"
|
||||||
r#"
|
|
||||||
(function() {{
|
(function() {{
|
||||||
const setupSheet = () => {{
|
const setupSheet = () => {{
|
||||||
const sheet = document.querySelector('#{}');
|
const sheet = document.querySelector('#{}');
|
||||||
const backdrop = document.querySelector('#{}');
|
const backdrop = document.querySelector('#{}');
|
||||||
const trigger = document.querySelector('[data-sheet-trigger="{}"]');
|
const trigger = document.querySelector('[data-sheet-trigger="{}"]');
|
||||||
|
if (!sheet || !backdrop || !trigger || sheet.hasAttribute('data-initialized')) return;
|
||||||
if (!sheet || !backdrop || !trigger) {{
|
|
||||||
setTimeout(setupSheet, 50);
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
|
|
||||||
if (sheet.hasAttribute('data-initialized')) {{
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
sheet.setAttribute('data-initialized', 'true');
|
sheet.setAttribute('data-initialized', 'true');
|
||||||
|
|
||||||
const openSheet = () => {{
|
const openSheet = () => {{
|
||||||
if (window.ScrollLock) window.ScrollLock.lock();
|
if (window.ScrollLock) window.ScrollLock.lock();
|
||||||
sheet.setAttribute('data-state', 'open');
|
sheet.setAttribute('data-state', 'open');
|
||||||
backdrop.setAttribute('data-state', 'open');
|
backdrop.setAttribute('data-state', 'open');
|
||||||
sheet.style.pointerEvents = 'auto';
|
sheet.style.pointerEvents = 'auto';
|
||||||
backdrop.style.pointerEvents = 'auto';
|
backdrop.style.pointerEvents = 'auto';
|
||||||
const direction = sheet.getAttribute('data-direction');
|
|
||||||
sheet.classList.remove('translate-x-full', '-translate-x-full', 'translate-y-full', '-translate-y-full');
|
sheet.classList.remove('translate-x-full', '-translate-x-full', 'translate-y-full', '-translate-y-full');
|
||||||
sheet.classList.add('translate-x-0', 'translate-y-0');
|
sheet.classList.add('translate-x-0', 'translate-y-0');
|
||||||
}};
|
}};
|
||||||
|
|
||||||
const closeSheet = () => {{
|
const closeSheet = () => {{
|
||||||
sheet.setAttribute('data-state', 'closed');
|
sheet.setAttribute('data-state', 'closed');
|
||||||
backdrop.setAttribute('data-state', 'closed');
|
backdrop.setAttribute('data-state', 'closed');
|
||||||
sheet.style.pointerEvents = 'none';
|
sheet.style.pointerEvents = 'none';
|
||||||
backdrop.style.pointerEvents = 'none';
|
backdrop.style.pointerEvents = 'none';
|
||||||
const direction = sheet.getAttribute('data-direction');
|
|
||||||
sheet.classList.remove('translate-x-0', 'translate-y-0');
|
sheet.classList.remove('translate-x-0', 'translate-y-0');
|
||||||
|
const direction = sheet.getAttribute('data-direction');
|
||||||
if (direction === 'Right') sheet.classList.add('translate-x-full');
|
if (direction === 'Right') sheet.classList.add('translate-x-full');
|
||||||
else if (direction === 'Left') sheet.classList.add('-translate-x-full');
|
else if (direction === 'Left') sheet.classList.add('-translate-x-full');
|
||||||
else if (direction === 'Top') sheet.classList.add('-translate-y-full');
|
|
||||||
else if (direction === 'Bottom') sheet.classList.add('translate-y-full');
|
|
||||||
if (window.ScrollLock) window.ScrollLock.unlock(300);
|
if (window.ScrollLock) window.ScrollLock.unlock(300);
|
||||||
}};
|
}};
|
||||||
|
|
||||||
trigger.addEventListener('click', openSheet);
|
trigger.addEventListener('click', openSheet);
|
||||||
const closeButtons = sheet.querySelectorAll('[data-sheet-close]');
|
sheet.querySelectorAll('[data-sheet-close]').forEach(btn => btn.addEventListener('click', closeSheet));
|
||||||
closeButtons.forEach(btn => btn.addEventListener('click', closeSheet));
|
|
||||||
backdrop.addEventListener('click', closeSheet);
|
backdrop.addEventListener('click', closeSheet);
|
||||||
document.addEventListener('keydown', (e) => {{
|
|
||||||
if (e.key === 'Escape' && sheet.getAttribute('data-state') === 'open') {{
|
|
||||||
e.preventDefault();
|
|
||||||
closeSheet();
|
|
||||||
}}
|
|
||||||
}});
|
|
||||||
}};
|
}};
|
||||||
|
setupSheet();
|
||||||
if (document.readyState === 'loading') {{
|
|
||||||
document.addEventListener('DOMContentLoaded', setupSheet);
|
|
||||||
}} else {{
|
|
||||||
setupSheet();
|
|
||||||
}}
|
|
||||||
}})();
|
}})();
|
||||||
"#,
|
"#, target_id_for_script, backdrop_id_for_script, target_id_for_script)}
|
||||||
target_id_for_script,
|
|
||||||
backdrop_id_for_script,
|
|
||||||
target_id_for_script,
|
|
||||||
)}
|
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ ENUM ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, strum::AsRefStr, strum::Display)]
|
#[derive(Clone, Copy, strum::AsRefStr, strum::Display)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub enum SheetDirection {
|
pub enum SheetDirection {
|
||||||
Right,
|
Right, Left, Top, Bottom,
|
||||||
Left,
|
|
||||||
Top,
|
|
||||||
Bottom,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SheetDirection {
|
impl SheetDirection {
|
||||||
@@ -224,7 +149,6 @@ impl SheetDirection {
|
|||||||
SheetDirection::Bottom => "translate-y-full",
|
SheetDirection::Bottom => "translate-y-full",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn initial_position(self) -> &'static str {
|
fn initial_position(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
SheetDirection::Right => "top-0 right-0 h-full w-[400px]",
|
SheetDirection::Right => "top-0 right-0 h-full w-[400px]",
|
||||||
|
|||||||
@@ -1,232 +1,65 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_router::hooks::use_location;
|
use tw_merge::tw_merge;
|
||||||
use leptos_ui::{clx, variants, void};
|
use crate::components::hooks::use_random::use_random_id_for;
|
||||||
|
|
||||||
mod components {
|
#[derive(Clone, Copy, strum::AsRefStr, strum::Display)]
|
||||||
use super::*;
|
#[allow(dead_code)]
|
||||||
clx! {SidenavWrapper, div, "group/sidenav-wrapper has-data-[variant=Inset]:bg-sidenav flex h-full w-full"}
|
pub enum SidenavSide { Left, Right }
|
||||||
// clx! {SidenavInset, div, "bg-background relative flex w-full flex-1 flex-col md:peer-data-[variant=Inset]:m-2 md:peer-data-[variant=Inset]:ml-0 md:peer-data-[variant=Inset]:rounded-xl md:peer-data-[variant=Inset]:shadow-sm md:peer-data-[variant=Inset]:peer-data-[state=Collapsed]:ml-2"}
|
|
||||||
clx! {SidenavInset, div, "bg-background relative flex w-full flex-1 flex-col data-[variant=Inset]:rounded-lg data-[variant=Inset]:border data-[variant=Inset]:border-sidenav-border data-[variant=Inset]:shadow-sm data-[variant=Inset]:m-2"}
|
|
||||||
// * data-[], not group-data-[]
|
|
||||||
clx! {SidenavInner, div, "flex flex-col w-full h-full bg-sidenav data-[variant=Floating]:rounded-lg data-[variant=Floating]:border data-[variant=Floating]:border-sidenav-border data-[variant=Floating]:shadow-sm"}
|
|
||||||
clx! {SidenavHeader, div, "flex flex-col gap-2 p-2"}
|
|
||||||
clx! {SidenavMenu, ul, "flex flex-col gap-1 w-full min-w-0"}
|
|
||||||
clx! {SidenavMenuSub, ul, "border-sidenav-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5 group-data-[collapsible=Icon]:hidden"}
|
|
||||||
clx! {SidenavMenuItem, li, "relative group/menu-item"}
|
|
||||||
clx! {SidenavContent, div, "scrollbar__on_hover", "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=Icon]:overflow-hidden"}
|
|
||||||
clx! {SidenavGroup, div, "flex relative flex-col p-2 w-full min-w-0"}
|
|
||||||
clx! {SidenavGroupContent, div, "w-full text-sm"}
|
|
||||||
clx! {SidenavGroupLabel, div, "text-sidenav-foreground/70 ring-sidenav-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 group-data-[collapsible=Icon]:-mt-8 group-data-[collapsible=Icon]:opacity-0"}
|
|
||||||
clx! {SidenavFooter, footer, "flex flex-col gap-2 p-2"}
|
|
||||||
// Button "More"
|
|
||||||
clx! {DropdownMenuTriggerEllipsis, button, "text-sidenav-foreground ring-sidenav-ring hover:bg-sidenav-accent hover:text-sidenav-accent-foreground peer-hover/menu-button:text-sidenav-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden peer-data-[size=sm]/menu-button:top-1 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 group-data-[collapsible=Icon]:hidden peer-data-[active=true]/menu-button:text-sidenav-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0"}
|
|
||||||
|
|
||||||
void! {SidenavInput, input,
|
#[derive(Clone, Copy, strum::AsRefStr, strum::Display)]
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
#[allow(dead_code)]
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50",
|
pub enum SidenavCollapsible { Off, Icon }
|
||||||
"focus-visible:ring-2", // TODO. Port tw_merge to Tailwind V4.
|
|
||||||
// "focus-visible:ring-[3px]", // TODO. Port tw_merge to Tailwind V4.
|
#[derive(Clone)]
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
pub struct SidenavContext {
|
||||||
"read-only:bg-muted",
|
pub state: RwSignal<SidenavState>,
|
||||||
// Specific to Sidenav
|
|
||||||
"w-full h-8 shadow-none bg-background"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub use components::*;
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
#[allow(dead_code)]
|
||||||
/* ========================================================== */
|
pub enum SidenavState { #[default] Expanded, Collapsed }
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SidenavLink(
|
pub fn SidenavWrapper(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
children: Children,
|
let state = RwSignal::new(SidenavState::Expanded);
|
||||||
#[prop(into)] href: String,
|
provide_context(SidenavContext { state });
|
||||||
#[prop(optional, into)] class: String,
|
let class = tw_merge!("flex min-h-screen w-full bg-background", class);
|
||||||
) -> impl IntoView {
|
view! { <div class=class>{children()}</div> }
|
||||||
let merged_class = tw_merge!(
|
}
|
||||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left outline-hidden ring-sidenav-ring transition-[width,height,padding] focus-visible:ring-2 active:bg-sidenav-accent active:text-sidenav-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidenav=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-[current=page]:bg-sidenav-accent aria-[current=page]:font-semibold aria-[current=page]:text-sidenav-accent-foreground data-[state=open]:hover:bg-sidenav-accent data-[state=open]:hover:text-sidenav-accent-foreground group-data-[collapsible=Icon]:size-8! group-data-[collapsible=Icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 hover:bg-sidenav-accent hover:text-sidenav-accent-foreground h-8 text-sm",
|
|
||||||
|
#[component]
|
||||||
|
pub fn Sidenav(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let ctx = expect_context::<SidenavContext>();
|
||||||
|
let class = tw_merge!(
|
||||||
|
"hidden md:flex flex-col border-r bg-card transition-all duration-300",
|
||||||
|
move || match ctx.state.get() {
|
||||||
|
SidenavState::Expanded => "w-[var(--sidenav-width)]",
|
||||||
|
SidenavState::Collapsed => "w-[var(--sidenav-width-icon)]",
|
||||||
|
},
|
||||||
class
|
class
|
||||||
);
|
);
|
||||||
|
view! { <aside class=class>{children()}</aside> }
|
||||||
let location = use_location();
|
|
||||||
|
|
||||||
// Check if the link is active based on current path
|
|
||||||
let href_clone = href.clone();
|
|
||||||
let is_active = move || {
|
|
||||||
let path = location.pathname.get();
|
|
||||||
path == href_clone || path.starts_with(&format!("{}/", href_clone))
|
|
||||||
};
|
|
||||||
|
|
||||||
let aria_current = move || if is_active() { "page" } else { "false" };
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<a data-name="SidenavLink" class=merged_class href=href aria-current=aria_current>
|
|
||||||
{children()}
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
variants! {
|
|
||||||
SidenavMenuButton {
|
|
||||||
base: "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidenav-ring transition-[width,height,padding] hover:bg-sidenav-accent hover:text-sidenav-accent-foreground focus-visible:ring-2 active:bg-sidenav-accent active:text-sidenav-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidenav=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-[current=page]:bg-sidenav-accent aria-[current=page]:font-medium aria-[current=page]:text-sidenav-accent-foreground data-[state=open]:hover:bg-sidenav-accent data-[state=open]:hover:text-sidenav-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 group-data-[collapsible=Icon]:size-8! group-data-[collapsible=Icon]:p-0! [&>svg]:stroke-2 aria-[current=page]:[&>svg]:stroke-[2.7]",
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
Default: "hover:bg-sidenav-accent hover:text-sidenav-accent-foreground", // Already in base
|
|
||||||
Outline: "bg-background shadow-[0_0_0_1px_hsl(var(--sidenav-border))] hover:bg-sidenav-accent hover:text-sidenav-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidenav-accent))]",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
Default: "h-8 text-sm",
|
|
||||||
Sm: "h-7 text-xs",
|
|
||||||
Lg: "h-12",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
component: {
|
|
||||||
element: button,
|
|
||||||
support_href: true,
|
|
||||||
support_aria_current: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================================== */
|
|
||||||
/* ✨ FUNCTIONS ✨ */
|
|
||||||
/* ========================================================== */
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, strum::IntoStaticStr)]
|
|
||||||
pub enum SidenavVariant {
|
|
||||||
#[default]
|
|
||||||
Sidenav,
|
|
||||||
Floating,
|
|
||||||
Inset,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
|
|
||||||
pub enum SidenavSide {
|
|
||||||
#[default]
|
|
||||||
Left,
|
|
||||||
Right,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Clone, Copy, PartialEq, Eq, strum::Display)]
|
|
||||||
pub enum SidenavCollapsible {
|
|
||||||
#[default]
|
|
||||||
Offcanvas,
|
|
||||||
None,
|
|
||||||
Icon,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Sidenav(
|
pub fn SidenavInset(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
#[prop(into, optional)] class: String,
|
let class = tw_merge!("flex flex-col flex-1 min-w-0", class);
|
||||||
#[prop(default = SidenavVariant::default())] variant: SidenavVariant,
|
view! { <main class=class>{children()}</main> }
|
||||||
#[prop(default = SidenavState::default())] data_state: SidenavState,
|
|
||||||
#[prop(default = SidenavSide::default())] data_side: SidenavSide,
|
|
||||||
#[prop(default = SidenavCollapsible::default())] data_collapsible: SidenavCollapsible,
|
|
||||||
children: Children,
|
|
||||||
) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
{if data_collapsible == SidenavCollapsible::None {
|
|
||||||
view! {
|
|
||||||
<aside
|
|
||||||
data-name="Sidenav"
|
|
||||||
class=tw_merge!(
|
|
||||||
"flex flex-col h-full bg-sidenav text-sidenav-foreground w-(--sidenav-width)", class.clone()
|
|
||||||
)
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
</aside>
|
|
||||||
}
|
|
||||||
.into_any()
|
|
||||||
} else {
|
|
||||||
view! {
|
|
||||||
<aside
|
|
||||||
data-name="Sidenav"
|
|
||||||
data-sidenav=data_state.to_string()
|
|
||||||
data-side=data_side.to_string()
|
|
||||||
class="hidden md:block group peer text-sidenav-foreground data-[state=Collapsed]:hidden"
|
|
||||||
>
|
|
||||||
// * SidenavGap: This is what handles the sidenav gap on desktop
|
|
||||||
<div
|
|
||||||
data-name="SidenavGap"
|
|
||||||
class=tw_merge!(
|
|
||||||
"relative w-(--sidenav-width) bg-transparent transition-[width] duration-200 ease-linear",
|
|
||||||
"group-data-[collapsible=Offcanvas]:w-0",
|
|
||||||
"group-data-[side=Right]:rotate-180",
|
|
||||||
match variant {
|
|
||||||
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:w-(--sidenav-width-icon)",
|
|
||||||
SidenavVariant::Floating | SidenavVariant::Inset =>
|
|
||||||
"group-data-[collapsible=Icon]:w-[calc(var(--sidenav-width-icon)+(--spacing(4)))]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
data-name="SidenavContainer"
|
|
||||||
class=tw_merge!(
|
|
||||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidenav-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
|
||||||
class,
|
|
||||||
match data_side {
|
|
||||||
SidenavSide::Left => "left-0 group-data-[collapsible=Offcanvas]:left-[calc(var(--sidenav-width)*-1)]",
|
|
||||||
SidenavSide::Right => "right-0 group-data-[collapsible=Offcanvas]:right-[calc(var(--sidenav-width)*-1)]"
|
|
||||||
},
|
|
||||||
match variant {
|
|
||||||
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:w-(--sidenav-width-icon) group-data-[side=Left]:border-r group-data-[side=Right]:border-l",
|
|
||||||
SidenavVariant::Floating | SidenavVariant::Inset =>
|
|
||||||
"p-2 group-data-[collapsible=Icon]:w-[calc(var(--sidenav-width-icon)+(--spacing(4))+2px)]",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
>
|
|
||||||
// * Act as a Sidenav for the onclick trigger to work with nested Sidenavs.
|
|
||||||
<SidenavInner attr:data-sidenav="Sidenav" attr:data-variant=variant.to_string()>
|
|
||||||
{children()}
|
|
||||||
<SidenavToggleRail />
|
|
||||||
</SidenavInner>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
}
|
|
||||||
.into_any()
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================================== */
|
#[component]
|
||||||
/* ✨ FUNCTIONS ✨ */
|
pub fn SidenavLink(children: Children, #[prop(into)] href: String, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
/* ========================================================== */
|
let _ = (href, class);
|
||||||
|
view! { <div>{children()}</div> }
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
|
|
||||||
pub enum SidenavState {
|
|
||||||
#[default]
|
|
||||||
Expanded,
|
|
||||||
Collapsed,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ONCLICK_TRIGGER: &str = "document.querySelector('[data-name=\"Sidenav\"]').setAttribute('data-state', document.querySelector('[data-name=\"Sidenav\"]').getAttribute('data-state') === 'Collapsed' ? 'Expanded' : 'Collapsed')";
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SidenavTrigger(children: Children) -> impl IntoView {
|
pub fn SidenavTrigger(children: Children) -> impl IntoView {
|
||||||
|
let _ = children;
|
||||||
|
let ctx = expect_context::<SidenavContext>();
|
||||||
view! {
|
view! {
|
||||||
// TODO. Use Button.
|
<button on:click=move |_| ctx.state.update(|s| *s = if *s == SidenavState::Expanded { SidenavState::Collapsed } else { SidenavState::Expanded })>
|
||||||
|
"Toggle"
|
||||||
<button
|
|
||||||
onclick=ONCLICK_TRIGGER
|
|
||||||
data-name="SidenavTrigger"
|
|
||||||
class="inline-flex gap-2 justify-center items-center -ml-1 text-sm font-medium whitespace-nowrap rounded-md transition-all outline-none disabled:opacity-50 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 aria-invalid:ring-destructive/20 aria-invalid:border-destructive size-7 dark:aria-invalid:ring-destructive/40 dark:hover:bg-accent/50 hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
|
||||||
fn SidenavToggleRail() -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<button
|
|
||||||
data-name="SidenavToggleRail"
|
|
||||||
aria-label="Toggle Sidenav"
|
|
||||||
tabindex="-1"
|
|
||||||
onclick=ONCLICK_TRIGGER
|
|
||||||
class="hidden absolute inset-y-0 z-20 w-4 transition-all ease-linear -translate-x-1/2 sm:flex group-data-[side=Left]:-right-4 group-data-[side=Right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] in-data-[side=Left]:cursor-w-resize in-data-[side=Right]:cursor-e-resize [[data-side=Left][data-state=Collapsed]_&]:cursor-e-resize [[data-side=Right][data-state=Collapsed]_&]:cursor-w-resize group-data-[collapsible=Offcanvas]:translate-x-0 group-data-[collapsible=Offcanvas]:after:left-full [[data-side=Left][data-collapsible=Offcanvas]_&]:-right-2 [[data-side=Right][data-collapsible=Offcanvas]_&]:-left-0eft-2 hover:after:bg-sidenav-border hover:group-data-[collapsible=Offcanvas]:bg-sidenav"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,7 @@ pub fn Table(children: Children, #[prop(optional, into)] class: String) -> impl
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn TableCaption(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
pub fn TableCaption(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let _ = class;
|
||||||
let class = tw_merge!("mt-4 text-sm text-muted-foreground", class);
|
let class = tw_merge!("mt-4 text-sm text-muted-foreground", class);
|
||||||
view! { <caption class=class>{children()}</caption> }
|
view! { <caption class=class>{children()}</caption> }
|
||||||
}
|
}
|
||||||
@@ -55,6 +56,7 @@ pub fn TableCell(children: Children, #[prop(optional, into)] class: String) -> i
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn TableFooter(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
pub fn TableFooter(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let _ = class;
|
||||||
let class = tw_merge!("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", class);
|
let class = tw_merge!("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", class);
|
||||||
view! { <tfoot class=class>{children()}</tfoot> }
|
view! { <tfoot class=class>{children()}</tfoot> }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user