Yamada UIのtextarea(autosize)について
はじめに
直近でYamada UIのtextareaにautosizeの機能を追加したり、バグの修正をしたり、いろいろといじり倒していたので、記録として残します。
autosize機能とは
textareaの高さを入力した文章の高さに合わせてくれる機能です。フォームで割と必須の機能だと思います。
以下のページで試せます。
autosizeの仕組み
react-textarea-autosizeを参考に実装しています。
簡単に説明すると、計算用の表示されないtextareaを用意して、高さを計算し、表示しているtextareaのほうに反映するという処理をしています。
Yamada UIで実装する際は、resizeの処理はすべてuseAutosize()に格納しました。
返ってくるresizeTextarea()を実行するとresizeの処理がされます。
const resizeTextarea = useAutosize(textareaRef, maxRows, minRows)
バグ対応
報告されたバグ
refで変更をかけてくるような場合に、resizeの処理がされないというものです。
例えば報告にもあるように、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の値を直接いれることはできませんが、一度変数に格納すれば可能です。
+ 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回リサイズされるようになりました。
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をトリガーできる機能を追加しました。
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}'`)
}
}
こういうのも出てきた。
Discussion