This commit is contained in:
sunzhongyi
2026-05-20 01:30:41 +08:00
parent f3e6b95be9
commit 7dded89537
44 changed files with 1166 additions and 3699 deletions
@@ -1,35 +0,0 @@
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
@@ -1,37 +0,0 @@
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>
);
};
+31 -39
View File
@@ -1,47 +1,39 @@
import React, { createContext, useContext } from 'react';
'use client';
import React, { createContext, useContext, ReactNode, CSSProperties } from 'react';
export interface LayoutProps {
maxWidth?: string;
padding?: string;
gutter?: number;
columns?: number; // 默认 24
maxWidth?: string; // 默认 '1280px'
padding?: string; // 默认 'var(--nui-space-6)'
theme?: 'light' | 'dark';
columns?: number;
children: React.ReactNode;
className?: string;
style?: CSSProperties;
children: ReactNode;
}
interface LayoutContextValue {
columns: number;
gutter: number;
}
const LayoutContext = createContext<LayoutContextValue>({
columns: 24,
gutter: 16,
});
interface LayoutContextValue { columns: number; }
const LayoutContext = createContext<LayoutContextValue>({ columns: 24 });
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>
);
};
columns = 24, maxWidth = '1280px', padding = 'var(--nui-space-6)',
theme, className, style, children,
}) => (
<LayoutContext.Provider value={{ columns }}>
<div
data-theme={theme}
className={className}
style={{
maxWidth,
margin: '0 auto',
padding,
background: 'var(--nui-bg-page)',
color: 'var(--nui-text-body)',
fontFamily: 'var(--font-family-body)',
...style,
}}
>
{children}
</div>
</LayoutContext.Provider>
);
+18 -26
View File
@@ -1,43 +1,35 @@
import React from 'react';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { Caption } from '../Text/Caption';
'use client';
import React, { 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;
caption?: string;
credit?: string;
span?: number;
children?: React.ReactNode;
className?: string;
style?: CSSProperties;
}
export const Figure: React.FC<FigureProps> = ({
src,
alt,
caption,
span = 1,
children,
src, alt, caption, credit, span, className, style,
}) => {
const section = useSection();
const width = calculateSpanWidth(span, section.columns);
const cols = span ? clampSpan(span, section.columns) : undefined;
return (
<figure
className="newspaper-figure"
style={{ width, margin: 0 }}
data-span={span}
className={cx('nui-figure nui-avoid-break', className)}
style={{
margin: 0,
gridColumn: cols ? `span ${cols}` : undefined,
...style,
}}
>
<img
src={src}
alt={alt}
style={{
width: '100%',
height: 'auto',
display: 'block',
}}
/>
{caption && <Caption>{caption}</Caption>}
{children}
<img src={src} alt={alt} style={{ display: 'block', width: '100%', height: 'auto' }} />
{(caption || credit) && <Caption credit={credit}>{caption}</Caption>}
</figure>
);
};
+20 -31
View File
@@ -1,42 +1,31 @@
import React from 'react';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { Caption } from '../Text/Caption';
'use client';
import React, { CSSProperties } from 'react';
import { clampSpan, cx } from '@newspaperui/utils';
import { useSection } from '../layout/Section';
export interface ImageProps {
src: string;
alt: string;
span?: number;
caption?: string;
priority?: 'High' | 'Medium' | 'Low';
className?: string;
style?: CSSProperties;
}
export const Image: React.FC<ImageProps> = ({
src,
alt,
span = 1,
caption,
priority = 'Medium',
}) => {
export const Image: React.FC<ImageProps> = ({ src, alt, span, className, style }) => {
const section = useSection();
const width = calculateSpanWidth(span, section.columns);
const cols = span ? clampSpan(span, section.columns) : undefined;
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>
<img
src={src}
alt={alt}
className={cx('nui-image', className)}
style={{
display: 'block',
width: '100%',
height: 'auto',
gridColumn: cols ? `span ${cols}` : undefined,
...style,
}}
/>
);
};
+44 -35
View File
@@ -1,61 +1,70 @@
import React from 'react';
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { clampSpan, cx } from '@newspaperui/utils';
import { useSection } from '../layout/Section';
export interface PullQuoteProps {
weight?: 'High' | 'Medium';
span?: number;
spanAllColumns?: boolean; // 在多栏 BodyText 内跨所有栏
author?: string;
children: React.ReactNode;
align?: 'left' | 'center';
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export const PullQuote: React.FC<PullQuoteProps> = ({
weight = 'High',
span,
author,
children,
weight = 'High', span, spanAllColumns = false, author, align = 'left',
className, style, 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);
const config = visualWeights.PullQuote[weight]!;
const cols = span ? clampSpan(span, section.columns) : undefined;
return (
<aside
className="newspaper-pull-quote"
className={cx(
'nui-pullquote nui-avoid-break nui-tnum',
spanAllColumns && 'nui-span-all-columns',
className,
)}
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)',
padding: 'var(--nui-space-4) 0',
borderTop: '1px solid var(--nui-rule-hairline)',
borderBottom: '1px solid var(--nui-rule-hairline)',
textAlign: align,
gridColumn: cols ? `span ${cols}` : undefined,
...style,
}}
data-weight={weight}
data-span={finalSpan}
>
<blockquote style={{ margin: 0 }}>
<p
style={{
fontFamily: `var(${config.fontFamily})`,
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
letterSpacing: config.letterSpacing,
color: `var(${config.color})`,
margin: 0,
textWrap: 'balance' as CSSProperties['textWrap'],
}}
>
{children}
</blockquote>
</p>
{author && (
<cite
<footer
className="nui-small-caps"
style={{
display: 'block',
marginTop: '0.5rem',
fontSize: '0.875em',
fontStyle: 'normal',
marginTop: 'var(--nui-space-2)',
fontSize: '11px',
color: 'var(--nui-text-muted)',
letterSpacing: '0.08em',
}}
>
{author}
</cite>
</footer>
)}
</aside>
);
+18 -27
View File
@@ -1,41 +1,32 @@
import React from 'react';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { Caption } from '../Text/Caption';
'use client';
import React, { CSSProperties } from 'react';
import { clampSpan, cx } from '@newspaperui/utils';
import { useSection } from '../layout/Section';
import { Caption } from '../text/Caption';
export interface VideoProps {
src: string;
poster?: string;
span?: number;
caption?: string;
credit?: string;
span?: number;
controls?: boolean;
className?: string;
style?: CSSProperties;
}
export const Video: React.FC<VideoProps> = ({
src,
poster,
span = 1,
caption,
src, poster, caption, credit, span, controls = true, className, style,
}) => {
const section = useSection();
const width = calculateSpanWidth(span, section.columns);
const cols = span ? clampSpan(span, section.columns) : undefined;
return (
<div
className="newspaper-video"
style={{ width }}
data-span={span}
<figure
className={cx('nui-video nui-avoid-break', className)}
style={{ margin: 0, gridColumn: cols ? `span ${cols}` : undefined, ...style }}
>
<video
src={src}
poster={poster}
controls
style={{
width: '100%',
height: 'auto',
display: 'block',
}}
/>
{caption && <Caption>{caption}</Caption>}
</div>
<video src={src} poster={poster} controls={controls} style={{ width: '100%', height: 'auto' }} />
{(caption || credit) && <Caption credit={credit}>{caption}</Caption>}
</figure>
);
};
@@ -1,54 +0,0 @@
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>
);
};
+37 -21
View File
@@ -1,44 +1,60 @@
import React from 'react';
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { clampSpan, cx } from '@newspaperui/utils';
import { useSection } from '../layout/Section';
export interface BodyTextProps {
weight?: 'High' | 'Medium' | 'Low';
span?: number;
children: React.ReactNode;
columns?: 1 | 2 | 3 | 4; // 多栏文字流
columnWidth?: string; // 默认 '18em'
columnFill?: 'auto' | 'balance';
dropCap?: boolean;
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export const BodyText: React.FC<BodyTextProps> = ({
weight = 'Medium',
span,
children,
weight = 'Medium', span, columns = 1, columnWidth = '18em',
columnFill = 'auto', dropCap = false,
className, style, 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);
const config = visualWeights.BodyText[weight]!;
const cols = span ? clampSpan(span, section.columns) : undefined;
const useColumns = columns >= 2;
return (
<p
className="newspaper-body-text"
<div
className={cx(
'nui-bodytext',
'nui-paragraph-flow',
'nui-osf',
'nui-hanging-punctuation',
useColumns && 'nui-column-rule',
dropCap && 'nui-drop-cap',
className,
)}
style={{
fontFamily: `var(${config.fontFamily})`,
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
color: `var(${config.color})`,
margin: config.margin,
width,
gridColumn: cols ? `span ${cols}` : undefined,
columnCount: useColumns ? columns : undefined,
columnWidth: useColumns ? columnWidth : undefined,
columnGap: useColumns ? 'var(--nui-gutter)' : undefined,
columnFill: useColumns ? columnFill : undefined,
...style,
}}
data-weight={weight}
data-span={finalSpan}
data-columns={columns}
>
{children}
</p>
</div>
);
};
+13 -11
View File
@@ -1,26 +1,28 @@
import React from 'react';
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { cx } from '@newspaperui/utils';
export interface BylineProps {
children: React.ReactNode;
className?: string;
style?: CSSProperties;
children: ReactNode; // e.g. "BY ALICE SMITH"
}
export const Byline: React.FC<BylineProps> = ({ children }) => {
const config = visualWeights.Byline.Standard;
if (!config) {
throw new Error('Byline configuration not found');
}
export const Byline: React.FC<BylineProps> = ({ className, style, children }) => {
const config = visualWeights.Byline.Standard!;
return (
<div
className="newspaper-byline"
className={cx('nui-byline nui-small-caps', className)}
style={{
fontFamily: `var(${config.fontFamily})`,
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
letterSpacing: config.letterSpacing,
color: `var(${config.color})`,
margin: config.margin,
...style,
}}
>
{children}
+29 -11
View File
@@ -1,29 +1,47 @@
import React from 'react';
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { cx } from '@newspaperui/utils';
export interface CaptionProps {
children: React.ReactNode;
credit?: string; // e.g. "Photograph by Jane Doe"
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export const Caption: React.FC<CaptionProps> = ({ children }) => {
const config = visualWeights.Caption.Standard;
if (!config) {
throw new Error('Caption configuration not found');
}
export const Caption: React.FC<CaptionProps> = ({ credit, className, style, children }) => {
const config = visualWeights.Caption.Standard!;
return (
<figcaption
className="newspaper-caption"
className={cx('nui-caption nui-osf', className)}
style={{
fontFamily: `var(${config.fontFamily})`,
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
fontStyle: config.fontStyle,
lineHeight: config.lineHeight,
color: config.color,
color: `var(${config.color})`,
margin: config.margin,
...style,
}}
>
{children}
{credit && (
<span
className="nui-small-caps"
style={{
display: 'inline-block',
marginLeft: 'var(--nui-space-2)',
fontSize: '11px',
color: 'var(--nui-text-muted)',
fontStyle: 'normal',
letterSpacing: '0.06em',
}}
>
{credit}
</span>
)}
</figcaption>
);
};
+23 -25
View File
@@ -1,51 +1,49 @@
import React from 'react';
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { clampSpan, cx } from '@newspaperui/utils';
import { useSection } from '../layout/Section';
const weightToTag: Record<'High' | 'Medium' | 'Low', 'h1' | 'h2' | 'h3'> = {
High: 'h1',
Medium: 'h2',
Low: 'h3',
High: 'h1', Medium: 'h2', Low: 'h3',
};
export interface HeadlineProps {
weight?: 'High' | 'Medium' | 'Low';
span?: number;
as?: 'h1' | 'h2' | 'h3' | 'h4';
children: React.ReactNode;
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
align?: 'left' | 'center' | 'right';
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export const Headline: React.FC<HeadlineProps> = ({
weight = 'High',
span,
as,
children,
weight = 'High', span, as, align, className, style, 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];
const config = visualWeights.Headline[weight]!;
const Tag = (as ?? weightToTag[weight]) as keyof JSX.IntrinsicElements;
const cols = span ? clampSpan(span, section.columns) : undefined;
const variantClass = config.fontVariant?.includes('lining') ? 'nui-tnum' : 'nui-osf';
return (
<Tag
className="newspaper-headline"
className={cx('nui-headline', variantClass, className)}
style={{
fontFamily: `var(${config.fontFamily})`,
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
letterSpacing: config.letterSpacing,
color: `var(${config.color})`,
margin: config.margin,
width,
textAlign: align,
textWrap: 'balance' as CSSProperties['textWrap'],
gridColumn: cols ? `span ${cols}` : undefined,
...style,
}}
data-weight={weight}
data-span={finalSpan}
>
{children}
</Tag>
+25 -17
View File
@@ -1,42 +1,50 @@
import React from 'react';
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { clampSpan, cx } from '@newspaperui/utils';
import { useSection } from '../layout/Section';
export interface QuoteProps {
variant?: 'block' | 'inline';
weight?: 'High' | 'Medium';
span?: number;
children: React.ReactNode;
cite?: string;
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export const Quote: React.FC<QuoteProps> = ({
weight = 'Medium',
span,
children,
variant = 'block', weight = 'Medium', span, cite, className, style, children,
}) => {
const section = useSection();
const config = visualWeights.Quote[weight];
const config = visualWeights.Quote[weight]!;
const cols = span ? clampSpan(span, section.columns) : undefined;
if (!config) {
throw new Error(`Invalid weight: ${weight} for Quote`);
if (variant === 'inline') {
return (
<em className={cx('nui-quote nui-quote--inline', className)} style={style}>
{children}
</em>
);
}
const finalSpan = span || (Array.isArray(config.span) ? config.span[0] : config.span);
const width = calculateSpanWidth(finalSpan, section.columns);
return (
<blockquote
className="newspaper-quote"
cite={cite}
className={cx('nui-quote nui-quote--block nui-osf', className)}
style={{
fontFamily: `var(${config.fontFamily})`,
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
color: `var(${config.color})`,
margin: config.margin,
width,
gridColumn: cols ? `span ${cols}` : undefined,
borderLeft: 'none',
...style,
}}
data-weight={weight}
data-span={finalSpan}
>
{children}
</blockquote>
+20 -22
View File
@@ -1,44 +1,42 @@
import React from 'react';
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { clampSpan, cx } from '@newspaperui/utils';
import { useSection } from '../layout/Section';
export interface SubheadProps {
weight?: 'High' | 'Medium';
span?: number;
children: React.ReactNode;
as?: 'h2' | 'h3' | 'h4' | 'p';
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export const Subhead: React.FC<SubheadProps> = ({
weight = 'Medium',
span,
children,
weight = 'Medium', span, as = 'p', className, style, 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);
const config = visualWeights.Subhead[weight]!;
const Tag = as as keyof JSX.IntrinsicElements;
const cols = span ? clampSpan(span, section.columns) : undefined;
return (
<h2
className="newspaper-subhead"
<Tag
className={cx('nui-subhead nui-osf', className)}
style={{
fontFamily: `var(${config.fontFamily})`,
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
fontStyle: config.fontStyle,
lineHeight: config.lineHeight,
color: config.color,
color: `var(${config.color})`,
margin: config.margin,
width,
gridColumn: cols ? `span ${cols}` : undefined,
...style,
}}
data-weight={weight}
data-span={finalSpan}
>
{children}
</h2>
</Tag>
);
};
@@ -1,51 +1,46 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../Layout/Layout';
import { Section } from '../Section/Section';
import { Article } from '../Article/Article';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Article } from '../layout/Article';
describe('Article Component', () => {
it('renders children correctly', () => {
describe('Article', () => {
it('applies grid-column span N when span is provided', () => {
const { container } = render(
<Layout>
<Section columns={12}>
<Article span={6}>
<div>Article Content</div>
</Article>
<Article span={6}>x</Article>
</Section>
</Layout>
</Layout>,
);
expect(container.textContent).toContain('Article Content');
const article = container.querySelector('article') as HTMLElement;
expect(article.style.gridColumn).toBe('span 6');
expect(article.getAttribute('data-span')).toBe('6');
});
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', () => {
it('defaults to filling section.columns when span is omitted', () => {
const { container } = render(
<Layout>
<Section columns={12}>
<Article span={6}>
<div>Half Width</div>
</Article>
<Section columns={8}>
<Article>x</Article>
</Section>
</Layout>
</Layout>,
);
const article = container.querySelector('.newspaper-article');
expect(article).toHaveStyle({ gridColumn: 'span 6' });
const article = container.querySelector('article') as HTMLElement;
expect(article.style.gridColumn).toBe('span 8');
expect(article.getAttribute('data-span')).toBe('8');
});
it('clamps span when exceeding section.columns', () => {
const { container } = render(
<Layout>
<Section columns={6}>
<Article span={20}>x</Article>
</Section>
</Layout>,
);
const article = container.querySelector('article') as HTMLElement;
expect(article.style.gridColumn).toBe('span 6');
expect(article.getAttribute('data-span')).toBe('6');
});
});
@@ -1,36 +1,39 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Layout } from '../Layout/Layout';
import { Section } from '../Section/Section';
import { Layout, useLayout } from '../layout/Layout';
describe('Layout Component', () => {
it('renders children correctly', () => {
const ColumnsProbe = () => {
const { columns } = useLayout();
return <span data-testid="cols">{columns}</span>;
};
describe('Layout', () => {
it('renders children', () => {
render(
<Layout>
<div>Test Content</div>
</Layout>
<p>hello</p>
</Layout>,
);
expect(screen.getByText('Test Content')).toBeInTheDocument();
expect(screen.getByText('hello')).toBeInTheDocument();
});
it('applies custom maxWidth and padding', () => {
it('provides default columns context of 24', () => {
render(
<Layout>
<ColumnsProbe />
</Layout>,
);
expect(screen.getByTestId('cols').textContent).toBe('24');
});
it('applies maxWidth and padding', () => {
const { container } = render(
<Layout maxWidth="1200px" padding="2rem">
<div>Content</div>
</Layout>
<Layout maxWidth="900px" padding="16px">
<p>x</p>
</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();
const root = container.firstChild as HTMLElement;
expect(root.style.maxWidth).toBe('900px');
expect(root.style.padding).toBe('16px');
});
});
@@ -1,46 +1,49 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../Layout/Layout';
import { Section } from '../Section/Section';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section, useSection } from '../layout/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');
});
const ColsProbe = () => {
const { columns } = useSection();
return <span data-testid="section-cols">{columns}</span>;
};
it('clamps columns exceeding layout columns and warns', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
describe('Section', () => {
it('applies grid-template-columns repeat', () => {
const { container } = render(
<Layout columns={24}>
<Section columns={30}>
<div>Clamped</div>
<Section columns={12}>
<p>x</p>
</Section>
</Layout>
</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();
const section = container.querySelector('section') as HTMLElement;
expect(section.style.display).toBe('grid');
expect(section.style.gridTemplateColumns).toBe('repeat(12, 1fr)');
expect(section.getAttribute('data-columns')).toBe('12');
});
it('applies custom padding and margin', () => {
it('clamps columns when exceeding layout.columns', () => {
render(
<Layout columns={12}>
<Section columns={24}>
<ColsProbe />
</Section>
</Layout>,
);
expect(screen.getByTestId('section-cols').textContent).toBe('12');
});
it('applies top and bottom borders when divider="both"', () => {
const { container } = render(
<Layout>
<Section columns={12} padding="1rem" margin="2rem">
<div>Content</div>
<Section columns={12} divider="both">
<p>x</p>
</Section>
</Layout>
</Layout>,
);
const section = container.querySelector('.newspaper-section');
expect(section).toHaveStyle({ padding: '1rem', margin: '2rem' });
const section = container.querySelector('section') as HTMLElement;
expect(section.style.borderTop).toContain('1px');
expect(section.style.borderBottom).toContain('1px');
});
});
+40 -48
View File
@@ -1,51 +1,43 @@
/**
* @newspaperui/components
* React components for newspaperui
*/
import '@newspaperui/theme';
// Layout components
export { Layout, useLayout } from './Layout/Layout';
export type { LayoutProps } from './Layout/Layout';
// layout
export { Layout, useLayout } from './layout/Layout';
export type { LayoutProps } from './layout/Layout';
export { Section, useSection } from './layout/Section';
export type { SectionProps } from './layout/Section';
export { Article } from './layout/Article';
export type { ArticleProps } from './layout/Article';
export { Layer } from './layout/Layer';
export type { LayerProps } from './layout/Layer';
export { Masthead } from './layout/Masthead';
export type { MastheadProps } from './layout/Masthead';
export { Rule } from './layout/Rule';
export type { RuleProps } from './layout/Rule';
export { Section, useSection } from './Section/Section';
export type { SectionProps } from './Section/Section';
// text
export { Headline } from './text/Headline';
export type { HeadlineProps } from './text/Headline';
export { Subhead } from './text/Subhead';
export type { SubheadProps } from './text/Subhead';
export { Kicker } from './text/Kicker';
export type { KickerProps } from './text/Kicker';
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 { Dateline } from './text/Dateline';
export type { DatelineProps } from './text/Dateline';
export { Caption } from './text/Caption';
export type { CaptionProps } from './text/Caption';
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';
// media
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';