From f51d8da120900f841b8878e9b53cb69f69a47d1f Mon Sep 17 00:00:00 2001 From: "Bajohr, Rayk" Date: Fri, 21 Aug 2026 12:45:32 +0200 Subject: [PATCH] feat(split)!: migrate showCollapseButton to collapsible Closes #2596 BREAKING CHANGE: The `showCollapseButton` input on si-split-part has been removed. The collapse button will now be rendered when the `collapsible` input is provided. Before: ```html ``` After: ```html ``` --- api-goldens/element-ng/split/index.api.md | 3 +- .../si-list-details.component.html | 2 - .../si-main-detail-container.component.html | 2 - .../schematics/ng-update/index.spec.ts | 156 ++++++++++-- .../ng-update/migrate-split-collapse.ts | 234 +++++++++++++++--- .../split/si-split-part.component.html | 2 +- .../split/si-split-part.component.spec.ts | 25 ++ .../split/si-split-part.component.ts | 12 +- .../split/si-split.component.spec.ts | 3 - .../si-split/si-split-hide-collapse.html | 8 +- .../si-split/si-split-hide-header.html | 1 - src/app/examples/si-split/si-split-mixed.html | 1 - 12 files changed, 364 insertions(+), 85 deletions(-) create mode 100644 projects/element-ng/split/si-split-part.component.spec.ts diff --git a/api-goldens/element-ng/split/index.api.md b/api-goldens/element-ng/split/index.api.md index 2d6fbd40b6..2c773e45ac 100644 --- a/api-goldens/element-ng/split/index.api.md +++ b/api-goldens/element-ng/split/index.api.md @@ -56,7 +56,7 @@ export class SiSplitPartComponent { readonly collapseLabel: _angular_core.InputSignal; readonly collapseOthers: _angular_core.InputSignalWithTransform; readonly collapseToMinSize: _angular_core.InputSignalWithTransform; - readonly collapsible: _angular_core.InputSignal; + readonly collapsible: _angular_core.InputSignal; readonly expanded: _angular_core.InputSignalWithTransform; readonly headerTemplate: _angular_core.InputSignal; readonly removeContentOnCollapse: _angular_core.InputSignalWithTransform; readonly scale: _angular_core.InputSignal; - readonly showCollapseButton: _angular_core.InputSignalWithTransform; readonly showHeader: _angular_core.InputSignalWithTransform; readonly size: _angular_core.InputSignalWithTransform; readonly stateChange: _angular_core.OutputEmitterRef; diff --git a/projects/element-ng/list-details/si-list-details.component.html b/projects/element-ng/list-details/si-list-details.component.html index f05b6b20a1..6ab488714c 100644 --- a/projects/element-ng/list-details/si-list-details.component.html +++ b/projects/element-ng/list-details/si-list-details.component.html @@ -9,7 +9,6 @@ scale="none" unit="fr" [size]="splitSizes()[0]" - [showCollapseButton]="false" [showHeader]="false" [minSize]="minListSize()" [stateId]="listStateId()" @@ -20,7 +19,6 @@ scale="auto" unit="fr" [size]="splitSizes()[1]" - [showCollapseButton]="false" [showHeader]="false" [minSize]="minDetailsSize()" [stateId]="detailsStateId()" diff --git a/projects/element-ng/main-detail-container/si-main-detail-container.component.html b/projects/element-ng/main-detail-container/si-main-detail-container.component.html index 4a278e151a..db6739a0e4 100644 --- a/projects/element-ng/main-detail-container/si-main-detail-container.component.html +++ b/projects/element-ng/main-detail-container/si-main-detail-container.component.html @@ -11,7 +11,6 @@ scale="none" unit="fr" [size]="splitSizes[0]" - [showCollapseButton]="false" [showHeader]="false" [minSize]="minMainSize()" [stateId]="mainStateId()" @@ -22,7 +21,6 @@ scale="auto" unit="fr" [size]="splitSizes[1]" - [showCollapseButton]="false" [showHeader]="false" [minSize]="minDetailSize()" [stateId]="detailStateId()" diff --git a/projects/element-ng/schematics/ng-update/index.spec.ts b/projects/element-ng/schematics/ng-update/index.spec.ts index 0a406942ca..6de612544f 100644 --- a/projects/element-ng/schematics/ng-update/index.spec.ts +++ b/projects/element-ng/schematics/ng-update/index.spec.ts @@ -95,7 +95,7 @@ export const appConfig: ApplicationConfig = { @Component({ selector: 'app-split', - template: \` + template: \` Left Center Right @@ -108,9 +108,9 @@ export class SplitComponent {}` const modifiedContent = tree.readContent('/projects/app/src/split.component.ts'); expect(modifiedContent).not.toContain('[sizes]'); - expect(modifiedContent).toContain('Left'); - expect(modifiedContent).toContain('Center'); - expect(modifiedContent).toContain('Right'); + expect(modifiedContent).toContain(' { @@ -132,12 +132,8 @@ export class SplitComponent {}`, const modifiedContent = tree.readContent('/projects/app/src/split.component.html'); expect(modifiedContent).not.toContain('[sizes]'); - expect(modifiedContent).toContain( - 'Left' - ); - expect(modifiedContent).toContain( - 'Right' - ); + expect(modifiedContent).toContain(' { @@ -168,8 +164,8 @@ export class SplitComponent {}` const tree = await runner.runSchematic('migration-v51', {}, appTree); const modifiedContent = tree.readContent('/projects/app/src/split.component.ts'); - expect(modifiedContent).toContain('Left'); - expect(modifiedContent).toContain('Right'); + expect(modifiedContent).toContain(' { @@ -190,12 +186,8 @@ export class SplitComponent {}`, const tree = await runner.runSchematic('migration-v51', {}, appTree); const modifiedContent = tree.readContent('/projects/app/src/split.component.html'); - expect(modifiedContent).toContain( - 'Left' - ); - expect(modifiedContent).toContain( - 'Right' - ); + expect(modifiedContent).toContain(' { @@ -209,10 +201,8 @@ export class SplitComponent {}`, const tree = await runner.runSchematic('migration-v51', {}, appTree); const modifiedContent = tree.readContent('/projects/app/src/split.component.html'); - expect(modifiedContent).toContain('Left'); - expect(modifiedContent).toContain( - 'Right' - ); + expect(modifiedContent).toContain(' { @@ -234,8 +224,8 @@ export class SplitComponent {}`, const modifiedContent = tree.readContent('/projects/app/src/split.component.html'); expect(modifiedContent).not.toContain('[sizes]'); - expect(modifiedContent).toContain('Left'); - expect(modifiedContent).toContain('Right'); + expect(modifiedContent).toContain(''); - expect(modifiedContent).toContain(''); + expect(modifiedContent).toContain(' { @@ -303,4 +293,118 @@ export class ExternalSplitComponent {}`, expect(template).toContain(''); expect(template).toContain('
'); }); + + it('should add the former default collapse direction to collapsible split parts', async () => { + addTestFiles(appTree, { + '/projects/app/src/split.component.ts': `import { Component } from '@angular/core'; + +@Component({ + selector: 'app-split', + template: \` + + +\` +}) +export class SplitComponent {}` + }); + + const tree = await runner.runSchematic('migration-v51', {}, appTree); + const component = tree.readContent('/projects/app/src/split.component.ts'); + + expect(component).not.toContain('showCollapseButton'); + expect(component.match(/collapsible="to-start"/g)).toHaveLength(4); + }); + + it('should remove false collapse buttons and their legacy directions', async () => { + addTestFiles(appTree, { + '/projects/app/src/split.component.ts': `import { Component } from '@angular/core'; + +@Component({ + selector: 'app-split', + template: \` + +\` +}) +export class SplitComponent {}`, + '/projects/app/src/external-split.component.ts': `import { Component } from '@angular/core'; + +@Component({ + selector: 'app-external-split', + templateUrl: './split.component.html' +}) +export class ExternalSplitComponent {}`, + '/projects/app/src/split.component.html': ` +

