Composant de révélation de balises dans les listes de sujets - Développer/réduire les balises dans les listes de sujets

Note : Avant de publier ceci dans les composants de thème, je voulais d’abord obtenir des commentaires pour savoir si ce composant de thème est qualifié ou s’il y a des problèmes majeurs avec lui.

:warning: Divulgation : Ce composant de thème a été planifié, implémenté et testé avec l’aide d’outils de codage IA.

J’adorerais avoir vos commentaires !


:information_source: Résumé Révélation des étiquettes
:eyeglasses: Aperçu Non disponible…
:hammer_and_wrench: Dépôt GitHub - jrgong420/discourse-tag-reveal
:question: Guide d’installation Comment installer un thème ou un composant de thème
:open_book: Nouveau aux thèmes Discourse ? Guide pour débutants sur l’utilisation des thèmes Discourse

Discourse Tag Reveal est un composant de thème léger qui maintient les listes de sujets bien rangées en n’affichant que les N premières étiquettes par sujet et en remplaçant les autres par un bouton bascule accessible « +X étiquettes supplémentaires ». Les utilisateurs peuvent développer pour voir toutes les étiquettes et réduire pour revenir à la vue abrégée. Il fonctionne directement avec l’interface utilisateur d’étiquettes standard de Discourse et ne nécessite aucune modification côté serveur.

Fonctionnalités

  • Limite d’étiquettes configurable (par défaut : 5) via les paramètres du thème

  • Bouton bascule stylisé comme une étiquette, accessible au clavier (Entrée/Espace) avec des attributs ARIA

  • Chaînes localisées à l’aide de themePrefix et discourse-i18n

  • Comportement sûr pour SPA : réinitialise et réapplique la logique lors des changements de page

  • Prend en charge le défilement infini via MutationObserver

  • CSS minimal ; respecte les styles d’étiquettes de base

  • Aucune substitution de modèle ni dépendance de plugin

Captures d’écran / Démo

…bientôt disponible

Installation et configuration

  • Testé avec la version Discourse : 3.6.0beta1

  • Configurez les paramètres dans l’onglet Paramètres du composant :

  • max_tags_visible (entier, par défaut 5) : Nombre d’étiquettes à afficher avant de réduire

  • toggle_tag_style : Style visuel du bouton bascule pour correspondre à l’apparence de l’étiquette (actuellement, seul le style « boîte » est implémenté)

  • Portée : affecte les listes de sujets (listes de sujets les plus récents, nouveaux, non lus et de catégories)

Compatibilité avec d’autres composants de thème

:warning: Seuls des tests minimaux ont été effectués, veuillez tester vous-même avant de déployer en production

Notes

  • Assurez-vous que l’étiquetage est activé (Admin → Paramètres → Étiquettes), sinon vous ne verrez aucun effet

  • Si votre site personnalise fortement le CSS des étiquettes, vous voudrez peut-être ajuster les styles .ts-toggle pour un alignement visuel parfait

Idées pour l’avenir

Je ne prévois pas vraiment d’implémenter plus de fonctionnalités, mais je suis heureux d’accepter des PR. Quelques idées pour l’avenir :

  • Activer/désactiver pour les étiquettes dans la vue du sujet

  • Contrôle granulaire pour des pages et/ou des catégories spécifiques

2 « J'aime »

Avez-vous délibérément nommé le paramètre de la même manière qu’un paramètre du cœur ? Je serais préoccupé par les malentendus.

1 « J'aime »

bonne remarque ! Je viens de le modifier…

2 « J'aime »

Ça a l’air vraiment intéressant. Je vais essayer sur mon environnement de développement plus tard car ça ne semble pas fonctionner sur Theme Creator (à moins que je ne fasse quelque chose de mal ?) :thinking:.

Ça semble intéressant ! Pourriez-vous partager quelques captures d’écran ou enregistrements d’écran de la fonctionnalité en action ?

:smiley: Ajout d’une courte démo vidéo dans le premier post, voir ici :

Je n’ai même pas encore vérifié comment soumettre/ajouter mon composant TC là-bas… :smiley:
Mais quoi qu’il en soit, je préfère recueillir quelques commentaires ici d’abord, et une fois qu’il sera prêt à être publié dans Theme component, je verrai comment l’ajouter là-bas.

3 « J'aime »

Theme creator n’utilise pas le style de boîte

