29 |
45 |
46 | {isOpen && (
47 | <>
48 |
setIsOpen(false)}
51 | />
52 |
53 | {languages.map((language) => (
54 |
68 | ))}
69 |
70 | >
71 | )}
72 |
73 | );
74 | };
75 |
76 | export default LanguageSwitcher;
77 |
--------------------------------------------------------------------------------
/rkk-demo/src/global/assets/styles/abstracts/_breakpoints.scss:
--------------------------------------------------------------------------------
1 | // Breakpoints
2 | $breakpoints: (
3 | xs: 480px,
4 | sm: 640px,
5 | md: 768px,
6 | lg: 1024px,
7 | xl: 1280px,
8 | 2xl: 1536px,
9 | ) !default;
10 |
11 | // Get breakpoint value
12 | @function breakpoint($name) {
13 | @return map-get($breakpoints, $name);
14 | }
15 |
16 | // Media query mixins
17 | @mixin media-up($name) {
18 | $value: breakpoint($name);
19 | @if $value {
20 | @media (min-width: $value) {
21 | @content;
22 | }
23 | }
24 | }
25 |
26 | @mixin media-down($name) {
27 | $value: breakpoint($name);
28 | @if $value {
29 | @media (max-width: ($value - 1px)) {
30 | @content;
31 | }
32 | }
33 | }
34 |
35 | @mixin media-between($lower, $upper) {
36 | $lower-value: breakpoint($lower);
37 | $upper-value: breakpoint($upper);
38 |
39 | @if $lower-value and $upper-value {
40 | @media (min-width: $lower-value) and (max-width: ($upper-value - 1px)) {
41 | @content;
42 | }
43 | }
44 | }
45 |
46 | @mixin media-only($name) {
47 | $value: breakpoint($name);
48 | $next: null;
49 |
50 | // Find next breakpoint
51 | $breakpoint-names: map-keys($breakpoints);
52 | $index: index($breakpoint-names, $name);
53 |
54 | @if $index and $index < length($breakpoint-names) {
55 | $next-name: nth($breakpoint-names, $index + 1);
56 | $next: breakpoint($next-name);
57 | }
58 |
59 | @if $value and $next {
60 | @media (min-width: $value) and (max-width: ($next - 1px)) {
61 | @content;
62 | }
63 | } @else if $value {
64 | @media (min-width: $value) {
65 | @content;
66 | }
67 | }
68 | }
69 |
70 | // Utility mixins for common breakpoints
71 | @mixin mobile-only {
72 | @include media-down(sm) {
73 | @content;
74 | }
75 | }
76 |
77 | @mixin tablet-only {
78 | @include media-between(sm, lg) {
79 | @content;
80 | }
81 | }
82 |
83 | @mixin desktop-only {
84 | @include media-up(lg) {
85 | @content;
86 | }
87 | }
88 |
89 | // High DPI / Retina display mixin
90 | @mixin retina {
91 | @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
92 | @content;
93 | }
94 | }
95 |
96 | // Orientation mixins
97 | @mixin landscape {
98 | @media (orientation: landscape) {
99 | @content;
100 | }
101 | }
102 |
103 | @mixin portrait {
104 | @media (orientation: portrait) {
105 | @content;
106 | }
107 | }
108 |
109 | // Reduced motion mixin
110 | @mixin reduced-motion {
111 | @media (prefers-reduced-motion: reduce) {
112 | @content;
113 | }
114 | }
115 |
116 | // Dark mode mixin
117 | @mixin dark-mode {
118 | @media (prefers-color-scheme: dark) {
119 | @content;
120 | }
121 | }
122 |
123 | // High contrast mixin
124 | @mixin high-contrast {
125 | @media (prefers-contrast: high) {
126 | @content;
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "homepage": "https://github.com/braiekhazem/react-kanban-kit",
3 | "name": "react-kanban-kit",
4 | "author": "hazem braiek",
5 | "private": false,
6 | "version": "0.0.2-beta.6",
7 | "type": "module",
8 | "main": "dist/index.cjs.js",
9 | "module": "dist/index.es.js",
10 | "types": "dist/index.d.ts",
11 | "exports": {
12 | ".": {
13 | "import": "./dist/index.es.js",
14 | "require": "./dist/index.umd.js",
15 | "types": "./dist/index.d.ts"
16 | }
17 | },
18 | "keywords": [
19 | "react-kanban-kit",
20 | "board",
21 | "kanban",
22 | "rkk",
23 | "react-kanban",
24 | "drag-and-drop",
25 | "virtualized",
26 | "programmatic-drag-and-drop"
27 | ],
28 | "sideEffects": true,
29 | "files": [
30 | "/dist"
31 | ],
32 | "publishConfig": {
33 | "access": "public"
34 | },
35 | "repository": {
36 | "type": "git",
37 | "url": "https://github.com/braiekhazem/react-kanban-kit"
38 | },
39 | "license": "MIT",
40 | "bin": {
41 | "mycli": "./cli.cjs"
42 | },
43 | "scripts": {
44 | "test": "jest --watchAll --coverage",
45 | "dev": "vite",
46 | "build": "tsc && vite build",
47 | "demo": "cd demo && npm run dev",
48 | "modern-demo": "vite serve demo/modern --config demo/vite.config.ts",
49 | "build-demo": "cd demo && npm run build",
50 | "prepare": "npm run build",
51 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
52 | "preview": "vite preview --port 8080",
53 | "format": "prettier --ignore-path .gitignore --write \"**/*.{ts,tsx,css,scss}\"",
54 | "husky": "husky install",
55 | "create-component": "bash ./scripts/create-component.sh",
56 | "docker:build": "./scripts/build-docker.sh",
57 | "predeploy": "npm run build",
58 | "deploy": "gh-pages -d dist"
59 | },
60 | "dependencies": {
61 | "@atlaskit/pragmatic-drag-and-drop": "^1.5.0",
62 | "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.0",
63 | "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
64 | "@atlaskit/pragmatic-drag-and-drop-react-beautiful-dnd-autoscroll": "^2.0.0",
65 | "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "^2.1.0",
66 | "classes": "^0.3.0",
67 | "classnames": "^2.5.1",
68 | "husky": "^8.0.3",
69 | "virtua": "^0.40.4",
70 | "vite-plugin-css-injected-by-js": "^3.4.0",
71 | "vite-plugin-dts": "^3.7.3"
72 | },
73 | "devDependencies": {
74 | "@babel/plugin-transform-react-jsx": "^7.23.4",
75 | "@types/lodash": "^4.17.16",
76 | "@types/node": "^22.15.21",
77 | "@types/react": "^18.2.0",
78 | "@types/react-dom": "^18.2.0",
79 | "@vitejs/plugin-react": "^4.0.0",
80 | "eslint": "^8.38.0",
81 | "eslint-plugin-storybook": "^0.8.0",
82 | "gh-pages": "^6.1.1",
83 | "prettier": "3.0.0",
84 | "react": "^18.2.0",
85 | "react-dom": "^18.2.0",
86 | "sass": "^1.71.1",
87 | "terser": "^5.29.2",
88 | "typescript": "^5.4.2",
89 | "vite": "^4.3.9",
90 | "vite-plugin-svgr": "^3.2.0"
91 | },
92 | "peerDependencies": {
93 | "react": ">=18.0.0",
94 | "react-dom": ">=18.0.0"
95 | },
96 | "peerDependenciesMeta": {
97 | "react": {
98 | "optional": false
99 | },
100 | "react-dom": {
101 | "optional": false
102 | }
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/components/Kanban.tsx:
--------------------------------------------------------------------------------
1 | import { BoardProps } from "./types";
2 | import {
3 | getColumnChildren,
4 | getColumnsFromDataSource,
5 | } from "@/utils/columnsUtils";
6 | import { withPrefix } from "@/utils/getPrefix";
7 | import classNames from "classnames";
8 | import { Column } from "./Column";
9 | import { forwardRef, useEffect, useRef } from "react";
10 | import { autoScroller } from "@atlaskit/pragmatic-drag-and-drop-react-beautiful-dnd-autoscroll";
11 | import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
12 | import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
13 | import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
14 | import { KanbanProvider } from "@/context/KanbanContext";
15 | import mergeRefs from "@/utils/mergeRefs";
16 | import { handleCardDrop } from "@/global/dnd/dropManager";
17 | import { getSharedProps } from "@/utils/getSharedProps";
18 | import ColumnAdder from "./ColumnAdder";
19 |
20 | const Kanban = forwardRef
((props, ref) => {
21 | const {
22 | dataSource,
23 | rootStyle = {},
24 | rootClassName,
25 | onColumnMove,
26 | onCardMove,
27 | renderColumnWrapper,
28 | renderColumnAdder,
29 | ...rest
30 | } = props;
31 |
32 | const columns = getColumnsFromDataSource(dataSource);
33 | const internalRef = useRef(null);
34 |
35 | useEffect(() => {
36 | if (!internalRef.current) return;
37 |
38 | return combine(
39 | monitorForElements({
40 | onDragStart({ location }) {
41 | autoScroller.start({ input: location.current.input });
42 | },
43 | onDrag({ location }) {
44 | autoScroller.updateInput({ input: location.current.input });
45 | },
46 | onDrop(args) {
47 | autoScroller.stop();
48 | handleCardDrop({
49 | source: {
50 | id: (args.source as any).id || "",
51 | data: args.source.data,
52 | },
53 | location: {
54 | current: {
55 | dropTargets: args.location.current.dropTargets,
56 | },
57 | },
58 | columns,
59 | dataSource,
60 | onCardMove,
61 | onColumnMove,
62 | });
63 | },
64 | }),
65 | autoScrollForElements({
66 | element: internalRef.current,
67 | canScroll: () => true,
68 | getConfiguration: () => ({
69 | maxScrollSpeed: "standard",
70 | }),
71 | })
72 | );
73 | }, [columns, dataSource, onCardMove, onColumnMove]);
74 |
75 | const containerClassName = classNames(withPrefix("board"), rootClassName);
76 |
77 | return (
78 |
79 |
84 | {columns?.map((column, index) => (
85 |
93 | ))}
94 |
95 |
96 |
97 | );
98 | });
99 |
100 | export default Kanban;
101 |
--------------------------------------------------------------------------------
/rkk-demo/src/pages/Overview/Overview.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { useTranslation } from "react-i18next";
3 | import { dropHandler, Kanban, type BoardData } from "react-kanban-kit";
4 | import { User } from "lucide-react";
5 | import { mockData } from "../../utils/_mock_";
6 |
7 | const PriorityBadge: React.FC<{ priority: string }> = ({ priority }) => {
8 | const getColorClass = (priority: string) => {
9 | switch (priority) {
10 | case "high":
11 | return "priority-high";
12 | case "medium":
13 | return "priority-medium";
14 | case "low":
15 | return "priority-low";
16 | default:
17 | return "priority-medium";
18 | }
19 | };
20 |
21 | return (
22 |
23 | {priority}
24 |
25 | );
26 | };
27 |
28 | export const Overview: React.FC = () => {
29 | const { t } = useTranslation();
30 | const [dataSource, setDataSource] = useState(
31 | structuredClone(mockData) as BoardData
32 | );
33 |
34 | return (
35 |
36 |
37 |
{t("pages.overview.title")}
38 |
{t("pages.overview.description")}
39 |
40 |
41 |
42 |
(
47 |
48 |
49 |
{data.title}
50 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | {data.content?.assignee ||
60 | t("common.unassigned", "Unassigned")}
61 |
62 |
63 |
64 |
65 | ),
66 | isDraggable: true,
67 | },
68 | }}
69 | cardsGap={6}
70 | virtualization={false}
71 | onCardMove={(move) =>
72 | setDataSource(
73 | dropHandler(
74 | move,
75 | dataSource,
76 | () => {},
77 | (newColumn) => {
78 | return {
79 | ...newColumn,
80 | totalItemsCount: (newColumn.totalItemsCount || 0) + 1,
81 | totalChildrenCount: (newColumn.totalChildrenCount || 0) + 1,
82 | };
83 | },
84 | (sourceColumn) => {
85 | return {
86 | ...sourceColumn,
87 | totalItemsCount: (sourceColumn.totalItemsCount || 0) - 1,
88 | totalChildrenCount:
89 | (sourceColumn.totalChildrenCount || 0) - 1,
90 | };
91 | }
92 | )
93 | )
94 | }
95 | />
96 |
97 |
98 | );
99 | };
100 |
101 | export default Overview;
102 |
--------------------------------------------------------------------------------
/rkk-demo/src/global/assets/styles/base/_reset.scss:
--------------------------------------------------------------------------------
1 | /* Modern CSS Reset */
2 | *,
3 | *::before,
4 | *::after {
5 | box-sizing: border-box;
6 | margin: 0;
7 | padding: 0;
8 | }
9 |
10 | html {
11 | line-height: 1.5;
12 | -webkit-text-size-adjust: 100%;
13 | -moz-tab-size: 4;
14 | tab-size: 4;
15 | font-family: var(--font-sans);
16 | font-feature-settings: normal;
17 | font-variation-settings: normal;
18 | }
19 |
20 | body {
21 | margin: 0;
22 | line-height: inherit;
23 | color: var(--gray-900);
24 | background-color: var(--gray-50);
25 | overflow: hidden;
26 | }
27 |
28 | hr {
29 | height: 0;
30 | color: inherit;
31 | border-top-width: 1px;
32 | }
33 |
34 | abbr:where([title]) {
35 | text-decoration: underline dotted;
36 | }
37 |
38 | h1,
39 | h2,
40 | h3,
41 | h4,
42 | h5,
43 | h6 {
44 | font-size: inherit;
45 | font-weight: inherit;
46 | }
47 |
48 | a {
49 | color: inherit;
50 | text-decoration: inherit;
51 | }
52 |
53 | b,
54 | strong {
55 | font-weight: bolder;
56 | }
57 |
58 | code,
59 | kbd,
60 | samp,
61 | pre {
62 | font-family: var(--font-mono);
63 | font-size: 1em;
64 | }
65 |
66 | small {
67 | font-size: 80%;
68 | }
69 |
70 | sub,
71 | sup {
72 | font-size: 75%;
73 | line-height: 0;
74 | position: relative;
75 | vertical-align: baseline;
76 | }
77 |
78 | sub {
79 | bottom: -0.25em;
80 | }
81 |
82 | sup {
83 | top: -0.5em;
84 | }
85 |
86 | table {
87 | text-indent: 0;
88 | border-color: inherit;
89 | border-collapse: collapse;
90 | }
91 |
92 | button,
93 | input,
94 | optgroup,
95 | select,
96 | textarea {
97 | font-family: inherit;
98 | font-size: 100%;
99 | font-weight: inherit;
100 | line-height: inherit;
101 | color: inherit;
102 | margin: 0;
103 | padding: 0;
104 | }
105 |
106 | button,
107 | select {
108 | text-transform: none;
109 | }
110 |
111 | button,
112 | [type="button"],
113 | [type="reset"],
114 | [type="submit"] {
115 | -webkit-appearance: button;
116 | background-color: transparent;
117 | background-image: none;
118 | }
119 |
120 | :-moz-focusring {
121 | outline: auto;
122 | }
123 |
124 | :-moz-ui-invalid {
125 | box-shadow: none;
126 | }
127 |
128 | progress {
129 | vertical-align: baseline;
130 | }
131 |
132 | ::-webkit-inner-spin-button,
133 | ::-webkit-outer-spin-button {
134 | height: auto;
135 | }
136 |
137 | [type="search"] {
138 | -webkit-appearance: textfield;
139 | outline-offset: -2px;
140 | }
141 |
142 | ::-webkit-search-decoration {
143 | -webkit-appearance: none;
144 | }
145 |
146 | ::-webkit-file-upload-button {
147 | -webkit-appearance: button;
148 | font: inherit;
149 | }
150 |
151 | summary {
152 | display: list-item;
153 | }
154 |
155 | blockquote,
156 | dl,
157 | dd,
158 | h1,
159 | h2,
160 | h3,
161 | h4,
162 | h5,
163 | h6,
164 | hr,
165 | figure,
166 | p,
167 | pre {
168 | margin: 0;
169 | }
170 |
171 | fieldset {
172 | margin: 0;
173 | padding: 0;
174 | }
175 |
176 | legend {
177 | padding: 0;
178 | }
179 |
180 | ol,
181 | ul,
182 | menu {
183 | list-style: none;
184 | margin: 0;
185 | padding: 0;
186 | }
187 |
188 | textarea {
189 | resize: vertical;
190 | }
191 |
192 | input::placeholder,
193 | textarea::placeholder {
194 | opacity: 1;
195 | color: var(--gray-400);
196 | }
197 |
198 | button,
199 | [role="button"] {
200 | cursor: pointer;
201 | }
202 |
203 | :disabled {
204 | cursor: default;
205 | }
206 |
207 | img,
208 | svg,
209 | video,
210 | canvas,
211 | audio,
212 | iframe,
213 | embed,
214 | object {
215 | display: block;
216 | vertical-align: middle;
217 | }
218 |
219 | img,
220 | video {
221 | max-width: 100%;
222 | height: auto;
223 | }
224 |
225 | [hidden] {
226 | display: none;
227 | }
228 |
--------------------------------------------------------------------------------
/rkk-demo/src/components/LanguageSwitcher/_LanguageSwitcher.scss:
--------------------------------------------------------------------------------
1 | @use "../../global/assets/styles/abstracts" as *;
2 |
3 | .#{$demo-prefix}-language-switcher {
4 | position: relative;
5 |
6 | &-trigger {
7 | @include flex-start;
8 | gap: var(--space-2);
9 | padding: var(--space-2) var(--space-3);
10 | font-size: var(--text-sm);
11 | font-weight: 500;
12 | color: var(--gray-600);
13 | background: rgba(255, 255, 255, 0.9);
14 | border: 1px solid var(--gray-200);
15 | border-radius: var(--radius-lg);
16 | transition: all var(--transition-base);
17 | cursor: pointer;
18 | backdrop-filter: blur(8px);
19 |
20 | &:hover {
21 | color: var(--primary-600);
22 | background: white;
23 | border-color: var(--primary-200);
24 | box-shadow: var(--shadow-sm);
25 | }
26 |
27 | &:focus-visible {
28 | outline: 2px solid var(--primary-500);
29 | outline-offset: 2px;
30 | }
31 | }
32 |
33 | &-current {
34 | @include flex-start;
35 | gap: var(--space-1-5);
36 | font-size: var(--text-sm);
37 |
38 | @include media-down(sm) {
39 | display: none;
40 | }
41 | }
42 |
43 | &-chevron {
44 | transition: transform var(--transition-base);
45 |
46 | &.open {
47 | transform: rotate(180deg);
48 | }
49 | }
50 |
51 | &-overlay {
52 | position: fixed;
53 | top: 0;
54 | left: 0;
55 | right: 0;
56 | bottom: 0;
57 | z-index: var(--z-overlay);
58 | }
59 |
60 | &-dropdown {
61 | position: absolute;
62 | top: calc(100% + var(--space-2));
63 | right: 0;
64 | min-width: 200px;
65 | background: white;
66 | border: 1px solid var(--gray-200);
67 | border-radius: var(--radius-lg);
68 | box-shadow: var(--shadow-xl);
69 | backdrop-filter: blur(16px);
70 | z-index: var(--z-dropdown);
71 | overflow: hidden;
72 | animation: dropdown-appear 0.15s ease-out;
73 |
74 | @include media-down(sm) {
75 | right: auto;
76 | left: 0;
77 | min-width: 160px;
78 | }
79 | }
80 |
81 | &-option {
82 | @include flex-start;
83 | gap: var(--space-3);
84 | width: 100%;
85 | padding: var(--space-3) var(--space-4);
86 | font-size: var(--text-sm);
87 | font-weight: 500;
88 | color: var(--gray-700);
89 | background: transparent;
90 | border: none;
91 | text-align: left;
92 | cursor: pointer;
93 | transition: all var(--transition-base);
94 |
95 | &:hover {
96 | background: var(--primary-50);
97 | color: var(--primary-700);
98 | }
99 |
100 | &.active {
101 | background: var(--primary-100);
102 | color: var(--primary-800);
103 | font-weight: 600;
104 |
105 | &::after {
106 | content: "✓";
107 | margin-left: auto;
108 | color: var(--primary-600);
109 | font-weight: 700;
110 | }
111 | }
112 |
113 | &:not(:last-child) {
114 | border-bottom: 1px solid var(--gray-100);
115 | }
116 | }
117 |
118 | &-flag {
119 | font-size: 1.125rem;
120 | flex-shrink: 0;
121 | }
122 |
123 | &-name {
124 | flex: 1;
125 | }
126 | }
127 |
128 | @keyframes dropdown-appear {
129 | from {
130 | opacity: 0;
131 | transform: translateY(-8px) scale(0.95);
132 | }
133 | to {
134 | opacity: 1;
135 | transform: translateY(0) scale(1);
136 | }
137 | }
138 |
139 | // RTL Support
140 | [dir="rtl"] .#{$demo-prefix}-language-switcher {
141 | &-dropdown {
142 | right: auto;
143 | left: 0;
144 |
145 | @include media-down(sm) {
146 | left: auto;
147 | right: 0;
148 | }
149 | }
150 |
151 | &-option {
152 | text-align: right;
153 |
154 | &.active::after {
155 | margin-left: 0;
156 | margin-right: auto;
157 | }
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/rkk-demo/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/GenericItem/GenericItem.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { BoardItem, BoardProps, ConfigMap, DndState } from "../types";
3 | import classNames from "classnames";
4 | import { withPrefix } from "@/utils/getPrefix";
5 | import CardSkeleton from "../CardSkeleton";
6 | import Card from "../Card";
7 | import DefaultCard from "../DefaultCard";
8 | import { CardShadow } from "../Card/Card";
9 |
10 | const isCardDraggable = (data: BoardItem, isTypeDraggable: boolean) => {
11 | return data?.isDraggable !== undefined ? data?.isDraggable : isTypeDraggable;
12 | };
13 |
14 | interface Props {
15 | index: number;
16 | options: {
17 | data: BoardItem;
18 | column: BoardItem;
19 | configMap: ConfigMap;
20 | //isSkeleton is used to show a skeleton UI when the item is not loaded yet
21 | isSkeleton: boolean;
22 | isShadow: boolean;
23 | isListFooter: boolean;
24 | renderListFooter?: (column: BoardItem) => React.ReactNode;
25 | cardWrapperStyle?: (
26 | card: BoardItem,
27 | column: BoardItem
28 | ) => React.CSSProperties;
29 | cardWrapperClassName?: string;
30 | cardsGap?: number;
31 | renderSkeletonCard?: BoardProps["renderSkeletonCard"];
32 | onCardDndStateChange?: (info: DndState) => void;
33 | onCardClick?: (
34 | e: React.MouseEvent,
35 | card: BoardItem
36 | ) => void;
37 | cardOverHeight?: number;
38 | renderCardDragIndicator?: (card: BoardItem, info: any) => React.ReactNode;
39 | renderCardDragPreview?: (card: BoardItem, info: any) => React.ReactNode;
40 | renderGap?: (column: BoardItem) => React.ReactNode;
41 | };
42 | }
43 |
44 | const GenericItem = (props: Props) => {
45 | const { index, options } = props;
46 | const {
47 | data,
48 | column,
49 | configMap,
50 | isSkeleton,
51 | cardWrapperStyle,
52 | cardWrapperClassName,
53 | cardsGap = 8,
54 | isShadow,
55 | isListFooter,
56 | cardOverHeight = 90,
57 | renderSkeletonCard,
58 | onCardClick,
59 | onCardDndStateChange,
60 | renderCardDragIndicator,
61 | renderListFooter,
62 | renderCardDragPreview,
63 | renderGap,
64 | } = options;
65 |
66 | const { render = DefaultCard, isDraggable = true } =
67 | configMap?.[data?.type] || {};
68 |
69 | const wrapperClassName = classNames(
70 | withPrefix("generic-item-wrapper"),
71 | cardWrapperClassName
72 | );
73 |
74 | const renderCardContent = () => {
75 | if (isListFooter)
76 | return (
77 |
78 | {renderListFooter?.(column) || "Default Footer"}
79 |
80 | );
81 | else if (isShadow)
82 | return (
83 |
89 | );
90 | else if (isSkeleton)
91 | return (
92 |
97 | {renderSkeletonCard?.({ index, column }) || (
98 |
99 | )}
100 |
101 | );
102 |
103 | return (
104 |
117 | );
118 | };
119 |
120 | return (
121 |
127 | {renderCardContent()}
128 |
129 | );
130 | };
131 |
132 | export default GenericItem;
133 |
--------------------------------------------------------------------------------
/src/global/theme-default.scss:
--------------------------------------------------------------------------------
1 | :root {
2 | --vf-font-familly: "Raleway", sans-serif;
3 | --vf-prefix-class: "vf";
4 |
5 | //COLORS
6 |
7 | --vf-color-white: #fff;
8 | --vf-color-black: #000;
9 |
10 | --vf-color-primary: #5f55ee;
11 | --vf-color-primary-50: #fcf9ff;
12 | --vf-color-primary-100: #f5eefa;
13 | --vf-color-primary-200: #e0ccef;
14 | --vf-color-primary-300: #c198e0;
15 | --vf-color-primary-400: #ad76d5;
16 | --vf-color-primary-500: #9854cb;
17 | --vf-color-primary-600: #7a43a2;
18 | --vf-color-primary-700: #5b327a;
19 | --vf-color-primary-800: #3d2251;
20 | --vf-color-primary-900: #301942;
21 | --vf-color-primary-950: #1e1129;
22 |
23 | --vf-color-secondary: #fdb022;
24 | --vf-color-secondary-50: #fffcf5;
25 | --vf-color-secondary-100: #fffaeb;
26 | --vf-color-secondary-200: #fef0c7;
27 | --vf-color-secondary-300: #fedf89;
28 | --vf-color-secondary-400: #fec84b;
29 | --vf-color-secondary-500: #fdb022;
30 | --vf-color-secondary-600: #f79009;
31 | --vf-color-secondary-700: #dc6803;
32 | --vf-color-secondary-800: #b54708;
33 | --vf-color-secondary-900: #92330a;
34 | --vf-color-secondary-950: #6c2304;
35 |
36 | --vf-color-red: #ff4141;
37 | --vf-color-red-50: #fff4f4;
38 | --vf-color-red-100: #ffe8e8;
39 | --vf-color-red-200: #ffd4d4;
40 | --vf-color-red-300: #ffb4b4;
41 | --vf-color-red-400: #ff8383;
42 | --vf-color-red-500: #ff4141;
43 | --vf-color-red-600: #ff1616;
44 | --vf-color-red-700: #d80000;
45 | --vf-color-red-800: #b50000;
46 | --vf-color-red-900: #7e040e;
47 | --vf-color-red-950: #64020a;
48 |
49 | --vf-color-gray: #9d9d9d;
50 | --vf-color-gray-50: #ffffff;
51 | --vf-color-gray-100: #f0f0f0;
52 | --vf-color-gray-200: #dadada;
53 | --vf-color-gray-300: #cecece;
54 | --vf-color-gray-400: #b6b6b6;
55 | --vf-color-gray-500: #9d9d9d;
56 | --vf-color-gray-600: #6a6a6a;
57 | --vf-color-gray-700: #545454;
58 | --vf-color-gray-800: #373737;
59 | --vf-color-gray-900: #1c1c1c;
60 | --vf-color-gray-950: #000000;
61 |
62 | --vf-color-green: #29b58b;
63 | --vf-color-green-50: #f1fffb;
64 | --vf-color-green-100: #e2fff6;
65 | --vf-color-green-200: #ccfff0;
66 | --vf-color-green-300: #a3edd7;
67 | --vf-color-green-400: #52c8a4;
68 | --vf-color-green-500: #29b58b;
69 | --vf-color-green-600: #00a372;
70 | --vf-color-green-700: #00825b;
71 | --vf-color-green-800: #006346;
72 | --vf-color-green-900: #00412e;
73 | --vf-color-green-950: #002117;
74 |
75 | --vf-color-orange: #29b58b;
76 | --vf-color-orange-50: #fffdf6;
77 | --vf-color-orange-100: #fffaea;
78 | --vf-color-orange-200: #fff5d4;
79 | --vf-color-orange-300: #ffecaa;
80 | --vf-color-orange-400: #ffe27f;
81 | --vf-color-orange-500: #ffcf2b;
82 | --vf-color-orange-600: #e6ba27;
83 | --vf-color-orange-700: #bf9b20;
84 | --vf-color-orange-800: #806716;
85 | --vf-color-orange-900: #4d3f0d;
86 | --vf-color-orange-950: #342a08;
87 | --vf-color-orange-1000: #fa8900;
88 |
89 | --vf-color-ashgrey: #b3afa1;
90 | --vf-color-ashgrey-50: #fdfdfc;
91 | --vf-color-ashgrey-100: #fbfbf9;
92 | --vf-color-ashgrey-200: #f7f6f2;
93 | --vf-color-ashgrey-300: #f0efea;
94 | --vf-color-ashgrey-400: #dddad0;
95 | --vf-color-ashgrey-500: #b3afa1;
96 | --vf-color-ashgrey-600: #858071;
97 | --vf-color-ashgrey-700: #5e5267;
98 | --vf-color-ashgrey-800: #676252;
99 | --vf-color-ashgrey-900: #39321d;
100 | --vf-color-ashgrey-950: #282210;
101 |
102 | --vf-color-snow: #b3afa1;
103 | --vf-color-snow-50: #fdfcfd;
104 | --vf-color-snow-100: #faf9fb;
105 | --vf-color-snow-200: #f5f2f7;
106 | --vf-color-snow-300: #edeaf0;
107 | --vf-color-snow-400: #d7d0dd;
108 | --vf-color-snow-500: #aba1b3;
109 | --vf-color-snow-600: #7c7185;
110 | --vf-color-snow-700: #5e5267;
111 | --vf-color-snow-800: #463454;
112 | --vf-color-snow-900: #2d1d39;
113 | --vf-color-snow-950: #1e1028;
114 |
115 | --vf-color-gray-0: #fff;
116 | --vf-color-yellow-50: #ffb300;
117 | --vf-color-cyan-50: #00bcd4;
118 | --vf-color-blue-50: #2196f3;
119 | --vf-color-violet-50: #673ab7;
120 |
121 | --vf-color-info: #1677ff;
122 | --vf-color-success: #52c41a;
123 | --vf-color-warning: #faad14;
124 | --vf-color-error: #ff4d4f;
125 |
126 | --vf-control-bar-height: 48px;
127 |
128 | --vf-border-radius: 12px;
129 |
130 | --vf-progress-bar-bg: #fff3;
131 | --vf-progress-bar-load-bg: #fff6;
132 | --vf-progress-bar-play-bg: var(--vf-color-primary);
133 |
134 | --vf-dropdown-menu-item-hover: #ffffff1a;
135 | --vf-sound-icon-size: 18px;
136 | --vf-sound-icon-color: #fff;
137 | }
138 |
--------------------------------------------------------------------------------
/src/components/types.ts:
--------------------------------------------------------------------------------
1 | import { TaskCardState } from "@/global/dnd/useCardDnd";
2 | import { TColumnState } from "@/global/dnd/useColumnDnd";
3 | import { CSSProperties, ReactNode } from "react";
4 |
5 | export interface DndState {
6 | state: TaskCardState | TColumnState;
7 | column?: BoardItem;
8 | card?: BoardItem;
9 | }
10 |
11 | export interface ScrollEvent {
12 | target: {
13 | scrollTop: number;
14 | scrollHeight: number;
15 | clientHeight: number;
16 | };
17 | }
18 |
19 | export type CardRenderProps = {
20 | data: BoardItem;
21 | column: BoardItem;
22 | index: number;
23 | isDraggable: boolean;
24 | };
25 |
26 | export type ConfigMap = {
27 | [type: string]: {
28 | render: (props: CardRenderProps) => React.ReactNode;
29 | isDraggable?: boolean;
30 | };
31 | };
32 |
33 | export interface BoardItem {
34 | id: string;
35 | title: string;
36 | parentId: string | null;
37 | children: string[];
38 | content?: any;
39 | type?: keyof ConfigMap;
40 | totalItems?: number;
41 | // totalChildrenCount is the total number of children in the column
42 | totalChildrenCount: number;
43 | // totalItemsCount is the total number of items (real content) in the column
44 | totalItemsCount?: number;
45 | isDraggable?: boolean;
46 | }
47 |
48 | export interface BoardData {
49 | root: BoardItem;
50 | [key: string]: BoardItem;
51 | }
52 |
53 | export interface BoardProps {
54 | dataSource: BoardData;
55 | configMap: ConfigMap;
56 | viewOnly?: boolean;
57 | loadMore?: (groupsId: string) => void;
58 | renderSkeletonCard?: ({
59 | index,
60 | column,
61 | }: {
62 | index: number;
63 | column: BoardItem;
64 | }) => ReactNode;
65 | renderColumnHeader?: (column: BoardItem) => ReactNode;
66 | renderCardDragIndicator?: (card: BoardItem, info: any) => ReactNode;
67 | renderCardDragPreview?: (card: BoardItem, info: any) => ReactNode;
68 | // renderColumnDragIndicator?: (column: BoardItem, info: any) => ReactNode;
69 | // renderColumnDragPreview?: (column: BoardItem, info: any) => ReactNode;
70 |
71 | renderListFooter?: (column: BoardItem) => ReactNode;
72 | allowListFooter?: (column: BoardItem) => boolean;
73 |
74 | renderColumnAdder?: () => ReactNode;
75 | allowColumnAdder?: boolean;
76 |
77 | renderColumnWrapper?: (
78 | column: BoardItem,
79 | {
80 | children,
81 | className,
82 | style,
83 | }: { children: ReactNode; className?: string; style?: CSSProperties }
84 | ) => ReactNode;
85 | columnWrapperStyle?: (column: BoardItem) => CSSProperties;
86 | columnHeaderStyle?: (column: BoardItem) => CSSProperties;
87 | columnListContentStyle?: (column: BoardItem) => CSSProperties;
88 | columnListContentClassName?: (column: BoardItem) => string;
89 | columnWrapperClassName?: (column: BoardItem) => string;
90 | columnHeaderClassName?: (column: BoardItem) => string;
91 | columnClassName?: (column: BoardItem) => string;
92 | columnStyle?: (column: BoardItem) => CSSProperties;
93 | rootStyle?: CSSProperties;
94 | rootClassName?: string;
95 | cardWrapperStyle?: (card: BoardItem, column: BoardItem) => CSSProperties;
96 | cardWrapperClassName?: string;
97 | virtualization?: boolean;
98 | cardsGap?: number;
99 | // renderGap?: (column: BoardItem) => ReactNode;
100 | onScroll?: (e: ScrollEvent, column: BoardItem) => void;
101 | onColumnMove?: ({
102 | columnId,
103 | fromIndex,
104 | toIndex,
105 | }: {
106 | columnId: string;
107 | fromIndex: number;
108 | toIndex: number;
109 | }) => void;
110 | onCardMove?: ({
111 | cardId,
112 | fromColumnId,
113 | toColumnId,
114 | taskAbove,
115 | taskBelow,
116 | position,
117 | }: {
118 | cardId: string;
119 | fromColumnId: string;
120 | toColumnId: string;
121 | taskAbove: string | null;
122 | taskBelow: string | null;
123 | position: number;
124 | }) => void;
125 | renderColumnFooter?: (column: BoardItem) => ReactNode;
126 | onColumnClick?: (
127 | e: React.MouseEvent,
128 | column: BoardItem
129 | ) => void;
130 | onCardClick?: (e: React.MouseEvent, card: BoardItem) => void;
131 | onCardDndStateChange?: (info: DndState) => void;
132 | onColumnDndStateChange?: (info: DndState) => void;
133 | }
134 |
135 | export interface DropParams {
136 | source: {
137 | id: string;
138 | data: any;
139 | };
140 | location: {
141 | current: {
142 | dropTargets: Array<{
143 | data: any;
144 | }>;
145 | };
146 | };
147 | columns: BoardItem[];
148 | dataSource: BoardData;
149 | onCardMove?: BoardProps["onCardMove"];
150 | onColumnMove?: BoardProps["onColumnMove"];
151 | }
152 |
--------------------------------------------------------------------------------
/rkk-demo/src/utils/kanbanUtils.ts:
--------------------------------------------------------------------------------
1 | import type { BoardData } from "react-kanban-kit";
2 |
3 | export const getAddCardPlaceholderKey = (columnId: string) =>
4 | `add-card-${columnId}`;
5 |
6 | export const addCardPlaceholder = (
7 | columnId: string,
8 | dataSource: BoardData,
9 | inTop: boolean = true
10 | ): BoardData => {
11 | const addCardPlaceholderKey = getAddCardPlaceholderKey(columnId);
12 |
13 | const alreadyHasAddCardPlaceholder = dataSource[columnId].children.includes(
14 | addCardPlaceholderKey
15 | );
16 |
17 | return {
18 | ...dataSource,
19 | [columnId]: {
20 | ...dataSource[columnId],
21 | totalChildrenCount: alreadyHasAddCardPlaceholder
22 | ? dataSource[columnId].totalChildrenCount - 1
23 | : dataSource[columnId].totalChildrenCount + 1,
24 | children: alreadyHasAddCardPlaceholder
25 | ? dataSource[columnId].children.filter(
26 | (child: string) => child !== addCardPlaceholderKey
27 | )
28 | : inTop
29 | ? [addCardPlaceholderKey, ...dataSource[columnId].children]
30 | : [...dataSource[columnId].children, addCardPlaceholderKey],
31 | },
32 | [addCardPlaceholderKey]: {
33 | id: addCardPlaceholderKey,
34 | title: "Add card",
35 | parentId: columnId,
36 | children: [],
37 | type: "new-card",
38 | content: {
39 | inTop,
40 | id: addCardPlaceholderKey,
41 | },
42 | },
43 | } as BoardData;
44 | };
45 |
46 | export const removeCardPlaceholder = (
47 | columnId: string,
48 | dataSource: BoardData
49 | ) => {
50 | const addCardPlaceholderKey = getAddCardPlaceholderKey(columnId);
51 | return {
52 | ...dataSource,
53 | [columnId]: {
54 | ...dataSource[columnId],
55 | totalChildrenCount: dataSource[columnId].totalChildrenCount - 1,
56 | children: dataSource[columnId].children.filter(
57 | (child: string) => child !== addCardPlaceholderKey
58 | ),
59 | },
60 | };
61 | };
62 |
63 | export const addCard = (
64 | columnId: string,
65 | dataSource: BoardData,
66 | title: string,
67 | inTop: boolean = true
68 | ): BoardData => {
69 | const newTaskId = `task-${title}-${Date.now()}`;
70 | return {
71 | ...dataSource,
72 | [columnId]: {
73 | ...dataSource[columnId],
74 | totalItemsCount: (dataSource[columnId].totalItemsCount || 0) + 1,
75 | children: [
76 | inTop ? newTaskId : null,
77 | ...dataSource[columnId].children.filter(
78 | (child: string) => child !== getAddCardPlaceholderKey(columnId)
79 | ),
80 | !inTop ? newTaskId : null,
81 | ].filter(Boolean),
82 | },
83 | [newTaskId]: {
84 | id: newTaskId,
85 | title,
86 | parentId: columnId,
87 | children: [],
88 | totalChildrenCount: 0,
89 | type: "card",
90 | content: {
91 | title,
92 | id: newTaskId,
93 | },
94 | },
95 | } as BoardData;
96 | };
97 |
98 | export const toggleCollapsedColumn = (
99 | columnId: string,
100 | dataSource: BoardData
101 | ): BoardData => {
102 | return {
103 | ...dataSource,
104 | [columnId]: {
105 | ...dataSource[columnId],
106 | content: {
107 | ...dataSource?.[columnId]?.content,
108 | isExpanded: !dataSource?.[columnId]?.content?.isExpanded,
109 | },
110 | },
111 | };
112 | };
113 |
114 | export const toggleCardOver = (
115 | columnId: string,
116 | dataSource: BoardData
117 | ): BoardData => {
118 | return {
119 | ...dataSource,
120 | [columnId]: {
121 | ...dataSource[columnId],
122 | content: {
123 | ...dataSource?.[columnId]?.content,
124 | isCardOver: !dataSource?.[columnId]?.content?.isCardOver,
125 | },
126 | },
127 | };
128 | };
129 |
130 | export const getPriorityColor = (priority: string) => {
131 | const colors = {
132 | high: "#ffc53d",
133 | medium: "#f59e0b",
134 | low: "#bbb",
135 | urgent: "#c62a2f",
136 | };
137 | return colors[priority as keyof typeof colors] || "#6b7280";
138 | };
139 |
140 | export const increaseColumnTotalItemsCount = (dataSource: BoardData) => {
141 | const columnsIds = dataSource?.root?.children;
142 | columnsIds.forEach((columnId: string) => {
143 | dataSource[columnId].totalChildrenCount =
144 | (dataSource[columnId].totalChildrenCount || 0) + 200;
145 | dataSource[columnId].totalItemsCount =
146 | (dataSource[columnId].totalItemsCount || 0) + 200;
147 | });
148 | return dataSource;
149 | };
150 |
151 | export const fetchTasks = () => {
152 | return new Promise((resolve) => {
153 | setTimeout(() => {
154 | resolve(200);
155 | }, 1000);
156 | });
157 | };
158 |
--------------------------------------------------------------------------------
/src/components/Column/Column.tsx:
--------------------------------------------------------------------------------
1 | import React, { Fragment, useEffect, useRef, useState } from "react";
2 | import {
3 | BoardItem,
4 | BoardProps,
5 | ConfigMap,
6 | DndState,
7 | ScrollEvent,
8 | } from "../types";
9 | import { withPrefix } from "@/utils/getPrefix";
10 | import classNames from "classnames";
11 | import ColumnHeader from "../ColumnHeader";
12 | import ColumnContent from "../ColumnContent";
13 | import { useColumnDnd } from "@/global/dnd/useColumnDnd";
14 |
15 | interface Props {
16 | index: number;
17 | data: BoardItem;
18 | configMap: ConfigMap;
19 | loadMore?: (columnId: string) => void;
20 | onColumnClick?: (
21 | e: React.MouseEvent,
22 | column: BoardItem
23 | ) => void;
24 | onCardClick?: (e: React.MouseEvent, card: BoardItem) => void;
25 | renderColumnHeader?: (column: BoardItem) => React.ReactNode;
26 | renderColumnFooter?: (column: BoardItem) => React.ReactNode;
27 | renderSkeletonCard?: BoardProps["renderSkeletonCard"];
28 | renderGap?: (column: BoardItem) => React.ReactNode;
29 | renderColumnWrapper: (
30 | column: BoardItem,
31 | {
32 | children,
33 | className,
34 | style,
35 | ref,
36 | }: {
37 | children: React.ReactNode;
38 | className?: string;
39 | style?: React.CSSProperties;
40 | ref?: React.RefObject;
41 | }
42 | ) => React.ReactNode;
43 | columnWrapperStyle?: (column: BoardItem) => React.CSSProperties;
44 | columnHeaderStyle?: (column: BoardItem) => React.CSSProperties;
45 | columnStyle?: (column: BoardItem) => React.CSSProperties;
46 | columnClassName?: (column: BoardItem) => string;
47 | onCardDndStateChange?: (info: DndState) => void;
48 | onColumnDndStateChange?: (info: DndState) => void;
49 | columnWrapperClassName?: (column: BoardItem) => string;
50 | columnHeaderClassName?: (column: BoardItem) => string;
51 | columnListContentStyle?: (column: BoardItem) => React.CSSProperties;
52 | renderCardDragIndicator?: (card: BoardItem, info: any) => React.ReactNode;
53 | renderColumnDragIndicator?: (column: BoardItem, info: any) => React.ReactNode;
54 | renderCardDragPreview?: (card: BoardItem, info: any) => React.ReactNode;
55 | renderColumnDragPreview?: (column: BoardItem, info: any) => React.ReactNode;
56 | columnListContentClassName?: (column: BoardItem) => string;
57 | renderListFooter?: (column: BoardItem) => React.ReactNode;
58 | renderColumnAdder?: (column: BoardItem) => React.ReactNode;
59 | items: BoardItem[];
60 | cardWrapperStyle?: (
61 | card: BoardItem,
62 | column: BoardItem
63 | ) => React.CSSProperties;
64 | cardWrapperClassName?: string;
65 | onScroll?: (e: ScrollEvent, column: BoardItem) => void;
66 | }
67 |
68 | const Column = (props: Props) => {
69 | const {
70 | index,
71 | data,
72 | items,
73 | onColumnClick,
74 | renderColumnHeader,
75 | renderColumnWrapper,
76 | renderColumnFooter,
77 | columnWrapperStyle,
78 | columnHeaderStyle,
79 | onColumnDndStateChange,
80 | columnWrapperClassName,
81 | columnHeaderClassName,
82 | columnListContentClassName,
83 | columnClassName,
84 | columnStyle,
85 | renderColumnAdder,
86 | ...rest
87 | } = props;
88 |
89 | const {
90 | headerRef,
91 | outerFullHeightRef,
92 | innerRef,
93 | state,
94 | cardOverShadowCount,
95 | } = useColumnDnd(data, index, items, onColumnDndStateChange);
96 |
97 | const containerClassName = classNames(
98 | withPrefix("column-outer"),
99 | columnWrapperClassName?.(data)
100 | );
101 |
102 | const ColumnWrapper = (children: React.ReactNode) =>
103 | renderColumnWrapper ? (
104 | renderColumnWrapper(data, {
105 | children,
106 | className: containerClassName,
107 | style: columnWrapperStyle?.(data),
108 | ref: outerFullHeightRef,
109 | })
110 | ) : (
111 |
116 | {children}
117 |
118 | );
119 |
120 | return (
121 | onColumnClick?.(e, data)}>
122 | {ColumnWrapper(
123 |
128 |
129 |
136 |
146 | {renderColumnFooter?.(data)}
147 |
148 |
149 | )}
150 |
151 | );
152 | };
153 |
154 | export default Column;
155 |
--------------------------------------------------------------------------------
/src/components/CardSkeleton/_CardSkeleton.scss:
--------------------------------------------------------------------------------
1 | @use "../../global/assets/styles/abstracts/variables" as *;
2 |
3 | // Shimmer animation keyframes
4 | @keyframes skeleton-shimmer {
5 | 0% {
6 | background-position: -200px 0;
7 | }
8 | 100% {
9 | background-position: calc(200px + 100%) 0;
10 | }
11 | }
12 |
13 | // Wave animation keyframes
14 | @keyframes skeleton-wave {
15 | 0% {
16 | transform: translateX(-100%);
17 | }
18 | 50% {
19 | transform: translateX(100%);
20 | }
21 | 100% {
22 | transform: translateX(100%);
23 | }
24 | }
25 |
26 | // Pulse animation keyframes
27 | @keyframes skeleton-pulse {
28 | 0%,
29 | 100% {
30 | opacity: 1;
31 | }
32 | 50% {
33 | opacity: 0.4;
34 | }
35 | }
36 |
37 | .#{$prefix}-skeleton {
38 | border-radius: 8px;
39 | border: 1px solid #e8ecf5;
40 | background: #fff;
41 | padding: 16px;
42 | margin-bottom: 8px;
43 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
44 | transition: box-shadow 0.2s ease;
45 | position: relative;
46 | overflow: hidden;
47 |
48 | &:hover {
49 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
50 | }
51 |
52 | &-content {
53 | display: flex;
54 | flex-direction: column;
55 | gap: 12px;
56 | }
57 |
58 | // Base skeleton element styles
59 | %skeleton-base {
60 | background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
61 | background-size: 200px 100%;
62 | animation: skeleton-shimmer 1.5s infinite ease-in-out;
63 | border-radius: 4px;
64 | position: relative;
65 | }
66 |
67 | // Wave animation base
68 | %skeleton-wave-base {
69 | background: #f0f0f0;
70 | position: relative;
71 | overflow: hidden;
72 | border-radius: 4px;
73 |
74 | &::after {
75 | position: absolute;
76 | top: 0;
77 | right: 0;
78 | bottom: 0;
79 | left: 0;
80 | transform: translateX(-100%);
81 | background: linear-gradient(
82 | 90deg,
83 | rgba(255, 255, 255, 0) 0,
84 | rgba(255, 255, 255, 0.2) 20%,
85 | rgba(255, 255, 255, 0.5) 60%,
86 | rgba(255, 255, 255, 0)
87 | );
88 | animation: skeleton-wave 2s infinite;
89 | content: "";
90 | }
91 | }
92 |
93 | // Title skeleton
94 | &-title {
95 | @extend %skeleton-base;
96 | height: 20px;
97 | width: 80%;
98 | border-radius: 6px;
99 | }
100 |
101 | // Description section
102 | &-description {
103 | display: flex;
104 | flex-direction: column;
105 | gap: 8px;
106 | }
107 |
108 | &-line {
109 | @extend %skeleton-base;
110 | height: 14px;
111 | width: 100%;
112 |
113 | &-short {
114 | width: 65%;
115 | }
116 | }
117 |
118 | // Priority indicator
119 | &-priority {
120 | @extend %skeleton-base;
121 | height: 4px;
122 | width: 40px;
123 | border-radius: 2px;
124 | margin-bottom: 4px;
125 | }
126 |
127 | // Tags section
128 | &-tags {
129 | display: flex;
130 | gap: 8px;
131 | flex-wrap: wrap;
132 | }
133 |
134 | &-tag {
135 | @extend %skeleton-base;
136 | height: 20px;
137 | width: 60px;
138 | border-radius: 12px;
139 |
140 | &:nth-child(2) {
141 | width: 45px;
142 | }
143 |
144 | &:nth-child(3) {
145 | width: 55px;
146 | }
147 | }
148 |
149 | // Footer section
150 | &-footer {
151 | display: flex;
152 | justify-content: space-between;
153 | align-items: center;
154 | margin-top: 4px;
155 |
156 | &-left {
157 | display: flex;
158 | align-items: center;
159 | gap: 8px;
160 | }
161 | }
162 |
163 | &-avatar {
164 | @extend %skeleton-base;
165 | width: 24px;
166 | height: 24px;
167 | border-radius: 50%;
168 | flex-shrink: 0;
169 | }
170 |
171 | &-assignee {
172 | @extend %skeleton-base;
173 | height: 12px;
174 | width: 60px;
175 | border-radius: 3px;
176 | }
177 |
178 | &-date {
179 | @extend %skeleton-base;
180 | height: 12px;
181 | width: 80px;
182 | border-radius: 3px;
183 | }
184 | }
185 |
186 | // Wave animation variant
187 | .#{$prefix}-skeleton-wave {
188 | .#{$prefix}-skeleton-title,
189 | .#{$prefix}-skeleton-line,
190 | .#{$prefix}-skeleton-tag,
191 | .#{$prefix}-skeleton-avatar,
192 | .#{$prefix}-skeleton-assignee,
193 | .#{$prefix}-skeleton-date,
194 | .#{$prefix}-skeleton-priority {
195 | @extend %skeleton-wave-base;
196 | animation: none; // Remove shimmer animation
197 | }
198 | }
199 |
200 | // Pulse animation variant
201 | .#{$prefix}-skeleton-pulse {
202 | .#{$prefix}-skeleton-title,
203 | .#{$prefix}-skeleton-line,
204 | .#{$prefix}-skeleton-tag,
205 | .#{$prefix}-skeleton-avatar,
206 | .#{$prefix}-skeleton-assignee,
207 | .#{$prefix}-skeleton-date,
208 | .#{$prefix}-skeleton-priority {
209 | background: #f0f0f0;
210 | animation: skeleton-pulse 1.5s ease-in-out infinite;
211 | }
212 | }
213 |
214 | // Responsive design for smaller screens
215 | @media (max-width: 768px) {
216 | .#{$prefix}-skeleton {
217 | padding: 12px;
218 |
219 | &-content {
220 | gap: 10px;
221 | }
222 |
223 | &-title {
224 | height: 18px;
225 | }
226 |
227 | &-line {
228 | height: 12px;
229 | }
230 |
231 | &-tag {
232 | height: 18px;
233 | width: 50px;
234 |
235 | &:nth-child(2) {
236 | width: 40px;
237 | }
238 | }
239 |
240 | &-avatar {
241 | width: 20px;
242 | height: 20px;
243 | }
244 | }
245 | }
246 |
--------------------------------------------------------------------------------
/rkk-demo/src/global/assets/styles/abstracts/_variables.scss:
--------------------------------------------------------------------------------
1 | // Demo app prefix
2 | $demo-prefix: "rkk-demo";
3 |
4 | // Layout variables with professional sizing
5 | :root {
6 | // Header with professional sizing
7 | --header-height: 64px;
8 | --header-bg: rgba(255, 255, 255, 0.95);
9 | --header-border: rgba(59, 130, 246, 0.08);
10 | --header-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
11 | --header-backdrop: blur(16px);
12 |
13 | // Sidebar with professional proportions
14 | --sidebar-width: 280px;
15 | --sidebar-collapsed-width: 72px;
16 | --sidebar-bg: linear-gradient(145deg, #fafbff 0%, #f5f7ff 100%);
17 | --sidebar-border: rgba(59, 130, 246, 0.08);
18 |
19 | // Professional color palette
20 | --primary-50: #eff6ff;
21 | --primary-100: #dbeafe;
22 | --primary-200: #bfdbfe;
23 | --primary-300: #93c5fd;
24 | --primary-400: #60a5fa;
25 | --primary-500: #3b82f6;
26 | --primary-600: #2563eb;
27 | --primary-700: #1d4ed8;
28 | --primary-800: #1e40af;
29 | --primary-900: #1e3a8a;
30 | --primary-950: #172554;
31 |
32 | // Clean gray scale
33 | --gray-25: #fcfcfd;
34 | --gray-50: #f9fafb;
35 | --gray-100: #f3f4f6;
36 | --gray-200: #e5e7eb;
37 | --gray-300: #d1d5db;
38 | --gray-400: #9ca3af;
39 | --gray-500: #6b7280;
40 | --gray-600: #4b5563;
41 | --gray-700: #374151;
42 | --gray-800: #1f2937;
43 | --gray-900: #111827;
44 | --gray-950: #030712;
45 |
46 | // Professional accent colors
47 | --accent-success: #10b981;
48 | --accent-warning: #f59e0b;
49 | --accent-danger: #ef4444;
50 | --accent-info: #06b6d4;
51 |
52 | // Professional typography
53 | --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
54 | "Helvetica Neue", Arial, sans-serif;
55 | --font-mono: "JetBrains Mono", "SF Mono", Monaco, Consolas, monospace;
56 | --font-display: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
57 |
58 | // Clean spacing scale
59 | --space-px: 1px;
60 | --space-0-5: 0.125rem;
61 | --space-1: 0.25rem;
62 | --space-1-5: 0.375rem;
63 | --space-2: 0.5rem;
64 | --space-2-5: 0.625rem;
65 | --space-3: 0.75rem;
66 | --space-3-5: 0.875rem;
67 | --space-4: 1rem;
68 | --space-5: 1.25rem;
69 | --space-6: 1.5rem;
70 | --space-7: 1.75rem;
71 | --space-8: 2rem;
72 | --space-10: 2.5rem;
73 | --space-12: 3rem;
74 | --space-16: 4rem;
75 | --space-20: 5rem;
76 | --space-24: 6rem;
77 |
78 | // Professional border radius
79 | --radius-none: 0px;
80 | --radius-sm: 0.125rem;
81 | --radius-base: 0.25rem;
82 | --radius-md: 0.375rem;
83 | --radius-lg: 0.5rem;
84 | --radius-xl: 0.75rem;
85 | --radius-2xl: 1rem;
86 | --radius-3xl: 1.5rem;
87 | --radius-full: 9999px;
88 |
89 | // Clean shadow system
90 | --shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
91 | --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
92 | --shadow-base: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
93 | --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
94 | 0 2px 4px -1px rgba(0, 0, 0, 0.06);
95 | --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
96 | 0 4px 6px -2px rgba(0, 0, 0, 0.05);
97 | --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
98 | 0 10px 10px -5px rgba(0, 0, 0, 0.04);
99 | --shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
100 | --shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06);
101 |
102 | // Professional colored shadows
103 | --shadow-primary: 0 4px 6px -1px rgba(59, 130, 246, 0.1),
104 | 0 2px 4px -1px rgba(59, 130, 246, 0.06);
105 | --shadow-success: 0 4px 6px -1px rgba(16, 185, 129, 0.1),
106 | 0 2px 4px -1px rgba(16, 185, 129, 0.06);
107 | --shadow-warning: 0 4px 6px -1px rgba(245, 158, 11, 0.1),
108 | 0 2px 4px -1px rgba(245, 158, 11, 0.06);
109 | --shadow-danger: 0 4px 6px -1px rgba(239, 68, 68, 0.1),
110 | 0 2px 4px -1px rgba(239, 68, 68, 0.06);
111 |
112 | // Subtle transitions
113 | --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
114 | --transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
115 | --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
116 |
117 | // Professional easing
118 | --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
119 | --ease-out: cubic-bezier(0, 0, 0.2, 1);
120 | --ease-in: cubic-bezier(0.4, 0, 1, 1);
121 |
122 | // Z-index scale
123 | --z-hide: -1;
124 | --z-auto: auto;
125 | --z-base: 0;
126 | --z-docked: 10;
127 | --z-dropdown: 1000;
128 | --z-sticky: 1020;
129 | --z-banner: 1030;
130 | --z-overlay: 1040;
131 | --z-modal: 1050;
132 | --z-popover: 1060;
133 | --z-tooltip: 1090;
134 |
135 | // Professional gradients
136 | --gradient-primary: linear-gradient(
137 | 135deg,
138 | var(--primary-500) 0%,
139 | var(--primary-600) 100%
140 | );
141 | --gradient-secondary: linear-gradient(
142 | 135deg,
143 | var(--gray-600) 0%,
144 | var(--gray-700) 100%
145 | );
146 | --gradient-success: linear-gradient(
147 | 135deg,
148 | var(--accent-success) 0%,
149 | #059669 100%
150 | );
151 | --gradient-warning: linear-gradient(
152 | 135deg,
153 | var(--accent-warning) 0%,
154 | #d97706 100%
155 | );
156 | --gradient-danger: linear-gradient(
157 | 135deg,
158 | var(--accent-danger) 0%,
159 | #dc2626 100%
160 | );
161 |
162 | // Subtle background patterns
163 | --bg-pattern-dots: radial-gradient(
164 | circle at 1px 1px,
165 | rgba(59, 130, 246, 0.03) 1px,
166 | transparent 0
167 | );
168 | --bg-pattern-grid: linear-gradient(
169 | rgba(59, 130, 246, 0.02) 1px,
170 | transparent 1px
171 | ),
172 | linear-gradient(90deg, rgba(59, 130, 246, 0.02) 1px, transparent 1px);
173 |
174 | // Professional spacing tokens
175 | --content-max-width: 1200px;
176 | --sidebar-max-width: 280px;
177 | --header-max-height: 64px;
178 | --card-min-height: 100px;
179 | --button-min-height: 40px;
180 | --input-min-height: 40px;
181 |
182 | // Professional typography scale
183 | --text-xs: 0.75rem;
184 | --text-sm: 0.875rem;
185 | --text-base: 1rem;
186 | --text-lg: 1.125rem;
187 | --text-xl: 1.25rem;
188 | --text-2xl: 1.5rem;
189 | --text-3xl: 1.875rem;
190 | --text-4xl: 2.25rem;
191 |
192 | // Line height scale
193 | --leading-tight: 1.25;
194 | --leading-snug: 1.375;
195 | --leading-normal: 1.5;
196 | --leading-relaxed: 1.625;
197 |
198 | // Letter spacing scale
199 | --tracking-tight: -0.025em;
200 | --tracking-normal: 0em;
201 | --tracking-wide: 0.025em;
202 | }
203 |
--------------------------------------------------------------------------------
/rkk-demo/src/components/Sidebar/_Sidebar.scss:
--------------------------------------------------------------------------------
1 | @use "../../global/assets/styles/abstracts" as *;
2 |
3 | .#{$demo-prefix}-sidebar {
4 | width: var(--sidebar-width);
5 | background: var(--sidebar-bg);
6 | border-right: 1px solid var(--sidebar-border);
7 | height: calc(100vh - var(--header-height));
8 | overflow-y: auto;
9 | @include custom-scrollbar(
10 | 6px,
11 | rgba(148, 163, 184, 0.1),
12 | rgba(148, 163, 184, 0.3)
13 | );
14 | position: relative;
15 |
16 | // Subtle pattern overlay
17 | &::before {
18 | content: "";
19 | position: absolute;
20 | top: 0;
21 | left: 0;
22 | right: 0;
23 | bottom: 0;
24 | background-image: var(--bg-pattern-dots);
25 | background-size: 16px 16px;
26 | opacity: 0.2;
27 | pointer-events: none;
28 | }
29 |
30 | @include media-down(lg) {
31 | width: var(--sidebar-collapsed-width);
32 | }
33 |
34 | @include media-down(md) {
35 | position: fixed;
36 | left: -100%;
37 | top: var(--header-height);
38 | width: var(--sidebar-width);
39 | z-index: var(--z-overlay);
40 | transition: left var(--transition-base);
41 | box-shadow: var(--shadow-xl);
42 | backdrop-filter: blur(16px);
43 |
44 | &.open {
45 | left: 0;
46 | }
47 | }
48 |
49 | &-content {
50 | padding: var(--space-6) 0;
51 | position: relative;
52 | z-index: 1;
53 |
54 | @include media-down(lg) {
55 | padding: var(--space-4) 0;
56 | }
57 | }
58 |
59 | &-section {
60 | padding: 0 var(--space-6);
61 |
62 | @include media-down(lg) {
63 | padding: 0 var(--space-3);
64 | }
65 |
66 | &:not(:last-child) {
67 | margin-bottom: var(--space-8);
68 | padding-bottom: var(--space-6);
69 | position: relative;
70 |
71 | // Clean divider
72 | &::after {
73 | content: "";
74 | position: absolute;
75 | bottom: 0;
76 | left: var(--space-6);
77 | right: var(--space-6);
78 | height: 1px;
79 | background: linear-gradient(
80 | 90deg,
81 | transparent,
82 | var(--gray-200),
83 | transparent
84 | );
85 |
86 | @include media-down(lg) {
87 | left: var(--space-3);
88 | right: var(--space-3);
89 | }
90 | }
91 | }
92 | }
93 |
94 | &-title {
95 | font-size: 0.625rem;
96 | font-weight: 700;
97 | text-transform: uppercase;
98 | letter-spacing: 0.1em;
99 | color: var(--gray-500);
100 | margin-bottom: var(--space-4);
101 | position: relative;
102 | @include flex-start;
103 | gap: var(--space-2);
104 |
105 | @include media-down(lg) {
106 | display: none;
107 | }
108 |
109 | // Simple accent line
110 | &::before {
111 | content: "";
112 | width: 12px;
113 | height: 2px;
114 | background: var(--gradient-primary);
115 | border-radius: var(--radius-full);
116 | flex-shrink: 0;
117 | }
118 | }
119 |
120 | &-nav {
121 | display: flex;
122 | flex-direction: column;
123 | gap: var(--space-2);
124 |
125 | &-item {
126 | @include flex-start;
127 | gap: var(--space-3);
128 | padding: var(--space-3) var(--space-4);
129 | border-radius: var(--radius-lg);
130 | color: var(--gray-600);
131 | text-decoration: none;
132 | font-size: var(--text-sm);
133 | font-weight: 500;
134 | transition: all var(--transition-base);
135 | position: relative;
136 | background: rgba(255, 255, 255, 0.6);
137 | border: 1px solid rgba(255, 255, 255, 0.8);
138 | backdrop-filter: blur(8px);
139 |
140 | &:hover {
141 | color: var(--gray-900);
142 | transform: translateY(-1px);
143 | box-shadow: var(--shadow-md);
144 | border-color: rgba(59, 130, 246, 0.15);
145 | background: rgba(255, 255, 255, 0.9);
146 |
147 | // Icon animation
148 | svg {
149 | transform: scale(1.05);
150 | color: var(--primary-600);
151 | }
152 | }
153 |
154 | &:active {
155 | transform: translateY(0);
156 | box-shadow: var(--shadow-sm);
157 | }
158 |
159 | // Icon styling
160 | svg {
161 | transition: all var(--transition-base);
162 | flex-shrink: 0;
163 | }
164 |
165 | @include media-down(lg) {
166 | justify-content: center;
167 | padding: var(--space-3);
168 |
169 | span {
170 | display: none;
171 | }
172 | }
173 | }
174 | }
175 | }
176 |
177 | // Clean breadcrumb
178 | .#{$demo-prefix}-breadcrumb {
179 | @include flex-start;
180 | gap: var(--space-2);
181 | padding: var(--space-3) var(--space-6);
182 | font-size: 0.75rem;
183 | color: var(--gray-500);
184 | background: rgba(59, 130, 246, 0.02);
185 | border-bottom: 1px solid rgba(59, 130, 246, 0.08);
186 |
187 | @include media-down(lg) {
188 | display: none;
189 | }
190 |
191 | &-item {
192 | &:not(:last-child)::after {
193 | content: "/";
194 | margin-left: var(--space-2);
195 | color: var(--gray-300);
196 | }
197 | }
198 |
199 | &-current {
200 | color: var(--primary-600);
201 | font-weight: 500;
202 | }
203 | }
204 |
205 | // Mobile sidebar toggle
206 | .#{$demo-prefix}-sidebar-toggle {
207 | @include button-primary;
208 | position: fixed;
209 | bottom: var(--space-6);
210 | right: var(--space-6);
211 | width: 48px;
212 | height: 48px;
213 | border-radius: 50%;
214 | z-index: var(--z-modal);
215 | display: none;
216 | padding: 0;
217 | box-shadow: var(--shadow-xl), var(--shadow-primary);
218 |
219 | @include media-down(md) {
220 | display: flex;
221 | }
222 |
223 | svg {
224 | transition: transform var(--transition-base);
225 | }
226 |
227 | &.active svg {
228 | transform: rotate(180deg);
229 | }
230 | }
231 |
232 | // Active indicator for current page
233 | .#{$demo-prefix}-progress-indicator {
234 | position: absolute;
235 | left: 0;
236 | top: var(--space-1);
237 | bottom: var(--space-1);
238 | width: 3px;
239 | background: var(--gradient-primary);
240 | border-radius: 0 var(--radius-full) var(--radius-full) 0;
241 | opacity: 0;
242 | transform: scaleY(0);
243 | transform-origin: top;
244 | transition: all var(--transition-base);
245 |
246 | .active & {
247 | opacity: 1;
248 | transform: scaleY(1);
249 | }
250 | }
251 |
--------------------------------------------------------------------------------
/src/components/Card/Card.tsx:
--------------------------------------------------------------------------------
1 | import { withPrefix } from "@/utils/getPrefix";
2 | import React, { Fragment, memo, useMemo } from "react";
3 | import { BoardItem, DndState } from "../types";
4 | import { createPortal } from "react-dom";
5 | import { TaskCardState, useCardDnd } from "@/global/dnd/useCardDnd";
6 |
7 | export const CardShadow = memo(
8 | ({
9 | height,
10 | customIndicator,
11 | }: {
12 | height: number;
13 | customIndicator?: React.ReactNode;
14 | }) => {
15 | return (
16 |
17 | {customIndicator || (
18 |
22 | )}
23 |
24 | );
25 | }
26 | );
27 |
28 | const CardDisplay = (props: {
29 | outerRef?: React.RefObject;
30 | innerRef?: React.RefObject;
31 | state: TaskCardState;
32 | data: BoardItem;
33 | column: BoardItem;
34 | index: number;
35 | isDraggable: boolean;
36 | render: (props: {
37 | data: BoardItem;
38 | column: BoardItem;
39 | index: number;
40 | isDraggable: boolean;
41 | }) => React.ReactNode;
42 | onClick?: (e: React.MouseEvent, card: BoardItem) => void;
43 | cardsGap?: number;
44 | renderCardDragIndicator?: (card: BoardItem, info: any) => React.ReactNode;
45 | renderGap?: (column: BoardItem) => React.ReactNode;
46 | }) => {
47 | const {
48 | outerRef,
49 | innerRef,
50 | state,
51 | data,
52 | column,
53 | index,
54 | isDraggable,
55 | cardsGap,
56 | render,
57 | onClick,
58 | renderCardDragIndicator,
59 | renderGap,
60 | } = props;
61 |
62 | const containerStyle = useMemo(() => {
63 | const styles: React.CSSProperties = {};
64 | if (state.type === "is-dragging-and-left-self") {
65 | styles.display = "none";
66 | }
67 | return styles;
68 | }, [state.type]);
69 |
70 | const innerStyle = useMemo(() => {
71 | if (state.type === "is-dragging") {
72 | return { opacity: 0.6 };
73 | }
74 | if (state.type === "preview") {
75 | return {
76 | width: state.dragging.width,
77 | height: state.dragging.height,
78 | transform: "rotate(4deg)",
79 | };
80 | }
81 | return {};
82 | }, [state]);
83 |
84 | const showTopShadow = state.type === "is-over" && state.closestEdge === "top";
85 | const showBottomShadow =
86 | state.type === "is-over" && state.closestEdge === "bottom";
87 | const shadowHeight = state.type === "is-over" ? state.dragging.height : 0;
88 | const renderContent = render({ data, column, index, isDraggable });
89 | const customIndicator = renderCardDragIndicator?.(
90 | state.type === "is-dragging" ? data : null,
91 | {
92 | height: shadowHeight,
93 | }
94 | );
95 |
96 | return (
97 |
98 | onClick?.(e, data)}
102 | style={{
103 | ...containerStyle,
104 | ...(cardsGap !== undefined ? { marginBottom: cardsGap } : {}),
105 | }}
106 | data-test-id={data?.id}
107 | data-rkk-column={column?.id}
108 | data-rkk-index={index}
109 | >
110 | {showTopShadow && (
111 |
112 | )}
113 |
122 | {renderContent}
123 |
124 | {showBottomShadow && (
125 |
126 | )}
127 |
128 | {/* {renderGap?.(column)} */}
129 |
130 | );
131 | };
132 |
133 | interface Props {
134 | render: (props: {
135 | data: BoardItem;
136 | column: BoardItem;
137 | index: number;
138 | isDraggable: boolean;
139 | }) => React.ReactNode;
140 | data: BoardItem;
141 | column: BoardItem;
142 | index: number;
143 | isDraggable: boolean;
144 | onClick?: (e: React.MouseEvent, card: BoardItem) => void;
145 | cardsGap?: number;
146 | renderGap?: (column: BoardItem) => React.ReactNode;
147 | onCardDndStateChange?: (info: DndState) => void;
148 | renderCardDragIndicator?: (card: BoardItem, info: any) => React.ReactNode;
149 | renderCardDragPreview?: (card: BoardItem, info: any) => React.ReactNode;
150 | }
151 |
152 | const Card = (props: Props) => {
153 | const {
154 | render,
155 | data,
156 | column,
157 | index,
158 | isDraggable,
159 | cardsGap,
160 | onClick,
161 | onCardDndStateChange,
162 | renderCardDragIndicator,
163 | renderCardDragPreview,
164 | renderGap,
165 | } = props;
166 | const { outerRef, innerRef, state } = useCardDnd(
167 | data,
168 | column,
169 | index,
170 | isDraggable,
171 | onCardDndStateChange
172 | );
173 |
174 | return (
175 | <>
176 |
190 |
191 | {state.type === "preview"
192 | ? createPortal(
193 | renderCardDragPreview?.(data, {
194 | state,
195 | data,
196 | column,
197 | index,
198 | isDraggable,
199 | }) || (
200 |
208 | ),
209 | state.container
210 | )
211 | : null}
212 | >
213 | );
214 | };
215 |
216 | export default Card;
217 |
--------------------------------------------------------------------------------
/src/global/dnd/useCardDnd.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from "react";
2 | import { BoardItem, DndState } from "@/components/types";
3 | import {
4 | draggable,
5 | dropTargetForElements,
6 | } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
7 | import {
8 | attachClosestEdge,
9 | extractClosestEdge,
10 | Edge,
11 | } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
12 | import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
13 | import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
14 | import { preserveOffsetOnSource } from "@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source";
15 | import { useKanbanContext } from "@/context/KanbanContext";
16 |
17 | export type TaskCardState =
18 | | {
19 | type: "idle";
20 | }
21 | | {
22 | type: "is-dragging";
23 | }
24 | | {
25 | type: "is-dragging-and-left-self";
26 | }
27 | | {
28 | type: "is-over";
29 | dragging: DOMRect;
30 | closestEdge: Edge;
31 | }
32 | | {
33 | type: "preview";
34 | container: HTMLElement;
35 | dragging: DOMRect;
36 | };
37 |
38 | const idle: TaskCardState = { type: "idle" };
39 |
40 | // Custom hook to handle all drag and drop logic
41 | export const useCardDnd = (
42 | data: BoardItem = {} as BoardItem,
43 | column: BoardItem = {} as BoardItem,
44 | index: number,
45 | isDraggable: boolean,
46 | onCardDndStateChange?: (info: DndState) => void
47 | ) => {
48 | const { viewOnly } = useKanbanContext();
49 | const outerRef = useRef(null);
50 | const innerRef = useRef(null);
51 | const [state, setState] = useState(idle);
52 |
53 | // Memoize initial data to prevent recreating on each render
54 | const getInitialData = useCallback(
55 | () => ({
56 | type: "card",
57 | itemId: data?.id,
58 | columnId: column?.id,
59 | index,
60 | isDraggable,
61 | parentId: data.parentId,
62 | rect: innerRef.current?.getBoundingClientRect() || null,
63 | }),
64 | [data?.id, column?.id, index, isDraggable, data?.parentId]
65 | );
66 |
67 | const getDropTargetData = useCallback(
68 | ({ input, element }) => {
69 | const cardData = {
70 | type: "card",
71 | "card-drop-target": true,
72 | itemId: data.id,
73 | columnId: column.id,
74 | index,
75 | isDraggable,
76 | parentId: data.parentId,
77 | };
78 |
79 | return attachClosestEdge(cardData, {
80 | input,
81 | element,
82 | allowedEdges: ["top", "bottom"],
83 | });
84 | },
85 | [data?.id, column?.id, index, isDraggable, data?.parentId]
86 | );
87 |
88 | // Optimize the drop check to avoid recalculating on every drag move
89 | const canDrop = useCallback(
90 | (args) => {
91 | const sourceData = args.source.data;
92 | if (sourceData.itemId === data.parentId) return false;
93 | return sourceData.isDraggable;
94 | },
95 | [data?.id, data?.parentId]
96 | );
97 |
98 | // Drag and drop event handlers
99 | const handleGenerateDragPreview = useCallback(
100 | ({ nativeSetDragImage, location }) => {
101 | setCustomNativeDragPreview({
102 | nativeSetDragImage,
103 | getOffset: preserveOffsetOnSource({
104 | element: innerRef.current!,
105 | input: location.current.input,
106 | }),
107 | render({ container }) {
108 | const rect = innerRef.current!.getBoundingClientRect();
109 | setState({
110 | type: "preview",
111 | container,
112 | dragging: rect,
113 | });
114 | },
115 | });
116 | },
117 | []
118 | );
119 |
120 | const handleDragStart = useCallback(() => {
121 | setState({ type: "is-dragging" });
122 | }, []);
123 |
124 | const handleDrop = useCallback(() => {
125 | setState(idle);
126 | }, []);
127 |
128 | const handleDragEnter = useCallback(
129 | ({ source, self }) => {
130 | if (source.data.type !== "card") return;
131 | if (source.data.itemId === data.id) return;
132 |
133 | const closestEdge = extractClosestEdge(self.data);
134 | if (!closestEdge) return;
135 |
136 | setState({
137 | type: "is-over",
138 | dragging: source.data.rect as DOMRect,
139 | closestEdge,
140 | });
141 | },
142 | [data?.id]
143 | );
144 |
145 | const handleDrag = useCallback(
146 | ({ source, self }) => {
147 | if (source.data.type !== "card") return;
148 | if (source.data.itemId === data.id) return;
149 |
150 | const closestEdge = extractClosestEdge(self.data);
151 | if (!closestEdge) return;
152 |
153 | setState({
154 | type: "is-over",
155 | dragging: source.data.rect as DOMRect,
156 | closestEdge,
157 | });
158 | },
159 | [data?.id]
160 | );
161 |
162 | const handleDragLeave = useCallback(
163 | ({ source }) => {
164 | if (source.data.type !== "card") return;
165 |
166 | if (source.data.itemId === data?.id) {
167 | setState({ type: "is-dragging-and-left-self" });
168 | return;
169 | }
170 |
171 | setState(idle);
172 | },
173 | [data.id]
174 | );
175 |
176 | // Setup drag and drop effects
177 | useEffect(() => {
178 | const outer = outerRef.current;
179 | const inner = innerRef.current;
180 |
181 | if (!outer || !inner) return;
182 |
183 | return combine(
184 | draggable({
185 | element: inner,
186 | getInitialData,
187 | onGenerateDragPreview: handleGenerateDragPreview,
188 | onDragStart: handleDragStart,
189 | onDrop: handleDrop,
190 | canDrag: () => isDraggable && !viewOnly,
191 | }),
192 | dropTargetForElements({
193 | element: outer,
194 | canDrop,
195 | getIsSticky: () => true,
196 | getData: getDropTargetData,
197 | onDragEnter: handleDragEnter,
198 | onDrag: handleDrag,
199 | onDragLeave: handleDragLeave,
200 | onDrop: handleDrop,
201 | })
202 | );
203 | }, [
204 | getInitialData,
205 | handleGenerateDragPreview,
206 | handleDragStart,
207 | handleDrop,
208 | isDraggable,
209 | canDrop,
210 | getDropTargetData,
211 | handleDragEnter,
212 | handleDrag,
213 | handleDragLeave,
214 | ]);
215 |
216 | useEffect(() => {
217 | onCardDndStateChange?.({ state, card: data, column });
218 | }, [state, onCardDndStateChange]);
219 |
220 | return {
221 | outerRef,
222 | innerRef,
223 | state,
224 | };
225 | };
226 |
--------------------------------------------------------------------------------
/src/global/dnd/useColumnDnd.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from "react";
2 | import { BoardItem, DndState } from "@/components/types";
3 | import { withPrefix } from "@/utils/getPrefix";
4 | import {
5 | draggable,
6 | dropTargetForElements,
7 | } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
8 | import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
9 | import { preserveOffsetOnSource } from "@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source";
10 | import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
11 | import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
12 | import { useKanbanContext } from "@/context/KanbanContext";
13 |
14 | export type TColumnState =
15 | | {
16 | type: "is-card-over";
17 | isOverChildCard: boolean;
18 | dragging: DOMRect;
19 | }
20 | | {
21 | type: "is-column-over";
22 | }
23 | | {
24 | type: "idle";
25 | }
26 | | {
27 | type: "is-dragging";
28 | };
29 |
30 | const isCardData = (data: any) => {
31 | return data.type === "card";
32 | };
33 |
34 | const isColumnData = (data: any) => {
35 | return data.type === "column";
36 | };
37 |
38 | const idle = { type: "idle" } as TColumnState;
39 |
40 | export const useColumnDnd = (
41 | data: BoardItem,
42 | index: number,
43 | items: BoardItem[],
44 | onColumnDndStateChange?: (info: DndState) => void
45 | ) => {
46 | const { viewOnly } = useKanbanContext();
47 | const headerRef = useRef(null);
48 | const outerFullHeightRef = useRef(null);
49 | const innerRef = useRef(null);
50 | const [state, setState] = useState(idle);
51 |
52 | const cardOverShadowCount =
53 | state.type === "is-card-over" && !state.isOverChildCard ? 1 : 0;
54 | const totalTasksCount = data.totalChildrenCount + cardOverShadowCount;
55 |
56 | const setIsCardOver = useCallback(
57 | ({ data, location }: { data: any; location: any }) => {
58 | const innerMost = location.current.dropTargets[0];
59 | const isOverChildCard = Boolean(innerMost?.data["card-drop-target"]);
60 |
61 | const proposed: TColumnState = {
62 | type: "is-card-over",
63 | dragging: data.rect,
64 | isOverChildCard,
65 | };
66 |
67 | setState(proposed);
68 | },
69 | []
70 | );
71 |
72 | const handleGenerateDragPreview = useCallback(
73 | ({ location, nativeSetDragImage }) => {
74 | setCustomNativeDragPreview({
75 | nativeSetDragImage,
76 | getOffset: preserveOffsetOnSource({
77 | element: headerRef.current!,
78 | input: location.current.input,
79 | }),
80 | render({ container }) {
81 | const rect = innerRef.current!.getBoundingClientRect();
82 | const preview = innerRef.current!.cloneNode(true) as HTMLElement;
83 | if (!preview) return;
84 |
85 | preview.style.width = `${rect.width}px`;
86 | preview.style.height = `${rect.height}px`;
87 | preview.style.transform = "rotate(4deg)";
88 |
89 | container.appendChild(preview);
90 | },
91 | });
92 | },
93 | []
94 | );
95 |
96 | const handleDragStart = useCallback(() => {
97 | setState({ type: "is-dragging" });
98 | }, []);
99 |
100 | const handleDrop = useCallback(() => {
101 | setState(idle);
102 | }, []);
103 |
104 | const handleDragEnter = useCallback(
105 | ({ source, location }) => {
106 | if (isCardData(source.data)) {
107 | setIsCardOver({ data: source.data, location });
108 | return;
109 | }
110 | if (isColumnData(source.data) && source.data.columnId !== data.id) {
111 | setState({ type: "is-column-over" });
112 | }
113 | },
114 | [data.id, setIsCardOver]
115 | );
116 |
117 | const handleDropTargetChange = useCallback(
118 | ({ source, location }) => {
119 | if (isCardData(source.data)) {
120 | setIsCardOver({ data: source.data, location });
121 | return;
122 | }
123 | },
124 | [setIsCardOver]
125 | );
126 |
127 | const handleDragLeave = useCallback(
128 | ({ source }) => {
129 | if (isColumnData(source.data) && source.data.columnId === data.id) {
130 | return;
131 | }
132 | setState(idle);
133 | },
134 | [data.id]
135 | );
136 |
137 | const canDrop = useCallback(({ source }) => {
138 | return source.data.type === "card" || source.data.type === "column";
139 | }, []);
140 |
141 | const canScroll = useCallback(({ source }) => {
142 | return source.data.type === "card";
143 | }, []);
144 |
145 | const getConfiguration = useCallback(() => {
146 | return {
147 | maxScrollSpeed: "standard" as const,
148 | };
149 | }, []);
150 |
151 | useEffect(() => {
152 | if (
153 | !outerFullHeightRef.current ||
154 | !innerRef.current ||
155 | !headerRef.current
156 | ) {
157 | console.warn("not ready");
158 | return;
159 | }
160 |
161 | const scroller = outerFullHeightRef.current.querySelector(
162 | `.${withPrefix("column-content-list")}`
163 | );
164 |
165 | const columnData = {
166 | type: "column",
167 | columnId: data.id,
168 | column: data,
169 | index,
170 | };
171 |
172 | return combine(
173 | draggable({
174 | element: headerRef.current,
175 | getInitialData: () => columnData,
176 | onGenerateDragPreview: handleGenerateDragPreview,
177 | onDragStart: handleDragStart,
178 | onDrop: handleDrop,
179 | //TODO: add dnd in columns
180 | canDrag: () => false,
181 | }),
182 | dropTargetForElements({
183 | element: outerFullHeightRef.current,
184 | getData: () => columnData,
185 | canDrop,
186 | getIsSticky: () => true,
187 | onDragStart: ({ source, location }) => {
188 | if (isCardData(source.data)) {
189 | setIsCardOver({ data: source.data, location });
190 | }
191 | },
192 | onDragEnter: handleDragEnter,
193 | onDropTargetChange: handleDropTargetChange,
194 | onDragLeave: handleDragLeave,
195 | onDrop: handleDrop,
196 | }),
197 | autoScrollForElements({
198 | canScroll,
199 | getConfiguration,
200 | element: scroller,
201 | })
202 | );
203 | }, [
204 | data,
205 | index,
206 | items?.length,
207 | handleGenerateDragPreview,
208 | handleDragStart,
209 | handleDrop,
210 | canDrop,
211 | setIsCardOver,
212 | handleDragEnter,
213 | handleDropTargetChange,
214 | handleDragLeave,
215 | canScroll,
216 | getConfiguration,
217 | ]);
218 |
219 | useEffect(() => {
220 | onColumnDndStateChange?.({ state, column: data });
221 | }, [state, onColumnDndStateChange]);
222 |
223 | return {
224 | headerRef,
225 | outerFullHeightRef,
226 | innerRef,
227 | state,
228 | cardOverShadowCount,
229 | totalTasksCount,
230 | };
231 | };
232 |
--------------------------------------------------------------------------------
/src/components/ColumnContent/ColumnContent.tsx:
--------------------------------------------------------------------------------
1 | import { withPrefix } from "@/utils/getPrefix";
2 | import React, { forwardRef, useEffect } from "react";
3 | import {
4 | BoardItem,
5 | BoardProps,
6 | ConfigMap,
7 | DndState,
8 | ScrollEvent,
9 | } from "../types";
10 | import classNames from "classnames";
11 | import { VList } from "virtua";
12 | import GenericItem from "../GenericItem";
13 | import { handleScroll } from "@/utils/scroll";
14 | import { checkIfSkeletonIsVisible } from "@/utils/infinite-scroll";
15 | import { useKanbanContext } from "@/context/KanbanContext";
16 |
17 | interface ListProps {
18 | column: BoardItem;
19 | items: BoardItem[];
20 | configMap: ConfigMap;
21 | cardWrapperStyle?: (
22 | card: BoardItem,
23 | column: BoardItem
24 | ) => React.CSSProperties;
25 | cardWrapperClassName?: string;
26 | cardsGap?: number;
27 | cardOverHeight?: number;
28 | cardOverShadowCount?: number;
29 | renderCardDragIndicator?: (card: BoardItem, info: any) => React.ReactNode;
30 | renderCardDragPreview?: (card: BoardItem, info: any) => React.ReactNode;
31 | onCardDndStateChange?: (info: DndState) => void;
32 | renderSkeletonCard?: BoardProps["renderSkeletonCard"];
33 | onScroll?: (e: React.UIEvent) => void;
34 | onCardClick?: (e: React.MouseEvent, card: BoardItem) => void;
35 | renderListFooter?: (column: BoardItem) => React.ReactNode;
36 | renderGap?: (column: BoardItem) => React.ReactNode;
37 | }
38 |
39 | const renderGenericItem = (
40 | items: BoardItem[],
41 | index: number,
42 | column: BoardItem,
43 | configMap: ConfigMap,
44 | cardOverShadowCount: number,
45 | renderListFooter: (column: BoardItem) => React.ReactNode,
46 | props: any,
47 | count: number
48 | ) => {
49 | return (
50 | = items.length,
58 | renderListFooter,
59 | isShadow:
60 | cardOverShadowCount && index === count - (renderListFooter ? 2 : 1),
61 | isListFooter:
62 | renderListFooter && index === count - (renderListFooter ? 1 : 0),
63 | ...props,
64 | }}
65 | />
66 | );
67 | };
68 |
69 | const VirtualizedList = ({
70 | column,
71 | items,
72 | configMap,
73 | onScroll,
74 | cardOverShadowCount,
75 | renderListFooter,
76 | ...props
77 | }: ListProps) => {
78 | const count =
79 | column?.totalChildrenCount +
80 | cardOverShadowCount +
81 | (renderListFooter ? 1 : 0);
82 |
83 | return (
84 |
89 | {(index: number) =>
90 | renderGenericItem(
91 | items,
92 | index,
93 | column,
94 | configMap,
95 | cardOverShadowCount,
96 | renderListFooter,
97 | props,
98 | count
99 | )
100 | }
101 |
102 | );
103 | };
104 |
105 | const NormalList = ({
106 | column,
107 | items,
108 | configMap,
109 | onScroll,
110 | cardOverShadowCount,
111 | renderListFooter,
112 | ...props
113 | }: ListProps) => {
114 | const count =
115 | column?.totalChildrenCount +
116 | cardOverShadowCount +
117 | (renderListFooter ? 1 : 0);
118 |
119 | return (
120 |
121 | {Array.from(
122 | {
123 | length: count,
124 | },
125 | (_, index) =>
126 | renderGenericItem(
127 | items,
128 | index,
129 | column,
130 | configMap,
131 | cardOverShadowCount,
132 | renderListFooter,
133 | props,
134 | count
135 | )
136 | )}
137 |
138 | );
139 | };
140 |
141 | interface Props {
142 | items: BoardItem[];
143 | column: BoardItem;
144 | columnListContentStyle?: (column: BoardItem) => React.CSSProperties;
145 | columnListContentClassName?: string;
146 | configMap: ConfigMap;
147 | renderSkeletonCard?: BoardProps["renderSkeletonCard"];
148 | cardWrapperStyle?: (
149 | card: BoardItem,
150 | column: BoardItem
151 | ) => React.CSSProperties;
152 | cardWrapperClassName?: string;
153 | onScroll?: (e: ScrollEvent, column: BoardItem) => void;
154 | onCardClick?: (e: React.MouseEvent, card: BoardItem) => void;
155 | loadMore?: (columnId: string) => void;
156 | cardOverShadowCount?: number;
157 | cardOverHeight?: number;
158 | onCardDndStateChange?: (info: DndState) => void;
159 | renderCardDragIndicator?: (card: BoardItem, info: any) => React.ReactNode;
160 | renderCardDragPreview?: (card: BoardItem, info: any) => React.ReactNode;
161 | renderListFooter?: (column: BoardItem) => React.ReactNode;
162 | renderGap?: (column: BoardItem) => React.ReactNode;
163 | }
164 |
165 | const ColumnContent = forwardRef((props, ref) => {
166 | const {
167 | items,
168 | column,
169 | configMap,
170 | columnListContentStyle,
171 | columnListContentClassName,
172 | cardWrapperStyle,
173 | renderSkeletonCard,
174 | cardWrapperClassName,
175 | onCardClick,
176 | loadMore,
177 | cardOverShadowCount,
178 | cardOverHeight,
179 | onCardDndStateChange,
180 | renderCardDragIndicator,
181 | renderCardDragPreview,
182 | renderListFooter,
183 | renderGap,
184 | } = props;
185 | const {
186 | virtualization = true,
187 | cardsGap,
188 | allowListFooter,
189 | } = useKanbanContext();
190 | const containerClassName = classNames(
191 | withPrefix("column-content"),
192 | columnListContentClassName
193 | );
194 |
195 | const onScroll = (e: ScrollEvent, column: BoardItem) => {
196 | const isSkeletonVisible = checkIfSkeletonIsVisible({
197 | columnId: column?.id,
198 | });
199 | if (isSkeletonVisible) loadMore?.(column?.id);
200 | props?.onScroll?.(e, column);
201 | };
202 |
203 | const List = virtualization ? VirtualizedList : NormalList;
204 |
205 | return (
206 |
211 | handleScroll(e, virtualization, onScroll, column)}
220 | onCardClick={onCardClick}
221 | cardOverShadowCount={cardOverShadowCount}
222 | onCardDndStateChange={onCardDndStateChange}
223 | renderCardDragIndicator={renderCardDragIndicator}
224 | renderCardDragPreview={renderCardDragPreview}
225 | cardOverHeight={cardOverHeight}
226 | renderGap={renderGap}
227 | renderListFooter={
228 | (allowListFooter !== undefined && allowListFooter?.(column)) ||
229 | allowListFooter === undefined
230 | ? renderListFooter
231 | : null
232 | }
233 | />
234 |
235 | );
236 | });
237 |
238 | export default ColumnContent;
239 |
--------------------------------------------------------------------------------
/rkk-demo/src/pages/ClickUpExample/_index.scss:
--------------------------------------------------------------------------------
1 | .clickup-example {
2 | .rkk-column-outer {
3 | width: 264px !important;
4 | min-width: 264px !important;
5 |
6 | &.expanded {
7 | width: 32px !important;
8 | min-width: 32px !important;
9 | .rkk-column-content-list {
10 | display: none !important;
11 | }
12 |
13 | .clickup-column-header {
14 | transform: rotate(90deg);
15 | transition: all 0.2s ease-in-out;
16 | -webkit-transition: all 0.2s ease-in-out;
17 | -moz-transition: all 0.2s ease-in-out;
18 | -ms-transition: all 0.2s ease-in-out;
19 | -o-transition: all 0.2s ease-in-out;
20 | }
21 |
22 | .clickup-column {
23 | height: 150px;
24 | }
25 | }
26 | }
27 |
28 | .clickup-column {
29 | padding: 0px !important;
30 | border-radius: 0.5rem !important;
31 | -webkit-border-radius: 0.5rem !important;
32 | -moz-border-radius: 0.5rem !important;
33 | -ms-border-radius: 0.5rem !important;
34 | -o-border-radius: 0.5rem !important;
35 |
36 | .rkk-column-content-list {
37 | padding: 0 0.25rem 0.25rem !important;
38 | scrollbar-color: var(--cu-border-hover)
39 | var(--board-group-color-translucent);
40 | scrollbar-width: thin;
41 | }
42 |
43 | .rkk-column-wrapper {
44 | gap: 0;
45 | }
46 |
47 | &-header {
48 | display: flex;
49 | justify-content: space-between;
50 | align-items: center;
51 | padding: 0.5rem;
52 |
53 | &:hover {
54 | span:first-child {
55 | opacity: 1;
56 | }
57 | }
58 | &-left {
59 | display: flex;
60 | align-items: center;
61 | justify-content: center;
62 | gap: 0.4rem;
63 | color: #fff;
64 | height: 24px;
65 | margin-right: 8px;
66 | padding: 4px 8px 4px 5px;
67 | border-radius: 5px;
68 | text-transform: uppercase;
69 | white-space: nowrap;
70 | span {
71 | font-size: 12px;
72 | font-weight: 500;
73 |
74 | &:first-child {
75 | display: block;
76 | width: 8px;
77 | height: 8px;
78 | border-radius: 50%;
79 | background-color: #fff;
80 | }
81 | }
82 | }
83 |
84 | &-right {
85 | display: flex;
86 | align-items: center;
87 | justify-content: center;
88 | span {
89 | display: flex;
90 | align-items: center;
91 | justify-content: center;
92 | cursor: pointer;
93 | width: 24px;
94 | height: 24px;
95 | border-radius: 0.375rem;
96 | -webkit-border-radius: 0.375rem;
97 | -moz-border-radius: 0.375rem;
98 | -ms-border-radius: 0.375rem;
99 | -o-border-radius: 0.375rem;
100 | &:hover {
101 | background-color: #00000017;
102 | }
103 |
104 | path {
105 | stroke: #6a6a6a;
106 | }
107 | &:first-child {
108 | opacity: 0;
109 | }
110 | transition: all 0.2s ease-in-out;
111 | -webkit-transition: all 0.2s ease-in-out;
112 | -moz-transition: all 0.2s ease-in-out;
113 | -ms-transition: all 0.2s ease-in-out;
114 | -o-transition: all 0.2s ease-in-out;
115 | }
116 | }
117 |
118 | &-count {
119 | font-size: 0.75rem;
120 | font-weight: 500;
121 | color: #838383;
122 | line-height: 1.4;
123 | word-wrap: break-word;
124 | flex: 1;
125 | }
126 | }
127 | }
128 | }
129 |
130 | // ClickUp Card Styling
131 | .clickup-card {
132 | background: #fff;
133 | border: 1px solid rgb(232, 232, 232);
134 | border-radius: 0.5rem;
135 | cursor: pointer;
136 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.055);
137 | -webkit-border-radius: 8px;
138 | -moz-border-radius: 8px;
139 | -ms-border-radius: 8px;
140 | -o-border-radius: 8px;
141 | &-content {
142 | padding: 8px;
143 | }
144 |
145 | &-title {
146 | font-size: 0.865rem;
147 | font-weight: 500;
148 | color: #202020;
149 | line-height: 1.4;
150 | word-wrap: break-word;
151 | }
152 |
153 | &-footer {
154 | margin-top: auto;
155 | padding-top: 4px;
156 | }
157 |
158 | &-icons {
159 | display: flex;
160 | align-items: center;
161 | gap: 5px;
162 | }
163 |
164 | &-icon {
165 | display: flex;
166 | align-items: center;
167 | justify-content: center;
168 | min-width: 22px;
169 | height: 22px;
170 | column-gap: 2px;
171 | border: 1px solid #e2e8f0;
172 | border-radius: 0.375rem;
173 | -webkit-border-radius: 0.375rem;
174 | -moz-border-radius: 0.375rem;
175 | -ms-border-radius: 0.375rem;
176 | -o-border-radius: 0.375rem;
177 | color: #838383;
178 | font-size: 12px;
179 |
180 | svg {
181 | width: 14px;
182 | height: 14px;
183 | stroke-width: 1.5;
184 | }
185 |
186 | &.priority-flag {
187 | span {
188 | font-size: 0.75rem;
189 | font-weight: 500;
190 | color: #646464;
191 | text-transform: capitalize;
192 | margin-right: 2px;
193 | }
194 | }
195 | }
196 | }
197 |
198 | // ClickUp Card Adder Styling
199 | .clickup-example-new-card {
200 | background: #fff;
201 | border: 1px solid #0091ff;
202 | border-radius: 8px;
203 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
204 | overflow: hidden;
205 |
206 | &-header {
207 | display: flex;
208 | align-items: center;
209 | gap: 8px;
210 | padding: 8px;
211 |
212 | input[type="text"] {
213 | flex: 1;
214 | min-height: 32px;
215 | padding: 0;
216 | font-size: 14px;
217 | font-weight: 400;
218 | color: #333;
219 | background: #fff;
220 | outline: none;
221 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
222 | "Noto Sans", "Ubuntu", "Droid Sans", "Helvetica Neue", sans-serif;
223 | border: none;
224 |
225 | &::placeholder {
226 | color: #999;
227 | }
228 |
229 | &:focus {
230 | border-color: none;
231 | box-shadow: none;
232 | }
233 | }
234 | }
235 |
236 | .clickup-save-btn {
237 | background: #2196f3;
238 | color: #fff;
239 | border: none;
240 | border-radius: 6px;
241 | height: 24px;
242 | padding: 0 8px;
243 | font-size: 13px;
244 | font-weight: 500;
245 | cursor: pointer;
246 | transition: background-color 0.15s ease;
247 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
248 | "Noto Sans", "Ubuntu", "Droid Sans", "Helvetica Neue", sans-serif;
249 | -webkit-border-radius: 6px;
250 | -moz-border-radius: 6px;
251 | -ms-border-radius: 6px;
252 | -o-border-radius: 6px;
253 | &:hover {
254 | background: #1976d2;
255 | }
256 |
257 | &:active {
258 | background: #1565c0;
259 | }
260 | }
261 | }
262 |
263 | .clickup-list-footer {
264 | display: flex;
265 | align-items: center;
266 | height: 32px;
267 | gap: 4px;
268 | padding-inline: 11px;
269 | border-radius: 0.5rem;
270 | -webkit-border-radius: 0.5rem;
271 | -moz-border-radius: 0.5rem;
272 | -ms-border-radius: 0.5rem;
273 | -o-border-radius: 0.5rem;
274 | cursor: pointer;
275 | &:hover {
276 | background-color: #00000017;
277 | }
278 | p {
279 | color: #838383;
280 | font-size: 0.865rem;
281 | font-weight: 500;
282 | }
283 | transition: all 0.2s ease-in-out;
284 | }
285 |
--------------------------------------------------------------------------------
/rkk-demo/src/pages/TrelloExample/_index.scss:
--------------------------------------------------------------------------------
1 | @use "../../global/assets/styles/abstracts" as *;
2 |
3 | .trello-example {
4 | background-image: url(https://d2k1ftgv7pobq7.cloudfront.net/images/backgrounds/gradients/flower.svg);
5 | background-size: cover;
6 | background-position: center;
7 | background-repeat: no-repeat;
8 | .rkk-demo-page-content {
9 | padding-bottom: 0 !important;
10 | }
11 |
12 | .rkk-board {
13 | padding-bottom: 10px;
14 | }
15 | .rkk-column-outer {
16 | width: 272px !important;
17 | min-width: 272px !important;
18 | }
19 |
20 | &-column {
21 | background-color: #ebecf0 !important;
22 | padding: 6px !important;
23 | padding-right: 2px !important;
24 | border-radius: 12px !important;
25 | box-shadow: var(
26 | --ds-shadow-raised,
27 | 0px 1px 1px #091e4240,
28 | 0px 0px 1px #091e424f
29 | );
30 | -webkit-border-radius: 12px !important;
31 | -moz-border-radius: 12px !important;
32 | -ms-border-radius: 12px !important;
33 | -o-border-radius: 12px !important;
34 | .rkk-column-wrappe {
35 | gap: 8px !important;
36 | }
37 |
38 | .rkk-column-content-list {
39 | padding: 0 3px 0 2px;
40 | -webkit-overflow-scrolling: touch !important;
41 | -webkit-transform: translate3d(0, 0, 0) !important;
42 | scrollbar-color: var(--ds-background-neutral-hovered, #091e4224)
43 | var(--ds-background-neutral, #091e420f) !important;
44 | scrollbar-width: thin !important;
45 | }
46 | }
47 |
48 | &-column-header {
49 | display: flex;
50 | align-items: center;
51 | justify-content: space-between;
52 | height: 32px;
53 | font-size: 14px;
54 | padding-right: 8px !important;
55 | font-weight: 600;
56 | color: #172b4d;
57 | padding: var(--ds-space-075, 6px) 0 var(--ds-space-075, 6px)
58 | var(--ds-space-150, 12px);
59 |
60 | span {
61 | flex: 1;
62 | }
63 |
64 | &-settings {
65 | display: flex;
66 | align-items: center;
67 | justify-content: center;
68 | width: 32px;
69 | height: 32px;
70 | border-radius: 3px;
71 | cursor: pointer;
72 | transition: background-color 0.2s ease;
73 | color: #6b778c;
74 |
75 | &:hover {
76 | background-color: #091e4224;
77 | color: #172b4d;
78 | }
79 | }
80 | }
81 | }
82 |
83 | // Trello Card Styling
84 | .trello-card {
85 | background: #fff;
86 | border-radius: 8px;
87 | box-shadow: var(
88 | --ds-shadow-raised,
89 | 0px 1px 1px #091e4240,
90 | 0px 0px 1px #091e424f
91 | );
92 | cursor: pointer;
93 | overflow: hidden;
94 |
95 | position: relative;
96 | text-decoration: none;
97 | transition: box-shadow 0.15s ease-in-out;
98 | -webkit-border-radius: 8px;
99 | -moz-border-radius: 8px;
100 | -ms-border-radius: 8px;
101 | -o-border-radius: 8px;
102 | &:hover {
103 | outline: 2px solid #0079bf;
104 | }
105 |
106 | // Cover Image
107 | &-cover {
108 | border-radius: 3px 3px 0 0;
109 | height: 160px;
110 | overflow: hidden;
111 | position: relative;
112 |
113 | img {
114 | width: 100%;
115 | height: 100%;
116 | object-fit: cover;
117 | display: block;
118 | }
119 | }
120 |
121 | // Card Content
122 | &-content {
123 | padding: var(--ds-space-100, 8px) var(--ds-space-150, 12px)
124 | var(--ds-space-050, 4px);
125 | position: relative;
126 | }
127 |
128 | // Labels
129 | &-labels {
130 | display: flex;
131 | flex-wrap: wrap;
132 | gap: 4px;
133 | margin-bottom: 4px;
134 | min-height: 0;
135 | }
136 |
137 | &-label {
138 | border-radius: 3px;
139 | color: #fff;
140 | display: block;
141 | font-size: 12px;
142 | font-weight: 700;
143 | height: 16px;
144 | line-height: 16px;
145 | max-width: 198px;
146 | min-width: 40px;
147 | overflow: hidden;
148 | padding: 0 8px;
149 | position: relative;
150 | text-overflow: ellipsis;
151 | white-space: nowrap;
152 |
153 | &:hover {
154 | max-width: none;
155 | padding-right: 8px;
156 | }
157 | }
158 |
159 | // Title
160 | &-title {
161 | color: #172b4d;
162 | font-size: 14px;
163 | font-weight: 500;
164 | line-height: 20px;
165 | margin: 0 0 4px;
166 | overflow-wrap: break-word;
167 | word-wrap: break-word;
168 | }
169 |
170 | // Badges (icons with counts)
171 | &-badges {
172 | display: flex;
173 | flex-wrap: wrap;
174 | gap: 8px;
175 | margin-top: 4px;
176 | align-items: center;
177 | }
178 |
179 | &-badge {
180 | align-items: center;
181 | color: #6b778c;
182 | display: flex;
183 | font-size: 12px;
184 | font-weight: 400;
185 | gap: 2px;
186 | line-height: 16px;
187 |
188 | svg {
189 | flex-shrink: 0;
190 | }
191 |
192 | &.trello-card-due {
193 | background-color: #091e420a;
194 | border-radius: 2px;
195 | padding: 0 4px;
196 |
197 | &.complete {
198 | background-color: #61bd4f;
199 | color: #fff;
200 | }
201 | }
202 |
203 | &.trello-card-checklist {
204 | &.complete {
205 | color: #61bd4f;
206 | }
207 | }
208 | }
209 |
210 | // Members
211 | &-members {
212 | display: flex;
213 | gap: 4px;
214 | margin-top: 8px;
215 | align-items: center;
216 | flex-wrap: wrap;
217 | }
218 |
219 | &-member {
220 | align-items: center;
221 | background-color: #dfe1e6;
222 | border-radius: 50%;
223 | color: #172b4d;
224 | display: flex;
225 | font-size: 12px;
226 | font-weight: 700;
227 | height: 28px;
228 | justify-content: center;
229 | line-height: 1;
230 | text-transform: uppercase;
231 | width: 28px;
232 | border: 2px solid #fff;
233 | margin-left: -4px;
234 |
235 | &:first-child {
236 | margin-left: 0;
237 | }
238 |
239 | // Generate different background colors for members
240 | &:nth-child(1) {
241 | background-color: #dfe1e6;
242 | }
243 | &:nth-child(2) {
244 | background-color: #b3d4fc;
245 | }
246 | &:nth-child(3) {
247 | background-color: #c7f0db;
248 | }
249 | &:nth-child(4) {
250 | background-color: #ffd3a5;
251 | }
252 | &:nth-child(5) {
253 | background-color: #fd9ca7;
254 | }
255 | }
256 |
257 | &-member-more {
258 | align-items: center;
259 | background-color: #f4f5f7;
260 | border: 1px solid #dfe1e6;
261 | border-radius: 50%;
262 | color: #6b778c;
263 | display: flex;
264 | font-size: 11px;
265 | font-weight: 600;
266 | height: 28px;
267 | justify-content: center;
268 | width: 28px;
269 | margin-left: -4px;
270 | }
271 | }
272 |
273 | // Responsive adjustments
274 | @include media-down(sm) {
275 | .trello-example-column {
276 | min-width: 272px;
277 | }
278 |
279 | .trello-card {
280 | &-cover {
281 | height: 120px;
282 | }
283 |
284 | &-members {
285 | margin-top: 6px;
286 | }
287 |
288 | &-member {
289 | height: 24px;
290 | width: 24px;
291 | font-size: 11px;
292 | }
293 |
294 | &-member-more {
295 | height: 24px;
296 | width: 24px;
297 | font-size: 10px;
298 | }
299 | }
300 | }
301 |
302 | .trello-example-list-footer {
303 | display: flex;
304 | align-items: center;
305 | justify-content: space-between;
306 | height: 35px;
307 | padding: var(--ds-space-050, 4px) var(--ds-space-100, 8px);
308 | cursor: pointer;
309 | border-radius: 8px;
310 | -webkit-border-radius: 8px;
311 | -moz-border-radius: 8px;
312 | -ms-border-radius: 8px;
313 | -o-border-radius: 8px;
314 | &-button {
315 | color: #44546f;
316 | font-size: 14px;
317 | font-weight: 500;
318 | line-height: 20px;
319 | svg {
320 | width: 20px;
321 | height: 20px;
322 | color: #44546f;
323 | }
324 | }
325 |
326 | &:hover {
327 | background-color: #091e4224;
328 | }
329 | }
330 |
--------------------------------------------------------------------------------
/rkk-demo/src/components/Navigation/_Navigation.scss:
--------------------------------------------------------------------------------
1 | @use "../../global/assets/styles/abstracts" as *;
2 |
3 | .#{$demo-prefix}-navigation {
4 | display: flex;
5 | flex-direction: column;
6 | gap: var(--space-3);
7 |
8 | &-item {
9 | @include card-interactive;
10 | @include flex-start;
11 | gap: var(--space-4);
12 | padding: var(--space-5);
13 | color: var(--gray-600);
14 | text-decoration: none;
15 | border: 2px solid transparent;
16 | position: relative;
17 | overflow: hidden;
18 | background: rgba(255, 255, 255, 0.9);
19 | backdrop-filter: blur(10px);
20 |
21 | // Subtle gradient border animation
22 | &::before {
23 | content: "";
24 | position: absolute;
25 | inset: 0;
26 | padding: 2px;
27 | background: linear-gradient(
28 | 135deg,
29 | transparent,
30 | var(--primary-200),
31 | transparent
32 | );
33 | border-radius: inherit;
34 | mask:
35 | linear-gradient(#fff 0 0) content-box,
36 | linear-gradient(#fff 0 0);
37 | mask-composite: xor;
38 | opacity: 0;
39 | transition: opacity var(--transition-base);
40 | }
41 |
42 | // Hover gradient overlay
43 | &::after {
44 | content: "";
45 | position: absolute;
46 | top: 0;
47 | left: 0;
48 | right: 0;
49 | bottom: 0;
50 | background: linear-gradient(
51 | 135deg,
52 | rgba(59, 130, 246, 0.02) 0%,
53 | rgba(147, 197, 253, 0.05) 100%
54 | );
55 | opacity: 0;
56 | transition: opacity var(--transition-base);
57 | }
58 |
59 | &:hover {
60 | color: var(--gray-900);
61 | transform: translateY(-6px) scale(1.02);
62 | box-shadow: var(--shadow-2xl), var(--shadow-primary);
63 | border-color: rgba(59, 130, 246, 0.1);
64 |
65 | &::before {
66 | opacity: 1;
67 | }
68 |
69 | &::after {
70 | opacity: 1;
71 | }
72 |
73 | .#{$demo-prefix}-navigation-item-icon {
74 | transform: scale(1.1) rotate(5deg);
75 | background: var(--gradient-primary);
76 | color: white;
77 | box-shadow: var(--shadow-primary);
78 | }
79 |
80 | .#{$demo-prefix}-navigation-item-description {
81 | color: var(--primary-600);
82 | }
83 | }
84 |
85 | &.active {
86 | background: linear-gradient(
87 | 135deg,
88 | var(--primary-50),
89 | rgba(59, 130, 246, 0.08)
90 | );
91 | color: var(--primary-700);
92 | border-color: var(--primary-200);
93 | box-shadow: var(--shadow-lg), var(--shadow-primary);
94 | transform: translateY(-2px);
95 |
96 | &::before {
97 | opacity: 1;
98 | background: var(--gradient-primary);
99 | }
100 |
101 | .#{$demo-prefix}-navigation-item-icon {
102 | background: var(--gradient-primary);
103 | color: white;
104 | box-shadow: var(--shadow-primary);
105 | transform: scale(1.05);
106 | }
107 |
108 | .#{$demo-prefix}-navigation-item-description {
109 | color: var(--primary-600);
110 | }
111 |
112 | // Active indicator
113 | &::after {
114 | content: "";
115 | position: absolute;
116 | top: 50%;
117 | right: var(--space-5);
118 | width: 8px;
119 | height: 8px;
120 | background: var(--primary-500);
121 | border-radius: 50%;
122 | transform: translateY(-50%);
123 | box-shadow: 0 0 10px rgba(59, 130, 246, 0.4);
124 | opacity: 1;
125 | }
126 | }
127 |
128 | &:active {
129 | transform: translateY(-2px) scale(0.98);
130 | box-shadow: var(--shadow-lg);
131 | }
132 |
133 | @include media-down(lg) {
134 | flex-direction: column;
135 | text-align: center;
136 | padding: var(--space-4);
137 | gap: var(--space-3);
138 | }
139 |
140 | @include media-down(sm) {
141 | padding: var(--space-3);
142 | gap: var(--space-2);
143 | }
144 |
145 | &-icon {
146 | @include flex-center;
147 | width: 52px;
148 | height: 52px;
149 | border-radius: var(--radius-2xl);
150 | background: rgba(148, 163, 184, 0.1);
151 | color: var(--gray-600);
152 | transition: all var(--transition-base);
153 | position: relative;
154 | overflow: hidden;
155 | flex-shrink: 0;
156 |
157 | @include media-down(lg) {
158 | width: 44px;
159 | height: 44px;
160 | }
161 |
162 | @include media-down(sm) {
163 | width: 36px;
164 | height: 36px;
165 | }
166 |
167 | // Inner glow effect
168 | &::before {
169 | content: "";
170 | position: absolute;
171 | inset: 1px;
172 | background: linear-gradient(
173 | 135deg,
174 | rgba(255, 255, 255, 0.6),
175 | transparent
176 | );
177 | border-radius: calc(var(--radius-2xl) - 1px);
178 | opacity: 0;
179 | transition: opacity var(--transition-base);
180 | }
181 |
182 | svg {
183 | transition: all var(--transition-base);
184 | z-index: 1;
185 | position: relative;
186 | }
187 | }
188 |
189 | &-content {
190 | flex: 1;
191 | min-width: 0;
192 | position: relative;
193 | z-index: 1;
194 |
195 | @include media-down(lg) {
196 | display: none;
197 | }
198 | }
199 |
200 | &-label {
201 | font-size: 0.9375rem;
202 | font-weight: 600;
203 | color: inherit;
204 | margin-bottom: var(--space-1-5);
205 | @include text-truncate;
206 | position: relative;
207 | letter-spacing: -0.025em;
208 | }
209 |
210 | &-description {
211 | font-size: 0.8125rem;
212 | font-weight: 400;
213 | color: var(--gray-500);
214 | @include text-truncate;
215 | line-height: 1.4;
216 | letter-spacing: 0.025em;
217 | transition: color var(--transition-base);
218 | }
219 | }
220 |
221 | // Loading state
222 | &-loading {
223 | .#{$demo-prefix}-navigation-item {
224 | pointer-events: none;
225 | opacity: 0.6;
226 |
227 | &-icon {
228 | @include shimmer;
229 | }
230 |
231 | &-label,
232 | &-description {
233 | @include shimmer;
234 | border-radius: var(--radius-base);
235 | color: transparent;
236 | }
237 |
238 | &-label {
239 | height: 16px;
240 | margin-bottom: var(--space-2);
241 | }
242 |
243 | &-description {
244 | height: 12px;
245 | }
246 | }
247 | }
248 |
249 | // Empty state
250 | &-empty {
251 | @include flex-col-center;
252 | padding: var(--space-16) var(--space-8);
253 | text-align: center;
254 | color: var(--gray-500);
255 |
256 | &-icon {
257 | font-size: 3rem;
258 | margin-bottom: var(--space-4);
259 | opacity: 0.6;
260 | }
261 |
262 | &-title {
263 | font-size: 0.875rem;
264 | font-weight: 600;
265 | color: var(--gray-700);
266 | margin-bottom: var(--space-2);
267 | }
268 |
269 | &-description {
270 | font-size: 0.75rem;
271 | line-height: 1.5;
272 | }
273 | }
274 | }
275 |
276 | // Badge for new/updated items
277 | .#{$demo-prefix}-navigation-badge {
278 | @include badge-primary;
279 | position: absolute;
280 | top: -6px;
281 | right: -6px;
282 | min-width: 20px;
283 | height: 20px;
284 | padding: 0 var(--space-1-5);
285 | font-size: 0.625rem;
286 | font-weight: 700;
287 | border: 2px solid white;
288 | box-shadow: var(--shadow-sm);
289 |
290 | &.new {
291 | @include badge-success;
292 | animation: pulse 2s infinite;
293 | }
294 |
295 | &.updated {
296 | @include badge-warning;
297 | }
298 | }
299 |
300 | // Pulse animation for new badges
301 | @keyframes pulse {
302 | 0%,
303 | 100% {
304 | opacity: 1;
305 | transform: scale(1);
306 | }
307 | 50% {
308 | opacity: 0.8;
309 | transform: scale(1.05);
310 | }
311 | }
312 |
--------------------------------------------------------------------------------
/rkk-demo/src/components/Header/_Header.scss:
--------------------------------------------------------------------------------
1 | @use "../../global/assets/styles/abstracts" as *;
2 |
3 | .#{$demo-prefix}-header {
4 | height: var(--header-height);
5 | background: rgba(255, 255, 255, 0.85);
6 | border-bottom: 1px solid rgba(59, 130, 246, 0.1);
7 | box-shadow:
8 | 0 1px 3px rgba(0, 0, 0, 0.1),
9 | 0 1px 20px rgba(59, 130, 246, 0.05);
10 | position: sticky;
11 | top: 0;
12 | z-index: var(--z-sticky);
13 | backdrop-filter: blur(20px);
14 | transition: all var(--transition-base);
15 |
16 | // Modern glassmorphism effect
17 | &::before {
18 | content: "";
19 | position: absolute;
20 | top: 0;
21 | left: 0;
22 | right: 0;
23 | bottom: 0;
24 | background: linear-gradient(
25 | 135deg,
26 | rgba(255, 255, 255, 0.4) 0%,
27 | rgba(255, 255, 255, 0.1) 100%
28 | );
29 | pointer-events: none;
30 | }
31 |
32 | &-content {
33 | @include flex-between;
34 | max-width: 100%;
35 | height: 100%;
36 | padding: 0 var(--space-6);
37 | position: relative;
38 | z-index: 1;
39 |
40 | @include media-down(md) {
41 | padding: 0 var(--space-4);
42 | }
43 | }
44 |
45 | &-left {
46 | @include flex-start;
47 | }
48 |
49 | &-logo {
50 | text-decoration: none;
51 | color: inherit;
52 | transition: all var(--transition-base);
53 | border-radius: var(--radius-lg);
54 | padding: var(--space-2);
55 | margin: calc(var(--space-2) * -1);
56 |
57 | &:hover {
58 | background-color: rgba(59, 130, 246, 0.05);
59 | }
60 | }
61 |
62 | &-right {
63 | @include flex-start;
64 | }
65 |
66 | &-nav {
67 | @include flex-start;
68 | gap: var(--space-2);
69 |
70 | &-item {
71 | @include flex-start;
72 | gap: var(--space-2);
73 | padding: var(--space-2-5) var(--space-4);
74 | font-size: var(--text-sm);
75 | font-weight: 500;
76 | color: var(--gray-600);
77 | text-decoration: none;
78 | border-radius: var(--radius-xl);
79 | transition: all var(--transition-base);
80 | border: 1px solid transparent;
81 | background: rgba(255, 255, 255, 0.6);
82 | backdrop-filter: blur(8px);
83 | position: relative;
84 | overflow: hidden;
85 |
86 | // Modern hover effect
87 | &::before {
88 | content: "";
89 | position: absolute;
90 | top: 0;
91 | left: 0;
92 | right: 0;
93 | bottom: 0;
94 | background: linear-gradient(
95 | 135deg,
96 | rgba(59, 130, 246, 0.1) 0%,
97 | rgba(147, 197, 253, 0.05) 100%
98 | );
99 | opacity: 0;
100 | transition: opacity var(--transition-base);
101 | }
102 |
103 | &:hover {
104 | color: var(--primary-700);
105 | background: rgba(255, 255, 255, 0.9);
106 | border-color: rgba(59, 130, 246, 0.2);
107 | transform: translateY(-1px);
108 | box-shadow:
109 | 0 4px 12px rgba(59, 130, 246, 0.15),
110 | 0 0 0 1px rgba(59, 130, 246, 0.1);
111 |
112 | &::before {
113 | opacity: 1;
114 | }
115 | }
116 |
117 | &:active {
118 | transform: translateY(0);
119 | }
120 |
121 | span {
122 | position: relative;
123 | z-index: 1;
124 |
125 | @include media-down(md) {
126 | display: none;
127 | }
128 | }
129 |
130 | svg {
131 | transition: all var(--transition-base);
132 | position: relative;
133 | z-index: 1;
134 | }
135 |
136 | &:hover svg {
137 | color: var(--primary-600);
138 | transform: scale(1.05);
139 | }
140 | }
141 | }
142 | }
143 |
144 | // Clean logo component
145 | .#{$demo-prefix}-logo {
146 | @include flex-start;
147 | gap: var(--space-3);
148 |
149 | &-icon {
150 | @include flex-center;
151 | width: 40px;
152 | height: 40px;
153 | background: var(--gradient-primary);
154 | color: white;
155 | border-radius: var(--radius-lg);
156 | font-weight: 700;
157 | font-size: 0.875rem;
158 | letter-spacing: var(--tracking-tight);
159 | box-shadow: var(--shadow-primary);
160 | font-family: var(--font-display);
161 | }
162 |
163 | &-text {
164 | display: flex;
165 | flex-direction: column;
166 |
167 | @include media-down(sm) {
168 | display: none;
169 | }
170 | }
171 |
172 | &-title {
173 | font-size: var(--text-lg);
174 | font-weight: 700;
175 | font-family: var(--font-display);
176 | color: var(--gray-900);
177 | line-height: var(--leading-tight);
178 | letter-spacing: var(--tracking-tight);
179 | }
180 |
181 | &-subtitle {
182 | font-size: var(--text-xs);
183 | font-weight: 500;
184 | color: var(--gray-500);
185 | line-height: var(--leading-snug);
186 | letter-spacing: var(--tracking-wide);
187 | text-transform: uppercase;
188 | }
189 | }
190 |
191 | // Mobile menu button
192 | .#{$demo-prefix}-mobile-menu-btn {
193 | @include flex-center;
194 | width: 36px;
195 | height: 36px;
196 | padding: 0;
197 | border: 1px solid var(--gray-200);
198 | background: white;
199 | border-radius: var(--radius-lg);
200 | display: none;
201 | color: var(--gray-600);
202 | transition: all var(--transition-base);
203 |
204 | @include media-down(md) {
205 | display: flex;
206 | }
207 |
208 | &:hover {
209 | background-color: var(--gray-50);
210 | border-color: var(--gray-300);
211 | color: var(--gray-900);
212 | }
213 |
214 | svg {
215 | transition: transform var(--transition-base);
216 | }
217 |
218 | &.active svg {
219 | transform: rotate(90deg);
220 | }
221 | }
222 |
223 | // Clean search box
224 | .#{$demo-prefix}-search {
225 | position: relative;
226 | display: flex;
227 | align-items: center;
228 | max-width: 300px;
229 | width: 100%;
230 | margin: 0 var(--space-4);
231 |
232 | @include media-down(lg) {
233 | display: none;
234 | }
235 |
236 | &-input {
237 | @include input-base;
238 | width: 100%;
239 | padding-left: var(--space-10);
240 | font-size: var(--text-sm);
241 | background: rgba(255, 255, 255, 0.8);
242 | border-color: var(--gray-200);
243 |
244 | &:focus {
245 | background: white;
246 | box-shadow:
247 | var(--shadow-md),
248 | 0 0 0 3px rgba(59, 130, 246, 0.1);
249 | }
250 |
251 | &:hover:not(:focus) {
252 | border-color: var(--gray-300);
253 | }
254 | }
255 |
256 | &-icon {
257 | position: absolute;
258 | left: var(--space-3);
259 | color: var(--gray-400);
260 | transition: color var(--transition-base);
261 | pointer-events: none;
262 | }
263 |
264 | &:focus-within &-icon {
265 | color: var(--primary-500);
266 | }
267 | }
268 |
269 | // Simple notification badge
270 | .#{$demo-prefix}-notification-badge {
271 | position: absolute;
272 | top: -2px;
273 | right: -2px;
274 | width: 8px;
275 | height: 8px;
276 | background: var(--accent-danger);
277 | border: 2px solid white;
278 | border-radius: 50%;
279 | box-shadow: var(--shadow-sm);
280 | }
281 |
282 | // Responsive adjustments
283 | @include media-down(sm) {
284 | .#{$demo-prefix}-header {
285 | &-nav {
286 | gap: var(--space-0-5);
287 |
288 | &-item {
289 | padding: var(--space-1-5) var(--space-2);
290 | font-size: var(--text-xs);
291 | }
292 | }
293 | }
294 |
295 | .#{$demo-prefix}-logo {
296 | gap: var(--space-2);
297 |
298 | &-icon {
299 | width: 36px;
300 | height: 36px;
301 | font-size: 0.75rem;
302 | }
303 |
304 | &-title {
305 | font-size: var(--text-base);
306 | }
307 | }
308 | }
309 |
310 | // RTL Support for Header
311 | [dir="rtl"] .#{$demo-prefix}-header {
312 | &-content {
313 | flex-direction: row-reverse;
314 | }
315 |
316 | &-left {
317 | flex-direction: row-reverse;
318 | }
319 |
320 | &-right {
321 | flex-direction: row-reverse;
322 | }
323 |
324 | &-nav {
325 | flex-direction: row-reverse;
326 | }
327 |
328 | &-logo {
329 | .#{$demo-prefix}-logo {
330 | flex-direction: row-reverse;
331 | }
332 | }
333 | }
334 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from "react-dom/client";
2 | import { Kanban } from "./";
3 | import { mockData } from "./utils/mocks/data";
4 | import CardSkeleton from "./components/CardSkeleton";
5 |
6 | const App = () => {
7 | return (
8 |
9 |
{
12 | console.log();
13 | }}
14 | // renderCardDragPreview={(card, info) => {
15 | // return (
16 | //
23 | // Preview of {card.title}
24 | //
25 | // );
26 | // }}
27 | // renderCardDragIndicator={(card, info) => {
28 | // return (
29 | //
36 | // );
37 | // }}
38 | // renderColumnFooter={(column) => (
39 | //
40 | // {column.title} have total as {column?.totalChildrenCount}
41 | //
42 | // )}
43 | // renderColumnWrapper={(column, { children, className, style }) => (
44 | //
52 | // {collapsed ? column?.title : children}
53 | //
54 | // )}
55 | // renderColumnHeader={(column) => (
56 | //
57 | // {column.title} have total as {column?.totalChildrenCount}
58 | //
59 | // )}
60 | // columnHeaderStyle={(column) => ({
61 | // backgroundColor: "red",
62 | // padding: "10px",
63 | // })}
64 | // columnWrapperStyle={(column) => ({
65 | // backgroundColor: "green",
66 | // padding: "10px",
67 | // })}
68 |
69 | // renderSkeletonCard={({ index, column }) => (
70 | //
71 | // Loading {index} {column.title} ...
72 | //
73 | // )}
74 |
75 | // Custom skeleton examples:
76 | renderSkeletonCard={({ index, column }) => (
77 |
78 | )}
79 | // renderSkeletonCard={({ index, column }) => (
80 | //
84 | // )}
85 | // onScroll={(e, column) => {
86 | // console.log(e, column);
87 | // }}
88 | // columnStyle={(column) => ({
89 | // backgroundColor: dragOverColumn === column.id ? "red" : "blue",
90 | // })}
91 | onCardMove={(event) => {
92 | console.log({ event });
93 | }}
94 | allowColumnAdder={true}
95 | renderColumnAdder={() => Add new Column
}
96 | renderListFooter={(column) => Add new one
}
97 | allowListFooter={(column) => true}
98 | rootClassName="check"
99 | dataSource={{
100 | root: {
101 | id: "root",
102 | title: "Root",
103 | children: ["col-1", "col-2", "col-3"],
104 | totalChildrenCount: 3,
105 | parentId: null,
106 | },
107 | "col-1": {
108 | id: "col-1",
109 | title: "To Do",
110 | children: ["task-1", "task-2"],
111 | totalChildrenCount: 2,
112 | parentId: "root",
113 | },
114 | "col-2": {
115 | id: "col-2",
116 | title: "In Progress",
117 | children: ["task-3"],
118 | totalChildrenCount: 1,
119 | parentId: "root",
120 | },
121 | "col-3": {
122 | id: "col-3",
123 | title: "Done",
124 | children: ["task-4"],
125 | totalChildrenCount: 1,
126 | parentId: "root",
127 | },
128 | "task-1": {
129 | id: "task-1",
130 | title: "DesigHomepage",
131 | parentId: "col-1",
132 | children: [],
133 | totalChildrenCount: 0,
134 | type: "card",
135 | content: {
136 | description: "Create wireframeand mockups for thhomepage",
137 | priority: "high",
138 | },
139 | },
140 | "task-2": {
141 | id: "task-2",
142 | title: "SetuDatabase",
143 | parentId: "col-1",
144 | children: [],
145 | totalChildrenCount: 0,
146 | type: "card",
147 | },
148 | "task-3": {
149 | id: "task-3",
150 | title: "Task 3",
151 | parentId: "col-2",
152 | children: [],
153 | totalChildrenCount: 0,
154 | type: "card",
155 | },
156 | "task-4": {
157 | id: "task-4",
158 | title: "Task 4",
159 | parentId: "col-3",
160 | children: [],
161 | totalChildrenCount: 0,
162 | type: "card",
163 | },
164 | }}
165 | cardsGap={6}
166 | // cardWrapperStyle={(card, col) => {
167 | // console.log({ col, card });
168 | // return {
169 | // backgroundColor: "red",
170 | // padding: "10px",
171 | // };
172 | // }}
173 | renderCardDragPreview={(card, info) => {
174 | console.log({ card, info });
175 | return (
176 |
185 | Preview of {card.title}
186 |
187 | );
188 | }}
189 | cardWrapperClassName="card-hazem"
190 | // loadMore={(columnId) => {
191 | // console.log("loadMore", columnId);
192 | // }}
193 | // onCardDndStateChange={(info) => {
194 | // console.log({ info });
195 | // if (info.state.type === "idle") {
196 | // setDragOverColumn(info.column?.id);
197 | // }
198 | // }}
199 | // onColumnDndStateChange={(info) => {
200 | // if (info.state.type === "is-card-over") {
201 | // setDragOverColumn(info.column?.id);
202 | // } else {
203 | // setDragOverColumn(null);
204 | // }
205 | // }}
206 | virtualization={true} // Set to false to disable virtualization and use normal map instead
207 | configMap={{
208 | card: {
209 | render: (props) => (
210 |
221 | Card {props.data.title}
222 |
223 | ),
224 | isDraggable: true,
225 | },
226 | // divider: {
227 | // render: (props) => (
228 | //
235 | // ),
236 | // isDraggable: true,
237 | // },
238 | cardLoading: {
239 | render: (props) => Card Loading
,
240 | isDraggable: true,
241 | },
242 | footer: {
243 | render: (props) => {
244 | return Add Task
;
245 | },
246 | isDraggable: false,
247 | },
248 | }}
249 | />
250 |
251 | );
252 | };
253 |
254 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
255 |
256 | );
257 |
--------------------------------------------------------------------------------
/rkk-demo/src/pages/ClickUpExample/ClickUpExample.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import {
3 | Kanban,
4 | type BoardItem,
5 | type BoardData,
6 | dropHandler,
7 | } from "react-kanban-kit";
8 | import { mockData } from "../../utils/_mock_";
9 | import { Calendar, User, Flag, ChevronLeft, Plus } from "lucide-react";
10 | import {
11 | getPriorityColor,
12 | addCard,
13 | addCardPlaceholder,
14 | getAddCardPlaceholderKey,
15 | removeCardPlaceholder,
16 | toggleCollapsedColumn,
17 | } from "../../utils/kanbanUtils";
18 |
19 | const ClickUpColumnHeader = ({
20 | column,
21 | toggleCollapsedColumnHandler,
22 | addCardPlaceholderHandler,
23 | }: {
24 | column: BoardItem;
25 | toggleCollapsedColumnHandler: (columnId: string) => void;
26 | addCardPlaceholderHandler: (columnId: string, inTop: boolean) => void;
27 | }) => {
28 | const isExpanded = column?.content?.isExpanded;
29 |
30 | return (
31 |
32 |
36 |
37 | {column.title}
38 |
39 |
{column.totalItemsCount}
40 | {!isExpanded && (
41 |
42 |
toggleCollapsedColumnHandler(column.id)}>
43 |
44 |
45 |
addCardPlaceholderHandler(column.id, true)}>
46 |
47 |
48 |
49 | )}
50 |
51 | );
52 | };
53 |
54 | const ClickUpCardAdder: React.FC<{
55 | columnId: string;
56 | dataSource: BoardData;
57 | setDataSource: (dataSource: BoardData) => void;
58 | inTop: boolean;
59 | }> = ({ columnId, dataSource, setDataSource, inTop }) => {
60 | const [newCardTitle, setNewCardTitle] = useState("");
61 |
62 | const removeCardPlaceholderHandler = (columnId: string) => {
63 | setDataSource(removeCardPlaceholder(columnId, dataSource));
64 | };
65 |
66 | const addCardHandler = (columnId: string, title: string) => {
67 | if (!title.trim()) return;
68 | setDataSource(addCard(columnId, dataSource, title, inTop));
69 | };
70 |
71 | return (
72 | {
75 | if (newCardTitle.trim()) addCardHandler(columnId, newCardTitle);
76 | else removeCardPlaceholderHandler(columnId);
77 | }}
78 | tabIndex={0}
79 | >
80 |
81 | setNewCardTitle(e.target.value)}
85 | placeholder="Task name"
86 | autoFocus
87 | onKeyDown={(e) => {
88 | if (e.key === "Enter") {
89 | addCardHandler(columnId, newCardTitle);
90 | } else if (e.key === "Escape") {
91 | removeCardPlaceholderHandler(columnId);
92 | }
93 | }}
94 | />
95 |
101 |
102 |
103 | );
104 | };
105 |
106 | const ClickUpCard = ({ data }: { data: BoardItem }) => {
107 | const priorityColor = getPriorityColor(data.content?.priority);
108 |
109 | return (
110 |
111 |
112 | {/* Task Title */}
113 |
{data.title}
114 |
115 | {/* Card Footer with Icons */}
116 |
117 |
118 | {/* User Icon */}
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 | {data.content?.priority}
130 |
131 |
132 |
133 |
134 |
135 | );
136 | };
137 |
138 | export const ClickUpExample: React.FC = () => {
139 | const [dataSource, setDataSource] = useState(
140 | structuredClone(mockData) as BoardData
141 | );
142 |
143 | const addCardPlaceholderHandler = (
144 | columnId: string,
145 | inTop: boolean = true
146 | ) => {
147 | setDataSource(addCardPlaceholder(columnId, dataSource, inTop));
148 | };
149 |
150 | const toggleCollapsedColumnHandler = (columnId: string) => {
151 | setDataSource(toggleCollapsedColumn(columnId, dataSource));
152 | };
153 |
154 | return (
155 |
156 |
157 |
ClickUp-Style Kanban Board
158 |
159 | A ClickUp-inspired board with priority indicators and clean card
160 | design
161 |
162 |
163 |
164 |
165 |
,
171 | isDraggable: true,
172 | },
173 | "new-card": {
174 | render: ({ column, data }) => (
175 |
181 | ),
182 | isDraggable: false,
183 | },
184 | }}
185 | columnClassName={() => "clickup-column"}
186 | renderColumnHeader={(column) => (
187 |
192 | )}
193 | cardsGap={4}
194 | virtualization={true}
195 | onCardMove={(move) => {
196 | setDataSource(
197 | dropHandler(
198 | move,
199 | dataSource,
200 | () => {},
201 | (newColumn) => {
202 | return {
203 | ...newColumn,
204 | totalItemsCount: (newColumn.totalItemsCount || 0) + 1,
205 | totalChildrenCount: (newColumn.totalChildrenCount || 0) + 1,
206 | };
207 | },
208 | (sourceColumn) => {
209 | return {
210 | ...sourceColumn,
211 | totalItemsCount: (sourceColumn.totalItemsCount || 0) - 1,
212 | totalChildrenCount:
213 | (sourceColumn.totalChildrenCount || 0) - 1,
214 | };
215 | }
216 | )
217 | );
218 | }}
219 | columnListContentClassName={() => "clickup-column-list-content"}
220 | columnStyle={(column) => ({
221 | background: `color-mix(in srgb, ${column?.content?.color}, transparent 92%)`,
222 | })}
223 | renderListFooter={(column) => (
224 | addCardPlaceholderHandler(column.id, false)}
227 | >
228 |
229 |
230 |
231 |
Add Task
232 |
233 | )}
234 | allowListFooter={(column) => {
235 | return !column.children.includes(
236 | getAddCardPlaceholderKey(column.id)
237 | );
238 | }}
239 | onColumnClick={(_, column) => {
240 | if (column?.content?.isExpanded)
241 | toggleCollapsedColumnHandler(column.id);
242 | }}
243 | columnWrapperClassName={(column) => {
244 | const className = column?.content?.isExpanded ? "expanded" : "";
245 | return className;
246 | }}
247 | />
248 |
249 |
250 | );
251 | };
252 |
253 | export default ClickUpExample;
254 |
--------------------------------------------------------------------------------