テキストを省略せずに一行で収めたい
これは数ある方針のうちの一つです
さいしょに
一覧の行やカードの見出しのように、幅が決まっていて1行で見せたい場所があります。横にアイコンやバッジが並べば、見出しに使える幅はさらに狭くなります。また、入る文字列の長さは事前にわかるとは限りません。
text-overflow: ellipsisで省略するのもいいのですが後半が消えちゃいます。折り返しを許すと行数が変わりデザインが変わってしまいます。
ハイフンも罠になります。AB-12のような文字列はハイフンの直後が折り返し可能な位置なので幅に余裕があっても2行になることがあります。1行に保ちたい要素には短いものも含めてすべてwhite-space: nowrapをつけないといけません。
CSSには中身の文字量に応じてフォントサイズを縮める仕組みがありませんので、収まるかどうかは実際の描画幅を測らないとわからないといけません。そういうことならJavaScriptの出番です!
使える幅との比率をフォントサイズに掛ける
要素にwhite-space: nowrapとoverflow: hiddenを付けておくと、scrollWidthがテキスト本来の幅、clientWidthが使える幅になります。はみ出していたら、その比率をフォントサイズに掛けるだけです。
戻してから測る
前回縮めた値が残ったまま測ると、比率が二重に掛かって縮みすぎます。まずインラインのfont-sizeを消して基準サイズに戻し、それから測ります。scrollWidthは整数に丸められるうえ、paddingのようにフォントサイズで変わらない値も比率に混ざるので、1回では決まりません。収まるまで数回反復します。
const fit = (el) => {
el.style.fontSize = ""; // 前回の縮小を戻してから測る
for (let i = 0; i < 3 && el.scrollWidth > el.clientWidth; i++) {
const size = parseFloat(getComputedStyle(el).fontSize);
el.style.fontSize = `${(size * el.clientWidth) / el.scrollWidth}px`;
}
};
CSS側の前提
.fit-text {
/* インライン要素はclientWidthが0になる。blockかflexアイテムで使う */
display: block;
overflow: hidden;
white-space: nowrap;
/* pxではなくem。縮めた後のフォントサイズに追従する */
letter-spacing: 0.05em;
}
字間をpxで書くと、文字だけ縮んで字間が固定で残り、縮み方が足りません。emなら1回の計算でほぼ収束します。なお、画面幅に比例させる全体の縮小はsp-unit縮尺レシピの担当で、このレシピはそれでもはみ出す長文を個別に収める係です。2つは重ねて使えます。
composableにまとめる
測り直しのタイミングをまとめて包むと、使う側はrefを渡すだけになります。ResizeObserverは画面リサイズだけでなく、v-showで隠れていた要素が表示されて幅が確定した瞬間にも発火するので、開閉するUIの測り直しもこれ1つで済みます。
import { onBeforeUnmount, onMounted, type ShallowRef } from "vue";
/** テキストが要素からはみ出す場合にフォントサイズを縮めて1行に収める */
export function useFitText(target: Readonly<ShallowRef<HTMLElement | null>>) {
let observer: ResizeObserver | null = null;
const run = () => {
const el = target.value;
if (el && el.clientWidth > 0) fit(el); // 非表示の間は測らない
};
onMounted(() => {
run();
if (typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(run);
if (target.value) observer.observe(target.value);
}
// 代替フォントで測った幅はずれる。Webフォント確定後に測り直す
document.fonts?.ready.then(run);
});
onBeforeUnmount(() => observer?.disconnect());
}
<script setup lang="ts">
const titleEl = useTemplateRef("titleEl");
useFitText(titleEl);
</script>
<template>
<span ref="titleEl" class="fit-text">{{ title }}</span>
</template>
自分自身をobserveしても無限ループにはなりません。font-sizeを変えると高さが動くので通知は一度来ますが、2回目は同じ値に落ち着いて止まります。
入れる手順
-
見出し要素の幅を確定させる
インラインのままでは
clientWidthが0なので、blockかflexアイテムにします。flexアイテムはmin-width: autoのせいで中身より狭くなれないため、見出し要素か親にmin-width: 0またはoverflow: hiddenを付けて、使える幅まで縮めるようにします。 -
nowrapにする
white-space: nowrapで折り返しを禁止します。ハイフンを含む短い文字列にも忘れずに。字間はemへ。text-overflow: ellipsisは縮小が効けば発火しないので、JSが動かなかったときの保険として残せます。 -
useFitTextを適用する
見出し要素のrefを渡すだけです。初回、リサイズ、表示切り替え、Webフォント読み込み後の測り直しはcomposableが面倒を見ます。
気をつけるところ
- 隠れている要素とインライン要素は
clientWidthが0です。0のときは何もせず戻るので、blockになっていないと無言で効かないままになります。表示された瞬間の測り直しはResizeObserverが拾ってくれます。 - Webフォントの読み込み前に測ると、代替フォントの幅で計算してしまいます。
document.fonts.readyで確定後にもう一度測ります。 - どこまでも縮む仕組みなので、極端に長い文字列が来ると読めなくなります。想定される最大長を握れないなら、下限のフォントサイズを決めて、そこからは折り返しや省略に切り替える判断もあります。
- テスト環境のjsdomにはResizeObserverがありません。
typeof ResizeObserver !== 'undefined'のガードを入れておくと、コンポーネントのマウントでテストが落ちません。