[Vue]headlessUIのMenuをネストさせる
概要
(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」
- 確認したところ挙動がめちゃくちゃもっさりしていた。修正した内容としてはheadlessUIでの処理を加えただけ。重くなるような処理に心当たりはない。
- デプロイ先とローカルでの差分がわからない。
- たぶんbuildの有無かな...
- 総合的な環境がわかっていない。
- 恐らくフロントだけデプロイしていると思われるが...
ローカル環境確認
まずはローカルでの開発環境を確認。Dockerfileを見ると特に変なことはしていなさそう。
となると、ローカルでbuildしてbuild後のファイルを参照してみますか。
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:false
、target:static
として動作すると思われる。
※Netrifyなどのホスティングサービスにホストするときにはこうするっぽい?
とりあえずyarn generate
してみる。
静的ファイル確認
生成後の静的ファイルは以下の記事で試してみる。
Netrifyで発生しているエラーはあっさり再現確認できました(´・ω・`)
devとgenerateで動作違うとかあるのか...今度から気をつけよう。
恐らくstateを上位に渡していく際にこけていると思われます。(フロントよくわかんないけどたぶん)
そのため、以下で作成した記事はよろしくない内容になります。すでに先頭に注意書き記載済み。
実現したいこと
前置きが長くなりましたが、headlessUIを用いてMenuのネストを実現することが目的です。
今のソースではMenuの中にDisclosureをネストさせているという状況。
github上で類似のやり取りを発見。結論としては自前で実装するしか無さそう。
headlessUIのマニュアル通りclose()を使って実装できました。
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出してフィニッシュです(´・ω・`)
すんなりマージされたら嬉しいな(´・ω・`)
Discussion