diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b9375459..457b6c69 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -4,6 +4,9 @@ on: push: branches: [main] workflow_dispatch: + workflow_run: + workflows: ['Publish npm package'] + types: [completed] permissions: contents: read @@ -14,6 +17,7 @@ concurrency: jobs: build: + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..d64954f1 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,137 @@ +name: Publish npm package + +on: + push: + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: npm-release + cancel-in-progress: false + +jobs: + publish: + if: github.repository == 'ifhover/leaf-ui' + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: npm + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '26' + registry-url: 'https://registry.npmjs.org' + - name: Validate release tag, source commit and npm version + id: release + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + node --input-type=module <<'NODE' + import assert from 'node:assert/strict'; + import { execFileSync } from 'node:child_process'; + import { appendFileSync, readFileSync } from 'node:fs'; + + const { RELEASE_TAG: tag, GITHUB_REF: ref, GITHUB_SHA: runCommit } = process.env; + assert.match(tag, /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/, 'Use a vX.Y.Z stable release tag, including when running the workflow manually.'); + assert.equal(ref, `refs/tags/${tag}`, 'Select the release tag under Run workflow, not a branch.'); + const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); + const commit = git('rev-parse', 'HEAD'); + assert.equal(commit, runCommit, 'The checkout must match the workflow run commit for provenance.'); + assert.equal(git('rev-parse', `refs/tags/${tag}^{commit}`), commit, 'The checkout must match the release tag.'); + git('merge-base', '--is-ancestor', commit, 'origin/main'); + const pkg = JSON.parse(readFileSync('packages/react/package.json', 'utf8')); + assert.equal(pkg.name, '@ifhover/leaf-ui'); + assert.equal(tag, `v${pkg.version}`, 'The tag must match packages/react/package.json.'); + assert.equal(pkg.repository.url, 'git+https://github.com/ifhover/leaf-ui.git'); + + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(pkg.name)}`, { + signal: AbortSignal.timeout(30_000), + }); + assert.ok(response.ok || response.status === 404, `npm registry returned ${response.status}.`); + const registry = response.status === 404 ? {} : await response.json(); + const published = registry.versions?.[pkg.version]; + const latest = registry['dist-tags']?.latest; + if (published) { + assert.equal(published.gitHead, commit, 'This version was already published from a different commit. Bump the version.'); + assert.equal(latest, pkg.version, 'This version is no longer latest; refusing to republish an older release.'); + console.log(`${pkg.name}@${pkg.version} is already published from this commit; skipping the upload.`); + } else if (latest) { + assert.match(latest, /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/, 'npm latest must be a stable version.'); + const currentParts = pkg.version.split('.').map(BigInt); + const latestParts = latest.split('.').map(BigInt); + const differing = currentParts.findIndex((part, index) => part !== latestParts[index]); + assert.ok(differing >= 0 && currentParts[differing] > latestParts[differing], 'The release version must be newer than npm latest.'); + } + appendFileSync(process.env.GITHUB_OUTPUT, `version=${pkg.version}\ncommit=${commit}\nalready_published=${Boolean(published)}\n`); + NODE + - uses: pnpm/action-setup@v4 + if: steps.release.outputs.already_published != 'true' + - name: Install dependencies + if: steps.release.outputs.already_published != 'true' + run: pnpm install --frozen-lockfile + - name: Check source, tests and release notes + if: steps.release.outputs.already_published != 'true' + run: pnpm check + - name: Build and verify library and documentation + if: steps.release.outputs.already_published != 'true' + run: pnpm build + - name: Ensure the build did not change release sources + if: steps.release.outputs.already_published != 'true' + run: | + if [[ -n "$(git status --porcelain)" ]]; then + git status --short + echo 'Commit generated source changes before publishing.' >&2 + exit 1 + fi + - name: Install npm with trusted publishing support + if: steps.release.outputs.already_published != 'true' + run: npm install --global npm@11 + - name: Publish to npm using OIDC + if: steps.release.outputs.already_published != 'true' + working-directory: packages/react + run: npm publish --access public --tag latest --provenance --registry https://registry.npmjs.org/ + - name: Verify published commit and latest tag + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + RELEASE_COMMIT: ${{ steps.release.outputs.commit }} + run: | + node --input-type=module <<'NODE' + import assert from 'node:assert/strict'; + import { appendFileSync } from 'node:fs'; + import { setTimeout } from 'node:timers/promises'; + + const { RELEASE_VERSION: version, RELEASE_COMMIT: commit } = process.env; + for (let attempt = 1; attempt <= 12; attempt++) { + try { + const response = await fetch('https://registry.npmjs.org/@ifhover%2Fleaf-ui', { + cache: 'no-store', + signal: AbortSignal.timeout(30_000), + }); + assert.ok(response.ok, `npm registry returned ${response.status}.`); + const registry = await response.json(); + assert.equal(registry.versions?.[version]?.gitHead, commit, 'npm gitHead does not match the release commit.'); + assert.equal(registry['dist-tags']?.latest, version, 'npm latest does not match the release version.'); + appendFileSync(process.env.GITHUB_STEP_SUMMARY, [ + `Verified npm package: [@ifhover/leaf-ui@${version}](https://www.npmjs.com/package/@ifhover/leaf-ui/v/${version})`, + `Release tag: v${version}; commit: ${commit}`, + 'The Deploy documentation workflow runs after this workflow succeeds. Check its result before considering the release complete.', + '', + ].join('\n\n')); + break; + } catch (error) { + if (attempt === 12) throw error; + console.log(`Waiting for npm registry metadata (${attempt}/12): ${error.message}`); + await setTimeout(10_000); + } + } + NODE diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 6e204437..efc0657e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -31,6 +31,7 @@ jobs: with: name: production-bundles path: packages/react/.quality + include-hidden-files: true react-compatibility: runs-on: ubuntu-latest @@ -71,6 +72,7 @@ jobs: if: always() with: name: browser-${{ matrix.browser }} + include-hidden-files: true path: | packages/react/playwright-report packages/react/test-results diff --git a/.gitignore b/.gitignore index f83a18a0..110a2e42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +apps/docs/docs/public/locales/ dist/ doc_build/ site_build/ diff --git a/AGENTS.md b/AGENTS.md index 47f5f786..75a57506 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ 开始开发、修改使用文档或准备发布前,必须阅读并遵守 [开发、版本与文档同步流程](DEVELOPMENT-WORKFLOW.md)。该文档是分支开发、正式发布、开发文档预览与清理的统一约定。 +新增、修改或重构组件前,必须阅读并遵守 [组件编写与开发规则](packages/react/DEVELOPMENT.md) 和 [样式与交互标准](DESIGN-STANDARDS.md)。组件的公共 API、区域默认参数、内部控件、复合组件、表单语义和验证要求均以这些规则为准。 + ## 核心规则 - `main` 是稳定发布分支。组件代码和使用文档的改动必须在新分支完成,合并到 `main` 意味着完成一次新版本发布。 @@ -16,8 +18,8 @@ ## 相关说明 - 项目结构与本地开发:[DEVELOPMENT.md](DEVELOPMENT.md) -- 组件实现约定:[packages/react/DEVELOPMENT.md](packages/react/DEVELOPMENT.md) +- 组件编写与开发规则:[packages/react/DEVELOPMENT.md](packages/react/DEVELOPMENT.md) - 样式与交互标准:[DESIGN-STANDARDS.md](DESIGN-STANDARDS.md) - 发布操作:[packages/react/RELEASING.md](packages/react/RELEASING.md) -- 文档构建与快照:[apps/docs/README.md](apps/docs/README.md) +- 文档构建与快照:[apps/docs/AGENTS.md](apps/docs/AGENTS.md) - 质量验收:[packages/react/QUALITY.md](packages/react/QUALITY.md) diff --git a/DESIGN-REFRESH.md b/DESIGN-REFRESH.md index bfe4cf40..db9d0a1e 100644 --- a/DESIGN-REFRESH.md +++ b/DESIGN-REFRESH.md @@ -31,7 +31,7 @@ All 83 catalog entries were reviewed. The seven static or direct manipulation pr | Button, Input, InputNumber, Textarea, Checkbox, Radio, Switch, Slider, Rate, InputOTP, InputMask, SignaturePad, ImageCropper, ColorPicker | Neutral field hierarchy, focus/hover/press feedback, continuous check/thumb changes, measured textarea growth, stable loading button width/name, validation and tool feedback. Typing, drawing, cropping and pointer dragging remain immediate. | | Select, AutoComplete, Mentions, Cascader, TreeSelect, Transfer | Refined trigger and option surfaces, opening origins, chevrons, selection feedback, column/list changes; virtual data windows do not animate as ordinary rows. | | Calendar, DatePicker, DateTimePicker, DateRangePicker, TimePicker, TimeRangePicker | Directional view changes, shared calendar surfaces, direct date range selection, simultaneous time endpoints, continuous time selection and keyboard feedback. | -| Tabs, Segmented, Menu, Steps, Breadcrumb, Pagination, Anchor, BackTop, FloatButton, AppBar | Measured shared selection indicators, retained tab panels, directional content and height transitions, progress rings, navigation hover/press, staggered quick actions. Geometry handles scrolling, RTL, resized text and scaled ancestors. | +| Tabs, Segmented, Menu, Steps, Breadcrumb, Pagination, Anchor, BackTop, FloatButton, AppBar | Measured shared selection indicators, immediately switched tab panels with retained state, progress rings, navigation hover/press, staggered quick actions. Geometry handles scrolling, RTL, resized text and scaled ancestors. | | Collapse, Tree, OrgChart, Carousel | Natural expansion/collapse with retained inert exit content, branch chevrons, interrupted list layout motion, zoom-aware geometry, directional overlapping carousel slides and natural host heights. Virtual Tree remains immediate. | | Modal, Drawer, Confirm, Popover, Popconfirm, Tooltip, Dropdown, CommandPalette, Tour | Coordinated masks/surfaces, actual floating origins, moving highlights, natural content height changes, preserved inputs/focus, orderly exit presence, async generation isolation and correct Tour step references. | | Alert, Message, Notification, Loading, LoadingBar, Progress, Result, ErrorBoundary, Skeleton, Statistic | State/icon feedback, continuous queue collapse, progress changes and completion, resolved skeleton content reveal without an extra wrapper, immediate numeric values with subtle update motion. Empty uses the Result family. | diff --git a/DESIGN-STANDARDS.md b/DESIGN-STANDARDS.md index 8353fccc..5f5222a7 100644 --- a/DESIGN-STANDARDS.md +++ b/DESIGN-STANDARDS.md @@ -4,22 +4,32 @@ The default appearance is quiet and readable. Every component uses semantic them | Foundation | Comfortable | Compact | | --- | --- | --- | -| Base control height | 40 px | 34 px | +| Base control height | 36 px | 30 px | | Base body text | 14 px / 1.6 | 14 px / 1.6 | | Secondary text | 13 px | 13 px | | Spacing unit | 8 px | 6 px | | Form row gap | 24 px | 16 px | | List row vertical padding | 12 px | 8 px | -| Popup option minimum height | 36 px | 30 px | +| Popup option minimum height | 32 px | 26 px | | Dialog padding, block / inline | 20 / 24 px | 16 / 20 px | | Radius, small / base / medium / large | 6 / 10 / 12 / 16 px | 6 / 10 / 12 / 16 px | Component size is a local choice; density is a region-wide layout choice. Compact is intended for pointer/keyboard data-heavy interfaces. Use comfortable or larger explicit controls for touch interfaces; essential targets should be at least 24 px and preferably 44 px. -Hierarchy comes from radius, borders and type rather than decoration. Radii step down from the outside in: dialogs, drawers and popups use the large radius; content surfaces (Card, Alert, Collapse, Descriptions, Transfer, Upload, FileList, Result content, choice cards and lists) use the medium radius; buttons, fields and Table frames use the base radius; checkboxes and small chips use the small radius; status tags are pills. Content surfaces use hairline borders and muted fills without shadows; shadows are reserved for layers that float (popups use `shadow-md`, dialogs `shadow-lg`) and for raised handles such as slider thumbs and selected segments. Text hierarchy relies on size and color: 14 px body, 13 px secondary text, `fontWeight` (500) for component titles, labels, selected tabs and buttons, and `headingWeight` (600) only for Typography headings. Option group headings in Select, Dropdown, Menu, CommandPalette, List and rich choice groups use the shared section label: 12 px, weight 500, 0.04em tracking, uppercase for Latin text, muted color. Indicators that accompany text (Steps icons, Result and Upload symbols) are sized relative to the text, not to the control height. +Hierarchy comes from radius, borders and type rather than decoration. Radii step down from the outside in: dialogs, drawers and popups use the large radius; content surfaces (Card, Alert, Collapse, Descriptions, Transfer, Upload, FileList, Result content, choice cards and lists) use the medium radius; buttons, fields and Table frames use the base radius; checkboxes and small chips use the small radius; status tags use borderless fills and the small radius. Content surfaces use hairline borders and muted fills without shadows; shadows are reserved for layers that float (popups use `shadow-md`, dialogs `shadow-lg`) and for raised handles such as slider thumbs and selected segments. Text hierarchy relies on size and color: 14 px body, 13 px secondary text, `fontWeight` (500) for component titles, labels, selected tabs and buttons, and `headingWeight` (600) only for Typography headings. Option group headings in Select, Dropdown, Menu, CommandPalette, List and rich choice groups use the shared section label: 12 px, weight 500, 0.04em tracking, uppercase for Latin text, muted color. Indicators that accompany text (Steps icons, Result and Upload symbols) are sized relative to the text, not to the control height. Inset surfaces use concentric curves: `inner radius = max(0px, outer radius - padding - border width)`. A default popup has a 16 px outer radius, 6 px padding and a 1 px border, so its options use 9 px. Hover, selected and moving highlight layers share that curve. The shared geometry helper accounts for unequal padding with horizontal/vertical corner radii, including the extra insets in Cascader, TreeSelect and TimePicker. Theme and component padding changes update the curves automatically; small and square themes clamp to zero. Independent controls, calendar cells, tags and icons retain their semantic radii. Do not introduce clipping merely to hide a mismatch. +Menu navigation items, hover backgrounds and moving selection indicators use the same base radius as buttons, independent of menu padding. An explicit Menu component radius overrides this shared default. + +Select, TreeSelect and Cascader use shared multi-selection tags: tag height is the current control height minus 8 px, with 6 px inline gaps and 3 px field padding above and below. Tags wrap by default; `tagWrap={false}` or `maxTagCount="responsive"` keeps one line, truncates long labels and collapses excess values into `+N`. Reserve the actual input, clear and arrow space; when a label cannot fit, show the count alone. Folding is presentation only and must not alter selection or FormData. Width-driven folding does not replay selection entry animations. TreeSelect retains expanded branches across popup unmounts; search expansion is temporary, arrows only expand, and checkbox rows accept both label and empty-area activation. + +Tag uses borderless tinted or solid fills and a tighter radius of five-sixths of the theme's small radius (5 px by default). Explicit Tag component radius overrides take precedence. + +Pagination defaults to surface-filled outline buttons; `variant="ghost"` keeps borderless controls. Both share control-height tokens, stable pressed geometry and a moving primary selection surface. Outline hover/press changes the border and text, while ghost uses neutral fills. `showTotal` accepts a boolean for a localized count or a render function. Page and page-size changes use one callback; quick jumps commit on Enter or on leaving the pagination region without overriding a clicked pagination action. + +Card-style Tabs fit the tab labels by default while panels retain the available content width. Their size describes the entire tab bar, including padding and borders: 30 / 36 / 42 px by default. Inner buttons subtract those insets from the shared control height. Tab entries, buttons and moving selection share an inner radius derived from the bar radius minus its actual padding and border, clamped at zero; regional themes and explicit component overrides feed the same calculation. Default bar padding is 2 / 4 / 5 px, with 12 px inline label padding and a 2 px gap. Keep room for the selection shadow inside the clipped track without introducing scroll overflow. Tab content and panel height update immediately without entry, fade, slide or height animations; the tab bar's selection indicator retains its movement animation. + | State | Required behavior | | --- | --- | | Default | Legible text, consistent icon alignment, stable geometry | @@ -36,9 +46,9 @@ Use the primary color for actionable emphasis. Use success/info/warning/danger f Warning glyphs, field accents and status fills use a clear amber (`#faad14` in light, `#ffc53d` in dark). Warning text derives a darker or lighter readable ink independently; solid warning tags choose a contrasting label. Do not darken every warning accent to achieve text contrast. -Dropdown pointer, keyboard and pressed states share one moving background; danger transitions interpolate its color. Select uses the same opaque primary tint for selected rows and the moving highlight, which stays visible when crossing a selection. Selected text and checkmarks convey the persistent value without changing the hover fill. +Unselected options use one opaque neutral hover color (`--leaf-color-option-hover`, 9% text mixed with 91% surface; default light `#ebebeb`, dark `#2f2f33`) for pointer, keyboard candidate and pressed feedback. Select, AutoComplete, Cascader, TreeSelect / Tree, time columns, Dropdown, Menu and CommandPalette share it. Persistent selections retain the opaque 9% primary tint, semantic text and checkmarks; suggestion candidates are neutral even when their listbox uses `aria-selected` to announce navigation. Moving backgrounds interpolate both position and color; opaque fills prevent darkening when they cross selected rows, and static rows must not add a second hover / pressed fill. Disabled options receive no interaction feedback; danger actions retain their red semantic fill. These states inherit the regional appearance and palette without changing option geometry. -Ordinary action feedback uses 120–200 ms easing. Dialogs and drawers can use up to 320 ms; drawers slide without overshoot. Messages and notifications enter as one unit, without inner icon/text springs. Selection indicators may move to the newly selected item; their animation must not move the control itself. Card lift is opt-in through `hoverable`. Honor system reduced motion and `theme.motion={false}`; animation never gates functionality. +Ordinary action feedback uses 120–200 ms easing. Dialogs and drawers can use up to 320 ms; drawers slide without overshoot. Messages and notifications enter as one unit, without inner icon/text springs. Selection indicators may move to the newly selected item; their animation must not move the control itself. Cards retain their position and surface on hover. Honor system reduced motion and `theme.motion={false}`; animation never gates functionality. Solid and outlined Button activation uses a single translucent perimeter wave, following Ant Design's ease-out curve: a 400 ms expansion to 6 px outside the border and a 2 s opacity decay from 0.2. Ghost and link buttons omit the decoration, including danger and icon-only ghost buttons. Both timings follow the regional motion duration. One persistent decorative span and one browser animation handle both properties, without geometry/color measurement, per-click React roots, observers or per-frame JavaScript. It follows the actual corner radii and semantic action color and never clips or scales the label. Repeated activation replaces the current wave; disabling, loading, switching to ghost, unmounting or changing motion preferences cancels it. The decoration neither intercepts pointers nor changes scrollable layout, native submission, focus or accessible names. Forced colors omits the wave while retaining the focus outline. @@ -46,14 +56,18 @@ Solid buttons brighten on hover and deepen on press; derived shades retain reada Dialog headings, form edges and footer actions share the same inline padding. The body has sufficient separation from both heading and actions. A notification close action occupies only the heading row. Icons are decorative when the accompanying text already conveys the meaning. Every icon-only action needs an accessible name. -Modal and Drawer use a content surface, subtle section dividers and a muted footer by default. `divided={false}` groups content with spacing. Their heading icons share a neutral circular base. Confirm is an API-only decision prompt: a compact 420 px surface, spacing-based sections, a bare semantic glyph beside the explanation and no background base or section dividers. Its heading starts at the content edge, independently of the body icon. Popconfirm and Notification use a bare semantic glyph aligned with the title line. Headers without a supporting description use a 16 px vertical inset; Modal and Confirm footers use 12 px. Titles and supporting descriptions share an independent text column. The close action remains on the inline end. Body content scrolls without moving the heading or footer. Borders and surfaces derive from the current light/dark region, including custom palettes and density. +Modal and Drawer use a content surface, subtle section dividers and a muted footer by default. `divided={false}` groups content with spacing. Their heading icons share a neutral circular base. Confirm is an API-only decision prompt: a compact 420 px surface, spacing-based sections, a bare semantic glyph beside the explanation and no background base or section dividers. Its heading starts at the content edge, independently of the body icon. Popconfirm and Notification use a bare semantic glyph aligned with the title line. Headers without a supporting description use a 16 px vertical inset; Divided Card, Modal and Drawer footers share 12 px vertical insets; spacing-only dialog and Confirm footers use 12 px below their actions. Titles and supporting descriptions share an independent text column. The close action remains on the inline end. Body content scrolls without moving the heading or footer. Borders and surfaces derive from the current light/dark region, including custom palettes and density. + +Card no longer exposes `hoverable` or adds hover lift; `bordered` remains available. Card `size` offers small, default and large layouts: body padding defaults to 16 / 20 / 24 px, heading insets to 12 / 16 / 20 px and title text to 14 / 15 / 16 px. Embedded Tabs follow the Card size unless explicitly overridden; child controls keep their own sizes. Card headings use `fontWeight` (500 by default) and equal vertical insets; the body does not add a second inset below an undivided header. Card retains spacing-based sections by default; `divided` opts into borders and a muted footer for settings or action-focused content. Checkbox/Radio card choices have one full clickable label, a neutral icon base, a title/description column and a trailing native control. Groups may arrange these as a responsive grid or one list with shared borders and row separators. Selected borders use the primary color without gradients or lifting. Status tags are non-interactive, and supporting text is an accessible description rather than part of the choice name. Neutral Alert surfaces preserve semantic icon colors and announcement roles. + +Card can integrate top line tabs through its `tabs` configuration. With a heading, navigation sits below the title and description without an intervening divider; without a heading, tabs fill the entire heading height with vertically centered labels and Card actions move to the trailing edge. Replacement headings derive their minimum height from the Card title line and symmetric heading insets, respect larger explicit tab sizes, and have no top-only outer padding. One full-width divider runs under the entire tab bar, including its actions. Tab labels and panels align with the Card content edge, panel insets and header surfaces inherit Card tokens, and tab size follows Card size unless explicitly set. The footer retains Card's spacing or divided treatment. Reuse Tabs navigation, overflow, content retention and animation rather than creating a second tab state system; loading replaces panel content while navigation remains mounted. -Card retains spacing-based sections by default; `divided` opts into borders and a muted footer for settings or action-focused content. Checkbox/Radio card choices have one full clickable label, a neutral icon base, a title/description column and a trailing native control. Groups may arrange these as a responsive grid or one list with shared borders and row separators. Selected borders use the primary color without gradients or lifting. Status tags are non-interactive, and supporting text is an accessible description rather than part of the choice name. Neutral Alert surfaces preserve semantic icon colors and announcement roles. +FileList uses one shared frame and row separators for text and thumbnail lists. Visuals, file information and actions occupy separate columns; names truncate within their column, while type and optional size remain secondary. Upload state, progress and errors belong to the information column. Preview buttons, download links, removal and Upload actions share square geometry. Picture cards keep a stable preview ratio and align their actions at the bottom. Missing sizes leave no empty placeholder, failed thumbnails retain a type icon, and asynchronous removal feedback stays with its file. No row shadow or hover lift is applied. -ConfigProvider remains the configuration entry. Base colors, radius and height derive related geometry; advanced `dividerColor`, component `borderColor`, `headerBackground` and `footerBackground` provide selective overrides. CSS variables are consumption points for application styling. Filled actions and checkmarks remain solid colors, and single-line controls keep the 40px base height. +ConfigProvider remains the configuration entry. Base colors, radius and height derive related geometry; advanced `dividerColor`, component `borderColor`, `headerBackground` and `footerBackground` provide selective overrides. CSS variables are consumption points for application styling. Filled actions and checkmarks remain solid colors, and single-line controls keep the 36px base height. Buttons expose solid, outline, ghost and link appearances. Link actions use theme-colored text with no border, padding or control-height minimum; hover and press change only their color, and activation has no wave. Outline uses a content surface, body text and a visible neutral border; danger uses its semantic text and border. Alerts use one neutral surface with semantic icon colors. Result icons sit on the same muted circular base for every status. Table frames use the same base radius as buttons, 13 px muted column headers and divider-colored row separators, and fill their container before horizontal overflow; a minimum width remains a lower bound. Scrollbar tracks overlay content and appear on hover, scroll or keyboard focus; dragging must neither steal input focus nor add a pointer focus outline. -Development migration: replace Button `variant="soft"` with `variant="outline"` and remove Alert `variant` props. Comfortable controls now derive 34 / 40 / 46 px sizes; compact controls use a 34 px base. Set `theme.controlHeight` explicitly when an application needs to retain its former sizing. Content surfaces move from the large to the new medium radius (`borderRadiusMd` / `--leaf-radius-md`) and drop their default shadow; small text becomes 13 px (`fontSize - 1px`); button inline padding derives from its actual height (0.42 times the height, 16.8 px at 40 px), with explicit component padding taking precedence for every size; component titles use `fontWeight` instead of `headingWeight`; small Steps retain body-size labels and at least 24 px indicators; rich choice legends use section labels; replace controlled `Confirm` / `ConfirmProps` with `useConfirm().confirm(options)` / `ConfirmOptions`, and move rich confirmation flows to Modal; Confirm retains a bare body icon; tags become pills; popups use `shadow-md`; Pagination items become borderless. Include these compatibility changes in the next release's migration notes. +Development migration: replace Button `variant="soft"` with `variant="outline"` and remove Alert `variant` props. Comfortable controls now derive 30 / 36 / 42 px sizes; compact controls use a 30 px base. Set `theme.controlHeight` explicitly when an application needs to retain its former sizing. Content surfaces move from the large to the new medium radius (`borderRadiusMd` / `--leaf-radius-md`) and drop their default shadow; small text becomes 13 px (`fontSize - 1px`); button inline padding derives from its actual height (0.42 times the height, 15.12 px at 36 px), with explicit component padding taking precedence for every size; component titles use `fontWeight` instead of `headingWeight`; small Steps retain body-size labels and at least 24 px indicators; rich choice legends use section labels; replace controlled `Confirm` / `ConfirmProps` with `useConfirm().confirm(options)` / `ConfirmOptions`, and move rich confirmation flows to Modal; Confirm retains a bare body icon; tags use borderless fills and the small radius; replace Tag `variant="outline"` with the default `soft` appearance; popups use `shadow-md`; Pagination uses outlined page buttons by default; set variant="ghost" to retain borderless buttons. Include these compatibility changes in the next release's migration notes. Acceptance uses keyboard behavior, geometry assertions, axe, SSR, build/package checks and manual inspection in both themes and densities. Screenshot baseline comparisons are deliberately deferred. See `IMPLEMENTATION-BATCHES.md` and `packages/react/ACCESSIBILITY.md` for evidence and manual coverage limits. diff --git a/DEVELOPMENT-WORKFLOW.md b/DEVELOPMENT-WORKFLOW.md index 8f6fd715..5ad0a4f7 100644 --- a/DEVELOPMENT-WORKFLOW.md +++ b/DEVELOPMENT-WORKFLOW.md @@ -49,8 +49,8 @@ npm 新作用域为 `@ifhover/leaf-ui`;迁移前的稳定版仍属于 `@sudden 正式发布的操作命令见 [packages/react/RELEASING.md](packages/react/RELEASING.md)。发布顺序为: 1. 将准备好的发布分支合并到 `main`,确认版本、更新记录和源码已提交。此后不叠加新的待发布功能,先完成这一版本的发布。 -2. 在实际合并后的发布提交重新检查与构建,确保工作区干净。使用该提交发布 npm 和创建 `vX.Y.Z` 标签;采用 squash 或 rebase 合并时,以合并后的提交为准。 -3. 确认 npm 的版本、`latest` 和 `gitHead`,推送标签,并部署默认在线文档与 `/v/X.Y.Z/` 快照。若合并触发的 Pages 构建早于 npm 发布,发布后重新触发 `Deploy documentation` 工作流。 +2. 在实际合并后的发布提交重新检查与构建,确保工作区干净。创建并推送 `vX.Y.Z` 标签,触发 `Publish npm package` 通过 OIDC 发布 npm;普通提交与 `main` 推送不会发布 npm。也可手动运行发布工作流,并在 **Use workflow from** 中选择已推送的正式版本标签。采用 squash 或 rebase 合并时,以合并后的提交为准。 +3. 发布工作流确认 npm 的版本、`latest` 和 `gitHead` 后,自动触发 `Deploy documentation`,部署默认在线文档与 `/v/X.Y.Z/` 快照;文档部署失败时重跑文档工作流。源码、标签和 npm 必须对应同一提交,本地手动发布操作见发布说明。 4. 分支合并到 `main` 后必须删除对应临时在线文档、资源、预览入口与活动预览清单中的条目,并停止继续更新该预览的任务。放弃开发、关闭未合并 PR 或删除开发分支时,也执行相同清理;清理结果保留在 PR 或发布记录中。 5. 验证正式站点与 npm 一致,开发预览 URL 已不再提供开发内容,正式历史快照仍可访问,再报告发布完成。 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 42e881af..80cc7f70 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -80,13 +80,13 @@ pnpm --dir packages/react pack ## 文档内容边界 -在线文档只包含组件使用说明。文档站运行与维护说明见 [apps/docs/README.md](apps/docs/README.md),组件开发流程见 [packages/react/DEVELOPMENT.md](packages/react/DEVELOPMENT.md)。 +在线文档只包含组件使用说明。文档站运行与维护说明见 [apps/docs/AGENTS.md](apps/docs/AGENTS.md),组件开发流程见 [packages/react/DEVELOPMENT.md](packages/react/DEVELOPMENT.md)。 -每次 npm 升版发布都需维护中英文更新记录,并同步部署版本快照。具体步骤见 [发布文档](packages/react/RELEASING.md);`pnpm check:release` 会校验组件包当前版本的记录。`pnpm build:versions` 使用 npm gitHead 独立构建每个已发布版本,导航和 AI 查询均以快照版本为准。版本构建与 AI 文档契约见 [文档站维护说明](apps/docs/README.md),Skill 发行见 [skills/README.md](skills/README.md)。 +每次 npm 升版发布都需维护中英文更新记录,并同步部署版本快照。具体步骤见 [发布文档](packages/react/RELEASING.md);`pnpm check:release` 会校验组件包当前版本的记录。`pnpm build:versions` 使用 npm gitHead 独立构建每个已发布版本,导航和 AI 查询均以快照版本为准。版本构建与 AI 文档契约见 [文档站维护说明](apps/docs/AGENTS.md),Skill 发行见 [skills/README.md](skills/README.md)。 ## 样式与图标 -源码统一使用 SCSS,通过 @rsbuild/plugin-sass 构建。公共尺寸和样式 mixin 在 packages/react/src/styles;40px 是默认表单对齐基准。图标使用 lucide-react 的具名导入,具体约定见组件库开发文档。 +组件库与文档站主题使用 SCSS,通过 @rsbuild/plugin-sass 构建;文档示例优先使用 Flex、Space、Grid、FormField 和 Text,不依赖 Tailwind CSS;示例规则见 [文档站协作约定](apps/docs/AGENTS.md)。站点界面现有的 Tailwind CSS 入口由 PostCSS 编译。公共尺寸和样式 mixin 在 packages/react/src/styles;36px 是默认表单对齐基准。图标使用 lucide-react 的具名导入,具体约定见组件库开发文档。 ## 质量验收 diff --git a/README.en-US.md b/README.en-US.md new file mode 100644 index 00000000..d082ba3e --- /dev/null +++ b/README.en-US.md @@ -0,0 +1,121 @@ +
+ + Leaf UI + + +

Leaf UI

+ +

Lightweight · Natural · Customizable

+

A React UI library with a green starting point and room for your own identity.

+ +

+ npm version + npm monthly downloads + React 18 and 19 + TypeScript support + Component quality check status +

+ +

+ Documentation · + Components · + Changelog · + Report an issue +

+ +

简体中文 · English

+
+ +## ✨ Features + +- 🍃 **A natural starting point** — A green default theme and clear interaction states, with components for inputs, forms, layout, navigation, and feedback. +- 🎨 **Make it yours** — Customize colors, radii, typography, control heights, and light or dark appearance through ConfigProvider, scoped themes, and CSS variables. +- 🛡️ **Built with TypeScript** — Includes type declarations and preserves native HTML attributes, DOM refs, and form semantics. +- 🌍 **Multiple languages** — Chinese and English documentation, built-in locales, custom translations, nested locale settings, and RTL direction. +- ⌨️ **Thoughtful interactions** — Keyboard navigation, focus management, and support for reduced motion preferences. +- 📦 **Fits your stack** — React 18 / 19, ESM, CommonJS, and SSR, with component styles retained on demand in supported production builds. + +## 🖥️ Compatibility + +Supports React **18 / 19**, modern browsers, and server-side rendering. We recommend the current or previous stable browser release. + +|
Chrome |
Edge |
Firefox |
Safari | +| :-------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------: | +| ≥ 109 | ≥ 109 | ≥ 121 | ≥ 16.4 | + +These are minimum support targets. See [browser compatibility](https://ifhover.github.io/leaf-ui/en/guide/compatibility.html) for test coverage and fallbacks, and the [SSR guide](https://ifhover.github.io/leaf-ui/en/guide/ssr.html) for Next.js and other frameworks. + +## 📦 Installation + +```bash +pnpm add @ifhover/leaf-ui +``` + +
+Using npm / Yarn / Bun + +```bash +npm install @ifhover/leaf-ui +yarn add @ifhover/leaf-ui +bun add @ifhover/leaf-ui +``` + +
+ +Your application must provide `react` and `react-dom`. Compiled CSS is included; you do not need Sass. + +## 🔨 Quick start + +```tsx +import { Button, DatePicker } from '@ifhover/leaf-ui'; + +export default function App() { + return ( + <> + + + + ); +} +``` + +Base styles and the required component and service styles load automatically, without manual CSS imports. Vite and Rsbuild / Rspack production builds retain styles on demand for named ESM imports from the root entry. For direct esbuild or CommonJS use, component subpaths such as `@ifhover/leaf-ui/button` are available. Custom SSR pipelines can use the full static stylesheet at `@ifhover/leaf-ui/styles/all.css`. + +Single-line controls default to **36px**, with **30 / 42px** small and large sizes. Find more examples in [getting started](https://ifhover.github.io/leaf-ui/en/guide/getting-started.html). + +## 🎨 Themes and locales + +Use ConfigProvider to configure a theme and locale for your application or a specific region: + +```tsx +import { ConfigProvider } from '@ifhover/leaf-ui'; +import App from './App'; + +export default function Root() { + return ( + + + + ); +} +``` + +Set `locale` to `zh-CN` for Simplified Chinese, or set `theme.appearance` to `dark` for dark mode. Nested providers inherit unspecified options, and popups and messages follow the theme and locale of their region. + +Explore [theming](https://ifhover.github.io/leaf-ui/en/guide/theming.html) · [ConfigProvider themes and locales](https://ifhover.github.io/leaf-ui/en/components/config-provider.html). + +## 🤖 AI-assisted development + +Install the Leaf UI Skill in your application directory so your AI assistant can look up component APIs for the version your project uses: + +```bash +npx skills add ifhover/leaf-ui --skill leaf-ui +``` + +See [AI and Skills](https://ifhover.github.io/leaf-ui/en/guide/ai.html) for setup and usage. You can also switch between published versions at the top of the documentation site. + +## 🤝 Contributing + +Share bugs and ideas through [Issues](https://github.com/ifhover/leaf-ui/issues), or open a [Pull Request](https://github.com/ifhover/leaf-ui/pulls). + +See the [development guide](./DEVELOPMENT.md) for local setup, project structure, and builds. Before changing components or user documentation, read the [development, versioning, and documentation workflow](./DEVELOPMENT-WORKFLOW.md). diff --git a/README.md b/README.md index 99ae2ec9..ca9556e6 100644 --- a/README.md +++ b/README.md @@ -1,184 +1,121 @@ -# Leaf UI +
+ + Leaf UI + -轻盈、自然、可定制的 React UI 组件库。默认绿色主题,通过 ConfigProvider 调整颜色、圆角、字体和控件高度。支持 React 18 / 19,提供 TypeScript 类型、ESM 和 CommonJS 入口。 +

Leaf UI

-[在线文档](https://ifhover.github.io/leaf-ui/) · [更新记录](https://ifhover.github.io/leaf-ui/changelog.html) · [npm](https://www.npmjs.com/package/@ifhover/leaf-ui) +

轻盈 · 自然 · 可定制

+

一个以绿色为起点的 React UI 组件库,让界面保留你的个性。

-使用 AI 编写界面时,可在应用目录安装按项目实际版本查询 API 的 Skill:`npx skills add ifhover/leaf-ui --skill leaf-ui`。安装与使用见 [AI 与 Skills](https://ifhover.github.io/leaf-ui/guide/ai.html),文档顶部可切换已发布版本。 +

+ npm 版本 + npm 月下载量 + React 18 和 19 + TypeScript 类型支持 + 组件质量检查状态 +

-## 安装与使用 +

+ 在线文档 · + 组件总览 · + 更新记录 · + 反馈问题 +

-在你的 React 应用中安装组件包: +

简体中文 · English

+
-```bash -pnpm add @ifhover/leaf-ui -``` - -也可以使用 `npm install @ifhover/leaf-ui` 或 `yarn add @ifhover/leaf-ui`。样式只需在应用入口引入一次,使用组件无需安装 Sass。 +## ✨ 特性 -```tsx -import { Button, DatePicker, Input, Select } from '@ifhover/leaf-ui'; -import '@ifhover/leaf-ui/styles.css'; +- 🍃 **轻盈自然** — 绿色默认主题、清晰的交互状态,覆盖输入、表单、布局、导航与反馈。 +- 🎨 **随心定制** — 通过 ConfigProvider 调整颜色、圆角、字体、控件高度和明暗外观,支持区域主题与 CSS 变量。 +- 🛡️ **类型完备** — 使用 TypeScript 编写,提供类型声明,保留原生 HTML 属性、DOM ref 与表单语义。 +- 🌍 **多语言** — 提供中英文文档,支持内置语言、自定义翻译、嵌套语言配置与 RTL 方向。 +- ⌨️ **细致交互** — 支持键盘操作、焦点管理,以及系统的减少动态效果偏好。 +- 📦 **融入工程** — 兼容 React 18 / 19,提供 ESM、CommonJS 和 SSR 用法,组件样式可随生产构建按需保留。 -export function App() { - return ( -
{ - event.preventDefault(); - console.log(Object.fromEntries(new FormData(event.currentTarget))); - }}> - - <>{content}

Choose a team

} /> - -``` - -Mount the container before interaction. Popups copy the trigger region's theme and direction; avoid clipping containers. Modal and Drawer expose the equivalent option as `container`. - -## Shadow DOM and micro-frontends - -`getPopupContainer` can return a ShadowRoot. Import compiled component CSS inside that root too: external styles do not cross the boundary. Use a `style` / `link` element or `adoptedStyleSheets`. A separate configured container for each application region keeps styles and stacking predictable. - -## Language and direction - -Built-in locales are `zh-CN`, `zh-TW` and `en-US`. Override common control text with `messages`; `textMessages` uses English source text as keys for extension components. `weekStartsOn` configures the first weekday (0 is Sunday, 1 Monday), and `direction="rtl"` configures a region, including its popups. - -```tsx - - - - - - ); -} +```js title="next.config.mjs" +export default { + transpilePackages: ['@ifhover/leaf-ui'], +}; ``` -Import ProjectForm into your page. It inherits the outer theme without `dynamic(..., { ssr: false })`. +Append the package if you already have `transpilePackages`, then restart the development server. This lets Next.js process CSS inside the library and avoids the global CSS import error from `node_modules`. The option requires Next.js 13 or later. -## Next.js Pages Router +The default Turbopack build in Next.js 16.4.1 needs no extra configuration. Neither build requires manual Leaf UI stylesheet imports. -Import styles and wrap the page in `pages/_app.tsx`: +## TanStack Start -```tsx title="pages/_app.tsx" -import type { AppProps } from 'next/app'; +Use the default Vite setup. For application-wide configuration, wrap `Outlet` in your existing root route, `src/routes/__root.tsx`: + +```tsx title="src/routes/__root.tsx" import { ConfigProvider } from '@ifhover/leaf-ui'; -import '@ifhover/leaf-ui/styles.css'; +import { Outlet } from '@tanstack/react-router'; -export default function App({ Component, pageProps }: AppProps) { +function RootComponent() { return ( - - - + + + + + ); } ``` -For a personalized first frame, read preferences in getServerSideProps and return initialTheme. The client's first render uses the same pageProps. Later switches can use React state as in Providers above. - -## Other React SSR frameworks - -Remix, React Router framework mode and custom React SSR servers follow the same steps: - -1. Import `styles.css` through the framework's root entry or route, so it loads with the first frame. -2. Read theme and language on the server and wrap the rendered tree in ConfigProvider. -3. Pass the same initial settings to the browser through the framework's data mechanism. -4. Initialize client state with those settings, then handle later switches. +Keep your project's existing `RootDocument` and the other root route options. `HeadContent` in `` renders the initial stylesheet links; `Scripts` in `` loads the client scripts. Start discovers component styles in the build output, so Leaf UI needs no explicit `?url` stylesheet link or `'use client'` directive. -The static stylesheet is cacheable. Dynamic settings stay on each region's HTML container, with no DOM or window access during theme calculation. +See TanStack Start's [root route](https://tanstack.com/start/latest/docs/framework/react/guide/routing) and [CSS styling](https://tanstack.com/start/latest/docs/framework/react/guide/css-styling) guides. ## Keep the first render consistent -Prefer cookies, account settings or fixed defaults that the server can read. A preference stored only in localStorage is unavailable on the server: hydrate with a consistent default, then load it in an effect. This causes a visible theme switch. Persist preferences where the server can read them when the first frame should already match. +Use the same theme, language and initial values on the server and during client hydration. For dark mode on the first frame, read configuration from a Cookie or other server-readable data. Read preferences stored only in `localStorage` after mounting. See [theming](/en/guide/theming) and [switch languages](/en/guide/internationalization) for configuration. -Date pickers display local dates and times. Different server and browser time zones can produce different labels. Use a shared time-zone policy for SSR date defaults, or set the user's local dates after hydration. Avoid independently generating different initial values with `new Date()`. +Pass consistent initial values to dates, times and countdowns too; avoid separate `new Date()` calls for each side's initial render. Popups such as Modal and Message appear after mounting on the client. -Modal, Confirm, Message and floating panels create their portals after client mounting. Form controls themselves can render on the server; initially open overlays appear after hydration. +## Custom SSR -The entry `ConfigProvider` manages confirmations, messages, notifications and task loading bars automatically. Client descendants call `useConfirm`, `useMessage`, `useNotification` and `useLoadingBar`. These containers do not render on the server. State belongs to individual application roots and is not shared between SSR requests. +When rendering directly with Node without framework-managed stylesheet discovery, import the full static stylesheet in the entry that builds your browser assets: -## Images and file interactions - -Image renders its image on the server. The enlarged viewer, ImageCropper engine and SignaturePad engine load on demand in the browser. Stable containers and placeholders keep the server output consistent. Use these components in a Client Component without disabling SSR for the page. - -FileList renders URL-based metadata on the server. Upload keeps browser-selected File objects and temporary preview URLs in client state; no upload request is started during SSR. - -Interactive behavior requires client JavaScript. See [theming](/en/guide/theming) for style reuse and advanced settings. - -See [getting started](/en/guide/getting-started) for styles on demand. Default Node entries remain free of CSS imports, so ESM and CommonJS can render directly on the server. Framework browser builds use automatic style entries and retain the Next.js client boundary. For custom SSR or a build without the `browser` export condition, import `@ifhover/leaf-ui/styles/all.css` in the entry that produces page styles and include its emitted stylesheet in the initial HTML. - -## Date, number and popup hydration - -Pickers use local Date calendar fields and time. Date inputs and FormData represent local calendar values. `date.toISOString()` represents a UTC instant, so truncating it is not a reliable local date conversion. Convert business time zones at your application boundary and use the same convention on both server and client. Non-Gregorian calendars and dedicated timezone date types need an application adapter. - -Use consistent `locale`, values and timezone settings on server and client to keep Intl output identical. Pass a fixed deadline and `initialRemaining` to Countdown to avoid rendering differences caused by request latency. System appearance and breakpoint hooks accept initial values and read browser preferences after mounting. +```ts +import '@ifhover/leaf-ui/styles/all.css'; +``` -Portals mount on the client. Use function-valued `container` / `getPopupContainer` to defer DOM access; do not access `document` at module scope or during server rendering. See [Control and popup contracts](/en/guide/control-contracts). +Include the emitted CSS link in the initial HTML. The Node component entry does not import CSS and can be used directly for server rendering. diff --git a/apps/docs/docs/en/guide/tailwindcss.mdx b/apps/docs/docs/en/guide/tailwindcss.mdx new file mode 100644 index 00000000..77fbee80 --- /dev/null +++ b/apps/docs/docs/en/guide/tailwindcss.mdx @@ -0,0 +1,158 @@ +import { ComponentExample } from '../../../src/components/component-example'; +import { DocsCollapse } from '../../../src/components/docs-collapse'; +import { TailwindBasic } from '../../../src/examples/en/tailwindcss/basic'; +import { TailwindTheme } from '../../../src/examples/en/tailwindcss/theme'; + +# Tailwind CSS + +Reuse Leaf UI colors, radii and spacing in Tailwind. + +## Quick setup + +For a project with Tailwind installed, choose the configuration for your version. Versions 3.x and 4.x are supported; upgrade first if you use 2.x or earlier. + +### Tailwind CSS 4 + +Add one import to your CSS entry: + +```css title="app.css" +@import 'tailwindcss'; +@import '@ifhover/leaf-ui/tailwind.css'; +``` + + + +To omit Tailwind's Preflight reset, use this entry instead: + +```css title="app.css" +@layer theme, base, components, utilities; +@import 'tailwindcss/theme.css' layer(theme); +@import 'tailwindcss/utilities.css' layer(utilities); +@import '@ifhover/leaf-ui/tailwind.css'; +``` + + + +### Tailwind CSS 3 + +Add the preset and adjust `content` to match your source directories: + +```js title="tailwind.config.cjs" +const leafPreset = require('@ifhover/leaf-ui/tailwind').default; + +module.exports = { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + presets: [leafPreset], +}; +``` + +Keep the Tailwind 3 directives in your CSS entry: + +```css title="app.css" +@tailwind base; +@tailwind components; +@tailwind utilities; +``` + +For an existing configuration, append the preset and keep your source paths and plugins. + + + +```ts title="tailwind.config.ts" +import type { Config } from 'tailwindcss'; +import leafPreset from '@ifhover/leaf-ui/tailwind'; + +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + presets: [leafPreset], +} satisfies Config; +``` + +To disable Preflight, add `corePlugins: { preflight: false }` to the configuration. + + + +## Examples + +### Basic usage + +After setup, use `leaf-` names in utilities to share styles with components: + +} fileName="basic.tsx" width={360}> + +```tsx lineNumbers file="../../../src/examples/en/tailwindcss/basic.tsx" + +``` + + + +`bg-leaf-surface` sets the background, `p-leaf-md` sets padding, and `rounded-leaf` sets the radius. Standard Tailwind utilities and variants such as `hover:` and `md:` still work. + +### Follow the theme + +Utilities inside ConfigProvider follow its regional theme. Turn on dark mode to see the panel and button change together: + +} fileName="theme.tsx" width={360}> + +```tsx lineNumbers file="../../../src/examples/en/tailwindcss/theme.tsx" + +``` + + + +Backgrounds, text and borders read the current region's variables without extra `dark:` classes. Color and density changes do not require rebuilding Tailwind. See [Theming](/en/guide/theming) for more options. + +## Common design variables + +Both versions use the same utility names and extend the theme while keeping Tailwind defaults. + +
+ +| Design variable | Utility examples | +| ---------------------- | ------------------------------------------------------------------------------ | +| Primary color | `bg-leaf-primary`, `text-leaf-primary`, `text-leaf-on-primary` | +| Status colors | `text-leaf-success`, `text-leaf-warning`, `text-leaf-danger`, `text-leaf-info` | +| Text colors | `text-leaf-text`, `text-leaf-muted`, `text-leaf-subtle` | +| Backgrounds | `bg-leaf-surface`, `bg-leaf-surface-muted`, `bg-leaf-surface-raised` | +| Borders and focus | `border-leaf-border`, `border-leaf-divider`, `ring-leaf-focus` | +| Radii | `rounded-leaf`, `rounded-leaf-sm`, `rounded-leaf-md`, `rounded-leaf-lg` | +| Spacing | `p-leaf`, `p-leaf-xs`, `gap-leaf-sm`, `p-leaf-md`, `gap-leaf-lg`, `m-leaf-xl` | +| Control heights | `h-leaf-control`, `h-leaf-control-sm`, `h-leaf-control-lg` | +| Font and type sizes | `font-leaf`, `text-leaf`, `text-leaf-sm`, `text-leaf-lg` | +| Weight and line height | `font-leaf-medium`, `font-leaf-heading`, `leading-leaf` | +| Shadows | `shadow-leaf-xs`, `shadow-leaf-sm`, `shadow-leaf-md`, `shadow-leaf-lg` | + +
+ +Color names also work with `border-*`, `ring-*`, `fill-*` and similar utilities. Type size utilities include the theme line height. + + + +
+ +| Design variable | Utility examples | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Primary interactions | `hover:bg-leaf-primary-hover`, `active:bg-leaf-primary-active` | +| Danger interactions | `hover:bg-leaf-danger-hover`, `active:bg-leaf-danger-active` | +| Text on status colors | `text-leaf-on-warning`, `text-leaf-on-danger` | +| Border and option hover | `hover:border-leaf-border-hover`, `hover:bg-leaf-option-hover` | +| Choice control radius | `rounded-leaf-choice` | +| Density spacing | `gap-leaf-form-gap`, `gap-leaf-label-gap`, `py-leaf-row-padding`, `min-h-leaf-option-height`, `p-leaf-option-padding`, `px-leaf-field-padding` | +| Dialog spacing | `py-leaf-dialog-block`, `px-leaf-dialog-inline`, `gap-leaf-dialog-gap` | +| Transition durations | `duration-leaf`, `duration-leaf-fast`, `duration-leaf-slow` | +| Easing | `ease-leaf`, `ease-leaf-spring` | +| Overlay stacking | `z-leaf-popup`, `z-leaf-modal`, `z-leaf-message` | + +
+ +Combine motion utilities as `transition-colors duration-leaf ease-leaf motion-reduce:transition-none`. `theme.motion: false` disables regional transitions; `motion-reduce:` respects the system's reduced-motion preference. + +
+ +## Usage tips + +- **Opacity**: Tailwind 4 supports `bg-leaf-primary/50`. In Tailwind 3, these colors do not support `/50`, `bg-opacity-*` or `text-opacity-*`; define translucent colors in application CSS. +- **Source scanning**: Write complete class names such as `bg-leaf-primary` instead of constructing `bg-leaf-${color}`. +- **Base styles**: A CSS-only page with no Leaf UI component imports also needs `@ifhover/leaf-ui/styles.css`. +- **Breakpoints**: `sm:` and `md:` use your Tailwind configuration, independently of ConfigProvider breakpoints. Follow Tailwind 4's official browser support requirements. +- **Regions and overlays**: Custom Portal content needs the intended theme scope too. Leaf UI overlays synchronize themes automatically. Configure component-specific variables through [ConfigProvider](/en/guide/theming). diff --git a/apps/docs/docs/en/guide/theming.mdx b/apps/docs/docs/en/guide/theming.mdx index d028faf3..4c18ce4d 100644 --- a/apps/docs/docs/en/guide/theming.mdx +++ b/apps/docs/docs/en/guide/theming.mdx @@ -1,253 +1,199 @@ -import { ThemePlayground } from '../../../src/components/theme-playground'; +import { ComponentExample } from '../../../src/components/component-example'; +import { ThemeBasic } from '../../../src/examples/en/theming/basic'; +import { ThemeAppearance } from '../../../src/examples/en/theming/appearance'; +import { ThemeScoped } from '../../../src/examples/en/theming/scoped'; # Theming -Configure components with [ConfigProvider](/en/components/config-provider). Start with a primary color, base radius and control height; related sizes and interaction colors are derived for you. +Set colors, corner radii and dark mode through ConfigProvider's `theme`. A few settings are usually enough; related styles update automatically. -## Start with a few settings +## Change colors and corners -Wrap the application and import the stylesheet once: +Wrap your application in ConfigProvider. This example uses a purple primary color and an 8px base radius: -```tsx -import { Button, ConfigProvider, Input } from '@ifhover/leaf-ui'; -import '@ifhover/leaf-ui/styles.css'; +} fileName="basic.tsx" width="fit-content"> -export function App() { - return ( - - - - - ); -} +```tsx lineNumbers file="../../../src/examples/en/theming/basic.tsx" ``` -Omitted settings use defaults. Hover, pressed, soft backgrounds and focus colors follow the primary color automatically. - -## Try your theme + -Choose a preset or a custom color, then adjust radius and appearance. Expand the theme code or copy it directly into your application. +Replace Button with your application content. Buttons, fields, selectors and popups share the theme. Hover, pressed and focus colors derive automatically. - +`borderRadius` sets the base radius. Cards, dialogs and other surfaces derive radii for their own level, so you do not need to configure each component. Numbers represent pixels; CSS lengths such as `'0.5rem'` also work. -## Derived sizes +### Common settings -The defaults are a 10px base radius, 40px control height and 14px font size: +These fields all belong inside `theme`. Set only what you want to change. -| Base setting | Derived values | Default result | +| Setting | Example | Purpose | | --- | --- | --- | -| `borderRadius` | Small / medium / large radius × 0.6 / 1.2 / 1.6 | `6px / 12px / 16px` | -| `controlHeight` | Small height − 6px; large height + 6px | `34px / 46px` | -| `fontSize` | Small font − 1px; large font + 2px | `13px / 16px` | - -Numbers are pixels; CSS lengths with units are also accepted. Buttons, inputs and pickers share the same sizes. +| `primaryColor` | `'#7654c6'` | Set the primary color and derive related interaction colors. | +| `borderRadius` | `8` | Set the base radius; use `0` for square corners. | +| `appearance` | `'light'` / `'dark'` | Choose light or dark appearance. | +| `controlHeight` | `40` | Set the base single-line control height; small and large sizes follow. | +| `fontSize` | `14` | Set the base component font size. | +| `fontFamily` | `'Inter, sans-serif'` | Set the font family; your application loads the font files. | +| `motion` | `false` | Disable regional animations while keeping interactions available. | -Radii step down from the outside in: dialogs, drawers and popups use the large radius; content surfaces such as cards, alerts and choice cards use the medium radius; buttons, fields and table frames use the base radius; checkboxes and other small elements use the small radius; status tags are pills. Content surfaces are separated by hairline borders rather than shadows. +Unspecified fields inherit the outer configuration. Defaults are light appearance, a 10px base radius, 36px base control height and 14px font size. -Inset options and selected backgrounds derive their radius from the surface radius, padding and border: the default popup uses `16px - 6px - 1px = 9px`. Set `borderRadius` and `padding` through `theme.components.Floating` for shared popups; local `Dropdown` settings take precedence. Card `Tabs`, `Segmented` and `Menu` follow their own surfaces. Padding accepts numbers, CSS lengths and 1–4 value shorthand (such as `4px 8px`), updating the inner curves in each direction. +See [LeafTheme](/en/components/config-provider#leaftheme) for all theme fields. For individual text colors, backgrounds or sizes, use [LeafThemeTokens](/en/api/theme-tokens#leafthemetokens). -## Regional themes +## Use dark mode -Nested providers override only supplied settings, including individual token overrides: +Set `appearance: 'dark'`. Use `'light'` to switch back: ```tsx - - - - - + + ``` -Floating panels, Modal, confirmations and messages inherit their calling region’s theme. ConfigProvider manages confirmations and messages automatically; descendants call useConfirm and useMessage. Set `locale` separately for built-in text. +Component text, backgrounds and borders change together. An explicit `primaryColor` keeps its value; otherwise, the appearance supplies its default primary color. -## Appearance and switching +ConfigProvider styles the components in its region. It does not add a background to the page's `body`. To update your own page backgrounds and text, use the [CSS variables](#use-the-theme-in-application-css) below. -```tsx -import { useState } from 'react'; -import { Button, ConfigProvider } from '@ifhover/leaf-ui'; +### Add an appearance switch -export function ThemeSwitch() { - const [dark, setDark] = useState(false); - return ( - - - - ); -} +Theme settings are ordinary React props, so state can update them. Toggle the switch below to change the card, button and text together: + +} fileName="appearance.tsx" width={360}> + +```tsx lineNumbers file="../../../src/examples/en/theming/appearance.tsx" ``` -Dark appearance supplies suitable text, surface, border and status colors. Explicit primary colors keep their value. Pair very light brand colors with a suitable `onPrimaryColor` override. + -## Advanced overrides +### Follow the system -Use `theme.tokens` when you need specific derived sizes, semantic colors, surfaces, typography, motion or overlay stacking: +Use `useSystemAppearance` and pass its result to `theme.appearance` to follow the operating system: ```tsx -import type { LeafTheme } from '@ifhover/leaf-ui'; +import { ConfigProvider, useSystemAppearance } from '@ifhover/leaf-ui'; -const theme: LeafTheme = { - controlHeight: 36, - borderRadius: 8, - tokens: { - controlHeightSm: 28, - borderRadiusLg: 18, - infoColor: '#1677ff', - motionDuration: 200, - }, -}; +export function AppTheme() { + const appearance = useSystemAppearance('light'); + + return ( + + + + ); +} ``` -Explicit small and large sizes take precedence over derived values. See [LeafThemeTokens](/en/components/config-provider#leafthemetokens) for all options. +The `'light'` argument is the initial appearance. After mounting, the hook reads and watches system preferences. Your application stores manual choices and decides when to keep following the system. -## Tune the interaction rhythm +## Theme one region -The default base duration is 200ms. Press and hover feedback use a shorter duration; selection, disclosure and overlays use timing suited to their change. Row reordering and dialog height changes continue from the current visual position. +Nest another ConfigProvider to theme a specific area. The inner provider overrides only the supplied fields and inherits the rest: -Use `tokens.motionDuration` to adjust the region's pace, `motionEasing` for ordinary transitions, and `motionSpring` for selection and shape changes: +} fileName="scoped.tsx" width="fit-content"> -```tsx - - - +```tsx lineNumbers file="../../../src/examples/en/theming/scoped.tsx" ``` -`motion: false` stops CSS and JavaScript animations in the region, including loading rotation and portals. An inner `motion: true` region can resume motion. The system's reduced-motion preference always takes precedence. Direct interactions such as typing, dragging and cropping respond immediately. + -Surfaces and text use neutral grays, with the primary color reserved for actions and selection. Use `subtleTextColor`, `raisedSurfaceColor`, `borderHoverColor` and `shadowXs` to refine the hierarchy. +Both buttons use an 8px base radius. The inner provider changes only the primary color to purple. Select popups, Modal, and feedback created with `useConfirm` or `useMessage` also use their calling region's theme. -## Reuse styles in your own components +## Adjust control size and density -Read the current region's CSS variables in your application styles. Use ConfigProvider to configure Leaf UI, and `var()` to make custom components follow that configuration: +For one control, prefer its `size="sm"` or `size="lg"` prop. To make an entire region more compact, set ConfigProvider's `density`: ```tsx - - - + + ``` -```scss -.product-button { - min-height: var(--leaf-control-height); - padding: 0 14px; - border: 0; - border-radius: var(--leaf-radius); - background: var(--leaf-color-primary); - color: var(--leaf-color-on-primary); - font-family: var(--leaf-font-family); - font-size: var(--leaf-font-size); - font-weight: var(--leaf-font-weight); - cursor: pointer; -} -``` +`density` is a sibling of `theme`. It defaults to `'comfortable'`. The `'compact'` preset tightens control, form and popup spacing and changes the base control height from 36px to 30px without reducing text size. -| Variables to read | Purpose | -| --- | --- | -| `--leaf-color-primary` / `--leaf-color-on-primary` | Primary background and foreground | -| `--leaf-color-danger` / `--leaf-color-success` / `--leaf-color-warning` / `--leaf-color-info` | Semantic colors | -| `--leaf-color-text` / `--leaf-color-text-muted` | Main / secondary text | -| `--leaf-color-surface` / `--leaf-color-surface-muted` / `--leaf-color-border` | Surfaces and borders | -| `--leaf-radius` / `--leaf-radius-sm` / `--leaf-radius-md` / `--leaf-radius-lg` | Base / small / medium / large radius | -| `--leaf-control-height` / `--leaf-control-height-sm` / `--leaf-control-height-lg` | Base / small / large height | -| `--leaf-font-family` / `--leaf-font-size` / `--leaf-font-weight` | Typography | -| `--leaf-motion-duration` / `--leaf-motion-fast` / `--leaf-motion-slow` | Base / fast / layout transition duration | -| `--leaf-motion-easing` / `--leaf-motion-spring` | Ordinary / spring easing | - -Place custom components inside the intended provider. Import the compiled `styles.css`; consumers do not need Sass. +Set `theme.controlHeight` when you need an explicit height; it overrides the density preset. For example, `controlHeight: 40` produces 34px / 40px / 46px controls. See [ConfigProvider](/en/components/config-provider#base-height) for a size comparison. -## Server rendering +## Advanced customization -Static CSS and dynamic themes work together without generating CSS at runtime. See [SSR usage](/en/guide/ssr) for Next.js and other frameworks. +Use the settings below when your design needs more specific overrides. For colors, corners and appearance, the basic settings above are sufficient. -## Spacing, typography and component overrides +### Override individual colors or sizes -`spacing` derives small and large spacing. `lineHeight`, `headingWeight` and `elevation` configure body line height, Typography heading weight and shadows; dialog, card and other component titles use `tokens.fontWeight`. Keep configuring through ConfigProvider; use `theme.components` for scoped component overrides: +Use `theme.tokens` for specific styles beyond the basic settings. This example adjusts the success color, large radius and animation duration: ```tsx +import type { LeafTheme } from '@ifhover/leaf-ui'; + const theme = { - spacing: 8, lineHeight: 1.6, headingWeight: 600, elevation: 'soft', - components: { - Button: { borderRadius: 6, padding: '0 18px' }, - Input: { borderRadius: 6, controlHeight: 36 }, - Card: { padding: 16, shadow: '0 6px 20px #0001' }, + primaryColor: '#7654c6', + borderRadius: 8, + tokens: { + successColor: '#20834a', + borderRadiusLg: 18, + motionDuration: 160, }, } satisfies LeafTheme; ``` -Use exported component names. Each component consumes tokens relevant to its layout: input controls use radius, height, background and font size; containers use padding and shadow; layout components use gaps. For further changes, use component `className` / `style` and popup `popupClassName` / `popupStyle`. +Pass this object to ``. Here, `borderRadiusLg` overrides the derived large radius, and numeric `motionDuration` values are milliseconds. See [LeafThemeTokens](/en/api/theme-tokens#leafthemetokens) for the complete reference. + +For a very light primary color, use `tokens.onPrimaryColor` to set the text color on solid primary buttons. Check readability in normal, hovered and disabled states after changing your palette. `motion: false` disables regional animations; system reduced-motion preferences always take precedence. + +### Style one component type -## System appearance, responsive breakpoints and contrast +`theme.components` assigns styles by component name. For example, use 4px button corners and change card padding: ```tsx -import { ConfigProvider, colorContrast, useBreakpoint, useSystemAppearance } from '@ifhover/leaf-ui'; -function Application() { - const appearance = useSystemAppearance('light'); - return ; -} -function Content() { - const { md } = useBreakpoint({ md: false }); - return

