diff --git a/.changeset/bright-tables-expand.md b/.changeset/bright-tables-expand.md new file mode 100644 index 0000000..dc26a77 --- /dev/null +++ b/.changeset/bright-tables-expand.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": patch +--- + +Enhance DataTable with controlled sorting, pagination, selection, column visibility, and loading, empty, and error states. diff --git a/.changeset/bright-trees-navigate.md b/.changeset/bright-trees-navigate.md new file mode 100644 index 0000000..dacc522 --- /dev/null +++ b/.changeset/bright-trees-navigate.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": patch +--- + +Add TreeView for accessible resource, directory, and permission hierarchies with selection, expansion, and cascaded checkbox states. diff --git a/.changeset/calm-docs-suite-arrive.md b/.changeset/calm-docs-suite-arrive.md new file mode 100644 index 0000000..00f4853 --- /dev/null +++ b/.changeset/calm-docs-suite-arrive.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": minor +--- + +Add documentation and typography component suite: Callout, InlineCode, Steps, AnchorHeading, Toc, PrevNext, NavTree, and CodeGroup with accessibility and locale support. diff --git a/.changeset/calm-icons-arrive.md b/.changeset/calm-icons-arrive.md new file mode 100644 index 0000000..0122ee5 --- /dev/null +++ b/.changeset/calm-icons-arrive.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": minor +--- + +Add universal `Icon` component and multi-library icon resolution registry with `IconSource` integration across TreeView, NavTree, CommandPalette, Tabs, CodeBlock, and menus. diff --git a/.changeset/calm-landing-suite-arrive.md b/.changeset/calm-landing-suite-arrive.md new file mode 100644 index 0000000..349fe09 --- /dev/null +++ b/.changeset/calm-landing-suite-arrive.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": minor +--- + +Add landing page components: Navbar (SiteHeader), Footer, and Hero with layout variants, responsive mobile drawer, and CTA slots. diff --git a/.changeset/calm-locales-expand.md b/.changeset/calm-locales-expand.md new file mode 100644 index 0000000..faba255 --- /dev/null +++ b/.changeset/calm-locales-expand.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": patch +--- + +Expand i18n localization coverage across components (Select, Combobox, MultiSelect, DatePicker, DateRangePicker, CodeBlock, Editable, TagInput, AlertDialog, DataTable) and eliminate hardcoded Chinese fallbacks. diff --git a/.changeset/calm-locales-flow.md b/.changeset/calm-locales-flow.md new file mode 100644 index 0000000..e6dc959 --- /dev/null +++ b/.changeset/calm-locales-flow.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": minor +--- + +Add lightweight i18n locale injection context with default zh-CN/en-US messages, adapt Pagination, EmptyState, DataTable, FileUpload, and TreeView to consume contextual translations, and document usage in Playground. diff --git a/.changeset/calm-sheets-deploy.md b/.changeset/calm-sheets-deploy.md new file mode 100644 index 0000000..ae3180a --- /dev/null +++ b/.changeset/calm-sheets-deploy.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": patch +--- + +Add Sheet, a directional drawer for console details and secondary actions. diff --git a/.changeset/calm-trees-highlight.md b/.changeset/calm-trees-highlight.md new file mode 100644 index 0000000..6b1b78b --- /dev/null +++ b/.changeset/calm-trees-highlight.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": minor +--- + +Add highlight-variant customization, active selection badges, asynchronous child node loading, multi-selection mode, drag-and-drop reordering, and icon/label slots to TreeView. diff --git a/.changeset/fresh-timelines-flow.md b/.changeset/fresh-timelines-flow.md new file mode 100644 index 0000000..4bae723 --- /dev/null +++ b/.changeset/fresh-timelines-flow.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": patch +--- + +Add Timeline for operational logs and change history with status markers, actor metadata, and customization slots. diff --git a/.changeset/green-dates-arrive.md b/.changeset/green-dates-arrive.md new file mode 100644 index 0000000..9bd1f00 --- /dev/null +++ b/.changeset/green-dates-arrive.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": patch +--- + +Add DatePicker and DateRangePicker with stable local date values and range presets. diff --git a/.changeset/light-code-blocks-adapt.md b/.changeset/light-code-blocks-adapt.md new file mode 100644 index 0000000..75072be --- /dev/null +++ b/.changeset/light-code-blocks-adapt.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": patch +--- + +Match code block surfaces and syntax highlighting to the active light or dark theme. diff --git a/.changeset/moody-cases-itch.md b/.changeset/moody-cases-itch.md new file mode 100644 index 0000000..ab4a2e7 --- /dev/null +++ b/.changeset/moody-cases-itch.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": patch +--- + +Fix Dialog trigger slot handling, DataTable fallback for empty or undefined rows, TagInput IME composition behavior, Nuxt module type prefix, and package externalization. diff --git a/.changeset/quick-cloud-commands.md b/.changeset/quick-cloud-commands.md new file mode 100644 index 0000000..c101607 --- /dev/null +++ b/.changeset/quick-cloud-commands.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": patch +--- + +Add the experimental CommandPalette for accessible command navigation in developer tools. diff --git a/.changeset/quiet-shells-build.md b/.changeset/quiet-shells-build.md new file mode 100644 index 0000000..2dd2727 --- /dev/null +++ b/.changeset/quiet-shells-build.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": patch +--- + +Add AppShell and Sidebar primitives for developer console layouts. diff --git a/.changeset/shell-navigation-adapts.md b/.changeset/shell-navigation-adapts.md new file mode 100644 index 0000000..316b1d3 --- /dev/null +++ b/.changeset/shell-navigation-adapts.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": minor +--- + +Add collapsible desktop sidebars, mobile menu trigger slots, and content width conventions to AppShell. Document responsive Sheet navigation and AppShell, Sidebar, and NavigationMenu responsibilities. diff --git a/.changeset/smooth-files-arrive.md b/.changeset/smooth-files-arrive.md new file mode 100644 index 0000000..f9ef06c --- /dev/null +++ b/.changeset/smooth-files-arrive.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": patch +--- + +Add FileUpload with drag-and-drop, validation, upload progress, cancellation, previews, and retry states. diff --git a/.changeset/soft-themes-adapt.md b/.changeset/soft-themes-adapt.md new file mode 100644 index 0000000..32ce9a3 --- /dev/null +++ b/.changeset/soft-themes-adapt.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": minor +--- + +Add a configurable theme system with system color mode, palettes, density, radius, shadows, persistent preferences, and hydration-safe Nuxt initialization. Publish layered primitive and semantic CSS design tokens for consistent component styling. diff --git a/.changeset/tender-selects-grow.md b/.changeset/tender-selects-grow.md new file mode 100644 index 0000000..ee964eb --- /dev/null +++ b/.changeset/tender-selects-grow.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": patch +--- + +Add MultiSelect with searchable grouped options, removable selection tags, clearing, and form validation support. diff --git a/.changeset/tiny-panels-scale.md b/.changeset/tiny-panels-scale.md new file mode 100644 index 0000000..7a74475 --- /dev/null +++ b/.changeset/tiny-panels-scale.md @@ -0,0 +1,5 @@ +--- +"@recloudstudio/ui": patch +--- + +Add ResizablePanel for accessible, keyboard-adjustable console workspaces. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1789d39..a75b2d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: pull_request: push: - branches: [main] + branches: [main, dev] permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 617810a..ce03a47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: oven-sh/setup-bun@v2 with: bun-version: "1.4.2" @@ -40,6 +42,8 @@ jobs: url: https://npm.pkg.github.com steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 24 @@ -48,7 +52,7 @@ jobs: with: bun-version: "1.4.2" - run: bun install --frozen-lockfile - - run: bun run --cwd packages/ui typecheck && bun run --cwd packages/ui build + - run: bun run --cwd packages/ui typecheck && bun run --cwd packages/ui test && bun run --cwd packages/ui build - run: test "v$(node -p "require('./packages/ui/package.json').version")" = "$GITHUB_REF_NAME" # 重新推送同一 tag 时,跳过已发布过该版本的注册表,只补发失败的那一个 - id: published @@ -64,6 +68,8 @@ jobs: - if: matrix.registry.name == 'npm' && steps.published.outputs.exists != 'true' run: npm publish --provenance --access public --registry=${{ matrix.registry.url }} working-directory: packages/ui + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - if: matrix.registry.name == 'github' && steps.published.outputs.exists != 'true' run: npm publish --registry=${{ matrix.registry.url }} working-directory: packages/ui diff --git a/.gitignore b/.gitignore index 02b0eb3..34d5f83 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ .turbo/ .nuxt/ .output/ +.vitest/ *.log .DS_Store .direnv/ diff --git a/AGENTS.md b/AGENTS.md index 6d68298..cc76829 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,29 @@ Read `docs/README.md` inside that installed package first, then read the relevan This block is written and re-added by `turbo` before repository-scoped commands when an AI agent is detected. In the Turborepo source repository, its template is defined in `crates/turborepo-cli/src/cli/agent_guidance.rs`. Removing the managed block while updates are enabled means a later qualifying invocation will add it again. Set `"agentGuidance": false` in the root `turbo.json` or `turbo.jsonc` to opt out; this does not remove an existing block. Keep the block committed with your work to avoid an uncommitted change on the next agent invocation. +## ReCloud UI Agent Guide + +## External Engineering Standards + +The following standards are mandatory when their subject applies to the task: + +- [Git workflow](https://docs.worldexecute.me/development/git/) +- [TypeScript style](https://docs.worldexecute.me/development/ts-style/) +- [Markdown style](https://docs.worldexecute.me/development/markdown/) + +Read only the standard required for the current work; do not preload all three to conserve context. + +| Work being performed | Required reading | +| --- | --- | +| Creating branches, committing, preparing a PR, merging, tagging, or changing Git history | Git workflow | +| Adding or modifying `.ts`, `.tsx`, `.js`, `.jsx`, or Vue script code | TypeScript style | +| Adding or modifying `.md` / `.mdx` content, including Changesets | Markdown style | + +- Read the relevant linked standard before performing the matching work, and apply its rules throughout that task. +- Repository-local instructions, package scripts, and release workflow take precedence where they explicitly conflict with an external standard. In particular, this repository develops on `dev` as described in [Releases](#releases). +- Do not fetch, quote, or duplicate the full external standards in issues, PRs, commits, or repository files. Link to them and retain only task-specific project rules here. +- Do not commit secrets, credentials, tokens, local IDE settings, build output, or temporary files. Keep commits focused and independently verifiable. + ## Workspace - Use Bun 1.4.2. This is a Bun workspace: the publishable Vue 3 library is `packages/ui`; `apps/playground` is the Nuxt 4 documentation and integration app. @@ -24,6 +47,41 @@ This block is written and re-added by `turbo` before repository-scoped commands - The Playground build refuses to run while a Nuxt dev server holds `apps/playground/.nuxt`; stop the dev server first. - `bun run ci` is the full workspace gate. Biome intentionally excludes `.vue` files, so do not treat a passing root lint as Vue validation. +## Component Delivery + +- A published component lives in `packages/ui/src/components//`. Keep its implementation, public types, and `index.ts` barrel together. +- Export every public component and type from `packages/ui/src/index.ts`. Do not expose implementation-only helpers by accident. +- Every new public component needs a Playground page at `apps/playground/pages/components/.vue` and an entry in `apps/playground/utils/catalog.ts`. +- `catalog.ts` sorts entries inside each category by title; preserve category order and do not manually work around that sort. +- Follow existing Vue 3 patterns: typed props and emits, `cn()` for class composition, keyboard interactions and ARIA semantics for every interactive control, and light/dark mode support. +- Build console-oriented primitives first: dense but readable states, durable empty/loading/error states, controlled data APIs, and explicit async ownership. Do not make network requests or invent backend protocols inside UI components. +- Keep public state controlled where consumers need to own it. If a component also offers an uncontrolled default, document the interaction and preserve predictable update events. + +## Theme and Design Tokens + +- Treat `packages/ui/src/theme.css` as the token source of truth. Use semantic tokens (`--background`, `--foreground`, `--card`, `--primary`, `--border`, `--ring`, and status tokens) rather than hard-coded palette values in new or modified components. +- Keep the three visual layers distinct: brand gradients are for emphasis and primary moments; surfaces are neutral; semantic colors communicate status. Never use the brand gradient as generic decoration. +- `useTheme()` owns persisted user preferences for color mode, palette, density, radius, and shadow. Keep `light`, `dark`, and `system` behavior hydration-safe; `auto` remains a compatibility alias only. +- Any persisted theme preference must be represented both by `useTheme()` and `createThemeInitScript()` so the pre-hydration document state matches the hydrated app. +- Preserve legacy CSS variable aliases while migrating components; removing or renaming a public token is a breaking change. +- Use the Sans font stack for UI and the Mono stack for code. Apply tabular numbers to data-heavy tables and metrics with `.rc-tabular-nums` or `data-numeric`. + +## Changesets + +- Every consumer-visible `@recloudstudio/ui` change requires a Changeset in `.changeset/`; docs-only, tests-only, tooling-only, and behavior-neutral refactors do not. +- Read package names from `package.json`; never list private apps or invent package names. +- Choose the smallest valid bump: `patch` for compatible fixes, `minor` for compatible public features/options/exports, and `major` only for consumer-breaking API or behavior changes. +- Write one or two concise English release-note sentences focused on consumer impact. Do not edit package versions, generated changelogs, or `.changeset/config.json` unless explicitly asked. +- Create and validate the Changeset in the same change as the feature. A typical file is: + + ```md + --- + "@recloudstudio/ui": minor + --- + + Add configurable theme preferences and hydration-safe initialization. + ``` + ## Releases - Develop on `dev`; merge `dev` into `main` only for a planned release. Do not commit feature work directly to `main`, except an urgent production fix. diff --git a/apps/playground/app.vue b/apps/playground/app.vue index 2ae358a..87a9eaa 100644 --- a/apps/playground/app.vue +++ b/apps/playground/app.vue @@ -1,5 +1,6 @@ diff --git a/packages/ui/src/components/empty/EmptyState.vue b/packages/ui/src/components/empty/EmptyState.vue index f91e832..c28f6fc 100644 --- a/packages/ui/src/components/empty/EmptyState.vue +++ b/packages/ui/src/components/empty/EmptyState.vue @@ -1,25 +1,43 @@ diff --git a/packages/ui/src/components/file-upload/FileUpload.vue b/packages/ui/src/components/file-upload/FileUpload.vue new file mode 100644 index 0000000..eb3d1db --- /dev/null +++ b/packages/ui/src/components/file-upload/FileUpload.vue @@ -0,0 +1,86 @@ + + + diff --git a/packages/ui/src/components/file-upload/index.ts b/packages/ui/src/components/file-upload/index.ts new file mode 100644 index 0000000..223b211 --- /dev/null +++ b/packages/ui/src/components/file-upload/index.ts @@ -0,0 +1,2 @@ +export { default as FileUpload } from './FileUpload.vue' +export type { FileUploadHandler, FileUploadItem, FileUploadRequest, FileUploadStatus } from './types' diff --git a/packages/ui/src/components/file-upload/types.ts b/packages/ui/src/components/file-upload/types.ts new file mode 100644 index 0000000..3b8af8e --- /dev/null +++ b/packages/ui/src/components/file-upload/types.ts @@ -0,0 +1,18 @@ +export type FileUploadStatus = 'pending' | 'uploading' | 'success' | 'error' | 'cancelled' + +export interface FileUploadItem { + id: string + file: File + status: FileUploadStatus + progress?: number + error?: string + previewUrl?: string +} + +export interface FileUploadRequest { + file: File + signal: AbortSignal + setProgress: (value: number) => void +} + +export type FileUploadHandler = (request: FileUploadRequest) => Promise diff --git a/packages/ui/src/components/footer/Footer.vue b/packages/ui/src/components/footer/Footer.vue new file mode 100644 index 0000000..997ff09 --- /dev/null +++ b/packages/ui/src/components/footer/Footer.vue @@ -0,0 +1,138 @@ + + + diff --git a/packages/ui/src/components/footer/footer.test.ts b/packages/ui/src/components/footer/footer.test.ts new file mode 100644 index 0000000..3f3b091 --- /dev/null +++ b/packages/ui/src/components/footer/footer.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { Footer } from './index' + +const source = readFileSync(fileURLToPath(new URL('./Footer.vue', import.meta.url)), 'utf8') +const types = readFileSync(fileURLToPath(new URL('./types.ts', import.meta.url)), 'utf8') + +describe('Footer component contract', () => { + it('is exported as a Vue component', () => { + expect(Footer).toBeDefined() + }) + + it('renders configured footer links and keeps the default copyright SSR-safe', () => { + expect(source).toContain('v-for="link in col.links"') + expect(source).toContain("'© ReCloud Studio. All rights reserved.'") + expect(source).not.toContain('new Date()') + }) + + it('uses semantic tokens and exposes social customization through its slot', () => { + expect(source).toContain('hover:text-[color:var(--primary)]') + expect(source).toContain('') + }) + + it('maps supported social icon names and falls back to the accessible text name', () => { + expect(types).toContain('icon?: string') + expect(source).toContain("normalizedIcon === 'github' || normalizedIcon === 'discord'") + expect(source).toContain("normalizedIcon === 'twitter' || normalizedIcon === 'x'") + expect(source).toContain("socialIcon(s.icon) === 'github'") + expect(source).toContain("socialIcon(s.icon) === 'discord'") + expect(source).toContain("socialIcon(s.icon) === 'twitter'") + expect(source).toContain('{{ s.name }}') + expect(source).toContain(':aria-label="s.name"') + }) +}) diff --git a/packages/ui/src/components/footer/index.ts b/packages/ui/src/components/footer/index.ts new file mode 100644 index 0000000..538720d --- /dev/null +++ b/packages/ui/src/components/footer/index.ts @@ -0,0 +1,2 @@ +export { default as Footer } from './Footer.vue' +export type { FooterColumn, FooterLink, FooterProps, FooterSocial } from './types' diff --git a/packages/ui/src/components/footer/types.ts b/packages/ui/src/components/footer/types.ts new file mode 100644 index 0000000..27be639 --- /dev/null +++ b/packages/ui/src/components/footer/types.ts @@ -0,0 +1,28 @@ +export interface FooterLink { + label: string + href?: string + to?: string + target?: string +} + +export interface FooterColumn { + title: string + links: FooterLink[] +} + +export interface FooterSocial { + name: string + href: string + /** Built-in icon name: github, discord, twitter, or x. Unknown values render the name text. */ + icon?: string +} + +export interface FooterProps { + brandTitle?: string + brandDescription?: string + columns?: FooterColumn[] + copyright?: string + socials?: FooterSocial[] + bordered?: boolean + class?: string +} diff --git a/packages/ui/src/components/hero/Hero.vue b/packages/ui/src/components/hero/Hero.vue new file mode 100644 index 0000000..7b7107b --- /dev/null +++ b/packages/ui/src/components/hero/Hero.vue @@ -0,0 +1,179 @@ + + + diff --git a/packages/ui/src/components/hero/hero.test.ts b/packages/ui/src/components/hero/hero.test.ts new file mode 100644 index 0000000..6d818f7 --- /dev/null +++ b/packages/ui/src/components/hero/hero.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(fileURLToPath(new URL('./Hero.vue', import.meta.url)), 'utf8') + +describe('Hero component contract', () => { + it('supports center, split, and minimal layouts', () => { + expect(source).toContain("layout === 'center'") + expect(source).toContain("layout === 'split'") + expect(source).toContain("layout: 'center'") + }) + + it('renders CTA controls with public click events and action overrides', () => { + expect(source).toContain("@click=\"$emit('primary-click')\"") + expect(source).toContain("@click=\"$emit('secondary-click')\"") + expect(source).toContain('') + expect(source).toContain('') + }) + + it('uses semantic primary tokens for badges and primary CTAs', () => { + expect(source).toContain('bg-[color:var(--primary)]') + expect(source).toContain('hover:bg-[color:var(--primary-hover)]') + expect(source).not.toContain('#2563EB') + }) +}) diff --git a/packages/ui/src/components/hero/index.ts b/packages/ui/src/components/hero/index.ts new file mode 100644 index 0000000..1152ed0 --- /dev/null +++ b/packages/ui/src/components/hero/index.ts @@ -0,0 +1,2 @@ +export { default as Hero } from './Hero.vue' +export type { HeroLayout, HeroProps } from './types' diff --git a/packages/ui/src/components/hero/types.ts b/packages/ui/src/components/hero/types.ts new file mode 100644 index 0000000..68170c0 --- /dev/null +++ b/packages/ui/src/components/hero/types.ts @@ -0,0 +1,11 @@ +export type HeroLayout = 'center' | 'split' | 'minimal' + +export interface HeroProps { + layout?: HeroLayout + badge?: string + title?: string + description?: string + primaryActionText?: string + secondaryActionText?: string + class?: string +} diff --git a/packages/ui/src/components/icon/Icon.vue b/packages/ui/src/components/icon/Icon.vue new file mode 100644 index 0000000..cbd643c --- /dev/null +++ b/packages/ui/src/components/icon/Icon.vue @@ -0,0 +1,74 @@ + + + diff --git a/packages/ui/src/components/icon/icon.test.ts b/packages/ui/src/components/icon/icon.test.ts new file mode 100644 index 0000000..ab6785d --- /dev/null +++ b/packages/ui/src/components/icon/icon.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { Icon } from './index' + +describe('Icon component', () => { + it('is exported as a Vue component', () => { + expect(Icon).toBeDefined() + expect(Icon.name || Icon.__name).toBe('ReIcon') + }) + + it('implements accessibility semantics, sizing, and slot fallbacks in template', () => { + const sfcPath = resolve(__dirname, './Icon.vue') + const source = readFileSync(sfcPath, 'utf-8') + + // Accessibility: label given sets role="img" and aria-label; absent sets aria-hidden="true" + expect(source).toContain(':role="label ? \'img\' : undefined"') + expect(source).toContain(':aria-label="label || undefined"') + expect(source).toContain(':aria-hidden="label ? undefined : \'true\'"') + + // Rendering branches: component, svg markup, image src, slot fallback + expect(source).toContain("resolved?.kind === 'component'") + expect(source).toContain("resolved?.kind === 'svg'") + expect(source).toContain("resolved?.kind === 'image'") + expect(source).toContain('') + + // Size binding + expect(source).toContain(':style="sizeStyle"') + }) +}) diff --git a/packages/ui/src/components/icon/index.ts b/packages/ui/src/components/icon/index.ts new file mode 100644 index 0000000..27940d2 --- /dev/null +++ b/packages/ui/src/components/icon/index.ts @@ -0,0 +1,4 @@ +export { default as Icon } from './Icon.vue' +export * from './types' +export * from './registry' +export { lookupLucideIcon, toPascalCase } from './lucideResolver' diff --git a/packages/ui/src/components/icon/lucideResolver.ts b/packages/ui/src/components/icon/lucideResolver.ts new file mode 100644 index 0000000..5629d3e --- /dev/null +++ b/packages/ui/src/components/icon/lucideResolver.ts @@ -0,0 +1,55 @@ +import type { Component } from 'vue' + +/** + * 将 kebab-case、snake_case 或空格分隔的命名转换为 PascalCase。 + * 例如:'settings' -> 'Settings', 'arrow-left' -> 'ArrowLeft', 'circle-alert' -> 'CircleAlert'。 + */ +export function toPascalCase(name: string): string { + if (!name) return '' + const trimmed = name.trim() + if (!trimmed) return '' + + const parts = trimmed.split(/[-_\s]+/).filter(Boolean) + if (parts.length === 0) return '' + + return parts + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') +} + +/** + * 缓存已加载的 lucide-vue-next 模块,避免重复动态导入。 + */ +let lucideModulePromise: Promise> | null = null + +function loadLucideModule(): Promise> { + if (!lucideModulePromise) { + lucideModulePromise = import('lucide-vue-next').then((m) => { + // SAFETY: lucide-vue-next exports named Vue components under icon identifier keys. + return m as Record + }) + } + return lucideModulePromise +} + +/** + * 从 lucide-vue-next 中查找指定名称的图标组件。 + */ +export async function lookupLucideIcon(name: string): Promise { + if (!name) return null + const mod = await loadLucideModule() + const pascal = toPascalCase(name) + + const candidate = + mod[pascal] ?? + mod[`${pascal}Icon`] ?? + mod[name] ?? + mod[`${name}Icon`] + + if (candidate && (typeof candidate === 'object' || typeof candidate === 'function')) { + // SAFETY: the resolved candidate is a Vue component function or options object. + return candidate as Component + } + + return null +} diff --git a/packages/ui/src/components/icon/registry.test.ts b/packages/ui/src/components/icon/registry.test.ts new file mode 100644 index 0000000..f672d14 --- /dev/null +++ b/packages/ui/src/components/icon/registry.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { toPascalCase } from './lucideResolver' +import { + classifyIconSource, + looksLikeImage, + looksLikeSvg, + parsePrefixedName, + registerIconResolver, + resolveIconSource, + setDefaultIconResolver, + unregisterIconResolver +} from './registry' + +describe('Icon registry and classification', () => { + it('correctly converts naming conventions to PascalCase', () => { + expect(toPascalCase('settings')).toBe('Settings') + expect(toPascalCase('arrow-left')).toBe('ArrowLeft') + expect(toPascalCase('circle_alert')).toBe('CircleAlert') + expect(toPascalCase('folder open')).toBe('FolderOpen') + expect(toPascalCase('ChevronRight')).toBe('ChevronRight') + expect(toPascalCase('')).toBe('') + }) + + it('classifies SVG markup, image paths, and named icons accurately', () => { + expect(looksLikeSvg('')).toBe(true) + expect(looksLikeSvg(' ')).toBe(true) + expect(looksLikeSvg('settings')).toBe(false) + + expect(looksLikeImage('/icons/app.svg')).toBe(true) + expect(looksLikeImage('./logo.png')).toBe(true) + expect(looksLikeImage('../assets/hero.webp')).toBe(true) + expect(looksLikeImage('https://example.com/icon.svg')).toBe(true) + expect(looksLikeImage('data:image/svg+xml;base64,PHN2Zz4...')).toBe(true) + expect(looksLikeImage('lucide:settings')).toBe(false) + + expect(classifyIconSource('')).toBe('svg') + expect(classifyIconSource('/brand.png')).toBe('image') + expect(classifyIconSource('lucide:settings')).toBe('named') + expect(classifyIconSource({ render: () => null })).toBe('component') + }) + + it('parses prefixed names accurately', () => { + expect(parsePrefixedName('lucide:settings')).toEqual({ + prefix: 'lucide', + name: 'settings' + }) + expect(parsePrefixedName('MDI:home-outline')).toEqual({ + prefix: 'mdi', + name: 'home-outline' + }) + expect(parsePrefixedName('plain-name')).toBeNull() + }) + + it('resolves direct Vue component sources synchronously', () => { + const dummyComponent = { name: 'DummyIcon' } + const result = resolveIconSource(dummyComponent) + expect(result).toEqual({ kind: 'component', component: dummyComponent }) + }) + + it('resolves inline SVG markup synchronously', () => { + const svgCode = '' + const result = resolveIconSource(svgCode) + expect(result).toEqual({ kind: 'svg', markup: svgCode }) + }) + + it('resolves image URLs synchronously', () => { + const url = 'https://ui.worldexecute.me/icon.svg' + const result = resolveIconSource(url) + expect(result).toEqual({ kind: 'image', src: url }) + }) + + it('supports registering and unregistering custom prefix resolvers', async () => { + const customSvg = '' + registerIconResolver('testcustom', (name) => { + if (name === 'check') return customSvg + if (name === 'image') return '/resolved.png' + return null + }) + + const checkResult = resolveIconSource('testcustom:check') + expect(checkResult).toEqual({ kind: 'svg', markup: customSvg }) + + const imgResult = resolveIconSource('testcustom:image') + expect(imgResult).toEqual({ kind: 'image', src: '/resolved.png' }) + + const unknownResult = resolveIconSource('testcustom:missing') + expect(unknownResult).toBeNull() + + unregisterIconResolver('testcustom') + const unregistered = resolveIconSource('testcustom:check') + // After unregistration, unregistered prefix with no default resolver resolves to null + expect(unregistered).toBeNull() + }) + + it('supports asynchronous custom resolvers', async () => { + registerIconResolver('testasync', async (name) => { + if (name === 'rocket') { + return '' + } + return null + }) + + const promise = resolveIconSource('testasync:rocket') + expect(promise).toBeInstanceOf(Promise) + const resolved = await promise + expect(resolved).toEqual({ kind: 'svg', markup: '' }) + + unregisterIconResolver('testasync') + }) + + it('falls back to default resolver for unknown prefixes and bare names', async () => { + setDefaultIconResolver((name) => { + if (name === 'app-logo') return '/logo.svg' + if (name === 'unknown:test') return '' + return null + }) + + const bareResult = resolveIconSource('app-logo') + expect(bareResult).toEqual({ kind: 'image', src: '/logo.svg' }) + + const prefixResult = resolveIconSource('unknown:test') + expect(prefixResult).toEqual({ kind: 'svg', markup: '' }) + + setDefaultIconResolver(null) + }) + + it('resolves lucide icons asynchronously via built-in resolver', async () => { + const outcome = resolveIconSource('lucide:settings') + expect(outcome).toBeInstanceOf(Promise) + const resolved = await outcome + expect(resolved).not.toBeNull() + expect(resolved?.kind).toBe('component') + }) +}) diff --git a/packages/ui/src/components/icon/registry.ts b/packages/ui/src/components/icon/registry.ts new file mode 100644 index 0000000..92d2102 --- /dev/null +++ b/packages/ui/src/components/icon/registry.ts @@ -0,0 +1,186 @@ +import { ref } from 'vue' +import { lookupLucideIcon } from './lucideResolver' +import type { IconResolver, IconSource, ResolvedIcon } from './types' + +/** + * 响应式版本号计数器,供 Icon 组件监听动态注册变更。 + */ +export const iconRegistryVersion = ref(0) + +const prefixResolvers = new Map() +let defaultIconResolver: IconResolver | null = null + +/** + * 注册指定前缀的图标库解析器(前缀不区分大小写)。 + * 例如:`registerIconResolver('mdi', (name) => ...)` + */ +export function registerIconResolver(prefix: string, resolver: IconResolver): void { + const normalized = prefix.trim().toLowerCase() + if (!normalized) return + prefixResolvers.set(normalized, resolver) + iconRegistryVersion.value++ +} + +/** + * 注销指定前缀的图标库解析器。 + */ +export function unregisterIconResolver(prefix: string): void { + const normalized = prefix.trim().toLowerCase() + if (prefixResolvers.delete(normalized)) { + iconRegistryVersion.value++ + } +} + +/** + * 设置未带前缀图标名称的默认解析器。 + */ +export function setDefaultIconResolver(resolver: IconResolver | null | undefined): void { + defaultIconResolver = resolver ?? null + iconRegistryVersion.value++ +} + +/** + * 判断字符串是否为内联 SVG 代码片段。 + */ +export function looksLikeSvg(value: string): boolean { + return /^\s*]/i.test(value) +} + +/** + * 判断字符串是否为图像路径、网络 URL 或 Data URL。 + */ +export function looksLikeImage(value: string): boolean { + const trimmed = value.trim() + if ( + /^(?:https?:)?\/\//i.test(trimmed) || + trimmed.startsWith('/') || + trimmed.startsWith('./') || + trimmed.startsWith('../') || + trimmed.startsWith('data:image/') + ) { + return true + } + return /\.(svg|png|jpe?g|gif|webp|avif|ico)([?#].*)?$/i.test(trimmed) +} + +/** + * 解析带前缀的名称(例如 `'lucide:settings'` -> `{ prefix: 'lucide', name: 'settings' }`)。 + */ +export function parsePrefixedName( + value: string +): { prefix: string; name: string } | null { + const trimmed = value.trim() + const match = /^([a-zA-Z0-9_-]+):(.*)$/.exec(trimmed) + if (!match) return null + return { + prefix: match[1].toLowerCase(), + name: match[2].trim() + } +} + +/** + * 分析给定的图标源类型归类。 + */ +export function classifyIconSource( + source: IconSource +): 'component' | 'svg' | 'image' | 'named' { + if (typeof source !== 'string') { + return 'component' + } + if (looksLikeSvg(source)) { + return 'svg' + } + if (looksLikeImage(source)) { + return 'image' + } + return 'named' +} + +function isPromise(value: unknown): value is Promise { + if (value === null || typeof value !== 'object') { + return false + } + // SAFETY: duck-typing thenable checks whether `then` is callable. + const candidate = value as { then?: unknown } + return typeof candidate.then === 'function' +} + +function normalizeResolved(result: IconSource | null | undefined): ResolvedIcon | null { + if (!result) return null + if (typeof result !== 'string') { + return { kind: 'component', component: result } + } + const trimmed = result.trim() + if (!trimmed) return null + if (looksLikeSvg(trimmed)) { + return { kind: 'svg', markup: trimmed } + } + if (looksLikeImage(trimmed)) { + return { kind: 'image', src: trimmed } + } + return null +} + +/** + * 解析任意图标源为已规范化形态(支持同步返回或异步 Promise)。 + */ +export function resolveIconSource( + source: IconSource | null | undefined +): ResolvedIcon | Promise | null { + if (!source) return null + + if (typeof source !== 'string') { + return { kind: 'component', component: source } + } + + const trimmed = source.trim() + if (!trimmed) return null + + if (looksLikeSvg(trimmed)) { + return { kind: 'svg', markup: trimmed } + } + + if (looksLikeImage(trimmed)) { + return { kind: 'image', src: trimmed } + } + + const prefixed = parsePrefixedName(trimmed) + if (prefixed) { + const resolver = prefixResolvers.get(prefixed.prefix) + if (resolver) { + const outcome = resolver(prefixed.name) + if (isPromise(outcome)) { + return outcome.then(normalizeResolved) + } + return normalizeResolved(outcome) + } + + if (prefixed.prefix === 'lucide') { + return lookupLucideIcon(prefixed.name).then((comp) => { + return comp ? { kind: 'component', component: comp } : null + }) + } + + if (defaultIconResolver) { + const outcome = defaultIconResolver(trimmed) + if (isPromise(outcome)) { + return outcome.then(normalizeResolved) + } + return normalizeResolved(outcome) + } + + return null + } + + if (defaultIconResolver) { + const outcome = defaultIconResolver(trimmed) + if (isPromise(outcome)) { + return outcome.then(normalizeResolved) + } + return normalizeResolved(outcome) + } + + return lookupLucideIcon(trimmed).then((comp) => { + return comp ? { kind: 'component', component: comp } : null + }) +} diff --git a/packages/ui/src/components/icon/types.ts b/packages/ui/src/components/icon/types.ts new file mode 100644 index 0000000..e5adcb0 --- /dev/null +++ b/packages/ui/src/components/icon/types.ts @@ -0,0 +1,47 @@ +import type { Component } from 'vue' + +/** + * 支持的图标源类型: + * - Vue 组件(来自 `lucide-vue-next`、`@heroicons/vue` 或自定义 SVG 组件) + * - 字符串: + * - 以 ` IconSource | null | undefined | Promise + +export interface IconProps { + /** + * 图标源:支持 Vue 组件、内联 SVG 字符串、图片 URL 或注册前缀名称。 + */ + icon?: IconSource + + /** + * 图标尺寸,默认 `'1em'`。 + * 传入数字时自动添加 `px`(例如 `16` -> `'16px'`);传入字符串按 CSS 属性原样生效。 + */ + size?: number | string + + /** + * 无障碍描述文本。 + * 提供时赋予 `role="img"` 与 `aria-label`;缺省时应用 `aria-hidden="true"` 标记为装饰图标。 + */ + label?: string +} diff --git a/packages/ui/src/components/inline-code/InlineCode.vue b/packages/ui/src/components/inline-code/InlineCode.vue new file mode 100644 index 0000000..943a553 --- /dev/null +++ b/packages/ui/src/components/inline-code/InlineCode.vue @@ -0,0 +1,74 @@ + + {{ code }} + + + + + + + + + + + + diff --git a/packages/ui/src/components/inline-code/index.ts b/packages/ui/src/components/inline-code/index.ts new file mode 100644 index 0000000..62e5ab9 --- /dev/null +++ b/packages/ui/src/components/inline-code/index.ts @@ -0,0 +1,2 @@ +export { default as InlineCode } from './InlineCode.vue' +export type { InlineCodeProps } from './types' diff --git a/packages/ui/src/components/inline-code/types.ts b/packages/ui/src/components/inline-code/types.ts new file mode 100644 index 0000000..86e8dcc --- /dev/null +++ b/packages/ui/src/components/inline-code/types.ts @@ -0,0 +1,6 @@ +export interface InlineCodeProps { + code?: string + copyable?: boolean + variant?: 'subtle' | 'solid' | 'outline' + class?: string +} diff --git a/packages/ui/src/components/multi-select/MultiSelect.vue b/packages/ui/src/components/multi-select/MultiSelect.vue new file mode 100644 index 0000000..2638151 --- /dev/null +++ b/packages/ui/src/components/multi-select/MultiSelect.vue @@ -0,0 +1,35 @@ + + + diff --git a/packages/ui/src/components/multi-select/index.ts b/packages/ui/src/components/multi-select/index.ts new file mode 100644 index 0000000..92e9465 --- /dev/null +++ b/packages/ui/src/components/multi-select/index.ts @@ -0,0 +1,2 @@ +export { default as MultiSelect } from './MultiSelect.vue' +export type { MultiSelectOption, MultiSelectProps } from './types' diff --git a/packages/ui/src/components/multi-select/types.ts b/packages/ui/src/components/multi-select/types.ts new file mode 100644 index 0000000..ad54e47 --- /dev/null +++ b/packages/ui/src/components/multi-select/types.ts @@ -0,0 +1,21 @@ +export interface MultiSelectOption { + label: string + value: string + disabled?: boolean + group?: string +} + +export interface MultiSelectProps { + modelValue?: string[] + options: MultiSelectOption[] + placeholder?: string + id?: string + label?: string + hint?: string + error?: string + required?: boolean + emptyText?: string + clearable?: boolean + disabled?: boolean + class?: string +} diff --git a/packages/ui/src/components/nav-tree/NavTree.vue b/packages/ui/src/components/nav-tree/NavTree.vue new file mode 100644 index 0000000..b00f3ab --- /dev/null +++ b/packages/ui/src/components/nav-tree/NavTree.vue @@ -0,0 +1,125 @@ + + + diff --git a/packages/ui/src/components/nav-tree/index.ts b/packages/ui/src/components/nav-tree/index.ts new file mode 100644 index 0000000..df463aa --- /dev/null +++ b/packages/ui/src/components/nav-tree/index.ts @@ -0,0 +1,2 @@ +export { default as NavTree } from './NavTree.vue' +export type { NavTreeGroup, NavTreeItem, NavTreeProps } from './types' diff --git a/packages/ui/src/components/nav-tree/types.ts b/packages/ui/src/components/nav-tree/types.ts new file mode 100644 index 0000000..f2aa57b --- /dev/null +++ b/packages/ui/src/components/nav-tree/types.ts @@ -0,0 +1,25 @@ +import type { IconSource } from '../icon' + +export interface NavTreeItem { + title: string + href?: string + to?: string + badge?: string + icon?: IconSource + disabled?: boolean +} + +export interface NavTreeGroup { + title: string + collapsible?: boolean + defaultOpen?: boolean + items: NavTreeItem[] +} + +export interface NavTreeProps { + groups: NavTreeGroup[] + activeHref?: string + searchable?: boolean + searchPlaceholder?: string + class?: string +} diff --git a/packages/ui/src/components/navbar/Navbar.vue b/packages/ui/src/components/navbar/Navbar.vue new file mode 100644 index 0000000..69a2406 --- /dev/null +++ b/packages/ui/src/components/navbar/Navbar.vue @@ -0,0 +1,149 @@ + + + diff --git a/packages/ui/src/components/navbar/index.ts b/packages/ui/src/components/navbar/index.ts new file mode 100644 index 0000000..aae6961 --- /dev/null +++ b/packages/ui/src/components/navbar/index.ts @@ -0,0 +1,2 @@ +export { default as Navbar } from './Navbar.vue' +export type { NavbarLink, NavbarProps } from './types' diff --git a/packages/ui/src/components/navbar/navbar.test.ts b/packages/ui/src/components/navbar/navbar.test.ts new file mode 100644 index 0000000..1a7befe --- /dev/null +++ b/packages/ui/src/components/navbar/navbar.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(fileURLToPath(new URL('./Navbar.vue', import.meta.url)), 'utf8') + +describe('Navbar component contract', () => { + it('exposes dialog state to the mobile menu trigger', () => { + expect(source).toContain(':aria-expanded="mobileOpen"') + expect(source).toContain('aria-haspopup="dialog"') + expect(source).toContain('@click="mobileOpen = !mobileOpen"') + expect(source).toContain(':open="mobileOpen"') + }) + + it('uses semantic primary and ring tokens for brand and interactive states', () => { + expect(source).toContain('bg-[color:var(--primary)]') + expect(source).toContain('focus-visible:ring-[color:var(--ring)]') + expect(source).not.toContain('#2563EB') + }) +}) diff --git a/packages/ui/src/components/navbar/types.ts b/packages/ui/src/components/navbar/types.ts new file mode 100644 index 0000000..2b41866 --- /dev/null +++ b/packages/ui/src/components/navbar/types.ts @@ -0,0 +1,18 @@ +export interface NavbarLink { + label: string + href?: string + to?: string + target?: string + badge?: string + active?: boolean +} + +export interface NavbarProps { + brandTitle?: string + brandHref?: string + brandTo?: string + links?: NavbarLink[] + sticky?: boolean + bordered?: boolean + class?: string +} diff --git a/packages/ui/src/components/pagination/Pagination.vue b/packages/ui/src/components/pagination/Pagination.vue index 3180680..253b10f 100644 --- a/packages/ui/src/components/pagination/Pagination.vue +++ b/packages/ui/src/components/pagination/Pagination.vue @@ -1,10 +1,43 @@ @@ -12,6 +45,7 @@ diff --git a/packages/ui/src/components/prev-next/index.ts b/packages/ui/src/components/prev-next/index.ts new file mode 100644 index 0000000..40eedfe --- /dev/null +++ b/packages/ui/src/components/prev-next/index.ts @@ -0,0 +1,2 @@ +export { default as PrevNext } from './PrevNext.vue' +export type { PrevNextItem, PrevNextProps } from './types' diff --git a/packages/ui/src/components/prev-next/types.ts b/packages/ui/src/components/prev-next/types.ts new file mode 100644 index 0000000..4832ee6 --- /dev/null +++ b/packages/ui/src/components/prev-next/types.ts @@ -0,0 +1,14 @@ +export interface PrevNextItem { + title: string + href?: string + to?: string + description?: string +} + +export interface PrevNextProps { + prev?: PrevNextItem + next?: PrevNextItem + prevLabel?: string + nextLabel?: string + class?: string +} diff --git a/packages/ui/src/components/resizable-panel/ResizablePanel.vue b/packages/ui/src/components/resizable-panel/ResizablePanel.vue new file mode 100644 index 0000000..4415477 --- /dev/null +++ b/packages/ui/src/components/resizable-panel/ResizablePanel.vue @@ -0,0 +1,82 @@ + + + diff --git a/packages/ui/src/components/resizable-panel/index.ts b/packages/ui/src/components/resizable-panel/index.ts new file mode 100644 index 0000000..cbe36bc --- /dev/null +++ b/packages/ui/src/components/resizable-panel/index.ts @@ -0,0 +1 @@ +export { default as ResizablePanel } from './ResizablePanel.vue' diff --git a/packages/ui/src/components/select/Select.vue b/packages/ui/src/components/select/Select.vue index 70eb8f0..bb36c9e 100644 --- a/packages/ui/src/components/select/Select.vue +++ b/packages/ui/src/components/select/Select.vue @@ -13,7 +13,7 @@ :aria-required="required || undefined" :class="triggerClasses" > - + @@ -66,6 +66,7 @@ import { } from 'reka-ui' import { cn } from '../../utils/cn' import Field from '../field/Field.vue' +import { useComponentLocale } from '../../locale' export interface SelectOption { label: string @@ -88,7 +89,7 @@ export interface SelectProps { const props = withDefaults(defineProps(), { modelValue: '', - placeholder: '请选择...', + placeholder: undefined, id: undefined, label: '', hint: '', @@ -98,6 +99,9 @@ const props = withDefaults(defineProps(), { class: '' }) +const selectLoc = useComponentLocale('select') +const effectivePlaceholder = computed(() => props.placeholder ?? selectLoc.value.placeholder) + defineEmits<{ (e: 'update:modelValue', value: string): void }>() diff --git a/packages/ui/src/components/sheet/Sheet.vue b/packages/ui/src/components/sheet/Sheet.vue new file mode 100644 index 0000000..93d326e --- /dev/null +++ b/packages/ui/src/components/sheet/Sheet.vue @@ -0,0 +1,71 @@ +
+ + {{ title }} + {{ description }} + +
+ +
+ +
+ + + + + + + + + + diff --git a/packages/ui/src/components/sheet/index.ts b/packages/ui/src/components/sheet/index.ts new file mode 100644 index 0000000..a8c9060 --- /dev/null +++ b/packages/ui/src/components/sheet/index.ts @@ -0,0 +1 @@ +export { default as Sheet } from './Sheet.vue' diff --git a/packages/ui/src/components/steps/StepItem.vue b/packages/ui/src/components/steps/StepItem.vue new file mode 100644 index 0000000..3a1c1d3 --- /dev/null +++ b/packages/ui/src/components/steps/StepItem.vue @@ -0,0 +1,53 @@ + + + diff --git a/packages/ui/src/components/steps/Steps.vue b/packages/ui/src/components/steps/Steps.vue new file mode 100644 index 0000000..3475641 --- /dev/null +++ b/packages/ui/src/components/steps/Steps.vue @@ -0,0 +1,25 @@ + + + diff --git a/packages/ui/src/components/steps/index.ts b/packages/ui/src/components/steps/index.ts new file mode 100644 index 0000000..e1db8ed --- /dev/null +++ b/packages/ui/src/components/steps/index.ts @@ -0,0 +1,3 @@ +export { default as Steps } from './Steps.vue' +export { default as StepItem } from './StepItem.vue' +export type { StepItemProps, StepsProps } from './types' diff --git a/packages/ui/src/components/steps/types.ts b/packages/ui/src/components/steps/types.ts new file mode 100644 index 0000000..1064753 --- /dev/null +++ b/packages/ui/src/components/steps/types.ts @@ -0,0 +1,19 @@ +import type { InjectionKey } from 'vue' + +export interface StepsProps { + startIndex?: number + class?: string +} + +export interface StepItemProps { + title?: string + description?: string + step?: number | string + class?: string +} + +export interface StepsContext { + registerStep: () => number +} + +export const stepsInjectionKey: InjectionKey = Symbol('re-steps') diff --git a/packages/ui/src/components/table/DataTable.vue b/packages/ui/src/components/table/DataTable.vue index c9ee324..98e8a69 100644 --- a/packages/ui/src/components/table/DataTable.vue +++ b/packages/ui/src/components/table/DataTable.vue @@ -1,70 +1,83 @@ - diff --git a/packages/ui/src/components/table/index.ts b/packages/ui/src/components/table/index.ts index e734766..052485b 100644 --- a/packages/ui/src/components/table/index.ts +++ b/packages/ui/src/components/table/index.ts @@ -1,2 +1,2 @@ export { default as DataTable } from './DataTable.vue' -export type { DataTableProps, DataTableColumn } from './DataTable.vue' +export type { DataTableColumn, DataTableSort } from './types' diff --git a/packages/ui/src/components/table/types.ts b/packages/ui/src/components/table/types.ts new file mode 100644 index 0000000..ebe4ae6 --- /dev/null +++ b/packages/ui/src/components/table/types.ts @@ -0,0 +1,14 @@ +export interface DataTableColumn { + key: string + label: string + width?: string + align?: 'left' | 'center' | 'right' + slot?: boolean + sortable?: boolean + hideable?: boolean +} + +export interface DataTableSort { + key: string + direction: 'asc' | 'desc' +} diff --git a/packages/ui/src/components/tabs/Tabs.vue b/packages/ui/src/components/tabs/Tabs.vue index e643853..b04954f 100644 --- a/packages/ui/src/components/tabs/Tabs.vue +++ b/packages/ui/src/components/tabs/Tabs.vue @@ -12,7 +12,7 @@ :disabled="tab.disabled" class="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium text-slate-600 dark:text-slate-400 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2563EB] disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white dark:data-[state=active]:bg-[#0F172A] data-[state=active]:text-slate-900 dark:data-[state=active]:text-slate-100 data-[state=active]:shadow-xs cursor-pointer select-none" > - + {{ tab.label }} @@ -27,12 +27,13 @@ import { TabsList, TabsTrigger } from 'reka-ui' +import { Icon, type IconSource } from '../icon' import { cn } from '../../utils/cn' export interface TabItem { label: string value: string - icon?: any + icon?: IconSource disabled?: boolean } diff --git a/packages/ui/src/components/tag-input/TagInput.vue b/packages/ui/src/components/tag-input/TagInput.vue index 8c56ef8..0f4eedf 100644 --- a/packages/ui/src/components/tag-input/TagInput.vue +++ b/packages/ui/src/components/tag-input/TagInput.vue @@ -22,7 +22,7 @@