311 lines
12 KiB
Rust
311 lines
12 KiB
Rust
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>
|
|
}
|
|
}
|
|
|
|
#[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>
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
#[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()
|
|
} |