use tablo_ui::{icons, table_head, table_header, table_row};
use topcoat::{Result, context::Cx, icon::icon, runtime::Event, view::*};
use super::{CHROME_CELL_CLASS, Frame, live_link};
use crate::{
panel::list::TableSignals,
t,
table::{Sort, TableState, bulk_token},
};
pub(super) enum Heads<'s> {
Linked(&'s TableSignals, Option<&'s Sort>, Vec<String>),
Named,
Placeholder,
}
impl Frame<'_> {
pub(super) async fn render_thead<'a>(
&self,
cx: &'a Cx,
state: &TableState,
path: &str,
with_actions: bool,
with_bulk: bool,
heads_as: Heads<'_>,
) -> Result<BoxView<'a>> {
let (links, named) = match heads_as {
Heads::Linked(signals, sorted, keys) => (Some((signals, sorted, keys)), true),
Heads::Named => (None, true),
Heads::Placeholder => (None, false),
};
let mut heads: Vec<BoxView<'_>> = Vec::with_capacity(self.columns.len());
let slack = self.slack_column();
for (index, col) in self.columns.iter().enumerate() {
let width = col.width.style();
let fit = if slack == Some(index) { "w-full" } else { "" };
let align = col.alignment.class();
let label = col.label.clone();
let name = named.then(|| col.name.to_string());
let sortable = col.sortable;
let (head_class, aria_sort, header) =
if let (true, Some((signals, sorted, _))) = (sortable, links.as_ref()) {
let (aria, sort_icon, next_desc) = match sorted {
Some(s) if s.column == col.name => (
if s.descending {
"descending"
} else {
"ascending"
},
if s.descending {
icons::ARROW_DOWN
} else {
icons::ARROW_UP
},
!s.descending,
),
_ => ("none", icons::ARROW_UP_DOWN, false),
};
let href = state.sorted_by(path, col.name, next_desc);
let aria_label = t!(
cx,
"tablo-list-sort",
column = label.clone(),
direction = if next_desc { "descending" } else { "ascending" },
);
let link_attrs = live_link(cx, href, signals);
(
"cursor-pointer hover:bg-foreground/5",
Some(aria),
view! {
<a
class="inline-flex items-center gap-1 hover:text-foreground"
aria-label=(aria_label)
(link_attrs)
>
(label.clone())
icon(
data: sort_icon,
attrs: attributes! { class="size-4 shrink-0 text-muted-foreground" }
)
</a>
}
.boxed(),
)
} else {
("", None, view! { (label.clone()) }.boxed())
};
heads.push(
view! {
table_head(
attrs: attributes! {
class=([head_class, fit, align]
.join(" ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" "))
aria-sort=(aria_sort)
style=(width.as_deref())
data-column=(name)
},
(header)
)
}
.boxed(),
);
}
let select_all = with_bulk.then(|| match &links {
Some((signals, _, keys)) => select_all(cx, signals, keys),
None => attributes! { type="checkbox" disabled="" },
});
if with_actions {
heads.push(
view! {
table_head(
attrs: attributes! { class=(CHROME_CELL_CLASS) },
<span class="sr-only">(t!(cx, "tablo-list-actions"))</span>
)
}
.boxed(),
);
}
Ok(view! {
table_header(
table_row(
if let Some(attrs) = select_all {
table_head(
attrs: attributes! { class=(CHROME_CELL_CLASS) },
<input aria-label=(t!(cx, "tablo-list-select-all")) (attrs)>
)
}
for h in heads {
(h)
}
)
)
}
.boxed())
}
}
/// The select-all box over a page's selectable `keys`: checked when the selection holds every one,
/// indeterminate when it holds some, and toggling all of them at once. Keys on other pages stay
/// selected. Selecting expands every collapsed group, so a bulk write never takes a row the reader
/// cannot see.
fn select_all(cx: &Cx, signals: &TableSignals, keys: &[String]) -> Attributes {
let (bulk, all, collapsed) = (
signals.bulk.clone(),
signals.all.clone(),
signals.collapsed.clone(),
);
let expanded = String::new();
// The tokens, never the raw keys: a key may hold the wire's separator, so only the renderer
// can spell one (`state::bulk_token`).
let tokens: Vec<String> = keys.iter().map(|key| bulk_token(key)).collect();
// The handler reads them from one `|`-joined string: `${..}` hands JavaScript a hydratable
// value, and a `Vec` hydrates to the runtime's `Vec` class, which holds none of the `Array`
// methods below. A token holds no `|`, which the codec percent-encodes.
let joined = tokens.join("|");
attributes! {
type="checkbox"
:checked=$({
let wire = bulk.get();
raw!(
"cx.hydrate((t => t.length > 0 && t.every(k => String(${wire}).includes(k)))(String(${joined}).split('|').filter(Boolean)))",
!tokens.is_empty()
&& tokens.iter().all(|token| wire.contains(token.as_str())),
)
})
:indeterminate=$({
let wire = bulk.get();
raw!(
"cx.hydrate((t => t.some(k => String(${wire}).includes(k)) && !t.every(k => String(${wire}).includes(k)))(String(${joined}).split('|').filter(Boolean)))",
tokens.iter().any(|token| wire.contains(token.as_str()))
&& !tokens.iter().all(|token| wire.contains(token.as_str())),
)
})
@change=$(|e: Event| {
all.set(false);
let wire = bulk.get();
if e.target.checked {
collapsed.set(expanded);
bulk.set(
raw!(
"cx.hydrate((w => String(${joined}).split('|').filter(Boolean).filter(k => !w.includes(k)).reduce((s, k) => s + k, w))(String(${wire}).replace(/^,+$/, '')))",
wire.to_owned(),
),
);
} else {
bulk.set(
raw!(
"cx.hydrate(String(${joined}).split('|').filter(Boolean).reduce((s, k) => s.replaceAll(k, ','), String(${wire})).replace(/^,+$/, ''))",
wire.to_owned(),
),
);
}
})
}
}