Fully support <input type=color> (#36992)

This change adds a shadow-tree widget for `<input type=color>` elements.
It also involves some changes to the way layout interacts with the DOM,
because currently all `input` and `textarea` elements are rendered as
plain text and their descendants are ignored. This obviously doesn't
work for `<input type={color, date, range, etc}>`.


![image](https://github.com/user-attachments/assets/4f16c3b0-1f79-4095-b19d-1153f5853dd5)

<details><summary>HTML used for the screenshot above</summary>

```html
<input type=color>
```

</details>



Testing: I doubt that this affects WPT tests, because the appearance and
behaviour of the widget is almost entirely unspecified.

---------

Signed-off-by: Simon Wülker <simon.wuelker@arcor.de>
This commit is contained in:
Simon Wülker 2025-05-15 19:30:38 +02:00 committed by GitHub
parent f9382fcaa0
commit b100a98e1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 469 additions and 100 deletions

View file

@ -12,9 +12,13 @@ use std::str::FromStr;
use std::{f64, ptr};
use dom_struct::dom_struct;
use embedder_traits::{FilterPattern, InputMethodType};
use embedder_traits::{
EmbedderMsg, FilterPattern, FormControl as EmbedderFormControl, InputMethodType, RgbColor,
};
use encoding_rs::Encoding;
use euclid::{Point2D, Rect, Size2D};
use html5ever::{LocalName, Prefix, local_name, ns};
use ipc_channel::ipc;
use js::jsapi::{
ClippedTime, DateGetMsecSinceEpoch, Handle, JS_ClearPendingException, JSObject, NewDateObject,
NewUCRegExpObject, ObjectIsDate, RegExpFlag_UnicodeSets, RegExpFlags,
@ -25,7 +29,9 @@ use js::rust::{HandleObject, MutableHandleObject};
use net_traits::blob_url_store::get_blob_origin;
use net_traits::filemanager_thread::FileManagerThreadMsg;
use net_traits::{CoreResourceMsg, IpcSend};
use profile_traits::ipc;
use script_bindings::codegen::GenericBindings::ShadowRootBinding::{
ShadowRootMode, SlotAssignmentMode,
};
use style::attr::AttrValue;
use style::str::{split_commas, str_join};
use stylo_atoms::Atom;
@ -33,12 +39,12 @@ use stylo_dom::ElementState;
use time::{Month, OffsetDateTime, Time};
use unicode_bidi::{BidiClass, bidi_class};
use url::Url;
use webrender_api::units::DeviceIntRect;
use super::bindings::str::{FromInputValueString, ToInputValueString};
use crate::clipboard_provider::EmbedderClipboardProvider;
use crate::dom::activation::Activatable;
use crate::dom::attr::Attr;
use crate::dom::bindings::cell::DomRefCell;
use crate::dom::bindings::cell::{DomRefCell, Ref};
use crate::dom::bindings::codegen::Bindings::ElementBinding::ElementMethods;
use crate::dom::bindings::codegen::Bindings::EventBinding::EventMethods;
use crate::dom::bindings::codegen::Bindings::FileListBinding::FileListMethods;
@ -48,30 +54,33 @@ use crate::dom::bindings::codegen::Bindings::NodeBinding::{GetRootNodeOptions, N
use crate::dom::bindings::error::{Error, ErrorResult};
use crate::dom::bindings::inheritance::Castable;
use crate::dom::bindings::reflector::DomGlobal;
use crate::dom::bindings::root::{DomRoot, LayoutDom, MutNullableDom};
use crate::dom::bindings::str::{DOMString, USVString};
use crate::dom::bindings::root::{Dom, DomRoot, LayoutDom, MutNullableDom};
use crate::dom::bindings::str::{DOMString, FromInputValueString, ToInputValueString, USVString};
use crate::dom::clipboardevent::ClipboardEvent;
use crate::dom::compositionevent::CompositionEvent;
use crate::dom::document::Document;
use crate::dom::element::{AttributeMutation, Element, LayoutElementHelpers};
use crate::dom::element::{AttributeMutation, Element, ElementCreator, LayoutElementHelpers};
use crate::dom::event::{Event, EventBubbles, EventCancelable};
use crate::dom::eventtarget::EventTarget;
use crate::dom::file::File;
use crate::dom::filelist::{FileList, LayoutFileListHelpers};
use crate::dom::globalscope::GlobalScope;
use crate::dom::htmldatalistelement::HTMLDataListElement;
use crate::dom::htmldivelement::HTMLDivElement;
use crate::dom::htmlelement::HTMLElement;
use crate::dom::htmlfieldsetelement::HTMLFieldSetElement;
use crate::dom::htmlformelement::{
FormControl, FormDatum, FormDatumValue, FormSubmitterElement, HTMLFormElement, ResetFrom,
SubmittedFrom,
};
use crate::dom::htmlstyleelement::HTMLStyleElement;
use crate::dom::keyboardevent::KeyboardEvent;
use crate::dom::mouseevent::MouseEvent;
use crate::dom::node::{
BindContext, CloneChildrenFlag, Node, NodeDamage, NodeTraits, ShadowIncluding, UnbindContext,
};
use crate::dom::nodelist::NodeList;
use crate::dom::shadowroot::{IsUserAgentWidget, ShadowRoot};
use crate::dom::textcontrol::{TextControlElement, TextControlSelection};
use crate::dom::validation::{Validatable, is_barred_by_datalist_ancestor};
use crate::dom::validitystate::{ValidationFlags, ValidityState};
@ -92,6 +101,34 @@ const DEFAULT_RESET_VALUE: &str = "Reset";
const PASSWORD_REPLACEMENT_CHAR: char = '●';
const DEFAULT_FILE_INPUT_VALUE: &str = "No file chosen";
#[derive(Clone, JSTraceable, MallocSizeOf)]
#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)]
/// Contains references to the elements in the shadow tree for `<input type=range>`.
///
/// The shadow tree consists of a single div with the currently selected color as
/// the background.
struct InputTypeColorShadowTree {
color_value: Dom<HTMLDivElement>,
}
#[derive(Clone, JSTraceable, MallocSizeOf)]
#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)]
#[non_exhaustive]
enum ShadowTree {
Color(InputTypeColorShadowTree),
// TODO: Add shadow trees for other input types (range etc) here
}
const COLOR_TREE_STYLE: &str = "
#color-value {
width: 100%;
height: 100%;
box-sizing: border-box;
border: 1px solid gray;
border-radius: 2px;
}
";
/// <https://html.spec.whatwg.org/multipage/#attr-input-type>
#[derive(Clone, Copy, Default, JSTraceable, PartialEq)]
#[allow(dead_code)]
@ -172,8 +209,7 @@ impl InputType {
fn is_textual(&self) -> bool {
matches!(
*self,
InputType::Color |
InputType::Date |
InputType::Date |
InputType::DatetimeLocal |
InputType::Email |
InputType::Hidden |
@ -277,9 +313,16 @@ impl From<&Atom> for InputType {
#[derive(Debug, PartialEq)]
enum ValueMode {
/// <https://html.spec.whatwg.org/multipage/#dom-input-value-value>
Value,
/// <https://html.spec.whatwg.org/multipage/#dom-input-value-default>
Default,
/// <https://html.spec.whatwg.org/multipage/#dom-input-value-default-on>
DefaultOn,
/// <https://html.spec.whatwg.org/multipage/#dom-input-value-filename>
Filename,
}
@ -314,6 +357,7 @@ pub(crate) struct HTMLInputElement {
form_owner: MutNullableDom<HTMLFormElement>,
labels_node_list: MutNullableDom<NodeList>,
validity_state: MutNullableDom<ValidityState>,
shadow_tree: DomRefCell<Option<ShadowTree>>,
}
#[derive(JSTraceable)]
@ -372,6 +416,7 @@ impl HTMLInputElement {
form_owner: Default::default(),
labels_node_list: MutNullableDom::new(None),
validity_state: Default::default(),
shadow_tree: Default::default(),
}
}
@ -803,7 +848,7 @@ impl HTMLInputElement {
.map(DomRoot::from_ref)
}
// https://html.spec.whatwg.org/multipage/#suffering-from-being-missing
/// <https://html.spec.whatwg.org/multipage/#suffering-from-being-missing>
fn suffers_from_being_missing(&self, value: &DOMString) -> bool {
match self.input_type() {
// https://html.spec.whatwg.org/multipage/#checkbox-state-(type%3Dcheckbox)%3Asuffering-from-being-missing
@ -958,9 +1003,9 @@ impl HTMLInputElement {
failed_flags
}
// https://html.spec.whatwg.org/multipage/#suffering-from-an-underflow
// https://html.spec.whatwg.org/multipage/#suffering-from-an-overflow
// https://html.spec.whatwg.org/multipage/#suffering-from-a-step-mismatch
/// * <https://html.spec.whatwg.org/multipage/#suffering-from-an-underflow>
/// * <https://html.spec.whatwg.org/multipage/#suffering-from-an-overflow>
/// * <https://html.spec.whatwg.org/multipage/#suffering-from-a-step-mismatch>
fn suffers_from_range_issues(&self, value: &DOMString) -> ValidationFlags {
if value.is_empty() || !self.does_value_as_number_apply() {
return ValidationFlags::empty();
@ -1014,9 +1059,109 @@ impl HTMLInputElement {
failed_flags
}
/// Return a reference to the ShadowRoot that this element is a host of,
/// or create one if none exists.
fn shadow_root(&self, can_gc: CanGc) -> DomRoot<ShadowRoot> {
self.upcast::<Element>().shadow_root().unwrap_or_else(|| {
self.upcast::<Element>()
.attach_shadow(
IsUserAgentWidget::Yes,
ShadowRootMode::Closed,
false,
false,
false,
SlotAssignmentMode::Manual,
can_gc,
)
.expect("Attaching UA shadow root failed")
})
}
fn create_color_shadow_tree(&self, can_gc: CanGc) {
let document = self.owner_document();
let shadow_root = self.shadow_root(can_gc);
Node::replace_all(None, shadow_root.upcast::<Node>(), can_gc);
let color_value = HTMLDivElement::new(local_name!("div"), None, &document, None, can_gc);
color_value
.upcast::<Element>()
.SetId(DOMString::from("color-value"), can_gc);
shadow_root
.upcast::<Node>()
.AppendChild(color_value.upcast::<Node>(), can_gc)
.unwrap();
let style = HTMLStyleElement::new(
local_name!("style"),
None,
&document,
None,
ElementCreator::ScriptCreated,
can_gc,
);
style
.upcast::<Node>()
.SetTextContent(Some(DOMString::from(COLOR_TREE_STYLE)), can_gc);
shadow_root
.upcast::<Node>()
.AppendChild(style.upcast::<Node>(), can_gc)
.unwrap();
let _ = self
.shadow_tree
.borrow_mut()
.insert(ShadowTree::Color(InputTypeColorShadowTree {
color_value: color_value.as_traced(),
}));
}
/// Get a handle to the shadow tree for this input, assuming it's [InputType] is `Color`.
///
/// If the input is not currently a shadow host, a new shadow tree will be created.
///
/// If the input is a shadow host for a different kind of shadow tree then the old
/// tree will be removed and a new one will be created.
fn color_shadow_tree(&self, can_gc: CanGc) -> Ref<InputTypeColorShadowTree> {
let has_color_shadow_tree = self
.shadow_tree
.borrow()
.as_ref()
.is_some_and(|shadow_tree| matches!(shadow_tree, ShadowTree::Color(_)));
if !has_color_shadow_tree {
self.create_color_shadow_tree(can_gc);
}
let shadow_tree = self.shadow_tree.borrow();
Ref::filter_map(shadow_tree, |shadow_tree| {
let shadow_tree = shadow_tree.as_ref()?;
let ShadowTree::Color(color_tree) = shadow_tree;
Some(color_tree)
})
.ok()
.expect("UA shadow tree was not created")
}
fn update_shadow_tree_if_needed(&self, can_gc: CanGc) {
if self.input_type() == InputType::Color {
let color_shadow_tree = self.color_shadow_tree(can_gc);
let mut value = self.Value();
if value.str().is_valid_simple_color_string() {
value.make_ascii_lowercase();
} else {
value = DOMString::from("#000000");
}
let style = format!("background-color: {value}");
color_shadow_tree
.color_value
.upcast::<Element>()
.set_string_attribute(&local_name!("style"), style.into(), can_gc);
}
}
}
pub(crate) trait LayoutHTMLInputElementHelpers<'dom> {
/// Return a string that represents the contents of the element for layout.
fn value_for_layout(self) -> Cow<'dom, str>;
fn size_for_layout(self) -> u32;
fn selection_for_layout(self) -> Option<Range<usize>>;
@ -1075,16 +1220,15 @@ impl<'dom> LayoutHTMLInputElementHelpers<'dom> for LayoutDom<'dom, HTMLInputElem
Some(filelist) => {
let length = filelist.len();
if length == 0 {
return DEFAULT_FILE_INPUT_VALUE.into();
}
if length == 1 {
DEFAULT_FILE_INPUT_VALUE.into()
} else if length == 1 {
match filelist.file_for_layout(0) {
Some(file) => return file.name().to_string().into(),
None => return DEFAULT_FILE_INPUT_VALUE.into(),
Some(file) => file.name().to_string().into(),
None => DEFAULT_FILE_INPUT_VALUE.into(),
}
} else {
format!("{} files", length).into()
}
format!("{} files", length).into()
},
None => DEFAULT_FILE_INPUT_VALUE.into(),
}
@ -1103,6 +1247,9 @@ impl<'dom> LayoutHTMLInputElementHelpers<'dom> for LayoutDom<'dom, HTMLInputElem
self.placeholder().into()
}
},
InputType::Color => {
unreachable!("Input type color is explicitly not rendered as text");
},
_ => {
let text = self.get_raw_textinput_value();
if !text.is_empty() {
@ -1178,11 +1325,11 @@ impl TextControlElement for HTMLInputElement {
InputType::Week |
InputType::Time |
InputType::DatetimeLocal |
InputType::Number |
InputType::Color => true,
InputType::Number => true,
InputType::Button |
InputType::Checkbox |
InputType::Color |
InputType::File |
InputType::Hidden |
InputType::Image |
@ -1257,7 +1404,7 @@ impl HTMLInputElementMethods<crate::DomTypeHolder> for HTMLInputElement {
// https://html.spec.whatwg.org/multipage/#dom-input-checked
fn SetChecked(&self, checked: bool) {
self.update_checked_state(checked, true);
update_related_validity_states(self, CanGc::note())
self.value_changed(CanGc::note());
}
// https://html.spec.whatwg.org/multipage/#dom-input-readonly
@ -1349,7 +1496,7 @@ impl HTMLInputElementMethods<crate::DomTypeHolder> for HTMLInputElement {
},
}
update_related_validity_states(self, can_gc);
self.value_changed(can_gc);
self.upcast::<Node>().dirty(NodeDamage::OtherNodeDamage);
Ok(())
}
@ -1724,18 +1871,6 @@ fn perform_radio_group_validation(elem: &HTMLInputElement, group: Option<&Atom>,
}
}
fn update_related_validity_states(elem: &HTMLInputElement, can_gc: CanGc) {
match elem.input_type() {
InputType::Radio => {
perform_radio_group_validation(elem, elem.radio_group_name().as_ref(), can_gc)
},
_ => {
elem.validity_state()
.perform_validation_and_update(ValidationFlags::all(), can_gc);
},
}
}
// https://html.spec.whatwg.org/multipage/#radio-button-group
fn in_same_group(
other: &HTMLInputElement,
@ -1905,7 +2040,7 @@ impl HTMLInputElement {
InputType::Radio | InputType::Checkbox => {
self.update_checked_state(self.DefaultChecked(), false);
self.checked_changed.set(false);
update_related_validity_states(self, can_gc);
self.value_changed(can_gc);
},
InputType::Image => (),
_ => (),
@ -1949,8 +2084,9 @@ impl HTMLInputElement {
.collect()
});
let (chan, recv) = ipc::channel(self.global().time_profiler_chan().clone())
.expect("Error initializing channel");
let (chan, recv) =
profile_traits::ipc::channel(self.global().time_profiler_chan().clone())
.expect("Error initializing channel");
let msg =
FileManagerThreadMsg::SelectFiles(webview_id, filter, chan, origin, opt_test_paths);
resource_threads
@ -1977,8 +2113,9 @@ impl HTMLInputElement {
None => None,
};
let (chan, recv) = ipc::channel(self.global().time_profiler_chan().clone())
.expect("Error initializing channel");
let (chan, recv) =
profile_traits::ipc::channel(self.global().time_profiler_chan().clone())
.expect("Error initializing channel");
let msg =
FileManagerThreadMsg::SelectFile(webview_id, filter, chan, origin, opt_test_path);
resource_threads
@ -2348,6 +2485,70 @@ impl HTMLInputElement {
},
})
}
fn update_related_validity_states(&self, can_gc: CanGc) {
match self.input_type() {
InputType::Radio => {
perform_radio_group_validation(self, self.radio_group_name().as_ref(), can_gc)
},
_ => {
self.validity_state()
.perform_validation_and_update(ValidationFlags::all(), can_gc);
},
}
}
fn value_changed(&self, can_gc: CanGc) {
self.update_related_validity_states(can_gc);
self.update_shadow_tree_if_needed(can_gc);
}
/// <https://html.spec.whatwg.org/multipage/#show-the-picker,-if-applicable>
fn show_the_picker_if_applicable(&self, can_gc: CanGc) {
// FIXME: Implement most of this algorithm
// Step 2. If element is not mutable, then return.
if !self.is_mutable() {
return;
}
// Step 6. Otherwise, the user agent should show the relevant user interface for selecting a value for element,
// in the way it normally would when the user interacts with the control.
if self.input_type() == InputType::Color {
let (ipc_sender, ipc_receiver) =
ipc::channel::<Option<RgbColor>>().expect("Failed to create IPC channel!");
let document = self.owner_document();
let rect = self.upcast::<Node>().bounding_content_box_or_zero(can_gc);
let rect = Rect::new(
Point2D::new(rect.origin.x.to_px(), rect.origin.y.to_px()),
Size2D::new(rect.size.width.to_px(), rect.size.height.to_px()),
);
let current_value = self.Value();
let current_color = RgbColor {
red: u8::from_str_radix(&current_value[1..3], 16).unwrap(),
green: u8::from_str_radix(&current_value[3..5], 16).unwrap(),
blue: u8::from_str_radix(&current_value[5..7], 16).unwrap(),
};
document.send_to_embedder(EmbedderMsg::ShowFormControl(
document.webview_id(),
DeviceIntRect::from_untyped(&rect.to_box2d()),
EmbedderFormControl::ColorPicker(current_color, ipc_sender),
));
let Ok(response) = ipc_receiver.recv() else {
log::error!("Failed to receive response");
return;
};
if let Some(selected_color) = response {
let formatted_color = format!(
"#{:0>2x}{:0>2x}{:0>2x}",
selected_color.red, selected_color.green, selected_color.blue
);
let _ = self.SetValue(formatted_color.into(), can_gc);
}
}
}
}
impl VirtualMethods for HTMLInputElement {
@ -2359,6 +2560,7 @@ impl VirtualMethods for HTMLInputElement {
self.super_type()
.unwrap()
.attribute_mutated(attr, mutation, can_gc);
match *attr.local_name() {
local_name!("disabled") => {
let disabled_state = match mutation {
@ -2553,7 +2755,7 @@ impl VirtualMethods for HTMLInputElement {
_ => {},
}
update_related_validity_states(self, can_gc);
self.value_changed(can_gc);
}
fn parse_plain_attribute(&self, name: &LocalName, value: DOMString) -> AttrValue {
@ -2584,7 +2786,8 @@ impl VirtualMethods for HTMLInputElement {
if self.input_type() == InputType::Radio {
self.radio_group_updated(self.radio_group_name().as_ref());
}
update_related_validity_states(self, can_gc);
self.value_changed(can_gc);
}
fn unbind_from_tree(&self, context: &UnbindContext, can_gc: CanGc) {
@ -2730,7 +2933,7 @@ impl VirtualMethods for HTMLInputElement {
}
}
update_related_validity_states(self, can_gc);
self.value_changed(can_gc);
}
// https://html.spec.whatwg.org/multipage/#the-input-element%3Aconcept-node-clone-ext
@ -2752,7 +2955,7 @@ impl VirtualMethods for HTMLInputElement {
elem.textinput
.borrow_mut()
.set_content(self.textinput.borrow().get_content());
update_related_validity_states(self, can_gc);
self.value_changed(can_gc);
}
}
@ -2861,7 +3064,8 @@ impl Activatable for HTMLInputElement {
},
// https://html.spec.whatwg.org/multipage/#checkbox-state-(type=checkbox):input-activation-behavior
// https://html.spec.whatwg.org/multipage/#radio-button-state-(type=radio):input-activation-behavior
InputType::Checkbox | InputType::Radio => true,
// https://html.spec.whatwg.org/multipage/#color-state-(type=color):input-activation-behavior
InputType::Checkbox | InputType::Radio | InputType::Color => true,
_ => false,
}
}
@ -2907,7 +3111,7 @@ impl Activatable for HTMLInputElement {
};
if activation_state.is_some() {
update_related_validity_states(self, can_gc);
self.value_changed(can_gc);
}
activation_state
@ -2966,7 +3170,7 @@ impl Activatable for HTMLInputElement {
_ => (),
}
update_related_validity_states(self, can_gc);
self.value_changed(can_gc);
}
/// <https://html.spec.whatwg.org/multipage/#input-activation-behavior>
@ -3028,6 +3232,10 @@ impl Activatable for HTMLInputElement {
},
// https://html.spec.whatwg.org/multipage/#file-upload-state-(type=file):input-activation-behavior
InputType::File => self.select_files(None, can_gc),
// https://html.spec.whatwg.org/multipage/#color-state-(type=color):input-activation-behavior
InputType::Color => {
self.show_the_picker_if_applicable(can_gc);
},
_ => (),
}
}