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";