├── .changeset ├── README.md └── config.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── docs.yml │ ├── publish.yml │ ├── snapshot.yml │ └── verify.yml ├── .gitignore ├── .npmrc ├── .yarnrc.yml ├── CONTRIBUTING.md ├── LICENSE ├── examples ├── basic │ ├── index.html │ ├── package.json │ ├── readme.MD │ ├── src │ │ ├── data.ts │ │ ├── main.tsx │ │ ├── style.css │ │ └── vite-env.d.ts │ ├── tsconfig.json │ └── vite.config.js ├── comprehensive │ ├── index.html │ ├── package.json │ ├── readme.MD │ ├── src │ │ ├── data.ts │ │ ├── main.tsx │ │ ├── style.css │ │ └── vite-env.d.ts │ ├── tsconfig.json │ └── vite.config.js └── react-compiler │ ├── index.html │ ├── package.json │ ├── readme.MD │ ├── src │ ├── data.ts │ ├── main.tsx │ ├── style.css │ └── vite-env.d.ts │ ├── tsconfig.json │ └── vite.config.js ├── homepagedata.json ├── ideas.md ├── lerna.json ├── nx.json ├── package.json ├── packages ├── core │ ├── CHANGELOG.md │ ├── package.json │ ├── src │ │ ├── core │ │ │ ├── build-proxified-instance.ts │ │ │ ├── build-static-instance.ts │ │ │ ├── core.spec.ts │ │ │ └── create-tree.ts │ │ ├── features │ │ │ ├── async-data-loader │ │ │ │ ├── async-data-loader.spec.ts │ │ │ │ ├── feature.ts │ │ │ │ └── types.ts │ │ │ ├── drag-and-drop │ │ │ │ ├── drag-and-drop.spec.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ │ ├── expand-all │ │ │ │ ├── expand-all.spec.ts │ │ │ │ ├── feature.ts │ │ │ │ └── types.ts │ │ │ ├── hotkeys-core │ │ │ │ ├── feature.ts │ │ │ │ └── types.ts │ │ │ ├── keyboard-drag-and-drop │ │ │ │ ├── feature.ts │ │ │ │ ├── keyboard-drag-and-drop.spec.ts │ │ │ │ └── types.ts │ │ │ ├── main │ │ │ │ └── types.ts │ │ │ ├── prop-memoization │ │ │ │ ├── feature.ts │ │ │ │ ├── prop-memoization.spec.ts │ │ │ │ └── types.ts │ │ │ ├── renaming │ │ │ │ ├── feature.ts │ │ │ │ ├── renaming.spec.ts │ │ │ │ └── types.ts │ │ │ ├── search │ │ │ │ ├── feature.ts │ │ │ │ ├── search.spec.ts │ │ │ │ └── types.ts │ │ │ ├── selection │ │ │ │ ├── feature.ts │ │ │ │ ├── selection.spec.ts │ │ │ │ └── types.ts │ │ │ ├── sync-data-loader │ │ │ │ ├── feature.ts │ │ │ │ └── types.ts │ │ │ └── tree │ │ │ │ ├── feature.ts │ │ │ │ ├── tree.spec.ts │ │ │ │ └── types.ts │ │ ├── index.ts │ │ ├── mddocs-entry.ts │ │ ├── test-utils │ │ │ ├── test-tree-do.ts │ │ │ ├── test-tree-expect.ts │ │ │ └── test-tree.ts │ │ ├── types │ │ │ ├── core.ts │ │ │ └── deep-merge.ts │ │ ├── utilities │ │ │ ├── create-on-drop-handler.ts │ │ │ ├── errors.ts │ │ │ ├── insert-items-at-target.ts │ │ │ └── remove-items-from-parents.ts │ │ ├── utils.spec.ts │ │ └── utils.ts │ ├── tsconfig.json │ ├── typedoc.json │ └── vitest.config.ts ├── docs │ ├── .gitignore │ ├── CHANGELOG.md │ ├── README.md │ ├── babel.config.js │ ├── docs │ │ ├── 0-root │ │ │ └── getstarted.mdx │ │ ├── 1-guides │ │ │ ├── 0-state.mdx │ │ │ ├── 1-hotkeys.mdx │ │ │ ├── 2-accessibility.mdx │ │ │ ├── 3-styling.mdx │ │ │ ├── 99-rct-migration.mdx │ │ │ └── _category_.json │ │ ├── 2-features │ │ │ ├── 00-overview.mdx │ │ │ ├── 01-tree.mdx │ │ │ ├── 02-sync-dataloader.mdx │ │ │ ├── 03-async-dataloader.mdx │ │ │ ├── 04-selection.mdx │ │ │ ├── 05-dnd.mdx │ │ │ ├── 05-kdnd.mdx │ │ │ ├── 07-hotkeys.mdx │ │ │ ├── 08-search.mdx │ │ │ ├── 09-renaming.mdx │ │ │ ├── 10-expandall.mdx │ │ │ ├── 11-prop-memoization.mdx │ │ │ ├── 99-main.mdx │ │ │ └── _category_.json │ │ ├── 3-dnd │ │ │ ├── 1-overview.mdx │ │ │ ├── 2-foreign-dnd.mdx │ │ │ ├── 3-customizability.mdx │ │ │ ├── 4-behavior.mdx │ │ │ └── _category_.json │ │ ├── 4-recipes │ │ │ ├── 0-external-state-updates.mdx │ │ │ ├── 1-handling-expensive-components.mdx │ │ │ ├── 2-virtualization.mdx │ │ │ ├── 3-proxy-instances.mdx │ │ │ ├── 4-plugins.mdx │ │ │ ├── 5-click-behavior.mdx │ │ │ └── _category_.json │ │ ├── 5-contributing │ │ │ ├── 2-tests.mdx │ │ │ └── _category_.json │ │ ├── 6-changelog │ │ │ └── _category_.json │ │ └── 7-demos │ │ │ ├── 0-demos.mdx │ │ │ └── _category_.json │ ├── docusaurus.config.ts │ ├── package.json │ ├── sidebars.ts │ ├── src │ │ ├── components │ │ │ ├── demo │ │ │ │ ├── demo-box.module.css │ │ │ │ ├── demo-box.tsx │ │ │ │ └── use-cleaned-code.ts │ │ │ ├── docs-page │ │ │ │ ├── docs-page-header.tsx │ │ │ │ ├── feature-page-header.tsx │ │ │ │ ├── link-row.tsx │ │ │ │ └── styles.module.css │ │ │ └── home │ │ │ │ ├── demo-grid.module.css │ │ │ │ ├── demo-grid.tsx │ │ │ │ ├── home-notes.module.css │ │ │ │ └── home-notes.tsx │ │ ├── css │ │ │ └── custom.css │ │ ├── pages │ │ │ ├── index.module.css │ │ │ ├── index.tsx │ │ │ └── markdown-page.md │ │ └── util │ │ │ ├── use-all-stories.ts │ │ │ ├── use-stories-by-tags.ts │ │ │ ├── use-stories.ts │ │ │ └── use-story.ts │ ├── static │ │ ├── .nojekyll │ │ └── img │ │ │ ├── banner-1.png │ │ │ ├── banner-github.png │ │ │ ├── docusaurus.png │ │ │ ├── favicon.ico │ │ │ ├── ht-dnd-no-reordering.gif │ │ │ ├── ht-dnd-reparenting.gif │ │ │ ├── ht-homeend.gif │ │ │ ├── ht-kdnd-foreign-in.gif │ │ │ ├── ht-kdnd-foreign-out.gif │ │ │ ├── ht-kdnd-navigation.gif │ │ │ ├── ht-keyselect.gif │ │ │ ├── ht-leftright.gif │ │ │ ├── ht-rename.gif │ │ │ ├── ht-search.gif │ │ │ ├── ht-selections.gif │ │ │ └── logo.svg │ ├── storybook-plugin.ts │ └── tsconfig.json ├── react │ ├── .babelrc.json │ ├── CHANGELOG.md │ ├── package.json │ ├── src │ │ ├── assistive-tree-description.tsx │ │ ├── index.ts │ │ └── use-tree.tsx │ ├── tsconfig.json │ └── typedoc.json └── sb-react │ ├── .babelrc.json │ ├── .storybook │ ├── main.ts │ ├── preview.ts │ └── style.css │ ├── CHANGELOG.md │ ├── package.json │ ├── src │ ├── argtypes.ts │ ├── async-data-loading.stories.tsx │ ├── async │ │ ├── async-get-children-with-data.stories.tsx │ │ ├── async-loading-state.stories.tsx │ │ └── async-optimistic-invalidation.stories.tsx │ ├── dnd │ │ ├── basic.stories.tsx │ │ ├── can-drag.stories.tsx │ │ ├── can-drop.stories.tsx │ │ ├── cannot-drop-inbetween.stories.tsx │ │ ├── comprehensive.stories.tsx │ │ ├── drag-inside.stories.tsx │ │ ├── drag-line.stories.tsx │ │ ├── drag-outside.stories.tsx │ │ ├── kitchensink.stories.tsx │ │ ├── minimal-dragline-styling.stories.tsx │ │ ├── on-drop-handler.stories.tsx │ │ └── visible-assistive-text.stories.tsx │ ├── expand-all │ │ ├── async-data.stories.tsx │ │ └── basic.stories.tsx │ ├── general │ │ ├── basic-styling.stories.tsx │ │ ├── comprehensive-sample.stories.tsx │ │ ├── example.stories.tsx │ │ ├── item-data-objects.stories.tsx │ │ ├── recursive-datastructure.stories.tsx │ │ └── simple.stories.tsx │ ├── guides │ │ ├── click-behavior │ │ │ ├── expand-on-arrow-click.css │ │ │ ├── expand-on-arrow-click.stories.tsx │ │ │ └── expand-on-double-click.stories.tsx │ │ ├── multiple-trees-advanced.stories.tsx │ │ ├── multiple-trees.stories.tsx │ │ ├── overwriting-internals.stories.tsx │ │ └── render-performance │ │ │ ├── memoized-slow-item-renderers.stories.tsx │ │ │ └── slow-item-renderers.stories.tsx │ ├── hotkeys │ │ ├── custom-hotkeys.stories.tsx │ │ ├── overwriting-hotkeys.stories.tsx │ │ └── visible-hotkeys.stories.tsx │ ├── plugins │ │ ├── simple-plugin.stories.tsx │ │ └── transform-props.stories.tsx │ ├── renaming │ │ ├── basic.stories.tsx │ │ └── can-rename.stories.tsx │ ├── scalability │ │ ├── many-features.stories.tsx │ │ ├── scalability.stories.tsx │ │ ├── virtualization-dynamic-height.stories.tsx │ │ └── virtualization.stories.tsx │ ├── search │ │ ├── async.stories.tsx │ │ ├── basic.stories.tsx │ │ ├── custom-matcher.stories.tsx │ │ └── scroll-behaviour.stories.tsx │ ├── state │ │ ├── distinct-state-handlers.stories.tsx │ │ ├── external-state.stories.tsx │ │ └── internal-state.stories.tsx │ └── utils │ │ ├── data.ts │ │ ├── hotkey-debugger.stories.tsx │ │ ├── unit-test-async.stories.tsx │ │ └── unit-test-sync.stories.tsx │ └── tsconfig.json ├── readme.md ├── scripts ├── examples-data-template.ts.tpl ├── generate-llmtxt.mjs ├── prepare.mjs └── version.mjs ├── tsconfig.json ├── tsconfig.lint.json ├── typedoc.base.json ├── typedoc.json └── yarn.lock /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [ 6 | ["@headless-tree/core", "@headless-tree/react"] 7 | ], 8 | "linked": [], 9 | "access": "public", 10 | "baseBranch": "main", 11 | "updateInternalDependencies": "patch", 12 | "ignore": [] 13 | } 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: lukasbach 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior. Ideally this includes a sandbox link that reproduces the issue. There 14 | are some starter sandboxes provided for Headless Tree for Codesandbox or Stackblitz, that you can use 15 | to create a reproduction: https://headless-tree.lukasbach.com/examples 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. A gif or screencast can also help understand your problem. 22 | 23 | **Additional context** 24 | You can help by providing additional details that are available to you, such as 25 | 26 | - Device if mobile 27 | - Operating System, Browser 28 | - Version of the Library or tool for which you report the bug 29 | 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discord Chat 4 | url: https://discord.gg/KuZ6EezzVw 5 | about: An official discord server to discuss issues and features -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: lukasbach 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'npm' 9 | directory: '/' 10 | open-pull-requests-limit: 5 11 | schedule: 12 | interval: 'daily' -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | deployments: write 12 | id-token: write 13 | 14 | jobs: 15 | docs: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: actions/cache@v3 20 | with: 21 | path: | 22 | **/node_modules 23 | ./.yarn/cache 24 | key: ${{ runner.os }}-deps-${{ hashFiles('**/yarn.lock') }} 25 | - uses: volta-cli/action@v4.1.1 26 | - run: yarn 27 | - run: yarn lint:test 28 | - run: yarn build:core 29 | - run: yarn build:sb 30 | - run: yarn llmtxt 31 | - run: yarn build:web 32 | - run: | 33 | mkdir -p ./packages/docs/build/storybook 34 | cp -r ./packages/sb-react/storybook-static ./packages/docs/build/storybook/react 35 | - uses: actions/upload-pages-artifact@v3 36 | with: 37 | path: ./packages/docs/build 38 | - uses: actions/deploy-pages@v4 39 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | branches: 5 | - main 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | publish: 14 | runs-on: ubuntu-latest 15 | name: Publish 16 | steps: 17 | - uses: actions/checkout@v3 18 | with: 19 | persist-credentials: false 20 | fetch-depth: 0 21 | 22 | - uses: actions/cache@v3 23 | with: 24 | path: | 25 | **/node_modules 26 | ./.yarn/cache 27 | key: ${{ runner.os }}-deps-${{ hashFiles('**/yarn.lock') }} 28 | - uses: volta-cli/action@v4.1.1 29 | with: 30 | registry-url: "https://registry.npmjs.org" 31 | 32 | - run: yarn 33 | - run: yarn lint:test 34 | - run: yarn test 35 | - run: yarn build:core 36 | - run: yarn build:web 37 | - name: Copy readme.md to packages 38 | run: | 39 | for dir in $(find packages -type d -maxdepth 1); do 40 | cp readme.md $dir/readme.md 41 | done 42 | 43 | - name: Create Release Pull Request or Publish to npm 44 | id: changesets 45 | uses: changesets/action@v1 46 | with: 47 | version: npx zx ./scripts/version.mjs 48 | publish: yarn changeset publish 49 | commit: "chore: release" 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 53 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 54 | -------------------------------------------------------------------------------- /.github/workflows/snapshot.yml: -------------------------------------------------------------------------------- 1 | name: Snapshot Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | snapshot: 14 | runs-on: ubuntu-latest 15 | name: Publish 16 | steps: 17 | - uses: actions/checkout@v3 18 | with: 19 | persist-credentials: false 20 | fetch-depth: 0 21 | 22 | - uses: actions/cache@v3 23 | with: 24 | path: | 25 | **/node_modules 26 | ./.yarn/cache 27 | key: ${{ runner.os }}-deps-${{ hashFiles('**/yarn.lock') }} 28 | - uses: volta-cli/action@v4.1.1 29 | with: 30 | registry-url: "https://registry.npmjs.org" 31 | 32 | - run: yarn 33 | - run: yarn lint:test 34 | - run: yarn test 35 | - run: yarn build:core 36 | - run: yarn build:web 37 | 38 | - name: Snapshot Versioning 39 | run: yarn changeset version --snapshot 40 | 41 | - name: Snapshot Release 42 | run: | 43 | if git diff --quiet; then 44 | echo "No changes detected" 45 | exit 0 46 | else 47 | yarn changeset publish --no-git-tag --tag snapshot 48 | fi 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 51 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 52 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 53 | -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | name: Verify 2 | on: 3 | push: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | verify: 9 | runs-on: ubuntu-latest 10 | name: Verify 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/cache@v3 14 | with: 15 | path: | 16 | **/node_modules 17 | ./.yarn/cache 18 | key: ${{ runner.os }}-deps-${{ hashFiles('**/yarn.lock') }} 19 | - uses: volta-cli/action@v4.1.1 20 | - run: yarn 21 | - run: yarn lint:test 22 | - run: yarn test 23 | - run: yarn build:core 24 | - run: yarn build:web 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pnp.* 2 | .yarn/* 3 | !.yarn/patches 4 | !.yarn/plugins 5 | !.yarn/releases 6 | !.yarn/sdks 7 | !.yarn/versions 8 | node_modules 9 | 10 | .nx 11 | 12 | .idea 13 | /docs 14 | lib 15 | 16 | tsconfig.tsbuildinfo 17 | 18 | storybook-static 19 | /packages/docs/apidocs 20 | /packages/docs/.cache 21 | /packages/docs/public 22 | /packages/docs/docs/md-test 23 | /packages/docs/docs/0-root/examples.mdx 24 | /examples/*/dist 25 | /packages/*/readme.md 26 | /packages/readme.md 27 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | // registry.npmjs.org/:_authToken=${NPM_TOKEN} 2 | registry-url=https://registry.npmjs.org/ 3 | always-auth=true 4 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: false 4 | 5 | nodeLinker: node-modules 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | To run locally, you need to install NodeJS and Yarn. Locally installing 4 | Volta is recommended, since it will make sure to use the correct versions 5 | of NodeJS and Yarn. 6 | 7 | To develop locally, run ``yarn`` to install dependencies, then 8 | 9 | - ``yarn start`` to build the library in watch mode and start the storybook instance 10 | - ``yarn start:docs`` to work on the documentation page. You might need to run 11 | ``yarn build`` first. If you just want to edit documentation pages, you can also 12 | just directly edit the markdown files. 13 | - ``yarn verify`` to run the linter, tests and build the library 14 | 15 | When proposing a new change, please document the changes and whether 16 | they are breaking or not by running ``yarn changeset`` prior to committing. 17 | 18 | Before proposing your changes, please check if you those of the following steps 19 | which make sense in the scope of your change have been completed: 20 | 21 | - Changes to the usage of the library are documented in the Docs Pages 22 | - Potential visual features are reproducible in a Storybook story 23 | - Unit Tests verifying the changes have been implemented 24 | - Linter and Unit Tests run successfully 25 | 26 | Feel free to reach out at any time during your contribution process if you 27 | have any questions or need help. You can also collaborate in our Discord 28 | community to get help or discuss your ideas with other contributors: 29 | - https://discord.gg/WN85eMY3 30 | 31 | There are some more detailed documentations available on contributing implementation 32 | changes to Headless Tree at the HT website: 33 | - https://headless-tree.lukasbach.com/contributing/overview -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Lukas Bach 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@headless-tree/example-basic", 3 | "description": "Basic example of Headless Tree with React", 4 | "private": true, 5 | "version": "0.0.0", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "start": "vite", 10 | "build": "tsc -b && vite build" 11 | }, 12 | "dependencies": { 13 | "@headless-tree/core": "^1.0.0", 14 | "@headless-tree/react": "^1.0.0", 15 | "classnames": "^2.3.2", 16 | "react": "^19.0.0", 17 | "react-dom": "^19.0.0" 18 | }, 19 | "devDependencies": { 20 | "@types/react": "^19.0.10", 21 | "@types/react-dom": "^19.0.4", 22 | "@vitejs/plugin-react": "^4.3.4", 23 | "globals": "^16.0.0", 24 | "typescript": "~5.7.2", 25 | "vite": "^6.3.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/basic/readme.MD: -------------------------------------------------------------------------------- 1 | # Basic example of Headless Tree with React 2 | 3 | To run this example: 4 | 5 | - `npm install` to install dependencies 6 | - `npm start` to start the dev server 7 | 8 | You can run this sample from [Stackblitz](https://stackblitz.com/github/lukasbach/headless-tree/tree/main/examples/basic?preset=node&file=src/main.tsx) or [CodeSandbox](https://codesandbox.io/p/devbox/github/lukasbach/headless-tree/tree/main/examples/basic?file=src/main.tsx). The source code is available on [GitHub](https://github.com/lukasbach/headless-tree/tree/main/examples/basic). 9 | 10 | -------------------------------------------------------------------------------- /examples/basic/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import "./style.css"; 4 | import { 5 | asyncDataLoaderFeature, 6 | createOnDropHandler, 7 | dragAndDropFeature, 8 | hotkeysCoreFeature, 9 | keyboardDragAndDropFeature, 10 | selectionFeature, 11 | } from "@headless-tree/core"; 12 | import { AssistiveTreeDescription, useTree } from "@headless-tree/react"; 13 | import cn from "classnames"; 14 | import { DemoItem, asyncDataLoader, data } from "./data"; 15 | 16 | export const Tree = () => { 17 | const tree = useTree({ 18 | initialState: { 19 | expandedItems: ["fruit"], 20 | selectedItems: ["banana", "orange"], 21 | }, 22 | rootItemId: "root", 23 | getItemName: (item) => item.getItemData()?.name, 24 | isItemFolder: (item) => !!item.getItemData()?.children, 25 | canReorder: true, 26 | onDrop: createOnDropHandler((item, newChildren) => { 27 | data[item.getId()].children = newChildren; 28 | }), 29 | indent: 20, 30 | dataLoader: asyncDataLoader, 31 | features: [ 32 | asyncDataLoaderFeature, 33 | selectionFeature, 34 | hotkeysCoreFeature, 35 | dragAndDropFeature, 36 | keyboardDragAndDropFeature, 37 | ], 38 | }); 39 | 40 | return ( 41 |
42 | 43 | {tree.getItems().map((item) => ( 44 | 61 | ))} 62 |
63 |
64 | ); 65 | }; 66 | 67 | createRoot(document.getElementById("root")!).render( 68 | 69 |
70 | 71 |
72 |
, 73 | ); 74 | -------------------------------------------------------------------------------- /examples/basic/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare module "*.css"; 3 | -------------------------------------------------------------------------------- /examples/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "isolatedModules": true, 11 | "moduleDetection": "force", 12 | "noEmit": true, 13 | "jsx": "react-jsx", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "noUncheckedSideEffectImports": true 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /examples/basic/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | }); 7 | -------------------------------------------------------------------------------- /examples/comprehensive/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/comprehensive/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@headless-tree/example-comprehensive", 3 | "description": "Integration of Headless Tree with React with most HT features enabled", 4 | "private": true, 5 | "version": "0.0.0", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "start": "vite", 10 | "build": "tsc -b && vite build" 11 | }, 12 | "dependencies": { 13 | "@headless-tree/core": "^1.0.0", 14 | "@headless-tree/react": "^1.0.0", 15 | "classnames": "^2.3.2", 16 | "react": "^19.0.0", 17 | "react-dom": "^19.0.0" 18 | }, 19 | "devDependencies": { 20 | "@types/react": "^19.0.10", 21 | "@types/react-dom": "^19.0.4", 22 | "@vitejs/plugin-react": "^4.3.4", 23 | "globals": "^16.0.0", 24 | "typescript": "~5.7.2", 25 | "vite": "^6.3.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/comprehensive/readme.MD: -------------------------------------------------------------------------------- 1 | # Integration of Headless Tree with React with most HT features enabled 2 | 3 | To run this example: 4 | 5 | - `npm install` to install dependencies 6 | - `npm start` to start the dev server 7 | 8 | You can run this sample from [Stackblitz](https://stackblitz.com/github/lukasbach/headless-tree/tree/main/examples/comprehensive?preset=node&file=src/main.tsx) or [CodeSandbox](https://codesandbox.io/p/devbox/github/lukasbach/headless-tree/tree/main/examples/comprehensive?file=src/main.tsx). The source code is available on [GitHub](https://github.com/lukasbach/headless-tree/tree/main/examples/comprehensive). 9 | 10 | -------------------------------------------------------------------------------- /examples/comprehensive/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare module "*.css"; 3 | -------------------------------------------------------------------------------- /examples/comprehensive/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "isolatedModules": true, 11 | "moduleDetection": "force", 12 | "noEmit": true, 13 | "jsx": "react-jsx", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "noUncheckedSideEffectImports": true 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /examples/comprehensive/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | }); 7 | -------------------------------------------------------------------------------- /examples/react-compiler/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/react-compiler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@headless-tree/example-react-compiler", 3 | "description": "Headless Tree with React Compiler enabled", 4 | "private": true, 5 | "version": "0.0.0", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "start": "vite", 10 | "build": "tsc -b && vite build" 11 | }, 12 | "dependencies": { 13 | "@headless-tree/core": "^1.0.0", 14 | "@headless-tree/react": "^1.0.0", 15 | "classnames": "^2.3.2", 16 | "react": "^19.0.0", 17 | "react-dom": "^19.0.0" 18 | }, 19 | "devDependencies": { 20 | "@types/react": "^19.0.10", 21 | "@types/react-dom": "^19.0.4", 22 | "@vitejs/plugin-react": "^4.3.4", 23 | "babel-plugin-react-compiler": "^19.1.0-rc.1", 24 | "globals": "^16.0.0", 25 | "typescript": "~5.7.2", 26 | "vite": "^6.3.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/react-compiler/readme.MD: -------------------------------------------------------------------------------- 1 | # Headless Tree with React Compiler enabled 2 | 3 | To run this example: 4 | 5 | - `npm install` to install dependencies 6 | - `npm start` to start the dev server 7 | 8 | You can run this sample from [Stackblitz](https://stackblitz.com/github/lukasbach/headless-tree/tree/main/examples/react-compiler?preset=node&file=src/main.tsx) or [CodeSandbox](https://codesandbox.io/p/devbox/github/lukasbach/headless-tree/tree/main/examples/react-compiler?file=src/main.tsx). The source code is available on [GitHub](https://github.com/lukasbach/headless-tree/tree/main/examples/react-compiler). 9 | 10 | -------------------------------------------------------------------------------- /examples/react-compiler/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare module "*.css"; 3 | -------------------------------------------------------------------------------- /examples/react-compiler/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "isolatedModules": true, 11 | "moduleDetection": "force", 12 | "noEmit": true, 13 | "jsx": "react-jsx", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "noUncheckedSideEffectImports": true 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /examples/react-compiler/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | plugins: [ 6 | react({ 7 | babel: { 8 | plugins: [ 9 | ["babel-plugin-react-compiler", { 10 | target: "19" 11 | }], 12 | ], 13 | }, 14 | }), 15 | ], 16 | }); 17 | -------------------------------------------------------------------------------- /homepagedata.json: -------------------------------------------------------------------------------- 1 | { 2 | "repo": "headless-tree", 3 | "title": "Headless Tree", 4 | "category": "library", 5 | "created_at": "2025-04-15T00:00:00.000Z", 6 | "highlight": true, 7 | "docs": "https://headless-tree.lukasbach.com", 8 | "npm": "@headless-tree/core @headless-tree/react" 9 | } 10 | -------------------------------------------------------------------------------- /ideas.md: -------------------------------------------------------------------------------- 1 | - Auto-open folders when dragging onto them for 1 second 2 | - Drag item to bottom most location shows dragline at 0 3 | - Dragging items out of the tree (or otherwise removing them), they remain selected. Now ctrl+selecting a new item and dragging that out as well will throw an error (in comprehensive demo) 4 | - Styling guide -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 | "version": "0.0.0" 4 | } 5 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasksRunnerOptions": { 3 | "default": { 4 | "runner": "nx-cloud", 5 | "options": { 6 | "cacheableOperations": [ 7 | "build:cjs", 8 | "build:esm", 9 | "build:docs", 10 | "typecheck", 11 | "build:sb", 12 | "lint:test" 13 | ], 14 | "accessToken": "YmU4OTg0NzYtZTIzYy00ZTdmLWJhM2ItMGRmYTUyZGE0YzJkfHJlYWQtb25seQ==" 15 | } 16 | } 17 | }, 18 | "targetDefaults": { 19 | "build:cjs": { 20 | "outputs": [ 21 | "{projectRoot}/lib/cjs" 22 | ] 23 | }, 24 | "build:esm": { 25 | "outputs": [ 26 | "{projectRoot}/lib/esm" 27 | ] 28 | }, 29 | "build:docs": { 30 | "outputs": [ 31 | "{projectRoot}/public", 32 | "{projectRoot}/build", 33 | "{projectRoot}/.docusaurus" 34 | ] 35 | }, 36 | "build:sb": { 37 | "outputs": [ 38 | "{projectRoot}/storybook-static" 39 | ] 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*", 6 | "examples/*" 7 | ], 8 | "scripts": { 9 | "lint": "eslint . --fix", 10 | "lint:test": "eslint .", 11 | "start": "lerna run start,start:sb --parallel --scope @headless-tree/core --scope @headless-tree/sb-react", 12 | "start:docs": "lerna run start --parallel --scope @headless-tree/docs", 13 | "build": "lerna run build,build:cjs,build:esm,build:docs,build:sb --concurrency 1", 14 | "build:core": "lerna run build,build:cjs,build:esm --concurrency 1", 15 | "build:web": "lerna run build:docs,build:sb --concurrency 1", 16 | "test": "lerna run test --stream", 17 | "postinstall": "zx scripts/prepare.mjs", 18 | "llmtxt": "zx scripts/generate-llmtxt.mjs", 19 | "verify": "yarn lint && lerna run build,build:esm,test --stream" 20 | }, 21 | "dependencies": { 22 | "@changesets/cli": "^2.27.5", 23 | "@lukasbach/eslint-config-deps": "^1.0.7", 24 | "@types/node": "22.10.7", 25 | "@types/react": "18.2.66", 26 | "@types/react-dom": "18.2.22", 27 | "eslint": "^8.57.0", 28 | "front-matter": "^4.0.2", 29 | "lerna": "^8.1.3", 30 | "typedoc": "^0.25.13", 31 | "typedoc-plugin-markdown": "^3.17.1", 32 | "zx": "^8.3.2" 33 | }, 34 | "volta": { 35 | "node": "23.6.1", 36 | "yarn": "4.1.1" 37 | }, 38 | "eslintConfig": { 39 | "extends": "@lukasbach/base/react", 40 | "parserOptions": { 41 | "project": "./tsconfig.lint.json" 42 | }, 43 | "rules": { 44 | "no-param-reassign": "off", 45 | "no-nested-ternary": "warn", 46 | "@typescript-eslint/no-shadow": "off", 47 | "@typescript-eslint/no-unused-vars": "warn", 48 | "no-empty-pattern": "off", 49 | "react/button-has-type": "off", 50 | "react/no-unescaped-entities": "warn", 51 | "no-alert": "off", 52 | "react/destructuring-assignment": "off" 53 | }, 54 | "ignorePatterns": [ 55 | "lib", 56 | "*.js" 57 | ] 58 | }, 59 | "devDependencies": { 60 | "@nrwl/nx-cloud": "latest" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @headless-tree/core 2 | 3 | ## 1.1.0 4 | 5 | ### Minor Changes 6 | 7 | - 64d8e2a: add getChildrenWithData method to data loader to support fetching all children of an item at once 8 | - 35260e3: fixed hotkey issues where releasing modifier keys (like shift) before normal keys can cause issues with subsequent keydown events 9 | 10 | ### Patch Changes 11 | 12 | - 29b2c64: improved key-handling behavior for hotkeys while input elements are focused (#98) 13 | - da1e757: fixed a bug where alt-tabbing out of browser will break hotkeys feature 14 | - c283f52: add feature to allow async data invalidation without triggering rerenders with `invalidateItemData(optimistic: true)` (#95) 15 | - 29b2c64: added option to completely ignore hotkey events while input elements are focused (`ignoreHotkeysOnInput`) (#98) 16 | - cd5b27c: add position:absolute to default styles of getDragLineStyle() 17 | 18 | ## 1.0.1 19 | 20 | ### Patch Changes 21 | 22 | - c9f9932: fixed tree.focusNextItem() and tree.focusPreviousItem() throwing if no item is currently focused 23 | - 6ed84b4: recursive item references are filtered out when rendering (#89) 24 | - 4bef2f2: fixed a bug where hotkeys involving shift may not work properly depending on the order of shift and other key inputs (#98) 25 | 26 | ## 1.0.0 27 | 28 | ### Minor Changes 29 | 30 | - 9e5027b: The propMemoization feature now memoizes all prop-generation related functions, including searchinput and renameinput related props 31 | 32 | ## 0.0.15 33 | 34 | ### Patch Changes 35 | 36 | - 2af5668: Bug fix: Mutations to expanded tree items from outside will now trigger a rebuild of the tree structure (#65) 37 | - 617faea: Support for keyboard-controlled drag-and-drop events 38 | 39 | ## 0.0.14 40 | 41 | ### Patch Changes 42 | 43 | - 7e702fb: dev release 44 | 45 | ## 0.0.13 46 | 47 | ### Patch Changes 48 | 49 | - fdaefbc: dev release 50 | 51 | ## 0.0.12 52 | 53 | ### Patch Changes 54 | 55 | - 7236907: dev release 56 | 57 | ## 0.0.11 58 | 59 | ### Patch Changes 60 | 61 | - 7ed33ac: dev release 62 | 63 | ## 0.0.10 64 | 65 | ### Patch Changes 66 | 67 | - 520ec27: test release 68 | 69 | ## 0.0.9 70 | 71 | ### Patch Changes 72 | 73 | - ab2a124: dev release 74 | 75 | ## 0.0.8 76 | 77 | ### Patch Changes 78 | 79 | - 076dfc5: dev release 80 | 81 | ## 0.0.7 82 | 83 | ### Patch Changes 84 | 85 | - 6ec53b3: dev release 86 | 87 | ## 0.0.6 88 | 89 | ### Patch Changes 90 | 91 | - bc9c446: dev release 92 | 93 | ## 0.0.5 94 | 95 | ### Patch Changes 96 | 97 | - 751682a: dev release 98 | 99 | ## 0.0.4 100 | 101 | ### Patch Changes 102 | 103 | - dc6d813: tag pushing 104 | 105 | ## 0.0.3 106 | 107 | ### Patch Changes 108 | 109 | - 6460368: tree shaking 110 | 111 | ## 0.0.2 112 | 113 | ### Patch Changes 114 | 115 | - 05e24de: release test 116 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@headless-tree/core", 3 | "version": "1.1.0", 4 | "type": "module", 5 | "main": "lib/cjs/index.js", 6 | "module": "lib/esm/index.js", 7 | "types": "lib/esm/index.d.ts", 8 | "sideEffects": false, 9 | "scripts": { 10 | "build:cjs": "tsc -m commonjs --outDir lib/cjs", 11 | "build:esm": "tsc", 12 | "start": "tsc -w", 13 | "test": "vitest run" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git@github.com:lukasbach/headless-tree.git", 18 | "directory": "packages/core" 19 | }, 20 | "author": "Lukas Bach ", 21 | "license": "MIT", 22 | "devDependencies": { 23 | "jsdom": "^26.0.0", 24 | "typescript": "^5.7.2", 25 | "vitest": "^3.0.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/core/src/core/build-static-instance.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-continue,no-labels,no-extra-label */ 2 | 3 | import { InstanceBuilder } from "../features/main/types"; 4 | 5 | export const buildStaticInstance: InstanceBuilder = ( 6 | features, 7 | instanceType, 8 | buildOpts, 9 | ) => { 10 | const instance: any = {}; 11 | const finalize = () => { 12 | const opts = buildOpts(instance); 13 | featureLoop: for (let i = 0; i < features.length; i++) { 14 | // Loop goes in forward order, each features overwrite previous ones and wraps those in a prev() fn 15 | const definition = features[i][instanceType]; 16 | if (!definition) continue featureLoop; 17 | methodLoop: for (const [key, method] of Object.entries(definition)) { 18 | if (!method) continue methodLoop; 19 | const prev = instance[key]; 20 | instance[key] = (...args: any[]) => { 21 | return method({ ...opts, prev }, ...args); 22 | }; 23 | } 24 | } 25 | }; 26 | return [instance as any, finalize]; 27 | }; 28 | -------------------------------------------------------------------------------- /packages/core/src/features/async-data-loader/types.ts: -------------------------------------------------------------------------------- 1 | import { SetStateFn } from "../../types/core"; 2 | import { SyncDataLoaderFeatureDef } from "../sync-data-loader/types"; 3 | 4 | export interface AsyncDataLoaderDataRef { 5 | itemData: Record; 6 | childrenIds: Record; 7 | } 8 | 9 | /** 10 | * @category Async Data Loader/General 11 | * */ 12 | export type AsyncDataLoaderFeatureDef = { 13 | state: { 14 | loadingItemData: string[]; 15 | loadingItemChildrens: string[]; 16 | }; 17 | config: { 18 | rootItemId: string; 19 | 20 | /** Will be called when HT retrieves item data for an item whose item data is asynchronously being loaded. 21 | * Can be used to create placeholder data to use for rendering the tree item while it is loaded. If not defined, 22 | * the tree item data will be null. */ 23 | createLoadingItemData?: () => T; 24 | 25 | setLoadingItemData?: SetStateFn; 26 | setLoadingItemChildrens?: SetStateFn; 27 | onLoadedItem?: (itemId: string, item: T) => void; 28 | onLoadedChildren?: (itemId: string, childrenIds: string[]) => void; 29 | }; 30 | treeInstance: SyncDataLoaderFeatureDef["treeInstance"] & { 31 | /** @deprecated use loadItemData instead */ 32 | waitForItemDataLoaded: (itemId: string) => Promise; 33 | /** @deprecated use loadChildrenIds instead */ 34 | waitForItemChildrenLoaded: (itemId: string) => Promise; 35 | loadItemData: (itemId: string) => Promise; 36 | loadChildrenIds: (itemId: string) => Promise; 37 | }; 38 | itemInstance: SyncDataLoaderFeatureDef["itemInstance"] & { 39 | /** Invalidate fetched data for item, and triggers a refetch and subsequent rerender if the item is visible 40 | * @param optimistic If true, the item will not trigger a state update on `loadingItemData`, and 41 | * the tree will continue to display the old data until the new data has loaded. */ 42 | invalidateItemData: (optimistic?: boolean) => Promise; 43 | 44 | /** Invalidate fetched children ids for item, and triggers a refetch and subsequent rerender if the item is visible 45 | * @param optimistic If true, the item will not trigger a state update on `loadingItemChildrens`, and 46 | * the tree will continue to display the old data until the new data has loaded. */ 47 | invalidateChildrenIds: (optimistic?: boolean) => Promise; 48 | 49 | updateCachedChildrenIds: (childrenIds: string[]) => void; 50 | isLoading: () => boolean; 51 | }; 52 | hotkeys: SyncDataLoaderFeatureDef["hotkeys"]; 53 | }; 54 | -------------------------------------------------------------------------------- /packages/core/src/features/drag-and-drop/types.ts: -------------------------------------------------------------------------------- 1 | import { ItemInstance, SetStateFn } from "../../types/core"; 2 | 3 | export interface DndDataRef { 4 | lastDragCode?: string; 5 | lastAllowDrop?: boolean; 6 | } 7 | 8 | export interface DndState { 9 | draggedItems?: ItemInstance[]; 10 | draggingOverItem?: ItemInstance; 11 | dragTarget?: DragTarget; 12 | } 13 | 14 | export interface DragLineData { 15 | indent: number; 16 | top: number; 17 | left: number; 18 | width: number; 19 | } 20 | 21 | export type DragTarget = 22 | | { 23 | item: ItemInstance; 24 | childIndex: number; 25 | insertionIndex: number; 26 | dragLineIndex: number; 27 | dragLineLevel: number; 28 | } 29 | | { 30 | item: ItemInstance; 31 | }; 32 | 33 | export enum DragTargetPosition { 34 | Top = "top", 35 | Bottom = "bottom", 36 | Item = "item", 37 | } 38 | 39 | export type DragAndDropFeatureDef = { 40 | state: { 41 | dnd?: DndState | null; 42 | }; 43 | config: { 44 | setDndState?: SetStateFn | undefined | null>; 45 | 46 | /** Defines the size of the area at the top and bottom of an item where, when an item is dropped, the item willö 47 | * be placed above or below the item within the same parent, as opposed to being placed inside the item. 48 | * If `canReorder` is `false`, this is ignored. */ 49 | reorderAreaPercentage?: number; 50 | canReorder?: boolean; 51 | 52 | canDrag?: (items: ItemInstance[]) => boolean; 53 | canDrop?: (items: ItemInstance[], target: DragTarget) => boolean; 54 | 55 | indent?: number; 56 | 57 | createForeignDragObject?: (items: ItemInstance[]) => { 58 | format: string; 59 | data: any; 60 | }; 61 | canDropForeignDragObject?: ( 62 | dataTransfer: DataTransfer, 63 | target: DragTarget, 64 | ) => boolean; 65 | onDrop?: ( 66 | items: ItemInstance[], 67 | target: DragTarget, 68 | ) => void | Promise; 69 | onDropForeignDragObject?: ( 70 | dataTransfer: DataTransfer, 71 | target: DragTarget, 72 | ) => void | Promise; 73 | onCompleteForeignDrop?: (items: ItemInstance[]) => void; 74 | }; 75 | treeInstance: { 76 | getDragTarget: () => DragTarget | null; 77 | getDragLineData: () => DragLineData | null; 78 | 79 | getDragLineStyle: ( 80 | topOffset?: number, 81 | leftOffset?: number, 82 | ) => Record; 83 | }; 84 | itemInstance: { 85 | isDragTarget: () => boolean; 86 | isDragTargetAbove: () => boolean; 87 | isDragTargetBelow: () => boolean; 88 | isDraggingOver: () => boolean; 89 | }; 90 | hotkeys: never; 91 | }; 92 | -------------------------------------------------------------------------------- /packages/core/src/features/expand-all/expand-all.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | import { TestTree } from "../../test-utils/test-tree"; 3 | import { expandAllFeature } from "./feature"; 4 | import { propMemoizationFeature } from "../prop-memoization/feature"; 5 | 6 | const factory = TestTree.default({}).withFeatures( 7 | expandAllFeature, 8 | propMemoizationFeature, 9 | ); 10 | 11 | describe("core-feature/expand-all", () => { 12 | factory.forSuits((tree) => { 13 | describe("tree instance calls", () => { 14 | it("expands all", async () => { 15 | const expandPromise = tree.instance.expandAll(); 16 | await tree.resolveAsyncVisibleItems(); 17 | await expandPromise; 18 | tree.expect.foldersExpanded("x12", "x13", "x14", "x4", "x41", "x44"); 19 | }); 20 | 21 | it("collapses all", () => { 22 | tree.instance.collapseAll(); 23 | tree.expect.foldersCollapsed("x1", "x2", "x3", "x4"); 24 | }); 25 | 26 | it("cancels expanding all", async () => { 27 | const token = { current: true }; 28 | const expandPromise = tree.instance.expandAll(token); 29 | token.current = false; 30 | await tree.resolveAsyncVisibleItems(); 31 | await expandPromise; 32 | tree.expect.foldersCollapsed("x2", "x3", "x4"); 33 | }); 34 | }); 35 | 36 | describe("item instance calls", () => { 37 | it("expands all", async () => { 38 | const expandPromise = Promise.all([ 39 | // not sure why all are needed... 40 | tree.instance.getItemInstance("x1").expandAll(), 41 | tree.instance.getItemInstance("x2").expandAll(), 42 | tree.instance.getItemInstance("x3").expandAll(), 43 | tree.instance.getItemInstance("x4").expandAll(), 44 | ]); 45 | await tree.resolveAsyncVisibleItems(); 46 | await expandPromise; 47 | tree.expect.foldersExpanded("x2", "x21", "x24"); 48 | }); 49 | 50 | it("collapses all", () => { 51 | tree.instance.collapseAll(); 52 | tree.expect.foldersCollapsed("x1", "x2", "x3", "x4"); 53 | }); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /packages/core/src/features/expand-all/feature.ts: -------------------------------------------------------------------------------- 1 | import { FeatureImplementation } from "../../types/core"; 2 | 3 | export const expandAllFeature: FeatureImplementation = { 4 | key: "expand-all", 5 | 6 | treeInstance: { 7 | expandAll: async ({ tree }, cancelToken) => { 8 | await Promise.all( 9 | tree.getItems().map((item) => item.expandAll(cancelToken)), 10 | ); 11 | }, 12 | 13 | collapseAll: ({ tree }) => { 14 | tree.applySubStateUpdate("expandedItems", []); 15 | tree.rebuildTree(); 16 | }, 17 | }, 18 | 19 | itemInstance: { 20 | expandAll: async ({ tree, item }, cancelToken) => { 21 | if (cancelToken?.current) { 22 | return; 23 | } 24 | if (!item.isFolder()) { 25 | return; 26 | } 27 | 28 | item.expand(); 29 | await tree.waitForItemChildrenLoaded(item.getId()); 30 | await Promise.all( 31 | item.getChildren().map(async (child) => { 32 | await tree.waitForItemChildrenLoaded(item.getId()); 33 | await child?.expandAll(cancelToken); 34 | }), 35 | ); 36 | }, 37 | 38 | collapseAll: ({ item }) => { 39 | if (!item.isExpanded()) return; 40 | for (const child of item.getChildren()) { 41 | child?.collapseAll(); 42 | } 43 | item.collapse(); 44 | }, 45 | }, 46 | 47 | hotkeys: { 48 | expandSelected: { 49 | hotkey: "Control+Shift+Plus", 50 | handler: async (_, tree) => { 51 | const cancelToken = { current: false }; 52 | const cancelHandler = (e: KeyboardEvent) => { 53 | if (e.code === "Escape") { 54 | cancelToken.current = true; 55 | } 56 | }; 57 | document.addEventListener("keydown", cancelHandler); 58 | await Promise.all( 59 | tree.getSelectedItems().map((item) => item.expandAll(cancelToken)), 60 | ); 61 | document.removeEventListener("keydown", cancelHandler); 62 | }, 63 | }, 64 | 65 | collapseSelected: { 66 | hotkey: "Control+Shift+Minus", 67 | handler: (_, tree) => { 68 | tree.getSelectedItems().forEach((item) => item.collapseAll()); 69 | }, 70 | }, 71 | }, 72 | }; 73 | -------------------------------------------------------------------------------- /packages/core/src/features/expand-all/types.ts: -------------------------------------------------------------------------------- 1 | export interface ExpandAllDataRef {} 2 | 3 | export type ExpandAllFeatureDef = { 4 | state: {}; 5 | config: {}; 6 | treeInstance: { 7 | expandAll: (cancelToken?: { current: boolean }) => Promise; 8 | collapseAll: () => void; 9 | }; 10 | itemInstance: { 11 | expandAll: (cancelToken?: { current: boolean }) => Promise; 12 | collapseAll: () => void; 13 | }; 14 | hotkeys: "expandSelected" | "collapseSelected"; 15 | }; 16 | -------------------------------------------------------------------------------- /packages/core/src/features/hotkeys-core/types.ts: -------------------------------------------------------------------------------- 1 | import { CustomHotkeysConfig, TreeInstance } from "../../types/core"; 2 | 3 | export interface HotkeyConfig { 4 | hotkey: string; 5 | canRepeat?: boolean; 6 | allowWhenInputFocused?: boolean; 7 | isEnabled?: (tree: TreeInstance) => boolean; 8 | preventDefault?: boolean; 9 | handler: (e: KeyboardEvent, tree: TreeInstance) => void; 10 | } 11 | 12 | export interface HotkeysCoreDataRef { 13 | keydownHandler?: (e: KeyboardEvent) => void; 14 | keyupHandler?: (e: KeyboardEvent) => void; 15 | resetHandler?: (e: FocusEvent) => void; 16 | pressedKeys: Set; 17 | } 18 | 19 | export type HotkeysCoreFeatureDef = { 20 | state: {}; 21 | config: { 22 | hotkeys?: CustomHotkeysConfig; 23 | onTreeHotkey?: (name: string, e: KeyboardEvent) => void; 24 | 25 | /** Do not handle key inputs while an HTML input element is focused */ 26 | ignoreHotkeysOnInputs?: boolean; 27 | }; 28 | treeInstance: {}; 29 | itemInstance: {}; 30 | hotkeys: never; 31 | }; 32 | -------------------------------------------------------------------------------- /packages/core/src/features/keyboard-drag-and-drop/types.ts: -------------------------------------------------------------------------------- 1 | import { ItemInstance, SetStateFn } from "../../types/core"; 2 | 3 | export interface KDndDataRef { 4 | kDndDataTransfer: DataTransfer | undefined; 5 | } 6 | 7 | export enum AssistiveDndState { 8 | None, 9 | Started, 10 | Dragging, 11 | Completed, 12 | Aborted, 13 | } 14 | 15 | export type KeyboardDragAndDropFeatureDef = { 16 | state: { 17 | assistiveDndState?: AssistiveDndState | null; 18 | }; 19 | config: { 20 | setAssistiveDndState?: SetStateFn; 21 | onStartKeyboardDrag?: (items: ItemInstance[]) => void; 22 | }; 23 | treeInstance: { 24 | startKeyboardDrag: (items: ItemInstance[]) => void; 25 | startKeyboardDragOnForeignObject: (dataTransfer: DataTransfer) => void; 26 | stopKeyboardDrag: () => void; 27 | }; 28 | itemInstance: {}; 29 | hotkeys: "startDrag" | "cancelDrag" | "completeDrag" | "dragUp" | "dragDown"; 30 | }; 31 | -------------------------------------------------------------------------------- /packages/core/src/features/main/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FeatureImplementation, 3 | HotkeysConfig, 4 | ItemInstance, 5 | SetStateFn, 6 | TreeConfig, 7 | TreeInstance, 8 | TreeState, 9 | Updater, 10 | } from "../../types/core"; 11 | import { ItemMeta } from "../tree/types"; 12 | 13 | export type InstanceTypeMap = { 14 | itemInstance: ItemInstance; 15 | treeInstance: TreeInstance; 16 | }; 17 | 18 | export type InstanceBuilder = ( 19 | features: FeatureImplementation[], 20 | instanceType: T, 21 | buildOpts: (self: any) => any, 22 | ) => [instance: InstanceTypeMap[T], finalize: () => void]; 23 | 24 | export type MainFeatureDef = { 25 | state: {}; 26 | config: { 27 | features?: FeatureImplementation[]; 28 | initialState?: Partial>; 29 | state?: Partial>; 30 | setState?: SetStateFn>>; 31 | instanceBuilder?: InstanceBuilder; 32 | }; 33 | treeInstance: { 34 | /** @internal */ 35 | applySubStateUpdate: >( 36 | stateName: K, 37 | updater: Updater[K]>, 38 | ) => void; 39 | setState: SetStateFn>; 40 | getState: () => TreeState; 41 | setConfig: SetStateFn>; 42 | getConfig: () => TreeConfig; 43 | getItemInstance: (itemId: string) => ItemInstance; 44 | getItems: () => ItemInstance[]; 45 | registerElement: (element: HTMLElement | null) => void; 46 | getElement: () => HTMLElement | undefined | null; 47 | /** @internal */ 48 | getDataRef: () => { current: D }; 49 | /* @internal */ 50 | getHotkeyPresets: () => HotkeysConfig; 51 | rebuildTree: () => void; 52 | }; 53 | itemInstance: { 54 | registerElement: (element: HTMLElement | null) => void; 55 | getItemMeta: () => ItemMeta; 56 | getElement: () => HTMLElement | undefined | null; 57 | /** @internal */ 58 | getDataRef: () => { current: D }; 59 | }; 60 | hotkeys: never; 61 | }; 62 | -------------------------------------------------------------------------------- /packages/core/src/features/prop-memoization/feature.ts: -------------------------------------------------------------------------------- 1 | import { FeatureImplementation } from "../../types/core"; 2 | import { PropMemoizationDataRef } from "./types"; 3 | 4 | const memoize = ( 5 | props: Record, 6 | memoizedProps: Record, 7 | ) => { 8 | for (const key in props) { 9 | if (typeof props[key] === "function") { 10 | if (memoizedProps && key in memoizedProps) { 11 | props[key] = memoizedProps[key]; 12 | } else { 13 | memoizedProps[key] = props[key]; 14 | } 15 | } 16 | } 17 | return props; 18 | }; 19 | 20 | export const propMemoizationFeature: FeatureImplementation = { 21 | key: "prop-memoization", 22 | 23 | overwrites: [ 24 | "main", 25 | "async-data-loader", 26 | "sync-data-loader", 27 | "drag-and-drop", 28 | "expand-all", 29 | "hotkeys-core", 30 | "renaming", 31 | "search", 32 | "selection", 33 | ], 34 | 35 | treeInstance: { 36 | getContainerProps: ({ tree, prev }, treeLabel) => { 37 | const dataRef = tree.getDataRef(); 38 | const props = prev?.(treeLabel) ?? {}; 39 | dataRef.current.memo ??= {}; 40 | dataRef.current.memo.tree ??= {}; 41 | return memoize(props, dataRef.current.memo.tree); 42 | }, 43 | 44 | getSearchInputElementProps: ({ tree, prev }) => { 45 | const dataRef = tree.getDataRef(); 46 | const props = prev?.() ?? {}; 47 | dataRef.current.memo ??= {}; 48 | dataRef.current.memo.search ??= {}; 49 | return memoize(props, dataRef.current.memo.search); 50 | }, 51 | }, 52 | 53 | itemInstance: { 54 | getProps: ({ item, prev }) => { 55 | const dataRef = item.getDataRef(); 56 | const props = prev?.() ?? {}; 57 | dataRef.current.memo ??= {}; 58 | dataRef.current.memo.item ??= {}; 59 | return memoize(props, dataRef.current.memo.item); 60 | }, 61 | 62 | getRenameInputProps: ({ item, prev }) => { 63 | const dataRef = item.getDataRef(); 64 | const props = prev?.() ?? {}; 65 | dataRef.current.memo ??= {}; 66 | dataRef.current.memo.rename ??= {}; 67 | return memoize(props, dataRef.current.memo.rename); 68 | }, 69 | }, 70 | }; 71 | -------------------------------------------------------------------------------- /packages/core/src/features/prop-memoization/prop-memoization.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | import { TestTree } from "../../test-utils/test-tree"; 3 | import { propMemoizationFeature } from "./feature"; 4 | import { FeatureImplementation } from "../../types/core"; 5 | 6 | const itemHandler = vi.fn(); 7 | const treeHandler = vi.fn(); 8 | const createItemValue = vi.fn(); 9 | const createTreeValue = vi.fn(); 10 | 11 | const customFeature: FeatureImplementation = { 12 | itemInstance: { 13 | getProps: ({ prev }) => ({ 14 | ...prev?.(), 15 | customValue: createItemValue(), 16 | onCustomEvent: () => itemHandler(), 17 | }), 18 | }, 19 | treeInstance: { 20 | getContainerProps: ({ prev }, treeLabel) => ({ 21 | ...prev?.(treeLabel), 22 | customValue: createTreeValue(), 23 | onCustomEvent: () => treeHandler(), 24 | }), 25 | }, 26 | }; 27 | 28 | const factory = TestTree.default({}).withFeatures( 29 | customFeature, 30 | propMemoizationFeature, 31 | ); 32 | 33 | describe("core-feature/prop-memoization", () => { 34 | it("memoizes props", async () => { 35 | const tree = await factory.suits.sync().tree.createTestCaseTree(); 36 | createTreeValue.mockReturnValue(123); 37 | expect(tree.instance.getContainerProps().onCustomEvent).toBe( 38 | tree.instance.getContainerProps().onCustomEvent, 39 | ); 40 | expect(tree.instance.getContainerProps().customValue).toBe(123); 41 | expect(tree.instance.getContainerProps().customValue).toBe(123); 42 | }); 43 | factory.forSuits((tree) => { 44 | describe("tree props", () => { 45 | it("memoizes props", async () => { 46 | createTreeValue.mockReturnValue(123); 47 | expect(tree.instance.getContainerProps().onCustomEvent).toBe( 48 | tree.instance.getContainerProps().onCustomEvent, 49 | ); 50 | expect(tree.instance.getContainerProps().customValue).toBe(123); 51 | expect(tree.instance.getContainerProps().customValue).toBe(123); 52 | }); 53 | 54 | it("doesnt return stale values", async () => { 55 | createTreeValue.mockReturnValueOnce(123); 56 | createTreeValue.mockReturnValueOnce(456); 57 | expect(tree.instance.getContainerProps().customValue).toBe(123); 58 | expect(tree.instance.getContainerProps().customValue).toBe(456); 59 | }); 60 | 61 | it("propagates calls properly", async () => { 62 | tree.instance.getContainerProps().onCustomEvent(); 63 | tree.instance.getContainerProps().onCustomEvent(); 64 | expect(treeHandler).toHaveBeenCalledTimes(2); 65 | }); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /packages/core/src/features/prop-memoization/types.ts: -------------------------------------------------------------------------------- 1 | export interface PropMemoizationDataRef { 2 | memo?: { 3 | tree?: Record; 4 | item?: Record; 5 | search?: Record; 6 | rename?: Record; 7 | }; 8 | } 9 | 10 | export type PropMemoizationFeatureDef = { 11 | state: {}; 12 | config: {}; 13 | treeInstance: {}; 14 | itemInstance: {}; 15 | hotkeys: never; 16 | }; 17 | -------------------------------------------------------------------------------- /packages/core/src/features/renaming/types.ts: -------------------------------------------------------------------------------- 1 | import { ItemInstance, SetStateFn } from "../../types/core"; 2 | 3 | export type RenamingFeatureDef = { 4 | state: { 5 | renamingItem?: string | null; 6 | renamingValue?: string; 7 | }; 8 | config: { 9 | setRenamingItem?: SetStateFn; 10 | setRenamingValue?: SetStateFn; 11 | canRename?: (item: ItemInstance) => boolean; 12 | onRename?: (item: ItemInstance, value: string) => void; 13 | }; 14 | treeInstance: { 15 | getRenamingItem: () => ItemInstance | null; 16 | getRenamingValue: () => string; 17 | abortRenaming: () => void; 18 | completeRenaming: () => void; 19 | isRenamingItem: () => boolean; 20 | }; 21 | itemInstance: { 22 | getRenameInputProps: () => any; 23 | canRename: () => boolean; 24 | isRenaming: () => boolean; 25 | startRenaming: () => void; 26 | }; 27 | hotkeys: "renameItem" | "abortRenaming" | "completeRenaming"; 28 | }; 29 | -------------------------------------------------------------------------------- /packages/core/src/features/search/types.ts: -------------------------------------------------------------------------------- 1 | import { ItemInstance, SetStateFn } from "../../types/core"; 2 | import { HotkeysCoreDataRef } from "../hotkeys-core/types"; 3 | 4 | export interface SearchFeatureDataRef extends HotkeysCoreDataRef { 5 | matchingItems: ItemInstance[]; 6 | searchInput: HTMLInputElement | null; 7 | } 8 | 9 | export type SearchFeatureDef = { 10 | state: { 11 | search: string | null; 12 | }; 13 | config: { 14 | setSearch?: SetStateFn; 15 | onOpenSearch?: () => void; 16 | onCloseSearch?: () => void; 17 | isSearchMatchingItem?: (search: string, item: ItemInstance) => boolean; 18 | }; 19 | treeInstance: { 20 | setSearch: (search: string | null) => void; 21 | openSearch: (initialValue?: string) => void; 22 | closeSearch: () => void; 23 | isSearchOpen: () => boolean; 24 | getSearchValue: () => string; 25 | registerSearchInputElement: (element: HTMLInputElement | null) => void; // TODO remove 26 | getSearchInputElement: () => HTMLInputElement | null; 27 | getSearchInputElementProps: () => any; 28 | getSearchMatchingItems: () => ItemInstance[]; 29 | }; 30 | itemInstance: { 31 | isMatchingSearch: () => boolean; 32 | }; 33 | hotkeys: 34 | | "openSearch" 35 | | "closeSearch" 36 | | "submitSearch" 37 | | "nextSearchItem" 38 | | "previousSearchItem"; 39 | }; 40 | -------------------------------------------------------------------------------- /packages/core/src/features/selection/types.ts: -------------------------------------------------------------------------------- 1 | import { ItemInstance, SetStateFn } from "../../types/core"; 2 | 3 | export type SelectionFeatureDef = { 4 | state: { 5 | selectedItems: string[]; 6 | }; 7 | config: { 8 | setSelectedItems?: SetStateFn; 9 | }; 10 | treeInstance: { 11 | setSelectedItems: (selectedItems: string[]) => void; 12 | getSelectedItems: () => ItemInstance[]; 13 | }; 14 | itemInstance: { 15 | select: () => void; 16 | deselect: () => void; 17 | toggleSelect: () => void; 18 | isSelected: () => boolean; 19 | selectUpTo: (ctrl: boolean) => void; 20 | }; 21 | hotkeys: 22 | | "toggleSelectedItem" 23 | | "selectUpwards" 24 | | "selectDownwards" 25 | | "selectAll"; 26 | }; 27 | -------------------------------------------------------------------------------- /packages/core/src/features/sync-data-loader/feature.ts: -------------------------------------------------------------------------------- 1 | import { FeatureImplementation } from "../../types/core"; 2 | import { makeStateUpdater } from "../../utils"; 3 | import { throwError } from "../../utilities/errors"; 4 | 5 | const promiseErrorMessage = "sync dataLoader returned promise"; 6 | const unpromise = (data: T | Promise): T => { 7 | if (!data || (typeof data === "object" && "then" in data)) { 8 | throw throwError(promiseErrorMessage); 9 | } 10 | return data; 11 | }; 12 | 13 | export const syncDataLoaderFeature: FeatureImplementation = { 14 | key: "sync-data-loader", 15 | 16 | getInitialState: (initialState) => ({ 17 | loadingItemData: [], 18 | loadingItemChildrens: [], 19 | ...initialState, 20 | }), 21 | 22 | getDefaultConfig: (defaultConfig, tree) => ({ 23 | setLoadingItemData: makeStateUpdater("loadingItemData", tree), 24 | setLoadingItemChildrens: makeStateUpdater("loadingItemChildrens", tree), 25 | ...defaultConfig, 26 | }), 27 | 28 | stateHandlerNames: { 29 | loadingItemData: "setLoadingItemData", 30 | loadingItemChildrens: "setLoadingItemChildrens", 31 | }, 32 | 33 | treeInstance: { 34 | waitForItemDataLoaded: async () => {}, 35 | waitForItemChildrenLoaded: async () => {}, 36 | 37 | retrieveItemData: ({ tree }, itemId) => { 38 | return unpromise(tree.getConfig().dataLoader.getItem(itemId)); 39 | }, 40 | 41 | retrieveChildrenIds: ({ tree }, itemId) => { 42 | const { dataLoader } = tree.getConfig(); 43 | if ("getChildren" in dataLoader) { 44 | return unpromise(dataLoader.getChildren(itemId)); 45 | } 46 | return unpromise(dataLoader.getChildrenWithData(itemId)).map( 47 | (c) => c.data, 48 | ); 49 | }, 50 | 51 | loadItemData: ({ tree }, itemId) => tree.retrieveItemData(itemId), 52 | loadChildrenIds: ({ tree }, itemId) => tree.retrieveChildrenIds(itemId), 53 | }, 54 | 55 | itemInstance: { 56 | isLoading: () => false, 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /packages/core/src/features/sync-data-loader/types.ts: -------------------------------------------------------------------------------- 1 | export type TreeDataLoader = 2 | | { 3 | getItem: (itemId: string) => T | Promise; 4 | getChildren: (itemId: string) => string[] | Promise; 5 | } 6 | | { 7 | getItem: (itemId: string) => T | Promise; 8 | getChildrenWithData: ( 9 | itemId: string, 10 | ) => { id: string; data: T }[] | Promise<{ id: string; data: T }[]>; 11 | }; 12 | 13 | export type SyncDataLoaderFeatureDef = { 14 | state: {}; 15 | config: { 16 | rootItemId: string; 17 | dataLoader: TreeDataLoader; 18 | }; 19 | treeInstance: { 20 | retrieveItemData: (itemId: string) => T; 21 | retrieveChildrenIds: (itemId: string) => string[]; 22 | }; 23 | itemInstance: { 24 | isLoading: () => boolean; 25 | }; 26 | hotkeys: never; 27 | }; 28 | -------------------------------------------------------------------------------- /packages/core/src/features/tree/types.ts: -------------------------------------------------------------------------------- 1 | import { ItemInstance, SetStateFn, TreeInstance } from "../../types/core"; 2 | 3 | export interface ItemMeta { 4 | itemId: string; 5 | parentId: string; 6 | level: number; 7 | index: number; 8 | setSize: number; 9 | posInSet: number; 10 | } 11 | 12 | export interface TreeItemDataRef { 13 | memoizedValues: Record; 14 | memoizedDeps: Record; 15 | } 16 | 17 | export type TreeFeatureDef = { 18 | state: { 19 | expandedItems: string[]; 20 | focusedItem: string | null; 21 | }; 22 | config: { 23 | isItemFolder: (item: ItemInstance) => boolean; 24 | getItemName: (item: ItemInstance) => string; 25 | 26 | onPrimaryAction?: (item: ItemInstance) => void; 27 | scrollToItem?: (item: ItemInstance) => void; 28 | 29 | setExpandedItems?: SetStateFn; 30 | setFocusedItem?: SetStateFn; 31 | }; 32 | treeInstance: { 33 | /** @internal */ 34 | getItemsMeta: () => ItemMeta[]; 35 | 36 | getFocusedItem: () => ItemInstance; 37 | focusNextItem: () => void; 38 | focusPreviousItem: () => void; 39 | updateDomFocus: () => void; 40 | 41 | /** Pass to the container rendering the tree children. The `treeLabel` parameter 42 | * will be passed as `aria-label` parameter, and is recommended to be set. */ 43 | getContainerProps: (treeLabel?: string) => Record; 44 | }; 45 | itemInstance: { 46 | getId: () => string; 47 | getProps: () => Record; 48 | getItemName: () => string; 49 | getItemData: () => T; 50 | equals: (other?: ItemInstance | null) => boolean; 51 | expand: () => void; 52 | collapse: () => void; 53 | isExpanded: () => boolean; 54 | isDescendentOf: (parentId: string) => boolean; 55 | isFocused: () => boolean; 56 | isFolder: () => boolean; 57 | setFocused: () => void; 58 | getParent: () => ItemInstance | undefined; 59 | getChildren: () => ItemInstance[]; 60 | getIndexInParent: () => number; 61 | primaryAction: () => void; 62 | getTree: () => TreeInstance; 63 | getItemAbove: () => ItemInstance | undefined; 64 | getItemBelow: () => ItemInstance | undefined; 65 | scrollTo: ( 66 | scrollIntoViewArg?: boolean | ScrollIntoViewOptions, 67 | ) => Promise; 68 | }; 69 | hotkeys: 70 | | "focusNextItem" 71 | | "focusPreviousItem" 72 | | "expandOrDown" 73 | | "collapseOrUp" 74 | | "focusFirstItem" 75 | | "focusLastItem"; 76 | }; 77 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types/core"; 2 | export * from "./core/create-tree"; 3 | 4 | export * from "./features/tree/types"; 5 | export { MainFeatureDef, InstanceBuilder } from "./features/main/types"; 6 | export * from "./features/drag-and-drop/types"; 7 | export * from "./features/keyboard-drag-and-drop/types"; 8 | export * from "./features/selection/types"; 9 | export * from "./features/async-data-loader/types"; 10 | export * from "./features/sync-data-loader/types"; 11 | export * from "./features/hotkeys-core/types"; 12 | export * from "./features/search/types"; 13 | export * from "./features/renaming/types"; 14 | export * from "./features/expand-all/types"; 15 | export * from "./features/prop-memoization/types"; 16 | 17 | export * from "./features/selection/feature"; 18 | export * from "./features/hotkeys-core/feature"; 19 | export * from "./features/async-data-loader/feature"; 20 | export * from "./features/sync-data-loader/feature"; 21 | export * from "./features/drag-and-drop/feature"; 22 | export * from "./features/keyboard-drag-and-drop/feature"; 23 | export * from "./features/search/feature"; 24 | export * from "./features/renaming/feature"; 25 | export * from "./features/expand-all/feature"; 26 | export * from "./features/prop-memoization/feature"; 27 | 28 | export * from "./utilities/create-on-drop-handler"; 29 | export * from "./utilities/insert-items-at-target"; 30 | export * from "./utilities/remove-items-from-parents"; 31 | 32 | export * from "./core/build-proxified-instance"; 33 | export * from "./core/build-static-instance"; 34 | -------------------------------------------------------------------------------- /packages/core/src/types/deep-merge.ts: -------------------------------------------------------------------------------- 1 | type TAllKeys = T extends any ? keyof T : never; 2 | 3 | type TIndexValue = T extends any 4 | ? K extends keyof T 5 | ? T[K] 6 | : D 7 | : never; 8 | 9 | type TPartialKeys = Omit & 10 | Partial> extends infer O 11 | ? { [P in keyof O]: O[P] } 12 | : never; 13 | 14 | type TFunction = (...a: any[]) => any; 15 | 16 | type TPrimitives = 17 | | string 18 | | number 19 | | boolean 20 | | bigint 21 | | symbol 22 | | Date 23 | | TFunction; 24 | 25 | export type TMerged = [T] extends [Array] 26 | ? { [K in keyof T]: TMerged } 27 | : [T] extends [TPrimitives] 28 | ? T 29 | : [T] extends [object] 30 | ? TPartialKeys<{ [K in TAllKeys]: TMerged> }, never> 31 | : T; 32 | -------------------------------------------------------------------------------- /packages/core/src/utilities/create-on-drop-handler.ts: -------------------------------------------------------------------------------- 1 | import { ItemInstance } from "../types/core"; 2 | import { DragTarget } from "../features/drag-and-drop/types"; 3 | import { removeItemsFromParents } from "./remove-items-from-parents"; 4 | import { insertItemsAtTarget } from "./insert-items-at-target"; 5 | 6 | export const createOnDropHandler = 7 | ( 8 | onChangeChildren: (item: ItemInstance, newChildren: string[]) => void, 9 | ) => 10 | async (items: ItemInstance[], target: DragTarget) => { 11 | const itemIds = items.map((item) => item.getId()); 12 | await removeItemsFromParents(items, onChangeChildren); 13 | await insertItemsAtTarget(itemIds, target, onChangeChildren); 14 | }; 15 | -------------------------------------------------------------------------------- /packages/core/src/utilities/errors.ts: -------------------------------------------------------------------------------- 1 | const prefix = "Headless Tree: "; 2 | 3 | export const throwError = (message: string) => Error(prefix + message); 4 | 5 | // eslint-disable-next-line no-console 6 | export const logWarning = (message: string) => console.warn(prefix + message); 7 | -------------------------------------------------------------------------------- /packages/core/src/utilities/insert-items-at-target.ts: -------------------------------------------------------------------------------- 1 | import { ItemInstance } from "../types/core"; 2 | import { DragTarget } from "../features/drag-and-drop/types"; 3 | 4 | export const insertItemsAtTarget = async ( 5 | itemIds: string[], 6 | target: DragTarget, 7 | onChangeChildren: ( 8 | item: ItemInstance, 9 | newChildrenIds: string[], 10 | ) => Promise | void, 11 | ) => { 12 | await target.item.getTree().waitForItemChildrenLoaded(target.item.getId()); 13 | const oldChildrenIds = target.item 14 | .getTree() 15 | .retrieveChildrenIds(target.item.getId()); 16 | 17 | // add moved items to new common parent, if dropped onto parent 18 | if (!("childIndex" in target)) { 19 | const newChildren = [...oldChildrenIds, ...itemIds]; 20 | await onChangeChildren(target.item, newChildren); 21 | if (target.item && "updateCachedChildrenIds" in target.item) { 22 | target.item.updateCachedChildrenIds(newChildren); 23 | } 24 | target.item.getTree().rebuildTree(); 25 | return; 26 | } 27 | 28 | // add moved items to new common parent, if dropped between siblings 29 | const newChildren = [ 30 | ...oldChildrenIds.slice(0, target.insertionIndex), 31 | ...itemIds, 32 | ...oldChildrenIds.slice(target.insertionIndex), 33 | ]; 34 | 35 | await onChangeChildren(target.item, newChildren); 36 | 37 | if (target.item && "updateCachedChildrenIds" in target.item) { 38 | target.item.updateCachedChildrenIds(newChildren); 39 | } 40 | target.item.getTree().rebuildTree(); 41 | }; 42 | -------------------------------------------------------------------------------- /packages/core/src/utilities/remove-items-from-parents.ts: -------------------------------------------------------------------------------- 1 | import { ItemInstance } from "../types/core"; 2 | 3 | export const removeItemsFromParents = async ( 4 | movedItems: ItemInstance[], 5 | onChangeChildren: ( 6 | item: ItemInstance, 7 | newChildrenIds: string[], 8 | ) => void | Promise, 9 | ) => { 10 | const movedItemsIds = movedItems.map((item) => item.getId()); 11 | const uniqueParents = [ 12 | ...new Set(movedItems.map((item) => item.getParent())), 13 | ]; 14 | 15 | for (const parent of uniqueParents) { 16 | const siblings = parent?.getChildren(); 17 | if (siblings && parent) { 18 | const newChildren = siblings 19 | .filter((sibling) => !movedItemsIds.includes(sibling.getId())) 20 | .map((i) => i.getId()); 21 | await onChangeChildren(parent, newChildren); 22 | if (parent && "updateCachedChildrenIds" in parent) { 23 | parent?.updateCachedChildrenIds(newChildren); 24 | } 25 | } 26 | } 27 | 28 | movedItems[0].getTree().rebuildTree(); 29 | }; 30 | -------------------------------------------------------------------------------- /packages/core/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { SetStateFn, TreeState, Updater } from "./types/core"; 2 | 3 | export type NoInfer = [T][T extends any ? 0 : never]; 4 | 5 | export const memo = ( 6 | deps: (...args: [...P]) => [...D], 7 | fn: (...args: [...D]) => R, 8 | ) => { 9 | let value: R | undefined; 10 | let oldDeps: D | null = null; 11 | 12 | return (...a: [...P]) => { 13 | const newDeps = deps(...a); 14 | 15 | if (!value) { 16 | value = fn(...newDeps); 17 | oldDeps = newDeps; 18 | return value; 19 | } 20 | 21 | const match = 22 | oldDeps && 23 | oldDeps.length === newDeps.length && 24 | !oldDeps.some((dep, i) => dep !== newDeps[i]); 25 | 26 | if (match) { 27 | return value; 28 | } 29 | 30 | value = fn(...newDeps); 31 | oldDeps = newDeps; 32 | return value; 33 | }; 34 | }; 35 | 36 | export function functionalUpdate(updater: Updater, input: T): T { 37 | return typeof updater === "function" 38 | ? (updater as (input: T) => T)(input) 39 | : updater; 40 | } 41 | export function makeStateUpdater>( 42 | key: K, 43 | instance: unknown, 44 | ): SetStateFn[K]> { 45 | return (updater: Updater[K]>) => { 46 | (instance as any).setState((old: TTableState) => { 47 | return { 48 | ...old, 49 | [key]: functionalUpdate(updater, (old as any)[key]), 50 | }; 51 | }); 52 | }; 53 | } 54 | 55 | export const poll = (fn: () => boolean, interval = 100, timeout = 1000) => 56 | new Promise((resolve) => { 57 | let clear: ReturnType; 58 | const i = setInterval(() => { 59 | if (fn()) { 60 | resolve(); 61 | clearInterval(i); 62 | clearTimeout(clear); 63 | } 64 | }, interval); 65 | clear = setTimeout(() => { 66 | clearInterval(i); 67 | }, timeout); 68 | }); 69 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["./src/**/*"], 4 | "exclude": ["./src/**/*.spec.tsx", "./src/**/*.spec.ts", "./src/**/*.stories.tsx"], 5 | "compilerOptions": { 6 | "outDir": "lib/esm" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/core/typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../typedoc.base.json"], 3 | "entryPoints": ["src/mddocs-entry.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/core/vitest.config.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | test: {}, 6 | }); 7 | -------------------------------------------------------------------------------- /packages/docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | docs/6-changelog/*.md 23 | docs/5-contributing/1-overview.mdx 24 | 25 | static/llm-full.txt 26 | static/llms-full.txt 27 | static/llm.txt 28 | static/llms.txt 29 | static/llm 30 | -------------------------------------------------------------------------------- /packages/docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @headless-tree/docs 2 | 3 | ## 0.0.4 4 | 5 | ### Patch Changes 6 | 7 | - 8ba6e91: The Headless Tree documentation site now contains Angolia-powered search 8 | 9 | ## 0.0.3 10 | 11 | ### Patch Changes 12 | 13 | - 7ed33ac: dev release 14 | 15 | ## 0.0.2 16 | 17 | ### Patch Changes 18 | 19 | - 520ec27: test release 20 | 21 | ## 0.0.1 22 | 23 | ### Patch Changes 24 | 25 | - ab2a124: dev release 26 | -------------------------------------------------------------------------------- /packages/docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /packages/docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /packages/docs/docs/1-guides/3-styling.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | slug: "/guides/styling" 3 | title: "Styling" 4 | subtitle: "Customize the appearance of your tree" 5 | category: guide 6 | --- 7 | 8 | import { DemoBox } from "../../src/components/demo/demo-box"; 9 | 10 | # Styling 11 | 12 | Headless Tree is designed to be as unopinionated as possible when it comes to styling. This means that both 13 | rendering and styling is generally up to you. The tree does not come with any default styles, and you can 14 | use whatever styling framework you have already set up in your project. 15 | 16 | Headless Tree provides several methods on the [item instance variable](/api/core/interface/ItemInstance) that you can use to derive information 17 | about each item and how it should be styled, such as 18 | 19 | - [`isFolder()`](/api/core/interface/ItemInstance#isFolder) 20 | - [`isExpanded()`](/api/core/interface/ItemInstance#isExpanded) 21 | - [`isSelected()`](/api/core/interface/ItemInstance#isSelected) 22 | - [`isFocused()`](/api/core/interface/ItemInstance#isFocused) 23 | - [`isDragTarget()`](/api/core/interface/ItemInstance#isDragTarget) 24 | - [`isDraggingOver()`](/api/core/interface/ItemInstance#isDraggingOver) 25 | - [`isRenaming()`](/api/core/interface/ItemInstance#isRenaming) 26 | - [`isLoading()`](/api/core/interface/ItemInstance#isLoading) 27 | - [`isMatchingSearch()`](/api/core/interface/ItemInstance#isMatchingSearch) 28 | 29 | In the following example, and default styles that are typically used in other samples are omitted, 30 | and just some basic inline styles are used to demonstrate how HT looks without styling: 31 | 32 | 33 | 34 | If you want to use the default styles that are used in other samples, you can copy their CSS code 35 | from here: 36 | 37 | - https://github.com/lukasbach/headless-tree/blob/main/packages/sb-react/.storybook/style.css 38 | -------------------------------------------------------------------------------- /packages/docs/docs/1-guides/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Guides", 3 | "position": 2 4 | } 5 | -------------------------------------------------------------------------------- /packages/docs/docs/2-features/02-sync-dataloader.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | slug: "/features/sync-dataloader" 3 | title: "Sync Data Loader" 4 | subtitle: "Data interface for synchronous data sources" 5 | hide_title: true 6 | --- 7 | 8 | import { DemoBox } from "../../src/components/demo/demo-box"; 9 | import { FeaturePageHeader } from "../../src/components/docs-page/feature-page-header"; 10 | 11 | 16 | 17 | 18 | 19 | When using Headless Tree, you need to provide an interface to read your data. The most straight-forward way 20 | to do this is using the Sync Data Loader Feature. Alternatively, you can use the [Async Data Loader Feature](/features/async-dataloader) 21 | if you are dealing with asynchronous data sources. In both cases, you need to include the respective feature 22 | explicitly. 23 | 24 | When using the Sync Data Loader Feature, you need to provide a `dataLoader` property in your tree config 25 | which implements the [`TreeDataLoader`](/api/core#TreeDataLoader) interface. 26 | 27 | ```ts 28 | const tree = useTree({ 29 | rootItemId: "root-item", 30 | getItemName: (item) => item.getItemData().itemName, 31 | isItemFolder: (item) => item.isFolder, 32 | dataLoader: { 33 | getItem: (itemId) => myDataStructure[itemId], 34 | getChildren: (itemId) => myDataStructure[itemId].childrenIds, 35 | }, 36 | features: [ syncDataLoaderFeature ], 37 | }); 38 | ``` 39 | 40 | Note that Headless Tree may call `getItem` and `getChildren` multiple times for the same item, and also during each 41 | render. Therefore, you should make sure that these functions are fast and do not perform any expensive operations. 42 | 43 | Asynchronous data providers on the other hand provide caching out of the box, and only call those functions once 44 | per item until their data are explicitly invalidated. If your data source provides an synchronous, yet expensive 45 | interface, you can still use the [Async Data Loader](/features/async-dataloader) instead. 46 | 47 | :::warning 48 | 49 | You should implement the `dataLoader.getItem` and `dataLoader.getChildren` functions so that they return 50 | synchronously. If you need to fetch data asynchronously, you should use the [Async Data Loader](/features/async-dataloader) 51 | instead. 52 | 53 | ::: -------------------------------------------------------------------------------- /packages/docs/docs/2-features/04-selection.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | slug: "/features/selection" 3 | title: "Selections" 4 | subtitle: "Support for selecting multiple items" 5 | hide_title: true 6 | --- 7 | 8 | import { DemoBox } from "../../src/components/demo/demo-box"; 9 | import {FeaturePageHeader} from "../../src/components/docs-page/feature-page-header"; 10 | 11 | 16 | 17 | 18 | The selection feature provides the ability of multiselect, allowing users to select multiple items at once. 19 | Without it, Headless Tree just allows users to focus a single item, and act on it through that. 20 | This feature is particularly useful in combination with the [drag-and-drop](/features/dnd) feature, as it allows users to 21 | select multiple items and drag them all at once. 22 | 23 | By default, Headless Tree will maintain the selected items in its internal state. If a `setState` or `setSelectedItems` 24 | function is provided in the tree configuration, you can manage the focused item yourself (see [Managing State](/guides/state)). 25 | 26 | Call `item.select()`, `item.deselect()` or `item.toggleSelection()` to change the selection state of an item. The feature 27 | will also add behavior to the `onClick` handler provided as prop for each of the tree items for ctrl-clicking and 28 | shift-clicking to select multiple items at once. -------------------------------------------------------------------------------- /packages/docs/docs/2-features/05-dnd.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | slug: "/features/dnd" 3 | title: "Drag and Drop" 4 | subtitle: "Drag-and-drop Capabilities for tree items and external drag objects" 5 | hide_title: true 6 | --- 7 | 8 | import { DemoBox } from "../../src/components/demo/demo-box"; 9 | import {FeaturePageHeader} from "../../src/components/docs-page/feature-page-header"; 10 | 11 | 16 | 17 | 18 | 19 | 20 | The Drag-And-Drop Feature provides drag-and-drop capabilities. It allows to drag a single tree item (or many of the 21 | [selection feature](/features/selection) is included in the tree config) and drop it somewhere else in the tree. 22 | The feature also allows you to create interactions between the tree and external drag objects, allowing you to drag 23 | tree items out of the tree, or foreign data objects from outside the tree inside. As extension of that, this can 24 | also be used to implement drag behavior between several trees. 25 | 26 | Since this feature composes a large part of the functionality of Headless Tree, it is documented in its own section 27 | "Drag and Drop" on the left, get started with the [Drag and Drop Overview Page](/dnd/overview). 28 | -------------------------------------------------------------------------------- /packages/docs/docs/2-features/07-hotkeys.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | slug: "/features/hotkeys" 3 | title: "Hotkeys" 4 | subtitle: "Support for custom and predefined hotkeys to navigate and interact with the tree" 5 | hide_title: true 6 | --- 7 | 8 | import { DemoBox } from "../../src/components/demo/demo-box"; 9 | import { FeaturePageHeader } from "../../src/components/docs-page/feature-page-header"; 10 | 11 | 16 | 17 | 18 | 19 | The Hotkeys Core feature is necessary for any keyboard-based interaction via hotkeys to work. 20 | Each of the features define their own hotkeys configurations (e.g. the Selection Feature provides the `selectAll` hotkey 21 | defaulting to `Ctrl+A`), which can be customized or extended in the tree configuration. 22 | However, only the Hotkeys Core feature implements the logic to listen for and handle keyboard events, and 23 | is necessary for any hotkeys of other features to work. 24 | 25 | The feature also makes it very easy to add custom hotkeys with arbitrary keybindings and implementations. 26 | 27 | Look into the [Guide on Hotkeys](/guides/hotkeys) for details on how to use the Hotkeys Core Feature, and how to 28 | overwrite or add custom hotkeys. 29 | 30 | The [Accessibility Guide](/guides/accessibility#hotkeys) shows some demos of which hotkeys are available and how they can be used. -------------------------------------------------------------------------------- /packages/docs/docs/2-features/08-search.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | slug: "/features/search" 3 | title: "Tree Search" 4 | subtitle: "Searching for items in the tree by typing in a search query" 5 | hide_title: true 6 | --- 7 | 8 | import { DemoBox } from "../../src/components/demo/demo-box"; 9 | import { FeaturePageHeader } from "../../src/components/docs-page/feature-page-header"; 10 | 11 | 16 | 17 | 18 | 19 | The Search feature provides the functionality for users to quickly find a certain item by typing 20 | a part of its name. This fulfills the accessibility feature of a typeahead search, and is particularly 21 | useful in large trees where the user might not be able to find the item they are looking for by 22 | scrolling through the tree, while also making its use a bit more obvious by showing the search 23 | input field. This is consistent with similar tree implementations such as the file tree in JetBrains IDEs, 24 | where typing in the tree will show a search input field. 25 | 26 | The items will not be shown in a filtered view, but instead search matches will be visually highlighted, 27 | the tree will be scrolled and focused to the first search match, and navigating through the items with 28 | up or down arrow keys will move the focus only between matched items. 29 | 30 | The search input field will automatically be opened when the user starts typing while focusing the tree, 31 | but it can also be opened programmatically by calling `tree.openSearch()`. Blurring the input field, pressing 32 | Escape (if the Hotkeys Core Feature is included), or invoking `tree.closeSearch()` will close the search. 33 | `tree.isSearchOpen()` can be used to check if the search input field is currently open and should be rendered. 34 | 35 | Similar to the remaining tree integration, it is up to the library consumer to render the search input field 36 | correctly. Pass `tree.getSearchInputElementProps()` as props to the search input to hook it up with change 37 | handlers and register its element with Headless Tree. 38 | 39 | ```jsx 40 | {tree.isSearchOpen() && ( 41 | <> 42 | 43 | ({tree.getSearchMatchingItems().length} matches) 44 | 45 | )} 46 | ``` 47 | 48 | Then, use `item.isMatchingSearch()` to determine if an item is currently a search match, and style it accordingly. 49 | -------------------------------------------------------------------------------- /packages/docs/docs/2-features/09-renaming.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | slug: "/features/renaming" 3 | title: "Renaming" 4 | subtitle: "Rename items in the tree" 5 | hide_title: true 6 | --- 7 | 8 | import { DemoBox } from "../../src/components/demo/demo-box"; 9 | import { FeaturePageHeader } from "../../src/components/docs-page/feature-page-header"; 10 | 11 | 16 | 17 | 18 | 19 | The renaming feature allows you to allow users to rename items in the tree. They can either start renaming 20 | an item via the `renameItem` hotkey (defaulting to F2), or when the `item.startRenaming()` method is called. 21 | You can hook this up to e.g. double-clicking an item-name or to a context menu option. 22 | 23 | The feature exposes two state variables, `renamingItem` to keep track of the item that is currently being renamed, 24 | and `renamingValue` to keep track of the new name that the user is typing in. When 25 | [maintaining individual states](/guides/state#managing-individual-feature-states), you can hook into changes to 26 | those states via the `setRenamingItem` and `setRenamingValue` config methods. 27 | 28 | ## Rendering the Rename Input 29 | 30 | Similar to other Headless Tree Features, the library only provides the functionality, but not the UI. For any tree 31 | item, you can determine whether it should render a renaming behavior with `item.isRenaming()`. Make sure to 32 | pass `item.getRenameInputProps()` to the input element to hook up the renaming behavior to the input field. 33 | 34 | ```jsx 35 | if (item.isRenaming()) { 36 | return ( 37 | 38 | ); 39 | } 40 | 41 | // otherwise, render the item as usual 42 | ``` 43 | 44 | ## Customizing which items can be renamed 45 | 46 | You can also customize which items can be renamed by providing a `canRename` function in the tree configuration. 47 | 48 | -------------------------------------------------------------------------------- /packages/docs/docs/2-features/10-expandall.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | slug: "/features/expandall" 3 | title: "Expand all" 4 | subtitle: "API for expanding or collapsing all items with a single click" 5 | hide_title: true 6 | --- 7 | 8 | import { DemoBox } from "../../src/components/demo/demo-box"; 9 | import { FeaturePageHeader } from "../../src/components/docs-page/feature-page-header"; 10 | 11 | 16 | 17 | 18 | 19 | The expand-all feature exposes methods both on any item instance and the tree instance to expand or collapse 20 | all of its items. When called on the tree instance, it will expand or collapse all items in the tree, otherwise 21 | it will only affect all children of the item. 22 | 23 | When used in conjunction with the [async data loader](/features/async-dataloader), the expand-all feature will 24 | wait for children to load in during each step, before triggering the expanding of the next level. You can also 25 | programmatically abort the expanding process at any time when this happens. When calling the `expandAll` method, 26 | you can pass an object with a `current` variable. Setting this to `false` during the expanding progress will 27 | cancel the expanding process while it is happening. 28 | 29 | ```ts 30 | const cancelToken = { current: false }; 31 | 32 | const expand = () => { 33 | cancelToken.current = false; 34 | tree.expandAll(cancelToken); 35 | }; 36 | const cancelExpanding = () => { 37 | cancelToken.current = true; 38 | }; 39 | ``` -------------------------------------------------------------------------------- /packages/docs/docs/2-features/11-prop-memoization.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | slug: "/features/propmemoization" 3 | title: "Prop Memoization" 4 | subtitle: "Feature making all props created by HT consistent and useable for memoization" 5 | hide_title: true 6 | --- 7 | 8 | import { FeaturePageHeader } from "../../src/components/docs-page/feature-page-header"; 9 | import {DemoBox} from "../../src/components/demo/demo-box"; 10 | 11 | 16 | 17 | By default, React props generated by `tree.getContainerProps()` and `item.getProps()` will not 18 | be memoized, and might change their reference during each render as they are generated fresh 19 | each time. 20 | 21 | If you rely on stable props, or need them to be memoized, you can simply include the `propMemoizationFeature` 22 | and the props will be memoized automatically. The feature doesn't require any configuration or expose any 23 | methods. 24 | 25 | ## Example 26 | 27 | Consider this sample, where the render method of each item is intentionally slowed down, resulting 28 | in a slow usage experience when expanding or collapsing items: 29 | 30 | 31 | 32 | Here, the render method is memoized and the propMemoizationFeature is included. Tree items still 33 | render slowly during the initial render, but items that are unchanged during rerender do not retrigger 34 | the slow render function, resulting in a faster experience when expanding or collapsing items: 35 | 36 | -------------------------------------------------------------------------------- /packages/docs/docs/2-features/99-main.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | slug: "/features/main" 3 | title: "Main Feature" 4 | subtitle: "Core Features related to Headless-Tree" 5 | hide_title: true 6 | --- 7 | 8 | import { DemoBox } from "../../src/components/demo/demo-box"; 9 | import { FeaturePageHeader } from "../../src/components/docs-page/feature-page-header"; 10 | 11 | 17 | 18 | 19 | 20 | The main feature provides the core functionality of Headless Tree, enabling most functions that are 21 | required by other features to work. Similar to the [Tree Feature](/features/tree), the main feature 22 | is included automatically and does not need to be actively imported. -------------------------------------------------------------------------------- /packages/docs/docs/2-features/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Features", 3 | "position": 3 4 | } 5 | -------------------------------------------------------------------------------- /packages/docs/docs/3-dnd/3-customizability.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | slug: "/dnd/customizability" 3 | title: "Customizability" 4 | category: draganddrop 5 | --- 6 | 7 | import { DemoBox } from "../../src/components/demo/demo-box"; 8 | 9 | # Customizability 10 | 11 | The Drag and Drop Feature provides several options to customize the behavior of the drag-and-drop interaction, 12 | including several ways to restrict what and when users can drag and drop items. 13 | 14 | ## No reordering 15 | 16 | Set the config option `canReorder` to false, to disable users from being able to choose arbitrary drop locations 17 | within a specific item. Drag lines will not be rendered, and can be omitted from the render implementation. 18 | Dragging an item between several items will either always target the specific item that is hovered over as new parent, 19 | or the parent of the item that is currently being hovered over if the direct hover target is not a folder. 20 | 21 | The [Drop event described earlier](/dnd/overview#the-drop-event) will always contain only an item as target, 22 | never `childIndex`, `insertionIndex`, `dragLineIndex` and `dragLineLevel`. 23 | 24 | 25 | 26 | ## Limiting which items can be dragged 27 | 28 | Implement a [`canDrag`](/api/core/interface/DragAndDropFeatureConfig#canDrag) 29 | handler that specifies which items can be dragged. This will be called everytime the user 30 | tries to start dragging items, and will pass the items to drag as parameter. Returning false in the handler 31 | will prevent the drag from starting. 32 | 33 | 34 | 35 | ## Limiting where users can drop items 36 | 37 | Similarly, implement a [`canDrop`](/api/core/interface/DragAndDropFeatureConfig#canDrop) 38 | handler that specifies where items can be dropped. This will be called everytime 39 | the user drags items over a potential drop target (not on every single mousemove event, just when a different drop 40 | target is hovered on), and will pass the items to drop and the target as parameters. Returning false in the handler 41 | will prevent the drop from finalizing and calling the `onDrop` method, as well as visually indicating that the 42 | drop is not allowed at that location. 43 | 44 | Note that this doesn't concern [dragging foreign data inside](/dnd/foreign-dnd#dragging-foreign-objects-inside-of-tree), 45 | which can be controlled with [`canDropForeignDragObject`](/api/core/interface/DragAndDropFeatureConfig#canDropForeignDragObject) 46 | instead. 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /packages/docs/docs/3-dnd/4-behavior.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | slug: "/dnd/behavior" 3 | title: "Details on Dnd Behavior" 4 | category: draganddrop 5 | --- 6 | 7 | ## Reparenting 8 | 9 | For items dragged on the lower half of the bottom-most item of a tree, the specific 10 | target folder is chosen based on horizontal offset of the dragged item. This is called 11 | "Reparenting". 12 | 13 | 14 | ![Drag and Drop Reparenting Demo](../../static/img/ht-dnd-reparenting.gif) 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /packages/docs/docs/3-dnd/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Drag and Drop", 3 | "position": 4 4 | } 5 | -------------------------------------------------------------------------------- /packages/docs/docs/4-recipes/1-handling-expensive-components.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | slug: "/recipe/handling-expensive-components" 3 | title: "Handling expensive Components" 4 | subtitle: "How to use proper memoization to use expensive tree-item components" 5 | category: recipe 6 | --- 7 | 8 | import { DemoBox } from "../../src/components/demo/demo-box"; 9 | 10 | Sometimes, rendering tree items can be expensive. This can cause slow usage of the Tree react component, 11 | since all items are rendered as part of a single flat list, and mutations to the displayed tree structure, 12 | such as expanding or collapsing items or changing the tree will trigger a re-render of all items. 13 | 14 | 15 | 16 | Memoizing the individual tree item render methods can help reduce the performance impact of rendering 17 | expensive tree items. This can be done using the `React.memo` function, which will only re-render the 18 | component if the props have changed. 19 | 20 | `React.memo` will only rerender its contents if any of its props have changed. Headless Tree doesn't 21 | memoize props generated with `tree.getContainerProps()` and `item.getProps()` by default, and will 22 | create new props during each render. By including the [Prop Memoization Feature](/features/propmemoization) 23 | however, these props will be memoized automatically, making memoization of render items feasible. 24 | 25 | Note that, in the sample below, expanding or collapsing individual items is much more efficient than 26 | in the sample above, since now only those items that actually change will be rerendered. 27 | 28 | 29 | 30 | You can further improve performance on large trees by making use of Virtualization, which is explained in 31 | the [Virtualization Guide](/recipe/virtualization). 32 | -------------------------------------------------------------------------------- /packages/docs/docs/4-recipes/2-virtualization.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | slug: "/recipe/virtualization" 3 | title: "Virtualization" 4 | subtitle: "Integrate virtualization into your tree to support many items" 5 | category: recipe 6 | --- 7 | 8 | import { DemoBox } from "../../src/components/demo/demo-box"; 9 | 10 | Large trees can have a significant impact on render and usage performance, and cause slow 11 | interactions. Virtualization is a concept, where the performance hit of a very large list of items 12 | is mitigated by only rendering the items that are currently visible in the viewport. 13 | 14 | While this is not trivial to do with nested structures like trees, Headless Tree makes this 15 | easy by flattening the tree structure and providing the tree items as flat list. Virtualization 16 | is not a included feature of Headless Tree, but you can easily pass this flat list to any virtualization 17 | library of your choice, and use that to create a tree that only renders the visible items. 18 | 19 | In the sample below, `react-virtual` is used to virtualize the tree and render 100k items while 20 | still being performant in rendering and interaction. 21 | 22 | 23 | 24 | :::warning 25 | 26 | You likely will want to use proxified item instances instead of static item instances when 27 | using trees with many items. Read this guide to learn more about [Proxy Item Instances](/recipe/proxy-instances). 28 | You can use them by setting the `instanceBuilder` tree config option to [`buildProxiedInstance`](/api/core/function/buildProxiedInstance), 29 | a symbol that you can import from `@headless-tree/core`. 30 | 31 | ::: 32 | 33 | If you need to need a ref to the virtualized DOM items, keep in mind that `treeItem.getProps()` also 34 | returns a ref that needs to be assigned to the DOM element. Also keep in mind that the ref function 35 | of a DOM element is called both on mount and unmount, and calling `treeItem.getProps()` will fail 36 | if the item is already unloaded in the tree. Calling `treeItem.getProps()` outside the ref, like the following, 37 | will work: 38 | 39 | ```ts jsx 40 | const props = item.getProps(); 41 | return ( 42 | 59 | 60 | 61 | ))} 62 |
63 |

