UNPKG

ui69

Version:

Unstyled, accessible UI components for React Native, inspired by shadcn/ui

760 lines (679 loc) 26.7 kB
#!/usr/bin/env node /** * ui69 CLI * * A CLI tool for adding unstyled, accessible React Native UI components to your project. * Inspired by shadcn/ui for web. */ const fs = require('fs-extra'); const path = require('path'); const { execSync } = require('child_process'); // Define component directory path - this is critical for finding component files // We need to use __dirname to get the actual location of this script const COMPONENT_DIR = path.join(__dirname, '../components'); // Check if we need to install inquirer try { require.resolve('inquirer'); } catch (error) { console.log('Installing dependencies...'); execSync('npm install --no-save inquirer@^8.0.0'); console.log('Dependencies installed!'); } // Import inquirer for interactive prompts const inquirer = require('inquirer'); // Colors for terminal output const colors = { reset: "\x1b[0m", green: "\x1b[32m", red: "\x1b[31m", blue: "\x1b[34m", cyan: "\x1b[36m", yellow: "\x1b[33m", bold: "\x1b[1m", magenta: "\x1b[35m", gray: "\x1b[90m" }; // Logger const log = { success: (text) => console.log(`${colors.green}✓ ${text}${colors.reset}`), error: (text) => console.log(`${colors.red}✖ ${text}${colors.reset}`), info: (text) => console.log(`${colors.blue}ℹ ${text}${colors.reset}`), warning: (text) => console.log(`${colors.yellow}⚠ ${text}${colors.reset}`), title: (text) => console.log(`\n${colors.bold}${text}${colors.reset}\n`), code: (text) => console.log(`${colors.cyan}${text}${colors.reset}`), prompt: (text) => console.log(`${colors.magenta}? ${text}${colors.reset}`), muted: (text) => console.log(`${colors.gray}${text}${colors.reset}`) }; // Theme configuration template (TypeScript version) const THEME_CONFIG_TEMPLATE = `// theme.config.ts /** * UI69 Theme Configuration * * Customize your app's colors and design tokens here. * Just like shadcn/ui, you can easily change any value to match your brand. * * 🎨 To change your theme: * 1. Modify the colors below * 2. Save the file * 3. All components will automatically use your new colors! */ export interface LightTheme { // Base colors background: string; // Main app background foreground: string; // Primary text color // Component surfaces card: string; // Card backgrounds cardForeground: string; // Text on cards popover: string; // Dropdown/modal backgrounds popoverForeground: string; // Text on dropdowns/modals // Brand colors primary: string; // Primary buttons, links, focus states primaryForeground: string; // Text on primary colored backgrounds secondary: string; // Secondary buttons and backgrounds secondaryForeground: string; // Text on secondary backgrounds // Utility colors muted: string; // Subtle backgrounds mutedForeground: string; // Subtle text (placeholders, descriptions) accent: string; // Accent backgrounds (highlights) accentForeground: string; // Text on accent backgrounds destructive: string; // Error/danger buttons and alerts destructiveForeground: string; // Text on destructive backgrounds // Interactive elements border: string; // Borders, dividers input: string; // Input field backgrounds ring: string; // Focus rings, selections } /** * 🎨 CUSTOMIZE YOUR THEME HERE * * These are the default colors - change them to match your brand! * Colors should be in hex format (#000000) for React Native compatibility. */ export const lightTheme: LightTheme = { // 🔹 Base colors - The foundation of your app background: '#ffffff', // Pure white background foreground: '#0a0a0a', // Almost black text // 🔹 Surface colors - For cards, modals, etc. card: '#ffffff', // White card backgrounds cardForeground: '#0a0a0a', // Dark text on cards popover: '#ffffff', // White popover backgrounds popoverForeground: '#0a0a0a', // Dark text on popovers // 🔹 Brand colors - Make these match your brand! primary: '#171717', // 👈 CHANGE THIS to your brand color! primaryForeground: '#fafafa', // Light text on dark primary secondary: '#f5f5f5', // Light gray for secondary elements secondaryForeground: '#171717', // Dark text on light secondary // 🔹 Utility colors - For subtle elements muted: '#f5f5f5', // Very light gray mutedForeground: '#737373', // Medium gray text accent: '#f5f5f5', // Same as secondary for consistency accentForeground: '#171717', // Dark text on accent destructive: '#ef4444', // Red for errors/delete actions destructiveForeground: '#fafafa', // Light text on red // 🔹 Interactive elements border: '#e5e5e5', // Light gray borders input: '#e5e5e5', // Light gray input backgrounds ring: '#171717', // Focus ring (usually matches primary) }; /** * 🔧 Design Tokens * * These control the shape and spacing of your components */ export const radius = { sm: 4, // Small corners (tight, minimal) md: 6, // Medium corners (balanced - most common) lg: 8, // Large corners (soft, friendly) xl: 12, // Extra large corners (very rounded) }; /** * 📦 Export the current theme * * This is what your components will use. * You can also create multiple themes and switch between them. */ export const currentTheme = lightTheme; /** * 🎨 QUICK THEME EXAMPLES * * Copy and paste one of these to quickly change your theme: */ // 🔵 Blue Theme // export const currentTheme: LightTheme = { // ...lightTheme, // primary: '#3b82f6', // Blue primary // ring: '#3b82f6', // Blue focus ring // }; // 🟢 Green Theme // export const currentTheme: LightTheme = { // ...lightTheme, // primary: '#22c55e', // Green primary // ring: '#22c55e', // Green focus ring // }; // 🟣 Purple Theme // export const currentTheme: LightTheme = { // ...lightTheme, // primary: '#8b5cf6', // Purple primary // ring: '#8b5cf6', // Purple focus ring // }; // 🟠 Orange Theme // export const currentTheme: LightTheme = { // ...lightTheme, // primary: '#f97316', // Orange primary // ring: '#f97316', // Orange focus ring // }; // 🔴 Red Theme // export const currentTheme: LightTheme = { // ...lightTheme, // primary: '#ef4444', // Red primary // ring: '#ef4444', // Red focus ring // }; /** * 🎨 CUSTOM BRAND EXAMPLE * * Here's how to create a custom theme for your brand: */ // export const currentTheme: LightTheme = { // ...lightTheme, // // // Your brand colors // primary: '#your-brand-color', // 👈 Put your brand color here // ring: '#your-brand-color', // Usually same as primary // // // Optional: customize other colors // secondary: '#your-secondary-color', // accent: '#your-accent-color', // // // Optional: customize surface colors // background: '#fafafa', // Slightly off-white // card: '#ffffff', // Pure white cards // }; /** * 💡 PRO TIPS: * * 1. Start by changing just the \`primary\` color - this will transform your app! * 2. Make sure \`primaryForeground\` has good contrast with \`primary\` * 3. Use online tools like coolors.co to generate color palettes * 4. Test your colors with both light and dark text * 5. Keep \`border\` and \`input\` subtle for a clean look */ /** * 💡 PRO TIPS: * * 1. Start by changing just the \`primary\` color - this will transform your app! * 2. Make sure \`primaryForeground\` has good contrast with \`primary\` * 3. Use online tools like coolors.co to generate color palettes * 4. Test your colors with both light and dark text * 5. Keep \`border\` and \`input\` subtle for a clean look */ `; // Check if theme.config.js exists in the current directory function checkThemeConfigExists() { const currentDir = process.cwd(); const themeConfigPaths = [ path.join(currentDir, 'theme.config.js'), path.join(currentDir, 'theme.config.ts') ]; return themeConfigPaths.some(configPath => fs.existsSync(configPath)); } // Show error message when theme config is missing function showThemeConfigMissing() { showSplash(); log.error('ui69 is not initialized in this project.'); console.log(''); log.info('To get started, run the init command:'); log.code(' npx ui69@latest init'); console.log(''); log.muted('This will create a theme.config.ts file in your project root.'); console.log(''); } // Ensure component directory exists function ensureComponentsExist() { if (!fs.existsSync(COMPONENT_DIR)) { log.error(`Components directory not found: ${COMPONENT_DIR}`); log.info(`Make sure the package is installed correctly and the components directory exists.`); log.info(`Current directory: ${__dirname}`); process.exit(1); } } // Initialize ui69 in the project async function initializeProject() { showSplash(); log.title('Initializing ui69 in your project...'); const currentDir = process.cwd(); const themeConfigPath = path.join(currentDir, 'theme.config.ts'); // Check if theme.config.ts already exists if (fs.existsSync(themeConfigPath)) { log.warning('theme.config.ts already exists in your project.'); const response = await inquirer.prompt([ { type: 'confirm', name: 'overwrite', message: 'Do you want to overwrite the existing theme.config.ts?', default: false } ]); if (!response.overwrite) { log.info('Initialization cancelled. Your existing theme.config.ts was not modified.'); return; } } try { // Create theme.config.ts await fs.writeFile(themeConfigPath, THEME_CONFIG_TEMPLATE); log.success('Created theme.config.ts in your project root'); // Check if it's a TypeScript project and suggest tsconfig update const tsconfigPath = path.join(currentDir, 'tsconfig.json'); if (fs.existsSync(tsconfigPath)) { log.info('TypeScript project detected! ✨'); } else { log.muted('You can also create a tsconfig.json file if you want to use TypeScript.'); } // Success message log.title('🎉 ui69 initialized successfully!'); console.log('Next steps:'); log.code(' 1. Customize your theme in theme.config.ts'); log.code(' 2. Add components: npx ui69@latest add button'); log.code(' 3. Import and use: import { Button } from "./components/ui/button"'); console.log('\\nQuick start:'); log.code(' npx ui69@latest add button'); log.code(' npx ui69@latest add input'); log.code(' npx ui69@latest add toast'); } catch (error) { log.error('Failed to create theme.config.ts'); console.error(error); process.exit(1); } } // Available components configuration function getComponentsConfig() { // Check component directory exists ensureComponentsExist(); return { alert: { name: "Alert", description: "Alert component with multiple variants, icons, titles, descriptions, and action support", dependencies: [], files: [ { src: path.join(COMPONENT_DIR, 'ui/alert.tsx'), dest: 'components/ui/alert.tsx', } ] }, 'alert-dialog': { name: "AlertDialog", description: "Modal dialogs that interrupt the user with important content and await a decision", dependencies: ['react-native-safe-area-context', 'expo-linear-gradient'], files: [ { src: path.join(COMPONENT_DIR, 'ui/alert-dialog.tsx'), dest: 'components/ui/alert-dialog.tsx', } ] }, button: { name: "Button", description: "Button component with multiple variants and sizes", dependencies: [], files: [ { src: path.join(COMPONENT_DIR, 'ui/button.tsx'), dest: 'components/ui/button.tsx', } ] }, checkbox: { name: "Checkbox", description: "Checkbox input with multiple variants, group support, indeterminate state, and professional SVG icons", dependencies: ['react-native-svg'], files: [ { src: path.join(COMPONENT_DIR, 'ui/checkbox.tsx'), dest: 'components/ui/checkbox.tsx', } ] }, radio: { name: "Radio", description: "Radio button component with group management, multiple variants, and single-selection logic", dependencies: [], files: [ { src: path.join(COMPONENT_DIR, 'ui/radio.tsx'), dest: 'components/ui/radio.tsx', } ] }, switch: { name: "Switch", description: "Toggle switch component with smooth animations, multiple variants, and group support", dependencies: [], files: [ { src: path.join(COMPONENT_DIR, 'ui/switch.tsx'), dest: 'components/ui/switch.tsx', } ] }, skeleton: { name: "Skeleton", description: "Skeleton loading component with shimmer and wave animations", dependencies: ['react-native-reanimated', 'expo-linear-gradient'], files: [ { src: path.join(COMPONENT_DIR, 'ui/skeleton.tsx'), dest: 'components/ui/skeleton.tsx', } ] }, seperator: { name: "Seperator", description: "Seperator component for dividing content", dependencies: [], files: [ { src: path.join(COMPONENT_DIR, 'ui/seperator.tsx'), dest: 'components/ui/seperator.tsx', } ] }, card: { name: "Card", description: "Container component with default and warning variants, plus header, content and footer sections", dependencies: [], files: [ { src: path.join(COMPONENT_DIR, 'ui/card.tsx'), dest: 'components/ui/card.tsx', } ] }, badge: { name: "Badge", description: "Small status indicator with multiple variants and dot style option", dependencies: [], files: [ { src: path.join(COMPONENT_DIR, 'ui/badge.tsx'), dest: 'components/ui/badge.tsx', } ] }, avatar: { name: "Avatar", description: "User profile image component with fallback, status indicators, and grouping support", dependencies: [], files: [ { src: path.join(COMPONENT_DIR, 'ui/avatar.tsx'), dest: 'components/ui/avatar.tsx', } ] }, accordion: { name: "Accordion", description: "Collapsible content sections with customizable styling and animations", dependencies: [], files: [ { src: path.join(COMPONENT_DIR, 'ui/accordion.tsx'), dest: 'components/ui/accordion.tsx', } ] }, 'input-otp': { name: "InputOTP", description: "One-time password input component with support for different input types, patterns, and custom dimensions", dependencies: [], files: [ { src: path.join(COMPONENT_DIR, 'ui/input-otp.tsx'), dest: 'components/ui/input-otp.tsx', } ] }, input: { name: "Input", description: "Text input component with multiple variants, validation, and icon support", dependencies: [], files: [ { src: path.join(COMPONENT_DIR, 'ui/input.tsx'), dest: 'components/ui/input.tsx', } ] }, toast: { name: "Toast", description: "Toast notifications with animations, gestures, and multiple variants", dependencies: ['react-native-reanimated', 'react-native-gesture-handler', 'react-native-safe-area-context'], files: [ { src: path.join(COMPONENT_DIR, 'ui/toast.tsx'), dest: 'components/ui/toast.tsx', } ] }, select: { name: "Select", description: "Select dropdown component with smooth animations and positioning", dependencies: ['react-native-safe-area-context', 'react-native-svg'], files: [ { src: path.join(COMPONENT_DIR, 'ui/select.tsx'), dest: 'components/ui/select.tsx', } ] }, drawer: { name: "Drawer", description: "Customizable drawer/sheet component with gesture support, smooth animations, and custom dimensions", dependencies: ['react-native-safe-area-context', 'react-native-gesture-handler'], files: [ { src: path.join(COMPONENT_DIR, 'ui/drawer.tsx'), dest: 'components/ui/drawer.tsx', } ] }, }; } // Interactive component selector with space selection async function selectComponents() { // Check if theme config exists before allowing component selection if (!checkThemeConfigExists()) { showThemeConfigMissing(); process.exit(1); } // Get components configuration const components = getComponentsConfig(); // Show splash screen showSplash(); log.title('Which components would you like to add?'); // Convert components to choices format for inquirer const choices = Object.entries(components).map(([key, value]) => ({ name: `${value.name}`, value: key, checked: false })); try { const response = await inquirer.prompt([ { type: 'checkbox', name: 'selectedComponents', message: 'Select components using space, then press Enter to confirm', choices: choices, validate: (answer) => { if (answer.length < 1) { return 'You must choose at least one component.'; } return true; } } ]); return response.selectedComponents; } catch (error) { log.error('Error selecting components:'); console.error(error); process.exit(1); } } // Function to install a component async function installComponent(component) { // Check if theme config exists before allowing component installation if (!checkThemeConfigExists()) { showThemeConfigMissing(); process.exit(1); } // Get components configuration const components = getComponentsConfig(); // Check if the component exists if (!components[component]) { log.error(`Component '${component}' not found.`); console.log(`\\nAvailable components:\\n${Object.keys(components).map(c => ` - ${c}`).join('\\n')}`); process.exit(1); } const config = components[component]; log.title(`Installing ${config.name} component`); // Create the necessary directories and copy the files for (const file of config.files) { const srcPath = file.src; const destPath = path.join(process.cwd(), file.dest); // Check if source file exists if (!fs.existsSync(srcPath)) { log.error(`Source file not found: ${srcPath}`); log.info(`Expected at: ${srcPath}`); log.info(`Current directory: ${__dirname}`); process.exit(1); } // Create directory if it doesn't exist try { await fs.ensureDir(path.dirname(destPath)); log.success(`Created directory for ${path.dirname(file.dest)}`); } catch (error) { log.error(`Failed to create directory for ${file.dest}`); console.error(error); process.exit(1); } // Copy the file try { await fs.copy(srcPath, destPath); log.success(`Created ${colors.bold}${file.dest}`); } catch (error) { log.error(`Failed to copy file to ${file.dest}`); console.error(error); process.exit(1); } } // Show dependencies if any if (config.dependencies && config.dependencies.length > 0) { log.info(`\\n${config.name} requires the following dependencies:`); config.dependencies.forEach(dep => { log.code(` ${dep}`); }); console.log('\\nInstall them with:'); if (config.dependencies.includes('expo-linear-gradient')) { log.code(` npx expo install ${config.dependencies.join(' ')}`); } else { log.code(` npm install ${config.dependencies.join(' ')}`); } } // Installation complete log.success(`\\n${config.name} installed successfully!`); } // List all available components function listComponents() { // Get components configuration const components = getComponentsConfig(); showSplash(); log.title('Available Components'); Object.entries(components).forEach(([name, config]) => { console.log(`${colors.bold}${name}${colors.reset}`); console.log(` ${config.description}`); if (config.dependencies && config.dependencies.length > 0) { console.log(` ${colors.gray}Dependencies: ${config.dependencies.join(', ')}${colors.reset}`); } console.log(''); }); console.log('To add a component:'); log.code(' npx ui69@latest add <component>'); console.log('\\nOr select from the interactive menu:'); log.code(' npx ui69@latest add'); console.log('\\n' + colors.yellow + '⚠ Note: You must run "npx ui69@latest init" first to initialize ui69 in your project.' + colors.reset); } // Show a splash screen function showSplash() { console.log(` ${colors.bold}${colors.magenta}╭───────────────────────────────────────────────╮${colors.reset} ${colors.bold}${colors.magenta}│ │${colors.reset} ${colors.bold}${colors.magenta}│ ${colors.reset}${colors.bold}ui69${colors.magenta} │${colors.reset} ${colors.bold}${colors.magenta}│ ${colors.reset}${colors.bold}UI components for React Native${colors.magenta} │${colors.reset} ${colors.bold}${colors.magenta}╰───────────────────────────────────────────────╯${colors.reset} `); } // Main CLI function async function main() { const args = process.argv.slice(2); const command = args[0]; // Handle different commands switch (command) { case 'init': await initializeProject(); break; case 'add': const componentName = args[1]; if (!componentName) { // If no component specified, show interactive selector const selectedComponents = await selectComponents(); // Install each selected component for (const component of selectedComponents) { await installComponent(component); } } else { await installComponent(componentName); } break; case 'list': listComponents(); break; case '--version': case '-v': try { const packageJson = require('../package.json'); console.log(packageJson.version); } catch (error) { log.error('Unable to read package.json'); console.error(error); process.exit(1); } break; case '--help': case '-h': default: showSplash(); log.title('ui69 CLI'); console.log('A collection of unstyled, accessible UI components for React Native'); console.log('\\nCommands:'); console.log(' init Initialize ui69 in your project (creates theme.config.ts)'); console.log(' add [component] Add a component to your project (interactive if no component specified)'); console.log(' list List all available components'); console.log(' --help, -h Show this help message'); console.log(' --version, -v Show the version number'); console.log('\\nExamples:'); log.code(' npx ui69@latest init'); log.code(' npx ui69@latest add button'); log.code(' npx ui69@latest add input'); log.code(' npx ui69@latest add toast'); log.code(' npx ui69@latest add # Interactive component selection'); log.code(' npx ui69@latest list'); console.log('\\n' + colors.yellow + '⚠ Note: You must run "npx ui69@latest init" first before adding components.' + colors.reset); break; } } // Run the CLI main().catch(error => { log.error('An error occurred:'); console.error(error); process.exit(1); });