Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,38 @@ export function PathStorePoweredRenderDemoClient({
const options = useMemo<PathStoreFileTreeOptions>(
() => ({
...sharedOptions,
composition: {
...sharedOptions.composition,
header: {
...sharedOptions.composition?.header,
render: () => {
const header = document.createElement('div');
header.style.alignItems = 'center';
header.style.display = 'flex';
header.style.gap = '12px';
header.style.padding = '8px 12px';

const label = document.createElement('strong');
label.textContent = 'Provisional header slot';
header.append(label);

const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Log header action';
button.addEventListener('click', () => {
addLog('header action: clicked');
});
header.append(button);

return header;
},
},
},
id: 'pst-phase4',
onSelectionChange: handleSelectionChange,
preparedInput,
}),
[handleSelectionChange, preparedInput, sharedOptions]
[addLog, handleSelectionChange, preparedInput, sharedOptions]
);

return (
Expand All @@ -99,21 +126,22 @@ export function PathStorePoweredRenderDemoClient({
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
Path-store lane · provisional
</p>
<h1 className="text-2xl font-bold">Focus + Selection</h1>
<h1 className="text-2xl font-bold">Focus + Selection + Header Slot</h1>
<p className="text-muted-foreground max-w-3xl text-sm leading-6">
Phase 4 keeps the landed focus/navigation model and adds selection:
click and keyboard selection semantics, path-first imperative item
methods, and lightweight selection-change observation in the existing
methods, lightweight selection-change observation, and now the first
simple composition surface via a slotted header in the existing
path-store-powered demo.
</p>
</header>

<HydratedPathStoreExample
containerHtml={containerHtml}
description="Click a row to select it, use Ctrl/Cmd-click and Shift-click for multi-selection, and try Ctrl+Space, Shift+ArrowUp/Down, and Ctrl+A. Directory rows still keep the Phase 2 toggle behavior on plain click, and selection changes are logged below."
description="Click a row to select it, use Ctrl/Cmd-click and Shift-click for multi-selection, and try the slotted header button above the tree. Directory rows still keep the Phase 2 toggle behavior on plain click, and selection changes are logged below."
footer={<StateLog entries={log} />}
options={options}
title="Focus + Selection"
title="Focus + Selection + Header Slot"
/>

<section className="space-y-3 rounded-lg border p-4">
Expand Down
7 changes: 7 additions & 0 deletions apps/docs/app/trees-dev/path-store-powered/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@ const linuxKernelWorkload = getVirtualizationWorkload('linux-1x');
const linuxKernelPreparedInput = createPresortedPreparedInput(
linuxKernelWorkload.files
);
const PATH_STORE_HEADER_HTML =
'<div data-path-store-demo-header style="align-items:center;display:flex;gap:12px;padding:8px 12px"><strong>Provisional header slot</strong><button type="button">Log header action</button></div>';

export default function PathStorePoweredPage() {
const sharedOptions: Omit<PathStoreFileTreeOptions, 'id' | 'preparedInput'> =
{
composition: {
header: {
html: PATH_STORE_HEADER_HTML,
},
},
flattenEmptyDirectories: true,
initialExpandedPaths: linuxKernelWorkload.expandedFolders,
paths: linuxKernelWorkload.files,
Expand Down
47 changes: 45 additions & 2 deletions packages/trees/src/path-store/file-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,25 @@ import {
ensureFileTreeStyles,
FileTreeContainerLoaded,
} from '../components/web-components';
import { FILE_TREE_STYLE_ATTRIBUTE, FILE_TREE_TAG_NAME } from '../constants';
import {
FILE_TREE_STYLE_ATTRIBUTE,
FILE_TREE_TAG_NAME,
HEADER_SLOT_NAME,
} from '../constants';
import fileTreeStyles from '../style.css';
import { PathStoreTreesController } from './controller';
import {
hydratePathStoreTreesRoot,
renderPathStoreTreesRoot,
unmountPathStoreTreesRoot,
} from './runtime';
import { PathStoreTreesManagedSlotHost } from './slotHost';
import type {
PathStoreFileTreeOptions,
PathStoreFileTreeSsrPayload,
PathStoreTreeHydrationProps,
PathStoreTreeRenderProps,
PathStoreTreesCompositionOptions,
PathStoreTreesItemHandle,
PathStoreTreesSelectionChangeListener,
} from './types';
Expand Down Expand Up @@ -58,6 +64,17 @@ function parseSpriteSheet(spriteSheet: string): SVGElement | undefined {
return svg instanceof SVGElement ? svg : undefined;
}

function getHeaderSlotHtml(
composition: PathStoreTreesCompositionOptions | undefined
): string {
const headerHtml = composition?.header?.html?.trim();
if (headerHtml == null || headerHtml.length === 0) {
return '';
}

return `<div slot="${HEADER_SLOT_NAME}" data-path-store-managed-slot="${HEADER_SLOT_NAME}">${headerHtml}</div>`;
}

function ensureBuiltInSpriteSheet(shadowRoot: ShadowRoot): void {
if (shadowRoot.querySelector('svg[data-icon-sprite]') != null) {
return;
Expand All @@ -72,11 +89,13 @@ function ensureBuiltInSpriteSheet(shadowRoot: ShadowRoot): void {
export class PathStoreFileTree {
static LoadedCustomComponent: boolean = FileTreeContainerLoaded;

readonly #composition: PathStoreTreesCompositionOptions | undefined;
readonly #controller: PathStoreTreesController;
readonly #id: string;
readonly #onSelectionChange:
| PathStoreTreesSelectionChangeListener
| undefined;
readonly #slotHost = new PathStoreTreesManagedSlotHost();
readonly #viewOptions: Pick<
PathStoreFileTreeOptions,
'itemHeight' | 'overscan' | 'viewportHeight'
Expand All @@ -88,13 +107,15 @@ export class PathStoreFileTree {

public constructor(options: PathStoreFileTreeOptions) {
const {
composition,
id,
itemHeight,
onSelectionChange,
overscan,
viewportHeight,
...controllerOptions
} = options;
this.#composition = composition;
this.#id = createClientId(id);
this.#onSelectionChange = onSelectionChange;
this.#viewOptions = {
Expand All @@ -118,6 +139,8 @@ export class PathStoreFileTree {
delete this.#wrapper.dataset.fileTreeVirtualizedWrapper;
this.#wrapper = undefined;
}
this.#slotHost.clearAll();
this.#slotHost.setHost(null);
if (this.#fileTreeContainer != null) {
delete this.#fileTreeContainer.dataset.fileTreeVirtualized;
this.#fileTreeContainer = undefined;
Expand All @@ -142,6 +165,7 @@ export class PathStoreFileTree {
public hydrate({ fileTreeContainer }: PathStoreTreeHydrationProps): void {
const host = this.#prepareHost(fileTreeContainer);
const wrapper = this.#getOrCreateWrapper(host);
this.#syncHeaderSlotContent();
hydratePathStoreTreesRoot(wrapper, {
controller: this.#controller,
...this.#getResolvedViewOptions(host),
Expand All @@ -157,6 +181,7 @@ export class PathStoreFileTree {
containerWrapper
);
const wrapper = this.#getOrCreateWrapper(host);
this.#syncHeaderSlotContent();
renderPathStoreTreesRoot(wrapper, {
controller: this.#controller,
...this.#getResolvedViewOptions(host),
Expand Down Expand Up @@ -195,6 +220,21 @@ export class PathStoreFileTree {
onSelectionChange(this.#controller.getSelectedPaths());
}

// Keeps header slot content attached to the host light DOM so hydration and
// later composition surfaces can share one host-managed slot path.
#syncHeaderSlotContent(): void {
const renderHeader = this.#composition?.header?.render;
if (renderHeader != null) {
this.#slotHost.setSlotContent(HEADER_SLOT_NAME, renderHeader());
return;
}

this.#slotHost.setSlotHtml(
HEADER_SLOT_NAME,
this.#composition?.header?.html ?? null
);
}

#getOrCreateWrapper(host: HTMLElement): HTMLDivElement {
if (this.#wrapper != null) {
return this.#wrapper;
Expand Down Expand Up @@ -239,6 +279,7 @@ export class PathStoreFileTree {
ensureBuiltInSpriteSheet(shadowRoot);
host.dataset.fileTreeVirtualized = 'true';
host.style.display = 'flex';
this.#slotHost.setHost(host);
this.#fileTreeContainer = host;
return host;
}
Expand All @@ -248,6 +289,7 @@ export function preloadPathStoreFileTree(
options: PathStoreFileTreeOptions
): PathStoreFileTreeSsrPayload {
const {
composition,
id,
itemHeight,
onSelectionChange: _onSelectionChange,
Expand All @@ -271,7 +313,8 @@ export function preloadPathStoreFileTree(
controller.destroy();

const shadowHtml = `${getBuiltInSpriteSheet('minimal')}<style ${FILE_TREE_STYLE_ATTRIBUTE}>${fileTreeStyles}</style><div data-file-tree-id="${resolvedId}" data-file-tree-virtualized-wrapper="true">${bodyHtml}</div>`;
const html = `<file-tree-container id="${resolvedId}" data-file-tree-virtualized="true"><template shadowrootmode="open">${shadowHtml}</template></file-tree-container>`;
const headerSlotHtml = getHeaderSlotHtml(composition);
const html = `<file-tree-container id="${resolvedId}" data-file-tree-virtualized="true"><template shadowrootmode="open">${shadowHtml}</template>${headerSlotHtml}</file-tree-container>`;
return {
html,
id: resolvedId,
Expand Down
2 changes: 2 additions & 0 deletions packages/trees/src/path-store/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
export { PathStoreTreesController } from './controller';
export { PathStoreFileTree, preloadPathStoreFileTree } from './file-tree';
export type {
PathStoreTreesCompositionOptions,
PathStoreTreesDirectoryHandle,
PathStoreFileTreeOptions,
PathStoreFileTreeSsrPayload,
PathStoreTreesFileHandle,
PathStoreTreesHeaderCompositionOptions,
PathStoreTreeHydrationProps,
PathStoreTreesItemHandle,
PathStoreTreeRenderProps,
Expand Down
86 changes: 86 additions & 0 deletions packages/trees/src/path-store/slotHost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Tracks the library-owned slotted nodes so header content can move with the
// host element without clobbering user-managed light-DOM children.
export class PathStoreTreesManagedSlotHost {
#contentBySlot = new Map<string, HTMLElement>();
#host: HTMLElement | null = null;

public clearAll(): void {
for (const content of this.#contentBySlot.values()) {
content.remove();
}
this.#contentBySlot.clear();
}

public setHost(host: HTMLElement | null): void {
this.#host = host;
if (host == null) {
return;
}

this.#adoptExistingManagedContent(host);

for (const [slotName, content] of this.#contentBySlot) {
this.#attachContent(slotName, content);
}
}

public setSlotContent(slotName: string, content: HTMLElement | null): void {
const currentContent = this.#contentBySlot.get(slotName) ?? null;
if (currentContent === content) {
if (content != null) {
this.#attachContent(slotName, content);
}
return;
}

currentContent?.remove();
if (content == null) {
this.#contentBySlot.delete(slotName);
return;
}

this.#contentBySlot.set(slotName, content);
this.#attachContent(slotName, content);
}

public setSlotHtml(slotName: string, html: string | null): void {
const normalizedHtml = html?.trim() ?? '';
if (normalizedHtml.length === 0) {
this.setSlotContent(slotName, null);
return;
}

const currentContent = this.#contentBySlot.get(slotName) ?? null;
if (currentContent != null && currentContent.innerHTML === normalizedHtml) {
this.#attachContent(slotName, currentContent);
return;
}

const nextContent = document.createElement('div');
nextContent.innerHTML = normalizedHtml;
this.setSlotContent(slotName, nextContent);
}

#attachContent(slotName: string, content: HTMLElement): void {
content.slot = slotName;
content.dataset.pathStoreManagedSlot = slotName;
if (this.#host != null && content.parentNode !== this.#host) {
this.#host.appendChild(content);
}
}

#adoptExistingManagedContent(host: HTMLElement): void {
for (const element of Array.from(host.children)) {
if (!(element instanceof HTMLElement)) {
continue;
}

const slotName = element.dataset.pathStoreManagedSlot;
if (slotName == null || this.#contentBySlot.has(slotName)) {
continue;
}

this.#contentBySlot.set(slotName, element);
}
}
}
10 changes: 10 additions & 0 deletions packages/trees/src/path-store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export interface PathStoreTreesRenderOptions {

export interface PathStoreFileTreeOptions
extends PathStoreTreesControllerOptions, PathStoreTreesRenderOptions {
composition?: PathStoreTreesCompositionOptions;
id?: string;
onSelectionChange?: PathStoreTreesSelectionChangeListener;
}
Expand Down Expand Up @@ -117,3 +118,12 @@ export type PathStoreTreesControllerListener = () => void;
export type PathStoreTreesSelectionChangeListener = (
selectedPaths: readonly PathStoreTreesPublicId[]
) => void;

export interface PathStoreTreesHeaderCompositionOptions {
html?: string;
render?: () => HTMLElement | null;
}

export interface PathStoreTreesCompositionOptions {
header?: PathStoreTreesHeaderCompositionOptions;
}
2 changes: 2 additions & 0 deletions packages/trees/src/path-store/view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';

import { Icon } from '../components/Icon';
import { MiddleTruncate, Truncate } from '../components/OverflowText';
import { HEADER_SLOT_NAME } from '../constants';
import { PathStoreTreesController } from './controller';
import type {
PathStoreTreesDirectoryHandle,
Expand Down Expand Up @@ -668,6 +669,7 @@ export function PathStoreTreesView({
data-path-store-guide-style="true"
dangerouslySetInnerHTML={{ __html: guideStyleText }}
/>
<slot name={HEADER_SLOT_NAME} data-type="header-slot" />
<div ref={scrollRef} data-file-tree-virtualized-scroll="true">
<div
ref={listRef}
Expand Down
Loading
Loading