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>
This commit is contained in:
80
packages/web/src/components/ui/BaseButton.vue
Normal file
80
packages/web/src/components/ui/BaseButton.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user