לדלג לתוכן

8.5 מנטין הוקים הרצאה

מנטין הוקים - Mantine Hooks

בשיעור זה נלמד את ההוקים השימושיים שמנטין מציעה. הוקים אלו חוסכים כתיבת קוד חוזר ומספקים פונקציונליות נפוצה באופן אלגנטי.


useDisclosure - ניהול פתוח/סגור

ההוק הנפוץ ביותר - מנהל מצב בוליאני (פתוח/סגור) עם פעולות open, close, toggle.

import { useDisclosure } from "@mantine/hooks";
import { Modal, Drawer, Button, Group, Stack, Text } from "@mantine/core";

function DisclosureExample() {
  const [modalOpened, modalHandlers] = useDisclosure(false);
  const [drawerOpened, drawerHandlers] = useDisclosure(false);
  const [expanded, { toggle }] = useDisclosure(false);

  return (
    <>
      <Group>
        <Button onClick={modalHandlers.open}>פתח מודאל</Button>
        <Button onClick={drawerHandlers.open}>פתח מגירה</Button>
        <Button onClick={toggle}>
          {expanded ? "הסתר" : "הצג"} פרטים
        </Button>
      </Group>

      {expanded && (
        <Text mt="md">פרטים נוספים שמוצגים כשלוחצים על הכפתור</Text>
      )}

      <Modal opened={modalOpened} onClose={modalHandlers.close} title="מודאל">
        <Text>תוכן המודאל</Text>
      </Modal>

      <Drawer opened={drawerOpened} onClose={drawerHandlers.close} title="מגירה">
        <Text>תוכן המגירה</Text>
      </Drawer>
    </>
  );
}
  • מחזיר מערך: [value, { open, close, toggle }]
  • הפרמטר הראשון הוא הערך ההתחלתי (false כברירת מחדל)
  • ניתן להעביר callbacks שיופעלו ב-open ו-close
const [opened, { open, close }] = useDisclosure(false, {
  onOpen: () => console.log("נפתח"),
  onClose: () => console.log("נסגר"),
});

useForm - ניהול טפסים

הוק חזק לניהול טפסים עם ולידציה:

npm install @mantine/form
import { useForm } from "@mantine/form";
import { TextInput, NumberInput, Select, Button, Stack, Paper, Title } from "@mantine/core";

interface FormValues {
  name: string;
  email: string;
  age: number | "";
  role: string;
}

function RegistrationForm() {
  const form = useForm<FormValues>({
    mode: "uncontrolled",
    initialValues: {
      name: "",
      email: "",
      age: "",
      role: "",
    },
    validate: {
      name: (value) =>
        value.trim().length < 2 ? "השם חייב להכיל לפחות 2 תווים" : null,
      email: (value) =>
        /^\S+@\S+$/.test(value) ? null : "כתובת אימייל לא תקינה",
      age: (value) =>
        value === "" ? "גיל הוא שדה חובה" :
        value < 13 ? "הגיל המינימלי הוא 13" : null,
      role: (value) =>
        value ? null : "יש לבחור תפקיד",
    },
  });

  function handleSubmit(values: FormValues) {
    console.log("נשלח:", values);
  }

  return (
    <Paper shadow="sm" p="xl" withBorder maw={450} mx="auto">
      <Title order={3} 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")}
          />

          <NumberInput
            label="גיל"
            placeholder="הכנס גיל"
            min={0}
            max={120}
            required
            key={form.key("age")}
            {...form.getInputProps("age")}
          />

          <Select
            label="תפקיד"
            placeholder="בחר תפקיד"
            data={["מפתח", "מעצב", "מנהל פרויקט", "QA"]}
            required
            key={form.key("role")}
            {...form.getInputProps("role")}
          />

          <Button type="submit" fullWidth mt="md">
            שלח
          </Button>
        </Stack>
      </form>
    </Paper>
  );
}

פיצ'רים מתקדמים של useForm

