Componente de Revelar Tags em Listas de Tópicos - Expandir/Recolher tags em listas de tópicos

Nota: Antes de postar isso nos componentes de tema, eu queria obter algum feedback primeiro se este componente de tema se qualifica ou se há algum problema importante com ele.

:warning: Divulgação: Este componente de tema foi planejado, implementado e testado com a ajuda de ferramentas de codificação de IA.

Adoraria ouvir seu feedback!


:information_source: Resumo Revelação de Tags
:eyeglasses: Prévia Não disponível…
:hammer_and_wrench: Repositório GitHub - jrgong420/discourse-tag-reveal
:question: Guia de Instalação Como instalar um tema ou componente de tema
:open_book: Novo em Temas do Discourse? Guia para iniciantes no uso de Temas do Discourse

Discourse Tag Reveal é um componente de tema leve que mantém as listas de tópicos organizadas, mostrando apenas as primeiras N tags por tópico e substituindo as restantes por um alternador acessível " +X mais tags ". Os usuários podem expandir para ver todas as tags e recolher de volta para a visualização encurtada. Ele funciona imediatamente com a interface de tags padrão do Discourse e não requer alterações no lado do servidor.

Funcionalidades

  • Limite de tags configurável (padrão: 5) através das configurações do tema

  • Alternador estilizado como uma tag, acessível por teclado (Enter/Espaço) com atributos ARIA

  • Strings localizadas usando themePrefix e discourse-i18n

  • Comportamento seguro para SPA: redefine e reaplica a lógica em mudanças de página

  • Suporta rolagem infinita via MutationObserver

  • CSS mínimo; respeita os estilos de tag principais

  • Sem substituições de template ou dependências de plugin

Capturas de Tela / Demonstração

…em breve

Instalação e Configuração

  • Testado com a versão do Discourse: 3.6.0beta1

  • Configure as configurações na aba Configurações do componente:

  • max_tags_visible (inteiro, padrão 5): Quantas tags mostrar antes de recolher

  • toggle_tag_style: Estilo visual do alternador para corresponder à aparência da tag (Atualmente apenas o estilo “box” implementado)

  • Escopo: afeta as listas de tópicos (listas de tópicos Mais recentes, Novas, Não lidas e de categorias)

Compatibilidade com outros Componentes de Tema

:warning: Apenas testes mínimos realizados, por favor, teste você mesmo antes de implantar em produção

Notas

  • Certifique-se de que a marcação esteja habilitada (Admin → Configurações → Tags), caso contrário, você não verá nenhum efeito

  • Se o seu site personalizar pesadamente o CSS das tags, você pode querer ajustar os estilos .ts-toggle para um alinhamento visual perfeito

Ideias para o futuro

Eu realmente não planejo implementar mais recursos, mas ficarei feliz em aceitar PRs. Algumas ideias para o futuro:

  • Habilitar/desabilitar para tags na visualização de tópicos

  • Controle granular para páginas e/ou categorias específicas

Você nomeou deliberadamente a configuração com o mesmo nome de uma configuração no núcleo? Eu estaria preocupado com mal-entendidos.

boa observação! Acabei de modificar…

Parece muito interessante. Vou tentar no meu ambiente de desenvolvimento mais tarde, já que não parece funcionar no Theme Creator (a menos que eu esteja fazendo algo errado?) :thinking:.

Parece interessante! Você poderia compartilhar algumas capturas de tela ou gravações de tela do recurso em ação?

:smiley: Adicionei uma demonstração rápida em vídeo na primeira postagem, veja aqui:

Eu nem verifiquei como enviar/adicionar meu componente TC lá… :smiley:
Mas de qualquer forma, prefiro coletar algum feedback aqui primeiro, e assim que estiver pronto para ser publicado em Theme component, verei como adicioná-lo lá.

O Theme Creator não usa o estilo de caixa

Você pode querer usar

more_tags:
  one: "+%{count} tag a mais"
  other: "+%{count} tags a mais"

ponto válido. Esqueci de alterar o rótulo padrão para +%{count} a mais para mantê-lo curto e conciso, é assim que o usamos e mantemos as coisas compactas e limpas.

Olá,

Este recurso pode ser interessante em algumas situações!

