diff --git a/.changeset/config-provider-infra-upgrade.md b/.changeset/config-provider-infra-upgrade.md new file mode 100644 index 00000000..936a37b9 --- /dev/null +++ b/.changeset/config-provider-infra-upgrade.md @@ -0,0 +1,23 @@ +--- +"@tiny-design/react": minor +--- + +Upgrade the global configuration infrastructure around `ConfigProvider` and align popup, scroll, and static layer behavior across the component library. + +Highlights: + +- Reworked `ConfigProvider` to use provider-scoped theme containers instead of mutating global HTML styles. +- Added `ConfigProvider.useConfig()` and `ConfigProvider.config({ holderRender })` support for a wider set of static APIs. +- Added static `Modal.open()` and `Modal.confirm()` APIs that participate in the shared static host pipeline. +- Unified popup container resolution across `Portal`, `Popup`, and `Cascader`. +- Unified target container resolution across `Anchor`, `Sticky`, `BackTop`, `Overlay`, and `Tour`. +- Improved `Sticky` container observation with `ResizeObserver`. +- Improved `useTheme()` to sync with DOM state, localStorage, system preference changes, and cross-tab storage events. +- Added `onCopy` to `CopyToClipboard` so copy results can be observed by consumers. + +Notes for consumers: + +- `Anchor` and `BackTop` now accept and resolve `Window` as a first-class target container shape. +- `BackTop` now defaults to `ConfigProvider.getTargetContainer()` when present. +- `ConfigProvider` only renders an internal scope node when scoped theme behavior is required. +- Static APIs such as `Message.*`, `Notification.*`, `LoadingBar.*`, and `Modal.open()` can now be wrapped consistently through `ConfigProvider.config({ holderRender })`. diff --git a/.changeset/migrate-scss-to-css-custom-properties.md b/.changeset/migrate-scss-to-css-custom-properties.md new file mode 100644 index 00000000..868fe9be --- /dev/null +++ b/.changeset/migrate-scss-to-css-custom-properties.md @@ -0,0 +1,12 @@ +--- +"@tiny-design/react": minor +"@tiny-design/tokens": minor +--- + +Migrate component styles from SCSS variables to CSS custom properties (`--ty-*`) for better runtime theming support. + +- Migrate ~80 structural SCSS constants (padding, sizing, transitions) to runtime-customizable CSS custom properties +- Tokenize hardcoded values in Button, Input, Card, Select, and Notification components +- Add component-scoped CSS variable fallback chains (e.g., `--ty-btn-border-radius` falls back to `--ty-border-radius`) +- Add `ThemeConfig` API to `ConfigProvider` for programmatic token and component-level overrides +- Three-level customization: global tokens, component tokens, and scoped instance overrides via CSS diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 943d4a37..5ebb2de9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,6 +69,19 @@ your-component/ 3. Export the component from `packages/react/src/index.ts` 4. Add the route in `apps/docs/src/routers.tsx` +### Public Type Exports + +Every component module must export its public TypeScript types from its own `index.tsx`. + +- If the component has a `types.ts` file, re-export its public types from `packages/react/src/your-component/index.tsx` +- If the component exposes public types from related files, re-export them from the same module barrel as well +- Keep component-level type exports consistent with the runtime component export instead of creating one-off type exceptions elsewhere + +The package root barrel must also re-export those public component types from `packages/react/src/index.ts`. + +- Do not add root-level type exports for only one or two special-case components +- Do not rely on consumers importing public component types from internal file paths when the type is part of the supported API surface + ## Code Style - TypeScript strict mode is enabled diff --git a/apps/docs/guides/customise-theme.md b/apps/docs/guides/customise-theme.md index 8acbc816..b3596cca 100755 --- a/apps/docs/guides/customise-theme.md +++ b/apps/docs/guides/customise-theme.md @@ -4,7 +4,7 @@ Tiny UI provides three ways to customise the look and feel: 1. **Theme Editor** — a visual, no-code tool for real-time theming (great for exploration and quick customisation). 2. **Design tokens** — CSS custom properties that power light and dark mode. These are the runtime values every component reads. -3. **SCSS variables** — compile-time variables (sizes, font stacks, border radii, etc.) that can be overridden when you build your own stylesheet. +3. **SCSS constants** — compile-time structural constants (padding, transitions, arrow sizes, etc.) that can be overridden when you build your own stylesheet. ## Theme Editor @@ -14,7 +14,7 @@ The built-in [Theme Editor](/theme/theme-editor) lets you visually customise des - Adjust primary, success, warning, danger, and info colours, background, text, and border colours. - Tweak typography (font size, line height, font weight) and details (border radius, spacing, sizing). - Preview changes live on real components. -- Export your customised tokens as CSS or SCSS to use in your project. +- Export your customised tokens as CSS or JSON to use in your project. Changes are applied instantly via CSS custom properties — no rebuild required. @@ -55,7 +55,7 @@ The hook returns: ## Design tokens (CSS custom properties) -Every colour, shadow, and visual state is exposed as a `--ty-*` CSS custom property on `:root`. You can override any token in your own stylesheet: +Every colour, shadow, and visual state is exposed as a `--ty-*` CSS custom property on `:root`. This is the **primary way** to customise Tiny UI. You can override any token in your own stylesheet: ```css :root { @@ -65,6 +65,16 @@ Every colour, shadow, and visual state is exposed as a `--ty-*` CSS custom prope } ``` +For dark mode overrides, target the dark theme selector: + +```css +html[data-tiny-theme='dark'] { + --ty-color-primary: #3d9bff; + --ty-color-primary-hover: #66b3ff; + --ty-color-primary-active: #007bff; +} +``` + ### Commonly used tokens | Token | Light default | Description | @@ -76,16 +86,21 @@ Every colour, shadow, and visual state is exposed as a `--ty-*` CSS custom prope | `--ty-color-text` | `rgba(0,0,0,0.85)` | Primary text colour | | `--ty-color-text-secondary` | `rgba(0,0,0,0.65)` | Secondary text colour | | `--ty-color-border` | `#d9d9d9` | Default border colour | +| `--ty-border-radius` | `2px` | Global border radius | +| `--ty-font-size-base` | `1rem` | Base font size | +| `--ty-height-sm` | `24px` | Small control height | +| `--ty-height-md` | `32px` | Medium control height | +| `--ty-height-lg` | `42px` | Large control height | -The full list of tokens can be found in the source: +Every component also has its own tokens for fine-grained control. For example, Button uses `--ty-btn-default-bg`, `--ty-btn-default-color`, etc. The full list of tokens can be found in the source: - [Light theme tokens](https://github.com/wangdicoder/tiny-design/blob/master/packages/tokens/scss/themes/_light.scss) - [Dark theme tokens](https://github.com/wangdicoder/tiny-design/blob/master/packages/tokens/scss/themes/_dark.scss) -## SCSS variables +## SCSS constants -If you import Tiny UI's SCSS source instead of the pre-built CSS, you can override compile-time variables such as sizes, spacing, font stacks, and border radii. Every variable uses the `!default` flag, so your overrides take precedence. +If you import Tiny UI's SCSS source instead of the pre-built CSS, you can override compile-time structural constants such as padding, transitions, and arrow sizes. These are values that don't need to change at runtime. -> **What's `!default`?** A Sass variable with `!default` is only assigned if it hasn't already been defined. By declaring your value *before* importing Tiny UI's styles, your value wins. +Every constant uses the `!default` flag, so your overrides take precedence. ### 1. Install Sass @@ -95,13 +110,13 @@ $ npm install sass --save-dev ### 2. Create your overrides file -Create a file, e.g. `theme-variables.scss`. Your overrides **must come before** the Tiny UI import: +Create a file, e.g. `theme-overrides.scss`. Your overrides **must come before** the Tiny UI import: ```scss -// Your overrides -$primary-color: #007bff; -$border-radius: 4px; -$font-size-base: 14px; +// Override structural constants +$btn-padding-md: 0 20px; +$card-body-padding: 20px; +$tooltip-arrow-size: 6px; // Import Tiny UI styles (applies your overrides via !default) @use "@tiny-design/react/es/style/index" as *; @@ -110,32 +125,27 @@ $font-size-base: 14px; ### 3. Import in your entry file ```js -import './theme-variables.scss'; +import './theme-overrides.scss'; ``` -The full list of SCSS variables can be found in [_variables.scss](https://github.com/wangdicoder/tiny-design/blob/master/packages/tokens/scss/_variables.scss). +The full list of SCSS constants can be found in [_constants.scss](https://github.com/wangdicoder/tiny-design/blob/master/packages/tokens/scss/_constants.scss). -Some commonly overridden variables: +Some commonly overridden constants: ```scss -// Color -$primary-color: #6e41bf !default; - -// Font -$font-size-base: 1rem !default; -$font-size-lg: $font-size-base * 1.25 !default; -$font-size-sm: $font-size-base * 0.875 !default; -$font-weight: 400 !default; - -// Border -$border-radius: 2px !default; -$border-width: 1px !default; -$border-color: $gray-300 !default; - -// Component sizes -$height-sm: 24px !default; -$height-md: 32px !default; -$height-lg: 42px !default; +// Button +$btn-padding-sm: 0 10px !default; +$btn-padding-md: 0 15px !default; +$btn-padding-lg: 0 28px !default; + +// Card +$card-header-padding: 13px 16px !default; +$card-body-padding: 16px !default; + +// Notification +$notification-width: 380px !default; ``` -Please report an issue if the existing list of variables is not enough for you. +> **Note:** Colours, font sizes, border radii, shadows, and all other visual tokens should be customised via CSS custom properties (see above), not SCSS variables. SCSS constants are only for structural values like padding and sizing. + +Please report an issue if the existing list of tokens or constants is not enough for you. diff --git a/apps/docs/guides/customise-theme.zh_CN.md b/apps/docs/guides/customise-theme.zh_CN.md index ae343ba0..3799458f 100644 --- a/apps/docs/guides/customise-theme.zh_CN.md +++ b/apps/docs/guides/customise-theme.zh_CN.md @@ -4,7 +4,7 @@ Tiny UI 提供三种方式来定制外观: 1. **主题编辑器** — 一个可视化的实时主题工具,无需编写代码(非常适合探索和快速定制)。 2. **设计令牌(Design tokens)** — 驱动亮色/暗色模式的 CSS 自定义属性,所有组件在运行时读取这些值。 -3. **SCSS 变量** — 编译时变量(尺寸、字体、圆角等),可在构建自定义样式表时覆盖。 +3. **SCSS 常量** — 编译时结构常量(内边距、过渡动画、箭头尺寸等),可在构建自定义样式表时覆盖。 ## 主题编辑器 @@ -14,7 +14,7 @@ Tiny UI 提供三种方式来定制外观: - 调整主色、成功色、警告色、危险色和信息色,以及背景色、文本色和边框色。 - 调整排版(字号、行高、字重)和细节(圆角、间距、尺寸)。 - 在真实组件上实时预览更改效果。 -- 导出自定义的令牌为 CSS 或 SCSS,在你的项目中使用。 +- 导出自定义的令牌为 CSS 或 JSON,在你的项目中使用。 更改通过 CSS 自定义属性即时生效 — 无需重新构建。 @@ -55,7 +55,7 @@ const App = () => { ## 设计令牌(CSS 自定义属性) -所有颜色、阴影和视觉状态都以 `--ty-*` CSS 自定义属性的形式暴露在 `:root` 上。你可以在自己的样式表中覆盖任意令牌: +所有颜色、阴影和视觉状态都以 `--ty-*` CSS 自定义属性的形式暴露在 `:root` 上。这是定制 Tiny UI 的**主要方式**。你可以在自己的样式表中覆盖任意令牌: ```css :root { @@ -65,6 +65,16 @@ const App = () => { } ``` +暗色模式下的覆盖,使用暗色主题选择器: + +```css +html[data-tiny-theme='dark'] { + --ty-color-primary: #3d9bff; + --ty-color-primary-hover: #66b3ff; + --ty-color-primary-active: #007bff; +} +``` + ### 常用令牌 | 令牌 | 亮色默认值 | 说明 | @@ -76,16 +86,21 @@ const App = () => { | `--ty-color-text` | `rgba(0,0,0,0.85)` | 主文本色 | | `--ty-color-text-secondary` | `rgba(0,0,0,0.65)` | 次要文本色 | | `--ty-color-border` | `#d9d9d9` | 默认边框色 | +| `--ty-border-radius` | `2px` | 全局圆角 | +| `--ty-font-size-base` | `1rem` | 基础字号 | +| `--ty-height-sm` | `24px` | 小尺寸控件高度 | +| `--ty-height-md` | `32px` | 中尺寸控件高度 | +| `--ty-height-lg` | `42px` | 大尺寸控件高度 | -完整的令牌列表请参考源码: +每个组件也有自己的令牌,用于细粒度控制。例如,Button 使用 `--ty-btn-default-bg`、`--ty-btn-default-color` 等。完整的令牌列表请参考源码: - [亮色主题令牌](https://github.com/wangdicoder/tiny-design/blob/master/packages/tokens/scss/themes/_light.scss) - [暗色主题令牌](https://github.com/wangdicoder/tiny-design/blob/master/packages/tokens/scss/themes/_dark.scss) -## SCSS 变量 +## SCSS 常量 -如果你引入的是 Tiny UI 的 SCSS 源文件而非预编译的 CSS,可以覆盖编译时变量,如尺寸、间距、字体和圆角等。每个变量都使用了 `!default` 标志,因此你的覆盖值会优先生效。 +如果你引入的是 Tiny UI 的 SCSS 源文件而非预编译的 CSS,可以覆盖编译时结构常量,如内边距、过渡动画和箭头尺寸。这些是不需要在运行时变化的值。 -> **什么是 `!default`?** 带有 `!default` 的 Sass 变量仅在尚未定义时才会赋值。在引入 Tiny UI 样式*之前*声明你的值,你的值就会生效。 +每个常量都使用了 `!default` 标志,因此你的覆盖值会优先生效。 ### 1. 安装 Sass @@ -95,13 +110,13 @@ $ npm install sass --save-dev ### 2. 创建覆盖文件 -创建一个文件,例如 `theme-variables.scss`。你的覆盖值**必须写在** Tiny UI 引入语句之前: +创建一个文件,例如 `theme-overrides.scss`。你的覆盖值**必须写在** Tiny UI 引入语句之前: ```scss -// 你的覆盖值 -$primary-color: #007bff; -$border-radius: 4px; -$font-size-base: 14px; +// 覆盖结构常量 +$btn-padding-md: 0 20px; +$card-body-padding: 20px; +$tooltip-arrow-size: 6px; // 引入 Tiny UI 样式(通过 !default 应用你的覆盖值) @use "@tiny-design/react/es/style/index" as *; @@ -110,32 +125,27 @@ $font-size-base: 14px; ### 3. 在入口文件中引入 ```js -import './theme-variables.scss'; +import './theme-overrides.scss'; ``` -完整的 SCSS 变量列表请参考 [_variables.scss](https://github.com/wangdicoder/tiny-design/blob/master/packages/tokens/scss/_variables.scss)。 +完整的 SCSS 常量列表请参考 [_constants.scss](https://github.com/wangdicoder/tiny-design/blob/master/packages/tokens/scss/_constants.scss)。 -以下是一些常用的可覆盖变量: +以下是一些常用的可覆盖常量: ```scss -// 颜色 -$primary-color: #6e41bf !default; - -// 字体 -$font-size-base: 1rem !default; -$font-size-lg: $font-size-base * 1.25 !default; -$font-size-sm: $font-size-base * 0.875 !default; -$font-weight: 400 !default; - -// 边框 -$border-radius: 2px !default; -$border-width: 1px !default; -$border-color: $gray-300 !default; - -// 组件尺寸 -$height-sm: 24px !default; -$height-md: 32px !default; -$height-lg: 42px !default; +// 按钮 +$btn-padding-sm: 0 10px !default; +$btn-padding-md: 0 15px !default; +$btn-padding-lg: 0 28px !default; + +// 卡片 +$card-header-padding: 13px 16px !default; +$card-body-padding: 16px !default; + +// 通知 +$notification-width: 380px !default; ``` -如果现有的变量列表无法满足你的需求,请提交 issue 反馈。 +> **注意:** 颜色、字号、圆角、阴影等所有视觉令牌应通过 CSS 自定义属性定制(见上方),而非 SCSS 变量。SCSS 常量仅用于内边距、尺寸等结构性值。 + +如果现有的令牌或常量列表无法满足你的需求,请提交 issue 反馈。 diff --git a/apps/docs/src/containers/theme-editor/components/export-dialog.tsx b/apps/docs/src/containers/theme-editor/components/export-dialog.tsx index 31b34883..fcde808d 100644 --- a/apps/docs/src/containers/theme-editor/components/export-dialog.tsx +++ b/apps/docs/src/containers/theme-editor/components/export-dialog.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { Button, Modal, Tabs } from '@tiny-design/react'; -import { generateCSS, generateSCSS, generateJSON } from '../utils/export-theme'; +import { generateCSS, generateJSON } from '../utils/export-theme'; interface ExportDialogProps { visible: boolean; @@ -28,7 +28,6 @@ export const ExportDialog = ({ const [copied, setCopied] = useState(false); const cssCode = generateCSS(appliedTokens); - const scssCode = generateSCSS(seeds); const jsonCode = generateJSON(seeds); const handleCopy = (code: string) => { @@ -71,9 +70,6 @@ export const ExportDialog = ({ {renderBlock(cssCode, 'tiny-theme.css', 'text/css')} - - {renderBlock(scssCode, 'tiny-theme.scss', 'text/x-scss')} - {renderBlock(jsonCode, 'tiny-theme.json', 'application/json')} diff --git a/apps/docs/src/containers/theme-editor/constants/default-tokens.ts b/apps/docs/src/containers/theme-editor/constants/default-tokens.ts index ec0b2836..94dd3fcd 100644 --- a/apps/docs/src/containers/theme-editor/constants/default-tokens.ts +++ b/apps/docs/src/containers/theme-editor/constants/default-tokens.ts @@ -6,7 +6,7 @@ export interface TokenDef { labelZh: string; type: TokenType; defaultValue: string; - scssVar?: string; + options?: { label: string; value: string }[]; min?: number; max?: number; @@ -22,7 +22,7 @@ export const COLOR_TOKENS: TokenDef[] = [ labelZh: '主色', type: 'color', defaultValue: '#6e41bf', - scssVar: '$primary-color', + }, { key: 'color-success', @@ -30,7 +30,7 @@ export const COLOR_TOKENS: TokenDef[] = [ labelZh: '成功色', type: 'color', defaultValue: '#52c41a', - scssVar: '$success-color', + }, { key: 'color-warning', @@ -38,7 +38,7 @@ export const COLOR_TOKENS: TokenDef[] = [ labelZh: '警告色', type: 'color', defaultValue: '#ff9800', - scssVar: '$warning-color', + }, { key: 'color-danger', @@ -46,7 +46,7 @@ export const COLOR_TOKENS: TokenDef[] = [ labelZh: '危险色', type: 'color', defaultValue: '#f44336', - scssVar: '$danger-color', + }, { key: 'color-info', @@ -54,7 +54,7 @@ export const COLOR_TOKENS: TokenDef[] = [ labelZh: '信息色', type: 'color', defaultValue: '#1890ff', - scssVar: '$info-color', + }, { key: 'color-bg', @@ -88,7 +88,7 @@ export const FONT_TOKENS: TokenDef[] = [ type: 'font', defaultValue: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif', - scssVar: '$font-family-sans-serif', + }, { key: 'font-family-monospace', @@ -97,7 +97,7 @@ export const FONT_TOKENS: TokenDef[] = [ type: 'font', defaultValue: '"Lucida Console", Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace', - scssVar: '$font-family-monospace', + }, ]; @@ -109,7 +109,7 @@ export const TYPOGRAPHY_TOKENS: TokenDef[] = [ labelZh: '基础字号', type: 'size', defaultValue: '1rem', - scssVar: '$font-size-base', + min: 12, max: 20, step: 1, @@ -121,7 +121,7 @@ export const TYPOGRAPHY_TOKENS: TokenDef[] = [ labelZh: '小号字号', type: 'size', defaultValue: '0.875rem', - scssVar: '$font-size-sm', + min: 10, max: 16, step: 1, @@ -133,7 +133,7 @@ export const TYPOGRAPHY_TOKENS: TokenDef[] = [ labelZh: '大号字号', type: 'size', defaultValue: '1.25rem', - scssVar: '$font-size-lg', + min: 14, max: 24, step: 1, @@ -145,7 +145,7 @@ export const TYPOGRAPHY_TOKENS: TokenDef[] = [ labelZh: '字重', type: 'select', defaultValue: '400', - scssVar: '$font-weight', + options: [ { label: 'Light (300)', value: '300' }, { label: 'Regular (400)', value: '400' }, @@ -160,7 +160,7 @@ export const TYPOGRAPHY_TOKENS: TokenDef[] = [ labelZh: '行高', type: 'number', defaultValue: '1.5', - scssVar: '$line-height-base', + min: 1, max: 2.5, step: 0.1, @@ -171,7 +171,7 @@ export const TYPOGRAPHY_TOKENS: TokenDef[] = [ labelZh: '标题字重', type: 'select', defaultValue: '500', - scssVar: '$headings-font-weight', + options: [ { label: 'Regular (400)', value: '400' }, { label: 'Medium (500)', value: '500' }, @@ -200,7 +200,7 @@ export const DETAIL_TOKENS: TokenDef[] = [ labelZh: '圆角', type: 'size', defaultValue: '2px', - scssVar: '$border-radius', + min: 0, max: 20, step: 1, @@ -212,7 +212,7 @@ export const DETAIL_TOKENS: TokenDef[] = [ labelZh: '小尺寸高度', type: 'size', defaultValue: '24px', - scssVar: '$height-sm', + min: 20, max: 36, step: 2, @@ -224,7 +224,7 @@ export const DETAIL_TOKENS: TokenDef[] = [ labelZh: '中尺寸高度', type: 'size', defaultValue: '32px', - scssVar: '$height-md', + min: 28, max: 44, step: 2, @@ -236,7 +236,7 @@ export const DETAIL_TOKENS: TokenDef[] = [ labelZh: '大尺寸高度', type: 'size', defaultValue: '42px', - scssVar: '$height-lg', + min: 36, max: 56, step: 2, @@ -269,7 +269,7 @@ export const SPACING_TOKENS: TokenDef[] = [ labelZh: '基础间距', type: 'size', defaultValue: '16px', - scssVar: '$spacer', + min: 8, max: 24, step: 2, diff --git a/apps/docs/src/containers/theme-editor/utils/export-theme.ts b/apps/docs/src/containers/theme-editor/utils/export-theme.ts index 01099994..1354e99b 100644 --- a/apps/docs/src/containers/theme-editor/utils/export-theme.ts +++ b/apps/docs/src/containers/theme-editor/utils/export-theme.ts @@ -1,5 +1,3 @@ -import { ALL_TOKENS } from '../constants/default-tokens'; - /** Keys that should be excluded from CSS variable export (they are meta-seeds, not real tokens) */ const META_KEYS = new Set(['shadow-intensity']); @@ -12,30 +10,6 @@ export function generateCSS(overrides: Record): string { return `:root {\n${lines}\n}`; } -export function generateSCSS(seeds: Record): string { - const scssMap = new Map(); - for (const def of ALL_TOKENS) { - if (def.scssVar) { - scssMap.set(def.key, def.scssVar); - } - } - - const lines: string[] = []; - for (const [key, value] of Object.entries(seeds)) { - if (META_KEYS.has(key)) continue; - const scssVar = scssMap.get(key); - if (scssVar) { - lines.push(`${scssVar}: ${value};`); - } - } - - if (lines.length === 0) { - return '// No SCSS variable overrides'; - } - - return `// Override these before importing @tiny-design/tokens\n${lines.join('\n')}`; -} - export function generateJSON(seeds: Record): string { const clean: Record = {}; for (const [key, value] of Object.entries(seeds)) { diff --git a/packages/charts/src/style/index.scss b/packages/charts/src/style/index.scss index 61356808..cf5346c4 100644 --- a/packages/charts/src/style/index.scss +++ b/packages/charts/src/style/index.scss @@ -1,5 +1,3 @@ -@use '@tiny-design/tokens/scss/tokens' as *; - // ---- Chart Container ---- .ty-chart { position: relative; @@ -8,12 +6,12 @@ // Recharts text elements inherit chart-friendly styles .recharts-cartesian-axis-tick-value { - font-size: $token-font-size-sm; - fill: $token-color-text-secondary; + font-size: var(--ty-font-size-sm); + fill: var(--ty-color-text-secondary); } .recharts-cartesian-grid line { - stroke: $token-color-border-light; + stroke: var(--ty-color-border-light); } } @@ -31,25 +29,25 @@ } .ty-chart-tooltip { - background-color: $token-color-bg-elevated; + background-color: var(--ty-color-bg-elevated); backdrop-filter: blur(12px); - border: 1px solid $token-color-border-light; + border: 1px solid var(--ty-color-border-light); border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(0, 0, 0, 0.03); padding: 10px 14px; - font-size: $token-font-size-sm; + font-size: var(--ty-font-size-sm); min-width: 140px; line-height: 1.5; &__label { font-weight: 600; - color: $token-color-text; + color: var(--ty-color-text); margin-bottom: 6px; padding-bottom: 6px; - border-bottom: 1px solid $token-color-border-light; + border-bottom: 1px solid var(--ty-color-border-light); letter-spacing: -0.01em; } @@ -103,14 +101,14 @@ display: flex; align-items: center; gap: 4px; - color: $token-color-text-secondary; + color: var(--ty-color-text-secondary); white-space: nowrap; } &__item-value { font-weight: 600; font-variant-numeric: tabular-nums; - color: $token-color-text; + color: var(--ty-color-text); font-feature-settings: 'tnum'; } } @@ -121,7 +119,7 @@ flex-wrap: wrap; justify-content: center; gap: 16px; - font-size: $token-font-size-sm; + font-size: var(--ty-font-size-sm); &_top { padding-bottom: 8px; @@ -146,6 +144,6 @@ } &__label { - color: $token-color-text-secondary; + color: var(--ty-color-text-secondary); } } diff --git a/packages/extract/src/extract-tokens.ts b/packages/extract/src/extract-tokens.ts index e2698c05..83adfcbb 100644 --- a/packages/extract/src/extract-tokens.ts +++ b/packages/extract/src/extract-tokens.ts @@ -1,17 +1,17 @@ import * as fs from 'node:fs'; +import * as path from 'node:path'; import type { TokenData, ExtractTokensOptions } from './types.js'; // Map variable name prefixes to categories const CATEGORY_RULES: Array<{ test: (name: string) => boolean; category: string }> = [ - { test: (n) => /^(box-)?shadow/.test(n), category: 'shadows' }, + { test: (n) => /^shadow/.test(n), category: 'shadows' }, { test: (n) => /^size-(xs|sm|md|lg|xl|xxl)$/.test(n), category: 'breakpoints' }, { test: (n) => /^(font|line-height|heading|h\d-)/.test(n), category: 'typography' }, { test: (n) => /^(spacer|height-)/.test(n), category: 'spacing' }, { test: (n) => - /color$/.test(n) || + /^color-/.test(n) || /^(white|black|gray|red|orange|yellow|green|teal|cyan|blue|indigo|purple|magenta)-/.test(n) || - /^(info|success|warning|danger|primary)-/.test(n) || /^(body-bg|body-color)/.test(n), category: 'colors', }, @@ -25,7 +25,6 @@ function categorize(name: string): string | null { } export function extractTokens(options: ExtractTokensOptions): TokenData { - const content = fs.readFileSync(options.variablesPath, 'utf-8'); const result: TokenData = { colors: {}, typography: {}, @@ -34,11 +33,13 @@ export function extractTokens(options: ExtractTokensOptions): TokenData { shadows: {}, }; - // Match SCSS variable declarations: $name: value !default; + const variablesContent = fs.readFileSync(options.variablesPath, 'utf-8'); + + // Parse SCSS variable declarations: $name: value !default; const varRegex = /^\$([a-z0-9-]+):\s*(.+?)\s*!default\s*;/gm; let match: RegExpExecArray | null; - while ((match = varRegex.exec(content)) !== null) { + while ((match = varRegex.exec(variablesContent)) !== null) { const name = match[1]; const value = match[2]; const category = categorize(name); @@ -51,5 +52,30 @@ export function extractTokens(options: ExtractTokensOptions): TokenData { } } + // Also parse theme map files in the themes/ directory next to _variables.scss + const themesDir = path.join(path.dirname(options.variablesPath), 'themes'); + const lightThemePath = path.join(themesDir, '_light.scss'); + + if (fs.existsSync(lightThemePath)) { + const themeContent = fs.readFileSync(lightThemePath, 'utf-8'); + + // Match map entries: key: value, + // Handles multi-value entries like shadows by matching up to the trailing comma + const mapEntryRegex = /^\s+([a-z0-9-]+):\s*(.+?),?\s*$/gm; + + while ((match = mapEntryRegex.exec(themeContent)) !== null) { + const name = match[1]; + const value = match[2].replace(/,\s*$/, ''); + const category = categorize(name); + + if (category && !result[category][name]) { + result[category][name] = { + variable: `--ty-${name}`, + value, + }; + } + } + } + return result; } diff --git a/packages/mcp/__tests__/extract-tokens.test.ts b/packages/mcp/__tests__/extract-tokens.test.ts index 1c739870..df698cd4 100644 --- a/packages/mcp/__tests__/extract-tokens.test.ts +++ b/packages/mcp/__tests__/extract-tokens.test.ts @@ -18,13 +18,13 @@ describe('extractTokens', () => { it('extracts color tokens', () => { const result = extractTokens({ variablesPath: VARIABLES_PATH }); - expect(result.colors['primary-color']).toEqual({ - variable: '$primary-color', + expect(result.colors['color-primary']).toEqual({ + variable: '--ty-color-primary', value: '#6e41bf', }); - expect(result.colors['info-color']).toEqual({ - variable: '$info-color', + expect(result.colors['color-info']).toEqual({ + variable: '--ty-color-info', value: '#1890ff', }); }); @@ -33,7 +33,7 @@ describe('extractTokens', () => { const result = extractTokens({ variablesPath: VARIABLES_PATH }); expect(result.typography['font-size-base']).toEqual({ - variable: '$font-size-base', + variable: '--ty-font-size-base', value: '1rem', }); }); @@ -51,6 +51,6 @@ describe('extractTokens', () => { const result = extractTokens({ variablesPath: VARIABLES_PATH }); expect(result.shadows).toBeDefined(); - expect(result.shadows['box-shadow-sm']).toBeDefined(); + expect(result.shadows['shadow-sm']).toBeDefined(); }); }); diff --git a/packages/react/src/_utils/__tests__/use-theme.test.tsx b/packages/react/src/_utils/__tests__/use-theme.test.tsx new file mode 100644 index 00000000..1313104f --- /dev/null +++ b/packages/react/src/_utils/__tests__/use-theme.test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { useTheme } from '../use-theme'; + +const ThemeConsumer = () => { + const { mode, resolvedTheme, setMode, toggle } = useTheme(); + + return ( +
+ {mode} + {resolvedTheme} + + +
+ ); +}; + +describe('useTheme', () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute('data-tiny-theme'); + }); + + it('should initialize from the current DOM theme attribute', () => { + document.documentElement.setAttribute('data-tiny-theme', 'dark'); + + render(); + + expect(screen.getByTestId('mode').textContent).toBe('dark'); + expect(screen.getByTestId('resolved').textContent).toBe('dark'); + }); + + it('should persist theme updates to localStorage and html attribute', () => { + render(); + + act(() => { + screen.getByText('Set dark').click(); + }); + + expect(screen.getByTestId('mode').textContent).toBe('dark'); + expect(localStorage.getItem('ty-theme')).toBe('dark'); + expect(document.documentElement.getAttribute('data-tiny-theme')).toBe('dark'); + }); + + it('should react to storage events from other tabs', () => { + render(); + + act(() => { + window.dispatchEvent(new StorageEvent('storage', { + key: 'ty-theme', + newValue: 'dark', + })); + }); + + expect(screen.getByTestId('mode').textContent).toBe('dark'); + expect(document.documentElement.getAttribute('data-tiny-theme')).toBe('dark'); + }); +}); diff --git a/packages/react/src/_utils/dom.ts b/packages/react/src/_utils/dom.ts index 4d0ee98f..fb8ad9b0 100755 --- a/packages/react/src/_utils/dom.ts +++ b/packages/react/src/_utils/dom.ts @@ -56,3 +56,27 @@ export const getNodeScrollHeight = (node: Target): number => { } return (node as HTMLElement).scrollHeight; }; + +export const getScrollParents = (node: HTMLElement | null): Array => { + if (typeof window === 'undefined' || !node) { + return []; + } + + const parents = new Set(); + let current: HTMLElement | null = node.parentElement; + + while (current) { + const style = window.getComputedStyle(current); + const overflow = `${style.overflow}${style.overflowX}${style.overflowY}`; + + if (/(auto|scroll|overlay)/.test(overflow)) { + parents.add(current); + } + + current = current.parentElement; + } + + parents.add(window); + + return Array.from(parents); +}; diff --git a/packages/react/src/_utils/use-theme.ts b/packages/react/src/_utils/use-theme.ts index cba4640c..56bde0b4 100644 --- a/packages/react/src/_utils/use-theme.ts +++ b/packages/react/src/_utils/use-theme.ts @@ -1,8 +1,8 @@ import { useSyncExternalStore, useCallback } from 'react'; - -export type ThemeMode = 'light' | 'dark' | 'system'; +import type { ThemeMode } from '../config-provider/config-context'; const STORAGE_KEY = 'ty-theme'; +const THEME_ATTR = 'data-tiny-theme'; function getSystemTheme(): 'light' | 'dark' { if (typeof window === 'undefined') return 'light'; @@ -11,7 +11,13 @@ function getSystemTheme(): 'light' | 'dark' { function applyTheme(mode: ThemeMode): void { if (typeof document === 'undefined') return; - document.documentElement.setAttribute('data-tiny-theme', mode); + document.documentElement.setAttribute(THEME_ATTR, mode); +} + +function readDomTheme(): ThemeMode | null { + if (typeof document === 'undefined') return null; + const value = document.documentElement.getAttribute(THEME_ATTR); + return value === 'light' || value === 'dark' || value === 'system' ? value : null; } function readStoredTheme(): ThemeMode { @@ -19,12 +25,16 @@ function readStoredTheme(): ThemeMode { return (localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'light'; } +function readInitialTheme(): ThemeMode { + return readDomTheme() ?? readStoredTheme(); +} + // ---- Shared store ---- -let currentMode: ThemeMode = readStoredTheme(); +let currentMode: ThemeMode = readInitialTheme(); const listeners = new Set<() => void>(); function getSnapshot(): ThemeMode { - return currentMode; + return readDomTheme() ?? currentMode; } function getServerSnapshot(): ThemeMode { @@ -32,27 +42,81 @@ function getServerSnapshot(): ThemeMode { } function subscribe(cb: () => void): () => void { + const syncFromDom = () => { + const domTheme = readDomTheme(); + + if (domTheme && domTheme !== currentMode) { + currentMode = domTheme; + cb(); + } + }; + listeners.add(cb); - return () => listeners.delete(cb); + syncFromDom(); + + const observer = + typeof MutationObserver !== 'undefined' && typeof document !== 'undefined' + ? new MutationObserver(() => { + syncFromDom(); + }) + : null; + + observer?.observe(document.documentElement, { + attributes: true, + attributeFilter: [THEME_ATTR], + }); + + return () => { + listeners.delete(cb); + observer?.disconnect(); + }; } function setThemeMode(next: ThemeMode): void { currentMode = next; - localStorage.setItem(STORAGE_KEY, next); + if (typeof localStorage !== 'undefined') { + localStorage.setItem(STORAGE_KEY, next); + } applyTheme(next); listeners.forEach((cb) => cb()); } +function emit(): void { + listeners.forEach((cb) => cb()); +} + // Listen for system preference changes at module level -if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { - window - .matchMedia('(prefers-color-scheme: dark)') - .addEventListener('change', () => { +if (typeof document !== 'undefined') { + applyTheme(currentMode); +} + +if (typeof window !== 'undefined') { + if (typeof window.matchMedia === 'function') { + const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)'); + const handleSystemThemeChange = () => { if (currentMode === 'system') { - // Force re-render for all subscribers so resolvedTheme updates - listeners.forEach((cb) => cb()); + emit(); } - }); + }; + + if (typeof mediaQueryList.addEventListener === 'function') { + mediaQueryList.addEventListener('change', handleSystemThemeChange); + } else if (typeof mediaQueryList.addListener === 'function') { + mediaQueryList.addListener(handleSystemThemeChange); + } + } + + window.addEventListener('storage', (event) => { + if (event.key !== STORAGE_KEY) { + return; + } + + currentMode = event.newValue === 'light' || event.newValue === 'dark' || event.newValue === 'system' + ? event.newValue + : 'light'; + applyTheme(currentMode); + emit(); + }); } // ---- Hook ---- diff --git a/packages/react/src/alert/index.tsx b/packages/react/src/alert/index.tsx index 15db282c..81e1f061 100755 --- a/packages/react/src/alert/index.tsx +++ b/packages/react/src/alert/index.tsx @@ -1,3 +1,4 @@ import Alert from './alert'; export default Alert; +export type * from './types'; diff --git a/packages/react/src/anchor/anchor.tsx b/packages/react/src/anchor/anchor.tsx index 0ab4ca91..64ae2ebb 100755 --- a/packages/react/src/anchor/anchor.tsx +++ b/packages/react/src/anchor/anchor.tsx @@ -1,6 +1,7 @@ import React, { useContext, useState, useCallback, useEffect, useRef, useMemo } from 'react'; import classNames from 'classnames'; import { ConfigContext } from '../config-provider/config-context'; +import { resolveTargetContainer } from '../config-provider/container-utils'; import { getPrefixCls } from '../_utils/general'; import { AnchorLinkProps, AnchorProps } from './types'; import { AnchorContext } from './anchor-context'; @@ -59,16 +60,17 @@ const Anchor = (props: AnchorProps): JSX.Element => { }, [prefixCls, type]); const getScrollContainer = useCallback((): HTMLElement | Window => { - return getContainer ? getContainer() : window; - }, [getContainer]); + return resolveTargetContainer(configContext, getContainer); + }, [configContext, getContainer]); const scrollToAnchor = useCallback( (anchorName: string): void => { const element = document.getElementById(anchorName); if (!element) return; - if (getContainer) { - const container = getContainer(); + const scrollContainer = resolveTargetContainer(configContext, getContainer); + if (scrollContainer && scrollContainer !== window) { + const container = scrollContainer as HTMLElement; const containerRect = container.getBoundingClientRect(); const elementRect = element.getBoundingClientRect(); container.scrollTo({ @@ -79,7 +81,7 @@ const Anchor = (props: AnchorProps): JSX.Element => { element.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }, - [getContainer] + [configContext, getContainer] ); const handleLinkClick = (e: React.MouseEvent, anchorName: string) => { @@ -115,7 +117,8 @@ const Anchor = (props: AnchorProps): JSX.Element => { const links = linksRef.current; if (links.size === 0) return; - const container = getContainer?.(); + const targetContainer = resolveTargetContainer(configContext, getContainer); + const container = targetContainer && targetContainer !== window ? (targetContainer as HTMLElement) : undefined; const containerTop = container ? container.getBoundingClientRect().top : 0; @@ -171,7 +174,7 @@ const Anchor = (props: AnchorProps): JSX.Element => { } return newActiveId; }); - }, [onChange, getContainer, offsetTop]); + }, [configContext, onChange, getContainer, offsetTop]); useEffect(() => { updateInk(); diff --git a/packages/react/src/anchor/index.tsx b/packages/react/src/anchor/index.tsx index 4b9527a8..dff9c002 100755 --- a/packages/react/src/anchor/index.tsx +++ b/packages/react/src/anchor/index.tsx @@ -9,3 +9,4 @@ const DefaultAnchor = Anchor as IAnchor; DefaultAnchor.Link = AnchorLink; export default DefaultAnchor; +export type * from './types'; diff --git a/packages/react/src/anchor/types.ts b/packages/react/src/anchor/types.ts index d3c840df..41b6216d 100644 --- a/packages/react/src/anchor/types.ts +++ b/packages/react/src/anchor/types.ts @@ -6,7 +6,7 @@ export interface AnchorProps extends BaseProps { type?: 'dot' | 'line'; offsetBottom?: number; offsetTop?: number; - getContainer?: () => HTMLElement; + getContainer?: () => HTMLElement | Window; onChange?: (currentActiveLink: string) => void; onClick?: (e: React.MouseEvent, link: { title: string; href: string }) => void; children?: React.ReactNode; diff --git a/packages/react/src/aspect-ratio/index.tsx b/packages/react/src/aspect-ratio/index.tsx index 152797a0..dd9e3928 100755 --- a/packages/react/src/aspect-ratio/index.tsx +++ b/packages/react/src/aspect-ratio/index.tsx @@ -1,3 +1,4 @@ import AspectRatio from './aspect-ratio'; export default AspectRatio; +export type * from './types'; diff --git a/packages/react/src/auto-complete/index.tsx b/packages/react/src/auto-complete/index.tsx index 364f5c7a..30407043 100755 --- a/packages/react/src/auto-complete/index.tsx +++ b/packages/react/src/auto-complete/index.tsx @@ -1,4 +1,4 @@ import AutoComplete from './auto-complete'; export default AutoComplete; -export type { AutoCompleteProps, AutoCompleteOption } from './types'; +export type * from './types'; diff --git a/packages/react/src/avatar/index.tsx b/packages/react/src/avatar/index.tsx index eefdefa8..9abad82c 100755 --- a/packages/react/src/avatar/index.tsx +++ b/packages/react/src/avatar/index.tsx @@ -9,3 +9,4 @@ const DefaultAvatar = Avatar as IAvatar; DefaultAvatar.Group = AvatarGroup; export default DefaultAvatar; +export type * from './types'; diff --git a/packages/react/src/avatar/style/_index.scss b/packages/react/src/avatar/style/_index.scss index 6ac7cb2b..2867ee62 100755 --- a/packages/react/src/avatar/style/_index.scss +++ b/packages/react/src/avatar/style/_index.scss @@ -6,8 +6,8 @@ justify-content: center; align-items: center; text-align: center; - background: $avatar-bg; - color: $avatar-color; + background: var(--ty-avatar-bg); + color: var(--ty-avatar-color); white-space: nowrap; position: relative; vertical-align: middle; @@ -48,7 +48,7 @@ } &_offline { - background-color: $gray-400; + background-color: var(--ty-avatar-offline-color); } } diff --git a/packages/react/src/back-top/__tests__/back-top.test.tsx b/packages/react/src/back-top/__tests__/back-top.test.tsx index 61a9ad89..f4cce974 100644 --- a/packages/react/src/back-top/__tests__/back-top.test.tsx +++ b/packages/react/src/back-top/__tests__/back-top.test.tsx @@ -1,5 +1,6 @@ -import { render } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; import BackTop from '../index'; +import ConfigProvider from '../../config-provider'; describe('', () => { it('should match the snapshot', () => { @@ -11,4 +12,26 @@ describe('', () => { const { container } = render(); expect(container).toBeEmptyDOMElement(); }); + + it('should use the configured target container by default', () => { + const container = document.createElement('div'); + Object.defineProperty(container, 'scrollTop', { + value: 400, + writable: true, + }); + document.body.appendChild(container); + + const { getByRole } = render( + container}> + + + ); + + expect(getByRole('button', { name: 'Back to top' })).toBeInTheDocument(); + + fireEvent.click(getByRole('button', { name: 'Back to top' })); + expect(container.scrollTop).toBeLessThanOrEqual(400); + + document.body.removeChild(container); + }); }); diff --git a/packages/react/src/back-top/back-top.tsx b/packages/react/src/back-top/back-top.tsx index f4bab5a5..fd7d0a84 100755 --- a/packages/react/src/back-top/back-top.tsx +++ b/packages/react/src/back-top/back-top.tsx @@ -1,7 +1,8 @@ import React, { useCallback, useContext, useEffect, useState } from 'react'; import classNames from 'classnames'; -import { Target } from '../_utils/dom'; +import { getScroll, Target } from '../_utils/dom'; import { ConfigContext } from '../config-provider/config-context'; +import { resolveTargetContainer } from '../config-provider/container-utils'; import { getPrefixCls } from '../_utils/general'; import { BackTopProps } from './types'; @@ -18,7 +19,7 @@ const easeInOutCubic = (t: number, b: number, c: number, d: number): number => { const BackTop = (props: BackTopProps): JSX.Element | null => { const { visibilityHeight = 300, - target = (): Target => window, + target, prefixCls: customisedCls, onClick, className, @@ -31,17 +32,17 @@ const BackTop = (props: BackTopProps): JSX.Element | null => { [`${prefixCls}_custom`]: !!children, }); const [visible, setVisible] = useState(true); + const resolvedTarget = useCallback( + (): Target => resolveTargetContainer(configContext, target), + [configContext, target] + ); const getDistanceFromTop = useCallback((): number => { - const targetNode = target(); - if (targetNode === window) { - return window.pageYOffset || document.body.scrollTop || document.documentElement.scrollTop; - } - return (targetNode as HTMLElement).scrollTop; - }, [target]); + return getScroll(resolvedTarget(), true); + }, [resolvedTarget]); const setScrollToTop = (distance: number): void => { - const targetNode = target(); + const targetNode = resolvedTarget(); if (targetNode === window) { document.body.scrollTop = distance; document.documentElement.scrollTop = distance; @@ -76,14 +77,14 @@ const BackTop = (props: BackTopProps): JSX.Element | null => { }, [getDistanceFromTop, visible, visibilityHeight]); useEffect(() => { - const targetNode = target(); + const targetNode = resolvedTarget(); targetNode.addEventListener('scroll', handleOnScroll); handleOnScroll(); return (): void => { targetNode.removeEventListener('scroll', handleOnScroll); }; - }, [target, handleOnScroll]); + }, [resolvedTarget, handleOnScroll]); if (visible) { return ( diff --git a/packages/react/src/back-top/index.md b/packages/react/src/back-top/index.md index 67525982..c5ada677 100644 --- a/packages/react/src/back-top/index.md +++ b/packages/react/src/back-top/index.md @@ -49,8 +49,8 @@ Use custom children to replace the default button. | Property | Description | Type | Default | | ----------------- | ----------------------------------------------------------------------------- | ----------------- | ------------- | -| target | Specifies the scrollable area dom node | () => HTMLElement | () => window | +| target | Specifies the scrollable area dom node. Defaults to `ConfigProvider.getTargetContainer()` when available. | () => HTMLElement | Window | provider target container | | visibilityHeight | The `BackTop` button will not show until the scroll height reaches this value | number | 300 | | onClick | A callback function, which can be executed when you click the button | () => void | - | | style | Style object of container object | CSSProperties | - | -| className | ClassName of container | string | - | \ No newline at end of file +| className | ClassName of container | string | - | diff --git a/packages/react/src/back-top/index.tsx b/packages/react/src/back-top/index.tsx index 3d0dcfbe..bdd70377 100755 --- a/packages/react/src/back-top/index.tsx +++ b/packages/react/src/back-top/index.tsx @@ -1,3 +1,4 @@ import BackTop from './back-top'; export default BackTop; +export type * from './types'; diff --git a/packages/react/src/back-top/index.zh_CN.md b/packages/react/src/back-top/index.zh_CN.md index 4777e99c..42d5a560 100644 --- a/packages/react/src/back-top/index.zh_CN.md +++ b/packages/react/src/back-top/index.zh_CN.md @@ -49,8 +49,8 @@ import { BackTop } from 'tiny-design'; | 属性 | 说明 | 类型 | 默认值 | | ----------------- | ------------------------------------------------------------------------- | ----------------- | ------------- | -| target | 指定可滚动区域的 DOM 节点 | () => HTMLElement | () => window | +| target | 指定可滚动区域的 DOM 节点;未设置时会优先使用 `ConfigProvider.getTargetContainer()` | () => HTMLElement | Window | provider target container | | visibilityHeight | 滚动高度达到此值时才出现 `BackTop` 按钮 | number | 300 | | onClick | 点击按钮时的回调 | () => void | - | | style | 容器的样式对象 | CSSProperties | - | -| className | 容器的类名 | string | - | \ No newline at end of file +| className | 容器的类名 | string | - | diff --git a/packages/react/src/badge/index.tsx b/packages/react/src/badge/index.tsx index c3a5ecd5..826d54f0 100755 --- a/packages/react/src/badge/index.tsx +++ b/packages/react/src/badge/index.tsx @@ -1,3 +1,4 @@ import Badge from './badge'; export default Badge; +export type * from './types'; diff --git a/packages/react/src/breadcrumb/index.tsx b/packages/react/src/breadcrumb/index.tsx index 77670551..7979647e 100755 --- a/packages/react/src/breadcrumb/index.tsx +++ b/packages/react/src/breadcrumb/index.tsx @@ -9,3 +9,4 @@ const DefaultBreadcrumb = Breadcrumb as IBreadcrumb; DefaultBreadcrumb.Item = BreadcrumbItem; export default DefaultBreadcrumb; +export type * from './types'; diff --git a/packages/react/src/breadcrumb/style/_index.scss b/packages/react/src/breadcrumb/style/_index.scss index f8f55d30..82000651 100755 --- a/packages/react/src/breadcrumb/style/_index.scss +++ b/packages/react/src/breadcrumb/style/_index.scss @@ -8,7 +8,7 @@ display: flex; align-items: center; color: var(--ty-color-text-tertiary); - font-size: $breadcrumb-font-size; + font-size: var(--ty-font-size-base); } &-item { diff --git a/packages/react/src/button/index.tsx b/packages/react/src/button/index.tsx index 2876c127..a136aaa6 100755 --- a/packages/react/src/button/index.tsx +++ b/packages/react/src/button/index.tsx @@ -9,3 +9,4 @@ const DefaultButton = Button as IButton; DefaultButton.Group = ButtonGroup; export default DefaultButton; +export type * from './types'; diff --git a/packages/react/src/button/style/_index.scss b/packages/react/src/button/style/_index.scss index 90ed5dfc..09dcb6e5 100755 --- a/packages/react/src/button/style/_index.scss +++ b/packages/react/src/button/style/_index.scss @@ -14,14 +14,14 @@ $btn-prefix: #{$prefix}-btn; display: inline-flex; justify-content: center; align-items: center; - min-width: 50px; + min-width: var(--ty-btn-min-width); vertical-align: middle; text-decoration: none; white-space: nowrap; user-select: none; - border-radius: $btn-border-radius; + border-radius: var(--ty-btn-border-radius, var(--ty-border-radius)); transition: $btn-transition; - line-height: $btn-line-height; + line-height: var(--ty-btn-line-height, var(--ty-line-height-base)); &__loader { @include loader; @@ -31,7 +31,7 @@ $btn-prefix: #{$prefix}-btn; display: inline-block; flex-shrink: 0; pointer-events: none; - line-height: $btn-line-height; + line-height: var(--ty-btn-line-height, var(--ty-line-height-base)); vertical-align: middle; & + span { @@ -117,15 +117,15 @@ $btn-prefix: #{$prefix}-btn; // Sizes &_sm { - @include btn-size($btn-padding-sm, $btn-font-size-sm, $btn-height-sm); + @include btn-size($btn-padding-sm, var(--ty-btn-font-size-sm, var(--ty-font-size-sm)), var(--ty-btn-height-sm, var(--ty-height-sm))); } &_md { - @include btn-size($btn-padding-md, $btn-font-size-md, $btn-height-md); + @include btn-size($btn-padding-md, var(--ty-btn-font-size, var(--ty-font-size-base)), var(--ty-btn-height-md, var(--ty-height-md))); } &_lg { - @include btn-size($btn-padding-lg, $btn-font-size-lg, $btn-height-lg); + @include btn-size($btn-padding-lg, var(--ty-btn-font-size-lg, var(--ty-font-size-lg)), var(--ty-btn-height-lg, var(--ty-height-lg))); } &_block { @@ -137,7 +137,7 @@ $btn-prefix: #{$prefix}-btn; } &_round { - border-radius: $btn-height-lg; + border-radius: var(--ty-height-lg); } &_loading { @@ -166,7 +166,7 @@ $btn-prefix: #{$prefix}-btn; display: inline-block; & + .#{$btn-prefix}-group { - margin-left: 10px; + margin-left: var(--ty-btn-group-gap); } .#{$btn-prefix} { @@ -183,26 +183,26 @@ $btn-prefix: #{$prefix}-btn; } &:first-child { - border-top-left-radius: $btn-border-radius; - border-bottom-left-radius: $btn-border-radius; + border-top-left-radius: var(--ty-btn-border-radius, var(--ty-border-radius)); + border-bottom-left-radius: var(--ty-btn-border-radius, var(--ty-border-radius)); } &:last-child { - border-top-right-radius: $btn-border-radius; - border-bottom-right-radius: $btn-border-radius; + border-top-right-radius: var(--ty-btn-border-radius, var(--ty-border-radius)); + border-bottom-right-radius: var(--ty-btn-border-radius, var(--ty-border-radius)); } } &_round { .#{$btn-prefix} { &:first-child { - border-top-left-radius: 30px; - border-bottom-left-radius: 30px; + border-top-left-radius: var(--ty-btn-round-radius); + border-bottom-left-radius: var(--ty-btn-round-radius); } &:last-child { - border-top-right-radius: 30px; - border-bottom-right-radius: 30px; + border-top-right-radius: var(--ty-btn-round-radius); + border-bottom-right-radius: var(--ty-btn-round-radius); } } } @@ -214,7 +214,7 @@ $btn-prefix: #{$prefix}-btn; &_danger { .#{$btn-prefix} { &:not(:first-child) { - border-left-color: rgb(255 255 255 / 20%); + border-left-color: var(--ty-btn-group-divider-color); } } } diff --git a/packages/react/src/calendar/index.tsx b/packages/react/src/calendar/index.tsx index 278ac9d2..9e3618ac 100755 --- a/packages/react/src/calendar/index.tsx +++ b/packages/react/src/calendar/index.tsx @@ -1,3 +1,4 @@ import Calendar from './calendar'; export default Calendar; +export type * from './types'; diff --git a/packages/react/src/calendar/style/_index.scss b/packages/react/src/calendar/style/_index.scss index f7a0819a..8191de2f 100644 --- a/packages/react/src/calendar/style/_index.scss +++ b/packages/react/src/calendar/style/_index.scss @@ -1,8 +1,8 @@ @use '@tiny-design/tokens/scss/variables' as *; .#{$prefix}-calendar { - background: var(--ty-calendar-bg, #fff); - border: 1px solid var(--ty-calendar-border, #{$gray-200}); + background: var(--ty-calendar-bg); + border: 1px solid var(--ty-calendar-border); border-radius: var(--ty-border-radius); outline: none; @@ -21,7 +21,7 @@ align-items: center; justify-content: space-between; padding: 8px 12px; - border-bottom: 1px solid var(--ty-calendar-border, #{$gray-200}); + border-bottom: 1px solid var(--ty-calendar-border); } &__header-nav { @@ -69,7 +69,7 @@ font-size: inherit; padding: 2px 6px; border-radius: 4px; - color: var(--ty-color-text, #{$gray-800}); + color: var(--ty-color-text); transition: color 0.2s; &:hover { @@ -96,14 +96,14 @@ text-align: center; font-weight: 500; font-size: var(--ty-font-size-sm); - color: var(--ty-color-text-secondary, #{$gray-600}); + color: var(--ty-color-text-secondary); } // ── Week number ───────────────────────────────────────────────────────── &__week-number-header { width: 32px; - color: var(--ty-color-text-secondary, #{$gray-400}); + color: var(--ty-color-text-quaternary); font-weight: 400; font-size: 12px; } @@ -111,7 +111,7 @@ &__week-number { text-align: center; font-size: 12px; - color: var(--ty-color-text-secondary, #{$gray-400}); + color: var(--ty-color-text-quaternary); padding: 4px 0; user-select: none; } @@ -134,7 +134,7 @@ } &:not(&_disabled):hover .#{$prefix}-calendar__cell-inner { - background: var(--ty-calendar-hover, #{$gray-100}); + background: var(--ty-calendar-hover); } &_selected:not(&_disabled):hover .#{$prefix}-calendar__cell-inner { @@ -142,11 +142,11 @@ } &_in-view:not(&_disabled) { - color: var(--ty-color-text, #{$gray-900}); + color: var(--ty-color-text); } &:not(&_in-view) { - color: var(--ty-color-text-quaternary, #{$gray-300}); + color: var(--ty-color-text-quaternary); .#{$prefix}-calendar__cell-dot { opacity: 0.4; @@ -159,7 +159,7 @@ } &_selected .#{$prefix}-calendar__cell-inner { - background: var(--ty-color-primary, #{$primary-color}); + background: var(--ty-color-primary); color: #fff; border-radius: var(--ty-border-radius); } @@ -171,27 +171,27 @@ // ── Range selection ───────────────────────────────────────────────── &_in-range { - background: var(--ty-color-primary-bg, rgba($primary-color, 0.08)); + background: var(--ty-color-primary-bg); } &_range-start { border-radius: var(--ty-border-radius) 0 0 var(--ty-border-radius); - background: var(--ty-color-primary-bg, rgba($primary-color, 0.08)); + background: var(--ty-color-primary-bg); } &_range-start .#{$prefix}-calendar__cell-inner { - background: var(--ty-color-primary, #{$primary-color}); + background: var(--ty-color-primary); color: #fff; border-radius: var(--ty-border-radius); } &_range-end { border-radius: 0 var(--ty-border-radius) var(--ty-border-radius) 0; - background: var(--ty-color-primary-bg, rgba($primary-color, 0.08)); + background: var(--ty-color-primary-bg); } &_range-end .#{$prefix}-calendar__cell-inner { - background: var(--ty-color-primary, #{$primary-color}); + background: var(--ty-color-primary); color: #fff; border-radius: var(--ty-border-radius); } @@ -204,7 +204,7 @@ &_focused .#{$prefix}-calendar__cell-inner, &:focus-visible .#{$prefix}-calendar__cell-inner { - outline: 2px solid var(--ty-color-primary, #{$primary-color}); + outline: 2px solid var(--ty-color-primary); outline-offset: 1px; } } @@ -250,7 +250,7 @@ width: 6px; height: 6px; border-radius: 50%; - background-color: var(--ty-color-primary, #{$primary-color}); + background-color: var(--ty-color-primary); } // ── Month panel (year mode) ───────────────────────────────────────────── @@ -270,15 +270,15 @@ transition: all 0.2s; &:hover { - background: var(--ty-calendar-hover, #{$gray-100}); + background: var(--ty-calendar-hover); } &_selected { - background: var(--ty-color-primary, #{$primary-color}); + background: var(--ty-color-primary); color: #fff; &:hover { - background: var(--ty-color-primary, #{$primary-color}); + background: var(--ty-color-primary); opacity: 0.9; } } @@ -313,21 +313,21 @@ font-size: var(--ty-font-size-base); &:hover { - background: var(--ty-calendar-hover, #{$gray-100}); + background: var(--ty-calendar-hover); } &_selected { - background: var(--ty-color-primary, #{$primary-color}); + background: var(--ty-color-primary); color: #fff; &:hover { - background: var(--ty-color-primary, #{$primary-color}); + background: var(--ty-color-primary); opacity: 0.9; } } &_out { - color: var(--ty-color-text-secondary, #{$gray-400}); + color: var(--ty-color-text-quaternary); } } @@ -335,7 +335,7 @@ &__footer { padding: 8px 12px; - border-top: 1px solid var(--ty-calendar-border, #{$gray-200}); + border-top: 1px solid var(--ty-calendar-border); text-align: center; } @@ -348,4 +348,4 @@ opacity: 0.8; } } -} \ No newline at end of file +} diff --git a/packages/react/src/card/index.tsx b/packages/react/src/card/index.tsx index 8a9a485c..16060e29 100755 --- a/packages/react/src/card/index.tsx +++ b/packages/react/src/card/index.tsx @@ -1,8 +1,6 @@ import Card from './card'; import CardContent from './card-content'; -export type { CardVariant, CardProps } from './types'; - type ICard = typeof Card & { Content: typeof CardContent; }; @@ -11,3 +9,4 @@ const DefaultCard = Card as ICard; DefaultCard.Content = CardContent; export default DefaultCard; +export type * from './types'; diff --git a/packages/react/src/card/style/_index.scss b/packages/react/src/card/style/_index.scss index 1a307a3b..6f99f8ac 100644 --- a/packages/react/src/card/style/_index.scss +++ b/packages/react/src/card/style/_index.scss @@ -5,12 +5,12 @@ box-sizing: border-box; padding: 0; margin: 0; - border-radius: $card-border-radius; + border-radius: var(--ty-card-border-radius, var(--ty-border-radius)); transition: all 0.3s; background-color: var(--ty-card-bg); & > img:first-child { - border-radius: $card-border-radius $card-border-radius 0 0; + border-radius: var(--ty-card-border-radius, var(--ty-border-radius)) var(--ty-card-border-radius, var(--ty-border-radius)) 0 0; } &_outlined { @@ -43,11 +43,11 @@ justify-content: space-between; padding: $card-header-padding; color: var(--ty-card-header-color); - font-weight: 500; - font-size: 16px; + font-weight: var(--ty-card-header-font-weight); + font-size: var(--ty-card-header-font-size); background: transparent; border-bottom: 1px solid var(--ty-card-border); - border-radius: $card-border-radius $card-border-radius 0 0; + border-radius: var(--ty-card-border-radius, var(--ty-border-radius)) var(--ty-card-border-radius, var(--ty-border-radius)) 0 0; } &__body { diff --git a/packages/react/src/carousel/index.tsx b/packages/react/src/carousel/index.tsx index 31b41c7c..c5907ab6 100755 --- a/packages/react/src/carousel/index.tsx +++ b/packages/react/src/carousel/index.tsx @@ -1,7 +1,7 @@ import Carousel from './carousel'; import CarouselItem from './carousel-item'; -export type { CarouselProps, CarouselRef, DotPlacement, CarouselEffect } from './types'; +export type * from './types'; export type { CarouselItemProps } from './carousel-item'; type ICarousel = typeof Carousel & { diff --git a/packages/react/src/cascader/__tests__/cascader.test.tsx b/packages/react/src/cascader/__tests__/cascader.test.tsx index 2acf6ec2..21a2e223 100644 --- a/packages/react/src/cascader/__tests__/cascader.test.tsx +++ b/packages/react/src/cascader/__tests__/cascader.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; import Cascader from '../index'; +import ConfigProvider from '../../config-provider'; const options = [ { @@ -81,4 +82,25 @@ describe('', () => { ); expect(getByText('Zhejiang / Hangzhou / West Lake')).toBeInTheDocument(); }); + + it('should respect the configured popup container', () => { + const popupContainer = document.createElement('div'); + document.body.appendChild(popupContainer); + + const { container } = render( + popupContainer}> + + + ); + + const selector = container.querySelector('.ty-cascader__selector'); + fireEvent.click(selector!); + + const dropdown = popupContainer.querySelector('.ty-cascader__dropdown'); + + expect(dropdown).toBeTruthy(); + expect(dropdown?.parentElement).toBe(popupContainer); + + document.body.removeChild(popupContainer); + }); }); diff --git a/packages/react/src/cascader/cascader.tsx b/packages/react/src/cascader/cascader.tsx index eef8af5a..925d68f2 100644 --- a/packages/react/src/cascader/cascader.tsx +++ b/packages/react/src/cascader/cascader.tsx @@ -1,9 +1,11 @@ -import React, { useState, useEffect, useRef, useContext, useMemo } from 'react'; -import { createPortal } from 'react-dom'; +import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react'; import classNames from 'classnames'; import { ConfigContext } from '../config-provider/config-context'; +import { resolvePopupContainer } from '../config-provider/container-utils'; +import { getScrollParents } from '../_utils/dom'; import { getPrefixCls } from '../_utils/general'; import { ArrowDown } from '../_utils/components'; +import Portal from '../portal'; import { CascaderProps, CascaderOption, CascaderValue } from './types'; const getOptionsByValue = ( @@ -67,6 +69,33 @@ const Cascader = React.forwardRef((props, ref) => } }, [props.open]); + const updateDropdownPosition = useCallback(() => { + if (!open || !wrapperRef.current || !dropdownRef.current) { + return; + } + + const rect = wrapperRef.current.getBoundingClientRect(); + const offsetParent = dropdownRef.current.offsetParent; + + if (offsetParent && offsetParent instanceof HTMLElement) { + const offsetParentRect = offsetParent.getBoundingClientRect(); + setDropdownStyle({ + position: 'absolute', + top: rect.bottom - offsetParentRect.top + offsetParent.scrollTop + 4, + left: rect.left - offsetParentRect.left + offsetParent.scrollLeft, + zIndex: 1050, + }); + return; + } + + setDropdownStyle({ + position: 'absolute', + top: rect.bottom + window.scrollY + 4, + left: rect.left + window.scrollX, + zIndex: 1050, + }); + }, [open]); + // Build columns from selected value on mount useEffect(() => { const cols: CascaderOption[][] = [options]; @@ -84,18 +113,30 @@ const Cascader = React.forwardRef((props, ref) => setActiveColumns(cols); }, [options, selectedValue]); - // Position dropdown below selector useEffect(() => { - if (open && wrapperRef.current) { - const rect = wrapperRef.current.getBoundingClientRect(); - setDropdownStyle({ - position: 'absolute', - top: rect.bottom + 4 + window.scrollY, - left: rect.left + window.scrollX, - zIndex: 1050, - }); + if (!open) { + return undefined; } - }, [open]); + + const popupContainer = resolvePopupContainer(configContext, wrapperRef.current); + const scrollTargets = new Set([ + ...getScrollParents(wrapperRef.current), + ...getScrollParents(popupContainer), + ]); + + updateDropdownPosition(); + window.addEventListener('resize', updateDropdownPosition); + scrollTargets.forEach((target) => { + target.addEventListener('scroll', updateDropdownPosition); + }); + + return () => { + window.removeEventListener('resize', updateDropdownPosition); + scrollTargets.forEach((target) => { + target.removeEventListener('scroll', updateDropdownPosition); + }); + }; + }, [configContext, open, updateDropdownPosition]); // Click outside useEffect(() => { @@ -195,7 +236,8 @@ const Cascader = React.forwardRef((props, ref) => }); const dropdown = open - ? createPortal( + ? ( +
{activeColumns.map((columnOptions, level) => ( @@ -232,9 +274,9 @@ const Cascader = React.forwardRef((props, ref) => ))}
-
, - document.body - ) + +
+ ) : null; return ( diff --git a/packages/react/src/cascader/index.tsx b/packages/react/src/cascader/index.tsx index 72c46dd6..67fba37a 100644 --- a/packages/react/src/cascader/index.tsx +++ b/packages/react/src/cascader/index.tsx @@ -1,3 +1,4 @@ import Cascader from './cascader'; export default Cascader; +export type * from './types'; diff --git a/packages/react/src/cascader/style/_index.scss b/packages/react/src/cascader/style/_index.scss index c2aecc38..fd50c0dd 100644 --- a/packages/react/src/cascader/style/_index.scss +++ b/packages/react/src/cascader/style/_index.scss @@ -32,21 +32,21 @@ display: flex; align-items: center; width: 100%; - border: 1px solid var(--ty-cascader-border, #{$gray-300}); + border: 1px solid var(--ty-cascader-border); border-radius: var(--ty-border-radius); - background: var(--ty-cascader-bg, #fff); + background: var(--ty-cascader-bg); cursor: pointer; transition: all 0.2s; position: relative; &:hover { - border-color: var(--ty-color-primary, #{$primary-color}); + border-color: var(--ty-color-primary); } } &_open &__selector { - border-color: var(--ty-color-primary, #{$primary-color}); - box-shadow: 0 0 0 2px rgba($primary-color, 0.1); + border-color: var(--ty-color-primary); + box-shadow: var(--ty-input-focus-shadow); } &__display { @@ -54,18 +54,18 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--ty-color-text, #{$gray-800}); + color: var(--ty-color-text); } &__placeholder { - color: var(--ty-color-text-secondary, #{$gray-400}); + color: var(--ty-color-text-placeholder); } &__clear { position: absolute; right: 24px; font-size: 12px; - color: var(--ty-color-text-secondary, #{$gray-400}); + color: var(--ty-color-text-tertiary); cursor: pointer; display: none; @@ -74,7 +74,7 @@ } &:hover { - color: var(--ty-color-text, #{$gray-600}); + color: var(--ty-color-text-secondary); } } @@ -82,7 +82,7 @@ position: absolute; right: 8px; font-size: 12px; - color: var(--ty-color-text-secondary, #{$gray-400}); + color: var(--ty-color-text-tertiary); transition: transform 0.2s; display: inline-flex; align-items: center; @@ -94,7 +94,7 @@ } &__dropdown { - background: var(--ty-cascader-dropdown-bg, #fff); + background: var(--ty-cascader-dropdown-bg); border-radius: var(--ty-border-radius); box-shadow: $select-dropdown-shadow; font-size: var(--ty-font-size-base); @@ -113,13 +113,13 @@ overflow-y: auto; &:not(:last-child) { - border-right: 1px solid var(--ty-cascader-border, #{$gray-200}); + border-right: 1px solid var(--ty-cascader-border); } } &__menu-empty { padding: 8px 12px; - color: var(--ty-color-text-secondary, #{$gray-500}); + color: var(--ty-color-text-secondary); text-align: center; } @@ -132,13 +132,13 @@ transition: background 0.15s; &:hover:not(&_disabled) { - background: var(--ty-cascader-hover, #{$gray-100}); + background: var(--ty-cascader-hover); } &_active { - color: var(--ty-color-primary, #{$primary-color}); + color: var(--ty-color-primary); font-weight: $select-selected-font-weight; - background: var(--ty-cascader-selected-bg, rgba($primary-color, 0.06)); + background: var(--ty-cascader-selected-bg); } &_disabled { @@ -157,6 +157,6 @@ &__menu-item-arrow { margin-left: 8px; font-size: 12px; - color: var(--ty-color-text-secondary, #{$gray-400}); + color: var(--ty-color-text-tertiary); } } diff --git a/packages/react/src/checkbox/index.tsx b/packages/react/src/checkbox/index.tsx index 19ea4ccd..e688b1ac 100755 --- a/packages/react/src/checkbox/index.tsx +++ b/packages/react/src/checkbox/index.tsx @@ -9,3 +9,4 @@ const DefaultCheckbox = Checkbox as ICheckbox; DefaultCheckbox.Group = CheckboxGroup; export default DefaultCheckbox; +export type * from './types'; diff --git a/packages/react/src/col/index.tsx b/packages/react/src/col/index.tsx index 0c0af4de..f2f3904a 100755 --- a/packages/react/src/col/index.tsx +++ b/packages/react/src/col/index.tsx @@ -1,3 +1,4 @@ import Col from '../grid/col'; export default Col; +export type { ColProps, ColSize } from '../grid/types'; diff --git a/packages/react/src/collapse/style/_index.scss b/packages/react/src/collapse/style/_index.scss index 609ad677..53aebf48 100755 --- a/packages/react/src/collapse/style/_index.scss +++ b/packages/react/src/collapse/style/_index.scss @@ -2,7 +2,7 @@ .#{$prefix}-collapse { box-sizing: border-box; - border-radius: $collapse-border-radius; + border-radius: var(--ty-border-radius); color: var(--ty-color-text); font-size: 14px; border: 1px solid var(--ty-collapse-border); @@ -15,10 +15,10 @@ border-bottom: 1px solid var(--ty-collapse-border); &:last-child { - border-radius: 0 0 $collapse-border-radius $collapse-border-radius; + border-radius: 0 0 var(--ty-border-radius) var(--ty-border-radius); .#{$prefix}-collapse-item__content { - border-radius: 0 0 $collapse-border-radius $collapse-border-radius; + border-radius: 0 0 var(--ty-border-radius) var(--ty-border-radius); } } diff --git a/packages/react/src/color-picker/index.tsx b/packages/react/src/color-picker/index.tsx index 9d00b562..e6c556fa 100644 --- a/packages/react/src/color-picker/index.tsx +++ b/packages/react/src/color-picker/index.tsx @@ -1,3 +1,4 @@ import ColorPicker from './color-picker'; export default ColorPicker; +export type * from './types'; diff --git a/packages/react/src/color-picker/style/_index.scss b/packages/react/src/color-picker/style/_index.scss index 45db4054..e62ac489 100644 --- a/packages/react/src/color-picker/style/_index.scss +++ b/packages/react/src/color-picker/style/_index.scss @@ -24,7 +24,7 @@ width: 32px; height: 32px; border-radius: var(--ty-border-radius); - border: 1px solid var(--ty-color-picker-border, #{$gray-300}); + border: 1px solid var(--ty-color-picker-border); padding: 3px; cursor: pointer; } @@ -37,7 +37,7 @@ &__panel { padding: 12px; - background: var(--ty-color-picker-bg, #fff); + background: var(--ty-color-picker-bg); border-radius: 8px; box-shadow: 0 6px 16px 0 rgb(0 0 0 / 8%), 0 3px 6px -4px rgb(0 0 0 / 12%), 0 9px 28px 8px rgb(0 0 0 / 5%); width: 240px; @@ -86,7 +86,7 @@ width: 28px; height: 28px; border-radius: 50%; - border: 1px solid var(--ty-color-picker-border, #{$gray-300}); + border: 1px solid var(--ty-color-picker-border); flex-shrink: 0; } @@ -116,7 +116,7 @@ position: absolute; inset: 0; border-radius: 6px; - background: repeating-conic-gradient(#{$gray-300} 0% 25%, transparent 0% 50%) 0 0 / 8px 8px; + background: repeating-conic-gradient(var(--ty-color-border) 0% 25%, transparent 0% 50%) 0 0 / 8px 8px; z-index: -1; } } @@ -142,33 +142,33 @@ } &__format-btn { - border: 1px solid var(--ty-color-picker-border, #{$gray-300}); + border: 1px solid var(--ty-color-picker-border); background: transparent; border-radius: var(--ty-border-radius); padding: 2px 6px; cursor: pointer; font-size: 12px; - color: var(--ty-color-text, #{$gray-700}); + color: var(--ty-color-text); white-space: nowrap; &:hover { - border-color: var(--ty-color-primary, #{$primary-color}); + border-color: var(--ty-color-primary); } } &__hex-input { flex: 1; - border: 1px solid var(--ty-color-picker-border, #{$gray-300}); + border: 1px solid var(--ty-color-picker-border); border-radius: var(--ty-border-radius); padding: 2px 6px; font-size: 12px; font-family: var(--ty-font-family-monospace); - color: var(--ty-color-text, #{$gray-800}); + color: var(--ty-color-text); outline: none; min-width: 0; &:focus { - border-color: var(--ty-color-primary, #{$primary-color}); + border-color: var(--ty-color-primary); } } @@ -178,7 +178,7 @@ gap: 6px; margin-top: 12px; padding-top: 12px; - border-top: 1px solid var(--ty-color-picker-border, #{$gray-200}); + border-top: 1px solid var(--ty-color-picker-border); } &__preset { @@ -186,7 +186,7 @@ height: 20px; border-radius: var(--ty-border-radius); cursor: pointer; - border: 1px solid var(--ty-color-picker-border, #{$gray-300}); + border: 1px solid var(--ty-color-picker-border); transition: transform 0.15s; &:hover { diff --git a/packages/react/src/config-provider/__tests__/__snapshots__/config-provider.test.tsx.snap b/packages/react/src/config-provider/__tests__/__snapshots__/config-provider.test.tsx.snap deleted file mode 100644 index 5637e732..00000000 --- a/packages/react/src/config-provider/__tests__/__snapshots__/config-provider.test.tsx.snap +++ /dev/null @@ -1,16 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` should match the snapshot 1`] = ` - - - -`; diff --git a/packages/react/src/config-provider/__tests__/config-provider.test.tsx b/packages/react/src/config-provider/__tests__/config-provider.test.tsx index a28d1339..119bd393 100644 --- a/packages/react/src/config-provider/__tests__/config-provider.test.tsx +++ b/packages/react/src/config-provider/__tests__/config-provider.test.tsx @@ -1,16 +1,41 @@ import React from 'react'; -import { render } from '@testing-library/react'; -import ConfigProvider from '../index'; +import { act, render, screen } from '@testing-library/react'; +import ConfigProvider, { useConfig } from '../index'; import Button from '../../button'; +import Message from '../../message'; +import Notification from '../../notification'; +import LoadingBar from '../../loading-bar'; +import Modal from '../../modal'; describe('', () => { - it('should match the snapshot', () => { - const { asFragment } = render( + afterEach(() => { + document.documentElement.removeAttribute('data-tiny-theme'); + document.documentElement.removeAttribute('style'); + ConfigProvider.config({ holderRender: undefined }); + document.querySelectorAll('.ty-message-container').forEach((node) => node.remove()); + document.querySelectorAll('.ty-notification-container').forEach((node) => node.remove()); + document.querySelectorAll('#ty-loading-bar').forEach((node) => node.parentElement?.remove()); + }); + + it('should render provider scope and popup holder when scoped theme is enabled', () => { + const { container } = render( + + + + ); + expect(container.querySelector('.custom-config-provider')).toBeTruthy(); + expect(container.querySelector('.custom-config-provider__popup-holder')).toBeTruthy(); + }); + + it('should avoid rendering provider scope when scoped theme is not needed', () => { + const { container } = render( ); - expect(asFragment()).toMatchSnapshot(); + + expect(container.querySelector('.custom-config-provider')).toBeFalsy(); + expect(container.querySelector('.custom-config-provider__popup-holder')).toBeFalsy(); }); it('should render children', () => { @@ -30,4 +55,188 @@ describe('', () => { ); expect(container.querySelector('.my-btn')).toBeTruthy(); }); + + it('should restore parent theme mode when nested provider unmounts', () => { + const nested = render( + + +
Content
+
+
+ ); + + const providers = nested.container.querySelectorAll('.ty-config-provider'); + expect(providers[0].getAttribute('data-tiny-theme')).toBe('dark'); + expect(providers[1].getAttribute('data-tiny-theme')).toBe('light'); + + nested.rerender( + +
Content
+
+ ); + + expect(nested.container.querySelector('.ty-config-provider')?.getAttribute('data-tiny-theme')).toBe('dark'); + }); + + it('should restore parent token overrides when nested provider unmounts', () => { + const nested = render( + + +
Content
+
+
+ ); + + const providers = nested.container.querySelectorAll('.ty-config-provider'); + expect((providers[0] as HTMLElement).style.getPropertyValue('--ty-color-primary')).toBe('blue'); + expect((providers[1] as HTMLElement).style.getPropertyValue('--ty-color-primary')).toBe('red'); + + nested.rerender( + +
Content
+
+ ); + + expect( + (nested.container.querySelector('.ty-config-provider') as HTMLElement).style.getPropertyValue( + '--ty-color-primary' + ) + ).toBe('blue'); + }); + + it('should replace old token values when the same provider updates theme config', () => { + const { rerender } = render( + +
Content
+
+ ); + + const getProvider = () => document.querySelector('.ty-config-provider') as HTMLElement; + + expect(getProvider().style.getPropertyValue('--ty-color-primary')).toBe('blue'); + expect(getProvider().style.getPropertyValue('--ty-border-radius')).toBe('8px'); + + rerender( + +
Content
+
+ ); + + expect(getProvider().style.getPropertyValue('--ty-color-success')).toBe('green'); + expect(getProvider().style.getPropertyValue('--ty-color-primary')).toBe(''); + expect(getProvider().style.getPropertyValue('--ty-border-radius')).toBe(''); + }); + + it('should isolate different token keys across multiple providers', () => { + const nested = render( + + +
Content
+
+
+ ); + + const providers = nested.container.querySelectorAll('.ty-config-provider'); + expect((providers[0] as HTMLElement).style.getPropertyValue('--ty-color-primary')).toBe('blue'); + expect((providers[1] as HTMLElement).style.getPropertyValue('--ty-border-radius')).toBe('12px'); + + nested.rerender( + +
Content
+
+ ); + + const provider = nested.container.querySelector('.ty-config-provider') as HTMLElement; + expect(provider.style.getPropertyValue('--ty-color-primary')).toBe('blue'); + expect(provider.style.getPropertyValue('--ty-border-radius')).toBe(''); + }); + + it('should expose merged config via useConfig', () => { + const Consumer = () => { + const config = useConfig(); + return ( +
+ ); + }; + + render( + + + + + + ); + + const node = screen.getByTestId('config'); + expect(node.getAttribute('data-prefix')).toBe('outer'); + expect(node.getAttribute('data-size')).toBe('sm'); + expect(node.getAttribute('data-theme')).toBe('dark'); + }); + + it('should apply holderRender to static message APIs', () => { + ConfigProvider.config({ + holderRender: (children) =>
{children}
, + }); + + act(() => { + Message.info('Hello', 1000, undefined as unknown as () => void, {}); + }); + + expect(document.querySelector('[data-testid="message-holder"]')).toBeTruthy(); + expect(document.body.textContent).toContain('Hello'); + }); + + it('should apply holderRender to static notification APIs', () => { + ConfigProvider.config({ + holderRender: (children) =>
{children}
, + }); + + act(() => { + Notification.open({ title: 'Notice', description: 'Body', duration: 0 }); + }); + + expect(document.querySelector('[data-testid="notification-holder"]')).toBeTruthy(); + expect(document.body.textContent).toContain('Notice'); + }); + + it('should apply holderRender to static loading bar APIs', () => { + ConfigProvider.config({ + holderRender: (children) =>
{children}
, + }); + + act(() => { + LoadingBar.start(); + }); + + expect(document.querySelector('[data-testid="loading-bar-holder"]')).toBeTruthy(); + expect(document.querySelector('#ty-loading-bar')).toBeTruthy(); + + act(() => { + LoadingBar.succeed(); + }); + }); + + it('should apply holderRender to static modal APIs', () => { + ConfigProvider.config({ + holderRender: (children) =>
{children}
, + }); + + let instance!: ReturnType; + + act(() => { + instance = Modal.open({ header: 'Static Modal', children: 'Body' }); + }); + + expect(document.querySelector('[data-testid="modal-holder"]')).toBeTruthy(); + expect(document.body.textContent).toContain('Static Modal'); + + act(() => { + instance.destroy(); + }); + }); }); diff --git a/packages/react/src/config-provider/config-context.tsx b/packages/react/src/config-provider/config-context.tsx index 507223b4..3a608db1 100644 --- a/packages/react/src/config-provider/config-context.tsx +++ b/packages/react/src/config-provider/config-context.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { SizeType } from '../_utils/props'; import { SpaceSize } from '../space/types'; import { Locale } from '../locale/types'; +import { ThemeConfig } from './token-utils'; export type ThemeMode = 'light' | 'dark' | 'system'; @@ -11,7 +12,10 @@ export interface ConfigContextProps { shimmer?: boolean; space?: SpaceSize; theme?: ThemeMode; + themeConfig?: ThemeConfig; locale?: Locale; + getPopupContainer?: (trigger?: HTMLElement | null) => HTMLElement; + getTargetContainer?: () => HTMLElement | Window; } export const ConfigContext = React.createContext({ @@ -19,4 +23,10 @@ export const ConfigContext = React.createContext({ componentSize: 'md', shimmer: false, space: 'sm', + getPopupContainer: () => document.body, + getTargetContainer: () => window, }); + +export function useConfig(): ConfigContextProps { + return React.useContext(ConfigContext); +} diff --git a/packages/react/src/config-provider/config-provider.tsx b/packages/react/src/config-provider/config-provider.tsx index aadac032..cd31142c 100644 --- a/packages/react/src/config-provider/config-provider.tsx +++ b/packages/react/src/config-provider/config-provider.tsx @@ -1,30 +1,112 @@ -import { useEffect } from 'react'; -import { ConfigContext } from './config-context'; -import { ConfigProviderProps } from './types'; +import React, { Fragment, useCallback, useContext, useMemo, useState } from 'react'; +import { ConfigContext, ThemeMode, useConfig } from './config-context'; +import { ConfigProviderComponent, ConfigProviderProps } from './types'; +import { buildCssVars, ThemeConfig } from './token-utils'; +import { setStaticConfig } from './static-config'; import IntlProvider from '../intl-provider'; -const ConfigProvider = (props: ConfigProviderProps): JSX.Element => { - const { children, theme, locale, ...otherProps } = props; +function isThemeConfig(theme: unknown): theme is ThemeConfig { + return typeof theme === 'object' && theme !== null; +} - useEffect(() => { - if (!theme) return; - const html = document.documentElement; - html.setAttribute('data-tiny-theme', theme); - }, [theme]); +const ConfigProviderImpl = (props: ConfigProviderProps): React.ReactElement => { + const { + children, + theme, + locale, + prefixCls, + componentSize, + shimmer, + space, + getPopupContainer, + getTargetContainer, + } = props; + const parentConfig = useContext(ConfigContext); + const [holderElement, setHolderElement] = useState(null); - const content = locale ? ( - {children} + const themeConfig = isThemeConfig(theme) ? theme : undefined; + const mode = themeConfig ? themeConfig.mode : (theme as ThemeMode | undefined); + const cssVars = useMemo( + () => (themeConfig ? buildCssVars(themeConfig) : undefined), + [themeConfig] + ); + const requiresScope = Boolean(mode || cssVars); + + const mergedGetPopupContainer = useCallback( + (trigger?: HTMLElement | null) => + getPopupContainer?.(trigger) ?? + (requiresScope ? holderElement : null) ?? + parentConfig.getPopupContainer?.(trigger) ?? + document.body, + [getPopupContainer, holderElement, parentConfig, requiresScope] + ); + + const popupHolderRef = useCallback((node: HTMLDivElement | null) => { + setHolderElement(node); + }, []); + + const mergedConfig = useMemo( + () => ({ + prefixCls: prefixCls ?? parentConfig.prefixCls, + componentSize: componentSize ?? parentConfig.componentSize, + shimmer: shimmer ?? parentConfig.shimmer, + space: space ?? parentConfig.space, + theme: mode ?? parentConfig.theme, + themeConfig: themeConfig ?? parentConfig.themeConfig, + locale: locale ?? parentConfig.locale, + getPopupContainer: mergedGetPopupContainer, + getTargetContainer: getTargetContainer ?? parentConfig.getTargetContainer, + }), + [ + componentSize, + getTargetContainer, + locale, + mergedGetPopupContainer, + mode, + parentConfig, + prefixCls, + shimmer, + space, + themeConfig, + ] + ); + + const content = mergedConfig.locale ? ( + {children} ) : ( children ); + const popupHolder = requiresScope ? ( +
+ ) : null; + return ( - - {content} + + {requiresScope ? ( +
+ {content} + {popupHolder} +
+ ) : ( + {content} + )}
); }; -ConfigProvider.displayName = 'ConfigProvider'; +ConfigProviderImpl.displayName = 'ConfigProvider'; + +const ConfigProvider = ConfigProviderImpl as ConfigProviderComponent; + +ConfigProvider.config = setStaticConfig; +ConfigProvider.useConfig = useConfig; export default ConfigProvider; diff --git a/packages/react/src/config-provider/container-utils.ts b/packages/react/src/config-provider/container-utils.ts new file mode 100644 index 00000000..260fd3b1 --- /dev/null +++ b/packages/react/src/config-provider/container-utils.ts @@ -0,0 +1,16 @@ +import { ConfigContextProps } from './config-context'; + +export function resolveTargetContainer( + config: ConfigContextProps, + targetContainer?: () => HTMLElement | Window +): HTMLElement | Window { + return targetContainer?.() ?? config.getTargetContainer?.() ?? window; +} + +export function resolvePopupContainer( + config: ConfigContextProps, + trigger?: HTMLElement | null, + popupContainer?: (trigger?: HTMLElement | null) => HTMLElement +): HTMLElement { + return popupContainer?.(trigger) ?? config.getPopupContainer?.(trigger) ?? document.body; +} diff --git a/packages/react/src/config-provider/demo/DynamicTheme.tsx b/packages/react/src/config-provider/demo/DynamicTheme.tsx new file mode 100644 index 00000000..d73eb2d6 --- /dev/null +++ b/packages/react/src/config-provider/demo/DynamicTheme.tsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react'; +import { Button, ConfigProvider, Space } from '@tiny-design/react'; + +export default function DynamicThemeDemo() { + const [danger, setDanger] = useState(false); + + return ( + + + + + + + ); +} diff --git a/packages/react/src/config-provider/demo/NestedTheme.tsx b/packages/react/src/config-provider/demo/NestedTheme.tsx new file mode 100644 index 00000000..8959a16f --- /dev/null +++ b/packages/react/src/config-provider/demo/NestedTheme.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Button, Card, ConfigProvider, Space } from '@tiny-design/react'; + +export default function NestedThemeDemo() { + return ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/react/src/config-provider/demo/PortalTheme.tsx b/packages/react/src/config-provider/demo/PortalTheme.tsx new file mode 100644 index 00000000..f64163c4 --- /dev/null +++ b/packages/react/src/config-provider/demo/PortalTheme.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Button, ConfigProvider, Select, Space, Tooltip } from '@tiny-design/react'; + +const options = [ + { label: 'Ocean', value: 'ocean' }, + { label: 'Forest', value: 'forest' }, + { label: 'Sunset', value: 'sunset' }, +]; + +export default function PortalThemeDemo() { + return ( + + + + + + + +``` + +Even though the Select dropdown and Tooltip popup are rendered through portals, they still inherit the same token values from the current provider scope. + +#### 3. Updating theme config + +When the `theme` prop changes, old overrides are removed and replaced by the new ones. + +```jsx +const [danger, setDanger] = useState(false); + + + + +``` + +In this example, when `danger` switches back to `false`, `borderRadius: '2px'` is removed instead of leaking into the next theme state. + +### CSS Custom Property Overrides + +You can also customize components directly via CSS without using `ThemeConfig`: + +```css +/* Global — affects all components */ +:root { --ty-border-radius: 8px; } + +/* Component-specific — only affects Button */ +:root { --ty-btn-border-radius: 20px; } + +/* Scoped — only affects Buttons inside .my-section */ +.my-section { --ty-btn-border-radius: 0; } +``` + +## Static Functions + +Static feedback APIs such as `Message.*`, `Notification.*`, `LoadingBar.*`, and `Modal.open()` do not automatically read the nearest React tree provider. Use `ConfigProvider.config()` to provide a holder wrapper for them. + +```jsx +ConfigProvider.config({ + holderRender: (children) => ( + + {children} + + ), +}); +``` + +## useConfig + +Read the merged parent config inside custom components or hooks. + +```jsx +const { componentSize, getPopupContainer, locale } = ConfigProvider.useConfig(); +``` + ## Props | Property | Description | Type | Default | @@ -25,6 +222,8 @@ return ( | shimmer | display shimmer effect for [Skeleton](#/components/skeleton). | boolean | false | | space | set Space size, ref [Space](#/components/space). | enum: `sm` | `md` | `lg` or `number`. | `sm` | | locale | set locale for components (e.g. `en_US`, `zh_CN`). | Locale | - | -| theme | set theme mode. | enum: `light` | `dark` | `system` | - | +| getPopupContainer | set the container for popup-based components within this provider scope. | `(trigger?: HTMLElement \| null) => HTMLElement` | provider popup holder | +| getTargetContainer | set the default scroll target for components such as `Anchor`, `Sticky`, and `BackTop`, and the scroll-lock target for layers such as `Overlay` and `Tour`. | `() => HTMLElement \| Window` | `() => window` | +| theme | set theme mode or theme config with token overrides. | `ThemeMode` | `ThemeConfig` | - | > The `prefixCls` property is useful to solve the classname conflict with other libraries. Please note that this will lose default styles from `ty`. To solve that, also updating the `prefix` variable in the `_variables.scss`. diff --git a/packages/react/src/config-provider/index.tsx b/packages/react/src/config-provider/index.tsx index 414e1236..e1f96c63 100644 --- a/packages/react/src/config-provider/index.tsx +++ b/packages/react/src/config-provider/index.tsx @@ -1,3 +1,8 @@ import ConfigProvider from './config-provider'; +import { useConfig } from './config-context'; +export type * from './types'; +export type { ThemeConfig } from './token-utils'; +export type { StaticConfig } from './static-config'; export default ConfigProvider; +export { useConfig }; diff --git a/packages/react/src/config-provider/index.zh_CN.md b/packages/react/src/config-provider/index.zh_CN.md index cdb090da..a24ef235 100644 --- a/packages/react/src/config-provider/index.zh_CN.md +++ b/packages/react/src/config-provider/index.zh_CN.md @@ -1,6 +1,13 @@ +import NestedThemeDemo from './demo/NestedTheme'; +import NestedThemeSource from './demo/NestedTheme.tsx?raw'; +import PortalThemeDemo from './demo/PortalTheme'; +import PortalThemeSource from './demo/PortalTheme.tsx?raw'; +import DynamicThemeDemo from './demo/DynamicTheme'; +import DynamicThemeSource from './demo/DynamicTheme.tsx?raw'; + # ConfigProvider -为组件提供统一的全局配置。 +为组件提供统一的全局配置,支持通过设计令牌进行主题定制。 ## 使用方式 @@ -16,9 +23,195 @@ return ( ); ``` -# ConfigProvider 全局配置 +## 示例 + + + + + +### 嵌套 Provider + +内层 provider 会覆盖外层 provider,这也是嵌套挂载和卸载时最需要保证正确的行为。 + + + + + + +### Portal 继承 + +令牌会作用在当前 provider 的作用域节点上,弹层类组件默认也会渲染到这个 provider 对应的 popup holder 中。 + + + + + + + + +### 动态更新 Theme + +当 `theme` 属性更新时,旧 token 会先被清理,再应用新的值。 + + + + + + + +## 主题定制 + +`theme` 属性既可以接收主题模式字符串,也可以接收 `ThemeConfig` 对象来进行细粒度的令牌定制。 + +### 主题模式 + +```jsx + + + +``` + +### 令牌覆盖 + +使用 `ThemeConfig` 自定义全局令牌和组件级令牌: + +```jsx +import { ConfigProvider } from 'tiny-design'; + + + + +``` + +### ThemeConfig + +| 属性 | 说明 | 类型 | 默认值 | +| ---------- | ------------------------------------------------ | --------------------------------------------- | ------- | +| mode | 主题模式 | enum: `light` | `dark` | `system` | - | +| token | 全局设计令牌覆盖(camelCase 键名) | `Record` | - | +| components | 组件级令牌覆盖(camelCase 键名) | `Record>` | - | + +令牌键名使用 camelCase 格式,会自动转换为 CSS 自定义属性。例如 `colorPrimary` 会转换为 `--ty-color-primary`,`Button.borderRadius` 会转换为 `--ty-btn-border-radius`。ConfigProvider 会把这些值应用到自己的作用域节点上,而不是直接写到全局 ``。因此嵌套 provider 会形成真正的嵌套作用域,弹层类组件也会通过 provider 的 popup holder 继承相同的值。 + +当启用局部主题时,`ConfigProvider` 会渲染一个带 `display: contents` 的内部作用域节点,用来承载局部 CSS 变量和 popup holder。它对布局影响很小,但这个节点仍然存在于 DOM 中,依赖直接父子关系的 DOM 逻辑需要注意这一点。 + +### 示例 + +#### 1. 嵌套 ConfigProvider + +内层 `ConfigProvider` 会覆盖外层配置;当内层卸载时,外层值会自动恢复。 + +```jsx + + + + + + + +``` + +这个例子里: + +- `DangerSection` 外部的 `--ty-color-primary` 是 `#1677ff` +- `DangerSection` 内部的 `--ty-color-primary` 会变成 `#f5222d` +- 当 `DangerSection` 卸载后,值会恢复成 `#1677ff` + +#### 2. Portal 组件也会继承 + +因为弹层类组件默认会渲染到当前 provider 的 holder 中,所以通过 Portal 渲染出来的内容也会继承这些值。 + +```jsx + +