import { useCallback, useEffect, useId, useLayoutEffect, useRef } from 'react';
import type { ChangeEvent } from 'react';
import { SPRING, Spring, createLoop } from '../shared/spring';
import { resolveAccent, type Accent } from '../shared/accent';
import { RollingNumber } from '../shared/RollingNumber';
import { useControllable, useReducedMotion } from '../forms-kit/kit';
import '../shared/ember.css';
import './textarea.css';
export interface TextareaProps {
label?: string;
placeholder?: string;
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
minRows?: number;
maxRows?: number;
/** Shows a budget line and a counter. Typing past it is allowed and marked invalid. */
maxLength?: number;
disabled?: boolean;
accent?: Accent;
className?: string;
}
const LINE = 20; // line height
const PAD_Y = 10;
const BORDER = 0; // the shell is a borderless fill
const boxFor = (rows: number) => rows * LINE + 2 * PAD_Y + 2 * BORDER;
export function Textarea({
label = 'Message',
placeholder = 'Write something',
value,
defaultValue = '',
onChange,
minRows = 3,
maxRows = 8,
maxLength,
disabled = false,
accent,
className,
}: TextareaProps) {
const id = useId();
const helpId = `${id}-help`;
const [text, setText] = useControllable(value, defaultValue, onChange);
const reduced = useReducedMotion();
const lo = Math.max(1, Math.round(minRows));
const hi = Math.max(lo, Math.round(maxRows));
const fieldRef = useRef<HTMLDivElement>(null);
const areaRef = useRef<HTMLTextAreaElement>(null);
const mirrorRef = useRef<HTMLTextAreaElement>(null);
const fillRef = useRef<HTMLSpanElement>(null);
const sim = useRef({
h: new Spring(boxFor(lo), SPRING.snappy),
fill: new Spring(0, SPRING.snappy),
loop: null as ReturnType<typeof createLoop> | null,
first: true,
fillShown: false,
reduced: false,
});
sim.current.reduced = reduced;
const paint = useCallback(() => {
const s = sim.current;
const field = fieldRef.current;
if (field) field.style.height = `${s.h.value.toFixed(2)}px`;
if (fillRef.current) fillRef.current.style.width = `${(Math.max(0, Math.min(1, s.fill.value)) * 100).toFixed(3)}%`;
}, []);
// Created in a layout effect so mount-time layout effects below can already wake it.
useLayoutEffect(() => {
const s = sim.current;
s.loop = createLoop((dt) => {
const a = s.h.step(dt);
const b = s.fill.step(dt);
paint();
return a || b;
});
return () => s.loop?.stop();
}, [paint]);
/** Measure the content with an off-screen twin, then spring the box toward it. */
const measure = useCallback(() => {
const s = sim.current;
const mirror = mirrorRef.current;
const area = areaRef.current;
const field = fieldRef.current;
if (!mirror || !area || !field) return;
mirror.style.width = `${field.clientWidth}px`;
mirror.value = area.value || ' ';
const content = mirror.scrollHeight + 2 * BORDER;
const minH = boxFor(lo);
const maxH = boxFor(hi);
const target = Math.min(maxH, Math.max(minH, content));
area.style.height = `${target - 2 * BORDER}px`;
area.style.overflowY = content > maxH + 0.5 ? 'auto' : 'hidden';
if (s.first || s.reduced) {
s.first = false;
s.h.set(target);
paint();
return;
}
if (target !== s.h.target) {
s.h.target = target;
s.loop?.wake();
}
}, [lo, hi, paint]);
useLayoutEffect(measure, [text, measure]);
// Wrapping changes with width.
useEffect(() => {
const field = fieldRef.current;
if (!field || typeof ResizeObserver === 'undefined') return;
let w = field.clientWidth;
const ro = new ResizeObserver(() => {
if (field.clientWidth === w) return;
w = field.clientWidth;
measure();
});
ro.observe(field);
return () => ro.disconnect();
}, [measure]);
// Fonts arriving change line breaks.
useEffect(() => {
document.fonts?.ready.then(() => measure()).catch(() => {});
}, [measure]);
/* ---------------------------------------------------------------- budget */
const count = Array.from(text).length;
const budget = maxLength !== undefined && maxLength > 0 ? maxLength : null;
const ratio = budget ? count / budget : 0;
const over = budget !== null && count > budget;
const tone = !budget ? 'none' : over ? 'over' : ratio > 0.9 ? 'hot' : 'rest';
useLayoutEffect(() => {
const s = sim.current;
const target = budget ? Math.min(1, ratio) : 0;
if (s.reduced || !budget || !s.fillShown) {
s.fillShown = true;
s.fill.set(target);
paint();
return;
}
s.fill.target = target;
s.loop?.wake();
}, [ratio, budget, paint]);
const onInput = (e: ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value);
return (
<div
className={`ember em-ta${disabled ? ' is-disabled' : ''}${className ? ` ${className}` : ''}`}
data-accent={resolveAccent(accent)}
data-tone={tone}
>
<label className="em-label" htmlFor={id}>
{label}
</label>
<div ref={fieldRef} className="em-field em-ta__field" style={{ height: boxFor(lo) }}>
<textarea
ref={areaRef}
id={id}
className="em-ta__area"
value={text}
placeholder={placeholder}
rows={lo}
disabled={disabled}
aria-invalid={over || undefined}
aria-describedby={budget ? helpId : undefined}
onChange={onInput}
/>
</div>
<textarea ref={mirrorRef} className="em-ta__area em-ta__mirror" rows={1} tabIndex={-1} aria-hidden="true" readOnly />
<div className="em-help em-ta__help" id={helpId}>
{budget ? (
<span className="em-ta__track" aria-hidden="true">
<span ref={fillRef} className="em-ta__fill" />
</span>
) : null}
{budget ? (
<span className="em-ta__count">
<RollingNumber value={String(count)} reduced={reduced} />
<span className="em-ta__of"> / {budget}</span>
{over ? <span className="em-sr"> characters, over the limit</span> : <span className="em-sr"> characters</span>}
</span>
) : null}
</div>
</div>
);
}
export default Textarea;