Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@
| `useDcFormSubmit` | `@dc-ui/components/form` | 提交状态机 composable:`{ status, run, reset }`;`run(fn)` 驱动 idle→submitting→success/error,成功/失败按时长自动回退 |
| `useDcForm` | `@dc-ui/components/form` | 读取 `DcForm` 注入上下文(`DC_FORM_INJECTION_KEY`),供自定义提交控件使用 |
| `DcBadge` | `@dc-ui/components/badge` | shadcn Badge 语义化封装:default/secondary/outline/destructive 透传 + `success`/`warning`/`danger`/`active`/`neutral`(带 dark 变体,与 DcStatusPill 色系一致) |
| `DcCopyButton` | `@dc-ui/components/copy-button` | 复制按钮:`copy-text` + `navigator.clipboard`,成功图标短暂变 ✅(`success-duration`);`label`/`show-toast` 可选;`@copied`/`@error` 事件 |
| `DcCopyButton` | `@dc-ui/components/button` | 复制按钮:复用 `DcButton` 契约,`variant` 缺省 `ghost`;`copy-text` 必填触发复制(`useClipboard`),成功图标切 ✅(1200ms 自动回退、色值变 emerald);`@copied`/`@error` 事件;`label`/`tooltip` 提供可访问名,`copy-text` 兜底 |
| `DcDropdownActionItem` | `@dc-ui/components/dropdown-action-item` | 菜单动作项:`icon`/`label`/`danger`(destructive 变体)/`disabled`/`inset` + `@select`,收敛 `Icon mr-2 + span` 散写 |
| `DcFormActions` | `@dc-ui/components/form-actions` | 表单底部操作组:「取消 + 提交(DcSubmitButton)」+ 中间插槽;`submit-status`/`cancel-label`/`submit-label`/`danger-submit` |

Expand All @@ -635,10 +635,11 @@
- **tooltip 补齐边界**:只为可操作的 icon-only 控件补齐;文字按钮、纯展示 Icon、以及说明型 Switch/Checkbox/链接提示维持原行为,不为统一形式强行增加 tooltip。
- 新增 dc-ui 组件需先证明既有组件无法覆盖;在本文档登记聚合后的完整契约,并符合 §20/§21 的密度与 token 规则。

### 22.3 迁移状态(2026-08 批次,未提交
### 22.3 迁移状态(2026-08 批次)

- **`DcButton` 聚合**:`DcIconButton` 已并入并删除(13 处调用迁入);手写 Tooltip+按钮迁移 10 文件 30 处(MessageToolbar 12 / WindowSideBar 8 / ChatStatusBar 2 / ModelConfigItem / MessageBlockImage / MessageBlockToolCallImagePreview / SkillsIndicator / SkillsPanel / ChatInputToolbar 4)。`DcButton` 已补齐 tooltip 定位、延迟、content class 与 keyboard-focus 参数,保留复杂提示契约。
- **`DcCopyButton` 归位**:由 `@dc-ui/components/copy-button` 并入 `@dc-ui/components/button`(旧目录删除),消费方(MessageToolbar / ArtifactBlock / CodeArtifact / McpJsonViewer / McpServers / TraceDialog / MessageBlockToolCall)全部经 `copy-text` 传入复制内容,MessageToolbar 增加 `copy-text` prop。
- **`DcConfirmDialog`**:迁移 5 文件 7 框(ChatPage / ModelConfigDialog ×2 / KnowledgeFileItem ×2 / SkillInstallDialog / SkillDetailDialog ×2)+ ChatPage.test.ts 适配。
- **`DcEmpty`**:迁移 10 文件 12 处空态(含 MemoryEmptyState 用 `#action` 插槽,组件接口不变)。
- **`DcSectionCard`** 4 文件 11 卡(MemoryDiagnosticsPanel / MemoryDirectivesPanel / MemoryConfigInlinePanel / OfficialPluginDetailPage);**`DcStatusPill`** 2 文件(RemoteSettings 5 渠道 / AcpDebugDialog);**`DcInlineError`** 12 文件(McpServerForm 5 处等)。
- **有意保留(记录在案)**:WindowSideBar remote-control(多行 tooltip)、WindowSideBarSessionItem pin/delete(依赖 scoped CSS)、MessageDialog / UpdateTaskCheckDialog(动态多按钮)、SettingsLeaveGuardDialog(路由守卫)、DataSettings 单按钮错误弹窗、desc-only 空态提示、带重试按钮的错误横幅。
- **有意保留(记录在案)**:WindowSideBar remote-control(多行 tooltip)、WindowSideBarSessionItem pin/delete(依赖 scoped CSS)、MessageDialog / UpdateTaskCheckDialog(动态多按钮)、SettingsLeaveGuardDialog(路由守卫)、DataSettings 单按钮错误弹窗、desc-only 空态提示、带重试按钮的错误横幅。
33 changes: 4 additions & 29 deletions src/dc-ui/components/button/DcButton.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed, useSlots } from 'vue'
import { Icon } from '@iconify/vue'
import { Primitive } from 'reka-ui'
Expand All @@ -12,32 +10,9 @@ import {
TooltipProvider,
TooltipTrigger
} from '@shadcn/components/ui/tooltip'
import { dcButtonVariants, type DcButtonVariants } from './index'
import { dcButtonVariants, DcButtonProps } from './props'

