Skip to content
Merged
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
Expand Up @@ -84,11 +84,7 @@ export const HomeTodoCard = ({
const titleRow = (
<div className="flex w-full items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1">
<Checkbox
checked={isCompleted}
onChange={onToggleCompleted}
disabled={isCompleted}
/>
<Checkbox checked={isCompleted} onChange={onToggleCompleted} />
<p
className={cn(
"typo-body-sb-12 min-w-0 flex-1 truncate",
Expand Down Expand Up @@ -133,7 +129,6 @@ export const HomeTodoCard = ({
<Checkbox
checked={isSubtaskCompleted}
onChange={(checked) => onToggleSubtaskCompleted?.(checked)}
disabled={isCompleted}
/>
<p className="typo-body-r-12 text-timo-gray-700 min-w-0 flex-1 truncate">
{subtaskTitle}
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,77 @@
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 { SubtaskInputEntry } from "@/utils/todo/subtask-input-list";
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: SubtaskInputEntry[];
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((entry, index) => (
<div
key={entry.id}
className="flex w-full items-center justify-center gap-2"
>
<Checkbox checked={false} onChange={() => {}} />
<textarea
value={entry.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,114 @@
import { useEffect, useRef, useState } from "react";
import { useController } from "react-hook-form";

import type { CreateTodoRequest } from "@/api/todo/todo-schema";
import type { SubtaskInputEntry } from "@/utils/todo/subtask-input-list";
import type { KeyboardEvent } from "react";
import type { Control } from "react-hook-form";

import {
addSubtaskInputOnEnter,
removeSubtaskInputOnBackspace,
} from "@/utils/todo/subtask-input-list";

const MAX_SUBTASK_COUNT = 10;

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

export const useSubtaskField = ({ control }: UseSubtaskFieldParams) => {
const { field } = useController({ name: "subtasks", control });
const nextEntryId = useRef(0);
const createEntry = (value = ""): SubtaskInputEntry => ({
id: nextEntryId.current++,
value,
});

const [subtaskInputs, setSubtaskInputs] = useState<SubtaskInputEntry[]>(
() => [createEntry()],
);
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 = (entries: SubtaskInputEntry[]) => {
field.onChange(entries.map((entry) => entry.value.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((entry, i) =>
i === index ? { ...entry, value } : entry,
);
syncFormValue(next);
return next;
});
};

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

setSubtaskInputs((prev) => {
const { entries, focusIndex } = addSubtaskInputOnEnter(
prev,
index,
createEntry,
MAX_SUBTASK_COUNT,
);

if (focusIndex !== null) pendingFocusIndex.current = focusIndex;
return entries;
});
return;
}

if (event.key === "Backspace") {
const { entries, focusIndex } = removeSubtaskInputOnBackspace(
subtaskInputs,
index,
);

if (focusIndex !== null) {
event.preventDefault();
pendingFocusIndex.current = focusIndex;
setSubtaskInputs(entries);
}
}
};

const reset = () => {
setSubtaskInputs([createEntry()]);
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