64 | Press [i1] to invalidate item data, or [i2] to invalidate its children 65 | array. 66 |

67 |
68 | Loading item name:{" "} 69 | setLoaderName(e.target.value)} 72 | /> 73 |
74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /packages/sb-react/src/async/async-loading-state.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React, { useState } from "react"; 3 | import { 4 | asyncDataLoaderFeature, 5 | hotkeysCoreFeature, 6 | selectionFeature, 7 | } from "@headless-tree/core"; 8 | import { useTree } from "@headless-tree/react"; 9 | import cn from "classnames"; 10 | 11 | const meta = { 12 | title: "React/Async/Async Loading State", 13 | tags: ["feature/async-data-loader"], 14 | } satisfies Meta; 15 | 16 | export default meta; 17 | 18 | // eslint-disable-next-line no-promise-executor-return 19 | const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 20 | 21 | // story-start 22 | export const AsyncLoadingState = () => { 23 | const [loadingItemData, setLoadingItemData] = useState([]); 24 | const [loadingItemChildrens, setLoadingItemChildrens] = useState( 25 | [], 26 | ); 27 | const tree = useTree({ 28 | state: { loadingItemData, loadingItemChildrens }, 29 | setLoadingItemData, 30 | setLoadingItemChildrens, 31 | rootItemId: "root", 32 | getItemName: (item) => item.getItemData(), 33 | isItemFolder: () => true, 34 | createLoadingItemData: () => "Loading...", 35 | dataLoader: { 36 | getItem: (itemId) => wait(800).then(() => itemId), 37 | getChildren: (itemId) => 38 | wait(800).then(() => [`${itemId}-1`, `${itemId}-2`, `${itemId}-3`]), 39 | }, 40 | indent: 20, 41 | features: [asyncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], 42 | }); 43 | 44 | return ( 45 | <> 46 |
47 | {tree.getItems().map((item) => ( 48 | 65 | 66 | 67 | ))} 68 |
69 |