type DcIconSize = '3' | '3.5' | '4'
type DcTooltipSide = 'top' | 'bottom' | 'left' | 'right'

interface Props extends PrimitiveProps {
variant?: DcButtonVariants['variant']
size?: DcButtonVariants['size']
icon?: string
iconSize?: DcIconSize
iconClass?: HTMLAttributes['class']
loading?: boolean
disabled?: boolean
active?: boolean
/** Visible tooltip. `label` alone only provides the accessible name. */
tooltip?: string
tooltipSide?: DcTooltipSide
tooltipSideOffset?: number
tooltipDelayDuration?: number
tooltipContentClass?: HTMLAttributes['class']
tooltipIgnoreNonKeyboardFocus?: boolean
label?: string
class?: HTMLAttributes['class']
}

const props = withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<DcButtonProps>(), {
as: 'button',
iconSize: '4',
disabled: false,
Expand Down Expand Up @@ -98,7 +73,7 @@ if (import.meta.env.DEV && !accessibleName.value && isIconOnly.value && !slots.d
:class="buttonClass"
>
<Spinner v-if="loading" data-icon="inline-start" :class="iconSizeClass" />
<Icon v-else-if="icon" :icon="icon" :class="cn(iconSizeClass, iconClass)" />
<Icon v-else-if="icon" :key="icon" :icon="icon" :class="cn(iconSizeClass, iconClass)" />
<slot />
</Primitive>
</TooltipTrigger>
Expand All @@ -121,7 +96,7 @@ if (import.meta.env.DEV && !accessibleName.value && isIconOnly.value && !slots.d
:class="buttonClass"
>
<Spinner v-if="loading" data-icon="inline-start" :class="iconSizeClass" />
<Icon v-else-if="icon" :icon="icon" :class="cn(iconSizeClass, iconClass)" />
<Icon v-else-if="icon" :key="icon" :icon="icon" :class="cn(iconSizeClass, iconClass)" />
<slot />
</Primitive>
</TooltipProvider>
Expand Down
79 changes: 79 additions & 0 deletions src/dc-ui/components/button/DcCopyButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<script lang="ts">
import { computed, defineComponent, h, onUnmounted, ref, type PropType } from 'vue'
import { useClipboard } from '@vueuse/core'
import { cn } from '@shadcn/lib/utils'
import { type DcButtonProps } from './props'
import DcButton from './DcButton.vue'

interface DcCopyButtonProps extends DcButtonProps {
copyText?: string
}

export default defineComponent({
name: 'DcCopyButton',
inheritAttrs: false,
props: {
copyText: String,
Comment thread
zhangmo8 marked this conversation as resolved.
Outdated
variant: {
type: String as PropType<DcCopyButtonProps['variant']>,
default: 'ghost'
}
},
emits: {
copied: () => true,
error: (_error: unknown) => true
},
setup(props, { attrs, emit, slots }) {
const copied = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
const { copy } = useClipboard()
const icon = computed(() => (copied.value ? 'lucide:check' : 'lucide:copy'))

const copyText = async () => {
if (!props.copyText) return
try {
await copy(props.copyText)
copied.value = true
clearTimeout(timer)
timer = setTimeout(() => {
copied.value = false
}, 1200)
emit('copied')
} catch (error) {
console.error('[DcCopyButton] Failed to copy', error)
emit('error', error)
}
Comment thread
zhangmo8 marked this conversation as resolved.
}

onUnmounted(() => clearTimeout(timer))

return () => {
const inheritedAttrs = attrs as Partial<DcButtonProps> & Record<string, unknown>
const { copyText: _copyText, ...buttonProps } = props
const accessibleName = inheritedAttrs.label ?? inheritedAttrs.tooltip ?? _copyText

return h(
DcButton,
{
...inheritedAttrs,
...buttonProps,
key: icon.value,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep keyboard focus while changing the success icon

Using icon.value as the component key makes Vue unmount and recreate the entire DcButton whenever copied changes. A keyboard-activated copy therefore replaces the focused DOM node and moves focus to <body>; the next keyboard action and tooltip focus state are lost. The button and its icon already react to the icon prop, so this component-level key is unnecessary. Remove it and add a focus-retention assertion.

icon: icon.value,
iconClass: cn(
inheritedAttrs.iconClass,
copied.value && 'animate-in zoom-in-75 duration-200'
),
label: accessibleName,
class: cn(
'shrink-0',
copied.value ? 'text-emerald-600 dark:text-emerald-400' : '',
inheritedAttrs.class
),
onClick: copyText
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
},
slots

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve the visible label fallback

The previous template rendered <slot>{{ label }}</slot>. Passing the raw slots object here removes that fallback. CodeArtifact, McpJsonViewer, McpServers, and TraceDialog pass label without a default slot, so their copy controls change from icon + visible text to icon-only. Preserve label as the default-slot fallback (or retain the template implementation) and cover one text-button consumer.

)
}
}
})
</script>
39 changes: 2 additions & 37 deletions src/dc-ui/components/button/index.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,3 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'

export { default as DcButton } from './DcButton.vue'

export const dcButtonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-[color,background-color,border-color,scale] duration-[var(--dc-motion-fast)] ease-[var(--dc-ease-out-soft)] active:scale-[0.97] motion-reduce:active:scale-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4 [&_svg]:shrink-0 shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 gap-2 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
xs: 'h-7 gap-1.5 rounded-md px-2.5 text-xs has-[>svg]:px-2',
lg: 'h-10 gap-2 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-8',
'icon-sm': 'size-7',
'icon-xs': 'size-6',
'icon-lg': 'size-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)

export type DcButtonVariants = VariantProps<typeof dcButtonVariants>
export { default as DcCopyButton } from './DcCopyButton.vue'
export * from './props'
62 changes: 62 additions & 0 deletions src/dc-ui/components/button/props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'

export interface DcButtonProps extends PrimitiveProps {
variant?: DcButtonVariants['variant']
size?: DcButtonVariants['size']
icon?: string
iconSize?: DcIconSize
iconClass?: HTMLAttributes['class']
loading?: boolean
disabled?: boolean
active?: boolean
/** Visible tooltip. `label` alone only provides the accessible name. */
tooltip?: string
tooltipSide?: DcTooltipSide
tooltipSideOffset?: number
tooltipDelayDuration?: number
tooltipContentClass?: HTMLAttributes['class']
tooltipIgnoreNonKeyboardFocus?: boolean
label?: string
class?: HTMLAttributes['class']
}

import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'

type DcIconSize = '3' | '3.5' | '4'
type DcTooltipSide = 'top' | 'bottom' | 'left' | 'right'

export const dcButtonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-[color,background-color,border-color,scale] duration-[var(--dc-motion-fast)] ease-[var(--dc-ease-out-soft)] active:scale-[0.97] motion-reduce:active:scale-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4 [&_svg]:shrink-0 shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 gap-2 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
xs: 'h-7 gap-1.5 rounded-md px-2.5 text-xs has-[>svg]:px-2',
lg: 'h-10 gap-2 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-8',
'icon-sm': 'size-7',
'icon-xs': 'size-6',
'icon-lg': 'size-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)

export type DcButtonVariants = VariantProps<typeof dcButtonVariants>
Loading