Skip to content

Commit

Permalink
Refactor vaTextarea autosize calculations to use scrollHeight instead…
Browse files Browse the repository at this point in the history
… of newline characters (#4146)

* Fix vaTextarea not calculating autosizing properly with wrapped lines

* fix: correct textarea autosize jumps and incorrect line height

* chore: remove unused code

---------

Co-authored-by: Maksim Nedoshev <m0ksem1337@gmail.com>
  • Loading branch information
speedpro and m0ksem committed Mar 14, 2024
1 parent 7ce4bce commit 293c1bc
Show file tree
Hide file tree
Showing 3 changed files with 147 additions and 33 deletions.
122 changes: 92 additions & 30 deletions packages/ui/src/components/va-textarea/VaTextarea.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,24 @@
:error="computedError"
:error-messages="computedErrorMessages"
>
<div class="va-textarea__resize-wrapper" :class="{
'va-textarea__resize-wrapper--resizable': isResizable,
}">
<div
class="va-textarea__resize-wrapper"
:class="{
'va-textarea__resize-wrapper--resizable': isResizable,
}"
>
<textarea
v-model="valueComputed"
v-bind="{ ...computedProps, ...listeners, ...validationAriaAttributes }"
class="va-textarea__textarea"
ref="textarea"
:rows="rows"
:style="computedStyle"
:rows="computedRowsCount"
:loading="isLoading"
ref="textarea"
:ariaLabel="$props.label"
class="va-textarea__textarea"
:class="{
'va-textarea__textarea--autosize': autosize,
}"
@focus="validationListeners.onFocus"
@blur="validationListeners.onBlur"
/>
Expand All @@ -25,30 +31,52 @@
</template>

<script lang="ts">
import { computed, CSSProperties, shallowRef } from 'vue'
import {
computed,
CSSProperties,
shallowRef,
ref,
watchEffect,
} from 'vue'
import pick from 'lodash/pick.js'
import { VaInputWrapper } from '../va-input-wrapper'
import { useFormFieldProps, useEmitProxy, useStateful, useStatefulProps, useValidation, useValidationProps, useValidationEmits } from '../../composables'
import { extractComponentProps, filterComponentProps } from '../../utils/component-options'
import {
useFormFieldProps,
useEmitProxy,
useStateful,
useStatefulProps,
useValidation,
useValidationProps,
useValidationEmits,
} from '../../composables'
import {
extractComponentProps,
filterComponentProps,
} from '../../utils/component-options'
import { blurElement, focusElement } from '../../utils/focus'
import { useTextHeight } from './composables/useLineHeight'
const positiveNumberValidator = (val: number) => {
if (val > 0) {
return true
}
throw new Error(`\`minRows|maxRows\` must be a positive integer greater than 0, but ${val} is provided`)
throw new Error(
`\`minRows|maxRows\` must be a positive integer greater than 0, but ${val} is provided`,
)
}
const { createEmits, createListeners } = useEmitProxy([
'input', 'change', 'click', 'update:modelValue',
'input',
'change',
'click',
'update:modelValue',
])
const VaInputWrapperProps = extractComponentProps(VaInputWrapper)
</script>

