use topcoat::{
context::Cx,
icon::{IconData, icon},
runtime::Event,
view::*,
};
use crate::{
Text,
display::{Color, FontWeight},
};
const LINK_SCHEMES: &[&str] = &["http", "https", "mailto", "tel"];
pub(crate) fn link_href(href: &str) -> Option<&str> {
match url::Url::parse(href) {
Ok(url) => LINK_SCHEMES.contains(&url.scheme()).then_some(href),
Err(url::ParseError::RelativeUrlWithoutBase) => Some(href),
Err(_) => None,
}
}
pub(crate) fn truncate(text: &str, chars: usize) -> Option<String> {
let (cut, _) = text.char_indices().nth(chars)?;
Some(format!("{}…", text[..cut].trim_end()))
}
#[derive(Default)]
pub(crate) struct TextCell {
pub(crate) text: String,
pub(crate) color: Option<Color>,
pub(crate) badge: bool,
pub(crate) tabular: bool,
pub(crate) weight: FontWeight,
pub(crate) icon: Option<(IconData, Option<Color>)>,
pub(crate) description: Option<String>,
pub(crate) url: Option<String>,
pub(crate) new_tab: bool,
pub(crate) tooltip: Option<String>,
pub(crate) limit: Option<usize>,
pub(crate) copyable: bool,
pub(crate) placeholder: Option<Text>,
}
pub(crate) fn text_cell(cx: &Cx, cell: TextCell) -> BoxView<'_> {
let value = if cell.text.trim().is_empty() {
let placeholder = cell
.placeholder
.map_or_else(|| "—".to_string(), |placeholder| placeholder.localize(cx));
view! { <span class="text-muted-foreground">(placeholder)</span> }.boxed()
} else {
value_view(cx, &cell)
};
match cell
.description
.filter(|description| !description.trim().is_empty())
{
None => value,
Some(description) => view! {
<div class="flex min-w-0 flex-col">
<span class="truncate">(value)</span>
<span class="truncate text-xs text-muted-foreground">
(description)
</span>
</div>
}
.boxed(),
}
}
pub(crate) fn badges_cell(cx: &Cx, cells: Vec<TextCell>) -> BoxView<'_> {
if cells.is_empty() {
return text_cell(cx, TextCell::default());
}
let badges: Vec<BoxView<'_>> = cells.iter().map(|cell| value_view(cx, cell)).collect();
view! {
<span class="flex flex-wrap gap-1">
for badge in badges {
(badge)
}
</span>
}
.boxed()
}
fn value_view<'a>(cx: &'a Cx, cell: &TextCell) -> BoxView<'a> {
let truncated = cell.limit.and_then(|limit| truncate(&cell.text, limit));
let title = cell
.tooltip
.clone()
.filter(|tooltip| !tooltip.trim().is_empty())
.or_else(|| truncated.is_some().then(|| cell.text.clone()));
let shown = truncated.unwrap_or_else(|| cell.text.clone());
let glyph = cell
.icon
.clone()
.map(|(data, color)| icon_view(cx, data, color));
let content = if cell.badge {
badge_view(cx, shown, cell.color, glyph)
} else {
let class = [
Some("inline-flex items-center gap-1.5"),
cell.color.map(Color::text_class),
cell.tabular.then_some("tabular-nums"),
Some(cell.weight.class()).filter(|class| !class.is_empty()),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" ");
view! {
<span class=(class)>
if let Some(glyph) = glyph {
(glyph)
}
(shown)
</span>
}
.boxed()
};
let content = match cell.url.as_deref().and_then(link_href) {
Some(href) => {
let href = href.to_string();
let (target, rel) = if cell.new_tab {
(Some("_blank"), Some("noopener noreferrer"))
} else {
(None, None)
};
view! {
<a
href=(href)
target=(target)
rel=(rel)
class="underline-offset-4 hover:underline"
data-cell-link=""
>
(content)
</a>
}
.boxed()
}
None => content,
};
let copy = cell.copyable.then(|| copy_button(cx, cell.text.clone()));
if title.is_none() && copy.is_none() {
return content;
}
view! {
<span class="inline-flex max-w-full items-center gap-1" title=(title)>
(content)
if let Some(copy) = copy {
(copy)
}
</span>
}
.boxed()
}
pub(crate) fn icon_view(cx: &Cx, data: IconData, color: Option<Color>) -> BoxView<'_> {
let class = match color {
Some(color) => format!("size-4 shrink-0 {}", color.text_class()),
None => "size-4 shrink-0".to_string(),
};
view! { icon(data: data, attrs: attributes! { class=(class) aria-hidden="true" }) }.boxed()
}
fn badge_view<'a>(
cx: &'a Cx,
text: String,
color: Option<Color>,
glyph: Option<BoxView<'a>>,
) -> BoxView<'a> {
view! {
tablo_ui::badge(
variant: tablo_ui::BadgeVariant::Outline,
if let Some(glyph) = glyph {
(glyph)
} else if let Some(color) = color {
<span class=(color.dot_class()) aria-hidden="true"></span>
}
(text)
)
}
.boxed()
}
fn copy_button(cx: &Cx, value: String) -> BoxView<'_> {
let label = crate::t!(cx, "tablo-list-copy");
let copied = value.clone();
view! {
<button
type="button"
class="inline-flex size-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label=(label.clone())
title=(label)
data-copy=(value)
@click=$(|_e: Event| {
raw!("navigator.clipboard?.writeText(String(${copied}))", ());
})
>
icon(
data: tablo_ui::icons::COPY,
attrs: attributes! { class="size-3.5" aria-hidden="true" }
)
</button>
}
.boxed()
}
#[cfg(test)]
mod tests;