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>
81 lines
2.9 KiB
Vue
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>
|