@frank-auth/react
Version:
Flexible and customizable React UI components for Frank Authentication
659 lines (584 loc) • 21.1 kB
text/typescript
/**
* @frank-auth/react - Appearance Configuration
*
* Advanced appearance management system with component-level customization,
* branding integration, and responsive design support.
*/
import type {
AppearanceConfig,
BrandingConfig,
ColorVariant,
ComponentAppearance,
ComponentSize,
OrganizationConfig,
} from './types';
import {
DEFAULT_APPEARANCE_CONFIG,
DEFAULT_BRANDING_CONFIG,
DEFAULT_COMPONENT_APPEARANCE,
DEFAULT_LAYOUT_CONFIG,
} from './defaults';
// ============================================================================
// Component Variant Configurations
// ============================================================================
/**
* Input component appearance variants
*/
export const INPUT_VARIANTS = {
flat: {
className: 'bg-default-100 hover:bg-default-200 focus:bg-default-100',
style: {
backgroundColor: 'hsl(var(--color-default-100))',
border: 'none',
borderRadius: 'var(--border-radius-md)',
},
},
bordered: {
className: 'border-2 border-default-200 hover:border-default-300 focus:border-primary',
style: {
backgroundColor: 'transparent',
border: '2px solid hsl(var(--color-default-200))',
borderRadius: 'var(--border-radius-md)',
},
},
underlined: {
className: 'border-b-2 border-default-300 hover:border-default-400 focus:border-primary',
style: {
backgroundColor: 'transparent',
border: 'none',
borderBottom: '2px solid hsl(var(--color-default-300))',
borderRadius: '0',
},
},
faded: {
className: 'bg-default-50 hover:bg-default-100 focus:bg-default-100 border border-default-200',
style: {
backgroundColor: 'hsl(var(--color-default-50))',
border: '1px solid hsl(var(--color-default-200))',
borderRadius: 'var(--border-radius-md)',
},
},
} as const;
/**
* Button component appearance variants
*/
export const BUTTON_VARIANTS = {
solid: {
className: 'bg-primary text-primary-foreground hover:bg-primary-600 focus:bg-primary-700',
style: {
backgroundColor: 'hsl(var(--color-primary))',
color: 'hsl(var(--color-primary-foreground))',
border: 'none',
},
},
bordered: {
className: 'border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground',
style: {
backgroundColor: 'transparent',
color: 'hsl(var(--color-primary))',
border: '2px solid hsl(var(--color-primary))',
},
},
light: {
className: 'bg-primary-100 text-primary-700 hover:bg-primary-200',
style: {
backgroundColor: 'hsl(var(--color-primary-100))',
color: 'hsl(var(--color-primary-700))',
border: 'none',
},
},
flat: {
className: 'bg-default-100 text-default-700 hover:bg-default-200',
style: {
backgroundColor: 'hsl(var(--color-default-100))',
color: 'hsl(var(--color-default-700))',
border: 'none',
},
},
faded: {
className: 'bg-default-50 text-default-700 hover:bg-default-100 border border-default-200',
style: {
backgroundColor: 'hsl(var(--color-default-50))',
color: 'hsl(var(--color-default-700))',
border: '1px solid hsl(var(--color-default-200))',
},
},
shadow: {
className: 'bg-primary text-primary-foreground hover:bg-primary-600 shadow-lg hover:shadow-xl',
style: {
backgroundColor: 'hsl(var(--color-primary))',
color: 'hsl(var(--color-primary-foreground))',
border: 'none',
boxShadow: 'var(--shadow-lg)',
},
},
ghost: {
className: 'text-primary hover:bg-primary-100 hover:text-primary-700',
style: {
backgroundColor: 'transparent',
color: 'hsl(var(--color-primary))',
border: 'none',
},
},
} as const;
/**
* Card component appearance variants
*/
export const CARD_VARIANTS = {
shadow: {
className: 'bg-card text-card-foreground shadow-md',
style: {
backgroundColor: 'hsl(var(--color-card))',
color: 'hsl(var(--color-card-foreground))',
boxShadow: 'var(--shadow-md)',
},
},
bordered: {
className: 'bg-card text-card-foreground border border-border',
style: {
backgroundColor: 'hsl(var(--color-card))',
color: 'hsl(var(--color-card-foreground))',
border: '1px solid hsl(var(--color-border))',
},
},
flat: {
className: 'bg-default-50 text-default-900',
style: {
backgroundColor: 'hsl(var(--color-default-50))',
color: 'hsl(var(--color-default-900))',
},
},
} as const;
// ============================================================================
// Size Configurations
// ============================================================================
/**
* Component size configurations
*/
export const SIZE_CONFIGS = {
sm: {
padding: 'var(--spacing-sm)',
fontSize: 'var(--font-size-sm)',
height: '32px',
minHeight: '32px',
},
md: {
padding: 'var(--spacing-md)',
fontSize: 'var(--font-size-base)',
height: '40px',
minHeight: '40px',
},
lg: {
padding: 'var(--spacing-lg)',
fontSize: 'var(--font-size-lg)',
height: '48px',
minHeight: '48px',
},
} as const;
/**
* Modal size configurations
*/
export const MODAL_SIZES = {
xs: { width: '320px', maxWidth: '90vw' },
sm: { width: '400px', maxWidth: '90vw' },
md: { width: '512px', maxWidth: '90vw' },
lg: { width: '640px', maxWidth: '90vw' },
xl: { width: '768px', maxWidth: '90vw' },
'2xl': { width: '896px', maxWidth: '95vw' },
'3xl': { width: '1024px', maxWidth: '95vw' },
'4xl': { width: '1280px', maxWidth: '95vw' },
'5xl': { width: '1536px', maxWidth: '95vw' },
full: { width: '100vw', height: '100vh', maxWidth: '100vw', maxHeight: '100vh' },
} as const;
// ============================================================================
// Responsive Breakpoints
// ============================================================================
/**
* Responsive breakpoints
*/
export const BREAKPOINTS = {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
} as const;
/**
* Responsive utilities
*/
export const RESPONSIVE_UTILITIES = {
/**
* Check if screen size matches breakpoint
*/
isBreakpoint: (breakpoint: keyof typeof BREAKPOINTS): boolean => {
if (typeof window === 'undefined') return false;
return window.matchMedia(`(min-width: ${BREAKPOINTS[breakpoint]})`).matches;
},
/**
* Get current breakpoint
*/
getCurrentBreakpoint: (): keyof typeof BREAKPOINTS => {
if (typeof window === 'undefined') return 'md';
const breakpoints = Object.entries(BREAKPOINTS).reverse();
for (const [name, size] of breakpoints) {
if (window.matchMedia(`(min-width: ${size})`).matches) {
return name as keyof typeof BREAKPOINTS;
}
}
return 'sm';
},
/**
* Create responsive value getter
*/
getResponsiveValue: <T>(values: Partial<Record<keyof typeof BREAKPOINTS, T>>, fallback: T): T => {
const currentBreakpoint = RESPONSIVE_UTILITIES.getCurrentBreakpoint();
const orderedBreakpoints: (keyof typeof BREAKPOINTS)[] = ['2xl', 'xl', 'lg', 'md', 'sm'];
// Find the first matching value from current breakpoint down
const currentIndex = orderedBreakpoints.indexOf(currentBreakpoint);
for (let i = currentIndex; i < orderedBreakpoints.length; i++) {
const breakpoint = orderedBreakpoints[i];
if (values[breakpoint] !== undefined) {
return values[breakpoint]!;
}
}
return fallback;
},
};
// ============================================================================
// Appearance Manager Class
// ============================================================================
export class AppearanceManager {
private config: AppearanceConfig;
private listeners: Set<(config: AppearanceConfig) => void> = new Set();
constructor(initialConfig?: Partial<AppearanceConfig>) {
this.config = this.mergeAppearanceConfig(DEFAULT_APPEARANCE_CONFIG, initialConfig);
}
/**
* Get current appearance configuration
*/
getConfig(): AppearanceConfig {
return { ...this.config };
}
/**
* Update appearance configuration
*/
updateConfig(updates: Partial<AppearanceConfig>): void {
this.config = this.mergeAppearanceConfig(this.config, updates);
this.notifyListeners();
}
/**
* Apply organization branding
*/
applyOrganizationBranding(organization: OrganizationConfig): void {
const branding: BrandingConfig = {
logo: {
url: organization.settings.branding?.logo,
alt: organization.name,
},
colors: {
primary: organization.settings.branding?.primaryColor || this.config.branding.colors.primary,
secondary: organization.settings.branding?.secondaryColor || this.config.branding.colors.secondary,
},
fonts: this.config.branding.fonts,
customCSS: organization.settings.branding?.customCSS,
};
this.updateConfig({ branding });
}
/**
* Get component styles for a specific component
*/
getComponentStyles(
componentType: keyof ComponentAppearance,
variant?: string,
size?: ComponentSize,
color?: ColorVariant
): {
className: string;
style: React.CSSProperties;
} {
const componentConfig = this.config.components[componentType];
// Get base styles
let styles = this.getBaseComponentStyles(componentType, componentConfig);
// Apply variant styles
if (variant) {
const variantStyles = this.getVariantStyles(componentType, variant);
styles = this.mergeStyles(styles, variantStyles);
}
// Apply size styles
const actualSize = size || (componentConfig as any)?.size || 'md';
const sizeStyles = this.getSizeStyles(actualSize);
styles = this.mergeStyles(styles, sizeStyles);
// Apply color styles
if (color) {
const colorStyles = this.getColorStyles(color);
styles = this.mergeStyles(styles, colorStyles);
}
return styles;
}
/**
* Get layout styles
*/
getLayoutStyles(): React.CSSProperties {
return {
'--container-max-width': this.config.layout.containerMaxWidth,
'--sidebar-width': this.config.layout.sidebarWidth,
'--header-height': this.config.layout.headerHeight,
'--footer-height': this.config.layout.footerHeight,
'--content-padding': this.config.layout.contentPadding,
} as React.CSSProperties;
}
/**
* Get branding CSS variables
*/
getBrandingVariables(): Record<string, string> {
const { branding } = this.config;
return {
'--brand-primary': branding.colors.primary,
'--brand-secondary': branding.colors.secondary,
'--brand-accent': branding.colors.accent || branding.colors.primary,
'--brand-font-primary': branding.fonts.primary,
'--brand-font-secondary': branding.fonts.secondary || branding.fonts.primary,
};
}
/**
* Generate complete CSS for appearance
*/
generateCSS(): string {
const brandingVars = this.getBrandingVariables();
const layoutStyles = this.getLayoutStyles();
let css = ':root {\n';
// Add branding variables
Object.entries(brandingVars).forEach(([property, value]) => {
css += ` ${property}: ${value};\n`;
});
// Add layout variables
Object.entries(layoutStyles).forEach(([property, value]) => {
css += ` ${property}: ${value};\n`;
});
css += '}\n\n';
// Add custom CSS if provided
if (this.config.customCSS) {
css += this.config.customCSS + '\n';
}
if (this.config.branding.customCSS) {
css += this.config.branding.customCSS + '\n';
}
return css;
}
/**
* Apply appearance to DOM
*/
applyToDOM(): void {
if (typeof document === 'undefined') return;
// Apply CSS variables
const brandingVars = this.getBrandingVariables();
const layoutStyles = this.getLayoutStyles();
const root = document.documentElement;
Object.entries({ ...brandingVars, ...layoutStyles }).forEach(([property, value]) => {
root.style.setProperty(property, value);
});
// Apply custom CSS if not already applied
const customCSSId = 'frank-auth-custom-css';
let styleEl = document.getElementById(customCSSId) as HTMLStyleElement;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = customCSSId;
document.head.appendChild(styleEl);
}
const customCSS = [this.config.customCSS, this.config.branding.customCSS]
.filter(Boolean)
.join('\n');
styleEl.textContent = customCSS;
}
/**
* Subscribe to appearance changes
*/
subscribe(callback: (config: AppearanceConfig) => void): () => void {
this.listeners.add(callback);
return () => {
this.listeners.delete(callback);
};
}
// Private methods
private mergeAppearanceConfig(
base: AppearanceConfig,
override?: Partial<AppearanceConfig>
): AppearanceConfig {
if (!override) return { ...base };
return {
...base,
...override,
layout: { ...base.layout, ...override.layout },
components: { ...base.components, ...override.components },
branding: {
...base.branding,
...override.branding,
logo: { ...base.branding.logo, ...override.branding?.logo },
colors: { ...base.branding.colors, ...override.branding?.colors },
fonts: { ...base.branding.fonts, ...override.branding?.fonts },
},
};
}
private getBaseComponentStyles(
componentType: keyof ComponentAppearance,
config: any
): { className: string; style: React.CSSProperties } {
return {
className: `frank-${componentType}`,
style: {
borderRadius: `var(--border-radius-${config?.radius || 'md'})`,
transition: 'all var(--duration-normal) var(--easing-ease-out)',
},
};
}
private getVariantStyles(
componentType: keyof ComponentAppearance,
variant: string
): { className: string; style: React.CSSProperties } {
switch (componentType) {
case 'input':
return INPUT_VARIANTS[variant as keyof typeof INPUT_VARIANTS] || INPUT_VARIANTS.bordered;
case 'button':
return BUTTON_VARIANTS[variant as keyof typeof BUTTON_VARIANTS] || BUTTON_VARIANTS.solid;
case 'card':
return CARD_VARIANTS[variant as keyof typeof CARD_VARIANTS] || CARD_VARIANTS.shadow;
default:
return { className: '', style: {} };
}
}
private getSizeStyles(size: ComponentSize): { className: string; style: React.CSSProperties } {
const sizeConfig = SIZE_CONFIGS[size];
return {
className: `frank-size-${size}`,
style: {
padding: sizeConfig.padding,
fontSize: sizeConfig.fontSize,
minHeight: sizeConfig.minHeight,
},
};
}
private getColorStyles(color: ColorVariant): { className: string; style: React.CSSProperties } {
return {
className: `frank-color-${color}`,
style: {
'--component-color': `var(--color-${color})`,
'--component-color-foreground': `var(--color-${color}-foreground)`,
} as React.CSSProperties,
};
}
private mergeStyles(
base: { className: string; style: React.CSSProperties },
override: { className: string; style: React.CSSProperties }
): { className: string; style: React.CSSProperties } {
return {
className: [base.className, override.className].filter(Boolean).join(' '),
style: { ...base.style, ...override.style },
};
}
private notifyListeners(): void {
this.listeners.forEach(callback => callback(this.config));
}
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Create an appearance manager instance
*/
export function createAppearanceManager(config?: Partial<AppearanceConfig>): AppearanceManager {
return new AppearanceManager(config);
}
/**
* Get component class names based on configuration
*/
export function getComponentClassNames(
componentType: keyof ComponentAppearance,
config: ComponentAppearance,
variant?: string,
size?: ComponentSize,
color?: ColorVariant,
additionalClasses?: string[]
): string {
const classes: string[] = [`frank-${componentType}`];
// Add variant class
if (variant) {
classes.push(`frank-${componentType}-${variant}`);
}
// Add size class
const actualSize = size || (config[componentType] as any)?.size || 'md';
classes.push(`frank-size-${actualSize}`);
// Add color class
if (color) {
classes.push(`frank-color-${color}`);
}
// Add additional classes
if (additionalClasses) {
classes.push(...additionalClasses);
}
return classes.join(' ');
}
/**
* Convert appearance config to Tailwind classes
*/
export function appearanceConfigToTailwind(config: ComponentAppearance): Record<string, string> {
const tailwindClasses: Record<string, string> = {};
// Convert input config
if (config.input) {
const { variant, size, color, radius } = config.input;
const classes = ['transition-all', 'duration-200'];
// Variant classes
switch (variant) {
case 'flat':
classes.push('bg-default-100', 'hover:bg-default-200', 'focus:bg-default-100');
break;
case 'bordered':
classes.push('border-2', 'border-default-200', 'hover:border-default-300', 'focus:border-primary');
break;
case 'underlined':
classes.push('border-b-2', 'border-default-300', 'hover:border-default-400', 'focus:border-primary');
break;
case 'faded':
classes.push('bg-default-50', 'border', 'border-default-200', 'hover:bg-default-100');
break;
}
// Size classes
switch (size) {
case 'sm':
classes.push('px-2', 'py-1', 'text-sm', 'h-8');
break;
case 'md':
classes.push('px-3', 'py-2', 'text-base', 'h-10');
break;
case 'lg':
classes.push('px-4', 'py-3', 'text-lg', 'h-12');
break;
}
// Radius classes
if (radius === 'none') classes.push('rounded-none');
else if (radius === 'sm') classes.push('rounded-sm');
else if (radius === 'md') classes.push('rounded-md');
else if (radius === 'lg') classes.push('rounded-lg');
else if (radius === 'xl') classes.push('rounded-xl');
else if (radius === 'full') classes.push('rounded-full');
tailwindClasses.input = classes.join(' ');
}
return tailwindClasses;
}
/**
* Create responsive component props
*/
export function createResponsiveProps<T>(
values: Partial<Record<keyof typeof BREAKPOINTS, T>>,
fallback: T
): T {
return RESPONSIVE_UTILITIES.getResponsiveValue(values, fallback);
}
// ============================================================================
// Export appearance utilities
// ============================================================================
export {
DEFAULT_APPEARANCE_CONFIG,
DEFAULT_COMPONENT_APPEARANCE,
DEFAULT_LAYOUT_CONFIG,
DEFAULT_BRANDING_CONFIG,
};