blob: de7842ab6490041cad37a0837ea7dd30a98d8cde [file] [log] [blame]
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "third_party/blink/renderer/core/html/forms/html_select_menu_element.h"
#include "third_party/blink/renderer/core/dom/events/event.h"
#include "third_party/blink/renderer/core/dom/flat_tree_traversal.h"
#include "third_party/blink/renderer/core/dom/shadow_root.h"
#include "third_party/blink/renderer/core/dom/text.h"
#include "third_party/blink/renderer/core/frame/web_feature.h"
#include "third_party/blink/renderer/core/html/forms/html_button_element.h"
#include "third_party/blink/renderer/core/html/html_div_element.h"
#include "third_party/blink/renderer/core/html/html_popup_element.h"
#include "third_party/blink/renderer/core/html/html_slot_element.h"
#include "third_party/blink/renderer/core/inspector/console_message.h"
#include "third_party/blink/renderer/platform/instrumentation/use_counter.h"
namespace blink {
HTMLSelectMenuElement::HTMLSelectMenuElement(Document& document)
: HTMLElement(html_names::kSelectmenuTag, document) {
DCHECK(RuntimeEnabledFeatures::HTMLSelectMenuElementEnabled());
DCHECK(RuntimeEnabledFeatures::HTMLPopupElementEnabled());
UseCounter::Count(document, WebFeature::kSelectMenuElement);
// TODO(crbug.com/1121840) This should really be a user-agent shadow root.
// But, these don't support name-based assignment (see
// ShouldAssignToCustomSlot). Perhaps names-based slot assignment can be added
// to user-agent shadows? See crbug.com/1179356.
AttachShadowRootInternal(ShadowRootType::kClosed);
CreateShadowSubtree();
}
void HTMLSelectMenuElement::CreateShadowSubtree() {
DCHECK(IsShadowHost(this));
Document& document = this->GetDocument();
// TODO(crbug.com/1121840) Where to put the styles for the default elements in
// the shadow tree? We'd like to have them in the UA styles (html.css), but
// the -webkit pseudo-id selectors only work if this is a UA shadow DOM. We
// can't use a UA shadow DOMs because these don't currently support named
// slots. For now, just set the style attributes with raw inline strings, but
// we should be able to do something better than this. Probably the solution
// is to get named slots working in UA shadow DOM (crbug.com/1179356), and
// then we can switch to that and use the -webkit pseudo-id selectors.
auto* button_slot = MakeGarbageCollected<HTMLSlotElement>(document);
slotchange_listener_ =
MakeGarbageCollected<HTMLSelectMenuElement::SlotChangeEventListener>(
this);
button_slot->addEventListener(event_type_names::kSlotchange,
slotchange_listener_, false);
button_slot->setAttribute(html_names::kNameAttr, kButtonPartName);
button_part_ = MakeGarbageCollected<HTMLButtonElement>(document);
button_part_->setAttribute(html_names::kPartAttr, kButtonPartName);
button_part_->setAttribute(html_names::kStyleAttr,
R"CSS(
display: inline-flex;
align-items: center;
background-color: #ffffff;
padding: 0 0 0 3px;
border: 1px solid #767676;
border-radius: 2px;
cursor: default;
)CSS");
button_part_listener_ =
MakeGarbageCollected<HTMLSelectMenuElement::ButtonPartEventListener>(
this);
button_part_->addEventListener(event_type_names::kClick,
button_part_listener_, false);
selected_value_part_ = MakeGarbageCollected<HTMLDivElement>(document);
selected_value_part_->setAttribute(html_names::kPartAttr,
kSelectedValuePartName);
auto* button_icon = MakeGarbageCollected<HTMLDivElement>(document);
button_icon->setAttribute(html_names::kStyleAttr,
R"CSS(
background-image: url(
'data:image/svg+xml,\
<svg width="20" height="14" viewBox="0 0 20 16" fill="none" xmlns="http://www.w3.org/2000/svg">\
<path d="M4 6 L10 12 L 16 6" stroke="WindowText" stroke-width="3" stroke-linejoin="round"/>\
</svg>');
background-origin: content-box;
background-repeat: no-repeat;
background-size: contain;
height: 1.0em;
margin-inline-start: 4px;
opacity: 1;
outline: none;
padding-bottom: 2px;
padding-inline-start: 3px;
padding-inline-end: 3px;
padding-top: 2px;
width: 1.2em;
)CSS");
auto* listbox_slot = MakeGarbageCollected<HTMLSlotElement>(document);
listbox_slot->addEventListener(event_type_names::kSlotchange,
slotchange_listener_, false);
listbox_slot->setAttribute(html_names::kNameAttr, kListboxPartName);
listbox_part_ = MakeGarbageCollected<HTMLPopupElement>(document);
listbox_part_->setAttribute(html_names::kPartAttr, kListboxPartName);
auto* options_slot = MakeGarbageCollected<HTMLSlotElement>(document);
options_slot->addEventListener(event_type_names::kSlotchange,
slotchange_listener_, false);
button_part_->AppendChild(selected_value_part_);
button_part_->AppendChild(button_icon);
button_slot->AppendChild(button_part_);
listbox_part_->appendChild(options_slot);
listbox_slot->appendChild(listbox_part_);
this->GetShadowRoot()->AppendChild(button_slot);
this->GetShadowRoot()->AppendChild(listbox_slot);
option_part_listener_ =
MakeGarbageCollected<HTMLSelectMenuElement::OptionPartEventListener>(
this);
}
String HTMLSelectMenuElement::value() const {
if (selected_option_) {
return selected_option_->innerText();
}
return "";
}
void HTMLSelectMenuElement::setValue(const String& value, bool send_events) {
// Find the option with innerText matching the given parameter and make it the
// current selection.
for (auto& option : option_parts_) {
if (option->innerText() == value) {
SetSelectedOption(option);
break;
}
}
}
bool HTMLSelectMenuElement::IsOpen() const {
// TODO(crbug.com/1121840) listbox_part_ can be null if
// the author has filled the listbox slot without including
// a replacement listbox part. Instead of null checks like this,
// we should consider refusing to render the control at all if
// either of the key parts (button or listbox) are missing.
return listbox_part_ != nullptr && listbox_part_->open();
}
void HTMLSelectMenuElement::Open() {
if (listbox_part_ != nullptr && !IsOpen()) {
listbox_part_->show();
}
}
void HTMLSelectMenuElement::Close() {
if (listbox_part_ != nullptr && IsOpen()) {
listbox_part_->hide();
}
}
void HTMLSelectMenuElement::UpdatePartElements() {
Element* new_button_part = nullptr;
Element* new_selected_value_part = nullptr;
HTMLPopupElement* new_listbox_part = nullptr;
HeapLinkedHashSet<Member<Element>> new_option_parts;
for (Node* node = FlatTreeTraversal::FirstChild(*this); node != nullptr;
node = FlatTreeTraversal::Next(*node, this)) {
// For all part types, if there are multiple candidates, choose the
// one that comes first in the flat tree traversal.
auto* element = DynamicTo<Element>(node);
if (element == nullptr) {
continue;
}
if (new_button_part == nullptr &&
element->getAttribute(html_names::kPartAttr) == kButtonPartName) {
new_button_part = element;
}
if (new_selected_value_part == nullptr &&
element->getAttribute(html_names::kPartAttr) ==
kSelectedValuePartName) {
new_selected_value_part = element;
}
if (new_listbox_part == nullptr &&
element->getAttribute(html_names::kPartAttr) == kListboxPartName) {
// TODO(crbug.com/1121840) Should we allow non-<popup> elements to be
// the listbox part? If so, how to manage open/closed state?
if (auto* popup_element = DynamicTo<HTMLPopupElement>(element)) {
new_listbox_part = popup_element;
} else {
GetDocument().AddConsoleMessage(MakeGarbageCollected<ConsoleMessage>(
mojom::blink::ConsoleMessageSource::kRendering,
mojom::blink::ConsoleMessageLevel::kWarning,
"Found non-<popup> element labeled as listbox under <selectmenu>, "
"but only a <popup> can be used for the <selectmenu>'s listbox "
"part."));
}
}
// The fact that this comes after the clauses for other parts
// means that an <option> element labeled as another part will
// be handled as the other part type. E.g. <option part="button">
// will be treated as a button.
// TODO(crbug.com/1121840) Only include options that are inside the
// listbox, or allow them to be anywhere in the <selectmenu>?
if (element->getAttribute(html_names::kPartAttr) == kOptionPartName ||
IsA<HTMLOptionElement>(element)) {
new_option_parts.insert(element);
}
}
if (button_part_ != new_button_part) {
if (button_part_) {
button_part_->removeEventListener(event_type_names::kClick,
button_part_listener_, false);
}
if (new_button_part) {
new_button_part->addEventListener(event_type_names::kClick,
button_part_listener_, false);
}
button_part_ = new_button_part;
}
selected_value_part_ = new_selected_value_part;
listbox_part_ = new_listbox_part;
bool updateSelectedOption = false;
for (auto& option : option_parts_) {
if (!new_option_parts.Contains(option)) {
option->removeEventListener(event_type_names::kClick,
option_part_listener_, false);
if (option == selected_option_) {
updateSelectedOption = true;
}
// TODO(crbug.com/1121840) Whenever we figure out how to set
// focusability properly (without using tabIndex), we should undo up
// those changes here for elements that are no longer option parts.
}
}
for (auto& option : new_option_parts) {
if (!option_parts_.Contains(option)) {
option->addEventListener(event_type_names::kClick, option_part_listener_,
false);
// TODO(crbug.com/1121840) We don't want to actually change the attribute,
// and if tabindex is already set we shouldn't override it. So we need to
// come up with something else here.
option->setTabIndex(-1);
}
}
option_parts_ = new_option_parts;
if (updateSelectedOption || selected_option_ == nullptr) {
// If the currently selected option was removed, or if
// we didn't have a selected option previously, change the
// selection to the first option part, if there is one.
SetSelectedOption(option_parts_.size() > 0 ? option_parts_.front()
: nullptr);
}
}
void HTMLSelectMenuElement::SetSelectedOption(Element* selected_option) {
if (selected_option_ == selected_option)
return;
selected_option_ = selected_option;
UpdateSelectedValuePartContents();
}
void HTMLSelectMenuElement::UpdateSelectedValuePartContents() {
// Null-check here because the selected-value part is optional; the author
// might replace the button contents and not provide a selected-value part if
// they want to show something in the button other than the current value of
// the <selectmenu>.
if (selected_value_part_) {
selected_value_part_->setTextContent(
selected_option_ ? selected_option_->innerText() : "");
}
}
void HTMLSelectMenuElement::ButtonPartEventListener::Invoke(ExecutionContext*,
Event* event) {
if (event->type() == event_type_names::kClick &&
!select_menu_element_->IsOpen()) {
select_menu_element_->Open();
}
}
void HTMLSelectMenuElement::OptionPartEventListener::Invoke(ExecutionContext*,
Event* event) {
if (event->type() == event_type_names::kClick) {
Element* target_element =
DynamicTo<Element>(event->currentTarget()->ToNode());
DCHECK(target_element);
DCHECK(select_menu_element_->option_parts_.Contains(target_element));
select_menu_element_->SetSelectedOption(target_element);
select_menu_element_->listbox_part_->hide();
}
}
void HTMLSelectMenuElement::SlotChangeEventListener::Invoke(ExecutionContext*,
Event* event) {
DCHECK_EQ(event->type(), event_type_names::kSlotchange);
// TODO(crbug.com/1121840) Slotchange doesn't fire when
// the children of slotted content change, so it isn't
// enough to do this here. We might need to set up mutation observers
// or something to watch for changes in addition or instead of the
// slotchange event.
// Also, if we want to match the select behavior, then we should be
// doing this update synchronously. See failing tests in
// external/wpt/html/semantics/forms/the-selectmenu-element/selectmenu-value.html
select_menu_element_->UpdatePartElements();
}
void HTMLSelectMenuElement::Trace(Visitor* visitor) const {
visitor->Trace(button_part_listener_);
visitor->Trace(option_part_listener_);
visitor->Trace(slotchange_listener_);
visitor->Trace(button_part_);
visitor->Trace(selected_value_part_);
visitor->Trace(listbox_part_);
visitor->Trace(option_parts_);
visitor->Trace(selected_option_);
HTMLElement::Trace(visitor);
}
constexpr char HTMLSelectMenuElement::kButtonPartName[];
constexpr char HTMLSelectMenuElement::kSelectedValuePartName[];
constexpr char HTMLSelectMenuElement::kListboxPartName[];
constexpr char HTMLSelectMenuElement::kOptionPartName[];
} // namespace blink