mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(editor): Add adaptive layout to ChatInput (#36939)
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: kgrhartlage <kai.hartlage@n8n.io>
This commit is contained in:
+16
-1
@@ -18,7 +18,7 @@ export default {
|
||||
},
|
||||
layout: {
|
||||
control: 'select',
|
||||
options: ['single-line', 'multiline'],
|
||||
options: ['single-line', 'multiline', 'adaptive'],
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
@@ -122,6 +122,21 @@ MultiLine.args = {
|
||||
maxLength: 1000,
|
||||
};
|
||||
|
||||
export const Adaptive = Template.bind({});
|
||||
Adaptive.args = {
|
||||
placeholder: 'Starts as one line and grows with your text...',
|
||||
maxLength: 1000,
|
||||
layout: 'adaptive',
|
||||
};
|
||||
Adaptive.parameters = {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Adaptive supports the default icon-only send/stop button. A custom button label or action slot falls back to the multiline layout.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const workflowSuggestions: WorkflowSuggestion[] = [
|
||||
{
|
||||
id: 'invoice-pipeline',
|
||||
|
||||
@@ -2,6 +2,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
|
||||
import N8nChatInput from './ChatInput.vue';
|
||||
import { createComponentRenderer } from '../../__tests__/render';
|
||||
@@ -177,6 +178,254 @@ describe('N8nChatInput', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('adaptive layout', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not reserve the multiline minimum height', () => {
|
||||
const { container } = renderComponent({
|
||||
props: {
|
||||
layout: 'adaptive',
|
||||
},
|
||||
global: {
|
||||
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
|
||||
},
|
||||
});
|
||||
|
||||
const chatContainer = container.querySelector('.container') as HTMLElement;
|
||||
expect(chatContainer.style.minHeight).toBe('');
|
||||
expect(chatContainer.classList.toString()).toContain('adaptiveContainer');
|
||||
});
|
||||
|
||||
it('keeps the multiline minimum height for the default layout', () => {
|
||||
const { container } = renderComponent({
|
||||
global: {
|
||||
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
|
||||
},
|
||||
});
|
||||
|
||||
const chatContainer = container.querySelector('.container') as HTMLElement;
|
||||
expect(chatContainer.style.minHeight).toBe('80px');
|
||||
});
|
||||
|
||||
it('falls back to multiline for a custom send button label', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { container } = renderComponent({
|
||||
props: {
|
||||
layout: 'adaptive',
|
||||
buttonLabel: 'Send message',
|
||||
},
|
||||
global: {
|
||||
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
|
||||
},
|
||||
});
|
||||
|
||||
const chatContainer = container.querySelector('.container') as HTMLElement;
|
||||
expect(chatContainer.style.minHeight).toBe('80px');
|
||||
expect(chatContainer.classList.toString()).not.toContain('adaptiveContainer');
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Falling back to `multiline`'));
|
||||
});
|
||||
|
||||
it.each(['left-actions', 'actions', 'extra-actions', 'right-actions'])(
|
||||
'falls back to multiline for the %s slot',
|
||||
(slotName) => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { container } = renderComponent({
|
||||
props: {
|
||||
layout: 'adaptive',
|
||||
},
|
||||
slots: {
|
||||
[slotName]: '<button>Custom action</button>',
|
||||
},
|
||||
global: {
|
||||
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
|
||||
},
|
||||
});
|
||||
|
||||
const chatContainer = container.querySelector('.container') as HTMLElement;
|
||||
expect(chatContainer.style.minHeight).toBe('80px');
|
||||
expect(chatContainer.classList.toString()).not.toContain('adaptiveContainer');
|
||||
expect(container).toHaveTextContent('Custom action');
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Falling back to `multiline`'));
|
||||
},
|
||||
);
|
||||
|
||||
it('falls back to multiline when an action slot appears after mount', async () => {
|
||||
const showAction = ref(false);
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
N8nChatInput,
|
||||
{ layout: 'adaptive' },
|
||||
showAction.value ? { 'right-actions': () => h('button', 'Custom action') } : {},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'] },
|
||||
});
|
||||
const chatContainer = wrapper.find('.container');
|
||||
expect(chatContainer.classes().join(' ')).toContain('adaptiveContainer');
|
||||
|
||||
showAction.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('.container').classes().join(' ')).not.toContain('adaptiveContainer');
|
||||
expect(wrapper.find('.container').attributes('style')).toContain('min-height: 80px');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('remeasures the height when a slot flips the effective layout', async () => {
|
||||
// Adaptive and multiline use different textarea padding, so identical content
|
||||
// measures differently; stand in for that with a mutable measurement.
|
||||
let measured = 32;
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
'scrollHeight',
|
||||
);
|
||||
Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => measured,
|
||||
});
|
||||
|
||||
try {
|
||||
const showAction = ref(false);
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
N8nChatInput,
|
||||
{ layout: 'adaptive', modelValue: 'hello' },
|
||||
showAction.value ? { 'right-actions': () => h('button', 'Action') } : {},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'] },
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(wrapper.find('textarea').attributes('style')).toContain('height: 32px'),
|
||||
);
|
||||
|
||||
measured = 36;
|
||||
showAction.value = true;
|
||||
await nextTick();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(wrapper.find('textarea').attributes('style')).toContain('height: 36px'),
|
||||
);
|
||||
wrapper.unmount();
|
||||
} finally {
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollHeight', originalDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLTextAreaElement.prototype, 'scrollHeight');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('remeasures the height when the layout prop changes', async () => {
|
||||
let measured = 32;
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
'scrollHeight',
|
||||
);
|
||||
Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => measured,
|
||||
});
|
||||
|
||||
try {
|
||||
const wrapper = mount(N8nChatInput, {
|
||||
attachTo: document.body,
|
||||
props: { layout: 'adaptive', modelValue: 'hello' },
|
||||
global: { stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'] },
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(wrapper.find('textarea').attributes('style')).toContain('height: 32px'),
|
||||
);
|
||||
|
||||
measured = 36;
|
||||
await wrapper.setProps({ layout: 'multiline' });
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(wrapper.find('textarea').attributes('style')).toContain('height: 36px'),
|
||||
);
|
||||
wrapper.unmount();
|
||||
} finally {
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollHeight', originalDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLTextAreaElement.prototype, 'scrollHeight');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('autosizes the textarea like multiline', async () => {
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
'scrollHeight',
|
||||
);
|
||||
Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get(this: HTMLTextAreaElement) {
|
||||
return this.value?.includes('\n') ? 72 : 24;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { container } = renderComponent({
|
||||
props: {
|
||||
layout: 'adaptive',
|
||||
modelValue: '',
|
||||
},
|
||||
global: {
|
||||
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
|
||||
},
|
||||
});
|
||||
|
||||
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
textarea.value = 'Line 1\nLine 2\nLine 3';
|
||||
await fireEvent.input(textarea);
|
||||
|
||||
await vi.waitFor(() => expect(textarea.getAttribute('style')).toContain('height: 72px'));
|
||||
} finally {
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollHeight', originalDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLTextAreaElement.prototype, 'scrollHeight');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('submits on Enter and inserts a newline on Shift+Enter, like multiline', async () => {
|
||||
const render = renderComponent({
|
||||
props: {
|
||||
layout: 'adaptive',
|
||||
modelValue: 'Test message',
|
||||
},
|
||||
global: {
|
||||
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
|
||||
},
|
||||
});
|
||||
|
||||
const textarea = render.container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
|
||||
await fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true });
|
||||
expect(render.emitted('submit')).toBeFalsy();
|
||||
expect(render.emitted('update:modelValue')).toBeTruthy();
|
||||
|
||||
await fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||
expect(render.emitted('submit')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('character limit', () => {
|
||||
it('should show warning banner when at character limit', () => {
|
||||
const { container } = renderComponent({
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUpdated,
|
||||
ref,
|
||||
toRef,
|
||||
useSlots,
|
||||
watch,
|
||||
watchEffect,
|
||||
} from 'vue';
|
||||
|
||||
import { useAutosizeTextarea } from '../../composables/useAutosizeTextarea';
|
||||
import { useCharacterLimit } from '../../composables/useCharacterLimit';
|
||||
@@ -30,7 +40,17 @@ export interface N8nChatInputProps {
|
||||
refocusAfterSend?: boolean;
|
||||
autofocus?: boolean;
|
||||
buttonLabel?: string;
|
||||
layout?: 'multiline' | 'single-line';
|
||||
/**
|
||||
* - 'multiline': the tall composer — a fixed minimum height with the actions on
|
||||
* their own bottom row.
|
||||
* - 'single-line': one fixed row; Enter always submits.
|
||||
* - 'adaptive': starts as a single row with the send button inline and grows
|
||||
* with the content, up to `maxLinesBeforeScroll`. Keyboard behavior matches
|
||||
* 'multiline' (Shift+Enter inserts a newline). Supports only the default
|
||||
* icon-only send/stop button; custom labels or action slots fall back to
|
||||
* 'multiline'.
|
||||
*/
|
||||
layout?: 'multiline' | 'single-line' | 'adaptive';
|
||||
autosize?: boolean | { minRows: number; maxRows: number };
|
||||
submitDisabled?: boolean;
|
||||
sendButtonTestId?: string;
|
||||
@@ -69,11 +89,40 @@ const emit = defineEmits<{
|
||||
'upgrade-click': [];
|
||||
}>();
|
||||
|
||||
const slots = useSlots();
|
||||
const { t } = useI18n();
|
||||
|
||||
const textareaRef = ref<HTMLTextAreaElement>();
|
||||
const isFocused = ref(false);
|
||||
const textValue = ref(props.modelValue || '');
|
||||
function hasCustomActions() {
|
||||
return Boolean(
|
||||
props.buttonLabel ||
|
||||
slots['left-actions'] ||
|
||||
slots.actions ||
|
||||
slots['extra-actions'] ||
|
||||
slots['right-actions'],
|
||||
);
|
||||
}
|
||||
|
||||
// Read fresh on every use instead of via a computed: slots are not reactive, so a
|
||||
// computed would cache the first result and miss action slots that appear later.
|
||||
function effectiveLayout() {
|
||||
return props.layout === 'adaptive' && hasCustomActions() ? 'multiline' : props.layout;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
watchEffect(() => {
|
||||
if (props.layout === 'adaptive' && hasCustomActions()) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[N8nChatInput] `layout="adaptive"` supports only the default icon-only send/stop button. ' +
|
||||
'Falling back to `multiline` because a custom button label or action slot was provided.',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const autosizeRows = computed(() =>
|
||||
typeof props.autosize === 'object'
|
||||
? props.autosize
|
||||
@@ -104,9 +153,11 @@ const sendDisabled = computed(
|
||||
props.creditsRemaining === 0),
|
||||
);
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
return props.layout === 'single-line' ? undefined : { minHeight: '80px' };
|
||||
});
|
||||
// Only the classic multiline composer reserves the tall block; 'adaptive' starts
|
||||
// at one row and lets the autosized textarea drive the height.
|
||||
function containerStyle() {
|
||||
return effectiveLayout() === 'multiline' ? { minHeight: '80px' } : undefined;
|
||||
}
|
||||
|
||||
const hasNoCredits = computed(() => {
|
||||
return (
|
||||
@@ -133,7 +184,7 @@ watch(
|
||||
async (newValue) => {
|
||||
textValue.value = newValue || '';
|
||||
await nextTick();
|
||||
if (props.layout === 'single-line' || props.autosize === false) return;
|
||||
if (!isAutosizeEnabled.value) return;
|
||||
|
||||
// Wait for an additional animation frame to ensure DOM has fully updated
|
||||
await new Promise(requestAnimationFrame);
|
||||
@@ -141,19 +192,10 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
watch([() => props.layout, () => props.autosize], ([layout, autosize]) => {
|
||||
if (layout === 'single-line' || autosize === false) {
|
||||
clearTextareaHeight();
|
||||
return;
|
||||
}
|
||||
|
||||
void nextTick(() => adjustHeight());
|
||||
});
|
||||
|
||||
watch(textValue, (newValue, oldValue) => {
|
||||
emit('update:modelValue', newValue);
|
||||
// Single-line layout has fixed height; only multiline needs autosizing.
|
||||
if (props.layout === 'single-line' || props.autosize === false) return;
|
||||
if (!isAutosizeEnabled.value) return;
|
||||
|
||||
// Only adjust height if value actually changed
|
||||
if (newValue !== oldValue) {
|
||||
@@ -182,7 +224,7 @@ async function handleStop() {
|
||||
}
|
||||
|
||||
async function handleKeyDown(event: KeyboardEvent) {
|
||||
if (props.layout === 'single-line' && event.key === 'Enter' && !event.isComposing) {
|
||||
if (effectiveLayout() === 'single-line' && event.key === 'Enter' && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
if (!sendDisabled.value) {
|
||||
await handleSubmit();
|
||||
@@ -250,6 +292,17 @@ function focusInput(options?: FocusOptions) {
|
||||
textareaRef.value?.focus(options);
|
||||
}
|
||||
|
||||
// Each layout measures to a different height, so switching leaves a stale one.
|
||||
// Slot-driven switches aren't reactive, so re-check on render, not in a watcher.
|
||||
let renderedLayout = effectiveLayout();
|
||||
onUpdated(() => {
|
||||
const currentLayout = effectiveLayout();
|
||||
if (currentLayout === renderedLayout) return;
|
||||
|
||||
renderedLayout = currentLayout;
|
||||
adjustHeight();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// Adjust height on mount to respect initial content
|
||||
void nextTick(() => adjustHeight());
|
||||
@@ -273,10 +326,11 @@ defineExpose({
|
||||
{
|
||||
[$style.focused]: isFocused,
|
||||
[$style.disabled]: disabled || hasNoCredits,
|
||||
[$style.singleLineContainer]: layout === 'single-line',
|
||||
[$style.singleLineContainer]: effectiveLayout() === 'single-line',
|
||||
[$style.adaptiveContainer]: effectiveLayout() === 'adaptive',
|
||||
},
|
||||
]"
|
||||
:style="containerStyle"
|
||||
:style="containerStyle()"
|
||||
@click.self="handleContainerClick"
|
||||
>
|
||||
<slot name="leading" />
|
||||
@@ -290,7 +344,10 @@ defineExpose({
|
||||
v-model="textValue"
|
||||
:class="[
|
||||
$style.textarea,
|
||||
{ [$style.singleLineTextarea]: layout === 'single-line' },
|
||||
{
|
||||
[$style.singleLineTextarea]: effectiveLayout() === 'single-line',
|
||||
[$style.adaptiveTextarea]: effectiveLayout() === 'adaptive',
|
||||
},
|
||||
'ignore-key-press-node-creator',
|
||||
'ignore-key-press-canvas',
|
||||
]"
|
||||
@@ -301,11 +358,17 @@ defineExpose({
|
||||
@keydown="handleKeyDown"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@input="layout === 'single-line' || autosize === false ? undefined : adjustHeight"
|
||||
@input="isAutosizeEnabled ? adjustHeight : undefined"
|
||||
@click="handleFocusableRegionClick"
|
||||
/>
|
||||
<div
|
||||
:class="[$style.bottomActions, { [$style.singleLineActions]: layout === 'single-line' }]"
|
||||
:class="[
|
||||
$style.bottomActions,
|
||||
{
|
||||
[$style.singleLineActions]: effectiveLayout() === 'single-line',
|
||||
[$style.adaptiveActions]: effectiveLayout() === 'adaptive',
|
||||
},
|
||||
]"
|
||||
@click="handleFocusableRegionClick"
|
||||
>
|
||||
<div
|
||||
@@ -337,7 +400,7 @@ defineExpose({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.trailing && layout !== 'single-line'" :class="$style.trailing">
|
||||
<div v-if="$slots.trailing && effectiveLayout() !== 'single-line'" :class="$style.trailing">
|
||||
<slot name="trailing" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -386,6 +449,18 @@ defineExpose({
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Adaptive: one visual row that grows with the autosized textarea. The send button
|
||||
is pinned to the bottom-right corner instead of holding a dedicated actions row,
|
||||
so the textarea height is the only thing that moves while typing — the button
|
||||
just rides the bottom edge. See the overrides next to the single-line ones at
|
||||
the end of this stylesheet. */
|
||||
.adaptiveContainer {
|
||||
position: relative;
|
||||
/* The pinned button is taller than one line of text; without this floor it
|
||||
would poke out of the single-row state. */
|
||||
min-height: calc(var(--height--md) + 2 * var(--spacing--2xs));
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
border: none;
|
||||
@@ -458,6 +533,23 @@ defineExpose({
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Placed after `.textarea`, whose `padding` shorthand would otherwise win the tie. */
|
||||
.adaptiveTextarea {
|
||||
/* Reserve the pinned button's column at every height: a width that differed
|
||||
between the compact and grown states would re-wrap the text and make the
|
||||
two states oscillate. */
|
||||
padding-right: calc(var(--height--md) + var(--spacing--3xs));
|
||||
/* A 24px text line plus 4px above and below matches the 32px send button,
|
||||
leaving the same 8px inset on every side in the compact state. */
|
||||
padding-block: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.adaptiveActions {
|
||||
position: absolute;
|
||||
right: var(--spacing--2xs);
|
||||
bottom: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.leading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -102,6 +102,7 @@ describe('useAutosizeTextarea', () => {
|
||||
const TestComponent = defineComponent({
|
||||
props: {
|
||||
enabled: { type: Boolean, default: true },
|
||||
content: { type: String, default: 'one\ntwo\nthree' },
|
||||
},
|
||||
setup(props) {
|
||||
const textarea = ref<HTMLTextAreaElement>();
|
||||
@@ -114,7 +115,7 @@ describe('useAutosizeTextarea', () => {
|
||||
|
||||
return { textarea, textareaStyles, calculateTextareaHeight, clearTextareaHeight };
|
||||
},
|
||||
template: '<textarea ref="textarea" value="one\ntwo\nthree" />',
|
||||
template: '<textarea ref="textarea" :value="content" />',
|
||||
});
|
||||
|
||||
it('recalculates height into reactive styles', () => {
|
||||
@@ -151,6 +152,23 @@ describe('useAutosizeTextarea', () => {
|
||||
expect(textarea.scrollTop).toBe(textarea.scrollHeight);
|
||||
});
|
||||
|
||||
it('does not scroll a textarea whose content still fits within maxRows', async () => {
|
||||
const wrapper = mount(TestComponent, {
|
||||
attachTo: document.body,
|
||||
props: { content: 'one\ntwo' },
|
||||
});
|
||||
const textarea = wrapper.vm.textarea as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
|
||||
textarea.scrollTop = 0;
|
||||
|
||||
wrapper.vm.calculateTextareaHeight();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.textareaStyles.overflowY).toBe('hidden');
|
||||
expect(textarea.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it('preserves the scroll position when the caret is mid-content', async () => {
|
||||
const wrapper = mount(TestComponent, { attachTo: document.body });
|
||||
const textarea = wrapper.vm.textarea as HTMLTextAreaElement;
|
||||
|
||||
@@ -143,7 +143,10 @@ export function useAutosizeTextarea({
|
||||
if (!toValue(enabled) || !textareaElement) return;
|
||||
|
||||
textareaStyles.value = calcTextareaHeight(textareaElement, toValue(rows));
|
||||
void scrollCaretIntoView();
|
||||
// Only scroll when the content genuinely exceeds maxRows.
|
||||
if (textareaStyles.value.overflowY === 'auto') {
|
||||
void scrollCaretIntoView();
|
||||
}
|
||||
};
|
||||
|
||||
function clearTextareaHeight() {
|
||||
|
||||
Reference in New Issue
Block a user