70 | Press [i1] to invalidate item data, or [i2] to invalidate its children 71 | array. 72 |

73 |

Loading:

74 |
75 |         {JSON.stringify({ loadingItemData, loadingItemChildrens }, null, 2)}
76 |       
77 | 78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /packages/sb-react/src/dnd/basic.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React, { useState } from "react"; 3 | import { 4 | TreeState, 5 | dragAndDropFeature, 6 | hotkeysCoreFeature, 7 | keyboardDragAndDropFeature, 8 | selectionFeature, 9 | syncDataLoaderFeature, 10 | } from "@headless-tree/core"; 11 | import { AssistiveTreeDescription, useTree } from "@headless-tree/react"; 12 | import cn from "classnames"; 13 | 14 | const meta = { 15 | title: "React/Drag and Drop/Basic", 16 | tags: ["feature/dnd", "basic"], 17 | } satisfies Meta; 18 | 19 | export default meta; 20 | 21 | // story-start 22 | export const Basic = () => { 23 | const [state, setState] = useState>>({ 24 | expandedItems: ["root-1", "root-1-2"], 25 | selectedItems: ["root-1-2-1", "root-1-2-2"], 26 | }); 27 | const tree = useTree({ 28 | state, 29 | setState, 30 | rootItemId: "root", 31 | getItemName: (item) => item.getItemData(), 32 | isItemFolder: () => true, 33 | canReorder: true, 34 | onDrop: (items, target) => { 35 | alert( 36 | `Dropped ${items.map((item) => 37 | item.getId(), 38 | )} on ${target.item.getId()}, ${JSON.stringify(target)}`, 39 | ); 40 | }, 41 | indent: 20, 42 | dataLoader: { 43 | getItem: (itemId) => itemId, 44 | getChildren: (itemId) => [ 45 | `${itemId}-1`, 46 | `${itemId}-2`, 47 | `${itemId}-3`, 48 | `${itemId}-4`, 49 | ], 50 | }, 51 | features: [ 52 | syncDataLoaderFeature, 53 | selectionFeature, 54 | hotkeysCoreFeature, 55 | dragAndDropFeature, 56 | keyboardDragAndDropFeature, 57 | ], 58 | }); 59 | 60 | return ( 61 |
62 | 63 | {tree.getItems().map((item) => ( 64 | 81 | ))} 82 |
83 |
84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /packages/sb-react/src/dnd/can-drag.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React, { useState } from "react"; 3 | import { 4 | dragAndDropFeature, 5 | hotkeysCoreFeature, 6 | keyboardDragAndDropFeature, 7 | selectionFeature, 8 | syncDataLoaderFeature, 9 | } from "@headless-tree/core"; 10 | import { AssistiveTreeDescription, useTree } from "@headless-tree/react"; 11 | import cn from "classnames"; 12 | 13 | const meta = { 14 | title: "React/Drag and Drop/Can Drag", 15 | tags: ["feature/dnd", "basic"], 16 | } satisfies Meta; 17 | 18 | export default meta; 19 | 20 | // story-start 21 | export const CanDrag = () => { 22 | const [state, setState] = useState({}); 23 | const tree = useTree({ 24 | state, 25 | setState, 26 | rootItemId: "root", 27 | getItemName: (item) => item.getItemData(), 28 | isItemFolder: () => true, 29 | canReorder: true, 30 | canDrag: (items) => 31 | items.every( 32 | (i) => i.getItemName().endsWith("1") || i.getItemName().endsWith("2"), 33 | ), 34 | onDrop: (items, target) => { 35 | alert( 36 | `Dropped ${items.map((item) => 37 | item.getId(), 38 | )} on ${target.item.getId()}, ${JSON.stringify(target)}`, 39 | ); 40 | }, 41 | indent: 20, 42 | dataLoader: { 43 | getItem: (itemId) => itemId, 44 | getChildren: (itemId) => [ 45 | `${itemId}-1`, 46 | `${itemId}-2`, 47 | `${itemId}-3`, 48 | `${itemId}-4`, 49 | `${itemId}-5`, 50 | `${itemId}-6`, 51 | ], 52 | }, 53 | features: [ 54 | syncDataLoaderFeature, 55 | selectionFeature, 56 | hotkeysCoreFeature, 57 | dragAndDropFeature, 58 | keyboardDragAndDropFeature, 59 | ], 60 | }); 61 | 62 | return ( 63 | <> 64 |

