feat(vue): expand @robonen/vue composable collection
Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import type { MaybeRef, MultiWatchSources, Ref, WatchSource } from 'vue';
|
||||
import { nextTick, toRef, toValue, watch } from 'vue';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { defaultWindow } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { useResizeObserver } from '@/composables/elements/useResizeObserver';
|
||||
|
||||
export interface UseTextareaAutosizeOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* The textarea element to autosize. May also be bound via the returned `textarea` ref
|
||||
*/
|
||||
element?: MaybeRef<HTMLTextAreaElement | undefined | null>;
|
||||
|
||||
/**
|
||||
* The textarea content. May also be bound via the returned `input` ref
|
||||
*/
|
||||
input?: MaybeRef<string>;
|
||||
|
||||
/**
|
||||
* Cap the resulting height (in pixels). Beyond this the textarea scrolls
|
||||
*/
|
||||
maxHeight?: number;
|
||||
|
||||
/**
|
||||
* Additional reactive sources that should trigger a resize when they change
|
||||
*/
|
||||
watch?: WatchSource | MultiWatchSources;
|
||||
|
||||
/**
|
||||
* Invoked after each resize once the textarea height settles
|
||||
*
|
||||
* @default noop
|
||||
*/
|
||||
onResize?: VoidFunction;
|
||||
|
||||
/**
|
||||
* Apply the computed height to this element instead of the textarea itself.
|
||||
* Useful when the textarea is wrapped (e.g. for a CSS grid auto-grow trick)
|
||||
*/
|
||||
styleTarget?: MaybeRef<HTMLElement | undefined | null>;
|
||||
|
||||
/**
|
||||
* Which style property carries the computed height
|
||||
*
|
||||
* @default 'height'
|
||||
*/
|
||||
styleProp?: 'height' | 'minHeight';
|
||||
}
|
||||
|
||||
export interface UseTextareaAutosizeReturn {
|
||||
/**
|
||||
* The textarea element being autosized. Bind it with `ref` in the template
|
||||
*/
|
||||
textarea: Ref<HTMLTextAreaElement | undefined | null>;
|
||||
|
||||
/**
|
||||
* Two-way bound textarea content
|
||||
*/
|
||||
input: Ref<string>;
|
||||
|
||||
/**
|
||||
* Force a resize on demand (e.g. after a programmatic value change)
|
||||
*/
|
||||
triggerResize: VoidFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useTextareaAutosize
|
||||
* @category Browser
|
||||
* @description Auto-resizes a `<textarea>` to fit its content. Reacts to user
|
||||
* input, programmatic content changes, and element resize. Reuses
|
||||
* `useEventListener` for a passive, auto-cleaned `input` listener and
|
||||
* `useResizeObserver` to re-measure when the textarea's width changes (so
|
||||
* reflowed text is re-fitted). SSR safe.
|
||||
*
|
||||
* @param {UseTextareaAutosizeOptions} [options={}] Options
|
||||
* @returns {UseTextareaAutosizeReturn} `textarea`, `input`, and `triggerResize`
|
||||
*
|
||||
* @example
|
||||
* const { textarea, input } = useTextareaAutosize();
|
||||
* // <textarea ref="textarea" v-model="input" />
|
||||
*
|
||||
* @example
|
||||
* const { textarea, input, triggerResize } = useTextareaAutosize({ maxHeight: 320 });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useTextareaAutosize(options: UseTextareaAutosizeOptions = {}): UseTextareaAutosizeReturn {
|
||||
const {
|
||||
window = defaultWindow,
|
||||
maxHeight,
|
||||
styleProp = 'height',
|
||||
onResize = noop,
|
||||
} = options;
|
||||
|
||||
const textarea = toRef(options.element) as Ref<HTMLTextAreaElement | undefined | null>;
|
||||
const input = toRef(options.input ?? '') as Ref<string>;
|
||||
|
||||
// Cached, non-reactive — only used to skip redundant resizes on height-only
|
||||
// ResizeObserver callbacks (we resize on width changes, not our own height edits)
|
||||
let lastWidth = -1;
|
||||
let lastScrollHeight = -1;
|
||||
|
||||
const triggerResize: VoidFunction = () => {
|
||||
const el = textarea.value;
|
||||
|
||||
if (!el)
|
||||
return;
|
||||
|
||||
const target = (toValue(options.styleTarget) ?? el) as HTMLElement;
|
||||
|
||||
// Collapse to a single line so scrollHeight reports the true content height
|
||||
el.style[styleProp] = '1px';
|
||||
|
||||
const scrollHeight = el.scrollHeight;
|
||||
const height = maxHeight !== undefined ? Math.min(scrollHeight, maxHeight) : scrollHeight;
|
||||
|
||||
if (target === el)
|
||||
el.style[styleProp] = `${height}px`;
|
||||
else {
|
||||
// Restore the textarea to its natural value and size the wrapper instead
|
||||
el.style[styleProp] = '';
|
||||
target.style[styleProp] = `${height}px`;
|
||||
}
|
||||
|
||||
if (scrollHeight !== lastScrollHeight) {
|
||||
lastScrollHeight = scrollHeight;
|
||||
onResize();
|
||||
}
|
||||
};
|
||||
|
||||
// React to programmatic content changes and to the element binding itself.
|
||||
// flush 'post' guarantees the DOM (and v-model'd value) is updated before we measure
|
||||
watch(
|
||||
[input, textarea],
|
||||
() => nextTick(triggerResize),
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
// React to direct user typing without waiting for the bound model to round-trip.
|
||||
// Passive: we never call preventDefault here
|
||||
useEventListener(textarea, 'input', triggerResize, { passive: true });
|
||||
|
||||
// React to width changes (font/box reflow). Ignore pure height deltas — those
|
||||
// are usually our own edits and would otherwise loop
|
||||
useResizeObserver(textarea, (entries) => {
|
||||
const entry = entries[0];
|
||||
|
||||
if (!entry)
|
||||
return;
|
||||
|
||||
const width = entry.contentRect.width;
|
||||
|
||||
if (width === lastWidth)
|
||||
return;
|
||||
|
||||
const run = () => {
|
||||
lastWidth = width;
|
||||
triggerResize();
|
||||
};
|
||||
|
||||
if (window && typeof window.requestAnimationFrame === 'function')
|
||||
window.requestAnimationFrame(run);
|
||||
else
|
||||
run();
|
||||
});
|
||||
|
||||
if (options.watch)
|
||||
watch(options.watch, triggerResize, { immediate: true, deep: true });
|
||||
|
||||
return {
|
||||
textarea,
|
||||
input,
|
||||
triggerResize,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user