docs: nextjs,frontends,ui (11 files)

This commit is contained in:
Richard Ward
2025-12-30 13:00:42 +00:00
parent 56ed528b4e
commit 8fc4991ae9
11 changed files with 379 additions and 38 deletions

View File

@@ -0,0 +1,30 @@
import React from 'react'
export interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement> {
children?: React.ReactNode
text?: string
level?: 1 | 2 | 3 | 4 | 5 | 6
variant?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
}
/**
* Heading component for section titles
* Maps to Typography variant="h1-h6" or can be used standalone
*/
export const Heading: React.FC<HeadingProps> = ({
children,
text,
level = 2,
variant,
className = '',
...props
}) => {
const headingLevel = variant ? parseInt(variant.slice(1)) : level
const Tag = `h${headingLevel}` as keyof JSX.IntrinsicElements
return (
<Tag className={`heading heading--level-${headingLevel} ${className}`} {...props}>
{text || children}
</Tag>
)
}

View File

@@ -1,28 +1,135 @@
import React, { forwardRef } from 'react'
/**
* Valid button variants for styling
*/
export type ButtonVariant = 'default' | 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'text'
/**
* Valid button sizes
*/
export type ButtonSize = 'sm' | 'md' | 'lg'
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children?: React.ReactNode
/** Button style variant */
variant?: ButtonVariant
/** Button size */
size?: ButtonSize
/** @deprecated Use variant="primary" instead */
primary?: boolean
/** @deprecated Use variant="secondary" instead */
secondary?: boolean
/** @deprecated Use variant="outline" instead */
outline?: boolean
/** @deprecated Use variant="ghost" instead */
ghost?: boolean
/** @deprecated Use size="sm" instead */
sm?: boolean
/** @deprecated Use size="lg" instead */
lg?: boolean
/** Icon-only button styling */
icon?: boolean
/** Show loading spinner and disable */
loading?: boolean
/** Full width button */
fullWidth?: boolean
/** Start icon element */
startIcon?: React.ReactNode
/** End icon element */
endIcon?: React.ReactNode
}
/**
* Get variant class from props (supports legacy and new API)
*/
const getVariantClass = (props: ButtonProps): string => {
if (props.variant) return `btn--${props.variant}`
if (props.primary) return 'btn--primary'
if (props.secondary) return 'btn--secondary'
if (props.outline) return 'btn--outline'
if (props.ghost) return 'btn--ghost'
return ''
}
/**
* Get size class from props (supports legacy and new API)
*/
const getSizeClass = (props: ButtonProps): string => {
if (props.size) return `btn--${props.size}`
if (props.sm) return 'btn--sm'
if (props.lg) return 'btn--lg'
return ''
}
/**
* Button component with Material-UI inspired styling
*
* @example
* ```tsx
* <Button variant="primary" size="md">Click me</Button>
* <Button variant="outline" startIcon={<Plus />}>Add Item</Button>
* <Button loading>Saving...</Button>
* ```
*/
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ children, primary, secondary, outline, ghost, sm, lg, icon, loading, disabled, className = '', ...props }, ref) => (
<button
ref={ref}
className={`btn ${primary ? 'btn--primary' : ''} ${secondary ? 'btn--secondary' : ''} ${outline ? 'btn--outline' : ''} ${ghost ? 'btn--ghost' : ''} ${sm ? 'btn--sm' : ''} ${lg ? 'btn--lg' : ''} ${icon ? 'btn--icon' : ''} ${loading ? 'btn--loading' : ''} ${className}`}
disabled={disabled || loading}
{...props}
>
{children}
</button>
)
(props, ref) => {
const {
children,
variant,
size,
primary,
secondary,
outline,
ghost,
sm,
lg,
icon,
loading,
fullWidth,
startIcon,
endIcon,
disabled,
className = '',
type = 'button',
'aria-busy': ariaBusy,
...restProps
} = props
const classes = [
'btn',
getVariantClass(props),
getSizeClass(props),
icon ? 'btn--icon' : '',
loading ? 'btn--loading' : '',
fullWidth ? 'btn--full-width' : '',
className,
].filter(Boolean).join(' ')
return (
<button
ref={ref}
type={type}
className={classes}
disabled={disabled || loading}
aria-busy={ariaBusy ?? loading}
aria-disabled={disabled || loading}
{...restProps}
>
{loading && (
<span className="btn__spinner" aria-hidden="true">
<svg className="btn__spinner-icon" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</span>
)}
{startIcon && <span className="btn__start-icon" aria-hidden="true">{startIcon}</span>}
{children && <span className="btn__content">{children}</span>}
{endIcon && <span className="btn__end-icon" aria-hidden="true">{endIcon}</span>}
</button>
)
}
)
Button.displayName = 'Button'

View File

@@ -26,7 +26,7 @@ Atoms are the smallest, indivisible UI elements in the MetaBuilder component lib
| `Badge` | Status indicator chip | `Chip` |
| `Avatar` | User/entity image with fallback | `MuiAvatar` |
| `IconButton` | Icon-only button | `MuiIconButton` |
| `Icon` | Icon wrapper for MUI icons | `@mui/icons-material` |
| `Icon` | Icon wrapper for fakemui icons | `@/fakemui/icons` |
| `Link` | Navigation link with Next.js integration | `MuiLink` + `NextLink` |
| `Text` | Typography with weight/alignment options | `Typography` |