65 | Only items that end with 1 or 2 can be dragged. 66 |

67 |
68 | 69 | {tree.getItems().map((item) => ( 70 | 87 | ))} 88 |
89 |
90 | 91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /packages/sb-react/src/dnd/can-drop.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React, { useState } from "react"; 3 | import { 4 | dragAndDropFeature, 5 | hotkeysCoreFeature, 6 | keyboardDragAndDropFeature, 7 | selectionFeature, 8 | syncDataLoaderFeature, 9 | } from "@headless-tree/core"; 10 | import { AssistiveTreeDescription, useTree } from "@headless-tree/react"; 11 | import cn from "classnames"; 12 | 13 | const meta = { 14 | title: "React/Drag and Drop/Can Drop", 15 | tags: ["feature/dnd", "basic"], 16 | } satisfies Meta; 17 | 18 | export default meta; 19 | 20 | // story-start 21 | export const CanDrop = () => { 22 | const [state, setState] = useState({}); 23 | const tree = useTree({ 24 | state, 25 | setState, 26 | rootItemId: "root", 27 | getItemName: (item) => item.getItemData(), 28 | isItemFolder: () => true, 29 | canReorder: true, // TODO! invert for error, still allows to drop even if drop is not shown 30 | canDrop: (items, { item }) => 31 | item.getItemName().endsWith("1") || item.getItemName().endsWith("2"), 32 | onDrop: (items, target) => { 33 | alert( 34 | `Dropped ${items.map((item) => 35 | item.getId(), 36 | )} on ${target.item.getId()}, ${JSON.stringify(target)}`, 37 | ); 38 | }, 39 | indent: 20, 40 | dataLoader: { 41 | getItem: (itemId) => itemId, 42 | getChildren: (itemId) => [ 43 | `${itemId}-1`, 44 | `${itemId}-2`, 45 | `${itemId}-3`, 46 | `${itemId}-4`, 47 | `${itemId}-5`, 48 | `${itemId}-6`, 49 | ], 50 | }, 51 | features: [ 52 | syncDataLoaderFeature, 53 | selectionFeature, 54 | hotkeysCoreFeature, 55 | dragAndDropFeature, 56 | keyboardDragAndDropFeature, 57 | ], 58 | }); 59 | 60 | return ( 61 | <> 62 |

