Files
codex-proxy-rs/frontend/src/components/base/BaseButton.vue
T
2026-08-14 01:24:49 +08:00

154 lines
5.1 KiB
Vue

<script setup lang="ts">
import { LoaderCircle } from '@lucide/vue'
import { computed, useSlots } from 'vue'
type ButtonVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'ghost'
type ButtonSize = 'sm' | 'default' | 'md' | 'lg'
const props = withDefaults(
defineProps<{
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
spinIconOnLoading?: boolean
disabled?: boolean
active?: boolean
iconOnly?: boolean
label?: string
title?: string
type?: 'button' | 'submit' | 'reset'
}>(),
{
variant: 'primary',
size: 'default',
loading: false,
spinIconOnLoading: false,
disabled: false,
active: false,
iconOnly: false,
type: 'button',
},
)
const slots = useSlots()
const variantClasses = {
default:
'bg-(--cp-bg-muted) text-(--cp-text-primary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-active) active:bg-(--cp-default-bg-active)',
primary:
'bg-(--cp-info) text-(--cp-info-on) hover:bg-(--cp-info-hover) active:bg-(--cp-info-pressed)',
success:
'bg-(--cp-success-bg) text-(--cp-success-text) hover:bg-(--cp-success-bg-hover) active:bg-(--cp-success-bg-active)',
warning:
'bg-(--cp-warning-bg) text-(--cp-warning-text) hover:bg-(--cp-warning-bg-hover) active:bg-(--cp-warning-bg-active)',
danger:
'bg-(--cp-danger-bg) text-(--cp-danger-text) hover:bg-(--cp-danger-bg-hover) active:bg-(--cp-danger-bg-active)',
ghost:
'bg-transparent text-(--cp-text-secondary) hover:bg-(--cp-bg-subtle) active:bg-(--cp-bg-muted)',
}
const iconOnlyDefaultClasses
= 'bg-(--cp-bg-surface) text-(--cp-text-secondary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal) active:bg-(--cp-default-bg-active)'
const sizeClasses: Record<ButtonSize, string> = {
sm: 'h-7 gap-1.5 px-3 text-xs',
default: 'h-(--cp-input-height-default) gap-2 px-4 text-[13px]',
md: 'h-11 gap-2 px-4 text-[13px]',
lg: 'h-11.5 gap-2.5 px-5 text-[15px]',
}
const iconOnlySizeClasses: Record<ButtonSize, string> = {
sm: 'size-8 rounded-(--cp-icon-button-radius)',
default: 'size-(--cp-input-height-default) rounded-(--cp-icon-button-radius)',
md: 'size-11 rounded-(--cp-icon-button-radius)',
lg: 'size-11.5 rounded-(--cp-icon-button-radius)',
}
const loadingIconSize: Record<ButtonSize, number> = {
sm: 14,
default: 15,
md: 16,
lg: 17,
}
const loadingIndicatorStyle = computed(() => {
const iconSize = loadingIconSize[props.size]
return {
width: `${iconSize}px`,
height: `${iconSize}px`,
}
})
const classes = computed(() => [
'inline-flex items-center justify-center border-0 font-bold leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none motion-safe:active:translate-y-px motion-safe:active:scale-[0.985]',
props.iconOnly ? iconOnlySizeClasses[props.size] : sizeClasses[props.size],
props.iconOnly ? 'shrink-0' : undefined,
props.iconOnly ? '' : 'rounded-(--cp-button-radius-base)',
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
props.iconOnly && (props.variant === 'default' || props.variant === 'primary')
? iconOnlyDefaultClasses
: variantClasses[props.variant],
props.active ? 'bg-(--cp-default-bg-hover) text-(--cp-normal)' : undefined,
])
const ariaLabel = computed(() => props.label || props.title)
const labelText = computed(() => {
const defaultSlot = slots.default?.()
const text = defaultSlot
?.map(node => (typeof node.children === 'string' ? node.children : ''))
.join('')
.trim()
return text || ''
})
const labelClasses = computed(() => [
'inline-flex min-w-0 items-center justify-center gap-2 [&>svg]:shrink-0',
labelText.value.length === 2 ? 'tracking-[0.12em]' : undefined,
])
const loadingIconClasses = computed(() => [
'inline-grid shrink-0 place-items-center leading-none [&>svg]:block',
props.loading
? '[&>svg]:origin-center [&>svg]:animate-spin [&>svg]:transform-view [&>svg]:will-change-transform motion-reduce:[&>svg]:animate-none'
: undefined,
])
</script>
<template>
<button
:type="type"
:class="classes"
:disabled="disabled || loading"
:aria-label="iconOnly ? ariaLabel : undefined"
:title="title || (iconOnly ? label : undefined)"
:aria-busy="loading"
>
<span
v-if="loading && !spinIconOnLoading"
:class="loadingIconClasses"
:style="loadingIndicatorStyle"
aria-hidden="true"
>
<LoaderCircle
:size="loadingIconSize[size]"
/>
</span>
<span v-else-if="$slots.icon" :class="loadingIconClasses" aria-hidden="true">
<slot name="icon" />
</span>
<span v-if="!iconOnly && $slots.default" :class="labelClasses">
<slot />
</span>
<span
v-if="iconOnly && !$slots.icon && (!loading || spinIconOnLoading)"
:class="loadingIconClasses"
aria-hidden="true"
>
<slot />
</span>
</button>
</template>