<template> <ol ref="orderFieldListEl" class="order-filter tw-flex tw-flex-col tw-gap-3 tw-p-0 tw-m-0 tw-w-min group" > <li v-for="(sortOrders, sortField) in normalizedChoices" :key="sortField" class="tw-flex tw-gap-3 tw-items-center tw-transition-opacity group-hover:tw-opacity-100" :class="{ 'tw-opacity-50': !selectedFieldNames.includes(sortField) }" > <button type="button" class="btn tw-cursor-grab tw-p-1 -tw-ml-1" :class="{ 'tw-invisible': !selectedFieldNames.includes(sortField) }" tabindex="-1" data-drag-handle > <icon-system-uicons-drag-vertical /> </button> <label class="btn tw-my-0 -tw-ml-3 tw-mr-6 tw-whitespace-nowrap tw-gap-3 hocus:tw-bg-gray-100" > <input type="checkbox" :checked="selectedFieldNames.includes(sortField)" @input=" handleOrderSelection( sortField, null, ($event?.target as HTMLInputElement)?.checked === false, ) " /> <span>{{ t(`${translateBase}.${sortField}.name`) }}</span> </label> <RadioGroup class="tw-whitespace-nowrap tw-ml-auto" :name="sortField" :choices="sortOrders" :model-value="selectedOrder[sortField] ?? null" @update:model-value="handleOrderSelection(sortField, $event)" /> </li> </ol> </template> <script lang="ts" setup generic="T extends string"> import { useSortable } from '@vueuse/integrations/useSortable' import { computed, ref } from 'vue' import { useI18n } from '@/i18n' import RadioGroup from '@/components/generic/RadioGroup.vue' defineOptions({ compatConfig: { MODE: 3 } }) type OrderDirection = 'asc' | 'desc' type OrderField = { name: T; directions: OrderDirection[] } type Value = T | `-${T}` const modelValue = defineModel<Value[]>({ required: true }) const props = defineProps<{ choices: (T | OrderField)[] translateBase: string }>() const { t } = useI18n() const orderFieldListEl = ref<HTMLOListElement>() const orderFields = computed<OrderField[]>(() => { return props.choices.map((choice) => normalizeOrderField(choice)) }) const selectedFieldNames = computed( () => modelValue.value.map((value) => (value.startsWith('-') ? value.slice(1) : value)) as T[], ) const deselectedFieldNames = computed( () => orderFields.value .map((field) => field.name) .filter((fieldName) => !selectedFieldNames.value.includes(fieldName)) as T[], ) useSortable(orderFieldListEl, modelValue, { animation: 200, ghostClass: 'order-filter-drag-ghost', handle: '[data-drag-handle]', }) const normalizedChoices = computed(() => { const orderedFieldNames = [...selectedFieldNames.value, ...deselectedFieldNames.value] as T[] const orderedOrderFields = orderedFieldNames.map( (fieldName) => orderFields.value.find((field) => field.name === fieldName) as OrderField, ) return Object.fromEntries( orderedOrderFields.map((field) => { return [ field.name, field.directions.map((direction) => ({ value: direction, label: createChoiceLabel(field.name, direction), })), ] }), ) as Record<T, { value: OrderDirection; label: string }[]> }) const selectedOrder = computed( () => Object.fromEntries( modelValue.value.map((value) => { const field = (value.startsWith('-') ? value.slice(1) : value) as T return [field, value.startsWith('-') ? 'desc' : 'asc'] }), ) as Record<T, OrderDirection>, ) function handleOrderSelection(fieldName: T, direction: OrderDirection | null, forceOff = false) { const currentValue = [...modelValue.value] const newSortDirection = direction ?? orderFields.value.find((f) => f.name === fieldName)?.directions[0] ?? 'asc' const newSortField: Value = newSortDirection === 'asc' ? fieldName : `-${fieldName}` let includedFieldName: Value | null = null if (currentValue.includes(fieldName)) { includedFieldName = fieldName } else if (currentValue.includes(`-${fieldName}`)) { includedFieldName = `-${fieldName}` } if (includedFieldName === null) { // Field is currently not active as sorting parameter. Activating now. currentValue.push(newSortField) } else { // Field is currently active as sorting parameter. // The field may be active, but the sort direction might have changed. // Check if we have to replace the current sorting field or have to remove it. const replaceValue = includedFieldName !== newSortField && !forceOff ? [newSortField] : [] currentValue.splice(currentValue.indexOf(includedFieldName), 1, ...replaceValue) } modelValue.value = currentValue } function createChoiceLabel(fieldName: T, direction: OrderDirection) { if (direction === 'asc') return t(`${props.translateBase}.${fieldName}.directions.asc`) if (direction === 'desc') return t(`${props.translateBase}.${fieldName}.directions.desc`) } function normalizeOrderField(fieldName: T | OrderField): OrderField { if (typeof fieldName === 'string') return { name: fieldName, directions: ['asc', 'desc'] } else return fieldName } </script> <style scoped lang="postcss"> .order-filter-drag-ghost { @apply tw-rounded; @apply tw-opacity-40; @apply tw-border-2; @apply tw-border-solid; @apply tw-border-aura-primary; } .order-filter :deep([role='radiogroup'] .btn) { @apply tw-min-w-[90px]; } </style>