Componente de Revelación de Etiquetas en Listas de Temas - Expandir/Contraer etiquetas en listas de temas

Nota: Antes de publicar esto en componentes de temas, quería obtener primero algunos comentarios si este componente de tema califica o si hay algún problema importante con él.

:warning: Divulgación: Este componente de tema fue planeado, implementado y probado con la ayuda de herramientas de codificación de IA.

¡Me encantaría escuchar sus comentarios!


:information_source: Resumen Revelación de etiquetas
:eyeglasses: Vista previa No disponible…
:hammer_and_wrench: Repositorio GitHub - jrgong420/discourse-tag-reveal
:question: Guía de instalación Cómo instalar un tema o componente de tema
:open_book: ¿Nuevo en temas de Discourse? Guía para principiantes sobre el uso de temas de Discourse

Discourse Tag Reveal es un componente de tema ligero que mantiene ordenadas las listas de temas al mostrar solo las primeras N etiquetas por tema y reemplazar el resto con un control “X etiquetas más” accesible. Los usuarios pueden expandir para ver todas las etiquetas y contraer de nuevo a la vista acortada. Funciona directamente con la interfaz de usuario de etiquetas estándar de Discourse y no requiere cambios en el lado del servidor.

Características

  • Límite de etiquetas configurable (predeterminado: 5) a través de la configuración del tema

  • Control diseñado como una etiqueta, accesible desde el teclado (Enter/Espacio) con atributos ARIA

  • Cadenas localizadas usando themePrefix y discourse-i18n

  • Comportamiento seguro para SPA: restablece y reaplica la lógica en los cambios de página

  • Admite desplazamiento infinito a través de MutationObserver

  • CSS mínimo; respeta los estilos de etiquetas principales

  • Sin reemplazos de plantilla ni dependencias de complementos

Capturas de pantalla / Demo

…próximamente

Instalación y configuración

  • Probado con la versión de Discourse: 3.6.0beta1

  • Configure los ajustes en la pestaña de Configuración del componente:

  • max_tags_visible (entero, predeterminado 5): cuántas etiquetas mostrar antes de contraer

  • toggle_tag_style: estilo visual del control para que coincida con la apariencia de la etiqueta (actualmente solo implementado el estilo “box”)

  • Alcance: afecta a las listas de temas (listas de temas Más recientes, Nuevos, No leídos y de categorías)

Compatibilidad con otros componentes de temas

:warning: Solo se realizaron pruebas mínimas, pruébelo usted mismo antes de implementarlo en producción

Notas

  • Asegúrese de que las etiquetas estén habilitadas (Administrador → Configuración → Etiquetas), de lo contrario, no verá ningún efecto

  • Si su sitio personaliza mucho el CSS de las etiquetas, es posible que desee ajustar los estilos de .ts-toggle para una alineación visual perfecta

Ideas para el futuro

Realmente no planeo implementar más funciones, pero estoy feliz de aceptar PRs. Algunas ideas para el futuro:

  • Habilitar/deshabilitar etiquetas en la vista de tema

  • Control granular para páginas y/o categorías específicas

2 Me gusta

¿Nombraste deliberadamente la configuración igual que una configuración en el núcleo? Me preocuparían los malentendidos.

1 me gusta

¡buena observación! Acabo de modificarlo…

2 Me gusta

Parece muy interesante. Lo probaré en mi entorno de desarrollo más tarde, ya que no parece funcionar en Theme Creator (¿a menos que esté haciendo algo mal?):thinking:.

¡Suena interesante! ¿Podrías compartir algunas capturas de pantalla o grabaciones de pantalla de la función en acción?

:smiley: Añadí una demostración rápida en video en la primera publicación, véala aquí:

Ni siquiera he comprobado cómo enviar/añadir mi componente TC allí… :smiley:
Pero de todos modos, prefiero recopilar algunos comentarios aquí primero, y una vez que esté listo para ser publicado en Theme component, veré cómo añadirlo allí.

3 Me gusta

Theme creator no usa el estilo de caja

Quizás quieras usar

more_tags:
  one: "+%{count} etiqueta más"
  other: "+%{count} etiquetas más"
1 me gusta

buen punto. Olvidé cambiar la etiqueta predeterminada a +%{count} más para que sea breve y concisa, así es como la usamos y mantenemos las cosas compactas y limpias.

1 me gusta

Hola,

¡Esta característica podría ser interesante en algunas situaciones!

