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));}
|
.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{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;}
|
.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-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{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);}
|
.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')));
|
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(){
|
function _positionModelDropdown(){
|
||||||
const dd=$('composerModelDropdown');
|
const dd=$('composerModelDropdown');
|
||||||
const chip=$('composerModelChip');
|
const chip=$('composerModelChip');
|
||||||
@@ -3723,9 +3752,62 @@ function _positionModelDropdown(){
|
|||||||
const panel=$('composerMobileConfigPanel');
|
const panel=$('composerMobileConfigPanel');
|
||||||
const anchor=(panel&&panel.classList.contains('open')&&mobileAction)?mobileAction:(chip&&chip.offsetParent?chip:mobileAction);
|
const anchor=(panel&&panel.classList.contains('open')&&mobileAction)?mobileAction:(chip&&chip.offsetParent?chip:mobileAction);
|
||||||
if(!anchor) return;
|
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();
|
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);
|
const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth);
|
||||||
left=Math.max(0, Math.min(left, maxLeft));
|
left=Math.max(0, Math.min(left, maxLeft));
|
||||||
dd.style.left=`${left}px`;
|
dd.style.left=`${left}px`;
|
||||||
@@ -4532,6 +4614,10 @@ function closeModelDropdown(){
|
|||||||
if(dd) dd.classList.remove('open');
|
if(dd) dd.classList.remove('open');
|
||||||
if(chip) chip.classList.remove('active');
|
if(chip) chip.classList.remove('active');
|
||||||
if(mobileAction) mobileAction.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(){
|
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 ──────────────────────────────────────
|
// ── Fit-based composer footer collapse ──────────────────────────────────────
|
||||||
// Stage classes on .composer-footer:
|
// Stage classes on .composer-footer:
|
||||||
// (none) full labels · .cf-icons icon chips · .cf-icons.cf-burger hamburger.
|
// (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