💭

[Vue]headlessUIのMenuをネストさせる

2023/04/25に公開

概要

(OSSコントリビュート)[https://github.com/activist-org/activist]しているプロジェクトで、Netrifyとローカル(Docker)での挙動が異なるというコメントを貰った。
実現したいことは、headlessUIのMenuをネストするということです。

※Nuxt.jsは全く触ったことないのでそこから諸々の確認スタートです(´・ω・`)

初動確認

  • デプロイ先での挙動確認できていない。
    • 確認したところ挙動がめちゃくちゃもっさりしていた。修正した内容としてはheadlessUIでの処理を加えただけ。重くなるような処理に心当たりはない。
      • コンソール上では「caught (in promise) RangeError: Maximum call stack size exceeded」のエラーを吐いている。
      • 該当ファイル名は「app.config.xxxxxxx.js」
  • デプロイ先とローカルでの差分がわからない。
    • たぶんbuildの有無かな...
  • 総合的な環境がわかっていない。
    • 恐らくフロントだけデプロイしていると思われるが...

ローカル環境確認

まずはローカルでの開発環境を確認。Dockerfileを見ると特に変なことはしていなさそう。
となると、ローカルでbuildしてbuild後のファイルを参照してみますか。

Dockerfile
FROM node:19-alpine

# Set work dir for relative paths
WORKDIR /app

# Install dependencies
COPY ./package*.json .
COPY ./yarn.lock .
RUN yarn install && yarn cache clean

# Copy code from context to image
COPY . .

RUN npm rebuild esbuild
CMD yarn dev

Nuxtにはbuild時の設定が複数あるらしい。
以下の記事を参考にすると、ssr:falsetarget:staticとして動作すると思われる。
※Netrifyなどのホスティングサービスにホストするときにはこうするっぽい?
https://qiita.com/mt877/items/40317e0f3b1205c2ea70

とりあえずyarn generate してみる。

静的ファイル確認

生成後の静的ファイルは以下の記事で試してみる。
https://blog.taka1156.site/article/hcpeu85l7/

Netrifyで発生しているエラーはあっさり再現確認できました(´・ω・`)
devとgenerateで動作違うとかあるのか...今度から気をつけよう。
恐らくstateを上位に渡していく際にこけていると思われます。(フロントよくわかんないけどたぶん)
そのため、以下で作成した記事はよろしくない内容になります。すでに先頭に注意書き記載済み。
https://zenn.dev/purupurupu/articles/f8399c443c345b

実現したいこと

前置きが長くなりましたが、headlessUIを用いてMenuのネストを実現することが目的です。
今のソースではMenuの中にDisclosureをネストさせているという状況。

github上で類似のやり取りを発見。結論としては自前で実装するしか無さそう。
headlessUIのマニュアル通りclose()を使って実装できました。

https://github.com/tailwindlabs/headlessui/discussions/669

Vueで詰まったところ

Menuの開閉をリアクティブに管理するしたいなあと思っていたが、なかなかうまくいかない。
3.2以降でrefを使う場合、templateとロジック側で記載がことなる模様...(インラインで実装する際は.valueが不要)

<template>
  <div v-if="submenuVisible" class="px-3 py-2">
  </div>
</template>
import { ref } from "vue";
const submenuVisible = ref(false);
const toggleSubmenu = () => {
  submenuVisible.value = !submenuVisible.value;
};
インラインで書くとこうなる
<template>
  <div v-if="submenuVisible = !submenuVisible" class="px-3 py-2">
  </div>
</template>

headlesssUIで詰まったところ

準備されているコンポーネントを利用すると、state関連の受け渡しがガンガン走ってしまう。
内部的にはdivだったりbuttonだったりの簡単な概念に変換できるので、マニュアルを見ながら最小限のレンダリングで済むように修正。

最終的なコード
<template>
  <Menu
    as="div"
    class="relative inline-block text-left"
    v-slot="{ open, close }"
  >
    <MenuButton
      class="justify-center select-none rounded-md text-light-text dark:text-dark-text active:text-light-cta-orange active:dark:text-dark-cta-orange focus-brand"
    >
      <div>
        <Icon name="bi:three-dots-vertical" size="1.5em" />
      </div>
    </MenuButton>

    <transition
      enter-active-class="transition duration-100 ease-out"
      enter-from-class="opacity-0 transform scale-95"
      enter-to-class="opacity-100 transform scale-100"
      leave-active-class="transition duration-75 ease-in"
      leave-from-class="opacity-100 transform scale-100"
      leave-to-class="opacity-0 transform scale-95"
    >
      <MenuItems
        class="absolute right-0 mt-2 border shadow-lg origin-top-right rounded-md bg-light-content dark:bg-dark-content ring-1 ring-black ring-opacity-5 focus:outline-none border-light-text dark:border-dark-text overflow-clip"
      >
        <div class="w-full mx-auto grid divide-y grid-row-2">
          <button
            @click="toggleThemeVisible"
            :class="[
              isThemeVisible
                ? 'bg-light-cta-orange dark:bg-dark-cta-orange text-light-content dark:text-dark-content'
                : 'text-light-text dark:text-dark-text hover:bg-light-highlight dark:hover:bg-dark-highlight',
              'flex w-full items-center pr-6',
            ]"
          >
            <div
              class="relative z-0 flex items-center w-full px-3 py-2 text-sm font-medium text-left space-x-2"
            >
              <Icon
                v-if="$colorMode.preference == 'system'"
                name="bi:circle-half"
              />
              <Icon v-if="$colorMode.preference == 'light'" name="bi:sun" />
              <Icon
                v-else-if="$colorMode.preference == 'dark'"
                name="bi:moon"
              />
              <p>{{ $t("theme") }}</p>
            </div>
            <Icon
              name="bi:chevron-down"
              :class="
                open
                  ? 'absolute right-0 mr-2 rotate-180 transform'
                  : 'absolute right-0 mr-2'
              "
            />
          </button>
          <div v-if="isThemeVisible" class="px-3 py-2">
            <MenuItem>
              <button
                class="flex items-center w-full px-2 py-2 text-sm rounded-md group text-light-text dark:text-dark-text hover:bg-light-highlight dark:hover:bg-dark-highlight"
                @click="
                  $colorMode.preference = 'system';
                  toggleThemeVisible();
                  close();
                "
              >
                <Icon name="bi:circle-half" size="1em" />
                <p class="px-1">System</p>
              </button>
            </MenuItem>
            <MenuItem>
              <button
                class="flex items-center w-full px-2 py-2 text-sm rounded-md group text-light-text dark:text-dark-text hover:bg-light-highlight dark:hover:bg-dark-highlight"
                @click="
                  $colorMode.preference = 'light';
                  toggleThemeVisible();
                  close();
                "
              >
                <Icon name="bi:sun" size="1em" />
                <p class="px-1">Light</p>
              </button>
            </MenuItem>
            <MenuItem>
              <button
                class="flex items-center w-full px-2 py-2 text-sm rounded-md group text-light-text dark:text-dark-text hover:bg-light-highlight dark:hover:bg-dark-highlight"
                @click="
                  $colorMode.preference = 'dark';
                  toggleThemeVisible();
                  close();
                "
              >
                <Icon name="bi:moon" size="1em" />
                <p class="px-1">Dark</p>
              </button>
            </MenuItem>
          </div>
          <button
            @click="toggleLocaleVisible"
            :class="[
              isLocaleVisible
                ? 'bg-light-cta-orange dark:bg-dark-cta-orange text-light-content dark:text-dark-content'
                : 'text-light-text dark:text-dark-text hover:bg-light-highlight dark:hover:bg-dark-highlight',
              'flex w-full items-center pr-6',
            ]"
          >
            <div
              class="relative z-0 flex items-center w-full px-3 py-2 text-sm font-medium text-left space-x-2"
            >
              <Icon name="bi:globe" />
              <p class="uppercase">{{ $i18n.locale }}</p>
            </div>
            <Icon
              name="bi:chevron-down"
              :class="
                open
                  ? 'absolute right-0 mr-2 rotate-180 transform'
                  : 'absolute right-0 mr-2'
              "
            />
          </button>
          <MenuItem v-if="isLocaleVisible" class="px-2 py-2">
            <ul>
              <NuxtLink
                v-for="locale in availableLocales"
                :key="locale.code"
                :to="switchLocalePath(locale.code)"
              >
                <!-- <button @click="close"> -->
                <li
                  @click="
                    toggleLocaleVisible();
                    close();
                  "
                  class="flex w-24 px-1 py-2 text-sm rounded-md group items-left text-light-text dark:text-dark-text hover:bg-light-highlight dark:hover:bg-dark-highlight"
                >
                  {{ locale.name }}
                </li>
                <!-- </button> -->
              </NuxtLink>
            </ul>
          </MenuItem>
        </div>
      </MenuItems>
    </transition>
  </Menu>
</template>

<script setup>
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
import { ref } from "vue";
const isThemeVisible = ref(false);
const isLocaleVisible = ref(false);

const toggleThemeVisible = () => {
  isThemeVisible.value = !isThemeVisible.value;
};
const toggleLocaleVisible = () => {
  isLocaleVisible.value = !isLocaleVisible.value;
};

const { locale, locales } = useI18n();
const switchLocalePath = useSwitchLocalePath();
const availableLocales = computed(() => {
  return locales.value.filter((i) => i.code !== locale.value);
});
</script>

静的ファイルの確認もして問題なさそうなのでPR出してフィニッシュです(´・ω・`)
すんなりマージされたら嬉しいな(´・ω・`)

GitHubで編集を提案

Discussion