-
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user