/* * Copyright (C) 2010 Nokia Corporation and/or its subsidiary(-ies). * Copyright (C) 1999 Lars Knoll (knoll@kde.org) * (C) 1999 Antti Koivisto (koivisto@kde.org) * (C) 2001 Dirk Mueller (mueller@kde.org) * Copyright (C) 2004, 2005, 2006, 2007, 2009, 2010, 2011 Apple Inc. All rights reserved. * (C) 2006 Alexey Proskuryakov (ap@nypop.com) * Copyright (C) 2010 Google Inc. All rights reserved. * Copyright (C) 2009 Torch Mobile Inc. All rights reserved. (http://www.torchmobile.com/) * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Library General Public * License as published by the Free Software Foundation; either * version 2 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Library General Public License for more details. * * You should have received a copy of the GNU Library General Public License * along with this library; see the file COPYING.LIB. If not, write to * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, * Boston, MA 02110-1301, USA. * */ #include "config.h" #include "HTMLSelectElement.h" #include "AXObjectCache.h" #include "DOMFormData.h" #include "ElementTraversal.h" #include "EventHandler.h" #include "EventNames.h" #include "FormController.h" #include "Frame.h" #include "GenericCachedHTMLCollection.h" #include "HTMLFormElement.h" #include "HTMLHRElement.h" #include "HTMLNames.h" #include "HTMLOptGroupElement.h" #include "HTMLOptionElement.h" #include "HTMLOptionsCollection.h" #include "HTMLParserIdioms.h" #include "KeyboardEvent.h" #include "LocalizedStrings.h" #include "MouseEvent.h" #include "NodeRareData.h" #include "Page.h" #include "PlatformMouseEvent.h" #include "RenderListBox.h" #include "RenderMenuList.h" #include "RenderTheme.h" #include "Settings.h" #include "SpatialNavigation.h" #include #include namespace WebCore { WTF_MAKE_ISO_ALLOCATED_IMPL(HTMLSelectElement); using namespace WTF::Unicode; using namespace HTMLNames; // Upper limit agreed upon with representatives of Opera and Mozilla. static const unsigned maxSelectItems = 10000; HTMLSelectElement::HTMLSelectElement(const QualifiedName& tagName, Document& document, HTMLFormElement* form) : HTMLFormControlElementWithState(tagName, document, form) , m_typeAhead(this) , m_size(0) , m_lastOnChangeIndex(-1) , m_activeSelectionAnchorIndex(-1) , m_activeSelectionEndIndex(-1) , m_isProcessingUserDrivenChange(false) , m_multiple(false) , m_activeSelectionState(false) , m_allowsNonContiguousSelection(false) , m_shouldRecalcListItems(false) { ASSERT(hasTagName(selectTag)); } Ref HTMLSelectElement::create(const QualifiedName& tagName, Document& document, HTMLFormElement* form) { ASSERT(tagName.matches(selectTag)); return adoptRef(*new HTMLSelectElement(tagName, document, form)); } void HTMLSelectElement::didRecalcStyle(Style::Change styleChange) { // Even though the options didn't necessarily change, we will call setOptionsChangedOnRenderer for its side effect // of recomputing the width of the element. We need to do that if the style change included a change in zoom level. setOptionsChangedOnRenderer(); HTMLFormControlElement::didRecalcStyle(styleChange); } const AtomString& HTMLSelectElement::formControlType() const { static MainThreadNeverDestroyed selectMultiple("select-multiple", AtomString::ConstructFromLiteral); static MainThreadNeverDestroyed selectOne("select-one", AtomString::ConstructFromLiteral); return m_multiple ? selectMultiple : selectOne; } void HTMLSelectElement::deselectItems(HTMLOptionElement* excludeElement) { deselectItemsWithoutValidation(excludeElement); updateValidity(); } void HTMLSelectElement::optionSelectedByUser(int optionIndex, bool fireOnChangeNow, bool allowMultipleSelection) { // User interaction such as mousedown events can cause list box select elements to send change events. // This produces that same behavior for changes triggered by other code running on behalf of the user. if (!usesMenuList()) { updateSelectedState(optionToListIndex(optionIndex), allowMultipleSelection, false); updateValidity(); if (auto* renderer = this->renderer()) renderer->updateFromElement(); if (fireOnChangeNow) listBoxOnChange(); return; } // Bail out if this index is already the selected one, to avoid running unnecessary JavaScript that can mess up // autofill when there is no actual change (see https://bugs.webkit.org/show_bug.cgi?id=35256 and ). // The selectOption function does not behave this way, possibly because other callers need a change event even // in cases where the selected option is not change. if (optionIndex == selectedIndex()) return; selectOption(optionIndex, DeselectOtherOptions | (fireOnChangeNow ? DispatchChangeEvent : 0) | UserDriven); } bool HTMLSelectElement::hasPlaceholderLabelOption() const { // The select element has no placeholder label option if it has an attribute "multiple" specified or a display size of non-1. // // The condition "size() > 1" is not compliant with the HTML5 spec as of Dec 3, 2010. "size() != 1" is correct. // Using "size() > 1" here because size() may be 0 in WebKit. // See the discussion at https://bugs.webkit.org/show_bug.cgi?id=43887 // // "0 size()" happens when an attribute "size" is absent or an invalid size attribute is specified. // In this case, the display size should be assumed as the default. // The default display size is 1 for non-multiple select elements, and 4 for multiple select elements. // // Finally, if size() == 0 and non-multiple, the display size can be assumed as 1. if (multiple() || size() > 1) return false; int listIndex = optionToListIndex(0); ASSERT(listIndex >= 0); if (listIndex < 0) return false; HTMLOptionElement& option = downcast(*listItems()[listIndex]); return !listIndex && option.value().isEmpty(); } String HTMLSelectElement::validationMessage() const { if (!willValidate()) return String(); if (customError()) return customValidationMessage(); return valueMissing() ? validationMessageValueMissingForSelectText() : String(); } bool HTMLSelectElement::valueMissing() const { if (!willValidate()) return false; if (!isRequired()) return false; int firstSelectionIndex = selectedIndex(); // If a non-placeholer label option is selected (firstSelectionIndex > 0), it's not value-missing. return firstSelectionIndex < 0 || (!firstSelectionIndex && hasPlaceholderLabelOption()); } void HTMLSelectElement::listBoxSelectItem(int listIndex, bool allowMultiplySelections, bool shift, bool fireOnChangeNow) { if (!multiple()) optionSelectedByUser(listToOptionIndex(listIndex), fireOnChangeNow, false); else { updateSelectedState(listIndex, allowMultiplySelections, shift); updateValidity(); if (fireOnChangeNow) listBoxOnChange(); } } bool HTMLSelectElement::usesMenuList() const { #if !PLATFORM(IOS_FAMILY) if (RenderTheme::singleton().delegatesMenuListRendering()) return true; return !m_multiple && m_size <= 1; #else return !m_multiple; #endif } int HTMLSelectElement::activeSelectionStartListIndex() const { if (m_activeSelectionAnchorIndex >= 0) return m_activeSelectionAnchorIndex; return optionToListIndex(selectedIndex()); } int HTMLSelectElement::activeSelectionEndListIndex() const { if (m_activeSelectionEndIndex >= 0) return m_activeSelectionEndIndex; return lastSelectedListIndex(); } ExceptionOr HTMLSelectElement::add(const OptionOrOptGroupElement& element, const std::optional& before) { RefPtr beforeElement; if (before) { beforeElement = WTF::switchOn(before.value(), [](const RefPtr& element) -> HTMLElement* { return element.get(); }, [this](int index) -> HTMLElement* { return item(index); } ); } HTMLElement& toInsert = WTF::switchOn(element, [](const auto& htmlElement) -> HTMLElement& { return *htmlElement; } ); return insertBefore(toInsert, beforeElement.get()); } void HTMLSelectElement::remove(int optionIndex) { int listIndex = optionToListIndex(optionIndex); if (listIndex < 0) return; listItems()[listIndex]->remove(); } String HTMLSelectElement::value() const { for (auto* item : listItems()) { if (is(*item)) { HTMLOptionElement& option = downcast(*item); if (option.selected()) return option.value(); } } return emptyString(); } void HTMLSelectElement::setValue(const String& value) { // Find the option with value() matching the given parameter and make it the current selection. unsigned optionIndex = 0; for (auto* item : listItems()) { if (is(*item)) { if (downcast(*item).value() == value) { setSelectedIndex(optionIndex); return; } ++optionIndex; } } setSelectedIndex(-1); } bool HTMLSelectElement::hasPresentationalHintsForAttribute(const QualifiedName& name) const { if (name == alignAttr) { // Don't map 'align' attribute. This matches what Firefox, Opera and IE do. // See http://bugs.webkit.org/show_bug.cgi?id=12072 return false; } return HTMLFormControlElementWithState::hasPresentationalHintsForAttribute(name); } void HTMLSelectElement::parseAttribute(const QualifiedName& name, const AtomString& value) { if (name == sizeAttr) { unsigned oldSize = m_size; unsigned size = limitToOnlyHTMLNonNegative(value); // Ensure that we've determined selectedness of the items at least once prior to changing the size. if (oldSize != size) updateListItemSelectedStates(); m_size = size; updateValidity(); if (m_size != oldSize) { invalidateStyleAndRenderersForSubtree(); setRecalcListItems(); updateValidity(); } } else if (name == multipleAttr) parseMultipleAttribute(value); else HTMLFormControlElementWithState::parseAttribute(name, value); } int HTMLSelectElement::defaultTabIndex() const { return 0; } bool HTMLSelectElement::isKeyboardFocusable(KeyboardEvent* event) const { if (renderer()) return isFocusable(); return HTMLFormControlElementWithState::isKeyboardFocusable(event); } bool HTMLSelectElement::isMouseFocusable() const { if (renderer()) return isFocusable(); return HTMLFormControlElementWithState::isMouseFocusable(); } bool HTMLSelectElement::canSelectAll() const { return !usesMenuList(); } RenderPtr HTMLSelectElement::createElementRenderer(RenderStyle&& style, const RenderTreePosition&) { #if !PLATFORM(IOS_FAMILY) if (usesMenuList()) return createRenderer(*this, WTFMove(style)); return createRenderer(*this, WTFMove(style)); #else return createRenderer(*this, WTFMove(style)); #endif } bool HTMLSelectElement::childShouldCreateRenderer(const Node& child) const { if (!HTMLFormControlElementWithState::childShouldCreateRenderer(child)) return false; #if !PLATFORM(IOS_FAMILY) if (!usesMenuList()) return is(child) || is(child) || validationMessageShadowTreeContains(child); #endif return validationMessageShadowTreeContains(child); } Ref HTMLSelectElement::selectedOptions() { return ensureRareData().ensureNodeLists().addCachedCollection::traversalType>>(*this, SelectedOptions); } Ref HTMLSelectElement::options() { return ensureRareData().ensureNodeLists().addCachedCollection(*this, SelectOptions); } void HTMLSelectElement::updateListItemSelectedStates() { if (m_shouldRecalcListItems) recalcListItems(); } void HTMLSelectElement::childrenChanged(const ChildChange& change) { setRecalcListItems(); updateValidity(); m_lastOnChangeSelection.clear(); HTMLFormControlElementWithState::childrenChanged(change); } void HTMLSelectElement::optionElementChildrenChanged() { setRecalcListItems(); updateValidity(); if (auto* cache = document().existingAXObjectCache()) cache->childrenChanged(this); } bool HTMLSelectElement::accessKeyAction(bool sendMouseEvents) { focus(); return dispatchSimulatedClick(0, sendMouseEvents ? SendMouseUpDownEvents : SendNoEvents); } void HTMLSelectElement::setMultiple(bool multiple) { bool oldMultiple = this->multiple(); int oldSelectedIndex = selectedIndex(); setBooleanAttribute(multipleAttr, multiple); // Restore selectedIndex after changing the multiple flag to preserve // selection as single-line and multi-line has different defaults. if (oldMultiple != this->multiple()) setSelectedIndex(oldSelectedIndex); } void HTMLSelectElement::setSize(unsigned size) { setUnsignedIntegralAttribute(sizeAttr, limitToOnlyHTMLNonNegative(size)); } HTMLOptionElement* HTMLSelectElement::namedItem(const AtomString& name) { return options()->namedItem(name); } HTMLOptionElement* HTMLSelectElement::item(unsigned index) { return options()->item(index); } ExceptionOr HTMLSelectElement::setItem(unsigned index, HTMLOptionElement* option) { if (!option) { remove(index); return { }; } if (index > maxSelectItems - 1) index = maxSelectItems - 1; int diff = index - length(); RefPtr before; // Out of array bounds? First insert empty dummies. if (diff > 0) { auto result = setLength(index); if (result.hasException()) return result; // Replace an existing entry? } else if (diff < 0) { before = item(index + 1); remove(index); } // Finally add the new element. auto result = add(option, HTMLElementOrInt { before.get() }); if (result.hasException()) return result; if (diff >= 0 && option->selected()) optionSelectionStateChanged(*option, true); return { }; } ExceptionOr HTMLSelectElement::setLength(unsigned newLength) { if (newLength > length() && newLength > maxSelectItems) { document().addConsoleMessage(MessageSource::Other, MessageLevel::Warning, makeString("Blocked attempt to expand the option list to ", newLength, " items. The maximum number of items allowed is ", maxSelectItems, '.')); return { }; } int diff = length() - newLength; if (diff < 0) { // Add dummy elements. do { auto result = add(HTMLOptionElement::create(document()).ptr(), std::nullopt); if (result.hasException()) return result; } while (++diff); } else { auto& items = listItems(); // Removing children fires mutation events, which might mutate the DOM further, so we first copy out a list // of elements that we intend to remove then attempt to remove them one at a time. Vector> itemsToRemove; size_t optionIndex = 0; for (auto& item : items) { if (is(*item) && optionIndex++ >= newLength) { ASSERT(item->parentNode()); itemsToRemove.append(downcast(*item)); } } // FIXME: Clients can detect what order we remove the options in; is it good to remove them in ascending order? // FIXME: This ignores exceptions. A previous version passed through the exception only for the last item removed. // What exception behavior do we want? for (auto& item : itemsToRemove) item->remove(); } return { }; } bool HTMLSelectElement::isRequiredFormControl() const { return isRequired(); } bool HTMLSelectElement::willRespondToMouseClickEvents() { #if PLATFORM(IOS_FAMILY) return !isDisabledFormControl(); #else return HTMLFormControlElementWithState::willRespondToMouseClickEvents(); #endif } // Returns the 1st valid item |skip| items from |listIndex| in direction |direction| if there is one. // Otherwise, it returns the valid item closest to that boundary which is past |listIndex| if there is one. // Otherwise, it returns |listIndex|. // Valid means that it is enabled and an option element. int HTMLSelectElement::nextValidIndex(int listIndex, SkipDirection direction, int skip) const { ASSERT(direction == -1 || direction == 1); auto& listItems = this->listItems(); int lastGoodIndex = listIndex; int size = listItems.size(); for (listIndex += direction; listIndex >= 0 && listIndex < size; listIndex += direction) { --skip; if (!listItems[listIndex]->isDisabledFormControl() && is(*listItems[listIndex])) { lastGoodIndex = listIndex; if (skip <= 0) break; } } return lastGoodIndex; } int HTMLSelectElement::nextSelectableListIndex(int startIndex) const { return nextValidIndex(startIndex, SkipForwards, 1); } int HTMLSelectElement::previousSelectableListIndex(int startIndex) const { if (startIndex == -1) startIndex = listItems().size(); return nextValidIndex(startIndex, SkipBackwards, 1); } int HTMLSelectElement::firstSelectableListIndex() const { auto& items = listItems(); int index = nextValidIndex(items.size(), SkipBackwards, INT_MAX); if (static_cast(index) == items.size()) return -1; return index; } int HTMLSelectElement::lastSelectableListIndex() const { return nextValidIndex(-1, SkipForwards, INT_MAX); } // Returns the index of the next valid item one page away from |startIndex| in direction |direction|. int HTMLSelectElement::nextSelectableListIndexPageAway(int startIndex, SkipDirection direction) const { auto& items = listItems(); // Can't use m_size because renderer forces a minimum size. int pageSize = 0; auto* renderer = this->renderer(); if (is(*renderer)) pageSize = downcast(*renderer).size() - 1; // -1 so we still show context. // One page away, but not outside valid bounds. // If there is a valid option item one page away, the index is chosen. // If there is no exact one page away valid option, returns startIndex or the most far index. int edgeIndex = direction == SkipForwards ? 0 : items.size() - 1; int skipAmount = pageSize + (direction == SkipForwards ? startIndex : edgeIndex - startIndex); return nextValidIndex(edgeIndex, direction, skipAmount); } void HTMLSelectElement::selectAll() { ASSERT(!usesMenuList()); if (!renderer() || !m_multiple) return; // Save the selection so it can be compared to the new selectAll selection // when dispatching change events. saveLastSelection(); m_activeSelectionState = true; setActiveSelectionAnchorIndex(nextSelectableListIndex(-1)); setActiveSelectionEndIndex(previousSelectableListIndex(-1)); if (m_activeSelectionAnchorIndex < 0) return; updateListBoxSelection(false); listBoxOnChange(); updateValidity(); } void HTMLSelectElement::saveLastSelection() { if (usesMenuList()) { m_lastOnChangeIndex = selectedIndex(); return; } m_lastOnChangeSelection.clear(); for (auto& element : listItems()) m_lastOnChangeSelection.append(is(*element) && downcast(*element).selected()); } void HTMLSelectElement::setActiveSelectionAnchorIndex(int index) { m_activeSelectionAnchorIndex = index; // Cache the selection state so we can restore the old selection as the new // selection pivots around this anchor index. m_cachedStateForActiveSelection.clear(); for (auto& element : listItems()) m_cachedStateForActiveSelection.append(is(*element) && downcast(*element).selected()); } void HTMLSelectElement::setActiveSelectionEndIndex(int index) { m_activeSelectionEndIndex = index; } void HTMLSelectElement::updateListBoxSelection(bool deselectOtherOptions) { ASSERT(renderer()); #if !PLATFORM(IOS_FAMILY) ASSERT(renderer()->isListBox() || m_multiple); #else ASSERT(renderer()->isMenuList() || m_multiple); #endif ASSERT(!listItems().size() || m_activeSelectionAnchorIndex >= 0); unsigned start = std::min(m_activeSelectionAnchorIndex, m_activeSelectionEndIndex); unsigned end = std::max(m_activeSelectionAnchorIndex, m_activeSelectionEndIndex); auto& items = listItems(); for (unsigned i = 0; i < items.size(); ++i) { auto& element = *items[i]; if (!is(element) || downcast(element).isDisabledFormControl()) continue; if (i >= start && i <= end) downcast(element).setSelectedState(m_activeSelectionState); else if (deselectOtherOptions || i >= m_cachedStateForActiveSelection.size()) downcast(element).setSelectedState(false); else downcast(element).setSelectedState(m_cachedStateForActiveSelection[i]); } invalidateSelectedItems(); scrollToSelection(); updateValidity(); } void HTMLSelectElement::listBoxOnChange() { ASSERT(!usesMenuList() || m_multiple); auto& items = listItems(); // If the cached selection list is empty, or the size has changed, then fire // dispatchFormControlChangeEvent, and return early. if (m_lastOnChangeSelection.isEmpty() || m_lastOnChangeSelection.size() != items.size()) { dispatchFormControlChangeEvent(); return; } // Update m_lastOnChangeSelection and fire dispatchFormControlChangeEvent. bool fireOnChange = false; for (unsigned i = 0; i < items.size(); ++i) { auto& element = *items[i]; bool selected = is(element) && downcast(element).selected(); if (selected != m_lastOnChangeSelection[i]) fireOnChange = true; m_lastOnChangeSelection[i] = selected; } if (fireOnChange) { dispatchInputEvent(); dispatchFormControlChangeEvent(); } } void HTMLSelectElement::dispatchChangeEventForMenuList() { ASSERT(usesMenuList()); int selected = selectedIndex(); if (m_lastOnChangeIndex != selected && m_isProcessingUserDrivenChange) { m_lastOnChangeIndex = selected; m_isProcessingUserDrivenChange = false; dispatchInputEvent(); dispatchFormControlChangeEvent(); } } void HTMLSelectElement::scrollToSelection() { #if !PLATFORM(IOS_FAMILY) if (usesMenuList()) return; auto* renderer = this->renderer(); if (!is(renderer)) return; downcast(*renderer).selectionChanged(); #else if (auto* renderer = this->renderer()) renderer->repaint(); #endif } void HTMLSelectElement::setOptionsChangedOnRenderer() { if (auto* renderer = this->renderer()) { #if !PLATFORM(IOS_FAMILY) if (is(*renderer)) downcast(*renderer).setOptionsChanged(true); else downcast(*renderer).setOptionsChanged(true); #else downcast(*renderer).setOptionsChanged(true); #endif } } const Vector& HTMLSelectElement::listItems() const { if (m_shouldRecalcListItems) recalcListItems(); else { #if ASSERT_ENABLED Vector items = m_listItems; recalcListItems(false); ASSERT(items == m_listItems); #endif } return m_listItems; } void HTMLSelectElement::invalidateSelectedItems() { if (HTMLCollection* collection = cachedHTMLCollection(SelectedOptions)) collection->invalidateCache(); } void HTMLSelectElement::setRecalcListItems() { m_shouldRecalcListItems = true; // Manual selection anchor is reset when manipulating the select programmatically. m_activeSelectionAnchorIndex = -1; setOptionsChangedOnRenderer(); invalidateStyleForSubtree(); if (!isConnected()) { if (HTMLCollection* collection = cachedHTMLCollection(SelectOptions)) collection->invalidateCache(); } if (!isConnected()) invalidateSelectedItems(); if (auto* cache = document().existingAXObjectCache()) cache->childrenChanged(this); } void HTMLSelectElement::recalcListItems(bool updateSelectedStates) const { m_listItems.clear(); m_shouldRecalcListItems = false; RefPtr foundSelected; RefPtr firstOption; for (RefPtr currentElement = ElementTraversal::firstWithin(*this); currentElement; ) { if (!is(*currentElement)) { currentElement = ElementTraversal::nextSkippingChildren(*currentElement, this); continue; } HTMLElement& current = downcast(*currentElement); // Only consider optgroup elements that are direct children of the select element. if (is(current) && current.parentNode() == this) { m_listItems.append(¤t); if (RefPtr nextElement = ElementTraversal::firstWithin(current)) { currentElement = nextElement; continue; } } if (is(current)) { m_listItems.append(¤t); if (updateSelectedStates && !m_multiple) { HTMLOptionElement& option = downcast(current); if (!firstOption) firstOption = &option; if (option.selected()) { if (foundSelected) foundSelected->setSelectedState(false); foundSelected = &option; } else if (m_size <= 1 && !foundSelected && !option.isDisabledFormControl()) { foundSelected = &option; foundSelected->setSelectedState(true); } } } if (current.hasTagName(hrTag)) m_listItems.append(¤t); // In conforming HTML code, only and