🍞

Vue.jsでDOM操作の鬼になり、パンくずの伸縮表示を極めたい

2023/12/15に公開

こちらはVue Advent Calendar 2023の15日目の記事です。

Vue.jsでDOM操作の鬼になり、パンくずの伸縮表示を極めたい.comを作りました

きっかけは業務でした。

👨 デザイナーさん 「Googleドライブのように、縮小するとアイテムが省略されるパンくずを作ってください」

私 「おかのした」

そうして作ったものが今回の記事の内容なのですが、なんとGoogleドライブのパンくずよりも複雑な挙動のものになりました。

下記から確認することが出来ます。PCでみてね!

https://hiroko-ino.github.io/breadcrumb-shrink-grow/

https://github.com/hiroko-ino/breadcrumb-shrink-grow

動作の要件としては、

  • パンくずが、表示可能な限りすべて表示されること
  • パンくずが、表示可能領域がなくなった場合省略され三点リーダーのメニューに収まること
    • 省略は、min-widthを設定してあり、それギリギリまではflex-shrinkで縮小する。各パンくずの最小値がmin-width限界まで達したところでアイテムの非表示を行う

となっています。

Googleドライブに関しては、観察してみるとわかるのですが、

  • パンくずの表示は現在とその親二つのみ
  • アイテムの横幅関係なく、ブレークポイントで省略する

という風になっています。

こういったGoogleドライブよりもグレート(?)なパンくずが生まれたのは、単純な私の仕様の勘違いもあるのですが、業務で作ったベータ版のパンくず(min-widthの考慮なし)をもっと改良したいと思いアドベントカレンダーを期に完成させてみました。

コードのポイント

パンくずのメーンのコードはこちらにあります。
(時間がなくて、各箇所をコンポーネント化出来てないのが歯がゆい)

https://github.com/hiroko-ino/breadcrumb-shrink-grow/blob/main/src/components/BreadcrumbList.vue

DOMのrefとしては、ul(親要素)とホームアイコンと三点リーダーを除いたliの複数refを用意します。
v-forのアイテムの複数refが簡単に取れるのはVue.jsのすごくいいところですね。

アイテムの何が省略されているかを管理するため、showArrayというbooleanの入る配列を作ります。
初期値として、最初はすべて表示(true)にしておきます。

/**
 * 要素を表示するかどうかの配列
 */
const showArray = ref<boolean[]>(new Array<boolean>(props.list.length).fill(true))

また、各計算と操作に必要なため、各アイテムの初期width、表示されているアイテムのみのwidthの配列とその合計、computedでshowArrayの何番目から表示が行われているのかを持っておきます。

/**
 * 子要素の初期横幅(配列)
 */
const itemsInitialWidth = ref<number[]>([])

...

/**
 * 表示アイテムの横幅のみ配列
 */
const widthArrayOnlyDisplayItem = ref<number[]>([]) // 別関数でこれらをアップデートする

/**
 * 何番目から表示しているか
 */
 const showIndex = computed(() => showArray.value.findIndex(bool => bool))

...

/**
 * showArrayOnlyDisplayItemの合計値
 */
const sumOfShowArrayOnlyDisplayItem = computed(() => {
  return widthArrayOnlyDisplayItem.value.reduce((a, b) => {
    return a + b;
  }, 0);
})

eventListenerでリサイズ時に親要素のrefのclientWidthを取得してrefに入れます。
特にアンマウント等は期にしなくて良い実装ですが、せっかくなのでvueuseのuseEventListenerを使っています。

/**
 * 親要素のclientWidthを更新する
 */
const updateParentWidth = () => {
  const _parentWidth = parentRef.value?.clientWidth
  if (_parentWidth) {
    parentWidth.value = _parentWidth
  }
}
// resize時に親要素のclientWidthを更新する
useEventListener('resize', updateParentWidth)

そうして、watchで親要素のwidthが変わったとき、propsの内容が変わったときに関数を実行します。
propsの内容が変わったときは内容の増減に対応するためshowArrayを初期化しなおします。

/**
 * watch時とアイテム増減時に実行する関数
 */