63 | Only on items that end with 1 or 2 can be dropped on. 64 |

65 |
66 | 67 | {tree.getItems().map((item) => ( 68 | 85 | ))} 86 |
87 |
88 | 89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /packages/sb-react/src/dnd/cannot-drop-inbetween.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React, { useState } from "react"; 3 | import { 4 | TreeState, 5 | dragAndDropFeature, 6 | hotkeysCoreFeature, 7 | keyboardDragAndDropFeature, 8 | selectionFeature, 9 | syncDataLoaderFeature, 10 | } from "@headless-tree/core"; 11 | import { AssistiveTreeDescription, useTree } from "@headless-tree/react"; 12 | import cn from "classnames"; 13 | 14 | const meta = { 15 | title: "React/Drag and Drop/Cannot Drop Inbetween", 16 | tags: ["feature/dnd"], 17 | } satisfies Meta; 18 | 19 | export default meta; 20 | 21 | // story-start 22 | export const CannotDropInbetween = () => { 23 | const [state, setState] = useState>>({ 24 | expandedItems: ["root-1", "root-1-2"], 25 | }); 26 | const tree = useTree({ 27 | state, 28 | setState, 29 | rootItemId: "root", 30 | getItemName: (item) => item.getItemData(), 31 | isItemFolder: (item) => item.getItemMeta().level < 2, 32 | canReorder: false, 33 | onDrop: (items, target) => { 34 | alert( 35 | `Dropped ${items.map((item) => 36 | item.getId(), 37 | )} on ${target.item.getId()}, ${JSON.stringify(target)}`, 38 | ); 39 | }, 40 | indent: 20, 41 | dataLoader: { 42 | getItem: (itemId) => itemId, 43 | getChildren: (itemId) => [ 44 | `${itemId}-1`, 45 | `${itemId}-2`, 46 | `${itemId}-3`, 47 | `${itemId}-4`, 48 | `${itemId}-5`, 49 | `${itemId}-6`, 50 | ], 51 | }, 52 | features: [ 53 | syncDataLoaderFeature, 54 | selectionFeature, 55 | hotkeysCoreFeature, 56 | dragAndDropFeature, 57 | keyboardDragAndDropFeature, 58 | ], 59 | }); 60 | 61 | return ( 62 |
63 | 64 | {tree.getItems().map((item) => ( 65 | 82 | ))} 83 |
84 |
85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /packages/sb-react/src/dnd/drag-line.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React, { useState } from "react"; 3 | import { 4 | TreeState, 5 | dragAndDropFeature, 6 | hotkeysCoreFeature, 7 | keyboardDragAndDropFeature, 8 | selectionFeature, 9 | syncDataLoaderFeature, 10 | } from "@headless-tree/core"; 11 | import { AssistiveTreeDescription, useTree } from "@headless-tree/react"; 12 | import cn from "classnames"; 13 | 14 | const meta = { 15 | title: "React/Drag and Drop/Drag Line", 16 | tags: ["feature/dnd", "basic"], 17 | } satisfies Meta; 18 | 19 | export default meta; 20 | 21 | // story-start 22 | export const DragLine = () => { 23 | const [state, setState] = useState>>({ 24 | expandedItems: ["root-1", "root-1-2"], 25 | }); 26 | const tree = useTree({ 27 | state, 28 | setState, 29 | rootItemId: "root", 30 | getItemName: (item) => item.getItemData(), 31 | isItemFolder: () => true, 32 | canReorder: true, 33 | onDrop: (items, target) => { 34 | alert( 35 | `Dropped ${items.map((item) => 36 | item.getId(), 37 | )} on ${target.item.getId()}, ${JSON.stringify(target)}`, 38 | ); 39 | }, 40 | indent: 20, 41 | dataLoader: { 42 | getItem: (itemId) => itemId, 43 | getChildren: (itemId) => [ 44 | `${itemId}-1`, 45 | `${itemId}-2`, 46 | `${itemId}-3`, 47 | `${itemId}-4`, 48 | `${itemId}-5`, 49 | `${itemId}-6`, 50 | ], 51 | }, 52 | features: [ 53 | syncDataLoaderFeature, 54 | selectionFeature, 55 | hotkeysCoreFeature, 56 | dragAndDropFeature, 57 | keyboardDragAndDropFeature, 58 | ], 59 | }); 60 | 61 | const dragLine = tree.getDragLineData(); 62 | 63 | return ( 64 |
65 | 66 | {tree.getItems().map((item) => ( 67 | 84 | ))} 85 |
86 |
87 |         {JSON.stringify(
88 |           {
89 |             dragLine,
90 |           },
91 |           null,
92 |           2,
93 |         )}
94 |       
95 |
96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /packages/sb-react/src/dnd/minimal-dragline-styling.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React, { useState } from "react"; 3 | import { 4 | TreeState, 5 | dragAndDropFeature, 6 | hotkeysCoreFeature, 7 | keyboardDragAndDropFeature, 8 | selectionFeature, 9 | syncDataLoaderFeature, 10 | } from "@headless-tree/core"; 11 | import { AssistiveTreeDescription, useTree } from "@headless-tree/react"; 12 | import cn from "classnames"; 13 | 14 | const meta = { 15 | title: "React/Drag and Drop/Minimal Dragline Styling", 16 | tags: ["feature/dnd"], 17 | } satisfies Meta; 18 | 19 | export default meta; 20 | 21 | // story-start 22 | export const MinimalDraglineStyling = () => { 23 | const [state, setState] = useState>>({ 24 | expandedItems: ["root-1", "root-1-2"], 25 | selectedItems: ["root-1-2-1", "root-1-2-2"], 26 | }); 27 | const tree = useTree({ 28 | state, 29 | setState, 30 | rootItemId: "root", 31 | getItemName: (item) => item.getItemData(), 32 | isItemFolder: () => true, 33 | canReorder: true, 34 | onDrop: (items, target) => { 35 | alert( 36 | `Dropped ${items.map((item) => 37 | item.getId(), 38 | )} on ${target.item.getId()}, ${JSON.stringify(target)}`, 39 | ); 40 | }, 41 | indent: 20, 42 | dataLoader: { 43 | getItem: (itemId) => itemId, 44 | getChildren: (itemId) => [ 45 | `${itemId}-1`, 46 | `${itemId}-2`, 47 | `${itemId}-3`, 48 | `${itemId}-4`, 49 | ], 50 | }, 51 | features: [ 52 | syncDataLoaderFeature, 53 | selectionFeature, 54 | hotkeysCoreFeature, 55 | dragAndDropFeature, 56 | keyboardDragAndDropFeature, 57 | ], 58 | }); 59 | 60 | return ( 61 |
62 | 63 | {tree.getItems().map((item) => ( 64 | 81 | ))} 82 |
89 |
90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /packages/sb-react/src/general/example.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React, { useState } from "react"; 3 | import { 4 | hotkeysCoreFeature, 5 | selectionFeature, 6 | syncDataLoaderFeature, 7 | } from "@headless-tree/core"; 8 | import { useTree } from "@headless-tree/react"; 9 | import cn from "classnames"; 10 | 11 | const meta = { 12 | title: "React/General/Example", 13 | } satisfies Meta; 14 | 15 | export default meta; 16 | 17 | // story-start 18 | export const Example = () => { 19 | const [state, setState] = useState({}); 20 | const tree = useTree({ 21 | state, 22 | setState, 23 | rootItemId: "folder", 24 | getItemName: (item) => item.getItemData(), 25 | isItemFolder: (item) => !item.getItemData().endsWith("item"), 26 | hotkeys: { 27 | customEvent: { 28 | hotkey: "Escape", 29 | handler: () => alert("Hello!"), 30 | }, 31 | }, 32 | dataLoader: { 33 | getItem: (itemId) => itemId, 34 | getChildren: (itemId) => [ 35 | `${itemId}-1`, 36 | `${itemId}-2`, 37 | `${itemId}-3`, 38 | `${itemId}-1item`, 39 | `${itemId}-2item`, 40 | `${itemId}-3item`, 41 | ], 42 | }, 43 | indent: 20, 44 | features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], 45 | }); 46 | 47 | return ( 48 |
49 | {tree.getItems().map((item) => ( 50 | 66 | ))} 67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /packages/sb-react/src/general/item-data-objects.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React from "react"; 3 | import { 4 | hotkeysCoreFeature, 5 | selectionFeature, 6 | syncDataLoaderFeature, 7 | } from "@headless-tree/core"; 8 | import { useTree } from "@headless-tree/react"; 9 | import cn from "classnames"; 10 | 11 | const meta = { 12 | title: "React/General/Item Data Objects", 13 | } satisfies Meta; 14 | 15 | export default meta; 16 | 17 | // story-start 18 | interface Item { 19 | name: string; 20 | children?: string[]; 21 | isFolder?: boolean; 22 | isRed?: boolean; 23 | } 24 | 25 | const items: Record = { 26 | root: { name: "Root", children: ["folder1", "folder2"], isFolder: true }, 27 | folder1: { name: "Folder 1", children: ["item1", "item2"], isFolder: true }, 28 | folder2: { 29 | name: "Folder 2 (red)", 30 | children: ["folder3"], 31 | isFolder: true, 32 | isRed: true, 33 | }, 34 | folder3: { name: "Folder 3", children: ["item3"], isFolder: true }, 35 | item1: { name: "Item 1 (red)", isRed: true }, 36 | item2: { name: "Item 2" }, 37 | item3: { name: "Item 3" }, 38 | }; 39 | 40 | export const ItemDataObjects = () => { 41 | const tree = useTree({ 42 | rootItemId: "root", 43 | getItemName: (item) => item.getItemData().name, 44 | isItemFolder: (item) => Boolean(item.getItemData().isFolder), 45 | dataLoader: { 46 | getItem: (itemId) => items[itemId], 47 | getChildren: (itemId) => items[itemId].children ?? [], 48 | }, 49 | indent: 20, 50 | features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], 51 | }); 52 | 53 | return ( 54 |
55 | {tree.getItems().map((item) => ( 56 | 75 | ))} 76 |
77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /packages/sb-react/src/general/recursive-datastructure.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React, { useState } from "react"; 3 | import { 4 | hotkeysCoreFeature, 5 | selectionFeature, 6 | syncDataLoaderFeature, 7 | } from "@headless-tree/core"; 8 | import { useTree } from "@headless-tree/react"; 9 | import cn from "classnames"; 10 | 11 | const meta = { 12 | title: "React/General/Recursive Datastructure", 13 | } satisfies Meta; 14 | 15 | export default meta; 16 | 17 | // story-start 18 | export const RecursiveDatastructure = () => { 19 | const [state, setState] = useState({}); 20 | const tree = useTree({ 21 | state, 22 | setState, 23 | rootItemId: "folder", 24 | getItemName: (item) => item.getItemData(), 25 | isItemFolder: (item) => !item.getItemData().endsWith("item"), 26 | hotkeys: { 27 | customEvent: { 28 | hotkey: "Escape", 29 | handler: () => alert("Hello!"), 30 | }, 31 | }, 32 | dataLoader: { 33 | getItem: (itemId) => itemId, 34 | getChildren: (itemId) => [ 35 | itemId, 36 | `${itemId}-1`, 37 | `${itemId}-2`, 38 | `${itemId}-3`, 39 | `${itemId}-1item`, 40 | `${itemId}-2item`, 41 | `${itemId}-3item`, 42 | ], 43 | }, 44 | indent: 20, 45 | features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], 46 | }); 47 | 48 | return ( 49 | <> 50 |

