Interface de sélection

Dans ma note d'intention, j'avais formulé le souhait de changer la méthode de sélection d'un contenu en proposant à l'utilisateur une interface plus conviviale que la liste de sélection.

Proposition d'interface de sélection

Voici comment j'ai procédé en deux temps : introduction d'une modale pour la sélection et système de favoris.

Modale de sélection

Dans la liste des composants proposés par Limitless (la librairie de CSS utilisée par Tiria), celui aui se rapproche le plus du design que je souhaite implémenter est le Card Deck. Chaque type de contenu sera représentée par une tuile (Card) carrée de 150 pixels de côté avec l'icône au dessus de son nom.

Tuile de contenu

Nouvelle modale

Je commence par ajouter au fichier todd.php une nouvelle modale modal_select_module qui sert d'étape 1 avant la configuration.

<div id="modal_select_module" class="modal fade">
  <div class="modal-dialog modal-lg">
    <div class="modal-content">
      <div class="modal-header bg-primary text-white">
        <h6 class="modal-title">Choisissez un type de contenu</h6>
        <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
      </div>
      <div class="modal-body bg-light">
        <div
          class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 g-3 justify-content-center"
          id="grid_module_select"
        >
          <!-- Loaded via JS -->
        </div>
      </div>
    </div>
  </div>
</div>

Je masque de la liste déroulante native (<select>) dans cet écran modal_edit_content avec un style "display: none".

<div class="form-group" style="display: none;">
  <label>Module</label>
  <select name="module_code" id="edit_module_code" class="form-control" required>
    <!-- Loaded via JS -->
  </select>
</div>

Style