View File

@@ -7,15 +7,15 @@ describe('Icon', () => {
it.each([
{ name: 'Home', size: 'small' },
{ name: 'Settings', size: 'medium' },
{ name: 'Delete', size: 'large' },
{ name: 'Add', size: 'inherit' },
{ name: 'Trash', size: 'large' },
{ name: 'Plus', size: 'inherit' },
] as const)('renders icon $name with size $size', ({ name, size }) => {
const { container } = render(<Icon name={name} size={size} />)
expect(container.querySelector('svg')).not.toBeNull()
})
it('applies custom sx styles', () => {
const { container } = render(<Icon name="Home" sx={{ color: 'primary.main' }} />)
const { container } = render(<Icon name="Home" sx={{ color: 'blue' }} />)
expect(container.querySelector('svg')).not.toBeNull()
})

View File

@@ -1,40 +1,44 @@
'use client'
import * as MuiIcons from '@mui/icons-material'
import { SvgIconProps } from '@mui/material'
import { forwardRef } from 'react'
import * as FakeMuiIcons from '@/fakemui/icons'
import { CSSProperties, forwardRef } from 'react'
export type IconName = keyof typeof MuiIcons
// Create a type from the available fakemui icons
export type IconName = keyof typeof FakeMuiIcons
export type IconSize = 'small' | 'medium' | 'large' | 'inherit'
export interface IconProps extends Omit<SvgIconProps, 'fontSize'> {
export interface IconProps {
name: IconName
size?: IconSize
sx?: CSSProperties & Record<string, unknown>
className?: string
style?: CSSProperties
}
const sizeMap = {
small: 20,
medium: 24,
large: 32,
inherit: undefined,
}
const Icon = forwardRef<SVGSVGElement, IconProps>(
({ name, size = 'medium', sx, ...props }, ref) => {
const IconComponent = MuiIcons[name]
({ name, size = 'medium', sx, className, style, ...props }, ref) => {
const IconComponent = FakeMuiIcons[name]
if (!IconComponent) {
console.warn(`Icon "${name}" not found in @mui/icons-material`)
if (!IconComponent || typeof IconComponent !== 'function') {
console.warn(`Icon "${name}" not found in @/fakemui/icons`)
return null
}
const fontSizeMap = {
small: 20,
medium: 24,
large: 32,
inherit: 'inherit' as const,
}
const sizeValue = sizeMap[size]
const combinedStyle = { ...style, ...sx }
return (
<IconComponent
ref={ref}
sx={{
fontSize: fontSizeMap[size],
...sx,
}}
size={sizeValue}
style={combinedStyle}
className={className}
{...props}
/>
)

View File

@@ -37,9 +37,11 @@ import { Button, Input } from '@/components/ui/atoms'
import { Card, Select } from '@/components/ui/molecules'
import { Table, Form } from '@/components/ui/organisms'
// Direct MUI imports are also fine
// Direct MUI imports are also fine for layout/typography
import { Box, Typography, Grid } from '@mui/material'
import { Add as AddIcon } from '@mui/icons-material'
// Use fakemui for icons
import { Plus, Trash, Settings } from '@/fakemui/icons'
```
## Atomic Design Pattern

View File

@@ -7,3 +7,13 @@ export {
type PageDefinition,
PageRenderer,
} from './page-renderer'
// Component registry for Lua to React mapping
export {
componentRegistry,
getComponentByType,
hasComponent,
getRegisteredComponentTypes,
registerComponent,
type LuaComponentProps,
} from './component-registry'

View File

@@ -28,11 +28,13 @@
"tests": {
"scripts": [
"tests/metadata.test.lua",
"tests/components.test.lua"
"tests/components.test.lua",
"tests/transfer.test.lua"
],
"cases": [
"tests/metadata.cases.json",
"tests/components.cases.json"
"tests/components.cases.json",
"tests/transfer.cases.json"
]
},
"minLevel": 5

View File

@@ -0,0 +1,43 @@
{
"initiateTransfer": {
"authorized": [
{ "userLevel": 6, "tenantId": "tenant_001", "description": "supergod can initiate transfer" }
],
"unauthorized": [
{ "userLevel": 1, "description": "public cannot initiate" },
{ "userLevel": 2, "description": "user cannot initiate" },
{ "userLevel": 3, "description": "moderator cannot initiate" },
{ "userLevel": 4, "description": "admin cannot initiate" },
{ "userLevel": 5, "description": "god cannot initiate" }
]
},
"confirmTransfer": {
"valid": [
{ "tenantId": "tenant_001", "targetUserId": "new_owner_1", "description": "transfer to new owner" },
{ "tenantId": "tenant_002", "targetUserId": "admin_user", "description": "transfer to admin" }
],
"missingTarget": [
{ "tenantId": "tenant_001", "description": "missing target user" }
],
"unauthorized": [
{ "userLevel": 1, "description": "public cannot confirm" },
{ "userLevel": 2, "description": "user cannot confirm" },
{ "userLevel": 3, "description": "moderator cannot confirm" },
{ "userLevel": 4, "description": "admin cannot confirm" },
{ "userLevel": 5, "description": "god cannot confirm" }
]
},
"assignGod": {
"authorized": [
{ "targetUserId": "user_123", "description": "assign god to user" },
{ "targetUserId": "admin_456", "description": "promote admin to god" }
],
"unauthorized": [
{ "userLevel": 1, "description": "public cannot assign god" },
{ "userLevel": 2, "description": "user cannot assign god" },
{ "userLevel": 3, "description": "moderator cannot assign god" },
{ "userLevel": 4, "description": "admin cannot assign god" },
{ "userLevel": 5, "description": "god cannot assign god" }
]
}
}

View File

@@ -0,0 +1,133 @@
-- Transfer Tests for ui_level5
-- Parameterized tests for god-level transfer functions
local describe = require("lua_test.describe")
local it = require("lua_test.it")
local it_each = require("lua_test.it_each")
local expect = require("lua_test.expect")
local beforeEach = require("lua_test.beforeEach")
local mock = require("lua_test.mock")
local cases = require("tests.transfer.cases")
local initiateTransfer = require("transfer.initiate_transfer")
local confirmTransfer = require("transfer.confirm_transfer")
local assignGod = require("transfer.assign_god")
describe("transfer (level5)", function()
-- Mock permission system and levels
beforeEach(function()
_G.LEVELS = {
PUBLIC = 1,
USER = 2,
MODERATOR = 3,
ADMIN = 4,
GOD = 5,
SUPERGOD = 6
}
_G.check = {
can_access = mock.fn(function(user, level)
return user.level and user.level >= level
end)
}
end)
describe("initiateTransfer", function()
describe("with supergod permissions", function()
it_each(cases.initiateTransfer.authorized, "$description", function(case)
local user = { id = "god_1", level = case.userLevel }
local ctx = { user = user, tenantId = case.tenantId }
local result = initiateTransfer.initiateTransfer(ctx)
expect(result.success).toBe(true)
expect(result.action).toBe("open_transfer_dialog")
expect(result.tenantId).toBe(case.tenantId)
end)
end)
describe("without supergod permissions", function()
it_each(cases.initiateTransfer.unauthorized, "$description (level $userLevel)", function(case)
local user = { id = "user_1", level = case.userLevel }
local ctx = { user = user, tenantId = "tenant_1" }
_G.check.can_access = mock.fn(function(u, l)
return u.level >= l
end)
local result = initiateTransfer.initiateTransfer(ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("Supergod required")
end)
end)
end)
describe("confirmTransfer", function()
describe("with valid parameters", function()
it_each(cases.confirmTransfer.valid, "$description", function(case)
local user = { id = "supergod_1", level = 6 }
local ctx = { user = user, tenantId = case.tenantId, targetUserId = case.targetUserId }
local result = confirmTransfer.confirmTransfer(ctx)
expect(result.success).toBe(true)
expect(result.action).toBe("transfer_ownership")
expect(result.targetUserId).toBe(case.targetUserId)
end)
end)
describe("missing target user", function()
it_each(cases.confirmTransfer.missingTarget, "$description", function(case)
local user = { id = "supergod_1", level = 6 }
local ctx = { user = user, tenantId = case.tenantId, targetUserId = nil }
local result = confirmTransfer.confirmTransfer(ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("Target user required")
end)
end)
describe("unauthorized", function()
it_each(cases.confirmTransfer.unauthorized, "$description", function(case)
local user = { id = "user_1", level = case.userLevel }
local ctx = { user = user, tenantId = "tenant_1", targetUserId = "user_2" }
_G.check.can_access = mock.fn(function(u, l)
return u.level >= l
end)
local result = confirmTransfer.confirmTransfer(ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("Supergod required")
end)
end)
end)
describe("assignGod", function()
describe("with supergod permissions", function()
it_each(cases.assignGod.authorized, "$description", function(case)
local user = { id = "supergod_1", level = 6 }
local ctx = { user = user, userId = case.targetUserId }
local result = assignGod.assignGod(ctx)
expect(result.success).toBe(true)
expect(result.action).toBe("assign_god")
expect(result.userId).toBe(case.targetUserId)
end)
end)
describe("without supergod permissions", function()
it_each(cases.assignGod.unauthorized, "$description", function(case)
local user = { id = "user_1", level = case.userLevel }
local ctx = { user = user, userId = "target_1" }
_G.check.can_access = mock.fn(function(u, l)
return u.level >= l
end)
local result = assignGod.assignGod(ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("Supergod required")
end)
end)
end)
end)
return {
name = "transfer.test",
description = "Tests for god-level transfer functions"
}

View File

@@ -15,5 +15,15 @@
"pages": ["level6"],
"scripts": ["layout", "tenants", "transfer", "system"],
"components": []
},
"tests": {
"scripts": [
"tests/metadata.test.lua",
"tests/components.test.lua",
"tests/transfer.test.lua"
],
"cases": [
"tests/transfer.cases.json"
]
}
}