This commit is contained in:
bipproduction
2026-04-01 10:43:03 +08:00
parent 816db7568c
commit 39d659acd0
175 changed files with 21765 additions and 0 deletions

View File

@@ -0,0 +1,258 @@
# Components Reference
`@mantine/core` provides 120+ components. This reference covers key patterns.
## Layout Components
### Container, Stack, Group, Flex
```tsx
import { Container, Stack, Group, Flex } from '@mantine/core';
<Container size="md">{/* Centers content, max-width */}</Container>
<Stack gap="md">{/* Vertical flex */}</Stack>
<Group gap="sm" justify="space-between">{/* Horizontal flex */}</Group>
<Flex direction="column" gap="md" align="center">{/* Generic flex */}</Flex>
```
### Grid & SimpleGrid
```tsx
import { Grid, SimpleGrid } from '@mantine/core';
// CSS Grid with responsive spans
<Grid>
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }}>Responsive</Grid.Col>
</Grid>
// Equal-width columns
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>{/* Items */}</SimpleGrid>
```
## Button Variants
```tsx
import { Button, ActionIcon } from '@mantine/core';
<Button variant="filled">Default</Button>
<Button variant="outline">Outline</Button>
<Button variant="light">Light</Button>
<Button variant="subtle">Subtle</Button>
<Button variant="white">White</Button>
<Button loading>Loading state</Button>
<Button leftSection={<IconPlus />}>With Icon</Button>
// Icon button
<ActionIcon variant="filled" color="blue"><IconSettings /></ActionIcon>
```
## Inputs Pattern
All inputs follow consistent API:
```tsx
import { TextInput, PasswordInput, Textarea, NumberInput, Select } from '@mantine/core';
// Common props: label, description, error, required, placeholder
<TextInput
label="Email"
description="We won't share it"
error="Invalid email"
required
withAsterisk
/>
<Select
label="Country"
data={['USA', 'Canada']}
searchable
clearable
/>
// Objects with value/label
<Select data={[{ value: 'us', label: 'United States' }]} />
```
## Overlays Pattern
Modals, Drawers, Menus, Popovers all use similar pattern:
```tsx
import { Modal, Drawer, Menu, Popover } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
// Common pattern with useDisclosure
const [opened, { open, close }] = useDisclosure(false);
// Modal
<Modal opened={opened} onClose={close} title="Title">Content</Modal>
// Drawer
<Drawer opened={opened} onClose={close} position="left">Navigation</Drawer>
// Menu (dropdown)
<Menu>
<Menu.Target><Button>Toggle</Button></Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconSettings />}>Settings</Menu.Item>
<Menu.Divider />
<Menu.Item color="red">Delete</Menu.Item>
</Menu.Dropdown>
</Menu>
// Popover
<Popover width={200} withArrow>
<Popover.Target><Button>Info</Button></Popover.Target>
<Popover.Dropdown>Details here</Popover.Dropdown>
</Popover>
```
## Feedback Components
```tsx
import { Loader, Alert, Notification, Progress, Skeleton } from '@mantine/core';
<Loader type="bars" /> // oval, bars, dots
<Alert variant="light" color="blue" title="Info">Message</Alert>
<Notification title="Success" color="green" icon={<IconCheck />}>
Saved!
</Notification>
<Progress value={65} />
<Progress.Root size="xl">
<Progress.Section value={35} color="cyan"><Progress.Label>Docs</Progress.Label></Progress.Section>
</Progress.Root>
// Loading placeholders
<Skeleton height={50} circle />
<Skeleton height={8} radius="xl" />
```
## Typography
```tsx
import { Title, Text, Anchor, Highlight, Code } from '@mantine/core';
<Title order={1}>h1 heading</Title>
<Title order={2} c="dimmed">h2 dimmed</Title>
<Text size="sm" c="dimmed" fw={700}>Small bold dimmed</Text>
<Text truncate>Long text...</Text>
<Text lineClamp={2}>Multi-line truncate...</Text>
<Highlight highlight={['react', 'mantine']}>
Learn React with Mantine
</Highlight>
<Code>inline</Code>
<Code block>{`const x = 1;`}</Code>
```
## Data Display
```tsx
import { Badge, Card, Table, Avatar, Image, Tabs, Accordion } from '@mantine/core';
// Badge variants
<Badge>Default</Badge>
<Badge variant="dot" color="red">Dot</Badge>
// Card with sections
<Card shadow="sm" padding="lg" withBorder>
<Card.Section><Image src="/img.jpg" height={160} /></Card.Section>
<Text>Content</Text>
</Card>
// Table
<Table striped highlightOnHover withTableBorder>
<Table.Thead><Table.Tr><Table.Th>Name</Table.Th></Table.Tr></Table.Thead>
<Table.Tbody><Table.Tr><Table.Td>John</Table.Td></Table.Tr></Table.Tbody>
</Table>
// Tabs
<Tabs defaultValue="tab1">
<Tabs.List>
<Tabs.Tab value="tab1">First</Tabs.Tab>
<Tabs.Tab value="tab2">Second</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="tab1">Content 1</Tabs.Panel>
</Tabs>
// Accordion
<Accordion defaultValue="item-1">
<Accordion.Item value="item-1">
<Accordion.Control>Section 1</Accordion.Control>
<Accordion.Panel>Content</Accordion.Panel>
</Accordion.Item>
</Accordion>
```
## Navigation
```tsx
import { NavLink, Pagination, Stepper, Breadcrumbs } from '@mantine/core';
<NavLink href="#" label="Dashboard" leftSection={<IconHome />} active />
<NavLink label="Settings">
<NavLink label="General" />
<NavLink label="Security" />
</NavLink>
<Pagination total={10} value={page} onChange={setPage} />
<Stepper active={active}>
<Stepper.Step label="Step 1">Content 1</Stepper.Step>
<Stepper.Step label="Step 2">Content 2</Stepper.Step>
<Stepper.Completed>Done!</Stepper.Completed>
</Stepper>
```
## Common Style Props
All components accept these props:
```tsx
<Component
// Margin & Padding
m="md" mt="xs" p="sm" px="md"
// Colors
c="dimmed" bg="blue.1"
// Typography
fw={500} fz="sm"
// Dimensions
w={200} h="100%" maw={500}
// Responsive
p={{ base: 'xs', sm: 'md', lg: 'xl' }}
/>
```
## Polymorphic Components
Render as different elements:
```tsx
import { Button } from '@mantine/core';
import { Link } from 'react-router-dom';
<Button component={Link} to="/about">Link Button</Button>
<Button component="a" href="https://example.com">Anchor Button</Button>
```
## Visibility Props
```tsx
<Text hiddenFrom="sm">Hidden on sm+</Text>
<Text visibleFrom="md">Visible on md+</Text>
<Text lightHidden>Only in dark mode</Text>
<Text darkHidden>Only in light mode</Text>
```

