11521 sujets

JavaScript, DOM et API Web HTML5

Bonjour,

Il y a quelques temps j'ai codé des flip cards et les ai intégré à la code base de mon petit CMS. Voici une version de démonstration en statique : https://scriptura.github.io/page/articles.html

Et surtout voici un Code Pen : https://codepen.io/olivier-c/pen/WNWBpMG

Ça fonctionne avec ou sans JavaScript :
- en CSS les flip cards feront le minimum syndical mais sont accessibles au clavier (désactivez JS pour voir),
- avec JS l'expérience est améliorée mais je perds la possibilité d'atteindre le verso des cards avec le clavier.

J'ai tenté tout un tas de trucs plus ou moins complexes... pas très concluants (puisque je suis là !). L'exemple exposé ici est épuré de mes tentatives infructueuses, j'ai rétrogradé à une version moins usine à gaz avant de venir vers vous.

Voilà donc, je suis ouvert à vos suggestions pour rendre accessible ce script au clavier. Merci.
____
Le HTML :
<div class="column-fix gap gap-bottom">
  <article class="flip card" tabindex="0">
    <section>
      <h2 class="h2">
        <a href="/page/article.html">Integer hendrerit</a>
      </h2>
      <p>Vivamus sodales eget eros ut euismod...</p>
    </section>
    <section>
      <h2 class="h2">
        <a href="/page/article.html">Integer hendrerit</a>
      </h2>
      <ul>
        <li>Auteur : Anonyme</li>
        <li>Créé le : 17/05/2024</li>
        <li>Modifié le : 26/06/2024</li>
        <li>Mots clés : Concombres, Carottes, Choux</li>
      </ul>
    </section>
  </article>
</div>

Le CSS :
.flip {
  display: grid;
  perspective: 60em;
  transform-style: preserve-3d; /* @note Utile pour les éléments enfants */
  outline: none;

  & > * {
    grid-area: 1/-1; /* @note Évite un position absolute, les 2 éléments enfants de .flip s'adaptent donc l'un à l'autre, selon le contenu le plus conséquent des 2. */
    backface-visibility: hidden;
    transition-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1.275);
    transition-duration: var(--flip-duration, 3s);
    transition-property: transform, opacity;
    cursor: pointer;
  }

  & > :last-child {
    transform: rotateY(-180deg);
  }

  &.active {
    & > * {
      transition-duration: 1s;
    }

    & > :first-child {
      transform: rotateY(180deg);
    }

    & > :last-child {
      transform: rotateY(0deg);
    }
  }
  @media (scripting: none) {
    &:focus,
    &:active {
      & > * {
        transition-duration: 1s;
      }

      & > :first-child {
        transform: rotateY(180deg);
      }

      & > :last-child {
        transform: rotateY(0deg);
      }
    }
  }
}

Le JS :
'use strict'

const flipList = document.querySelectorAll('.flip')
let autoFlipTimeout, autoUnflipTimeout
let isAutoFlipping = true // Variable de contrôle

/**
 * Script de démonstration permettant à l'utilisateur de comprendre qu'une action 
 * est possible sur la carte. Retourne automatiquement la première carte après n seconde 
 * et la remet à son état initial après n seconde.
 */
const autoFlipFirstCard = () => {
  if (flipList.length > 0) {
    autoFlipTimeout = setTimeout(() => {
      flipList[0].classList.add('active')
      autoUnflipTimeout = setTimeout(() => {
        flipList[0].classList.remove('active')
        isAutoFlipping = false // Processus automatique terminé
      }, 1500)
    }, 1000)
  }
}

/**
 * Gère l'événement de vue de page en mettant à jour le compteur de vues dans 
 * le stockage local et en retournant automatiquement la première carte si le 
 * compteur de vues est inférieur à n.
 *
 * @note Le compteur de vues est unique pour toutes les pages.
 */
const handlePageView = () => {
  const pageViewed = 'demoFlipCard' // @note Un unique compteur pour toutes les pages.
   
  let views = parseInt(localStorage.getItem(pageViewed)) || 0
  views++
  localStorage.setItem(pageViewed, views)

  if (views < 4) {
    autoFlipFirstCard()
  }
}

handlePageView()

/**
 * Ajoute un gestionnaire d'événements à chaque élément avec la classe 'flip'.
 * Lorsqu'un élément est cliqué, il vérifie s'il possède déjà la classe 'active'.
 * Si c'est le cas, il la supprime, sinon il l'ajoute.
 * Si un autre élément a déjà la classe 'active', elle lui est retirée après 3 secondes.
 * @param {NodeList} flipList Liste des éléments auxquels ajouter des gestionnaires d'événements.
 */
flipList.forEach(flip => {
  flip.addEventListener('click', () => {
    // Annuler le processus automatique si l'utilisateur clique sur la première carte flip
    if (flip === flipList[0] && isAutoFlipping) {
      clearTimeout(autoFlipTimeout)
      clearTimeout(autoUnflipTimeout)
      isAutoFlipping = false
    }

    if (flip.classList.contains('active')) {
      flip.classList.remove('active')
    } else {
      document.querySelectorAll('.flip').forEach(element => {
        if (element !== flip && element.classList.contains('active')) {
          setTimeout(() => {
            element.classList.remove('active')
          }, 3000)
        }
      })
      flip.classList.add('active')
    }
  })

  // Enlever la classe .active lorsqu'un élément prend le focus
  flip.addEventListener('focus', () => {
    flip.classList.remove('active')
  })
})

