feat: add Theme component and enhance Button with href and target props

feat/vapor
tangjinzhou 2025-07-30 19:31:43 +08:00
parent 73dd6d7625
commit 428fdfc182
10 changed files with 213 additions and 36 deletions

View File

@ -1,37 +1,37 @@
<template>
<button :class="rootClass" @click="$emit('click', $event)" :disabled="disabled" :style="cssVars">
<slot name="loading"></slot>
<button :class="rootClass" @click="handleClick" :disabled="disabled" :style="cssVars">
<slot name="loading">
<LoadingOutlined v-if="loading" />
</slot>
<slot name="icon"></slot>
<slot></slot>
<span><slot></slot></span>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, Fragment } from 'vue'
import { buttonProps, buttonEmits, ButtonSlots } from './meta'
import { getCssVarColor } from '@/utils/colorAlgorithm'
import { useThemeInject } from '../theme/hook'
import LoadingOutlined from '@ant-design/icons-vue/LoadingOutlined'
const props = defineProps(buttonProps)
defineEmits(buttonEmits)
const emit = defineEmits(buttonEmits)
defineSlots<ButtonSlots>()
// todo: color value should from theme provider
const theme = useThemeInject()
const color = computed(() => {
if (props.disabled) {
return 'rgba(0,0,0,0.25)'
}
if (props.color) {
return props.color
}
if (props.danger) {
return '#ff4d4f'
}
if (props.variant === 'text') {
return '#000000'
return theme.dangerColor
}
return '#1677ff'
return theme.primaryColor
})
const rootClass = computed(() => {
@ -42,9 +42,17 @@ const rootClass = computed(() => {
'ant-btn-danger': props.danger,
'ant-btn-loading': props.loading,
'ant-btn-disabled': props.disabled,
'ant-btn-custom-color': props.color || props.danger,
}
})
const cssVars = computed(() => {
return getCssVarColor(color.value)
})
const handleClick = (event: MouseEvent) => {
emit('click', event)
if (props.href) {
window.open(props.href, props.target)
}
}
</script>

View File

@ -62,6 +62,20 @@ export const buttonProps = {
color: {
type: String,
},
/**
* Specifies the href of the button
*/
href: {
type: String,
},
/**
* Specifies the target of the button
*/
target: {
type: String,
},
} as const
export type ButtonProps = ExtractPublicPropTypes<typeof buttonProps>

View File

@ -2,40 +2,55 @@
.ant-btn {
@apply relative;
@apply inline-flex shrink-0 cursor-pointer items-center justify-center gap-1 whitespace-nowrap;
@apply inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap;
@apply border-1 text-sm;
@apply box-border rounded-md px-4 transition-all duration-200 select-none;
&:where(.ant-btn-disabled) {
@apply cursor-not-allowed;
}
&:where(.ant-btn-loading) {
@apply cursor-default opacity-50;
@apply cursor-default opacity-65;
}
&:where(.ant-btn-solid) {
@apply border-none bg-[var(--bg-color)] text-[var(--bg-color-content)];
@apply not-disabled:hover:bg-[var(--bg-color-hover)] not-disabled:active:bg-[var(--bg-color-active)];
&:where(.ant-btn-solid:not(:disabled)) {
@apply border-none bg-[var(--accent-color)] text-[var(--accent-color-content)];
@apply hover:bg-[var(--accent-color-hover)] active:bg-[var(--accent-color-active)];
}
&:where(.ant-btn-outlined),
&:where(.ant-btn-dashed) {
@apply border-[var(--border-color-tint-30)] bg-transparent text-[var(--text-color)];
@apply not-disabled:hover:border-[var(--border-color-hover)] not-disabled:hover:text-[var(--text-color-hover)] not-disabled:active:border-[var(--border-color-active)] not-disabled:active:text-[var(--text-color-active)];
@apply disabled:border-[var(--border-color-tint-80)];
&:where(.ant-btn-outlined:not(:disabled)),
&:where(.ant-btn-dashed:not(:disabled)) {
@apply border-[var(--accent-color)] bg-transparent text-[var(--accent-color)];
@apply hover:text-[var(--accent-color-hover)] active:border-[var(--accent-color-active)] active:text-[var(--accent-color-active)];
@apply border-[var(--accent-color-active)] hover:border-[var(--accent-color-hover)];
}
&:where(.ant-btn-text) {
@apply border-none bg-transparent text-[var(--text-color)];
@apply not-disabled:hover:bg-[var(--bg-color-tint-90)] not-disabled:hover:text-[var(--text-color-hover)];
&:where(.ant-btn-text:not(.ant-btn-custom-color):not(:disabled)) {
@apply border-none bg-transparent text-[var(--neutral-color)];
@apply hover:bg-[var(--neutral-disabled-bg)];
}
&:where(.ant-btn-text.ant-btn-custom-color:not(:disabled)) {
@apply border-none bg-transparent text-[var(--accent-color)];
@apply hover:bg-[var(--accent-color-1)] hover:text-[var(--accent-color-hover)];
}
&:where(.ant-btn-link) {
@apply border-none bg-transparent text-[var(--text-color)] not-disabled:hover:text-[var(--text-color-hover)];
&:where(.ant-btn-link:not(:disabled)) {
@apply border-none bg-transparent text-[var(--accent-color)] hover:text-[var(--accent-color-hover)];
}
&:where(.ant-btn-dashed) {
@apply border-dashed;
}
&:where(.ant-btn-filled) {
@apply border-none bg-[var(--bg-color-tint-90)] text-[var(--text-color)] not-disabled:hover:text-[var(--text-color-hover)];
@apply not-disabled:hover:bg-[var(--bg-color-tint-80)] not-disabled:active:bg-[var(--bg-color-tint-80)];
&:where(.ant-btn-filled:not(:disabled)) {
@apply border-none bg-[var(--accent-color-1)] text-[var(--accent-color)] hover:text-[var(--accent-color-hover)];
@apply hover:bg-[var(--accent-color-2)] active:bg-[var(--accent-color-3)] active:text-[var(--accent-color-active)];
}
&:where(.ant-btn-disabled) {
@apply cursor-not-allowed;
@apply border-[var(--neutral-border)] bg-[var(--neutral-disabled-bg)] text-[var(--neutral-disabled)];
}
&:where(.ant-btn-disabled.ant-btn-text),
&:where(.ant-btn-disabled.ant-btn-link) {
@apply border-none bg-transparent;
}
&:where(.ant-btn-disabled.ant-btn-filled) {
@apply border-none;
}
&:where(.ant-btn-sm) {

View File

@ -1,2 +1,3 @@
export { default as Button } from './button'
export { default as Input } from './input'
export { default as Theme } from './theme'

View File

@ -0,0 +1,12 @@
<template>
<slot />
</template>
<script setup lang="ts">
import { themeProps } from './meta'
import { useThemeProvide } from './hook'
const props = defineProps(themeProps)
useThemeProvide(props)
</script>

View File

@ -0,0 +1,21 @@
import { inject, InjectionKey, provide, Reactive } from 'vue'
type ThemeType = Reactive<{
appearance: 'light' | 'dark'
primaryColor: string
dangerColor: string
}>
const ThemeSymbol: InjectionKey<ThemeType> = Symbol('theme')
export const useThemeInject = () => {
return inject(ThemeSymbol, {
appearance: 'light',
primaryColor: '#1677ff',
dangerColor: '#ff4d4f',
})
}
export const useThemeProvide = (theme: ThemeType) => {
provide(ThemeSymbol, theme)
}

View File

@ -0,0 +1,14 @@
export * from './hook'
import { App, Plugin } from 'vue'
import Theme from './Theme.vue'
export { Theme }
/* istanbul ignore next */
Theme.install = function (app: App) {
app.component('ATheme', Theme)
return app
}
export default Theme as typeof Theme & Plugin

View File

@ -0,0 +1,31 @@
import { PropType, ExtractPublicPropTypes } from 'vue'
// Theme Props
export const themeProps = {
/**
* Specifies the theme of the component
* @default 'light'
*/
appearance: {
type: String as PropType<'light' | 'dark'>,
default: 'light',
},
/**
* Specifies the primary color of the component
* @default '#1677FF'
*/
primaryColor: {
type: String,
default: '#1677FF',
},
/**
* Specifies the danger color of the component
* @default '#ff4d4f'
*/
dangerColor: {
type: String,
default: '#ff4d4f',
},
} as const
export type ThemeProps = ExtractPublicPropTypes<typeof themeProps>

View File

@ -19,4 +19,11 @@
--color-warning-content: #ffffff;
--color-error: #ff3333;
--color-error-content: #ffffff;
--neutral-color: #000000e0;
--neutral-secondary: #000000a6;
--neutral-disabled: #00000040;
--neutral-border: #d9d9d9;
--neutral-separator: #0505050f;
--neutral-bg: #f5f5f5;
}

View File

@ -1,4 +1,5 @@
import { TinyColor } from '@ctrl/tinycolor'
import { generate, presetPalettes, presetDarkPalettes } from '@ant-design/colors'
export const getAlphaColor = (baseColor: string, alpha: number) =>
new TinyColor(baseColor).setAlpha(alpha).toRgbString()
@ -16,12 +17,65 @@ export const getShadeColor = (baseColor: string, shadeNumber: number) => {
return new TinyColor(baseColor).shade(shadeNumber).toString()
}
export const getCssVarColor = (baseColor: string) => {
export const getLightNeutralColor = () => {
return {
'--neutral-color': '#000000e0',
'--neutral-secondary': '#000000a6',
'--neutral-disabled': '#00000040',
'--neutral-disabled-bg': '#0000000a',
'--neutral-border': '#d9d9d9',
'--neutral-separator': '#0505050f',
'--neutral-bg': '#f5f5f5',
}
}
export const getDarkNeutralColor = () => {
return {
'--neutral-color': '#FFFFFFD9',
'--neutral-secondary': '#FFFFFFA6',
'--neutral-disabled': '#FFFFFF40',
'--neutral-disabled-bg': 'rgba(255, 255, 255, 0.08)',
'--neutral-border': '#424242',
'--neutral-separator': '#FDFDFD1F',
'--neutral-bg': '#000000',
}
}
export const getCssVarColor = (
baseColor: string,
opts?: { appearance: 'light' | 'dark'; backgroundColor: string },
) => {
const { appearance = 'light', backgroundColor = '#141414' } = opts || {}
const color = new TinyColor(baseColor)
const preset = appearance === 'dark' ? presetDarkPalettes : presetPalettes
const colors =
preset[baseColor] ||
generate(
color.toHexString(),
appearance === 'dark' ? { theme: appearance, backgroundColor } : undefined,
)
const accentColor = colors[5]
return {
'--accent-color-1': colors[0],
'--accent-color-2': colors[1],
'--accent-color-3': colors[2],
'--accent-color-4': colors[3],
'--accent-color-5': colors[4],
'--accent-color-6': colors[5],
'--accent-color-7': colors[6],
'--accent-color-8': colors[7],
'--accent-color-9': colors[8],
'--accent-color-10': colors[9],
'--accent-color': accentColor,
'--accent-color-hover': colors[4],
'--accent-color-active': colors[5],
'--accent-color-content': '#ffffff',
...(appearance === 'dark' ? getDarkNeutralColor() : getLightNeutralColor()),
'--bg-color': baseColor,
'--bg-color-hover': getTintColor(baseColor, 10),
'--bg-color-active': getTintColor(baseColor, 20),
'--bg-color-content': '#ffffff',
'--border-color': baseColor,
'--border-color-hover': getTintColor(baseColor, 10),
'--border-color-active': getTintColor(baseColor, 20),