feat: responsive system, engineering infra, new components, performance

- Section: responsive prop with media query injection
- visual-weights: fontSize clamp() for responsive sizing
- variables.css: add border-radius/shadow/transition/z-index tokens
- ESLint flat config + Prettier + Changeset init
- New components: Footer, NewsSidebar, BreakingNewsBanner
- Image/Figure: loading=lazy, aspectRatio, sizes props
This commit is contained in:
sunzhongyi
2026-05-21 10:04:35 +08:00
parent 184353cfb0
commit 5f65d741ed
54 changed files with 2759 additions and 101 deletions
+24 -6
View File
@@ -1,7 +1,7 @@
{
"name": "@newspaperui/components",
"version": "0.0.0",
"description": "React components for newspaperui",
"name": "newspaperui-components",
"version": "0.1.0",
"description": "Production-grade newspaper layout React components",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
@@ -29,8 +29,8 @@
"react-dom": "^18.3.1"
},
"dependencies": {
"@newspaperui/theme": "workspace:*",
"@newspaperui/utils": "workspace:*"
"newspaperui-theme": "workspace:*",
"newspaperui-utils": "workspace:*"
},
"devDependencies": {
"@testing-library/react": "^16.1.0",
@@ -45,5 +45,23 @@
"vite": "^5.4.11",
"vite-plugin-dts": "^4.3.0",
"vitest": "^2.1.8"
},
"license": "MIT",
"author": "sunzhongyi",
"repository": {
"type": "git",
"url": "https://github.com/joisun/newspaperui.git"
},
"keywords": [
"newspaper",
"react",
"components",
"layout",
"typography",
"css-grid",
"multi-column"
],
"publishConfig": {
"access": "public"
}
}
}
+7 -1
View File
@@ -1,4 +1,4 @@
import '@newspaperui/theme';
import 'newspaperui-theme';
// layout
export { Layout, useLayout } from './layout/Layout';
@@ -13,6 +13,12 @@ export { Masthead } from './layout/Masthead';
export type { MastheadProps } from './layout/Masthead';
export { Rule } from './layout/Rule';
export type { RuleProps } from './layout/Rule';
export { Footer } from './layout/Footer';
export type { FooterProps } from './layout/Footer';
export { Sidebar as NewsSidebar } from './layout/Sidebar';
export type { SidebarProps as NewsSidebarProps } from './layout/Sidebar';
export { BreakingNewsBanner } from './layout/BreakingNewsBanner';
export type { BreakingNewsBannerProps } from './layout/BreakingNewsBanner';
// text
export { Headline } from './text/Headline';
+1 -1
View File
@@ -1,6 +1,6 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { clampSpan, cx } from '@newspaperui/utils';
import { clampSpan, cx } from 'newspaperui-utils';
import { useSection } from './Section';
export interface ArticleProps {
@@ -0,0 +1,48 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { cx } from 'newspaperui-utils';
export interface BreakingNewsBannerProps {
label?: string;
className?: string;
style?: CSSProperties;
children: ReactNode;
}
/**
* BreakingNewsBanner — 突发新闻横幅
*
* @example
* <BreakingNewsBanner label="BREAKING">
* Major earthquake strikes coastal region
* </BreakingNewsBanner>
*/
export const BreakingNewsBanner: React.FC<BreakingNewsBannerProps> = ({
label = 'BREAKING', className, style, children,
}) => (
<div
className={cx('nui-breaking', className)}
role="alert"
style={{
gridColumn: '1 / -1',
background: 'var(--nui-accent-primary)',
color: 'var(--nui-bg-page)',
padding: 'var(--nui-space-3) var(--nui-space-6)',
fontFamily: 'var(--font-family-meta)',
fontSize: '13px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: 'var(--nui-space-4)',
...style,
}}
>
<span style={{
fontVariantCaps: 'small-caps',
letterSpacing: '0.1em',
fontWeight: 700,
flexShrink: 0,
}}>{label}</span>
<span style={{ flex: 1 }}>{children}</span>
</div>
);
+57
View File
@@ -0,0 +1,57 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { cx } from 'newspaperui-utils';
export interface FooterProps {
copyright?: string;
edition?: string;
links?: Array<{ label: string; href: string }>;
className?: string;
style?: CSSProperties;
children?: ReactNode;
}
/**
* Footer — 报纸页脚/版权信息区
*
* @example
* <Footer copyright="© 2026 The Daily Chronicle" edition="Vol. CXLIX" />
*/
export const Footer: React.FC<FooterProps> = ({
copyright, edition, links, className, style, children,
}) => (
<footer
className={cx('nui-footer nui-small-caps', className)}
style={{
gridColumn: '1 / -1',
borderTop: '2px solid var(--nui-rule-decorative)',
paddingTop: 'var(--nui-space-4)',
marginTop: 'var(--nui-space-8)',
fontFamily: 'var(--font-family-meta)',
fontSize: '11px',
letterSpacing: '0.06em',
color: 'var(--nui-text-muted)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 'var(--nui-space-4)',
...style,
}}
>
<div>
{copyright && <span>{copyright}</span>}
{edition && <span style={{ marginLeft: 'var(--nui-space-4)' }}>{edition}</span>}
</div>
{links && (
<nav style={{ display: 'flex', gap: 'var(--nui-space-4)' }}>
{links.map(link => (
<a key={link.href} href={link.href} style={{ color: 'var(--nui-text-muted)', textDecoration: 'none' }}>
{link.label}
</a>
))}
</nav>
)}
{children}
</footer>
);
+1 -1
View File
@@ -1,6 +1,6 @@
'use client';
import React from 'react';
import { cx } from '@newspaperui/utils';
import { cx } from 'newspaperui-utils';
export interface MastheadProps {
title: string;
+1 -1
View File
@@ -1,6 +1,6 @@
'use client';
import React, { CSSProperties } from 'react';
import { cx } from '@newspaperui/utils';
import { cx } from 'newspaperui-utils';
export interface RuleProps {
variant?: 'hairline' | 'double' | 'thick';
+13 -4
View File
@@ -1,6 +1,6 @@
'use client';
import React, { createContext, useContext, ReactNode, CSSProperties } from 'react';
import { clampSpan, cx } from '@newspaperui/utils';
import React, { createContext, useContext, useId, ReactNode, CSSProperties } from 'react';
import { clampSpan, cx } from 'newspaperui-utils';
import { useLayout } from './Layout';
export interface SectionProps {
@@ -8,6 +8,7 @@ export interface SectionProps {
gap?: string; // 默认 'var(--nui-gutter)'
breakable?: boolean; // 是否允许 print 分页断开,默认 true
divider?: 'none' | 'top' | 'bottom' | 'both'; // hairline 分隔
responsive?: { sm?: number; md?: number; lg?: number }; // 响应式列数覆盖
className?: string;
style?: CSSProperties;
children: ReactNode;
@@ -32,10 +33,17 @@ export const useSection = () => useContext(SectionContext);
*/
export const Section: React.FC<SectionProps> = ({
columns, gap = 'var(--nui-gutter)', breakable = true, divider = 'none',
className, style, children,
responsive, className, style, children,
}) => {
const layout = useLayout();
const cols = clampSpan(columns, layout.columns);
const id = useId().replace(/:/g, '');
const sectionId = `nui-s-${id}`;
const responsiveCSS = responsive ? `
@media (max-width: 768px) { .${sectionId} { grid-template-columns: repeat(${responsive.sm ?? 12}, 1fr) !important; } }
@media (min-width: 769px) and (max-width: 1024px) { .${sectionId} { grid-template-columns: repeat(${responsive.md ?? 16}, 1fr) !important; } }
` : '';
const dividerStyle: CSSProperties = {};
if (divider === 'top' || divider === 'both')
@@ -45,8 +53,9 @@ export const Section: React.FC<SectionProps> = ({
return (
<SectionContext.Provider value={{ columns: cols }}>
{responsiveCSS && <style dangerouslySetInnerHTML={{ __html: responsiveCSS }} />}
<section
className={cx('nui-section', className)}
className={cx('nui-section', sectionId, className)}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${cols}, 1fr)`,
@@ -0,0 +1,47 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { clampSpan, cx } from 'newspaperui-utils';
import { useSection } from './Section';
export interface SidebarProps {
span?: number;
position?: 'left' | 'right';
divider?: boolean;
className?: string;
style?: CSSProperties;
children: ReactNode;
}
/**
* Sidebar — 侧边栏内容区(天气、股票、快讯摘要等)
*
* @example
* <Section columns={24}>
* <Article span={18}>...</Article>
* <Sidebar span={6} position="right" divider>...</Sidebar>
* </Section>
*/
export const Sidebar: React.FC<SidebarProps> = ({
span, position = 'right', divider = false, className, style, children,
}) => {
const section = useSection();
const cols = span ? clampSpan(span, section.columns) : 6;
const borderStyle: CSSProperties = divider
? position === 'left'
? { borderRight: '1px solid var(--nui-rule-hairline)', paddingRight: 'var(--nui-space-4)' }
: { borderLeft: '1px solid var(--nui-rule-hairline)', paddingLeft: 'var(--nui-space-4)' }
: {};
return (
<aside
className={cx('nui-sidebar', className)}
style={{
gridColumn: `span ${cols}`,
...borderStyle,
...style,
}}
>
{children}
</aside>
);
};
+21 -6
View File
@@ -1,17 +1,21 @@
'use client';
import React, { CSSProperties } from 'react';
import { clampSpan, cx } from '@newspaperui/utils';
import React, { ReactNode, CSSProperties } from 'react';
import { clampSpan, cx } from 'newspaperui-utils';
import { useSection } from '../layout/Section';
import { Caption } from '../text/Caption';
export interface FigureProps {
src: string;
alt: string;
src?: string;
alt?: string;
caption?: string;
credit?: string;
span?: number;
loading?: 'lazy' | 'eager';
aspectRatio?: string;
sizes?: string;
className?: string;
style?: CSSProperties;
children?: ReactNode;
}
/**
@@ -20,6 +24,7 @@ export interface FigureProps {
* - 将图片与 Caption 组合为语义化 figure 元素
* - 支持 caption 文字说明和 credit 来源标注
* - 自动避免 print 分页断开(break-inside: avoid
* - 支持 children 自定义内容替代 img
*
* @example
* <Figure
@@ -28,10 +33,12 @@ export interface FigureProps {
* caption="Downtown at dusk"
* credit="Photo by John"
* span={10}
* aspectRatio="16/9"
* />
*/
export const Figure: React.FC<FigureProps> = ({
src, alt, caption, credit, span, className, style,
src, alt, caption, credit, span, loading, aspectRatio, sizes,
className, style, children,
}) => {
const section = useSection();
const cols = span ? clampSpan(span, section.columns) : undefined;
@@ -44,7 +51,15 @@ export const Figure: React.FC<FigureProps> = ({
...style,
}}
>
<img src={src} alt={alt} style={{ display: 'block', width: '100%', height: 'auto' }} />
{src ? (
<img
src={src}
alt={alt ?? ''}
loading={loading ?? 'lazy'}
sizes={sizes}
style={{ display: 'block', width: '100%', height: 'auto', aspectRatio }}
/>
) : children}
{(caption || credit) && <Caption credit={credit}>{caption}</Caption>}
</figure>
);
+12 -3
View File
@@ -1,12 +1,15 @@
'use client';
import React, { CSSProperties } from 'react';
import { clampSpan, cx } from '@newspaperui/utils';
import { clampSpan, cx } from 'newspaperui-utils';
import { useSection } from '../layout/Section';
export interface ImageProps {
src: string;
alt: string;
span?: number;
loading?: 'lazy' | 'eager';
aspectRatio?: string;
sizes?: string;
className?: string;
style?: CSSProperties;
}
@@ -16,23 +19,29 @@ export interface ImageProps {
*
* - 响应式全宽图片,自动适配栅格列宽
* - 通过 span 控制在 Section 中占据的列数
* - 默认 lazy loading 优化性能
* - 必须提供 alt 属性以确保无障碍访问
*
* @example
* <Image src="/photo.jpg" alt="City skyline" span={12} />
* <Image src="/photo.jpg" alt="City skyline" span={12} aspectRatio="16/9" />
*/
export const Image: React.FC<ImageProps> = ({ src, alt, span, className, style }) => {
export const Image: React.FC<ImageProps> = ({
src, alt, span, loading, aspectRatio, sizes, className, style,
}) => {
const section = useSection();
const cols = span ? clampSpan(span, section.columns) : undefined;
return (
<img
src={src}
alt={alt}
loading={loading ?? 'lazy'}
sizes={sizes}
className={cx('nui-image', className)}
style={{
display: 'block',
width: '100%',
height: 'auto',
aspectRatio,
gridColumn: cols ? `span ${cols}` : undefined,
...style,
}}
+2 -2
View File
@@ -1,7 +1,7 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { clampSpan, cx } from '@newspaperui/utils';
import { visualWeights, resolveFontSize } from 'newspaperui-theme';
import { clampSpan, cx } from 'newspaperui-utils';
import { useSection } from '../layout/Section';
export interface PullQuoteProps {
+1 -1
View File
@@ -1,6 +1,6 @@
'use client';
import React, { CSSProperties } from 'react';
import { clampSpan, cx } from '@newspaperui/utils';
import { clampSpan, cx } from 'newspaperui-utils';
import { useSection } from '../layout/Section';
import { Caption } from '../text/Caption';
+2 -2
View File
@@ -1,7 +1,7 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { clampSpan, cx } from '@newspaperui/utils';
import { visualWeights, resolveFontSize } from 'newspaperui-theme';
import { clampSpan, cx } from 'newspaperui-utils';
import { useSection } from '../layout/Section';
export interface BodyTextProps {
+2 -2
View File
@@ -1,7 +1,7 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { cx } from '@newspaperui/utils';
import { visualWeights, resolveFontSize } from 'newspaperui-theme';
import { cx } from 'newspaperui-utils';
export interface BylineProps {
className?: string;
+2 -2
View File
@@ -1,7 +1,7 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { cx } from '@newspaperui/utils';
import { visualWeights, resolveFontSize } from 'newspaperui-theme';
import { cx } from 'newspaperui-utils';
export interface CaptionProps {
credit?: string; // e.g. "Photograph by Jane Doe"
+2 -2
View File
@@ -1,7 +1,7 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { cx } from '@newspaperui/utils';
import { visualWeights, resolveFontSize } from 'newspaperui-theme';
import { cx } from 'newspaperui-utils';
export interface DatelineProps {
className?: string;
+2 -2
View File
@@ -1,7 +1,7 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { clampSpan, cx } from '@newspaperui/utils';
import { visualWeights, resolveFontSize } from 'newspaperui-theme';
import { clampSpan, cx } from 'newspaperui-utils';
import { useSection } from '../layout/Section';
const weightToTag: Record<'High' | 'Medium' | 'Low', 'h1' | 'h2' | 'h3'> = {
+2 -2
View File
@@ -1,7 +1,7 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { cx } from '@newspaperui/utils';
import { visualWeights, resolveFontSize } from 'newspaperui-theme';
import { cx } from 'newspaperui-utils';
export interface KickerProps {
className?: string;
+2 -2
View File
@@ -1,7 +1,7 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { clampSpan, cx } from '@newspaperui/utils';
import { visualWeights, resolveFontSize } from 'newspaperui-theme';
import { clampSpan, cx } from 'newspaperui-utils';
import { useSection } from '../layout/Section';
export interface QuoteProps {
+2 -2
View File
@@ -1,7 +1,7 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { clampSpan, cx } from '@newspaperui/utils';
import { visualWeights, resolveFontSize } from 'newspaperui-theme';
import { clampSpan, cx } from 'newspaperui-utils';
import { useSection } from '../layout/Section';
export interface SubheadProps {