| 26 |
26 |
|
//!
|
| 27 |
27 |
|
//! # What the description does not carry
|
| 28 |
28 |
|
//!
|
| 29 |
|
- |
//! [`Field`] describes the field and not its contents, so three things arrive
|
| 30 |
|
- |
//! from the renderer side in [`Filling`]: the current value, the options of a
|
| 31 |
|
- |
//! select, and the placeholder. The first two are genuinely renderer state. The
|
| 32 |
|
- |
//! third is user-facing text and belongs with `label` and `hint` in
|
| 33 |
|
- |
//! makeover-layout; it lives here because that crate is published and adding a
|
| 34 |
|
- |
//! field to `Field` is a breaking change, not because this is its home.
|
|
29 |
+ |
//! One thing: the **current value**, which arrives in [`Filling`].
|
|
30 |
+ |
//!
|
|
31 |
+ |
//! It used to be three. Writing this emitter is what found them, and the other
|
|
32 |
+ |
//! two turned out not to be renderer state at all — the placeholder is
|
|
33 |
+ |
//! user-facing text that sits with `label` and `hint`, and a select's options
|
|
34 |
+ |
//! are needed by every renderer, which is how each of them ends up inventing a
|
|
35 |
+ |
//! near-miss of the same struct. Both moved down into `makeover-layout` 0.8.0,
|
|
36 |
+ |
//! `Choice` included, and this crate reads them off [`Field`] now.
|
|
37 |
+ |
//!
|
|
38 |
+ |
//! The value stays, and it is not a leftover. A webview reads it back out of
|
|
39 |
+ |
//! the DOM, an immediate-mode renderer writes through a `&mut`, and a terminal
|
|
40 |
+ |
//! keeps an edit buffer; a description carrying it would have to carry a way to
|
|
41 |
+ |
//! write it back, at which point it is a form model.
|
| 35 |
42 |
|
|
| 36 |
43 |
|
use crate::{Emit, class};
|
| 37 |
|
- |
use makeover_layout::{Field, FieldKind};
|
|
44 |
+ |
use makeover_layout::{Choice, Field, FieldKind};
|
| 38 |
45 |
|
use std::fmt::Write as _;
|
| 39 |
46 |
|
|
| 40 |
47 |
|
/// A string that is already markup, and is emitted without escaping.
|
| 47 |
54 |
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 48 |
55 |
|
pub struct Markup<'a>(pub &'a str);
|
| 49 |
56 |
|
|
| 50 |
|
- |
/// One option of a select.
|
| 51 |
|
- |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 52 |
|
- |
pub struct Choice<'a> {
|
| 53 |
|
- |
/// What is submitted.
|
| 54 |
|
- |
pub value: &'a str,
|
| 55 |
|
- |
/// What is read.
|
| 56 |
|
- |
pub label: &'a str,
|
| 57 |
|
- |
}
|
| 58 |
|
- |
|
| 59 |
|
- |
impl<'a> Choice<'a> {
|
| 60 |
|
- |
/// An option whose submitted value is also its label.
|
| 61 |
|
- |
#[must_use]
|
| 62 |
|
- |
pub const fn plain(value: &'a str) -> Self {
|
| 63 |
|
- |
Self {
|
| 64 |
|
- |
value,
|
| 65 |
|
- |
label: value,
|
| 66 |
|
- |
}
|
| 67 |
|
- |
}
|
| 68 |
|
- |
}
|
| 69 |
|
- |
|
| 70 |
57 |
|
/// What the field currently holds.
|
| 71 |
58 |
|
///
|
| 72 |
59 |
|
/// An enum rather than a bag of optional fields, on the same reasoning
|
| 73 |
|
- |
/// [`makeover_layout::Depth`] is one: a select with no options and a checkbox
|
| 74 |
|
- |
/// with a string value are both unsayable here, where a struct would let them
|
| 75 |
|
- |
/// be said and then have to cope.
|
|
60 |
+ |
/// [`makeover_layout::Depth`] is one: a checkbox holding a string is unsayable
|
|
61 |
+ |
/// here, where a struct would let it be said and then have to cope.
|
| 76 |
62 |
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
| 77 |
63 |
|
pub enum Value<'a> {
|
| 78 |
64 |
|
/// Nothing yet.
|
| 79 |
65 |
|
#[default]
|
| 80 |
66 |
|
Absent,
|
| 81 |
|
- |
/// The value of anything that takes typed text.
|
|
67 |
+ |
/// The value of anything that takes typed text, a select included: what a
|
|
68 |
+ |
/// select holds is the `value` of one of [`Field::options`]'s
|
|
69 |
+ |
/// [`Choice`]s.
|
|
70 |
+ |
///
|
|
71 |
+ |
/// It carried the options too until makeover-layout 0.8.0 moved them onto
|
|
72 |
+ |
/// the field, which collapsed a `Chosen { options, value }` variant into
|
|
73 |
+ |
/// this one. `makeover-immediate` arrived at the same single-variant shape
|
|
74 |
+ |
/// on its own, from the other direction.
|
| 82 |
75 |
|
Text(&'a str),
|
| 83 |
|
- |
/// The options of a select, and which of them is current.
|
| 84 |
|
- |
Chosen {
|
| 85 |
|
- |
/// Every option, in the order they are offered.
|
| 86 |
|
- |
options: &'a [Choice<'a>],
|
| 87 |
|
- |
/// The current value. Matched against each option's `value`.
|
| 88 |
|
- |
value: &'a str,
|
| 89 |
|
- |
},
|
| 90 |
76 |
|
/// A checkbox, on or off.
|
| 91 |
77 |
|
On(bool),
|
| 92 |
78 |
|
}
|
| 95 |
81 |
|
/// The value as text, for the kinds that submit one.
|
| 96 |
82 |
|
const fn as_text(&self) -> &'a str {
|
| 97 |
83 |
|
match self {
|
| 98 |
|
- |
Self::Text(text) | Self::Chosen { value: text, .. } => text,
|
|
84 |
+ |
Self::Text(text) => text,
|
| 99 |
85 |
|
Self::Absent | Self::On(_) => "",
|
| 100 |
86 |
|
}
|
| 101 |
87 |
|
}
|
| 106 |
92 |
|
pub struct Filling<'a> {
|
| 107 |
93 |
|
/// What the field holds now.
|
| 108 |
94 |
|
pub value: Value<'a>,
|
| 109 |
|
- |
/// Ghost text shown while the field is empty.
|
| 110 |
|
- |
pub placeholder: Option<&'a str>,
|
| 111 |
95 |
|
/// Markup appended inside the group, after the hint. Not escaped.
|
| 112 |
96 |
|
pub trailing: Option<Markup<'a>>,
|
| 113 |
97 |
|
/// Scopes the `id` attributes to one instance of the form.
|
| 130 |
114 |
|
pub const fn of(value: Value<'a>) -> Self {
|
| 131 |
115 |
|
Self {
|
| 132 |
116 |
|
value,
|
| 133 |
|
- |
placeholder: None,
|
| 134 |
117 |
|
trailing: None,
|
| 135 |
118 |
|
id_prefix: None,
|
| 136 |
119 |
|
}
|
| 262 |
245 |
|
let id = filling.id_for(field.name);
|
| 263 |
246 |
|
let attrs = control_attributes(field, &id, field.name);
|
| 264 |
247 |
|
let field_class = class("field", opts);
|
| 265 |
|
- |
let placeholder = filling.placeholder.map_or_else(String::new, |text| {
|
|
248 |
+ |
let placeholder = field.placeholder.map_or_else(String::new, |text| {
|
| 266 |
249 |
|
format!(" placeholder=\"{}\"", escape(text))
|
| 267 |
250 |
|
});
|
| 268 |
251 |
|
|
| 272 |
255 |
|
escape(filling.value.as_text())
|
| 273 |
256 |
|
),
|
| 274 |
257 |
|
FieldKind::Select => {
|
| 275 |
|
- |
let options = match filling.value {
|
| 276 |
|
- |
Value::Chosen { options, value } => options_html(options, value),
|
| 277 |
|
- |
// Described as a select and filled as something else. Emitting
|
| 278 |
|
- |
// an empty select says so on screen rather than in a log.
|
| 279 |
|
- |
_ => String::new(),
|
| 280 |
|
- |
};
|
|
258 |
+ |
// A select described with no options emits an empty select, which
|
|
259 |
+ |
// says so on screen rather than in a log. That is the description's
|
|
260 |
+ |
// own position on `Field::options`, not a fallback invented here.
|
|
261 |
+ |
let options = options_html(field.options, filling.value.as_text());
|
| 281 |
262 |
|
format!("<select class=\"{field_class}\"{attrs}>{options}</select>")
|
| 282 |
263 |
|
}
|
| 283 |
264 |
|
FieldKind::Checkbox => {
|
| 519 |
500 |
|
#[test]
|
| 520 |
501 |
|
fn a_select_keeps_a_value_no_option_carries() {
|
| 521 |
502 |
|
let options = [Choice::plain("1"), Choice::plain("3"), Choice::plain("7")];
|
| 522 |
|
- |
let filling = Filling::of(Value::Chosen {
|
| 523 |
|
- |
options: &options,
|
| 524 |
|
- |
value: "10",
|
| 525 |
|
- |
});
|
| 526 |
|
- |
let html = field_html(&field(FieldKind::Select), &filling, &Emit::default());
|
|
503 |
+ |
let f = Field::select("title", "Title", &options);
|
|
504 |
+ |
let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
|
| 527 |
505 |
|
assert!(html.contains("data-unmatched=\"true\""), "{html}");
|
| 528 |
506 |
|
// Selected, so the next save round-trips it rather than writing the
|
| 529 |
507 |
|
// first option over the top of it.
|
| 530 |
508 |
|
assert!(html.contains("<option value=\"10\" selected"), "{html}");
|
| 531 |
509 |
|
}
|
| 532 |
510 |
|
|
|
511 |
+ |
#[test]
|
|
512 |
+ |
fn a_select_with_no_options_emits_an_empty_select() {
|
|
513 |
+ |
// The description says a select with no options is sayable, because an
|
|
514 |
+ |
// app whose option list has not loaded has exactly that. Emitting the
|
|
515 |
+ |
// empty select reports it on screen rather than in a log.
|
|
516 |
+ |
let f = Field::select("title", "Title", &[]);
|
|
517 |
+ |
let html = field_html(&f, &Filling::default(), &Emit::default());
|
|
518 |
+ |
assert!(html.contains("<select"), "{html}");
|
|
519 |
+ |
assert!(!html.contains("<option"), "{html}");
|
|
520 |
+ |
}
|
|
521 |
+ |
|
|
522 |
+ |
#[test]
|
|
523 |
+ |
fn a_placeholder_comes_off_the_description_and_is_escaped() {
|
|
524 |
+ |
// It arrived in `Filling` until makeover-layout 0.8.0 and was never
|
|
525 |
+ |
// covered here; it is a value in an attribute like any other.
|
|
526 |
+ |
let f = Field {
|
|
527 |
+ |
placeholder: Some("x\" onfocus=alert(1) autofocus=\""),
|
|
528 |
+ |
..field(FieldKind::Text)
|
|
529 |
+ |
};
|
|
530 |
+ |
let html = field_html(&f, &Filling::default(), &Emit::default());
|
|
531 |
+ |
assert!(html.contains("placeholder=\""), "{html}");
|
|
532 |
+ |
assert!(!html.contains("\" onfocus"), "{html}");
|
|
533 |
+ |
}
|
|
534 |
+ |
|
| 533 |
535 |
|
#[test]
|
| 534 |
536 |
|
fn a_select_marks_the_option_that_matches() {
|
| 535 |
537 |
|
let options = [Choice::plain("1"), Choice::plain("3")];
|
| 536 |
|
- |
let filling = Filling::of(Value::Chosen {
|
| 537 |
|
- |
options: &options,
|
| 538 |
|
- |
value: "3",
|
| 539 |
|
- |
});
|
| 540 |
|
- |
let html = field_html(&field(FieldKind::Select), &filling, &Emit::default());
|
|
538 |
+ |
let f = Field::select("title", "Title", &options);
|
|
539 |
+ |
let html = field_html(&f, &Filling::of(Value::Text("3")), &Emit::default());
|
| 541 |
540 |
|
assert!(
|
| 542 |
541 |
|
html.contains("<option value=\"3\" selected>3</option>"),
|
| 543 |
542 |
|
"{html}"
|