BATONZ Tech Blog

M&Aプラットフォーム運営の株式会社バトンズによる技術ブログです。

フルスクリーンモードのVDialogに任意のz-indexを設定する方法

利用環境

  • Nuxt: 2.16.1
  • Vuetify: 2.6.14

はじめに

今回は長らくバトンズのエンジニアを悩ませた「フルスクリーンモードのVDialogがグローバルナビゲーションより前面に表示できない問題」についてお話ししたいと思います!

※ 分かりやすさのため必ずしも正確な表現になっていないところもありますが、ご了承くださいませ。

「フルスクリーンモードのVDialogがグローバルナビゲーションより前面に表示できない問題」とは?

直面した問題としては、読んで字の如く、グローバルナビゲーションよりフルスクリーンモードのVDialogを前面に表示したいのに常にグローバルナビゲーションが前面にきてしまうというものでした。

問題そのものは、グローバルナビゲーションのz-indexがVDialogより大きいだけなのですが、VDialogのz-indexをpropsで指定ができず、また、SFC内のscopedなcssでz-indexを与えても解決できず、長らくバトンズのエンジニアを悩ませました。

今回検証に利用したサンプルでは、グローバルナビゲーションのz-indexを3000に設定し、同様の問題が再現するコードを用意しました。

期待する挙動(VDialogが最前面)

実際の挙動(VDialogが隠れる)

実際の挙動でのVDialogのz-indexは202でした

本投稿では、z-indexの202がどこから来たのかについてお話しさせていただきます。

原因

この問題の発生原因は簡単に言うと次の通りです。

  • VuetifyがVDialogに自動的に付与するz-indexの値がグローバルナビゲーションのz-indexより小さい
  • VDialogはscopedのcssでは適応されない構造のhtmlで展開されるため、SFCからダイアログのz-indexを設定できない

VuetifyがVDialogに自動的に付与するz-indexの値がグローバルナビゲーションのz-indexより小さい

Vuetifyはどの様にしてVDialogにz-indexを付与しているかvuetify ver.2.6.14のソースコードを見てみます。

https://github.com/vuetifyjs/vuetify/blob/v2.6.14/packages/vuetify/src/components/VDialog/VDialog.ts#L29-L40

下記のコードがあることから、VuetifyのVDialogは複数のmixinをextendしたコンポーネントであることがわかりました。

const baseMixins = mixins(
  Dependent,
  Detachable,
  Overlayable,
  Returnable,
  Stackable,
  Activatable,
)

