diff --git a/scripts/jest/setup/mocks.js b/scripts/jest/setup/mocks.js index 1f7dc9cdb80..a2d782a8b89 100644 --- a/scripts/jest/setup/mocks.js +++ b/scripts/jest/setup/mocks.js @@ -15,10 +15,12 @@ jest.mock('./../../../src/components/icon', () => { return { EuiIcon }; }); -jest.mock('./../../../src/components/text_truncate', () => { - const rest = jest.requireActual('./../../../src/components/text_truncate'); - const utils = require('./../../../src/components/text_truncate/utils.testenv'); - return { ...rest, ...utils }; +jest.mock('./../../../src/services/canvas', () => { + const rest = jest.requireActual('./../../../src/services/canvas'); + const { + CanvasTextUtils, + } = require('./../../../src/services/canvas/canvas_text_utils.testenv'); + return { ...rest, CanvasTextUtils }; }); jest.mock('./../../../src/services/accessibility', () => { diff --git a/src/components/combo_box/combo_box_input/combo_box_input.tsx b/src/components/combo_box/combo_box_input/combo_box_input.tsx index 4a34bd4675d..9ec39055588 100644 --- a/src/components/combo_box/combo_box_input/combo_box_input.tsx +++ b/src/components/combo_box/combo_box_input/combo_box_input.tsx @@ -16,8 +16,7 @@ import React, { import classNames from 'classnames'; import { CommonProps } from '../../common'; -import { htmlIdGenerator, keys } from '../../../services'; -import { CanvasTextUtils } from '../../text_truncate'; +import { htmlIdGenerator, keys, CanvasTextUtils } from '../../../services'; import { EuiScreenReaderOnly } from '../../accessibility'; import { EuiFormControlLayout, diff --git a/src/components/text_truncate/index.ts b/src/components/text_truncate/index.ts index 97059eef856..a2785e5a6c7 100644 --- a/src/components/text_truncate/index.ts +++ b/src/components/text_truncate/index.ts @@ -12,4 +12,4 @@ export type { } from './text_truncate'; export { EuiTextTruncate } from './text_truncate'; -export { CanvasTextUtils, TruncationUtils } from './utils'; +export { TruncationUtils } from './utils'; diff --git a/src/components/text_truncate/utils.test.ts b/src/components/text_truncate/utils.test.ts index df76b8e40cb..b4e248f7274 100644 --- a/src/components/text_truncate/utils.test.ts +++ b/src/components/text_truncate/utils.test.ts @@ -6,47 +6,7 @@ * Side Public License, v 1. */ -import { CanvasTextUtils, TruncationUtils } from './utils'; - -let mockCanvasWidth = 0; -Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { - value: () => ({ measureText: () => ({ width: mockCanvasWidth }), font: '' }), -}); - -describe('CanvasTextUtils', () => { - describe('font calculations', () => { - it('computes the set font if passed a container element', () => { - const container = document.createElement('div'); - container.style.font = '14px Inter'; - - const utils = new CanvasTextUtils({ container }); - expect(utils.context.font).toEqual('14px Inter'); - }); - - it('accepts a static font string', () => { - const utils = new CanvasTextUtils({ font: '14px Inter' }); - expect(utils.context.font).toEqual('14px Inter'); - }); - }); - - describe('text width utils', () => { - const utils = new CanvasTextUtils({ font: '' }); - - describe('textWidth', () => { - it('returns the measured text width from the canvas', () => { - mockCanvasWidth = 200; - expect(utils.textWidth).toEqual(200); - }); - }); - - describe('setTextToCheck', () => { - it('sets the internal currentText variable', () => { - utils.setTextToCheck('hello world'); - expect(utils.currentText).toEqual('hello world'); - }); - }); - }); -}); +import { TruncationUtils } from './utils'; describe('TruncationUtils', () => { const params = { @@ -56,6 +16,11 @@ describe('TruncationUtils', () => { font: '14px Inter', }; + const setMockTextWidth = (width: number) => (utils: TruncationUtils) => { + // @ts-ignore - mocked canvas_text_utils.testenv allows setting this value + utils.textWidth = width; + }; + // A few utilities log errors - silence them and capture the messages const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); beforeEach(() => consoleErrorSpy.mockClear()); @@ -69,7 +34,7 @@ describe('TruncationUtils', () => { describe('setTextWidthRatio', () => { it('sets the ratio of the available width to the full text width', () => { - mockCanvasWidth = 10000; + setMockTextWidth(10000)(utils); utils.setTextWidthRatio(); expect(utils.widthRatio).toEqual(0.1); }); @@ -77,7 +42,7 @@ describe('TruncationUtils', () => { it('allow measuring passed text and deducting an offset width', () => { // Note: there isn't a super great way to mock a real-world example of this // in Jest because mockCanvasWidth applies to both the measured text and excluded text - mockCanvasWidth = 500; + setMockTextWidth(500)(utils); utils.setTextWidthRatio('text to measure', 'some excluded text'); expect(utils.widthRatio).toEqual(1); }); @@ -85,7 +50,7 @@ describe('TruncationUtils', () => { describe('getTextFromRatio', () => { it('splits the passed text string by the ratio determined by `setTextWidthRatio`', () => { - mockCanvasWidth = 3000; + setMockTextWidth(3000)(utils); utils.setTextWidthRatio(); // 0.33 // Should split the strings by the last/first third expect(utils.getTextFromRatio('Lorem ipsum', 'start')).toEqual('psum'); @@ -99,36 +64,36 @@ describe('TruncationUtils', () => { describe('checkIfTruncationIsNeeded', () => { it('returns false if truncation is not needed', () => { - mockCanvasWidth = 100; + setMockTextWidth(100)(utils); expect(utils.checkIfTruncationIsNeeded()).toEqual(false); - mockCanvasWidth = 400; + setMockTextWidth(400)(utils); expect(utils.checkIfTruncationIsNeeded()).toBeUndefined(); }); }); describe('checkSufficientEllipsisWidth', () => { it('returns false and errors if the container is not wide enough for the ellipsis', () => { - mockCanvasWidth = 201; + setMockTextWidth(201)(utils); expect(utils.checkSufficientEllipsisWidth('startEnd')).toEqual(false); expect(consoleErrorSpy).toHaveBeenCalledWith( 'The truncation ellipsis is larger than the available width. No text can be rendered.' ); - mockCanvasWidth = 10; + setMockTextWidth(10)(utils); expect(utils.checkSufficientEllipsisWidth('start')).toBeUndefined(); }); }); describe('checkTruncationOffsetWidth', () => { it('returns false and errors if the container is not wide enough for the offset text', () => { - mockCanvasWidth = 201; + setMockTextWidth(201)(utils); expect(utils.checkTruncationOffsetWidth('hello')).toEqual(false); expect(consoleErrorSpy).toHaveBeenCalledWith( 'The passed truncationOffset is too large for the available width. Truncating the offset instead.' ); - mockCanvasWidth = 200; + setMockTextWidth(200)(utils); expect(utils.checkTruncationOffsetWidth('world')).toBeUndefined(); }); }); diff --git a/src/components/text_truncate/utils.testenv.ts b/src/components/text_truncate/utils.testenv.ts deleted file mode 100644 index 9018cacc784..00000000000 --- a/src/components/text_truncate/utils.testenv.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { TruncationUtils as _TruncationUtils } from './utils'; - -export class CanvasTextUtils { - constructor(_: any) {} - - computeFontFromElement = (_: HTMLElement) => ''; - - textWidth = 0; - - currentText = ''; - setTextToCheck = (text: string) => { - this.currentText = text; - }; -} - -export class TruncationUtils extends _TruncationUtils { - constructor(props: ConstructorParameters[0]) { - super(props); - } - - // Jest perf optimization - since there's no meaningful truncation we can make - // without meaningful width calculations, just return the full untruncated text - truncateStart = (_?: number) => this.fullText; - truncateEnd = (_?: number) => this.fullText; - truncateStartEndAtPosition = (_?: number) => this.fullText; - truncateStartEndAtMiddle = () => this.fullText; - truncateMiddle = () => this.fullText; -} diff --git a/src/components/text_truncate/utils.ts b/src/components/text_truncate/utils.ts index fc7d39e6c6b..6c7fce65c38 100644 --- a/src/components/text_truncate/utils.ts +++ b/src/components/text_truncate/utils.ts @@ -6,72 +6,14 @@ * Side Public License, v 1. */ -import type { ExclusiveUnion } from '../common'; +import { CanvasTextParams, CanvasTextUtils } from '../../services/canvas'; -type CanvasTextParams = ExclusiveUnion< - { container: HTMLElement }, - { font: CanvasTextDrawingStyles['font'] } ->; type TruncationParams = CanvasTextParams & { fullText: string; ellipsis: string; availableWidth: number; }; -/** - * Under the hood, a temporary Canvas element is created for manipulating text - * & determining text width. - * - * To accurately measure text, canvas rendering requires either a container to - * compute/derive font styles from, or a static font string (useful for usage - * outside the DOM). Particular care should be applied when fallback fonts are - * used, as more fallback fonts can lead to less precision. - * - * Please note that while canvas is more significantly more performant than DOM - * measurement, there are subpixel to single digit pixel differences between - * DOM and canvas measurement due to the different rendering engines used. - */ -export class CanvasTextUtils { - context: CanvasRenderingContext2D; - currentText = ''; - - constructor({ font, container }: CanvasTextParams) { - this.context = document.createElement('canvas').getContext('2d')!; - - // Set the canvas font to ensure text width calculations are correct - if (font) { - this.context.font = font; - } else if (container) { - this.context.font = this.computeFontFromElement(container); - } - } - - computeFontFromElement = (element: HTMLElement) => { - const computedStyles = window.getComputedStyle(element); - // TODO: font-stretch is not included even though it potentially should be - // @see https://developer.mozilla.org/en-US/docs/Web/CSS/font#constituent_properties - // It appears to be unsupported and/or breaks font computation in canvas - return [ - 'font-style', - 'font-variant', - 'font-weight', - 'font-size', - 'font-family', - ] - .map((prop) => computedStyles.getPropertyValue(prop)) - .join(' ') - .trim(); - }; - - get textWidth() { - return this.context.measureText(this.currentText).width; - } - - setTextToCheck = (text: string) => { - this.currentText = text; - }; -} - /** * Utilities for truncating types at various positions, as well as * determining whether truncation is possible or even necessary. diff --git a/src/services/canvas/canvas_text_utils.test.ts b/src/services/canvas/canvas_text_utils.test.ts new file mode 100644 index 00000000000..9c032e110ae --- /dev/null +++ b/src/services/canvas/canvas_text_utils.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CanvasTextUtils } from './canvas_text_utils'; + +let mockCanvasWidth = 0; +Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + value: () => ({ measureText: () => ({ width: mockCanvasWidth }), font: '' }), +}); + +describe('CanvasTextUtils', () => { + describe('font calculations', () => { + it('computes the set font if passed a container element', () => { + const container = document.createElement('div'); + container.style.font = '14px Inter'; + + const utils = new CanvasTextUtils({ container }); + expect(utils.context.font).toEqual('14px Inter'); + }); + + it('accepts a static font string', () => { + const utils = new CanvasTextUtils({ font: '14px Inter' }); + expect(utils.context.font).toEqual('14px Inter'); + }); + }); + + describe('text width utils', () => { + const utils = new CanvasTextUtils({ font: '' }); + + describe('textWidth', () => { + it('returns the measured text width from the canvas', () => { + mockCanvasWidth = 200; + expect(utils.textWidth).toEqual(200); + }); + }); + + describe('setTextToCheck', () => { + it('sets the internal currentText variable', () => { + utils.setTextToCheck('hello world'); + expect(utils.currentText).toEqual('hello world'); + }); + }); + }); +}); diff --git a/src/services/canvas/canvas_text_utils.testenv.ts b/src/services/canvas/canvas_text_utils.testenv.ts new file mode 100644 index 00000000000..50e60881ecb --- /dev/null +++ b/src/services/canvas/canvas_text_utils.testenv.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export class CanvasTextUtils { + constructor(_: any) {} + + computeFontFromElement = (_: HTMLElement) => ''; + + textWidth = 0; + + currentText = ''; + setTextToCheck = (text: string) => { + this.currentText = text; + }; +} diff --git a/src/services/canvas/canvas_text_utils.ts b/src/services/canvas/canvas_text_utils.ts new file mode 100644 index 00000000000..ddee5a427c4 --- /dev/null +++ b/src/services/canvas/canvas_text_utils.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ExclusiveUnion } from '../../components/common'; + +export type CanvasTextParams = ExclusiveUnion< + { container: HTMLElement }, + { font: CanvasTextDrawingStyles['font'] } +>; + +/** + * Creates a temporary Canvas element for manipulating text & determining text width. + * + * To accurately measure text, canvas rendering requires either a container to + * compute/derive font styles from, or a static font string (useful for usage + * outside the DOM). Particular care should be applied when fallback fonts are + * used, as more fallback fonts can lead to less precision. + * + * Please note that while canvas is more significantly more performant than DOM + * measurement, there are subpixel to single digit pixel differences between + * DOM and canvas measurement due to the different rendering engines used. + */ +export class CanvasTextUtils { + context: CanvasRenderingContext2D; + currentText = ''; + + constructor({ font, container }: CanvasTextParams) { + this.context = document.createElement('canvas').getContext('2d')!; + + // Set the canvas font to ensure text width calculations are correct + if (font) { + this.context.font = font; + } else if (container) { + this.context.font = this.computeFontFromElement(container); + } + } + + computeFontFromElement = (element: HTMLElement) => { + const computedStyles = window.getComputedStyle(element); + // TODO: font-stretch is not included even though it potentially should be + // @see https://developer.mozilla.org/en-US/docs/Web/CSS/font#constituent_properties + // It appears to be unsupported and/or breaks font computation in canvas + return [ + 'font-style', + 'font-variant', + 'font-weight', + 'font-size', + 'font-family', + ] + .map((prop) => computedStyles.getPropertyValue(prop)) + .join(' ') + .trim(); + }; + + get textWidth() { + return this.context.measureText(this.currentText).width; + } + + setTextToCheck = (text: string) => { + this.currentText = text; + }; +} diff --git a/src/services/canvas/index.ts b/src/services/canvas/index.ts new file mode 100644 index 00000000000..704c753f543 --- /dev/null +++ b/src/services/canvas/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { CanvasTextUtils } from './canvas_text_utils'; +export type { CanvasTextParams } from './canvas_text_utils'; diff --git a/src/services/index.ts b/src/services/index.ts index b81e657cc94..99ee327b329 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -26,6 +26,7 @@ export { useIsWithinMinBreakpoint, } from './breakpoint'; export type { EuiBreakpointSize } from './breakpoint'; +export { CanvasTextUtils, type CanvasTextParams } from './canvas'; export { brighten, calculateContrast, diff --git a/upcoming_changelogs/7234.md b/upcoming_changelogs/7234.md new file mode 100644 index 00000000000..a933eea224d --- /dev/null +++ b/upcoming_changelogs/7234.md @@ -0,0 +1,3 @@ +**Bug fixes** + +- Fixed broken `EuiTextTruncate` testenv mocks