refactor: Migrate to monorepo
BREAKING CHANGE: Complete structure overhaul
This commit is contained in:
496 files changed
+45388
-7531
No files matched your search
@@ -0,0 +1,169 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import prettier from 'eslint-config-prettier/flat';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import jsdoc from 'eslint-plugin-jsdoc';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
/**
|
||||
* Base ESLint configuration
|
||||
*/
|
||||
export const baseConfig = tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
'**/dist',
|
||||
'**/node_modules',
|
||||
'**/.github',
|
||||
'**/.changeset',
|
||||
'**/coverage',
|
||||
'.config/**/*',
|
||||
'*.config.{js,ts,mjs,cjs}',
|
||||
'!.storybook', // Allow linting of .storybook directory
|
||||
],
|
||||
},
|
||||
|
||||
// JavaScript
|
||||
eslint.configs.recommended,
|
||||
|
||||
// TypeScript - Strict Configuration (only for .ts/.tsx files)
|
||||
{
|
||||
files: ['**/*.{ts,tsx,mts,cts}'],
|
||||
extends: [
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// TypeScript strict rules
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
'@typescript-eslint/prefer-nullish-coalescing': 'error',
|
||||
'@typescript-eslint/prefer-optional-chain': 'error',
|
||||
'@typescript-eslint/no-unnecessary-condition': 'error',
|
||||
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||
'@typescript-eslint/prefer-as-const': 'error',
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports', disallowTypeAnnotations: false },
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': [
|
||||
'error',
|
||||
{ allowExpressions: true, allowTypedFunctionExpressions: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Allow triple-slash references in `*.d.ts` files.
|
||||
{
|
||||
files: ['**/*.d.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/triple-slash-reference': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Import/Export Rules
|
||||
{
|
||||
plugins: { import: importPlugin },
|
||||
rules: {
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
groups: [
|
||||
'builtin',
|
||||
'external',
|
||||
'internal',
|
||||
['parent', 'sibling'],
|
||||
'index',
|
||||
'type',
|
||||
],
|
||||
'newlines-between': 'always',
|
||||
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||
},
|
||||
],
|
||||
'import/no-duplicates': 'error',
|
||||
'import/no-unused-modules': 'error',
|
||||
'import/no-cycle': 'error',
|
||||
'import/no-self-import': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// JSDoc Requirements - Only for main source code
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}', 'packages/**/*.{ts,tsx}', 'services/**/*.{ts,tsx}'],
|
||||
plugins: { jsdoc },
|
||||
rules: {
|
||||
'jsdoc/require-jsdoc': [
|
||||
'error',
|
||||
{
|
||||
contexts: [
|
||||
'ExportNamedDeclaration > FunctionDeclaration',
|
||||
'ExportDefaultDeclaration > FunctionDeclaration',
|
||||
'TSInterfaceDeclaration',
|
||||
'TSTypeAliasDeclaration',
|
||||
],
|
||||
},
|
||||
],
|
||||
'jsdoc/require-description': 'error',
|
||||
'jsdoc/require-param': 'error',
|
||||
'jsdoc/require-returns': 'error',
|
||||
'jsdoc/require-example': 'warn',
|
||||
'jsdoc/check-alignment': 'error',
|
||||
'jsdoc/check-indentation': 'error',
|
||||
'jsdoc/check-syntax': 'error',
|
||||
'jsdoc/check-tag-names': 'error',
|
||||
'jsdoc/check-types': 'error',
|
||||
'jsdoc/valid-types': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// Test files
|
||||
{
|
||||
files: ['**/*.{test,spec}.{js,jsx,ts,tsx}', '.config/jest/**/*.{js,jsx,ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.jest,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Relax rules for test files and jest configuration
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'jsdoc/require-jsdoc': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// CommonJS files - disable all TypeScript rules
|
||||
{
|
||||
files: ['**/*.cjs'],
|
||||
...tseslint.configs.disableTypeChecked,
|
||||
},
|
||||
|
||||
// Config files and tooling
|
||||
{
|
||||
files: ['*.config.{js,ts,mjs,cjs}', '.storybook/**/*.{js,ts,cjs,mjs}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'jsdoc/require-jsdoc': 'off',
|
||||
'no-undef': 'off', // For CommonJS files
|
||||
},
|
||||
},
|
||||
|
||||
// Prettier
|
||||
prettier
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@ermnvldmr/eslint-config",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared ESLint configuration",
|
||||
"type": "module",
|
||||
"main": "./index.js",
|
||||
"files": [
|
||||
"index.js",
|
||||
"react.js",
|
||||
"astro.js"
|
||||
],
|
||||
"dependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsdoc": "^61.5.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"eslint-plugin-storybook": "^0.10.2",
|
||||
"globals": "^16.0.0",
|
||||
"typescript-eslint": "^8.31.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@ermnvldmr/jest-config",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared Jest configuration",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./base": "./dist/base.js",
|
||||
"./react": "./dist/react.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jest/globals": "^30.2.0",
|
||||
"jest": "^30.2.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"@types/jest": "^30.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"jest-environment-jsdom": "^30.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Config } from 'jest';
|
||||
|
||||
/**
|
||||
* Base Jest configuration for TypeScript projects.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { baseJestConfig } from '@ermnvldmr/jest-config/base';
|
||||
*
|
||||
* export default {
|
||||
* ...baseJestConfig,
|
||||
* // package-specific overrides
|
||||
* } satisfies Config;
|
||||
* ```
|
||||
*/
|
||||
export const baseJestConfig: Config = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
useESM: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/**/*.test.{ts,tsx}',
|
||||
'!src/**/*.stories.{ts,tsx}',
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
testMatch: [
|
||||
'**/__tests__/**/*.{ts,tsx}',
|
||||
'**/*.{test,spec}.{ts,tsx}',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Jest configuration exports for the monorepo.
|
||||
*/
|
||||
|
||||
export { baseJestConfig } from './base.js';
|
||||
export { reactJestConfig } from './react.js';
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Config } from 'jest';
|
||||
import { baseJestConfig } from './base.js';
|
||||
|
||||
/**
|
||||
* Jest configuration for React projects with jsdom environment.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { reactJestConfig } from '@ermnvldmr/jest-config/react';
|
||||
*
|
||||
* export default reactJestConfig;
|
||||
* ```
|
||||
*/
|
||||
export const reactJestConfig: Config = {
|
||||
...baseJestConfig,
|
||||
testEnvironment: 'jsdom',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
|
||||
moduleNameMapper: {
|
||||
...baseJestConfig.moduleNameMapper,
|
||||
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
|
||||
},
|
||||
transform: {
|
||||
...baseJestConfig.transform,
|
||||
'^.+\\.(js|jsx|ts|tsx)$': [
|
||||
'ts-jest',
|
||||
{
|
||||
useESM: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { baseJestConfig } from '@ermnvldmr/jest-config/base';
|
||||
|
||||
export default baseJestConfig;
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@ermnvldmr/stl",
|
||||
"version": "1.0.0",
|
||||
"description": "Common reusable dependencies",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && vite build",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ermnvldmr/eslint-config": "workspace:*",
|
||||
"@ermnvldmr/jest-config": "workspace:*",
|
||||
"@ermnvldmr/vite-config": "workspace:*",
|
||||
"jest": "^30.2.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": "^4.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { cn } from './cn';
|
||||
|
||||
/**
|
||||
* Test suite for the cn utility function
|
||||
*/
|
||||
describe('shared/helpers/cn/cn', () => {
|
||||
it('combines simple class names', () => {
|
||||
expect(cn('p-4', 'bg-red-500')).toBe('p-4 bg-red-500');
|
||||
expect(cn('flex', 'items-center', 'justify-between')).toBe('flex items-center justify-between');
|
||||
});
|
||||
|
||||
it('resolves conflicting Tailwind classes using tailwind-merge', () => {
|
||||
expect(cn('p-4 bg-red-500', 'bg-blue-500')).toBe('p-4 bg-blue-500');
|
||||
expect(cn('text-sm font-normal', 'font-bold')).toBe('text-sm font-bold');
|
||||
expect(cn('px-2 py-1', 'p-4')).toBe('p-4');
|
||||
});
|
||||
|
||||
it('handles conditional classes using clsx', () => {
|
||||
// Test with actual conditional values (simulating real usage)
|
||||
const activeCondition = Math.random() > -1; // Always true but not constant
|
||||
const inactiveCondition = Math.random() < -1; // Always false but not constant
|
||||
|
||||
expect(cn('p-4', activeCondition && 'bg-red-500')).toBe('p-4 bg-red-500');
|
||||
expect(cn('p-4', inactiveCondition && 'bg-red-500')).toBe('p-4');
|
||||
|
||||
// Test null and undefined conditions
|
||||
const nullCondition: string | null = null;
|
||||
const undefinedCondition: string | undefined = undefined;
|
||||
expect(cn('p-4', nullCondition)).toBe('p-4');
|
||||
expect(cn('p-4', undefinedCondition)).toBe('p-4');
|
||||
});
|
||||
|
||||
it('handles object-based conditional classes', () => {
|
||||
expect(cn('p-4', { 'bg-red-500': true, 'text-white': false })).toBe('p-4 bg-red-500');
|
||||
expect(cn('p-4', { 'bg-red-500': false, 'text-white': true })).toBe('p-4 text-white');
|
||||
});
|
||||
|
||||
it('handles array inputs', () => {
|
||||
expect(cn(['flex', 'items-center'], 'justify-between')).toBe('flex items-center justify-between');
|
||||
expect(cn(['p-4', null, 'bg-red-500'])).toBe('p-4 bg-red-500');
|
||||
});
|
||||
|
||||
it('handles empty inputs', () => {
|
||||
expect(cn()).toBe('');
|
||||
expect(cn('', null, undefined, false)).toBe('');
|
||||
});
|
||||
|
||||
it('combines complex scenarios', () => {
|
||||
// Simulate dynamic conditions and variant selection
|
||||
const getIsActive = (): boolean => true;
|
||||
const getPrimaryVariant = (): 'primary' | 'secondary' => 'primary';
|
||||
const getSecondaryVariant = (): 'primary' | 'secondary' => 'secondary';
|
||||
|
||||
const isActive = getIsActive();
|
||||
const primaryVariant = getPrimaryVariant();
|
||||
const secondaryVariant = getSecondaryVariant();
|
||||
|
||||
// Test primary variant
|
||||
expect(
|
||||
cn(
|
||||
'base-class',
|
||||
isActive && 'active-class',
|
||||
{
|
||||
'primary-class': primaryVariant === 'primary',
|
||||
'secondary-class': primaryVariant === 'secondary',
|
||||
},
|
||||
['flex', 'items-center'],
|
||||
'bg-red-500 bg-blue-500' // Should resolve to bg-blue-500
|
||||
)
|
||||
).toBe('base-class active-class primary-class flex items-center bg-blue-500');
|
||||
|
||||
// Test secondary variant
|
||||
expect(
|
||||
cn(
|
||||
'base-class',
|
||||
isActive && 'active-class',
|
||||
{
|
||||
'primary-class': secondaryVariant === 'primary',
|
||||
'secondary-class': secondaryVariant === 'secondary',
|
||||
},
|
||||
['flex', 'items-center']
|
||||
)
|
||||
).toBe('base-class active-class secondary-class flex items-center');
|
||||
});
|
||||
|
||||
it('properly handles spacing and responsive classes', () => {
|
||||
expect(
|
||||
cn('p-2 p-4', 'sm:p-6', 'md:p-8')
|
||||
).toBe('p-4 sm:p-6 md:p-8');
|
||||
|
||||
expect(
|
||||
cn('m-2', 'mx-4', 'ml-6')
|
||||
).toBe('m-2 mx-4 ml-6');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Combines and merges CSS class names using clsx and tailwind-merge.
|
||||
*
|
||||
* This utility function combines the power of clsx for conditional class handling
|
||||
* with tailwind-merge for proper Tailwind CSS class deduplication and conflict resolution.
|
||||
*
|
||||
* @param inputs - Class values to combine (strings, objects, arrays, etc.)
|
||||
* @returns Merged class string with conflicts resolved
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* cn('px-4 py-2', 'bg-blue-500') // 'px-4 py-2 bg-blue-500'
|
||||
* cn('px-4', { 'px-8': true }) // 'px-8' (px-8 overrides px-4)
|
||||
* cn('text-red-500', undefined, 'text-blue-500') // 'text-blue-500'
|
||||
* ```
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Shared helper utilities for common functionality.
|
||||
*/
|
||||
|
||||
export { cn } from './cn/cn';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './types';
|
||||
export * from './functions';
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Props interface for components that accept a className prop.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* interface ButtonProps extends ClassNameProps {
|
||||
* children: string;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface ClassNameProps {
|
||||
/** Optional CSS class name */
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Shared type definitions for common patterns.
|
||||
*/
|
||||
|
||||
export type { ClassNameProps } from './classNameProps';
|
||||
export type { TestIdProps } from './testIdProps';
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Props interface for components that accept a test ID prop for React Testing Library.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* interface ButtonProps extends TestIdProps {
|
||||
* children: string;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface TestIdProps {
|
||||
/** Test identifier for React Testing Library queries */
|
||||
'data-testid'?: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "**/*.test.ts", "**/*.test.tsx", "**/*.stories.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createLibConfig } from '@ermnvldmr/vite-config/lib';
|
||||
|
||||
export default createLibConfig('ErmnvldmrSTL', 'src/index.ts');
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@ermnvldmr/storybook-config",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared Storybook configuration",
|
||||
"type": "module",
|
||||
"main": "./dist/main.js",
|
||||
"exports": {
|
||||
".": "./dist/main.js",
|
||||
"./preview": "./dist/preview.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@storybook/addon-a11y": "^8.6.15",
|
||||
"@storybook/addon-coverage": "^3.0.0",
|
||||
"@storybook/addon-essentials": "^8.6.15",
|
||||
"@storybook/addon-interactions": "^8.6.15",
|
||||
"@storybook/addon-themes": "^8.6.15",
|
||||
"@storybook/react": "^8.6.15",
|
||||
"@storybook/react-vite": "^8.6.15",
|
||||
"@storybook/test": "^8.6.15",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"vite-plugin-istanbul": "^7.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.2.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { basePreviewConfig } from './preview.js';
|
||||
|
||||
export { baseStorybookConfig } from './main.js';
|
||||
export { basePreviewConfig };
|
||||
@@ -0,0 +1,48 @@
|
||||
import { mergeConfig } from 'vite';
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
export const baseStorybookConfig: Partial<StorybookConfig> = {
|
||||
addons: [
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-interactions',
|
||||
'@storybook/addon-a11y',
|
||||
'@storybook/addon-themes',
|
||||
'@storybook/addon-coverage',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
typescript: {
|
||||
check: false,
|
||||
reactDocgen: 'react-docgen-typescript',
|
||||
reactDocgenTypescriptOptions: {
|
||||
shouldExtractLiteralValuesFromEnum: true,
|
||||
propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
|
||||
},
|
||||
},
|
||||
viteFinal: async (config) => {
|
||||
try {
|
||||
const tailwindcss = (await import('@tailwindcss/vite')).default;
|
||||
const istanbul = (await import('vite-plugin-istanbul')).default;
|
||||
|
||||
return mergeConfig(config, {
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
istanbul({
|
||||
include: 'src/**/*',
|
||||
exclude: ['node_modules', 'test/', '**/*.stories.tsx', '**/*.test.tsx'],
|
||||
extension: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
requireEnv: false,
|
||||
cypress: false,
|
||||
forceBuildInstrument: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
// Fallback if plugins can't be loaded
|
||||
console.warn('Failed to load Storybook plugins:', error);
|
||||
return config;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { withThemeByClassName } from '@storybook/addon-themes';
|
||||
import type { Preview } from '@storybook/react';
|
||||
|
||||
/**
|
||||
* Base preview configuration for theme support.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { basePreviewConfig } from '@ermnvldmr/storybook-config/preview';
|
||||
*
|
||||
* export default {
|
||||
* ...basePreviewConfig,
|
||||
* // additional overrides
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export const basePreviewConfig: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
backgrounds: {
|
||||
default: 'light',
|
||||
values: [
|
||||
{
|
||||
name: 'light',
|
||||
value: '#ffffff',
|
||||
},
|
||||
{
|
||||
name: 'dark',
|
||||
value: '#0a0a0a',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
withThemeByClassName({
|
||||
themes: {
|
||||
light: '',
|
||||
dark: 'dark',
|
||||
},
|
||||
defaultTheme: 'light',
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
|
||||
addons: [
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-interactions',
|
||||
'@storybook/addon-a11y',
|
||||
'@storybook/addon-themes',
|
||||
'@storybook/addon-coverage',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
typescript: {
|
||||
check: false,
|
||||
reactDocgen: 'react-docgen-typescript',
|
||||
reactDocgenTypescriptOptions: {
|
||||
shouldExtractLiteralValuesFromEnum: true,
|
||||
propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Preview } from '@storybook/react';
|
||||
import '../src/styles/index.css';
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { reactJestConfig } from '@ermnvldmr/jest-config/react';
|
||||
|
||||
export default reactJestConfig;
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"name": "@ermnvldmr/ui",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared UI components and design system",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./styles": "./dist/styles/index.css"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "tsc && vite build",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"storybook": "storybook dev -p 6007",
|
||||
"build-storybook": "storybook build",
|
||||
"test:storybook": "@storybook/test-runner",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist coverage storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ermnvldmr/stl": "workspace:*",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.10",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"lucide-react": "^0.503.0",
|
||||
"react-aria": "^3.45.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"tw-animate-css": "^1.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ermnvldmr/eslint-config": "workspace:*",
|
||||
"@ermnvldmr/jest-config": "workspace:*",
|
||||
"@ermnvldmr/storybook-config": "workspace:*",
|
||||
"@ermnvldmr/vite-config": "workspace:*",
|
||||
"@storybook/react": "^8.6.15",
|
||||
"@storybook/react-vite": "^8.6.15",
|
||||
"@storybook/test": "^8.6.15",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"storybook": "^8.6.15",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.2.2",
|
||||
"vite-plugin-istanbul": "^7.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwindcss": "^4.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { Stack } from '../Stack/Stack';
|
||||
|
||||
import type { StackProps } from '../Stack/Stack';
|
||||
|
||||
/**
|
||||
* Props for the HStack component (horizontal stack).
|
||||
* Omits direction since HStack is always flex-row.
|
||||
*/
|
||||
export interface HStackProps extends Omit<StackProps, 'direction'> {
|
||||
/** Content to be arranged horizontally */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A horizontal stack layout component that arranges children in a row using CSS Flexbox.
|
||||
*
|
||||
* This is a specialized version of the Stack component with a fixed horizontal direction.
|
||||
* It provides a convenient, semantic way to create horizontal layouts without needing
|
||||
* to specify direction="row" every time.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Simple horizontal stack
|
||||
* <HStack gap={4}>
|
||||
* <button>First</button>
|
||||
* <button>Second</button>
|
||||
* <button>Third</button>
|
||||
* </HStack>
|
||||
*
|
||||
* // Centered horizontal stack with space between
|
||||
* <HStack align="center" justify="between">
|
||||
* <div>Logo</div>
|
||||
* <nav>Navigation</nav>
|
||||
* <div>Profile</div>
|
||||
* </HStack>
|
||||
* ```
|
||||
*/
|
||||
export const HStack = memo(function HStack(props: HStackProps) {
|
||||
return <Stack direction="row" {...props} />;
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Stack } from './Stack';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
/**
|
||||
* A flexible layout component that arranges children using CSS Flexbox.
|
||||
*
|
||||
* The Stack component provides a consistent, type-safe way to create flexible layouts
|
||||
* with proper spacing and alignment. It supports all major flexbox properties
|
||||
* through semantic prop names that map to Tailwind CSS utility classes.
|
||||
*/
|
||||
const meta: Meta<typeof Stack> = {
|
||||
title: 'components/shared/Stack',
|
||||
component: Stack,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'A flexible layout component using CSS Flexbox for consistent spacing and alignment.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
direction: {
|
||||
control: 'select',
|
||||
options: ['row', 'col', 'row-reverse', 'col-reverse'],
|
||||
description: 'Flexbox direction',
|
||||
},
|
||||
justify: {
|
||||
control: 'select',
|
||||
options: ['start', 'end', 'center', 'between', 'around', 'evenly'],
|
||||
description: 'Main axis alignment',
|
||||
},
|
||||
align: {
|
||||
control: 'select',
|
||||
options: ['start', 'end', 'center', 'baseline', 'stretch'],
|
||||
description: 'Cross axis alignment',
|
||||
},
|
||||
wrap: {
|
||||
control: 'select',
|
||||
options: ['nowrap', 'wrap', 'wrap-reverse'],
|
||||
description: 'Flex wrap behavior',
|
||||
},
|
||||
gap: {
|
||||
control: 'select',
|
||||
options: [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 12, 16, 20, 24],
|
||||
description: 'Spacing between items',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
direction: 'row',
|
||||
justify: 'start',
|
||||
align: 'start',
|
||||
wrap: 'nowrap',
|
||||
gap: 4,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
/**
|
||||
*
|
||||
*/
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// Sample content components for demonstrations
|
||||
const SampleBox = ({ children, color = 'bg-primary' }: { children: React.ReactNode; color?: string }): React.JSX.Element => (
|
||||
<div className={`${color} text-primary-foreground px-4 py-2 rounded text-sm font-medium min-w-16 text-center`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Default horizontal stack with basic spacing.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
render: (args) => (
|
||||
<Stack {...args}>
|
||||
<SampleBox>First</SampleBox>
|
||||
<SampleBox color="bg-secondary">Second</SampleBox>
|
||||
<SampleBox color="bg-accent">Third</SampleBox>
|
||||
</Stack>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Vertical stack arrangement with items stacked in a column.
|
||||
*/
|
||||
export const Vertical: Story = {
|
||||
args: {
|
||||
direction: 'col',
|
||||
gap: 3,
|
||||
},
|
||||
render: (args) => (
|
||||
<Stack {...args}>
|
||||
<SampleBox>Top</SampleBox>
|
||||
<SampleBox color="bg-secondary">Middle</SampleBox>
|
||||
<SampleBox color="bg-accent">Bottom</SampleBox>
|
||||
</Stack>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Centered alignment on both axes.
|
||||
*/
|
||||
export const Centered: Story = {
|
||||
args: {
|
||||
direction: 'col',
|
||||
justify: 'center',
|
||||
align: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
render: (args) => (
|
||||
<div className="h-64 w-full border-2 border-dashed border-border">
|
||||
<Stack {...args} className="h-full w-full">
|
||||
<SampleBox>Centered</SampleBox>
|
||||
<SampleBox color="bg-secondary">Content</SampleBox>
|
||||
</Stack>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Space between items with justified distribution.
|
||||
*/
|
||||
export const SpaceBetween: Story = {
|
||||
args: {
|
||||
direction: 'row',
|
||||
justify: 'between',
|
||||
align: 'center',
|
||||
gap: 0,
|
||||
},
|
||||
render: (args) => (
|
||||
<div className="w-80 border-2 border-dashed border-border p-4">
|
||||
<Stack {...args} className="w-full">
|
||||
<SampleBox>Start</SampleBox>
|
||||
<SampleBox color="bg-secondary">Center</SampleBox>
|
||||
<SampleBox color="bg-accent">End</SampleBox>
|
||||
</Stack>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapping layout that allows items to flow to new lines.
|
||||
*/
|
||||
export const Wrapping: Story = {
|
||||
args: {
|
||||
direction: 'row',
|
||||
wrap: 'wrap',
|
||||
gap: 2,
|
||||
},
|
||||
render: (args) => (
|
||||
<div className="w-64 border-2 border-dashed border-border p-4">
|
||||
<Stack {...args} className="w-full">
|
||||
<SampleBox>Item 1</SampleBox>
|
||||
<SampleBox color="bg-secondary">Item 2</SampleBox>
|
||||
<SampleBox color="bg-accent">Item 3</SampleBox>
|
||||
<SampleBox color="bg-destructive">Item 4</SampleBox>
|
||||
<SampleBox>Item 5</SampleBox>
|
||||
<SampleBox color="bg-secondary">Item 6</SampleBox>
|
||||
</Stack>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Reverse direction stacks.
|
||||
*/
|
||||
export const Reversed: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Row Reverse</h3>
|
||||
<Stack direction="row-reverse" gap={3}>
|
||||
<SampleBox>1</SampleBox>
|
||||
<SampleBox color="bg-secondary">2</SampleBox>
|
||||
<SampleBox color="bg-accent">3</SampleBox>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Column Reverse</h3>
|
||||
<Stack direction="col-reverse" gap={3}>
|
||||
<SampleBox>1</SampleBox>
|
||||
<SampleBox color="bg-secondary">2</SampleBox>
|
||||
<SampleBox color="bg-accent">3</SampleBox>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Different gap sizes demonstration including decimal values.
|
||||
*/
|
||||
export const GapSizes: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Gap: 0.5 (Decimal)</h3>
|
||||
<Stack gap={0.5}>
|
||||
<SampleBox>A</SampleBox>
|
||||
<SampleBox color="bg-secondary">B</SampleBox>
|
||||
<SampleBox color="bg-accent">C</SampleBox>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Gap: 1.5 (Decimal)</h3>
|
||||
<Stack gap={1.5}>
|
||||
<SampleBox>A</SampleBox>
|
||||
<SampleBox color="bg-secondary">B</SampleBox>
|
||||
<SampleBox color="bg-accent">C</SampleBox>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Gap: 2.5 (Decimal)</h3>
|
||||
<Stack gap={2.5}>
|
||||
<SampleBox>A</SampleBox>
|
||||
<SampleBox color="bg-secondary">B</SampleBox>
|
||||
<SampleBox color="bg-accent">C</SampleBox>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Gap: 3.5 (Decimal)</h3>
|
||||
<Stack gap={3.5}>
|
||||
<SampleBox>A</SampleBox>
|
||||
<SampleBox color="bg-secondary">B</SampleBox>
|
||||
<SampleBox color="bg-accent">C</SampleBox>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Gap: 4 (Integer)</h3>
|
||||
<Stack gap={4}>
|
||||
<SampleBox>A</SampleBox>
|
||||
<SampleBox color="bg-secondary">B</SampleBox>
|
||||
<SampleBox color="bg-accent">C</SampleBox>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Gap: 8 (Large)</h3>
|
||||
<Stack gap={8}>
|
||||
<SampleBox>A</SampleBox>
|
||||
<SampleBox color="bg-secondary">B</SampleBox>
|
||||
<SampleBox color="bg-accent">C</SampleBox>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Alignment variations on the cross axis.
|
||||
*/
|
||||
export const AlignmentVariations: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Align: Start</h3>
|
||||
<div className="h-20 border-2 border-dashed border-border">
|
||||
<Stack align="start" className="h-full" gap={2}>
|
||||
<SampleBox>Short</SampleBox>
|
||||
<div className="bg-secondary text-secondary-foreground px-4 py-6 rounded text-sm">Tall</div>
|
||||
<SampleBox>Short</SampleBox>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Align: Center</h3>
|
||||
<div className="h-20 border-2 border-dashed border-border">
|
||||
<Stack align="center" className="h-full" gap={2}>
|
||||
<SampleBox>Short</SampleBox>
|
||||
<div className="bg-secondary text-secondary-foreground px-4 py-6 rounded text-sm">Tall</div>
|
||||
<SampleBox>Short</SampleBox>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Align: Stretch</h3>
|
||||
<div className="h-20 border-2 border-dashed border-border">
|
||||
<Stack align="stretch" className="h-full" gap={2}>
|
||||
<SampleBox>Stretched</SampleBox>
|
||||
<SampleBox color="bg-secondary">Stretched</SampleBox>
|
||||
<SampleBox color="bg-accent">Stretched</SampleBox>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Interactive playground for experimenting with all Stack properties.
|
||||
*/
|
||||
export const Playground: Story = {
|
||||
args: {
|
||||
direction: 'row',
|
||||
justify: 'start',
|
||||
align: 'center',
|
||||
wrap: 'nowrap',
|
||||
gap: 4,
|
||||
},
|
||||
render: (args) => (
|
||||
<div className="w-96 h-64 border-2 border-dashed border-border p-4">
|
||||
<Stack {...args} className="h-full w-full">
|
||||
<SampleBox>One</SampleBox>
|
||||
<SampleBox color="bg-secondary">Two</SampleBox>
|
||||
<SampleBox color="bg-accent">Three</SampleBox>
|
||||
<SampleBox color="bg-destructive">Four</SampleBox>
|
||||
</Stack>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
/**
|
||||
* Tailwind gap spacing scale values.
|
||||
*/
|
||||
type GapScale = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
|
||||
|
||||
/**
|
||||
* Flexbox direction values.
|
||||
*/
|
||||
type FlexDirection = 'row' | 'col' | 'row-reverse' | 'col-reverse';
|
||||
|
||||
/**
|
||||
* Flexbox justify-content values.
|
||||
*/
|
||||
type JustifyContent = 'start' | 'end' | 'center' | 'between' | 'around' | 'evenly';
|
||||
|
||||
/**
|
||||
* Flexbox align-items values.
|
||||
*/
|
||||
type AlignItems = 'start' | 'end' | 'center' | 'baseline' | 'stretch';
|
||||
|
||||
/**
|
||||
* Flexbox flex-wrap values.
|
||||
*/
|
||||
type FlexWrap = 'nowrap' | 'wrap' | 'wrap-reverse';
|
||||
|
||||
/**
|
||||
* Props for the flexible Stack layout component.
|
||||
*/
|
||||
export interface StackProps extends ClassNameProps, TestIdProps {
|
||||
/** Content to be arranged in the stack */
|
||||
children: React.ReactNode;
|
||||
/** Flexbox direction (row, column, or their reverse variants) */
|
||||
direction?: FlexDirection;
|
||||
/** How to distribute space between and around content items along the main axis */
|
||||
justify?: JustifyContent;
|
||||
/** How to align items along the cross axis */
|
||||
align?: AlignItems;
|
||||
/** Whether items should wrap to new lines/columns */
|
||||
wrap?: FlexWrap;
|
||||
/** Spacing between items using Tailwind's spacing scale */
|
||||
gap?: GapScale;
|
||||
}
|
||||
|
||||
/**
|
||||
* A flexible layout component that arranges children using CSS Flexbox.
|
||||
*
|
||||
* This component provides a consistent, type-safe way to create flexible layouts
|
||||
* with proper spacing and alignment. It supports all major flexbox properties
|
||||
* through semantic prop names that map to Tailwind CSS utility classes.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Horizontal stack with center alignment
|
||||
* <Stack direction="row" align="center" gap={4}>
|
||||
* <button>First</button>
|
||||
* <button>Second</button>
|
||||
* </Stack>
|
||||
*
|
||||
* // Vertical stack with space between items
|
||||
* <Stack direction="col" justify="between" gap={2}>
|
||||
* <div>Header</div>
|
||||
* <div>Content</div>
|
||||
* <div>Footer</div>
|
||||
* </Stack>
|
||||
* ```
|
||||
*/
|
||||
export const Stack = memo(function Stack({
|
||||
children,
|
||||
direction = 'row',
|
||||
justify = 'start',
|
||||
align = 'start',
|
||||
wrap = 'nowrap',
|
||||
gap = 0,
|
||||
className,
|
||||
'data-testid': testId,
|
||||
}: StackProps) {
|
||||
const directionClasses = {
|
||||
row: 'flex-row',
|
||||
col: 'flex-col',
|
||||
'row-reverse': 'flex-row-reverse',
|
||||
'col-reverse': 'flex-col-reverse',
|
||||
};
|
||||
|
||||
const justifyClasses = {
|
||||
start: 'justify-start',
|
||||
end: 'justify-end',
|
||||
center: 'justify-center',
|
||||
between: 'justify-between',
|
||||
around: 'justify-around',
|
||||
evenly: 'justify-evenly',
|
||||
};
|
||||
|
||||
const alignClasses = {
|
||||
start: 'items-start',
|
||||
end: 'items-end',
|
||||
center: 'items-center',
|
||||
baseline: 'items-baseline',
|
||||
stretch: 'items-stretch',
|
||||
};
|
||||
|
||||
const wrapClasses = {
|
||||
nowrap: 'flex-nowrap',
|
||||
wrap: 'flex-wrap',
|
||||
'wrap-reverse': 'flex-wrap-reverse',
|
||||
};
|
||||
|
||||
// Convert gap scale to Tailwind class using explicit mapping
|
||||
// This ensures all gap classes are detected by Tailwind's static analysis
|
||||
const gapClasses = {
|
||||
0: '',
|
||||
0.5: 'gap-0.5',
|
||||
1: 'gap-1',
|
||||
1.5: 'gap-1.5',
|
||||
2: 'gap-2',
|
||||
2.5: 'gap-2.5',
|
||||
3: 'gap-3',
|
||||
3.5: 'gap-3.5',
|
||||
4: 'gap-4',
|
||||
5: 'gap-5',
|
||||
6: 'gap-6',
|
||||
7: 'gap-7',
|
||||
8: 'gap-8',
|
||||
9: 'gap-9',
|
||||
10: 'gap-10',
|
||||
11: 'gap-11',
|
||||
12: 'gap-12',
|
||||
14: 'gap-14',
|
||||
16: 'gap-16',
|
||||
20: 'gap-20',
|
||||
24: 'gap-24',
|
||||
28: 'gap-28',
|
||||
32: 'gap-32',
|
||||
36: 'gap-36',
|
||||
40: 'gap-40',
|
||||
44: 'gap-44',
|
||||
48: 'gap-48',
|
||||
52: 'gap-52',
|
||||
56: 'gap-56',
|
||||
60: 'gap-60',
|
||||
64: 'gap-64',
|
||||
72: 'gap-72',
|
||||
80: 'gap-80',
|
||||
96: 'gap-96',
|
||||
} as const;
|
||||
|
||||
const gapClass = gapClasses[gap];
|
||||
|
||||
const stackClasses = cn(
|
||||
'flex',
|
||||
directionClasses[direction],
|
||||
justifyClasses[justify],
|
||||
alignClasses[align],
|
||||
wrapClasses[wrap],
|
||||
gapClass,
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={stackClasses}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { Stack } from '../Stack/Stack';
|
||||
|
||||
import type { StackProps } from '../Stack/Stack';
|
||||
|
||||
/**
|
||||
* Props for the VStack component (vertical stack).
|
||||
* Omits direction since VStack is always flex-col.
|
||||
*/
|
||||
export interface VStackProps extends Omit<StackProps, 'direction'> {
|
||||
/** Content to be arranged vertically */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A vertical stack layout component that arranges children in a column using CSS Flexbox.
|
||||
*
|
||||
* This is a specialized version of the Stack component with a fixed vertical direction.
|
||||
* It provides a convenient, semantic way to create vertical layouts without needing
|
||||
* to specify direction="col" every time.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Simple vertical stack
|
||||
* <VStack gap={4}>
|
||||
* <div>Header</div>
|
||||
* <div>Content</div>
|
||||
* <div>Footer</div>
|
||||
* </VStack>
|
||||
*
|
||||
* // Centered vertical stack with spacing
|
||||
* <VStack align="center" justify="center" gap={6}>
|
||||
* <h1>Title</h1>
|
||||
* <p>Description</p>
|
||||
* <button>Action</button>
|
||||
* </VStack>
|
||||
* ```
|
||||
*/
|
||||
export const VStack = memo(function VStack(props: VStackProps) {
|
||||
return <Stack direction="col" {...props} />;
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { HStack } from './HStack/HStack';
|
||||
export { VStack } from './VStack/VStack';
|
||||
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from './Button';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
|
||||
/**
|
||||
* A newspaper-style button component with italic Lato font and underlined text.
|
||||
*
|
||||
* Features:
|
||||
* - Rectangular design with subtle background
|
||||
* - Italic Medium Lato font (500 weight)
|
||||
* - Underlined text decoration
|
||||
* - Accessible interaction patterns via react-aria
|
||||
* - Responsive hover and focus states
|
||||
*/
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: 'components/paper-kit/Button',
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'A newspaper-style button following traditional print design patterns.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['default', 'primary', 'secondary'],
|
||||
description: 'Visual style variant',
|
||||
},
|
||||
children: {
|
||||
control: 'text',
|
||||
description: 'The button text content (string only)',
|
||||
},
|
||||
isDisabled: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the button is disabled',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
children: 'Explore all the articles',
|
||||
variant: 'default',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
/**
|
||||
*
|
||||
*/
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/**
|
||||
* The default newspaper-style button with underlined italic text.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'Explore all the articles',
|
||||
onPress: () => console.log('Default button pressed'),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Primary variant with enhanced visual prominence.
|
||||
*/
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
children: 'Read more stories',
|
||||
onPress: () => console.log('Primary button pressed'),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Secondary variant for supporting actions.
|
||||
*/
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
variant: 'secondary',
|
||||
children: 'Subscribe now',
|
||||
onPress: () => console.log('Secondary button pressed'),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Disabled state of the button.
|
||||
*/
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
children: 'Unavailable action',
|
||||
isDisabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Long text content to test text wrapping and layout.
|
||||
*/
|
||||
export const LongText: Story = {
|
||||
args: {
|
||||
children: 'Explore all the fascinating articles and stories from around the world',
|
||||
onPress: () => console.log('Long text button pressed'),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Interactive demonstration showing all variants side by side.
|
||||
*/
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 items-start">
|
||||
<Button variant="default" onPress={() => console.log('Default')}>
|
||||
Default Style
|
||||
</Button>
|
||||
<Button variant="primary" onPress={() => console.log('Primary')}>
|
||||
Primary Style
|
||||
</Button>
|
||||
<Button variant="secondary" onPress={() => console.log('Secondary')}>
|
||||
Secondary Style
|
||||
</Button>
|
||||
<Button isDisabled>
|
||||
Disabled State
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from './Button';
|
||||
import { render, screen } from '../../../test-utils';
|
||||
|
||||
/**
|
||||
* Test suite for the newspaper-style Button component
|
||||
*/
|
||||
describe('components/paper-kit/Button/Button', () => {
|
||||
const defaultProps = {
|
||||
children: 'Test Button',
|
||||
};
|
||||
|
||||
it('renders correctly with default props', () => {
|
||||
render(<Button {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Test Button' });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveTextContent('Test Button');
|
||||
});
|
||||
|
||||
it('applies correct styling classes', () => {
|
||||
render(<Button {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
// Check for key newspaper styling classes
|
||||
expect(button).toHaveClass('italic');
|
||||
expect(button).toHaveClass('underline');
|
||||
expect(button).toHaveClass('font-medium');
|
||||
expect(button).toHaveClass('bg-background');
|
||||
expect(button).toHaveClass('cursor-pointer');
|
||||
});
|
||||
|
||||
it('handles click events through onPress prop', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handlePress = jest.fn();
|
||||
|
||||
render(<Button onPress={handlePress}>{defaultProps.children}</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
await user.click(button);
|
||||
|
||||
expect(handlePress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('supports different variants', () => {
|
||||
const { rerender } = render(
|
||||
<Button variant="primary">{defaultProps.children}</Button>
|
||||
);
|
||||
|
||||
let button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-primary');
|
||||
|
||||
rerender(<Button variant="secondary">{defaultProps.children}</Button>);
|
||||
button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-secondary');
|
||||
});
|
||||
|
||||
it('handles disabled state correctly', () => {
|
||||
render(<Button isDisabled>{defaultProps.children}</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveClass('disabled:opacity-50');
|
||||
expect(button).toHaveClass('disabled:cursor-not-allowed');
|
||||
});
|
||||
|
||||
it('does not trigger onPress when disabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handlePress = jest.fn();
|
||||
|
||||
render(
|
||||
<Button isDisabled onPress={handlePress}>
|
||||
{defaultProps.children}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
await user.click(button);
|
||||
|
||||
expect(handlePress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supports custom className prop', () => {
|
||||
const customClass = 'custom-button-class';
|
||||
|
||||
render(<Button className={customClass}>{defaultProps.children}</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass(customClass);
|
||||
});
|
||||
|
||||
it('supports data-testid prop', () => {
|
||||
const testId = 'my-test-button';
|
||||
|
||||
render(<Button data-testid={testId}>{defaultProps.children}</Button>);
|
||||
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveTextContent('Test Button');
|
||||
});
|
||||
|
||||
it('only accepts string children', () => {
|
||||
// This test verifies TypeScript constraint - should compile without error
|
||||
render(<Button>Simple string</Button>);
|
||||
expect(screen.getByText('Simple string')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('maintains accessibility attributes', () => {
|
||||
render(
|
||||
<Button
|
||||
aria-describedby="button-description"
|
||||
aria-label="Custom accessibility label"
|
||||
>
|
||||
{defaultProps.children}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('aria-label', 'Custom accessibility label');
|
||||
expect(button).toHaveAttribute('aria-describedby', 'button-description');
|
||||
});
|
||||
|
||||
it('handles keyboard navigation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handlePress = jest.fn();
|
||||
|
||||
render(<Button onPress={handlePress}>{defaultProps.children}</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
button.focus();
|
||||
|
||||
expect(button).toHaveFocus();
|
||||
|
||||
await user.keyboard('{Enter}');
|
||||
expect(handlePress).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.keyboard(' ');
|
||||
expect(handlePress).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('renders string content correctly', () => {
|
||||
render(<Button>Simple text content</Button>);
|
||||
expect(screen.getByText('Simple text content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useButton } from 'react-aria';
|
||||
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
import type { AriaButtonProps } from 'react-aria';
|
||||
|
||||
/**
|
||||
* Props for the newspaper-style Button component.
|
||||
*/
|
||||
export interface ButtonProps extends AriaButtonProps, ClassNameProps, TestIdProps {
|
||||
/** The button text content */
|
||||
children: string;
|
||||
/** Visual style variant */
|
||||
variant?: 'default' | 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
/**
|
||||
* A newspaper-style button component with italic Lato font and underlined text.
|
||||
*
|
||||
* Features:
|
||||
* - Rectangular design with subtle background
|
||||
* - Italic Medium Lato font (500 weight)
|
||||
* - Underlined text decoration
|
||||
* - Accessible interaction patterns via react-aria
|
||||
* - Responsive hover and focus states
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Button onPress={() => console.log('Clicked!')}>
|
||||
* Explore all the articles
|
||||
* </Button>
|
||||
* ```
|
||||
*/
|
||||
export const Button = memo(function Button({
|
||||
children,
|
||||
variant = 'default',
|
||||
className = '',
|
||||
'data-testid': testId,
|
||||
...buttonProps
|
||||
}: ButtonProps) {
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
const { buttonProps: ariaButtonProps } = useButton(buttonProps, ref);
|
||||
|
||||
const baseClasses = [
|
||||
// Layout and spacing
|
||||
'inline-flex items-center justify-center',
|
||||
'px-3 py-2',
|
||||
'min-h-[40px]',
|
||||
|
||||
// Typography
|
||||
'font-sans font-medium italic',
|
||||
'text-base leading-6 tracking-[0.015em]',
|
||||
'text-center underline',
|
||||
|
||||
// Appearance
|
||||
'border border-border',
|
||||
'rounded-none',
|
||||
'transition-all duration-200',
|
||||
|
||||
// Interactive states
|
||||
'cursor-pointer',
|
||||
'hover:bg-muted/50',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'active:bg-muted',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
];
|
||||
|
||||
const variantClasses = {
|
||||
default: [
|
||||
'bg-background text-foreground',
|
||||
'hover:bg-muted/30',
|
||||
],
|
||||
primary: [
|
||||
'bg-primary text-primary-foreground',
|
||||
'hover:bg-primary/90',
|
||||
],
|
||||
secondary: [
|
||||
'bg-secondary text-secondary-foreground',
|
||||
'hover:bg-secondary/90',
|
||||
],
|
||||
};
|
||||
|
||||
const allClasses = [
|
||||
...baseClasses,
|
||||
...variantClasses[variant],
|
||||
className,
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<button
|
||||
{...ariaButtonProps}
|
||||
ref={ref}
|
||||
className={allClasses}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Separator } from './Separator';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
/**
|
||||
* A newspaper-style separator component for dividing content sections.
|
||||
*
|
||||
* Features:
|
||||
* - Single or double line variants inspired by newspaper layout traditions
|
||||
* - Multiple color options (black, outline, outline-light) with theme support
|
||||
* - Horizontal and vertical orientations
|
||||
* - Optional thinned gradient fade effect at line ends (10% fade length)
|
||||
* - 2px stroke width following print design standards
|
||||
* - Semantic div elements with proper ARIA separator role for accessibility
|
||||
*/
|
||||
const meta: Meta<typeof Separator> = {
|
||||
title: 'components/paper-kit/Separator',
|
||||
component: Separator,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'A newspaper-style separator following traditional print design patterns for content division.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
type: {
|
||||
control: 'select',
|
||||
options: ['single', 'double'],
|
||||
description: 'Type of separator line styling',
|
||||
},
|
||||
color: {
|
||||
control: 'select',
|
||||
options: ['black', 'outline', 'outline-light'],
|
||||
description: 'Color variant following newspaper design patterns',
|
||||
},
|
||||
direction: {
|
||||
control: 'select',
|
||||
options: ['horizontal', 'vertical'],
|
||||
description: 'Orientation of the separator',
|
||||
},
|
||||
thinned: {
|
||||
control: 'boolean',
|
||||
description: 'Whether line ends should be thinned with gradient fade (10% fade length)',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
type: 'single',
|
||||
direction: 'horizontal',
|
||||
thinned: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
/**
|
||||
* Story type for Separator component stories.
|
||||
*/
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/**
|
||||
* The default single-line separator with light outline color.
|
||||
* Uses the newspaper-standard 2px stroke with thinned gradient ends.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="w-96 p-8">
|
||||
<div className="text-center text-sm text-muted-foreground mb-4">
|
||||
Content Above
|
||||
</div>
|
||||
<Story />
|
||||
<div className="text-center text-sm text-muted-foreground mt-4">
|
||||
Content Below
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Double-line separator with standard outline color.
|
||||
* Features 4px spacing between the two parallel lines.
|
||||
*/
|
||||
export const Double: Story = {
|
||||
args: {
|
||||
type: 'double',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="w-96 p-8">
|
||||
<div className="text-center text-sm text-muted-foreground mb-4">
|
||||
Content Above
|
||||
</div>
|
||||
<Story />
|
||||
<div className="text-center text-sm text-muted-foreground mt-4">
|
||||
Content Below
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Black color variant for high contrast content separation.
|
||||
* Most prominent separator option following newspaper header styling.
|
||||
*/
|
||||
export const Black: Story = {
|
||||
args: {
|
||||
color: 'black',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="w-96 p-8">
|
||||
<div className="text-center text-sm text-muted-foreground mb-4">
|
||||
Content Above
|
||||
</div>
|
||||
<Story />
|
||||
<div className="text-center text-sm text-muted-foreground mt-4">
|
||||
Content Below
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Light outline color for subtle content divisions.
|
||||
* Ideal for separating related content sections.
|
||||
*/
|
||||
export const OutlineLight: Story = {
|
||||
args: {
|
||||
color: 'outline-light',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="w-96 p-8">
|
||||
<div className="text-center text-sm text-muted-foreground mb-4">
|
||||
Content Above
|
||||
</div>
|
||||
<Story />
|
||||
<div className="text-center text-sm text-muted-foreground mt-4">
|
||||
Content Below
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Vertical separator for side-by-side content division.
|
||||
* Fills the height of its container.
|
||||
*/
|
||||
export const Vertical: Story = {
|
||||
args: {
|
||||
direction: 'vertical',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="flex h-48 p-8">
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
|
||||
Left Content
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<Story />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
|
||||
Right Content
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Double vertical separator for enhanced visual separation.
|
||||
*/
|
||||
export const VerticalDouble: Story = {
|
||||
args: {
|
||||
type: 'double',
|
||||
direction: 'vertical',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="flex h-48 p-8">
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
|
||||
Left Content
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<Story />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
|
||||
Right Content
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Separator without thinned ends showing clean, sharp line edges.
|
||||
* Compare with default to see the gradient fade difference.
|
||||
*/
|
||||
export const NonThinned: Story = {
|
||||
args: {
|
||||
thinned: false,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="w-96 p-8">
|
||||
<div className="text-center text-sm text-muted-foreground mb-4">
|
||||
Content Above (Sharp Edges)
|
||||
</div>
|
||||
<Story />
|
||||
<div className="text-center text-sm text-muted-foreground mt-4">
|
||||
Content Below (Sharp Edges)
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Interactive demonstration showing all color variants side by side.
|
||||
* Useful for comparing color intensity and theme compatibility.
|
||||
*/
|
||||
export const AllColors: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-8 w-96">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Black</h3>
|
||||
<Separator color="black" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Outline (Default for Double)</h3>
|
||||
<Separator color="outline" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Outline Light (Default for Single)</h3>
|
||||
<Separator color="outline-light" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Demonstration of all type variants with their default colors.
|
||||
* Shows the automatic color selection behavior.
|
||||
*/
|
||||
export const AllTypes: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-8 w-96">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Single (Default: Outline Light)</h3>
|
||||
<Separator type="single" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Double (Default: Outline)</h3>
|
||||
<Separator type="double" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Comparison between thinned and non-thinned effects.
|
||||
* Demonstrates the 10% gradient fade at line ends.
|
||||
*/
|
||||
export const ThinnedComparison: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-8 w-96">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">With Thinned Ends (Default)</h3>
|
||||
<Separator thinned />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Without Thinned Ends</h3>
|
||||
<Separator thinned={false} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Comprehensive showcase of vertical separators in different contexts.
|
||||
* Demonstrates container height adaptation and spacing.
|
||||
*/
|
||||
export const VerticalShowcase: Story = {
|
||||
render: () => (
|
||||
<div className="flex space-x-8">
|
||||
<div className="flex h-32">
|
||||
<div className="flex items-center justify-center w-20 text-xs text-muted-foreground">
|
||||
Short
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<Separator direction="vertical" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-20 text-xs text-muted-foreground">
|
||||
Content
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-48">
|
||||
<div className="flex items-center justify-center w-20 text-xs text-muted-foreground">
|
||||
Medium
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<Separator direction="vertical" type="double" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-20 text-xs text-muted-foreground">
|
||||
Content
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-64">
|
||||
<div className="flex items-center justify-center w-20 text-xs text-muted-foreground">
|
||||
Tall
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<Separator color="black" direction="vertical" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-20 text-xs text-muted-foreground">
|
||||
Content
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { VStack } from '../../generic/VStack/VStack';
|
||||
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
|
||||
/**
|
||||
* Props for the newspaper-style Separator component.
|
||||
*/
|
||||
export interface SeparatorProps extends ClassNameProps, TestIdProps {
|
||||
/** Type of separator line styling */
|
||||
type?: 'single' | 'double';
|
||||
/** Color variant following newspaper design patterns */
|
||||
color?: 'black' | 'outline' | 'outline-light';
|
||||
/** Orientation of the separator */
|
||||
direction?: 'horizontal' | 'vertical';
|
||||
/** Whether line ends should be thinned with gradient fade */
|
||||
thinned?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A newspaper-style separator component for dividing content sections.
|
||||
*
|
||||
* Features:
|
||||
* - Single or double line variants inspired by newspaper layout traditions
|
||||
* - Multiple color options (black, outline, outline-light) with theme support
|
||||
* - Horizontal and vertical orientations
|
||||
* - Optional thinned gradient fade effect at line ends (10% fade length)
|
||||
* - 2px stroke width following print design standards
|
||||
* - Semantic HTML with proper ARIA attributes for accessibility
|
||||
*
|
||||
* The component uses CSS mask-image for the thinned effect and CSS custom properties
|
||||
* for theme-aware colors. Double separators have 4px spacing between lines.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic horizontal separator
|
||||
* <Separator />
|
||||
*
|
||||
* // Double-line separator with black color
|
||||
* <Separator type="double" color="black" />
|
||||
*
|
||||
* // Vertical separator without thinned ends
|
||||
* <Separator direction="vertical" thinned={false} />
|
||||
*
|
||||
* // Light separator for subtle content division
|
||||
* <Separator color="outline-light" />
|
||||
* ```
|
||||
*/
|
||||
export const Separator = memo(function Separator({
|
||||
type = 'single',
|
||||
color,
|
||||
direction = 'horizontal',
|
||||
thinned = true,
|
||||
className,
|
||||
'data-testid': testId,
|
||||
}: SeparatorProps) {
|
||||
// Default color based on type as specified
|
||||
const defaultColor = type === 'single' ? 'outline-light' : 'outline';
|
||||
const finalColor = color ?? defaultColor;
|
||||
|
||||
// Color mapping to CSS custom properties
|
||||
const colorClasses = {
|
||||
black: 'border-foreground',
|
||||
outline: 'border-border',
|
||||
'outline-light': 'border-muted',
|
||||
};
|
||||
|
||||
// Base line styles
|
||||
const lineBaseClasses = [
|
||||
'border-0',
|
||||
'border-solid',
|
||||
colorClasses[finalColor],
|
||||
// 2px stroke as specified
|
||||
direction === 'horizontal' ? 'border-t-2' : 'border-l-2',
|
||||
];
|
||||
|
||||
// Thinned effect using CSS mask
|
||||
const thinnedClasses = thinned ? [
|
||||
direction === 'horizontal'
|
||||
? '[mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]'
|
||||
: '[mask-image:linear-gradient(to_bottom,transparent,black_10%,black_90%,transparent)]',
|
||||
] : [];
|
||||
|
||||
// Size classes - fill container by default
|
||||
const sizeClasses = direction === 'horizontal'
|
||||
? ['w-full', 'h-0'] // Full width, no height (border provides the visual)
|
||||
: ['h-full', 'w-0']; // Full height, no width (border provides the visual)
|
||||
|
||||
// Single line implementation
|
||||
if (type === 'single') {
|
||||
const lineClasses = cn(
|
||||
...lineBaseClasses,
|
||||
...thinnedClasses,
|
||||
...sizeClasses,
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-orientation={direction}
|
||||
className={lineClasses}
|
||||
data-testid={testId}
|
||||
role="separator"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Double line implementation
|
||||
const singleLineClasses = cn(
|
||||
...lineBaseClasses,
|
||||
...thinnedClasses,
|
||||
...sizeClasses
|
||||
);
|
||||
|
||||
const containerClasses = cn(
|
||||
direction === 'horizontal' ? 'w-full' : 'h-full',
|
||||
className
|
||||
);
|
||||
|
||||
if (direction === 'horizontal') {
|
||||
return (
|
||||
<div
|
||||
aria-orientation="horizontal"
|
||||
className={containerClasses}
|
||||
data-testid={testId}
|
||||
role="separator"
|
||||
>
|
||||
<VStack gap={1}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={singleLineClasses}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={singleLineClasses}
|
||||
/>
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
aria-orientation="vertical"
|
||||
className={cn('flex flex-row gap-1', containerClasses)}
|
||||
data-testid={testId}
|
||||
role="separator"
|
||||
>
|
||||
<div aria-hidden="true" className={singleLineClasses} />
|
||||
<div aria-hidden="true" className={singleLineClasses} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Button } from './PaperButton/Button'
|
||||
export { Separator } from './PaperSeparator/Separator'
|
||||
@@ -0,0 +1,6 @@
|
||||
// Export all components
|
||||
export * from './components/paper';
|
||||
export * from './components/generic';
|
||||
|
||||
// Import styles to ensure they're bundled
|
||||
import './styles/index.css';
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -0,0 +1,315 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/*
|
||||
@layer base defaults inspired by shadcn UI Typography
|
||||
Provides opinionated, utility-driven defaults for HTML elements.
|
||||
*/
|
||||
@layer base {
|
||||
/* Headings */
|
||||
h1 {
|
||||
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
|
||||
}
|
||||
h2 {
|
||||
@apply mt-10 scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0;
|
||||
}
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply mt-8 scroll-m-20 text-2xl font-semibold tracking-tight;
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
p {
|
||||
@apply leading-7 [&:not(:first-child)]:mt-6;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
@apply text-ring font-medium underline underline-offset-2;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
blockquote {
|
||||
@apply mt-6 border-l-2 pl-6 italic;
|
||||
}
|
||||
|
||||
/* Code */
|
||||
code {
|
||||
@apply bg-muted rounded px-1 font-mono text-sm;
|
||||
}
|
||||
pre {
|
||||
@apply bg-muted overflow-auto rounded-lg p-4 font-mono text-sm;
|
||||
}
|
||||
|
||||
/* Images and Media */
|
||||
img,
|
||||
video {
|
||||
@apply max-w-full rounded-lg;
|
||||
}
|
||||
|
||||
/* Horizontal rules */
|
||||
hr {
|
||||
@apply border-border my-6 border-t;
|
||||
}
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Lato', sans-serif;
|
||||
--font-serif: 'EB Garamond', serif;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-rainby-red-50: #fef4f2;
|
||||
--color-rainby-red-100: #ffe7e1;
|
||||
--color-rainby-red-200: #ffd2c8;
|
||||
--color-rainby-red-300: #ffb4a3;
|
||||
--color-rainby-red-400: #fd876c;
|
||||
--color-rainby-red-500: #f5603e;
|
||||
--color-rainby-red-600: #e24420;
|
||||
--color-rainby-red-700: #be3617;
|
||||
--color-rainby-red-800: #9d3017;
|
||||
--color-rainby-red-900: #822d1a;
|
||||
--color-rainby-red-950: #471408;
|
||||
|
||||
--color-rainby-orange-50: #fff8ed;
|
||||
--color-rainby-orange-100: #ffefd5;
|
||||
--color-rainby-orange-200: #fedbaa;
|
||||
--color-rainby-orange-300: #fdc175;
|
||||
--color-rainby-orange-400: #fb9b3c;
|
||||
--color-rainby-orange-500: #f97e16;
|
||||
--color-rainby-orange-600: #ea630c;
|
||||
--color-rainby-orange-700: #c24a0c;
|
||||
--color-rainby-orange-800: #9a3b12;
|
||||
--color-rainby-orange-900: #7c3212;
|
||||
--color-rainby-orange-950: #431707;
|
||||
|
||||
--color-rainby-yellow-50: #fefde8;
|
||||
--color-rainby-yellow-100: #fefdc3;
|
||||
--color-rainby-yellow-200: #fef9a3;
|
||||
--color-rainby-yellow-300: #fdec47;
|
||||
--color-rainby-yellow-400: #f9db16;
|
||||
--color-rainby-yellow-500: #e9c209;
|
||||
--color-rainby-yellow-600: #ca9704;
|
||||
--color-rainby-yellow-700: #a16c07;
|
||||
--color-rainby-yellow-800: #85550e;
|
||||
--color-rainby-yellow-900: #714512;
|
||||
--color-rainby-yellow-950: #422406;
|
||||
|
||||
--color-rainby-green-50: #f8faeb;
|
||||
--color-rainby-green-100: #edf4d3;
|
||||
--color-rainby-green-200: #ddebab;
|
||||
--color-rainby-green-300: #c4dc7a;
|
||||
--color-rainby-green-400: #b6d167;
|
||||
--color-rainby-green-500: #8daf33;
|
||||
--color-rainby-green-600: #6e8b25;
|
||||
--color-rainby-green-700: #546b20;
|
||||
--color-rainby-green-800: #44551f;
|
||||
--color-rainby-green-900: #3a491e;
|
||||
--color-rainby-green-950: #1e280b;
|
||||
|
||||
--color-rainby-cyan-50: #effbfc;
|
||||
--color-rainby-cyan-100: #d7f2f6;
|
||||
--color-rainby-cyan-200: #b4e6ed;
|
||||
--color-rainby-cyan-300: #81d3e0;
|
||||
--color-rainby-cyan-400: #45b6cb;
|
||||
--color-rainby-cyan-500: #2a9ab0;
|
||||
--color-rainby-cyan-600: #257d95;
|
||||
--color-rainby-cyan-700: #24657a;
|
||||
--color-rainby-cyan-800: #265464;
|
||||
--color-rainby-cyan-900: #234756;
|
||||
--color-rainby-cyan-950: #122e3a;
|
||||
|
||||
--color-rainby-blue-50: #eff5ff;
|
||||
--color-rainby-blue-100: #dbe8fe;
|
||||
--color-rainby-blue-200: #bfd8fe;
|
||||
--color-rainby-blue-300: #a2c9fe;
|
||||
--color-rainby-blue-400: #5f9efb;
|
||||
--color-rainby-blue-500: #3a79f7;
|
||||
--color-rainby-blue-600: #2358ed;
|
||||
--color-rainby-blue-700: #1c44d9;
|
||||
--color-rainby-blue-800: #1d38b0;
|
||||
--color-rainby-blue-900: #1d358b;
|
||||
--color-rainby-blue-950: #172254;
|
||||
|
||||
--color-rainby-violet-50: #eef0ff;
|
||||
--color-rainby-violet-100: #dfe3ff;
|
||||
--color-rainby-violet-200: #bdc2ff;
|
||||
--color-rainby-violet-300: #a3a8fe;
|
||||
--color-rainby-violet-400: #837ffa;
|
||||
--color-rainby-violet-500: #6e60f4;
|
||||
--color-rainby-violet-600: #6043e8;
|
||||
--color-rainby-violet-700: #5435cd;
|
||||
--color-rainby-violet-800: #442ea5;
|
||||
--color-rainby-violet-900: #3b2c83;
|
||||
--color-rainby-violet-950: #231a4c;
|
||||
|
||||
--color-rainby-neutral-50: #f6f6f6;
|
||||
--color-rainby-neutral-100: #e7e7e7;
|
||||
--color-rainby-neutral-200: #d1d1d1;
|
||||
--color-rainby-neutral-300: #b0b0b0;
|
||||
--color-rainby-neutral-400: #888888;
|
||||
--color-rainby-neutral-500: #737373;
|
||||
--color-rainby-neutral-600: #5d5d5d;
|
||||
--color-rainby-neutral-700: #4f4f4f;
|
||||
--color-rainby-neutral-800: #454545;
|
||||
--color-rainby-neutral-900: #3d3d3d;
|
||||
--color-rainby-neutral-950: #262626;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Core palette */
|
||||
--background: var(--color-rainby-neutral-50);
|
||||
--border: var(--color-rainby-neutral-500);
|
||||
--input: var(--color-rainby-neutral-800);
|
||||
--foreground: var(--color-rainby-neutral-950);
|
||||
--ring: var(--color-rainby-cyan-700);
|
||||
|
||||
/* Surfaces */
|
||||
--card: var(--color-rainby-neutral-50);
|
||||
--card-foreground: var(--color-rainby-neutral-950);
|
||||
--popover: var(--color-rainby-neutral-50);
|
||||
--popover-foreground: var(--color-rainby-neutral-950);
|
||||
|
||||
/* Semantic */
|
||||
--primary: var(--color-rainby-cyan-200);
|
||||
--primary-foreground: var(--color-rainby-cyan-950);
|
||||
--secondary: var(--color-rainby-green-200);
|
||||
--secondary-foreground: var(--color-rainby-green-950);
|
||||
--muted: var(--color-rainby-neutral-300);
|
||||
--muted-foreground: var(--color-rainby-neutral-600);
|
||||
--accent: var(--color-rainby-violet-200);
|
||||
--accent-foreground: var(--color-rainby-violet-950);
|
||||
--destructive: var(--color-rainby-red-200);
|
||||
--destructive-foreground: var(--color-rainby-red-950);
|
||||
|
||||
/* Charts (e.g. Recharts in ShadCN examples) */
|
||||
--chart-1: var(--color-rainby-blue-500);
|
||||
--chart-2: var(--color-rainby-green-500);
|
||||
--chart-3: var(--color-rainby-yellow-500);
|
||||
--chart-4: var(--color-rainby-orange-500);
|
||||
--chart-5: var(--color-rainby-red-500);
|
||||
|
||||
/* Sidebar (if you use ShadCN’s sidebar components) */
|
||||
--sidebar: var(--color-rainby-neutral-50);
|
||||
--sidebar-foreground: var(--color-rainby-neutral-950);
|
||||
--sidebar-primary: var(--color-rainby-cyan-200);
|
||||
--sidebar-primary-foreground: var(--color-rainby-cyan-950);
|
||||
--sidebar-accent: var(--color-rainby-violet-200);
|
||||
--sidebar-accent-foreground: var(--color-rainby-violet-950);
|
||||
--sidebar-border: var(--color-rainby-neutral-500);
|
||||
--sidebar-ring: var(--color-rainby-cyan-700);
|
||||
|
||||
/* Radii */
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
/* ──────────────── Dark Mode Override ──────────────── */
|
||||
.dark {
|
||||
/* Core palette flips */
|
||||
--background: var(--color-rainby-neutral-950);
|
||||
--ring: var(--color-rainby-cyan-300);
|
||||
--border: var(--color-rainby-neutral-500);
|
||||
--input: var(--color-rainby-neutral-300);
|
||||
--foreground: var(--color-rainby-neutral-50);
|
||||
|
||||
/* Surfaces */
|
||||
--card: var(--color-rainby-neutral-950);
|
||||
--card-foreground: var(--color-rainby-neutral-50);
|
||||
--popover: var(--color-rainby-neutral-950);
|
||||
--popover-foreground: var(--color-rainby-neutral-50);
|
||||
|
||||
/* Semantic */
|
||||
--primary: var(--color-rainby-cyan-800);
|
||||
--primary-foreground: var(--color-rainby-cyan-50);
|
||||
--secondary: var(--color-rainby-green-800);
|
||||
--secondary-foreground: var(--color-rainby-green-50);
|
||||
--muted: var(--color-rainby-neutral-600);
|
||||
--muted-foreground: var(--color-rainby-neutral-100);
|
||||
--accent: var(--color-rainby-violet-800);
|
||||
--accent-foreground: var(--color-rainby-violet-50);
|
||||
--destructive: var(--color-rainby-red-800);
|
||||
--destructive-foreground: var(--color-rainby-red-50);
|
||||
|
||||
/* Charts */
|
||||
--chart-1: var(--color-rainby-blue-400);
|
||||
--chart-2: var(--color-rainby-green-400);
|
||||
--chart-3: var(--color-rainby-yellow-400);
|
||||
--chart-4: var(--color-rainby-orange-400);
|
||||
--chart-5: var(--color-rainby-red-400);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: var(--color-rainby-neutral-950);
|
||||
--sidebar-foreground: var(--color-rainby-neutral-50);
|
||||
--sidebar-primary: var(--color-rainby-cyan-800);
|
||||
--sidebar-primary-foreground: var(--color-rainby-cyan-50);
|
||||
--sidebar-accent: var(--color-rainby-violet-800);
|
||||
--sidebar-accent-foreground: var(--color-rainby-violet-50);
|
||||
--sidebar-border: var(--color-rainby-neutral-500);
|
||||
--sidebar-ring: var(--color-rainby-cyan-700);
|
||||
|
||||
/* Radii stays same */
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
@utility frosted-glass {
|
||||
@apply bg-background/30 rounded-lg shadow-lg backdrop-blur-md backdrop-opacity-75 backdrop-filter;
|
||||
}
|
||||
|
||||
@utility vstack {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
@utility hstack {
|
||||
@apply flex flex-row;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import type { RenderOptions, RenderResult } from '@testing-library/react';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
/**
|
||||
* Custom render function that sets up any required providers
|
||||
* @param ui - React component to render
|
||||
* @param options - Render options excluding wrapper
|
||||
* @returns RenderResult from testing library
|
||||
* @example
|
||||
* ```tsx
|
||||
* render(<Button>Click me</Button>);
|
||||
* ```
|
||||
*/
|
||||
function customRender(
|
||||
ui: ReactElement,
|
||||
options?: Omit<RenderOptions, 'wrapper'>
|
||||
): RenderResult {
|
||||
return render(ui, {
|
||||
// Add any providers here if needed in the future
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export everything from testing library
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export * from '@testing-library/react';
|
||||
|
||||
// Override render method
|
||||
export { customRender as render };
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "**/*.test.ts", "**/*.test.tsx", "**/*.stories.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createLibConfig } from '@ermnvldmr/vite-config/lib';
|
||||
|
||||
export default createLibConfig('ErmnvldmrUI', 'src/index.ts');
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@ermnvldmr/vite-config",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared Vite configuration",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./lib": "./dist/lib.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"vite": "^7.2.2",
|
||||
"vite-plugin-dts": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3",
|
||||
"@types/node": "^25.0.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Vite configuration utilities for the monorepo.
|
||||
*/
|
||||
|
||||
export { createLibConfig } from './lib.js';
|
||||
@@ -0,0 +1,65 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
import dts from 'vite-plugin-dts';
|
||||
import type { UserConfig } from 'vite';
|
||||
|
||||
/**
|
||||
* Base Vite configuration for library packages.
|
||||
*
|
||||
* @param packageName - The name for the library build
|
||||
* @param entry - Entry point file path (relative to package root)
|
||||
* @param external - Array of external dependencies to exclude from bundle
|
||||
* @returns Vite configuration object
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createLibConfig } from '@ermnvldmr/vite-config/lib';
|
||||
*
|
||||
* export default createLibConfig('MyLib', 'src/index.ts', ['react', 'react-dom']);
|
||||
* ```
|
||||
*/
|
||||
export function createLibConfig(
|
||||
packageName: string,
|
||||
entry: string = 'src/index.ts',
|
||||
external: string[] = []
|
||||
): UserConfig {
|
||||
return defineConfig({
|
||||
plugins: [
|
||||
dts({
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(process.cwd(), entry),
|
||||
name: packageName,
|
||||
formats: ['es'],
|
||||
fileName: 'index',
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'react/jsx-runtime',
|
||||
'react/jsx-dev-runtime',
|
||||
'tailwindcss',
|
||||
...external
|
||||
],
|
||||
output: {
|
||||
globals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
'react/jsx-runtime': 'React',
|
||||
'react/jsx-dev-runtime': 'React',
|
||||
},
|
||||
},
|
||||
},
|
||||
cssCodeSplit: false,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(process.cwd(), 'src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Reference in new issue
Block a user