Skip to content

Instantly share code, notes, and snippets.

@MajesticPotatoe
Last active October 27, 2022 19:03
Show Gist options
  • Save MajesticPotatoe/73c201afebacc4ec9b5119c0d57120fe to your computer and use it in GitHub Desktop.
Save MajesticPotatoe/73c201afebacc4ec9b5119c0d57120fe to your computer and use it in GitHub Desktop.
<script setup lang="ts">
// imports
import { groupBy, keyBy, orderBy } from 'lodash'
import { useFilter } from 'vuetify/lib/composables/filter'
// globals
const props = defineProps({
density: {
type: String,
default: 'default',
} as any,
fixedGroups: {
type: Boolean,
default: false,
},
fixedHeader: {
type: Boolean,
default: false,
},
groupedBy: {
type: String || null,
default: null,
},
groupSortDesc: {
type: Boolean,
default: false,
},
headerClass: {
type: String,
default: '',
},
headers: {
type: Array,
default: () => { return [] },
} as any,
height: {
type: String || undefined,
default: undefined,
},
itemKey: {
type: String,
default: 'id',
},
items: {
type: Array,
default: () => { return [] },
} as any,
loading: {
type: Boolean,
default: false,
},
modelValue: {
type: Object || null,
default: null,
},
queryDrawer: {
type: Boolean,
default: false,
},
queryDrawerWidth: {
type: String || Number,
default: '280',
},
searchable: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
singleExpand: {
type: Boolean,
default: false,
},
sortBy: {
type: String,
default: null,
},
sortDesc: {
type: Boolean,
default: false,
},
})
const slots = useSlots()
// reactive
const expanded = ref([] as string[])
const groupExpanded = ref(['History', 'Upcoming'] as string[])
const internalSearchDrawer = ref(false)
const sortBy = ref(props?.sortBy || null)
const sortDesc = ref(props?.sortDesc || false) as any
const search = ref(null)
const stickyGroups = ref([] as string[])
// computed
const computedHeaders = computed(() => {
const headers = props?.headers?.filter((v: any) => !props?.groupedBy || props?.groupedBy != v.value)
if (props?.singleExpand) {
headers.unshift({ text: '', value: 'expand' })
}
return headers
})
const computedItems = computed(() => {
const idItems = (props?.items || []).map((item: any, ind: number) => {
if (!item[props?.itemKey]) {
item[props?.itemKey] = ind + 1
}
return item
})
const filterKeys = Object.keys(keyBy(props?.headers || [], 'value'))
const { filteredItems } = useFilter({ filterKeys }, idItems, computed(() => search.value || undefined))
return orderBy(filteredItems.value, [`item.${sortBy.value}`], [sortDesc.value ? 'desc' : 'asc'])
}) as any
const groupedItems = computed(() => props?.groupedBy ? groupBy(computedItems.value, `item.${props.groupedBy}`) : {})
const groupKeys = computed(() => props?.groupSortDesc
? Object.keys(groupedItems.value).sort().reverse()
: Object.keys(groupedItems.value).sort(),
)
const groups = computed(() => Object.keys(groupedItems.value) || [])
const showTopSlot = computed(() => slots.top || props.queryDrawer || props.searchable)
// methods
function onIntersect (value: boolean, entry: any) {
const id = entry?.[0]?.target?.id || null
if(value && !stickyGroups.value.includes(id)) {
stickyGroups.value.push(id)
} else if (!value) {
stickyGroups.value = stickyGroups.value.filter(v => v != id)
}
}
function setSort (headerName: string) {
const sameSort = headerName === sortBy.value
sortBy.value = sameSort && sortDesc.value ? null : headerName
sortDesc.value = sameSort && !sortDesc.value
}
function toggleGroupExpand (val: string) {
if (groupExpanded.value.includes(val)) {
groupExpanded.value = groupExpanded.value.filter(v => v != val)
} else {
groupExpanded.value.push(val)
}
}
function toggleExpand (val: string) {
const included = expanded.value.includes(val)
if (props?.singleExpand) {
expanded.value = included ? [] : [val]
}
else if (included) {
expanded.value = expanded.value.filter(v => v != val)
} else {
expanded.value.push(val)
}
}
// watchers
watch(groups, (nVal) => {
groupExpanded.value = nVal || []
})
</script>
<script lang="ts">
export default {
inheritAttrs: false,
}
</script>
<template>
<v-layout class="rounded-lg">
<v-navigation-drawer
v-model="internalSearchDrawer"
class="pa-2"
temporary
:width="queryDrawerWidth"
>
<slot name="query-drawer" />
</v-navigation-drawer>
<v-main>
<div
v-if="showTopSlot"
class="pa-2 d-flex align-center"
>
<BaseIconButton
v-if="queryDrawer"
color="info"
icon="$mdiMagnify"
tooltip-text="Search"
@click="internalSearchDrawer = !internalSearchDrawer"
/>
<slot name="top" />
<v-spacer v-if="!$slots.top" />
<v-text-field
v-if="searchable"
v-model="search"
clearable
density="compact"
placeholder="Search"
prepend-inner-icon="$mdiMagnify"
style="max-width: 300px;"
/>
</div>
<v-divider v-if="showTopSlot" />
<v-table
:height="height"
:density="density"
:fixed-header="fixedHeader"
>
<tr v-if="loading">
<td :colspan="headers.length">
<v-progress-linear
indeterminate
/>
</td>
</tr>
<thead>
<tr>
<th
v-for="header in computedHeaders"
:key="header.value"
:class="[
headerClass,
header.class || '',
header.align ? `text-${header.align}` : '',
header.sortable ? 'sortable' : ''
]"
style="z-index: 1"
@click="header.sortable ? setSort(header.value) : null"
>
<div class="d-flex align-center">
<v-icon
v-if="header.sortable && sortBy === header.value"
size="x-small"
:icon="sortDesc ? '$mdiChevronDown' : '$mdiChevronUp'"
/>
<div>
{{ header.text }}
</div>
</div>
</th>
</tr>
</thead>
<tbody>
<template v-if="groupedBy">
<template
v-for="(group, gi) in groupKeys"
:key="`group-${gi}`"
>
<tr
:id="`group-${gi}`"
v-intersect="onIntersect"
:class="[
'group-header no-pointer',
stickyGroups.includes(`group-${gi}`) ? 'sticky-group' : ''
]"
>
<td :colspan="computedHeaders.length">
<div class="d-flex align-center">
<v-btn
size="x-small"
variant="text"
:icon=" groupExpanded.includes(group) ? '$mdiMinus' : '$mdiPlus'"
@click="toggleGroupExpand(group)"
/>
<div class="text-subtitle-2 mx-2">
{{ group }}
</div>
</div>
</td>
</tr>
<template v-if="groupExpanded.includes(group)">
<template
v-for="({ item }, ind) in groupedItems[group]"
:key="`tr-${ind}`"
>
<tr
:class="[
selectable ? 'selectable' : 'no-pointer',
item?.[itemKey] === modelValue?.[itemKey] ? 'selected' : ''
]"
@click="selectable ? $emit('update:modelValue', item?.[itemKey] === modelValue?.[itemKey] ? null : item) : null"
>
<slot
v-for="header in computedHeaders"
:key="`tr-${ind}-${header.value}`"
:item="item"
:name="`item-${header.value}`"
:value="item[header.value]"
>
<td
:class="[
header.align ? `text-${header.align}` : '',
header.cellClass || ''
]"
>
<v-btn
v-if="header.value === 'expand'"
size="x-small"
variant="text"
:icon=" expanded.includes(item[itemKey]) ? '$mdiChevronDown' : '$mdiChevronRight'"
@click="toggleExpand(item[itemKey])"
/>
<span v-else>{{ item[header.value] || '' }}</span>
</td>
</slot>
</tr>
<slot
v-if="singleExpand && expanded.includes(item[itemKey])"
:headers="computedHeaders"
:item="item"
name="expanded-item"
>
<tr>
<td :colspan="computedHeaders.length">
Expanded Slot
</td>
</tr>
</slot>
</template>
</template>
</template>
</template>
<template v-else>
<template
v-for="({ item }, ind) in computedItems"
:key="`tr-${ind}`"
>
<tr
:class="[
selectable ? 'selectable' : 'no-pointer',
item?.[itemKey] === modelValue?.[itemKey] ? 'selected' : ''
]"
@click="selectable ? $emit('update:modelValue', item?.[itemKey] === modelValue?.[itemKey] ? null : item) : null"
>
<slot
v-for="header in computedHeaders"
:key="`tr-${ind}-${header.value}`"
:item="item"
:name="`item-${header.value}`"
:value="item[header.value]"
>
<td
:class="[
header.align ? `text-${header.align}` : '',
header.cellClass || ''
]"
>
<v-btn
v-if="header.value === 'expand'"
size="x-small"
variant="text"
:icon=" expanded.includes(item[itemKey]) ? '$mdiChevronDown' : '$mdiChevronRight'"
@click="toggleExpand(item[itemKey])"
/>
<span v-else>{{ item[header.value] || '' }}</span>
</td>
</slot>
</tr>
<slot
v-if="singleExpand && expanded.includes(item[itemKey])"
:headers="computedHeaders"
:item="item"
name="expanded-item"
>
<tr>
<td :colspan="computedHeaders.length">
Expanded Slot
</td>
</tr>
</slot>
</template>
</template>
<tr v-if="!computedItems.length">
<td :colspan="computedHeaders.length">
<slot
v-if="search"
name="no-results"
>
<span class="text-center">No results found</span>
</slot>
<slot
v-else
name="no-data"
>
<span class="text-center">No data available</span>
</slot>
</td>
</tr>
</tbody>
</v-table>
</v-main>
</v-layout>
</template>
<style lang="sass">
.group-header
background-color: rgb(235, 235, 235) !important
.no-pointer
cursor: unset !important
.selected
background-color: rgba(0, 0, 0, 0.16) !important
.selectable, .sortable
cursor: pointer !important
.sticky-group
position: sticky
top: 48px
z-index: 1
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment