Запрос обратной связи: компонент отображения тегов в списках тем — раскрытие/сворачивание тегов в списках тем

Примечание: Перед публикацией этого компонента темы я хотел бы получить обратную связь — соответствует ли он критериям или есть ли в нём серьёзные проблемы.

:warning: Раскрытие информации: Этот компонент темы был спланирован, реализован и протестирован с помощью инструментов ИИ для программирования.

Буду рад получить ваши отзывы!


:information_source: Краткое описание Tag Reveal
:eyeglasses: Предпросмотр Недоступно…
:hammer_and_wrench: Репозиторий https://github.com/jrgong420/discourse-tag-reveal
:question: Руководство по установке Как установить тему или компонент темы
:open_book: Новичок в темах Discourse? Начинающее руководство по использованию тем Discourse

Discourse Tag Reveal — это лёгкий компонент темы, который поддерживает аккуратность списков тем, отображая только первые N тегов для каждой темы и заменяя остальные доступной кнопкой-переключателем «+X ещё тегов». Пользователи могут развернуть список, чтобы увидеть все теги, и свернуть его обратно в сокращённый вид. Компонент работает «из коробки» со стандартным интерфейсом тегов Discourse и не требует изменений на стороне сервера.

Возможности

  • Настраиваемый лимит тегов (по умолчанию: 5) через настройки темы

  • Переключатель оформлен как тег, доступен с клавиатуры (Enter/Space) с атрибутами ARIA

  • Локализованные строки с использованием themePrefix и discourse-i18n

  • Безопасное поведение в SPA: сброс и повторное применение логики при изменении страницы

  • Поддержка бесконечной прокрутки через MutationObserver

  • Минимальный CSS; сохраняет основные стили тегов

  • Никаких переопределений шаблонов или зависимостей от плагинов

Видео-демонстрация:

CleanShot 2025-10-18 at 19.28.39

Установка и настройка

  • Протестировано с версией Discourse: 3.6.0beta1

  • Настройки конфигурируются на вкладке «Settings» компонента:

  • max_tags_visible (целое число, по умолчанию 5): количество отображаемых тегов перед сворачиванием

  • toggle_tag_style: визуальный стиль переключателя для соответствия внешнему виду тегов (в настоящее время реализован только стиль «box»)

  • Область применения: влияет на списки тем (Последние, Новые, Непрочитанные и списки тем категорий)

Совместимость с другими компонентами тем

:warning: Проведены лишь минимальные тесты, пожалуйста, протестируйте самостоятельно перед развёртыванием в продакшен

Примечания

  • Убедитесь, что теги включены (Админ → Настройки → Теги), иначе эффект не будет заметен

  • Если ваш сайт сильно кастомизирует CSS тегов, возможно, потребуется настроить стили .ts-toggle для идеального визуального выравнивания

Идеи на будущее

Я не планирую добавлять новые функции, но с радостью рассмотрю PR. Вот несколько идей на будущее:

  • Возможность включения/выключения для тегов в просмотре темы

  • Детальный контроль для конкретных страниц и/или категорий

Вы намеренно назвали эту настройку так же, как одну из настроек в ядре? Я бы беспокоился о возможном недопонимании.

Отличное замечание! Только что исправил…

Выглядит действительно интересно. Я попробую это в своей среде разработки позже, так как, похоже, это не работает в Theme Creator (если я, конечно, ничего не делаю не так?) :thinking:.

Звучит интересно! Не могли бы вы поделиться несколькими скриншотами или записями экрана, демонстрирующими работу этой функции?

:smiley: Добавлена короткая видеодемонстрация в первом посте, смотрите здесь:

Я даже ещё не проверял, как отправить/добавить свой компонент для TC туда… :smiley:
Но в любом случае, я предпочитаю сначала собрать здесь обратную связь, а когда всё будет готово к публикации в #theme-component, посмотрю, как добавить его туда.

Theme Creator не использует стиль рамки для тегов

https://github.com/jrgong420/discourse-tag-reveal/blob/19ea982ff7eda93cb369b2af947dd23b847af961/locales/en.yml#L9-L9

Возможно, вам стоит использовать

more_tags:
  one: "+%{count} ещё тег"
  few: "+%{count} ещё тега"
  many: "+%{count} ещё тегов"
  other: "+%{count} ещё тегов"

Хорошая мысль. Я забыл изменить метку по умолчанию на +%{count} ещё, чтобы она была короткой и лаконичной. Так мы её используем, чтобы всё оставалось компактным и чистым.

Привет,

Эта функция может быть интересна в некоторых ситуациях!

На первый взгляд, стоит отметить несколько моментов:

  • Настройки темы и настройки сайта — это не одно и то же. Чтобы получить доступ к max_tags_per_topic, сначала нужно получить сервис, например: const siteSettings = api.container.lookup("service:site-settings");

  • Дополнительные проверки для получения лимита, вероятно, не нужны; вы можете получить значение напрямую. Скорее всего, можно сделать так: Math.min(settings.max_tags_visible, siteSettings.max_tags_per_topic)

  • Вы не восстанавливаете видимость разделителей.

  • Возможно, стоит отменить регистрацию обработчиков событий.

  • Процесс при первоначальной загрузке, скорее всего, не нужен при использовании MutationObserver. Обычно, прежде чем применять решение глобально, стоит проверить, можно ли сузить область вокруг элемента с помощью API (например, через плагин outlet).

Давайте проверю, есть ли другой способ!

Поскольку это находится в файле api-initializers, сработает ли также @service siteSettings?

Можете проверить сейчас? Последнее коммит должно было исправить указанные моменты.

Минимальная версия Discourse 3.6.0 означает, что использовать её смогут далеко не сразу. Вы имели в виду 3.5.0 или 3.6.0beta1?

Я имел в виду 3.6.0beta1, именно с этой версией я его тестировал…

Вы используете это в классе. В противном случае это не сработает.

Тогда вам нужно написать 3.6.0.beta1, иначе в данный момент никто не сможет его установить.

Я немного проверил. Действительно, простого способа достичь этого нет; однако я нашел интересный и упрощенный метод с использованием API.

  • Он использует модель темы для изменения того, какие видимые теги будут выведены до генерации шаблона. Это означает отсутствие манипуляций с DOM и независимость от настроек. В зависимости от состояния (revealTags) он вернет либо исходный список, либо его часть.

  • Для создания кнопки переключения используется API для добавления тега с HTML-кодом кнопки (к сожалению, здесь нет точки расширения плагина). Событие клика обрабатывается отдельно. При клике обновляется состояние переключения (revealTags), и мы запускаем повторный рендеринг списка тегов.

Главное преимущество этого подхода в том, что вам не нужно возиться с HTML и выяснять, что показывать/скрывать с помощью CSS, исходя из различных стилей.

chrome_lSKqwYt5Z7

Делюсь здесь своим тестовым кодом:

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

Привет, ребята! Я выпустил ещё одно обновление и добавил дополнительные экспериментальные функции (теги «featured», которые всегда отображаются первыми и не учитываются при подсчёте максимального количества, а также подсветка строки темы в списке тем). В связи с этим общая логика работы с тегами немного меняется, чтобы обеспечить более расширенный функционал для подсветки определённых тем на основе настроенных вкладок.

@Arkshine, спасибо за твой упрощённый метод, я очень это ценю!!!

Это также повлияло на просмотр отдельной темы, поэтому мы добавили настройку для ручного включения такого поведения. Кроме того, при использовании нового метода состояние развёрнутой темы сохраняется при переходе на другие маршруты/страницы, но я пока не реализовал эту часть.

Я внедрил это в эту ветку:

https://github.com/jrgong420/discourse-tag-reveal/tree/feature/model-based-tag-reveal

  • Мне кажется, вам стоит пересмотреть CSS.

    • Вероятно, не стоит добавлять discourse-tag к кнопке переключения — это не тег.
    • Также не используйте класс box для неё, так как это ломает стиль списка (я использовал «-box»).
    • Настройка toggle_tag_style имеет только значение «box». Возможно, стоит добавить значение «none», чтобы она лучше вписывалась в стиль списка/маркеров.
    • Начнём с простого, а вы сможете настроить как захотите:
      .reveal-tag-action {
        color: var(--primary-500);
      
        &.-box {
          background-color: var(--primary-50);
          outline: 1px solid var(--primary-200);
          padding-inline: 8px;
        }
      }
      
      /* Скрывает последний разделитель перед кнопкой переключения */
      .discourse-tags__tag-separator:has(+ .reveal-tag-action) {
        visibility: hidden;
      }
      

    Bs1rdLFyIU

    Стиль «box» в настройках сайта и темы:
    chrome_raXs2Gc1sd

    Дам больше отзывов по CSS, который вы внедряете. Пора спать.

На самом деле это было сделано намеренно. Например, плагин голосования по темам использует этот класс для элементов с надписью «x голосов».

→ посмотрите это в действии в категории Feature - Discourse Meta

Спасибо, я проверю!

Это действительно хороший момент, на случай если пользователи предпочитают свой собственный CSS.

Понятно. Я считаю, что это имеет смысл, если информация отображается в виде тега, но здесь это кнопка для отображения дополнительных тегов; контекст для меня другой. Решать вам; я не думаю, что это так уж важно.

Продолжая обратную связь:

  • Список тегов может отображаться в других местах, например: на странице категорий, в активности пользователя и т. д. Я бы, вероятно, убрал настройку collapse_in_topic_view и либо создал новую с конкретными маршрутами, либо просто включил её везде.
    В моем тестовом коде я использовал что-то вроде этого для игнорирования других маршрутов:

    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;
        }
    
  • Внедрение CSS можно заменить использованием API для добавления класса к topic-list-item и к тегу, а затем перенести CSS в common.css.

    Например:

    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
    /* Скрывает последний разделитель перед кнопкой переключения */
    .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);
          }
        }
      }
    }
    
    
  • Вам не нужно устанавливать текущий маршрут из onPageChange, вы можете получить к нему доступ через роутер.

  • Будьте осторожны с регистром букв в тегах. У вас есть настройки сайта, которые не принуждают к нижнему регистру, поэтому, я думаю, лучше не изменять тег.

  • Что касается сброса состояния, вы, вероятно, можете использовать onPageChange.

    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");
          }
        }
      });
    
  • Если возможно, было бы здорово добавить тесты.

Вот полный код теста (я внес другие незначительные изменения)

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

      // Показывать переключатель только если есть скрытые теги
      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");
      }
    }
  });
});