Files
arboretum/packages/web/src/components/ui/BaseButton.vue
Johan LEROY b80e68e539 feat: refonte UX du dashboard (coquille, tri/filtre/pagination, ⌘K, toasts)
Refonte front (packages/web) : coquille applicative partagée (sidebar desktop +
barre d'onglets mobile + PageHeader, langue/push/logout centralisés) pilotée par
route.meta.layout ; design system (BaseButton/BaseBadge/SegmentedControl/EmptyState/
SkeletonRow, anneau de focus, icônes @lucide/vue) ; tri/filtre/recherche/pagination
côté client via useListControls (+ synchro URL) sur sessions/worktrees/groupes ;
section « À traiter », palette de commandes ⌘K, toasts. Aucune modif serveur/protocole/DB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:47:36 +02:00

81 lines
2.9 KiB
Vue

<template>
<component
:is="tag"
:class="classes"
v-bind="mergedAttrs"
@click="onClick"
>
<Loader2 v-if="loading" :size="iconSize" class="animate-spin" />
<component :is="icon" v-else-if="icon" :size="iconSize" />
<slot />
</component>
</template>
<script setup lang="ts">
// Bouton unifié : variantes + tailles + état loading + anneau de focus cohérent.
// Rend un <RouterLink> quand `to` est fourni (et actionnable), sinon un <button>.
import { computed, useAttrs, type Component } from 'vue';
import { RouterLink, type RouteLocationRaw } from 'vue-router';
import { Loader2 } from '@lucide/vue';
defineOptions({ inheritAttrs: false });
const props = withDefaults(
defineProps<{
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md';
loading?: boolean;
disabled?: boolean;
iconOnly?: boolean;
icon?: Component;
to?: RouteLocationRaw;
type?: 'button' | 'submit';
}>(),
{ variant: 'secondary', size: 'md', type: 'button' },
);
const emit = defineEmits<{ click: [MouseEvent] }>();
const attrs = useAttrs();
const isDisabled = computed(() => props.disabled || props.loading);
const asLink = computed(() => props.to !== undefined && !isDisabled.value);
const tag = computed(() => (asLink.value ? RouterLink : 'button'));
const iconSize = computed(() => (props.size === 'sm' ? 15 : 16));
const BASE =
'inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 disabled:cursor-not-allowed disabled:opacity-50';
const VARIANTS: Record<NonNullable<typeof props.variant>, string> = {
primary: 'bg-emerald-600 text-white hover:bg-emerald-500 focus-visible:ring-emerald-500/70',
secondary: 'border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 focus-visible:ring-emerald-500/70',
ghost: 'text-zinc-300 hover:bg-zinc-800/60 focus-visible:ring-emerald-500/70',
danger: 'border border-red-900 bg-red-950 text-red-300 hover:bg-red-900 focus-visible:ring-red-500/70',
};
const SIZES = {
sm: { text: 'h-8 px-2.5 text-xs', icon: 'h-8 w-8' },
md: { text: 'h-9 px-3 text-sm', icon: 'h-9 w-9' },
};
const classes = computed(() => {
const size = SIZES[props.size];
return [BASE, VARIANTS[props.variant], props.iconOnly ? size.icon : size.text];
});
// Attributs natifs propres au tag rendu (button : type/disabled/aria-busy ; lien : to).
const nativeProps = computed(() => {
if (asLink.value) return { to: props.to };
return { type: props.type, disabled: isDisabled.value, 'aria-busy': props.loading || undefined };
});
// useAttrs (class/aria-label/etc. du parent) + props natifs.
const mergedAttrs = computed(() => ({ ...attrs, ...nativeProps.value }));
function onClick(e: MouseEvent): void {
if (isDisabled.value) {
e.preventDefault();
return;
}
emit('click', e);
}
</script>