95 lines
3.6 KiB
Vue
95 lines
3.6 KiB
Vue
<script setup lang="ts">
|
|
import { computed } from 'vue';
|
|
|
|
type Severity = 'primary' | 'secondary' | 'danger' | 'success' | 'info';
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
/** Text content. The default slot wins when both are given. */
|
|
label?: string;
|
|
/** Icon class, e.g. `fas fa-plus`. Rendered before the label. */
|
|
icon?: string;
|
|
severity?: Severity;
|
|
size?: 'small' | 'normal' | 'large';
|
|
/** Shows a spinner and blocks interaction. */
|
|
loading?: boolean;
|
|
/** Pill shape. Used for the icon-only action buttons in list views. */
|
|
rounded?: boolean;
|
|
/** Borderless, transparent until hovered. */
|
|
text?: boolean;
|
|
outlined?: boolean;
|
|
disabled?: boolean;
|
|
type?: 'button' | 'submit' | 'reset';
|
|
}>(),
|
|
{
|
|
label: undefined,
|
|
icon: undefined,
|
|
severity: 'primary',
|
|
size: 'normal',
|
|
type: 'button',
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Every variant resolves to plain Tailwind utilities so an application built on
|
|
* Vanguard can restyle a button by editing this file, with no theme layer or
|
|
* specificity to work around.
|
|
*/
|
|
const solid: Record<Severity, string> = {
|
|
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus-visible:outline-primary-600',
|
|
secondary: 'bg-slate-200 text-slate-700 hover:bg-slate-300 focus-visible:outline-slate-400',
|
|
danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600',
|
|
success: 'bg-green-600 text-white hover:bg-green-700 focus-visible:outline-green-600',
|
|
info: 'bg-sky-600 text-white hover:bg-sky-700 focus-visible:outline-sky-600',
|
|
};
|
|
|
|
const subtle: Record<Severity, string> = {
|
|
primary: 'text-primary-600 hover:bg-primary-50 focus-visible:outline-primary-600',
|
|
secondary: 'text-slate-400 hover:bg-slate-100 hover:text-slate-600 focus-visible:outline-slate-400',
|
|
danger: 'text-red-600 hover:bg-red-50 focus-visible:outline-red-600',
|
|
success: 'text-green-600 hover:bg-green-50 focus-visible:outline-green-600',
|
|
info: 'text-sky-600 hover:bg-sky-50 focus-visible:outline-sky-600',
|
|
};
|
|
|
|
const outline: Record<Severity, string> = {
|
|
primary: 'border border-primary-600 text-primary-600 hover:bg-primary-50',
|
|
secondary: 'border border-slate-300 text-slate-700 hover:bg-slate-100',
|
|
danger: 'border border-red-600 text-red-600 hover:bg-red-50',
|
|
success: 'border border-green-600 text-green-600 hover:bg-green-50',
|
|
info: 'border border-sky-600 text-sky-600 hover:bg-sky-50',
|
|
};
|
|
|
|
const sizes = {
|
|
small: 'px-2.5 py-1 text-xs',
|
|
normal: 'px-4 py-2',
|
|
large: 'px-5 py-2.5 text-base',
|
|
};
|
|
|
|
/** Icon-only buttons are squared off so the pill variant renders as a circle. */
|
|
const iconOnly = computed(() => Boolean(props.icon) && !props.label);
|
|
|
|
const iconSizes = {
|
|
small: 'h-7 w-7',
|
|
normal: 'h-9 w-9',
|
|
large: 'h-11 w-11',
|
|
};
|
|
|
|
const classes = computed(() => [
|
|
'inline-flex cursor-pointer items-center justify-center gap-2 font-medium transition-colors',
|
|
'focus-visible:outline-2 focus-visible:outline-offset-2',
|
|
'disabled:pointer-events-none disabled:opacity-60',
|
|
props.rounded ? 'rounded-full' : 'rounded-md',
|
|
iconOnly.value ? iconSizes[props.size] : sizes[props.size],
|
|
props.text ? subtle[props.severity] : props.outlined ? outline[props.severity] : solid[props.severity],
|
|
]);
|
|
</script>
|
|
|
|
<template>
|
|
<button :type="type" :class="classes" :disabled="disabled || loading">
|
|
<i v-if="loading" class="fas fa-circle-notch animate-spin" aria-hidden="true" />
|
|
<i v-else-if="icon" :class="icon" aria-hidden="true" />
|
|
|
|
<slot>{{ label }}</slot>
|
|
</button>
|
|
</template>
|