feat(settings): refactor settings UI and add settings window (#14567)

Signed-off-by: kangfenmao <kangfenmao@qq.com>
Signed-off-by: jdzhang <625013594@qq.com>
Co-authored-by: jdzhang <625013594@qq.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
This commit is contained in:
亢奋猫
2026-05-08 18:09:26 +08:00
committed by GitHub
parent 63bcabf3da
commit dee3bb0928
314 changed files with 18475 additions and 10548 deletions

View File

@@ -146,6 +146,7 @@ export default defineConfig({
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
settings: resolve(__dirname, 'src/renderer/settings.html'),
quickAssistant: resolve(__dirname, 'src/renderer/quickAssistant.html'),
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),

View File

@@ -185,6 +185,7 @@
"@floating-ui/dom": "1.7.3",
"@google/genai": "^1.46.0",
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^5.0.1",
"@iconify-json/material-icon-theme": "^1.2.56",
"@iconify/react": "^6.0.2",
"@j178/prek": "^0.3.4",
@@ -399,6 +400,7 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.55.0",
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2",
"react-infinite-scroll-component": "^6.1.0",

View File

@@ -468,6 +468,7 @@ export enum IpcChannel {
Analytics_TrackTokenUsage = 'analytics:track-token-usage',
// WindowManager
SettingsWindow_Open = 'settings-window:open',
WindowManager_Open = 'window-manager:open',
WindowManager_Close = 'window-manager:close',
WindowManager_Minimize = 'window-manager:minimize',

View File

@@ -1,6 +1,6 @@
/**
* Auto-generated preferences configuration
* Generated at: 2026-05-07T06:53:51.724Z
* Generated at: 2026-05-08T08:23:28.551Z
*
* This file is automatically generated from classification.json
* To update this file, modify classification.json and run:

View File

@@ -0,0 +1,11 @@
export type SettingsPath = '/settings/provider' | `/settings/${string}`
export const DEFAULT_SETTINGS_PATH: SettingsPath = '/settings/provider'
export function isSettingsPath(value: unknown): value is SettingsPath {
return typeof value === 'string' && (value === '/settings/provider' || value.startsWith('/settings/'))
}
export function normalizeSettingsPath(value: unknown): SettingsPath {
return isSettingsPath(value) ? value : DEFAULT_SETTINGS_PATH
}

View File

@@ -68,10 +68,10 @@ Use the full Cherry Studio design system so Tailwind theme tokens resolve to Che
<div className="p-xl">Extra large spacing (5rem)</div>
<div className="p-8xl">Maximum spacing (15rem)</div>
<div className="rounded-4xs">Tiny radius (0.25rem)</div>
<div className="rounded-xs">Small radius (1rem)</div>
<div className="rounded-md">Medium radius (2rem)</div>
<div className="rounded-xl">Large radius (3rem)</div>
<div className="rounded-4xs">Tiny radius (0.03125rem)</div>
<div className="rounded-xs">Small radius (0.125rem)</div>
<div className="rounded-md">Medium radius (0.5rem)</div>
<div className="rounded-xl">Large radius (0.875rem)</div>
<div className="rounded-round">Full radius (999px)</div>
```

View File

@@ -52,11 +52,18 @@
},
"homepage": "https://github.com/CherryHQ/cherry-studio#readme",
"peerDependencies": {
"@hookform/resolvers": "^5.0.0",
"framer-motion": "^11.0.0 || ^12.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.55.0",
"tailwindcss": "^4.1.13"
},
"peerDependenciesMeta": {
"@hookform/resolvers": {
"optional": true
}
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
@@ -78,10 +85,14 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-callback-ref": "^1.1.1",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.545.0",
"radix-ui": "^1.4.3",
"react-day-picker": "^9.14.0",
"react-dropzone": "^14.3.8",
"tailwind-merge": "^2.5.5",
"vaul": "^1.1.2"

View File

@@ -12,12 +12,18 @@ describe('buildThemeContractCss', () => {
expect(css).toContain("@import './tokens.css';")
expect(css).toContain('/* Runtime Theme Inputs */')
expect(css).toContain('--cs-theme-primary: var(--cs-primary);')
expect(css).toContain('--cs-theme-ring: color-mix(in srgb, var(--cs-theme-primary) 40%, transparent);')
expect(css).not.toContain('--cs-user-font-family:')
expect(css).not.toContain('--cs-user-code-font-family:')
expect(css).toContain('/* Compatibility Aliases */')
expect(css).toContain('--primary: var(--color-primary);')
expect(css).toContain('--ring: var(--color-ring);')
expect(css).toContain('--color-neutral-50: var(--cs-neutral-50);')
expect(css).toContain('--color-brand-500: var(--cs-brand-500);')
expect(css).toContain('/* Semantic Colors */')
expect(css).toContain('--color-primary: var(--cs-theme-primary);')
expect(css).toContain('--color-ring: var(--cs-theme-ring);')
expect(css).not.toContain('--color-ring: var(--cs-ring);')
expect(css).toContain('--color-destructive: var(--cs-destructive);')
expect(css).toContain('--color-error-base: var(--cs-error-base);')
expect(css).toContain('--radius-md: var(--cs-radius-md);')

View File

@@ -10,17 +10,17 @@ const THEME_OUTPUT_PATH = path.join(STYLES_DIR, 'theme.css')
const RUNTIME_THEME_INPUT_LINES = [
'--cs-theme-primary: var(--cs-primary);',
'--cs-user-font-family: initial;',
'--cs-user-code-font-family: initial;'
'--cs-theme-ring: color-mix(in srgb, var(--cs-theme-primary) 40%, transparent);'
]
const COMPATIBILITY_ALIAS_LINES = ['--primary: var(--color-primary);']
const COMPATIBILITY_ALIAS_LINES = ['--primary: var(--color-primary);', '--ring: var(--color-ring);']
const PRIMARY_SEMANTIC_LINES = [
'--color-primary: var(--cs-theme-primary);',
'--color-primary-hover: var(--cs-primary-hover);',
'--color-primary-soft: color-mix(in srgb, var(--color-primary) 60%, transparent);',
'--color-primary-mute: color-mix(in srgb, var(--color-primary) 30%, transparent);'
'--color-primary-mute: color-mix(in srgb, var(--color-primary) 30%, transparent);',
'--color-ring: var(--cs-theme-ring);'
]
const SPACING_COMMENT_LINES = [
@@ -75,7 +75,7 @@ function buildSection(title: string, lines: string[]): string {
export function buildThemeContractCss(inputs: ThemeContractInputs): string {
const semanticContractTokens = inputs.semanticColors.filter(
(token) => token !== 'primary' && token !== 'primary-hover'
(token) => !['primary', 'primary-hover', 'ring'].includes(token)
)
const sections = [

View File

@@ -100,12 +100,12 @@ export function generateAvatar(opts: { outPath: string; colorName: string; varia
const sf = project.createSourceFile('avatar.tsx', '', { overwrite: true })
sf.addImportDeclaration({
moduleSpecifier: '../../../../lib/utils',
moduleSpecifier: '@cherrystudio/ui/lib/utils',
namedImports: ['cn']
})
sf.addImportDeclaration({
moduleSpecifier: '../../../primitives/avatar',
moduleSpecifier: '@cherrystudio/ui/components/primitives/avatar',
namedImports: ['Avatar', 'AvatarFallback']
})

View File

@@ -7,6 +7,17 @@ import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap
import type { CodeEditorProps } from './types'
import { prepareCodeChanges } from './utils'
const codeEditorGutterTheme = EditorView.theme({
'.cm-gutters': {
backgroundColor: 'transparent',
borderRight: 'none',
color: 'var(--color-muted-foreground)'
},
'.cm-activeLineGutter': {
backgroundColor: 'transparent'
}
})
/**
* A code editor component based on CodeMirror.
* This is a wrapper of ReactCodeMirror.
@@ -92,6 +103,7 @@ const CodeEditor = ({
...(extensions ?? []),
...langExtensions,
...(wrapped ? [EditorView.lineWrapping] : []),
codeEditorGutterTheme,
saveKeymapExtension,
blurExtension,
heightListenerExtension

View File

@@ -1,6 +1,4 @@
import * as React from 'react'
import { Button } from '../../primitives/button'
import { Button } from '@cherrystudio/ui/components/primitives/button'
import {
Dialog,
DialogClose,
@@ -9,7 +7,8 @@ import {
DialogFooter,
DialogHeader,
DialogTitle
} from '../../primitives/dialog'
} from '@cherrystudio/ui/components/primitives/dialog'
import * as React from 'react'
interface ConfirmDialogProps {
/** Controls the open state of the dialog */

View File

@@ -0,0 +1,295 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest'
import type { ColumnDef } from '@tanstack/react-table'
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import type * as React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../../primitives/checkbox', () => ({
Checkbox: ({
checked,
disabled,
onCheckedChange,
...props
}: React.ComponentProps<'button'> & {
checked?: boolean | 'indeterminate'
onCheckedChange?: (checked: boolean) => void
}) => (
<button
type="button"
role="checkbox"
aria-checked={checked === 'indeterminate' ? 'mixed' : Boolean(checked)}
disabled={disabled}
onClick={() => onCheckedChange?.(!checked)}
{...props}
/>
)
}))
vi.mock('../../../primitives/radio-group', async () => {
const React = await import('react')
const RadioContext = React.createContext<{
value?: string
onValueChange?: (value: string) => void
}>({})
return {
RadioGroup: ({
value,
onValueChange,
children,
...props
}: React.ComponentProps<'div'> & {
value?: string
onValueChange?: (value: string) => void
}) => (
<RadioContext value={{ value, onValueChange }}>
<div role="radiogroup" {...props}>
{children}
</div>
</RadioContext>
),
RadioGroupItem: ({
value,
disabled,
...props
}: Omit<React.ComponentProps<'button'>, 'value'> & {
value: string
}) => {
const context = React.use(RadioContext)
return (
<button
type="button"
role="radio"
aria-checked={context.value === value}
disabled={disabled}
onClick={() => context.onValueChange?.(value)}
{...props}
/>
)
}
}
})
import { DataTable } from '../index'
type Person = {
id: string
name: string
role: string
locked?: boolean
}
const people: Person[] = [
{ id: '1', name: 'Ada', role: 'Engineer' },
{ id: '2', name: 'Grace', role: 'Scientist' },
{ id: '3', name: 'Linus', role: 'Maintainer', locked: true }
]
const columns: ColumnDef<Person>[] = [
{
accessorKey: 'name',
header: 'Name',
meta: { width: 180, maxWidth: 180 }
},
{
accessorKey: 'role',
header: 'Role',
cell: ({ row }) => <span>{row.original.role}</span>
}
]
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('DataTable', () => {
it('renders dynamic columns and cells', () => {
render(<DataTable data={people} columns={columns} rowKey="id" />)
expect(screen.getByRole('columnheader', { name: 'Name' })).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: 'Role' })).toBeInTheDocument()
expect(screen.getByText('Ada')).toBeInTheDocument()
expect(screen.getByText('Scientist')).toBeInTheDocument()
})
it('renders header slots', () => {
render(
<DataTable
data={people}
columns={columns}
rowKey="id"
headerLeft={<button type="button">Left action</button>}
headerRight={<button type="button">Right action</button>}
/>
)
expect(screen.getByRole('button', { name: 'Left action' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Right action' })).toBeInTheDocument()
})
it('uses full parent width with an optional max width', () => {
render(<DataTable data={people} columns={columns} rowKey="id" maxWidth={480} />)
const root = screen.getByRole('table').closest('[data-slot="data-table"]')
expect(root).toHaveClass('w-full', 'max-w-full')
expect(root).toHaveStyle({ maxWidth: '480px' })
})
it('applies explicit column widths and leaves unspecified columns fluid', () => {
render(<DataTable data={people} columns={columns} rowKey="id" />)
expect(screen.getByRole('columnheader', { name: 'Name' })).toHaveStyle({ width: '180px' })
expect(screen.getByRole('columnheader', { name: 'Name' })).toHaveStyle({ maxWidth: '180px' })
expect(screen.getByRole('cell', { name: 'Ada' })).toHaveStyle({ width: '180px' })
expect(screen.getByRole('cell', { name: 'Ada' })).toHaveStyle({ maxWidth: '180px' })
expect(screen.getByRole('columnheader', { name: 'Role' })).not.toHaveStyle({ width: '180px' })
expect(screen.getByRole('columnheader', { name: 'Role' }).style.width).toBe('')
})
it('bounds long cell and expanded row content to the table width', () => {
const longCellText = 'Navigate to a URL and optionally fetch page content. '.repeat(4).trim()
const longExpandedText = 'Expanded schema description '.repeat(8).trim()
render(
<DataTable
data={[{ id: 'long-row', name: 'open', role: longCellText }]}
columns={columns}
rowKey="id"
expandedRowKeys={['long-row']}
renderExpandedRow={() => <div>{longExpandedText}</div>}
/>
)
const longCell = screen.getByText(longCellText).closest('td')
expect(longCell).toHaveClass('min-w-0', 'max-w-full', 'whitespace-normal', 'break-words')
expect(longCell?.className).toContain('[overflow-wrap:anywhere]')
const expandedCell = screen.getByText(longExpandedText).closest('td')
expect(expandedCell).toHaveClass('max-w-full', 'whitespace-normal', 'break-words')
const expandedContent = expandedCell?.querySelector('div[class*="overflow-hidden"]')
expect(expandedContent).toHaveClass('w-full', 'max-w-full', 'overflow-hidden', 'whitespace-normal', 'break-words')
expect(expandedContent?.className).toContain('[overflow-wrap:anywhere]')
expect(expandedContent?.className).toContain('[&_table]:table-fixed')
})
it('supports multiple row selection and disabled rows', () => {
const onChange = vi.fn()
render(
<DataTable
data={people}
columns={columns}
rowKey="id"
selection={{
type: 'multiple',
selectedRowKeys: ['1'],
onChange,
getCheckboxProps: (person) => ({ disabled: person.locked })
}}
/>
)
const graceRow = screen.getByText('Grace').closest('tr')
expect(graceRow).not.toBeNull()
fireEvent.click(within(graceRow as HTMLTableRowElement).getByRole('checkbox'))
expect(onChange).toHaveBeenCalledWith(['1', '2'], [people[0], people[1]])
const lockedRow = screen.getByText('Linus').closest('tr')
expect(lockedRow).not.toBeNull()
expect(within(lockedRow as HTMLTableRowElement).getByRole('checkbox')).toBeDisabled()
})
it('supports select all for multiple selection', () => {
const onChange = vi.fn()
render(
<DataTable
data={people}
columns={columns}
rowKey="id"
selection={{
type: 'multiple',
selectedRowKeys: [],
onChange,
getCheckboxProps: (person) => ({ disabled: person.locked })
}}
/>
)
fireEvent.click(screen.getByRole('checkbox', { name: 'Select all rows' }))
expect(onChange).toHaveBeenCalledWith(['1', '2'], [people[0], people[1]])
})
it('supports single row selection', () => {
const onChange = vi.fn()
render(
<DataTable
data={people}
columns={columns}
rowKey="id"
selection={{
type: 'single',
selectedRowKey: null,
onChange
}}
/>
)
const graceRow = screen.getByText('Grace').closest('tr')
expect(graceRow).not.toBeNull()
fireEvent.click(within(graceRow as HTMLTableRowElement).getByRole('radio'))
expect(onChange).toHaveBeenCalledWith('2', people[1])
})
it('tracks one selected row for single selection', () => {
render(
<DataTable
data={people}
columns={columns}
rowKey="id"
selection={{
type: 'single',
selectedRowKey: '1',
onChange: vi.fn()
}}
/>
)
const radios = screen.getAllByRole('radio')
expect(radios[0]).toHaveAttribute('aria-checked', 'true')
expect(radios[1]).toHaveAttribute('aria-checked', 'false')
})
it('renders empty text', () => {
render(<DataTable data={[]} columns={columns} rowKey="id" emptyText="No people" />)
expect(screen.getByText('No people')).toBeInTheDocument()
})
it('supports controlled expanded rows', () => {
const onExpandedRowChange = vi.fn()
render(
<DataTable
data={people}
columns={columns}
rowKey="id"
expandedRowKeys={['1']}
onExpandedRowChange={onExpandedRowChange}
renderExpandedRow={(person) => <div>Details for {person.name}</div>}
/>
)
expect(screen.getByText('Details for Ada')).toBeInTheDocument()
const graceRow = screen.getByText('Grace').closest('tr')
expect(graceRow).not.toBeNull()
fireEvent.click(within(graceRow as HTMLTableRowElement).getByRole('button', { name: 'Expand row' }))
expect(onExpandedRowChange).toHaveBeenCalledWith(['1', '2'])
})
})

View File

@@ -0,0 +1,482 @@
import { Checkbox } from '@cherrystudio/ui/components/primitives/checkbox'
import { RadioGroup, RadioGroupItem } from '@cherrystudio/ui/components/primitives/radio-group'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@cherrystudio/ui/components/primitives/table'
import { cn } from '@cherrystudio/ui/lib/utils'
import {
type Cell,
type ColumnDef,
flexRender,
getCoreRowModel,
type RowSelectionState,
type Updater,
useReactTable
} from '@tanstack/react-table'
import { ChevronRight } from 'lucide-react'
import * as React from 'react'
export type DataTableKey = React.Key
export type DataTableColumnMeta = {
className?: string
headerClassName?: string
width?: number | string
maxWidth?: number | string
align?: 'left' | 'center' | 'right'
}
type DataTableSelectionBase<TData> = {
getCheckboxProps?: (record: TData) => {
disabled?: boolean
ariaLabel?: string
}
columnWidth?: number | string
}
export type DataTableSelection<TData> =
| (DataTableSelectionBase<TData> & {
type: 'single'
selectedRowKey: DataTableKey | null
onChange: (selectedRowKey: DataTableKey | null, selectedRow: TData | null) => void
})
| (DataTableSelectionBase<TData> & {
type: 'multiple'
selectedRowKeys: DataTableKey[]
onChange: (selectedRowKeys: DataTableKey[], selectedRows: TData[]) => void
})
export type DataTableProps<TData> = {
data: TData[]
columns: ColumnDef<TData, unknown>[]
rowKey: keyof TData | ((record: TData) => DataTableKey)
selection?: DataTableSelection<TData>
headerLeft?: React.ReactNode
headerRight?: React.ReactNode
emptyText?: React.ReactNode
maxHeight?: number | string
maxWidth?: number | string
tableLayout?: 'auto' | 'fixed'
rowClassName?: string | ((record: TData, index: number) => string)
onRowClick?: (record: TData, index: number) => void
renderExpandedRow?: (record: TData, index: number) => React.ReactNode
getCanExpand?: (record: TData) => boolean
expandedRowKeys?: DataTableKey[]
onExpandedRowChange?: (expandedRowKeys: DataTableKey[]) => void
className?: string
}
const normalizeKey = (key: DataTableKey) => String(key)
const toCssSize = (value: number | string | undefined) => (typeof value === 'number' ? `${value}px` : value)
const contentContainmentClassName = 'min-w-0 max-w-full whitespace-normal break-words [overflow-wrap:anywhere]'
const tableShellClassName =
'w-full max-w-full overflow-hidden rounded-lg border border-border/70 bg-background-subtle shadow-xs'
const tableScrollAreaClassName = 'w-full max-w-full overflow-x-auto'
const tableHeaderClassName = 'sticky top-0 z-1 bg-background-subtle/70'
const tableHeaderRowClassName = 'border-border/70 bg-background-subtle/70 hover:bg-transparent'
const tableHeaderCellClassName =
'h-10 bg-background-subtle/70 px-3 py-2 font-semibold text-muted-foreground text-xs leading-4'
const tableBodyRowClassName =
'border-border/60 bg-background-subtle hover:bg-muted/40 data-[state=selected]:bg-muted/60'
const tableBodyCellClassName = 'px-3 py-2.5 text-sm font-medium leading-5 text-foreground'
const tableExpandedCellClassName = 'bg-muted/20 px-3 py-2.5 text-sm font-medium leading-5 text-foreground'
const tableEmptyCellClassName = 'h-24 px-3 py-6 text-center text-sm font-medium text-foreground-muted'
const tableExpandButtonClassName =
'flex size-6 items-center justify-center rounded-md text-foreground-muted transition-colors hover:bg-accent/70 hover:text-foreground'
function getColumnMeta<TData>(cell: Cell<TData, unknown>): DataTableColumnMeta | undefined {
return cell.column.columnDef.meta as DataTableColumnMeta | undefined
}
function getHeaderMeta<TData>(columnDef: ColumnDef<TData, unknown>): DataTableColumnMeta | undefined {
return columnDef.meta as DataTableColumnMeta | undefined
}
function getAlignClass(align?: DataTableColumnMeta['align']) {
if (align === 'center') return 'text-center'
if (align === 'right') return 'text-right'
return undefined
}
function getColumnStyle(meta?: DataTableColumnMeta): React.CSSProperties | undefined {
if (!meta?.width && !meta?.maxWidth) {
return undefined
}
return {
width: toCssSize(meta.width),
maxWidth: toCssSize(meta.maxWidth)
}
}
function DataTable<TData>({
data,
columns,
rowKey,
selection,
headerLeft,
headerRight,
emptyText = 'No results.',
maxHeight,
maxWidth,
tableLayout = 'auto',
rowClassName,
onRowClick,
renderExpandedRow,
getCanExpand,
expandedRowKeys = [],
onExpandedRowChange,
className
}: DataTableProps<TData>) {
const getRecordKey = React.useCallback(
(record: TData): DataTableKey => {
if (typeof rowKey === 'function') {
return rowKey(record)
}
return record[rowKey] as DataTableKey
},
[rowKey]
)
const rowById = React.useMemo(() => {
const map = new Map<string, { key: DataTableKey; record: TData }>()
data.forEach((record) => {
const key = getRecordKey(record)
map.set(normalizeKey(key), { key, record })
})
return map
}, [data, getRecordKey])
const selectedRowIds = React.useMemo<RowSelectionState>(() => {
if (!selection) {
return {}
}
const selectedRowKeys =
selection.type === 'single'
? selection.selectedRowKey === null
? []
: [selection.selectedRowKey]
: selection.selectedRowKeys
return selectedRowKeys.reduce<RowSelectionState>((acc, key) => {
acc[normalizeKey(key)] = true
return acc
}, {})
}, [selection])
const emitSelectionChange = React.useCallback(
(nextSelection: RowSelectionState) => {
if (!selection) {
return
}
const selected = Object.keys(nextSelection)
.filter((id) => nextSelection[id])
.map((id) => rowById.get(id))
.filter((entry): entry is { key: DataTableKey; record: TData } => Boolean(entry))
const normalizedSelected = selection.type === 'single' ? selected.slice(-1) : selected
if (selection.type === 'single') {
const selectedEntry = normalizedSelected[0]
selection.onChange(selectedEntry?.key ?? null, selectedEntry?.record ?? null)
return
}
selection.onChange(
normalizedSelected.map((entry) => entry.key),
normalizedSelected.map((entry) => entry.record)
)
},
[rowById, selection]
)
const handleRowSelectionChange = React.useCallback(
(updater: Updater<RowSelectionState>) => {
const next = typeof updater === 'function' ? updater(selectedRowIds) : updater
emitSelectionChange(next)
},
[emitSelectionChange, selectedRowIds]
)
const selectionColumn = React.useMemo<ColumnDef<TData> | null>(() => {
if (!selection) {
return null
}
const width = selection.columnWidth ?? 44
if (selection.type === 'single') {
return {
id: '__selection',
size: typeof width === 'number' ? width : undefined,
header: '',
cell: ({ row }) => {
const checkboxProps = selection.getCheckboxProps?.(row.original)
return (
<div className="flex items-center justify-center">
<RadioGroupItem
value={row.id}
disabled={!row.getCanSelect() || checkboxProps?.disabled}
aria-label={checkboxProps?.ariaLabel ?? 'Select row'}
size="sm"
/>
</div>
)
},
enableSorting: false,
enableHiding: false,
meta: { width, maxWidth: width, align: 'center' } satisfies DataTableColumnMeta
}
}
return {
id: '__selection',
size: typeof width === 'number' ? width : undefined,
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
size="sm"
checked={table.getIsAllRowsSelected() ? true : table.getIsSomeRowsSelected() ? 'indeterminate' : false}
onCheckedChange={(checked) => table.toggleAllRowsSelected(Boolean(checked))}
aria-label="Select all rows"
/>
</div>
),
cell: ({ row }) => {
const checkboxProps = selection.getCheckboxProps?.(row.original)
return (
<div className="flex items-center justify-center">
<Checkbox
size="sm"
checked={row.getIsSelected()}
disabled={!row.getCanSelect() || checkboxProps?.disabled}
onCheckedChange={(checked) => row.toggleSelected(Boolean(checked))}
aria-label={checkboxProps?.ariaLabel ?? 'Select row'}
/>
</div>
)
},
enableSorting: false,
enableHiding: false,
meta: { width, maxWidth: width, align: 'center' } satisfies DataTableColumnMeta
}
}, [selection])
const expandedRowIdSet = React.useMemo(
() => new Set(expandedRowKeys.map((key) => normalizeKey(key))),
[expandedRowKeys]
)
const toggleExpandedRow = React.useCallback(
(record: TData) => {
if (!onExpandedRowChange) {
return
}
const key = getRecordKey(record)
const id = normalizeKey(key)
const next = new Set(expandedRowIdSet)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
const orderedKeys = data.map((item) => getRecordKey(item)).filter((itemKey) => next.has(normalizeKey(itemKey)))
onExpandedRowChange(orderedKeys)
},
[data, expandedRowIdSet, getRecordKey, onExpandedRowChange]
)
const expandColumn = React.useMemo<ColumnDef<TData> | null>(() => {
if (!renderExpandedRow) {
return null
}
return {
id: '__expand',
size: 36,
header: '',
cell: ({ row }) => {
const canExpand = getCanExpand ? getCanExpand(row.original) : true
const isExpanded = expandedRowIdSet.has(row.id)
if (!canExpand) {
return null
}
return (
<button
type="button"
className={tableExpandButtonClassName}
aria-label={isExpanded ? 'Collapse row' : 'Expand row'}
aria-expanded={isExpanded}
onClick={(event) => {
event.stopPropagation()
toggleExpandedRow(row.original)
}}>
<ChevronRight className={cn('size-4 transition-transform', isExpanded && 'rotate-90')} />
</button>
)
},
enableSorting: false,
enableHiding: false,
meta: { width: 36, maxWidth: 36, align: 'center' } satisfies DataTableColumnMeta
}
}, [expandedRowIdSet, getCanExpand, renderExpandedRow, toggleExpandedRow])
const tableColumns = React.useMemo<ColumnDef<TData>[]>(
() => [selectionColumn, expandColumn, ...columns].filter((column): column is ColumnDef<TData> => Boolean(column)),
[columns, expandColumn, selectionColumn]
)
const table = useReactTable({
data,
columns: tableColumns,
getRowId: (record) => normalizeKey(getRecordKey(record)),
getCoreRowModel: getCoreRowModel(),
state: {
rowSelection: selectedRowIds
},
enableRowSelection: (row) => !selection?.getCheckboxProps?.(row.original)?.disabled,
enableMultiRowSelection: selection?.type === 'multiple',
onRowSelectionChange: handleRowSelectionChange
})
const visibleColumnCount = table.getVisibleFlatColumns().length
const hasToolbar = Boolean(headerLeft || headerRight)
const tableElement = (
<div data-slot="data-table-shell" className={cn(tableShellClassName, className)}>
<div
style={{ maxHeight: toCssSize(maxHeight) }}
className={cn(tableScrollAreaClassName, maxHeight && 'overflow-y-auto')}>
<Table className="max-w-full" style={{ tableLayout }}>
<TableHeader className={tableHeaderClassName}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className={tableHeaderRowClassName}>
{headerGroup.headers.map((header) => {
const meta = getHeaderMeta(header.column.columnDef)
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={cn(
contentContainmentClassName,
tableHeaderCellClassName,
getAlignClass(meta?.align),
meta?.headerClassName
)}
style={getColumnStyle(meta)}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row, index) => {
const key = getRecordKey(row.original)
const isExpanded = expandedRowIdSet.has(normalizeKey(key))
const customRowClassName =
typeof rowClassName === 'function' ? rowClassName(row.original, index) : rowClassName
return (
<React.Fragment key={row.id}>
<TableRow
data-state={row.getIsSelected() ? 'selected' : undefined}
className={cn(tableBodyRowClassName, onRowClick && 'cursor-pointer', customRowClassName)}
onClick={() => onRowClick?.(row.original, index)}>
{row.getVisibleCells().map((cell) => {
const meta = getColumnMeta(cell)
return (
<TableCell
key={cell.id}
className={cn(
contentContainmentClassName,
tableBodyCellClassName,
getAlignClass(meta?.align),
meta?.className
)}
style={getColumnStyle(meta)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
)
})}
</TableRow>
{renderExpandedRow && isExpanded && (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={visibleColumnCount}
className={cn(tableExpandedCellClassName, contentContainmentClassName)}>
<div
className={cn(
'w-full overflow-hidden',
contentContainmentClassName,
'[&_table]:w-full [&_table]:table-fixed [&_td]:whitespace-normal [&_th]:whitespace-normal'
)}>
{renderExpandedRow(row.original, index)}
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
)
})
) : (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={visibleColumnCount || 1} className={tableEmptyCellClassName}>
{emptyText}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)
return (
<div
data-slot="data-table"
className="flex w-full max-w-full flex-col gap-2"
style={{ maxWidth: toCssSize(maxWidth) }}>
{hasToolbar && (
<div className="flex min-h-8 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">{headerLeft}</div>
<div className="flex min-w-0 items-center justify-end gap-2">{headerRight}</div>
</div>
)}
{selection?.type === 'single' ? (
<RadioGroup
value={selection.selectedRowKey === null ? '' : normalizeKey(selection.selectedRowKey)}
onValueChange={(value) => {
const selected = rowById.get(value)
selection.onChange(selected?.key ?? null, selected?.record ?? null)
}}
className="block">
{tableElement}
</RadioGroup>
) : (
tableElement
)}
</div>
)
}
export { DataTable }
export type { ColumnDef }

View File

@@ -0,0 +1,74 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { DateTimePicker } from '../index'
beforeAll(() => {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as any
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('DateTimePicker', () => {
it('formats the selected value in the trigger', () => {
render(
<DateTimePicker
defaultValue={new Date(2026, 3, 29, 14, 5, 9)}
format="yyyy-MM-dd HH:mm:ss"
placeholder="Pick date"
/>
)
expect(screen.getByRole('button')).toHaveTextContent('2026-04-29 14:05:09')
})
it('updates hour, minute and second when granularity is second', () => {
const onChange = vi.fn()
function ControlledPicker() {
const [value, setValue] = useState(new Date(2026, 3, 29, 14, 5, 9))
return (
<DateTimePicker
value={value}
granularity="second"
open
onOpenChange={() => {}}
onChange={(date) => {
if (date) setValue(date)
onChange(date)
}}
/>
)
}
render(<ControlledPicker />)
fireEvent.change(screen.getByLabelText('Hour'), { target: { value: '08' } })
fireEvent.change(screen.getByLabelText('Minute'), { target: { value: '30' } })
fireEvent.change(screen.getByLabelText('Second'), { target: { value: '45' } })
const lastCall = onChange.mock.calls.at(-1)?.[0] as Date
expect(lastCall.getHours()).toBe(8)
expect(lastCall.getMinutes()).toBe(30)
expect(lastCall.getSeconds()).toBe(45)
})
it('hides time controls when granularity is day', () => {
render(<DateTimePicker defaultValue={new Date(2026, 3, 29)} granularity="day" open onOpenChange={() => {}} />)
expect(screen.queryByLabelText('Hour')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Minute')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Second')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,289 @@
import { Button } from '@cherrystudio/ui/components/primitives/button'
import { Calendar, type CalendarProps } from '@cherrystudio/ui/components/primitives/calendar'
import { Input } from '@cherrystudio/ui/components/primitives/input'
import { Popover, PopoverContent, PopoverTrigger } from '@cherrystudio/ui/components/primitives/popover'
import { cn } from '@cherrystudio/ui/lib/utils'
import { format as formatDate } from 'date-fns'
import { CalendarIcon } from 'lucide-react'
import * as React from 'react'
export type DateTimeGranularity = 'day' | 'hour' | 'minute' | 'second'
export type DateTimePickerLabels = {
hour?: string
minute?: string
second?: string
}
type DateTimePickerValueProps =
| {
value: Date | null | undefined
onChange: (date: Date | undefined) => void
defaultValue?: never
}
| {
value?: never
defaultValue?: Date | null
onChange?: (date: Date | undefined) => void
}
type DateTimePickerOpenProps =
| {
open: boolean | undefined
onOpenChange: (open: boolean) => void
defaultOpen?: never
}
| {
open?: never
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
type DateTimePickerBaseProps = {
granularity?: DateTimeGranularity
format?: string
placeholder?: React.ReactNode
disabled?: boolean
className?: string
triggerClassName?: string
popoverClassName?: string
calendarProps?: Omit<CalendarProps, 'mode' | 'selected' | 'onSelect' | 'month' | 'onMonthChange'>
labels?: DateTimePickerLabels
}
export type DateTimePickerProps = DateTimePickerBaseProps & DateTimePickerValueProps & DateTimePickerOpenProps
const defaultLabels = {
hour: 'Hour',
minute: 'Minute',
second: 'Second'
} satisfies Required<DateTimePickerLabels>
const defaultFormatByGranularity: Record<DateTimeGranularity, string> = {
day: 'yyyy-MM-dd',
hour: 'yyyy-MM-dd HH',
minute: 'yyyy-MM-dd HH:mm',
second: 'yyyy-MM-dd HH:mm:ss'
}
function DateTimePicker({
value,
defaultValue,
onChange,
open,
defaultOpen,
onOpenChange,
granularity = 'day',
format,
placeholder = 'Pick a date',
disabled,
className,
triggerClassName,
popoverClassName,
calendarProps,
labels
}: DateTimePickerProps) {
const isValueControlled = value !== undefined
const [internalValue, setInternalValue] = React.useState<Date | undefined>(() => normalizeDate(defaultValue))
const selectedDate = isValueControlled ? normalizeDate(value) : internalValue
const [month, setMonth] = React.useState<Date>(() => {
const initialDate = normalizeDate(value) ?? normalizeDate(defaultValue) ?? new Date()
return getMonthDate(initialDate)
})
const isOpenControlled = open !== undefined
const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? false)
const pickerOpen = isOpenControlled ? open : internalOpen
const mergedLabels = { ...defaultLabels, ...labels }
const selectedYear = selectedDate?.getFullYear()
const selectedMonth = selectedDate?.getMonth()
React.useEffect(() => {
if (selectedYear === undefined || selectedMonth === undefined) return
setMonth(new Date(selectedYear, selectedMonth))
}, [selectedMonth, selectedYear])
const setPickerOpen = React.useCallback(
(nextOpen: boolean) => {
if (!isOpenControlled) setInternalOpen(nextOpen)
onOpenChange?.(nextOpen)
},
[isOpenControlled, onOpenChange]
)
const commitDate = React.useCallback(
(nextDate: Date | undefined) => {
if (!isValueControlled) setInternalValue(nextDate)
onChange?.(nextDate)
},
[isValueControlled, onChange]
)
const handleSelectDate = React.useCallback(
(date: Date | undefined) => {
if (!date) {
commitDate(undefined)
return
}
const nextDate = mergeDatePart(date, selectedDate)
setMonth(getMonthDate(nextDate))
commitDate(nextDate)
if (granularity === 'day') setPickerOpen(false)
},
[commitDate, granularity, selectedDate, setPickerOpen]
)
const handleTimePartChange = React.useCallback(
(part: 'hours' | 'minutes' | 'seconds', rawValue: string) => {
const nextDate = selectedDate ? new Date(selectedDate) : new Date()
const max = part === 'hours' ? 23 : 59
const nextValue = clampTimeValue(rawValue, max)
if (part === 'hours') nextDate.setHours(nextValue)
if (part === 'minutes') nextDate.setMinutes(nextValue)
if (part === 'seconds') nextDate.setSeconds(nextValue)
commitDate(nextDate)
},
[commitDate, selectedDate]
)
const formattedValue = selectedDate
? safeFormatDate(selectedDate, format ?? defaultFormatByGranularity[granularity])
: null
return (
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled}
data-empty={!formattedValue}
className={cn(
'h-9 w-[240px] justify-start text-left font-normal data-[empty=true]:text-muted-foreground',
triggerClassName
)}>
<CalendarIcon className="size-4" />
<span className="truncate">{formattedValue ?? placeholder}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className={cn('w-auto p-0', popoverClassName)}>
<div className={cn('flex flex-col', className)}>
<Calendar
{...calendarProps}
mode="single"
selected={selectedDate}
onSelect={handleSelectDate}
month={month}
onMonthChange={setMonth}
disabled={disabled || calendarProps?.disabled}
captionLayout={calendarProps?.captionLayout ?? 'dropdown'}
hideNavigation={calendarProps?.hideNavigation ?? true}
startMonth={calendarProps?.startMonth ?? new Date(1900, 0)}
endMonth={calendarProps?.endMonth ?? new Date(2100, 11)}
/>
{granularity !== 'day' && (
<div className="grid grid-cols-3 gap-2 border-border border-t p-3">
<TimeInput
label={mergedLabels.hour}
value={selectedDate?.getHours() ?? 0}
max={23}
disabled={disabled}
onChange={(nextValue) => handleTimePartChange('hours', nextValue)}
/>
{(granularity === 'minute' || granularity === 'second') && (
<TimeInput
label={mergedLabels.minute}
value={selectedDate?.getMinutes() ?? 0}
max={59}
disabled={disabled}
onChange={(nextValue) => handleTimePartChange('minutes', nextValue)}
/>
)}
{granularity === 'second' && (
<TimeInput
label={mergedLabels.second}
value={selectedDate?.getSeconds() ?? 0}
max={59}
disabled={disabled}
onChange={(nextValue) => handleTimePartChange('seconds', nextValue)}
/>
)}
</div>
)}
</div>
</PopoverContent>
</Popover>
)
}
function TimeInput({
label,
value,
max,
disabled,
onChange
}: {
label: string
value: number
max: number
disabled?: boolean
onChange: (value: string) => void
}) {
return (
<label className="grid gap-1">
<span className="sr-only">{label}</span>
<Input
aria-label={label}
type="number"
inputMode="numeric"
min={0}
max={max}
value={padTimeValue(value)}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
className="h-8 px-2 text-center font-mono text-sm"
/>
</label>
)
}
function normalizeDate(date: Date | null | undefined) {
return date instanceof Date && !Number.isNaN(date.getTime()) ? date : undefined
}
function getMonthDate(date: Date) {
return new Date(date.getFullYear(), date.getMonth())
}
function mergeDatePart(date: Date, current: Date | undefined) {
const nextDate = new Date(date)
if (current) {
nextDate.setHours(current.getHours(), current.getMinutes(), current.getSeconds(), current.getMilliseconds())
}
return nextDate
}
function clampTimeValue(value: string, max: number) {
const parsed = Number.parseInt(value, 10)
if (Number.isNaN(parsed)) return 0
return Math.min(Math.max(parsed, 0), max)
}
function padTimeValue(value: number) {
return String(value).padStart(2, '0')
}
function safeFormatDate(date: Date, format: string) {
try {
return formatDate(date, format)
} catch {
return date.toLocaleString()
}
}
export { DateTimePicker }

View File

@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import EditableNumber from '../index'
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('EditableNumber', () => {
it('clamps and rounds committed values', () => {
const onChange = vi.fn()
render(<EditableNumber value={1} min={0} max={10} precision={1} onChange={onChange} />)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '20.24' } })
expect(onChange).toHaveBeenCalledWith(10)
})
it('defers changes until blur when changeOnBlur is enabled', () => {
const onChange = vi.fn()
render(<EditableNumber value={1} precision={1} changeOnBlur onChange={onChange} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '2.26' } })
expect(onChange).not.toHaveBeenCalled()
fireEvent.blur(input)
expect(onChange).toHaveBeenCalledWith(2.3)
})
it('reverts the draft value with Escape', () => {
const onChange = vi.fn()
render(<EditableNumber value={4} changeOnBlur onChange={onChange} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '9' } })
fireEvent.keyDown(input, { key: 'Escape' })
expect(input).toHaveValue(4)
expect(onChange).not.toHaveBeenCalled()
})
})

View File

@@ -91,6 +91,7 @@ const EditableNumber: React.FC<EditableNumberProps> = ({
}) => {
const [isEditing, setIsEditing] = React.useState(false)
const [inputValue, setInputValue] = React.useState(() => toInputValue(value, precision))
const inputRef = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
if (!isEditing) {
@@ -114,6 +115,12 @@ const EditableNumber: React.FC<EditableNumberProps> = ({
setIsEditing(true)
}
React.useEffect(() => {
if (isEditing) {
inputRef.current?.focus()
}
}, [isEditing])
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const nextValue = event.target.value
setInputValue(nextValue)
@@ -145,10 +152,29 @@ const EditableNumber: React.FC<EditableNumberProps> = ({
}
const displayValue = formatter ? formatter(value ?? null) : (value ?? placeholder)
const shouldRenderDisplayValue = Boolean(formatter || prefix || suffix)
const inputAlignClass = align === 'start' ? 'text-left' : align === 'center' ? 'text-center' : 'text-right'
const inputClassName = cn(
'border-input bg-background w-full rounded-md border px-3 text-sm shadow-xs outline-none transition-[color,box-shadow] [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
sizeClasses[size],
inputAlignClass,
shouldRenderDisplayValue && !isEditing && 'hidden',
className
)
const handleDisplayKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleFocus()
}
}
return (
<div className="relative inline-block">
<input
ref={inputRef}
type="number"
value={inputValue}
min={min}
@@ -159,34 +185,30 @@ const EditableNumber: React.FC<EditableNumberProps> = ({
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
className={cn(
'border-input bg-background w-full rounded-md border px-3 text-sm shadow-xs outline-none transition-[color,box-shadow] [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
sizeClasses[size],
align === 'start' ? 'text-left' : align === 'center' ? 'text-center' : 'text-right',
className
)}
style={{
...style,
opacity: isEditing ? 1 : 0
}}
className={inputClassName}
style={style}
/>
<div
className={cn(
'pointer-events-none absolute inset-0 flex items-center px-3',
!isEditing ? 'flex' : 'hidden',
alignClasses[align],
sizeClasses[size],
className
)}
style={style}>
<span className="truncate">
{prefix}
{displayValue}
{suffix}
</span>
</div>
{shouldRenderDisplayValue && !isEditing && (
<div
className={cn(
'border-input bg-background flex w-full cursor-text items-center rounded-md border px-3 text-sm shadow-xs outline-none transition-[color,box-shadow]',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
disabled && 'pointer-events-none cursor-not-allowed opacity-50',
alignClasses[align],
sizeClasses[size],
className
)}
onClick={handleFocus}
onKeyDown={handleDisplayKeyDown}
tabIndex={disabled ? -1 : 0}
style={style}>
<span className="truncate">
{prefix}
{displayValue}
{suffix}
</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,54 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { afterEach, describe, expect, it } from 'vitest'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '../index'
afterEach(() => {
cleanup()
})
describe('Form', () => {
it('wires aria-invalid and message ids when a field has an error', async () => {
function FormFixture() {
const form = useForm({ defaultValues: { name: '' } })
useEffect(() => {
form.setError('name', { message: 'Name is required' })
}, [form])
return (
<Form {...form}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<input {...field} />
</FormControl>
<FormDescription>Visible to teammates.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</Form>
)
}
render(<FormFixture />)
const input = await screen.findByLabelText('Name')
const description = screen.getByText('Visible to teammates.')
const message = screen.getByText('Name is required')
expect(input).toHaveAttribute('aria-invalid', 'true')
expect(input.getAttribute('aria-describedby')).toContain(description.id)
expect(input.getAttribute('aria-describedby')).toContain(message.id)
})
})

View File

@@ -0,0 +1,141 @@
'use client'
import { Label } from '@cherrystudio/ui/components/primitives/label'
import { cn } from '@cherrystudio/ui/lib/utils'
import type * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import * as React from 'react'
import {
Controller,
type ControllerProps,
type FieldPath,
type FieldValues,
FormProvider,
useFormContext,
useFormState
} from 'react-hook-form'
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
function FormField<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
TTransformedValues = TFieldValues
>({ ...props }: ControllerProps<TFieldValues, TName, TTransformedValues>) {
return (
<FormFieldContext value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext>
)
}
const useFormField = () => {
const fieldContext = React.use(FormFieldContext)
const itemContext = React.use(FormItemContext)
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>')
}
if (!itemContext) {
throw new Error('useFormField should be used within <FormItem>')
}
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue | null>(null)
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId()
return (
<FormItemContext value={{ id }}>
<div data-slot="form-item" className={cn('grid gap-2', className)} {...props} />
</FormItemContext>
)
}
function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? '') : props.children
if (!body) {
return null
}
return (
<p data-slot="form-message" id={formMessageId} className={cn('text-destructive text-sm', className)} {...props}>
{body}
</p>
)
}
export { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useFormField }

View File

@@ -1,6 +1,6 @@
import { Tooltip } from '@cherrystudio/ui/components/primitives/tooltip'
import type { LucideIcon } from 'lucide-react'
import { Tooltip } from '../../primitives/tooltip'
import type { IconTooltipProps } from './types'
export interface BaseIconTooltipProps extends IconTooltipProps {

View File

@@ -1,7 +1,6 @@
import type { TooltipProps } from '@cherrystudio/ui/components/primitives/tooltip'
import type { LucideProps } from 'lucide-react'
import type { TooltipProps } from '../../primitives/tooltip'
export interface IconTooltipProps extends TooltipProps {
iconProps?: LucideProps
}

View File

@@ -1,9 +1,8 @@
// Original path: src/renderer/src/components/Preview/ImageToolButton.tsx
import { Button } from '@cherrystudio/ui/components/primitives/button'
import { Tooltip } from '@cherrystudio/ui/components/primitives/tooltip'
import { memo } from 'react'
import { Button } from '../../primitives/button'
import { Tooltip } from '../../primitives/tooltip'
interface ImageToolButtonProps {
tooltip: string
icon: React.ReactNode

View File

@@ -1,13 +1,10 @@
import { cn } from '@cherrystudio/ui/lib/utils'
import { toUndefinedIfNull } from '@cherrystudio/ui/utils/index'
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
import { Edit2Icon, EyeIcon, EyeOffIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { useCallback, useMemo, useState } from 'react'
import type { InputProps } from '../../primitives/input'
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '../../primitives/input-group'
import type { InputProps } from '@cherrystudio/ui/components/primitives/input'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from '@cherrystudio/ui/components/primitives/input-group'
import {
Select,
SelectContent,
@@ -16,12 +13,19 @@ import {
SelectLabel,
SelectTrigger,
SelectValue
} from '../../primitives/select'
} from '@cherrystudio/ui/components/primitives/select'
import { cn } from '@cherrystudio/ui/lib/utils'
import { toUndefinedIfNull } from '@cherrystudio/ui/utils/index'
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
import { Edit2Icon, EyeIcon, EyeOffIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { useCallback, useMemo, useState } from 'react'
const inputGroupVariants = cva(
[
'h-auto',
'rounded-xs',
'rounded-md',
'has-[[data-slot=input-group-control]:focus-visible]:ring-ring/40',
'has-[[data-slot=input-group-control]:focus-visible]:border-[#3CD45A]'
],

View File

@@ -24,7 +24,7 @@ const menuItemVariants = cva(
'text-foreground-secondary',
'hover:bg-accent hover:text-foreground',
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
'data-[active=true]:border-border'
'data-[active=true]:border-transparent'
),
ghost: cn(
'text-foreground-secondary',

View File

@@ -93,7 +93,7 @@ function PageSidePanel({
transition={{ type: 'spring', damping: 30, stiffness: 350 }}
data-slot="page-side-panel"
className={cn(
'absolute top-2 bottom-2 z-50 flex w-100 flex-col overflow-hidden rounded-xs border border-border/30 bg-card text-card-foreground shadow-2xl outline-none',
'absolute top-2 bottom-2 z-50 flex w-100 flex-col overflow-hidden rounded-md border border-border/30 bg-card text-card-foreground shadow-2xl outline-none',
side === 'right' ? 'right-2' : 'left-2',
contentClassName
)}>

View File

@@ -91,7 +91,7 @@ export function SelectDropdown<T extends { id: string }>({
return (
<div
className={cn(
'flex items-center gap-1 rounded-3xs pr-1 transition-colors',
'flex items-center gap-1 rounded-md pr-1 transition-colors',
isSelected && 'bg-primary/10 text-primary'
)}>
<button
@@ -100,14 +100,14 @@ export function SelectDropdown<T extends { id: string }>({
onSelect(item.id)
setOpen(false)
}}
className="flex min-w-0 flex-1 items-center gap-2 rounded-3xs px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent/60">
className="flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent/60">
{renderItem(item, isSelected)}
</button>
<button
type="button"
aria-label={removeLabel}
onClick={() => onRemove(item.id)}
className="shrink-0 rounded-3xs p-1 text-muted-foreground/30 transition-colors hover:bg-accent/60 hover:text-foreground">
className="shrink-0 rounded-md p-1 text-muted-foreground/30 transition-colors hover:bg-accent/60 hover:text-foreground">
<X size={10} />
</button>
</div>
@@ -121,7 +121,7 @@ export function SelectDropdown<T extends { id: string }>({
setOpen(false)
}}
className={cn(
'w-full rounded-3xs px-2.5 py-1.5 text-left text-xs transition-colors',
'w-full rounded-md px-2.5 py-1.5 text-left text-xs transition-colors',
isSelected ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-accent/60'
)}>
{renderItem(item, isSelected)}
@@ -135,7 +135,7 @@ export function SelectDropdown<T extends { id: string }>({
<button
type="button"
className={cn(
'flex w-full items-center justify-between rounded-3xs border bg-transparent px-2.5 py-1.5 text-xs transition-colors hover:bg-muted/30',
'flex w-full items-center justify-between rounded-md border bg-transparent px-2.5 py-1.5 text-xs transition-colors hover:bg-muted/30',
open ? 'border-primary/40 ring-1 ring-primary/15' : 'border-border/40'
)}>
<div className="flex min-w-0 flex-1 items-center gap-2 text-left">
@@ -155,7 +155,7 @@ export function SelectDropdown<T extends { id: string }>({
<PopoverContent
align="start"
sideOffset={4}
className="w-(--radix-popover-trigger-width) rounded-3xs border border-border/40 bg-popover p-1 shadow-lg">
className="w-(--radix-popover-trigger-width) rounded-md border border-border/40 bg-popover p-1 shadow-lg">
{items.length === 0 && emptyText ? (
<div className="px-2.5 py-3 text-muted-foreground/45 text-xs">{emptyText}</div>
) : virtualize ? (

View File

@@ -23,6 +23,19 @@ export {
// Composite Components
export { ConfirmDialog, type ConfirmDialogProps } from './composites/ConfirmDialog'
export {
type ColumnDef,
DataTable,
type DataTableColumnMeta,
type DataTableProps,
type DataTableSelection
} from './composites/DataTable'
export {
type DateTimeGranularity,
DateTimePicker,
type DateTimePickerLabels,
type DateTimePickerProps
} from './composites/DateTimePicker'
export { default as Ellipsis } from './composites/Ellipsis'
export { default as EmojiAvatar } from './composites/EmojiAvatar'
export { EmptyState, type EmptyStatePreset, type EmptyStateProps } from './composites/EmptyState'
@@ -61,6 +74,16 @@ export { DraggableList, useDraggableReorder } from './composites/DraggableList'
// EditableNumber
export type { EditableNumberProps } from './composites/EditableNumber'
export { default as EditableNumber } from './composites/EditableNumber'
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField
} from './composites/Form'
// Tooltip variants
export { HelpTooltip, type IconTooltipProps, InfoTooltip, WarnTooltip } from './composites/IconTooltips'
// ImageToolButton
@@ -79,27 +102,33 @@ export { Sortable } from './composites/Sortable'
/* Shadcn Primitive Components */
export * from './primitives/accordion'
export * from './primitives/alert'
export * from './primitives/badge'
export * from './primitives/breadcrumb'
export * from './primitives/button'
export * from './primitives/button-group'
export * from './primitives/calendar'
export * from './primitives/checkbox'
export * from './primitives/combobox'
export * from './primitives/command'
export * from './primitives/context-menu'
export * from './primitives/dialog'
export * from './primitives/drawer'
export * from './primitives/field'
export * from './primitives/input'
export * from './primitives/input-group'
export * from './primitives/item'
export * from './primitives/kbd'
export * from './primitives/label'
export * from './primitives/pagination'
export * from './primitives/popover'
export * from './primitives/radioGroup'
export * from './primitives/radio-group'
export * from './primitives/segmented-control'
export * from './primitives/select'
export * from './primitives/separator'
export * from './primitives/shadcn-io/dropzone'
export * from './primitives/skeleton'
export * from './primitives/slider'
export * from './primitives/table'
export * from './primitives/tabs'
export * as Textarea from './primitives/textarea'

View File

@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { Info } from 'lucide-react'
import { afterEach, describe, expect, it } from 'vitest'
import { Alert } from '../alert'
afterEach(() => {
cleanup()
})
describe('Alert', () => {
it('renders message and description', () => {
render(<Alert type="error" message="Failed" description="Something went wrong" />)
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(screen.getByText('Failed')).toBeInTheDocument()
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
})
it('renders the status role for non-error alerts', () => {
render(<Alert type="warning" message="Heads up" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('renders optional icon and action', () => {
render(<Alert type="info" message="Info" showIcon action={<button type="button">Open</button>} />)
expect(screen.getByText('Open')).toBeInTheDocument()
expect(document.querySelector('[data-slot="alert-icon"] svg')).toBeInTheDocument()
})
it.each(['info', 'success', 'warning', 'error'] as const)(
'marks the default %s icon with its semantic type',
(type) => {
render(<Alert type={type} message="Message" showIcon />)
expect(screen.getByRole(type === 'error' ? 'alert' : 'status')).toHaveAttribute('data-type', type)
const icon = document.querySelector('[data-slot="alert-icon"] svg')
expect(icon).toHaveClass('lucide-custom')
expect(icon?.closest('[data-slot="alert-icon"]')).toHaveAttribute('data-type', type)
}
)
it('applies semantic color to custom lucide icons', () => {
render(<Alert type="warning" message="Custom icon" showIcon icon={<Info className="custom-class" />} />)
const iconSlot = document.querySelector('[data-slot="alert-icon"]')
const icon = document.querySelector('[data-slot="alert-icon"] svg')
expect(iconSlot).toHaveAttribute('data-type', 'warning')
expect(icon).toHaveClass('custom-class')
})
})

View File

@@ -0,0 +1,149 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { Combobox, type ComboboxOption } from '../combobox'
const options: ComboboxOption[] = [
{ value: 'alpha', label: 'Alpha' },
{ value: 'beta', label: 'Beta' },
{ value: 'gamma', label: 'Gamma' }
]
beforeAll(() => {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as any
Element.prototype.scrollIntoView = vi.fn()
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('Combobox', () => {
it('maps the selected value to the trigger placeholder when opened', async () => {
render(
<Combobox
options={options}
value="beta"
searchPlacement="trigger"
placeholder="Pick one"
emptyText="No results"
/>
)
const input = screen.getByRole<HTMLInputElement>('combobox')
expect(input).toHaveValue('Beta')
fireEvent.click(input)
await waitFor(() => {
expect(input).toHaveFocus()
expect(input).toHaveValue('')
expect(input).toHaveAttribute('placeholder', 'Beta')
})
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Beta')).toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
})
it('filters options from the trigger input when searchPlacement is trigger', () => {
render(<Combobox options={options} searchPlacement="trigger" placeholder="Pick one" emptyText="No results" />)
const input = screen.getByRole('combobox')
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'gam' } })
expect(screen.getByText('Gamma')).toBeInTheDocument()
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
expect(screen.queryByText('Beta')).not.toBeInTheDocument()
})
it('selects the first filtered option with Enter in trigger search mode', () => {
const onChange = vi.fn()
render(
<Combobox
options={options}
searchPlacement="trigger"
placeholder="Pick one"
emptyText="No results"
onChange={onChange}
/>
)
const input = screen.getByRole('combobox')
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'bet' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onChange).toHaveBeenCalledWith('beta')
})
it('keeps trigger search open when the trigger input is clicked while open', async () => {
render(<Combobox options={options} searchPlacement="trigger" placeholder="Pick one" emptyText="No results" />)
const input = screen.getByRole('combobox')
fireEvent.click(input)
await waitFor(() => {
expect(screen.getByText('Alpha')).toBeInTheDocument()
})
fireEvent.click(input)
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(input).toHaveAttribute('aria-expanded', 'true')
})
it('does not clear a single value when the selected option is selected again', () => {
const onChange = vi.fn()
render(
<Combobox
options={options}
value="beta"
searchPlacement="trigger"
placeholder="Pick one"
emptyText="No results"
onChange={onChange}
/>
)
const input = screen.getByRole('combobox')
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'bet' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onChange).not.toHaveBeenCalled()
expect(onChange).not.toHaveBeenCalledWith('')
})
it('applies filterOption in content search mode', async () => {
render(
<Combobox
options={options.map((option) => ({
...option,
description: option.value === 'gamma' ? 'Third item' : 'Regular item'
}))}
placeholder="Pick one"
searchPlaceholder="Search descriptions"
emptyText="No results"
filterOption={(option, search) => option.description?.toLowerCase().includes(search.toLowerCase()) ?? false}
/>
)
fireEvent.click(screen.getByRole('button'))
fireEvent.change(screen.getByPlaceholderText('Search descriptions'), { target: { value: 'third' } })
await waitFor(() => {
expect(screen.getByText('Gamma')).toBeInTheDocument()
})
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
expect(screen.queryByText('Beta')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,43 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SegmentedControl } from '../segmented-control'
const options = [
{ value: 'app', label: 'App' },
{ value: 'window', label: 'Window' },
{ value: 'disabled', label: 'Disabled', disabled: true }
] as const
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('SegmentedControl', () => {
it('keeps controlled selection until the value prop changes', () => {
const onValueChange = vi.fn()
render(<SegmentedControl value="app" options={options} onValueChange={onValueChange} />)
fireEvent.click(screen.getByRole('radio', { name: 'Window' }))
expect(onValueChange).toHaveBeenCalledWith('window')
expect(screen.getByRole('radio', { name: 'App' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('radio', { name: 'Window' })).toHaveAttribute('aria-checked', 'false')
})
it('does not emit changes for disabled options', () => {
const onValueChange = vi.fn()
render(<SegmentedControl defaultValue="app" options={options} onValueChange={onValueChange} />)
fireEvent.click(screen.getByRole('radio', { name: 'Disabled' }))
expect(onValueChange).not.toHaveBeenCalled()
expect(screen.getByRole('radio', { name: 'App' })).toHaveAttribute('aria-checked', 'true')
})
})

View File

@@ -0,0 +1,36 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it } from 'vitest'
import { Switch } from '../switch'
afterEach(() => {
cleanup()
})
describe('Switch', () => {
it('toggles aria-checked when clicked', async () => {
const user = userEvent.setup()
render(<Switch />)
const root = screen.getByRole('switch')
expect(root).toHaveAttribute('aria-checked', 'false')
await user.click(root)
expect(root).toHaveAttribute('aria-checked', 'true')
})
it('does not toggle when disabled', async () => {
const user = userEvent.setup()
render(<Switch disabled />)
const root = screen.getByRole('switch')
expect(root).toHaveAttribute('aria-checked', 'false')
await user.click(root)
expect(root).toHaveAttribute('aria-checked', 'false')
})
})

View File

@@ -0,0 +1,127 @@
import { cn } from '@cherrystudio/ui/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
import { CheckCircle2, Info, TriangleAlert, XCircle } from 'lucide-react'
import * as React from 'react'
const alertVariants = cva(
cn(
'relative flex w-full items-start gap-3 rounded-md border px-3 py-2.5 text-sm leading-5 shadow-xs',
'[&_svg]:pointer-events-none [&_svg]:shrink-0'
),
{
variants: {
type: {
info: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900 dark:bg-blue-950/30 dark:text-blue-300',
success: 'border-[var(--color-success-base)] bg-[var(--color-success-bg)] text-[var(--color-success-base)]',
warning: 'border-[var(--color-warning-base)] bg-[var(--color-warning-bg)] text-[var(--color-warning-base)]',
error: 'border-[var(--color-error-border)] bg-[var(--color-error-bg)] text-[var(--color-error-base)]'
}
},
defaultVariants: {
type: 'info'
}
}
)
const alertIconVariants = cva('', {
variants: {
type: {
info: 'text-blue-600 dark:text-blue-300',
success: 'text-[var(--color-success-base)]',
warning: 'text-[var(--color-warning-base)]',
error: 'text-[var(--color-error-base)]'
}
},
defaultVariants: {
type: 'info'
}
})
const alertIconContainerVariants = cva('mt-0.5 flex shrink-0 items-center', {
variants: {
type: {
info: 'text-blue-600 dark:text-blue-300 [&_.lucide:not(.lucide-custom)]:!text-blue-600 dark:[&_.lucide:not(.lucide-custom)]:!text-blue-300',
success: 'text-[var(--color-success-base)] [&_.lucide:not(.lucide-custom)]:!text-[var(--color-success-base)]',
warning: 'text-[var(--color-warning-base)] [&_.lucide:not(.lucide-custom)]:!text-[var(--color-warning-base)]',
error: 'text-[var(--color-error-base)] [&_.lucide:not(.lucide-custom)]:!text-[var(--color-error-base)]'
}
},
defaultVariants: {
type: 'info'
}
})
const alertIcons = {
info: Info,
success: CheckCircle2,
warning: TriangleAlert,
error: XCircle
} satisfies Record<NonNullable<AlertProps['type']>, React.ComponentType<{ className?: string; size?: number }>>
type AlertProps = Omit<React.ComponentProps<'div'>, 'title'> &
VariantProps<typeof alertVariants> & {
message?: React.ReactNode
description?: React.ReactNode
action?: React.ReactNode
icon?: React.ReactNode
showIcon?: boolean
}
function Alert({
className,
type = 'info',
message,
description,
action,
icon,
showIcon = false,
children,
role,
ref,
...props
}: AlertProps) {
const Icon = alertIcons[type ?? 'info']
const alertRole = role ?? (type === 'error' ? 'alert' : 'status')
return (
<div
ref={ref}
role={alertRole}
data-slot="alert"
data-type={type}
className={cn(alertVariants({ type }), className)}
{...props}>
{showIcon && (
<span data-slot="alert-icon" data-type={type} className={alertIconContainerVariants({ type })}>
{icon ?? <Icon size={16} className={cn('lucide-custom', alertIconVariants({ type }))} />}
</span>
)}
<div data-slot="alert-content" className="min-w-0 flex-1">
{children ?? (
<>
{message && (
<div data-slot="alert-message" className="font-medium">
{message}
</div>
)}
{description && (
<div data-slot="alert-description" className="mt-1 text-xs leading-5 opacity-90">
{description}
</div>
)}
</>
)}
</div>
{action && (
<div data-slot="alert-action" className="ml-2 flex shrink-0 items-center">
{action}
</div>
)}
</div>
)
}
Alert.displayName = 'Alert'
export { Alert, alertVariants }
export type { AlertProps }

View File

@@ -1,45 +1,41 @@
import { Separator } from '@cherrystudio/ui/components/primitives/separator'
import { cn } from '@cherrystudio/ui/lib/utils'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
const buttonGroupVariants = cva('inline-flex', {
variants: {
orientation: {
horizontal: 'flex-row',
vertical: 'flex-col'
const buttonGroupVariants = cva(
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 *:focus-visible:relative *:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 [&>[data-slot=button-group-item]>[data-slot=button][data-variant=default]]:bg-primary/10 [&>[data-slot=button-group-item]>[data-slot=button][data-variant=default]]:text-primary [&>[data-slot=button-group-item]>[data-slot=button][data-variant=default]]:shadow-[inset_0_0_0_1px_var(--color-primary)]/20 [&>[data-slot=button-group-item]>[data-slot=button][data-variant=default]]:hover:bg-primary/15 [&>[data-slot=button][data-variant=default]]:bg-primary/10 [&>[data-slot=button][data-variant=default]]:text-primary [&>[data-slot=button][data-variant=default]]:shadow-[inset_0_0_0_1px_var(--color-primary)]/20 [&>[data-slot=button][data-variant=default]]:hover:bg-primary/15",
{
variants: {
orientation: {
horizontal: '',
vertical: 'flex-col'
},
attached: {
true: '',
false: 'gap-2'
}
},
attached: {
true: '',
false: 'gap-2'
}
},
compoundVariants: [
{
compoundVariants: [
{
orientation: 'horizontal',
attached: true,
className:
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot=button-group-item]:not(:first-child)>:is([data-slot=button],[data-slot=input],[data-slot=select-trigger])]:rounded-l-none [&>[data-slot=button-group-item]:not(:first-child)>:is([data-slot=button],[data-slot=input],[data-slot=select-trigger])]:border-l-0 [&>[data-slot=button-group-item]:not(:last-child)>:is([data-slot=button],[data-slot=input],[data-slot=select-trigger])]:rounded-r-none'
},
{
orientation: 'vertical',
attached: true,
className:
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot=button-group-item]:not(:first-child)>:is([data-slot=button],[data-slot=input],[data-slot=select-trigger])]:rounded-t-none [&>[data-slot=button-group-item]:not(:first-child)>:is([data-slot=button],[data-slot=input],[data-slot=select-trigger])]:border-t-0 [&>[data-slot=button-group-item]:not(:last-child)>:is([data-slot=button],[data-slot=input],[data-slot=select-trigger])]:rounded-b-none'
}
],
defaultVariants: {
orientation: 'horizontal',
attached: true,
className: cn(
'items-center',
'[&>[data-slot=button]:not(:first-child)]:-ml-px',
'[&>[data-slot=button]:not(:first-child)]:rounded-l-none',
'[&>[data-slot=button]:not(:last-child)]:rounded-r-none'
)
},
{
orientation: 'vertical',
attached: true,
className: cn(
'items-stretch',
'[&>[data-slot=button]:not(:first-child)]:-mt-px',
'[&>[data-slot=button]:not(:first-child)]:rounded-t-none',
'[&>[data-slot=button]:not(:last-child)]:rounded-b-none'
)
attached: true
}
],
defaultVariants: {
orientation: 'horizontal',
attached: true
}
})
)
function ButtonGroup({
className,
@@ -49,12 +45,53 @@ function ButtonGroup({
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
data-slot="button-group"
role="group"
data-slot="button-group"
data-orientation={orientation}
data-attached={attached}
className={cn(buttonGroupVariants({ orientation, attached }), className)}
{...props}
/>
)
}
export { ButtonGroup, buttonGroupVariants }
function ButtonGroupItem({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="button-group-item" className={cn('relative flex min-w-0', className)} {...props} />
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'div'
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-md border bg-muted px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn('relative m-0! self-stretch bg-input data-[orientation=vertical]:h-auto', className)}
{...props}
/>
)
}
export { ButtonGroup, ButtonGroupItem, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants }

View File

@@ -7,28 +7,29 @@ import * as React from 'react'
const buttonVariants = cva(
cn(
'inline-flex items-center justify-center gap-2 whitespace-nowrap',
'rounded-xs font-medium transition-all',
'rounded-md font-medium transition-all',
'disabled:pointer-events-none disabled:opacity-40',
"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 [&_.lucide:not(.lucide-custom)]:text-current outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
'aria-loading:cursor-progress aria-loading:opacity-40',
'shadow-xs'
),
{
variants: {
variant: {
default: 'bg-primary hover:bg-primary-hover text-white',
default:
'bg-neutral-900 text-white hover:bg-neutral-800 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200',
destructive: 'bg-destructive text-white hover:bg-destructive-hover focus-visible:ring-destructive/20',
outline: cn('border border-primary/40 bg-primary/10 text-primary', 'hover:bg-primary/5'),
outline: 'border border-border bg-transparent text-foreground hover:bg-accent',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:text-primary-hover text-primary',
link: 'text-primary underline-offset-4 hover:underline hover:text-primary-hover'
ghost: 'text-neutral-900 shadow-none hover:bg-accent hover:text-accent-foreground dark:text-neutral-100',
link: 'text-neutral-900 underline-offset-4 hover:text-neutral-700 hover:underline dark:text-neutral-100 dark:hover:text-neutral-300'
},
size: {
default: 'min-h-8 px-3 text-[13px]',
default: 'min-h-7.5 gap-1.5 px-2.5 text-[13px]',
sm: 'min-h-7 rounded-md gap-1.5 px-2.5 text-xs',
lg: 'min-h-9 rounded-md px-4 text-sm',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-sm': 'size-7',
'icon-lg': 'size-10'
}
},
@@ -61,7 +62,8 @@ function Button({
// Determine spinner size based on button size
const getSpinnerSize = () => {
if (size === 'sm' || size === 'icon-sm') return 14
if (size === 'icon-sm') return 13
if (size === 'sm') return 14
if (size === 'lg' || size === 'icon-lg') return 18
return 16
}
@@ -75,6 +77,7 @@ function Button({
return (
<Comp
data-slot="button"
data-variant={variant ?? 'default'}
className={cn(buttonVariants({ variant, size, className }))}
disabled={disabled || loading}
aria-loading={loading}

View File

@@ -0,0 +1,149 @@
import { cn } from '@cherrystudio/ui/lib/utils'
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from 'lucide-react'
import * as React from 'react'
import {
type ChevronProps,
DayPicker,
type DropdownNavProps,
type DropdownProps,
getDefaultClassNames,
type MonthCaptionProps
} from 'react-day-picker'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
components,
showOutsideDays = true,
captionLayout = 'dropdown',
...props
}: CalendarProps) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
captionLayout={captionLayout}
className={cn('p-3', className)}
classNames={{
...defaultClassNames,
root: cn(defaultClassNames.root, 'w-fit'),
months: cn(defaultClassNames.months, 'relative flex flex-col gap-4 sm:flex-row'),
month: cn(defaultClassNames.month, 'space-y-3'),
month_caption: cn(defaultClassNames.month_caption, 'flex h-8 items-center justify-center'),
caption_label: cn(defaultClassNames.caption_label, 'font-medium text-sm'),
dropdowns: cn(defaultClassNames.dropdowns, 'flex w-full items-center justify-center gap-2'),
dropdown_root: cn(defaultClassNames.dropdown_root, 'relative'),
dropdown: cn(
defaultClassNames.dropdown,
'h-8 rounded-md border border-border bg-background px-2 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/50'
),
nav: cn(defaultClassNames.nav, 'absolute inset-x-0 top-3 flex items-center justify-between px-3'),
button_previous: cn(
defaultClassNames.button_previous,
'inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40'
),
button_next: cn(
defaultClassNames.button_next,
'inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40'
),
month_grid: cn(defaultClassNames.month_grid, 'w-full border-collapse space-y-1'),
weekdays: cn(defaultClassNames.weekdays, 'flex'),
weekday: cn(defaultClassNames.weekday, 'w-8 rounded-md text-center font-normal text-muted-foreground text-xs'),
week: cn(defaultClassNames.week, 'mt-1 flex w-full'),
day: cn(defaultClassNames.day, 'size-8 p-0 text-center text-sm'),
day_button: cn(
defaultClassNames.day_button,
'inline-flex size-8 items-center justify-center rounded-md font-normal text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-40'
),
selected: cn(
defaultClassNames.selected,
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground'
),
today: cn(defaultClassNames.today, 'bg-accent text-accent-foreground'),
outside: cn(defaultClassNames.outside, 'text-muted-foreground opacity-50'),
disabled: cn(defaultClassNames.disabled, 'text-muted-foreground opacity-40'),
range_middle: cn(defaultClassNames.range_middle, 'rounded-none bg-accent text-accent-foreground'),
range_start: cn(defaultClassNames.range_start, 'rounded-l-md bg-primary text-primary-foreground'),
range_end: cn(defaultClassNames.range_end, 'rounded-r-md bg-primary text-primary-foreground'),
hidden: cn(defaultClassNames.hidden, 'invisible'),
...classNames
}}
components={{
MonthCaption: CalendarMonthCaption,
DropdownNav: CalendarDropdownNav,
Dropdown: CalendarDropdown,
Chevron: CalendarChevron,
...components
}}
{...props}
/>
)
}
function CalendarMonthCaption({ children }: MonthCaptionProps) {
return <>{children}</>
}
function CalendarDropdownNav({ className, ...props }: DropdownNavProps) {
return <div className={cn('flex w-full items-center gap-2', className)} {...props} />
}
function CalendarDropdown({
value,
onChange,
options,
disabled,
className,
style,
'aria-label': ariaLabel
}: DropdownProps) {
return (
<Select
value={value?.toString()}
disabled={disabled}
onValueChange={(nextValue) => handleCalendarDropdownChange(nextValue, onChange)}>
<SelectTrigger
aria-label={ariaLabel}
size="sm"
style={style}
className={cn('min-w-0 first:flex-1 last:shrink-0', className)}>
<SelectValue />
</SelectTrigger>
<SelectContent align="center" className="max-h-64">
{options?.map((option) => (
<SelectItem key={option.value} value={String(option.value)} disabled={option.disabled}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
function handleCalendarDropdownChange(value: string | number, onChange: DropdownProps['onChange']) {
if (!onChange) return
const event = {
target: {
value: String(value)
}
} as React.ChangeEvent<HTMLSelectElement>
onChange(event)
}
function CalendarChevron({ className, orientation, disabled, ...props }: ChevronProps) {
const iconClassName = cn('size-4', disabled && 'opacity-40', className)
if (orientation === 'left') return <ChevronLeft className={iconClassName} {...props} />
if (orientation === 'right') return <ChevronRight className={iconClassName} {...props} />
if (orientation === 'up') return <ChevronUp className={iconClassName} {...props} />
return <ChevronDown className={iconClassName} {...props} />
}
export { Calendar }

View File

@@ -9,7 +9,8 @@ import {
CommandItem,
CommandList
} from '@cherrystudio/ui/components/primitives/command'
import { Popover, PopoverContent, PopoverTrigger } from '@cherrystudio/ui/components/primitives/popover'
import { Input } from '@cherrystudio/ui/components/primitives/input'
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@cherrystudio/ui/components/primitives/popover'
import { cn } from '@cherrystudio/ui/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
import { Check, ChevronDown, X } from 'lucide-react'
@@ -19,7 +20,7 @@ import * as React from 'react'
const comboboxTriggerVariants = cva(
cn(
'inline-flex items-center justify-between rounded-2xs border-1 text-sm transition-colors outline-none font-normal',
'inline-flex items-center justify-between rounded-md border-1 text-sm transition-colors outline-none font-normal',
'bg-zinc-50 dark:bg-zinc-900',
'text-foreground'
),
@@ -44,7 +45,7 @@ const comboboxTriggerVariants = cva(
)
const comboboxItemVariants = cva(
'relative flex items-center gap-2 px-2 py-1.5 text-sm rounded-2xs cursor-pointer transition-colors outline-none select-none',
'relative flex items-center gap-2 px-2 py-1.5 text-sm rounded-md cursor-pointer transition-colors outline-none select-none',
{
variants: {
state: {
@@ -59,20 +60,28 @@ const comboboxItemVariants = cva(
}
)
const comboboxInputSizeClasses = {
sm: 'h-8 px-2 text-xs',
default: 'h-9 px-3 text-sm',
lg: 'h-10 px-4 text-sm'
}
// ==================== Types ====================
export interface ComboboxOption {
export type ComboboxOption<TExtra extends object = Record<never, never>> = {
value: string
label: string
disabled?: boolean
icon?: React.ReactNode
description?: string
[key: string]: any
}
} & TExtra
export interface ComboboxProps extends Omit<VariantProps<typeof comboboxTriggerVariants>, 'state'> {
export type ComboboxSearchPlacement = 'content' | 'trigger'
export interface ComboboxProps<TExtra extends object = Record<never, never>>
extends Omit<VariantProps<typeof comboboxTriggerVariants>, 'state'> {
// Data source
options: ComboboxOption[]
options: ComboboxOption<TExtra>[]
value?: string | string[]
defaultValue?: string | string[]
onChange?: (value: string | string[]) => void
@@ -81,14 +90,16 @@ export interface ComboboxProps extends Omit<VariantProps<typeof comboboxTriggerV
multiple?: boolean
// Custom rendering
renderOption?: (option: ComboboxOption) => React.ReactNode
renderValue?: (value: string | string[], options: ComboboxOption[]) => React.ReactNode
renderOption?: (option: ComboboxOption<TExtra>) => React.ReactNode
renderValue?: (value: string | string[], options: ComboboxOption<TExtra>[]) => React.ReactNode
// Search
searchable?: boolean
searchPlacement?: ComboboxSearchPlacement
searchPlaceholder?: string
emptyText?: string
onSearch?: (search: string) => void
filterOption?: (option: ComboboxOption<TExtra>, search: string) => boolean
// State
error?: boolean
@@ -100,6 +111,7 @@ export interface ComboboxProps extends Omit<VariantProps<typeof comboboxTriggerV
placeholder?: string
className?: string
popoverClassName?: string
triggerStyle?: React.CSSProperties
width?: string | number
// Other
@@ -108,7 +120,7 @@ export interface ComboboxProps extends Omit<VariantProps<typeof comboboxTriggerV
// ==================== Component ====================
export function Combobox({
export function Combobox<TExtra extends object = Record<never, never>>({
options,
value: controlledValue,
defaultValue,
@@ -117,9 +129,11 @@ export function Combobox({
renderOption,
renderValue,
searchable = true,
searchPlacement = 'content',
searchPlaceholder = 'Search...',
emptyText = 'No results found.',
onSearch,
filterOption,
error = false,
disabled = false,
open: controlledOpen,
@@ -127,16 +141,29 @@ export function Combobox({
placeholder = 'Please Select',
className,
popoverClassName,
triggerStyle,
width,
size,
name
}: ComboboxProps) {
}: ComboboxProps<TExtra>) {
// ==================== State ====================
const [internalOpen, setInternalOpen] = React.useState(false)
const [internalValue, setInternalValue] = React.useState<string | string[]>(defaultValue ?? (multiple ? [] : ''))
const [triggerSearch, setTriggerSearch] = React.useState('')
const [contentSearch, setContentSearch] = React.useState('')
const triggerInputRef = React.useRef<HTMLInputElement>(null)
const open = controlledOpen ?? internalOpen
const setOpen = onOpenChange ?? setInternalOpen
const setOpen = React.useCallback(
(nextOpen: boolean) => {
if (onOpenChange) {
onOpenChange(nextOpen)
} else {
setInternalOpen(nextOpen)
}
},
[onOpenChange]
)
const value = controlledValue ?? internalValue
const setValue = (newValue: string | string[]) => {
@@ -146,8 +173,45 @@ export function Combobox({
onChange?.(newValue)
}
const selectedOption = !multiple ? options.find((opt) => opt.value === value) : undefined
const triggerSearchEnabled = searchable && searchPlacement === 'trigger' && !multiple
const contentSearchEnabled = searchable && !triggerSearchEnabled
const manualFilterEnabled = triggerSearchEnabled || (contentSearchEnabled && Boolean(filterOption))
const activeSearch = triggerSearchEnabled ? triggerSearch : contentSearch
const normalizedSearch = activeSearch.trim().toLowerCase()
const visibleOptions = React.useMemo(() => {
if (!manualFilterEnabled || !normalizedSearch) {
return options
}
return options.filter((option) => {
if (filterOption) {
return filterOption(option, activeSearch)
}
return [option.label, option.value, option.description]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(normalizedSearch)
})
}, [activeSearch, filterOption, manualFilterEnabled, normalizedSearch, options])
// ==================== Handlers ====================
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
setTriggerSearch('')
setContentSearch('')
return
}
if (triggerSearchEnabled) {
setTriggerSearch('')
}
}
const handleSelect = (selectedValue: string) => {
if (multiple) {
const currentValues = (value as string[]) || []
@@ -156,8 +220,10 @@ export function Combobox({
: [...currentValues, selectedValue]
setValue(newValues)
} else {
setValue(selectedValue === value ? '' : selectedValue)
setOpen(false)
if (selectedValue !== value) {
setValue(selectedValue)
}
handleOpenChange(false)
}
}
@@ -176,6 +242,84 @@ export function Combobox({
return value === optionValue
}
const handleTriggerInputFocus = (event: React.FocusEvent<HTMLInputElement>) => {
if (!triggerSearchEnabled) {
return
}
if (!open) {
handleOpenChange(true)
}
event.currentTarget.select()
}
const handleTriggerInputMouseDown = () => {
if (!triggerSearchEnabled || open) {
return
}
handleOpenChange(true)
}
const handleTriggerInputClick = (event: React.MouseEvent<HTMLInputElement>) => {
if (!triggerSearchEnabled) {
return
}
event.preventDefault()
event.currentTarget.focus()
if (!open) {
handleOpenChange(true)
}
}
const handleTriggerInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const nextSearch = event.target.value
setTriggerSearch(nextSearch)
onSearch?.(nextSearch)
if (!open) {
setOpen(true)
}
}
const handleTriggerInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (!triggerSearchEnabled) {
return
}
if (event.key === 'Escape') {
handleOpenChange(false)
return
}
if (event.key === 'Enter') {
if (!normalizedSearch) {
event.preventDefault()
handleOpenChange(false)
return
}
const firstEnabledOption = visibleOptions.find((option) => !option.disabled)
if (firstEnabledOption) {
event.preventDefault()
handleSelect(firstEnabledOption.value)
}
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
if (!open) {
handleOpenChange(true)
}
}
}
const handleContentSearchChange = (nextSearch: string) => {
setContentSearch(nextSearch)
onSearch?.(nextSearch)
}
// ==================== Render Helpers ====================
const renderTriggerContent = () => {
@@ -226,7 +370,54 @@ export function Combobox({
return <span className="text-muted-foreground">{placeholder}</span>
}
const renderOptionContent = (option: ComboboxOption) => {
const renderTriggerInput = () => {
const triggerInputValue = open ? triggerSearch : (selectedOption?.label ?? '')
const triggerInputPlaceholder = open ? (selectedOption?.label ?? placeholder) : placeholder
const inputSize = size ?? 'default'
return (
<PopoverAnchor asChild>
<div className="relative" style={{ width: triggerWidth }}>
<PopoverTrigger asChild>
<Input
ref={triggerInputRef}
type="text"
value={triggerInputValue}
placeholder={triggerInputPlaceholder}
disabled={disabled}
aria-expanded={open}
aria-invalid={error}
role="combobox"
autoComplete="off"
spellCheck={false}
onFocus={handleTriggerInputFocus}
onMouseDown={handleTriggerInputMouseDown}
onClick={handleTriggerInputClick}
onChange={handleTriggerInputChange}
onKeyDown={handleTriggerInputKeyDown}
style={triggerStyle}
className={cn(
'w-full rounded-md border-1 bg-zinc-50 pr-8 shadow-none transition-colors dark:bg-zinc-900',
'focus-visible:border-primary focus-visible:ring-3 focus-visible:ring-primary/20',
error && 'border-destructive! focus-visible:ring-red-600/20',
disabled && 'cursor-not-allowed opacity-50',
comboboxInputSizeClasses[inputSize],
className
)}
/>
</PopoverTrigger>
<ChevronDown
className={cn(
'pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 shrink-0 opacity-50 transition-transform',
open && 'rotate-180'
)}
/>
</div>
</PopoverAnchor>
)
}
const renderOptionContent = (option: ComboboxOption<TExtra>) => {
if (renderOption) {
return renderOption(option)
}
@@ -249,39 +440,78 @@ export function Combobox({
const triggerWidth = width ? (typeof width === 'number' ? `${width}px` : width) : undefined
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size={size}
disabled={disabled}
style={{ width: triggerWidth }}
className={cn(comboboxTriggerVariants({ state, size }), className)}
aria-expanded={open}
aria-invalid={error}>
{renderTriggerContent()}
<ChevronDown className="size-4 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className={cn('p-0 rounded-2xs', popoverClassName)} style={{ width: triggerWidth }}>
<Command>
{searchable && (
<CommandInput placeholder={searchPlaceholder} className="h-9 rounded-none" onValueChange={onSearch} />
<Popover open={open} onOpenChange={handleOpenChange}>
{triggerSearchEnabled ? (
renderTriggerInput()
) : (
<PopoverTrigger asChild>
<Button
variant="outline"
size={size}
disabled={disabled}
style={{ width: triggerWidth, ...triggerStyle }}
className={cn(comboboxTriggerVariants({ state, size }), className)}
aria-expanded={open}
aria-invalid={error}>
{renderTriggerContent()}
<ChevronDown className="size-4 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
)}
<PopoverContent
className={cn('p-0 rounded-md', popoverClassName)}
style={{ width: triggerWidth }}
onOpenAutoFocus={(event) => {
if (!triggerSearchEnabled) {
return
}
event.preventDefault()
triggerInputRef.current?.focus()
}}>
<Command shouldFilter={!manualFilterEnabled}>
{contentSearchEnabled && (
<CommandInput
placeholder={searchPlaceholder}
className="h-9 rounded-none"
onValueChange={handleContentSearchChange}
/>
)}
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
disabled={option.disabled}
onSelect={() => handleSelect(option.value)}
className={cn(comboboxItemVariants({ state: option.disabled ? 'disabled' : 'default' }))}>
{renderOptionContent(option)}
</CommandItem>
))}
</CommandGroup>
{manualFilterEnabled ? (
visibleOptions.length === 0 ? (
<div className="py-6 text-center text-muted-foreground text-sm">{emptyText}</div>
) : (
<CommandGroup>
{visibleOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value || option.label}
disabled={option.disabled}
onSelect={() => handleSelect(option.value)}
className={cn(comboboxItemVariants({ state: option.disabled ? 'disabled' : 'default' }))}>
{renderOptionContent(option)}
</CommandItem>
))}
</CommandGroup>
)
) : (
<>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
disabled={option.disabled}
onSelect={() => handleSelect(option.value)}
className={cn(comboboxItemVariants({ state: option.disabled ? 'disabled' : 'default' }))}>
{renderOptionContent(option)}
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>

View File

@@ -0,0 +1,205 @@
import { cn } from '@cherrystudio/ui/lib/utils'
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import * as React from 'react'
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
}
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
}
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
}
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
className
)}
{...props}>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
)
}
function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className
)}
checked={checked}
{...props}>
<span className="pointer-events-none absolute left-2 flex size-4 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className
)}
{...props}>
<span className="pointer-events-none absolute left-2 flex size-4 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 font-medium text-sm data-[inset]:pl-8', className)}
{...props}
/>
)
}
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="context-menu-shortcut"
className={cn('ml-auto text-muted-foreground text-xs', className)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuPortal,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger
}

View File

@@ -46,7 +46,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xs border border-border p-6 shadow-lg duration-200 sm:max-w-lg',
'bg-card text-card-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-md border border-border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...props}>
@@ -54,7 +54,7 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
className="ring-offset-background focus-visible:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-md opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

View File

@@ -0,0 +1,157 @@
import { Separator } from '@cherrystudio/ui/components/primitives/separator'
import { cn } from '@cherrystudio/ui/lib/utils'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div role="list" data-slot="item-group" className={cn('group/item-group flex flex-col', className)} {...props} />
)
}
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return <Separator data-slot="item-separator" orientation="horizontal" className={cn('my-0', className)} {...props} />
}
const itemVariants = cva(
'group/item flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-accent/50',
{
variants: {
variant: {
default: 'bg-transparent',
outline: 'border-border',
muted: 'bg-muted/50'
},
size: {
default: 'gap-4 p-4',
sm: 'gap-2.5 px-4 py-3'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
function Item({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div'
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "size-8 rounded-sm border bg-muted [&_svg:not([class*='size-'])]:size-4",
image: 'size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover'
}
},
defaultVariants: {
variant: 'default'
}
}
)
function ItemMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-content"
className={cn('flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none', className)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-title"
className={cn('flex w-fit items-center gap-2 text-sm leading-snug font-medium', className)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="item-description"
className={cn(
'line-clamp-2 text-sm leading-normal font-normal text-balance text-muted-foreground',
'[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="item-actions" className={cn('flex items-center gap-2', className)} {...props} />
}
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-header"
className={cn('flex basis-full items-center justify-between gap-2', className)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-footer"
className={cn('flex basis-full items-center justify-between gap-2', className)}
{...props}
/>
)
}
export {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemFooter,
ItemGroup,
ItemHeader,
ItemMedia,
ItemSeparator,
ItemTitle
}

View File

@@ -5,7 +5,7 @@ function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
<kbd
data-slot="kbd"
className={cn(
'bg-primary/10 text-primary pointer-events-none inline-flex w-fit min-w-5 items-center justify-center gap-1 rounded-3xs p-1 font-sans text-xs font-medium select-none',
'bg-primary/10 text-primary pointer-events-none inline-flex w-fit min-w-5 items-center justify-center gap-1 rounded-md p-1 font-sans text-xs font-medium select-none',
"[&_svg:not([class*='size-'])]:size-3",
'[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10',
className

View File

@@ -40,7 +40,7 @@ function PaginationLink({ className, isActive, size = 'icon', ...props }: Pagina
variant: isActive ? 'outline' : 'ghost',
size
}),
'text-foreground hover:text-primary hover:shadow-none hover:bg-primary/10 rounded-3xs',
'text-foreground hover:text-primary hover:shadow-none hover:bg-primary/10 rounded-md',
isActive && 'bg-background text-primary',
className
)}

View File

@@ -1,25 +1,22 @@
import { cn } from '@cherrystudio/ui/lib/utils'
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
import { cva, type VariantProps } from 'class-variance-authority'
import { CircleIcon } from 'lucide-react'
import * as React from 'react'
const radioGroupItemVariants = cva(
cn(
'aspect-square shrink-0 rounded-full border transition-all outline-none',
'border-primary text-primary',
'hover:bg-primary/10',
'aria-checked:ring-3 aria-checked:ring-primary/20',
'aspect-square shrink-0 rounded-full border border-input bg-transparent text-primary shadow-none transition-[color,border-color,box-shadow] outline-none',
'data-[state=checked]:border-2 data-[state=checked]:border-primary',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
'disabled:cursor-not-allowed disabled:border-gray-500/10 disabled:bg-background-subtle',
'dark:bg-input/30 shadow-xs'
'disabled:cursor-not-allowed disabled:opacity-50'
),
{
variants: {
size: {
sm: 'size-4',
md: 'size-5',
lg: 'size-6'
sm: 'size-3.5',
md: 'size-4',
lg: 'size-5'
}
},
defaultVariants: {
@@ -43,13 +40,7 @@ function RadioGroupItem({
data-size={size}
className={cn(radioGroupItemVariants({ size }), className)}
{...props}>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center">
<CircleIcon
className={cn('fill-primary absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-2.5')}
/>
</RadioGroupPrimitive.Indicator>
<RadioGroupPrimitive.Indicator data-slot="radio-group-indicator" />
</RadioGroupPrimitive.Item>
)
}

View File

@@ -0,0 +1,81 @@
import { cn } from '@cherrystudio/ui/lib/utils'
import * as React from 'react'
export interface SegmentedControlOption<TValue extends string = string> {
value: TValue
label: React.ReactNode
disabled?: boolean
}
export interface SegmentedControlProps<TValue extends string = string>
extends Omit<React.ComponentPropsWithoutRef<'div'>, 'defaultValue' | 'onChange'> {
options: readonly SegmentedControlOption<TValue>[]
value?: TValue
defaultValue?: TValue
onValueChange?: (value: TValue) => void
disabled?: boolean
size?: 'sm' | 'default'
}
function SegmentedControl<TValue extends string = string>({
options,
value,
defaultValue,
onValueChange,
disabled = false,
size = 'default',
className,
...props
}: SegmentedControlProps<TValue>) {
const [internalValue, setInternalValue] = React.useState<TValue | undefined>(defaultValue ?? options[0]?.value)
const selectedValue = value ?? internalValue
const handleSelect = (option: SegmentedControlOption<TValue>) => {
if (disabled || option.disabled || option.value === selectedValue) {
return
}
if (value === undefined) {
setInternalValue(option.value)
}
onValueChange?.(option.value)
}
return (
<div
role="radiogroup"
data-slot="segmented-control"
data-size={size}
aria-disabled={disabled}
className={cn(
'inline-flex items-center rounded-full border border-border/60 bg-muted/60 p-0.5',
disabled && 'pointer-events-none opacity-50',
className
)}
{...props}>
{options.map((option) => {
const selected = option.value === selectedValue
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={selected}
disabled={disabled || option.disabled}
onClick={() => handleSelect(option)}
className={cn(
'inline-flex min-w-0 items-center justify-center rounded-full font-medium text-foreground-muted outline-none transition-[background-color,color,box-shadow]',
'hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50',
size === 'sm' ? 'h-7 gap-1.5 px-2.5 text-xs' : 'h-8 gap-2 px-3 text-sm',
selected && 'bg-background text-foreground shadow-xs'
)}>
{option.label}
</button>
)
})}
</div>
)
}
export { SegmentedControl }

View File

@@ -6,7 +6,7 @@ import * as React from 'react'
const selectTriggerVariants = cva(
cn(
'inline-flex items-center justify-between rounded-2xs border-1 text-sm transition-colors outline-none font-normal',
'inline-flex items-center justify-between rounded-md border-1 text-sm transition-colors outline-none font-normal',
'bg-zinc-50 dark:bg-zinc-900',
'text-foreground'
),
@@ -82,7 +82,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-2xs border shadow-md',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className

View File

@@ -1,5 +1,3 @@
'use client'
import { cn } from '@cherrystudio/ui/lib/utils'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import * as React from 'react'
@@ -16,7 +14,7 @@ function Separator({
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className
)}
{...props}

View File

@@ -47,7 +47,7 @@ const sliderThumbVariants = cva(
}
)
const sliderMarkLabelVariants = cva('absolute text-muted-foreground', {
const sliderMarkLabelVariants = cva('absolute top-0 whitespace-nowrap text-muted-foreground leading-none', {
variants: {
size: {
sm: 'text-[10px]',
@@ -161,11 +161,13 @@ function Slider({
{sliderElement}
<div
data-slot="slider-marks"
className={cn('relative', isVertical ? 'ml-2 flex h-full flex-col justify-between' : 'mt-1.5 w-full')}>
className={cn('relative', isVertical ? 'ml-2 flex h-full flex-col justify-between' : 'mt-1.5 h-4 w-full')}>
{marks.map((mark) => {
const range = max - min
if (range === 0) return null
const percentage = ((mark.value - min) / range) * 100
const transform =
percentage <= 0 ? 'translateX(0)' : percentage >= 100 ? 'translateX(-100%)' : 'translateX(-50%)'
return (
<span
key={mark.value}
@@ -174,7 +176,7 @@ function Slider({
style={
isVertical
? { top: `${100 - percentage}%`, transform: 'translateY(-50%)' }
: { left: `${percentage}%`, transform: 'translateX(-50%)' }
: { left: `${percentage}%`, transform }
}>
{mark.label}
</span>

View File

@@ -8,20 +8,21 @@ const switchRootVariants = cva(
[
'cs-switch cs-switch-root',
'group relative cursor-pointer peer inline-flex shrink-0 items-center rounded-full shadow-xs outline-none transition-all',
'data-[state=unchecked]:bg-gray-500/20 data-[state=checked]:bg-primary',
'data-[state=unchecked]:bg-gray-500/20 data-[state=checked]:bg-brand-600',
'disabled:cursor-not-allowed disabled:opacity-40',
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50'
],
{
variants: {
size: {
xs: ['h-4.5 w-8'],
sm: ['w-9 h-5'],
md: ['w-11 h-5.5'],
lg: ['w-11 h-6']
},
loading: {
false: null,
true: ['bg-primary-hover!']
true: ['bg-brand-300!']
}
},
defaultVariants: {
@@ -39,16 +40,22 @@ const switchThumbVariants = cva(
{
variants: {
size: {
xs: ['ml-[1px] size-4 data-[state=checked]:translate-x-3.5'],
sm: ['size-4.5 ml-[1px] data-[state=checked]:translate-x-4'],
md: ['size-[19px] ml-0.5 data-[state=checked]:translate-x-[21px]'],
lg: ['size-5 ml-[3px] data-[state=checked]:translate-x-4.5']
},
loading: {
false: null,
true: ['bg-primary-hover!']
true: ['bg-brand-300!']
}
},
compoundVariants: [
{
size: 'xs',
loading: true,
className: 'ml-0.5 size-3.5 data-[state=checked]:translate-x-3.5'
},
{
size: 'sm',
loading: true,
@@ -84,7 +91,7 @@ const switchThumbSvgVariants = cva(['transition-all'], {
interface SwitchProps extends Omit<React.ComponentProps<typeof SwitchPrimitive.Root>, 'children'> {
/** When true, displays a loading animation in the switch thumb. Defaults to false when undefined. */
loading?: boolean
size?: 'sm' | 'md' | 'lg'
size?: 'xs' | 'sm' | 'md' | 'lg'
classNames?: {
root?: string
thumb?: string

View File

@@ -0,0 +1,60 @@
import { cn } from '@cherrystudio/ui/lib/utils'
import * as React from 'react'
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div data-slot="table-container" className="relative w-full overflow-auto">
<table data-slot="table" className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return <tbody data-slot="table-body" className={cn('[&_tr:last-child]:border-0', className)} {...props} />
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot="table-row"
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={cn('h-9 px-3 text-left align-middle font-medium text-muted-foreground', className)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return <td data-slot="table-cell" className={cn('px-3 py-2 align-middle', className)} {...props} />
}
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
return (
<caption data-slot="table-caption" className={cn('mt-4 text-muted-foreground text-sm', className)} {...props} />
)
}
export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow }

View File

@@ -94,7 +94,7 @@ const tabsTriggerVariants = cva(
'font-normal text-muted-foreground hover:text-foreground',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'data-[state=active]:text-primary',
'after:absolute after:bg-primary/10 after:rounded-full',
'after:absolute after:rounded-full after:bg-transparent',
'data-[state=active]:after:bg-primary'
]
},

View File

@@ -12,7 +12,7 @@ import * as React from 'react'
const textareaVariants = cva(
cn(
'flex field-sizing-content min-h-16 w-full border bg-transparent px-4 py-3 text-lg shadow-xs transition-[color,box-shadow] outline-none resize-y',
'rounded-xs',
'rounded-md',
'border-input text-foreground placeholder:text-foreground-secondary',
'focus-visible:border-primary focus-visible:ring-ring focus-visible:ring-[3px]',
'disabled:cursor-not-allowed disabled:opacity-50',

View File

@@ -1,3 +1,4 @@
import { cn } from '@cherrystudio/ui/lib/utils'
import {
Arrow as RadixArrow,
Content as RadixContent,
@@ -8,8 +9,6 @@ import {
} from '@radix-ui/react-tooltip'
import * as React from 'react'
import { cn } from '../../lib/utils'
type Side = 'top' | 'bottom' | 'left' | 'right'
type Align = 'start' | 'center' | 'end'
@@ -32,8 +31,6 @@ function parsePlacement(placement?: string): { side: Side; align: Align } {
return mapping[placement ?? 'top'] ?? { side: 'top', align: 'center' }
}
// --- Compound component exports (for advanced usage) ---
export type TooltipProviderProps = React.ComponentProps<typeof RadixProvider>
export type TooltipRootProps = React.ComponentProps<typeof RadixRoot>
export type TooltipTriggerProps = React.ComponentProps<typeof RadixTrigger>
@@ -56,9 +53,11 @@ function TooltipTrigger({ ...props }: TooltipTriggerProps) {
}
const contentStyles =
'z-50 max-w-60 rounded-3xs border border-border bg-background px-4 py-3 text-sm leading-4 text-foreground shadow-[0px_6px_12px_0px_rgba(0,0,0,0.2)] animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2'
'z-50 w-fit max-w-80 origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-neutral-900 px-3 py-1.5 text-neutral-50 text-xs leading-relaxed whitespace-normal break-words fade-in-0 zoom-in-95 dark:bg-neutral-100 dark:text-neutral-900 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95'
function TooltipContent({ className, sideOffset = 4, children, ...props }: TooltipContentProps) {
const arrowStyles = 'z-50 fill-neutral-900 dark:fill-neutral-100'
function TooltipContent({ className, sideOffset = 0, children, ...props }: TooltipContentProps) {
return (
<RadixPortal>
<RadixContent
@@ -67,14 +66,12 @@ function TooltipContent({ className, sideOffset = 4, children, ...props }: Toolt
className={cn(contentStyles, className)}
{...props}>
{children}
<RadixArrow className="fill-background [&>path:first-child]:fill-border" />
<RadixArrow className={arrowStyles} />
</RadixContent>
</RadixPortal>
)
}
// --- Backward-compatible flat API (drop-in replacement for HeroUI Tooltip) ---
export interface TooltipProps {
children?: React.ReactNode
content?: React.ReactNode
@@ -118,7 +115,7 @@ export const Tooltip = ({
const { side, align } = parsePlacement(placement)
const controlledProps: Partial<React.ComponentProps<typeof RadixRoot>> = {}
const controlledProps: Partial<TooltipRootProps> = {}
if (isOpen != null) {
controlledProps.open = isOpen
controlledProps.onOpenChange = onOpenChange
@@ -127,7 +124,7 @@ export const Tooltip = ({
}
return (
<RadixProvider delayDuration={delay}>
<TooltipProvider delayDuration={delay}>
<RadixRoot delayDuration={delay} {...controlledProps}>
<RadixTrigger asChild>
<div className={cn('relative z-10 inline-block', classNames?.placeholder)} onClick={onClick}>
@@ -139,19 +136,17 @@ export const Tooltip = ({
data-slot="tooltip-content"
side={side}
align={align}
sideOffset={4}
sideOffset={0}
className={cn(contentStyles, classNames?.content, className)}>
{tooltipContent}
{showArrow && <RadixArrow className="fill-background [&>path:first-child]:fill-border" />}
{showArrow && <RadixArrow className={arrowStyles} />}
</RadixContent>
</RadixPortal>
</RadixRoot>
</RadixProvider>
</TooltipProvider>
)
}
// --- NormalTooltip (convenience wrapper using compound components) ---
interface NormalTooltipProps extends TooltipRootProps {
content: React.ReactNode
side?: TooltipContentProps['side']

View File

@@ -272,13 +272,13 @@
/* Runtime Theme Inputs */
/* ==================== */
--cs-theme-primary: var(--cs-primary);
--cs-user-font-family: initial;
--cs-user-code-font-family: initial;
--cs-theme-ring: color-mix(in srgb, var(--cs-theme-primary) 40%, transparent);
/* ==================== */
/* Compatibility Aliases */
/* ==================== */
--primary: var(--color-primary);
--ring: var(--color-ring);
/* ==================== */
/* Semantic Colors */
@@ -287,6 +287,7 @@
--color-primary-hover: var(--cs-primary-hover);
--color-primary-soft: color-mix(in srgb, var(--color-primary) 60%, transparent);
--color-primary-mute: color-mix(in srgb, var(--color-primary) 30%, transparent);
--color-ring: var(--cs-theme-ring);
--color-destructive: var(--cs-destructive);
--color-destructive-hover: var(--cs-destructive-hover);
--color-success: var(--cs-success);
@@ -301,7 +302,6 @@
--color-border: var(--cs-border);
--color-border-hover: var(--cs-border-hover);
--color-border-active: var(--cs-border-active);
--color-ring: var(--cs-ring);
--color-secondary: var(--cs-secondary);
--color-secondary-hover: var(--cs-secondary-hover);
--color-secondary-active: var(--cs-secondary-active);

View File

@@ -13,7 +13,7 @@
--cs-warning: var(--cs-amber-500);
/* Background & Foreground */
--cs-background: var(--cs-zinc-50);
--cs-background: var(--cs-white);
--cs-background-subtle: oklch(0 0 0 / 0.02);
--cs-foreground: oklch(0 0 0 / 0.9);
--cs-foreground-secondary: oklch(0 0 0 / 0.6);
@@ -48,7 +48,7 @@
/* Dark Mode */
.dark {
/* Background & Foreground */
--cs-background: var(--cs-zinc-900);
--cs-background: oklch(0.191 0 0 / 0.55);
--cs-background-subtle: oklch(1 0 0 / 0.02);
--cs-foreground: oklch(1 0 0 / 0.9);
--cs-foreground-secondary: oklch(1 0 0 / 0.6);

View File

@@ -4,15 +4,15 @@
*/
:root {
--cs-radius-4xs: 0.25rem; /* 4px */
--cs-radius-3xs: 0.5rem; /* 8px */
--cs-radius-2xs: 0.75rem; /* 12px */
--cs-radius-xs: 1rem; /* 16px */
--cs-radius-sm: 1.5rem; /* 24px */
--cs-radius-md: 2rem; /* 32px */
--cs-radius-lg: 2.5rem; /* 40px */
--cs-radius-xl: 3rem; /* 48px */
--cs-radius-2xl: 3.5rem; /* 56px */
--cs-radius-3xl: 4rem; /* 64px */
--cs-radius-round: 999px; /* 完全圆角,保持 px */
--cs-radius-4xs: 0.03125rem; /* 0.5px */
--cs-radius-3xs: 0.0625rem; /* 1px */
--cs-radius-2xs: 0.09375rem; /* 1.5px */
--cs-radius-xs: 0.125rem; /* 2px */
--cs-radius-sm: 0.375rem; /* 6px */
--cs-radius-md: 0.5rem; /* 8px */
--cs-radius-lg: 0.625rem; /* 10px */
--cs-radius-xl: 0.875rem; /* 14px */
--cs-radius-2xl: 1.125rem; /* 18px */
--cs-radius-3xl: 1.375rem; /* 22px */
--cs-radius-round: 9999px; /* fully rounded, keep px */
}

View File

@@ -0,0 +1,198 @@
import type { ColumnDef } from '@cherrystudio/ui'
import { Badge, Button, DataTable, Input } from '@cherrystudio/ui'
import type { Meta, StoryObj } from '@storybook/react'
import { Pencil, Trash2 } from 'lucide-react'
import type { Key } from 'react'
import { useMemo, useState } from 'react'
type Task = {
id: string
name: string
status: 'active' | 'paused' | 'completed'
owner: string
locked?: boolean
}
const tasks: Task[] = [
{ id: '1', name: 'Refresh index', status: 'active', owner: 'Ada' },
{ id: '2', name: 'Sync providers', status: 'paused', owner: 'Grace' },
{ id: '3', name: 'Archive logs', status: 'completed', owner: 'Linus', locked: true }
]
const columns: ColumnDef<Task>[] = [
{
accessorKey: 'name',
header: 'Name',
meta: { width: 220 }
},
{
accessorKey: 'status',
header: 'Status',
meta: { width: 120 },
cell: ({ row }) => <Badge variant="outline">{row.original.status}</Badge>
},
{
accessorKey: 'owner',
header: 'Owner'
}
]
const columnsWithActions: ColumnDef<Task>[] = [
...columns,
{
id: 'actions',
header: 'Actions',
meta: { width: 96, maxWidth: 96, align: 'center' },
cell: ({ row }) => (
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-foreground-muted hover:bg-accent/70 hover:text-foreground"
aria-label={`Edit ${row.original.name}`}>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
aria-label={`Delete ${row.original.name}`}>
<Trash2 className="size-4" />
</Button>
</div>
)
}
]
const meta: Meta<typeof DataTable<Task>> = {
title: 'Components/Composites/DataTable',
component: DataTable<Task>,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'A shadcn/TanStack-powered data table with Cherry Studio styling, optional max width, selection, header slots, empty state, scrolling, and controlled expanded rows.'
}
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => <DataTable className="w-[640px]" data={tasks} columns={columns} rowKey="id" />
}
export const WithActions: Story = {
render: () => <DataTable className="w-[720px]" data={tasks} columns={columnsWithActions} rowKey="id" />
}
export const WithMaxWidth: Story = {
render: () => (
<div className="w-[800px] max-w-full">
<DataTable data={tasks} columns={columns} rowKey="id" maxWidth={640} />
</div>
)
}
export const WithToolbar: Story = {
render: function WithToolbarExample() {
const [query, setQuery] = useState('')
const filtered = useMemo(
() => tasks.filter((task) => task.name.toLowerCase().includes(query.toLowerCase())),
[query]
)
return (
<DataTable
className="w-[640px]"
data={filtered}
columns={columns}
rowKey="id"
headerLeft={<span className="text-muted-foreground text-sm">{filtered.length} tasks</span>}
headerRight={
<Input className="w-48" placeholder="Search tasks" value={query} onChange={(e) => setQuery(e.target.value)} />
}
/>
)
}
}
export const MultipleSelection: Story = {
render: function MultipleSelectionExample() {
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>(['1'])
return (
<DataTable
className="w-[640px]"
data={tasks}
columns={columns}
rowKey="id"
selection={{
type: 'multiple',
selectedRowKeys,
onChange: setSelectedRowKeys,
getCheckboxProps: (task) => ({ disabled: task.locked })
}}
headerLeft={<span className="text-muted-foreground text-sm">{selectedRowKeys.length} selected</span>}
/>
)
}
}
export const SingleSelection: Story = {
render: function SingleSelectionExample() {
const [selectedRowKey, setSelectedRowKey] = useState<Key | null>(null)
return (
<DataTable
className="w-[640px]"
data={tasks}
columns={columns}
rowKey="id"
selection={{
type: 'single',
selectedRowKey,
onChange: setSelectedRowKey
}}
/>
)
}
}
export const Empty: Story = {
render: () => <DataTable className="w-[640px]" data={[]} columns={columns} rowKey="id" emptyText="No tasks" />
}
export const ScrollAndExpand: Story = {
render: function ScrollAndExpandExample() {
const [expandedRowKeys, setExpandedRowKeys] = useState<Key[]>(['1'])
return (
<DataTable
className="w-[640px]"
data={[...tasks, ...tasks.map((task) => ({ ...task, id: `${task.id}-copy`, name: `${task.name} copy` }))]}
columns={columns}
rowKey="id"
maxHeight={240}
expandedRowKeys={expandedRowKeys}
onExpandedRowChange={setExpandedRowKeys}
renderExpandedRow={(task) => (
<div className="flex items-center justify-between text-sm">
<span>
{task.name} is owned by {task.owner}.
</span>
<Button size="sm" variant="outline">
Open
</Button>
</div>
)}
/>
)
}
}

View File

@@ -0,0 +1,114 @@
import type { DateTimeGranularity } from '@cherrystudio/ui'
import { DateTimePicker } from '@cherrystudio/ui'
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
const meta: Meta<typeof DateTimePicker> = {
title: 'Components/Composites/DateTimePicker',
component: DateTimePicker,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'A shadcn-style date and time picker built from Popover, DayPicker dropdown navigation, Select, and compact time inputs. Supports configurable time granularity and date-fns format strings.'
}
}
},
tags: ['autodocs'],
argTypes: {
granularity: {
control: 'select',
options: ['day', 'hour', 'minute', 'second'] satisfies DateTimeGranularity[]
},
format: {
control: 'text'
},
disabled: {
control: 'boolean'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const DateOnly: Story = {
render: function DateOnlyExample() {
const [value, setValue] = useState<Date | undefined>(new Date(2026, 3, 29))
return (
<DateTimePicker
value={value}
onChange={setValue}
granularity="day"
format="yyyy-MM-dd"
placeholder="Pick a date"
/>
)
}
}
export const WithMinutes: Story = {
render: function WithMinutesExample() {
const [value, setValue] = useState<Date | undefined>(new Date(2026, 3, 29, 14, 30))
return (
<DateTimePicker
value={value}
onChange={setValue}
granularity="minute"
format="yyyy-MM-dd HH:mm"
placeholder="Pick date and time"
/>
)
}
}
export const WithSeconds: Story = {
render: function WithSecondsExample() {
const [value, setValue] = useState<Date | undefined>(new Date(2026, 3, 29, 14, 30, 45))
return (
<DateTimePicker
value={value}
onChange={setValue}
granularity="second"
format="yyyy-MM-dd HH:mm:ss"
placeholder="Pick date and time"
/>
)
}
}
export const CustomYearRange: Story = {
render: function CustomYearRangeExample() {
const [value, setValue] = useState<Date | undefined>(new Date(2026, 3, 29, 9, 0))
return (
<DateTimePicker
value={value}
onChange={setValue}
granularity="hour"
format="PPP HH':00'"
calendarProps={{
startMonth: new Date(2020, 0),
endMonth: new Date(2035, 11)
}}
/>
)
}
}
export const Disabled: Story = {
render: function DisabledExample() {
return (
<DateTimePicker
defaultValue={new Date(2026, 3, 29, 14, 30)}
granularity="minute"
format="yyyy-MM-dd HH:mm"
disabled
/>
)
}
}

View File

@@ -1,10 +1,10 @@
import { Button } from '@cherrystudio/ui/components/primitives/button'
import { Checkbox } from '@cherrystudio/ui/components/primitives/checkbox'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { ArrowDown, ArrowUp, Check, ChevronRight, Pencil, Pin, PinOff, Plus } from 'lucide-react'
import { useMemo, useState } from 'react'
import { EntitySelector } from '../../../src/components/composites/EntitySelector'
import { Button } from '../../../src/components/primitives/button'
import { Checkbox } from '../../../src/components/primitives/checkbox'
type ExampleItem = { id: string; name: string; description?: string; emoji?: string }

View File

@@ -73,15 +73,15 @@ function ItemCard({ item, dragging }: { item: ExampleItem; dragging: boolean })
}
export const Vertical: Story = {
render: (args) => <VerticalDemo {...(args as any)} />
render: (args) => <VerticalDemo {...args} />
}
export const Horizontal: Story = {
render: (args) => <HorizontalDemo {...(args as any)} />
render: (args) => <HorizontalDemo {...args} />
}
export const Grid: Story = {
render: (args) => <GridDemo {...(args as any)} />
render: (args) => <GridDemo {...args} />
}
function VerticalDemo(args: any) {

View File

@@ -1,4 +1,4 @@
import { Button, ButtonGroup } from '@cherrystudio/ui'
import { Button, ButtonGroup, ButtonGroupItem, Input } from '@cherrystudio/ui'
import type { Meta, StoryObj } from '@storybook/react'
import { ChevronLeft, ChevronRight, LayoutGrid, List } from 'lucide-react'
@@ -76,3 +76,24 @@ export const IconToggleStyle: Story = {
</ButtonGroup>
)
}
export const InputWithButton: Story = {
render: () => (
<ButtonGroup className="w-80">
<Input placeholder="Type to search..." className="flex-1" />
<Button>Search</Button>
</ButtonGroup>
)
}
export const WrappedInputWithButton: Story = {
render: () => (
<ButtonGroup className="w-80">
<ButtonGroupItem className="flex-1">
<Input placeholder="Wrapped input..." className="pr-14" />
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-xs">Ctrl K</span>
</ButtonGroupItem>
<Button>Search</Button>
</ButtonGroup>
)
}

View File

@@ -36,6 +36,11 @@ const meta: Meta<typeof Combobox> = {
searchable: {
control: { type: 'boolean' },
description: 'Enable search functionality'
},
searchPlacement: {
control: { type: 'select' },
options: ['content', 'trigger'],
description: 'Where the search input is rendered'
}
}
}
@@ -114,6 +119,145 @@ const iconOptions = [
}
]
const fontOptions = [
{
value: 'inter',
label: 'Inter',
description: 'Neutral UI sans serif',
category: 'Sans',
fontFamily: 'Inter, sans-serif'
},
{
value: 'nunito-sans',
label: 'Nunito Sans',
description: 'Friendly rounded sans serif',
category: 'Sans',
fontFamily: '"Nunito Sans", sans-serif'
},
{
value: 'geist',
label: 'Geist',
description: 'Modern app interface font',
category: 'Sans',
fontFamily: 'Geist, sans-serif'
},
{
value: 'system-ui',
label: 'System UI',
description: 'Native operating system font',
category: 'Sans',
fontFamily: 'system-ui, sans-serif'
},
{
value: 'sf-pro',
label: 'SF Pro',
description: 'Apple platform interface font',
category: 'Sans',
fontFamily: '-apple-system, BlinkMacSystemFont, sans-serif'
},
{
value: 'roboto',
label: 'Roboto',
description: 'Android and Material UI font',
category: 'Sans',
fontFamily: 'Roboto, sans-serif'
},
{
value: 'source-sans',
label: 'Source Sans 3',
description: 'Readable product copy font',
category: 'Sans',
fontFamily: '"Source Sans 3", sans-serif'
},
{
value: 'ibm-plex-sans',
label: 'IBM Plex Sans',
description: 'Technical and enterprise UI font',
category: 'Sans',
fontFamily: '"IBM Plex Sans", sans-serif'
},
{
value: 'geist-mono',
label: 'Geist Mono',
description: 'Technical mono for code',
category: 'Mono',
fontFamily: '"Geist Mono", monospace'
},
{
value: 'jetbrains-mono',
label: 'JetBrains Mono',
description: 'Programming-focused mono font',
category: 'Mono',
fontFamily: '"JetBrains Mono", monospace'
},
{
value: 'fira-code',
label: 'Fira Code',
description: 'Ligature-friendly code font',
category: 'Mono',
fontFamily: '"Fira Code", monospace'
},
{
value: 'source-code-pro',
label: 'Source Code Pro',
description: 'Adobe monospace family',
category: 'Mono',
fontFamily: '"Source Code Pro", monospace'
},
{
value: 'berkeley-mono',
label: 'Berkeley Mono',
description: 'Dense terminal and editor font',
category: 'Mono',
fontFamily: '"Berkeley Mono", monospace'
},
{
value: 'ui-monospace',
label: 'UI Monospace',
description: 'Native system monospace stack',
category: 'Mono',
fontFamily: 'ui-monospace, monospace'
}
]
const searchableToolOptions = [
{
value: 'claude-code',
label: 'Claude Code',
description: 'Agentic coding assistant',
category: 'AI',
keywords: 'anthropic sonnet terminal'
},
{
value: 'cursor',
label: 'Cursor',
description: 'AI-native code editor',
category: 'Editor',
keywords: 'autocomplete composer workspace'
},
{
value: 'github-copilot',
label: 'GitHub Copilot',
description: 'Inline code completion',
category: 'AI',
keywords: 'github suggestion pair programming'
},
{
value: 'raycast',
label: 'Raycast',
description: 'Command launcher and extensions',
category: 'Productivity',
keywords: 'launcher snippets automation'
},
{
value: 'linear',
label: 'Linear',
description: 'Issue tracking and planning',
category: 'Planning',
keywords: 'tickets roadmap triage'
}
]
// ==================== Stories ====================
// Default - 占位符状态
@@ -349,6 +493,78 @@ export const WithoutSearch: Story = {
}
}
export const TriggerSearchFontList: Story = {
render: function TriggerSearchFontListExample() {
const [font, setFont] = useState('inter')
const selectedFont = fontOptions.find((option) => option.value === font)
return (
<div className="flex w-[360px] flex-col gap-4">
<Combobox
options={fontOptions}
value={font}
onChange={(val) => setFont(val as string)}
searchPlacement="trigger"
placeholder="Select font"
emptyText="No fonts found"
width={360}
renderOption={(option) => (
<div className="flex w-full items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate" style={{ fontFamily: option.fontFamily }}>
{option.label}
</div>
<div className="truncate text-muted-foreground text-xs">{option.description}</div>
</div>
<span className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground text-xs">{option.category}</span>
</div>
)}
/>
<div className="rounded-md border bg-muted/40 px-3 py-2">
<div className="text-muted-foreground text-xs">Selected font</div>
<div className="mt-1 truncate text-sm" style={{ fontFamily: selectedFont?.fontFamily }}>
{selectedFont?.label}
</div>
</div>
</div>
)
}
}
export const CustomFilterOption: Story = {
render: function CustomFilterOptionExample() {
const [tool, setTool] = useState('')
return (
<Combobox
options={searchableToolOptions}
value={tool}
onChange={(val) => setTool(val as string)}
placeholder="Search tools"
searchPlaceholder="Search label, category, or keyword"
emptyText="No tools found"
width={320}
filterOption={(option, search) =>
[option.label, option.description, option.category, option.keywords]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(search.trim().toLowerCase())
}
renderOption={(option) => (
<div className="flex w-full items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate">{option.label}</div>
<div className="truncate text-muted-foreground text-xs">{option.description}</div>
</div>
<span className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground text-xs">{option.category}</span>
</div>
)}
/>
)
}
}
// 实际使用场景 - 综合展示
export const RealWorldExamples: Story = {
render: function RealWorldExample() {

View File

@@ -0,0 +1,130 @@
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger
} from '@cherrystudio/ui'
import type { Meta, StoryObj } from '@storybook/react'
import { Copy, Download, Pencil, Settings, Trash2 } from 'lucide-react'
import { useState } from 'react'
const meta: Meta<typeof ContextMenu> = {
title: 'Components/Primitives/ContextMenu',
component: ContextMenu,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'Displays a menu when right-clicking a trigger area. Built on Radix UI Context Menu and styled with shadcn tokens.'
}
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex h-36 w-72 items-center justify-center rounded-md border border-dashed bg-background-subtle text-sm text-muted-foreground">
Right click this area
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem>
<Pencil />
Rename
<ContextMenuShortcut>F2</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
<Copy />
Duplicate
<ContextMenuShortcut>D</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
<Download />
Export
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">
<Trash2 />
Delete
<ContextMenuShortcut></ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
export const WithSubMenu: Story = {
render: () => (
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex h-36 w-72 items-center justify-center rounded-md border border-dashed bg-background-subtle text-sm text-muted-foreground">
Right click for nested actions
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-52">
<ContextMenuLabel>Provider</ContextMenuLabel>
<ContextMenuItem>
<Pencil />
Edit
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>
<Settings />
More actions
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-40">
<ContextMenuItem>Open settings</ContextMenuItem>
<ContextMenuItem>View logs</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">Reset</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>
)
}
export const CheckboxAndRadioItems: Story = {
render: function CheckboxAndRadioItemsExample() {
const [showBadge, setShowBadge] = useState(true)
const [density, setDensity] = useState('comfortable')
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex h-36 w-72 items-center justify-center rounded-md border border-dashed bg-background-subtle text-sm text-muted-foreground">
Right click for selectable items
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuCheckboxItem checked={showBadge} onCheckedChange={setShowBadge}>
Show status badge
</ContextMenuCheckboxItem>
<ContextMenuSeparator />
<ContextMenuLabel inset>Density</ContextMenuLabel>
<ContextMenuRadioGroup value={density} onValueChange={setDensity}>
<ContextMenuRadioItem value="compact">Compact</ContextMenuRadioItem>
<ContextMenuRadioItem value="comfortable">Comfortable</ContextMenuRadioItem>
<ContextMenuRadioItem value="spacious">Spacious</ContextMenuRadioItem>
</ContextMenuRadioGroup>
</ContextMenuContent>
</ContextMenu>
)
}
}

View File

@@ -1,9 +1,8 @@
import { DescriptionSwitch } from '@cherrystudio/ui/components/primitives/switch'
import type { Meta, StoryObj } from '@storybook/react'
import { Bell, Eye, Lock, Moon, Shield, Wifi, Zap } from 'lucide-react'
import { useState } from 'react'
import { DescriptionSwitch } from '../../../src/components/primitives/switch'
const meta: Meta<typeof DescriptionSwitch> = {
title: 'Components/Primitives/DescriptionSwitch',
component: DescriptionSwitch,

View File

@@ -1,3 +1,11 @@
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea
} from '@cherrystudio/ui/components/primitives/input-group'
import type { Meta, StoryObj } from '@storybook/react'
import {
AtSign,
@@ -19,15 +27,6 @@ import {
} from 'lucide-react'
import { useState } from 'react'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea
} from '../../../src/components/primitives/input-group'
const meta: Meta<typeof InputGroup> = {
title: 'Components/Primitives/InputGroup',
component: InputGroup,

View File

@@ -0,0 +1,212 @@
import {
Badge,
Button,
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemFooter,
ItemGroup,
ItemHeader,
ItemMedia,
ItemSeparator,
ItemTitle
} from '@cherrystudio/ui'
import type { Meta, StoryObj } from '@storybook/react'
import { Bell, Check, ChevronRight, Cloud, Database, FileText, Settings, Shield } from 'lucide-react'
const meta: Meta<typeof Item> = {
title: 'Components/Primitives/Item',
component: Item,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'Displays structured list items with media, content, actions, headers, and footers. Based on shadcn/ui.'
}
}
},
tags: ['autodocs'],
argTypes: {
variant: {
control: { type: 'select' },
options: ['default', 'outline', 'muted'],
description: 'The visual style variant of the item'
},
size: {
control: { type: 'select' },
options: ['default', 'sm'],
description: 'The size of the item'
},
asChild: {
control: { type: 'boolean' },
description: 'Render as a child element'
},
className: {
control: { type: 'text' },
description: 'Additional CSS classes'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Item className="w-[360px]">
<ItemMedia variant="icon">
<Settings />
</ItemMedia>
<ItemContent>
<ItemTitle>General Settings</ItemTitle>
<ItemDescription>Configure the default behavior and appearance of the application.</ItemDescription>
</ItemContent>
<ItemActions>
<Button variant="ghost" size="icon-sm" aria-label="Open settings">
<ChevronRight />
</Button>
</ItemActions>
</Item>
)
}
export const Variants: Story = {
render: () => (
<div className="grid w-[420px] gap-3">
<Item>
<ItemMedia variant="icon">
<FileText />
</ItemMedia>
<ItemContent>
<ItemTitle>Default</ItemTitle>
<ItemDescription>A transparent item for subtle list layouts.</ItemDescription>
</ItemContent>
</Item>
<Item variant="outline">
<ItemMedia variant="icon">
<Database />
</ItemMedia>
<ItemContent>
<ItemTitle>Outline</ItemTitle>
<ItemDescription>A bordered item for grouped settings or selectable rows.</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted">
<ItemMedia variant="icon">
<Cloud />
</ItemMedia>
<ItemContent>
<ItemTitle>Muted</ItemTitle>
<ItemDescription>A softened item for secondary cards or inactive states.</ItemDescription>
</ItemContent>
</Item>
</div>
)
}
export const Sizes: Story = {
render: () => (
<div className="grid w-[420px] gap-3">
<Item size="sm" variant="outline">
<ItemMedia variant="icon">
<Bell />
</ItemMedia>
<ItemContent>
<ItemTitle>Small item</ItemTitle>
<ItemDescription>Compact spacing for dense settings lists.</ItemDescription>
</ItemContent>
</Item>
<Item variant="outline">
<ItemMedia variant="icon">
<Bell />
</ItemMedia>
<ItemContent>
<ItemTitle>Default item</ItemTitle>
<ItemDescription>Comfortable spacing for explanatory list rows.</ItemDescription>
</ItemContent>
</Item>
</div>
)
}
export const WithHeaderAndFooter: Story = {
render: () => (
<Item variant="outline" className="w-[420px]">
<ItemHeader>
<Badge variant="outline">Security</Badge>
<span className="text-muted-foreground text-xs">Updated today</span>
</ItemHeader>
<ItemMedia variant="icon">
<Shield />
</ItemMedia>
<ItemContent>
<ItemTitle>Device verification</ItemTitle>
<ItemDescription>Require trusted devices before syncing sensitive data.</ItemDescription>
</ItemContent>
<ItemActions>
<Button variant="outline" size="sm">
Manage
</Button>
</ItemActions>
<ItemFooter>
<span className="text-muted-foreground text-xs">2 devices trusted</span>
<span className="flex items-center gap-1 text-success text-xs">
<Check size={12} />
Enabled
</span>
</ItemFooter>
</Item>
)
}
export const GroupedSettings: Story = {
render: () => (
<ItemGroup className="w-[420px] rounded-lg border border-border bg-background">
<Item size="sm">
<ItemMedia variant="icon">
<Cloud />
</ItemMedia>
<ItemContent>
<ItemTitle>Cloud sync</ItemTitle>
<ItemDescription>Keep settings available across devices.</ItemDescription>
</ItemContent>
<ItemActions>
<Badge>On</Badge>
</ItemActions>
</Item>
<ItemSeparator />
<Item size="sm">
<ItemMedia variant="icon">
<Database />
</ItemMedia>
<ItemContent>
<ItemTitle>Local data</ItemTitle>
<ItemDescription>Manage caches, exports, and local backups.</ItemDescription>
</ItemContent>
<ItemActions>
<Button variant="ghost" size="icon-sm" aria-label="Open local data">
<ChevronRight />
</Button>
</ItemActions>
</Item>
</ItemGroup>
)
}
export const AsChild: Story = {
render: () => (
<Item asChild variant="outline" className="w-[360px] hover:bg-accent">
<a href="#" aria-label="Open documentation">
<ItemMedia variant="icon">
<FileText />
</ItemMedia>
<ItemContent>
<ItemTitle>Documentation</ItemTitle>
<ItemDescription>Use asChild to render an item as a link.</ItemDescription>
</ItemContent>
</a>
</Item>
)
}

View File

@@ -1,7 +1,7 @@
import { Kbd, KbdGroup } from '@cherrystudio/ui'
import type { Meta, StoryObj } from '@storybook/react'
import { Command, Copy, Save, Search } from 'lucide-react'
// import { Tooltip, TooltipContent, TooltipTrigger } from '../../../src/components/primitives/tooltip'
// import { Tooltip, TooltipContent, TooltipTrigger } from '@cherrystudio/ui/components/primitives/tooltip'
const meta: Meta<typeof Kbd> = {
title: 'Components/Primitives/Kbd',

View File

@@ -0,0 +1,210 @@
import { SegmentedControl } from '@cherrystudio/ui'
import type { Meta, StoryObj } from '@storybook/react'
import { Code, FileText, LayoutGrid, List, Monitor, Moon, Sun } from 'lucide-react'
import { useState } from 'react'
const meta: Meta<typeof SegmentedControl> = {
title: 'Components/Primitives/SegmentedControl',
component: SegmentedControl,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'A pill-shaped segmented control for choosing exactly one option from a compact set. Useful for settings rows, view modes, and short enum choices.'
}
}
},
tags: ['autodocs'],
argTypes: {
value: {
control: { type: 'text' },
description: 'The selected option value in controlled mode'
},
defaultValue: {
control: { type: 'text' },
description: 'The initially selected option value in uncontrolled mode'
},
disabled: {
control: { type: 'boolean' },
description: 'Whether the whole control is disabled'
},
size: {
control: { type: 'select' },
options: ['sm', 'default'],
description: 'The visual size of each segment'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
defaultValue: 'system',
options: [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System' }
]
}
}
export const Sizes: Story = {
render: () => (
<div className="flex flex-col gap-5">
<div>
<p className="mb-2 text-sm text-muted-foreground">Small</p>
<SegmentedControl
size="sm"
defaultValue="left"
options={[
{ value: 'left', label: 'Left' },
{ value: 'right', label: 'Right' }
]}
/>
</div>
<div>
<p className="mb-2 text-sm text-muted-foreground">Default</p>
<SegmentedControl
defaultValue="grid"
options={[
{ value: 'grid', label: 'Grid' },
{ value: 'list', label: 'List' },
{ value: 'compact', label: 'Compact' }
]}
/>
</div>
</div>
)
}
export const WithIcons: Story = {
render: () => (
<SegmentedControl
defaultValue="system"
options={[
{
value: 'light',
label: (
<>
<Sun className="size-4" />
Light
</>
)
},
{
value: 'dark',
label: (
<>
<Moon className="size-4" />
Dark
</>
)
},
{
value: 'system',
label: (
<>
<Monitor className="size-4" />
System
</>
)
}
]}
/>
)
}
export const Disabled: Story = {
render: () => (
<div className="flex flex-col gap-5">
<div>
<p className="mb-2 text-sm text-muted-foreground">Entire control disabled</p>
<SegmentedControl
disabled
defaultValue="grid"
options={[
{ value: 'grid', label: 'Grid' },
{ value: 'list', label: 'List' }
]}
/>
</div>
<div>
<p className="mb-2 text-sm text-muted-foreground">One option disabled</p>
<SegmentedControl
defaultValue="read"
options={[
{ value: 'read', label: 'Read' },
{ value: 'write', label: 'Write', disabled: true },
{ value: 'admin', label: 'Admin' }
]}
/>
</div>
</div>
)
}
export const ViewMode: Story = {
render: () => (
<SegmentedControl
size="sm"
defaultValue="grid"
aria-label="View mode"
options={[
{
value: 'grid',
label: (
<>
<LayoutGrid className="size-4" />
Grid
</>
)
},
{
value: 'list',
label: (
<>
<List className="size-4" />
List
</>
)
},
{
value: 'details',
label: (
<>
<FileText className="size-4" />
Details
</>
)
}
]}
/>
)
}
export const Controlled: Story = {
render: function ControlledExample() {
const [value, setValue] = useState('preview')
return (
<div className="flex flex-col items-center gap-3">
<SegmentedControl
value={value}
onValueChange={setValue}
options={[
{ value: 'preview', label: 'Preview' },
{ value: 'code', label: 'Code' },
{ value: 'split', label: 'Split' }
]}
/>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Code className="size-4" />
Selected: {value}
</div>
</div>
)
}
}

View File

@@ -1,8 +1,7 @@
import { Slider } from '@cherrystudio/ui/components/primitives/slider'
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { Slider } from '../../../src/components/primitives/slider'
const meta: Meta<typeof Slider> = {
title: 'Components/Primitives/Slider',
component: Slider,

View File

@@ -11,7 +11,7 @@ const meta: Meta<typeof Switch> = {
docs: {
description: {
component:
'A switch component based on Radix UI Switch, allowing users to toggle between on/off states. Supports three sizes (sm, md, lg), loading states, and an enhanced DescriptionSwitch variant with label and description. Built with accessibility in mind.'
'A switch component based on Radix UI Switch, allowing users to toggle between on/off states. Supports four sizes (xs, sm, md, lg), loading states, and an enhanced DescriptionSwitch variant with label and description. Built with accessibility in mind.'
}
}
},
@@ -27,7 +27,7 @@ const meta: Meta<typeof Switch> = {
},
size: {
control: { type: 'select' },
options: ['sm', 'md', 'lg'],
options: ['xs', 'sm', 'md', 'lg'],
description: 'The size of the switch'
},
defaultChecked: {
@@ -171,6 +171,30 @@ export const Controlled: Story = {
export const Sizes: Story = {
render: () => (
<div className="flex flex-col gap-6">
<div>
<p className="mb-3 text-sm text-muted-foreground">Extra small (xs)</p>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Switch id="size-xs-1" size="xs" />
<label htmlFor="size-xs-1" className="cursor-pointer text-sm">
Extra small switch
</label>
</div>
<div className="flex items-center gap-2">
<Switch id="size-xs-2" size="xs" defaultChecked />
<label htmlFor="size-xs-2" className="cursor-pointer text-sm">
Extra small switch (on)
</label>
</div>
<div className="flex items-center gap-2">
<Switch id="size-xs-3" size="xs" loading defaultChecked />
<label htmlFor="size-xs-3" className="cursor-pointer text-sm">
Extra small switch (loading)
</label>
</div>
</div>
</div>
<div>
<p className="mb-3 text-sm text-muted-foreground">Small (sm)</p>
<div className="flex flex-col gap-2">
@@ -292,6 +316,7 @@ export const DescriptionSwitchPositions: Story = {
export const DescriptionSwitchSizes: Story = {
render: () => (
<div className="flex w-96 flex-col gap-6">
<DescriptionSwitch label="Extra small switch" description="Dense table rows and compact controls" size="xs" />
<DescriptionSwitch label="Small switch" description="Compact size for dense layouts" size="sm" />
<DescriptionSwitch label="Medium switch" description="Default size for most use cases" size="md" defaultChecked />
<DescriptionSwitch label="Large switch" description="Larger size for emphasis" size="lg" />
@@ -319,6 +344,10 @@ export const SizeComparison: Story = {
<div className="flex flex-col gap-4">
<p className="text-xs font-medium text-muted-foreground">Off</p>
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2">
<Switch id="compare-xs-1" size="xs" />
<span className="text-xs text-muted-foreground">xs</span>
</div>
<div className="flex flex-col items-center gap-2">
<Switch id="compare-sm-1" size="sm" />
<span className="text-xs text-muted-foreground">sm</span>
@@ -337,6 +366,10 @@ export const SizeComparison: Story = {
<div className="flex flex-col gap-4">
<p className="text-xs font-medium text-muted-foreground">On</p>
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2">
<Switch id="compare-xs-2" size="xs" defaultChecked />
<span className="text-xs text-muted-foreground">xs</span>
</div>
<div className="flex flex-col items-center gap-2">
<Switch id="compare-sm-2" size="sm" defaultChecked />
<span className="text-xs text-muted-foreground">sm</span>
@@ -355,6 +388,10 @@ export const SizeComparison: Story = {
<div className="flex flex-col gap-4">
<p className="text-xs font-medium text-muted-foreground">Loading</p>
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2">
<Switch id="compare-xs-3" size="xs" loading defaultChecked />
<span className="text-xs text-muted-foreground">xs</span>
</div>
<div className="flex flex-col items-center gap-2">
<Switch id="compare-sm-3" size="sm" loading defaultChecked />
<span className="text-xs text-muted-foreground">sm</span>

View File

@@ -1,8 +1,7 @@
import * as Textarea from '@cherrystudio/ui/components/primitives/textarea'
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import * as Textarea from '../../../src/components/primitives/textarea'
const meta: Meta<typeof Textarea.Input> = {
title: 'Components/Primitives/Textarea',
component: Textarea.Input,

View File

@@ -1,6 +1,13 @@
import { resolve } from 'node:path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
alias: {
'@cherrystudio/ui': resolve(__dirname, 'src')
}
},
test: {
globals: true,
environment: 'node',

709
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,208 +1,202 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Github Releases Timeline</title>
<link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet" />
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.1/dist/markdown-it.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/typography@0.5.10/dist/typography.min.css"></script>
</head>
<body id="app">
<div :class="isDark ? 'dark-bg' : 'bg'" class="min-h-screen">
<div class="mx-auto max-w-3xl px-4 py-12">
<h1 class="mb-8 text-3xl font-bold" :class="isDark ? 'text-white' : 'text-gray-900'">Release Timeline</h1>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Github Releases Timeline</title>
<link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet" />
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.1/dist/markdown-it.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/typography@0.5.10/dist/typography.min.css"></script>
</head>
<!-- Loading状态 -->
<div v-if="loading" class="py-8 text-center">
<div
class="inline-block h-8 w-8 animate-spin rounded-full border-4"
:class="isDark ? 'border-gray-700 border-t-blue-500' : 'border-gray-300 border-t-blue-500'"></div>
</div>
<body id="app">
<div :class="isDark ? 'dark-bg' : 'bg'" class="min-h-screen">
<div class="mx-auto max-w-3xl px-4 py-12">
<h1 class="mb-8 text-3xl font-bold" :class="isDark ? 'text-white' : 'text-gray-900'">Release Timeline</h1>
<!-- Error 状态 -->
<div v-else-if="error" class="py-8 text-center text-red-500">{{ error }}</div>
<!-- Loading状态 -->
<div v-if="loading" class="py-8 text-center">
<div class="inline-block h-8 w-8 animate-spin rounded-full border-4"
:class="isDark ? 'border-gray-700 border-t-blue-500' : 'border-gray-300 border-t-blue-500'"></div>
</div>
<!-- Release 列表 -->
<div v-else class="space-y-8">
<div
v-for="release in releases"
:key="release.id"
class="relative pl-8"
:class="isDark ? 'border-l-2 border-gray-700' : 'border-l-2 border-gray-200'">
<div class="absolute top-0 -left-2 h-4 w-4 rounded-full bg-green-500"></div>
<div
class="rounded-lg p-6 shadow-sm transition-shadow"
:class="isDark ? 'bg-black hover:shadow-md hover:shadow-black' : 'bg-white hover:shadow-md'">
<div class="mb-4 flex items-start justify-between">
<div>
<h2 class="text-xl font-semibold" :class="isDark ? 'text-white' : 'text-gray-900'">
{{ release.name || release.tag_name }}
</h2>
<p class="mt-1 text-sm" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
{{ formatDate(release.published_at) }}
</p>
</div>
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium"
:class="isDark ? 'bg-green-900 text-green-200' : 'bg-green-100 text-green-800'">
{{ release.tag_name }}
</span>
<!-- Error 状态 -->
<div v-else-if="error" class="py-8 text-center text-red-500">{{ error }}</div>
<!-- Release 列表 -->
<div v-else class="space-y-8">
<div v-for="release in releases" :key="release.id" class="relative pl-8"
:class="isDark ? 'border-l-2 border-gray-700' : 'border-l-2 border-gray-200'">
<div class="absolute top-0 -left-2 h-4 w-4 rounded-full bg-green-500"></div>
<div class="rounded-lg p-6 shadow-sm transition-shadow"
:class="isDark ? 'bg-black hover:shadow-md hover:shadow-black' : 'bg-white hover:shadow-md'">
<div class="mb-4 flex items-start justify-between">
<div>
<h2 class="text-xl font-semibold" :class="isDark ? 'text-white' : 'text-gray-900'">
{{ release.name || release.tag_name }}
</h2>
<p class="mt-1 text-sm" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
{{ formatDate(release.published_at) }}
</p>
</div>
<div
class="prose"
:class="isDark ? 'text-gray-300 dark-prose' : 'text-gray-600'"
v-html="renderMarkdown(release.body)"></div>
<span class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium"
:class="isDark ? 'bg-green-900 text-green-200' : 'bg-green-100 text-green-800'">
{{ release.tag_name }}
</span>
</div>
<div class="prose" :class="isDark ? 'text-gray-300 dark-prose' : 'text-gray-600'"
v-html="renderMarkdown(release.body)"></div>
</div>
</div>
</div>
</div>
</div>
<script>
const md = window.markdownit({
breaks: true,
linkify: true
})
<script>
const md = window.markdownit({
breaks: true,
linkify: true
})
const { createApp } = Vue
const { createApp } = Vue
createApp({
data() {
return {
releases: [],
loading: true,
error: null,
isDark: false
}
},
methods: {
async fetchReleases() {
try {
this.loading = true
this.error = null
const response = await fetch('https://api.github.com/repos/kangfenmao/cherry-studio/releases')
if (!response.ok) {
throw new Error('Failed to fetch releases')
}
this.releases = await response.json()
} catch (err) {
this.error = 'Error loading releases: ' + err.message
} finally {
this.loading = false
}
},
formatDate(dateString) {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
},
renderMarkdown(content) {
if (!content) return ''
return md.render(content)
},
initTheme() {
// 从 URL 参数获取主题设置
const url = new URL(window.location.href)
const theme = url.searchParams.get('theme')
this.isDark = theme === 'dark'
}
},
mounted() {
this.initTheme()
this.fetchReleases()
createApp({
data() {
return {
releases: [],
loading: true,
error: null,
isDark: false
}
}).mount('#app')
</script>
<style>
/* 基础的 Markdown 样式 */
.prose {
line-height: 1.6;
},
methods: {
async fetchReleases() {
try {
this.loading = true
this.error = null
const response = await fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases')
if (!response.ok) {
throw new Error('Failed to fetch releases')
}
this.releases = await response.json()
} catch (err) {
this.error = 'Error loading releases: ' + err.message
} finally {
this.loading = false
}
},
formatDate(dateString) {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
},
renderMarkdown(content) {
if (!content) return ''
return md.render(content)
},
initTheme() {
// 从 URL 参数获取主题设置
const url = new URL(window.location.href)
const theme = url.searchParams.get('theme')
this.isDark = theme === 'dark'
}
},
mounted() {
this.initTheme()
this.fetchReleases()
}
}).mount('#app')
</script>
.prose h1 {
font-size: 1.5em;
margin: 1em 0;
}
<style>
/* 基础的 Markdown 样式 */
.prose {
line-height: 1.6;
}
.prose h2 {
font-size: 1.3em;
margin: 0.8em 0;
}
.prose h1 {
font-size: 1.5em;
margin: 1em 0;
}
.prose h3 {
font-size: 1.1em;
margin: 0.6em 0;
}
.prose h2 {
font-size: 1.3em;
margin: 0.8em 0;
}
.prose ul {
list-style-type: disc;
margin-left: 1.5em;
margin-bottom: 1em;
}
.prose h3 {
font-size: 1.1em;
margin: 0.6em 0;
}
.prose ol {
list-style-type: decimal;
margin-left: 1.5em;
margin-bottom: 1em;
}
.prose ul {
list-style-type: disc;
margin-left: 1.5em;
margin-bottom: 1em;
}
.prose code {
padding: 0.2em 0.4em;
border-radius: 0.2em;
font-size: 0.9em;
}
.prose ol {
list-style-type: decimal;
margin-left: 1.5em;
margin-bottom: 1em;
}
.dark .prose code {
background-color: #1f2937;
}
.prose code {
padding: 0.2em 0.4em;
border-radius: 0.2em;
font-size: 0.9em;
}
.prose code {
background-color: #f3f4f6;
}
.dark .prose code {
background-color: #1f2937;
}
.prose pre code {
display: block;
padding: 1em;
overflow-x: auto;
}
.prose code {
background-color: #f3f4f6;
}
.prose a {
color: #3b82f6;
text-decoration: underline;
}
.prose pre code {
display: block;
padding: 1em;
overflow-x: auto;
}
.dark .prose a {
color: #60a5fa;
}
.prose a {
color: #3b82f6;
text-decoration: underline;
}
.prose blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1em;
margin: 1em 0;
}
.dark .prose a {
color: #60a5fa;
}
.dark .prose blockquote {
border-left-color: #374151;
color: #9ca3af;
}
.prose blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1em;
margin: 1em 0;
}
.dark .prose {
color: #e5e7eb;
}
.dark .prose blockquote {
border-left-color: #374151;
color: #9ca3af;
}
.dark-bg {
background-color: #151515;
}
.dark .prose {
color: #e5e7eb;
}
.bg {
background-color: #f2f2f2;
}
</style>
</body>
</html>
.dark-bg {
background-color: #151515;
}
.bg {
background-color: #f2f2f2;
}
</style>
</body>
</html>

View File

@@ -349,7 +349,7 @@ async function getCachedRelease(env) {
async function checkNewRelease(env) {
try {
// 获取 GitHub 最新版本
const githubResponse = await fetch('https://api.github.com/repos/kangfenmao/cherry-studio/releases/latest', {
const githubResponse = await fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases/latest', {
headers: { 'User-Agent': 'CloudflareWorker' }
})

View File

@@ -25,6 +25,7 @@ import { PythonService } from '@main/services/PythonService'
import { QuickAssistantService } from '@main/services/QuickAssistantService'
import { SearchService } from '@main/services/SearchService'
import { SelectionService } from '@main/services/selection/SelectionService'
import { SettingsWindowService } from '@main/services/SettingsWindowService'
import { ShortcutService } from '@main/services/ShortcutService'
import { SpanCacheService } from '@main/services/SpanCacheService'
import { SubWindowService } from '@main/services/SubWindowService'
@@ -71,6 +72,7 @@ export const services = {
LanTransferService,
PowerMonitorService,
SelectionService,
SettingsWindowService,
ShortcutService,
ThemeService,
SpanCacheService,

View File

@@ -7,6 +7,7 @@ import type { BrowserWindow, BrowserWindowConstructorOptions, VisibleOnAllWorksp
*/
export enum WindowType {
Main = 'main',
Settings = 'settings',
QuickAssistant = 'quickAssistant',
SubWindow = 'subWindow',
SelectionToolbar = 'selectionToolbar',

View File

@@ -93,6 +93,36 @@ export const WINDOW_TYPE_REGISTRY: Partial<Record<WindowType, WindowTypeMetadata
}
},
// Settings window — singleton popup surface for application settings.
// The renderer consumes initData as the target /settings/* route, so open()
// can focus an existing settings window and navigate it in-place.
[WindowType.Settings]: {
type: WindowType.Settings,
lifecycle: 'singleton',
singletonConfig: {
retentionTime: 300
},
htmlPath: 'settings.html',
windowOptions: {
...DEFAULT_WINDOW_CONFIG,
width: 960,
height: 680,
minWidth: 760,
minHeight: 520,
autoHideMenuBar: true,
transparent: false,
vibrancy: 'sidebar',
visualEffectState: 'active',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
webSecurity: false,
webviewTag: true
}
}
},
// Detached tab window — multi-instance, one per user-detached Tab.
// Placed adjacent to Main because a SubWindow is logically a Main spin-off
// (a Tab dragged out of Main becomes its own BrowserWindow here; drag back

View File

@@ -65,4 +65,22 @@ describe('PreferenceSeeder', () => {
const after = (await dbh.db.select().from(preferenceTable)).length
expect(after).toBe(before)
})
it('should delete obsolete default preferences', async () => {
await dbh.db.insert(preferenceTable).values({
scope: 'default',
key: 'app.settings.open_target',
value: 'app'
})
const seed = new PreferenceSeeder()
await seed.run(dbh.db)
const rows = await dbh.db
.select()
.from(preferenceTable)
.where(and(eq(preferenceTable.scope, 'default'), eq(preferenceTable.key, 'app.settings.open_target')))
expect(rows).toHaveLength(0)
})
})

View File

@@ -1,9 +1,12 @@
import { preferenceTable } from '@data/db/schemas/preference'
import { DefaultPreferences } from '@shared/data/preference/preferenceSchemas'
import { and, eq } from 'drizzle-orm'
import type { DbType, ISeeder } from '../../types'
import { hashObject } from '../hashObject'
const OBSOLETE_DEFAULT_PREFERENCE_KEYS = ['app.settings.open_target'] as const
export class PreferenceSeeder implements ISeeder {
readonly name = 'preference'
readonly description = 'Insert default preference values'
@@ -14,6 +17,12 @@ export class PreferenceSeeder implements ISeeder {
}
async run(db: DbType): Promise<void> {
for (const obsoleteKey of OBSOLETE_DEFAULT_PREFERENCE_KEYS) {
await db
.delete(preferenceTable)
.where(and(eq(preferenceTable.scope, 'default'), eq(preferenceTable.key, obsoleteKey)))
}
const preferences = await db.select().from(preferenceTable)
// Convert existing preferences to a Map for quick lookup

View File

@@ -4,7 +4,6 @@ import { WindowType } from '@main/core/window/types'
import { getAppLanguage, locales } from '@main/utils/language'
import { handleZoomFactor } from '@main/utils/zoom'
import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes'
import { IpcChannel } from '@shared/IpcChannel'
import { findShortcutDefinition } from '@shared/shortcuts/definitions'
import type { ShortcutPreferenceKey } from '@shared/shortcuts/types'
import { resolveShortcutPreference } from '@shared/shortcuts/utils'
@@ -16,6 +15,7 @@ const zoomShortcutKeys: ShortcutPreferenceKey[] = [
'shortcut.general.zoom_out',
'shortcut.general.zoom_reset'
]
const menuShortcutKeys: ShortcutPreferenceKey[] = ['shortcut.general.show_settings', ...zoomShortcutKeys]
const getShortcutAccelerator = (key: ShortcutPreferenceKey): string | undefined => {
const definition = findShortcutDefinition(key)
@@ -44,7 +44,7 @@ export class AppMenuService extends BaseService {
const preferenceService = application.get('PreferenceService')
this.registerDisposable(preferenceService.subscribeChange('app.language', () => this.setupApplicationMenu()))
for (const key of zoomShortcutKeys) {
for (const key of menuShortcutKeys) {
this.registerDisposable(preferenceService.subscribeChange(key, () => this.setupApplicationMenu()))
}
@@ -55,6 +55,7 @@ export class AppMenuService extends BaseService {
const locale = locales[getAppLanguage()]
const { appMenu } = locale.translation
const settingsAccelerator = getShortcutAccelerator('shortcut.general.show_settings')
const zoomInAccelerator = getShortcutAccelerator('shortcut.general.zoom_in')
const zoomOutAccelerator = getShortcutAccelerator('shortcut.general.zoom_out')
const zoomResetAccelerator = getShortcutAccelerator('shortcut.general.zoom_reset')
@@ -66,8 +67,14 @@ export class AppMenuService extends BaseService {
{
label: appMenu.about + ' ' + app.name,
click: () => {
application.get('MainWindowService').showMainWindow()
application.get('WindowManager').broadcastToType(WindowType.Main, IpcChannel.MainWindow_NavigateToAbout)
application.get('SettingsWindowService').open('/settings/about')
}
},
{
label: locale.translation.settings.title,
accelerator: settingsAccelerator,
click: () => {
application.get('SettingsWindowService').open('/settings/provider')
}
},
{ type: 'separator' },

View File

@@ -41,7 +41,18 @@ export class MainWindowService extends BaseService {
constructor() {
super()
this._onMainWindowCreated = this.registerDisposable(new Emitter<BrowserWindow>())
this.onMainWindowCreated = this._onMainWindowCreated.event
this.onMainWindowCreated = (listener) => {
const disposable = this._onMainWindowCreated.event(listener)
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
try {
listener(this.mainWindow)
} catch (error) {
// Keep replay semantics aligned with Emitter.fire(): one listener must not break service init.
logger.error('Failed to replay main window listener', error as Error)
}
}
return disposable
}
}
protected async onInit() {
@@ -588,16 +599,12 @@ export class MainWindowService extends BaseService {
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
if (mainWindow.isFocused()) {
// if tray is enabled, hide the main window, else do nothing
if (application.get('PreferenceService').get('app.tray.on_close')) {
// Same pattern as the close handler: tell WM to stop counting Main
// toward Dock visibility BEFORE hiding, so the Dock coordinates with
// whatever else is alive (e.g. a SubWindow) rather than blindly hiding.
if (isMac) {
application.get('WindowManager').behavior.setMacShowInDockByType(WindowType.Main, false)
}
mainWindow.hide()
// Same pattern as the close handler when the user opted into tray-close:
// tell WM to stop counting Main toward Dock visibility BEFORE hiding.
if (isMac && application.get('PreferenceService').get('app.tray.on_close')) {
application.get('WindowManager').behavior.setMacShowInDockByType(WindowType.Main, false)
}
mainWindow.hide()
} else {
mainWindow.focus()
}

View File

@@ -0,0 +1,117 @@
import { application } from '@application'
import { isMac } from '@main/constant'
import { BaseService, DependsOn, Injectable, Phase, ServicePhase } from '@main/core/lifecycle'
import { type WindowOptions, WindowType } from '@main/core/window/types'
import type { SettingsPath } from '@shared/data/types/settingsPath'
import { normalizeSettingsPath } from '@shared/data/types/settingsPath'
import { IpcChannel } from '@shared/IpcChannel'
import type { BrowserWindow } from 'electron'
import { nativeTheme } from 'electron'
export function createSettingsWindowOptions(isMacPlatform: boolean, dark: boolean): Partial<WindowOptions> {
return {
darkTheme: dark,
...(!isMacPlatform && { backgroundColor: dark ? '#181818' : '#FFFFFF' })
}
}
@Injectable('SettingsWindowService')
@ServicePhase(Phase.WhenReady)
@DependsOn(['WindowManager'])
export class SettingsWindowService extends BaseService {
private readonly settingsWindowCleanups = new Map<string, () => void>()
protected async onInit() {
const wm = application.get('WindowManager')
this.registerDisposable(
wm.onWindowCreatedByType(WindowType.Settings, ({ id, window }) => {
this.setupSettingsWindow(id, window)
})
)
this.registerDisposable(() => {
for (const cleanup of this.settingsWindowCleanups.values()) {
cleanup()
}
this.settingsWindowCleanups.clear()
})
this.registerIpcHandlers()
}
private registerIpcHandlers(): void {
this.ipcHandle(IpcChannel.SettingsWindow_Open, (_event, path?: unknown) => {
return this.open(this.normalizePath(path))
})
}
public open(path?: SettingsPath): string {
const wm = application.get('WindowManager')
const options = this.getWindowOptions()
const windowId = wm.open(WindowType.Settings, {
initData: this.normalizePath(path),
options
})
this.syncSettingsWindowBounds(windowId, options)
return windowId
}
private setupSettingsWindow(windowId: string, window: BrowserWindow): void {
window.setTitle('')
const webContents = window.webContents
const onPageTitleUpdated = (event: Electron.Event) => {
event.preventDefault()
window.setTitle('')
}
let cleaned = false
const cleanup = () => {
if (cleaned) return
cleaned = true
window.off('closed', cleanup)
if (!webContents.isDestroyed()) {
webContents.off('page-title-updated', onPageTitleUpdated)
}
this.settingsWindowCleanups.delete(windowId)
}
window.once('closed', cleanup)
webContents.on('page-title-updated', onPageTitleUpdated)
this.settingsWindowCleanups.set(windowId, cleanup)
}
private getWindowOptions(): Partial<WindowOptions> {
return {
...createSettingsWindowOptions(isMac, nativeTheme.shouldUseDarkColors),
...this.getMainWindowBoundsOptions()
}
}
private getMainWindowBoundsOptions(): Pick<WindowOptions, 'x' | 'y' | 'width' | 'height'> | undefined {
const wm = application.get('WindowManager')
const mainWindowInfo = wm.getWindowsByType(WindowType.Main)[0]
if (!mainWindowInfo) return undefined
const mainWindow = wm.getWindow(mainWindowInfo.id)
if (!mainWindow || mainWindow.isDestroyed()) return undefined
const { x, y, width, height } = mainWindow.getBounds()
if (width <= 0 || height <= 0) return undefined
return { x, y, width, height }
}
private syncSettingsWindowBounds(windowId: string, options: Partial<WindowOptions>): void {
const { x, y, width, height } = options
if (x === undefined || y === undefined || !width || !height) return
const window = application.get('WindowManager').getWindow(windowId)
if (!window || window.isDestroyed()) return
window.setBounds({ x, y, width, height })
}
private normalizePath(path: unknown): SettingsPath {
return normalizeSettingsPath(path)
}
}

View File

@@ -1,5 +1,6 @@
import { application } from '@application'
import { loggerService } from '@logger'
import { isMac } from '@main/constant'
import { BaseService, DependsOn, Injectable, Phase, ServicePhase } from '@main/core/lifecycle'
import { WindowType } from '@main/core/window/types'
import { handleZoomFactor } from '@main/utils/zoom'
@@ -20,12 +21,13 @@ const toAccelerator = (keys: string[]): string => keys.join('+')
const relevantDefinitions = SHORTCUT_DEFINITIONS.filter(
(d) =>
d.scope !== 'renderer' &&
!(isMac && d.key === 'shortcut.general.show_settings') &&
(!d.supportedPlatforms || d.supportedPlatforms.includes(process.platform as SupportedPlatform))
)
@Injectable('ShortcutService')
@ServicePhase(Phase.WhenReady)
@DependsOn(['MainWindowService', 'SelectionService'])
@DependsOn(['MainWindowService', 'SelectionService', 'SettingsWindowService'])
export class ShortcutService extends BaseService {
private mainWindow: BrowserWindow | null = null
private handlers = new Map<ShortcutPreferenceKey, ShortcutHandler>()
@@ -53,8 +55,7 @@ export class ShortcutService extends BaseService {
})
this.handlers.set('shortcut.general.show_settings', () => {
application.get('MainWindowService').showMainWindow()
application.get('WindowManager').broadcastToType(WindowType.Main, IpcChannel.MainWindow_NavigateToSettings)
application.get('SettingsWindowService').open('/settings/provider')
})
this.handlers.set('shortcut.feature.quick_assistant.toggle_window', () => {
@@ -153,8 +154,8 @@ export class ShortcutService extends BaseService {
this.registerDisposable(() => window.off('closed', onClosed))
}
if (!window.isDestroyed() && window.isFocused()) {
this.registerShortcuts(window, false)
if (!window.isDestroyed()) {
this.registerShortcuts(window, !window.isFocused())
}
}

View File

@@ -0,0 +1,137 @@
import type { MenuItemConstructorOptions } from 'electron'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { applicationMock, menuMock, shellMock, appMock, preferenceServiceMock, settingsWindowServiceMock } = vi.hoisted(
() => {
const preferenceServiceMock = {
get: vi.fn(),
subscribeChange: vi.fn(() => ({ dispose: vi.fn() }))
}
const settingsWindowServiceMock = {
open: vi.fn()
}
return {
preferenceServiceMock,
settingsWindowServiceMock,
applicationMock: {
get: vi.fn((name: string) => {
if (name === 'PreferenceService') return preferenceServiceMock
if (name === 'SettingsWindowService') return settingsWindowServiceMock
if (name === 'WindowManager') {
return { getAllWindows: vi.fn(() => []) }
}
return undefined
})
},
menuMock: {
buildFromTemplate: vi.fn((template: MenuItemConstructorOptions[]) => ({ template })),
setApplicationMenu: vi.fn()
},
shellMock: {
openExternal: vi.fn()
},
appMock: {
name: 'Cherry Studio',
getLocale: vi.fn(() => 'en-US')
}
}
}
)
vi.mock('@application', () => ({
application: applicationMock
}))
vi.mock('@main/core/lifecycle', () => {
class MockBaseService {
protected readonly _disposables: Array<{ dispose: () => void } | (() => void)> = []
protected registerDisposable<T extends { dispose: () => void } | (() => void)>(disposable: T): T {
this._disposables.push(disposable)
return disposable
}
}
return {
BaseService: MockBaseService,
Conditional: () => (target: unknown) => target,
Injectable: () => (target: unknown) => target,
onPlatform: () => () => true,
ServicePhase: () => (target: unknown) => target,
Phase: { WhenReady: 'whenReady' }
}
})
vi.mock('@main/utils/zoom', () => ({
handleZoomFactor: vi.fn()
}))
vi.mock('electron', () => ({
app: appMock,
Menu: menuMock,
shell: shellMock
}))
import { handleZoomFactor } from '@main/utils/zoom'
import { AppMenuService } from '../AppMenuService'
const latestTemplate = () => menuMock.buildFromTemplate.mock.calls.at(-1)?.[0] as MenuItemConstructorOptions[]
describe('AppMenuService', () => {
let service: AppMenuService
beforeEach(() => {
vi.clearAllMocks()
preferenceServiceMock.get.mockReturnValue(undefined)
service = new AppMenuService()
})
it('registers the settings menu accelerator through the native app menu', async () => {
await (service as any).onInit()
const appSubmenu = latestTemplate()[0].submenu as MenuItemConstructorOptions[]
const settingsItem = appSubmenu.find((item) => item.label === 'Settings')
expect(settingsItem).toMatchObject({
accelerator: 'CommandOrControl+,'
})
settingsItem?.click?.(undefined as never, undefined as never, undefined as never)
expect(settingsWindowServiceMock.open).toHaveBeenCalledWith('/settings/provider')
})
it('opens the About settings route from the native app menu', async () => {
await (service as any).onInit()
const appSubmenu = latestTemplate()[0].submenu as MenuItemConstructorOptions[]
const aboutItem = appSubmenu.find((item) => String(item.label).startsWith('About '))
aboutItem?.click?.(undefined as never, undefined as never, undefined as never)
expect(settingsWindowServiceMock.open).toHaveBeenCalledWith('/settings/about')
})
it('uses default zoom accelerators and wires them to zoom handling', async () => {
await (service as any).onInit()
const viewSubmenu = latestTemplate()[3].submenu as MenuItemConstructorOptions[]
const zoomInItem = viewSubmenu.find((item) => item.accelerator === 'CommandOrControl+=')
const zoomOutItem = viewSubmenu.find((item) => item.accelerator === 'CommandOrControl+-')
const zoomResetItem = viewSubmenu.find((item) => item.accelerator === 'CommandOrControl+0')
expect(zoomInItem).toBeTruthy()
expect(zoomOutItem).toBeTruthy()
expect(zoomResetItem).toBeTruthy()
zoomInItem?.click?.(undefined as never, undefined as never, undefined as never)
zoomOutItem?.click?.(undefined as never, undefined as never, undefined as never)
zoomResetItem?.click?.(undefined as never, undefined as never, undefined as never)
expect(handleZoomFactor).toHaveBeenCalledWith([], 0.1)
expect(handleZoomFactor).toHaveBeenCalledWith([], -0.1)
expect(handleZoomFactor).toHaveBeenCalledWith([], 0, true)
})
})

View File

@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Hoisted state lets individual tests mutate platform flags / preferences without
// re-mocking modules. The mock factories below read these via getters, preserving
// live-binding semantics so each test sees the current value.
const { platformState, prefValues, applicationMock, windowManagerMock } = vi.hoisted(() => {
const { platformState, prefValues, applicationMock, windowManagerMock, loggerMock } = vi.hoisted(() => {
const platformState = { isMac: false, isWin: false, isLinux: false, isDev: false }
const prefValues: Record<string, unknown> = {
'app.tray.enabled': false,
@@ -26,6 +26,11 @@ const { platformState, prefValues, applicationMock, windowManagerMock } = vi.hoi
onWindowDestroyedByType: vi.fn(() => vi.fn()),
open: vi.fn(() => 'mock-window-id')
}
const loggerMock = {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn()
}
const applicationMock = {
isQuitting: false,
quit: vi.fn(),
@@ -41,7 +46,7 @@ const { platformState, prefValues, applicationMock, windowManagerMock } = vi.hoi
}),
getPath: vi.fn((key: string, filename?: string) => (filename ? `/mock/${key}/${filename}` : `/mock/${key}`))
}
return { platformState, prefValues, applicationMock, windowManagerMock }
return { platformState, prefValues, applicationMock, windowManagerMock, loggerMock }
})
vi.mock('@main/constant', () => ({
@@ -61,7 +66,7 @@ vi.mock('@main/constant', () => ({
vi.mock('@logger', () => ({
loggerService: {
withContext: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() })
withContext: () => loggerMock
}
}))
@@ -107,19 +112,36 @@ vi.mock('@main/core/lifecycle', async () => {
return { ...actual, BaseService: StubBase }
})
// Import after mocks
import { MainWindowService } from '../MainWindowService'
interface MockBrowserWindow extends EventEmitter {
isDestroyed: ReturnType<typeof vi.fn>
isFullScreen: ReturnType<typeof vi.fn>
isMinimized: ReturnType<typeof vi.fn>
isVisible: ReturnType<typeof vi.fn>
isFocused: ReturnType<typeof vi.fn>
hide: ReturnType<typeof vi.fn>
show: ReturnType<typeof vi.fn>
focus: ReturnType<typeof vi.fn>
restore: ReturnType<typeof vi.fn>
setVisibleOnAllWorkspaces: ReturnType<typeof vi.fn>
setFullScreen: ReturnType<typeof vi.fn>
webContents: { reload: ReturnType<typeof vi.fn>; on: ReturnType<typeof vi.fn> }
}
function createMockWindow(): MockBrowserWindow {
const win = new EventEmitter() as MockBrowserWindow
win.isDestroyed = vi.fn(() => false)
win.isFullScreen = vi.fn(() => false)
win.isMinimized = vi.fn(() => false)
win.isVisible = vi.fn(() => true)
win.isFocused = vi.fn(() => true)
win.hide = vi.fn()
win.show = vi.fn()
win.focus = vi.fn()
win.restore = vi.fn()
win.setVisibleOnAllWorkspaces = vi.fn()
win.setFullScreen = vi.fn()
win.webContents = {
reload: vi.fn(),
// capture render-process-gone listener for crash-recovery tests
@@ -164,6 +186,7 @@ describe('MainWindowService', () => {
applicationMock.quit.mockReset()
applicationMock.forceExit.mockReset()
windowManagerMock.behavior.setMacShowInDockByType.mockReset()
loggerMock.error.mockReset()
svc = new MainWindowService()
win = createMockWindow()
@@ -173,6 +196,28 @@ describe('MainWindowService', () => {
vi.clearAllMocks()
})
it('replays the existing main window to late subscribers', () => {
;(svc as any).mainWindow = win
const listener = vi.fn()
svc.onMainWindowCreated(listener)
expect(listener).toHaveBeenCalledWith(win)
})
it('logs late subscriber replay failures without throwing', () => {
;(svc as any).mainWindow = win
const error = new Error('listener failed')
const listener = vi.fn(() => {
throw error
})
expect(() => svc.onMainWindowCreated(listener)).not.toThrow()
expect(listener).toHaveBeenCalledWith(win)
expect(loggerMock.error).toHaveBeenCalledWith('Failed to replay main window listener', error)
})
describe('close handler', () => {
it('does nothing when application.isQuitting is true (lets native close proceed)', () => {
applicationMock.isQuitting = true
@@ -289,6 +334,39 @@ describe('MainWindowService', () => {
})
})
describe('toggleMainWindow', () => {
it('hides a focused visible main window even when tray-close is disabled', () => {
;(svc as any).mainWindow = win
prefValues['app.tray.on_close'] = false
svc.toggleMainWindow()
expect(win.hide).toHaveBeenCalledTimes(1)
expect(windowManagerMock.behavior.setMacShowInDockByType).not.toHaveBeenCalled()
})
it('focuses a visible unfocused main window instead of hiding it', () => {
;(svc as any).mainWindow = win
win.isFocused.mockReturnValue(false)
svc.toggleMainWindow()
expect(win.focus).toHaveBeenCalledTimes(1)
expect(win.hide).not.toHaveBeenCalled()
})
it('keeps Dock suppression when hiding on macOS with tray-close enabled', () => {
platformState.isMac = true
prefValues['app.tray.on_close'] = true
;(svc as any).mainWindow = win
svc.toggleMainWindow()
expect(windowManagerMock.behavior.setMacShowInDockByType).toHaveBeenCalledWith('main', false)
expect(win.hide).toHaveBeenCalledTimes(1)
})
})
describe('crash recovery', () => {
it('reloads webContents on first crash', () => {
attachCrashMonitor(svc, win)

View File

@@ -0,0 +1,236 @@
import { EventEmitter } from 'events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { applicationMock, windowManagerMock } = vi.hoisted(() => {
const windowManagerMock = {
open: vi.fn<(type: string, args?: { initData?: unknown; options?: unknown }) => string>(() => 'settings-window-id'),
getWindow: vi.fn<(id: string) => unknown>(() => undefined),
getWindowsByType: vi.fn<(type: string) => unknown[]>(() => []),
getWindowIdByWebContents: vi.fn<(sender: unknown) => string | null>(() => null),
close: vi.fn<(id: string) => void>(),
onWindowCreatedByType: vi.fn(() => ({ dispose: vi.fn() })),
onWindowDestroyedByType: vi.fn(() => ({ dispose: vi.fn() }))
}
const applicationMock = {
get: vi.fn((name: string) => {
if (name === 'WindowManager') return windowManagerMock
throw new Error(`unexpected service: ${name}`)
})
}
return { applicationMock, windowManagerMock }
})
vi.mock('@application', () => ({ application: applicationMock }))
vi.mock('electron', () => ({
nativeTheme: {
shouldUseDarkColors: false
}
}))
vi.mock('@main/core/lifecycle', async () => {
const actual = (await vi.importActual('@main/core/lifecycle')) as Record<string, unknown>
class StubBase {
protected ipcHandle = vi.fn()
protected registerDisposable = vi.fn(<T>(disposable: T) => disposable)
}
return { ...actual, BaseService: StubBase }
})
import { WindowType } from '@main/core/window/types'
import { IpcChannel } from '@shared/IpcChannel'
import { createSettingsWindowOptions, SettingsWindowService } from '../SettingsWindowService'
interface MockWebContents extends EventEmitter {
send: ReturnType<typeof vi.fn>
isDestroyed: ReturnType<typeof vi.fn>
}
interface MockBrowserWindow extends EventEmitter {
webContents: MockWebContents
setTitle: ReturnType<typeof vi.fn>
getBounds: ReturnType<typeof vi.fn>
setBounds: ReturnType<typeof vi.fn>
isDestroyed: ReturnType<typeof vi.fn>
isMinimized: ReturnType<typeof vi.fn>
isVisible: ReturnType<typeof vi.fn>
restore: ReturnType<typeof vi.fn>
show: ReturnType<typeof vi.fn>
focus: ReturnType<typeof vi.fn>
}
function createMockWindow(): MockBrowserWindow {
const window = new EventEmitter() as MockBrowserWindow
window.webContents = new EventEmitter() as MockWebContents
window.webContents.send = vi.fn()
window.webContents.isDestroyed = vi.fn(() => false)
window.setTitle = vi.fn()
window.getBounds = vi.fn(() => ({ x: 0, y: 0, width: 1280, height: 800 }))
window.setBounds = vi.fn()
window.isDestroyed = vi.fn(() => false)
window.isMinimized = vi.fn(() => false)
window.isVisible = vi.fn(() => false)
window.restore = vi.fn()
window.show = vi.fn()
window.focus = vi.fn()
return window
}
function getCreatedListener() {
const call = windowManagerMock.onWindowCreatedByType.mock.calls.at(-1)
if (!call) throw new Error('onWindowCreatedByType was not registered')
return (call as unknown as [WindowType, (managed: { id: string; window: MockBrowserWindow }) => void])[1]
}
function getIpcHandleHandler(service: SettingsWindowService, channel: string) {
const call = (service as any).ipcHandle.mock.calls.find(
([registeredChannel]: [string]) => registeredChannel === channel
)
if (!call) throw new Error(`ipcHandle handler not registered for channel: ${channel}`)
return call[1]
}
function mockManagedWindows({
mainWindow,
settingsWindow
}: {
mainWindow: MockBrowserWindow
settingsWindow?: MockBrowserWindow
}) {
windowManagerMock.getWindowsByType.mockImplementation((type: string) => {
if (type === WindowType.Main) return [{ id: 'main-window-id' }]
if (type === WindowType.Settings && settingsWindow) return [{ id: 'settings-window-id' }]
return []
})
windowManagerMock.getWindow.mockImplementation((id: string) => {
if (id === 'main-window-id') return mainWindow
if (id === 'settings-window-id') return settingsWindow
return undefined
})
}
describe('SettingsWindowService', () => {
let service: SettingsWindowService
beforeEach(async () => {
vi.clearAllMocks()
vi.useRealTimers()
windowManagerMock.open.mockReset().mockReturnValue('settings-window-id')
windowManagerMock.getWindow.mockReset().mockReturnValue(undefined)
windowManagerMock.getWindowsByType.mockReset().mockReturnValue([])
windowManagerMock.getWindowIdByWebContents.mockReset().mockReturnValue(null)
windowManagerMock.close.mockReset()
windowManagerMock.onWindowCreatedByType.mockReset().mockReturnValue({ dispose: vi.fn() })
windowManagerMock.onWindowDestroyedByType.mockReset().mockReturnValue({ dispose: vi.fn() })
service = new SettingsWindowService()
await (service as any).onInit()
})
it('registers settings IPC and opens the settings window through the service', () => {
const handler = getIpcHandleHandler(service, IpcChannel.SettingsWindow_Open)
handler({}, '/settings/about')
expect(windowManagerMock.open).toHaveBeenCalledWith(
WindowType.Settings,
expect.objectContaining({ initData: '/settings/about' })
)
expect(windowManagerMock.getWindow).not.toHaveBeenCalled()
})
it('tracks lifecycle disposables for window subscriptions and settings window cleanup', () => {
expect((service as any).registerDisposable).toHaveBeenCalledWith(
windowManagerMock.onWindowCreatedByType.mock.results[0].value
)
expect((service as any).registerDisposable).toHaveBeenCalledWith(expect.any(Function))
})
it('normalizes non-settings paths to the provider settings page', () => {
const handler = getIpcHandleHandler(service, IpcChannel.SettingsWindow_Open)
handler({}, '/agents')
expect(windowManagerMock.open).toHaveBeenCalledWith(
WindowType.Settings,
expect.objectContaining({ initData: '/settings/provider' })
)
})
it('opens the standalone settings window over the current main window bounds', () => {
const mainWindow = createMockWindow()
const settingsWindow = createMockWindow()
mainWindow.getBounds.mockReturnValue({ x: 20, y: 40, width: 1440, height: 900 })
mockManagedWindows({ mainWindow, settingsWindow })
service.open('/settings/about')
expect(windowManagerMock.open).toHaveBeenCalledWith(
WindowType.Settings,
expect.objectContaining({
options: expect.objectContaining({
x: 20,
y: 40,
width: 1440,
height: 900
})
})
)
expect(settingsWindow.setBounds).toHaveBeenCalledWith({ x: 20, y: 40, width: 1440, height: 900 })
})
it('keeps the native title empty even when the page title changes', () => {
const window = createMockWindow()
const event = { preventDefault: vi.fn() }
getCreatedListener()({ id: 'settings-window-id', window })
window.webContents.emit('page-title-updated', event)
expect(window.setTitle).toHaveBeenCalledWith('')
expect(event.preventDefault).toHaveBeenCalledOnce()
})
it('removes settings window listeners when the window closes', () => {
const window = createMockWindow()
const webContents = window.webContents
const event = { preventDefault: vi.fn() }
getCreatedListener()({ id: 'settings-window-id', window })
window.emit('closed')
webContents.emit('page-title-updated', event)
expect(event.preventDefault).not.toHaveBeenCalled()
expect(window.setTitle).toHaveBeenCalledOnce()
})
it('does not read BrowserWindow.webContents during closed cleanup', () => {
const window = createMockWindow()
const webContents = window.webContents
getCreatedListener()({ id: 'settings-window-id', window })
Object.defineProperty(window, 'webContents', {
configurable: true,
get: () => {
throw new TypeError('Object has been destroyed')
}
})
expect(() => window.emit('closed')).not.toThrow()
webContents.emit('page-title-updated', { preventDefault: vi.fn() })
expect(window.setTitle).toHaveBeenCalledOnce()
})
it('uses platform-specific settings window options', () => {
expect(createSettingsWindowOptions(true, true)).toEqual({ darkTheme: true })
expect(createSettingsWindowOptions(true, false)).toEqual({ darkTheme: false })
expect(createSettingsWindowOptions(false, true)).toEqual({
darkTheme: true,
backgroundColor: '#181818'
})
expect(createSettingsWindowOptions(false, false)).toEqual({
darkTheme: false,
backgroundColor: '#FFFFFF'
})
})
})

View File

@@ -16,28 +16,38 @@ vi.mock('@data/PreferenceService', async () => {
return MockMainPreferenceServiceExport
})
const { windowServiceMock, windowManagerMock, selectionServiceMock, quickAssistantServiceMock, globalShortcutMock } =
vi.hoisted(() => ({
windowServiceMock: {
onMainWindowCreated: vi.fn(),
showMainWindow: vi.fn(),
toggleMainWindow: vi.fn()
},
windowManagerMock: {
broadcastToType: vi.fn()
},
selectionServiceMock: {
toggleEnabled: vi.fn(),
processSelectTextByShortcut: vi.fn()
},
quickAssistantServiceMock: {
toggleQuickAssistant: vi.fn()
},
globalShortcutMock: {
register: vi.fn(),
unregister: vi.fn()
}
}))
const {
windowServiceMock,
windowManagerMock,
selectionServiceMock,
settingsWindowServiceMock,
quickAssistantServiceMock,
globalShortcutMock
} = vi.hoisted(() => ({
windowServiceMock: {
onMainWindowCreated: vi.fn(),
showMainWindow: vi.fn(),
toggleMainWindow: vi.fn()
},
windowManagerMock: {
open: vi.fn(),
broadcastToType: vi.fn()
},
selectionServiceMock: {
toggleEnabled: vi.fn(),
processSelectTextByShortcut: vi.fn()
},
settingsWindowServiceMock: {
open: vi.fn()
},
quickAssistantServiceMock: {
toggleQuickAssistant: vi.fn()
},
globalShortcutMock: {
register: vi.fn(),
unregister: vi.fn()
}
}))
vi.mock('@application', async () => {
const { mockApplicationFactory } = await import('@test-mocks/main/application')
@@ -45,6 +55,7 @@ vi.mock('@application', async () => {
MainWindowService: windowServiceMock,
WindowManager: windowManagerMock,
SelectionService: selectionServiceMock,
SettingsWindowService: settingsWindowServiceMock,
QuickAssistantService: quickAssistantServiceMock
} as any)
})
@@ -84,6 +95,7 @@ import { MockMainPreferenceServiceUtils } from '@test-mocks/main/PreferenceServi
import { ShortcutService } from '../ShortcutService'
const supportsSelectionShortcuts = ['darwin', 'win32'].includes(process.platform)
const settingsShortcutHandledByNativeMenu = process.platform === 'darwin'
class MockBrowserWindow {
private readonly events = new EventEmitter()
@@ -163,23 +175,62 @@ describe('ShortcutService', () => {
it('registers focused window shortcuts including shortcut variants', async () => {
await (service as any).onInit()
expect(globalShortcutMock.register).toHaveBeenCalledWith('CommandOrControl+,', expect.any(Function))
if (settingsShortcutHandledByNativeMenu) {
expect(globalShortcutMock.register).not.toHaveBeenCalledWith('CommandOrControl+,', expect.any(Function))
} else {
expect(globalShortcutMock.register).toHaveBeenCalledWith('CommandOrControl+,', expect.any(Function))
}
expect(globalShortcutMock.register).toHaveBeenCalledWith('CommandOrControl+=', expect.any(Function))
expect(globalShortcutMock.register).toHaveBeenCalledWith('CommandOrControl+numadd', expect.any(Function))
})
it('registers global shortcuts immediately for an unfocused main window', async () => {
MockMainPreferenceServiceUtils.setPreferenceValue('shortcut.general.show_main_window', {
binding: ['CommandOrControl', 'M'],
enabled: true
})
mainWindow.setFocused(false)
await (service as any).onInit()
expect(globalShortcutMock.register).toHaveBeenCalledWith('CommandOrControl+M', expect.any(Function))
expect(globalShortcutMock.register).not.toHaveBeenCalledWith('CommandOrControl+=', expect.any(Function))
const showMainRegistration = globalShortcutMock.register.mock.calls.find(
([accelerator]) => accelerator === 'CommandOrControl+M'
)
const showMainHandler = showMainRegistration?.[1] as (() => void) | undefined
showMainHandler?.()
expect(windowServiceMock.toggleMainWindow).toHaveBeenCalledTimes(1)
})
it('opens the settings window through SettingsWindowService', async () => {
await (service as any).onInit()
const handler = (service as any).handlers.get('shortcut.general.show_settings') as (() => void) | undefined
handler?.()
expect(settingsWindowServiceMock.open).toHaveBeenCalledWith('/settings/provider')
expect(windowServiceMock.showMainWindow).not.toHaveBeenCalled()
expect(windowManagerMock.broadcastToType).not.toHaveBeenCalledWith(
WindowType.Main,
IpcChannel.MainWindow_NavigateToSettings
)
})
it('re-registers only the changed accelerator when shortcut binding changes', async () => {
await (service as any).onInit()
globalShortcutMock.register.mockClear()
globalShortcutMock.unregister.mockClear()
MockMainPreferenceServiceUtils.setPreferenceValue('shortcut.general.show_settings', {
binding: ['Alt', ','],
MockMainPreferenceServiceUtils.setPreferenceValue('shortcut.general.zoom_in', {
binding: ['Alt', '='],
enabled: true
})
expect(globalShortcutMock.unregister).toHaveBeenCalledWith('CommandOrControl+,')
expect(globalShortcutMock.register).toHaveBeenCalledWith('Alt+,', expect.any(Function))
expect(globalShortcutMock.unregister).toHaveBeenCalledWith('CommandOrControl+=')
expect(globalShortcutMock.register).toHaveBeenCalledWith('Alt+=', expect.any(Function))
expect(globalShortcutMock.register).not.toHaveBeenCalledWith('CommandOrControl+=', expect.any(Function))
})
@@ -256,7 +307,7 @@ describe('ShortcutService', () => {
})
it('notifies the renderer when a shortcut cannot be registered', async () => {
globalShortcutMock.register.mockImplementation((accelerator: string) => accelerator !== 'CommandOrControl+,')
globalShortcutMock.register.mockImplementation((accelerator: string) => accelerator !== 'CommandOrControl+0')
await (service as any).onInit()
@@ -264,15 +315,15 @@ describe('ShortcutService', () => {
WindowType.Main,
IpcChannel.Shortcut_RegistrationConflict,
{
key: 'shortcut.general.show_settings',
accelerator: 'CommandOrControl+,',
key: 'shortcut.general.zoom_reset',
accelerator: 'CommandOrControl+0',
hasConflict: true
}
)
})
it('does not notify repeatedly for the same shortcut conflict', async () => {
globalShortcutMock.register.mockImplementation((accelerator: string) => accelerator !== 'CommandOrControl+,')
globalShortcutMock.register.mockImplementation((accelerator: string) => accelerator !== 'CommandOrControl+0')
await (service as any).onInit()
windowManagerMock.broadcastToType.mockClear()
@@ -283,7 +334,7 @@ describe('ShortcutService', () => {
WindowType.Main,
IpcChannel.Shortcut_RegistrationConflict,
expect.objectContaining({
key: 'shortcut.general.show_settings',
key: 'shortcut.general.zoom_reset',
hasConflict: true
})
)

View File

@@ -72,25 +72,31 @@ export class ProtocolService extends BaseService {
private handleProtocolUrl(url: string) {
if (!url) return
const urlObj = new URL(url)
const params = new URLSearchParams(urlObj.search)
try {
const urlObj = new URL(url)
const params = new URLSearchParams(urlObj.search)
switch (urlObj.hostname.toLowerCase()) {
case 'mcp':
handleMcpProtocolUrl(urlObj)
return
case 'providers':
void handleProvidersProtocolUrl(urlObj)
return
case 'navigate':
handleNavigateProtocolUrl(urlObj)
return
switch (urlObj.hostname.toLowerCase()) {
case 'mcp':
handleMcpProtocolUrl(urlObj)
return
case 'providers':
handleProvidersProtocolUrl(urlObj).catch((error) =>
logger.error('Failed to handle providers protocol URL', error as Error)
)
return
case 'navigate':
handleNavigateProtocolUrl(urlObj)
return
}
application.get('WindowManager').broadcastToType(WindowType.Main, 'protocol-data', {
url,
params: Object.fromEntries(params.entries())
})
} catch (error) {
logger.error('Failed to handle protocol URL', error as Error)
}
application.get('WindowManager').broadcastToType(WindowType.Main, 'protocol-data', {
url,
params: Object.fromEntries(params.entries())
})
}
private handleArgvForUrl(args: string[]) {

View File

@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { appMock, loggerMock, handlersMock, windowManagerMock } = vi.hoisted(() => {
const appMock = {
on: vi.fn(),
removeListener: vi.fn(),
setAsDefaultProtocolClient: vi.fn()
}
const loggerMock = {
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn()
}
const handlersMock = {
handleMcpProtocolUrl: vi.fn(),
handleNavigateProtocolUrl: vi.fn(),
handleProvidersProtocolUrl: vi.fn()
}
const windowManagerMock = {
broadcastToType: vi.fn()
}
return { appMock, loggerMock, handlersMock, windowManagerMock }
})
vi.mock('electron', () => ({ app: appMock }))
vi.mock('@logger', () => ({
loggerService: {
withContext: () => loggerMock
}
}))
vi.mock('@application', () => ({
application: {
get: (name: string) => {
if (name === 'WindowManager') return windowManagerMock
throw new Error(`unexpected service: ${name}`)
},
getPath: (key: string, filename?: string) => (filename ? `/mock/${key}/${filename}` : `/mock/${key}`)
}
}))
vi.mock('@main/core/lifecycle', () => {
class MockBaseService {
protected registerDisposable<T>(disposable: T): T {
return disposable
}
}
return {
BaseService: MockBaseService,
Injectable: () => (target: unknown) => target,
ServicePhase: () => (target: unknown) => target,
Phase: { Background: 'background' }
}
})
vi.mock('../handlers/mcpInstall', () => ({
handleMcpProtocolUrl: handlersMock.handleMcpProtocolUrl
}))
vi.mock('../handlers/navigate', () => ({
handleNavigateProtocolUrl: handlersMock.handleNavigateProtocolUrl
}))
vi.mock('../handlers/providersImport', () => ({
handleProvidersProtocolUrl: handlersMock.handleProvidersProtocolUrl
}))
import { WindowType } from '@main/core/window/types'
import { ProtocolService } from '../ProtocolService'
describe('ProtocolService', () => {
let service: ProtocolService
beforeEach(() => {
vi.clearAllMocks()
service = new ProtocolService()
})
it('logs malformed protocol URLs instead of throwing', () => {
expect(() => (service as any).handleProtocolUrl('not a url')).not.toThrow()
expect(loggerMock.error).toHaveBeenCalledWith('Failed to handle protocol URL', expect.any(TypeError))
})
it('logs asynchronous providers handler failures', async () => {
const error = new Error('failed')
handlersMock.handleProvidersProtocolUrl.mockRejectedValueOnce(error)
;(service as any).handleProtocolUrl('cherrystudio://providers/api-keys?v=1&data=abc')
await vi.waitFor(() => {
expect(loggerMock.error).toHaveBeenCalledWith('Failed to handle providers protocol URL', error)
})
})
it('broadcasts unknown protocol hosts to main windows', () => {
;(service as any).handleProtocolUrl('cherrystudio://unknown/path?foo=bar')
expect(windowManagerMock.broadcastToType).toHaveBeenCalledWith(WindowType.Main, 'protocol-data', {
url: 'cherrystudio://unknown/path?foo=bar',
params: { foo: 'bar' }
})
})
})

View File

@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { applicationMock, loggerMock, mainWindowServiceMock, settingsWindowServiceMock } = vi.hoisted(() => {
const mainWindowServiceMock = {
getMainWindow: vi.fn(),
showMainWindow: vi.fn()
}
const settingsWindowServiceMock = {
open: vi.fn()
}
const loggerMock = {
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn()
}
const applicationMock = {
get: vi.fn((name: string) => {
if (name === 'MainWindowService') return mainWindowServiceMock
if (name === 'SettingsWindowService') return settingsWindowServiceMock
throw new Error(`unexpected service: ${name}`)
})
}
return { applicationMock, loggerMock, mainWindowServiceMock, settingsWindowServiceMock }
})
vi.mock('@application', () => ({ application: applicationMock }))
vi.mock('@logger', () => ({
loggerService: {
withContext: () => loggerMock
}
}))
vi.mock('@main/constant', () => ({
isMac: false
}))
import { handleNavigateProtocolUrl } from '../navigate'
describe('navigate protocol handler', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
})
it('blocks paths outside the route allowlist', () => {
handleNavigateProtocolUrl(new URL('cherrystudio://navigate/agents-legacy'))
expect(loggerMock.warn).toHaveBeenCalledWith('Blocked navigation to disallowed route: /agents-legacy')
expect(mainWindowServiceMock.getMainWindow).not.toHaveBeenCalled()
})
it('opens settings routes through SettingsWindowService', () => {
handleNavigateProtocolUrl(new URL('cherrystudio://navigate/settings/provider?id=openai'))
expect(settingsWindowServiceMock.open).toHaveBeenCalledWith('/settings/provider?id=openai')
expect(mainWindowServiceMock.getMainWindow).not.toHaveBeenCalled()
})
it('passes query strings to window.navigate without string interpolation injection', async () => {
const executeJavaScript = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(undefined)
mainWindowServiceMock.getMainWindow.mockReturnValue({
isDestroyed: () => false,
webContents: { executeJavaScript }
})
handleNavigateProtocolUrl(new URL("cherrystudio://navigate/agents?x=');attackerCode();//"))
await vi.waitFor(() => {
expect(executeJavaScript).toHaveBeenCalledTimes(2)
})
expect(executeJavaScript).toHaveBeenNthCalledWith(1, `typeof window.navigate === 'function'`)
expect(executeJavaScript).toHaveBeenNthCalledWith(
2,
`window.navigate({ to: ${JSON.stringify("/agents?x=');attackerCode();//")} })`
)
})
it('retries when the main window is not available yet', () => {
vi.useFakeTimers()
mainWindowServiceMock.getMainWindow.mockReturnValue(null)
handleNavigateProtocolUrl(new URL('cherrystudio://navigate/agents'))
expect(mainWindowServiceMock.getMainWindow).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(1000)
expect(mainWindowServiceMock.getMainWindow).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { applicationMock, loggerMock, settingsWindowServiceMock } = vi.hoisted(() => {
const settingsWindowServiceMock = {
open: vi.fn()
}
const loggerMock = {
debug: vi.fn(),
error: vi.fn()
}
const applicationMock = {
get: vi.fn((name: string) => {
if (name === 'SettingsWindowService') return settingsWindowServiceMock
throw new Error(`unexpected service: ${name}`)
})
}
return { applicationMock, loggerMock, settingsWindowServiceMock }
})
vi.mock('@application', () => ({ application: applicationMock }))
vi.mock('@logger', () => ({
loggerService: {
withContext: () => loggerMock
}
}))
import { handleProvidersProtocolUrl, parseProvidersImportData } from '../providersImport'
const toUrlSafeBase64 = (value: unknown) =>
Buffer.from(JSON.stringify(value), 'utf-8').toString('base64').replaceAll('+', '_').replaceAll('/', '-')
describe('providersImport protocol handler', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('opens provider settings with decoded provider import data', async () => {
const config = {
id: 'tokenflux',
baseUrl: 'https://tokenflux.ai/v1',
apiKey: 'sk-test',
name: 'TokenFlux',
type: 'openai'
}
const data = toUrlSafeBase64(config)
await handleProvidersProtocolUrl(new URL(`cherrystudio://providers/api-keys?v=1&data=${data}`))
expect(settingsWindowServiceMock.open).toHaveBeenCalledWith(
`/settings/provider?addProviderData=${encodeURIComponent(JSON.stringify(config))}`
)
})
it('does not open settings when provider import data is invalid', async () => {
await handleProvidersProtocolUrl(new URL('cherrystudio://providers/api-keys?v=1&data=not-json'))
expect(settingsWindowServiceMock.open).not.toHaveBeenCalled()
expect(loggerMock.error).toHaveBeenCalled()
})
it('preserves standard base64 plus and slash characters through URL parsing', async () => {
const config = { id: 'tokenflux', apiKey: 'sk-10895-Ͽ' }
const data = Buffer.from(JSON.stringify(config), 'utf-8').toString('base64')
expect(data).toContain('+')
expect(data).toContain('/')
await handleProvidersProtocolUrl(new URL(`cherrystudio://providers/api-keys?v=1&data=${data}`))
expect(settingsWindowServiceMock.open).toHaveBeenCalledWith(
`/settings/provider?addProviderData=${encodeURIComponent(JSON.stringify(config))}`
)
})
it('parses wrapped legacy provider import payloads', () => {
const payload = Buffer.from("({'id':'tokenflux'})", 'utf-8').toString('base64')
expect(parseProvidersImportData(payload)).toBe(JSON.stringify({ id: 'tokenflux' }))
})
})

View File

@@ -1,12 +1,12 @@
import { application } from '@application'
import { loggerService } from '@logger'
import { isMac } from '@main/constant'
import { normalizeSettingsPath } from '@shared/data/types/settingsPath'
const logger = loggerService.withContext('ProtocolService:navigate')
// Allowed route prefixes to prevent arbitrary navigation
const ALLOWED_ROUTES = [
'/settings/',
const ALLOWED_ROUTE_PREFIXES = [
'/settings',
'/agents',
'/knowledge',
'/openclaw',
@@ -17,10 +17,12 @@ const ALLOWED_ROUTES = [
'/apps',
'/code',
'/store',
'/launchpad',
'/'
'/launchpad'
]
const isAllowedRoute = (path: string): boolean =>
ALLOWED_ROUTE_PREFIXES.some((route) => path === route || path.startsWith(`${route}/`))
/**
* Handle cherrystudio://navigate/<path> deep links.
*
@@ -33,7 +35,7 @@ export function handleNavigateProtocolUrl(url: URL) {
const targetPath = url.pathname || '/'
const normalizedPath = targetPath.startsWith('/') ? targetPath : `/${targetPath}`
if (!ALLOWED_ROUTES.some((route) => normalizedPath === route || normalizedPath.startsWith(route))) {
if (!isAllowedRoute(normalizedPath)) {
logger.warn(`Blocked navigation to disallowed route: ${normalizedPath}`)
return
}
@@ -44,27 +46,37 @@ export function handleNavigateProtocolUrl(url: URL) {
logger.debug('handleNavigateProtocolUrl', { path: fullPath })
const mainWindow = application.get('MainWindowService').getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents
.executeJavaScript(`typeof window.navigate === 'function'`)
.then((hasNavigate) => {
if (hasNavigate) {
void mainWindow.webContents.executeJavaScript(`window.navigate('${fullPath}')`)
if (isMac) {
application.get('MainWindowService').showMainWindow()
}
} else {
logger.warn('window.navigate not available yet, retrying in 1s')
setTimeout(() => handleNavigateProtocolUrl(url), 1000)
}
})
.catch((error) => {
logger.error('Failed to navigate:', error as Error)
})
} else {
logger.warn('Main window not available, retrying in 1s')
setTimeout(() => handleNavigateProtocolUrl(url), 1000)
if (fullPath.startsWith('/settings/')) {
application.get('SettingsWindowService').open(normalizeSettingsPath(fullPath))
return
}
const navigateMainWindow = async () => {
const mainWindow = application.get('MainWindowService').getMainWindow()
if (!mainWindow || mainWindow.isDestroyed()) {
logger.warn('Main window not available, retrying in 1s')
setTimeout(() => handleNavigateProtocolUrl(url), 1000)
return
}
try {
const hasNavigate = await mainWindow.webContents.executeJavaScript(`typeof window.navigate === 'function'`)
if (!hasNavigate) {
logger.warn('window.navigate not available yet, retrying in 1s')
setTimeout(() => handleNavigateProtocolUrl(url), 1000)
return
}
await mainWindow.webContents.executeJavaScript(`window.navigate({ to: ${JSON.stringify(fullPath)} })`)
if (isMac) {
application.get('MainWindowService').showMainWindow()
}
} catch (error) {
logger.error('Failed to navigate:', error as Error)
}
}
void navigateMainWindow()
}

View File

@@ -1,9 +1,9 @@
import { application } from '@application'
import { loggerService } from '@logger'
import { isMac } from '@main/constant'
const logger = loggerService.withContext('ProtocolService:providersImport')
function ParseData(data: string) {
export function parseProvidersImportData(data: string) {
try {
const result = JSON.parse(
Buffer.from(data, 'base64').toString('utf-8').replaceAll("'", '"').replaceAll('(', '').replaceAll(')', '')
@@ -11,7 +11,7 @@ function ParseData(data: string) {
return JSON.stringify(result)
} catch (error) {
logger.error('ParseData error:', error as Error)
logger.error('parseProvidersImportData error:', error as Error)
return null
}
}
@@ -32,39 +32,20 @@ export async function handleProvidersProtocolUrl(url: URL) {
// replace + and / to _ and - because + and / are processed by URLSearchParams
const processedSearch = url.search.replaceAll('+', '_').replaceAll('/', '-')
const params = new URLSearchParams(processedSearch)
const data = ParseData(params.get('data')?.replaceAll('_', '+').replaceAll('-', '/') || '')
const data = parseProvidersImportData(params.get('data')?.replaceAll('_', '+').replaceAll('-', '/') || '')
if (!data) {
logger.error('handleProvidersProtocolUrl data is null or invalid')
return
}
const mainWindow = application.get('MainWindowService').getMainWindow()
const version = params.get('v')
if (version == '1') {
// TODO: handle different version
logger.debug('handleProvidersProtocolUrl', { data, version })
}
// add check there is window.navigate function in mainWindow
if (
mainWindow &&
!mainWindow.isDestroyed() &&
(await mainWindow.webContents.executeJavaScript(`typeof window.navigate === 'function'`))
) {
void mainWindow.webContents.executeJavaScript(
`window.navigate('/settings/provider?addProviderData=${encodeURIComponent(data)}')`
)
if (isMac) {
application.get('MainWindowService').showMainWindow()
}
} else {
setTimeout(() => {
logger.debug('handleProvidersProtocolUrl timeout', { data, version })
void handleProvidersProtocolUrl(url)
}, 1000)
}
application.get('SettingsWindowService').open(`/settings/provider?addProviderData=${encodeURIComponent(data)}`)
break
}
default:

View File

@@ -33,6 +33,7 @@ import type {
KnowledgeSearchResult as KnowledgeVectorSearchResult,
RestoreKnowledgeBaseDto
} from '@shared/data/types/knowledge'
import type { SettingsPath } from '@shared/data/types/settingsPath'
import type {
WebSearchFetchUrlsRequest,
WebSearchResponse,
@@ -560,6 +561,9 @@ const api = {
}
},
windowManager: {
openSettings: (path: SettingsPath = '/settings/provider'): Promise<string> =>
ipcRenderer.invoke(IpcChannel.SettingsWindow_Open, path),
// Retrieve init data that the main process stored for this window via
// wm.setInitData() or wm.open({ initData }). Returns null when no data was set or when
// the sender window is not managed by WindowManager (e.g., detached devtools).

View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' 'unsafe-inline' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; media-src 'self' file:; frame-src * file:" />
<title>Cherry Studio - Settings</title>
<style>
html,
body {
margin: 0;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/windows/settings/entryPoint.tsx"></script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
:root {
--font-family:
var(--cs-user-font-family, var(--user-font-family)), Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI',
var(--cs-user-font-family, var(--user-font-family, Ubuntu)), -apple-system, BlinkMacSystemFont, 'Segoe UI',
system-ui, Roboto, Oxygen,
Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
@@ -10,18 +10,18 @@
'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--code-font-family:
var(--cs-user-code-font-family, var(--user-code-font-family)), 'Cascadia Code', 'Fira Code', 'Consolas', Menlo,
var(--cs-user-code-font-family, var(--user-code-font-family, 'Cascadia Code')), 'Fira Code', 'Consolas', Menlo,
Courier, monospace;
}
/* Windows系统专用字体配置 */
body[os='windows'] {
--font-family:
var(--cs-user-font-family, var(--user-font-family)), 'Twemoji Country Flags', Ubuntu, -apple-system,
var(--cs-user-font-family, var(--user-font-family, 'Twemoji Country Flags')), Ubuntu, -apple-system,
BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans', 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--code-font-family:
var(--cs-user-code-font-family, var(--user-code-font-family)), 'Cascadia Code', 'Fira Code', 'Consolas',
var(--cs-user-code-font-family, var(--user-code-font-family, 'Cascadia Code')), 'Fira Code', 'Consolas',
'Sarasa Mono SC', 'Microsoft YaHei UI', Courier, monospace;
}

View File

@@ -15,9 +15,9 @@ const AddButton = ({
return (
<Button
{...props}
onClick={props.onClick}
variant="ghost"
className={cn(
'h-9 w-[calc(var(--assistants-width)-20px)] justify-start rounded-lg bg-transparent px-3 text-[13px] text-[var(--color-text-2)] hover:bg-[var(--color-list-item)]',
'h-9 w-[calc(var(--assistants-width)-20px)] justify-start rounded-lg bg-transparent px-3 text-[13px] text-muted-foreground shadow-none hover:bg-muted hover:text-foreground dark:bg-transparent dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-foreground',
className
)}>
<PlusIcon size={16} className="shrink-0" />

View File

@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest'
import i18n from '@renderer/i18n'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
vi.mock('@logger', () => ({
loggerService: {
withContext: () => ({
error: vi.fn()
})
}
}))
vi.mock('@cherrystudio/ui', async (importOriginal) => {
return await importOriginal()
})
import AppModalProvider, { type AppModalApi } from '..'
beforeAll(() => {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as any
if (!globalThis.PointerEvent) {
globalThis.PointerEvent = MouseEvent as any
}
})
afterEach(() => {
cleanup()
})
async function renderModalProvider() {
let modal: AppModalApi | undefined
render(<AppModalProvider onReady={(api) => (modal = api)} />)
await waitFor(() => {
expect(modal).toBeDefined()
})
return modal!
}
describe('AppModalProvider Dialog integration', () => {
it('mounts the real Dialog primitive and resolves on confirm', async () => {
const user = userEvent.setup()
const modal = await renderModalProvider()
let confirmed: ReturnType<AppModalApi['confirm']>
act(() => {
confirmed = modal.confirm({
title: 'Real dialog',
content: 'Mounted through the package dialog.'
})
})
expect(await screen.findByRole('dialog')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: i18n.t('common.confirm') }))
await expect(confirmed!).resolves.toBe(true)
})
})

View File

@@ -0,0 +1,268 @@
import i18n from '@renderer/i18n'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const toastError = vi.fn()
vi.mock('@logger', () => ({
loggerService: {
withContext: () => ({
error: vi.fn()
})
}
}))
vi.mock('@cherrystudio/ui', () => {
const React = require('react')
return {
Button: ({ children, loadingIcon, loading, ...props }) =>
React.createElement('button', props, loading ? loadingIcon : null, children),
Dialog: ({ children, open }) => (open ? React.createElement(React.Fragment, null, children) : null),
DialogContent: ({ children, ...props }) => {
delete props.showCloseButton
delete props.onInteractOutside
return React.createElement('div', { role: 'dialog', ...props }, children)
},
DialogDescription: ({ children }) => React.createElement('div', null, children),
DialogFooter: ({ children, ...props }) => React.createElement('div', props, children),
DialogHeader: ({ children, ...props }) => React.createElement('div', props, children),
DialogTitle: ({ children, ...props }) => React.createElement('h2', props, children)
}
})
import AppModalProvider, { type AppModalApi } from '..'
beforeEach(() => {
toastError.mockClear()
Object.defineProperty(window, 'toast', {
configurable: true,
value: { error: toastError }
})
})
async function renderModalProvider() {
let modal: AppModalApi | undefined
render(<AppModalProvider onReady={(api) => (modal = api)} />)
await waitFor(() => {
expect(modal).toBeDefined()
})
return modal!
}
describe('AppModalProvider', () => {
it('keeps window.modal.confirm compatible with promise-style confirmation', async () => {
const user = userEvent.setup()
const modal = await renderModalProvider()
let confirmed: ReturnType<AppModalApi['confirm']>
act(() => {
confirmed = modal.confirm({
title: 'Delete item',
content: 'This cannot be undone.',
okText: 'Delete',
cancelText: 'Cancel'
})
})
await waitFor(() => {
expect(screen.getByText('Delete item')).toBeInTheDocument()
})
expect(screen.getByText('This cannot be undone.')).toBeInTheDocument()
expect(typeof confirmed!.catch).toBe('function')
expect(typeof confirmed!.finally).toBe('function')
await user.click(screen.getByRole('button', { name: 'Delete' }))
await expect(confirmed!).resolves.toBe(true)
})
it('keeps the modal open when onOk rejects so it can still be cancelled', async () => {
const user = userEvent.setup()
const modal = await renderModalProvider()
const onOk = vi.fn().mockRejectedValue(new Error('failed'))
let confirmed: ReturnType<AppModalApi['confirm']>
act(() => {
confirmed = modal.confirm({
title: 'Retry action',
content: 'The first attempt fails.',
okText: 'Run',
cancelText: 'Cancel',
onOk
})
})
await user.click(await screen.findByRole('button', { name: 'Run' }))
await waitFor(() => {
expect(onOk).toHaveBeenCalledOnce()
})
expect(toastError).toHaveBeenCalledWith({ title: i18n.t('common.error'), description: 'failed' })
expect(screen.getByText('Retry action')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await expect(confirmed!).resolves.toBe(false)
})
it('resolves confirm as false when cancelled', async () => {
const user = userEvent.setup()
const modal = await renderModalProvider()
let confirmed: ReturnType<AppModalApi['confirm']>
act(() => {
confirmed = modal.confirm({
title: 'Leave page',
content: 'Unsaved changes will be lost.',
okText: 'Leave',
cancelText: 'Stay'
})
})
await user.click(await screen.findByRole('button', { name: 'Stay' }))
await expect(confirmed!).resolves.toBe(false)
})
it('renders feedback modals without a cancel button', async () => {
const user = userEvent.setup()
const modal = await renderModalProvider()
let confirmed: ReturnType<AppModalApi['error']>
act(() => {
confirmed = modal.error({
title: 'Backup failed',
content: 'Disk is full.'
})
})
await waitFor(() => {
expect(screen.getByText('Backup failed')).toBeInTheDocument()
})
expect(screen.queryByRole('button', { name: i18n.t('common.cancel') })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: i18n.t('common.confirm') }))
await expect(confirmed!).resolves.toBe(true)
})
it('uses translated default text for destructive confirmations', async () => {
const user = userEvent.setup()
const modal = await renderModalProvider()
let confirmed: ReturnType<AppModalApi['confirm']>
act(() => {
confirmed = modal.confirm({
title: 'Delete item',
content: 'This cannot be undone.',
okButtonProps: { danger: true }
})
})
await user.click(await screen.findByRole('button', { name: i18n.t('common.delete') }))
await expect(confirmed!).resolves.toBe(true)
})
it('supports update and destroy handles for loading-style modals', async () => {
const modal = await renderModalProvider()
let loadingModal: ReturnType<AppModalApi['info']>
act(() => {
loadingModal = modal.info({
title: 'Migrating data',
content: 'Starting...',
okButtonProps: { style: { display: 'none' } }
})
})
await waitFor(() => {
expect(screen.getByText('Starting...')).toBeInTheDocument()
})
expect(screen.queryByRole('button', { name: i18n.t('common.confirm') })).not.toBeInTheDocument()
act(() => {
loadingModal!.update({ content: 'Almost done.' })
})
await waitFor(() => {
expect(screen.getByText('Almost done.')).toBeInTheDocument()
})
act(() => {
loadingModal!.destroy()
})
await expect(loadingModal!).resolves.toBe(false)
await waitFor(() => {
expect(screen.queryByText('Almost done.')).not.toBeInTheDocument()
})
})
it('destroyAll resolves every open modal as cancelled', async () => {
const modal = await renderModalProvider()
let first: ReturnType<AppModalApi['info']>
let second: ReturnType<AppModalApi['confirm']>
act(() => {
first = modal.info({ title: 'First modal' })
second = modal.confirm({ title: 'Second modal' })
})
await waitFor(() => {
expect(screen.getByText('First modal')).toBeInTheDocument()
expect(screen.getByText('Second modal')).toBeInTheDocument()
})
act(() => {
modal.destroyAll()
})
await expect(first!).resolves.toBe(false)
await expect(second!).resolves.toBe(false)
})
it('keeps the modal mounted until the close animation finishes', async () => {
const modal = await renderModalProvider()
vi.useFakeTimers()
try {
const afterClose = vi.fn()
let loadingModal: ReturnType<AppModalApi['info']>
act(() => {
loadingModal = modal.info({
title: 'Closing soon',
content: 'Waiting for animation.',
afterClose
})
})
expect(screen.getByText('Closing soon')).toBeInTheDocument()
act(() => {
loadingModal!.destroy()
})
expect(afterClose).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(199)
})
expect(afterClose).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(1)
})
expect(afterClose).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
})

View File

@@ -0,0 +1,393 @@
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@cherrystudio/ui'
import { cn } from '@cherrystudio/ui/lib/utils'
import { loggerService } from '@logger'
import i18n from '@renderer/i18n'
import { formatErrorMessage } from '@renderer/utils/error'
import { AlertCircle, CheckCircle2, Info, Loader2, TriangleAlert, XCircle } from 'lucide-react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { v4 as uuidv4 } from 'uuid'
type ModalType = 'confirm' | 'error' | 'info' | 'success' | 'warning'
type ModalAction = () => unknown | Promise<unknown>
type ModalButtonProps = {
danger?: boolean
disabled?: boolean
style?: React.CSSProperties
className?: string
}
const logger = loggerService.withContext('AppModal')
export interface AppModalFuncProps {
title?: React.ReactNode
content?: React.ReactNode
okText?: React.ReactNode
cancelText?: React.ReactNode
onOk?: ModalAction
onCancel?: ModalAction
afterClose?: () => void
okButtonProps?: ModalButtonProps
cancelButtonProps?: Omit<ModalButtonProps, 'danger'>
centered?: boolean
width?: string | number
icon?: React.ReactNode
maskClosable?: boolean
closable?: boolean
className?: string
rootClassName?: string
style?: React.CSSProperties
okCancel?: boolean
}
export interface AppModalReturn extends PromiseLike<boolean> {
catch: Promise<boolean>['catch']
finally: Promise<boolean>['finally']
destroy: () => void
update: (config: AppModalFuncProps) => void
}
export interface AppModalApi {
confirm: (config: AppModalFuncProps) => AppModalReturn
error: (config: AppModalFuncProps) => AppModalReturn
info: (config: AppModalFuncProps) => AppModalReturn
success: (config: AppModalFuncProps) => AppModalReturn
warning: (config: AppModalFuncProps) => AppModalReturn
warn: (config: AppModalFuncProps) => AppModalReturn
destroyAll: () => void
}
interface ModalItem {
id: string
type: ModalType
props: AppModalFuncProps
open: boolean
loading: boolean
resolve: (confirmed: boolean) => void
}
interface Props {
onReady: (api: AppModalApi) => void
}
const CLOSE_ANIMATION_MS = 200
function createId() {
return uuidv4()
}
function getIcon(type: ModalType, icon: React.ReactNode) {
if (icon === null) return null
if (icon !== undefined) return icon
const className = 'mt-0.5 size-5 shrink-0'
switch (type) {
case 'error':
return <XCircle className={cn(className, 'text-destructive')} />
case 'warning':
return <TriangleAlert className={cn(className, 'text-warning')} />
case 'success':
return <CheckCircle2 className={cn(className, 'text-success')} />
case 'info':
return <Info className={cn(className, 'text-info')} />
case 'confirm':
return <AlertCircle className={cn(className, 'text-warning')} />
}
}
function getContentStyle(props: AppModalFuncProps): React.CSSProperties | undefined {
const style = { ...props.style }
if (props.width !== undefined) {
style.width = props.width
style.maxWidth = 'calc(100vw - 2rem)'
}
return Object.keys(style).length > 0 ? style : undefined
}
function shouldShowOkButton(props: AppModalFuncProps) {
return props.okButtonProps?.style?.display !== 'none'
}
function shouldShowCancelButton(type: ModalType, props: AppModalFuncProps) {
if (props.okCancel === false) return false
if (type !== 'confirm') return false
return props.cancelButtonProps?.style?.display !== 'none'
}
function getOkText(type: ModalType, props: AppModalFuncProps) {
if (props.okText !== undefined) return props.okText
if (type === 'confirm' && props.okButtonProps?.danger) {
return i18n.t('common.delete')
}
return i18n.t('common.confirm')
}
function getCancelText(props: AppModalFuncProps) {
return props.cancelText ?? i18n.t('common.cancel')
}
function AppModalItem({
item,
close,
updateLoading
}: {
item: ModalItem
close: (id: string, confirmed: boolean, callback?: ModalAction) => void
updateLoading: (id: string, loading: boolean) => void
}) {
const { props, type } = item
const icon = getIcon(type, props.icon)
const showOkButton = shouldShowOkButton(props)
const showCancelButton = shouldShowCancelButton(type, props)
const handleCancel = useCallback(() => {
close(item.id, false, props.onCancel)
}, [close, item.id, props.onCancel])
const handleConfirm = useCallback(async () => {
updateLoading(item.id, true)
try {
await props.onOk?.()
close(item.id, true)
} catch (error) {
logger.error('Modal onOk failed', error as Error)
window.toast.error({ title: i18n.t('common.error'), description: formatErrorMessage(error) })
updateLoading(item.id, false)
}
}, [close, item.id, props, updateLoading])
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
handleCancel()
}
},
[handleCancel]
)
return (
<Dialog open={item.open} onOpenChange={handleOpenChange}>
<DialogContent
data-app-modal="true"
showCloseButton={props.closable === true}
className={cn('app-modal gap-5 sm:max-w-lg', props.rootClassName, props.className)}
style={getContentStyle(props)}
onInteractOutside={(event) => {
if (props.maskClosable === false) {
event.preventDefault()
}
}}>
<DialogHeader className="gap-3">
<div className="flex items-start gap-3">
{icon}
<div className="min-w-0 flex-1">
{props.title ? <DialogTitle className="text-base leading-6">{props.title}</DialogTitle> : null}
{props.content ? (
<DialogDescription asChild>
<div className={cn('mt-2 text-muted-foreground text-sm leading-5', props.title ? '' : 'mt-0')}>
{props.content}
</div>
</DialogDescription>
) : null}
</div>
</div>
</DialogHeader>
{(showOkButton || showCancelButton) && (
<DialogFooter>
{showCancelButton && (
<Button
variant="outline"
onClick={handleCancel}
disabled={props.cancelButtonProps?.disabled || item.loading}
className={props.cancelButtonProps?.className}
style={props.cancelButtonProps?.style}>
{getCancelText(props)}
</Button>
)}
{showOkButton && (
<Button
variant={props.okButtonProps?.danger ? 'destructive' : 'default'}
onClick={handleConfirm}
disabled={props.okButtonProps?.disabled}
loading={item.loading}
loadingIcon={<Loader2 className="size-4 animate-spin" />}
className={props.okButtonProps?.className}
style={props.okButtonProps?.style}>
{getOkText(type, props)}
</Button>
)}
</DialogFooter>
)}
</DialogContent>
</Dialog>
)
}
export default function AppModalProvider({ onReady }: Props) {
const [items, setItems] = useState<ModalItem[]>([])
const itemsRef = useRef<ModalItem[]>([])
const closeTimersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
useEffect(() => {
itemsRef.current = items
}, [items])
const remove = useCallback((id: string) => {
const item = itemsRef.current.find((current) => current.id === id)
const closeTimer = closeTimersRef.current.get(id)
if (closeTimer) {
clearTimeout(closeTimer)
closeTimersRef.current.delete(id)
}
setItems((current) => {
const nextItems = current.filter((modal) => modal.id !== id)
itemsRef.current = nextItems
return nextItems
})
item?.props.afterClose?.()
}, [])
const scheduleRemove = useCallback(
(id: string) => {
const existingTimer = closeTimersRef.current.get(id)
if (existingTimer) {
clearTimeout(existingTimer)
}
closeTimersRef.current.set(
id,
setTimeout(() => {
remove(id)
}, CLOSE_ANIMATION_MS)
)
},
[remove]
)
const closeItem = useCallback(
(id: string, confirmed: boolean, callback?: ModalAction) => {
const item = itemsRef.current.find((current) => current.id === id)
if (!item || !item.open) return
void Promise.resolve(callback?.()).catch((error) => {
logger.error('Modal onCancel failed', error as Error)
})
item.resolve(confirmed)
setItems((current) => {
const nextItems = current.map((modal) => (modal.id === id ? { ...modal, loading: false, open: false } : modal))
itemsRef.current = nextItems
return nextItems
})
scheduleRemove(id)
},
[scheduleRemove]
)
const close = useCallback(
(id: string, confirmed: boolean, callback?: ModalAction) => {
closeItem(id, confirmed, callback)
},
[closeItem]
)
const update = useCallback((id: string, props: AppModalFuncProps) => {
setItems((current) => {
const nextItems = current.map((item) => (item.id === id ? { ...item, props: { ...item.props, ...props } } : item))
itemsRef.current = nextItems
return nextItems
})
}, [])
const updateLoading = useCallback((id: string, loading: boolean) => {
setItems((current) => {
const nextItems = current.map((item) => (item.id === id ? { ...item, loading } : item))
itemsRef.current = nextItems
return nextItems
})
}, [])
const show = useCallback(
(type: ModalType, props: AppModalFuncProps): AppModalReturn => {
const id = createId()
let resolvePromise: (confirmed: boolean) => void = () => {}
const promise = new Promise<boolean>((resolve) => {
resolvePromise = resolve
})
const item: ModalItem = {
id,
type,
props,
open: true,
loading: false,
resolve: resolvePromise
}
setItems((current) => {
const nextItems = current.concat(item)
itemsRef.current = nextItems
return nextItems
})
return Object.assign(promise, {
destroy: () => closeItem(id, false),
update: (config: AppModalFuncProps) => update(id, config)
})
},
[closeItem, update]
)
const api = useMemo<AppModalApi>(
() => ({
confirm: (config) => show('confirm', config),
error: (config) => show('error', config),
info: (config) => show('info', config),
success: (config) => show('success', config),
warning: (config) => show('warning', config),
warn: (config) => show('warning', config),
destroyAll: () => {
itemsRef.current.forEach((item) => closeItem(item.id, false))
}
}),
[closeItem, show]
)
useEffect(() => {
onReady(api)
}, [api, onReady])
useEffect(() => {
const closeTimers = closeTimersRef.current
return () => {
closeTimers.forEach((timer) => clearTimeout(timer))
closeTimers.clear()
}
}, [])
return (
<>
{items.map((item) => (
<AppModalItem key={item.id} item={item} close={close} updateLoading={updateLoading} />
))}
</>
)
}

Some files were not shown because too many files have changed in this diff Show More