トピックリストのタグ表示コンポーネント - トピックリストのタグを展開/折りたたむ

注:これをテーマコンポーネントに投稿する前に、このテーマコンポーネントが適格かどうか、または重大な問題がないか、まずフィードバックを得たいと思いました。

:warning: 開示:このテーマコンポーネントは、AIコーディングツールの助けを借りて計画、実装、テストされました。

フィードバックをお待ちしています!


:information_source: 概要 タグ表示
:eyeglasses: プレビュー 利用不可…
:hammer_and_wrench: リポジトリ GitHub - jrgong420/discourse-tag-reveal
:question: インストールガイド テーマまたはテーマコンポーネントのインストール方法
:open_book: Discourseテーマ初心者の方へ Discourseテーマの使用に関する初心者ガイド

Discourse Tag Revealは、トピックごとに最初のN個のタグのみを表示し、残りをアクセス可能な「+X個のタグ」トグルに置き換えることで、トピックリストを整理する軽量テーマコンポーネントです。ユーザーは展開してすべてのタグを表示し、折りたたんで短縮ビューに戻すことができます。Discourseの標準タグUIでそのまま動作し、サーバー側の変更は不要です。

機能

  • テーマ設定による設定可能なタグ制限(デフォルト:5)

  • タグとしてスタイル設定され、ARIA属性を使用してキーボードからアクセス可能(Enter/Space)なトグル

  • themePrefixdiscourse-i18nを使用したローカライズされた文字列

  • SPA対応:ページ変更時にロジックをリセットおよび再適用します

  • MutationObserverによる無限スクロールをサポート

  • 最小限のCSS。コアタグスタイルを尊重します

  • テンプレートオーバーライドやプラグイン依存関係はありません

スクリーンショット/デモ

…近日公開

インストールと設定

  • テスト済みのDiscourseバージョン:3.6.0beta1

  • コンポーネントの[設定]タブで設定を構成します:

  • max_tags_visible(整数、デフォルト5):折りたたむ前に表示するタグの数

  • toggle_tag_style:タグの外観に一致するトグルの視覚スタイル(現在、「box」スタイルのみ実装)

  • スコープ:トピックリスト(最新、新規、未読、およびカテゴリトピックリスト)に影響します

他のテーマコンポーネントとの互換性

:warning: 最小限のテストしか実行されていません。本番環境にデプロイする前に、ご自身でテストしてください

注記

  • タグ付けが有効になっていることを確認してください(管理者→設定→タグ)。そうでない場合は、効果が見られません。

  • サイトでタグCSSを大幅にカスタマイズしている場合は、.ts-toggleスタイルを調整して、視覚的な整合性を完璧にする必要があるかもしれません。

将来のアイデア

実際には、これ以上の機能は実装する予定はありませんが、PRは喜んで受け入れます。将来のアイデアの一部:

  • トピックビューでのタグの有効化/無効化

  • 特定のページやカテゴリに対する詳細な制御

「いいね!」 2

コアの設定と同じ名前の設定にしたのは意図的ですか?誤解を招くのではないかと心配です。

「いいね!」 1

よく気がつきました!修正しました…

「いいね!」 2

とても面白そうですね。テーマクリエーターでは動作しないようですが(何か間違っているのでしょうか? :thinking:)、後で開発環境で試してみます。

面白そうですね!その機能が実際に動作しているところのスクリーンショットや画面録画をいくつか共有していただけますか?

:smiley: 最初の投稿に簡単なビデオデモを追加しました。こちらをご覧ください。

TCコンポーネントの提出/追加方法もまだ確認していません… :smiley:
いずれにせよ、まずはここでフィードバックをいくつか集めたいと思います。Theme component で公開する準備ができたら、そこでの追加方法を検討します。

「いいね!」 3

テーマ クリエイターはボックス スタイルを使用していません

以下を使用することをお勧めします。

more_tags:
  one: "+%{count} more tag"
  other: "+%{count} more tags"
「いいね!」 1

良い点ですね。デフォルトのラベルを +%{count} more に変更し忘れていました。これにより、短く簡潔に保つことができます。このように使用することで、物事をコンパクトかつクリーンに保ちます。

「いいね!」 1

こんにちは。

この機能は、状況によっては興味深いかもしれません。

一見したところ、いくつか注意すべき点があります。

  • テーマ設定とサイト設定は同じではありません。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(プラグインのアウトレットなど)を使用して要素のスコープを縮小する方法があるかどうかを確認したいはずです。

別の方法がないか確認してみます。

「いいね!」 1

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

「いいね!」 2

皆さん、さらにアップデートをプッシュし、追加の実験的機能(常に最初に表示され、最大数に含まれない「注目の」タグ + トピックリストビューのトピック行のハイライト)を追加しました。これにより、全体的なTCは、設定されたタブに基づいて特定のものをハイライトするためのより拡張された機能で少し方向転換しています。

@Arkshine 簡略化された方法を共有していただきありがとうございます。本当に感謝しています!シングル・トピック・ビューにも影響があったため、その動作を手動で有効にする設定を追加しました。このブランチに実装しました。

  • CSSを確認する必要があると思います。

    • トグルボタンに discourse-tag を追加するのはおそらく避けるべきです。タグではありません。
    • 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

    サイトとテーマ設定でのボックススタイル:
    chrome_raXs2Gc1sd

    注入するCSSについて、さらにフィードバックします。今は寝る時間です。

「いいね!」 1

実際には意図的でした。例えば、トピック投票プラグインは「x票」要素にクラスを使用しています。

Feature - Discourse Meta カテゴリで実際に動作を確認してください。

ありがとうございます、確認します!

今のところ、私たちのDiscourseインスタンスで使用しているものだけを実装しました。後で不足しているスタイルを追加します(PRはいつでも歓迎します ;))。

なるほど。情報をタグとして表示するのは理にかなっていると思いますが、ここでは、より多くのタグを表示するためのボタンであり、文脈が異なると感じます。それはあなた次第です。それほど重要ではないと思います。

フィードバックを続けます。

  • タグリストは、カテゴリページ、ユーザーのアクティビティなど、他の場所にも表示できます。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
  /* 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);
        }
      }
    }
  }
  • 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);

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