/* @vue/component */
export default baseMixins.extend({
  name: 'v-dialog',

  directives: { ClickOutside },

もう少し読み進めると、dataの中にstackMinZIndex: 200というコードがありました。 変数名からVDialogのz-indexの最小値は200になりそうだということが予想できそうです。 ただ、前述のVDialogのz-index:202と異なっています。どこかにz-index:201の何かあるのでしょうか。 もう少し読み進めてみることにします。

  data () {
    return {
      activatedBy: null as EventTarget | null,
      animate: false,
      animateTimeout: -1,
      stackMinZIndex: 200,
      previousActiveElement: null as HTMLElement | null,
    }
  },

L259でstyle: { zIndex: this.activeZIndex }という記述がありました。 どうやらどこかでactiveZIndexに202を代入しているところがありそうです。

genContent () {
      return this.showLazyContent(() => [
        this.$createElement(VThemeProvider, {
          props: {
            root: true,
            light: this.light,
            dark: this.dark,
          },
        }, [
          this.$createElement('div', {
            class: this.contentClasses,
            attrs: {
              role: 'dialog',
              'aria-modal': this.hideOverlay ? undefined : 'true',
              ...this.getScopeIdAttrs(),
            },
            on: { keydown: this.onKeydown },
            style: { zIndex: this.activeZIndex }, // ここがL259!!
            ref: 'content',
          }, [this.genTransition()]),
        ]),
      ])
    },

ただ、VDailogの実装では、activeZIndexに値を代入している箇所がないので別のところで代入されている様です。

activeZIndexでgrepしてみるとstackableに由来する値であることにたどり着きました。stackableはbaseMixinsに含まれているmixinなのでこちらで間違いなさそうです。

https://github.com/vuetifyjs/vuetify/blob/v2.6.14/packages/vuetify/src/mixins/stackable/index.ts

こちらのファイルを読み進めていくとありました。 ここのコメントを見てやっと202の理由がわかりました。

Return max current z-index (excluding self) + 2

現在の最大のz-index + 2を返す(自分自身は除く)

(2 to leave room for an overlay below, if needed) return parseInt(index)

2を足すのは下にオーバーレイを置く余地を残すため

computed: {
    activeZIndex (): number {
      if (typeof window === 'undefined') return 0

      const content = this.stackElement || this.$refs.content
      // Return current zindex if not active

      const index = !this.isActive
        ? getZIndex(content)
        : this.getMaxZIndex(this.stackExclude || [content]) + 2

      if (index == null) return index

      // Return max current z-index (excluding self) + 2
      // (2 to leave room for an overlay below, if needed)
      return parseInt(index)
    },
  },

VDialogはscopedのcssでは適応されない構造のhtmlで展開されるため、SFCからダイアログのz-indexを設定できない

今回はv-dailogの位置がhtml上のどこにあるか調べるためにkoko-ga-v-dialogというclassを目印として付与しました。

下の画像はhtmlの内容です。VDialogのコンポーネントに指定したclassやstyleは青い枠の中に展開されています。そして、z-indexが設定されている要素は赤い枠の方になっているため、scopedなcssの範囲外になっていました。なお、赤枠の方にクラスをつけたい場合はcontent-classというpropsで設定できます。 ただし、scopedで管理外のため、グローバルなcssしか反映されません。propsにcontent-styleがあれば良かったのですが、おそらく自動的に付与されるz-indexを上書きされたくないなど理由で対応していないのかなと予想しています。

最後にバトンズの解決案の進歩の歴史を紹介して終わりにしたいと思います。

解決方法:レベル1:

グローバなcssファイルで下記の定義を追加する。

フルスクリーンにするダイアログが常に1つであれば実はこれでも事足りますが、 あまりいいやり方とは思えません。

.v-dialog__content v-dialog__content--active {
  z-index: 9999;
}

解決方法:レベル2

class="v-dialog__content--active"というクラスを持った要素にz-indexを指定する。 下記の様な要素を任意の場所にいておくと実は好きなz-indexの値をVDialogに与えることができます。

<div class="v-dialog__content--active" style="z-index: 9999"></div>

これで解決できるのはVuetifyのstackableの下記のメソッドの処理で検出されるからです。 そのため、自分で用意したz-indexがgetMaxZIndexとして取得され、前述の+2の処理を経てVDialogのz-indexになるためです。

getMaxZIndex (exclude: Element[] = []) {
      const base = this.$el
      // Start with lowest allowed z-index or z-index of
      // base component's element, whichever is greater
      const zis = [this.stackMinZIndex, getZIndex(base)]
      // Convert the NodeList to an array to
      // prevent an Edge bug with Symbol.iterator
      // https://github.com/vuetifyjs/vuetify/issues/2146
      const activeElements = [
        ...document.getElementsByClassName('v-menu__content--active'),
        ...document.getElementsByClassName('v-dialog__content--active'),   // <-- ここの処理で検出されるため
      ] 

      // Get z-index for all active dialogs
      for (let index = 0; index < activeElements.length; index++) {
        if (!exclude.includes(activeElements[index])) {
          zis.push(getZIndex(activeElements[index]))
        }
      }

      return Math.max(...zis)
    }

解決方法:レベル3

初期値のオーバーライド 最初からこれが思いつけば良かったのですが、VDialogをextendしたコンポーネントを作成して、 そのstackMinZIndexの初期値に任意の値を与える。

つまり、v-dailogは直接使わず、今回作成したXxxxDialogを利用するのがスマートそうでした。

<script>
import { VDialog } from 'vuetify/lib'
export default VDialog.extend({
  data() {
    return {
      activatedBy: null,
      animate: false,
      animateTimeout: -1,
      isActive: !!this.value,
      stackMinZIndex: 9999
    }
  }
})
</script>

最後に

近々、バトンズはVuetify2からVuetify3にアップグレードする予定なのですが、 Vuetify3では内部実装が変わっている関係で本投稿の内容はVuetify2限定での話になります。

最後まで読んでいただきありがとうございました!!