LibWeb: Light dismiss popovers on click

This commit is contained in:
Gingeh 2025-04-13 18:58:40 +10:00 committed by Andrew Kaster
commit 1f1884da54
Notes: github-actions[bot] 2025-04-29 01:43:08 +00:00
9 changed files with 308 additions and 6 deletions

View file

@ -592,6 +592,7 @@ void Document::visit_edges(Cell::Visitor& visitor)
visitor.visit(m_top_layer_pending_removals); visitor.visit(m_top_layer_pending_removals);
visitor.visit(m_showing_auto_popover_list); visitor.visit(m_showing_auto_popover_list);
visitor.visit(m_showing_hint_popover_list); visitor.visit(m_showing_hint_popover_list);
visitor.visit(m_popover_pointerdown_target);
visitor.visit(m_console_client); visitor.visit(m_console_client);
visitor.visit(m_editing_host_manager); visitor.visit(m_editing_host_manager);
visitor.visit(m_local_storage_holder); visitor.visit(m_local_storage_holder);
@ -6117,6 +6118,23 @@ void Document::process_top_layer_removals()
} }
} }
// https://html.spec.whatwg.org/multipage/popover.html#topmost-auto-popover
GC::Ptr<HTML::HTMLElement> Document::topmost_auto_or_hint_popover()
{
// To find the topmost auto or hint popover given a Document document, perform the following steps. They return an HTML element or null.
// 1. If document's showing hint popover list is not empty, then return document's showing hint popover list's last element.
if (!m_showing_hint_popover_list.is_empty())
return m_showing_hint_popover_list.last();
// 2. If document's showing auto popover list is not empty, then return document's showing auto popover list's last element.
if (!m_showing_auto_popover_list.is_empty())
return m_showing_auto_popover_list.last();
// 3. Return null.
return {};
}
void Document::set_needs_to_refresh_scroll_state(bool b) void Document::set_needs_to_refresh_scroll_state(bool b)
{ {
if (auto* paintable = this->paintable()) if (auto* paintable = this->paintable())

View file

@ -796,6 +796,11 @@ public:
Vector<GC::Ref<HTML::HTMLElement>> const& showing_auto_popover_list() const { return m_showing_auto_popover_list; } Vector<GC::Ref<HTML::HTMLElement>> const& showing_auto_popover_list() const { return m_showing_auto_popover_list; }
Vector<GC::Ref<HTML::HTMLElement>> const& showing_hint_popover_list() const { return m_showing_hint_popover_list; } Vector<GC::Ref<HTML::HTMLElement>> const& showing_hint_popover_list() const { return m_showing_hint_popover_list; }
GC::Ptr<HTML::HTMLElement> topmost_auto_or_hint_popover();
void set_popover_pointerdown_target(GC::Ptr<HTML::HTMLElement> target) { m_popover_pointerdown_target = target; }
GC::Ptr<HTML::HTMLElement> popover_pointerdown_target() { return m_popover_pointerdown_target; }
size_t transition_generation() const { return m_transition_generation; } size_t transition_generation() const { return m_transition_generation; }
// Does document represent an embedded svg img // Does document represent an embedded svg img
@ -1203,6 +1208,7 @@ private:
Vector<GC::Ref<HTML::HTMLElement>> m_showing_auto_popover_list; Vector<GC::Ref<HTML::HTMLElement>> m_showing_auto_popover_list;
Vector<GC::Ref<HTML::HTMLElement>> m_showing_hint_popover_list; Vector<GC::Ref<HTML::HTMLElement>> m_showing_hint_popover_list;
GC::Ptr<HTML::HTMLElement> m_popover_pointerdown_target;
// https://dom.spec.whatwg.org/#document-allow-declarative-shadow-roots // https://dom.spec.whatwg.org/#document-allow-declarative-shadow-roots
bool m_allow_declarative_shadow_roots { false }; bool m_allow_declarative_shadow_roots { false };

View file

@ -32,6 +32,7 @@
#include <LibWeb/HTML/HTMLElement.h> #include <LibWeb/HTML/HTMLElement.h>
#include <LibWeb/HTML/HTMLLabelElement.h> #include <LibWeb/HTML/HTMLLabelElement.h>
#include <LibWeb/HTML/HTMLParagraphElement.h> #include <LibWeb/HTML/HTMLParagraphElement.h>
#include <LibWeb/HTML/PopoverInvokerElement.h>
#include <LibWeb/HTML/ToggleEvent.h> #include <LibWeb/HTML/ToggleEvent.h>
#include <LibWeb/HTML/Window.h> #include <LibWeb/HTML/Window.h>
#include <LibWeb/Infra/CharacterTypes.h> #include <LibWeb/Infra/CharacterTypes.h>
@ -1771,6 +1772,34 @@ GC::Ptr<HTMLElement> HTMLElement::nearest_inclusive_open_popover()
return {}; return {};
} }
// https://html.spec.whatwg.org/multipage/popover.html#nearest-inclusive-target-popover-for-invoker
GC::Ptr<HTMLElement> HTMLElement::nearest_inclusive_target_popover_for_invoker()
{
// To find the nearest inclusive target popover for invoker given a Node node:
// 1. Let currentNode be node.
auto* current_node = this;
// 2. While currentNode is not null:
while (current_node) {
// 1. Let targetPopover be currentNode's popover target element.
auto target_popover = PopoverInvokerElement::get_the_popover_target_element(*current_node);
// AD-HOC: This also allows hint popovers.
// See nearest_inclusive_open_popover above.
// 2. If targetPopover is not null and targetPopover's popover attribute is in the auto state and targetPopover's popover visibility state is showing, then return targetPopover.
if (target_popover) {
if (target_popover->popover().has_value() && target_popover->popover().value().is_one_of("auto", "hint") && target_popover->popover_visibility_state() == PopoverVisibilityState::Showing)
return target_popover;
}
// 3. Set currentNode to currentNode's ancestor in the flat tree.
current_node = current_node->shadow_including_first_ancestor_of_type<HTMLElement>();
}
return {};
}
// https://html.spec.whatwg.org/multipage/popover.html#queue-a-popover-toggle-event-task // https://html.spec.whatwg.org/multipage/popover.html#queue-a-popover-toggle-event-task
void HTMLElement::queue_a_popover_toggle_event_task(String old_state, String new_state) void HTMLElement::queue_a_popover_toggle_event_task(String old_state, String new_state)
{ {
@ -1809,6 +1838,108 @@ void HTMLElement::queue_a_popover_toggle_event_task(String old_state, String new
}; };
} }
// https://html.spec.whatwg.org/multipage/popover.html#light-dismiss-open-popovers
void HTMLElement::light_dismiss_open_popovers(UIEvents::PointerEvent const& event, GC::Ptr<DOM::Node> const target)
{
// To light dismiss open popovers, given a PointerEvent event:
// 1. Assert: event's isTrusted attribute is true.
VERIFY(event.is_trusted());
// 2. Let target be event's target.
// FIXME: The event's target hasn't been initialized yet, so it's passed as an argument
// 3. Let document be target's node document.
auto& document = target->document();
// 4. Let topmostPopover be the result of running topmost auto popover given document.
auto topmost_popover = document.topmost_auto_or_hint_popover();
// 5. If topmostPopover is null, then return.
if (!topmost_popover)
return;
// 6. If event's type is "pointerdown", then: set document's popover pointerdown target to the result of running topmost clicked popover given target.
if (event.type() == UIEvents::EventNames::pointerdown)
document.set_popover_pointerdown_target(topmost_clicked_popover(target));
// 7. If event's type is "pointerup", then:
if (event.type() == UIEvents::EventNames::pointerup) {
// 1. Let ancestor be the result of running topmost clicked popover given target.
auto const ancestor = topmost_clicked_popover(target);
// 2. Let sameTarget be true if ancestor is document's popover pointerdown target.
bool const same_target = ancestor == document.popover_pointerdown_target();
// 3. Set document's popover pointerdown target to null.
document.set_popover_pointerdown_target({});
// 4. If ancestor is null, then set ancestor to document.
Variant<GC::Ptr<HTMLElement>, GC::Ptr<DOM::Document>> ancestor_or_document = ancestor;
if (!ancestor)
ancestor_or_document = GC::Ptr(document);
// 5. If sameTarget is true, then run hide all popovers until given ancestor, false, and true.
if (same_target)
hide_all_popovers_until(ancestor_or_document, FocusPreviousElement::No, FireEvents::Yes);
}
}
// https://html.spec.whatwg.org/multipage/popover.html#get-the-popover-stack-position
size_t HTMLElement::popover_stack_position()
{
// To get the popover stack position, given an HTML element popover:
// 1. Let hintList be popover's node document's showing hint popover list.
auto const& hint_list = document().showing_hint_popover_list();
// 2. Let autoList be popover's node document's showing auto popover list.
auto const& auto_list = document().showing_auto_popover_list();
// 3. If popover is in hintList, then return the index of popover in hintList + the size of autoList + 1.
if (hint_list.contains_slow(GC::Ref(*this)))
return hint_list.find_first_index(GC::Ref(*this)).value() + auto_list.size() + 1;
// 4. If popover is in autoList, then return the index of popover in autoList + 1.
if (auto_list.contains_slow(GC::Ref(*this)))
return auto_list.find_first_index(GC::Ref(*this)).value() + 1;
// 5. Return 0.
return 0;
}
// https://html.spec.whatwg.org/multipage/popover.html#topmost-clicked-popover
GC::Ptr<HTMLElement> HTMLElement::topmost_clicked_popover(GC::Ptr<DOM::Node> node)
{
// To find the topmost clicked popover, given a Node node:
GC::Ptr<HTMLElement> nearest_element = as_if<HTMLElement>(*node);
if (!nearest_element)
nearest_element = node->shadow_including_first_ancestor_of_type<HTMLElement>();
if (!nearest_element)
return {};
// 1. Let clickedPopover be the result of running nearest inclusive open popover given node.
auto clicked_popover = nearest_element->nearest_inclusive_open_popover();
// 2. Let invokerPopover be the result of running nearest inclusive target popover for invoker given node.
auto invoker_popover = nearest_element->nearest_inclusive_target_popover_for_invoker();
if (!clicked_popover)
return invoker_popover;
if (!invoker_popover)
return clicked_popover;
// 3. If the result of getting the popover stack position given clickedPopover is greater than the result of getting the popover stack position given invokerPopover, then return clickedPopover.
if (clicked_popover->popover_stack_position() > invoker_popover->popover_stack_position())
return clicked_popover;
// 4. Return invokerPopover.
return invoker_popover;
}
void HTMLElement::did_receive_focus() void HTMLElement::did_receive_focus()
{ {
if (!first_is_one_of(m_content_editable_state, ContentEditableState::True, ContentEditableState::PlaintextOnly)) if (!first_is_one_of(m_content_editable_state, ContentEditableState::True, ContentEditableState::PlaintextOnly))

View file

@ -149,6 +149,8 @@ public:
static void hide_all_popovers_until(Variant<GC::Ptr<HTMLElement>, GC::Ptr<DOM::Document>> endpoint, FocusPreviousElement focus_previous_element, FireEvents fire_events); static void hide_all_popovers_until(Variant<GC::Ptr<HTMLElement>, GC::Ptr<DOM::Document>> endpoint, FocusPreviousElement focus_previous_element, FireEvents fire_events);
static GC::Ptr<HTMLElement> topmost_popover_ancestor(GC::Ptr<DOM::Node> new_popover_or_top_layer_element, Vector<GC::Ref<HTMLElement>> const& popover_list, GC::Ptr<HTMLElement> invoker, IsPopover is_popover); static GC::Ptr<HTMLElement> topmost_popover_ancestor(GC::Ptr<DOM::Node> new_popover_or_top_layer_element, Vector<GC::Ref<HTMLElement>> const& popover_list, GC::Ptr<HTMLElement> invoker, IsPopover is_popover);
static void light_dismiss_open_popovers(UIEvents::PointerEvent const&, GC::Ptr<DOM::Node>);
bool is_inert() const { return m_inert; } bool is_inert() const { return m_inert; }
virtual bool is_valid_invoker_command(String&) { return false; } virtual bool is_valid_invoker_command(String&) { return false; }
@ -197,7 +199,10 @@ private:
static Optional<String> popover_value_to_state(Optional<String> value); static Optional<String> popover_value_to_state(Optional<String> value);
void hide_popover_stack_until(Vector<GC::Ref<HTMLElement>> const& popover_list, FocusPreviousElement focus_previous_element, FireEvents fire_events); void hide_popover_stack_until(Vector<GC::Ref<HTMLElement>> const& popover_list, FocusPreviousElement focus_previous_element, FireEvents fire_events);
GC::Ptr<HTMLElement> nearest_inclusive_open_popover(); GC::Ptr<HTMLElement> nearest_inclusive_open_popover();
GC::Ptr<HTMLElement> nearest_inclusive_target_popover_for_invoker();
static void close_entire_popover_list(Vector<GC::Ref<HTMLElement>> const& popover_list, FocusPreviousElement focus_previous_element, FireEvents fire_events); static void close_entire_popover_list(Vector<GC::Ref<HTMLElement>> const& popover_list, FocusPreviousElement focus_previous_element, FireEvents fire_events);
static GC::Ptr<HTMLElement> topmost_clicked_popover(GC::Ptr<DOM::Node> node);
size_t popover_stack_position();
// https://html.spec.whatwg.org/multipage/custom-elements.html#attached-internals // https://html.spec.whatwg.org/multipage/custom-elements.html#attached-internals
GC::Ptr<ElementInternals> m_attached_internals; GC::Ptr<ElementInternals> m_attached_internals;

View file

@ -78,8 +78,9 @@ GC::Ptr<HTMLElement> PopoverInvokerElement::get_the_popover_target_element(GC::R
{ {
// To get the popover target element given a Node node, perform the following steps. They return an HTML element or null. // To get the popover target element given a Node node, perform the following steps. They return an HTML element or null.
auto const* form_associated_element = dynamic_cast<FormAssociatedElement const*>(node.ptr()); auto const* form_associated_element = as_if<FormAssociatedElement>(*node);
VERIFY(form_associated_element); if (!form_associated_element)
return {};
// 1. If node is not a button, then return null. // 1. If node is not a button, then return null.
if (!form_associated_element->is_button()) if (!form_associated_element->is_button())

View file

@ -21,6 +21,8 @@ public:
void set_popover_target_element(GC::Ptr<DOM::Element> value) { m_popover_target_element = value; } void set_popover_target_element(GC::Ptr<DOM::Element> value) { m_popover_target_element = value; }
static GC::Ptr<HTMLElement> get_the_popover_target_element(GC::Ref<DOM::Node> node);
static void popover_target_activation_behaviour(GC::Ref<DOM::Node> node, GC::Ref<DOM::Node> event_target); static void popover_target_activation_behaviour(GC::Ref<DOM::Node> node, GC::Ref<DOM::Node> event_target);
protected: protected:
@ -29,8 +31,6 @@ protected:
private: private:
GC::Ptr<DOM::Element> m_popover_target_element; GC::Ptr<DOM::Element> m_popover_target_element;
static GC::Ptr<HTMLElement> get_the_popover_target_element(GC::Ref<DOM::Node> node);
}; };
} }

View file

@ -339,6 +339,17 @@ static void set_user_selection(GC::Ptr<DOM::Node> anchor_node, unsigned anchor_o
(void)selection->set_base_and_extent(*anchor_node, anchor_offset, *focus_node, focus_offset); (void)selection->set_base_and_extent(*anchor_node, anchor_offset, *focus_node, focus_offset);
} }
// https://html.spec.whatwg.org/multipage/interactive-elements.html#run-light-dismiss-activities
static void light_dismiss_activities(UIEvents::PointerEvent const& event, const GC::Ptr<DOM::Node> target)
{
// To run light dismiss activities, given a PointerEvent event:
// 1. Run light dismiss open popovers with event.
HTML::HTMLElement::light_dismiss_open_popovers(event, target);
// FIXME: 2. Run light dismiss open dialogs with event.
}
EventHandler::EventHandler(Badge<HTML::Navigable>, HTML::Navigable& navigable) EventHandler::EventHandler(Badge<HTML::Navigable>, HTML::Navigable& navigable)
: m_navigable(navigable) : m_navigable(navigable)
, m_drag_and_drop_event_handler(make<DragAndDropEventHandler>()) , m_drag_and_drop_event_handler(make<DragAndDropEventHandler>())
@ -481,7 +492,9 @@ EventResult EventHandler::handle_mouseup(CSSPixelPoint viewport_position, CSSPix
auto page_offset = compute_mouse_event_page_offset(viewport_position); auto page_offset = compute_mouse_event_page_offset(viewport_position);
auto offset = compute_mouse_event_offset(page_offset, *layout_node->first_paintable()); auto offset = compute_mouse_event_offset(page_offset, *layout_node->first_paintable());
node->dispatch_event(UIEvents::PointerEvent::create_from_platform_event(node->realm(), UIEvents::EventNames::pointerup, screen_position, page_offset, viewport_position, offset, {}, button, buttons, modifiers).release_value_but_fixme_should_propagate_errors()); auto pointer_event = UIEvents::PointerEvent::create_from_platform_event(node->realm(), UIEvents::EventNames::pointerup, screen_position, page_offset, viewport_position, offset, {}, button, buttons, modifiers).release_value_but_fixme_should_propagate_errors();
light_dismiss_activities(pointer_event, node);
node->dispatch_event(pointer_event);
node->dispatch_event(UIEvents::MouseEvent::create_from_platform_event(node->realm(), UIEvents::EventNames::mouseup, screen_position, page_offset, viewport_position, offset, {}, button, buttons, modifiers).release_value_but_fixme_should_propagate_errors()); node->dispatch_event(UIEvents::MouseEvent::create_from_platform_event(node->realm(), UIEvents::EventNames::mouseup, screen_position, page_offset, viewport_position, offset, {}, button, buttons, modifiers).release_value_but_fixme_should_propagate_errors());
handled_event = EventResult::Handled; handled_event = EventResult::Handled;
@ -629,7 +642,9 @@ EventResult EventHandler::handle_mousedown(CSSPixelPoint viewport_position, CSSP
m_mousedown_target = node.ptr(); m_mousedown_target = node.ptr();
auto page_offset = compute_mouse_event_page_offset(viewport_position); auto page_offset = compute_mouse_event_page_offset(viewport_position);
auto offset = compute_mouse_event_offset(page_offset, *layout_node->first_paintable()); auto offset = compute_mouse_event_offset(page_offset, *layout_node->first_paintable());
node->dispatch_event(UIEvents::PointerEvent::create_from_platform_event(node->realm(), UIEvents::EventNames::pointerdown, screen_position, page_offset, viewport_position, offset, {}, button, buttons, modifiers).release_value_but_fixme_should_propagate_errors()); auto pointer_event = UIEvents::PointerEvent::create_from_platform_event(node->realm(), UIEvents::EventNames::pointerdown, screen_position, page_offset, viewport_position, offset, {}, button, buttons, modifiers).release_value_but_fixme_should_propagate_errors();
light_dismiss_activities(pointer_event, node);
node->dispatch_event(pointer_event);
node->dispatch_event(UIEvents::MouseEvent::create_from_platform_event(node->realm(), UIEvents::EventNames::mousedown, screen_position, page_offset, viewport_position, offset, {}, button, buttons, modifiers).release_value_but_fixme_should_propagate_errors()); node->dispatch_event(UIEvents::MouseEvent::create_from_platform_event(node->realm(), UIEvents::EventNames::mousedown, screen_position, page_offset, viewport_position, offset, {}, button, buttons, modifiers).release_value_but_fixme_should_propagate_errors());
} }

