לדלג לתוכן

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 מונעת טעינות מיותרות

תשובות לשאלות

  1. useState לעומת useLocalStorage - useState שומר ערכים בזיכרון בלבד - הם נמחקים ברענון הדף. useLocalStorage שומר את הערכים גם ב-localStorage של הדפדפן, כך שהם נשארים אחרי רענון, סגירת הדפדפן ופתיחה מחדש. ה-API זהה ל-useState אז ההחלפה פשוטה.

  2. useDebouncedValue לחיפוש - בלי debounce, כל הקלדת תו שולחת קריאת API. אם המשתמש מקליד "שלום" זה 4 קריאות (ש, של, שלו, שלום). עם debounce של 300ms, הקריאה נשלחת רק אחרי שהמשתמש הפסיק להקליד, מה שחוסך קריאות רשת מיותרות ומשפר ביצועים.

  3. 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")}.

  4. useIntersection לעומת useScrollIntoView - useIntersection הוא רק "צופה" - הוא מדווח מתי אלמנט נכנס/יוצא מהתצוגה (שימושי ל-lazy loading, infinite scroll, אנימציות). useScrollIntoView הוא "פועל" - הוא גולל את הדף כדי להציג אלמנט ספציפי (שימושי לניווט, "חזרה למעלה", קישורי עוגן).

  5. useHotkeys ומה זה mod - useHotkeys מגדיר קיצורי מקלדת גלובליים לאפליקציה. הוא שימושי לפעולות מהירות כמו שמירה (Ctrl+S), חיפוש (Ctrl+K), ניווט. mod הוא alias שמתורגם אוטומטית ל-Ctrl בווינדוס/לינוקס ול-Cmd (Command) במק, כך שקיצור אחד עובד בכל מערכת הפעלה.