Content

+
` + }); + + const tree = await runner.runSchematic('migration-v51', {}, appTree); + const component = tree.readContent('/projects/app/src/split.component.ts'); + const template = tree.readContent('/projects/app/src/split.component.html'); + + expect(component).not.toContain('showCollapseButton'); + expect(component).not.toContain('collapseDirection'); + expect(component).not.toContain('collapsible'); + expect(template).toBe(` +

Content

+
`); + }); + + it('should conditionally migrate dynamic collapse button bindings', async () => { + addTestFiles(appTree, { + '/projects/app/src/split.component.ts': `import { Component } from '@angular/core'; + +@Component({ + selector: 'app-split', + template: \` + + +
\` +}) +export class SplitComponent { + readonly canCollapse = false; + readonly direction = 'end'; +}` + }); + + const tree = await runner.runSchematic('migration-v51', {}, appTree); + const component = tree.readContent('/projects/app/src/split.component.ts'); + + const condition = `![false, null, undefined, 'false'].includes($any(canCollapse))`; + expect(component).toContain(`[collapsible]="${condition} ? 'to-start' : undefined"`); + expect(component).toContain(`[collapsible]="${condition} ? 'to-end' : undefined"`); + expect(component).toContain(`[collapsible]="${condition} ? (direction) : undefined"`); + expect(component).toContain('
'); + expect(component.match(/showCollapseButton/g)).toHaveLength(1); + expect(component).not.toContain('collapseDirection'); + }); + + it('should preserve an explicit collapsible input when removing showCollapseButton=false', async () => { + addTestFiles(appTree, { + '/projects/app/src/split.component.ts': `import { Component } from '@angular/core'; + +@Component({ + selector: 'app-split', + template: \`\` +}) +export class SplitComponent {}` + }); + + const tree = await runner.runSchematic('migration-v51', {}, appTree); + const component = tree.readContent('/projects/app/src/split.component.ts'); + + expect(component).toBe(`import { Component } from '@angular/core'; + +@Component({ + selector: 'app-split', + template: \`\` +}) +export class SplitComponent {}`); + }); }); diff --git a/projects/element-ng/schematics/ng-update/migrate-split-collapse.ts b/projects/element-ng/schematics/ng-update/migrate-split-collapse.ts index 32c11e83b0..f5dbd6c56b 100644 --- a/projects/element-ng/schematics/ng-update/migrate-split-collapse.ts +++ b/projects/element-ng/schematics/ng-update/migrate-split-collapse.ts @@ -4,6 +4,7 @@ */ import { Rule, SchematicContext, Tree, UpdateRecorder } from '@angular-devkit/schematics'; +import type { Attribute, Element } from '@angular/compiler'; import { dirname, join } from 'path/posix'; import ts from 'typescript'; @@ -18,6 +19,9 @@ const collapseValues = new Map([ ['start', 'to-start'], ['end', 'to-end'] ]); +const collapseDirectionNames = ['collapseDirection', '[collapseDirection]']; +const collapsibleNames = ['collapsible', '[collapsible]']; +const showCollapseButtonNames = ['showCollapseButton', '[showCollapseButton]']; export const splitCollapseMigrationRule = (options: { path: string }): Rule => { return async (tree: Tree, context: SchematicContext) => { @@ -64,49 +68,217 @@ const migrateSplitCollapseTemplate = ( recorder: UpdateRecorder ): void => { findElement(template, element => element.name === 'si-split-part').forEach(element => { - element.attrs - .filter( - attribute => - attribute.name === 'collapseDirection' || attribute.name === '[collapseDirection]' - ) - .forEach(attribute => { - const isBound = attribute.name.startsWith('['); - const attributeNameOffset = attribute.sourceSpan.start.offset + offset + (isBound ? 1 : 0); - recorder.remove(attributeNameOffset, 'collapseDirection'.length); - recorder.insertLeft(attributeNameOffset, 'collapsible'); - - const value = isBound - ? getStringLiteral(attribute.value) - : collapseValues.get(attribute.value); - if (!value || !attribute.valueSpan) { - return; - } - - const valueOffset = attribute.valueSpan.start.offset + offset; - recorder.remove( - valueOffset, - attribute.valueSpan.end.offset - attribute.valueSpan.start.offset - ); - recorder.insertLeft(valueOffset, isBound ? `'${value}'` : value); - }); + migrateSplitCollapseElement(template, element, offset, recorder); }); }; +const migrateSplitCollapseElement = ( + template: string, + element: Element, + offset: number, + recorder: UpdateRecorder +): void => { + const showCollapseButton = element.attrs.find(attribute => + showCollapseButtonNames.includes(attribute.name) + ); + const collapseDirections = element.attrs.filter(attribute => + collapseDirectionNames.includes(attribute.name) + ); + const collapseDirection = collapseDirections[0]; + const collapsible = element.attrs.find(attribute => collapsibleNames.includes(attribute.name)); + + if (showCollapseButton) { + const staticValue = getStaticBooleanValue(showCollapseButton); + if (staticValue === undefined) { + const target = collapsible ?? collapseDirection ?? showCollapseButton; + replaceAttribute( + target, + formatBoundAttribute( + 'collapsible', + `${getBooleanAttributeCondition(getShowCollapseButtonExpression(showCollapseButton))} ? ${getCollapsibleExpression(collapsible ?? collapseDirection)} : undefined` + ), + offset, + recorder + ); + if (target !== showCollapseButton) { + removeAttribute(template, showCollapseButton, offset, recorder); + } + collapseDirections + .filter(attribute => attribute !== target) + .forEach(attribute => removeAttribute(template, attribute, offset, recorder)); + return; + } + + removeAttribute(template, showCollapseButton, offset, recorder); + if (!staticValue && !collapsible) { + collapseDirections.forEach(attribute => + removeAttribute(template, attribute, offset, recorder) + ); + return; + } + } + + if (collapsible) { + collapseDirections.forEach(attribute => removeAttribute(template, attribute, offset, recorder)); + return; + } + + if (collapseDirections.length > 0) { + collapseDirections.forEach(attribute => migrateCollapseDirection(attribute, offset, recorder)); + } else { + insertAttribute(template, element, 'collapsible="to-start"', offset, recorder); + } +}; + +const migrateCollapseDirection = ( + attribute: Attribute, + offset: number, + recorder: UpdateRecorder +): void => { + const isBound = attribute.name.startsWith('['); + const attributeNameOffset = attribute.sourceSpan.start.offset + offset + (isBound ? 1 : 0); + recorder.remove(attributeNameOffset, 'collapseDirection'.length); + recorder.insertLeft(attributeNameOffset, 'collapsible'); + + const value = isBound ? getStringLiteral(attribute.value) : collapseValues.get(attribute.value); + if (!value || !attribute.valueSpan) { + return; + } + + const valueOffset = attribute.valueSpan.start.offset + offset; + recorder.remove(valueOffset, attribute.valueSpan.end.offset - attribute.valueSpan.start.offset); + recorder.insertLeft(valueOffset, isBound ? `'${value}'` : value); +}; + +const getCollapsibleExpression = (attribute: Attribute | undefined): string => { + if (!attribute) { + return `'to-start'`; + } + + if (attribute.name.startsWith('[')) { + const migratedLiteral = getStringLiteral(attribute.value); + return migratedLiteral ? `'${migratedLiteral}'` : `(${attribute.value})`; + } + + return `'${collapseValues.get(attribute.value) ?? attribute.value}'`; +}; + +const getStaticBooleanValue = (attribute: Attribute): boolean | undefined => { + if (!attribute.name.startsWith('[')) { + if (/^\s*{{[\s\S]*}}\s*$/.test(attribute.value)) { + return undefined; + } + return attribute.value !== 'false'; + } + + const initializer = getExpression(attribute.value); + if (!initializer) { + return undefined; + } + + if (initializer.kind === ts.SyntaxKind.TrueKeyword) { + return true; + } + if ( + initializer.kind === ts.SyntaxKind.FalseKeyword || + initializer.kind === ts.SyntaxKind.NullKeyword || + (ts.isIdentifier(initializer) && initializer.text === 'undefined') || + (ts.isStringLiteral(initializer) && initializer.text === 'false') + ) { + return false; + } + if (ts.isStringLiteral(initializer) || ts.isNumericLiteral(initializer)) { + return true; + } + + return undefined; +}; + +const getShowCollapseButtonExpression = (attribute: Attribute): string => { + if (attribute.name.startsWith('[')) { + return attribute.value; + } + + return attribute.value.replace(/^\s*{{\s*|\s*}}\s*$/g, ''); +}; + +const getBooleanAttributeCondition = (expression: string): string => + `![false, null, undefined, 'false'].includes($any(${expression}))`; + +const formatBoundAttribute = (name: string, expression: string): string => { + const quote = expression.includes('"') && !expression.includes("'") ? "'" : '"'; + const escapedExpression = + quote === '"' ? expression.replaceAll('"', '"') : expression.replaceAll("'", '''); + return `[${name}]=${quote}${escapedExpression}${quote}`; +}; + +const replaceAttribute = ( + attribute: Attribute, + replacement: string, + offset: number, + recorder: UpdateRecorder +): void => { + const start = attribute.sourceSpan.start.offset + offset; + recorder.remove(start, attribute.sourceSpan.end.offset - attribute.sourceSpan.start.offset); + recorder.insertLeft(start, replacement); +}; + +const insertAttribute = ( + template: string, + element: Element, + attribute: string, + offset: number, + recorder: UpdateRecorder +): void => { + const selfClosing = element.startSourceSpan.toString().endsWith('/>'); + const insertOffset = element.startSourceSpan.end.offset - (selfClosing ? 2 : 1); + const prefix = /\s/.test(template[insertOffset - 1]) ? '' : ' '; + recorder.insertLeft(insertOffset + offset, `${prefix}${attribute}${selfClosing ? ' ' : ''}`); +}; + +const removeAttribute = ( + template: string, + attribute: { sourceSpan: { start: { offset: number }; end: { offset: number } } }, + offset: number, + recorder: UpdateRecorder +): void => { + const start = attribute.sourceSpan.start.offset; + const end = attribute.sourceSpan.end.offset; + const lineStart = template.lastIndexOf('\n', start - 1) + 1; + const lineEnd = template.indexOf('\n', end); + const endOfLine = lineEnd === -1 ? template.length : lineEnd; + if ( + template.slice(lineStart, start).trim() === '' && + template.slice(end, endOfLine).trim() === '' + ) { + const removeEnd = lineEnd === -1 ? endOfLine : lineEnd + 1; + recorder.remove(lineStart + offset, removeEnd - lineStart); + return; + } + + const removeStart = start > 0 && /\s/.test(template[start - 1]) ? start - 1 : start; + recorder.remove(removeStart + offset, attribute.sourceSpan.end.offset - removeStart); +}; + const getStringLiteral = (value: string): string | undefined => { - const expression = ts.createSourceFile( + const initializer = getExpression(value); + return initializer && ts.isStringLiteral(initializer) + ? collapseValues.get(initializer.text) + : undefined; +}; + +const getExpression = (value: string): ts.Expression | undefined => { + const statement = ts.createSourceFile( 'template-expression.ts', `const value = ${value};`, ts.ScriptTarget.Latest, true ).statements[0]; - if (!expression || !ts.isVariableStatement(expression)) { + if (!statement || !ts.isVariableStatement(statement)) { return undefined; } - const initializer = expression.declarationList.declarations[0]?.initializer; - return initializer && ts.isStringLiteral(initializer) - ? collapseValues.get(initializer.text) - : undefined; + return statement.declarationList.declarations[0]?.initializer; }; const migrateCollapseToLiterals = (sourceFile: ts.SourceFile, recorder: UpdateRecorder): void => { diff --git a/projects/element-ng/split/si-split-part.component.html b/projects/element-ng/split/si-split-part.component.html index 92f34a5dc5..36557f97ba 100644 --- a/projects/element-ng/split/si-split-part.component.html +++ b/projects/element-ng/split/si-split-part.component.html @@ -18,7 +18,7 @@ } } - @if (showCollapseButton()) { + @if (collapsible()) {