View file

@ -0,0 +1,26 @@
p1 open: false (should be false)
p2 open: false (should be false)
Opening p1
p1 open: true (should be true)
p2 open: false (should be false)
Opening p2
p1 open: true (should be true)
p2 open: true (should be true)
Clicking p2
p1 open: true (should be true)
p2 open: true (should be true)
Clicking p1
p1 open: true (should be true)
p2 open: false (should be false)
Clicking p1's show invoker
p1 open: true (should be true)
p2 open: false (should be false)
Clicking p1's toggle invoker
p1 open: false (should be false)
p2 open: false (should be false)
Re-opening both popovers
p1 open: true (should be true)
p2 open: true (should be true)
Clicking outside both popovers
p1 open: false (should be false)
p2 open: false (should be false)

View file

@ -0,0 +1,100 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<style>
[popover] {
/* Position most popovers at the bottom-right, out of the way */
inset: auto;
bottom: 0;
right: 0;
}
[popover]::backdrop {
/* This should *not* affect anything: */
pointer-events: auto;
}
#p1 {
top: 50px;
}
#p2 {
top: 120px;
}
</style>
<button id=b1t popovertarget='p1'>Popover 1</button>
<button id=b1s popovertarget='p1' popovertargetaction=show>Popover 1</button>
<span id=outside>Outside all popovers</span>
<div popover id=p1>
<span id=inside1>Inside popover 1</span>
<button id=b2 popovertarget='p2' popovertargetaction=show>Popover 2</button>
<span id=inside1after>Inside popover 1 after button</span>
<div popover id=p2>
<span id=inside2>Inside popover 2</span>
</div>
</div>
<script>
click = function (element) {
const boundingRect = element.getBoundingClientRect();
const centerPoint = {
x: boundingRect.left + boundingRect.width / 2,
y: boundingRect.top + boundingRect.height / 2
};
internals.click(centerPoint.x, centerPoint.y);
};
test(() => {
println("p1 open: " + p1.matches(":popover-open") + " (should be false)");
println("p2 open: " + p2.matches(":popover-open") + " (should be false)");
println("Opening p1");
click(b1t);
println("p1 open: " + p1.matches(":popover-open") + " (should be true)");
println("p2 open: " + p2.matches(":popover-open") + " (should be false)");
println("Opening p2");
click(b2);
println("p1 open: " + p1.matches(":popover-open") + " (should be true)");
println("p2 open: " + p2.matches(":popover-open") + " (should be true)");
println("Clicking p2");
click(p2);
println("p1 open: " + p1.matches(":popover-open") + " (should be true)");
println("p2 open: " + p2.matches(":popover-open") + " (should be true)");
println("Clicking p1");
click(p1);
println("p1 open: " + p1.matches(":popover-open") + " (should be true)");
println("p2 open: " + p2.matches(":popover-open") + " (should be false)");
println("Clicking p1's show invoker");
click(b1s);
println("p1 open: " + p1.matches(":popover-open") + " (should be true)");
println("p2 open: " + p2.matches(":popover-open") + " (should be false)");
println("Clicking p1's toggle invoker");
click(b1t);
println("p1 open: " + p1.matches(":popover-open") + " (should be false)");
println("p2 open: " + p2.matches(":popover-open") + " (should be false)");
println("Re-opening both popovers");
click(b1t);
click(b2);
println("p1 open: " + p1.matches(":popover-open") + " (should be true)");
println("p2 open: " + p2.matches(":popover-open") + " (should be true)");
println("Clicking outside both popovers");
click(outside);
println("p1 open: " + p1.matches(":popover-open") + " (should be false)");
println("p2 open: " + p2.matches(":popover-open") + " (should be false)");
});
</script>