Compare commits

...

6 Commits

Author SHA1 Message Date
919f8f2948 fix: guard package consistency quantifier 2025-12-27 15:27:21 +00:00
111760c7a5 Merge pull request #78 from johndoe6345789/copilot/create-missing-common-molecules
Add missing form field and navigation molecules
2025-12-27 04:32:02 +00:00
f8b2a714e9 Merge branch 'main' into copilot/create-missing-common-molecules 2025-12-27 04:31:56 +00:00
edebc20dda Merge pull request #86 from johndoe6345789/copilot/run-lint-on-core-docs
Fix npm run lint: broken symlinks and ESLint errors
2025-12-27 04:25:39 +00:00
copilot-swe-agent[bot]
ccee347a01 Add missing common molecules: form fields, search bars, nav items
- Created PasswordField, EmailField, NumberField molecules
- Created SearchBar molecule with clear and filter buttons
- Created NavItem, NavLink, NavGroup navigation molecules
- Added comprehensive tests for all new molecules
- Updated index files to export new molecules
- Updated README with new molecule documentation

Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
2025-12-27 04:13:54 +00:00
copilot-swe-agent[bot]
b1b712c4ff Initial plan 2025-12-27 04:00:41 +00:00
18 changed files with 1269 additions and 4 deletions

View File

