a11y: avoid no-op composer height resets

This commit is contained in:
sheldon-im committed 2026-07-20 13:43:09 +09:00
1 parent 98a67ee6cf
commit 6a30d74ac7
2 files changed
+87 -5

No files matched your search

+12
View File
@@ -6733,12 +6733,23 @@ function transcript(){
}
let _composerAutoResizeRaf=0;
let _composerLastResizeValueLength=0;
function autoResize(){
if(_composerAutoResizeRaf && typeof cancelAnimationFrame==='function'){
cancelAnimationFrame(_composerAutoResizeRaf);
_composerAutoResizeRaf=0;
}
const el=$('msg');
const _nextValueLength=String(el.value||'').length;
const _isGrowing=_nextValueLength>_composerLastResizeValueLength;
const _fitsCurrentHeight=el.scrollHeight<=el.offsetHeight;
// A one-line append does not need a height round trip. Avoiding the
// `auto` → measured-height mutation keeps textarea caret geometry stable for
// assistive technology while deletes and genuine multi-line growth still resize.
if(_isGrowing&&_fitsCurrentHeight){
_composerLastResizeValueLength=_nextValueLength;
return;
}
const _prevComposerH=el.offsetHeight;
// #5514: autoResize() momentarily sets the textarea to height:'auto' (collapses
// a multi-row composer toward its 1-row min) before reading scrollHeight and
@@ -6763,6 +6774,7 @@ function autoResize(){
const _prevScrollTop=_msgs?_msgs.scrollTop:0;
el.style.height='auto';
el.style.height=Math.min(el.scrollHeight,200)+'px';
_composerLastResizeValueLength=_nextValueLength;
if(_msgs&&_msgs.scrollTop!==_prevScrollTop) _msgs.scrollTop=_prevScrollTop;
updateSendBtn();
// Genuine NET growth (a new row that keeps the composer taller than before)
@@ -41,9 +41,73 @@ MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
# Static wiring
# ---------------------------------------------------------------------------
def test_single_line_growth_does_not_write_height_in_node_fixture():
node = shutil.which("node")
if not node: # pragma: no cover
pytest.skip("node not available")
body = _autoresize_body()
harness = textwrap.dedent(
"""
let _composerAutoResizeRaf = 0;
let _composerLastResizeValueLength = 0;
let writes = 0;
const msg = {
value: 'a',
offsetHeight: 44,
scrollHeight: 44,
style: {
set height(_value) { writes += 1; },
get height() { return '44px'; },
},
};
const messages = { scrollTop: 0 };
const $ = (id) => id === 'msg' ? msg : id === 'messages' ? messages : null;
function updateSendBtn() { throw new Error('one-line append must not refresh again from autoResize'); }
function _repinMessagesAfterComposerResize() { throw new Error('one-line append must not repin transcript'); }
%(autoresize)s
autoResize();
console.log(JSON.stringify({ writes, lastLength: _composerLastResizeValueLength }));
"""
) % {"autoresize": body}
proc = subprocess.run([node, "-e", harness], capture_output=True, text=True, timeout=30)
assert proc.returncode == 0, proc.stderr
assert json.loads(proc.stdout) == {"writes": 0, "lastLength": 1}
def test_single_line_delete_still_runs_the_height_round_trip_in_node_fixture():
node = shutil.which("node")
if not node: # pragma: no cover
pytest.skip("node not available")
body = _autoresize_body()
harness = textwrap.dedent(
"""
let _composerAutoResizeRaf = 0;
let _composerLastResizeValueLength = 5;
let writes = 0, height = 100;
const msg = {
value: 'a',
get offsetHeight() { return height; },
scrollHeight: 44,
style: {
set height(value) { writes += 1; height = value === 'auto' ? 44 : parseInt(value, 10); },
get height() { return height + 'px'; },
},
};
const messages = { scrollTop: 0 };
const $ = (id) => id === 'msg' ? msg : id === 'messages' ? messages : null;
let sendUpdates = 0;
function updateSendBtn() { sendUpdates += 1; }
function _repinMessagesAfterComposerResize() {}
%(autoresize)s
autoResize();
console.log(JSON.stringify({ writes, height, lastLength: _composerLastResizeValueLength, sendUpdates }));
"""
) % {"autoresize": body}
proc = subprocess.run([node, "-e", harness], capture_output=True, text=True, timeout=30)
assert proc.returncode == 0, proc.stderr
assert json.loads(proc.stdout) == {"writes": 2, "height": 44, "lastLength": 1, "sendUpdates": 1}
def _helper_body() -> str:
start = UI_JS.find("function _repinMessagesAfterComposerResize(")
@@ -118,8 +182,13 @@ def test_resize_observer_installed_on_composer():
assert "can't strand" in BOOT_JS
# ---------------------------------------------------------------------------
# Behavioral (node vm) — the pin guard under a viewport shrink
def test_single_line_growth_skips_the_height_round_trip():
body = _autoresize_body()
assert "let _composerLastResizeValueLength=0;" in MESSAGES_JS
assert "const _isGrowing=_nextValueLength>_composerLastResizeValueLength;" in body
assert "const _fitsCurrentHeight=el.scrollHeight<=el.offsetHeight;" in body
assert "if(_isGrowing&&_fitsCurrentHeight){" in body
assert "el.style.height='auto'" in body
# ---------------------------------------------------------------------------
def _run(scenario):
@@ -232,6 +301,7 @@ def _run_autoresize(scenario):
const $ = (id) => (id === 'msg' ? msgEl : id === 'messages' ? msgsEl : null);
let _messageUserUnpinned = %(unpinned)s, _scrollPinned = %(pinned)s;
let _composerAutoResizeRaf = 0;
let _composerLastResizeValueLength = 0;
let _repinCalls = 0;
function updateSendBtn(){}
function _messageBottomDistance(){ return msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight; }