Dans todd.css je déclare une nouvelle classe :

  • Ajout de la classe module-select-card avec aspect-ratio: 1 / 1 (max 150x150px) et des effets de survol (translateY, box-shadow, scale sur l'icône).
  • Désactivation de la sélection de texte (user-select: none) sur les cartes pour éviter que le texte soit surligné lors du clic prolongé.
.module-select-card {
  transition: all 0.2s ease-in-out;
  border: 1px solid rgba(0, 0, 0, 0.125);
  border-radius: 0.25rem;
  aspect-ratio: 1 / 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  max-width: 150px;
  margin: 0 auto;
  user-select: none;
  -webkit-user-select: none;
  cursor: pointer;
  position: relative;
  text-align: center;
  height: 100%;
}

.module-select-card:hover {
  transform: translateY(-5px);
  box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
  border-color: #2196f3;
}

.module-select-card .module-icon {
  width: 48px;
  height: 48px;
  fill: currentColor;
  color: #2196f3;
  margin: 0.5rem 0;
  transition: transform 0.2s ease;
}

.module-select-card:hover .module-icon {
  transform: scale(1.1);
}

.module-select-card .module-title {
  margin: 0;
  font-weight: 600;
  color: #6c757d;
  font-size: 13px;
}

Javascript

Je définis une classe ToddGrid responsable de l'affichage de la grille et de la gestion du dynamisme de la page :

  • clic sur les tuiles ;
  • tooltips ;
  • prise en compte des favoris (voir infra).
/**
 * ToddGrid
 * Handles the display of the module selection grid cards
 * and UI interactions to pick a module in exactly the same way as before.
 */
const ToddGrid = {
  init: function () {
    this.bindEvents();
  },

  bindEvents: function () {
    const self = this;

    // Listen for favorite updates from ToddFavorites component
    $(document).on("todd:favorites:updated", function (e, newFavorites) {
      if (typeof toddModules !== "undefined" && toddModules) {
        self.render(toddModules, newFavorites);
      }
    });

    // Click on a module card from the selection grid
    $(document).on("click", ".module-select-card", function (e) {
      if ($(this).data("was-long-press")) {
        // Prevent normal click if it was a long press
        e.preventDefault();
        return false;
      }

      const tooltipInstance = bootstrap.Tooltip.getInstance(this);
      if (tooltipInstance) tooltipInstance.hide();

      const moduleCode = $(this).data("code");

      // Close selection modal
      $("#modal_select_module").modal("hide");

      // Keep the selection in the hidden input to maintain form logic
      $("#edit_module_code").val(moduleCode).trigger("change");

      // Show change module button (this is creation flow)
      $("#btn_change_module").show();

      // Show standard edit modal (with a small delay for smooth transition)
      setTimeout(function () {
        $("#modal_edit_content").modal("show");
      }, 150);
    });

    // Change selected module (go back to grid)
    $(document).on("click", "#btn_change_module", function () {
      $("#modal_edit_content").modal("hide");
      // Allow a slight delay for smooth transition
      setTimeout(function () {
        $("#modal_select_module").modal("show");
      }, 150);
    });
  },

  /**
   * Renders the modules list as a grid of selectable cards.
   * Sorts favorites first, then alphabetically.
   */
  render: function (modules, favorites) {
    let html = "";
    favorites = favorites || [];

    // Convert to array and sort: Favorites first, then alphabetical by label
    let modulesArray = [];
    $.each(modules, function (k, mod) {
      modulesArray.push(mod);
    });

    modulesArray.sort(function (a, b) {
      const isFavA = favorites.indexOf(a.code) !== -1;
      const isFavB = favorites.indexOf(b.code) !== -1;

      if (isFavA && !isFavB) return -1;
      if (!isFavA && isFavB) return 1;

      const labelA = a.label ? a.label.toLowerCase() : "";
      const labelB = b.label ? b.label.toLowerCase() : "";
      return labelA.localeCompare(labelB);
    });

    $.each(modulesArray, function (i, mod) {
      if (mod.is_active == 1) {
        const isFav = favorites.indexOf(mod.code) !== -1;
        const iconId =
          window.ToddUtils && window.ToddUtils.mapIcon
            ? window.ToddUtils.mapIcon(mod.icon)
            : "ph-cube";

        // Tooltip info
        const tooltipAttr =
          ' data-bs-toggle="tooltip" data-bs-placement="top" title="Maintenir enfoncé pour ' +
          (isFav ? "retirer des" : "ajouter aux") +
          ' favoris"';

        html += '<div class="col">';
        html +=
          '  <div class="card card-body module-select-card" data-code="' +
          mod.code +
          '"' +
          tooltipAttr +
          ">";

        if (isFav) {
          html +=
            '    <div class="module-fav-badge"><svg><use href="#icon-bookmark2"/></svg></div>';
        }

        html += '    <svg class="module-icon"><use href="#' + iconId + '"/></svg>';
        html += '    <div class="module-title">' + mod.label + "</div>";
        html += "  </div>";
        html += "</div>";
      }
    });
    $("#grid_module_select").html(html);

    // Initialize tooltips with a delay
    const tooltipTriggerList = [].slice.call(
      document.querySelectorAll('#grid_module_select [data-bs-toggle="tooltip"]'),
    );
    tooltipTriggerList.map(function (tooltipTriggerEl) {
      return new bootstrap.Tooltip(tooltipTriggerEl, {
        delay: { show: 1000, hide: 100 },
      });
    });
  },
};

$(function () {
  ToddGrid.init();
});

Il reste à présent à inclure cette fonctionnalité dans le fichier tood.inc.php :

$jsToLoad['footer'][] = $pluginurl . 'assets/js/pages/components/grid.js';

Gestion des favoris

Les types de contenus apparaissent classés dans l'ordre alphabétique. Je souqhite ajouter une fonctionnalité qui permette à l'utilisateur de positionner en tête de liste ses contenus favoris.

Réflexion sur l'expérience utilisateur

L'utilisateur doit pouvoir sélectionner un ou plusieurs contenus comme contenus favoris :

  • lorsque l'utilisateur fait un clic prolongé sur une tuile, la tuile se déplace en tête de liste ;
  • il sort une tuile de la liste des favoris par la même manipulation ;
  • les favoris se positionnent ensemble et restent en ordre alphabétique ;
  • l'utilisateur est informé de cette fonctionnalité par un tooltip diffusé après que le pointeur de la souris soit immobile sur une tuile plus de 2s ;
  • les favoris sont persistents quelque soit la machine sur laquelle l'utilisateur interroge l'extension TODD.

Étapes de l'implémentation

Déclaration dans la base de données

Je déclare une clé todd_fav_contents dans la table {DBPFX}custom_fields en ajoutant une requête SQL pour insérer cette clé (si elle n'existe pas déjà) avec attachment_type = 'user' dans le fichier install.php principal (à la racine du plugin TODD) :

$check_cf = "SELECT field_id FROM {DBPFX}custom_fields WHERE field_key = 'todd_fav_contents' AND attachment_type = 'user'";
if (!$db->get1x1($check_cf)) {
    $db->dml("INSERT INTO {DBPFX}custom_fields (attachment_type, field_key, field_label, field_type, field_required, field_active, field_creation_date)
              VALUES ('user', 'todd_fav_contents', 'TODD Favoris', 'text', 0, 1, NOW())");
}

Services

Je modifie les services dans todd.ws.php pour gérer la liste des favoris dans la base. J'ajoute une nouvelle action AJAX toggleFavoriteContent qui :

  • lit les favoris actuels de l'utilisateur ($user->getMeta('todd_fav_contents')).
  • ajoute ou retire le code du module de ce tableau.
  • sauvegarde le nouveau tableau en JSON via $user->setMeta('todd_fav_contents', $nouvelles_donnees).
  • la récupération des modules retournera les favoris actuels de l'utilisateur côté client (via getModules ou une requête additionnelle si nécessaire, ou on intégrera cette info dans le payload).
case 'toggleFavoriteContent':
	if (!isset($user) || !$user->id) {
	    $result['msg'] = "Non autorisé";
	    break;
	}

	$module_code = isset($_REQUEST['module_code']) ? $_REQUEST['module_code'] : '';
	if (!$module_code) {
	    $result['msg'] = "Code module manquant";
	    break;
	}

	$favStr = $user->getMeta('todd_fav_contents');
	$favs = [];
	if ($favStr) {
	    $favs = is_string($favStr) ? json_decode($favStr, true) : $favStr;
	    if (!is_array($favs)) $favs = [];
	}

	// Toggle logic
	$index = array_search($module_code, $favs);
	$action_taken = '';
	if ($index !== false) {
	    unset($favs[$index]);
	    $favs = array_values($favs); // reindex
	    $action_taken = 'removed';
	} else {
	    $favs[] = $module_code;
	    $action_taken = 'added';
	}

	// Save
	if ($user->setMeta('todd_fav_contents', json_encode($favs))) {
	    $result['status'] = 1;
	    $result['action_taken'] = $action_taken;
	    $result['favorites'] = $favs;
	    $result['msg'] = "Favoris mis à jour";
	} else {
	    $result['msg'] = "Erreur lors de la sauvegarde";
	}
	break;

Je modifie aussi todd.ws.php responsable des services de l'extension TODD pour récupérer la liste des favoris.

$favs = [];
if (isset($user) && $user->id) {
    $favStr = $user->getMeta('todd_fav_contents');
    if ($favStr) {
        $favs = is_string($favStr) ? json_decode($favStr, true) : $favStr;
        if (!is_array($favs)) $favs = [];
    }
}
$result['favorites'] = $favs;

Style

Je crée un style spécifique dans todd.css pour ajouter aux tuiles favorites un badge :

Badge favori

  • Ajout du style pour l'icône de favori (en haut à gauche de la carte, couleur accent).
  • Ajustement du style général pour s'assurer que l'interactivité du clic prolongé est fluide (désactiver la sélection de texte user-select: none).
.module-fav-badge {
  position: absolute;
  top: -5px;
  left: 12px;
  background: transparent;
  line-height: 1;
  z-index: 2;
}

.module-fav-badge > svg {
  width: 24px;
  height: 24px;
  color: #ffa726; /* Equivalent to text-warning / bg-orange-400 */
  fill: currentColor;
  display: inline-block;
  vertical-align: middle;
}

Interface dynamique

Mise à jour de la fonction [renderModuleGridSelect] pour :

  1. Croiser les modules avec la liste des favoris de l'utilisateur.
  2. Trier le tableau de modules : d'abord les favoris (par ordre alphabétique), puis les non-favoris (par ordre alphabétique).
  3. Ajouter l'icône ph-bookmark-simple (remplie) sur les modules marqués comme favoris.
  • Implémentation du clic prolongé (long press) :
    • Utilisation des événements mousedown / touchstart pour démarrer un timer (ex: 1000ms).
    • Si le bouton est relâché avant (mouseup, mouseleave), annuler le timer (c'est un clic simple de sélection).
    • Si le timer arrive à terme, déclencher l'action de "marquer/démarquer comme favori" (appel AJAX vers toggleFavoriteContent).
  • Configuration de l'infobulle (Tooltip) Bootstrap :
    • Ajout d'un tooltip sur les cartes expliquant que le clic long met en favori.
    • Définition d'un delay: { "show": 1000, "hide": 100 } pour que le tooltip n'apparaisse qu'après une seconde d'immobilité.

Comme pour la grille, nous mettons tout le métier dans un fichier favorites.js à part : je déclare une classe ToddFavorites qu'il suffira d'initialiser.

/**
 * ToddFavorites
 * Represents the Favorites component for TODD module selection.
 * Handles the long press logic and the AJAX call to toggle a favorite.
 */
const ToddFavorites = {
  init: function () {
    this.bindEvents();
  },

  bindEvents: function () {
    const self = this;
    let pressTimer;
    let isLongPress = false;

    $(document).on("mousedown touchstart", ".module-select-card", function (e) {
      const $card = $(this);
      isLongPress = false;
      $card.removeData("was-long-press");

      // Start long press timer
      pressTimer = window.setTimeout(function () {
        isLongPress = true;
        $card.data("was-long-press", true);

        const moduleCode = $card.data("code");

        // Hide tooltip immediately when action triggers
        const tooltipInstance = bootstrap.Tooltip.getInstance($card[0]);
        if (tooltipInstance) tooltipInstance.hide();

        // Call backend to toggle favorite
        self.toggleFavorite(moduleCode);
      }, 1000); // 1 second long press
    });

    $(document).on("mouseup mouseleave touchend", ".module-select-card", function (e) {
      clearTimeout(pressTimer);
    });

    // Click on a module card from the selection grid is handled by todd.js,
    // but we need to prevent default if it was a long press.
    $(document).on("click", ".module-select-card", function (e) {
      if ($(this).data("was-long-press") || isLongPress) {
        // Prevent normal click if it was a long press
        e.preventDefault();
        e.stopImmediatePropagation();
        return false;
      }
    });
  },

  toggleFavorite: function (moduleCode) {
    $.ajax({
      url: SITE_URL + "plugins/todd/scripts/todd.ws.php",
      type: "POST",
      dataType: "json",
      data: {
        action: "toggleFavoriteContent",
        module_code: moduleCode,
      },
      success: function (res) {
        if (res.status == 1) {
          // Trigger a custom event on the document so other modules (like todd.js)
          // can listen to it and re-render the grid.
          $(document).trigger("todd:favorites:updated", [res.favorites]);
        } else {
          if (typeof App !== "undefined" && App.notyInfo) {
            App.notyInfo(res.msg || "Erreur lors de la mise en favori", "error");
          } else {
            alert(res.msg || "Erreur lors de la mise en favori");
          }
        }
      },
    });
  },
};

// Start logic when document is ready
$(function () {
  ToddFavorites.init();
});

Il reste à présent à inclure cette fonctionnalité dans le fichier tood.inc.php :

$jsToLoad['footer'][] = $pluginurl . 'assets/js/pages/components/favorites.js';

Rendu final

Rendu final