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.
88 lines
3.2 KiB
Vue
88 lines
3.2 KiB
Vue
<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>
|