diff --git a/components/parts/common/swiper-controls.php b/components/parts/common/swiper-controls.php
new file mode 100644
index 00000000..aa4fa4f0
--- /dev/null
+++ b/components/parts/common/swiper-controls.php
@@ -0,0 +1,17 @@
+
+
+
diff --git a/inc/Services/Assets.php b/inc/Services/Assets.php
index 474af5bb..f764a0d3 100644
--- a/inc/Services/Assets.php
+++ b/inc/Services/Assets.php
@@ -64,7 +64,7 @@ public function register_assets(): void {
$this->assets_tools->register_script(
'scripts',
'dist/' . $file,
- array_merge( [ 'jquery' ], $asset_data['dependencies'] ), // ensure jQuery dependency is set even if not declared explicitly in the JS
+ array_merge( [ 'jquery', 'wp-i18n' ], $asset_data['dependencies'] ), // ensure jQuery dependency is set even if not declared explicitly in the JS
$asset_data['version'],
[ 'strategy' => 'defer' ]
);
@@ -88,6 +88,12 @@ public function register_assets(): void {
public function enqueue_scripts(): void {
// JavaScript
$this->assets_tools->enqueue_script( 'scripts' );
+
+ wp_set_script_translations(
+ 'scripts',
+ 'beapi-frontend-framework',
+ \get_theme_file_path( 'languages' )
+ );
}
/**
diff --git a/languages/beapi-frontend-framework-fr_FR-a442c66c97675e8bda3e3b62b3a8c8ec.json b/languages/beapi-frontend-framework-fr_FR-a442c66c97675e8bda3e3b62b3a8c8ec.json
new file mode 100644
index 00000000..c2b80112
--- /dev/null
+++ b/languages/beapi-frontend-framework-fr_FR-a442c66c97675e8bda3e3b62b3a8c8ec.json
@@ -0,0 +1 @@
+{"translation-revision-date":"2026-05-07 16:41+0200","generator":"WP-CLI\/2.12.0","source":"dist\/app.js","domain":"messages","locale_data":{"messages":{"":{"domain":"messages","lang":"fr","plural-forms":"nplurals=2; plural=(n > 1);"},"Previous slide":["Diapositive pr\u00e9c\u00e9dente"],"Next slide":["Diapositive suivante"],"This is the first slide":["C'est la premi\u00e8re diapositive"],"This is the last slide":["C'est la derni\u00e8re diapositive"],"Go to slide {{index}}":["Aller \u00e0 la diapositive {{index}}"],"{{index}} of {{slidesLength}}":["{{index}} sur {{slidesLength}}"]}}}
\ No newline at end of file
diff --git a/languages/beapi-frontend-framework.pot b/languages/beapi-frontend-framework.pot
index 6d9bc44c..3f2a99c9 100644
--- a/languages/beapi-frontend-framework.pot
+++ b/languages/beapi-frontend-framework.pot
@@ -252,3 +252,29 @@ msgstr ""
msgctxt "Space size name"
msgid "lg (64px)"
msgstr ""
+
+#: components/parts/common/swiper-controls.php:14
+#: dist/app.js
+msgid "Previous slide"
+msgstr ""
+
+#: components/parts/common/swiper-controls.php:15
+#: dist/app.js
+msgid "Next slide"
+msgstr ""
+
+#: dist/app.js
+msgid "This is the first slide"
+msgstr ""
+
+#: dist/app.js
+msgid "This is the last slide"
+msgstr ""
+
+#: dist/app.js
+msgid "Go to slide {{index}}"
+msgstr ""
+
+#: dist/app.js
+msgid "{{index}} of {{slidesLength}}"
+msgstr ""
diff --git a/languages/fr_FR.mo b/languages/fr_FR.mo
index c62c0ed6..da90e99c 100644
Binary files a/languages/fr_FR.mo and b/languages/fr_FR.mo differ
diff --git a/languages/fr_FR.po b/languages/fr_FR.po
index 4f5562c0..18ace95a 100644
--- a/languages/fr_FR.po
+++ b/languages/fr_FR.po
@@ -296,3 +296,29 @@ msgstr ""
#~ msgid "Toggle menu"
#~ msgstr "Ouvrir/Fermer le menu"
+
+#: components/parts/common/swiper-controls.php:14
+#: dist/app.js
+msgid "Previous slide"
+msgstr "Diapositive précédente"
+
+#: components/parts/common/swiper-controls.php:15
+#: dist/app.js
+msgid "Next slide"
+msgstr "Diapositive suivante"
+
+#: dist/app.js
+msgid "This is the first slide"
+msgstr "C'est la première diapositive"
+
+#: dist/app.js
+msgid "This is the last slide"
+msgstr "C'est la dernière diapositive"
+
+#: dist/app.js
+msgid "Go to slide {{index}}"
+msgstr "Aller à la diapositive {{index}}"
+
+#: dist/app.js
+msgid "{{index}} of {{slidesLength}}"
+msgstr "{{index}} sur {{slidesLength}}"
diff --git a/package-lock.json b/package-lock.json
index 6edc34d7..482636c2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,8 @@
"@fontsource-utils/scss": "^0.2.4",
"@fontsource/poppins": "^5.3.0",
"lazysizes": "^5.3.2",
- "oneloop.js": "^5.3.1"
+ "oneloop.js": "^5.3.1",
+ "swiper": "^14.3.0"
},
"devDependencies": {
"@wordpress/blocks": "^11.21.0",
@@ -20,6 +21,7 @@
"@wordpress/dependency-extraction-webpack-plugin": "^6.53.0",
"@wordpress/dom-ready": "^3.58.0",
"@wordpress/hooks": "^3.58.0",
+ "@wordpress/i18n": "^4.58.0",
"@wordpress/stylelint-config": "^24.2.0",
"clean-webpack-plugin": "^4.0.0",
"concurrently": "^9.2.1",
@@ -15286,6 +15288,25 @@
"dev": true,
"license": "CC0-1.0"
},
+ "node_modules/swiper": {
+ "version": "14.3.0",
+ "resolved": "https://registry.npmjs.org/swiper/-/swiper-14.3.0.tgz",
+ "integrity": "sha512-qw0Xv1+sylM8KCQxWxV2iYAK2G6XjFGPZ/8F1baEsY8DK4A3sTnLx6jUJ0G71rDbVQ05JHMWkcWmJ5NvlxZO0w==",
+ "funding": [
+ {
+ "type": "custom",
+ "url": "https://sponsors.nolimits4web.com"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nolimits4web"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.7.0"
+ }
+ },
"node_modules/table": {
"version": "6.9.0",
"dev": true,
diff --git a/package.json b/package.json
index 3f1255d9..40b231da 100644
--- a/package.json
+++ b/package.json
@@ -19,13 +19,15 @@
"@fontsource-utils/scss": "^0.2.4",
"@fontsource/poppins": "^5.3.0",
"lazysizes": "^5.3.2",
- "oneloop.js": "^5.3.1"
+ "oneloop.js": "^5.3.1",
+ "swiper": "^14.3.0"
},
"devDependencies": {
"@wordpress/blocks": "^11.21.0",
"@wordpress/browserslist-config": "^6.53.0",
"@wordpress/dependency-extraction-webpack-plugin": "^6.53.0",
"@wordpress/dom-ready": "^3.58.0",
+ "@wordpress/i18n": "^4.58.0",
"@wordpress/hooks": "^3.58.0",
"@wordpress/stylelint-config": "^24.2.0",
"clean-webpack-plugin": "^4.0.0",
diff --git a/src/js/classes/Slider.js b/src/js/classes/Slider.js
new file mode 100644
index 00000000..67d3a5e8
--- /dev/null
+++ b/src/js/classes/Slider.js
@@ -0,0 +1,196 @@
+import AbstractDomElement from './AbstractDomElement'
+import Swiper from 'swiper'
+import { A11y, Navigation } from 'swiper/modules'
+import { __ } from '@wordpress/i18n'
+import noop from '../utils/noop'
+
+/**
+ * Thin wrapper around Swiper with project defaults (A11y + Navigation).
+ *
+ * Default modules are always kept; extra modules in `options.modules` are appended
+ * without duplicates.
+ *
+ * - A11y messages are translated via `@wordpress/i18n`; override per instance via `options.a11y` (`prevSlideMessage`, `nextSlideMessage`, etc.).
+ * - If slides are ``, `slideRole` is cleared so Swiper does not set `role="group"` and break list semantics for screen readers (overridable via `options.a11y`).
+ * - When `hideNavigationFromA11y` is true, prev/next are removed from the accessibility tree and `.swiper-notification` is removed (nav announcements are unused).
+ * - When `hideNavigationFromA11y` is false, `.swiper-notification` is kept so Swiper can announce first/prev/next/last messages on keyboard activation of the nav controls.
+ * - `wrapperLiveRegion` is left to Swiper’s default (`true`): `aria-live="polite"` without autoplay, `aria-live="off"` with autoplay (overridable via `options.a11y`).
+ *
+ * Markup (minimal):
+ * ```html
+ *
+ * ```
+ *
+ * @example
+ * import Slider from './classes/Slider'
+ * import { Pagination } from 'swiper/modules'
+ *
+ * Slider.init('.my-slider', {
+ * hideNavigationFromA11y: true,
+ * fixLastSlideActiveOnEnd: true,
+ * onChange() {},
+ * options: {
+ * slidesPerView: 'auto',
+ * modules: [Pagination],
+ * navigation: {
+ * prevEl: '.swiper-button-prev',
+ * nextEl: '.swiper-button-next',
+ * },
+ * a11y: {
+ * // Optional: override translated defaults per slider
+ * nextSlideMessage: 'Next news item',
+ * },
+ * },
+ * })
+ *
+ * const slider = new Slider(el, { … })
+ * slider.getInstance() // Swiper
+ *
+ * @extends AbstractDomElement
+ */
+export default class Slider extends AbstractDomElement {
+ /**
+ * @param {HTMLElement} element - Root `.swiper` element
+ * @param {SliderOptions} [options]
+ */
+ constructor(element, options) {
+ const instance = super(element, options)
+
+ // avoid double init :
+ if (!instance.isNewInstance()) {
+ return instance
+ }
+
+ this.init()
+ }
+
+ init() {
+ const el = this._element
+ const { options, onChange, hideNavigationFromA11y, fixLastSlideActiveOnEnd } = this._settings
+
+ // Merge default modules with user modules, avoiding duplicates.
+ // AbstractDomElement.extend() replaces arrays instead of concatenating:
+ // - If user passes no modules: extend copies defaults → old merge duplicated them
+ // - If user passes modules: extend replaces defaults → we lose A11y/Navigation
+ // This merge ensures defaults are always present + user modules added without duplicates.
+ const defaultModules = Slider.defaults.options.modules
+ const userModules = options.modules || []
+ const allModules = [...defaultModules, ...userModules.filter((mod) => !defaultModules.includes(mod))]
+
+ // role="group" on breaks list semantics for screen readers.
+ const slidesAreListItems = el.querySelector('.swiper-slide')?.tagName === 'LI'
+
+ // Temesis feedback: hide navigation buttons from accessibility tree
+ // since slides are already keyboard accessible (e.g. cards with focusable content).
+ const hideNavigationButtons = (swiper) => {
+ if (!hideNavigationFromA11y) {
+ return
+ }
+
+ swiper.navigation.prevEl?.setAttribute('tabindex', '-1')
+ swiper.navigation.prevEl?.setAttribute('aria-hidden', 'true')
+ swiper.navigation.nextEl?.setAttribute('tabindex', '-1')
+ swiper.navigation.nextEl?.setAttribute('aria-hidden', 'true')
+ }
+
+ // Swiper usually sets .swiper-slide-active correctly for integer or half
+ // slidesPerView (e.g. 2, 2.5). With 'auto' or other fractions (e.g. 2.75),
+ // the last slide may not get that class when reaching the end.
+ // Keeping activeIndex in sync also keeps keyboard focus consistent: focusing
+ // content in the last slide scrolls it fully into view instead of leaving it cropped.
+ const applyLastSlideActiveFix = (swiper) => {
+ // When all slides fit, Swiper keeps isBeginning and isEnd true while activeIndex stays 0.
+ if (!swiper.isEnd || swiper.isBeginning) {
+ return
+ }
+
+ // Force the index on the last slide
+ swiper.activeIndex = swiper.slides.length - 1
+ // Ask Swiper to update the classes (.swiper-slide-active)
+ swiper.updateSlidesClasses()
+ }
+
+ const mergedOptions = {
+ ...options,
+ modules: allModules,
+ a11y: {
+ ...(slidesAreListItems ? { slideRole: '' } : {}),
+ ...options.a11y,
+ },
+ on: {
+ ...options.on,
+ afterInit: (swiper) => {
+ hideNavigationButtons(swiper)
+
+ // Live region announces nav messages on keyboard prev/next; keep it when
+ // navigation is exposed to AT, remove it when nav is intentionally hidden.
+ if (hideNavigationFromA11y) {
+ swiper.el.querySelector('.swiper-notification')?.remove()
+ }
+
+ options.on?.afterInit?.(swiper)
+ },
+ // Swiper resets tabindex on slide change, so we need to hide buttons again
+ transitionEnd: (swiper) => {
+ hideNavigationButtons(swiper)
+ options.on?.transitionEnd?.(swiper)
+ },
+ progress: function (swiper, progress) {
+ if (fixLastSlideActiveOnEnd) {
+ applyLastSlideActiveFix(swiper)
+ }
+
+ options.on?.progress?.call(this, swiper, progress)
+ },
+ slideChange: function (swiper) {
+ if (fixLastSlideActiveOnEnd) {
+ applyLastSlideActiveFix(swiper)
+ }
+
+ options.on?.slideChange?.call(this, swiper)
+ },
+ },
+ }
+
+ this.swiper = new Swiper(el, mergedOptions)
+
+ this.swiper.on('slideChange', onChange.bind(this))
+ }
+
+ /**
+ * @returns {import('swiper').Swiper}
+ */
+ getInstance() {
+ return this.swiper
+ }
+}
+
+/**
+ * @typedef {Object} SliderOptions
+ * @property {Function} [onChange] - Called on slideChange (`this` = Slider instance)
+ * @property {boolean} [hideNavigationFromA11y=false] - Hide prev/next from AT when slides are already focusable; also removes `.swiper-notification`
+ * @property {boolean} [fixLastSlideActiveOnEnd=false] - Fix `.swiper-slide-active` at the end with `slidesPerView: 'auto'` or fractional values other than halves (e.g. 2.75)
+ * @property {import('swiper').SwiperOptions} [options] - Native Swiper options (merged with defaults)
+ */
+
+Slider.defaults = {
+ onChange: noop,
+ hideNavigationFromA11y: false,
+ fixLastSlideActiveOnEnd: false,
+ options: {
+ modules: [A11y, Navigation],
+ a11y: {
+ prevSlideMessage: __('Previous slide', 'beapi-frontend-framework'),
+ nextSlideMessage: __('Next slide', 'beapi-frontend-framework'),
+ firstSlideMessage: __('This is the first slide', 'beapi-frontend-framework'),
+ lastSlideMessage: __('This is the last slide', 'beapi-frontend-framework'),
+ paginationBulletMessage: __('Go to slide {{index}}', 'beapi-frontend-framework'),
+ slideLabelMessage: __('{{index}} of {{slidesLength}}', 'beapi-frontend-framework'),
+ },
+ },
+}
diff --git a/src/scss/10-vendor/_swiper.scss b/src/scss/10-vendor/_swiper.scss
new file mode 100644
index 00000000..e43f51d9
--- /dev/null
+++ b/src/scss/10-vendor/_swiper.scss
@@ -0,0 +1,57 @@
+@use "../01-abstract/variables" as *;
+@use "../01-abstract/theme-json" as *;
+@use "~swiper/css";
+@use "~swiper/css/a11y";
+
+.swiper {
+ --swiper-theme-color: #{$color-primary};
+
+ &__controls {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &:has(.swiper-button-lock) {
+ display: none;
+ }
+ }
+
+ &__buttons {
+ display: flex;
+ gap: $settings-spacing-xs;
+
+ button,
+ [role="button"] {
+ &.swiper-button-prev {
+ transform: scaleX(-1);
+ }
+
+ &.swiper-button-disabled {
+ cursor: not-allowed;
+ opacity: .3;
+ }
+ }
+ }
+
+ &__button {
+ width: 44px;
+ height: 44px;
+ border-radius: 100%;
+ background-color: var(--swiper-theme-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ }
+
+ &-overflow-visible {
+ overflow: visible;
+ }
+
+ .swiper-wrapper {
+ box-sizing: border-box;
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ }
+}
diff --git a/src/scss/editor.scss b/src/scss/editor.scss
index 7c6fad9d..891581d9 100644
--- a/src/scss/editor.scss
+++ b/src/scss/editor.scss
@@ -84,4 +84,4 @@ variables.$entry-file-name: "editor";
* == Plugins or libraries custom styles.
*/
-// @use "10-vendor/...";
+@use "10-vendor/swiper";
diff --git a/src/scss/style.scss b/src/scss/style.scss
index 486bce0a..a5f76002 100644
--- a/src/scss/style.scss
+++ b/src/scss/style.scss
@@ -108,4 +108,4 @@ variables.$entry-file-name: "style";
* == Plugins or libraries custom styles.
*/
-// @use "10-vendor/...";
+@use "10-vendor/swiper";