From dee3bb09281f113c6ec26fd1afdedf69978025ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Fri, 8 May 2026 18:09:26 +0800 Subject: [PATCH] feat(settings): refactor settings UI and add settings window (#14567) Signed-off-by: kangfenmao Signed-off-by: jdzhang <625013594@qq.com> Co-authored-by: jdzhang <625013594@qq.com> Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com> --- electron.vite.config.ts | 1 + package.json | 2 + packages/shared/IpcChannel.ts | 1 + .../data/preference/preferenceSchemas.ts | 2 +- packages/shared/data/types/settingsPath.ts | 11 + packages/ui/README.md | 8 +- packages/ui/package.json | 11 + .../scripts/__tests__/build-theme-css.test.ts | 6 + packages/ui/scripts/build-theme-css.ts | 10 +- packages/ui/scripts/codegen.ts | 4 +- .../composites/CodeEditor/CodeEditor.tsx | 12 + .../composites/ConfirmDialog/index.tsx | 7 +- .../DataTable/__tests__/DataTable.test.tsx | 295 +++++ .../components/composites/DataTable/index.tsx | 482 +++++++ .../__tests__/DateTimePicker.test.tsx | 74 ++ .../composites/DateTimePicker/index.tsx | 289 ++++ .../__tests__/EditableNumber.test.tsx | 52 + .../composites/EditableNumber/index.tsx | 76 +- .../composites/Form/__tests__/Form.test.tsx | 54 + .../src/components/composites/Form/index.tsx | 141 ++ .../composites/IconTooltips/IconTooltip.tsx | 2 +- .../composites/IconTooltips/types.ts | 3 +- .../composites/ImageToolButton/index.tsx | 5 +- .../src/components/composites/Input/input.tsx | 28 +- .../composites/MenuList/MenuItem.tsx | 2 +- .../composites/PageSidePanel/index.tsx | 2 +- .../composites/SelectDropdown/index.tsx | 12 +- packages/ui/src/components/index.ts | 31 +- .../primitives/__tests__/alert.test.tsx | 56 + .../primitives/__tests__/combobox.test.tsx | 149 +++ .../__tests__/segmented-control.test.tsx | 43 + .../primitives/__tests__/switch.test.tsx | 36 + .../ui/src/components/primitives/alert.tsx | 127 ++ .../components/primitives/button-group.tsx | 111 +- .../ui/src/components/primitives/button.tsx | 21 +- .../ui/src/components/primitives/calendar.tsx | 149 +++ .../ui/src/components/primitives/combobox.tsx | 324 ++++- .../components/primitives/context-menu.tsx | 205 +++ .../ui/src/components/primitives/dialog.tsx | 4 +- .../ui/src/components/primitives/item.tsx | 157 +++ packages/ui/src/components/primitives/kbd.tsx | 2 +- .../src/components/primitives/pagination.tsx | 2 +- .../{radioGroup.tsx => radio-group.tsx} | 25 +- .../primitives/segmented-control.tsx | 81 ++ .../ui/src/components/primitives/select.tsx | 4 +- .../src/components/primitives/separator.tsx | 4 +- .../ui/src/components/primitives/slider.tsx | 8 +- .../ui/src/components/primitives/switch.tsx | 15 +- .../ui/src/components/primitives/table.tsx | 60 + .../ui/src/components/primitives/tabs.tsx | 2 +- .../ui/src/components/primitives/textarea.tsx | 2 +- .../ui/src/components/primitives/tooltip.tsx | 27 +- packages/ui/src/styles/theme.css | 6 +- .../ui/src/styles/tokens/colors/semantic.css | 4 +- packages/ui/src/styles/tokens/radius.css | 22 +- .../composites/DataTable.stories.tsx | 198 +++ .../composites/DateTimePicker.stories.tsx | 114 ++ .../composites/EntitySelector.stories.tsx | 4 +- .../composites/Sortable.stories.tsx | 6 +- .../primitives/ButtonGroup.stories.tsx | 23 +- .../primitives/Combobox.stories.tsx | 216 +++ .../primitives/ContextMenu.stories.tsx | 130 ++ .../primitives/DescriptionSwitch.stories.tsx | 3 +- .../primitives/InputGroup.stories.tsx | 17 +- .../components/primitives/Item.stories.tsx | 212 +++ .../components/primitives/Kbd.stories.tsx | 2 +- .../primitives/SegmentedControl.stories.tsx | 210 +++ .../components/primitives/Slider.stories.tsx | 3 +- .../components/primitives/Switch.stories.tsx | 41 +- .../primitives/Textarea.stories.tsx | 3 +- packages/ui/vitest.config.ts | 7 + pnpm-lock.yaml | 709 +++++++++- resources/cherry-studio/releases.html | 344 +++-- scripts/cloudflare-worker.js | 2 +- src/main/core/application/serviceRegistry.ts | 2 + src/main/core/window/types.ts | 1 + src/main/core/window/windowRegistry.ts | 30 + .../__tests__/preferenceSeeder.test.ts | 18 + .../db/seeding/seeders/preferenceSeeder.ts | 9 + src/main/services/AppMenuService.ts | 15 +- src/main/services/MainWindowService.ts | 27 +- src/main/services/SettingsWindowService.ts | 117 ++ src/main/services/ShortcutService.ts | 11 +- .../services/__tests__/AppMenuService.test.ts | 137 ++ .../__tests__/MainWindowService.test.ts | 86 +- .../__tests__/SettingsWindowService.test.ts | 236 ++++ .../__tests__/ShortcutService.test.ts | 115 +- src/main/services/protocol/ProtocolService.ts | 40 +- .../__tests__/ProtocolService.test.ts | 106 ++ .../handlers/__tests__/navigate.test.ts | 91 ++ .../__tests__/providersImport.test.ts | 81 ++ .../services/protocol/handlers/navigate.ts | 68 +- .../protocol/handlers/providersImport.ts | 29 +- src/preload/index.ts | 4 + src/renderer/settings.html | 23 + src/renderer/src/assets/styles/font.css | 8 +- src/renderer/src/components/AddButton.tsx | 4 +- .../__tests__/AppModal.integration.test.tsx | 70 + .../AppModal/__tests__/AppModal.test.tsx | 268 ++++ .../src/components/AppModal/index.tsx | 393 ++++++ .../src/components/CollapsibleSearchBar.tsx | 46 +- .../src/components/ListItem/index.tsx | 2 +- .../ModelSelector/ModelSelector.tsx | 8 +- .../__tests__/ModelSelector.test.tsx | 49 +- .../src/components/ModelSelector/types.ts | 1 + .../src/components/ModelSelectorLegacy.tsx | 10 +- .../src/components/S3BackupManager.tsx | 32 +- .../src/components/Sidebar/Sidebar.tsx | 34 +- .../src/components/Sidebar/SidebarDocked.tsx | 4 +- .../src/components/Sidebar/SidebarFooter.tsx | 6 +- .../src/components/Sidebar/SidebarMenu.tsx | 16 +- src/renderer/src/components/TextBadge.tsx | 22 - src/renderer/src/components/TopView/index.tsx | 13 +- .../src/components/WebdavBackupManager.tsx | 32 +- .../src/components/layout/AppShellTabBar.tsx | 6 +- .../components/layout/ShellTabBarActions.tsx | 50 +- .../__tests__/ShellTabBarActions.test.tsx | 95 ++ src/renderer/src/config/constant.ts | 12 +- src/renderer/src/context/TabsContext.tsx | 20 +- src/renderer/src/context/ThemeProvider.tsx | 27 +- src/renderer/src/env.d.ts | 4 +- .../hooks/__tests__/useMiniAppPopup.test.ts | 120 +- src/renderer/src/hooks/useMiniAppPopup.ts | 62 +- src/renderer/src/i18n/locales/en-us.json | 70 +- src/renderer/src/i18n/locales/zh-cn.json | 62 +- src/renderer/src/i18n/locales/zh-tw.json | 60 +- src/renderer/src/i18n/translate/de-de.json | 252 ++-- src/renderer/src/i18n/translate/el-gr.json | 252 ++-- src/renderer/src/i18n/translate/es-es.json | 252 ++-- src/renderer/src/i18n/translate/fr-fr.json | 252 ++-- src/renderer/src/i18n/translate/ja-jp.json | 252 ++-- src/renderer/src/i18n/translate/pt-pt.json | 252 ++-- src/renderer/src/i18n/translate/ro-ro.json | 252 ++-- src/renderer/src/i18n/translate/ru-ru.json | 252 ++-- src/renderer/src/i18n/translate/vi-vn.json | 314 +++-- .../AgentSettings/AgentSettingsPopup.tsx | 0 .../AgentSettings/BaseSettingsPopup.tsx | 37 +- .../AgentSettings/SessionSettingsPopup.tsx | 0 .../components/AccessibleDirsSetting.tsx | 13 +- .../components/AdvancedSettings.tsx | 3 +- .../components/DescriptionSetting.tsx | 4 +- .../components/EssentialSettings.tsx | 0 .../components/HeartbeatSetting.tsx | 8 +- .../AgentSettings/components/ModelSetting.tsx | 0 .../AgentSettings/components/NameSetting.tsx | 4 +- .../components/PermissionModeSettings.tsx | 6 +- .../components/PromptSettings.tsx | 67 +- .../SkillsSettings/SkillsSettings.tsx | 32 +- .../components/SoulModeSetting.tsx | 2 +- .../AgentSettings/components/TaskListItem.tsx | 5 +- .../components/TaskLogsModal.tsx | 5 +- .../components/ToolsSettings.tsx | 7 +- .../AgentSettings/index.tsx | 0 .../AgentSettings/shared.tsx | 69 +- .../AgentChatNavbar/AgentContent.tsx | 4 +- .../src/pages/agents/components/AgentItem.tsx | 4 +- .../components/AgentSessionInputbar.tsx | 2 +- .../pages/agents/components/SessionItem.tsx | 4 +- .../AssistantKnowledgeBaseSettings.tsx | 26 +- .../AssistantMCPSettings.tsx | 174 +-- .../AssistantModelSettings.tsx | 71 +- .../AssistantPromptSettings.tsx | 89 +- .../AssistantSettings/index.tsx | 90 +- .../Inputbar/tools/permissionModeTool.tsx | 2 +- .../home/Messages/PermissionModeDisplay.tsx | 2 +- .../src/pages/home/Messages/Prompt.tsx | 2 +- .../Tools/MessageAgentTools/NavigateTool.tsx | 3 +- .../home/Tabs/components/AssistantItem.tsx | 2 +- .../ChatNavbarContent/TopicContent.tsx | 2 +- .../home/components/ChatNavBar/index.tsx | 2 +- .../home/components/SelectModelButton.tsx | 27 +- .../src/pages/settings/AboutSettings.tsx | 158 +-- .../ChannelsSettings/ChannelDetail.tsx | 261 ++-- .../ChannelsSettings/ChannelForms.tsx | 129 +- .../pages/settings/ChannelsSettings/index.tsx | 7 +- .../components/ThemeColorPicker.tsx | 105 ++ .../__tests__/ThemeColorPicker.test.tsx | 34 + .../pages/settings/CommonSettings/index.tsx | 851 ++++++++++++ .../ComponentLabAgentSelectorSettings.tsx | 2 +- .../ComponentLabAssistantSelectorSettings.tsx | 2 +- .../ComponentLabModelSelectorSettings.tsx | 2 +- .../ComponentLabSettings.tsx | 9 +- .../settings/ComponentLabSettings/index.ts | 4 + .../DataSettings/BasicDataSettings.tsx | 185 +-- .../settings/DataSettings/DataSettings.tsx | 120 +- .../DataSettings/ImportMenuSettings.tsx | 9 +- .../DataSettings/LocalBackupSettings.tsx | 19 +- .../DataSettings/MarkdownExportSettings.tsx | 47 +- .../DataSettings/NutstoreSettings.tsx | 27 +- .../settings/DataSettings/S3Settings.tsx | 18 +- .../settings/DataSettings/WebDavSettings.tsx | 18 +- .../DisplaySettings/DisplaySettings.tsx | 517 -------- .../DisplaySettings/SidebarIconsManager.tsx | 274 ---- .../DocProcessSettings/OcrImageSettings.tsx | 19 +- .../DocProcessSettings/OcrOVSettings.tsx | 11 +- .../DocProcessSettings/OcrPpocrSettings.tsx | 5 +- .../OcrProviderSettings.tsx | 22 +- .../DocProcessSettings/OcrSettings.tsx | 24 +- .../DocProcessSettings/OcrSystemSettings.tsx | 60 +- .../OcrTesseractSettings.tsx | 71 +- .../PreprocessProviderSettings.tsx | 62 +- .../DocProcessSettings/PreprocessSettings.tsx | 24 +- .../settings/DocProcessSettings/index.tsx | 284 +++- .../src/pages/settings/GeneralSettings.tsx | 392 ------ .../JoplinSettings.tsx | 42 +- .../NotionSettings.tsx | 68 +- .../ObsidianSettings.tsx | 60 +- .../SiyuanSettings.tsx | 33 +- .../YuqueSettings.tsx | 89 +- .../settings/IntegrationSettings/index.tsx | 72 + .../MCPSettings/AddMcpServerModal.tsx | 224 ++-- .../MCPSettings/BuiltinMCPServerList.tsx | 180 ++- .../settings/MCPSettings/EditMcpJsonPopup.tsx | 108 +- .../MCPSettings/EnvironmentDependencies.tsx | 234 ++++ .../settings/MCPSettings/InstallNpxUv.tsx | 179 --- .../settings/MCPSettings/McpDescription.tsx | 28 +- .../settings/MCPSettings/McpDetailList.tsx | 53 + .../settings/MCPSettings/McpMarketList.tsx | 118 +- .../pages/settings/MCPSettings/McpPrompt.tsx | 102 +- .../MCPSettings/McpProviderSettings.tsx | 182 +-- .../settings/MCPSettings/McpResource.tsx | 112 +- .../settings/MCPSettings/McpServerCard.tsx | 338 +++-- .../settings/MCPSettings/McpServersList.tsx | 258 ++-- .../settings/MCPSettings/McpSettings.tsx | 1171 +++++++++++------ .../pages/settings/MCPSettings/McpTool.tsx | 221 ++-- .../pages/settings/MCPSettings/NpxSearch.tsx | 110 +- .../MCPSettings/ProtocolInstallWarning.tsx | 4 +- .../settings/MCPSettings/SyncServersPopup.tsx | 483 ++++--- .../src/pages/settings/MCPSettings/index.tsx | 114 +- .../DefaultAssistantSettings.tsx | 506 ++++--- .../settings/ModelSettings/ModelSettings.tsx | 404 ++++-- .../ModelSettings/ParameterSlider.tsx | 72 + .../ModelSettings/QuickModelPopup.tsx | 139 +- .../ProviderSettings/AddProviderPopup.tsx | 213 ++- .../ProviderSettings/AnthropicSettings.tsx | 31 +- .../ApiOptionsSettings/ApiOptionsSettings.tsx | 11 +- .../ProviderSettings/AwsBedrockSettings.tsx | 25 +- .../ProviderSettings/CherryINOAuth.tsx | 172 +-- .../ProviderSettings/CherryINSettings.tsx | 39 +- .../ProviderSettings/CustomHeaderPopup.tsx | 6 +- .../ProviderSettings/DMXAPISettings.tsx | 29 +- .../EditModelPopup/ModelEditContent.tsx | 36 +- .../EditModelPopup/ModelTypeSelector.tsx | 98 +- .../ProviderSettings/GPUStackSettings.tsx | 15 +- .../GithubCopilotSettings.tsx | 136 +- .../ProviderSettings/LMStudioSettings.tsx | 15 +- .../ModelList/AddModelPopup.tsx | 6 +- .../ModelList/DownloadOVMSModelPopup.tsx | 2 +- .../ModelList/HealthCheckPopup.tsx | 14 +- .../ModelList/ManageModelsList.tsx | 45 +- .../ModelList/ManageModelsPopup.tsx | 139 +- .../ProviderSettings/ModelList/ModelList.tsx | 22 +- .../ModelList/ModelListGroup.tsx | 27 +- .../ModelList/ModelListItem.tsx | 15 +- .../ModelList/NewApiAddModelPopup.tsx | 6 +- .../ModelList/NewApiBatchAddModelPopup.tsx | 2 +- .../ProviderSettings/ModelNotesPopup.tsx | 10 +- .../ProviderSettings/OVMSSettings.tsx | 8 +- .../ProviderSettings/ProviderList.tsx | 167 ++- .../ProviderSettings/ProviderOAuth.tsx | 58 +- .../ProviderSettings/ProviderSetting.tsx | 105 +- .../ProviderSettings/UrlSchemaInfoPopup.tsx | 21 +- .../ProviderSettings/VertexAISettings.tsx | 104 +- .../pages/settings/QuickAssistantSettings.tsx | 249 ++-- .../SelectionAssistantSettings.tsx | 146 +- .../components/ActionsList.tsx | 20 +- .../components/ActionsListDivider.tsx | 32 +- .../components/ActionsListItem.tsx | 131 +- .../components/MacProcessTrustHintModal.tsx | 60 +- .../components/SelectionActionSearchModal.tsx | 300 +++-- .../components/SelectionActionUserModal.tsx | 458 +++---- .../components/SelectionActionsList.tsx | 30 +- .../components/SelectionFilterListModal.tsx | 98 +- .../components/SettingsActionsListHeader.tsx | 26 +- .../src/pages/settings/SettingGroup.tsx | 67 +- .../src/pages/settings/SettingsPage.tsx | 326 +++-- .../src/pages/settings/ShortcutSettings.tsx | 261 +++- .../SkillsSettings/SkillsSettings.tsx | 705 +++------- .../src/pages/settings/TasksSettings.tsx | 1026 ++++++++------- .../ApiServerSettings/ApiServerSettings.tsx | 462 +++---- .../CustomLanguageModal.tsx | 186 --- .../CustomLanguageSettings.tsx | 165 --- .../TranslatePromptSettings.tsx | 66 - .../WebSearchSettings/AddSubscribePopup.tsx | 186 ++- .../WebSearchSettings/BasicSettings.tsx | 79 +- .../WebSearchSettings/BlacklistSettings.tsx | 217 +-- .../CompressionSettings/CutoffSettings.tsx | 31 +- .../CompressionSettings/index.tsx | 44 +- .../WebSearchGeneralSettings.tsx | 10 +- .../WebSearchProviderSetting.tsx | 99 +- .../settings/WebSearchSettings/index.tsx | 31 +- src/renderer/src/pages/settings/index.tsx | 142 +- .../presets/AssistantPresetsPage.tsx | 8 +- .../components/AssistantPresetCard.tsx | 2 +- .../src/pages/translate/TranslatePage.tsx | 2 +- .../src/pages/translate/TranslateSettings.tsx | 2 +- .../{ => components}/TranslateHistory.tsx | 14 +- .../CustomLanguageModal.tsx | 205 +++ .../CustomLanguageSettings.tsx | 189 +++ .../TranslatePromptSettings.tsx | 47 + .../TranslateSettingsPopup.tsx | 54 +- src/renderer/src/routeTree.gen.ts | 76 +- src/renderer/src/routes/settings/display.tsx | 6 - src/renderer/src/routes/settings/general.tsx | 4 +- .../src/routes/settings/integrations.tsx | 6 + .../src/routes/settings/mcp/mcp-install.tsx | 4 +- src/renderer/src/routes/settings/notes.tsx | 6 - src/renderer/src/routes/settings/plugins.tsx | 15 + src/renderer/src/services/BackupService.ts | 4 + .../src/services/SettingsWindowService.ts | 5 + src/renderer/src/utils/index.ts | 12 +- .../selection/toolbar/SelectionToolbar.tsx | 2 + .../src/windows/settings/entryPoint.tsx | 158 +++ tests/renderer.setup.ts | 33 +- 314 files changed, 18475 insertions(+), 10548 deletions(-) create mode 100644 packages/shared/data/types/settingsPath.ts create mode 100644 packages/ui/src/components/composites/DataTable/__tests__/DataTable.test.tsx create mode 100644 packages/ui/src/components/composites/DataTable/index.tsx create mode 100644 packages/ui/src/components/composites/DateTimePicker/__tests__/DateTimePicker.test.tsx create mode 100644 packages/ui/src/components/composites/DateTimePicker/index.tsx create mode 100644 packages/ui/src/components/composites/EditableNumber/__tests__/EditableNumber.test.tsx create mode 100644 packages/ui/src/components/composites/Form/__tests__/Form.test.tsx create mode 100644 packages/ui/src/components/composites/Form/index.tsx create mode 100644 packages/ui/src/components/primitives/__tests__/alert.test.tsx create mode 100644 packages/ui/src/components/primitives/__tests__/combobox.test.tsx create mode 100644 packages/ui/src/components/primitives/__tests__/segmented-control.test.tsx create mode 100644 packages/ui/src/components/primitives/__tests__/switch.test.tsx create mode 100644 packages/ui/src/components/primitives/alert.tsx create mode 100644 packages/ui/src/components/primitives/calendar.tsx create mode 100644 packages/ui/src/components/primitives/context-menu.tsx create mode 100644 packages/ui/src/components/primitives/item.tsx rename packages/ui/src/components/primitives/{radioGroup.tsx => radio-group.tsx} (60%) create mode 100644 packages/ui/src/components/primitives/segmented-control.tsx create mode 100644 packages/ui/src/components/primitives/table.tsx create mode 100644 packages/ui/stories/components/composites/DataTable.stories.tsx create mode 100644 packages/ui/stories/components/composites/DateTimePicker.stories.tsx create mode 100644 packages/ui/stories/components/primitives/ContextMenu.stories.tsx create mode 100644 packages/ui/stories/components/primitives/Item.stories.tsx create mode 100644 packages/ui/stories/components/primitives/SegmentedControl.stories.tsx create mode 100644 src/main/services/SettingsWindowService.ts create mode 100644 src/main/services/__tests__/AppMenuService.test.ts create mode 100644 src/main/services/__tests__/SettingsWindowService.test.ts create mode 100644 src/main/services/protocol/__tests__/ProtocolService.test.ts create mode 100644 src/main/services/protocol/handlers/__tests__/navigate.test.ts create mode 100644 src/main/services/protocol/handlers/__tests__/providersImport.test.ts create mode 100644 src/renderer/settings.html create mode 100644 src/renderer/src/components/AppModal/__tests__/AppModal.integration.test.tsx create mode 100644 src/renderer/src/components/AppModal/__tests__/AppModal.test.tsx create mode 100644 src/renderer/src/components/AppModal/index.tsx delete mode 100644 src/renderer/src/components/TextBadge.tsx create mode 100644 src/renderer/src/components/layout/__tests__/ShellTabBarActions.test.tsx rename src/renderer/src/pages/{settings => agents}/AgentSettings/AgentSettingsPopup.tsx (100%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/BaseSettingsPopup.tsx (68%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/SessionSettingsPopup.tsx (100%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/components/AccessibleDirsSetting.tsx (88%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/components/AdvancedSettings.tsx (98%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/components/DescriptionSetting.tsx (94%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/components/EssentialSettings.tsx (100%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/components/HeartbeatSetting.tsx (93%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/components/ModelSetting.tsx (100%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/components/NameSetting.tsx (96%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/components/PermissionModeSettings.tsx (97%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/components/PromptSettings.tsx (76%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/components/SkillsSettings/SkillsSettings.tsx (90%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/components/SoulModeSetting.tsx (97%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/components/TaskListItem.tsx (95%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/components/TaskLogsModal.tsx (94%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/components/ToolsSettings.tsx (98%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/index.tsx (100%) rename src/renderer/src/pages/{settings => agents}/AgentSettings/shared.tsx (77%) rename src/renderer/src/pages/{settings => home}/AssistantSettings/AssistantKnowledgeBaseSettings.tsx (84%) rename src/renderer/src/pages/{settings => home}/AssistantSettings/AssistantMCPSettings.tsx (58%) rename src/renderer/src/pages/{settings => home}/AssistantSettings/AssistantModelSettings.tsx (93%) rename src/renderer/src/pages/{settings => home}/AssistantSettings/AssistantPromptSettings.tsx (79%) rename src/renderer/src/pages/{settings => home}/AssistantSettings/index.tsx (73%) create mode 100644 src/renderer/src/pages/settings/CommonSettings/components/ThemeColorPicker.tsx create mode 100644 src/renderer/src/pages/settings/CommonSettings/components/__tests__/ThemeColorPicker.test.tsx create mode 100644 src/renderer/src/pages/settings/CommonSettings/index.tsx rename src/renderer/src/pages/settings/{ => ComponentLabSettings}/ComponentLabAgentSelectorSettings.tsx (99%) rename src/renderer/src/pages/settings/{ => ComponentLabSettings}/ComponentLabAssistantSelectorSettings.tsx (99%) rename src/renderer/src/pages/settings/{ => ComponentLabSettings}/ComponentLabModelSelectorSettings.tsx (99%) rename src/renderer/src/pages/settings/{ => ComponentLabSettings}/ComponentLabSettings.tsx (87%) create mode 100644 src/renderer/src/pages/settings/ComponentLabSettings/index.ts delete mode 100644 src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx delete mode 100644 src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx delete mode 100644 src/renderer/src/pages/settings/GeneralSettings.tsx rename src/renderer/src/pages/settings/{DataSettings => IntegrationSettings}/JoplinSettings.tsx (78%) rename src/renderer/src/pages/settings/{DataSettings => IntegrationSettings}/NotionSettings.tsx (72%) rename src/renderer/src/pages/settings/{DataSettings => IntegrationSettings}/ObsidianSettings.tsx (64%) rename src/renderer/src/pages/settings/{DataSettings => IntegrationSettings}/SiyuanSettings.tsx (82%) rename src/renderer/src/pages/settings/{DataSettings => IntegrationSettings}/YuqueSettings.tsx (53%) create mode 100644 src/renderer/src/pages/settings/IntegrationSettings/index.tsx create mode 100644 src/renderer/src/pages/settings/MCPSettings/EnvironmentDependencies.tsx delete mode 100644 src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx create mode 100644 src/renderer/src/pages/settings/MCPSettings/McpDetailList.tsx create mode 100644 src/renderer/src/pages/settings/ModelSettings/ParameterSlider.tsx delete mode 100644 src/renderer/src/pages/settings/TranslateSettingsPopup/CustomLanguageModal.tsx delete mode 100644 src/renderer/src/pages/settings/TranslateSettingsPopup/CustomLanguageSettings.tsx delete mode 100644 src/renderer/src/pages/settings/TranslateSettingsPopup/TranslatePromptSettings.tsx rename src/renderer/src/pages/translate/{ => components}/TranslateHistory.tsx (96%) create mode 100644 src/renderer/src/pages/translate/components/TranslateSettingsPopup/CustomLanguageModal.tsx create mode 100644 src/renderer/src/pages/translate/components/TranslateSettingsPopup/CustomLanguageSettings.tsx create mode 100644 src/renderer/src/pages/translate/components/TranslateSettingsPopup/TranslatePromptSettings.tsx rename src/renderer/src/pages/{settings => translate/components}/TranslateSettingsPopup/TranslateSettingsPopup.tsx (53%) delete mode 100644 src/renderer/src/routes/settings/display.tsx create mode 100644 src/renderer/src/routes/settings/integrations.tsx delete mode 100644 src/renderer/src/routes/settings/notes.tsx create mode 100644 src/renderer/src/routes/settings/plugins.tsx create mode 100644 src/renderer/src/services/SettingsWindowService.ts create mode 100644 src/renderer/src/windows/settings/entryPoint.tsx diff --git a/electron.vite.config.ts b/electron.vite.config.ts index a9a45976d5..b5ae848e54 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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'), diff --git a/package.json b/package.json index daab1b1fef..88d23b673d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 416f0c490c..473bf85590 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -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', diff --git a/packages/shared/data/preference/preferenceSchemas.ts b/packages/shared/data/preference/preferenceSchemas.ts index 02708c3309..9a56d66459 100644 --- a/packages/shared/data/preference/preferenceSchemas.ts +++ b/packages/shared/data/preference/preferenceSchemas.ts @@ -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: diff --git a/packages/shared/data/types/settingsPath.ts b/packages/shared/data/types/settingsPath.ts new file mode 100644 index 0000000000..6a317ffb99 --- /dev/null +++ b/packages/shared/data/types/settingsPath.ts @@ -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 +} diff --git a/packages/ui/README.md b/packages/ui/README.md index d6afcb263f..11db30e39f 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -68,10 +68,10 @@ Use the full Cherry Studio design system so Tailwind theme tokens resolve to Che
Extra large spacing (5rem)
Maximum spacing (15rem)
-
Tiny radius (0.25rem)
-
Small radius (1rem)
-
Medium radius (2rem)
-
Large radius (3rem)
+
Tiny radius (0.03125rem)
+
Small radius (0.125rem)
+
Medium radius (0.5rem)
+
Large radius (0.875rem)
Full radius (999px)
``` diff --git a/packages/ui/package.json b/packages/ui/package.json index e51ddd5485..ab09b3377d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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" diff --git a/packages/ui/scripts/__tests__/build-theme-css.test.ts b/packages/ui/scripts/__tests__/build-theme-css.test.ts index b6c1c03626..d82b104ff3 100644 --- a/packages/ui/scripts/__tests__/build-theme-css.test.ts +++ b/packages/ui/scripts/__tests__/build-theme-css.test.ts @@ -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);') diff --git a/packages/ui/scripts/build-theme-css.ts b/packages/ui/scripts/build-theme-css.ts index 64a9e8dd7f..c52c07986c 100644 --- a/packages/ui/scripts/build-theme-css.ts +++ b/packages/ui/scripts/build-theme-css.ts @@ -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 = [ diff --git a/packages/ui/scripts/codegen.ts b/packages/ui/scripts/codegen.ts index 449f3121b2..13740a8edf 100644 --- a/packages/ui/scripts/codegen.ts +++ b/packages/ui/scripts/codegen.ts @@ -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'] }) diff --git a/packages/ui/src/components/composites/CodeEditor/CodeEditor.tsx b/packages/ui/src/components/composites/CodeEditor/CodeEditor.tsx index da05922ea3..4e463d5c69 100644 --- a/packages/ui/src/components/composites/CodeEditor/CodeEditor.tsx +++ b/packages/ui/src/components/composites/CodeEditor/CodeEditor.tsx @@ -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 diff --git a/packages/ui/src/components/composites/ConfirmDialog/index.tsx b/packages/ui/src/components/composites/ConfirmDialog/index.tsx index c0d8d8ee32..72100c24a3 100644 --- a/packages/ui/src/components/composites/ConfirmDialog/index.tsx +++ b/packages/ui/src/components/composites/ConfirmDialog/index.tsx @@ -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 */ diff --git a/packages/ui/src/components/composites/DataTable/__tests__/DataTable.test.tsx b/packages/ui/src/components/composites/DataTable/__tests__/DataTable.test.tsx new file mode 100644 index 0000000000..8e9469afab --- /dev/null +++ b/packages/ui/src/components/composites/DataTable/__tests__/DataTable.test.tsx @@ -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 + }) => ( + } + headerRight={} + /> + ) + + 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() + + 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() + + 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( +
{longExpandedText}
} + /> + ) + + 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( + ({ 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( + ({ 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( + + ) + + 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( + + ) + + const radios = screen.getAllByRole('radio') + expect(radios[0]).toHaveAttribute('aria-checked', 'true') + expect(radios[1]).toHaveAttribute('aria-checked', 'false') + }) + + it('renders empty text', () => { + render() + expect(screen.getByText('No people')).toBeInTheDocument() + }) + + it('supports controlled expanded rows', () => { + const onExpandedRowChange = vi.fn() + + render( +
Details for {person.name}
} + /> + ) + + 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']) + }) +}) diff --git a/packages/ui/src/components/composites/DataTable/index.tsx b/packages/ui/src/components/composites/DataTable/index.tsx new file mode 100644 index 0000000000..023b97029d --- /dev/null +++ b/packages/ui/src/components/composites/DataTable/index.tsx @@ -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 = { + getCheckboxProps?: (record: TData) => { + disabled?: boolean + ariaLabel?: string + } + columnWidth?: number | string +} + +export type DataTableSelection = + | (DataTableSelectionBase & { + type: 'single' + selectedRowKey: DataTableKey | null + onChange: (selectedRowKey: DataTableKey | null, selectedRow: TData | null) => void + }) + | (DataTableSelectionBase & { + type: 'multiple' + selectedRowKeys: DataTableKey[] + onChange: (selectedRowKeys: DataTableKey[], selectedRows: TData[]) => void + }) + +export type DataTableProps = { + data: TData[] + columns: ColumnDef[] + rowKey: keyof TData | ((record: TData) => DataTableKey) + selection?: DataTableSelection + 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(cell: Cell): DataTableColumnMeta | undefined { + return cell.column.columnDef.meta as DataTableColumnMeta | undefined +} + +function getHeaderMeta(columnDef: ColumnDef): 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({ + data, + columns, + rowKey, + selection, + headerLeft, + headerRight, + emptyText = 'No results.', + maxHeight, + maxWidth, + tableLayout = 'auto', + rowClassName, + onRowClick, + renderExpandedRow, + getCanExpand, + expandedRowKeys = [], + onExpandedRowChange, + className +}: DataTableProps) { + 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() + data.forEach((record) => { + const key = getRecordKey(record) + map.set(normalizeKey(key), { key, record }) + }) + return map + }, [data, getRecordKey]) + + const selectedRowIds = React.useMemo(() => { + if (!selection) { + return {} + } + + const selectedRowKeys = + selection.type === 'single' + ? selection.selectedRowKey === null + ? [] + : [selection.selectedRowKey] + : selection.selectedRowKeys + + return selectedRowKeys.reduce((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) => { + const next = typeof updater === 'function' ? updater(selectedRowIds) : updater + emitSelectionChange(next) + }, + [emitSelectionChange, selectedRowIds] + ) + + const selectionColumn = React.useMemo | 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 ( +
+ +
+ ) + }, + enableSorting: false, + enableHiding: false, + meta: { width, maxWidth: width, align: 'center' } satisfies DataTableColumnMeta + } + } + + return { + id: '__selection', + size: typeof width === 'number' ? width : undefined, + header: ({ table }) => ( +
+ table.toggleAllRowsSelected(Boolean(checked))} + aria-label="Select all rows" + /> +
+ ), + cell: ({ row }) => { + const checkboxProps = selection.getCheckboxProps?.(row.original) + return ( +
+ row.toggleSelected(Boolean(checked))} + aria-label={checkboxProps?.ariaLabel ?? 'Select row'} + /> +
+ ) + }, + 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 | 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 ( + + ) + }, + enableSorting: false, + enableHiding: false, + meta: { width: 36, maxWidth: 36, align: 'center' } satisfies DataTableColumnMeta + } + }, [expandedRowIdSet, getCanExpand, renderExpandedRow, toggleExpandedRow]) + + const tableColumns = React.useMemo[]>( + () => [selectionColumn, expandColumn, ...columns].filter((column): column is ColumnDef => 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 = ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = getHeaderMeta(header.column.columnDef) + + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + + {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 ( + + onRowClick?.(row.original, index)}> + {row.getVisibleCells().map((cell) => { + const meta = getColumnMeta(cell) + + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + {renderExpandedRow && isExpanded && ( + + +
+ {renderExpandedRow(row.original, index)} +
+
+
+ )} +
+ ) + }) + ) : ( + + + {emptyText} + + + )} +
+
+
+
+ ) + + return ( +
+ {hasToolbar && ( +
+
{headerLeft}
+
{headerRight}
+
+ )} + {selection?.type === 'single' ? ( + { + const selected = rowById.get(value) + selection.onChange(selected?.key ?? null, selected?.record ?? null) + }} + className="block"> + {tableElement} + + ) : ( + tableElement + )} +
+ ) +} + +export { DataTable } +export type { ColumnDef } diff --git a/packages/ui/src/components/composites/DateTimePicker/__tests__/DateTimePicker.test.tsx b/packages/ui/src/components/composites/DateTimePicker/__tests__/DateTimePicker.test.tsx new file mode 100644 index 0000000000..e210144501 --- /dev/null +++ b/packages/ui/src/components/composites/DateTimePicker/__tests__/DateTimePicker.test.tsx @@ -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( + + ) + + 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 ( + {}} + onChange={(date) => { + if (date) setValue(date) + onChange(date) + }} + /> + ) + } + + render() + + 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( {}} />) + + expect(screen.queryByLabelText('Hour')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Minute')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Second')).not.toBeInTheDocument() + }) +}) diff --git a/packages/ui/src/components/composites/DateTimePicker/index.tsx b/packages/ui/src/components/composites/DateTimePicker/index.tsx new file mode 100644 index 0000000000..a09824d4f1 --- /dev/null +++ b/packages/ui/src/components/composites/DateTimePicker/index.tsx @@ -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 + labels?: DateTimePickerLabels +} + +export type DateTimePickerProps = DateTimePickerBaseProps & DateTimePickerValueProps & DateTimePickerOpenProps + +const defaultLabels = { + hour: 'Hour', + minute: 'Minute', + second: 'Second' +} satisfies Required + +const defaultFormatByGranularity: Record = { + 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(() => normalizeDate(defaultValue)) + const selectedDate = isValueControlled ? normalizeDate(value) : internalValue + const [month, setMonth] = React.useState(() => { + 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 ( + + + + + +
+ + {granularity !== 'day' && ( +
+ handleTimePartChange('hours', nextValue)} + /> + {(granularity === 'minute' || granularity === 'second') && ( + handleTimePartChange('minutes', nextValue)} + /> + )} + {granularity === 'second' && ( + handleTimePartChange('seconds', nextValue)} + /> + )} +
+ )} +
+
+
+ ) +} + +function TimeInput({ + label, + value, + max, + disabled, + onChange +}: { + label: string + value: number + max: number + disabled?: boolean + onChange: (value: string) => void +}) { + return ( + + ) +} + +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 } diff --git a/packages/ui/src/components/composites/EditableNumber/__tests__/EditableNumber.test.tsx b/packages/ui/src/components/composites/EditableNumber/__tests__/EditableNumber.test.tsx new file mode 100644 index 0000000000..6c585296da --- /dev/null +++ b/packages/ui/src/components/composites/EditableNumber/__tests__/EditableNumber.test.tsx @@ -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() + + 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() + + 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() + + const input = screen.getByRole('spinbutton') + fireEvent.change(input, { target: { value: '9' } }) + fireEvent.keyDown(input, { key: 'Escape' }) + + expect(input).toHaveValue(4) + expect(onChange).not.toHaveBeenCalled() + }) +}) diff --git a/packages/ui/src/components/composites/EditableNumber/index.tsx b/packages/ui/src/components/composites/EditableNumber/index.tsx index 0d98fa7f26..d0aa409726 100644 --- a/packages/ui/src/components/composites/EditableNumber/index.tsx +++ b/packages/ui/src/components/composites/EditableNumber/index.tsx @@ -91,6 +91,7 @@ const EditableNumber: React.FC = ({ }) => { const [isEditing, setIsEditing] = React.useState(false) const [inputValue, setInputValue] = React.useState(() => toInputValue(value, precision)) + const inputRef = React.useRef(null) React.useEffect(() => { if (!isEditing) { @@ -114,6 +115,12 @@ const EditableNumber: React.FC = ({ setIsEditing(true) } + React.useEffect(() => { + if (isEditing) { + inputRef.current?.focus() + } + }, [isEditing]) + const handleChange = (event: React.ChangeEvent) => { const nextValue = event.target.value setInputValue(nextValue) @@ -145,10 +152,29 @@ const EditableNumber: React.FC = ({ } 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) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + handleFocus() + } + } return (
= ({ 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} /> -
- - {prefix} - {displayValue} - {suffix} - -
+ {shouldRenderDisplayValue && !isEditing && ( +
+ + {prefix} + {displayValue} + {suffix} + +
+ )}
) } diff --git a/packages/ui/src/components/composites/Form/__tests__/Form.test.tsx b/packages/ui/src/components/composites/Form/__tests__/Form.test.tsx new file mode 100644 index 0000000000..ddc83f70a2 --- /dev/null +++ b/packages/ui/src/components/composites/Form/__tests__/Form.test.tsx @@ -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 ( +
+ ( + + Name + + + + Visible to teammates. + + + )} + /> + + ) + } + + render() + + 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) + }) +}) diff --git a/packages/ui/src/components/composites/Form/index.tsx b/packages/ui/src/components/composites/Form/index.tsx new file mode 100644 index 0000000000..e29b2602b4 --- /dev/null +++ b/packages/ui/src/components/composites/Form/index.tsx @@ -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 = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext(null) + +function FormField< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, + TTransformedValues = TFieldValues +>({ ...props }: ControllerProps) { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.use(FormFieldContext) + const itemContext = React.use(FormItemContext) + + if (!fieldContext) { + throw new Error('useFormField should be used within ') + } + + if (!itemContext) { + throw new Error('useFormField should be used within ') + } + + 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(null) + +function FormItem({ className, ...props }: React.ComponentProps<'div'>) { + const id = React.useId() + + return ( + +
+ + ) +} + +function FormLabel({ className, ...props }: React.ComponentProps) { + const { error, formItemId } = useFormField() + + return ( +