Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions demos/4006-tab-focus-fix.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8">
<title>Swiper - Tab Focus Fix Test</title>
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
</head>

<body>
<h1>Tab Focus Fix Test - Issue #4006</h1>
<p>Use the Tab key to navigate through the links in the slides. The slider should not break when a link gets focused.</p>

<!-- Swiper -->
<div class="swiper">
<div class="swiper-wrapper">
<div class="swiper-slide">
<h2>Slide 1</h2>
<p>This slide contains a link:</p>
<a href="#slide1">Link in Slide 1</a>
</div>
<div class="swiper-slide">
<h2>Slide 2</h2>
<p>This slide contains multiple links:</p>
<a href="#slide2a">First Link in Slide 2</a><br>
<a href="#slide2b">Second Link in Slide 2</a>
</div>
<div class="swiper-slide">
<h2>Slide 3</h2>
<p>This slide contains a button:</p>
<button onclick="alert('Button clicked!')">Click Me</button>
</div>
<div class="swiper-slide">
<h2>Slide 4</h2>
<p>This slide contains a link that should trigger navigation:</p>
<a href="#slide4">Link in Slide 4</a>
</div>
<div class="swiper-slide">
<h2>Slide 5</h2>
<p>Another link here:</p>
<a href="#slide5">Link in Slide 5</a>
</div>
<div class="swiper-slide">
<h2>Slide 6</h2>
<p>Final slide with a link:</p>
<a href="#slide6">Link in Slide 6</a>
</div>
</div>
<!-- Add Pagination -->
<div class="swiper-pagination"></div>
<!-- Add Arrows -->
<div class="swiper-button-next"></div>
<div class="swiper-button-prev"></div>
</div>

<div style="margin-top: 20px; padding: 20px; background: #f0f0f0;">
<h3>Test Instructions:</h3>
<ol>
<li>Click on the slider or press Tab to start tabbing through the page</li>
<li>When you tab to a link inside a slide, the slider should automatically navigate to that slide</li>
<li>The slider display should NOT break - slides should remain properly aligned</li>
<li>Continue tabbing through all links to verify the fix works for all slides</li>
</ol>
<p><strong>Expected behavior:</strong> The slider should smoothly navigate to the slide containing the focused link without breaking the layout.</p>
<p><strong>Previous bug:</strong> When a link got focus, the browser would scroll the container, changing scrollLeft and breaking the slider display.</p>
</div>

<!-- Initialize Swiper -->
<script type="module">
import Swiper from 'swiper/swiper-bundle.mjs';
import 'swiper/swiper-bundle.css';
var swiper = new Swiper('.swiper', {
slidesPerView: 1,
spaceBetween: 30,
keyboard: {
enabled: true,
},
pagination: {
el: '.swiper-pagination',
clickable: true,
},
navigation: {
nextEl: '.swiper-button-next',
prevEl: '.swiper-button-prev',
},
a11y: {
enabled: true,
scrollOnFocus: true,
},
});
</script>

<!-- Demo styles -->
<style>
html,
body {
position: relative;
height: 100%;
margin: 0;
padding: 20px;
}

body {
background: #eee;
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 14px;
color: #000;
}

h1 {
text-align: center;
margin-bottom: 10px;
}

.swiper {
width: 100%;
height: 400px;
margin-left: auto;
margin-right: auto;
margin-bottom: 20px;
}

.swiper-slide {
text-align: center;
font-size: 18px;
background: #fff;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 20px;
box-sizing: border-box;
}

.swiper-slide h2 {
margin-top: 0;
}

.swiper-slide a,
.swiper-slide button {
display: inline-block;
margin: 10px;
padding: 10px 20px;
background: #007aff;
color: white;
text-decoration: none;
border-radius: 5px;
border: none;
cursor: pointer;
font-size: 16px;
}

.swiper-slide a:hover,
.swiper-slide button:hover {
background: #0056b3;
}

.swiper-slide a:focus,
.swiper-slide button:focus {
outline: 3px solid #ff6b6b;
outline-offset: 2px;
}
</style>
</body>

</html>

1 change: 1 addition & 0 deletions demos/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ <h1>Swiper demos</h1>
<li><a href="/430-slideable-menu.html">430-slideable-menu</a></li>
<li><a href="/440-change-direction.html">440-change-direction</a></li>
<li><a href="/450-watchSlidesVisibility.html">450-watchSlidesVisibility</a></li>
<li><a href="/4006-tab-focus-fix.html">4006-tab-focus-fix</a></li>
</ul>
</body>

