This commit is contained in:
sunzhongyi
2026-05-19 21:09:56 +08:00
commit f3e6b95be9
78 changed files with 10099 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Build outputs
dist
.next
out
build
*.tsbuildinfo
# Environment
.env
.env*.local
# IDE
.vscode/*
!.vscode/extensions.json
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Testing
coverage
.nyc_output
# Turbo
.turbo
# Misc
.cache
.temp
@@ -0,0 +1,102 @@
- generic [ref=e2]:
- navigation [ref=e3]:
- generic [ref=e4]:
- link "NewspaperUI" [ref=e5] [cursor=pointer]:
- /url: /
- paragraph [ref=e6]: 报纸布局组件库
- generic [ref=e7]:
- link "概览" [ref=e9] [cursor=pointer]:
- /url: /
- link "栅格系统" [ref=e11] [cursor=pointer]:
- /url: /grid-system
- generic [ref=e12]:
- link "核心组件" [ref=e13] [cursor=pointer]:
- /url: /components
- generic [ref=e14]:
- link "Article" [ref=e15] [cursor=pointer]:
- /url: /components/article
- link "Layer" [ref=e16] [cursor=pointer]:
- /url: /components/layer
- link "媒体组件" [ref=e17] [cursor=pointer]:
- /url: /components/media
- link "文本组件" [ref=e19] [cursor=pointer]:
- /url: /text
- link "主题系统" [ref=e21] [cursor=pointer]:
- /url: /theme
- generic [ref=e22]:
- link "示例" [ref=e23] [cursor=pointer]:
- /url: /examples
- generic [ref=e24]:
- link "跨栏布局" [ref=e25] [cursor=pointer]:
- /url: /examples/spanning
- link "响应式布局" [ref=e26] [cursor=pointer]:
- /url: /examples/responsive
- main [ref=e27]:
- generic [ref=e29]:
- heading "NewspaperUI" [level=1] [ref=e30]
- paragraph [ref=e31]: 生产级报纸布局组件库,参考 InDesign 设计理念,支持 24 列栅格、跨栏、视觉权重和主题系统。
- heading "设计理念" [level=2] [ref=e32]
- paragraph [ref=e33]: NewspaperUI 借鉴专业排版软件 InDesign 的设计思想,为 Web 应用提供报纸风格的布局能力。 组件设计遵循少而精、职责明确、无冗余、无歧义的原则。
- heading "核心特性" [level=2] [ref=e34]
- list [ref=e35]:
- listitem [ref=e36]:
- strong [ref=e37]: 24 列栅格系统
- text: "- 灵活的栅格布局,支持精确的列控制"
- listitem [ref=e38]:
- strong [ref=e39]: 跨栏布局
- text: "- 内容可以跨越多列,实现复杂的报纸排版"
- listitem [ref=e40]:
- strong [ref=e41]: 视觉权重系统
- text: "- High/Medium/Low 三级权重,自动映射字体大小和样式"
- listitem [ref=e42]:
- strong [ref=e43]: 主题系统
- text: "- 基于 CSS Variables,支持主题切换和自定义"
- listitem [ref=e44]:
- strong [ref=e45]: 响应式设计
- text: "- 小屏自动调整为 12-16 列,保持良好的阅读体验"
- listitem [ref=e46]:
- strong [ref=e47]: 浮动 Layer
- text: "- 支持绝对定位的浮动元素,如广告、拉引等"
- heading "快速开始" [level=2] [ref=e48]
- heading "安装" [level=3] [ref=e49]
- code [ref=e51]: pnpm add @newspaperui/components @newspaperui/theme
- heading "基础使用" [level=3] [ref=e52]
- generic [ref=e53]:
- generic [ref=e54]:
- heading "简单的报纸布局" [level=3] [ref=e55]
- paragraph [ref=e56]: 使用 Layout 和 Section 创建基础的栅格布局
- generic [ref=e58]:
- article [ref=e60]:
- heading "主要新闻标题" [level=1] [ref=e61]
- paragraph [ref=e62]: 这是一篇重要的新闻内容,使用 High 权重的标题和正文文本。
- generic [ref=e63]:
- article [ref=e64]:
- heading "次要新闻" [level=1] [ref=e65]
- paragraph [ref=e66]: 这是另一篇新闻,使用 Medium 权重。
- article [ref=e67]:
- heading "更多新闻" [level=1] [ref=e68]
- paragraph [ref=e69]: 更多内容在这里展示。
- button "查看代码" [ref=e71] [cursor=pointer]
- heading "下一步" [level=2] [ref=e72]
- paragraph [ref=e73]: 探索文档了解更多功能:
- list [ref=e74]:
- listitem [ref=e75]:
- link "栅格系统" [ref=e76] [cursor=pointer]:
- /url: /grid-system
- text: "- 了解 24 列栅格的工作原理"
- listitem [ref=e77]:
- link "核心组件" [ref=e78] [cursor=pointer]:
- /url: /components/article
- text: "- 学习 Article、Layer 等核心组件"
- listitem [ref=e79]:
- link "文本组件" [ref=e80] [cursor=pointer]:
- /url: /text
- text: "- 掌握视觉权重系统"
- listitem [ref=e81]:
- link "主题系统" [ref=e82] [cursor=pointer]:
- /url: /theme
- text: "- 自定义主题和样式"
- listitem [ref=e83]:
- link "示例" [ref=e84] [cursor=pointer]:
- /url: /examples/spanning
- text: "- 查看完整的布局示例"
@@ -0,0 +1,13 @@
- generic [active]:
- generic [ref=e5] [cursor=pointer]:
- button "Open Next.js Dev Tools" [ref=e6]:
- img [ref=e7]
- generic [ref=e10]:
- button "Open issues overlay" [ref=e11]:
- generic [ref=e12]:
- generic [ref=e13]: "0"
- generic [ref=e14]: "1"
- generic [ref=e15]: Issue
- button "Collapse issues badge" [ref=e16]:
- img [ref=e17]
- alert [ref=e19]
@@ -0,0 +1,102 @@
- generic [ref=e2]:
- navigation [ref=e3]:
- generic [ref=e4]:
- link "NewspaperUI" [ref=e5] [cursor=pointer]:
- /url: /
- paragraph [ref=e6]: 报纸布局组件库
- generic [ref=e7]:
- link "概览" [ref=e9] [cursor=pointer]:
- /url: /
- link "栅格系统" [ref=e11] [cursor=pointer]:
- /url: /grid-system
- generic [ref=e12]:
- link "核心组件" [ref=e13] [cursor=pointer]:
- /url: /components
- generic [ref=e14]:
- link "Article" [ref=e15] [cursor=pointer]:
- /url: /components/article
- link "Layer" [ref=e16] [cursor=pointer]:
- /url: /components/layer
- link "媒体组件" [ref=e17] [cursor=pointer]:
- /url: /components/media
- link "文本组件" [ref=e19] [cursor=pointer]:
- /url: /text
- link "主题系统" [ref=e21] [cursor=pointer]:
- /url: /theme
- generic [ref=e22]:
- link "示例" [ref=e23] [cursor=pointer]:
- /url: /examples
- generic [ref=e24]:
- link "跨栏布局" [ref=e25] [cursor=pointer]:
- /url: /examples/spanning
- link "响应式布局" [ref=e26] [cursor=pointer]:
- /url: /examples/responsive
- main [ref=e27]:
- generic [ref=e29]:
- heading "NewspaperUI" [level=1] [ref=e30]
- paragraph [ref=e31]: 生产级报纸布局组件库,参考 InDesign 设计理念,支持 24 列栅格、跨栏、视觉权重和主题系统。
- heading "设计理念" [level=2] [ref=e32]
- paragraph [ref=e33]: NewspaperUI 借鉴专业排版软件 InDesign 的设计思想,为 Web 应用提供报纸风格的布局能力。 组件设计遵循少而精、职责明确、无冗余、无歧义的原则。
- heading "核心特性" [level=2] [ref=e34]
- list [ref=e35]:
- listitem [ref=e36]:
- strong [ref=e37]: 24 列栅格系统
- text: "- 灵活的栅格布局,支持精确的列控制"
- listitem [ref=e38]:
- strong [ref=e39]: 跨栏布局
- text: "- 内容可以跨越多列,实现复杂的报纸排版"
- listitem [ref=e40]:
- strong [ref=e41]: 视觉权重系统
- text: "- High/Medium/Low 三级权重,自动映射字体大小和样式"
- listitem [ref=e42]:
- strong [ref=e43]: 主题系统
- text: "- 基于 CSS Variables,支持主题切换和自定义"
- listitem [ref=e44]:
- strong [ref=e45]: 响应式设计
- text: "- 小屏自动调整为 12-16 列,保持良好的阅读体验"
- listitem [ref=e46]:
- strong [ref=e47]: 浮动 Layer
- text: "- 支持绝对定位的浮动元素,如广告、拉引等"
- heading "快速开始" [level=2] [ref=e48]
- heading "安装" [level=3] [ref=e49]
- code [ref=e51]: pnpm add @newspaperui/components @newspaperui/theme
- heading "基础使用" [level=3] [ref=e52]
- generic [ref=e53]:
- generic [ref=e54]:
- heading "简单的报纸布局" [level=3] [ref=e55]
- paragraph [ref=e56]: 使用 Layout 和 Section 创建基础的栅格布局
- generic [ref=e58]:
- article [ref=e60]:
- heading "主要新闻标题" [level=1] [ref=e61]
- paragraph [ref=e62]: 这是一篇重要的新闻内容,使用 High 权重的标题和正文文本。
- generic [ref=e63]:
- article [ref=e64]:
- heading "次要新闻" [level=2] [ref=e65]
- paragraph [ref=e66]: 这是另一篇新闻,使用 Medium 权重。
- article [ref=e67]:
- heading "更多新闻" [level=2] [ref=e68]
- paragraph [ref=e69]: 更多内容在这里展示。
- button "查看代码" [ref=e71]
- heading "下一步" [level=2] [ref=e72]
- paragraph [ref=e73]: 探索文档了解更多功能:
- list [ref=e74]:
- listitem [ref=e75]:
- link "栅格系统" [ref=e76] [cursor=pointer]:
- /url: /grid-system
- text: "- 了解 24 列栅格的工作原理"
- listitem [ref=e77]:
- link "核心组件" [ref=e78] [cursor=pointer]:
- /url: /components/article
- text: "- 学习 Article、Layer 等核心组件"
- listitem [ref=e79]:
- link "文本组件" [ref=e80] [cursor=pointer]:
- /url: /text
- text: "- 掌握视觉权重系统"
- listitem [ref=e81]:
- link "主题系统" [ref=e82] [cursor=pointer]:
- /url: /theme
- text: "- 自定义主题和样式"
- listitem [ref=e83]:
- link "示例" [ref=e84] [cursor=pointer]:
- /url: /examples/spanning
- text: "- 查看完整的布局示例"
+82
View File
@@ -0,0 +1,82 @@
# NewspaperUI
A newspaper-style UI component library built with React, TypeScript, and Tailwind CSS.
## Project Structure
```
newspaperui/
├── packages/
│ ├── components/ # React components
│ ├── theme/ # CSS variables and Tailwind tokens
│ ├── utils/ # Utility functions
│ └── docs/ # Documentation site (Next.js)
├── turbo.json # Turborepo configuration
├── pnpm-workspace.yaml # pnpm workspace configuration
└── package.json # Root package.json
```
## Getting Started
### Prerequisites
- Node.js >= 18.0.0
- pnpm >= 9.0.0
### Installation
```bash
pnpm install
```
### Development
```bash
# Run all packages in dev mode
pnpm dev
# Build all packages
pnpm build
# Run linting
pnpm lint
# Run tests
pnpm test
```
## Packages
### @newspaperui/components
React components for building newspaper-style interfaces.
**Dependencies**: `@newspaperui/theme`, `@newspaperui/utils`
### @newspaperui/theme
CSS variables and Tailwind tokens for consistent theming.
### @newspaperui/utils
Utility functions used across the component library.
### @newspaperui/docs
Documentation site built with Next.js 14+ App Router.
**Dependencies**: `@newspaperui/components`
## Tech Stack
- **React** 18+
- **TypeScript** 5+
- **Tailwind CSS** 3+
- **Next.js** 14+ (docs only)
- **Vite** 5+ (build tool for components/theme/utils)
- **Turborepo** (monorepo orchestration)
- **pnpm** (package manager)
## License
MIT
+143
View File
@@ -0,0 +1,143 @@
# AI Agent Prompt: 生产级报纸组件库开发
## 任务目标
开发一个浏览器端生产级报纸布局组件库,参考 InDesign,组件少而精、职责明确、无冗余、无歧义,支持全局 24 列栅格、跨栏、视觉权重和主题系统,提供文档网站(内嵌 demo,无需 storybook)。
---
## 1. 项目 setup 与架构
- **Monorepo 管理**pnpm workspace / Turborepo
- Packages:
1. `components` → Layout / Section / Article / Layer / Text / Media
2. `theme` → 全局主题变量、字体、颜色、间距、视觉权重映射
3. `docs` → 文档网站(shadcn stack + demo
4. `utils` → 栅格计算、跨栏逻辑、响应式辅助函数
- **目录结构示例**:
```
/packages
/components
/Layout
/Section
/Article
/Layer
/Text
/Media
/theme
/docs
/utils
```
- **技术栈**:
- React 18+, TypeScript
- TailwindCSS + shadcn 文档 stack
- Vitest + React Testing Library
- 构建工具:Vite + Rollup
---
## 2. 全局栅格系统
- `<Layout>` 顶层容器:
- maxWidth, padding, gutter, theme
- 栅格总列数 24
- snap-to-grid 支持
- Section 内对象 span ≤ Section.columns
- 响应式调整:小屏 1216 列
---
## 3. 核心布局组件
1. `<Section>`
- columns, breakable, padding/margin, priority
- 内部对象 span ≤ Section.columns
2. `<Article>`
- span, priority/weight, breakable
- 内含 Headline / Subhead / BodyText / Image / PullQuote
3. `<Layer>`
- position, top/left/right/bottom, zIndex
- 用于浮动广告、拉引、浮动图片
---
## 4. 内容组件
- 文本类:Headline / Subhead / BodyText / Quote / Byline / Caption
- 媒体类:Image / Figure / Video / PullQuote
- 属性:span(跨栏)、weightHigh/Medium/Low)、margin/padding
- 所有 span 基于 Section.columns
---
## 5. 视觉权重映射表(24 列)
| 组件 | 权重 | font-size | font-weight | line-height | color | span | margin/padding |
|---|---|---|---|---|---|---|---|
| Headline | High | 3648px | 700 | 1.1 | #111 | 68 | 0 0 1rem 0 |
| Headline | Medium | 2834px | 600 | 1.2 | #111 | 46 | 0 0 0.75rem 0 |
| Headline | Low | 2226px | 500 | 1.3 | #222 | 24 | 0 0 0.5rem 0 |
| Subhead | High | 2024px | 600 | 1.25 | #222 | 23 | 0 0 0.5rem 0 |
| Subhead | Medium | 1618px | 500 | 1.3 | #333 | 12 | 0 0 0.25rem 0 |
| BodyText | High | 16px | 400 | 1.5 | #333 | 1 | 0 0 1rem 0 |
| BodyText | Medium | 1415px | 400 | 1.5 | #444 | 1 | 0 0 0.75rem 0 |
| BodyText | Low | 1214px | 400 | 1.4 | #555 | 1 | 0 0 0.5rem 0 |
| Quote | High | 2024px | 500 | 1.4 | #222 | 2 | 0 0 0.75rem 0 |
| Quote | Medium | 1618px | 400 | 1.4 | #333 | 1 | 0 0 0.5rem 0 |
| Image Caption | Standard | 1214px | 400 | 1.3 | #555 | 1 | 0.25rem 0 |
| PullQuote | High | 2428px | 600 | 1.2 | #111 | 23 | 0 0 0.5rem 0 |
| PullQuote | Medium | 1820px | 500 | 1.25 | #222 | 12 | 0 0 0.25rem 0 |
| Byline | Standard | 1214px | 400 | 1.3 | #555 | 1 | 0 0 0.25rem 0 |
---
## 6. 主题系统
- 全局新闻字体预设
- 颜色变量(黑/灰/强调色)
- 间距变量(gutter, margin, padding
- 权重映射 Token
- Tailwind + CSS Variables 支持主题切换
---
## 7. 布局规则
- Section 内对象 span ≤ Section.columns
- 高权重对象可跨多栏
- Layer 可浮动/绝对定位
- breakable 控制分页断开
- 响应式:小屏 1216 列
---
## 8. 文档网站
- 技术栈:shadcn stack + Next.js + Tailwind + MDX
- Demo 内嵌组件,展示属性、span、视觉权重、跨栏效果
- 文档章节:
1. 概览与目标
2. 栅格系统与 Layout/Section 说明
3. 核心组件(Article/Image/Layer)属性说明
4. 文本组件属性与权重映射
5. 主题与变量使用指南
6. 跨栏和浮动 Layer 示例
7. 响应式布局展示
---
## 9. 实施顺序
1. 初始化 monorepo + Layout / Section 基础组件
2. 配置主题系统、视觉权重映射 CSS / Tailwind Token
3. 开发 Article / Image / Layer / PullQuote / 文本组件
4. 实现 Section 网格跨栏逻辑
5. 测试生产级报纸布局:跨栏、浮动 Layer、breakable、响应式
6. 构建文档网站,内嵌 demo 展示组件属性与效果
7. 确认生产级排版效果符合视觉权重、跨栏、主题、响应式要求
Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

+22
View File
@@ -0,0 +1,22 @@
{
"name": "newspaperui",
"version": "0.0.0",
"private": true,
"description": "A newspaper-style UI component library",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"clean": "turbo run clean && rm -rf node_modules"
},
"devDependencies": {
"turbo": "^2.3.3",
"typescript": "^5.7.2"
},
"packageManager": "pnpm@9.15.4",
"engines": {
"node": ">=18.0.0",
"pnpm": ">=9.0.0"
}
}
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@newspaperui/components",
"version": "0.0.0",
"description": "React components for newspaperui",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "vite build",
"dev": "vite build --watch",
"test": "vitest run",
"test:watch": "vitest",
"lint": "tsc --noEmit",
"clean": "rm -rf dist"
},
"peerDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"dependencies": {
"@newspaperui/theme": "workspace:*",
"@newspaperui/utils": "workspace:*"
},
"devDependencies": {
"@testing-library/react": "^16.1.0",
"@testing-library/jest-dom": "^6.6.3",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vite-plugin-dts": "^4.3.0",
"vitest": "^2.1.8"
}
}
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,37 @@
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>
);
};
+47
View File
@@ -0,0 +1,47 @@
import React, { createContext, useContext } from 'react';
export interface LayoutProps {
maxWidth?: string;
padding?: string;
gutter?: number;
theme?: 'light' | 'dark';
columns?: number;
children: React.ReactNode;
}
interface LayoutContextValue {
columns: number;
gutter: number;
}
const LayoutContext = createContext<LayoutContextValue>({
columns: 24,
gutter: 16,
});
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>
);
};
+43
View File
@@ -0,0 +1,43 @@
import React from 'react';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { Caption } from '../Text/Caption';
export interface FigureProps {
src: string;
alt: string;
caption?: string;
span?: number;
children?: React.ReactNode;
}
export const Figure: React.FC<FigureProps> = ({
src,
alt,
caption,
span = 1,
children,
}) => {
const section = useSection();
const width = calculateSpanWidth(span, section.columns);
return (
<figure
className="newspaper-figure"
style={{ width, margin: 0 }}
data-span={span}
>
<img
src={src}
alt={alt}
style={{
width: '100%',
height: 'auto',
display: 'block',
}}
/>
{caption && <Caption>{caption}</Caption>}
{children}
</figure>
);
};
+42
View File
@@ -0,0 +1,42 @@
import React from 'react';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { Caption } from '../Text/Caption';
export interface ImageProps {
src: string;
alt: string;
span?: number;
caption?: string;
priority?: 'High' | 'Medium' | 'Low';
}
export const Image: React.FC<ImageProps> = ({
src,
alt,
span = 1,
caption,
priority = 'Medium',
}) => {
const section = useSection();
const width = calculateSpanWidth(span, section.columns);
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>
);
};
@@ -0,0 +1,62 @@
import React from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
export interface PullQuoteProps {
weight?: 'High' | 'Medium';
span?: number;
author?: string;
children: React.ReactNode;
}
export const PullQuote: React.FC<PullQuoteProps> = ({
weight = 'High',
span,
author,
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);
return (
<aside
className="newspaper-pull-quote"
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)',
}}
data-weight={weight}
data-span={finalSpan}
>
<blockquote style={{ margin: 0 }}>
{children}
</blockquote>
{author && (
<cite
style={{
display: 'block',
marginTop: '0.5rem',
fontSize: '0.875em',
fontStyle: 'normal',
}}
>
{author}
</cite>
)}
</aside>
);
};
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { Caption } from '../Text/Caption';
export interface VideoProps {
src: string;
poster?: string;
span?: number;
caption?: string;
}
export const Video: React.FC<VideoProps> = ({
src,
poster,
span = 1,
caption,
}) => {
const section = useSection();
const width = calculateSpanWidth(span, section.columns);
return (
<div
className="newspaper-video"
style={{ width }}
data-span={span}
>
<video
src={src}
poster={poster}
controls
style={{
width: '100%',
height: 'auto',
display: 'block',
}}
/>
{caption && <Caption>{caption}</Caption>}
</div>
);
};
@@ -0,0 +1,54 @@
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>
);
};
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
export interface BodyTextProps {
weight?: 'High' | 'Medium' | 'Low';
span?: number;
children: React.ReactNode;
}
export const BodyText: React.FC<BodyTextProps> = ({
weight = 'Medium',
span,
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);
return (
<p
className="newspaper-body-text"
style={{
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
margin: config.margin,
width,
}}
data-weight={weight}
data-span={finalSpan}
>
{children}
</p>
);
};
+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
export interface BylineProps {
children: React.ReactNode;
}
export const Byline: React.FC<BylineProps> = ({ children }) => {
const config = visualWeights.Byline.Standard;
if (!config) {
throw new Error('Byline configuration not found');
}
return (
<div
className="newspaper-byline"
style={{
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
margin: config.margin,
}}
>
{children}
</div>
);
};
+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
export interface CaptionProps {
children: React.ReactNode;
}
export const Caption: React.FC<CaptionProps> = ({ children }) => {
const config = visualWeights.Caption.Standard;
if (!config) {
throw new Error('Caption configuration not found');
}
return (
<figcaption
className="newspaper-caption"
style={{
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
margin: config.margin,
}}
>
{children}
</figcaption>
);
};
+53
View File
@@ -0,0 +1,53 @@
import React from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
const weightToTag: Record<'High' | 'Medium' | 'Low', 'h1' | 'h2' | 'h3'> = {
High: 'h1',
Medium: 'h2',
Low: 'h3',
};
export interface HeadlineProps {
weight?: 'High' | 'Medium' | 'Low';
span?: number;
as?: 'h1' | 'h2' | 'h3' | 'h4';
children: React.ReactNode;
}
export const Headline: React.FC<HeadlineProps> = ({
weight = 'High',
span,
as,
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];
return (
<Tag
className="newspaper-headline"
style={{
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
margin: config.margin,
width,
}}
data-weight={weight}
data-span={finalSpan}
>
{children}
</Tag>
);
};
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
export interface QuoteProps {
weight?: 'High' | 'Medium';
span?: number;
children: React.ReactNode;
}
export const Quote: React.FC<QuoteProps> = ({
weight = 'Medium',
span,
children,
}) => {
const section = useSection();
const config = visualWeights.Quote[weight];
if (!config) {
throw new Error(`Invalid weight: ${weight} for Quote`);
}
const finalSpan = span || (Array.isArray(config.span) ? config.span[0] : config.span);
const width = calculateSpanWidth(finalSpan, section.columns);
return (
<blockquote
className="newspaper-quote"
style={{
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
margin: config.margin,
width,
}}
data-weight={weight}
data-span={finalSpan}
>
{children}
</blockquote>
);
};
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
export interface SubheadProps {
weight?: 'High' | 'Medium';
span?: number;
children: React.ReactNode;
}
export const Subhead: React.FC<SubheadProps> = ({
weight = 'Medium',
span,
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);
return (
<h2
className="newspaper-subhead"
style={{
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
margin: config.margin,
width,
}}
data-weight={weight}
data-span={finalSpan}
>
{children}
</h2>
);
};
@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../Layout/Layout';
import { Section } from '../Section/Section';
import { Article } from '../Article/Article';
describe('Article Component', () => {
it('renders children correctly', () => {
const { container } = render(
<Layout>
<Section columns={12}>
<Article span={6}>
<div>Article Content</div>
</Article>
</Section>
</Layout>
);
expect(container.textContent).toContain('Article Content');
});
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', () => {
const { container } = render(
<Layout>
<Section columns={12}>
<Article span={6}>
<div>Half Width</div>
</Article>
</Section>
</Layout>
);
const article = container.querySelector('.newspaper-article');
expect(article).toHaveStyle({ gridColumn: 'span 6' });
});
});
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Layout } from '../Layout/Layout';
import { Section } from '../Section/Section';
describe('Layout Component', () => {
it('renders children correctly', () => {
render(
<Layout>
<div>Test Content</div>
</Layout>
);
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('applies custom maxWidth and padding', () => {
const { container } = render(
<Layout maxWidth="1200px" padding="2rem">
<div>Content</div>
</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();
});
});
@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../Layout/Layout';
import { Section } from '../Section/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');
});
it('clamps columns exceeding layout columns and warns', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { container } = render(
<Layout columns={24}>
<Section columns={30}>
<div>Clamped</div>
</Section>
</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();
});
it('applies custom padding and margin', () => {
const { container } = render(
<Layout>
<Section columns={12} padding="1rem" margin="2rem">
<div>Content</div>
</Section>
</Layout>
);
const section = container.querySelector('.newspaper-section');
expect(section).toHaveStyle({ padding: '1rem', margin: '2rem' });
});
});
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+51
View File
@@ -0,0 +1,51 @@
/**
* @newspaperui/components
* React components for newspaperui
*/
// Layout components
export { Layout, useLayout } from './Layout/Layout';
export type { LayoutProps } from './Layout/Layout';
export { Section, useSection } from './Section/Section';
export type { SectionProps } from './Section/Section';
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';
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [
react(),
dts({
insertTypesEntry: true,
}),
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'NewspaperUIComponents',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`,
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
},
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
});
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/__tests__/setup.ts'],
},
});
@@ -0,0 +1,192 @@
'use client';
import { Demo } from '@/components/Demo';
import { PropsTable } from '@/components/PropsTable';
import { Layout, Section, Article, Headline, BodyText, Subhead } from '@newspaperui/components';
export default function ArticlePage() {
return (
<div className="prose prose-lg max-w-none">
<h1>Article </h1>
<p className="text-xl text-gray-600">
Article
</p>
<h2></h2>
<p>
<code>Article</code>
span
</p>
<h2>API </h2>
<PropsTable
data={[
{
name: 'span',
type: 'number',
required: true,
description: '文章占据的列数,必须 ≤ 所在 Section 的 columns',
},
{
name: 'priority',
type: 'number',
default: '0',
description: '优先级,数值越大优先级越高,影响排序',
},
{
name: 'weight',
type: '"High" | "Medium" | "Low"',
default: '"Medium"',
description: '视觉权重,影响内部文本组件的默认样式',
},
{
name: 'breakable',
type: 'boolean',
default: 'true',
description: '是否允许在此处分页断开',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: '文章内容',
},
]}
/>
<h2></h2>
<Demo
title="单列文章"
description="创建一个占据 8 列的文章"
code={`<Layout columns={24}>
<Section columns={24}>
<Article span={8}>
<Headline weight="High">文章标题</Headline>
<Subhead>副标题说明</Subhead>
<BodyText>
这是文章的正文内容。Article 组件提供了内容容器,
可以包含多种文本和媒体组件。
</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={8}>
<Headline weight="High"></Headline>
<Subhead></Subhead>
<BodyText>
Article
</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
span span
span
</p>
<Demo
title="多列文章并排"
description="在 24 列布局中创建三个 8 列的文章"
code={`<Layout columns={24}>
<Section columns={24}>
<Article span={8}>
<Headline weight="High">主要文章</Headline>
<BodyText>占据 8 列的主要内容</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium">次要文章</Headline>
<BodyText>占据 8 列的次要内容</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium">更多内容</Headline>
<BodyText>占据 8 列的更多内容</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={8}>
<Headline weight="High"></Headline>
<BodyText> 8 使 High </BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText> 8 使 Medium </BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText> 8 </BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<Demo
title="混合宽度布局"
description="组合不同宽度的文章实现复杂布局"
code={`<Layout columns={24}>
<Section columns={24}>
<Article span={16}>
<Headline weight="High">宽文章 (16 列)</Headline>
<BodyText>
这是一篇占据 16 列的宽文章,适合放置重要内容或长篇文章。
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Low">窄文章 (8 列)</Headline>
<BodyText>这是占据 8 列的窄文章,适合侧边栏或简短内容。</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={16}>
<Headline weight="High"> (16 )</Headline>
<BodyText>
16
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Low"> (8 )</Headline>
<BodyText> 8 </BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2>使</h2>
<ul>
<li>使 12-16 </li>
<li>使 6-8 </li>
<li> Section Article span Section.columns</li>
<li>使 priority </li>
<li> weight </li>
<li>使 span </li>
</ul>
<h2></h2>
<ul>
<li>Article span Section columns </li>
<li> Section Article span Section.columns</li>
<li>Article </li>
<li>breakable </li>
</ul>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
'use client';
import { Demo } from '@/components/Demo';
import { PropsTable } from '@/components/PropsTable';
import { Layout, Section, Article, Layer, Headline, BodyText } from '@newspaperui/components';
export default function LayerPage() {
return (
<div className="prose prose-lg max-w-none">
<h1>Layer </h1>
<p className="text-xl text-gray-600">
Layer 广
</p>
<h2></h2>
<p>
<code>Layer</code> 使
广
</p>
<h2>API </h2>
<PropsTable
data={[
{
name: 'position',
type: '"absolute" | "fixed" | "sticky"',
default: '"absolute"',
description: 'CSS position 属性',
},
{
name: 'top',
type: 'string | number',
description: '距离顶部的距离',
},
{
name: 'right',
type: 'string | number',
description: '距离右侧的距离',
},
{
name: 'bottom',
type: 'string | number',
description: '距离底部的距离',
},
{
name: 'left',
type: 'string | number',
description: '距离左侧的距离',
},
{
name: 'zIndex',
type: 'number',
default: '10',
description: 'z-index 层级',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: '浮动层内容',
},
]}
/>
<h2></h2>
<Demo
title="浮动拉引"
description="在文章旁边添加浮动的拉引文字"
code={`<Layout columns={24}>
<Section columns={24}>
<div style={{ position: 'relative', minHeight: '300px' }}>
<Article span={16}>
<Headline weight="High">主要文章</Headline>
<BodyText>
这是一篇主要文章的内容。旁边有一个浮动的拉引,
用于突出显示文章中的重要引用或观点。
</BodyText>
</Article>
<Layer position="absolute" top="20px" right="20px" zIndex={10}>
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 max-w-xs">
<p className="text-lg font-semibold text-gray-800">
"这是一段重要的引用文字"
</p>
</div>
</Layer>
</div>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<div style={{ position: 'relative', minHeight: '300px' }}>
<Article span={16}>
<Headline weight="High"></Headline>
<BodyText>
Layer 使
</BodyText>
</Article>
<Layer position="absolute" top="20px" right="20px" zIndex={10}>
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 max-w-xs">
<p className="text-lg font-semibold text-gray-800">
"这是一段重要的引用文字"
</p>
</div>
</Layer>
</div>
</Section>
</Layout>
</Demo>
<h2>广</h2>
<Demo
title="侧边浮动广告"
description="在页面侧边添加固定位置的广告"
code={`<Layout columns={24}>
<Section columns={24}>
<div style={{ position: 'relative', minHeight: '400px' }}>
<Article span={18}>
<Headline weight="High">文章内容</Headline>
<BodyText>
这是文章的主要内容区域。右侧有一个浮动的广告位,
使用 Layer 组件实现。
</BodyText>
</Article>
<Layer position="absolute" top="0" right="0" zIndex={5}>
<div className="bg-blue-100 border border-blue-300 p-6 w-48">
<p className="text-center font-bold text-blue-900">广告位</p>
<p className="text-sm text-blue-700 mt-2">300x250</p>
</div>
</Layer>
</div>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<div style={{ position: 'relative', minHeight: '400px' }}>
<Article span={18}>
<Headline weight="High"></Headline>
<BodyText>
广
使 Layer 广
</BodyText>
<BodyText>
Layer
</BodyText>
</Article>
<Layer position="absolute" top="0" right="0" zIndex={5}>
<div className="bg-blue-100 border border-blue-300 p-6 w-48 text-center">
<p className="font-bold text-blue-900">广</p>
<p className="text-sm text-blue-700 mt-2">300x250</p>
</div>
</Layer>
</div>
</Section>
</Layout>
</Demo>
<h2>使</h2>
<ul>
<li>Layer <code>position: relative</code></li>
<li>使 zIndex Layer </li>
<li></li>
<li> Layer</li>
<li>使 <code>position="fixed"</code> </li>
<li>使 <code>position="sticky"</code> </li>
</ul>
<h2></h2>
<ul>
<li>Layer Section Article </li>
<li> Layer </li>
<li> Layer </li>
<li> Layer 访</li>
<li> Layer </li>
</ul>
<h2></h2>
<ul>
<li> - </li>
<li>广 - 广</li>
<li> - </li>
<li> - 使 fixed </li>
<li> - 使 sticky </li>
</ul>
</div>
);
}
+271
View File
@@ -0,0 +1,271 @@
'use client';
import { Demo } from '@/components/Demo';
import { PropsTable } from '@/components/PropsTable';
import { Layout, Section, Article, Headline, BodyText } from '@newspaperui/components';
import { Image, Figure } from '@newspaperui/components';
export default function MediaPage() {
return (
<div className="prose prose-lg max-w-none">
<h1></h1>
<p className="text-xl text-gray-600">
NewspaperUI ImageFigure Video
</p>
<h2>Image </h2>
<p>
<code>Image</code>
</p>
<PropsTable
data={[
{
name: 'src',
type: 'string',
required: true,
description: '图片 URL',
},
{
name: 'alt',
type: 'string',
required: true,
description: '图片替代文本(无障碍)',
},
{
name: 'span',
type: 'number',
description: '图片占据的列数',
},
{
name: 'aspectRatio',
type: 'string',
default: '"16/9"',
description: '图片宽高比',
},
{
name: 'objectFit',
type: '"cover" | "contain" | "fill"',
default: '"cover"',
description: '图片填充方式',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
]}
/>
<Demo
title="基础图片"
description="在文章中插入图片"
code={`<Article span={12}>
<Headline weight="High">带图片的文章</Headline>
<Image
src="https://via.placeholder.com/800x600"
alt="示例图片"
span={12}
/>
<BodyText>图片下方的文字说明</BodyText>
</Article>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={12}>
<Headline weight="High"></Headline>
<Image
src="https://via.placeholder.com/800x600"
alt="示例图片"
span={12}
/>
<BodyText></BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2>Figure </h2>
<p>
<code>Figure</code>
</p>
<PropsTable
data={[
{
name: 'src',
type: 'string',
required: true,
description: '图片 URL',
},
{
name: 'alt',
type: 'string',
required: true,
description: '图片替代文本',
},
{
name: 'caption',
type: 'string',
required: true,
description: '图注文字',
},
{
name: 'span',
type: 'number',
description: 'Figure 占据的列数',
},
{
name: 'aspectRatio',
type: 'string',
default: '"16/9"',
description: '图片宽高比',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
]}
/>
<Demo
title="带图注的图片"
description="使用 Figure 组件添加图注"
code={`<Article span={12}>
<Headline weight="High">新闻标题</Headline>
<Figure
src="https://via.placeholder.com/800x600"
alt="新闻配图"
caption="这是图片的说明文字,描述图片内容"
span={12}
/>
<BodyText>新闻正文内容...</BodyText>
</Article>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={12}>
<Headline weight="High"></Headline>
<Figure
src="https://via.placeholder.com/800x600"
alt="新闻配图"
caption="这是图片的说明文字,描述图片内容"
span={12}
/>
<BodyText>...</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2>Video </h2>
<p>
<code>Video</code>
</p>
<PropsTable
data={[
{
name: 'src',
type: 'string',
required: true,
description: '视频 URL',
},
{
name: 'poster',
type: 'string',
description: '视频封面图片 URL',
},
{
name: 'span',
type: 'number',
description: '视频占据的列数',
},
{
name: 'controls',
type: 'boolean',
default: 'true',
description: '是否显示控制条',
},
{
name: 'autoPlay',
type: 'boolean',
default: 'false',
description: '是否自动播放',
},
{
name: 'loop',
type: 'boolean',
default: 'false',
description: '是否循环播放',
},
{
name: 'muted',
type: 'boolean',
default: 'false',
description: '是否静音',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
]}
/>
<Demo
title="嵌入视频"
description="在文章中嵌入视频内容"
code={`<Article span={16}>
<Headline weight="High">视频新闻</Headline>
<Video
src="https://example.com/video.mp4"
poster="https://via.placeholder.com/800x450"
span={16}
controls
/>
<BodyText>视频相关的文字说明...</BodyText>
</Article>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={16}>
<Headline weight="High"></Headline>
<div className="bg-gray-200 aspect-video flex items-center justify-center">
<p className="text-gray-600"></p>
</div>
<BodyText>...</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2>使</h2>
<ul>
<li>使 Image </li>
<li>使 Figure </li>
<li>使 Video </li>
<li> span </li>
<li> aspectRatio </li>
<li> alt </li>
<li></li>
</ul>
<h2></h2>
<p>
</p>
<h2></h2>
<ul>
<li>使WebPAVIF</li>
<li></li>
<li>使lazy loading</li>
<li></li>
<li>使 CDN </li>
</ul>
</div>
);
}
@@ -0,0 +1,251 @@
'use client';
import { Demo } from '@/components/Demo';
import { Layout, Section, Article } from '@newspaperui/components';
import { Headline, BodyText } from '@newspaperui/components';
export default function ResponsivePage() {
return (
<div className="prose prose-lg max-w-none">
<h1></h1>
<p className="text-xl text-gray-600">
NewspaperUI
</p>
<h2></h2>
<p>
NewspaperUI
</p>
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 my-6">
<h3 className="text-lg font-semibold text-blue-900 mt-0"></h3>
<ul className="mb-0 text-blue-800">
<li><strong>1024px</strong>: 24 </li>
<li><strong>768px-1023px</strong>: 16 </li>
<li><strong>&lt;768px</strong>: 12 </li>
</ul>
</div>
<h2></h2>
<p>
</p>
<Demo
title="响应式三栏布局"
description="大屏显示三栏,中屏显示两栏,小屏显示单栏"
code={`<Layout columns={24}>
<Section columns={24}>
{/* 大屏: 8+8+8, 中屏: 12+12, 小屏: 24 */}
<Article span={8} className="lg:span-8 md:span-12 sm:span-24">
<Headline weight="Medium">第一栏</Headline>
<BodyText>内容自动适应屏幕宽度</BodyText>
</Article>
<Article span={8} className="lg:span-8 md:span-12 sm:span-24">
<Headline weight="Medium">第二栏</Headline>
<BodyText>响应式布局</BodyText>
</Article>
<Article span={8} className="lg:span-8 md:span-12 sm:span-24">
<Headline weight="Medium">第三栏</Headline>
<BodyText>自动换行</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText>
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText>
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText>
NewspaperUI
</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
</p>
<Demo
title="主次内容自适应"
description="大屏 16+8,中屏 12+12,小屏全宽堆叠"
code={`<Layout columns={24}>
<Section columns={24}>
<Article span={16}>
<Headline weight="High">主要内容</Headline>
<BodyText weight="High">
主要内容在大屏占据 16 列,在中屏占据 12 列,
在小屏占据全宽。
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Low">侧边栏</Headline>
<BodyText weight="Low">
侧边栏在大屏占据 8 列,在中屏占据 12 列,
在小屏移到主内容下方。
</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={16}>
<Headline weight="High"></Headline>
<BodyText weight="High">
</BodyText>
<BodyText>
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Low"></Headline>
<BodyText weight="Low">
1<br />
2<br />
3<br />
...
</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
NewspaperUI
</p>
<Demo
title="移动优先布局"
description="小屏优化的单栏布局"
>
<Layout columns={24}>
<Section columns={24}>
<Article span={24}>
<Headline weight="High"></Headline>
<BodyText>
</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
</p>
<Demo
title="响应式媒体"
description="图片自动适应容器宽度"
>
<Layout columns={24}>
<Section columns={24}>
<Article span={16}>
<Headline weight="High"></Headline>
<div className="bg-gray-200 aspect-video flex items-center justify-center my-4">
<span className="text-gray-600"></span>
</div>
<BodyText>
</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<ul>
<li>使remempx</li>
<li></li>
<li> 16px</li>
<li> 44x44px</li>
<li></li>
<li></li>
<li></li>
</ul>
<h2></h2>
<p>
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 my-6">
<div className="border border-gray-200 rounded-lg p-4">
<h3 className="text-lg font-semibold mb-2"></h3>
<ul className="text-sm space-y-1 mb-0">
<li>iPhone SE (375px)</li>
<li>iPhone 12/13 (390px)</li>
<li>iPhone 14 Pro Max (430px)</li>
<li>Android (360px)</li>
</ul>
</div>
<div className="border border-gray-200 rounded-lg p-4">
<h3 className="text-lg font-semibold mb-2"></h3>
<ul className="text-sm space-y-1 mb-0">
<li>iPad Mini (768px)</li>
<li>iPad (810px)</li>
<li>iPad Pro (1024px)</li>
<li>Android (800px)</li>
</ul>
</div>
<div className="border border-gray-200 rounded-lg p-4">
<h3 className="text-lg font-semibold mb-2"></h3>
<ul className="text-sm space-y-1 mb-0">
<li> (1366px)</li>
<li> (1920px)</li>
<li> (2560px)</li>
<li> (3440px)</li>
</ul>
</div>
</div>
<h2></h2>
<p>
使
</p>
<ul>
<li><strong>Chrome DevTools</strong>: Cmd/Ctrl + Shift + M</li>
<li><strong>Firefox</strong>: Cmd/Ctrl + Shift + M</li>
<li><strong>Safari</strong>: Develop Enter Responsive Design Mode</li>
</ul>
<h2></h2>
<ul>
<li>使lazy loading</li>
<li></li>
<li>使WebPAVIF</li>
<li></li>
<li>使 CDN </li>
<li> JavaScript CSS</li>
</ul>
</div>
);
}
@@ -0,0 +1,267 @@
'use client';
import { Demo } from '@/components/Demo';
import { Layout, Section, Article, Layer } from '@newspaperui/components';
import { Headline, Subhead, BodyText, Quote } from '@newspaperui/components';
export default function SpanningPage() {
return (
<div className="prose prose-lg max-w-none">
<h1></h1>
<p className="text-xl text-gray-600">
使 NewspaperUI
</p>
<h2></h2>
<p>
</p>
<Demo
title="报纸头版"
description="主新闻占据 16 列,侧边栏占据 8 列,带浮动拉引"
code={`<Layout columns={24}>
<Section columns={24}>
<div style={{ position: 'relative', minHeight: '600px' }}>
{/* 主新闻 - 16 列 */}
<Article span={16}>
<Headline weight="High">重大新闻标题占据主要版面</Headline>
<Subhead weight="High">
副标题提供更多背景信息和上下文
</Subhead>
<BodyText weight="High">
这是主要新闻的正文内容。在报纸布局中,最重要的新闻通常占据最大的版面,
使用最大的字号和最粗的字重。这样的布局能够立即吸引读者的注意力。
</BodyText>
<BodyText>
新闻的后续段落继续详细描述事件的发展。通过合理的跨栏布局,
我们可以在有限的版面中呈现丰富的内容。
</BodyText>
</Article>
{/* 侧边栏 - 8 列 */}
<Article span={8}>
<Headline weight="Medium">次要新闻</Headline>
<BodyText weight="Medium">
侧边栏的新闻使用较小的字号,适合放置次要但仍然重要的内容。
</BodyText>
</Article>
{/* 浮动拉引 */}
<Layer position="absolute" top="100px" right="20px" zIndex={10}>
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 max-w-xs">
<Quote weight="High">
"这是一段重要的引用,用于突出文章中的关键观点"
</Quote>
</div>
</Layer>
</div>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<div style={{ position: 'relative', minHeight: '600px' }}>
<Article span={16}>
<Headline weight="High"></Headline>
<Subhead weight="High">
</Subhead>
<BodyText weight="High">
使
</BodyText>
<BodyText>
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText weight="Medium">
使
</BodyText>
</Article>
<Layer position="absolute" top="100px" right="20px" zIndex={10}>
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 max-w-xs">
<Quote weight="High">
"这是一段重要的引用,用于突出文章中的关键观点"
</Quote>
</div>
</Layer>
</div>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
24 8
</p>
<Demo
title="三栏布局"
description="三个 8 列的文章并排显示"
code={`<Layout columns={24}>
<Section columns={24}>
<Article span={8}>
<Headline weight="Medium">第一栏标题</Headline>
<BodyText>第一栏的内容...</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium">第二栏标题</Headline>
<BodyText>第二栏的内容...</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium">第三栏标题</Headline>
<BodyText>第三栏的内容...</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText>
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText>
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText>
</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
使
</p>
<Demo
title="12 + 12 列布局"
description="两个等宽的 12 列区域"
code={`<Layout columns={24}>
<Section columns={24}>
<Article span={12}>
<Headline weight="High">左侧主要内容</Headline>
<BodyText weight="High">
占据 12 列的主要内容区域...
</BodyText>
</Article>
<Article span={12}>
<Headline weight="High">右侧主要内容</Headline>
<BodyText weight="High">
同样占据 12 列的内容区域...
</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={12}>
<Headline weight="High"></Headline>
<BodyText weight="High">
12
</BodyText>
</Article>
<Article span={12}>
<Headline weight="High"></Headline>
<BodyText weight="High">
</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
</p>
<Demo
title="混合列宽布局"
description="6 + 12 + 6 列的组合布局"
code={`<Layout columns={24}>
<Section columns={24}>
<Article span={6}>
<Headline weight="Low">侧边栏</Headline>
<BodyText weight="Low">简短内容</BodyText>
</Article>
<Article span={12}>
<Headline weight="High">主要内容</Headline>
<BodyText>详细的主要内容...</BodyText>
</Article>
<Article span={6}>
<Headline weight="Low">另一侧边栏</Headline>
<BodyText weight="Low">补充信息</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={6}>
<Headline weight="Low"></Headline>
<BodyText weight="Low">
1<br />
2<br />
3
</BodyText>
</Article>
<Article span={12}>
<Headline weight="High"></Headline>
<BodyText>
12
6
</BodyText>
</Article>
<Article span={6}>
<Headline weight="Low"></Headline>
<BodyText weight="Low">
1<br />
2<br />
3
</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<ul>
<li>使 span 12-16 </li>
<li>使 span 4-8 </li>
<li></li>
<li>使 Layer </li>
<li></li>
<li></li>
</ul>
<h2></h2>
<ul>
<li> Article span Section columns</li>
<li></li>
<li></li>
<li>使 8:166:12:6 </li>
<li></li>
</ul>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
@import '@newspaperui/theme/variables.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-white text-gray-900;
}
h1 {
@apply text-4xl font-bold mb-4;
}
h2 {
@apply text-3xl font-semibold mt-8 mb-4;
}
h3 {
@apply text-2xl font-semibold mt-6 mb-3;
}
p {
@apply mb-4 leading-relaxed;
}
code {
@apply text-sm bg-gray-100 px-1.5 py-0.5 rounded;
}
pre code {
@apply bg-transparent p-0;
}
}
+209
View File
@@ -0,0 +1,209 @@
'use client';
import { Demo } from '@/components/Demo';
import { PropsTable } from '@/components/PropsTable';
import { Layout, Section, Article, Headline, BodyText } from '@newspaperui/components';
export default function GridSystemPage() {
return (
<div className="prose prose-lg max-w-none">
<h1></h1>
<p className="text-xl text-gray-600">
NewspaperUI 24
</p>
<h2></h2>
<p>
24 12 24
使 1/32/31/43/4
</p>
<h2>Layout </h2>
<p>
<code>Layout</code>
</p>
<PropsTable
data={[
{
name: 'columns',
type: 'number',
default: '24',
description: '栅格总列数,默认 24 列',
},
{
name: 'maxWidth',
type: 'string',
default: '"1440px"',
description: '容器最大宽度',
},
{
name: 'gutter',
type: 'number',
default: '16',
description: '列间距(单位 px',
},
{
name: 'padding',
type: 'string',
default: '"1rem"',
description: '容器内边距',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
]}
/>
<Demo
title="基础 Layout"
description="创建一个 24 列的栅格容器"
code={`<Layout columns={24}>
{/* 内容 */}
</Layout>`}
>
<Layout columns={24}>
<div className="bg-blue-50 border-2 border-blue-200 p-4 text-center">
24
</div>
</Layout>
</Demo>
<h2>Section </h2>
<p>
<code>Section</code> Layout Section
Section span Section columns
</p>
<PropsTable
data={[
{
name: 'columns',
type: 'number',
required: true,
description: 'Section 占据的列数,必须 ≤ Layout.columns',
},
{
name: 'breakable',
type: 'boolean',
default: 'true',
description: '是否允许在此处分页断开',
},
{
name: 'priority',
type: "'High' | 'Medium' | 'Low'",
default: "'Medium'",
description: '优先级,用于排序',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
]}
/>
<Demo
title="多 Section 布局"
description="将 24 列划分为 8 列和 16 列两个区域"
code={`<Layout columns={24}>
<Section columns={8}>
<Article span={8}>
<Headline weight="High">8 列区域</Headline>
<BodyText>这个区域占据 8 列</BodyText>
</Article>
</Section>
<Section columns={16}>
<Article span={8}>
<Headline weight="Medium">16 列区域 - 左</Headline>
<BodyText>这个区域占据 16 列,分为两个 8 列文章</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium">16 列区域 - 右</Headline>
<BodyText>第二个 8 列文章</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={8}>
<Article span={8}>
<Headline weight="High">8 </Headline>
<BodyText> 8 </BodyText>
</Article>
</Section>
<Section columns={16}>
<Article span={8}>
<Headline weight="Medium">16 - </Headline>
<BodyText> 16 8 </BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium">16 - </Headline>
<BodyText> 8 </BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
12-16
CSS
</p>
<Demo
title="响应式栅格示例"
description="在不同屏幕尺寸下自动调整列数"
>
<Layout columns={24}>
<Section columns={6}>
<Article span={6}>
<Headline weight="Low">6 </Headline>
<BodyText></BodyText>
</Article>
</Section>
<Section columns={6}>
<Article span={6}>
<Headline weight="Low">6 </Headline>
<BodyText></BodyText>
</Article>
</Section>
<Section columns={6}>
<Article span={6}>
<Headline weight="Low">6 </Headline>
<BodyText></BodyText>
</Article>
</Section>
<Section columns={6}>
<Article span={6}>
<Headline weight="Low">6 </Headline>
<BodyText></BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<ul>
<li>Layout columns 24</li>
<li>Section columns Layout columns</li>
<li>Section span Section columns</li>
<li> span </li>
<li> 12-16 </li>
</ul>
<h2></h2>
<ul>
<li>使 24 1/21/31/41/61/8 </li>
<li>使 8-16 </li>
<li>使 4-8 </li>
<li></li>
<li> Section 8:166:1812:12 </li>
</ul>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from 'next';
import { Sidebar } from '@/components/Sidebar';
import './globals.css';
export const metadata: Metadata = {
title: 'NewspaperUI - Documentation',
description: 'A newspaper-style UI component library',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body className="antialiased">
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 overflow-x-hidden">
<div className="max-w-5xl mx-auto px-8 py-12">
{children}
</div>
</main>
</div>
</body>
</html>
);
}
+102
View File
@@ -0,0 +1,102 @@
'use client';
import { Demo } from '@/components/Demo';
import { Layout, Section, Article } from '@newspaperui/components';
import { Headline, BodyText } from '@newspaperui/components';
export default function HomePage() {
return (
<div className="prose prose-lg max-w-none">
<h1>NewspaperUI</h1>
<p className="text-xl text-gray-600">
InDesign 24
</p>
<h2></h2>
<p>
NewspaperUI InDesign Web
</p>
<h2></h2>
<ul>
<li><strong>24 </strong> - </li>
<li><strong></strong> - </li>
<li><strong></strong> - High/Medium/Low </li>
<li><strong></strong> - CSS Variables</li>
<li><strong></strong> - 12-16 </li>
<li><strong> Layer</strong> - 广</li>
</ul>
<h2></h2>
<h3></h3>
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<code>pnpm add @newspaperui/components @newspaperui/theme</code>
</pre>
<h3>使</h3>
<Demo
title="简单的报纸布局"
description="使用 Layout 和 Section 创建基础的栅格布局"
code={`import { Layout, Section, Article, Headline, BodyText } from '@newspaperui/components';
function MyPage() {
return (
<Layout columns={24}>
<Section columns={8}>
<Article span={8}>
<Headline weight="High">主要新闻标题</Headline>
<BodyText>这是一篇重要的新闻内容...</BodyText>
</Article>
</Section>
<Section columns={16}>
<Article span={8}>
<Headline weight="Medium">次要新闻</Headline>
<BodyText>这是另一篇新闻...</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium">更多新闻</Headline>
<BodyText>更多内容...</BodyText>
</Article>
</Section>
</Layout>
);
}`}
>
<Layout columns={24}>
<Section columns={8}>
<Article span={8}>
<Headline weight="High"></Headline>
<BodyText>使 High </BodyText>
</Article>
</Section>
<Section columns={16}>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText>使 Medium </BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText></BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
</p>
<ul>
<li><a href="/grid-system"></a> - 24 </li>
<li><a href="/components/article"></a> - ArticleLayer </li>
<li><a href="/text"></a> - </li>
<li><a href="/theme"></a> - </li>
<li><a href="/examples/spanning"></a> - </li>
</ul>
</div>
);
}
+334
View File
@@ -0,0 +1,334 @@
'use client';
import { Demo } from '@/components/Demo';
import { PropsTable } from '@/components/PropsTable';
import { Headline, Subhead, BodyText, Quote, Byline, Caption } from '@newspaperui/components';
export default function TextPage() {
return (
<div className="prose prose-lg max-w-none">
<h1></h1>
<p className="text-xl text-gray-600">
NewspaperUI
</p>
<h2></h2>
<p>
Visual Weight NewspaperUI
HighMediumLow
</p>
<h2>Headline </h2>
<p>
<code>Headline</code>
</p>
<PropsTable
data={[
{
name: 'weight',
type: '"High" | "Medium" | "Low"',
default: '"Medium"',
description: '视觉权重级别',
},
{
name: 'as',
type: '"h1" | "h2" | "h3" | "h4" | "h5" | "h6"',
default: '"h2"',
description: 'HTML 标签类型',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: '标题内容',
},
]}
/>
<Demo
title="标题视觉权重"
description="展示不同权重的标题效果"
code={`<Headline weight="High">High 权重标题</Headline>
<Headline weight="Medium">Medium 权重标题</Headline>
<Headline weight="Low">Low 权重标题</Headline>`}
>
<div className="space-y-4">
<Headline weight="High">High (36-48px, 700)</Headline>
<Headline weight="Medium">Medium (28-34px, 600)</Headline>
<Headline weight="Low">Low (22-26px, 500)</Headline>
</div>
</Demo>
<h2>Subhead </h2>
<p>
<code>Subhead</code>
</p>
<PropsTable
data={[
{
name: 'weight',
type: '"High" | "Medium"',
default: '"Medium"',
description: '视觉权重级别',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: '副标题内容',
},
]}
/>
<Demo
title="副标题示例"
description="标题和副标题的组合使用"
code={`<Headline weight="High">主标题</Headline>
<Subhead weight="High">副标题说明文字</Subhead>
<BodyText>正文内容...</BodyText>`}
>
<div className="space-y-2">
<Headline weight="High"></Headline>
<Subhead weight="High"></Subhead>
<BodyText>...</BodyText>
</div>
</Demo>
<h2>BodyText </h2>
<p>
<code>BodyText</code>
</p>
<PropsTable
data={[
{
name: 'weight',
type: '"High" | "Medium" | "Low"',
default: '"Medium"',
description: '视觉权重级别',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: '正文内容',
},
]}
/>
<Demo
title="正文文本权重"
description="不同权重的正文文本"
code={`<BodyText weight="High">High 权重正文 (16px)</BodyText>
<BodyText weight="Medium">Medium 权重正文 (14-15px)</BodyText>
<BodyText weight="Low">Low 权重正文 (12-14px)</BodyText>`}
>
<div className="space-y-3">
<BodyText weight="High">
High 16px
</BodyText>
<BodyText weight="Medium">
Medium 14-15px
</BodyText>
<BodyText weight="Low">
Low 12-14px
</BodyText>
</div>
</Demo>
<h2>Quote </h2>
<p>
<code>Quote</code>
</p>
<PropsTable
data={[
{
name: 'weight',
type: '"High" | "Medium"',
default: '"Medium"',
description: '视觉权重级别',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: '引用内容',
},
]}
/>
<Demo
title="引用文本"
description="在文章中插入引用"
code={`<Quote weight="High">
这是一段重要的引用文字,来自某位专家的观点。
</Quote>`}
>
<Quote weight="High">
</Quote>
</Demo>
<h2>Byline Caption </h2>
<p>
<code>Byline</code> <code>Caption</code>
</p>
<PropsTable
data={[
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: '文本内容',
},
]}
/>
<Demo
title="署名和图注"
description="使用 Byline 和 Caption"
code={`<Headline weight="High">文章标题</Headline>
<Byline>作者:张三 | 2024年1月1日</Byline>
<BodyText>文章内容...</BodyText>
<Caption>图1:示例图片的说明文字</Caption>`}
>
<div className="space-y-2">
<Headline weight="High"></Headline>
<Byline> | 202411 | </Byline>
<BodyText>...</BodyText>
<div className="bg-gray-200 h-32 flex items-center justify-center">
<span className="text-gray-500"></span>
</div>
<Caption>1</Caption>
</div>
</Demo>
<h2></h2>
<p>
24
</p>
<div className="my-6 overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b-2 border-gray-300">
<th className="text-left py-2 px-3"></th>
<th className="text-left py-2 px-3"></th>
<th className="text-left py-2 px-3"></th>
<th className="text-left py-2 px-3"></th>
<th className="text-left py-2 px-3"></th>
<th className="text-left py-2 px-3"></th>
</tr>
</thead>
<tbody className="text-sm">
<tr className="border-b border-gray-200">
<td className="py-2 px-3">Headline</td>
<td className="py-2 px-3">High</td>
<td className="py-2 px-3">36-48px</td>
<td className="py-2 px-3">700</td>
<td className="py-2 px-3">1.1</td>
<td className="py-2 px-3">#111</td>
</tr>
<tr className="border-b border-gray-200">
<td className="py-2 px-3">Headline</td>
<td className="py-2 px-3">Medium</td>
<td className="py-2 px-3">28-34px</td>
<td className="py-2 px-3">600</td>
<td className="py-2 px-3">1.2</td>
<td className="py-2 px-3">#111</td>
</tr>
<tr className="border-b border-gray-200">
<td className="py-2 px-3">Headline</td>
<td className="py-2 px-3">Low</td>
<td className="py-2 px-3">22-26px</td>
<td className="py-2 px-3">500</td>
<td className="py-2 px-3">1.3</td>
<td className="py-2 px-3">#222</td>
</tr>
<tr className="border-b border-gray-200">
<td className="py-2 px-3">Subhead</td>
<td className="py-2 px-3">High</td>
<td className="py-2 px-3">20-24px</td>
<td className="py-2 px-3">600</td>
<td className="py-2 px-3">1.25</td>
<td className="py-2 px-3">#222</td>
</tr>
<tr className="border-b border-gray-200">
<td className="py-2 px-3">Subhead</td>
<td className="py-2 px-3">Medium</td>
<td className="py-2 px-3">16-18px</td>
<td className="py-2 px-3">500</td>
<td className="py-2 px-3">1.3</td>
<td className="py-2 px-3">#333</td>
</tr>
<tr className="border-b border-gray-200">
<td className="py-2 px-3">BodyText</td>
<td className="py-2 px-3">High</td>
<td className="py-2 px-3">16px</td>
<td className="py-2 px-3">400</td>
<td className="py-2 px-3">1.5</td>
<td className="py-2 px-3">#333</td>
</tr>
<tr className="border-b border-gray-200">
<td className="py-2 px-3">BodyText</td>
<td className="py-2 px-3">Medium</td>
<td className="py-2 px-3">14-15px</td>
<td className="py-2 px-3">400</td>
<td className="py-2 px-3">1.5</td>
<td className="py-2 px-3">#444</td>
</tr>
<tr className="border-b border-gray-200">
<td className="py-2 px-3">BodyText</td>
<td className="py-2 px-3">Low</td>
<td className="py-2 px-3">12-14px</td>
<td className="py-2 px-3">400</td>
<td className="py-2 px-3">1.4</td>
<td className="py-2 px-3">#555</td>
</tr>
</tbody>
</table>
</div>
<h2>使</h2>
<ul>
<li>使 Headline High </li>
<li>使 Headline Medium Low </li>
<li>使 BodyText Medium </li>
<li>使 BodyText High </li>
<li>使 BodyText Low </li>
<li>使 Quote </li>
<li>使 Byline </li>
<li>使 Caption </li>
</ul>
</div>
);
}
+310
View File
@@ -0,0 +1,310 @@
import { CodeBlock } from '@/components/CodeBlock';
export default function ThemePage() {
return (
<div className="prose prose-lg max-w-none">
<h1></h1>
<p className="text-xl text-gray-600">
NewspaperUI CSS Variables
</p>
<h2>CSS Variables</h2>
<p>
使 CSS CSS Variables
使
</p>
<h3></h3>
<CodeBlock
title="CSS Variables"
language="css"
code={`:root {
/* 颜色系统 */
--color-text-primary: #111;
--color-text-secondary: #222;
--color-text-tertiary: #333;
--color-text-muted: #555;
--color-background: #ffffff;
--color-border: #e5e7eb;
/* 字体系统 */
--font-family-serif: Georgia, 'Times New Roman', serif;
--font-family-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-family-mono: 'Courier New', monospace;
/* 间距系统 */
--spacing-gutter: 1rem;
--spacing-section: 2rem;
--spacing-article: 1.5rem;
/* 栅格系统 */
--grid-columns: 24;
--grid-max-width: 1280px;
}`}
/>
<h2>Tailwind </h2>
<p>
NewspaperUI Tailwind CSS Tailwind 使
</p>
<CodeBlock
title="tailwind.config.js"
language="javascript"
code={`import { newspaperTheme } from '@newspaperui/theme';
export default {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
newspaper: {
text: {
primary: 'var(--color-text-primary)',
secondary: 'var(--color-text-secondary)',
tertiary: 'var(--color-text-tertiary)',
muted: 'var(--color-text-muted)',
},
},
},
fontFamily: {
serif: 'var(--font-family-serif)',
sans: 'var(--font-family-sans)',
mono: 'var(--font-family-mono)',
},
},
},
plugins: [newspaperTheme],
};`}
/>
<h2>使</h2>
<p>
使 CSS Variables Tailwind
</p>
<CodeBlock
title="使用示例"
language="tsx"
code={`// 使用 CSS Variables
<div style={{ color: 'var(--color-text-primary)' }}>
主要文本
</div>
// 使用 Tailwind 工具类
<div className="text-newspaper-text-primary">
主要文本
</div>`}
/>
<h2></h2>
<p>
CSS Variables
</p>
<CodeBlock
title="自定义主题"
language="css"
code={`:root {
/* 覆盖默认颜色 */
--color-text-primary: #000000;
--color-text-secondary: #1a1a1a;
--color-background: #f9f9f9;
/* 自定义字体 */
--font-family-serif: 'Merriweather', Georgia, serif;
/* 调整间距 */
--spacing-gutter: 1.5rem;
--grid-max-width: 1440px;
}`}
/>
<h2></h2>
<p>
CSS Variables
</p>
<CodeBlock
title="深色模式"
language="css"
code={`@media (prefers-color-scheme: dark) {
:root {
--color-text-primary: #f5f5f5;
--color-text-secondary: #e0e0e0;
--color-text-tertiary: #cccccc;
--color-text-muted: #999999;
--color-background: #1a1a1a;
--color-border: #333333;
}
}
/* 或使用类名切换 */
.dark {
--color-text-primary: #f5f5f5;
--color-text-secondary: #e0e0e0;
--color-background: #1a1a1a;
}`}
/>
<h2></h2>
<p>
</p>
<CodeBlock
title="自定义视觉权重"
language="typescript"
code={`import { visualWeights } from '@newspaperui/theme';
// 查看默认配置
console.log(visualWeights.Headline.High);
// {
// fontSize: '36px',
// fontWeight: 700,
// lineHeight: 1.1,
// color: '#111',
// span: [6, 8],
// margin: '0 0 1rem 0',
// }
// 自定义权重配置
const customWeights = {
...visualWeights,
Headline: {
...visualWeights.Headline,
High: {
...visualWeights.Headline.High,
fontSize: '48px', // 更大的标题
fontWeight: 800,
},
},
};`}
/>
<h2></h2>
<p>
</p>
<CodeBlock
title="响应式变量"
language="css"
code={`:root {
--grid-columns: 24;
--spacing-gutter: 1rem;
}
@media (max-width: 768px) {
:root {
--grid-columns: 12;
--spacing-gutter: 0.75rem;
}
}
@media (max-width: 480px) {
:root {
--grid-columns: 6;
--spacing-gutter: 0.5rem;
}
}`}
/>
<h2></h2>
<ul>
<li>使 <code>--color-text-primary</code> <code>--color-black</code></li>
<li> WCAG </li>
<li>使remempx</li>
<li></li>
<li></li>
<li></li>
</ul>
<h2></h2>
<p>
NewspaperUI
</p>
<CodeBlock
title="导入主题"
language="typescript"
code={`// 在你的主 CSS 文件中
import '@newspaperui/theme/variables.css';
// 或在 Next.js 的 _app.tsx 中
import '@newspaperui/theme/variables.css';
import '@newspaperui/theme/tailwind.css';`}
/>
<h2></h2>
<p>
NewspaperUI 使
</p>
<CodeBlock
title="主题工具"
language="typescript"
code={`import { getVisualWeight, applyTheme } from '@newspaperui/theme';
// 获取特定组件的视觉权重配置
const headlineHighConfig = getVisualWeight('Headline', 'High');
// 应用主题到元素
applyTheme(element, {
'--color-text-primary': '#000',
'--font-family-serif': 'Georgia',
});`}
/>
<h2></h2>
<p>
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 my-8">
<div className="border border-gray-200 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-3"></h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span></span>
<span className="font-mono">#111111</span>
</div>
<div className="flex justify-between">
<span></span>
<span className="font-mono">#ffffff</span>
</div>
<div className="flex justify-between">
<span></span>
<span className="font-mono">Georgia</span>
</div>
</div>
</div>
<div className="border border-gray-200 rounded-lg p-6 bg-gray-900 text-white">
<h3 className="text-lg font-semibold mb-3"></h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span></span>
<span className="font-mono">#f5f5f5</span>
</div>
<div className="flex justify-between">
<span></span>
<span className="font-mono">#1a1a1a</span>
</div>
<div className="flex justify-between">
<span></span>
<span className="font-mono">Georgia</span>
</div>
</div>
</div>
</div>
<h2></h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties">MDN: CSS Custom Properties</a></li>
<li><a href="https://tailwindcss.com/docs/customizing-colors">Tailwind CSS: Customizing Colors</a></li>
<li><a href="https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html">WCAG: Contrast Guidelines</a></li>
</ul>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
'use client';
import { useState } from 'react';
interface CodeBlockProps {
code: string;
language?: string;
title?: string;
}
export function CodeBlock({ code, language = 'tsx', title }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="my-4 rounded-lg overflow-hidden border border-gray-200">
{title && (
<div className="bg-gray-50 px-4 py-2 border-b border-gray-200 flex justify-between items-center">
<span className="text-sm font-medium text-gray-700">{title}</span>
<span className="text-xs text-gray-500">{language}</span>
</div>
)}
<div className="relative">
<button
onClick={handleCopy}
className="absolute top-2 right-2 px-3 py-1 text-xs bg-gray-700 hover:bg-gray-600 text-white rounded"
>
{copied ? '已复制' : '复制'}
</button>
<pre className="p-4 bg-gray-900 text-gray-100 overflow-x-auto">
<code>{code}</code>
</pre>
</div>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
'use client';
import { useState } from 'react';
interface DemoProps {
title: string;
description?: string;
code?: string;
children: React.ReactNode;
}
export function Demo({ title, description, code, children }: DemoProps) {
const [showCode, setShowCode] = useState(false);
return (
<div className="my-8 border border-gray-200 rounded-lg overflow-hidden">
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
{description && (
<p className="mt-1 text-sm text-gray-600">{description}</p>
)}
</div>
<div className="p-6 bg-white">
{children}
</div>
{code && (
<>
<div className="border-t border-gray-200 px-4 py-2 bg-gray-50">
<button
onClick={() => setShowCode(!showCode)}
className="text-sm font-medium text-gray-700 hover:text-gray-900"
>
{showCode ? '隐藏代码' : '查看代码'}
</button>
</div>
{showCode && (
<div className="border-t border-gray-200">
<pre className="p-4 bg-gray-900 text-gray-100 overflow-x-auto text-sm">
<code>{code}</code>
</pre>
</div>
)}
</>
)}
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
interface PropDefinition {
name: string;
type: string;
default?: string;
description: string;
required?: boolean;
}
interface PropsTableProps {
data: PropDefinition[];
}
export function PropsTable({ data }: PropsTableProps) {
return (
<div className="my-6 overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b-2 border-gray-300">
<th className="text-left py-3 px-4 font-semibold text-gray-900"></th>
<th className="text-left py-3 px-4 font-semibold text-gray-900"></th>
<th className="text-left py-3 px-4 font-semibold text-gray-900"></th>
<th className="text-left py-3 px-4 font-semibold text-gray-900"></th>
</tr>
</thead>
<tbody>
{data.map((prop) => (
<tr key={prop.name} className="border-b border-gray-200">
<td className="py-3 px-4">
<code className="text-sm bg-gray-100 px-2 py-1 rounded">
{prop.name}
{prop.required && <span className="text-red-500 ml-1">*</span>}
</code>
</td>
<td className="py-3 px-4">
<code className="text-sm text-blue-600">{prop.type}</code>
</td>
<td className="py-3 px-4">
{prop.default ? (
<code className="text-sm bg-gray-100 px-2 py-1 rounded">{prop.default}</code>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="py-3 px-4 text-sm text-gray-700">{prop.description}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
interface NavItem {
title: string;
href: string;
items?: NavItem[];
}
const navigation: NavItem[] = [
{
title: '概览',
href: '/',
},
{
title: '栅格系统',
href: '/grid-system',
},
{
title: '核心组件',
href: '/components',
items: [
{ title: 'Article', href: '/components/article' },
{ title: 'Layer', href: '/components/layer' },
{ title: '媒体组件', href: '/components/media' },
],
},
{
title: '文本组件',
href: '/text',
},
{
title: '主题系统',
href: '/theme',
},
{
title: '示例',
href: '/examples',
items: [
{ title: '跨栏布局', href: '/examples/spanning' },
{ title: '响应式布局', href: '/examples/responsive' },
],
},
];
export function Sidebar() {
const pathname = usePathname();
return (
<nav className="w-64 border-r border-gray-200 bg-white h-screen sticky top-0 overflow-y-auto">
<div className="p-6">
<Link href="/" className="text-xl font-bold text-gray-900">
NewspaperUI
</Link>
<p className="mt-1 text-sm text-gray-600"></p>
</div>
<div className="px-4 pb-6">
{navigation.map((item) => (
<div key={item.href} className="mb-4">
<Link
href={item.href}
className={`block px-3 py-2 rounded-md text-sm font-medium ${
pathname === item.href
? 'bg-gray-100 text-gray-900'
: 'text-gray-700 hover:bg-gray-50'
}`}
>
{item.title}
</Link>
{item.items && (
<div className="ml-4 mt-2 space-y-1">
{item.items.map((subItem) => (
<Link
key={subItem.href}
href={subItem.href}
className={`block px-3 py-2 rounded-md text-sm ${
pathname === subItem.href
? 'bg-gray-100 text-gray-900'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
{subItem.title}
</Link>
))}
</div>
)}
</div>
))}
</div>
</nav>
);
}
+7
View File
@@ -0,0 +1,7 @@
import type { MDXComponents } from 'mdx/types';
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
};
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+25
View File
@@ -0,0 +1,25 @@
import createMDX from '@next/mdx';
import rehypePrettyCode from 'rehype-pretty-code';
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
transpilePackages: ['@newspaperui/components', '@newspaperui/theme', '@newspaperui/utils'],
};
const withMDX = createMDX({
extension: /\.mdx?$/,
options: {
rehypePlugins: [
[
rehypePrettyCode,
{
theme: 'github-dark',
keepBackground: false,
},
],
],
},
});
export default withMDX(nextConfig);
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@newspaperui/docs",
"version": "0.0.0",
"description": "Documentation site for newspaperui",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"clean": "rm -rf .next"
},
"dependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@newspaperui/components": "workspace:*",
"@newspaperui/theme": "workspace:*",
"@newspaperui/utils": "workspace:*",
"@next/mdx": "^16.2.6",
"@types/mdx": "^2.0.13",
"next": "^15.1.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"rehype-pretty-code": "^0.14.3",
"shiki": "^4.1.0"
},
"devDependencies": {
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+15
View File
@@ -0,0 +1,15 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
};
export default config;
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"noEmit": true,
"incremental": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"paths": {
"@/*": ["./*"]
},
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@newspaperui/theme",
"version": "0.0.0",
"description": "Theme tokens and CSS variables for newspaperui",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./variables.css": "./src/variables.css",
"./tailwind.config": "./src/tailwind.config.js"
},
"files": [
"dist",
"src/variables.css",
"src/tailwind.config.js"
],
"scripts": {
"build": "vite build",
"dev": "vite build --watch",
"lint": "tsc --noEmit",
"clean": "rm -rf dist"
},
"devDependencies": {
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vite-plugin-dts": "^4.3.0"
}
}
+21
View File
@@ -0,0 +1,21 @@
/**
* @newspaperui/theme
* Theme tokens and CSS variables for newspaperui
*/
// Import CSS variables
import './variables.css';
// Export visual weight types and configuration
export type {
VisualWeight,
ComponentType,
VisualWeightConfig,
} from './visual-weights';
export { visualWeights, resolveFontSize } from './visual-weights';
// Export Tailwind config for consumers
export { default as tailwindConfig } from './tailwind.config.js';
export const version = '0.0.0';
+103
View File
@@ -0,0 +1,103 @@
/**
* Tailwind CSS Configuration for NewspaperUI
* Extends Tailwind with newspaper-specific utilities and 24-column grid
*/
const config = {
theme: {
extend: {
colors: {
nui: {
text: {
primary: 'var(--nui-color-text-primary)',
secondary: 'var(--nui-color-text-secondary)',
tertiary: 'var(--nui-color-text-tertiary)',
quaternary: 'var(--nui-color-text-quaternary)',
muted: 'var(--nui-color-text-muted)',
},
gray: {
50: 'var(--nui-color-gray-50)',
100: 'var(--nui-color-gray-100)',
200: 'var(--nui-color-gray-200)',
300: 'var(--nui-color-gray-300)',
400: 'var(--nui-color-gray-400)',
500: 'var(--nui-color-gray-500)',
600: 'var(--nui-color-gray-600)',
700: 'var(--nui-color-gray-700)',
800: 'var(--nui-color-gray-800)',
900: 'var(--nui-color-gray-900)',
},
accent: {
primary: 'var(--nui-color-accent-primary)',
secondary: 'var(--nui-color-accent-secondary)',
tertiary: 'var(--nui-color-accent-tertiary)',
},
},
},
spacing: {
'nui-gutter': 'var(--nui-gutter)',
'nui-gutter-sm': 'var(--nui-gutter-sm)',
'nui-gutter-lg': 'var(--nui-gutter-lg)',
},
fontFamily: {
'nui-serif': 'var(--nui-font-serif)',
'nui-serif-display': 'var(--nui-font-serif-display)',
'nui-sans': 'var(--nui-font-sans)',
'nui-sans-condensed': 'var(--nui-font-sans-condensed)',
'nui-mono': 'var(--nui-font-mono)',
},
fontSize: {
'nui-xs': 'var(--nui-font-size-xs)',
'nui-sm': 'var(--nui-font-size-sm)',
'nui-base': 'var(--nui-font-size-base)',
'nui-lg': 'var(--nui-font-size-lg)',
'nui-xl': 'var(--nui-font-size-xl)',
'nui-2xl': 'var(--nui-font-size-2xl)',
'nui-3xl': 'var(--nui-font-size-3xl)',
'nui-4xl': 'var(--nui-font-size-4xl)',
'nui-5xl': 'var(--nui-font-size-5xl)',
'nui-6xl': 'var(--nui-font-size-6xl)',
},
fontWeight: {
'nui-normal': 'var(--nui-font-weight-normal)',
'nui-medium': 'var(--nui-font-weight-medium)',
'nui-semibold': 'var(--nui-font-weight-semibold)',
'nui-bold': 'var(--nui-font-weight-bold)',
},
lineHeight: {
'nui-tight': 'var(--nui-line-height-tight)',
'nui-snug': 'var(--nui-line-height-snug)',
'nui-normal': 'var(--nui-line-height-normal)',
'nui-relaxed': 'var(--nui-line-height-relaxed)',
'nui-loose': 'var(--nui-line-height-loose)',
'nui-body': 'var(--nui-line-height-body)',
},
maxWidth: {
'nui-grid': 'var(--nui-grid-max-width)',
},
// 24-column grid utilities
gridTemplateColumns: {
'nui-24': 'repeat(24, minmax(0, 1fr))',
'nui-16': 'repeat(16, minmax(0, 1fr))',
'nui-12': 'repeat(12, minmax(0, 1fr))',
},
gridColumn: {
'span-13': 'span 13 / span 13',
'span-14': 'span 14 / span 14',
'span-15': 'span 15 / span 15',
'span-16': 'span 16 / span 16',
'span-17': 'span 17 / span 17',
'span-18': 'span 18 / span 18',
'span-19': 'span 19 / span 19',
'span-20': 'span 20 / span 20',
'span-21': 'span 21 / span 21',
'span-22': 'span 22 / span 22',
'span-23': 'span 23 / span 23',
'span-24': 'span 24 / span 24',
},
},
},
plugins: [],
};
export default config;
+127
View File
@@ -0,0 +1,127 @@
/**
* NewspaperUI Theme Variables
* Global CSS variables for newspaper layout system
*/
:root {
/* ========== Color System ========== */
/* Primary text colors */
--nui-color-text-primary: #111111;
--nui-color-text-secondary: #222222;
--nui-color-text-tertiary: #333333;
--nui-color-text-quaternary: #444444;
--nui-color-text-muted: #555555;
/* Gray scale */
--nui-color-gray-50: #fafafa;
--nui-color-gray-100: #f5f5f5;
--nui-color-gray-200: #e5e5e5;
--nui-color-gray-300: #d4d4d4;
--nui-color-gray-400: #a3a3a3;
--nui-color-gray-500: #737373;
--nui-color-gray-600: #525252;
--nui-color-gray-700: #404040;
--nui-color-gray-800: #262626;
--nui-color-gray-900: #171717;
/* Accent colors */
--nui-color-accent-primary: #0066cc;
--nui-color-accent-secondary: #cc0000;
--nui-color-accent-tertiary: #006600;
/* ========== Spacing System ========== */
/* Gutter (column gap) */
--nui-gutter: 1rem;
--nui-gutter-sm: 0.75rem;
--nui-gutter-lg: 1.5rem;
/* Margin */
--nui-margin-xs: 0.25rem;
--nui-margin-sm: 0.5rem;
--nui-margin-md: 0.75rem;
--nui-margin-lg: 1rem;
--nui-margin-xl: 1.5rem;
/* Padding */
--nui-padding-xs: 0.25rem;
--nui-padding-sm: 0.5rem;
--nui-padding-md: 0.75rem;
--nui-padding-lg: 1rem;
--nui-padding-xl: 1.5rem;
/* ========== Font System ========== */
/* Serif fonts for headlines and body text */
--nui-font-serif: 'Georgia', 'Times New Roman', serif;
--nui-font-serif-display: 'Playfair Display', 'Georgia', serif;
/* Sans-serif fonts for UI elements */
--nui-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
--nui-font-sans-condensed: 'Arial Narrow', 'Helvetica Condensed', sans-serif;
/* Monospace for code */
--nui-font-mono: 'Courier New', Courier, monospace;
/* ========== Grid System ========== */
/* 24-column grid */
--nui-grid-columns: 24;
--nui-grid-max-width: 1440px;
--nui-grid-container-padding: 2rem;
/* Responsive breakpoints */
--nui-breakpoint-sm: 768px;
--nui-breakpoint-md: 1024px;
--nui-breakpoint-lg: 1440px;
/* ========== Typography Scale ========== */
/* Font sizes */
--nui-font-size-xs: 0.75rem; /* 12px */
--nui-font-size-sm: 0.875rem; /* 14px */
--nui-font-size-base: 1rem; /* 16px */
--nui-font-size-lg: 1.125rem; /* 18px */
--nui-font-size-xl: 1.25rem; /* 20px */
--nui-font-size-2xl: 1.5rem; /* 24px */
--nui-font-size-3xl: 1.75rem; /* 28px */
--nui-font-size-4xl: 2rem; /* 32px */
--nui-font-size-5xl: 2.25rem; /* 36px */
--nui-font-size-6xl: 3rem; /* 48px */
/* Font weights */
--nui-font-weight-normal: 400;
--nui-font-weight-medium: 500;
--nui-font-weight-semibold: 600;
--nui-font-weight-bold: 700;
/* Line heights */
--nui-line-height-tight: 1.1;
--nui-line-height-snug: 1.2;
--nui-line-height-normal: 1.25;
--nui-line-height-relaxed: 1.3;
--nui-line-height-loose: 1.4;
--nui-line-height-body: 1.5;
}
[data-theme="dark"] {
/* Primary text colors */
--nui-color-text-primary: #eeeeee;
--nui-color-text-secondary: #dddddd;
--nui-color-text-tertiary: #cccccc;
--nui-color-text-quaternary: #bbbbbb;
--nui-color-text-muted: #aaaaaa;
/* Gray scale */
--nui-color-gray-50: #171717;
--nui-color-gray-100: #262626;
--nui-color-gray-200: #404040;
--nui-color-gray-300: #525252;
--nui-color-gray-400: #737373;
--nui-color-gray-500: #a3a3a3;
--nui-color-gray-600: #d4d4d4;
--nui-color-gray-700: #e5e5e5;
--nui-color-gray-800: #f5f5f5;
--nui-color-gray-900: #fafafa;
/* Accent colors */
--nui-color-accent-primary: #4da6ff;
--nui-color-accent-secondary: #ff6666;
--nui-color-accent-tertiary: #66cc66;
}
+164
View File
@@ -0,0 +1,164 @@
/**
* Resolves a fontSize value to a single CSS string.
* For range tuples [min, max], returns the lower bound (min).
*/
export function resolveFontSize(fontSize: string | [string, string]): string {
return Array.isArray(fontSize) ? fontSize[0] : fontSize;
}
export type VisualWeight = 'High' | 'Medium' | 'Low' | 'Standard';
export type ComponentType =
| 'Headline'
| 'Subhead'
| 'BodyText'
| 'Quote'
| 'PullQuote'
| 'Byline'
| 'Caption';
export interface VisualWeightConfig {
fontSize: string | [string, string];
fontWeight: number;
lineHeight: number;
color: string;
span: number | [number, number];
margin: string;
}
/**
* Visual weight configuration mapping
* Maps component types and their visual weights to specific styling configurations
*/
export const visualWeights: Record<
ComponentType,
Partial<Record<VisualWeight, VisualWeightConfig>>
> = {
Headline: {
High: {
fontSize: ['36px', '48px'],
fontWeight: 700,
lineHeight: 1.1,
color: '#111',
span: [6, 8],
margin: '0 0 1rem 0',
},
Medium: {
fontSize: ['28px', '34px'],
fontWeight: 600,
lineHeight: 1.2,
color: '#111',
span: [4, 6],
margin: '0 0 0.75rem 0',
},
Low: {
fontSize: ['22px', '26px'],
fontWeight: 500,
lineHeight: 1.3,
color: '#222',
span: [2, 4],
margin: '0 0 0.5rem 0',
},
},
Subhead: {
High: {
fontSize: ['20px', '24px'],
fontWeight: 600,
lineHeight: 1.25,
color: '#222',
span: [2, 3],
margin: '0 0 0.5rem 0',
},
Medium: {
fontSize: ['16px', '18px'],
fontWeight: 500,
lineHeight: 1.3,
color: '#333',
span: [1, 2],
margin: '0 0 0.25rem 0',
},
},
BodyText: {
High: {
fontSize: '16px',
fontWeight: 400,
lineHeight: 1.5,
color: '#333',
span: 1,
margin: '0 0 1rem 0',
},
Medium: {
fontSize: ['14px', '15px'],
fontWeight: 400,
lineHeight: 1.5,
color: '#444',
span: 1,
margin: '0 0 0.75rem 0',
},
Low: {
fontSize: ['12px', '14px'],
fontWeight: 400,
lineHeight: 1.4,
color: '#555',
span: 1,
margin: '0 0 0.5rem 0',
},
},
Quote: {
High: {
fontSize: ['20px', '24px'],
fontWeight: 500,
lineHeight: 1.4,
color: '#222',
span: 2,
margin: '0 0 0.75rem 0',
},
Medium: {
fontSize: ['16px', '18px'],
fontWeight: 400,
lineHeight: 1.4,
color: '#333',
span: 1,
margin: '0 0 0.5rem 0',
},
},
PullQuote: {
High: {
fontSize: ['24px', '28px'],
fontWeight: 600,
lineHeight: 1.2,
color: '#111',
span: [2, 3],
margin: '0 0 0.5rem 0',
},
Medium: {
fontSize: ['18px', '20px'],
fontWeight: 500,
lineHeight: 1.25,
color: '#222',
span: [1, 2],
margin: '0 0 0.25rem 0',
},
},
Byline: {
Standard: {
fontSize: ['12px', '14px'],
fontWeight: 400,
lineHeight: 1.3,
color: '#555',
span: 1,
margin: '0 0 0.25rem 0',
},
},
Caption: {
Standard: {
fontSize: ['12px', '14px'],
fontWeight: 400,
lineHeight: 1.3,
color: '#555',
span: 1,
margin: '0.25rem 0',
},
},
};
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [
dts({
insertTypesEntry: true,
}),
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'NewspaperUITheme',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`,
},
rollupOptions: {
external: [],
},
},
});
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@newspaperui/utils",
"version": "0.0.0",
"description": "Utility functions for newspaperui",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "vite build",
"dev": "vite build --watch",
"lint": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf dist"
},
"devDependencies": {
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vite-plugin-dts": "^4.3.0",
"vitest": "^2.1.8"
}
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { calculateSpanWidth, validateSpan, calculateGutter } from '../grid';
describe('grid utilities', () => {
describe('calculateSpanWidth', () => {
it('should calculate correct width percentage for 24-column grid', () => {
expect(calculateSpanWidth(6, 24)).toBe('25.000%');
expect(calculateSpanWidth(12, 24)).toBe('50.000%');
expect(calculateSpanWidth(24, 24)).toBe('100.000%');
expect(calculateSpanWidth(1, 24)).toBe('4.167%');
});
it('should calculate correct width percentage for 12-column grid', () => {
expect(calculateSpanWidth(6, 12)).toBe('50.000%');
expect(calculateSpanWidth(3, 12)).toBe('25.000%');
expect(calculateSpanWidth(12, 12)).toBe('100.000%');
});
it('should use 24 columns as default', () => {
expect(calculateSpanWidth(6)).toBe('25.000%');
expect(calculateSpanWidth(12)).toBe('50.000%');
});
it('should throw error for invalid span', () => {
expect(() => calculateSpanWidth(0, 24)).toThrow();
expect(() => calculateSpanWidth(25, 24)).toThrow();
expect(() => calculateSpanWidth(-1, 24)).toThrow();
});
});
describe('validateSpan', () => {
it('should return true for valid spans', () => {
expect(validateSpan(1, 24)).toBe(true);
expect(validateSpan(12, 24)).toBe(true);
expect(validateSpan(24, 24)).toBe(true);
});
it('should return false for invalid spans', () => {
expect(validateSpan(0, 24)).toBe(false);
expect(validateSpan(25, 24)).toBe(false);
expect(validateSpan(-1, 24)).toBe(false);
expect(validateSpan(1.5, 24)).toBe(false);
});
});
describe('calculateGutter', () => {
it('should calculate gutter width correctly', () => {
const gutter = calculateGutter(1440, 24, 0.05);
expect(gutter).toBeGreaterThan(0);
expect(gutter).toBeLessThan(100);
});
it('should throw error for invalid inputs', () => {
expect(() => calculateGutter(0, 24, 0.05)).toThrow();
expect(() => calculateGutter(1440, 0, 0.05)).toThrow();
expect(() => calculateGutter(1440, 24, -0.1)).toThrow();
expect(() => calculateGutter(1440, 24, 1.5)).toThrow();
});
});
});
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import {
calculateSpanWidth,
calculateLayout,
getResponsiveColumns,
adjustSpanForScreen,
} from '../index';
describe('integration tests', () => {
it('should work together for responsive layout calculation', () => {
// Scenario: Layout 3 items on different screen sizes
const items = [
{ id: 'headline', span: 12 },
{ id: 'image', span: 8 },
{ id: 'text', span: 4 },
];
// Large screen (24 columns)
const largeScreenColumns = getResponsiveColumns(1440);
expect(largeScreenColumns).toBe(24);
const largeLayout = calculateLayout(items, largeScreenColumns);
expect(largeLayout).toHaveLength(3);
// All items fit in one row: 12 + 8 + 4 = 24
expect(largeLayout[0].row).toBe(0);
expect(largeLayout[1].row).toBe(0);
expect(largeLayout[2].row).toBe(0);
// Medium screen (16 columns) - adjust spans
const mediumScreenColumns = getResponsiveColumns(900);
expect(mediumScreenColumns).toBe(16);
const adjustedItems = items.map((item) => ({
...item,
span: adjustSpanForScreen(item.span, 900),
}));
const mediumLayout = calculateLayout(adjustedItems, mediumScreenColumns);
expect(mediumLayout).toHaveLength(3);
// Small screen (12 columns) - adjust spans
const smallScreenColumns = getResponsiveColumns(600);
expect(smallScreenColumns).toBe(12);
const smallAdjustedItems = items.map((item) => ({
...item,
span: adjustSpanForScreen(item.span, 600),
}));
const smallLayout = calculateLayout(smallAdjustedItems, smallScreenColumns);
expect(smallLayout).toHaveLength(3);
});
it('should calculate correct widths for newspaper layout', () => {
// 6-column headline in 24-column grid
const headlineWidth = calculateSpanWidth(6, 24);
expect(headlineWidth).toBe('25.000%');
// 8-column image in 24-column grid
const imageWidth = calculateSpanWidth(8, 24);
expect(imageWidth).toBe('33.333%');
// Full-width article
const fullWidth = calculateSpanWidth(24, 24);
expect(fullWidth).toBe('100.000%');
});
});
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import {
getResponsiveColumns,
adjustSpanForScreen,
isBreakpoint,
getCurrentBreakpoint,
BREAKPOINTS,
} from '../responsive';
describe('responsive utilities', () => {
describe('getResponsiveColumns', () => {
it('should return 12 columns for small screens', () => {
expect(getResponsiveColumns(320)).toBe(12);
expect(getResponsiveColumns(767)).toBe(12);
});
it('should return 16 columns for medium screens', () => {
expect(getResponsiveColumns(768)).toBe(16);
expect(getResponsiveColumns(1023)).toBe(16);
});
it('should return 24 columns for large screens', () => {
expect(getResponsiveColumns(1024)).toBe(24);
expect(getResponsiveColumns(1440)).toBe(24);
expect(getResponsiveColumns(1920)).toBe(24);
});
});
describe('adjustSpanForScreen', () => {
it('should not adjust span for large screens (24 columns)', () => {
expect(adjustSpanForScreen(12, 1440)).toBe(12);
expect(adjustSpanForScreen(6, 1920)).toBe(6);
expect(adjustSpanForScreen(24, 1024)).toBe(24);
});
it('should proportionally adjust span for medium screens (16 columns)', () => {
expect(adjustSpanForScreen(12, 800)).toBe(8); // 12/24 * 16 = 8
expect(adjustSpanForScreen(6, 900)).toBe(4); // 6/24 * 16 = 4
expect(adjustSpanForScreen(24, 1000)).toBe(16); // 24/24 * 16 = 16
});
it('should proportionally adjust span for small screens (12 columns)', () => {
expect(adjustSpanForScreen(12, 600)).toBe(6); // 12/24 * 12 = 6
expect(adjustSpanForScreen(6, 400)).toBe(3); // 6/24 * 12 = 3
expect(adjustSpanForScreen(24, 700)).toBe(12); // 24/24 * 12 = 12
});
it('should ensure minimum span of 1', () => {
expect(adjustSpanForScreen(1, 600)).toBe(1);
expect(adjustSpanForScreen(2, 600)).toBeGreaterThanOrEqual(1);
});
it('should not exceed target columns', () => {
expect(adjustSpanForScreen(24, 600)).toBeLessThanOrEqual(12);
expect(adjustSpanForScreen(24, 800)).toBeLessThanOrEqual(16);
});
});
describe('isBreakpoint', () => {
it('should correctly identify breakpoints', () => {
expect(isBreakpoint(768, 'sm')).toBe(true);
expect(isBreakpoint(767, 'sm')).toBe(false);
expect(isBreakpoint(1024, 'md')).toBe(true);
expect(isBreakpoint(1023, 'md')).toBe(false);
expect(isBreakpoint(1440, 'lg')).toBe(true);
expect(isBreakpoint(1439, 'lg')).toBe(false);
});
});
describe('getCurrentBreakpoint', () => {
it('should return correct breakpoint names', () => {
expect(getCurrentBreakpoint(320)).toBe('xs');
expect(getCurrentBreakpoint(767)).toBe('xs');
expect(getCurrentBreakpoint(768)).toBe('sm');
expect(getCurrentBreakpoint(1023)).toBe('sm');
expect(getCurrentBreakpoint(1024)).toBe('md');
expect(getCurrentBreakpoint(1439)).toBe('md');
expect(getCurrentBreakpoint(1440)).toBe('lg');
expect(getCurrentBreakpoint(1920)).toBe('lg');
});
});
describe('BREAKPOINTS', () => {
it('should have correct breakpoint values', () => {
expect(BREAKPOINTS.sm).toBe(768);
expect(BREAKPOINTS.md).toBe(1024);
expect(BREAKPOINTS.lg).toBe(1440);
});
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { isSpanValid, calculateLayout, type LayoutItem } from '../span';
describe('span utilities', () => {
describe('isSpanValid', () => {
it('should return true for valid spans', () => {
expect(isSpanValid(1, 24)).toBe(true);
expect(isSpanValid(12, 24)).toBe(true);
expect(isSpanValid(24, 24)).toBe(true);
expect(isSpanValid(6, 12)).toBe(true);
});
it('should return false for invalid spans', () => {
expect(isSpanValid(0, 24)).toBe(false);
expect(isSpanValid(25, 24)).toBe(false);
expect(isSpanValid(-1, 24)).toBe(false);
expect(isSpanValid(1.5, 24)).toBe(false);
expect(isSpanValid(13, 12)).toBe(false);
});
});
describe('calculateLayout', () => {
it('should layout items in a single row when they fit', () => {
const items: LayoutItem[] = [
{ id: 'a', span: 6 },
{ id: 'b', span: 6 },
{ id: 'c', span: 6 },
];
const layout = calculateLayout(items, 24);
expect(layout).toHaveLength(3);
expect(layout[0]).toEqual({ item: items[0], row: 0, col: 0 });
expect(layout[1]).toEqual({ item: items[1], row: 0, col: 6 });
expect(layout[2]).toEqual({ item: items[2], row: 0, col: 12 });
});
it('should wrap to next row when items do not fit', () => {
const items: LayoutItem[] = [
{ id: 'a', span: 16 },
{ id: 'b', span: 12 },
];
const layout = calculateLayout(items, 24);
expect(layout).toHaveLength(2);
expect(layout[0]).toEqual({ item: items[0], row: 0, col: 0 });
expect(layout[1]).toEqual({ item: items[1], row: 1, col: 0 });
});
it('should handle exact row fills', () => {
const items: LayoutItem[] = [
{ id: 'a', span: 12 },
{ id: 'b', span: 12 },
{ id: 'c', span: 24 },
];
const layout = calculateLayout(items, 24);
expect(layout).toHaveLength(3);
expect(layout[0]).toEqual({ item: items[0], row: 0, col: 0 });
expect(layout[1]).toEqual({ item: items[1], row: 0, col: 12 });
expect(layout[2]).toEqual({ item: items[2], row: 1, col: 0 });
});
it('should throw error for invalid section columns', () => {
const items: LayoutItem[] = [{ id: 'a', span: 6 }];
expect(() => calculateLayout(items, 0)).toThrow();
expect(() => calculateLayout(items, -1)).toThrow();
});
it('should throw error for invalid item span', () => {
const items: LayoutItem[] = [{ id: 'a', span: 25 }];
expect(() => calculateLayout(items, 24)).toThrow();
});
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* Grid calculation utilities for 24-column newspaper layout system
*/
/**
* Calculate element width percentage in a grid system
* @param span - Number of columns the element spans
* @param totalColumns - Total number of columns in the grid (default: 24)
* @returns Width as a percentage string (e.g., "33.333%")
*/
export function calculateSpanWidth(
span: number,
totalColumns: number = 24
): string {
if (!validateSpan(span, totalColumns)) {
throw new Error(
`Invalid span: ${span}. Must be between 1 and ${totalColumns}`
);
}
const percentage = (span / totalColumns) * 100;
return `${percentage.toFixed(3)}%`;
}
/**
* Validate if a span value is within valid range
* @param span - Number of columns to validate
* @param maxColumns - Maximum number of columns allowed
* @returns True if span is valid, false otherwise
*/
export function validateSpan(span: number, maxColumns: number): boolean {
return Number.isInteger(span) && span >= 1 && span <= maxColumns;
}
/**
* Calculate gutter width based on container width and column count
* @param containerWidth - Total width of the container in pixels
* @param columns - Number of columns in the grid
* @param gutterRatio - Ratio of gutter to column width (default: 0.05)
* @returns Gutter width in pixels
*/
export function calculateGutter(
containerWidth: number,
columns: number,
gutterRatio: number = 0.05
): number {
if (containerWidth <= 0 || columns <= 0) {
throw new Error('Container width and columns must be positive numbers');
}
if (gutterRatio < 0 || gutterRatio > 1) {
throw new Error('Gutter ratio must be between 0 and 1');
}
// Calculate column width considering gutters
// Formula: containerWidth = (columns * columnWidth) + ((columns - 1) * gutter)
// Where gutter = columnWidth * gutterRatio
const totalGutterRatio = (columns - 1) * gutterRatio;
const columnWidth = containerWidth / (columns + totalGutterRatio);
const gutterWidth = columnWidth * gutterRatio;
return Math.round(gutterWidth * 100) / 100;
}
+30
View File
@@ -0,0 +1,30 @@
/**
* @newspaperui/utils
* Utility functions for newspaperui component library
*/
// Grid utilities
export {
calculateSpanWidth,
validateSpan,
calculateGutter,
} from './grid';
// Span and layout utilities
export {
isSpanValid,
calculateLayout,
type LayoutItem,
type PositionedLayoutItem,
} from './span';
// Responsive utilities
export {
getResponsiveColumns,
adjustSpanForScreen,
isBreakpoint,
getCurrentBreakpoint,
BREAKPOINTS,
} from './responsive';
export const version = '0.0.0';
+85
View File
@@ -0,0 +1,85 @@
/**
* Responsive utilities for newspaper layout system
*/
/**
* Breakpoint thresholds for responsive design
*/
export const BREAKPOINTS = {
sm: 768,
md: 1024,
lg: 1440,
} as const;
/**
* Get recommended grid column count based on screen width
* @param screenWidth - Current screen width in pixels
* @returns Recommended number of columns (12, 16, or 24)
*/
export function getResponsiveColumns(screenWidth: number): number {
if (screenWidth < BREAKPOINTS.sm) {
return 12; // Small screens: 12 columns
} else if (screenWidth < BREAKPOINTS.md) {
return 16; // Medium screens: 16 columns
} else {
return 24; // Large screens: 24 columns
}
}
/**
* Adjust span value for different screen sizes
* Proportionally scales span based on available columns
* @param span - Original span value (based on 24 columns)
* @param screenWidth - Current screen width in pixels
* @returns Adjusted span value for current screen size
*/
export function adjustSpanForScreen(
span: number,
screenWidth: number
): number {
const targetColumns = getResponsiveColumns(screenWidth);
const baseColumns = 24;
// If already at 24 columns, no adjustment needed
if (targetColumns === baseColumns) {
return span;
}
// Calculate proportional span
const adjustedSpan = Math.round((span / baseColumns) * targetColumns);
// Ensure at least 1 column and not exceeding target columns
return Math.max(1, Math.min(adjustedSpan, targetColumns));
}
/**
* Check if current screen width matches a breakpoint
* @param screenWidth - Current screen width in pixels
* @param breakpoint - Breakpoint name to check
* @returns True if screen width is at or above the breakpoint
*/
export function isBreakpoint(
screenWidth: number,
breakpoint: keyof typeof BREAKPOINTS
): boolean {
return screenWidth >= BREAKPOINTS[breakpoint];
}
/**
* Get current breakpoint name based on screen width
* @param screenWidth - Current screen width in pixels
* @returns Current breakpoint name ('sm', 'md', 'lg', or 'xs' for below sm)
*/
export function getCurrentBreakpoint(
screenWidth: number
): 'xs' | 'sm' | 'md' | 'lg' {
if (screenWidth >= BREAKPOINTS.lg) {
return 'lg';
} else if (screenWidth >= BREAKPOINTS.md) {
return 'md';
} else if (screenWidth >= BREAKPOINTS.sm) {
return 'sm';
} else {
return 'xs';
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Span and layout utilities for newspaper column system
*/
/**
* Check if an object's span is valid within a section's column count
* @param objectSpan - Number of columns the object spans
* @param sectionColumns - Total number of columns in the section
* @returns True if the span is valid, false otherwise
*/
export function isSpanValid(
objectSpan: number,
sectionColumns: number
): boolean {
return (
Number.isInteger(objectSpan) &&
Number.isInteger(sectionColumns) &&
objectSpan >= 1 &&
objectSpan <= sectionColumns
);
}
/**
* Layout item interface for grid positioning
*/
export interface LayoutItem {
span: number;
id: string;
}
/**
* Positioned layout item with row and column information
*/
export interface PositionedLayoutItem {
item: LayoutItem;
row: number;
col: number;
}
/**
* Calculate simple flow layout for items in a section
* Items are placed left-to-right, wrapping to new rows when needed
* @param items - Array of items to layout
* @param sectionColumns - Total number of columns in the section
* @returns Array of positioned items with row and column information
*/
export function calculateLayout(
items: LayoutItem[],
sectionColumns: number
): PositionedLayoutItem[] {
if (sectionColumns <= 0) {
throw new Error('Section columns must be a positive number');
}
const positioned: PositionedLayoutItem[] = [];
let currentRow = 0;
let currentCol = 0;
for (const item of items) {
// Validate item span
if (!isSpanValid(item.span, sectionColumns)) {
throw new Error(
`Invalid span ${item.span} for item ${item.id}. Must be between 1 and ${sectionColumns}`
);
}
// Check if item fits in current row
if (currentCol + item.span > sectionColumns) {
// Move to next row
currentRow++;
currentCol = 0;
}
// Position the item
positioned.push({
item,
row: currentRow,
col: currentCol,
});
// Update current column position
currentCol += item.span;
// If we've filled the row exactly, move to next row
if (currentCol === sectionColumns) {
currentRow++;
currentCol = 0;
}
}
return positioned;
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [
dts({
insertTypesEntry: true,
}),
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'NewspaperUIUtils',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`,
},
rollupOptions: {
external: [],
},
},
});
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/', '**/*.test.ts'],
},
},
});
+4980
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
packages:
- 'packages/*'
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"paths": {
"@/*": ["./src/*"]
}
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "out/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"]
},
"clean": {
"cache": false
}
}
}