diff --git a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx index 6b443599c..415af3b25 100644 --- a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx +++ b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx @@ -86,11 +86,38 @@ export function PathStorePoweredRenderDemoClient({ const options = useMemo( () => ({ ...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 ( @@ -99,21 +126,22 @@ export function PathStorePoweredRenderDemoClient({

Path-store lane ยท provisional

-

Focus + Selection

+

Focus + Selection + Header Slot

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.

} options={options} - title="Focus + Selection" + title="Focus + Selection + Header Slot" />
diff --git a/apps/docs/app/trees-dev/path-store-powered/page.tsx b/apps/docs/app/trees-dev/path-store-powered/page.tsx index 7eab1bd74..2114e30f7 100644 --- a/apps/docs/app/trees-dev/path-store-powered/page.tsx +++ b/apps/docs/app/trees-dev/path-store-powered/page.tsx @@ -11,10 +11,17 @@ const linuxKernelWorkload = getVirtualizationWorkload('linux-1x'); const linuxKernelPreparedInput = createPresortedPreparedInput( linuxKernelWorkload.files ); +const PATH_STORE_HEADER_HTML = + '
Provisional header slot
'; export default function PathStorePoweredPage() { const sharedOptions: Omit = { + composition: { + header: { + html: PATH_STORE_HEADER_HTML, + }, + }, flattenEmptyDirectories: true, initialExpandedPaths: linuxKernelWorkload.expandedFolders, paths: linuxKernelWorkload.files, diff --git a/packages/trees/src/path-store/file-tree.ts b/packages/trees/src/path-store/file-tree.ts index 09546971b..7fa646128 100644 --- a/packages/trees/src/path-store/file-tree.ts +++ b/packages/trees/src/path-store/file-tree.ts @@ -7,7 +7,11 @@ 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 { @@ -15,11 +19,13 @@ import { renderPathStoreTreesRoot, unmountPathStoreTreesRoot, } from './runtime'; +import { PathStoreTreesManagedSlotHost } from './slotHost'; import type { PathStoreFileTreeOptions, PathStoreFileTreeSsrPayload, PathStoreTreeHydrationProps, PathStoreTreeRenderProps, + PathStoreTreesCompositionOptions, PathStoreTreesItemHandle, PathStoreTreesSelectionChangeListener, } from './types'; @@ -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 `
${headerHtml}
`; +} + function ensureBuiltInSpriteSheet(shadowRoot: ShadowRoot): void { if (shadowRoot.querySelector('svg[data-icon-sprite]') != null) { return; @@ -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' @@ -88,6 +107,7 @@ export class PathStoreFileTree { public constructor(options: PathStoreFileTreeOptions) { const { + composition, id, itemHeight, onSelectionChange, @@ -95,6 +115,7 @@ export class PathStoreFileTree { viewportHeight, ...controllerOptions } = options; + this.#composition = composition; this.#id = createClientId(id); this.#onSelectionChange = onSelectionChange; this.#viewOptions = { @@ -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; @@ -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), @@ -157,6 +181,7 @@ export class PathStoreFileTree { containerWrapper ); const wrapper = this.#getOrCreateWrapper(host); + this.#syncHeaderSlotContent(); renderPathStoreTreesRoot(wrapper, { controller: this.#controller, ...this.#getResolvedViewOptions(host), @@ -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; @@ -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; } @@ -248,6 +289,7 @@ export function preloadPathStoreFileTree( options: PathStoreFileTreeOptions ): PathStoreFileTreeSsrPayload { const { + composition, id, itemHeight, onSelectionChange: _onSelectionChange, @@ -271,7 +313,8 @@ export function preloadPathStoreFileTree( controller.destroy(); const shadowHtml = `${getBuiltInSpriteSheet('minimal')}
${bodyHtml}
`; - const html = ``; + const headerSlotHtml = getHeaderSlotHtml(composition); + const html = `${headerSlotHtml}`; return { html, id: resolvedId, diff --git a/packages/trees/src/path-store/index.ts b/packages/trees/src/path-store/index.ts index 940596763..b2460a69c 100644 --- a/packages/trees/src/path-store/index.ts +++ b/packages/trees/src/path-store/index.ts @@ -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, diff --git a/packages/trees/src/path-store/slotHost.ts b/packages/trees/src/path-store/slotHost.ts new file mode 100644 index 000000000..67982edf3 --- /dev/null +++ b/packages/trees/src/path-store/slotHost.ts @@ -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(); + #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); + } + } +} diff --git a/packages/trees/src/path-store/types.ts b/packages/trees/src/path-store/types.ts index 26ea184ba..70147b63b 100644 --- a/packages/trees/src/path-store/types.ts +++ b/packages/trees/src/path-store/types.ts @@ -69,6 +69,7 @@ export interface PathStoreTreesRenderOptions { export interface PathStoreFileTreeOptions extends PathStoreTreesControllerOptions, PathStoreTreesRenderOptions { + composition?: PathStoreTreesCompositionOptions; id?: string; onSelectionChange?: PathStoreTreesSelectionChangeListener; } @@ -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; +} diff --git a/packages/trees/src/path-store/view.tsx b/packages/trees/src/path-store/view.tsx index 6124374d5..55c02eece 100644 --- a/packages/trees/src/path-store/view.tsx +++ b/packages/trees/src/path-store/view.tsx @@ -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, @@ -668,6 +669,7 @@ export function PathStoreTreesView({ data-path-store-guide-style="true" dangerouslySetInnerHTML={{ __html: guideStyleText }} /> +
', { + url: 'http://localhost', + }); + const originalValues = { + CSSStyleSheet: Reflect.get(globalThis, 'CSSStyleSheet'), + customElements: Reflect.get(globalThis, 'customElements'), + document: Reflect.get(globalThis, 'document'), + Event: Reflect.get(globalThis, 'Event'), + HTMLElement: Reflect.get(globalThis, 'HTMLElement'), + HTMLButtonElement: Reflect.get(globalThis, 'HTMLButtonElement'), + HTMLDivElement: Reflect.get(globalThis, 'HTMLDivElement'), + HTMLStyleElement: Reflect.get(globalThis, 'HTMLStyleElement'), + HTMLTemplateElement: Reflect.get(globalThis, 'HTMLTemplateElement'), + MutationObserver: Reflect.get(globalThis, 'MutationObserver'), + navigator: Reflect.get(globalThis, 'navigator'), + Node: Reflect.get(globalThis, 'Node'), + ResizeObserver: Reflect.get(globalThis, 'ResizeObserver'), + SVGElement: Reflect.get(globalThis, 'SVGElement'), + ShadowRoot: Reflect.get(globalThis, 'ShadowRoot'), + window: Reflect.get(globalThis, 'window'), + }; + + class MockStyleSheet { + replaceSync(_value: string): void {} + } + + class MockResizeObserver { + observe(_target: Element): void {} + disconnect(): void {} + } + + Object.assign(globalThis, { + CSSStyleSheet: MockStyleSheet, + customElements: dom.window.customElements, + document: dom.window.document, + Event: dom.window.Event, + HTMLElement: dom.window.HTMLElement, + HTMLButtonElement: dom.window.HTMLButtonElement, + HTMLDivElement: dom.window.HTMLDivElement, + HTMLStyleElement: dom.window.HTMLStyleElement, + HTMLTemplateElement: dom.window.HTMLTemplateElement, + MutationObserver: dom.window.MutationObserver, + navigator: dom.window.navigator, + Node: dom.window.Node, + ResizeObserver: MockResizeObserver, + SVGElement: dom.window.SVGElement, + ShadowRoot: dom.window.ShadowRoot, + window: dom.window, + }); + + return { + cleanup() { + for (const [key, value] of Object.entries(originalValues)) { + if (value === undefined) { + Reflect.deleteProperty(globalThis, key); + } else { + Object.assign(globalThis, { [key]: value }); + } + } + dom.window.close(); + }, + dom, + }; +} + +async function flushDom(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('path-store header slot', () => { + test('preloadPathStoreFileTree includes the header slot outlet', async () => { + const { preloadPathStoreFileTree } = await import('../src/path-store'); + + const payload = preloadPathStoreFileTree({ + composition: { + header: { + html: '', + }, + }, + flattenEmptyDirectories: true, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + viewportHeight: 120, + }); + + expect(payload.shadowHtml).toContain('slot name="header"'); + expect(payload.html).toContain('slot="header"'); + expect(payload.html).toContain('data-test-ssr-header'); + }); + + test('render attaches and cleanup removes host-managed header content', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree } = await import('../src/path-store'); + const mount = dom.window.document.createElement('div'); + dom.window.document.body.appendChild(mount); + + const fileTree = new PathStoreFileTree({ + composition: { + header: { + render: (): HTMLElement => { + const header = dom.window.document.createElement('button'); + header.dataset.testHeader = 'true'; + header.textContent = 'Header action'; + return header as unknown as HTMLElement; + }, + }, + }, + flattenEmptyDirectories: true, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + viewportHeight: 120, + }); + + fileTree.render({ containerWrapper: mount }); + await flushDom(); + + const host = fileTree.getFileTreeContainer(); + expect(host?.querySelectorAll('[slot="header"]')).toHaveLength(1); + expect(host?.querySelector('[data-test-header="true"]')).not.toBeNull(); + + fileTree.cleanUp(); + expect(host?.querySelector('[slot="header"]')).toBeNull(); + } finally { + cleanup(); + } + }); + + test('render attaches HTML-only header content without requiring SSR markup', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree } = await import('../src/path-store'); + const mount = dom.window.document.createElement('div'); + dom.window.document.body.appendChild(mount); + + const fileTree = new PathStoreFileTree({ + composition: { + header: { + html: '', + }, + }, + flattenEmptyDirectories: true, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + viewportHeight: 120, + }); + + fileTree.render({ containerWrapper: mount }); + await flushDom(); + + const host = fileTree.getFileTreeContainer(); + expect(host?.querySelectorAll('[slot="header"]')).toHaveLength(1); + expect( + host?.querySelector('[data-test-html-header="true"]')?.textContent + ).toBe('HTML header'); + + fileTree.cleanUp(); + expect(host?.querySelector('[slot="header"]')).toBeNull(); + } finally { + cleanup(); + } + }); + + test('hydrate keeps header slot content to a single host-managed node', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree, preloadPathStoreFileTree } = + await import('../src/path-store'); + const payload = preloadPathStoreFileTree({ + composition: { + header: { + html: '
Hydrated header
', + }, + }, + flattenEmptyDirectories: true, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + viewportHeight: 120, + }); + + const mount = dom.window.document.createElement('div'); + mount.innerHTML = payload.html; + dom.window.document.body.appendChild(mount); + + const host = mount.querySelector('file-tree-container'); + if (!(host instanceof dom.window.HTMLElement)) { + throw new Error('expected SSR host'); + } + expect(host.querySelectorAll('[slot="header"]')).toHaveLength(1); + + const fileTree = new PathStoreFileTree({ + composition: { + header: { + render: (): HTMLElement => { + const header = dom.window.document.createElement('div'); + header.dataset.testHydratedHeader = 'true'; + header.textContent = 'Hydrated header'; + return header as unknown as HTMLElement; + }, + }, + }, + flattenEmptyDirectories: true, + id: payload.id, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + viewportHeight: 120, + }); + + fileTree.hydrate({ fileTreeContainer: host }); + await flushDom(); + fileTree.render({ fileTreeContainer: host }); + await flushDom(); + + expect(host.querySelectorAll('[slot="header"]')).toHaveLength(1); + expect( + host.querySelector('[data-test-hydrated-header="true"]') + ).not.toBeNull(); + expect( + host.querySelectorAll('[data-test-ssr-header="true"]') + ).toHaveLength(0); + + fileTree.cleanUp(); + } finally { + cleanup(); + } + }); +});