skills
This commit is contained in:
258
.agents/skills/mantine-dev/references/components.md
Normal file
258
.agents/skills/mantine-dev/references/components.md
Normal 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>
|
||||
```
|
||||
269
.agents/skills/mantine-dev/references/eslint.md
Normal file
269
.agents/skills/mantine-dev/references/eslint.md
Normal 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.
|
||||
496
.agents/skills/mantine-dev/references/forms.md
Normal file
496
.agents/skills/mantine-dev/references/forms.md
Normal 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();
|
||||
}
|
||||
)}
|
||||
>
|
||||
```
|
||||
215
.agents/skills/mantine-dev/references/getting-started.md
Normal file
215
.agents/skills/mantine-dev/references/getting-started.md
Normal 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}>
|
||||
```
|
||||
344
.agents/skills/mantine-dev/references/hooks.md
Normal file
344
.agents/skills/mantine-dev/references/hooks.md
Normal 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
|
||||
472
.agents/skills/mantine-dev/references/styling.md
Normal file
472
.agents/skills/mantine-dev/references/styling.md
Normal 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
|
||||
```
|
||||
395
.agents/skills/mantine-dev/references/testing.md
Normal file
395
.agents/skills/mantine-dev/references/testing.md
Normal 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
|
||||
Reference in New Issue
Block a user