🌱

Yamada UIのtextarea(autosize)について

2024/03/09に公開

はじめに

直近でYamada UIのtextareaにautosizeの機能を追加したり、バグの修正をしたり、いろいろといじり倒していたので、記録として残します。

autosize機能とは

textareaの高さを入力した文章の高さに合わせてくれる機能です。フォームで割と必須の機能だと思います。
以下のページで試せます。

https://yamada-ui.com/ja/components/forms/textarea#高さを自動調整する

autosizeの仕組み

react-textarea-autosizeを参考に実装しています。
簡単に説明すると、計算用の表示されないtextareaを用意して、高さを計算し、表示しているtextareaのほうに反映するという処理をしています。

https://github.com/Andarist/react-textarea-autosize

Yamada UIで実装する際は、resizeの処理はすべてuseAutosize()に格納しました。
返ってくるresizeTextarea()を実行するとresizeの処理がされます。

textarea.tsx
const resizeTextarea = useAutosize(textareaRef, maxRows, minRows)

バグ対応

報告されたバグ

refで変更をかけてくるような場合に、resizeの処理がされないというものです。

https://github.com/hirotomoyamada/yamada-ui/issues/827

例えば報告にもあるように、react-hook-formのreset()でresizeが効きません。

export const reactHookForm: Story = () => {
  type Data = { textarea: string }

  const {
    register,
    handleSubmit,
    watch,
    reset,
    formState: { errors },
  } = useForm<Data>()

  const onSubmit: SubmitHandler<Data> = (data) => console.log("submit:", data)

  console.log("watch:", watch())

  return (
    <VStack as="form" onSubmit={handleSubmit(onSubmit)}>
      <FormControl
        isInvalid={!!errors.textarea}
        label="Feedback"
        errorMessage={errors.textarea?.message}
      >
        <Textarea
          autosize
          placeholder="your feedback"
          {...register("textarea", {
            required: { value: true, message: "This is required." },
          })}
        />
      </FormControl>

      // ここのreset()でresizeされない
      <Button type="button" alignSelf="flex-end" onClick={() => reset()}>
        Reset
      </Button>
      <Button type="submit" alignSelf="flex-end">
        Submit
      </Button>
    </VStack>
  )
}

3つの場合にresizeの処理がされるようにしていたのですが、それに該当しない場合のようでした。

  • windowがリサイズされた場合
  • fontがロードされた場合
  • textareaのonChangeがトリガーされた場合

対応

refが変更された場合もresizeの処理がされるようにすればよいだけなのですが、単純にその処理を追加すると、textareaのonChangeでもトリガーされた場合、2回実行されてしまうので、いろいろと試しました。追加でユーザー側でresizeをトリガーできる機能も実装しました。

①refの変更を検知してresize処理を実行する

refの変更を検知してresizeする処理を追加するだけです。
depsにrefの値を直接いれることはできませんが、一度変数に格納すれば可能です。

textarea.tsx
+ const value = textareaRef.current?.value ?? ""
+ useUpdateEffect(() => {
+   if(!autosize) return
+   resizeTextarea()
+ }, [value])

return (
  <ui.textarea
    ref={mergeRefs(ref, textareaRef)}
    className={cx("ui-textarea", className)}
    resize={resize}
    rows={rows}
    __css={css}
    {...rest}
    onChange={handlerAll(autosize ? resizeTextarea : noop, onChange)}
  />
)

ただ、このままではonChangeのほうでもトリガーされるので、1文字入力されるたびにresizeの処理が2回実行されます。
そのため、resizeのロジックの中に中身の変更がある場合だけ、resizeを実行するという条件を追加しました。
valueRefで前回の値を持っておいて、今回の値と変更がなければ何もせずreturnしてます。
これで1回の変更につき、1回リサイズされるようになりました。

