mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 12:27:41 +08:00
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:
@@ -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'),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
11
packages/shared/data/types/settingsPath.ts
Normal file
11
packages/shared/data/types/settingsPath.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);')
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
482
packages/ui/src/components/composites/DataTable/index.tsx
Normal file
482
packages/ui/src/components/composites/DataTable/index.tsx
Normal 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 }
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
289
packages/ui/src/components/composites/DateTimePicker/index.tsx
Normal file
289
packages/ui/src/components/composites/DateTimePicker/index.tsx
Normal 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 }
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
141
packages/ui/src/components/composites/Form/index.tsx
Normal file
141
packages/ui/src/components/composites/Form/index.tsx
Normal 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 }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]'
|
||||
],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
)}>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
127
packages/ui/src/components/primitives/alert.tsx
Normal file
127
packages/ui/src/components/primitives/alert.tsx
Normal 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 }
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}
|
||||
|
||||
149
packages/ui/src/components/primitives/calendar.tsx
Normal file
149
packages/ui/src/components/primitives/calendar.tsx
Normal 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 }
|
||||
@@ -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>
|
||||
|
||||
205
packages/ui/src/components/primitives/context-menu.tsx
Normal file
205
packages/ui/src/components/primitives/context-menu.tsx
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
157
packages/ui/src/components/primitives/item.tsx
Normal file
157
packages/ui/src/components/primitives/item.tsx
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
81
packages/ui/src/components/primitives/segmented-control.tsx
Normal file
81
packages/ui/src/components/primitives/segmented-control.tsx
Normal 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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
60
packages/ui/src/components/primitives/table.tsx
Normal file
60
packages/ui/src/components/primitives/table.tsx
Normal 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 }
|
||||
@@ -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'
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
198
packages/ui/stories/components/composites/DataTable.stories.tsx
Normal file
198
packages/ui/stories/components/composites/DataTable.stories.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
212
packages/ui/stories/components/primitives/Item.stories.tsx
Normal file
212
packages/ui/stories/components/primitives/Item.stories.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
709
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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' }
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { BrowserWindow, BrowserWindowConstructorOptions, VisibleOnAllWorksp
|
||||
*/
|
||||
export enum WindowType {
|
||||
Main = 'main',
|
||||
Settings = 'settings',
|
||||
QuickAssistant = 'quickAssistant',
|
||||
SubWindow = 'subWindow',
|
||||
SelectionToolbar = 'selectionToolbar',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
117
src/main/services/SettingsWindowService.ts
Normal file
117
src/main/services/SettingsWindowService.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
137
src/main/services/__tests__/AppMenuService.test.ts
Normal file
137
src/main/services/__tests__/AppMenuService.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
236
src/main/services/__tests__/SettingsWindowService.test.ts
Normal file
236
src/main/services/__tests__/SettingsWindowService.test.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
106
src/main/services/protocol/__tests__/ProtocolService.test.ts
Normal file
106
src/main/services/protocol/__tests__/ProtocolService.test.ts
Normal 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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' }))
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
23
src/renderer/settings.html
Normal file
23
src/renderer/settings.html
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
268
src/renderer/src/components/AppModal/__tests__/AppModal.test.tsx
Normal file
268
src/renderer/src/components/AppModal/__tests__/AppModal.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
393
src/renderer/src/components/AppModal/index.tsx
Normal file
393
src/renderer/src/components/AppModal/index.tsx
Normal 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
Reference in New Issue
Block a user