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.
This commit is contained in:
87
packages/web/src/components/ListToolbar.vue
Normal file
87
packages/web/src/components/ListToolbar.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<!-- recherche -->
|
||||
<div class="relative flex-1">
|
||||
<Search :size="15" class="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
v-model="searchModel"
|
||||
type="search"
|
||||
class="input pl-8"
|
||||
:placeholder="t(searchPlaceholderKey ?? 'controls.searchPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- tri : clé + direction -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<label class="sr-only" :for="sortId">{{ t('controls.sortBy') }}</label>
|
||||
<select :id="sortId" class="input w-auto" :value="controls.sortKey.value" @change="onSortChange">
|
||||
<option v-for="def in controls.sortDefs" :key="def.key" :value="def.key">{{ t(def.labelKey) }}</option>
|
||||
</select>
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon-only
|
||||
:icon="controls.sortDir.value === 'asc' ? ArrowUp : ArrowDown"
|
||||
:aria-label="t(controls.sortDir.value === 'asc' ? 'sort.asc' : 'sort.desc')"
|
||||
@click="controls.setSort(controls.sortKey.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- chips de filtre + compteur (le compteur s'affiche même sans filtres) -->
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<template v-for="def in controls.filterDefs.value" :key="def.key">
|
||||
<button
|
||||
v-for="opt in def.options"
|
||||
:key="def.key + ':' + opt.value"
|
||||
type="button"
|
||||
class="rounded-full border px-2 py-0.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||
:class="isActive(def.key, opt.value)
|
||||
? 'border-sky-700 bg-sky-950/40 text-sky-300'
|
||||
: 'border-zinc-700 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'"
|
||||
@click="controls.toggleFilter(def.key, opt.value)"
|
||||
>
|
||||
{{ opt.labelKey ? t(opt.labelKey) : opt.label }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<BaseButton
|
||||
v-if="controls.activeFilterCount.value > 0 || controls.search.value"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:icon="X"
|
||||
@click="controls.clearAll()"
|
||||
>
|
||||
{{ t('controls.clearAll') }}
|
||||
</BaseButton>
|
||||
|
||||
<span class="ml-auto text-xs text-zinc-500">{{ t('controls.results', controls.total.value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { computed, useId } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Search, ArrowUp, ArrowDown, X } from '@lucide/vue';
|
||||
import BaseButton from './ui/BaseButton.vue';
|
||||
import type { ListToolbarControls } from '../composables/useListControls';
|
||||
|
||||
const props = defineProps<{ controls: ListToolbarControls<T>; searchPlaceholderKey?: string }>();
|
||||
const { t } = useI18n();
|
||||
const sortId = useId();
|
||||
|
||||
const searchModel = computed({
|
||||
get: () => props.controls.search.value,
|
||||
set: (v: string) => props.controls.setSearch(v),
|
||||
});
|
||||
|
||||
function onSortChange(e: Event): void {
|
||||
props.controls.setSort((e.target as HTMLSelectElement).value);
|
||||
}
|
||||
|
||||
function isActive(key: string, value: string): boolean {
|
||||
return props.controls.filters.value[key]?.has(value) ?? false;
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user