Add custom menu panel to the correct location (same as hamburger menu panel)

In Reuse Discourse Hamburger Functionality, a way to add a new menu item to the header-icons location (i.e. where Discourse hamburger menu is located) is described.

In essence, a call to api.decorateWidget('header-icons:after', callback) is used to (1) add an additional menu entry with its own icon and (2) add a menu panel if its underlying state variable for its visibility is true.

This works, but is slightly inconsistent with the way Discourse’s existing menu entries work: The new menu is added as a child to the header-icons location (i.e. the div.d-header-icons element), while Discourse’s menus are children of div.panel (i.e. the parent element of div.d-header-icons).

This difference causes the added menu to be occluded by the div.header-cloak element for the mobile view.

Is there a way to add a widget to div.panel? Alternatively, is there a chance that the element div.panel will be available as a widget location?

I found a solution to this issue. The missing piece was the api.addHeaderPanel method. I’m not quite sure why its third argument, a callback, has to be provided, but passing a do-nothing function works.

Anyway, a theme component with the following files allows one to add a Discourse menu entry and panel in the correct locations that works in both desktop and mobile views.

./common/head_tag.html:

The following pieces of JavaScript are all part of this script element:

<script type="text/discourse-plugin" version="0.8">
    // …
</script>

First, I’m setting up a data structure that contains the content of the new menu:

const { h } = require('virtual-dom');
const { attachAdditionalPanel } = require('discourse/widgets/header');

const menuItems = [
    {
        text: 'Home',
        href: '/'
    },
    {
        text: 'FAQ',
        href: '/faq'
    }
];

Second, I’m creating a new widget called custom-menu. This follows what Discourse uses for its own hamburger menu (see app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6).

api.createWidget('custom-menu', {
    tagName: 'div.custom-panel',

    settings: {
        maxWidth: 320
    },

    panelContents() {
        return h(
            'ul.custom-menu',
            menuItems.map(item => h('li', h('a', { href: item.href }, item.text)))
        );
    },

    html() {
        return this.attach('menu-panel', {
            contents: () => this.panelContents(),
            maxWidth: this.settings.maxWidth
        });
    },

    clickOutside(event) {
        if (this.site.mobileView) {
            this.clickOutsideMobile(event);
        } else {
            this.sendWidgetAction('toggleCustomMenu');
        }
    },

    clickOutsideMobile(event) {
        const centeredElement = document.elementFromPoint(event.clientX, event.clientY);
        if (
            !centeredElement.classList.contains('header-cloak') &&
            centeredElement.closest('.panel').length > 0
        ) {
            this.sendWidgetAction('toggleCustomMenu');
        } else {
            const panel = document.querySelector('.menu-panel');
            panel.classList.add('animate');
            const panelOffsetDirection = this.site.mobileView ? 'left' : 'right';
            panel.style.setProperty(panelOffsetDirection, -window.innerWidth);

            const headerCloak = document.querySelector('.header-cloak');
            headerCloak.classList.add('animate');
            headerCloak.style.setProperty('opacity', 0);
            console.log('hi')

            Ember.run.later(() => this.sendWidgetAction('toggleCustomMenu'), 200);
        }
    }
});

Next, I add a new entry to Discourse’s top menu bar (where the search icon, hamburger menu, and the user menu are located). I also setup a widget action that is responsible for changing the state variable that controls the visibility of the custom menu.

api.decorateWidget('header-icons:after', function (helper) {
    const headerState = helper.widget.parentWidget.state;

    return helper.attach('header-dropdown', {
      title: 'custom-menu',
      icon: 'bars',
      iconId: 'toggle-custom-menu',
      active: headerState.customMenuVisible,
      action: 'toggleCustomMenu',
    });
});

api.attachWidgetAction('header', 'toggleCustomMenu', function() {
    this.state.customMenuVisible = !this.state.customMenuVisible;
});

Finally, the following line adds the custom-menu widget to the correct location in the DOM depending on whether the state variable customMenuVisible is true.

api.addHeaderPanel('custom-menu', 'customMenuVisible', function (attrs, state) {
    // This callback has to be provided. Don’t know why.
});

(Again, all these JavaScript blocks belong into the script element above.)

./common/common.scss:

This stylesheets just adds some missing styles that the other panels use.

.d-header .custom-panel {
  width: 0;
}

.mobile-view .custom-panel .menu-panel.slide-in {
  left: 0;
}

Я уже некоторое время ломаю голову над этой проблемой. Заголовок для пользовательского меню-гамбургера отображает информацию о локали, так что при наведении на меню я получаю следующее:

[en_US.some-title]

Попытка использовать I18n.t(themePrefix("some-title")) после создания темы с файлом локали приводит к следующему результату:

[en_US.Some Title]

Как есть
api.decorateWidget('header-icons:before', function (helper) {
    const headerState = helper.widget.parentWidget.state;

    return helper.attach('header-dropdown', {
      title: "mysite.menu_title",
      icon: 'bars',
      iconId: 'toggle-custom-menu',
      active: headerState.customMenuVisible,
      action: 'toggleCustomMenu',
      href: ""
    });
});
Тема с i18n
api.decorateWidget('header-icons:before', function (helper) {
    const headerState = helper.widget.parentWidget.state;

    return helper.attach('header-dropdown', {
      title: I18n.t(themePrefix("mysite.menu_title")),
      icon: 'bars',
      iconId: 'toggle-custom-menu',
      active: headerState.customMenuVisible,
      action: 'toggleCustomMenu'
    });
});

Вот мой полный код скрипта. Тема и CSS — значения по умолчанию для только что созданной темы с помощью команды discourse_theme new.

