diff --git a/packages/components/package.json b/packages/components/package.json index 018adb25e..ad9389bbf 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -50,6 +50,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^0.469.0", diff --git a/packages/components/src/new-components.test.ts b/packages/components/src/new-components.test.ts new file mode 100644 index 000000000..da8061541 --- /dev/null +++ b/packages/components/src/new-components.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { ComponentRegistry } from '@object-ui/core'; + +describe('New Components Registration', () => { + // Import all renderers to register them + beforeAll(async () => { + await import('./renderers'); + }); + + describe('Form Components', () => { + it('should register date-picker component', () => { + const component = ComponentRegistry.getConfig('date-picker'); + expect(component).toBeDefined(); + expect(component?.label).toBe('Date Picker'); + }); + + it('should register file-upload component', () => { + const component = ComponentRegistry.getConfig('file-upload'); + expect(component).toBeDefined(); + expect(component?.label).toBe('File Upload'); + }); + }); + + describe('Data Display Components', () => { + it('should register list component', () => { + const component = ComponentRegistry.getConfig('list'); + expect(component).toBeDefined(); + expect(component?.label).toBe('List'); + }); + + it('should register tree-view component', () => { + const component = ComponentRegistry.getConfig('tree-view'); + expect(component).toBeDefined(); + expect(component?.label).toBe('Tree View'); + }); + }); + + describe('Layout Components', () => { + it('should register grid component', () => { + const component = ComponentRegistry.getConfig('grid'); + expect(component).toBeDefined(); + expect(component?.label).toBe('Grid Layout'); + }); + + it('should register flex component', () => { + const component = ComponentRegistry.getConfig('flex'); + expect(component).toBeDefined(); + expect(component?.label).toBe('Flex Layout'); + }); + + it('should register container component', () => { + const component = ComponentRegistry.getConfig('container'); + expect(component).toBeDefined(); + expect(component?.label).toBe('Container'); + }); + }); + + describe('Feedback Components', () => { + it('should register loading component', () => { + const component = ComponentRegistry.getConfig('loading'); + expect(component).toBeDefined(); + expect(component?.label).toBe('Loading'); + }); + }); +}); diff --git a/packages/components/src/renderers/data-display/index.ts b/packages/components/src/renderers/data-display/index.ts index 4209fd719..7b1a3bb0c 100644 --- a/packages/components/src/renderers/data-display/index.ts +++ b/packages/components/src/renderers/data-display/index.ts @@ -2,3 +2,5 @@ import './badge'; import './avatar'; import './alert'; import './chart'; +import './list'; +import './tree-view'; diff --git a/packages/components/src/renderers/data-display/list.tsx b/packages/components/src/renderers/data-display/list.tsx new file mode 100644 index 000000000..08bd0fd5b --- /dev/null +++ b/packages/components/src/renderers/data-display/list.tsx @@ -0,0 +1,55 @@ +import { ComponentRegistry } from '@object-ui/core'; +import { renderChildren } from '../../lib/utils'; +import { cn } from '@/lib/utils'; + +ComponentRegistry.register('list', + ({ schema, className, ...props }) => { + const ListTag = schema.ordered ? 'ol' : 'ul'; + + return ( +
+ {schema.title && ( +

{schema.title}

+ )} + + {schema.items?.map((item: any, index: number) => ( +
  • + {typeof item === 'string' ? item : item.content || renderChildren(item.body)} +
  • + ))} +
    +
    + ); + }, + { + label: 'List', + inputs: [ + { name: 'title', type: 'string', label: 'Title' }, + { name: 'ordered', type: 'boolean', label: 'Ordered List (numbered)', defaultValue: false }, + { + name: 'items', + type: 'array', + label: 'List Items', + description: 'Array of strings or objects with content/body' + }, + { name: 'className', type: 'string', label: 'CSS Class' } + ], + defaultProps: { + ordered: false, + items: [ + 'First item', + 'Second item', + 'Third item' + ], + className: 'text-sm' + } + } +); diff --git a/packages/components/src/renderers/data-display/tree-view.tsx b/packages/components/src/renderers/data-display/tree-view.tsx new file mode 100644 index 000000000..732ba04de --- /dev/null +++ b/packages/components/src/renderers/data-display/tree-view.tsx @@ -0,0 +1,152 @@ +import { ComponentRegistry } from '@object-ui/core'; +import { ChevronRight, ChevronDown, Folder, File } from 'lucide-react'; +import { useState } from 'react'; +import { cn } from '@/lib/utils'; + +interface TreeNode { + id: string; + label: string; + icon?: string; + children?: TreeNode[]; + data?: any; +} + +const TreeNodeComponent = ({ + node, + level = 0, + onNodeClick +}: { + node: TreeNode; + level?: number; + onNodeClick?: (node: TreeNode) => void; +}) => { + const [isOpen, setIsOpen] = useState(false); + const hasChildren = node.children && node.children.length > 0; + + const handleToggle = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsOpen(!isOpen); + }; + + const handleClick = () => { + if (onNodeClick) { + onNodeClick(node); + } + }; + + return ( +
    +
    + {hasChildren ? ( + + ) : ( + + )} + {node.icon === 'folder' ? ( + + ) : node.icon === 'file' ? ( + + ) : null} + {node.label} +
    + {hasChildren && isOpen && ( +
    + {node.children!.map((child) => ( + + ))} +
    + )} +
    + ); +}; + +ComponentRegistry.register('tree-view', + ({ schema, className, ...props }) => { + const handleNodeClick = (node: TreeNode) => { + if (schema.onNodeClick) { + schema.onNodeClick(node); + } + }; + + return ( +
    + {schema.title && ( +

    {schema.title}

    + )} +
    + {schema.nodes?.map((node: TreeNode) => ( + + ))} +
    +
    + ); + }, + { + label: 'Tree View', + inputs: [ + { name: 'title', type: 'string', label: 'Title' }, + { + name: 'nodes', + type: 'array', + label: 'Tree Nodes', + description: 'Array of { id, label, icon, children, data }' + }, + { name: 'className', type: 'string', label: 'CSS Class' } + ], + defaultProps: { + title: 'File Explorer', + nodes: [ + { + id: '1', + label: 'Documents', + icon: 'folder', + children: [ + { id: '1-1', label: 'Resume.pdf', icon: 'file' }, + { id: '1-2', label: 'Cover Letter.docx', icon: 'file' } + ] + }, + { + id: '2', + label: 'Photos', + icon: 'folder', + children: [ + { id: '2-1', label: 'Vacation', icon: 'folder', children: [ + { id: '2-1-1', label: 'Beach.jpg', icon: 'file' } + ]}, + { id: '2-2', label: 'Family.jpg', icon: 'file' } + ] + }, + { + id: '3', + label: 'README.md', + icon: 'file' + } + ] + } + } +); diff --git a/packages/components/src/renderers/feedback/index.ts b/packages/components/src/renderers/feedback/index.ts index 93f2a701e..eedbfddd0 100644 --- a/packages/components/src/renderers/feedback/index.ts +++ b/packages/components/src/renderers/feedback/index.ts @@ -1,3 +1,4 @@ import './progress'; import './skeleton'; import './toaster'; +import './loading'; diff --git a/packages/components/src/renderers/feedback/loading.tsx b/packages/components/src/renderers/feedback/loading.tsx new file mode 100644 index 000000000..45c91f048 --- /dev/null +++ b/packages/components/src/renderers/feedback/loading.tsx @@ -0,0 +1,68 @@ +import { ComponentRegistry } from '@object-ui/core'; +import { Spinner } from '@/ui'; +import { cn } from '@/lib/utils'; + +ComponentRegistry.register('loading', + ({ schema, className, ...props }) => { + const size = schema.size || 'md'; + const fullscreen = schema.fullscreen || false; + + const loadingContent = ( +
    + + {schema.text && ( +

    {schema.text}

    + )} +
    + ); + + if (fullscreen) { + return ( +
    + {loadingContent} +
    + ); + } + + return ( +
    + {loadingContent} +
    + ); + }, + { + label: 'Loading', + inputs: [ + { name: 'text', type: 'string', label: 'Loading Text' }, + { + name: 'size', + type: 'enum', + enum: ['sm', 'md', 'lg', 'xl'], + label: 'Size', + defaultValue: 'md' + }, + { + name: 'fullscreen', + type: 'boolean', + label: 'Fullscreen Overlay', + defaultValue: false + }, + { name: 'className', type: 'string', label: 'CSS Class' } + ], + defaultProps: { + text: 'Loading...', + size: 'md', + fullscreen: false + } + } +); diff --git a/packages/components/src/renderers/form/date-picker.tsx b/packages/components/src/renderers/form/date-picker.tsx new file mode 100644 index 000000000..1864c8530 --- /dev/null +++ b/packages/components/src/renderers/form/date-picker.tsx @@ -0,0 +1,61 @@ +import { ComponentRegistry } from '@object-ui/core'; +import { Calendar, Button, Popover, PopoverTrigger, PopoverContent, Label } from '@/ui'; +import { CalendarIcon } from 'lucide-react'; +import { format } from 'date-fns'; +import { cn } from '@/lib/utils'; + +ComponentRegistry.register('date-picker', + ({ schema, className, value, onChange, ...props }) => { + const handleSelect = (date: Date | undefined) => { + if (onChange) { + onChange(date); + } + }; + + return ( +
    + {schema.label && } + + + + + + + + +
    + ); + }, + { + label: 'Date Picker', + inputs: [ + { name: 'label', type: 'string', label: 'Label' }, + { name: 'placeholder', type: 'string', label: 'Placeholder' }, + { name: 'format', type: 'string', label: 'Date Format', description: 'date-fns format string (e.g., "PPP", "yyyy-MM-dd")' }, + { name: 'id', type: 'string', label: 'ID', required: true } + ], + defaultProps: { + label: 'Date', + placeholder: 'Pick a date', + format: 'PPP', + id: 'date-picker-field' + } + } +); diff --git a/packages/components/src/renderers/form/file-upload.tsx b/packages/components/src/renderers/form/file-upload.tsx new file mode 100644 index 000000000..d182f2694 --- /dev/null +++ b/packages/components/src/renderers/form/file-upload.tsx @@ -0,0 +1,97 @@ +import { ComponentRegistry } from '@object-ui/core'; +import { Label, Button } from '@/ui'; +import { Upload, X } from 'lucide-react'; +import { useState, useRef } from 'react'; +import { cn } from '@/lib/utils'; + +ComponentRegistry.register('file-upload', + ({ schema, className, value, onChange, ...props }) => { + const [files, setFiles] = useState(value || []); + const inputRef = useRef(null); + + const handleFileChange = (e: React.ChangeEvent) => { + const newFiles = Array.from(e.target.files || []); + const updatedFiles = schema.multiple ? [...files, ...newFiles] : newFiles; + setFiles(updatedFiles); + if (onChange) { + onChange(updatedFiles); + } + }; + + const handleRemoveFile = (index: number) => { + const updatedFiles = files.filter((_, i) => i !== index); + setFiles(updatedFiles); + if (onChange) { + onChange(updatedFiles); + } + }; + + const handleClick = () => { + inputRef.current?.click(); + }; + + return ( +
    + {schema.label && } +
    + + + {files.length > 0 && ( +
    + {files.map((file, index) => ( +
    + {file.name} + +
    + ))} +
    + )} +
    +
    + ); + }, + { + label: 'File Upload', + inputs: [ + { name: 'label', type: 'string', label: 'Label' }, + { name: 'buttonText', type: 'string', label: 'Button Text' }, + { name: 'accept', type: 'string', label: 'Accepted File Types', description: 'MIME types (e.g., "image/*,application/pdf")' }, + { name: 'multiple', type: 'boolean', label: 'Allow Multiple Files' }, + { name: 'id', type: 'string', label: 'ID', required: true } + ], + defaultProps: { + label: 'Upload files', + buttonText: 'Choose files', + multiple: true, + id: 'file-upload-field' + } + } +); diff --git a/packages/components/src/renderers/form/index.ts b/packages/components/src/renderers/form/index.ts index 5d068db89..bdd27fce8 100644 --- a/packages/components/src/renderers/form/index.ts +++ b/packages/components/src/renderers/form/index.ts @@ -9,3 +9,5 @@ import './slider'; import './toggle'; import './input-otp'; import './calendar'; +import './date-picker'; +import './file-upload'; diff --git a/packages/components/src/renderers/layout/container.tsx b/packages/components/src/renderers/layout/container.tsx new file mode 100644 index 000000000..3e2846920 --- /dev/null +++ b/packages/components/src/renderers/layout/container.tsx @@ -0,0 +1,85 @@ +import { ComponentRegistry } from '@object-ui/core'; +import { renderChildren } from '../../lib/utils'; +import { cn } from '@/lib/utils'; + +ComponentRegistry.register('container', + ({ schema, className, ...props }) => { + const maxWidth = schema.maxWidth || 'xl'; + const padding = schema.padding || 4; + const centered = schema.centered !== false; // Default to true + + const containerClass = cn( + // Base container + 'w-full', + // Max width + maxWidth === 'sm' && 'max-w-sm', + maxWidth === 'md' && 'max-w-md', + maxWidth === 'lg' && 'max-w-lg', + maxWidth === 'xl' && 'max-w-xl', + maxWidth === '2xl' && 'max-w-2xl', + maxWidth === '3xl' && 'max-w-3xl', + maxWidth === '4xl' && 'max-w-4xl', + maxWidth === '5xl' && 'max-w-5xl', + maxWidth === '6xl' && 'max-w-6xl', + maxWidth === '7xl' && 'max-w-7xl', + maxWidth === 'full' && 'max-w-full', + maxWidth === 'screen' && 'max-w-screen-2xl', + // Centering + centered && 'mx-auto', + // Padding + padding === 0 && 'p-0', + padding === 1 && 'p-1', + padding === 2 && 'p-2', + padding === 3 && 'p-3', + padding === 4 && 'p-4', + padding === 5 && 'p-5', + padding === 6 && 'p-6', + padding === 7 && 'p-7', + padding === 8 && 'p-8', + padding === 10 && 'p-10', + padding === 12 && 'p-12', + padding === 16 && 'p-16', + className + ); + + return ( +
    + {schema.children && renderChildren(schema.children)} +
    + ); + }, + { + label: 'Container', + inputs: [ + { + name: 'maxWidth', + type: 'enum', + enum: ['sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl', 'full', 'screen'], + label: 'Max Width', + defaultValue: 'xl' + }, + { + name: 'padding', + type: 'number', + label: 'Padding', + defaultValue: 4, + description: 'Padding value (0, 1-8, 10, 12, 16)' + }, + { + name: 'centered', + type: 'boolean', + label: 'Center Horizontally', + defaultValue: true + }, + { name: 'className', type: 'string', label: 'CSS Class' } + ], + defaultProps: { + maxWidth: 'xl', + padding: 4, + centered: true, + children: [ + { type: 'text', content: 'Container content goes here' } + ] + } + } +); diff --git a/packages/components/src/renderers/layout/flex.tsx b/packages/components/src/renderers/layout/flex.tsx new file mode 100644 index 000000000..c905d86eb --- /dev/null +++ b/packages/components/src/renderers/layout/flex.tsx @@ -0,0 +1,107 @@ +import { ComponentRegistry } from '@object-ui/core'; +import { renderChildren } from '../../lib/utils'; +import { cn } from '@/lib/utils'; + +ComponentRegistry.register('flex', + ({ schema, className, ...props }) => { + const direction = schema.direction || 'row'; + const justify = schema.justify || 'start'; + const align = schema.align || 'start'; + const gap = schema.gap || 2; + const wrap = schema.wrap || false; + + const flexClass = cn( + 'flex', + // Direction + direction === 'row' && 'flex-row', + direction === 'col' && 'flex-col', + direction === 'row-reverse' && 'flex-row-reverse', + direction === 'col-reverse' && 'flex-col-reverse', + // Justify content + justify === 'start' && 'justify-start', + justify === 'end' && 'justify-end', + justify === 'center' && 'justify-center', + justify === 'between' && 'justify-between', + justify === 'around' && 'justify-around', + justify === 'evenly' && 'justify-evenly', + // Align items + align === 'start' && 'items-start', + align === 'end' && 'items-end', + align === 'center' && 'items-center', + align === 'baseline' && 'items-baseline', + align === 'stretch' && 'items-stretch', + // Gap + gap === 0 && 'gap-0', + gap === 1 && 'gap-1', + gap === 2 && 'gap-2', + gap === 3 && 'gap-3', + gap === 4 && 'gap-4', + gap === 5 && 'gap-5', + gap === 6 && 'gap-6', + gap === 7 && 'gap-7', + gap === 8 && 'gap-8', + // Wrap + wrap && 'flex-wrap', + className + ); + + return ( +
    + {schema.children && renderChildren(schema.children)} +
    + ); + }, + { + label: 'Flex Layout', + inputs: [ + { + name: 'direction', + type: 'enum', + enum: ['row', 'col', 'row-reverse', 'col-reverse'], + label: 'Direction', + defaultValue: 'row' + }, + { + name: 'justify', + type: 'enum', + enum: ['start', 'end', 'center', 'between', 'around', 'evenly'], + label: 'Justify Content', + defaultValue: 'start' + }, + { + name: 'align', + type: 'enum', + enum: ['start', 'end', 'center', 'baseline', 'stretch'], + label: 'Align Items', + defaultValue: 'start' + }, + { + name: 'gap', + type: 'number', + label: 'Gap', + defaultValue: 2, + description: 'Gap between items (0-8)' + }, + { + name: 'wrap', + type: 'boolean', + label: 'Wrap', + defaultValue: false, + description: 'Allow flex items to wrap' + }, + { name: 'className', type: 'string', label: 'CSS Class' } + ], + defaultProps: { + direction: 'row', + justify: 'start', + align: 'center', + gap: 2, + wrap: false, + children: [ + { type: 'button', label: 'Button 1' }, + { type: 'button', label: 'Button 2' }, + { type: 'button', label: 'Button 3' } + ] + } + } +); diff --git a/packages/components/src/renderers/layout/grid.tsx b/packages/components/src/renderers/layout/grid.tsx new file mode 100644 index 000000000..d74dbc5c7 --- /dev/null +++ b/packages/components/src/renderers/layout/grid.tsx @@ -0,0 +1,90 @@ +import { ComponentRegistry } from '@object-ui/core'; +import { renderChildren } from '../../lib/utils'; +import { cn } from '@/lib/utils'; + +ComponentRegistry.register('grid', + ({ schema, className, ...props }) => { + const gridCols = schema.columns || 2; + const gap = schema.gap || 4; + + // Generate Tailwind grid classes + const gridClass = cn( + 'grid', + // Grid columns classes + gridCols === 1 && 'grid-cols-1', + gridCols === 2 && 'grid-cols-2', + gridCols === 3 && 'grid-cols-3', + gridCols === 4 && 'grid-cols-4', + gridCols === 5 && 'grid-cols-5', + gridCols === 6 && 'grid-cols-6', + gridCols === 7 && 'grid-cols-7', + gridCols === 8 && 'grid-cols-8', + gridCols === 9 && 'grid-cols-9', + gridCols === 10 && 'grid-cols-10', + gridCols === 11 && 'grid-cols-11', + gridCols === 12 && 'grid-cols-12', + // Gap classes + gap === 0 && 'gap-0', + gap === 1 && 'gap-1', + gap === 2 && 'gap-2', + gap === 3 && 'gap-3', + gap === 4 && 'gap-4', + gap === 5 && 'gap-5', + gap === 6 && 'gap-6', + gap === 7 && 'gap-7', + gap === 8 && 'gap-8', + // Responsive columns + schema.mdColumns && `md:grid-cols-${schema.mdColumns}`, + schema.lgColumns && `lg:grid-cols-${schema.lgColumns}`, + className + ); + + return ( +
    + {schema.children && renderChildren(schema.children)} +
    + ); + }, + { + label: 'Grid Layout', + inputs: [ + { + name: 'columns', + type: 'number', + label: 'Columns', + defaultValue: 2, + description: 'Number of columns (1-12)' + }, + { + name: 'mdColumns', + type: 'number', + label: 'MD Breakpoint Columns', + description: 'Columns at md breakpoint (optional)' + }, + { + name: 'lgColumns', + type: 'number', + label: 'LG Breakpoint Columns', + description: 'Columns at lg breakpoint (optional)' + }, + { + name: 'gap', + type: 'number', + label: 'Gap', + defaultValue: 4, + description: 'Gap between items (0-8)' + }, + { name: 'className', type: 'string', label: 'CSS Class' } + ], + defaultProps: { + columns: 2, + gap: 4, + children: [ + { type: 'card', title: 'Card 1', description: 'First card' }, + { type: 'card', title: 'Card 2', description: 'Second card' }, + { type: 'card', title: 'Card 3', description: 'Third card' }, + { type: 'card', title: 'Card 4', description: 'Fourth card' } + ] + } + } +); diff --git a/packages/components/src/renderers/layout/index.ts b/packages/components/src/renderers/layout/index.ts index f04175c50..061a601e9 100644 --- a/packages/components/src/renderers/layout/index.ts +++ b/packages/components/src/renderers/layout/index.ts @@ -1,2 +1,5 @@ import './card'; import './tabs'; +import './grid'; +import './flex'; +import './container'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da91e8c72..90c91dedc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -304,6 +304,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + date-fns: + specifier: ^4.1.0 + version: 4.1.0 embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@18.3.1)