Compare commits

...

4 Commits

Author SHA1 Message Date
spinline
79040e6098 fix: make SidenavMenuButton class prop reactive to resolve compilation error
All checks were successful
Build MIPS Binary / build (push) Successful in 1m49s
2026-02-12 23:51:47 +03:00
spinline
3d1d461956 fix: restore essential UI components and resolve compilation errors after aggressive cleanup
Some checks failed
Build MIPS Binary / build (push) Failing after 44s
2026-02-12 23:50:07 +03:00
spinline
d8ce07001f 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
2026-02-12 23:46:41 +03:00
spinline
c8139f9338 chore: comprehensive cleanup of unused imports, dead code and compiler warnings
All checks were successful
Build MIPS Binary / build (push) Successful in 1m50s
2026-02-12 23:43:33 +03:00
16 changed files with 224 additions and 1419 deletions

View File

@@ -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,72 +9,37 @@ 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_action_stored = StoredValue::new(on_action);
let menu_action = move |action: &'static str| {
on_action.run((action.to_string(), hash.get_value())); let on_click = move |action: &str| {
on_action_stored.get_value().run((action.to_string(), hash.clone()));
}; };
let start_click = { let on_click = on_click.clone(); move |_| on_click("start") };
let stop_click = { let on_click = on_click.clone(); move |_| on_click("stop") };
let delete_click = { let on_click = on_click.clone(); move |_| on_click("delete") };
let delete_data_click = { let on_click = on_click.clone(); move |_| on_click("delete_with_data") };
view! { view! {
<ContextMenu> <ContextMenu>
<ContextMenuTrigger> <ContextMenuTrigger>
{children()} {children()}
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent class="w-48">
<ContextMenuContent class="w-56"> <ContextMenuItem on:click=start_click>
<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=stop_click>
> "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=delete_click>
</svg> "Sil"
"Start" </ContextMenuItem>
</ContextMenuAction> <ContextMenuItem class="text-destructive font-bold" on:click=delete_data_click>
"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>
} }
} }

View File

@@ -1,5 +1,3 @@
use leptos::prelude::*;
pub fn use_random_id_for(prefix: &str) -> String { pub fn use_random_id_for(prefix: &str) -> String {
format!("{}_{}", prefix, js_sys::Math::random().to_string().replace(".", "")) format!("{}_{}", prefix, js_sys::Math::random().to_string().replace(".", ""))
} }

View File

