לדלג לתוכן

8.8 ערכות נושא ומצב כהה הרצאה

ערכות נושא ומצב כהה - Theming and Dark Mode

בשיעור זה נלמד כיצד לבנות מערכת עיצוב גמישה עם ערכות נושא, ליישם מצב כהה עם Mantine, לשמור את העדפת המשתמש, וליצור ערכות צבעים מותאמות.


Design Tokens - אסימוני עיצוב

Design tokens הם הערכים הקטנים ביותר במערכת העיצוב - צבעים, מרווחים, פונטים, צללים וכו'. הם הבסיס לעקביות בעיצוב.

למה Design Tokens חשובים

  • עקביות - כל הקומפוננטות משתמשות באותם ערכים
  • תחזוקה - שינוי במקום אחד משפיע על כל האפליקציה
  • ערכות נושא - החלפת tokens מחליפה את כל העיצוב
  • תקשורת - שפה משותפת בין מעצבים ומפתחים

Tokens ב-Mantine

import { createTheme } from "@mantine/core";

const theme = createTheme({
  // צבעים
  colors: {
    brand: ["#f0f4ff", "#dce4f5", "#b4c6e7", "#8aa7db", "#668dd0", "#4e7dc9", "#4275c7", "#3363b0", "#29589e", "#1a4b8c"],
  },
  primaryColor: "brand",

  // טיפוגרפיה
  fontFamily: "Rubik, sans-serif",
  fontSizes: { xs: "0.75rem", sm: "0.875rem", md: "1rem", lg: "1.125rem", xl: "1.25rem" },

  // ריווח
  spacing: { xs: "0.5rem", sm: "0.75rem", md: "1rem", lg: "1.5rem", xl: "2rem" },

  // רדיוס
  radius: { xs: "2px", sm: "4px", md: "8px", lg: "16px", xl: "32px" },

  // צללים
  shadows: {
    xs: "0 1px 2px rgba(0, 0, 0, 0.05)",
    sm: "0 1px 3px rgba(0, 0, 0, 0.1)",
    md: "0 4px 6px rgba(0, 0, 0, 0.1)",
    lg: "0 10px 15px rgba(0, 0, 0, 0.1)",
    xl: "0 20px 25px rgba(0, 0, 0, 0.15)",
  },
});

מצב כהה עם Mantine

הגדרה בסיסית

import { MantineProvider } from "@mantine/core";

function App() {
  return (
    <MantineProvider defaultColorScheme="auto">
      {/* auto = עוקב אחרי הגדרות המערכת */}
      {/* light = תמיד בהיר */}
      {/* dark = תמיד כהה */}
    </MantineProvider>
  );
}

useMantineColorScheme - שליטה על מצב הצבע

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

function ColorSchemeToggle() {
  const { setColorScheme, colorScheme } = useMantineColorScheme();

  return (
    <Stack>
      <Text>מצב נוכחי: {colorScheme}</Text>

      <Group>
        <Button
          variant={colorScheme === "light" ? "filled" : "outline"}
          onClick={() => setColorScheme("light")}
        >
          בהיר
        </Button>
        <Button
          variant={colorScheme === "dark" ? "filled" : "outline"}
          onClick={() => setColorScheme("dark")}
        >
          כהה
        </Button>
        <Button
          variant={colorScheme === "auto" ? "filled" : "outline"}
          onClick={() => setColorScheme("auto")}
        >
          אוטומטי
        </Button>
      </Group>
    </Stack>
  );
}

useComputedColorScheme - המצב האמיתי

import { useComputedColorScheme, useMantineColorScheme, Switch } from "@mantine/core";

