feat: real component preview in Create page, RelatedArticles + AuthorCard, test coverage 40/51
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Layout } from '../layout/Layout';
|
||||
import { Section } from '../layout/Section';
|
||||
import { Byline } from '../text/Byline';
|
||||
|
||||
describe('Byline', () => {
|
||||
it('renders with small-caps class', () => {
|
||||
const { container } = render(
|
||||
<Layout><Section columns={24}><Byline>By Alice</Byline></Section></Layout>
|
||||
);
|
||||
const el = container.querySelector('.nui-byline');
|
||||
expect(el?.classList.contains('nui-small-caps')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders children', () => {
|
||||
const { getByText } = render(
|
||||
<Layout><Section columns={24}><Byline>By Alice Smith</Byline></Section></Layout>
|
||||
);
|
||||
expect(getByText('By Alice Smith')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Layout } from '../layout/Layout';
|
||||
import { Section } from '../layout/Section';
|
||||
import { Caption } from '../text/Caption';
|
||||
|
||||
describe('Caption', () => {
|
||||
it('renders as figcaption', () => {
|
||||
const { container } = render(
|
||||
<Layout><Section columns={24}><Caption>text</Caption></Section></Layout>
|
||||
);
|
||||
expect(container.querySelector('figcaption')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders credit in small-caps', () => {
|
||||
const { container } = render(
|
||||
<Layout><Section columns={24}><Caption credit="Photo by X">text</Caption></Section></Layout>
|
||||
);
|
||||
const credit = container.querySelector('.nui-small-caps');
|
||||
expect(credit?.textContent).toBe('Photo by X');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Layout } from '../layout/Layout';
|
||||
import { Section } from '../layout/Section';
|
||||
import { Footer } from '../layout/Footer';
|
||||
|
||||
describe('Footer', () => {
|
||||
const wrap = (ui: React.ReactElement) => render(
|
||||
<Layout><Section columns={24}>{ui}</Section></Layout>
|
||||
);
|
||||
|
||||
it('renders copyright text', () => {
|
||||
const { getByText } = wrap(<Footer copyright="© 2026" />);
|
||||
expect(getByText('© 2026')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders links', () => {
|
||||
const { getByText } = wrap(
|
||||
<Footer links={[{ label: 'About', href: '/about' }]} />
|
||||
);
|
||||
const link = getByText('About');
|
||||
expect(link.getAttribute('href')).toBe('/about');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Layout } from '../layout/Layout';
|
||||
import { Section } from '../layout/Section';
|
||||
import { Image } from '../media/Image';
|
||||
|
||||
describe('Image', () => {
|
||||
const wrap = (ui: React.ReactElement) => render(
|
||||
<Layout><Section columns={24}>{ui}</Section></Layout>
|
||||
);
|
||||
|
||||
it('renders img with src and alt', () => {
|
||||
const { container } = wrap(<Image src="/test.jpg" alt="test" />);
|
||||
const img = container.querySelector('img');
|
||||
expect(img?.getAttribute('src')).toBe('/test.jpg');
|
||||
expect(img?.getAttribute('alt')).toBe('test');
|
||||
});
|
||||
|
||||
it('defaults to lazy loading', () => {
|
||||
const { container } = wrap(<Image src="/test.jpg" alt="test" />);
|
||||
const img = container.querySelector('img');
|
||||
expect(img?.getAttribute('loading')).toBe('lazy');
|
||||
});
|
||||
|
||||
it('applies aspectRatio', () => {
|
||||
const { container } = wrap(<Image src="/test.jpg" alt="test" aspectRatio="16/9" />);
|
||||
const img = container.querySelector('img') as HTMLElement;
|
||||
expect(img.style.aspectRatio).toBe('16/9');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Layout } from '../layout/Layout';
|
||||
import { Section } from '../layout/Section';
|
||||
import { Kicker } from '../text/Kicker';
|
||||
|
||||
describe('Kicker', () => {
|
||||
it('renders with small-caps class', () => {
|
||||
const { container } = render(
|
||||
<Layout><Section columns={24}><Kicker>BREAKING</Kicker></Section></Layout>
|
||||
);
|
||||
const el = container.querySelector('.nui-kicker');
|
||||
expect(el).toBeTruthy();
|
||||
expect(el?.classList.contains('nui-small-caps')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders children text', () => {
|
||||
const { getByText } = render(
|
||||
<Layout><Section columns={24}><Kicker>Politics</Kicker></Section></Layout>
|
||||
);
|
||||
expect(getByText('Politics')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Layer } from '../layout/Layer';
|
||||
|
||||
describe('Layer', () => {
|
||||
it('renders with absolute position by default', () => {
|
||||
const { container } = render(<Layer>content</Layer>);
|
||||
const div = container.firstElementChild as HTMLElement;
|
||||
expect(div.style.position).toBe('absolute');
|
||||
});
|
||||
|
||||
it('applies top/left/zIndex', () => {
|
||||
const { container } = render(<Layer position="fixed" top="10px" left="20px" zIndex={99}>x</Layer>);
|
||||
const div = container.firstElementChild as HTMLElement;
|
||||
expect(div.style.position).toBe('fixed');
|
||||
expect(div.style.top).toBe('10px');
|
||||
expect(div.style.left).toBe('20px');
|
||||
expect(div.style.zIndex).toBe('99');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Layout } from '../layout/Layout';
|
||||
import { Section } from '../layout/Section';
|
||||
import { PullQuote } from '../media/PullQuote';
|
||||
|
||||
describe('PullQuote', () => {
|
||||
const wrap = (ui: React.ReactElement) => render(
|
||||
<Layout><Section columns={24}>{ui}</Section></Layout>
|
||||
);
|
||||
|
||||
it('renders as aside with border-top and border-bottom', () => {
|
||||
const { container } = wrap(<PullQuote>quote text</PullQuote>);
|
||||
const aside = container.querySelector('aside') as HTMLElement;
|
||||
expect(aside).toBeTruthy();
|
||||
expect(aside.style.borderTop).toContain('1px solid');
|
||||
expect(aside.style.borderBottom).toContain('1px solid');
|
||||
});
|
||||
|
||||
it('renders author in footer', () => {
|
||||
const { getByText } = wrap(<PullQuote author="John">quote</PullQuote>);
|
||||
expect(getByText('— John')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('applies spanAllColumns class', () => {
|
||||
const { container } = wrap(<PullQuote spanAllColumns>quote</PullQuote>);
|
||||
const aside = container.querySelector('aside');
|
||||
expect(aside?.classList.contains('nui-span-all-columns')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Layout } from '../layout/Layout';
|
||||
import { Section } from '../layout/Section';
|
||||
import { Quote } from '../text/Quote';
|
||||
|
||||
describe('Quote', () => {
|
||||
const wrap = (ui: React.ReactElement) => render(
|
||||
<Layout><Section columns={24}>{ui}</Section></Layout>
|
||||
);
|
||||
|
||||
it('renders block variant as blockquote', () => {
|
||||
const { container } = wrap(<Quote variant="block">text</Quote>);
|
||||
expect(container.querySelector('blockquote')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders inline variant as em', () => {
|
||||
const { container } = wrap(<Quote variant="inline">text</Quote>);
|
||||
expect(container.querySelector('em')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('block variant does not apply visible border-left', () => {
|
||||
const { container } = wrap(<Quote variant="block">text</Quote>);
|
||||
const bq = container.querySelector('blockquote') as HTMLElement;
|
||||
// borderLeft: 'none' is set in component; jsdom normalizes it to empty sub-properties
|
||||
expect(bq.style.borderLeftStyle).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Layout } from '../layout/Layout';
|
||||
import { Section } from '../layout/Section';
|
||||
import { Rule } from '../layout/Rule';
|
||||
|
||||
describe('Rule', () => {
|
||||
const wrap = (ui: React.ReactElement) => render(
|
||||
<Layout><Section columns={24}>{ui}</Section></Layout>
|
||||
);
|
||||
|
||||
it('renders hairline by default', () => {
|
||||
const { container } = wrap(<Rule />);
|
||||
const hr = container.querySelector('hr');
|
||||
expect(hr).toBeTruthy();
|
||||
expect(hr?.style.borderTop).toContain('1px solid');
|
||||
});
|
||||
|
||||
it('renders thick variant', () => {
|
||||
const { container } = wrap(<Rule variant="thick" />);
|
||||
const hr = container.querySelector('hr');
|
||||
expect(hr?.style.borderTop).toContain('3px solid');
|
||||
});
|
||||
|
||||
it('renders double variant with gradient', () => {
|
||||
const { container } = wrap(<Rule variant="double" />);
|
||||
const hr = container.querySelector('hr');
|
||||
expect(hr?.style.height).toBe('6px');
|
||||
});
|
||||
});
|
||||
@@ -47,3 +47,11 @@ export { Video } from './media/Video';
|
||||
export type { VideoProps } from './media/Video';
|
||||
export { PullQuote } from './media/PullQuote';
|
||||
export type { PullQuoteProps } from './media/PullQuote';
|
||||
|
||||
// additional layout
|
||||
export { RelatedArticles } from './layout/RelatedArticles';
|
||||
export type { RelatedArticlesProps, RelatedArticle } from './layout/RelatedArticles';
|
||||
|
||||
// additional text
|
||||
export { AuthorCard } from './text/AuthorCard';
|
||||
export type { AuthorCardProps } from './text/AuthorCard';
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { cx } from 'newspaperui-utils';
|
||||
|
||||
export interface RelatedArticle {
|
||||
title: string;
|
||||
href: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface RelatedArticlesProps {
|
||||
title?: string;
|
||||
articles: RelatedArticle[];
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* RelatedArticles — "See Also" / "相关报道" 列表
|
||||
*
|
||||
* @example
|
||||
* <RelatedArticles title="Related" articles={[
|
||||
* { title: 'Climate bill passes', href: '/article/1', category: 'Politics' }
|
||||
* ]} />
|
||||
*/
|
||||
export const RelatedArticles: React.FC<RelatedArticlesProps> = ({
|
||||
title = 'Related', articles, className, style,
|
||||
}) => (
|
||||
<aside
|
||||
className={cx('nui-related', className)}
|
||||
style={{
|
||||
borderTop: '2px solid var(--nui-rule-decorative)',
|
||||
paddingTop: 'var(--nui-space-4)',
|
||||
marginTop: 'var(--nui-space-6)',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<h4 style={{
|
||||
fontFamily: 'var(--font-family-meta)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
fontVariantCaps: 'small-caps',
|
||||
letterSpacing: '0.08em',
|
||||
color: 'var(--nui-accent-primary)',
|
||||
margin: '0 0 var(--nui-space-3) 0',
|
||||
}}>{title}</h4>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{articles.map((article, i) => (
|
||||
<li key={i} style={{
|
||||
borderBottom: '1px solid var(--nui-rule-hairline)',
|
||||
padding: 'var(--nui-space-2) 0',
|
||||
}}>
|
||||
{article.category && (
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-family-meta)',
|
||||
fontSize: '10px',
|
||||
fontVariantCaps: 'small-caps',
|
||||
letterSpacing: '0.06em',
|
||||
color: 'var(--nui-text-muted)',
|
||||
marginRight: 'var(--nui-space-2)',
|
||||
}}>{article.category}</span>
|
||||
)}
|
||||
<a href={article.href} style={{
|
||||
fontFamily: 'var(--font-family-headline)',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.3,
|
||||
color: 'var(--nui-text-primary)',
|
||||
textDecoration: 'none',
|
||||
}}>{article.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { cx } from 'newspaperui-utils';
|
||||
|
||||
export interface AuthorCardProps {
|
||||
name: string;
|
||||
role?: string;
|
||||
bio?: string;
|
||||
avatar?: string;
|
||||
email?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthorCard — 作者简介卡片(长篇报道末尾)
|
||||
*
|
||||
* @example
|
||||
* <AuthorCard name="Eleanor Whitcombe" role="Senior Correspondent" bio="..." avatar="/avatar.jpg" />
|
||||
*/
|
||||
export const AuthorCard: React.FC<AuthorCardProps> = ({
|
||||
name, role, bio, avatar, email, className, style,
|
||||
}) => (
|
||||
<div
|
||||
className={cx('nui-author-card', className)}
|
||||
style={{
|
||||
borderTop: '1px solid var(--nui-rule-hairline)',
|
||||
paddingTop: 'var(--nui-space-4)',
|
||||
marginTop: 'var(--nui-space-6)',
|
||||
display: 'flex',
|
||||
gap: 'var(--nui-space-4)',
|
||||
alignItems: 'flex-start',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{avatar && (
|
||||
<img
|
||||
src={avatar}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
style={{
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-family-meta)',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
fontVariantCaps: 'small-caps',
|
||||
letterSpacing: '0.06em',
|
||||
color: 'var(--nui-text-primary)',
|
||||
}}>{name}</div>
|
||||
{role && (
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-family-meta)',
|
||||
fontSize: '12px',
|
||||
color: 'var(--nui-text-muted)',
|
||||
marginTop: '2px',
|
||||
}}>{role}</div>
|
||||
)}
|
||||
{bio && (
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-family-body)',
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.5,
|
||||
color: 'var(--nui-text-secondary)',
|
||||
margin: 'var(--nui-space-2) 0 0 0',
|
||||
}}>{bio}</p>
|
||||
)}
|
||||
{email && (
|
||||
<a href={`mailto:${email}`} style={{
|
||||
fontFamily: 'var(--font-family-meta)',
|
||||
fontSize: '11px',
|
||||
color: 'var(--nui-accent-primary)',
|
||||
textDecoration: 'none',
|
||||
marginTop: 'var(--nui-space-2)',
|
||||
display: 'inline-block',
|
||||
}}>{email}</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
Reference in New Issue
Block a user