diff --git a/apps/docs/app/(trees)/_docs/DocsPage.tsx b/apps/docs/app/(trees)/_docs/DocsPage.tsx index 5f9ecf94e..a1673e619 100644 --- a/apps/docs/app/(trees)/_docs/DocsPage.tsx +++ b/apps/docs/app/(trees)/_docs/DocsPage.tsx @@ -8,6 +8,7 @@ import * as chooseYourIntegrationConstants from '../docs/Guides/ChooseYourIntegr import * as customizeIconsConstants from '../docs/Guides/CustomizeIcons/constants'; import * as getStartedWithReactConstants from '../docs/Guides/GetStartedWithReact/constants'; import * as getStartedWithVanillaConstants from '../docs/Guides/GetStartedWithVanilla/constants'; +import * as getStartedWithVueConstants from '../docs/Guides/GetStartedWithVue/constants'; import * as handleLargeTreesEfficientlyConstants from '../docs/Guides/HandleLargeTreesEfficiently/constants'; import * as navigateSelectionFocusAndSearchConstants from '../docs/Guides/NavigateSelectionFocusAndSearch/constants'; import * as renameDragAndTriggerItemActionsConstants from '../docs/Guides/RenameDragAndTriggerItemActions/constants'; @@ -24,6 +25,7 @@ import * as reactApiConstants from '../docs/Reference/ReactAPI/constants'; import * as ssrApiConstants from '../docs/Reference/SSRAPI/constants'; import * as stylingAndThemingConstants from '../docs/Reference/StylingAndTheming/constants'; import * as vanillaApiConstants from '../docs/Reference/VanillaAPI/constants'; +import * as vueApiConstants from '../docs/Reference/VueAPI/constants'; import { DocsLayout } from '@/components/docs/DocsLayout'; import { HeadingAnchors } from '@/components/docs/HeadingAnchors'; import { ProseWrapper } from '@/components/docs/ProseWrapper'; @@ -45,6 +47,10 @@ const GUIDE_SECTIONS: readonly DocsSection[] = [ filePath: '(trees)/docs/Guides/GetStartedWithReact/content.mdx', constants: getStartedWithReactConstants, }, + { + filePath: '(trees)/docs/Guides/GetStartedWithVue/content.mdx', + constants: getStartedWithVueConstants, + }, { filePath: '(trees)/docs/Guides/GetStartedWithVanilla/content.mdx', constants: getStartedWithVanillaConstants, @@ -89,6 +95,10 @@ const REFERENCE_SECTIONS: readonly DocsSection[] = [ filePath: '(trees)/docs/Reference/ReactAPI/content.mdx', constants: reactApiConstants, }, + { + filePath: '(trees)/docs/Reference/VueAPI/content.mdx', + constants: vueApiConstants, + }, { filePath: '(trees)/docs/Reference/VanillaAPI/content.mdx', constants: vanillaApiConstants, @@ -106,7 +116,7 @@ const REFERENCE_SECTIONS: readonly DocsSection[] = [ const treesDocsTitle = 'Trees, from Pierre'; const treesDocsDescription = - 'Guide-first documentation for @pierre/trees, covering React, vanilla, prepared input, styling, icons, Git status, large trees, and SSR hydration.'; + 'Guide-first documentation for @pierre/trees, covering React, Vue, vanilla, prepared input, styling, icons, Git status, large trees, and SSR hydration.'; // Next.js replaces (does not deep-merge) nested metadata objects like // `openGraph` and `twitter` from parent segments. Re-declare `images` here diff --git a/apps/docs/app/(trees)/docs/Guides/ChooseYourIntegration/constants.ts b/apps/docs/app/(trees)/docs/Guides/ChooseYourIntegration/constants.ts index f841bccba..9264b5b6d 100644 --- a/apps/docs/app/(trees)/docs/Guides/ChooseYourIntegration/constants.ts +++ b/apps/docs/app/(trees)/docs/Guides/ChooseYourIntegration/constants.ts @@ -11,6 +11,20 @@ export function ProjectTree({ paths }: { paths: readonly string[] }) { }` ); +export const CHOOSE_INTEGRATION_VUE_EXAMPLE = docsCodeSnippet( + 'project-tree.vue', + ` + +` +); + export const CHOOSE_INTEGRATION_VANILLA_EXAMPLE = docsCodeSnippet( 'mount-tree.ts', `import { FileTree } from '@pierre/trees'; diff --git a/apps/docs/app/(trees)/docs/Guides/ChooseYourIntegration/content.mdx b/apps/docs/app/(trees)/docs/Guides/ChooseYourIntegration/content.mdx index 75ae1665f..a7b72ce6f 100644 --- a/apps/docs/app/(trees)/docs/Guides/ChooseYourIntegration/content.mdx +++ b/apps/docs/app/(trees)/docs/Guides/ChooseYourIntegration/content.mdx @@ -1,6 +1,7 @@ -`@pierre/trees` exposes one path-first model and two primary runtime entries: a -thin React layer in [`@pierre/trees/react`](#get-started-with-react) and the -vanilla class in [`@pierre/trees`](#get-started-with-vanilla). +`@pierre/trees` exposes one path-first model and three primary runtime entries: +a thin React layer in [`@pierre/trees/react`](#get-started-with-react), a Vue 3 +layer in [`@pierre/trees/vue`](#get-started-with-vue), and the vanilla class in +[`@pierre/trees`](#get-started-with-vanilla). ### Choose your integration @@ -14,7 +15,7 @@ and the target you rename or move later. For the shared vocabulary behind that rule, jump to [Shared concepts](#shared-concepts). -#### 2. React vs Vanilla JS +#### 2. React, Vue, or Vanilla JS Pick the React entry point when the surrounding UI already lives in React. Jump to the [getting started with React](#get-started-with-react) guide for more @@ -22,7 +23,12 @@ info. -Pick the vanilla class when your app is not React-based, or when another +Pick the Vue entry point when the surrounding UI already lives in Vue 3. Jump to +the [getting started with Vue](#get-started-with-vue) guide for more. + + + +Pick the vanilla class when your app is not React- or Vue-based, or when another framework will own lifecycle around an imperative model. Jump to the [getting started with vanilla JS](#get-started-with-vanilla) for more. @@ -30,9 +36,8 @@ framework will own lifecycle around an imperative model. Jump to the #### 3. Understand tree-shape -Both runtimes consume the same tree data. Small examples can start with raw +All runtimes consume the same tree data. Small examples can start with raw `paths`, but real application trees should move to prepared input before the -client pays that shaping work on every load. Regardless of your runtime being -React or vanilla JS, be sure to read +client pays that shaping work on every load. Regardless of your runtime, read [Shape tree data for fast rendering](#shape-tree-data-for-fast-rendering) after your runtime quickstart. diff --git a/apps/docs/app/(trees)/docs/Guides/GetStartedWithVanilla/content.mdx b/apps/docs/app/(trees)/docs/Guides/GetStartedWithVanilla/content.mdx index b5cb1a59f..f8f269df7 100644 --- a/apps/docs/app/(trees)/docs/Guides/GetStartedWithVanilla/content.mdx +++ b/apps/docs/app/(trees)/docs/Guides/GetStartedWithVanilla/content.mdx @@ -1,7 +1,7 @@ ### Get started with vanilla -Use the vanilla runtime when your app is not React-based, or when another -framework will own lifecycle around an imperative tree model. +Use the vanilla runtime when your app is not React- or Vue-based, or when +another framework will own lifecycle around an imperative tree model. `new FileTree(...)` creates the model, and `render(...)` or `hydrate(...)` attaches it to the DOM. @@ -72,5 +72,5 @@ the same: - mount or unmount around the instance - keep all tree reads and writes on the model itself -That keeps React as the only first-class wrapper surface without changing how -the underlying model works. +That keeps framework wrappers thin without changing how the underlying model +works. diff --git a/apps/docs/app/(trees)/docs/Guides/GetStartedWithVue/constants.ts b/apps/docs/app/(trees)/docs/Guides/GetStartedWithVue/constants.ts new file mode 100644 index 000000000..4293b7f47 --- /dev/null +++ b/apps/docs/app/(trees)/docs/Guides/GetStartedWithVue/constants.ts @@ -0,0 +1,75 @@ +import { docsCodeSnippet } from '@/lib/docsCodeSnippet'; + +export const VUE_QUICKSTART_INSTALL = docsCodeSnippet( + 'install.sh', + `bun add @pierre/trees +# npm: npm install @pierre/trees +# pnpm: pnpm add @pierre/trees` +); + +export const VUE_QUICKSTART_PROJECT_TREE = docsCodeSnippet( + 'project-tree.vue', + ` + +` +); + +export const VUE_QUICKSTART_SEARCHABLE_TREE = docsCodeSnippet( + 'searchable-tree.vue', + ` + +` +); diff --git a/apps/docs/app/(trees)/docs/Guides/GetStartedWithVue/content.mdx b/apps/docs/app/(trees)/docs/Guides/GetStartedWithVue/content.mdx new file mode 100644 index 000000000..105e480c2 --- /dev/null +++ b/apps/docs/app/(trees)/docs/Guides/GetStartedWithVue/content.mdx @@ -0,0 +1,77 @@ +### Get started with Vue + +Use the Vue entry point when your surrounding UI already lives in Vue 3. The +composable creates one stable tree model, and the component mounts that model +into the host element. + +#### Install `@pierre/trees` + +Use the package root for the vanilla runtime, and the `/vue` entry point for the +Vue wrapper. + + + +#### Create the model with `useFileTree(...)` + +`useFileTree(...)` from `@pierre/trees/vue` creates the model exactly once for +the component lifetime. Later option changes are not a controlled update path. +If the tree data or runtime behavior changes after mount, update the model +through methods such as `resetPaths(...)`, `setComposition(...)`, +`setGitStatus(...)`, or `setIcons(...)`. + +For small trees, you can pass raw `paths`. For scalable trees, prefer +`preparedInput` produced on the server or another non-UI boundary. + + + +If you are still deciding how to shape that input, read +[Shape tree data for fast rendering](#shape-tree-data-for-fast-rendering) after +this quickstart. + +#### Render with `` + +`` is a thin Vue wrapper over the model. It mounts the tree into the +host element, forwards normal host attributes such as `class` and `style`, and +hydrates existing server output when you pass `preloadedData` later. + +Keep the mental model simple: the model owns the tree state, and the Vue +component renders it. + +#### Read and update tree state through the model + +Vue code reads snapshots from the model through composables, and writes back +through model methods. + + + +Use `useFileTreeSelector(model, selector, equality?)` when sibling UI needs a +custom derived ref without updating on unrelated tree changes. For the shared +interaction vocabulary, read +[Navigate selection, focus, and search](#navigate-selection-focus-and-search) +and [Vue API](#vue-api). + +#### Use simple `paths` input only when the tree is small + +Raw `paths` is the low-ceremony path for demos, tests, and very small static +trees. It is not the scalable default. Once the tree becomes expensive to shape +or sort on the client, move that work out of the UI. + +#### Move to prepared input before the tree gets expensive + +The recommended scale path is: + +1. Load canonical paths on the server or another non-UI boundary. +2. Prepare the tree input once. +3. Pass `preparedInput` into `useFileTree(...)`. + +If the server already knows the final order, +`preparePresortedFileTreeInput(...)` is the highest-performance prepared-input +variant because the client can skip both shaping and extra sorting work. + +#### Add SSR later when first paint matters + +Hydration layers on top of the same model-first Vue story. The client still +calls `useFileTree(...)`, and the Vue wrapper still renders the same model. The +only difference is that the tree starts from preloaded server output. + +When you need that flow, continue with [SSR](#ssr) and [SSR API](#ssr-api). diff --git a/apps/docs/app/(trees)/docs/Guides/HandleLargeTreesEfficiently/content.mdx b/apps/docs/app/(trees)/docs/Guides/HandleLargeTreesEfficiently/content.mdx index 33a7da86c..0850f896f 100644 --- a/apps/docs/app/(trees)/docs/Guides/HandleLargeTreesEfficiently/content.mdx +++ b/apps/docs/app/(trees)/docs/Guides/HandleLargeTreesEfficiently/content.mdx @@ -24,7 +24,7 @@ scale-oriented public path. If you only have an unsorted path list, use `prepareFileTreeInput(...)` instead. -Either way, pass the prepared result into React, vanilla, or SSR hydration. +Either way, pass the prepared result into React, Vue, vanilla, or SSR hydration. #### Keep rendering work small diff --git a/apps/docs/app/(trees)/docs/Guides/NavigateSelectionFocusAndSearch/content.mdx b/apps/docs/app/(trees)/docs/Guides/NavigateSelectionFocusAndSearch/content.mdx index 227a3a321..2457ace65 100644 --- a/apps/docs/app/(trees)/docs/Guides/NavigateSelectionFocusAndSearch/content.mdx +++ b/apps/docs/app/(trees)/docs/Guides/NavigateSelectionFocusAndSearch/content.mdx @@ -69,8 +69,8 @@ The DOM host is not the source of truth. The model is. #### What to read next -- For runtime-specific lookup, jump to [React API](#react-api) or - [Vanilla API](#vanilla-api). +- For runtime-specific lookup, jump to [React API](#react-api), + [Vue API](#vue-api), or [Vanilla API](#vanilla-api). - For shared search-mode semantics, read [Shared concepts](#shared-concepts-search-mode-semantics). - For row-level editing workflows that build on this same focus model, continue diff --git a/apps/docs/app/(trees)/docs/Guides/RenameDragAndTriggerItemActions/content.mdx b/apps/docs/app/(trees)/docs/Guides/RenameDragAndTriggerItemActions/content.mdx index 222d8df24..05bf99212 100644 --- a/apps/docs/app/(trees)/docs/Guides/RenameDragAndTriggerItemActions/content.mdx +++ b/apps/docs/app/(trees)/docs/Guides/RenameDragAndTriggerItemActions/content.mdx @@ -83,4 +83,5 @@ separation clear. If you need the shared vocabulary behind those events, read [Shared concepts](#shared-concepts-mutation-vocabulary). For runtime lookup, -jump to [React API](#react-api) or [Vanilla API](#vanilla-api). +jump to [React API](#react-api), [Vue API](#vue-api), or +[Vanilla API](#vanilla-api). diff --git a/apps/docs/app/(trees)/docs/Guides/SSR/constants.ts b/apps/docs/app/(trees)/docs/Guides/SSR/constants.ts index 58957ca8a..7f8f91928 100644 --- a/apps/docs/app/(trees)/docs/Guides/SSR/constants.ts +++ b/apps/docs/app/(trees)/docs/Guides/SSR/constants.ts @@ -38,6 +38,32 @@ export function ProjectTreeClient({ }` ); +export const SSR_GUIDE_VUE_HYDRATION = docsCodeSnippet( + 'project-tree-client.vue', + ` + +` +); + export const SSR_GUIDE_VANILLA_HYDRATION = docsCodeSnippet( 'vanilla-hydrate.ts', `import { FileTree } from '@pierre/trees'; diff --git a/apps/docs/app/(trees)/docs/Guides/SSR/content.mdx b/apps/docs/app/(trees)/docs/Guides/SSR/content.mdx index 3c4faa5a9..ceaccf6b6 100644 --- a/apps/docs/app/(trees)/docs/Guides/SSR/content.mdx +++ b/apps/docs/app/(trees)/docs/Guides/SSR/content.mdx @@ -2,7 +2,8 @@ Use SSR when you want the tree to arrive from the server with a fast first paint, then become interactive on the client. This is not a third primary -runtime. It is a preload-and-hydrate layer over the same React or vanilla model. +runtime. It is a preload-and-hydrate layer over the same React, Vue, or vanilla +model. #### Start with the server step @@ -36,6 +37,15 @@ the wrapper receives the opaque handoff object as `preloadedData`. The model stays primary. `preloadedData` only activates hydration. +#### Vue flow + +Vue still creates the model with `useFileTree(...)`. The difference is that the +component receives the opaque handoff object as `preloadedData`. + + + +The model stays primary. `preloadedData` only activates hydration. + #### Vanilla flow Vanilla uses the same preload step, but the client hydrates the server-rendered @@ -49,9 +59,9 @@ hydrating. #### Keep the payload opaque In docs and application code, refer to the preload result as an SSR payload or -handoff object. React consumes it as `preloadedData`. Vanilla hydrates existing -server markup. Both flows reuse the same server work without teaching payload -internals as the main story. +handoff object. React and Vue consume it as `preloadedData`. Vanilla hydrates +existing server markup. Every flow reuses the same server work without teaching +payload internals as the main story. #### Pair SSR with prepared input for large trees @@ -61,8 +71,8 @@ client hydrate that same result. #### Advanced note: declarative shadow DOM -The preloaded path uses declarative shadow DOM under the hood. In React, the -packaged wrapper already handles the host ownership details it needs for +The preloaded path uses declarative shadow DOM under the hood. In React and Vue, +the packaged wrappers already handle the host ownership details they need for hydration. Use the runtime behavior instead of inventing custom DOM diffing or raw payload plumbing. diff --git a/apps/docs/app/(trees)/docs/Guides/ShapeTreeDataForFastRendering/constants.ts b/apps/docs/app/(trees)/docs/Guides/ShapeTreeDataForFastRendering/constants.ts index 795913767..dd6791dea 100644 --- a/apps/docs/app/(trees)/docs/Guides/ShapeTreeDataForFastRendering/constants.ts +++ b/apps/docs/app/(trees)/docs/Guides/ShapeTreeDataForFastRendering/constants.ts @@ -28,6 +28,24 @@ export function ReactTree({ }` ); +export const SHAPE_TREE_DATA_VUE_TREE = docsCodeSnippet( + 'vue-tree.vue', + ` + +` +); + export const SHAPE_TREE_DATA_VANILLA_MOUNT = docsCodeSnippet( 'mount-vanilla-tree.ts', `import { FileTree, type FileTreePreparedInput } from '@pierre/trees'; diff --git a/apps/docs/app/(trees)/docs/Guides/ShapeTreeDataForFastRendering/content.mdx b/apps/docs/app/(trees)/docs/Guides/ShapeTreeDataForFastRendering/content.mdx index e97802963..f93f536bb 100644 --- a/apps/docs/app/(trees)/docs/Guides/ShapeTreeDataForFastRendering/content.mdx +++ b/apps/docs/app/(trees)/docs/Guides/ShapeTreeDataForFastRendering/content.mdx @@ -17,10 +17,12 @@ preparation step moves earlier. #### Pass `preparedInput` into the runtime -Both primary runtimes consume the same prepared payload shape. +All primary runtimes consume the same prepared payload shape. + + #### Use simple `paths` input only for small trees @@ -51,7 +53,7 @@ The recommended split is simple: 1. Load canonical paths outside the UI. 2. Prepare the input once. -3. Pass `preparedInput` into React, vanilla, or SSR hydration. +3. Pass `preparedInput` into React, Vue, vanilla, or SSR hydration. That reduces client CPU work, makes startup cost more predictable, and lets the same prepared payload feed every runtime. diff --git a/apps/docs/app/(trees)/docs/Guides/StyleAndThemeTheTree/content.mdx b/apps/docs/app/(trees)/docs/Guides/StyleAndThemeTheTree/content.mdx index f324eac88..f95a6b57d 100644 --- a/apps/docs/app/(trees)/docs/Guides/StyleAndThemeTheTree/content.mdx +++ b/apps/docs/app/(trees)/docs/Guides/StyleAndThemeTheTree/content.mdx @@ -52,7 +52,7 @@ Pass `density` to `useFileTree` (or `preloadFileTree`, or the vanilla `FileTree` constructor) to bundle row height and spacing into one option. The keyword form (`'compact'`, `'default'`, `'relaxed'`) resolves both at once; a numeric form keeps the default row height and supplies a custom spacing factor. Every runtime -— vanilla CSR, vanilla SSR, React CSR, and React SSR — paints +— vanilla CSR, vanilla SSR, React CSR, React SSR, Vue CSR, and Vue SSR — paints `--trees-item-height` and `--trees-density-override` onto the host from the resolved density so the virtualized and painted row heights stay aligned. Caller-set inline values on the host still win, so you can override either diff --git a/apps/docs/app/(trees)/docs/Overview/content.mdx b/apps/docs/app/(trees)/docs/Overview/content.mdx index fb05889fa..06e79cf45 100644 --- a/apps/docs/app/(trees)/docs/Overview/content.mdx +++ b/apps/docs/app/(trees)/docs/Overview/content.mdx @@ -1,13 +1,13 @@ ## Overview }> - **Trees is in beta.** Start from the public React, vanilla, and SSR entry + **Trees is in beta.** Start from the public React, Vue, vanilla, and SSR entry points on this page. Expect polish and small API shifts between beta releases. -**Trees** keeps one path-first model across React, vanilla, and SSR hydration. -Selection, focus, search, rename, drag and drop, Git status, and row annotations -all work in terms of canonical paths. +**Trees** keeps one path-first model across React, Vue, vanilla, and SSR +hydration. Selection, focus, search, rename, drag and drop, Git status, and row +annotations all work in terms of canonical paths. `. For the full runtime surface, read [React API](#react-api). For the workflow guide, read [SSR](#ssr). +#### Vue handoff + +Vue still creates the model with `useFileTree(...)`, then passes the payload to +``. + +For the full runtime surface, read [Vue API](#vue-api). For the workflow guide, +read [SSR](#ssr). + #### Vanilla handoff Vanilla emits the server-rendered tree into the page first, creates diff --git a/apps/docs/app/(trees)/docs/Reference/SharedConcepts/content.mdx b/apps/docs/app/(trees)/docs/Reference/SharedConcepts/content.mdx index b4aa9c94e..586e7c60c 100644 --- a/apps/docs/app/(trees)/docs/Reference/SharedConcepts/content.mdx +++ b/apps/docs/app/(trees)/docs/Reference/SharedConcepts/content.mdx @@ -1,8 +1,8 @@ ### Shared concepts -This page owns the cross-runtime language for Trees. React, vanilla, and SSR all -use the same path-first identity model, the same tree-defining inputs, and the -same mutation vocabulary. +This page owns the cross-runtime language for Trees. React, Vue, vanilla, and +SSR all use the same path-first identity model, the same tree-defining inputs, +and the same mutation vocabulary. #### Path-first identity @@ -89,8 +89,8 @@ Trees supports three search modes: - `expand-matches`: expands matching branches into surrounding tree context and keeps non-matching rows visible. -React and vanilla use the same search modes. Runtime pages should not redefine -them. +React, Vue, and vanilla use the same search modes. Runtime pages should not +redefine them. #### Selection, focus, and item handles @@ -106,14 +106,14 @@ Common lookup topics are: Use [Navigate selection, focus, and search](#navigate-selection-focus-and-search) -for workflows, [React API](#react-api) for selector hooks, and -[Vanilla API](#vanilla-api) for direct class methods. +for workflows, [React API](#react-api) and [Vue API](#vue-api) for reactive +selector helpers, and [Vanilla API](#vanilla-api) for direct class methods. #### Interaction and editing options `dragAndDrop`, `renaming`, and `composition` are the runtime-agnostic editing -surfaces. They share the same path-first event model even though the React and -vanilla integration points differ. +surfaces. They share the same path-first event model even though each runtime's +integration points differ. Use [Rename, drag, and trigger item actions](#rename-drag-and-trigger-item-actions) @@ -141,7 +141,7 @@ virtualized row window. For density, prefer the `density` keyword (`'compact' | 'default' | 'relaxed'`) or a numeric factor — it resolves both row height and spacing in one place, and -the React `` wrapper paints `--trees-item-height` and +the React and Vue `` wrappers paint `--trees-item-height` and `--trees-density-override` for you. Reach for `itemHeight` only when you need a row height that doesn't match a preset. @@ -171,8 +171,8 @@ turn them into a filesystem-sync tutorial. Server preload returns one handoff object. Pass it forward unchanged. -React consumes that handoff as `preloadedData`. Vanilla hydrates existing server -markup through `hydrate({ fileTreeContainer })`. Neither runtime should teach -field-by-field payload choreography as the main story. +React and Vue consume that handoff as `preloadedData`. Vanilla hydrates existing +server markup through `hydrate({ fileTreeContainer })`. Runtime pages should not +teach field-by-field payload choreography as the main story. For the preload and hydration contract, read [SSR API](#ssr-api). diff --git a/apps/docs/app/(trees)/docs/Reference/StylingAndTheming/content.mdx b/apps/docs/app/(trees)/docs/Reference/StylingAndTheming/content.mdx index c38ad1cf9..ae19f844c 100644 --- a/apps/docs/app/(trees)/docs/Reference/StylingAndTheming/content.mdx +++ b/apps/docs/app/(trees)/docs/Reference/StylingAndTheming/content.mdx @@ -9,6 +9,8 @@ Host styling comes first. - React: use normal host `className` and `style` props on `` +- Vue: use normal host `class` and `style` attributes on + `` - Vanilla: style the mounted host returned by `getFileTreeContainer()` Host styles own width, height, borders, background, and panel placement. CSS @@ -51,7 +53,8 @@ Input shape: Output shape: - `TreeThemeStyles` -- compatible with React inline styles and vanilla host-style assignment +- compatible with React inline styles, Vue style bindings, and vanilla + host-style assignment @@ -65,6 +68,12 @@ React: - styling stays on the host element through normal `className` and `style` props - there is no separate React-only styling API beyond shared tree options +Vue: + +- styling stays on the host element through normal `class` and `style` + attributes +- there is no separate Vue-only styling API beyond shared tree options + Vanilla: - apply styles through the host or container you already own diff --git a/apps/docs/app/(trees)/docs/Reference/VueAPI/constants.ts b/apps/docs/app/(trees)/docs/Reference/VueAPI/constants.ts new file mode 100644 index 000000000..0f679af1a --- /dev/null +++ b/apps/docs/app/(trees)/docs/Reference/VueAPI/constants.ts @@ -0,0 +1,25 @@ +import { docsCodeSnippet } from '@/lib/docsCodeSnippet'; + +export const VUE_API_EXAMPLE = docsCodeSnippet( + 'project-tree.vue', + ` + +` +); + +export const VUE_API_SELECTOR_COMPOSABLES = docsCodeSnippet( + 'selector-composables.ts', + `const { model } = useFileTree({ paths, search: true }); +const selectedPaths = useFileTreeSelection(model); +const search = useFileTreeSearch(model); +const focusedPath = useFileTreeSelector(model, (currentModel) => + currentModel.getFocusedPath() +);` +); diff --git a/apps/docs/app/(trees)/docs/Reference/VueAPI/content.mdx b/apps/docs/app/(trees)/docs/Reference/VueAPI/content.mdx new file mode 100644 index 000000000..b93feea54 --- /dev/null +++ b/apps/docs/app/(trees)/docs/Reference/VueAPI/content.mdx @@ -0,0 +1,132 @@ +### Vue API + +The Vue entry point lives in `@pierre/trees/vue`. It is a thin Vue integration +layer over the same imperative model the vanilla runtime uses. + +#### Vue model-first story + +Start with `useFileTree(options)`, not with a giant controlled component. The +composable creates one stable model for the component lifetime. Later option +changes are not a controlled reconfiguration path. Update the model through +model methods instead. + + + +For the shared option meanings behind that call, read +[Shared concepts](#shared-concepts-shared-option-groups). + +#### `useFileTree(...)` + +`useFileTree(...)`: + +- accepts the shared tree options surface +- creates the model exactly once +- cleans the model up when the Vue owner unmounts +- returns `UseFileTreeResult`, which currently exposes `model` + +Keep using model methods such as `resetPaths(...)`, `setComposition(...)`, +`setGitStatus(...)`, `setIcons(...)`, and the search or mutation methods when +the tree changes after mount. + +#### `` + +The Vue component mounts the model into a host element. + +Primary props: + +- `model` +- normal host attributes such as `class`, `style`, and `id` + +Vue-only props and slots: + +- `preloadedData` +- `#header` +- `#context-menu="{ item, context }"` + +Behavior notes: + +- it renders the model into the host element +- it hydrates instead of rendering fresh when matching preloaded content is + already present +- it uses `id ?? preloadedData?.id` for host identity when hydration is involved +- `#header` and `#context-menu` layer Vue rendering onto the model's composition + surface + +#### Reading tree state from Vue + +Vue components read from the model through composables and write back through +model methods. + +Common read patterns include: + +- selected paths for sibling panels or action bars +- search state for search inputs and result counts +- custom derived snapshots such as "is this path focused?" + +#### Selector composables + +`useFileTreeSelector(model, selector, equality?)` + +- generic bridge from the imperative model into Vue +- returns a `ShallowRef` selected from the current model +- accepts an optional equality function when the selected value needs custom + comparison + +`useFileTreeSelection(model)` + +- convenience composable for a `ShallowRef` of selected paths + +`useFileTreeSearch(model)` + +- returns computed search refs plus model-backed actions +- refs: `isOpen`, `matchingPaths`, `value` +- actions: `open(initialValue?)`, `close()`, `setValue(value)`, + `focusNextMatch()`, `focusPreviousMatch()` + + + +#### Writing to the model from Vue + +Vue writes through the same imperative model methods the vanilla runtime +exposes. + +Typical write paths are: + +- focus and selection methods on the model or item handles +- search methods such as `setSearch(...)` and `openSearch(...)` +- mutation methods such as `add(...)`, `remove(...)`, `move(...)`, `batch(...)`, + and `resetPaths(...)` +- runtime reconfiguration methods such as `setComposition(...)`, + `setGitStatus(...)`, and `setIcons(...)` + +#### Vue-specific composition surface + +Vue adds two high-level slots on top of the model: + +- `#header` for Vue-rendered header content +- `#context-menu="{ item, context }"` for Vue-rendered context menus + +Use them when the menu or header belongs in Vue. The shared meaning of the +underlying composition options still lives in +[Shared concepts](#shared-concepts-interaction-and-editing-options). + +#### Mutation and subscriptions from Vue + +Use composables when Vue UI needs reactive reads. Use `model.onMutation(...)` +when you need semantic side effects such as persistence, logging, or analytics +around add, remove, move, reset, or batch operations. + +#### Hydration boundary + +The Vue runtime consumes server preload through `preloadedData` on +``. The model still comes from `useFileTree(...)`, and the client +must use matching tree-defining options. + +For the preload contract, read [SSR API](#ssr-api). For workflow guidance, read +[SSR](#ssr). + +#### Appearance boundary + +Style the Vue host with normal host attributes such as `class` and `style`. For +deeper lookup, use [Styling and theming](#styling-and-theming) and +[Icons](#icons). diff --git a/apps/docs/lib/product-config.ts b/apps/docs/lib/product-config.ts index 3825d04bf..3f888a676 100644 --- a/apps/docs/lib/product-config.ts +++ b/apps/docs/lib/product-config.ts @@ -40,7 +40,7 @@ export const PRODUCTS: Record = { description: "@pierre/trees is an open source file tree rendering library. It's built for performance and flexibility, is super customizable, and comes packed with features.", llmsDescription: - 'An open source file tree rendering library for the web. Built for extreme performance on large trees, with React and vanilla JS APIs, SSR support, and customizable styling.', + 'An open source file tree rendering library for the web. Built for extreme performance on large trees, with React, Vue, and vanilla JS APIs, SSR support, and customizable styling.', basePath: '', docsPath: '/docs', packageName: '@pierre/trees', diff --git a/apps/docs/scripts/generate-llms-txt.ts b/apps/docs/scripts/generate-llms-txt.ts index 364949c2a..bfd56e8ee 100644 --- a/apps/docs/scripts/generate-llms-txt.ts +++ b/apps/docs/scripts/generate-llms-txt.ts @@ -54,6 +54,7 @@ const DIFFS_SECTIONS = [ const TREES_SECTIONS = [ 'Guides/ChooseYourIntegration', 'Guides/GetStartedWithReact', + 'Guides/GetStartedWithVue', 'Guides/GetStartedWithVanilla', 'Guides/ShapeTreeDataForFastRendering', 'Guides/NavigateSelectionFocusAndSearch', @@ -65,6 +66,7 @@ const TREES_SECTIONS = [ 'Guides/SSR', 'Reference/SharedConcepts', 'Reference/ReactAPI', + 'Reference/VueAPI', 'Reference/VanillaAPI', 'Reference/SSRAPI', 'Reference/StylingAndTheming', @@ -95,9 +97,11 @@ const SECTION_DESCRIPTIONS: Record> = { }, trees: { 'Guides/ChooseYourIntegration': - 'Choosing between React and vanilla, with the shared path-first model', + 'Choosing between React, Vue, and vanilla, with the shared path-first model', 'Guides/GetStartedWithReact': 'React quickstart with useFileTree, FileTree, selectors, and prepared input', + 'Guides/GetStartedWithVue': + 'Vue quickstart with useFileTree, FileTree, selectors, and prepared input', 'Guides/GetStartedWithVanilla': 'Vanilla quickstart with new FileTree, render, model methods, and prepared input', 'Guides/ShapeTreeDataForFastRendering': @@ -115,11 +119,13 @@ const SECTION_DESCRIPTIONS: Record> = { 'Guides/HandleLargeTreesEfficiently': 'Prepared input, virtualization settings, and SSR guidance for large trees', 'Guides/SSR': - 'Server preload, React and vanilla hydration, and opaque SSR handoff guidance', + 'Server preload, React, Vue, and vanilla hydration, and opaque SSR handoff guidance', 'Reference/SharedConcepts': 'Path-first identity, shared options, search modes, mutation vocabulary, and SSR framing', 'Reference/ReactAPI': 'useFileTree, FileTree, selector hooks, and React-specific composition lookup', + 'Reference/VueAPI': + 'useFileTree, FileTree, selector composables, and Vue-specific slots', 'Reference/VanillaAPI': 'FileTree construction, lifecycle, imperative methods, and subscriptions', 'Reference/SSRAPI': @@ -178,6 +184,7 @@ function extToLang(filename: string): string { const map: Record = { '.ts': 'typescript', '.tsx': 'tsx', + '.vue': 'vue', '.js': 'javascript', '.jsx': 'jsx', '.css': 'css', diff --git a/bun.lock b/bun.lock index 8811217ce..f420c75e7 100644 --- a/bun.lock +++ b/bun.lock @@ -104,7 +104,7 @@ }, "packages/diffs": { "name": "@pierre/diffs", - "version": "1.1.19", + "version": "1.1.20", "dependencies": { "@pierre/theme": "catalog:", "@shikijs/transformers": "^3.0.0", @@ -209,6 +209,8 @@ "@types/jsdom": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@vitejs/plugin-vue": "catalog:", + "@vue/server-renderer": "catalog:", "autoprefixer": "catalog:", "jsdom": "catalog:", "postcss": "catalog:", @@ -217,11 +219,16 @@ "tsdown": "catalog:", "typescript": "catalog:", "vite": "catalog:", + "vue": "catalog:", }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", + "vue": "^3.5.0", }, + "optionalPeers": [ + "vue", + ], }, "packages/truncate": { "name": "@pierre/truncate", @@ -290,7 +297,9 @@ "@types/react-dom": "19.2.3", "@typescript/native-preview": "7.0.0-dev.20260128.1", "@vitejs/plugin-react": "5.0.3", + "@vitejs/plugin-vue": "6.0.6", "@vscode/web-custom-data": "0.6.3", + "@vue/server-renderer": "3.5.33", "autoprefixer": "10.4.22", "babel-plugin-react-compiler": "1.0.0", "baseline-browser-mapping": "^2.9.14", @@ -338,6 +347,7 @@ "typescript": "5.9.2", "unist-util-visit": "5.0.0", "vite": "npm:rolldown-vite@7.1.12", + "vue": "3.5.33", "zod": "4.1.11", }, "packages": { @@ -889,8 +899,28 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.0.3", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.35", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-PFVHhosKkofGH0Yzrw1BipSedTH68BFF8ZWy1kfUpCtJcouXXY0+racG8sExw7hw0HoX36813ga5o3LTWZ4FUg=="], + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.6", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.13" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg=="], + "@vscode/web-custom-data": ["@vscode/web-custom-data@0.6.3", "", {}, "sha512-3pDUAPGVkra1KjR2L5m3b7BgzLTlWdep4ijsRoqeLcrp+e7cJcyjnae8IkAdF/xS6Zo3B1YZSmIBIhRAEYBIog=="], + "@vue/compiler-core": ["@vue/compiler-core@3.5.33", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/shared": "3.5.33", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.33", "", { "dependencies": { "@vue/compiler-core": "3.5.33", "@vue/shared": "3.5.33" } }, "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA=="], + + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.33", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/compiler-core": "3.5.33", "@vue/compiler-dom": "3.5.33", "@vue/compiler-ssr": "3.5.33", "@vue/shared": "3.5.33", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.10", "source-map-js": "^1.2.1" } }, "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA=="], + + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.33", "", { "dependencies": { "@vue/compiler-dom": "3.5.33", "@vue/shared": "3.5.33" } }, "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A=="], + + "@vue/reactivity": ["@vue/reactivity@3.5.33", "", { "dependencies": { "@vue/shared": "3.5.33" } }, "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A=="], + + "@vue/runtime-core": ["@vue/runtime-core@3.5.33", "", { "dependencies": { "@vue/reactivity": "3.5.33", "@vue/shared": "3.5.33" } }, "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ=="], + + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.33", "", { "dependencies": { "@vue/reactivity": "3.5.33", "@vue/runtime-core": "3.5.33", "@vue/shared": "3.5.33", "csstype": "^3.2.3" } }, "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw=="], + + "@vue/server-renderer": ["@vue/server-renderer@3.5.33", "", { "dependencies": { "@vue/compiler-ssr": "3.5.33", "@vue/shared": "3.5.33" }, "peerDependencies": { "vue": "3.5.33" } }, "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw=="], + + "@vue/shared": ["@vue/shared@3.5.33", "", {}, "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -1995,6 +2025,8 @@ "vite": ["rolldown-vite@7.1.12", "", { "dependencies": { "@oxc-project/runtime": "0.90.0", "fdir": "^6.5.0", "lightningcss": "^1.30.1", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rolldown": "1.0.0-beta.39", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "esbuild": "^0.25.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-JREtUS+Lpa3s5Ha3ajf2F4LMS4BFxlVjpGz0k0ZR8rV3ZO3tzk5hukqyi9yRBcrvnTUg/BEForyCDahALFYAZA=="], + "vue": ["vue@3.5.33", "", { "dependencies": { "@vue/compiler-dom": "3.5.33", "@vue/compiler-sfc": "3.5.33", "@vue/runtime-dom": "3.5.33", "@vue/server-renderer": "3.5.33", "@vue/shared": "3.5.33" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], @@ -2081,6 +2113,20 @@ "@types/jsdom/undici-types": ["undici-types@7.24.7", "", {}, "sha512-XA+gOBkzYD3C74sZowtCLTpgtaCdqZhqCvR6y9LXvrKTt/IVU6bz49T4D+BPi475scshCCkb0IklJRw6T1ZlgQ=="], + "@vitejs/plugin-vue/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.13", "", {}, "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA=="], + + "@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "@vue/compiler-sfc/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "@vue/compiler-sfc/postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="], + "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "brace-expansion/balanced-match": ["balanced-match@4.0.3", "", {}, "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g=="], diff --git a/package.json b/package.json index 0b891242e..ead1c6f59 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "@typescript/native-preview": "7.0.0-dev.20260128.1", "@vscode/web-custom-data": "0.6.3", "@vitejs/plugin-react": "5.0.3", + "@vitejs/plugin-vue": "6.0.6", + "@vue/server-renderer": "3.5.33", "autoprefixer": "10.4.22", "babel-plugin-react-compiler": "1.0.0", "baseline-browser-mapping": "^2.9.14", @@ -93,6 +95,7 @@ "typescript": "5.9.2", "unist-util-visit": "5.0.0", "vite": "npm:rolldown-vite@7.1.12", + "vue": "3.5.33", "zod": "4.1.11" } }, diff --git a/packages/trees/PUBLISHING.md b/packages/trees/PUBLISHING.md index 92f8b003e..3e9ee4e9d 100644 --- a/packages/trees/PUBLISHING.md +++ b/packages/trees/PUBLISHING.md @@ -62,6 +62,7 @@ not mask packaging bugs. Do this against a beta publish (step 4 with - React `18.3.1` - React `19` +- Vue `3.5` In each: @@ -71,7 +72,7 @@ In each: 4. Run a production build. 5. Render a simple tree in a real browser. 6. Exercise each subpath: `@pierre/trees`, `@pierre/trees/react`, - `@pierre/trees/ssr`, `@pierre/trees/web-components`. + `@pierre/trees/vue`, `@pierre/trees/ssr`, `@pierre/trees/web-components`. **Bun note.** Bun's `minimum-release-age` protection can block fresh installs right after a publish. Use: diff --git a/packages/trees/README.md b/packages/trees/README.md index 4225e3c24..29d297bb8 100644 --- a/packages/trees/README.md +++ b/packages/trees/README.md @@ -2,11 +2,12 @@ Path-first file tree UI for the web. -`@pierre/trees` ships one implementation through four public entry points: +`@pierre/trees` ships one implementation through five public entry points: - `@pierre/trees` — vanilla model, mounting API, prepared input helpers, icons, theming, and core types - `@pierre/trees/react` — React hooks and `` +- `@pierre/trees/vue` — Vue 3 component and composables - `@pierre/trees/ssr` — preload helpers for declarative-shadow-DOM SSR - `@pierre/trees/web-components` — custom-element registration side effect @@ -91,6 +92,35 @@ export function Example({ paths }: { paths: string[] }) { `@pierre/trees/react` exports `FileTree`, `useFileTree`, `useFileTreeSearch`, `useFileTreeSelection`, and `useFileTreeSelector`. +## Vue usage + +```vue + + + +``` + +`@pierre/trees/vue` exports `FileTree`, `useFileTree`, `useFileTreeSearch`, +`useFileTreeSelection`, and `useFileTreeSelector`. The composables return Vue +refs for reactive state while tree actions stay on the `FileTree` instance. + ## SSR ```tsx diff --git a/packages/trees/package.json b/packages/trees/package.json index 6a298b157..85138c1ef 100644 --- a/packages/trees/package.json +++ b/packages/trees/package.json @@ -20,6 +20,9 @@ "react": [ "dist/react/index.d.ts" ], + "vue": [ + "dist/vue/index.d.ts" + ], "ssr": [ "dist/ssr/index.d.ts" ], @@ -41,6 +44,10 @@ "types": "./dist/react/index.d.ts", "import": "./dist/react/index.js" }, + "./vue": { + "types": "./dist/vue/index.d.ts", + "import": "./dist/vue/index.js" + }, "./ssr": { "types": "./dist/ssr/index.d.ts", "import": "./dist/ssr/index.js" @@ -83,6 +90,8 @@ "@types/jsdom": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@vitejs/plugin-vue": "catalog:", + "@vue/server-renderer": "catalog:", "autoprefixer": "catalog:", "jsdom": "catalog:", "postcss": "catalog:", @@ -90,10 +99,23 @@ "react-dom": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "vue": "catalog:" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", - "react-dom": "^18.3.1 || ^19.0.0" + "react-dom": "^18.3.1 || ^19.0.0", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "vue": { + "optional": true + } } } diff --git a/packages/trees/src/vue/FileTree.ts b/packages/trees/src/vue/FileTree.ts new file mode 100644 index 000000000..ffaaa3cf2 --- /dev/null +++ b/packages/trees/src/vue/FileTree.ts @@ -0,0 +1,269 @@ +import { + type CSSProperties, + defineComponent, + h, + type PropType, + toRaw, + type VNodeChild, + type VNodeRef, +} from 'vue'; + +import { + CONTEXT_MENU_SLOT_NAME, + FILE_TREE_TAG_NAME, + HEADER_SLOT_NAME, +} from '../constants'; +import type { + FileTreeCompositionOptions, + FileTreeContextMenuItem, + FileTreeContextMenuOpenContext, + FileTreeSsrPayload, +} from '../model/publicTypes'; +import type { FileTree as FileTreeModel } from '../render/FileTree'; + +interface ActiveContextMenuState { + context: FileTreeContextMenuOpenContext; + item: FileTreeContextMenuItem; +} + +export type FileTreePreloadedData = Pick< + FileTreeSsrPayload, + 'id' | 'shadowHtml' +>; + +function hasExistingPreloadedContent(host: HTMLElement): boolean { + const shadowRoot = host.shadowRoot; + if ( + shadowRoot?.querySelector('[data-file-tree-id]') instanceof HTMLElement || + shadowRoot?.querySelector('[data-file-tree-id]') instanceof SVGElement + ) { + return true; + } + + return ( + host.querySelector('template[shadowrootmode="open"]') instanceof + HTMLTemplateElement + ); +} + +function resolveComposition( + baselineComposition: FileTreeCompositionOptions | undefined, + hasHeader: boolean, + hasContextMenu: boolean, + onClose: () => void, + onOpen: ( + item: FileTreeContextMenuItem, + context: FileTreeContextMenuOpenContext + ) => void +): FileTreeCompositionOptions | undefined { + const nextComposition: FileTreeCompositionOptions = { + ...(baselineComposition ?? {}), + }; + + if (hasHeader) { + delete nextComposition.header; + } + + if (hasContextMenu) { + const baselineContextMenu = baselineComposition?.contextMenu; + const baselineOnClose = baselineContextMenu?.onClose; + const baselineOnOpen = baselineContextMenu?.onOpen; + + nextComposition.contextMenu = { + ...(baselineContextMenu ?? {}), + enabled: true, + onClose: () => { + baselineOnClose?.(); + onClose(); + }, + onOpen: (item, context) => { + onOpen(item, context); + baselineOnOpen?.(item, context); + }, + }; + delete nextComposition.contextMenu.render; + } + + return nextComposition.header != null || nextComposition.contextMenu != null + ? nextComposition + : undefined; +} + +export const FileTree = defineComponent({ + name: 'FileTree', + inheritAttrs: false, + props: { + id: { + required: false, + type: String, + }, + model: { + required: true, + type: Object as PropType, + }, + preloadedData: { + required: false, + type: Object as PropType, + }, + }, + data(): { + activeContextMenu: ActiveContextMenuState | null; + baselineComposition: FileTreeCompositionOptions | undefined; + hostElement: HTMLElement | null; + shouldRenderPreloadedTemplate: boolean; + } { + return { + activeContextMenu: null, + baselineComposition: toRaw(this.model).getComposition(), + hostElement: null, + shouldRenderPreloadedTemplate: this.preloadedData != null, + }; + }, + computed: { + hasContextMenuSlot(): boolean { + return this.$slots['context-menu'] != null; + }, + hasHeaderSlot(): boolean { + return this.$slots.header != null; + }, + resolvedComposition(): FileTreeCompositionOptions | undefined { + return resolveComposition( + this.baselineComposition, + this.hasHeaderSlot, + this.hasContextMenuSlot, + this.handleContextMenuClose, + this.handleContextMenuOpen + ); + }, + resolvedHostId(): string | undefined { + return this.id ?? this.preloadedData?.id; + }, + rawModel(): FileTreeModel { + return toRaw(this.model); + }, + }, + watch: { + model( + nextModel: FileTreeModel, + previousModel: FileTreeModel | undefined + ): void { + const rawPreviousModel = + previousModel == null ? undefined : toRaw(previousModel); + const rawNextModel = toRaw(nextModel); + rawPreviousModel?.unmount(); + rawPreviousModel?.setComposition(this.baselineComposition); + this.baselineComposition = rawNextModel.getComposition(); + this.activeContextMenu = null; + this.syncComposition(); + this.mountModel(); + }, + preloadedData(): void { + this.mountModel(); + }, + resolvedComposition(): void { + this.syncComposition(); + }, + hasContextMenuSlot(nextHasContextMenuSlot: boolean): void { + if (!nextHasContextMenuSlot) { + this.activeContextMenu = null; + } + }, + }, + mounted(): void { + this.syncComposition(); + this.mountModel(); + this.shouldRenderPreloadedTemplate = false; + }, + updated(): void { + this.syncComposition(); + }, + beforeUnmount(): void { + this.rawModel.unmount(); + this.rawModel.setComposition(this.baselineComposition); + }, + methods: { + handleContextMenuClose(): void { + this.activeContextMenu = null; + }, + handleContextMenuOpen( + item: FileTreeContextMenuItem, + context: FileTreeContextMenuOpenContext + ): void { + this.activeContextMenu = { context, item }; + }, + mountModel(): void { + const hostElement = this.hostElement; + if (hostElement == null) { + return; + } + + if ( + this.preloadedData != null && + hasExistingPreloadedContent(hostElement) + ) { + this.rawModel.hydrate({ fileTreeContainer: hostElement }); + } else { + this.rawModel.render({ fileTreeContainer: hostElement }); + } + }, + setHostElement(node: Element | null): void { + this.hostElement = node instanceof HTMLElement ? node : null; + }, + syncComposition(): void { + this.rawModel.setComposition(this.resolvedComposition); + }, + }, + render(): VNodeChild { + const attrs = { ...this.$attrs }; + const callerStyle = attrs.style; + delete attrs.style; + + const children: VNodeChild[] = []; + if (this.shouldRenderPreloadedTemplate && this.preloadedData != null) { + children.push( + h('template', { + innerHTML: this.preloadedData.shadowHtml, + shadowrootmode: 'open', + }) + ); + } + + const header = this.$slots.header?.(); + if (header != null) { + children.push(h('div', { slot: HEADER_SLOT_NAME }, header)); + } + + const contextMenu = this.activeContextMenu; + const contextMenuSlot = this.$slots['context-menu']; + if (contextMenu != null && contextMenuSlot != null) { + children.push( + h( + 'div', + { slot: CONTEXT_MENU_SLOT_NAME }, + contextMenuSlot({ + context: contextMenu.context, + item: contextMenu.item, + }) + ) + ); + } + + const densityStyle: CSSProperties = { + '--trees-density-override': this.rawModel.getDensityFactor(), + '--trees-item-height': `${String(this.rawModel.getItemHeight())}px`, + }; + + return h( + FILE_TREE_TAG_NAME, + { + ...attrs, + 'data-allow-mismatch': + this.preloadedData == null ? undefined : 'children', + id: this.resolvedHostId, + ref: this.setHostElement as VNodeRef, + style: [densityStyle, callerStyle], + }, + children + ); + }, +}); diff --git a/packages/trees/src/vue/index.ts b/packages/trees/src/vue/index.ts new file mode 100644 index 000000000..13862f679 --- /dev/null +++ b/packages/trees/src/vue/index.ts @@ -0,0 +1,12 @@ +export { FileTree, type FileTreePreloadedData } from './FileTree'; +export { useFileTree, type UseFileTreeResult } from './useFileTree'; +export { + useFileTreeSelector, + type FileTreeSelector, + type FileTreeSelectorEquality, +} from './useFileTreeSelector'; +export { useFileTreeSelection } from './useFileTreeSelection'; +export { + useFileTreeSearch, + type FileTreeSearchState, +} from './useFileTreeSearch'; diff --git a/packages/trees/src/vue/useFileTree.ts b/packages/trees/src/vue/useFileTree.ts new file mode 100644 index 000000000..3fef28eb7 --- /dev/null +++ b/packages/trees/src/vue/useFileTree.ts @@ -0,0 +1,20 @@ +import { getCurrentScope, markRaw, onScopeDispose } from 'vue'; + +import type { FileTreeOptions } from '../model/publicTypes'; +import { FileTree } from '../render/FileTree'; + +export interface UseFileTreeResult { + model: FileTree; +} + +export function useFileTree(options: FileTreeOptions): UseFileTreeResult { + const model = markRaw(new FileTree(options)); + + if (getCurrentScope() != null) { + onScopeDispose(() => { + model.cleanUp(); + }); + } + + return { model }; +} diff --git a/packages/trees/src/vue/useFileTreeSearch.ts b/packages/trees/src/vue/useFileTreeSearch.ts new file mode 100644 index 000000000..0eb0045d0 --- /dev/null +++ b/packages/trees/src/vue/useFileTreeSearch.ts @@ -0,0 +1,70 @@ +import type { ShallowRef } from 'vue'; +import { computed, toRaw } from 'vue'; +import type { ComputedRef } from 'vue'; + +import type { FileTree } from '../render/FileTree'; +import { areArraysEqual, useFileTreeSelector } from './useFileTreeSelector'; + +interface FileTreeSearchSnapshot { + isOpen: boolean; + matchingPaths: readonly string[]; + value: string; +} + +export interface FileTreeSearchState { + close: () => void; + focusNextMatch: () => void; + focusPreviousMatch: () => void; + isOpen: ComputedRef; + matchingPaths: ComputedRef; + open: (initialValue?: string) => void; + setValue: (value: string | null) => void; + snapshot: ShallowRef; + value: ComputedRef; +} + +function areSearchSnapshotsEqual( + previous: FileTreeSearchSnapshot, + next: FileTreeSearchSnapshot +): boolean { + return ( + previous.isOpen === next.isOpen && + previous.value === next.value && + areArraysEqual(previous.matchingPaths, next.matchingPaths) + ); +} + +export function useFileTreeSearch(model: FileTree): FileTreeSearchState { + const rawModel = toRaw(model); + const snapshot = useFileTreeSelector( + rawModel, + (currentModel): FileTreeSearchSnapshot => ({ + isOpen: currentModel.isSearchOpen(), + matchingPaths: currentModel.getSearchMatchingPaths(), + value: currentModel.getSearchValue(), + }), + areSearchSnapshotsEqual + ); + + return { + close: () => { + rawModel.closeSearch(); + }, + focusNextMatch: () => { + rawModel.focusNextSearchMatch(); + }, + focusPreviousMatch: () => { + rawModel.focusPreviousSearchMatch(); + }, + isOpen: computed(() => snapshot.value.isOpen), + matchingPaths: computed(() => snapshot.value.matchingPaths), + open: (initialValue?: string) => { + rawModel.openSearch(initialValue); + }, + setValue: (value: string | null) => { + rawModel.setSearch(value); + }, + snapshot, + value: computed(() => snapshot.value.value), + }; +} diff --git a/packages/trees/src/vue/useFileTreeSelection.ts b/packages/trees/src/vue/useFileTreeSelection.ts new file mode 100644 index 000000000..5d30accfe --- /dev/null +++ b/packages/trees/src/vue/useFileTreeSelection.ts @@ -0,0 +1,14 @@ +import type { ShallowRef } from 'vue'; + +import type { FileTree } from '../render/FileTree'; +import { areArraysEqual, useFileTreeSelector } from './useFileTreeSelector'; + +export function useFileTreeSelection( + model: FileTree +): ShallowRef { + return useFileTreeSelector( + model, + (currentModel) => currentModel.getSelectedPaths(), + areArraysEqual + ); +} diff --git a/packages/trees/src/vue/useFileTreeSelector.ts b/packages/trees/src/vue/useFileTreeSelector.ts new file mode 100644 index 000000000..abaefb64c --- /dev/null +++ b/packages/trees/src/vue/useFileTreeSelector.ts @@ -0,0 +1,62 @@ +import { getCurrentScope, onScopeDispose, shallowRef, toRaw } from 'vue'; +import type { ShallowRef } from 'vue'; + +import type { FileTree } from '../render/FileTree'; + +export type FileTreeSelector = (model: FileTree) => TSelected; +export type FileTreeSelectorEquality = ( + previous: TSelected, + next: TSelected +) => boolean; + +export function areArraysEqual( + previous: readonly TValue[], + next: readonly TValue[] +): boolean { + if (previous === next) { + return true; + } + + if (previous.length !== next.length) { + return false; + } + + for (let index = 0; index < previous.length; index += 1) { + if (!Object.is(previous[index], next[index])) { + return false; + } + } + + return true; +} + +function areSelectedValuesEqual( + previous: TSelected, + next: TSelected, + isEqual?: FileTreeSelectorEquality +): boolean { + return Object.is(previous, next) || isEqual?.(previous, next) === true; +} + +export function useFileTreeSelector( + model: FileTree, + selector: FileTreeSelector, + isEqual?: FileTreeSelectorEquality +): ShallowRef { + const rawModel = toRaw(model); + const selected = shallowRef(selector(rawModel)) as ShallowRef; + const unsubscribe = rawModel.subscribe(() => { + const nextValue = selector(rawModel); + if (areSelectedValuesEqual(selected.value, nextValue, isEqual)) { + return; + } + + selected.value = nextValue; + }); + + if (getCurrentScope() != null) { + onScopeDispose(unsubscribe); + } + + return selected; +} diff --git a/packages/trees/test/e2e/file-tree-vue.pw.ts b/packages/trees/test/e2e/file-tree-vue.pw.ts new file mode 100644 index 000000000..8c6f04a31 --- /dev/null +++ b/packages/trees/test/e2e/file-tree-vue.pw.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; + +declare global { + interface Window { + __fileTreeVueFixtureReady?: boolean; + } +} + +test.describe('file-tree Vue fixture', () => { + test('renders and updates through the Vue adapter', async ({ page }) => { + await page.goto('/test/e2e/fixtures/file-tree-vue.html'); + await page.waitForFunction(() => window.__fileTreeVueFixtureReady === true); + + const tree = page.locator('file-tree-container'); + await expect( + tree.locator('button[data-item-path="src/components/FileTree.vue"]') + ).toBeVisible(); + + await page.locator('[data-file-tree-vue-header-add]').click(); + await expect( + tree.locator('button[data-item-path="src/generated-vue-file.ts"]') + ).toBeVisible(); + await expect(page.locator('[data-file-tree-vue-state]')).toContainText( + 'selected=1' + ); + + await page.locator('[data-file-tree-vue-search]').click(); + await expect(page.locator('[data-file-tree-vue-state]')).toContainText( + 'search=vue' + ); + await expect( + tree.locator('button[data-item-path="src/components/FileTree.vue"]') + ).toBeVisible(); + }); +}); diff --git a/packages/trees/test/e2e/fixtures/file-tree-vue-fixture.vue b/packages/trees/test/e2e/fixtures/file-tree-vue-fixture.vue new file mode 100644 index 000000000..e7b938135 --- /dev/null +++ b/packages/trees/test/e2e/fixtures/file-tree-vue-fixture.vue @@ -0,0 +1,97 @@ + + + diff --git a/packages/trees/test/e2e/fixtures/file-tree-vue-main.ts b/packages/trees/test/e2e/fixtures/file-tree-vue-main.ts new file mode 100644 index 000000000..16618e3ae --- /dev/null +++ b/packages/trees/test/e2e/fixtures/file-tree-vue-main.ts @@ -0,0 +1,10 @@ +import { createApp } from 'vue'; + +import FileTreeVueFixture from './file-tree-vue-fixture.vue'; + +const mount = document.querySelector('#app'); +if (!(mount instanceof HTMLElement)) { + throw new Error('Missing Vue fixture mount.'); +} + +createApp(FileTreeVueFixture).mount(mount); diff --git a/packages/trees/test/e2e/fixtures/file-tree-vue.html b/packages/trees/test/e2e/fixtures/file-tree-vue.html new file mode 100644 index 000000000..926c039a7 --- /dev/null +++ b/packages/trees/test/e2e/fixtures/file-tree-vue.html @@ -0,0 +1,43 @@ + + + + + + file-tree Vue fixture + + + +
+
+
+ + + diff --git a/packages/trees/test/e2e/fixtures/vue-shim.d.ts b/packages/trees/test/e2e/fixtures/vue-shim.d.ts new file mode 100644 index 000000000..a31307a94 --- /dev/null +++ b/packages/trees/test/e2e/fixtures/vue-shim.d.ts @@ -0,0 +1,6 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + + const component: DefineComponent, {}, unknown>; + export default component; +} diff --git a/packages/trees/test/e2e/vite.config.ts b/packages/trees/test/e2e/vite.config.ts index 99ae522c3..edcedba57 100644 --- a/packages/trees/test/e2e/vite.config.ts +++ b/packages/trees/test/e2e/vite.config.ts @@ -1,3 +1,4 @@ +import vue from '@vitejs/plugin-vue'; import { resolve } from 'node:path'; import { defineConfig } from 'vite'; @@ -6,6 +7,7 @@ const portFromEnv = Number(process.env.FILE_TREE_E2E_PORT); const port = Number.isFinite(portFromEnv) ? portFromEnv : defaultPort; export default defineConfig({ + plugins: [vue()], publicDir: resolve( import.meta.dirname, '..', diff --git a/packages/trees/test/file-tree-vue.test.ts b/packages/trees/test/file-tree-vue.test.ts new file mode 100644 index 000000000..351698887 --- /dev/null +++ b/packages/trees/test/file-tree-vue.test.ts @@ -0,0 +1,575 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + spyOn, + test, +} from 'bun:test'; +import { JSDOM } from 'jsdom'; +import type { App, Component, VNodeChild } from 'vue'; + +let createApp: typeof import('vue').createApp; +let createSSRApp: typeof import('vue').createSSRApp; +let defineComponent: typeof import('vue').defineComponent; +let h: typeof import('vue').h; +let nextTick: typeof import('vue').nextTick; +let renderToString: typeof import('@vue/server-renderer').renderToString; +let FileTreeVue: typeof import('../src/vue').FileTree; +let useFileTree: typeof import('../src/vue').useFileTree; +let useFileTreeSearch: typeof import('../src/vue').useFileTreeSearch; +let useFileTreeSelection: typeof import('../src/vue').useFileTreeSelection; +let FileTreeClass: typeof import('../src/render/FileTree').FileTree; +let preloadFileTree: typeof import('../src/render/FileTree').preloadFileTree; + +const TAG = 'file-tree-container'; +const originalGlobals = { + CSSStyleSheet: Reflect.get(globalThis, 'CSSStyleSheet'), + customElements: Reflect.get(globalThis, 'customElements'), + document: Reflect.get(globalThis, 'document'), + Event: Reflect.get(globalThis, 'Event'), + Element: Reflect.get(globalThis, 'Element'), + HTMLElement: Reflect.get(globalThis, 'HTMLElement'), + HTMLButtonElement: Reflect.get(globalThis, 'HTMLButtonElement'), + HTMLDivElement: Reflect.get(globalThis, 'HTMLDivElement'), + HTMLInputElement: Reflect.get(globalThis, 'HTMLInputElement'), + 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'), +}; + +const dom = new JSDOM('', { + pretendToBeVisual: true, + url: 'http://localhost', +}); + +class MockCSSStyleSheet { + replaceSync(_value: string): void {} +} + +class MockResizeObserver { + observe(_target: Element): void {} + disconnect(): void {} +} + +beforeAll(async () => { + Object.assign(globalThis, { + CSSStyleSheet: MockCSSStyleSheet, + customElements: dom.window.customElements, + document: dom.window.document, + Event: dom.window.Event, + Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, + HTMLButtonElement: dom.window.HTMLButtonElement, + HTMLDivElement: dom.window.HTMLDivElement, + HTMLInputElement: dom.window.HTMLInputElement, + 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, + }); + + class FileTreeContainerElement extends dom.window.HTMLElement { + constructor() { + super(); + if (this.shadowRoot == null) { + this.attachShadow({ mode: 'open' }); + } + } + } + + if (dom.window.customElements.get(TAG) == null) { + dom.window.customElements.define(TAG, FileTreeContainerElement); + } + + ({ createApp, createSSRApp, defineComponent, h, nextTick } = + await import('vue')); + ({ renderToString } = await import('@vue/server-renderer')); + ({ + FileTree: FileTreeVue, + useFileTree, + useFileTreeSearch, + useFileTreeSelection, + } = await import('../src/vue')); + ({ FileTree: FileTreeClass, preloadFileTree } = + await import('../src/render/FileTree')); +}); + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +afterAll(() => { + for (const [key, value] of Object.entries(originalGlobals)) { + if (value === undefined) { + Reflect.deleteProperty(globalThis, key); + } else { + Object.assign(globalThis, { [key]: value }); + } + } + + dom.window.close(); +}); + +async function flushDom(): Promise { + await nextTick(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function dispatchClick(target: Element): void { + target.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); +} + +async function mountComponent( + component: Component, + container: HTMLElement +): Promise> { + const app = createApp(component); + app.mount(container); + await flushDom(); + return app; +} + +function getHost(container: HTMLElement): HTMLElement { + const host = container.querySelector(TAG); + if (!(host instanceof dom.window.HTMLElement)) { + throw new Error('expected rendered file-tree host'); + } + + return host; +} + +function getItemButton(host: HTMLElement, path: string): HTMLButtonElement { + const button = host.shadowRoot?.querySelector(`[data-item-path="${path}"]`); + if (!(button instanceof dom.window.HTMLButtonElement)) { + throw new Error(`expected item button for ${path}`); + } + + return button; +} + +const BASE_OPTIONS = { + flattenEmptyDirectories: true, + initialExpansion: 'open' as const, + paths: ['README.md', 'src/index.ts'], + initialVisibleRowCount: 120 / 30, +}; + +describe('file-tree Vue lane', () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + test('renders a model-first tree and applies model mutations from Vue event handlers', async () => { + const model = new FileTreeClass(BASE_OPTIONS); + const component = defineComponent({ + render(): VNodeChild { + return h('div', [ + h( + 'button', + { + 'data-test-add': '', + onClick: () => { + model.add('src/utils.ts'); + }, + type: 'button', + }, + 'Add path' + ), + h(FileTreeVue, { model }), + ]); + }, + }); + + const app = await mountComponent(component, container); + try { + const host = getHost(container); + expect(getItemButton(host, 'README.md')).not.toBeNull(); + expect( + host.shadowRoot?.querySelector('[data-item-path="src/utils.ts"]') + ).toBeNull(); + + const addButton = container.querySelector('[data-test-add]'); + if (!(addButton instanceof dom.window.HTMLButtonElement)) { + throw new Error('expected add button'); + } + + dispatchClick(addButton); + await flushDom(); + + expect(getItemButton(host, 'src/utils.ts')).not.toBeNull(); + } finally { + app.unmount(); + model.cleanUp(); + } + }); + + test('useFileTree cleans up the owned model when its component unmounts', async () => { + let capturedModel: InstanceType | null = null; + const component = defineComponent({ + setup() { + const { model } = useFileTree(BASE_OPTIONS); + capturedModel = model; + return { model }; + }, + render(): VNodeChild { + return h(FileTreeVue, { model: this.model }); + }, + }); + const app = await mountComponent(component, container); + + if (capturedModel == null) { + throw new Error('expected model from useFileTree'); + } + + const cleanUpSpy = spyOn(capturedModel, 'cleanUp'); + app.unmount(); + await flushDom(); + expect(cleanUpSpy).toHaveBeenCalledTimes(1); + cleanUpSpy.mockRestore(); + }); + + test('restores a model header composition after a Vue slot override unmounts', async () => { + const model = new FileTreeClass({ + ...BASE_OPTIONS, + composition: { + header: { + html: '', + }, + }, + }); + const component = defineComponent({ + render(): VNodeChild { + return h( + FileTreeVue, + { model }, + { + header: () => + h('button', { 'data-test-vue-header': '' }, 'Vue header'), + } + ); + }, + }); + + const app = await mountComponent(component, container); + try { + const firstHost = getHost(container); + expect( + firstHost.querySelector('[data-test-vue-header]')?.textContent + ).toBe('Vue header'); + expect( + firstHost.querySelector('[data-test-model-header="true"]') + ).toBeNull(); + + app.unmount(); + const secondContainer = document.createElement('div'); + document.body.appendChild(secondContainer); + try { + model.render({ containerWrapper: secondContainer }); + await flushDom(); + const secondHost = getHost(secondContainer); + expect( + secondHost.querySelector('[data-test-model-header="true"]') + ?.textContent + ).toBe('Model header'); + } finally { + secondContainer.remove(); + } + } finally { + model.cleanUp(); + } + }); + + test('renders the context-menu scoped slot from model open events', async () => { + const model = new FileTreeClass({ + ...BASE_OPTIONS, + composition: { + contextMenu: { + buttonVisibility: 'always', + enabled: true, + triggerMode: 'button', + }, + }, + }); + const component = defineComponent({ + render(): VNodeChild { + return h( + FileTreeVue, + { model }, + { + 'context-menu': ({ + item, + }: { + item: import('../src').ContextMenuItem; + }) => + h( + 'div', + { 'data-test-vue-context-menu': '' }, + `Menu for ${item.path}` + ), + } + ); + }, + }); + + const app = await mountComponent(component, container); + try { + const host = getHost(container); + const anchorElement = getItemButton(host, 'README.md'); + model.getComposition()?.contextMenu?.onOpen?.( + { kind: 'file', name: 'README.md', path: 'README.md' }, + { + anchorElement, + anchorRect: { + bottom: 10, + height: 10, + left: 0, + right: 10, + top: 0, + width: 10, + x: 0, + y: 0, + }, + close: () => {}, + restoreFocus: () => {}, + } + ); + await flushDom(); + + expect( + host.querySelector('[data-test-vue-context-menu]')?.textContent + ).toBe('Menu for README.md'); + } finally { + app.unmount(); + model.cleanUp(); + } + }); + + test('selection and search composables rerender from model updates', async () => { + const model = new FileTreeClass({ ...BASE_OPTIONS, search: true }); + const component = defineComponent({ + setup() { + const selectedPaths = useFileTreeSelection(model); + const search = useFileTreeSearch(model); + return { search, selectedPaths }; + }, + render(): VNodeChild { + return h('div', [ + h( + 'button', + { + 'data-test-select': '', + onClick: () => { + model.getItem('README.md')?.select(); + }, + type: 'button', + }, + 'Select README' + ), + h( + 'button', + { + 'data-test-search': '', + onClick: () => { + this.search.open('read'); + }, + type: 'button', + }, + 'Search read' + ), + h( + 'output', + { 'data-test-selected-count': '' }, + String(this.selectedPaths.length) + ), + h( + 'output', + { 'data-test-search-open': '' }, + String(this.search.isOpen.value) + ), + h( + 'output', + { 'data-test-search-value': '' }, + this.search.value.value + ), + h( + 'output', + { 'data-test-search-count': '' }, + String(this.search.matchingPaths.value.length) + ), + h(FileTreeVue, { model }), + ]); + }, + }); + + const app = await mountComponent(component, container); + try { + expect( + container.querySelector('[data-test-selected-count]')?.textContent + ).toBe('0'); + expect( + container.querySelector('[data-test-search-open]')?.textContent + ).toBe('false'); + expect( + container.querySelector('[data-test-search-value]')?.textContent + ).toBe(''); + expect( + container.querySelector('[data-test-search-count]')?.textContent + ).toBe('0'); + + const selectButton = container.querySelector('[data-test-select]'); + const searchButton = container.querySelector('[data-test-search]'); + if ( + !(selectButton instanceof dom.window.HTMLButtonElement) || + !(searchButton instanceof dom.window.HTMLButtonElement) + ) { + throw new Error('expected composable harness buttons'); + } + + dispatchClick(selectButton); + await flushDom(); + expect( + container.querySelector('[data-test-selected-count]')?.textContent + ).toBe('1'); + + dispatchClick(searchButton); + await flushDom(); + expect( + container.querySelector('[data-test-search-open]')?.textContent + ).toBe('true'); + expect( + container.querySelector('[data-test-search-value]')?.textContent + ).toBe('read'); + expect( + container.querySelector('[data-test-search-count]')?.textContent + ).toBe('1'); + } finally { + app.unmount(); + model.cleanUp(); + } + }); + + test('hydrates colocated preloadedData and preserves live header interactions', async () => { + const preloadedData = preloadFileTree({ + ...BASE_OPTIONS, + id: 'pst-vue-ssr-test', + }); + const originalDocument = Reflect.get(globalThis, 'document'); + const originalWindow = Reflect.get(globalThis, 'window'); + const hydrationWarnings: string[] = []; + const originalConsoleError = console.error; + const originalConsoleWarn = console.warn; + + function createHarness() { + const model = new FileTreeClass(BASE_OPTIONS); + return defineComponent({ + data() { + return { count: 0, model }; + }, + beforeUnmount() { + model.cleanUp(); + }, + render(): VNodeChild { + return h( + FileTreeVue, + { model, preloadedData }, + { + header: () => + h( + 'button', + { + 'data-test-ssr-header': '', + onClick: () => { + this.count += 1; + }, + type: 'button', + }, + `Header action ${String(this.count)}` + ), + } + ); + }, + }); + } + + try { + Reflect.deleteProperty(globalThis, 'document'); + Reflect.deleteProperty(globalThis, 'window'); + const serverHtml = await renderToString(createSSRApp(createHarness())); + Object.assign(globalThis, { + document: originalDocument, + window: originalWindow, + }); + + expect(serverHtml).toContain('template shadowrootmode="open"'); + expect(serverHtml).toContain('data-allow-mismatch="children"'); + expect(serverHtml).toContain('data-test-ssr-header'); + + container.innerHTML = serverHtml; + console.error = (...args: unknown[]) => { + hydrationWarnings.push(args.map((value) => String(value)).join(' ')); + }; + console.warn = (...args: unknown[]) => { + hydrationWarnings.push(args.map((value) => String(value)).join(' ')); + }; + + const app = createSSRApp(createHarness()); + app.mount(container); + await flushDom(); + + expect( + hydrationWarnings.some((message) => + message.toLowerCase().includes('hydration') + ) + ).toBe(false); + + const host = getHost(container); + expect(host.querySelectorAll('[slot="header"]')).toHaveLength(1); + expect(host.querySelector('template[shadowrootmode="open"]')).toBeNull(); + const headerNode = host.querySelector('[data-test-ssr-header]'); + if (!(headerNode instanceof dom.window.HTMLButtonElement)) { + throw new Error('expected hydrated header button'); + } + + expect(headerNode.textContent).toBe('Header action 0'); + dispatchClick(headerNode); + await flushDom(); + expect(headerNode.textContent).toBe('Header action 1'); + expect(host.querySelector('template[shadowrootmode="open"]')).toBeNull(); + + app.unmount(); + } finally { + console.error = originalConsoleError; + console.warn = originalConsoleWarn; + Object.assign(globalThis, { + document: originalDocument, + window: originalWindow, + }); + } + }); +});