function ThemeSwitch() {
  const { setColorScheme } = useMantineColorScheme();
  const computedColorScheme = useComputedColorScheme("light");

  // computedColorScheme הוא תמיד "light" או "dark"
  // גם כש-colorScheme הוא "auto"

  return (
    <Switch
      label="מצב כהה"
      checked={computedColorScheme === "dark"}
      onChange={(e) =>
        setColorScheme(e.currentTarget.checked ? "dark" : "light")
      }
      size="lg"
      onLabel="כהה"
      offLabel="בהיר"
    />
  );
}
  • useMantineColorScheme מחזיר את הערך המוגדר (light/dark/auto)
  • useComputedColorScheme מחזיר את הערך בפועל (light/dark) - גם כש-auto
  • שימושי כשצריך לדעת מה באמת מוצג

שמירת העדפת המשתמש

Mantine שומרת אוטומטית את העדפת ערכת הנושא ב-localStorage. אפשר לשלוט בזה:

import { MantineProvider } from "@mantine/core";

function App() {
  return (
    <MantineProvider
      defaultColorScheme="auto"
      // Mantine שומרת את הבחירה ב-localStorage אוטומטית
      // המפתח ברירת מחדל הוא "mantine-color-scheme-value"
    >
      <TheApp />
    </MantineProvider>
  );
}

כפתור מעבר מלא עם שמירה

import {
  useMantineColorScheme,
  useComputedColorScheme,
  ActionIcon,
  Tooltip,
} from "@mantine/core";

function ThemeToggle() {
  const { setColorScheme } = useMantineColorScheme();
  const computedColorScheme = useComputedColorScheme("light");

  function toggleColorScheme() {
    setColorScheme(computedColorScheme === "dark" ? "light" : "dark");
  }

  return (
    <Tooltip label={computedColorScheme === "dark" ? "מצב בהיר" : "מצב כהה"}>
      <ActionIcon
        variant="default"
        size="lg"
        onClick={toggleColorScheme}
        aria-label="החלף ערכת צבעים"
      >
        {computedColorScheme === "dark" ? "S" : "M"}
      </ActionIcon>
    </Tooltip>
  );
}

יצירת ערכות צבעים מותאמות

צבעים שונים לבהיר וכהה

import { createTheme, CSSVariablesResolver, MantineProvider } from "@mantine/core";

const theme = createTheme({
  primaryColor: "brand",
  colors: {
    brand: [
      "#eef3ff", "#dee2f2", "#bcc3d8", "#97a3be", "#7888a8",
      "#63779d", "#577198", "#466085", "#3b5478", "#2c466b",
    ],
  },
  primaryShade: { light: 6, dark: 7 },
});

const resolver: CSSVariablesResolver = (theme) => ({
  variables: {},
  light: {
    "--app-bg": "#f8f9fa",
    "--card-bg": "#ffffff",
    "--text-primary": "#212529",
    "--text-secondary": "#6c757d",
    "--border-color": "#dee2e6",
    "--hover-bg": "#f1f3f5",
    "--sidebar-bg": "#ffffff",
    "--header-bg": "#ffffff",
    "--success-bg": "#d3f9d8",
    "--error-bg": "#ffe3e3",
  },
  dark: {
    "--app-bg": "#1a1b1e",
    "--card-bg": "#25262b",
    "--text-primary": "#c1c2c5",
    "--text-secondary": "#909296",
    "--border-color": "#373a40",
    "--hover-bg": "#2c2e33",
    "--sidebar-bg": "#1a1b1e",
    "--header-bg": "#1a1b1e",
    "--success-bg": "#2b3b2d",
    "--error-bg": "#3b2b2b",
  },
});

function App() {
  return (
    <MantineProvider
      theme={theme}
      defaultColorScheme="auto"
      cssVariablesResolver={resolver}
    >
      <TheApp />
    </MantineProvider>
  );
}

שימוש במשתנים ב-CSS

/* Dashboard.module.css */
.page {
  min-height: 100vh;
  background-color: var(--app-bg);
}

.card {
  background-color: var(--card-bg);
  border: 1px solid var(--border-color);
  border-radius: var(--mantine-radius-md);
  padding: var(--mantine-spacing-lg);
  transition: background-color 0.2s, border-color 0.2s;
}

.card:hover {
  background-color: var(--hover-bg);
}

