Skip to content
2 changes: 1 addition & 1 deletion apps/timo-web/api/todo/todo-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const createTodoRequestSchema = z
repeatType: todoRepeatTypeSchema,
repeatWeekdays: z.array(todoRepeatWeekdaySchema).nullable(),
repeatDayOfMonth: z.number().int().min(1).max(31).nullable(),
memo: z.string().nullable(),
memo: z.string().max(300).nullable(),
})
.superRefine((value, ctx) => {
if (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
const MEMO_MAX_LENGTH = 300;

const resizeTextarea = (element: HTMLTextAreaElement | null) => {
if (!element) return;
element.style.height = "auto";
element.style.height = `${element.scrollHeight}px`;
};

export interface CreateTodoMemoFieldProps {
value: string;
placeholder: string;
Expand All @@ -12,10 +20,13 @@ export const CreateTodoMemoField = ({
return (
<textarea
value={value}
ref={resizeTextarea}
onChange={(event) => onChange(event.target.value)}
onInput={(event) => resizeTextarea(event.currentTarget)}
placeholder={placeholder}
maxLength={MEMO_MAX_LENGTH}
rows={1}
className="typo-body-r-12 text-timo-gray-800 w-full resize-none outline-none"
className="typo-body-r-12 text-timo-gray-800 max-h-[40vh] w-full resize-none overflow-y-auto wrap-break-word outline-none"
/>
);
};
Original file line number Diff line number Diff line change
@@ -1,42 +1,76 @@
import { Checkbox } from "@repo/timo-design-system/ui";

import type { CreateTodoRequest } from "@/api/todo/todo-schema";
import type { UseFormRegister } from "react-hook-form";
import type { KeyboardEvent } from "react";

const resizeTextarea = (element: HTMLTextAreaElement | null) => {
if (!element) return;
element.style.height = "auto";
element.style.height = `${element.scrollHeight}px`;
};

export interface CreateTodoTaskFieldsProps {
register: UseFormRegister<CreateTodoRequest>;
titleValue: string;
titlePlaceholder: string;
subtaskInput: string;
onTitleChange: (value: string) => void;
subtaskInputs: string[];
subtaskPlaceholder: string;
onSubtaskInputChange: (value: string) => void;
registerSubtaskInputRef: (
index: number,
) => (element: HTMLTextAreaElement | null) => void;
onSubtaskInputChange: (index: number, value: string) => void;
onSubtaskInputKeyDown: (
index: number,
event: KeyboardEvent<HTMLTextAreaElement>,
) => void;
}

export const CreateTodoTaskFields = ({
register,
titleValue,
titlePlaceholder,
subtaskInput,
onTitleChange,
subtaskInputs,
subtaskPlaceholder,
registerSubtaskInputRef,
onSubtaskInputChange,
onSubtaskInputKeyDown,
}: CreateTodoTaskFieldsProps) => {
return (
<div className="flex w-full flex-col items-start gap-1.5">
<div className="flex w-full items-center gap-2">
<Checkbox checked={false} onChange={() => {}} disabled />
<input
{...register("title")}
<div className="flex w-full items-center justify-center gap-2">
<Checkbox checked={false} onChange={() => {}} />
<textarea
value={titleValue}
ref={resizeTextarea}
onChange={(event) => {
onTitleChange(event.target.value);
resizeTextarea(event.currentTarget);
}}
placeholder={titlePlaceholder}
className="typo-headline-b-14 text-timo-black min-w-0 flex-1 outline-none"
/>
</div>
<div className="flex w-full items-center gap-2">
<Checkbox checked={false} onChange={() => {}} disabled />
<input
value={subtaskInput}
onChange={(event) => onSubtaskInputChange(event.target.value)}
placeholder={subtaskPlaceholder}
className="typo-body-r-12 text-timo-gray-800 min-w-0 flex-1 outline-none"
rows={1}
className="typo-headline-b-14 text-timo-black min-w-0 flex-1 resize-none overflow-hidden wrap-break-word outline-none"
/>
</div>

{subtaskInputs.map((value, index) => (
<div
key={index}
className="flex w-full items-center justify-center gap-2"
Comment thread
kimminna marked this conversation as resolved.
Resolved
>
<Checkbox checked={false} onChange={() => {}} />
<textarea
value={value}
ref={registerSubtaskInputRef(index)}
onChange={(event) => {
onSubtaskInputChange(index, event.target.value);
resizeTextarea(event.currentTarget);
}}
onKeyDown={(event) => onSubtaskInputKeyDown(index, event)}
placeholder={subtaskPlaceholder}
rows={1}
className="typo-body-r-12 text-timo-gray-800 min-w-0 flex-1 resize-none overflow-hidden wrap-break-word outline-none"
/>
</div>
))}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { DeleteIcon } from "@repo/timo-design-system/icons";
import { CreateButton, TodoToolbar } from "@repo/timo-design-system/ui";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useController, useForm, useWatch } from "react-hook-form";
import { useController, useForm } from "react-hook-form";

import type { CreateTodoRequest } from "@/api/todo/todo-schema";
import type { PriorityLevel } from "@repo/timo-design-system/ui";
Expand All @@ -16,11 +15,13 @@ import { CreateTodoMemoField } from "@/app/[locale]/(main)/(with-time-sidebar)/h
import { CreateTodoTaskFields } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_components/todo-modal/CreateTodoTaskFields";
import { useIconField } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-icon-field";
import { useRepeatField } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-repeat-field";
import { useSubtaskField } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-subtask-field";
import {
DEFAULT_TAG,
useTagField,
} from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-tag-field";
import { useTimeField } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-time-field";
import { useTitleField } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/todo-modal/use-title-field";
import { formatDateToIsoDate } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date";
import { OverlayModal } from "@/components/modal/OverlayModal";
import { AnimatedToast } from "@/components/toast/AnimatedToast";
Expand Down Expand Up @@ -63,15 +64,11 @@ export const CreateTodoModalContent = ({
const tCommon = useTranslations("Common");
const tToast = useTranslations("Toast");

const [subtaskInput, setSubtaskInput] = useState("");

const { register, control, handleSubmit, setValue, reset } =
useForm<CreateTodoRequest>({
resolver: zodResolver(createTodoRequestSchema),
defaultValues: createDefaultValues(defaultDate),
});
const { control, handleSubmit, reset } = useForm<CreateTodoRequest>({
resolver: zodResolver(createTodoRequestSchema),
defaultValues: createDefaultValues(defaultDate),
});

const title = useWatch({ control, name: "title" });
const { field: dateField } = useController({ name: "date", control });
const { field: memoField } = useController({ name: "memo", control });
const { field: priorityField } = useController({
Expand All @@ -83,25 +80,20 @@ export const CreateTodoModalContent = ({
const tagField = useTagField({ control });
const timeField = useTimeField({ control });
const repeatField = useRepeatField({ control });
const subtaskField = useSubtaskField({ control });
const titleField = useTitleField({ control });

const dateValue = dateField.value
? new Date(`${dateField.value}T00:00:00`)
: undefined;

const handleSubtaskInputChange = (value: string) => {
setSubtaskInput(value);
setValue("subtasks", value.trim() ? [value] : [], {
shouldValidate: true,
});
};

const handleFormSubmit = (data: CreateTodoRequest) => {
const tag = tagField.selectedTagOption ?? DEFAULT_TAG;

onSubmit(data, { id: data.tagId ?? tag.id, name: tag.name });

reset(createDefaultValues(defaultDate));
setSubtaskInput("");
subtaskField.reset();
timeField.resetTime();
onClose();
};
Expand Down Expand Up @@ -142,11 +134,14 @@ export const CreateTodoModalContent = ({
/>

<CreateTodoTaskFields
register={register}
titleValue={titleField.title}
titlePlaceholder={t("createModal.titlePlaceholder")}
subtaskInput={subtaskInput}
onTitleChange={titleField.handleTitleChange}
subtaskInputs={subtaskField.subtaskInputs}
subtaskPlaceholder={t("createModal.subtaskPlaceholder")}
onSubtaskInputChange={handleSubtaskInputChange}
registerSubtaskInputRef={subtaskField.registerInputRef}
onSubtaskInputChange={subtaskField.handleInputChange}
onSubtaskInputKeyDown={subtaskField.handleInputKeyDown}
/>
</div>

Expand Down Expand Up @@ -214,7 +209,7 @@ export const CreateTodoModalContent = ({

<CreateButton
label={t("createModal.create")}
disabled={!title.trim()}
disabled={!titleField.title.trim()}
onClick={handleSubmit(handleFormSubmit)}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useEffect, useRef, useState } from "react";
import { useController } from "react-hook-form";

import type { CreateTodoRequest } from "@/api/todo/todo-schema";
import type { KeyboardEvent } from "react";
import type { Control } from "react-hook-form";

const MAX_SUBTASK_COUNT = 10;

export interface UseSubtaskFieldParams {
control: Control<CreateTodoRequest>;
}

export const useSubtaskField = ({ control }: UseSubtaskFieldParams) => {
const { field } = useController({ name: "subtasks", control });
const [subtaskInputs, setSubtaskInputs] = useState<string[]>([""]);
const inputRefs = useRef<Array<HTMLTextAreaElement | null>>([]);
const pendingFocusIndex = useRef<number | null>(null);

useEffect(() => {
if (pendingFocusIndex.current === null) return;

const index = pendingFocusIndex.current;
pendingFocusIndex.current = null;

const element = inputRefs.current[index];
if (!element) return;

element.focus();
const caretPosition = element.value.length;
element.setSelectionRange(caretPosition, caretPosition);
}, [subtaskInputs.length]);

const syncFormValue = (inputs: string[]) => {
field.onChange(inputs.map((input) => input.trim()).filter(Boolean));
};

const registerInputRef =
(index: number) => (element: HTMLTextAreaElement | null) => {
inputRefs.current[index] = element;
};

const handleInputChange = (index: number, value: string) => {
setSubtaskInputs((prev) => {
const next = prev.map((input, i) => (i === index ? value : input));
syncFormValue(next);
return next;
});
};

const handleInputKeyDown = (
index: number,
event: KeyboardEvent<HTMLTextAreaElement>,
) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();

setSubtaskInputs((prev) => {
const isLastField = index === prev.length - 1;
const hasValue = Boolean(prev[index]?.trim());
const canAddMore = prev.length < MAX_SUBTASK_COUNT;

if (!isLastField || !hasValue || !canAddMore) return prev;

pendingFocusIndex.current = prev.length;
return [...prev, ""];
});
return;
}

const isFieldEmpty = subtaskInputs[index] === "";
const canMergeIntoPrevious = index > 0;

if (event.key === "Backspace" && isFieldEmpty && canMergeIntoPrevious) {
event.preventDefault();
pendingFocusIndex.current = index - 1;
setSubtaskInputs((prev) => prev.filter((_, i) => i !== index));
}
};

const reset = () => {
setSubtaskInputs([""]);
inputRefs.current = [];
};

return {
subtaskInputs,
registerInputRef,
handleInputChange,
handleInputKeyDown,
reset,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useController } from "react-hook-form";

import type { CreateTodoRequest } from "@/api/todo/todo-schema";
import type { Control } from "react-hook-form";

import {
TITLE_MAX_WEIGHTED_LENGTH,
truncateToWeightedLength,
} from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/text-length";

export interface UseTitleFieldParams {
control: Control<CreateTodoRequest>;
}

export const useTitleField = ({ control }: UseTitleFieldParams) => {
const { field } = useController({ name: "title", control });

const handleTitleChange = (value: string) => {
field.onChange(truncateToWeightedLength(value, TITLE_MAX_WEIGHTED_LENGTH));
};

return {
title: field.value,
handleTitleChange,
};
};
Loading
Loading