Сводка
<script type="text/discourse-plugin" version="0.8">

const { h } = require('virtual-dom');
const { attachAdditionalPanel } = require('discourse/widgets/header');
const { iconNode } = require("discourse-common/lib/icon-library");

const mySiteAboutLinks = [
        {
            text: "Knowledgebase",
            rawTitle: "Knowledgebase",
            href: "/k",
            className: "mysite-about",
            icon: "question"
        }
    ];

    const mySiteSupportLinks = [
        {
            text: "Staff",
            rawTitle: "Staff",
            href: "/page/staff/1/",
            className: "mysite-support",
            icon: "link"
        },
        {
            text: "Contact",
            rawTitle: "Contact",
            href: "/page/contact/2/",
            className: "mysite-support",
            icon: "far-envelope"
        }
    ];

api.createWidget('custom-menu', {
    tagName: 'div.custom-panel',
    
    settings: {
        maxWidth: 320
    },
    
    panelContents() {
        results = [];
        
        results[1] = h('ul.custom-about-menu.menu-links.columned', mySiteAboutLinks.map(l => h('li', h('a.widget-link', { href: l.href, title: l.rawTitle }, [ iconNode(l.icon.toLowerCase()), ' ', h('span.d-label', l.text) ]))));
        results[2] = h('div.clearfix');
        results[3] = h('hr');
        results[4] = h('ul.custom-support-menu.menu-links.columned', mySiteSupportLinks.map(k => h('li', h('a.widget-link', { href: k.href, title: k.rawTitle }, [ iconNode(k.icon.toLowerCase()), ' ', h('span.d-label', k.text) ]))))
        
        return results;
        
    },

    html() {
        return this.attach('menu-panel', {
            contents: () => this.panelContents(),
            maxWidth: this.settings.maxWidth
        });
    },

    clickOutside(event) {
        if (this.site.mobileView) {
            this.clickOutsideMobile(event);
        } else {
            this.sendWidgetAction('toggleCustomMenu');
        }
    },

    clickOutsideMobile(event) {
        const centeredElement = document.elementFromPoint(event.clientX, event.clientY);
        if (
            !centeredElement.classList.contains('header-cloak') &&
            centeredElement.closest('.panel').length > 0
        ) {
            this.sendWidgetAction('toggleCustomMenu');
        } else {
            const panel = document.querySelector('.menu-panel');
            panel.classList.add('animate');
            const panelOffsetDirection = this.site.mobileView ? 'left' : 'right';
            panel.style.setProperty(panelOffsetDirection, -window.innerWidth);

            const headerCloak = document.querySelector('.header-cloak');
            headerCloak.classList.add('animate');
            headerCloak.style.setProperty('opacity', 0);
            console.log('hi')

            Ember.run.later(() => this.sendWidgetAction('toggleCustomMenu'), 200);
        }}
});

api.decorateWidget('header-icons:before', function (helper) {
    const headerState = helper.widget.parentWidget.state;

    return helper.attach('header-dropdown', {
      title: I18n.t(themePrefix("mysite.menu_title")),
      icon: 'bars',
      iconId: 'toggle-custom-menu',
      active: headerState.customMenuVisible,
      action: 'toggleCustomMenu'
    });
});


api.attachWidgetAction('header', 'toggleCustomMenu', function() {
    this.state.customMenuVisible = !this.state.customMenuVisible;
});

api.addHeaderPanel('custom-menu', 'customMenuVisible', function (attrs, state) {
    // Этот обратный вызов должен быть предоставлен. Не знаю почему.
});

Есть ли у вас идеи, как исправить заголовок при наведении, чтобы отображался только текст заголовка без скобок и информации о локали?

:fist:

Я посмотрел и почти уверен, что это не ваша вина — код, который вы используете, должен работать.

Проблема в том, что атрибут title для виджета выпадающего списка заголовка ожидает ключ перевода, а не функцию перевода.

Поэтому, когда вы используете это в теме:

title: I18n.t(themePrefix("mysite.menu_title"))

вы по сути вложенно вызываете две функции перевода.

Та, что в вашей теме, возвращает строку, которую вы указали в файле локали. В данном случае это:

Some title

А та, что в коде виджета, затем воспринимает эту строку как ключ перевода, например:

I18n.t('Some title')

который, очевидно, не существует, поэтому вы видите описанную вами проблему, когда возвращается:

[en_US.Some Title]

Другими словами, это ошибка в ядре. Мы её исправим :+1:

Это хорошо, что мы это знаем! Есть какие-то мысли о том, почему код, приведённый как есть, тоже вызывает такую проблему?

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

Мне кажется, что даже с текущим кодом вы увидели бы

[en_US.custom-menu]

при наведении на ссылку, если бы добавили

title: 'custom-menu'

как указано в коде, приведённом выше пользователем kleinfreund.

Возможно, они просто никогда не замечали этой проблемы.

Также, если вам нужно временное решение на это время, вы можете объединить старый способ (до переводов тем) с новым:

I18n.translations.en.js.custom_translation = I18n.t(themePrefix("custom_theme_translation"));

return helper.attach('header-dropdown', {
  title: 'custom_translation',

Спасибо за отзыв, @Johani! И это работает превосходно, @awesomerobot. Я очень ценю это! Эти ответы сработали лучше, чем если бы я бился головой о клавиатуру и/или принимал мотрин!

Обновление: Описанный выше подход перестал работать недавно, и я пытаюсь выяснить, что произошло. Если кто-то знает другую публикацию, в которой описаны какие-либо изменения, я буду рад её прочитать.