Vous voudrez peut-être utiliser

more_tags:
  one: "+%{count} autre balise"
  other: "+%{count} autres balises"
1 « J'aime »

Bon point. J’ai oublié de changer l’étiquette par défaut en +%{count} de plus pour qu’elle soit courte et concise, c’est ainsi que nous l’utilisons et que nous gardons les choses compactes et épurées.

1 « J'aime »

Salut,

Cette fonctionnalité pourrait être intéressante dans certaines situations !

À première vue, il y a quelques points à noter :

  • Les paramètres du thème et les paramètres du site ne sont pas les mêmes. Vous devez d’abord récupérer le service pour accéder à max_tags_per_topic, par exemple : const siteSettings = api.container.lookup(\"service:site-settings\");

  • Les vérifications supplémentaires pour obtenir la limite ne devraient pas être nécessaires ; vous pouvez récupérer la valeur directement. Vous pouvez probablement faire Math.min(settings.max_tags_visible, siteSettings.max_tags_per_topic )

  • Vous ne restaurez pas la visibilité des séparateurs.

  • Vous voudrez peut-être désenregistrer les événements.

  • Le processus au chargement initial ne devrait pas être nécessaire avec MutationObserver. Habituellement, avant de passer à l’échelle mondiale, vous voudriez d’abord vérifier s’il existe un moyen de réduire la portée autour de l’élément en utilisant l’API (une sortie de plugin, par exemple).

Je vais vérifier s’il existe une autre façon !

1 « J'aime »

Puisqu’il se trouve dans le fichier api-initializers, est-ce que @service siteSettings fonctionnerait également ?

Pouvez-vous vérifier maintenant ? Le dernier commit devrait avoir corrigé les points abordés.

La version minimale de Discourse 3.6.0 signifie qu’il faudra beaucoup de temps avant que quiconque puisse l’utiliser. Vouliez-vous dire 3.5.0 ou 3.6.0beta1 ?

Je voulais dire 3.6.0beta1, c’est la version avec laquelle je l’ai testé…

Vous utilisez ceci dans une classe. Cela ne fonctionnera pas autrement.

Vous voulez alors écrire 3.6.0.beta1, sinon personne ne pourra l’installer pour le moment.

J’ai vérifié un peu. En effet, il n’y a pas de moyen simple d’y parvenir ; cependant, j’ai trouvé une méthode intéressante et simplifiée pour le faire en utilisant l’API.

  • Il utilise le modèle de sujet pour modifier les balises visibles qui seront générées avant que le modèle ne soit généré. Cela signifie aucune manipulation du DOM et aucune dépendance aux paramètres. Selon l’état (revealTags), il renverra la liste d’origine ou une liste partielle.

  • Pour créer le bouton basculant, il utilise l’API pour ajouter une balise avec le HTML d’un bouton (malheureusement, il n’y a pas de sortie de plugin ici). L’événement de clic est géré séparément. Au clic, l’état du basculement est mis à jour (revealTags), et nous déclenchons un nouveau rendu de la liste des balises.

Le grand avantage de cette méthode est que vous n’avez pas à toucher au HTML et à déterminer quoi afficher/masquer avec CSS, en fonction des différents styles.

chrome_lSKqwYt5Z7

Je partage mon code de test ici :

import { apiInitializer } from "discourse/lib/api";
import { i18n } from "discourse-i18n";
import { computed } from "@ember/object";

export default apiInitializer((api) => {
  const siteSettings = api.container.lookup("service:site-settings");

  const maxVisibleTags = Math.min(
    settings.max_tags_visible,
    siteSettings.max_tags_per_topic
  );

  let topicModels = {};

  api.modifyClass(
    "model:topic",
    (Superclass) =>
      class extends Superclass {
        revealTags = false;

        init() {
          super.init(...arguments);
          topicModels[this.id] = this;
        }

        @computed("tags")
        get visibleListTags() {
          if (this.revealTags) {
            return super.visibleListTags;
          }
          return super.visibleListTags.slice(0, maxVisibleTags);
        }
      }
  );

  api.addTagsHtmlCallback(
    (topic, params) => {
      if (topic.tags.length <= maxVisibleTags) {
        return "";
      }

      const isExpanded = topic.revealTags;
      const label = isExpanded
        ? i18n(themePrefix("js.tag_reveal.hide"))
        : i18n(themePrefix("js.tag_reveal.more_tags"), {
            count: topic.tags.length - maxVisibleTags,
          });

      return `<a class="reveal-tag-action" role="button" aria-expanded="${isExpanded}">${label}</a>`;
    },
    {
      priority: siteSettings.max_tags_per_topic + 1,
    }
  );

  document.addEventListener("click", (event) => {
    const target = event.target;
    if (!target?.matches(".reveal-tag-action")) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    const element =
      target.closest("[data-topic-id]") ||
      document.querySelector("h1[data-topic-id]");
    const topicId = element?.dataset.topicId;
    if (!topicId) {
      return;
    }

    const topicModel = topicModels[topicId];
    if (!topicModel) {
      return;
    }

    topicModel.revealTags = !topicModel.revealTags;
    topicModel.notifyPropertyChange("tags");
  });
});
.reveal-tag-action {
  background-color: var(--primary-50);
  border: 1px solid var(--primary-200);
  color: var(--primary-800);
  font-size: small;
  padding-inline: 3px;
}

.discourse-tags__tag-separator:has(+ .reveal-tag-action) {
  visibility: hidden;
}

2 « J'aime »

Salut tout le monde, j’ai poussé une autre mise à jour et ajouté des fonctionnalités expérimentales supplémentaires (« tags » mis en avant qui viennent toujours en premier et ne sont pas calculés dans le nombre maximum + ligne de sujet en vedette dans la vue de la liste des sujets), donc le TC global pivote un peu avec des fonctionnalités plus étendues pour mettre en évidence certains éléments en fonction des onglets configurés.

@Arkshine merci d’avoir partagé votre méthode simplifiée, je l’apprécie vraiment !!!

Cela a également affecté la vue d’un seul sujet, nous avons donc ajouté un paramètre pour activer ce comportement manuellement. De plus, avec la nouvelle méthode, l’état développé persiste lors de la navigation vers un itinéraire/une page différent(e), mais je ne l’ai pas encore abordé.

Je l’ai implémenté dans cette branche :

  • Je pense que vous devez revoir le CSS.

    • vous ne devriez probablement pas ajouter discourse-tag au bouton de bascule, ce n’est pas une balise.
    • n’utilisez pas non plus la classe box dessus, cela perturbe le style de la liste
    • le réglage toggle_tag_style n’a que la valeur « box », peut-être pourriez-vous ajouter « none », afin qu’il s’intègre mieux dans le style liste/puces.
    • commencez simplement et vous pourrez ajuster comme vous le souhaitez
      .reveal-tag-action {
        color: var(--primary-500);
      
        &.-box {
          background-color: var(--primary-50);
          outline: 1px solid var(--primary-200);
          padding-inline: 8px;
        }
      }
      
      /* Masque le dernier séparateur avant le bouton de bascule */
      .discourse-tags__tag-separator:has(+ .reveal-tag-action) {
        visibility: hidden;
      }
      

    Bs1rdLFyIU

    Style de boîte dans les paramètres du site et du thème :
    chrome_raXs2Gc1sd

    Je donnerai plus de commentaires sur le CSS que vous injectez. Il est temps de dormir pour l’instant.

1 « J'aime »

en fait, c’était intentionnel. par exemple, le plugin de vote de sujet utilise la classe sur les éléments « x votes ».

→ voyez-le en action dans la catégorie Feature - Discourse Meta

Merci, je vais vérifier !

Pour l’instant, je n’ai implémenté que le style boîte car c’est ce que j’utilise sur notre instance discourse. J’ajouterai les styles manquants plus tard (ouvert aux PRs :wink: )

Je vois. Je pense que cela a du sens si vous affichez des informations sous forme d’étiquette, mais ici, c’est un bouton pour afficher plus d’étiquettes ; le contexte est différent pour moi. C’est à vous de décider ; je ne pense pas que cela ait beaucoup d’importance.

Pour continuer avec les commentaires :

  • La liste des étiquettes peut être affichée à d’autres endroits, tels que : la page des catégories, les activités de l’utilisateur, etc. Je supprimerais probablement le paramètre collapse_in_topic_view et en créerais un nouveau avec des routes spécifiques ou je l’activerais simplement partout.
    Dans mon code de test, j’ai utilisé quelque chose comme ça pour ignorer les autres routes :
JS
    function isAllowedRoute(routeName) {
        const fullRoutesName = [
          "index",
          "userActivity.topics",
          "userActivity.read",
          ...siteSettings.top_menu.split("|").map((item) => `discovery.${item}`),
        ];

        const partialRoutesName = ["topic."];

        if (
          fullRoutesName.includes(routeName) ||
          partialRoutesName.some((partial) => routeName.startsWith(partial))
        ) {
          return true;
        }

        return false;
      }
  • L’injection CSS peut être remplacée en utilisant l’API pour ajouter une classe à topic-list-item et à une étiquette, puis vous déplacez le CSS vers common.css.

Par exemple :

JS
```js
import { defaultRenderTag } from "discourse/lib/render-tag";

api.registerValueTransformer(
  "topic-list-item-class",
  ({ value, context }) => {
    if (highlightedTagsSet.size === 0) {
      return value;
    }

    if (context.topic?.tags?.some((tag) => highlightedTagsSet.has(tag))) {
      return [...value, `highlighted-tag__${settings.highlighted_style}`];
    }

    return value;
  }
);

api.replaceTagRenderer((tag, params) => {
  if (highlightedTagsSet.has(tag)) {
    params.extraClass = params.extraClass || "";
    params.extraClass += "highlighted";
  }

  return defaultRenderTag(tag, params);
});
```
CSS
  /* Hides the last separator before the toggle button */
  .discourse-tags__tag-separator:has(+ .reveal-tag-action) {
    visibility: hidden;
  }

  .reveal-tag-action {
    color: var(--primary-500);

    &.-box {
      background-color: var(--primary-50);
      outline: 1px solid var(--primary-200);
      padding-inline: 8px;
    }
  }

  .latest-topic-list-item,
  .topic-list-item {
    .discourse-tag.highlighted {
      color: var(--tertiary);
      border-color: var(--tertiary);
      background: color-mix(in srgb, var(--tertiary) 12%, transparent);
      font-weight: 600;
    }
  }

    &.highlighted-tag {
      &__left-border {
        border-left: 3px solid var(--tertiary);
        background: color-mix(in srgb, var(--tertiary) 6%, transparent);
        transition: box-shadow 160ms ease;

        &:hover {
          box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
        }
      }

      &__outline {
        outline: 1px solid var(--tertiary);
        outline-offset: -2px;
        border-radius: 7px;
        background: color-mix(in srgb, var(--tertiary) 5%, transparent);
        box-shadow: 0 1px 6px rgba(0, 0, 0, 0.06);
        transition: background-color 160ms ease;
      }

      &__card {
        border-left: 3px solid var(--tertiary);
        background: var(--tertiary-very-low);
        border-radius: var(--border-radius);
        padding-block: var(--space-2);
        box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
        transition: box-shadow 160ms ease;

        &:hover {
          box-shadow: 0 3px 12px rgba(0, 0, 0, 0.1);
        }
      }
    }
  }

  • Vous n’avez pas besoin de définir la route actuelle à partir de onPageChange, vous pouvez y accéder depuis le routeur.
  • Faites attention à la casse des étiquettes. Vous avez des paramètres de site qui n’imposent pas la casse minuscule, je pense donc qu’il est préférable de ne pas modifier l’étiquette.
  • Concernant la réinitialisation de l’état, vous pouvez probablement utiliser onPageChange.
