├── .editorconfig ├── .github └── workflows │ ├── build-and-test.yml │ └── publish.yml ├── .gitignore ├── .node-version ├── .yarn ├── releases │ └── yarn-4.0.2.cjs └── versions │ ├── 10de8bbe.yml │ ├── 29c64826.yml │ ├── 5b5c1471.yml │ ├── 894d7021.yml │ ├── 9ef5f981.yml │ ├── b57f0b1e.yml │ └── c94fa517.yml ├── .yarnrc.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bin └── publish ├── modules ├── docs │ ├── .gitignore │ ├── .hugo_build.lock │ ├── .prettierrc │ ├── README.md │ ├── archetypes │ │ └── default.md │ ├── assets │ │ ├── css-utils │ │ │ └── tokens-to-tailwind.js │ │ ├── css │ │ │ ├── blocks │ │ │ │ ├── code-example.css │ │ │ │ └── prose.css │ │ │ ├── compositions │ │ │ │ ├── cluster.css │ │ │ │ ├── flow.css │ │ │ │ ├── grid.css │ │ │ │ ├── repel.css │ │ │ │ ├── sidebar.css │ │ │ │ ├── switcher.css │ │ │ │ └── wrapper.css │ │ │ ├── global │ │ │ │ ├── fonts.css │ │ │ │ ├── global.css │ │ │ │ ├── reset.css │ │ │ │ └── variables.css │ │ │ ├── main.css │ │ │ └── utilities │ │ │ │ ├── region.css │ │ │ │ └── visually-hidden.css │ │ ├── design-tokens │ │ │ ├── colors.json │ │ │ ├── fonts.json │ │ │ ├── spacing.js │ │ │ ├── text-leading.json │ │ │ ├── text-sizes.js │ │ │ ├── text-weights.json │ │ │ └── viewports.json │ │ └── js │ │ │ └── main.js │ ├── content │ │ ├── _index.md │ │ └── terms.md │ ├── hugo.toml │ ├── layouts │ │ ├── _default │ │ │ ├── baseof.html │ │ │ ├── home.html │ │ │ ├── list.html │ │ │ └── single.html │ │ └── partials │ │ │ ├── footer.html │ │ │ ├── head.html │ │ │ ├── head │ │ │ ├── css.html │ │ │ └── js.html │ │ │ ├── header.html │ │ │ ├── menu.html │ │ │ └── terms.html │ ├── package.json │ ├── postcss.config.js │ ├── tailwind.config.js │ └── yarn.lock ├── e2e │ ├── .gitignore │ ├── README.md │ ├── cypress.config.js │ ├── cypress │ │ ├── e2e │ │ │ ├── cities-spec.cy.js │ │ │ └── gmail-spec.cy.js │ │ ├── fixtures │ │ │ └── example.json │ │ └── support │ │ │ ├── commands.js │ │ │ └── e2e.js │ └── package.json ├── react-arborist │ ├── .gitignore │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── components │ │ │ ├── cursor.tsx │ │ │ ├── default-container.tsx │ │ │ ├── default-cursor.tsx │ │ │ ├── default-drag-preview.tsx │ │ │ ├── default-node.tsx │ │ │ ├── default-row.tsx │ │ │ ├── drag-preview-container.tsx │ │ │ ├── list-inner-element.tsx │ │ │ ├── list-outer-element.tsx │ │ │ ├── outer-drop.ts │ │ │ ├── provider.tsx │ │ │ ├── row-container.tsx │ │ │ ├── tree-container.tsx │ │ │ └── tree.tsx │ │ ├── context.ts │ │ ├── data │ │ │ ├── create-index.ts │ │ │ ├── create-list.ts │ │ │ ├── create-root.ts │ │ │ ├── make-tree.ts │ │ │ └── simple-tree.ts │ │ ├── dnd │ │ │ ├── compute-drop.ts │ │ │ ├── drag-hook.ts │ │ │ ├── drop-hook.ts │ │ │ ├── measure-hover.ts │ │ │ └── outer-drop-hook.ts │ │ ├── hooks │ │ │ ├── use-fresh-node.ts │ │ │ ├── use-simple-tree.ts │ │ │ └── use-validated-props.ts │ │ ├── index.ts │ │ ├── interfaces │ │ │ ├── node-api.ts │ │ │ ├── tree-api.test.ts │ │ │ └── tree-api.ts │ │ ├── state │ │ │ ├── dnd-slice.ts │ │ │ ├── drag-slice.ts │ │ │ ├── edit-slice.ts │ │ │ ├── focus-slice.ts │ │ │ ├── initial.ts │ │ │ ├── open-slice.ts │ │ │ ├── root-reducer.ts │ │ │ └── selection-slice.ts │ │ ├── types │ │ │ ├── dnd.ts │ │ │ ├── handlers.ts │ │ │ ├── renderers.ts │ │ │ ├── state.ts │ │ │ ├── tree-props.ts │ │ │ └── utils.ts │ │ └── utils.ts │ └── tsconfig.json └── showcase │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── components │ ├── fill-flex-parent.tsx │ └── merge-refs.ts │ ├── data │ ├── cities.ts │ └── gmail.ts │ ├── next.config.js │ ├── package.json │ ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── cities.tsx │ ├── gmail.tsx │ ├── index.tsx │ └── vscode.tsx │ ├── public │ ├── favicon.ico │ ├── img │ │ ├── cities-demo.png │ │ ├── gmail-demo.png │ │ └── golden-gate-bridge.jpeg │ └── vercel.svg │ ├── styles │ ├── Gmail.module.css │ ├── Home.module.css │ ├── cities.module.css │ ├── globals.css │ └── vscode.module.css │ └── tsconfig.json ├── package.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,json,yml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | on: 3 | pull_request: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v2 12 | with: 13 | node-version: "20.x" 14 | registry-url: "https://registry.npmjs.org" 15 | - run: yarn 16 | - run: yarn build 17 | - run: yarn test 18 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to NPM 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - v* 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v2 13 | with: 14 | node-version: "20.x" 15 | registry-url: "https://registry.npmjs.org" 16 | - run: yarn 17 | - run: yarn publish --tag latest 18 | env: 19 | YARN_NPM_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | *node_modules* 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .pnp.* 26 | .yarn/* 27 | !.yarn/patches 28 | !.yarn/plugins 29 | !.yarn/releases 30 | !.yarn/sdks 31 | !.yarn/versions 32 | 33 | .parcel-cache 34 | .vscode 35 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20.5.0 2 | -------------------------------------------------------------------------------- /.yarn/versions/10de8bbe.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brimdata/react-arborist/c851c6f76ad5878f3ea7b68602e9b2f970df142e/.yarn/versions/10de8bbe.yml -------------------------------------------------------------------------------- /.yarn/versions/29c64826.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brimdata/react-arborist/c851c6f76ad5878f3ea7b68602e9b2f970df142e/.yarn/versions/29c64826.yml -------------------------------------------------------------------------------- /.yarn/versions/5b5c1471.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brimdata/react-arborist/c851c6f76ad5878f3ea7b68602e9b2f970df142e/.yarn/versions/5b5c1471.yml -------------------------------------------------------------------------------- /.yarn/versions/894d7021.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brimdata/react-arborist/c851c6f76ad5878f3ea7b68602e9b2f970df142e/.yarn/versions/894d7021.yml -------------------------------------------------------------------------------- /.yarn/versions/9ef5f981.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brimdata/react-arborist/c851c6f76ad5878f3ea7b68602e9b2f970df142e/.yarn/versions/9ef5f981.yml -------------------------------------------------------------------------------- /.yarn/versions/b57f0b1e.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brimdata/react-arborist/c851c6f76ad5878f3ea7b68602e9b2f970df142e/.yarn/versions/b57f0b1e.yml -------------------------------------------------------------------------------- /.yarn/versions/c94fa517.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brimdata/react-arborist/c851c6f76ad5878f3ea7b68602e9b2f970df142e/.yarn/versions/c94fa517.yml -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: false 4 | 5 | nodeLinker: node-modules 6 | 7 | yarnPath: .yarn/releases/yarn-4.0.2.cjs 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Version 3.0.0 2 | 3 | **Breaking Changes** 4 | 5 | - Tree Component `disableDrop` Prop 6 | - NodeApi `isDroppable` property 7 | 8 | **Features** 9 | 10 | - Disable Edit 11 | - Disable Drop Dynamically 12 | 13 | **Extras** 14 | 15 | - Indent Lines in Cities Demo 16 | - Cypress Integration Tests 17 | - Removed ForwardRef Redeclare 18 | 19 | ## Features 20 | 21 | **Disable Edit** 22 | 23 | The `disableEdit` prop was added to the tree to specify nodes that cannot be edited. This also fixed a bug when pressing the keyboard shortcut "Enter" on a node that did not render a form. The tree would get stuck in the "editing" mode and could not return to the normal mode. 24 | 25 | **Disable Drop Dynamically** 26 | 27 | The `disableDrop` prop now accepts a function with the arguments described below. Previously you could only provide a static list of nodes that were not droppable, but now you can determine it dynamically. 28 | 29 | ## Breaking Changes 30 | 31 | **Tree Component `disableDrop` Prop** 32 | 33 | If you were passing a function to the `disableDrop` prop, you'll need to update it to use the following signature: 34 | 35 | ```ts 36 | declare function disableDrop(args: { 37 | dragNodes: NodeApi[]; // The nodes being dragged 38 | parentNode: NodeApi; // The new parent of the dragNodes if dropped 39 | index: number; // The new child index of the dragNodes if dropped 40 | }): boolean; 41 | ``` 42 | 43 | This lets you disallow a drop based on the items being dragged and which node you are hovering over. You might notice it matches the function signature of the onMove handler. It is still possible to pass a string or a boolean to the `disableDrop` prop to prevent drops statically. 44 | 45 | **NodeApi `isDroppable` property** 46 | 47 | The `.isDroppable` property has been removed from the NodeApi class. This is now determined dynamically from the tree's state. It doesn't make sense to ask an single node if it is droppable anymore. 48 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Testing Locally 2 | 3 | 1. Clone the repo 4 | 2. From the root, run yarn && yarn start 5 | 3. Visit localhost:3000 6 | 7 | # Running Tests 8 | 9 | Run `yarn build && yarn test` from the root of the repo. 10 | 11 | To test individual modules, cd into them and run `yarn test`. For example, running the unit tests would be `cd modules/react-arborist && yarn test`. 12 | 13 | # Publishing a Release 14 | 15 | ### Release Branch 16 | 17 | 1. Checkout main locally 18 | 2. Increment the version number in modules/react-arborist/package.json 19 | 3. Create a branch called release/v0.0.0 20 | 4. Open a PR to main 21 | 5. Test, review, and merge, delete branch 22 | 23 | ### Create Github Release 24 | 25 | 1. Create a release based on main 26 | 2. Assign a new tag to be created with v0.0.0 27 | 3. Title the release "Version 0.0.0" 28 | 4. Write release notes 29 | 5. Publish 30 | 6. Check that it successfully published to npmjs 31 | 32 | The Github actions workflow will publish to npm. 33 | 34 | # Publish the Demo Site 35 | 36 | I run yarn build, then I copy the showcase/out directory into the netlify manual deploys interface. 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Brim Data 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 | -------------------------------------------------------------------------------- /bin/publish: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | yarn build 4 | cp README.md modules/react-arborist/README.md 5 | yarn workspace react-arborist npm publish $@ 6 | -------------------------------------------------------------------------------- /modules/docs/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | resources 4 | public 5 | hugo_stats.json 6 | -------------------------------------------------------------------------------- /modules/docs/.hugo_build.lock: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brimdata/react-arborist/c851c6f76ad5878f3ea7b68602e9b2f970df142e/modules/docs/.hugo_build.lock -------------------------------------------------------------------------------- /modules/docs/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 90, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "bracketSpacing": false, 6 | "quoteProps": "consistent", 7 | "trailingComma": "none", 8 | "arrowParens": "always", 9 | "plugins": ["prettier-plugin-go-template"], 10 | "overrides": [ 11 | { 12 | "files": ["*.html"], 13 | "options": { 14 | "parser": "go-template" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /modules/docs/README.md: -------------------------------------------------------------------------------- 1 | # Hugo Starter Site 2 | 3 | This is my favorite way to code static websites. It's a Hugo site with the following front-end technologies built in. 4 | 5 | ### CUBE CSS 6 | 7 | I copied much of the inspiring [cube-boilerplate](https://github.com/Set-Creative-Studio/cube-boilerplate/tree/main) into this Hugo enviroment. I've modified it to use the [utopia-core](https://github.com/trys/utopia-core) functions for font sizes and spacing. 8 | 9 | The boilerplate uses a modified tailwindcss config. In order to get tailwindy behavior in Hugo, I followed this [hugo-starter-tailwind-basic](https://github.com/bep/hugo-starter-tailwind-basic) from [bep](https://github.com/bep). 10 | 11 | ### Hotwired Turbo 12 | 13 | I use [@hotwired/turbo](https://github.com/hotwired/turbo) to speed everything up for free. 14 | 15 | ## Installation 16 | 17 | ```sh 18 | 19 | git clone https://github.com/jameskerr/hugo-starter 20 | 21 | mv hugo-starter my-cool-site # rename to something you want 22 | 23 | cd my-cool-site 24 | 25 | yarn 26 | 27 | hugo server 28 | ``` 29 | 30 | ## CSS Instructions 31 | 32 | Add your own CSS files anywhere in these directories to have them automatically included. 33 | 34 | - `assets/css/blocks/` 35 | - `assets/css/compositions/` 36 | - `assets/css/utilities/` 37 | 38 | Take a look at `assets/css/main.css` for how it all is stitched together. Also visit the docs for [CUBE CSS](https://cube.fyi/) and [Utopia](https://utopia.fyi/). 39 | 40 | ## JS Instructions 41 | 42 | Add your JavaScript files to `assets/js`, then import then into `assets/js/main.js`. These will get build using Hugo's [js.Build pipe](https://gohugo.io/hugo-pipes/js/). 43 | 44 | Enjoy! 45 | 46 | Authored by [James Kerr](http://jameskerr.blog) 47 | -------------------------------------------------------------------------------- /modules/docs/archetypes/default.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = '{{ replace .File.ContentBaseName "-" " " | title }}' 3 | date = {{ .Date }} 4 | draft = true 5 | +++ 6 | -------------------------------------------------------------------------------- /modules/docs/assets/css-utils/tokens-to-tailwind.js: -------------------------------------------------------------------------------- 1 | const slugify = require('slugify'); 2 | 3 | const nameSlug = (text) => { 4 | return slugify(text, {lower: true}); 5 | }; 6 | 7 | /** 8 | * Converts human readable tokens into tailwind config friendly ones 9 | * 10 | * @param {array} tokens {name: string, value: any} 11 | * @return {object} {key, value} 12 | */ 13 | const tokensToTailwind = (tokens, options = {slugify: true}) => { 14 | let response = {}; 15 | 16 | tokens.forEach(({name, value}) => { 17 | const key = options.slugify ? nameSlug(name) : name; 18 | response[key] = value; 19 | }); 20 | 21 | return response; 22 | }; 23 | 24 | module.exports = tokensToTailwind; 25 | -------------------------------------------------------------------------------- /modules/docs/assets/css/blocks/code-example.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brimdata/react-arborist/c851c6f76ad5878f3ea7b68602e9b2f970df142e/modules/docs/assets/css/blocks/code-example.css -------------------------------------------------------------------------------- /modules/docs/assets/css/blocks/prose.css: -------------------------------------------------------------------------------- 1 | .prose { 2 | --flow-space: var(--space-m); 3 | } 4 | 5 | .prose p { 6 | max-inline-size: var(--measure); 7 | } 8 | -------------------------------------------------------------------------------- /modules/docs/assets/css/compositions/cluster.css: -------------------------------------------------------------------------------- 1 | /* 2 | CLUSTER 3 | More info: https://every-layout.dev/layouts/cluster/ 4 | A layout that lets you distribute items with consitent 5 | spacing, regardless of their size 6 | 7 | CUSTOM PROPERTIES AND CONFIGURATION 8 | --gutter (var(--space-s-m)): This defines the space 9 | between each item. 10 | 11 | --cluster-horizontal-alignment (flex-start) How items should align 12 | horizontally. Can be any acceptable flexbox aligmnent value. 13 | 14 | --cluster-vertical-alignment How items should align vertically. 15 | Can be any acceptable flexbox alignment value. 16 | */ 17 | 18 | .cluster { 19 | display: flex; 20 | flex-wrap: wrap; 21 | gap: var(--gutter, var(--space-s-m)); 22 | justify-content: var(--cluster-horizontal-alignment, flex-start); 23 | align-items: var(--cluster-vertical-alignment, center); 24 | } 25 | -------------------------------------------------------------------------------- /modules/docs/assets/css/compositions/flow.css: -------------------------------------------------------------------------------- 1 | /* 2 | FLOW COMPOSITION 3 | Like the Every Layout stack: https://every-layout.dev/layouts/stack/ 4 | Info about this implementation: https://piccalil.li/quick-tip/flow-utility/ 5 | */ 6 | .flow > * + * { 7 | margin-top: var(--flow-space, 1em); 8 | } 9 | -------------------------------------------------------------------------------- /modules/docs/assets/css/compositions/grid.css: -------------------------------------------------------------------------------- 1 | /* AUTO GRID 2 | Related Every Layout: https://every-layout.dev/layouts/grid/ 3 | More info on the flexible nature: https://piccalil.li/tutorial/create-a-responsive-grid-layout-with-no-media-queries-using-css-grid/ 4 | A flexible layout that will create an auto-fill grid with 5 | configurable grid item sizes 6 | 7 | CUSTOM PROPERTIES AND CONFIGURATION 8 | --gutter (var(--space-s-m)): This defines the space 9 | between each item. 10 | 11 | --grid-min-item-size (14rem): How large each item should be 12 | ideally, as a minimum. 13 | 14 | --grid-placement (auto-fill): Set either auto-fit or auto-fill 15 | to change how empty grid tracks are handled */ 16 | 17 | .grid { 18 | display: grid; 19 | grid-template-columns: repeat( 20 | var(--grid-placement, auto-fill), 21 | minmax(var(--grid-min-item-size, 16rem), 1fr) 22 | ); 23 | gap: var(--gutter, var(--space-s-l)); 24 | } 25 | 26 | /* A split 50/50 layout */ 27 | .grid[data-layout='50-50'] { 28 | --grid-placement: auto-fit; 29 | --grid-min-item-size: clamp(16rem, 50vw, 33rem); 30 | } 31 | 32 | /* Three column grid layout */ 33 | .grid[data-layout='thirds'] { 34 | --grid-placement: auto-fit; 35 | --grid-min-item-size: clamp(16rem, 33%, 20rem); 36 | } 37 | 38 | /* Twelve column grid layout */ 39 | .grid[data-layout='twelfths'] { 40 | display: grid; 41 | grid-template-columns: repeat(12, 1fr); 42 | } 43 | 44 | /* Special layout for larger devices. Used on home page intro */ 45 | .grid[data-layout='lg:10/2'] { 46 | grid-template-columns: 100%; 47 | } 48 | 49 | @media screen(md) { 50 | .grid[data-layout='lg:10/2'] { 51 | grid-template-columns: clamp(40rem, 80vw, 60rem); 52 | } 53 | } 54 | 55 | @media screen(lg) { 56 | .grid[data-layout='lg:10/2'] { 57 | grid-template-columns: 10fr 2fr; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /modules/docs/assets/css/compositions/repel.css: -------------------------------------------------------------------------------- 1 | /* 2 | REPEL 3 | A little layout that pushes items away from each other where 4 | there is space in the viewport and stacks on small viewports 5 | 6 | CUSTOM PROPERTIES AND CONFIGURATION 7 | --gutter (var(--space-s-m)): This defines the space 8 | between each item. 9 | 10 | --repel-vertical-alignment How items should align vertically. 11 | Can be any acceptable flexbox alignment value. 12 | */ 13 | .repel { 14 | display: flex; 15 | flex-wrap: wrap; 16 | justify-content: space-between; 17 | align-items: var(--repel-vertical-alignment, center); 18 | gap: var(--gutter, var(--space-s-m)); 19 | } 20 | 21 | .repel[data-nowrap] { 22 | flex-wrap: nowrap; 23 | } 24 | -------------------------------------------------------------------------------- /modules/docs/assets/css/compositions/sidebar.css: -------------------------------------------------------------------------------- 1 | /* 2 | SIDEBAR 3 | More info: https://every-layout.dev/layouts/sidebar/ 4 | A layout that allows you to have a flexible main content area 5 | and a "fixed" width sidebar that sits on the left or right. 6 | If there is not enough viewport space to fit both the sidebar 7 | width *and* the main content minimum width, they will stack 8 | on top of each other 9 | 10 | CUSTOM PROPERTIES AND CONFIGURATION 11 | --gutter (var(--space-size-1)): This defines the space 12 | between the sidebar and main content. 13 | 14 | --sidebar-target-width (20rem): How large the sidebar should be 15 | 16 | --sidebar-content-min-width(50%): The minimum size of the main content area 17 | 18 | EXCEPTIONS 19 | .sidebar[data-direction='rtl']: flips the sidebar to be on the right 20 | */ 21 | .sidebar { 22 | display: flex; 23 | flex-wrap: wrap; 24 | gap: var(--gutter, var(--space-s-l)); 25 | } 26 | 27 | .sidebar > :first-child { 28 | flex-basis: var(--sidebar-target-width, 20rem); 29 | flex-grow: 1; 30 | } 31 | 32 | .sidebar > :last-child { 33 | flex-basis: 0; 34 | flex-grow: 999; 35 | min-width: var(--sidebar-content-min-width, 50%); 36 | } 37 | -------------------------------------------------------------------------------- /modules/docs/assets/css/compositions/switcher.css: -------------------------------------------------------------------------------- 1 | /* 2 | SWITCHER 3 | More info: https://every-layout.dev/layouts/switcher/ 4 | A layout that allows you to lay **2** items next to each other 5 | until there is not enough horizontal space to allow that. 6 | 7 | CUSTOM PROPERTIES AND CONFIGURATION 8 | --gutter (var(--space-size-1)): This defines the space 9 | between each item 10 | 11 | --switcher-target-container-width (40rem): How large the container 12 | needs to be to allow items to sit inline with each other 13 | 14 | --switcher-vertical-alignment How items should align vertically. 15 | Can be any acceptable flexbox alignment value. 16 | */ 17 | .switcher { 18 | display: flex; 19 | flex-wrap: wrap; 20 | gap: var(--gutter, var(--space-s-l)); 21 | align-items: var(--switcher-vertical-alignment, flex-start); 22 | } 23 | 24 | .switcher > * { 25 | flex-grow: 1; 26 | flex-basis: calc((var(--switcher-target-container-width, 40rem) - 100%) * 999); 27 | } 28 | 29 | /* Max 2 items, 30 | so anything greater than 2 is full width */ 31 | .switcher > :nth-child(n + 3) { 32 | flex-basis: 100%; 33 | } 34 | -------------------------------------------------------------------------------- /modules/docs/assets/css/compositions/wrapper.css: -------------------------------------------------------------------------------- 1 | /* 2 | WRAPPER COMPOSITION 3 | A common wrapper/container 4 | */ 5 | .wrapper { 6 | margin-inline: auto; 7 | max-width: clamp(16rem, var(--wrapper-max-width, 100vw), 80rem); 8 | padding-left: var(--gutter); 9 | padding-right: var(--gutter); 10 | position: relative; 11 | } 12 | -------------------------------------------------------------------------------- /modules/docs/assets/css/global/fonts.css: -------------------------------------------------------------------------------- 1 | /* @font-face here */ 2 | -------------------------------------------------------------------------------- /modules/docs/assets/css/global/global.css: -------------------------------------------------------------------------------- 1 | /* 2 | Global styles 3 | 4 | Low-specificity, global styles that apply to the whole 5 | project: https://cube.fyi/css.html 6 | */ 7 | body { 8 | background: var(--color-light); 9 | color: var(--color-dark); 10 | font-size: var(--size-step-0); 11 | font-family: var(--font-base); 12 | line-height: var(--leading-standard); 13 | } 14 | 15 | pre { 16 | font-size: var(--size-step--1); 17 | padding: var(--space-s); 18 | border-radius: var(--radius-m); 19 | overflow-x: auto; 20 | margin: 0; 21 | } 22 | -------------------------------------------------------------------------------- /modules/docs/assets/css/global/reset.css: -------------------------------------------------------------------------------- 1 | /* Modern reset: https://piccalil.li/blog/a-more-modern-css-reset/ */ 2 | 3 | /* Box sizing rules */ 4 | *, 5 | *::before, 6 | *::after { 7 | box-sizing: border-box; 8 | } 9 | 10 | /* Prevent font size inflation */ 11 | html { 12 | -moz-text-size-adjust: none; 13 | -webkit-text-size-adjust: none; 14 | text-size-adjust: none; 15 | } 16 | 17 | /* Remove default margin in favour of better control in authored CSS */ 18 | body, 19 | h1, 20 | h2, 21 | h3, 22 | h4, 23 | p, 24 | figure, 25 | blockquote, 26 | dl, 27 | dd { 28 | margin-block-end: 0; 29 | } 30 | 31 | /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */ 32 | ul[role='list'], 33 | ol[role='list'] { 34 | list-style: none; 35 | } 36 | 37 | /* Set core body defaults */ 38 | body { 39 | min-height: 100vh; 40 | line-height: 1.5; 41 | } 42 | 43 | /* Set shorter line heights on headings and interactive elements */ 44 | h1, 45 | h2, 46 | h3, 47 | h4, 48 | button, 49 | input, 50 | label { 51 | line-height: 1.1; 52 | } 53 | 54 | /* Balance text wrapping on headings */ 55 | h1, 56 | h2, 57 | h3, 58 | h4 { 59 | text-wrap: balance; 60 | } 61 | 62 | /* A elements that don't have a class get default styles */ 63 | a:not([class]) { 64 | text-decoration-skip-ink: auto; 65 | color: currentColor; 66 | } 67 | 68 | /* Make images easier to work with */ 69 | img, 70 | picture { 71 | max-width: 100%; 72 | display: block; 73 | } 74 | 75 | /* Inherit fonts for inputs and buttons */ 76 | input, 77 | button, 78 | textarea, 79 | select { 80 | font: inherit; 81 | } 82 | 83 | /* Make sure textareas without a rows attribute are not tiny */ 84 | textarea:not([rows]) { 85 | min-height: 10em; 86 | } 87 | 88 | /* Anything that has been anchored to should have extra scroll margin */ 89 | :target { 90 | scroll-margin-block: 5ex; 91 | } 92 | -------------------------------------------------------------------------------- /modules/docs/assets/css/global/variables.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --gutter: var(--space-s-l); 3 | --transition-base: 250ms ease; 4 | --transition-movement: 200ms linear; 5 | --transition-fade: 300ms ease; 6 | --transition-bounce: 500ms cubic-bezier(0.5, 0.05, 0.2, 1.5); 7 | --leading-standard: 1.5; 8 | --measure: 60ch; 9 | --radius-m: 8px; 10 | } 11 | -------------------------------------------------------------------------------- /modules/docs/assets/css/main.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | 3 | @import 'assets/css/global/reset.css'; 4 | @import 'assets/css/global/fonts.css'; 5 | 6 | @import 'tailwindcss/components'; 7 | @import 'assets/css/global/variables.css'; 8 | @import 'assets/css/global/global.css'; 9 | 10 | @import-glob 'assets/css/blocks/*.css'; 11 | @import-glob 'assets/css/compositions/*.css'; 12 | @import-glob 'assets/css/utilities/*.css'; 13 | 14 | @import 'tailwindcss/utilities'; 15 | -------------------------------------------------------------------------------- /modules/docs/assets/css/utilities/region.css: -------------------------------------------------------------------------------- 1 | /* 2 | REGION UTILITY 3 | Consistent block padding for page sections 4 | */ 5 | .region { 6 | padding-block: var(--region-space, var(--space-xl-2xl)); 7 | } 8 | -------------------------------------------------------------------------------- /modules/docs/assets/css/utilities/visually-hidden.css: -------------------------------------------------------------------------------- 1 | /* 2 | VISUALLY HIDDEN UTILITY 3 | Info: https://piccalil.li/quick-tip/visually-hidden/ 4 | */ 5 | .visually-hidden { 6 | border: 0; 7 | clip: rect(0 0 0 0); 8 | height: 0; 9 | margin: 0; 10 | overflow: hidden; 11 | padding: 0; 12 | position: absolute; 13 | width: 1px; 14 | white-space: nowrap; 15 | } 16 | -------------------------------------------------------------------------------- /modules/docs/assets/design-tokens/colors.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Colors", 3 | "description": "Hex color codes that can be shared, cross-platform. They can be converted at point of usage, such as HSL for web or CMYK for print.", 4 | "items": [ 5 | { 6 | "name": "Dark", 7 | "value": "#030303" 8 | }, 9 | { 10 | "name": "Light", 11 | "value": "#ffffff" 12 | }, 13 | { 14 | "name": "Primary", 15 | "value": "#02394A" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /modules/docs/assets/design-tokens/fonts.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Fonts", 3 | "description": "Each array of fonts creates a priority-based order. The first font in the array should be the ideal font, followed by sensible, web-safe fallbacks", 4 | "items": [ 5 | { 6 | "name": "Base", 7 | "description": "System fonts for body copy and globally set text.", 8 | "value": ["Rooney", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"] 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /modules/docs/assets/design-tokens/spacing.js: -------------------------------------------------------------------------------- 1 | const {calculateSpaceScale} = require('utopia-core'); 2 | 3 | module.exports = { 4 | title: 'Spacing', 5 | description: 6 | 'Consistent spacing sizes, based on a ratio, with min and max sizes. This allows you to set spacing based on the context size. For example, min for mobile and max for desktop browsers.', 7 | meta: { 8 | scaleGenerator: 9 | 'https://utopia.fyi/space/calculator/?c=330,18,1.2,1200,24,1.25,6,2,&s=0.75|0.5|0.25,1.5|2|3|4|6|8,s-l|s-xl&g=s,l,xl,12' 10 | }, 11 | items: Object.values( 12 | calculateSpaceScale({ 13 | minWidth: 320, 14 | maxWidth: 1240, 15 | minSize: 18, 16 | maxSize: 20, 17 | positiveSteps: [1.5, 2, 3, 4, 6], 18 | negativeSteps: [0.75, 0.5, 0.25], 19 | customSizes: ['s-l', '2xl-4xl'] 20 | }) 21 | ) 22 | .flatMap(value => value) 23 | .map(spacing => { 24 | return {name: spacing.label, value: spacing.clamp}; 25 | }) 26 | }; 27 | -------------------------------------------------------------------------------- /modules/docs/assets/design-tokens/text-leading.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Leading", 3 | "description": "Ratio-based leading/line-height values", 4 | "items": [ 5 | { 6 | "name": "Flat", 7 | "value": 1 8 | }, 9 | { 10 | "name": "Fine", 11 | "value": 1.15 12 | }, 13 | { 14 | "name": "Standard", 15 | "value": 1.5 16 | }, 17 | { 18 | "name": "Loose", 19 | "value": 1.7 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /modules/docs/assets/design-tokens/text-sizes.js: -------------------------------------------------------------------------------- 1 | const {calculateTypeScale} = require('utopia-core'); 2 | 3 | module.exports = { 4 | items: calculateTypeScale({ 5 | minWidth: 320, 6 | maxWidth: 1240, 7 | minFontSize: 18, 8 | maxFontSize: 20, 9 | minTypeScale: 1.2, 10 | maxTypeScale: 1.25, 11 | positiveSteps: 5, 12 | negativeSteps: 2 13 | }).map(size => { 14 | return {name: 'step-' + size.step, value: size.clamp}; 15 | }) 16 | }; 17 | -------------------------------------------------------------------------------- /modules/docs/assets/design-tokens/text-weights.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Text Weights", 3 | "description": "Helper classes and custom properties for common font weights", 4 | "meta": {}, 5 | "items": [ 6 | { 7 | "name": "Regular", 8 | "value": 400 9 | }, 10 | { 11 | "name": "Medium", 12 | "value": 500 13 | }, 14 | { 15 | "name": "Bold", 16 | "value": 700 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /modules/docs/assets/design-tokens/viewports.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Viewports", 3 | "description": "The min and maximum viewports used to generate fluid type and space scales.", 4 | "min": 330, 5 | "mid": 760, 6 | "max": 1230 7 | } 8 | -------------------------------------------------------------------------------- /modules/docs/assets/js/main.js: -------------------------------------------------------------------------------- 1 | // Fast for Free 2 | import '@hotwired/turbo'; 3 | -------------------------------------------------------------------------------- /modules/docs/content/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Home 3 | --- 4 | 5 |
6 | 7 |
8 | React Arborist is a library for rendering tree-like data structures. This is a common UI element in many desktop and web applications. The classic example is displaying files on a file system. 9 | 10 | React Arborist follows the partially-controlled component pattern. This means that you can opt-in to state control the component as you need to. Otherwise, the state is controlled internally. 11 | 12 | Let's get a tree setup. 13 | 14 | Suppose we have data that looks like this. 15 | 16 |
17 | 18 |
19 | 20 | ```js 21 | const data = { 22 | name: 'code', 23 | path: '/users/jk', 24 | files: [ 25 | { 26 | name: 'react-arborist', 27 | path: '/users/jk/code', 28 | files: [ 29 | { 30 | name: 'package.json', 31 | path: '/users/jk/code/react-arborist' 32 | }, 33 | { 34 | name: '.prettierrc', 35 | path: '/users/jk/code/react-arborist' 36 | } 37 | ] 38 | } 39 | ] 40 | }; 41 | ``` 42 | 43 |
44 |
45 | 46 |
47 | 48 |
49 | It's important to note that tree data can come in many shapes and sizes. However, they do all follow a common pattern. The `Tree` component that react-arborist exports needs you to convert your data into an array Nodes. 50 | 51 | There is a utility you can use to turn any data into nodes. It works like this. 52 | 53 |
54 |
55 | 56 | ```jsx 57 | // #APPROVED 58 | const [data, setData] = useState(myRandomData); 59 | const nodes = createNodes(data, { 60 | id: (d) => d.path, 61 | name: (d) => d.name, 62 | children: (d) => d.files, 63 | isLeaf: (d) => !('files' in d), 64 | sort: (a, b) => a.name - b.name, 65 | isVisible: (node) => true // not sure about his yet 66 | }); 67 | 68 | setData(nodes.handleChange(e)) 72 | }} 73 | > 74 | ``` 75 | 76 | ```jsx 77 | // #APPROVED 78 | const nodes = useNodes(myRandomData, { 79 | id: (d) => d.path, 80 | name: (d) => d.name, 81 | children: (d) => d.files, 82 | isLeaf: (d) => !('files' in d), 83 | sort: (a, b) => a.name - b.name, 84 | isVisible: (node) => true // not sure about his yet 85 | }); 86 | 87 | ; 88 | ``` 89 | 90 |
91 |
92 | 93 |
94 |
95 | You can then pass these nodes to the Tree Component. 96 |
97 | 98 |
99 |
100 | 101 | ```jsx 102 | setNodes(newValue) 106 | }} 107 | > 108 | {Node} 109 | 110 | ``` 111 | 112 |
113 |
114 | 115 |
116 |
117 | Now let's say you want to persist the expanded and collapsed state of the tree. You will opt-in to control that peice of state by providing the expanded prop. 118 |
119 |
120 | 121 | ```jsx 122 | // If you keep the open data within your tree data, 123 | // you can extract it with the useOpens hook. 124 | 125 | const [opens, setOpens] = useOpens(data, { 126 | id: (d) => d.path 127 | isOpen = (d) => d.isOpen 128 | }) 129 | 130 | // Otherwise, you can provide your own object 131 | // to keep track of it. 132 | const [opens, setOpens] = useState({}) 133 | 134 | setOpens(newValue) 138 | }} 139 | /> 140 | ``` 141 | 142 |
143 |
144 | 145 | Finally, you may also want to keep track and of the selection state, and be able to change the selection from outside the tree. A nice way to do that might be. 146 | 147 | ```jsx 148 | 149 | const id = useSelector(Current.fileId) 150 | const selection = useMultiSelection(id) 151 | 152 | useEffect(() => { 153 | selection.only(id) 154 | }, [id]) 155 | 156 | 162 | ``` 163 | 164 | Ok, now maybe you want to sync the tree state with an external data store, like your backend database. Use the the following callbacks to get that done. 165 | 166 | ```jsx 167 | const nodes = useNodes(data) 168 | 169 | api.move(args)} 171 | onEdit={(args) => api.edit(args)} 172 | onCreate={() => api.create(args)} 173 | onDelete={() => api.destroy(args)} 174 | onOpen={(args) => { 175 | nodes.addChild(id, {loading: true}) 176 | api.fetchChildren(id) 177 | }} 178 | onSelect={} 179 | onFocus={} 180 | nodes={{ 181 | value: nodes.value, 182 | onChange: nodes.set 183 | }} 184 | selection={{ 185 | value: selection.value, 186 | onChange: selection.set 187 | }} 188 | opens={{ 189 | value: opens.value, 190 | onChange: opens.set 191 | }} 192 | focus={{ 193 | value: focus.value, 194 | onChange: focus.set 195 | }} 196 | dnd={{ 197 | value: dnd.value, 198 | onChange: dnd.set 199 | }} 200 | treeState={{ 201 | value: state.value, 202 | onChange: state.set 203 | }} 204 | /> 205 | ``` 206 | 207 | ## How would you handle changing the selection externally as well as internally. 208 | 209 | Well let's think about this. How do we do this in smaller components. We have an input, whenever it changes we handle it in the callback then we change the state. The onChange handler does not fire if we set the state ourselves. 210 | 211 | The same should work in our app. The onSelect callback should fire if it changes internally, but it should not if it is changed from the outside. 212 | 213 | ```jsx 214 | const [value, setValue] = useState('hello world'); 215 | 216 | return setValue(e.target.value)} />; 217 | ``` 218 | 219 | ```jsx 220 | const selection = useMultiSelection(); 221 | 222 | return ( 223 | { 227 | // Now we will include all the relevant information 228 | // to perform side effects in here. 229 | // This will run if changed internally, 230 | // This will not run if changed externally 231 | selection.set(e.target.value); 232 | } 233 | }} 234 | /> 235 | ); 236 | ``` 237 | 238 | That works. Love that. 239 | 240 | ## Handle Node Manipulation 241 | 242 | ```jsx 243 | const nodes = useNodes(/* */); 244 | 245 | return ( 246 | { 250 | // e.type === "new" | "create" | "update" | "delete" | "move" 251 | // e.payload = { 252 | id: //string 253 | isLeaf:// 254 | parentId: // id, 255 | index://n 256 | 257 | } 258 | nodes.set(e.value); 259 | } 260 | }} 261 | /> 262 | ); 263 | ``` 264 | 265 | Yup, that's going to work great. 266 | 267 | ## Tree filtering can now happen in the useNodes hook. 268 | 269 | ```jsx 270 | const nodes = useNodes({ 271 | searchTerm: '', 272 | searchMatch: leafs | leavesAndInternal | custom 273 | }); 274 | ``` 275 | 276 | ## Changing the selection externally 277 | 278 | ```jsx 279 | const chatId = useCurrentChatId(); 280 | const selection = useSelection(); 281 | 282 | useEffect(() => { 283 | selection.selectOne(chatId, {scroll: 'center'}); 284 | }, [chatId]); 285 | 286 | return ( 287 | 292 | ); 293 | ``` 294 | 295 | ## Select All 296 | 297 | ```jsx 298 | const tree = useTree(data, { 299 | nodes: { 300 | id: (d) => d.path, 301 | children: (d) => d.items, 302 | searchTerm: 'hi', 303 | searchFilter: leavesOnly 304 | }, 305 | selection: { 306 | id: (data) => select(Current.getPath) 307 | }, 308 | opens: { 309 | id: (d) => d.path, 310 | isOpen: (d) => d.isOpen 311 | }, 312 | keybindings: (defaults) => { 313 | return { 314 | ...defaults(), 315 | "cmd+a": () => tree.thisThat() 316 | "up": () => tree.focus.up() 317 | } 318 | } 319 | }); 320 | 321 | return ( 322 | {} 326 | row: () => {} 327 | cursor: () => {} 328 | 329 | }} 330 | /> 331 | ); 332 | ``` 333 | -------------------------------------------------------------------------------- /modules/docs/content/terms.md: -------------------------------------------------------------------------------- 1 | # Terms 2 | 3 | This is the list of the common domain models found in react-arborist. 4 | 5 | * **Tree View**: The main component that renders the UI. 6 | * **Source Data**: Any data you bring from the outside world. 7 | * **Node Object**: An interface that the TreeManager and TreeController expects 8 | * **Source Data Proxy**: A wrapper around SourceData conforming to the NodeObject with methods to mutating SourceData. 9 | * **Tree Manager:** Responsible for responding to change events from the TreeView 10 | * **Tree Controller**: The programming API for developers to interact with the tree. 11 | * **Node Controller**: The programming API for developers to interact with the node. 12 | * **Partial Controller**: An object with the properties `value` and `onChange`, used for managing a slice of the component's state. 13 | 14 | ## Details 15 | 16 | When you reach for react-arborist you usually will bring with you some _source data_ that you with to render with the _TreeView_ component. 17 | 18 | The _TreeView_ component receives many props, one of which is called "nodes". The "nodes" prop is a _partial controller_ object with `value` and `onChange` properties. The nodes partial controller value must be an array of _node objects_. 19 | 20 | 21 | 22 | A _node object_ is anything with the following interface: 23 | 24 | ```ts 25 | type NodeObject = { 26 | id: string; 27 | data: T; 28 | parent: NodeObject | null; 29 | children: NodeObject[] | null; 30 | isLeaf: boolean; 31 | level: number; 32 | } 33 | ``` 34 | 35 | 36 | You can convert the _source data_ into _node objects_ yourself, or you can use a helper function provided by react-arborist called `createTreeManager(sourceData, options)`. It will return a _TreeManager_ instance. The _tree manager_ will have a property called "nodes" which will return an array of objects that conform to the _node object_ interface. However, they will be instances of the _SourceDataProxy_ class. These objects have all the properties required of _node objects_ along with methods to mutate the _source data_ it contains. The _TreeManager_ also has methods for mutating the _source data_ in response to change events. 37 | 38 | ### Internals 39 | 40 | Now let's see the internals of the _TreeView_ component. All props are given to the _TreeController_ class. The UI components will inquire this _tree controller_ for the data and state they need to render UI. 41 | 42 | During a render, the _tree controller_ will flatten the _node objects_ into an array of _node controllers_. This array can be accessed with the `.nodes` property. 43 | 44 | To create a _NodeController_ instance, you will need the parent _tree controller_, the relevant _node object_ and the _row index_ where it will be rendered in UI. That class provides a bunch of convince methods for developers to use when rendering the UI. -------------------------------------------------------------------------------- /modules/docs/hugo.toml: -------------------------------------------------------------------------------- 1 | baseURL = 'https://example.org/' 2 | languageCode = 'en-us' 3 | title = 'React Arborist' 4 | 5 | [markup.goldmark.renderer] 6 | unsafe = true 7 | 8 | [module] 9 | [module.hugoVersion] 10 | extended = false 11 | min = "0.112.0" 12 | 13 | [[module.mounts]] 14 | source = "assets" 15 | target = "assets" 16 | 17 | [[module.mounts]] 18 | source = "hugo_stats.json" 19 | target = "assets/watching/hugo_stats.json" 20 | 21 | [build] 22 | writeStats = true 23 | [[build.cachebusters]] 24 | source = "assets/watching/hugo_stats\\.json" 25 | target = "main\\.css" 26 | [[build.cachebusters]] 27 | source = "(postcss|tailwind)\\.config\\.js" 28 | target = "css" 29 | [[build.cachebusters]] 30 | source = "assets/.*\\.(js|ts|jsx|tsx)" 31 | target = "js" 32 | [[build.cachebusters]] 33 | source = "assets/.*\\.(.*)$" 34 | target = "$1" 35 | -------------------------------------------------------------------------------- /modules/docs/layouts/_default/baseof.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | {{ partial "head.html" . }} 8 | 9 | 10 |
11 | {{ partial "header.html" . }} 12 |
13 |
14 | {{ block "main" . }}{{ end }} 15 |
16 |
17 | {{ partial "footer.html" . }} 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /modules/docs/layouts/_default/home.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 |
3 | {{ .Content }} 4 |
5 | {{ range site.RegularPages }} 6 |

{{ .LinkTitle }}

7 | {{ .Summary }} 8 | {{ end }} 9 | {{ end }} 10 | -------------------------------------------------------------------------------- /modules/docs/layouts/_default/list.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 |

{{ .Title }}

3 | {{ .Content }} 4 | {{ range .Pages }} 5 |

{{ .LinkTitle }}

6 | {{ .Summary }} 7 | {{ end }} 8 | {{ end }} 9 | -------------------------------------------------------------------------------- /modules/docs/layouts/_default/single.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 |

{{ .Title }}

3 | 4 | {{ $dateMachine := .Date | time.Format "2006-01-02T15:04:05-07:00" }} 5 | {{ $dateHuman := .Date | time.Format ":date_long" }} 6 | 7 | 8 | {{ .Content }} 9 | {{ partial "terms.html" (dict "taxonomy" "tags" "page" .) }} 10 | {{ end }} 11 | -------------------------------------------------------------------------------- /modules/docs/layouts/partials/footer.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /modules/docs/layouts/partials/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ if .IsHome }} 5 | {{ site.Title }} 6 | {{ else }} 7 | {{ printf "%s | %s" .Title site.Title }} 8 | {{ end }} 9 | 10 | {{ partialCached "head/css.html" . }} 11 | {{ partialCached "head/js.html" . }} 12 | -------------------------------------------------------------------------------- /modules/docs/layouts/partials/head/css.html: -------------------------------------------------------------------------------- 1 | {{- with resources.Get "css/main.css" | postCSS }} 2 | {{- if eq hugo.Environment "development" }} 3 | 4 | {{- else }} 5 | {{- with . | minify | fingerprint }} 6 | 12 | {{- end }} 13 | {{- end }} 14 | {{- end }} 15 | -------------------------------------------------------------------------------- /modules/docs/layouts/partials/head/js.html: -------------------------------------------------------------------------------- 1 | {{- with resources.Get "js/main.js" }} 2 | {{- if eq hugo.Environment "development" }} 3 | {{- $opts := dict "format" "esm" }} 4 | {{- with . | js.Build }} 5 | 6 | {{- end }} 7 | {{- else }} 8 | {{- $opts := dict "minify" true }} 9 | {{- with . | js.Build $opts | fingerprint }} 10 | 16 | {{- end }} 17 | {{- end }} 18 | {{- end }} 19 | -------------------------------------------------------------------------------- /modules/docs/layouts/partials/header.html: -------------------------------------------------------------------------------- 1 |
2 |