{md ? 'Wide layout' : 'Compact layout'}

; -} -const contrast = colorContrast('#ffffff', '#20834a'); + + + ``` -`useBreakpoint` reads breakpoints from the current region for your application layout. Grid's existing `sm/md/lg` CSS rules still use the fixed breakpoints listed in its docs. `colorContrast` returns a WCAG contrast ratio, or `null` for invalid colors. Transparent colors are composited with the background and optional third canvas argument. Aim for 4.5 for ordinary text, or 3 for large text. +See [LeafComponentTokens](/en/api/theme-tokens#leafcomponenttokens) for style fields, and each component's documentation for the fields it supports. -System preferences update after client hydration. For SSR, start with a server Cookie or a consistent fallback; see [SSR](/en/guide/ssr). +`theme.components` changes styles. To set default button `size`, `variant` or other component props, use ConfigProvider's separate `components` prop; see [Component defaults](/en/components/config-provider#component-defaults). -## Section surfaces +### Use the theme in application CSS -Dialog sections, rich choices and neutral alerts derive their appearance from the same base theme. The muted surface controls supporting areas, and dividerColor is an optional advanced override. Configure individual section backgrounds through components; Confirm shares Modal settings. Header and footer backgrounds apply to Modal, Drawer and Card; borderColor also applies to rich Checkbox/Radio and Alert surfaces. +ConfigProvider exposes `--leaf-*` CSS variables within its region. Your own content can read them to follow color and appearance changes: ```tsx - - + +
Application content
``` -## LeafComponentTokens +```css +.app-panel { + padding: 16px; + color: var(--leaf-color-text); + background: var(--leaf-color-surface); + border: 1px solid var(--leaf-color-border); + border-radius: var(--leaf-radius); +} +``` -| Property | Type | Default | Description | -| --- | --- | --- | --- | -| `borderRadius` | `string \| number` | — | Border radius; numbers are pixels. | -| `controlHeight` | `string \| number` | — | Base control height, deriving other sizes. | -| `padding` | `string \| number` | — | Padding for content or a highlighted target. | -| `gap` | `string \| number` | — | Gap between items; numbers are pixels. | -| `fontSize` | `string \| number` | — | Font size; numbers are pixels. | -| `color` | `string` | — | Color or semantic tone. | -| `background` | `string` | — | Component surface background. | -| `borderColor` | `string` | — | Color of outer borders and section dividers. | -| `headerBackground` | `string` | — | Header background; inherits the surface by default. | -| `footerBackground` | `string` | — | Footer background; divided surfaces use the muted surface by default. | -| `shadow` | `string` | — | CSS box-shadow value. | +| Common variable | Purpose | +| --- | --- | +| `--leaf-color-primary` | Current primary color. | +| `--leaf-color-on-primary` | Text on a solid primary background. | +| `--leaf-color-text` / `--leaf-color-text-muted` | Primary and secondary text. | +| `--leaf-color-surface` / `--leaf-color-surface-muted` | Content and supporting backgrounds. | +| `--leaf-color-border` | Border color. | +| `--leaf-radius` | Base radius. | -## LeafThemeStyle +Configure Leaf UI component themes through ConfigProvider and read the results from application CSS. Changing a single base CSS variable does not regenerate the full set of interaction colors. -| Property | Type | Default | Description | -| --- | --- | --- | --- | -| `` [key: `--leaf-${string}`] `` | `string \| number \| undefined` | — | Reusable CSS custom property. | +For Tailwind CSS, import the package configuration to reuse the theme through utilities such as `bg-leaf-surface` and `text-leaf-primary`. See [Tailwind CSS](/en/guide/tailwindcss) for Tailwind 3 / 4 setup. +## Server rendering -## Density and portable colors +Use the same appearance for the server and the client's initial render. To remember a user's choice, read a cookie on the server and supply that initial value. System appearance updates after mounting. -Use `` to reduce control heights and spacing throughout a region, including portals. Explicit theme sizes take precedence. Derived colors are calculated in JavaScript; static light/dark palettes support standalone CSS and SSR. Pass concrete colors for a deterministic first frame; CSS variables resolve in the browser. +Prefer concrete HEX, RGB or HSL colors. A color such as `var(--brand-color)` resolves after mounting in the browser and cannot guarantee matching derived colors in the server's first frame. -See [Browser compatibility](./compatibility), [Design resources](./design-resources) and [Visual standards](./design-standards). +See [Installation & quick start](/en/guide/getting-started) for setup, [server-side rendering](/en/guide/ssr) for frameworks such as Next.js, and [Browser compatibility](/en/guide/compatibility) for support targets. diff --git a/apps/docs/docs/en/guide/workspace.mdx b/apps/docs/docs/en/guide/workspace.mdx index 4e5625f0..8249acf4 100644 --- a/apps/docs/docs/en/guide/workspace.mdx +++ b/apps/docs/docs/en/guide/workspace.mdx @@ -1,19 +1,5 @@ -import { BusinessWorkspace } from '../../../src/examples/scenarios/workspace'; +# Workspace composition -# Complete workspace +Use [Layout](../components/layout) for the page shell, [Flex](../components/flex) and [Grid](../components/grid) for layout, [Form](../components/form) for editing, and [Table](../components/table) for structured data. -A working project-management and team-settings page. Search, create, edit, archive and restore projects, then save team details, appearance, density and background blur preferences. - - - -This example uses in-memory data and a 350ms simulated save; refreshing restores the initial data. Enter an existing project name to exercise server field errors and retry. Required names and email addresses use native form validation. Search for an unknown name to see the empty state. Discard changes to restore saved preferences. - -Replace the simulated save with your API and handle authentication, persistence and cancellation in the application. - -
-View source - -```tsx lineNumbers file="../../../src/examples/scenarios/workspace.tsx" -``` - -
+Keep authentication, persistence and request cancellation in application code. Component pages show focused API examples. diff --git a/apps/docs/docs/guide/accessibility.mdx b/apps/docs/docs/guide/accessibility.mdx deleted file mode 100644 index bbc6d320..00000000 --- a/apps/docs/docs/guide/accessibility.mdx +++ /dev/null @@ -1,21 +0,0 @@ -import { AccessibilityWorkbench } from '../../src/examples/scenarios/accessibility'; - -# 可访问性验收 - -使用下面的工作台检查键盘、主题、密度、浮层和减少动态效果。组件提供基础语义;应用还需要为业务字段、图标按钮和复合控件补充合适的名称与说明。 - - - -| 检查 | 预期 | -| --- | --- | -| Tab / Shift+Tab | 顺序合理,焦点可见,禁用操作不进入顺序 | -| Enter / Space / 方向键 | 按钮可触发,选择和标签页可切换,Slider 可调整 | -| 弹窗 + 内部 Select | 焦点留在浮层体系内,Escape 先关闭内部浮层,再关闭弹窗 | -| 关闭浮层 | 焦点返回打开按钮,页面恢复可滚动与可操作 | -| 空字段 / 无效邮箱 | 显示字段错误,焦点定位到需要修正的字段 | -| 深浅色 / 高对比度 | 焦点、状态和文本仍可辨认;业务自定义色需重新测量对比度 | -| 200% / 400% 放大 | 320 CSS px 宽度可重排,操作可见,无不必要的页面横向滚动 | -| 触屏 | 点按可开关浮层,关键操作目标足够大,不依赖 hover | -| 减少动态效果 | 关闭动画后所有流程仍可使用 | - -自动检查使用 axe 与真实浏览器行为断言,不进行截图基线对比。Chromium/WebKit 模拟的触摸与视口重排不能代替真机浏览器缩放测试。读屏器需另行人工验收:Windows + NVDA,macOS/iOS + VoiceOver,并记录版本、读出的名称/角色/状态及错误通知。未经人工执行的项目必须标记“待验收”。实际记录见仓库 `packages/react/ACCESSIBILITY.md`。 diff --git a/apps/docs/docs/guide/compatibility.mdx b/apps/docs/docs/guide/compatibility.mdx index 27a3a4ef..02de30a8 100644 --- a/apps/docs/docs/guide/compatibility.mdx +++ b/apps/docs/docs/guide/compatibility.mdx @@ -1,6 +1,21 @@ +import { withBase } from '@rspress/core/runtime'; + # 浏览器兼容性 -建议使用当前与上一代稳定版 Chrome、Edge、Firefox 或 Safari。工程基线为 Chrome/Edge 109、Firefox 121、Safari 16.4;这些最低版本是支持目标,不能等同于已逐个实测的版本。IE 和旧版 WebView 不在支持范围内。 +建议使用 Chrome、Edge、Firefox 或 Safari 的当前或上一代稳定版。 + +## 最低版本 + +| 浏览器 | 最低版本要求 | +| --- | --- | +| Google Chrome | 109 及以上 | +| Microsoft Edge | 109 及以上 | +| Mozilla Firefox | 121 及以上 | +| Safari | 16.4 及以上 | + +表中版本是 Leaf UI 的最低支持目标,不代表已逐个版本完成实测。IE、旧版 Edge(EdgeHTML)和旧版 WebView 不在支持范围内。 + +## 功能与降级 | 能力 | 实现与降级 | | --- | --- | @@ -21,4 +36,6 @@ 颜色支持具体 hex、rgb、hsl 和命名色,推荐通过 `theme` / `tokens` 配置。JS 无法在服务端解析 CSS 变量;使用 `var(...)` 时,浏览器在挂载后解析并生成派生值,祖先的 class/style 变化会重新同步。需要严格一致的 SSR 首屏时应传入具体颜色。不要只改 CSS 中的基础色而期望所有派生色自动重新计算。 +## 验证范围 + CI 已配置 Chromium、Firefox、WebKit 行为测试,以及 React 18/19、SSR 和包导出检查。WebKit 的通过不代表已经在真实 Safari 或 iOS 设备验收。当前本机 Firefox 启动存在环境问题(`spawn UNKNOWN`),不能将其列为通过;具体已执行记录见仓库 `packages/react/ACCESSIBILITY.md`。 diff --git a/apps/docs/docs/guide/control-contracts.mdx b/apps/docs/docs/guide/control-contracts.mdx deleted file mode 100644 index 040b6c47..00000000 --- a/apps/docs/docs/guide/control-contracts.mdx +++ /dev/null @@ -1,66 +0,0 @@ -# 控件与浮层 - -输入控件与浮层使用一致的状态约定。 - -## 禁用与只读 - -- `disabled` 禁止交互,也不参加原生表单提交。`Form disabled` 会禁用所有内部控件,单个控件的 `disabled={false}` 不会绕过它。 -- `readOnly` 锁定业务值,禁止输入、选项选择、清除与标签删除;已有值仍可以提交。 -- 日期、时间和 Select 的 `inputReadOnly` 只关闭键盘输入,仍允许面板选择。它适合需要减少输入错误的场景。 -- `required` 按整个字段的值校验,CheckboxGroup、Transfer 等内部搜索框不会额外成为必填项。 - -## 原生表单与重置 - -传入 `name` 后可通过 `new FormData(form)` 读取值。多选值使用 `data.getAll(name)`;具体序列化方式见各组件文档。非受控组件通过 `defaultValue` 提供初始值,原生 `reset` 恢复初始值;受控组件需要同步重置应用状态。 - -日期的 `format` 控制显示文字和 `onChange` 的文字参数;FormData 仍使用稳定格式:日期 `YYYY-MM-DD`、时间 `HH:mm[:ss]`、区间 `start/end`。避免把用户显示格式当作服务端交换格式。 - -## 定制浮层 - -选择器共用下面的浮层选项。单个组件的配置优先于 ConfigProvider 的 `getPopupContainer`。`popupRender` 可附加操作区,保留收到的内容以维持键盘操作。 - -```tsx -import { ConfigProvider, Select } from '@ifhover/leaf-ui'; - - document.getElementById('overlay-root')!}> - - -``` - -自定义 `locale` 使用 BCP 47 名称,让数字与日期的 Intl 格式保持一致,并通过两个消息属性提供自己的翻译。完整文本字段见 [LeafMessages](/components/config-provider#leafmessages)。 - -## PopupOptions - -| 属性 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `popupPlacement` | `'top' \| 'top-start' \| 'top-end' \| 'bottom' \| 'bottom-start' \| 'bottom-end' \| 'left' \| 'left-start' \| 'left-end' \| 'right' \| 'right-start' \| 'right-end'` | — | 浮层相对触发控件的位置。 | -| `popupClassName` | `string` | — | 浮层的自定义类名。 | -| `popupStyle` | `CSSProperties` | — | 浮层的自定义样式。 | -| `popupRender` | `(content: React.ReactNode) => React.ReactNode` | — | 包装或补充面板内容,保留传入的内容以维持交互。 | -| `getPopupContainer` | `() => Element \| DocumentFragment` | — | 返回浮层的挂载容器,也可返回 ShadowRoot。 | diff --git a/apps/docs/docs/guide/design-resources.mdx b/apps/docs/docs/guide/design-resources.mdx deleted file mode 100644 index 8c952a8c..00000000 --- a/apps/docs/docs/guide/design-resources.mdx +++ /dev/null @@ -1,17 +0,0 @@ -import { withBase } from '@rspress/core/runtime'; - -# 设计资源 - -资源由组件运行时使用的颜色、派生配方和密度参数生成,便于设计与实现保持一致。 - -- DTCG tokens:标准颜色、尺寸、行高与动效时长。 -- Tokens Studio tokens:兼容 `type` / `value` 格式,选择一个外观和一个密度集合。 -- 浅色基础变量、深色基础变量。 -- 浅色组件状态、深色组件状态:可编辑文字、路径与命名分组。 -- 本地 Figma 导入插件:原生颜色/密度变量、Button 与 Input 组件变体。 - -SVG 可直接拖入 Figma。插件解压后,在 Figma 桌面端选择 **Plugins → Development → Import plugin from manifest**,导入 `manifest.json` 并运行 **Leaf UI Resources**。每次运行创建一个页面与变量集合;可使用 Undo 撤回。字体使用 Inter,缺少时插件选择可用字体。 - -插件包括 Button 的四种外观、四种状态和两种密度,Input 的默认、焦点、错误、禁用状态与两种密度。颜色填充与控件高度绑定变量。其余 SVG 是可编辑设计参考,可在 Figma 中进一步转换为组件。 - -在仓库运行 `pnpm design:export` 可重新生成资源。本地已检查 JSON、SVG 与 JavaScript 语法;Figma 实际导入效果需要人工验收。这套资源尚未发布为在线 Figma 组件库。 diff --git a/apps/docs/docs/guide/design-standards.mdx b/apps/docs/docs/guide/design-standards.mdx index 256bafb5..5d37682e 100644 --- a/apps/docs/docs/guide/design-standards.mdx +++ b/apps/docs/docs/guide/design-standards.mdx @@ -2,13 +2,18 @@ 统一使用语义颜色、尺寸与间距变量,组件在深浅色、舒适与紧凑模式下保持一致。完整规范维护在仓库 `DESIGN-STANDARDS.md`。 +Card 的小、默认和大尺寸统一调整留白、标题字号和标题栏高度,内嵌 Tabs 默认跟随卡片尺寸。标签页与标题共用一个头部区域:有标题时在标题下方,没有标题时占满标题栏高度并垂直居中,分割线只位于整条标签栏下方。标签文字与正文左边缘对齐,页面内容沿用卡片内边距,底部继续由 `divided` 控制分区外观。 + 警告状态统一使用明亮的黄橙色,图标、控件边框和状态填充保持一致;文字使用独立派生的可读颜色。可通过 ConfigProvider 的 `theme.tokens.warningColor` 定制警告色。 -下拉菜单的悬停、键盘定位与按下共用移动高亮背景,危险项的颜色平滑过渡。Select 的选中项和移动高亮使用相同底色,经过选中项时保持连续移动;选中值通过文字与勾选标记区分。 +下拉选项统一使用灰色的悬停与键盘候选背景,已选中项保留主题色底色、文字和勾选标记。Select、自动完成、级联、树选择、时间列表和菜单使用同一配色方案;自动完成、提及和命令面板中的候选项仍使用灰色。移动背景平滑切换位置和颜色,经过选中项时不会叠色;禁用项不响应交互,危险操作保留红色提示。配色随 ConfigProvider 的区域主题与深浅外观调整。 + +Select、TreeSelect 和 Cascader 的多选标签使用一致的尺寸与间距。默认可换行,`tagWrap={false}` 保持单行并以 `+N` 折叠超出部分;长标签自动省略,搜索框和操作按钮始终保留空间。折叠只影响展示,不改变已选值。 | 基础项 | 舒适 | 紧凑 | | --- | --- | --- | -| 控件高度 | 40px | 34px | +| 控件高度 | 36px | 30px | +| 下拉选项最小高度 | 32px | 26px | | 正文字号 / 行高 | 14px / 1.6 | 14px / 1.6 | | 间距单位 | 8px | 6px | | 表单行间距 | 24px | 16px | @@ -18,14 +23,22 @@ 主按钮和描边按钮点击后沿外轮廓快速扩散一圈波纹,再缓慢淡出,按钮与文字保持稳定;文字按钮不展示波纹。主按钮悬停提亮、按下加深,描边按钮通过文字和边框变色反馈。菜单和标签页通过颜色、透明度、边框和阴影反馈,选中指示器可以移动到目标项。抽屉平滑滑入;提示内容整体出现,图标和文字不单独回弹。关闭区域动效或系统减少动态效果时,按钮不展示波纹;系统偏好优先于页面设置。 -层级由圆角、边框和文字共同建立。圆角由外向内递减:弹窗、抽屉和浮层使用大圆角,卡片、提示与选项卡片等内容表面使用中圆角,按钮、输入框和表格外框使用基础圆角,复选框等小元素使用小圆角,状态标签为胶囊形。内容表面依靠细边框和浅灰底区分,不叠加阴影;阴影只留给真正浮起的层,例如浮层、弹窗、拖动中的项目和滑块把手。文字层级依靠字号与颜色:正文 14px、辅助文字 13px,组件标题、标签和按钮使用 500 字重,600 只用于文档标题;选项分组等小标题使用 12px、加字距的灰色样式。 +层级由圆角、边框和文字共同建立。圆角由外向内递减:弹窗、抽屉和浮层使用大圆角,卡片、提示与选项卡片等内容表面使用中圆角,按钮、输入框和表格外框使用基础圆角,复选框等小元素使用小圆角,状态标签使用无边框的小圆角。内容表面依靠细边框和浅灰底区分,不叠加阴影;阴影只留给真正浮起的层,例如浮层、弹窗、拖动中的项目和滑块把手。文字层级依靠字号与颜色:正文 14px、辅助文字 13px,组件标题、标签和按钮使用 500 字重,600 只用于文档标题;选项分组等小标题使用 12px、加字距的灰色样式。 紧贴容器的内部背景采用嵌套圆角:`内部圆角 = max(0px, 外框圆角 − 内边距 − 边框厚度)`。默认浮层的外框为 16px、内边距为 6px、边框为 1px,因此选项为 9px。悬停、选中和移动高亮背景使用同一轮廓,并随主题自动更新;非等距留白按各方向计算,多层结构计入额外留白。小圆角和直角主题收敛到 0,独立控件、日历格子、标签和图标继续使用各自的语义圆角。 +Menu 的导航项、悬停背景与移动选中高亮使用和按钮一致的基础圆角,不随菜单内边距缩小;单独配置 Menu 圆角时,这些轮廓一起更新。 + +Pagination 默认使用描边按钮,也可切换为无边框样式;尺寸沿用控件高度,按下时保持位置与大小,选中背景平滑移动。描边按钮通过文字和边框变色反馈,无边框按钮使用中性背景。 + +卡片样式 Tabs 的内层标签和移动选中背景使用相同圆角,由标签栏圆角减去实际留白和边框派生,最小为零,跟随区域主题或组件级圆角配置。标签栏随内容收缩,尺寸包含边框和内边距,正文仍使用可用宽度。 + +Tag 仅提供无边框的浅底和实色形态,圆角取主题小圆角的六分之五,默认 5px;组件级圆角配置优先。 + 组件验收要覆盖默认、悬停、按下、键盘焦点、选中、禁用、加载、错误、空状态。状态同时用文字或 ARIA 表达,不能只依靠颜色。图标按钮需要可访问名称;图标与文本的基线、弹窗表单与页脚边缘需要对齐。 紧凑模式面向鼠标和键盘的信息密集页面,不缩小正文字号。触屏优先舒适或更大的显式尺寸;必要操作目标至少 24px,推荐 44px。尺寸覆盖的顺序是局部组件设置、显式主题尺寸、区域密度、默认值。 -按钮的横向留白随实际高度按比例生成,各尺寸都可以用组件级 `padding` 定制。小尺寸步骤条保留正文字号,卡片和列表形式的选项组使用统一的分区标题。Modal 的标题图标与浅分隔线适合丰富内容;Confirm 是通过 API 调用的简短提示,使用留白分区,图标放在正文旁。没有副标题时减少头部留白,Modal 和 Confirm 的底部操作区保持紧凑。 +按钮的横向留白随实际高度按比例生成,各尺寸都可以用组件级 `padding` 定制。小尺寸步骤条保留正文字号,卡片和列表形式的选项组使用统一的分区标题。Card 标题区域的上下留白一致,与正文衔接时不重复叠加间距。Modal 的标题图标与浅分隔线适合丰富内容;Confirm 是通过 API 调用的简短提示,使用留白分区,图标放在正文旁。没有副标题时减少头部留白,Card 分区、Modal 和 Drawer 的底部操作区使用一致的紧凑留白;Confirm 沿用相同的底部留白。 链接按钮无背景、边框、内边距和固定控件高度,悬停 / 按下只改变文字颜色,不展示波纹。页面跳转使用 Link;Button 的链接样式用于文字操作。 diff --git a/apps/docs/docs/guide/getting-started.mdx b/apps/docs/docs/guide/getting-started.mdx index 8b8b58f5..78219fec 100644 --- a/apps/docs/docs/guide/getting-started.mdx +++ b/apps/docs/docs/guide/getting-started.mdx @@ -1,37 +1,40 @@ import { ComponentExample } from '../../src/components/component-example'; import { GettingStartedExample } from '../../src/components/guide-examples'; +import { PackageInstall } from '../../src/components/package-install'; # 安装&快速开始 -Leaf UI 支持 React 18 与 React 19。安装组件包并引入样式后,就可以在 React 页面中使用。 +Leaf UI 支持 React 18 与 React 19。安装后即可在 React 页面中使用,基础样式与组件样式会自动加载。 ## 安装组件包 在你的 React 项目目录下执行: + + ```bash -pnpm add @ifhover/leaf-ui +npm install @ifhover/leaf-ui ``` -也可以使用 npm 或 Yarn: - ```bash -npm install @ifhover/leaf-ui -# 或者 yarn add @ifhover/leaf-ui ``` -React 和 React DOM 由你的应用提供。如果项目尚未安装,请先执行 `pnpm add react react-dom`。可在 [npm](https://www.npmjs.com/package/@ifhover/leaf-ui) 查看最新版本。 +```bash +pnpm add @ifhover/leaf-ui +``` -## 引入样式 +```bash +bun add @ifhover/leaf-ui +``` -在应用入口导入一次基础样式,包含主题变量、公共浮层和 Confirm、Message 等 API 服务的样式: + -```tsx title="src/main.tsx" -import '@ifhover/leaf-ui/styles.css'; -``` +React 和 React DOM 由你的应用提供。如果项目尚未安装,请先执行 `pnpm add react react-dom`。可在 [npm](https://www.npmjs.com/package/@ifhover/leaf-ui) 查看最新版本。 + +浏览器打包时,组件入口会自动加载主题变量、公共样式与所需的组件和服务样式,无需手动导入 CSS 或安装 Sass。Next.js、TanStack Start 等框架的额外配置见[服务端渲染](/guide/ssr)。 -浏览器打包时,组件入口会自动加载组件样式,无需再手动导入每个组件的 CSS,也无需安装 Sass。Next.js 等框架的配置见 [SSR 使用](/guide/ssr)。 +示例通过 Flex、Space、Grid 和 FormField 组织布局,无需安装 Tailwind CSS。 ## 使用组件 @@ -50,7 +53,7 @@ import '@ifhover/leaf-ui/styles.css'; - [组件总览](/components/):浏览可用组件。 - [Button 按钮](/components/button):查看样式、尺寸、加载状态与完整 API。 -- [使用图标](/guide/icons):通过 Lucide 图标表达操作。 -- [定制主题](/guide/theming):实时调整颜色、圆角和外观。 -- [SSR 使用](/guide/ssr):在 Next.js 等框架中使用组件。 +- [使用图标](/components/icons):通过 Lucide 图标表达操作。 +- [定制主题](/guide/theming):设置主题色、圆角和深色模式。 +- [服务端渲染](/guide/ssr):Next.js、TanStack Start 等框架的额外配置。 - [浏览器兼容性](./compatibility):查看支持的浏览器与降级策略。 diff --git a/apps/docs/docs/guide/icons.mdx b/apps/docs/docs/guide/icons.mdx deleted file mode 100644 index ed5d8709..00000000 --- a/apps/docs/docs/guide/icons.mdx +++ /dev/null @@ -1,29 +0,0 @@ -import { ComponentExample } from '../../src/components/component-example'; -import { ButtonIcons } from '../../src/components/button-examples'; - -# 使用图标 - -Leaf UI 使用 **Lucide** 图标。按钮通过 startIcon / endIcon 放置图标,输入框通过 prefix / suffix 放置装饰图标。 - -## 安装和导入 - -在应用中直接引用图标时,将 lucide-react 声明为应用依赖: - -```bash -pnpm add lucide-react -``` - -按名称导入需要的图标即可。尺寸默认跟随组件的图标容器,颜色继承组件的当前文字色。 - -} fileName="icons.tsx"> - -```tsx lineNumbers file="../../src/examples/button/icons.tsx" -``` - - - -## 为图标操作提供名称 - -带文字的按钮使用文字作为名称,前后图标按装饰性内容处理。仅图标按钮必须提供 aria-label,例如“添加项目”或“删除项目”。图标不应作为操作含义的唯一提示。 - -Leaf UI 的图标属性接受 ReactNode,也可以传入你现有项目的图标组件。Button 的危险状态会让图标和文字一起使用危险色,无需在图标中固定颜色。 diff --git a/apps/docs/docs/guide/internationalization.mdx b/apps/docs/docs/guide/internationalization.mdx new file mode 100644 index 00000000..70293a83 --- /dev/null +++ b/apps/docs/docs/guide/internationalization.mdx @@ -0,0 +1,29 @@ +# 切换语言 + +通过 ConfigProvider 的 `locale` 设置组件语言,日期面板、空状态、清除按钮等内置文本会一起切换。 + +## 设置语言 + +在应用入口用 ConfigProvider 包住页面,指定语言代码即可。下面将日期面板的内置文本切换为英文: + +```tsx +import { ConfigProvider, DatePicker } from '@ifhover/leaf-ui'; + +export default function App() { + return ( + + + + ); +} +``` + +把 DatePicker 换成你的应用内容,就能统一设置应用内组件及浮层的语言。默认使用简体中文;所有内置语言及代码见 [ConfigProvider → 内置语言](/components/config-provider#内置语言)。 + +## 在应用中切换 + +如果应用已有语言选择器,将当前语言状态传给 `locale` 即可。状态变化后,区域内的组件会随之更新,参见 [ConfigProvider 的切换示例](/components/config-provider#切换语言)。 + +只想改变一部分页面时,可以在该区域嵌套 ConfigProvider。内层未设置 `locale` 时会继承外层语言。 + +页面标题、字段标签和选项内容仍由应用自行翻译。如果需要内置列表以外的语言,或想修改某条提示,请参阅 [ConfigProvider → 补充自己的语言](/components/config-provider#补充自己的语言)。 diff --git a/apps/docs/docs/guide/introduction.mdx b/apps/docs/docs/guide/introduction.mdx index 85848e37..12b8dbae 100644 --- a/apps/docs/docs/guide/introduction.mdx +++ b/apps/docs/docs/guide/introduction.mdx @@ -1,33 +1,101 @@ +import { ComponentExample } from '../../src/components/component-example'; +import { + IntroductionBrowsers, + IntroductionFeature, + IntroductionFeatures, + IntroductionHero, +} from '../../src/components/introduction'; +import { PackageInstall } from '../../src/components/package-install'; +import { GettingStartedExample } from '../../src/examples/guide/getting-started'; + # 认识 Leaf UI -Leaf UI 是一个轻盈、自然、可定制的 React 组件库。以绿色为起点,让组件融入你的产品,也保留你的个性。 + + +一套为 React 应用准备的 UI 组件。以绿色为起点,让界面清晰自然,也保留你的品牌个性。 + + + +## 为什么选择 Leaf UI + + + + +克制的色彩、舒适的圆角与清晰的交互状态,让内容成为主角。开箱即用,也为你的设计留出空间。 + + + + +颜色、圆角、控件高度、字体和明暗外观,都可以通过 ConfigProvider 调整。支持嵌套区域主题,浮层与消息沿用所在区域的配置。 + + + + +提供 TypeScript 类型,保留原生 HTML 属性与 DOM ref。支持受控与非受控用法,表单仍可使用原生提交和 FormData。 + + + + +从按钮、输入框到日期选择、表格、导航和反馈,使用一致的尺寸与交互规则。图标采用 Lucide,也支持传入自己的图标节点。 + + + + +内置多种语言资源,支持自定义翻译、语言回退与 RTL 方向。组件文案由配置管理,业务标题和字段标签由应用自行翻译。 + + + + +键盘操作、可见焦点、禁用与加载状态贯穿组件,并响应减少动态效果的系统偏好。为仅含图标的按钮提供 aria-label,即可补充可访问名称。 + + + + +## 环境支持 + +支持 **React 18 / 19**,提供 **ESM / CommonJS** 入口与服务端渲染用法。推荐使用现代浏览器当前或上一代稳定版。 + + + +以上为最低支持目标,实际验证范围与降级策略见[浏览器兼容性](/guide/compatibility)。Next.js、TanStack Start 等框架的接入见[服务端渲染](/guide/ssr)。 -## 轻盈自然 +## 从一个组件开始 -克制的色彩、舒适的圆角和清晰的交互状态,让内容成为主角。你可以直接使用默认的绿色主题,也可以调整主题色,让组件与品牌保持一致。 +在已有 React 项目中安装: -## 可定制 + -通过 ConfigProvider 调整颜色、基础圆角、控件高度、字体与动效。大小尺寸自动生成,同一页面可以使用不同区域主题;业务组件也能读取主题变量复用样式。 +```bash +npm install @ifhover/leaf-ui +``` -## 熟悉的使用方式 +```bash +yarn add @ifhover/leaf-ui +``` -组件支持 React 18 / 19,提供 TypeScript 类型,保留原生 HTML 属性与 DOM ref。你可以沿用 React 中处理事件、表单与状态的方式。 +```bash +pnpm add @ifhover/leaf-ui +``` -## 一致的基础组件 +```bash +bun add @ifhover/leaf-ui +``` -提供常用输入、选择、日期时间、表单、菜单、弹窗和反馈组件。可在[组件总览](/components/)中浏览所有组件。 + -图标使用 `lucide-react`,你也可以将自己的 React 图标节点传给组件。 +直接导入组件即可使用,浏览器构建会自动加载所需样式,无需安装 Sass。试着点击下面的按钮: -## 照顾每一次交互 +} width="fit-content" fileName="getting-started.tsx"> -组件包含键盘焦点、禁用与加载状态,并响应减少动态效果的系统偏好。只有图标的按钮需要设置 `aria-label`,为辅助技术提供清晰的名称。 +```tsx lineNumbers file="../../src/examples/guide/getting-started.tsx" -选择菜单、日历、时间和级联面板都沿用相同的主题,可以使用键盘选择、清除或收起。面板在页面边缘自动避让,并继承局部容器的主题变量。 +``` -## 从这里开始 + -先阅读[安装&快速开始](/guide/getting-started),再前往[组件总览](/components/)找到需要的组件。 +## 接下来 -如果希望调整颜色和圆角,可以直接使用[主题编辑器](/guide/theming)。 +- [安装与快速开始](/guide/getting-started):了解安装方式与项目接入。 +- [组件总览](/components/):找到界面需要的组件。 +- [定制主题](/guide/theming):设置品牌色、圆角和深色模式。 +- [切换语言](/guide/internationalization):为应用配置组件语言。 diff --git a/apps/docs/docs/guide/scenarios.mdx b/apps/docs/docs/guide/scenarios.mdx deleted file mode 100644 index 78dacb03..00000000 --- a/apps/docs/docs/guide/scenarios.mdx +++ /dev/null @@ -1,57 +0,0 @@ -import { ComponentExample } from '../../src/components/component-example'; -import { CompositeForm, RemoteSelection } from '../../src/examples/scenarios/basic'; -import { SchemaForm } from '../../src/examples/scenarios/schema'; - -# 业务场景 - -把字段布局、异步校验和复合控件组合到一个表单中。 - -## 动态表单与异步校验 - -`Form` 使用原生表单。`useFormValidation` 接收 `FormData`,支持同步校验、Schema 适配、异步请求和服务端错误。校验回调返回以字段名为 key 的错误;提交函数中可用 `setErrors` 回填服务端字段错误。 - -将 `handleSubmit`、`handleReset` 绑定到表单。新的提交或重置会取消旧请求,异步函数需要响应 `AbortSignal`。`CheckboxGroup`、`Transfer` 等复合字段按整体值校验。文件上传在表单构造 FormData 时追加 File 或已有文件 URL,目录文件可从 `File.webkitRelativePath` 读取相对路径。 - -} fileName="basic.tsx"> - -```tsx lineNumbers file="../../src/examples/scenarios/basic.tsx" -``` - - - -使用 Zod、Valibot 等 Schema 时,在 `validate` 中解析 `FormData`,将 Schema 的 issues 映射为字段错误即可。重复字段用 `data.getAll(name)`,动态数组需要按应用的数据模型转换;Leaf 不隐式解析点号路径。React Hook Form 的接入见 [Form](../components/form#接入-react-hook-form)。 - -## Schema 与服务端错误 - -这个示例使用 Zod,把 issues 的字段路径转换为 `FormField` 的错误。若由 Schema 完整接管校验,给表单设置 `noValidate`;保留原生校验时省略它即可。`onSubmit` 接收取消信号,提交完成前检查信号再回填服务端错误。 - -} fileName="schema.tsx"> - -```tsx lineNumbers file="../../src/examples/scenarios/schema.tsx" -``` - - - -## 远程搜索与竞态 - -远程过滤设为 `filterOption={false}`,将查询参数、取消请求与分页状态放在应用层。`onPopupScroll` 可触发加载下一页,`popupRender` 可追加重试按钮。Select 只保留已选项的回显记录,不会无限缓存历史搜索结果。 - -} fileName="basic.tsx"> - -```tsx lineNumbers file="../../src/examples/scenarios/basic.tsx" -``` - - - -切换租户或树的数据版本时,更新 `Tree`、`TreeSelect` 或 `Cascader` 的 `cacheKey`。组件会丢弃懒加载缓存并取消旧请求;加载函数必须返回当前请求的子节点。 - -## 上传服务适配 - -`autoUpload={false}` 配合 `UploadHandle.upload()` 实现手动开始;`concurrency` 限制同时进行的上传。分片、校验摘要和断点续传属于服务端协议,在 `customRequest` 中实现:读取 `File.slice()`、保存服务端 uploadId、传递 `signal`,每片完成时通过 `onProgress` 更新总进度。不要在前端组件中假定一种存储服务的续传协议。 - -更多 API 见 [Form](../components/form)、[Select](../components/select) 和 [Upload](../components/upload)。 - - -## 完整业务页面 - -[完整工作空间](./workspace) 串起项目搜索、新建、编辑、归档与恢复、字段校验和团队设置保存;[可访问性工作台](./accessibility) 可检查键盘、浮层、主题与密度。 diff --git a/apps/docs/docs/guide/ssr.mdx b/apps/docs/docs/guide/ssr.mdx index c6a92245..7220490b 100644 --- a/apps/docs/docs/guide/ssr.mdx +++ b/apps/docs/docs/guide/ssr.mdx @@ -1,163 +1,77 @@ -# SSR 使用 +# 服务端渲染 -Leaf UI 支持服务端渲染。将编译好的样式导入框架入口,再通过 ConfigProvider 提供初始主题即可。 - -## 静态样式与动态主题 - -组件布局、状态和动画使用编译好的静态 CSS。`styles.css` 提供基础与 API 服务样式,浏览器组件入口自动加载组件样式;支持 CSS 裁剪的生产构建可从根入口按需保留具名导入的样式。直接 Node 入口不加载 CSS,自行管理 SSR 样式时使用完整静态入口 `@ifhover/leaf-ui/styles/all.css`。ConfigProvider 在渲染时输出当前区域的主题变量,大小尺寸使用 CSS 计算,浏览器直接应用它们。 - -因此 SSR 和动态主题可以同时保留:服务端输出首屏 HTML 与初始主题,客户端使用同一份配置接管后,再通过 React 状态切换主题。无需 CSS-in-JS 注册器、服务端提取样式、Sass 配置或关闭 SSR。 +Leaf UI 的基础样式和组件样式会自动加载。下面只介绍各框架需要额外处理的部分,安装和组件用法见[快速开始](/guide/getting-started)。 ## Next.js App Router -### 在根布局导入样式 - -```tsx title="app/layout.tsx" -import type { ReactNode } from 'react'; -import { cookies } from 'next/headers'; -import type { LeafTheme } from '@ifhover/leaf-ui'; -import '@ifhover/leaf-ui/styles.css'; -import './globals.css'; -import { Providers } from './providers'; - -export default async function RootLayout({ children }: { children: ReactNode }) { - const store = await cookies(); - const initialTheme: LeafTheme = { - primaryColor: '#7654c6', - borderRadius: 8, - controlHeight: 40, - appearance: store.get('leaf-appearance')?.value === 'dark' ? 'dark' : 'light', - }; - - return ( - - - {children} - - - ); -} -``` - -该示例使用当前 Next.js 的异步 cookies API。Cookie 为可选的外观偏好;也可以在服务端读取账户设置,或始终提供固定主题。 - -### 创建可以切换主题的区域 +全局配置放在 Client Component 中: ```tsx title="app/providers.tsx" 'use client'; -import { useState, type ReactNode } from 'react'; -import { Button, ConfigProvider, type LeafTheme } from '@ifhover/leaf-ui'; - -export function Providers({ - initialTheme, - children, -}: { - initialTheme: LeafTheme; - children: ReactNode; -}) { - const [theme, setTheme] = useState(initialTheme); - - function toggleAppearance() { - const appearance = theme.appearance === 'dark' ? 'light' : 'dark'; - setTheme((current) => ({ ...current, appearance })); - document.cookie = - 'leaf-appearance=' + appearance + '; Path=/; Max-Age=31536000; SameSite=Lax'; - } +import { ConfigProvider } from '@ifhover/leaf-ui'; +import type { ReactNode } from 'react'; - return ( - - - {children} - - ); +export function Providers({ children }: { children: ReactNode }) { + return {children}; } ``` -`'use client'` 为交互组件声明客户端边界,仍会参与 Next.js 的服务端预渲染。包的构建入口也保留了该声明。在 Server Component 中可以导入并渲染 Leaf UI 组件;事件回调、hooks 和交互状态应放在 Client Component 中,传过边界的配置保持可序列化。 +在已有的 `app/layout.tsx` 中,用 `Providers` 包裹 `` 内的 `{children}`。布局本身保留为 Server Component。 -将服务端内容作为 children 传给 Providers 可以保留这些内容的 Server Component 边界,不必把整个应用写成客户端组件。 +含事件回调、hooks 或交互状态的业务组件也要声明 `'use client'`。Client Component 仍会参与服务端预渲染,无需设置 `ssr: false`。 -### 使用表单组件 +## Next.js Pages Router -```tsx title="app/project-form.tsx" -'use client'; +全局配置放在 `pages/_app.tsx`,无需声明 `'use client'`。 -import { Button, Form, FormField, Input } from '@ifhover/leaf-ui'; +使用 Webpack 构建时,在现有 Next.js 配置中加入: -export function ProjectForm() { - return ( -
{ - event.preventDefault(); - const data = new FormData(event.currentTarget); - console.log(data.get('name')); - }}> - - - - -
- ); -} +```js title="next.config.mjs" +export default { + transpilePackages: ['@ifhover/leaf-ui'], +}; ``` -在页面里导入 ProjectForm 后即可使用,它会继承外层主题。无需 `dynamic(..., { ssr: false })`。 +已有 `transpilePackages` 时追加包名,修改后重启开发服务。它让 Next.js 处理组件库内部的 CSS,避免“不能从 node_modules 内导入全局 CSS”的错误。此选项需要 Next.js 13 或以上。 -## Next.js Pages Router +Next.js 16.4.1 的默认 Turbopack 构建无需这项额外配置。两种构建方式都无需手动导入 Leaf UI 样式。 -在 `pages/_app.tsx` 导入样式并包裹组件: +## TanStack Start -```tsx title="pages/_app.tsx" -import type { AppProps } from 'next/app'; +使用默认 Vite 配置即可。需要全局配置时,在已有根路由 `src/routes/__root.tsx` 中包裹 `Outlet`: + +```tsx title="src/routes/__root.tsx" import { ConfigProvider } from '@ifhover/leaf-ui'; -import '@ifhover/leaf-ui/styles.css'; +import { Outlet } from '@tanstack/react-router'; -export default function App({ Component, pageProps }: AppProps) { +function RootComponent() { return ( - - - + + + + + ); } ``` -需要用户专属首屏主题时,在页面的 getServerSideProps 中读取偏好并返回 initialTheme。客户端首次渲染使用同样的 pageProps;后续切换可以参照上方 Providers 的 React 状态写法。 - -## 其他 React SSR 框架 - -Remix、React Router 框架模式或自建 React SSR 服务使用相同方式: - -1. 按框架规则在根入口或根路由引入 `styles.css`,确保样式随首屏加载。 -2. 在服务端读取主题和语言,把 ConfigProvider 包裹进要渲染的组件树。 -3. 将同一份初始配置通过框架的数据机制传给客户端。 -4. 客户端从这份配置初始化状态,再响应主题切换。 +`RootDocument` 沿用项目已有的文档组件,根路由的其他配置保持不变。`` 中的 `HeadContent` 输出首屏样式链接,`` 中的 `Scripts` 加载客户端脚本。Start 会从构建产物中发现组件样式,无需为 Leaf UI 添加 `?url` 样式链接或 `'use client'`。 -静态 CSS 可以由浏览器缓存,动态配置保留在 HTML 的区域容器中。主题计算不依赖 window、matchMedia 或读取 DOM。 +详见 TanStack Start 的[根路由](https://tanstack.com/start/latest/docs/framework/react/guide/routing)与[样式处理](https://tanstack.com/start/latest/docs/framework/react/guide/css-styling)。 -## 保持首次渲染一致 +## 首次渲染保持一致 -首屏主题优先来自服务端可读的 Cookie、账户设置或固定默认值。若只把偏好放在 localStorage,服务端无法读取:先用一致的默认配置完成接管,再在 effect 中读取偏好,会产生一次可见主题切换。希望首屏直接正确时,应把偏好同步到服务端可读的存储。 +服务端与客户端首次渲染使用相同的主题、语言和初始值。首屏需要深色模式时,从 Cookie 等服务端可读的数据中取得配置;只存在 `localStorage` 中的偏好,应在挂载后读取。主题和语言的配置方式见[定制主题](/guide/theming)与[切换语言](/guide/internationalization)。 -日期选择器使用本地日期和时间;时区不同可能导致服务器与浏览器显示不同的值。SSR 页面使用日期默认值时,让服务端与浏览器采用同一时区策略,或在客户端接管后设置用户本地日期。不要在两次首次渲染中分别调用 `new Date()` 生成不同初值。 +日期、时间和倒计时也应传入一致的初值;避免两端分别用 `new Date()` 生成初值。Modal、Message 等浮层在客户端挂载后显示。 -Modal、Confirm、Message 与选择浮层会在客户端挂载后创建 Portal。表单控件本身可以服务端渲染;已打开的弹层在客户端接管后展示。 +## 自行搭建 SSR -入口的 `ConfigProvider` 会自动托管确认框、消息、通知和任务加载条,客户端子组件通过 `useConfirm`、`useMessage`、`useNotification`、`useLoadingBar` 调用即可。服务端不输出这些容器;每个应用入口的状态相互独立,不会在 SSR 请求之间共享队列。 +直接使用 Node 渲染、未通过框架收集页面样式时,在负责生成浏览器资源的入口引入完整静态样式: -## 图片与文件交互 - -Image 的图片本身可以服务端渲染。放大预览、ImageCropper 的裁剪引擎、SignaturePad 的绘图引擎在客户端按需加载;服务端保留稳定的容器与占位内容。可以直接在 Client Component 中使用这些组件,无需禁用整个页面的 SSR。 - -FileList 可在服务端展示基于 URL 的文件信息。Upload 选择的 File 对象和临时预览 URL 保留在客户端状态中;服务端渲染不会启动上传请求。 - -这些交互组件需要客户端 JavaScript 才能响应操作。样式复用与高级主题配置见[定制主题](/guide/theming)。 - -按需样式的用法统一见[安装&快速开始](/guide/getting-started)。包的 Node 默认入口不导入 CSS,ESM / CommonJS 可直接用于服务端渲染;支持 CSS 的框架浏览器构建使用自动样式入口,并保留 Next.js 客户端边界。若自行搭建 SSR,或构建器未启用 `browser` 导出条件,请在负责生成页面样式的入口导入 `@ifhover/leaf-ui/styles/all.css`,将输出的 CSS 链接加入首屏 HTML。 - -## 日期、数字与浮层的首屏 - -Picker 采用本地 Date 的年月日与时间。日期输入及 FormData 是本地日历值;`date.toISOString()` 表示 UTC 时间点,不能直接用它截取用户所在地区的日期。需要业务时区时在应用的数据边界完成转换,并在客户端和服务端使用同一约定。非公历与带时区的专用 Date 类型由应用适配。 - -服务端与客户端使用相同的 `locale`、初始值和时区设置,以免 Intl 数字或日期文字不同。倒计时可以传固定 `value` 与 `initialRemaining` 避免请求延迟导致首屏差异。系统外观与断点辅助 Hook 接受初始值,挂载后再读取浏览器偏好。 +```ts +import '@ifhover/leaf-ui/styles/all.css'; +``` -浮层会在客户端挂载。`container` / `getPopupContainer` 使用函数延迟访问 DOM;不要在模块顶层或服务端渲染时直接读取 `document`。详情见 [控件与浮层](/guide/control-contracts)。 +将输出的 CSS 链接加入首屏 HTML。Node 的组件入口不导入 CSS,可以直接用于服务端渲染。 diff --git a/apps/docs/docs/guide/tailwindcss.mdx b/apps/docs/docs/guide/tailwindcss.mdx new file mode 100644 index 00000000..4918b3ac --- /dev/null +++ b/apps/docs/docs/guide/tailwindcss.mdx @@ -0,0 +1,158 @@ +import { ComponentExample } from '../../src/components/component-example'; +import { DocsCollapse } from '../../src/components/docs-collapse'; +import { TailwindBasic } from '../../src/examples/tailwindcss/basic'; +import { TailwindTheme } from '../../src/examples/tailwindcss/theme'; + +# Tailwind CSS + +在 Tailwind 中复用 Leaf UI 的颜色、圆角和间距。 + +## 快速接入 + +在已安装 Tailwind 的项目中,按版本选择下面的配置。支持 3.x 和 4.x,2.x 及更早版本请先升级。 + +### Tailwind CSS 4 + +在 CSS 入口增加一行导入即可: + +```css title="app.css" +@import 'tailwindcss'; +@import '@ifhover/leaf-ui/tailwind.css'; +``` + + + +如果不需要 Tailwind 的 Preflight 重置样式,改用下面的入口: + +```css title="app.css" +@layer theme, base, components, utilities; +@import 'tailwindcss/theme.css' layer(theme); +@import 'tailwindcss/utilities.css' layer(utilities); +@import '@ifhover/leaf-ui/tailwind.css'; +``` + + + +### Tailwind CSS 3 + +在配置中加入 preset,`content` 按项目目录调整: + +```js title="tailwind.config.cjs" +const leafPreset = require('@ifhover/leaf-ui/tailwind').default; + +module.exports = { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + presets: [leafPreset], +}; +``` + +CSS 入口保持 Tailwind 3 的写法: + +```css title="app.css" +@tailwind base; +@tailwind components; +@tailwind utilities; +``` + +已有配置只需追加 preset,保留自己的扫描目录和插件。 + + + +```ts title="tailwind.config.ts" +import type { Config } from 'tailwindcss'; +import leafPreset from '@ifhover/leaf-ui/tailwind'; + +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + presets: [leafPreset], +} satisfies Config; +``` + +需要关闭 Preflight 时,在配置中添加 `corePlugins: { preflight: false }`。 + + + +## 使用示例 + +### 基础用法 + +引入配置后,在普通工具类中使用 `leaf-` 名称,就能与组件共享样式: + +} fileName="basic.tsx" width={360}> + +```tsx lineNumbers file="../../src/examples/tailwindcss/basic.tsx" + +``` + + + +`bg-leaf-surface` 设置背景,`p-leaf-md` 设置内边距,`rounded-leaf` 设置圆角。Tailwind 原有工具类和 `hover:`、`md:` 等变体仍可照常使用。 + +### 跟随主题 + +放在 ConfigProvider 内的工具类会跟随区域主题。试着打开深色模式,查看面板和按钮一起变化: + +} fileName="theme.tsx" width={360}> + +```tsx lineNumbers file="../../src/examples/tailwindcss/theme.tsx" + +``` + + + +背景、文字和边框读取当前区域的变量,无需额外编写 `dark:` 类名。颜色和密度变化也无需重新构建 Tailwind;更多配置见 [定制主题](/guide/theming)。 + +## 常用设计变量 + +两种版本使用相同的工具类名称,只扩展主题,保留 Tailwind 的默认值。 + +
+ +| 设计变量 | 工具类示例 | +| ---------- | ------------------------------------------------------------------------------ | +| 主题色 | `bg-leaf-primary`、`text-leaf-primary`、`text-leaf-on-primary` | +| 状态色 | `text-leaf-success`、`text-leaf-warning`、`text-leaf-danger`、`text-leaf-info` | +| 文字颜色 | `text-leaf-text`、`text-leaf-muted`、`text-leaf-subtle` | +| 背景颜色 | `bg-leaf-surface`、`bg-leaf-surface-muted`、`bg-leaf-surface-raised` | +| 边框与焦点 | `border-leaf-border`、`border-leaf-divider`、`ring-leaf-focus` | +| 圆角 | `rounded-leaf`、`rounded-leaf-sm`、`rounded-leaf-md`、`rounded-leaf-lg` | +| 间距 | `p-leaf`、`p-leaf-xs`、`gap-leaf-sm`、`p-leaf-md`、`gap-leaf-lg`、`m-leaf-xl` | +| 控件高度 | `h-leaf-control`、`h-leaf-control-sm`、`h-leaf-control-lg` | +| 字体与字号 | `font-leaf`、`text-leaf`、`text-leaf-sm`、`text-leaf-lg` | +| 字重与行高 | `font-leaf-medium`、`font-leaf-heading`、`leading-leaf` | +| 阴影 | `shadow-leaf-xs`、`shadow-leaf-sm`、`shadow-leaf-md`、`shadow-leaf-lg` | + +
+ +颜色名称也可用于 `border-*`、`ring-*`、`fill-*` 等工具类。字号类默认包含主题行高。 + + + +
+ +| 设计变量 | 工具类示例 | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| 主题色交互 | `hover:bg-leaf-primary-hover`、`active:bg-leaf-primary-active` | +| 危险色交互 | `hover:bg-leaf-danger-hover`、`active:bg-leaf-danger-active` | +| 状态色上的文字 | `text-leaf-on-warning`、`text-leaf-on-danger` | +| 边框与选项悬停 | `hover:border-leaf-border-hover`、`hover:bg-leaf-option-hover` | +| 选择控件圆角 | `rounded-leaf-choice` | +| 密度相关间距 | `gap-leaf-form-gap`、`gap-leaf-label-gap`、`py-leaf-row-padding`、`min-h-leaf-option-height`、`p-leaf-option-padding`、`px-leaf-field-padding` | +| 弹窗间距 | `py-leaf-dialog-block`、`px-leaf-dialog-inline`、`gap-leaf-dialog-gap` | +| 过渡时间 | `duration-leaf`、`duration-leaf-fast`、`duration-leaf-slow` | +| 过渡曲线 | `ease-leaf`、`ease-leaf-spring` | +| 浮层层级 | `z-leaf-popup`、`z-leaf-modal`、`z-leaf-message` | + +
+ +动效可写为 `transition-colors duration-leaf ease-leaf motion-reduce:transition-none`。`theme.motion: false` 关闭区域过渡;`motion-reduce:` 响应系统的减少动态效果偏好。 + +
+ +## 使用提示 + +- **透明度**:Tailwind 4 支持 `bg-leaf-primary/50`;Tailwind 3 不支持这些颜色的 `/50`、`bg-opacity-*` 或 `text-opacity-*`,半透明色需在业务 CSS 中定义。 +- **源码扫描**:写完整类名,如 `bg-leaf-primary`,不要拼接 `bg-leaf-${color}`。 +- **基础样式**:没有导入 Leaf UI 组件的纯 CSS 页面,还需引入 `@ifhover/leaf-ui/styles.css`。 +- **响应式断点**:`sm:`、`md:` 使用项目的 Tailwind 配置,不随 ConfigProvider 的断点变化。Tailwind 4 的浏览器要求以其官方支持范围为准。 +- **区域与浮层**:自定义 Portal 内容也需放在对应主题区域。Leaf UI 自带浮层会自动同步主题;组件专用变量通过 [ConfigProvider](/guide/theming) 调整。 diff --git a/apps/docs/docs/guide/theming.mdx b/apps/docs/docs/guide/theming.mdx index 897286b6..6bf8300d 100644 --- a/apps/docs/docs/guide/theming.mdx +++ b/apps/docs/docs/guide/theming.mdx @@ -1,259 +1,199 @@ -import { ThemePlayground } from '../../src/components/theme-playground'; +import { ComponentExample } from '../../src/components/component-example'; +import { ThemeBasic } from '../../src/examples/theming/basic'; +import { ThemeAppearance } from '../../src/examples/theming/appearance'; +import { ThemeScoped } from '../../src/examples/theming/scoped'; # 定制主题 -通过 [ConfigProvider](/components/config-provider) 设置组件的主题。通常只需调整主题色、基础圆角和控件高度,其余样式会自动配合。 +颜色、圆角和深色模式都通过 ConfigProvider 的 `theme` 设置。通常几行配置就够了,其他样式会自动跟随。 -## 从几个设置开始 +## 改颜色和圆角 -在应用最外层放置 ConfigProvider,并引入一次组件样式: +在应用入口用 ConfigProvider 包住页面。下面把主题色改成紫色,基础圆角改成 8px: -```tsx -import { Button, ConfigProvider, Input } from '@ifhover/leaf-ui'; -import '@ifhover/leaf-ui/styles.css'; +} fileName="basic.tsx" width="fit-content"> -export function App() { - return ( - - - - - ); -} +```tsx lineNumbers file="../../src/examples/theming/basic.tsx" ``` -未传入的选项使用默认值。悬停、按下、浅色背景和焦点颜色会跟随主题色,不需要分别配置。 - -## 试试你的风格 + -选择预设配色或自定义颜色,调整圆角和外观,预览会立即更新。展开主题代码或直接复制,即可应用到自己的页面。 +把示例中的 Button 换成你的应用内容即可。按钮、输入框、选择器及浮层会一起使用这份主题;悬停、按下和焦点颜色会自动生成。 - +`borderRadius` 是圆角的基准值。卡片、弹窗等会按各自的层级生成圆角,因此不必逐个组件设置。数字表示像素,也支持 `'0.5rem'` 等 CSS 长度。 -## 自动生成大小尺寸 +### 常用参数 -默认基础圆角为 10px,单行控件高度为 40px,字号为 14px。修改基础值后,其他尺寸会同步调整: +下面这些参数都放在 `theme` 中,只写需要修改的项。 -| 基础设置 | 自动生成的值 | 默认结果 | +| 参数 | 常见写法 | 用途 | | --- | --- | --- | -| `borderRadius` | 小 / 中 / 大圆角分别为基础值的 0.6 / 1.2 / 1.6 倍 | `6px / 12px / 16px` | -| `controlHeight` | 小尺寸减 6px,大尺寸加 6px | `34px / 46px` | -| `fontSize` | 小字号减 1px,大字号加 2px | `13px / 16px` | - -数字表示像素,也可以传入带单位的 CSS 长度。按钮、输入框和选择器共用这套尺寸,放在一起时保持一致。 +| `primaryColor` | `'#7654c6'` | 设置主题色,相关交互颜色自动生成。 | +| `borderRadius` | `8` | 调整基础圆角,`0` 为直角。 | +| `appearance` | `'light'` / `'dark'` | 设置浅色或深色外观。 | +| `controlHeight` | `40` | 调整单行控件的基础高度,小、大尺寸同步变化。 | +| `fontSize` | `14` | 设置组件基础字号。 | +| `fontFamily` | `'Inter, sans-serif'` | 设置字体;字体文件由应用加载。 | +| `motion` | `false` | 关闭当前区域的动效,保留正常操作。 | -圆角由外向内递减:弹窗、抽屉和浮层使用大圆角,卡片、提示和选项卡片等内容表面使用中圆角,按钮、输入框和表格外框使用基础圆角,复选框等小元素使用小圆角,状态标签为胶囊形。内容表面默认只用细边框区分层级,不叠加阴影。 +未设置的字段继承外层配置。默认使用浅色外观、10px 基础圆角、36px 基础控件高度和 14px 字号。 -紧贴容器的选项与选中背景按外框圆角、内边距和边框自动推导圆角,默认浮层为 `16px − 6px − 1px = 9px`。通过 `theme.components.Floating` 可统一设置浮层的 `borderRadius` 和 `padding`,`Dropdown` 的局部设置优先;卡片式 `Tabs`、`Segmented` 和 `Menu` 同样跟随各自的容器。内边距支持数字、CSS 长度和 1–4 值简写(例如 `4px 8px`),各方向的内部圆弧会同步更新。 +完整参数见 [LeafTheme](/components/config-provider#leaftheme)。需要单独调整文字色、背景色或某个尺寸时,再查阅 [LeafThemeTokens](/api/theme-tokens#leafthemetokens)。 -## 局部主题与嵌套 +## 使用深色模式 -ConfigProvider 可以包裹整个应用,也可以只包裹一个区域。内层只覆盖明确设置的选项: +设置 `appearance: 'dark'` 即可;改回 `'light'` 恢复浅色: ```tsx - - - - - + + ``` -浮层、Modal、确认框与消息会继承调用区域的主题。ConfigProvider 自动托管确认框和消息,子组件通过 useConfirm 和 useMessage 调用。语言通过 ConfigProvider 的 `locale` 单独设置。 +组件的文字、背景和边框会一起切换。显式设置的 `primaryColor` 会保留原色;没有指定时使用该外观的默认主题色。 -## 深色外观与动态切换 +ConfigProvider 负责区域内组件的样式,不会给页面的 `body` 自动添加背景。业务页面的背景和文字也需要跟随外观时,可使用下文的 [CSS 变量](#在业务样式中使用主题)。 -使用 `appearance` 切换浅色与深色;主题是普通 React 属性,可以用状态动态更新: +### 加一个切换开关 -```tsx -import { useState } from 'react'; -import { Button, ConfigProvider } from '@ifhover/leaf-ui'; +主题是普通 React 参数,可以用状态更新。打开下面的开关,查看卡片、按钮和文字一起切换: -export function ThemeSwitch() { - const [dark, setDark] = useState(false); - return ( - - - - ); -} +} fileName="appearance.tsx" width={360}> + +```tsx lineNumbers file="../../src/examples/theming/appearance.tsx" ``` -深色外观会提供适合深色表面的文字、边框和状态色。显式设置的主题色保留原值;很浅的品牌色可以在高级配置中搭配 `onPrimaryColor`。 + -## 有特殊需求时再覆盖 +### 跟随系统外观 -`theme.tokens` 可以单独调整自动生成的尺寸、语义色、表面色、文字色、动效或浮层层级: +希望随操作系统切换时,使用 `useSystemAppearance`,并把结果传给 `theme.appearance`: ```tsx -import type { LeafTheme } from '@ifhover/leaf-ui'; +import { ConfigProvider, useSystemAppearance } from '@ifhover/leaf-ui'; -const theme: LeafTheme = { - controlHeight: 36, - borderRadius: 8, - tokens: { - controlHeightSm: 28, - borderRadiusLg: 18, - infoColor: '#1677ff', - motionDuration: 200, - }, -}; +export function AppTheme() { + const appearance = useSystemAppearance('light'); + + return ( + + + + ); +} ``` -手动设置的小 / 大尺寸优先于自动生成的值。嵌套配置按字段合并,包括 tokens;未覆盖的配置继续继承。完整选项见 [LeafThemeTokens](/components/config-provider#leafthemetokens)。 +这里的 `'light'` 是首次渲染使用的外观,挂载后会读取并监听系统偏好。用户手动选择外观时,由应用保存选择并决定是否继续跟随系统。 -## 调整交互节奏 +## 只调整一个区域 -默认基础过渡时间为 200ms。按压和悬停采用更短的反馈,选中指示条、展开与浮层使用与变化幅度相配的时间;列表重排和弹窗内容高度变化会从当前位置继续过渡。 +嵌套一个 ConfigProvider,就能为某块内容设置独立主题。内层只覆盖写出的字段,其他配置继续继承: -通过 `tokens.motionDuration` 调整当前区域的整体节奏,`motionEasing` 调整普通过渡,`motionSpring` 调整选择和形状变化的弹性曲线: +} fileName="scoped.tsx" width="fit-content"> -```tsx - - - +```tsx lineNumbers file="../../src/examples/theming/scoped.tsx" ``` -`motion: false` 关闭当前区域的 CSS 与 JavaScript 动画,包括加载旋转和浮层。内层可通过 `motion: true` 单独启用。系统的“减少动态效果”偏好始终优先。输入、拖动和裁剪等直接操作保持即时响应。 + -表面与文字使用中性灰阶;主题色用于操作和选择。需要更精细地调整层次时,可使用 `subtleTextColor`、`raisedSurfaceColor`、`borderHoverColor` 和 `shadowXs`。 +示例中两个按钮的基础圆角都是 8px,内层只把主题色改成紫色。从该区域打开的 Select 浮层、Modal,以及通过 `useConfirm`、`useMessage` 创建的反馈也会使用所在区域的主题。 -## 在自己的组件中复用样式 +## 调整控件尺寸和密度 -CSS 变量用于读取当前区域的主题,方便自己编写的组件与 Leaf UI 保持一致。调整库组件的主题时使用 ConfigProvider;在业务 CSS 中通过 `var()` 消费这些变量: +只调整一个控件时,优先使用它的 `size="sm"` 或 `size="lg"`。要让整个区域的控件更紧凑,可以设置 ConfigProvider 的 `density`: ```tsx - - - + + ``` -```scss -.product-button { - min-height: var(--leaf-control-height); - padding: 0 14px; - border: 0; - border-radius: var(--leaf-radius); - background: var(--leaf-color-primary); - color: var(--leaf-color-on-primary); - font-family: var(--leaf-font-family); - font-size: var(--leaf-font-size); - font-weight: var(--leaf-font-weight); - cursor: pointer; -} -``` +`density` 与 `theme` 同级。默认值为 `'comfortable'`;`'compact'` 会同时收紧控件、表单和浮层的间距,基础控件高度从 36px 调整为 30px,字号保持不变。 -| 可读取的变量 | 用途 | -| --- | --- | -| `--leaf-color-primary` / `--leaf-color-on-primary` | 主题背景与其上的文字 | -| `--leaf-color-danger` / `--leaf-color-success` / `--leaf-color-warning` / `--leaf-color-info` | 语义状态色 | -| `--leaf-color-text` / `--leaf-color-text-muted` | 主要 / 次要文字 | -| `--leaf-color-surface` / `--leaf-color-surface-muted` / `--leaf-color-border` | 表面与边框 | -| `--leaf-radius` / `--leaf-radius-sm` / `--leaf-radius-md` / `--leaf-radius-lg` | 基础 / 小 / 中 / 大圆角 | -| `--leaf-control-height` / `--leaf-control-height-sm` / `--leaf-control-height-lg` | 基础 / 小 / 大控件高度 | -| `--leaf-font-family` / `--leaf-font-size` / `--leaf-font-weight` | 字体、字号与字重 | -| `--leaf-motion-duration` / `--leaf-motion-fast` / `--leaf-motion-slow` | 基础 / 快速 / 布局过渡时间 | -| `--leaf-motion-easing` / `--leaf-motion-spring` | 普通过渡 / 弹性曲线 | - -业务组件需要位于相应的 ConfigProvider 区域内,才能读取该区域的主题。使用者只需引入编译好的 `styles.css`,无需安装 Sass。 +需要指定高度时再设置 `theme.controlHeight`,它优先于密度预设。例如 `controlHeight: 40` 会生成 34px / 40px / 46px 三种控件高度。尺寸对比见 [ConfigProvider](/components/config-provider#基础高度)。 -## 服务端渲染 +## 进阶定制 -静态 CSS 与动态主题可以一起使用,不需要运行时生成 CSS。Next.js 与其他 SSR 框架的接入方法见 [SSR 使用](/guide/ssr)。 +下面的配置适合已有明确设计要求的项目。只改颜色、圆角或外观时,使用前面的基础参数即可。 -## 间距、字体与组件覆盖 +### 单独覆盖颜色或尺寸 -基础 `spacing` 会生成小、大间距,`lineHeight`、`headingWeight` 和 `elevation` 分别调整正文行高、Typography 标题字重和阴影;弹窗、卡片等组件标题使用 `tokens.fontWeight`。继续通过 ConfigProvider 配置;组件专属覆盖放在 `theme.components`: +`theme.tokens` 用于修改基础参数之外的具体样式。例如只调整成功色、大圆角和动效时长: ```tsx +import type { LeafTheme } from '@ifhover/leaf-ui'; + const theme = { - spacing: 8, - lineHeight: 1.6, - headingWeight: 600, - elevation: 'soft', - components: { - Button: { borderRadius: 6, padding: '0 18px' }, - Input: { borderRadius: 6, controlHeight: 36 }, - Card: { padding: 16, shadow: '0 6px 20px #0001' }, + primaryColor: '#7654c6', + borderRadius: 8, + tokens: { + successColor: '#20834a', + borderRadiusLg: 18, + motionDuration: 160, }, } satisfies LeafTheme; ``` -组件名使用导出的名称。覆盖只作用于该区域中对应组件;每类组件使用与其布局相关的 token:输入组件支持圆角、高度、背景、字号,容器支持内边距和阴影,排列组件支持间距。通过组件本身的 `className` / `style` 与浮层的 `popupClassName` / `popupStyle` 可进一步定制。 +将这个对象传给 ``。这里的 `borderRadiusLg` 会覆盖自动生成的大圆角;`motionDuration` 的数字单位是毫秒。完整字段和说明见 [LeafThemeTokens](/api/theme-tokens#leafthemetokens)。 -## 系统外观、响应断点与对比度 +如果使用很浅的主题色,可通过 `tokens.onPrimaryColor` 设置实色按钮上的文字色。自定义配色后,应检查正常、悬停和禁用状态的可读性。`motion: false` 可以关闭区域动效,系统的“减少动态效果”偏好始终优先。 -```tsx -import { ConfigProvider, colorContrast, useBreakpoint, useSystemAppearance } from '@ifhover/leaf-ui'; +### 只调整某类组件的样式 -function Application() { - const appearance = useSystemAppearance('light'); - return ; -} -function Content() { - const { md } = useBreakpoint({ md: false }); - return

