fix(forms): wrap textarea submission values

This commit is contained in:
ldm0
2026-09-28 01:46:59 +08:00
parent ca24ed4556
commit 9606fd8ed2
6 changed files with 159 additions and 10 deletions
+4 -4
View File
@@ -39,10 +39,10 @@ pub use option::{
option_disabled_ancestor_step,
};
pub use text::{
normalize_custom_validation_message, normalize_form_submission_newlines,
parse_non_negative_integer_prefix, parse_non_negative_length_attribute,
parse_positive_integer_prefix, text_control_suffers_too_long, text_control_suffers_too_short,
text_control_value_length,
apply_textarea_wrapping_transformation, normalize_custom_validation_message,
normalize_form_submission_newlines, parse_non_negative_integer_prefix,
parse_non_negative_length_attribute, parse_positive_integer_prefix,
text_control_suffers_too_long, text_control_suffers_too_short, text_control_value_length,
};
pub use validity::FormControlValidity;
+23
View File
@@ -59,6 +59,8 @@ fn integer_prefix_parsers_follow_html_attribute_rules() {
assert_eq!(parse_non_negative_integer_prefix("999999999999"), 0);
assert_eq!(parse_positive_integer_prefix(" 12px"), Some(12));
assert_eq!(parse_positive_integer_prefix(" +12px"), Some(12));
assert_eq!(parse_positive_integer_prefix("\u{a0}12px"), None);
assert_eq!(parse_positive_integer_prefix("0"), None);
assert_eq!(parse_positive_integer_prefix("-1"), None);
assert_eq!(parse_positive_integer_prefix("abc"), None);
@@ -71,6 +73,27 @@ fn integer_prefix_parsers_follow_html_attribute_rules() {
assert_eq!(parse_non_negative_length_attribute("2147483648"), None);
}
#[test]
fn textarea_wrapping_transformation_respects_hard_state_and_character_width() {
let wrap = |value: &str, wrap, cols| {
apply_textarea_wrapping_transformation(value.to_owned(), wrap, cols)
};
assert_eq!(wrap("hello world", Some("soft"), Some("7")), "hello world");
assert_eq!(wrap("hello world", Some("ſoft"), Some("7")), "hello world");
assert_eq!(wrap("1234567", Some("hard"), Some("7")), "1234567");
assert_eq!(
wrap("hello world", Some("HaRd"), Some("7")),
"hello w\norld"
);
assert_eq!(wrap("ab\ncdef", Some("hard"), Some("3")), "ab\ncde\nf");
assert_eq!(wrap("é🙂x", Some("hard"), Some("2")), "é🙂\nx");
assert_eq!(
wrap("123456789012345678901", Some("hard"), None),
"12345678901234567890\n1"
);
}
#[test]
fn input_type_tokens_and_support_matrices_are_shared() {
assert!(InputType::Week.supports_value_as_number());
+40 -1
View File
@@ -34,7 +34,12 @@ pub fn parse_non_negative_integer_prefix(value: &str) -> i32 {
}
pub fn parse_positive_integer_prefix(value: &str) -> Option<u32> {
let digits = integer_prefix_digits(value);
let value = value.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
let value = value.strip_prefix('+').unwrap_or(value);
let digits = value
.chars()
.take_while(|ch| ch.is_ascii_digit())
.collect::<String>();
digits
.parse::<i32>()
.ok()
@@ -42,6 +47,40 @@ pub fn parse_positive_integer_prefix(value: &str) -> Option<u32> {
.map(|value| value as u32)
}
pub fn apply_textarea_wrapping_transformation(
value: String,
wrap_attribute: Option<&str>,
cols_attribute: Option<&str>,
) -> String {
if !wrap_attribute.is_some_and(|wrap| wrap.eq_ignore_ascii_case("hard")) {
return value;
}
let character_width = cols_attribute
.and_then(parse_positive_integer_prefix)
.filter(|width| *width <= i32::MAX as u32)
.unwrap_or(20);
hard_wrap_textarea_value(&value, character_width)
}
fn hard_wrap_textarea_value(value: &str, character_width: u32) -> String {
let mut output = String::with_capacity(value.len());
let mut current_width = 0;
for character in value.chars() {
if character == '\n' {
output.push(character);
current_width = 0;
continue;
}
if current_width == character_width {
output.push('\n');
current_width = 0;
}
output.push(character);
current_width += 1;
}
output
}
pub fn parse_non_negative_length_attribute(value: &str) -> Option<usize> {
parse_html_integer_prefix(value)
.filter(|value| *value >= 0)
@@ -2,7 +2,10 @@ use super::storage::{form_data_entries, form_data_is_object, push_form_data_entr
use super::*;
use crate::custom_elements::is_form_associated_custom_element_handle;
use crate::dom::{
forms::{InputType, OptionDisabledAncestorStep, option_disabled_ancestor_step},
forms::{
InputType, OptionDisabledAncestorStep, apply_textarea_wrapping_transformation,
option_disabled_ancestor_step,
},
native::Node,
};
use crate::native_bridge::{
@@ -399,9 +402,16 @@ fn native_form_control_value(
let (runtime_ptr, handle) = node_runtime_and_handle_from_object(scope, control).ok()?;
let runtime = unsafe { &*runtime_ptr };
let element = runtime.dom_host().node(handle).and_then(Node::as_element)?;
if element.is_html_input() || element.is_html_textarea() {
if element.is_html_input() {
return Some(text_control_value(runtime, handle));
}
if element.is_html_textarea() {
return Some(apply_textarea_wrapping_transformation(
text_control_value(runtime, handle),
element.attribute_ns("", "wrap"),
element.attribute_ns("", "cols"),
));
}
if element.is_html_option() {
return Some(element.option_value(runtime.dom_host().dom(), handle));
}
@@ -348,14 +348,18 @@ fn detached_control_submission_value<'s>(
return crate::native_bridge::element::text_control_value(runtime, handle);
}
if element.is_html_textarea() {
let value = crate::native_bridge::element::text_control_value(runtime, handle);
let mut value = crate::native_bridge::element::text_control_value(runtime, handle);
if !element.input_value_dirty()
&& value.is_empty()
&& let Some(attribute_value) = element.attribute_ns("", "value")
{
return attribute_value.to_owned();
value = attribute_value.to_owned();
}
return value;
return moli_dom::forms::apply_textarea_wrapping_transformation(
value,
element.attribute_ns("", "wrap"),
element.attribute_ns("", "cols"),
);
}
if element.is_html_button() {
return element
@@ -5593,6 +5593,79 @@ fn empty_get_form_submit_replaces_existing_action_query() {
.expect("empty GET form submit should queue a pending location navigation");
assert_eq!(pending.url.as_str(), "https://form-empty-get.test/submit?");
}
#[test]
fn textarea_hard_wrap_is_ascii_case_insensitive_in_form_entries() {
let mut vm = new_storage_test_vm("https://textarea-hard-wrap.test/path/index.html");
let form_data_values = vm
.eval(
r#"
(() => {
const root = document.documentElement || document.appendChild(document.createElement('html'));
const body = document.body || root.appendChild(document.createElement('body'));
const form = document.createElement('form');
form.action = '/submit';
const add = (name, wrap, cols, value) => {
const textarea = document.createElement('textarea');
textarea.name = name;
textarea.setAttribute('wrap', wrap);
textarea.setAttribute('cols', cols);
textarea.value = value;
form.append(textarea);
return textarea;
};
const lower = add('lower', 'hard', '7', 'hello world');
add('mixed', 'HaRd', '7', 'hello world');
add('soft', 'SoFt', '7', 'hello world');
add('invalid', 'ſoft', '7', 'hello world');
add('existing', 'HARD', '3', 'ab\ncdef');
body.append(form);
const data = new FormData(form);
const visible = value => value.replaceAll('\n', '<LF>');
const result = [
lower.value.includes('\n'),
...['lower', 'mixed', 'soft', 'invalid', 'existing'].map(name => visible(data.get(name)))
].join('|');
form.submit();
return result;
})()
"#,
)
.expect("textarea hard-wrap form submission should evaluate");
assert_eq!(
form_data_values,
"false|hello w<LF>orld|hello w<LF>orld|hello world|hello world|ab<LF>cde<LF>f"
);
let pending = vm
.take_pending_location_navigation_with_seed()
.expect("textarea GET submission should queue a pending navigation");
let values = pending
.url
.query_pairs()
.into_owned()
.collect::<std::collections::HashMap<_, _>>();
assert_eq!(
values.get("lower").map(String::as_str),
Some("hello w\r\norld")
);
assert_eq!(
values.get("mixed").map(String::as_str),
Some("hello w\r\norld")
);
assert_eq!(values.get("soft").map(String::as_str), Some("hello world"));
assert_eq!(
values.get("invalid").map(String::as_str),
Some("hello world")
);
assert_eq!(
values.get("existing").map(String::as_str),
Some("ab\r\ncde\r\nf")
);
}
#[test]
fn get_form_submit_dispatches_cancelable_navigate_event_with_source_element() {
let mut vm = new_storage_test_vm("https://form-get-navigate.test/path/index.html");