Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 37 additions & 6 deletions packages/trees/src/components/OverflowText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,17 @@ export interface OverflowTextProps extends PropsWithChildren {
className?: string;
marker?: ComponentChildren | ((props: MarkerProps) => ComponentChildren);
variant?: 'default' | 'fade';
// Stands in for `children` in the hidden copy that detects overflow. Only the
// text metrics of that copy matter, so pass a plain-text equivalent when
// `children` holds things that should exist once in the DOM: data attributes
// that get queried, ids, form controls.
measureChildren?: ComponentChildren;
}

export type MiddleTruncateProps = Omit<OverflowTextProps, 'mode' | 'children'> &
export type MiddleTruncateProps = Omit<
OverflowTextProps,
'mode' | 'children' | 'measureChildren'
> &
AllowableContentGroups & {
minimumLength?: number;
priority?: 'start' | 'end' | 'equal';
Expand Down Expand Up @@ -51,10 +59,14 @@ type AllowableContentGroups =
| {
children?: never;
contents: [ComponentChildren, ComponentChildren];
// Per-segment stand-ins for the hidden copies that detect overflow, in the
// same order as `contents`. See `measureChildren` on OverflowTextProps.
measureContents?: [ComponentChildren, ComponentChildren];
}
| {
contents?: never;
children: string;
measureContents?: never;
};

// When a split boundary lands adjacent to whitespace, the trailing/leading
Expand Down Expand Up @@ -210,7 +222,8 @@ function OverflowMarker({

function OverflowContent(options: OverflowTextProps) {
'use no memo';
const { mode, children } = options;
const { mode, children, measureChildren } = options;
const overflowChildren = measureChildren ?? children;

// The inner span wrapper here is only needed to implement
// the right aligned internals for fruncate
Expand All @@ -220,22 +233,31 @@ function OverflowContent(options: OverflowTextProps) {
{mode === 'fruncate' ? <span>{children}</span> : children}
</div>
<div data-truncate-content="overflow" aria-hidden>
{mode === 'fruncate' ? <span>{children}</span> : children}
{mode === 'fruncate' ? (
<span>{overflowChildren}</span>
) : (
overflowChildren
)}
</div>
</div>
);
}

export function OverflowText({
children,
measureChildren,
mode = 'truncate',
marker = '…',
variant = 'default',
...props
}: OverflowTextProps): JSX.Element {
'use no memo';
const contentNode = (
<OverflowContent key="content" mode={mode}>
<OverflowContent
key="content"
mode={mode}
measureChildren={measureChildren}
>
{children}
</OverflowContent>
);
Expand Down Expand Up @@ -291,6 +313,7 @@ export function Fruncate({
export function MiddleTruncate({
children,
contents,
measureContents,
priority = 'end',
split = 'center',
minimumLength = 12,
Expand All @@ -306,8 +329,16 @@ export function MiddleTruncate({
console.error('MiddleTruncate: contents must be an array of two items');
return null;
}
firstSegment = <Truncate {...props}>{contents[0]}</Truncate>;
secondSegment = <Fruncate {...props}>{contents[1]}</Fruncate>;
firstSegment = (
<Truncate {...props} measureChildren={measureContents?.[0]}>
{contents[0]}
</Truncate>
);
secondSegment = (
<Fruncate {...props} measureChildren={measureContents?.[1]}>
{contents[1]}
</Fruncate>
);
} else {
// TODO: figure out how to support ReactNode children in the future
if (typeof children !== 'string') {
Expand Down
95 changes: 76 additions & 19 deletions packages/trees/src/render/FileTreeView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @jsxImportSource preact */
import { Fragment } from 'preact';
import type { JSX } from 'preact';
import type { ComponentChildren, JSX } from 'preact';
import {
useCallback,
useEffect,
Expand Down Expand Up @@ -41,6 +41,7 @@ import type {
FileTreeItemHandle,
FileTreeRowDecoration,
FileTreeVisibleRow,
FileTreeVisibleSegment,
} from '../model/publicTypes';
import {
FILE_TREE_DEFAULT_ITEM_HEIGHT,
Expand Down Expand Up @@ -86,30 +87,86 @@ function formatFlattenedSegments(
return renameInput ?? row.name;
}

return (
<span data-item-flattened-subitems>
{segments.map((segment, index) => {
const isLast = index === segments.length - 1;
return (
const isRenaming = renameInput != null;

const renderSegment = (
segment: FileTreeVisibleSegment,
content: ComponentChildren
): JSX.Element => (
<span
key={segment.path}
data-item-flattened-subitem={segment.path}
data-item-flattened-subitem-drag-target={
dragTargetFlattenedSegmentPath === segment.path ? 'true' : undefined
}
>
{content}
</span>
);

// Renaming keeps every segment on a flex line of its own, where the terminal
// segment becomes the input. The input sizes itself against a flex item, and
// it has to stay visible rather than be truncated away.
if (isRenaming) {
return (
<span data-item-flattened-subitems>
{segments.map((segment, index) => (
<Fragment key={segment.path}>
<span
data-item-flattened-subitem={segment.path}
data-item-flattened-subitem-drag-target={
dragTargetFlattenedSegmentPath === segment.path
? 'true'
: undefined
}
>
{isLast && renameInput != null ? (
{renderSegment(
segment,
index === segments.length - 1 ? (
renameInput
) : (
<Truncate>{segment.name}</Truncate>
)}
</span>
)
)}
{index < segments.length - 1 ? ' / ' : ''}
</Fragment>
);
})}
))}
</span>
);
}

// Otherwise the path splits at its last separator, the same place the
// `leaf-path` split picks. Truncating each segment on its own spent the row on
// ellipses ("t… / kc… / … / se…") instead of on the path; truncating the run
// as a whole would drop the terminal directory, which is the one the row
// actually opens. So the leading run absorbs the shrinking and the terminal
// directory holds its ground: "test / kotlin / com… / server".
const leading = segments.slice(0, -1);
const terminal = segments[segments.length - 1];
if (terminal == null) {
return row.name;
}

// This separator opens the terminal segment, so its leading space lands at
// the start of that line box, where the browser collapses it away ("b/ c").
// A non-breaking space survives.
const terminalSeparator = '\u00a0/ ';

// The hidden copies that detect overflow measure the same text without
// duplicating the per-segment markup that drag and drop and the tests query.
const leadingText = leading.map((segment) => segment.name).join(' / ');
const terminalText = `${terminalSeparator}${terminal.name}`;

return (
<span data-item-flattened-subitems>
<MiddleTruncate
priority="end"
contents={[
leading.map((segment, index) => (
<Fragment key={segment.path}>
{renderSegment(segment, segment.name)}
{index < leading.length - 1 ? ' / ' : ''}
</Fragment>
)),
<Fragment key={terminal.path}>
{terminalSeparator}
{renderSegment(terminal, terminal.name)}
</Fragment>,
]}
measureContents={[leadingText, terminalText]}
/>
</span>
);
}
Expand Down
20 changes: 18 additions & 2 deletions packages/trees/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -856,8 +856,10 @@
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] {
* their painted background is inset by the focus ring width. The marker
* sits outside the segments themselves, so inset from the section that
* contains them. */
[data-item-section='content']:has([data-item-flattened-subitems]) {
--truncate-marker-block-inset: var(--trees-focus-ring-width);
}

Expand Down Expand Up @@ -930,7 +932,21 @@
}

/* Flattened Directory Parts */
/*
Outside renaming the segments are a middle-truncate group, so the leading run
can shrink while the terminal directory holds its width. As flex items each
segment shrank and truncated on its own, which spent a narrow row on ellipses
rather than on the path.
*/
[data-item-flattened-subitems] {
display: block;
min-width: 0;
}
/*
Renaming swaps the terminal segment for an input, which sizes itself against a
flex item rather than against the row.
*/
[data-item-flattened-subitems]:has([data-item-rename-input]) {
display: inline-flex;
align-items: center;
gap: 2px;
Expand Down
64 changes: 64 additions & 0 deletions packages/trees/test/e2e/file-tree-composition.pw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,70 @@ test.describe('file-tree composition surfaces', () => {
expect(fileMarkerPadding.bottom).toBe(0);
});

test('a narrow flattened row truncates the leading run and keeps the terminal directory', async ({
page,
}) => {
await page.goto('/test/e2e/fixtures/file-tree-composition.html');
await page.waitForFunction(
() => window.__fileTreeCompositionFixtureReady === true
);

const measureFlattenedRow = (mountWidth: string) =>
page.evaluate((width) => {
const mount = document.querySelector(
'[data-file-tree-composition-mount]'
);
if (!(mount instanceof HTMLElement)) {
throw new Error('Expected file-tree composition mount.');
}
mount.style.width = width;

const host = document.querySelector('file-tree-container');
const row = Array.from(
host?.shadowRoot?.querySelectorAll('button[data-type="item"]') ?? []
).find(
(candidate) =>
candidate.querySelector('[data-item-flattened-subitems]') != null
);
if (!(row instanceof HTMLElement)) {
throw new Error(
'Expected a flattened row in the composition fixture.'
);
}

const segments = Array.from(
row.querySelectorAll('[data-item-flattened-subitem]')
);
const terminal = segments.at(-1);

return {
// A segment carrying its own marker is a segment truncating alone.
segmentsWithOwnMarker: segments.filter(
(segment) => segment.querySelector('[data-truncate-marker]') != null
).length,
segmentCount: segments.length,
terminalName: terminal?.textContent ?? null,
terminalWidth: terminal?.getBoundingClientRect().width ?? 0,
};
}, mountWidth);

const wide = await measureFlattenedRow('360px');
expect(wide.segmentCount).toBeGreaterThan(1);

const narrow = await measureFlattenedRow('120px');

// Narrowing the row spends the shrinking on the leading run. Every segment
// truncating on its own is what left a deep path rendering as
// "t… / kc… / … / se…", with none of it readable.
expect(wide.segmentsWithOwnMarker).toBe(0);
expect(narrow.segmentsWithOwnMarker).toBe(0);

// The terminal directory is the one the row opens, so it keeps its width
// rather than being truncated away with the rest of the path.
expect(narrow.terminalName).toBe(wide.terminalName);
expect(narrow.terminalWidth).toBeCloseTo(wide.terminalWidth, 1);
});

test('keyboard navigation retargets the focused row trigger away from a stale hover', async ({
page,
}) => {
Expand Down
34 changes: 26 additions & 8 deletions packages/trees/test/file-tree-keyboard-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,17 +499,35 @@ describe('file-tree keyboard and selection', () => {
'[data-item-flattened-subitems]'
);

expect(flattenedContainer?.innerHTML).toContain(' / ');
expect(
// The rendered path reads as one run. The separator opening the terminal
// segment uses a non-breaking space so it survives at the start of its
// line box.
const visibleText = Array.from(
flattenedContainer?.querySelectorAll(
':scope > [data-item-flattened-subitem]'
).length
).toBe(2);
'[data-truncate-content="visible"]'
) ?? []
)
.map((node) => node.textContent)
.join('');
expect(visibleText).toBe('src\u00a0/ lib');

// Each segment is in the DOM once. The copies that measure overflow carry
// plain text, so they never duplicate a segment's identity.
expect(
Array.from(
flattenedContainer?.querySelectorAll(
'[data-item-flattened-subitem]'
) ?? []
).map((node) => node.getAttribute('data-item-flattened-subitem'))
).toEqual(['src/', 'src/lib/']);

// No span exists solely to hold a separator, which would make it a hover
// and drop target of its own.
expect(
flattenedContainer?.querySelector(
':scope > span:not([data-item-flattened-subitem])'
Array.from(flattenedContainer?.querySelectorAll('span') ?? []).some(
(node) => node.textContent?.trim() === '/'
)
).toBeNull();
).toBe(false);

fileTree.cleanUp();
} finally {
Expand Down