diff --git a/projects/core/.visual/format-truncate.dark.png b/projects/core/.visual/format-truncate.dark.png new file mode 100644 index 000000000..cd20c77b9 --- /dev/null +++ b/projects/core/.visual/format-truncate.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6745ff0971fff8dd2ab6326c7f19e36994b481cc7f48064c872d70b3bcf2995f +size 28870 diff --git a/projects/core/.visual/format-truncate.png b/projects/core/.visual/format-truncate.png new file mode 100644 index 000000000..5ead374ca --- /dev/null +++ b/projects/core/.visual/format-truncate.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c936fd8a189fc69bf9ccb409d6435e567ed4abefc07dd75c7dda8f9119d794b +size 29407 diff --git a/projects/core/package.json b/projects/core/package.json index 84c50a87c..7d2f3dba8 100644 --- a/projects/core/package.json +++ b/projects/core/package.json @@ -426,6 +426,18 @@ "types": "./dist/format-relative-time/define.d.ts", "default": "./dist/format-relative-time/define.js" }, + "./format-truncate": { + "types": "./dist/format-truncate/index.d.ts", + "default": "./dist/format-truncate/index.js" + }, + "./format-truncate/index.js": { + "types": "./dist/format-truncate/index.d.ts", + "default": "./dist/format-truncate/index.js" + }, + "./format-truncate/define.js": { + "types": "./dist/format-truncate/define.d.ts", + "default": "./dist/format-truncate/define.js" + }, "./forms": { "types": "./dist/forms/index.d.ts", "default": "./dist/forms/index.js" diff --git a/projects/core/src/bundle.ts b/projects/core/src/bundle.ts index de28bb4e1..d9d59eb8f 100644 --- a/projects/core/src/bundle.ts +++ b/projects/core/src/bundle.ts @@ -31,6 +31,7 @@ import '@nvidia-elements/core/format-bytes/define.js'; import '@nvidia-elements/core/format-datetime/define.js'; import '@nvidia-elements/core/format-number/define.js'; import '@nvidia-elements/core/format-relative-time/define.js'; +import '@nvidia-elements/core/format-truncate/define.js'; import '@nvidia-elements/core/forms/define.js'; import '@nvidia-elements/core/gauge/define.js'; import '@nvidia-elements/core/grid/define.js'; @@ -101,6 +102,7 @@ export * from '@nvidia-elements/core/format-bytes'; export * from '@nvidia-elements/core/format-datetime'; export * from '@nvidia-elements/core/format-number'; export * from '@nvidia-elements/core/format-relative-time'; +export * from '@nvidia-elements/core/format-truncate'; export * from '@nvidia-elements/core/forms'; export * from '@nvidia-elements/core/gauge'; export * from '@nvidia-elements/core/grid'; diff --git a/projects/core/src/format-truncate/define.ts b/projects/core/src/format-truncate/define.ts new file mode 100644 index 000000000..4ba0054a4 --- /dev/null +++ b/projects/core/src/format-truncate/define.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { define } from '@nvidia-elements/core/internal'; +import { FormatTruncate } from '@nvidia-elements/core/format-truncate'; + +define(FormatTruncate); + +declare global { + interface HTMLElementTagNameMap { + 'nve-format-truncate': FormatTruncate; + } +} diff --git a/projects/core/src/format-truncate/format-truncate.css b/projects/core/src/format-truncate/format-truncate.css new file mode 100644 index 000000000..469ba8f0f --- /dev/null +++ b/projects/core/src/format-truncate/format-truncate.css @@ -0,0 +1,21 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +:host { + display: contents; +} + +span { + text-wrap: nowrap; +} + +slot { + display: initial; + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/projects/core/src/format-truncate/format-truncate.examples.ts b/projects/core/src/format-truncate/format-truncate.examples.ts new file mode 100644 index 000000000..538f1dc72 --- /dev/null +++ b/projects/core/src/format-truncate/format-truncate.examples.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import '@nvidia-elements/core/format-truncate/define.js'; + +export default { + title: 'Elements/FormatTruncate', + component: 'nve-format-truncate' +}; + +/** + * @summary Start, center, and end positions keep the most useful part of constrained text. Choose the position that matches how users distinguish each value. + */ +export const Default = { + render: () => html` +
+ training-pipeline-2026-08-05-production + training-pipeline-2026-08-05-production + training-pipeline-2026-08-05-production +
+ ` +}; + +/** + * @summary Inherited heading, body, and code styles keep truncated text aligned with surrounding typography. Place the component inside semantic text elements to preserve their visual hierarchy. + * @tags test-case + */ +export const Typography = { + render: () => html` +
+

training-pipeline-2026-08-05-production

+

training-pipeline-2026-08-05-production

+ training-pipeline-2026-08-05-production +
+ ` +}; + +/** + * @summary Center truncation bias keeps units from either the start or end. Use end bias for shared suffixes and start bias for identifiers with meaningful endings. + * @tags test-case + */ +export const Bias = { + render: () => html` +
+ sha256:1234567890abcdef1234567890abcdef + sha256:1234567890abcdef1234567890abcdef +
+ ` +}; + +/** + * @summary Character, word, and path strategies preserve complete graphemes or meaningful text units. Match the strategy to identifiers, labels, or file paths. + * @tags test-case + */ +export const Strategy = { + render: () => html` +
+ experiment-👩🏽‍💻-0123456789abcdef + NVIDIA autonomous vehicle training pipeline production run + /models/checkpoints/production/model.bin +
+ ` +}; diff --git a/projects/core/src/format-truncate/format-truncate.test.axe.ts b/projects/core/src/format-truncate/format-truncate.test.axe.ts new file mode 100644 index 000000000..69312cf03 --- /dev/null +++ b/projects/core/src/format-truncate/format-truncate.test.axe.ts @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { runAxe } from '@internals/testing/axe'; +import { FormatTruncate } from '@nvidia-elements/core/format-truncate'; +import '@nvidia-elements/core/format-truncate/define.js'; + +describe(FormatTruncate.metadata.tag, () => { + it('should pass axe checks for every truncation position and strategy', async () => { + const fixture = await createFixture(html` + training-pipeline-2026-08-05-production + training-pipeline-2026-08-05-production + experiment-👩🏽‍💻-0123456789abcdef + NVIDIA autonomous vehicle training pipeline + /models/checkpoints/production/model.bin + `); + + try { + await elementIsStable(fixture.querySelector(FormatTruncate.metadata.tag)); + const results = await runAxe([FormatTruncate.metadata.tag]); + expect(results.violations.length).toBe(0); + } finally { + removeFixture(fixture); + } + }); +}); diff --git a/projects/core/src/format-truncate/format-truncate.test.lighthouse.ts b/projects/core/src/format-truncate/format-truncate.test.lighthouse.ts new file mode 100644 index 000000000..f8bdcea5e --- /dev/null +++ b/projects/core/src/format-truncate/format-truncate.test.lighthouse.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { lighthouseRunner } from '@internals/vite'; + +describe('format-truncate lighthouse report', () => { + test('format-truncate should meet lighthouse benchmarks', async () => { + const report = await lighthouseRunner.getReport( + 'nve-format-truncate', + /* html */ ` + /models/checkpoints/production/model.bin + + ` + ); + + expect(report.scores.performance).toBe(100); + expect(report.scores.accessibility).toBe(100); + expect(report.scores.bestPractices).toBe(100); + expect(report.payload.javascript.kb).toBeLessThan(16); + }); +}); diff --git a/projects/core/src/format-truncate/format-truncate.test.ssr.ts b/projects/core/src/format-truncate/format-truncate.test.ssr.ts new file mode 100644 index 000000000..fd72e9f0f --- /dev/null +++ b/projects/core/src/format-truncate/format-truncate.test.ssr.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { ssrRunner } from '@internals/vite'; +import { FormatTruncate } from '@nvidia-elements/core/format-truncate'; +import '@nvidia-elements/core/format-truncate/define.js'; + +describe(FormatTruncate.metadata.tag, () => { + it('should pass baseline ssr check', async () => { + const result = await ssrRunner.render( + html`abcdefghij` + ); + + expect(result.includes('shadowroot="open"')).toBe(true); + expect(result.includes('nve-format-truncate')).toBe(true); + expect(result.includes('abcdefghij')).toBe(true); + }); +}); diff --git a/projects/core/src/format-truncate/format-truncate.test.ts b/projects/core/src/format-truncate/format-truncate.test.ts new file mode 100644 index 000000000..4c68da46a --- /dev/null +++ b/projects/core/src/format-truncate/format-truncate.test.ts @@ -0,0 +1,241 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { FormatTruncate } from '@nvidia-elements/core/format-truncate'; +import '@nvidia-elements/core/format-truncate/define.js'; +import { truncateText } from './utils.js'; + +describe(FormatTruncate.metadata.tag, () => { + let fixture: HTMLElement; + let element: FormatTruncate; + + beforeEach(async () => { + fixture = await createFixture(html`short text`); + element = getTruncate(fixture); + await elementIsStable(element); + }); + + afterEach(() => { + removeFixture(fixture); + }); + + it('should define element', () => { + expect(customElements.get(FormatTruncate.metadata.tag)).toBeDefined(); + }); + + it('should expose reflected defaults', () => { + expect(element.position).toBe('start'); + expect(element.getAttribute('position')).toBe('start'); + expect(element.bias).toBe('end'); + expect(element.getAttribute('bias')).toBe('end'); + expect(element.strategy).toBe('character'); + expect(element.getAttribute('strategy')).toBe('character'); + expect(element.preserve).toBe(6); + expect(element.getAttribute('preserve')).toBe('6'); + }); + + it('should render computed content in an aria-hidden internal host', () => { + expect(element.hasAttribute('title')).toBe(false); + expect(element.shadowRoot?.textContent).toBe('short text'); + const internalHost = element.shadowRoot?.querySelector('[internal-host]'); + expect(internalHost).toBeInstanceOf(HTMLSpanElement); + expect(internalHost?.getAttribute('aria-hidden')).toBe('true'); + expect(internalHost?.textContent).toBe('short text'); + }); + + it('should inherit font size from its container', () => { + fixture.style.font = '700 32px/1.2 serif'; + fixture.style.color = 'rgb(12, 34, 56)'; + + const internalHost = element.shadowRoot?.querySelector('[internal-host]'); + expect(internalHost).toBeInstanceOf(HTMLSpanElement); + + const styles = getComputedStyle(internalHost!); + expect(styles.fontSize).toBe('32px'); + }); + + it('should normalize mixed slotted content', async () => { + element.innerHTML = 'alphabetagamma'; + getDefaultSlot(element).dispatchEvent(new Event('slotchange')); + await elementIsStable(element); + + expect(element.shadowRoot?.textContent).toBe('alpha beta gamma'); + expect(element.hasAttribute('title')).toBe(false); + }); + + it('should remove text alternatives for empty content', async () => { + element.textContent = ''; + getDefaultSlot(element).dispatchEvent(new Event('slotchange')); + await elementIsStable(element); + + expect(element.shadowRoot?.textContent).toBe(''); + expect(element.hasAttribute('title')).toBe(false); + }); + + it('should visually hide the source slot with the component stylesheet', () => { + const slot = getDefaultSlot(element); + expect(slot.hidden).toBe(false); + expect(getComputedStyle(slot).position).toBe('absolute'); + expect(getComputedStyle(slot).width).toBe('1px'); + }); + + it('should recompute the plain text for a constrained container', async () => { + fixture.style.width = '40px'; + element.position = 'end'; + element.textContent = 'abcdefghij'; + getDefaultSlot(element).dispatchEvent(new Event('slotchange')); + await elementIsStable(element); + + expect(element.shadowRoot?.textContent).toContain('…'); + expect(element.title).toBe('abcdefghij'); + expect(element.shadowRoot?.querySelector('[internal-host]')).toBeInstanceOf(HTMLSpanElement); + }); + + it('should recompute text after moving between containers', async () => { + const narrowContainer = document.createElement('div'); + narrowContainer.style.width = '40px'; + const wideContainer = document.createElement('div'); + wideContainer.style.width = '400px'; + fixture.append(narrowContainer, wideContainer); + + element.textContent = 'abcdefghij'; + narrowContainer.append(element); + await elementIsStable(element); + + expect(element.shadowRoot?.textContent).toContain('…'); + expect(element.title).toBe('abcdefghij'); + + wideContainer.append(element); + await elementIsStable(element); + + expect(element.shadowRoot?.textContent).toBe('abcdefghij'); + expect(element.hasAttribute('title')).toBe(false); + }); +}); + +describe('truncateText', () => { + it('should leave text unchanged when it fits', () => { + expect(renderTruncatedText('short', { availableWidth: 5 })).toBe('short'); + }); + + it('should truncate at the start or end by grapheme', () => { + expect(renderTruncatedText('abcdefghij', { position: 'start', availableWidth: 6 })).toBe('…fghij'); + expect(renderTruncatedText('abcdefghij', { position: 'end', availableWidth: 6 })).toBe('abcde…'); + }); + + it('should keep the configured center bias', () => { + expect( + renderTruncatedText('abcdefghij', { position: 'center', bias: 'start', preserve: 3, availableWidth: 8 }) + ).toBe('abc…ghij'); + expect(renderTruncatedText('abcdefghij', { position: 'center', bias: 'end', preserve: 3, availableWidth: 8 })).toBe( + 'abcd…hij' + ); + }); + + it('should preserve complete words', () => { + expect( + renderTruncatedText('alpha beta gamma delta', { + position: 'center', + strategy: 'word', + bias: 'end', + preserve: 2, + availableWidth: 17 + }) + ).toBe('alpha…gamma delta'); + expect( + renderTruncatedText('alpha beta gamma delta', { + position: 'center', + strategy: 'word', + bias: 'start', + preserve: 2, + availableWidth: 16 + }) + ).toBe('alpha beta…delta'); + }); + + it('should preserve complete path segments', () => { + expect( + renderTruncatedText('/models/checkpoints/run/model.bin', { + position: 'center', + strategy: 'path', + bias: 'end', + preserve: 2, + availableWidth: 22 + }) + ).toBe('/models…/run/model.bin'); + expect( + renderTruncatedText('/models/checkpoints/run/model.bin', { + position: 'center', + strategy: 'path', + bias: 'start', + preserve: 2, + availableWidth: 30 + }) + ).toBe('/models/checkpoints/…model.bin'); + }); + + it('should not split Unicode grapheme clusters', () => { + expect( + renderTruncatedText('A👩🏽‍💻BCD', { + position: 'end', + availableWidth: 4, + measureText: measureGraphemes + }) + ).toBe('A👩🏽‍💻B…'); + }); + + it('should clamp preserve to one whole unit', () => { + expect( + renderTruncatedText('abcdefghij', { + position: 'center', + bias: 'end', + preserve: 0.5, + availableWidth: 6 + }) + ).toBe('abcd…j'); + }); + + it('should keep the biased edge when preserved content does not fit', () => { + expect( + renderTruncatedText('abcdefghij', { + position: 'center', + bias: 'end', + preserve: 6, + availableWidth: 4 + }) + ).toBe('…hij'); + }); +}); + +type TruncateOptions = Parameters[1]; + +function renderTruncatedText(text: string, options: Partial): string { + return truncateText(text, { + position: 'end', + strategy: 'character', + bias: 'end', + preserve: 6, + availableWidth: Number.POSITIVE_INFINITY, + measureText: value => Array.from(value).length, + ...options + }); +} + +function measureGraphemes(text: string): number { + return Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text)).length; +} + +function getTruncate(container: ParentNode): FormatTruncate { + const element = container.querySelector(FormatTruncate.metadata.tag); + if (!element) throw new Error('Expected format truncate element'); + return element; +} + +function getDefaultSlot(element: FormatTruncate): HTMLSlotElement { + const slot = element.shadowRoot?.querySelector('slot:not([name])'); + if (!slot) throw new Error('Expected default slot'); + return slot; +} diff --git a/projects/core/src/format-truncate/format-truncate.test.visual.ts b/projects/core/src/format-truncate/format-truncate.test.visual.ts new file mode 100644 index 000000000..e80682f0d --- /dev/null +++ b/projects/core/src/format-truncate/format-truncate.test.visual.ts @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { visualRunner } from '@internals/vite'; + +describe('format-truncate visual', () => { + test('format-truncate should match visual baseline', async () => { + const report = await visualRunner.render('format-truncate', template()); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); + + test('format-truncate should match visual baseline in dark theme', async () => { + const report = await visualRunner.render('format-truncate.dark', template('dark')); + expect(report.maxDiffPercentage).toBeLessThan(2); + }); +}); + +function template(theme: '' | 'dark' = ''): string { + return /* html */ ` + +
+ training-pipeline-2026-08-05-production +

training-pipeline-2026-08-05-production

+ training-pipeline-2026-08-05-production + NVIDIA autonomous vehicle training pipeline + /models/checkpoints/production/model.bin + training-pipeline-2026-08-05-production + short text +
+ `; +} diff --git a/projects/core/src/format-truncate/format-truncate.ts b/projects/core/src/format-truncate/format-truncate.ts new file mode 100644 index 000000000..aa9d1dfd8 --- /dev/null +++ b/projects/core/src/format-truncate/format-truncate.ts @@ -0,0 +1,210 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { TemplateResult } from 'lit'; +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators/property.js'; +import { attachInternals, useStyles, typeSSR } from '@nvidia-elements/core/internal'; +import { normalizeTruncateString, normalizeTruncateText, truncateText } from './utils.js'; +import styles from './format-truncate.css?inline'; + +/** + * @element nve-format-truncate + * @description Truncates text at its start, center, or end while preserving the full text for assistive technology. + * @documentation https://nvidia.github.io/elements/docs/elements/format-truncate/ + * @since 0.0.0 + * @entrypoint \@nvidia-elements/core/format-truncate + * @slot - Text to truncate. The component flattens, trims, and normalizes mixed text and element nodes. + * @beta + */ +@typeSSR() +export class FormatTruncate extends LitElement { + static styles = useStyles([styles]); + + static readonly metadata = { + tag: 'nve-format-truncate', + version: '0.0.0' + }; + + /** + * Location of the ellipsis. Use start or end to preserve one edge, or center to preserve both edges. + */ + @property({ type: String, reflect: true }) position: 'start' | 'center' | 'end' = 'start'; + + /** + * Text unit used for center truncation. Character preserves graphemes, word preserves words, and path preserves path segments. + */ + @property({ type: String, reflect: true }) strategy: 'character' | 'word' | 'path' = 'character'; + + /** + * Edge favored by center truncation. The favored edge keeps at least the configured number of units. + */ + @property({ type: String, reflect: true }) bias: 'start' | 'end' = 'end'; + + /** + * Number of strategy units retained at the biased edge during center truncation. + */ + @property({ type: Number, reflect: true }) preserve = 6; + + /** @private */ + declare _internals: ElementInternals; + + #measurementContext?: CanvasRenderingContext2D | null; + #resizeObserver?: ResizeObserver; + + override connectedCallback(): void { + super.connectedCallback(); + attachInternals(this); + this.#observeAvailableWidth(); + this.requestUpdate(); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#resizeObserver?.disconnect(); + } + + connectedMoveCallback(): void { + this.#observeAvailableWidth(); + this.requestUpdate(); + } + + override render(): TemplateResult { + const text = this.#slottedText; + const truncatedText = this.#renderText(text); + + if (truncatedText !== text) this.title = text; + else this.removeAttribute('title'); + + return html``; + } + + get #slottedText(): string { + const slot = this.shadowRoot?.querySelector('slot:not([name])'); + if (slot) return normalizeTruncateText(slot.assignedNodes({ flatten: true })); + + const nodes = Array.from(this.childNodes ?? []).filter(isDefaultSlotNode); + return nodes.length ? normalizeTruncateText(nodes) : normalizeTruncateString(this.textContent ?? ''); + } + + #renderText(text: string): string { + const measureText = this.#createTextMeasurer(); + + return truncateText(text, { + position: this.position, + strategy: this.strategy, + bias: this.bias, + preserve: this.preserve, + availableWidth: this.#availableWidth, + measureText + }); + } + + get #availableWidth(): number { + const view = this.ownerDocument?.defaultView; + if (!view) return Number.POSITIVE_INFINITY; + + for (let container = getComposedParent(this); container; container = getComposedParent(container)) { + const containerWidth = getContentWidth(container, view); + if (containerWidth > 0) return containerWidth; + } + + return Number.POSITIVE_INFINITY; + } + + #createTextMeasurer(): (text: string) => number { + const view = this.ownerDocument?.defaultView; + const context = this.#textMeasurementContext; + if (!view || !context) return () => 0; + + const computedStyles = view.getComputedStyle(this); + context.font = computedStyles.font; + + const letterSpacing = cssPixelValue(computedStyles.letterSpacing); + const wordSpacing = cssPixelValue(computedStyles.wordSpacing); + const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + + return text => this.#measureText(text, { context, letterSpacing, wordSpacing, graphemeSegmenter }); + } + + #measureText( + text: string, + { + context, + letterSpacing, + wordSpacing, + graphemeSegmenter + }: { + context: CanvasRenderingContext2D; + letterSpacing: number; + wordSpacing: number; + graphemeSegmenter: Intl.Segmenter; + } + ): number { + const graphemeSegments = graphemeSegmenter.segment(text); + const whitespaceCount = Array.from(text.matchAll(/\s/gu)).length; + + return ( + context.measureText(text).width + + Math.max(0, Array.from(graphemeSegments).length - 1) * letterSpacing + + whitespaceCount * wordSpacing + ); + } + + get #textMeasurementContext(): CanvasRenderingContext2D | null { + if (this.#measurementContext === undefined) { + this.#measurementContext = this.ownerDocument?.createElement('canvas').getContext('2d') ?? null; + } + + return this.#measurementContext; + } + + #observeAvailableWidth(): void { + if (typeof ResizeObserver === 'undefined') return; + + this.#resizeObserver ??= new ResizeObserver(() => { + if (this.isConnected) this.requestUpdate(); + }); + this.#resizeObserver.disconnect(); + this.#resizeObserver.observe(this); + + for (let container = getComposedParent(this); container; container = getComposedParent(container)) { + this.#resizeObserver.observe(container); + if (container.clientWidth > 0) break; + } + } + + #onSlotChange = (): void => { + this.requestUpdate(); + }; +} + +function getContentWidth(element: HTMLElement, view: Window): number { + const computedStyles = view.getComputedStyle(element); + return Math.max( + 0, + element.clientWidth - + cssPixelValue(computedStyles.paddingInlineStart) - + cssPixelValue(computedStyles.paddingInlineEnd) + ); +} + +function getComposedParent(element: HTMLElement): HTMLElement | null { + if (element.parentElement) return element.parentElement; + + const root = element.getRootNode(); + return root instanceof ShadowRoot && root.host instanceof HTMLElement ? root.host : null; +} + +function cssPixelValue(value: string): number { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function isDefaultSlotNode(node: Node): boolean { + return !isElementNode(node) || !node.getAttribute('slot'); +} + +function isElementNode(node: Node): node is Element { + return node.nodeType === 1; +} diff --git a/projects/core/src/format-truncate/index.ts b/projects/core/src/format-truncate/index.ts new file mode 100644 index 000000000..f0417514d --- /dev/null +++ b/projects/core/src/format-truncate/index.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from './format-truncate.js'; diff --git a/projects/core/src/format-truncate/utils.ts b/projects/core/src/format-truncate/utils.ts new file mode 100644 index 000000000..a7a41e1e9 --- /dev/null +++ b/projects/core/src/format-truncate/utils.ts @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const ELLIPSIS = '…'; + +type TruncatePosition = 'start' | 'center' | 'end'; +type TruncateStrategy = 'character' | 'word' | 'path'; +type TruncateBias = 'start' | 'end'; + +export function normalizeTruncateText(nodes: Iterable): string { + return normalizeTruncateString(Array.from(nodes, node => node.textContent ?? '').join(' ')); +} + +export function normalizeTruncateString(text: string): string { + return text.replace(/\s+/gu, ' ').trim(); +} + +export function truncateText( + text: string, + { + position, + strategy, + bias, + preserve, + availableWidth, + measureText + }: { + position: TruncatePosition; + strategy: TruncateStrategy; + bias: TruncateBias; + preserve: number; + availableWidth: number; + measureText: (value: string) => number; + } +): string { + if (!text || !Number.isFinite(availableWidth)) return text; + if (availableWidth <= 0) return ''; + if (measureText(text) <= availableWidth) return text; + if (measureText(ELLIPSIS) > availableWidth) return ELLIPSIS; + + switch (position) { + case 'start': + return truncateStart(segmentText(text, 'grapheme'), availableWidth, measureText); + case 'center': + return truncateMiddle(text, { strategy, bias, preserve, availableWidth, measureText }); + case 'end': + default: + return truncateEnd(segmentText(text, 'grapheme'), availableWidth, measureText); + } +} + +function truncateMiddle( + text: string, + { + strategy, + bias, + preserve, + availableWidth, + measureText + }: { + strategy: TruncateStrategy; + bias: TruncateBias; + preserve: number; + availableWidth: number; + measureText: (value: string) => number; + } +): string { + const units = getUnits(text, strategy, bias); + const preserveCount = Math.min(units.length, normalizePreserve(preserve)); + + if (bias === 'start') { + const preservedUnits = units.slice(0, preserveCount); + const truncatableUnits = units.slice(preserveCount); + const candidate = (count: number) => + joinWithEllipsis(preservedUnits.join(''), truncatableUnits.slice(-count || truncatableUnits.length).join('')); + + if (measureText(candidate(0)) > availableWidth) { + return truncateEnd(preservedUnits, availableWidth, measureText); + } + + return candidate(findLargestFittingCount(truncatableUnits.length, candidate, { availableWidth, measureText })); + } + + const truncatableUnits = units.slice(0, units.length - preserveCount); + const preservedUnits = units.slice(units.length - preserveCount); + const candidate = (count: number) => + joinWithEllipsis(truncatableUnits.slice(0, count).join(''), preservedUnits.join('')); + + if (measureText(candidate(0)) > availableWidth) { + return truncateStart(preservedUnits, availableWidth, measureText); + } + + return candidate(findLargestFittingCount(truncatableUnits.length, candidate, { availableWidth, measureText })); +} + +function truncateStart(units: string[], availableWidth: number, measureText: (value: string) => number): string { + const candidate = (count: number) => `${ELLIPSIS}${units.slice(-count || units.length).join('')}`; + return candidate(findLargestFittingCount(units.length - 1, candidate, { availableWidth, measureText })); +} + +function truncateEnd(units: string[], availableWidth: number, measureText: (value: string) => number): string { + const candidate = (count: number) => `${units.slice(0, count).join('')}${ELLIPSIS}`; + return candidate(findLargestFittingCount(units.length - 1, candidate, { availableWidth, measureText })); +} + +function findLargestFittingCount( + maximum: number, + candidate: (count: number) => string, + { + availableWidth, + measureText + }: { + availableWidth: number; + measureText: (value: string) => number; + } +): number { + let lower = 0; + let upper = maximum; + let fitting = 0; + + while (lower <= upper) { + const count = Math.floor((lower + upper) / 2); + + if (measureText(candidate(count)) <= availableWidth) { + fitting = count; + lower = count + 1; + } else { + upper = count - 1; + } + } + + return fitting; +} + +function joinWithEllipsis(start: string, end: string): string { + return `${start.trimEnd()}${ELLIPSIS}${end.trimStart()}`; +} + +function normalizePreserve(value: number): number { + return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1; +} + +function segmentText(text: string, granularity: 'grapheme' | 'word'): string[] { + const segments = new Intl.Segmenter(undefined, { granularity }).segment(text); + return Array.from(segments, segment => segment.segment); +} + +function mergeWhitespace(segments: string[], bias: TruncateBias): string[] { + return bias === 'start' ? mergeFollowingWhitespace(segments) : mergePrecedingWhitespace(segments); +} + +function mergeFollowingWhitespace(segments: string[]): string[] { + const units: string[] = []; + + for (const segment of segments) { + if (segment.trim()) units.push(segment); + else appendToLastUnit(units, segment); + } + + return units; +} + +function mergePrecedingWhitespace(segments: string[]): string[] { + const units: string[] = []; + let whitespace = ''; + + for (const segment of segments) { + if (segment.trim()) { + units.push(whitespace + segment); + whitespace = ''; + } else { + whitespace += segment; + } + } + + appendToLastUnit(units, whitespace); + + return units; +} + +function appendToLastUnit(units: string[], text: string): void { + const previous = units.at(-1); + if (previous !== undefined) units[units.length - 1] = previous + text; +} + +function splitPathUnits(text: string, bias: TruncateBias): string[] { + const pathSegments = Array.from(text.matchAll(/[^/\\]+/gu)); + if (pathSegments.length === 0) return [text]; + + if (bias === 'start') { + return pathSegments.map((segment, index) => { + const start = index === 0 ? 0 : segment.index; + const end = pathSegments[index + 1]?.index ?? text.length; + return text.slice(start, end); + }); + } + + return pathSegments.map((segment, index) => { + const previous = pathSegments[index - 1]; + const start = previous ? previous.index + previous[0].length : 0; + const end = index === pathSegments.length - 1 ? text.length : segment.index + segment[0].length; + return text.slice(start, end); + }); +} + +function getUnits(text: string, strategy: TruncateStrategy, bias: TruncateBias): string[] { + switch (strategy) { + case 'word': { + const units = mergeWhitespace(segmentText(text, 'word'), bias); + return units.length ? units : [text]; + } + case 'path': { + const units = splitPathUnits(text, bias); + return units.length ? units : [text]; + } + case 'character': + default: + return segmentText(text, 'grapheme'); + } +} diff --git a/projects/core/src/index.test.lighthouse.ts b/projects/core/src/index.test.lighthouse.ts index 9d6b92790..58fd17e20 100644 --- a/projects/core/src/index.test.lighthouse.ts +++ b/projects/core/src/index.test.lighthouse.ts @@ -6,23 +6,28 @@ import { lighthouseRunner } from '@internals/vite'; describe('lighthouse report', () => { test('JS Bundles should remain within compressed bundle limits', async () => { - const report = await lighthouseRunner.getReport(`bundles/index.js`, /* html */` + const report = await lighthouseRunner.getReport( + `bundles/index.js`, + /* html */ ` - `); + ` + ); expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(134.5); + expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(136); // if sudden drop in size, check vite bundle config and bundle demo to ensure side effects are properly preserved expect(report.payload.javascript.requests['index.js'].kb).toBeGreaterThan(100); }); test('JS imports should remain within compressed bundle limits', async () => { - const report = await lighthouseRunner.getReport('js-modules', /* html */` + const report = await lighthouseRunner.getReport( + 'js-modules', + /* html */ ` - `); + ` + ); expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.requests[Object.keys(report.payload.javascript.requests)[0]].kb).toBeLessThan(86.3); + expect(report.payload.javascript.requests[Object.keys(report.payload.javascript.requests)[0]].kb).toBeLessThan(88); }); }); diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js index c46a66e07..1cb233d6a 100644 --- a/projects/site/src/_11ty/layouts/common.js +++ b/projects/site/src/_11ty/layouts/common.js @@ -277,6 +277,7 @@ export const renderDocsNav = data => /* html */ ` Format Datetime Format Number Format Relative Time + Format Truncate Forms Getting Started diff --git a/projects/site/src/docs/elements/format-truncate.md b/projects/site/src/docs/elements/format-truncate.md new file mode 100644 index 000000000..6c7546fc1 --- /dev/null +++ b/projects/site/src/docs/elements/format-truncate.md @@ -0,0 +1,27 @@ +--- +{ + title: 'Format Truncate', + layout: 'docs.11ty.js', + tag: 'nve-format-truncate' +} +--- + +## Installation + +{% install 'nve-format-truncate' %} + +## Default + +{% example '@nvidia-elements/core/format-truncate/format-truncate.examples.json', 'Default' %} + +## Typography + +{% example '@nvidia-elements/core/format-truncate/format-truncate.examples.json', 'Typography' %} + +## Bias + +{% example '@nvidia-elements/core/format-truncate/format-truncate.examples.json', 'Bias' %} + +## Strategy + +{% example '@nvidia-elements/core/format-truncate/format-truncate.examples.json', 'Strategy' %}