@@ -4520,6 +4520,9 @@ const isControlRoot = (el) => {
45204520const isControlHost = (el) => {
45214521 return el.hasAttribute("navi-control-host");
45224522};
4523+ const isControl = (el) => {
4524+ return isControlRoot(el) || isControlHost(el);
4525+ };
45234526
45244527/**
45254528 * Returns the nearest ancestor of `el` (exclusive of `el`'s own control) that
@@ -6236,7 +6239,10 @@ const css$12 = /* css */ `
62366239 * Shows a callout attached to the specified element
62376240 * @param {string} message - HTML content for the callout
62386241 * @param {Object} options - Configuration options
6239- * @param {HTMLElement} [options.anchorElement] - Element the callout should follow. If not provided or too big, callout will be centered in viewport
6242+ * @param {HTMLElement} [options.anchorElement] - Element the callout points at and follows.
6243+ * If not provided or too big, callout will be centered in viewport. It says where the
6244+ * callout is drawn, not what dismisses it: see `openingEvent` for what a container anchor
6245+ * costs
62406246 * @param {string} [options.status=""] - Callout status: "info" | "warning" | "error" | "success"
62416247 * @param {string} [options.testId] - `data-testid` on the callout element. The callout is
62426248 * drawn by navi, so nothing the caller renders can carry the name a test needs — same
@@ -6245,6 +6251,18 @@ const css$12 = /* css */ `
62456251 * callouts apart.
62466252 * @param {Function} [options.onClose] - Callback when callout is closed
62476253 * @param {boolean} [options.closeOnClickOutside] - Whether to close on outside clicks (defaults to true for "info" status)
6254+ * @param {Event} [options.openingEvent] - The event being handled when the callout was asked
6255+ * for. While it is still dispatching, its `currentTarget` names the opener: the one part of
6256+ * the anchor that does not count as "outside", so the press reaches the handler owning the
6257+ * callout and `reopen` decides (toggle by default), instead of the callout being closed here
6258+ * and opened again within that same press.
6259+ *
6260+ * A callout opened later — after an await, from an effect — has no opener. The anchor keeps
6261+ * the exemption only if it is itself a control, which does have a handler that would re-open
6262+ * it; a container anchor (a card, a block of a settings page) has none, so all of it
6263+ * dismisses. The cost is the toggle: pressing what started the work closes the callout as an
6264+ * outside press, and the work opens a fresh one when it ends. Anchor to an always-mounted box
6265+ * around the opener instead of to the container to keep the toggle
62486266 * @param {boolean} [options.icon=true] - Whether the status icon is shown beside the message.
62496267 * Never shown without a status either way (see the CSS).
62506268 * @param {boolean} [options.closeButton=true] - Whether the cross is shown. Without it the callout
@@ -6626,13 +6644,41 @@ const openCallout = (
66266644 })();
66276645
66286646 {
6629- // document.body as anchor means "no anchor" (the callout is docked in the
6630- // viewport); everything would be inside it.
6631- const isInsideAnchor = (target) => {
6647+ // The exemption below belongs to the opener, not to the whole anchor: only
6648+ // something carrying a handler can decide, and what a callout is anchored
6649+ // to is not always what opened it.
6650+ const openerElement = (() => {
66326651 if (!anchorElement || anchorElement === document.body) {
6652+ // document.body as anchor means "no anchor" (the callout is docked in
6653+ // the viewport); everything would be inside it.
6654+ return null;
6655+ }
6656+ // `currentTarget` is set only while an event is dispatching, so reading
6657+ // it here tells a callout opened from a handler — one that has an owner
6658+ // about to decide on the next press — from one opened later, out of any
6659+ // gesture, which has none. Kept within the anchor: an opener elsewhere on
6660+ // the page is outside like anything else.
6661+ const openingTarget = openingEvent ? openingEvent.currentTarget : null;
6662+ if (
6663+ openingTarget &&
6664+ openingTarget.nodeType === Node.ELEMENT_NODE &&
6665+ (anchorElement === openingTarget ||
6666+ anchorElement.contains(openingTarget))
6667+ ) {
6668+ return findControlRoot(openingTarget) || openingTarget;
6669+ }
6670+ // No handler was running: the anchor speaks for itself only if it is a
6671+ // control, which does have one. A container anchor (a card, a block of a
6672+ // settings page) has nothing that would re-open the callout, and
6673+ // exempting all of it would leave the one place the user is most likely
6674+ // to press — the thing the callout points at — unable to dismiss it.
6675+ return isControl(anchorElement) ? anchorElement : null;
6676+ })();
6677+ const isInsideOpener = (target) => {
6678+ if (!openerElement) {
66336679 return false;
66346680 }
6635- return anchorElement === target || anchorElement .contains(target);
6681+ return openerElement === target || openerElement .contains(target);
66366682 };
66376683 const handleClickOutside = (event) => {
66386684 if (event.button !== 0) {
@@ -6646,14 +6692,14 @@ const openCallout = (
66466692 ) {
66476693 return;
66486694 }
6649- if (isInsideAnchor (clickTarget)) {
6650- // Pressing the anchor is not "outside": this listener is on document in
6695+ if (isInsideOpener (clickTarget)) {
6696+ // Pressing the opener is not "outside": this listener is on document in
66516697 // the capture phase, so closing here would destroy the callout before
66526698 // the event reaches the handler that owns it — and that handler,
66536699 // opening a callout on the very anchor it was just removed from, would
66546700 // create a second one within the same click. Left open, openCallout's
66556701 // `reopen` decides (toggle by default).
6656- debug(event, `click on anchor , let the anchor handler decide`);
6702+ debug(event, `click on opener , let its handler decide`);
66576703 return;
66586704 }
66596705 requestClose(event, "click_outside");
@@ -6666,10 +6712,10 @@ const openCallout = (
66666712 if (keyTarget === calloutElement || calloutElement.contains(keyTarget)) {
66676713 return;
66686714 }
6669- if (isInsideAnchor (keyTarget)) {
6670- // Space on the anchor produces a click afterwards — same reasoning as
6715+ if (isInsideOpener (keyTarget)) {
6716+ // Space on the opener produces a click afterwards — same reasoning as
66716717 // handleClickOutside above.
6672- debug(event, `space on anchor , let the anchor handler decide`);
6718+ debug(event, `space on opener , let its handler decide`);
66736719 return;
66746720 }
66756721 requestClose(event, "space_outside");
0 commit comments