refactor: Migrate to monorepo

BREAKING CHANGE: Complete structure overhaul
This commit is contained in:
deytenit committed 2026-01-24 23:28:36 +03:00
1 parent 5300b4bae0
commit 46a2f68342
496 files changed
+45388 -7531

No files matched your search

+169
View File
@@ -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
);
+26
View File
@@ -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"
}
}
+39
View File
@@ -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"
}
}
+43
View File
@@ -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}',
],
};
+6
View File
@@ -0,0 +1,6 @@
/**
* Jest configuration exports for the monorepo.
*/
export { baseJestConfig } from './base.js';
export { reactJestConfig } from './react.js';
+31
View File
@@ -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,
},
],
},
};
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"types": []
},
"include": ["src/**/*"],
"exclude": ["dist"]
}
+3
View File
@@ -0,0 +1,3 @@
import { baseJestConfig } from '@ermnvldmr/jest-config/base';
export default baseJestConfig;
+41
View File
@@ -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"
}
}
+95
View File
@@ -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');
});
});
+22
View File
@@ -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));
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Shared helper utilities for common functionality.
*/
export { cn } from './cn/cn';
+2
View File
@@ -0,0 +1,2 @@
export * from './types';
export * from './functions';
+14
View File
@@ -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;
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Shared type definitions for common patterns.
*/
export type { ClassNameProps } from './classNameProps';
export type { TestIdProps } from './testIdProps';
+14
View File
@@ -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;
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"],
"exclude": ["dist", "**/*.test.ts", "**/*.test.tsx", "**/*.stories.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { createLibConfig } from '@ermnvldmr/vite-config/lib';
export default createLibConfig('ErmnvldmrSTL', 'src/index.ts');
+35
View File
@@ -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"
}
}
+4
View File
@@ -0,0 +1,4 @@
import { basePreviewConfig } from './preview.js';
export { baseStorybookConfig } from './main.js';
export { basePreviewConfig };
+48
View File
@@ -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;
}
},
};
+48
View File
@@ -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',
}),
],
};
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"types": []
},
"include": ["src/**/*"],
"exclude": ["dist"]
}
+26
View File
@@ -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;
+15
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
import { reactJestConfig } from '@ermnvldmr/jest-config/react';
export default reactJestConfig;
+69
View File
@@ -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'
+6
View File
@@ -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';
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+315
View File
@@ -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;
}
}
+31
View File
@@ -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 };
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"],
"exclude": ["dist", "**/*.test.ts", "**/*.test.tsx", "**/*.stories.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { createLibConfig } from '@ermnvldmr/vite-config/lib';
export default createLibConfig('ErmnvldmrUI', 'src/index.ts');
+31
View File
@@ -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"
}
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Vite configuration utilities for the monorepo.
*/
export { createLibConfig } from './lib.js';
+65
View File
@@ -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'),
},
},
});
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"types": []
},
"include": ["src/**/*"],
"exclude": ["dist"]
}