From 071db7ca1b3cd4556dfb1822ff1ce2c3ced7ce7e Mon Sep 17 00:00:00 2001 From: ziyan2006 Date: Fri, 29 May 2026 15:41:10 +0800 Subject: [PATCH] [codex] Stabilize HTML deck navigation state (#3142) * fix: stabilize html deck navigation state * fix: avoid misclassifying transform decks as scroll decks * fix: detect default root-scroller decks --------- Co-authored-by: Nongzi <3051966228@qq.com> --- apps/web/src/runtime/srcdoc.ts | 70 +++++- .../runtime/design-template-deck-nav.test.ts | 96 ++++++++ ...rcdoc-deck-bridge-scroll-container.test.ts | 207 ++++++++++++++++++ ...rcdoc-deck-bridge-transform-driven.test.ts | 143 ++++++++++++ apps/web/tests/runtime/srcdoc.test.ts | 3 +- .../simple-deck/assets/template.html | 50 ++++- design-templates/simple-deck/example.html | 37 +++- .../examples/simple-deck/assets/template.html | 50 ++++- 8 files changed, 636 insertions(+), 20 deletions(-) create mode 100644 apps/web/tests/runtime/srcdoc-deck-bridge-scroll-container.test.ts create mode 100644 apps/web/tests/runtime/srcdoc-deck-bridge-transform-driven.test.ts diff --git a/apps/web/src/runtime/srcdoc.ts b/apps/web/src/runtime/srcdoc.ts index 90617878e..c64ce3f77 100644 --- a/apps/web/src/runtime/srcdoc.ts +++ b/apps/web/src/runtime/srcdoc.ts @@ -1528,9 +1528,42 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string { if (structured.length) return structured; return document.querySelectorAll('.slide'); } - function scroller(){ - if (document.body && document.body.scrollWidth > document.body.clientWidth + 1) return document.body; - return document.scrollingElement || document.documentElement; + function scrollOverflow(el){ + if (!el) return 0; + return Math.max(0, (el.scrollWidth || 0) - (el.clientWidth || 0)); + } + function overflowMode(el){ + if (!el || !window.getComputedStyle) return ''; + try { + return String(window.getComputedStyle(el).overflowX || '').toLowerCase(); + } catch (_) { + return ''; + } + } + function isScrollableOverflowMode(mode){ + return mode === 'auto' || mode === 'scroll' || mode === 'overlay'; + } + function isClippedOverflowMode(mode){ + return mode === 'hidden' || mode === 'clip'; + } + function isRootScrollContainer(el){ + return !!el && ( + el === document.scrollingElement || + el === document.documentElement || + el === document.body + ); + } + function rootScrollerClipped(){ + return isClippedOverflowMode(overflowMode(document.documentElement)) || + isClippedOverflowMode(overflowMode(document.body)); + } + function scrollLeftOf(el){ + if (!el) return 0; + try { + return Number(el.scrollLeft) || 0; + } catch (_) { + return 0; + } } function scrollTargets(){ var targets = []; @@ -1560,7 +1593,15 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string { return false; } function isScrollDeck(){ - return hasHorizontalScroll(); + var targets = scrollTargets(); + for (var i=0; i= 0) return true; + // A bare active-class marker is not enough to prove the host can drive the + // deck by class mutation alone. Many generated decks keep that marker in + // sync for counters / dots but move the visible slide via a translated + // stage or track, so flipping classes in the host bridge updates the + // reported slide index while leaving the canvas on the old page. Only + // treat class-driven decks as directly mutable when inactive siblings are + // actually hidden by computed visibility rules. + var active = findActiveByClass(list); + if (active >= 0 && hasComputedHiddenSibling(list, active)) return true; for (var i=0; i bodyScrollLeft, + set: (_value: number) => { + bodyScrollLeft = 0; + }, + }); + Object.defineProperty(win.document.documentElement, 'scrollLeft', { + configurable: true, + get: () => documentScrollLeft, + set: (value: number) => { + documentScrollLeft = value; + }, + }); + Object.defineProperty(win.document.body, 'scrollTo', { + configurable: true, + value: () => {}, + }); + Object.defineProperty(win.document.documentElement, 'scrollTo', { + configurable: true, + value: ({ left }: { left?: number }) => { + if (typeof left === 'number') { + documentScrollLeft = left; + } + }, + }); + return dom; +} + function activeSlideIndex(win: DOMWindow) { const slides = Array.from(win.document.querySelectorAll('.deck > .slide')); return slides.findIndex((slide) => slide.classList.contains('active')); @@ -71,4 +138,33 @@ describe('design template deck navigation', () => { expect(dots[6]?.classList.contains('active')).toBe(true); expect(dots[6]?.getAttribute('aria-current')).toBe('true'); }); + + it('keeps simple-deck keyboard navigation single-step and synced to documentElement scroll', () => { + const dom = setupSimpleDeck(); + const { window: win } = dom; + const counter = win.document.getElementById('counter'); + + expect(counter?.textContent?.trim()).toBe('1 / 6'); + expect(win.document.documentElement.scrollLeft).toBe(0); + + win.document.body.dispatchEvent(new win.KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'ArrowRight', + })); + win.document.dispatchEvent(new win.Event('scroll', { bubbles: true })); + + expect(counter?.textContent?.trim()).toBe('2 / 6'); + expect(win.document.documentElement.scrollLeft).toBe(1000); + + win.document.body.dispatchEvent(new win.KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'ArrowRight', + })); + win.document.dispatchEvent(new win.Event('scroll', { bubbles: true })); + + expect(counter?.textContent?.trim()).toBe('3 / 6'); + expect(win.document.documentElement.scrollLeft).toBe(2000); + }); }); diff --git a/apps/web/tests/runtime/srcdoc-deck-bridge-scroll-container.test.ts b/apps/web/tests/runtime/srcdoc-deck-bridge-scroll-container.test.ts new file mode 100644 index 000000000..7ec20f998 --- /dev/null +++ b/apps/web/tests/runtime/srcdoc-deck-bridge-scroll-container.test.ts @@ -0,0 +1,207 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { buildSrcdoc } from '../../src/runtime/srcdoc'; + +function extractDeckBridgeScript(srcdoc: string): string { + const match = srcdoc.match(/