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

2 curtidas

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

1 curtida

boa observação! Acabei de modificar…

2 curtidas

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á.

3 curtidas

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"
1 curtida

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.

1 curtida

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!

1 curtida

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

2 curtidas

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.

1 curtida

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