Collapsible Subcategories in Sidebar

AI-generated summary

The discussion revolves around implementing collapsible subcategories in the Discourse sidebar, inspired by the Unreal Engine forum’s design. alkah3st notes that while standard components may not suffice, they developed a custom JavaScript and CSS solution to achieve the desired functionality. The script dynamically collapses subcategories under parent categories, adding toggle buttons for interaction. nathank references similar themes and encourages sharing the code, acknowledging the maintenance challenges. alkah3st shares the full script and CSS, explaining it works by tracking parent-child category relationships and applying visibility toggles. The solution is effective but cautioned against for sites with many subcategories due to performance concerns.

I’ve seen a few posts about this but none have definitive answers.

Does the Unreal Engine Discourse (featured on the Discourse “Discover” page) have a completely custom theme, or are there components that can achieve this?

I also notice they display the subcategories as top level links when you click into one, and then beneath it lists all the topics. It looks like what’s happening here is setting “Subcategory List Style” to “Rows” on the parent category to achieve that?

It looks like this may not be something solvable with a component.

However I was able to generate JS and CSS that achieves this with a little AI help, if anyone is interested I am happy to share.

You’ve seen this I take it:

And this:

By all means, do share your code. Of course, the challenge with things like this is the ongoing maintenance.

Yeah, it seems a lot of what they’re doing is custom stuff.

My script looks like this:

import { apiInitializer } from "discourse/lib/api";
import { ajax } from "discourse/lib/ajax";

export default apiInitializer("0.11.1", (api) => {
  ajax("/site.json").then((data) => {
    const categories = data.categories;

    // Build map of parent_id => [child_category_ids]
    const childMap = {};
    categories.forEach((cat) => {
      if (cat.parent_category_id) {
        if (!childMap[cat.parent_category_id]) {
          childMap[cat.parent_category_id] = [];
        }
        childMap[cat.parent_category_id].push(cat.id);
      }
    });

    const collapseState = {}; // Tracks collapse state per parent

    function applyCollapseState(parentId, childIds, collapsed) {
      childIds.forEach((childId) => {
        const childEl = document.querySelector(
          `.sidebar-section-link-wrapper[data-category-id="${childId}"]`
        );
        if (childEl) {
          childEl.classList.toggle("is-collapsed", collapsed);
          childEl.classList.add("is-subcategory");
        }
      });
    }

    function ensureToggleExists(parentId, childIds) {
      const parentEl = document.querySelector(
        `.sidebar-section-link-wrapper[data-category-id="${parentId}"]`
      );
      if (!parentEl || parentEl.classList.contains("has-toggle")) return;

      const toggle = document.createElement("span");
      toggle.innerText = collapseState[parentId] ? "▸" : "▾";
      toggle.className = "toggle-subcategories";
      toggle.style.cursor = "pointer";
      toggle.style.marginLeft = "0.5em";

      toggle.onclick = () => {
        const isNowCollapsed = !collapseState[parentId];
        applyCollapseState(parentId, childIds, isNowCollapsed);
        toggle.innerText = isNowCollapsed ? "▸" : "▾";
        collapseState[parentId] = isNowCollapsed;
      };

      parentEl.classList.add("has-toggle");
      const link = parentEl.querySelector(".sidebar-section-link");
      if (link) link.appendChild(toggle);
    }

    api.onPageChange(() => {
      Object.entries(childMap).forEach(([parentId, childIds]) => {
        // Set default collapse state
        if (collapseState[parentId] === undefined) {
          collapseState[parentId] = true;
        }

        applyCollapseState(parentId, childIds, collapseState[parentId]);
        ensureToggleExists(parentId, childIds);
      });

      const container = document.querySelector('[data-section-name="categories"]');
      if (!container) return;

      const observer = new MutationObserver(() => {
        Object.entries(childMap).forEach(([parentId, childIds]) => {
          applyCollapseState(parentId, childIds, collapseState[parentId]);
          ensureToggleExists(parentId, childIds); // <-- Restore toggles
        });
      });

      observer.observe(container, {
        childList: true,
        subtree: true,
      });
    });
  });
});

I just dropped that into the theme’s JS panel. The relevant CSS:

.sidebar-section-link-wrapper.is-collapsed {
  display: none !important;
}

.sidebar-section-link-wrapper.is-subcategory {
  padding-left: 1.5em;
}

.toggle-subcategories {
    float: right;
    display: flex;
    width: 30px;
    align-items: center;
    font-size: .9em;
    line-height: 1;
    height: 30px;
}

It works well enough for my purposes but I wouldn’t recommend this if you have lots of subcategories.