const form = useForm({
  mode: "uncontrolled",
  initialValues: {
    name: "",
    email: "",
    terms: false,
  },
  validate: {
    name: (value) => (value.length < 2 ? "קצר מדי" : null),
    email: (value) => (/^\S+@\S+$/.test(value) ? null : "אימייל לא תקין"),
    terms: (value) => (value ? null : "חובה לאשר תנאים"),
  },
  validateInputOnBlur: true,
  validateInputOnChange: true,
});

// פעולות שימושיות
form.setFieldValue("name", "יוסי");
form.reset(); // איפוס לערכים ההתחלתיים
form.validate(); // הפעלת ולידציה ידנית
form.isValid(); // בדיקה אם הטופס תקין
form.isDirty(); // בדיקה אם הטופס השתנה
form.setFieldError("email", "כתובת תפוסה");

// ולידציה מבוססת ערכים אחרים
const formWithCross = useForm({
  mode: "uncontrolled",
  initialValues: {
    password: "",
    confirmPassword: "",
  },
  validate: {
    confirmPassword: (value, values) =>
      value !== values.password ? "הסיסמאות לא תואמות" : null,
  },
});

ניהול רשימות בטופס

import { useForm } from "@mantine/form";
import { TextInput, Button, Group, Stack, ActionIcon } from "@mantine/core";

function DynamicForm() {
  const form = useForm({
    mode: "uncontrolled",
    initialValues: {
      employees: [{ name: "", email: "" }],
    },
  });

  return (
    <form>
      <Stack>
        {form.getValues().employees.map((_, index) => (
          <Group key={form.key(`employees.${index}`)}>
            <TextInput
              placeholder="שם"
              {...form.getInputProps(`employees.${index}.name`)}
              style={{ flex: 1 }}
            />
            <TextInput
              placeholder="אימייל"
              {...form.getInputProps(`employees.${index}.email`)}
              style={{ flex: 1 }}
            />
            <ActionIcon
              color="red"
              onClick={() => form.removeListItem("employees", index)}
            >
              X
            </ActionIcon>
          </Group>
        ))}

        <Button
          variant="light"
          onClick={() =>
            form.insertListItem("employees", { name: "", email: "" })
          }
        >
          הוסף עובד
        </Button>
      </Stack>
    </form>
  );
}

useMediaQuery - זיהוי גודל מסך

import { useMediaQuery } from "@mantine/hooks";
import { Text, Stack } from "@mantine/core";

function MediaQueryExample() {
  const isMobile = useMediaQuery("(max-width: 768px)");
  const isTablet = useMediaQuery("(min-width: 769px) and (max-width: 1024px)");
  const isDesktop = useMediaQuery("(min-width: 1025px)");
  const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");

  return (
    <Stack>
      <Text>
        סוג מכשיר: {isMobile ? "טלפון" : isTablet ? "טאבלט" : "דסקטופ"}
      </Text>

      {isMobile ? (
        <Text>תצוגת טלפון - תפריט המבורגר</Text>
      ) : (
        <Text>תצוגת דסקטופ - תפריט מלא</Text>
      )}

      {prefersReducedMotion && (
        <Text>המשתמש מעדיף תנועה מופחתת - מבטלים אנימציות</Text>
      )}
    </Stack>
  );
}

useViewportSize - גודל חלון

import { useViewportSize } from "@mantine/hooks";
import { Text } from "@mantine/core";

function ViewportExample() {
  const { height, width } = useViewportSize();

  return (
    <Text>
      גודל חלון: {width}x{height} פיקסלים
    </Text>
  );
}

useClipboard - העתקה ללוח

import { useClipboard } from "@mantine/hooks";
import { Button, TextInput, Group, Tooltip } from "@mantine/core";

function ClipboardExample() {
  const clipboard = useClipboard({ timeout: 2000 });

  return (
    <Group>
      <TextInput
        value="https://example.com/share/abc123"
        readOnly
        style={{ flex: 1 }}
      />
      <Tooltip label={clipboard.copied ? "הועתק!" : "העתק"}>
        <Button
          color={clipboard.copied ? "green" : "blue"}
          onClick={() => clipboard.copy("https://example.com/share/abc123")}
        >
          {clipboard.copied ? "הועתק" : "העתק"}
        </Button>
      </Tooltip>
    </Group>
  );
}
  • clipboard.copy(text) - מעתיק טקסט ללוח
  • clipboard.copied - האם הועתק לאחרונה (מתאפס אחרי timeout)
  • clipboard.error - האם היתה שגיאה

