◆ そういうメソッドあればいいのに

テキストエリアの高さって固定にしてユーザが勝手に調整すれば良いと思ってましたが ちょっと自動で広げたり縮めたりしたかったのでやってみました

困ったのは意外とブラウザごとに動きが違うということ
IE はともかく Chrome と Firefox はこの辺りならさすがに揃ってるとは思ったのですが そうは行きませんでした

ちなみに IE/Edge はテキストエリアの右下を掴んでユーザがサイズ調整する機能がありませんでした
確認時に手動でサイズ変更しようとして初めて気づきました
HTML5 になるよりずっと前からある当たり前機能だと思っていたのですが そうではなかったんですね

コード

最終的にこんな関数でできました
読んでみてなんでこうなってるの?と思えるところばっかりだったので一応珍しいくらいにコメントいっぱいです

function adjustTextAreaHeight(ta) {
const initial_height = parseFloat(getComputedStyle(ta).height)

// height と clientHeight の差分 px
// padding 量だけど box-sizing で変わるので実際の差分から取得
const diff = ta.clientHeight - initial_height

// height を 0 にした状態の scrollHeight が必要な高さ
setHeightPx(0)
const noscroll_height = ta.scrollHeight - diff
setHeightPx(noscroll_height)

// Firefox は高さ 0 のときにスクロールバーなしの高さなのでここで終わり
// 続けるとちゃんと動かない
if (navigator.userAgent.includes("Firefox")) return

// Chrome はスクロールバーあり状態の必要高さなので右端折返しがあるとその分隙間がある
// scrollHeight と clientHeight が異なるところまで縮める
let height = noscroll_height
while (ta.scrollHeight === ta.clientHeight) {
setHeightPx(--height)
}
const final_height = height + 1

// いったんスクロールバーない状態にしないと折り返しあり状態になっている
setHeightPx(noscroll_height)
// 再計算
ta.scrollHeight
setHeightPx(final_height)

function setHeightPx(height) {
ta.style.height = height + "px"
}
}

説明

実際の高さを求めるために使うプロパティは clientHeight と scrollHeight です
clientHeight はテキストエリア自体の高さで scrollHeight はスクロールする分も含めた高さです
テキストエリアが 100px でも改行がいっぱいあって 1000px 分のテキストがあれば scrollHeight は 1000px になります

必要以上の高さがあって下に空白スペースのあるテキストエリアはこの 2 つの値が一緒です
スクロールバーが表示されていると clientHeight のほうが小さくなります


実際のテキストエリアの高さを調整するために設定するのは style.height プロパティです
これは clientHeight と一緒ではありません
clientHeight は padding まで含めた高さですが height は box-sizing によって変わります
その box-sizing の設定によって padding や border などをどう計算するか変えるのだと分岐とか処理が面倒なのと将来的にもっといろいろ考慮することが増えた時に大変なので 今回は実際の差分を使うようにしました

最初の方の diff 変数に差分のピクセル数が入っています
scrollHeight を ◯px にしたいときは ◯ - diff を height に指定すると scrollHeight が ◯px になります

高さを求める

基本的には clientHeight を scrollHeight の高さにすればスクロールバーがなくなります
しかし 余分な高さがあるときにはどれだけ縮めればスクロールバー出ないギリギリの値なのかがわかりません

なので 一度スクロールバーが絶対に出るくらいに縮めた上で scrollHeight を取得します
そうすればスクロールバーがいらない最低の高さがわかります

ですが 例外もあります
スクロールバーが出ている場合 スクロールバーが出ている状態でのコンテンツの高さ分が scrollHeight の値です
右に長くて折り返しが起きる場合 スクロールバーのありなしで折り返し位置が異なります
スクロールバーがある分狭くなり早めに折り返されて スクロールバーがないときよりも行数が増える場合があります

結果として scrollHeight に合わせて広げてみたら下に隙間ができるということが起きます
大抵の場合はちょっとした隙間ですが 折り返し数が多くなってくると何十行分も差がでてしまうことだってありえます

ここからは徐々に縮めていって スクロールバーが出たらその直前の値がスクロールバーを出さない最低の高さとわかります
今回は極端に何十何百 px もないという前提で 1px ずつ縮めています

効率よくするなら

場合によっては遅くなるので 効率良く縮めていくよう改良したほうが良いと思います
例えば scrollHeight が 500px だったら次は 500/2=250px にしてスクロールバーがあるか確認して あったら (250+500)/2=375px で試して まだ出ていないなら 250/2=125px で試すのように二分探索とかでも良いと思います

ただ こうする場合はちょっと面倒で 大きく縮めたときにスクロールバーがでていた場合が特殊です
スクロールバーが出ている状態で広げるのと スクロールバーがでていない状態で縮める場合で 同じ高さにしてもスクロールバーの有無が変わります
スクロールバーがでている状態で広げると スクロールバーがある前提での折り返しで必要高さが決まります
スクロールバーを消してみればちょうど収まってスクロールバーいらないかもしれない という状態でも試してくれないので 一度スクロールバーがない状態にしてから縮めないと本当にスクロールバーが出ない最低の高さを求められません

言ってることがわかりづらいかもしれないので図のような説明も入れてみます

スクロールバーの有無

入力されているデータは 10 個の a が 5 行です

aaaaaaaaaa
aaaaaaaaaa
aaaaaaaaaa
aaaaaaaaaa
aaaaaaaaaa

必要な高さは 5 行分です

高さを縮めてスクロールバーを出すと スクロールバーで 2 文字分とすると 8 文字のところで折り返しが起きます

aaaaaaaa||
aa ||
aaaaaaaa||
aa ||
aaaaaaaa||
aa ||
aaaaaaaa||
aa ||
aaaaaaaa||
aa ||

「||」 のところがスクロールバーです
これだと 10 行必要になりましたね

本当は 5 行分の高さでいいのに この状態だと 5 行や 6 行の高さではスクロールバーが出ます
一度 10 行分の高さにして再計算させると スクロールバーがないので 5 行にしてもスクロールバーが出ません

こういう面倒くささがあります
Chrome では

Firefox の場合

Chrome はこれまでの方法でいろいろ試してもちゃんと求めてる高さにできました
ですが Firefox だと動かなくて調べてみると 十分に小さい 0 px の高さにしたときの scrollHeight の計算方法が違っていました

Chrome だと高さが小さいときはスクロールバーがあるのでスクロールバーがある状態での高さです
しかし Firefox だと 0 px の場合の scrollHeight はスクロールバーがない状態での高さでした
なので縮めていくという必要はなくていきなり求めてる高さに設定できていました

また Chrome のときの縮めすぎた場合に一度大きめにして再計算させてから と言った部分も違っていてスクロールバーありなしと高さの計算方法が Chrome と Firefox では違ってるみたいです
Firefox の計算方法が スクロールバーがあるのにない前提の高さになっていて正しい動きなのか心配なところもありますが とりあえず Firefox は最初の高さ設定のところまでとしています

デモ

試せるページです
テキストエリアになにか入力するごとに自動で高さが調整されます
手動でサイズを変えてその後で何か入力すれば 伸びたり縮んだりします

wrap が off なら

今回は 1 行が折り返される前提でしたが wrap="off" なら単純に改行数から行数がわかるので height の指定をなくして rows を指定するだけでできそうです

a.oninput = function (eve) {
a.rows = this.value.split("\n").length
a.style.height = ""
}