From 62408b8c8f13fc0514a69caf732924c53604ca01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kuna?= <1282324+codecalm@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:03:50 +0200 Subject: [PATCH] Replace the autosize package with an own implementation (#3052) --- .changeset/autosize-own.md | 6 +++ core/js/src/autosize.ts | 75 +++++++++++++++++++++++--- core/js/src/global.d.ts | 14 ----- core/js/tests/unit/autosize.spec.ts | 80 ++++++++++++++++++---------- core/libs.json | 6 --- core/package.json | 5 +- docs/content/ui/plugins/autosize.mdx | 32 +++++++---- pnpm-lock.yaml | 10 +--- shared/data/open-source.json | 6 --- 9 files changed, 151 insertions(+), 83 deletions(-) create mode 100644 .changeset/autosize-own.md diff --git a/.changeset/autosize-own.md b/.changeset/autosize-own.md new file mode 100644 index 000000000..4d397d344 --- /dev/null +++ b/.changeset/autosize-own.md @@ -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`. diff --git a/core/js/src/autosize.ts b/core/js/src/autosize.ts index 6c04e8fe3..cf052b8bb 100644 --- a/core/js/src/autosize.ts +++ b/core/js/src/autosize.ts @@ -5,9 +5,8 @@ * -------------------------------------------------------------------------- */ -import autosize from 'autosize' - import BaseComponent from './bootstrap/base-component' +import EventHandler from './bootstrap/dom/event-handler' import SelectorEngine from './bootstrap/dom/selector-engine' import type { ElementSelector } from './bootstrap/types' @@ -20,6 +19,10 @@ type ComponentConfigInput = Record */ 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}"]` @@ -30,20 +33,47 @@ const DefaultType: Record = {} /** * Class definition * - * Wraps the `autosize` plugin (https://github.com/jackmoore/autosize), bundled - * with `tabler.js`. + * Grows a textarea with its content and shrinks it back when text is removed. + * 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 { declare _element: HTMLTextAreaElement declare _config: ComponentConfig + _observer: ResizeObserver | null = null + _inlineStyle = '' + _width = 0 + _onInput = (): void => this.update() constructor(element: ElementSelector, config?: ComponentConfigInput) { super(element, config) - if (this._element) { - autosize(this._element) + if (!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 @@ -61,11 +91,40 @@ class Autosize extends BaseComponent { // Public 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 { - 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() } } diff --git a/core/js/src/global.d.ts b/core/js/src/global.d.ts index 22c30b437..bcf19aa9a 100644 --- a/core/js/src/global.d.ts +++ b/core/js/src/global.d.ts @@ -1,17 +1,3 @@ -// The `autosize` package ships no type definitions -declare module 'autosize' { - type AutosizeTarget = Element | ArrayLike - - 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 interface IMaskInstance { value: string diff --git a/core/js/tests/unit/autosize.spec.ts b/core/js/tests/unit/autosize.spec.ts index 6ee061d1f..b28168987 100644 --- a/core/js/tests/unit/autosize.spec.ts +++ b/core/js/tests/unit/autosize.spec.ts @@ -1,14 +1,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest' -import autosize from 'autosize' import { clearFixture, getFixture } from '../helpers/fixture' 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', () => { let fixtureEl: HTMLElement @@ -17,8 +10,7 @@ describe('Autosize', () => { }) beforeEach(() => { - vi.clearAllMocks() - fixtureEl.innerHTML = '' + fixtureEl.innerHTML = '' }) afterEach(() => { @@ -26,6 +18,8 @@ describe('Autosize', () => { }) 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', () => { it('should return plugin name', () => { @@ -34,41 +28,73 @@ describe('Autosize', () => { }) 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()) - expect(plugin).toHaveBeenCalledWith(textarea()) 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', () => { - it('should call autosize.update with the element', () => { + it('should scroll again past max-height', () => { + textarea().style.maxHeight = '40px' const instance = new Autosize(textarea()) + + textarea().value = lines(5) 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', () => { - 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 element = textarea() instance.dispose() - expect(plugin.destroy).toHaveBeenCalledWith(element) - expect(Autosize.getInstance(element)).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) + expect(textarea().getAttribute('style')).toBe(style) + expect(Autosize.getInstance(textarea())).toBeNull() }) }) }) diff --git a/core/libs.json b/core/libs.json index f11dbd9ad..94b935b3e 100644 --- a/core/libs.json +++ b/core/libs.json @@ -5,12 +5,6 @@ "dist/imask.min.js" ] }, - "autosize": { - "npm": "autosize", - "js": [ - "dist/autosize.min.js" - ] - }, "apexcharts": { "npm": "apexcharts", "js": [ diff --git a/core/package.json b/core/package.json index 6828bfe9a..d45ed0949 100644 --- a/core/package.json +++ b/core/package.json @@ -249,10 +249,8 @@ "@melloware/coloris": "^0.25.0", "@types/node": "^26.4.0", "@vitest/browser-playwright": "4.1.11", - "playwright": "1.62.1", "@vitest/coverage-istanbul": "4.1.11", "apexcharts": "7.0.0", - "autosize": "^6.0.1", "choices.js": "^11.2.4", "clipboard": "^2.0.11", "countup.js": "^2.10.1", @@ -268,10 +266,11 @@ "list.js": "^2.3.1", "litepicker": "^2.0.12", "nouislider": "^15.8.1", + "playwright": "1.62.1", "plyr": "^3.8.4", - "signature_pad": "^5.1.4", "sass": "1.103.1", "sass-true": "^10.1.0", + "signature_pad": "^5.1.4", "sortablejs": "^1.15.7", "star-rating.js": "^4.3.1", "tom-select": "^2.6.2", diff --git a/docs/content/ui/plugins/autosize.mdx b/docs/content/ui/plugins/autosize.mdx index c05cce394..557d9e9a5 100644 --- a/docs/content/ui/plugins/autosize.mdx +++ b/docs/content/ui/plugins/autosize.mdx @@ -1,7 +1,7 @@ --- title: Autosize 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] --- import Example from '@components/Example.astro'; @@ -9,7 +9,7 @@ import CodeDocs from '@components/CodeDocs.astro'; ## 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. @@ -17,7 +17,7 @@ A textarea keeps its height no matter how much text it holds, so long answers en ## 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 @@ -27,11 +27,15 @@ Add `data-bs-toggle="autosize"` to a textarea. Tabler picks it up on page load, -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. + + + + ## 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: @@ -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. -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 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 tabler.Autosize.getInstance(document.getElementById('message'))?.dispose(); @@ -58,12 +62,20 @@ tabler.Autosize.getInstance(document.getElementById('message'))?.dispose(); | Method | Description | | --- | --- | | `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`. | | `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 - 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. -- Do not remove the resize handle unless the field really grows on its own - without either one, a user cannot see long text. +- 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. +- 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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b942895e..b76862543 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,9 +132,6 @@ importers: apexcharts: specifier: 7.0.0 version: 7.0.0 - autosize: - specifier: ^6.0.1 - version: 6.0.1 choices.js: specifier: ^11.2.4 version: 11.2.4 @@ -2095,9 +2092,6 @@ packages: peerDependencies: postcss: ^8.1.0 - autosize@6.0.1: - resolution: {integrity: sha512-f86EjiUKE6Xvczc4ioP1JBlWG7FKrE13qe/DxBCpe8GCipCq2nFw73aO8QEBKHfSbYGDN5eB9jXWKen7tspDqQ==} - axios@0.33.0: resolution: {integrity: sha512-juz19g7B3UWT9r3+tgRFQf2Bdy6IKDVroiyuVOUrkILVKHpqHL++K1l8ybvfdEP9B/VE72vk8vllbnedVCm/og==} @@ -6651,8 +6645,6 @@ snapshots: postcss: 8.5.28 postcss-value-parser: 4.2.0 - autosize@6.0.1: {} - axios@0.33.0(debug@4.4.3(supports-color@10.2.2)): dependencies: 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): dependencies: lightningcss: 1.33.0 - picomatch: 4.0.5 + picomatch: 4.0.7 postcss: 8.5.28 rolldown: 1.2.6 tinyglobby: 0.2.17 diff --git a/shared/data/open-source.json b/shared/data/open-source.json index 994070ccc..f02b4ec15 100644 --- a/shared/data/open-source.json +++ b/shared/data/open-source.json @@ -11,12 +11,6 @@ "description": "Vanilla JavaScript input masking library with support for various mask types and dynamic behavior.", "license": "MIT" }, - { - "name": "Autosize", - "url": "https://www.jacklmoore.com/autosize/", - "description": "Automatically adjusts textarea height to fit text content.", - "license": "MIT" - }, { "name": "ApexCharts", "url": "https://apexcharts.com/",