useLocalStorage - אחסון מקומי

import { useLocalStorage } from "@mantine/hooks";
import { TextInput, Switch, Select, Stack, Text, Paper, Button } from "@mantine/core";

function LocalStorageExample() {
  const [name, setName] = useLocalStorage({
    key: "user-name",
    defaultValue: "",
  });

  const [darkMode, setDarkMode] = useLocalStorage({
    key: "dark-mode",
    defaultValue: false,
  });

  const [language, setLanguage] = useLocalStorage({
    key: "language",
    defaultValue: "he",
  });

  return (
    <Paper p="lg" shadow="sm" withBorder maw={400}>
      <Stack>
        <Text fw={500}>הגדרות (נשמרות ב-localStorage)</Text>

        <TextInput
          label="שם"
          value={name}
          onChange={(e) => setName(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" },
          ]}
        />

        <Text size="sm" c="dimmed">
          הערכים נשמרים אוטומטית ויישארו גם אחרי רענון הדף
        </Text>
      </Stack>
    </Paper>
  );
}

useHotkeys - קיצורי מקלדת

import { useHotkeys } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { Text, Paper, Stack, Kbd, Group } from "@mantine/core";

function HotkeysExample() {
  useHotkeys([
    ["mod+s", () => {
      console.log("שמירה");
      // notifications.show({ message: "נשמר בהצלחה" });
    }],
    ["mod+k", () => {
      console.log("פתיחת חיפוש");
    }],
    ["mod+shift+d", () => {
      console.log("מצב כהה");
    }],
    ["escape", () => {
      console.log("ביטול");
    }],
  ]);

  return (
    <Paper p="lg" shadow="sm" withBorder maw={400}>
      <Stack>
        <Text fw={500}>קיצורי מקלדת זמינים:</Text>
        <Group>
          <Kbd>Ctrl</Kbd> + <Kbd>S</Kbd>
          <Text size="sm">שמירה</Text>
        </Group>
        <Group>
          <Kbd>Ctrl</Kbd> + <Kbd>K</Kbd>
          <Text size="sm">חיפוש</Text>
        </Group>
        <Group>
          <Kbd>Ctrl</Kbd> + <Kbd>Shift</Kbd> + <Kbd>D</Kbd>
          <Text size="sm">מצב כהה</Text>
        </Group>
        <Group>
          <Kbd>Esc</Kbd>
          <Text size="sm">ביטול</Text>
        </Group>
      </Stack>
    </Paper>
  );
}
  • mod מתורגם ל-Ctrl בווינדוס ו-Cmd במק
  • ניתן להשתמש בכל שילוב מקשים

useDebouncedValue - ערך מושהה

import { useDebouncedValue } from "@mantine/hooks";
import { TextInput, Text, Stack, Paper } from "@mantine/core";
import { useState } from "react";

function SearchWithDebounce() {
  const [value, setValue] = useState("");
  const [debounced] = useDebouncedValue(value, 300);

  return (
    <Paper p="lg" shadow="sm" withBorder maw={400}>
      <Stack>
        <TextInput
          label="חיפוש"
          placeholder="הקלד לחיפוש..."
          value={value}
          onChange={(e) => setValue(e.currentTarget.value)}
        />

        <Text size="sm">
          ערך מיידי: <b>{value}</b>
        </Text>
        <Text size="sm">
          ערך מושהה (300ms): <b>{debounced}</b>
        </Text>
        <Text size="xs" c="dimmed">
          ה-API ייקרא רק כשהערך המושהה משתנה, ולא בכל הקלדה
        </Text>
      </Stack>
    </Paper>
  );
}
  • useDebouncedValue מחזיר את הערך רק אחרי שהמשתמש הפסיק להקליד
  • שימושי לחיפוש - מונע קריאות API מיותרות