Expand Down
122 changes: 121 additions & 1 deletion src/modules/a11y/a11y.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export default function A11y({ swiper, extendParams, on }) {
let preventFocusHandler;
let focusTargetSlideEl;
let visibilityChangedTimestamp = new Date().getTime();
let savedScrollLeft = 0;
let savedScrollTop = 0;
let isRestoringScroll = false;
let scrollRestoreTimeout = null;

function notify(message) {
const notification = liveRegion;
Expand Down Expand Up @@ -242,6 +246,104 @@ export default function A11y({ swiper, extendParams, on }) {
visibilityChangedTimestamp = new Date().getTime();
};

const restoreScrollPosition = () => {
if (!isRestoringScroll) return;
if (swiper.isHorizontal()) {
if (swiper.params.cssMode && swiper.el.scrollLeft !== savedScrollLeft) {
swiper.el.scrollLeft = savedScrollLeft;
}
if (swiper.wrapperEl.scrollLeft !== savedScrollLeft) {
swiper.wrapperEl.scrollLeft = savedScrollLeft;
}
} else {
if (swiper.params.cssMode && swiper.el.scrollTop !== savedScrollTop) {
swiper.el.scrollTop = savedScrollTop;
}
if (swiper.wrapperEl.scrollTop !== savedScrollTop) {
swiper.wrapperEl.scrollTop = savedScrollTop;
}
}
};

const handleScroll = () => {
if (isRestoringScroll) {
restoreScrollPosition();
}
};

const handleFocusIn = (e) => {
if (swiper.a11y.clicked || !swiper.params.a11y.scrollOnFocus) return;
if (new Date().getTime() - visibilityChangedTimestamp < 100) return;

const slideEl = e.target.closest(`.${swiper.params.slideClass}, swiper-slide`);
if (!slideEl || !swiper.slides.includes(slideEl)) return;

// Check if the focused element is a focusable element (link, button, etc.)
const focusableElements = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'AREA'];
const isFocusableElement = focusableElements.includes(e.target.tagName) ||
e.target.hasAttribute('tabindex') ||
e.target.isContentEditable;

if (!isFocusableElement) return;

if (e.sourceCapabilities && e.sourceCapabilities.firesTouchEvents) return;

// Always save and restore scroll position to prevent browser scroll from breaking the slider
// Save current scroll positions before browser scrolls
if (swiper.isHorizontal()) {
savedScrollLeft = swiper.params.cssMode ? swiper.el.scrollLeft : swiper.wrapperEl.scrollLeft;
} else {
savedScrollTop = swiper.params.cssMode ? swiper.el.scrollTop : swiper.wrapperEl.scrollTop;
}

// Enable scroll restoration
isRestoringScroll = true;

// Clear any existing timeout
if (scrollRestoreTimeout) {
clearTimeout(scrollRestoreTimeout);
}

// Restore scroll position immediately and multiple times to catch browser scroll
restoreScrollPosition();
requestAnimationFrame(() => {
restoreScrollPosition();
requestAnimationFrame(() => {
restoreScrollPosition();
});
});

// Disable scroll restoration after a short time
scrollRestoreTimeout = setTimeout(() => {
isRestoringScroll = false;
}, 100);

focusTargetSlideEl = slideEl;
const isActive = swiper.slides.indexOf(slideEl) === swiper.activeIndex;
const isVisible =
swiper.params.watchSlidesProgress &&
swiper.visibleSlides &&
swiper.visibleSlides.includes(slideEl);

// Only navigate to slide if it's not already active or visible
if (!isActive && !isVisible) {
// Navigate to the correct slide
requestAnimationFrame(() => {
if (preventFocusHandler) return;
if (swiper.params.loop) {
swiper.slideToLoop(
swiper.getSlideIndexWhenGrid(parseInt(slideEl.getAttribute('data-swiper-slide-index'))),
0,
);
} else {
swiper.slideTo(swiper.getSlideIndexWhenGrid(swiper.slides.indexOf(slideEl)), 0);
}

preventFocusHandler = false;
});
}
};

const handleFocus = (e) => {
if (swiper.a11y.clicked || !swiper.params.a11y.scrollOnFocus) return;
if (new Date().getTime() - visibilityChangedTimestamp < 100) return;
Expand Down Expand Up @@ -351,10 +453,16 @@ export default function A11y({ swiper, extendParams, on }) {
// Tab focus
const document = getDocument();
document.addEventListener('visibilitychange', onVisibilityChange);
swiper.el.addEventListener('focus', handleFocus, true);
// Use focusin to catch focus events before browser scrolls
swiper.el.addEventListener('focusin', handleFocusIn, true);
swiper.el.addEventListener('focus', handleFocus, true);
swiper.el.addEventListener('pointerdown', handlePointerDown, true);
swiper.el.addEventListener('pointerup', handlePointerUp, true);
// Listen for scroll events to restore scroll position when browser scrolls due to focus
swiper.wrapperEl.addEventListener('scroll', handleScroll, { passive: true });
if (swiper.params.cssMode) {
swiper.el.addEventListener('scroll', handleScroll, { passive: true });
}
};
function destroy() {
if (liveRegion) liveRegion.remove();
Expand All @@ -380,10 +488,22 @@ export default function A11y({ swiper, extendParams, on }) {
document.removeEventListener('visibilitychange', onVisibilityChange);
// Tab focus
if (swiper.el && typeof swiper.el !== 'string') {
swiper.el.removeEventListener('focusin', handleFocusIn, true);
swiper.el.removeEventListener('focus', handleFocus, true);
swiper.el.removeEventListener('pointerdown', handlePointerDown, true);
swiper.el.removeEventListener('pointerup', handlePointerUp, true);
}
// Remove scroll listeners
if (swiper.wrapperEl) {
swiper.wrapperEl.removeEventListener('scroll', handleScroll, { passive: true });
}
if (swiper.params.cssMode && swiper.el && typeof swiper.el !== 'string') {
swiper.el.removeEventListener('scroll', handleScroll, { passive: true });
}
// Clear timeout
if (scrollRestoreTimeout) {
clearTimeout(scrollRestoreTimeout);
}
}

on('beforeInit', () => {
Expand Down