Modifié par Olivier C (30 Jun 2024 - 23:18)
Alors... j'ai trouvé une solution qui consiste à désactiver le script lorsqu'une touche de contrôle est enfoncée :
// Détecte les interactions au clavier pour désactiver le flip automatique et les classes 'active'
const handleKeyboardNavigation = (event) => {
  // Liste des touches de navigation clavier
  const navigationKeys = ['Tab'];

  if (navigationKeys.includes(event.key)) {
    clearTimeout(autoFlipTimeout);
    clearTimeout(autoUnflipTimeout);
    isAutoFlipping = false;

    // Retirer toutes les classes 'active' des cartes
    flipList.forEach(flip => flip.classList.remove('active'));
  }
};

document.addEventListener('keydown', handleKeyboardNavigation);

Mais après il faut surtout reproduire le comportement du CSS via JS, ça marche mais bof bof bof pour ce dernier point :
flipList.forEach(flip => {
  flip.addEventListener('click', () => {

  // [ici le code vu précédemment]

  // Gérer les événements focus et blur pour appliquer les transformations CSS
  flip.addEventListener('focus', () => applyKeyboardStyles(flip), true);
  flip.addEventListener('blur', () => removeKeyboardStyles(flip), true);
  flip.addEventListener('keydown', (event) => {
    if (event.key === ' ') {
      event.preventDefault(); // Empêcher le comportement par défaut de la touche
      applyKeyboardStyles(flip);
    }
  });
});

/**
 * Applique les styles de transformation pour la navigation au clavier.
 * @param {HTMLElement} flip L'élément flip auquel appliquer les styles.
 */
const applyKeyboardStyles = (flip) => {
  clearTimeout(autoFlipTimeout);
  clearTimeout(autoUnflipTimeout);
  isAutoFlipping = false;

  flip.children[0].style.transitionDuration = '1s';
  flip.children[0].style.transform = 'rotateY(180deg)';
  flip.children[flip.children.length - 1].style.transitionDuration = '1s';
  flip.children[flip.children.length - 1].style.transform = 'rotateY(0deg)';
};

/**
 * Supprime les styles de transformation appliqués pour la navigation au clavier.
 * @param {HTMLElement} flip L'élément flip auquel supprimer les styles.
 */
const removeKeyboardStyles = (flip) => {
  flip.children[0].style.transitionDuration = '';
  flip.children[0].style.transform = '';
  flip.children[flip.children.length - 1].style.transitionDuration = '';
  flip.children[flip.children.length - 1].style.transform = '';
};

Si quelqu'un avait une solution pour zapper de manière élégante ce dernier point je suis preneur.
Modifié par Olivier C (30 Jun 2024 - 23:19)
Je vais tenter une modification de mon CSS avec des variables que je récupérerai en JS. Comme ça, si modification du CSS le JS le prendra en compte :
:root {
  --flip-duration: 3s;
  --transition-duration: 1s;
  --rotate-first-child: rotateY(180deg);
  --rotate-last-child: rotateY(0deg);
}

const transitionDuration = getComputedStyle(flip).getPropertyValue('--transition-duration');
const rotateFirstChild = getComputedStyle(flip).getPropertyValue('--rotate-first-child');
const rotateLastChild = getComputedStyle(flip).getPropertyValue('--rotate-last-child');

C'est un compromis acceptable j'imagine...

En attendant, si vous avez une approche plus élégante/optimisée je suis preneur...
Modifié par Olivier C (30 Jun 2024 - 11:07)
Argh ! ça me rend fou : quand les valeurs sont codées en dur ça marche, lorsque je les appelle en getComputedStyle ça marche aussi... sauf pour le raccourci clavier. Et c'est la seule chose que je change ! Je n'y comprends rien :
const applyKeyboardStyles = (flip) => {
  const transitionDuration = getComputedStyle(flip).getPropertyValue('--flip-transition-duration');
  const rotateFirstChild = getComputedStyle(flip).getPropertyValue('--flip-rotate-first-child');
  const rotateLastChild = getComputedStyle(flip).getPropertyValue('--flip-rotate-last-child');
  
  clearTimeout(autoFlipTimeout);
  clearTimeout(autoUnflipTimeout);
  isAutoFlipping = false;
  /* Le code qui ne fonctionne pas totalement (perte de la capacité raccourci clavier): */
  flip.children[0].style.transitionDuration = transitionDuration;
  flip.children[0].style.transform = rotateFirstChild;
  flip.children[flip.children.length - 1].style.transitionDuration = transitionDuration;
  flip.children[flip.children.length - 1].style.transform = rotateLastChild;
  /* Le code qui fonctionne avec des valeurs en dur :
  flip.children[0].style.transitionDuration = '1s';
  flip.children[0].style.transform = 'rotateY(180deg)';
  flip.children[flip.children.length - 1].style.transitionDuration = '1s';
  flip.children[flip.children.length - 1].style.transform = 'rotateY(0deg)';
  */
};

Les styles à jour :
.flip {
  --flip-duration: 3s;
  --flip-transition-duration: 1s;
  --flip-rotate-first-child: rotateY(180deg);
  --flip-rotate-last-child: rotateY(0deg);
  display: grid;
  perspective: 60em;
  transform-style: preserve-3d; /* Utile pour les éléments enfants */
  outline: none;

  & > * {
    grid-area: 1/-1; /* Évite un position absolute, les 2 éléments enfants de .flip s'adaptent donc l'un à l'autre, selon le contenu le plus conséquent des 2. */
    backface-visibility: hidden;
    transition-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1.275);
    transition-duration: var(--flip-duration);
    transition-property: transform, opacity;
    cursor: pointer;
  }

  & > :last-child {
    transform: rotateY(-180deg);
  }

  &.active {
    & > * {
      transition-duration: var(--flip-transition-duration);
    }

    & > :first-child {
      transform: var(--flip-rotate-first-child);
    }

    & > :last-child {
      transform: var(--flip-rotate-last-child);
    }
  }

  @media (scripting: none) {
    &:focus,
    &:active {
      & > * {
        transition-duration: var(--flip-transition-duration);
      }

      & > :first-child {
        transform: var(--flip-rotate-first-child);
      }

      & > :last-child {
        transform: var(--flip-rotate-last-child);
      }
    }
  }
}

C'est peut-être le fait de coder sur un Pen qui pose problème car getComputedStyle(flip) renvoie bien des valeurs CSS mais pas les variables recherchées...
  const styles = getComputedStyle(flip);
  
  console.log(styles); // OK pour l'ensemble des règles CSS
  console.log(styles.getPropertyValue('--flip-transition-duration')); // nop...
  
  const transitionDuration = styles.getPropertyValue('--flip-transition-duration');
  const rotateFirstChild = styles.getPropertyValue('--flip-rotate-first-child');
  const rotateLastChild = styles.getPropertyValue('--flip-rotate-last-child');

Modifié par Olivier C (30 Jun 2024 - 23:19)
Argh !

Smiley biggol Smiley biggol Smiley biggol Smiley biggol Smiley biggol Smiley biggol
Smiley biggol Smiley biggol Smiley biggol Smiley biggol Smiley biggol Smiley biggol
Smiley biggol Smiley biggol Smiley biggol Smiley biggol Smiley biggol Smiley biggol
Smiley biggol Smiley biggol Smiley biggol Smiley biggol Smiley biggol Smiley biggol

C'était la configuration du Pen ! ça marchait depuis le début ! Smiley mur

{CodePen} Smiley rocket
Modifié par Olivier C (30 Jun 2024 - 21:37)
Modérateur
Sinon, il me semble qu'il suffit de faire fit de @media (scripting: none) { /* flip les cards */} et de laisser cette portion de CSS sans filtrage et en les jumelant au selecteur .active , non?
Je n'ai pas tester en profondeur et basé sur le premier codepen en lien.

cdt
Meilleure solution
Bonsoir Gcyrillus,


Merci pour ton intervention. J'avais tenté cette voie mais j'avais eu des problèmes de scope. Mais du coup ta remarque ça m'a obligé à retourner voir mes styles - au cas où - et de là je me suis reposé la question de la pertinence de mes attributs tabindex dans mon HTML : de leurs utilisations provenait le mal ! Ils sont intéressants pour la navigation sans JavaScript (notion de persistance contrairement à :focus-within) mais parasitaient l'expérience utilisateur en CSS only. Il fallait donc les désactiver si JS :
flip.removeAttribute('tabindex')

Une fois le bug enfin trouvé mon CSS a coulé de source, avec l'utilisation de :focus-within on en vient à réduire la totalité du CSS à ceci :
.flip {
  display: grid;
  perspective: 60em;
  transform-style: preserve-3d; /* Utile pour les éléments enfants */
  outline: none;

  & > * {
    grid-area: 1/-1; /* Évite un position absolute, les 2 éléments enfants de .flip s'adaptent donc l'un à l'autre, selon le contenu le plus conséquent des 2. */
    backface-visibility: hidden;
    transition-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1.275);
    transition-duration: var(--flip-transition-duration, 3s);
    transition-property: transform, opacity;
    cursor: pointer;
  }

  & > :last-child {
    transform: rotateY(-180deg);
  }

  &:focus-within,
  &:active,
  &.active {
    & > * {
      --flip-transition-duration: 1s;
    }

    & > :first-child {
      transform: rotateY(180deg);
    }

    & > :last-child {
      transform: rotateY(0deg);
    }
  }
}

Avec un dernier petit ajustement côté JS :
flip.addEventListener('focusin', () => {
  flip.classList.remove('active')
})

Et maintenant tout est OK !
Merci infiniment.
Modifié par Olivier C (01 Jul 2024 - 11:46)