51 | In this sample, every folder contains a reference to itself among other 52 | items. HT will filter out recursive children, and warn the user in the 53 | console. 54 |

55 |
56 | {tree.getItems().map((item) => ( 57 | 73 | ))} 74 |
75 | 76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /packages/sb-react/src/general/simple.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React from "react"; 3 | import { 4 | hotkeysCoreFeature, 5 | selectionFeature, 6 | syncDataLoaderFeature, 7 | } from "@headless-tree/core"; 8 | import { useTree } from "@headless-tree/react"; 9 | import cn from "classnames"; 10 | 11 | const meta = { 12 | title: "React/General/Simple Example", 13 | tags: ["homepage"], 14 | } satisfies Meta; 15 | 16 | export default meta; 17 | 18 | // story-start 19 | export const SimpleExample = () => { 20 | const tree = useTree({ 21 | initialState: { expandedItems: ["folder-1"] }, 22 | rootItemId: "folder", 23 | getItemName: (item) => item.getItemData(), 24 | isItemFolder: (item) => !item.getItemData().endsWith("item"), 25 | dataLoader: { 26 | getItem: (itemId) => itemId, 27 | getChildren: (itemId) => [ 28 | `${itemId}-1`, 29 | `${itemId}-2`, 30 | `${itemId}-3`, 31 | `${itemId}-1item`, 32 | `${itemId}-2item`, 33 | ], 34 | }, 35 | indent: 20, 36 | features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], 37 | }); 38 | 39 | return ( 40 |
41 | {tree.getItems().map((item) => ( 42 | 58 | ))} 59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /packages/sb-react/src/guides/click-behavior/expand-on-arrow-click.css: -------------------------------------------------------------------------------- 1 | .noarrow .treeitem.folder:before { 2 | content: " "; 3 | width: 0; 4 | margin-right: 0; 5 | } -------------------------------------------------------------------------------- /packages/sb-react/src/guides/overwriting-internals.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React from "react"; 3 | import { 4 | FeatureImplementation, 5 | hotkeysCoreFeature, 6 | selectionFeature, 7 | syncDataLoaderFeature, 8 | } from "@headless-tree/core"; 9 | import { useTree } from "@headless-tree/react"; 10 | import cn from "classnames"; 11 | 12 | const meta = { 13 | title: "React/Guides/Overwriting Internals", 14 | tags: ["homepage"], 15 | } satisfies Meta; 16 | 17 | export default meta; 18 | 19 | // story-start 20 | const customFeature: FeatureImplementation = { 21 | itemInstance: { 22 | getProps: ({ prev, item }) => ({ 23 | ...prev?.(), 24 | onMouseOver: () => { 25 | console.log("Mouse over!", item.getId()); 26 | }, 27 | }), 28 | expand: ({ prev, itemId }) => { 29 | // Run the original implementation 30 | prev?.(); 31 | 32 | alert(`Item ${itemId} expanded!`); 33 | }, 34 | }, 35 | onItemMount: (item, element) => { 36 | // You can also hook into various lifecycle events. This runs 37 | // when a tree item is mounted into the DOM. 38 | console.log("Item mounts!", item.getId(), element); 39 | }, 40 | }; 41 | 42 | export const OverwritingInternals = () => { 43 | const tree = useTree({ 44 | rootItemId: "folder", 45 | getItemName: (item) => item.getItemData(), 46 | isItemFolder: (item) => !item.getItemData().endsWith("item"), 47 | dataLoader: { 48 | getItem: (itemId) => itemId, 49 | getChildren: (itemId) => [ 50 | `${itemId}-1`, 51 | `${itemId}-2`, 52 | `${itemId}-3`, 53 | `${itemId}-1item`, 54 | `${itemId}-2item`, 55 | ], 56 | }, 57 | indent: 20, 58 | features: [ 59 | syncDataLoaderFeature, 60 | selectionFeature, 61 | hotkeysCoreFeature, 62 | customFeature, 63 | ], 64 | }); 65 | 66 | return ( 67 |
68 | {tree.getItems().map((item) => ( 69 | 85 | ))} 86 |
87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /packages/sb-react/src/guides/render-performance/memoized-slow-item-renderers.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React, { HTMLProps, forwardRef, memo } from "react"; 3 | import { 4 | hotkeysCoreFeature, 5 | propMemoizationFeature, 6 | selectionFeature, 7 | syncDataLoaderFeature, 8 | } from "@headless-tree/core"; 9 | import { useTree } from "@headless-tree/react"; 10 | import { action } from "@storybook/addon-actions"; 11 | import cn from "classnames"; 12 | 13 | const meta = { 14 | title: "React/Guides/Render Performance/Memoized Slow Item Renderers", 15 | tags: ["feature/propmemoization"], 16 | } satisfies Meta; 17 | 18 | export default meta; 19 | 20 | // story-start 21 | const SlowItem = forwardRef< 22 | HTMLButtonElement, 23 | HTMLProps & { 24 | level: number; 25 | innerClass: string; 26 | title: string; 27 | } 28 | >(({ level, innerClass, title, ...props }, ref) => { 29 | const start = Date.now(); 30 | while (Date.now() - start < 20); // force the component to take 20ms to render 31 | action("renderItem")(); 32 | return ( 33 | 40 | ); 41 | }); 42 | 43 | const MemoizedItem = memo(SlowItem); 44 | 45 | export const MemoizedSlowItemRenderers = () => { 46 | const tree = useTree({ 47 | rootItemId: "folder", 48 | initialState: { 49 | expandedItems: ["folder-1", "folder-2", "folder-3"], 50 | }, 51 | getItemName: (item) => item.getItemData(), 52 | isItemFolder: (item) => !item.getItemData().endsWith("item"), 53 | indent: 20, 54 | dataLoader: { 55 | getItem: (itemId) => itemId, 56 | getChildren: (itemId) => [ 57 | `${itemId}-1`, 58 | `${itemId}-2`, 59 | `${itemId}-3`, 60 | `${itemId}-1item`, 61 | `${itemId}-2item`, 62 | ], 63 | }, 64 | features: [ 65 | syncDataLoaderFeature, 66 | selectionFeature, 67 | hotkeysCoreFeature, 68 | propMemoizationFeature, 69 | ], 70 | }); 71 | 72 | return ( 73 |
74 | {tree.getItems().map((item) => ( 75 | 87 | ))} 88 |
89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /packages/sb-react/src/guides/render-performance/slow-item-renderers.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React, { HTMLProps, forwardRef } from "react"; 3 | import { 4 | hotkeysCoreFeature, 5 | selectionFeature, 6 | syncDataLoaderFeature, 7 | } from "@headless-tree/core"; 8 | import { useTree } from "@headless-tree/react"; 9 | import { action } from "@storybook/addon-actions"; 10 | import cn from "classnames"; 11 | 12 | const meta = { 13 | title: "React/Guides/Render Performance/Slow Item Renderers", 14 | tags: ["feature/propmemoization"], 15 | } satisfies Meta; 16 | 17 | export default meta; 18 | 19 | // story-start 20 | const SlowItem = forwardRef>( 21 | (props, ref) => { 22 | const start = Date.now(); 23 | while (Date.now() - start < 20); // force the component to take 20ms to render 24 | action("renderItem")(); 25 | return 89 | ))} 90 |
91 | 92 | ); 93 | }; 94 | -------------------------------------------------------------------------------- /packages/sb-react/src/hotkeys/overwriting-hotkeys.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React from "react"; 3 | import { 4 | hotkeysCoreFeature, 5 | selectionFeature, 6 | syncDataLoaderFeature, 7 | } from "@headless-tree/core"; 8 | import { useTree } from "@headless-tree/react"; 9 | import cn from "classnames"; 10 | 11 | const meta = { 12 | title: "React/Hotkeys/Overwriting Hotkeys", 13 | tags: ["feature/hotkeys", "homepage"], 14 | } satisfies Meta; 15 | 16 | export default meta; 17 | 18 | // story-start 19 | export const OverwritingHotkeys = () => { 20 | const tree = useTree({ 21 | rootItemId: "folder", 22 | getItemName: (item) => item.getItemData(), 23 | isItemFolder: (item) => !item.getItemData().endsWith("item"), 24 | indent: 20, 25 | dataLoader: { 26 | getItem: (itemId) => itemId, 27 | getChildren: (itemId) => [ 28 | `${itemId}-1`, 29 | `${itemId}-2`, 30 | `${itemId}-3`, 31 | `${itemId}-1item`, 32 | `${itemId}-2item`, 33 | ], 34 | }, 35 | hotkeys: { 36 | focusNextItem: { 37 | hotkey: "ArrowRight", 38 | }, 39 | focusPreviousItem: { 40 | hotkey: "ArrowLeft", 41 | }, 42 | }, 43 | features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], 44 | }); 45 | 46 | return ( 47 | <> 48 |