useIntersection - זיהוי רכיב בתצוגה

import { useIntersection } from "@mantine/hooks";
import { Paper, Text, Stack } from "@mantine/core";

function IntersectionExample() {
  const { ref, entry } = useIntersection({
    root: null,
    threshold: 0.5,
  });

  const isVisible = entry?.isIntersecting || false;

  return (
    <Stack>
      <Text>גלול למטה כדי לראות את האלמנט</Text>

      <div style={{ height: "100vh" }} />

      <Paper
        ref={ref}
        p="xl"
        shadow="sm"
        withBorder
        bg={isVisible ? "green.0" : "gray.0"}
        style={{ transition: "background-color 0.3s" }}
      >
        <Text fw={500}>
          {isVisible ? "האלמנט נראה!" : "האלמנט מוסתר"}
        </Text>
      </Paper>

      <div style={{ height: "100vh" }} />
    </Stack>
  );
}
  • שימושי ל-lazy loading, אנימציות בגלילה, ואינסוף גלילה (infinite scroll)
  • threshold: 0.5 אומר שהאלמנט נחשב "נראה" כש-50% ממנו בתצוגה

useScrollIntoView - גלילה לרכיב

import { useScrollIntoView } from "@mantine/hooks";
import { Button, Paper, Text, Stack, Group } from "@mantine/core";

function ScrollToExample() {
  const { scrollIntoView, targetRef } = useScrollIntoView<HTMLDivElement>({
    offset: 60, // offset עבור header קבוע
    duration: 500,
  });

  const { scrollIntoView: scrollToTop, targetRef: topRef } =
    useScrollIntoView<HTMLDivElement>();

  return (
    <Stack>
      <div ref={topRef} />

      <Group>
        <Button onClick={() => scrollIntoView({ alignment: "center" })}>
          גלול למטה
        </Button>
      </Group>

      <div style={{ height: "100vh" }}>
        <Text c="dimmed" ta="center" mt="xl">
          גלול או לחץ על הכפתור
        </Text>
      </div>

      <Paper ref={targetRef} p="xl" shadow="md" withBorder bg="blue.0">
        <Text fw={500}>הגעתם לכאן!</Text>
        <Button
          mt="md"
          variant="light"
          onClick={() => scrollToTop({ alignment: "start" })}
        >
          חזרה למעלה
        </Button>
      </Paper>
    </Stack>
  );
}

הוקים נוספים שימושיים

useClickOutside - לחיצה מחוץ לרכיב

import { useClickOutside } from "@mantine/hooks";
import { Paper, Text } from "@mantine/core";
import { useState } from "react";

function ClickOutsideExample() {
  const [opened, setOpened] = useState(false);
  const ref = useClickOutside(() => setOpened(false));

  return (
    <>
      <button onClick={() => setOpened(true)}>פתח</button>
      {opened && (
        <Paper ref={ref} shadow="md" p="md" withBorder>
          <Text>לחץ מחוץ לאלמנט הזה לסגירה</Text>
        </Paper>
      )}
    </>
  );
}

useToggle - מעבר בין ערכים

import { useToggle } from "@mantine/hooks";
import { Button, Text } from "@mantine/core";

function ToggleExample() {
  const [value, toggle] = useToggle(["light", "dark"] as const);

  return (
    <>
      <Text>מצב נוכחי: {value}</Text>
      <Button onClick={() => toggle()}>החלף מצב</Button>
    </>
  );
}

useWindowScroll - מיקום גלילה

import { useWindowScroll } from "@mantine/hooks";
import { Button, Text, Group } from "@mantine/core";

function ScrollExample() {
  const [scroll, scrollTo] = useWindowScroll();

  return (
    <Group>
      <Text>
        גלילה: X={scroll.x}, Y={scroll.y}
      </Text>
      <Button onClick={() => scrollTo({ y: 0 })}>
        חזרה למעלה
      </Button>
    </Group>
  );
}

דוגמה מלאה - טופס יצירת קשר עם הוקים

