feat: resolve complex closure errors and finalize advanced DataTable features
Some checks failed
Build MIPS Binary / build (push) Failing after 1m30s
Some checks failed
Build MIPS Binary / build (push) Failing after 1m30s
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use icons::{ArrowUpDown, Inbox};
|
use icons::{ArrowUpDown, Inbox, Settings2, Play, Square, Trash2, Ellipsis};
|
||||||
use crate::store::{get_action_messages, show_toast};
|
use crate::store::{get_action_messages, show_toast};
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use shared::NotificationLevel;
|
use shared::NotificationLevel;
|
||||||
@@ -9,8 +9,23 @@ use crate::components::context_menu::TorrentContextMenu;
|
|||||||
use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent as CardBody};
|
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::button::{Button, ButtonVariant};
|
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
|
||||||
use crate::components::ui::empty::*;
|
use crate::components::ui::empty::*;
|
||||||
|
use crate::components::ui::input::Input;
|
||||||
|
use crate::components::ui::multi_select::*;
|
||||||
|
use crate::components::ui::dropdown_menu::*;
|
||||||
|
use crate::components::ui::alert_dialog::*;
|
||||||
|
|
||||||
|
const ALL_COLUMNS: [(&str, &str); 8] = [
|
||||||
|
("Name", "Name"),
|
||||||
|
("Size", "Size"),
|
||||||
|
("Progress", "Progress"),
|
||||||
|
("Status", "Status"),
|
||||||
|
("DownSpeed", "DL Speed"),
|
||||||
|
("UpSpeed", "UP Speed"),
|
||||||
|
("ETA", "ETA"),
|
||||||
|
("AddedDate", "Date"),
|
||||||
|
];
|
||||||
|
|
||||||
fn format_bytes(bytes: i64) -> String {
|
fn format_bytes(bytes: i64) -> String {
|
||||||
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
@@ -56,8 +71,13 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
let sort_col = signal(SortColumn::AddedDate);
|
let sort_col = signal(SortColumn::AddedDate);
|
||||||
let sort_dir = signal(SortDirection::Descending);
|
let sort_dir = signal(SortDirection::Descending);
|
||||||
|
|
||||||
// Multi-selection state
|
|
||||||
let selected_hashes = RwSignal::new(HashSet::<String>::new());
|
let selected_hashes = RwSignal::new(HashSet::<String>::new());
|
||||||
|
|
||||||
|
let visible_columns = RwSignal::new(HashSet::from([
|
||||||
|
"Name".to_string(), "Size".to_string(), "Progress".to_string(),
|
||||||
|
"Status".to_string(), "DownSpeed".to_string(), "UpSpeed".to_string(),
|
||||||
|
"ETA".to_string(), "AddedDate".to_string()
|
||||||
|
]));
|
||||||
|
|
||||||
let sorted_hashes_data = Memo::new(move |_| {
|
let sorted_hashes_data = Memo::new(move |_| {
|
||||||
let torrents_map = store.torrents.get();
|
let torrents_map = store.torrents.get();
|
||||||
@@ -116,11 +136,8 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
selected_hashes.update(|selected| {
|
selected_hashes.update(|selected| {
|
||||||
let hashes = filtered_hashes.get_untracked();
|
let hashes = filtered_hashes.get_untracked();
|
||||||
for h in hashes {
|
for h in hashes {
|
||||||
if checked {
|
if checked { selected.insert(h); }
|
||||||
selected.insert(h);
|
else { selected.remove(&h); }
|
||||||
} else {
|
|
||||||
selected.remove(&h);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -138,14 +155,33 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
|
|
||||||
let sort_icon = move |col: SortColumn| {
|
let sort_icon = move |col: SortColumn| {
|
||||||
let is_active = sort_col.0.get() == col;
|
let is_active = sort_col.0.get() == col;
|
||||||
let class = if is_active {
|
let class = if is_active { "size-3 text-primary" } else { "size-3 opacity-30 group-hover:opacity-100 transition-opacity" };
|
||||||
"size-3 opacity-100 text-primary"
|
view! { <ArrowUpDown class=class.to_string() /> }
|
||||||
} else {
|
};
|
||||||
"size-3 opacity-30 group-hover:opacity-100 transition-opacity"
|
|
||||||
};
|
let bulk_action = move |action: &'static str| {
|
||||||
view! {
|
let hashes: Vec<String> = selected_hashes.get().into_iter().collect();
|
||||||
<ArrowUpDown class=class.to_string() />
|
if hashes.is_empty() { return; }
|
||||||
}
|
|
||||||
|
spawn_local(async move {
|
||||||
|
let mut success = true;
|
||||||
|
for hash in hashes {
|
||||||
|
let res = match action {
|
||||||
|
"start" => api::torrent::start(&hash).await,
|
||||||
|
"stop" => api::torrent::stop(&hash).await,
|
||||||
|
"delete" => api::torrent::delete(&hash).await,
|
||||||
|
"delete_with_data" => api::torrent::delete_with_data(&hash).await,
|
||||||
|
_ => Ok(()),
|
||||||
|
};
|
||||||
|
if res.is_err() { success = false; }
|
||||||
|
}
|
||||||
|
if success {
|
||||||
|
show_toast(NotificationLevel::Success, format!("Toplu işlem başarıyla tamamlandı: {}", action));
|
||||||
|
selected_hashes.update(|s| s.clear());
|
||||||
|
} else {
|
||||||
|
show_toast(NotificationLevel::Error, "Bazı işlemler başarısız oldu.");
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_action = Callback::new(move |(action, hash): (String, String)| {
|
let on_action = Callback::new(move |(action, hash): (String, String)| {
|
||||||
@@ -168,15 +204,97 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="h-full bg-background relative flex flex-col overflow-hidden px-4 py-2">
|
<div class="h-full bg-background relative flex flex-col overflow-hidden px-4 py-4 gap-4">
|
||||||
// --- DESKTOP VIEW ---
|
// --- TOPBAR ---
|
||||||
<div class="hidden md:flex flex-col h-full overflow-hidden">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<DataTableWrapper class="flex-1 min-h-0 bg-card/50">
|
<div class="flex items-center gap-2 flex-1 max-w-md">
|
||||||
|
<Input
|
||||||
|
class="h-9"
|
||||||
|
placeholder="Torrent ara..."
|
||||||
|
bind_value=store.search_query
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Show when=move || selected_count.get() > 0>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<Button variant=ButtonVariant::Secondary size=ButtonSize::Sm class="gap-2">
|
||||||
|
<Ellipsis class="size-4" />
|
||||||
|
{move || format!("Toplu İşlem ({})", selected_count.get())}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent class="w-48">
|
||||||
|
<DropdownMenuLabel>"Seçili Torrentler"</DropdownMenuLabel>
|
||||||
|
<DropdownMenuGroup class="mt-2">
|
||||||
|
<DropdownMenuItem on:click=move |_| bulk_action("start")>
|
||||||
|
<Play class="mr-2 size-4" /> "Başlat"
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem on:click=move |_| bulk_action("stop")>
|
||||||
|
<Square class="mr-2 size-4" /> "Durdur"
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<div class="my-1 h-px bg-border" />
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger class="w-full">
|
||||||
|
<DropdownMenuItem class="text-destructive focus:bg-destructive/10">
|
||||||
|
<Trash2 class="mr-2 size-4" /> "Toplu Sil"
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>"Toplu Silme Onayı"</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{move || format!("Seçili {} torrent silinecek. Bu işlem geri alınamaz.", selected_count.get())}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogClose>"İptal"</AlertDialogClose>
|
||||||
|
<Button variant=ButtonVariant::Destructive on:click=move |_| bulk_action("delete")>
|
||||||
|
"Sil"
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<MultiSelect values=visible_columns>
|
||||||
|
<MultiSelectTrigger class="w-[140px] h-9">
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<Settings2 class="size-4" />
|
||||||
|
"Sütunlar"
|
||||||
|
</div>
|
||||||
|
</MultiSelectTrigger>
|
||||||
|
<MultiSelectContent>
|
||||||
|
<MultiSelectGroup>
|
||||||
|
{ALL_COLUMNS.into_iter().map(|(id, label)| {
|
||||||
|
let id_val = id.to_string();
|
||||||
|
view! {
|
||||||
|
<MultiSelectItem>
|
||||||
|
<MultiSelectOption value=id_val.clone() attr:disabled=move || id_val == "Name">
|
||||||
|
{label}
|
||||||
|
</MultiSelectOption>
|
||||||
|
</MultiSelectItem>
|
||||||
|
}.into_any()
|
||||||
|
}).collect_view()}
|
||||||
|
</MultiSelectGroup>
|
||||||
|
</MultiSelectContent>
|
||||||
|
</MultiSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// --- MAIN TABLE ---
|
||||||
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<DataTableWrapper class="h-full bg-card/50">
|
||||||
<div class="h-full overflow-auto">
|
<div class="h-full overflow-auto">
|
||||||
<DataTable>
|
<DataTable>
|
||||||
<DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
<DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
||||||
<DataTableRow class="hover:bg-transparent">
|
<DataTableRow class="hover:bg-transparent">
|
||||||
<DataTableHead class="w-12">
|
<DataTableHead class="w-12 px-4">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked=Signal::derive(move || {
|
checked=Signal::derive(move || {
|
||||||
let hashes = filtered_hashes.get();
|
let hashes = filtered_hashes.get();
|
||||||
@@ -186,38 +304,53 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
/>
|
/>
|
||||||
</DataTableHead>
|
</DataTableHead>
|
||||||
|
|
||||||
// Column Headers with Sorting
|
{move || visible_columns.get().contains("Name").then(|| view! {
|
||||||
<DataTableHead class="cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Name)>
|
<DataTableHead class="cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Name)>
|
||||||
<div class="flex items-center gap-2">"Name" {move || sort_icon(SortColumn::Name)}</div>
|
<div class="flex items-center gap-2">"Name" {move || sort_icon(SortColumn::Name)}</div>
|
||||||
</DataTableHead>
|
</DataTableHead>
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Size)>
|
{move || visible_columns.get().contains("Size").then(|| view! {
|
||||||
<div class="flex items-center gap-2">"Size" {move || sort_icon(SortColumn::Size)}</div>
|
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Size)>
|
||||||
</DataTableHead>
|
<div class="flex items-center gap-2">"Size" {move || sort_icon(SortColumn::Size)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
<DataTableHead class="w-48 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Progress)>
|
{move || visible_columns.get().contains("Progress").then(|| view! {
|
||||||
<div class="flex items-center gap-2">"Progress" {move || sort_icon(SortColumn::Progress)}</div>
|
<DataTableHead class="w-48 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Progress)>
|
||||||
</DataTableHead>
|
<div class="flex items-center gap-2">"Progress" {move || sort_icon(SortColumn::Progress)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Status)>
|
{move || visible_columns.get().contains("Status").then(|| view! {
|
||||||
<div class="flex items-center gap-2">"Status" {move || sort_icon(SortColumn::Status)}</div>
|
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Status)>
|
||||||
</DataTableHead>
|
<div class="flex items-center gap-2">"Status" {move || sort_icon(SortColumn::Status)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
{move || visible_columns.get().contains("DownSpeed").then(|| view! {
|
||||||
<div class="flex items-center justify-end gap-2">"DL Speed" {move || sort_icon(SortColumn::DownSpeed)}</div>
|
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
||||||
</DataTableHead>
|
<div class="flex items-center justify-end gap-2">"DL Speed" {move || sort_icon(SortColumn::DownSpeed)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
|
{move || visible_columns.get().contains("UpSpeed").then(|| view! {
|
||||||
<div class="flex items-center justify-end gap-2">"UP Speed" {move || sort_icon(SortColumn::UpSpeed)}</div>
|
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
|
||||||
</DataTableHead>
|
<div class="flex items-center justify-end gap-2">"UP Speed" {move || sort_icon(SortColumn::UpSpeed)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::ETA)>
|
{move || visible_columns.get().contains("ETA").then(|| view! {
|
||||||
<div class="flex items-center justify-end gap-2">"ETA" {move || sort_icon(SortColumn::ETA)}</div>
|
<DataTableHead class="w-24 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::ETA)>
|
||||||
</DataTableHead>
|
<div class="flex items-center justify-end gap-2">"ETA" {move || sort_icon(SortColumn::ETA)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
<DataTableHead class="w-32 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::AddedDate)>
|
{move || visible_columns.get().contains("AddedDate").then(|| view! {
|
||||||
<div class="flex items-center justify-end gap-2">"Date" {move || sort_icon(SortColumn::AddedDate)}</div>
|
<DataTableHead class="w-32 cursor-pointer group select-none text-right" on:click=move |_| handle_sort(SortColumn::AddedDate)>
|
||||||
</DataTableHead>
|
<div class="flex items-center justify-end gap-2">"Date" {move || sort_icon(SortColumn::AddedDate)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
}).into_any()}
|
||||||
</DataTableRow>
|
</DataTableRow>
|
||||||
</DataTableHeader>
|
</DataTableHeader>
|
||||||
<DataTableBody>
|
<DataTableBody>
|
||||||
@@ -225,39 +358,25 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
when=move || !filtered_hashes.get().is_empty()
|
when=move || !filtered_hashes.get().is_empty()
|
||||||
fallback=move || view! {
|
fallback=move || view! {
|
||||||
<DataTableRow class="hover:bg-transparent">
|
<DataTableRow class="hover:bg-transparent">
|
||||||
<DataTableCell attr:colspan="9" class="h-[400px]">
|
<DataTableCell attr:colspan="10" class="h-[400px]">
|
||||||
<Empty class="h-full">
|
<Empty class="h-full">
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant=EmptyMediaVariant::Icon>
|
<EmptyMedia variant=EmptyMediaVariant::Icon>
|
||||||
<Inbox class="size-10" />
|
<Inbox class="size-10 text-muted-foreground" />
|
||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
<EmptyTitle>"Torrent Bulunamadı"</EmptyTitle>
|
<EmptyTitle>"Torrent Bulunamadı"</EmptyTitle>
|
||||||
<EmptyDescription>
|
<EmptyDescription>
|
||||||
{move || {
|
{move || {
|
||||||
let query = store.search_query.get();
|
let query = store.search_query.get();
|
||||||
if query.is_empty() {
|
if query.is_empty() { "Henüz torrent bulunmuyor.".to_string() }
|
||||||
"Henüz eklenmiş bir torrent bulunmuyor.".to_string()
|
else { "Arama kriterlerinize uygun sonuç bulunamadı.".to_string() }
|
||||||
} else {
|
|
||||||
format!("'{}' araması için sonuç bulunamadı.", query)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
</EmptyDescription>
|
</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
<EmptyContent>
|
|
||||||
<Button
|
|
||||||
variant=ButtonVariant::Outline
|
|
||||||
on:click=move |_| {
|
|
||||||
store.search_query.set(String::new());
|
|
||||||
store.filter.set(crate::store::FilterStatus::All);
|
|
||||||
}
|
|
||||||
>
|
|
||||||
"Tümünü Göster"
|
|
||||||
</Button>
|
|
||||||
</EmptyContent>
|
|
||||||
</Empty>
|
</Empty>
|
||||||
</DataTableCell>
|
</DataTableCell>
|
||||||
</DataTableRow>
|
</DataTableRow>
|
||||||
}
|
}.into_any()
|
||||||
>
|
>
|
||||||
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
||||||
let on_action = on_action.clone();
|
let on_action = on_action.clone();
|
||||||
@@ -272,6 +391,7 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
hash=hash.clone()
|
hash=hash.clone()
|
||||||
on_action=on_action.clone()
|
on_action=on_action.clone()
|
||||||
is_selected=is_selected
|
is_selected=is_selected
|
||||||
|
visible_columns=visible_columns
|
||||||
on_select=Callback::new(move |checked| {
|
on_select=Callback::new(move |checked| {
|
||||||
selected_hashes.update(|selected| {
|
selected_hashes.update(|selected| {
|
||||||
if checked { selected.insert(h_for_change.clone()); }
|
if checked { selected.insert(h_for_change.clone()); }
|
||||||
@@ -287,44 +407,16 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</DataTableWrapper>
|
</DataTableWrapper>
|
||||||
|
|
||||||
// Selection Info Footer
|
|
||||||
<div class="flex items-center justify-between py-2 text-xs text-muted-foreground">
|
|
||||||
<div>
|
|
||||||
{move || format!("{} / {} torrent seçili", selected_count.get(), filtered_hashes.get().len())}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// --- MOBILE VIEW ---
|
<div class="hidden md:flex items-center justify-between px-2 py-1 text-[11px] text-muted-foreground bg-muted/20 border rounded-md">
|
||||||
<div class="md:hidden flex flex-col h-full bg-muted/10 relative overflow-hidden">
|
<div class="flex gap-4">
|
||||||
<div class="flex-1 overflow-y-auto p-3 min-h-0">
|
<span>{move || format!("Toplam: {} torrent", filtered_hashes.get().len())}</span>
|
||||||
<Show
|
<Show when=move || selected_count.get() > 0>
|
||||||
when=move || !filtered_hashes.get().is_empty()
|
<span class="text-primary font-medium">{move || format!("{} torrent seçili", selected_count.get())}</span>
|
||||||
fallback=move || view! {
|
|
||||||
<Empty class="h-64 mt-10">
|
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyMedia variant=EmptyMediaVariant::Icon>
|
|
||||||
<Inbox class="size-10" />
|
|
||||||
</EmptyMedia>
|
|
||||||
<EmptyTitle>"Boş Görünüyor"</EmptyTitle>
|
|
||||||
<EmptyDescription>"Burada gösterilecek bir şey yok."</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
|
||||||
let on_action = on_action.clone();
|
|
||||||
move |hash| {
|
|
||||||
view! {
|
|
||||||
<div class="pb-3">
|
|
||||||
<TorrentCard hash=hash.clone() on_action=on_action.clone() />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} />
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
<div>"VibeTorrent v3"</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
@@ -335,6 +427,7 @@ fn TorrentRow(
|
|||||||
hash: String,
|
hash: String,
|
||||||
on_action: Callback<(String, String)>,
|
on_action: Callback<(String, String)>,
|
||||||
is_selected: Signal<bool>,
|
is_selected: Signal<bool>,
|
||||||
|
visible_columns: RwSignal<HashSet<String>>,
|
||||||
on_select: Callback<bool>,
|
on_select: Callback<bool>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
@@ -357,8 +450,6 @@ fn TorrentRow(
|
|||||||
selected.as_deref() == Some(stored_hash.get_value().as_str())
|
selected.as_deref() == Some(stored_hash.get_value().as_str())
|
||||||
});
|
});
|
||||||
|
|
||||||
let t_name_for_title = t_name.clone();
|
|
||||||
let t_name_for_content = t_name.clone();
|
|
||||||
let h_for_menu = stored_hash.get_value();
|
let h_for_menu = stored_hash.get_value();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
@@ -368,41 +459,90 @@ fn TorrentRow(
|
|||||||
attr:data-state=move || if is_selected.get() || is_active_selection.get() { "selected" } else { "" }
|
attr:data-state=move || if is_selected.get() || is_active_selection.get() { "selected" } else { "" }
|
||||||
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||||
>
|
>
|
||||||
<DataTableCell class="w-12">
|
<DataTableCell class="w-12 px-4">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked=is_selected
|
checked=is_selected
|
||||||
on_checked_change=on_select
|
on_checked_change=on_select
|
||||||
/>
|
/>
|
||||||
</DataTableCell>
|
</DataTableCell>
|
||||||
<DataTableCell class="font-medium truncate max-w-[200px] lg:max-w-md" attr:title=t_name_for_title>
|
|
||||||
{t_name_for_content}
|
{let t_name = t_name.clone();
|
||||||
</DataTableCell>
|
move || visible_columns.get().contains("Name").then({
|
||||||
<DataTableCell class="font-mono text-xs text-muted-foreground">
|
let t_name = t_name.clone();
|
||||||
{format_bytes(t.size)}
|
move || view! {
|
||||||
</DataTableCell>
|
<DataTableCell class="font-medium truncate max-w-[200px] lg:max-w-md" attr:title=t_name.clone()>
|
||||||
<DataTableCell>
|
{t_name.clone()}
|
||||||
<div class="flex items-center gap-2">
|
</DataTableCell>
|
||||||
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden min-w-[80px]">
|
}
|
||||||
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
|
}).into_any()}
|
||||||
</div>
|
|
||||||
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", t.percent_complete)}</span>
|
{move || visible_columns.get().contains("Size").then(|| {
|
||||||
</div>
|
let size_str = format_bytes(t.size);
|
||||||
</DataTableCell>
|
view! {
|
||||||
<DataTableCell class={format!("text-xs font-semibold {}", status_color)}>
|
<DataTableCell class="font-mono text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{format!("{:?}", t.status)}
|
{size_str}
|
||||||
</DataTableCell>
|
</DataTableCell>
|
||||||
<DataTableCell class="text-right font-mono text-xs text-green-600 dark:text-green-500 whitespace-nowrap">
|
}
|
||||||
{format_speed(t.down_rate)}
|
}).into_any()}
|
||||||
</DataTableCell>
|
|
||||||
<DataTableCell class="text-right font-mono text-xs text-blue-600 dark:text-blue-500 whitespace-nowrap">
|
{move || visible_columns.get().contains("Progress").then(|| {
|
||||||
{format_speed(t.up_rate)}
|
let percent = t.percent_complete;
|
||||||
</DataTableCell>
|
view! {
|
||||||
<DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">
|
<DataTableCell>
|
||||||
{format_duration(t.eta)}
|
<div class="flex items-center gap-2">
|
||||||
</DataTableCell>
|
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden min-w-[80px]">
|
||||||
<DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">
|
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", percent)></div>
|
||||||
{format_date(t.added_date)}
|
</div>
|
||||||
</DataTableCell>
|
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", percent)}</span>
|
||||||
|
</div>
|
||||||
|
</DataTableCell>
|
||||||
|
}
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("Status").then(|| {
|
||||||
|
let status_str = format!("{:?}", t.status);
|
||||||
|
view! {
|
||||||
|
<DataTableCell class={format!("text-xs font-semibold whitespace-nowrap {}", status_color)}>
|
||||||
|
{status_str}
|
||||||
|
</DataTableCell>
|
||||||
|
}
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("DownSpeed").then(|| {
|
||||||
|
let speed_str = format_speed(t.down_rate);
|
||||||
|
view! {
|
||||||
|
<DataTableCell class="text-right font-mono text-xs text-green-600 dark:text-green-500 whitespace-nowrap">
|
||||||
|
{speed_str}
|
||||||
|
</DataTableCell>
|
||||||
|
}
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("UpSpeed").then(|| {
|
||||||
|
let speed_str = format_speed(t.up_rate);
|
||||||
|
view! {
|
||||||
|
<DataTableCell class="text-right font-mono text-xs text-blue-600 dark:text-blue-500 whitespace-nowrap">
|
||||||
|
{speed_str}
|
||||||
|
</DataTableCell>
|
||||||
|
}
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("ETA").then(|| {
|
||||||
|
let eta_str = format_duration(t.eta);
|
||||||
|
view! {
|
||||||
|
<DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{eta_str}
|
||||||
|
</DataTableCell>
|
||||||
|
}
|
||||||
|
}).into_any()}
|
||||||
|
|
||||||
|
{move || visible_columns.get().contains("AddedDate").then(|| {
|
||||||
|
let date_str = format_date(t.added_date);
|
||||||
|
view! {
|
||||||
|
<DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{date_str}
|
||||||
|
</DataTableCell>
|
||||||
|
}
|
||||||
|
}).into_any()}
|
||||||
</DataTableRow>
|
</DataTableRow>
|
||||||
</TorrentContextMenu>
|
</TorrentContextMenu>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
|
|||||||
@@ -6,5 +6,12 @@ pub mod context_menu;
|
|||||||
pub mod theme_toggle;
|
pub mod theme_toggle;
|
||||||
pub mod svg_icon;
|
pub mod svg_icon;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
pub mod data_table;pub mod checkbox;
|
pub mod data_table;
|
||||||
|
pub mod checkbox;
|
||||||
pub mod empty;
|
pub mod empty;
|
||||||
|
pub mod multi_select;
|
||||||
|
pub mod dropdown_menu;
|
||||||
|
pub mod alert_dialog;
|
||||||
|
pub mod dialog;
|
||||||
|
pub mod select;
|
||||||
|
pub mod separator;
|
||||||
332
frontend/src/components/ui/select.rs
Normal file
332
frontend/src/components/ui/select.rs
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
use icons::{Check, ChevronDown, ChevronUp};
|
||||||
|
use leptos::context::Provider;
|
||||||
|
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;
|
||||||
|
use crate::components::hooks::use_random::use_random_id_for;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, AsRefStr)]
|
||||||
|
pub enum SelectPosition {
|
||||||
|
#[default]
|
||||||
|
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]
|
||||||
|
pub fn SelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
|
||||||
|
let select_ctx = expect_context::<SelectContext>();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<span data-name="SelectValue" class="text-sm text-muted-foreground truncate">
|
||||||
|
{move || { select_ctx.value_signal.get().unwrap_or_else(|| placeholder.clone()) }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================== */
|
||||||
|
/* ✨ FUNCTIONS ✨ */
|
||||||
|
/* ========================================================== */
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SelectOption(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================== */
|
||||||
|
/* ✨ FUNCTIONS ✨ */
|
||||||
|
/* ========================================================== */
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SelectContext {
|
||||||
|
target_id: String,
|
||||||
|
value_signal: RwSignal<Option<String>>,
|
||||||
|
on_change: Option<Callback<Option<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Select(
|
||||||
|
children: Children,
|
||||||
|
#[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]
|
||||||
|
pub fn SelectTrigger(
|
||||||
|
children: Children,
|
||||||
|
#[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]
|
||||||
|
pub fn SelectContent(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(default = SelectPosition::default())] position: SelectPosition,
|
||||||
|
) -> 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();
|
||||||
|
|
||||||
|
// Scroll indicator signals
|
||||||
|
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<script src="/hooks/lock_scroll.js"></script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-name="SelectContent"
|
||||||
|
class=merged_class
|
||||||
|
id=ctx.target_id
|
||||||
|
data-target="target__select"
|
||||||
|
data-state="closed"
|
||||||
|
data-position=position.to_string()
|
||||||
|
style="pointer-events: none;"
|
||||||
|
on:scroll=on_scroll
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-scroll-up="true"
|
||||||
|
class=move || {
|
||||||
|
if can_scroll_up_signal.get() {
|
||||||
|
"sticky -top-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||||
|
} else {
|
||||||
|
"hidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronUp class="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
{children()}
|
||||||
|
<div
|
||||||
|
data-scroll-down="true"
|
||||||
|
class=move || {
|
||||||
|
if can_scroll_down_signal.get() {
|
||||||
|
"sticky -bottom-1 z-10 flex items-center justify-center py-1 bg-card"
|
||||||
|
} else {
|
||||||
|
"hidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronDown class="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{format!(
|
||||||
|
r#"
|
||||||
|
(function() {{
|
||||||
|
const 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;
|
||||||
|
|
||||||
|
// Determine if dropdown should go above or below
|
||||||
|
if (spaceBelow < 200 && spaceAbove > spaceBelow) {{
|
||||||
|
select.setAttribute('data-position', 'Above');
|
||||||
|
}} else {{
|
||||||
|
select.setAttribute('data-position', 'Below');
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Set min-width to match trigger
|
||||||
|
select.style.minWidth = `${{triggerRect.width}}px`;
|
||||||
|
}};
|
||||||
|
|
||||||
|
const openSelect = () => {{
|
||||||
|
isOpen = true;
|
||||||
|
|
||||||
|
// Lock scrolling
|
||||||
|
window.ScrollLock.lock();
|
||||||
|
|
||||||
|
// Update position and open
|
||||||
|
updatePosition();
|
||||||
|
select.setAttribute('data-state', 'open');
|
||||||
|
select.style.pointerEvents = 'auto';
|
||||||
|
|
||||||
|
// Trigger scroll event to update indicators
|
||||||
|
select.dispatchEvent(new Event('scroll'));
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
setTimeout(() => {{
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
}}, 0);
|
||||||
|
}};
|
||||||
|
|
||||||
|
const closeSelect = () => {{
|
||||||
|
isOpen = false;
|
||||||
|
select.setAttribute('data-state', 'closed');
|
||||||
|
select.style.pointerEvents = 'none';
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
|
||||||
|
// Unlock scrolling after animation
|
||||||
|
window.ScrollLock.unlock(200);
|
||||||
|
}};
|
||||||
|
|
||||||
|
const handleClickOutside = (e) => {{
|
||||||
|
if (!select.contains(e.target) && !trigger.contains(e.target)) {{
|
||||||
|
closeSelect();
|
||||||
|
}}
|
||||||
|
}};
|
||||||
|
|
||||||
|
// Toggle select when trigger is clicked
|
||||||
|
trigger.addEventListener('click', (e) => {{
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isOpen) {{
|
||||||
|
closeSelect();
|
||||||
|
}} else {{
|
||||||
|
openSelect();
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Close when option is selected
|
||||||
|
const options = select.querySelectorAll('[data-select-option]');
|
||||||
|
options.forEach(option => {{
|
||||||
|
option.addEventListener('click', () => {{
|
||||||
|
closeSelect();
|
||||||
|
}});
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Handle ESC key to close
|
||||||
|
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,
|
||||||
|
)}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user