N 行以上なら省略表示

N 行以上なら省略表示したい

所感: LGTM

import { FC, ReactNode, CSSProperties } from "react";
import styles from "./LineClampingBox.module.scss";

/** 指定した行数以上なら省略表示する */
export const LineClampingBox: FC<{
  /** 最大行数 */
  maxLines: number;
  children: ReactNode;
}> = ({ maxLines, children }) => {
  return (
    <div
      className={styles.box}
      // CSS variables で値を渡す
      style={{ "--maxLines": maxLines } as CSSProperties}
    >
      {children}
    </div>
  );
};
.box {
  // -webkit- prefix を使い続けるのはやや微妙だが, 現状はこれが最適と思われる
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: var(--maxLines);
  overflow: hidden;
}

N 行以上なら省略表示しつつ, 省略され得る時は開閉ボタンを表示したい

所感: 実装を読み書きするコストが必要以上に高いかもです. 本当に「N 行以上なら省略表示」が重要なのであればこれでもよいですが, もし可能なら他の手段も検討できませんか?

import { FC, ReactNode, CSSProperties, useState, useRef, useEffect } from "react";
import clsx from "clsx";
import styles from "./LineClampingBox.module.scss";

/** 指定した行数以上なら省略表示する */
export const LineClampingBox: FC<{
  /** 省略時の最大行数 */
  maxLines: number;
  /**
   * 注意: ステートフルなコンポーネントを children に渡さないこと.
   * LineClampingBox は省略表示するのに十分な行数があるかを判定するために, 二重に children を描画する.
   * もしステートフルなコンポーネントが存在すると状態がずれてしまい, 誤った判定をしてしまう可能性がある.
   * (二重に children を描画するのではなく, 描画された要素をコピーすれば解消するかも?)
   */
  children: ReactNode;
}> = ({ maxLines, children }) => {
  // hasManyLines = 省略表示をするのに十分な行数があるか = 開閉ボタンを表示すべきか.
  // 描画前に計算することは不可能なので, 描画した結果を元に判定する.
  // CSS だけで判定できると JS の実行を待たずに済むので layout shift が減らせるのだが,
  // そういった手段は現状ではなさそうに見える.
  const [hasManyLines, setHasManyLines] = useState(false);
  const hiddenTextRef = useRef<HTMLDivElement | null>(null);
  // 判定は要素がリサイズされたときか children が変化したときに行えば良い.
  // ResizeObserver を使いつつ, children が変化したときは再判定させるために useEffect の依存配列に children を入れている.
  useEffect(() => {
    const elem = hiddenTextRef.current;
    if (!elem) {
      return () => {};
    }
    const observer = new ResizeObserver(() => {
      // 省略表示されているときは scrollHeight > clientHeight になっている
      // 参考: https://stackoverflow.com/questions/52169520/how-can-i-check-whether-line-clamp-is-enabled
      setHasManyLines(elem.scrollHeight > elem.clientHeight);
    });
    observer.observe(elem);
    return () => {
      observer.disconnect();
    };
  }, [children]);

  const [isOpen, setIsOpen] = useState(false);

  return (
    <div
      className={styles.box}
      // CSS variables で値を渡す
      style={{ "--maxLines": maxLines } as CSSProperties}
    >
      <div className={clsx(styles.children, isOpen && styles.open)}>
        {children}
      </div>
      {/*
        * hasManyLines の判定に使う非表示の children.
        * inert, aria-hidden 属性を付けてインタラクションを無効化しておく.
        */}
      <div className={clsx(styles.children, styles.hidden)} inert aria-hidden ref={hiddenTextRef}>
        {children}
      </div>
      {hasManyLines && (
        <div>
          {isOpen ? (
            <button type="button" onClick={() => { setIsOpen(false); }}>
              show less
            </button>
          ) : (
            <button type="button" onClick={() => { setIsOpen(true); }}>
              show more
            </button>
          )}
        </div>
      )}
    </div>
  );
};
.box {
  position: relative;
}

.children {
  // -webkit- prefix を使い続けるのはやや微妙だが, 現状はこれが最適と思われる
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: var(--maxLines);
  overflow: hidden;

  // display: none だとサイズが測れないので, opacity: 0 で透明化する.
  // そのままだと領域をとってしまうので, position: absolute で領域をとらないようにする.
  &.hidden {
    position: absolute;
    opacity: 0;
  }

  &.open {
    -webkit-line-clamp: none;
  }
}

次回: N 文字以上なら省略表示したい

「文字」とは?