<script lang="ts" setup>
defineOptions({
name: 'VaTextarea',
})
Expand Down Expand Up @@ -95,11 +123,12 @@ const blur = () => {
blurElement(textarea.value)
}
const reset = () => withoutValidation(() => {
emit('update:modelValue', props.clearValue)
emit('clear')
resetValidation()
})
const reset = () =>
withoutValidation(() => {
emit('update:modelValue', props.clearValue)
emit('clear')
resetValidation()
})
const {
isDirty,
Expand All @@ -120,23 +149,54 @@ const isResizable = computed(() => {
return props.resize && !props.autosize
})
const computedRowsCount = computed<number | undefined>(() => {
const rows = ref(props.minRows)
const textHeight = useTextHeight(textarea, valueComputed)
function calculateInputHeight () {
let minRows = parseFloat(String(props.minRows))
let maxRows = parseFloat(String(props.maxRows))
minRows = isNaN(minRows) ? 1 : minRows
maxRows = isNaN(maxRows) ? Infinity : maxRows
if (!props.autosize) {
return undefined
rows.value = Math.max(maxRows, Math.min(minRows, maxRows ?? 0))
return
}
const rows = valueComputed.value ? valueComputed.value.toString().split('\n').length : 1
if (!props.maxRows) {
return rows
if (!textHeight.value || !textarea.value) {
return
}
return Math.max(Number(props.minRows), Math.min(rows, Number(props.maxRows)))
const style = getComputedStyle(textarea.value)
const height = textHeight.value
const lineHeight = parseFloat(style.lineHeight)
const minHeight = Math.max(
minRows * lineHeight,
minRows + Math.round(lineHeight),
)
const maxHeight = maxRows * lineHeight || Infinity
const newHeight = Math.max(minHeight, Math.min(maxHeight, height ?? 0))
rows.value = Math.round(newHeight / lineHeight)
// Make height 1px bigger to prevent jumps
textarea.value.style.height = `${newHeight + 1}px`
}
watchEffect(() => {
calculateInputHeight()
})
const computedStyle = computed(() => (({
resize: isResizable.value ? undefined : 'none',
}) as CSSProperties))
const computedStyle = computed(
() =>
({
resize: isResizable.value ? undefined : 'none',
} as CSSProperties),
)
const computedProps = computed(() => ({
...pick(props, ['disabled', 'readonly', 'placeholder', 'ariaLabel']),
Expand All @@ -160,7 +220,7 @@ defineExpose({
</script>

<style lang="scss">
@import '../../styles/resources/index.scss';
@import "../../styles/resources/index.scss";
.va-textarea {
.va-input-wrapper__field {
Expand All @@ -185,8 +245,6 @@ defineExpose({
flex: 1;
font-family: var(--va-font-family);
width: 100%;
padding: 1px 0;
margin: -1px 0;
background: transparent;
color: currentColor;
box-sizing: content-box;
Expand All @@ -196,6 +254,10 @@ defineExpose({
resize: none;
@include va-scroll(var(--va-secondary));
&--autosize {
overflow: hidden;
}
}
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Ref, ref, watch } from 'vue'
import { useResizeObserver } from '../../../composables'

const makeTextElement = (textarea: HTMLTextAreaElement) => {
const div = document.createElement('div')
div.style.position = 'absolute'
div.style.top = '0'
div.style.left = '0'
div.style.width = 'auto'
const { font } = window.getComputedStyle(textarea)
div.style.font = font
div.textContent = 'Vuestic'
div.style.zIndex = '-1'
div.style.pointerEvents = 'none'
div.style.opacity = '0'
div.ariaHidden = 'true'
div.innerText = textarea.value

return div
}

/**
* Used to get textarea textHeight. Uses resizeObserver and fake div, because
* font family may be loaded after the component is mounted.
*/
export const useTextHeight = (textarea: Ref<HTMLTextAreaElement | undefined>, text: Ref<string | number>) => {
const textElement = ref<HTMLElement>()
const textHeight = ref<number>()

watch(textarea, (el) => {
if (el) {
textElement.value = makeTextElement(el)
textarea.value?.parentElement?.appendChild(textElement.value)
}
})

useResizeObserver(textElement, (newElement) => {
if (!newElement || !textarea.value) { return }

textHeight.value = newElement[0].contentRect.height
})

watch(text, (newText) => {
if (!textElement.value) { return }
textElement.value.innerText = String(newText)
// Add space to correctly handle new lines with br
textElement.value.innerHTML += '&nbsp;;'
})

return textHeight
}
7 changes: 4 additions & 3 deletions packages/ui/src/composables/useResizeObserver.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { onBeforeUnmount, onMounted, ref, Ref, unref, watch } from 'vue'

type MaybeRef<T> = T | Ref<T>
type MaybeArray<T> = T | T[]

export const useResizeObserver = <T extends HTMLElement | undefined>(elementsList: MaybeRef<T>[], cb: ResizeObserverCallback) => {
export const useResizeObserver = <T extends HTMLElement | undefined>(elementsList: MaybeRef<T>[] | Ref<T>, cb: ResizeObserverCallback) => {
let resizeObserver: ResizeObserver | undefined

const observeAll = (elementsList: MaybeRef<T>[]) => {
Expand All @@ -15,12 +16,12 @@ export const useResizeObserver = <T extends HTMLElement | undefined>(elementsLis

watch(elementsList, (newValue) => {
resizeObserver?.disconnect()
observeAll(newValue)
observeAll(Array.isArray(newValue) ? newValue : [newValue])
})

onMounted(() => {
resizeObserver = new ResizeObserver(cb)
observeAll(elementsList)
observeAll(Array.isArray(elementsList) ? elementsList : [elementsList])
})

onBeforeUnmount(() => resizeObserver?.disconnect())
Expand Down

0 comments on commit 293c1bc

Please sign in to comment.