├── .all-contributorsrc ├── .changeset ├── README.md └── config.json ├── .github ├── FUNDING.yml ├── canary-version.mjs └── workflows │ ├── pr.yml │ ├── release-canary.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── .storybook ├── main.ts ├── manager-head.html ├── preview-head.html ├── preview.ts └── test-runner.ts ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── UPGRADE.md ├── biome.json ├── package.json ├── pnpm-lock.yaml ├── react-trello-README.md ├── react-trello.gif ├── src ├── components │ ├── AddCardLink.tsx │ ├── Card.tsx │ ├── Card │ │ └── Tag.tsx │ ├── Lane │ │ ├── LaneFooter.tsx │ │ ├── LaneHeader.tsx │ │ └── LaneHeader │ │ │ └── LaneMenu.tsx │ ├── Loader.tsx │ ├── NewCardForm.tsx │ ├── NewLaneForm.tsx │ ├── NewLaneSection.tsx │ └── index.ts ├── controllers │ ├── Board.tsx │ ├── BoardContainer.tsx │ └── Lane.tsx ├── dnd │ ├── Container.tsx │ └── Draggable.tsx ├── helpers │ ├── autosize.ts │ ├── createTranslate.ts │ └── deprecationWarnings.ts ├── index.tsx ├── locales │ ├── en │ │ └── translation.json │ ├── index.ts │ └── ru │ │ └── translation.json ├── store │ ├── store.ts │ └── useBoard.ts ├── styles │ ├── Base.ts │ ├── Elements.ts │ └── Loader.ts ├── types │ ├── Board.ts │ ├── EventBus.ts │ └── utilities.ts └── widgets │ ├── DeleteButton.tsx │ ├── EditableLabel.tsx │ ├── InlineInput.tsx │ ├── NewLaneTitleEditor.tsx │ └── index.ts ├── stories ├── AdvancedFeatures.story.tsx ├── BasicFunctions.story.tsx ├── CustomAddCardLink.story.tsx ├── CustomCard.story.tsx ├── CustomCardWithDrag.story.tsx ├── CustomLaneFooter.story.tsx ├── CustomLaneHeader.story.tsx ├── CustomNewCardForm.story.tsx ├── CustomNewLaneForm.story.tsx ├── CustomNewLaneSection.story.tsx ├── Deprecations.story.tsx ├── DragDrop.story.tsx ├── EditableBoard.story.tsx ├── I18n.story.tsx ├── MultipleBoards.story.tsx ├── PaginationAndEvents.story.tsx ├── Realtime.story.tsx ├── RestrictedLanes.story.tsx ├── Styling.story.tsx ├── __snapshots__ │ ├── AdvancedFeatures.story.tsx.snap │ ├── BasicFunctions.story.tsx.snap │ ├── CustomAddCardLink.story.tsx.snap │ ├── CustomCard.story.tsx.snap │ ├── CustomCardWithDrag.story.tsx.snap │ ├── CustomLaneFooter.story.tsx.snap │ ├── CustomLaneHeader.story.tsx.snap │ ├── CustomNewCardForm.story.tsx.snap │ ├── CustomNewLaneForm.story.tsx.snap │ ├── CustomNewLaneSection.story.tsx.snap │ ├── Deprecations.story.tsx.snap │ ├── DragDrop.story.tsx.snap │ ├── EditableBoard.story.tsx.snap │ ├── I18n.story.tsx.snap │ ├── MultipleBoards.story.tsx.snap │ ├── PaginationAndEvents.story.tsx.snap │ ├── Realtime.story.tsx.snap │ ├── RestrictedLanes.story.tsx.snap │ └── Styling.story.tsx.snap ├── board.css ├── data │ ├── base.json │ ├── board_with_custom_width.json │ ├── collapsible.json │ ├── data-sort.json │ ├── drag-drop.json │ └── other-board.json ├── drag.css └── helpers │ ├── debug.js │ └── i18n.js └── tsconfig.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "KaiSpencer", 10 | "name": "Kai Spencer", 11 | "avatar_url": "https://avatars.githubusercontent.com/u/51139521?v=4", 12 | "profile": "https://github.com/KaiSpencer", 13 | "contributions": [ 14 | "code", 15 | "doc", 16 | "maintenance" 17 | ] 18 | } 19 | ], 20 | "contributorsPerLine": 7, 21 | "projectName": "react-trello-ts", 22 | "projectOwner": "KaiSpencer", 23 | "repoType": "github", 24 | "repoHost": "https://github.com", 25 | "skipCi": true 26 | } 27 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch" 10 | } 11 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [KaiSpencer] 4 | -------------------------------------------------------------------------------- /.github/canary-version.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { exec } from "child_process"; 4 | import fs from "fs"; 5 | 6 | try { 7 | exec("git rev-parse --short HEAD", (err, stdout) => { 8 | if (err) { 9 | console.log(err); 10 | process.exit(1); 11 | } 12 | const commitHash = stdout.trim(); 13 | 14 | const pkg = JSON.parse(fs.readFileSync("package.json", "utf-8")); 15 | const oldVersion = pkg.version; 16 | const [major, minor, patch] = oldVersion.split(".").map(Number); 17 | const newVersion = `${major}.${minor}.${patch + 1}-canary.${commitHash}`; 18 | 19 | pkg.version = newVersion; 20 | 21 | const content = `${JSON.stringify(pkg, null, "\t")}\n`; 22 | const newContent = content 23 | .replace( 24 | new RegExp(`"@uploadthing/\\*": "${oldVersion}"`, "g"), 25 | `"@uploadthing/*": "${newVersion}"`, 26 | ) 27 | .replace( 28 | new RegExp(`"uploadthing": "${oldVersion}"`, "g"), 29 | `"uploadthing": "${newVersion}"`, 30 | ); 31 | 32 | fs.writeFileSync("package.json", newContent); 33 | }); 34 | } catch (error) { 35 | console.error(error); 36 | process.exit(1); 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | 7 | jobs: 8 | lint: 9 | name: Node ${{ matrix.node-version }} 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | node-version: [18, 20, 22] 14 | timeout-minutes: 15 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | 21 | - name: Install Node.js 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - uses: pnpm/action-setup@v2 27 | name: Install pnpm 28 | id: pnpm-install 29 | with: 30 | version: 10.6.0 31 | run_install: false 32 | 33 | - name: Get pnpm store directory 34 | id: pnpm-cache 35 | shell: bash 36 | run: | 37 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 38 | 39 | - uses: actions/cache@v3 40 | name: Setup pnpm cache 41 | with: 42 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 43 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 44 | restore-keys: | 45 | ${{ runner.os }}-pnpm-store- 46 | 47 | - name: Install dependencies 48 | run: pnpm install 49 | 50 | - name: Playwright Browsers 51 | run: pnpm exec playwright install chromium 52 | 53 | - name: Lint 54 | run: pnpm lint 55 | 56 | - name: Build 57 | run: pnpm build 58 | 59 | - name: Check publish configuration 60 | run: pnpm publint --strict 61 | 62 | - name: Check library type configuration 63 | run: pnpm attw --pack . 64 | 65 | - name: Unit Test 66 | run: pnpm test 67 | -------------------------------------------------------------------------------- /.github/workflows/release-canary.yml: -------------------------------------------------------------------------------- 1 | name: Release - Canary 2 | 3 | on: 4 | pull_request: 5 | types: [labeled] 6 | branches: 7 | - main 8 | jobs: 9 | release: 10 | if: contains(github.event.pull_request.labels.*.name, 'release canary') 11 | name: Build & Publish a canary release 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v3 17 | 18 | - name: Install Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18 22 | 23 | - uses: pnpm/action-setup@v2 24 | name: Install pnpm 25 | id: pnpm-install 26 | with: 27 | version: 8 28 | run_install: false 29 | 30 | - name: Get pnpm store directory 31 | id: pnpm-cache 32 | run: | 33 | echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT 34 | 35 | - name: Setup pnpm cache 36 | uses: actions/cache@v3 37 | with: 38 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 39 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 40 | restore-keys: | 41 | ${{ runner.os }}-pnpm-store- 42 | 43 | - name: Install dependencies 44 | run: pnpm install 45 | 46 | - name: Check packages for common errors 47 | run: pnpm build 48 | 49 | - name: Bump version to canary 50 | run: node .github/canary-version.mjs 51 | 52 | - name: Authenticate to npm and publish 53 | run: | 54 | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc 55 | pnpm publish --access public --tag canary --no-git-checks 56 | 57 | - name: Create a new comment notifying of the new canary version 58 | uses: actions/github-script@v6 59 | with: 60 | github-token: ${{ secrets.GITHUB_TOKEN }} 61 | script: | 62 | // Get package version 63 | const fs = require("fs"); 64 | let text = 'A new canary is available for testing. You can install this latest build in your project with:\n\n```sh\n' 65 | 66 | const packageJson = JSON.parse(fs.readFileSync("package.json")); 67 | const version = packageJson.version; 68 | text += `pnpm add react-trello-ts@${version}` 69 | text += '\n```\n\n' 70 | text += `Current bundle size (minified + gzipped): ![NPM Bundle MIN Size](https://img.shields.io/bundlephobia/minzip/react-trello-ts.svg)\n\n` 71 | text += `New bundle size (minified + gzipped): ![NPM Bundle MIN Size](https://img.shields.io/bundlephobia/minzip/react-trello-ts@${version})\n\n` 72 | 73 | // Create a comment on the PR with the new canary version 74 | github.rest.issues.createComment({ 75 | owner: context.repo.owner, 76 | repo: context.repo.repo, 77 | issue_number: context.payload.pull_request.number, 78 | body: text, 79 | }) 80 | 81 | // Remove the label 82 | github.rest.issues.removeLabel({ 83 | owner: context.repo.owner, 84 | repo: context.repo.repo, 85 | issue_number: context.payload.pull_request.number, 86 | name: 'release canary', 87 | }); -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | 18 | - name: Install Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18 22 | 23 | - uses: pnpm/action-setup@v2 24 | name: Install pnpm 25 | id: pnpm-install 26 | with: 27 | version: 8 28 | run_install: false 29 | 30 | - name: Get pnpm store directory 31 | id: pnpm-cache 32 | shell: bash 33 | run: | 34 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 35 | 36 | - uses: actions/cache@v3 37 | name: Setup pnpm cache 38 | with: 39 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 40 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 41 | restore-keys: | 42 | ${{ runner.os }}-pnpm-store- 43 | 44 | - name: Install dependencies 45 | run: pnpm install 46 | 47 | - name: Create Release Pull Request or Publish to npm 48 | id: changesets 49 | uses: changesets/action@v1 50 | with: 51 | publish: pnpm run release 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .DS_Store 4 | .idea 5 | npm-debug.log 6 | dist/ 7 | .history/ 8 | storybook-build 9 | .npmrc 10 | coverage 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | src 4 | test 5 | examples 6 | coverage 7 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 2 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/react-vite"; 2 | 3 | export default ({ 4 | addons: ["@storybook/addon-essentials", "@chromatic-com/storybook"], 5 | framework: { 6 | name: "@storybook/react-vite", 7 | options: {}, 8 | }, 9 | stories: ["../stories/**/*.story.@(js|tsx|mdx)"], 10 | core: {}, 11 | docs: {}, 12 | typescript: { 13 | reactDocgen: "react-docgen-typescript", 14 | }, 15 | } satisfies StorybookConfig); 16 | -------------------------------------------------------------------------------- /.storybook/manager-head.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KaiSpencer/react-trello-ts/304d413fec4b327b68e4c0c89ccbd985fcf76971/.storybook/manager-head.html -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | tags: [], 3 | }; 4 | -------------------------------------------------------------------------------- /.storybook/test-runner.ts: -------------------------------------------------------------------------------- 1 | import type { TestRunnerConfig } from "@storybook/test-runner"; 2 | 3 | const config: TestRunnerConfig = { 4 | async postVisit(page, context) { 5 | // the #storybook-root element wraps the story. In Storybook 6.x, the selector is #root 6 | const elementHandler = await page.$("#storybook-root"); 7 | const innerHTML = await elementHandler?.innerHTML(); 8 | if (!innerHTML) { 9 | throw new Error("No innerHTML found"); 10 | } 11 | expect(innerHTML).toMatchSnapshot(); 12 | }, 13 | }; 14 | 15 | export default config; 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.defaultFormatter": "biomejs.biome", 4 | 5 | "editor.formatOnSave": true, 6 | "prettier.enable": false, 7 | "[typescriptreact]": { 8 | "editor.defaultFormatter": "biomejs.biome" 9 | }, 10 | "[typescript]": { 11 | "editor.defaultFormatter": "biomejs.biome" 12 | }, 13 | "[javascript]": { 14 | "editor.defaultFormatter": "biomejs.biome" 15 | }, 16 | "[json]": { 17 | "editor.defaultFormatter": "biomejs.biome" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # react-trello-ts 2 | 3 | ## 2.1.2 4 | 5 | ### Patch Changes 6 | 7 | - 7bd0dc4: Update dependencies 8 | 9 | ## 2.1.1 10 | 11 | ### Patch Changes 12 | 13 | - fcffa93: Update dependencies 14 | 15 | ## 2.1.0 16 | 17 | ### Minor Changes 18 | 19 | - 4320234: repo rework, no expected functional change. 20 | 21 | ## 2.0.16 22 | 23 | ### Patch Changes 24 | 25 | - dd24717: bug/ onCardUpdate should fire - #26 26 | 27 | ## 2.0.15 28 | 29 | ### Patch Changes 30 | 31 | - c90dd1d: Unused style prop in Lane.tsx 32 | 33 | ## 2.0.14 34 | 35 | ### Patch Changes 36 | 37 | - b38da98: Add changesets 38 | 39 | Linting fix to prefer template strings over concat 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 RC 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Trello TS 2 | 3 | 4 | [![npm version](https://badge.fury.io/js/react-trello-ts.svg)](https://badge.fury.io/js/react-trello-ts) 5 | [![NPM Bundle MIN Size](https://img.shields.io/bundlephobia/minzip/react-trello-ts.svg)](https://npmjs.com/react-trello-ts) 6 | 7 | [![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat-square)](#contributors-) 8 | 9 | 10 | 11 | Typescript fork of [rcdexta/react-trello](https://github.com/rcdexta/react-trello) 12 | 13 | Pluggable components to add a Trello (like) kanban board to your application 14 | 15 | > This library is not affiliated, associated, authorized, endorsed by or in any way officially connected to Trello, Inc. `Trello` is a registered trademark of Atlassian, Inc. 16 | 17 | ## react-trello compatability 18 | 19 | For a direct typescript implementation of the original react-trello, see the `v1` branch. 20 | 21 | No new features will be added to this branch, only bug fixes and commits inline with `react-trello` 22 | 23 | For a react-trello-ts latest features see the main branch. 24 | 25 | ### Releases 26 | 27 | The `v1` branch will be released as 1.x.x 28 | 29 | The main branch will be released as 2.x.x 30 | 31 | ## Getting Started 32 | 33 | Install using npm or yarn 34 | 35 | ```bash 36 | $ npm install --save react-trello-ts 37 | ``` 38 | 39 | or 40 | 41 | ```bash 42 | $ yarn add react-trello-ts 43 | ``` 44 | 45 | ## Documentation 46 | 47 | See the LINK_TO_DOCS_SITE for more information. 48 | 49 | Whilst the docs site is under development (see issue [#6](https://github.com/KaiSpencer/react-trello-ts/issues/6)), you can find the legacy docs for `v1` in the `react-trello-README.md` file. 50 | 51 | ## Contributors ✨ 52 | 53 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 |

Kai Spencer

