Skip to content

Commit 6f03210

Browse files
committed
feat: add Tasks component for displaying weekly tasks with animations and date utilities
1 parent 35557a8 commit 6f03210

File tree

7 files changed

+525
-3
lines changed

7 files changed

+525
-3
lines changed

src/components/animations/click-select.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ const ClickSelect = () => {
102102
layoutId={select.name}
103103
className={cn(
104104
"flex cursor-pointer items-center gap-2 rounded-full w-fit py-1 px-2 capitalize relative",
105-
select.className,
105+
select.className
106106
)}
107107
>
108108
<span>{select.name}</span>
@@ -141,7 +141,7 @@ const ClickSelect = () => {
141141
onClick={() => handleAdd(item)}
142142
className={cn(
143143
"flex cursor-pointer items-center gap-4 rounded-full w-fit p-1 px-2 capitalize z-0 relative",
144-
item.className,
144+
item.className
145145
)}
146146
>
147147
<span>{item.name}</span>
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
"use client";
2+
3+
import type React from "react";
4+
import { useRef, useState, useEffect } from "react";
5+
import { motion, AnimatePresence } from "motion/react";
6+
7+
interface CaretPosition {
8+
x: number;
9+
y: number;
10+
height: number;
11+
}
12+
13+
interface CustomCaretProps {
14+
position: CaretPosition;
15+
isVisible: boolean;
16+
isFocused: boolean;
17+
hasSelection: boolean;
18+
}
19+
20+
const CustomCaret: React.FC<CustomCaretProps> = ({
21+
position,
22+
isVisible,
23+
isFocused,
24+
hasSelection,
25+
}) => {
26+
if (!isFocused || hasSelection) {
27+
return null;
28+
}
29+
30+
return (
31+
<motion.div
32+
initial={{ opacity: 0 }}
33+
animate={{ opacity: isVisible ? 1 : 0 }}
34+
transition={{ duration: 0.1 }}
35+
className="absolute pointer-events-none w-[2px] bg-blue-500"
36+
style={{
37+
left: `${position.x}px`,
38+
top: `${position.y}px`,
39+
height: `${position.height}px`,
40+
}}
41+
/>
42+
);
43+
};
44+
45+
interface CustomTextareaProps {
46+
placeholder?: string;
47+
defaultValue?: string;
48+
className?: string;
49+
rows?: number;
50+
}
51+
52+
const CustomTextarea = ({
53+
placeholder = "Type something...",
54+
defaultValue = "",
55+
className = "",
56+
rows = 5,
57+
}: CustomTextareaProps) => {
58+
const textareaRef = useRef<HTMLTextAreaElement>(null);
59+
const overlayRef = useRef<HTMLDivElement>(null);
60+
const [text, setText] = useState(defaultValue);
61+
const [caretPosition, setCaretPosition] = useState<CaretPosition>({
62+
x: 0,
63+
y: 0,
64+
height: 20,
65+
});
66+
const [isCaretVisible, setIsCaretVisible] = useState(true);
67+
const [isFocused, setIsFocused] = useState(false);
68+
const [selectionRange, setSelectionRange] = useState<{
69+
start: number;
70+
end: number;
71+
} | null>(null);
72+
73+
// Toggle caret visibility for blinking effect
74+
useEffect(() => {
75+
if (!isFocused) return;
76+
77+
const interval = setInterval(() => {
78+
setIsCaretVisible((prev) => !prev);
79+
}, 530); // Standard caret blink rate
80+
81+
return () => clearInterval(interval);
82+
}, [isFocused]);
83+
84+
// Update caret position when text changes or on selection change
85+
const updateCaretPosition = () => {
86+
if (!textareaRef.current || !overlayRef.current) return;
87+
88+
const textarea = textareaRef.current;
89+
const start = textarea.selectionStart;
90+
const end = textarea.selectionEnd;
91+
92+
// Update selection range
93+
if (start !== end) {
94+
setSelectionRange({ start, end });
95+
} else {
96+
setSelectionRange(null);
97+
}
98+
99+
// Create a temporary element to measure text dimensions
100+
const tempElement = document.createElement("div");
101+
tempElement.style.position = "absolute";
102+
tempElement.style.visibility = "hidden";
103+
tempElement.style.whiteSpace = "pre-wrap";
104+
tempElement.style.wordBreak = "break-word";
105+
tempElement.style.width = `${textarea.clientWidth}px`;
106+
tempElement.style.font = window.getComputedStyle(textarea).font;
107+
tempElement.style.padding = window.getComputedStyle(textarea).padding;
108+
109+
// Get text before caret
110+
const textBeforeCaret = text.substring(0, start);
111+
112+
// Handle empty text or caret at the beginning
113+
if (!textBeforeCaret) {
114+
setCaretPosition({
115+
x: Number.parseInt(window.getComputedStyle(textarea).paddingLeft),
116+
y: Number.parseInt(window.getComputedStyle(textarea).paddingTop),
117+
height:
118+
Number.parseInt(window.getComputedStyle(textarea).lineHeight) || 20,
119+
});
120+
return;
121+
}
122+
123+
// Add text content and line break element
124+
tempElement.textContent = textBeforeCaret;
125+
tempElement.innerHTML += '<span id="caret-position"></span>';
126+
127+
// Append to body, measure, then remove
128+
document.body.appendChild(tempElement);
129+
const caretEl = tempElement.querySelector("#caret-position");
130+
131+
if (caretEl) {
132+
const caretRect = caretEl.getBoundingClientRect();
133+
const textareaRect = textarea.getBoundingClientRect();
134+
135+
setCaretPosition({
136+
x: caretRect.left - textareaRect.left,
137+
y: caretRect.top - textareaRect.top,
138+
height:
139+
Number.parseInt(window.getComputedStyle(textarea).lineHeight) || 20,
140+
});
141+
}
142+
143+
document.body.removeChild(tempElement);
144+
};
145+
146+
// Handle text change
147+
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
148+
setText(e.target.value);
149+
updateCaretPosition(); // Update immediately
150+
// Double check position after DOM update
151+
requestAnimationFrame(() => {
152+
requestAnimationFrame(updateCaretPosition);
153+
});
154+
};
155+
156+
// Handle selection and caret position updates
157+
const handleSelect = () => {
158+
requestAnimationFrame(updateCaretPosition);
159+
};
160+
161+
// Handle key events for caret updates
162+
const handleKeyUp = () => {
163+
requestAnimationFrame(updateCaretPosition);
164+
};
165+
166+
// Handle mouse events
167+
const handleMouseUp = () => {
168+
requestAnimationFrame(updateCaretPosition);
169+
};
170+
171+
// Handle focus events
172+
const handleFocus = () => {
173+
setIsFocused(true);
174+
updateCaretPosition();
175+
};
176+
177+
const handleBlur = () => {
178+
setIsFocused(false);
179+
setSelectionRange(null);
180+
};
181+
182+
// Focus the textarea when clicking on the container
183+
const handleContainerClick = (e: React.MouseEvent<HTMLDivElement>) => {
184+
if (e.target === overlayRef.current) {
185+
textareaRef.current?.focus();
186+
}
187+
};
188+
189+
// Render the selected text highlight
190+
const renderHighlight = () => {
191+
if (!selectionRange || !isFocused) return null;
192+
193+
const { start, end } = selectionRange;
194+
const beforeSelection = text.substring(0, start);
195+
const selection = text.substring(start, end);
196+
const afterSelection = text.substring(end);
197+
198+
return (
199+
<div className="absolute inset-0 pointer-events-none whitespace-pre-wrap break-words p-3 text-transparent">
200+
<span>{beforeSelection}</span>
201+
<span className="bg-blue-200 text-transparent">{selection}</span>
202+
<span>{afterSelection}</span>
203+
</div>
204+
);
205+
};
206+
207+
return (
208+
<div
209+
className={`relative rounded-md border border-input ${className}`}
210+
onClick={handleContainerClick}
211+
>
212+
{/* Text highlight overlay */}
213+
{renderHighlight()}
214+
215+
{/* Custom caret */}
216+
<AnimatePresence>
217+
<CustomCaret
218+
position={caretPosition}
219+
isVisible={isCaretVisible}
220+
isFocused={isFocused}
221+
hasSelection={!!selectionRange}
222+
/>
223+
</AnimatePresence>
224+
225+
{/* Text display overlay */}
226+
<div
227+
ref={overlayRef}
228+
className="absolute inset-0 pointer-events-none whitespace-pre-wrap break-words p-3 text-transparent"
229+
>
230+
{text || placeholder}
231+
</div>
232+
233+
{/* Actual textarea (invisible but functional) */}
234+
<textarea
235+
ref={textareaRef}
236+
value={text}
237+
onChange={handleChange}
238+
onSelect={handleSelect}
239+
onKeyUp={handleKeyUp}
240+
onMouseUp={handleMouseUp}
241+
onFocus={handleFocus}
242+
onBlur={handleBlur}
243+
placeholder={placeholder}
244+
rows={rows}
245+
className="relative w-full h-full p-3 bg-transparent text-foreground resize-none border"
246+
style={{ caretColor: "transparent" }} // Hide the native caret
247+
/>
248+
</div>
249+
);
250+
};
251+
252+
export default CustomTextarea;

src/components/animations/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import YearsTabs from "@/components/animations/years-tabs";
4040
import Feedback from "@/components/shared/feedback";
4141
import Footer from "@/components/shared/footer";
4242
import Hero from "@/components/shared/hero";
43+
import CustomTextarea from "./custom-textarea";
4344
import DotMatrixClock from "./dotted-matric-clock";
4445
import DynamicStatusButton from "./dynamic-status-button";
4546
import FluidButton from "./fluid-button";
@@ -50,6 +51,7 @@ import ModeToggle from "./mode-toggle";
5051
import Plan from "./plan";
5152
import StackClick from "./stacked-click";
5253
import StatusButton from "./status-button";
54+
import Tasks from "./tasks";
5355
import TodoList from "./todo-list";
5456
import View from "./view";
5557
import Volume from "./volume";
@@ -64,6 +66,7 @@ export {
6466
ComponentPreview,
6567
Counter,
6668
Cursor,
69+
CustomTextarea,
6770
DotMatrixClock,
6871
DropdownNav,
6972
DynamicIsland,
@@ -98,6 +101,7 @@ export {
98101
StackClick,
99102
StatusButton,
100103
TabBars,
104+
Tasks,
101105
TelegramInput,
102106
TodoList,
103107
Typer,

src/components/animations/joi-download-button.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ const JoiDownloadButton = () => {
3030
<motion.span animate={{ x: hovered ? -22 : 0 }} transition={TRANSITION}>
3131
Download for IOS
3232
</motion.span>
33-
3433
<motion.span
3534
animate={{ x: hovered ? -12 : 20, opacity: hovered ? 1 : 0 }}
3635
transition={TRANSITION}

0 commit comments

Comments
 (0)