Replace the autosize package with an own implementation (#3052)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-09-14 20:03:50 +02:00
1 parent 68c844c7d1
commit 62408b8c8f
9 files changed
+151 -83

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@tabler/core": patch
"@tabler/docs": patch
---
Replaced the `autosize` package with a dependency-free `Autosize` component that fires `resized.bs.autosize`.
+67 -8
View File
@@ -5,9 +5,8 @@
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------
*/ */
import autosize from 'autosize'
import BaseComponent from './bootstrap/base-component' import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine' import SelectorEngine from './bootstrap/dom/selector-engine'
import type { ElementSelector } from './bootstrap/types' import type { ElementSelector } from './bootstrap/types'
@@ -20,6 +19,10 @@ type ComponentConfigInput = Record<string, unknown>
*/ */
const NAME = 'autosize' const NAME = 'autosize'
const DATA_KEY = `bs.${NAME}`
const EVENT_KEY = `.${DATA_KEY}`
const EVENT_RESIZED = `resized${EVENT_KEY}`
const SELECTOR_DATA_TOGGLE = `[data-bs-toggle="${NAME}"], [data-tblr-toggle="${NAME}"]` const SELECTOR_DATA_TOGGLE = `[data-bs-toggle="${NAME}"], [data-tblr-toggle="${NAME}"]`
@@ -30,20 +33,47 @@ const DefaultType: Record<keyof ComponentConfig, string> = {}
/** /**
* Class definition * Class definition
* *
* Wraps the `autosize` plugin (https://github.com/jackmoore/autosize), bundled * Grows a textarea with its content and shrinks it back when text is removed.
* with `tabler.js`. * The height follows `scrollHeight`, so `rows` sets the starting height and
* `max-height` caps the growth, after which the field scrolls again.
*/ */
class Autosize extends BaseComponent { class Autosize extends BaseComponent {
declare _element: HTMLTextAreaElement declare _element: HTMLTextAreaElement
declare _config: ComponentConfig declare _config: ComponentConfig
_observer: ResizeObserver | null = null
_inlineStyle = ''
_width = 0
_onInput = (): void => this.update()
constructor(element: ElementSelector, config?: ComponentConfigInput) { constructor(element: ElementSelector, config?: ComponentConfigInput) {
super(element, config) super(element, config)
if (this._element) { if (!this._element) {
autosize(this._element) return
} }
this._inlineStyle = this._element.getAttribute('style') ?? ''
this._element.style.resize = 'none'
this._element.style.overflowY = 'hidden'
// `input` is not in EventHandler's list of native events, so it is bound
// directly.
this._element.addEventListener('input', this._onInput)
// Text wraps differently when the field changes width (a resized window,
// a collapsing sidebar), so the height is measured again then.
if (typeof ResizeObserver !== 'undefined') {
this._observer = new ResizeObserver(() => {
if (this._element.offsetWidth !== this._width) {
this._width = this._element.offsetWidth
this.update()
}
})
this._observer.observe(this._element)
}
this.update()
} }
// Getters // Getters
@@ -61,11 +91,40 @@ class Autosize extends BaseComponent {
// Public // Public
update(): void { update(): void {
autosize.update(this._element) const element = this._element
if (!element.isConnected || element.offsetParent === null) {
return
}
// Collapsing the field to measure it can move the page; the scroll
// position is put back afterwards.
const { scrollTop } = document.documentElement
const style = getComputedStyle(element)
const border = style.boxSizing === 'border-box' ? Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth) : 0
const before = element.style.height
element.style.height = 'auto'
const height = `${element.scrollHeight + border}px`
element.style.height = height
// Past max-height the content no longer fits, so let the field scroll.
element.style.overflowY = element.scrollHeight > element.clientHeight ? 'auto' : 'hidden'
document.documentElement.scrollTop = scrollTop
if (height !== before) {
EventHandler.trigger(element, EVENT_RESIZED)
}
} }
dispose(): void { dispose(): void {
autosize.destroy(this._element) this._element.removeEventListener('input', this._onInput)
this._observer?.disconnect()
if (this._inlineStyle) {
this._element.setAttribute('style', this._inlineStyle)
} else {
this._element.removeAttribute('style')
}
super.dispose() super.dispose()
} }
} }
-14
View File
@@ -1,17 +1,3 @@
// The `autosize` package ships no type definitions
declare module 'autosize' {
type AutosizeTarget = Element | ArrayLike<Element>
interface Autosize {
(target: AutosizeTarget): AutosizeTarget
update(target: AutosizeTarget): AutosizeTarget
destroy(target: AutosizeTarget): AutosizeTarget
}
const autosize: Autosize
export default autosize
}
// IMask (https://imask.js.org) is loaded separately, not bundled // IMask (https://imask.js.org) is loaded separately, not bundled
interface IMaskInstance { interface IMaskInstance {
value: string value: string
+53 -27
View File
@@ -1,14 +1,7 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import autosize from 'autosize'
import { clearFixture, getFixture } from '../helpers/fixture' import { clearFixture, getFixture } from '../helpers/fixture'
import Autosize from '../../src/autosize' import Autosize from '../../src/autosize'
vi.mock('autosize', () => ({
default: Object.assign(vi.fn(), { update: vi.fn(), destroy: vi.fn() }),
}))
const plugin = vi.mocked(autosize)
describe('Autosize', () => { describe('Autosize', () => {
let fixtureEl: HTMLElement let fixtureEl: HTMLElement
@@ -17,8 +10,7 @@ describe('Autosize', () => {
}) })
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() fixtureEl.innerHTML = '<textarea data-bs-toggle="autosize" rows="1" style="width: 200px; line-height: 20px; padding: 0; border: 0; box-sizing: border-box"></textarea>'
fixtureEl.innerHTML = '<textarea data-bs-toggle="autosize"></textarea>'
}) })
afterEach(() => { afterEach(() => {
@@ -26,6 +18,8 @@ describe('Autosize', () => {
}) })
const textarea = (): HTMLTextAreaElement => fixtureEl.querySelector('textarea')! const textarea = (): HTMLTextAreaElement => fixtureEl.querySelector('textarea')!
const lines = (count: number): string => Array.from({ length: count }, (_, i) => `line ${i + 1}`).join('\n')
const height = (): number => Number.parseFloat(textarea().style.height)
describe('NAME', () => { describe('NAME', () => {
it('should return plugin name', () => { it('should return plugin name', () => {
@@ -34,41 +28,73 @@ describe('Autosize', () => {
}) })
describe('constructor', () => { describe('constructor', () => {
it('should call the autosize plugin with the element', () => { it('should size the field to its content and hide the resize handle', () => {
textarea().value = lines(4)
const instance = new Autosize(textarea()) const instance = new Autosize(textarea())
expect(plugin).toHaveBeenCalledWith(textarea())
expect(Autosize.getInstance(textarea())).toBe(instance) expect(Autosize.getInstance(textarea())).toBe(instance)
expect(height()).toBe(80)
expect(textarea().style.resize).toBe('none')
expect(textarea().style.overflowY).toBe('hidden')
})
})
describe('input', () => {
it('should grow and shrink with the text', () => {
new Autosize(textarea())
const initial = height()
textarea().value = lines(3)
textarea().dispatchEvent(new Event('input'))
expect(height()).toBe(60)
textarea().value = ''
textarea().dispatchEvent(new Event('input'))
expect(height()).toBe(initial)
})
it('should fire resized.bs.autosize when the height changes', () => {
new Autosize(textarea())
const spy = vi.fn()
textarea().addEventListener('resized.bs.autosize', spy)
textarea().value = lines(2)
textarea().dispatchEvent(new Event('input'))
textarea().dispatchEvent(new Event('input'))
expect(spy).toHaveBeenCalledTimes(1)
}) })
}) })
describe('update', () => { describe('update', () => {
it('should call autosize.update with the element', () => { it('should scroll again past max-height', () => {
textarea().style.maxHeight = '40px'
const instance = new Autosize(textarea()) const instance = new Autosize(textarea())
textarea().value = lines(5)
instance.update() instance.update()
expect(plugin.update).toHaveBeenCalledWith(textarea()) expect(textarea().clientHeight).toBe(40)
expect(textarea().style.overflowY).toBe('auto')
})
it('should include the border when the box is border-box', () => {
textarea().style.border = '3px solid red'
textarea().value = lines(2)
new Autosize(textarea())
expect(height()).toBe(46)
}) })
}) })
describe('dispose', () => { describe('dispose', () => {
it('should call autosize.destroy and remove the instance', () => { it('should restore the inline style and remove the instance', () => {
const style = textarea().getAttribute('style')
const instance = new Autosize(textarea()) const instance = new Autosize(textarea())
const element = textarea()
instance.dispose() instance.dispose()
expect(plugin.destroy).toHaveBeenCalledWith(element) expect(textarea().getAttribute('style')).toBe(style)
expect(Autosize.getInstance(element)).toBeNull() expect(Autosize.getInstance(textarea())).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should reuse the instance and not initialise the plugin twice', () => {
const first = Autosize.getOrCreateInstance(textarea())
const second = Autosize.getOrCreateInstance(textarea())
expect(second).toBe(first)
expect(plugin).toHaveBeenCalledTimes(1)
}) })
}) })
}) })
-6
View File
@@ -5,12 +5,6 @@
"dist/imask.min.js" "dist/imask.min.js"
] ]
}, },
"autosize": {
"npm": "autosize",
"js": [
"dist/autosize.min.js"
]
},
"apexcharts": { "apexcharts": {
"npm": "apexcharts", "npm": "apexcharts",
"js": [ "js": [
+2 -3
View File
@@ -249,10 +249,8 @@
"@melloware/coloris": "^0.25.0", "@melloware/coloris": "^0.25.0",
"@types/node": "^26.4.0", "@types/node": "^26.4.0",
"@vitest/browser-playwright": "4.1.11", "@vitest/browser-playwright": "4.1.11",
"playwright": "1.62.1",
"@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-istanbul": "4.1.11",
"apexcharts": "7.0.0", "apexcharts": "7.0.0",
"autosize": "^6.0.1",
"choices.js": "^11.2.4", "choices.js": "^11.2.4",
"clipboard": "^2.0.11", "clipboard": "^2.0.11",
"countup.js": "^2.10.1", "countup.js": "^2.10.1",
@@ -268,10 +266,11 @@
"list.js": "^2.3.1", "list.js": "^2.3.1",
"litepicker": "^2.0.12", "litepicker": "^2.0.12",
"nouislider": "^15.8.1", "nouislider": "^15.8.1",
"playwright": "1.62.1",
"plyr": "^3.8.4", "plyr": "^3.8.4",
"signature_pad": "^5.1.4",
"sass": "1.103.1", "sass": "1.103.1",
"sass-true": "^10.1.0", "sass-true": "^10.1.0",
"signature_pad": "^5.1.4",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"star-rating.js": "^4.3.1", "star-rating.js": "^4.3.1",
"tom-select": "^2.6.2", "tom-select": "^2.6.2",
+22 -10
View File
@@ -1,7 +1,7 @@
--- ---
title: Autosize title: Autosize
summary: The autosize element will automatically adjust the textarea height and make it easier for users to follow as they type. summary: The autosize element will automatically adjust the textarea height and make it easier for users to follow as they type.
description: Make textareas grow automatically as users type with the autosize plugin, so longer text stays visible without manual resizing. description: Make textareas grow automatically as users type with autosize, so longer text stays visible without manual resizing.
related: [/ui/forms/elements] related: [/ui/forms/elements]
--- ---
import Example from '@components/Example.astro'; import Example from '@components/Example.astro';
@@ -9,7 +9,7 @@ import CodeDocs from '@components/CodeDocs.astro';
## Overview ## Overview
A textarea keeps its height no matter how much text it holds, so long answers end up in a small scrolling box. The [autosize](https://github.com/jackmoore/autosize) plugin grows the field as the user types and shrinks it again when text is removed. A textarea keeps its height no matter how much text it holds, so long answers end up in a small scrolling box. Autosize grows the field as the user types and shrinks it again when text is removed.
<Example column vertical> <Example column vertical>
<label class="form-label">Autosize example</label> <textarea class="form-control" data-bs-toggle="autosize" placeholder="Type something…"></textarea> <label class="form-label">Autosize example</label> <textarea class="form-control" data-bs-toggle="autosize" placeholder="Type something…"></textarea>
@@ -17,7 +17,7 @@ A textarea keeps its height no matter how much text it holds, so long answers en
## Installation ## Installation
The plugin is bundled with `tabler.js`, so there is nothing extra to install or load. It only changes the height of the field, so it needs no CSS of its own. Autosize is part of `tabler.js` and has no dependency, so there is nothing extra to install or load. It only sets the height of the field, so it needs no CSS of its own.
## Usage ## Usage
@@ -27,11 +27,15 @@ Add `data-bs-toggle="autosize"` to a textarea. Tabler picks it up on page load,
<label class="form-label">Message</label> <textarea class="form-control" data-bs-toggle="autosize" rows="1" placeholder="Write a message…"></textarea> <label class="form-label">Message</label> <textarea class="form-control" data-bs-toggle="autosize" rows="1" placeholder="Write a message…"></textarea>
</Example> </Example>
Set `rows` to choose the starting height. With `rows="1"` the field starts as a single line and grows from there. Set `rows` to choose the starting height. With `rows="1"` the field starts as a single line and grows from there. Set a `max-height` to cap the growth; past it the field scrolls again.
<Example column vertical>
<label class="form-label">Notes, at most four lines</label> <textarea class="form-control" data-bs-toggle="autosize" rows="2" style="max-height: 6.5rem" placeholder="Keep typing to see the field stop growing…"></textarea>
</Example>
## JavaScript ## JavaScript
Tabler wraps the plugin in an `Autosize` component that works like the Bootstrap components: it is created once per element and stored on it. On page load Tabler creates one for every element with `data-bs-toggle="autosize"`. This is the code that runs: Tabler ships an `Autosize` component that works like the Bootstrap components: it is created once per element and stored on it. On page load Tabler creates one for every element with `data-bs-toggle="autosize"`. It sets the height from the content on every `input` event and again when the field changes width. This is the code that runs:
<CodeDocs name="autosize-init" file="core/js/src/autosize.ts" /> <CodeDocs name="autosize-init" file="core/js/src/autosize.ts" />
@@ -43,13 +47,13 @@ const autosize = new tabler.Autosize(document.getElementById('message'));
`tabler.Autosize.getOrCreateInstance(element)` returns the existing component or creates a new one, so it is safe to call more than once for the same field. `tabler.Autosize.getOrCreateInstance(element)` returns the existing component or creates a new one, so it is safe to call more than once for the same field.
Autosize measures the field, so a hidden textarea gets the wrong height. Call `update()` once it becomes visible: Autosize measures the field, so a hidden textarea is skipped. Call `update()` once it becomes visible, or after you set its value from code:
```js ```js
tabler.Autosize.getOrCreateInstance(document.getElementById('message')).update(); tabler.Autosize.getOrCreateInstance(document.getElementById('message')).update();
``` ```
Call `dispose()` to stop the plugin and remove the component from the element: Call `dispose()` to stop it and put the inline style back the way it was:
```js ```js
tabler.Autosize.getInstance(document.getElementById('message'))?.dispose(); tabler.Autosize.getInstance(document.getElementById('message'))?.dispose();
@@ -58,12 +62,20 @@ tabler.Autosize.getInstance(document.getElementById('message'))?.dispose();
| Method | Description | | Method | Description |
| --- | --- | | --- | --- |
| `update()` | Recalculates the height, for example after the textarea becomes visible or its value changes from code. | | `update()` | Recalculates the height, for example after the textarea becomes visible or its value changes from code. |
| `dispose()` | Stops the plugin and removes the component from the element. | | `dispose()` | Stops listening, restores the inline style and removes the component from the element. |
| `getInstance(element)` | Static. Returns the component for the element, or `null`. | | `getInstance(element)` | Static. Returns the component for the element, or `null`. |
| `getOrCreateInstance(element)` | Static. Returns the component for the element and creates it when needed. | | `getOrCreateInstance(element)` | Static. Returns the component for the element and creates it when needed. |
Every change of height fires `resized.bs.autosize` on the textarea, for example to scroll a chat window to the bottom:
```js
document.getElementById('message').addEventListener('resized.bs.autosize', () => {
thread.scrollTop = thread.scrollHeight;
});
```
## Accessibility ## Accessibility
- Keep the `label` for the field. A growing textarea is still a normal form control and needs its name. - Keep the `label` for the field. A growing textarea is still a normal form control and needs its name.
- Let the field grow instead of setting a fixed `max-height`. Cutting the growth off brings back the scrolling box the plugin is meant to remove. - Let the field grow freely when you can. A `max-height` brings back a scrolling box, so keep it generous, for example the height of the visible chat window.
- Do not remove the resize handle unless the field really grows on its own - without either one, a user cannot see long text. - Autosize hides the resize handle, because the field grows on its own. With a `max-height` the field scrolls past the cap, so long text stays reachable.
+1 -9
View File
@@ -132,9 +132,6 @@ importers:
apexcharts: apexcharts:
specifier: 7.0.0 specifier: 7.0.0
version: 7.0.0 version: 7.0.0
autosize:
specifier: ^6.0.1
version: 6.0.1
choices.js: choices.js:
specifier: ^11.2.4 specifier: ^11.2.4
version: 11.2.4 version: 11.2.4
@@ -2095,9 +2092,6 @@ packages:
peerDependencies: peerDependencies:
postcss: ^8.1.0 postcss: ^8.1.0
autosize@6.0.1:
resolution: {integrity: sha512-f86EjiUKE6Xvczc4ioP1JBlWG7FKrE13qe/DxBCpe8GCipCq2nFw73aO8QEBKHfSbYGDN5eB9jXWKen7tspDqQ==}
axios@0.33.0: axios@0.33.0:
resolution: {integrity: sha512-juz19g7B3UWT9r3+tgRFQf2Bdy6IKDVroiyuVOUrkILVKHpqHL++K1l8ybvfdEP9B/VE72vk8vllbnedVCm/og==} resolution: {integrity: sha512-juz19g7B3UWT9r3+tgRFQf2Bdy6IKDVroiyuVOUrkILVKHpqHL++K1l8ybvfdEP9B/VE72vk8vllbnedVCm/og==}
@@ -6651,8 +6645,6 @@ snapshots:
postcss: 8.5.28 postcss: 8.5.28
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
autosize@6.0.1: {}
axios@0.33.0(debug@4.4.3(supports-color@10.2.2)): axios@0.33.0(debug@4.4.3(supports-color@10.2.2)):
dependencies: dependencies:
follow-redirects: 1.16.0(debug@4.4.3(supports-color@10.2.2)) follow-redirects: 1.16.0(debug@4.4.3(supports-color@10.2.2))
@@ -9615,7 +9607,7 @@ snapshots:
vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.1)(sass@1.103.1)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0): vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.1)(sass@1.103.1)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0):
dependencies: dependencies:
lightningcss: 1.33.0 lightningcss: 1.33.0
picomatch: 4.0.5 picomatch: 4.0.7
postcss: 8.5.28 postcss: 8.5.28
rolldown: 1.2.6 rolldown: 1.2.6
tinyglobby: 0.2.17 tinyglobby: 0.2.17
-6
View File
@@ -11,12 +11,6 @@
"description": "Vanilla JavaScript input masking library with support for various mask types and dynamic behavior.", "description": "Vanilla JavaScript input masking library with support for various mask types and dynamic behavior.",
"license": "MIT" "license": "MIT"
}, },
{
"name": "Autosize",
"url": "https://www.jacklmoore.com/autosize/",
"description": "Automatically adjusts textarea height to fit text content.",
"license": "MIT"
},
{ {
"name": "ApexCharts", "name": "ApexCharts",
"url": "https://apexcharts.com/", "url": "https://apexcharts.com/",