fix: reparent mobile model picker to body so fixed positioning escapes footer (#6080)
The composer model dropdown (#composerModelDropdown) is a child of .composer-footer, which sets container-type:inline-size (and, under the Geist Contrast skin, a backdrop-filter). Both establish a fixed containing block, so the phone position:fixed dropdown resolved against the FOOTER (pinned to the bottom of the screen) instead of the visual viewport — dropping the menu below the fold on all skins (the "tapping the model picker does nothing / opens off-screen" symptom in #6080). Fix (Fable UX gate recommendation on PR #6105): on the phone path (<=640px) reparent the dropdown to <body> and switch it to position:fixed via a .model-dropdown--floating class, exactly the working #profileDropdown idiom, so fixed positioning is viewport-relative on every skin. The visualViewport math from PR #6105 is preserved (viewport-relative coordinates, above/below flip preferring the larger space, width = viewport - 16px, titlebar-bottom clamp) and so are the rAF-coalesced visualViewport resize/scroll listeners. Desktop (>640px) is unchanged: closeModelDropdown() and the desktop branch of _positionModelDropdown() restore the element into its original .composer-footer slot and clear the phone inline styles, so the desktop popover stays an absolutely positioned footer child, byte-for-byte identical to master. Because the menu now leaves the footer on phones, no per-skin backdrop-filter override is needed. Adds tests/test_issue6080_model_picker_reparent.py, including a node-executed DOM-parentage guard that runs _positionModelDropdown()/closeModelDropdown() against a stub DOM and asserts the open phone dropdown has NO .composer-footer ancestor (escaping the container-type trap) and is restored into the footer on close and on desktop. Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
This commit is contained in:
1 parent
7a862c9f6d
commit
fca5056589
3 files changed
+395
-2
No files matched your search
@@ -3247,6 +3247,14 @@
|
||||
.ws-dropdown-footer{left:0;right:auto;bottom:calc(100% + 4px);min-width:280px;max-width:min(420px,calc(100vw - 32px));}
|
||||
.model-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;min-width:280px;max-width:min(420px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:min(70vh,640px);overflow-y:auto;}
|
||||
.model-dropdown.open{display:block;}
|
||||
/* Phone path (#6080): the model picker is reparented to <body> and switched to
|
||||
position:fixed by _positionModelDropdown() so it escapes the .composer-footer
|
||||
containing block (container-type:inline-size, plus a backdrop-filter under the
|
||||
Geist Contrast skin — both establish a fixed containing block that would
|
||||
otherwise resolve `fixed` against the FOOTER, dropping the menu below the
|
||||
fold). Same idiom as #profileDropdown. JS owns left/top/width/maxHeight; this
|
||||
rule only flips positioning and relaxes the desktop width clamps. */
|
||||
.model-dropdown.model-dropdown--floating{position:fixed;left:0;bottom:auto;min-width:0;}
|
||||
.model-scope-note{position:sticky;top:0;z-index:2;padding:9px 14px;border-bottom:1px solid var(--border);color:var(--text);font-size:11px;font-weight:650;line-height:1.4;background:color-mix(in srgb,var(--surface) 82%,var(--accent-bg));box-shadow:0 1px 0 rgba(0,0,0,.12);}
|
||||
.model-group{padding:8px 14px 4px;font-size:10px;font-weight:700;letter-spacing:.04em;color:var(--muted);text-transform:uppercase;border-top:1px solid var(--border2);margin-top:2px;}.model-group.collapsible{cursor:pointer;user-select:none}.model-group.collapsible:before{content:'\25b6';display:inline-block;font-size:8px;margin-right:5px;transition:transform .15s ease}.model-group.collapsible.open:before{transform:rotate(90deg)}.model-group-body:empty{display:none}
|
||||
.model-group.sub{padding:4px 14px 2px 22px;font-size:9px;border-top:none;margin-top:0;letter-spacing:.03em;color:var(--muted);background:transparent;}.model-group.sub.collapsible:before{content:'\25b6';display:inline-block;font-size:7px;margin-right:4px;transition:transform .15s ease}.model-group.sub.collapsible.open:before{transform:rotate(90deg)}.model-group-body.sub{padding-left:8px;border-left:1px solid var(--border);}
|
||||
|
||||
+108
-2
@@ -3714,6 +3714,35 @@ function syncModelChip(){
|
||||
if(mobileAction) mobileAction.classList.toggle('active',!!(dd&&dd.classList.contains('open')));
|
||||
}
|
||||
|
||||
// Remembers where #composerModelDropdown lives in the composer-footer so the
|
||||
// phone path can move it to <body> and put it back exactly. Captured lazily on
|
||||
// the first reparent (see _positionModelDropdown phone branch).
|
||||
let _modelDropdownHome=null;
|
||||
|
||||
// Return the model dropdown into its original .composer-footer slot and clear
|
||||
// every inline style the phone path wrote, so the desktop CSS (position:absolute
|
||||
// anchored on the relatively-positioned .composer-footer) fully governs again.
|
||||
// Safe to call when the element never moved — it just no-ops the reinsert.
|
||||
function _restoreModelDropdownHome(){
|
||||
const dd=document.getElementById('composerModelDropdown');
|
||||
if(!dd) return;
|
||||
dd.classList.remove('model-dropdown--floating');
|
||||
dd.style.left='';
|
||||
dd.style.top='';
|
||||
dd.style.bottom='';
|
||||
dd.style.width='';
|
||||
dd.style.maxWidth='';
|
||||
dd.style.maxHeight='';
|
||||
if(_modelDropdownHome&&_modelDropdownHome.parent&&dd.parentNode!==_modelDropdownHome.parent){
|
||||
const ref=_modelDropdownHome.nextSibling;
|
||||
if(ref&&ref.parentNode===_modelDropdownHome.parent){
|
||||
_modelDropdownHome.parent.insertBefore(dd,ref);
|
||||
}else{
|
||||
_modelDropdownHome.parent.appendChild(dd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _positionModelDropdown(){
|
||||
const dd=$('composerModelDropdown');
|
||||
const chip=$('composerModelChip');
|
||||
@@ -3723,9 +3752,62 @@ function _positionModelDropdown(){
|
||||
const panel=$('composerMobileConfigPanel');
|
||||
const anchor=(panel&&panel.classList.contains('open')&&mobileAction)?mobileAction:(chip&&chip.offsetParent?chip:mobileAction);
|
||||
if(!anchor) return;
|
||||
const chipRect=anchor.getBoundingClientRect();
|
||||
const isPhone=typeof window.matchMedia==='function'&&window.matchMedia('(max-width:640px)').matches;
|
||||
if(isPhone){
|
||||
// #6080: .composer-footer sets container-type:inline-size (and a
|
||||
// backdrop-filter under the Geist Contrast skin) — both establish a fixed
|
||||
// containing block, so a position:fixed dropdown left inside the footer
|
||||
// resolves against the FOOTER (bottom of screen) instead of the viewport
|
||||
// and lands below the fold. Reparent to <body> — exactly the working
|
||||
// #profileDropdown idiom — so position:fixed is viewport-relative on ALL
|
||||
// skins, then compute coordinates against the visual viewport.
|
||||
if(!_modelDropdownHome){
|
||||
_modelDropdownHome={parent:dd.parentNode,nextSibling:dd.nextSibling};
|
||||
}
|
||||
if(dd.parentNode!==document.body) document.body.appendChild(dd);
|
||||
dd.classList.add('model-dropdown--floating');
|
||||
const anchorRect=anchor.getBoundingClientRect();
|
||||
const visualViewport=window.visualViewport;
|
||||
const viewportWidth=Math.max(1,Number(visualViewport&&visualViewport.width)||window.innerWidth||1);
|
||||
const viewportHeight=Math.max(1,Number(visualViewport&&visualViewport.height)||window.innerHeight||1);
|
||||
const viewportTop=Math.max(0,Number(visualViewport&&visualViewport.offsetTop)||0);
|
||||
const viewportBottom=viewportTop+viewportHeight;
|
||||
const margin=8;
|
||||
const gap=6;
|
||||
const viewportLeft=Math.max(0,Number(visualViewport&&visualViewport.offsetLeft)||0);
|
||||
const viewportRight=viewportLeft+viewportWidth;
|
||||
const titlebar=document.querySelector('.app-titlebar');
|
||||
const titlebarBottom=titlebar&&typeof titlebar.getBoundingClientRect==='function'
|
||||
? Number(titlebar.getBoundingClientRect().bottom)||0
|
||||
: 0;
|
||||
const contentTop=Math.max(viewportTop+margin,titlebarBottom+margin);
|
||||
const menuWidth=Math.max(1,viewportWidth-margin*2);
|
||||
const left=Math.max(viewportLeft+margin,Math.min(anchorRect.left,viewportRight-menuWidth-margin));
|
||||
dd.style.left=`${left}px`;
|
||||
dd.style.width=`${menuWidth}px`;
|
||||
dd.style.maxWidth=`${menuWidth}px`;
|
||||
dd.style.bottom='auto';
|
||||
const menuHeight=Math.max(dd.scrollHeight,dd.offsetHeight);
|
||||
const aboveSpace=Math.max(0,anchorRect.top-contentTop-gap-margin);
|
||||
const belowSpace=Math.max(0,viewportBottom-anchorRect.bottom-gap-margin);
|
||||
const openAbove=aboveSpace>=Math.min(menuHeight,belowSpace)||aboveSpace>=belowSpace;
|
||||
const availableHeight=Math.max(1,openAbove?aboveSpace:belowSpace);
|
||||
dd.style.maxHeight=`${availableHeight}px`;
|
||||
const visibleHeight=Math.min(menuHeight||availableHeight,availableHeight);
|
||||
const top=openAbove
|
||||
? anchorRect.top-gap-visibleHeight
|
||||
: anchorRect.bottom+gap;
|
||||
dd.style.top=`${Math.max(contentTop,Math.min(top,viewportBottom-margin-visibleHeight))}px`;
|
||||
return;
|
||||
}
|
||||
// Desktop (>640px): keep the current master behaviour — an absolutely
|
||||
// positioned .composer-footer child. Restore the element into the footer (in
|
||||
// case a prior phone open moved it to <body>) and clear the phone inline
|
||||
// styles so the desktop CSS anchor is byte-for-byte identical to master.
|
||||
_restoreModelDropdownHome();
|
||||
const anchorRect=anchor.getBoundingClientRect();
|
||||
const footerRect=footer.getBoundingClientRect();
|
||||
let left=chipRect.left-footerRect.left;
|
||||
let left=anchorRect.left-footerRect.left;
|
||||
const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth);
|
||||
left=Math.max(0, Math.min(left, maxLeft));
|
||||
dd.style.left=`${left}px`;
|
||||
@@ -4532,6 +4614,10 @@ function closeModelDropdown(){
|
||||
if(dd) dd.classList.remove('open');
|
||||
if(chip) chip.classList.remove('active');
|
||||
if(mobileAction) mobileAction.classList.remove('active');
|
||||
// If the phone path reparented the menu onto <body>, put it back in the
|
||||
// footer and clear the fixed-position inline styles so the DOM returns to its
|
||||
// baseline shape and the next desktop open anchors correctly (#6080).
|
||||
if(typeof _restoreModelDropdownHome==='function') _restoreModelDropdownHome();
|
||||
}
|
||||
|
||||
function closeSettingsModelDropdown(){
|
||||
@@ -4653,6 +4739,26 @@ window.addEventListener('resize',()=>{
|
||||
}
|
||||
});
|
||||
|
||||
// visualViewport resize/scroll fire on mobile when the on-screen keyboard opens
|
||||
// or the URL bar collapses/expands — the phone dropdown is fixed to the visual
|
||||
// viewport, so it must be re-measured against the new offsets. Coalesce with rAF
|
||||
// so a burst of scroll/resize events triggers at most one reposition per frame.
|
||||
let _modelDropdownRepositionScheduled=false;
|
||||
function _repositionOpenModelDropdown(){
|
||||
const dd=$('composerModelDropdown');
|
||||
if(!(dd&&dd.classList.contains('open'))||_modelDropdownRepositionScheduled) return;
|
||||
_modelDropdownRepositionScheduled=true;
|
||||
requestAnimationFrame(()=>{
|
||||
_modelDropdownRepositionScheduled=false;
|
||||
const openDd=$('composerModelDropdown');
|
||||
if(openDd&&openDd.classList.contains('open')) _positionModelDropdown();
|
||||
});
|
||||
}
|
||||
if(window.visualViewport){
|
||||
window.visualViewport.addEventListener('resize',_repositionOpenModelDropdown);
|
||||
window.visualViewport.addEventListener('scroll',_repositionOpenModelDropdown);
|
||||
}
|
||||
|
||||
// ── Fit-based composer footer collapse ──────────────────────────────────────
|
||||
// Stage classes on .composer-footer:
|
||||
// (none) full labels · .cf-icons icon chips · .cf-icons.cf-burger hamburger.
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Regression guards for #6080 — mobile composer model picker lands off-screen.
|
||||
|
||||
Root cause: #composerModelDropdown is a child of .composer-footer, which sets
|
||||
`container-type:inline-size` (and, under the Geist Contrast skin, a
|
||||
`backdrop-filter`). Both establish a fixed containing block, so a phone
|
||||
`position:fixed` dropdown resolves against the FOOTER (pinned to the bottom of
|
||||
the screen) instead of the visual viewport, dropping the menu below the fold —
|
||||
the "tapping the model picker does nothing / opens off-screen" symptom.
|
||||
|
||||
Fix (PR #6105 rework, credit @webtecnica): on the phone path the dropdown is
|
||||
reparented to <body> and switched to position:fixed via a `--floating` class,
|
||||
exactly the working #profileDropdown idiom, so `fixed` is viewport-relative on
|
||||
ALL skins. Desktop (>640px) is unchanged: the menu stays an absolutely
|
||||
positioned .composer-footer child.
|
||||
|
||||
The node-executed test below is the load-bearing one: it runs the real
|
||||
`_positionModelDropdown` / `closeModelDropdown` against a stub DOM and asserts
|
||||
DOM parentage, i.e. that the dropdown is NOT a descendant of the footer while
|
||||
open on a phone (so no container-type ancestor can trap its fixed positioning),
|
||||
and that it is restored into the footer on close and on desktop.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent.resolve()
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_body(src: str, marker: str) -> str:
|
||||
start = src.index(marker)
|
||||
depth = 0
|
||||
opened = False
|
||||
for idx, ch in enumerate(src[start:], start):
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
opened = True
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if opened and depth == 0:
|
||||
return src[start : idx + 1]
|
||||
raise AssertionError(f"Could not extract function body for {marker}")
|
||||
|
||||
|
||||
# ── Static structure guards ──────────────────────────────────────────────────
|
||||
|
||||
def test_css_has_floating_fixed_rule():
|
||||
"""The reparented phone menu needs a body-level position:fixed rule."""
|
||||
m = re.search(r"\.model-dropdown\.model-dropdown--floating\{([^}]*)\}", CSS)
|
||||
assert m, ".model-dropdown--floating rule missing (phone reparent CSS)"
|
||||
body = m.group(1)
|
||||
assert "position:fixed" in body, (
|
||||
"floating model dropdown must be position:fixed so it anchors to the "
|
||||
"viewport once reparented to <body> (#6080)"
|
||||
)
|
||||
|
||||
|
||||
def test_no_geist_backdrop_override_added_for_model_dropdown():
|
||||
"""Because the menu leaves the footer, the Geist backdrop-filter workaround
|
||||
must NOT be introduced (the reparent is the fix, not a per-skin override)."""
|
||||
assert "container-type:normal" not in CSS.replace(" ", ""), (
|
||||
"do not neutralise container-type on the footer — reparent instead"
|
||||
)
|
||||
|
||||
|
||||
def test_position_fn_has_phone_reparent_branch():
|
||||
body = _function_body(UI_JS, "function _positionModelDropdown(")
|
||||
assert "matchMedia('(max-width:640px)')" in body, "phone breakpoint check missing"
|
||||
assert "document.body.appendChild(dd)" in body, (
|
||||
"phone path must reparent the dropdown to <body> to escape the "
|
||||
".composer-footer containing block (#6080)"
|
||||
)
|
||||
assert "model-dropdown--floating" in body, "phone path must apply the floating class"
|
||||
# PR #6105 visual-viewport math must be preserved.
|
||||
assert "window.visualViewport" in body
|
||||
assert "titlebar" in body and "app-titlebar" in body, "titlebar clamp preserved"
|
||||
assert "openAbove" in body, "above/below flip preserved"
|
||||
assert "menuWidth" in body and "margin*2" in body, "width=viewport-16px preserved"
|
||||
|
||||
|
||||
def test_close_restores_home_parent():
|
||||
body = _function_body(UI_JS, "function closeModelDropdown(")
|
||||
assert "_restoreModelDropdownHome" in body, (
|
||||
"closeModelDropdown must return the reparented menu to the footer"
|
||||
)
|
||||
|
||||
|
||||
def test_raf_coalesced_visualviewport_listeners_present():
|
||||
assert "_repositionOpenModelDropdown" in UI_JS
|
||||
assert "requestAnimationFrame" in _function_body(UI_JS, "function _repositionOpenModelDropdown(")
|
||||
assert "window.visualViewport.addEventListener('resize',_repositionOpenModelDropdown)" in UI_JS
|
||||
assert "window.visualViewport.addEventListener('scroll',_repositionOpenModelDropdown)" in UI_JS
|
||||
|
||||
|
||||
# ── Load-bearing DOM-parentage guard (executed in node) ──────────────────────
|
||||
|
||||
def test_dropdown_escapes_footer_containing_block_when_open_on_phone():
|
||||
"""Prove via real execution that the open phone dropdown is reparented to
|
||||
<body> and is NOT a descendant of .composer-footer (the container-type
|
||||
ancestor), and that it is restored into the footer on close / on desktop."""
|
||||
block_start = UI_JS.index("let _modelDropdownHome=null;")
|
||||
block_end = UI_JS.index("function _readModelOverflowData(")
|
||||
position_block = UI_JS[block_start:block_end]
|
||||
close_block = _function_body(UI_JS, "function closeModelDropdown(")
|
||||
snippets = [position_block, close_block]
|
||||
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
const assert = require('assert');
|
||||
const snippets = {json.dumps(snippets)};
|
||||
|
||||
class ClassList {{
|
||||
constructor(el) {{ this.el = el; this.set = new Set(); }}
|
||||
add(n) {{ this.set.add(n); this._sync(); }}
|
||||
remove(n) {{ this.set.delete(n); this._sync(); }}
|
||||
contains(n) {{ return this.set.has(n); }}
|
||||
toggle(n, f) {{
|
||||
if (f === undefined) f = !this.set.has(n);
|
||||
if (f) this.add(n); else this.remove(n);
|
||||
return f;
|
||||
}}
|
||||
_sync() {{ this.el.className = [...this.set].join(' '); }}
|
||||
}}
|
||||
class Node {{
|
||||
constructor(tag, id) {{
|
||||
this.tagName = (tag || 'div').toUpperCase();
|
||||
this.id = id || '';
|
||||
this.className = '';
|
||||
this.classList = new ClassList(this);
|
||||
this.style = {{}};
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.dataset = {{}};
|
||||
this._rect = {{left:0, top:0, right:0, bottom:0, width:0, height:0}};
|
||||
this.scrollHeight = 0; this.offsetHeight = 0;
|
||||
this.offsetWidth = 0; this.clientWidth = 0;
|
||||
this.offsetParent = null;
|
||||
}}
|
||||
_detach(child) {{
|
||||
const i = this.children.indexOf(child);
|
||||
if (i >= 0) this.children.splice(i, 1);
|
||||
child.parentNode = null;
|
||||
}}
|
||||
appendChild(child) {{
|
||||
if (child.parentNode) child.parentNode._detach(child);
|
||||
child.parentNode = this; this.children.push(child); return child;
|
||||
}}
|
||||
insertBefore(child, ref) {{
|
||||
if (child.parentNode) child.parentNode._detach(child);
|
||||
child.parentNode = this;
|
||||
const i = ref ? this.children.indexOf(ref) : -1;
|
||||
if (i >= 0) this.children.splice(i, 0, child);
|
||||
else this.children.push(child);
|
||||
return child;
|
||||
}}
|
||||
get nextSibling() {{
|
||||
if (!this.parentNode) return null;
|
||||
const i = this.parentNode.children.indexOf(this);
|
||||
return this.parentNode.children[i + 1] || null;
|
||||
}}
|
||||
getBoundingClientRect() {{ return this._rect; }}
|
||||
}}
|
||||
|
||||
const body = new Node('body', 'body');
|
||||
const footer = new Node('div'); // .composer-footer (container-type ancestor)
|
||||
footer.className = 'composer-footer';
|
||||
footer.clientWidth = 390;
|
||||
footer._rect = {{left:0, top:560, right:390, bottom:600, width:390, height:40}};
|
||||
const titlebar = new Node('div');
|
||||
titlebar.className = 'app-titlebar';
|
||||
titlebar._rect = {{left:0, top:0, right:390, bottom:44, width:390, height:44}};
|
||||
|
||||
const dd = new Node('div', 'composerModelDropdown');
|
||||
dd.scrollHeight = 300; dd.offsetHeight = 300; dd.offsetWidth = 280;
|
||||
const chip = new Node('button', 'composerModelChip');
|
||||
chip.offsetParent = footer; // visible on the desktop composer
|
||||
chip._rect = {{left:100, top:560, right:160, bottom:600, width:60, height:40}};
|
||||
const mobileAction = new Node('button', 'composerMobileModelAction');
|
||||
mobileAction.offsetParent = footer;
|
||||
mobileAction._rect = {{left:20, top:560, right:120, bottom:600, width:100, height:40}};
|
||||
const panel = new Node('div', 'composerMobileConfigPanel');
|
||||
|
||||
// dd starts life inside the footer, followed by a sibling — proves the
|
||||
// insertBefore restore path returns it to the exact original slot.
|
||||
const trailingSibling = new Node('div', 'uploadBarWrap');
|
||||
footer.appendChild(dd);
|
||||
footer.appendChild(trailingSibling);
|
||||
|
||||
const elements = new Map([
|
||||
['composerModelDropdown', dd],
|
||||
['composerModelChip', chip],
|
||||
['composerMobileModelAction', mobileAction],
|
||||
['composerMobileConfigPanel', panel],
|
||||
]);
|
||||
|
||||
let PHONE = true;
|
||||
globalThis.document = {{
|
||||
body,
|
||||
getElementById: (id) => elements.get(id) || null,
|
||||
querySelector: (sel) => {{
|
||||
if (sel === '.composer-footer') return footer;
|
||||
if (sel === '.app-titlebar') return titlebar;
|
||||
return null;
|
||||
}},
|
||||
}};
|
||||
globalThis.window = {{
|
||||
matchMedia: (q) => ({{ matches: q.indexOf('max-width:640px') !== -1 ? PHONE : false }}),
|
||||
innerWidth: 390, innerHeight: 800,
|
||||
visualViewport: {{ width: 390, height: 600, offsetTop: 0, offsetLeft: 0 }},
|
||||
}};
|
||||
globalThis.$ = (id) => elements.get(id) || null;
|
||||
|
||||
eval(snippets.join(String.fromCharCode(10)) + String.fromCharCode(10) + `;globalThis.__t = {{
|
||||
position: _positionModelDropdown,
|
||||
close: closeModelDropdown,
|
||||
restore: _restoreModelDropdownHome,
|
||||
}};`);
|
||||
|
||||
function ancestorIsFooter(el) {{
|
||||
let p = el.parentNode;
|
||||
while (p) {{ if (p === footer) return true; p = p.parentNode; }}
|
||||
return false;
|
||||
}}
|
||||
|
||||
// ── Phone: open ──────────────────────────────────────────────────────
|
||||
PHONE = true;
|
||||
dd.classList.add('open');
|
||||
__t.position();
|
||||
assert.strictEqual(dd.parentNode, body,
|
||||
'open phone dropdown must be reparented to <body>');
|
||||
assert.notStrictEqual(dd.parentNode, footer,
|
||||
'open phone dropdown must NOT remain a footer child');
|
||||
assert.strictEqual(ancestorIsFooter(dd), false,
|
||||
'open phone dropdown must have NO .composer-footer ancestor — this is ' +
|
||||
'what escapes the container-type/backdrop-filter fixed-containing-block trap (#6080)');
|
||||
assert.strictEqual(dd.classList.contains('model-dropdown--floating'), true,
|
||||
'phone dropdown must carry the floating (position:fixed) class');
|
||||
assert.ok(dd.style.top && dd.style.top.endsWith('px'), 'phone path sets a px top');
|
||||
assert.ok(dd.style.width && dd.style.width.endsWith('px'), 'phone path sets a px width');
|
||||
|
||||
// ── Phone: close restores it to the exact original footer slot ───────
|
||||
__t.close();
|
||||
assert.strictEqual(dd.parentNode, footer,
|
||||
'closed dropdown must be restored into .composer-footer');
|
||||
assert.strictEqual(dd.nextSibling, trailingSibling,
|
||||
'restore must reinsert the dropdown before its original next sibling');
|
||||
assert.strictEqual(dd.classList.contains('model-dropdown--floating'), false,
|
||||
'close must drop the floating class');
|
||||
assert.strictEqual(dd.style.top, '', 'close must clear the fixed inline top');
|
||||
assert.strictEqual(dd.style.width, '', 'close must clear the inline width');
|
||||
|
||||
// ── Desktop: never reparents, stays an absolutely positioned footer child ──
|
||||
PHONE = false;
|
||||
dd.classList.add('open');
|
||||
__t.position();
|
||||
assert.strictEqual(dd.parentNode, footer,
|
||||
'desktop dropdown must stay inside .composer-footer (behaviour identical to master)');
|
||||
assert.strictEqual(dd.classList.contains('model-dropdown--floating'), false,
|
||||
'desktop dropdown must not use the floating class');
|
||||
assert.ok(dd.style.top === '' || dd.style.top === undefined || dd.style.top === null,
|
||||
'desktop path must not write a fixed top');
|
||||
|
||||
console.log('OK');
|
||||
"""
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["node", "-e", script],
|
||||
cwd=REPO_ROOT,
|
||||
check=False,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
assert proc.returncode == 0, (
|
||||
f"node DOM-parentage assertions failed:\n{proc.stdout}\n{proc.stderr}"
|
||||
)
|
||||
assert "OK" in proc.stdout
|
||||
Reference in new issue
Block a user