.sidebar {
  background-color: var(--sidebar-bg);
  border-left: 1px solid var(--border-color);
}

.textPrimary {
  color: var(--text-primary);
}

.textSecondary {
  color: var(--text-secondary);
}

שימוש ב-data attribute לצבעי מצב

/* אלטרנטיבה ללא CSSVariablesResolver */
.card {
  background-color: white;
  color: #333;
}

[data-mantine-color-scheme="dark"] .card {
  background-color: #25262b;
  color: #c1c2c5;
}

גישת CSS Variables לערכות נושא

ניתן ליצור מערכת ערכות נושא מלאה עם CSS Variables:

import { createTheme, MantineProvider, CSSVariablesResolver } from "@mantine/core";

type ThemeName = "default" | "ocean" | "forest" | "sunset";

const themeConfigs: Record<ThemeName, {
  primary: string;
  light: Record<string, string>;
  dark: Record<string, string>;
}> = {
  default: {
    primary: "blue",
    light: {
      "--accent": "#228be6",
      "--accent-light": "#e7f5ff",
      "--surface": "#ffffff",
    },
    dark: {
      "--accent": "#4dabf7",
      "--accent-light": "#1c3a5c",
      "--surface": "#25262b",
    },
  },
  ocean: {
    primary: "cyan",
    light: {
      "--accent": "#15aabf",
      "--accent-light": "#e3fafc",
      "--surface": "#f0fffe",
    },
    dark: {
      "--accent": "#3bc9db",
      "--accent-light": "#1a3a3e",
      "--surface": "#1a2e30",
    },
  },
  forest: {
    primary: "green",
    light: {
      "--accent": "#40c057",
      "--accent-light": "#ebfbee",
      "--surface": "#f0fff0",
    },
    dark: {
      "--accent": "#51cf66",
      "--accent-light": "#1a3b1e",
      "--surface": "#1a2e1a",
    },
  },
  sunset: {
    primary: "orange",
    light: {
      "--accent": "#fd7e14",
      "--accent-light": "#fff4e6",
      "--surface": "#fffaf0",
    },
    dark: {
      "--accent": "#ffa94d",
      "--accent-light": "#3b2e1a",
      "--surface": "#2e251a",
    },
  },
};

function useThemeConfig(themeName: ThemeName) {
  const config = themeConfigs[themeName];

  const theme = createTheme({
    primaryColor: config.primary,
  });

  const resolver: CSSVariablesResolver = () => ({
    variables: {},
    light: config.light,
    dark: config.dark,
  });

  return { theme, resolver };
}
import { useState } from "react";
import {
  MantineProvider,
  Button,
  Group,
  Paper,
  Title,
  Text,
  Stack,
  Container,
  Select,
} from "@mantine/core";

function ThemeSwitcherApp() {
  const [themeName, setThemeName] = useState<ThemeName>("default");
  const { theme, resolver } = useThemeConfig(themeName);

  return (
    <MantineProvider
      theme={theme}
      defaultColorScheme="auto"
      cssVariablesResolver={resolver}
    >
      <Container size="sm" py="xl">
        <Stack gap="lg">
          <Title order={2}>בחירת ערכת נושא</Title>

          <Select
            label="ערכת נושא"
            value={themeName}
            onChange={(val) => setThemeName((val as ThemeName) || "default")}
            data={[
              { value: "default", label: "ברירת מחדל (כחול)" },
              { value: "ocean", label: "אוקיינוס (תכלת)" },
              { value: "forest", label: "יער (ירוק)" },
              { value: "sunset", label: "שקיעה (כתום)" },
            ]}
          />

          <Group>
            <Button>כפתור ראשי</Button>
            <Button variant="light">כפתור בהיר</Button>
            <Button variant="outline">כפתור מתאר</Button>
          </Group>

          <Paper
            p="lg"
            withBorder
            style={{ backgroundColor: "var(--accent-light)" }}
          >
            <Text fw={500} style={{ color: "var(--accent)" }}>
              כרטיס עם צבעי ערכת הנושא
            </Text>
            <Text size="sm" c="dimmed" mt="xs">
              הצבעים משתנים אוטומטית כשמחליפים ערכת נושא
            </Text>
          </Paper>
        </Stack>
      </Container>
    </MantineProvider>
  );
}

