From 35f90f39caa7ce82340deb89317849796cbded34 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Thu, 2 Jul 2026 18:03:10 -0500 Subject: [PATCH] see if we can use text-overflow ellipsis --- .../trees/src/components/OverflowText.tsx | 143 ++----- packages/trees/src/style.css | 275 ++------------ .../test/e2e/file-tree-composition.pw.ts | 354 +++++++++++++----- .../trees/test/overflow-text-split.test.ts | 61 ++- 4 files changed, 369 insertions(+), 464 deletions(-) diff --git a/packages/trees/src/components/OverflowText.tsx b/packages/trees/src/components/OverflowText.tsx index a382278c6..224f01ce3 100644 --- a/packages/trees/src/components/OverflowText.tsx +++ b/packages/trees/src/components/OverflowText.tsx @@ -10,16 +10,13 @@ export type CSSPropertiesWithVars = CSSProperties & { [key: `--${string}`]: string | number | undefined; }; -export interface MarkerProps extends PropsWithChildren {} - export type TruncateMode = 'truncate' | 'fruncate'; export interface OverflowTextProps extends PropsWithChildren { mode?: TruncateMode; style?: Omit; className?: string; - marker?: ComponentChildren | ((props: MarkerProps) => ComponentChildren); - variant?: 'default' | 'fade'; + 'data-truncate-segment-priority'?: '1' | '2'; } export type MiddleTruncateProps = Omit & @@ -37,7 +34,7 @@ export type MiddleTruncateProps = Omit & export type MiddleTruncateFilteredProps = Pick< MiddleTruncateProps, - 'priority' | 'variant' + 'priority' > & { splitIndex?: number; splitOffset?: number }; export type CustomSplitFn = ( @@ -186,81 +183,20 @@ export const splitFirst: CustomSplitFn = ( return [contents.slice(0, splitIndex), contents.slice(splitIndex)]; }; -function OverflowMarker({ - children, - marker, - variant = 'default', -}: OverflowTextProps) { - 'use no memo'; - const isFadeVariant = variant === 'fade'; - return ( -
-
- {typeof marker === 'function' ? ( - marker({ children }) - ) : isFadeVariant ? ( - - ) : ( - marker - )} -
-
- ); -} - -function OverflowContent(options: OverflowTextProps) { - 'use no memo'; - const { mode, children } = options; - - // The inner span wrapper here is only needed to implement - // the right aligned internals for fruncate - return ( -
-
- {mode === 'fruncate' ? {children} : children} -
-
- {mode === 'fruncate' ? {children} : children} -
-
- ); -} - export function OverflowText({ children, mode = 'truncate', - marker = '…', - variant = 'default', ...props }: OverflowTextProps): JSX.Element { 'use no memo'; - const contentNode = ( - - {children} - - ); - const markerNode = ( - - ); - const fillNode =
; - return ( -
-
- {mode === 'truncate' - ? [contentNode, markerNode] - : [markerNode, contentNode, fillNode]} -
-
+ + {mode === 'fruncate' ? ( + {children} + ) : ( + children + )} + ); } @@ -306,8 +242,8 @@ export function MiddleTruncate({ console.error('MiddleTruncate: contents must be an array of two items'); return null; } - firstSegment = {contents[0]}; - secondSegment = {contents[1]}; + firstSegment = contents[0]; + secondSegment = contents[1]; } else { // TODO: figure out how to support ReactNode children in the future if (typeof children !== 'string') { @@ -315,9 +251,9 @@ export function MiddleTruncate({ return null; } - // In case styling relies on the presence of the component, we will return a div + // In case styling relies on the presence of the component, keep a host node. if (children.length === 0) { - return
; + return ; } // If the minimumLength is not met, we will still truncate the text, @@ -372,59 +308,30 @@ export function MiddleTruncate({ const [firstHalfMessage, secondHalfMessage] = splitFn(children, { priority, - variant: props.variant, splitIndex: typeof splitIndex === 'number' ? splitIndex : undefined, splitOffset: typeof splitOffset === 'number' ? splitOffset : undefined, }); - const firstIsLarger = firstHalfMessage.length >= secondHalfMessage.length; - const secondIsLarger = !firstIsLarger; - - const firstCanBeSimple = priority === 'equal' && secondIsLarger; - const secondCanBeSimple = priority === 'equal' && firstIsLarger; - - const firstPropOverrides: Partial = {}; - const secondPropOverrides: Partial = {}; - - if (firstCanBeSimple) { - firstPropOverrides.marker = ''; - } - if (secondCanBeSimple) { - secondPropOverrides.marker = ''; - } - - firstSegment = ( - - {firstHalfMessage} - - ); - secondSegment = ( - - {secondHalfMessage} - - ); + firstSegment = firstHalfMessage; + secondSegment = secondHalfMessage; } + const firstPriority = + priority === 'start' || priority === 'equal' ? '1' : '2'; + const secondPriority = priority === 'end' || priority === 'equal' ? '1' : '2'; + return ( -
-
+ {firstSegment} -
-
+ + {secondSegment} -
-
+ + ); } diff --git a/packages/trees/src/style.css b/packages/trees/src/style.css index ef8e2d267..89cae7cf2 100644 --- a/packages/trees/src/style.css +++ b/packages/trees/src/style.css @@ -577,8 +577,6 @@ font-size: var(--trees-font-size); color: var(--trees-fg); background-color: var(--trees-bg); - --truncate-marker-background-color: var(--trees-bg); - --truncate-marker-background-overlay-color: transparent; font-family: var(--trees-font-family); font-weight: var(--trees-font-weight-regular); } @@ -839,28 +837,16 @@ line-height: var(--trees-row-height); gap: var(--trees-item-row-gap); border-radius: var(--trees-border-radius); - /* Row states may be translucent, so markers paint the tree background first - * and then the state color on top to avoid compositing the same alpha twice. */ - --truncate-marker-background-color: var(--trees-bg); - --truncate-marker-background-overlay-color: transparent; - --truncate-marker-block-inset: 0px; &:hover, &[data-item-context-hover='true'] { background-color: var(--trees-bg-muted); - --truncate-marker-background-overlay-color: var(--trees-bg-muted); } &[data-item-focused='true'], &:focus-visible { z-index: 2; - /* Flattened segment markers sit high enough to cover the row outline unless - * their painted background is inset by the focus ring width. */ - [data-item-flattened-subitems] { - --truncate-marker-block-inset: var(--trees-focus-ring-width); - } - &::before { position: absolute; inset: 0; @@ -881,7 +867,6 @@ &[data-item-selected='true'] { color: var(--trees-selected-fg); background-color: var(--trees-selected-bg); - --truncate-marker-background-overlay-color: var(--trees-selected-bg); z-index: 3; [data-item-section='icon'] { @@ -916,7 +901,6 @@ [data-item-selected='true'] ) { background-color: var(--trees-bg); - --truncate-marker-background-overlay-color: transparent; } [data-item-selected='true']:has(+ [data-item-selected='true']) { @@ -1126,8 +1110,7 @@ max-width: 100%; overflow: hidden; text-overflow: ellipsis; - /* Breaks middle truncate component to also set this */ - /* white-space: nowrap; */ + white-space: nowrap; } [data-item-section='decoration'] { @@ -1414,254 +1397,44 @@ color: var(--trees-fg); } - /** Truncation marker CSS kept local so trees owns its row rendering behavior. */ [data-truncate-container] { - /* CUSTOM TO TREES, TO SUPPORT THE OUTLINE */ - margin-top: -1px; - margin-bottom: -1px; - - /* Width of the fade from default marker to text */ - --truncate-internal-marker-fade-width: var( - --truncate-marker-fade-width, - 2px - ); - /* Width of the solid color between the fade from the default marker to the text */ - --truncate-internal-marker-gap: var(--truncate-marker-gap, 0px); - /* Opacity of the marker 'color' property, not of the element itself */ - --truncate-internal-marker-opacity: var(--truncate-marker-opacity, 50%); - /* Opacity of the marker 'color' property specifically for the middle truncate, not opacity of the element itself */ - --truncate-internal-middle-marker-opacity: var( - --truncate-middle-marker-opacity, - 80% - ); - /* Background color of the default marker */ - --truncate-internal-marker-background-color: var( - --truncate-marker-background-color, - light-dark(white, black) - ); - --truncate-internal-marker-background-overlay-color: var( - --truncate-marker-background-overlay-color, - transparent - ); - --truncate-internal-marker-block-inset: var( - --truncate-marker-block-inset, - 0px - ); - /* Duration of the fade out animation for the marker */ - --truncate-internal-marker-fade-out-duration: var( - --truncate-marker-fade-out-duration, - 0ms - ); - /* Duration of the fade in animation for the marker */ - --truncate-internal-marker-fade-in-duration: var( - --truncate-marker-fade-in-duration, - 100ms - ); - - /* FADE Variant specifics */ - --truncate-internal-fade-marker-color: var( - --truncate-fade-marker-color, - #000 - ); - --truncate-internal-fade-marker-width: var( - --truncate-fade-marker-width, - 0.2lh - ); - - /* - In some special cases people might be adding spacing in other ways - that would benefit from being able to override this, however the container - query below can't use this and would need to be redeclared with the overridden - value. It's a bad time, but better than nothing. - */ - --truncate-internal-single-line-height: 1lh; - - height: var(--truncate-internal-single-line-height); + display: block; min-width: 0; + max-width: 100%; overflow: hidden; - } - - [data-truncate-marker] { - display: flex; - position: absolute; - height: var(--truncate-internal-single-line-height); - padding-block: var(--truncate-internal-marker-block-inset); - box-sizing: border-box; - align-items: center; - background-clip: content-box; - z-index: 2; - color: color-mix( - in srgb, - currentColor var(--truncate-internal-marker-opacity), - transparent - ); - - /* Core trick for hiding the marker until overflow occurs */ - opacity: 0; - transition: opacity var(--truncate-internal-marker-fade-out-duration) - ease-in-out; - } - - @container measure (height > 1lh) { - [data-truncate-marker] { - opacity: 1; - transition: opacity var(--truncate-internal-marker-fade-in-duration) - ease-in-out; - } - } - - [data-truncate-grid] { - display: grid; - position: relative; - } - - [data-truncate-content='visible'] { + text-overflow: ellipsis; white-space: nowrap; } - [data-truncate-content='overflow'] { - opacity: 0; - pointer-events: none; - user-select: none; - word-break: break-all; - margin-top: calc(-1 * var(--truncate-internal-single-line-height)); - } - - [data-truncate-marker-cell] { - container: measure / size; - overflow: visible; - user-select: none; - pointer-events: none; - } - - [data-truncate-container='truncate'] { - & [data-truncate-grid] { - grid-template-columns: minmax(0, max-content) 0; - } - & [data-truncate-marker] { - right: 0; - } - & [data-truncate-fade] { - margin-right: calc(-2 * var(--truncate-internal-fade-marker-width)); - } - } - [data-truncate-container='fruncate'] { - & [data-truncate-grid] { - grid-template-columns: 0 minmax(0, max-content) auto; - } - & [data-truncate-content] { - direction: rtl; - } - & [data-truncate-content] > span { - unicode-bidi: plaintext; - } - & [data-truncate-fade] { - margin-left: calc(-2 * var(--truncate-internal-fade-marker-width)); - } - } - - [data-truncate-variant='default'] { - & [data-truncate-marker] { - background-color: var(--truncate-internal-marker-background-color); - background-image: linear-gradient( - var(--truncate-internal-marker-background-overlay-color), - var(--truncate-internal-marker-background-overlay-color) - ); - } - & [data-truncate-marker]::after, - & [data-truncate-marker]::before { - content: ''; - position: absolute; - width: calc( - var(--truncate-internal-marker-fade-width) + - var(--truncate-internal-marker-gap) - ); - inset-block-start: var(--truncate-internal-marker-block-inset); - height: max( - 0px, - calc( - var(--truncate-internal-single-line-height) - - var(--truncate-internal-marker-block-inset) * 2 - ) - ); - background-color: var(--truncate-internal-marker-background-color); - background-image: linear-gradient( - var(--truncate-internal-marker-background-overlay-color), - var(--truncate-internal-marker-background-overlay-color) - ); - -webkit-mask-image: linear-gradient( - var(--truncate-internal-fade-dir), - #000 0%, - #000 var(--truncate-internal-marker-gap), - transparent 100% - ); - mask-image: linear-gradient( - var(--truncate-internal-fade-dir), - #000 0%, - #000 var(--truncate-internal-marker-gap), - transparent 100% - ); - } - & [data-truncate-marker]::after { - --truncate-internal-fade-dir: to right; - right: calc( - -1 * - ( - var(--truncate-internal-marker-fade-width) + - var(--truncate-internal-marker-gap) - ) - ); - } - & [data-truncate-marker]::before { - --truncate-internal-fade-dir: to left; - left: calc( - -1 * - ( - var(--truncate-internal-marker-fade-width) + - var(--truncate-internal-marker-gap) - ) - ); - } - } - - [data-truncate-variant='fade'] { - & [data-truncate-marker] { - background: transparent; - } + direction: rtl; + text-align: end; } - [data-truncate-fade] { - box-shadow: - 0 0 calc(var(--truncate-internal-fade-marker-width) / 2) - var(--truncate-internal-fade-marker-color), - 0 0 var(--truncate-internal-fade-marker-width) - var(--truncate-internal-fade-marker-color); - width: calc(var(--truncate-internal-fade-marker-width) * 2); - height: calc( - var(--truncate-internal-single-line-height) - - (var(--truncate-internal-fade-marker-width) * 2) - ); - margin: var(--truncate-internal-fade-marker-width) 0; + [data-truncate-container='fruncate'] > [data-truncate-content] { + unicode-bidi: plaintext; } [data-truncate-group-container='middle'] { - & [data-truncate-container] { - --truncate-marker-opacity: var(--truncate-internal-middle-marker-opacity); - } - display: flex; min-width: 0; + max-width: 100%; + white-space: nowrap; + } - & > div { - min-width: 0; - } + [data-truncate-group-container='middle'] > [data-truncate-container] { + /* Keep enough room for the browser to paint the ellipsis glyph instead of + * clipping text into the neighboring middle-truncation segment. */ + min-inline-size: 1em; + } - & > div[data-truncate-segment-priority='1'] { - flex: 0 1 max-content; - } - & > div[data-truncate-segment-priority='2'] { - flex: 0 999999 max-content; - } + [data-truncate-group-container='middle'] + > [data-truncate-segment-priority='1'] { + flex: 0 1 max-content; + } + + [data-truncate-group-container='middle'] + > [data-truncate-segment-priority='2'] { + flex: 0 999999 max-content; } } diff --git a/packages/trees/test/e2e/file-tree-composition.pw.ts b/packages/trees/test/e2e/file-tree-composition.pw.ts index ae898aeda..51f3cc99c 100644 --- a/packages/trees/test/e2e/file-tree-composition.pw.ts +++ b/packages/trees/test/e2e/file-tree-composition.pw.ts @@ -196,7 +196,7 @@ test.describe('file-tree composition surfaces', () => { expect(triggerCenterY).toBeLessThanOrEqual(rowBox.y + rowBox.height); }); - test('truncated markers match translucent row states and leave focused outlines clear', async ({ + test('truncated names use native text overflow without legacy marker layers', async ({ page, }) => { await page.goto('/test/e2e/fixtures/file-tree-composition.html'); @@ -208,137 +208,303 @@ test.describe('file-tree composition surfaces', () => { const mount = document.querySelector( '[data-file-tree-composition-mount]' ); - const host = document.querySelector('file-tree-container'); - if (!(mount instanceof HTMLElement) || !(host instanceof HTMLElement)) { - throw new Error('Expected file-tree composition fixture elements.'); + if (!(mount instanceof HTMLElement)) { + throw new Error('Expected file-tree composition fixture mount.'); } mount.style.width = '48px'; - host.style.setProperty('--trees-bg-override', 'rgb(240, 240, 240)'); - host.style.setProperty( - '--trees-bg-muted-override', - 'rgba(0, 0, 0, 0.25)' - ); - host.style.setProperty('--trees-focus-ring-width-override', '2px'); }); - const flattenedPath = await page.evaluate(() => { + const row = page.locator( + 'file-tree-container button[data-item-path="README.md"]' + ); + await expect(row).toBeVisible(); + + const truncation = await page.evaluate(() => { const host = document.querySelector('file-tree-container'); - const flattenedRow = Array.from( - host?.shadowRoot?.querySelectorAll('button[data-type="item"]') ?? [] - ).find( - (row) => row.querySelector('[data-item-flattened-subitems]') != null + const rowElement = host?.shadowRoot?.querySelector( + 'button[data-item-path="README.md"]' + ); + const group = rowElement?.querySelector( + '[data-truncate-group-container="middle"]' + ); + if ( + !(rowElement instanceof HTMLElement) || + !(group instanceof HTMLElement) + ) { + throw new Error('Expected README.md row middle truncation group.'); + } + + const directContainers = Array.from(group.children).filter( + (child): child is HTMLElement => + child instanceof HTMLElement && + child.hasAttribute('data-truncate-container') + ); + const shrinkingContainers = directContainers.filter( + (container) => + container.getAttribute('data-truncate-segment-priority') === '2' ); - return flattenedRow instanceof HTMLElement - ? (flattenedRow.dataset.itemPath ?? null) - : null; + return { + groupText: group.textContent, + directContainerCount: directContainers.length, + directChildCount: group.children.length, + legacyLayerCount: rowElement.querySelectorAll( + '[data-truncate-marker], [data-truncate-grid], [data-truncate-fill]' + ).length, + segments: directContainers.map((container) => { + const style = getComputedStyle(container); + return { + mode: container.getAttribute('data-truncate-container'), + priority: container.getAttribute('data-truncate-segment-priority'), + text: container.textContent, + overflowX: style.overflowX, + overflowY: style.overflowY, + textOverflow: style.textOverflow, + whiteSpace: style.whiteSpace, + }; + }), + shrinkingSegmentStyles: shrinkingContainers.map((container) => { + const style = getComputedStyle(container); + return { + overflowX: style.overflowX, + textOverflow: style.textOverflow, + whiteSpace: style.whiteSpace, + }; + }), + }; }); - if (flattenedPath == null) { - throw new Error('Expected a flattened row in the composition fixture.'); - } + expect(truncation.directContainerCount).toBe(2); + expect(truncation.directChildCount).toBe(2); + expect(truncation.groupText).toBe('README.md'); + expect(truncation.legacyLayerCount).toBe(0); + expect(truncation.segments).toEqual([ + { + mode: 'truncate', + overflowX: 'hidden', + overflowY: 'hidden', + priority: '2', + text: 'README.', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + { + mode: 'fruncate', + overflowX: 'hidden', + overflowY: 'hidden', + priority: '1', + text: 'md', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + ]); + expect(truncation.shrinkingSegmentStyles).toEqual([ + { + overflowX: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + ]); + }); - const row = page.locator( - `file-tree-container button[data-item-path="${flattenedPath}"]` + test('keeps middle-truncated README segments separated at the ellipsis boundary', async ({ + page, + }) => { + await page.goto('/test/e2e/fixtures/file-tree-composition.html'); + await page.waitForFunction( + () => window.__fileTreeCompositionFixtureReady === true ); - await row.hover(); - await expect - .poll(() => - page.evaluate((path) => { - const host = document.querySelector('file-tree-container'); - const rowElement = Array.from( - host?.shadowRoot?.querySelectorAll('button[data-type="item"]') ?? [] - ).find( - (candidate) => candidate.getAttribute('data-item-path') === path - ); - const marker = rowElement?.querySelector('[data-truncate-marker]'); - return marker instanceof HTMLElement - ? getComputedStyle(marker).opacity - : null; - }, flattenedPath) - ) - .toBe('1'); + await page.evaluate(() => { + const mount = document.querySelector( + '[data-file-tree-composition-mount]' + ); + if (!(mount instanceof HTMLElement)) { + throw new Error('Expected file-tree composition fixture mount.'); + } + + mount.style.width = '42px'; + }); + + const row = page.locator( + 'file-tree-container button[data-item-path="README.md"]' + ); + await expect(row).toBeVisible(); - const hoverStyles = await page.evaluate((path) => { + const layout = await page.evaluate(() => { const host = document.querySelector('file-tree-container'); - const rowElement = Array.from( - host?.shadowRoot?.querySelectorAll('button[data-type="item"]') ?? [] - ).find((candidate) => candidate.getAttribute('data-item-path') === path); - const marker = rowElement?.querySelector('[data-truncate-marker]'); + const rowElement = host?.shadowRoot?.querySelector( + 'button[data-item-path="README.md"]' + ); + const group = rowElement?.querySelector( + '[data-truncate-group-container="middle"]' + ); if ( !(rowElement instanceof HTMLElement) || - !(marker instanceof HTMLElement) + !(group instanceof HTMLElement) ) { - throw new Error('Expected truncated flattened row marker.'); + throw new Error('Expected README.md row middle truncation group.'); } - const rowStyle = getComputedStyle(rowElement); - const markerStyle = getComputedStyle(marker); + const directContainers = Array.from(group.children).filter( + (child): child is HTMLElement => + child instanceof HTMLElement && + child.hasAttribute('data-truncate-container') + ); + const [firstSegment, secondSegment] = directContainers; + if ( + directContainers.length !== 2 || + !(firstSegment instanceof HTMLElement) || + !(secondSegment instanceof HTMLElement) + ) { + throw new Error( + 'Expected README.md middle truncation to split in two.' + ); + } + + const firstRect = firstSegment.getBoundingClientRect(); + const secondRect = secondSegment.getBoundingClientRect(); + const firstStyle = getComputedStyle(firstSegment); + const fontSize = Number.parseFloat(firstStyle.fontSize); + const ellipsisVisibleWidth = Math.max(6, fontSize * 0.5); + return { - markerBackgroundClip: markerStyle.backgroundClip, - markerBackgroundColor: markerStyle.backgroundColor, - markerBackgroundImage: markerStyle.backgroundImage, - rowBackgroundColor: rowStyle.backgroundColor, + ellipsisVisibleWidth, + firstRight: firstRect.right, + firstText: firstSegment.textContent, + firstWidth: firstRect.width, + secondLeft: secondRect.left, + secondText: secondSegment.textContent, }; - }, flattenedPath); + }); - expect(hoverStyles.rowBackgroundColor).toBe('rgba(0, 0, 0, 0.25)'); - expect(hoverStyles.markerBackgroundColor).toBe('rgb(240, 240, 240)'); - expect(hoverStyles.markerBackgroundImage).toContain('rgba(0, 0, 0, 0.25)'); - expect(hoverStyles.markerBackgroundClip).toBe('content-box'); + expect(layout.firstText).toBe('README.'); + expect(layout.secondText).toBe('md'); + expect(layout.firstWidth).toBeGreaterThanOrEqual( + layout.ellipsisVisibleWidth + ); + expect(layout.secondLeft).toBeGreaterThanOrEqual(layout.firstRight - 0.5); + }); - await row.focus(); + test('flattened and short rows use native truncation styles', async ({ + page, + }) => { + await page.goto('/test/e2e/fixtures/file-tree-composition.html'); + await page.waitForFunction( + () => window.__fileTreeCompositionFixtureReady === true + ); - const focusStyles = await page.evaluate((path) => { - const host = document.querySelector('file-tree-container'); - const rowElement = Array.from( - host?.shadowRoot?.querySelectorAll('button[data-type="item"]') ?? [] - ).find((candidate) => candidate.getAttribute('data-item-path') === path); - const marker = rowElement?.querySelector('[data-truncate-marker]'); - if (!(marker instanceof HTMLElement)) { - throw new Error('Expected focused flattened row marker.'); + await page.evaluate(() => { + const mount = document.querySelector( + '[data-file-tree-composition-mount]' + ); + if (!(mount instanceof HTMLElement)) { + throw new Error('Expected file-tree composition fixture mount.'); } - const markerStyle = getComputedStyle(marker); - const markerBeforeStyle = getComputedStyle(marker, '::before'); - return { - markerHeight: Number.parseFloat(markerStyle.height), - markerPaddingBottom: Number.parseFloat(markerStyle.paddingBottom), - markerPaddingTop: Number.parseFloat(markerStyle.paddingTop), - markerBeforeHeight: Number.parseFloat(markerBeforeStyle.height), - markerBeforeTop: Number.parseFloat(markerBeforeStyle.top), + mount.style.width = '48px'; + }); + + const truncation = await page.evaluate(() => { + const readNativeTruncationStyle = (container: HTMLElement) => { + const style = getComputedStyle(container); + return { + overflowX: style.overflowX, + overflowY: style.overflowY, + textOverflow: style.textOverflow, + whiteSpace: style.whiteSpace, + }; }; - }, flattenedPath); - expect(focusStyles.markerPaddingTop).toBe(2); - expect(focusStyles.markerPaddingBottom).toBe(2); - expect(focusStyles.markerBeforeTop).toBe(2); - expect(focusStyles.markerBeforeHeight).toBe(focusStyles.markerHeight - 4); + const host = document.querySelector('file-tree-container'); + const shadowRoot = host?.shadowRoot; + const flattenedSubitems = shadowRoot?.querySelector( + '[data-item-flattened-subitems]' + ); + const flattenedRow = flattenedSubitems?.closest( + 'button[data-type="item"]' + ); + + if ( + !(flattenedSubitems instanceof HTMLElement) || + !(flattenedRow instanceof HTMLElement) + ) { + throw new Error('Expected row containing flattened subitems.'); + } - await page - .locator('file-tree-container button[data-item-path="README.md"]') - .focus(); + const flattenedSegmentContainers = Array.from( + flattenedSubitems.querySelectorAll( + '[data-item-flattened-subitem] > [data-truncate-container]' + ) + ); - const fileMarkerPadding = await page.evaluate(() => { - const host = document.querySelector('file-tree-container'); - const marker = host?.shadowRoot?.querySelector( - 'button[data-item-path="README.md"] [data-truncate-marker]' + const shortRow = shadowRoot?.querySelector( + 'button[data-item-path="src/"]' ); - if (!(marker instanceof HTMLElement)) { - throw new Error('Expected focused README.md row marker.'); + if (!(shortRow instanceof HTMLElement)) { + throw new Error('Expected src/ row in file-tree fixture.'); } - const markerStyle = getComputedStyle(marker); + const shortRowContainers = Array.from( + shortRow.querySelectorAll( + '[data-item-section="content"] [data-truncate-container]' + ) + ); + return { - bottom: Number.parseFloat(markerStyle.paddingBottom), - top: Number.parseFloat(markerStyle.paddingTop), + flattenedLegacyLayerCount: flattenedRow.querySelectorAll( + '[data-truncate-marker], [data-truncate-grid], [data-truncate-fill]' + ).length, + flattenedSegmentCount: flattenedSubitems.querySelectorAll( + '[data-item-flattened-subitem]' + ).length, + flattenedSegmentStyles: flattenedSegmentContainers.map((container) => ({ + mode: container.getAttribute('data-truncate-container'), + ...readNativeTruncationStyle(container), + })), + shortRowLegacyLayerCount: shortRow.querySelectorAll( + '[data-truncate-marker], [data-truncate-grid], [data-truncate-fill]' + ).length, + shortRowSegments: shortRowContainers.map((container) => { + const style = getComputedStyle(container); + return { + mode: container.getAttribute('data-truncate-container'), + text: container.textContent, + textAlign: style.textAlign, + ...readNativeTruncationStyle(container), + }; + }), }; }); - expect(fileMarkerPadding.top).toBe(0); - expect(fileMarkerPadding.bottom).toBe(0); + expect(truncation.flattenedSegmentCount).toBeGreaterThan(1); + expect(truncation.flattenedLegacyLayerCount).toBe(0); + expect(truncation.flattenedSegmentStyles).toHaveLength( + truncation.flattenedSegmentCount + ); + for (const segmentStyle of truncation.flattenedSegmentStyles) { + expect(segmentStyle).toEqual({ + mode: 'truncate', + overflowX: 'hidden', + overflowY: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }); + } + + expect(truncation.shortRowLegacyLayerCount).toBe(0); + expect(truncation.shortRowSegments).toEqual([ + { + mode: 'fruncate', + overflowX: 'hidden', + overflowY: 'hidden', + text: 'src', + textAlign: 'end', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + ]); }); test('keyboard navigation retargets the focused row trigger away from a stale hover', async ({ diff --git a/packages/trees/test/overflow-text-split.test.ts b/packages/trees/test/overflow-text-split.test.ts index 3749b9e2c..fe4fe0031 100644 --- a/packages/trees/test/overflow-text-split.test.ts +++ b/packages/trees/test/overflow-text-split.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from 'bun:test'; +import { h, render } from 'preact'; -import { splitCenter, splitExtension } from '../src/components/OverflowText'; +import { + MiddleTruncate, + splitCenter, + splitExtension, +} from '../src/components/OverflowText'; +import { installDom } from './helpers/dom'; // A split boundary that lands adjacent to whitespace causes the boundary space // to collapse under `white-space: nowrap`, making the name visually lose its @@ -64,3 +70,56 @@ describe('splitCenter whitespace boundary (issue #744)', () => { expect(splitCenter('abcdef')).toEqual(['abc', 'def']); }); }); + +describe('MiddleTruncate rendered shape', () => { + test('splits extension names into two native truncation containers without duplicate marker content', () => { + const { cleanup, dom } = installDom(); + const mount = dom.window.document.createElement('div'); + try { + render( + h(MiddleTruncate, { + children: 'archive.tar.gz', + minimumLength: 5, + split: 'extension', + }), + mount + ); + + const group = mount.querySelector( + '[data-truncate-group-container="middle"]' + ); + if (!(group instanceof dom.window.HTMLElement)) { + throw new Error('expected middle truncation group'); + } + + const directContainers = Array.from(group.children).filter((child) => + child.hasAttribute('data-truncate-container') + ); + + expect(directContainers).toHaveLength(2); + expect(group.children).toHaveLength(2); + expect( + directContainers.map((container) => + container.getAttribute('data-truncate-container') + ) + ).toEqual(['truncate', 'fruncate']); + expect( + directContainers.map((container) => + container.getAttribute('data-truncate-segment-priority') + ) + ).toEqual(['2', '1']); + expect( + directContainers.map((container) => container.textContent) + ).toEqual(['archive.tar.', 'gz']); + expect(group.textContent).toBe('archive.tar.gz'); + expect( + group.querySelectorAll( + '[data-truncate-marker], [data-truncate-grid], [data-truncate-fill]' + ) + ).toHaveLength(0); + } finally { + render(null, mount); + cleanup(); + } + }); +});