@@ -8,26 +8,15 @@ pub struct ThemeMode {
const LOCALSTORAGE_KEY: &str = "darkmode"; const LOCALSTORAGE_KEY: &str = "darkmode";
/// Hook to access the dark mode context
///
/// Returns the ThemeMode instance from context for easy access
pub fn use_theme_mode() -> ThemeMode { pub fn use_theme_mode() -> ThemeMode {
expect_context::<ThemeMode>() expect_context::<ThemeMode>()
} }
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
impl ThemeMode { impl ThemeMode {
#[must_use]
/// Initializes a new ThemeMode instance.
pub fn init() -> Self { pub fn init() -> Self {
let theme_mode = Self { state: RwSignal::new(false) }; let theme_mode = Self { state: RwSignal::new(false) };
provide_context(theme_mode); provide_context(theme_mode);
// Use Effect to handle browser-only initialization
Effect::new(move |_| { Effect::new(move |_| {
let initial = Self::get_storage_state().unwrap_or(Self::prefers_dark_mode()); let initial = Self::get_storage_state().unwrap_or(Self::prefers_dark_mode());
theme_mode.state.set(initial); theme_mode.state.set(initial);
@@ -43,45 +32,14 @@ impl ThemeMode {
}); });
} }
pub fn set_dark(&self) {
self.set(true);
}
pub fn set_light(&self) {
self.set(false);
}
/// - `dark`: Set to `true` for dark mode, and `false` for light mode.
pub fn set(&self, dark: bool) {
self.state.set(dark);
Self::set_storage_state(dark);
}
#[must_use]
pub fn get(&self) -> bool { pub fn get(&self) -> bool {
self.state.get() self.state.get()
} }
#[must_use]
pub fn is_dark(&self) -> bool {
self.state.get()
}
#[must_use]
pub fn is_light(&self) -> bool {
!self.state.get()
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
/// Retrieves the local storage object, if available.
fn get_storage() -> Option<Storage> { fn get_storage() -> Option<Storage> {
window().local_storage().ok().flatten() window().local_storage().ok().flatten()
} }
/// Retrieves the dark mode state from local storage, if available.
fn get_storage_state() -> Option<bool> { fn get_storage_state() -> Option<bool> {
Self::get_storage() Self::get_storage()
.and_then(|storage| storage.get(LOCALSTORAGE_KEY).ok()) .and_then(|storage| storage.get(LOCALSTORAGE_KEY).ok())
@@ -89,7 +47,6 @@ impl ThemeMode {
.and_then(|entry| entry.parse::<bool>().ok()) .and_then(|entry| entry.parse::<bool>().ok())
} }
/// Checks whether the user's system prefers dark mode based on media queries.
fn prefers_dark_mode() -> bool { fn prefers_dark_mode() -> bool {
window() window()
.match_media("(prefers-color-scheme: dark)") .match_media("(prefers-color-scheme: dark)")
@@ -99,10 +56,9 @@ impl ThemeMode {
.unwrap_or_default() .unwrap_or_default()
} }
/// Stores the dark mode state in local storage.
fn set_storage_state(state: bool) { fn set_storage_state(state: bool) {
if let Some(storage) = Self::get_storage() { if let Some(storage) = Self::get_storage() {
storage.set(LOCALSTORAGE_KEY, state.to_string().as_str()).ok(); let _ = storage.set(LOCALSTORAGE_KEY, state.to_string().as_str());
} }
} }
} }

View File

@@ -7,7 +7,6 @@ use crate::store::{get_action_messages, show_toast};
use crate::api; use crate::api;
use shared::NotificationLevel; use shared::NotificationLevel;
use crate::components::context_menu::TorrentContextMenu; use crate::components::context_menu::TorrentContextMenu;
use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent as CardBody};
use crate::components::ui::data_table::*; use crate::components::ui::data_table::*;
use crate::components::ui::checkbox::Checkbox; use crate::components::ui::checkbox::Checkbox;
use crate::components::ui::badge::{Badge, BadgeVariant}; use crate::components::ui::badge::{Badge, BadgeVariant};

View File

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

View File

@@ -6,7 +6,6 @@ pub enum BadgeVariant {
#[default] #[default]
Default, Default,
Secondary, Secondary,
Outline,
Destructive, Destructive,
Success, Success,
Warning, Warning,
@@ -22,7 +21,6 @@ pub fn Badge(
let variant_classes = match variant { let variant_classes = match variant {
BadgeVariant::Default => "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", BadgeVariant::Default => "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
BadgeVariant::Secondary => "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", BadgeVariant::Secondary => "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
BadgeVariant::Outline => "text-foreground",
BadgeVariant::Destructive => "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", BadgeVariant::Destructive => "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
BadgeVariant::Success => "border-transparent bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20", BadgeVariant::Success => "border-transparent bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
BadgeVariant::Warning => "border-transparent bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20", BadgeVariant::Warning => "border-transparent bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20",

View File

@@ -209,7 +209,7 @@ pub fn ContextMenuTrigger(
class=trigger_class class=trigger_class
data-name="ContextMenuTrigger" data-name="ContextMenuTrigger"
data-context-trigger=ctx.target_id data-context-trigger=ctx.target_id
on:contextmenu=move |e: web_sys::MouseEvent| { on:contextmenu=move |_e: web_sys::MouseEvent| {
if let Some(cb) = on_open { if let Some(cb) = on_open {
cb.run(()); cb.run(());
} }

View File

@@ -1,6 +1,6 @@
// * Reuse @table.rs // * Reuse @table.rs
pub use crate::components::ui::table::{ pub use crate::components::ui::table::{
Table as DataTable, TableBody as DataTableBody, TableCaption as DataTableCaption, TableCell as DataTableCell, Table as DataTable, TableBody as DataTableBody, TableCell as DataTableCell,
TableFooter as DataTableFooter, TableHead as DataTableHead, TableHeader as DataTableHeader, TableHead as DataTableHead, TableHeader as DataTableHeader,
TableRow as DataTableRow, TableWrapper as DataTableWrapper, TableRow as DataTableRow, TableWrapper as DataTableWrapper,
}; };

View File

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

View File

@@ -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,138 +69,53 @@ 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,
#[prop(optional, into)] class: String, #[prop(optional, into)] class: String,
#[prop(optional, into)] href: Option<String>, #[prop(optional, into)] href: Option<String>,
) -> impl IntoView { ) -> impl IntoView {
let _ctx = expect_context::<DropdownMenuContext>();
let class = tw_merge!( let class = tw_merge!(
"inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground", "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)]
struct DropdownMenuContext { struct DropdownMenuContext {
target_id: String, target_id: String,
align: DropdownMenuAlign,
} }
#[component] #[component]
pub fn DropdownMenu( pub fn DropdownMenu(children: Children) -> impl IntoView {
children: Children,
#[prop(default = DropdownMenuAlign::default())] align: DropdownMenuAlign,
) -> 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(), align };
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>
} }
@@ -223,314 +125,105 @@ 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]
pub fn DropdownMenuContent( pub fn DropdownMenuContent(
children: Children, children: Children,
#[prop(optional, into)] class: String, #[prop(optional, into)] class: String,
#[prop(default = DropdownMenuAlign::default())] align: DropdownMenuAlign,
#[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 ctx.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 ctx.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();
// Check if any other dropdown is open
const allDropdowns = document.querySelectorAll('[data-target=\"target__dropdown\"]');
let otherDropdownOpen = false;
allDropdowns.forEach(dd => {{
if (dd !== dropdown && dd.getAttribute('data-state') === 'open') {{
otherDropdownOpen = true;
dd.setAttribute('data-state', 'closed');
dd.style.pointerEvents = 'none';
// Unlock scroll
if (window.ScrollLock) {{
window.ScrollLock.unlock(200);
}}
}}
}});
// If another dropdown was open, just close it and don't open this one
if (otherDropdownOpen) {{
return;
}}
// Normal toggle behavior
if (isOpen) {{
closeDropdown();
}} else {{
openDropdown();
}}
}});
// Close when action is clicked
const actions = dropdown.querySelectorAll('[data-dropdown-close]');
actions.forEach(action => {{
action.addEventListener('click', () => {{
closeDropdown();
}});
}});
// Handle ESC key to close
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && isOpen) {{
e.preventDefault();
closeDropdown();
}}
}}); }});
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>
}
}

