Adicione o painel de menu personalizado ao local correto (igual ao painel do menu hambúrguer)

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

Tenho pensado muito sobre isso há algum tempo. O título do menu hambúrguer personalizado exibe as informações de localidade, de modo que, ao passar o mouse sobre o menu, recebo o seguinte:

[en_US.some-title]

Ao tentar usar I18n.t(themePrefix("some-title")) após criar um tema com um arquivo de localidade, recebo o seguinte:

[en_US.Some Title]

Conforme fornecido
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: ""
    });
});
Tema com 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'
    });
});

Aqui está meu código completo do script. O tema e o CSS são os padrões para um tema recém-gerado usando discourse_theme new.

Resumo
<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) {
    // Esta função de callback precisa ser fornecida. Não sei por quê.
});

Alguma ideia sobre como corrigir o título ao passar o mouse para que apenas o título seja exibido, sem colchetes e sem a localidade?

:fist:

Analisei e tenho quase certeza de que nada disso é culpa sua; o código que você está usando deve funcionar.

O problema aqui é que o atributo title do widget de menu suspenso do cabeçalho espera uma chave de tradução, e não uma função de tradução.

Então, quando você usa isso em um tema:

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

você está essencialmente aninhando duas funções de tradução.

A do seu tema retorna qualquer string que você definir no arquivo de locale. Neste caso, é:

Some title

A do código do widget então pega essa string e a processa como uma chave de tradução, assim:

I18n.t('Some title')

o que, obviamente, não existe. Por isso você vê o problema que relatou, onde ele retorna:

[en_US.Some Title]

Em outras palavras, isso é um bug no núcleo. Vamos corrigi-lo :+1:

Isso é bom de saber! Alguma ideia sobre por que o código fornecido também retornaria tal problema?

Olhei o histórico e posso afirmar com certa segurança de que não houve alterações relevantes nesse arquivo entre o momento em que a postagem foi feita e o momento em que você fez sua pergunta.

Minha suposição é que, mesmo com o código como está, você teria obtido

[en_US.custom-menu]

ao passar o mouse sobre o link se adicionasse

title: 'custom-menu'

depois do link, conforme indicado no código fornecido acima por kleinfreund.

Talvez eles simplesmente nunca tenham notado esse problema.

Além disso, se você quiser uma solução temporária no meio termo, pode mesclar a maneira antiga (antes das traduções de temas) com a nova maneira

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

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

Obrigado pelo feedback, @Johani! E isso funciona perfeitamente, @awesomerobot. Agradeço muito! Essas respostas funcionaram melhor do que eu batendo a cabeça no teclado e/ou tomando Motrin!

Atualização: A abordagem acima parou de funcionar recentemente e estou trabalhando para descobrir o que aconteceu. Se alguém souber de outra postagem que abranja qualquer mudança, ficarei feliz em lê-la.