{md ? '宽屏布局' : '紧凑布局'}

; -} -const contrast = colorContrast('#ffffff', '#20834a'); +`theme.components` 按组件名称设置样式。例如让按钮使用 4px 圆角,同时调整卡片内边距: + +```tsx + + + ``` -`useBreakpoint` 按当前区域的断点生成布尔值,可用于业务布局。Grid 的现有 `sm/md/lg` CSS 响应规则仍使用组件文档中的固定断点。`colorContrast` 返回 WCAG 对比度比值,无法解析颜色时返回 `null`;透明颜色会先与背景和可选第三参数的底色合成。普通文字建议至少 4.5,较大文字至少 3。 +可用样式字段见 [LeafComponentTokens](/api/theme-tokens#leafcomponenttokens),具体组件支持的字段以其文档为准。 -系统偏好在客户端接管后更新;SSR 的首次外观应来自服务端 Cookie 或一致的初始值,见 [SSR 使用](/guide/ssr)。 +这里的 `theme.components` 调整样式。如果想统一按钮的 `size`、`variant` 或其他组件参数,使用 ConfigProvider 上的 `components`,见 [组件默认值](/components/config-provider#组件默认值)。 -## 分区与辅助表面 +### 在业务样式中使用主题 -弹窗分区、卡片选项和中性提示从同一基础主题派生外观。辅助面沿用 mutedSurfaceColor,分隔线可通过高级 dividerColor 覆盖。按组件覆盖分区背景时使用 components;Confirm 共用 Modal 的配置。headerBackground、footerBackground 用于 Modal、Drawer 和 Card;borderColor 也用于卡片选项、成组选项和 Alert 的边框。 +ConfigProvider 会在它包裹的区域提供 `--leaf-*` CSS 变量。业务内容位于该区域内时,可以读取当前主题,随颜色和外观一起更新: ```tsx - - + +
业务内容
``` -## LeafComponentTokens +```css +.app-panel { + padding: 16px; + color: var(--leaf-color-text); + background: var(--leaf-color-surface); + border: 1px solid var(--leaf-color-border); + border-radius: var(--leaf-radius); +} +``` -| 属性 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `borderRadius` | `string \| number` | — | 圆角;数字表示像素。 | -| `controlHeight` | `string \| number` | — | 基础控件高度;其他尺寸由此生成。 | -| `padding` | `string \| number` | — | 内容或高亮目标的内边距。 | -| `gap` | `string \| number` | — | 子项之间的间距;数字表示像素。 | -| `fontSize` | `string \| number` | — | 字号;数字表示像素。 | -| `color` | `string` | — | 颜色或语义色。 | -| `background` | `string` | — | 组件表面背景。 | -| `borderColor` | `string` | — | 外层边框与分区分隔线颜色。 | -| `headerBackground` | `string` | — | 标题区域背景;默认继承表面颜色。 | -| `footerBackground` | `string` | — | 底部操作区域背景;分区外观默认使用淡灰辅助面。 | -| `shadow` | `string` | — | CSS box-shadow 的值。 | +| 常用变量 | 用途 | +| --- | --- | +| `--leaf-color-primary` | 当前主题色。 | +| `--leaf-color-on-primary` | 实色主题背景上的文字色。 | +| `--leaf-color-text` / `--leaf-color-text-muted` | 主要、次要文字色。 | +| `--leaf-color-surface` / `--leaf-color-surface-muted` | 内容背景、辅助背景。 | +| `--leaf-color-border` | 边框颜色。 | +| `--leaf-radius` | 基础圆角。 | -## LeafThemeStyle +调整 Leaf UI 组件的主题仍通过 ConfigProvider,业务 CSS 用这些变量读取结果。仅修改某个基础 CSS 变量,不会重新生成整套交互颜色。 -| 属性 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `` [key: `--leaf-${string}`] `` | `string \| number \| undefined` | — | 可复用的 CSS 自定义属性。 | +使用 Tailwind CSS 时,可直接引入包提供的配置,通过 `bg-leaf-surface`、`text-leaf-primary` 等工具类复用主题。Tailwind 3 / 4 的接入方式见 [Tailwind CSS](/guide/tailwindcss)。 +## 服务端渲染 -## 密度与兼容色板 +服务端和客户端首次渲染应使用相同的外观。需要记住用户选择时,可由服务端读取 Cookie 并传入初始值;使用系统外观时,挂载后再更新。 -使用 `` 同时调整区域及浮层的控件高度与间距。显式主题尺寸优先。派生色由 JavaScript 生成;静态深浅色板支持独立 CSS 和 SSR。需要确定的首屏颜色时传入具体颜色,CSS 变量在浏览器中解析。 +主题色优先传入具体的 HEX、RGB 或 HSL 颜色。`var(--brand-color)` 需要在浏览器挂载后解析,不能保证服务端首屏就得到对应的派生颜色。 -参见[浏览器兼容性](./compatibility)、[设计资源](./design-resources)与[视觉规范](./design-standards)。 +安装见 [安装&快速开始](/guide/getting-started),Next.js 等框架的接入见 [服务端渲染](/guide/ssr),支持范围见 [浏览器兼容性](/guide/compatibility)。 diff --git a/apps/docs/docs/guide/workspace.mdx b/apps/docs/docs/guide/workspace.mdx index d28f22ed..556663f7 100644 --- a/apps/docs/docs/guide/workspace.mdx +++ b/apps/docs/docs/guide/workspace.mdx @@ -1,19 +1,5 @@ -import { BusinessWorkspace } from '../../src/examples/scenarios/workspace'; +# 工作空间组合 -# 完整工作空间 +使用 [Layout](../components/layout) 组织页面框架,[Flex](../components/flex) 与 [Grid](../components/grid) 排列内容,[Form](../components/form) 编辑字段,[Table](../components/table) 展示结构化数据。 -项目管理和团队设置组成一个可操作的业务页面。可搜索项目、新建或编辑项目、归档与恢复,也可以保存团队信息、外观、密度和背景模糊设置。 - - - -示例使用内存数据和 350ms 的模拟保存,刷新后恢复初始数据。输入已有的项目名称可体验服务端字段错误与重试;空名称、无效邮箱使用原生表单校验。搜索不存在的内容可查看空状态。设置页可放弃未保存的修改。 - -应用接入时可用真实 API 替换模拟保存,并在业务层处理鉴权、持久化和请求取消。 - -
-查看源码 - -```tsx lineNumbers file="../../src/examples/scenarios/workspace.tsx" -``` - -
+鉴权、持久化和请求取消由应用处理。组件页面提供各自能力的简短示例。 diff --git a/apps/docs/docs/public/browsers/LICENSE.txt b/apps/docs/docs/public/browsers/LICENSE.txt new file mode 100644 index 00000000..b08c6473 --- /dev/null +++ b/apps/docs/docs/public/browsers/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) Cătălin Mariș + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/docs/docs/public/browsers/SOURCES.txt b/apps/docs/docs/public/browsers/SOURCES.txt new file mode 100644 index 00000000..370b61e4 --- /dev/null +++ b/apps/docs/docs/public/browsers/SOURCES.txt @@ -0,0 +1,12 @@ +Browser logos identify the browsers in the compatibility guide. + +Source: https://github.com/alrra/browser-logos +Revision: 58881b84c4d73adc03c06fa2c275a7abee02d935 + +chrome.svg: src/chrome/chrome.svg +edge.svg: src/edge/edge.svg +firefox.svg: src/firefox/firefox.svg +safari.svg: src/safari/safari.svg + +The upstream MIT license is included in LICENSE.txt. +Browser names and logos belong to their respective owners. diff --git a/apps/docs/docs/public/browsers/chrome.svg b/apps/docs/docs/public/browsers/chrome.svg new file mode 100644 index 00000000..a6279983 --- /dev/null +++ b/apps/docs/docs/public/browsers/chrome.svg @@ -0,0 +1 @@ +Google Chrome \ No newline at end of file diff --git a/apps/docs/docs/public/browsers/edge.svg b/apps/docs/docs/public/browsers/edge.svg new file mode 100644 index 00000000..06f0cad6 --- /dev/null +++ b/apps/docs/docs/public/browsers/edge.svg @@ -0,0 +1 @@ +Microsoft Edge \ No newline at end of file diff --git a/apps/docs/docs/public/browsers/firefox.svg b/apps/docs/docs/public/browsers/firefox.svg new file mode 100644 index 00000000..9f690c16 --- /dev/null +++ b/apps/docs/docs/public/browsers/firefox.svg @@ -0,0 +1 @@ +Mozilla Firefox \ No newline at end of file diff --git a/apps/docs/docs/public/browsers/safari.svg b/apps/docs/docs/public/browsers/safari.svg new file mode 100644 index 00000000..be561604 --- /dev/null +++ b/apps/docs/docs/public/browsers/safari.svg @@ -0,0 +1 @@ +Apple Safari \ No newline at end of file diff --git a/apps/docs/docs/public/components/file-list.svg b/apps/docs/docs/public/components/file-list.svg index cd34f93c..660f4a67 100644 --- a/apps/docs/docs/public/components/file-list.svg +++ b/apps/docs/docs/public/components/file-list.svg @@ -1 +1 @@ -Project report1 / 4 +Project report.pdfPDF · 2.4 MBProject notes.mdMD · 4 KB diff --git a/apps/docs/docs/public/components/flex.svg b/apps/docs/docs/public/components/flex.svg new file mode 100644 index 00000000..86fb610d --- /dev/null +++ b/apps/docs/docs/public/components/flex.svg @@ -0,0 +1,12 @@ + + Flex + + + + + + + + + + diff --git a/apps/docs/docs/public/components/image-cropper.svg b/apps/docs/docs/public/components/image-cropper.svg deleted file mode 100644 index 4208480c..00000000 --- a/apps/docs/docs/public/components/image-cropper.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/docs/docs/public/components/pagination.svg b/apps/docs/docs/public/components/pagination.svg index 942a28bb..f004f17d 100644 --- a/apps/docs/docs/public/components/pagination.svg +++ b/apps/docs/docs/public/components/pagination.svg @@ -1 +1 @@ -pagination1234511–20 / 50 items +pagination1234511–20 / 50 items diff --git a/apps/docs/docs/public/components/signature-pad.svg b/apps/docs/docs/public/components/signature-pad.svg deleted file mode 100644 index e09bf990..00000000 --- a/apps/docs/docs/public/components/signature-pad.svg +++ /dev/null @@ -1 +0,0 @@ -Signature diff --git a/apps/docs/docs/public/components/tag.svg b/apps/docs/docs/public/components/tag.svg index 8076acc1..db9d4d00 100644 --- a/apps/docs/docs/public/components/tag.svg +++ b/apps/docs/docs/public/components/tag.svg @@ -1 +1 @@ -tagPublishedDesignPendingReadyLeaf +tagPublishedDesignPendingReadyLeaf diff --git a/apps/docs/docs/public/design/leaf-dark-components.svg b/apps/docs/docs/public/design/leaf-dark-components.svg index e920af57..0f1c946b 100644 --- a/apps/docs/docs/public/design/leaf-dark-components.svg +++ b/apps/docs/docs/public/design/leaf-dark-components.svg @@ -1 +1 @@ -Leaf UI dark design resourcesLeaf UI / dark component statessolidDefaultHoverPressedDisabledoutlineDefaultHoverPressedDisabledghostDefaultHoverPressedDisabledFields / default · focus · errorDefaultFocusErroriKeep the team informedReadable spacing and a semantic status icon.Project savedYour changes are ready for the team.Dialog headingForm and footer share an inline edgeSave +Leaf UI dark design resourcesLeaf UI / dark component statessolidDefaultHoverPressedDisabledoutlineDefaultHoverPressedDisabledghostDefaultHoverPressedDisabledFields / default · focus · errorDefaultFocusErroriKeep the team informedReadable spacing and a semantic status icon.Project savedYour changes are ready for the team.Dialog headingForm and footer share an inline edgeSave diff --git a/apps/docs/docs/public/design/leaf-dark-foundations.svg b/apps/docs/docs/public/design/leaf-dark-foundations.svg index 1224cf00..baccb877 100644 --- a/apps/docs/docs/public/design/leaf-dark-foundations.svg +++ b/apps/docs/docs/public/design/leaf-dark-foundations.svg @@ -1 +1 @@ -Leaf UI dark design resourcesLeaf UI / dark foundationsprimary#83cf9eon-primary#11271adanger#ef8585on-danger#381818warning#ffc53dinfo#69b1ffsuccess#83cf9etext#ededf0text-muted#a5a5aesurface#1c1c20surface-muted#25252bborder#38383fTypography · Inter / system sans-serifHeading 24 / 600Body 14 / 1.6 · Semantic colors and stable geometryComfortable: 40px controls / 8px spacingCompact: 34px controls / 6px spacing +Leaf UI dark design resourcesLeaf UI / dark foundationsprimary#83cf9eon-primary#11271adanger#ef8585on-danger#381818warning#ffc53dinfo#69b1ffsuccess#83cf9etext#ededf0text-muted#a5a5aesurface#1c1c20surface-muted#25252bborder#38383fTypography · Inter / system sans-serifHeading 24 / 600Body 14 / 1.6 · Semantic colors and stable geometryComfortable: 36px controls / 8px spacingCompact: 30px controls / 6px spacing diff --git a/apps/docs/docs/public/design/leaf-figma-plugin.zip b/apps/docs/docs/public/design/leaf-figma-plugin.zip index 17913556..245b23cf 100644 Binary files a/apps/docs/docs/public/design/leaf-figma-plugin.zip and b/apps/docs/docs/public/design/leaf-figma-plugin.zip differ diff --git a/apps/docs/docs/public/design/leaf-light-components.svg b/apps/docs/docs/public/design/leaf-light-components.svg index 9e6edb39..1b90e0e6 100644 --- a/apps/docs/docs/public/design/leaf-light-components.svg +++ b/apps/docs/docs/public/design/leaf-light-components.svg @@ -1 +1 @@ -Leaf UI light design resourcesLeaf UI / light component statessolidDefaultHoverPressedDisabledoutlineDefaultHoverPressedDisabledghostDefaultHoverPressedDisabledFields / default · focus · errorDefaultFocusErroriKeep the team informedReadable spacing and a semantic status icon.Project savedYour changes are ready for the team.Dialog headingForm and footer share an inline edgeSave +Leaf UI light design resourcesLeaf UI / light component statessolidDefaultHoverPressedDisabledoutlineDefaultHoverPressedDisabledghostDefaultHoverPressedDisabledFields / default · focus · errorDefaultFocusErroriKeep the team informedReadable spacing and a semantic status icon.Project savedYour changes are ready for the team.Dialog headingForm and footer share an inline edgeSave diff --git a/apps/docs/docs/public/design/leaf-light-foundations.svg b/apps/docs/docs/public/design/leaf-light-foundations.svg index 7fb8531a..83756ee5 100644 --- a/apps/docs/docs/public/design/leaf-light-foundations.svg +++ b/apps/docs/docs/public/design/leaf-light-foundations.svg @@ -1 +1 @@ -Leaf UI light design resourcesLeaf UI / light foundationsprimary#20834aon-primary#ffffffdanger#c83c3con-danger#ffffffwarning#faad14info#1677ffsuccess#20834atext#202024text-muted#68686fsurface#ffffffsurface-muted#f6f6f7border#e5e5e8Typography · Inter / system sans-serifHeading 24 / 600Body 14 / 1.6 · Semantic colors and stable geometryComfortable: 40px controls / 8px spacingCompact: 34px controls / 6px spacing +Leaf UI light design resourcesLeaf UI / light foundationsprimary#20834aon-primary#ffffffdanger#c83c3con-danger#ffffffwarning#faad14info#1677ffsuccess#20834atext#202024text-muted#68686fsurface#ffffffsurface-muted#f6f6f7border#e5e5e8Typography · Inter / system sans-serifHeading 24 / 600Body 14 / 1.6 · Semantic colors and stable geometryComfortable: 36px controls / 8px spacingCompact: 30px controls / 6px spacing diff --git a/apps/docs/docs/public/design/leaf.tokens-studio.json b/apps/docs/docs/public/design/leaf.tokens-studio.json index 97dccd7e..c7726ac9 100644 --- a/apps/docs/docs/public/design/leaf.tokens-studio.json +++ b/apps/docs/docs/public/design/leaf.tokens-studio.json @@ -49,6 +49,10 @@ "type": "color", "value": "#e5e5e8" }, + "option-hover": { + "type": "color", + "value": "rgba(235, 235, 235, 1)" + }, "primary-text-80": { "type": "color", "value": "rgba(32, 111, 66, 1)" @@ -601,6 +605,10 @@ "type": "color", "value": "#38383f" }, + "option-hover": { + "type": "color", + "value": "rgba(47, 47, 51, 1)" + }, "primary-text-80": { "type": "color", "value": "rgba(152, 213, 174, 1)" @@ -1107,7 +1115,7 @@ "dimension": { "control-height": { "type": "dimension", - "value": "40px" + "value": "36px" }, "spacing": { "type": "dimension", @@ -1127,7 +1135,7 @@ }, "density-option-height": { "type": "dimension", - "value": "36px" + "value": "32px" }, "density-option-padding": { "type": "dimension", @@ -1155,7 +1163,7 @@ "dimension": { "control-height": { "type": "dimension", - "value": "34px" + "value": "30px" }, "spacing": { "type": "dimension", @@ -1175,7 +1183,7 @@ }, "density-option-height": { "type": "dimension", - "value": "30px" + "value": "26px" }, "density-option-padding": { "type": "dimension", diff --git a/apps/docs/docs/public/design/leaf.tokens.json b/apps/docs/docs/public/design/leaf.tokens.json index 746c859b..accef57f 100644 --- a/apps/docs/docs/public/design/leaf.tokens.json +++ b/apps/docs/docs/public/design/leaf.tokens.json @@ -109,6 +109,15 @@ "hex": "#e5e5e8" } }, + "option-hover": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [0.9215686274509803, 0.9215686274509803, 0.9215686274509803], + "alpha": 1, + "hex": "#ebebeb" + } + }, "primary-text-80": { "$type": "color", "$value": { @@ -1346,6 +1355,15 @@ "hex": "#38383f" } }, + "option-hover": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [0.1843137254901961, 0.1843137254901961, 0.2], + "alpha": 1, + "hex": "#2f2f33" + } + }, "primary-text-80": { "$type": "color", "$value": { @@ -2478,7 +2496,7 @@ "control-height": { "$type": "dimension", "$value": { - "value": 40, + "value": 36, "unit": "px" } }, @@ -2513,7 +2531,7 @@ "density-option-height": { "$type": "dimension", "$value": { - "value": 36, + "value": 32, "unit": "px" } }, @@ -2559,7 +2577,7 @@ "control-height": { "$type": "dimension", "$value": { - "value": 34, + "value": 30, "unit": "px" } }, @@ -2594,7 +2612,7 @@ "density-option-height": { "$type": "dimension", "$value": { - "value": 30, + "value": 26, "unit": "px" } }, diff --git a/apps/docs/docs/public/package-managers/LICENSE.devicon.txt b/apps/docs/docs/public/package-managers/LICENSE.devicon.txt new file mode 100644 index 00000000..fcc9b667 --- /dev/null +++ b/apps/docs/docs/public/package-managers/LICENSE.devicon.txt @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2015 konpa + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/docs/docs/public/package-managers/README.txt b/apps/docs/docs/public/package-managers/README.txt new file mode 100644 index 00000000..c0d6ca2b --- /dev/null +++ b/apps/docs/docs/public/package-managers/README.txt @@ -0,0 +1,7 @@ +Package manager brand assets, stored locally for the documentation UI. + +npm: Simple Icons (CC0), https://github.com/simple-icons/simple-icons/blob/develop/icons/npm.svg +The original npm brand color is applied to the SVG. + +Yarn, pnpm, Bun: Devicon (MIT), https://github.com/devicons/devicon/tree/master/icons +See LICENSE.devicon.txt. Brand names and marks belong to their respective owners. diff --git a/apps/docs/docs/public/package-managers/bun.svg b/apps/docs/docs/public/package-managers/bun.svg new file mode 100644 index 00000000..bb0174d5 --- /dev/null +++ b/apps/docs/docs/public/package-managers/bun.svg @@ -0,0 +1 @@ +Bun diff --git a/apps/docs/docs/public/package-managers/npm.svg b/apps/docs/docs/public/package-managers/npm.svg new file mode 100644 index 00000000..84e84786 --- /dev/null +++ b/apps/docs/docs/public/package-managers/npm.svg @@ -0,0 +1 @@ +npm \ No newline at end of file diff --git a/apps/docs/docs/public/package-managers/pnpm.svg b/apps/docs/docs/public/package-managers/pnpm.svg new file mode 100644 index 00000000..f1d43e7b --- /dev/null +++ b/apps/docs/docs/public/package-managers/pnpm.svg @@ -0,0 +1 @@ +pnpm diff --git a/apps/docs/docs/public/package-managers/yarn.svg b/apps/docs/docs/public/package-managers/yarn.svg new file mode 100644 index 00000000..d58de658 --- /dev/null +++ b/apps/docs/docs/public/package-managers/yarn.svg @@ -0,0 +1 @@ +Yarn diff --git a/apps/docs/package.json b/apps/docs/package.json index 69aee4aa..a665ebac 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -4,15 +4,15 @@ "private": true, "type": "module", "scripts": { - "dev": "rspress dev --host 127.0.0.1 --port 3000", - "build": "rspress build", + "dev": "node scripts/sync-locales.mjs && rspress dev --host 127.0.0.1 --port 3000", + "build": "node scripts/sync-locales.mjs && rspress build", "preview": "rspress preview --host 127.0.0.1 --port 3000", "typecheck": "tsc --noEmit" }, "dependencies": { "@fontsource-variable/inter": "5.3.0", - "@rspress/core": "^2.0.23", "@ifhover/leaf-ui": "workspace:*", + "@rspress/core": "^2.0.23", "@tanstack/react-table": "^8.21.3", "lucide-react": "^1.50.0", "react": "^19.3.0", @@ -20,8 +20,12 @@ }, "devDependencies": { "@rsbuild/plugin-sass": "^2.0.1", + "@tailwindcss/postcss": "^4.3.3", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", + "postcss": "^8.5.29", + "tailwindcss": "^4.3.3", + "tailwindcss-v3": "npm:tailwindcss@3.4.19", "zod": "^4.6.5" } } diff --git a/apps/docs/rspress.config.ts b/apps/docs/rspress.config.ts index 22353994..e598566d 100644 --- a/apps/docs/rspress.config.ts +++ b/apps/docs/rspress.config.ts @@ -3,6 +3,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { pluginSass } from '@rsbuild/plugin-sass'; import { defineConfig } from '@rspress/core'; +import tailwindcss from '@tailwindcss/postcss'; +import { rehypeApiTypes } from './scripts/api-type-display.mjs'; import { remarkExampleSource } from './scripts/example-source.mjs'; const directory = path.dirname(fileURLToPath(import.meta.url)); @@ -13,22 +15,18 @@ const guideSidebar = [ items: [ { text: '认识 Leaf UI', link: '/guide/introduction' }, { text: '安装&快速开始', link: '/guide/getting-started' }, - { text: 'SSR 使用', link: '/guide/ssr' }, + { text: '服务端渲染', tag: 'SSR', link: '/guide/ssr' }, { text: 'AI 与 Skills', link: '/guide/ai' }, - { text: '控件与浮层', link: '/guide/control-contracts' }, - { text: '业务场景', link: '/guide/scenarios' }, - { text: '完整工作空间', link: '/guide/workspace' }, { text: '浏览器兼容性', link: '/guide/compatibility' }, - { text: '可访问性验收', link: '/guide/accessibility' }, ], }, { text: '个性化', items: [ { text: '定制主题', link: '/guide/theming' }, + { text: 'Tailwind CSS', link: '/guide/tailwindcss' }, + { text: '切换语言', link: '/guide/internationalization' }, { text: '视觉与交互规范', link: '/guide/design-standards' }, - { text: '设计资源', link: '/guide/design-resources' }, - { text: '使用图标', link: '/guide/icons' }, ], }, ]; @@ -36,11 +34,15 @@ const guideSidebar = [ const componentSidebar = [ { text: '组件', - items: [{ text: '组件总览', link: '/components/' }], + items: [ + { text: '组件总览', link: '/components/' }, + { text: '使用图标', link: '/components/icons' }, + ], }, { text: '通用', items: [ + { text: 'ConfigProvider', tag: '全局配置,配置', link: '/components/config-provider' }, { text: 'Typography', tag: '排版', link: '/components/typography' }, { text: 'Button', tag: '按钮', link: '/components/button' }, { text: 'Divider', tag: '分割线', link: '/components/divider' }, @@ -52,6 +54,7 @@ const componentSidebar = [ { text: 'Splitter', tag: '分割面板', link: '/components/splitter' }, { text: 'Layout', tag: '布局', link: '/components/layout' }, { text: 'Grid', tag: '栅格', link: '/components/grid' }, + { text: 'Flex', tag: '弹性布局', link: '/components/flex' }, { text: 'Space', tag: '间距', link: '/components/space' }, { text: 'ScrollArea', tag: '滚动区域', link: '/components/scroll-area' }, { text: 'Scrollbar', tag: '滚动条', link: '/components/scrollbar' }, @@ -98,8 +101,6 @@ const componentSidebar = [ { text: 'Carousel', tag: '轮播', link: '/components/carousel' }, { text: 'Image', tag: '图片', link: '/components/image' }, { text: 'FileList', tag: '文件列表', link: '/components/file-list' }, - { text: 'ImageCropper', tag: '图片裁剪', link: '/components/image-cropper' }, - { text: 'SignaturePad', tag: '签名板', link: '/components/signature-pad' }, ], }, { @@ -159,27 +160,39 @@ const componentSidebar = [ { text: 'Popover', tag: '气泡卡片', link: '/components/popover' }, ], }, +]; + +const apiSidebar = [ { - text: '配置', - items: [{ text: 'ConfigProvider', tag: '全局配置', link: '/components/config-provider' }], + text: 'API 参考', + items: [{ text: '参考总览', link: '/api/' }], + }, + { + text: '全局配置', + items: [ + { text: 'LeafComponentDefaults', link: '/api/component-defaults' }, + { text: 'LeafThemeTokens', link: '/api/theme-tokens' }, + { text: 'PopupOptions', link: '/api/popup-options' }, + { text: '翻译资源', link: '/api/translations' }, + ], }, ]; const englishLabels: Record = { - 控件与浮层: 'Control and popup contracts', - 业务场景: 'Application scenarios', - 完整工作空间: 'Complete workspace', + 'API 参考': 'API reference', + 参考总览: 'Overview', + 翻译资源: 'Translation resources', + 全局配置: 'Configuration', 浏览器兼容性: 'Browser compatibility', - 可访问性验收: 'Accessibility acceptance', 视觉与交互规范: 'Visual and interaction standard', - 设计资源: 'Design resources', 开始使用: 'Getting started', '认识 Leaf UI': 'Introduction', '安装&快速开始': 'Installation & quick start', - 'SSR 使用': 'SSR usage', + 服务端渲染: 'Server-side rendering', 'AI 与 Skills': 'AI & Skills', 个性化: 'Personalization', 定制主题: 'Theming', + 切换语言: 'Switch languages', 使用图标: 'Icons', 组件: 'Components', 组件总览: 'Overview', @@ -193,14 +206,20 @@ const englishLabels: Record = { 反馈与交互: 'Feedback', 配置: 'Configuration', }; -const englishSidebar = (items: typeof guideSidebar | typeof componentSidebar): typeof items => +const englishSidebarTags: Record = { + '/guide/ssr': 'SSR', + '/components/config-provider': 'Config', +}; +const englishSidebar = ( + items: typeof guideSidebar | typeof componentSidebar | typeof apiSidebar, +): typeof items => items.map((item) => ({ ...item, text: englishLabels[item.text] ?? item.text, items: item.items.map((child) => ({ ...child, text: englishLabels[child.text] ?? child.text, - tag: undefined, + tag: englishSidebarTags[child.link], })), })); @@ -243,7 +262,11 @@ export default defineConfig({ logoText: 'Leaf UI', outDir: 'doc_build', mediumZoom: { selector: '.rspress-doc img:not([data-no-zoom])' }, - markdown: { link: { checkDeadLinks: true }, remarkPlugins: [remarkExampleSource] }, + markdown: { + link: { checkDeadLinks: true }, + remarkPlugins: [remarkExampleSource], + rehypePlugins: [rehypeApiTypes], + }, themeConfig: { llmsUI: { placement: 'outline', viewOptions: ['markdownLink'] }, darkMode: 'light', @@ -251,7 +274,7 @@ export default defineConfig({ { lang: 'zh', label: '简体中文', - sidebar: { '/guide/': guideSidebar, '/components/': componentSidebar }, + sidebar: { '/guide/': guideSidebar, '/components/': componentSidebar, '/api/': apiSidebar }, }, { lang: 'en', @@ -259,20 +282,23 @@ export default defineConfig({ nav: [ { text: 'Guide', link: '/guide/introduction', activeMatch: '^/en/guide/' }, { text: 'Components', link: '/components/', activeMatch: '^/en/components/' }, + { text: 'API', link: '/api/', activeMatch: '^/en/api/' }, { text: 'Changelog', link: '/changelog', activeMatch: '^/en/changelog' }, ], sidebar: { '/en/guide/': englishSidebar(guideSidebar), '/en/components/': englishSidebar(componentSidebar), + '/en/api/': englishSidebar(apiSidebar), }, }, ], nav: [ { text: '指南', link: '/guide/introduction', activeMatch: '^/guide/' }, { text: '组件', link: '/components/', activeMatch: '^/components/' }, + { text: 'API', link: '/api/', activeMatch: '^/api/' }, { text: '更新记录', link: '/changelog', activeMatch: '^/changelog' }, ], - sidebar: { '/guide/': guideSidebar, '/components/': componentSidebar }, + sidebar: { '/guide/': guideSidebar, '/components/': componentSidebar, '/api/': apiSidebar }, socialLinks: [{ icon: 'github', mode: 'link', content: 'https://github.com/ifhover/leaf-ui' }], }, builderConfig: { @@ -284,6 +310,13 @@ export default defineConfig({ }, }, plugins: [pluginSass()], + tools: { + postcss: { + postcssOptions: { + plugins: [tailwindcss()], + }, + }, + }, resolve: { alias: { '@ifhover/leaf-ui/table/tanstack$': path.resolve( diff --git a/apps/docs/scripts/api-type-display.d.mts b/apps/docs/scripts/api-type-display.d.mts new file mode 100644 index 00000000..1ba7550d --- /dev/null +++ b/apps/docs/scripts/api-type-display.d.mts @@ -0,0 +1 @@ +export function rehypeApiTypes(): (tree: unknown) => void; diff --git a/apps/docs/scripts/api-type-display.mjs b/apps/docs/scripts/api-type-display.mjs new file mode 100644 index 00000000..88040d2b --- /dev/null +++ b/apps/docs/scripts/api-type-display.mjs @@ -0,0 +1,66 @@ +const isElement = (node, name) => node.type === 'element' && node.tagName === name; +const textContent = (node) => + node.type === 'text' ? node.value : (node.children ?? []).map(textContent).join(''); + +/** Keep linked type expressions in one code box without changing Markdown or API data. */ +export function rehypeApiTypes() { + return (tree) => { + const visit = (node) => { + if (isElement(node, 'table')) { + const rows = node.children + .flatMap((child) => + isElement(child, 'thead') || isElement(child, 'tbody') ? child.children : [child], + ) + .filter((child) => isElement(child, 'tr')); + const headers = rows[0]?.children.filter((child) => isElement(child, 'th')) ?? []; + const column = headers.findIndex((header) => + /^(?:Type|类型)$/u.test(textContent(header).trim()), + ); + if (column !== -1) { + if (/^(?:Description|说明)$/u.test(textContent(headers.at(-1)).trim())) { + node.properties = { + ...node.properties, + className: [ + ...new Set([].concat(node.properties?.className ?? [], 'leaf-api-table')), + ], + }; + } + for (const row of rows.slice(1)) { + const cell = row.children.filter((child) => isElement(child, 'td'))[column]; + if (!cell?.children.some((child) => isElement(child, 'code') || isElement(child, 'a'))) + continue; + const inline = (child) => + child.type === 'text' || + isElement(child, 'code') || + (isElement(child, 'a') && + child.children.every((part) => part.type === 'text' || isElement(part, 'code'))); + if (!cell.children.every(inline)) continue; + const children = cell.children.flatMap((child) => { + if (isElement(child, 'code')) return child.children; + if (isElement(child, 'a')) + return [ + { + ...child, + children: child.children.flatMap((part) => + isElement(part, 'code') ? part.children : [part], + ), + }, + ]; + return [child]; + }); + cell.children = [ + { + type: 'element', + tagName: 'code', + properties: { className: ['leaf-api-type'] }, + children, + }, + ]; + } + } + } + for (const child of node.children ?? []) visit(child); + }; + visit(tree); + }; +} diff --git a/apps/docs/scripts/component-metadata.mjs b/apps/docs/scripts/component-metadata.mjs index ddf01796..746cc3db 100644 --- a/apps/docs/scripts/component-metadata.mjs +++ b/apps/docs/scripts/component-metadata.mjs @@ -1,4 +1,17 @@ export const additions = [ + [ + 'Flex', + '弹性布局', + 'flex', + '布局', + 'Layout', + '控制内容方向、对齐、间距与换行。', + 'Control direction, alignment, spacing and wrapping.', + 'FlexBasic', + 'FlexAlignment', + '方向与对齐', + 'Direction and alignment', + ], [ 'Typography', '排版', @@ -176,16 +189,30 @@ export const updatedPages = new Set([ 'menu', 'tabs', 'breadcrumb', + 'pagination', 'steps', 'slider', 'color-picker', 'progress', 'config-provider', 'guide/theming', - 'guide/control-contracts', + 'api/popup-options', + 'api/component-defaults', + 'api/theme-tokens', ]); -export function documentationPage(entry) { +export function documentationPage(entry, availablePages) { + const reference = { + LeafComponentDefaults: 'api/component-defaults', + LeafThemeTokens: 'api/theme-tokens', + LeafComponentTokens: 'api/theme-tokens', + LeafThemeStyle: 'api/theme-tokens', + PopupOptions: 'api/popup-options', + LeafTranslation: 'api/translations', + LeafTranslations: 'api/translations', + }[entry.name]; + // Historical snapshots keep their original pages when separate references do not exist. + if (reference && (!availablePages || availablePages.has(reference))) return reference; if (entry.module === './theme' || entry.module === './shared/types') return 'guide/theming'; if (entry.module === './shared/floating') return 'guide/control-contracts'; if (entry.module === './shared/dialog') return 'components/modal'; @@ -206,11 +233,9 @@ export function documentationPage(entry) { inputnumber: 'input-number', inputotp: 'input-otp', inputmask: 'input-mask', - imagecropper: 'image-cropper', infinitescroll: 'infinite-scroll', loadingbar: 'loading-bar', scrollarea: 'scroll-area', - signaturepad: 'signature-pad', timepicker: 'time-picker', timerangepicker: 'time-range-picker', treeselect: 'tree-select', diff --git a/apps/docs/scripts/export-ai-docs.mjs b/apps/docs/scripts/export-ai-docs.mjs index 7d84e6ea..8f296b75 100644 --- a/apps/docs/scripts/export-ai-docs.mjs +++ b/apps/docs/scripts/export-ai-docs.mjs @@ -68,13 +68,25 @@ export async function exportAiDocs({ repository, output, base, sourceCommit = nu const version = manifest.version; const exports = await publicApi(repository); const files = await walkFiles(output); + const markdownFiles = files.filter((file) => { + const relative = path.relative(output, file).replaceAll(path.sep, '/'); + return /^(en\/)?(components|guide|api)\/.+\.md$/.test(relative); + }); + const availablePages = new Set( + markdownFiles.map((file) => + path + .relative(output, file) + .replaceAll(path.sep, '/') + .replace(/^en\//, '') + .replace(/\.md$/, ''), + ), + ); const pages = []; const normalize = (value) => value.replace(/[^a-z0-9]/gi, '').toLowerCase(); const origin = 'https://ifhover.github.io'; let exampleSource; - for (const file of files.filter((candidate) => candidate.endsWith('.md'))) { + for (const file of markdownFiles) { const relative = path.relative(output, file).replaceAll(path.sep, '/'); - if (!/^(en\/)?(components|guide)\/.+\.md$/.test(relative)) continue; const english = relative.startsWith('en/'); const id = relative.replace(/^en\//, '').replace(/\.md$/, ''); let document; @@ -104,7 +116,9 @@ export async function exportAiDocs({ repository, output, base, sourceCommit = nu const html = relative.replace(/\.md$/, '.html'); const symbols = exports .filter((entry) => { - if (documentationPage(entry) === id) return true; + const target = documentationPage(entry, availablePages); + if (target.startsWith('api/')) return target === id; + if (target === id) return true; const module = entry.module.split('/').filter(Boolean).at(-1); if (id === 'components/result' && module === 'empty') return true; if (id === 'components/textarea' && entry.module.startsWith('./textarea/')) return true; @@ -155,6 +169,25 @@ export async function exportAiDocs({ repository, output, base, sourceCommit = nu }; await mkdir(path.join(output, 'api'), { recursive: true }); await writeFile(path.join(output, 'api/index.json'), `${JSON.stringify(index, null, 2)}\n`); + // Copy only resources from this snapshot. Older releases without JSON stay unchanged. + for (const name of [ + 'en-US.json', + 'zh-CN.json', + 'zh-TW.json', + 'ja-JP.json', + 'ko-KR.json', + 'schema.json', + ]) { + let resource; + try { + resource = await readFile(path.join(repository, 'packages/react/src/locales', name)); + } catch (error) { + if (error.code === 'ENOENT') continue; + throw error; + } + await mkdir(path.join(output, 'locales'), { recursive: true }); + await writeFile(path.join(output, 'locales', name), resource); + } const llms = await readFile(path.join(output, 'llms.txt'), 'utf8'); const text = `# Leaf UI ${version}\n\n> Version-specific documentation for ${manifest.name}@${version}.\n\nRead only the pages required for your task. Match the package name and version installed in the application; do not substitute the latest version.\n\n## API discovery\n\n- [API and exported type index](${origin}${base}api/index.json): Map exact public exports to individual Markdown pages.\n\n${llms.replace(/^# .+\n/, '')}`; await writeFile(path.join(output, 'llms.txt'), text, 'utf8'); diff --git a/apps/docs/scripts/generate-extension-thumbnails.mjs b/apps/docs/scripts/generate-extension-thumbnails.mjs index 9cb3a807..a20652f8 100644 --- a/apps/docs/scripts/generate-extension-thumbnails.mjs +++ b/apps/docs/scripts/generate-extension-thumbnails.mjs @@ -194,25 +194,21 @@ const images = { '' + t(108, 117, 'Upload files', 11), 'file-list': - r(48, 16, 204, 126, '#f4f7f5') + - r(81, 23, 138, 112) + - t(101, 48, 'Project report', 11) + - line(101, 61, 93) + - line(101, 77, 98) + - line(101, 93, 85) + - t(129, 125, '1 / 4', 9, '#718078'), - 'image-cropper': - r(43, 24, 214, 112, '#d9eddf', 'none') + - '' + - r(84, 36, 132, 86, 'none', '#fff', 0) + - '', - 'signature-pad': - r(32, 30, 236, 94) + - ''.replace( - ' sixty', - '60 99C88 32 74 107 105 72S96 117 132 93S131 116 170 93S196 107 230 78', - ) + - t(52, 141, 'Signature', 10, '#718078'), + r(28, 22, 244, 112) + + [0, 1] + .map((i) => { + const y = 35 + i * 54; + return ( + r(42, y, 30, 30, i ? '#eff5ff' : '#fff1f1') + + t(83, y + 10, i ? 'Project notes.md' : 'Project report.pdf', 11) + + t(83, y + 28, i ? 'MD · 4 KB' : 'PDF · 2.4 MB', 9, '#718078') + + '' + ); + }) + .join('') + + '', }; for (const [name, content] of Object.entries(images)) { const svg = diff --git a/apps/docs/scripts/generate-foundation-thumbnails.mjs b/apps/docs/scripts/generate-foundation-thumbnails.mjs index ccfe3d95..2df2b787 100644 --- a/apps/docs/scripts/generate-foundation-thumbnails.mjs +++ b/apps/docs/scripts/generate-foundation-thumbnails.mjs @@ -100,6 +100,7 @@ const images = { rect(22, 72, 196, 26, '#f6f6f7', 'none') + text(33, 89, 'Home / Projects / Leaf Garden', '#68686f', 9), pagination: + rect(12, 40, 28, 28, '#fff', '#e5e5e8') + icon(ChevronLeft, 20, 48, '#68686f') + [1, 2, 3, 4, 5] .map( @@ -114,18 +115,19 @@ const images = { ) + text(53 + i * 31, 58, n, n === 2 ? '#20834a' : '#202024'), ) .join('') + - icon(ChevronRight, 204, 48, '#68686f') + + rect(200, 40, 28, 28, '#fff', '#e5e5e8') + + icon(ChevronRight, 208, 48, '#68686f') + text(73, 92, '11–20 / 50 items', '#68686f', 9), tag: - rect(21, 34, 59, 25, '#eaf4ed', '#bddcc7') + + rect(21, 34, 59, 25, '#eaf4ed', 'none', 5) + text(30, 51, 'Published', '#20834a', 9) + - rect(90, 34, 54, 25, '#edf5ff', '#bbd7fc') + + rect(90, 34, 54, 25, '#edf5ff', 'none', 5) + text(101, 51, 'Design', '#1677ff') + - rect(154, 34, 64, 25, '#fff6e9', '#e8d1af') + + rect(154, 34, 64, 25, '#fff6e9', 'none', 5) + text(164, 51, 'Pending', '#8d671c') + - rect(56, 74, 61, 25, '#20834a', 'none') + + rect(56, 74, 61, 25, '#20834a', 'none', 5) + text(68, 91, 'Ready', '#fff') + - rect(129, 74, 61, 25, '#f4effb', '#d9cdee') + + rect(129, 74, 61, 25, '#f4effb', 'none', 5) + text(141, 91, 'Leaf', '#7654c6') + icon(X, 169, 80, '#7654c6', 12), badge: diff --git a/apps/docs/scripts/sync-component-api.mjs b/apps/docs/scripts/sync-component-api.mjs index b52028d7..07d58823 100644 --- a/apps/docs/scripts/sync-component-api.mjs +++ b/apps/docs/scripts/sync-component-api.mjs @@ -306,11 +306,6 @@ const descriptions = { 'Offset; color stops use a percentage from 0 to 100.', ], color: ['颜色或语义色。', 'Color or semantic tone.'], - messages: ['覆盖内置组件的语言文本。', 'Override built-in component messages.'], - textMessages: [ - '以英文原文为 key,覆盖扩展组件的文本。', - 'Override extended component labels using the English source text as the key.', - ], direction: ['文字和交互方向。', 'Text and interaction direction.'], weekStartsOn: [ '一周起始日,0 表示周日、1 表示周一。', @@ -555,10 +550,17 @@ const specific = { 'Show the page-size selector.', ], 'DataTablePaginationProps.pageSizeOptions': ['可选择的每页条数。', 'Available page sizes.'], - 'DataTablePaginationProps.showQuickJumper': ['展示页码跳转输入框。', 'Show a page-number input.'], + 'DataTablePaginationProps.showQuickJumper': [ + '回车或离开分页区域时跳转。', + 'Commit a quick jump on Enter or when leaving the pagination region.', + ], 'DataTablePaginationProps.showTotal': [ - '定制总数及当前范围的展示。', - 'Customize the total and current range.', + 'true 展示默认统计,也可传入函数定制总数及当前范围。', + 'true shows a localized total; pass a function to customize the total and visible range.', + ], + 'DataTablePaginationProps.variant': [ + '分页按钮样式:描边 / 无边框。', + 'Pagination button appearance: outlined or borderless.', ], 'DataTablePaginationProps.simple': ['使用简洁分页器。', 'Use compact pagination.'], 'DataTablePaginationProps.hideOnSinglePage': [ @@ -687,6 +689,7 @@ const specific = { ], }; const headings = new Map(); +const documentedTypes = new Set(); for (const english of [false, true]) { for (const [page, names] of byPage) { let document = ''; @@ -697,6 +700,7 @@ for (const english of [false, true]) { ); } catch {} for (const name of names) { + if (sectionMatch(document, name)) documentedTypes.add(`${english}/${name}`); headings.set( `${english}/${name}`, sectionMatch(document, name)?.heading ?? @@ -724,12 +728,13 @@ function typeCell(value, page, english) { let position = 0; for (const match of text.matchAll(/\b[A-Za-z_]\w*\b/gu)) { const type = types.find((item) => item.name === match[0]); - if (!type || !schema.get(type.name)?.size) continue; + if (!type || (!schema.get(type.name)?.size && !documentedTypes.has(`${english}/${type.name}`))) + continue; const target = documentationPage(type); const url = target === page ? `#${anchor(type.name, english)}` - : `${target.startsWith('guide/') ? '../guide/' : '../components/'}${target.split('/').at(-1)}#${anchor(type.name, english)}`; + : `${path.posix.relative(path.posix.dirname(page), target)}#${anchor(type.name, english)}`; if (match.index > position) pieces.push(code(text.slice(position, match.index))); pieces.push(`[${code(match[0])}](${url})`); position = match.index + match[0].length; @@ -743,12 +748,14 @@ function escapePattern(value) { function sectionMatch(document, name) { const headings = [`${name.replace(/Props$/u, '')} API`, `${name} API`, name]; for (const heading of headings) { - const expression = new RegExp( - `(^## ${escapePattern(heading)}\\r?\\n)([\\s\\S]*?)(?=^## |$(?![\\s\\S]))`, - 'mu', - ); - const match = document.match(expression); - if (match) return { match, heading, expression }; + for (let level = 2; level <= 6; level++) { + const expression = new RegExp( + `(^#{${level}} ${escapePattern(heading)}\\r?\\n)([\\s\\S]*?)(?=^#{1,${level}} |$(?![\\s\\S]))`, + 'mu', + ); + const match = document.match(expression); + if (match) return { match, heading, expression }; + } } return null; } @@ -770,7 +777,7 @@ for (const [page, names] of byPage) { try { document = await readFile(file, 'utf8'); } catch { - if (!addition && page !== 'guide/control-contracts') continue; + if (!addition) continue; document = ''; } if (addition && !document) { @@ -790,10 +797,6 @@ for (const [page, names] of byPage) { const prefix = english ? '../../../src' : '../../src'; document = `import { ComponentExample } from '${prefix}/components/component-example';\nimport { ${basic}, ${advanced} } from '${prefix}/examples/${slug}/basic';\n\n# ${name}${english ? '' : ` ${zh}`}\n\n${english ? enDescription : description}\n\n## ${english ? 'Basic usage' : '基础用法'}\n\n} fileName="basic.tsx">\n\n\`\`\`tsx lineNumbers file="${prefix}/examples/${slug}/basic.tsx"\n\`\`\`\n\n\n\n## ${english ? enAdvancedTitle : advancedTitle}\n\n} fileName="basic.tsx">\n\n\`\`\`tsx lineNumbers file="${prefix}/examples/${slug}/basic.tsx"\n\`\`\`\n\n\n`; } - if (page === 'guide/control-contracts' && !document) - document = english - ? '# Control and popup contracts\n\nA common contract for fields and floating panels.\n' - : '# 控件与浮层\n\n输入控件与浮层使用一致的状态约定。\n'; let original = document; try { if (baselineRef) @@ -808,9 +811,13 @@ for (const [page, names] of byPage) { if (!props?.size) continue; const section = sectionMatch(document, name); const old = readApiRows(sectionMatch(original, name)?.match[2] ?? section?.match[2] ?? ''); + const hasDefault = + !/^\| (?:属性 \| 类型 \| 说明|Property \| Type \| Description) \|\r?$/mu.test( + section?.match[2] ?? '', + ); const rows = [ - `| ${english ? 'Property | Type | Default | Description' : '属性 | 类型 | 默认值 | 说明'} |`, - '| --- | --- | --- | --- |', + `| ${english ? 'Property | Type' : '属性 | 类型'} | ${hasDefault ? `${english ? 'Default' : '默认值'} | ` : ''}${english ? 'Description' : '说明'} |`, + hasDefault ? '| --- | --- | --- | --- |' : '| --- | --- | --- |', ]; for (const [property, value] of props) { if (value.type === 'never') continue; @@ -837,7 +844,7 @@ for (const [page, names] of byPage) { : `设置 ${property}。`)) ).replace(/^(?:Required\.\s*|必需。)+/u, ''); rows.push( - `| ${code(property)} | ${typeCell(value.type, page, english)} | ${value.defaultValue ? code(value.defaultValue) : ((!addition ? previous?.defaultValue : undefined) ?? '—')} | ${!value.optional ? (english ? 'Required. ' : '必需。') : ''}${description} |`, + `| ${code(property)} | ${typeCell(value.type, page, english)} | ${hasDefault ? `${value.defaultValue ? code(value.defaultValue) : ((!addition ? previous?.defaultValue : undefined) ?? '—')} | ` : ''}${!value.optional ? (english ? 'Required. ' : '必需。') : ''}${description} |`, ); } for (const [property, value] of old) @@ -867,7 +874,7 @@ for (const [page, names] of byPage) { ].includes(property) ) rows.push( - `| ${code(property)} | ${value.type} | ${value.defaultValue} | ${descriptions[property]?.[english ? 1 : 0] ?? value.description} |`, + `| ${code(property)} | ${value.type} | ${hasDefault ? `${value.defaultValue} | ` : ''}${descriptions[property]?.[english ? 1 : 0] ?? value.description} |`, ); const table = rows.join('\n'); if (section) { diff --git a/apps/docs/scripts/sync-locales.mjs b/apps/docs/scripts/sync-locales.mjs new file mode 100644 index 00000000..2cfd8f0e --- /dev/null +++ b/apps/docs/scripts/sync-locales.mjs @@ -0,0 +1,17 @@ +import { copyFile, mkdir } from 'node:fs/promises'; + +// Public files must exist before Rspress checks links and collects static assets. +const output = new URL('../docs/public/locales/', import.meta.url); +await mkdir(output, { recursive: true }); +for (const name of [ + 'en-US.json', + 'zh-CN.json', + 'zh-TW.json', + 'ja-JP.json', + 'ko-KR.json', + 'schema.json', +]) + await copyFile( + new URL(`../../../packages/react/src/locales/${name}`, import.meta.url), + new URL(name, output), + ); diff --git a/apps/docs/scripts/tailwind.test.mjs b/apps/docs/scripts/tailwind.test.mjs new file mode 100644 index 00000000..ceb8bd3d --- /dev/null +++ b/apps/docs/scripts/tailwind.test.mjs @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { renderTailwindTheme } from '../../../packages/react/scripts/build-tailwind.mjs'; + +const docsRoot = fileURLToPath(new URL('../', import.meta.url)); +const libraryRoot = fileURLToPath(new URL('../../../packages/react/', import.meta.url)); +// An isolated dependency directory can exercise earlier compiler versions with the same cases. +const compilerRoot = process.env.LEAF_TAILWIND_COMPAT_ROOT + ? path.resolve(process.env.LEAF_TAILWIND_COMPAT_ROOT) + : docsRoot; +const requireCompiler = createRequire(path.join(compilerRoot, 'package.json')); +const tailwind3 = requireCompiler('tailwindcss-v3'); +const tailwind4 = requireCompiler('@tailwindcss/postcss'); +const postcss = requireCompiler('postcss'); +const requireLibrary = createRequire(path.join(libraryRoot, 'package.json')); +const { build } = requireLibrary('esbuild'); +const { outputFiles } = await build({ + stdin: { + contents: + "export { default as preset } from './tailwind'; export { leafTailwindTokens as tokens } from './tailwind-tokens';", + resolveDir: path.join(libraryRoot, 'src'), + }, + bundle: true, + write: false, + format: 'esm', + platform: 'node', +}); +const { preset, tokens } = await import( + `data:text/javascript;base64,${Buffer.from(outputFiles[0].text).toString('base64')}` +); +const cssTheme = renderTailwindTheme(tokens); +const utilityPrefixes = { + colors: 'bg', + borderRadius: 'rounded', + spacing: 'p', + fontFamily: 'font', + fontSize: 'text', + lineHeight: 'leading', + fontWeight: 'font', + boxShadow: 'shadow', + transitionDuration: 'duration', + transitionTimingFunction: 'ease', + zIndex: 'z', +}; +const tokenClasses = Object.entries(utilityPrefixes).flatMap(([group, prefix]) => + Object.keys(tokens[group]).map((name) => `${prefix}-${name}`), +); +const extraClasses = [ + 'p-4', + 'rounded-lg', + 'bg-red-500', + 'text-sm', + 'text-leaf-primary', + 'border-leaf-border', + 'ring-leaf-focus', + 'fill-leaf-success', + 'stroke-leaf-info', + 'h-leaf-control', + 'min-h-leaf-control', + 'min-w-leaf-control', + 'gap-leaf-sm', + 'hover:bg-leaf-option-hover', + 'focus-visible:ring-leaf-focus', + 'md:p-leaf-lg', +]; +const guideFiles = ['guide/tailwindcss.mdx', 'en/guide/tailwindcss.mdx'].map((file) => + path.join(docsRoot, 'docs', file), +); +const guides = await Promise.all(guideFiles.map((file) => readFile(file, 'utf8'))); +const examples = await Promise.all( + guides.flatMap((guide, index) => + [...guide.matchAll(/\bfile="([^"]+\.tsx)"/g)].map((match) => + readFile(path.resolve(path.dirname(guideFiles[index]), match[1]), 'utf8'), + ), + ), +); +const guideClasses = examples.flatMap((source) => + [...source.matchAll(/className="([^"]+)"/g)].flatMap((match) => match[1].split(/\s+/)), +); +const commonClasses = [...new Set([...tokenClasses, ...extraClasses, ...guideClasses])]; + +function ruleFor(root, className) { + const selector = `.${className.replaceAll(/[:/]/g, '\\$&')}`; + let found; + root.walkRules((rule) => { + if (rule.selector === selector) found = rule; + }); + return found; +} + +for (const version of [3, 4]) { + const packageVersion = requireCompiler( + `${version === 3 ? 'tailwindcss-v3' : 'tailwindcss'}/package.json`, + ).version; + test(`Tailwind ${packageVersion} compiles every mapped token and the guide examples`, async () => { + const classes = [ + ...commonClasses, + ...(version === 4 ? ['bg-leaf-primary/50', 'text-leaf-muted/75'] : []), + ]; + const html = `
`; + let result; + if (version === 3) { + result = await postcss([ + tailwind3({ + content: [{ raw: html, extension: 'html' }], + presets: [preset], + corePlugins: { preflight: false }, + }), + ]).process('@tailwind utilities;', { from: undefined }); + } else { + const directory = await mkdtemp(path.join(compilerRoot, '.tailwind-test-')); + try { + await writeFile(path.join(directory, 'input.html'), html); + result = await postcss([tailwind4({ optimize: false })]).process( + `@import "tailwindcss/theme.css" layer(theme);\n@import "tailwindcss/utilities.css" source(none);\n@source "./input.html";\n${cssTheme}`, + { from: path.join(directory, 'input.css') }, + ); + } finally { + assert.ok(directory.startsWith(path.join(compilerRoot, '.tailwind-test-'))); + await rm(directory, { recursive: true, force: true }); + } + } + for (const className of classes.filter((name) => !name.includes(':'))) { + assert.ok(ruleFor(result.root, className), `Tailwind ${version}: ${className} exists`); + } + for (const [group, prefix] of Object.entries(utilityPrefixes)) { + for (const [name, value] of Object.entries(tokens[group])) { + const rule = ruleFor(result.root, `${prefix}-${name}`); + assert.ok( + rule.toString().includes(value), + `Tailwind ${version}: ${prefix}-${name} reads the Leaf variable on the element`, + ); + } + } + const color = ruleFor(result.root, 'bg-leaf-primary'); + assert.ok( + color.nodes.some( + (node) => node.prop === 'background-color' && node.value === 'var(--leaf-color-primary)', + ), + ); + if (version === 4) + assert.match(ruleFor(result.root, 'bg-leaf-primary/50').toString(), /color-mix\(/); + assert.match(ruleFor(result.root, 'text-leaf').toString(), /--leaf-line-height/); + assert.match(result.css, /hover\\:bg-leaf-option-hover/); + assert.match(result.css, /focus-visible\\:ring-leaf-focus/); + assert.match(result.css, /md\\:p-leaf-lg/); + }); +} + +test('the preset satisfies the official Tailwind 3 Config type without Tailwind runtime imports', async () => { + const directory = await mkdtemp(path.join(docsRoot, '.tailwind-test-')); + try { + const source = path.join(libraryRoot, 'src/tailwind').replaceAll('\\', '/'); + await writeFile( + path.join(directory, 'config.ts'), + `import type { Config } from 'tailwindcss-v3';\nimport leafPreset from ${JSON.stringify(source)};\nexport default { content: [], presets: [leafPreset] } satisfies Config;\n`, + ); + await writeFile( + path.join(directory, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + noEmit: true, + strict: true, + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'Bundler', + types: [], + skipLibCheck: false, + }, + files: ['./config.ts'], + }), + ); + const compiler = path.join( + path.dirname(requireLibrary.resolve('typescript/package.json')), + 'bin/tsc', + ); + execFileSync(process.execPath, [compiler, '--project', path.join(directory, 'tsconfig.json')], { + stdio: 'pipe', + }); + } finally { + assert.ok(directory.startsWith(path.join(docsRoot, '.tailwind-test-'))); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/docs/scripts/versioned-docs.test.mjs b/apps/docs/scripts/versioned-docs.test.mjs index dd9c5310..a0fe66b7 100644 --- a/apps/docs/scripts/versioned-docs.test.mjs +++ b/apps/docs/scripts/versioned-docs.test.mjs @@ -7,6 +7,7 @@ import test from 'node:test'; import { parse } from '@babel/parser'; import { versionTarget } from '../src/components/version-path.ts'; import { properties, readApiRows } from './api-model.mjs'; +import { rehypeApiTypes } from './api-type-display.mjs'; import { extractExampleMarkdown, extractExampleSource, @@ -14,6 +15,67 @@ import { remarkExampleSource, } from './example-source.mjs'; +test('API type links keep one code expression without changing links or other cells', () => { + const text = (value) => ({ type: 'text', value }); + const element = (tagName, children, properties = {}) => ({ + type: 'element', + tagName, + children, + properties, + }); + const code = (value) => element('code', [text(value)]); + const reference = (name, href) => element('a', [code(name)], { href }); + for (const label of ['Type', '类型']) { + const type = element('td', [ + code('Partial> | "), + reference('LeafTheme', '#leaftheme'), + ]); + const property = element('td', [code('Input')]); + const description = element('td', [reference('InputProps', '/components/input#input-api')]); + const unrelated = structuredClone(description); + const tree = element('table', [ + element('thead', [ + element('tr', [ + element('th', [text('Property')]), + element('th', [text(label)]), + element('th', [text('Description')]), + ]), + ]), + element('tbody', [element('tr', [property, type, description])]), + ]); + rehypeApiTypes()(tree); + assert.deepEqual(tree.properties.className, ['leaf-api-table']); + assert.equal(type.children.length, 1); + const expression = type.children[0]; + assert.equal(expression.tagName, 'code'); + assert.deepEqual( + expression.children + .filter((node) => node.tagName === 'a') + .map((node) => node.properties.href), + ['/components/input#input-api', '#leaftheme'], + ); + assert.equal( + expression.children + .map((node) => (node.type === 'text' ? node.value : node.children[0].value)) + .join(''), + "Partial> | LeafTheme", + ); + assert.equal( + expression.children.some( + (node) => node.tagName === 'code' || node.children?.some((part) => part.tagName === 'code'), + ), + false, + ); + assert.deepEqual(property.children, [code('Input')]); + assert.deepEqual(description, unrelated); + const once = structuredClone(tree); + rehypeApiTypes()(tree); + assert.deepEqual(tree, once); + } +}); + test('example language props and translation helpers disappear while real locale state remains', () => { const source = `import { Button } from '@ifhover/leaf-ui'; export function Demo({ english = false }: { english?: boolean }) { @@ -37,7 +99,7 @@ export function LocaleDemo() { assert.match(extractExampleSource(nested, 'Demo'), /"乙"/); }); -import { publicApi, walkFiles } from './export-ai-docs.mjs'; +import { exportAiDocs, publicApi, walkFiles } from './export-ai-docs.mjs'; test('example code keeps only the selected preview and its scoped dependencies', () => { const source = `import { Button, Input, type ButtonProps } from '@ifhover/leaf-ui'; @@ -185,6 +247,28 @@ test('AI discovery records the optional adapter import path separately from nati assert.equal(api.find((item) => item.name === 'DataTableRowReorderInfo')?.kind, 'type'); }); +test('scoped component defaults retain public discovery and built-in API defaults', async () => { + const root = path.resolve(import.meta.dirname, '../../..'); + const entry = path.join(root, 'packages/react/src/index.ts'); + const api = await publicApi(root); + assert.equal(api.find((item) => item.name === 'LeafComponentDefaults')?.kind, 'type'); + assert.ok(!api.some((item) => item.name === 'LeafMessages')); + const config = await properties(entry, 'ConfigProviderProps'); + assert.equal(config.get('components').type, 'LeafComponentDefaults'); + assert.ok(!config.has('messages') && !config.has('textMessages')); + assert.equal((await properties(entry, 'ButtonProps')).get('size').defaultValue, "'md'"); + assert.equal((await properties(entry, 'InputProps')).get('size').defaultValue, "'md'"); + assert.equal((await properties(entry, 'InputProps')).get('allowClear').defaultValue, 'false'); + assert.equal((await properties(entry, 'InputSearchProps')).get('loading').defaultValue, 'false'); + assert.equal((await properties(entry, 'SelectProps')).get('size').defaultValue, "'md'"); + const defaults = await properties(entry, 'LeafComponentDefaults'); + for (const name of ['Button', 'Input', 'InputSearch', 'Select', 'Modal']) + assert.match(defaults.get(name).type, /\| null/u); + assert.match(defaults.get('Select').type, /'options'/u); + assert.match(defaults.get('Modal').type, /'open'/u); + assert.equal((await properties(entry, 'ModalProps')).get('width').defaultValue, '480'); +}); + test('version switch preserves the page, language and anchor when available', () => { const release = { version: '0.2.0', @@ -407,6 +491,135 @@ for (const [packageName, version] of [ } }); +test('AI references own configuration types without changing historical snapshot destinations', async () => { + for (const separateReferences of [false, true]) { + const root = await mkdtemp(path.join(tmpdir(), 'leaf-ui-reference-test-')); + const output = path.join(root, 'output'); + try { + await mkdir(path.join(root, 'packages/react/src'), { recursive: true }); + await writeFile( + path.join(root, 'packages/react/package.json'), + JSON.stringify({ name: '@ifhover/leaf-ui', version: '0.5.0' }), + ); + await writeFile( + path.join(root, 'packages/react/src/index.ts'), + [ + "export { Button } from './button';", + "export { ConfigProvider } from './config-provider';", + "export type { LeafComponentDefaults } from './config-provider';", + "export type { LeafThemeTokens } from './theme';", + "export type { LeafComponentTokens, LeafThemeStyle } from './theme';", + "export type { PopupOptions } from './shared/floating';", + ...(separateReferences + ? ["export type { LeafTranslation, LeafTranslations } from './locales/types';"] + : []), + ...(separateReferences + ? [] + : ["export type { LeafMessages } from './config-provider/context';"]), + ].join('\n'), + ); + const references = { + LeafComponentDefaults: 'api/component-defaults', + LeafThemeTokens: 'api/theme-tokens', + LeafComponentTokens: 'api/theme-tokens', + LeafThemeStyle: 'api/theme-tokens', + PopupOptions: 'api/popup-options', + ...(separateReferences + ? { LeafTranslation: 'api/translations', LeafTranslations: 'api/translations' } + : {}), + ...(!separateReferences ? { LeafMessages: 'components/config-provider' } : {}), + }; + for (const prefix of ['', 'en/']) { + const pages = { + 'components/button': '# Button\n', + 'components/config-provider': '# ConfigProvider\n', + 'guide/theming': '# Theming\n', + ...(!separateReferences ? { 'guide/control-contracts': '# Popup contracts\n' } : {}), + ...(separateReferences + ? Object.fromEntries( + Object.entries(references).map(([name, page]) => [page, `# ${name}\n`]), + ) + : {}), + }; + for (const [page, content] of Object.entries(pages)) { + const file = path.join(output, `${prefix}${page}.md`); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, content); + } + } + await mkdir(path.join(output, 'api'), { recursive: true }); + await writeFile(path.join(output, 'api/index.html'), '

API reference

'); + await writeFile(path.join(output, 'llms.txt'), '# Leaf UI\n'); + const resources = [ + 'en-US.json', + 'zh-CN.json', + 'zh-TW.json', + 'ja-JP.json', + 'ko-KR.json', + 'schema.json', + ]; + if (separateReferences) { + await mkdir(path.join(root, 'packages/react/src/locales'), { recursive: true }); + for (const name of resources) + await writeFile( + path.join(root, 'packages/react/src/locales', name), + JSON.stringify({ snapshot: '0.5.0', file: name }), + ); + } + const index = await exportAiDocs({ + repository: root, + output, + base: '/leaf-ui/v/0.5.0/', + sourceCommit: 'b'.repeat(40), + }); + for (const [name, reference] of Object.entries(references)) { + for (const language of ['zh', 'en']) { + const owners = index.pages.filter( + (page) => + page.language === language && page.symbols.some((symbol) => symbol.name === name), + ); + assert.deepEqual( + owners.map((page) => page.id), + [ + separateReferences + ? reference + : ['LeafThemeTokens', 'LeafComponentTokens', 'LeafThemeStyle'].includes(name) + ? 'guide/theming' + : name === 'PopupOptions' + ? 'guide/control-contracts' + : 'components/config-provider', + ], + ); + if (separateReferences) { + const markdown = await readFile(path.join(output, owners[0].markdown), 'utf8'); + assert.ok(markdown.includes('Leaf UI 0.5.0 · Package: @ifhover/leaf-ui')); + assert.ok(markdown.includes(`/v/0.5.0/${owners[0].html}`)); + } + } + } + assert.equal( + await readFile(path.join(output, 'api/index.html'), 'utf8'), + '

API reference

', + ); + assert.equal( + JSON.parse(await readFile(path.join(output, 'api/index.json'), 'utf8')).version, + '0.5.0', + ); + for (const name of resources) { + const file = path.join(output, 'locales', name); + if (separateReferences) + assert.deepEqual(JSON.parse(await readFile(file, 'utf8')), { + snapshot: '0.5.0', + file: name, + }); + else await assert.rejects(readFile(file), { code: 'ENOENT' }); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + } +}); + const registryFixture = (name, version, commit = 'a'.repeat(40)) => ({ name, 'dist-tags': { latest: version }, diff --git a/apps/docs/src/components/component-catalog.ts b/apps/docs/src/components/component-catalog.ts index 11180230..bcea6f51 100644 --- a/apps/docs/src/components/component-catalog.ts +++ b/apps/docs/src/components/component-catalog.ts @@ -1,4 +1,13 @@ export const componentCatalog = [ + [ + 'Flex', + '弹性布局', + 'flex', + '布局', + 'Layout', + '控制内容方向、对齐、间距与换行。', + 'Control direction, alignment, spacing and wrapping.', + ], [ 'Scrollbar', '滚动条', @@ -629,24 +638,6 @@ export const componentCatalog = [ '选择或拖拽文件并上传。', 'Choose or drop files to upload.', ], - [ - 'ImageCropper', - '图片裁剪', - 'image-cropper', - '媒体', - 'Media', - '移动选框并调整裁剪范围。', - 'Move and resize a selection to crop images.', - ], - [ - 'SignaturePad', - '签名板', - 'signature-pad', - '媒体', - 'Media', - '手写签名并导出图片。', - 'Capture handwritten signatures and export images.', - ], [ 'Typography', '排版', diff --git a/apps/docs/src/components/component-example.scss b/apps/docs/src/components/component-example.scss index c0f2ab7f..0c0091d0 100644 --- a/apps/docs/src/components/component-example.scss +++ b/apps/docs/src/components/component-example.scss @@ -3,7 +3,7 @@ --rp-code-block-bg: var(--leaf-example-code-bg); --rp-code-font-size: 13px; min-width: 0; - overflow: hidden; + overflow: clip; margin: 20px 0 32px; border: 1px solid var(--leaf-site-border); border-radius: 12px; @@ -29,26 +29,24 @@ } .leaf-component-example__preview-content { - display: flex; - align-items: center; - justify-content: center; - flex-wrap: wrap; + display: grid; + grid-template-columns: minmax(0, 1fr); gap: 16px; - width: 100%; min-width: 0; + max-width: 100%; } -.leaf-component-example__preview-content > .leaf-demo-stack { - margin-inline: auto; -} - -.leaf-component-example__preview-content > * { +:where(.leaf-component-example__preview-content) > * { min-width: 0; max-width: 100%; } .leaf-component-example__code { border-top: 1px solid var(--leaf-site-divider); + background: var(--leaf-example-code-bg); + scroll-margin-top: calc( + var(--rp-nav-height) + var(--rp-sidebar-menu-height) + var(--rp-banner-height, 0px) + 16px + ); } // Keep the documentation's prose list rules out of component previews. @@ -127,6 +125,7 @@ .leaf-component-example__code-viewport::after { content: ''; position: absolute; + z-index: 2; inset: auto 0 0; height: 88px; background: linear-gradient(to bottom, transparent 5%, var(--leaf-example-code-bg) 90%); @@ -167,7 +166,7 @@ } .leaf-component-example__expand { - z-index: 1; + z-index: 3; display: flex; justify-content: center; position: relative; @@ -180,12 +179,20 @@ } .leaf-component-example[data-expanded='true'] .leaf-component-example__expand { + position: sticky; + bottom: 0; margin-top: 0; padding-top: 6px; + pointer-events: none; } .leaf-component-example__expand > button { background: var(--leaf-site-panel); + pointer-events: auto; +} + +.leaf-component-example[data-expanded='true'] .leaf-component-example__expand > button { + box-shadow: none; } .leaf-component-example__expand svg { diff --git a/apps/docs/src/components/component-example.tsx b/apps/docs/src/components/component-example.tsx index 4597052f..5555e50b 100644 --- a/apps/docs/src/components/component-example.tsx +++ b/apps/docs/src/components/component-example.tsx @@ -1,6 +1,6 @@ import './component-example.scss'; import { Button } from '@ifhover/leaf-ui'; -import { type ReactNode, useEffect, useId, useRef, useState } from 'react'; +import { type CSSProperties, type ReactNode, useEffect, useId, useRef, useState } from 'react'; import { CopyButton } from './copy-button'; import { useDocsLocale } from './i18n'; import { Icon } from './icon'; @@ -12,6 +12,8 @@ interface ComponentExampleProps { preview: ReactNode; children: ReactNode; fileName?: string; + /** Preview width in pixels or a CSS length; always limited to the available space. */ + width?: CSSProperties['width']; } /** A build-highlighted MDX code fence paired with its live React preview. */ @@ -20,6 +22,7 @@ export function ComponentExample({ preview, children, fileName = 'example.tsx', + width = 480, }: ComponentExampleProps) { const { t } = useDocsLocale(); const [expanded, setExpanded] = useState(false); @@ -47,6 +50,17 @@ export function ComponentExample({ return sourceRef.current?.querySelector('code')?.textContent ?? ''; } + function toggleExpanded() { + const code = sourceRef.current?.closest('.leaf-component-example__code'); + if (expanded && code) { + const scrollMargin = Number.parseFloat(getComputedStyle(code).scrollMarginTop); + if (code.getBoundingClientRect().top < scrollMargin) { + code.scrollIntoView({ block: 'start', behavior: 'instant' }); + } + } + setExpanded(!expanded); + } + return (
-
{preview}
+
+ {preview} +
@@ -94,7 +110,7 @@ export function ComponentExample({ variant="outline" aria-expanded={expanded} aria-controls={codeId} - onClick={() => setExpanded(!expanded)} + onClick={toggleExpanded} endIcon={} > {expanded ? t('收起代码', 'Collapse code') : t('展开代码', 'Expand code')} diff --git a/apps/docs/src/components/component-filter.scss b/apps/docs/src/components/component-filter.scss new file mode 100644 index 00000000..66777471 --- /dev/null +++ b/apps/docs/src/components/component-filter.scss @@ -0,0 +1,11 @@ +.leaf-component-filter { + min-width: 0; + + > .leaf-input { + width: 100%; + } + + input::-webkit-search-cancel-button { + appearance: none; + } +} diff --git a/apps/docs/src/components/component-filter.tsx b/apps/docs/src/components/component-filter.tsx new file mode 100644 index 00000000..74fbf1f8 --- /dev/null +++ b/apps/docs/src/components/component-filter.tsx @@ -0,0 +1,38 @@ +import './component-filter.scss'; +import { Input } from '@ifhover/leaf-ui'; +import { Search } from 'lucide-react'; + +export function matchesComponentQuery(query: string, text: string) { + const normalize = (value: string) => value.toLowerCase().replace(/[\s_-]+/g, ''); + const normalizedText = normalize(text); + return query + .trim() + .split(/\s+/) + .every((word) => normalizedText.includes(normalize(word))); +} + +export function ComponentFilter({ + value, + onChange, + label, +}: { + value: string; + onChange: (value: string) => void; + label: string; +}) { + return ( +
+ onChange(event.target.value)} + prefix={} + allowClear + aria-label={label} + placeholder={label} + autoComplete="off" + spellCheck={false} + /> +
+ ); +} diff --git a/apps/docs/src/components/component-overview.scss b/apps/docs/src/components/component-overview.scss index 2b3da409..07d9d0c6 100644 --- a/apps/docs/src/components/component-overview.scss +++ b/apps/docs/src/components/component-overview.scss @@ -1,3 +1,9 @@ +.leaf-component-overview__empty { + padding-block: 32px; + color: var(--leaf-site-muted); + text-align: center; +} + .leaf-component-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(175px, 100%), 1fr)); @@ -38,11 +44,10 @@ aspect-ratio: 2 / 1; border-bottom: 1px solid var(--leaf-site-border); background: var(--leaf-site-soft); - img { + > svg { display: block; width: 100%; height: 100%; - object-fit: contain; } } &__body { diff --git a/apps/docs/src/components/component-overview.tsx b/apps/docs/src/components/component-overview.tsx index e1daf335..f97d947c 100644 --- a/apps/docs/src/components/component-overview.tsx +++ b/apps/docs/src/components/component-overview.tsx @@ -1,23 +1,38 @@ import './component-overview.scss'; -import { withBase } from '@rspress/core/runtime'; import { ArrowUpRight } from 'lucide-react'; +import { useState } from 'react'; import { componentCatalog } from './component-catalog'; +import { ComponentFilter, matchesComponentQuery } from './component-filter'; +import { componentThumbnails } from './component-thumbnails'; +import { useDocsTheme } from './docs-theme'; import { useDocsLocale } from './i18n'; export function ComponentOverview() { const { english, t, url } = useDocsLocale(); - const components = componentCatalog.map( - ([name, label, slug, zhCategory, enCategory, zhDesc, enDesc]) => ({ + const { color } = useDocsTheme(); + const [query, setQuery] = useState(''); + const components = componentCatalog + .filter(([name, label, slug]) => matchesComponentQuery(query, `${name} ${label} ${slug}`)) + .map(([name, label, slug, zhCategory, enCategory, zhDesc, enDesc]) => ({ name, label, slug, category: t(zhCategory, enCategory), description: t(zhDesc, enDesc), - }), - ); + })); const groups = [...new Set(components.map((component) => component.category))]; return (
+ + {components.length === 0 && ( +

+ {t('没有匹配的组件,试试其他关键词。', 'No matching components. Try another keyword.')} +

+ )} {groups.map((group) => (

{group}

@@ -30,17 +45,17 @@ export function ComponentOverview() { className="leaf-component-card" href={url(`/components/${component.slug}.html`)} > -
- -
+
-
+
{features.map((feature) => (

{feature.title}

{feature.text}

+ {'href' in feature && ( + }> + {t('了解组件默认值', 'Explore component defaults')} + + )}
))}
-
-
-
- SMALL DETAILS. BETTER FEEL. -

- {t('亲手试一下,会更有感觉。', 'A little interaction goes a long way.')} -

-

- {t( - '切换、展开、保存。好体验,藏在每一次操作里。', - 'Switch, unfold, save. The experience is in every little action.', - )} -

-
-
- -
diff --git a/apps/docs/src/components/introduction.scss b/apps/docs/src/components/introduction.scss new file mode 100644 index 00000000..9b0902a7 --- /dev/null +++ b/apps/docs/src/components/introduction.scss @@ -0,0 +1,117 @@ +.leaf-intro-hero { + margin-block: 24px 8px; + padding: 36px 24px; + border: 1px solid var(--leaf-site-border); + border-radius: 16px; + background: var(--leaf-site-soft); + text-align: center; + + &__name { + margin: 0; + color: var(--leaf-site-text); + font-size: 42px; + font-weight: 600; + line-height: 1.1; + letter-spacing: -0.04em; + } + + &__tagline { + margin: 0; + color: var(--leaf-color-primary-text-55); + font-size: 15px; + font-weight: 500; + line-height: 1.6; + } + + &__description { + max-inline-size: 500px; + color: var(--leaf-site-muted); + font-size: 15px; + line-height: 1.8; + + > p { + margin: 0; + } + } + + &__badges { + margin-block: 2px 8px; + + > span { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 9px; + border: 1px solid var(--leaf-site-border); + border-radius: 6px; + color: var(--leaf-site-muted); + background: var(--leaf-site-panel); + font-size: 12px; + line-height: 1.5; + white-space: nowrap; + } + } +} + +.leaf-intro-features { + margin-block: 20px; +} + +.leaf-intro-feature { + min-inline-size: 0; + + &__icon { + flex-shrink: 0; + color: var(--leaf-site-accent); + } + + &__description { + color: var(--leaf-site-muted); + font-size: 14px; + line-height: 1.8; + + > p { + margin: 0; + } + } +} + +.leaf-intro-browsers { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 20px 0; + padding: 0; + overflow: hidden; + border: 1px solid var(--leaf-site-border); + border-radius: 12px; + background: var(--leaf-site-panel); + list-style: none; + + > li { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + min-inline-size: 0; + padding: 20px 8px; + color: var(--leaf-site-text); + font-size: 14px; + font-weight: 500; + + + li { + border-inline-start: 1px solid var(--leaf-site-border); + } + } + + &__version { + color: var(--leaf-site-muted); + font-size: 13px; + font-weight: 400; + } +} + +@media (max-width: 480px) { + .leaf-intro-hero { + padding: 28px 16px; + } +} diff --git a/apps/docs/src/components/introduction.tsx b/apps/docs/src/components/introduction.tsx new file mode 100644 index 00000000..fcf64bea --- /dev/null +++ b/apps/docs/src/components/introduction.tsx @@ -0,0 +1,126 @@ +import { Card, Flex, Grid } from '@ifhover/leaf-ui'; +import { withBase } from '@rspress/core/runtime'; +import { + ArrowRight, + Blocks, + Braces, + Globe2, + Keyboard, + Leaf, + Monitor, + Palette, + ShieldCheck, +} from 'lucide-react'; +import type { ReactNode } from 'react'; +import { ButtonLink } from './button-link'; +import { useDocsLocale } from './i18n'; +import './introduction.scss'; + +export function IntroductionHero({ children }: { children: ReactNode }) { + const { t, url } = useDocsLocale(); + return ( +
+ + +

Leaf UI

+

+ {t('轻盈 · 自然 · 可定制', 'Lightweight · Natural · Customizable')} +

+
{children}
+ + + + + + + + + + + + }> + {t('开始使用', 'Get started')} + + + {t('探索组件', 'Explore components')} + + +
+
+ ); +} + +const featureIcons = { + leaf: Leaf, + palette: Palette, + types: ShieldCheck, + components: Blocks, + languages: Globe2, + keyboard: Keyboard, +}; + +export function IntroductionFeatures({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function IntroductionFeature({ + icon, + title, + children, +}: { + icon: keyof typeof featureIcons; + title: string; + children: ReactNode; +}) { + const Icon = featureIcons[icon]; + return ( + + + ); +} + +const browsers = [ + { name: 'Chrome', icon: 'chrome', minimum: '109' }, + { name: 'Edge', icon: 'edge', minimum: '109' }, + { name: 'Firefox', icon: 'firefox', minimum: '121' }, + { name: 'Safari', icon: 'safari', minimum: '16.4' }, +]; + +export function IntroductionBrowsers() { + const { t } = useDocsLocale(); + return ( +
    + {browsers.map(({ name, icon, minimum }) => ( +
  • + + {name} + ≥ {minimum} +
  • + ))} +
+ ); +} diff --git a/apps/docs/src/components/package-install.scss b/apps/docs/src/components/package-install.scss new file mode 100644 index 00000000..5e378500 --- /dev/null +++ b/apps/docs/src/components/package-install.scss @@ -0,0 +1,118 @@ +.leaf-package-install { + width: 100%; + min-width: 0; + text-align: start; + + .leaf-tabs__panel { + padding-block: 0; + } + + .leaf-tabs__tab img { + display: block; + flex: none; + } + + &__command { + display: flex; + align-items: center; + gap: 12px; + min-height: 56px; + padding: 10px 14px; + background: var(--leaf-site-soft); + + code { + flex: 1; + min-width: 0; + color: var(--leaf-site-text); + font-family: var(--rp-font-family-mono); + font-size: 13px; + line-height: 1.7; + white-space: pre-wrap; + overflow-wrap: anywhere; + + > span { + color: var(--leaf-color-primary); + } + } + + .leaf-button { + flex: none; + color: var(--leaf-site-muted); + } + } + + &__prompt { + color: var(--leaf-site-muted); + font-family: var(--rp-font-family-mono); + font-size: 13px; + user-select: none; + } + + &[data-variant='guide'] { + margin-block: 20px 24px; + + .leaf-tabs__panels { + padding-top: 8px; + } + + .rp-codeblock { + margin-block: 0; + } + } + + &[data-variant='hero'] { + max-width: 410px; + overflow: clip; + border: 1px solid var(--leaf-site-border); + border-radius: 12px; + background: var(--leaf-site-panel); + + .leaf-tabs__header { + padding-inline: 12px; + } + + .leaf-tabs__list { + flex: 1; + } + + .leaf-tabs__track { + gap: 0; + } + + .leaf-tabs__entry { + flex: 1; + } + + .leaf-tabs__tab { + flex: 1; + justify-content: center; + } + } +} + +@media (max-width: 520px) { + .leaf-package-install { + .leaf-tabs__track { + gap: 0; + } + + .leaf-tabs__entry, + .leaf-tabs__tab { + flex: 1; + } + + .leaf-tabs__tab { + gap: 6px; + padding-inline: 7px; + } + + &__command { + gap: 8px; + padding-inline: 12px; + } + + &[data-variant='hero'] .leaf-tabs__header { + padding-inline: 6px; + } + } +} diff --git a/apps/docs/src/components/package-install.tsx b/apps/docs/src/components/package-install.tsx new file mode 100644 index 00000000..84352862 --- /dev/null +++ b/apps/docs/src/components/package-install.tsx @@ -0,0 +1,118 @@ +import { Tabs } from '@ifhover/leaf-ui'; +import { withBase } from '@rspress/core/runtime'; +import { Children, isValidElement, type ReactNode, useEffect, useState } from 'react'; +import { CopyButton } from './copy-button'; +import { useDocsLocale } from './i18n'; +import './package-install.scss'; + +const packageManagers = [ + { key: 'npm', label: 'npm', action: 'install' }, + { key: 'yarn', label: 'Yarn', action: 'add' }, + { key: 'pnpm', label: 'pnpm', action: 'add' }, + { key: 'bun', label: 'Bun', action: 'add' }, +] as const; + +const storageKey = 'leaf-ui-docs-package-manager'; +const changeEvent = 'leaf-ui-docs-package-manager-change'; + +function usePackageManager() { + // Match the static HTML before restoring the browser's preference. + const [manager, setManager] = useState('pnpm'); + + useEffect(() => { + const restore = (value: unknown) => { + setManager(packageManagers.find((item) => item.key === value)?.key ?? 'pnpm'); + }; + const syncStorage = (event: StorageEvent) => { + if (event.key === storageKey || event.key === null) restore(event.newValue); + }; + const syncPage = (event: Event) => { + if (event instanceof CustomEvent) restore(event.detail); + }; + + try { + restore(window.localStorage.getItem(storageKey)); + } catch { + // The tabs still work when browser storage is unavailable. + } + window.addEventListener('storage', syncStorage); + window.addEventListener(changeEvent, syncPage); + return () => { + window.removeEventListener('storage', syncStorage); + window.removeEventListener(changeEvent, syncPage); + }; + }, []); + + function selectManager(key: string) { + const selection = packageManagers.find((item) => item.key === key); + if (!selection) return; + setManager(selection.key); + try { + window.localStorage.setItem(storageKey, selection.key); + } catch { + // Changing the selection must not depend on persistence. + } + window.dispatchEvent(new CustomEvent(changeEvent, { detail: selection.key })); + } + + return { manager, selectManager }; +} + +export function PackageInstall({ + variant = 'guide', + children, +}: { + variant?: 'guide' | 'hero'; + children?: ReactNode; +}) { + const { t } = useDocsLocale(); + const { manager, selectManager } = usePackageManager(); + // Rspress passes the original fences as one string when exporting Markdown. + if (typeof children === 'string') return <>{children}; + const panels = Children.toArray(children).filter(isValidElement); + + return ( +
+ { + const command = `${manager.key} ${manager.action} @ifhover/leaf-ui`; + return { + key: manager.key, + label: manager.label, + icon: ( + + ), + children: panels[index] ?? ( +
+ + + {manager.key} {manager.action} @ifhover/leaf-ui + + +
+ ), + }; + })} + /> +
+ ); +} diff --git a/apps/docs/src/components/theme-color-switcher.tsx b/apps/docs/src/components/theme-color-switcher.tsx new file mode 100644 index 00000000..36bc5652 --- /dev/null +++ b/apps/docs/src/components/theme-color-switcher.tsx @@ -0,0 +1,45 @@ +import { Button, ColorPicker } from '@ifhover/leaf-ui'; +import { useDocsTheme } from './docs-theme'; +import { useDocsLocale } from './i18n'; +import { defaultThemeColor, themeColorPresets } from './theme-colors'; + +export function ThemeColorSwitcher() { + const { color, setColor } = useDocsTheme(); + const { t } = useDocsLocale(); + return ( + preset.color), + }, + ]} + popupRender={(content) => ( + <> +
+
{t('主题色', 'Theme color')}
+

+ {t('同步应用到文档和组件预览', 'Applies to the docs and component previews')} +

+
+ {content} +
+ {color.toUpperCase()} + +
+ + )} + /> + ); +} diff --git a/apps/docs/src/components/theme-playground.tsx b/apps/docs/src/components/theme-playground.tsx index b4ca3ddc..f198cb4b 100644 --- a/apps/docs/src/components/theme-playground.tsx +++ b/apps/docs/src/components/theme-playground.tsx @@ -13,13 +13,16 @@ import { import { CodeBlockRuntime } from '@rspress/core/theme-original'; import { useId, useState } from 'react'; import { CopyButton } from './copy-button'; +import { useDocsTheme } from './docs-theme'; import { useDocsLocale } from './i18n'; import { Icon } from './icon'; -import { defaultThemeColor, normalizeThemeColor, themeColorPresets } from './theme-colors'; +import { normalizeThemeColor, themeColorPresets } from './theme-colors'; export function ThemePlayground() { const { t } = useDocsLocale(); - const [color, setColor] = useState(defaultThemeColor); + const { color: docsColor } = useDocsTheme(); + const [customColor, setColor] = useState(); + const color = customColor ?? docsColor; const [radius, setRadius] = useState(10); const [mode, setMode] = useState<'light' | 'dark'>('light'); const [density, setDensity] = useState<'comfortable' | 'compact'>('comfortable'); @@ -44,7 +47,7 @@ export function ThemePlayground() { ].join('\n'); function reset() { - setColor(defaultThemeColor); + setColor(undefined); setRadius(10); setMode('light'); setDensity('comfortable'); diff --git a/apps/docs/src/examples/alert/basic.tsx b/apps/docs/src/examples/alert/basic.tsx index 847d1d14..689c4777 100644 --- a/apps/docs/src/examples/alert/basic.tsx +++ b/apps/docs/src/examples/alert/basic.tsx @@ -1,7 +1,7 @@ import { Alert, Space } from '@ifhover/leaf-ui'; export function AlertBasic() { return ( - + diff --git a/apps/docs/src/examples/alert/neutral.tsx b/apps/docs/src/examples/alert/neutral.tsx index cb21a7a1..d8c101cb 100644 --- a/apps/docs/src/examples/alert/neutral.tsx +++ b/apps/docs/src/examples/alert/neutral.tsx @@ -4,7 +4,7 @@ import { ShieldCheck } from 'lucide-react'; export function NeutralAlerts({ english = false }: { english?: boolean }) { const t = (zh: string, en: string) => (english ? en : zh); return ( - + } diff --git a/apps/docs/src/examples/auto-complete/basic.tsx b/apps/docs/src/examples/auto-complete/basic.tsx index fd1ee694..6785493c 100644 --- a/apps/docs/src/examples/auto-complete/basic.tsx +++ b/apps/docs/src/examples/auto-complete/basic.tsx @@ -1,4 +1,4 @@ -import { AutoComplete, Space } from '@ifhover/leaf-ui'; +import { AutoComplete, Form, FormField, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; const options = [ @@ -12,9 +12,8 @@ export function AutoCompleteBasic() { const [value, setValue] = useState(''); const [selected, setSelected] = useState(''); return ( - -
- 项目名称 +
+ -
- + + 输入:{value || '空'} · 最近选中:{selected || '无'} - -
- 禁用建议 + + -
-
+ + ); } diff --git a/apps/docs/src/examples/auto-complete/search.tsx b/apps/docs/src/examples/auto-complete/search.tsx index 82cc2ae9..cb41a05c 100644 --- a/apps/docs/src/examples/auto-complete/search.tsx +++ b/apps/docs/src/examples/auto-complete/search.tsx @@ -1,12 +1,11 @@ -import { AutoComplete, type AutoCompleteOption, Space } from '@ifhover/leaf-ui'; +import { AutoComplete, type AutoCompleteOption, Form, FormField, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; export function AutoCompleteSearch() { const [options, setOptions] = useState([]); return ( - -
- 邮箱地址 +
+ -
- 建议由输入动态生成,也可以直接填写完整地址。 -
+ + 建议由输入动态生成,也可以直接填写完整地址。 + ); } diff --git a/apps/docs/src/examples/avatar/basic.tsx b/apps/docs/src/examples/avatar/basic.tsx index 640ae3d9..07266203 100644 --- a/apps/docs/src/examples/avatar/basic.tsx +++ b/apps/docs/src/examples/avatar/basic.tsx @@ -1,27 +1,27 @@ -import { Avatar, Badge, Space } from '@ifhover/leaf-ui'; +import { Avatar, Badge, Flex, Text } from '@ifhover/leaf-ui'; export function AvatarBasic() { return ( - -
- 小尺寸 + + + 小尺寸 LF -
-
- 默认尺寸 + + + 默认尺寸 LE -
-
- 大尺寸 · 方形 + + + 大尺寸 · 方形 UI -
-
- 未读提醒 + + + 未读提醒 A -
-
+ + ); } diff --git a/apps/docs/src/examples/avatar/fallback.tsx b/apps/docs/src/examples/avatar/fallback.tsx index 9a87f366..0211a50e 100644 --- a/apps/docs/src/examples/avatar/fallback.tsx +++ b/apps/docs/src/examples/avatar/fallback.tsx @@ -1,17 +1,17 @@ -import { Avatar, Space } from '@ifhover/leaf-ui'; +import { Avatar, Flex, Text } from '@ifhover/leaf-ui'; export function AvatarFallback() { return ( - -
- 加载失败时显示文字 + + + 加载失败时显示文字 LF -
-
- 未设置图片时显示默认图标 + + + 未设置图片时显示默认图标 -
-
+ + ); } diff --git a/apps/docs/src/examples/avatar/image.tsx b/apps/docs/src/examples/avatar/image.tsx index 111b6ea1..5fa2507e 100644 --- a/apps/docs/src/examples/avatar/image.tsx +++ b/apps/docs/src/examples/avatar/image.tsx @@ -1,16 +1,16 @@ -import { Avatar, Space } from '@ifhover/leaf-ui'; +import { Avatar, Flex, Text } from '@ifhover/leaf-ui'; export function AvatarImage() { const image = '../avatar-portrait.svg'; return ( - -
- 圆形图片 + + + 圆形图片 -
-
- 方形图片 + + + 方形图片 -
-
+ + ); } diff --git a/apps/docs/src/examples/badge/basic.tsx b/apps/docs/src/examples/badge/basic.tsx index 55a1c514..2d9d8c52 100644 --- a/apps/docs/src/examples/badge/basic.tsx +++ b/apps/docs/src/examples/badge/basic.tsx @@ -1,4 +1,4 @@ -import { Badge, Button, Space } from '@ifhover/leaf-ui'; +import { Badge, Button, Flex, Space, Text } from '@ifhover/leaf-ui'; import { Bell, Mail } from 'lucide-react'; export function BadgeBasic() { @@ -12,18 +12,18 @@ export function BadgeBasic() { export function BadgeCounts() { return ( -
- 超过数量上限 + + 超过数量上限
-
- 显示零值 + + + 显示零值
+
); } diff --git a/apps/docs/src/examples/breadcrumb/basic.tsx b/apps/docs/src/examples/breadcrumb/basic.tsx index bb50cea0..721083ef 100644 --- a/apps/docs/src/examples/breadcrumb/basic.tsx +++ b/apps/docs/src/examples/breadcrumb/basic.tsx @@ -1,4 +1,5 @@ -import { Breadcrumb, type BreadcrumbItem, Space } from '@ifhover/leaf-ui'; +import type { BreadcrumbItem } from '@ifhover/leaf-ui'; +import { Breadcrumb, Space } from '@ifhover/leaf-ui'; import { House } from 'lucide-react'; const items: readonly BreadcrumbItem[] = [ @@ -8,7 +9,7 @@ const items: readonly BreadcrumbItem[] = [ ]; export function BreadcrumbBasic() { return ( - + diff --git a/apps/docs/src/examples/button/extensions.tsx b/apps/docs/src/examples/button/extensions.tsx index 36777b59..3eddf178 100644 --- a/apps/docs/src/examples/button/extensions.tsx +++ b/apps/docs/src/examples/button/extensions.tsx @@ -1,16 +1,16 @@ -import { Button, ButtonGroup, Space, SplitButton } from '@ifhover/leaf-ui'; +import { Button, ButtonGroup, Flex, Space, SplitButton, Text } from '@ifhover/leaf-ui'; export function ExtensionDemo() { return ( -
-

按钮组

+ + 按钮组 -
-
-

分裂按钮

+ + + 分裂按钮 保存 -
+
); } diff --git a/apps/docs/src/examples/calendar/basic.tsx b/apps/docs/src/examples/calendar/basic.tsx index 08f0c339..5bf3a5ca 100644 --- a/apps/docs/src/examples/calendar/basic.tsx +++ b/apps/docs/src/examples/calendar/basic.tsx @@ -1,4 +1,4 @@ -import { Calendar, Space } from '@ifhover/leaf-ui'; +import { Calendar, Space, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; const events: Record = { @@ -9,22 +9,19 @@ const events: Record = { export function CalendarBasic() { const [value, setValue] = useState(new Date(2026, 9, 4)); return ( - + { const event = date.getMonth() === 9 ? events[date.getDate()] : undefined; - return event ? ( - - - {event.label} - - ) : null; + return event ? {event.label} : null; }} /> - - 已选日期: {value.getFullYear()}-{value.getMonth() + 1}-{value.getDate()} + + + 已选日期: {value.getFullYear()}-{value.getMonth() + 1}-{value.getDate()} + ); diff --git a/apps/docs/src/examples/calendar/compact.tsx b/apps/docs/src/examples/calendar/compact.tsx index 0245618c..2623f1e6 100644 --- a/apps/docs/src/examples/calendar/compact.tsx +++ b/apps/docs/src/examples/calendar/compact.tsx @@ -1,13 +1,13 @@ -import { Calendar, Space } from '@ifhover/leaf-ui'; +import { Calendar, Space, Text } from '@ifhover/leaf-ui'; export function CalendarCompact() { return ( - + date.getDay() === 0 || date.getDay() === 6} /> -

周末不可选;已有的选中日期仍清晰可见。

+ 周末不可选;已有的选中日期仍清晰可见。
); } diff --git a/apps/docs/src/examples/card/basic.tsx b/apps/docs/src/examples/card/basic.tsx index a945b7a6..d2dc6398 100644 --- a/apps/docs/src/examples/card/basic.tsx +++ b/apps/docs/src/examples/card/basic.tsx @@ -1,53 +1,4 @@ -import { Avatar, Button, Card, Space, Tag } from '@ifhover/leaf-ui'; -import { ArrowUpRight, Leaf } from 'lucide-react'; - +import { Card } from '@ifhover/leaf-ui'; export function CardBasic() { - return ( - - 进行中} - hoverable - footer={ -
- LF - 设计团队 - -
- } - > -
-
-

- 整理项目与近期计划,让每一个好想法都有生长的空间。 -

-
- - - 适合内容密集的页面。 - -
- ); + return 整理项目资料,让团队围绕同一份计划协作。; } diff --git a/apps/docs/src/examples/card/sections.tsx b/apps/docs/src/examples/card/sections.tsx index 39a1e5d0..025495a9 100644 --- a/apps/docs/src/examples/card/sections.tsx +++ b/apps/docs/src/examples/card/sections.tsx @@ -1,42 +1,32 @@ -import { Button, Card, Space, Tag } from '@ifhover/leaf-ui'; -import { useState } from 'react'; - +import { Button, Card, Flex, Space, Tag } from '@ifhover/leaf-ui'; export function CardSections({ english = false }: { english?: boolean }) { const t = (zh: string, en: string) => (english ? en : zh); - const [saved, setSaved] = useState(false); return ( - + {t('默认', 'Default')}} + title={t('项目概览', 'Project overview')} + description={t('默认通过留白组织内容', 'Group content with spacing')} + extra={{t('进行中', 'In progress')}} > {t( - '适合展示概览、项目或其他轻量内容。', - 'Use for summaries, projects and lightweight content.', + '标题、说明和正文自然分组。', + 'The heading, description and body form natural sections.', )} - - - + + + + } > - - {saved - ? t('设置已保存。', 'Settings saved.') - : t( - '接收重要事件提醒,每周汇总一次活动。', - 'Receive important updates and a weekly activity summary.', - )} - + {t( + '设置 divided 后,标题、正文和底部操作使用分隔线划分。', + 'Enable divided to separate the heading, body and footer actions.', + )} ); diff --git a/apps/docs/src/examples/card/sizes.tsx b/apps/docs/src/examples/card/sizes.tsx new file mode 100644 index 00000000..f4bdb4ed --- /dev/null +++ b/apps/docs/src/examples/card/sizes.tsx @@ -0,0 +1,19 @@ +import { Card, Space } from '@ifhover/leaf-ui'; + +export function CardSizes() { + const items = [ + { key: 'overview', label: '概览', children: '整理项目资料,让设计与开发围绕同一份计划协作。' }, + { + key: 'activity', + label: '最近动态', + children: '今天更新了组件规范,并邀请两位成员加入项目。', + }, + ]; + return ( + + + + + + ); +} diff --git a/apps/docs/src/examples/card/tabs-with-title.tsx b/apps/docs/src/examples/card/tabs-with-title.tsx new file mode 100644 index 00000000..b2e8ff9d --- /dev/null +++ b/apps/docs/src/examples/card/tabs-with-title.tsx @@ -0,0 +1,65 @@ +import { Button, Card, Descriptions, Flex, Form, FormField, Input, Tag } from '@ifhover/leaf-ui'; +import { useId, useState } from 'react'; + +export function CardTabsWithTitle() { + const [activeKey, setActiveKey] = useState('settings'); + const [saved, setSaved] = useState(false); + const formId = useId(); + return ( + {saved ? '已保存' : '草稿'}} + tabs={{ + activeKey, + onChange: setActiveKey, + items: [ + { + key: 'settings', + label: '基本信息', + children: ( +
setSaved(false)} + onSubmit={(event) => { + event.preventDefault(); + setSaved(true); + }} + > + + + + + + +
+ ), + }, + { + key: 'members', + label: '团队成员', + children: ( + + ), + }, + { key: 'billing', label: '账单', disabled: true, children: null }, + ], + }} + footer={ + + + + } + /> + ); +} diff --git a/apps/docs/src/examples/card/tabs.tsx b/apps/docs/src/examples/card/tabs.tsx new file mode 100644 index 00000000..1f2a422d --- /dev/null +++ b/apps/docs/src/examples/card/tabs.tsx @@ -0,0 +1,35 @@ +import { Button, Card, Descriptions, Space } from '@ifhover/leaf-ui'; + +export function CardTabs() { + return ( + 查看项目} + tabs={{ + 'aria-label': '项目概览', + items: [ + { + key: 'overview', + label: '概览', + children: ( + + 让设计与开发围绕同一份计划协作。 + + + ), + }, + { + key: 'activity', + label: '最近动态', + children: '今天更新了组件规范,并邀请两位成员加入项目。', + }, + ], + }} + /> + ); +} diff --git a/apps/docs/src/examples/cascader/basic.tsx b/apps/docs/src/examples/cascader/basic.tsx index 1d840a78..d7ae82bc 100644 --- a/apps/docs/src/examples/cascader/basic.tsx +++ b/apps/docs/src/examples/cascader/basic.tsx @@ -1,4 +1,4 @@ -import { Cascader, Space } from '@ifhover/leaf-ui'; +import { Cascader, Form, FormField } from '@ifhover/leaf-ui'; const options = [ { @@ -22,19 +22,16 @@ const options = [ export function CascaderBasic() { return ( - -
- 项目分类 +
+ -
-
- 默认分类 + + -
-
- 禁用分类 + + -
-
+ + ); } diff --git a/apps/docs/src/examples/cascader/capabilities.tsx b/apps/docs/src/examples/cascader/capabilities.tsx index 590eceaf..21c4dec0 100644 --- a/apps/docs/src/examples/cascader/capabilities.tsx +++ b/apps/docs/src/examples/cascader/capabilities.tsx @@ -1,4 +1,5 @@ -import { Button, Cascader, type CascaderOption, Space } from '@ifhover/leaf-ui'; +import type { CascaderOption } from '@ifhover/leaf-ui'; +import { Button, Cascader, Flex, Space, Text } from '@ifhover/leaf-ui'; import { useCallback, useState } from 'react'; const options = [ @@ -31,18 +32,16 @@ export function Capabilities({ english = false }) { }, []); return ( -
-

+ + {english ? 'Search, multiple paths and intermediate levels' : '搜索、多选与选择中间层'} -

+ -
-
-

- {english ? 'Lazy loading and explicit cache refresh' : '懒加载与刷新缓存'} -

+ + + {english ? 'Lazy loading and explicit cache refresh' : '懒加载与刷新缓存'} -
+ diff --git a/apps/docs/src/examples/cascader/controlled.tsx b/apps/docs/src/examples/cascader/controlled.tsx index f610c01f..a7973346 100644 --- a/apps/docs/src/examples/cascader/controlled.tsx +++ b/apps/docs/src/examples/cascader/controlled.tsx @@ -1,16 +1,15 @@ -import { Cascader, Space } from '@ifhover/leaf-ui'; +import { Cascader, Form, FormField, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; import { regions } from './options'; export function CascaderControlled() { const [value, setValue] = useState(['zhejiang', 'hangzhou', 'xihu']); return ( - -
- 收货地区 +
+ -
- 选中路径:{value.join(' → ') || '未选择'} -
+ + 选中路径:{value.join(' → ') || '未选择'} + ); } diff --git a/apps/docs/src/examples/cascader/single-line.tsx b/apps/docs/src/examples/cascader/single-line.tsx new file mode 100644 index 00000000..1a627fba --- /dev/null +++ b/apps/docs/src/examples/cascader/single-line.tsx @@ -0,0 +1,30 @@ +import { Cascader } from '@ifhover/leaf-ui'; + +const options = [ + { + value: 'product', + label: '产品团队', + children: [ + { value: 'design', label: '产品体验设计' }, + { value: 'web', label: '前端开发' }, + { value: 'mobile', label: '移动端开发' }, + ], + }, +]; + +export function SingleLine() { + return ( + + ); +} diff --git a/apps/docs/src/examples/checkbox/check-all.tsx b/apps/docs/src/examples/checkbox/check-all.tsx index c383d7d8..42a8f11f 100644 --- a/apps/docs/src/examples/checkbox/check-all.tsx +++ b/apps/docs/src/examples/checkbox/check-all.tsx @@ -1,4 +1,4 @@ -import { Checkbox, Space } from '@ifhover/leaf-ui'; +import { Checkbox, Flex, Space, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; const options = ['设计更新', '产品动态', '每周摘要']; @@ -7,7 +7,7 @@ export function CheckboxCheckAll() { const [selected, setSelected] = useState(['设计更新']); const allChecked = selected.length === options.length; return ( - + 0 && !allChecked} @@ -15,7 +15,7 @@ export function CheckboxCheckAll() { > 订阅全部 - + {options.map((option) => ( ))} - - 已选择 {selected.length} 项 + + 已选择 {selected.length} 项 ); } diff --git a/apps/docs/src/examples/checkbox/surfaces.tsx b/apps/docs/src/examples/checkbox/surfaces.tsx index c8fa051e..f0a30640 100644 --- a/apps/docs/src/examples/checkbox/surfaces.tsx +++ b/apps/docs/src/examples/checkbox/surfaces.tsx @@ -4,7 +4,7 @@ import { HardDrive, Mail, MessageSquare } from 'lucide-react'; export function CheckboxCards({ english = false }: { english?: boolean }) { const t = (zh: string, en: string) => (english ? en : zh); return ( - + Leaf Garden 的项目信息。

}, @@ -11,15 +12,15 @@ const items: CollapseItem[] = [ ]; export function CollapseBasic() { return ( - -
- 自由展开 + + + 自由展开 -
-
- 手风琴 · 紧凑尺寸 + + + 手风琴 · 紧凑尺寸 -
+
); } diff --git a/apps/docs/src/examples/color-picker/basic.tsx b/apps/docs/src/examples/color-picker/basic.tsx index c7b0961c..a0f01938 100644 --- a/apps/docs/src/examples/color-picker/basic.tsx +++ b/apps/docs/src/examples/color-picker/basic.tsx @@ -1,13 +1,11 @@ -import { ColorPicker, Space } from '@ifhover/leaf-ui'; +import { ColorPicker, Space, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; export function ColorPickerBasic() { const [color, setColor] = useState('#20834a'); return ( - + -
- 你的品牌,你的颜色。 -
+ 当前颜色:{color}
); } diff --git a/apps/docs/src/examples/color-picker/capabilities.tsx b/apps/docs/src/examples/color-picker/capabilities.tsx index 3c10bd7b..9d42e8ab 100644 --- a/apps/docs/src/examples/color-picker/capabilities.tsx +++ b/apps/docs/src/examples/color-picker/capabilities.tsx @@ -1,21 +1,21 @@ -import { ColorPicker, Space } from '@ifhover/leaf-ui'; +import { ColorPicker, Flex, Space, Text } from '@ifhover/leaf-ui'; export function Capabilities({ english = false }) { return ( -
-

{english ? 'Solid color' : '纯色'}

+ + {english ? 'Solid color' : '纯色'} -
-
-

+ + + {english ? 'Gradient: select, add or remove stops' : '渐变:选择、添加或移除色标'} -

+ -
+
); } diff --git a/apps/docs/src/examples/color-picker/formats.tsx b/apps/docs/src/examples/color-picker/formats.tsx index d685d5dd..6f7e2d0b 100644 --- a/apps/docs/src/examples/color-picker/formats.tsx +++ b/apps/docs/src/examples/color-picker/formats.tsx @@ -1,19 +1,19 @@ -import { ColorPicker, Space } from '@ifhover/leaf-ui'; +import { ColorPicker, Flex, Text } from '@ifhover/leaf-ui'; export function ColorPickerFormats() { return ( - -
- HEX + + + HEX -
-
- RGB · 半透明 + + + RGB · 半透明 -
-
- HSL · 不透明 + + + HSL · 不透明 -
-
+ + ); } diff --git a/apps/docs/src/examples/color-picker/presets.tsx b/apps/docs/src/examples/color-picker/presets.tsx index d504533c..84134cbe 100644 --- a/apps/docs/src/examples/color-picker/presets.tsx +++ b/apps/docs/src/examples/color-picker/presets.tsx @@ -1,4 +1,5 @@ -import { ColorPicker, type ColorPreset, Space } from '@ifhover/leaf-ui'; +import type { ColorPreset } from '@ifhover/leaf-ui'; +import { ColorPicker, Flex, Text } from '@ifhover/leaf-ui'; const presets: readonly ColorPreset[] = [ { label: '品牌颜色', colors: ['#20834a', '#1677ff', '#7654c6', '#f49b23'] }, @@ -6,15 +7,15 @@ const presets: readonly ColorPreset[] = [ ]; export function ColorPickerPresets() { return ( - -
- 自定义预设 · 可清除 + + + 自定义预设 · 可清除 -
-
- 禁用 + + + 禁用 -
-
+ + ); } diff --git a/apps/docs/src/examples/config-provider/basic.tsx b/apps/docs/src/examples/config-provider/basic.tsx index 092d2ff7..1987dc5b 100644 --- a/apps/docs/src/examples/config-provider/basic.tsx +++ b/apps/docs/src/examples/config-provider/basic.tsx @@ -1,50 +1,23 @@ -import { - Button, - ConfigProvider, - DatePicker, - Select, - Space, - Switch, - useConfirm, - useMessage, -} from '@ifhover/leaf-ui'; -import { useState } from 'react'; +import { Button, ConfigProvider, Form, FormField, Input, Select } from '@ifhover/leaf-ui'; -function Region() { - const { message } = useMessage(); - const { confirm } = useConfirm(); - return ( - - + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/docs/src/examples/config-provider/density.tsx b/apps/docs/src/examples/config-provider/density.tsx index 7eb9042a..dec2e310 100644 --- a/apps/docs/src/examples/config-provider/density.tsx +++ b/apps/docs/src/examples/config-provider/density.tsx @@ -1,40 +1,41 @@ -import { Button, ConfigProvider, Form, FormField, Input, Select, Space } from '@ifhover/leaf-ui'; -import { useDocsLocale } from '../../components/i18n'; +import { + Button, + ConfigProvider, + Form, + FormField, + Input, + Select, + Space, + Text, +} from '@ifhover/leaf-ui'; export function ConfigProviderDensity() { - const { t } = useDocsLocale(); return ( -
+ {(['comfortable', 'compact'] as const).map((density) => ( -

{density === 'compact' ? t('紧凑', 'Compact') : t('舒适', 'Comfortable')}

-
event.preventDefault()}> - - - - - + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/docs/src/examples/config-provider/sizes.tsx b/apps/docs/src/examples/config-provider/sizes.tsx index 1da6ad7b..96f18320 100644 --- a/apps/docs/src/examples/config-provider/sizes.tsx +++ b/apps/docs/src/examples/config-provider/sizes.tsx @@ -1,39 +1,32 @@ -import { Button, ConfigProvider, Input, Slider, Space } from '@ifhover/leaf-ui'; +import { Button, ConfigProvider, Form, FormField, Slider, Space, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; export function ConfigProviderSizes() { - const [height, setHeight] = useState(34); + const [height, setHeight] = useState(36); return ( - -
-

基础高度:{height}px

- -
- - {(['sm', 'md', 'lg'] as const).map((size) => ( -
- {size} - - -
- ))} -
- - + +
+ + + +
+ + + {( + [ + ['sm', '小尺寸'], + ['md', '默认尺寸'], + ['lg', '大尺寸'], + ] as const + ).map(([size, label]) => ( + + {label} + + + ))} +
); diff --git a/apps/docs/src/examples/config-provider/translations.tsx b/apps/docs/src/examples/config-provider/translations.tsx new file mode 100644 index 00000000..39003b36 --- /dev/null +++ b/apps/docs/src/examples/config-provider/translations.tsx @@ -0,0 +1,50 @@ +import { + Button, + ConfigProvider, + DatePicker, + Form, + FormField, + Pagination, + Select, + Space, + useConfirm, +} from '@ifhover/leaf-ui'; +import mars from './zh-CN-x-mars.json'; + +const translations = { 'zh-CN-x-mars': mars }; + +function Controls() { + const { confirm } = useConfirm(); + return ( + +
+ + + + + + + + + +
+ ), + }, + { + key: 'members', + label: 'Members', + children: ( + + ), + }, + { key: 'billing', label: 'Billing', disabled: true, children: null }, + ], + }} + footer={ + + + + } + /> + ); +} diff --git a/apps/docs/src/examples/en/card/tabs.tsx b/apps/docs/src/examples/en/card/tabs.tsx new file mode 100644 index 00000000..36ca33cd --- /dev/null +++ b/apps/docs/src/examples/en/card/tabs.tsx @@ -0,0 +1,35 @@ +import { Button, Card, Descriptions, Space } from '@ifhover/leaf-ui'; + +export function CardTabs() { + return ( + View project} + tabs={{ + 'aria-label': 'Project overview', + items: [ + { + key: 'overview', + label: 'Overview', + children: ( + + Keep design and development working from the same plan. + + + ), + }, + { + key: 'activity', + label: 'Activity', + children: 'Updated the component guidelines and invited two teammates today.', + }, + ], + }} + /> + ); +} diff --git a/apps/docs/src/examples/en/cascader/basic.tsx b/apps/docs/src/examples/en/cascader/basic.tsx index 01372332..5fbdf600 100644 --- a/apps/docs/src/examples/en/cascader/basic.tsx +++ b/apps/docs/src/examples/en/cascader/basic.tsx @@ -1,4 +1,4 @@ -import { Cascader, Space } from '@ifhover/leaf-ui'; +import { Cascader, Form, FormField } from '@ifhover/leaf-ui'; const options = [ { @@ -22,23 +22,20 @@ const options = [ export function CascaderBasic() { return ( - -
- Project category +
+ -
-
- Default category + + -
-
- Disabled category + + -
-
+
+ ); } diff --git a/apps/docs/src/examples/en/cascader/controlled.tsx b/apps/docs/src/examples/en/cascader/controlled.tsx index eb4ca32a..affed449 100644 --- a/apps/docs/src/examples/en/cascader/controlled.tsx +++ b/apps/docs/src/examples/en/cascader/controlled.tsx @@ -1,21 +1,20 @@ -import { Cascader, Space } from '@ifhover/leaf-ui'; +import { Cascader, Form, FormField, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; import { regions } from './options'; export function CascaderControlled() { const [value, setValue] = useState(['zhejiang', 'hangzhou', 'xihu']); return ( - -
- Delivery region +
+ -
- Selected path:{value.join(' → ') || 'Not selected'} -
+ + Selected path:{value.join(' → ') || 'Not selected'} + ); } diff --git a/apps/docs/src/examples/en/cascader/single-line.tsx b/apps/docs/src/examples/en/cascader/single-line.tsx new file mode 100644 index 00000000..26c1236a --- /dev/null +++ b/apps/docs/src/examples/en/cascader/single-line.tsx @@ -0,0 +1,30 @@ +import { Cascader } from '@ifhover/leaf-ui'; + +const options = [ + { + value: 'product', + label: 'Product', + children: [ + { value: 'design', label: 'Experience design' }, + { value: 'web', label: 'Web development' }, + { value: 'mobile', label: 'Mobile development' }, + ], + }, +]; + +export function SingleLine() { + return ( + + ); +} diff --git a/apps/docs/src/examples/en/checkbox/check-all.tsx b/apps/docs/src/examples/en/checkbox/check-all.tsx index 220eaf94..a223e3b0 100644 --- a/apps/docs/src/examples/en/checkbox/check-all.tsx +++ b/apps/docs/src/examples/en/checkbox/check-all.tsx @@ -1,4 +1,4 @@ -import { Checkbox, Space } from '@ifhover/leaf-ui'; +import { Checkbox, Flex, Space, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; const options = ['Design updates', 'Product news', 'Weekly updates']; @@ -7,7 +7,7 @@ export function CheckboxCheckAll() { const [selected, setSelected] = useState(['Design updates']); const allChecked = selected.length === options.length; return ( - + 0 && !allChecked} @@ -15,7 +15,7 @@ export function CheckboxCheckAll() { > Subscribe to all - + {options.map((option) => ( ))} - - Selected {selected.length} items + + Selected {selected.length} items ); } diff --git a/apps/docs/src/examples/en/collapse/basic.tsx b/apps/docs/src/examples/en/collapse/basic.tsx index f7694599..6bbfeec3 100644 --- a/apps/docs/src/examples/en/collapse/basic.tsx +++ b/apps/docs/src/examples/en/collapse/basic.tsx @@ -1,4 +1,5 @@ -import { Collapse, type CollapseItem, Input, Space } from '@ifhover/leaf-ui'; +import type { CollapseItem } from '@ifhover/leaf-ui'; +import { Collapse, Flex, Input, Space, Text } from '@ifhover/leaf-ui'; const items: CollapseItem[] = [ { key: 'about', label: 'About the project', children:

About Leaf Garden.

}, @@ -6,25 +7,25 @@ const items: CollapseItem[] = [ key: 'settings', label: 'Settings', children: ( -
- Project name + + Project name -
+ ), }, { key: 'archived', label: 'Archived', children: null, disabled: true }, ]; export function CollapseBasic() { return ( - -
- Expand freely + + + Expand freely -
-
- Accordion · Compact + + + Accordion · Compact -
+
); } diff --git a/apps/docs/src/examples/en/color-picker/basic.tsx b/apps/docs/src/examples/en/color-picker/basic.tsx index 62440eb1..d3e35a08 100644 --- a/apps/docs/src/examples/en/color-picker/basic.tsx +++ b/apps/docs/src/examples/en/color-picker/basic.tsx @@ -1,13 +1,11 @@ -import { ColorPicker, Space } from '@ifhover/leaf-ui'; +import { ColorPicker, Space, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; export function ColorPickerBasic() { const [color, setColor] = useState('#20834a'); return ( - + -
- Your brand, your color. -
+ Current color:{color}
); } diff --git a/apps/docs/src/examples/en/color-picker/formats.tsx b/apps/docs/src/examples/en/color-picker/formats.tsx index e4fe91fa..c635460c 100644 --- a/apps/docs/src/examples/en/color-picker/formats.tsx +++ b/apps/docs/src/examples/en/color-picker/formats.tsx @@ -1,19 +1,19 @@ -import { ColorPicker, Space } from '@ifhover/leaf-ui'; +import { ColorPicker, Flex, Text } from '@ifhover/leaf-ui'; export function ColorPickerFormats() { return ( - -
- HEX + + + HEX -
-
- RGB · Transparent + + + RGB · Transparent -
-
- HSL · Opaque + + + HSL · Opaque -
-
+ + ); } diff --git a/apps/docs/src/examples/en/color-picker/presets.tsx b/apps/docs/src/examples/en/color-picker/presets.tsx index f4eaa550..4f78cdf5 100644 --- a/apps/docs/src/examples/en/color-picker/presets.tsx +++ b/apps/docs/src/examples/en/color-picker/presets.tsx @@ -1,4 +1,5 @@ -import { ColorPicker, type ColorPreset, Space } from '@ifhover/leaf-ui'; +import type { ColorPreset } from '@ifhover/leaf-ui'; +import { ColorPicker, Flex, Text } from '@ifhover/leaf-ui'; const presets: readonly ColorPreset[] = [ { label: 'Brand colors', colors: ['#20834a', '#1677ff', '#7654c6', '#f49b23'] }, @@ -6,15 +7,15 @@ const presets: readonly ColorPreset[] = [ ]; export function ColorPickerPresets() { return ( - -
- Custom presets · Clearable + + + Custom presets · Clearable -
-
- Disabled + + + Disabled -
-
+ + ); } diff --git a/apps/docs/src/examples/en/config-provider/basic.tsx b/apps/docs/src/examples/en/config-provider/basic.tsx index edc0dab8..f7aae0c3 100644 --- a/apps/docs/src/examples/en/config-provider/basic.tsx +++ b/apps/docs/src/examples/en/config-provider/basic.tsx @@ -1,50 +1,23 @@ -import { - Button, - ConfigProvider, - DatePicker, - Select, - Space, - Switch, - useConfirm, - useMessage, -} from '@ifhover/leaf-ui'; -import { useState } from 'react'; +import { Button, ConfigProvider, Form, FormField, Input, Select } from '@ifhover/leaf-ui'; -function Region() { - const { message } = useMessage(); - const { confirm } = useConfirm(); - return ( - - + + + + + + + + + + + + + + + +
+ ); +} diff --git a/apps/docs/src/examples/en/config-provider/density.tsx b/apps/docs/src/examples/en/config-provider/density.tsx new file mode 100644 index 00000000..62f9fcd0 --- /dev/null +++ b/apps/docs/src/examples/en/config-provider/density.tsx @@ -0,0 +1,41 @@ +import { + Button, + ConfigProvider, + Form, + FormField, + Input, + Select, + Space, + Text, +} from '@ifhover/leaf-ui'; + +export function ConfigProviderDensity() { + return ( + + {(['comfortable', 'compact'] as const).map((density) => ( + + + {density === 'compact' ? 'Compact' : 'Comfortable'} +
+ + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/docs/src/examples/en/config-provider/sizes.tsx b/apps/docs/src/examples/en/config-provider/sizes.tsx index b08aefaf..21374e80 100644 --- a/apps/docs/src/examples/en/config-provider/sizes.tsx +++ b/apps/docs/src/examples/en/config-provider/sizes.tsx @@ -1,39 +1,32 @@ -import { Button, ConfigProvider, Input, Slider, Space } from '@ifhover/leaf-ui'; +import { Button, ConfigProvider, Form, FormField, Slider, Space, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; export function ConfigProviderSizes() { - const [height, setHeight] = useState(34); + const [height, setHeight] = useState(36); return ( - -
-

Base height: {height}px

- -
- - {(['sm', 'md', 'lg'] as const).map((size) => ( -
- {size} - - -
- ))} -
- - + +
+ + + +
+ + + {( + [ + ['sm', 'Small'], + ['md', 'Default'], + ['lg', 'Large'], + ] as const + ).map(([size, label]) => ( + + {label} + + + ))} +
); diff --git a/apps/docs/src/examples/en/config-provider/translations.tsx b/apps/docs/src/examples/en/config-provider/translations.tsx new file mode 100644 index 00000000..4e41862b --- /dev/null +++ b/apps/docs/src/examples/en/config-provider/translations.tsx @@ -0,0 +1,51 @@ +import { + Button, + ConfigProvider, + DatePicker, + Form, + FormField, + Pagination, + Select, + Space, + useConfirm, +} from '@ifhover/leaf-ui'; +import mars from '../../config-provider/zh-CN-x-mars.json'; + +const translations = { 'zh-CN-x-mars': mars }; + +function Controls() { + const { confirm } = useConfirm(); + return ( + +
+ + + + + ops.update(field.index, e.target.value)} - style={{ flex: 1, minWidth: 0 }} /> )} - + ))} - + diff --git a/apps/docs/src/examples/en/grid/advanced.tsx b/apps/docs/src/examples/en/grid/advanced.tsx index 706f4d8b..8603c66c 100644 --- a/apps/docs/src/examples/en/grid/advanced.tsx +++ b/apps/docs/src/examples/en/grid/advanced.tsx @@ -1,54 +1,21 @@ -import { Col, Row, Space } from '@ifhover/leaf-ui'; - +import { Card, Col, Row, Space } from '@ifhover/leaf-ui'; export function GridColumns() { return ( - - + + -
- 8 / 24 -
+ 8 / 24 -
- 16 / 24 -
+ 16 / 24
-
- Responsive column -
+ Responsive width -
- Visible on wider screens -
+ Hidden on narrow screens
diff --git a/apps/docs/src/examples/en/grid/basic.tsx b/apps/docs/src/examples/en/grid/basic.tsx index d8c8d152..6bd0f562 100644 --- a/apps/docs/src/examples/en/grid/basic.tsx +++ b/apps/docs/src/examples/en/grid/basic.tsx @@ -1,19 +1,9 @@ -import { Grid } from '@ifhover/leaf-ui'; - +import { Card, Grid } from '@ifhover/leaf-ui'; export function GridBasic() { return ( - - {['Design', 'Develop', 'Publish'].map((label) => ( -
- {label} -
+ + {['Design', 'Develop', 'Release'].map((label) => ( + {label} ))} ); diff --git a/apps/docs/src/examples/en/guide/control-sizes.tsx b/apps/docs/src/examples/en/guide/control-sizes.tsx index eaa0b9e5..da906cf3 100644 --- a/apps/docs/src/examples/en/guide/control-sizes.tsx +++ b/apps/docs/src/examples/en/guide/control-sizes.tsx @@ -1,24 +1,24 @@ -import { Button, type ControlSize, Input, Select, Space } from '@ifhover/leaf-ui'; - +import type { ControlSize } from '@ifhover/leaf-ui'; +import { Button, Flex, Input, Select, Text } from '@ifhover/leaf-ui'; export function ControlSizes() { return ( - + {(['sm', 'md', 'lg'] as const).map((size: ControlSize) => ( -
- {size} - + + {size} + - - + + + -
-
- Search projects + + } placeholder="Search projects" /> -
-
- Email + + } placeholder="hello@leaf.design" /> -
-
- Read-only project name + + -
-
+
+
); } diff --git a/apps/docs/src/examples/en/input/clear.tsx b/apps/docs/src/examples/en/input/clear.tsx index 09c1398a..da75557d 100644 --- a/apps/docs/src/examples/en/input/clear.tsx +++ b/apps/docs/src/examples/en/input/clear.tsx @@ -1,11 +1,10 @@ -import { Input, Space } from '@ifhover/leaf-ui'; +import { Form, FormField, Input, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; export function InputClear() { const [value, setValue] = useState('Leaf Garden'); return ( - -
- Project name +
+ -
-
- Keyword + + `${text.length} characters`} /> -
-

Current text: {value || 'Empty'}

-
+
+ Current text: {value || 'Empty'} + ); } diff --git a/apps/docs/src/examples/en/input/controlled.tsx b/apps/docs/src/examples/en/input/controlled.tsx index 9ab22b92..844c92d4 100644 --- a/apps/docs/src/examples/en/input/controlled.tsx +++ b/apps/docs/src/examples/en/input/controlled.tsx @@ -1,13 +1,12 @@ -import { Input, Space } from '@ifhover/leaf-ui'; +import { Form, FormField, Input, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; export function InputControlled() { const [value, setValue] = useState(''); const [submitted, setSubmitted] = useState(''); return ( - -
- Enter and submit +
+ setValue(event.target.value)} onPressEnter={() => setSubmitted(value)} /> -
- + + Submitted:{submitted || 'Waiting for input'} - -
+ + ); } diff --git a/apps/docs/src/examples/en/input/extensions.tsx b/apps/docs/src/examples/en/input/extensions.tsx index a26c74c8..6b1ffc7f 100644 --- a/apps/docs/src/examples/en/input/extensions.tsx +++ b/apps/docs/src/examples/en/input/extensions.tsx @@ -1,4 +1,4 @@ -import { Input, InputGroup, InputSearch, Select, Space } from '@ifhover/leaf-ui'; +import { Flex, Input, InputGroup, InputSearch, Select, Space, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; export function ExtensionDemo() { const [query, setQuery] = useState(''); @@ -14,8 +14,8 @@ export function ExtensionDemo() { />

Search: {query || 'No search yet'}

-
-

Input group

+ + Input group -
+
); } diff --git a/apps/docs/src/examples/en/input/password.tsx b/apps/docs/src/examples/en/input/password.tsx index 275d9492..ee10eb63 100644 --- a/apps/docs/src/examples/en/input/password.tsx +++ b/apps/docs/src/examples/en/input/password.tsx @@ -1,12 +1,11 @@ -import { Input, Space } from '@ifhover/leaf-ui'; +import { Form, FormField, Input } from '@ifhover/leaf-ui'; import { Eye, EyeOff, LockKeyhole } from 'lucide-react'; import { useState } from 'react'; export function InputPassword() { const [visible, setVisible] = useState(false); return ( - -
- Password +
+ } /> -
-
- Controlled password + + (shown ? : )} /> -
-
- Disabled password + + -
-
- No visibility toggle + + -
-
+ + ); } diff --git a/apps/docs/src/examples/en/input/states.tsx b/apps/docs/src/examples/en/input/states.tsx index 0238d137..73651464 100644 --- a/apps/docs/src/examples/en/input/states.tsx +++ b/apps/docs/src/examples/en/input/states.tsx @@ -1,28 +1,16 @@ -import { Input, Space } from '@ifhover/leaf-ui'; - +import { Form, FormField, Input } from '@ifhover/leaf-ui'; export function InputStates() { return ( - -
- Disabled input - -
-
- Project name error · Error - -
- - Project name is required. - -
- Name warning - -
-
+
event.preventDefault()}> + + + + + + + + + +
); } diff --git a/apps/docs/src/examples/en/layout/advanced.tsx b/apps/docs/src/examples/en/layout/advanced.tsx index 51956279..3908fa58 100644 --- a/apps/docs/src/examples/en/layout/advanced.tsx +++ b/apps/docs/src/examples/en/layout/advanced.tsx @@ -1,111 +1,38 @@ -import { Layout, Menu } from '@ifhover/leaf-ui'; -import { - Folder, - LayoutDashboard, - Leaf, - PanelLeftClose, - PanelLeftOpen, - Settings, -} from 'lucide-react'; -import { useEffect, useState } from 'react'; - +import { Card, Layout, Menu, useBreakpoint } from '@ifhover/leaf-ui'; +import { Folder, LayoutDashboard } from 'lucide-react'; +import { useState } from 'react'; export function LayoutResponsive() { const [collapsed, setCollapsed] = useState(false); - const [narrow, setNarrow] = useState(false); + const { sm } = useBreakpoint(); + const compact = collapsed || !sm; const [selected, setSelected] = useState('overview'); - useEffect(() => { - const query = matchMedia('(max-width: 640px)'); - const update = () => setNarrow(query.matches); - update(); - query.addEventListener('change', update); - return () => query.removeEventListener('change', update); - }, []); - const compact = collapsed || narrow; const items = [ { key: 'overview', label: 'Overview', icon: }, { key: 'projects', label: 'Projects', icon: }, - { key: 'settings', label: 'Settings', icon: }, ]; return ( - + : } - style={{ borderInlineEnd: '1px solid var(--leaf-color-border)' }} + breakpoint={575} > -
-
- - - Workspace - - -
- Workspace / {items.find((item) => item.key === selected)?.label} -
-
-

- {items.find((item) => item.key === selected)?.label} -

-

- Manage projects and track progress in one workspace. -

-
-
- - Leaf Studio · Workspace - -
+ + item.key === selected)?.label}> + Select a navigation item to view its content. + + ); } diff --git a/apps/docs/src/examples/en/layout/basic.tsx b/apps/docs/src/examples/en/layout/basic.tsx index 75c6bb2e..3c03f9a1 100644 --- a/apps/docs/src/examples/en/layout/basic.tsx +++ b/apps/docs/src/examples/en/layout/basic.tsx @@ -1,48 +1,15 @@ -import { Layout } from '@ifhover/leaf-ui'; - +import { Layout, Text } from '@ifhover/leaf-ui'; export function LayoutBasic() { return ( - - - Header + + + Header - - Sider - - - Content - + Sidebar + Content - - Footer - + Footer ); } diff --git a/apps/docs/src/examples/en/loading/overlay.tsx b/apps/docs/src/examples/en/loading/overlay.tsx index e3e4fc54..ffd85944 100644 --- a/apps/docs/src/examples/en/loading/overlay.tsx +++ b/apps/docs/src/examples/en/loading/overlay.tsx @@ -1,18 +1,18 @@ -import { Button, Loading, Space } from '@ifhover/leaf-ui'; +import { Button, Card, Loading, Space } from '@ifhover/leaf-ui'; import { useState } from 'react'; export function LoadingOverlay() { const [spinning, setSpinning] = useState(true); return ( - + -
+ Leaf Garden

See project details and recent updates here.

-
+
); diff --git a/apps/docs/src/examples/en/loading/sizes.tsx b/apps/docs/src/examples/en/loading/sizes.tsx index 508b5b06..a23c8622 100644 --- a/apps/docs/src/examples/en/loading/sizes.tsx +++ b/apps/docs/src/examples/en/loading/sizes.tsx @@ -1,19 +1,19 @@ -import { Loading, Space } from '@ifhover/leaf-ui'; +import { Flex, Loading, Text } from '@ifhover/leaf-ui'; export function LoadingSizes() { return ( - -
- Small + + + Small -
-
- Medium + + + Medium -
-
- Large + + + Large -
-
+ + ); } diff --git a/apps/docs/src/examples/en/menu/advanced.tsx b/apps/docs/src/examples/en/menu/advanced.tsx index 70c17e60..8600614e 100644 --- a/apps/docs/src/examples/en/menu/advanced.tsx +++ b/apps/docs/src/examples/en/menu/advanced.tsx @@ -1,4 +1,5 @@ -import { Button, Menu, type MenuItem, Space } from '@ifhover/leaf-ui'; +import type { MenuItem } from '@ifhover/leaf-ui'; +import { Button, Flex, Menu, Text } from '@ifhover/leaf-ui'; import { BookOpen, Folder, House, Palette, Settings } from 'lucide-react'; import { useState } from 'react'; @@ -18,18 +19,18 @@ const items: MenuItem[] = [ export function MenuHorizontal() { const [collapsed, setCollapsed] = useState(false); return ( - -
-

Horizontal navigation

+ + + Horizontal navigation -
-
-

Collapsible sidebar

+ + + Collapsible sidebar -
-
+ + ); } diff --git a/apps/docs/src/examples/en/message/basic.tsx b/apps/docs/src/examples/en/message/basic.tsx index 0cfab8b4..a167302d 100644 --- a/apps/docs/src/examples/en/message/basic.tsx +++ b/apps/docs/src/examples/en/message/basic.tsx @@ -1,4 +1,4 @@ -import { Button, Space, useMessage } from '@ifhover/leaf-ui'; +import { Button, Flex, useMessage } from '@ifhover/leaf-ui'; export function MessageBasic() { const { message } = useMessage(); async function save() { @@ -13,7 +13,7 @@ export function MessageBasic() { }); } return ( - + - + ); } diff --git a/apps/docs/src/examples/en/message/shared.tsx b/apps/docs/src/examples/en/message/shared.tsx index 31e339a3..9710e54e 100644 --- a/apps/docs/src/examples/en/message/shared.tsx +++ b/apps/docs/src/examples/en/message/shared.tsx @@ -1,4 +1,4 @@ -import { Button, Space, useMessage } from '@ifhover/leaf-ui'; +import { Button, Flex, useMessage } from '@ifhover/leaf-ui'; function PublishAction() { const { message } = useMessage(); @@ -35,9 +35,9 @@ function PublishResult() { export function MessageShared() { return ( - + - + ); } diff --git a/apps/docs/src/examples/en/modal/description.tsx b/apps/docs/src/examples/en/modal/description.tsx new file mode 100644 index 00000000..aced816a --- /dev/null +++ b/apps/docs/src/examples/en/modal/description.tsx @@ -0,0 +1,25 @@ +import { Button, Modal } from '@ifhover/leaf-ui'; +import { Save } from 'lucide-react'; +import { useState } from 'react'; + +export function ModalDescription() { + const [open, setOpen] = useState(false); + return ( + <> + + } + confirmText="Save" + onClose={() => setOpen(false)} + onConfirm={() => setOpen(false)} + > + Save the current settings? + + + ); +} diff --git a/apps/docs/src/examples/en/pagination/basic.tsx b/apps/docs/src/examples/en/pagination/basic.tsx index aec99505..2ace1a38 100644 --- a/apps/docs/src/examples/en/pagination/basic.tsx +++ b/apps/docs/src/examples/en/pagination/basic.tsx @@ -1,27 +1,16 @@ -import { Pagination, Space } from '@ifhover/leaf-ui'; -import { useState } from 'react'; +import { Pagination, Space, Text } from '@ifhover/leaf-ui'; + export function PaginationBasic() { - const [current, setCurrent] = useState(1); - const [pageSize, setPageSize] = useState(10); return ( - - `${start}–${end} / ${total} items`} - onChange={(page, size) => { - setCurrent(page); - setPageSize(size); - }} - /> - - -

- Current page: {current}; page size: {pageSize} items. -

+ + + Outline (default) + + + + Ghost + + ); } diff --git a/apps/docs/src/examples/en/pagination/controlled.tsx b/apps/docs/src/examples/en/pagination/controlled.tsx new file mode 100644 index 00000000..210d7acf --- /dev/null +++ b/apps/docs/src/examples/en/pagination/controlled.tsx @@ -0,0 +1,17 @@ +import { Pagination } from '@ifhover/leaf-ui'; +import { useState } from 'react'; + +export function PaginationControlled() { + const [pagination, setPagination] = useState({ current: 1, pageSize: 10 }); + + return ( + setPagination({ current, pageSize })} + /> + ); +} diff --git a/apps/docs/src/examples/en/pagination/sizes.tsx b/apps/docs/src/examples/en/pagination/sizes.tsx new file mode 100644 index 00000000..e53e259c --- /dev/null +++ b/apps/docs/src/examples/en/pagination/sizes.tsx @@ -0,0 +1,20 @@ +import { Pagination, Space, Text } from '@ifhover/leaf-ui'; + +export function PaginationSizes() { + return ( + + + Small + + + + Default + + + + Large + + + + ); +} diff --git a/apps/docs/src/examples/en/pagination/states.tsx b/apps/docs/src/examples/en/pagination/states.tsx new file mode 100644 index 00000000..8a388aaa --- /dev/null +++ b/apps/docs/src/examples/en/pagination/states.tsx @@ -0,0 +1,16 @@ +import { Pagination, Space, Text } from '@ifhover/leaf-ui'; + +export function PaginationStates() { + return ( + + + Simple + + + + Disabled + + + + ); +} diff --git a/apps/docs/src/examples/en/popover/basic.tsx b/apps/docs/src/examples/en/popover/basic.tsx index e29de046..3025b046 100644 --- a/apps/docs/src/examples/en/popover/basic.tsx +++ b/apps/docs/src/examples/en/popover/basic.tsx @@ -1,7 +1,7 @@ -import { Button, Popover, Space } from '@ifhover/leaf-ui'; +import { Button, Flex, Popover } from '@ifhover/leaf-ui'; export function PopoverBasic() { return ( - + @@ -13,6 +13,6 @@ export function PopoverBasic() { > - + ); } diff --git a/apps/docs/src/examples/en/popover/controlled.tsx b/apps/docs/src/examples/en/popover/controlled.tsx index 635e9c3e..023425fe 100644 --- a/apps/docs/src/examples/en/popover/controlled.tsx +++ b/apps/docs/src/examples/en/popover/controlled.tsx @@ -1,7 +1,6 @@ -import { Button, Input, Popover, Space } from '@ifhover/leaf-ui'; -import { useId, useState } from 'react'; +import { Button, Form, FormField, Input, Popover } from '@ifhover/leaf-ui'; +import { useState } from 'react'; export function PopoverControlled() { - const id = useId(); const [open, setOpen] = useState(false); return ( - - -
+
{ + event.preventDefault(); + setOpen(false); + }} + > + + + + +
} > diff --git a/apps/docs/src/examples/en/popover/placements.tsx b/apps/docs/src/examples/en/popover/placements.tsx index 80e72863..753c4edc 100644 --- a/apps/docs/src/examples/en/popover/placements.tsx +++ b/apps/docs/src/examples/en/popover/placements.tsx @@ -1,7 +1,7 @@ -import { Button, Popover, Space } from '@ifhover/leaf-ui'; +import { Button, Flex, Popover } from '@ifhover/leaf-ui'; export function PopoverPlacements() { return ( - + {(['top', 'bottom', 'left', 'right'] as const).map((placement) => ( ))} - + ); } diff --git a/apps/docs/src/examples/en/progress/basic.tsx b/apps/docs/src/examples/en/progress/basic.tsx index 8fc7c3b2..1194043f 100644 --- a/apps/docs/src/examples/en/progress/basic.tsx +++ b/apps/docs/src/examples/en/progress/basic.tsx @@ -1,11 +1,11 @@ -import { Button, Progress, Space } from '@ifhover/leaf-ui'; +import { Button, Flex, Progress, Space } from '@ifhover/leaf-ui'; import { useState } from 'react'; export function ProgressBasic() { const [percent, setPercent] = useState(45); return ( - + - + - + ); } diff --git a/apps/docs/src/examples/en/progress/circles.tsx b/apps/docs/src/examples/en/progress/circles.tsx index 3fe12ff5..151e482d 100644 --- a/apps/docs/src/examples/en/progress/circles.tsx +++ b/apps/docs/src/examples/en/progress/circles.tsx @@ -1,19 +1,19 @@ -import { Progress, Space } from '@ifhover/leaf-ui'; +import { Flex, Progress, Text } from '@ifhover/leaf-ui'; export function ProgressCircles() { return ( - -
- In progress + + + In progress -
-
- Complete + + + Complete -
-
- Failed + + + Failed -
-
+ + ); } diff --git a/apps/docs/src/examples/en/progress/states.tsx b/apps/docs/src/examples/en/progress/states.tsx index c2d3fab9..751a5d5d 100644 --- a/apps/docs/src/examples/en/progress/states.tsx +++ b/apps/docs/src/examples/en/progress/states.tsx @@ -1,23 +1,23 @@ -import { Progress, Space } from '@ifhover/leaf-ui'; +import { Flex, Progress, Space, Text } from '@ifhover/leaf-ui'; export function ProgressStates() { return ( - -
- Static progress + + + Static progress -
-
- Task failed + + + Task failed -
-
- Task complete + + + Task complete -
-
- Unknown duration + + + Unknown duration -
+
); } diff --git a/apps/docs/src/examples/en/qr-code/advanced.tsx b/apps/docs/src/examples/en/qr-code/advanced.tsx index 450271a1..75b45838 100644 --- a/apps/docs/src/examples/en/qr-code/advanced.tsx +++ b/apps/docs/src/examples/en/qr-code/advanced.tsx @@ -1,15 +1,15 @@ -import { QRCode, Space } from '@ifhover/leaf-ui'; +import { Flex, QRCode, Space, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; export function QRCodeStatus() { const [expired, setExpired] = useState(true); return ( - - - Loading + + + Loading - - Refresh after expiry + + Refresh after expiry setExpired(false)} /> - - Custom color + + Custom color - + ); } diff --git a/apps/docs/src/examples/en/radio/controlled.tsx b/apps/docs/src/examples/en/radio/controlled.tsx index 3f8f50ba..336d25db 100644 --- a/apps/docs/src/examples/en/radio/controlled.tsx +++ b/apps/docs/src/examples/en/radio/controlled.tsx @@ -1,10 +1,10 @@ -import { RadioGroup, Space } from '@ifhover/leaf-ui'; +import { RadioGroup, Space, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; export function RadioControlled() { const [value, setValue] = useState('daily'); return ( - + - Selected:{value} + Selected:{value} ); } diff --git a/apps/docs/src/examples/en/rate/basic.tsx b/apps/docs/src/examples/en/rate/basic.tsx index 606ab5fd..73e9daac 100644 --- a/apps/docs/src/examples/en/rate/basic.tsx +++ b/apps/docs/src/examples/en/rate/basic.tsx @@ -1,11 +1,13 @@ -import { Rate, Space } from '@ifhover/leaf-ui'; +import { Rate, Space, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; export function RateBasic() { const [value, setValue] = useState(3.5); return ( - + - Current rating: {value} + + Current rating: {value} + ); } diff --git a/apps/docs/src/examples/en/rate/colors.tsx b/apps/docs/src/examples/en/rate/colors.tsx index 12069c91..7a6b59e9 100644 --- a/apps/docs/src/examples/en/rate/colors.tsx +++ b/apps/docs/src/examples/en/rate/colors.tsx @@ -1,17 +1,19 @@ -import { Rate, Space } from '@ifhover/leaf-ui'; +import { Flex, Rate, Space, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; export function RateColors() { const [value, setValue] = useState(2); return ( - + - - Coral for low scores, amber for medium and yellow for high. Current rating: {value} + + + Coral for low scores, amber for medium and yellow for high. Current rating: {value} + -
- Brand color + + Brand color -
+
); } diff --git a/apps/docs/src/examples/en/rate/states.tsx b/apps/docs/src/examples/en/rate/states.tsx index 70080469..d1e0af3a 100644 --- a/apps/docs/src/examples/en/rate/states.tsx +++ b/apps/docs/src/examples/en/rate/states.tsx @@ -1,15 +1,15 @@ -import { Rate, Space } from '@ifhover/leaf-ui'; +import { Flex, Rate, Space, Text } from '@ifhover/leaf-ui'; export function RateStates() { return ( - -
- Read-only rating + + + Read-only rating -
-
- Disabled rating + + + Disabled rating -
+
); } diff --git a/apps/docs/src/examples/en/result/states.tsx b/apps/docs/src/examples/en/result/states.tsx index 2a20235f..f71e86de 100644 --- a/apps/docs/src/examples/en/result/states.tsx +++ b/apps/docs/src/examples/en/result/states.tsx @@ -1,7 +1,7 @@ import { Button, Result, Space } from '@ifhover/leaf-ui'; export function ResultStates() { return ( - + + {['A', 'B', 'C', 'D', 'E'].map((name) => ( diff --git a/apps/docs/src/examples/en/segmented/advanced.tsx b/apps/docs/src/examples/en/segmented/advanced.tsx index 13669412..6a04166b 100644 --- a/apps/docs/src/examples/en/segmented/advanced.tsx +++ b/apps/docs/src/examples/en/segmented/advanced.tsx @@ -1,9 +1,9 @@ -import { Segmented, Space } from '@ifhover/leaf-ui'; +import { Segmented, Space, Text } from '@ifhover/leaf-ui'; export function SegmentedOptions() { return ( - - Fill container + + Fill container - Small size + Small size ); diff --git a/apps/docs/src/examples/en/segmented/basic.tsx b/apps/docs/src/examples/en/segmented/basic.tsx index 5bad4f3d..afa5e742 100644 --- a/apps/docs/src/examples/en/segmented/basic.tsx +++ b/apps/docs/src/examples/en/segmented/basic.tsx @@ -1,11 +1,13 @@ -import { Segmented, Space } from '@ifhover/leaf-ui'; +import { Segmented, Space, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; export function SegmentedBasic() { const [value, setValue] = useState('Weekly'); return ( - + - Current view: {value} + + Current view: {value} + ); } diff --git a/apps/docs/src/examples/en/select/basic.tsx b/apps/docs/src/examples/en/select/basic.tsx index 27ec769d..41454708 100644 --- a/apps/docs/src/examples/en/select/basic.tsx +++ b/apps/docs/src/examples/en/select/basic.tsx @@ -1,4 +1,4 @@ -import { Select, Space } from '@ifhover/leaf-ui'; +import { Form, FormField, Select } from '@ifhover/leaf-ui'; const options = [ { label: 'Design studio', value: 'design' }, @@ -9,19 +9,16 @@ const options = [ export function SelectBasic() { return ( - -
- Choose a team +
+ -
-
- Disabled team + + -
- Current:{value || 'Not selected'} -
- Team required · Error + + Current:{value || 'Not selected'} + -
-
- Fixed popup width + + -
-
- Loading options + + -
-
+ + ); } diff --git a/apps/docs/src/examples/en/select/search.tsx b/apps/docs/src/examples/en/select/search.tsx index 79f70b0a..0ef47e72 100644 --- a/apps/docs/src/examples/en/select/search.tsx +++ b/apps/docs/src/examples/en/select/search.tsx @@ -1,4 +1,5 @@ -import { Select, type SelectOption, Space } from '@ifhover/leaf-ui'; +import type { SelectOption } from '@ifhover/leaf-ui'; +import { Form, FormField, Select, Text } from '@ifhover/leaf-ui'; import { useState } from 'react'; const options: SelectOption[] = [ @@ -9,9 +10,8 @@ const options: SelectOption[] = [ export function SelectSearch() { const [teams, setTeams] = useState(['design']); return ( - -
- Search teams +
+ -
- {teams.join(', ') || '—'} -
+ + + {teams.join(', ') || '—'} + + ); } diff --git a/apps/docs/src/examples/en/select/single-line.tsx b/apps/docs/src/examples/en/select/single-line.tsx new file mode 100644 index 00000000..5b3e815f --- /dev/null +++ b/apps/docs/src/examples/en/select/single-line.tsx @@ -0,0 +1,29 @@ +import { Form, FormField, Select } from '@ifhover/leaf-ui'; + +const options = [ + { value: '0', label: 'A longer design team name' }, + { value: '1', label: 'Frontend' }, + { value: '2', label: 'Backend' }, + { value: '3', label: 'Operations' }, +]; +const selected = options.map((option) => option.value); + +export function SingleLine() { + return ( +
+ + + +
+ ); +} diff --git a/apps/docs/src/examples/en/select/theme.tsx b/apps/docs/src/examples/en/select/theme.tsx deleted file mode 100644 index cc32c7d7..00000000 --- a/apps/docs/src/examples/en/select/theme.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { ConfigProvider, type LeafTheme, Select } from '@ifhover/leaf-ui'; - -const theme: LeafTheme = { - primaryColor: '#7654c6', - borderRadius: 6, -}; - -export function SelectTheme() { - return ( - -
- Themed team - -
- ), + children: , }, { key: 'history', label: 'History', disabled: true, children: null }, ]; export function TabsBasic() { return ( - -
- Line tabs + + + Line tabs -
-
- Card tabs - -
-
- Vertical tabs - -
+
+ + Card tabs + + + + Vertical tabs + +
); } diff --git a/apps/docs/src/examples/en/tabs/closable.tsx b/apps/docs/src/examples/en/tabs/closable.tsx index 8ca4b328..ac4ddad2 100644 --- a/apps/docs/src/examples/en/tabs/closable.tsx +++ b/apps/docs/src/examples/en/tabs/closable.tsx @@ -1,4 +1,4 @@ -import { Button, type TabItem, Tabs } from '@ifhover/leaf-ui'; +import { Button, Space, type TabItem, Tabs } from '@ifhover/leaf-ui'; import { useRef, useState } from 'react'; export function TabsClosable() { const sequence = useRef(2); @@ -6,29 +6,30 @@ export function TabsClosable() { { key: '1', label: 'First project', children: 'First project content', closable: true }, { key: '2', label: 'Second project', children: 'Second project content', closable: true }, ]); + const addTab = () => { + const key = String(++sequence.current); + setItems((list) => [ + ...list, + { key, label: `Project ${key}`, children: `Project ${key} content`, closable: true }, + ]); + }; return ( -
- Card tabs + setItems((list) => list.filter((item) => item.key !== key))} - extra={ - - } + onAdd={addTab} + addLabel="Add tab" /> -
+ {items.length === 0 && ( + + + + )} +
); } diff --git a/apps/docs/src/examples/en/tabs/extensions.tsx b/apps/docs/src/examples/en/tabs/extensions.tsx index 967afe2f..ce1e227f 100644 --- a/apps/docs/src/examples/en/tabs/extensions.tsx +++ b/apps/docs/src/examples/en/tabs/extensions.tsx @@ -1,4 +1,4 @@ -import { type TabItem, Tabs } from '@ifhover/leaf-ui'; +import { Button, Space, type TabItem, Tabs } from '@ifhover/leaf-ui'; import { useRef, useState } from 'react'; export function ExtensionDemo() { const count = useRef(7); @@ -10,19 +10,30 @@ export function ExtensionDemo() { closable: true, })), ); + const addTab = () => { + const id = String(count.current++); + setItems((current) => [ + ...current, + { key: id, label: `Item ${id}`, children: 'New item', closable: true }, + ]); + }; return ( - { - const id = String(count.current++); - setItems((current) => [ - ...current, - { key: id, label: `Item ${id}`, children: 'New item', closable: true }, - ]); - }} - onClose={(key) => setItems((current) => current.filter((item) => item.key !== key))} - /> + + setItems((current) => current.filter((item) => item.key !== key))} + /> + {items.length === 0 && ( + + + + )} + ); } diff --git a/apps/docs/src/examples/en/tabs/sizes.tsx b/apps/docs/src/examples/en/tabs/sizes.tsx new file mode 100644 index 00000000..8d0ec284 --- /dev/null +++ b/apps/docs/src/examples/en/tabs/sizes.tsx @@ -0,0 +1,34 @@ +import { Grid, Space, Tabs, Text } from '@ifhover/leaf-ui'; + +const items = [ + { key: 'overview', label: 'Overview', children: 'View recent project updates.' }, + { key: 'members', label: 'Members', children: 'Manage project members and access.' }, +]; +const labels = { sm: 'Small', md: 'Default', lg: 'Large' }; +const variants = [ + { type: 'line', label: 'Line tabs' }, + { type: 'card', label: 'Card tabs' }, +] as const; + +export function TabsSizes() { + return ( + + {variants.map(({ type, label }) => ( + + {label} + {(['sm', 'md', 'lg'] as const).map((size) => ( + + {labels[size]} + + + ))} + + ))} + + ); +} diff --git a/apps/docs/src/examples/en/tag/basic.tsx b/apps/docs/src/examples/en/tag/basic.tsx index 77f47151..6daf23d1 100644 --- a/apps/docs/src/examples/en/tag/basic.tsx +++ b/apps/docs/src/examples/en/tag/basic.tsx @@ -1,20 +1,20 @@ -import { Space, Tag } from '@ifhover/leaf-ui'; +import { Flex, Space, Tag } from '@ifhover/leaf-ui'; import { Check } from 'lucide-react'; export function TagBasic() { return ( - - + + {(['default', 'primary', 'success', 'info', 'warning', 'error'] as const).map((color) => ( {color} ))} - - + + Published - }> + }> Completed @@ -23,7 +23,7 @@ export function TagBasic() { Large tag - + ); } diff --git a/apps/docs/src/examples/en/tag/extensions.tsx b/apps/docs/src/examples/en/tag/extensions.tsx deleted file mode 100644 index 0d85ea44..00000000 --- a/apps/docs/src/examples/en/tag/extensions.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { CheckableTag, Space, TagGroup } from '@ifhover/leaf-ui'; -export function ExtensionDemo() { - return ( - - Favorites only - - - ); -} diff --git a/apps/docs/src/examples/en/tailwindcss/basic.tsx b/apps/docs/src/examples/en/tailwindcss/basic.tsx new file mode 100644 index 00000000..1c0c52fe --- /dev/null +++ b/apps/docs/src/examples/en/tailwindcss/basic.tsx @@ -0,0 +1,14 @@ +import { Flex } from '@ifhover/leaf-ui'; + +export function TailwindBasic() { + return ( + +

Reuse your theme styles

+

Share colors, radii and spacing with components.

+
+ ); +} diff --git a/apps/docs/src/examples/en/tailwindcss/theme.tsx b/apps/docs/src/examples/en/tailwindcss/theme.tsx new file mode 100644 index 00000000..900cc048 --- /dev/null +++ b/apps/docs/src/examples/en/tailwindcss/theme.tsx @@ -0,0 +1,26 @@ +import { Button, ConfigProvider, Flex, Switch } from '@ifhover/leaf-ui'; +import { useState } from 'react'; + +export function TailwindTheme() { + const [dark, setDark] = useState(false); + + return ( + + setDark(event.target.checked)}> + Dark mode + + + +

Utilities and components share a theme

+

Backgrounds, text and borders change together.

+ +
+
+
+ ); +} diff --git a/apps/docs/src/examples/en/textarea/basic.tsx b/apps/docs/src/examples/en/textarea/basic.tsx index 02a1416c..3542fa75 100644 --- a/apps/docs/src/examples/en/textarea/basic.tsx +++ b/apps/docs/src/examples/en/textarea/basic.tsx @@ -1,35 +1,32 @@ -import { Space, Textarea } from '@ifhover/leaf-ui'; +import { Form, FormField, Textarea } from '@ifhover/leaf-ui'; export function TextareaBasic() { return ( - -
- Project description +
+