use super::{dom_id, layout::into_node};
use crate::action::{Action, ActionMeta, Result};
use crate::ctx::{BoxFuture, Ctx, Outcome};
use crate::i18n::Text;
use crate::model::{Field, Model};
use crate::render::{Render, attr, escape, slug};
use crate::screen::{Component, Effect, FormSource, Node, ScreenDef, UiCtx};
use crate::value::{FieldKind, FieldType, Value};
use serde_json::{Map, Value as Json, json};
use std::sync::Arc;
into_node!(
Alert, Badge, EmptyState, Skeleton, Progress, Breadcrumb, Timeline
);
pub use crate::grid::Tone;
fn spoken(tone: Tone, r: &Render) -> String {
r.text(&Text::key(format!("zeyn.ui.tone.{}", tone.code())))
}
pub struct Alert {
tone: Tone,
title: Option<Text>,
text: Text,
dismissible: bool,
}
impl Alert {
pub fn new(tone: Tone, text: impl Into<Text>) -> Self {
Self {
tone,
title: None,
text: text.into(),
dismissible: false,
}
}
pub fn info(text: impl Into<Text>) -> Self {
Self::new(Tone::Info, text)
}
pub fn success(text: impl Into<Text>) -> Self {
Self::new(Tone::Success, text)
}
pub fn warning(text: impl Into<Text>) -> Self {
Self::new(Tone::Warning, text)
}
pub fn danger(text: impl Into<Text>) -> Self {
Self::new(Tone::Danger, text)
}
pub fn title(mut self, title: impl Into<Text>) -> Self {
self.title = Some(title.into());
self
}
pub fn dismissible(mut self) -> Self {
self.dismissible = true;
self
}
}
impl Component for Alert {
fn render(&self, out: &mut String, r: &Render) {
let tone = self.tone.code();
out.push_str(&format!(r#"<div class="z-callout z-tone-{tone}" data-testid="{}.alert.{tone}"><span class="z-callout-icon" aria-hidden="true"></span><div class="z-callout-body">"#, r.scope));
let spoken = format!(
r#"<span class="z-sr-only">{}: </span>"#,
spoken(self.tone, r)
);
match &self.title {
Some(t) => out.push_str(&format!(
r#"<p class="z-callout-title">{spoken}{}</p><p>{}</p>"#,
r.text(t),
r.text(&self.text)
)),
None => out.push_str(&format!(r#"<p>{spoken}{}</p>"#, r.text(&self.text))),
}
out.push_str("</div>");
if self.dismissible {
out.push_str(&format!(
r#"<button type="button" class="z-icon-button" data-zeyn-dismiss aria-label="{}"><span aria-hidden="true">×</span></button>"#,
r.text(&Text::key("zeyn.ui.dismiss"))
));
}
out.push_str("</div>");
}
fn schema(&self) -> Json {
json!({ "type": "alert", "tone": self.tone, "title": self.title, "text": self.text, "dismissible": self.dismissible })
}
}
pub struct Badge {
text: Text,
tone: Tone,
}
impl Badge {
pub fn new(text: impl Into<Text>) -> Self {
Self {
text: text.into(),
tone: Tone::Neutral,
}
}
pub fn tone(mut self, tone: Tone) -> Self {
self.tone = tone;
self
}
pub fn html(&self, r: &Render) -> String {
format!(
r#"<span class="z-badge z-tone-{}">{}</span>"#,
self.tone.code(),
r.text(&self.text)
)
}
}
impl Component for Badge {
fn render(&self, out: &mut String, r: &Render) {
out.push_str(&self.html(r));
}
fn schema(&self) -> Json {
json!({ "type": "badge", "text": self.text, "tone": self.tone })
}
}
pub struct EmptyState {
title: Text,
text: Option<Text>,
button: Option<(Text, Effect)>,
}
impl EmptyState {
pub fn new(title: impl Into<Text>) -> Self {
Self {
title: title.into(),
text: None,
button: None,
}
}
pub fn text(mut self, text: impl Into<Text>) -> Self {
self.text = Some(text.into());
self
}
pub fn opens<S: ScreenDef>(
mut self,
label: impl Into<Text>,
screen: S,
params: S::Params,
) -> Self {
self.button = Some((label.into(), crate::screen::Window::open(screen, params)));
self
}
}
impl Component for EmptyState {
fn render(&self, out: &mut String, r: &Render) {
out.push_str(&format!(r#"<div class="z-empty"><span class="z-empty-icon" aria-hidden="true"></span><p class="z-empty-title">{}</p>"#, r.text(&self.title)));
if let Some(t) = &self.text {
out.push_str(&format!(r#"<p class="z-empty-text">{}</p>"#, r.text(t)));
}
if let Some((label, effect)) = &self.button {
out.push_str(&format!(
r#"<button type="button" class="z-button z-primary" data-zeyn-effect="{}" data-testid="{}.empty.{}">{}</button>"#,
attr(&effect.to_json(r)),
r.scope,
slug(&label.english()),
r.text(label)
));
}
out.push_str("</div>");
}
fn schema(&self) -> Json {
json!({ "type": "empty_state", "title": self.title, "text": self.text, "button": self.button.as_ref().map(|(l, e)| json!({ "label": l, "effect": e })) })
}
}
pub struct Skeleton {
lines: u8,
}
impl Skeleton {
pub fn lines(lines: u8) -> Self {
Self {
lines: lines.clamp(1, 20),
}
}
}
impl Component for Skeleton {
fn render(&self, out: &mut String, r: &Render) {
out.push_str(&format!(
r#"<div class="z-skeleton" aria-busy="true"><span class="z-sr-only">{}</span>"#,
r.text(&Text::key("zeyn.ui.loading"))
));
for i in 0..self.lines {
out.push_str(&format!(
r#"<span class="z-skeleton-line" aria-hidden="true" style="--z-skeleton-width:{}%"></span>"#,
100 - (i as u32 * 17) % 45
));
}
out.push_str("</div>");
}
fn schema(&self) -> Json {
json!({ "type": "skeleton", "lines": self.lines })
}
}
pub struct Progress {
label: Text,
value: u64,
max: u64,
tone: Tone,
}
impl Progress {
pub fn new(label: impl Into<Text>, value: u64, max: u64) -> Self {
Self {
label: label.into(),
value: value.min(max.max(1)),
max: max.max(1),
tone: Tone::Info,
}
}
pub fn tone(mut self, tone: Tone) -> Self {
self.tone = tone;
self
}
}
impl Component for Progress {
fn render(&self, out: &mut String, r: &Render) {
let id = dom_id(r, "progress");
let percent = self.value * 100 / self.max;
out.push_str(&format!(
r#"<div class="z-progress z-tone-{}"><div class="z-progress-head"><span id="{id}">{}</span><span class="z-progress-value">{percent}%</span></div><div class="z-progress-track" role="progressbar" aria-labelledby="{id}" aria-valuemin="0" aria-valuemax="{}" aria-valuenow="{}" aria-valuetext="{percent}%"><span class="z-progress-bar" style="--z-progress:{percent}%"></span></div></div>"#,
self.tone.code(),
r.text(&self.label),
self.max,
self.value
));
}
fn schema(&self) -> Json {
json!({ "type": "progress", "label": self.label, "value": self.value, "max": self.max, "tone": self.tone })
}
}
pub struct DescriptionList {
items: Vec<(Text, Value, FieldKind)>,
columns: u8,
}
pub struct DescriptionOf<'a, M> {
model: &'a M,
list: DescriptionList,
}
impl DescriptionList {
pub fn of<M: Model>(model: &M) -> DescriptionOf<'_, M> {
DescriptionOf {
model,
list: DescriptionList {
items: Vec::new(),
columns: 2,
},
}
}
pub fn new() -> Self {
DescriptionList {
items: Vec::new(),
columns: 2,
}
}
pub fn item(mut self, label: impl Into<Text>, text: impl Into<String>) -> Self {
self.items
.push((label.into(), Value::Text(text.into()), FieldKind::Text));
self
}
pub fn columns(mut self, n: u8) -> Self {
self.columns = n.clamp(1, 4);
self
}
}
impl Default for DescriptionList {
fn default() -> Self {
Self::new()
}
}
impl<M: Model> DescriptionOf<'_, M> {
pub fn field<T: FieldType + 'static>(mut self, field: Field<M, T>) -> Self {
let meta = field.meta();
self.list.items.push((
meta.label_text(),
field.get(self.model).to_value(),
meta.kind,
));
self
}
pub fn item(mut self, label: impl Into<Text>, text: impl Into<String>) -> Self {
self.list = self.list.item(label, text);
self
}
pub fn columns(mut self, n: u8) -> Self {
self.list = self.list.columns(n);
self
}
}
impl<M> From<DescriptionOf<'_, M>> for Node {
fn from(d: DescriptionOf<'_, M>) -> Self {
Node::new(d.list)
}
}
impl From<DescriptionList> for Node {
fn from(d: DescriptionList) -> Self {
Node::new(d)
}
}
impl Component for DescriptionList {
fn render(&self, out: &mut String, r: &Render) {
out.push_str(&format!(
r#"<dl class="z-details" style="--z-columns:{}">"#,
self.columns
));
for (label, value, kind) in &self.items {
let shown = r.value(value, kind);
let shown = if shown.is_empty() {
r#"<span class="z-muted" aria-label="-">—</span>"#.to_string()
} else {
shown
};
out.push_str(&format!(
r#"<div class="z-details-item"><dt>{}</dt><dd>{shown}</dd></div>"#,
r.text(label)
));
}
out.push_str("</dl>");
}
fn schema(&self) -> Json {
let items: Vec<Json> = self
.items
.iter()
.map(|(l, v, _)| json!({ "label": l, "value": v }))
.collect();
json!({ "type": "description_list", "columns": self.columns, "items": items })
}
}
#[derive(Default)]
pub struct Breadcrumb {
links: Vec<(Text, &'static str, Json)>,
current: Option<Text>,
}
impl Breadcrumb {
pub fn new() -> Self {
Self::default()
}
pub fn link<S: ScreenDef>(
mut self,
label: impl Into<Text>,
_screen: S,
params: S::Params,
) -> Self {
self.links.push((
label.into(),
S::META.name,
serde_json::to_value(params).unwrap_or(Json::Null),
));
self
}
pub fn current(mut self, label: impl Into<Text>) -> Self {
self.current = Some(label.into());
self
}
}
impl Component for Breadcrumb {
fn render(&self, out: &mut String, r: &Render) {
out.push_str(&format!(
r#"<nav class="z-breadcrumb" aria-label="{}"><ol>"#,
r.text(&Text::key("zeyn.ui.breadcrumb"))
));
for (label, screen, params) in &self.links {
out.push_str(&format!(
r#"<li><a href="{}" data-zeyn-base-href>{}</a></li>"#,
screen_href(r, screen, params),
r.text(label)
));
}
if let Some(c) = &self.current {
out.push_str(&format!(
r#"<li><span aria-current="page">{}</span></li>"#,
r.text(c)
));
}
out.push_str("</ol></nav>");
}
fn schema(&self) -> Json {
let links: Vec<Json> = self
.links
.iter()
.map(|(l, s, p)| json!({ "label": l, "screen": s, "params": p }))
.collect();
json!({ "type": "breadcrumb", "links": links, "current": self.current })
}
}
pub(crate) fn screen_href(r: &Render, screen: &str, params: &Json) -> String {
let href = crate::screen::Routes::href(r, screen, params).unwrap_or_else(|| {
let query = if params.is_null() {
String::new()
} else {
format!("?params={}", encode(¶ms.to_string()))
};
format!("/_zeyn/route/{screen}{query}")
});
escape(&href)
}
pub(crate) fn encode(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
#[derive(Default)]
pub struct Timeline {
events: Vec<(Value, Text, Option<Text>, Tone)>,
}
impl Timeline {
pub fn new() -> Self {
Self::default()
}
pub fn event<T: FieldType>(
mut self,
when: T,
title: impl Into<Text>,
detail: Option<Text>,
tone: Tone,
) -> Self {
self.events
.push((when.to_value(), title.into(), detail, tone));
self
}
}
impl Component for Timeline {
fn render(&self, out: &mut String, r: &Render) {
out.push_str(r#"<ol class="z-timeline">"#);
for (when, title, detail, tone) in &self.events {
let kind = match when {
Value::Date(_) => FieldKind::Date,
_ => FieldKind::DateTime,
};
out.push_str(&format!(
r#"<li class="z-tone-{}"><span class="z-timeline-dot" aria-hidden="true"></span><div><p class="z-timeline-title">{}</p><p class="z-timeline-when">{}</p>"#,
tone.code(),
r.text(title),
r.value(when, &kind)
));
if let Some(d) = detail {
out.push_str(&format!(
r#"<p class="z-timeline-detail">{}</p>"#,
r.text(d)
));
}
out.push_str("</div></li>");
}
out.push_str("</ol>");
}
fn schema(&self) -> Json {
let events: Vec<Json> = self
.events
.iter()
.map(|(w, t, d, tone)| json!({ "when": w, "title": t, "detail": d, "tone": tone }))
.collect();
json!({ "type": "timeline", "events": events })
}
}
type SuccessFn<O> = Arc<dyn Fn(&O, &mut UiCtx) + Send + Sync>;
pub struct ActionButton<A: Action> {
label: Text,
input: A::Input,
confirm: Option<Text>,
tone: Tone,
tip: Option<Text>,
on_success: Option<SuccessFn<A::Output>>,
}
impl<A: Action> ActionButton<A>
where
A::Input: Clone + Sync,
{
pub fn new(label: impl Into<Text>, _action: A, input: A::Input) -> Self {
Self {
label: label.into(),
input,
confirm: None,
tone: Tone::Neutral,
tip: None,
on_success: None,
}
}
pub fn confirm(mut self, message: impl Into<Text>) -> Self {
self.confirm = Some(message.into());
self
}
pub fn tip(mut self, text: impl Into<Text>) -> Self {
self.tip = Some(text.into());
self
}
pub fn tone(mut self, tone: Tone) -> Self {
self.tone = tone;
self
}
pub fn on_success(
mut self,
handler: impl Fn(&A::Output, &mut UiCtx) + Send + Sync + 'static,
) -> Self {
self.on_success = Some(Arc::new(handler));
self
}
}
impl<A> FormSource for ActionButton<A>
where
A: Action,
A::Input: Clone + Sync,
A::Output: Outcome,
{
fn action(&self) -> ActionMeta {
A::META
}
fn submit(&self, ctx: Ctx, _values: Map<String, Json>) -> BoxFuture<Result<Vec<Effect>>> {
let input = self.input.clone();
let on_success = self.on_success.clone();
Box::pin(async move {
let output = A::call(ctx, input).await?;
if let Some(error) = output.failure() {
return Err(error);
}
let mut ui = UiCtx::default();
if let Some(handler) = on_success {
handler(&output, &mut ui);
}
Ok(ui.into_effects())
})
}
fn tool_schema(&self) -> Json {
json!({ "type": "object", "properties": {}, "description": self.confirm.as_ref().map(Text::english) })
}
}
impl<A> Component for ActionButton<A>
where
A: Action,
A::Input: Clone + Sync,
A::Output: Outcome,
{
fn render(&self, out: &mut String, r: &Render) {
let confirm = match &self.confirm {
Some(m) => format!(
r#" data-zeyn-confirm="{}" data-text-title="{}" data-text-ok="{}" data-text-cancel="{}""#,
r.text(m),
r.text(&Text::key("zeyn.ui.confirm.title")),
escape(&self.label_text(r)),
r.text(&Text::key("zeyn.ui.confirm.cancel"))
),
None => String::new(),
};
let class = if self.tone == Tone::Danger {
"z-button z-danger-button"
} else {
"z-button"
};
let (tip_attrs, tip) = match &self.tip {
Some(t) => crate::ui::tip(r, &crate::ui::dom_id(r, "tip"), t),
None => (String::new(), String::new()),
};
out.push_str(&format!(
r#"<form class="z-action" data-zeyn-submit="{}"{confirm} novalidate><button type="submit" class="{class}" data-testid="{}.action.{}"{tip_attrs}>{}</button>{tip}</form>"#,
A::META.name,
r.scope,
slug(&self.label.english()),
r.text(&self.label)
));
}
fn schema(&self) -> Json {
json!({ "type": "action_button", "label": self.label, "action": A::META, "confirm": self.confirm, "tone": self.tone })
}
fn form(&self) -> Option<&dyn FormSource> {
Some(self)
}
}
impl<A: Action> ActionButton<A> {
fn label_text(&self, r: &Render) -> String {
r.i18n.text(r.locale, &self.label)
}
}
impl<A> From<ActionButton<A>> for Node
where
A: Action,
A::Input: Clone + Sync,
A::Output: Outcome,
{
fn from(b: ActionButton<A>) -> Self {
Node::new(b)
}
}