nidus-sync/ts/components/layout/ListCard.vue
Eli Ribble da229592f5
Handle selection within generic CardList
VueJS generics are kinda insane. And awesome.
2026-05-18 15:40:02 +00:00

160 lines
3.8 KiB
Vue

<style scoped>
.filters-section {
font-size: 0.875rem;
}
</style>
<template>
<div class="card shadow-sm h-100 reports-list">
<div class="card-header bg-light pane-header">
<div class="filters-section">
<slot
:active-filters="activeFilters"
:apply-filter="applyFilter"
:clear-filters="clearFilters"
name="filters"
/>
</div>
</div>
<div class="card-body scroll-pane">
<div class="list-group list-group-flush">
<div class="loading list-group-item" v-if="loading || items == null">
Loading...
</div>
<div class="list-group-item" v-else-if="items.length == 0">
No items
</div>
<div
v-else-if="items.length == 0"
class="list-group-item text-center text-muted p-4"
>
<i class="bi bi-inbox fs-1"></i>
<p class="mt-2">No items match the current filters</p>
<button class="btn btn-sm btn-outline-primary" @click="clearFilters">
Reset Filters
</button>
</div>
<div v-else v-for="item in filteredItems" :key="item.id">
<slot
name="item"
:item="item"
:index="item.id"
:is-selected="isSelected(item)"
:toggle-selection="() => toggleSelection(item)"
></slot>
</div>
</div>
</div>
</div>
</template>
<script setup generic="T extends Record<string, any>" lang="ts">
import { ref, computed } from "vue";
interface Props {
items: T[];
loading?: boolean;
itemKey?: keyof T | ((item: T) => string | number);
selectionMode?: "single" | "multiple" | "none";
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
itemKey: "id",
selectionMode: "none",
});
defineSlots<{
filters(props: {
applyFilter: (filterFn: (item: T) => boolean) => void;
clearFilters: () => void;
activeFilters: number;
}): any;
item(props: {
item: T;
index: string | number;
isSelected: boolean;
toggleSelection: () => void;
}): any;
footer(props: { total: number }): any;
}>();
// State
const emit = defineEmits<{
"update:selectedItems": [items: T[]];
"selection-change": [items: T[]];
}>();
const filterFunctions = ref<Array<(item: T) => boolean>>([]);
const selectedItemKeys = ref<Set<string | number>>(new Set());
// Computed
const filteredItems = computed(() => {
if (filterFunctions.value.length === 0) {
return props.items;
}
return props.items.filter((item) =>
filterFunctions.value.every((fn) => fn(item)),
);
});
const activeFilters = computed(() => filterFunctions.value.length);
const selectedItems = computed(() => {
return props.items.filter((item) =>
selectedItemKeys.value.has(getItemKey(item)),
);
});
// Methods
const applyFilter = (filterFn: (item: T) => boolean) => {
// Add or replace filter (you can modify this logic as needed)
filterFunctions.value.push(filterFn);
};
const clearFilters = () => {
filterFunctions.value = [];
};
const getItemKey = (item: T): string | number => {
if (typeof props.itemKey === "function") {
return props.itemKey(item);
}
return item[props.itemKey] as string | number;
};
const isSelected = (item: T): boolean => {
return selectedItemKeys.value.has(getItemKey(item));
};
const toggleSelection = (item: T) => {
if (props.selectionMode === "none") return;
const key = getItemKey(item);
if (props.selectionMode === "single") {
if (selectedItemKeys.value.has(key)) {
selectedItemKeys.value.clear();
} else {
selectedItemKeys.value.clear();
selectedItemKeys.value.add(key);
}
} else {
if (selectedItemKeys.value.has(key)) {
selectedItemKeys.value.delete(key);
} else {
selectedItemKeys.value.add(key);
}
}
emit("update:selectedItems", selectedItems.value);
emit("selection-change", selectedItems.value);
};
const clearSelection = () => {
selectedItemKeys.value.clear();
emit("update:selectedItems", []);
emit("selection-change", []);
};
defineExpose({
clearSelection,
selectedItems,
});
</script>