A primera vista, hay algunas cosas a tener en cuenta:

  • La configuración del tema y la configuración del sitio no son lo mismo. Necesitas obtener el servicio primero para acceder a max_tags_per_topic, por ejemplo: const siteSettings = api.container.lookup(\"service:site-settings\");

  • Las comprobaciones adicionales para obtener el límite no deberían ser necesarias; puedes obtener el valor directamente. Probablemente puedas hacer Math.min(settings.max_tags_visible, siteSettings.max_tags_per_topic )

  • No estás restaurando la visibilidad de los separadores.

  • Es posible que desees anular el registro de los eventos.

  • El proceso de carga inicial no debería ser necesario con MutationObserver. Normalmente, antes de hacerlo global, querrás comprobar primero si hay una forma de reducir el alcance alrededor del elemento utilizando la API (salida de plugin, por ejemplo).

¡Déjame ver si hay una forma diferente!

1 me gusta

Dado que está en el archivo api-initializers, ¿funcionaría también @service siteSettings?

¿Puedes comprobarlo ahora? El último commit debería haber corregido los puntos abordados.

La versión mínima de Discourse 3.6.0 significa que pasará bastante tiempo hasta que alguien pueda usarla. ¿Quisiste decir 3.5.0 o 3.6.0beta1?

Quise decir 3.6.0beta1, esa es la versión con la que la he estado probando…

Lo usas en una clase. No funcionará de otra manera.

Entonces querrás escribir 3.6.0.beta1, de lo contrario nadie podrá instalarlo en este momento.

He comprobado un poco. De hecho, no hay una forma sencilla de lograr eso; sin embargo, encontré un método interesante y simplificado para hacerlo usando la API.

  • Utiliza el modelo de tema para cambiar qué etiquetas visibles se mostrarán antes de que se genere la plantilla. Significa que no hay manipulación del DOM y es independiente de la configuración. Dependiendo del estado (revealTags), devolverá la lista original o una parcial.

  • Para crear el botón de alternancia, utiliza la API para agregar una etiqueta con el HTML de un botón (desafortunadamente, no hay un plugin outlet aquí). El evento de clic se maneja por separado. Al hacer clic, se actualiza el estado de alternancia (revealTags) y se activa una nueva renderización de la lista de etiquetas.

La gran ventaja de esta manera es que no tienes que meterte con el HTML y averiguar qué mostrar/ocultar con CSS, basándote en los diferentes estilos.

chrome_lSKqwYt5Z7

Comparto mi código de prueba aquí:

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 Me gusta

Hola chicos, he lanzado otra actualización y he añadido funciones experimentales adicionales (“etiquetas destacadas” que siempre van primero y no se calculan en la cantidad máxima + fila de temas destacados en la vista de lista de temas), por lo que el TC general está pivotando un poco con una funcionalidad más ampliada para destacar ciertos elementos basados en pestañas configuradas.

@Arkshine ¡gracias por compartir tu método simplificado, lo aprecio mucho!

También afectó a la vista de un solo tema, así que añadimos una configuración para habilitar ese comportamiento manualmente. Además, con el nuevo método, el estado expandido persiste al navegar a una ruta/página diferente, pero aún no lo he abordado.

Lo he implementado en esta rama:

  • Creo que necesitas revisar el CSS.

    • probablemente no deberías añadir discourse-tag al botón de alternancia, no es una etiqueta.
    • tampoco uses la clase box en él, estropea el estilo de la lista
    • la configuración toggle_tag_style solo tiene el valor “box”, quizás podrías añadir “none”, para que encaje mejor en el estilo de lista/viñeta.
    • empieza de forma sencilla y puedes ajustar como quieras
      .reveal-tag-action {
        color: var(--primary-500);
      
        &.-box {
          background-color: var(--primary-50);
          outline: 1px solid var(--primary-200);
          padding-inline: 8px;
        }
      }
      
      /* Oculta el último separador antes del botón de alternancia */
      .discourse-tags__tag-separator:has(+ .reveal-tag-action) {
        visibility: hidden;
      }
      

    Bs1rdLFyIU

    Estilo de caja en la configuración del sitio y del tema:
    chrome_raXs2Gc1sd

    Daré más comentarios sobre el CSS que inyectes. Hora de dormir por ahora.

1 me gusta

en realidad fue intencional. por ejemplo, el plugin de votación de temas utiliza la clase en los elementos “x votos”.

→ verlo en acción en la categoría Feature - Discourse Meta

¡Gracias, lo comprobaré!

Por ahora solo he implementado el estilo de caja, ya que es lo que estoy usando en nuestra instancia de Discourse. Añadiré los estilos que faltan más adelante (aunque acepto PRs :wink: )

Ya veo. Creo que tiene sentido si muestras información como una etiqueta, pero aquí, es un botón para mostrar más etiquetas; el contexto es diferente para mí. Tú decides; no creo que importe mucho.

Para continuar con los comentarios:

  • La lista de etiquetas se puede mostrar en otros lugares, como: la página de categorías, las actividades del usuario, etc. Probablemente eliminaría la configuración collapse_in_topic_view y crearía una nueva con rutas específicas o simplemente la habilitaría en todas partes.
    En mi código de prueba, usé algo como esto para ignorar otras rutas:
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;
      }
  • La inyección de CSS se puede reemplazar usando la API para agregar una clase a topic-list-item y a una etiqueta, luego mueves el CSS a common.css.

Por ejemplo:

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
/* Oculta el último separador antes del botón de revelar */
.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);
      }
    }
  }
}

  ```
  • No necesitas establecer la ruta actual desde onPageChange, puedes acceder a ella desde el router.
  • Ten cuidado con las mayúsculas y minúsculas de las etiquetas. Tienes configuraciones del sitio que no fuerzan las minúsculas, así que creo que es mejor no modificar la etiqueta.
  • Sobre restablecer el estado, probablemente puedas usar 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 puedes, sería genial añadir pruebas.

Aquí está el código de prueba completo (hice otros cambios menores)

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

      // Solo muestra el alternador si hay etiquetas ocultas
      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");
      }
    }
  });
});