use-autosize.ts
const useAutosize = (
  ref: RefObject<HTMLTextAreaElement>,
  maxRows: number,
  minRows: number,
) => {
  const heightRef = useRef(0)
+  const valueRef = useRef<string>()

  const resizeTextarea = () => {
    const el = ref.current
    if (!el) return

    let { value, placeholder, style } = el
+    if (value === valueRef.current) return
+    else valueRef.current = value

    value ??= placeholder ?? "x"

    const nodeSizeData = getSizingData(el)

    if (!nodeSizeData) return

    const height = calcHeight(nodeSizeData, value, maxRows, minRows)

    if (heightRef.current !== height) {
      heightRef.current = height

      style.height = `${height}px`
    }
  }

  return resizeTextarea
}

②resizeをトリガーできる機能の追加

修正というよりは、補完できる機能を追加した感じです。
ユーザー側で強制的にresizeをトリガーできる機能を追加しました。

textarea.tsx
export const Textarea = forwardRef<TextareaProps, "textarea">((props, ref) => {
  const [styles, mergedProps] = useComponentStyle("Textarea", props)
  let {
    className,
    rows,
    resize = "none",
    autosize,
    maxRows = Infinity,
    minRows = 1,
+    resizeRef,
    onChange,
    ...rest
  } = omitThemeProps(mergedProps)

(省略)

+  assignRef(resizeRef, resizeTextarea)

  return (
(省略)

使い方は以下のような感じ。
onResizeを実行すればresize処理が走ります。

export const useResize: Story = () => {
  const resizeRef = useRef<() => void>(null)
  const onResize = () => {
    resizeRef.current?.()
  }

  return (
    <>
      <VStack>
        <Textarea placeholder="use resize" resizeRef={resizeRef} />
        <Button alignSelf="flex-end" onClick={onResize}>
          Resize
        </Button>
      </VStack>
    </>
  )
}

まとめ

resizeをトリガーする機能はまだドキュメントには追記できていないので、これから頑張ります。毎週日月曜日くらいにリリースされるので、次のリリース以降で使えるようになります。
もともとYamada UIのtextareaにはautosizeの機能がなかったのですが、自分が欲しかったので、issueをたててプルリク出して、いろいろ修正してもらって今リリースされているという感じです。
みなさんも欲しい機能があれば、バシバシ要望あげてください!

補足

useSafeLayoutEffect, useUpdateEffect, assignRefはYamada UI独自のものです。
github上で見れますが、一応、実装載せておきます。
useUpdateEffectはレンダリング時の最初の実行をスキップするやつ。

useSafeLayoutEffect
export const useSafeLayoutEffect = Boolean(globalThis?.document)
  ? React.useLayoutEffect
  : React.useEffect
useUpdateEffect
export const useUpdateEffect = (
  callback: React.EffectCallback,
  deps: React.DependencyList,
) => {
  const renderCycleRef = React.useRef(false)
  const effectCycleRef = React.useRef(false)

  React.useEffect(() => {
    const mounted = renderCycleRef.current
    const run = mounted && effectCycleRef.current

    if (run) return callback()

    effectCycleRef.current = true
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps)

  React.useEffect(() => {
    renderCycleRef.current = true

    return () => {
      renderCycleRef.current = false
    }
  }, [])
}
assignRef
export const assignRef = <T extends any = any>(
  ref: ReactRef<T> | undefined,
  value: T,
) => {
  if (ref == null) return

  if (typeof ref === "function") {
    ref(value)

    return
  }

  try {
    // @ts-ignore
    ref.current = value
  } catch (error) {
    throw new Error(`Cannot assign value '${value}' to ref '${ref}'`)
  }
}

こういうのも出てきた。

https://twitter.com/jh3yy/status/1710398436917321799?ref_src=twsrc^tfw|twcamp^tweetembed|twterm^1710398436917321799|twgr^4d2e002dc3247782a9f93854d75468edb9e0f00e|twcon^s1_&ref_url=https%3A%2F%2Fcoliss.com%2Farticles%2Fbuild-websites%2Foperation%2Fcss%2Fauto-resizing-text-input-with-form-sizing.html

Discussion