import { useForm } from "@mantine/form";
import { useMediaQuery, useClipboard, useHotkeys, useDisclosure } from "@mantine/hooks";
import {
  Paper, Title, TextInput, Select, Textarea, Button, Stack, Group,
  Modal, Text, Tooltip,
} from "@mantine/core";

function ContactFormWithHooks() {
  const isMobile = useMediaQuery("(max-width: 768px)");
  const clipboard = useClipboard({ timeout: 2000 });
  const [submitted, submitHandlers] = useDisclosure(false);

  const form = useForm({
    mode: "uncontrolled",
    initialValues: {
      name: "",
      email: "",
      subject: "",
      message: "",
    },
    validate: {
      name: (v) => (v.trim().length < 2 ? "שם קצר מדי" : null),
      email: (v) => (/^\S+@\S+$/.test(v) ? null : "אימייל לא תקין"),
      subject: (v) => (v ? null : "בחר נושא"),
      message: (v) => (v.trim().length < 10 ? "ההודעה קצרה מדי" : null),
    },
    validateInputOnBlur: true,
  });

  // קיצור מקלדת לשליחה
  useHotkeys([
    ["mod+enter", () => {
      if (form.isValid()) {
        submitHandlers.open();
      }
    }],
  ]);

  function handleSubmit() {
    submitHandlers.open();
    form.reset();
  }

  const referenceId = "REF-" + Math.random().toString(36).substring(2, 8).toUpperCase();

  return (
    <>
      <Paper
        shadow="sm"
        p={isMobile ? "md" : "xl"}
        withBorder
        maw={isMobile ? "100%" : 500}
        mx="auto"
      >
        <Title order={isMobile ? 3 : 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")}
            />

            <Select
              label="נושא"
              placeholder="בחר נושא"
              data={["שאלה כללית", "תמיכה טכנית", "משוב", "אחר"]}
              required
              key={form.key("subject")}
              {...form.getInputProps("subject")}
            />

            <Textarea
              label="הודעה"
              placeholder="כתוב את ההודעה..."
              minRows={4}
              required
              key={form.key("message")}
              {...form.getInputProps("message")}
            />

            <Button type="submit" fullWidth>
              שלח
            </Button>
          </Stack>
        </form>
      </Paper>

      <Modal opened={submitted} onClose={submitHandlers.close} title="נשלח בהצלחה" centered>
        <Stack>
          <Text>ההודעה נשלחה בהצלחה. מספר הפניה שלך:</Text>
          <Group>
            <Text fw={700} size="lg">{referenceId}</Text>
            <Tooltip label={clipboard.copied ? "הועתק!" : "העתק"}>
              <Button
                size="xs"
                variant="light"
                color={clipboard.copied ? "green" : "blue"}
                onClick={() => clipboard.copy(referenceId)}
              >
                {clipboard.copied ? "הועתק" : "העתק"}
              </Button>
            </Tooltip>
          </Group>
          <Button onClick={submitHandlers.close} fullWidth mt="md">
            סגור
          </Button>
        </Stack>
      </Modal>
    </>
  );
}

סיכום

  • useDisclosure מנהל מצב פתוח/סגור בצורה נקייה, שימושי ל-Modal, Drawer וכל toggle
  • useForm מספק ניהול טפסים מלא עם ולידציה, getInputProps, ותמיכה ברשימות דינמיות
  • useMediaQuery מאפשר התאמה למסכים שונים בלוגיקת JavaScript
  • useClipboard מספק העתקה ללוח עם משוב למשתמש
  • useLocalStorage שומר ערכים ב-localStorage עם API פשוט של useState
  • useHotkeys מגדיר קיצורי מקלדת גלובליים
  • useDebouncedValue מונע קריאות מיותרות בחיפוש ושדות טקסט
  • useIntersection מזהה מתי רכיב נכנס לתצוגה
  • useScrollIntoView מאפשר גלילה חלקה לרכיב ספציפי
  • שילוב מספר הוקים יחד יוצר חוויית משתמש עשירה ומקצועית