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
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@newspaperui/components",
"version": "0.0.0",
"description": "React components 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",
"test": "vitest run",
"test:watch": "vitest",
"lint": "tsc --noEmit",
"clean": "rm -rf dist"
},
"peerDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"dependencies": {
"@newspaperui/theme": "workspace:*",
"@newspaperui/utils": "workspace:*"
},
"devDependencies": {
"@testing-library/react": "^16.1.0",
"@testing-library/jest-dom": "^6.6.3",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vite-plugin-dts": "^4.3.0",
"vitest": "^2.1.8"
}
}
@@ -0,0 +1,35 @@
import React from 'react';
import { useSection } from '../Section/Section';
export interface ArticleProps {
span: number;
priority?: 'High' | 'Medium' | 'Low';
breakable?: boolean;
children: React.ReactNode;
}
export const Article: React.FC<ArticleProps> = ({
span,
priority = 'Medium',
breakable = true,
children,
}) => {
const section = useSection();
const effectiveSpan = Math.min(Math.max(1, span), section.columns);
if (span !== effectiveSpan) {
console.warn(`[Article] span=${span} exceeds section.columns=${section.columns}, clamped to ${effectiveSpan}`);
}
return (
<article
className={`newspaper-article priority-${priority.toLowerCase()}`}
style={{
gridColumn: `span ${effectiveSpan}`,
breakInside: breakable ? 'auto' : 'avoid',
}}
data-span={span}
>
{children}
</article>
);
};
+37
View File
@@ -0,0 +1,37 @@
import React from 'react';
export interface LayerProps {
position: 'absolute' | 'fixed' | 'sticky';
top?: string;
left?: string;
right?: string;
bottom?: string;
zIndex?: number;
children: React.ReactNode;
}
export const Layer: React.FC<LayerProps> = ({
position,
top,
left,
right,
bottom,
zIndex = 10,
children,
}) => {
return (
<div
className="newspaper-layer"
style={{
position,
top,
left,
right,
bottom,
zIndex,
}}
>
{children}
</div>
);
};
+47
View File
@@ -0,0 +1,47 @@
import React, { createContext, useContext } from 'react';
export interface LayoutProps {
maxWidth?: string;
padding?: string;
gutter?: number;
theme?: 'light' | 'dark';
columns?: number;
children: React.ReactNode;
}
interface LayoutContextValue {
columns: number;
gutter: number;
}
const LayoutContext = createContext<LayoutContextValue>({
columns: 24,
gutter: 16,
});
export const useLayout = () => useContext(LayoutContext);
export const Layout: React.FC<LayoutProps> = ({
maxWidth = '1440px',
padding = '1rem',
gutter = 16,
theme = 'light',
columns = 24,
children,
}) => {
return (
<LayoutContext.Provider value={{ columns, gutter }}>
<div
className={`newspaper-layout ${theme === 'dark' ? 'dark' : ''}`}
style={{
maxWidth,
padding,
margin: '0 auto',
'--gutter': `${gutter}px`,
} as React.CSSProperties}
>
{children}
</div>
</LayoutContext.Provider>
);
};
+43
View File
@@ -0,0 +1,43 @@
import React from 'react';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { Caption } from '../Text/Caption';
export interface FigureProps {
src: string;
alt: string;
caption?: string;
span?: number;
children?: React.ReactNode;
}
export const Figure: React.FC<FigureProps> = ({
src,
alt,
caption,
span = 1,
children,
}) => {
const section = useSection();
const width = calculateSpanWidth(span, section.columns);
return (
<figure
className="newspaper-figure"
style={{ width, margin: 0 }}
data-span={span}
>
<img
src={src}
alt={alt}
style={{
width: '100%',
height: 'auto',
display: 'block',
}}
/>
{caption && <Caption>{caption}</Caption>}
{children}
</figure>
);
};
+42
View File
@@ -0,0 +1,42 @@
import React from 'react';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { Caption } from '../Text/Caption';
export interface ImageProps {
src: string;
alt: string;
span?: number;
caption?: string;
priority?: 'High' | 'Medium' | 'Low';
}
export const Image: React.FC<ImageProps> = ({
src,
alt,
span = 1,
caption,
priority = 'Medium',
}) => {
const section = useSection();
const width = calculateSpanWidth(span, section.columns);
return (
<div
className={`newspaper-image priority-${priority.toLowerCase()}`}
style={{ width }}
data-span={span}
>
<img
src={src}
alt={alt}
style={{
width: '100%',
height: 'auto',
display: 'block',
}}
/>
{caption && <Caption>{caption}</Caption>}
</div>
);
};
@@ -0,0 +1,62 @@
import React from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
export interface PullQuoteProps {
weight?: 'High' | 'Medium';
span?: number;
author?: string;
children: React.ReactNode;
}
export const PullQuote: React.FC<PullQuoteProps> = ({
weight = 'High',
span,
author,
children,
}) => {
const section = useSection();
const config = visualWeights.PullQuote[weight];
if (!config) {
throw new Error(`Invalid weight: ${weight} for PullQuote`);
}
const finalSpan = span || (Array.isArray(config.span) ? config.span[0] : config.span);
const width = calculateSpanWidth(finalSpan, section.columns);
return (
<aside
className="newspaper-pull-quote"
style={{
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
margin: config.margin,
width,
padding: 'var(--nui-padding-lg, 1rem)',
borderLeft: '4px solid var(--nui-color-accent-primary, currentColor)',
}}
data-weight={weight}
data-span={finalSpan}
>
<blockquote style={{ margin: 0 }}>
{children}
</blockquote>
{author && (
<cite
style={{
display: 'block',
marginTop: '0.5rem',
fontSize: '0.875em',
fontStyle: 'normal',
}}
>
{author}
</cite>
)}
</aside>
);
};
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { Caption } from '../Text/Caption';
export interface VideoProps {
src: string;
poster?: string;
span?: number;
caption?: string;
}
export const Video: React.FC<VideoProps> = ({
src,
poster,
span = 1,
caption,
}) => {
const section = useSection();
const width = calculateSpanWidth(span, section.columns);
return (
<div
className="newspaper-video"
style={{ width }}
data-span={span}
>
<video
src={src}
poster={poster}
controls
style={{
width: '100%',
height: 'auto',
display: 'block',
}}
/>
{caption && <Caption>{caption}</Caption>}
</div>
);
};
@@ -0,0 +1,54 @@
import React, { createContext, useContext } from 'react';
import { useLayout } from '../Layout/Layout';
export interface SectionProps {
columns: number;
breakable?: boolean;
padding?: string;
margin?: string;
priority?: 'High' | 'Medium' | 'Low';
children: React.ReactNode;
}
interface SectionContextValue {
columns: number;
}
const SectionContext = createContext<SectionContextValue>({ columns: 24 });
export const useSection = () => useContext(SectionContext);
export const Section: React.FC<SectionProps> = ({
columns,
breakable = true,
padding,
margin,
priority = 'Medium',
children,
}) => {
const layout = useLayout();
const effectiveColumns = Math.min(Math.max(1, columns), layout.columns);
if (columns !== effectiveColumns) {
console.warn(`[Section] columns=${columns} exceeds layout.columns=${layout.columns}, clamped to ${effectiveColumns}`);
}
return (
<SectionContext.Provider value={{ columns: effectiveColumns }}>
<section
className={`newspaper-section priority-${priority.toLowerCase()}`}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${effectiveColumns}, 1fr)`,
gap: 'var(--nui-gutter, 1rem)',
padding,
margin,
breakInside: breakable ? 'auto' : 'avoid',
}}
data-columns={columns}
>
{children}
</section>
</SectionContext.Provider>
);
};
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
export interface BodyTextProps {
weight?: 'High' | 'Medium' | 'Low';
span?: number;
children: React.ReactNode;
}
export const BodyText: React.FC<BodyTextProps> = ({
weight = 'Medium',
span,
children,
}) => {
const section = useSection();
const config = visualWeights.BodyText[weight];
if (!config) {
throw new Error(`Invalid weight: ${weight} for BodyText`);
}
const finalSpan = span || (Array.isArray(config.span) ? config.span[0] : config.span);
const width = calculateSpanWidth(finalSpan, section.columns);
return (
<p
className="newspaper-body-text"
style={{
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
margin: config.margin,
width,
}}
data-weight={weight}
data-span={finalSpan}
>
{children}
</p>
);
};
+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
export interface BylineProps {
children: React.ReactNode;
}
export const Byline: React.FC<BylineProps> = ({ children }) => {
const config = visualWeights.Byline.Standard;
if (!config) {
throw new Error('Byline configuration not found');
}
return (
<div
className="newspaper-byline"
style={{
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
margin: config.margin,
}}
>
{children}
</div>
);
};
+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
export interface CaptionProps {
children: React.ReactNode;
}
export const Caption: React.FC<CaptionProps> = ({ children }) => {
const config = visualWeights.Caption.Standard;
if (!config) {
throw new Error('Caption configuration not found');
}
return (
<figcaption
className="newspaper-caption"
style={{
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
margin: config.margin,
}}
>
{children}
</figcaption>
);
};
+53
View File
@@ -0,0 +1,53 @@
import React from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
const weightToTag: Record<'High' | 'Medium' | 'Low', 'h1' | 'h2' | 'h3'> = {
High: 'h1',
Medium: 'h2',
Low: 'h3',
};
export interface HeadlineProps {
weight?: 'High' | 'Medium' | 'Low';
span?: number;
as?: 'h1' | 'h2' | 'h3' | 'h4';
children: React.ReactNode;
}
export const Headline: React.FC<HeadlineProps> = ({
weight = 'High',
span,
as,
children,
}) => {
const section = useSection();
const config = visualWeights.Headline[weight];
if (!config) {
throw new Error(`Invalid weight: ${weight} for Headline`);
}
const finalSpan = span || (Array.isArray(config.span) ? config.span[0] : config.span);
const width = calculateSpanWidth(finalSpan, section.columns);
const Tag = as ?? weightToTag[weight];
return (
<Tag
className="newspaper-headline"
style={{
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
margin: config.margin,
width,
}}
data-weight={weight}
data-span={finalSpan}
>
{children}
</Tag>
);
};
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
export interface QuoteProps {
weight?: 'High' | 'Medium';
span?: number;
children: React.ReactNode;
}
export const Quote: React.FC<QuoteProps> = ({
weight = 'Medium',
span,
children,
}) => {
const section = useSection();
const config = visualWeights.Quote[weight];
if (!config) {
throw new Error(`Invalid weight: ${weight} for Quote`);
}
const finalSpan = span || (Array.isArray(config.span) ? config.span[0] : config.span);
const width = calculateSpanWidth(finalSpan, section.columns);
return (
<blockquote
className="newspaper-quote"
style={{
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
margin: config.margin,
width,
}}
data-weight={weight}
data-span={finalSpan}
>
{children}
</blockquote>
);
};
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
export interface SubheadProps {
weight?: 'High' | 'Medium';
span?: number;
children: React.ReactNode;
}
export const Subhead: React.FC<SubheadProps> = ({
weight = 'Medium',
span,
children,
}) => {
const section = useSection();
const config = visualWeights.Subhead[weight];
if (!config) {
throw new Error(`Invalid weight: ${weight} for Subhead`);
}
const finalSpan = span || (Array.isArray(config.span) ? config.span[0] : config.span);
const width = calculateSpanWidth(finalSpan, section.columns);
return (
<h2
className="newspaper-subhead"
style={{
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
margin: config.margin,
width,
}}
data-weight={weight}
data-span={finalSpan}
>
{children}
</h2>
);
};
@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../Layout/Layout';
import { Section } from '../Section/Section';
import { Article } from '../Article/Article';
describe('Article Component', () => {
it('renders children correctly', () => {
const { container } = render(
<Layout>
<Section columns={12}>
<Article span={6}>
<div>Article Content</div>
</Article>
</Section>
</Layout>
);
expect(container.textContent).toContain('Article Content');
});
it('clamps span exceeding section columns and warns', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
render(
<Layout>
<Section columns={12}>
<Article span={15}>
<div>Clamped</div>
</Article>
</Section>
</Layout>
);
expect(warnSpy).toHaveBeenCalledWith(
'[Article] span=15 exceeds section.columns=12, clamped to 12'
);
warnSpy.mockRestore();
});
it('applies correct grid-column span based on span', () => {
const { container } = render(
<Layout>
<Section columns={12}>
<Article span={6}>
<div>Half Width</div>
</Article>
</Section>
</Layout>
);
const article = container.querySelector('.newspaper-article');
expect(article).toHaveStyle({ gridColumn: 'span 6' });
});
});
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Layout } from '../Layout/Layout';
import { Section } from '../Section/Section';
describe('Layout Component', () => {
it('renders children correctly', () => {
render(
<Layout>
<div>Test Content</div>
</Layout>
);
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('applies custom maxWidth and padding', () => {
const { container } = render(
<Layout maxWidth="1200px" padding="2rem">
<div>Content</div>
</Layout>
);
const layout = container.querySelector('.newspaper-layout');
expect(layout).toHaveStyle({ maxWidth: '1200px', padding: '2rem' });
});
it('provides default columns value of 24', () => {
render(
<Layout>
<Section columns={12}>
<div>Test</div>
</Section>
</Layout>
);
expect(screen.getByText('Test')).toBeInTheDocument();
});
});
@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../Layout/Layout';
import { Section } from '../Section/Section';
describe('Section Component', () => {
it('renders children correctly', () => {
const { container } = render(
<Layout>
<Section columns={12}>
<div>Section Content</div>
</Section>
</Layout>
);
expect(container.textContent).toContain('Section Content');
});
it('clamps columns exceeding layout columns and warns', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { container } = render(
<Layout columns={24}>
<Section columns={30}>
<div>Clamped</div>
</Section>
</Layout>
);
expect(warnSpy).toHaveBeenCalledWith(
'[Section] columns=30 exceeds layout.columns=24, clamped to 24'
);
const section = container.querySelector('.newspaper-section');
expect(section).toHaveStyle({ gridTemplateColumns: 'repeat(24, 1fr)' });
warnSpy.mockRestore();
});
it('applies custom padding and margin', () => {
const { container } = render(
<Layout>
<Section columns={12} padding="1rem" margin="2rem">
<div>Content</div>
</Section>
</Layout>
);
const section = container.querySelector('.newspaper-section');
expect(section).toHaveStyle({ padding: '1rem', margin: '2rem' });
});
});
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+51
View File
@@ -0,0 +1,51 @@
/**
* @newspaperui/components
* React components for newspaperui
*/
// Layout components
export { Layout, useLayout } from './Layout/Layout';
export type { LayoutProps } from './Layout/Layout';
export { Section, useSection } from './Section/Section';
export type { SectionProps } from './Section/Section';
export { Article } from './Article/Article';
export type { ArticleProps } from './Article/Article';
export { Layer } from './Layer/Layer';
export type { LayerProps } from './Layer/Layer';
// Text components
export { Headline } from './Text/Headline';
export type { HeadlineProps } from './Text/Headline';
export { Subhead } from './Text/Subhead';
export type { SubheadProps } from './Text/Subhead';
export { BodyText } from './Text/BodyText';
export type { BodyTextProps } from './Text/BodyText';
export { Quote } from './Text/Quote';
export type { QuoteProps } from './Text/Quote';
export { Byline } from './Text/Byline';
export type { BylineProps } from './Text/Byline';
export { Caption } from './Text/Caption';
export type { CaptionProps } from './Text/Caption';
// Media components
export { Image } from './Media/Image';
export type { ImageProps } from './Media/Image';
export { Figure } from './Media/Figure';
export type { FigureProps } from './Media/Figure';
export { Video } from './Media/Video';
export type { VideoProps } from './Media/Video';
export { PullQuote } from './Media/PullQuote';
export type { PullQuoteProps } from './Media/PullQuote';
export const version = '0.0.0';
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [
react(),
dts({
insertTypesEntry: true,
}),
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'NewspaperUIComponents',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`,
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
},
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
});
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/__tests__/setup.ts'],
},
});