This commit is contained in:
sunzhongyi
2026-05-20 01:30:49 +08:00
parent 7dded89537
commit 610805a374
42 changed files with 3451 additions and 0 deletions
@@ -0,0 +1,32 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { clampSpan, cx } from '@newspaperui/utils';
import { useSection } from './Section';
export interface ArticleProps {
span?: number;
breakable?: boolean;
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export const Article: React.FC<ArticleProps> = ({
span, breakable = true, className, style, children,
}) => {
const section = useSection();
const cols = span ? clampSpan(span, section.columns) : section.columns;
return (
<article
className={cx('nui-article', className)}
style={{
gridColumn: `span ${cols}`,
breakInside: breakable ? 'auto' : 'avoid',
...style,
}}
data-span={cols}
>
{children}
</article>
);
};
+26
View File
@@ -0,0 +1,26 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
export interface LayerProps {
position?: 'absolute' | 'fixed' | 'sticky';
top?: string | number;
left?: string | number;
right?: string | number;
bottom?: string | number;
zIndex?: number;
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export const Layer: React.FC<LayerProps> = ({
position = 'absolute', top, left, right, bottom, zIndex,
className, style, children,
}) => (
<div
className={className}
style={{ position, top, left, right, bottom, zIndex, ...style }}
>
{children}
</div>
);
@@ -0,0 +1,81 @@
'use client';
import React from 'react';
import { cx } from '@newspaperui/utils';
export interface MastheadProps {
title: string;
kicker?: string;
edition?: string;
date?: string;
price?: string;
variant?: 'classic' | 'blackletter' | 'modern';
className?: string;
}
export const Masthead: React.FC<MastheadProps> = ({
title, kicker, edition, date, price, variant = 'classic', className,
}) => {
const fontFamily =
variant === 'blackletter'
? 'var(--font-family-blackletter)'
: 'var(--font-family-masthead)';
const align = variant === 'modern' ? 'left' : 'center';
return (
<header
className={cx('nui-masthead', `nui-masthead--${variant}`, className)}
style={{
gridColumn: '1 / -1',
textAlign: align,
color: 'var(--nui-text-primary)',
}}
>
<div className="nui-masthead-rule-top" />
{kicker && (
<div
className="nui-small-caps"
style={{
fontSize: '12px',
color: 'var(--nui-accent-primary)',
margin: 'var(--nui-space-2) 0',
letterSpacing: '0.1em',
}}
>
{kicker}
</div>
)}
<h1
style={{
fontFamily,
fontWeight: 700,
fontSize: variant === 'modern' ? 'clamp(40px, 6vw, 72px)' : 'clamp(48px, 8vw, 96px)',
lineHeight: 1.0,
letterSpacing: variant === 'blackletter' ? '0' : '0.02em',
margin: 'var(--nui-space-2) 0 var(--nui-space-3) 0',
}}
>
{title}
</h1>
{(edition || date || price) && (
<div
className="nui-small-caps"
style={{
display: 'flex',
justifyContent: align === 'left' ? 'flex-start' : 'space-between',
gap: 'var(--nui-space-6)',
fontSize: '11px',
color: 'var(--nui-text-muted)',
padding: 'var(--nui-space-2) 0',
margin: 0,
letterSpacing: '0.08em',
}}
>
{edition && <span>{edition}</span>}
{date && <span>{date}</span>}
{price && <span>{price}</span>}
</div>
)}
<div className="nui-masthead-rule-bottom" />
</header>
);
};
+56
View File
@@ -0,0 +1,56 @@
'use client';
import React, { CSSProperties } from 'react';
import { cx } from '@newspaperui/utils';
export interface RuleProps {
variant?: 'hairline' | 'double' | 'thick';
orientation?: 'horizontal' | 'vertical';
span?: number; // 横向时占多少列 (1 / -1 全跨)
className?: string;
style?: CSSProperties;
}
export const Rule: React.FC<RuleProps> = ({
variant = 'hairline', orientation = 'horizontal', span, className, style,
}) => {
const isHorizontal = orientation === 'horizontal';
const baseStyle: CSSProperties = isHorizontal
? { width: '100%', border: 0, margin: 0 }
: { height: '100%', width: '1px', border: 0, margin: 0 };
const variantStyle: CSSProperties = (() => {
if (variant === 'hairline')
return isHorizontal
? { borderTop: '1px solid var(--nui-rule-hairline)' }
: { background: 'var(--nui-rule-hairline)' };
if (variant === 'thick')
return isHorizontal
? { borderTop: '3px solid var(--nui-rule-decorative)' }
: { width: '3px', background: 'var(--nui-rule-decorative)' };
// double
return isHorizontal
? {
height: '6px',
background:
'linear-gradient(to bottom, var(--nui-rule-decorative) 0 1px, transparent 1px 4px, var(--nui-rule-decorative) 4px 6px)',
}
: {
width: '6px',
background:
'linear-gradient(to right, var(--nui-rule-decorative) 0 1px, transparent 1px 4px, var(--nui-rule-decorative) 4px 6px)',
};
})();
return (
<hr
className={cx('nui-rule', `nui-rule--${variant}`, className)}
style={{
...baseStyle,
...variantStyle,
gridColumn: isHorizontal && span ? `span ${span}` : isHorizontal ? '1 / -1' : undefined,
...style,
}}
aria-orientation={orientation}
/>
);
};
@@ -0,0 +1,53 @@
'use client';
import React, { createContext, useContext, ReactNode, CSSProperties } from 'react';
import { clampSpan, cx } from '@newspaperui/utils';
import { useLayout } from './Layout';
export interface SectionProps {
columns: number; // section 内部的栅格列数 (≤ layout.columns)
gap?: string; // 默认 'var(--nui-gutter)'
breakable?: boolean; // 是否允许 print 分页断开,默认 true
divider?: 'none' | 'top' | 'bottom' | 'both'; // hairline 分隔
className?: string;
style?: CSSProperties;
children: ReactNode;
}
interface SectionContextValue { columns: number; }
const SectionContext = createContext<SectionContextValue>({ columns: 24 });
export const useSection = () => useContext(SectionContext);
export const Section: React.FC<SectionProps> = ({
columns, gap = 'var(--nui-gutter)', breakable = true, divider = 'none',
className, style, children,
}) => {
const layout = useLayout();
const cols = clampSpan(columns, layout.columns);
const dividerStyle: CSSProperties = {};
if (divider === 'top' || divider === 'both')
dividerStyle.borderTop = '1px solid var(--nui-rule-hairline)';
if (divider === 'bottom' || divider === 'both')
dividerStyle.borderBottom = '1px solid var(--nui-rule-hairline)';
return (
<SectionContext.Provider value={{ columns: cols }}>
<section
className={cx('nui-section', className)}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gap,
breakInside: breakable ? 'auto' : 'avoid',
...dividerStyle,
paddingTop: divider === 'top' || divider === 'both' ? 'var(--nui-space-4)' : undefined,
paddingBottom: divider === 'bottom' || divider === 'both' ? 'var(--nui-space-4)' : undefined,
...style,
}}
data-columns={cols}
>
{children}
</section>
</SectionContext.Provider>
);
};
+31
View File
@@ -0,0 +1,31 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { cx } from '@newspaperui/utils';
export interface DatelineProps {
className?: string;
style?: CSSProperties;
children: ReactNode; // e.g. "LONDON —"
}
export const Dateline: React.FC<DatelineProps> = ({ className, style, children }) => {
const config = visualWeights.Dateline.Standard!;
return (
<span
className={cx('nui-dateline nui-small-caps', className)}
style={{
fontFamily: `var(${config.fontFamily})`,
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
letterSpacing: config.letterSpacing,
color: `var(${config.color})`,
margin: config.margin,
...style,
}}
>
{children}
</span>
);
};
+31
View File
@@ -0,0 +1,31 @@
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { cx } from '@newspaperui/utils';
export interface KickerProps {
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export const Kicker: React.FC<KickerProps> = ({ className, style, children }) => {
const config = visualWeights.Kicker.Standard!;
return (
<div
className={cx('nui-kicker nui-small-caps', className)}
style={{
fontFamily: `var(${config.fontFamily})`,
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
letterSpacing: config.letterSpacing,
color: `var(${config.color})`,
margin: config.margin,
...style,
}}
>
{children}
</div>
);
};
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { BodyText } from '../text/BodyText';
describe('BodyText', () => {
it('enables multi-column flow when columns >= 2', () => {
const { container } = render(
<Layout>
<Section columns={12}>
<BodyText columns={3}>
<p>p1</p>
</BodyText>
</Section>
</Layout>,
);
const root = container.querySelector('.nui-bodytext') as HTMLElement;
expect(root.style.columnCount).toBe('3');
expect(root.style.columnGap).toBe('var(--nui-gutter)');
expect(root.classList.contains('nui-column-rule')).toBe(true);
expect(root.getAttribute('data-columns')).toBe('3');
});
it('applies the nui-drop-cap class when dropCap is true', () => {
const { container } = render(
<Layout>
<Section columns={12}>
<BodyText dropCap>
<p>p</p>
</BodyText>
</Section>
</Layout>,
);
const root = container.querySelector('.nui-bodytext') as HTMLElement;
expect(root.classList.contains('nui-drop-cap')).toBe(true);
});
it('does not enable multi-column flow when columns is 1 (default)', () => {
const { container } = render(
<Layout>
<Section columns={12}>
<BodyText>
<p>p</p>
</BodyText>
</Section>
</Layout>,
);
const root = container.querySelector('.nui-bodytext') as HTMLElement;
expect(root.style.columnCount).toBe('');
expect(root.classList.contains('nui-column-rule')).toBe(false);
});
});
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Headline } from '../text/Headline';
describe('Headline', () => {
it('renders h1 for weight="High" and h2 for weight="Medium"', () => {
const { container, rerender } = render(
<Layout>
<Section columns={12}>
<Headline weight="High">A</Headline>
</Section>
</Layout>,
);
expect(container.querySelector('h1')).not.toBeNull();
rerender(
<Layout>
<Section columns={12}>
<Headline weight="Medium">B</Headline>
</Section>
</Layout>,
);
expect(container.querySelector('h2')).not.toBeNull();
});
it('respects "as" prop overriding default tag', () => {
const { container } = render(
<Layout>
<Section columns={12}>
<Headline weight="High" as="h2">
X
</Headline>
</Section>
</Layout>,
);
expect(container.querySelector('h1')).toBeNull();
expect(container.querySelector('h2')).not.toBeNull();
});
it('uses CSS variable references for fontFamily', () => {
const { container } = render(
<Layout>
<Section columns={12}>
<Headline weight="High">X</Headline>
</Section>
</Layout>,
);
const h1 = container.querySelector('h1') as HTMLElement;
expect(h1.style.fontFamily).toContain('var(--font-family-');
});
});
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Masthead } from '../layout/Masthead';
describe('Masthead', () => {
it('renders title as an h1', () => {
render(<Masthead title="The Daily Times" />);
const h1 = screen.getByRole('heading', { level: 1 });
expect(h1.textContent).toBe('The Daily Times');
});
it('uses the blackletter font family when variant="blackletter"', () => {
render(<Masthead title="Gazette" variant="blackletter" />);
const h1 = screen.getByRole('heading', { level: 1 }) as HTMLElement;
expect(h1.style.fontFamily).toContain('--font-family-blackletter');
});
it('renders edition, date, and price when provided', () => {
render(
<Masthead
title="The Daily Times"
edition="Late Edition"
date="May 19, 2026"
price="$3.00"
/>,
);
expect(screen.getByText('Late Edition')).toBeInTheDocument();
expect(screen.getByText('May 19, 2026')).toBeInTheDocument();
expect(screen.getByText('$3.00')).toBeInTheDocument();
});
});