דפוסי עיצוב רספונסיביים

התאמת theme לגודל מסך

import { useMediaQuery, useComputedColorScheme } from "@mantine/hooks";
import { Paper, Text, Stack, Group, Button, Container, Title } from "@mantine/core";

function ResponsiveThemedPage() {
  const isMobile = useMediaQuery("(max-width: 768px)");
  const colorScheme = useComputedColorScheme("light");

  return (
    <Container size="md" py={isMobile ? "md" : "xl"}>
      <Stack gap={isMobile ? "md" : "xl"}>
        <Title order={isMobile ? 3 : 1}>
          ממשק רספונסיבי ותמטי
        </Title>

        <Text size={isMobile ? "sm" : "lg"} c="dimmed">
          הממשק מתאים את עצמו לגודל המסך ולערכת הצבעים
        </Text>

        <Paper
          p={isMobile ? "md" : "xl"}
          shadow={isMobile ? "xs" : "md"}
          withBorder
        >
          <Stack gap="md">
            <Text>
              מצב: {colorScheme === "dark" ? "כהה" : "בהיר"}
              {" | "}
              מכשיר: {isMobile ? "טלפון" : "דסקטופ"}
            </Text>

            <Group
              justify={isMobile ? "center" : "flex-start"}
              gap={isMobile ? "xs" : "md"}
            >
              <Button size={isMobile ? "sm" : "md"}>
                פעולה
              </Button>
              <Button
                variant="outline"
                size={isMobile ? "sm" : "md"}
              >
                ביטול
              </Button>
            </Group>
          </Stack>
        </Paper>
      </Stack>
    </Container>
  );
}

CSS רספונסיבי עם ערכות נושא

/* ResponsiveThemed.module.css */
.hero {
  padding: var(--mantine-spacing-xl) var(--mantine-spacing-md);
  text-align: center;
  background-color: var(--mantine-primary-color-light);
  border-radius: var(--mantine-radius-lg);
}

@media (min-width: 48em) {
  .hero {
    padding: calc(var(--mantine-spacing-xl) * 3) var(--mantine-spacing-xl);
  }
}

.statsGrid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--mantine-spacing-md);
}