{{ site.Title }}

3 | {{ partial "menu.html" (dict "menuID" "main" "page" .) }} 4 |
5 | -------------------------------------------------------------------------------- /modules/docs/layouts/partials/menu.html: -------------------------------------------------------------------------------- 1 | {{- /* 2 | Renders a menu for the given menu ID. 3 | 4 | @context {page} page The current page. 5 | @context {string} menuID The menu ID. 6 | 7 | @example: {{ partial "menu.html" (dict "menuID" "main" "page" .) }} 8 | */}} 9 | 10 | {{- $page := .page }} 11 | {{- $menuID := .menuID }} 12 | 13 | {{- with index site.Menus $menuID }} 14 | 19 | {{- end }} 20 | 21 | {{- define "partials/inline/menu/walk.html" }} 22 | {{- $page := .page }} 23 | {{- range .menuEntries }} 24 | {{- $attrs := dict "href" .URL }} 25 | {{- if $page.IsMenuCurrent .Menu . }} 26 | {{- $attrs = merge $attrs (dict "class" "active" "aria-current" "page") }} 27 | {{- else if $page.HasMenuCurrent .Menu .}} 28 | {{- $attrs = merge $attrs (dict "class" "ancestor" "aria-current" "true") }} 29 | {{- end }} 30 | {{- $name := .Name }} 31 | {{- with .Identifier }} 32 | {{- with T . }} 33 | {{- $name = . }} 34 | {{- end }} 35 | {{- end }} 36 |
  • 37 | {{ $name }} 44 | {{- with .Children }} 45 |
      46 | {{- partial "inline/menu/walk.html" (dict "page" $page "menuEntries" .) }} 47 |
    48 | {{- end }} 49 |
  • 50 | {{- end }} 51 | {{- end }} 52 | -------------------------------------------------------------------------------- /modules/docs/layouts/partials/terms.html: -------------------------------------------------------------------------------- 1 | {{- /* 2 | For a given taxonomy, renders a list of terms assigned to the page. 3 | 4 | @context {page} page The current page. 5 | @context {string} taxonomy The taxonony. 6 | 7 | @example: {{ partial "terms.html" (dict "taxonomy" "tags" "page" .) }} 8 | */}} 9 | 10 | {{- $page := .page }} 11 | {{- $taxonomy := .taxonomy }} 12 | 13 | {{- with $page.GetTerms $taxonomy }} 14 | {{- $label := (index . 0).Parent.LinkTitle }} 15 |
    16 |
    {{ $label }}:
    17 | 22 |
    23 | {{- end }} 24 | -------------------------------------------------------------------------------- /modules/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "ISC", 3 | "dependencies": { 4 | "@hotwired/turbo": "^8.0.4", 5 | "autoprefixer": "^10.4.18", 6 | "clean-css": "^5.3.2", 7 | "concurrently": "^8.2.2", 8 | "cssnano": "^6.0.1", 9 | "postcss": "^8.4.31", 10 | "postcss-cli": "^10.1.0", 11 | "postcss-import": "^15.1.0", 12 | "postcss-import-ext-glob": "^2.1.1", 13 | "postcss-js": "^4.0.1", 14 | "prettier": "^3.2.5", 15 | "prettier-plugin-go-template": "^0.0.15", 16 | "slugify": "^1.6.6", 17 | "tailwindcss": "^3.3.5", 18 | "utopia-core": "^1.3.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /modules/docs/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-import-ext-glob'), 4 | require('postcss-import'), 5 | require('tailwindcss'), 6 | require('autoprefixer') 7 | ] 8 | }; 9 | -------------------------------------------------------------------------------- /modules/docs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const plugin = require('tailwindcss/plugin'); 2 | const postcss = require('postcss'); 3 | const postcssJs = require('postcss-js'); 4 | 5 | const tokensToTailwind = require('./assets/css-utils/tokens-to-tailwind.js'); 6 | 7 | // Raw design tokens 8 | const colorTokens = require('./assets/design-tokens/colors.json'); 9 | const fontTokens = require('./assets/design-tokens/fonts.json'); 10 | const spacingTokens = require('./assets/design-tokens/spacing.js'); 11 | const textSizeTokens = require('./assets/design-tokens/text-sizes.js'); 12 | const textLeadingTokens = require('./assets/design-tokens/text-leading.json'); 13 | const textWeightTokens = require('./assets/design-tokens/text-weights.json'); 14 | const viewportTokens = require('./assets/design-tokens/viewports.json'); 15 | 16 | // Process design tokens 17 | const colors = tokensToTailwind(colorTokens.items); 18 | const fontFamily = tokensToTailwind(fontTokens.items); 19 | const fontWeight = tokensToTailwind(textWeightTokens.items); 20 | const fontSize = tokensToTailwind(textSizeTokens.items, {slugify: false}); 21 | const lineHeight = tokensToTailwind(textLeadingTokens.items); 22 | const spacing = tokensToTailwind(spacingTokens.items); 23 | 24 | module.exports = { 25 | content: ['./hugo_stats.json'], 26 | // Add color classes to safe list so they are always generated 27 | safelist: [], 28 | presets: [], 29 | theme: { 30 | screens: { 31 | sm: `${viewportTokens.min}px`, 32 | md: `${viewportTokens.mid}px`, 33 | lg: `${viewportTokens.max}px` 34 | }, 35 | colors, 36 | spacing, 37 | fontSize, 38 | lineHeight, 39 | fontFamily, 40 | fontWeight, 41 | backgroundColor: ({theme}) => theme('colors'), 42 | textColor: ({theme}) => theme('colors'), 43 | margin: ({theme}) => ({ 44 | auto: 'auto', 45 | ...theme('spacing') 46 | }), 47 | padding: ({theme}) => theme('spacing') 48 | }, 49 | variantOrder: [ 50 | 'first', 51 | 'last', 52 | 'odd', 53 | 'even', 54 | 'visited', 55 | 'checked', 56 | 'empty', 57 | 'read-only', 58 | 'group-hover', 59 | 'group-focus', 60 | 'focus-within', 61 | 'hover', 62 | 'focus', 63 | 'focus-visible', 64 | 'active', 65 | 'disabled' 66 | ], 67 | 68 | // Disables Tailwind's reset and usage of rgb/opacity 69 | corePlugins: { 70 | preflight: false, 71 | textOpacity: false, 72 | backgroundOpacity: false, 73 | borderOpacity: false 74 | }, 75 | 76 | // Prevents Tailwind's core components 77 | blocklist: ['container'], 78 | 79 | // Prevents Tailwind from generating that wall of empty custom properties 80 | experimental: { 81 | optimizeUniversalDefaults: true 82 | }, 83 | 84 | plugins: [ 85 | // Generates custom property values from tailwind config 86 | plugin(function ({addComponents, config}) { 87 | let result = ''; 88 | 89 | const currentConfig = config(); 90 | const groups = [ 91 | {key: 'colors', prefix: 'color'}, 92 | {key: 'spacing', prefix: 'space'}, 93 | {key: 'fontSize', prefix: 'size'}, 94 | {key: 'lineHeight', prefix: 'leading'}, 95 | {key: 'fontFamily', prefix: 'font'}, 96 | {key: 'fontWeight', prefix: 'font'} 97 | ]; 98 | 99 | groups.forEach(({key, prefix}) => { 100 | const group = currentConfig.theme[key]; 101 | if (!group) { 102 | return; 103 | } 104 | 105 | Object.keys(group).forEach((key) => { 106 | result += `--${prefix}-${key}: ${group[key]};`; 107 | }); 108 | }); 109 | const thing = postcssJs.objectify(postcss.parse(result)); 110 | addComponents({ 111 | ':root': postcssJs.objectify(postcss.parse(result)) 112 | }); 113 | }), 114 | 115 | // Generates custom utility classes 116 | plugin(function ({addUtilities, config}) { 117 | const currentConfig = config(); 118 | const customUtilities = [ 119 | {key: 'spacing', prefix: 'flow-space', property: '--flow-space'}, 120 | {key: 'spacing', prefix: 'region-space', property: '--region-space'}, 121 | {key: 'spacing', prefix: 'gutter', property: '--gutter'}, 122 | {key: 'fontSize', prefix: 'size', property: 'font-size'} 123 | ]; 124 | 125 | customUtilities.forEach(({key, prefix, property}) => { 126 | const group = currentConfig.theme[key]; 127 | 128 | if (!group) { 129 | return; 130 | } 131 | 132 | Object.keys(group).forEach((key) => { 133 | console.log(`.${prefix}-${key}`); 134 | addUtilities({ 135 | [`.${prefix}-${key}`]: postcssJs.objectify( 136 | postcss.parse(`${property}: ${group[key]}`) 137 | ) 138 | }); 139 | }); 140 | }); 141 | }) 142 | ] 143 | }; 144 | -------------------------------------------------------------------------------- /modules/e2e/.gitignore: -------------------------------------------------------------------------------- 1 | cypress/videos 2 | cypress/screenshots 3 | cypress/downloads 4 | -------------------------------------------------------------------------------- /modules/e2e/README.md: -------------------------------------------------------------------------------- 1 | # e2e 2 | -------------------------------------------------------------------------------- /modules/e2e/cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("cypress"); 2 | 3 | module.exports = defineConfig({ 4 | e2e: { 5 | setupNodeEvents(on, config) { 6 | // implement node event listeners here 7 | }, 8 | viewportHeight: 900, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /modules/e2e/cypress/e2e/cities-spec.cy.js: -------------------------------------------------------------------------------- 1 | describe("Testing the Cities Demo", () => { 2 | beforeEach(() => { 3 | cy.visit("http://localhost:3000/cities"); 4 | }); 5 | 6 | it("Does not steel the selection when the selection prop changes", () => { 7 | cy.get("button").contains("Select San Francisco").click(); 8 | cy.focused().invoke("is", "button").should("equal", true); 9 | cy.focused().should("have.text", "Select San Francisco"); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /modules/e2e/cypress/e2e/gmail-spec.cy.js: -------------------------------------------------------------------------------- 1 | const TOTAL_ITEMS = 17; 2 | 3 | describe("Testing the Gmail Demo", () => { 4 | beforeEach(() => { 5 | cy.visit("http://localhost:3000/gmail"); 6 | cy.get("[role=treeitem]").as("item"); 7 | }); 8 | 9 | it("Edits The Social Node", () => { 10 | cy.get("[role=treeitem").contains("Social").click(); 11 | cy.focused().type("{enter}"); 12 | cy.focused().type("My Favorite Social Sites{enter}"); 13 | cy.get("[role=treeitem]").contains("My Favorite Social Sites"); 14 | }); 15 | 16 | it("Collapses and Expands the Categories", () => { 17 | cy.get("@item").should("have.length", TOTAL_ITEMS); 18 | cy.get("@item").contains("Categories").click(); 19 | cy.get("@item").should("have.length", "12"); 20 | cy.get("@item").contains("Categories").click(); 21 | cy.get("@item").should("have.length", TOTAL_ITEMS); 22 | }); 23 | 24 | it("Up and Down Arrows", () => { 25 | cy.get("@item").first().click(); 26 | cy.focused().type("{downArrow}"); 27 | cy.focused().should("contain.text", "Starred"); 28 | cy.focused().type("{downArrow}"); 29 | cy.focused().should("contain.text", "Snoozed"); 30 | cy.focused().type("{upArrow}{upArrow}{upArrow}{upArrow}"); 31 | cy.focused().should("contain.text", "Inbox"); 32 | }); 33 | 34 | it("Left and Right Arrows", () => { 35 | cy.get("@item").should("have.length", TOTAL_ITEMS); 36 | cy.get("@item").contains("Categories").click(); 37 | cy.focused().type("{leftArrow}"); 38 | cy.get("@item").should("have.length", 12); 39 | cy.focused().type("{rightArrow}"); 40 | cy.get("@item").should("have.length", TOTAL_ITEMS); 41 | cy.focused().should("contain.text", "Categories"); 42 | cy.focused().type("{rightArrow}"); 43 | cy.focused().should("contain.text", "Social"); 44 | cy.focused().type("{downArrow}"); 45 | cy.focused().type("{downArrow}"); 46 | cy.focused().should("contain.text", "Forums"); 47 | }); 48 | 49 | it("Creates Leaf Nodes", () => { 50 | // At the root level 51 | cy.get("@item").first().click(); 52 | cy.focused().type("a"); 53 | cy.focused().type("Turn A New Leaf{enter}"); 54 | cy.get("@item").should("have.length", TOTAL_ITEMS + 1); 55 | 56 | // In a Folder 57 | cy.get("@item").contains("Social").click(); 58 | cy.focused().type("a"); 59 | cy.focused().type("Turn More Leaves{enter}"); 60 | cy.get("@item").should("have.length", TOTAL_ITEMS + 2); 61 | 62 | // On a folder that is closed 63 | cy.get("@item").contains("Categories").click(); // closed it 64 | cy.focused().should("have.attr", "aria-expanded", "false"); 65 | cy.focused().type("a"); 66 | cy.focused().type("Root{enter}"); 67 | cy.get("@item").contains("Root").click(); 68 | cy.focused().should("have.attr", "aria-level", "1"); 69 | 70 | // On a folder that is open 71 | cy.get("@item").contains("Categories").click(); // opened it 72 | cy.focused().should("have.attr", "aria-expanded", "true"); 73 | cy.focused().type("a"); 74 | cy.focused().type("Child{enter}"); 75 | cy.get("@item").contains("Child").click(); 76 | cy.focused().should("have.attr", "aria-level", "2"); 77 | }); 78 | 79 | it("Creates Internal Nodes", () => { 80 | // At the root level 81 | cy.get("@item").first().click(); 82 | cy.focused().type("A"); 83 | cy.focused().type("Turn A New Internal{enter}"); 84 | cy.get("@item").should("have.length", TOTAL_ITEMS + 1); 85 | cy.focused().children().should("have.class", "isInternal"); 86 | 87 | // In a Folder 88 | cy.get("@item").contains("Social").click(); 89 | cy.focused().type("A"); 90 | cy.focused().type("Turn More Inernals{enter}"); 91 | cy.get("@item").should("have.length", TOTAL_ITEMS + 2); 92 | cy.focused().children().should("have.class", "isInternal"); 93 | 94 | // On a folder that is closed 95 | cy.get("@item").contains("Categories").click(); // closed it 96 | cy.focused().should("have.attr", "aria-expanded", "false"); 97 | cy.focused().type("A"); 98 | cy.focused().type("Root{enter}"); 99 | cy.get("@item").contains("Root").click(); 100 | cy.focused().children().should("have.class", "isInternal"); 101 | cy.focused().should("have.attr", "aria-level", "1"); 102 | 103 | // On a folder that is open 104 | cy.get("@item").contains("Categories").click(); // opened it 105 | cy.focused().should("have.attr", "aria-expanded", "true"); 106 | cy.focused().type("A"); 107 | cy.focused().type("Child{enter}"); 108 | cy.get("@item").contains("Child").click(); 109 | cy.focused().should("have.attr", "aria-level", "2"); 110 | }); 111 | 112 | it("drags and drops in its list", () => { 113 | dragAndDrop( 114 | cy.get("@item").contains("Inbox").first(), 115 | cy.get("@item").contains("Sent").first() 116 | ); 117 | 118 | cy.get("@item").contains("Inbox").click(); 119 | cy.focused().invoke("index").should("eq", 2); 120 | }); 121 | 122 | it("drags and drops into folder", () => { 123 | dragAndDrop( 124 | cy.get("@item").contains("Starred").first(), 125 | cy.get("@item").contains("Social").first() 126 | ); 127 | cy.get("@item").contains("Starred").click(); 128 | cy.focused().invoke("index").should("eq", 11); 129 | }); 130 | 131 | it("prevents Inbox from Dragging into Categories", () => { 132 | dragAndDrop( 133 | cy.get("@item").contains("Inbox").first(), 134 | cy.get("@item").contains("Social").first() 135 | ); 136 | cy.get("@item").contains("Inbox").click(); 137 | cy.focused().invoke("index").should("eq", 0); 138 | }); 139 | 140 | it("filters to github, then checks expanding and collapsing", () => { 141 | cy.get("input").type("Git"); 142 | cy.get("@item").should("have.length", 3); 143 | cy.get("@item").contains("Categories").click(); // collapses 144 | cy.get("@item").should("have.length", 1); 145 | }); 146 | }); 147 | 148 | function dragAndDrop(src, dst) { 149 | const dataTransfer = new DataTransfer(); 150 | src.trigger("dragstart", { dataTransfer }); 151 | dst.trigger("drop", { dataTransfer }); 152 | dst.trigger("dragend", { dataTransfer }); 153 | } 154 | -------------------------------------------------------------------------------- /modules/e2e/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /modules/e2e/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -------------------------------------------------------------------------------- /modules/e2e/cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /modules/e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e", 3 | "packageManager": "yarn@3.2.0", 4 | "scripts": { 5 | "cy:run": "yarn cypress run", 6 | "start": "yarn cypress open", 7 | "serve": "yarn workspace showcase static > /dev/null", 8 | "test": "start-server-and-test serve http://localhost:3000 cy:run" 9 | }, 10 | "devDependencies": { 11 | "cypress": "^13.6.1", 12 | "serve": "^14.2.1", 13 | "start-server-and-test": "^2.0.3" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /modules/react-arborist/.gitignore: -------------------------------------------------------------------------------- 1 | # this is copied from the root 2 | README.md 3 | dist 4 | -------------------------------------------------------------------------------- /modules/react-arborist/jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | /** @type {import('jest').Config} */ 7 | const config = { 8 | // All imported modules in your tests should be mocked automatically 9 | // automock: false, 10 | 11 | // Stop running tests after `n` failures 12 | // bail: 0, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/lt/60gl0cgx76x1cm9cf7jrgp400000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls, instances, contexts and results before every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: undefined, 25 | 26 | // The directory where Jest should output its coverage files 27 | // coverageDirectory: undefined, 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // Indicates which provider should be used to instrument code for coverage 35 | coverageProvider: "v8", 36 | 37 | // A list of reporter names that Jest uses when writing coverage reports 38 | // coverageReporters: [ 39 | // "json", 40 | // "text", 41 | // "lcov", 42 | // "clover" 43 | // ], 44 | 45 | // An object that configures minimum threshold enforcement for coverage results 46 | // coverageThreshold: undefined, 47 | 48 | // A path to a custom dependency extractor 49 | // dependencyExtractor: undefined, 50 | 51 | // Make calling deprecated APIs throw helpful error messages 52 | // errorOnDeprecated: false, 53 | 54 | // The default configuration for fake timers 55 | // fakeTimers: { 56 | // "enableGlobally": false 57 | // }, 58 | 59 | // Force coverage collection from ignored files using an array of glob patterns 60 | // forceCoverageMatch: [], 61 | 62 | // A path to a module which exports an async function that is triggered once before all test suites 63 | // globalSetup: undefined, 64 | 65 | // A path to a module which exports an async function that is triggered once after all test suites 66 | // globalTeardown: undefined, 67 | 68 | // A set of global variables that need to be available in all test environments 69 | // globals: {}, 70 | 71 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 72 | // maxWorkers: "50%", 73 | 74 | // An array of directory names to be searched recursively up from the requiring module's location 75 | // moduleDirectories: [ 76 | // "node_modules" 77 | // ], 78 | 79 | // An array of file extensions your modules use 80 | // moduleFileExtensions: [ 81 | // "js", 82 | // "mjs", 83 | // "cjs", 84 | // "jsx", 85 | // "ts", 86 | // "tsx", 87 | // "json", 88 | // "node" 89 | // ], 90 | 91 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 92 | // moduleNameMapper: {}, 93 | 94 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 95 | // modulePathIgnorePatterns: [], 96 | 97 | // Activates notifications for test results 98 | // notify: false, 99 | 100 | // An enum that specifies notification mode. Requires { notify: true } 101 | // notifyMode: "failure-change", 102 | 103 | // A preset that is used as a base for Jest's configuration 104 | preset: "ts-jest", 105 | 106 | // Run tests from one or more projects 107 | // projects: undefined, 108 | 109 | // Use this configuration option to add custom reporters to Jest 110 | // reporters: undefined, 111 | 112 | // Automatically reset mock state before every test 113 | // resetMocks: false, 114 | 115 | // Reset the module registry before running each individual test 116 | // resetModules: false, 117 | 118 | // A path to a custom resolver 119 | // resolver: undefined, 120 | 121 | // Automatically restore mock state and implementation before every test 122 | // restoreMocks: false, 123 | 124 | // The root directory that Jest should scan for tests and modules within 125 | rootDir: "./src", 126 | 127 | // A list of paths to directories that Jest should use to search for files in 128 | // roots: [ 129 | // "" 130 | // ], 131 | 132 | // Allows you to use a custom runner instead of Jest's default test runner 133 | // runner: "jest-runner", 134 | 135 | // The paths to modules that run some code to configure or set up the testing environment before each test 136 | // setupFiles: [], 137 | 138 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 139 | // setupFilesAfterEnv: [], 140 | 141 | // The number of seconds after which a test is considered as slow and reported as such in the results. 142 | // slowTestThreshold: 5, 143 | 144 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 145 | // snapshotSerializers: [], 146 | 147 | // The test environment that will be used for testing 148 | // testEnvironment: "jest-environment-node", 149 | 150 | // Options that will be passed to the testEnvironment 151 | // testEnvironmentOptions: {}, 152 | 153 | // Adds a location field to test results 154 | // testLocationInResults: false, 155 | 156 | // The glob patterns Jest uses to detect test files 157 | // testMatch: [ 158 | // "**/__tests__/**/*.[jt]s?(x)", 159 | // "**/?(*.)+(spec|test).[tj]s?(x)" 160 | // ], 161 | 162 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 163 | // testPathIgnorePatterns: [ 164 | // "/node_modules/" 165 | // ], 166 | 167 | // The regexp pattern or array of patterns that Jest uses to detect test files 168 | // testRegex: [], 169 | 170 | // This option allows the use of a custom results processor 171 | // testResultsProcessor: undefined, 172 | 173 | // This option allows use of a custom test runner 174 | // testRunner: "jest-circus/runner", 175 | 176 | // A map from regular expressions to paths to transformers 177 | // transform: undefined, 178 | 179 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 180 | // transformIgnorePatterns: [ 181 | // "/node_modules/", 182 | // "\\.pnp\\.[^\\/]+$" 183 | // ], 184 | 185 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 186 | // unmockedModulePathPatterns: undefined, 187 | 188 | // Indicates whether each individual test should be reported during the run 189 | // verbose: undefined, 190 | 191 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 192 | // watchPathIgnorePatterns: [], 193 | 194 | // Whether to use watchman for file crawling 195 | // watchman: true, 196 | }; 197 | 198 | module.exports = config; 199 | -------------------------------------------------------------------------------- /modules/react-arborist/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-arborist", 3 | "version": "3.4.3", 4 | "license": "MIT", 5 | "source": "src/index.ts", 6 | "main": "dist/main/index.js", 7 | "module": "dist/module/index.js", 8 | "types": "dist/module/index.d.ts", 9 | "sideEffects": false, 10 | "scripts": { 11 | "build:cjs": "tsc --outDir dist/main", 12 | "build:es": "tsc --outDir dist/module --module es2022 --moduleResolution node", 13 | "build": "npm-run-all clean -p 'build:**'", 14 | "clean": "rimraf dist", 15 | "prepack": "yarn build", 16 | "test": "jest", 17 | "watch": "yarn build:es --watch" 18 | }, 19 | "files": [ 20 | "src", 21 | "dist" 22 | ], 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/brimdata/react-arborist.git" 26 | }, 27 | "homepage": "https://react-arborist.netlify.app", 28 | "bugs": "https://github.com/brimdata/react-arborist/issues", 29 | "keywords": [ 30 | "react", 31 | "arborist", 32 | "react-arborist", 33 | "treeview", 34 | "tree", 35 | "vitualized", 36 | "dnd", 37 | "multiselection", 38 | "filterable" 39 | ], 40 | "dependencies": { 41 | "react-dnd": "^14.0.3", 42 | "react-dnd-html5-backend": "^14.0.3", 43 | "react-window": "^1.8.11", 44 | "redux": "^5.0.0", 45 | "use-sync-external-store": "^1.2.0" 46 | }, 47 | "peerDependencies": { 48 | "react": ">= 16.14", 49 | "react-dom": ">= 16.14" 50 | }, 51 | "devDependencies": { 52 | "@types/jest": "^29.5.11", 53 | "@types/react": "^18.2.43", 54 | "@types/react-window": "^1.8.8", 55 | "@types/use-sync-external-store": "^0.0.6", 56 | "jest": "^29.7.0", 57 | "npm-run-all": "^4.1.5", 58 | "rimraf": "^5.0.5", 59 | "ts-jest": "^29.1.1", 60 | "typescript": "^5.6.0" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /modules/react-arborist/src/components/cursor.tsx: -------------------------------------------------------------------------------- 1 | import { useDndContext, useTreeApi } from "../context"; 2 | 3 | export function Cursor() { 4 | const tree = useTreeApi(); 5 | const state = useDndContext(); 6 | const cursor = state.cursor; 7 | if (!cursor || cursor.type !== "line") return null; 8 | const indent = tree.indent; 9 | const top = 10 | tree.rowHeight * cursor.index + 11 | (tree.props.padding ?? tree.props.paddingTop ?? 0); 12 | const left = indent * cursor.level; 13 | const Cursor = tree.renderCursor; 14 | return ; 15 | } 16 | -------------------------------------------------------------------------------- /modules/react-arborist/src/components/default-container.tsx: -------------------------------------------------------------------------------- 1 | import { FixedSizeList } from "react-window"; 2 | import { useDataUpdates, useTreeApi } from "../context"; 3 | import { focusNextElement, focusPrevElement } from "../utils"; 4 | import { ListOuterElement } from "./list-outer-element"; 5 | import { ListInnerElement } from "./list-inner-element"; 6 | import { RowContainer } from "./row-container"; 7 | 8 | let focusSearchTerm = ""; 9 | let timeoutId: any = null; 10 | 11 | /** 12 | * All these keyboard shortcuts seem like they should be configurable. 13 | * Each operation should be a given a name and separated from 14 | * the event handler. Future clean up welcome. 15 | */ 16 | export function DefaultContainer() { 17 | useDataUpdates(); 18 | const tree = useTreeApi(); 19 | return ( 20 |
    { 32 | if (!e.currentTarget.contains(e.relatedTarget)) { 33 | tree.onFocus(); 34 | } 35 | }} 36 | onBlur={(e) => { 37 | if (!e.currentTarget.contains(e.relatedTarget)) { 38 | tree.onBlur(); 39 | } 40 | }} 41 | onKeyDown={(e) => { 42 | if (tree.isEditing) { 43 | return; 44 | } 45 | if (e.key === "Backspace") { 46 | if (!tree.props.onDelete) return; 47 | const ids = Array.from(tree.selectedIds); 48 | if (ids.length > 1) { 49 | let nextFocus = tree.mostRecentNode; 50 | while (nextFocus && nextFocus.isSelected) { 51 | nextFocus = nextFocus.nextSibling; 52 | } 53 | if (!nextFocus) nextFocus = tree.lastNode; 54 | tree.focus(nextFocus, { scroll: false }); 55 | tree.delete(Array.from(ids)); 56 | } else { 57 | const node = tree.focusedNode; 58 | if (node) { 59 | const sib = node.nextSibling; 60 | const parent = node.parent; 61 | tree.focus(sib || parent, { scroll: false }); 62 | tree.delete(node); 63 | } 64 | } 65 | return; 66 | } 67 | if (e.key === "Tab" && !e.shiftKey) { 68 | e.preventDefault(); 69 | focusNextElement(e.currentTarget); 70 | return; 71 | } 72 | if (e.key === "Tab" && e.shiftKey) { 73 | e.preventDefault(); 74 | focusPrevElement(e.currentTarget); 75 | return; 76 | } 77 | if (e.key === "ArrowDown") { 78 | e.preventDefault(); 79 | const next = tree.nextNode; 80 | if (e.metaKey) { 81 | tree.select(tree.focusedNode); 82 | tree.activate(tree.focusedNode); 83 | return; 84 | } else if (!e.shiftKey || tree.props.disableMultiSelection) { 85 | tree.focus(next); 86 | return; 87 | } else { 88 | if (!next) return; 89 | const current = tree.focusedNode; 90 | if (!current) { 91 | tree.focus(tree.firstNode); 92 | } else if (current.isSelected) { 93 | tree.selectContiguous(next); 94 | } else { 95 | tree.selectMulti(next); 96 | } 97 | return; 98 | } 99 | } 100 | if (e.key === "ArrowUp") { 101 | e.preventDefault(); 102 | const prev = tree.prevNode; 103 | if (!e.shiftKey || tree.props.disableMultiSelection) { 104 | tree.focus(prev); 105 | return; 106 | } else { 107 | if (!prev) return; 108 | const current = tree.focusedNode; 109 | if (!current) { 110 | tree.focus(tree.lastNode); // ? 111 | } else if (current.isSelected) { 112 | tree.selectContiguous(prev); 113 | } else { 114 | tree.selectMulti(prev); 115 | } 116 | return; 117 | } 118 | } 119 | if (e.key === "ArrowRight") { 120 | const node = tree.focusedNode; 121 | if (!node) return; 122 | if (node.isInternal && node.isOpen) { 123 | tree.focus(tree.nextNode); 124 | } else if (node.isInternal) tree.open(node.id); 125 | return; 126 | } 127 | if (e.key === "ArrowLeft") { 128 | const node = tree.focusedNode; 129 | if (!node || node.isRoot) return; 130 | if (node.isInternal && node.isOpen) tree.close(node.id); 131 | else if (!node.parent?.isRoot) { 132 | tree.focus(node.parent); 133 | } 134 | return; 135 | } 136 | if (e.key === "a" && e.metaKey && !tree.props.disableMultiSelection) { 137 | e.preventDefault(); 138 | tree.selectAll(); 139 | return; 140 | } 141 | if (e.key === "a" && !e.metaKey && tree.props.onCreate) { 142 | tree.createLeaf(); 143 | return; 144 | } 145 | if (e.key === "A" && !e.metaKey) { 146 | if (!tree.props.onCreate) return; 147 | tree.createInternal(); 148 | return; 149 | } 150 | 151 | if (e.key === "Home") { 152 | // add shift keys 153 | e.preventDefault(); 154 | tree.focus(tree.firstNode); 155 | return; 156 | } 157 | if (e.key === "End") { 158 | // add shift keys 159 | e.preventDefault(); 160 | tree.focus(tree.lastNode); 161 | return; 162 | } 163 | if (e.key === "Enter") { 164 | const node = tree.focusedNode; 165 | if (!node) return; 166 | if (!node.isEditable || !tree.props.onRename) return; 167 | setTimeout(() => { 168 | if (node) tree.edit(node); 169 | }); 170 | return; 171 | } 172 | if (e.key === " ") { 173 | e.preventDefault(); 174 | const node = tree.focusedNode; 175 | if (!node) return; 176 | if (node.isLeaf) { 177 | node.select(); 178 | node.activate(); 179 | } else { 180 | node.toggle(); 181 | } 182 | return; 183 | } 184 | if (e.key === "*") { 185 | const node = tree.focusedNode; 186 | if (!node) return; 187 | tree.openSiblings(node); 188 | return; 189 | } 190 | if (e.key === "PageUp") { 191 | e.preventDefault(); 192 | tree.pageUp(); 193 | return; 194 | } 195 | if (e.key === "PageDown") { 196 | e.preventDefault(); 197 | tree.pageDown(); 198 | } 199 | 200 | // If they type a sequence of characters 201 | // collect them. Reset them after a timeout. 202 | // Use it to search the tree for a node, then focus it. 203 | // Clean this up a bit later 204 | clearTimeout(timeoutId); 205 | focusSearchTerm += e.key; 206 | timeoutId = setTimeout(() => { 207 | focusSearchTerm = ""; 208 | }, 600); 209 | const node = tree.visibleNodes.find((n) => { 210 | // @ts-ignore 211 | const name = n.data.name; 212 | if (typeof name === "string") { 213 | return name.toLowerCase().startsWith(focusSearchTerm); 214 | } else return false; 215 | }); 216 | if (node) tree.focus(node.id); 217 | }} 218 | > 219 | {/* @ts-ignore */} 220 | tree.visibleNodes[index]?.id || index} 229 | outerElementType={ListOuterElement} 230 | innerElementType={ListInnerElement} 231 | onScroll={tree.props.onScroll} 232 | onItemsRendered={tree.onItemsRendered.bind(tree)} 233 | ref={tree.list} 234 | > 235 | {RowContainer} 236 | 237 |
    238 | ); 239 | } 240 | -------------------------------------------------------------------------------- /modules/react-arborist/src/components/default-cursor.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties } from "react"; 2 | import { CursorProps } from "../types/renderers"; 3 | 4 | const placeholderStyle = { 5 | display: "flex", 6 | alignItems: "center", 7 | zIndex: 1, 8 | }; 9 | 10 | const lineStyle = { 11 | flex: 1, 12 | height: "2px", 13 | background: "#4B91E2", 14 | borderRadius: "1px", 15 | }; 16 | 17 | const circleStyle = { 18 | width: "4px", 19 | height: "4px", 20 | boxShadow: "0 0 0 3px #4B91E2", 21 | borderRadius: "50%", 22 | }; 23 | 24 | export const DefaultCursor = React.memo(function DefaultCursor({ 25 | top, 26 | left, 27 | indent, 28 | }: CursorProps) { 29 | const style: CSSProperties = { 30 | position: "absolute", 31 | pointerEvents: "none", 32 | top: top - 2 + "px", 33 | left: left + "px", 34 | right: indent + "px", 35 | }; 36 | return ( 37 |
    38 |
    39 |
    40 |
    41 | ); 42 | }); 43 | -------------------------------------------------------------------------------- /modules/react-arborist/src/components/default-drag-preview.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, memo } from "react"; 2 | import { XYCoord } from "react-dnd"; 3 | import { useTreeApi } from "../context"; 4 | import { DragPreviewProps } from "../types/renderers"; 5 | import { IdObj } from "../types/utils"; 6 | 7 | const layerStyles: CSSProperties = { 8 | position: "fixed", 9 | pointerEvents: "none", 10 | zIndex: 100, 11 | left: 0, 12 | top: 0, 13 | width: "100%", 14 | height: "100%", 15 | }; 16 | 17 | const getStyle = (offset: XYCoord | null) => { 18 | if (!offset) return { display: "none" }; 19 | const { x, y } = offset; 20 | return { transform: `translate(${x}px, ${y}px)` }; 21 | }; 22 | 23 | const getCountStyle = (offset: XYCoord | null) => { 24 | if (!offset) return { display: "none" }; 25 | const { x, y } = offset; 26 | return { transform: `translate(${x + 10}px, ${y + 10}px)` }; 27 | }; 28 | 29 | export function DefaultDragPreview({ 30 | offset, 31 | mouse, 32 | id, 33 | dragIds, 34 | isDragging, 35 | }: DragPreviewProps) { 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | ); 44 | } 45 | 46 | const Overlay = memo(function Overlay(props: { 47 | children: JSX.Element[]; 48 | isDragging: boolean; 49 | }) { 50 | if (!props.isDragging) return null; 51 | return
    {props.children}
    ; 52 | }); 53 | 54 | function Position(props: { children: JSX.Element; offset: XYCoord | null }) { 55 | return ( 56 |
    57 | {props.children} 58 |
    59 | ); 60 | } 61 | 62 | function Count(props: { count: number; mouse: XYCoord | null }) { 63 | const { count, mouse } = props; 64 | if (count > 1) 65 | return ( 66 |
    67 | {count} 68 |
    69 | ); 70 | else return null; 71 | } 72 | 73 | const PreviewNode = memo(function PreviewNode(props: { 74 | id: string | null; 75 | dragIds: string[]; 76 | }) { 77 | const tree = useTreeApi(); 78 | const node = tree.get(props.id); 79 | if (!node) return null; 80 | return ( 81 | 91 | ); 92 | }); 93 | -------------------------------------------------------------------------------- /modules/react-arborist/src/components/default-node.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import { NodeRendererProps } from "../types/renderers"; 3 | import { IdObj } from "../types/utils"; 4 | 5 | export function DefaultNode(props: NodeRendererProps) { 6 | return ( 7 |
    8 | { 10 | e.stopPropagation(); 11 | props.node.toggle(); 12 | }} 13 | > 14 | {props.node.isLeaf ? "🌳" : props.node.isOpen ? "🗁" : "🗀"} 15 | {" "} 16 | {props.node.isEditing ? : } 17 |
    18 | ); 19 | } 20 | 21 | function Show(props: NodeRendererProps) { 22 | return ( 23 | <> 24 | {/* @ts-ignore */} 25 | {props.node.data.name} 26 | 27 | ); 28 | } 29 | 30 | function Edit({ node }: NodeRendererProps) { 31 | const input = useRef(); 32 | 33 | useEffect(() => { 34 | input.current?.focus(); 35 | input.current?.select(); 36 | }, []); 37 | 38 | return ( 39 | node.reset()} 44 | onKeyDown={(e) => { 45 | if (e.key === "Escape") node.reset(); 46 | if (e.key === "Enter") node.submit(input.current?.value || ""); 47 | }} 48 | > 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /modules/react-arborist/src/components/default-row.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { RowRendererProps } from "../types/renderers"; 3 | import { IdObj } from "../types/utils"; 4 | 5 | export function DefaultRow({ 6 | node, 7 | attrs, 8 | innerRef, 9 | children, 10 | }: RowRendererProps) { 11 | return ( 12 |
    e.stopPropagation()} 16 | onClick={node.handleClick} 17 | > 18 | {children} 19 |
    20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /modules/react-arborist/src/components/drag-preview-container.tsx: -------------------------------------------------------------------------------- 1 | import { useDragLayer } from "react-dnd"; 2 | import { useDndContext, useTreeApi } from "../context"; 3 | import { DefaultDragPreview } from "./default-drag-preview"; 4 | 5 | export function DragPreviewContainer() { 6 | const tree = useTreeApi(); 7 | const { offset, mouse, item, isDragging } = useDragLayer((m) => { 8 | return { 9 | offset: m.getSourceClientOffset(), 10 | mouse: m.getClientOffset(), 11 | item: m.getItem(), 12 | isDragging: m.isDragging(), 13 | }; 14 | }); 15 | 16 | const DragPreview = tree.props.renderDragPreview || DefaultDragPreview; 17 | return ( 18 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /modules/react-arborist/src/components/list-inner-element.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { forwardRef } from "react"; 3 | import { useTreeApi } from "../context"; 4 | 5 | export const ListInnerElement = forwardRef(function InnerElement( 6 | { style, ...rest }, 7 | ref 8 | ) { 9 | const tree = useTreeApi(); 10 | const paddingTop = tree.props.padding ?? tree.props.paddingTop ?? 0; 11 | const paddingBottom = tree.props.padding ?? tree.props.paddingBottom ?? 0; 12 | return ( 13 |
    21 | ); 22 | }); 23 | -------------------------------------------------------------------------------- /modules/react-arborist/src/components/list-outer-element.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | import { useTreeApi } from "../context"; 3 | import { treeBlur } from "../state/focus-slice"; 4 | import { Cursor } from "./cursor"; 5 | 6 | export const ListOuterElement = forwardRef(function Outer( 7 | props: React.HTMLProps, 8 | ref 9 | ) { 10 | const { children, ...rest } = props; 11 | const tree = useTreeApi(); 12 | return ( 13 |
    { 18 | if (e.currentTarget === e.target) tree.deselectAll(); 19 | }} 20 | > 21 | 22 | {children} 23 |
    24 | ); 25 | }); 26 | 27 | const DropContainer = () => { 28 | const tree = useTreeApi(); 29 | return ( 30 |
    39 | 40 |
    41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /modules/react-arborist/src/components/outer-drop.ts: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { useOuterDrop } from "../dnd/outer-drop-hook"; 3 | 4 | export function OuterDrop(props: { children: ReactElement }) { 5 | useOuterDrop(); 6 | return props.children; 7 | } 8 | -------------------------------------------------------------------------------- /modules/react-arborist/src/components/provider.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ReactNode, 3 | useEffect, 4 | useImperativeHandle, 5 | useMemo, 6 | useRef, 7 | } from "react"; 8 | import { useSyncExternalStore } from "use-sync-external-store/shim"; 9 | import { FixedSizeList } from "react-window"; 10 | import { 11 | DataUpdatesContext, 12 | DndContext, 13 | NodesContext, 14 | TreeApiContext, 15 | } from "../context"; 16 | import { TreeApi } from "../interfaces/tree-api"; 17 | import { initialState } from "../state/initial"; 18 | import { Actions, rootReducer, RootState } from "../state/root-reducer"; 19 | import { HTML5Backend } from "react-dnd-html5-backend"; 20 | import { DndProvider } from "react-dnd"; 21 | import { TreeProps } from "../types/tree-props"; 22 | import { createStore, Store } from "redux"; 23 | import { actions as visibility } from "../state/open-slice"; 24 | 25 | type Props = { 26 | treeProps: TreeProps; 27 | imperativeHandle: React.Ref | undefined>; 28 | children: ReactNode; 29 | }; 30 | 31 | const SERVER_STATE = initialState(); 32 | 33 | export function TreeProvider({ 34 | treeProps, 35 | imperativeHandle, 36 | children, 37 | }: Props) { 38 | const list = useRef(null); 39 | const listEl = useRef(null); 40 | const store = useRef>( 41 | // @ts-ignore 42 | createStore(rootReducer, initialState(treeProps)) 43 | ); 44 | const state = useSyncExternalStore( 45 | store.current.subscribe, 46 | store.current.getState, 47 | () => SERVER_STATE 48 | ); 49 | 50 | /* The tree api object is stable. */ 51 | const api = useMemo(() => { 52 | return new TreeApi(store.current, treeProps, list, listEl); 53 | }, []); 54 | 55 | /* Make sure the tree instance stays in sync */ 56 | const updateCount = useRef(0); 57 | useMemo(() => { 58 | updateCount.current += 1; 59 | api.update(treeProps); 60 | }, [...Object.values(treeProps), state.nodes.open]); 61 | 62 | /* Expose the tree api */ 63 | useImperativeHandle(imperativeHandle, () => api); 64 | 65 | /* Change selection based on props */ 66 | useEffect(() => { 67 | if (api.props.selection) { 68 | api.select(api.props.selection, { focus: false }); 69 | } else { 70 | api.deselectAll(); 71 | } 72 | }, [api.props.selection]); 73 | 74 | /* Clear visability for filtered nodes */ 75 | useEffect(() => { 76 | if (!api.props.searchTerm) { 77 | store.current.dispatch(visibility.clear(true)); 78 | } 79 | }, [api.props.searchTerm]); 80 | 81 | return ( 82 | 83 | 84 | 85 | 86 | 91 | {children} 92 | 93 | 94 | 95 | 96 | 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /modules/react-arborist/src/components/row-container.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useMemo, useRef } from "react"; 2 | import { useDataUpdates, useNodesContext, useTreeApi } from "../context"; 3 | import { useDragHook } from "../dnd/drag-hook"; 4 | import { useDropHook } from "../dnd/drop-hook"; 5 | import { useFreshNode } from "../hooks/use-fresh-node"; 6 | 7 | type Props = { 8 | style: React.CSSProperties; 9 | index: number; 10 | }; 11 | 12 | export const RowContainer = React.memo(function RowContainer({ 13 | index, 14 | style, 15 | }: Props) { 16 | /* When will the will re-render. 17 | * 18 | * The row component is memo'd so it will only render 19 | * when a new instance of the NodeApi class is passed 20 | * to it. 21 | * 22 | * The TreeApi instance is stable. It does not 23 | * change when the internal state changes. 24 | * 25 | * The TreeApi has all the references to the nodes. 26 | * We need to clone the nodes when their state 27 | * changes. The node class contains no state itself, 28 | * It always checks the tree for state. The tree's 29 | * state will always be up to date. 30 | */ 31 | 32 | useDataUpdates(); // Re-render when tree props or visability changes 33 | const _ = useNodesContext(); // So that we re-render appropriately 34 | const tree = useTreeApi(); // Tree already has the fresh state 35 | const node = useFreshNode(index); 36 | 37 | const el = useRef(null); 38 | const dragRef = useDragHook(node); 39 | const dropRef = useDropHook(el, node); 40 | const innerRef = useCallback( 41 | (n: any) => { 42 | el.current = n; 43 | dropRef(n); 44 | }, 45 | [dropRef] 46 | ); 47 | 48 | const indent = tree.indent * node.level; 49 | const nodeStyle = useMemo(() => ({ paddingLeft: indent }), [indent]); 50 | const rowStyle = useMemo( 51 | () => ({ 52 | ...style, 53 | top: 54 | parseFloat(style.top as string) + 55 | (tree.props.padding ?? tree.props.paddingTop ?? 0), 56 | }), 57 | [style, tree.props.padding, tree.props.paddingTop] 58 | ); 59 | const rowAttrs: React.HTMLAttributes = { 60 | role: "treeitem", 61 | "aria-level": node.level + 1, 62 | "aria-selected": node.isSelected, 63 | "aria-expanded": node.isOpen, 64 | style: rowStyle, 65 | tabIndex: -1, 66 | className: tree.props.rowClassName, 67 | }; 68 | 69 | useEffect(() => { 70 | if (!node.isEditing && node.isFocused) { 71 | el.current?.focus({ preventScroll: true }); 72 | } 73 | }, [node.isEditing, node.isFocused, el.current]); 74 | 75 | const Node = tree.renderNode; 76 | const Row = tree.renderRow; 77 | 78 | return ( 79 | 80 | 81 | 82 | ); 83 | }); 84 | -------------------------------------------------------------------------------- /modules/react-arborist/src/components/tree-container.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useTreeApi } from "../context"; 3 | import { DefaultContainer } from "./default-container"; 4 | 5 | export function TreeContainer() { 6 | const tree = useTreeApi(); 7 | const Container = tree.props.renderContainer || DefaultContainer; 8 | return ( 9 | <> 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /modules/react-arborist/src/components/tree.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | import { TreeProvider } from "./provider"; 3 | import { TreeApi } from "../interfaces/tree-api"; 4 | import { OuterDrop } from "./outer-drop"; 5 | import { TreeContainer } from "./tree-container"; 6 | import { DragPreviewContainer } from "./drag-preview-container"; 7 | import { TreeProps } from "../types/tree-props"; 8 | import { IdObj } from "../types/utils"; 9 | import { useValidatedProps } from "../hooks/use-validated-props"; 10 | 11 | function TreeComponent( 12 | props: TreeProps, 13 | ref: React.Ref | undefined> 14 | ) { 15 | const treeProps = useValidatedProps(props); 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | } 25 | 26 | export const Tree = forwardRef(TreeComponent) as ( 27 | props: TreeProps & { ref?: React.ForwardedRef | undefined> } 28 | ) => ReturnType; 29 | -------------------------------------------------------------------------------- /modules/react-arborist/src/context.ts: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useMemo } from "react"; 2 | import { TreeApi } from "./interfaces/tree-api"; 3 | import { RootState } from "./state/root-reducer"; 4 | import { IdObj } from "./types/utils"; 5 | 6 | export const TreeApiContext = createContext | null>(null); 7 | 8 | export function useTreeApi() { 9 | const value = useContext | null>( 10 | TreeApiContext as unknown as React.Context | null> 11 | ); 12 | if (value === null) throw new Error("No Tree Api Provided"); 13 | return value; 14 | } 15 | 16 | export const NodesContext = createContext(null); 17 | 18 | export function useNodesContext() { 19 | const value = useContext(NodesContext); 20 | if (value === null) throw new Error("Provide a NodesContext"); 21 | return value; 22 | } 23 | 24 | export const DndContext = createContext(null); 25 | 26 | export function useDndContext() { 27 | const value = useContext(DndContext); 28 | if (value === null) throw new Error("Provide a DnDContext"); 29 | return value; 30 | } 31 | 32 | export const DataUpdatesContext = createContext(0); 33 | 34 | export function useDataUpdates() { 35 | useContext(DataUpdatesContext); 36 | } 37 | -------------------------------------------------------------------------------- /modules/react-arborist/src/data/create-index.ts: -------------------------------------------------------------------------------- 1 | import { NodeApi } from "../interfaces/node-api"; 2 | import { IdObj } from "../types/utils"; 3 | 4 | export const createIndex = (nodes: NodeApi[]) => { 5 | return nodes.reduce<{ [id: string]: number }>((map, node, index) => { 6 | map[node.id] = index; 7 | return map; 8 | }, {}); 9 | }; 10 | -------------------------------------------------------------------------------- /modules/react-arborist/src/data/create-list.ts: -------------------------------------------------------------------------------- 1 | import { NodeApi } from "../interfaces/node-api"; 2 | import { TreeApi } from "../interfaces/tree-api"; 3 | import { IdObj } from "../types/utils"; 4 | 5 | export function createList(tree: TreeApi) { 6 | if (tree.isFiltered) { 7 | return flattenAndFilterTree(tree.root, tree.isMatch.bind(tree)); 8 | } else { 9 | return flattenTree(tree.root); 10 | } 11 | } 12 | 13 | function flattenTree(root: NodeApi): NodeApi[] { 14 | const list: NodeApi[] = []; 15 | function collect(node: NodeApi) { 16 | if (node.level >= 0) { 17 | list.push(node); 18 | } 19 | if (node.isOpen) { 20 | node.children?.forEach(collect); 21 | } 22 | } 23 | collect(root); 24 | list.forEach(assignRowIndex); 25 | return list; 26 | } 27 | 28 | function flattenAndFilterTree( 29 | root: NodeApi, 30 | isMatch: (n: NodeApi) => boolean 31 | ): NodeApi[] { 32 | const matches: Record = {}; 33 | const list: NodeApi[] = []; 34 | 35 | function markMatch(node: NodeApi) { 36 | const yes = !node.isRoot && isMatch(node); 37 | if (yes) { 38 | matches[node.id] = true; 39 | let parent = node.parent; 40 | while (parent) { 41 | matches[parent.id] = true; 42 | parent = parent.parent; 43 | } 44 | } 45 | if (node.children) { 46 | for (let child of node.children) markMatch(child); 47 | } 48 | } 49 | 50 | function collect(node: NodeApi) { 51 | if (node.level >= 0 && matches[node.id]) { 52 | list.push(node); 53 | } 54 | if (node.isOpen) { 55 | node.children?.forEach(collect); 56 | } 57 | } 58 | 59 | markMatch(root); 60 | collect(root); 61 | list.forEach(assignRowIndex); 62 | return list; 63 | } 64 | 65 | function assignRowIndex(node: NodeApi, index: number) { 66 | node.rowIndex = index; 67 | } 68 | -------------------------------------------------------------------------------- /modules/react-arborist/src/data/create-root.ts: -------------------------------------------------------------------------------- 1 | import { IdObj } from "../types/utils"; 2 | import { NodeApi } from "../interfaces/node-api"; 3 | import { TreeApi } from "../interfaces/tree-api"; 4 | 5 | export const ROOT_ID = "__REACT_ARBORIST_INTERNAL_ROOT__"; 6 | 7 | export function createRoot(tree: TreeApi): NodeApi { 8 | function visitSelfAndChildren( 9 | data: T, 10 | level: number, 11 | parent: NodeApi | null 12 | ) { 13 | const id = tree.accessId(data); 14 | const node = new NodeApi({ 15 | tree, 16 | data, 17 | level, 18 | parent, 19 | id, 20 | children: null, 21 | isDraggable: tree.isDraggable(data), 22 | rowIndex: null, 23 | }); 24 | const children = tree.accessChildren(data); 25 | if (children) { 26 | node.children = children.map((child: T) => 27 | visitSelfAndChildren(child, level + 1, node) 28 | ); 29 | } 30 | return node; 31 | } 32 | 33 | const root = new NodeApi({ 34 | tree, 35 | id: ROOT_ID, 36 | // @ts-ignore 37 | data: { id: ROOT_ID }, 38 | level: -1, 39 | parent: null, 40 | children: null, 41 | isDraggable: true, 42 | rowIndex: null, 43 | }); 44 | 45 | const data: readonly T[] = tree.props.data ?? []; 46 | 47 | root.children = data.map((child) => { 48 | return visitSelfAndChildren(child, 0, root); 49 | }); 50 | 51 | return root; 52 | } 53 | -------------------------------------------------------------------------------- /modules/react-arborist/src/data/make-tree.ts: -------------------------------------------------------------------------------- 1 | // A function that turns a string of text into a tree 2 | // Each line is a node 3 | // The number of spaces at the beginning indicate the level 4 | 5 | export function makeTree(string: string) { 6 | const root = { id: "ROOT", name: "ROOT", isOpen: true }; 7 | let prevNode = root; 8 | let prevLevel = -1; 9 | let id = 1; 10 | string.split("\n").forEach((line) => { 11 | const name = line.trimStart(); 12 | const level = line.length - name.length; 13 | const diff = level - prevLevel; 14 | const node = { id: (id++).toString(), name, isOpen: false }; 15 | if (diff === 1) { 16 | // First child 17 | //@ts-ignore 18 | node.parent = prevNode; 19 | //@ts-ignore 20 | prevNode.children = [node]; 21 | } else { 22 | // Find the parent and go up 23 | //@ts-ignore 24 | let parent = prevNode.parent; 25 | for (let i = diff; i < 0; i++) { 26 | parent = parent.parent; 27 | } 28 | //@ts-ignore 29 | node.parent = parent; 30 | parent.children.push(node); 31 | } 32 | prevNode = node; 33 | prevLevel = level; 34 | }); 35 | 36 | return root; 37 | } 38 | -------------------------------------------------------------------------------- /modules/react-arborist/src/data/simple-tree.ts: -------------------------------------------------------------------------------- 1 | type SimpleData = { id: string; name: string; children?: SimpleData[] }; 2 | 3 | export class SimpleTree { 4 | root: SimpleNode; 5 | constructor(data: T[]) { 6 | this.root = createRoot(data); 7 | } 8 | 9 | get data() { 10 | return this.root.children?.map((node) => node.data) ?? []; 11 | } 12 | 13 | create(args: { parentId: string | null; index: number; data: T }) { 14 | const parent = args.parentId ? this.find(args.parentId) : this.root; 15 | if (!parent) return null; 16 | parent.addChild(args.data, args.index); 17 | } 18 | 19 | move(args: { id: string; parentId: string | null; index: number }) { 20 | const src = this.find(args.id); 21 | const parent = args.parentId ? this.find(args.parentId) : this.root; 22 | if (!src || !parent) return; 23 | parent.addChild(src.data, args.index); 24 | src.drop(); 25 | } 26 | 27 | update(args: { id: string; changes: Partial }) { 28 | const node = this.find(args.id); 29 | if (node) node.update(args.changes); 30 | } 31 | 32 | drop(args: { id: string }) { 33 | const node = this.find(args.id); 34 | if (node) node.drop(); 35 | } 36 | 37 | find(id: string, node: SimpleNode = this.root): SimpleNode | null { 38 | if (!node) return null; 39 | if (node.id === id) return node as SimpleNode; 40 | if (node.children) { 41 | for (let child of node.children) { 42 | const found = this.find(id, child); 43 | if (found) return found; 44 | } 45 | return null; 46 | } 47 | return null; 48 | } 49 | } 50 | 51 | function createRoot(data: T[]) { 52 | const root = new SimpleNode({ id: "ROOT" } as T, null); 53 | root.children = data.map((d) => createNode(d as T, root)); 54 | return root; 55 | } 56 | 57 | function createNode(data: T, parent: SimpleNode) { 58 | const node = new SimpleNode(data, parent); 59 | if (data.children) 60 | node.children = data.children.map((d) => createNode(d as T, node)); 61 | return node; 62 | } 63 | 64 | class SimpleNode { 65 | id: string; 66 | children?: SimpleNode[]; 67 | constructor(public data: T, public parent: SimpleNode | null) { 68 | this.id = data.id; 69 | } 70 | 71 | hasParent(): this is this & { parent: SimpleNode } { 72 | return !!this.parent; 73 | } 74 | 75 | get childIndex(): number { 76 | return this.hasParent() ? this.parent.children!.indexOf(this) : -1; 77 | } 78 | 79 | addChild(data: T, index: number) { 80 | const node = createNode(data, this); 81 | this.children = this.children ?? []; 82 | this.children.splice(index, 0, node); 83 | this.data.children = this.data.children ?? []; 84 | this.data.children.splice(index, 0, data); 85 | } 86 | 87 | removeChild(index: number) { 88 | this.children?.splice(index, 1); 89 | this.data.children?.splice(index, 1); 90 | } 91 | 92 | update(changes: Partial) { 93 | if (this.hasParent()) { 94 | const i = this.childIndex; 95 | this.parent.addChild({ ...this.data, ...changes }, i); 96 | this.drop(); 97 | } 98 | } 99 | 100 | drop() { 101 | if (this.hasParent()) this.parent.removeChild(this.childIndex); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /modules/react-arborist/src/dnd/compute-drop.ts: -------------------------------------------------------------------------------- 1 | import { XYCoord } from "react-dnd"; 2 | import { NodeApi } from "../interfaces/node-api"; 3 | import { 4 | bound, 5 | indexOf, 6 | isClosed, 7 | isItem, 8 | isOpenWithEmptyChildren, 9 | } from "../utils"; 10 | import { DropResult } from "./drop-hook"; 11 | 12 | function measureHover(el: HTMLElement, offset: XYCoord) { 13 | const rect = el.getBoundingClientRect(); 14 | const x = offset.x - Math.round(rect.x); 15 | const y = offset.y - Math.round(rect.y); 16 | const height = rect.height; 17 | const inTopHalf = y < height / 2; 18 | const inBottomHalf = !inTopHalf; 19 | const pad = height / 4; 20 | const inMiddle = y > pad && y < height - pad; 21 | const atTop = !inMiddle && inTopHalf; 22 | const atBottom = !inMiddle && inBottomHalf; 23 | return { x, inTopHalf, inBottomHalf, inMiddle, atTop, atBottom }; 24 | } 25 | 26 | type HoverData = ReturnType; 27 | 28 | function getNodesAroundCursor( 29 | node: NodeApi | null, 30 | prev: NodeApi | null, 31 | next: NodeApi | null, 32 | hover: HoverData 33 | ): [NodeApi | null, NodeApi | null] { 34 | if (!node) { 35 | // We're hovering over the empty part of the list, not over an item, 36 | // Put the cursor below the last item which is "prev" 37 | return [prev, null]; 38 | } 39 | if (node.isInternal) { 40 | if (hover.atTop) { 41 | return [prev, node]; 42 | } else if (hover.inMiddle) { 43 | return [node, node]; 44 | } else { 45 | return [node, next]; 46 | } 47 | } else { 48 | if (hover.inTopHalf) { 49 | return [prev, node]; 50 | } else { 51 | return [node, next]; 52 | } 53 | } 54 | } 55 | 56 | type Args = { 57 | element: HTMLElement; 58 | offset: XYCoord; 59 | indent: number; 60 | node: NodeApi | null; 61 | prevNode: NodeApi | null; 62 | nextNode: NodeApi | null; 63 | }; 64 | 65 | export type ComputedDrop = { 66 | drop: DropResult | null; 67 | cursor: Cursor | null; 68 | }; 69 | 70 | function dropAt( 71 | parentId: string | undefined, 72 | index: number | null 73 | ): DropResult { 74 | return { parentId: parentId || null, index }; 75 | } 76 | 77 | function lineCursor(index: number, level: number) { 78 | return { 79 | type: "line" as "line", 80 | index, 81 | level, 82 | }; 83 | } 84 | 85 | function noCursor() { 86 | return { 87 | type: "none" as "none", 88 | }; 89 | } 90 | 91 | function highlightCursor(id: string) { 92 | return { 93 | type: "highlight" as "highlight", 94 | id, 95 | }; 96 | } 97 | 98 | function walkUpFrom(node: NodeApi, level: number) { 99 | let drop = node; 100 | while (drop.parent && drop.level > level) { 101 | drop = drop.parent; 102 | } 103 | const parentId = drop.parent?.id || null; 104 | const index = indexOf(drop) + 1; 105 | return { parentId, index }; 106 | } 107 | 108 | export type LineCursor = ReturnType; 109 | export type NoCursor = ReturnType; 110 | export type HighlightCursor = ReturnType; 111 | export type Cursor = LineCursor | NoCursor | HighlightCursor; 112 | 113 | /** 114 | * This is the most complex, tricky function in the whole repo. 115 | */ 116 | export function computeDrop(args: Args): ComputedDrop { 117 | const hover = measureHover(args.element, args.offset); 118 | const indent = args.indent; 119 | const hoverLevel = Math.round(Math.max(0, hover.x - indent) / indent); 120 | const { node, nextNode, prevNode } = args; 121 | const [above, below] = getNodesAroundCursor(node, prevNode, nextNode, hover); 122 | 123 | /* Hovering over the middle of a folder */ 124 | if (node && node.isInternal && hover.inMiddle) { 125 | return { 126 | drop: dropAt(node.id, null), 127 | cursor: highlightCursor(node.id), 128 | }; 129 | } 130 | 131 | /* 132 | * Now we only need to care about the node above the cursor 133 | * ----------- ------- 134 | */ 135 | 136 | /* There is no node above the cursor line */ 137 | if (!above) { 138 | return { 139 | drop: dropAt(below?.parent?.id, 0), 140 | cursor: lineCursor(0, 0), 141 | }; 142 | } 143 | 144 | /* The node above the cursor line is an item */ 145 | if (isItem(above)) { 146 | const level = bound(hoverLevel, below?.level || 0, above.level); 147 | return { 148 | drop: walkUpFrom(above, level), 149 | cursor: lineCursor(above.rowIndex! + 1, level), 150 | }; 151 | } 152 | 153 | /* The node above the cursor line is a closed folder */ 154 | if (isClosed(above)) { 155 | const level = bound(hoverLevel, below?.level || 0, above.level); 156 | return { 157 | drop: walkUpFrom(above, level), 158 | cursor: lineCursor(above.rowIndex! + 1, level), 159 | }; 160 | } 161 | 162 | /* The node above the cursor line is an open folder with no children */ 163 | if (isOpenWithEmptyChildren(above)) { 164 | const level = bound(hoverLevel, 0, above.level + 1); 165 | if (level > above.level) { 166 | /* Will be the first child of the empty folder */ 167 | return { 168 | drop: dropAt(above.id, 0), 169 | cursor: lineCursor(above.rowIndex! + 1, level), 170 | }; 171 | } else { 172 | /* Will be a sibling or grandsibling of the empty folder */ 173 | return { 174 | drop: walkUpFrom(above, level), 175 | cursor: lineCursor(above.rowIndex! + 1, level), 176 | }; 177 | } 178 | } 179 | 180 | /* The node above the cursor is a an open folder with children */ 181 | return { 182 | drop: dropAt(above?.id, 0), 183 | cursor: lineCursor(above.rowIndex! + 1, above.level + 1), 184 | }; 185 | } 186 | -------------------------------------------------------------------------------- /modules/react-arborist/src/dnd/drag-hook.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { ConnectDragSource, useDrag } from "react-dnd"; 3 | import { getEmptyImage } from "react-dnd-html5-backend"; 4 | import { useTreeApi } from "../context"; 5 | import { NodeApi } from "../interfaces/node-api"; 6 | import { DragItem } from "../types/dnd"; 7 | import { DropResult } from "./drop-hook"; 8 | import { actions as dnd } from "../state/dnd-slice"; 9 | 10 | export function useDragHook(node: NodeApi): ConnectDragSource { 11 | const tree = useTreeApi(); 12 | const ids = tree.selectedIds; 13 | const [_, ref, preview] = useDrag( 14 | () => ({ 15 | canDrag: () => node.isDraggable, 16 | type: "NODE", 17 | item: () => { 18 | // This is fired once at the begging of a drag operation 19 | const dragIds = tree.isSelected(node.id) ? Array.from(ids) : [node.id]; 20 | tree.dispatch(dnd.dragStart(node.id, dragIds)); 21 | return { id: node.id, dragIds }; 22 | }, 23 | end: () => { 24 | tree.hideCursor(); 25 | tree.dispatch(dnd.dragEnd()); 26 | }, 27 | }), 28 | [ids, node], 29 | ); 30 | 31 | useEffect(() => { 32 | preview(getEmptyImage()); 33 | }, [preview]); 34 | 35 | return ref; 36 | } 37 | -------------------------------------------------------------------------------- /modules/react-arborist/src/dnd/drop-hook.ts: -------------------------------------------------------------------------------- 1 | import { RefObject } from "react"; 2 | import { ConnectDropTarget, useDrop } from "react-dnd"; 3 | import { useTreeApi } from "../context"; 4 | import { NodeApi } from "../interfaces/node-api"; 5 | import { DragItem } from "../types/dnd"; 6 | import { computeDrop } from "./compute-drop"; 7 | import { actions as dnd } from "../state/dnd-slice"; 8 | import { safeRun } from "../utils"; 9 | import { ROOT_ID } from "../data/create-root"; 10 | 11 | export type DropResult = { 12 | parentId: string | null; 13 | index: number | null; 14 | }; 15 | 16 | export function useDropHook( 17 | el: RefObject, 18 | node: NodeApi, 19 | ): ConnectDropTarget { 20 | const tree = useTreeApi(); 21 | const [_, dropRef] = useDrop( 22 | () => ({ 23 | accept: "NODE", 24 | canDrop: () => tree.canDrop(), 25 | hover: (_item, m) => { 26 | const offset = m.getClientOffset(); 27 | if (!el.current || !offset) return; 28 | const { cursor, drop } = computeDrop({ 29 | element: el.current, 30 | offset: offset, 31 | indent: tree.indent, 32 | node: node, 33 | prevNode: node.prev, 34 | nextNode: node.next, 35 | }); 36 | if (drop) tree.dispatch(dnd.hovering(drop.parentId, drop.index)); 37 | 38 | if (m.canDrop()) { 39 | if (cursor) tree.showCursor(cursor); 40 | } else { 41 | tree.hideCursor(); 42 | } 43 | }, 44 | drop: (_, m) => { 45 | if (!m.canDrop()) return null; 46 | let { parentId, index, dragIds } = tree.state.dnd; 47 | safeRun(tree.props.onMove, { 48 | dragIds, 49 | parentId: parentId === ROOT_ID ? null : parentId, 50 | index: index === null ? 0 : index, // When it's null it was dropped over a folder 51 | dragNodes: tree.dragNodes, 52 | parentNode: tree.get(parentId), 53 | }); 54 | tree.open(parentId); 55 | }, 56 | }), 57 | [node, el.current, tree.props], 58 | ); 59 | 60 | return dropRef; 61 | } 62 | -------------------------------------------------------------------------------- /modules/react-arborist/src/dnd/measure-hover.ts: -------------------------------------------------------------------------------- 1 | import { XYCoord } from "react-dnd"; 2 | import { bound } from "../utils"; 3 | 4 | export function measureHover(el: HTMLElement, offset: XYCoord, indent: number) { 5 | const nextEl = el.nextElementSibling as HTMLElement | null; 6 | const prevEl = el.previousElementSibling as HTMLElement | null; 7 | const rect = el.getBoundingClientRect(); 8 | const x = offset.x - Math.round(rect.x); 9 | const y = offset.y - Math.round(rect.y); 10 | const height = rect.height; 11 | const inTopHalf = y < height / 2; 12 | const inBottomHalf = !inTopHalf; 13 | const pad = height / 4; 14 | const inMiddle = y > pad && y < height - pad; 15 | const maxLevel = Number( 16 | inBottomHalf ? el.dataset.level : prevEl ? prevEl.dataset.level : 0 17 | ); 18 | const minLevel = Number( 19 | inTopHalf ? el.dataset.level : nextEl ? nextEl.dataset.level : 0 20 | ); 21 | const level = bound(Math.floor(x / indent), minLevel, maxLevel); 22 | 23 | return { level, inTopHalf, inBottomHalf, inMiddle }; 24 | } 25 | 26 | export type HoverData = ReturnType; 27 | -------------------------------------------------------------------------------- /modules/react-arborist/src/dnd/outer-drop-hook.ts: -------------------------------------------------------------------------------- 1 | import { useDrop } from "react-dnd"; 2 | import { useTreeApi } from "../context"; 3 | import { DragItem } from "../types/dnd"; 4 | import { computeDrop } from "./compute-drop"; 5 | import { DropResult } from "./drop-hook"; 6 | import { actions as dnd } from "../state/dnd-slice"; 7 | 8 | export function useOuterDrop() { 9 | const tree = useTreeApi(); 10 | 11 | // In case we drop an item at the bottom of the list 12 | const [, drop] = useDrop( 13 | () => ({ 14 | accept: "NODE", 15 | canDrop: (_item, m) => { 16 | if (!m.isOver({ shallow: true })) return false; 17 | return tree.canDrop(); 18 | }, 19 | hover: (_item, m) => { 20 | if (!m.isOver({ shallow: true })) return; 21 | const offset = m.getClientOffset(); 22 | if (!tree.listEl.current || !offset) return; 23 | const { cursor, drop } = computeDrop({ 24 | element: tree.listEl.current, 25 | offset: offset, 26 | indent: tree.indent, 27 | node: null, 28 | prevNode: tree.visibleNodes[tree.visibleNodes.length - 1], 29 | nextNode: null, 30 | }); 31 | if (drop) tree.dispatch(dnd.hovering(drop.parentId, drop.index)); 32 | 33 | if (m.canDrop()) { 34 | if (cursor) tree.showCursor(cursor); 35 | } else { 36 | tree.hideCursor(); 37 | } 38 | }, 39 | }), 40 | [tree] 41 | ); 42 | 43 | drop(tree.listEl); 44 | } 45 | -------------------------------------------------------------------------------- /modules/react-arborist/src/hooks/use-fresh-node.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { useTreeApi } from "../context"; 3 | import { IdObj } from "../types/utils"; 4 | 5 | export function useFreshNode(index: number) { 6 | const tree = useTreeApi(); 7 | const original = tree.at(index); 8 | if (!original) throw new Error(`Could not find node for index: ${index}`); 9 | 10 | return useMemo(() => { 11 | const fresh = original.clone(); 12 | tree.visibleNodes[index] = fresh; // sneaky 13 | return fresh; 14 | // Return a fresh instance if the state values change 15 | }, [...Object.values(original.state), original]); 16 | } 17 | -------------------------------------------------------------------------------- /modules/react-arborist/src/hooks/use-simple-tree.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import { SimpleTree } from "../data/simple-tree"; 3 | import { 4 | CreateHandler, 5 | DeleteHandler, 6 | MoveHandler, 7 | RenameHandler, 8 | } from "../types/handlers"; 9 | import { IdObj } from "../types/utils"; 10 | 11 | export type SimpleTreeData = { 12 | id: string; 13 | name: string; 14 | children?: SimpleTreeData[]; 15 | }; 16 | 17 | let nextId = 0; 18 | 19 | export function useSimpleTree(initialData: readonly T[]) { 20 | const [data, setData] = useState(initialData); 21 | const tree = useMemo( 22 | () => 23 | new SimpleTree(data), 25 | [data] 26 | ); 27 | 28 | const onMove: MoveHandler = (args: { 29 | dragIds: string[]; 30 | parentId: null | string; 31 | index: number; 32 | }) => { 33 | for (const id of args.dragIds) { 34 | tree.move({ id, parentId: args.parentId, index: args.index }); 35 | } 36 | setData(tree.data); 37 | }; 38 | 39 | const onRename: RenameHandler = ({ name, id }) => { 40 | tree.update({ id, changes: { name } as any }); 41 | setData(tree.data); 42 | }; 43 | 44 | const onCreate: CreateHandler = ({ parentId, index, type }) => { 45 | const data = { id: `simple-tree-id-${nextId++}`, name: "" } as any; 46 | if (type === "internal") data.children = []; 47 | tree.create({ parentId, index, data }); 48 | setData(tree.data); 49 | return data; 50 | }; 51 | 52 | const onDelete: DeleteHandler = (args: { ids: string[] }) => { 53 | args.ids.forEach((id) => tree.drop({ id })); 54 | setData(tree.data); 55 | }; 56 | 57 | const controller = { onMove, onRename, onCreate, onDelete }; 58 | 59 | return [data, controller] as const; 60 | } 61 | -------------------------------------------------------------------------------- /modules/react-arborist/src/hooks/use-validated-props.ts: -------------------------------------------------------------------------------- 1 | import { TreeProps } from "../types/tree-props"; 2 | import { IdObj } from "../types/utils"; 3 | import { SimpleTreeData, useSimpleTree } from "./use-simple-tree"; 4 | 5 | export function useValidatedProps(props: TreeProps): TreeProps { 6 | if (props.initialData && props.data) { 7 | throw new Error( 8 | `React Arborist Tree => Provide either a data or initialData prop, but not both.` 9 | ); 10 | } 11 | if ( 12 | props.initialData && 13 | (props.onCreate || props.onDelete || props.onMove || props.onRename) 14 | ) { 15 | throw new Error( 16 | `React Arborist Tree => You passed the initialData prop along with a data handler. 17 | Use the data prop if you want to provide your own handlers.` 18 | ); 19 | } 20 | if (props.initialData) { 21 | /** 22 | * Let's break the rules of hooks here. If the initialData prop 23 | * is provided, we will assume it will not change for the life of 24 | * the component. 25 | * 26 | * We will provide the real data and the handlers to update it. 27 | * */ 28 | const [data, controller] = useSimpleTree(props.initialData); 29 | return { ...props, ...controller, data }; 30 | } else { 31 | return props; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /modules/react-arborist/src/index.ts: -------------------------------------------------------------------------------- 1 | /* The Public Api */ 2 | export { Tree } from "./components/tree"; 3 | export * from "./types/handlers"; 4 | export * from "./types/renderers"; 5 | export * from "./types/state"; 6 | export * from "./interfaces/node-api"; 7 | export * from "./interfaces/tree-api"; 8 | export * from "./data/simple-tree"; 9 | export * from "./hooks/use-simple-tree"; 10 | -------------------------------------------------------------------------------- /modules/react-arborist/src/interfaces/node-api.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TreeApi } from "./tree-api"; 3 | import { IdObj } from "../types/utils"; 4 | import { ROOT_ID } from "../data/create-root"; 5 | 6 | type Params = { 7 | id: string; 8 | data: T; 9 | level: number; 10 | children: NodeApi[] | null; 11 | parent: NodeApi | null; 12 | isDraggable: boolean; 13 | rowIndex: number | null; 14 | tree: TreeApi; 15 | }; 16 | 17 | export class NodeApi { 18 | tree: TreeApi; 19 | id: string; 20 | data: T; 21 | level: number; 22 | children: NodeApi[] | null; 23 | parent: NodeApi | null; 24 | isDraggable: boolean; 25 | rowIndex: number | null; 26 | 27 | constructor(params: Params) { 28 | this.tree = params.tree; 29 | this.id = params.id; 30 | this.data = params.data; 31 | this.level = params.level; 32 | this.children = params.children; 33 | this.parent = params.parent; 34 | this.isDraggable = params.isDraggable; 35 | this.rowIndex = params.rowIndex; 36 | } 37 | 38 | get isRoot() { 39 | return this.id === ROOT_ID; 40 | } 41 | 42 | get isLeaf() { 43 | return !Array.isArray(this.children); 44 | } 45 | 46 | get isInternal() { 47 | return !this.isLeaf; 48 | } 49 | 50 | get isOpen() { 51 | return this.isLeaf ? false : this.tree.isOpen(this.id); 52 | } 53 | 54 | get isClosed() { 55 | return this.isLeaf ? false : !this.tree.isOpen(this.id); 56 | } 57 | 58 | get isEditable() { 59 | return this.tree.isEditable(this.data); 60 | } 61 | 62 | get isEditing() { 63 | return this.tree.editingId === this.id; 64 | } 65 | 66 | get isSelected() { 67 | return this.tree.isSelected(this.id); 68 | } 69 | 70 | get isOnlySelection() { 71 | return this.isSelected && this.tree.hasOneSelection; 72 | } 73 | 74 | get isSelectedStart() { 75 | return this.isSelected && !this.prev?.isSelected; 76 | } 77 | 78 | get isSelectedEnd() { 79 | return this.isSelected && !this.next?.isSelected; 80 | } 81 | 82 | get isFocused() { 83 | return this.tree.isFocused(this.id); 84 | } 85 | 86 | get isDragging() { 87 | return this.tree.isDragging(this.id); 88 | } 89 | 90 | get willReceiveDrop() { 91 | return this.tree.willReceiveDrop(this.id); 92 | } 93 | 94 | get state() { 95 | return { 96 | isClosed: this.isClosed, 97 | isDragging: this.isDragging, 98 | isEditing: this.isEditing, 99 | isFocused: this.isFocused, 100 | isInternal: this.isInternal, 101 | isLeaf: this.isLeaf, 102 | isOpen: this.isOpen, 103 | isSelected: this.isSelected, 104 | isSelectedEnd: this.isSelectedEnd, 105 | isSelectedStart: this.isSelectedStart, 106 | willReceiveDrop: this.willReceiveDrop, 107 | }; 108 | } 109 | 110 | get childIndex() { 111 | if (this.parent && this.parent.children) { 112 | return this.parent.children.findIndex((child) => child.id === this.id); 113 | } else { 114 | return -1; 115 | } 116 | } 117 | 118 | get next(): NodeApi | null { 119 | if (this.rowIndex === null) return null; 120 | return this.tree.at(this.rowIndex + 1); 121 | } 122 | 123 | get prev(): NodeApi | null { 124 | if (this.rowIndex === null) return null; 125 | return this.tree.at(this.rowIndex - 1); 126 | } 127 | 128 | get nextSibling(): NodeApi | null { 129 | const i = this.childIndex; 130 | return this.parent?.children![i + 1] ?? null; 131 | } 132 | 133 | isAncestorOf(node: NodeApi | null) { 134 | if (!node) return false; 135 | let ancestor: NodeApi | null = node; 136 | while (ancestor) { 137 | if (ancestor.id === this.id) return true; 138 | ancestor = ancestor.parent; 139 | } 140 | return false; 141 | } 142 | 143 | select() { 144 | this.tree.select(this); 145 | } 146 | 147 | deselect() { 148 | this.tree.deselect(this); 149 | } 150 | 151 | selectMulti() { 152 | this.tree.selectMulti(this); 153 | } 154 | 155 | selectContiguous() { 156 | this.tree.selectContiguous(this); 157 | } 158 | 159 | activate() { 160 | this.tree.activate(this); 161 | } 162 | 163 | focus() { 164 | this.tree.focus(this); 165 | } 166 | 167 | toggle() { 168 | this.tree.toggle(this); 169 | } 170 | 171 | open() { 172 | this.tree.open(this); 173 | } 174 | 175 | openParents() { 176 | this.tree.openParents(this); 177 | } 178 | 179 | close() { 180 | this.tree.close(this); 181 | } 182 | 183 | submit(value: string) { 184 | this.tree.submit(this, value); 185 | } 186 | 187 | reset() { 188 | this.tree.reset(); 189 | } 190 | 191 | clone() { 192 | return new NodeApi({ ...this }); 193 | } 194 | 195 | edit() { 196 | return this.tree.edit(this); 197 | } 198 | 199 | handleClick = (e: React.MouseEvent) => { 200 | if (e.metaKey && !this.tree.props.disableMultiSelection) { 201 | this.isSelected ? this.deselect() : this.selectMulti(); 202 | } else if (e.shiftKey && !this.tree.props.disableMultiSelection) { 203 | this.selectContiguous(); 204 | } else { 205 | this.select(); 206 | this.activate(); 207 | } 208 | }; 209 | } 210 | -------------------------------------------------------------------------------- /modules/react-arborist/src/interfaces/tree-api.test.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from "redux"; 2 | import { rootReducer } from "../state/root-reducer"; 3 | import { TreeProps } from "../types/tree-props"; 4 | import { TreeApi } from "./tree-api"; 5 | 6 | function setupApi(props: TreeProps) { 7 | const store = createStore(rootReducer); 8 | return new TreeApi(store, props, { current: null }, { current: null }); 9 | } 10 | 11 | test("tree.canDrop()", () => { 12 | expect(setupApi({ disableDrop: true }).canDrop()).toBe(false); 13 | expect(setupApi({ disableDrop: () => false }).canDrop()).toBe(true); 14 | expect(setupApi({ disableDrop: false }).canDrop()).toBe(true); 15 | }); 16 | -------------------------------------------------------------------------------- /modules/react-arborist/src/state/dnd-slice.ts: -------------------------------------------------------------------------------- 1 | import { Cursor } from "../dnd/compute-drop"; 2 | import { ActionTypes } from "../types/utils"; 3 | import { initialState } from "./initial"; 4 | 5 | /* Types */ 6 | export type DndState = { 7 | dragId: null | string; 8 | cursor: Cursor; 9 | dragIds: string[]; 10 | parentId: null | string; 11 | index: number | null; 12 | }; 13 | 14 | /* Actions */ 15 | export const actions = { 16 | cursor(cursor: Cursor) { 17 | return { type: "DND_CURSOR" as const, cursor }; 18 | }, 19 | dragStart(id: string, dragIds: string[]) { 20 | return { type: "DND_DRAG_START" as const, id, dragIds }; 21 | }, 22 | dragEnd() { 23 | return { type: "DND_DRAG_END" as const }; 24 | }, 25 | hovering(parentId: string | null, index: number | null) { 26 | return { type: "DND_HOVERING" as const, parentId, index }; 27 | }, 28 | }; 29 | 30 | /* Reducer */ 31 | export function reducer( 32 | state: DndState = initialState()["dnd"], 33 | action: ActionTypes 34 | ): DndState { 35 | switch (action.type) { 36 | case "DND_CURSOR": 37 | return { ...state, cursor: action.cursor }; 38 | case "DND_DRAG_START": 39 | return { ...state, dragId: action.id, dragIds: action.dragIds }; 40 | case "DND_DRAG_END": 41 | return initialState()["dnd"]; 42 | case "DND_HOVERING": 43 | return { ...state, parentId: action.parentId, index: action.index }; 44 | default: 45 | return state; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /modules/react-arborist/src/state/drag-slice.ts: -------------------------------------------------------------------------------- 1 | import { ActionTypes } from "../types/utils"; 2 | import { actions as dnd } from "./dnd-slice"; 3 | import { initialState } from "./initial"; 4 | 5 | /* Types */ 6 | 7 | export type DragSlice = { 8 | id: string | null; 9 | selectedIds: string[]; 10 | destinationParentId: string | null; 11 | destinationIndex: number | null; 12 | }; 13 | 14 | /* Reducer */ 15 | 16 | export function reducer( 17 | state: DragSlice = initialState().nodes.drag, 18 | action: ActionTypes 19 | ): DragSlice { 20 | switch (action.type) { 21 | case "DND_DRAG_START": 22 | return { ...state, id: action.id, selectedIds: action.dragIds }; 23 | case "DND_DRAG_END": 24 | return { 25 | ...state, 26 | id: null, 27 | destinationParentId: null, 28 | destinationIndex: null, 29 | selectedIds: [], 30 | }; 31 | case "DND_HOVERING": 32 | if ( 33 | action.parentId !== state.destinationParentId || 34 | action.index != state.destinationIndex 35 | ) { 36 | return { 37 | ...state, 38 | destinationParentId: action.parentId, 39 | destinationIndex: action.index, 40 | }; 41 | } else { 42 | return state; 43 | } 44 | default: 45 | return state; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /modules/react-arborist/src/state/edit-slice.ts: -------------------------------------------------------------------------------- 1 | /* Types */ 2 | export type EditState = { id: string | null }; 3 | 4 | /* Actions */ 5 | export function edit(id: string | null) { 6 | return { type: "EDIT" as const, id }; 7 | } 8 | 9 | /* Reducer */ 10 | export function reducer( 11 | state: EditState = { id: null }, 12 | action: ReturnType 13 | ) { 14 | if (action.type === "EDIT") { 15 | return { ...state, id: action.id }; 16 | } else { 17 | return state; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /modules/react-arborist/src/state/focus-slice.ts: -------------------------------------------------------------------------------- 1 | /* Types */ 2 | 3 | export type FocusState = { id: string | null; treeFocused: boolean }; 4 | 5 | /* Actions */ 6 | 7 | export function focus(id: string | null) { 8 | return { type: "FOCUS" as const, id }; 9 | } 10 | 11 | export function treeBlur() { 12 | return { type: "TREE_BLUR" } as const; 13 | } 14 | 15 | /* Reducer */ 16 | 17 | export function reducer( 18 | state: FocusState = { id: null, treeFocused: false }, 19 | action: ReturnType | ReturnType 20 | ) { 21 | if (action.type === "FOCUS") { 22 | return { ...state, id: action.id, treeFocused: true }; 23 | } else if (action.type === "TREE_BLUR") { 24 | return { ...state, treeFocused: false }; 25 | } else { 26 | return state; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /modules/react-arborist/src/state/initial.ts: -------------------------------------------------------------------------------- 1 | import { TreeProps } from "../types/tree-props"; 2 | import { RootState } from "./root-reducer"; 3 | 4 | export const initialState = (props?: TreeProps): RootState => ({ 5 | nodes: { 6 | // Changes together 7 | open: { filtered: {}, unfiltered: props?.initialOpenState ?? {} }, 8 | focus: { id: null, treeFocused: false }, 9 | edit: { id: null }, 10 | drag: { 11 | id: null, 12 | selectedIds: [], 13 | destinationParentId: null, 14 | destinationIndex: null, 15 | }, 16 | selection: { ids: new Set(), anchor: null, mostRecent: null }, 17 | }, 18 | dnd: { 19 | cursor: { type: "none" }, 20 | dragId: null, 21 | dragIds: [], 22 | parentId: null, 23 | index: -1, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /modules/react-arborist/src/state/open-slice.ts: -------------------------------------------------------------------------------- 1 | import { ActionTypes } from "../types/utils"; 2 | 3 | /* Types */ 4 | export type OpenMap = { [id: string]: boolean }; 5 | export type OpenSlice = { unfiltered: OpenMap; filtered: OpenMap }; 6 | 7 | /* Actions */ 8 | export const actions = { 9 | open(id: string, filtered: boolean) { 10 | return { type: "VISIBILITY_OPEN" as const, id, filtered }; 11 | }, 12 | close(id: string, filtered: boolean) { 13 | return { type: "VISIBILITY_CLOSE" as const, id, filtered }; 14 | }, 15 | toggle(id: string, filtered: boolean) { 16 | return { type: "VISIBILITY_TOGGLE" as const, id, filtered }; 17 | }, 18 | clear(filtered: boolean) { 19 | return { type: "VISIBILITY_CLEAR" as const, filtered }; 20 | }, 21 | }; 22 | 23 | /* Reducer */ 24 | 25 | function openMapReducer( 26 | state: OpenMap = {}, 27 | action: ActionTypes 28 | ) { 29 | if (action.type === "VISIBILITY_OPEN") { 30 | return { ...state, [action.id]: true }; 31 | } else if (action.type === "VISIBILITY_CLOSE") { 32 | return { ...state, [action.id]: false }; 33 | } else if (action.type === "VISIBILITY_TOGGLE") { 34 | const prev = state[action.id]; 35 | return { ...state, [action.id]: !prev }; 36 | } else if (action.type === "VISIBILITY_CLEAR") { 37 | return {}; 38 | } else { 39 | return state; 40 | } 41 | } 42 | 43 | export function reducer( 44 | state: OpenSlice = { filtered: {}, unfiltered: {} }, 45 | action: ActionTypes 46 | ): OpenSlice { 47 | if (!action.type.startsWith("VISIBILITY")) return state; 48 | if (action.filtered) { 49 | return { ...state, filtered: openMapReducer(state.filtered, action) }; 50 | } else { 51 | return { ...state, unfiltered: openMapReducer(state.unfiltered, action) }; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /modules/react-arborist/src/state/root-reducer.ts: -------------------------------------------------------------------------------- 1 | import { ActionFromReducer, combineReducers } from "redux"; 2 | import { reducer as focus } from "./focus-slice"; 3 | import { reducer as edit } from "./edit-slice"; 4 | import { reducer as dnd } from "./dnd-slice"; 5 | import { reducer as selection } from "./selection-slice"; 6 | import { reducer as open } from "./open-slice"; 7 | import { reducer as drag } from "./drag-slice"; 8 | 9 | export const rootReducer = combineReducers({ 10 | nodes: combineReducers({ 11 | focus, 12 | edit, 13 | open, 14 | selection, 15 | drag, 16 | }), 17 | dnd, 18 | }); 19 | 20 | export type RootState = ReturnType; 21 | export type Actions = ActionFromReducer; 22 | -------------------------------------------------------------------------------- /modules/react-arborist/src/state/selection-slice.ts: -------------------------------------------------------------------------------- 1 | import { ActionTypes, IdObj } from "../types/utils"; 2 | import { identify } from "../utils"; 3 | import { initialState } from "./initial"; 4 | 5 | /* Types */ 6 | export type SelectionState = { 7 | ids: Set; 8 | anchor: string | null; 9 | mostRecent: string | null; 10 | }; 11 | 12 | /* Actions */ 13 | export const actions = { 14 | clear: () => ({ type: "SELECTION_CLEAR" as const }), 15 | 16 | only: (id: string | IdObj) => ({ 17 | type: "SELECTION_ONLY" as const, 18 | id: identify(id), 19 | }), 20 | 21 | add: (id: string | string[] | IdObj | IdObj[]) => ({ 22 | type: "SELECTION_ADD" as const, 23 | ids: (Array.isArray(id) ? id : [id]).map(identify), 24 | }), 25 | 26 | remove: (id: string | string[] | IdObj | IdObj[]) => ({ 27 | type: "SELECTION_REMOVE" as const, 28 | ids: (Array.isArray(id) ? id : [id]).map(identify), 29 | }), 30 | 31 | set: (args: { 32 | ids: Set; 33 | anchor: string | null; 34 | mostRecent: string | null; 35 | }) => ({ 36 | type: "SELECTION_SET" as const, 37 | ...args, 38 | }), 39 | 40 | mostRecent: (id: string | null | IdObj) => ({ 41 | type: "SELECTION_MOST_RECENT" as const, 42 | id: id === null ? null : identify(id), 43 | }), 44 | 45 | anchor: (id: string | null | IdObj) => ({ 46 | type: "SELECTION_ANCHOR" as const, 47 | id: id === null ? null : identify(id), 48 | }), 49 | }; 50 | 51 | /* Reducer */ 52 | export function reducer( 53 | state: SelectionState = initialState()["nodes"]["selection"], 54 | action: ActionTypes 55 | ): SelectionState { 56 | const ids = state.ids; 57 | switch (action.type) { 58 | case "SELECTION_CLEAR": 59 | return { ...state, ids: new Set() }; 60 | case "SELECTION_ONLY": 61 | return { ...state, ids: new Set([action.id]) }; 62 | case "SELECTION_ADD": 63 | if (action.ids.length === 0) return state; 64 | action.ids.forEach((id) => ids.add(id)); 65 | return { ...state, ids: new Set(ids) }; 66 | case "SELECTION_REMOVE": 67 | if (action.ids.length === 0) return state; 68 | action.ids.forEach((id) => ids.delete(id)); 69 | return { ...state, ids: new Set(ids) }; 70 | case "SELECTION_SET": 71 | return { 72 | ...state, 73 | ids: action.ids, 74 | mostRecent: action.mostRecent, 75 | anchor: action.anchor, 76 | }; 77 | case "SELECTION_MOST_RECENT": 78 | return { ...state, mostRecent: action.id }; 79 | case "SELECTION_ANCHOR": 80 | return { ...state, anchor: action.id }; 81 | default: 82 | return state; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /modules/react-arborist/src/types/dnd.ts: -------------------------------------------------------------------------------- 1 | export type CursorLocation = { 2 | index: number | null; 3 | level: number | null; 4 | parentId: string | null; 5 | }; 6 | 7 | export type DragItem = { 8 | id: string; 9 | }; 10 | -------------------------------------------------------------------------------- /modules/react-arborist/src/types/handlers.ts: -------------------------------------------------------------------------------- 1 | import { NodeApi } from "../interfaces/node-api"; 2 | import { IdObj } from "./utils"; 3 | 4 | export type CreateHandler = (args: { 5 | parentId: string | null; 6 | parentNode: NodeApi | null; 7 | index: number; 8 | type: "internal" | "leaf"; 9 | }) => (IdObj | null) | Promise; 10 | 11 | export type MoveHandler = (args: { 12 | dragIds: string[]; 13 | dragNodes: NodeApi[]; 14 | parentId: string | null; 15 | parentNode: NodeApi | null; 16 | index: number; 17 | }) => void | Promise; 18 | 19 | export type RenameHandler = (args: { 20 | id: string; 21 | name: string; 22 | node: NodeApi; 23 | }) => void | Promise; 24 | 25 | export type DeleteHandler = (args: { 26 | ids: string[]; 27 | nodes: NodeApi[]; 28 | }) => void | Promise; 29 | 30 | export type EditResult = 31 | | { cancelled: true } 32 | | { cancelled: false; value: string }; 33 | -------------------------------------------------------------------------------- /modules/react-arborist/src/types/renderers.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties, HTMLAttributes, ReactElement } from "react"; 2 | import { IdObj } from "./utils"; 3 | import { NodeApi } from "../interfaces/node-api"; 4 | import { TreeApi } from "../interfaces/tree-api"; 5 | import { XYCoord } from "react-dnd"; 6 | 7 | export type NodeRendererProps = { 8 | style: CSSProperties; 9 | node: NodeApi; 10 | tree: TreeApi; 11 | dragHandle?: (el: HTMLDivElement | null) => void; 12 | preview?: boolean; 13 | }; 14 | 15 | export type RowRendererProps = { 16 | node: NodeApi; 17 | innerRef: (el: HTMLDivElement | null) => void; 18 | attrs: HTMLAttributes; 19 | children: ReactElement; 20 | }; 21 | 22 | export type DragPreviewProps = { 23 | offset: XYCoord | null; 24 | mouse: XYCoord | null; 25 | id: string | null; 26 | dragIds: string[]; 27 | isDragging: boolean; 28 | }; 29 | 30 | export type CursorProps = { 31 | top: number; 32 | left: number; 33 | indent: number; 34 | }; 35 | -------------------------------------------------------------------------------- /modules/react-arborist/src/types/state.ts: -------------------------------------------------------------------------------- 1 | import { NodeApi } from "../interfaces/node-api"; 2 | 3 | export type NodeState = typeof NodeApi.prototype["state"]; 4 | -------------------------------------------------------------------------------- /modules/react-arborist/src/types/tree-props.ts: -------------------------------------------------------------------------------- 1 | import { BoolFunc } from "./utils"; 2 | import * as handlers from "./handlers"; 3 | import * as renderers from "./renderers"; 4 | import { ElementType, MouseEventHandler } from "react"; 5 | import { ListOnScrollProps } from "react-window"; 6 | import { NodeApi } from "../interfaces/node-api"; 7 | import { OpenMap } from "../state/open-slice"; 8 | import { useDragDropManager } from "react-dnd"; 9 | 10 | export interface TreeProps { 11 | /* Data Options */ 12 | data?: readonly T[]; 13 | initialData?: readonly T[]; 14 | 15 | /* Data Handlers */ 16 | onCreate?: handlers.CreateHandler; 17 | onMove?: handlers.MoveHandler; 18 | onRename?: handlers.RenameHandler; 19 | onDelete?: handlers.DeleteHandler; 20 | 21 | /* Renderers*/ 22 | children?: ElementType>; 23 | renderRow?: ElementType>; 24 | renderDragPreview?: ElementType; 25 | renderCursor?: ElementType; 26 | renderContainer?: ElementType<{}>; 27 | 28 | /* Sizes */ 29 | rowHeight?: number; 30 | overscanCount?: number; 31 | width?: number | string; 32 | height?: number; 33 | indent?: number; 34 | paddingTop?: number; 35 | paddingBottom?: number; 36 | padding?: number; 37 | 38 | /* Config */ 39 | childrenAccessor?: string | ((d: T) => readonly T[] | null); 40 | idAccessor?: string | ((d: T) => string); 41 | openByDefault?: boolean; 42 | selectionFollowsFocus?: boolean; 43 | disableMultiSelection?: boolean; 44 | disableEdit?: string | boolean | BoolFunc; 45 | disableDrag?: string | boolean | BoolFunc; 46 | disableDrop?: 47 | | string 48 | | boolean 49 | | ((args: { 50 | parentNode: NodeApi; 51 | dragNodes: NodeApi[]; 52 | index: number; 53 | }) => boolean); 54 | 55 | /* Event Handlers */ 56 | onActivate?: (node: NodeApi) => void; 57 | onSelect?: (nodes: NodeApi[]) => void; 58 | onScroll?: (props: ListOnScrollProps) => void; 59 | onToggle?: (id: string) => void; 60 | onFocus?: (node: NodeApi) => void; 61 | 62 | /* Selection */ 63 | selection?: string; 64 | 65 | /* Open State */ 66 | initialOpenState?: OpenMap; 67 | 68 | /* Search */ 69 | searchTerm?: string; 70 | searchMatch?: (node: NodeApi, searchTerm: string) => boolean; 71 | 72 | /* Extra */ 73 | className?: string | undefined; 74 | rowClassName?: string | undefined; 75 | 76 | dndRootElement?: globalThis.Node | null; 77 | onClick?: MouseEventHandler; 78 | onContextMenu?: MouseEventHandler; 79 | dndManager?: ReturnType; 80 | } 81 | -------------------------------------------------------------------------------- /modules/react-arborist/src/types/utils.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction } from "redux"; 2 | import { NodeApi } from "../interfaces/node-api"; 3 | 4 | export interface IdObj { 5 | id: string; 6 | } 7 | 8 | export type Identity = string | IdObj | null; 9 | 10 | export type BoolFunc = (data: T) => boolean; 11 | 12 | export type ActionTypes< 13 | Actions extends { [name: string]: (...args: any[]) => AnyAction } 14 | > = ReturnType; 15 | 16 | export type SelectOptions = { multi?: boolean; contiguous?: boolean }; 17 | 18 | export type NodesById = { [id: string]: NodeApi }; 19 | -------------------------------------------------------------------------------- /modules/react-arborist/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { NodeApi } from "./interfaces/node-api"; 2 | import { TreeApi } from "./interfaces/tree-api"; 3 | import { IdObj } from "./types/utils"; 4 | 5 | export function bound(n: number, min: number, max: number) { 6 | return Math.max(Math.min(n, max), min); 7 | } 8 | 9 | export function isItem(node: NodeApi | null) { 10 | return node && node.isLeaf; 11 | } 12 | 13 | export function isClosed(node: NodeApi | null) { 14 | return node && node.isInternal && !node.isOpen; 15 | } 16 | 17 | export function isOpenWithEmptyChildren(node: NodeApi | null) { 18 | return node && node.isOpen && !node.children?.length; 19 | } 20 | 21 | /** 22 | * Is first param a descendant of the second param 23 | */ 24 | export const isDescendant = (a: NodeApi, b: NodeApi) => { 25 | let n: NodeApi | null = a; 26 | while (n) { 27 | if (n.id === b.id) return true; 28 | n = n.parent; 29 | } 30 | return false; 31 | }; 32 | 33 | export const indexOf = (node: NodeApi) => { 34 | if (!node.parent) throw Error("Node does not have a parent"); 35 | return node.parent.children!.findIndex((c) => c.id === node.id); 36 | }; 37 | 38 | export function noop() {} 39 | 40 | export function dfs(node: NodeApi, id: string): NodeApi | null { 41 | if (!node) return null; 42 | if (node.id === id) return node; 43 | if (node.children) { 44 | for (let child of node.children) { 45 | const result = dfs(child, id); 46 | if (result) return result; 47 | } 48 | } 49 | return null; 50 | } 51 | 52 | export function walk( 53 | node: NodeApi, 54 | fn: (node: NodeApi) => void 55 | ): void { 56 | fn(node); 57 | if (node.children) { 58 | for (let child of node.children) { 59 | walk(child, fn); 60 | } 61 | } 62 | } 63 | 64 | export function focusNextElement(target: HTMLElement) { 65 | const elements = getFocusable(target); 66 | 67 | let next: HTMLElement; 68 | for (let i = 0; i < elements.length; ++i) { 69 | const item = elements[i]; 70 | if (item === target) { 71 | next = nextItem(elements, i); 72 | break; 73 | } 74 | } 75 | 76 | // @ts-ignore ?? 77 | next?.focus(); 78 | } 79 | 80 | export function focusPrevElement(target: HTMLElement) { 81 | const elements = getFocusable(target); 82 | let next: HTMLElement; 83 | for (let i = 0; i < elements.length; ++i) { 84 | const item = elements[i]; 85 | if (item === target) { 86 | next = prevItem(elements, i); 87 | break; 88 | } 89 | } 90 | // @ts-ignore 91 | next?.focus(); 92 | } 93 | 94 | function nextItem(list: HTMLElement[], index: number) { 95 | if (index + 1 < list.length) { 96 | return list[index + 1] as HTMLElement; 97 | } else { 98 | return list[0] as HTMLElement; 99 | } 100 | } 101 | 102 | function prevItem(list: HTMLElement[], index: number) { 103 | if (index - 1 >= 0) { 104 | return list[index - 1]; 105 | } else { 106 | return list[list.length - 1]; 107 | } 108 | } 109 | 110 | function getFocusable(target: HTMLElement) { 111 | return Array.from( 112 | document.querySelectorAll( 113 | 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled]), details:not([disabled]), summary:not(:disabled)' 114 | ) 115 | ).filter((e) => e === target || !target.contains(e)) as HTMLElement[]; 116 | } 117 | 118 | export function access( 119 | obj: any, 120 | accessor: string | boolean | Function 121 | ): T { 122 | if (typeof accessor === "boolean") return accessor as unknown as T; 123 | if (typeof accessor === "string") return obj[accessor] as T; 124 | return accessor(obj) as T; 125 | } 126 | 127 | export function identifyNull(obj: string | IdObj | null) { 128 | if (obj === null) return null; 129 | else return identify(obj); 130 | } 131 | 132 | export function identify(obj: string | IdObj) { 133 | return typeof obj === "string" ? obj : obj.id; 134 | } 135 | 136 | export function mergeRefs(...refs: any) { 137 | return (instance: any) => { 138 | refs.forEach((ref: any) => { 139 | if (typeof ref === "function") { 140 | ref(instance); 141 | } else if (ref != null) { 142 | ref.current = instance; 143 | } 144 | }); 145 | }; 146 | } 147 | 148 | export function safeRun any>( 149 | fn: T | undefined, 150 | ...args: Parameters 151 | ) { 152 | if (fn) return fn(...args); 153 | } 154 | 155 | export function waitFor(fn: () => boolean) { 156 | return new Promise((resolve, reject) => { 157 | let tries = 0; 158 | function check() { 159 | tries += 1; 160 | if (tries === 100) reject(); 161 | if (fn()) resolve(); 162 | else setTimeout(check, 10); 163 | } 164 | check(); 165 | }); 166 | } 167 | 168 | export function getInsertIndex(tree: TreeApi) { 169 | const focus = tree.focusedNode; 170 | if (!focus) return tree.root.children?.length ?? 0; 171 | if (focus.isOpen) return 0; 172 | if (focus.parent) return focus.childIndex + 1; 173 | return 0; 174 | } 175 | 176 | export function getInsertParentId(tree: TreeApi) { 177 | const focus = tree.focusedNode; 178 | if (!focus) return null; 179 | if (focus.isOpen) return focus.id; 180 | if (focus.parent && !focus.parent.isRoot) return focus.parent.id; 181 | return null; 182 | } 183 | -------------------------------------------------------------------------------- /modules/react-arborist/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | "jsx": "react-jsx", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 39 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 40 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 41 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 42 | // "resolveJsonModule": true, /* Enable importing .json files. */ 43 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 45 | 46 | /* JavaScript Support */ 47 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 48 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 50 | 51 | /* Emit */ 52 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 54 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 55 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 56 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 57 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 58 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 59 | // "removeComments": true, /* Disable emitting comments. */ 60 | // "noEmit": true, /* Disable emitting files from a compilation. */ 61 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 62 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 63 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 64 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 65 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 66 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 67 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 68 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 69 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 70 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 71 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 72 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 73 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 74 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 75 | 76 | /* Interop Constraints */ 77 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 79 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 80 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 81 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 82 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 83 | 84 | /* Type Checking */ 85 | "strict": true, /* Enable all strict type-checking options. */ 86 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 87 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 88 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 89 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 90 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 91 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 92 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 93 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 94 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 95 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 96 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 97 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 98 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 99 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 100 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 101 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 102 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 103 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 104 | 105 | /* Completeness */ 106 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 107 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /modules/showcase/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /modules/showcase/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /modules/showcase/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /modules/showcase/components/fill-flex-parent.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import mergeRefs from "./merge-refs"; 3 | import useResizeObserver from "use-resize-observer"; 4 | 5 | type Props = { 6 | children: (dimens: { width: number; height: number }) => ReactElement; 7 | }; 8 | 9 | const style = { 10 | flex: 1, 11 | width: "100%", 12 | height: "100%", 13 | minHeight: 0, 14 | minWidth: 0, 15 | }; 16 | 17 | export const FillFlexParent = React.forwardRef(function FillFlexParent( 18 | props: Props, 19 | forwardRef 20 | ) { 21 | const { ref, width, height } = useResizeObserver(); 22 | return ( 23 |
    24 | {width && height ? props.children({ width, height }) : null} 25 |
    26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /modules/showcase/components/merge-refs.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type AnyRef = React.MutableRefObject | React.RefCallback | null; 4 | 5 | export default function mergeRefs(...refs: AnyRef[]) { 6 | return (instance: any) => { 7 | refs.forEach((ref) => { 8 | if (typeof ref === "function") { 9 | ref(instance); 10 | } else if (ref != null) { 11 | ref.current = instance; 12 | } 13 | }); 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /modules/showcase/data/gmail.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType } from "react"; 2 | import * as icons from "react-icons/md"; 3 | 4 | export type GmailItem = { 5 | id: string; 6 | name: string; 7 | icon: ComponentType; 8 | unread?: number; 9 | readOnly: boolean; 10 | children?: GmailItem[]; 11 | }; 12 | 13 | export const gmailData: GmailItem[] = [ 14 | { 15 | id: "1", 16 | name: "Inbox", 17 | unread: 1, 18 | readOnly: true, 19 | icon: icons.MdInbox, 20 | }, 21 | { 22 | id: "2", 23 | name: "Starred", 24 | unread: 0, 25 | readOnly: true, 26 | icon: icons.MdStarOutline, 27 | }, 28 | { 29 | id: "3", 30 | name: "Snoozed", 31 | unread: 0, 32 | readOnly: true, 33 | icon: icons.MdAccessTime, 34 | }, 35 | { 36 | id: "4", 37 | name: "Sent", 38 | unread: 0, 39 | readOnly: true, 40 | icon: icons.MdSend, 41 | }, 42 | { 43 | id: "5", 44 | name: "Drafts", 45 | unread: 14, 46 | readOnly: true, 47 | icon: icons.MdOutlineDrafts, 48 | }, 49 | { 50 | id: "6", 51 | name: "Spam", 52 | unread: 54, 53 | readOnly: true, 54 | icon: icons.MdOutlineReportGmailerrorred, 55 | }, 56 | { 57 | id: "7", 58 | name: "Important", 59 | unread: 0, 60 | readOnly: true, 61 | icon: icons.MdLabelImportantOutline, 62 | }, 63 | { 64 | id: "8", 65 | name: "Chats", 66 | unread: 0, 67 | readOnly: true, 68 | icon: icons.MdOutlineChat, 69 | }, 70 | { 71 | id: "9", 72 | name: "Scheduled", 73 | unread: 0, 74 | readOnly: true, 75 | icon: icons.MdOutlineScheduleSend, 76 | }, 77 | { 78 | id: "10", 79 | name: "All Mail", 80 | unread: 0, 81 | readOnly: true, 82 | icon: icons.MdOutlineMail, 83 | }, 84 | { 85 | id: "11", 86 | name: "Trash", 87 | unread: 0, 88 | readOnly: true, 89 | icon: icons.MdOutlineDelete, 90 | }, 91 | { 92 | id: "12", 93 | name: "Categories", 94 | icon: icons.MdOutlineLabel, 95 | readOnly: true, 96 | children: [ 97 | { 98 | id: "13", 99 | name: "Social", 100 | unread: 946, 101 | readOnly: false, 102 | icon: icons.MdPeopleOutline, 103 | }, 104 | { 105 | id: "14", 106 | name: "Updates", 107 | unread: 4580, 108 | readOnly: false, 109 | icon: icons.MdOutlineInfo, 110 | }, 111 | { 112 | id: "15", 113 | name: "Forums", 114 | unread: 312, 115 | readOnly: false, 116 | icon: icons.MdChatBubbleOutline, 117 | children: [ 118 | { 119 | id: "15-1", 120 | name: "Github", 121 | readOnly: false, 122 | icon: icons.MdSocialDistance, 123 | }, 124 | ], 125 | }, 126 | { 127 | id: "16", 128 | name: "Promotions", 129 | unread: 312, 130 | readOnly: false, 131 | icon: icons.MdOutlineLocalOffer, 132 | }, 133 | ], 134 | }, 135 | ]; 136 | -------------------------------------------------------------------------------- /modules/showcase/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | output: "export", 4 | reactStrictMode: true, 5 | swcMinify: true, 6 | }; 7 | 8 | module.exports = nextConfig; 9 | -------------------------------------------------------------------------------- /modules/showcase/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "showcase", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "next dev", 7 | "build": "next build", 8 | "lint": "next lint", 9 | "static": "serve ./out", 10 | "clean": "rimraf .next out" 11 | }, 12 | "dependencies": { 13 | "clsx": "^2.0.0", 14 | "nanoid": "^5.0.4", 15 | "next": "^14.0.4", 16 | "react": "^18.2.0", 17 | "react-arborist": "workspace:*", 18 | "react-dom": "^18.2.0", 19 | "react-icons": "^4.12.0", 20 | "tree-model-improved": "^2.0.1", 21 | "use-resize-observer": "^9.1.0" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "20.10.4", 25 | "@types/react": "18.2.43", 26 | "@types/react-dom": "18.2.17", 27 | "eslint": "^8.55.0", 28 | "eslint-config-next": "^14.0.4", 29 | "npm-run-all": "^4.1.5", 30 | "rimraf": "^5.0.5", 31 | "serve": "^14.2.1", 32 | "typescript": "^5.3.3" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /modules/showcase/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import "../styles/globals.css"; 3 | import type { AppProps } from "next/app"; 4 | 5 | function MyApp({ Component, pageProps }: AppProps) { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | } 15 | 16 | export default MyApp; 17 | -------------------------------------------------------------------------------- /modules/showcase/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 11 | 12 | 13 | 14 |
    15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /modules/showcase/pages/cities.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { useEffect, useRef, useState } from "react"; 3 | import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist"; 4 | import styles from "../styles/cities.module.css"; 5 | import { cities } from "../data/cities"; 6 | import { BsMapFill, BsMap, BsGeo, BsGeoFill } from "react-icons/bs"; 7 | import { FillFlexParent } from "../components/fill-flex-parent"; 8 | import { MdArrowDropDown, MdArrowRight } from "react-icons/md"; 9 | import Link from "next/link"; 10 | 11 | type Data = { id: string; name: string; children?: Data[] }; 12 | 13 | const data = sortData(cities); 14 | const INDENT_STEP = 15; 15 | 16 | export default function Cities() { 17 | const [tree, setTree] = useState | null | undefined>(null); 18 | const [active, setActive] = useState(null); 19 | const [focused, setFocused] = useState(null); 20 | const [selectedCount, setSelectedCount] = useState(0); 21 | const [searchTerm, setSearchTerm] = useState(""); 22 | const [count, setCount] = useState(0); 23 | const [followsFocus, setFollowsFocus] = useState(false); 24 | const [disableMulti, setDisableMulti] = useState(false); 25 | 26 | useEffect(() => { 27 | setCount(tree?.visibleNodes.length ?? 0); 28 | }, [tree, searchTerm]); 29 | 30 | return ( 31 |
    32 |
    33 |
    34 | 35 | {(dimens) => ( 36 | setTree(t)} 42 | openByDefault={true} 43 | searchTerm={searchTerm} 44 | selection={active?.id} 45 | className={styles.tree} 46 | rowClassName={styles.row} 47 | padding={15} 48 | rowHeight={30} 49 | indent={INDENT_STEP} 50 | overscanCount={8} 51 | onSelect={(selected) => setSelectedCount(selected.length)} 52 | onActivate={(node) => setActive(node.data)} 53 | onFocus={(node) => setFocused(node.data)} 54 | onToggle={() => { 55 | setTimeout(() => { 56 | setCount(tree?.visibleNodes.length ?? 0); 57 | }); 58 | }} 59 | > 60 | {Node} 61 | 62 | )} 63 | 64 |
    65 |
    66 |

    React Arborist Cities Demo

    67 |

    68 | Heads up!
    69 | This site works best on a desktop screen. 70 |

    71 |

    72 | In this demo, we hook into some callbacks, use the tree ref api, and 73 | render a large number of nodes.{" "} 74 |

    75 |
    76 | 79 | 86 |
    87 |
    88 | 91 | setSearchTerm(e.currentTarget.value)} 95 | /> 96 |
    97 |
    98 | 101 | setFollowsFocus((v) => !v)} 105 | /> 106 |
    107 |
    108 | 111 | setDisableMulti((v) => !v)} 115 | /> 116 |
    117 |
    118 | 121 |
    122 | 123 | 124 |
    125 |
    126 | 127 | 128 |
    129 |
    130 |
    131 |
    132 | 133 |
    {focused?.name ?? "(none)"}
    134 |
    135 | 136 |
    137 | 138 |
    {active?.name ?? "(none)"}
    139 |
    140 | 141 |
    142 | 143 |
    {count}
    144 |
    145 | 146 |
    147 | 148 |
    {selectedCount}
    149 |
    150 |
    151 |

    152 | Back To Demos 153 |

    154 |

    155 | 156 | Go to Docs 157 | 158 |

    159 |

    160 | 161 | Follow on Twitter 162 | 163 |

    164 |
    165 |
    166 |
    167 | ); 168 | } 169 | 170 | function Node({ node, style, dragHandle }: NodeRendererProps) { 171 | const Icon = node.isInternal ? BsMapFill : BsGeoFill; 172 | const indentSize = Number.parseFloat(`${style.paddingLeft || 0}`); 173 | 174 | return ( 175 |
    node.isInternal && node.toggle()} 180 | > 181 |
    182 | {new Array(indentSize / INDENT_STEP).fill(0).map((_, index) => { 183 | return
    ; 184 | })} 185 |
    186 | 187 | {" "} 188 | 189 | {node.isEditing ? : node.data.name} 190 | 191 |
    192 | ); 193 | } 194 | 195 | function Input({ node }: { node: NodeApi }) { 196 | return ( 197 | e.currentTarget.select()} 203 | onBlur={() => node.reset()} 204 | onKeyDown={(e) => { 205 | if (e.key === "Escape") node.reset(); 206 | if (e.key === "Enter") node.submit(e.currentTarget.value); 207 | }} 208 | /> 209 | ); 210 | } 211 | 212 | function sortData(data: Data[]) { 213 | function sortIt(data: Data[]) { 214 | data.sort((a, b) => (a.name < b.name ? -1 : 1)); 215 | data.forEach((d) => { 216 | if (d.children) sortIt(d.children); 217 | }); 218 | return data; 219 | } 220 | return sortIt(data); 221 | } 222 | 223 | function FolderArrow({ node }: { node: NodeApi }) { 224 | return ( 225 | 226 | {node.isInternal ? ( 227 | node.isOpen ? ( 228 | 229 | ) : ( 230 | 231 | ) 232 | ) : null} 233 | 234 | ); 235 | } 236 | -------------------------------------------------------------------------------- /modules/showcase/pages/gmail.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { 3 | CursorProps, 4 | NodeApi, 5 | NodeRendererProps, 6 | Tree, 7 | TreeApi, 8 | } from "react-arborist"; 9 | import { gmailData, GmailItem } from "../data/gmail"; 10 | import * as icons from "react-icons/md"; 11 | import styles from "../styles/Gmail.module.css"; 12 | import { FillFlexParent } from "../components/fill-flex-parent"; 13 | import { SiGmail } from "react-icons/si"; 14 | import { BsTree } from "react-icons/bs"; 15 | import { useState } from "react"; 16 | import Link from "next/link"; 17 | 18 | export default function GmailSidebar() { 19 | const [term, setTerm] = useState(""); 20 | const globalTree = (tree?: TreeApi | null) => { 21 | // @ts-ignore 22 | window.tree = tree; 23 | }; 24 | 25 | return ( 26 |
    27 |
    28 |
    29 |
    30 | 31 | 32 |

    Gmail

    33 |
    34 | 38 | 39 | {({ width, height }) => { 40 | return ( 41 | data.readOnly} 51 | disableDrop={({ parentNode, dragNodes }) => { 52 | if ( 53 | parentNode.data.name === "Categories" && 54 | dragNodes.some((drag) => drag.data.name === "Inbox") 55 | ) { 56 | return true; 57 | } else { 58 | return false; 59 | } 60 | }} 61 | > 62 | {Node} 63 | 64 | ); 65 | }} 66 | 67 |
    68 |
    69 |

    React Arborist Style Demo

    70 |

    71 | Heads up!
    72 | This site works best on a desktop screen. 73 |

    74 |

    75 | React Arborist can be used to create something like the gmail 76 | sidebar. 77 |

    78 |

    The tree is fully functional. Try the following:

    79 |
      80 |
    • Drag the items around
    • 81 |
    • Try to drag Inbox into Categories (not allowed)
    • 82 |
    • Move focus with the arrow keys
    • 83 |
    • Toggle folders (press spacebar)
    • 84 |
    • 85 | Rename (press enter, only allowed on items in {"'"}Categories{"'"} 86 | ) 87 |
    • 88 |
    • Create a new item (press A)
    • 89 |
    • Create a new folder (press shift+A)
    • 90 |
    • Delete items (press delete)
    • 91 |
    • Select multiple items with shift or meta
    • 92 |
    • 93 | Filter the tree by typing in this text box:{" "} 94 | setTerm(e.currentTarget.value)} 97 | /> 98 |
    • 99 |
    100 |

    101 | Star it on{" "} 102 | Github (The 103 | docs are there too). 104 |

    105 |

    106 | Follow updates on{" "} 107 | Twitter. 108 |

    109 | 110 |

    111 | Back to Demos 112 |

    113 |
    114 |
    115 |
    116 | ); 117 | } 118 | 119 | function Node({ node, style, dragHandle }: NodeRendererProps) { 120 | const Icon = node.data.icon || BsTree; 121 | return ( 122 |
    node.isInternal && node.toggle()} 127 | > 128 | 129 | 130 | 131 | 132 | {node.isEditing ? : node.data.name} 133 | {node.data.unread === 0 ? null : node.data.unread} 134 |
    135 | ); 136 | } 137 | 138 | function Input({ node }: { node: NodeApi }) { 139 | return ( 140 | e.currentTarget.select()} 145 | onBlur={() => node.reset()} 146 | onKeyDown={(e) => { 147 | if (e.key === "Escape") node.reset(); 148 | if (e.key === "Enter") node.submit(e.currentTarget.value); 149 | }} 150 | /> 151 | ); 152 | } 153 | 154 | function FolderArrow({ node }: { node: NodeApi }) { 155 | if (node.isLeaf) return ; 156 | return ( 157 | 158 | {node.isOpen ? : } 159 | 160 | ); 161 | } 162 | 163 | function Cursor({ top, left }: CursorProps) { 164 | return
    ; 165 | } 166 | -------------------------------------------------------------------------------- /modules/showcase/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import Image from "next/image"; 3 | import Head from "next/head"; 4 | import Link from "next/link"; 5 | import styles from "../styles/Home.module.css"; 6 | 7 | const Home: NextPage = () => { 8 | return ( 9 |
    10 | 11 | React Arborist 12 | 13 | 14 | 15 |
    16 |

    React Arborist Demos

    17 |

    18 | React arborist is a tree view component library for react. It provides 19 | the behavior most tree views need and leaves the styling up to you. 20 |

    21 |

    22 | If you are using it in your project, send me a screenshot on{" "} 23 | Twitter! Also leave a 24 | star on{" "} 25 | GitHub. 26 |

    27 | 28 |
    29 | 30 |
    31 |
    32 | Custom Styles 33 |

    Gmail Sidebar Clone

    34 |

    35 | In this demo, we recreate the Gmail sidebar to demonstrate how 36 | you can style a tree any way you want. 37 |

    38 | View Demo 39 |
    40 | 41 | 42 | 43 |
    44 |
    45 | 30,000 Nodes 46 |

    Top Cities By Population

    47 |

    48 | In this demo, we populate the tree with US cities grouped by 49 | state to demonstrate how the tree performs with over 30,000 50 | nodes. 51 |

    52 | View Demo 53 |
    54 | 55 |
    56 |
    57 |
    58 | ); 59 | }; 60 | 61 | export default Home; 62 | -------------------------------------------------------------------------------- /modules/showcase/pages/vscode.tsx: -------------------------------------------------------------------------------- 1 | import useResizeObserver from "use-resize-observer"; 2 | import styles from "../styles/vscode.module.css"; 3 | import { NodeRendererProps, Tree } from "react-arborist"; 4 | import { SiTypescript } from "react-icons/si"; 5 | import { MdFolder } from "react-icons/md"; 6 | import clsx from "clsx"; 7 | 8 | let id = 1; 9 | 10 | type Entry = { name: string; id: string; children?: Entry[] }; 11 | 12 | const nextId = () => (id++).toString(); 13 | const file = (name: string) => ({ name, id: nextId() }); 14 | const folder = (name: string, ...children: Entry[]) => ({ 15 | name, 16 | id: nextId(), 17 | children, 18 | }); 19 | 20 | const structure = [ 21 | folder( 22 | "src", 23 | file("index.ts"), 24 | folder( 25 | "lib", 26 | file("index.ts"), 27 | file("worker.ts"), 28 | file("utils.ts"), 29 | file("model.ts") 30 | ), 31 | folder( 32 | "ui", 33 | file("button.ts"), 34 | file("form.ts"), 35 | file("table.ts"), 36 | folder( 37 | "demo", 38 | file("welcome.ts"), 39 | file("example.ts"), 40 | file("container.ts") 41 | ) 42 | ) 43 | ), 44 | ]; 45 | 46 | function sortChildren(node: Entry): Entry { 47 | if (!node.children) return node; 48 | const copy = [...node.children]; 49 | copy.sort((a, b) => { 50 | if (!!a.children && !b.children) return -1; 51 | if (!!b.children && !a.children) return 1; 52 | return a.name < b.name ? -1 : 1; 53 | }); 54 | const children = copy.map(sortChildren); 55 | return { ...node, children }; 56 | } 57 | 58 | function useTreeSort(data: Entry[]) { 59 | return data.map(sortChildren); 60 | } 61 | 62 | function Node({ style, node, dragHandle, tree }: NodeRendererProps) { 63 | return ( 64 |
    73 | {node.isInternal ? : } 74 | {node.data.name} {node.id} 75 |
    76 | ); 77 | } 78 | 79 | export default function VSCodeDemoPage() { 80 | const { width, height, ref } = useResizeObserver(); 81 | 82 | const data = useTreeSort(structure); 83 | 84 | return ( 85 |
    86 | 98 |
    99 |
    100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /modules/showcase/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brimdata/react-arborist/c851c6f76ad5878f3ea7b68602e9b2f970df142e/modules/showcase/public/favicon.ico -------------------------------------------------------------------------------- /modules/showcase/public/img/cities-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brimdata/react-arborist/c851c6f76ad5878f3ea7b68602e9b2f970df142e/modules/showcase/public/img/cities-demo.png -------------------------------------------------------------------------------- /modules/showcase/public/img/gmail-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brimdata/react-arborist/c851c6f76ad5878f3ea7b68602e9b2f970df142e/modules/showcase/public/img/gmail-demo.png -------------------------------------------------------------------------------- /modules/showcase/public/img/golden-gate-bridge.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brimdata/react-arborist/c851c6f76ad5878f3ea7b68602e9b2f970df142e/modules/showcase/public/img/golden-gate-bridge.jpeg -------------------------------------------------------------------------------- /modules/showcase/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /modules/showcase/styles/Gmail.module.css: -------------------------------------------------------------------------------- 1 | .page { 2 | background: rgba(0,0,0,0.4) url(/img/golden-gate-bridge.jpeg); 3 | background-blend-mode: multiply; 4 | background-size: cover; 5 | min-height: 100vh; 6 | color: white; 7 | display:flex; 8 | flex-direction: column; 9 | } 10 | 11 | .header { 12 | padding: 8px; 13 | display: flex; 14 | align-items: center; 15 | font-size: 32px; 16 | gap: 24px; 17 | } 18 | 19 | .header svg { 20 | filter: drop-shadow(0 1px 2px rgb(0 0 0 / 65%)) 21 | } 22 | 23 | .header h1 { 24 | font-size: 24px; 25 | margin: 0; 26 | font-weight: 300; 27 | text-shadow: 0 1px 2px rgb(0 0 0 / 65%); 28 | } 29 | 30 | .header svg:nth-child(2) { 31 | color: #d24239; 32 | filter: drop-shadow(0 1px 2px rgb(0 0 0 / 65%)) 33 | } 34 | 35 | .sidebar { 36 | display: flex; 37 | width: 270px; 38 | flex-direction: column; 39 | font-family: Roboto, system-ui; 40 | } 41 | 42 | .composeButton { 43 | cursor: pointer; 44 | background: white; 45 | border-radius: 16px; 46 | color: #5f6368; 47 | height: 56px; 48 | min-width: 96px; 49 | border: 0; 50 | font-weight: 500; 51 | align-items: center; 52 | justify-content: center; 53 | box-shadow: 0 1px 2px 0 rgb(60 64 67 / 30%), 0 1px 3px 1px rgb(60 64 67 / 15%); 54 | font-size: 14px; 55 | width: max-content; 56 | display: flex; 57 | padding-right: 24px; 58 | margin: 16px 0 16px 8px; 59 | } 60 | 61 | .composeButton svg { 62 | font-size: 18px; 63 | margin: 0 12px; 64 | } 65 | 66 | .composeButton:hover { 67 | box-shadow: 0 1px 3px 0 rgb(60 64 67 / 30%), 0 4px 8px 3px rgb(60 64 67 / 15%); 68 | } 69 | 70 | 71 | [role="treeitem"]:has(.node) { 72 | color: white; 73 | border-radius: 0 16px 16px 0; 74 | cursor: pointer; 75 | text-shadow: 0 1px 2px rgb(0 0 0 / 65%); 76 | font-weight: 400; 77 | font-size: 14px; 78 | user-select: none;border: 1px dashed transparent; 79 | } 80 | 81 | [role="treeitem"]:has(.node):focus-visible { 82 | background-color: rgba(255,255,255,.2); 83 | outline: none; 84 | } 85 | 86 | [role="treeitem"][aria-selected="true"]:has(.node):focus-visible { 87 | background-color: rgba(255,255,255,.4); 88 | outline: none; 89 | } 90 | 91 | [role="treeitem"]:has(.node):hover { 92 | background-color: rgba(255,255,255,.2); 93 | } 94 | 95 | [role="treeitem"][aria-selected="true"]:has(.node) { 96 | background-color: rgba(255,255,255,.3); 97 | font-weight: 700; 98 | } 99 | 100 | [role="treeitem"]:has(.node:global(.willReceiveDrop)) { 101 | background-color: rgba(255,255,255,.4); 102 | border: 1px dashed white; 103 | } 104 | 105 | .node { 106 | color: #efefef; 107 | } 108 | 109 | [role="treeitem"][aria-selected="true"] .node { 110 | color: white; 111 | } 112 | 113 | .node { 114 | display: flex; 115 | align-items: center; 116 | margin: 0 12px 0 2px; 117 | height: 100%; 118 | line-height: 20px; 119 | white-space: nowrap; 120 | } 121 | 122 | 123 | 124 | /* Dropdown arrow */ 125 | .node span:nth-child(1) { 126 | width: 20px; 127 | display: flex; 128 | font-size: 20px; 129 | } 130 | 131 | /* Icon */ 132 | .node span:nth-child(2) { 133 | margin-right: 18px; 134 | display: flex; 135 | align-items: center; 136 | font-size: 20px; 137 | } 138 | 139 | /* Name */ 140 | .node span:nth-child(3) { 141 | flex: 1; 142 | overflow: hidden; 143 | text-overflow: ellipsis; 144 | } 145 | 146 | .dropCursor { 147 | width: 100%; 148 | height: 0px; 149 | border-top: 2px dotted white; 150 | position: absolute; 151 | } 152 | 153 | .mainContent { 154 | flex: 1; 155 | display: flex; 156 | min-height: 0; 157 | gap: 8px; 158 | } 159 | 160 | .content { 161 | overflow: auto; 162 | background-color: rgb( 255 255 255 / 0.9); 163 | border-radius: 16px; 164 | color: black; 165 | padding: 32px; 166 | margin: 8px; 167 | margin: 0 auto; 168 | margin-top: 132px; 169 | max-width: 600px; 170 | align-self: flex-start; 171 | } 172 | 173 | .mobileWarning { 174 | background: var(--primaryColor); 175 | color: white; 176 | padding: 1em; 177 | font-weight: bold; 178 | text-align: center; 179 | border-radius: 4px; 180 | display: none; 181 | } 182 | 183 | 184 | @media screen and (max-width: 600px) { 185 | .mainContent { 186 | flex-direction: column; 187 | padding-right: 0; 188 | } 189 | .content { 190 | order: 1; 191 | margin-top: 16px; 192 | margin-bottom: 16px; 193 | margin: 6px; 194 | } 195 | .sidebar { 196 | order: 2; 197 | height: 80vh; 198 | width: 100%; 199 | } 200 | 201 | .mobileWarning { 202 | display: block; 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /modules/showcase/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | padding: 1rem; 3 | max-width: 900px; 4 | margin: 0 auto; 5 | } 6 | 7 | .main p { 8 | line-height: 1.6; 9 | } 10 | 11 | .demos { 12 | display: flex; 13 | flex-wrap: wrap; 14 | justify-content: space-around; 15 | align-items: flex-start; 16 | gap: 20px; 17 | } 18 | 19 | .demoCard { 20 | flex: 1; 21 | max-width: 350px; 22 | border-radius: 6px; 23 | box-shadow: 0 0px 0px 1px rgb(0 0 0 /0.1) 24 | ,0 5px 10px -2px rgb(0 0 0 /0.2); 25 | padding-bottom: 20px; 26 | position: relative; 27 | overflow: hidden; 28 | margin: 50px 0; 29 | cursor: pointer; 30 | transition: all 0.5s; 31 | } 32 | 33 | .demoCard:hover { 34 | box-shadow: 0 0px 0px 1px rgb(0 0 0 /0.1) 35 | ,0 5px 20px -2px rgb(0 0 0 /0.4); 36 | } 37 | 38 | .demoCardImage { 39 | height: 350px; 40 | background-size: cover; 41 | } 42 | 43 | .demoCardImage:global(.gmail) { 44 | background-image: url(/img/gmail-demo.png); 45 | } 46 | 47 | .demoCardImage:global(.cities) { 48 | background-image: url(/img/cities-demo.png); 49 | } 50 | 51 | .demoCard b { 52 | background: hsl(5deg 65% 52%); 53 | color: white; 54 | font-weight: 500; 55 | letter-spacing: 1px;; 56 | text-transform: uppercase; 57 | font-size: 12px; 58 | padding: 5px 15px; 59 | border-radius: 12px 0 0 12px; 60 | position: absolute; 61 | top: 10px; 62 | right: 0; 63 | box-shadow: 0 0 10px rgb(0 0 0 /0.5); 64 | } 65 | 66 | .demoCard h2, 67 | .demoCard p, 68 | .demoCard a { 69 | padding: 0 20px; 70 | } 71 | 72 | @media screen and (max-width: 600px) { 73 | .demos { 74 | display: block; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /modules/showcase/styles/cities.module.css: -------------------------------------------------------------------------------- 1 | .container{ 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | .split { 7 | display: flex; 8 | } 9 | 10 | .treeContainer { 11 | display: flex; 12 | height: 100vh; 13 | flex: 1; 14 | min-width: 0; 15 | } 16 | 17 | .contentContainer { 18 | flex: 2; 19 | padding: 50px; 20 | height: 100vh; 21 | overflow-y: auto; 22 | } 23 | 24 | .contentContainer section { 25 | margin-bottom: 25px; 26 | } 27 | 28 | .contentContainer button { 29 | color: white; 30 | background: rgb(20, 127, 250); 31 | border: none; 32 | box-shadow: 1px 1px 0 rgb(12, 105, 211), 33 | 2px 2px 0 rgb(12, 105, 211), 34 | 3px 3px 0 rgb(12, 105, 211), 35 | 4px 4px 0 rgb(12, 105, 211), 36 | 5px 5px 0 rgb(12, 105, 211) 37 | ; 38 | font-size: 20px; 39 | padding: 10px; 40 | cursor: pointer; 41 | margin-bottom: 10px; 42 | } 43 | 44 | .contentContainer button:active { 45 | background: rgb(12, 105, 211); 46 | box-shadow: none; 47 | position: relative; 48 | top: 3px; 49 | left: 3px; 50 | } 51 | 52 | .contentContainer input { 53 | border: 2px solid rgb(12, 105, 211); 54 | box-shadow: 1px 1px 0 rgb(12, 105, 211), 55 | 2px 2px 0 rgb(12, 105, 211), 56 | 3px 3px 0 rgb(12, 105, 211), 57 | 4px 4px 0 rgb(12, 105, 211), 58 | 5px 5px 0 rgb(12, 105, 211) 59 | ; 60 | font-size: 20px; 61 | padding: 10px; 62 | } 63 | 64 | .contentContainer label, 65 | .contentContainer button, 66 | .contentContainer input { 67 | display: block; 68 | margin: 10px 0; 69 | } 70 | 71 | .statsgrid { 72 | display:flex; 73 | gap: 10px; 74 | flex-wrap: wrap; 75 | } 76 | 77 | .infobox { 78 | width: 200px; 79 | height: 100px; 80 | background:rgb(20, 127, 250); 81 | border-radius: 4px; 82 | display: flex; 83 | flex-direction: column; 84 | align-items: center; 85 | color:rgb(179, 211, 249); 86 | box-shadow: 1px 1px 0 rgb(12, 105, 211), 87 | 2px 2px 0 rgb(12, 105, 211), 88 | 3px 3px 0 rgb(12, 105, 211), 89 | 4px 4px 0 rgb(12, 105, 211), 90 | 5px 5px 0 rgb(12, 105, 211) 91 | ; 92 | } 93 | 94 | .stat { 95 | color: white; 96 | text-shadow: 0 1px 1px rgb(0 0 0 /0.4); 97 | font-size: 30px; 98 | justify-self: center; 99 | margin: auto 0; 100 | padding: 5px; 101 | text-align: center; 102 | } 103 | 104 | 105 | .tree { 106 | border-radius: 16px; 107 | background: #efefef; 108 | } 109 | 110 | .row { 111 | white-space: nowrap; 112 | cursor: pointer; 113 | } 114 | 115 | .node { 116 | position: relative; 117 | border-radius: 8px; 118 | display: flex; 119 | align-items: center; 120 | margin: 0 20px; 121 | height: 100%; 122 | } 123 | 124 | .node:global(.willReceiveDrop) { 125 | background: #bbb; 126 | } 127 | 128 | .node:global(.isSelected) { 129 | background: rgb(20, 127, 250, 0.5); 130 | color: white; 131 | border-radius: 0; 132 | } 133 | 134 | .node:global(.isSelectedStart) { 135 | border-radius: 8px 8px 0 0 ; 136 | } 137 | 138 | .node:global(.isSelectedEnd) { 139 | border-radius: 0 0 8px 8px; 140 | } 141 | 142 | .node:global(.isSelectedStart.isSelectedEnd) { 143 | border-radius: 8px; 144 | } 145 | 146 | .tree:hover .indentLines { 147 | display: flex; 148 | } 149 | 150 | .indentLines { 151 | --indent-size: 15px; 152 | 153 | position: absolute; 154 | top: 0; 155 | left: 0; 156 | z-index: -1; 157 | display: none; 158 | align-items: flex-start; 159 | height: 100%; 160 | } 161 | 162 | .indentLines > div { 163 | height: 100%; 164 | padding-left: 10px; 165 | border-right: 1px solid #ccc; 166 | margin-right: calc(var(--indent-size) - 10px - 1px); 167 | } 168 | 169 | .row:focus { 170 | outline: none; 171 | } 172 | 173 | .row:focus .node { 174 | background: #ddd; 175 | } 176 | 177 | .row:focus .node:global(.isSelected) { 178 | background: rgb(12, 105, 211); 179 | } 180 | 181 | .icon { 182 | margin: 0 10px; 183 | flex-shrink: 0; 184 | } 185 | 186 | .text { 187 | flex: 1; 188 | overflow: hidden; 189 | text-overflow: ellipsis; 190 | } 191 | 192 | .node:global(.isInternal) { 193 | cursor: pointer; 194 | } 195 | 196 | .arrow { 197 | width: 20px; 198 | display: flex; 199 | font-size: 20px; 200 | } 201 | 202 | .buttonRow { 203 | display: flex; 204 | gap: 20px; 205 | } 206 | 207 | .mobileWarning { 208 | background: var(--primaryColor); 209 | color: white; 210 | padding: 1em; 211 | font-weight: bold; 212 | text-align: center; 213 | border-radius: 4px; 214 | display: none; 215 | } 216 | 217 | @media screen and (max-width: 720px) { 218 | .split { 219 | display: block; 220 | } 221 | .treeContainer { 222 | bottom: 0; 223 | left: 0; 224 | right: 0; 225 | height: 40vh; 226 | position: absolute; 227 | display: flex; 228 | } 229 | .tree { 230 | box-shadow: 0 -3px 6px rgb(0 0 0 / 0.15); 231 | } 232 | .contentContainer { 233 | padding-bottom: 50vh; 234 | } 235 | 236 | .mobileWarning { 237 | display: block; 238 | } 239 | } 240 | 241 | @media (prefers-color-scheme: dark) { 242 | .tree { 243 | background: #101010; 244 | } 245 | 246 | .row:focus-visible .node { 247 | color: black; 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /modules/showcase/styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | :root { 9 | --primaryColor: #d24239; 10 | } 11 | 12 | a { 13 | color: var(--primaryColor); 14 | } 15 | 16 | * { 17 | box-sizing: border-box; 18 | } 19 | 20 | @media (prefers-color-scheme: dark) { 21 | html { 22 | color-scheme: dark; 23 | } 24 | body { 25 | color: white; 26 | background: black; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /modules/showcase/styles/vscode.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | display: grid; 3 | grid-template-columns: 360px 1fr; 4 | grid-template-rows: 1fr; 5 | height: 100vh; 6 | width: 100vw; 7 | } 8 | 9 | .node { 10 | font-size: 13px; 11 | display: grid; 12 | grid-template-columns: auto 1fr; 13 | gap: 10px; 14 | cursor: default; 15 | height: 100%; 16 | align-items: center;; 17 | } 18 | 19 | .sidebar { 20 | background: #192226; 21 | color: rgb(95, 122, 135); 22 | } 23 | 24 | .main { 25 | background: #253238; 26 | } 27 | 28 | .node:global(.isInternal) svg { 29 | fill: #80CBC4; 30 | } 31 | 32 | .node:global(.isLeaf) svg { 33 | width: 10px; 34 | fill: #3865BD; 35 | } 36 | 37 | .node:hover { 38 | color: white; 39 | } 40 | 41 | .highlight { 42 | background: #062F4A; 43 | } 44 | -------------------------------------------------------------------------------- /modules/showcase/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true, 21 | }, 22 | "include": [ 23 | "next-env.d.ts", 24 | "**/*.ts", 25 | "**/*.tsx" 26 | ], 27 | "exclude": [ 28 | "node_modules" 29 | ], 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-arborist-monorepo", 3 | "workspaces": [ 4 | "./modules/*" 5 | ], 6 | "scripts": { 7 | "build": "yarn workspaces foreach --all run build", 8 | "build-lib": "yarn workspace react-arborist build", 9 | "test": "yarn workspaces foreach --all run test", 10 | "watch": "yarn workspace react-arborist watch", 11 | "bump": "yarn workspace react-arborist version", 12 | "clean": "yarn workspace react-arborist clean", 13 | "showcase": "yarn workspace showcase start", 14 | "start": "run-s clean build-lib && run-p watch showcase", 15 | "publish": "sh bin/publish" 16 | }, 17 | "private": true, 18 | "packageManager": "yarn@4.0.2", 19 | "devDependencies": { 20 | "npm-run-all": "^4.1.5", 21 | "typescript": "^5.3.3" 22 | } 23 | } 24 | --------------------------------------------------------------------------------