Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
98 changes: 98 additions & 0 deletions src/dc-ui/components/button/DcCopyButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<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
}

type ClickHandler = (event: MouseEvent) => void

export default defineComponent({
name: 'DcCopyButton',
inheritAttrs: false,
props: {
copyText: {
type: String,
required: true
},
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 () => {
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.
}

const callClickHandler = (handler: unknown, event: MouseEvent) => {
if (Array.isArray(handler)) {
handler.forEach((item) => callClickHandler(item, event))
return
}
if (typeof handler === 'function') {
const clickHandler = handler as ClickHandler
clickHandler(event)
}
}

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
const inheritedClick = inheritedAttrs.onClick

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: (event: MouseEvent) => {
callClickHandler(inheritedClick, event)
void copyText()
}
},
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