@@ -15,6 +15,10 @@ Molecules are simple groups of atoms that function together as a cohesive unit.
| `DropdownMenu` | Context/action menu | Menu, MenuItem |
| `FormField` | Label + input + error | Label, Input |
| `SearchInput` | Input with search icon | TextField |
| `PasswordField` | Password input with visibility toggle | TextField, IconButton |
| `EmailField` | Email input with icon | TextField, InputAdornment |
| `NumberField` | Number input with constraints | TextField |
| `SearchBar` | Search input with clear and filter buttons | TextField, IconButton |
| `Popover` | Floating content panel | MuiPopover |
### Application Molecules
@@ -27,7 +31,11 @@ Molecules are simple groups of atoms that function together as a cohesive unit.
## Usage
```typescript
import { Card, CardHeader, CardContent, Dialog, Alert } from '@/components/molecules'
import {
Card, CardHeader, CardContent,
Dialog, Alert,
PasswordField, EmailField, NumberField, SearchBar
} from '@/components/molecules'
function MyPage() {
return (
@@ -44,6 +52,35 @@ function MyPage() {
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>Modal content</DialogContent>
</Dialog>
<PasswordField
label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<EmailField
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
showIcon
/>
<NumberField
label="Age"
min={0}
max={120}
value={age}
onChange={(e) => setAge(e.target.value)}
/>
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
onClear={() => setSearchQuery('')}
showFilterButton
onFilterClick={handleOpenFilters}
/>
</Box>
)
}
@@ -72,4 +109,4 @@ function MyPage() {
</CardContent>
</Card>
```
```

View File

@@ -0,0 +1,62 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { EmailField } from './EmailField'
describe('EmailField', () => {
it.each([
{ label: 'Email', placeholder: 'you@example.com', showIcon: true },
{ label: 'Your Email', placeholder: 'Enter email', showIcon: false },
{ label: 'Work Email', placeholder: undefined, showIcon: true },
])('renders with label "$label", placeholder "$placeholder", showIcon $showIcon', ({ label, placeholder, showIcon }) => {
render(<EmailField label={label} placeholder={placeholder} showIcon={showIcon} />)
expect(screen.getByLabelText(label)).toBeTruthy()
if (placeholder) {
expect(screen.getByPlaceholderText(placeholder)).toBeTruthy()
}
})
it('renders with email icon by default', () => {
const { container } = render(<EmailField />)
// Icon is rendered via MUI Icon component
expect(container.querySelector('svg')).toBeTruthy()
})
it('does not render icon when showIcon is false', () => {
const { container } = render(<EmailField showIcon={false} />)
// No icon should be present
expect(container.querySelector('svg')).toBeNull()
})
it.each([
{ error: 'Invalid email', helperText: undefined },
{ error: undefined, helperText: 'Enter a valid email address' },
{ error: 'Required field', helperText: 'Please provide your email' },
])('displays error "$error" or helperText "$helperText"', ({ error, helperText }) => {
render(<EmailField error={error} helperText={helperText} />)
const displayText = error || helperText
if (displayText) {
expect(screen.getByText(displayText)).toBeTruthy()
}
})
it('calls onChange when value changes', () => {
const handleChange = vi.fn()
render(<EmailField onChange={handleChange} />)
const input = screen.getByLabelText('Email')
fireEvent.change(input, { target: { value: 'test@example.com' } })
expect(handleChange).toHaveBeenCalled()
})
it('has type="email" attribute', () => {
render(<EmailField />)
const input = screen.getByLabelText('Email') as HTMLInputElement
expect(input.type).toBe('email')
})
})

View File

@@ -0,0 +1,83 @@
'use client'
import { forwardRef } from 'react'
import { TextField } from '@mui/material'
import EmailIcon from '@mui/icons-material/Email'
import { InputAdornment } from '@mui/material'
export interface EmailFieldProps {
label?: string
name?: string
value?: string
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
error?: string
helperText?: string
required?: boolean
placeholder?: string
fullWidth?: boolean
disabled?: boolean
autoComplete?: string
showIcon?: boolean
className?: string
}
const EmailField = forwardRef<HTMLInputElement, EmailFieldProps>(
(
{
label = 'Email',
name = 'email',
value,
onChange,
error,
helperText,
required = false,
placeholder = 'you@example.com',
fullWidth = true,
disabled = false,
autoComplete = 'email',
showIcon = true,
...props
},
ref
) => {
return (
<TextField
inputRef={ref}
type="email"
label={label}
name={name}
value={value}
onChange={onChange}
error={!!error}
helperText={error || helperText}
required={required}
placeholder={placeholder}
fullWidth={fullWidth}
disabled={disabled}
autoComplete={autoComplete}
size="small"
slotProps={{
input: showIcon
? {
startAdornment: (
<InputAdornment position="start">
<EmailIcon fontSize="small" color="action" />
</InputAdornment>
),
}
: undefined,
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 1,
},
}}
{...props}
/>
)
}
)
EmailField.displayName = 'EmailField'
export { EmailField }

View File

@@ -0,0 +1,69 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { NumberField } from './NumberField'
describe('NumberField', () => {
it.each([
{ label: 'Number', value: undefined },
{ label: 'Age', value: 25 },
{ label: 'Quantity', value: 100 },
])('renders with label "$label" and value $value', ({ label, value }) => {
render(<NumberField label={label} value={value} />)
expect(screen.getByLabelText(label)).toBeTruthy()
if (value !== undefined) {
expect(screen.getByDisplayValue(value.toString())).toBeTruthy()
}
})
it.each([
{ min: 0, max: 100, step: 1 },
{ min: -10, max: 10, step: 0.5 },
{ min: undefined, max: undefined, step: undefined },
])('respects min $min, max $max, step $step constraints', ({ min, max, step }) => {
render(<NumberField min={min} max={max} step={step} />)
const input = screen.getByLabelText('Number') as HTMLInputElement
if (min !== undefined) {
expect(input.min).toBe(min.toString())
}
if (max !== undefined) {
expect(input.max).toBe(max.toString())
}
if (step !== undefined) {
expect(input.step).toBe(step.toString())
} else {
expect(input.step).toBe('1')
}
})
it('calls onChange when value changes', () => {
const handleChange = vi.fn()
render(<NumberField onChange={handleChange} />)
const input = screen.getByLabelText('Number')
fireEvent.change(input, { target: { value: '42' } })
expect(handleChange).toHaveBeenCalled()
})
it.each([
{ error: 'Value too high', helperText: undefined },
{ error: undefined, helperText: 'Enter a number between 0 and 100' },
])('displays error "$error" or helperText "$helperText"', ({ error, helperText }) => {
render(<NumberField error={error} helperText={helperText} />)
const displayText = error || helperText
if (displayText) {
expect(screen.getByText(displayText)).toBeTruthy()
}
})
it('has type="number" attribute', () => {
render(<NumberField />)
const input = screen.getByLabelText('Number') as HTMLInputElement
expect(input.type).toBe('number')
})
})

View File

@@ -0,0 +1,85 @@
'use client'
import { forwardRef } from 'react'
import { TextField } from '@mui/material'
export interface NumberFieldProps {
label?: string
name?: string
value?: number | string
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
error?: string
helperText?: string
required?: boolean
placeholder?: string
fullWidth?: boolean
disabled?: boolean
min?: number
max?: number
step?: number | string
className?: string
}
const NumberField = forwardRef<HTMLInputElement, NumberFieldProps>(
(
{
label = 'Number',
name,
value,
onChange,
error,
helperText,
required = false,
placeholder,
fullWidth = true,
disabled = false,
min,
max,
step = 1,
...props
},
ref
) => {
return (
<TextField
inputRef={ref}
type="number"
label={label}
name={name}
value={value}
onChange={onChange}
error={!!error}
helperText={error || helperText}
required={required}
placeholder={placeholder}
fullWidth={fullWidth}
disabled={disabled}
size="small"
slotProps={{
htmlInput: {
min,
max,
step,
},
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 1,
},
'& input[type=number]': {
MozAppearance: 'textfield',
},
'& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button': {
WebkitAppearance: 'none',
margin: 0,
},
}}
{...props}
/>
)
}
)
NumberField.displayName = 'NumberField'
export { NumberField }

View File

@@ -0,0 +1,63 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { PasswordField } from './PasswordField'
describe('PasswordField', () => {
it.each([
{ label: 'Password', placeholder: undefined },
{ label: 'Enter Password', placeholder: 'Your password' },
{ label: 'Confirm Password', placeholder: 'Confirm your password' },
])('renders with label "$label" and placeholder "$placeholder"', ({ label, placeholder }) => {
render(<PasswordField label={label} placeholder={placeholder} />)
expect(screen.getByLabelText(label)).toBeTruthy()
if (placeholder) {
expect(screen.getByPlaceholderText(placeholder)).toBeTruthy()
}
})
it('toggles password visibility when icon button is clicked', () => {
render(<PasswordField />)
const input = screen.getByLabelText('Password') as HTMLInputElement
expect(input.type).toBe('password')
const toggleButton = screen.getByLabelText('toggle password visibility')
fireEvent.click(toggleButton)
expect(input.type).toBe('text')
fireEvent.click(toggleButton)
expect(input.type).toBe('password')
})
it.each([
{ error: 'Password is required', helperText: undefined },
{ error: undefined, helperText: 'Must be at least 8 characters' },
{ error: 'Too short', helperText: 'Should be longer' },
])('displays error "$error" or helperText "$helperText"', ({ error, helperText }) => {
render(<PasswordField error={error} helperText={helperText} />)
const displayText = error || helperText
if (displayText) {
expect(screen.getByText(displayText)).toBeTruthy()
}
})
it('calls onChange when value changes', () => {
const handleChange = vi.fn()
render(<PasswordField onChange={handleChange} />)
const input = screen.getByLabelText('Password')
fireEvent.change(input, { target: { value: 'newpassword' } })
expect(handleChange).toHaveBeenCalled()
})
it('disables toggle button when field is disabled', () => {
render(<PasswordField disabled />)
const toggleButton = screen.getByLabelText('toggle password visibility')
expect(toggleButton.hasAttribute('disabled')).toBe(true)
})
})

View File

@@ -0,0 +1,94 @@
'use client'
import { forwardRef, useState } from 'react'
import { Box, IconButton, InputAdornment, TextField } from '@mui/material'
import VisibilityIcon from '@mui/icons-material/Visibility'
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'
export interface PasswordFieldProps {
label?: string
name?: string
value?: string
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
error?: string
helperText?: string
required?: boolean
placeholder?: string
fullWidth?: boolean
disabled?: boolean
autoComplete?: string
className?: string
}
const PasswordField = forwardRef<HTMLInputElement, PasswordFieldProps>(
(
{
label = 'Password',
name = 'password',
value,
onChange,
error,
helperText,
required = false,
placeholder,
fullWidth = true,
disabled = false,
autoComplete = 'current-password',
...props
},
ref
) => {
const [showPassword, setShowPassword] = useState(false)
const togglePasswordVisibility = () => {
setShowPassword((prev) => !prev)
}
return (
<TextField
inputRef={ref}
type={showPassword ? 'text' : 'password'}
label={label}
name={name}
value={value}
onChange={onChange}
error={!!error}
helperText={error || helperText}
required={required}
placeholder={placeholder}
fullWidth={fullWidth}
disabled={disabled}
autoComplete={autoComplete}
size="small"
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={togglePasswordVisibility}
onMouseDown={(e) => e.preventDefault()}
edge="end"
size="small"
disabled={disabled}
>
{showPassword ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
</IconButton>
</InputAdornment>
),
},
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 1,
},
}}
{...props}
/>
)
}
)
PasswordField.displayName = 'PasswordField'
export { PasswordField }

View File

@@ -0,0 +1,88 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { SearchBar } from './SearchBar'
describe('SearchBar', () => {
it.each([
{ placeholder: 'Search...', value: '' },
{ placeholder: 'Find items...', value: 'test query' },
{ placeholder: 'Type to search', value: 'example' },
])('renders with placeholder "$placeholder" and value "$value"', ({ placeholder, value }) => {
render(<SearchBar placeholder={placeholder} value={value} />)
expect(screen.getByPlaceholderText(placeholder)).toBeTruthy()
if (value) {
expect(screen.getByDisplayValue(value)).toBeTruthy()
}
})
it('shows search icon by default', () => {
const { container } = render(<SearchBar />)
// Search icon is always present
expect(container.querySelector('svg')).toBeTruthy()
})
it.each([
{ showClearButton: true, value: 'test', shouldShowClear: true },
{ showClearButton: false, value: 'test', shouldShowClear: false },
{ showClearButton: true, value: '', shouldShowClear: false },
])('handles clear button with showClearButton=$showClearButton, value="$value"',
({ showClearButton, value, shouldShowClear }) => {
render(<SearchBar showClearButton={showClearButton} value={value} />)
const clearButton = screen.queryByLabelText('clear search')
if (shouldShowClear) {
expect(clearButton).toBeTruthy()
} else {
expect(clearButton).toBeNull()
}
}
)
it('calls onClear when clear button is clicked', () => {
const handleClear = vi.fn()
const handleChange = vi.fn()
render(<SearchBar value="test" onClear={handleClear} onChange={handleChange} />)
const clearButton = screen.getByLabelText('clear search')
fireEvent.click(clearButton)
expect(handleClear).toHaveBeenCalled()
expect(handleChange).toHaveBeenCalledWith('')
})
it.each([
{ showFilterButton: true },
{ showFilterButton: false },
])('renders filter button when showFilterButton=$showFilterButton', ({ showFilterButton }) => {
render(<SearchBar showFilterButton={showFilterButton} />)
const filterButton = screen.queryByLabelText('open filters')
if (showFilterButton) {
expect(filterButton).toBeTruthy()
} else {
expect(filterButton).toBeNull()
}
})
it('calls onFilterClick when filter button is clicked', () => {
const handleFilterClick = vi.fn()
render(<SearchBar showFilterButton onFilterClick={handleFilterClick} />)
const filterButton = screen.getByLabelText('open filters')
fireEvent.click(filterButton)
expect(handleFilterClick).toHaveBeenCalled()
})
it('calls onChange when input value changes', () => {
const handleChange = vi.fn()
render(<SearchBar onChange={handleChange} />)
const input = screen.getByPlaceholderText('Search...')
fireEvent.change(input, { target: { value: 'new search' } })
expect(handleChange).toHaveBeenCalledWith('new search')
})
})

View File

@@ -0,0 +1,120 @@
'use client'
import { forwardRef, ReactNode } from 'react'
import { Box, TextField, InputAdornment, IconButton } from '@mui/material'
import SearchIcon from '@mui/icons-material/Search'
import ClearIcon from '@mui/icons-material/Clear'
import FilterListIcon from '@mui/icons-material/FilterList'
export interface SearchBarProps {
value?: string
onChange?: (value: string) => void
onClear?: () => void
onFilterClick?: () => void
placeholder?: string
fullWidth?: boolean
showFilterButton?: boolean
showClearButton?: boolean
disabled?: boolean
loading?: boolean
endAdornment?: ReactNode
className?: string
}
const SearchBar = forwardRef<HTMLInputElement, SearchBarProps>(
(
{
value = '',
onChange,
onClear,
onFilterClick,
placeholder = 'Search...',
fullWidth = true,
showFilterButton = false,
showClearButton = true,
disabled = false,
loading = false,
endAdornment,
...props
},
ref
) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e.target.value)
}
const handleClear = () => {
onChange?.('')
onClear?.()
}
return (
<TextField
inputRef={ref}
value={value}
onChange={handleChange}
placeholder={placeholder}
fullWidth={fullWidth}
disabled={disabled}
size="small"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" color="action" />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<Box sx={{ display: 'flex', gap: 0.5 }}>
{showClearButton && value && !disabled && (
<IconButton
aria-label="clear search"
onClick={handleClear}
edge="end"
size="small"
sx={{ p: 0.5 }}
>
<ClearIcon fontSize="small" />
</IconButton>
)}
{showFilterButton && (
<IconButton
aria-label="open filters"
onClick={onFilterClick}
edge="end"
size="small"
disabled={disabled}
sx={{ p: 0.5 }}
>
<FilterListIcon fontSize="small" />
</IconButton>
)}
{endAdornment}
</Box>
</InputAdornment>
),
},
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
bgcolor: 'background.paper',
transition: 'box-shadow 0.2s',
'&:hover': {
boxShadow: 1,
},
'&.Mui-focused': {
boxShadow: 2,
},
},
}}
{...props}
/>
)
}
)
SearchBar.displayName = 'SearchBar'
export { SearchBar }

View File

@@ -96,6 +96,26 @@ export {
type TextAreaProps,
} from './form/FormField'
export {
PasswordField,
type PasswordFieldProps,
} from './form/PasswordField'
export {
EmailField,
type EmailFieldProps,
} from './form/EmailField'
export {
NumberField,
type NumberFieldProps,
} from './form/NumberField'
export {
SearchBar,
type SearchBarProps,
} from './form/SearchBar'
export {
Popover,
PopoverTrigger,

View File

@@ -59,3 +59,6 @@ export {
BreadcrumbSeparator,
BreadcrumbEllipsis,
} from './navigation/Breadcrumb'
export { NavItem, type NavItemProps } from './navigation/NavItem'
export { NavLink, type NavLinkProps } from './navigation/NavLink'
export { NavGroup, type NavGroupProps } from './navigation/NavGroup'

View File

@@ -0,0 +1,111 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { NavGroup } from './NavGroup'
import { NavItem } from './NavItem'
import FolderIcon from '@mui/icons-material/Folder'
describe('NavGroup', () => {
it.each([
{ label: 'Navigation', defaultOpen: false },
{ label: 'Settings', defaultOpen: true },
{ label: 'Admin', defaultOpen: false },
])('renders with label "$label" and defaultOpen=$defaultOpen', ({ label, defaultOpen }) => {
render(
<NavGroup label={label} defaultOpen={defaultOpen}>
<NavItem label="Child Item" />
</NavGroup>
)
expect(screen.getByText(label)).toBeTruthy()
const childItem = screen.queryByText('Child Item')
if (defaultOpen) {
expect(childItem).toBeTruthy()
}
})
it('toggles collapse when clicked', () => {
render(
<NavGroup label="Menu">
<NavItem label="Child Item" />
</NavGroup>
)
const button = screen.getByRole('button', { name: /Menu/i })
let childItem = screen.queryByText('Child Item')
// Initially collapsed - item should not be visible
expect(childItem).toBeNull()
// Click to expand
fireEvent.click(button)
childItem = screen.queryByText('Child Item')
expect(childItem).toBeTruthy()
// Click to collapse
fireEvent.click(button)
// After collapsing, wait for animation and check
setTimeout(() => {
childItem = screen.queryByText('Child Item')
expect(childItem).toBeNull()
}, 500)
})
it('renders with icon', () => {
const { container } = render(
<NavGroup label="Files" icon={<FolderIcon data-testid="folder-icon" />}>
<NavItem label="Document" />
</NavGroup>
)
expect(screen.getByTestId('folder-icon')).toBeTruthy()
})
it.each([
{ disabled: true },
{ disabled: false },
])('handles disabled=$disabled state', ({ disabled }) => {
render(
<NavGroup label="Menu" disabled={disabled}>
<NavItem label="Child" />
</NavGroup>
)
const button = screen.getByRole('button', { name: /Menu/i })
if (disabled) {
expect(button.getAttribute('aria-disabled')).toBe('true')
fireEvent.click(button)
// Should not expand when disabled
expect(screen.queryByText('Child')).toBeNull()
} else {
expect(button.getAttribute('aria-disabled')).toBe(null)
}
})
it('renders divider when divider=true', () => {
const { container } = render(
<NavGroup label="Menu" divider>
<NavItem label="Child" />
</NavGroup>
)
// Check for MUI Divider component
const divider = container.querySelector('hr')
expect(divider).toBeTruthy()
})
it('renders multiple children', () => {
render(
<NavGroup label="Menu" defaultOpen>
<NavItem label="Child 1" />
<NavItem label="Child 2" />
<NavItem label="Child 3" />
</NavGroup>
)
expect(screen.getByText('Child 1')).toBeTruthy()
expect(screen.getByText('Child 2')).toBeTruthy()
expect(screen.getByText('Child 3')).toBeTruthy()
})
})

View File

@@ -0,0 +1,93 @@
'use client'
import { forwardRef, ReactNode, useState } from 'react'
import {
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Collapse,
Box,
Divider,
} from '@mui/material'
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
export interface NavGroupProps {
label: string
icon?: ReactNode
children: ReactNode
defaultOpen?: boolean
disabled?: boolean
divider?: boolean
className?: string
}
const NavGroup = forwardRef<HTMLDivElement, NavGroupProps>(
(
{ label, icon, children, defaultOpen = false, disabled = false, divider = false, ...props },
ref
) => {
const [open, setOpen] = useState(defaultOpen)
const handleToggle = () => {
if (!disabled) {
setOpen((prev) => !prev)
}
}
return (
<Box ref={ref} {...props}>
{divider && <Divider sx={{ my: 1 }} />}
<ListItem disablePadding>
<ListItemButton
onClick={handleToggle}
disabled={disabled}
sx={{
borderRadius: 1,
mx: 0.5,
my: 0.25,
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
{icon && (
<ListItemIcon
sx={{
minWidth: 40,
color: 'text.secondary',
}}
>
{icon}
</ListItemIcon>
)}
<ListItemText
primary={label}
primaryTypographyProps={{
variant: 'body2',
fontWeight: 600,
color: 'text.primary',
}}
/>
{open ? (
<ExpandLessIcon fontSize="small" sx={{ color: 'text.secondary' }} />
) : (
<ExpandMoreIcon fontSize="small" sx={{ color: 'text.secondary' }} />
)}
</ListItemButton>
</ListItem>
<Collapse in={open} timeout="auto" unmountOnExit>
<List component="div" disablePadding sx={{ pl: icon ? 3 : 1 }}>
{children}
</List>
</Collapse>
</Box>
)
}
)
NavGroup.displayName = 'NavGroup'
export { NavGroup }

View File

@@ -0,0 +1,68 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { NavItem } from './NavItem'
import HomeIcon from '@mui/icons-material/Home'
describe('NavItem', () => {
it.each([
{ label: 'Home', icon: <HomeIcon />, active: false },
{ label: 'Dashboard', icon: <HomeIcon />, active: true },
{ label: 'Settings', icon: undefined, active: false },
])('renders with label "$label", icon presence, active=$active', ({ label, icon, active }) => {
render(<NavItem label={label} icon={icon} active={active} />)
expect(screen.getByText(label)).toBeTruthy()
const button = screen.getByRole('button')
if (active) {
expect(button.classList.contains('Mui-selected')).toBe(true)
}
})
it.each([
{ badge: 5, badgeColor: 'primary' as const },
{ badge: '99+', badgeColor: 'error' as const },
{ badge: undefined, badgeColor: 'default' as const },
])('displays badge=$badge with badgeColor=$badgeColor', ({ badge, badgeColor }) => {
render(<NavItem label="Messages" icon={<HomeIcon />} badge={badge} badgeColor={badgeColor} />)
if (badge !== undefined) {
expect(screen.getByText(badge.toString())).toBeTruthy()
}
})
it('calls onClick when clicked', () => {
const handleClick = vi.fn()
render(<NavItem label="Home" onClick={handleClick} />)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(handleClick).toHaveBeenCalled()
})
it.each([
{ disabled: true, shouldBeDisabled: true },
{ disabled: false, shouldBeDisabled: false },
])('handles disabled=$disabled state', ({ disabled, shouldBeDisabled }) => {
render(<NavItem label="Home" disabled={disabled} />)
const button = screen.getByRole('button')
expect(button.getAttribute('aria-disabled')).toBe(shouldBeDisabled ? 'true' : null)
})
it('renders with secondary label', () => {
render(<NavItem label="Home" secondaryLabel="Main page" />)
expect(screen.getByText('Home')).toBeTruthy()
expect(screen.getByText('Main page')).toBeTruthy()
})
it('renders with href for navigation', () => {
render(<NavItem label="Home" href="/home" />)
// When href is provided, MUI renders it as a link, not a button
const link = screen.getByRole('link')
expect(link.getAttribute('href')).toBe('/home')
})
})

View File

@@ -0,0 +1,135 @@
'use client'
import { forwardRef, ReactNode } from 'react'
import {
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Badge,
Box,
} from '@mui/material'
export interface NavItemProps {
icon?: ReactNode
label: string
onClick?: () => void
active?: boolean
disabled?: boolean
badge?: number | string
badgeColor?: 'default' | 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success'
href?: string
secondaryLabel?: string
dense?: boolean
className?: string
}
const NavItem = forwardRef<HTMLLIElement, NavItemProps>(
(
{
icon,
label,
onClick,
active = false,
disabled = false,
badge,
badgeColor = 'primary',
href,
secondaryLabel,
dense = false,
...props
},
ref
) => {
return (
<ListItem
ref={ref}
disablePadding
{...props}
sx={{
...(props as any).sx,
}}
>
<ListItemButton
onClick={onClick}
disabled={disabled}
selected={active}
dense={dense}
href={href}
sx={{
borderRadius: 1,
mx: 0.5,
my: 0.25,
'&.Mui-selected': {
bgcolor: 'action.selected',
'&:hover': {
bgcolor: 'action.hover',
},
},
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
{icon && (
<ListItemIcon
sx={{
minWidth: 40,
color: active ? 'primary.main' : 'text.secondary',
}}
>
{badge !== undefined ? (
<Badge
badgeContent={badge}
color={badgeColor}
sx={{
'& .MuiBadge-badge': {
fontSize: '0.625rem',
height: 16,
minWidth: 16,
padding: '0 4px',
},
}}
>
{icon}
</Badge>
) : (
icon
)}
</ListItemIcon>
)}
<ListItemText
primary={label}
secondary={secondaryLabel}
primaryTypographyProps={{
variant: 'body2',
fontWeight: active ? 600 : 400,
color: active ? 'primary.main' : 'text.primary',
}}
secondaryTypographyProps={{
variant: 'caption',
}}
/>
{badge !== undefined && !icon && (
<Box sx={{ ml: 1 }}>
<Badge
badgeContent={badge}
color={badgeColor}
sx={{
'& .MuiBadge-badge': {
position: 'static',
transform: 'none',
},
}}
/>
</Box>
)}
</ListItemButton>
</ListItem>
)
}
)
NavItem.displayName = 'NavItem'
export { NavItem }

View File

@@ -0,0 +1,61 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { NavLink } from './NavLink'
import HomeIcon from '@mui/icons-material/Home'
describe('NavLink', () => {
it.each([
{ href: '/home', children: 'Home', active: false },
{ href: '/dashboard', children: 'Dashboard', active: true },
{ href: '/settings', children: 'Settings', active: false },
])('renders with href="$href", children="$children", active=$active', ({ href, children, active }) => {
render(<NavLink href={href} active={active}>{children}</NavLink>)
const link = screen.getByText(children)
expect(link).toBeTruthy()
const linkElement = link.closest('a')
expect(linkElement?.getAttribute('href')).toBe(href)
})
it('renders with icon', () => {
const { container } = render(
<NavLink href="/home" icon={<HomeIcon data-testid="home-icon" />}>
Home
</NavLink>
)
expect(screen.getByTestId('home-icon')).toBeTruthy()
})
it.each([
{ disabled: true, href: '/home' },
{ disabled: false, href: '/dashboard' },
])('handles disabled=$disabled state', ({ disabled, href }) => {
render(<NavLink href={href} disabled={disabled}>Link</NavLink>)
const link = screen.getByText('Link').closest('a')
if (disabled) {
expect(link?.hasAttribute('href')).toBe(false)
} else {
expect(link?.getAttribute('href')).toBe(href)
}
})
it('applies active styling when active=true', () => {
render(<NavLink href="/home" active>Home</NavLink>)
const link = screen.getByText('Home').closest('a')
// Check for active styling - MUI applies specific classes
expect(link).toBeTruthy()
})
it('does not have underline by default', () => {
render(<NavLink href="/home">Home</NavLink>)
const link = screen.getByText('Home').closest('a')
// MUI Link with underline="none" doesn't add text-decoration
expect(link).toBeTruthy()
})
})

View File

@@ -0,0 +1,72 @@
'use client'
import { forwardRef, ReactNode } from 'react'
import { Link as MuiLink, LinkProps as MuiLinkProps, Box } from '@mui/material'
export interface NavLinkProps extends Omit<MuiLinkProps, 'component'> {
href: string
active?: boolean
disabled?: boolean
children: ReactNode
icon?: ReactNode
className?: string
}
const NavLink = forwardRef<HTMLAnchorElement, NavLinkProps>(
({ href, active = false, disabled = false, children, icon, sx, ...props }, ref) => {
return (
<MuiLink
ref={ref}
href={disabled ? undefined : href}
underline="none"
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1,
borderRadius: 1,
fontSize: '0.875rem',
fontWeight: active ? 600 : 500,
color: active ? 'primary.main' : 'text.primary',
bgcolor: active ? 'action.selected' : 'transparent',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
transition: 'background-color 0.2s, color 0.2s',
'&:hover': disabled
? {}
: {
bgcolor: active ? 'action.selected' : 'action.hover',
color: active ? 'primary.main' : 'text.primary',
},
'&:focus-visible': {
outline: '2px solid',
outlineColor: 'primary.main',
outlineOffset: 2,
},
...sx,
}}
{...props}
>
{icon && (
<Box
component="span"
sx={{
display: 'flex',
alignItems: 'center',
fontSize: '1.25rem',
color: active ? 'primary.main' : 'text.secondary',
}}
>
{icon}
</Box>
)}
{children}
</MuiLink>
)
}
)
NavLink.displayName = 'NavLink'
export { NavLink }

View File

@@ -320,8 +320,9 @@ DataConsistency ==
\* Package consistency: installed packages must be in installed or disabled state
PackageConsistency ==
\A t \in Tenants, p \in installedPackages[t]:
packageStates[p] \in {"installed", "disabled", "installing"}
\A t \in Tenants:
\A p \in installedPackages[t]:
packageStates[p] \in {"installed", "disabled", "installing"}
\* DBAL safety: no queries processed in error state
DBALSafety ==