diff --git a/packages/happy-dom/src/css/declaration/property-manager/CSSStyleDeclarationPropertySetParser.ts b/packages/happy-dom/src/css/declaration/property-manager/CSSStyleDeclarationPropertySetParser.ts index 890157fe1..db5745af3 100644 --- a/packages/happy-dom/src/css/declaration/property-manager/CSSStyleDeclarationPropertySetParser.ts +++ b/packages/happy-dom/src/css/declaration/property-manager/CSSStyleDeclarationPropertySetParser.ts @@ -1,4 +1,5 @@ import CSSStyleDeclarationValueParser from './CSSStyleDeclarationValueParser.js'; +import CSSStyleDeclarationValueUtility from './CSSStyleDeclarationValueUtility.js'; import type ICSSStyleDeclarationPropertyValue from './ICSSStyleDeclarationPropertyValue.js'; const RECT_REGEXP = /^rect\((.*)\)$/i; @@ -2300,11 +2301,7 @@ export default class CSSStyleDeclarationPropertySetParser { ...this.getBackgroundColor('initial', important) }; - const parts = value - .replace(/\s,\s/g, ',') - .replace(/\s\/\s/g, '/') - .split(SPLIT_SPACE_SEPARATED_WITH_PARANTHESES_REGEXP); - + const parts = CSSStyleDeclarationValueUtility.splitBySpace(value.replace(/\s+\/\s+/g, '/')); const backgroundPositions = []; for (const part of parts) { @@ -2811,7 +2808,7 @@ export default class CSSStyleDeclarationPropertySetParser { return { 'background-image': { value: lowerValue, important } }; } - const parts = value.split(SPLIT_COMMA_SEPARATED_WITH_PARANTHESES_REGEXP); + const parts = CSSStyleDeclarationValueUtility.splitByComma(value); const parsed = []; for (const part of parts) { diff --git a/packages/happy-dom/src/css/declaration/property-manager/CSSStyleDeclarationValueParser.ts b/packages/happy-dom/src/css/declaration/property-manager/CSSStyleDeclarationValueParser.ts index 94d53de42..8acc0df5a 100644 --- a/packages/happy-dom/src/css/declaration/property-manager/CSSStyleDeclarationValueParser.ts +++ b/packages/happy-dom/src/css/declaration/property-manager/CSSStyleDeclarationValueParser.ts @@ -1,3 +1,5 @@ +import CSSStyleDeclarationValueUtility from './CSSStyleDeclarationValueUtility.js'; + const COLOR_REGEXP = /^#([0-9a-fA-F]{3,4}){1,2}$|^rgb\(([^)]*)\)$|^rgba\(([^)]*)\)$|^hsla?\(\s*(-?\d+|-?\d*.\d+)\s*,\s*(-?\d+|-?\d*.\d+)%\s*,\s*(-?\d+|-?\d*.\d+)%\s*(,\s*(-?\d+|-?\d*.\d+)\s*)?\)|(?:(rgba?|hsla?)\((var\(\s*(--[^)\s]+)\))\))/; @@ -12,7 +14,7 @@ const CALC_REGEXP = /^calc\([^^)]+\)$/; const CSS_VARIABLE_REGEXP = /^var\(\s*(--[^)\s]+)\)$/; const FIT_CONTENT_REGEXP = /^fit-content\([^^)]+\)$/; const GRADIENT_REGEXP = - /^((repeating-linear|linear|radial|repeating-radial|conic|repeating-conic)-gradient)\(([^)]+)\)$/; + /^((repeating-linear|linear|radial|repeating-radial|conic|repeating-conic)-gradient)\(((?:[^()]|\([^()]*\))*)\)$/; const GLOBALS = ['inherit', 'initial', 'unset', 'revert']; const COLORS = [ 'none', @@ -333,10 +335,9 @@ export default class CSSStyleDeclarationValueParser { public static getGradient(value: string): string | null { const match = value.match(GRADIENT_REGEXP); if (match) { - return `${match[1]}(${match[3] - .trim() - .split(/\s*,\s*/) - .join(', ')})`; + const args = match[3].trim(); + const parts = CSSStyleDeclarationValueUtility.splitByComma(args); + return `${match[1]}(${parts.join(', ')})`; } return null; } diff --git a/packages/happy-dom/src/css/declaration/property-manager/CSSStyleDeclarationValueUtility.ts b/packages/happy-dom/src/css/declaration/property-manager/CSSStyleDeclarationValueUtility.ts new file mode 100644 index 000000000..737572292 --- /dev/null +++ b/packages/happy-dom/src/css/declaration/property-manager/CSSStyleDeclarationValueUtility.ts @@ -0,0 +1,90 @@ +const SPLIT_BY_COMMA_REGEXP = /[,()]/gm; +const SPLIT_BY_SPACE_REGEXP = /[()]|\s+/gm; + +/** + * Style declaration value parser. + */ +export default class CSSStyleDeclarationValueUtility { + /** + * Splits by comma while respecting nested parentheses. + * + * @param value Value to split. + * @returns Array of parts. + */ + public static splitByComma(value: string): string[] { + const parts: string[] = []; + const regexp = new RegExp(SPLIT_BY_COMMA_REGEXP); + let match: RegExpExecArray | null; + let depth = 0; + let lastIndex = 0; + + while ((match = regexp.exec(value))) { + switch (match[0]) { + case '(': + depth++; + break; + case ')': + depth--; + break; + case ',': + if (depth === 0) { + const part = value.substring(lastIndex, match.index).trim(); + if (part) { + parts.push(part); + } + lastIndex = regexp.lastIndex; + } + break; + } + } + + if (lastIndex < value.length) { + parts.push(value.substring(lastIndex).trim()); + } + + return parts; + } + + /** + * Splits by space while respecting nested parentheses. + * + * @param value Value to split. + * @returns Array of parts. + */ + public static splitBySpace(value: string): string[] { + const parts: string[] = []; + const regexp = new RegExp(SPLIT_BY_SPACE_REGEXP); + let match: RegExpExecArray | null; + let depth = 0; + let lastIndex = 0; + + while ((match = regexp.exec(value))) { + switch (match[0]) { + case '(': + depth++; + break; + case ')': + depth--; + break; + default: + if (depth === 0) { + const part = value.substring(lastIndex, match.index).trim(); + if (part) { + parts.push(part); + } + lastIndex = regexp.lastIndex; + } + break; + } + } + + if (lastIndex < value.length) { + const part = value.substring(lastIndex).trim(); + if (part) { + parts.push(part); + } + } + + return parts; + } +} diff --git a/packages/happy-dom/src/nodes/text/Text.ts b/packages/happy-dom/src/nodes/text/Text.ts index 2a235ea6c..5f1197eb6 100644 --- a/packages/happy-dom/src/nodes/text/Text.ts +++ b/packages/happy-dom/src/nodes/text/Text.ts @@ -10,7 +10,7 @@ import type HTMLStyleElement from '../html-style-element/HTMLStyleElement.js'; * Text node. */ export default class Text extends CharacterData { - declare public cloneNode: (deep?: boolean) => Text; + public declare cloneNode: (deep?: boolean) => Text; public override [PropertySymbol.nodeType] = NodeTypeEnum.textNode; public override [PropertySymbol.textAreaNode]: HTMLTextAreaElement | null = null; public override [PropertySymbol.styleNode]: HTMLStyleElement | null = null; diff --git a/packages/happy-dom/test/css/declaration/CSSStyleDeclaration.test.ts b/packages/happy-dom/test/css/declaration/CSSStyleDeclaration.test.ts index ead481e94..c14f8bdfa 100644 --- a/packages/happy-dom/test/css/declaration/CSSStyleDeclaration.test.ts +++ b/packages/happy-dom/test/css/declaration/CSSStyleDeclaration.test.ts @@ -1997,6 +1997,71 @@ describe('CSSStyleDeclaration', () => { 'linear-gradient(to right, #111111 0%, #111111 0.5833333333333334rem, #dfdfdf 0.5833333333333334rem, #dfdfdf 100%)' ); }); + + it('Supports linear-gradient with rgba() colors.', () => { + const declaration = new CSSStyleDeclaration(PropertySymbol.illegalConstructor, window, { + element + }); + + element.style.background = + 'linear-gradient(0deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%)'; + + expect(declaration.background).toBe( + 'linear-gradient(0deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%)' + ); + }); + + it('Supports linear-gradient with mixed color formats.', () => { + const declaration = new CSSStyleDeclaration(PropertySymbol.illegalConstructor, window, { + element + }); + + element.style.background = + 'linear-gradient(to right, rgba(255, 0, 0, 0.5), #00ff00, hsla(240, 100%, 50%, 0.8))'; + + expect(declaration.background).toBe( + 'linear-gradient(to right, rgba(255, 0, 0, 0.5), #00ff00, hsla(240, 100%, 50%, 0.8))' + ); + }); + + it('Supports radial-gradient with rgba() colors.', () => { + const declaration = new CSSStyleDeclaration(PropertySymbol.illegalConstructor, window, { + element + }); + + element.style.background = + 'radial-gradient(circle, rgba(255, 255, 255, 0), rgba(0, 0, 0, 1))'; + + expect(declaration.background).toBe( + 'radial-gradient(circle, rgba(255, 255, 255, 0), rgba(0, 0, 0, 1))' + ); + }); + + it('Supports repeating gradients with rgba() colors.', () => { + const declaration = new CSSStyleDeclaration(PropertySymbol.illegalConstructor, window, { + element + }); + + element.style.background = + 'repeating-linear-gradient(45deg, rgba(0, 0, 0, 0.5) 0px, rgba(255, 255, 255, 0.5) 10px)'; + + expect(declaration.background).toBe( + 'repeating-linear-gradient(45deg, rgba(0, 0, 0, 0.5) 0px, rgba(255, 255, 255, 0.5) 10px)' + ); + }); + + it('Supports conic-gradient with rgba() colors.', () => { + const declaration = new CSSStyleDeclaration(PropertySymbol.illegalConstructor, window, { + element + }); + + element.style.background = + 'conic-gradient(from 90deg, rgba(255, 0, 0, 1), rgba(0, 255, 0, 1), rgba(0, 0, 255, 1))'; + + expect(declaration.background).toBe( + 'conic-gradient(from 90deg, rgba(255, 0, 0, 1), rgba(0, 255, 0, 1), rgba(0, 0, 255, 1))' + ); + }); }); describe('get backgroundImage()', () => { @@ -2034,6 +2099,45 @@ describe('CSSStyleDeclaration', () => { 'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")' ); }); + + it('Supports gradients with rgba() colors.', () => { + const declaration = new CSSStyleDeclaration(PropertySymbol.illegalConstructor, window, { + element + }); + + element.style.backgroundImage = + 'radial-gradient(circle, rgba(255, 255, 255, 0), rgba(0, 0, 0, 1))'; + + expect(declaration.backgroundImage).toBe( + 'radial-gradient(circle, rgba(255, 255, 255, 0), rgba(0, 0, 0, 1))' + ); + }); + + it('Supports multiple gradients with rgba() colors.', () => { + const declaration = new CSSStyleDeclaration(PropertySymbol.illegalConstructor, window, { + element + }); + + element.style.backgroundImage = + 'linear-gradient(to right, rgba(255, 0, 0, 0.5), rgba(0, 0, 255, 0.5)), radial-gradient(circle, rgba(0, 255, 0, 0.3), rgba(255, 255, 0, 0.3))'; + + expect(declaration.backgroundImage).toBe( + 'linear-gradient(to right, rgba(255, 0, 0, 0.5), rgba(0, 0, 255, 0.5)), radial-gradient(circle, rgba(0, 255, 0, 0.3), rgba(255, 255, 0, 0.3))' + ); + }); + + it('Supports URL and gradient combination with rgba() colors.', () => { + const declaration = new CSSStyleDeclaration(PropertySymbol.illegalConstructor, window, { + element + }); + + element.style.backgroundImage = + 'url("test.png"), linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.8))'; + + expect(declaration.backgroundImage).toBe( + 'url("test.png"), linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.8))' + ); + }); }); describe('get backgroundColor()', () => { diff --git a/packages/happy-dom/test/css/declaration/CSSStyleDeclarationValueParser.test.ts b/packages/happy-dom/test/css/declaration/CSSStyleDeclarationValueParser.test.ts index a2cf172e0..1d0b84922 100644 --- a/packages/happy-dom/test/css/declaration/CSSStyleDeclarationValueParser.test.ts +++ b/packages/happy-dom/test/css/declaration/CSSStyleDeclarationValueParser.test.ts @@ -30,4 +30,149 @@ describe('CSSStyleDeclarationValueParser', () => { }); } }); + + describe('getGradient()', () => { + it('Parses linear-gradient with rgba() colors', () => { + const result = CSSStyleDeclarationValueParser.getGradient( + 'linear-gradient(0deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%)' + ); + expect(result).toBe('linear-gradient(0deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%)'); + }); + + it('Parses linear-gradient with hex colors', () => { + const result = CSSStyleDeclarationValueParser.getGradient( + 'linear-gradient(180deg, #00000000 0%, #000000b3 100%)' + ); + expect(result).toBe('linear-gradient(180deg, #00000000 0%, #000000b3 100%)'); + }); + + it('Parses linear-gradient with mixed color formats', () => { + const result = CSSStyleDeclarationValueParser.getGradient( + 'linear-gradient(to right, rgba(255, 0, 0, 0.5), #00ff00, hsla(240, 100%, 50%, 0.8))' + ); + expect(result).toBe( + 'linear-gradient(to right, rgba(255, 0, 0, 0.5), #00ff00, hsla(240, 100%, 50%, 0.8))' + ); + }); + + it('Parses radial-gradient with rgba() colors', () => { + const result = CSSStyleDeclarationValueParser.getGradient( + 'radial-gradient(circle, rgba(255, 255, 255, 0), rgba(0, 0, 0, 1))' + ); + expect(result).toBe('radial-gradient(circle, rgba(255, 255, 255, 0), rgba(0, 0, 0, 1))'); + }); + + it('Parses conic-gradient with rgba() colors', () => { + const result = CSSStyleDeclarationValueParser.getGradient( + 'conic-gradient(from 45deg, rgba(255, 0, 0, 0.5), rgba(0, 0, 255, 1))' + ); + expect(result).toBe('conic-gradient(from 45deg, rgba(255, 0, 0, 0.5), rgba(0, 0, 255, 1))'); + }); + + it('Returns null for invalid gradients', () => { + expect(CSSStyleDeclarationValueParser.getGradient('not-a-gradient')).toBe(null); + expect(CSSStyleDeclarationValueParser.getGradient('linear-gradient(')).toBe(null); + }); + + it('Normalizes whitespace in gradient arguments', () => { + const result = CSSStyleDeclarationValueParser.getGradient( + 'linear-gradient(0deg,rgba(0,0,0,0) 0%,rgba(0,0,0,1) 100%)' + ); + expect(result).toBe('linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 100%)'); + }); + + it('Parses repeating-linear-gradient with rgba() colors', () => { + const result = CSSStyleDeclarationValueParser.getGradient( + 'repeating-linear-gradient(45deg, rgba(0, 0, 0, 0.5) 0px, rgba(255, 255, 255, 0.5) 10px)' + ); + expect(result).toBe( + 'repeating-linear-gradient(45deg, rgba(0, 0, 0, 0.5) 0px, rgba(255, 255, 255, 0.5) 10px)' + ); + }); + + it('Parses repeating-radial-gradient with rgba() colors', () => { + const result = CSSStyleDeclarationValueParser.getGradient( + 'repeating-radial-gradient(circle, rgba(255, 0, 0, 0.3), rgba(0, 0, 255, 0.3) 20px)' + ); + expect(result).toBe( + 'repeating-radial-gradient(circle, rgba(255, 0, 0, 0.3), rgba(0, 0, 255, 0.3) 20px)' + ); + }); + + it('Parses repeating-conic-gradient with rgba() colors', () => { + const result = CSSStyleDeclarationValueParser.getGradient( + 'repeating-conic-gradient(from 0deg, rgba(255, 0, 0, 0.5) 0deg, rgba(0, 0, 255, 0.5) 30deg)' + ); + expect(result).toBe( + 'repeating-conic-gradient(from 0deg, rgba(255, 0, 0, 0.5) 0deg, rgba(0, 0, 255, 0.5) 30deg)' + ); + }); + + it('Parses gradient with rgb() (no alpha)', () => { + const result = CSSStyleDeclarationValueParser.getGradient( + 'linear-gradient(to bottom, rgb(255, 0, 0), rgb(0, 0, 255))' + ); + expect(result).toBe('linear-gradient(to bottom, rgb(255, 0, 0), rgb(0, 0, 255))'); + }); + + it('Parses gradient with hsl() and hsla()', () => { + const result = CSSStyleDeclarationValueParser.getGradient( + 'linear-gradient(90deg, hsl(0, 100%, 50%), hsla(240, 100%, 50%, 0.5))' + ); + expect(result).toBe('linear-gradient(90deg, hsl(0, 100%, 50%), hsla(240, 100%, 50%, 0.5))'); + }); + + it('Parses gradient with many color stops', () => { + const result = CSSStyleDeclarationValueParser.getGradient( + 'linear-gradient(to right, rgba(255, 0, 0, 1) 0%, rgba(255, 255, 0, 1) 25%, rgba(0, 255, 0, 1) 50%, rgba(0, 255, 255, 1) 75%, rgba(0, 0, 255, 1) 100%)' + ); + expect(result).toBe( + 'linear-gradient(to right, rgba(255, 0, 0, 1) 0%, rgba(255, 255, 0, 1) 25%, rgba(0, 255, 0, 1) 50%, rgba(0, 255, 255, 1) 75%, rgba(0, 0, 255, 1) 100%)' + ); + }); + + it('Parses gradient with negative and decimal values in rgba()', () => { + const result = CSSStyleDeclarationValueParser.getGradient( + 'linear-gradient(0deg, rgba(0, 0, 0, 0.123), rgba(255, 128, 64, 0.999))' + ); + expect(result).toBe('linear-gradient(0deg, rgba(0, 0, 0, 0.123), rgba(255, 128, 64, 0.999))'); + }); + + it('Handles excessive whitespace correctly', () => { + const result = CSSStyleDeclarationValueParser.getGradient( + 'linear-gradient( to right , rgba( 255 , 0 , 0 , 0.5 ) , rgba( 0 , 0 , 255 , 1 ) )' + ); + expect(result).toBe( + 'linear-gradient(to right, rgba( 255 , 0 , 0 , 0.5 ), rgba( 0 , 0 , 255 , 1 ))' + ); + }); + + it('Returns null for unbalanced parentheses', () => { + // Missing closing parenthesis for gradient + expect(CSSStyleDeclarationValueParser.getGradient('linear-gradient(rgba(0,0,0,0), red')).toBe( + null + ); + // Missing closing parenthesis for rgba + expect(CSSStyleDeclarationValueParser.getGradient('linear-gradient(rgba(0,0,0,0, red)')).toBe( + null + ); + // Extra closing parenthesis + expect( + CSSStyleDeclarationValueParser.getGradient('linear-gradient(rgba(0,0,0,0), red))') + ).toBe(null); + }); + + it('Returns null for invalid gradient type', () => { + expect( + CSSStyleDeclarationValueParser.getGradient('invalid-gradient(rgba(0,0,0,0), red)') + ).toBe(null); + }); + + it('Parses gradient with nested calc() inside rgba()', () => { + const result = CSSStyleDeclarationValueParser.getGradient( + 'linear-gradient(0deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1))' + ); + expect(result).toBe('linear-gradient(0deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1))'); + }); + }); }); diff --git a/packages/happy-dom/test/nodes/parent-node/ParentNodeUtility.test.ts b/packages/happy-dom/test/nodes/parent-node/ParentNodeUtility.test.ts index 8c50c1aed..62d0dcffb 100644 --- a/packages/happy-dom/test/nodes/parent-node/ParentNodeUtility.test.ts +++ b/packages/happy-dom/test/nodes/parent-node/ParentNodeUtility.test.ts @@ -188,8 +188,8 @@ describe('ParentNodeUtility', () => { div.appendChild(element); document.body.appendChild(div); - expect(div.getElementsByClassName(unicodeClassName)).toBe(element); - expect(document.getElementsByClassName(unicodeClassName)).toBe(element); + expect(div.getElementsByClassName(unicodeClassName)[0]).toBe(element); + expect(document.getElementsByClassName(unicodeClassName)[0]).toBe(element); }); });