À primeira vista, há algumas coisas a observar:

  • As configurações de tema e as configurações do site não são as mesmas. Você precisa recuperar o serviço primeiro para acessar max_tags_per_topic, por exemplo: const siteSettings = api.container.lookup(\"service:site-settings\");

  • As verificações extras para obter o limite não deveriam ser necessárias; você pode obter o valor diretamente. Provavelmente você pode fazer Math.min(settings.max_tags_visible, siteSettings.max_tags_per_topic )

  • Você não está restaurando a visibilidade dos separadores.

  • Você pode querer cancelar o registro dos eventos

  • O processo na carga inicial não deve ser necessário com MutationObserver. Geralmente, antes de se tornar global, você gostaria de verificar primeiro se há uma maneira de reduzir o escopo em torno do elemento usando a API (saída de plugin, por exemplo).

Vou verificar se há uma maneira diferente!

Como está no arquivo api-initializers, @service siteSettings também funcionaria?

Pode verificar agora? O último commit deve ter corrigido os pontos abordados

A versão mínima do Discourse 3.6.0 significa que levará um bom tempo até que alguém possa usá-la. Você quis dizer 3.5.0 ou 3.6.0beta1?

Eu quis dizer 3.6.0beta1, essa é a versão com a qual tenho testado…

Você usa isso em uma classe. Não funcionará de outra forma.

Você quer escrever 3.6.0.beta1 então, caso contrário, ninguém poderá instalá-lo no momento.

Eu verifiquei um pouco. De fato, não há uma maneira direta de conseguir isso; no entanto, encontrei um método interessante e simplificado para fazer isso usando a API.

  • Ele usa o modelo de tópico para alterar quais tags visíveis serão geradas antes que o template seja gerado. Isso significa que não há manipulação de DOM e é independente das configurações. Dependendo do estado (revealTags), ele retornará a lista original ou uma parcial.

  • Para criar o botão de alternância, ele usa a API para adicionar uma tag com o HTML de um botão (infelizmente, não há um plugin outlet aqui). O evento de clique é tratado separadamente. Ao clicar, o estado de alternância é atualizado (revealTags) e acionamos uma nova renderização da lista de tags.

A grande vantagem dessa abordagem é que você não precisa mexer no HTML e descobrir o que mostrar/ocultar com CSS, com base nos diferentes estilos.

chrome_lSKqwYt5Z7

Compartilhando meu código de teste aqui:

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

Olá pessoal, fiz outra atualização e adicionei recursos experimentais adicionais (“tags em destaque” que sempre vêm primeiro e não são calculadas para a quantidade máxima + linha de tópico em destaque na visualização da lista de tópicos), então o TC geral está mudando um pouco com mais funcionalidades estendidas para destacar certos com base em guias configuradas.

@Arkshine obrigado por compartilhar seu método simplificado, eu realmente aprecio!!!

Também afetou a visualização de tópico único, então adicionamos uma configuração para habilitar esse comportamento manualmente. Além disso, com o novo método, o estado expandido persiste ao navegar para uma rota/página diferente, mas ainda não abordei isso.

Eu o implementei neste branch:

  • Acho que você precisa revisar o CSS.

    • você provavelmente não deveria adicionar discourse-tag ao botão de alternância, não é uma tag.
    • não use a classe box nele também, isso bagunça o estilo da lista
    • a configuração toggle_tag_style tem apenas o valor “box”, talvez você possa adicionar “none”, para que se encaixe melhor no estilo de lista/marcador.
    • começa simples e você pode ajustar como quiser
      .reveal-tag-action {
        color: var(--primary-500);
      
        &.-box {
          background-color: var(--primary-50);
          outline: 1px solid var(--primary-200);
          padding-inline: 8px;
        }
      }
      
      /* Esconde o último separador antes do botão de alternância */
      .discourse-tags__tag-separator:has(+ .reveal-tag-action) {
        visibility: hidden;
      }
      

    Bs1rdLFyIU

    Estilo da caixa nas configurações do site e do tema:
    chrome_raXs2Gc1sd

    Darei mais feedback sobre o CSS que você injetar. Hora de dormir por agora.

na verdade foi intencional. por exemplo, o plugin de votação de tópicos usa a classe nos elementos “x votos”.

→ veja em ação na categoria Feature - Discourse Meta

Obrigado, vou verificar!

Por enquanto, implementei apenas o estilo de caixa, pois é o que estou usando em nossa instância do Discourse. Adicionarei os estilos ausentes mais tarde (aceito PRs :wink: )

Entendo. Acho que faz sentido se você exibir informações como uma tag, mas aqui é um botão para exibir mais tags; o contexto é diferente para mim. A decisão é sua; não acho que isso importe muito.

Para continuar com o feedback:

  • A lista de tags pode ser exibida em outros lugares, como: página de categorias, atividades do usuário, etc. Eu provavelmente removeria a configuração collapse_in_topic_view e criaria uma nova com rotas específicas ou simplesmente a habilitaria em todos os lugares.
    Na minha versão de teste, usei algo assim para ignorar outras rotas:
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;
      }
  • A injeção de CSS pode ser substituída usando a API para adicionar uma classe a topic-list-item e a uma tag, então você move o CSS para common.css.

Por exemplo:

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

  • Você não precisa definir a rota atual de onPageChange, você pode acessá-la a partir do roteador.
  • Tenha cuidado com a capitalização das tags. Você tem configurações de site que não impõem letras minúsculas, então acho que é melhor não modificar a tag.
  • Sobre redefinir o estado, você provavelmente pode 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");
      }
    }
  });
```
  • Se possível, seria ótimo adicionar testes.

Aqui está o código de teste completo (fiz outras pequenas alterações)

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