This commit is contained in:
sunzhongyi
2026-05-19 21:09:56 +08:00
commit f3e6b95be9
78 changed files with 10099 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@newspaperui/utils",
"version": "0.0.0",
"description": "Utility functions for newspaperui",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "vite build",
"dev": "vite build --watch",
"lint": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf dist"
},
"devDependencies": {
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vite-plugin-dts": "^4.3.0",
"vitest": "^2.1.8"
}
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { calculateSpanWidth, validateSpan, calculateGutter } from '../grid';
describe('grid utilities', () => {
describe('calculateSpanWidth', () => {
it('should calculate correct width percentage for 24-column grid', () => {
expect(calculateSpanWidth(6, 24)).toBe('25.000%');
expect(calculateSpanWidth(12, 24)).toBe('50.000%');
expect(calculateSpanWidth(24, 24)).toBe('100.000%');
expect(calculateSpanWidth(1, 24)).toBe('4.167%');
});
it('should calculate correct width percentage for 12-column grid', () => {
expect(calculateSpanWidth(6, 12)).toBe('50.000%');
expect(calculateSpanWidth(3, 12)).toBe('25.000%');
expect(calculateSpanWidth(12, 12)).toBe('100.000%');
});
it('should use 24 columns as default', () => {
expect(calculateSpanWidth(6)).toBe('25.000%');
expect(calculateSpanWidth(12)).toBe('50.000%');
});
it('should throw error for invalid span', () => {
expect(() => calculateSpanWidth(0, 24)).toThrow();
expect(() => calculateSpanWidth(25, 24)).toThrow();
expect(() => calculateSpanWidth(-1, 24)).toThrow();
});
});
describe('validateSpan', () => {
it('should return true for valid spans', () => {
expect(validateSpan(1, 24)).toBe(true);
expect(validateSpan(12, 24)).toBe(true);
expect(validateSpan(24, 24)).toBe(true);
});
it('should return false for invalid spans', () => {
expect(validateSpan(0, 24)).toBe(false);
expect(validateSpan(25, 24)).toBe(false);
expect(validateSpan(-1, 24)).toBe(false);
expect(validateSpan(1.5, 24)).toBe(false);
});
});
describe('calculateGutter', () => {
it('should calculate gutter width correctly', () => {
const gutter = calculateGutter(1440, 24, 0.05);
expect(gutter).toBeGreaterThan(0);
expect(gutter).toBeLessThan(100);
});
it('should throw error for invalid inputs', () => {
expect(() => calculateGutter(0, 24, 0.05)).toThrow();
expect(() => calculateGutter(1440, 0, 0.05)).toThrow();
expect(() => calculateGutter(1440, 24, -0.1)).toThrow();
expect(() => calculateGutter(1440, 24, 1.5)).toThrow();
});
});
});
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import {
calculateSpanWidth,
calculateLayout,
getResponsiveColumns,
adjustSpanForScreen,
} from '../index';
describe('integration tests', () => {
it('should work together for responsive layout calculation', () => {
// Scenario: Layout 3 items on different screen sizes
const items = [
{ id: 'headline', span: 12 },
{ id: 'image', span: 8 },
{ id: 'text', span: 4 },
];
// Large screen (24 columns)
const largeScreenColumns = getResponsiveColumns(1440);
expect(largeScreenColumns).toBe(24);
const largeLayout = calculateLayout(items, largeScreenColumns);
expect(largeLayout).toHaveLength(3);
// All items fit in one row: 12 + 8 + 4 = 24
expect(largeLayout[0].row).toBe(0);
expect(largeLayout[1].row).toBe(0);
expect(largeLayout[2].row).toBe(0);
// Medium screen (16 columns) - adjust spans
const mediumScreenColumns = getResponsiveColumns(900);
expect(mediumScreenColumns).toBe(16);
const adjustedItems = items.map((item) => ({
...item,
span: adjustSpanForScreen(item.span, 900),
}));
const mediumLayout = calculateLayout(adjustedItems, mediumScreenColumns);
expect(mediumLayout).toHaveLength(3);
// Small screen (12 columns) - adjust spans
const smallScreenColumns = getResponsiveColumns(600);
expect(smallScreenColumns).toBe(12);
const smallAdjustedItems = items.map((item) => ({
...item,
span: adjustSpanForScreen(item.span, 600),
}));
const smallLayout = calculateLayout(smallAdjustedItems, smallScreenColumns);
expect(smallLayout).toHaveLength(3);
});
it('should calculate correct widths for newspaper layout', () => {
// 6-column headline in 24-column grid
const headlineWidth = calculateSpanWidth(6, 24);
expect(headlineWidth).toBe('25.000%');
// 8-column image in 24-column grid
const imageWidth = calculateSpanWidth(8, 24);
expect(imageWidth).toBe('33.333%');
// Full-width article
const fullWidth = calculateSpanWidth(24, 24);
expect(fullWidth).toBe('100.000%');
});
});
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import {
getResponsiveColumns,
adjustSpanForScreen,
isBreakpoint,
getCurrentBreakpoint,
BREAKPOINTS,
} from '../responsive';
describe('responsive utilities', () => {
describe('getResponsiveColumns', () => {
it('should return 12 columns for small screens', () => {
expect(getResponsiveColumns(320)).toBe(12);
expect(getResponsiveColumns(767)).toBe(12);
});
it('should return 16 columns for medium screens', () => {
expect(getResponsiveColumns(768)).toBe(16);
expect(getResponsiveColumns(1023)).toBe(16);
});
it('should return 24 columns for large screens', () => {
expect(getResponsiveColumns(1024)).toBe(24);
expect(getResponsiveColumns(1440)).toBe(24);
expect(getResponsiveColumns(1920)).toBe(24);
});
});
describe('adjustSpanForScreen', () => {
it('should not adjust span for large screens (24 columns)', () => {
expect(adjustSpanForScreen(12, 1440)).toBe(12);
expect(adjustSpanForScreen(6, 1920)).toBe(6);
expect(adjustSpanForScreen(24, 1024)).toBe(24);
});
it('should proportionally adjust span for medium screens (16 columns)', () => {
expect(adjustSpanForScreen(12, 800)).toBe(8); // 12/24 * 16 = 8
expect(adjustSpanForScreen(6, 900)).toBe(4); // 6/24 * 16 = 4
expect(adjustSpanForScreen(24, 1000)).toBe(16); // 24/24 * 16 = 16
});
it('should proportionally adjust span for small screens (12 columns)', () => {
expect(adjustSpanForScreen(12, 600)).toBe(6); // 12/24 * 12 = 6
expect(adjustSpanForScreen(6, 400)).toBe(3); // 6/24 * 12 = 3
expect(adjustSpanForScreen(24, 700)).toBe(12); // 24/24 * 12 = 12
});
it('should ensure minimum span of 1', () => {
expect(adjustSpanForScreen(1, 600)).toBe(1);
expect(adjustSpanForScreen(2, 600)).toBeGreaterThanOrEqual(1);
});
it('should not exceed target columns', () => {
expect(adjustSpanForScreen(24, 600)).toBeLessThanOrEqual(12);
expect(adjustSpanForScreen(24, 800)).toBeLessThanOrEqual(16);
});
});
describe('isBreakpoint', () => {
it('should correctly identify breakpoints', () => {
expect(isBreakpoint(768, 'sm')).toBe(true);
expect(isBreakpoint(767, 'sm')).toBe(false);
expect(isBreakpoint(1024, 'md')).toBe(true);
expect(isBreakpoint(1023, 'md')).toBe(false);
expect(isBreakpoint(1440, 'lg')).toBe(true);
expect(isBreakpoint(1439, 'lg')).toBe(false);
});
});
describe('getCurrentBreakpoint', () => {
it('should return correct breakpoint names', () => {
expect(getCurrentBreakpoint(320)).toBe('xs');
expect(getCurrentBreakpoint(767)).toBe('xs');
expect(getCurrentBreakpoint(768)).toBe('sm');
expect(getCurrentBreakpoint(1023)).toBe('sm');
expect(getCurrentBreakpoint(1024)).toBe('md');
expect(getCurrentBreakpoint(1439)).toBe('md');
expect(getCurrentBreakpoint(1440)).toBe('lg');
expect(getCurrentBreakpoint(1920)).toBe('lg');
});
});
describe('BREAKPOINTS', () => {
it('should have correct breakpoint values', () => {
expect(BREAKPOINTS.sm).toBe(768);
expect(BREAKPOINTS.md).toBe(1024);
expect(BREAKPOINTS.lg).toBe(1440);
});
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { isSpanValid, calculateLayout, type LayoutItem } from '../span';
describe('span utilities', () => {
describe('isSpanValid', () => {
it('should return true for valid spans', () => {
expect(isSpanValid(1, 24)).toBe(true);
expect(isSpanValid(12, 24)).toBe(true);
expect(isSpanValid(24, 24)).toBe(true);
expect(isSpanValid(6, 12)).toBe(true);
});
it('should return false for invalid spans', () => {
expect(isSpanValid(0, 24)).toBe(false);
expect(isSpanValid(25, 24)).toBe(false);
expect(isSpanValid(-1, 24)).toBe(false);
expect(isSpanValid(1.5, 24)).toBe(false);
expect(isSpanValid(13, 12)).toBe(false);
});
});
describe('calculateLayout', () => {
it('should layout items in a single row when they fit', () => {
const items: LayoutItem[] = [
{ id: 'a', span: 6 },
{ id: 'b', span: 6 },
{ id: 'c', span: 6 },
];
const layout = calculateLayout(items, 24);
expect(layout).toHaveLength(3);
expect(layout[0]).toEqual({ item: items[0], row: 0, col: 0 });
expect(layout[1]).toEqual({ item: items[1], row: 0, col: 6 });
expect(layout[2]).toEqual({ item: items[2], row: 0, col: 12 });
});
it('should wrap to next row when items do not fit', () => {
const items: LayoutItem[] = [
{ id: 'a', span: 16 },
{ id: 'b', span: 12 },
];
const layout = calculateLayout(items, 24);
expect(layout).toHaveLength(2);
expect(layout[0]).toEqual({ item: items[0], row: 0, col: 0 });
expect(layout[1]).toEqual({ item: items[1], row: 1, col: 0 });
});
it('should handle exact row fills', () => {
const items: LayoutItem[] = [
{ id: 'a', span: 12 },
{ id: 'b', span: 12 },
{ id: 'c', span: 24 },
];
const layout = calculateLayout(items, 24);
expect(layout).toHaveLength(3);
expect(layout[0]).toEqual({ item: items[0], row: 0, col: 0 });
expect(layout[1]).toEqual({ item: items[1], row: 0, col: 12 });
expect(layout[2]).toEqual({ item: items[2], row: 1, col: 0 });
});
it('should throw error for invalid section columns', () => {
const items: LayoutItem[] = [{ id: 'a', span: 6 }];
expect(() => calculateLayout(items, 0)).toThrow();
expect(() => calculateLayout(items, -1)).toThrow();
});
it('should throw error for invalid item span', () => {
const items: LayoutItem[] = [{ id: 'a', span: 25 }];
expect(() => calculateLayout(items, 24)).toThrow();
});
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* Grid calculation utilities for 24-column newspaper layout system
*/
/**
* Calculate element width percentage in a grid system
* @param span - Number of columns the element spans
* @param totalColumns - Total number of columns in the grid (default: 24)
* @returns Width as a percentage string (e.g., "33.333%")
*/
export function calculateSpanWidth(
span: number,
totalColumns: number = 24
): string {
if (!validateSpan(span, totalColumns)) {
throw new Error(
`Invalid span: ${span}. Must be between 1 and ${totalColumns}`
);
}
const percentage = (span / totalColumns) * 100;
return `${percentage.toFixed(3)}%`;
}
/**
* Validate if a span value is within valid range
* @param span - Number of columns to validate
* @param maxColumns - Maximum number of columns allowed
* @returns True if span is valid, false otherwise
*/
export function validateSpan(span: number, maxColumns: number): boolean {
return Number.isInteger(span) && span >= 1 && span <= maxColumns;
}
/**
* Calculate gutter width based on container width and column count
* @param containerWidth - Total width of the container in pixels
* @param columns - Number of columns in the grid
* @param gutterRatio - Ratio of gutter to column width (default: 0.05)
* @returns Gutter width in pixels
*/
export function calculateGutter(
containerWidth: number,
columns: number,
gutterRatio: number = 0.05
): number {
if (containerWidth <= 0 || columns <= 0) {
throw new Error('Container width and columns must be positive numbers');
}
if (gutterRatio < 0 || gutterRatio > 1) {
throw new Error('Gutter ratio must be between 0 and 1');
}
// Calculate column width considering gutters
// Formula: containerWidth = (columns * columnWidth) + ((columns - 1) * gutter)
// Where gutter = columnWidth * gutterRatio
const totalGutterRatio = (columns - 1) * gutterRatio;
const columnWidth = containerWidth / (columns + totalGutterRatio);
const gutterWidth = columnWidth * gutterRatio;
return Math.round(gutterWidth * 100) / 100;
}
+30
View File
@@ -0,0 +1,30 @@
/**
* @newspaperui/utils
* Utility functions for newspaperui component library
*/
// Grid utilities
export {
calculateSpanWidth,
validateSpan,
calculateGutter,
} from './grid';
// Span and layout utilities
export {
isSpanValid,
calculateLayout,
type LayoutItem,
type PositionedLayoutItem,
} from './span';
// Responsive utilities
export {
getResponsiveColumns,
adjustSpanForScreen,
isBreakpoint,
getCurrentBreakpoint,
BREAKPOINTS,
} from './responsive';
export const version = '0.0.0';
+85
View File
@@ -0,0 +1,85 @@
/**
* Responsive utilities for newspaper layout system
*/
/**
* Breakpoint thresholds for responsive design
*/
export const BREAKPOINTS = {
sm: 768,
md: 1024,
lg: 1440,
} as const;
/**
* Get recommended grid column count based on screen width
* @param screenWidth - Current screen width in pixels
* @returns Recommended number of columns (12, 16, or 24)
*/
export function getResponsiveColumns(screenWidth: number): number {
if (screenWidth < BREAKPOINTS.sm) {
return 12; // Small screens: 12 columns
} else if (screenWidth < BREAKPOINTS.md) {
return 16; // Medium screens: 16 columns
} else {
return 24; // Large screens: 24 columns
}
}
/**
* Adjust span value for different screen sizes
* Proportionally scales span based on available columns
* @param span - Original span value (based on 24 columns)
* @param screenWidth - Current screen width in pixels
* @returns Adjusted span value for current screen size
*/
export function adjustSpanForScreen(
span: number,
screenWidth: number
): number {
const targetColumns = getResponsiveColumns(screenWidth);
const baseColumns = 24;
// If already at 24 columns, no adjustment needed
if (targetColumns === baseColumns) {
return span;
}
// Calculate proportional span
const adjustedSpan = Math.round((span / baseColumns) * targetColumns);
// Ensure at least 1 column and not exceeding target columns
return Math.max(1, Math.min(adjustedSpan, targetColumns));
}
/**
* Check if current screen width matches a breakpoint
* @param screenWidth - Current screen width in pixels
* @param breakpoint - Breakpoint name to check
* @returns True if screen width is at or above the breakpoint
*/
export function isBreakpoint(
screenWidth: number,
breakpoint: keyof typeof BREAKPOINTS
): boolean {
return screenWidth >= BREAKPOINTS[breakpoint];
}
/**
* Get current breakpoint name based on screen width
* @param screenWidth - Current screen width in pixels
* @returns Current breakpoint name ('sm', 'md', 'lg', or 'xs' for below sm)
*/
export function getCurrentBreakpoint(
screenWidth: number
): 'xs' | 'sm' | 'md' | 'lg' {
if (screenWidth >= BREAKPOINTS.lg) {
return 'lg';
} else if (screenWidth >= BREAKPOINTS.md) {
return 'md';
} else if (screenWidth >= BREAKPOINTS.sm) {
return 'sm';
} else {
return 'xs';
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Span and layout utilities for newspaper column system
*/
/**
* Check if an object's span is valid within a section's column count
* @param objectSpan - Number of columns the object spans
* @param sectionColumns - Total number of columns in the section
* @returns True if the span is valid, false otherwise
*/
export function isSpanValid(
objectSpan: number,
sectionColumns: number
): boolean {
return (
Number.isInteger(objectSpan) &&
Number.isInteger(sectionColumns) &&
objectSpan >= 1 &&
objectSpan <= sectionColumns
);
}
/**
* Layout item interface for grid positioning
*/
export interface LayoutItem {
span: number;
id: string;
}
/**
* Positioned layout item with row and column information
*/
export interface PositionedLayoutItem {
item: LayoutItem;
row: number;
col: number;
}
/**
* Calculate simple flow layout for items in a section
* Items are placed left-to-right, wrapping to new rows when needed
* @param items - Array of items to layout
* @param sectionColumns - Total number of columns in the section
* @returns Array of positioned items with row and column information
*/
export function calculateLayout(
items: LayoutItem[],
sectionColumns: number
): PositionedLayoutItem[] {
if (sectionColumns <= 0) {
throw new Error('Section columns must be a positive number');
}
const positioned: PositionedLayoutItem[] = [];
let currentRow = 0;
let currentCol = 0;
for (const item of items) {
// Validate item span
if (!isSpanValid(item.span, sectionColumns)) {
throw new Error(
`Invalid span ${item.span} for item ${item.id}. Must be between 1 and ${sectionColumns}`
);
}
// Check if item fits in current row
if (currentCol + item.span > sectionColumns) {
// Move to next row
currentRow++;
currentCol = 0;
}
// Position the item
positioned.push({
item,
row: currentRow,
col: currentCol,
});
// Update current column position
currentCol += item.span;
// If we've filled the row exactly, move to next row
if (currentCol === sectionColumns) {
currentRow++;
currentCol = 0;
}
}
return positioned;
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [
dts({
insertTypesEntry: true,
}),
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'NewspaperUIUtils',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`,
},
rollupOptions: {
external: [],
},
},
});
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/', '**/*.test.ts'],
},
},
});