View File

@@ -0,0 +1,269 @@
# ESLint Configuration Reference
`eslint-config-mantine` provides ESLint rules and configurations used in Mantine projects.
## Installation
```bash
npm install -D @eslint/js eslint eslint-plugin-jsx-a11y eslint-plugin-react typescript-eslint eslint-config-mantine
```
## Configuration
Create `eslint.config.js` (ESLint flat config):
```js
import mantine from 'eslint-config-mantine';
import tseslint from 'typescript-eslint';
export default [
...tseslint.configs.recommended,
...mantine,
{
ignores: ['**/*.{mjs,cjs,js,d.ts,d.mts}'],
},
{
files: ['**/*.story.tsx'],
rules: {
'no-console': 'off',
},
},
{
languageOptions: {
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: process.cwd(),
},
},
},
];
```
## What's Included
eslint-config-mantine includes:
### TypeScript Rules
- `typescript-eslint/recommended` base rules
- Strict type checking
- No unused variables (except with `_` prefix)
- No explicit `any` (warning)
### React Rules
- React hooks rules (exhaustive-deps, rules-of-hooks)
- JSX-specific rules
- No unknown properties
- Self-closing components
### Accessibility (a11y)
- `eslint-plugin-jsx-a11y` rules
- Alt text requirements
- ARIA attribute validation
- Interactive element handling
- Focus management rules
### Import/Export
- Import order organization
- No duplicate imports
- No unresolved imports
### General
- No console.log (warning)
- Consistent code style
- No debugger
## Script Setup
Add to `package.json`:
```json
{
"scripts": {
"lint": "npm run eslint && npm run stylelint",
"eslint": "eslint . --cache",
"eslint:fix": "eslint . --cache --fix"
}
}
```
## Common Configuration Adjustments
### Allow console in specific files
```js
export default [
...mantine,
{
files: ['**/*.test.tsx', '**/*.story.tsx'],
rules: {
'no-console': 'off',
},
},
];
```
### Disable specific rules
```js
export default [
...mantine,
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'react/jsx-no-target-blank': 'off',
},
},
];
```
### Add custom rules
```js
export default [
...mantine,
{
rules: {
'prefer-const': 'error',
'no-var': 'error',
'object-shorthand': 'error',
},
},
];
```
### Configure for monorepo
```js
export default [
...mantine,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json', './packages/*/tsconfig.json'],
tsconfigRootDir: process.cwd(),
},
},
},
];
```
## Integration with Prettier
If using Prettier, add `eslint-config-prettier`:
```bash
npm install -D eslint-config-prettier
```
```js
import mantine from 'eslint-config-mantine';
import prettier from 'eslint-config-prettier';
export default [
...mantine,
prettier, // Must be last to override conflicting rules
];
```
## VS Code Integration
Install ESLint extension, then add to `.vscode/settings.json`:
```json
{
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
```
## Stylelint (Optional)
For CSS linting, the Mantine Vite template also includes Stylelint:
```bash
npm install -D stylelint stylelint-config-standard-scss
```
Create `stylelint.config.js`:
```js
export default {
extends: ['stylelint-config-standard-scss'],
rules: {
'selector-class-pattern': null,
},
};
```
Add to scripts:
```json
{
"scripts": {
"stylelint": "stylelint '**/*.css' --cache"
}
}
```
## Common ESLint Errors & Fixes
### React Hook Dependency Warning
```tsx
// Warning: React Hook useEffect has missing dependency
useEffect(() => {
fetchData(id);
}, []); // Missing 'id' and 'fetchData'
// Fix: Add dependencies
useEffect(() => {
fetchData(id);
}, [id, fetchData]);
// Or use useCallback for functions
const fetchData = useCallback((id) => { /* ... */ }, []);
```
### Unused Variable
```tsx
// Error: 'x' is defined but never used
const x = 5;
// Fix: Prefix with underscore if intentionally unused
const _x = 5;
```
### Missing Key Prop
```tsx
// Error: Missing "key" prop
items.map((item) => <Item>{item.name}</Item>);
// Fix: Add unique key
items.map((item) => <Item key={item.id}>{item.name}</Item>);
```
### Accessibility Issues
```tsx
// Error: img elements must have an alt prop
<img src="photo.jpg" />
// Fix: Add alt text
<img src="photo.jpg" alt="Description" />
// Decorative image
<img src="decoration.jpg" alt="" role="presentation" />
```
## Template Configuration
The [Mantine Vite template](https://github.com/mantinedev/vite-template) includes a complete ESLint + Prettier + Stylelint setup that you can use as reference.

View File

@@ -0,0 +1,496 @@
# Forms Reference
`@mantine/form` provides `useForm` hook for managing form state, validation, and submission.
## Installation
```bash
npm install @mantine/form
```
No styles needed — works with or without `@mantine/core`.
## Basic Usage
```tsx
import { useForm } from '@mantine/form';
import { TextInput, Button, Box } from '@mantine/core';
interface FormValues {
email: string;
name: string;
}
function Demo() {
const form = useForm<FormValues>({
mode: 'uncontrolled', // Recommended for performance
initialValues: {
email: '',
name: '',
},
validate: {
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
name: (value) => (value.length < 2 ? 'Name too short' : null),
},
});
return (
<Box component="form" onSubmit={form.onSubmit((values) => console.log(values))}>
<TextInput
label="Name"
placeholder="Your name"
key={form.key('name')}
{...form.getInputProps('name')}
/>
<TextInput
label="Email"
placeholder="your@email.com"
key={form.key('email')}
{...form.getInputProps('email')}
/>
<Button type="submit" mt="md">Submit</Button>
</Box>
);
}
```
## useForm Options
```tsx
interface UseFormInput<Values> {
mode?: 'controlled' | 'uncontrolled'; // Default: 'controlled'
initialValues?: Values;
initialErrors?: FormErrors;
initialDirty?: Record<string, boolean>;
initialTouched?: Record<string, boolean>;
validate?: FormValidation<Values>;
validateInputOnChange?: boolean | string[];
validateInputOnBlur?: boolean | string[];
clearInputErrorOnChange?: boolean;
onValuesChange?: (values: Values, previous: Values) => void;
onSubmitPreventDefault?: 'always' | 'never' | 'validation-failed';
}
```
## Controlled vs Uncontrolled Mode
### Uncontrolled (Recommended)
Better performance — values stored in DOM:
```tsx
const form = useForm({
mode: 'uncontrolled', // Add mode
initialValues: { name: '' },
});
<TextInput
key={form.key('name')} // Required for uncontrolled
{...form.getInputProps('name')}
/>
```
### Controlled
Values stored in React state — re-renders on every change:
```tsx
const form = useForm({
mode: 'controlled',
initialValues: { name: '' },
});
<TextInput {...form.getInputProps('name')} />
```
## Form Values
```tsx
// Get all values
const values = form.getValues();
// Set single field
form.setFieldValue('email', 'new@email.com');
// Set multiple values
form.setValues({ name: 'John', email: 'john@email.com' });
// Set values from previous state
form.setValues((prev) => ({ ...prev, name: 'Updated' }));
// Reset to initialValues
form.reset();
// Reset single field
form.resetField('email');
// Update initialValues (affects reset)
form.setInitialValues({ name: 'New Initial' });
```
## Validation
### Inline Rules
```tsx
const form = useForm({
mode: 'uncontrolled',
initialValues: {
email: '',
age: 0,
password: '',
confirmPassword: '',
},
validate: {
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
age: (value) => (value < 18 ? 'Must be 18+' : null),
// Access all values for cross-field validation
confirmPassword: (value, values) =>
value !== values.password ? 'Passwords do not match' : null,
},
});
```
### Function-based Validation
```tsx
const form = useForm({
mode: 'uncontrolled',
initialValues: { name: '', email: '' },
validate: (values) => ({
name: values.name.length < 2 ? 'Name too short' : null,
email: !values.email.includes('@') ? 'Invalid email' : null,
}),
});
```
### Schema Validation (Zod, Yup, Joi)
```tsx
import { zodResolver } from 'mantine-form-zod-resolver';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(2, 'Name must have at least 2 characters'),
email: z.string().email('Invalid email'),
age: z.number().min(18, 'Must be 18 or older'),
});
const form = useForm({
mode: 'uncontrolled',
initialValues: { name: '', email: '', age: 0 },
validate: zodResolver(schema),
});
```
Install resolver:
```bash
npm install mantine-form-zod-resolver zod
# or
npm install mantine-form-yup-resolver yup
# or
npm install mantine-form-joi-resolver joi
```
### Validation Timing
```tsx
const form = useForm({
mode: 'uncontrolled',
validateInputOnChange: true, // Validate all on change
validateInputOnBlur: true, // Validate all on blur
clearInputErrorOnChange: true, // Clear error when value changes (default)
});
// Validate specific fields only
const form = useForm({
mode: 'uncontrolled',
validateInputOnChange: ['email', 'password'],
validateInputOnBlur: ['email'],
});
```
### Manual Validation
```tsx
// Validate all fields
const result = form.validate();
// result.hasErrors: boolean
// result.errors: FormErrors
// Validate single field
form.validateField('email');
// Check if valid (without setting errors)
const isValid = form.isValid();
const isEmailValid = form.isValid('email');
```
## Errors
```tsx
// Get current errors
form.errors; // { email: 'Invalid', name: null }
// Set error
form.setFieldError('email', 'This email is taken');
// Set multiple errors
form.setErrors({ email: 'Invalid', name: 'Required' });
// Clear all errors
form.clearErrors();
// Clear single field error
form.clearFieldError('email');
```
## Form Submission
```tsx
<form
onSubmit={form.onSubmit(
// Success handler - called when validation passes
(values, event) => {
console.log('Valid:', values);
// Submit to API
},
// Error handler - called when validation fails
(errors, values, event) => {
console.log('Errors:', errors);
// Show notification, focus first error, etc.
}
)}
>
{/* inputs */}
</form>
```
## Nested Objects
```tsx
const form = useForm({
mode: 'uncontrolled',
initialValues: {
user: {
firstName: '',
lastName: '',
address: {
city: '',
country: '',
},
},
},
validate: {
user: {
firstName: (value) => (value.length < 2 ? 'Too short' : null),
address: {
city: (value) => (!value ? 'Required' : null),
},
},
},
});
<TextInput
key={form.key('user.firstName')}
{...form.getInputProps('user.firstName')}
/>
<TextInput
key={form.key('user.address.city')}
{...form.getInputProps('user.address.city')}
/>
```
## List Fields
```tsx
const form = useForm({
mode: 'uncontrolled',
initialValues: {
employees: [
{ name: '', email: '' },
],
},
});
// Add item
form.insertListItem('employees', { name: '', email: '' });
// Add at specific index
form.insertListItem('employees', { name: '', email: '' }, 0);
// Remove item
form.removeListItem('employees', 1);
// Replace item
form.replaceListItem('employees', 0, { name: 'New', email: 'new@email.com' });
// Reorder items
form.reorderListItem('employees', { from: 0, to: 2 });
```
### Rendering List
```tsx
import { FORM_INDEX } from '@mantine/form';
function Demo() {
const fields = form.getValues().employees.map((item, index) => (
<Group key={item.key}>
<TextInput
key={form.key(`employees.${index}.name`)}
{...form.getInputProps(`employees.${index}.name`)}
/>
<TextInput
key={form.key(`employees.${index}.email`)}
{...form.getInputProps(`employees.${index}.email`)}
/>
<ActionIcon onClick={() => form.removeListItem('employees', index)}>
<IconTrash />
</ActionIcon>
</Group>
));
return (
<Box>
{fields}
<Button onClick={() => form.insertListItem('employees', { name: '', email: '' })}>
Add Employee
</Button>
</Box>
);
}
// Validation for list items with FORM_INDEX
const form = useForm({
mode: 'uncontrolled',
validateInputOnChange: [`employees.${FORM_INDEX}.name`],
});
```
## Touched & Dirty State
```tsx
// Check if any field was interacted with
form.isTouched();
form.isTouched('email');
// Check if values differ from initialValues
form.isDirty();
form.isDirty('email');
// Set touched state
form.setTouched({ email: true, name: false });
form.resetTouched();
// Set dirty state
form.setDirty({ email: true });
form.resetDirty(); // Snapshot current values as "clean"
```
## Form Context
Share form across components without prop drilling:
```tsx
// formContext.ts
import { createFormContext } from '@mantine/form';
interface FormValues {
name: string;
email: string;
}
export const [FormProvider, useFormContext, useForm] = createFormContext<FormValues>();
```
```tsx
// Parent component
import { FormProvider, useForm } from './formContext';
function Parent() {
const form = useForm({
mode: 'uncontrolled',
initialValues: { name: '', email: '' },
});
return (
<FormProvider form={form}>
<NameInput />
<EmailInput />
</FormProvider>
);
}
// Child component
import { useFormContext } from './formContext';
function NameInput() {
const form = useFormContext();
return (
<TextInput
key={form.key('name')}
{...form.getInputProps('name')}
/>
);
}
```
## UseFormReturnType
Type for passing form as prop:
```tsx
import { UseFormReturnType } from '@mantine/form';
interface Props {
form: UseFormReturnType<{ name: string; email: string }>;
}
function NameField({ form }: Props) {
return (
<TextInput
key={form.key('name')}
{...form.getInputProps('name')}
/>
);
}
```
## Built-in Validators
```tsx
import { isNotEmpty, isEmail, hasLength, matches, isInRange } from '@mantine/form';
const form = useForm({
mode: 'uncontrolled',
initialValues: {
name: '',
email: '',
age: 0,
website: '',
terms: false,
},
validate: {
name: isNotEmpty('Name is required'),
email: isEmail('Invalid email'),
age: isInRange({ min: 18, max: 99 }, 'Age must be 18-99'),
website: matches(/^https?:\/\//, 'Must start with http'),
terms: isNotEmpty('Must accept terms'),
},
});
```
## Focus First Error
```tsx
<form
onSubmit={form.onSubmit(
(values) => { /* success */ },
(errors) => {
const firstErrorPath = Object.keys(errors)[0];
form.getInputNode(firstErrorPath)?.focus();
}
)}
>
```

View File

@@ -0,0 +1,215 @@
# Getting Started Reference
## Vite Template (Recommended)
The fastest way to start — official template includes everything:
```bash
# Clone template
git clone https://github.com/mantinedev/vite-template my-app
cd my-app
# Install dependencies
yarn install # or npm install
# Start development
yarn dev
```
### Template Features
- PostCSS with `postcss-preset-mantine`
- TypeScript configured
- Storybook setup
- Vitest with React Testing Library
- ESLint with `eslint-config-mantine`
- Prettier configured
## Manual Setup
### 1. Create Vite Project
```bash
npm create vite@latest my-app -- --template react-ts
cd my-app
```
### 2. Install Packages
```bash
# Core (required)
npm install @mantine/core @mantine/hooks
# PostCSS (required for responsive styles)
npm install -D postcss postcss-preset-mantine postcss-simple-vars
# Optional packages
npm install @mantine/form # Forms with validation
npm install @mantine/dates dayjs # Date/time components
npm install @mantine/notifications # Toast notifications
npm install @mantine/modals # Modal manager
npm install @mantine/charts recharts # Charts
npm install @mantine/dropzone # File upload
npm install @mantine/spotlight # Command palette (Cmd+K)
npm install @mantine/code-highlight # Code syntax highlighting
npm install @mantine/carousel embla-carousel-react # Carousel
npm install @mantine/tiptap @tiptap/react @tiptap/pm @tiptap/starter-kit # Rich text editor
```
### 3. Configure PostCSS
Create `postcss.config.cjs`:
```js
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};
```
### 4. Import Styles
In your entry file (e.g., `src/main.tsx` or `src/App.tsx`):
```tsx
// Required - core styles
import '@mantine/core/styles.css';
// Package-specific styles (import only what you use)
import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/code-highlight/styles.css';
import '@mantine/dropzone/styles.css';
import '@mantine/spotlight/styles.css';
import '@mantine/carousel/styles.css';
import '@mantine/tiptap/styles.css';
// Note: @mantine/form and @mantine/hooks have no styles
```
### 5. Setup MantineProvider
```tsx
// src/App.tsx
import '@mantine/core/styles.css';
import { MantineProvider, createTheme } from '@mantine/core';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const theme = createTheme({
// Your theme customization
primaryColor: 'blue',
fontFamily: 'Inter, sans-serif',
});
function App() {
return (
<MantineProvider theme={theme}>
<BrowserRouter>
<Routes>
{/* Your routes */}
</Routes>
</BrowserRouter>
</MantineProvider>
);
}
export default App;
```
## Project Structure
Recommended structure for Mantine projects:
```
src/
├── components/
│ ├── Layout/
│ │ ├── AppShell.tsx
│ │ ├── Header.tsx
│ │ └── Navbar.tsx
│ └── ui/ # Custom UI components
├── pages/
│ ├── Home.tsx
│ └── Settings.tsx
├── hooks/ # Custom hooks
├── theme/
│ ├── index.ts # createTheme export
│ └── components.ts # Component default props
├── utils/
├── App.tsx
├── main.tsx
└── App.module.css # CSS modules
```
## VS Code Setup
Install recommended extensions:
1. **PostCSS Intellisense and Highlighting**
For postcss syntax support and `$variable` recognition.
2. **CSS Variable Autocomplete**
For Mantine CSS variables autocomplete.
Create `.vscode/settings.json`:
```json
{
"cssVariables.lookupFiles": [
"**/*.css",
"node_modules/@mantine/core/styles.css"
]
}
```
## TypeScript Configuration
Mantine is fully typed. Your `tsconfig.json` should have:
```json
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"skipLibCheck": true
}
}
```
## Common Issues
### Styles Not Loading
Ensure `@mantine/core/styles.css` is imported before any component usage.
### PostCSS Mixins Not Working
Check that `postcss-preset-mantine` is installed and configured in `postcss.config.cjs`.
### Dark Mode Flash
For SSR apps, add `ColorSchemeScript` to `<head>`:
```tsx
import { ColorSchemeScript } from '@mantine/core';
// In your HTML head
<ColorSchemeScript defaultColorScheme="auto" />
```
### Hydration Warning
Spread `mantineHtmlProps` on `<html>` element:
```tsx
import { mantineHtmlProps } from '@mantine/core';
<html {...mantineHtmlProps}>
```

View File

@@ -0,0 +1,344 @@
# Hooks Reference
`@mantine/hooks` provides 75+ React hooks. No styles needed — works independently.
```bash
npm install @mantine/hooks
```
## State Management
### useDisclosure
Boolean state for modals/menus:
```tsx
import { useDisclosure } from '@mantine/hooks';
const [opened, { open, close, toggle }] = useDisclosure(false);
// With callbacks
const [opened, handlers] = useDisclosure(false, {
onOpen: () => console.log('Opened'),
onClose: () => console.log('Closed'),
});
```
### useToggle
Cycle through values:
```tsx
import { useToggle } from '@mantine/hooks';
const [value, toggle] = useToggle(['blue', 'orange', 'cyan']);
// toggle() cycles, toggle('cyan') sets specific
// Boolean
const [active, toggle] = useToggle([false, true]);
```
### useCounter
Numeric counter with min/max:
```tsx
import { useCounter } from '@mantine/hooks';
const [count, { increment, decrement, set, reset }] = useCounter(0, { min: 0, max: 10 });
```
### useListState
Array state management:
```tsx
import { useListState } from '@mantine/hooks';
const [values, handlers] = useListState([1, 2, 3]);
handlers.append(4); // Add to end
handlers.prepend(0); // Add to start
handlers.insert(2, 99); // Insert at index
handlers.remove(1); // Remove at index
handlers.reorder({ from: 0, to: 2 }); // Move item
handlers.filter((item) => item > 2); // Filter
handlers.apply((item) => item * 2); // Transform all
```
### useSetState
Object state with partial updates:
```tsx
import { useSetState } from '@mantine/hooks';
const [state, setState] = useSetState({ name: '', email: '', age: 0 });
setState({ name: 'John' }); // Partial update, others unchanged
```
## Storage
### useLocalStorage / useSessionStorage
```tsx
import { useLocalStorage } from '@mantine/hooks';
const [value, setValue, removeValue] = useLocalStorage({
key: 'my-key',
defaultValue: 'light',
});
// Auto-serializes objects
const [user, setUser] = useLocalStorage({
key: 'user',
defaultValue: { name: '', preferences: {} },
});
```
## Input/Debounce
### useDebouncedValue
```tsx
import { useDebouncedValue } from '@mantine/hooks';
const [value, setValue] = useState('');
const [debounced] = useDebouncedValue(value, 300);
useEffect(() => {
// API call with debounced value
}, [debounced]);
```
### useDebouncedCallback
```tsx
import { useDebouncedCallback } from '@mantine/hooks';
const search = useDebouncedCallback(async (query: string) => {
await searchAPI(query);
}, 300);
<TextInput onChange={(e) => search(e.target.value)} />
```
### useInputState
Simpler input handling:
```tsx
import { useInputState } from '@mantine/hooks';
const [name, setName] = useInputState('');
<TextInput value={name} onChange={setName} />
const [checked, setChecked] = useInputState(false);
<Checkbox checked={checked} onChange={setChecked} />
```
## UI Interactions
### useClickOutside
```tsx
import { useClickOutside } from '@mantine/hooks';
const ref = useClickOutside(() => close());
<Paper ref={ref}>Click outside to close</Paper>
```
### useHover
```tsx
import { useHover } from '@mantine/hooks';
const { hovered, ref } = useHover();
<Box ref={ref} bg={hovered ? 'blue' : 'gray'}>Hover me</Box>
```
### useFocusWithin
```tsx
import { useFocusWithin } from '@mantine/hooks';
const { ref, focused } = useFocusWithin();
<Box ref={ref} style={{ outline: focused ? '2px solid blue' : 'none' }}>
<TextInput /><TextInput />
</Box>
```
### useMediaQuery
```tsx
import { useMediaQuery } from '@mantine/hooks';
const isMobile = useMediaQuery('(max-width: 768px)');
const matches = useMediaQuery('(min-width: 48em)'); // sm breakpoint
return isMobile ? <MobileNav /> : <DesktopNav />;
```
### useViewportSize
```tsx
import { useViewportSize } from '@mantine/hooks';
const { width, height } = useViewportSize();
```
### useElementSize
```tsx
import { useElementSize } from '@mantine/hooks';
const { ref, width, height } = useElementSize();
<Box ref={ref}>Tracks this element's size</Box>
```
### useScrollIntoView
```tsx
import { useScrollIntoView } from '@mantine/hooks';
const { scrollIntoView, targetRef } = useScrollIntoView<HTMLDivElement>({
offset: 60,
duration: 500,
});
<Button onClick={() => scrollIntoView()}>Scroll</Button>
<div ref={targetRef}>Target</div>
```
### useIntersection / useInViewport
```tsx
import { useIntersection, useInViewport } from '@mantine/hooks';
// Detailed intersection info
const { ref, entry } = useIntersection({ threshold: 0.5 });
// Simple visibility check
const { ref, inViewport } = useInViewport();
```
## Utilities
### useClipboard
```tsx
import { useClipboard } from '@mantine/hooks';
const clipboard = useClipboard({ timeout: 500 });
<Button onClick={() => clipboard.copy('Text')} color={clipboard.copied ? 'teal' : 'blue'}>
{clipboard.copied ? 'Copied!' : 'Copy'}
</Button>
```
### useHotkeys
```tsx
import { useHotkeys, getHotkeyHandler } from '@mantine/hooks';
useHotkeys([
['mod+S', () => save()],
['ctrl+K', () => search()],
]);
// On specific input
<input onKeyDown={getHotkeyHandler([
['mod+Enter', submit],
['Escape', cancel],
])} />
```
### useFullscreen
```tsx
import { useFullscreen } from '@mantine/hooks';
const { toggle, fullscreen } = useFullscreen();
<Button onClick={toggle}>{fullscreen ? 'Exit' : 'Enter'} Fullscreen</Button>
```
### useIdle
```tsx
import { useIdle } from '@mantine/hooks';
const idle = useIdle(5000); // 5 seconds
<Text>{idle ? 'User is idle' : 'User is active'}</Text>
```
### useInterval / useTimeout
```tsx
import { useInterval, useTimeout } from '@mantine/hooks';
const interval = useInterval(() => tick(), 1000);
interval.start(); interval.stop(); interval.toggle();
const { start, clear } = useTimeout(() => action(), 3000);
```
### useDocumentTitle
```tsx
import { useDocumentTitle } from '@mantine/hooks';
useDocumentTitle('My Page Title');
```
### useOs
```tsx
import { useOs } from '@mantine/hooks';
const os = useOs(); // 'macos' | 'ios' | 'windows' | 'android' | 'linux'
<Text>Shortcut: {os === 'macos' ? '⌘' : 'Ctrl'}</Text>
```
### useNetwork
```tsx
import { useNetwork } from '@mantine/hooks';
const { online, downlink, effectiveType } = useNetwork();
return online ? <App /> : <OfflineMessage />;
```
### usePrevious
```tsx
import { usePrevious } from '@mantine/hooks';
const [value, setValue] = useState(0);
const previous = usePrevious(value);
```
### useMergedRef
Combine multiple refs:
```tsx
import { useMergedRef } from '@mantine/hooks';
const myRef = useRef<HTMLDivElement>(null);
const { ref: hookRef } = useHover();
const mergedRef = useMergedRef(myRef, hookRef);
<Box ref={mergedRef}>Content</Box>
```
## Complete Hook List
**State:** useDisclosure, useToggle, useCounter, useListState, useSetState, useQueue, usePagination
**Storage:** useLocalStorage, useSessionStorage, readLocalStorageValue
**Input:** useDebouncedValue, useDebouncedState, useDebouncedCallback, useInputState, useValidatedState, useUncontrolled
**UI:** useClickOutside, useHover, useFocusWithin, useFocusReturn, useFocusTrap, useMediaQuery, useViewportSize, useElementSize, useResizeObserver, useScrollIntoView, useIntersection, useInViewport, useWindowScroll, useMouse, useMove
**Utilities:** useClipboard, useHotkeys, useFullscreen, useIdle, useInterval, useTimeout, useDocumentTitle, useDocumentVisibility, useFavicon, useOs, useNetwork, usePrevious, useMergedRef, useId, useForceUpdate, useReducedMotion, useTextSelection, useWindowEvent, useEventListener, useEyeDropper, useHash, useHeadroom, useLogger, useMutationObserver, useOrientation, usePageLeave, usePinchToZoom, useStateHistory

View File

@@ -0,0 +1,472 @@
# Styling & Theming Reference
Mantine styling: MantineProvider, theme object, CSS modules, style props, and Styles API.
## MantineProvider
Required wrapper for all Mantine components:
```tsx
import { createTheme, MantineProvider } from '@mantine/core';
const theme = createTheme({
primaryColor: 'blue',
fontFamily: 'Inter, sans-serif',
});
function App() {
return (
<MantineProvider theme={theme}>
{/* App content */}
</MantineProvider>
);
}
```
### Key Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `theme` | `MantineThemeOverride` | - | Theme customization |
| `defaultColorScheme` | `'light' \| 'dark' \| 'auto'` | `'light'` | Default color scheme |
| `forceColorScheme` | `'light' \| 'dark'` | - | Force specific scheme |
| `env` | `'default' \| 'test'` | `'default'` | Disable transitions for tests |
## Theme Object
```tsx
import { createTheme, rem } from '@mantine/core';
const theme = createTheme({
// Colors
primaryColor: 'blue',
primaryShade: { light: 6, dark: 8 },
// Typography
fontFamily: 'Inter, sans-serif',
headings: {
fontFamily: 'Greycliff CF, sans-serif',
fontWeight: '700',
},
// Spacing & Sizing
spacing: { xs: rem(10), sm: rem(12), md: rem(16), lg: rem(20), xl: rem(32) },
radius: { xs: rem(2), sm: rem(4), md: rem(8), lg: rem(16), xl: rem(32) },
// Defaults
defaultRadius: 'md',
cursorType: 'pointer',
respectReducedMotion: true,
});
```
## Custom Colors
```tsx
import { createTheme, MantineColorsTuple } from '@mantine/core';
const brand: MantineColorsTuple = [
'#f0f9ff', '#e0f2fe', '#bae6fd', '#7dd3fc', '#38bdf8',
'#0ea5e9', '#0284c7', '#0369a1', '#075985', '#0c4a6e',
];
const theme = createTheme({
colors: { brand },
primaryColor: 'brand',
});
```
## Color Scheme (Dark Mode)
```tsx
import { useMantineColorScheme, useComputedColorScheme } from '@mantine/core';
function ColorSchemeToggle() {
const { setColorScheme, toggleColorScheme } = useMantineColorScheme();
const computed = useComputedColorScheme('light'); // Resolved value
return (
<>
<Button onClick={() => setColorScheme('light')}>Light</Button>
<Button onClick={() => setColorScheme('dark')}>Dark</Button>
<Button onClick={toggleColorScheme}>Toggle</Button>
</>
);
}
// SSR: Prevent flash of wrong color scheme
import { ColorSchemeScript, mantineHtmlProps } from '@mantine/core';
<html {...mantineHtmlProps}>
<head>
<ColorSchemeScript defaultColorScheme="auto" />
</head>
</html>
```
## Style Props
All components accept style props for quick styling:
```tsx
import { Box, Text, Button } from '@mantine/core';
function Demo() {
return (
<Box
p="md" // padding
m="lg" // margin
mt="xl" // margin-top
bg="blue.6" // background (color.shade)
c="white" // color
w={200} // width (number = px)
h="100%" // height
maw={500} // max-width
pos="relative" // position
ta="center" // text-align
fz="sm" // font-size
fw={700} // font-weight
ff="monospace" // font-family
lh={1.5} // line-height
style={{ borderRadius: 'var(--mantine-radius-md)' }}
>
<Text c="dimmed" fz="xs" tt="uppercase">
Uppercase dimmed text
</Text>
</Box>
);
}
```
### Common Style Props
| Prop | CSS Property | Example |
|------|--------------|---------|
| `m`, `mx`, `my`, `mt`, `mr`, `mb`, `ml` | margin | `m="md"`, `mt={20}` |
| `p`, `px`, `py`, `pt`, `pr`, `pb`, `pl` | padding | `p="lg"` |
| `w`, `h`, `maw`, `mah`, `miw`, `mih` | width, height, max/min | `w="100%"` |
| `c` | color | `c="blue.6"`, `c="dimmed"` |
| `bg` | background-color | `bg="gray.1"` |
| `fz` | font-size | `fz="sm"`, `fz={14}` |
| `fw` | font-weight | `fw={500}`, `fw="bold"` |
| `ta` | text-align | `ta="center"` |
| `td` | text-decoration | `td="underline"` |
| `tt` | text-transform | `tt="uppercase"` |
| `ff` | font-family | `ff="monospace"` |
| `lh` | line-height | `lh={1.5}` |
| `pos` | position | `pos="absolute"` |
| `top`, `left`, `right`, `bottom` | position offsets | `top={10}` |
| `display` | display | `display="flex"` |
| `opacity` | opacity | `opacity={0.5}` |
### Responsive Props
```tsx
<Box
w={{ base: '100%', sm: '50%', md: 400 }}
p={{ base: 'xs', md: 'xl' }}
display={{ base: 'none', md: 'block' }}
>
Responsive box
</Box>
```
## CSS Modules
Recommended styling approach. Create `.module.css` files:
```css
/* Button.module.css */
.root {
background-color: var(--mantine-color-blue-6);
@mixin hover {
background-color: var(--mantine-color-blue-7);
}
/* Responsive */
@mixin smaller-than sm {
font-size: var(--mantine-font-size-xs);
}
@mixin larger-than md {
padding: var(--mantine-spacing-xl);
}
/* Dark mode */
@mixin dark {
background-color: var(--mantine-color-blue-8);
}
@mixin light {
background-color: var(--mantine-color-blue-4);
}
}
.label {
color: var(--mantine-color-white);
}
```
```tsx
import { Button } from '@mantine/core';
import classes from './Button.module.css';
function Demo() {
return (
<Button classNames={classes}>
Styled button
</Button>
);
}
```
## PostCSS Preset
`postcss-preset-mantine` provides:
### Mixins
```css
/* Hover state */
@mixin hover {
/* Hover-only styles */
}
/* Responsive breakpoints */
@mixin smaller-than sm { }
@mixin larger-than md { }
/* Color scheme */
@mixin light { }
@mixin dark { }
/* RTL support */
@mixin rtl { }
@mixin ltr { }
```
### Functions
```css
.element {
/* rem() - convert to rem */
font-size: rem(16px); /* 1rem */
/* em() - convert to em */
padding: em(24px); /* 1.5em */
/* light-dark() - color scheme values */
background: light-dark(white, black);
/* alpha() - add opacity to color */
background: alpha(var(--mantine-color-blue-5), 0.5);
}
```
## Styles API
Override internal component styles:
### classNames Prop
```tsx
import { TextInput } from '@mantine/core';
import classes from './TextInput.module.css';
// CSS module with selectors matching Styles API
// .root, .input, .label, .error, etc.
<TextInput
classNames={{
root: classes.root,
input: classes.input,
label: classes.label,
}}
/>
```
### styles Prop (CSS-in-JS)
```tsx
<TextInput
styles={{
root: { marginBottom: 20 },
input: { backgroundColor: 'var(--mantine-color-gray-0)' },
label: { fontWeight: 700 },
}}
/>
```
### styles Function
```tsx
<TextInput
styles={(theme, props) => ({
input: {
borderColor: props.error
? theme.colors.red[6]
: theme.colors.gray[4],
},
})}
/>
```
### Finding Selectors
All Styles API selectors are documented for each component. Common patterns:
- `root` - Root element
- `label` - Label text
- `input` - Input element
- `wrapper` - Input wrapper
- `error` - Error message
- `description` - Description text
- `required` - Required asterisk
- `section` - Input sections (left/right icons)
## hiddenFrom / visibleFrom
Hide/show at breakpoints:
```tsx
import { Text } from '@mantine/core';
<Text hiddenFrom="sm">Hidden on sm and larger</Text>
<Text visibleFrom="md">Visible only on md and larger</Text>
```
## lightHidden / darkHidden
Hide based on color scheme:
```tsx
<Text lightHidden>Only in dark mode</Text>
<Text darkHidden>Only in light mode</Text>
```
## Box Component
Base component for custom styling:
```tsx
import { Box } from '@mantine/core';
<Box
component="section" // Render as different element
className={classes.wrapper}
p="md"
bg="gray.1"
style={{ borderRadius: 'var(--mantine-radius-md)' }}
>
Content
</Box>
```
## Polymorphic Components
Many components accept `component` prop:
```tsx
import { Button } from '@mantine/core';
import { Link } from 'react-router-dom';
// Render Button as Link
<Button component={Link} to="/about">
About
</Button>
// Render as native anchor
<Button component="a" href="https://example.com">
External
</Button>
```
## CSS Variables in Styles
Access theme values:
```tsx
<Box
style={{
backgroundColor: 'var(--mantine-color-blue-6)',
padding: 'var(--mantine-spacing-md)',
borderRadius: 'var(--mantine-radius-sm)',
boxShadow: 'var(--mantine-shadow-md)',
}}
>
Styled with CSS variables
</Box>
```
## Global Styles
```tsx
// In your CSS
:root {
--my-custom-color: #ff6b6b;
}
/* Target Mantine root element */
[data-mantine-color-scheme="dark"] {
--my-custom-color: #ff8787;
}
/* Global component overrides */
.mantine-Button-root {
font-weight: 600;
}
```
## rem() and em() Utilities
```tsx
import { rem, em } from '@mantine/core';
// In styles or inline
<Box style={{ fontSize: rem(16), padding: rem(24) }} />
// rem(16) => '1rem'
// em(24) => '1.5em'
```
## Style Props vs CSS Modules
| Use Case | Recommended |
|----------|-------------|
| Quick prototyping | Style props |
| Simple spacing/colors | Style props |
| Complex hover/focus states | CSS modules |
| Responsive layouts | CSS modules |
| Reusable component styles | CSS modules |
| Performance critical | CSS modules |
## Component Default Props (Theme)
Override defaults globally:
```tsx
const theme = createTheme({
components: {
Button: Button.extend({
defaultProps: { variant: 'outline', size: 'md', radius: 'xl' },
}),
TextInput: TextInput.extend({
defaultProps: { size: 'md' },
classNames: { root: 'my-input-root', input: 'my-input' },
}),
},
});
```
## CSS Variables Reference
```
--mantine-color-{color}-{shade} // Colors (0-9)
--mantine-primary-color-{shade} // Primary color
--mantine-spacing-{size} // xs, sm, md, lg, xl
--mantine-radius-{size} // xs, sm, md, lg, xl
--mantine-font-family // Main font
--mantine-font-size-{size} // xs, sm, md, lg, xl
--mantine-breakpoint-{size} // Responsive breakpoints
```

View File

@@ -0,0 +1,395 @@
# Testing Reference
Guide for testing Mantine applications with Vitest and React Testing Library.
## Installation
```bash
npm install -D vitest jsdom @testing-library/dom @testing-library/jest-dom @testing-library/react @testing-library/user-event
```
## Vitest Configuration
Add to `vite.config.ts`:
```ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './vitest.setup.mjs',
},
});
```
## Setup File
Create `vitest.setup.mjs`:
```js
import '@testing-library/jest-dom/vitest';
import { vi } from 'vitest';
// Fix for getComputedStyle
const { getComputedStyle } = window;
window.getComputedStyle = (elt) => getComputedStyle(elt);
// Mock scrollIntoView
window.HTMLElement.prototype.scrollIntoView = () => {};
// Mock matchMedia (required by Mantine)
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock ResizeObserver (required by some Mantine components)
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserver;
```
## Custom Render Function
All Mantine components require MantineProvider. Create custom render:
```tsx
// test-utils/render.tsx
import { render as testingLibraryRender } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import { theme } from '../src/theme'; // Your theme if any
export function render(ui: React.ReactNode) {
return testingLibraryRender(<>{ui}</>, {
wrapper: ({ children }) => (
<MantineProvider theme={theme} env="test">
{children}
</MantineProvider>
),
});
}
```
### Important: env="test"
Setting `env="test"` on MantineProvider:
- Disables CSS transitions (tests run faster)
- Disables portals (elements render in place, easier to query)
## Export Test Utilities
```tsx
// test-utils/index.ts
import userEvent from '@testing-library/user-event';
export * from '@testing-library/react';
export { render } from './render';
export { userEvent };
```
## Writing Tests
### Basic Component Test
```tsx
// Button.test.tsx
import { render, screen } from '../test-utils';
import { Button } from '@mantine/core';
describe('Button', () => {
it('renders children', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('handles click events', async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
});
```
### Testing Form Inputs
```tsx
import { render, screen } from '../test-utils';
import { userEvent } from '../test-utils';
import { TextInput } from '@mantine/core';
describe('TextInput', () => {
it('accepts user input', async () => {
const onChange = vi.fn();
render(<TextInput onChange={onChange} label="Name" />);
const input = screen.getByLabelText('Name');
await userEvent.type(input, 'John Doe');
expect(input).toHaveValue('John Doe');
expect(onChange).toHaveBeenCalled();
});
it('displays error', () => {
render(<TextInput label="Email" error="Invalid email" />);
expect(screen.getByText('Invalid email')).toBeInTheDocument();
});
});
```
### Testing useForm
```tsx
import { render, screen, waitFor } from '../test-utils';
import { userEvent } from '../test-utils';
import { useForm } from '@mantine/form';
import { TextInput, Button } from '@mantine/core';
function TestForm() {
const form = useForm({
mode: 'uncontrolled',
initialValues: { email: '' },
validate: {
email: (v) => (!v.includes('@') ? 'Invalid email' : null),
},
});
return (
<form onSubmit={form.onSubmit((values) => console.log(values))}>
<TextInput
label="Email"
key={form.key('email')}
{...form.getInputProps('email')}
/>
<Button type="submit">Submit</Button>
</form>
);
}
describe('Form', () => {
it('validates on submit', async () => {
render(<TestForm />);
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
expect(screen.getByText('Invalid email')).toBeInTheDocument();
});
it('clears error on valid input', async () => {
render(<TestForm />);
await userEvent.type(screen.getByLabelText('Email'), 'test@email.com');
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
expect(screen.queryByText('Invalid email')).not.toBeInTheDocument();
});
});
```
### Testing Modals
With `env="test"`, modals render in place (no portal):
```tsx
import { render, screen } from '../test-utils';
import { userEvent } from '../test-utils';
import { useDisclosure } from '@mantine/hooks';
import { Modal, Button } from '@mantine/core';
function ModalDemo() {
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Button onClick={open}>Open</Button>
<Modal opened={opened} onClose={close} title="Test Modal">
Modal Content
</Modal>
</>
);
}
describe('Modal', () => {
it('opens when button is clicked', async () => {
render(<ModalDemo />);
expect(screen.queryByText('Modal Content')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /open/i }));
expect(screen.getByText('Modal Content')).toBeInTheDocument();
});
});
```
### Testing Select/Dropdown
```tsx
import { render, screen } from '../test-utils';
import { userEvent } from '../test-utils';
import { Select } from '@mantine/core';
describe('Select', () => {
it('selects an option', async () => {
const onChange = vi.fn();
render(
<Select
label="Country"
data={['USA', 'Canada', 'UK']}
onChange={onChange}
/>
);
// Open dropdown
await userEvent.click(screen.getByLabelText('Country'));
// Select option
await userEvent.click(screen.getByRole('option', { name: 'Canada' }));
expect(onChange).toHaveBeenCalledWith('Canada');
});
});
```
### Testing Color Scheme
```tsx
import { render, screen } from '../test-utils';
import { useMantineColorScheme } from '@mantine/core';
function ColorSchemeToggle() {
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
return (
<button onClick={toggleColorScheme}>
Current: {colorScheme}
</button>
);
}
describe('Color Scheme', () => {
it('toggles color scheme', async () => {
render(<ColorSchemeToggle />);
expect(screen.getByText(/current: light/i)).toBeInTheDocument();
await userEvent.click(screen.getByRole('button'));
expect(screen.getByText(/current: dark/i)).toBeInTheDocument();
});
});
```
### Testing Notifications
```tsx
import { render, screen, waitFor } from '../test-utils';
import { notifications } from '@mantine/notifications';
import { Notifications } from '@mantine/notifications';
import { Button } from '@mantine/core';
function NotificationDemo() {
return (
<>
<Notifications />
<Button onClick={() => notifications.show({ message: 'Hello!' })}>
Show Notification
</Button>
</>
);
}
describe('Notifications', () => {
it('shows notification', async () => {
render(<NotificationDemo />);
await userEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByText('Hello!')).toBeInTheDocument();
});
});
});
```
## Testing Hooks
```tsx
import { renderHook, act } from '@testing-library/react';
import { useDisclosure } from '@mantine/hooks';
describe('useDisclosure', () => {
it('toggles state', () => {
const { result } = renderHook(() => useDisclosure(false));
expect(result.current[0]).toBe(false);
act(() => {
result.current[1].open();
});
expect(result.current[0]).toBe(true);
act(() => {
result.current[1].close();
});
expect(result.current[0]).toBe(false);
});
});
```
## Scripts
Add to `package.json`:
```json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
```
## Common Issues
### matchMedia Error
Ensure `matchMedia` mock is in setup file.
### ResizeObserver Error
Ensure `ResizeObserver` mock is in setup file.
### Portal Elements Not Found
Use `env="test"` on MantineProvider to disable portals.
### Transitions Cause Timing Issues
Use `env="test"` to disable transitions.
### Elements Not in Document
Make sure to use custom `render` that includes MantineProvider.
## Testing Checklist
- [ ] Setup file mocks matchMedia and ResizeObserver
- [ ] Custom render includes MantineProvider with `env="test"`
- [ ] Use `userEvent` for user interactions (not `fireEvent`)
- [ ] Use `waitFor` for async operations
- [ ] Use `getByRole` with accessible names when possible
- [ ] Test error states and edge cases