@media (min-width: 48em) {
  .statsGrid {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (min-width: 62em) {
  .statsGrid {
    grid-template-columns: repeat(4, 1fr);
  }
}

.statCard {
  padding: var(--mantine-spacing-lg);
  background-color: var(--card-bg);
  border: 1px solid var(--border-color);
  border-radius: var(--mantine-radius-md);
  transition: transform 0.2s, box-shadow 0.2s;
}

.statCard:hover {
  transform: translateY(-2px);
  box-shadow: var(--mantine-shadow-md);
}

דוגמה מלאה - דשבורד עם מצב כהה

import {
  AppShell,
  Burger,
  Group,
  NavLink,
  Title,
  Text,
  Button,
  SimpleGrid,
  Paper,
  Container,
  Stack,
  Switch,
  useMantineColorScheme,
  useComputedColorScheme,
  ActionIcon,
  Tooltip,
  Divider,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";

function ThemedDashboard() {
  const [opened, { toggle }] = useDisclosure();
  const { setColorScheme } = useMantineColorScheme();
  const computedColorScheme = useComputedColorScheme("light");

  const stats = [
    { title: "משתמשים", value: "1,234", change: "+12%", positive: true },
    { title: "הזמנות", value: "567", change: "+8%", positive: true },
    { title: "הכנסות", value: "45K", change: "+23%", positive: true },
    { title: "החזרות", value: "12", change: "-5%", positive: false },
  ];

  return (
    <AppShell
      header={{ height: 60 }}
      navbar={{ width: 250, breakpoint: "sm", collapsed: { mobile: !opened } }}
      padding="md"
    >
      <AppShell.Header>
        <Group h="100%" px="md" justify="space-between">
          <Group>
            <Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
            <Title order={3}>דשבורד</Title>
          </Group>
          <Group>
            <Tooltip label={computedColorScheme === "dark" ? "מצב בהיר" : "מצב כהה"}>
              <ActionIcon
                variant="default"
                size="lg"
                onClick={() =>
                  setColorScheme(
                    computedColorScheme === "dark" ? "light" : "dark"
                  )
                }
              >
                {computedColorScheme === "dark" ? "S" : "M"}
              </ActionIcon>
            </Tooltip>
          </Group>
        </Group>
      </AppShell.Header>

      <AppShell.Navbar p="md">
        <Stack gap="xs">
          <NavLink label="דשבורד" active />
          <NavLink label="משתמשים" />
          <NavLink label="הזמנות" />
          <NavLink label="מוצרים" />
          <Divider my="sm" />
          <NavLink label="הגדרות" />

          <div style={{ marginTop: "auto" }}>
            <Switch
              label="מצב כהה"
              checked={computedColorScheme === "dark"}
              onChange={(e) =>
                setColorScheme(e.currentTarget.checked ? "dark" : "light")
              }
            />
          </div>
        </Stack>
      </AppShell.Navbar>

      <AppShell.Main>
        <Container fluid>
          <Title order={2} mb="lg">סקירה כללית</Title>

          <SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} mb="xl">
            {stats.map((stat) => (
              <Paper key={stat.title} shadow="xs" p="md" withBorder>
                <Text size="sm" c="dimmed" tt="uppercase" fw={500}>
                  {stat.title}
                </Text>
                <Text size="xl" fw={700} mt="xs">
                  {stat.value}
                </Text>
                <Text size="sm" c={stat.positive ? "green" : "red"} mt="xs">
                  {stat.change}
                </Text>
              </Paper>
            ))}
          </SimpleGrid>

          <SimpleGrid cols={{ base: 1, md: 2 }}>
            <Paper shadow="xs" p="lg" withBorder>
              <Title order={4} mb="md">פעילות אחרונה</Title>
              <Stack gap="sm">
                {["משתמש חדש נרשם", "הזמנה הושלמה", "תשלום התקבל"].map(
                  (activity) => (
                    <Text key={activity} size="sm">{activity}</Text>
                  )
                )}
              </Stack>
            </Paper>

            <Paper shadow="xs" p="lg" withBorder>
              <Title order={4} mb="md">פעולות מהירות</Title>
              <Stack gap="sm">
                <Button variant="light" fullWidth>הוסף משתמש</Button>
                <Button variant="light" fullWidth>צור הזמנה</Button>
                <Button variant="light" fullWidth>הפק דוח</Button>
              </Stack>
            </Paper>
          </SimpleGrid>
        </Container>
      </AppShell.Main>
    </AppShell>
  );
}
  • כפתור החלפת מצב ב-header
  • Switch נוסף בתחתית הסיידבר
  • Mantine מטפלת אוטומטית בכל הצבעים - Paper, Text, Button וכו'
  • ההעדפה נשמרת אוטומטית ב-localStorage

סיכום

  • Design tokens הם הבסיס למערכת עיצוב עקבית ותחזוקתית
  • Mantine תומכת במצב כהה מחוץ לקופסה עם defaultColorScheme
  • useMantineColorScheme ו-setColorScheme שולטים על המצב
  • useComputedColorScheme מחזיר את המצב האמיתי (light/dark) גם כש-auto
  • Mantine שומרת את העדפת המשתמש ב-localStorage אוטומטית
  • CSSVariablesResolver מאפשר הגדרת משתנים שונים למצב בהיר וכהה
  • ניתן ליצור מערכת ערכות נושא מלאה עם CSS Variables ו-createTheme
  • data-mantine-color-scheme attribute מאפשר CSS מותנה למצב
  • שילוב עיצוב רספונסיבי עם ערכות נושא דרך media queries ו-system props