refactor(sort): replace modal with inline popup and improve UX

- Switch from Modal to lightweight Popup component
- Hide sort order picker when Manually (position) is selected
- Sort dropdown options alphabetically, keeping Manually first
- Add description text explaining sort behavior
This commit is contained in:
kolaente
2026-03-03 13:50:52 +01:00
parent 3b2a2fefe4
commit 7ba8e26a1c

View File

@@ -1,69 +1,74 @@
<template>
<XButton
variant="secondary"
icon="sort"
@click="() => modalOpen = true"
>
{{ $t('project.list.sort') }}
</XButton>
<Modal
:enabled="modalOpen"
transition-name="fade"
variant="hint-modal"
@close="() => modalOpen = false"
>
<div class="sort-popup">
<div class="field">
<label class="label">{{ $t('sorting.sortBy') }}</label>
<div class="select is-fullwidth">
<select v-model="sortField">
<option
v-for="o in options"
:key="o.value"
:value="o.value"
>
{{ o.label }}
</option>
</select>
<Popup>
<template #trigger="{toggle}">
<XButton
variant="secondary"
icon="sort"
@click.prevent.stop="toggle()"
>
{{ $t('project.list.sort') }}
</XButton>
</template>
<template #content="{close}">
<Card class="sort-popup">
<p class="sort-description has-text-grey is-size-7">
{{ $t('sorting.description') }}
</p>
<div class="field">
<label class="label">{{ $t('sorting.sortBy') }}</label>
<div class="select is-fullwidth">
<select v-model="sortField">
<option
v-for="o in options"
:key="o.value"
:value="o.value"
>
{{ o.label }}
</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">{{ $t('sorting.order') }}</label>
<div class="select is-fullwidth">
<select v-model="sortOrder">
<option value="asc">
{{ $t('sorting.asc') }}
</option>
<option value="desc">
{{ $t('sorting.desc') }}
</option>
</select>
<div
v-if="!isManualSort"
class="field"
>
<label class="label">{{ $t('sorting.order') }}</label>
<div class="select is-fullwidth">
<select v-model="sortOrder">
<option value="asc">
{{ $t('sorting.asc') }}
</option>
<option value="desc">
{{ $t('sorting.desc') }}
</option>
</select>
</div>
</div>
</div>
<div class="actions">
<XButton
variant="tertiary"
class="has-text-danger"
@click="modalOpen = false"
>
{{ $t('misc.cancel') }}
</XButton>
<XButton
variant="primary"
@click="applySort"
>
{{ $t('misc.doit') }}
</XButton>
</div>
</div>
</Modal>
<div class="actions">
<XButton
variant="tertiary"
@click="close()"
>
{{ $t('misc.cancel') }}
</XButton>
<XButton
variant="primary"
@click="applySort(close)"
>
{{ $t('misc.doit') }}
</XButton>
</div>
</Card>
</template>
</Popup>
</template>
<script setup lang="ts">
import {ref, watch} from 'vue'
import {ref, computed, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import XButton from '@/components/input/Button.vue'
import Modal from '@/components/misc/Modal.vue'
import Popup from '@/components/misc/Popup.vue'
import Card from '@/components/misc/Card.vue'
import type {SortBy} from '@/composables/useTaskList'
const props = defineProps<{ modelValue: SortBy }>()
@@ -71,50 +76,64 @@ const emit = defineEmits<{ 'update:modelValue': [value: SortBy] }>()
const {t} = useI18n({useScope: 'global'})
const modalOpen = ref(false)
const sortField = ref<string>('position')
const sortOrder = ref<'asc' | 'desc'>('asc')
const isManualSort = computed(() => sortField.value === 'position')
watch(() => props.modelValue, (val) => {
const key = Object.keys(val)[0] || 'position'
sortField.value = key
sortOrder.value = (val as SortBy)[key as keyof SortBy] ?? 'asc'
}, {immediate: true})
const options = [
{value: 'position', label: t('sorting.position')},
{value: 'title', label: t('task.attributes.title')},
{value: 'priority', label: t('task.attributes.priority')},
{value: 'due_date', label: t('task.attributes.dueDate')},
{value: 'start_date', label: t('task.attributes.startDate')},
{value: 'end_date', label: t('task.attributes.endDate')},
{value: 'percent_done', label: t('task.attributes.percentDone')},
{value: 'created', label: t('task.attributes.created')},
{value: 'updated', label: t('task.attributes.updated')},
]
const options = computed(() => {
const manualOption = {value: 'position', label: t('sorting.manually')}
const otherOptions = [
{value: 'title', label: t('task.attributes.title')},
{value: 'priority', label: t('task.attributes.priority')},
{value: 'due_date', label: t('task.attributes.dueDate')},
{value: 'start_date', label: t('task.attributes.startDate')},
{value: 'end_date', label: t('task.attributes.endDate')},
{value: 'percent_done', label: t('task.attributes.percentDone')},
{value: 'created', label: t('task.attributes.created')},
{value: 'updated', label: t('task.attributes.updated')},
].sort((a, b) => a.label.localeCompare(b.label))
function applySort() {
return [manualOption, ...otherOptions]
})
function applySort(close: () => void) {
const sort: SortBy = {} as SortBy
;(sort as Record<string, 'asc' | 'desc'>)[sortField.value] = sortOrder.value
const order = isManualSort.value ? 'asc' : sortOrder.value
;(sort as Record<string, 'asc' | 'desc'>)[sortField.value] = order
emit('update:modelValue', sort)
modalOpen.value = false
close()
}
</script>
<style scoped lang="scss">
.sort-popup {
display: flex;
flex-direction: column;
align-items: stretch;
margin: 0;
min-inline-size: 18rem;
.field {
margin-bottom: 1rem;
}
:deep(.card-content .content) {
display: flex;
flex-direction: column;
}
.actions {
display: flex;
justify-content: flex-end;
gap: .5rem;
}
.sort-description {
margin-block-end: 1rem;
}
.field {
margin-block-end: 1rem;
}
.actions {
display: flex;
justify-content: flex-end;
gap: .5rem;
}
}
</style>