const checkForAdjust = () => {
  // shrinkによる各サイズの変更をアップデートする
  updateWidthArrayOnlyDisplayItem()

  // 親要素の幅と内容物の差分
  const diff = parentWidth.value - sumOfShowArrayOnlyDisplayItem.value - homeAndHorizWidth.value - gap * widthArrayOnlyDisplayItem.value.length

  if (diff <= 0) {
    const minWidth = Math.min.apply(null, widthArrayOnlyDisplayItem.value)
    // 各アイテムの横幅が最小値に至っている場合
    if (minWidth === 144) {
      let total = 0;
      // 逆順から判定する
      [...widthArrayOnlyDisplayItem.value].reverse().forEach((width, index) => {
        total += width + gap
        showArray.value[showArray.value.length - 1 - index] = total < parentWidth.value - homeAndHorizWidth.value - gap * widthArrayOnlyDisplayItem.value.length
      })
    }
  } else {
    // すべてのアイテムを表示していない場合
    if (showIndex.value <= props.list.length -1) {
      // 次回表示したい横幅
      const nextSum = sumOfShowArrayOnlyDisplayItem.value + gap * (widthArrayOnlyDisplayItem.value.length + 1) + homeAndHorizWidth.value + (itemsInitialWidth.value[showIndex.value + 1] <= 144 ? itemsInitialWidth.value[showIndex.value + 1] : 144)
      if (parentWidth.value >= nextSum) {
        if (showIndex.value !== -1) {
          showArray.value[showIndex.value - 1] = true
        } else {
          showArray.value[showArray.value.length - 1] = true
        }
      }
    }
  }
}

watch(parentWidth, () => {
  checkForAdjust()
})

watch(() => props.list, () => {
  showArray.value = new Array<boolean>(props.list.length).fill(true)
  nextTick(() => {
    checkForAdjust()
    // nextTick mounted value
  })
})

onMounted(() => {
  if (parentRef && itemRefs) {
      updateItemsInitialWidth()
      updateParentWidth()
    }
})

これら(すべてのrefはGitHubで確認してください)を用いて、下記のようなtemplateとして表現します。showArrayを元に表示の制御をしていますね。

<template>
  <ul class="flex gap-x-2 max-w-breadcrumb" ref="parentRef">
    <li class="list-none flex items-center gap-x-1 shrink-0">
      <button @click="moveFolder(initialGrandfatherId)">
        <i className="i-mdi-home text-2xl" />
      </button>
      <span v-if="props.list.length > 0">
        <i className="i-mdi-chevron-right" />
      </span>
    </li>
    <li v-if="showHoriz" class="list-none flex items-center gap-x-1 shrink-0 relative">
      <button @click="toggleHorizMenu">
        <i className="i-mdi-dots-horizontal text-2xl" />
      </button>
      <ul v-if="showHorizMenu" class="absolute top-10 left-0 bg-white w-[160px] rounded-lg text-sm px-3 py-2 border flex flex-col gap-y-2">
        <template v-for="(item, index) in props.list">
          <li
            v-if="!showArray[index]"
            class="overflow-hidden text-ellipsis whitespace-nowrap max-w-full"
          >{{ item.text }}</li>
        </template>
      </ul>
      <span v-if="props.list.length > 0">
        <i className="i-mdi-chevron-right" />
      </span>
    </li>
    <template
      v-for="(item, index) in props.list"
      :key="`${index}_${item.text}_${item.id}`"
    >
      <li
        v-if="showArray[index]"
        ref="itemRefs"
        class="list-none shrink overflow-hidden min-w-[9em] flex items-center"
      >
        <template v-if="index !== props.list.length - 1">
          <button class="underline flex items-center items-center gap-x-1  max-w-full overflow-hidden" @click="moveFolder(item.id)">
            <span class="overflow-hidden text-ellipsis whitespace-nowrap max-w-[10em] w-full">{{ item.text }}</span>
          </button>
          <span>
              <i className="i-mdi-chevron-right" />
          </span>
        </template>
        <div v-else class="flex items-center items-center gap-x-1  max-w-full overflow-hidden relative">
          <span class="overflow-hidden text-ellipsis whitespace-nowrap max-w-[10em] w-full">{{ item.text }}</span>
          <button @click="toggleShowCurrentMenu">
            <i className="i-mdi-menu-down" />
          </button>
        </div>
      </li>
    </template>
  </ul>
</template>

こだわりポイント

  • checkForAdjustの内容は決まるまで6時間くらい唸っていたので褒めてもらえると嬉しい
  • 本題とは関係ないけど、フォルダの移動やフォルダ、ファイルの作成ができるようになっているので楽しんでほしい

おわりに

とまあ、あっさりとした記事になってしまったのですが、もしこんな厄介なパンくずを作りたい人がいればきっと役に立てると思います。
アドベントカレンダーで埋もれちゃうかもしれませんが、分かる人には見つけてもらえると信じて…

誕生日に気合いの入った記事をかけてよかったです🎂

Discussion