8.5 מנטין הוקים פתרון
פתרון - מנטין הוקים¶
פתרון תרגיל 1¶
import { useForm } from "@mantine/form";
import { useDisclosure } from "@mantine/hooks";
import {
Paper, Title, TextInput, Button, Stack, Group, Modal, Text,
} from "@mantine/core";
function RegistrationForm() {
const [success, successHandlers] = useDisclosure(false);
const form = useForm({
mode: "uncontrolled",
initialValues: {
name: "",
email: "",
password: "",
confirmPassword: "",
},
validate: {
name: (value) =>
value.trim().length < 2 ? "השם חייב להכיל לפחות 2 תווים" : null,
email: (value) =>
/^\S+@\S+\.\S+$/.test(value) ? null : "כתובת אימייל לא תקינה",
password: (value) => {
if (value.length < 8) return "הסיסמה חייבת להכיל לפחות 8 תווים";
if (!/[A-Z]/.test(value)) return "הסיסמה חייבת להכיל אות גדולה";
if (!/[0-9]/.test(value)) return "הסיסמה חייבת להכיל מספר";
return null;
},
confirmPassword: (value, values) =>
value !== values.password ? "הסיסמאות לא תואמות" : null,
},
validateInputOnBlur: true,
validateInputOnChange: ["confirmPassword"],
});
function handleSubmit() {
successHandlers.open();
}
return (
<>
<Paper shadow="sm" p="xl" withBorder maw={450} mx="auto">
<Title order={2} mb="lg">הרשמה</Title>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<TextInput
label="שם מלא"
placeholder="הכנס שם"
required
key={form.key("name")}
{...form.getInputProps("name")}
/>
<TextInput
label="אימייל"
placeholder="your@email.com"
required
key={form.key("email")}
{...form.getInputProps("email")}
/>
<TextInput
label="סיסמה"
placeholder="לפחות 8 תווים, אות גדולה ומספר"
type="password"
required
key={form.key("password")}
{...form.getInputProps("password")}
/>
<TextInput
label="אישור סיסמה"
placeholder="הכנס שוב את הסיסמה"
type="password"
required
key={form.key("confirmPassword")}
{...form.getInputProps("confirmPassword")}
/>
<Group mt="md">
<Button type="submit" flex={1}>
הרשם
</Button>
<Button variant="default" onClick={() => form.reset()}>
נקה טופס
</Button>
</Group>
</Stack>
</form>
</Paper>
<Modal opened={success} onClose={successHandlers.close} title="נרשמת בהצלחה" centered>
<Text>ברוך הבא! החשבון שלך נוצר בהצלחה.</Text>
<Button
fullWidth
mt="md"
onClick={() => {
successHandlers.close();
form.reset();
}}
>
אישור
</Button>
</Modal>
</>
);
}
- ולידציית סיסמה בודקת אורך, אות גדולה ומספר
- אישור סיסמה מוודא התאמה עם cross-field validation
- validateInputOnChange על confirmPassword לעדכון מיידי
- form.reset() מאפס הכל לערכים ההתחלתיים
פתרון תרגיל 2¶
import { useState, useRef } from "react";
import { useDebouncedValue, useHotkeys } from "@mantine/hooks";
import { TextInput, Paper, Stack, Text, Loader, Group } from "@mantine/core";
const allItems = [
"ריאקט - React",
"טייפסקריפט - TypeScript",
"ג'אווהסקריפט - JavaScript",
"מנטין - Mantine",
"טיילווינד - Tailwind",
"נוד - Node.js",
"אקספרס - Express",
"מונגו - MongoDB",
"פייתון - Python",
"ג'אווה - Java",
];
function AdvancedSearch() {
const [value, setValue] = useState("");
const [debounced] = useDebouncedValue(value, 300);
const inputRef = useRef<HTMLInputElement>(null);
const isLoading = value !== debounced && value.length > 0;
const results = debounced
? allItems.filter((item) =>
item.toLowerCase().includes(debounced.toLowerCase())
)
: [];
useHotkeys([
["mod+k", () => inputRef.current?.focus()],
["escape", () => {
setValue("");
inputRef.current?.blur();
}],
]);
return (
<Paper shadow="sm" p="lg" withBorder maw={500} mx="auto">
<Stack gap="md">
<TextInput
ref={inputRef}
label="חיפוש"
placeholder="הקלד לחיפוש... (Ctrl+K)"
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
rightSection={isLoading ? <Loader size="xs" /> : null}
/>
{isLoading && (
<Text size="sm" c="dimmed">מחפש...</Text>
)}
{!isLoading && debounced && results.length === 0 && (
<Text size="sm" c="red">לא נמצאו תוצאות עבור "{debounced}"</Text>
)}
{!isLoading && results.length > 0 && (
<Stack gap="xs">
<Text size="sm" c="dimmed">{results.length} תוצאות:</Text>
{results.map((item) => (
<Paper key={item} p="sm" bg="gray.0" radius="md">
<Text size="sm">{item}</Text>
</Paper>
))}
</Stack>
)}
<Group gap="xs">
<Text size="xs" c="dimmed">Ctrl+K לפוקוס</Text>
<Text size="xs" c="dimmed">|</Text>
<Text size="xs" c="dimmed">Escape לניקוי</Text>
</Group>
</Stack>
</Paper>
);
}
- השוואת value לעומת debounced מזהה מצב "טוען"
- Ctrl+K ממקד את שדה החיפוש, Escape מנקה
- Loader מוצג בצד השדה בזמן debounce
פתרון תרגיל 3¶
import { useLocalStorage, useMediaQuery } from "@mantine/hooks";
import {
Paper, Title, Switch, Select, NumberInput, TextInput, Button, Stack, Text, Alert,
} from "@mantine/core";
function SettingsPage() {
const isMobile = useMediaQuery("(max-width: 768px)");
const [darkMode, setDarkMode] = useLocalStorage({
key: "settings-dark-mode",
defaultValue: false,
});
const [language, setLanguage] = useLocalStorage({
key: "settings-language",
defaultValue: "he",
});
const [fontSize, setFontSize] = useLocalStorage({
key: "settings-font-size",
defaultValue: 16,
});
const [username, setUsername] = useLocalStorage({
key: "settings-username",
defaultValue: "",
});
function resetDefaults() {
setDarkMode(false);
setLanguage("he");
setFontSize(16);
setUsername("");
}
return (
<Paper shadow="sm" p={isMobile ? "md" : "xl"} withBorder maw={500} mx="auto">
<Title order={isMobile ? 3 : 2} mb="lg">
הגדרות
</Title>
<Alert variant="light" color="blue" mb="lg">
{isMobile
? "גרסת טלפון - כל השינויים נשמרים אוטומטית"
: "גרסת דסקטופ - כל השינויים נשמרים אוטומטית ב-localStorage"}
</Alert>
<Stack gap="md">
<TextInput
label="שם משתמש"
placeholder="הכנס שם"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
/>
<Switch
label="מצב כהה"
checked={darkMode}
onChange={(e) => setDarkMode(e.currentTarget.checked)}
/>
<Select
label="שפה"
value={language}
onChange={(val) => setLanguage(val || "he")}
data={[
{ value: "he", label: "עברית" },
{ value: "en", label: "English" },
]}
/>
<NumberInput
label="גודל פונט"
value={fontSize}
onChange={(val) => setFontSize(Number(val) || 16)}
min={12}
max={24}
step={1}
suffix="px"
/>
<Text size="sm" c="dimmed">
דוגמה לטקסט בגודל {fontSize}px:
</Text>
<Text style={{ fontSize: `${fontSize}px` }}>
שלום עולם! זה טקסט לדוגמה.
</Text>
<Button variant="light" color="red" onClick={resetDefaults} mt="md">
איפוס להגדרות ברירת מחדל
</Button>
</Stack>
</Paper>
);
}
- כל שינוי נשמר אוטומטית ב-localStorage
- useMediaQuery מתאים את הממשק לטלפון/דסקטופ
- הטקסט לדוגמה משתנה בזמן אמת עם גודל הפונט
פתרון תרגיל 4¶
import { useState } from "react";
import { useForm } from "@mantine/form";
import { useLocalStorage, useDisclosure, useClipboard, useHotkeys } from "@mantine/hooks";
import {
Paper, Title, TextInput, Button, Stack, Group, Modal, Text,
Checkbox, Tooltip, ActionIcon,
} from "@mantine/core";
interface Todo {
id: string;
text: string;
completed: boolean;
}
function TodoApp() {
const [todos, setTodos] = useLocalStorage<Todo[]>({
key: "todos",
defaultValue: [],
});
const [editOpened, editHandlers] = useDisclosure(false);
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const clipboard = useClipboard({ timeout: 2000 });
const form = useForm({
mode: "uncontrolled",
initialValues: { text: "" },
validate: {
text: (v) => (v.trim().length < 1 ? "הכנס טקסט" : null),
},
});
const editForm = useForm({
mode: "uncontrolled",
initialValues: { text: "" },
});
function addTodo(values: { text: string }) {
const newTodo: Todo = {
id: Date.now().toString(),
text: values.text,
completed: false,
};
setTodos([...todos, newTodo]);
form.reset();
}
function toggleTodo(id: string) {
setTodos(todos.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)));
}
function deleteTodo(id: string) {
setTodos(todos.filter((t) => t.id !== id));
if (selectedId === id) setSelectedId(null);
}
function startEdit(todo: Todo) {
setEditingTodo(todo);
editForm.setFieldValue("text", todo.text);
editHandlers.open();
}
function saveEdit(values: { text: string }) {
if (editingTodo) {
setTodos(todos.map((t) =>
t.id === editingTodo.id ? { ...t, text: values.text } : t
));
}
editHandlers.close();
}
function copyList() {
const text = todos
.map((t) => `${t.completed ? "[V]" : "[ ]"} ${t.text}`)
.join("\n");
clipboard.copy(text);
}
useHotkeys([
["mod+n", () => {
const input = document.querySelector<HTMLInputElement>("[data-todo-input]");
input?.focus();
}],
["mod+d", () => {
if (selectedId) deleteTodo(selectedId);
}],
]);
return (
<Paper shadow="sm" p="xl" withBorder maw={500} mx="auto">
<Group justify="space-between" mb="lg">
<Title order={2}>רשימת משימות</Title>
<Tooltip label={clipboard.copied ? "הועתק!" : "העתק רשימה"}>
<Button
variant="light"
size="xs"
color={clipboard.copied ? "green" : "blue"}
onClick={copyList}
>
{clipboard.copied ? "הועתק" : "העתק"}
</Button>
</Tooltip>
</Group>
<form onSubmit={form.onSubmit(addTodo)}>
<Group mb="lg">
<TextInput
placeholder="הוסף משימה... (Ctrl+N)"
style={{ flex: 1 }}
data-todo-input
key={form.key("text")}
{...form.getInputProps("text")}
/>
<Button type="submit">הוסף</Button>
</Group>
</form>
<Stack gap="xs">
{todos.length === 0 && (
<Text c="dimmed" ta="center" py="lg">אין משימות עדיין</Text>
)}
{todos.map((todo) => (
<Paper
key={todo.id}
p="sm"
withBorder
bg={selectedId === todo.id ? "blue.0" : undefined}
onClick={() => setSelectedId(todo.id)}
style={{ cursor: "pointer" }}
>
<Group justify="space-between">
<Group>
<Checkbox
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<Text
size="sm"
td={todo.completed ? "line-through" : undefined}
c={todo.completed ? "dimmed" : undefined}
>
{todo.text}
</Text>
</Group>
<Group gap="xs">
<ActionIcon variant="subtle" size="sm" onClick={() => startEdit(todo)}>
E
</ActionIcon>
<ActionIcon variant="subtle" size="sm" color="red" onClick={() => deleteTodo(todo.id)}>
X
</ActionIcon>
</Group>
</Group>
</Paper>
))}
</Stack>
<Text size="xs" c="dimmed" mt="md">
Ctrl+N: הוסף | Ctrl+D: מחק מסומן
</Text>
<Modal opened={editOpened} onClose={editHandlers.close} title="עריכת משימה" centered>
<form onSubmit={editForm.onSubmit(saveEdit)}>
<Stack>
<TextInput
label="טקסט המשימה"
key={editForm.key("text")}
{...editForm.getInputProps("text")}
/>
<Group justify="flex-end">
<Button variant="default" onClick={editHandlers.close}>ביטול</Button>
<Button type="submit">שמור</Button>
</Group>
</Stack>
</form>
</Modal>
</Paper>
);
}
- המשימות נשמרות ב-localStorage ונשארות אחרי רענון
- קיצורי מקלדת לפעולות מהירות
- העתקת רשימה מעוצבת ללוח
פתרון תרגיל 5¶
import { useWindowScroll, useMediaQuery } from "@mantine/hooks";
import { Button, Stack, Text, Paper } from "@mantine/core";
function BackToTop() {
const [scroll, scrollTo] = useWindowScroll();
const isMobile = useMediaQuery("(max-width: 768px)");
const isVisible = scroll.y > 300;
return (
<>
{/* תוכן ארוך לדוגמה */}
<Stack p="xl" maw={600} mx="auto">
{Array.from({ length: 30 }, (_, i) => (
<Paper key={i} p="md" shadow="xs" withBorder>
<Text>פריט מספר {i + 1}</Text>
<Text size="sm" c="dimmed">תוכן לדוגמה לגלילה</Text>
</Paper>
))}
</Stack>
{/* כפתור חזרה למעלה */}
<Button
onClick={() => scrollTo({ y: 0 })}
size={isMobile ? "sm" : "md"}
radius="xl"
style={{
position: "fixed",
bottom: 24,
left: 24,
zIndex: 100,
opacity: isVisible ? 1 : 0,
transform: isVisible ? "scale(1)" : "scale(0.8)",
transition: "opacity 0.3s ease, transform 0.3s ease",
pointerEvents: isVisible ? "auto" : "none",
}}
>
{isMobile ? "^" : "^ חזרה למעלה"}
</Button>
</>
);
}
- הכפתור מוצמד לפינה שמאלית תחתונה עם position: fixed
- אנימציית opacity ו-scale עם CSS transition
- pointerEvents: none כשלא נראה - מונע לחיצה על כפתור שקוף
- בטלפון הכפתור קטן יותר ומציג רק חץ
פתרון תרגיל 6¶
import { useState, useCallback } from "react";
import { useIntersection } from "@mantine/hooks";
import { Paper, Text, Stack, Skeleton, Alert, Container } from "@mantine/core";
interface Item {
id: number;
title: string;
content: string;
}
function generateItems(start: number, count: number): Item[] {
return Array.from({ length: count }, (_, i) => ({
id: start + i,
title: `פריט מספר ${start + i}`,
content: `תוכן של הפריט. זהו תיאור ארוך יותר שמתאר את הפריט מספר ${start + i}.`,
}));
}
const TOTAL_ITEMS = 50;
const PAGE_SIZE = 10;
function InfiniteScroll() {
const [items, setItems] = useState<Item[]>(() => generateItems(1, PAGE_SIZE));
const [loading, setLoading] = useState(false);
const hasMore = items.length < TOTAL_ITEMS;
const loadMore = useCallback(() => {
if (loading || !hasMore) return;
setLoading(true);
// סימולציה של טעינה מהשרת
setTimeout(() => {
const nextItems = generateItems(items.length + 1, PAGE_SIZE);
setItems((prev) => [...prev, ...nextItems]);
setLoading(false);
}, 1000);
}, [loading, hasMore, items.length]);
const { ref, entry } = useIntersection({
threshold: 0.5,
});
// טעינה כשהאלמנט נכנס לתצוגה
if (entry?.isIntersecting && !loading && hasMore) {
loadMore();
}
return (
<Container size="sm" py="xl">
<Text size="xl" fw={700} mb="lg">
אינסוף גלילה - {items.length}/{TOTAL_ITEMS} פריטים
</Text>
<Stack gap="md">
{items.map((item) => (
<Paper key={item.id} p="md" shadow="xs" withBorder>
<Text fw={500} mb="xs">{item.title}</Text>
<Text size="sm" c="dimmed">{item.content}</Text>
</Paper>
))}
{/* Skeleton בזמן טעינה */}
{loading && (
<>
{[1, 2, 3].map((n) => (
<Paper key={`skeleton-${n}`} p="md" withBorder>
<Skeleton height={16} width="40%" mb="sm" />
<Skeleton height={12} width="80%" />
</Paper>
))}
</>
)}
{/* אלמנט שומר - מפעיל טעינה כשנכנס לתצוגה */}
{hasMore && !loading && (
<div ref={ref} style={{ height: 20 }} />
)}
{/* הודעת סיום */}
{!hasMore && (
<Alert variant="light" color="gray" title="זהו!">
הגעת לסוף הרשימה. אין עוד פריטים לטעון.
</Alert>
)}
</Stack>
</Container>
);
}
- useIntersection מזהה מתי האלמנט ה"שומר" נכנס לתצוגה
- setTimeout מדמה קריאת API של 1 שנייה
- Skeleton מוצג בזמן טעינה כ-placeholder
- בדיקת hasMore מונעת טעינות מיותרות
תשובות לשאלות¶
-
useState לעומת useLocalStorage - useState שומר ערכים בזיכרון בלבד - הם נמחקים ברענון הדף. useLocalStorage שומר את הערכים גם ב-localStorage של הדפדפן, כך שהם נשארים אחרי רענון, סגירת הדפדפן ופתיחה מחדש. ה-API זהה ל-useState אז ההחלפה פשוטה.
-
useDebouncedValue לחיפוש - בלי debounce, כל הקלדת תו שולחת קריאת API. אם המשתמש מקליד "שלום" זה 4 קריאות (ש, של, שלו, שלום). עם debounce של 300ms, הקריאה נשלחת רק אחרי שהמשתמש הפסיק להקליד, מה שחוסך קריאות רשת מיותרות ומשפר ביצועים.
-
form.getInputProps - הפונקציה מחזירה אובייקט עם value, onChange, onBlur, error - כל מה שהקומפוננטה צריכה. במקום לכתוב ידנית
value={form.values.name} onChange={(e) => form.setFieldValue("name", e.target.value)} error={form.errors.name}, כותבים פשוט{...form.getInputProps("name")}. -
useIntersection לעומת useScrollIntoView - useIntersection הוא רק "צופה" - הוא מדווח מתי אלמנט נכנס/יוצא מהתצוגה (שימושי ל-lazy loading, infinite scroll, אנימציות). useScrollIntoView הוא "פועל" - הוא גולל את הדף כדי להציג אלמנט ספציפי (שימושי לניווט, "חזרה למעלה", קישורי עוגן).
-
useHotkeys ומה זה mod - useHotkeys מגדיר קיצורי מקלדת גלובליים לאפליקציה. הוא שימושי לפעולות מהירות כמו שמירה (Ctrl+S), חיפוש (Ctrl+K), ניווט.
modהוא alias שמתורגם אוטומטית ל-Ctrl בווינדוס/לינוקס ול-Cmd (Command) במק, כך שקיצור אחד עובד בכל מערכת הפעלה.