From 3cbae682be415cb49d6928fec18b5b7d9517e10a Mon Sep 17 00:00:00 2001 From: Artem Belik Date: Fri, 4 Sep 2026 09:52:13 +0300 Subject: [PATCH 1/3] fix(textarea)!: errors following a full review of the component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `KbqTextarea` implements `KbqFormFieldControl`, which declares `value`, `id`, `placeholder`, `required`, `disabled`, `focused`, `empty` and `errorState` as plain members — that interface is how the form field reads them, so they stay plain accessors. The four inputs the textarea owns are signals now. `canGrow` reported `!maxRowLimitReached && bound`, so it said `false` once the textarea hit `maxRows` even though the consumer had asked for growth. The folded value drives the resize handle and is internal; `canGrow()` reports what was bound. `freeRowsHeight` used to default itself by assigning its own input in `ngOnInit`, which is why the automated migration skipped it. The fallback is a computed, so binding it later takes effect instead of being overwritten on the next init. `maxRows` and `freeRowsHeight` report `number | undefined`, which is what an unbound textarea always held. The row count is a signal, so the `kbq-textarea_max-row-limit-reached` class follows it directly. It is written inside `runOutsideAngular`, so the class used to wait for an unrelated change detection pass. The parent animation subscription is torn down with the directive, and the generated id comes from the CDK `_IdGenerator`. BREAKING CHANGE: `KbqTextarea.canGrow`, `maxRows` and `freeRowsHeight` are signal inputs, `maxRowLimitReached` is a computed, `canGrow` reports the bound value rather than folding in the row limit, and generated ids changed shape. Reported and partly rewritten by the `textarea-signals` schematic. Co-Authored-By: Claude Opus 5 --- docs/guides/migration.en.md | 24 +- docs/guides/migration.ru.md | 24 +- .../textarea/textarea.component.spec.ts | 46 +++ .../components/textarea/textarea.component.ts | 120 ++++--- packages/schematics/src/collection.json | 5 + packages/schematics/src/migrations.json | 5 + .../src/migrations/textarea-signals/README.md | 64 ++++ .../src/migrations/textarea-signals/data.ts | 79 +++++ .../migrations/textarea-signals/index.spec.ts | 212 ++++++++++++ .../src/migrations/textarea-signals/index.ts | 305 ++++++++++++++++++ .../migrations/textarea-signals/schema.json | 20 ++ .../src/migrations/textarea-signals/schema.ts | 6 + .../components/textarea.api.md | 31 +- 13 files changed, 868 insertions(+), 73 deletions(-) create mode 100644 packages/schematics/src/migrations/textarea-signals/README.md create mode 100644 packages/schematics/src/migrations/textarea-signals/data.ts create mode 100644 packages/schematics/src/migrations/textarea-signals/index.spec.ts create mode 100644 packages/schematics/src/migrations/textarea-signals/index.ts create mode 100644 packages/schematics/src/migrations/textarea-signals/schema.json create mode 100644 packages/schematics/src/migrations/textarea-signals/schema.ts diff --git a/docs/guides/migration.en.md b/docs/guides/migration.en.md index ccb9a89267..60fe4cf330 100644 --- a/docs/guides/migration.en.md +++ b/docs/guides/migration.en.md @@ -1035,7 +1035,7 @@ for each option it deselected and reporting the shortened value to the form cont ### 18. Component review (20.3.0) -Ten components went through a full review in 20.3.0: notification-center, popover, search-expandable, select, split-button, title, toast, tooltip, tree and tree-select. Each review closed the members that were never part of the component's contract, moved inputs to signals where that was the point of it, and fixed the behavior it uncovered along the way. Only the changes that reach a consumer are listed here. +Components went through a full review in 20.3.0, in two waves. The first covered notification-center, popover, search-expandable, select, split-button, title, toast, tooltip, tree and tree-select; the second is the one each subsection below belongs to. Each review closed the members that were never part of the component's contract, moved inputs to signals where that was the point of it, and fixed the behavior it uncovered along the way. Only the changes that reach a consumer are listed here. Every schematic named below runs automatically: @@ -1113,6 +1113,28 @@ A `` with no projected button no longer throws outside dev mod Reported by `split-button-optional-disabled`. +#### Textarea + +`KbqTextarea` implements `KbqFormFieldControl`, which declares `value`, `id`, `placeholder`, `required`, `disabled`, `focused`, `empty` and `errorState` as plain members — that interface is how the form field reads them, so they stay plain accessors. What moved are the four inputs the textarea owns. + +`canGrow` was the odd one: its getter returned `!maxRowLimitReached && bound`, so it reported `false` once the textarea hit `maxRows` even though the consumer had asked for growth. The folded value drives the resize handle and is internal now; `canGrow()` reports what was bound. + +| Pattern | Manual migration | +| ------------------------------------------------------- | --------------------------------------------------------------------- | +| `.maxRows` / `.freeRowsHeight` / `.maxRowLimitReached` | Read as calls — rewritten for you | +| `.canGrow` | `canGrow()`, and expect what was bound — not `false` at the row limit | +| `.canGrow = …` / `.maxRows = …` / `.freeRowsHeight = …` | Bind them in the template; the inputs are read-only | + +**`maxRows` and `freeRowsHeight` report `number | undefined`.** Both were declared non-nullable while an unbound textarea held `undefined`, and `maxRowLimitReached` compared against it — `rowsCount > undefined` is false, which is why unlimited growth worked at all. + +**`freeRowsHeight` no longer writes itself.** It defaulted to the measured line height by assigning its own input in `ngOnInit`; the fallback is a computed now, so binding it later actually takes effect instead of being overwritten on the next init. + +**The `kbq-textarea_max-row-limit-reached` class follows the row count directly.** It is derived from a signal written inside `runOutsideAngular`, so the class used to wait for an unrelated change detection pass to appear. + +**Generated ids changed shape**, from `kbq-textarea-1` to `kbq-textarea-a1`. + +Handled by `textarea-signals`: the value-safe reads are rewritten, the rest is reported. + #### Title `kbq-title` measures its host and opens a tooltip when the text is truncated. The review kept that surface — the `kbq-title` input and the tooltip it opens — and closed the measurement machinery behind it. diff --git a/docs/guides/migration.ru.md b/docs/guides/migration.ru.md index 73fd0cd51d..e95e44bca6 100644 --- a/docs/guides/migration.ru.md +++ b/docs/guides/migration.ru.md @@ -1039,7 +1039,7 @@ ng g @koobiq/components:list-tree-multiple-input --project ### 18. Ревью компонентов (20.3.0) -В 20.3.0 полное ревью прошли десять компонентов: notification-center, popover, search-expandable, select, split-button, title, toast, tooltip, tree и tree-select. Каждое ревью закрывало члены, которые никогда не были частью контракта компонента, переводило входы на сигналы там, где в этом и был его смысл, и попутно исправляло найденные ошибки поведения. Ниже перечислено только то, что доходит до потребителя. +В 20.3.0 полное ревью прошло в две волны. В первую вошли notification-center, popover, search-expandable, select, split-button, title, toast, tooltip, tree и tree-select; ко второй относится каждый из подразделов ниже. Каждое ревью закрывало члены, которые никогда не были частью контракта компонента, переводило входы на сигналы там, где в этом и был его смысл, и попутно исправляло найденные ошибки поведения. Ниже перечислено только то, что доходит до потребителя. Все схематики, названные ниже, запускаются автоматически: @@ -1117,6 +1117,28 @@ if (splitButton.disabled === false) { Сообщает `split-button-optional-disabled`. +#### Textarea + +`KbqTextarea` реализует `KbqFormFieldControl`, который объявляет `value`, `id`, `placeholder`, `required`, `disabled`, `focused`, `empty` и `errorState` обычными членами — именно через этот интерфейс их читает form-field, поэтому они остались обычными геттерами и сеттерами. Переехали те четыре входа, которые принадлежат самому textarea. + +Особый случай — `canGrow`: его геттер возвращал `!maxRowLimitReached && привязанное значение`, поэтому он сообщал `false`, как только textarea упирался в `maxRows`, — хотя потребитель просил рост. Свёрнутое значение управляет ручкой изменения размера и стало внутренним; `canGrow()` возвращает то, что было привязано. + +| Что было | Как мигрировать вручную | +| ------------------------------------------------------- | -------------------------------------------------------------------------- | +| `.maxRows` / `.freeRowsHeight` / `.maxRowLimitReached` | Читать как вызовы — переписывается за вас | +| `.canGrow` | `canGrow()`, и ожидать привязанное значение, а не `false` на пределе строк | +| `.canGrow = …` / `.maxRows = …` / `.freeRowsHeight = …` | Привязать в шаблоне; входы доступны только на чтение | + +**`maxRows` и `freeRowsHeight` возвращают `number | undefined`.** Оба были объявлены ненулевыми, хотя textarea без привязки содержал `undefined`, и `maxRowLimitReached` сравнивался именно с ним: `rowsCount > undefined` ложно — благодаря этому неограниченный рост вообще работал. + +**`freeRowsHeight` больше не пишет сам в себя.** Он получал значение по умолчанию, присваивая собственный вход в `ngOnInit`; теперь запасное значение — это `computed`, поэтому привязка, сделанная позже, действительно применяется, а не затирается при следующей инициализации. + +**Класс `kbq-textarea_max-row-limit-reached` следует за числом строк напрямую.** Он выводится из сигнала, в который пишут внутри `runOutsideAngular`, поэтому раньше класс ждал постороннего цикла обнаружения изменений. + +**Формат генерируемых `id` изменился** с `kbq-textarea-1` на `kbq-textarea-a1`. + +Закрывается схематиком `textarea-signals`: безопасные по значению чтения переписываются, остальное сообщается в отчёте. + #### Title `kbq-title` измеряет свой хост и открывает тултип, когда текст обрезан. Ревью сохранило эту поверхность — вход `kbq-title` и открываемый тултип — и закрыло стоящий за ней механизм измерения. diff --git a/packages/components/textarea/textarea.component.spec.ts b/packages/components/textarea/textarea.component.spec.ts index b7365c8574..7d812e2a73 100644 --- a/packages/components/textarea/textarea.component.spec.ts +++ b/packages/components/textarea/textarea.component.spec.ts @@ -308,6 +308,40 @@ describe('KbqTextarea', () => { expect(getTextareaElement(fixture).classList.contains('kbq-textarea_max-row-limit-reached')).toBe(false); }); + + it('should treat a valueless canGrow attribute as true', () => { + const fixture = createComponent(KbqTextareaValuelessCanGrow); + + fixture.detectChanges(); + + const textarea = fixture.debugElement.query(By.directive(KbqTextarea)).injector.get(KbqTextarea); + + expect(textarea.canGrow()).toBe(true); + expect(getTextareaElement(fixture).classList.contains('kbq-textarea-resizable')).toBe(false); + }); + + it('should report the bound canGrow rather than folding in the row limit', () => { + const fixture = createComponent(KbqTextareaGrowWithMaxRows); + + fixture.detectChanges(); + + const textarea = fixture.debugElement.query(By.directive(KbqTextarea)).injector.get(KbqTextarea); + + expect(textarea.canGrow()).toBe(true); + expect(textarea.maxRowLimitReached()).toBe(false); + }); + + it('should leave maxRows and freeRowsHeight undefined when unbound', () => { + const fixture = createComponent(KbqTextareaForBehaviors); + + fixture.detectChanges(); + + const textarea = fixture.debugElement.query(By.directive(KbqTextarea)).injector.get(KbqTextarea); + + expect(textarea.maxRows()).toBeUndefined(); + expect(textarea.freeRowsHeight()).toBeUndefined(); + expect(textarea.maxRowLimitReached()).toBe(false); + }); }); describe('grow behavior', () => { @@ -547,3 +581,15 @@ describe('KbqTextarea', () => { })); }); }); + +@Component({ + imports: [KbqTextareaModule, KbqFormFieldModule, FormsModule], + template: ` + + + + ` +}) +class KbqTextareaValuelessCanGrow { + value = ''; +} diff --git a/packages/components/textarea/textarea.component.ts b/packages/components/textarea/textarea.component.ts index 81bcf2e679..07a033675a 100644 --- a/packages/components/textarea/textarea.component.ts +++ b/packages/components/textarea/textarea.component.ts @@ -1,7 +1,9 @@ +import { _IdGenerator } from '@angular/cdk/a11y'; import { coerceBooleanProperty, coerceCssPixelValue } from '@angular/cdk/coercion'; import { Platform } from '@angular/cdk/platform'; import { booleanAttribute, + computed, Directive, DoCheck, ElementRef, @@ -14,7 +16,8 @@ import { OnChanges, OnDestroy, OnInit, - Renderer2 + Renderer2, + signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormGroupDirective, NgControl, NgForm, UntypedFormControl } from '@angular/forms'; @@ -31,15 +34,17 @@ import { asapScheduler, observeOn, Subject } from 'rxjs'; export const KBQ_TEXTAREA_VALUE_ACCESSOR = new InjectionToken<{ value: any }>('KBQ_TEXTAREA_VALUE_ACCESSOR'); -let nextUniqueId = 0; +/** Coerces an optional numeric input, keeping `undefined` distinguishable from `0`. */ +const optionalNumberAttribute = (value: unknown): number | undefined => + value == null ? undefined : numberAttribute(value); @Directive({ selector: 'textarea[kbqTextarea]', providers: [{ provide: KbqFormFieldControl, useExisting: KbqTextarea }], host: { class: 'kbq-textarea', - '[class.kbq-textarea-resizable]': '!canGrow', - '[class.kbq-textarea_max-row-limit-reached]': 'maxRowLimitReached', + '[class.kbq-textarea-resizable]': '!growing()', + '[class.kbq-textarea_max-row-limit-reached]': 'maxRowLimitReached()', '[attr.id]': 'id', '[attr.placeholder]': 'placeholder', '[attr.aria-invalid]': 'errorState', @@ -66,27 +71,18 @@ export class KbqTextarea /** Whether the component is in an error state. */ errorState: boolean = false; - /** Parameter enables or disables the ability to automatically increase the height. - * If set to false, the textarea becomes vertically resizable. */ - // TODO: Skipped for migration because: - // Accessor inputs cannot be migrated as they are too complex. - @Input({ transform: booleanAttribute }) - get canGrow(): boolean { - return !this.maxRowLimitReached && this._canGrow; - } - - set canGrow(value: boolean) { - this._canGrow = value; - } + /** + * Parameter enables or disables the ability to automatically increase the height. + * If set to false, the textarea becomes vertically resizable. + */ + readonly canGrow = input(true, { transform: booleanAttribute }); protected readonly isBrowser = inject(Platform).isBrowser; protected readonly renderer = inject(Renderer2); private readonly window = inject(KBQ_WINDOW); - private _canGrow: boolean = true; - - /** Maximum number of lines to which the textarea will grow. Default unlimited */ - readonly maxRows = input(undefined!); + /** Maximum number of lines to which the textarea will grow. Unlimited when unset. */ + readonly maxRows = input(undefined, { transform: optionalNumberAttribute }); /** An object used to control when error messages are shown. */ // TODO: Skipped for migration because: @@ -166,10 +162,8 @@ export class KbqTextarea // is not migrated. @Input() placeholder: string; - /** Distance from the last line to the bottom border */ - // TODO: Skipped for migration because: - // Your application code writes to the input. This prevents migration. - @Input({ transform: numberAttribute }) freeRowsHeight: number; + /** Distance from the last line to the bottom border. Defaults to a single line height. */ + readonly freeRowsHeight = input(undefined, { transform: optionalNumberAttribute }); /** * Implemented as part of KbqFormFieldControl. @@ -204,23 +198,39 @@ export class KbqTextarea } } - /** Flag that will be set to true when the maximum number of lines is reached. - * Maximum number of rows can be set using the maxRows input. */ - get maxRowLimitReached(): boolean { - return this.rowsCount > this.maxRows(); - } + /** + * Flag that will be set to true when the maximum number of lines is reached. + * Maximum number of rows can be set using the maxRows input. + */ + readonly maxRowLimitReached = computed(() => { + const maxRows = this.maxRows(); + + return maxRows !== undefined && this.rowsCount() > maxRows; + }); + + /** + * Whether the textarea still grows with its content. It stops once `maxRows` is reached, which is + * when the native resize handle takes over. + * + * @docs-private + */ + protected readonly growing = computed(() => !this.maxRowLimitReached() && this.canGrow()); + + /** Distance from the last line to the bottom border, falling back to the measured line height. */ + private readonly resolvedFreeRowsHeight = computed(() => this.freeRowsHeight() ?? this.lineHeight()); - protected uid = `kbq-textarea-${nextUniqueId++}`; + protected readonly uid = inject(_IdGenerator).getId('kbq-textarea-'); protected previousNativeValue: any; private _disabled = false; - private _id: string; + private _id: string = this.uid; private _required = false; private valueAccessor: { value: any }; - private lineHeight: number = 0; - private minHeight: number = 0; - private rowsCount: number; + /** Measured once the textarea has been rendered; the growth arithmetic is in terms of these. */ + private readonly lineHeight = signal(0); + private readonly minHeight = signal(0); + private readonly rowsCount = signal(0); constructor() { const inputValueAccessor = inject(KBQ_TEXTAREA_VALUE_ACCESSOR, { optional: true, self: true }); @@ -231,11 +241,8 @@ export class KbqTextarea this.previousNativeValue = this.value; - // Force setter to be called in case id was not specified. - this.id = this.id; - // eslint-disable-next-line @angular-eslint/no-lifecycle-call - this.parent?.animationDone.subscribe(() => this.ngOnInit()); + this.parent?.animationDone.pipe(takeUntilDestroyed()).subscribe(() => this.ngOnInit()); this.stateChanges.pipe(observeOn(asapScheduler), takeUntilDestroyed()).subscribe(() => this.grow()); } @@ -244,16 +251,11 @@ export class KbqTextarea if (!this.isBrowser) return; Promise.resolve().then(() => { - this.lineHeight = parseInt(this.window.getComputedStyle(this.elementRef.nativeElement).lineHeight!, 10); - - const paddingTop = parseInt(this.window.getComputedStyle(this.elementRef.nativeElement).paddingTop!, 10); - const paddingBottom = parseInt( - this.window.getComputedStyle(this.elementRef.nativeElement).paddingBottom!, - 10 - ); + const styles = this.window.getComputedStyle(this.elementRef.nativeElement); + const lineHeight = parseInt(styles.lineHeight!, 10); - this.minHeight = this.lineHeight + paddingTop + paddingBottom; - this.freeRowsHeight = this.freeRowsHeight ?? this.lineHeight; + this.lineHeight.set(lineHeight); + this.minHeight.set(lineHeight + parseInt(styles.paddingTop!, 10) + parseInt(styles.paddingBottom!, 10)); }); setTimeout(this.grow, 0); @@ -294,13 +296,18 @@ export class KbqTextarea } } + /** @docs-private */ onBlur(): void { this.focusChanged(false); } - /** Grow textarea height to avoid vertical scroll */ + /** + * Grow textarea height to avoid vertical scroll. + * + * @docs-private + */ grow = () => { - if (!this.isBrowser || !this._canGrow) return; + if (!this.isBrowser || !this.canGrow()) return; this.ngZone.runOutsideAngular(() => { const textarea = this.elementRef.nativeElement; @@ -314,14 +321,17 @@ export class KbqTextarea clone.style.minHeight = '0'; // this line is important to height recalculation - const height = Math.max(this.minHeight, +clone.scrollHeight + diff + this.freeRowsHeight); + const lineHeight = this.lineHeight(); + const height = Math.max(this.minHeight(), +clone.scrollHeight + diff + this.resolvedFreeRowsHeight()); clone.remove(); - this.rowsCount = Math.floor(height / this.lineHeight); + this.rowsCount.set(lineHeight > 0 ? Math.floor(height / lineHeight) : 0); + + const maxRows = this.maxRows(); textarea.style.minHeight = coerceCssPixelValue( - this.maxRowLimitReached ? this.maxRows() * this.lineHeight : height + this.maxRowLimitReached() && maxRows !== undefined ? maxRows * lineHeight : height ); }); }; @@ -331,7 +341,11 @@ export class KbqTextarea this.elementRef.nativeElement.focus(); } - /** Callback for the cases where the focused state of the textarea changes. */ + /** + * Callback for the cases where the focused state of the textarea changes. + * + * @docs-private + */ focusChanged(isFocused: boolean) { if (isFocused !== this.focused) { this.focused = isFocused; diff --git a/packages/schematics/src/collection.json b/packages/schematics/src/collection.json index c5131fd7d8..ae60d8d368 100644 --- a/packages/schematics/src/collection.json +++ b/packages/schematics/src/collection.json @@ -158,6 +158,11 @@ "description": "Reports the breaking parts of the toast review — the members KbqToastComponent lost when it moved from KbqToastService to the KBQ_TOAST_STACK token, the narrowed animation subject and showTemplate context — and notes that the data passed to show() is no longer written to, so its style and icon defaults are resolved inside the toast", "factory": "./migrations/toast-stack-and-defaults/index", "schema": "./migrations/toast-stack-and-defaults/schema.json" + }, + "textarea-signals": { + "description": "Migrates KbqTextarea consumers to its signal-based inputs. KbqTextarea implements KbqFormFieldControl, which declares value, id, placeholder, required, disabled, focused, empty and errorState as plain members, so those stay plain accessors; what moved are the inputs the textarea owns. Rewrites programmatic reads of maxRows, freeRowsHeight and maxRowLimitReached to calls on receivers typed KbqTextarea. Warns - without auto-fixing - on canGrow, which is now a read-only InputSignal reporting what was bound instead of folding in the row limit, on writes to the read-only inputs, and on view/content queries that return the instance. Reports that maxRows and freeRowsHeight report number | undefined, that freeRowsHeight no longer overwrites its own input on init, that the max-row-limit class follows the row count directly, and that generated ids come from the CDK _IdGenerator.", + "factory": "./migrations/textarea-signals/index", + "schema": "./migrations/textarea-signals/schema.json" } } } diff --git a/packages/schematics/src/migrations.json b/packages/schematics/src/migrations.json index c4fe76dcc6..b281485555 100644 --- a/packages/schematics/src/migrations.json +++ b/packages/schematics/src/migrations.json @@ -120,6 +120,11 @@ "version": "20.3.0-0", "description": "Reports the breaking parts of the toast review. KbqToastComponent resolves its stack through the new KBQ_TOAST_STACK token instead of KbqToastService, so `service` is gone together with `elementRef`, `ttl`, `delay`, `isTemplateRef()`, `themePalette` and `toastStyle`, and everything the template renders became protected; KbqToastService.animation was narrowed from BehaviorSubject to Subject, so `.getValue()` / `.value` no longer exist; showTemplate() and `templates` return EmbeddedViewRef. Nothing is rewritten — a subclass has to be rewired by hand. Also notes the changes no call site can point at: the data passed to show() is no longer written to (the `style` and `icon` defaults are resolved inside the toast, so anything rendering the same object needs its own), hover and focus pause the shared countdown while a showTemplate() toast is not paused, every toast is a role=\"alert\"/\"status\" live region inside a labelled role=\"region\" stack named by the new `toastRegion` a11y locale key, and focus is handed on only for a keyboard dismissal.", "factory": "./migrations/toast-stack-and-defaults/index" + }, + "textarea-signals": { + "version": "20.3.0-0", + "description": "Migrates KbqTextarea consumers to its signal-based inputs. KbqTextarea implements KbqFormFieldControl, which declares value, id, placeholder, required, disabled, focused, empty and errorState as plain members, so those stay plain accessors; what moved are the inputs the textarea owns. Rewrites programmatic reads of maxRows, freeRowsHeight and maxRowLimitReached to calls on receivers typed KbqTextarea. Warns - without auto-fixing - on canGrow, which is now a read-only InputSignal reporting what was bound instead of folding in the row limit, on writes to the read-only inputs, and on view/content queries that return the instance. Reports that maxRows and freeRowsHeight report number | undefined, that freeRowsHeight no longer overwrites its own input on init, that the max-row-limit class follows the row count directly, and that generated ids come from the CDK _IdGenerator.", + "factory": "./migrations/textarea-signals/index" } } } diff --git a/packages/schematics/src/migrations/textarea-signals/README.md b/packages/schematics/src/migrations/textarea-signals/README.md new file mode 100644 index 0000000000..3cb3a2d90f --- /dev/null +++ b/packages/schematics/src/migrations/textarea-signals/README.md @@ -0,0 +1,64 @@ +# textarea-signals + +Migration schematic invoked automatically by `ng update @koobiq/components@20` (registered for +`20.3.0-0`). Migrates `KbqTextarea` consumers to its signal-based inputs. + +## Background + +`KbqTextarea` implements `KbqFormFieldControl`, which declares `value`, `id`, `placeholder`, +`required`, `disabled`, `focused`, `empty` and `errorState` as plain members. Those stay plain +accessors — the interface is the contract the form field reads them through. What moved are the four +inputs the textarea owns. + +`canGrow` was the odd one: + +```ts +get canGrow(): boolean { + return !this.maxRowLimitReached && this._canGrow; +} +``` + +It reported `false` once the textarea hit `maxRows`, even though the consumer had asked for growth. +The folded value drives the resize handle and is an internal `growing` computed now; `canGrow()` +reports what was bound. + +## What it rewrites + +| Before | After | +| ----------------------------- | ------------------------------- | +| `textarea.maxRows` | `textarea.maxRows()` | +| `textarea.freeRowsHeight` | `textarea.freeRowsHeight()` | +| `textarea.maxRowLimitReached` | `textarea.maxRowLimitReached()` | + +On receivers explicitly typed `KbqTextarea`. Already-migrated reads are left alone, so the schematic +is idempotent. There is no template pass: `kbqTextarea` is an attribute on a native ` + + ` +}) +class KbqTextareaValuelessFreeRowsHeight {} + @Component({ imports: [KbqTextareaModule, FormsModule], template: ` @@ -320,15 +364,62 @@ describe('KbqTextarea', () => { expect(getTextareaElement(fixture).classList.contains('kbq-textarea-resizable')).toBe(false); }); - it('should report the bound canGrow rather than folding in the row limit', () => { - const fixture = createComponent(KbqTextareaGrowWithMaxRows); + it('should report the bound canGrow rather than folding in the row limit', async () => { + // jsdom reports `line-height: normal`, which pins `rowsCount` at 0 and makes the limit + // unreachable - the distinguishing case is exactly the one the old getter got wrong. + const restore = mockScrollHeight(200); + + try { + const fixture = createComponent(KbqTextareaGrowWithMaxRows, [], [measuredLineHeight(20)]); + + fixture.detectChanges(); + await fixture.whenStable(); + + const textarea = fixture.debugElement.query(By.directive(KbqTextarea)).injector.get(KbqTextarea); + + textarea.grow(); + + expect(textarea.maxRowLimitReached()).toBe(true); + // The old getter returned `!maxRowLimitReached && bound`, i.e. false at this point. + expect(textarea.canGrow()).toBe(true); + } finally { + restore(); + } + }); + + it('should clamp the height and mark the limit once maxRows is exceeded', async () => { + const restore = mockScrollHeight(200); + + try { + const fixture = createComponent(KbqTextareaGrowWithMaxRows, [], [measuredLineHeight(20)]); + + fixture.detectChanges(); + await fixture.whenStable(); + + const textarea = fixture.debugElement.query(By.directive(KbqTextarea)).injector.get(KbqTextarea); + const element = getTextareaElement(fixture); + + textarea.grow(); + fixture.detectChanges(); + + // Clamped to `maxRows * lineHeight` rather than to the measured content height. + expect(element.style.minHeight).toBe('60px'); + expect(element.classList.contains('kbq-textarea_max-row-limit-reached')).toBe(true); + } finally { + restore(); + } + }); + + it('should report undefined for a valueless numeric attribute rather than NaN', () => { + const fixture = createComponent(KbqTextareaValuelessFreeRowsHeight); fixture.detectChanges(); const textarea = fixture.debugElement.query(By.directive(KbqTextarea)).injector.get(KbqTextarea); - expect(textarea.canGrow()).toBe(true); - expect(textarea.maxRowLimitReached()).toBe(false); + // `numberAttribute('')` is NaN, which is not nullish: it used to walk past every `??` and end + // up in `coerceCssPixelValue`, which yields `NaNpx` and is dropped by the CSSOM. + expect(textarea.freeRowsHeight()).toBeUndefined(); }); it('should leave maxRows and freeRowsHeight undefined when unbound', () => { diff --git a/packages/components/textarea/textarea.component.ts b/packages/components/textarea/textarea.component.ts index dadc1f32e9..05e612ac48 100644 --- a/packages/components/textarea/textarea.component.ts +++ b/packages/components/textarea/textarea.component.ts @@ -35,8 +35,13 @@ import { asapScheduler, observeOn, Subject } from 'rxjs'; export const KBQ_TEXTAREA_VALUE_ACCESSOR = new InjectionToken<{ value: any }>('KBQ_TEXTAREA_VALUE_ACCESSOR'); /** Coerces an optional numeric input, keeping `undefined` distinguishable from `0`. */ -const optionalNumberAttribute = (value: unknown): number | undefined => - value == null ? undefined : numberAttribute(value); +const optionalNumberAttribute = (value: unknown): number | undefined => { + const parsed = numberAttribute(value); + + // `numberAttribute` falls back to NaN, and NaN is not nullish: it would walk past every `??` below + // and end up in `coerceCssPixelValue`, which yields `NaNpx` and is dropped by the CSSOM. + return Number.isFinite(parsed) ? parsed : undefined; +}; @Directive({ selector: 'textarea[kbqTextarea]', @@ -205,8 +210,10 @@ export class KbqTextarea }); /** - * Whether the textarea still grows with its content. It stops once `maxRows` is reached, which is - * when the native resize handle takes over. + * Whether the textarea still grows with its content. It stops once `maxRows` is reached, and from + * there the element keeps its own scrollbar: `kbq-textarea_max-row-limit-reached` sets + * `resize: unset`, which follows `kbq-textarea-resizable` in the stylesheet and wins on source + * order, so no native handle appears. * * @docs-private */ @@ -327,7 +334,7 @@ export class KbqTextarea const maxRows = this.maxRows(); textarea.style.minHeight = coerceCssPixelValue( - this.maxRowLimitReached() && maxRows !== undefined ? maxRows * lineHeight : height + maxRows !== undefined && this.maxRowLimitReached() ? maxRows * lineHeight : height ); }); } diff --git a/packages/schematics/src/migrations/textarea-signals/README.md b/packages/schematics/src/migrations/textarea-signals/README.md index a9a48136e2..fa5a0213e3 100644 --- a/packages/schematics/src/migrations/textarea-signals/README.md +++ b/packages/schematics/src/migrations/textarea-signals/README.md @@ -19,8 +19,10 @@ get canGrow(): boolean { ``` It reported `false` once the textarea hit `maxRows`, even though the consumer had asked for growth. -The folded value drives the resize handle and is an internal `growing` computed now; `canGrow()` -reports what was bound. +The folded value is an internal `growing` computed now, and `canGrow()` reports what was bound. At the +row limit the element keeps its own scrollbar rather than gaining a native resize handle: +`kbq-textarea_max-row-limit-reached` sets `resize: unset`, which follows `kbq-textarea-resizable` in +the stylesheet and wins on source order. ## What it rewrites @@ -45,15 +47,20 @@ a reference variable is not tied to an element name the schematic can match. ## Notes with no call site to point at - **`maxRows` and `freeRowsHeight` report `number | undefined`.** Both were declared non-nullable - while an unbound textarea held `undefined`, and `maxRowLimitReached` compared against it — - `rowsCount > undefined` is false, which is why unlimited growth worked at all. + while an unbound `maxRows` held `undefined`, and `maxRowLimitReached` compared against it — + `rowsCount > undefined` is false, which is why unlimited growth worked at all. `freeRowsHeight` + differs: `ngOnInit` used to assign the measured line height into the input, so an unbound read came + back with a number. It stays `undefined` now, which is why the migration reports those reads rather + than rewriting them. - **`freeRowsHeight` no longer writes itself.** It defaulted to the measured line height by assigning its own input in `ngOnInit`; the fallback is a computed now, so binding it later actually takes effect instead of being overwritten on the next init. - **The `kbq-textarea_max-row-limit-reached` class follows the row count directly.** It is derived from a signal written inside `runOutsideAngular`, so the class used to wait for an unrelated change detection pass to appear. -- **Generated ids changed shape**, from `kbq-textarea-1` to `kbq-textarea-a1`. +- **Generated ids come from the CDK `_IdGenerator`** instead of a module-level counter. The shape is + unchanged for a default `APP_ID` - the CDK omits the app id when it is `ng` - and the per-prefix + counter still starts at 0, so a real app keeps getting `kbq-textarea-0`. ## Running it manually diff --git a/packages/schematics/src/migrations/textarea-signals/data.ts b/packages/schematics/src/migrations/textarea-signals/data.ts index 0f13069342..87205bec1d 100644 --- a/packages/schematics/src/migrations/textarea-signals/data.ts +++ b/packages/schematics/src/migrations/textarea-signals/data.ts @@ -6,32 +6,81 @@ * accessors. What moved are the inputs that belong to the textarea itself. * * - `textarea.canGrow` → a signal whose value changed: the getter folded in the row limit (warn) - * - `textarea.maxRows` / `freeRowsHeight` / `maxRowLimitReached` → calls (value unchanged — auto-fixed) - * - `textarea.freeRowsHeight = …` → the input is read-only now (warn) + * - `textarea.freeRowsHeight` → a signal whose value changed: the textarea used to write the measured + * line height into it on init, so an unbound read came back with a number (warn) + * - `textarea.maxRows` / `maxRowLimitReached` → calls (value unchanged — auto-fixed) + * - `textarea.grow` → a prototype method instead of a bound arrow property (warn) */ /** Members whose value is unchanged; a read must become a call. Auto-fixed. */ -export const SIGNAL_MEMBERS: readonly string[] = ['maxRows', 'freeRowsHeight', 'maxRowLimitReached']; - -/** - * Signal members that are writable via `.set(...)`. Every migrated member is an `input()` or a read-only - * `computed`, so this is empty — a programmatic write is left untouched and becomes a compile error. - */ -export const WRITABLE_MEMBERS: ReadonlySet = new Set(); +export const SIGNAL_MEMBERS: readonly string[] = ['maxRows', 'maxRowLimitReached']; /** TypeScript type annotation that marks a receiver as a textarea. */ export const TEXTAREA_TYPE = 'KbqTextarea'; +/** `exportAs` a template reference variable has to carry to point at the textarea. */ +export const TEXTAREA_EXPORT_AS = 'kbqTextarea'; + +/** Signal-API methods reachable on a signal; a read followed by one is already migrated. */ +export const SIGNAL_API_METHODS: ReadonlySet = new Set(['set', 'update', 'asReadonly', 'subscribe']); + +/** Reported for a value-changed member read through a template reference, which is not auto-fixed. */ +export const templateManualMessage = (members: Iterable): string => + `Read through a template reference variable and left untouched, because the value changed as well ` + + `as the shape: ${[...members].join(', ')}. Migrate those bindings by hand.`; + +/** Reported when a template names the textarea but cannot be parsed, so nothing in it was inspected. */ +export const UNPARSEABLE_TEMPLATE_MESSAGE = + 'This template names `kbqTextarea` but could not be parsed, so it was left untouched. Migrate reads ' + + 'through its template reference variables by hand.'; + /** Import specifier that marks a file as a textarea consumer. */ export const TEXTAREA_PACKAGE = '@koobiq/components/textarea'; /** - * `canGrow` became a read-only `InputSignal` AND changed its value: the getter used to return - * `!maxRowLimitReached && bound`, so it reported `false` once the textarea hit `maxRows` even though the - * consumer had asked for growth. It reports what was bound now — a mechanical `()` append would compile - * and hand back a different boolean. + * Read-only `InputSignal`s whose value also changed, so a mechanical `()` append would compile and hand + * back something else. + * + * `canGrow`: the getter used to return `!maxRowLimitReached && bound`, so it reported `false` once the + * textarea hit `maxRows` even though the consumer had asked for growth. It reports what was bound now. + * + * `freeRowsHeight`: `ngOnInit` used to assign the measured line height into the input, so an unbound + * textarea read back a number once the first microtask had run. The fallback is a private computed now + * and the input stays `undefined`, which turns `gap + 'px'` at a call site into `"undefined" + "px"` with no + * diagnostic. */ -export const VALUE_CHANGED_MEMBERS: readonly string[] = ['canGrow']; +export const VALUE_CHANGED_MEMBERS: readonly string[] = ['canGrow', 'freeRowsHeight']; + +/** + * Reported for a read of a member that still compiles after a mechanical `()` append but hands back a + * different value than it did before. + */ +export const valueChangedMessage = (members: Iterable): string => { + const names = [...members]; + const lines = [ + `${names.join(' and ')} ${names.length > 1 ? 'are' : 'is a'} read-only InputSignal` + + `${names.length > 1 ? 's' : ''} whose value also changed, so appending \`()\` compiles and ` + + 'hands back something else. Migrate by hand.' + ]; + + if (names.includes('canGrow')) { + lines.push( + '`canGrow` used to return `!maxRowLimitReached && bound`, so it reported false once the ' + + 'textarea hit `maxRows` even though the consumer had asked for growth; it reports what ' + + 'was bound now.' + ); + } + + if (names.includes('freeRowsHeight')) { + lines.push( + '`freeRowsHeight` used to be assigned the measured line height in `ngOnInit`, so an unbound ' + + 'textarea read back a number; the fallback is internal now and the input stays ' + + '`undefined`, which turns `gap + "px"` into `"undefined" + "px"` with no diagnostic.' + ); + } + + return lines.join(' '); +}; export interface WarnPattern { /** Owner of the member. The pattern is only evaluated for files that also name it. */ @@ -52,6 +101,15 @@ export const warnPatterns: WarnPattern[] = [ 'particular used to be written by the textarea itself on init, which is why the automated ' + 'migration skipped it.' }, + { + anchor: TEXTAREA_ANCHOR, + pattern: '\\.\\s*grow\\b(?!\\s*\\()', + message: + 'KbqTextarea.grow is a prototype method now, not a bound arrow property, so a detached ' + + 'reference loses `this`: `setTimeout(textarea.grow, 0)` and ' + + '`el.addEventListener("input", textarea.grow)` throw at the first property read. Call it ' + + 'through the instance - `() => textarea.grow()` - or bind it.' + }, { anchor: TEXTAREA_ANCHOR, pattern: '(?:viewChild|ViewChild|contentChild|ContentChild)[^\\n;]*\\bKbqTextarea\\b', @@ -73,7 +131,8 @@ export const SUMMARY = [ ' The `kbq-textarea_max-row-limit-reached` class follows the row count directly. It is derived from a ' + 'signal written inside `runOutsideAngular`, so the class used to wait for an unrelated change ' + 'detection pass to appear.', - ' Generated ids come from the CDK `_IdGenerator`, so their shape changed from `kbq-textarea-1` to ' + - '`kbq-textarea-a1` — the app id is part of the prefix now, which keeps two Angular apps on one page ' + - 'from colliding.' + ' Generated ids come from the CDK `_IdGenerator` instead of a module-level counter. The shape is ' + + 'unchanged for a default `APP_ID`: the CDK omits the app id when it is `ng`, and the per-prefix ' + + 'counter still starts at 0, so a real app keeps getting `kbq-textarea-0`. Only an app that sets ' + + '`APP_ID` explicitly sees it in the id, and the counter is shared per prefix rather than per module.' ]; diff --git a/packages/schematics/src/migrations/textarea-signals/index.spec.ts b/packages/schematics/src/migrations/textarea-signals/index.spec.ts index 5ed19a1bc5..04e49a3a4b 100644 --- a/packages/schematics/src/migrations/textarea-signals/index.spec.ts +++ b/packages/schematics/src/migrations/textarea-signals/index.spec.ts @@ -3,7 +3,9 @@ import { Tree } from '@angular-devkit/schematics'; import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; import { getWorkspace } from '@schematics/angular/utility/workspace'; import * as path from 'path'; +import { lastValueFrom } from 'rxjs'; import { createTestApp } from '../../utils/testing'; +import textareaSignals from './index'; import { Schema } from './schema'; const collectionPath = path.join(__dirname, '../../collection.json'); @@ -46,6 +48,12 @@ describe(SCHEMATIC_NAME, () => { return paths(projects.get(first)!).ts; } + function firstHtmlPath(): string { + const [first] = projects.keys(); + + return paths(projects.get(first)!).html; + } + it('rewrites maxRows reads on a parameter typed KbqTextarea (incl. optional chain) to calls', async () => { const ts = firstTsPath(); @@ -90,14 +98,17 @@ describe(SCHEMATIC_NAME, () => { ' maxRows = 3;\n' + '}\n' + 'class Demo {\n' + - ' read(other: Other) {\n' + - ' return other.maxRows;\n' + + ' read(textarea: KbqTextarea, other: Other) {\n' + + ' return textarea.maxRows + other.maxRows;\n' + ' }\n' + '}\n'; appTree.overwrite(ts, source); - expect((await run()).readText(ts)).toBe(source); + const updated = (await run()).readText(ts); + + // Only the textarea receiver is rewritten; `other.maxRows` is a plain number on an unrelated class. + expect(updated).toContain('textarea.maxRows() + other.maxRows'); }); it('is idempotent — an already migrated read is left alone', async () => { @@ -144,7 +155,7 @@ describe(SCHEMATIC_NAME, () => { appTree.overwrite(ts, source); expect((await run()).readText(ts)).toContain('return textarea.canGrow;'); - expect(messages.join('\n')).toContain('maxRows'); + expect(messages.join('\n')).toContain('read-only InputSignal'); }); it('warns about a write to freeRowsHeight', async () => { @@ -209,4 +220,136 @@ describe(SCHEMATIC_NAME, () => { expect((await run(false)).readText(ts)).toBe(source); expect(messages.join('\n')).toContain('would update'); }); + + it('applies the migration when `fix` is absent, as it is under `ng update`', async () => { + const ts = firstTsPath(); + const [first] = projects.keys(); + + appTree.overwrite( + ts, + "import { KbqTextarea } from '@koobiq/components/textarea';\n" + + 'class Demo {\n' + + ' read(textarea: KbqTextarea) {\n' + + ' return textarea.maxRows;\n' + + ' }\n' + + '}\n' + ); + + // Called through the rule rather than `runSchematic`: `ng update` runs the factory straight from + // migrations.json, which carries no schema, so the `fix` default in schema.json never applies. + const updated = await lastValueFrom(runner.callRule(textareaSignals({ project: first }), appTree)); + + expect(updated.readText(ts)).toContain('return textarea.maxRows();'); + }); + + it('reports a compound assignment instead of rewriting it into invalid syntax', async () => { + const ts = firstTsPath(); + const source = + "import { KbqTextarea } from '@koobiq/components/textarea';\n" + + 'class Demo {\n' + + ' rows = 2;\n' + + ' write(textarea: KbqTextarea) {\n' + + ' textarea.maxRows += 4;\n' + + ' textarea.maxRows ??= this.rows;\n' + + ' }\n' + + '}\n'; + + appTree.overwrite(ts, source); + + // `textarea.maxRows() += 4` is not assignable to, so the file would stop parsing. + expect((await run()).readText(ts)).toBe(source); + }); + + it('reports an increment and a delete instead of rewriting them', async () => { + const ts = firstTsPath(); + const source = + "import { KbqTextarea } from '@koobiq/components/textarea';\n" + + 'class Demo {\n' + + ' write(textarea: KbqTextarea) {\n' + + ' textarea.maxRows++;\n' + + ' delete (textarea as any).maxRows;\n' + + ' }\n' + + '}\n'; + + appTree.overwrite(ts, source); + + expect((await run()).readText(ts)).toBe(source); + }); + + it('rewrites a read under a negation rather than treating it as a write', async () => { + const ts = firstTsPath(); + + appTree.overwrite( + ts, + "import { KbqTextarea } from '@koobiq/components/textarea';\n" + + 'class Demo {\n' + + ' read(textarea: KbqTextarea) {\n' + + ' return !textarea.maxRowLimitReached;\n' + + ' }\n' + + '}\n' + ); + + expect((await run()).readText(ts)).toContain('return !textarea.maxRowLimitReached();'); + }); + + it('warns about freeRowsHeight instead of rewriting it', async () => { + const ts = firstTsPath(); + const source = + "import { KbqTextarea } from '@koobiq/components/textarea';\n" + + 'class Demo {\n' + + ' read(textarea: KbqTextarea) {\n' + + ' return textarea.freeRowsHeight;\n' + + ' }\n' + + '}\n'; + + appTree.overwrite(ts, source); + + // The old `ngOnInit` assigned the measured line height into the input, so a mechanical `()` would + // compile and start reporting `undefined`. + expect((await run()).readText(ts)).toBe(source); + expect(messages.join('\n')).toContain('freeRowsHeight'); + }); + + it('warns about a detached reference to grow', async () => { + const ts = firstTsPath(); + + appTree.overwrite( + ts, + "import { KbqTextarea } from '@koobiq/components/textarea';\n" + + 'class Demo {\n' + + ' schedule(textarea: KbqTextarea) {\n' + + ' setTimeout(textarea.grow, 0);\n' + + ' }\n' + + '}\n' + ); + + await run(); + + expect(messages.join('\n')).toContain('prototype method'); + }); + + it('rewrites a read through a template reference variable', async () => { + const html = firstHtmlPath(); + + appTree.overwrite( + html, + '\n' + + '
{{ t.maxRows }}
\n' + ); + + const updated = (await run()).readText(html); + + expect(updated).toContain('[class.at-limit]="t.maxRowLimitReached()"'); + expect(updated).toContain('{{ t.maxRows() }}'); + }); + + it('reports a value-changed member read through a template reference', async () => { + const html = firstHtmlPath(); + const source = '\n
{{ t.canGrow }}
\n'; + + appTree.overwrite(html, source); + + expect((await run()).readText(html)).toBe(source); + expect(messages.join('\n')).toContain('canGrow'); + }); }); diff --git a/packages/schematics/src/migrations/textarea-signals/index.ts b/packages/schematics/src/migrations/textarea-signals/index.ts index cccf97b1c5..92eba60470 100644 --- a/packages/schematics/src/migrations/textarea-signals/index.ts +++ b/packages/schematics/src/migrations/textarea-signals/index.ts @@ -1,21 +1,28 @@ import { Path } from '@angular-devkit/core'; import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; import ts from 'typescript'; +import { visitAll, Visitor } from '../../utils/ast'; import { logMessage } from '../../utils/messages'; import { setupOptions } from '../../utils/package-config'; +import { collectInlineTemplateRanges, parseTemplate } from '../../utils/typescript'; import { + SIGNAL_API_METHODS, SIGNAL_MEMBERS, SUMMARY, + templateManualMessage, + TEXTAREA_EXPORT_AS, TEXTAREA_PACKAGE, TEXTAREA_TYPE, + UNPARSEABLE_TEMPLATE_MESSAGE, VALUE_CHANGED_MEMBERS, - warnPatterns, - WRITABLE_MEMBERS + valueChangedMessage, + warnPatterns } from './data'; import { Schema } from './schema'; const LABEL = '[textarea-signals]'; const TS_EXT = '.ts'; +const HTML_EXT = '.html'; /** A text-span edit on the original file content. Applied right-to-left so offsets stay valid. */ interface Edit { @@ -130,32 +137,73 @@ function inReceiverScope(node: ts.PropertyAccessExpression, sourceFile: ts.Sourc return receivers.some((r) => r.text === receiverText && start >= r.start && end <= r.end); } +/** Binary operators that make their left operand a write target rather than a read. */ +const ASSIGNMENT_OPERATORS = new Set([ + ts.SyntaxKind.EqualsToken, + ts.SyntaxKind.PlusEqualsToken, + ts.SyntaxKind.MinusEqualsToken, + ts.SyntaxKind.AsteriskEqualsToken, + ts.SyntaxKind.AsteriskAsteriskEqualsToken, + ts.SyntaxKind.SlashEqualsToken, + ts.SyntaxKind.PercentEqualsToken, + ts.SyntaxKind.LessThanLessThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, + ts.SyntaxKind.AmpersandEqualsToken, + ts.SyntaxKind.BarEqualsToken, + ts.SyntaxKind.CaretEqualsToken, + ts.SyntaxKind.BarBarEqualsToken, + ts.SyntaxKind.AmpersandAmpersandEqualsToken, + ts.SyntaxKind.QuestionQuestionEqualsToken +]); + +/** Unary operators that write their operand back. Every other prefix operator is a plain read. */ +const INCREMENT_OPERATORS = new Set([ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken]); + +/** Whether `node` sits on the left of a destructuring assignment, where it is written rather than read. */ +function isDestructuringTarget(node: ts.Node): boolean { + let current: ts.Node = node; + + while ( + ts.isPropertyAssignment(current.parent) || + ts.isShorthandPropertyAssignment(current.parent) || + ts.isSpreadAssignment(current.parent) || + ts.isSpreadElement(current.parent) || + ts.isObjectLiteralExpression(current.parent) || + ts.isArrayLiteralExpression(current.parent) + ) { + current = current.parent; + } + + return ( + ts.isBinaryExpression(current.parent) && + current.parent.left === current && + ASSIGNMENT_OPERATORS.has(current.parent.operatorToken.kind) + ); +} + /** Classifies a matched property access and appends the resulting edit(s). */ -function classifyAccess(node: ts.PropertyAccessExpression, sourceFile: ts.SourceFile, edits: Edit[]): void { +function classifyAccess(node: ts.PropertyAccessExpression, edits: Edit[]): void { const parent = node.parent; - // Already migrated: a call, or a `.set(...)` write — leave alone (idempotent). + // Already migrated: `x.maxRows()` — leave alone (idempotent). if (ts.isCallExpression(parent) && parent.expression === node) return; - if (ts.isPropertyAccessExpression(parent) && parent.expression === node && parent.name.text === 'set') return; - - // Write target: `x.member = RHS`. Every KbqTextarea signal member is `input()` (read-only), so there is no - // writable member — leave the write untouched (it becomes a compile error the consumer fixes by hand). - if ( - ts.isBinaryExpression(parent) && - parent.left === node && - parent.operatorToken.kind === ts.SyntaxKind.EqualsToken - ) { - if (WRITABLE_MEMBERS.has(node.name.text)) { - const rhs = parent.right; - edits.push({ start: node.getEnd(), end: rhs.getStart(sourceFile), text: '.set(' }); - edits.push({ start: rhs.getEnd(), end: rhs.getEnd(), text: ')' }); - } + // Write target, in every shape. Each migrated member is an `input()` or a read-only `computed`, so a + // write has no mechanical translation: it is left untouched and becomes the read-only error the + // consumer fixes by hand. Appending `()` to it would produce unparseable TypeScript instead. + if (ts.isBinaryExpression(parent) && parent.left === node && ASSIGNMENT_OPERATORS.has(parent.operatorToken.kind)) + return; + // `x.maxRows++` / `--x.maxRows` and `delete x.maxRows` are writes too. Only the increment operators + // count: a `PrefixUnaryExpression` is also how `!x.maxRowLimitReached` is spelled, and that is a read. + if (ts.isPostfixUnaryExpression(parent) && parent.operand === node) return; + if (ts.isPrefixUnaryExpression(parent) && parent.operand === node && INCREMENT_OPERATORS.has(parent.operator)) return; - } + if (ts.isDeleteExpression(parent)) return; + if (isDestructuringTarget(node)) return; - // Read (incl. optional chain `x?.compact`): append `()`. + // Read (incl. optional chain `x?.maxRows`): append `()`. edits.push({ start: node.getEnd(), end: node.getEnd(), text: '()' }); } @@ -170,7 +218,7 @@ function collectAccessEdits(sourceFile: ts.SourceFile, receivers: Receiver[]): E SIGNAL_MEMBERS.includes(node.name.text) && inReceiverScope(node, sourceFile, receivers) ) { - classifyAccess(node, sourceFile, edits); + classifyAccess(node, edits); } node.forEachChild(visit); @@ -225,12 +273,7 @@ function warnReceiverMembers(context: SchematicContext, filePath: string, conten const valueChanged = collectValueChangedAccess(sourceFile, receivers); if (valueChanged.size > 0) { - logMessage(context.logger, [ - `${LABEL} ${filePath}`, - ` \`canGrow\` is a read-only InputSignal — read it as \`textarea.canGrow()\`. Its value also`, - ` changed: the getter used to return \`!maxRowLimitReached && bound\`, so it reported false once`, - ` the textarea hit \`maxRows\` even though the consumer had asked for growth. Migrate by hand.` - ]); + logMessage(context.logger, [`${LABEL} ${filePath}`, ` ${valueChangedMessage(valueChanged)}`]); } } @@ -242,6 +285,127 @@ function logWarnings(context: SchematicContext, filePath: string, content: strin } } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Matches `.` where the access is neither already a call nor a signal-API call. A template can + * only read these, so an assignment never needs excluding. The dot is matched with the whitespace around it + * - Angular's expression grammar allows `t . maxRows` and a binding wrapped over two lines - and that + * whitespace is captured so the rewrite keeps the layout. + */ +function memberAccessPattern(ref: string, members: readonly string[]): RegExp { + const methods = [...SIGNAL_API_METHODS].join('|'); + + return new RegExp( + `\\b(${escapeRegExp(ref)})(\\s*\\.\\s*)(${members.join('|')})\\b(?!\\s*\\()(?!\\s*\\.\\s*(?:${methods})\\b)`, + 'g' + ); +} + +/** Reference variables bound to the textarea: `#t="kbqTextarea"` on any element. */ +class TemplateCollector implements Visitor { + readonly refs = new Set(); + + visitElement(element: any): void { + for (const attr of element.attrs ?? []) { + if (typeof attr.name !== 'string' || attr.value !== TEXTAREA_EXPORT_AS) continue; + + if (attr.name.startsWith('#')) this.refs.add(attr.name.slice(1)); + else if (attr.name.startsWith('ref-')) this.refs.add(attr.name.slice(4)); + } + + this.visitChildren(element); + } + + visitBlock(block: any): void { + this.visitChildren(block); + } + + visitChildren(node: any): void { + visitAll(this, node.children ?? []); + } + + visitAttribute(): void {} + visitText(): void {} + visitComment(): void {} + visitExpansion(): void {} + visitExpansionCase(): void {} + visitBlockParameter(): void {} + visitLetDeclaration(): void {} +} + +interface TemplateResult { + content: string; + changed: boolean; + /** Members read through a ref that the rewrite deliberately leaves alone. */ + manual: Set; + unparseable: boolean; +} + +/** Pass B - rewrite reads through a textarea reference variable and report the ones that changed value. */ +async function migrateTemplate(template: string): Promise { + const untouched: TemplateResult = { content: template, changed: false, manual: new Set(), unparseable: false }; + + if (!template.includes(TEXTAREA_EXPORT_AS)) return untouched; + + const parsed = await parseTemplate(template); + + if (!parsed.tree) return { ...untouched, unparseable: true }; + + const collector = new TemplateCollector(); + + visitAll(collector, (parsed.tree as { rootNodes: unknown[] }).rootNodes); + + const refs = [...collector.refs]; + + if (refs.length === 0) return untouched; + + const manual = new Set(); + let content = template; + let changed = false; + + for (const ref of refs) { + for (const match of template.matchAll(memberAccessPattern(ref, VALUE_CHANGED_MEMBERS))) { + manual.add(match[3]); + } + + const next = content.replace(memberAccessPattern(ref, SIGNAL_MEMBERS), '$1$2$3()'); + + if (next !== content) { + content = next; + changed = true; + } + } + + return { content, changed, manual, unparseable: false }; +} + +/** Pass B (inline) - the same, inside inline component templates. */ +async function migrateInlineTemplates(content: string, fileName: string): Promise { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const manual = new Set(); + let result = content; + let changed = false; + let unparseable = false; + + // Splice right-to-left so earlier offsets stay valid. + for (const { start, end } of collectInlineTemplateRanges(sourceFile).sort((a, b) => b.start - a.start)) { + const migrated = await migrateTemplate(result.slice(start, end)); + + migrated.manual.forEach((member) => manual.add(member)); + unparseable ||= migrated.unparseable; + + if (migrated.changed) { + result = result.slice(0, start) + migrated.content + result.slice(end); + changed = true; + } + } + + return { content: result, changed, manual, unparseable }; +} + /** * A `.ts` file is a textarea consumer if it names any of the exported symbols or imports the package. There * is no element to look for: `kbqTextarea` is an attribute on a native `