LibWeb: Add HTMLSelectedContentElement for customizable select

Introduce the HTMLSelectedContentElement and integrate it into
<select>, <option> and HTMLParser.

See whatwg/html#10548.

There are two bugs with WPT tests which causes the third subtest
in selectedcontent.html and selectedcontent-mutations.html fail.
See whatwg/html#11882, web-platform-tests/wpt#55849.
This commit is contained in:
Feng Yu 2025-12-07 16:37:54 -08:00 committed by Sam Atkins
parent 89d50befb0
commit b58fcaeecf
Notes: github-actions[bot] 2025-12-12 12:07:51 +00:00
25 changed files with 945 additions and 205 deletions

View file

@ -22,6 +22,7 @@
#include <LibWeb/HTML/HTMLOptGroupElement.h>
#include <LibWeb/HTML/HTMLOptionElement.h>
#include <LibWeb/HTML/HTMLSelectElement.h>
#include <LibWeb/HTML/HTMLSelectedContentElement.h>
#include <LibWeb/HTML/Navigable.h>
#include <LibWeb/HTML/Numbers.h>
#include <LibWeb/HTML/Window.h>
@ -290,8 +291,8 @@ void HTMLSelectElement::reset_algorithm()
// https://html.spec.whatwg.org/multipage/form-elements.html#dom-select-selectedindex
WebIDL::Long HTMLSelectElement::selected_index() const
{
// The selectedIndex IDL attribute, on getting, must return the index of the first option element in the list of options
// in tree order that has its selectedness set to true, if any. If there isn't one, then it must return 1.
// The selectedIndex getter steps are to return the index of the first option element in this's list of options
// in tree order that has its selectedness set to true, if any. If there isn't one, then return 1.
update_cached_list_of_options();
WebIDL::Long index = 0;
@ -303,23 +304,41 @@ WebIDL::Long HTMLSelectElement::selected_index() const
return -1;
}
void HTMLSelectElement::set_selected_index(WebIDL::Long index)
// https://html.spec.whatwg.org/multipage/form-elements.html#dom-select-selectedindex
WebIDL::ExceptionOr<void> HTMLSelectElement::set_selected_index(WebIDL::Long index)
{
update_cached_list_of_options();
// On setting, the selectedIndex attribute must set the selectedness of all the option elements in the list of options to false,
// and then the option element in the list of options whose index is the given new value,
// if any, must have its selectedness set to true and its dirtiness set to true.
for (auto& option : m_cached_list_of_options)
option->set_selected_internal(false);
// The selectedIndex setter steps are:
ScopeGuard guard { [&]() { update_inner_text_element(); } };
if (index < 0 || static_cast<size_t>(index) >= m_cached_list_of_options.size())
return;
// 1. Let firstMatchingOption be null.
GC::Ptr<HTMLOptionElement> first_matching_option;
auto& selected_option = m_cached_list_of_options[index];
selected_option->set_selected_internal(true);
selected_option->m_dirty = true;
// 2. For each option of this's list of options:
update_cached_list_of_options();
WebIDL::Long current_index = 0;
for (auto const& option : m_cached_list_of_options) {
// 1. Set option's selectedness to false.
option->set_selected_internal(false);
// 2. If firstMatchingOption is null and option's index is equal to the given value, then
// set firstMatchingOption to option.
if (!first_matching_option && current_index == index)
first_matching_option = option;
current_index++;
}
// 3. If firstMatchingOption is not null, then set firstMatchingOption's selectedness to true
// and set firstMatchingOption's dirtiness to true.
if (first_matching_option) {
first_matching_option->set_selected_internal(true);
first_matching_option->m_dirty = true;
}
// 4. Run update a select's selectedcontent given this.
TRY(update_selectedcontent());
return {};
}
// https://html.spec.whatwg.org/multipage/interaction.html#dom-tabindex
@ -393,8 +412,12 @@ Optional<ARIA::Role> HTMLSelectElement::default_role() const
return ARIA::Role::combobox;
}
// https://html.spec.whatwg.org/multipage/form-elements.html#dom-select-value
Utf16String HTMLSelectElement::value() const
{
// The value getter steps are to return the value of the first option element in this's
// list of options in tree order that has its selectedness set to true, if any. If there
// isn't one, then return the empty string.
update_cached_list_of_options();
for (auto const& option_element : m_cached_list_of_options)
if (option_element->selected())
@ -402,29 +425,61 @@ Utf16String HTMLSelectElement::value() const
return {};
}
// https://html.spec.whatwg.org/multipage/form-elements.html#dom-select-value
WebIDL::ExceptionOr<void> HTMLSelectElement::set_value(Utf16String const& value)
{
// The value setter steps are:
ScopeGuard guard { [&]() { update_inner_text_element(); } };
update_cached_list_of_options();
for (auto const& option_element : list_of_options())
option_element->set_selected(option_element->value() == value);
update_inner_text_element();
// 1. Let firstMatchingOption be null.
GC::Ptr<HTMLOptionElement> first_matching_option;
// 2. For each option of this's list of options:
for (auto const& option_element : m_cached_list_of_options) {
// 1. Set option's selectedness to false.
option_element->set_selected_internal(false);
// 2. If firstMatchingOption is null and option's value is equal to the given value, then set
// firstMatchingOption to option.
if (!first_matching_option && option_element->value() == value)
first_matching_option = option_element;
}
// 3. If firstMatchingOption is not null, then set firstMatchingOption's selectedness to true and set
// firstMatchingOption's dirtiness to true.
if (first_matching_option) {
first_matching_option->set_selected_internal(true);
first_matching_option->m_dirty = true;
}
// 4. Run update a select's selectedcontent given this.
TRY(update_selectedcontent());
return {};
}
void HTMLSelectElement::queue_input_and_change_events()
// https://html.spec.whatwg.org/multipage/form-elements.html#send-select-update-notifications
void HTMLSelectElement::send_select_update_notifications()
{
// When the user agent is to send select update notifications, queue an element task on the user interaction task source given the select element to run these steps:
// To send select update notifications for a select element element, queue an element task on
// the user interaction task source given element to run these steps:
queue_an_element_task(HTML::Task::Source::UserInteraction, [this] {
// 1. Set the select element's user validity to true.
m_user_validity = true;
// 2. Fire an event named input at the select element, with the bubbles and composed attributes initialized to true.
// 2. Run update a select's selectedcontent given element.
MUST(update_selectedcontent());
// FIXME: 3. Run clone selected option into select button given element.
// 4. Fire an event named input at element, with the bubbles and composed attributes initialized to true.
auto input_event = DOM::Event::create(realm(), HTML::EventNames::input);
input_event->set_bubbles(true);
input_event->set_composed(true);
dispatch_event(input_event);
// 3. Fire an event named change at the select element, with the bubbles attribute initialized to true.
// 5. Fire an event named change at element, with the bubbles attribute initialized to true.
auto change_event = DOM::Event::create(realm(), HTML::EventNames::change);
change_event->set_bubbles(true);
dispatch_event(*change_event);
@ -577,7 +632,7 @@ void HTMLSelectElement::did_select_item(Optional<u32> const& id)
}
update_inner_text_element();
queue_input_and_change_events();
send_select_update_notifications();
}
void HTMLSelectElement::form_associated_element_was_inserted()
@ -674,37 +729,35 @@ void HTMLSelectElement::update_inner_text_element()
}
// https://html.spec.whatwg.org/multipage/form-elements.html#selectedness-setting-algorithm
// https://whatpr.org/html/11890/form-elements.html#selectedness-setting-algorithm
void HTMLSelectElement::update_selectedness()
{
if (has_attribute(AttributeNames::multiple))
return;
// The selectedness setting algorithm, given a select element element, is to run the following steps:
update_cached_list_of_options();
// If element's multiple attribute is absent, and element's display size is 1,
if (display_size() == 1) {
// and no option elements in the element's list of options have their selectedness set to true,
if (m_cached_number_of_selected_options == 0) {
// then set the selectedness of the first option element in the list of options in tree order
// that is not disabled, if any, to true, and return.
for (auto const& option_element : m_cached_list_of_options) {
if (!option_element->disabled()) {
option_element->set_selected_internal(true);
update_inner_text_element();
break;
}
}
return;
}
}
// 1. Let updateSelectedcontent be false.
auto should_update_selectedcontent = false;
// If element's multiple attribute is absent,
// and two or more option elements in element's list of options have their selectedness set to true,
// then set the selectedness of all but the last option element with its selectedness set to true
// in the list of options in tree order to false.
if (m_cached_number_of_selected_options >= 2) {
// then set the selectedness of all but the last option element with its selectedness set to true
// in the list of options in tree order to false.
// 2. If element 's multiple attribute is absent, and element's display size is 1,
// and no option elements in the element's list of options have their selectedness set to true, then
if (!has_attribute(AttributeNames::multiple) && display_size() == 1 && m_cached_number_of_selected_options == 0) {
// 1. Set the selectedness of the first option element in the list of options in tree order
// that is not disabled, if any, to true.
for (auto const& option_element : m_cached_list_of_options) {
if (!option_element->disabled()) {
option_element->set_selected_internal(true);
break;
}
}
// 2. Set updateSelectedcontent to true.
should_update_selectedcontent = true;
}
// Otherwise, if element's multiple attribute is absent,
// and two or more option elements in element's list of options have their selectedness set to true, then:
else if (!has_attribute(AttributeNames::multiple) && m_cached_number_of_selected_options >= 2) {
// 1. Set the selectedness of all but the last option element with its selectedness set to true
// in the list of options in tree order to false.
GC::Ptr<HTML::HTMLOptionElement> last_selected_option;
u64 last_selected_option_update_index = 0;
@ -722,8 +775,16 @@ void HTMLSelectElement::update_selectedness()
if (option_element != last_selected_option)
option_element->set_selected_internal(false);
}
// 2. Set updateSelectedcontent to true.
should_update_selectedcontent = true;
}
// 4. If updateSelectedcontent is true, then run update a select's selectedcontent given element.
if (should_update_selectedcontent) {
MUST(update_selectedcontent());
update_inner_text_element();
}
update_inner_text_element();
}
bool HTMLSelectElement::is_focusable() const
@ -746,6 +807,88 @@ HTMLOptionElement* HTMLSelectElement::placeholder_label_option() const
return {};
}
// https://html.spec.whatwg.org/multipage/form-elements.html#select-enabled-selectedcontent
GC::Ptr<HTMLSelectedContentElement> HTMLSelectElement::enabled_selectedcontent() const
{
// To get a select's enabled selectedcontent given a select element select:
// 1. If select has the multiple attribute, then return null.
if (has_attribute(AttributeNames::multiple))
return nullptr;
// 2. Let selectedcontent be the first selectedcontent element descendant of select in tree order if any such
// element exists; otherwise return null.
GC::Ptr<HTMLSelectedContentElement> selectedcontent;
for_each_in_subtree_of_type<HTMLSelectedContentElement>([&](auto& element) {
selectedcontent = const_cast<HTMLSelectedContentElement*>(&element);
return TraversalDecision::Break;
});
if (!selectedcontent)
return nullptr;
// 3. If selectedcontent is disabled, then return null.
if (selectedcontent->disabled())
return nullptr;
// 4. Return selectedcontent.
return selectedcontent;
}
// https://html.spec.whatwg.org/multipage/form-elements.html#clear-a-select%27s-non-primary-selectedcontent-elements
void HTMLSelectElement::clear_non_primary_selectedcontent()
{
// To clear a select's non-primary selectedcontent elements, given a select element select:
// 1. Let passedFirstSelectedcontent be false.
bool passed_first_selectedcontent = false;
// 2. For each descendant of select's descendants in tree order that is a selectedcontent element:
for_each_in_subtree_of_type<HTMLSelectedContentElement>([&](auto& element) {
// 1. If passedFirstSelectedcontent is false, then set passedFirstSelectedcontent to true.
if (!passed_first_selectedcontent)
passed_first_selectedcontent = true;
// 2. Otherwise, run clear a selectedcontent given descendant.
else
element.clear_selectedcontent();
return TraversalDecision::Continue;
});
}
// https://html.spec.whatwg.org/multipage/form-elements.html#update-a-select%27s-selectedcontent
WebIDL::ExceptionOr<void> HTMLSelectElement::update_selectedcontent()
{
// To update a select's selectedcontent given a select element select:
// 1. Let selectedcontent be the result of get a select's enabled selectedcontent given select.
auto selectedcontent = enabled_selectedcontent();
// 2. If selectedcontent is null, then return.
if (!selectedcontent)
return {};
// 3. Let option be the first option in select's list of options whose selectedness is true,
// if any such option exists, otherwise null.
update_cached_list_of_options();
GC::Ptr<HTML::HTMLOptionElement> option;
for (auto const& candidate : m_cached_list_of_options) {
if (candidate->selected()) {
option = candidate;
break;
}
}
// 4. If option is null, then run clear a selectedcontent given selectedcontent.
if (!option) {
selectedcontent->clear_selectedcontent();
return {};
}
// 5. Otherwise, run clone an option into a selectedcontent given option and selectedcontent.
TRY(option->clone_into_selectedcontent(*selectedcontent));
return {};
}
// https://html.spec.whatwg.org/multipage/form-elements.html#the-select-element%3Asuffering-from-being-missing
bool HTMLSelectElement::suffering_from_being_missing() const
{