JS
```js
api.onPageChange((url) => {
    const route = api.container.lookup("service:router").recognize(url);
    if (!isAllowedRoute(route?.name)) {
      return;
    }

    for (const [id, model] of topicModels) {
      if (model && model.revealTags) {
        model.revealTags = false;
        model.notifyPropertyChange("tags");
      }
    }
  });
```
  • Si possible, ce serait bien d’ajouter des tests.

Voici le code de test complet (j’ai fait d’autres changements mineurs)

JS
import { computed } from "@ember/object";
import { apiInitializer } from "discourse/lib/api";
import { i18n } from "discourse-i18n";
import { defaultRenderTag } from "discourse/lib/render-tag";
import { service } from "@ember/service";

export default apiInitializer((api) => {
  const siteSettings = api.container.lookup("service:site-settings");
  const router = api.container.lookup("service:router");

  const maxVisibleTags = Math.min(
    settings.max_tags_visible,
    siteSettings.max_tags_per_topic
  );

  const highlightedTagsSet = new Set(settings.highlighted_tags.split("|"));
  const topicModels = new Map();

  function isAllowedRoute(routeName) {
    const fullRoutesName = [
      "index",
      "userActivity.topics",
      "userActivity.read",
      "tag.show",
      ...siteSettings.top_menu.split("|").map((item) => `discovery.${item}`),
    ];

    const partialRoutesName = ["topic."];

    if (
      fullRoutesName.includes(routeName) ||
      partialRoutesName.some((partial) => routeName.startsWith(partial))
    ) {
      return true;
    }

    return false;
  }

  api.modifyClass(
    "model:topic",
    (Superclass) =>
      class extends Superclass {
        @service router;

        revealTags = false;

        init() {
          super.init(...arguments);
          topicModels.set(String(this.id), this);
        }

        willDestroy() {
          super.willDestroy(...arguments);
          topicModels.delete(String(this.id));
        }

        @computed("tags")
        get visibleListTags() {
          const baseTags = super.visibleListTags || [];

          if (!isAllowedRoute(this.router.currentRouteName)) {
            return baseTags;
          }

          const highlightedList = [];
          const regularList = [];

          baseTags.forEach((tag) => {
            if (highlightedTagsSet.has(tag)) {
              highlightedList.push(tag);
            } else {
              regularList.push(tag);
            }
          });

          if (this.revealTags) {
            return [...highlightedList, ...regularList];
          }

          return [...highlightedList, ...regularList.slice(0, maxVisibleTags)];
        }
      }
  );

  api.addTagsHtmlCallback(
    (topic) => {
      if (!isAllowedRoute(topic.router.currentRouteName)) {
        return "";
      }

      const allTags = topic.tags || [];
      if (allTags.length === 0) {
        return "";
      }

      const highlightedCount = allTags.filter((tag) =>
        highlightedTagsSet.has(tag)
      ).length;
      const regularCount = allTags.length - highlightedCount;
      const effectiveLimit =
        highlightedCount + Math.min(regularCount, maxVisibleTags);

      // Only show toggle if there are hidden tags
      if (allTags.length <= effectiveLimit) {
        return "";
      }

      const isExpanded = topic.revealTags;
      const hiddenCount = allTags.length - effectiveLimit;
      const label = isExpanded
        ? i18n(themePrefix("js.tag_reveal.hide"))
        : i18n(themePrefix("js.tag_reveal.more_tags"), {
            count: hiddenCount,
          });

      const classList = ["discourse-tag", "reveal-tag-action"];
      if (settings.toggle_tag_style === "box") {
        classList.push("-box");
      }

      return `<a class="${classList.join(" ")}" role="button" aria-expanded="${isExpanded}">${label}</a>`;
    },
    {
      priority: siteSettings.max_tags_per_topic + 1,
    }
  );

  api.registerValueTransformer(
    "topic-list-item-class",
    ({ value, context }) => {
      if (highlightedTagsSet.size === 0) {
        return value;
      }

      if (context.topic?.tags?.some((tag) => highlightedTagsSet.has(tag))) {
        return [...value, `highlighted-tag__${settings.highlighted_style}`];
      }

      return value;
    }
  );

  api.replaceTagRenderer((tag, params) => {
    let newParams = params;

    if (highlightedTagsSet.has(tag)) {
      newParams = {
        ...params,
        extraClass: [params.extraClass, "highlighted"]
          .filter(Boolean)
          .join(" "),
      };
    }

    return defaultRenderTag(tag, newParams);
  });

  document.addEventListener(
    "click",
    (event) => {
      const target = event.target;
      if (!target?.matches(".reveal-tag-action")) {
        return;
      }

      event.preventDefault();
      event.stopPropagation();

      const element =
        target.closest("[data-topic-id]") ||
        document.querySelector("h1[data-topic-id]");
      const topicId = element?.dataset.topicId;
      if (!topicId) {
        return;
      }

      const topicModel = topicModels.get(topicId);
      if (!topicModel) {
        return;
      }

      topicModel.revealTags = !topicModel.revealTags;
      topicModel.notifyPropertyChange("tags");
    },
    true
  );

  api.onPageChange((url) => {
    const route = api.container.lookup("service:router").recognize(url);
    if (!isAllowedRoute(route?.name)) {
      return;
    }

    for (const [id, model] of topicModels) {
      if (model && model.revealTags) {
        model.revealTags = false;
        model.notifyPropertyChange("tags");
      }
    }
  });
});