49 | In this example, the hotkeys for moving the focus up and down are bound 50 | to "ArrowLeft" and "ArrowRight", overwriting the 51 | hotkeys for expanding and collapsing (by default, the hotkeys are 52 | ArrowUp and ArrowDown). 53 |

54 |
55 | {tree.getItems().map((item) => ( 56 | 72 | ))} 73 |
74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /packages/sb-react/src/plugins/simple-plugin.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React from "react"; 3 | import { 4 | FeatureImplementation, 5 | hotkeysCoreFeature, 6 | selectionFeature, 7 | syncDataLoaderFeature, 8 | } from "@headless-tree/core"; 9 | import { useTree } from "@headless-tree/react"; 10 | import cn from "classnames"; 11 | 12 | const meta = { 13 | title: "React/Plugins/Simple Plugin", 14 | tags: ["guide/plugin", "basic", "homepage"], 15 | } satisfies Meta; 16 | 17 | export default meta; 18 | 19 | // story-start 20 | 21 | declare module "@headless-tree/core" { 22 | export interface ItemInstance { 23 | alertItem: () => void; 24 | } 25 | } 26 | 27 | const customFeature: FeatureImplementation = { 28 | itemInstance: { 29 | alertItem: ({ item }) => { 30 | alert(`Clicked on ${item.getItemName()}`); 31 | }, 32 | }, 33 | }; 34 | 35 | export const SimplePlugin = () => { 36 | const tree = useTree({ 37 | rootItemId: "folder", 38 | getItemName: (item) => item.getItemData(), 39 | isItemFolder: (item) => !item.getItemData().endsWith("item"), 40 | indent: 20, 41 | dataLoader: { 42 | getItem: (itemId) => itemId, 43 | getChildren: (itemId) => [ 44 | `${itemId}-1`, 45 | `${itemId}-2`, 46 | `${itemId}-3`, 47 | `${itemId}-1item`, 48 | `${itemId}-2item`, 49 | ], 50 | }, 51 | features: [ 52 | syncDataLoaderFeature, 53 | selectionFeature, 54 | hotkeysCoreFeature, 55 | customFeature, 56 | ], 57 | }); 58 | 59 | return ( 60 |
61 | {tree.getItems().map((item) => ( 62 |
63 | 79 | 80 |
81 | ))} 82 |
83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /packages/sb-react/src/plugins/transform-props.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React from "react"; 3 | import { 4 | FeatureImplementation, 5 | hotkeysCoreFeature, 6 | selectionFeature, 7 | syncDataLoaderFeature, 8 | } from "@headless-tree/core"; 9 | import { useTree } from "@headless-tree/react"; 10 | import cn from "classnames"; 11 | 12 | const meta = { 13 | title: "React/Plugins/Transform Props", 14 | tags: ["guide/plugin", "basic"], 15 | } satisfies Meta; 16 | 17 | export default meta; 18 | 19 | // story-start 20 | const customFeature: FeatureImplementation = { 21 | itemInstance: { 22 | getProps: ({ prev }) => { 23 | const { "aria-label": label, ...props } = prev?.() ?? {}; 24 | return { ...props, "data-label": label }; 25 | }, 26 | }, 27 | }; 28 | 29 | export const TransformProps = () => { 30 | const tree = useTree({ 31 | rootItemId: "folder", 32 | getItemName: (item) => item.getItemData(), 33 | isItemFolder: (item) => !item.getItemData().endsWith("item"), 34 | indent: 20, 35 | dataLoader: { 36 | getItem: (itemId) => itemId, 37 | getChildren: (itemId) => [ 38 | `${itemId}-1`, 39 | `${itemId}-2`, 40 | `${itemId}-3`, 41 | `${itemId}-1item`, 42 | `${itemId}-2item`, 43 | ], 44 | }, 45 | features: [ 46 | syncDataLoaderFeature, 47 | selectionFeature, 48 | hotkeysCoreFeature, 49 | customFeature, 50 | ], 51 | }); 52 | 53 | return ( 54 |
55 | {tree.getItems().map((item) => ( 56 | 72 | ))} 73 |
74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /packages/sb-react/src/renaming/basic.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React, { Fragment } from "react"; 3 | import { 4 | hotkeysCoreFeature, 5 | renamingFeature, 6 | selectionFeature, 7 | syncDataLoaderFeature, 8 | } from "@headless-tree/core"; 9 | import { useTree } from "@headless-tree/react"; 10 | import cn from "classnames"; 11 | 12 | const meta = { 13 | title: "React/Renaming/Basic", 14 | tags: ["feature/renaming", "basic", "homepage"], 15 | } satisfies Meta; 16 | 17 | export default meta; 18 | 19 | // story-start 20 | export const Basic = () => { 21 | const tree = useTree({ 22 | rootItemId: "root", 23 | getItemName: (item) => item.getItemData(), 24 | isItemFolder: () => true, 25 | dataLoader: { 26 | getItem: (itemId) => itemId, 27 | getChildren: (itemId) => [`${itemId}-1`, `${itemId}-2`, `${itemId}-3`], 28 | }, 29 | onRename: (item, value) => { 30 | alert(`Renamed ${item.getItemName()} to ${value}`); 31 | }, 32 | initialState: { 33 | expandedItems: ["root-1", "root-1-1"], 34 | renamingItem: "root-1-1-2", 35 | renamingValue: "abc", 36 | }, 37 | features: [ 38 | syncDataLoaderFeature, 39 | selectionFeature, 40 | hotkeysCoreFeature, 41 | renamingFeature, 42 | ], 43 | }); 44 | 45 | return ( 46 | <> 47 |

