| 206 |
206 |
|
FieldKind::Date => "date",
|
| 207 |
207 |
|
FieldKind::DateTime => "datetime-local",
|
| 208 |
208 |
|
FieldKind::Radio => "radio",
|
|
209 |
+ |
// The clearest case in this list that a kind is not decoration: a
|
|
210 |
+ |
// number and a range submit the same value and are different controls,
|
|
211 |
+ |
// and the browser is the one drawing the difference.
|
|
212 |
+ |
FieldKind::Range => "range",
|
| 209 |
213 |
|
// Select and Textarea are not inputs at all; they never reach here.
|
| 210 |
214 |
|
// Radio is one, but it is emitted once per option by `radio_html` and
|
| 211 |
215 |
|
// so does not reach here either.
|
| 253 |
257 |
|
escape_into(max, out);
|
| 254 |
258 |
|
out.push('"');
|
| 255 |
259 |
|
}
|
|
260 |
+ |
// The browser's own default is `step="1"`, which turns a 0-to-1 threshold
|
|
261 |
+ |
// into a two-position control. That is the granularity the description
|
|
262 |
+ |
// means when it says nothing, so this is emitted only when an app has said
|
|
263 |
+ |
// otherwise rather than defaulted here.
|
|
264 |
+ |
if let Some(step) = field.step {
|
|
265 |
+ |
out.push_str(" step=\"");
|
|
266 |
+ |
escape_into(step, out);
|
|
267 |
+ |
out.push('"');
|
|
268 |
+ |
}
|
| 256 |
269 |
|
if field.invalid() {
|
| 257 |
270 |
|
out.push_str(" aria-invalid=\"true\"");
|
| 258 |
271 |
|
}
|
| 343 |
356 |
|
if field.required {
|
| 344 |
357 |
|
out.push_str(" required");
|
| 345 |
358 |
|
}
|
|
359 |
+ |
// A radio group has room a `<select>` does not, so the reason gets its
|
|
360 |
+ |
// own element beside the label rather than being run into it. The class
|
|
361 |
+ |
// is what a stylesheet mutes; the text is there either way, which is
|
|
362 |
+ |
// the half that matters — the finding was a greyed control with its
|
|
363 |
+ |
// explanation behind a hover.
|
|
364 |
+ |
if let Some(reason) = opt.unavailable {
|
|
365 |
+ |
out.push_str(" disabled");
|
|
366 |
+ |
out.push_str("><span>");
|
|
367 |
+ |
escape_into(opt.label, out);
|
|
368 |
+ |
out.push_str("</span><span class=\"");
|
|
369 |
+ |
push_class(out, "form-option-reason", opts);
|
|
370 |
+ |
out.push_str("\">");
|
|
371 |
+ |
escape_into(reason, out);
|
|
372 |
+ |
out.push_str("</span></label>");
|
|
373 |
+ |
continue;
|
|
374 |
+ |
}
|
| 346 |
375 |
|
out.push_str("><span>");
|
| 347 |
376 |
|
escape_into(opt.label, out);
|
| 348 |
377 |
|
out.push_str("</span></label>");
|
| 351 |
380 |
|
out.push_str("</div>");
|
| 352 |
381 |
|
}
|
| 353 |
382 |
|
|
| 354 |
|
- |
/// The options of a select, with an unmatched current value carried as its own.
|
|
383 |
+ |
/// The options of a select: the unanswered instruction, an unmatched current
|
|
384 |
+ |
/// value carried as its own, then the options themselves.
|
| 355 |
385 |
|
///
|
| 356 |
386 |
|
/// A select handed a value no option carries renders with nothing selected, the
|
| 357 |
387 |
|
/// browser falls back to the first option, and the next save writes a value
|
| 358 |
388 |
|
/// nobody chose. goingson hit exactly that with a backup-retention default of
|
| 359 |
389 |
|
/// 10 against a 1/3/7/14/0 list, and grew this stray-option fix locally; it is
|
| 360 |
390 |
|
/// here so the second app gets it without hitting the bug first.
|
| 361 |
|
- |
fn push_options(out: &mut String, options: &[Choice<'_>], value: &str) {
|
|
391 |
+ |
fn push_options(out: &mut String, field: &Field<'_>, options: &[Choice<'_>], value: &str) {
|
|
392 |
+ |
// The unanswered state, which HTML has no attribute for: `placeholder` is
|
|
393 |
+ |
// not a `<select>` attribute, and the idiom is an empty option that cannot
|
|
394 |
+ |
// be chosen back. `disabled` is what stops it being re-selected once the
|
|
395 |
+ |
// user has answered, and `selected` is what puts it in the closed control
|
|
396 |
+ |
// while the value is empty; together they read as an instruction rather
|
|
397 |
+ |
// than as an option.
|
|
398 |
+ |
//
|
|
399 |
+ |
// `required` keeps working through it rather than around it: the option's
|
|
400 |
+ |
// value is empty, so a required select with this showing is invalid, which
|
|
401 |
+ |
// is the true report on a question nobody has answered.
|
|
402 |
+ |
//
|
|
403 |
+ |
// Emitted only while the value is empty, so it does not sit in the open
|
|
404 |
+ |
// list once the field is answered. A non-empty value no option carries is a
|
|
405 |
+ |
// wrong answer rather than an absent one and takes the stray-option path
|
|
406 |
+ |
// below.
|
|
407 |
+ |
if value.is_empty() {
|
|
408 |
+ |
if let Some(text) = field.placeholder {
|
|
409 |
+ |
out.push_str("<option value=\"\" disabled selected>");
|
|
410 |
+ |
escape_into(text, out);
|
|
411 |
+ |
out.push_str("</option>");
|
|
412 |
+ |
}
|
|
413 |
+ |
}
|
| 362 |
414 |
|
if !value.is_empty() && !options.iter().any(|opt| opt.value == value) {
|
| 363 |
415 |
|
// The one place an escaped value is worth keeping: it is written twice,
|
| 364 |
416 |
|
// as the option's value and as its text.
|
| 375 |
427 |
|
if opt.value == value {
|
| 376 |
428 |
|
out.push_str(" selected");
|
| 377 |
429 |
|
}
|
|
430 |
+ |
// `disabled` is what the browser reads, and it says nothing about why.
|
|
431 |
+ |
// The reason goes in the option's own text, because a `<select>` gives
|
|
432 |
+ |
// its options no room for anything else: no title attribute the
|
|
433 |
+ |
// keyboard reaches, no second line, no element inside. So the row reads
|
|
434 |
+ |
// "Multi-sample: Drop a second sample onto the keyboard." and is the
|
|
435 |
+ |
// one place the precondition can be both attached to its option and
|
|
436 |
+ |
// read without a pointer.
|
|
437 |
+ |
if let Some(reason) = opt.unavailable {
|
|
438 |
+ |
out.push_str(" disabled");
|
|
439 |
+ |
out.push('>');
|
|
440 |
+ |
escape_into(opt.label, out);
|
|
441 |
+ |
out.push_str(": ");
|
|
442 |
+ |
escape_into(reason, out);
|
|
443 |
+ |
out.push_str("</option>");
|
|
444 |
+ |
continue;
|
|
445 |
+ |
}
|
| 378 |
446 |
|
out.push('>');
|
| 379 |
447 |
|
escape_into(opt.label, out);
|
| 380 |
448 |
|
out.push_str("</option>");
|
| 420 |
488 |
|
// A select described with no options emits an empty select, which
|
| 421 |
489 |
|
// says so on screen rather than in a log. That is the description's
|
| 422 |
490 |
|
// own position on `Field::options`, not a fallback invented here.
|
| 423 |
|
- |
push_options(out, field.options, filling.value.as_text());
|
|
491 |
+ |
push_options(out, field, field.options, filling.value.as_text());
|
| 424 |
492 |
|
out.push_str("</select>");
|
| 425 |
493 |
|
}
|
| 426 |
494 |
|
FieldKind::Checkbox => {
|
| 798 |
866 |
|
assert!(!html.contains("<option"), "{html}");
|
| 799 |
867 |
|
}
|
| 800 |
868 |
|
|
|
869 |
+ |
#[test]
|
|
870 |
+ |
fn an_unanswered_select_shows_its_ghost_text_and_cannot_be_chosen_back() {
|
|
871 |
+ |
let options = [Choice::new("sp404", "SP-404")];
|
|
872 |
+ |
let f = Field {
|
|
873 |
+ |
placeholder: Some("Select device..."),
|
|
874 |
+ |
..Field::select("device", "Conform for device", &options)
|
|
875 |
+ |
};
|
|
876 |
+ |
let html = field_html(&f, &Filling::default(), &Emit::default());
|
|
877 |
+ |
|
|
878 |
+ |
assert!(
|
|
879 |
+ |
html.contains("<option value=\"\" disabled selected>Select device...</option>"),
|
|
880 |
+ |
"{html}"
|
|
881 |
+ |
);
|
|
882 |
+ |
// First, so the closed control reads it rather than the first real
|
|
883 |
+ |
// option.
|
|
884 |
+ |
assert!(
|
|
885 |
+ |
html.find("Select device...") < html.find("SP-404"),
|
|
886 |
+ |
"{html}"
|
|
887 |
+ |
);
|
|
888 |
+ |
}
|
|
889 |
+ |
|
|
890 |
+ |
#[test]
|
|
891 |
+ |
fn an_answered_select_drops_the_ghost_text() {
|
|
892 |
+ |
// It is an instruction about an empty field, so it has nothing to say
|
|
893 |
+ |
// once the field is answered, and leaving it in the list is one dead
|
|
894 |
+ |
// row every time the control is opened afterwards.
|
|
895 |
+ |
let options = [Choice::new("sp404", "SP-404")];
|
|
896 |
+ |
let f = Field {
|
|
897 |
+ |
placeholder: Some("Select device..."),
|
|
898 |
+ |
..Field::select("device", "Conform for device", &options)
|
|
899 |
+ |
};
|
|
900 |
+ |
let html = field_html(&f, &Filling::of(Value::Text("sp404")), &Emit::default());
|
|
901 |
+ |
assert!(!html.contains("Select device..."), "{html}");
|
|
902 |
+ |
}
|
|
903 |
+ |
|
|
904 |
+ |
#[test]
|
|
905 |
+ |
fn a_wrong_answer_is_kept_and_is_not_the_ghost_text() {
|
|
906 |
+ |
// The two paths through `push_options` meet here. An unmatched value is
|
|
907 |
+ |
// an answer that is wrong and stays visible as itself; only the empty
|
|
908 |
+ |
// value is unanswered.
|
|
909 |
+ |
let options = [Choice::plain("1"), Choice::plain("7")];
|
|
910 |
+ |
let f = Field {
|
|
911 |
+ |
placeholder: Some("Pick one"),
|
|
912 |
+ |
..Field::select("retention", "Keep backups for", &options)
|
|
913 |
+ |
};
|
|
914 |
+ |
let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
|
|
915 |
+ |
assert!(html.contains("data-unmatched=\"true\""), "{html}");
|
|
916 |
+ |
assert!(!html.contains("Pick one"), "{html}");
|
|
917 |
+ |
}
|
|
918 |
+ |
|
|
919 |
+ |
#[test]
|
|
920 |
+ |
fn a_range_is_a_range_input_and_carries_its_extent() {
|
|
921 |
+ |
let f = Field {
|
|
922 |
+ |
step: Some("0.01"),
|
|
923 |
+ |
..Field::range("review", "Review above", "0", "1")
|
|
924 |
+ |
};
|
|
925 |
+ |
let html = field_html(&f, &Filling::of(Value::Text("0.72")), &Emit::default());
|
|
926 |
+ |
assert!(html.contains("type=\"range\""), "{html}");
|
|
927 |
+ |
assert!(html.contains("min=\"0\""), "{html}");
|
|
928 |
+ |
assert!(html.contains("max=\"1\""), "{html}");
|
|
929 |
+ |
// Without it the browser steps by 1 and a 0-to-1 question becomes a
|
|
930 |
+ |
// two-position control.
|
|
931 |
+ |
assert!(html.contains("step=\"0.01\""), "{html}");
|
|
932 |
+ |
}
|
|
933 |
+ |
|
|
934 |
+ |
#[test]
|
|
935 |
+ |
fn a_number_with_bounds_is_still_typed_into() {
|
|
936 |
+ |
// The distinction the kind exists for, at the renderer where getting it
|
|
937 |
+ |
// wrong is most visible: goingson's `min="1"` duration must not come
|
|
938 |
+ |
// back as a slider.
|
|
939 |
+ |
let f = Field {
|
|
940 |
+ |
min: Some("1"),
|
|
941 |
+ |
..Field::new(FieldKind::Number, "minutes", "Minutes")
|
|
942 |
+ |
};
|
|
943 |
+ |
let html = field_html(&f, &Filling::of(Value::Text("30")), &Emit::default());
|
|
944 |
+ |
assert!(html.contains("type=\"number\""), "{html}");
|
|
945 |
+ |
assert!(!html.contains("type=\"range\""), "{html}");
|
|
946 |
+ |
// And nothing invents a step for it.
|
|
947 |
+ |
assert!(!html.contains("step="), "{html}");
|
|
948 |
+ |
}
|
|
949 |
+ |
|
|
950 |
+ |
#[test]
|
|
951 |
+ |
fn an_unavailable_option_is_disabled_and_says_why() {
|
|
952 |
+ |
let options = [
|
|
953 |
+ |
Choice::new("chromatic", "Chromatic"),
|
|
954 |
+ |
Choice::new("multi", "Multi-sample").unless("Drop a second sample."),
|
|
955 |
+ |
];
|
|
956 |
+ |
let f = Field::radio("mode", "Mode", &options);
|
|
957 |
+ |
let html = field_html(&f, &Filling::of(Value::Text("chromatic")), &Emit::default());
|
|
958 |
+ |
|
|
959 |
+ |
assert!(html.contains(" disabled"), "{html}");
|
|
960 |
+ |
assert!(html.contains("Drop a second sample."), "{html}");
|
|
961 |
+ |
// The option is still offered: dropping it is what costs the user the
|
|
962 |
+ |
// knowledge that the mode exists.
|
|
963 |
+ |
assert!(html.contains("value=\"multi\""), "{html}");
|
|
964 |
+ |
// And the reason is its own element, not run into the label.
|
|
965 |
+ |
assert!(html.contains("form-option-reason"), "{html}");
|
|
966 |
+ |
}
|
|
967 |
+ |
|
|
968 |
+ |
#[test]
|
|
969 |
+ |
fn an_unavailable_select_option_carries_its_reason_in_its_text() {
|
|
970 |
+ |
// A `<select>` gives an option no room for a second element, so the
|
|
971 |
+ |
// reason has to be in the text or be unreadable without a pointer.
|
|
972 |
+ |
let options = [Choice::new("multi", "Multi-sample").unless("Drop a second sample.")];
|
|
973 |
+ |
let f = Field::select("mode", "Mode", &options);
|
|
974 |
+ |
let html = field_html(&f, &Filling::default(), &Emit::default());
|
|
975 |
+ |
assert!(
|
|
976 |
+ |
html.contains(">Multi-sample: Drop a second sample.</option>"),
|
|
977 |
+ |
"{html}"
|
|
978 |
+ |
);
|
|
979 |
+ |
assert!(html.contains("disabled"), "{html}");
|
|
980 |
+ |
}
|
|
981 |
+ |
|
| 801 |
982 |
|
#[test]
|
| 802 |
983 |
|
fn a_radio_group_is_named_by_its_label_instead_of_pointing_at_it() {
|
| 803 |
984 |
|
// The association inverts, and getting it wrong is silent: a
|
| 878 |
1059 |
|
fn a_radio_option_cannot_break_out_of_its_attribute() {
|
| 879 |
1060 |
|
// Values are `&str` and carry whatever the app put in them. The ids are
|
| 880 |
1061 |
|
// numbered rather than derived from the value for the same reason.
|
| 881 |
|
- |
let hostile = [Choice {
|
| 882 |
|
- |
value: "x\" onclick=alert(1) data-x=\"",
|
| 883 |
|
- |
label: "<script>alert(1)</script>",
|
| 884 |
|
- |
}];
|
|
1062 |
+ |
let hostile = [Choice::new(
|
|
1063 |
+ |
"x\" onclick=alert(1) data-x=\"",
|
|
1064 |
+ |
"<script>alert(1)</script>",
|
|
1065 |
+ |
)];
|
| 885 |
1066 |
|
let f = Field::radio("storage", "Storage style", &hostile);
|
| 886 |
1067 |
|
let html = field_html(&f, &Filling::default(), &Emit::default());
|
| 887 |
1068 |
|
|