Files
notebrook-notes/frontend-vue/src/components/base/BaseButton.vue
2025-09-02 22:35:24 +02:00

191 lines
3.7 KiB
Vue

<template>
<button
:type="type"
:disabled="disabled"
:class="[
'base-button',
`base-button--${variant}`,
`base-button--${size}`,
{ 'base-button--loading': loading }
]"
:aria-label="ariaLabel"
:aria-describedby="ariaDescribedby"
@click="$emit('click', $event)"
@keydown="handleKeydown"
>
<span v-if="loading" class="base-button__spinner" aria-hidden="true"></span>
<span :class="{ 'base-button__content--hidden': loading }">
<slot />
</span>
</button>
</template>
<script setup lang="ts">
interface Props {
type?: 'button' | 'submit' | 'reset'
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
ariaLabel?: string
ariaDescribedby?: string
}
withDefaults(defineProps<Props>(), {
type: 'button',
variant: 'primary',
size: 'md',
disabled: false,
loading: false
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
emit('click', event as any)
}
}
</script>
<style scoped>
.base-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 8px;
font-family: inherit;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
text-decoration: none;
/* iOS-specific optimizations */
-webkit-appearance: none;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.base-button:focus-visible {
outline: 2px solid #646cff;
outline-offset: 2px;
}
.base-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Sizes */
.base-button--sm {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
min-height: 2.75rem; /* 44px minimum for iOS touch targets */
min-width: 2.75rem;
}
.base-button--md {
padding: 0.75rem 1rem;
font-size: 1rem;
min-height: 2.75rem;
min-width: 2.75rem;
}
.base-button--lg {
padding: 1rem 1.5rem;
font-size: 1.125rem;
min-height: 3rem;
min-width: 3rem;
}
/* Variants */
.base-button--primary {
background-color: #646cff;
color: white;
}
.base-button--primary:hover:not(:disabled) {
background-color: #535bf2;
}
.base-button--secondary {
background-color: #f9f9f9;
color: #213547;
border-color: #d1d5db;
}
.base-button--secondary:hover:not(:disabled) {
background-color: #f3f4f6;
border-color: #9ca3af;
}
.base-button--danger {
background-color: #ef4444;
color: white;
}
.base-button--danger:hover:not(:disabled) {
background-color: #dc2626;
}
.base-button--ghost {
background-color: transparent;
color: #646cff;
/* Ensure ghost buttons always meet minimum touch targets */
min-height: 2.75rem;
min-width: 2.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.base-button--ghost:hover:not(:disabled) {
background-color: rgba(100, 108, 255, 0.1);
}
/* Loading state */
.base-button--loading {
cursor: wait;
}
.base-button__spinner {
position: absolute;
width: 1rem;
height: 1rem;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.base-button__content--hidden {
opacity: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.base-button--secondary {
background-color: #374151;
color: rgba(255, 255, 255, 0.87);
border-color: #4b5563;
}
.base-button--secondary:hover:not(:disabled) {
background-color: #4b5563;
border-color: #6b7280;
}
}
</style>