48 | {" "} 51 | or press F2 when an item is focused. 52 |

53 |
54 | {tree.getItems().map((item) => ( 55 | 56 | {item.isRenaming() ? ( 57 |
61 | 62 |
63 | ) : ( 64 | 79 | )} 80 |
81 | ))} 82 |
83 | 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /packages/sb-react/src/search/async.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React from "react"; 3 | import { 4 | asyncDataLoaderFeature, 5 | hotkeysCoreFeature, 6 | searchFeature, 7 | selectionFeature, 8 | } from "@headless-tree/core"; 9 | import { useTree } from "@headless-tree/react"; 10 | import cn from "classnames"; 11 | 12 | const meta = { 13 | title: "React/Search/Async Search Tree", 14 | tags: ["feature/search", "basic", "feature/async"], 15 | } satisfies Meta; 16 | 17 | export default meta; 18 | 19 | // eslint-disable-next-line no-promise-executor-return 20 | const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 21 | 22 | // story-start 23 | export const AsyncSearchTree = () => { 24 | const tree = useTree({ 25 | rootItemId: "root", 26 | getItemName: (item) => item.getItemData(), 27 | isItemFolder: () => true, 28 | createLoadingItemData: () => "Loading...", 29 | dataLoader: { 30 | getItem: (itemId) => wait(800).then(() => itemId), 31 | getChildren: (itemId) => 32 | wait(800).then(() => [`${itemId}-1`, `${itemId}-2`, `${itemId}-3`]), 33 | }, 34 | initialState: { 35 | expandedItems: [ 36 | "root-1", 37 | "root-2", 38 | "root-3", 39 | "root-1-1", 40 | "root-1-2", 41 | "root-1-3", 42 | ], 43 | }, 44 | features: [ 45 | asyncDataLoaderFeature, 46 | selectionFeature, 47 | hotkeysCoreFeature, 48 | searchFeature, 49 | ], 50 | }); 51 | 52 | return ( 53 | <> 54 |

55 | or press 56 | any letter keys while focusing the tree to search. 57 |

58 | {tree.isSearchOpen() && ( 59 | <> 60 |

Navigate between search results with ArrowUp and ArrowDown.

61 | ( 62 | {tree.getSearchMatchingItems().length} matches) 63 | 64 | )}{" "} 65 |
66 | {tree.getItems().map((item) => ( 67 | 84 | ))} 85 |
86 | 87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /packages/sb-react/src/search/basic.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React from "react"; 3 | import { 4 | hotkeysCoreFeature, 5 | searchFeature, 6 | selectionFeature, 7 | syncDataLoaderFeature, 8 | } from "@headless-tree/core"; 9 | import { useTree } from "@headless-tree/react"; 10 | import cn from "classnames"; 11 | 12 | const meta = { 13 | title: "React/Search/Basic", 14 | tags: ["feature/search", "basic", "homepage"], 15 | } satisfies Meta; 16 | 17 | export default meta; 18 | 19 | // story-start 20 | export const Basic = () => { 21 | const tree = useTree({ 22 | rootItemId: "root", 23 | getItemName: (item) => item.getItemData(), 24 | isItemFolder: () => true, 25 | dataLoader: { 26 | getItem: (itemId) => itemId, 27 | getChildren: (itemId) => [`${itemId}-1`, `${itemId}-2`, `${itemId}-3`], 28 | }, 29 | initialState: { 30 | expandedItems: [ 31 | "root-1", 32 | "root-2", 33 | "root-3", 34 | "root-1-1", 35 | "root-1-2", 36 | "root-1-3", 37 | ], 38 | }, 39 | features: [ 40 | syncDataLoaderFeature, 41 | selectionFeature, 42 | hotkeysCoreFeature, 43 | searchFeature, 44 | ], 45 | }); 46 | 47 | return ( 48 | <> 49 |

50 | or press 51 | any letter keys while focusing the tree to search. 52 |

53 | {tree.isSearchOpen() && ( 54 | <> 55 |

Navigate between search results with ArrowUp and ArrowDown.

56 | ( 57 | {tree.getSearchMatchingItems().length} matches) 58 | 59 | )}{" "} 60 |
61 | {tree.getItems().map((item) => ( 62 | 79 | ))} 80 |
81 | 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /packages/sb-react/src/state/distinct-state-handlers.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React, { useState } from "react"; 3 | import { 4 | hotkeysCoreFeature, 5 | selectionFeature, 6 | syncDataLoaderFeature, 7 | } from "@headless-tree/core"; 8 | import { useTree } from "@headless-tree/react"; 9 | import cn from "classnames"; 10 | 11 | const meta = { 12 | title: "React/State/Distinct State Handlers", 13 | tags: ["guides/state", "homepage"], 14 | } satisfies Meta; 15 | 16 | export default meta; 17 | 18 | // story-start 19 | export const DistinctStateHandlers = () => { 20 | const [selectedItems, setSelectedItems] = useState([]); 21 | const [expandedItems, setExpandedItems] = useState([]); 22 | const [focusedItem, setFocusedItem] = useState(null); 23 | 24 | const tree = useTree({ 25 | state: { selectedItems, expandedItems, focusedItem }, 26 | rootItemId: "root", 27 | setSelectedItems, 28 | setExpandedItems, 29 | setFocusedItem, 30 | getItemName: (item) => item.getItemData(), 31 | isItemFolder: () => true, 32 | dataLoader: { 33 | getItem: (itemId) => itemId, 34 | getChildren: (itemId) => [`${itemId}-1`, `${itemId}-2`, `${itemId}-3`], 35 | }, 36 | features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], 37 | }); 38 | 39 | return ( 40 | <> 41 |
42 | {tree.getItems().map((item) => ( 43 | 59 | ))} 60 |
61 |

Selected Items

62 |
{JSON.stringify(selectedItems)}
63 |

Expanded Items

64 |
{JSON.stringify(expandedItems)}
65 |

Focused Item

66 |
{JSON.stringify(focusedItem)}
67 | 68 |
69 | 79 |
80 | 81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /packages/sb-react/src/state/external-state.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React, { useState } from "react"; 3 | import { 4 | TreeState, 5 | hotkeysCoreFeature, 6 | selectionFeature, 7 | syncDataLoaderFeature, 8 | } from "@headless-tree/core"; 9 | import { useTree } from "@headless-tree/react"; 10 | import cn from "classnames"; 11 | 12 | const meta = { 13 | title: "React/State/External State", 14 | tags: ["guides/state"], 15 | } satisfies Meta; 16 | 17 | export default meta; 18 | 19 | // story-start 20 | export const ExternalState = () => { 21 | const [state, setState] = useState>>({}); 22 | 23 | const tree = useTree({ 24 | state, 25 | setState, 26 | rootItemId: "root", 27 | getItemName: (item) => item.getItemData(), 28 | isItemFolder: () => true, 29 | dataLoader: { 30 | getItem: (itemId) => itemId, 31 | getChildren: (itemId) => [`${itemId}-1`, `${itemId}-2`, `${itemId}-3`], 32 | }, 33 | features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], 34 | }); 35 | 36 | return ( 37 | <> 38 |
39 | {tree.getItems().map((item) => ( 40 | 56 | ))} 57 |
58 |
{JSON.stringify(state, null, 2)}
59 |
60 | 72 |
73 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /packages/sb-react/src/state/internal-state.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React from "react"; 3 | import { 4 | hotkeysCoreFeature, 5 | selectionFeature, 6 | syncDataLoaderFeature, 7 | } from "@headless-tree/core"; 8 | import { useTree } from "@headless-tree/react"; 9 | import cn from "classnames"; 10 | 11 | const meta = { 12 | title: "React/State/Internal State", 13 | tags: ["guides/state"], 14 | } satisfies Meta; 15 | 16 | export default meta; 17 | 18 | // story-start 19 | export const InternalState = () => { 20 | const tree = useTree({ 21 | rootItemId: "root", 22 | getItemName: (item) => item.getItemData(), 23 | isItemFolder: () => true, 24 | dataLoader: { 25 | getItem: (itemId) => itemId, 26 | getChildren: (itemId) => [`${itemId}-1`, `${itemId}-2`, `${itemId}-3`], 27 | }, 28 | features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], 29 | }); 30 | 31 | return ( 32 |
33 | {tree.getItems().map((item) => ( 34 | 50 | ))} 51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /packages/sb-react/src/utils/unit-test-async.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React from "react"; 3 | import { 4 | asyncDataLoaderFeature, 5 | createOnDropHandler, 6 | dragAndDropFeature, 7 | hotkeysCoreFeature, 8 | keyboardDragAndDropFeature, 9 | selectionFeature, 10 | } from "@headless-tree/core"; 11 | import { useTree } from "@headless-tree/react"; 12 | import cn from "classnames"; 13 | import { DemoItem, createDemoData, unitTestTree } from "./data"; 14 | 15 | const meta = { 16 | title: "React/Misc/Async Tree used in Unit Tests", 17 | tags: ["misc/unittest", "dev"], 18 | } satisfies Meta; 19 | 20 | const { data, asyncDataLoader } = createDemoData(unitTestTree); 21 | 22 | export default meta; 23 | 24 | // story-start 25 | export const UnitTestAsync = () => { 26 | const tree = useTree({ 27 | rootItemId: "x", 28 | createLoadingItemData: () => ({ name: "Loading" }), 29 | dataLoader: asyncDataLoader, 30 | getItemName: (item) => item.getItemData().name, 31 | indent: 20, 32 | isItemFolder: (item) => item.getItemMeta().level < 2, 33 | initialState: { 34 | expandedItems: ["x1", "x11", "x2", "x21"], 35 | }, 36 | canReorder: true, 37 | onDrop: createOnDropHandler((item, newChildren) => { 38 | console.log("!!", item.getId(), newChildren); // TODO doesnt work! 39 | data[item.getId()].children = newChildren; 40 | }), 41 | features: [ 42 | asyncDataLoaderFeature, 43 | selectionFeature, 44 | hotkeysCoreFeature, 45 | dragAndDropFeature, 46 | keyboardDragAndDropFeature, 47 | ], 48 | }); 49 | 50 | return ( 51 |
52 | {tree.getItems().map((item) => ( 53 | 70 | ))} 71 |
72 |
73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /packages/sb-react/src/utils/unit-test-sync.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta } from "@storybook/react"; 2 | import React from "react"; 3 | import { 4 | createOnDropHandler, 5 | dragAndDropFeature, 6 | hotkeysCoreFeature, 7 | keyboardDragAndDropFeature, 8 | selectionFeature, 9 | syncDataLoaderFeature, 10 | } from "@headless-tree/core"; 11 | import { useTree } from "@headless-tree/react"; 12 | import cn from "classnames"; 13 | import { DemoItem, createDemoData, unitTestTree } from "./data"; 14 | 15 | const meta = { 16 | title: "React/Misc/Sync Tree used in Unit Tests", 17 | tags: ["misc/unittest", "dev"], 18 | } satisfies Meta; 19 | 20 | const { data, syncDataLoader } = createDemoData(unitTestTree); 21 | 22 | export default meta; 23 | 24 | // story-start 25 | export const UnitTestSync = () => { 26 | const tree = useTree({ 27 | rootItemId: "x", 28 | createLoadingItemData: () => ({ name: "Loading" }), 29 | dataLoader: syncDataLoader, 30 | getItemName: (item) => item.getItemData().name, 31 | indent: 20, 32 | isItemFolder: (item) => item.getItemMeta().level < 2, 33 | initialState: { 34 | expandedItems: ["x1", "x11", "x2", "x21"], 35 | }, 36 | canReorder: true, 37 | onDrop: createOnDropHandler((item, newChildren) => { 38 | data[item.getId()].children = newChildren; 39 | }), 40 | features: [ 41 | syncDataLoaderFeature, 42 | selectionFeature, 43 | hotkeysCoreFeature, 44 | dragAndDropFeature, 45 | keyboardDragAndDropFeature, 46 | ], 47 | }); 48 | 49 | return ( 50 |
51 | {tree.getItems().map((item) => ( 52 | 69 | ))} 70 |
71 |
72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /packages/sb-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["./src/**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /scripts/version.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zx 2 | 3 | import "zx/globals"; 4 | 5 | // to fix https://github.com/changesets/changesets/issues/1011 6 | await $({ cwd: "packages/react" })`npm pkg delete peerDependencies."@headless-tree/core"`; 7 | await $`yarn changeset version`; 8 | await $({ cwd: "packages/react" })`npm pkg set peerDependencies."@headless-tree/core"="*"`; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib", 4 | "declaration": true, 5 | "strictNullChecks": true, 6 | "esModuleInterop": true, 7 | "module": "ES2022", 8 | "target": "ES6", 9 | "moduleResolution": "Node", 10 | "jsx": "react", 11 | "skipLibCheck": true, 12 | "strict": true 13 | }, 14 | "include": ["packages"], 15 | "exclude": ["src/**/template-*", "packages/docs"] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.lint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["packages", "examples"], 4 | "exclude": [] 5 | } 6 | -------------------------------------------------------------------------------- /typedoc.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "excludeInternal": true, 3 | "includeVersion": true 4 | } 5 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": [ 4 | "packages/core", 5 | "packages/react" 6 | ], 7 | "name": "Headless Tree", 8 | "entryPointStrategy": "packages", 9 | "out": "docs", 10 | "extends": ["./typedoc.base.json"] 11 | } 12 | --------------------------------------------------------------------------------