カスタムメニューパネルを正しい場所(ハンバーガーメニューパネルと同じ場所)に追加

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:

それは知れてよかったですね!では、なぜ提示されたコードでも同様の問題が発生するのでしょうか?

履歴を確認したところ、その投稿がなされた時点から質問がなされた時点までの間に、ここで問題となるようなそのファイルの変更は一切なかったと確信して言えます。

私の推測ですが、kleinfreund さんが上記で示したコードに

title: 'custom-menu'

を追加すれば、現状のコードのままでも、リンクにカーソルを合わせたときに

[en_US.custom-menu]

が表示されるはずです。

おそらく、単にその問題に気づかなかっただけなのでしょう。

また、当面の応急処置として、以前の方式(テーマ翻訳導入前)と新しい方式をマージすることもできます。

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

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

フィードバックをありがとう、@Johani!そして、@awesomerobot、それは見事に機能しています。感謝しています!それらの対応は、私がキーボードに頭をぶつけたり、モトリンを飲んだりするよりもずっと効果的でした!

更新:上記のアプローチは最近機能しなくなり、何が起きたかを確認中です。変更点について取り上げている別の投稿をご存知の方がいらっしゃれば、ぜひ読ませていただきたいです。