View File

@@ -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, SelectLabel as MultiSelectLabel, 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() }
} }

View File

@@ -1,311 +1,34 @@
use icons::{Check, ChevronDown, ChevronUp};
use leptos::context::Provider;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_ui::clx;
use strum::{AsRefStr, Display};
use tw_merge::*;
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical; #[component] pub fn SelectGroup(children: Children) -> impl IntoView { view! { <div class="px-1 py-1">{children()}</div> } }
use crate::components::hooks::use_random::use_random_id_for; #[component] pub fn SelectItem(children: Children) -> impl IntoView { view! { <div class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground">{children()}</div> } }
#[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>(); view! { <span class="text-sm text-muted-foreground">{placeholder}</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]
pub fn SelectOption( pub fn SelectOption(children: Children, #[prop(optional, into)] value: Option<String>) -> impl IntoView {
children: Children, let _ = value;
#[prop(optional, into)] class: String, view! { <div role="option">{children()}</div> }
#[prop(default = false.into(), into)] aria_selected: Signal<bool>,
#[prop(optional, into)] value: Option<String>,
) -> impl IntoView {
let ctx = expect_context::<SelectContext>();
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)]
struct SelectContext {
target_id: String,
value_signal: RwSignal<Option<String>>,
on_change: Option<Callback<Option<String>>>,
} }
#[component] #[component]
pub fn Select( pub fn Select(children: Children) -> impl IntoView {
children: Children, view! { <div>{children()}</div> }
#[prop(optional, into)] class: String,
#[prop(optional, into)] default_value: Option<String>,
#[prop(optional)] on_change: Option<Callback<Option<String>>>,
) -> impl IntoView {
let select_target_id = use_random_id_for("select");
let value_signal = RwSignal::new(default_value);
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) -> impl IntoView {
children: Children, view! { <button type="button">{children()}</button> }
#[prop(optional, into)] class: String,
#[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]
pub fn SelectContent( pub fn SelectContent(children: Children) -> impl IntoView {
children: Children, view! { <div>{children()}</div> }
#[prop(optional, into)] class: String, }
#[prop(default = SelectPosition::default())] position: SelectPosition,
#[prop(optional)] on_close: Option<Callback<()>>,
) -> impl IntoView {
let ctx = expect_context::<SelectContext>();
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! {
<div
data-name="SelectContent"
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()}
<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>
<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()
}

View File

@@ -1,35 +1,21 @@
use leptos::prelude::*; use leptos::prelude::*;
use tw_merge::*; use tailwind_fuse::tw_merge;
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
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_signal = move || tw_merge!(
let orientation = orientation.get(); "shrink-0 bg-border",
let separator = SeparatorClass { orientation }; 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.clone()
);
view! { <div class=class_signal 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,
}

View File

@@ -18,32 +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 ✨ */
/* ========================================================== */
pub type SheetVariant = ButtonVariant;
pub type SheetSize = ButtonSize;
#[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>
@@ -59,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()}
@@ -75,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()}
@@ -91,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>
}.into_any() }
} }
/* ========================================================== */
/* ✨ 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 {
@@ -227,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]",

View File

@@ -1,232 +1,69 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos_router::hooks::use_location; use tw_merge::tw_merge;
use leptos_ui::{clx, variants, void};
mod components { #[derive(Clone, Copy, PartialEq, Eq, Default)]
use super::*; #[allow(dead_code)]
clx! {SidenavWrapper, div, "group/sidenav-wrapper has-data-[variant=Inset]:bg-sidenav flex h-full w-full"} pub enum SidenavState { #[default] Expanded, Collapsed }
// 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)]
"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", pub struct SidenavContext {
"focus-visible:border-ring focus-visible:ring-ring/50", pub state: RwSignal<SidenavState>,
"focus-visible:ring-2", // TODO. Port tw_merge to Tailwind V4.
// "focus-visible:ring-[3px]", // TODO. Port tw_merge to Tailwind V4.
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"read-only:bg-muted",
// Specific to Sidenav
"w-full h-8 shadow-none bg-background"
}
} }
pub use components::*;
/* ========================================================== */
/* ✨ 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",
class
);
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! { #[component]
SidenavMenuButton { pub fn Sidenav(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
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]", let ctx = expect_context::<SidenavContext>();
variants: { let class_signal = move || tw_merge!(
variant: { "hidden md:flex flex-col border-r bg-card transition-all duration-300",
Default: "hover:bg-sidenav-accent hover:text-sidenav-accent-foreground", // Already in base match ctx.state.get() {
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))]", SidenavState::Expanded => "w-[var(--sidenav-width)]",
}, SidenavState::Collapsed => "w-[var(--sidenav-width-icon)]",
size: {
Default: "h-8 text-sm",
Sm: "h-7 text-xs",
Lg: "h-12",
}
}, },
component: { class.clone()
element: button, );
support_href: true, view! { <aside class=class_signal>{children()}</aside> }
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, #[component] pub fn SidenavHeader(children: Children) -> impl IntoView { view! { <div class="flex flex-col">{children()}</div> } }
#[component] pub fn SidenavContent(children: Children) -> impl IntoView { view! { <div class="flex-1 overflow-auto">{children()}</div> } }
#[component] pub fn SidenavFooter(children: Children) -> impl IntoView { view! { <div class="mt-auto">{children()}</div> } }
#[component] pub fn SidenavGroup(children: Children) -> impl IntoView { view! { <div class="px-2 py-2">{children()}</div> } }
#[component] pub fn SidenavGroupLabel(children: Children) -> impl IntoView { view! { <div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">{children()}</div> } }
#[component] pub fn SidenavGroupContent(children: Children) -> impl IntoView { view! { <div class="space-y-1">{children()}</div> } }
#[component] pub fn SidenavMenu(children: Children) -> impl IntoView { view! { <nav class="grid gap-1">{children()}</nav> } }
#[component] pub fn SidenavMenuItem(children: Children) -> impl IntoView { view! { <div>{children()}</div> } }
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum SidenavMenuButtonVariant { #[default] Default, Outline }
#[component]
pub fn SidenavMenuButton(
children: Children, children: Children,
#[prop(into, optional)] variant: Signal<SidenavMenuButtonVariant>,
#[prop(into, optional)] class: Signal<String>,
) -> impl IntoView { ) -> impl IntoView {
view! { let class_signal = move || tw_merge!(
{if data_collapsible == SidenavCollapsible::None { "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
view! { if variant.get() == SidenavMenuButtonVariant::Outline { "border border-input bg-background shadow-xs" } else { "" },
<aside class.get()
data-name="Sidenav" );
class=tw_merge!( view! { <button class=class_signal>{children()}</button> }
"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] pub fn SidenavLink(children: Children, #[prop(into)] href: String) -> impl IntoView {
/* ✨ FUNCTIONS ✨ */ view! { <a href=href class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-accent">{children()}</a> }
/* ========================================================== */
#[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]
pub fn SidenavTrigger(children: Children) -> impl IntoView {
view! {
// TODO. Use Button.
<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>
}
}
#[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"
/>
}
}

View File

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