💻 📖 🚧
63 | 64 | 65 | 66 | 67 | 68 | 69 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 70 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Upgrade instictions 2 | 3 | ## Upgrade from 2.1 to 2.2 4 | 5 | ### Texts 6 | 7 | Boths text properties are removed: `addLaneTitle` and `addCardLink`. 8 | Use translation function `t` to full control texts: 9 | 10 | | Legacy property | Key name in components object| 11 | | ------------------- | ---------------------------- | 12 | | addLaneTitle | "Add another lane" | 13 | | addCardLink | "Click to add card" | 14 | 15 | [Complete list of available translation keys](src/locales/en/translation.json) 16 | 17 | #### Migration example 18 | 19 | Instead of 20 | 21 | ```javascript 22 | 26 | ``` 27 | 28 | Use 29 | 30 | ```javascript 31 | 32 | import { createTranslate } from 'react-trello' 33 | 34 | const TEXTS = { 35 | "Add another lane": "NEW LANE", 36 | "Click to add card": "Click to add card", 37 | "Delete lane": "Delete lane", 38 | "Lane actions": "Lane actions", 39 | "button": { 40 | "Add lane": "Add lane", 41 | "Add card": "ADD CARD", 42 | "Cancel": "Cancel" 43 | }, 44 | "placeholder": { 45 | "title": "title", 46 | "description": "description", 47 | "label": "label" 48 | } 49 | } 50 | 51 | ``` 52 | 53 | ### Components customization 54 | 55 | These properties are removed: `addCardLink`, `customLaneHeader`, `newCardTemplate`, `newLaneTemplate` 56 | and `customCardLayout` with `children` element. 57 | 58 | You must use `components` property, that contains map of custom 59 | `React.Component`'s (not elements/templates) 60 | 61 | | Legacy property | Key name in components map| 62 | | ------------------- | ---------------------------- | 63 | | addCardLink | AddCardLink | 64 | | customLaneHeader | LaneHeader | 65 | | newCardTemplate | NewCardForm | 66 | | newLaneTemplate | NewLaneSection | 67 | | customCardLayout (children) | Card | 68 | 69 | Full list of available components keys - 70 | [src/components/index.js](src/components/index.js) 71 | 72 | #### Migration example 73 | 74 | Instead of 75 | 76 | ```javascript 77 | New Card} 79 | customLaneHeader={} 80 | newCardTemplate ={} 81 | newLaneTemplate ={} 82 | customCardLayout 83 | > 84 | 85 | 86 | 87 | ``` 88 | 89 | Use 90 | 91 | ```javascript 92 | const components = { 93 | AddCardLink: () => , 94 | LaneHeader: CustomLaneHeader, 95 | NewCardForm: NewCard, 96 | NewLaneSection: NewLane, 97 | Card: CustomCard 98 | }; 99 | 100 | ``` 101 | 102 | That's all. Have a nice day! ) 103 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.0.0/schema.json", 3 | "linter": { 4 | "enabled": true, 5 | "rules": { 6 | "recommended": true, 7 | "suspicious": { 8 | "noExplicitAny": "off" 9 | }, 10 | "a11y": { "useButtonType": "off" } 11 | }, 12 | 13 | "ignore": [ 14 | "coverage", 15 | "node_modules", 16 | "*.story.tsx", 17 | "dist", 18 | "src/helpers/autosize.ts" 19 | ] 20 | }, 21 | "formatter": { 22 | "enabled": true, 23 | "formatWithErrors": true, 24 | "ignore": ["node_modules", "dist"] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-trello-ts", 3 | "version": "2.1.2", 4 | "description": "Pluggable typesafe components to add a trello like kanban board to your application", 5 | "main": "./dist/index.mjs", 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "./dist/index.mjs", 10 | "require": "./dist/index.cjs" 11 | } 12 | }, 13 | "types": "./dist/index.d.ts", 14 | "files": [ 15 | "dist", 16 | "README" 17 | ], 18 | "engines": { 19 | "node": ">=18" 20 | }, 21 | "scripts": { 22 | "prepublish": "pnpm build", 23 | "storybook": "storybook dev -p 6006", 24 | "storybook:no-open": "pnpm storybook --no-open", 25 | "test": "start-server-and-test storybook:no-open http://localhost:6006 test-storybook", 26 | "test-storybook": "test-storybook", 27 | "build": "unbuild", 28 | "docs": "storybook build -o docs", 29 | "build-storybook": "storybook build -o storybook-build", 30 | "lint": "pnpm biome check .", 31 | "release": "pnpm build && pnpm changeset publish", 32 | "changeset": "changeset" 33 | }, 34 | "repository": { 35 | "type": "git", 36 | "url": "https://github.com/KaiSpencer/react-trello-ts" 37 | }, 38 | "keywords": [ 39 | "react", 40 | "trello", 41 | "board", 42 | "typescript" 43 | ], 44 | "author": "Kai Spencer", 45 | "license": "MIT", 46 | "bugs": { 47 | "url": "https://github.com/KaiSpencer/react-trello-ts/issues" 48 | }, 49 | "homepage": "https://github.com/KaiSpencer/react-trello-ts", 50 | "dependencies": { 51 | "react-popopo": "^2.1.9", 52 | "react-trello-ts-smooth-dnd": "^0.0.3", 53 | "uuid": "^11.1.0", 54 | "zustand": "^5.0.3" 55 | }, 56 | "devDependencies": { 57 | "@arethetypeswrong/cli": "^0.15.3", 58 | "@biomejs/biome": "1.4.1", 59 | "@changesets/cli": "^2.27.5", 60 | "@chromatic-com/storybook": "^1.5.0", 61 | "@microsoft/api-extractor": "^7.47.0", 62 | "@storybook/addon-essentials": "^8.6.4", 63 | "@storybook/cli": "^8.6.4", 64 | "@storybook/core-events": "^8.6.4", 65 | "@storybook/react": "^8.6.4", 66 | "@storybook/react-vite": "^8.6.4", 67 | "@storybook/test-runner": "^0.22.0", 68 | "@types/node": "^18.19.39", 69 | "@types/react": "^16.14.60", 70 | "@types/react-dom": "^16.9.24", 71 | "@types/styled-components": "^5.1.34", 72 | "@types/uuid": "^10", 73 | "i18next": "^17.3.1", 74 | "playwright": "^1.51.0", 75 | "publint": "^0.3.8", 76 | "react": "^18.3.1", 77 | "react-dom": "^18.3.1", 78 | "react-i18next": "^10.13.2", 79 | "start-server-and-test": "^2.0.4", 80 | "storybook": "^8.6.4", 81 | "styled-components": "^5.3.11", 82 | "typescript": "^5.8.2", 83 | "unbuild": "3.5.0" 84 | }, 85 | "peerDependencies": { 86 | "react": ">=16.8.0 <=18", 87 | "react-dom": ">=16.8.0 <=18", 88 | "styled-components": ">= 4.0.3" 89 | }, 90 | "packageManager": "pnpm@10.6.0", 91 | "pnpm": { 92 | "onlyBuiltDependencies": [ 93 | "@swc/core", 94 | "esbuild" 95 | ] 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /react-trello.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KaiSpencer/react-trello-ts/304d413fec4b327b68e4c0c89ccbd985fcf76971/react-trello.gif -------------------------------------------------------------------------------- /src/components/AddCardLink.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, HTMLAttributes, PropsWithChildren } from "react"; 2 | import { createTranslate } from ".."; 3 | import { AddCardLink as _AddCardLink } from "../styles/Base"; 4 | 5 | /** 6 | * AddCardLink component type 7 | * 8 | * Pass in a type to the optional generic to add custom properties 9 | * 10 | * @example 11 | * 12 | * type Props = { 13 | * customProperty: string; 14 | * } 15 | * 16 | * const CustomAddCardLink: AddCardLinkComponent = ({ customProperty, ...props }) => { 17 | * return ( 18 | * ... 19 | * {customProperty} 20 | * ... 21 | * ) 22 | * } 23 | */ 24 | export type AddCardLinkComponent = 25 | FC>; 26 | 27 | interface AddCardLinkProps 28 | extends HTMLAttributes { 29 | t: typeof createTranslate; 30 | } 31 | export const AddCardLink: FC> = ({ 32 | onClick, 33 | t, 34 | }) => <_AddCardLink onClick={onClick}>{t("Click to add card")}; 35 | -------------------------------------------------------------------------------- /src/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, FC, PropsWithChildren } from "react"; 2 | import { StyledComponent } from "styled-components"; 3 | import { createTranslate } from ".."; 4 | import { 5 | CardHeader, 6 | CardRightContent, 7 | CardTitle, 8 | Detail, 9 | Footer, 10 | MovableCardWrapper, 11 | } from "../styles/Base"; 12 | import { Card as ICard } from "../types/Board"; 13 | import { InlineInput } from "../widgets"; 14 | import { DeleteButton } from "../widgets"; 15 | import { Tag, TagProps } from "./Card/Tag"; 16 | 17 | /** 18 | * Card component type 19 | * 20 | * Pass in a type to the optional generic to add custom properties to the card 21 | * 22 | * @example 23 | * 24 | * type CustomCardProps = { 25 | * dueOn: string; 26 | * } 27 | * 28 | * const CustomCard: CardComponent = ({ dueOn, ...props }) => { 29 | * return ( 30 | * 31 | * {dueOn} 32 | * 33 | * ) 34 | * } 35 | */ 36 | export type CardComponent = FC< 37 | PropsWithChildren 38 | >; 39 | 40 | export type CardProps = { 41 | showDeleteButton?: boolean; 42 | onDelete?: () => void; 43 | onClick?: (e) => void; 44 | onChange?: (card: ICard) => void; 45 | style?: CSSProperties; 46 | tagStyle?: CSSProperties; 47 | className?: string; 48 | id: string; 49 | index: number; 50 | title?: string; 51 | label?: string; 52 | description?: string; 53 | tags?: TagProps[]; 54 | cardDraggable?: boolean; 55 | editable?: boolean; 56 | metadata?: Record; 57 | t: typeof createTranslate; 58 | }; 59 | 60 | export const Card: CardComponent = ({ 61 | onDelete, 62 | onChange, 63 | id, 64 | onClick, 65 | style, 66 | className, 67 | description, 68 | label, 69 | t, 70 | tags, 71 | title, 72 | cardDraggable, 73 | editable, 74 | showDeleteButton, 75 | tagStyle, 76 | }) => { 77 | const _onDelete = ( 78 | e: 79 | | React.MouseEvent 80 | | React.MouseEvent>, 81 | ) => { 82 | onDelete(); 83 | e.stopPropagation(); 84 | }; 85 | const updateCard = (card: Partial) => { 86 | onChange({ ...card, id }); 87 | }; 88 | 89 | return ( 90 | 96 | 97 | 98 | {editable ? ( 99 | updateCard({ title: value })} 105 | /> 106 | ) : ( 107 | title 108 | )} 109 | 110 | 111 | {editable ? ( 112 | updateCard({ label: value })} 118 | /> 119 | ) : ( 120 | label 121 | )} 122 | 123 | {showDeleteButton && } 124 | 125 | 126 | {editable ? ( 127 | 133 | updateCard({ description: value }) 134 | } 135 | /> 136 | ) : ( 137 | description 138 | )} 139 | 140 | {tags && tags.length > 0 && ( 141 |
142 | {tags.map((tag) => ( 143 | 144 | ))} 145 |
146 | )} 147 |
148 | ); 149 | }; 150 | -------------------------------------------------------------------------------- /src/components/Card/Tag.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, FC, PropsWithChildren } from "react"; 2 | import { TagSpan } from "../../styles/Base"; 3 | 4 | export interface TagProps { 5 | title: string; 6 | color?: string; 7 | bgcolor?: string; 8 | tagStyle?: CSSProperties; 9 | } 10 | export const Tag: FC> = ({ 11 | title, 12 | color, 13 | bgcolor, 14 | tagStyle, 15 | ...otherProps 16 | }) => { 17 | const style = { 18 | color: color || "white", 19 | backgroundColor: bgcolor || "orange", 20 | ...tagStyle, 21 | }; 22 | return ( 23 | 24 | {title} 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/Lane/LaneFooter.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, HTMLAttributes, PropsWithChildren } from "react"; 2 | 3 | import { LaneFooter as _LaneFooter } from "../../styles/Base"; 4 | import { CollapseBtn, ExpandBtn } from "../../styles/Elements"; 5 | 6 | export type LaneFooterComponent = FC>; 7 | 8 | interface LaneFooterProps extends HTMLAttributes { 9 | collapsed?: boolean; 10 | } 11 | export const LaneFooter: LaneFooterComponent = ({ 12 | onClick, 13 | onKeyDown, 14 | collapsed, 15 | }) => ( 16 | <_LaneFooter onClick={onClick} onKeyDown={onKeyDown}> 17 | {collapsed ? : } 18 | 19 | ); 20 | -------------------------------------------------------------------------------- /src/components/Lane/LaneHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | CSSProperties, 3 | FC, 4 | HTMLAttributes, 5 | PropsWithChildren, 6 | } from "react"; 7 | import createTranslate from "../../helpers/createTranslate"; 8 | import { 9 | LaneHeader as _LaneHeader, 10 | RightContent, 11 | Title, 12 | } from "../../styles/Base"; 13 | import { InlineInput } from "../../widgets/InlineInput"; 14 | import { LaneMenu } from "./LaneHeader/LaneMenu"; 15 | 16 | export type LaneHeaderProps = _LaneHeaderProps & { 17 | [key: string]: any; 18 | }; 19 | interface _LaneHeaderProps extends HTMLAttributes { 20 | updateTitle?: (title: string) => void; 21 | canAddLanes?: boolean; 22 | onDelete?: () => void; 23 | editLaneTitle?: boolean; 24 | label?: string; 25 | titleStyle?: CSSProperties; 26 | labelStyle?: CSSProperties; 27 | laneDraggable?: boolean; 28 | t: typeof createTranslate; 29 | } 30 | export const LaneHeader: FC> = ({ 31 | updateTitle = () => {}, 32 | canAddLanes = false, 33 | onDelete, 34 | onDoubleClick, 35 | editLaneTitle, 36 | label, 37 | title, 38 | titleStyle, 39 | labelStyle, 40 | t, 41 | laneDraggable, 42 | }) => { 43 | return ( 44 | <_LaneHeader onDoubleClick={onDoubleClick} editLaneTitle={editLaneTitle}> 45 | 46 | {editLaneTitle ? ( 47 | <InlineInput 48 | value={title} 49 | border={true} 50 | placeholder={t("placeholder.title") as unknown as string} 51 | resize="vertical" 52 | onSave={updateTitle} 53 | /> 54 | ) : ( 55 | title 56 | )} 57 | 58 | {label && ( 59 | 60 | {label} 61 | 62 | )} 63 | {canAddLanes && } 64 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/components/Lane/LaneHeader/LaneMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, PropsWithChildren } from "react"; 2 | import { Popover } from "react-popopo"; 3 | 4 | import createTranslate from "../../../helpers/createTranslate"; 5 | import { 6 | CustomPopoverContainer, 7 | CustomPopoverContent, 8 | } from "../../../styles/Base"; 9 | import { 10 | DeleteWrapper, 11 | GenDelButton, 12 | LaneMenuContent, 13 | LaneMenuHeader, 14 | LaneMenuItem, 15 | LaneMenuTitle, 16 | MenuButton, 17 | } from "../../../styles/Elements"; 18 | 19 | interface LaneMenuProps { 20 | t: typeof createTranslate; 21 | onDelete: () => void; 22 | } 23 | export const LaneMenu: FC> = ({ 24 | t, 25 | onDelete, 26 | }) => ( 27 | ⋮} 32 | > 33 | 34 | {t("Lane actions")} 35 | 36 | 37 | 38 | 39 | 40 | {t("Delete lane")} 41 | 42 | 43 | ); 44 | -------------------------------------------------------------------------------- /src/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { LoaderDiv, LoadingBar } from "../styles/Loader"; 3 | 4 | export const Loader = () => ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/components/NewCardForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, FC, PropsWithChildren, useState } from "react"; 2 | import createTranslate from "../helpers/createTranslate"; 3 | import { 4 | CardForm, 5 | CardHeader, 6 | CardRightContent, 7 | CardTitle, 8 | CardWrapper, 9 | Detail, 10 | } from "../styles/Base"; 11 | import { AddButton, CancelButton } from "../styles/Elements"; 12 | import { EditableLabel } from "../widgets/EditableLabel"; 13 | 14 | export interface FormState { 15 | title: string; 16 | description: string; 17 | label: string; 18 | laneId: string; 19 | } 20 | 21 | export interface NewCardFormProps { 22 | laneId: string; 23 | onCancel: () => void; 24 | onAdd: (formState: FormState) => void; 25 | t: typeof createTranslate; 26 | } 27 | export const NewCardForm: FC = ({ 28 | laneId, 29 | onCancel, 30 | onAdd, 31 | t, 32 | }) => { 33 | const [formState, setFormState] = useState(); 34 | 35 | const handleAdd = () => { 36 | onAdd({ ...formState, laneId }); 37 | }; 38 | 39 | const updateField = ( 40 | field: K, 41 | value: FormState[K], 42 | ) => { 43 | setFormState({ ...formState, [field]: value }); 44 | }; 45 | 46 | return ( 47 | 48 | 49 | 50 | 51 | updateField("title", val)} 54 | autoFocus={true} 55 | /> 56 | 57 | 58 | updateField("label", val)} 61 | /> 62 | 63 | 64 | 65 | 68 | updateField("description", val) 69 | } 70 | /> 71 | 72 | 73 | {t("button.Add card")} 74 | {t("button.Cancel")} 75 | 76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/components/NewLaneForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FC, 3 | HTMLAttributes, 4 | PropsWithChildren, 5 | useRef, 6 | useState, 7 | } from "react"; 8 | import { ThemedStyledFunction } from "styled-components"; 9 | import { v1 } from "uuid"; 10 | import createTranslate from "../helpers/createTranslate"; 11 | import { LaneTitle, NewLaneButtons, Section } from "../styles/Base"; 12 | import { AddButton, CancelButton } from "../styles/Elements"; 13 | import { NewLaneTitleEditor } from "../widgets/NewLaneTitleEditor"; 14 | 15 | interface NewLaneFormProps 16 | extends HTMLAttributes< 17 | ThemedStyledFunction<"section", object, object, never> 18 | > { 19 | onCancel: () => void; 20 | onAdd: ({ id, title }: { id: string; title: string }) => void; 21 | t: typeof createTranslate; 22 | } 23 | export const NewLaneForm: FC> = ({ 24 | onAdd, 25 | onCancel, 26 | t, 27 | }) => { 28 | const titleRef = useRef(); 29 | const handleSubmit = () => { 30 | onAdd({ 31 | id: v1(), 32 | title: titleRef.current.value, 33 | }); 34 | }; 35 | 36 | return ( 37 |
38 | 39 | 48 | 49 | 50 | {t("button.Add lane")} 51 | {t("button.Cancel")} 52 | 53 |
54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/components/NewLaneSection.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, PropsWithChildren } from "react"; 2 | import { createTranslate } from ".."; 3 | import { NewLaneSection as _NewLaneSection } from "../styles/Base"; 4 | import { AddLaneLink } from "../styles/Elements"; 5 | 6 | export const NewLaneSection: FC< 7 | PropsWithChildren<{ t: typeof createTranslate; onClick: () => void }> 8 | > = ({ t, onClick }) => ( 9 | <_NewLaneSection> 10 | {t("Add another lane")} 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BoardWrapper, 3 | GlobalStyle, 4 | ScrollableLane, 5 | Section, 6 | } from "../styles/Base"; 7 | import { AddCardLink } from "./AddCardLink"; 8 | import { Card } from "./Card"; 9 | import { LaneFooter } from "./Lane/LaneFooter"; 10 | import { LaneHeader } from "./Lane/LaneHeader"; 11 | import { Loader } from "./Loader"; 12 | import { NewCardForm } from "./NewCardForm"; 13 | import { NewLaneForm } from "./NewLaneForm"; 14 | import { NewLaneSection } from "./NewLaneSection"; 15 | 16 | export { 17 | GlobalStyle, 18 | BoardWrapper, 19 | Loader, 20 | ScrollableLane, 21 | LaneHeader, 22 | LaneFooter, 23 | Section, 24 | NewLaneForm, 25 | NewLaneSection, 26 | NewCardForm, 27 | Card, 28 | AddCardLink, 29 | }; 30 | -------------------------------------------------------------------------------- /src/controllers/Board.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, PropsWithChildren, createContext } from "react"; 2 | import { v1 } from "uuid"; 3 | import * as DefaultComponets from "../components"; 4 | import { store } from "../store/store"; 5 | import { BoardData } from "../types/Board"; 6 | import { BoardContainer } from "./BoardContainer"; 7 | 8 | export const Board: FC< 9 | PropsWithChildren<{ 10 | id?: string; 11 | className?: string; 12 | components: typeof DefaultComponets; 13 | data: BoardData; 14 | t?: any; 15 | }> 16 | > = ({ data, children, className, components, id, t, ...rest }) => { 17 | const allClassNames = className 18 | ? `react-trello-board ${className}` 19 | : "react-trello-board"; 20 | 21 | return ( 22 | 23 | {/* 24 | // @ts-ignore */} 25 | 26 | 34 | 35 | ); 36 | }; 37 | 38 | export const BoardContext = createContext(store); 39 | -------------------------------------------------------------------------------- /src/controllers/BoardContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | CSSProperties, 3 | FC, 4 | PropsWithChildren, 5 | useEffect, 6 | useState, 7 | } from "react"; 8 | import { PopoverWrapper } from "react-popopo"; 9 | import Container from "../dnd/Container"; 10 | import { Draggable } from "../dnd/Draggable"; 11 | import { Lane } from "./Lane"; 12 | 13 | import { createTranslate } from ".."; 14 | import { FormState as NewCardFormState } from "../components/NewCardForm"; 15 | import { useBoard } from "../store/useBoard"; 16 | import { BoardData, Card, Lane as ILane } from "../types/Board"; 17 | import { EventBusHandle } from "../types/EventBus"; 18 | import * as DefaultComponents from "./../components"; 19 | export interface BoardContainerProps { 20 | id?: string; 21 | components?: Partial; 22 | className?: string; 23 | data: BoardData; 24 | reducerData?: BoardData; 25 | onDataChange?: (reducerData: BoardData) => void; 26 | eventBusHandle?: (handle: EventBusHandle) => void; 27 | onLaneScroll?: (requestedPage: any, laneId: any) => Promise; 28 | onCardClick?: ( 29 | cardId: Card["id"], 30 | metadata: { id: string }, 31 | card: Card, 32 | ) => void; 33 | onBeforeCardDelete?: () => void; 34 | onCardDelete?: (cardId: string, laneId: string) => void; 35 | onCardAdd?: (card: Card, laneId: string) => void; 36 | onCardUpdate?: (cardId: string, data: Card) => void; 37 | onLaneAdd?: (laneAddParams: NewCardFormState) => void; 38 | onLaneDelete?: () => void; 39 | onLaneClick?: (laneId: string) => void; 40 | onLaneUpdate?: (laneId: string, data: ILane) => void; 41 | laneSortFunction?: (cardA: Card, cardB: Card) => number; 42 | draggable?: boolean; 43 | collapsibleLanes?: boolean; 44 | editable?: boolean; 45 | canAddLanes?: boolean; 46 | hideCardDeleteAction?: boolean; 47 | hideCardDeleteIcon?: boolean; 48 | handleDragStart?: (cardId: string, laneId: string) => void; 49 | handleDragEnd?: ( 50 | cardId: Card["id"], 51 | sourceLandId: ILane["id"], 52 | targetLaneId: ILane["id"], 53 | position: number, 54 | card: Card, 55 | ) => void; 56 | handleLaneDragStart?: (payloadId: string) => void; 57 | handleLaneDragEnd?: ( 58 | removedIndex: string, 59 | addedIndex: string, 60 | payload: ILane, 61 | ) => void; 62 | style?: CSSProperties; 63 | tagStyle?: CSSProperties; 64 | laneStyle?: CSSProperties; 65 | cardStyle?: CSSProperties; 66 | laneDraggable?: boolean; 67 | cardDraggable?: boolean; 68 | cardDragClass?: string; 69 | laneDragClass?: string; 70 | laneDropClass?: string; 71 | editLaneTitle?: boolean; 72 | onCardMoveAcrossLanes?: ( 73 | fromLaneId: string, 74 | toLaneId: string, 75 | cardId: string, 76 | addedIndex: string, 77 | ) => void; 78 | t?: typeof createTranslate; 79 | } 80 | 81 | export const BoardContainer: FC> = ({ 82 | components, 83 | handleDragStart = () => {}, 84 | handleDragEnd = () => {}, 85 | handleLaneDragStart = () => {}, 86 | className, 87 | style, 88 | id, 89 | draggable = false, 90 | laneDraggable = true, 91 | data, 92 | cardDragClass = "react_trello_dragClass", 93 | collapsibleLanes = false, 94 | laneDragClass = "react_trello_dragLaneClass", 95 | laneDropClass = "", 96 | onDataChange = () => {}, 97 | cardDraggable = true, 98 | onCardAdd, 99 | onCardUpdate = () => {}, 100 | onCardClick, 101 | onBeforeCardDelete, 102 | handleLaneDragEnd = () => {}, 103 | onCardDelete, 104 | cardStyle, 105 | hideCardDeleteIcon = false, 106 | onLaneScroll, 107 | onLaneClick, 108 | onLaneAdd = () => {}, 109 | onLaneDelete = () => {}, 110 | onLaneUpdate = () => {}, 111 | editable = false, 112 | canAddLanes = false, 113 | eventBusHandle, 114 | tagStyle, 115 | editLaneTitle, 116 | laneStyle, 117 | laneSortFunction, 118 | onCardMoveAcrossLanes = () => {}, 119 | t, 120 | ...otherProps 121 | }) => { 122 | const [addLaneMode, setAddLaneMode] = useState(false); 123 | const board = useBoard(); 124 | // biome-ignore lint/correctness/useExhaustiveDependencies: 125 | useEffect(() => { 126 | board.initializeLanes(data.lanes); 127 | if (eventBusHandle) { 128 | wireEventBus(); 129 | } 130 | return () => { 131 | board.refreshBoard([]); 132 | }; 133 | }, []); 134 | 135 | // biome-ignore lint/correctness/useExhaustiveDependencies: 136 | useEffect(() => { 137 | if (JSON.stringify(board.data) !== JSON.stringify(data)) { 138 | onDataChange(data); 139 | board.initializeLanes(data.lanes); 140 | } 141 | }, [data]); 142 | 143 | const wireEventBus = () => { 144 | const eventBus: EventBusHandle = { 145 | publish: (event) => { 146 | switch (event.type) { 147 | case "ADD_CARD": 148 | return board.addCard(event.card, event.laneId, event.index); 149 | case "UPDATE_CARD": 150 | return board.updateCard(event.laneId, event.card); 151 | case "REMOVE_CARD": 152 | return board.removeCard(event.laneId, event.cardId); 153 | case "REFRESH_BOARD": 154 | return board.refreshBoard(event.data.lanes); 155 | case "MOVE_CARD": 156 | return board.moveCard( 157 | event.fromLaneId, 158 | event.toLaneId, 159 | event.cardId, 160 | event.index, 161 | ); 162 | case "UPDATE_CARDS": 163 | return board.updateCards(event.laneId, event.cards); 164 | case "UPDATE_LANES": 165 | return board.updateLanes(event.lanes); 166 | case "UPDATE_LANE": 167 | return board.updateLane(event.lane); 168 | } 169 | }, 170 | }; 171 | eventBusHandle(eventBus); 172 | }; 173 | const getLaneDetails = (index) => { 174 | return board.data.lanes[index]; 175 | }; 176 | const getCardDetails = (laneId, cardIndex) => { 177 | return board.data.lanes.find((lane) => lane.id === laneId).cards[cardIndex]; 178 | }; 179 | const groupName = `TrelloBoard${id}`; 180 | return ( 181 | 182 | 183 | { 187 | handleLaneDragStart(payload.id); 188 | }} 189 | dragClass={laneDragClass} 190 | dropClass={laneDropClass} 191 | onDrop={({ removedIndex, addedIndex, payload }) => { 192 | if (removedIndex !== addedIndex) { 193 | board.moveLane(removedIndex, addedIndex); 194 | handleLaneDragEnd(removedIndex, addedIndex, payload); 195 | } 196 | }} 197 | lockAxis="x" 198 | getChildPayload={(index) => getLaneDetails(index)} 199 | groupName={groupName} 200 | > 201 | {board.data.lanes.map((lane, index) => { 202 | const { id, droppable, ...otherProps } = lane; 203 | 204 | const laneToRender = ( 205 | 241 | ); 242 | return draggable && laneDraggable ? ( 243 | {laneToRender} 244 | ) : ( 245 | laneToRender 246 | ); 247 | })} 248 | 249 | 250 | {canAddLanes && ( 251 | 252 | {editable && !addLaneMode ? ( 253 | setAddLaneMode(true)} 256 | /> 257 | ) : ( 258 | addLaneMode && ( 259 | setAddLaneMode(false)} 261 | onAdd={({ id, title }) => { 262 | setAddLaneMode(false); 263 | board.addLane({ id, title, cards: [] }); 264 | onLaneAdd({ 265 | laneId: id, 266 | title, 267 | description: "", 268 | label: "", 269 | }); 270 | }} 271 | t={t} 272 | /> 273 | ) 274 | )} 275 | 276 | )} 277 | 278 | ); 279 | }; 280 | -------------------------------------------------------------------------------- /src/controllers/Lane.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, FC, PropsWithChildren, useEffect } from "react"; 2 | import { v1 } from "uuid"; 3 | 4 | import { components, createTranslate } from ".."; 5 | import Container from "../dnd/Container"; 6 | import { Draggable } from "../dnd/Draggable"; 7 | import { useBoard } from "../store/useBoard"; 8 | import { Card, Lane as ILane } from "../types/Board"; 9 | 10 | interface LaneProps { 11 | id: string; 12 | index?: number; 13 | boardId?: string; 14 | title?: string; 15 | label?: string; 16 | cards?: any[]; 17 | style?: any; 18 | collapsibleLanes?: boolean; 19 | className?: string; 20 | titleStyle?: any; 21 | titleClassName?: string; 22 | labelStyle?: any; 23 | labelClassName?: string; 24 | cardStyle?: any; 25 | cardClassName?: string; 26 | currentPage?: number; 27 | draggable?: boolean; 28 | droppable?: boolean; 29 | editable?: boolean; 30 | canAddLanes?: boolean; 31 | laneSortFunction?: (cardA: Card, cardB: Card) => number; 32 | hideCardDeleteIcon?: boolean; 33 | cardDraggable?: boolean; 34 | cardDragClass?: string; 35 | cardDropClass?: string; 36 | tagStyle?: CSSProperties; 37 | components?: Partial; 38 | onLaneScroll?: (page: number, laneId: string) => Promise; 39 | onLaneAdd?: (params: any) => void; 40 | onLaneDelete?: (laneId: string) => void; 41 | onLaneUpdate?: (laneId: string, data: ILane) => void; 42 | onCardClick?: (cardId: string, metadata: { id: string }, card: Card) => void; 43 | onCardAdd?: (card: any, laneId: string) => void; 44 | onCardDelete?: (cardId: string, laneId: string) => void; 45 | onCardUpdate?: (laneId: string, card: any) => void; 46 | onBeforeCardDelete?: (callback: () => void) => void; 47 | onCardMoveAcrossLanes?: ( 48 | fromLaneId: string, 49 | toLaneId: string, 50 | cardId: string, 51 | index: string, 52 | ) => void; 53 | onLaneClick?: (laneId: string) => void; 54 | handleDragStart?: (cardId: string, laneId: string) => void; 55 | handleDragEnd?: ( 56 | cardId: string, 57 | payloadLaneId: string, 58 | laneId: string, 59 | addedIndex: number, 60 | newCard: Card, 61 | ) => void; 62 | getCardDetails?: (laneId: string, cardIndex: number) => any; 63 | t?: typeof createTranslate; 64 | } 65 | export const Lane: FC> = ({ 66 | id, 67 | index, 68 | boardId, 69 | title, 70 | label, 71 | cards, 72 | style, 73 | collapsibleLanes, 74 | className, 75 | titleStyle, 76 | titleClassName, 77 | labelStyle, 78 | labelClassName, 79 | cardStyle, 80 | cardClassName, 81 | currentPage, 82 | draggable, 83 | droppable, 84 | editable, 85 | canAddLanes, 86 | laneSortFunction, 87 | hideCardDeleteIcon, 88 | cardDraggable, 89 | cardDragClass, 90 | cardDropClass, 91 | tagStyle, 92 | components, 93 | onLaneScroll, 94 | onLaneAdd, 95 | onLaneDelete, 96 | onLaneUpdate, 97 | onCardClick, 98 | onCardAdd, 99 | onCardDelete, 100 | onCardUpdate, 101 | onBeforeCardDelete, 102 | onCardMoveAcrossLanes, 103 | onLaneClick, 104 | handleDragStart, 105 | handleDragEnd, 106 | getCardDetails, 107 | t, 108 | children, 109 | ...otherProps 110 | }) => { 111 | const board = useBoard(); 112 | const [loading, setLoading] = React.useState(false); 113 | const [currentPageState, setCurrentPageState] = React.useState(currentPage); 114 | const [collapsed, setCollapsed] = React.useState(false); 115 | const [addCardMode, setAddCardMode] = React.useState(false); 116 | const [isDraggingOver, setIsDraggingOver] = React.useState(false); 117 | 118 | // biome-ignore lint/correctness/useExhaustiveDependencies: 119 | useEffect(() => { 120 | setCurrentPageState(currentPage); 121 | }, [cards]); 122 | 123 | const sortCards = (cards: Card[], sortFunction: typeof laneSortFunction) => { 124 | if (!cards) { 125 | return []; 126 | } 127 | if (!sortFunction) { 128 | return cards; 129 | } 130 | return cards.concat().sort((card1, card2) => sortFunction(card1, card2)); 131 | }; 132 | const addNewCard = (params: { 133 | title?: string; 134 | laneId?: string; 135 | description?: string; 136 | label?: string; 137 | }) => { 138 | const laneId = params.laneId || id; 139 | const _id = v1(); 140 | setAddCardMode(false); 141 | const card = { id: _id, ...params }; 142 | board.addCard(card, laneId); 143 | onCardAdd?.(card, laneId); 144 | }; 145 | const updateCard = (updatedCard: Card) => { 146 | board.updateCard(id, updatedCard); 147 | onCardUpdate?.(id, updatedCard); 148 | }; 149 | const updateTitle = (value) => { 150 | board.updateLane({ id, title: value }); 151 | onLaneUpdate?.(id, { id, title: value }); 152 | }; 153 | const laneDidMount = (node: HTMLDivElement) => { 154 | if (node) { 155 | node.addEventListener("scroll", handleScroll); 156 | } 157 | }; 158 | const removeLane = () => { 159 | board.removeLane(id); 160 | onLaneDelete?.(id); 161 | }; 162 | const handleScroll = (evt) => { 163 | const node = evt.target; 164 | const elemScrollPosition = 165 | node.scrollHeight - node.scrollTop - node.clientHeight; 166 | // In some browsers and/or screen sizes a decimal rest value between 0 and 1 exists, so it should be checked on < 1 instead of < 0 167 | if (elemScrollPosition < 1 && onLaneScroll && !loading) { 168 | setLoading(true); 169 | const nextPage = currentPageState + 1; 170 | onLaneScroll(nextPage, id).then((moreCards: Card[]) => { 171 | if ((moreCards || []).length > 0) { 172 | board.paginateLane(id, moreCards, nextPage); 173 | } 174 | setLoading(false); 175 | }); 176 | } 177 | }; 178 | const groupName = `TrelloBoard${boardId}Lane`; 179 | const handleCardClick = (e, card) => { 180 | onCardClick?.(card.id, card.metadata, card.laneId); 181 | e.stopPropagation(); 182 | }; 183 | const toggleLaneCollapsed = () => { 184 | collapsibleLanes && setCollapsed(!collapsed); 185 | }; 186 | const removeCard = (cardId: Card["id"]) => { 187 | if (onBeforeCardDelete && typeof onBeforeCardDelete === "function") { 188 | onBeforeCardDelete(() => { 189 | board.removeCard(id, cardId); 190 | onCardDelete?.(cardId, id); 191 | }); 192 | } else { 193 | board.removeCard(id, cardId); 194 | onCardDelete?.(cardId, id); 195 | } 196 | }; 197 | const onDragStart = ({ payload }) => { 198 | handleDragStart?.(payload.id, id); 199 | }; 200 | const onDragEnd = ( 201 | laneId: string, 202 | result: { addedIndex: any; payload: any }, 203 | ) => { 204 | const { addedIndex, payload } = result; 205 | 206 | if (isDraggingOver) { 207 | setIsDraggingOver(false); 208 | } 209 | 210 | if (addedIndex != null) { 211 | const newCard = { ...payload, laneId }; 212 | handleDragEnd?.(payload.id, laneId, id, addedIndex, newCard); 213 | 214 | const response = handleDragEnd 215 | ? handleDragEnd(payload.id, payload.laneId, laneId, addedIndex, newCard) 216 | : true; 217 | if (response === undefined || !!response) { 218 | board.moveCard(payload.laneId, laneId, payload.id, addedIndex); 219 | onCardMoveAcrossLanes?.(payload.laneId, laneId, payload.id, addedIndex); 220 | } 221 | return response; 222 | } 223 | }; 224 | 225 | const renderDragContainer = (isDraggingOver: boolean) => { 226 | const showableCards = collapsed ? [] : cards; 227 | const cardList = sortCards(showableCards, laneSortFunction).map( 228 | (card, idx) => { 229 | const onDeleteCard = () => removeCard(card.id); 230 | const cardToRender = ( 231 | handleCardClick(e, card)} 238 | onChange={(updatedCard) => updateCard(updatedCard)} 239 | showDeleteButton={!hideCardDeleteIcon} 240 | tagStyle={tagStyle} 241 | cardDraggable={cardDraggable} 242 | editable={editable} 243 | title={card.title} 244 | description={card.description} 245 | label={card.label} 246 | metadata={card.metadata} 247 | id={card.id} 248 | t={t} 249 | {...card} 250 | /> 251 | ); 252 | return cardDraggable && 253 | (!Object.prototype.hasOwnProperty.call(card, "draggable") || 254 | card.draggable) ? ( 255 | {cardToRender} 256 | ) : ( 257 | {cardToRender} 258 | ); 259 | }, 260 | ); 261 | 262 | return ( 263 | 267 | { 274 | onDragEnd(id, e); 275 | }} 276 | onDragEnter={() => setIsDraggingOver(true)} 277 | onDragLeave={() => setIsDraggingOver(false)} 278 | shouldAcceptDrop={(sourceContainerOptions) => 279 | droppable && sourceContainerOptions.groupName === groupName 280 | } 281 | getChildPayload={(index) => getCardDetails(id, index)} 282 | > 283 | {cardList} 284 | 285 | {editable && !addCardMode && ( 286 | setAddCardMode(true)} t={t} /> 287 | )} 288 | {addCardMode && ( 289 | setAddCardMode(false)} 291 | t={t} 292 | laneId={id} 293 | onAdd={addNewCard} 294 | /> 295 | )} 296 | 297 | ); 298 | }; 299 | 300 | const allClassNames = className 301 | ? `react-trello-lane ${className}` 302 | : "react-trello-lane"; 303 | const showFooter = collapsibleLanes && cards.length > 0; 304 | return ( 305 | onLaneClick?.(id)} 309 | draggable={false} 310 | className={allClassNames} 311 | style={style} 312 | > 313 | 325 | {renderDragContainer(isDraggingOver)} 326 | {loading && } 327 | {showFooter && ( 328 | 332 | )} 333 | 334 | ); 335 | }; 336 | -------------------------------------------------------------------------------- /src/dnd/Container.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import container, { dropHandlers } from "react-trello-ts-smooth-dnd"; 4 | 5 | container.dropHandler = dropHandlers.reactDropHandler().handler; 6 | container.wrapChild = (p: any) => p; // dont wrap children they will already be wrapped 7 | 8 | export interface ContainerProps { 9 | behaviour?: "move" | "copy" | "drag-zone"; 10 | groupName?: string; 11 | orientation?: "horizontal" | "vertical"; 12 | style?: object; 13 | dragHandleSelector?: string; 14 | className?: string; 15 | nonDragAreaSelector?: string; 16 | dragBeginDelay?: number; 17 | animationDuration?: number; 18 | autoScrollEnabled?: string; 19 | lockAxis?: string; 20 | dragClass?: string; 21 | dropClass?: string; 22 | onDragStart?: (params: any) => void; 23 | onDragEnd?: ( 24 | laneId: string, 25 | result: { addedIndex: any; payload: any }, 26 | ) => void; 27 | onDrop?: (params: any) => void; 28 | onDropReady?: (params: any) => void; 29 | getChildPayload?: (index: number) => any; 30 | shouldAnimateDrop?: (params: any) => boolean; 31 | shouldAcceptDrop?: (params: any) => boolean; 32 | onDragEnter?: (params: any) => void; 33 | onDragLeave?: (params: any) => void; 34 | render?: (params: any) => any; 35 | getGhostParent?: () => any; 36 | removeOnDropOut?: boolean; 37 | } 38 | 39 | class Container extends Component { 40 | containerDiv: typeof Container | Element | Text; 41 | prevContainer: typeof Container | Element | Text; 42 | container: any; 43 | constructor(props) { 44 | super(props); 45 | this.getContainerOptions = this.getContainerOptions.bind(this); 46 | this.setRef = this.setRef.bind(this); 47 | this.prevContainer = null; 48 | } 49 | 50 | componentDidMount() { 51 | this.containerDiv = this.containerDiv || ReactDOM.findDOMNode(this); 52 | this.prevContainer = this.containerDiv; 53 | this.container = container(this.containerDiv, this.getContainerOptions()); 54 | } 55 | 56 | componentWillUnmount() { 57 | this.container.dispose(); 58 | this.container = null; 59 | } 60 | 61 | componentDidUpdate() { 62 | this.containerDiv = this.containerDiv || ReactDOM.findDOMNode(this); 63 | if (this.containerDiv) { 64 | if (this.prevContainer && this.prevContainer !== this.containerDiv) { 65 | this.container.dispose(); 66 | this.container = container( 67 | this.containerDiv, 68 | this.getContainerOptions(), 69 | ); 70 | this.prevContainer = this.containerDiv; 71 | } 72 | } 73 | } 74 | 75 | render() { 76 | if (this.props.render) { 77 | return this.props.render(this.setRef); 78 | } 79 | return ( 80 |
81 | {this.props.children} 82 |
83 | ); 84 | } 85 | 86 | setRef(element) { 87 | this.containerDiv = element; 88 | } 89 | 90 | getContainerOptions() { 91 | const functionProps: Pick< 92 | ContainerProps, 93 | | "onDragEnd" 94 | | "onDragStart" 95 | | "onDrop" 96 | | "getChildPayload" 97 | | "shouldAnimateDrop" 98 | | "shouldAcceptDrop" 99 | | "onDragEnter" 100 | | "onDragLeave" 101 | | "render" 102 | | "onDropReady" 103 | | "getGhostParent" 104 | > = {}; 105 | 106 | if (this.props.onDragStart) { 107 | functionProps.onDragStart = (...p) => this.props.onDragStart(...p); 108 | } 109 | 110 | if (this.props.onDragEnd) { 111 | functionProps.onDragEnd = (...p) => this.props.onDragEnd(...p); 112 | } 113 | 114 | if (this.props.onDrop) { 115 | functionProps.onDrop = (...p) => this.props.onDrop(...p); 116 | } 117 | 118 | if (this.props.getChildPayload) { 119 | functionProps.getChildPayload = (...p) => 120 | this.props.getChildPayload(...p); 121 | } 122 | 123 | if (this.props.shouldAnimateDrop) { 124 | functionProps.shouldAnimateDrop = (...p) => 125 | this.props.shouldAnimateDrop(...p); 126 | } 127 | 128 | if (this.props.shouldAcceptDrop) { 129 | functionProps.shouldAcceptDrop = (...p) => 130 | this.props.shouldAcceptDrop(...p); 131 | } 132 | 133 | if (this.props.onDragEnter) { 134 | functionProps.onDragEnter = (...p) => this.props.onDragEnter(...p); 135 | } 136 | 137 | if (this.props.onDragLeave) { 138 | functionProps.onDragLeave = (...p) => this.props.onDragLeave(...p); 139 | } 140 | 141 | if (this.props.render) { 142 | functionProps.render = (...p) => this.props.render(...p); 143 | } 144 | 145 | if (this.props.onDropReady) { 146 | functionProps.onDropReady = (...p) => this.props.onDropReady(...p); 147 | } 148 | 149 | if (this.props.getGhostParent) { 150 | functionProps.getGhostParent = (...p) => this.props.getGhostParent(...p); 151 | } 152 | 153 | return Object.assign({}, this.props, functionProps); 154 | } 155 | } 156 | 157 | export default Container; 158 | -------------------------------------------------------------------------------- /src/dnd/Draggable.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ReactElement } from "react"; 2 | import { constants } from "react-trello-ts-smooth-dnd"; 3 | 4 | const { wrapperClass } = constants; 5 | 6 | export class Draggable extends Component<{ 7 | render?: () => ReactElement; 8 | className?: string; 9 | }> { 10 | render() { 11 | if (this.props.render) { 12 | return React.cloneElement(this.props.render(), { 13 | className: wrapperClass, 14 | }); 15 | } 16 | 17 | const clsName = this.props.className ? `${this.props.className} ` : ""; 18 | return ( 19 |
20 | {this.props.children} 21 |
22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/helpers/autosize.ts: -------------------------------------------------------------------------------- 1 | type AutosizeOptions = {}; 2 | 3 | interface AutosizeMethods { 4 | destroy: () => void; 5 | update: () => void; 6 | } 7 | 8 | interface SetHeightOptions { 9 | restoreTextAlign: string | null; 10 | testForHeightReduction: boolean; 11 | } 12 | 13 | type ScrollTopCache = [HTMLElement, number][]; 14 | type RestoreScrollTops = () => void; 15 | 16 | const assignedElements = new Map(); 17 | 18 | function assign(ta: HTMLTextAreaElement, options?: AutosizeOptions): void { 19 | if ( 20 | !ta || 21 | !ta.nodeName || 22 | ta.nodeName !== "TEXTAREA" || 23 | assignedElements.has(ta) 24 | ) 25 | return; 26 | 27 | let previousHeight: number | null = null; 28 | 29 | function cacheScrollTops(el: HTMLElement): RestoreScrollTops { 30 | const arr: ScrollTopCache = []; 31 | 32 | while (el && el.parentNode && el.parentNode instanceof Element) { 33 | if (el.parentNode.scrollTop) { 34 | arr.push([el.parentNode as HTMLElement, el.parentNode.scrollTop]); 35 | } 36 | el = el.parentNode as HTMLElement; 37 | } 38 | 39 | return () => 40 | arr.forEach(([node, scrollTop]) => { 41 | node.style.scrollBehavior = "auto"; 42 | node.scrollTop = scrollTop; 43 | node.style.scrollBehavior = null; 44 | }); 45 | } 46 | 47 | const computed = window.getComputedStyle(ta); 48 | 49 | function setHeight({ 50 | restoreTextAlign = null, 51 | testForHeightReduction = true, 52 | }: Partial = {}): void { 53 | const initialOverflowY = computed.overflowY; 54 | 55 | if (ta.scrollHeight === 0) { 56 | // If the scrollHeight is 0, then the element probably has display:none or is detached from the DOM. 57 | return; 58 | } 59 | 60 | // disallow vertical resizing 61 | if (computed.resize === "vertical") { 62 | ta.style.resize = "none"; 63 | } else if (computed.resize === "both") { 64 | ta.style.resize = "horizontal"; 65 | } 66 | 67 | let restoreScrollTops: RestoreScrollTops | undefined; 68 | 69 | // remove inline height style to accurately measure situations where the textarea should shrink 70 | // however, skip this step if the new value appends to the previous value, as textarea height should only have grown 71 | if (testForHeightReduction) { 72 | // ensure the scrollTop values of parent elements are not modified as a consequence of shrinking the textarea height 73 | restoreScrollTops = cacheScrollTops(ta); 74 | ta.style.height = ""; 75 | } 76 | 77 | let newHeight: number; 78 | 79 | if (computed.boxSizing === "content-box") { 80 | newHeight = 81 | ta.scrollHeight - 82 | (parseFloat(computed.paddingTop) + parseFloat(computed.paddingBottom)); 83 | } else { 84 | newHeight = 85 | ta.scrollHeight + 86 | parseFloat(computed.borderTopWidth) + 87 | parseFloat(computed.borderBottomWidth); 88 | } 89 | 90 | if ( 91 | computed.maxHeight !== "none" && 92 | newHeight > parseFloat(computed.maxHeight) 93 | ) { 94 | if (computed.overflowY === "hidden") { 95 | ta.style.overflow = "scroll"; 96 | } 97 | newHeight = parseFloat(computed.maxHeight); 98 | } else if (computed.overflowY !== "hidden") { 99 | ta.style.overflow = "hidden"; 100 | } 101 | 102 | ta.style.height = newHeight + "px"; 103 | 104 | if (restoreTextAlign) { 105 | ta.style.textAlign = restoreTextAlign; 106 | } 107 | 108 | if (restoreScrollTops) { 109 | restoreScrollTops(); 110 | } 111 | 112 | if (previousHeight !== newHeight) { 113 | ta.dispatchEvent(new Event("autosize:resized", { bubbles: true })); 114 | previousHeight = newHeight; 115 | } 116 | 117 | if (initialOverflowY !== computed.overflow && !restoreTextAlign) { 118 | const textAlign = computed.textAlign; 119 | 120 | if (computed.overflow === "hidden") { 121 | // Webkit fails to reflow text after overflow is hidden, 122 | // even if hiding overflow would allow text to fit more compactly. 123 | // The following is intended to force the necessary text reflow. 124 | ta.style.textAlign = textAlign === "start" ? "end" : "start"; 125 | } 126 | 127 | setHeight({ 128 | restoreTextAlign: textAlign, 129 | testForHeightReduction: true, 130 | }); 131 | } 132 | } 133 | 134 | function fullSetHeight(): void { 135 | setHeight({ 136 | testForHeightReduction: true, 137 | restoreTextAlign: null, 138 | }); 139 | } 140 | 141 | const handleInput = (() => { 142 | let previousValue = ta.value; 143 | 144 | return (): void => { 145 | setHeight({ 146 | // if previousValue is '', check for height shrinkage because the placeholder may be taking up space instead 147 | // if new value is merely appending to previous value, skip checking for height deduction 148 | testForHeightReduction: 149 | previousValue === "" || !ta.value.startsWith(previousValue), 150 | restoreTextAlign: null, 151 | }); 152 | 153 | previousValue = ta.value; 154 | }; 155 | })(); 156 | 157 | interface SavedStyles { 158 | height: string; 159 | resize: string; 160 | textAlign: string; 161 | overflowY: string; 162 | overflowX: string; 163 | wordWrap: string; 164 | } 165 | 166 | const destroy = ((style: SavedStyles) => 167 | function destroyAutosize(): void { 168 | ta.removeEventListener("autosize:destroy", destroy); 169 | ta.removeEventListener("autosize:update", fullSetHeight); 170 | ta.removeEventListener("input", handleInput); 171 | window.removeEventListener("resize", fullSetHeight); // future todo: consider replacing with ResizeObserver 172 | Object.keys(style).forEach( 173 | (key) => 174 | (ta.style[key as keyof SavedStyles] = 175 | style[key as keyof SavedStyles]), 176 | ); 177 | assignedElements.delete(ta); 178 | })({ 179 | height: ta.style.height, 180 | resize: ta.style.resize, 181 | textAlign: ta.style.textAlign, 182 | overflowY: ta.style.overflowY, 183 | overflowX: ta.style.overflowX, 184 | wordWrap: ta.style.wordWrap, 185 | } as SavedStyles); 186 | 187 | ta.addEventListener("autosize:destroy", destroy as EventListener); 188 | ta.addEventListener("autosize:update", fullSetHeight); 189 | ta.addEventListener("input", handleInput); 190 | window.addEventListener("resize", fullSetHeight); // future todo: consider replacing with ResizeObserver 191 | ta.style.overflowX = "hidden"; 192 | ta.style.wordWrap = "break-word"; 193 | 194 | assignedElements.set(ta, { 195 | destroy, 196 | update: fullSetHeight, 197 | }); 198 | 199 | fullSetHeight(); 200 | } 201 | 202 | function destroy(ta: HTMLTextAreaElement): void { 203 | const methods = assignedElements.get(ta); 204 | if (methods) { 205 | methods.destroy(); 206 | } 207 | } 208 | 209 | function update(ta: HTMLTextAreaElement): void { 210 | const methods = assignedElements.get(ta); 211 | if (methods) { 212 | methods.update(); 213 | } 214 | } 215 | 216 | interface Autosize { 217 | ( 218 | el: 219 | | HTMLTextAreaElement 220 | | HTMLTextAreaElement[] 221 | | NodeListOf, 222 | options?: AutosizeOptions, 223 | ): 224 | | HTMLTextAreaElement 225 | | HTMLTextAreaElement[] 226 | | NodeListOf; 227 | destroy: ( 228 | el: 229 | | HTMLTextAreaElement 230 | | HTMLTextAreaElement[] 231 | | NodeListOf, 232 | ) => 233 | | HTMLTextAreaElement 234 | | HTMLTextAreaElement[] 235 | | NodeListOf; 236 | update: ( 237 | el: 238 | | HTMLTextAreaElement 239 | | HTMLTextAreaElement[] 240 | | NodeListOf, 241 | ) => 242 | | HTMLTextAreaElement 243 | | HTMLTextAreaElement[] 244 | | NodeListOf; 245 | } 246 | 247 | let autosize: Autosize; 248 | 249 | // Do nothing in Node.js environment 250 | if (typeof window === "undefined") { 251 | const noop = (el: T): T => el; 252 | autosize = noop as unknown as Autosize; 253 | autosize.destroy = noop; 254 | autosize.update = noop; 255 | } else { 256 | const autosizeFunction = ( 257 | el: 258 | | HTMLTextAreaElement 259 | | HTMLTextAreaElement[] 260 | | NodeListOf, 261 | options?: AutosizeOptions, 262 | ) => { 263 | if (el) { 264 | // Check if el is an array-like object with a length property 265 | const isArrayLike = (obj: any): obj is { length: number } => 266 | obj && typeof obj.length === "number"; 267 | 268 | // Use the type guard to safely access length 269 | if (isArrayLike(el) && el.length) { 270 | Array.prototype.forEach.call(el, (x: HTMLTextAreaElement) => 271 | assign(x, options), 272 | ); 273 | } else { 274 | // Single element case 275 | assign(el as HTMLTextAreaElement, options); 276 | } 277 | } 278 | return el; 279 | }; 280 | 281 | autosizeFunction.destroy = ( 282 | el: 283 | | HTMLTextAreaElement 284 | | HTMLTextAreaElement[] 285 | | NodeListOf, 286 | ) => { 287 | if (el) { 288 | const isArrayLike = (obj: any): obj is { length: number } => 289 | obj && typeof obj.length === "number"; 290 | 291 | if (isArrayLike(el) && el.length) { 292 | Array.prototype.forEach.call(el, destroy); 293 | } else { 294 | destroy(el as HTMLTextAreaElement); 295 | } 296 | } 297 | return el; 298 | }; 299 | 300 | autosizeFunction.update = ( 301 | el: 302 | | HTMLTextAreaElement 303 | | HTMLTextAreaElement[] 304 | | NodeListOf, 305 | ) => { 306 | if (el) { 307 | const isArrayLike = (obj: any): obj is { length: number } => 308 | obj && typeof obj.length === "number"; 309 | 310 | if (isArrayLike(el) && el.length) { 311 | Array.prototype.forEach.call(el, update); 312 | } else { 313 | update(el as HTMLTextAreaElement); 314 | } 315 | } 316 | return el; 317 | }; 318 | 319 | autosize = autosizeFunction; 320 | } 321 | 322 | export default autosize; 323 | -------------------------------------------------------------------------------- /src/helpers/createTranslate.ts: -------------------------------------------------------------------------------- 1 | const getValue = (table) => (key) => { 2 | const keys = key.includes(".") ? key.split(".") : [key]; 3 | let value = table; 4 | for (const k of keys) { 5 | if (value && k in value) { 6 | value = value[k]; 7 | } else { 8 | return undefined; 9 | } 10 | } 11 | return value; 12 | }; 13 | 14 | export default (table) => (key) => getValue(table)(key); 15 | -------------------------------------------------------------------------------- /src/helpers/deprecationWarnings.ts: -------------------------------------------------------------------------------- 1 | const REPLACE_TABLE = { 2 | addCardLink: "components.Card", 3 | customLaneHeader: "components.LaneHeader", 4 | newLaneTemplate: "components.NewLaneSection", 5 | newCardTemplate: "components.NewCardForm", 6 | children: "components.Card", 7 | customCardLayout: "components.Card", 8 | addLaneTitle: '`t` function with key "Add another lane"', 9 | // addCardLink: '`t` function with key "Click to add card"' 10 | }; 11 | 12 | const warn = (prop: keyof typeof REPLACE_TABLE) => { 13 | const use = REPLACE_TABLE[prop]; 14 | console.warn( 15 | `react-trello property '${prop}' is removed. Use '${use}' instead. More - https://github.com/rcdexta/react-trello/blob/master/UPGRADE.md`, 16 | ); 17 | }; 18 | 19 | export default (props) => { 20 | for (const key in REPLACE_TABLE) { 21 | if (Object.prototype.hasOwnProperty.call(REPLACE_TABLE, key)) { 22 | warn(key as keyof typeof REPLACE_TABLE); 23 | } else { 24 | console.warn(`react-trello property '${key}' is removed`); 25 | } 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import * as DefaultComponents from "./components"; 4 | import { 5 | BoardContainer, 6 | BoardContainerProps, 7 | } from "./controllers/BoardContainer"; 8 | import { Lane } from "./controllers/Lane"; 9 | import Container from "./dnd/Container"; 10 | import { Draggable } from "./dnd/Draggable"; 11 | import deprecationWarnings from "./helpers/deprecationWarnings"; 12 | import locales from "./locales"; 13 | 14 | export * from "./widgets"; 15 | 16 | import { Board } from "./controllers/Board"; 17 | import createTranslate from "./helpers/createTranslate"; 18 | 19 | export { Draggable, Container, BoardContainer, Lane, createTranslate, locales }; 20 | 21 | export { DefaultComponents as components }; 22 | 23 | const DEFAULT_LANG = "en"; 24 | 25 | function DefaultBoard({ 26 | components, 27 | lang = DEFAULT_LANG, 28 | ...otherProps 29 | }: BoardContainerProps & { lang?: keyof typeof locales }) { 30 | deprecationWarnings(otherProps); 31 | 32 | const translate = createTranslate(locales[lang || "en"].translation); 33 | return ( 34 | 39 | ); 40 | } 41 | 42 | export default DefaultBoard; 43 | -------------------------------------------------------------------------------- /src/locales/en/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "Add another lane": "+ Add another lane", 3 | "Click to add card": "Click to add card", 4 | "Delete lane": "Delete lane", 5 | "Lane actions": "Lane actions", 6 | "button": { 7 | "Add lane": "Add lane", 8 | "Add card": "Add card", 9 | "Cancel": "Cancel" 10 | }, 11 | "placeholder": { 12 | "title": "title", 13 | "description": "description", 14 | "label": "label" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/locales/index.ts: -------------------------------------------------------------------------------- 1 | // i18next support structure 2 | import eng from "./en/translation.json"; 3 | import rus from "./ru/translation.json"; 4 | export default { 5 | en: { 6 | translation: eng, 7 | }, 8 | ru: { 9 | translation: rus, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/locales/ru/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "Add another lane": "+Добавить колонку", 3 | "Click to add card": "+Добавить карточку", 4 | "Delete lane": "Удалить колонку", 5 | "Lane actions": "Действия над колонкой", 6 | "button": { 7 | "Add card": "Добавить карту", 8 | "Add lane": "Добавить колонку", 9 | "Cancel": "Отменить" 10 | }, 11 | "placeholder": { 12 | "title": "Название", 13 | "description": "Описание", 14 | "label": "Метка" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { BoardData, Card, Lane } from "../types/Board"; 3 | 4 | export interface State { 5 | data: BoardData; 6 | initializeLanes: (lanes: BoardData["lanes"]) => void; 7 | refreshBoard: (lanes?: Lane[]) => void; 8 | addCard: (card: Card, laneId: string, index?: number) => void; 9 | removeCard: (laneId: string, cardId: string) => void; 10 | moveCard: ( 11 | fromLaneId: string, 12 | toLaneId: string, 13 | cardId: string, 14 | index: number, 15 | ) => void; 16 | updateCards: (laneId: string, cards: Card[]) => void; 17 | updateCard: (laneId: string, card: Card) => void; 18 | updateLanes: (lanes: Lane[]) => void; 19 | updateLane: (lane: Partial) => void; 20 | paginateLane: (laneId: string, newCards: Card[], nextPage: number) => void; 21 | moveLane: (fromIndex: number, toIndex: number) => void; 22 | removeLane: (laneId: string) => void; 23 | addLane: (lane: Lane) => void; 24 | } 25 | export const store = create()((set) => { 26 | // Helper function to find a lane by ID and throw if not found 27 | const findLaneIndex = ( 28 | state: State, 29 | laneId: string, 30 | errorMessage = "Lane not found", 31 | ) => { 32 | const laneIndex = state.data.lanes.findIndex((l) => l.id === laneId); 33 | if (laneIndex === -1) { 34 | throw new Error(errorMessage); 35 | } 36 | return laneIndex; 37 | }; 38 | 39 | // Helper function to update a single lane 40 | const updateLaneById = ( 41 | state: State, 42 | laneId: string, 43 | updates: Partial, 44 | ) => { 45 | const laneIndex = findLaneIndex(state, laneId); 46 | const updatedLanes = [...state.data.lanes]; 47 | updatedLanes[laneIndex] = { ...updatedLanes[laneIndex], ...updates }; 48 | 49 | return { data: { ...state.data, lanes: updatedLanes } }; 50 | }; 51 | 52 | return { 53 | data: { lanes: [] }, 54 | initializeLanes: (lanes: Lane[]) => 55 | set((state) => ({ 56 | data: { 57 | ...state.data, 58 | lanes: lanes.map((lane) => ({ 59 | ...lane, 60 | currentPage: 1, 61 | cards: lane.cards?.map((c) => ({ ...c, laneId: lane.id })), 62 | })), 63 | }, 64 | })), 65 | refreshBoard: (lanes = []) => set(() => ({ data: { lanes } })), 66 | addCard: (card, laneId, index) => 67 | set((state) => { 68 | const laneIndex = findLaneIndex(state, laneId); 69 | const lane = state.data.lanes[laneIndex]; 70 | 71 | let updatedCards; 72 | if (index === undefined) { 73 | updatedCards = [...lane.cards, card]; 74 | } else { 75 | updatedCards = [...lane.cards]; 76 | updatedCards[index] = card; 77 | } 78 | 79 | return updateLaneById(state, laneId, { cards: updatedCards }); 80 | }), 81 | removeCard: (laneId, cardId) => 82 | set((state) => { 83 | const laneIndex = findLaneIndex(state, laneId); 84 | const lane = state.data.lanes[laneIndex]; 85 | const updatedCards = lane.cards.filter((c) => c.id !== cardId); 86 | 87 | return updateLaneById(state, laneId, { cards: updatedCards }); 88 | }), 89 | moveCard: (fromLaneId, toLaneId, cardId, index) => 90 | set((state) => { 91 | const fromLaneIndex = state.data.lanes.findIndex( 92 | (l) => l.id === fromLaneId, 93 | ); 94 | if (fromLaneIndex === -1) { 95 | throw new Error("fromLane not found"); 96 | } 97 | 98 | const toLaneIndex = state.data.lanes.findIndex( 99 | (l) => l.id === toLaneId, 100 | ); 101 | if (toLaneIndex === -1) { 102 | throw new Error("toLane not found"); 103 | } 104 | 105 | const fromLane = state.data.lanes[fromLaneIndex]; 106 | const toLane = state.data.lanes[toLaneIndex]; 107 | 108 | const cardIndex = fromLane.cards.findIndex((c) => c.id === cardId); 109 | if (cardIndex === -1) { 110 | return state; 111 | } 112 | 113 | const card = fromLane.cards[cardIndex]; 114 | const newCard = { ...card, laneId: toLaneId }; 115 | 116 | const updatedFromCards = fromLane.cards.filter( 117 | (_, i) => i !== cardIndex, 118 | ); 119 | 120 | let updatedToCards; 121 | if (index !== undefined) { 122 | updatedToCards = [...toLane.cards]; 123 | updatedToCards.splice(index, 0, newCard); 124 | } else { 125 | updatedToCards = [...toLane.cards, newCard]; 126 | } 127 | 128 | const updatedLanes = [...state.data.lanes]; 129 | updatedLanes[fromLaneIndex] = { ...fromLane, cards: updatedFromCards }; 130 | updatedLanes[toLaneIndex] = { ...toLane, cards: updatedToCards }; 131 | 132 | return { data: { ...state.data, lanes: updatedLanes } }; 133 | }), 134 | updateCards: (laneId, cards) => 135 | set((state) => updateLaneById(state, laneId, { cards })), 136 | updateCard: (laneId, card) => 137 | set((state) => { 138 | const laneIndex = findLaneIndex(state, laneId); 139 | const lane = state.data.lanes[laneIndex]; 140 | const cardIndex = lane.cards.findIndex((c) => c.id === card.id); 141 | 142 | if (cardIndex === -1) { 143 | return state; 144 | } 145 | 146 | const updatedCards = [...lane.cards]; 147 | updatedCards[cardIndex] = card; 148 | 149 | return updateLaneById(state, laneId, { cards: updatedCards }); 150 | }), 151 | updateLanes: (lanes) => 152 | set((state) => ({ 153 | data: { ...state.data, lanes }, 154 | })), 155 | updateLane: (lane) => 156 | set((state) => { 157 | if (!lane.id) return state; 158 | 159 | const laneIndex = state.data.lanes.findIndex((l) => l.id === lane.id); 160 | if (laneIndex === -1) { 161 | return state; 162 | } 163 | 164 | return updateLaneById(state, lane.id, lane); 165 | }), 166 | paginateLane: (laneId, newCards, nextPage) => 167 | set((state) => { 168 | const updatedLanes = state.data.lanes.map((l) => 169 | l.id === laneId 170 | ? { ...l, cards: [...l.cards, ...newCards], currentPage: nextPage } 171 | : l, 172 | ); 173 | 174 | return { data: { ...state.data, lanes: updatedLanes } }; 175 | }), 176 | moveLane: (fromIndex, toIndex) => 177 | set((state) => { 178 | const updatedLanes = [...state.data.lanes]; 179 | const [lane] = updatedLanes.splice(fromIndex, 1); 180 | updatedLanes.splice(toIndex, 0, lane); 181 | 182 | return { data: { ...state.data, lanes: updatedLanes } }; 183 | }), 184 | removeLane: (laneId) => 185 | set((state) => { 186 | const updatedLanes = state.data.lanes.filter((l) => l.id !== laneId); 187 | return { data: { ...state.data, lanes: updatedLanes } }; 188 | }), 189 | addLane: (lane) => 190 | set((state) => ({ 191 | data: { ...state.data, lanes: [...state.data.lanes, lane] }, 192 | })), 193 | }; 194 | }); 195 | -------------------------------------------------------------------------------- /src/store/useBoard.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { useStore } from "zustand"; 3 | import { BoardContext } from "../controllers/Board"; 4 | 5 | export const useBoard = () => { 6 | const store = useContext(BoardContext); 7 | return useStore(store); 8 | }; 9 | -------------------------------------------------------------------------------- /src/styles/Base.ts: -------------------------------------------------------------------------------- 1 | import { PopoverContainer, PopoverContent } from "react-popopo"; 2 | import styled, { createGlobalStyle, css } from "styled-components"; 3 | 4 | export const GlobalStyle = createGlobalStyle` 5 | .comPlainTextContentEditable { 6 | -webkit-user-modify: read-write-plaintext-only; 7 | cursor: text; 8 | } 9 | 10 | .comPlainTextContentEditable--has-placeholder::before { 11 | content: attr(placeholder); 12 | opacity: 0.5; 13 | color: inherit; 14 | cursor: text; 15 | } 16 | 17 | .react_trello_dragClass { 18 | transform: rotate(3deg); 19 | } 20 | 21 | .react_trello_dragLaneClass { 22 | transform: rotate(3deg); 23 | } 24 | 25 | .icon-overflow-menu-horizontal:before { 26 | content: "\\E91F"; 27 | } 28 | .icon-lg, .icon-sm { 29 | color: #798d99; 30 | } 31 | .icon-lg { 32 | height: 32px; 33 | font-size: 16px; 34 | line-height: 32px; 35 | width: 32px; 36 | } 37 | `; 38 | 39 | export const CustomPopoverContainer = styled(PopoverContainer)` 40 | position: absolute; 41 | right: 10px; 42 | flex-flow: column nowrap; 43 | `; 44 | 45 | export const CustomPopoverContent = styled(PopoverContent)` 46 | visibility: hidden; 47 | margin-top: -5px; 48 | opacity: 0; 49 | position: absolute; 50 | z-index: 10; 51 | box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.3); 52 | transition: all 0.3s ease 0ms; 53 | border-radius: 3px; 54 | min-width: 7em; 55 | flex-flow: column nowrap; 56 | background-color: #fff; 57 | color: #000; 58 | padding: 5px; 59 | left: 50%; 60 | transform: translateX(-50%); 61 | ${(props) => 62 | props.active && 63 | ` 64 | visibility: visible; 65 | opacity: 1; 66 | transition-delay: 100ms; 67 | `} &::before { 68 | visibility: hidden; 69 | } 70 | a { 71 | color: rgba(255, 255, 255, 0.56); 72 | padding: 0.5em 1em; 73 | margin: 0; 74 | text-decoration: none; 75 | &:hover { 76 | background-color: #00bcd4 !important; 77 | color: #37474f; 78 | } 79 | } 80 | `; 81 | 82 | export const BoardWrapper = styled.div` 83 | background-color: #3179ba; 84 | overflow-y: hidden; 85 | padding: 5px; 86 | color: #393939; 87 | display: flex; 88 | flex-direction: row; 89 | align-items: flex-start; 90 | height: 100vh; 91 | `; 92 | 93 | export const Header = styled.header<{ editLaneTitle?: boolean }>` 94 | margin-bottom: 10px; 95 | display: flex; 96 | flex-direction: row; 97 | align-items: flex-start; 98 | `; 99 | 100 | export const Section = styled.section` 101 | background-color: #e3e3e3; 102 | border-radius: 3px; 103 | margin: 5px 5px; 104 | position: relative; 105 | padding: 10px; 106 | display: inline-flex; 107 | height: auto; 108 | max-height: 90%; 109 | flex-direction: column; 110 | `; 111 | 112 | export const LaneHeader = styled(Header)` 113 | margin-bottom: 0px; 114 | ${(props) => 115 | props.editLaneTitle && 116 | css` 117 | padding: 0px; 118 | line-height: 30px; 119 | `} ${(props) => 120 | !props.editLaneTitle && 121 | css` 122 | padding: 0px 5px; 123 | `}; 124 | `; 125 | 126 | export const LaneFooter = styled.div` 127 | display: flex; 128 | justify-content: center; 129 | align-items: center; 130 | width: 100%; 131 | position: relative; 132 | height: 10px; 133 | `; 134 | 135 | export const ScrollableLane = styled.div<{ isDraggingOver?: boolean }>` 136 | flex: 1; 137 | overflow-y: auto; 138 | min-width: 250px; 139 | overflow-x: hidden; 140 | align-self: center; 141 | max-height: 90vh; 142 | margin-top: 10px; 143 | flex-direction: column; 144 | justify-content: space-between; 145 | `; 146 | 147 | export const Title = styled.span` 148 | font-weight: bold; 149 | font-size: 15px; 150 | line-height: 18px; 151 | cursor: ${(props) => (props.draggable ? "grab" : "auto")}; 152 | width: 70%; 153 | `; 154 | 155 | export const RightContent = styled.span` 156 | width: 38%; 157 | text-align: right; 158 | padding-right: 10px; 159 | font-size: 13px; 160 | `; 161 | export const CardWrapper = styled.article` 162 | border-radius: 3px; 163 | border-bottom: 1px solid #ccc; 164 | background-color: #fff; 165 | position: relative; 166 | padding: 10px; 167 | cursor: pointer; 168 | max-width: 250px; 169 | margin-bottom: 7px; 170 | min-width: 230px; 171 | `; 172 | 173 | export const MovableCardWrapper = styled(CardWrapper)` 174 | &:hover { 175 | background-color: #f0f0f0; 176 | color: #000; 177 | } 178 | `; 179 | 180 | export const CardHeader = styled(Header)` 181 | border-bottom: 1px solid #eee; 182 | padding-bottom: 6px; 183 | color: #000; 184 | `; 185 | 186 | export const CardTitle = styled(Title)` 187 | font-size: 14px; 188 | `; 189 | 190 | export const CardRightContent = styled(RightContent)` 191 | font-size: 10px; 192 | `; 193 | 194 | export const Detail = styled.div` 195 | font-size: 12px; 196 | color: #4d4d4d; 197 | white-space: pre-wrap; 198 | `; 199 | 200 | export const Footer = styled.div` 201 | border-top: 1px solid #eee; 202 | padding-top: 6px; 203 | text-align: right; 204 | display: flex; 205 | justify-content: flex-end; 206 | flex-direction: row; 207 | flex-wrap: wrap; 208 | `; 209 | 210 | export const TagSpan = styled.span` 211 | padding: 2px 3px; 212 | border-radius: 3px; 213 | margin: 2px 5px; 214 | font-size: 70%; 215 | `; 216 | 217 | export const AddCardLink = styled.a` 218 | border-radius: 0 0 3px 3px; 219 | color: #838c91; 220 | display: block; 221 | padding: 5px 2px; 222 | margin-top: 10px; 223 | position: relative; 224 | text-decoration: none; 225 | cursor: pointer; 226 | 227 | &:hover { 228 | //background-color: #cdd2d4; 229 | color: #4d4d4d; 230 | text-decoration: underline; 231 | } 232 | `; 233 | 234 | export const LaneTitle = styled.div` 235 | font-size: 15px; 236 | width: 268px; 237 | height: auto; 238 | `; 239 | 240 | export const LaneSection = styled.section` 241 | background-color: #2b6aa3; 242 | border-radius: 3px; 243 | margin: 5px; 244 | position: relative; 245 | padding: 5px; 246 | display: inline-flex; 247 | height: auto; 248 | flex-direction: column; 249 | `; 250 | 251 | export const NewLaneSection = styled(LaneSection)` 252 | width: 200px; 253 | `; 254 | 255 | export const NewLaneButtons = styled.div` 256 | margin-top: 10px; 257 | `; 258 | 259 | export const CardForm = styled.div` 260 | background-color: #e3e3e3; 261 | `; 262 | 263 | export const InlineInput = styled.textarea<{ 264 | border: boolean; 265 | resize?: "vertical" | "horizontal" | "none"; 266 | }>` 267 | overflow-x: hidden; /* for Firefox (issue #5) */ 268 | word-wrap: break-word; 269 | min-height: 18px; 270 | max-height: 112px; /* optional, but recommended */ 271 | resize: none; 272 | width: 100%; 273 | height: 18px; 274 | font-size: inherit; 275 | font-weight: inherit; 276 | line-height: inherit; 277 | text-align: inherit; 278 | background-color: transparent; 279 | box-shadow: none; 280 | box-sizing: border-box; 281 | border-radius: 3px; 282 | border: 0; 283 | padding: 0 8px; 284 | outline: 0; 285 | ${(props) => 286 | props.border && 287 | css` 288 | &:focus { 289 | box-shadow: inset 0 0 0 2px #0079bf; 290 | } 291 | `} &:focus { 292 | background-color: white; 293 | } 294 | ${(props) => 295 | props.resize && 296 | css` 297 | resize: ${props.resize}; 298 | `}; 299 | `; 300 | -------------------------------------------------------------------------------- /src/styles/Elements.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { CardWrapper, MovableCardWrapper } from "./Base"; 3 | 4 | export const DeleteWrapper = styled.div` 5 | text-align: center; 6 | position: absolute; 7 | top: -1px; 8 | right: 2px; 9 | cursor: pointer; 10 | `; 11 | 12 | export const GenDelButton = styled.button` 13 | transition: all 0.5s ease; 14 | display: inline-block; 15 | border: none; 16 | font-size: 15px; 17 | height: 15px; 18 | padding: 0; 19 | margin-top: 5px; 20 | text-align: center; 21 | width: 15px; 22 | background: inherit; 23 | cursor: pointer; 24 | `; 25 | 26 | export const DelButton = styled.button` 27 | transition: all 0.5s ease; 28 | display: inline-block; 29 | border: none; 30 | font-size: 8px; 31 | height: 15px; 32 | line-height: 1px; 33 | margin: 0 0 8px; 34 | padding: 0; 35 | text-align: center; 36 | width: 15px; 37 | background: inherit; 38 | cursor: pointer; 39 | opacity: 0; 40 | ${MovableCardWrapper}:hover & { 41 | opacity: 1; 42 | } 43 | `; 44 | 45 | export const MenuButton = styled.button` 46 | transition: all 0.5s ease; 47 | display: inline-block; 48 | border: none; 49 | outline: none; 50 | font-size: 16px; 51 | font-weight: bold; 52 | height: 15px; 53 | line-height: 1px; 54 | margin: 0 0 8px; 55 | padding: 0; 56 | text-align: center; 57 | width: 15px; 58 | background: inherit; 59 | cursor: pointer; 60 | `; 61 | 62 | export const LaneMenuHeader = styled.div` 63 | position: relative; 64 | margin-bottom: 4px; 65 | text-align: center; 66 | `; 67 | 68 | export const LaneMenuContent = styled.div` 69 | overflow-x: hidden; 70 | overflow-y: auto; 71 | padding: 0 12px 12px; 72 | `; 73 | 74 | export const LaneMenuItem = styled.div` 75 | cursor: pointer; 76 | display: block; 77 | font-weight: 700; 78 | padding: 6px 12px; 79 | position: relative; 80 | margin: 0 -12px; 81 | text-decoration: none; 82 | 83 | &:hover { 84 | background-color: #3179BA; 85 | color: #fff; 86 | } 87 | `; 88 | 89 | export const LaneMenuTitle = styled.span` 90 | box-sizing: border-box; 91 | color: #6b808c; 92 | display: block; 93 | line-height: 30px; 94 | border-bottom: 1px solid rgba(9,45,66,.13); 95 | margin: 0 6px; 96 | overflow: hidden; 97 | padding: 0 32px; 98 | position: relative; 99 | text-overflow: ellipsis; 100 | white-space: nowrap; 101 | z-index: 1; 102 | `; 103 | 104 | export const DeleteIcon = styled.span` 105 | position: relative; 106 | display: inline-block; 107 | width: 4px; 108 | height: 4px; 109 | opacity: 1; 110 | overflow: hidden; 111 | border: 1px solid #83bd42; 112 | border-radius: 50%; 113 | padding: 4px; 114 | background-color: #83bd42; 115 | 116 | ${CardWrapper}:hover & { 117 | opacity: 1; 118 | } 119 | 120 | &:hover::before, 121 | &:hover::after { 122 | background: red; 123 | } 124 | 125 | &:before, 126 | &:after { 127 | content: ''; 128 | position: absolute; 129 | height: 2px; 130 | width: 60%; 131 | top: 45%; 132 | left: 20%; 133 | background: #fff; 134 | border-radius: 5px; 135 | } 136 | 137 | &:before { 138 | -webkit-transform: rotate(45deg); 139 | -moz-transform: rotate(45deg); 140 | -o-transform: rotate(45deg); 141 | transform: rotate(45deg); 142 | } 143 | 144 | &:after { 145 | -webkit-transform: rotate(-45deg); 146 | -moz-transform: rotate(-45deg); 147 | -o-transform: rotate(-45deg); 148 | transform: rotate(-45deg); 149 | } 150 | `; 151 | 152 | export const ExpandCollapseBase = styled.span` 153 | width: 36px; 154 | margin: 0 auto; 155 | font-size: 14px; 156 | position: relative; 157 | cursor: pointer; 158 | `; 159 | 160 | export const CollapseBtn = styled(ExpandCollapseBase)` 161 | &:before { 162 | content: ''; 163 | position: absolute; 164 | top: 0; 165 | left: 0; 166 | border-bottom: 7px solid #444; 167 | border-left: 7px solid transparent; 168 | border-right: 7px solid transparent; 169 | border-radius: 6px; 170 | } 171 | &:after { 172 | content: ''; 173 | position: absolute; 174 | left: 4px; 175 | top: 4px; 176 | border-bottom: 3px solid #e3e3e3; 177 | border-left: 3px solid transparent; 178 | border-right: 3px solid transparent; 179 | } 180 | `; 181 | 182 | export const ExpandBtn = styled(ExpandCollapseBase)` 183 | &:before { 184 | content: ''; 185 | position: absolute; 186 | top: 0; 187 | left: 0; 188 | border-top: 7px solid #444; 189 | border-left: 7px solid transparent; 190 | border-right: 7px solid transparent; 191 | border-radius: 6px; 192 | } 193 | &:after { 194 | content: ''; 195 | position: absolute; 196 | left: 4px; 197 | top: 0px; 198 | border-top: 3px solid #e3e3e3; 199 | border-left: 3px solid transparent; 200 | border-right: 3px solid transparent; 201 | } 202 | `; 203 | 204 | export const AddButton = styled.button` 205 | background: #5aac44; 206 | color: #fff; 207 | transition: background 0.3s ease; 208 | min-height: 32px; 209 | padding: 4px 16px; 210 | vertical-align: top; 211 | margin-top: 0; 212 | margin-right: 8px; 213 | font-weight: bold; 214 | border-radius: 3px; 215 | font-size: 14px; 216 | cursor: pointer; 217 | margin-bottom: 0; 218 | `; 219 | 220 | export const CancelButton = styled.button` 221 | background: #999999; 222 | color: #fff; 223 | transition: background 0.3s ease; 224 | min-height: 32px; 225 | padding: 4px 16px; 226 | vertical-align: top; 227 | margin-top: 0; 228 | font-weight: bold; 229 | border-radius: 3px; 230 | font-size: 14px; 231 | cursor: pointer; 232 | margin-bottom: 0; 233 | `; 234 | export const AddLaneLink = styled.button` 235 | background: #2b6aa3; 236 | border: none; 237 | color: #fff; 238 | transition: background 0.3s ease; 239 | min-height: 32px; 240 | padding: 4px 16px; 241 | vertical-align: top; 242 | margin-top: 0; 243 | margin-right: 0px; 244 | border-radius: 4px; 245 | font-size: 13px; 246 | cursor: pointer; 247 | margin-bottom: 0; 248 | `; 249 | -------------------------------------------------------------------------------- /src/styles/Loader.ts: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from "styled-components"; 2 | 3 | const keyframeAnimation = keyframes` 4 | 0% { 5 | transform: scale(1); 6 | } 7 | 20% { 8 | transform: scale(1, 2.2); 9 | } 10 | 40% { 11 | transform: scale(1); 12 | } 13 | `; 14 | export const LoaderDiv = styled.div` 15 | text-align: center; 16 | margin: 15px 0; 17 | `; 18 | 19 | export const LoadingBar = styled.div` 20 | display: inline-block; 21 | margin: 0 2px; 22 | width: 4px; 23 | height: 18px; 24 | border-radius: 4px; 25 | animation: ${keyframeAnimation} 1s ease-in-out infinite; 26 | background-color: #777; 27 | 28 | &:nth-child(1) { 29 | animation-delay: 0.0001s; 30 | } 31 | &:nth-child(2) { 32 | animation-delay: 0.09s; 33 | } 34 | &:nth-child(3) { 35 | animation-delay: 0.18s; 36 | } 37 | &:nth-child(4) { 38 | animation-delay: 0.27s; 39 | } 40 | `; 41 | -------------------------------------------------------------------------------- /src/types/Board.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react"; 2 | 3 | export interface BoardData { 4 | lanes: Lane[]; 5 | } 6 | 7 | export interface Lane { 8 | id: string; 9 | title?: string; 10 | label?: string; 11 | style?: CSSProperties; 12 | cards?: Card[]; 13 | currentPage?: number; 14 | droppable?: boolean; 15 | labelStyle?: CSSProperties; 16 | cardStyle?: CSSProperties; 17 | disallowAddingCard?: boolean; 18 | [key: string]: any; 19 | } 20 | 21 | export interface Card { 22 | id: string; 23 | title?: string; 24 | label?: string; 25 | description?: string; 26 | laneId?: string; 27 | style?: CSSProperties; 28 | draggable?: boolean; 29 | [key: string]: any; 30 | } 31 | -------------------------------------------------------------------------------- /src/types/EventBus.ts: -------------------------------------------------------------------------------- 1 | import { BoardData, Card, Lane } from "./Board"; 2 | export interface EventBusHandle { 3 | publish: (event: EventBusEvent) => any; 4 | } 5 | 6 | type EventBusEvent = 7 | | { 8 | type: "ADD_CARD"; 9 | laneId: string; 10 | card: Card; 11 | index?: number; 12 | } 13 | | { 14 | type: "UPDATE_CARD"; 15 | laneId: string; 16 | card: Card; 17 | } 18 | | { 19 | type: "REMOVE_CARD"; 20 | laneId: string; 21 | cardId: string; 22 | } 23 | | { 24 | type: "REFRESH_BOARD"; 25 | data: BoardData; 26 | } 27 | | { 28 | type: "MOVE_CARD"; 29 | fromLaneId: string; 30 | toLaneId: string; 31 | cardId: string; 32 | index: number; 33 | } 34 | | { 35 | type: "UPDATE_CARDS"; 36 | laneId: string; 37 | cards: Card[]; 38 | } 39 | | { 40 | type: "UPDATE_LANES"; 41 | lanes: BoardData["lanes"]; 42 | } 43 | | { 44 | type: "UPDATE_LANE"; 45 | laneId: string; 46 | lane: Lane; 47 | }; 48 | -------------------------------------------------------------------------------- /src/types/utilities.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Given a type `TType` and a type `TRequired` which is a subset of required keys of `TType`, return a new type which is the 3 | * same as `TType` but with all keys optional except those in `TRequired`. 4 | */ 5 | export type PartialExcept< 6 | TType, 7 | TRequired extends keyof TType, 8 | > = Partial & Pick; 9 | -------------------------------------------------------------------------------- /src/widgets/DeleteButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, HTMLAttributes, PropsWithChildren } from "react"; 2 | import { StyledComponent, ThemedStyledFunction } from "styled-components"; 3 | import { DelButton, DeleteWrapper } from "../styles/Elements"; 4 | 5 | type DeleteButtonProps = HTMLAttributes; 6 | export const DeleteButton: FC> = ({ 7 | ...rest 8 | }) => { 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/widgets/EditableLabel.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FC, 3 | PropsWithChildren, 4 | createRef, 5 | useEffect, 6 | useState, 7 | } from "react"; 8 | import createTranslate from "../helpers/createTranslate"; 9 | 10 | interface EditableLabelProps { 11 | onChange?: (labelText: string) => void; 12 | placeholder?: string; 13 | autoFocus?: boolean; 14 | inline?: boolean; 15 | value?: string; 16 | } 17 | export const EditableLabel: FC> = ({ 18 | autoFocus = false, 19 | inline = false, 20 | onChange = () => {}, 21 | placeholder = "", 22 | value = "", 23 | children, 24 | }) => { 25 | const [labelText, setLabelText] = useState(""); 26 | const divRef = createRef(); 27 | useEffect(() => { 28 | if (autoFocus) { 29 | divRef.current.focus(); 30 | } 31 | }); 32 | 33 | const getClassName = () => { 34 | const placeholder = 35 | labelText === "" ? "comPlainTextContentEditable--has-placeholder" : ""; 36 | return `comPlainTextContentEditable ${placeholder}`; 37 | }; 38 | const onPaste = (event: React.ClipboardEvent) => { 39 | event.preventDefault(); 40 | navigator.clipboard.writeText(event.clipboardData.getData("text")); 41 | }; 42 | const onBlur = () => { 43 | onChange(labelText); 44 | }; 45 | const onKeyDown = (event: React.KeyboardEvent) => { 46 | if (event.key === "Enter") { 47 | event.preventDefault(); 48 | onChange(labelText); 49 | divRef.current.blur(); 50 | } 51 | if (event.key === "Escape") { 52 | divRef.current.textContent = labelText; 53 | event.preventDefault(); 54 | event.stopPropagation(); 55 | } 56 | }; 57 | const onTextChange = (event: React.FormEvent) => { 58 | const value = event.currentTarget.innerText; 59 | setLabelText(value); 60 | }; 61 | 62 | return ( 63 |
73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /src/widgets/InlineInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect, useRef, useState } from "react"; 2 | import autosize from "../helpers/autosize"; 3 | import { InlineInput as _InlineInput } from "../styles/Base"; 4 | 5 | interface InlineInputProps { 6 | onSave?: (inputValue: string) => void; 7 | onCancel?: () => void; 8 | border?: boolean; 9 | placeholder?: string; 10 | value?: string; 11 | autoFocus?: boolean; 12 | resize?: "none" | "vertical" | "horizontal"; 13 | } 14 | 15 | export const InlineInput: FC = ({ 16 | autoFocus = false, 17 | border = false, 18 | onSave = () => {}, 19 | onCancel = () => {}, 20 | placeholder = "", 21 | value = "", 22 | resize = "none", 23 | }) => { 24 | const [inputValue, setInputValue] = useState(value); 25 | const inputRef = useRef(null); 26 | 27 | const onFocus = (e: React.FocusEvent) => 28 | e.target.select(); 29 | 30 | const onMouseDown = (e: React.MouseEvent) => { 31 | if (document.activeElement !== e.target) { 32 | e.preventDefault(); 33 | inputRef.current.focus(); 34 | } 35 | }; 36 | 37 | const onBlur = () => { 38 | updateValue(); 39 | }; 40 | 41 | const onKeyDown = (e: React.KeyboardEvent) => { 42 | if (e.key === "Enter") { 43 | inputRef.current.blur(); 44 | e.preventDefault(); 45 | } 46 | if (e.key === "Escape") { 47 | setValue(value); 48 | inputRef.current.blur(); 49 | e.preventDefault(); 50 | } 51 | if (e.key === "Tab") { 52 | if (inputValue.length === 0) { 53 | onCancel(); 54 | } 55 | inputRef.current.blur(); 56 | e.preventDefault(); 57 | } 58 | }; 59 | 60 | const getValue = () => inputRef.current.value || ""; 61 | const setValue = (newValue: string) => { 62 | if (inputRef.current) { 63 | inputRef.current.value = newValue; 64 | } 65 | }; 66 | 67 | const updateValue = () => { 68 | if (getValue() !== value) { 69 | onSave(getValue()); 70 | } 71 | }; 72 | 73 | const setRef = (ref: HTMLTextAreaElement) => { 74 | inputRef.current = ref; 75 | if (resize !== "none") { 76 | autosize(inputRef.current); 77 | } 78 | }; 79 | 80 | useEffect(() => { 81 | setInputValue(value); 82 | }, [value]); 83 | 84 | return ( 85 | <_InlineInput 86 | ref={setRef} 87 | border={border} 88 | onMouseDown={onMouseDown} 89 | onFocus={onFocus} 90 | onBlur={onBlur} 91 | onKeyDown={onKeyDown} 92 | placeholder={value.length === 0 ? undefined : placeholder} 93 | defaultValue={value} 94 | autoComplete="off" 95 | autoCorrect="off" 96 | autoCapitalize="off" 97 | spellCheck="false" 98 | rows={1} 99 | autoFocus={autoFocus} 100 | /> 101 | ); 102 | }; 103 | -------------------------------------------------------------------------------- /src/widgets/NewLaneTitleEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { HTMLAttributes, PropsWithChildren, useState } from "react"; 2 | import autosize from "../helpers/autosize"; 3 | import { InlineInput } from "../styles/Base"; 4 | 5 | interface NewLaneTitleEditorProps extends HTMLAttributes { 6 | onSave?: (inputValue: string) => void; 7 | onCancel?: () => void; 8 | border?: boolean; 9 | placeholder?: string; 10 | value?: string; 11 | autoFocus?: boolean; 12 | autoResize?: boolean; 13 | resize?: "vertical" | "horizontal" | "none"; 14 | inputRef: React.MutableRefObject; 15 | } 16 | export const NewLaneTitleEditor: React.FC< 17 | PropsWithChildren 18 | > = ({ 19 | autoFocus = false, 20 | border = false, 21 | onCancel = () => {}, 22 | onSave = () => {}, 23 | placeholder = "", 24 | resize = "none", 25 | value = "", 26 | inputRef, 27 | }) => { 28 | const [inputValue, setInputValue] = useState(value); 29 | const onKeyDown = (e: React.KeyboardEvent) => { 30 | if (e.key === "Enter") { 31 | inputRef.current.blur(); 32 | saveValue(); 33 | e.preventDefault(); 34 | } 35 | if (e.key === "Escape") { 36 | setInputValue(value); 37 | inputRef.current.blur(); 38 | cancel(); 39 | e.preventDefault(); 40 | } 41 | if (e.key === "Tab") { 42 | if (inputValue.length === 0) { 43 | onCancel(); 44 | } 45 | inputRef.current.blur(); 46 | e.preventDefault(); 47 | } 48 | }; 49 | const setRef = (ref: HTMLTextAreaElement) => { 50 | inputRef.current = ref; 51 | if (resize !== "none") { 52 | autosize(inputRef.current); 53 | } 54 | }; 55 | const cancel = () => { 56 | setInputValue(""); 57 | onCancel(); 58 | inputRef.current.blur(); 59 | }; 60 | const saveValue = () => { 61 | if (inputValue !== value) { 62 | onSave(inputValue); 63 | } 64 | }; 65 | return ( 66 | 76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/widgets/index.ts: -------------------------------------------------------------------------------- 1 | export { DeleteButton } from "./DeleteButton"; 2 | export { EditableLabel } from "./EditableLabel"; 3 | export { InlineInput } from "./InlineInput"; 4 | -------------------------------------------------------------------------------- /stories/AdvancedFeatures.story.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import { Meta, StoryObj } from "@storybook/react"; 4 | import Board from "../src"; 5 | 6 | import data from "./data/base.json"; 7 | import debug from "./helpers/debug"; 8 | Board.displayName = "Board"; 9 | 10 | class AsyncBoard extends Component { 11 | state = { 12 | boardData: { lanes: [{ id: "loading", title: "loading..", cards: [] }] }, 13 | }; 14 | 15 | componentDidMount() { 16 | setTimeout(this.getBoard.bind(this), 1000); 17 | } 18 | 19 | getBoard() { 20 | this.setState({ boardData: data }); 21 | } 22 | 23 | render() { 24 | return ; 25 | } 26 | } 27 | 28 | const meta: Meta = { 29 | title: "Advanced Features", 30 | component: Board, 31 | }; 32 | 33 | export default meta; 34 | type Story = StoryObj; 35 | 36 | export const AsyncLoad: Story = { 37 | render(args) { 38 | return ; 39 | }, 40 | }; 41 | 42 | export const CollapsibleLanes: Story = { 43 | args: { 44 | data, 45 | draggable: true, 46 | collapsibleLanes: true, 47 | onDataChange: (nextData) => { 48 | debug("data has changed"); 49 | debug(nextData); 50 | }, 51 | }, 52 | }; 53 | 54 | /** 55 | * This story implements onCardClick and onLaneClick handlers. 56 | * These handlers are called when a card or lane is clicked. 57 | * Try clicking on a card or lane to see an alert. 58 | */ 59 | export const EventHandling: Story = { 60 | name: "Event Handling", 61 | args: { 62 | data, 63 | draggable: true, 64 | onCardClick: (cardId, metadata, laneId) => 65 | alert( 66 | `Card with id:${cardId} clicked. Has metadata.id: ${metadata.id}. Card in lane: ${laneId}`, 67 | ), 68 | onLaneClick: (laneId) => alert(`Lane with id:${laneId} clicked`), 69 | }, 70 | }; 71 | -------------------------------------------------------------------------------- /stories/BasicFunctions.story.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from "@storybook/react"; 2 | import React from "react"; 3 | import Board from "../src"; 4 | 5 | import data from "./data/base.json"; 6 | import dataSortedLane from "./data/data-sort.json"; 7 | Board.displayName = "Board"; 8 | 9 | const meta: Meta = { 10 | title: "Basic Functions", 11 | parameters: { 12 | info: "A demonstration of onDragStart and onDragEnd hooks for card and lanes", 13 | }, 14 | component: Board, 15 | }; 16 | export default meta; 17 | type Story = StoryObj; 18 | 19 | export const FullBoardExample: Story = { 20 | args: { data }, 21 | }; 22 | 23 | export const SortedLane: Story = { 24 | args: { 25 | data: dataSortedLane, 26 | laneSortFunction: (cardA, cardB) => { 27 | const dateA = new Date(cardA.metadata.completedAt); 28 | const dateB = new Date(cardB.metadata.completedAt); 29 | return dateA.getTime() - dateB.getTime(); 30 | }, 31 | }, 32 | }; 33 | 34 | export const ReverseSortedLane: Story = { 35 | args: { 36 | data: dataSortedLane, 37 | laneSortFunction(cardA, cardB) { 38 | const dateA = new Date(cardA.metadata.completedAt); 39 | const dateB = new Date(cardB.metadata.completedAt); 40 | return dateB.getTime() - dateA.getTime(); 41 | }, 42 | }, 43 | }; 44 | 45 | const PER_PAGE = 15; 46 | function generateCards(requestedPage = 1) { 47 | const cards = []; 48 | const fetchedItems = (requestedPage - 1) * PER_PAGE; 49 | for (let i = fetchedItems + 1; i <= fetchedItems + PER_PAGE; i++) { 50 | cards.push({ 51 | id: `${i}`, 52 | title: `Card${i}`, 53 | description: `Description for #${i}`, 54 | }); 55 | } 56 | return cards; 57 | } 58 | function delayedPromise(durationInMs, resolutionPayload) { 59 | return new Promise((resolve) => { 60 | setTimeout(() => { 61 | resolve(resolutionPayload); 62 | }, durationInMs); 63 | }); 64 | } 65 | function paginate(requestedPage, laneId) { 66 | // simulate no more cards after page 2 67 | if (requestedPage > 2) { 68 | return delayedPromise(2000, []); 69 | } 70 | const newCards = generateCards(requestedPage); 71 | return delayedPromise(2000, newCards); 72 | } 73 | export const BasicFunctions: Story = { 74 | args: { 75 | data: { 76 | lanes: [ 77 | { 78 | id: "Lane1", 79 | title: "Lane1", 80 | cards: generateCards(), 81 | }, 82 | ], 83 | }, 84 | laneSortFunction: (card1, card2) => parseInt(card1.id) - parseInt(card2.id), 85 | onLaneScroll: paginate, 86 | }, 87 | }; 88 | 89 | export const Tags: Story = { 90 | name: "Tags", 91 | args: { 92 | data: { 93 | lanes: [ 94 | { 95 | id: "lane1", 96 | title: "Planned Tasks", 97 | cards: [ 98 | { 99 | id: "Card1", 100 | title: "Card1", 101 | description: "foo card", 102 | metadata: { cardId: "Card1" }, 103 | tags: [ 104 | { title: "High", color: "white", bgcolor: "#EB5A46" }, 105 | { title: "Tech Debt", color: "white", bgcolor: "#0079BF" }, 106 | { 107 | title: "Very long tag that is", 108 | color: "white", 109 | bgcolor: "#61BD4F", 110 | }, 111 | { title: "One more", color: "white", bgcolor: "#61BD4F" }, 112 | ], 113 | }, 114 | { 115 | id: "Card2", 116 | title: "Card2", 117 | description: "bar card", 118 | metadata: { cardId: "Card2" }, 119 | tags: [{ title: "Low" }], 120 | }, 121 | ], 122 | }, 123 | ], 124 | }, 125 | tagStyle: { fontSize: "80%" }, 126 | }, 127 | }; 128 | -------------------------------------------------------------------------------- /stories/CustomAddCardLink.story.tsx: -------------------------------------------------------------------------------- 1 | import { Meta } from "@storybook/react"; 2 | import React from "react"; 3 | 4 | import Board from "../src"; 5 | import { AddCardLinkComponent } from "../src/components/AddCardLink"; 6 | 7 | import data from "./data/collapsible.json"; 8 | Board.displayName = "Board"; 9 | 10 | export default ({ 11 | title: "Custom Components", 12 | component: () => { 13 | const CustomAddCardLink: AddCardLinkComponent = ({ onClick, t }) => ( 14 | 15 | ); 16 | return ( 17 | 22 | ); 23 | }, 24 | } satisfies Meta); 25 | 26 | export const AddCardLink = {}; 27 | -------------------------------------------------------------------------------- /stories/CustomCard.story.tsx: -------------------------------------------------------------------------------- 1 | import { Meta } from "@storybook/react"; 2 | import React, { CSSProperties } from "react"; 3 | import Board from "../src"; 4 | import { CardComponent } from "../src/components/Card"; 5 | import { Tag } from "../src/components/Card/Tag"; 6 | import { MovableCardWrapper } from "../src/styles/Base"; 7 | import { BoardData } from "../src/types/Board"; 8 | import { DeleteButton } from "./../src/widgets/DeleteButton"; 9 | Board.displayName = "Board"; 10 | 11 | const CustomCardExample: CardComponent<{ 12 | name: string; 13 | cardStyle: CSSProperties; 14 | body: string; 15 | dueOn: string; 16 | cardColor: CSSProperties["color"]; 17 | subTitle: string; 18 | escalationText: string; 19 | }> = ({ 20 | onClick, 21 | className, 22 | name, 23 | cardStyle, 24 | body, 25 | dueOn, 26 | cardColor, 27 | subTitle, 28 | tagStyle, 29 | escalationText, 30 | tags, 31 | showDeleteButton, 32 | onDelete, 33 | id, 34 | index, 35 | t, 36 | }) => { 37 | const clickDelete = (e) => { 38 | onDelete(); 39 | e.stopPropagation(); 40 | }; 41 | 42 | return ( 43 | 48 |
59 |
{name}
60 |
{dueOn}
61 | {showDeleteButton && } 62 |
63 |
64 |
{subTitle}
65 |
66 | {body} 67 |
68 |
77 | {escalationText} 78 |
79 | {tags && ( 80 |
90 | {tags.map((tag) => ( 91 | 92 | ))} 93 |
94 | )} 95 |
96 |
97 | ); 98 | }; 99 | 100 | const data: BoardData = { 101 | lanes: [ 102 | { 103 | id: "lane1", 104 | title: "Planned Tasks", 105 | label: "12/12", 106 | style: { backgroundColor: "cyan", padding: 20 }, 107 | titleStyle: { fontSize: 20, marginBottom: 15 }, 108 | labelStyle: { color: "#009688", fontWeight: "bold" }, 109 | cards: [ 110 | { 111 | id: "Card1", 112 | name: "John Smith", 113 | dueOn: "due in a day", 114 | subTitle: "SMS received at 12:13pm today", 115 | body: "Thanks. Please schedule me for an estimate on Monday.", 116 | escalationText: "Escalated to OPS-ESCALATIONS!", 117 | cardColor: "#BD3B36", 118 | cardStyle: { 119 | borderRadius: 6, 120 | boxShadow: "0 0 6px 1px #BD3B36", 121 | marginBottom: 15, 122 | }, 123 | metadata: { id: "Card1" }, 124 | }, 125 | { 126 | id: "Card2", 127 | name: "Card Weathers", 128 | dueOn: "due now", 129 | subTitle: "Email received at 1:14pm", 130 | body: "Is the estimate free, and can someone call me soon?", 131 | escalationText: "Escalated to Admin", 132 | cardColor: "#E08521", 133 | cardStyle: { 134 | borderRadius: 6, 135 | boxShadow: "0 0 6px 1px #E08521", 136 | marginBottom: 15, 137 | }, 138 | metadata: { id: "Card1" }, 139 | }, 140 | ], 141 | }, 142 | { 143 | id: "lane2", 144 | title: "Long Lane name this is i suppose ha!", 145 | cards: [ 146 | { 147 | id: "Card3", 148 | name: "Michael Caine", 149 | dueOn: "due in a day", 150 | subTitle: "Email received at 4:23pm today", 151 | body: 152 | "You are welcome. Interested in doing business with you" + " again", 153 | escalationText: "Escalated to OPS-ESCALATIONS!", 154 | cardColor: "#BD3B36", 155 | cardStyle: { 156 | borderRadius: 6, 157 | boxShadow: "0 0 6px 1px #BD3B36", 158 | marginBottom: 15, 159 | }, 160 | metadata: { id: "Card1" }, 161 | tags: [ 162 | { title: "Critical", color: "white", bgcolor: "red" }, 163 | { title: "2d ETA", color: "white", bgcolor: "#0079BF" }, 164 | ], 165 | }, 166 | ], 167 | }, 168 | ], 169 | }; 170 | 171 | export default ({ 172 | title: "Custom Components", 173 | component: () => { 174 | return ( 175 | 181 | alert( 182 | `Card with id:${cardId} clicked. Has metadata.id: ${metadata.id}`, 183 | ) 184 | } 185 | /> 186 | ); 187 | }, 188 | } satisfies Meta); 189 | 190 | export const Card: Meta = {}; 191 | -------------------------------------------------------------------------------- /stories/CustomCardWithDrag.story.tsx: -------------------------------------------------------------------------------- 1 | import { Meta } from "@storybook/react"; 2 | import React from "react"; 3 | 4 | import { MovableCardWrapper } from "../src/styles/Base"; 5 | import debug from "./helpers/debug"; 6 | 7 | import Board from "../src"; 8 | import { BoardData } from "../src/types/Board"; 9 | Board.displayName = "Board"; 10 | 11 | const CustomCard = (props) => { 12 | return ( 13 | 19 |
29 |
{props.name}
30 |
31 |
32 |
33 | {props.subTitle} 34 |
35 |
36 | {props.body} 37 |
38 |
39 |
40 | ); 41 | }; 42 | 43 | const customCardData = { 44 | lanes: [ 45 | { 46 | id: "lane1", 47 | title: "Planned", 48 | cards: [ 49 | { 50 | id: "Card1", 51 | name: "John Smith", 52 | subTitle: "SMS received at 12:13pm today", 53 | body: "Thanks. Please schedule me for an estimate on Monday.", 54 | metadata: { id: "Card1" }, 55 | }, 56 | { 57 | id: "Card2", 58 | name: "Card Weathers", 59 | subTitle: "Email received at 1:14pm", 60 | body: "Is the estimate free, and can someone call me soon?", 61 | metadata: { id: "Card1" }, 62 | }, 63 | ], 64 | }, 65 | { 66 | id: "lane2", 67 | title: "Work In Progress", 68 | cards: [ 69 | { 70 | id: "Card3", 71 | name: "Michael Caine", 72 | subTitle: "Email received at 4:23pm today", 73 | body: "You are welcome. Interested in doing business with you again", 74 | metadata: { id: "Card1" }, 75 | }, 76 | ], 77 | }, 78 | ], 79 | }; 80 | 81 | const BoardWithCustomCard = () => { 82 | const [boardData, setBoardData] = React.useState(customCardData); 83 | const onDragEnd = (cardId, sourceLandId, targetLaneId, index, card) => { 84 | debug("Calling onDragEnd"); 85 | 86 | // Create updated card without immer 87 | const updatedCard = { 88 | ...card, 89 | cardColor: "#d0fdd2", 90 | }; 91 | 92 | // Create updated board without immer 93 | const updatedBoard = { 94 | ...boardData, 95 | lanes: boardData.lanes.map((lane) => { 96 | // Source lane - remove the card 97 | if (lane.id === sourceLandId) { 98 | return { 99 | ...lane, 100 | cards: lane.cards?.filter((c) => c.id !== cardId) ?? [], 101 | }; 102 | } 103 | // Target lane - add the card at the specified index 104 | if (lane.id === targetLaneId) { 105 | const newCards = [...(lane.cards ?? [])]; 106 | newCards.splice(index, 0, updatedCard); 107 | return { 108 | ...lane, 109 | cards: newCards, 110 | }; 111 | } 112 | // Other lanes remain unchanged 113 | return lane; 114 | }), 115 | }; 116 | 117 | setBoardData(updatedBoard); 118 | }; 119 | return ( 120 | 126 | alert(`Card with id:${cardId} clicked. Has metadata.id: ${metadata.id}`) 127 | } 128 | components={{ Card: CustomCard }} 129 | /> 130 | ); 131 | }; 132 | 133 | export default ({ 134 | title: "Custom Components", 135 | component: BoardWithCustomCard, 136 | } satisfies Meta); 137 | 138 | export const DragnDropStyling = {}; 139 | -------------------------------------------------------------------------------- /stories/CustomLaneFooter.story.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from "@storybook/react"; 2 | import React from "react"; 3 | 4 | import Board from "../src"; 5 | import type { LaneFooterComponent } from "../src/components/Lane/LaneFooter"; 6 | 7 | import data from "./data/collapsible.json"; 8 | Board.displayName = "Board"; 9 | 10 | const LaneFooterComponent: LaneFooterComponent = ({ 11 | onClick, 12 | onKeyDown, 13 | collapsed, 14 | }) => ( 15 |
16 | {collapsed ? "click to expand" : "click to collapse"} 17 |
18 | ); 19 | 20 | export default ({ 21 | title: "Custom Components", 22 | component: Board, 23 | } satisfies Meta); 24 | type Story = StoryObj; 25 | export const LaneFooter: Story = { 26 | render: (args) => { 27 | return ( 28 | 33 | ); 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /stories/CustomLaneHeader.story.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, storiesOf } from "@storybook/react"; 2 | import React from "react"; 3 | 4 | import Board from "../src"; 5 | import { LaneHeaderProps } from "../src/components/Lane/LaneHeader"; 6 | Board.displayName = "Board"; 7 | 8 | const CustomLaneHeader = ({ 9 | label, 10 | cards, 11 | title, 12 | current, 13 | target, 14 | }: LaneHeaderProps) => { 15 | const buttonHandler = () => { 16 | alert( 17 | `The label passed to the lane was: ${label}. The lane has ${cards.length} cards!`, 18 | ); 19 | }; 20 | return ( 21 |
22 |
32 |
{title}
33 | {label && ( 34 |
35 | 42 |
43 | )} 44 |
45 |
46 | Percentage: {current || 0}/{target} 47 |
48 |
49 | ); 50 | }; 51 | 52 | export default ({ 53 | title: "Custom Components", 54 | component: () => { 55 | const data = { 56 | lanes: [ 57 | { 58 | id: "lane1", 59 | title: "Planned Tasks", 60 | current: "70", // custom property 61 | target: "100", // custom property 62 | label: "First Lane here", 63 | cards: [ 64 | { 65 | id: "Card1", 66 | title: "John Smith", 67 | description: 68 | "Thanks. Please schedule me for an estimate on Monday.", 69 | }, 70 | { 71 | id: "Card2", 72 | title: "Card Weathers", 73 | description: "Email received at 1:14pm", 74 | }, 75 | ], 76 | }, 77 | { 78 | id: "lane2", 79 | title: "Completed Tasks", 80 | label: "Second Lane here", 81 | current: "30", // custom property 82 | target: "100", // custom property 83 | cards: [ 84 | { 85 | id: "Card3", 86 | title: "Michael Caine", 87 | description: 88 | "You are welcome. Interested in doing business with you" + 89 | " again", 90 | tags: [ 91 | { title: "Critical", color: "white", bgcolor: "red" }, 92 | { title: "2d ETA", color: "white", bgcolor: "#0079BF" }, 93 | ], 94 | }, 95 | ], 96 | }, 97 | ], 98 | }; 99 | 100 | return ; 101 | }, 102 | } satisfies Meta); 103 | 104 | export const LaneHeader = {}; 105 | -------------------------------------------------------------------------------- /stories/CustomNewCardForm.story.tsx: -------------------------------------------------------------------------------- 1 | import { Meta } from "@storybook/react"; 2 | import React, { Component } from "react"; 3 | 4 | import Board from "../src"; 5 | import { NewCardFormProps } from "../src/components/NewCardForm"; 6 | 7 | import data from "./data/base.json"; 8 | Board.displayName = "Board"; 9 | 10 | class NewCardFormComponent extends Component { 11 | private titleRef: any; 12 | private descRef: any; 13 | handleAdd = () => 14 | this.props.onAdd({ 15 | label: "", 16 | laneId: this.props.laneId, 17 | title: this.titleRef.value, 18 | description: this.descRef.value, 19 | }); 20 | setTitleRef = (ref) => (this.titleRef = ref); 21 | setDescRef = (ref) => (this.descRef = ref); 22 | render() { 23 | const { onCancel } = this.props; 24 | return ( 25 |
33 |
34 |
35 |
36 | 37 |
38 |
39 | 44 |
45 |
46 | 47 | 48 |
49 |
50 | ); 51 | } 52 | } 53 | 54 | export default ({ 55 | title: "Custom Components", 56 | component: () => ( 57 | 63 | ), 64 | } satisfies Meta); 65 | 66 | export const NewCardForm = {}; 67 | -------------------------------------------------------------------------------- /stories/CustomNewLaneForm.story.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import Board from "../src"; 4 | 5 | import data from "./data/data-sort.json"; 6 | Board.displayName = "Board"; 7 | 8 | class NewLaneFormComponent extends Component { 9 | render() { 10 | const { onCancel, t } = this.props; 11 | const handleAdd = () => this.props.onAdd({ title: this.inputRef.value }); 12 | const setInputRef = (ref) => (this.inputRef = ref); 13 | return ( 14 |
15 | 16 | 17 | 18 |
19 | ); 20 | } 21 | } 22 | 23 | export default { 24 | title: "Custom Components", 25 | component: () => ( 26 | 32 | ), 33 | }; 34 | 35 | export const NewLaneForm = {}; 36 | -------------------------------------------------------------------------------- /stories/CustomNewLaneSection.story.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Board from "../src"; 4 | 5 | import data from "./data/data-sort.json"; 6 | Board.displayName = "Board"; 7 | 8 | const NewLaneSectionComponent = ({ t, onClick }) => ( 9 | 10 | ); 11 | 12 | export default { 13 | title: "Custom Components", 14 | component: () => ( 15 | 21 | ), 22 | }; 23 | 24 | export const NewLaneSection = {}; 25 | -------------------------------------------------------------------------------- /stories/Deprecations.story.tsx: -------------------------------------------------------------------------------- 1 | import { Meta } from "@storybook/react"; 2 | // @ts-nocheck 3 | import React, { Component } from "react"; 4 | 5 | import Board from "../src"; 6 | 7 | import { CardProps } from "../src/components/Card"; 8 | import { Tag } from "../src/components/Card/Tag"; 9 | import "./board.css"; 10 | Board.displayName = "Board"; 11 | 12 | import data from "./data/base.json"; 13 | 14 | const CustomLaneHeader = (props) => { 15 | const buttonHandler = () => { 16 | alert( 17 | `The label passed to the lane was: ${props.label}. The lane has ${props.cards.length} cards!`, 18 | ); 19 | }; 20 | return ( 21 |
22 |
32 |
{props.title}
33 | {props.label && ( 34 |
35 | 38 |
39 | )} 40 |
41 |
42 | ); 43 | }; 44 | 45 | class NewCard extends Component { 46 | updateField = (field, evt) => { 47 | this.setState({ [field]: evt.target.value }); 48 | }; 49 | 50 | handleAdd = () => { 51 | this.props.onAdd(this.state); 52 | }; 53 | 54 | render() { 55 | const { onCancel } = this.props; 56 | return ( 57 |
65 |
66 |
67 |
68 | this.updateField("title", evt)} 71 | placeholder="Title" 72 | /> 73 |
74 |
75 | this.updateField("description", evt)} 78 | placeholder="Description" 79 | /> 80 |
81 |
82 | 83 | 84 |
85 |
86 | ); 87 | } 88 | } 89 | const CustomCard = (props) => { 90 | return ( 91 |
92 |
103 |
{props.name}
104 |
{props.dueOn}
105 |
106 |
107 |
108 | {props.subTitle} 109 |
110 |
111 | {props.body} 112 |
113 |
122 | {props.escalationText} 123 |
124 | {props.tags && ( 125 |
135 | {props.tags.map((tag) => ( 136 | 137 | ))} 138 |
139 | )} 140 |
141 |
142 | ); 143 | }; 144 | 145 | export default ({ 146 | title: "Deprecation warnings", 147 | component: () => ( 148 | New Card} 152 | customLaneHeader={} 153 | newLaneTemplate={
new lane
} 154 | newCardTemplate={} 155 | customCardLayout={true} 156 | > 157 | 158 |
159 | ), 160 | } satisfies Meta); 161 | 162 | export const v2_2_warnings = {}; 163 | -------------------------------------------------------------------------------- /stories/DragDrop.story.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from "@storybook/react"; 2 | import React from "react"; 3 | import debug from "./helpers/debug"; 4 | 5 | import Board from "../src"; 6 | 7 | import data from "./data/base.json"; 8 | 9 | export default ({ 10 | title: "Drag-n-Drop", 11 | component: Board, 12 | parameters: { 13 | info: "A demonstration of onDragStart and onDragEnd hooks for card and lanes", 14 | }, 15 | } satisfies Meta); 16 | type Story = StoryObj; 17 | Board.displayName = "Board"; 18 | 19 | export const DragNDrop: Story = { 20 | name: "Basic", 21 | render: (args) => { 22 | const handleDragStart = (cardId, laneId) => { 23 | debug("drag started"); 24 | debug(`cardId: ${cardId}`); 25 | debug(`laneId: ${laneId}`); 26 | }; 27 | 28 | const handleDragEnd = ( 29 | cardId, 30 | sourceLaneId, 31 | targetLaneId, 32 | position, 33 | card, 34 | ) => { 35 | debug("drag ended"); 36 | debug(`cardId: ${cardId}`); 37 | debug(`sourceLaneId: ${sourceLaneId}`); 38 | debug(`targetLaneId: ${targetLaneId}`); 39 | debug(`newPosition: ${position}`); 40 | debug("cardDetails:"); 41 | debug(card); 42 | }; 43 | 44 | const handleLaneDragStart = (laneId) => { 45 | debug(`lane drag started for ${laneId}`); 46 | }; 47 | 48 | const handleLaneDragEnd = (removedIndex, addedIndex, { id }) => { 49 | debug(`lane drag ended from position ${removedIndex} for laneId=${id}`); 50 | debug(`New lane position: ${addedIndex}`); 51 | }; 52 | 53 | const shouldReceiveNewData = (nextData) => { 54 | debug("data has changed"); 55 | debug(nextData); 56 | }; 57 | 58 | const onCardMoveAcrossLanes = ( 59 | fromLaneId, 60 | toLaneId, 61 | cardId, 62 | addedIndex, 63 | ) => { 64 | debug( 65 | `onCardMoveAcrossLanes: ${fromLaneId}, ${toLaneId}, ${cardId}, ${addedIndex}`, 66 | ); 67 | }; 68 | 69 | return ( 70 | 81 | ); 82 | }, 83 | }; 84 | 85 | export const DragStyling: Story = { 86 | render: (args) => ( 87 | 93 | ), 94 | }; 95 | -------------------------------------------------------------------------------- /stories/EditableBoard.story.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj, storiesOf } from "@storybook/react"; 2 | import React, { Component } from "react"; 3 | import debug from "./helpers/debug"; 4 | 5 | import Board from "../src"; 6 | 7 | import data from "./data/base.json"; 8 | import smallData from "./data/data-sort.json"; 9 | 10 | const disallowAddingCardData = { ...data }; 11 | disallowAddingCardData.lanes[0].title = "Disallowed adding card"; 12 | disallowAddingCardData.lanes[0].disallowAddingCard = true; 13 | Board.displayName = "Board"; 14 | 15 | export default ({ 16 | title: "Editable Board", 17 | component: Board, 18 | } satisfies Meta); 19 | type Story = StoryObj; 20 | export const AddDeleteCards: Story = { 21 | name: "Add/Delete Cards", 22 | render: (args) => { 23 | const shouldReceiveNewData = (nextData) => { 24 | debug("Board has changed"); 25 | debug(nextData); 26 | }; 27 | 28 | const handleCardDelete = (cardId, laneId) => { 29 | debug(`Card: ${cardId} deleted from lane: ${laneId}`); 30 | }; 31 | 32 | const handleCardAdd = (card, laneId) => { 33 | debug(`New card added to lane ${laneId}`); 34 | debug(card); 35 | }; 36 | 37 | return ( 38 | 46 | alert(`Card with id:${cardId} clicked. Card in lane: ${laneId}`) 47 | } 48 | editable={true} 49 | /> 50 | ); 51 | }, 52 | }; 53 | 54 | export const AddNewLane: Story = { 55 | name: "Add New Lane", 56 | render: (args) => { 57 | return ( 58 | debug(`You added a line with title ${t.title}`)} 63 | /> 64 | ); 65 | }, 66 | }; 67 | 68 | export const DisallowAddingCardForSpecificLane: Story = { 69 | name: "Disallow Adding Card for specific Lane", 70 | render: (args) => { 71 | return ; 72 | }, 73 | }; 74 | 75 | export const InlineEditLaneTitleAndCards: Story = { 76 | render: (args) => { 77 | return ( 78 | 84 | debug(`onCardUpdate: ${cardId} -> ${JSON.stringify(data, null, 2)}`) 85 | } 86 | onLaneUpdate={(laneId, data) => 87 | debug(`onLaneUpdate: ${laneId} -> ${data.title}`) 88 | } 89 | onLaneAdd={(t) => debug(`You added a line with title ${t.title}`)} 90 | /> 91 | ); 92 | }, 93 | }; 94 | -------------------------------------------------------------------------------- /stories/I18n.story.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from "@storybook/react"; 2 | import React from "react"; 3 | import { I18nextProvider, useTranslation } from "react-i18next"; 4 | 5 | import Board from "../src"; 6 | import createTranslate from "../src/helpers/createTranslate"; 7 | 8 | import smallData from "./data/data-sort.json"; 9 | import i18n from "./helpers/i18n"; 10 | Board.displayName = "Board"; 11 | 12 | const I18nBoard = () => { 13 | const { t } = useTranslation(); 14 | return ( 15 |
16 |
17 | 18 | 19 |
20 | 27 |
28 | ); 29 | }; 30 | 31 | export default ({ 32 | title: "I18n", 33 | component: Board, 34 | } satisfies Meta); 35 | type Story = StoryObj; 36 | export const CustomTexts: Story = { 37 | name: "Custom texts", 38 | render: (args) => { 39 | const TEXTS = { 40 | "Add another lane": "NEW LANE", 41 | "Click to add card": "Click to add card", 42 | "Delete lane": "Delete lane", 43 | "Lane actions": "Lane actions", 44 | button: { 45 | "Add lane": "Add lane", 46 | "Add card": "Add card", 47 | Cancel: "Cancel", 48 | }, 49 | placeholder: { 50 | title: "title", 51 | description: "description", 52 | label: "label", 53 | }, 54 | }; 55 | 56 | const customTranslation = createTranslate(TEXTS); 57 | return ( 58 | 65 | ); 66 | }, 67 | }; 68 | 69 | export const FlatTranslationTable: Story = { 70 | name: "Flat translation table", 71 | render: (args) => { 72 | const FLAT_TRANSLATION_TABLE = { 73 | "Add another lane": "+ Weitere Liste erstellen", 74 | "Click to add card": "Klicken zum Erstellen einer Karte", 75 | "Delete lane": "Liste löschen", 76 | "Lane actions": "Listenaktionen", 77 | "button.Add lane": "Liste hinzufügen", 78 | "button.Add card": "Karte hinzufügen", 79 | "button.Cancel": "Abbrechen", 80 | "placeholder.title": "Titel", 81 | "placeholder.description": "Beschreibung", 82 | "placeholder.label": "Label", 83 | }; 84 | 85 | return ( 86 | FLAT_TRANSLATION_TABLE[key]} 89 | editable={true} 90 | canAddLanes={true} 91 | draggable={true} 92 | /> 93 | ); 94 | }, 95 | }; 96 | 97 | export const UsingI18next: Story = { 98 | name: "Using i18next", 99 | render: (args) => { 100 | return ( 101 | 102 | 103 | 104 | ); 105 | }, 106 | }; 107 | -------------------------------------------------------------------------------- /stories/MultipleBoards.story.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from "@storybook/react"; 2 | import React from "react"; 3 | 4 | import Board from "../src"; 5 | 6 | Board.displayName = "Board"; 7 | 8 | import data1 from "./data/base.json"; 9 | import data2 from "./data/other-board.json"; 10 | 11 | const containerStyles = { 12 | height: 500, 13 | padding: 20, 14 | }; 15 | 16 | export default ({ 17 | title: "Multiple Boards", 18 | tags: ["autodocs"], 19 | component: Board, 20 | } satisfies Meta); 21 | type Story = StoryObj; 22 | export const MultipleBoards: Story = { 23 | render: (args) => { 24 | return ( 25 |
26 |
27 | 28 |
29 |
30 | 31 |
32 |
33 | ); 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /stories/PaginationAndEvents.story.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj, storiesOf } from "@storybook/react"; 2 | import React, { FC, PropsWithChildren } from "react"; 3 | 4 | import Board from "../src"; 5 | import { BoardData } from "../src/types/Board"; 6 | import { EventBusHandle } from "../src/types/EventBus"; 7 | 8 | const PER_PAGE = 15; 9 | Board.displayName = "Board"; 10 | 11 | function generateCards(requestedPage = 1) { 12 | const cards = []; 13 | const fetchedItems = (requestedPage - 1) * PER_PAGE; 14 | for (let i = fetchedItems + 1; i <= fetchedItems + PER_PAGE; i++) { 15 | cards.push({ 16 | id: `${i}`, 17 | title: `Card${i}`, 18 | description: `Description for #${i}`, 19 | metadata: { cardId: `${i}` }, 20 | }); 21 | } 22 | return cards; 23 | } 24 | const delayedPromise = (durationInMs, resolutionPayload) => { 25 | return new Promise((resolve) => { 26 | setTimeout(() => { 27 | resolve(resolutionPayload); 28 | }, durationInMs); 29 | }); 30 | }; 31 | const BoardWrapper: FC> = ({ 32 | data, 33 | children, 34 | }) => { 35 | const [boardData, setBoardData] = React.useState(data); 36 | const [eventBus, setEventBus] = React.useState(); 37 | const onDataChange = (newData: BoardData) => { 38 | setBoardData(newData); 39 | }; 40 | const refreshCards = () => { 41 | setBoardData({ 42 | lanes: [ 43 | { 44 | id: "Lane1", 45 | title: "Changed Lane", 46 | cards: [], 47 | }, 48 | ], 49 | }); 50 | }; 51 | const addCard = () => { 52 | eventBus.publish({ 53 | type: "ADD_CARD", 54 | laneId: "Lane1", 55 | card: { 56 | id: "000", 57 | title: "EC2 Instance Down", 58 | label: "30 mins", 59 | description: "Main EC2 instance down", 60 | metadata: { cardId: "000" }, 61 | }, 62 | }); 63 | }; 64 | const paginate = (requestedPage, laneId) => { 65 | const newCards = generateCards(requestedPage); 66 | return delayedPromise(2000, newCards); 67 | }; 68 | 69 | return ( 70 |
71 | 74 | 77 | setEventBus(eventBus)} 80 | laneSortFunction={(card1, card2) => 81 | parseInt(card1.id) - parseInt(card2.id) 82 | } 83 | onLaneScroll={paginate} 84 | onDataChange={onDataChange} 85 | /> 86 |
87 | ); 88 | }; 89 | 90 | export default ({ 91 | title: "Advanced Features", 92 | component: Board, 93 | } satisfies Meta); 94 | 95 | type Story = StoryObj; 96 | 97 | export const ScrollingAndEvents: Story = { 98 | name: "Scrolling and Events", 99 | render: (args) => { 100 | const data = { 101 | lanes: [ 102 | { 103 | id: "Lane1", 104 | title: "Lane1", 105 | cards: generateCards(), 106 | }, 107 | ], 108 | }; 109 | 110 | return ; 111 | }, 112 | }; 113 | -------------------------------------------------------------------------------- /stories/Realtime.story.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, storiesOf } from "@storybook/react"; 2 | import React, { Component } from "react"; 3 | import debug from "./helpers/debug"; 4 | 5 | import Board from "../src"; 6 | 7 | import data from "./data/base.json"; 8 | Board.displayName = "Board"; 9 | 10 | class RealtimeBoard extends Component { 11 | state = { boardData: data, eventBus: undefined }; 12 | 13 | setEventBus = (handle) => { 14 | this.state.eventBus = handle; 15 | }; 16 | 17 | completeMilkEvent = () => { 18 | this.state.eventBus.publish({ 19 | type: "REMOVE_CARD", 20 | laneId: "PLANNED", 21 | cardId: "Milk", 22 | }); 23 | this.state.eventBus.publish({ 24 | type: "ADD_CARD", 25 | laneId: "COMPLETED", 26 | card: { 27 | id: "Milk", 28 | title: "Buy Milk", 29 | label: "15 mins", 30 | description: "Use Headspace app", 31 | }, 32 | }); 33 | }; 34 | 35 | addBlockedEvent = () => { 36 | this.state.eventBus.publish({ 37 | type: "ADD_CARD", 38 | laneId: "BLOCKED", 39 | card: { 40 | id: "Ec2Error", 41 | title: "EC2 Instance Down", 42 | label: "30 mins", 43 | description: "Main EC2 instance down", 44 | }, 45 | }); 46 | }; 47 | 48 | modifyLaneTitle = () => { 49 | const data = { 50 | ...this.state.boardData, 51 | lanes: this.state.boardData.lanes.map((lane, index) => { 52 | if (index === 1) { 53 | return { ...lane, title: "New Lane Title" }; 54 | } 55 | return lane; 56 | }), 57 | }; 58 | 59 | this.setState({ boardData: data }); 60 | }; 61 | 62 | modifyCardTitle = () => { 63 | const data = { 64 | ...this.state.boardData, 65 | lanes: this.state.boardData.lanes.map((lane, index) => { 66 | if (index === 1) { 67 | return { 68 | ...lane, 69 | cards: lane.cards.map((card, index) => { 70 | if (index === 0) { 71 | return { ...card, title: "New Card Title" }; 72 | } 73 | return card; 74 | }), 75 | }; 76 | } 77 | return lane; 78 | }), 79 | }; 80 | this.setState({ boardData: data }); 81 | }; 82 | 83 | updateCard = () => { 84 | this.state.eventBus.publish({ 85 | type: "UPDATE_CARD", 86 | laneId: "PLANNED", 87 | card: { 88 | id: "Plan2", 89 | title: "UPDATED Dispose Garbage", 90 | label: "45 mins", 91 | description: "UPDATED Sort out recyclable and waste as needed", 92 | }, 93 | }); 94 | }; 95 | 96 | prioritizeWriteBlog = () => { 97 | this.state.eventBus.publish({ 98 | type: "MOVE_CARD", 99 | fromLaneId: "PLANNED", 100 | toLaneId: "WIP", 101 | cardId: "Plan3", 102 | index: 0, 103 | }); 104 | }; 105 | 106 | shouldReceiveNewData = (nextData) => { 107 | debug("data has changed"); 108 | debug(nextData); 109 | }; 110 | 111 | addCardWithIndex = () => { 112 | this.state.eventBus.publish({ 113 | type: "ADD_CARD", 114 | laneId: "WIP", 115 | card: { 116 | id: "AddWithIndex", 117 | title: "Urgent Task", 118 | label: "5 mins", 119 | description: "Everything is down", 120 | }, 121 | index: 0, 122 | }); 123 | }; 124 | 125 | render() { 126 | return ( 127 |
128 | 131 | 134 | 137 | 140 | 143 | 146 | 149 | 154 |
155 | ); 156 | } 157 | } 158 | 159 | export default ({ 160 | title: "Advanced Features", 161 | component: Board, 162 | } satisfies Meta); 163 | 164 | export const RealtimeEvents = { 165 | name: "Realtime Events", 166 | render: (args) => , 167 | }; 168 | -------------------------------------------------------------------------------- /stories/RestrictedLanes.story.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj, storiesOf } from "@storybook/react"; 2 | import React from "react"; 3 | 4 | import Board from "../src"; 5 | 6 | import data from "./data/drag-drop.json"; 7 | Board.displayName = "Board"; 8 | 9 | export default ({ 10 | title: "Drag-n-Drop", 11 | component: Board, 12 | parameters: { 13 | info: "A demonstration of onDragStart and onDragEnd hooks for card and lanes", 14 | }, 15 | } satisfies Meta); 16 | 17 | type Story = StoryObj; 18 | 19 | export const RestrictLanes: Story = { 20 | name: "Restrict lanes", 21 | render: (args) => { 22 | return ; 23 | }, 24 | }; 25 | 26 | export const DragCardsNotLanes: Story = { 27 | name: "Drag Cards not Lanes", 28 | render: (args) => { 29 | return ; 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /stories/Styling.story.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from "@storybook/react"; 2 | import React from "react"; 3 | 4 | import Board from "../src"; 5 | import { BoardData } from "../src/types/Board"; 6 | import "./board.css"; 7 | import data from "./data/base.json"; 8 | Board.displayName = "Board"; 9 | 10 | const dataWithLaneStyles = { 11 | lanes: [ 12 | { 13 | id: "PLANNED", 14 | title: "Planned Tasks", 15 | label: "20/70", 16 | style: { 17 | width: 280, 18 | backgroundColor: "#3179ba", 19 | color: "#fff", 20 | boxShadow: "2px 2px 4px 0px rgba(0,0,0,0.75)", 21 | }, 22 | cards: [ 23 | { 24 | id: "Milk", 25 | title: "Buy milk", 26 | label: "15 mins", 27 | description: "2 Gallons of milk at the Deli store", 28 | }, 29 | { 30 | id: "Plan2", 31 | title: "Dispose Garbage", 32 | label: "10 mins", 33 | description: "Sort out recyclable and waste as needed", 34 | }, 35 | ], 36 | }, 37 | { 38 | id: "DONE", 39 | title: "Doned tasks", 40 | label: "10/70", 41 | style: { 42 | width: 280, 43 | backgroundColor: "#ba7931", 44 | color: "#fff", 45 | boxShadow: "2px 2px 4px 0px rgba(0,0,0,0.75)", 46 | }, 47 | cards: [ 48 | { 49 | id: "burn", 50 | title: "Burn Garbage", 51 | label: "10 mins", 52 | description: "Sort out recyclable and waste as needed", 53 | }, 54 | ], 55 | }, 56 | { 57 | id: "ARCHIVE", 58 | title: "Archived tasks", 59 | label: "1/2", 60 | cards: [ 61 | { 62 | id: "archived", 63 | title: "Archived", 64 | label: "10 mins", 65 | }, 66 | ], 67 | }, 68 | ], 69 | }; 70 | 71 | const dataWithCardStyles: BoardData = { 72 | lanes: [ 73 | { 74 | id: "PLANNED", 75 | title: "Planned Tasks", 76 | label: "20/70", 77 | cards: [ 78 | { 79 | id: "Milk", 80 | title: "Buy milk", 81 | label: "15 mins", 82 | description: "2 Gallons of milk at the Deli store", 83 | style: { backgroundColor: "#eec" }, 84 | }, 85 | { 86 | id: "Plan2", 87 | title: "Dispose Garbage", 88 | label: "10 mins", 89 | description: "Sort out recyclable and waste as needed", 90 | }, 91 | { 92 | id: "Plan3", 93 | title: "Burn Garbage", 94 | label: "20 mins", 95 | }, 96 | ], 97 | }, 98 | ], 99 | }; 100 | 101 | export default ({ 102 | title: "Styling", 103 | component: Board, 104 | } satisfies Meta); 105 | 106 | type Story = StoryObj; 107 | 108 | export const BoardStyling: Story = { 109 | name: "Board Styling", 110 | render: (args) => ( 111 | 116 | ), 117 | }; 118 | 119 | export const LaneStyling: Story = { 120 | name: "Lane Styling", 121 | render: (args) => ( 122 | 127 | ), 128 | }; 129 | 130 | export const CardStyling: Story = { 131 | name: "Card Styling", 132 | render: (args) => ( 133 | 134 | ), 135 | }; 136 | -------------------------------------------------------------------------------- /stories/__snapshots__/CustomCard.story.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Custom Components Card smoke-test 1`] = ` 4 |
7 |
8 |
9 |
10 |
14 |
15 | 18 | Planned Tasks 19 | 20 | 21 | 22 | 12/12 23 | 24 | 25 |
26 |
27 |
28 |
29 |
32 |
33 |
34 | John Smith 35 |
36 |
37 | due in a day 38 |
39 |
40 | 43 |
44 |
45 |
46 |
47 | SMS received at 12:13pm today 48 |
49 |
50 | 51 | Thanks. Please schedule me for an estimate on Monday. 52 | 53 |
54 |
55 | Escalated to OPS-ESCALATIONS! 56 |
57 |
58 |
59 |
60 |
61 |
64 |
65 |
66 | Card Weathers 67 |
68 |
69 | due now 70 |
71 |
72 | 75 |
76 |
77 |
78 |
79 | Email received at 1:14pm 80 |
81 |
82 | 83 | Is the estimate free, and can someone call me soon? 84 | 85 |
86 |
87 | Escalated to Admin 88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
100 |
101 | 102 | Long Lane name this is i suppose ha! 103 | 104 |
105 |
106 |
107 |
108 |
111 |
112 |
113 | Michael Caine 114 |
115 |
116 | due in a day 117 |
118 |
119 | 122 |
123 |
124 |
125 |
126 | Email received at 4:23pm today 127 |
128 |
129 | 130 | You are welcome. Interested in doing business with you again 131 | 132 |
133 |
134 | Escalated to OPS-ESCALATIONS! 135 |
136 |
137 | 140 | Critical 141 | 142 | 145 | 2d ETA 146 | 147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 | `; 159 | -------------------------------------------------------------------------------- /stories/__snapshots__/CustomCardWithDrag.story.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Custom Components DragnDropStyling smoke-test 1`] = ` 4 |
7 |
8 |
9 |
10 |
13 |
14 | 15 | Planned 16 | 17 |
18 |
19 |
20 |
21 |
25 |
26 |
27 | John Smith 28 |
29 |
30 |
31 |
32 | SMS received at 12:13pm today 33 |
34 |
35 | 36 | Thanks. Please schedule me for an estimate on Monday. 37 | 38 |
39 |
40 |
41 |
42 |
43 |
47 |
48 |
49 | Card Weathers 50 |
51 |
52 |
53 |
54 | Email received at 1:14pm 55 |
56 |
57 | 58 | Is the estimate free, and can someone call me soon? 59 | 60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
72 |
73 | 74 | Work In Progress 75 | 76 |
77 |
78 |
79 |
80 |
84 |
85 |
86 | Michael Caine 87 |
88 |
89 |
90 |
91 | Email received at 4:23pm today 92 |
93 |
94 | 95 | You are welcome. Interested in doing business with you again 96 | 97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 | `; 109 | -------------------------------------------------------------------------------- /stories/__snapshots__/CustomLaneHeader.story.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Custom Components LaneHeader smoke-test 1`] = ` 4 |
7 |
8 |
9 |
13 |
14 |
15 |
16 | Planned Tasks 17 |
18 |
19 | 24 |
25 |
26 |
27 | Percentage: 0/ 28 |
29 |
30 |
31 |
32 |
33 |
36 |
37 | 40 | John Smith 41 | 42 | 43 | 44 |
45 | 48 |
49 |
50 |
51 | Thanks. Please schedule me for an estimate on Monday. 52 |
53 |
54 |
55 |
56 |
59 |
60 | 63 | Card Weathers 64 | 65 | 66 | 67 |
68 | 71 |
72 |
73 |
74 | Email received at 1:14pm 75 |
76 |
77 |
78 |
79 |
80 |
81 |
85 |
86 |
87 |
88 | Completed Tasks 89 |
90 |
91 | 96 |
97 |
98 |
99 | Percentage: 0/ 100 |
101 |
102 |
103 |
104 |
105 |
108 |
109 | 112 | Michael Caine 113 | 114 | 115 | 116 |
117 | 120 |
121 |
122 |
123 | You are welcome. Interested in doing business with you again 124 |
125 |
126 | 129 | Critical 130 | 131 | 134 | 2d ETA 135 | 136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 | `; 146 | -------------------------------------------------------------------------------- /stories/__snapshots__/CustomNewLaneForm.story.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Custom Components NewLaneForm smoke-test 1`] = ` 4 |
7 |
8 |
9 |
12 |
13 | 14 | Sorted Lane 15 | 16 | 17 | 18 | 20/70 19 | 20 | 21 |
22 |
23 |
24 |
25 |
28 |
29 | 32 | 43 | 44 | 45 | 56 | 57 |
58 | 61 |
62 |
63 |
64 | 75 |
76 |
77 |
78 |
79 |
82 |
83 | 86 | 97 | 98 | 99 | 110 | 111 |
112 | 115 |
116 |
117 |
118 | 129 |
130 |
131 |
132 |
133 |
136 |
137 | 140 | 151 | 152 | 153 | 164 | 165 |
166 | 169 |
170 |
171 |
172 | 183 |
184 |
185 |
186 |
187 |
190 |
191 | 194 | 205 | 206 | 207 | 218 | 219 |
220 | 223 |
224 |
225 |
226 | 237 |
238 |
239 |
240 |
241 | 242 | Click to add card 243 | 244 |
245 |
246 |
247 |
248 |
249 |
250 | 253 |
254 |
255 |
256 | `; 257 | -------------------------------------------------------------------------------- /stories/__snapshots__/CustomNewLaneSection.story.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Custom Components NewLaneSection smoke-test 1`] = ` 4 |
7 |
8 |
9 |
12 |
13 | 14 | Sorted Lane 15 | 16 | 17 | 18 | 20/70 19 | 20 | 21 |
22 |
23 |
24 |
25 |
28 |
29 | 32 | 43 | 44 | 45 | 56 | 57 |
58 | 61 |
62 |
63 |
64 | 75 |
76 |
77 |
78 |
79 |
82 |
83 | 86 | 97 | 98 | 99 | 110 | 111 |
112 | 115 |
116 |
117 |
118 | 129 |
130 |
131 |
132 |
133 |
136 |
137 | 140 | 151 | 152 | 153 | 164 | 165 |
166 | 169 |
170 |
171 |
172 | 183 |
184 |
185 |
186 |
187 |
190 |
191 | 194 | 205 | 206 | 207 | 218 | 219 |
220 | 223 |
224 |
225 |
226 | 237 |
238 |
239 |
240 |
241 | 242 | Click to add card 243 | 244 |
245 |
246 |
247 |
248 |
249 | 252 |
253 |
254 | `; 255 | -------------------------------------------------------------------------------- /stories/board.css: -------------------------------------------------------------------------------- 1 | .boardContainer { 2 | background-color: #4BBF6B; 3 | } -------------------------------------------------------------------------------- /stories/data/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "lanes": [ 3 | { 4 | "id": "PLANNED", 5 | "title": "Planned Tasks", 6 | "label": "20/70", 7 | "style": { "width": 280 }, 8 | "cards": [ 9 | { 10 | "id": "Milk", 11 | "title": "Buy milk", 12 | "label": "15 mins", 13 | "description": "2 Gallons of milk at the Deli store" 14 | }, 15 | { 16 | "id": "Plan2", 17 | "title": "Dispose Garbage", 18 | "label": "10 mins", 19 | "description": "Sort out recyclable and waste as needed" 20 | }, 21 | { 22 | "id": "Plan3", 23 | "title": "Write Blog", 24 | "label": "30 mins", 25 | "description": "Can AI make memes?" 26 | }, 27 | { 28 | "id": "Plan4", 29 | "title": "Pay Rent", 30 | "label": "5 mins", 31 | "description": "Transfer to bank account" 32 | } 33 | ] 34 | }, 35 | { 36 | "id": "WIP", 37 | "title": "Work In Progress", 38 | "label": "10/20", 39 | "style": { "width": 280 }, 40 | "cards": [ 41 | { 42 | "id": "Wip1", 43 | "title": "Clean House", 44 | "label": "30 mins", 45 | "description": "Soap wash and polish floor. Polish windows and doors. Scrap all broken glasses" 46 | } 47 | ] 48 | }, 49 | { 50 | "id": "BLOCKED", 51 | "title": "Blocked", 52 | "label": "0/0", 53 | "style": { "width": 280 }, 54 | "cards": [] 55 | }, 56 | { 57 | "id": "COMPLETED", 58 | "title": "Completed", 59 | "style": { "width": 280 }, 60 | "label": "2/5", 61 | "cards": [ 62 | { 63 | "id": "Completed1", 64 | "title": "Practice Meditation", 65 | "label": "15 mins", 66 | "description": "Use Headspace app" 67 | }, 68 | { 69 | "id": "Completed2", 70 | "title": "Maintain Daily Journal", 71 | "label": "15 mins", 72 | "description": "Use Spreadsheet for now" 73 | } 74 | ] 75 | }, 76 | { 77 | "id": "REPEAT", 78 | "title": "Repeat", 79 | "style": { "width": 280 }, 80 | "label": "1/1", 81 | "cards": [ 82 | { 83 | "id": "Repeat1", 84 | "title": "Morning Jog", 85 | "label": "30 mins", 86 | "description": "Track using fitbit" 87 | } 88 | ] 89 | }, 90 | { 91 | "id": "ARCHIVED", 92 | "title": "Archived", 93 | "style": { "width": 280 }, 94 | "label": "1/1", 95 | "cards": [ 96 | { 97 | "id": "Archived1", 98 | "title": "Go Trekking", 99 | "label": "300 mins", 100 | "description": "Completed 10km on cycle" 101 | } 102 | ] 103 | }, 104 | { 105 | "id": "ARCHIVED2", 106 | "title": "Archived2", 107 | "style": { "width": 280 }, 108 | "label": "1/1", 109 | "cards": [ 110 | { 111 | "id": "Archived2", 112 | "title": "Go Jogging", 113 | "label": "300 mins", 114 | "description": "Completed 10km on cycle" 115 | } 116 | ] 117 | }, 118 | { 119 | "id": "ARCHIVED3", 120 | "title": "Archived3", 121 | "style": { "width": 280 }, 122 | "label": "1/1", 123 | "cards": [ 124 | { 125 | "id": "Archived3", 126 | "title": "Go Cycling", 127 | "label": "300 mins", 128 | "description": "Completed 10km on cycle" 129 | } 130 | ] 131 | } 132 | ] 133 | } 134 | -------------------------------------------------------------------------------- /stories/data/board_with_custom_width.json: -------------------------------------------------------------------------------- 1 | { 2 | "lanes": [ 3 | { 4 | "id": "PLANNED", 5 | "title": "Planned Tasks", 6 | "label": "20/70", 7 | "style": { "width": 280 }, 8 | "cards": [ 9 | { 10 | "id": "Milk", 11 | "title": "Buy milk", 12 | "label": "15 mins", 13 | "cardStyle": { 14 | "width": 270, 15 | "maxWidth": 270, 16 | "margin": "auto", 17 | "marginBottom": 5 18 | }, 19 | "description": "2 Gallons of milk at the Deli store" 20 | }, 21 | { 22 | "id": "Plan2", 23 | "title": "Dispose Garbage", 24 | "label": "10 mins", 25 | "cardStyle": { 26 | "width": 270, 27 | "maxWidth": 270, 28 | "margin": "auto", 29 | "marginBottom": 5 30 | }, 31 | "description": "Sort out recyclable and waste as needed" 32 | }, 33 | { 34 | "id": "Plan3", 35 | "title": "Write Blog", 36 | "label": "30 mins", 37 | "cardStyle": { 38 | "width": 270, 39 | "maxWidth": 270, 40 | "margin": "auto", 41 | "marginBottom": 5 42 | }, 43 | "description": "Can AI make memes?" 44 | }, 45 | { 46 | "id": "Plan4", 47 | "title": "Pay Rent", 48 | "label": "5 mins", 49 | "cardStyle": { 50 | "width": 270, 51 | "maxWidth": 270, 52 | "margin": "auto", 53 | "marginBottom": 5 54 | }, 55 | "description": "Transfer to bank account" 56 | } 57 | ] 58 | }, 59 | { 60 | "id": "WIP", 61 | "title": "Work In Progress", 62 | "label": "10/20", 63 | "style": { "width": 280 }, 64 | "cards": [ 65 | { 66 | "id": "Wip1", 67 | "title": "Clean House", 68 | "label": "30 mins", 69 | "cardStyle": { 70 | "width": 270, 71 | "maxWidth": 270, 72 | "margin": "auto", 73 | "marginBottom": 5 74 | }, 75 | "description": "Soap wash and polish floor. Polish windows and doors. Scrap all broken glasses" 76 | } 77 | ] 78 | }, 79 | { 80 | "id": "BLOCKED", 81 | "title": "Blocked", 82 | "label": "0/0", 83 | "style": { "width": 280 }, 84 | "cards": [] 85 | }, 86 | { 87 | "id": "COMPLETED", 88 | "title": "Completed", 89 | "style": { "width": 280 }, 90 | "label": "2/5", 91 | "cards": [ 92 | { 93 | "id": "Completed1", 94 | "title": "Practice Meditation", 95 | "label": "15 mins", 96 | "cardStyle": { 97 | "width": 270, 98 | "maxWidth": 270, 99 | "margin": "auto", 100 | "marginBottom": 5 101 | }, 102 | "description": "Use Headspace app" 103 | }, 104 | { 105 | "id": "Completed2", 106 | "title": "Maintain Daily Journal", 107 | "label": "15 mins", 108 | "cardStyle": { 109 | "width": 270, 110 | "maxWidth": 270, 111 | "margin": "auto", 112 | "marginBottom": 5 113 | }, 114 | "description": "Use Spreadsheet for now" 115 | } 116 | ] 117 | }, 118 | { 119 | "id": "REPEAT", 120 | "title": "Repeat", 121 | "style": { "width": 280 }, 122 | "label": "1/1", 123 | "cards": [ 124 | { 125 | "id": "Repeat1", 126 | "title": "Morning Jog", 127 | "label": "30 mins", 128 | "cardStyle": { 129 | "width": 270, 130 | "maxWidth": 270, 131 | "margin": "auto", 132 | "marginBottom": 5 133 | }, 134 | "description": "Track using fitbit" 135 | } 136 | ] 137 | }, 138 | { 139 | "id": "ARCHIVED", 140 | "title": "Archived", 141 | "style": { "width": 280 }, 142 | "label": "1/1", 143 | "cards": [ 144 | { 145 | "id": "Archived1", 146 | "title": "Go Trekking", 147 | "label": "300 mins", 148 | "cardStyle": { 149 | "width": 270, 150 | "maxWidth": 270, 151 | "margin": "auto", 152 | "marginBottom": 5 153 | }, 154 | "description": "Completed 10km on cycle" 155 | } 156 | ] 157 | }, 158 | { 159 | "id": "ARCHIVED2", 160 | "title": "Archived2", 161 | "style": { "width": 280 }, 162 | "label": "1/1", 163 | "cards": [ 164 | { 165 | "id": "Archived1", 166 | "title": "Go Trekking", 167 | "label": "300 mins", 168 | "cardStyle": { 169 | "width": 270, 170 | "maxWidth": 270, 171 | "margin": "auto", 172 | "marginBottom": 5 173 | }, 174 | "description": "Completed 10km on cycle" 175 | } 176 | ] 177 | }, 178 | { 179 | "id": "ARCHIVED3", 180 | "title": "Archived3", 181 | "style": { "width": 280 }, 182 | "label": "1/1", 183 | "cards": [ 184 | { 185 | "id": "Archived1", 186 | "title": "Go Trekking", 187 | "label": "300 mins", 188 | "cardStyle": { 189 | "width": 270, 190 | "maxWidth": 270, 191 | "margin": "auto", 192 | "marginBottom": 5 193 | }, 194 | "description": "Completed 10km on cycle" 195 | } 196 | ] 197 | } 198 | ] 199 | } 200 | -------------------------------------------------------------------------------- /stories/data/collapsible.json: -------------------------------------------------------------------------------- 1 | { 2 | "lanes": [ 3 | { 4 | "id": "PLANNED", 5 | "title": "Double Click Here", 6 | "label": "20/70", 7 | "style": { "width": 280 }, 8 | "cards": [ 9 | { 10 | "id": "Milk", 11 | "title": "Buy milk", 12 | "label": "15 mins", 13 | "description": "2 Gallons of milk at the Deli store" 14 | }, 15 | { 16 | "id": "Plan2", 17 | "title": "Dispose Garbage", 18 | "label": "10 mins", 19 | "description": "Sort out recyclable and waste as needed" 20 | }, 21 | { 22 | "id": "Plan3", 23 | "title": "Write Blog", 24 | "label": "30 mins", 25 | "description": "Can AI make memes?" 26 | }, 27 | { 28 | "id": "Plan4", 29 | "title": "Pay Rent", 30 | "label": "5 mins", 31 | "description": "Transfer to bank account" 32 | } 33 | ] 34 | }, 35 | { 36 | "id": "WIP", 37 | "title": "Work In Progress", 38 | "label": "10/20", 39 | "style": { "width": 280 }, 40 | "cards": [ 41 | { 42 | "id": "Wip1", 43 | "title": "Clean House", 44 | "label": "30 mins", 45 | "description": "Soap wash and polish floor. Polish windows and doors. Scrap all broken glasses" 46 | } 47 | ] 48 | }, 49 | { 50 | "id": "BLOCKED", 51 | "title": "Blocked", 52 | "label": "0/0", 53 | "style": { "width": 280 }, 54 | "cards": [] 55 | }, 56 | { 57 | "id": "COMPLETED", 58 | "title": "Completed", 59 | "style": { "width": 280 }, 60 | "label": "2/5", 61 | "cards": [ 62 | { 63 | "id": "Completed1", 64 | "title": "Practice Meditation", 65 | "label": "15 mins", 66 | "description": "Use Headspace app" 67 | }, 68 | { 69 | "id": "Completed2", 70 | "title": "Maintain Daily Journal", 71 | "label": "15 mins", 72 | "description": "Use Spreadsheet for now" 73 | } 74 | ] 75 | }, 76 | { 77 | "id": "REPEAT", 78 | "title": "Repeat", 79 | "style": { "width": 280 }, 80 | "label": "1/1", 81 | "cards": [ 82 | { 83 | "id": "Repeat1", 84 | "title": "Morning Jog", 85 | "label": "30 mins", 86 | "description": "Track using fitbit" 87 | } 88 | ] 89 | }, 90 | { 91 | "id": "ARCHIVED", 92 | "title": "Archived", 93 | "style": { "width": 280 }, 94 | "label": "1/1", 95 | "cards": [ 96 | { 97 | "id": "Archived1", 98 | "title": "Go Trekking", 99 | "label": "300 mins", 100 | "description": "Completed 10km on cycle" 101 | } 102 | ] 103 | }, 104 | { 105 | "id": "ARCHIVED2", 106 | "title": "Archived2", 107 | "style": { "width": 280 }, 108 | "label": "1/1", 109 | "cards": [ 110 | { 111 | "id": "Archived2", 112 | "title": "Go Jogging", 113 | "label": "300 mins", 114 | "description": "Completed 10km on cycle" 115 | } 116 | ] 117 | }, 118 | { 119 | "id": "ARCHIVED3", 120 | "title": "Archived3", 121 | "style": { "width": 280 }, 122 | "label": "1/1", 123 | "cards": [ 124 | { 125 | "id": "Archived3", 126 | "title": "Go Cycling", 127 | "label": "300 mins", 128 | "description": "Completed 10km on cycle" 129 | } 130 | ] 131 | } 132 | ] 133 | } 134 | -------------------------------------------------------------------------------- /stories/data/data-sort.json: -------------------------------------------------------------------------------- 1 | { 2 | "lanes": [ 3 | { 4 | "id": "SORTED_LANE", 5 | "title": "Sorted Lane", 6 | "label": "20/70", 7 | "cards": [ 8 | { 9 | "id": "Card1", 10 | "title": "Buy milk", 11 | "label": "2017-12-01", 12 | "description": "2 Gallons of milk at the Deli store", 13 | "metadata": { 14 | "completedAt": "2017-12-01T10:00:00Z", 15 | "shortCode": "abc" 16 | } 17 | }, 18 | { 19 | "id": "Card2", 20 | "title": "Dispose Garbage", 21 | "label": "2017-11-01", 22 | "description": "Sort out recyclable and waste as needed", 23 | "metadata": { 24 | "completedAt": "2017-11-01T10:00:00Z", 25 | "shortCode": "aaa" 26 | } 27 | }, 28 | { 29 | "id": "Card3", 30 | "title": "Write Blog", 31 | "label": "2017-10-01", 32 | "description": "Can AI make memes?", 33 | "metadata": { 34 | "completedAt": "2017-10-01T10:00:00Z", 35 | "shortCode": "fa1" 36 | } 37 | }, 38 | { 39 | "id": "Card4", 40 | "title": "Pay Rent", 41 | "label": "2017-09-01", 42 | "description": "Transfer to bank account", 43 | "metadata": { 44 | "completedAt": "2017-09-01T10:00:00Z", 45 | "shortCode": "ga2" 46 | } 47 | } 48 | ] 49 | } 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /stories/data/drag-drop.json: -------------------------------------------------------------------------------- 1 | { 2 | "lanes": [ 3 | { 4 | "id": "PLANNED", 5 | "title": "Planned Tasks", 6 | "label": "20/70", 7 | "cards": [ 8 | { 9 | "id": "Milk", 10 | "title": "Buy milk", 11 | "label": "15 mins", 12 | "description": "2 Gallons of milk at the Deli store" 13 | }, 14 | { 15 | "id": "Plan2", 16 | "title": "Dispose Garbage", 17 | "label": "10 mins", 18 | "description": "Sort out recyclable and waste as needed" 19 | }, 20 | { 21 | "id": "Plan3", 22 | "title": "Write Blog", 23 | "label": "30 mins", 24 | "description": "Can AI make memes?" 25 | }, 26 | { 27 | "id": "Plan4", 28 | "title": "Pay Rent", 29 | "label": "5 mins", 30 | "description": "Transfer to bank account" 31 | } 32 | ] 33 | }, 34 | { 35 | "id": "WIP", 36 | "title": "Work In Progress (Not Droppable)", 37 | "label": "10/20", 38 | "droppable": false, 39 | "cards": [ 40 | { 41 | "id": "Wip1", 42 | "title": "Clean House", 43 | "label": "30 mins", 44 | "description": "Soap wash and polish floor. Polish windows and doors. Scrap all broken glasses" 45 | } 46 | ] 47 | }, 48 | { 49 | "id": "COMPLETED", 50 | "title": "Completed (Droppable)", 51 | "label": "0/0", 52 | "style": { "width": 280 }, 53 | "cards": [] 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /stories/data/other-board.json: -------------------------------------------------------------------------------- 1 | { 2 | "lanes": [ 3 | { 4 | "id": "yesterday", 5 | "title": "Yesterday", 6 | "label": "20/70", 7 | "cards": [ 8 | { 9 | "id": "Wip1", 10 | "title": "Clean House", 11 | "label": "30 mins", 12 | "description": "Soap wash and polish floor. Polish windows and doors. Scrap all broken glasses" 13 | } 14 | ] 15 | }, 16 | { 17 | "id": "today", 18 | "title": "Today", 19 | "label": "10/20", 20 | "droppable": false, 21 | "cards": [ 22 | { 23 | "id": "Milk", 24 | "title": "Buy milk", 25 | "label": "15 mins", 26 | "description": "2 Gallons of milk at the Deli store" 27 | }, 28 | { 29 | "id": "Plan2", 30 | "title": "Dispose Garbage", 31 | "label": "10 mins", 32 | "description": "Sort out recyclable and waste as needed" 33 | }, 34 | { 35 | "id": "Plan3", 36 | "title": "Write Blog", 37 | "label": "30 mins", 38 | "description": "Can AI make memes?" 39 | }, 40 | { 41 | "id": "Plan4", 42 | "title": "Pay Rent", 43 | "label": "5 mins", 44 | "description": "Transfer to bank account" 45 | } 46 | ] 47 | }, 48 | { 49 | "id": "tomorrow", 50 | "title": "Tomorrow", 51 | "label": "0/0", 52 | "cards": [] 53 | } 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /stories/drag.css: -------------------------------------------------------------------------------- 1 | .draggingCard { 2 | background-color: #7fffd4; 3 | border: 1px dashed #a5916c; 4 | transform: rotate(2deg); 5 | } 6 | 7 | .draggingLane { 8 | background-color: #ffaecf; 9 | transform: rotate(2deg); 10 | border: 1px dashed #a5916c; 11 | } 12 | -------------------------------------------------------------------------------- /stories/helpers/debug.js: -------------------------------------------------------------------------------- 1 | export default (message) => { 2 | if (process.env.NODE_ENV === "test") { 3 | return; 4 | } 5 | if (typeof message === "object") { 6 | console.dir(message); 7 | } else { 8 | console.log(message); 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /stories/helpers/i18n.js: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import { initReactI18next } from "react-i18next"; 3 | 4 | import resources from "../../src/locales"; 5 | 6 | i18n 7 | .use(initReactI18next) // passes i18n down to react-i18next 8 | .init({ 9 | resources, 10 | lng: "en", 11 | }); 12 | 13 | export default i18n; 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 4 | "jsx": "react", /* Specify what JSX code is generated. */ 5 | "moduleResolution": "Node", 6 | "declaration": true, /* Specify what module code is generated. */ 7 | "baseUrl": ".",/* Specify the base directory to resolve non-relative module names. */ 8 | "paths": {"rt/*":["./src/*"], "@/*": ["./src/*"]}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 9 | "resolveJsonModule": true, /* Enable importing .json files. */ 10 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 11 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 12 | "allowJs": true, 13 | "strict": false, /* Enable all strict type-checking options. */ 14 | "strictFunctionTypes": false, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 15 | "skipLibCheck": true, 16 | "emitDeclarationOnly": true, 17 | "allowSyntheticDefaultImports": true, 18 | }, 19 | "include": [ 20 | "./src/**/*.ts", 21 | "./src/**/*.tsx", 22 | "./src/**/*.js", 23 | "./src/**/*.jsx" 24 | ], 25 | "exclude": ["node_modules", "dist", "stories"] 26 | } 27 | --------------------------------------------------------------------------------